본문 바로가기
임베디드/STM32

7. [STM32] GPIO - Peripheral Register 1

by fuhehe 2024. 7. 26.

이번 시간에는 앞에서 만든 GPIOTest 프로젝트에서 사용된 HAL Library 의 소스를 살펴볼 것이다.

사실 소스를 보면 대부분의 루틴은 Peripheral Register를 엑세스하는 일이다. 따라서 이번장에서는 HAL Library의 내부 코딩을 살펴보는 것이지만 실제로는 GPIO의 레지스터의 구성을 알아보는 것이다.

단순히 라이브러리로 제공되는 함수만 기계적으로 쓸게 아니라 내부가 어떻게 구성되어 있는지 보는것도 의미가 있을 것이다. 다행히 대부분의 소스는 우리가 확인할 수 있다.

 

  • 본 블로그는 STM32를 소프트웨어 엔지니어 관점에서 바라본 블로그입니다. 따라서 회로등의 전자공학 관련 내용은 사실과 다를 수 있습니다.
  • 본 블로그에서 사용된 MCU 및 개발보드는 다음과 같습니다.
    1. NUCLEO-F429ZI (STM32F429ZIT LQFP144)
    2. NUCLEO-F439ZI (STM32F439ZIT LQFP144)

 

 

 

1. 준비물

RM0090 Reference Manual이 필요하다. 이전 글에 링크를 올렸지만 안받았다면 아래 링크에서 다운로드 받자.

<RM0090 Reference Manual>

 

소스는 이전 시간에 작성한 GPIOTest를 그대로 사용한다.

 

 

2. STM32의 Peripheral 레지스터

먼저 GPIOTest프로젝트에 쓰인 HAL_GPIO_ReadPin()에 대해 살펴보자. 함수 프로토타입은 아래와 같다.

GPIO_PinState HAL_GPIO_ReadPin(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)

첫번째 파라미터는 GPIO_TypeDef라는 구조체의 포인터이고 두번째 파라미터는 단순 uint16_t형인 핀번호이다.

리턴값은 GPIO_PinState라는 열거형이다.

 

먼저 첫번째 파라미터로 넘긴 GPIOC의 정의를 따라가보자. Ctrl키를 누른 상태에서 GPIOC를 클릭하면 stm32f429xx.h파일에 정의된 GPIOC의 define을 볼 수 있다. GPIOC는 GPIOC_BASE라는 또다른 define을 GPIO_TypeDef형 포인터로 강제 형변환을 하고 있다. 이걸 차례대로 거슬러 올라가 보면 다음과 같은 순서로 define되어 있는걸 볼 수 있다.

#define PERIPH_BASE           0x40000000UL /*!< Peripheral base address in the alias region                                */
#define AHB1PERIPH_BASE       (PERIPH_BASE + 0x00020000UL)
#define GPIOC_BASE            (AHB1PERIPH_BASE + 0x0800UL)
#define GPIOC               ((GPIO_TypeDef *) GPIOC_BASE)

 

결국 GPIOC_BASE는 0x4000 0000 + 0x0002 0000 + 0x0000 0800 = 0x4002 0800의 주소값이다.

이 주소는 아래 Reference Manual의 65p와 같이 선형메모리의 GPIOC Peripheral 레지스터 시작 주소라는걸 알 수 있다.

[GPIO 레지스터 메모리]

 

Entry Point장에서도 설명했지만 ARM Cortex-M4는 각 종류별 메모리를 하나의 선형 메모리로 관리한다. 여기에는 GPIO, UART, I²2 등의 Peripheral의 각 레지스터도 포함된다.

이 Peripheral 레지스터는 Entry Point에서 설명한 MCU의 Core 레지스터, 즉 r0, r1,.., pc, sp등의 레지스터와는 다르다.

우리는 각 장치를 제어하기 위해서는 해당 장치에 해당하는 Peripheral 레지스터(이하 별도의 설명이 없는한 레지스터는 Peripheral 레지스터를 의미한다.)를 조작해야 한다.

GPIO도 마찬가지다. 핀 상태를 설정하거나 읽기 위해서는 레지스터를 조작해야 한다. 또한 각 GPIO가 어떻게 동작할지 설정하는것도 레지스터를 통해서다.

우리가 CubeMX를 통해 각 GPIO 핀의 입출력, Pull-up/Pull-down등을 설정하는 것도 결국은 레지스터를 어떻게 설정할 것인지를 설정하는 것이다.

 

즉, 우리가 S/W적으로 MCU를 제어한다는건 레지스터에 값을 읽거나 쓴다는 의미와 동일하다. 그러므로 우리는 해당 Peripheral의 레지스터가 선형메모리의 어느 주소에 있는지를 먼저 파악해야 한다.

 

다시 위의 define문을 보자.

그냥 GPIOC의 정의같은 경우

#define GPIOC ((GPIO_Typedef*)0x40020800)

와 같이 한번에 정의하면 될텐데 굳이 저렇게 몇단계를 걸쳐서 정의를 해놓은 것일까?

