임베디드 학습 노트 목차

메모리 맵 — 주변장치가 주소인 이유

GPIOA->ODR = 0x20; 이 한 줄이 LED 를 켠다. 이게 어떻게 가능한가?

답은 주변장치가 메모리 주소에 붙어 있기 때문이다. 특정 주소에 값을 쓰면 그것이 RAM 이 아니라 GPIO 회로에 전달된다. 이 발상 하나가 임베디드 프로그래밍의 뼈대다.


4GB 주소 공간은 미리 나뉘어 있다

Cortex-M 은 32비트 주소를 쓰므로 4GB 공간을 가지는데, 그 용도가 아키텍처 차원에서 정해져 있다. 어느 벤더의 칩이든 같다.

0xFFFF_FFFF ┌───────────────────────────┐
            │  벤더 전용                │  511MB
0xE010_0000 ├───────────────────────────┤
            │  Private Peripheral Bus   │  1MB   ← NVIC · SysTick · SCB
0xE000_0000 ├───────────────────────────┤
            │  외부 장치                │  1GB
0xA000_0000 ├───────────────────────────┤
            │  외부 RAM                 │  1GB
0x6000_0000 ├───────────────────────────┤
            │  주변장치                 │  512MB ← GPIO · UART · SPI · Timer
0x4000_0000 ├───────────────────────────┤
            │  SRAM                     │  512MB ← 변수 · 스택 · 힙
0x2000_0000 ├───────────────────────────┤
            │  Code                     │  512MB ← Flash · 벡터 테이블
0x0000_0000 └───────────────────────────┘

이 배치가 고정이라 CMSIS 가 성립한다. SysTick 이 어느 칩에서든 0xE000_E010 에 있으므로, 벤더가 달라도 같은 코드로 다룰 수 있다.

칩이 실제로 가진 메모리는 이 공간의 극히 일부다. STM32F4 라면 Flash 512KB 가 0x0800_0000 부터, SRAM 128KB 가 0x2000_0000 부터 붙는 식이다. 나머지는 비어 있고, 접근하면 HardFault 가 난다.

주변장치가 주소라는 것의 의미

#define GPIOA_BASE   0x4002_0000UL
#define GPIOA_ODR    (*(volatile uint32_t *)(GPIOA_BASE + 0x14))

GPIOA_ODR = 0x20;      // 5번 핀 HIGH

0x4002_0014 에 쓴 값은 메모리에 저장되지 않는다. 버스를 타고 GPIO 주변장치로 가서 출력 회로의 상태를 바꾼다.

CPU ──[주소 0x40020014, 데이터 0x20]──> 버스 ──> GPIO 주변장치
                                                    └ 5번 핀 전압을 3.3V 로

그 주소는 어디서 나온 숫자인가 — 직접 조립해 보기

0x4002_0014 같은 수를 외울 필요는 없다. 버스 베이스 + 주변장치 오프셋 + 레지스터 오프셋 세 단을 더한 것뿐이다 (STM32F4 기준).

주변장치 영역 베이스   0x4000_0000
  AHB1 버스 오프셋     +   0x0002_0000   →  AHB1 베이스 0x4002_0000
    GPIO 는 1KB(0x400) 간격으로 나열된다
주변장치계산주소
GPIOAAHB1 + 0×0x4000x4002_0000
GPIOBAHB1 + 1×0x4000x4002_0400
GPIOCAHB1 + 2×0x4000x4002_0800
GPIODAHB1 + 3×0x4000x4002_0C00

여기에 레지스터 오프셋을 더한다 — MODER +0x00, IDR +0x10, ODR +0x14, BSRR +0x18.

GPIOD->ODR = 0x4002_0C00 + 0x14 = 0x4002_0C14

주소를 보고 역으로 읽을 수도 있어야 한다. 폴트가 나서 BFAR = 0x4002_0C14 가 찍혔다면, "AHB1 + 0xC00 → 네 번째 GPIO(D) + 0x14 → ODR" 로 어느 주변장치를 건드리다 터졌는지 바로 안다. 이 계산이 디버깅에서 실제로 쓰인다.

