04. 포인터·volatile·메모리 맵드 I/O
임베디드에서 레지스터는 곧 메모리의 특정 주소입니다(예: STM32 GPIOC->ODR 은 0x4001100C). 그 주소에 쓰면 핀이 움직이고, 읽으면 핀 상태가 들어옵니다 — 이것이 메모리 맵드 I/O(MMIO)입니다. 주소를 다루는 포인터와, 하드웨어가 멋대로 바꾸는 값을 안전하게 읽는 volatile 을 PC의 gcc 로 흉내 내며 익힙니다. (*(volatile uint32_t *)주소) 패턴은 14편 이후 STM32 제어의 토대입니다.
이 강의에서 배우는 것
- 1포인터의 주소(&)와 역참조(*)를 구분해 사용한다
- 2레지스터가 고정 주소의 메모리임을 이해하고 MMIO 개념을 설명한다
- 3(*(volatile uint32_t *)ADDR) 패턴으로 특정 주소에 읽고 쓴다
- 4volatile 이 왜 필요한지(최적화로 인한 읽기 생략 방지) 설명한다
- 5주변장치를 구조체+포인터로 묶는 CMSIS 스타일을 이해한다
소개
3편에서 레지스터의 비트를 조작했습니다. 그런데 그 레지스터는 어디에 있을까요? 임베디드에서 레지스터는 메모리 주소 공간의 특정 번지에 배치됩니다. 주소를 직접 다루려면 포인터가, 하드웨어가 바꾸는 값을 안전하게 읽으려면 volatile 이 필요합니다.
핵심 개념
1) 포인터 — 주소를 담는 변수
uint8_t x = 10;
uint8_t *p = &x; /* p 는 x 의 주소 */
*p = 20; /* 역참조: p 가 가리키는 곳(x)에 20 → x==20 */p 는 주소(어디), *p 는 그 주소의 내용물(무엇). 둘을 혼동하지 않는 것이 핵심입니다.
2) 메모리 맵드 I/O — 레지스터 = 주소
| STM32F103 예 | 주소 | 의미 |
|---|---|---|
| RCC->APB2ENR | 0x40021018 | 주변장치 클럭 인에이블 |
| GPIOC->CRH | 0x40011004 | 포트C 상위 핀 설정 |
| GPIOC->ODR | 0x4001100C | 포트C 출력 데이터 |
#define GPIOC_ODR (*(volatile uint32_t *)0x4001100CUL)
GPIOC_ODR |= (1u << 13); /* PC13 출력 High */3) volatile — "매번 진짜로 읽어라"
컴파일러는 "값이 안 바뀐다"고 판단하면 메모리 읽기를 생략하고 CPU 내부에 캐싱합니다. 하지만 하드웨어 레지스터는 하드웨어가 멋대로 바꿉니다.
while (!(UART_SR & TXE)) { } /* TXE 가 1 될 때까지 대기 */UART_SR 이 volatile 이 아니면 컴파일러가 한 번 읽은 값으로 무한 루프를 돌릴 수 있습니다. MMIO 포인터·ISR 공유 변수에 volatile 은 사실상 필수입니다.
4) 구조체로 레지스터 블록 묶기(CMSIS)
typedef struct {
volatile uint32_t MODER; /* +0x00 */
volatile uint32_t IDR; /* +0x04 */
volatile uint32_t ODR; /* +0x08 */
} gpio_t;
#define GPIOC ((gpio_t *)0x40011000UL)
GPIOC->ODR |= (1u << 13); /* 시작주소 + 0x08 */멤버 순서가 곧 오프셋(+0,+4,+8…)이라, 13편 이후 GPIOC->ODR, RCC->APB2ENR 표기가 모두 이 패턴입니다.
핵심 예제
PC에는 0x40011000 같은 주소가 없으니 변수 하나를 "가짜 레지스터"로 삼아 같은 포인터 패턴을 연습합니다. 동작 원리는 동일합니다.
#include <stdio.h>
#include <stdint.h>
#define BIT(n) (1u << (n))
int main(void)
{
volatile uint32_t fake_reg = 0; /* 실제론 고정 주소의 레지스터 */
volatile uint32_t *reg = &fake_reg; /* 그 주소를 가리키는 포인터 */
*reg |= BIT(3); /* set bit3 */
*reg |= BIT(17); /* set bit17 */
*reg &= ~BIT(3); /* clear bit3 */
printf("reg=0x%08X bit17=%s\n", (unsigned)*reg,
((*reg >> 17) & 1u) ? "ON" : "OFF");
return 0;
}
// reg=0x00020000 bit17=ON자주 하는 실수
Q. while (!(SR & TXE)); 가 무한 루프에 빠져요.
A. SR 이 volatile 이 아니면 컴파일러가 "루프 안에서 안 변하니 한 번만 읽자"고 최적화합니다. 레지스터 포인터/매크로에 volatile 을 붙여 매번 실제 주소에서 다시 읽게 하세요.
Q. (uint32_t *)0x40011000 에 PC에서 접근하니 죽어요(segfault).
A. 그 주소는 STM32에만 존재하는 주변장치 번지라 PC 메모리에는 없습니다. PC 연습에서는 변수의 주소를 포인터에 담아 패턴을 익히고, 실제 번지 접근은 14편 시뮬레이터/보드에서 합니다.
Q. 주소를 정수로 쓸 때 0x40011000 만 쓰면 되나요?
A. 32비트 주소 상수는 0x40011000UL 처럼 부호 없는 long 접미사를 붙이고, 반드시 포인터 타입으로 캐스팅합니다: (volatile uint32_t *)0x40011000UL.
정리
- 임베디드 레지스터는 고정 주소의 메모리이고, 접근은 곧 포인터 역참조다
- MMIO 관용구: (*(volatile uint32_t *)ADDR) 로 특정 주소를 읽고 쓴다
- volatile 은 하드웨어가 바꾸는 값을 캐싱하지 말고 매번 다시 읽게 한다
- 주변장치는 구조체+포인터로 묶으면 GPIO->ODR 처럼 깔끔하게 접근된다
- 함수 밖 변수를 바꾸려면 값이 아니라 주소(포인터)를 넘긴다
과제
- 가짜 레지스터 변수에 volatile 포인터로 bit5·bit12 를 set/clear 하고 결과를 16진으로 출력
- MODER/IDR/ODR 구조체를 만들어 각 멤버의 오프셋이 +0/+4/+8 인지 확인
- clear_flag(uint8_t *reg) 함수로 호출자의 변수를 0으로 만들기