이걸 이해하려면 각 Peripheral별 레지스터가 어떻게 구성이 되어 있는지를 이해해야 한다. 아래 그림은 데이터시트의 20p에 나와 있는 블럭 다이어그램의 일부이다. 

[STM32F429xx Block Diagram]

 

각 Peripheral들은 각각 Clock속도가 다른 버스에 연결되어 있고 현재 우리가 보고 있는 GPIOC의 경우 최대 180Mhz속도의 AHB1버스에 연결된 걸 볼 수 있다.

그리고 이전 Entry Point장에서 본적이 있는 데이터시트 86p의 메모리맵 왼쪽을 보면 모든 Peripheral 레지스터의 시작주소 는0x4000 0000이고 그중 AHB1의 시작주소는 오른쪽과 같이 0x4002 0000이라는 사실을 확인할 수 있다.

[Memory Map]

 

 

따라서 위의 define정의의 의미는

모든 Peripheral 레지스터의 시작주소는 0x4000 0000, 그중 AHB1 버스에 연결된 Peripheral의 시작주소는 전체 Peripheral레지스터의 시작주소인 0x4000 0000에서 0x0002 0000 만큼의 Offset값을 가지는 위치에 존재한다는걸 구조적으로 나타내기 위해서이다.

참고로 TIM1의 경우 블럭 다이어그램을 보면 왼쪽아래의 APB2 버스에 연결되어 있고 stm32f429xx.h파일에 이 주소는 APB2PERIPH_BASE define문에 (PERIPH_BASE + 0x0001 0000) 으로 정의되어 있다.

Reference Manual 66p에 보면 TIM1의 시작 주소가 나오는데 0x4001 0000부터라는걸 알 수 있다.

/*!< Peripheral memory map */
#define APB1PERIPH_BASE       PERIPH_BASE
#define APB2PERIPH_BASE       (PERIPH_BASE + 0x00010000UL)
#define AHB1PERIPH_BASE       (PERIPH_BASE + 0x00020000UL)
#define AHB2PERIPH_BASE       (PERIPH_BASE + 0x10000000UL)

 

다시 우리가 보고 있는 GPIOC로 돌아가서 정리를 해보면

  • 모든 Peripheral의 시작주소는 0x4000 0000
  • AHB1버스에 연결된 Peripheral의 시작주소는 0x4000 0000 에서 0x0002 0000만큼 떨어진 위치
  • GPIOC의 시작주소는 (0x4000 0000 + 0x0002 0000)에서 0x0000 0800만큼 떨어진 위치

를 구조적으로 나타낸 것이다.

 

따라서 우리는 GPIOC뿐 아니라 다른 장치의 레지스터 위치의 정의를 찾으려면 stm32f429xx.h 파일에서 찾아보거나 Reference Manual을 살펴보면 된다.

 

그 다음으로 GPIOC의 define문중 제일 밑에 있던 실제 GPIOC의 define문을 보자.

#define GPIOC ((GPIO_Typedef*)GPIOC_BASE)

라고 되어 있다.

즉, 0x4002 0800 의 주소값 리터럴이 GPIO_Typedef구조체 포인터형으로 강제 형변환 된다. 그럼 GPIO_Typedef 구조체 형식을 보자. 이 형식의 정의 또한 stm32f429xx.h 파일에 정의되어 있다. (강제 형변환하는 의미를 모르겠다면 제일 아래부분에 따로 설명을 해놨으므로 참조하라)

typedef struct
{
  __IO uint32_t MODER;    /*!< GPIO port mode register,               Address offset: 0x00      */
  __IO uint32_t OTYPER;   /*!< GPIO port output type register,        Address offset: 0x04      */
  __IO uint32_t OSPEEDR;  /*!< GPIO port output speed register,       Address offset: 0x08      */
  __IO uint32_t PUPDR;    /*!< GPIO port pull-up/pull-down register,  Address offset: 0x0C      */
  __IO uint32_t IDR;      /*!< GPIO port input data register,         Address offset: 0x10      */
  __IO uint32_t ODR;      /*!< GPIO port output data register,        Address offset: 0x14      */
  __IO uint32_t BSRR;     /*!< GPIO port bit set/reset register,      Address offset: 0x18      */
  __IO uint32_t LCKR;     /*!< GPIO port configuration lock register, Address offset: 0x1C      */
  __IO uint32_t AFR[2];   /*!< GPIO alternate function registers,     Address offset: 0x20-0x24 */
} GPIO_TypeDef;

 

전부 uint32_t형식의 멤버들로 구성되어 있고 앞에 __IO가 붙어있다.

__IO는 /Driver/CMSIS/Include/core_cm4.h 파일에 

#define __IO volatile

과 같이 정의되어 있는데 volatile은 컴파일러에게 이 변수에 대해서는 최적화 작업을 하지 말라고 지시하는 C 명령어이다.

요즘의 컴파일러들은 우리 생각보다 똑똑해서 자신이 의미없다고 판단하는 구문은 최적화 작업을 수행한다.

int n = 0;
int result = 0;

n = 10;
n = 20;
n = 30;

result = n + 100;