함정 — 클럭을 안 켜고 접근하면 폴트다. 주소가 맞아도 그 주변장치의 버스 클럭이 꺼져 있으면(RCC->AHB1ENR 등) 응답이 없어 BusFault 가 난다. "레지스터 주소는 분명히 맞는데 폴트가 난다"의 1순위 원인이며, 초기화 코드에서 클럭 인에이블을 가장 먼저 두는 이유다. 게다가 클럭을 켠 직후 몇 사이클은 반영이 안 될 수 있어, 벤더 HAL 은 켠 뒤 되읽기를 넣는다.

이것을 메모리 맵 I/O(memory-mapped I/O) 라 한다. 대안은 별도의 I/O 명령을 두는 방식(x86 의 in/out)인데, 메모리 맵 방식은 평범한 포인터 연산으로 하드웨어를 다룰 수 있다는 큰 장점이 있다.

그래서 volatile 이 필수다

// ✗ volatile 없이
uint32_t *sr = (uint32_t *)0x40004400;
while ((*sr & 0x80) == 0) { }        // 최적화하면 무한 루프가 된다

컴파일러는 *sr 을 한 번 읽고 "이 값은 안 바뀐다" 고 판단해 레지스터에 캐시한다. 루프 안에서 아무도 안 바꾸니 합리적인 추론이다. 하지만 하드웨어가 바꾼다.

// ✓ volatile 로 "매번 실제로 읽어라" 를 강제한다
volatile uint32_t *sr = (volatile uint32_t *)0x40004400;
while ((*sr & 0x80) == 0) { }

volatile"이 주소는 프로그램 밖의 무언가가 바꿀 수 있다" 를 컴파일러에게 알리는 표시다. 주변장치 레지스터에는 예외 없이 붙는다.

CMSIS 는 이것을 구조체로 묶는다

레지스터가 연속된 주소에 순서대로 놓여 있다는 점을 이용한다.

typedef struct {
    volatile uint32_t MODER;    // 0x00  모드 (입력/출력/대체기능/아날로그)
    volatile uint32_t OTYPER;   // 0x04  출력 타입 (push-pull / open-drain)
    volatile uint32_t OSPEEDR;  // 0x08  속도
    volatile uint32_t PUPDR;    // 0x0C  풀업/풀다운
    volatile uint32_t IDR;      // 0x10  입력 데이터 (읽기 전용)
    volatile uint32_t ODR;      // 0x14  출력 데이터
    volatile uint32_t BSRR;     // 0x18  비트 세트/리셋
    volatile uint32_t LCKR;     // 0x1C  잠금
    volatile uint32_t AFR[2];   // 0x20  대체 기능
} GPIO_TypeDef;

#define GPIOA  ((GPIO_TypeDef *) 0x40020000UL)

GPIOA->ODR = 0x20;      // 구조체 멤버 접근이 곧 주소 계산이다

->ODR베이스 + 0x14 로 컴파일된다. 구조체 필드 순서가 곧 레지스터 배치이고, 그래서 필드를 하나 빠뜨리면 그 뒤가 전부 어긋난다. 쓰지 않는 레지스터도 RESERVED 로 자리를 채워 두는 이유다.

typedef struct {
    volatile uint32_t CR1;
    volatile uint32_t CR2;
    uint32_t RESERVED0[2];      // ← 이 자리를 비워 두면 뒤가 밀린다
    volatile uint32_t SR;
} SOME_TypeDef;

비트밴딩 — 비트 하나를 원자적으로

일반적인 비트 조작은 읽고 → 고치고 → 쓰는 세 단계다.

GPIOA->ODR |= (1 << 5);
// 실제로는
//   ldr r0, [addr]      ← ①
//   orr r0, #0x20       ← ②
//   str r0, [addr]      ← ③

