임베디드 학습 노트 목차

MCU란 무엇인가 — OS 없이 도는 컴퓨터

임베디드를 처음 만지면 가장 낯선 것이 "프로그램이 끝나면 안 된다" 는 점이다. PC 프로그램은 mainreturn하면 OS가 정리하고 끝난다. MCU에는 돌아갈 OS가 없다. main이 끝나면 갈 곳이 없어 정의되지 않은 동작으로 빠진다.

이 한 가지 차이에서 임베디드의 거의 모든 것이 나온다.


MCU와 MPU — 무엇이 다른가

MCU (마이크로컨트롤러)MPU (마이크로프로세서)
STM32F4, ESP32, AVR라즈베리파이의 BCM, i.MX
메모리칩 안에 Flash·SRAM 내장외부 DRAM 필요
OS없거나 RTOS리눅스 등 범용 OS
부팅전원 넣으면 수십 µs 안에 코드 실행수 초 (부트로더 → 커널 → init)
전력mA~µA수백 mA~W
가격수백 원~수천 원수천 원~수만 원

핵심은 "메모리가 칩 안에 있다" 는 것이다. 그래서 외부 부품이 거의 없어도 돌고, 전원을 넣자마자 명령을 실행할 수 있다.

MCU 한 칩 안에 들어 있는 것

┌──────────────────────────────────────┐
│  CPU 코어 (Cortex-M4)                │
│  ├ Flash  512KB  ← 코드가 여기 산다  │
│  ├ SRAM   128KB  ← 변수가 여기 산다  │
│  ├ NVIC          ← 인터럽트 컨트롤러 │
│  └ 주변장치                          │
│     GPIO · UART · SPI · I2C          │
│     Timer · ADC · DMA · WDT          │
└──────────────────────────────────────┘
        └ 이 전부가 하나의 칩이다

Arduino Uno R3 보드. 가운데 검은 칩이 ATmega328P MCU 이고, 그 안에 CPU·Flash·SRAM·주변장치가 전부 들어 있다.
Arduino Uno R3 — SparkFun Electronics, CC BY 2.0 (Wikimedia Commons)

STM32 Nucleo 보드. 위쪽 절반이 ST-LINK 디버거이고 아래쪽이 실제 타깃 MCU 다. 잘라내면 디버거를 따로 쓸 수 있다.
STM32 Nucleo — CC0 (Wikimedia Commons)

위 두 보드가 입문에 가장 많이 쓰인다. Arduino Uno 의 가운데 검은 칩 하나가 위 그림의 전부를 담고 있고, Nucleo 는 위쪽 절반이 디버거(ST-LINK)라 별도 장비 없이 브레이크포인트를 걸 수 있다.

프로그램이 끝나면 안 된다

int main(void) {
    init_clock();
    init_gpio();

    while (1) {              // ← 이 루프가 없으면 안 된다
        led_toggle();
        delay_ms(500);
    }
    // 여기에 도달하면 안 된다
}

while(1)을 빼면 어떻게 되나. 컴파일러가 만든 시작 코드(startup)에서 main을 호출하는데, main이 반환하면 대개 무한 루프에 갇히거나 리셋된다. 구현에 따라 다르므로 의존하면 안 되는 동작이다.

이것이 슈퍼루프(superloop) 이고, OS 없는 임베디드의 기본 실행 구조다.

while (1) {
    read_sensors();          // ① 입력
    compute_control();       // ② 판단
    drive_actuators();       // ③ 출력
    feed_watchdog();         // ④ "나 살아 있다" 신고
}

이 한 바퀴를 주기(cycle) 라 하고, 그 시간이 시스템의 응답성을 정한다. 한 단계가 오래 걸리면 나머지 전부가 밀린다 — 우선순위도 선점도 없기 때문이다. 그래서 임베디드에서 delay_ms(500) 같은 코드는 초보의 표식이다. 그 0.5초 동안 아무것도 못 한다.

응답 시간을 수로 따져 보기

"버튼을 누르면 100ms 안에 반응해야 한다"는 요구가 있다고 하자. 슈퍼루프에서 이 요구를 만족하는지는 한 바퀴의 최악 시간으로 판단한다.

단계평균최악비고
read_sensors()2 ms5 msI²C 재시도가 붙을 때
compute_control()1 ms3 ms분기에 따라
drive_actuators()0.5 ms1 ms
log_to_uart()1 ms40 ms버퍼가 찼을 때 블로킹
한 바퀴4.5 ms49 ms
  • 버튼은 최악의 경우 한 바퀴를 통째로 기다린 뒤 읽히므로 응답 지연은 최대 49 ms. 요구(100ms)는 만족한다.
  • 그런데 log_to_uart 하나가 최악의 80% 를 차지한다. 여기에 기능을 조금만 더 얹으면 바로 한계를 넘는다. 평균 4.5ms 만 보고 있으면 이 위험이 안 보인다.