이 소스를 보면 n에 여러번의 대입문이 사용되었다. 이런 경우 컴파일러는 최종 n = 30;에 대해서만 컴파일하고 나머지는 버린다. 자신이 봤을 때 의미가 없기 때문이다.

하지만 레지스터 조작때는 이렇게 같은 레지스터에 값을 대입하는게 의미가 있을 수 있다. 이런 경우 프로그래머가 의도적으로 동일 레지스터에 동일 값을 넣을 경우 컴파일러에게 이 부분은 내가 의도적으로 작성한 것이므로 니가 임의로 최적화 작업을 하지 말라는 의미다.

 

다시 구조체로 돌아가자.

동일 형식으로 MODER, OTYPER, OSPEEDR등의 이름으로 정의된 이름이 나열된다. 이 이름들이 실제 GPIO의 각 레지스터 이름이다. ARM Cortex-M4의 경우 32bit 프로세서이므로 각 레지스터의 길이도 32bit이다. 따라서 모든 멤버의 형식이 uint32_t로 설정되어 있다.

 

이들 각 레지스터의 의미는 Reference Manual에 설명이 잘 되어 있다.

먼저 특정 Peripheral의 주소를 찾으려면 위에서 설명한 방법대로 찾거나 Reference Manual의 64p부터 시작되는 Table에서 해당 장치의 시작주소를 찾으면 된다.

그리고 각 Peripheral의 개별 레지스터는 목록에서 해당 Peripheral을 찾아 이동하면 되는데 GPIO의 경우 270p에 내용이 시작되고 우리가 보고자 하는 레지스터는 284p부터 나온다.

284p부터 보면 우리가 봤던 구조체 멤버의 순서에 맞게 GPIOx_MODER, GPIOx_OTYPER, GPIOx_OSPEEDR... 가 나오는데 각 레지스터 설명의 제일 윗부분에 Address offset값이 나온다.

이게 64p에서 찾은 각 장치의 시작주소에서 얼마나 떨어져 있는가를 표시하는 값이다.

GPIOx_MODER의 경우 오프셋값이 0x00이므로 해당 GPIO의 시작주소와 동일하고 GPIOx_OTYPER의 경우 0x04이므로 GPIOx_MODER부터 4바이트 이후에 위치한다. 즉 각 레지스터는 시작주소부터 4Byte의 크기로 일정하게 위치해 있다.

따라서 GPIOx의 x부분에 우리가 원하는 포트를 찾아 시작주소를 알아내고 이 페이지에서 각 오프셋 값을 적용하면 해당 레지스터의 주소를 알 수 있다.

우리가 보고 있는 GPIOC의 주소는 0x4002 0800라는걸 알았고 여기서 GPIOC_MODER레지스터를 엑세스하려면 오프셋이 0x00이므로 그냥 시작주소를 그대로 사용하면 된다. GPIOC_OTYPER을 엑세스 하려면 0x4002 0800 + 0x04이므로 0x4002 0804를 엑세스 하면 된다.

(물론 현재 소스에서는 GPIO_TypeDef형 구조체로 강제 형변환되어 있어 각 주소값을 사용하지 않아도 구조체 멤버로 엑세스가 가능하다.)

 

처음에 우리는 HAL_GPIO_ReadPin()함수에 대해 보고 있었다. 다시 이 함수내부로 돌아가자. 함수에서 Ctrl키를 누르고 마우스 우클릭하면 해당 함수의 구현부분으로 갈 수 있다.

GPIO_PinState HAL_GPIO_ReadPin(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
  GPIO_PinState bitstatus;

  /* Check the parameters */
  assert_param(IS_GPIO_PIN(GPIO_Pin));

  if((GPIOx->IDR & GPIO_Pin) != (uint32_t)GPIO_PIN_RESET)
  {
    bitstatus = GPIO_PIN_SET;
  }
  else
  {
    bitstatus = GPIO_PIN_RESET;
  }
  return bitstatus;
}

 

먼저 GPIO_PinState라는 열거형 변수를 하나 정의했다.

이 열거형은 단순히

typedef enum
{
  GPIO_PIN_RESET = 0,
  GPIO_PIN_SET
}GPIO_PinState;

이렇게 정의되어 있는데 Low상태를 GPIO_PIN_RESET = 0, High상태를 GPIO_PIN_SET = 1로 정의하고 있다.

그 다음으로 assert_param()호출이 나오는데 이건 밑에서 설명하기로 하고 레지스터 엑세스부터 보기로 하자.

 

if문에서 GPIO_Typedef구조체의 IDR, 즉 GPIOC_IDR 레지스터를 파라미터 GPIO_Pin과 AND연산을 수행해서 현재 핀의 Low/High상태를 알아낸다.

Reference Manual 286p에서 IDR레지스터 내용을 보자.

[GPIOx_IDR]

설명과 같이 이 레지스터는 Input Pin의 현재 상태를 가지고 있는 읽기전용 레지스터이다. 각 GPIO포트별로 16개의 Pin갯수를 가지므로 하위 16개 비트만 사용하며 상위 16비트는 사용하지 않는다. 하위 각 비트 순서는 Pin번호와 1:1대응한다.

 