①과 ③ 사이에 인터럽트가 끼어들어 같은 레지스터의 다른 비트를 바꾸면, 그 변경이 ③에 덮여 사라진다. 자주 겪는 미묘한 버그다.

Cortex-M3/M4 는 이 문제를 비트밴딩(bit-banding) 으로 푼다. SRAM 과 주변장치 영역의 앞 1MB 를 각각 32MB 짜리 별칭 영역에 펼쳐 두고, 별칭의 워드 하나가 원본의 비트 하나에 대응하게 한다.

비트밴드 영역          별칭 영역
0x2000_0000 (1MB)  →  0x2200_0000 (32MB)   SRAM
0x4000_0000 (1MB)  →  0x4200_0000 (32MB)   주변장치

별칭 주소 = 별칭_베이스 + (바이트_오프셋 × 32) + (비트번호 × 4)
#define BITBAND_PERIPH(addr, bit) \
    ((volatile uint32_t *)(0x42000000UL + (((uint32_t)(addr) - 0x40000000UL) * 32) + ((bit) * 4)))

*BITBAND_PERIPH(&GPIOA->ODR, 5) = 1;    // 5번 비트만 1 로 — 쓰기 한 번이면 끝

읽기·수정·쓰기가 아니라 쓰기 하나이므로 원자적이다. 인터럽트가 끼어들 틈이 없다.

공식이 왜 저렇게 생겼는지는 "비트 하나를 워드 하나로 펼친다" 로 이해하면 된다. 바이트 하나에는 비트가 8개이므로 바이트 하나가 워드 8개(= 32바이트)로 펼쳐진다. 그래서 × 32 이고, 비트 사이 간격이 워드 하나라 × 4 다.

원본비트계산별칭 주소
0x2000_0300 (SRAM 변수)20x2200_0000 + 0x300×32 + 2×40x2200_6008
0x4002_0C14 (GPIOD ODR)50x4200_0000 + 0x20C14×32 + 5×40x4241_8294

1MB 영역이 32MB 별칭으로 펼쳐지는 것도 같은 이유다(1MB × 8비트 × 4바이트 = 32MB).

함정 — 별칭 주소에 쓸 때 의미 있는 것은 최하위 비트 하나뿐이다. 2 를 쓰든 0xFF 를 쓰든 짝수면 0, 홀수면 1 로 동작한다. 그리고 읽으면 항상 0 또는 1 만 나온다. 이 영역을 일반 메모리처럼 memcpy 하거나 구조체로 덮으면 전혀 다른 결과가 된다.

다만 비트밴딩은 Cortex-M3/M4 에만 있고 M0/M7 일부에는 없다. 그래서 이식성이 필요한 코드는 다음 방법을 쓴다.

BSRR — 하드웨어가 제공하는 더 나은 방법

GPIOA->BSRR = (1 << 5);         // 5번 핀 SET   (하위 16비트)
GPIOA->BSRR = (1 << (5 + 16));  // 5번 핀 RESET (상위 16비트)

BSRR쓰기 전용이고 쓴 비트만 동작한다. 0 을 쓴 자리는 아무 영향이 없으므로 읽을 필요가 없다. 한 번의 쓰기로 끝나 원자적이고, 모든 Cortex-M 에서 쓸 수 있다.

GPIO 를 다룰 때 ODR |= 대신 BSRR 을 쓰는 것이 정석인 이유가 이것이다.

어디에 무엇이 있는지 확인하는 법

arm-none-eabi-objdump -h firmware.elf
# Sections:
# Idx Name      Size      VMA       LMA       File off  Algn
#   0 .isr_vector 000001c4  08000000  08000000  00010000  2**0
#   1 .text       00005f2c  080001c4  080001c4  000101c4  2**2
#   2 .data       0000006c  20000000  080060f0  00020000  2**2
#   3 .bss        00002000  20000070  20000070  00020070  2**0
#                            │         │
#                            │         └ LMA — 실제로 '저장된' 곳 (Flash)
#                            └ VMA — 실행 시 '있어야 할' 곳 (SRAM)

