부팅 — 전원을 넣고 main 까지
main()이 첫 줄이 아니다. 그 앞에 반드시 실행되는 코드가 있고, 그것이 없으면 전역 변수가 쓰레기 값이고 스택도 없다.이 편은 전원 인가부터
main진입까지 무슨 일이 일어나는지를 순서대로 따라간다. 여기를 알면 "왜 전역 변수가 0 이 아니지?" 같은 질문에 스스로 답할 수 있다.
벡터 테이블 — 0번지의 정체
Cortex-M 은 리셋 시 주소 0에서 두 개의 32비트 값을 하드웨어가 직접 읽는다.
0x0000_0000 초기 스택 포인터 값 ← MSP 에 들어간다
0x0000_0004 Reset_Handler 주소 ← PC 에 들어간다
0x0000_0008 NMI_Handler
0x0000_000C HardFault_Handler
0x0000_0010 MemManage_Handler
0x0000_0014 BusFault_Handler
0x0000_0018 UsageFault_Handler
...
0x0000_002C SVC_Handler
0x0000_0038 PendSV_Handler
0x0000_003C SysTick_Handler
0x0000_0040 IRQ0 (여기부터 벤더가 정한다)
0x0000_0044 IRQ1
첫 항목이 코드가 아니라 데이터라는 점이 특이하다. 다른 아키텍처는 리셋 주소로 점프해 거기서 스택을 세팅하는데, Cortex-M 은 하드웨어가 스택을 먼저 준비해 준다. 그래서 Reset_Handler 의 첫 줄부터 함수 호출을 할 수 있다.
// startup 파일에서 벡터 테이블은 이렇게 생겼다
__attribute__((section(".isr_vector")))
const void *vector_table[] = {
(void *)&_estack, // 0x00 스택 꼭대기 (링커가 준 심볼)
Reset_Handler, // 0x04
NMI_Handler,
HardFault_Handler,
...
};
.isr_vector 라는 별도 섹션에 넣는 이유는 링커 스크립트가 이것을 맨 앞에 배치하기 때문이다. 순서가 어긋나면 부팅 자체가 안 된다.
스택은 위에서 아래로 자란다
0x2002_0000 ┌───────────────┐ _estack ← SP 초기값 (SRAM 끝)
│ 스택 │ ↓ 아래로 자란다
│ ↓ │
│ │
│ ↑ │
│ 힙 │ ↑ 위로 자란다 (쓴다면)
├───────────────┤
│ .bss │
├───────────────┤
│ .data │
0x2000_0000 └───────────────┘ SRAM 시작
스택 포인터 초기값이 SRAM 의 끝인 이유가 이것이다. 아래로 자라므로 끝에서 시작해야 공간을 최대한 쓴다.
그리고 여기서 스택 오버플로가 왜 무서운지가 보인다 — 스택이 계속 자라면 .bss 섹션을 덮어쓴다. MPU 로 막지 않으면 경고 없이 전역 변수가 손상되고, 증상이 엉뚱한 곳에서 나타난다.
Reset_Handler 가 하는 일
전원이 들어온 뒤 main 에 닿기까지의 전 과정을 한 장으로 보면 이렇다. 하드웨어가 하는 일과
startup 코드가 하는 일의 경계가 어디인지가 핵심이다.
핵심 — ①②는 CPU 하드웨어가 자동으로 한다. 그래서 스택 포인터는 첫 명령이 실행되기 전에 이미 준비돼 있다. ③부터가 우리가 고칠 수 있는 코드다.
void Reset_Handler(void) {
// ① .data 를 Flash 에서 SRAM 으로 복사한다
uint32_t *src = &_sidata; // Flash 안의 초기값 (LMA)
uint32_t *dst = &_sdata; // SRAM 의 목적지 (VMA)
while (dst < &_edata) *dst++ = *src++;
// ② .bss 를 0 으로 채운다
dst = &_sbss;
while (dst < &_ebss) *dst++ = 0;
// ③ 클럭·FPU 등 초기 설정
SystemInit();
// ④ C++ 전역 생성자 (C 라면 없다)
__libc_init_array();
// ⑤ 드디어
main();
while (1) { } // main 이 반환하면 갇힌다
}
_sidata·_sdata·_edata 는 링커가 만들어 주는 심볼이다. 코드에는 값이 없고 링커가 배치를 결정하면서 주소를 채워 넣는다.
extern uint32_t _sidata, _sdata, _edata, _sbss, _ebss, _estack;
// └ 값이 아니라 '주소' 로 쓴다
&_sdata 처럼 주소 연산자를 붙여 쓰는 것이 관용이다. 변수 자체에는 의미 있는 값이 없고, 그 변수가 놓인 위치가 정보이기 때문이다.
왜 이 복사가 필요한가
int counter = 42; // .data — 초기값이 있다
int total; // .bss — 초기값이 없다 (C 표준상 0)
const int MAX = 100; // .rodata — 안 바뀐다
| 저장 위치 | 실행 위치 | 부팅 시 | |
|---|---|---|---|
.data | Flash | SRAM | 복사한다 |
.bss | (없음) | SRAM | 0 으로 채운다 |
.rodata | Flash | Flash | 그대로 둔다 |
.text | Flash | Flash | 그대로 둔다 |
Flash 는 실행 중에 쓸 수 없고 SRAM 은 전원이 꺼지면 사라진다. 그래서 "초기값이 있는 변수" 는 값을 Flash 에 두고 부팅할 때 SRAM 으로 옮기는 수밖에 없다.
uint8_t buffer[1024] = {0}; // ✗ .data 로 가서 Flash 1KB 를 먹는다
uint8_t buffer[1024]; // ✓ .bss — Flash 를 안 쓴다
C 표준이 초기화 안 한 전역은 0 이라고 보장하므로 = {0} 은 불필요하다. 그런데 명시적으로 쓰면 컴파일러가 .data 로 보내 1KB 짜리 0 덩어리가 Flash 에 실린다. 큰 배열에서 이 차이가 크다.
그래서 부팅 전에는 전역 변수를 믿으면 안 된다
int g_config = 0x1234;
void SystemInit(void) { // Reset_Handler 의 ③ 단계 — 복사 뒤에 불린다
if (g_config == 0x1234) { } // 여기서는 안전하다
}
일부 startup 은 SystemInit() 을 복사 전에 부른다. 그 경우 전역 변수는 쓰레기 값이다. 벤더 startup 파일의 순서를 확인하는 것이 안전하다.
벡터 테이블을 옮긴다 — VTOR
부트로더가 있으면 애플리케이션은 0번지가 아니라 뒤쪽에 놓인다.
0x0800_0000 부트로더 (32KB)
0x0800_8000 애플리케이션 ← 벡터 테이블이 여기 있다
// 애플리케이션 시작 시
SCB->VTOR = 0x08008000; // "벡터 테이블은 여기다" 를 CPU 에 알린다
VTOR(Vector Table Offset Register)을 설정하지 않으면 인터럽트가 부트로더의 핸들러로 간다. 증상이 고약하다 — 메인 루프는 도는데 인터럽트만 이상하게 동작한다.
// 부트로더가 애플리케이션으로 점프하는 전형적인 코드
void jump_to_app(uint32_t addr) {
uint32_t sp = *(volatile uint32_t *)addr; // 앱의 SP
uint32_t entry = *(volatile uint32_t *)(addr + 4); // 앱의 Reset_Handler
__disable_irq();
SysTick->CTRL = 0; // 쓰던 주변장치를 정리한다
SCB->VTOR = addr; // 벡터 테이블 이전
__set_MSP(sp); // 스택 포인터를 앱 것으로
((void (*)(void))entry)(); // 점프
}
주변장치를 정리하지 않고 점프하면 앱이 초기화하기 전에 인터럽트가 들어와 엉뚱한 핸들러로 간다. 부트로더에서 가장 자주 겪는 문제다.
부팅 순서를 실제로 확인하는 법
arm-none-eabi-objdump -d firmware.elf | head -20
# Disassembly of section .isr_vector:
# 08000000 <vector_table>:
# 8000000: 20020000 .word 0x20020000 ← SP 초기값
# 8000004: 080001c5 .word 0x080001c5 ← Reset_Handler (+1 = Thumb)
0x080001c5 의 마지막 비트 1 이 눈에 띈다. Cortex-M 은 Thumb 명령만 실행하므로, 주소의 LSB 를 1 로 두어 "Thumb 모드" 를 표시한다. 실제 주소는 0x080001c4 다.
이 비트를 0 으로 두면 즉시 HardFault 가 난다 — 함수 포인터를 직접 만들 때 데는 곳이다.
arm-none-eabi-nm firmware.elf | grep -E '_sdata|_edata|_sbss|_ebss|_estack'
# 20000000 D _sdata
# 2000006c D _edata
# 2000006c B _sbss
# 20002070 B _ebss
# 20020000 A _estack
_edata 와 _sbss 가 같은 주소인 것이 정상이다 — .data 가 끝나는 자리에서 .bss 가 시작한다.
부팅이 안 될 때 보는 순서
① 전원·리셋 핀 전압을 잰다 ← 하드웨어 문제일 수 있다
② 디버거가 붙나 ← 안 붙으면 클럭·전원 문제
③ 0x08000000 의 첫 두 워드를 읽는다 ← SP 가 SRAM 범위인가, Reset 주소 LSB 가 1 인가
④ Reset_Handler 에 브레이크포인트 ← 여기까지 오나
⑤ main 진입 직전에 브레이크포인트 ← 복사·클럭 설정에서 멈추나
③에서 SP 초기값이 SRAM 범위 밖이면 링커 스크립트의 _estack 이 잘못된 것이다. 아주 초기 단계라 디버거로도 잡기 어려우니, 값을 눈으로 확인하는 것이 빠르다.
// 클럭 설정에서 멈추는 것도 흔하다
while ((RCC->CR & RCC_CR_HSERDY) == 0) { } // 외부 크리스털이 없으면 영원히 여기
외부 크리스털을 안 붙인 보드에서 외부 클럭을 기다리면 여기서 무한 대기한다. 타임아웃을 두고 내부 클럭으로 폴백하는 것이 안전한 작성법이다.
uint32_t timeout = 100000;
while ((RCC->CR & RCC_CR_HSERDY) == 0) {
if (--timeout == 0) { use_internal_clock(); break; }
}
한눈에 정리
- 0번지 첫 워드는 SP 초기값, 둘째가 Reset_Handler 주소 — 하드웨어가 직접 읽는다
- 스택은 SRAM 끝에서 아래로 자란다 — 넘치면
.bss를 덮어써 경고 없이 전역 변수가 손상된다 - Reset_Handler 가
.data복사 ·.bss0채움 · 클럭 설정 후main을 부른다 _sdata같은 링커 심볼은 값이 아니라 주소로 쓴다 —&_sdata= {0}은.data로 가서 Flash 를 먹는다 — 표준이 0 을 보장하므로 안 쓰는 편이 낫다- 부트로더가 있으면
SCB->VTOR를 옮긴다 — 안 하면 인터럽트만 이상하게 동작한다 - 앱으로 점프 전에 주변장치를 정리하지 않으면 초기화 전에 인터럽트가 들어온다
- 함수 주소의 LSB 1 은 Thumb 표시 — 0 으로 두면 즉시 HardFault
- 외부 클럭 대기에는 타임아웃 — 크리스털이 없으면 부팅이 거기서 멈춘다
꼬리질문 대비
- "0번지의 첫 4바이트가 코드가 아닌 이유는?" → 리셋 시 하드웨어가 그 값을 MSP 에 넣는다. 첫 명령 전에 스택이 준비되도록
- "
.data복사가 왜 필요한가?" → Flash 는 실행 중 못 쓰고 SRAM 은 휘발성이라, 초기값을 Flash 에 두고 부팅 때 옮긴다- "부트로더에서 앱으로 점프할 때 반드시 할 일은?" → VTOR 을 앱의 벡터 테이블로 옮기고, SP 를 앱 값으로 세팅한 뒤 리셋 핸들러로 점프
출처 — Arm, Cortex-M4 Devices Generic User Guide(DUI 0553) §2.3 예외 모델·§4.3 SCB(VTOR) · Joseph Yiu, The Definitive Guide to Arm Cortex-M3/M4 3e Ch.7 예외·Ch.14 시작 · Arm, Procedure Call Standard for the Arm Architecture(AAPCS) — 스택 정렬 · STMicroelectronics, AN2606 STM32 부트로더