우리는 HAL_GPIO_ReadPin(GPIOC, GPIO_PIN13)으로 NUCLEO보드의 User Button의 입력을 체크하고 있으므로 GPIO_Pin파라미터로 GPIO_PIN_13을 전달했다.

GPIO_PIN_13은 stm32f4xx_hal_gpio.h파일에 다음과 같이 정의되어 있다.( 0x2000는 이진수로 0b0010 0000 0000 0000 이므로 13번 bit를 의미한다.)

#define GPIO_PIN_13                ((uint16_t)0x2000)  /* Pin 13 selected   */

 

따라서 함수내부의 if문 조건은

IDR레지스터의 현재 상태와 0x2000의 AND연산 결과를 체크한다. 0x2000은 이진수로 0b0010 0000 0000 0000이므로 13번 비트만 1로 설정해 다른값은 무시하고 해당 비트 상태만 확인하겠다는 의미다.

이때 AND연산된 값은 비트 위치에 따라 숫자가 달라지므로 단순히 0인지 체크하고 0이면 RESET상태, 0이 아닌 다른 값이면 SET상태로 판단한다.

즉, HAL_GPIO_ReadPin()함수는 입력 파라미터에 따라 GPIOx_IDR 레지스터의 n번째 비트값만 체크해서 상태를 리턴하도록 되어 있다.

 

이번에는 HAL_GPIO_WritePin()함수를 살펴보자. 프로토타입은 아래와 같다.

void HAL_GPIO_WritePin(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin, GPIO_PinState PinState)

HAL_GPIO_ReadPin()함수와 유사하지만 리턴값은 없고 변경할 상태값으로 GPIO_PinState 열거형 파라미터를 하나 더 받는다.

함수의 구현은 아래와 같다.

void HAL_GPIO_WritePin(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin, GPIO_PinState PinState)
{
  /* Check the parameters */
  assert_param(IS_GPIO_PIN(GPIO_Pin));
  assert_param(IS_GPIO_PIN_ACTION(PinState));

  if(PinState != GPIO_PIN_RESET)
  {
    GPIOx->BSRR = GPIO_Pin;
  }
  else
  {
    GPIOx->BSRR = (uint32_t)GPIO_Pin << 16U;
  }
}

 

HAL_GPIO_ReadPin()과 유사한 구조이지만 엑세스하는 레지스터가 다르다.

이번에는 BSRR 레지스터를 엑세스하고 있다. 사실 GPIO의 Output Pin의 상태값은 GPIOx_ODR레지스터를 통해 읽고 쓸 수 있다. 하지만 이 함수에서는 BSRR레지스터를 엑세스하는데 먼저 ODR레지스터 정의부터 보자.

[GPIOx_ODR]

설명에 이 레지스터는 해당 포트의 16개 Pin별 현재 출력값을 가지고 있고 읽기/쓰기 모두 가능하다고 나와있다.

하지만 아래 Note부분에 특정 개별 bit를 설정하기 위해서는GPIOx_BSRR 레지스터를 통해 설정할 수 있다고 나와있다.

 

[GPIOx_BSRR]

BSRR 레지스터는 특정 포트의 16개 Pin에 각각 개별로 Low/High 설정을 할 수 있도록 되어 있는데 Reset설정을 하려면 상위 16개비트 즉 [31:16]중 해당 Pin에 해당하는 bit만 1 설정하면 해당 bit의 ODR레지스터는 Reset된다. 설명에서 나와 있는데로 0으로 설정된 비트는 아무런 작업을 하지 않는다.

그리고 하위 16개비트 즉 [15:0]은 ODR레지스터를 Set하는데 사용된다.

이 레지스터는 쓰기 전용이며 읽었을때 무조건 0을 리턴하도록 되어 있으므로 읽는 용도로 사용하지 않는다.

 

소스로 돌아가자. 

HAL_GPIO_WritePin()에 GPIOB와 GPIO_PIN_0를 넘겼으므로 HAL_GPIO_WritePin()에서는 PinState파라미터 값에 따라

● PinState가 GPIO_PIN_SET인 경우

   BSRR레지스터에 해당 GPIO_PIN_0 (0x0001)을 그대로 쓴다. 이는 이진수로 0b0000 0000 0000 0001이므로 0번 비트를 1로 설정하게 되어 결국 ODR레지스터의 0번 bit를 1로 설정하겠다는 의미이다.

● PinState가 GPIO_PIN_RESET인 경우

   상위 16개 bit중 해당 Pin에 맞는 비트를 1로 설정하면 되는데 16번 비트부터 시작이 되므로 입력된 GPIO_PIN_0(0x0001)을 왼쪽으로 16bit만큼 Shift시켜 입력하도록 코딩이 되어 있다.

 

레지스터의 용도만 알고 있다면 별로 어려울게 없는 내용이다.

그런데 여기서 의문점은 Output Pin에 출력을 할때 ODR레지스터에다 바로 쓰지 않고 왜 BSRR레지스터라는걸 별도로 만들어서 세팅하도록 했을까?