.dataVMALMA 가 다른 것이 핵심이다. Flash 의 0x080060f0 에 저장돼 있다가 부팅 시 SRAM 의 0x20000000 으로 복사된다 — startup 코드가 하는 그 일이다.

arm-none-eabi-nm -S --size-sort firmware.elf | tail -5
# 20000070 00002000 b sensor_buffer      ← SRAM 8KB 를 이 배열 하나가 쓴다
# 08001a20 000004e8 T process_frame

SRAM 이 모자랄 때 무엇이 먹고 있는지 를 이렇게 찾는다.

없는 주소를 건드리면

volatile uint32_t *p = (uint32_t *)0x90000000;   // 외부 RAM 영역 — 칩에 없다
*p = 1;                                           // HardFault

MCU 는 물리적으로 존재하지 않는 주소에 접근하면 버스 오류 → HardFault 로 간다. 잘못된 포인터가 조용히 넘어가지 않고 즉시 터지므로, 오히려 진단이 쉬운 편이다.

// 정렬을 어겨도 폴트가 난다
volatile uint32_t *p = (uint32_t *)0x20000001;   // 4바이트 정렬이 아니다
*p = 1;                                           // UsageFault (정렬 검사가 켜져 있으면)

한눈에 정리

  • 4GB 주소 공간의 용도가 아키텍처로 고정돼 있어 CMSIS 가 성립한다. SysTick 은 어느 칩에서든 0xE000_E010
  • 주변장치는 주소다 — 그 주소에 쓴 값은 메모리가 아니라 회로로 간다(메모리 맵 I/O)
  • 레지스터 포인터에는 예외 없이 volatile — 없으면 컴파일러가 값을 캐시해 상태 폴링이 무한 루프가 된다
  • CMSIS 구조체 필드 순서가 곧 레지스터 배치 — 하나 빠뜨리면 뒤가 전부 어긋나 RESERVED 로 채운다
  • |= 는 읽기·수정·쓰기 세 단계라 인터럽트에 깨질 수 있다
  • **비트밴딩**은 별칭 워드 하나 = 원본 비트 하나. 쓰기 한 번이라 원자적이지만 M3/M4 한정
  • GPIO 는 BSRR 이 정석 — 쓰기 전용에 쓴 비트만 동작, 원자적이고 이식성도 있다
  • objdump -hVMALMA.data 복사의 근거다. nm -S --size-sort 로 SRAM 을 먹는 범인을 찾는다
  • 없는 주소·정렬 위반은 즉시 폴트 — 조용히 넘어가지 않는 것이 오히려 낫다

꼬리질문 대비

  • "주변장치 레지스터에 volatile 이 필요한 이유는?" → 값이 하드웨어로 바뀌는데 컴파일러는 그것을 모른다. 없으면 읽기가 최적화로 사라진다
  • "BFAR = 0x4002_0C14 이 찍혔다면 어디를 건드린 것인가?" → AHB1(0x40020000) + 0xC00 → 네 번째 GPIO(D), 오프셋 0x14 → ODR
  • "주소는 맞는데 접근하면 BusFault 가 난다. 1순위 원인은?" → 그 주변장치의 버스 클럭(RCC …ENR)을 안 켰다
  • "비트밴딩 별칭이 32배 크기인 이유는?" → 비트 하나를 워드(4바이트) 하나로 펼치므로 8비트 × 4바이트 = 32배

출처 — Arm, Cortex-M4 Devices Generic User Guide(DUI 0553) §2.2 Memory model · Arm, CMSIS-Core (Cortex-M) Register Mapping · Joseph Yiu, The Definitive Guide to Arm Cortex-M3/M4 3e Ch.5·Ch.6 · STMicroelectronics, RM0090 STM32F4 Reference Manual (GPIO·BSRR)

MCU란 무엇인가 — OS 없이 도는 컴퓨터비트 조작 — 레지스터를 정확히 건드리는 법