임베디드 학습 노트 목차

링커 스크립트와 빌드 — 무엇이 어디에 놓이는가

PC 프로그램은 링커 스크립트를 볼 일이 없다. OS 가 알아서 메모리를 준다. MCU 에는 그런 게 없으므로 우리가 직접 "Flash 는 여기부터 512KB, SRAM 은 여기부터 128KB" 를 알려 줘야 한다. 그게 링커 스크립트다.

그리고 빌드가 실패할 때 컴파일 오류보다 링크 오류가 더 자주 나는데, 그 메시지가 불친절해서 읽는 법을 알아야 한다.


빌드는 네 단계다

main.c ──[전처리]──> main.i ──[컴파일]──> main.s ──[어셈블]──> main.o
                                                                  │
startup.s ─────────────────────────────────────────────────────> startup.o
                                                                  │
                                        [링크 + 링커 스크립트] ───┘
                                                   │
                                             firmware.elf
                                                   │
                                   [objcopy] ──> firmware.bin / .hex
arm-none-eabi-gcc -E main.c -o main.i      # 전처리만 — 매크로가 어떻게 펼쳐졌나
arm-none-eabi-gcc -S main.c -o main.s      # 어셈블리까지 — 컴파일러가 뭘 만들었나
arm-none-eabi-gcc -c main.c -o main.o      # 오브젝트까지

-S 로 어셈블리를 보는 것이 임베디드에서 특히 유용하다. 최적화가 코드를 지웠는지, volatile 이 먹었는지, 어떤 명령으로 바뀌었는지를 눈으로 확인할 수 있다.

ELF 와 BIN 은 무엇이 다른가

arm-none-eabi-objcopy -O binary firmware.elf firmware.bin
arm-none-eabi-objcopy -O ihex   firmware.elf firmware.hex
  • ELF — 섹션 정보·심볼·디버그 정보를 다 담은 형식. 디버거가 이것을 쓴다
  • BIN — 순수한 바이트 덩어리. 주소 정보가 없어 어디에 구울지 따로 지정해야 한다
  • HEX — 각 줄에 주소가 붙어 있어 굽는 도구가 알아서 배치한다

BIN 을 잘못된 주소에 구우면 아무 오류 없이 안 돌아간다. HEX 는 주소가 파일 안에 있어 그 실수를 막아 준다.

링커 스크립트 읽기