사실 ODR레지스터는 읽기/쓰기 모두 가능하므로 ODR레지스터를 바로 엑세스해도 된다. 하지만 ODR레지스터를 조작하기 위해서는 다음과 같이 코딩을 해야하는데 C구문으로 보면 Set/Reset이 각각 한줄로 코딩되어 하나의 명령어 처럼 보이지만 이게 어셈블리어나 기계어로는 하나의 명령이 아니다. 

  if(PinState == GPIO_PIN_SET)
  {	  
	  GPIOx->ODR = GPIOx->ODR | GPIO_Pin;
  }
  else
  {
	  GPIOx->ODR = GPIOx->ODR & ~GPIO_Pin;
  }

즉 ODR을 먼저 읽는 작업이 선행되고 다시 그 값을 수정하는 작업과 그 값을 다시 ODR에 전체 쓰기를 해야하는 작업으로 구분된다.

만약 이게 main함수의 while문에서 호출되고 있고 다른 인터럽트 핸들러 루틴등에서도 Output Pin을 설정하는 루틴이 있거나 RTOS상에서 멀티쓰레드 방식으로 돌아가는 프로그램이라면 값을 읽는 중에 다른 핸들러나 쓰레드에 의해 ODR값이 바뀔 수 있다. 이렇게 되면 ODR을 읽고 기존값을 변경한 뒤 다시 ODR에 저장하려는 순간 다른쪽에서 ODR값을 수정했다면 이 수정된 값은 이전값으로 돌아가 버린다.

다음 상황을 보자. main의 while문안에서의 ODR 엑세스와 인터럽트 핸들러 루틴에서의 ODR 엑세스가 동시에 발생했다고 가정하면

  • 먼저 while문의에서 ODR을 읽음.
  • 이때 인터럽트가 발생해에서 ODR의 3번 bit에 값을 씀.
  • while문에서 첫번째 읽은 ODR값에 0번 비트값을 1로 설정해 다시 ODR에 씀.

이 상황에서는 2번째, 인터럽트에 의해 기록된 3번 비트 값은 3번째에 작업에 의해 반영이 안될 수 있다.

 

따라서 읽는 과정과 쓰는 과정이 단일 임계영역으로 지정되어야 하는데 이런 과정을 거치지 않고도 MCU에서 H/W적으로 특정비트만 제어가 가능하도록 해놓은 것이 BSRR레지스터이다.

 

HAL Library함수중 Read/Write이외에도 HAL_GPIO_TogglePin()함수도 있다. 이 함수는 각자 분석해 보기 바란다. 어차피 ODR레지스터와 BSRR레지스터를 엑세스하는게 전부다.

 

 

3. GPIO_Typedef 구조체 포인터

이번에는 미뤄뒀던 define문에서 최종 GPIOC의 정의가 (GPIO_TypeDef*)GPIOC_BASE와 같이 강제 형변환 되어 있는걸 살펴보자.

사실 C에 익숙한 사람들은 보면 알겠지만 모르는 사람들을 위해 설명을 덧붙인다.

위에서 설명한 바와 같이 GPIOC_BASE는 0x40020800 로 정의된 리터럴값이다. 이 리터럴값을 GPIO_TypeDef형 포인터로 강제 형변환을 했으므로 이 구문은 다음과 동일한 의미가 된다.

GPIO_TypeDef* pGPIOC = (GPIO_TypeDef*)GPIOC_BASE;

포인터 변수는 값으로 주소값을 가지고 있으므로 당연히 디버거로 돌려서 pGPIOC 변수를 확인해보면 0x4002 0800이라는 주소값을 가지고 있을 것이다.

 

그렇다면 pGPIOC 포인터 변수의 크기는 얼마일까? 

GPIO_TypeDef구조체의 크기일까? 아니다. 4바이트이다. ARM Cortex-M4 MCU는 32비트 프로세서이므로 포인터 변수 역시 32비트 크기로 4바이트이다.

GPIO_TypeDef* pGPIOC = (GPIO_TypeDef*)GPIOC_BASE;
int n = sizeof(pGPIOC);

이렇게 sizeof연산자를 사용해 코딩 한 뒤 n의 값을 보면 4라고 나온다.

 

그럼 아래 소스를 보고 답해보자.

GPIO_TypeDef* pGPIO = NULL;
char* pChar = NULL;
int* pInt = NULL;
float* pFloat = NULL;
double* pDouble = NULL;

int nGPIO = sizeof(pGPIO);
int nChar = sizeof(pChar);
int nInt = sizeof(pInt);
int nFloat = sizeof(pFloat);
int nDouble = sizeof(pDouble);

위의 소스에서 nGPIO, nChar, nInt, nFloat, nDouble의 값은 각각 얼마일까?

 

모두 4이다. 즉 포인터 변수는 형식이 뭐든 간에 동일한 크기를 가진다. 당연한것이 포인터 변수에 저장되는 값은 오로지 주소값이기 때문이다. 따라서 프로세서가 16비트이면 2바이트, 32비트이면 4바이트 그리고 64비트이면 8바이트의 크기를 가진다.