핵심 — 슈퍼루프의 응답 시간은 모든 단계의 최악 시간의 합이다. 그래서 임베디드에서는 "평균 얼마"가 아니라 "최악 얼마" 로 말하는 습관이 중요하다. 블로킹 함수 하나가 전체 시스템의 실시간성을 결정한다.

대응은 셋이다 — ① 오래 걸리는 일을 인터럽트·DMA 로 넘기기, ② 상태 기계로 쪼개 한 바퀴에 조금씩 진행하기, ③ 정말 필요하면 RTOS 로 선점 구조 만들기(16편).

왜 OS를 안 쓰나

안 쓰는 게 아니라 필요가 없거나 감당이 안 되는 것이다.

  • 메모리 — 리눅스 커널만 수 MB다. SRAM 128KB에는 들어가지 않는다
  • 부팅 시간 — 에어백 제어기가 3초 뒤에 준비되면 의미가 없다
  • 결정성 — 범용 OS는 "대체로 빠르다"를 보장하지 답이 언제 나오는지는 보장하지 않는다. 모터 제어는 매 1ms마다 반드시 계산이 끝나야 한다

세 번째가 특히 중요하다. 임베디드에서 자주 요구되는 것은 평균 성능이 아니라 최악 시간의 상한이다. 평균 100µs에 최악 50ms인 시스템보다, 평균 500µs에 최악 600µs인 시스템이 낫다.

실시간(real-time)은 "빠르다"가 아니다. 정해진 시간 안에 반드시 끝난다는 뜻이다. 시간 안에 못 끝내면 답이 맞아도 실패다.

  • 경성(hard) — 놓치면 시스템 실패. 에어백, 모터 제어
  • 연성(soft) — 놓치면 품질 저하. 오디오 버퍼, 화면 갱신

전원을 넣으면 무슨 일이 일어나나

① 전원 인가 · 리셋 해제
② 0x0000_0000 에서 스택 포인터 초기값을 읽어 SP 에 넣는다
③ 0x0000_0004 에서 Reset_Handler 주소를 읽어 거기로 점프
④ startup 코드가
     .data 를 Flash → SRAM 으로 복사   (초기값 있는 전역변수)
     .bss 를 0 으로 채운다             (초기값 없는 전역변수)
     클럭·FPU 설정
⑤ main() 호출

주소 0번지의 첫 4바이트가 코드가 아니라 스택 포인터 값이라는 점이 특이하다. Cortex-M은 리셋 시 이 둘을 하드웨어가 자동으로 읽는다 — 그래서 초기화 코드가 실행되기도 전에 스택이 준비돼 있다.

.data 복사가 왜 필요한가 하면 — Flash는 실행 중에 쓸 수 없고 SRAM은 전원이 꺼지면 사라지기 때문이다. 초기값은 Flash에 저장해 두고, 부팅할 때 SRAM으로 옮긴다.

int  counter = 42;      // .data — Flash 에 42 가 있고 부팅 시 SRAM 으로 복사된다
int  total;             // .bss  — 0 으로 채워진다. Flash 공간을 안 쓴다
const int MAX = 100;    // .rodata — Flash 에 남는다. 복사 안 한다

전역 배열을 = {0}으로 초기화하면 .data로 가서 Flash를 그 크기만큼 잡아먹는다. 그냥 두면 .bss라 Flash를 안 쓴다. 1KB 배열 하나가 펌웨어 크기를 1KB 늘리는 차이다.

개발 흐름이 다르다

PC        코드 → 컴파일 → 실행 → 로그 확인
MCU       코드 → 크로스 컴파일 → 플래시 굽기 → 보드에서 실행
                                    └ 로그를 어디로 볼 것인가?

printf를 쓸 화면이 없다. 그래서 디버깅 수단을 처음부터 마련해 둔다.

// ① UART 로 로그 — 가장 흔하다
printf("adc=%d\r\n", value);        // 시리얼 터미널로 나온다

// ② LED — 화면도 UART 도 없을 때
led_on();  /* 여기까지 왔다 */

// ③ GPIO 토글 + 오실로스코프 — 시간을 재야 할 때
GPIOA->BSRR = (1 << 5);     // 측정 시작
compute();
GPIOA->BSRR = (1 << 21);    // 측정 끝  → 파형의 폭이 곧 실행 시간

③이 임베디드다운 방법이다. 핀 하나를 올렸다 내리고 그 폭을 오실로스코프로 재면 함수 실행 시간을 마이크로초 단위로 알 수 있다. 로그를 찍으면 그 로그 자체가 시간을 잡아먹어 측정이 왜곡되는데, GPIO 토글은 한두 클럭이면 끝난다.

크로스 컴파일

개발하는 기계와 코드가 돌 기계가 다르다. PC(x86)에서 만든 실행 파일은 MCU(Arm)에서 돌지 않으므로, 대상 아키텍처용 도구 모음으로 빌드한다. 이것을 크로스 컴파일이라 부르고, 툴체인 이름에 대상이 그대로 적혀 있다.

arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard \
    -O2 -ffunction-sections -fdata-sections \
    -T stm32f4.ld -Wl,--gc-sections \
    main.c startup.s -o firmware.elf

arm-none-eabi-objcopy -O binary firmware.elf firmware.bin
arm-none-eabi-size firmware.elf
#    text    data     bss     dec     hex filename
#   24512     108    8192   32812    802c firmware.elf
#   │         │      └ SRAM 을 쓰지만 Flash 는 안 쓴다
#   │         └ Flash 와 SRAM 을 둘 다 쓴다 (복사되므로)
#   └ Flash 사용량

arm-none-eabi-"ARM 아키텍처 · OS 없음(none) · 임베디드 ABI" 라는 뜻이다. none이 OS가 없다는 표시다.

Flash 사용량 = text + data, SRAM 사용량 = data + bss(+ 스택 + 힙). 이 숫자를 매 빌드마다 보는 습관이 중요하다 — 용량이 넘치면 컴파일이 아니라 링크에서 실패하고, 그 오류 메시지는 초보에게 불친절하다.

SRAM 예산 — size 가 안 보여 주는 것까지 세어야 한다

arm-none-eabi-size 가 알려 주는 것은 data + bss 까지다. 그런데 실제로 SRAM 을 먹는 것은 그게 전부가 아니다. 스택과 힙은 링크 시점에 크기가 안 정해지므로 이 숫자에 안 잡힌다.

SRAM 128KB 시스템의 예산표를 실제로 짜 보면 이렇다.

항목크기size 에 잡히나
.data (초기값 있는 전역)108 B
.bss (초기값 없는 전역)8,192 B
메인 스택4,096 B
인터럽트용 여유1,024 B
DMA 버퍼 2채널4,096 B✓ (보통 .bss)
합계17,516 B (13.4%)남은 113,556 B

함정size 로는 통과했는데 실행 중에 죽는 경우가 여기서 나온다. 링커는 .data + .bss 가 SRAM 에 들어가는지만 검사하고, 스택이 자라다 .bss 를 침범하는 것은 검사하지 않는다. 그래서 전역 배열을 늘려 SRAM 을 95% 채워 놓으면, 링크는 성공하고 깊은 함수 호출이 일어나는 순간 스택이 전역 변수를 덮어쓴다.

증상은 "관계없는 변수가 저 혼자 바뀐다" 로 나타나 원인을 찾기가 매우 어렵다. 대응은 ① 링커 스크립트최소 스택 크기를 선언해 링크 시점에 검사시키기(06편), ② 스택 영역을 특정 패턴으로 채워 두고 최고 수위(high-water mark)를 측정하기(23편).


한눈에 정리

  • main은 끝나면 안 된다 — 돌아갈 OS가 없다. while(1) 슈퍼루프가 기본 구조
  • MCU는 메모리가 칩 안에 있어 부품이 적고 수십 µs 만에 부팅한다
  • 한 단계가 늦으면 전부 밀린다 — 슈퍼루프에는 우선순위도 선점도 없다. delay_ms는 그래서 위험하다
  • 실시간은 빠른 것이 아니라 시간 안에 반드시 끝나는 것 — 평균보다 최악 상한이 중요하다
  • 0번지의 첫 4바이트는 코드가 아니라 스택 포인터 초기값이고, 다음 4바이트가 Reset_Handler 주소다
  • .data는 Flash에서 SRAM으로 복사되고 .bss는 0으로 채워진다= {0} 초기화가 Flash를 잡아먹는 이유
  • 디버깅은 UART 로그 · LED · GPIO 토글 — 시간을 잴 때는 GPIO 토글 + 오실로스코프가 정확하다
  • arm-none-eabi-size로 Flash·SRAM 사용량을 매 빌드 확인 — 넘치면 링크에서 터진다

꼬리질문 대비

  • "MCU 에서 mainreturn 하면 어떻게 되나?" → 돌아갈 OS 가 없다. startup 코드의 무한 루프에 갇히거나 리셋된다 — 구현 정의라 의존하면 안 된다
  • "실시간(real-time)이 '빠르다'와 어떻게 다른가?" → 정해진 시간 안에 반드시 끝나는 것. 평균이 아니라 최악 시간의 상한이 요구된다
  • "전역 배열을 = {0} 으로 초기화하면 무엇이 달라지나?" → .bss 대신 .data 로 가서 그 크기만큼 Flash 를 잡아먹는다
  • "size 가 보여 주는 SRAM 사용량으로 충분한가?" → 아니다. 스택과 힙이 빠져 있어 링크가 통과해도 실행 중 스택이 전역을 침범할 수 있다

출처 — Joseph Yiu, The Definitive Guide to Arm Cortex-M3 and Cortex-M4 Processors 3e · Arm, Cortex-M4 Devices Generic User Guide(DUI 0553) · GNU Arm Embedded Toolchain 문서

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