메모리 맵 — 주변장치가 주소인 이유
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) 간격으로 나열된다
| 주변장치 | 계산 | 주소 |
|---|---|---|
| GPIOA | AHB1 + 0×0x400 | 0x4002_0000 |
| GPIOB | AHB1 + 1×0x400 | 0x4002_0400 |
| GPIOC | AHB1 + 2×0x400 | 0x4002_0800 |
| GPIOD | AHB1 + 3×0x400 | 0x4002_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 변수) | 2 | 0x2200_0000 + 0x300×32 + 2×4 | 0x2200_6008 |
0x4002_0C14 (GPIOD ODR) | 5 | 0x4200_0000 + 0x20C14×32 + 5×4 | 0x4241_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)
.data 의 VMA 와 LMA 가 다른 것이 핵심이다. 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 -h의 VMA≠LMA 가.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)