그럼 전부 동일한 크기를 가지는데 왜 변수 형식별로 포인터를 선언하는 것일까?

이유는 포인터 변수가 가리키고 있는 주소의 범위를 정하기 위해서다.

GPIO_TypeDef* pGPIO = NULL;
char* pChar = NULL;
int* pInt = NULL;
float* pFloat = NULL;
double* pDouble = NULL;

pGPIO++;			// 값 : 0x28(40)
pChar++;			// 값 : 0x1(1)
pInt++;				// 값 : 0x4(4)
pFloat++;			// 값 : 0x4(4)
pDouble++;			// 값 : 0x8(8)

위 소스와 같이 포인터 변수를 NULL즉 0으로 초기화한 뒤 각각 Increment연산자로 1증가시킨 뒤 해당 변수의 값들을 보면 주석과 같이 증가하는걸 볼 수 있다.

즉, double형 포인터 변수의 경우 한번에 8바이트 단위로 엑세스를 하겠다고 정의하는 것이다.

그럼 char* 형에 int형 변수의 주소를 대입해도 될까? 상관없다.

아래 소스를 보자.

int n = 0;
char* pChar = &n;

// 0x0000 03E8 = 1000
*(pChar + 0) = 0xE8;
*(pChar + 1) = 0x03;
*(pChar + 2) = 0x00;
*(pChar + 3) = 0x00;

pChar포인터에 int형 변수 n의 주소값을 대입하고 첫번째 바이트부터 값을 대입한 뒤 n을 확인해보면 1000이라는 값이 저장되어 있다.

char형 포인터는 주소를 1바이트씩 엑세스한다. 따라서 주소를 1씩 증가시키면 시작주소부터 1바이트씩 증가하게 된다.

따라서 1000이라는 숫자를 16진수로 변환한 뒤 Little Endian방식으로 뒤쪽부터 한바이트씩 쓰면 int형의 4바이트를 엑세스할 수도 있다.

포인터 변수를 int형으로 하면 그냥 *pInt  = 0x03E8; 이라고 하면 되기 때문에 굳이 char형의 포인터로 선언하지 않을 뿐이다. 

만약 int형 변수를 바이트 단위로 쪼개서 관리해야 한다면 위와같이 char형 포인터를 써서 엑세스 해도 된다.(물론 이런경우에는 GPIO_TypeDef와 같이 구조체로 관리하는게 낫다)

 

다시 GPIO_TypeDef 형 포인터로 돌아가자.

0x4002 0800이라는 리터럴값을 GPIO_TypeDef형 포인터로 강제 형변환 한다는 건 이 주소값을 시작번지로 해서 GPIO_TypeDef형의 크기만큼 한꺼번에 엑세스하겠다는 의미다.

GPIO_TypeDef형은 uint32_t형의 멤버 8개와 uint32_t형식의 두개 원소를 가지는 배열하나로 구성되어 있어 총 40바이트의 크기이다.

이 말은 GPIO_TypeDef형 포인터는 값을 1증가시키면 40바이트씩 증가한다는 말이다. 최초 0x4002 0800값이었고 Increment연산자로 1증가시키면 0x4002 0828의 값을 가진다.

 

어쨌든 리터럴값으로 된 주소값을 GPIO_TypeDef형의 포인터로 강제 형변환 했으니 이제부터는 GPIO_TypeDef구조체 형식으로 이 주소값을 엑세스 할 수 있게 되는 것이다.

 

앞에서 봤던 HAL_GPIO_ReadPin을 아래와 같이 바꿔도 문제가 없다. 아니 사실 동일한 코드라고 봐도 무방하다.

uint32_t* pInt32 = (uint32_t*)0x40020800;		// GPIOC_BASE
uint32_t nIDR = *(pInt32 + 0x4);	// uint32_t이므로 IDR의 Offset값이 0x10=16이 아니라 4증가한 위치가 됨.
GPIO_PinState state = ((nIDR & GPIO_PIN_13) != (uint32_t)GPIO_PIN_RESET) ? GPIO_PIN_SET : GPIO_PIN_RESET;

HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, state);

다만 이렇게 할 경우 코딩중에 숫자를 잘못 써서 버그가 생길 가능성이 증가하고 가독성이 떨어진다는 단점이 있다.

 

4. Assertion

이번에는 HAL_GPIO_ReadPin, HAL_GPIO_WritePin함수 내부를 볼때 나왔던 assert_param이라는 구문에 대해 살펴보자.

이런 assert류의 구문은 통상 어떤 변수의 값을 보증받기 위해 사용한다. 우리말로는 가정 설정문이라고 하는데 쉽게 말하면 절대로 대입되어서는 안되는 값이 들어왔을때 프로그램이 자체적으로 개발자에게 이를 알려주게 하는 기법이다.

말로만 해서는 이해가 잘 되지 않으므로 소스를 보자.

우선 HAL_GPIO_ReadPin에 있는 assert_param을 보면(/Core/Inc/stm32f4xx_hal_conf.h 파일에 정의되어 있다.)

