← Embedded C 강의 목록으로
🔌
기초
기초 · 선수: 03강

04. 포인터·volatile·메모리 맵드 I/O

임베디드에서 레지스터는 곧 메모리의 특정 주소입니다(예: STM32 GPIOC->ODR 은 0x4001100C). 그 주소에 쓰면 핀이 움직이고, 읽으면 핀 상태가 들어옵니다 — 이것이 메모리 맵드 I/O(MMIO)입니다. 주소를 다루는 포인터와, 하드웨어가 멋대로 바꾸는 값을 안전하게 읽는 volatile 을 PC의 gcc 로 흉내 내며 익힙니다. (*(volatile uint32_t *)주소) 패턴은 14편 이후 STM32 제어의 토대입니다.

포인터volatileMMIO레지스터CMSIS메모리맵
소요 시간
약 1.5시간
난이도
📊 초급
선수 조건
🎯 03강
결과물
레지스터가 "고정 주소의 메모리"임을 이해하고, (*(volatile uint32_t *)ADDR) 패턴과 구조체+포인터(CMSIS 스타일)로 특정 주소에 안전하게 읽고 쓸 수 있습니다.

이 강의에서 배우는 것

  • 1포인터의 주소(&)와 역참조(*)를 구분해 사용한다
  • 2레지스터가 고정 주소의 메모리임을 이해하고 MMIO 개념을 설명한다
  • 3(*(volatile uint32_t *)ADDR) 패턴으로 특정 주소에 읽고 쓴다
  • 4volatile 이 왜 필요한지(최적화로 인한 읽기 생략 방지) 설명한다
  • 5주변장치를 구조체+포인터로 묶는 CMSIS 스타일을 이해한다

소개

3편에서 레지스터의 비트를 조작했습니다. 그런데 그 레지스터는 어디에 있을까요? 임베디드에서 레지스터는 메모리 주소 공간의 특정 번지에 배치됩니다. 주소를 직접 다루려면 포인터가, 하드웨어가 바꾸는 값을 안전하게 읽으려면 volatile 이 필요합니다.

핵심 개념

1) 포인터 — 주소를 담는 변수

c
uint8_t  x = 10;
uint8_t *p = &x;   /* p 는 x 의 주소 */
*p = 20;           /* 역참조: p 가 가리키는 곳(x)에 20 → x==20 */

p 는 주소(어디), *p 는 그 주소의 내용물(무엇). 둘을 혼동하지 않는 것이 핵심입니다.

2) 메모리 맵드 I/O — 레지스터 = 주소

STM32F103 예주소의미
RCC->APB2ENR0x40021018주변장치 클럭 인에이블
GPIOC->CRH0x40011004포트C 상위 핀 설정
GPIOC->ODR0x4001100C포트C 출력 데이터
c
#define GPIOC_ODR (*(volatile uint32_t *)0x4001100CUL)
GPIOC_ODR |= (1u << 13);   /* PC13 출력 High */

3) volatile — "매번 진짜로 읽어라"

컴파일러는 "값이 안 바뀐다"고 판단하면 메모리 읽기를 생략하고 CPU 내부에 캐싱합니다. 하지만 하드웨어 레지스터는 하드웨어가 멋대로 바꿉니다.

c
while (!(UART_SR & TXE)) { }   /* TXE 가 1 될 때까지 대기 */
⚠️

UART_SR 이 volatile 이 아니면 컴파일러가 한 번 읽은 값으로 무한 루프를 돌릴 수 있습니다. MMIO 포인터·ISR 공유 변수에 volatile 은 사실상 필수입니다.

4) 구조체로 레지스터 블록 묶기(CMSIS)

c
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 같은 주소가 없으니 변수 하나를 "가짜 레지스터"로 삼아 같은 포인터 패턴을 연습합니다. 동작 원리는 동일합니다.

c
#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 처럼 깔끔하게 접근된다
  • 함수 밖 변수를 바꾸려면 값이 아니라 주소(포인터)를 넘긴다

과제

  1. 가짜 레지스터 변수에 volatile 포인터로 bit5·bit12 를 set/clear 하고 결과를 16진으로 출력
  2. MODER/IDR/ODR 구조체를 만들어 각 멤버의 오프셋이 +0/+4/+8 인지 확인
  3. clear_flag(uint8_t *reg) 함수로 호출자의 변수를 0으로 만들기
예제 코드 / 강의 자료

전체 강의 자료와 예제 코드(과제·정답 포함)는 GitHub에서 자유롭게 받아볼 수 있습니다.

GitHub에서 보기 ↗