임베디드 학습 노트 목차

부팅 — 전원을 넣고 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 — 안 바뀐다
저장 위치실행 위치부팅 시
.dataFlashSRAM복사한다
.bss(없음)SRAM0 으로 채운다
.rodataFlashFlash그대로 둔다
.textFlashFlash그대로 둔다

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 복사 · .bss 0채움 · 클럭 설정 후 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 부트로더

임베디드 C 함정 — 컴파일은 되는데 틀린 것들링커 스크립트와 빌드 — 무엇이 어디에 놓이는가