/* Exported macro ------------------------------------------------------------*/
#ifdef  USE_FULL_ASSERT
/**
  * @brief  The assert_param macro is used for function's parameters check.
  * @param  expr If expr is false, it calls assert_failed function
  *         which reports the name of the source file and the source
  *         line number of the call that failed.
  *         If expr is true, it returns no value.
  * @retval None
  */
  #define assert_param(expr) ((expr) ? (void)0U : assert_failed((uint8_t *)__FILE__, __LINE__))
/* Exported functions ------------------------------------------------------- */
  void assert_failed(uint8_t* file, uint32_t line);
#else
  #define assert_param(expr) ((void)0U)
#endif /* USE_FULL_ASSERT */

#ifdef 프리컴파일 명령어에 의해 USE_FULL_ASSERT가 define되어 있다면 위쪽 구문을 그렇지 않으면 아래쪽 구문을 사용한다.

아래쪽 구문은 ((void)0U)로 아무런 처리를 하지 않도록 되어 있고 위쪽 구문은 입력값 expr이 0이 아닐 경우 아무런 처리를 하지 않고 0일 경우 assert_failed()함수를 호출한다.

assert_failed()함수의 프로토타입이 바로 아래 정의되어 있는데 이 함수의 실제 구현부는 main.c 제일 아래부분에 정의되어 있다.

#ifdef  USE_FULL_ASSERT
/**
  * @brief  Reports the name of the source file and the source line number
  *         where the assert_param error has occurred.
  * @param  file: pointer to the source file name
  * @param  line: assert_param error line source number
  * @retval None
  */
void assert_failed(uint8_t *file, uint32_t line)
{
  /* USER CODE BEGIN 6 */
  /* User can add his own implementation to report the file name and line number,
     ex: printf("Wrong parameters value: file %s on line %d\r\n", file, line) */
  /* USER CODE END 6 */
}
#endif /* USE_FULL_ASSERT */

보는 바와 같이 실제 아무런 코드도 작성되어 있지 않다.

 

USE_FULL_ASSERT가 정의되어 있다는 가정하에 assert_param매크로는 asset_failed() 함수를 호출하는데 이때 파라미터로 __FILE__, __LINE__매크로를 넘겨준다. 이 매크로는 대부분의 C컴파일러에 포함된 매크로로 해당 파일의 파일명과 소스의 라인위치를 지정한다.

 

현재는 assert_failed()함수에 아무런 코드도 입력이 되어 있지 않지만 내부 주석처럼 file, line파라미터를 어떤 방식으로 문자열로 만들어 사용할 수 있다. 여기에 UART등의 통신으로 fail내용을 보낼 수도 있는것이다.

 

그럼 이런 assert를 쓰지않고 HAL_GPIO_ReadPin() 함수 내부에서 if문으로 체크를 하면 안될까?

안될껀 없지만 실제 이 함수에 0이 들어왔을 경우 프로그래머가 이 상황을 알 수가 없다. 만에 하나 실행중에 0이 들어오는 상황을 확인할 수 있다면 디버깅이 수월해 질 것이다.

 

다시 HAL_GPIO_ReadPin으로 돌아와서 assert_param호출부분을 보자.

  /* Check the parameters */
  assert_param(IS_GPIO_PIN(GPIO_Pin));

IS_GPIO_PIN이라는 또다른 매크로에 입력받은 GPIO_Pin을 넘기고 있다.

 

#define GPIO_PIN_MASK              0x0000FFFFU /* PIN mask for assert test */

#define IS_GPIO_PIN(PIN)        (((((uint32_t)PIN) & GPIO_PIN_MASK ) != 0x00U) && ((((uint32_t)PIN) & ~GPIO_PIN_MASK) == 0x00U))

IS_GPIO_PIN 매크로는 위와 같이 정의되어 있는데 괄호가 많아서 좀 복잡하게 보인다. 

구문의 역할만 설명을 하자면 입력된 GPIO_Pin은 0~15까지의 값만 가져야 한다. IDR레지스터에서 보듯 상위 [31:16]은 Reserved로 사용되지 않기 때문이다.

그래서 이 구문은 입력된 핀번호가 하위 [15:0] 중 하나인지를 체크하는 구문이다.

즉, (입력핀이 하위 핀 && 입력핀이 상위핀이 아님) 이면 True, 아니면 False로 판정한다.

 

main.c의 while문 안에 다음과 같이 코드를 추가한 뒤 동일 파일의 assert_failed()함수에도 코드를 추가하자. 이 함수내부에는 UART통신 구문이 들어가도 상관없다. 

그리고 이 부분에 브레이크 포인트를 걸고 assert_failed()가 호출될 때를 잡아보자.

// main.c의 while문 안에 추가
HAL_GPIO_WritePin(GPIOB, 0x00010000, state);



void assert_failed(uint8_t *file, uint32_t line)
{
  /* USER CODE BEGIN 6 */
  /* User can add his own implementation to report the file name and line number,
     ex: printf("Wrong parameters value: file %s on line %d\r\n", file, line) */
	HAL_GPIO_WritePin(GPIOD, GPIO_PIN_2, GPIO_PIN_SET);
  /* USER CODE END 6 */
}
#endif /* USE_FULL_ASSERT */