/* ① 무슨 메모리가 어디에 얼마나 있나 */
MEMORY
{
  FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
  RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

_estack = ORIGIN(RAM) + LENGTH(RAM);    /* 스택 꼭대기 = SRAM 끝 */

/* ② 각 섹션을 어디에 놓을 것인가 */
SECTIONS
{
  .isr_vector : {
    KEEP(*(.isr_vector))       /* KEEP — 아무도 참조 안 해도 지우지 마라 */
  } >FLASH

  .text : {
    *(.text*)                  /* 모든 오브젝트의 .text 를 모은다 */
    *(.rodata*)                /* const 데이터도 Flash 에 */
    . = ALIGN(4);
    _etext = .;                /* 여기까지가 코드 — 심볼로 기록 */
  } >FLASH

  .data : {
    _sdata = .;                /* SRAM 에서의 시작 */
    *(.data*)
    . = ALIGN(4);
    _edata = .;
  } >RAM AT> FLASH             /* ← 핵심: 실행은 RAM, 저장은 FLASH */

  _sidata = LOADADDR(.data);   /* Flash 안의 초기값 위치 */

  .bss : {
    _sbss = .;
    *(.bss*)
    *(COMMON)
    . = ALIGN(4);
    _ebss = .;
  } >RAM
}

>RAM AT> FLASH 가 전부다

>RAM        VMA — 실행할 때 있어야 할 주소
AT> FLASH   LMA — 실제로 저장될 주소

이 한 줄이 .data 복사가 필요한 이유를 그대로 표현한다. 값은 Flash 에 굽되 프로그램은 SRAM 주소로 접근하도록 컴파일하고, 그 사이를 startup 코드가 메운다.

arm-none-eabi-objdump -h firmware.elf | grep -A1 '\.data'
#  2 .data  0000006c  20000000  080060f0  00020000  2**2
#                     └ VMA      └ LMA      ← 다르다

KEEP 이 없으면 벡터 테이블이 사라진다

KEEP(*(.isr_vector))

--gc-sections 로 빌드하면 링커가 아무도 참조하지 않는 섹션을 지운다. 벡터 테이블은 C 코드에서 아무도 참조하지 않으므로(하드웨어가 읽는다) 그냥 두면 지워진다. KEEP 이 그것을 막는다.

크기를 줄이는 옵션

arm-none-eabi-gcc -ffunction-sections -fdata-sections \
                  -Wl,--gc-sections \
                  -Os ...
  • -ffunction-sections — 함수마다 별도 섹션으로 만든다
  • --gc-sections — 참조되지 않는 섹션을 링크 시점에 버린다

둘을 같이 써야 의미가 있다. 함수를 섹션으로 나눠 두지 않으면 통째로 하나라 버릴 단위가 없다.

arm-none-eabi-size firmware.elf
#    text    data     bss     dec     hex
#   24512     108    8192   32812    802c
Flash 사용량 = text + data          = 24,620
SRAM  사용량 = data + bss + 스택 + 힙 = 8,300 + α

data 가 양쪽에 다 들어가는 것을 헷갈리기 쉽다. Flash 에 초기값이 있고 SRAM 에 복사되므로 둘 다 차지한다.

최적화 수준

성격
-O0최적화 없음. 디버깅에 좋지만 크고 느리다
-O1·-O2일반적인 속도 최적화
-O3인라인·루프 펼치기를 공격적으로 — 코드가 커진다
-Os크기 우선. 임베디드 기본값
-Og디버깅 가능한 수준의 최적화

-O3 이 항상 빠른 것은 아니다. 코드가 커지면 Flash 대기 상태가 늘어 오히려 느려질 수 있다. 그리고 -O0 에서만 돌던 코드가 -O2 에서 깨진다면 대개 volatile 누락이나 미정의 동작이지 컴파일러 버그가 아니다.

맵 파일 — 무엇이 용량을 먹나

맵 파일은 링커가 남기는 배치 보고서다. 어떤 심볼이 어느 주소에 얼마나 놓였는지 전부 적혀 있어, "용량이 왜 이렇게 늘었지" 를 조사할 때 가장 먼저 연다.

arm-none-eabi-gcc ... -Wl,-Map=firmware.map
.text
 *(.text*)
 .text.process_frame   0x080012a0   0x4e8  build/dsp.o     ← 1,256 바이트
 .text.printf          0x08001788  0x1a24  libc.a(printf.o) ← 6,692 바이트

printf 하나가 6KB 를 먹는 것이 임베디드에서 유명한 함정이다. 부동소수점 포맷까지 들어가면 더 커진다.

# 크기 순으로 심볼을 본다 — 맵 파일보다 빠르다
arm-none-eabi-nm -S --size-sort -td firmware.elf | tail -10
# 0000006692 T _vfprintf_r
# 0000001256 T process_frame
# SRAM 을 먹는 것만
arm-none-eabi-nm -S --size-sort firmware.elf | grep -iE ' [bd] ' | tail -5
# 20000070 00002000 b sensor_buffer     ← 8KB 배열 하나

printf 를 줄이는 실무적 방법은 셋이다 — nano 라이브러리 쓰기(--specs=nano.specs), 부동소수점 포맷 빼기, 또는 직접 만든 경량 포맷터로 대체하기.

링크 오류 읽는 법

undefined reference to `HAL_GPIO_Init'

컴파일은 됐는데 구현을 못 찾은 것이다. 헤더는 있고 .c 파일을 빌드에 안 넣었거나 라이브러리를 안 링크했다.

region `FLASH' overflowed by 4216 bytes

펌웨어가 Flash 보다 크다. -Os 로 바꾸거나, --gc-sections 를 켜거나, printf 같은 큰 것을 걷어낸다.

region `RAM' overflowed by 1024 bytes

전역 변수가 SRAM 을 넘겼다. nm -S 로 큰 배열을 찾는다. 주의할 점은 이 오류가 스택과 힙을 계산에 안 넣는다는 것이다 — 링크가 통과해도 실행 중에 스택이 넘칠 수 있다.

스택 여유를 링크 시점에 검사시키는 법

링커는 기본적으로 스택을 모르지만, 가짜 섹션을 하나 만들어 강제로 자리를 잡게 하면 링커가 대신 검사해 준다. 실무에서 널리 쓰는 관용구다.

_Min_Stack_Size = 0x1000;    /* 요구 스택 4KB */
_Min_Heap_Size  = 0x0;       /* 힙 안 씀 */

.user_heap_stack :
{
  . = ALIGN(8);
  . = . + _Min_Heap_Size;
  . = . + _Min_Stack_Size;   /* ← 이만큼 자리를 실제로 예약한다 */
  . = ALIGN(8);
} >RAM

이렇게 두면 .bss 가 커져 스택 몫까지 침범하는 순간 링크가 실패한다.

region `RAM' overflowed by 512 bytes    ← 이제 스택 여유까지 포함해 판정된 것

"실행 중에 스택이 전역 변수를 덮어써서 관계없는 값이 바뀌는" 최악의 버그를, 빌드 단계에서 막을 수 있다. 링커 스크립트를 손대는 값어치가 가장 큰 한 줄이다.

함정 — 예약만 하면 얼마나 필요한지는 여전히 모른다. _Min_Stack_Size 는 근거 있는 수여야 한다. 실측 방법은 ① 스택 영역을 0xAA 로 채우고 한참 돌린 뒤 패턴이 남아 있는 지점까지가 안 쓴 부분(high-water mark), ② 컴파일러의 -fstack-usage 로 함수별 프레임 크기를 뽑아 최악 호출 경로를 합산하는 것이다. 재귀나 함수 포인터가 있으면 ②는 정확도가 떨어지므로 ①과 병행한다.

undefined reference to `_sbrk'
undefined reference to `_write'

printf·malloc 이 요구하는 시스템 콜 스텁이 없다. OS 가 없으니 우리가 빈 구현을 제공해야 한다.

int _write(int fd, char *buf, int len) {      // printf 가 이걸 부른다
    for (int i = 0; i < len; i++) uart_putc(buf[i]);
    return len;
}

재현 가능한 빌드

# 같은 소스에서 같은 바이너리가 나오는가
arm-none-eabi-gcc ... -o a.elf && sha256sum a.elf

__DATE__·__TIME__ 매크로를 쓰면 매번 다른 바이너리가 나온다. 펌웨어 검증·서명이 필요한 제품에서 문제가 되므로, 버전 정보는 빌드 시스템이 주입하는 편이 낫다.

arm-none-eabi-gcc -DFW_VERSION=\"$(git describe --tags --always)\" ...

실무 순서

# ① 매 빌드마다 크기 확인 — 어느 커밋에서 늘었는지 추적된다
arm-none-eabi-size firmware.elf

# ② 늘었으면 무엇 때문인지
arm-none-eabi-nm -S --size-sort -td firmware.elf | tail -20

# ③ 최적화가 코드를 어떻게 바꿨는지 의심되면
arm-none-eabi-objdump -d firmware.elf | less

# ④ 섹션 배치가 의도대로인지
arm-none-eabi-objdump -h firmware.elf

①을 CI 에 넣어 두면 용량이 갑자기 튀는 커밋을 바로 잡을 수 있다. Flash 가 90% 차면 경고를 내는 식이다.


한눈에 정리

  • ELF 는 디버거용, BIN 은 주소가 없어 굽는 위치를 따로 지정해야 한다. HEX 는 주소를 담는다
  • MEMORY 블록이 "무슨 메모리가 어디에", SECTIONS 가 "무엇을 어디에" 를 정한다
  • >RAM AT> FLASH 한 줄이 .data 복사의 근거 — VMALMA 가 다르다
  • KEEP 이 없으면 --gc-sections 가 벡터 테이블을 지운다 — 아무도 참조하지 않기 때문
  • -ffunction-sections--gc-sections 는 세트 — 나눠 두지 않으면 버릴 단위가 없다
  • Flash = text + data, SRAM = data + bss + 스택 + 힙data 가 양쪽에 들어간다
  • -O3 이 항상 빠르지 않다. -O0 에서만 돌던 코드가 깨지면 대개 volatile 누락이나 미정의 동작
  • printf 하나가 6KB — nano 라이브러리·부동소수점 제외·경량 포맷터로 줄인다
  • region overflowed 는 스택·힙을 계산에 안 넣는다 — 링크가 통과해도 실행 중에 넘칠 수 있다
  • _write·_sbrk 미정의는 OS 가 없어서다. 빈 스텁을 우리가 준다
  • 매 빌드 size 확인을 CI 에 — 용량이 튄 커밋을 즉시 잡는다

꼬리질문 대비

  • ">RAM AT> FLASH 는 무슨 뜻인가?" → VMA 는 RAM(실행 주소), LMA 는 Flash(저장 주소). .data 복사의 근거가 이 한 줄
  • "region RAM overflowed 가 스택도 계산하나?" → 아니다. .user_heap_stack 로 자리를 예약해야 링커가 스택까지 포함해 검사한다
  • "펌웨어 크기를 줄이는 첫 수단은?" → -ffunction-sections -fdata-sections + --gc-sections 로 안 쓰는 함수 제거, 그다음 printf 대체

출처 — GNU ld 매뉴얼 §3 Linker Scripts(MEMORY·SECTIONS·AT·KEEP) · GCC 매뉴얼 Optimize Options(-Os·-ffunction-sections) · GNU Arm Embedded Toolchain · newlib-nano 문서 · Arm, Cortex-M4 Devices Generic User Guide(DUI 0553)

부팅 — 전원을 넣고 main 까지GPIO — 핀 하나에 담긴 전기