HAL_GPIO_WritePin()의 두번째 파라미터에 16번 핀을 지정해서 강제로 assert_failed()함수가 호출되게 하려는 것이다.

 

실행해보면 의도와는 달리 assert_failed()함수는 호출되지 않는다.

왜냐하면 assert_param매크로의 정의에서 보듯 USE_FULL_ASSERT 가 define되어 있어야 하기 때문이다.

정의하는 방법은 두가지이다.

하나는 소스상에 이걸 define해주면 된다.

assert_param 매크로가 있는 /Core/Inc/stm32f4xx_hal_conf.h 파일위쪽(202Line)에 보면 이 define이 이미 코딩되어 있지만 주석처리되어 있다.

이걸 풀어주고 다시 컴파일하면 정상 동작한다.

두번째 방법은 소스를 건드리지 않고 컴파일 옵션에서 지정하는 방법이다.

CubeIDE의 메뉴에서 "Project-Properties"를 선택하면 아래와 같은 창이 뜬다.

[Project Properties창]

그림과 같이 왼쪽에서 C/C++ Build아래 Settings를 선택하고 위에서 Configuration에서 Debug가 선택되어 있는지 확인한다. 그리고 Tool Settings탭에서 MCU/MPU GCC Compiler 아래 Preprocessor를 선택하면 오른쪽에 Define symbols칸이 표시된다.

여기에서 Add버튼을 클릭하면 입력창이 하나 뜨는데 여기에다 USE_FULL_ASSERT 문자를 입력하면 된다.

이 옵션은 C의 Precompiler에게 컴파일시 소스에 define문을 추가하지 않았지만 여기에 지정된(즉 컴파일 명령어 인수 -D에) 문자열을 define했다고 가정하고 컴파일 하라는 의미이다.

 

주의할건 현재의 Build Configuration이 Debug/Release 두가지중 어느것이 Active되어 있는지 확인해야 한다. 위의 이미지에서는 "Debug [Active]"와 같이 Debug가 활성화되어 있다.

Build Configuration은 보통 Debug와 Release두개이다. Debug는 개발할때, Release는 개발이 완료되어 제품을 출하할때 사용하는 컴파일 옵션이다.

현재 Active상태를 변경하려면 메뉴의 "Project-Build Configuration-Set Active"에서 선택할 수 있다.

위 그림에서 Configuration란을 바꾼다는건 지금 수정할 대상이 Debug인지 Release인지를 지정하는 것이다.

 

입력을 했으면 아래 Apply and Close버튼을 눌러 적용하면 된다.

 

두가지 방법중 개인적으로는 두번째 방법을 추천한다.

사실 Debug모드로 컴파일하면 assert같은 디버깅용 코드들이 실행 프로그램에 그대로 들어간다. 하지만 모든 개발이 완료된 시점에서는 이런 코드는 제거된 순수 실행에 관련된 내용만 들어간 프로그램을 MCU에 주입해야 속도, 메모리 사용 측면에서 유리하다.

개발을 하다보면 이들 둘을 왔다갔다 하면서 컴파일을 하게 되는데 이때마다 소스를 열어서 주석을 달거나 푸는 과정을 거쳐야 한다면 상당히 귀찮아 진다.

따라서 이렇게 Build Configuration에 구분해서 정의를 해놓으면 메뉴에서 해당 항목만 Active로 선택해 주면 되므로 편하다.

 

위 그림에서 보면 Define symbols에 디폴트로 DEBUG라는 define도 추가되어 있다. 이런것들도 개발시에

#ifdef DEBUG
	char buf[256] = {0,};	
	
	sprintf(buf, "TEST\r\n");
	HAL_UART_Transmit(&huart3, (const uint8_t*)buf, strlen(buf), 10);	  
#endif

이런식으로 디버깅 해야하는 위치에 코딩해 놓으면 Debug모드로 컴파일시에는 이 구문이 들어가지만 Release모드로 컴파일하면 아예 이 구문이 빠진채 컴파일이 된다.

따라서 이런 구문을 소스에 넣었다 뺐다 할 필요가 없다.

 

 

5. 마무리

이번장에서는 HAL_GPIO_ReadPin, HAL_GPIO_WritePin 함수를 통해 레지스터에 대해 알아보았다.

쓰다보니 설명이 너무 길어져서 GPIO 초기화 관련 내용은 다음장으로 넘겼으므로 다음장에서 계속 레지스터에 대해 알아보도록 하자.

'임베디드 > STM32' 카테고리의 다른 글

9. [STM32] Bit-banding  (0) 2024.09.10
8. [STM32] GPIO - Peripheral Register 2  (0) 2024.08.13
6. [STM32] GPIO 기초  (0) 2024.07.25
5. [STM32] NUCLEO F429/439 보드 Pinmap  (0) 2024.07.22
4. [STM32] Entry Point  (0) 2024.07.12