🔵 C++↗ 🟠 코틀린↗ 🦀 Rust↗ 🐍 파이썬↗ ☕ 자바↗ 🟨 JS·TS↗ 🟣 C#↗ 🗄️ DB↗ 🌐 서버기술↗ 🎓 CS 기술↗
👋 처음이신가요? 이 가이드는 C 언어부터 마이크로컨트롤러 펌웨어까지 이어서 다룹니다 — C 기초 · 하드웨어 기초 · AVR · STM32 · 통신 드라이버 · FreeRTOS · 대규모 구조 · 양산 · 🔍 검색(Ctrl K)에 “UART 링 버퍼”처럼 하려는 일을 한글로 입력 · 코드 상자에 마우스를 올리면 복사 버튼이 나타납니다
C17 · C23 · 정수 · 포인터 · 문자열 · 메모리 · 구조체 · 전처리기 · 미정의 동작

C 는
모든 것의
바닥입니다

운영체제 · 데이터베이스 · 파이썬 인터프리터 · 그리고 세상의 거의 모든 마이크로컨트롤러 펌웨어가 C 로 쓰여 있습니다. 이 탭은 정수 · 배열과 포인터 · 문자열 · 메모리 · 구조체 · 함수 포인터 · 전처리기 · 여러 파일 빌드 · 미정의 동작까지, C 를 정면으로 다룹니다. 자바·파이썬과 다르게 동작하는 지점마다 멈춰서 왜 그런지 설명합니다. 여기서 익힌 포인터와 비트 연산이 그대로 하드웨어 레지스터를 움직이는 도구가 됩니다.

13SECTIONS
C17기준 표준 (C23 표기)
0사전 지식
100%복붙해서 도는 예제
C 01

C 는 왜 아직도 여기 있나

1972년에 나온 언어가 2020년대에도 리눅스 커널 · 파이썬 인터프리터 · SQLite · 모든 운영체제의 바닥을 차지하고 있습니다. 낡아서 남아 있는 게 아니라 대체할 자리가 없어서 남아 있습니다.

C 가 지금도 쓰이는 네 자리

자리이유
운영체제 · 드라이버하드웨어 주소를 있는 그대로 다뤄야 합니다
언어 런타임파이썬 · 루비 · PHP 가 전부 C 로 구현돼 있습니다
임베디드 · 펌웨어램이 수십 KB 인 칩에서는 C 밖에 안 돕니다
언어 사이의 다리C ABI 가 모든 언어의 공용어입니다 — FFI 탭의 주제
이 탭을 읽어야 하는 실질적인 이유 — 다른 언어로 부를 라이브러리를 만들려면 결국 C 인터페이스를 설계하게 됩니다. 안은 C++ 로 쓰더라도 경계는 C 입니다.

C 와 C++ 는 얼마나 다른가

C 코드는 대부분 C++ 컴파일러로도 컴파일됩니다. 하지만 "C++ 는 C 의 상위 집합"이라는 말은 정확하지 않습니다 — void* 암묵 변환, 구조체 초기화, bool, new 같은 이름 등에서 실제로 어긋납니다.

더 중요한 차이는 문법이 아니라 사고방식입니다. C 에는 소멸자도, RAII 도, 템플릿도 없습니다. 모든 자원 관리를 사람이 직접 합니다. 그래서 규율이 필요하고, 그 규율이 무너진 자리에서 세상의 보안 취약점 절반이 나왔습니다.

환경 만들기 — 셋 중 하나면 됩니다

리눅스 · WSL

sudo apt install build-essential gdb
gcc --version

# 컴파일 (경고를 최대로)
gcc -std=c17 -Wall -Wextra -g hello.c -o hello
./hello

macOS

xcode-select --install
clang --version

clang -std=c17 -Wall -Wextra -g hello.c -o hello
./hello

윈도우

# 방법 A — MSYS2 (gcc 를 그대로)
pacman -S mingw-w64-ucrt-x86_64-gcc

# 방법 B — Visual Studio (MSVC)
cl /std:c17 /W4 hello.c

첫 프로그램 — 한 줄씩 뜯어보기

#include <stdio.h>      // printf 의 '선언'만 가져옵니다 (구현은 링크 단계)

int main(void) {        // (void) = 인자를 받지 않는다는 명시
    printf("안녕, C\n");
    return 0;           // 0 = 정상 종료. 셸이 이 값을 읽습니다
}

int main() 와 int main(void) 는 C 에서 의미가 다릅니다. 앞의 것은 "인자 정보를 모른다"이고, 뒤의 것이 "인자가 없다"입니다. C++ 에서는 둘이 같아서 헷갈리기 쉽습니다.

경고 플래그를 반드시 켜세요

# 처음부터 이 조합으로 시작하세요
gcc -std=c17 -Wall -Wextra -Wpedantic \
    -Wshadow -Wconversion -g -O1 main.c -o app

# 개발 중에는 새니타이저까지
gcc -std=c17 -Wall -Wextra -g \
    -fsanitize=address,undefined main.c -o app
C 는 기본 설정이 아무것도 안 알려 줍니다. 경고를 안 켜면 if (x = 1) 같은 오타가 조용히 컴파일됩니다. 경고 플래그는 선택이 아니라 필수입니다.
표준 버전 — 무엇을 고를까. 실무 기본값은 C11 또는 C17입니다(둘은 거의 같습니다). C99 는 오래된 임베디드 툴체인 때문에 아직 쓰이고, C23 은 true/false 내장, nullptr, typeof 같은 편한 것들이 들어왔지만 컴파일러 지원이 아직 고르지 않습니다.
C 02

정수 — 크기가 정해져 있지 않습니다

파이썬 정수는 무한대까지 커지고, 자바 int 는 항상 32비트입니다. C 의 int 는 "최소 16비트, 나머지는 구현에 따라"입니다. 이 한 문장에서 나오는 버그가 아주 많습니다.

표준이 보장하는 것은 최소 크기뿐

타입표준 보장보통(64비트 리눅스)
char1바이트1 · 부호는 구현 정의
short≥ 16비트2
int≥ 16비트4
long≥ 32비트8 (윈도우는 4)
long long≥ 64비트8
size_t크기 표현용8 · 부호 없음
long 은 리눅스에서 8, 윈도우에서 4바이트입니다. 플랫폼 사이를 오가는 구조체에 long 을 쓰면 그 자체가 버그입니다.

그래서 실무에서는 stdint.h

#include <stdint.h>
#include <inttypes.h>   // 출력 형식 매크로

int32_t  a = -5;         // 정확히 32비트, 부호 있음
uint64_t b = 1ULL << 40; // 정확히 64비트, 부호 없음
size_t   n = 100;        // 길이 · 인덱스는 항상 size_t

// 출력할 때도 타입에 맞는 형식을 써야 합니다
printf("%" PRId32 " %" PRIu64 " %zu\n", a, b, n);

// ❌ printf("%d", n);   // size_t 를 %d 로 → UB

파일 형식 · 네트워크 프로토콜 · 다른 언어와 주고받는 구조체에는 반드시 고정 크기 타입을 쓰세요.

부호 있는 오버플로는 UB, 부호 없는 것은 순환

❌ 무엇이 일어날지 알 수 없다
int x = INT_MAX;
x = x + 1;          // 부호 있는 오버플로 = 미정의 동작

// 컴파일러는 "오버플로는 일어나지 않는다"고 가정하고
// 최적화하므로, 아래 검사가 통째로 삭제될 수 있습니다
if (x + 1 < x) { /* 여기는 영영 실행되지 않을 수 있음 */ }
✅ 넘치기 전에 검사한다
#include <limits.h>
if (a > 0 && b > INT_MAX - a) { /* 넘칠 것이다 */ }
else                            { int c = a + b; }

// GCC · Clang 내장 함수를 쓰면 더 간단합니다
int c;
if (__builtin_add_overflow(a, b, &c)) { /* 넘침 처리 */ }

// 부호 없는 타입은 넘침이 UB 가 아니라 '2^n 으로 나눈 나머지'
unsigned u = UINT_MAX; u++;   // 정의된 동작: 0 이 됩니다
부호 있는 값과 없는 값을 비교하면 조용히 뒤집힙니다. int i = -1; size_t n = 3; if (i < n) 는 거짓입니다 — i 가 거대한 부호 없는 수로 변환되기 때문입니다. -Wsign-compare(-Wextra 에 포함)를 켜 두세요.

char 의 부호는 정해져 있지 않다

x86 에서는 부호 있음, ARM 에서는 부호 없음인 경우가 많습니다. 바이트를 다룰 때는 unsigned char 나 uint8_t 를 명시하세요.

정수 승격이 먼저 일어난다

uint8_t a=200, b=100; a+b 는 uint8_t 가 아니라 int 로 계산돼 300 이 됩니다. 다시 담을 때 잘리므로 중간 타입을 항상 의식해야 합니다.

나눗셈과 시프트

음수를 오른쪽으로 시프트하는 것은 구현 정의이고, 시프트 폭이 타입 비트 수 이상이면 UB 입니다. x >> 32 (32비트 int) 는 쓰면 안 됩니다.

C 03

배열과 포인터 — 같아 보이지만 다릅니다

"C 에서 배열은 포인터다"라는 말은 절반만 맞습니다. 배열은 배열이고, 대부분의 자리에서 포인터로 변할 뿐입니다. 이 '변한다'가 어디서 일어나는지 아는 것이 C 를 다루는 핵심입니다.

배열을 함수에 넘기는 순간 — 크기 정보가 사라집니다 ① 선언한 함수 안에서는 배열 int a[10]; 10칸이 붙어 있는 실체 sizeof(a) == 40 — 전체 크기를 압니다 ② 함수 인자로 넘기면 포인터 void f(int a[10]) — 사실은 int* 주소 하나 sizeof(a) == 8 — 10 이라 써도 무시됩니다 그래서 C 함수의 규칙은 늘 이 모양입니다 void sum(const int* a, size_t n); 길이를 반드시 함께 받습니다 — 표준 라이브러리 함수가 전부 이렇게 생긴 이유입니다 배열을 매개변수로 받은 함수 안에서 sizeof 로 길이를 구하려는 시도가 초보자의 1번 실수입니다.
이 규칙 하나가 버퍼 오버플로가 C 에서 흔한 이유이기도 합니다 — 언어가 길이를 안 들고 다닙니다.

배열이 포인터로 변하지 않는 자리

int a[10];

sizeof(a);      // 40  — 여기서는 배열 그대로
&a;             // int(*)[10] — 배열 전체의 주소 (타입이 다릅니다!)
_Alignof(a);    // 정렬도 배열 기준 (식에 쓰는 건 GCC 확장 — 표준은 _Alignof(int[10]))

// 나머지는 전부 첫 원소의 주소로 변합니다
int* p = a;     // == &a[0]
f(a);           // f 는 int* 를 받습니다

// &a 와 a 는 값은 같지만 타입이 다릅니다
a + 1;          // 4바이트 앞
&a + 1;         // 40바이트 앞 (!)

포인터 산술의 단위는 '타입 크기'

int  a[5] = {10,20,30,40,50};
int* p = a;

*(p + 2);       // 30 — 2 는 '2바이트'가 아니라 '원소 2개'(= 8바이트)
p[2];           // 정확히 같은 뜻 (a[i] == *(a+i))
2[a];           // 놀랍게도 이것도 합법입니다 (쓰지 마세요)

// 두 포인터의 차이는 '원소 개수'
ptrdiff_t d = &a[4] - &a[1];    // 3

// ⚠ 배열 범위를 넘은 포인터 연산 자체가 UB 입니다
int* q = a + 5;   // 끝 바로 뒤까지는 합법 (역참조는 불가)
int* r = a + 6;   // ❌ UB

2차원 배열 — 진짜 2차원과 포인터 배열은 다릅니다

연속된 진짜 2차원

int m[3][4];            // 12개가 한 덩어리로 연속

m[1][2];                // == *(*(m+1)+2)
sizeof(m);              // 48
sizeof(m[0]);           // 16 (한 행)

// 함수로 넘길 때 열 수는 반드시 알려야 합니다
void f(int m[][4], int rows);
void g(int (*m)[4], int rows);   // 같은 뜻
메모리가 연속이라 캐시에 유리합니다. 크기를 컴파일 때 아는 경우 이쪽이 정답입니다.

포인터 배열 (흩어진 행)

int** m = malloc(3 * sizeof(int*));
for (int i = 0; i < 3; i++)
    m[i] = malloc(4 * sizeof(int));   // 행마다 따로 할당

m[1][2];                // 두 번 역참조 — 캐시 미스가 늘어납니다

// 해제도 역순으로 전부
for (int i = 0; i < 3; i++) free(m[i]);
free(m);
int** 와 int[3][4] 는 호환되지 않습니다. 서로 넘기면 컴파일은 경고만 내고 실행에서 죽습니다.
실무에서 가장 편한 절충 — 1차원 배열 하나를 잡고 idx = row * cols + col 로 직접 계산하는 방식입니다. 연속성도 지키고, 함수 인자로 넘기기도 쉽고, 해제도 한 번이면 끝납니다. 수치 계산 코드가 대부분 이 방식을 씁니다.
C 04

문자열 — 널 하나로 끝을 표시합니다

C 문자열은 타입이 아니라 약속입니다 — "'\0' 을 만날 때까지가 문자열이다". 길이를 들고 다니지 않는 이 설계에서 지난 50년 보안 취약점의 상당수가 나왔습니다.

"hello" 는 5바이트가 아니라 6바이트입니다 'h' 'e' 'l' 'l' 'o' '\0' ← 끝 표시 strlen 은 이 널을 찾을 때까지 한 바이트씩 셉니다 — 길이를 구하는 것이 O(n) 입니다 버퍼보다 긴 문자열을 복사하면 buf[8] — 우리가 잡은 공간 옆 지역 변수 저장된 복귀 주소 strcpy 는 널을 만날 때까지 계속 씁니다 — 여기까지 전부 덮어씁니다 복귀 주소가 바뀌면 함수가 끝날 때 공격자가 원하는 코드로 점프합니다 — 스택 버퍼 오버플로 공격의 원리입니다.
그래서 C 에서 문자열을 다룰 때는 길이를 언제나 손에 쥐고 있어야 합니다.

쓰면 안 되는 함수와 대안

쓰지 말 것대신
getsC11 에서 표준에서 삭제됨 — fgets
strcpysnprintf(dst, cap, "%s", src)
strcat길이를 세어 snprintf 로 한 번에
sprintfsnprintf — 상한을 반드시 지정
strncpy널을 안 붙일 수 있습니다 — 이름과 달리 안전하지 않음
strncpy 는 안전한 strcpy 가 아닙니다. 원본이 상한보다 길면 널 종료를 붙이지 않고 끝냅니다. 그 뒤 strlen 을 부르면 버퍼 밖으로 달려 나갑니다.

실무에서 쓰는 패턴

#include <stdio.h>
#include <string.h>

char buf[64];

// ① 만들기 — snprintf 하나면 충분합니다
int n = snprintf(buf, sizeof buf, "%s-%d", name, id);
if (n < 0 || (size_t)n >= sizeof buf) {
    /* 잘렸습니다. n 은 '필요했던 길이'를 알려 줍니다 */
}

// ② 읽기 — fgets 는 개행까지 함께 담습니다
if (fgets(buf, sizeof buf, stdin)) {
    buf[strcspn(buf, "\n")] = '\0';   // 개행 제거 관용구
}

// ③ 비교 — == 는 주소 비교입니다!
if (strcmp(a, b) == 0) { /* 같다 */ }

문자열 리터럴은 읽기 전용입니다

❌ 리터럴을 고치려 하면 죽습니다
char* s = "hello";   // 읽기 전용 영역을 가리킴
s[0] = 'H';          // 💥 세그멘테이션 폴트 (UB)

// C++ 에서는 이 대입 자체가 컴파일 에러입니다
// C 에서는 경고만 나거나 아예 안 납니다
✅ 고칠 것은 배열로 복사해 둡니다
char s[] = "hello";  // 6바이트를 스택에 복사
s[0] = 'H';          // ✅ 괜찮습니다

// 고치지 않을 것은 const 를 붙여 의도를 밝힙니다
const char* msg = "hello";   // 컴파일러가 막아 줍니다

한글은 한 글자가 아닙니다

UTF-8 에서 한글 한 글자는 3바이트입니다. strlen("한글") 은 6 을 돌려줍니다. 바이트 수와 글자 수를 구분하세요 — 자르다가 깨지는 사고의 원인입니다.

POSIX · BSD 확장

strdup(복사본 할당) 은 POSIX, strlcpy/strlcat(항상 널 종료) 은 BSD·macOS 계열입니다. 이식성이 필요하면 snprintf 로 통일하세요.

길이를 들고 다니는 습관

큰 프로젝트는 대부분 { char* p; size_t len; } 같은 자체 문자열 구조체를 만들어 씁니다. 길이를 잃지 않는 것만으로 버그가 크게 줄어듭니다.

C 05

메모리 — malloc 과 그 다섯 가지 함정

C 에는 가비지 컬렉터도, 소멸자도, unique_ptr 도 없습니다. 내가 잡은 것은 내가 놓아야 하고, 놓은 뒤에는 다시 쳐다보지 말아야 합니다. 규칙은 두 줄이지만, 지키는 일이 어렵습니다.

기본 네 함수

#include <stdlib.h>

int* p = malloc(n * sizeof *p);   // 쓰레기 값으로 채워져 있음
if (!p) { /* 실패 처리 — 반드시 검사 */ }

int* q = calloc(n, sizeof *q);    // 0 으로 초기화 + 곱셈 넘침 검사

int* r = realloc(p, m * sizeof *p);
if (!r) { free(p); return -1; }   // ⚠ 실패해도 p 는 살아 있습니다
p = r;                            //   바로 대입하면 p 를 잃습니다

free(p);
p = NULL;                         // 해제 후 NULL — 습관으로 만드세요
sizeof *p 를 쓰세요. sizeof(int) 라고 쓰면 나중에 타입을 바꿀 때 여기를 같이 고쳐야 합니다. *p 로 쓰면 타입이 바뀌어도 자동으로 따라옵니다.

다섯 가지 함정

이름증상
메모리 누수free 를 안 함 — 오래 도는 프로세스가 서서히 죽습니다
해제 후 사용free 뒤에 접근 — 보안 취약점 1위
이중 해제같은 것을 두 번 free — 힙 구조가 깨집니다
버퍼 넘침잡은 크기 밖에 쓰기 — 옆 데이터가 조용히 바뀝니다
미초기화 읽기malloc 직후의 값을 그냥 씀 — 실행마다 결과가 달라집니다
다섯 개 전부 ASan 이 잡아 줍니다. 개발과 CI 에서 -fsanitize=address 를 켜는 것이 이 절의 결론입니다.
힙에서 벌어지는 네 가지 시나리오 정상 — 잡고 · 쓰고 · 놓고 · NULL malloc → 사용 → free → p = NULL. 이 네 단계가 한 함수 안에서 보이면 실수하기 어렵습니다 누수 — free 를 빠뜨림 블록이 힙에 영영 남습니다. 요청마다 조금씩 새면 며칠 뒤 OOM 으로 죽습니다 — 재현이 가장 어려운 버그 해제 후 사용 — 반납한 자리를 계속 가리킴 그 자리는 곧 다른 데이터가 차지합니다. 값이 멀쩡해 보이다가 어느 순간부터 이상해집니다 이중 해제 — 같은 블록을 두 번 반납 힙 관리 자료구조 자체가 깨져, 전혀 관계없는 나중의 malloc 에서 크래시가 납니다 세 가지 모두 free 직후 포인터를 NULL 로 만드는 습관 하나로 크게 줄어듭니다 — free(NULL) 은 안전합니다.
"크래시가 난 자리"와 "버그가 있는 자리"가 완전히 다른 것이 힙 버그의 특징입니다.

소유권을 코드로 표현하는 방법

함수 이름과 주석으로 규약을 밝힌다

/* 반환값의 소유권은 호출자에게 넘어갑니다.
   다 쓴 뒤 buffer_free() 로 해제하세요. */
Buffer* buffer_create(size_t cap);
void    buffer_free(Buffer* b);

/* 반환값은 내부 버퍼를 가리킵니다.
   해제하지 마세요. b 가 살아 있는 동안만 유효합니다. */
const char* buffer_data(const Buffer* b);

C 에는 소유권을 표현할 문법이 없습니다. 이름 규칙(create/free 짝)과 const 가 유일한 도구입니다.

에러 처리 — goto 가 정답인 드문 자리

int load(const char* path, Result* out) {
    FILE*  f   = NULL;
    char*  buf = NULL;
    int    rc  = -1;

    f = fopen(path, "rb");
    if (!f)   goto done;

    buf = malloc(SIZE);
    if (!buf) goto done;

    if (fread(buf, 1, SIZE, f) != SIZE) goto done;

    out->data = buf;  buf = NULL;   // 소유권을 넘겼으니 여기서 비웁니다
    rc = 0;

done:
    free(buf);                     // NULL 이면 아무 일도 안 합니다
    if (f) fclose(f);
    return rc;
}
C 에는 finally 도 소멸자도 없습니다. 정리 지점을 한 곳으로 모으는 goto 가 커널을 포함한 대형 C 코드베이스의 표준 관용구입니다.

malloc 반환값 캐스팅

C 에서는 (int*)malloc(...) 처럼 캐스팅하지 않는 것이 권장됩니다. 캐스팅이 stdlib.h 를 빠뜨린 실수를 가려 버리기 때문입니다. (C++ 에서는 반대로 캐스팅이 필수입니다.)

스택에 잡는 것이 더 낫다

크기가 작고 함수 안에서만 쓴다면 지역 배열이 훨씬 빠르고 안전합니다. 스택은 보통 1~8MB 이므로, 수 KB 까지는 고민하지 말고 스택에 두세요.

아레나 · 풀 할당

수명이 같은 객체가 많다면 큰 덩어리를 한 번 잡고 잘라 쓰다가 한 번에 버리는 방식이 빠르고 누수도 없습니다. 게임·컴파일러에서 널리 쓰는 기법입니다.

C 06

구조체 · 정렬과 패딩

구조체 크기는 필드 크기의 합이 아닙니다. CPU 가 좋아하는 위치에 맞추려고 컴파일러가 빈칸을 끼워 넣기 때문입니다. 파일 형식이나 네트워크 프로토콜을 구조체로 다루려면 이 규칙을 알아야 합니다.

필드 순서만 바꿔도 구조체 크기가 달라집니다 ① char · int · char 순서 — 12바이트 a 빈칸 3바이트 n (int · 4바이트) b 빈칸 3바이트 빈칸이 6바이트 — 실제 데이터는 6바이트인데 크기는 12바이트입니다 ② 큰 것부터 — 8바이트 n (int · 4바이트) a b 빈칸 2 빈칸이 2바이트로 줄었습니다 — 구조체를 백만 개 만들면 4MB 차이입니다 규칙은 하나입니다 — 각 필드는 자기 크기의 배수 위치에서 시작해야 합니다 그리고 구조체 전체 크기는 가장 큰 필드 크기의 배수로 맞춰집니다(배열로 놓을 수 있어야 하므로)
멤버를 크기가 큰 것부터 선언하는 것만으로 메모리가 줄어듭니다 — 공짜 최적화입니다.

확인하는 법

#include <stddef.h>
#include <stdio.h>

struct A { char a; int n; char b; };

printf("%zu\n", sizeof(struct A));          // 12
printf("%zu\n", offsetof(struct A, n));     // 4  ← 빈칸 3 뒤
printf("%zu\n", _Alignof(struct A));        // 4

// 컴파일 때 못 박아 두는 방법 (C11)
_Static_assert(sizeof(struct A) == 12, "레이아웃이 바뀌었습니다");

_Static_assert 를 걸어 두면, 누군가 필드를 추가해 레이아웃을 바꿨을 때 빌드가 막아 줍니다. 파일 형식·프로토콜 구조체에는 꼭 넣으세요.

빈칸을 없애는 packed — 그리고 대가

// GCC · Clang
struct __attribute__((packed)) Header {
    uint8_t  type;
    uint32_t length;      // 1바이트 위치에서 시작 — 정렬이 깨집니다
};

// MSVC
#pragma pack(push, 1)
struct Header { uint8_t type; uint32_t length; };
#pragma pack(pop)
packed 는 마지막 수단입니다. 정렬되지 않은 접근은 x86 에서는 느려지기만 하지만 일부 ARM 에서는 크래시합니다. 파일에서 읽을 때는 바이트를 하나씩 읽어 조립하는 편이 이식성과 안전성 모두에서 낫습니다.

공용체와 비트필드

union — 같은 자리를 여러 타입으로

// 크기는 '가장 큰 멤버' 하나 — 동시에 하나만 유효합니다
typedef struct {
    enum { INT, STR, DBL } tag;    // 무엇이 들었는지 표시
    union { int i; char* s; double d; } v;
} Value;

Value x = { .tag = INT, .v.i = 42 };

switch (x.tag) {
    case INT: printf("%d\n", x.v.i); break;
    /* ... */
}
넣은 것과 다른 멤버를 읽는 것은 C 에서 허용되지만(타입 펀), C++ 에서는 UB 입니다. 태그를 같이 두는 습관이 안전합니다.

비트필드 — 플래그를 촘촘히

struct Flags {
    unsigned enabled  : 1;
    unsigned mode     : 3;   // 0~7
    unsigned reserved : 4;
};   // 쓰는 비트는 8개 — 단 sizeof 는 보통 4 (unsigned 단위로 잡힘)

읽기 쉽지만 비트 배치 순서가 구현 정의입니다. 하드웨어 레지스터나 통신 프로토콜에는 직접 시프트와 마스크를 쓰는 편이 이식성이 높습니다.

구조체를 그대로 파일에 쓰거나 소켓으로 보내지 마세요. 패딩 · 엔디안 · 타입 크기가 기계마다 다릅니다. 필드마다 명시적으로 직렬화하는 코드를 쓰는 것이 결국 가장 빠른 길입니다.
C 07

함수 포인터 — C 가 다형성을 흉내 내는 법

C 에는 인터페이스도 가상 함수도 없습니다. 그 자리를 함수 포인터가 채웁니다. 리눅스 커널의 파일 시스템, qsort, 이벤트 콜백이 전부 이 하나로 돌아갑니다.

문법 — 읽는 순서만 익히면 됩니다

int add(int a, int b) { return a + b; }

// 이름부터 안쪽으로 읽습니다: f 는 → 포인터 → 함수 → int 반환
int (*f)(int, int) = add;      // &add 라고 써도 같습니다
int r = f(2, 3);               // (*f)(2,3) 과 동일

// typedef 로 감싸면 훨씬 읽힙니다
typedef int (*BinOp)(int, int);
BinOp ops[] = { add, sub, mul };
int r2 = ops[0](2, 3);

// 괄호를 빼면 뜻이 완전히 달라집니다
int* g(int, int);              // int* 를 반환하는 함수 (포인터 아님)

qsort — 표준 라이브러리의 예

#include <stdlib.h>

int cmp_int(const void* a, const void* b) {
    int x = *(const int*)a, y = *(const int*)b;
    return (x > y) - (x < y);      // 뺄셈은 오버플로 위험이 있습니다
}

int a[5] = {3,1,4,1,5};
qsort(a, 5, sizeof a[0], cmp_int);
return x - y; 로 쓰면 안 됩니다. 큰 값들에서 정수 오버플로가 나서 정렬이 조용히 틀어집니다. 위처럼 비교 결과의 차로 쓰세요.
함수 포인터 표 — C++ vtable 을 손으로 만든 것과 같습니다 공통 코드 s->ops->read(s, buf, n) 어떤 구현인지 모릅니다 ops 표 read · write · close 함수 주소 세 개 파일 구현 소켓 구현 메모리 버퍼 구현 콜백에는 사용자 데이터 포인터를 반드시 함께 받으세요 void on_event(Event* e, void* user); — 이 void* 가 없으면 전역 변수로 상태를 넘기게 됩니다 함수 포인터와 데이터 포인터는 원칙적으로 다른 종류입니다 void* 에 함수 주소를 담는 것은 표준상 보장되지 않습니다 — dlsym 을 쓸 때 흔히 마주치는 경고입니다
C++ 의 virtual 은 컴파일러가 이 표를 대신 만들어 주는 것일 뿐입니다.

인터페이스 흉내 — 전체 모습

typedef struct Stream Stream;

typedef struct {
    ssize_t (*read )(Stream*, void*, size_t);
    ssize_t (*write)(Stream*, const void*, size_t);
    void    (*close)(Stream*);
} StreamOps;

struct Stream {
    const StreamOps* ops;   // 첫 필드에 두는 것이 관례
    /* 구현마다 다른 상태가 뒤에 옵니다 */
};

// 부르는 쪽은 구현을 전혀 모릅니다
ssize_t n = s->ops->read(s, buf, sizeof buf);

쓸 때 주의할 것

  • NULL 검사 — 표의 함수가 비어 있을 수 있습니다
  • 시그니처 불일치 — 잘못된 타입으로 부르면 UB. typedef 로 고정하세요
  • 수명 — 콜백에 넘긴 void* 가 콜백이 불릴 때까지 살아 있어야 합니다
  • C ABI 경계 — 다른 언어에서 넘어온 콜백은 호출 규약이 맞아야 합니다
  • 등록 해제 — 콜백을 등록했으면 해제하는 함수도 함께 제공하세요
C 08

파일 입출력과 에러 처리

C 의 에러 처리는 반환값과 errno 뿐입니다. 예외가 없으니 모든 호출의 반환값을 확인해야 하고, 확인하지 않은 한 줄이 나중에 사고가 됩니다.

텍스트 읽기 — 한 줄씩

#include <stdio.h>
#include <errno.h>
#include <string.h>

FILE* f = fopen("data.txt", "r");
if (!f) {
    fprintf(stderr, "열기 실패: %s\n", strerror(errno));
    return -1;
}

char line[256];
while (fgets(line, sizeof line, f)) {
    line[strcspn(line, "\n")] = '\0';
    /* 처리 */
}
if (ferror(f)) { /* 읽기 도중 오류 */ }

fclose(f);
feof(f) 를 루프 조건으로 쓰면 마지막 줄이 두 번 처리됩니다. feof 는 "읽기를 시도해서 실패한 뒤"에 참이 되기 때문입니다. 위처럼 읽기 함수의 반환값을 조건으로 쓰세요.

바이너리 읽기 — 통째로

FILE* f = fopen("blob.bin", "rb");   // ⚠ 윈도우에서 "b" 는 필수
if (!f) return -1;

fseek(f, 0, SEEK_END);
long size = ftell(f);                // 2GB 넘으면 부족합니다
if (size < 0) { fclose(f); return -1; }   // ftell 실패는 -1
fseek(f, 0, SEEK_SET);

unsigned char* buf = malloc((size_t)size);
if (!buf) { fclose(f); return -1; }
size_t got = fread(buf, 1, (size_t)size, f);
if (got != (size_t)size) { /* 짧게 읽힘 */ }

fclose(f);
free(buf);

큰 파일에는 fseeko/ftello(POSIX) 또는 메모리 매핑(mmap·CreateFileMapping)을 씁니다.

규칙이유
errno 는 즉시 읽는다다음 라이브러리 호출이 덮어씁니다. 실패 직후에 저장하세요
성공 시 errno 는 의미 없다성공해도 값이 바뀔 수 있습니다 — 반환값으로 먼저 판단하세요
fclose 도 실패할 수 있다버퍼를 비우다 디스크가 꽉 차면 여기서 실패합니다. 쓰기 파일은 반환값을 보세요
바이너리는 "b" 모드윈도우에서 텍스트 모드는 \n 을 \r\n 으로 바꿉니다
stdout 은 줄 단위 버퍼파이프로 넘기면 블록 버퍼가 됩니다 — 크래시하면 로그가 통째로 사라집니다
에러는 stderr 로버퍼링이 없고, 리다이렉트로 분리할 수 있습니다

반환값 규약을 하나로

프로젝트 안에서 "0 성공 · 음수 에러" 처럼 한 가지로 통일하세요. 어떤 함수는 0 이 성공이고 어떤 함수는 0 이 실패면 호출부에서 반드시 헷갈립니다.

부분 읽기 · 부분 쓰기

read/write(POSIX)는 요청한 만큼 다 처리하지 않을 수 있습니다. 소켓과 파이프에서 특히 그렇습니다 — 루프로 감싸는 것이 기본입니다.

경로와 인코딩

윈도우에서 한글 경로는 fopen 으로 안 열릴 수 있습니다. _wfopen 을 쓰거나 UTF-8 매니페스트를 켜야 합니다 — 크로스 플랫폼 코드에서 자주 만나는 함정입니다.

C 09

전처리기 — 강력하고 위험합니다

#define 은 문법을 아는 도구가 아니라 텍스트 치환기입니다. 컴파일러가 보기 전에 소스를 갈아엎기 때문에, 잘 쓰면 유용하고 잘못 쓰면 디버거로도 안 보이는 버그를 만듭니다.

매크로가 무는 세 가지

// ① 괄호 누락
#define SQ(x) x * x
SQ(1 + 2)        // → 1 + 2 * 1 + 2  = 5  (9 가 아닙니다)
#define SQ(x) ((x) * (x))            // ✅ 전부 괄호로

// ② 인자를 두 번 평가
#define MAX(a,b) ((a) > (b) ? (a) : (b))
MAX(i++, j)      // i 가 두 번 증가할 수 있습니다

// ③ 여러 문장을 한 덩어리로
#define LOG(m) printf("%s", m); fflush(stdout)
if (x) LOG("hi");    // fflush 는 if 밖에서 항상 실행됩니다!

#define LOG(m) do { printf("%s", m); fflush(stdout); } while (0)
상수는 매크로 대신 enum 이나 static const 를 쓰세요. 타입이 있고, 디버거에 이름이 보이고, 스코프도 지켜집니다.

그래도 매크로가 필요한 자리

// 파일 · 줄 번호가 필요한 로깅 — 함수로는 불가능합니다
#define LOG_ERR(fmt, ...) \
    fprintf(stderr, "[%s:%d] " fmt "\n", __FILE__, __LINE__, __VA_ARGS__)

// 배열 길이 (배열에만 써야 합니다 — 포인터에 쓰면 틀립니다)
#define COUNT_OF(a) (sizeof(a) / sizeof((a)[0]))

// 조건부 컴파일
#ifdef NDEBUG
#  define DBG(...) ((void)0)
#else
#  define DBG(...) LOG_ERR(__VA_ARGS__)
#endif

// 인클루드 가드 — 모든 헤더의 첫 줄
#ifndef MYLIB_BUFFER_H
#define MYLIB_BUFFER_H
/* ... */
#endif

플랫폼 · 컴파일러 분기

자주 쓰는 미리 정의된 매크로

매크로참이 되는 곳
_WIN32윈도우 (64비트에서도 정의됩니다)
__linux__리눅스
__APPLE__macOS · iOS
__GNUC__GCC 와 Clang 둘 다
_MSC_VERMSVC — 값이 버전
__cplusplusC++ 컴파일러로 컴파일 중
__STDC_VERSION__C 표준 버전 (C17 은 201710L)

C++ 에서도 부를 수 있는 헤더 만들기

#ifndef MYLIB_H
#define MYLIB_H

#ifdef __cplusplus
extern "C" {
#endif

int mylib_add(int a, int b);
void mylib_free(char* p);

#ifdef __cplusplus
}
#endif

#endif  /* MYLIB_H */
C 라이브러리 헤더의 표준 형태입니다. 이 여섯 줄이 없으면 C++ 에서 포함할 때 이름 맹글링 때문에 링크가 실패합니다. (FFI 탭에서 자세히 다룹니다)
#pragma once 는 표준이 아닙니다 — 다만 GCC · Clang · MSVC 가 전부 지원해서 실무에서는 널리 쓰입니다. 심볼릭 링크나 같은 파일이 두 경로로 보이는 환경에서 드물게 오작동하므로, 배포용 공개 헤더에는 전통적인 인클루드 가드를 권합니다.
C 10

여러 파일로 나누기 · 빌드

파일이 두 개가 되는 순간 선언과 정의, 내부와 외부를 구분해야 합니다. C 에는 모듈이 없어서 헤더와 링커의 규칙이 그 역할을 대신합니다.

헤더에는 '무엇이 있다'만, 소스에는 '실제 내용'을 buffer.h 타입 선언 · 함수 선언 extern 전역 선언 인클루드 가드 main.c — 포함해서 사용 buffer.c — 실제 정의 링커 선언과 정의를 잇는다 정의는 정확히 하나여야 static — 그 파일 안에서만 보이게 하는 스위치 static 을 붙이면 다른 파일에서 볼 수 없습니다 이름이 겹쳐도 충돌하지 않습니다 컴파일러가 더 과감하게 최적화합니다 안 붙이면 전역 심볼이 되어 링커에 노출됩니다 같은 이름이 두 파일에 있으면 충돌 공개할 것이 아니라면 붙이는 편이 낫습니다 헤더에 전역 변수를 그냥 쓰면 포함한 파일마다 정의가 생깁니다 — 헤더는 extern, 소스에 한 번만 정의하세요.
C 의 "모듈"은 결국 헤더 + static + 이름 접두사 세 가지로 흉내 내는 것입니다.

전역 변수를 여러 파일에서 쓰기

/* config.h — 선언만 */
extern int g_verbose;          // "어딘가에 있다"

/* config.c — 정의는 딱 한 번 */
int g_verbose = 0;

/* main.c */
#include "config.h"
int main(void) { g_verbose = 1; }
헤더에 int g_verbose; 라고 쓰면 포함하는 파일마다 정의가 생겨 multiple definition 이 납니다. (일부 컴파일러는 관대하게 넘어가지만 표준은 아닙니다)

가장 단순한 Makefile

CC      = gcc
CFLAGS  = -std=c17 -Wall -Wextra -g -O2
OBJS    = main.o buffer.o config.o

app: $(OBJS)
	$(CC) $(CFLAGS) -o $@ $(OBJS)

%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@

clean:
	rm -f $(OBJS) app

들여쓰기는 반드시 탭이어야 합니다 — 스페이스를 쓰면 "missing separator" 에러가 납니다. Make 의 대표적인 첫 관문입니다.

헤더 의존성 — 안 잡으면 반드시 물립니다

# 헤더를 고쳤는데 다시 빌드가 안 되는 문제를 막습니다
CFLAGS += -MMD -MP
-include $(OBJS:.o=.d)

이 두 줄이 없으면 헤더를 고쳐도 .o 가 그대로 남아 "고쳤는데 왜 안 바뀌지"가 반복됩니다. 실무에서는 이런 것을 알아서 처리하는 CMake 를 쓰는 편이 낫습니다(빌드 탭 참고).

공개 API 를 설계하는 규칙

  • 공개 함수는 전부 같은 접두사로 (mylib_) — C 에는 네임스페이스가 없습니다
  • 내부용 함수·변수는 전부 static
  • 구조체 내용은 가능하면 헤더에 노출하지 않기(불투명 포인터)
  • 헤더는 스스로 포함 가능해야 합니다 — 필요한 헤더를 자기가 포함
  • 헤더에서 <stdio.h> 같은 큰 헤더를 불필요하게 포함하지 않기
C 11

미정의 동작 — C 를 위험하게 만드는 것

UB 는 "이상한 값이 나온다"가 아니라 "표준이 아무 약속도 하지 않는다"입니다. 컴파일러는 UB 가 절대 일어나지 않는다고 가정하고 최적화합니다 — 그래서 내가 쓴 검사 코드가 통째로 사라지는 일이 벌어집니다.

UB 가 무서운 진짜 이유 — 검사 코드가 사라집니다 int v = *p; if (p == NULL) return; 내가 쓴 코드 컴파일러의 추론 "역참조가 성공했으니 p 는 NULL 이 아니다" 검사 삭제 if 문이 통째로 없어집니다 실무에서 가장 자주 밟는 여섯 가지 ① 부호 있는 정수 오버플로 a + b 가 INT_MAX 를 넘는 순간 넘침 검사가 최적화로 사라집니다 ② 초기화하지 않은 값 읽기 int x; if (x) — 실행마다 다릅니다 디버그에서만 되는 코드의 원인 ③ 배열 범위 밖 접근 a[n] — 끝 바로 뒤도 읽으면 UB 한 칸 차이 실수가 가장 흔합니다 ④ 수명이 끝난 것을 가리키기 지역 배열의 주소를 return 해제한 포인터를 계속 사용
"내 컴퓨터에서는 잘 돌던데"가 UB 에서는 아무 근거가 되지 않습니다.

나머지 두 가지 — 덜 알려졌지만 자주 밟습니다

// ⑤ 엄격한 앨리어싱 — 다른 타입 포인터로 같은 메모리 읽기
float f = 1.0f;
int   i = *(int*)&f;          // ❌ UB (-O2 에서 이상해집니다)

uint32_t bits;
memcpy(&bits, &f, sizeof bits);   // ✅ memcpy 는 항상 안전합니다

// ⑥ 한 식 안에서 같은 변수를 두 번 바꾸기
i = i++ + 1;                  // ❌ UB
arr[i] = i++;                 // ❌ UB
printf("%d %d", i++, i++);    // ❌ 인자 평가 순서도 정해져 있지 않습니다

memcpy 는 최적화 단계에서 대개 사라집니다. "느릴 것 같아서" 포인터 캐스팅을 쓸 이유가 없습니다.

방어하는 방법 — 도구가 전부입니다

도구잡아 주는 것
-Wall -Wextra미초기화 · 부호 비교 · 빠뜨린 return
-fsanitize=undefined오버플로 · 널 역참조 · 잘못된 시프트
-fsanitize=address범위 밖 접근 · 해제 후 사용 · 누수
Valgrind재컴파일 없이 검사 (느리지만 강력)
clang-tidy · cppcheck실행 없이 코드에서 패턴 탐지
퍼징(libFuzzer)사람이 생각 못 한 입력을 자동으로
UB 는 사람의 주의력으로 막을 수 없습니다. 도구를 CI 에 붙이는 것이 유일하게 확실한 대책입니다.
"UB 를 이용한 트릭"은 코드베이스의 시한폭탄입니다. 컴파일러 버전이 올라가거나 최적화 수준이 바뀌는 순간 동작이 달라집니다. 실제로 리눅스 커널은 -fno-strict-aliasing 을 켜서 표준이 아니라 컴파일러와 따로 약속하는 방식으로 이 문제를 우회합니다.
C 12

C 와 C++ — 실제로 다른 지점들

"C++ 는 C 의 상위 집합"이라는 설명은 실무에서는 틀립니다. C 코드를 C++ 로 컴파일하면 안 되는 것들이 있고, 반대로 C++ 습관을 C 에 가져오면 어색해집니다. 두 언어를 오갈 때 걸리는 지점을 모았습니다.

항목CC++
void* 대입암묵 변환됨 — 캐스팅 불필요캐스팅 필수 — malloc 에 캐스팅이 필요한 이유
구조체 이름struct P p; — struct 를 붙여야P p; 로 충분
지정 초기화C99 부터 {.x = 1}C++20 부터, 순서까지 지켜야 함
함수 선언 f()"인자 정보 없음" — 아무거나 받음"인자 없음" — f(void) 와 같음
bool<stdbool.h> 필요 (C23 부터 내장)키워드로 내장
union 타입 펀허용 — 다른 멤버로 읽어도 됨UB — memcpy/bit_cast 를 쓸 것
이름 맹글링없음 — 심볼 이름이 그대로있음 — extern "C" 가 필요한 이유
new·class·template그냥 식별자로 쓸 수 있음예약어 — C 헤더에 쓰면 C++ 에서 깨짐
문자 리터럴 'a'int 타입char 타입 — sizeof('a') 가 다릅니다

C 헤더를 C++ 에서도 쓰이게 만들기

  • extern "C" 가드를 반드시 넣습니다 (C 09 참고)
  • C++ 예약어를 식별자로 쓰지 않습니다 — new, class, template, this, operator
  • 구조체 태그와 typedef 이름을 같게 두면 양쪽에서 자연스럽습니다
  • 가변 길이 배열(VLA)은 C++ 에 없습니다 — 공개 헤더에 쓰지 마세요
  • restrict 는 C 전용입니다 — 매크로로 감싸 두세요

C 스타일이 C++ 에서 문제가 되는 것들

// ① malloc/free 로 C++ 객체를 다루면 생성자·소멸자가 안 불립니다
Widget* w = (Widget*)malloc(sizeof(Widget));   // ❌ 생성자 미호출
Widget* w = new Widget();                      // ✅

// ② memset 으로 클래스를 0 으로 미는 것
memset(&obj, 0, sizeof obj);   // ❌ vptr 까지 밀어 버립니다

// ③ C 캐스트는 무엇을 하는지 알 수 없습니다
(Derived*)base;                // ❌ 어떤 캐스트인지 불분명
static_cast<Derived*>(base);   // ✅ 의도가 드러납니다

// ④ 배열 대신 std::vector, 문자열 대신 std::string
//    C++ 에서 원시 배열을 쓸 이유는 거의 없습니다

어느 쪽으로 쓸 것인가 — 실무 판단

C 로 쓰는 게 나은 경우

  • 임베디드 · 부트로더 · 커널 모듈
  • 여러 언어에서 부를 라이브러리의 경계
  • C 로 된 기존 코드베이스에 얹는 코드
  • 빌드 도구가 C++ 를 지원하지 않는 환경

C++ 로 쓰는 게 나은 경우

  • 자원 관리가 복잡한 코드 — RAII 가 압도적
  • 자료구조가 많이 필요한 코드 — STL
  • 제네릭이 필요한 코드 — 템플릿
  • 규모가 커질 코드 — 타입 시스템의 도움이 큽니다

실무에서 가장 흔한 절충

안은 C++ 로 쓰고, 밖으로 내보내는 경계만 C 로 만드는 방식입니다. RAII 와 STL 의 이득을 그대로 누리면서, 파이썬·Node·C#·Go 어디서든 부를 수 있는 .so·.dll 이 나옵니다.

다음으로 볼 곳 — 여기서 배운 포인터 · 메모리 · 헤더 개념은 C ABI · FFI 탭에서 그대로 쓰입니다. C++ 의 RAII 와 스마트 포인터가 이 절의 문제들을 어떻게 없애는지는 현대 C++ 탭에서 볼 수 있습니다.
C 13

표준 라이브러리 지도 — 무엇이 어느 헤더에 있나

파이썬은 import 할 모듈 이름을 몰라도 검색하면 나옵니다. C 는 헤더가 30개도 안 되는 대신 이름이 줄임말이라 짐작이 안 됩니다 — atoi 가 왜 stdlib.h 에 있는지, memcpy 가 왜 string.h 에 있는지는 외우는 수밖에 없습니다. 이 절은 그 지도를 한 장에 담고, 실제로 손이 자주 가는 함수만 골라 표로 정리합니다.

C 표준 라이브러리 — 헤더 아홉 묶음이면 거의 전부입니다 stdio.h printf scanf fopen fgets 파일과 표준 입출력 stdlib.h malloc free qsort exit 메모리 · 변환 · 난수 string.h memcpy strlen strcmp 문자열이 아니라 메모리 math.h sqrt pow fabs round 링크에 -lm 이 필요 time.h time clock strftime 시각 · 경과 시간 측정 ctype.h isdigit isspace tolower 파서를 쓸 때 항상 stdint.h · stdbool.h int32_t uint64_t bool 크기가 확실한 타입 errno.h · assert.h errno perror assert 실패 원인과 개발용 검사 threads.h · stdatomic.h C11 이후 · 선택 기능 실무는 pthreads 가 다수 보조 헤더 — 필요할 때만 찾아 쓰면 됩니다 limits.h 정수 최댓값 · float.h 실수 정밀도 · stdarg.h 가변 인자 · stddef.h size_t NULL offsetof · locale.h 지역화
헤더 이름은 기능이 아니라 역사로 나뉘어 있습니다 — string.h 가 문자열 전용이 아니라는 점이 대표적입니다.

가장 많이 쓰는 함수 — stdio · stdlib

함수하는 일 · 주의
printf/fprintf서식 출력. 형식 문자와 타입이 다르면 UB
snprintf버퍼에 서식 출력 — sprintf 대신 항상 이것
fgets한 줄 읽기. 개행이 같이 들어옵니다
fopen/fclose실패하면 NULL. 반드시 검사
fread/fwrite바이너리 덩어리 입출력. 반환값 = 항목 수
malloc/calloccalloc 은 0으로 채우고 곱셈 넘침도 검사
realloc실패하면 원본 유지 + NULL 반환 ★
strtol/strtod문자열 → 수. atoi 대신 이것(에러 확인 가능)
qsort/bsearch비교 함수 포인터를 넘기는 정렬 · 이진 탐색
exit/atexit종료와 종료 훅. abort 는 정리 없이 죽음
man 페이지가 진짜 문서입니다 — 리눅스·맥에서 man 3 strtol 을 치면 반환값·에러·예제가 전부 나옵니다. 3번 절이 C 라이브러리, 2번 절이 시스템 콜입니다. 윈도우라면 cppreference.com/w/c 가 같은 역할을 합니다.

string.h — 이름과 달리 절반이 메모리 함수

함수하는 일 · 주의
memcpy겹치면 안 됨. 겹치면 memmove
memset바이트 단위 채움. 0 말고는 거의 안 씁니다
memcmp구조체 비교에 쓰면 패딩 때문에 틀립니다
strlen널을 만날 때까지 셉니다 — O(n). 루프 조건에 넣지 말 것
strcmp같으면 0. if (strcmp(a,b)) 는 “다르면”입니다
strcpy/strcat길이 검사 없음 — 버퍼 오버플로 1순위
strncpy안전해 보이지만 널을 안 붙일 수 있습니다 ★
strchr/strstr못 찾으면 NULL
strtok원본을 파괴 + 전역 상태 — strtok_r 을 쓰세요
strdupPOSIX(C23 부터 표준). free 는 내 책임
strncpy 는 안전 함수가 아닙니다 — 원본이 n 바이트 이상이면 널 종료를 붙이지 않습니다. “문자열 복사”가 목적이라면 snprintf(dst, n, "%s", src) 가 훨씬 안전하고 의도도 분명합니다. BSD 에서 온 strlcpy 는 표준이 아니라 플랫폼마다 있고 없고가 갈립니다 — 이식성이 필요하면 snprintf 가 정답입니다.

직접 확인 — 헤더 하나가 무엇을 주는지 보기 🟢 기초

한 파일에 몰아 담은 표준 라이브러리 맛보기

#include <stdio.h>      // printf, fopen
#include <stdlib.h>     // malloc, strtol, qsort
#include <string.h>     // strlen, memcpy
#include <ctype.h>      // isspace
#include <math.h>       // sqrt   (링크에 -lm)
#include <time.h>       // clock
#include <errno.h>      // errno
#include <limits.h>     // INT_MAX
#include <stdint.h>     // int64_t
#include <stdbool.h>    // bool, true, false

static int cmp_int(const void *a, const void *b) {
    int x = *(const int *)a, y = *(const int *)b;
    return (x > y) - (x < y);      // 뺄셈은 넘침 위험 — 이 관용구가 안전
}

int main(void) {
    // ① 문자열 → 수 : 에러를 확인할 수 있는 방법
    errno = 0;
    char *end;
    long v = strtol("  42abc", &end, 10);
    printf("v=%ld, 남은 문자='%s', errno=%d\n", v, end, errno);

    // ② 정렬
    int a[] = {5, 2, 9, 1};
    qsort(a, 4, sizeof a[0], cmp_int);
    for (int i = 0; i < 4; i++) printf("%d ", a[i]);
    putchar('\n');

    // ③ 시간 재기 (CPU 시간)
    clock_t t0 = clock();
    double s = 0;
    for (int i = 1; i < 5000000; i++) s += sqrt((double)i);
    printf("합=%.2f, %.3f초\n", s, (double)(clock() - t0) / CLOCKS_PER_SEC);

    printf("INT_MAX=%d, int64 최대=%lld\n", INT_MAX, (long long)INT64_MAX);
    return 0;
}
gcc -std=c17 -O2 -Wall -Wextra tour.c -o tour -lm
./tour

printf 서식 — 여기서 제일 많이 틀립니다

타입서식비고
int%d—
unsigned%u음수를 넣으면 거대한 수로 보입니다
long%ld윈도우에서 long 은 32비트
long long%lld어디서나 64비트
size_t%zu★ %d 로 찍으면 64비트에서 깨집니다
ptrdiff_t%td포인터 차이
int64_tPRId64<inttypes.h> 매크로
double%f %g %efloat 도 %f (자동 승격)
char*%sNULL 을 넘기면 UB
포인터%p(void*) 로 캐스트해서 넘길 것
#include <inttypes.h>
int64_t big = 9000000000;
size_t  n   = sizeof(int);

printf("%" PRId64 " / %zu\n", big, n);   // 이식성 있는 방법

// 컴파일러가 서식 오류를 잡아 주게 하기 ★
//   gcc/clang: -Wformat 은 -Wall 에 포함됩니다
//   내가 만든 함수에도 붙일 수 있습니다:
void logf_(const char *fmt, ...)
    __attribute__((format(printf, 1, 2)));
서식 문자열에 사용자 입력을 넣지 마세요 — printf(user_input) 은 포맷 스트링 취약점입니다. 출력만 하려면 printf("%s", user_input), 또는 fputs(user_input, stdout) 을 쓰세요.

한 단계 더 — 있는 줄 몰랐던 유용한 것들 🟡 중급

assert · errno · static_assert

#include <assert.h>
#include <errno.h>
#include <string.h>   // strerror

void put(int *arr, size_t n, size_t i, int v) {
    assert(arr != NULL);
    assert(i < n);          // 개발 중에만 검사됩니다
    arr[i] = v;
}
// NDEBUG 가 정의되면 assert 는 통째로 사라집니다:
//   gcc -DNDEBUG -O2 ...   ← 릴리스 빌드
// ★ 그래서 assert 안에 '부작용이 있는 식'을 넣으면 안 됩니다
//   assert(pop(&stack) == 3);   // 릴리스에서 pop 이 실행되지 않음!

// 컴파일 시점 검사 — C11 부터
#include <assert.h>
static_assert(sizeof(int) == 4, "이 코드는 32비트 int 를 가정합니다");

FILE *f = fopen("없는파일", "r");
if (!f) {
    // errno 는 '마지막 실패'를 담습니다. 성공한 호출도 값을 바꿀 수 있으니 실패 직후에만 봅니다
    int e = errno;          // 바로 저장 — 다음 호출(fprintf 포함)이 덮어쓸 수 있음
    fprintf(stderr, "열기 실패: %s (errno=%d)\n", strerror(e), e);
    // 또는 perror("fopen"); 한 줄로 — 단 다른 호출보다 먼저 불러야 합니다
}
errno 를 읽는 규칙 — ① 호출 직후에 읽습니다(다음 호출이 덮어씁니다). ② 성공했는지는 반환값으로 판단하고, errno 는 왜 실패했는지를 알 때만 봅니다. ③ strtol 처럼 “errno 로만 넘침을 알 수 있는” 함수는 호출 전에 errno=0 을 넣습니다.

난수 · 시간 · 환경변수

#include <stdlib.h>
#include <time.h>

// ① 난수 — rand() 는 품질이 낮습니다. 게임/시뮬레이션까지가 한계
srand((unsigned)time(NULL));      // 시드는 한 번만
int d = rand() % 6 + 1;           // ★ 모듈로 편향이 있지만 주사위 정도면 무방
// 암호용이라면 절대 rand() 를 쓰지 마세요:
//   리눅스 getrandom(), BSD/맥 arc4random_buf(), 윈도우 BCryptGenRandom()

// ② 실제 경과 시간 (clock 은 CPU 시간이라 sleep 을 못 셉니다)
struct timespec t0, t1;
timespec_get(&t0, TIME_UTC);      // C11 표준
/* ... 작업 ... */
timespec_get(&t1, TIME_UTC);
double ms = (t1.tv_sec - t0.tv_sec) * 1e3
          + (t1.tv_nsec - t0.tv_nsec) / 1e6;

// ③ 사람이 읽는 시각
time_t now = time(NULL);
struct tm tmv;
#if defined(_WIN32)
    localtime_s(&tmv, &now);
#else
    localtime_r(&now, &tmv);      // localtime() 은 스레드 안전하지 않습니다
#endif
char buf[64];
strftime(buf, sizeof buf, "%Y-%m-%d %H:%M:%S", &tmv);

// ④ 환경변수
const char *home = getenv("HOME");    // 없으면 NULL
왜 _r · _s 짝이 있나 — localtime·strtok·asctime 처럼 내부에 전역 버퍼를 쓰는 옛 함수들은 스레드에서 서로를 덮어씁니다. POSIX 는 _r(reentrant), 윈도우는 _s(safe) 이름으로 대체본을 줍니다. 멀티스레드 코드에서는 무조건 이쪽을 쓰세요.
표준에 없는 것들 — C 표준 라이브러리에는 네트워크·스레드 풀·정규식·JSON·디렉터리 순회·동적 배열이 없습니다. C11 의 <threads.h> 조차 선택 기능이라 MSVC 는 오랫동안 지원하지 않았습니다. 그래서 실무 C 는 POSIX(리눅스·맥) + Win32(윈도우) + 외부 라이브러리를 섞어 씁니다 — c17 · c18 · c26 에서 그 지형을 차례로 봅니다.
정리 — C 표준 라이브러리는 아홉 묶음이면 거의 다 커버됩니다. stdio(입출력) · stdlib(메모리·변환) · string(메모리 복사) 셋이 8할이고, atoi 대신 strtol, sprintf 대신 snprintf, strcpy 대신 snprintf 이 세 가지 교체만 몸에 붙여도 C 에서 나는 사고의 절반이 사라집니다.
자료구조 · 문자열 처리 · POSIX · 소켓 · 스레드 · 프로세스 · C23 · 라이브러리 · 테스트 · 빌드 · 생태계

C 로
실제 물건을
만듭니다

문법을 익혔다면 이제 표준 라이브러리에 없는 것을 직접 만들 차례입니다 — 동적 배열 · 해시 테이블 · 안전한 문자열 처리, 그리고 운영체제와 대화하는 POSIX 파일 · 소켓 · 스레드 · 프로세스. 뒤쪽은 C23, 임베디드 C 맛보기, 라이브러리 배포 · 테스트 · 프로젝트 구조 · 생태계로 이어집니다. 마이크로컨트롤러로 바로 가고 싶다면 이 탭은 건너뛰고 🔩 하드웨어 기초로 가도 됩니다.

13SECTIONS
C17기준 표준 (C23 표기)
POSIX리눅스 · macOS
100%복붙해서 도는 예제
C 14

동적 배열 · 연결 리스트를 직접 만듭니다

파이썬의 list, C++ 의 std::vector 에 해당하는 것이 C 에는 없습니다. 그래서 C 프로젝트는 거의 예외 없이 자기 동적 배열을 하나 가지고 있습니다. 직접 만들어 보면 “왜 vector 는 push 가 평균 O(1) 인가”, “왜 포인터를 보관하면 위험한가”가 한 번에 이해됩니다. 여기서 만든 코드는 그대로 복사해 쓸 수 있는 수준으로 씁니다.

같은 값 다섯 개 — 메모리에서는 완전히 다르게 놓입니다 ① 동적 배열 — 힙에 한 덩어리, 인덱스 접근 O(1) 10 20 30 40 50 빈칸 빈칸 len=5 · cap=7 — 주소가 12바이트씩 나란히 꽉 차면 cap 을 2배로 늘려 realloc 합니다 ② 연결 리스트 — 노드마다 따로 malloc, 흩어져 있음 10 next → 0x7f2a 20 next → 0x91c0 30 next → 0x4d18 40 next → NULL 끝 중간에 끼워 넣기·빼기는 포인터 두 개만 고치면 끝납니다 — O(1) 대신 i 번째를 찾으려면 앞에서부터 걸어야 하고, 매 노드가 캐시 미스입니다 — O(n) 배열이 유리한 경우 순회가 잦다 · 인덱스로 바로 꺼낸다 · 메모리를 아껴야 한다 실무 C 코드의 90%는 그냥 동적 배열이면 됩니다 리스트가 유리한 경우 중간 삽입·삭제가 매우 잦다 · 원소 주소가 변하면 안 된다 커널의 대기 큐 · 이벤트 루프의 타이머 목록이 대표적
배열은 커질 때 통째로 옮겨 다니고, 리스트는 영영 제자리입니다 — 이 한 줄이 둘의 모든 차이를 만듭니다.

동적 배열 — 복붙해서 바로 쓰는 전체 코드

// vec.h  — int 전용 동적 배열 (제네릭은 아래에서)
#ifndef VEC_H
#define VEC_H
#include <stddef.h>

typedef struct {
    int   *data;     // 힙에 있는 연속 버퍼
    size_t len;      // 지금 들어 있는 개수
    size_t cap;      // 버퍼가 담을 수 있는 개수
} Vec;

void vec_init(Vec *v);
int  vec_push(Vec *v, int value);          // 0 성공 / -1 메모리 부족
int  vec_get(const Vec *v, size_t i);
void vec_free(Vec *v);
#endif
// vec.c
#include "vec.h"
#include <stdlib.h>
#include <string.h>

void vec_init(Vec *v) { v->data = NULL; v->len = 0; v->cap = 0; }

static int vec_grow(Vec *v, size_t need) {
    if (need <= v->cap) return 0;

    size_t cap = v->cap ? v->cap * 2 : 8;   // ★ 2배 성장 — 평균 O(1) 의 이유
    while (cap < need) cap *= 2;

    // 곱셈이 넘치면 malloc 이 '작은' 크기를 성공시켜 버립니다 — 반드시 검사
    if (cap > (size_t)-1 / sizeof(int)) return -1;

    int *p = realloc(v->data, cap * sizeof(int));
    if (!p) return -1;        // ★ 실패해도 v->data 는 그대로 살아 있습니다
    v->data = p;
    v->cap  = cap;
    return 0;
}

int vec_push(Vec *v, int value) {
    if (vec_grow(v, v->len + 1) != 0) return -1;
    v->data[v->len++] = value;
    return 0;
}

int vec_get(const Vec *v, size_t i) {
    return v->data[i];        // 범위 검사는 호출자 책임 — C 의 기본 태도
}

void vec_free(Vec *v) {
    free(v->data);
    v->data = NULL; v->len = v->cap = 0;   // ★ dangling 방지
}
절대 하면 안 되는 realloc — v->data = realloc(v->data, n) 처럼 같은 변수에 바로 대입하면, 실패해서 NULL 이 돌아왔을 때 원래 버퍼의 주소를 잃어버려 통째로 샙니다. 위 코드처럼 임시 포인터에 받고 검사한 뒤에 대입하세요. 이건 C 코드 리뷰에서 가장 자주 지적되는 항목입니다.

왜 2배인가 — 증가 전략이 성능을 정합니다 🟡 중급

1씩 늘리기 vs 2배로 늘리기

전략n번 push 총 복사량평가
+1 씩O(n²) — 1+2+…+n10만 개면 체감으로 멈춤
+1024 씩O(n²) (상수만 작음)큰 데이터에서 결국 무너짐
×2 배O(n) — 총 복사 < 2n★ 표준 선택
×1.5 배O(n)메모리 재사용에 유리(MSVC STL)
// 성장 횟수를 직접 세어 보면 바로 보입니다
Vec v; vec_init(&v);
size_t prev = 0, grows = 0;
for (int i = 0; i < 1000000; i++) {
    vec_push(&v, i);
    if (v.cap != prev) { prev = v.cap; grows++; }
}
printf("100만 개를 담는 동안 성장 %zu회, cap=%zu\n", grows, v.cap);
// → 성장 18회. 즉 push 100만 번 중 재할당은 18번뿐입니다
vec_free(&v);
이걸 상환 분석(amortized)이라 부릅니다 — 어쩌다 한 번 비싼 연산이 있어도 전체를 나눠 보면 상수 시간이라는 뜻입니다. 파이썬 list.append, C++ vector::push_back, 자바 ArrayList.add 가 전부 같은 원리입니다.

가장 위험한 함정 — 재할당하면 주소가 바뀝니다 ★

위험 — 원소 주소를 보관
Vec v; vec_init(&v);
vec_push(&v, 10);
int *p = &v.data[0];   // 주소를 들고 있음

for (int i = 0; i < 100; i++)
    vec_push(&v, i);   // 중간에 realloc 발생!

*p = 99;               // ★ 이미 해제된 메모리
// use-after-free — 조용히 잘 돌다가
// 어느 날 다른 데이터를 망가뜨립니다
안전 — 인덱스를 보관
Vec v; vec_init(&v);
vec_push(&v, 10);
size_t idx = 0;        // 인덱스는 안 변합니다

for (int i = 0; i < 100; i++)
    vec_push(&v, i);

v.data[idx] = 99;      // 매번 새로 계산
// 리스트가 필요한 진짜 이유가 바로
// '주소가 절대 안 변한다'는 성질입니다
C++ 의 반복자 무효화와 똑같은 문제입니다 — vector 에 push_back 하면 기존 반복자·참조·포인터가 전부 무효가 되죠. C 에서는 그 규칙을 컴파일러가 알려 주지 않을 뿐입니다. ASan(-fsanitize=address)을 켜고 테스트를 돌리면 이 종류는 거의 다 잡힙니다.

제네릭 흉내 — void* 방식과 매크로 방식 🔴 고급

① void* + 원소 크기 — 코드가 하나면 됩니다

typedef struct {
    void  *data;
    size_t len, cap, esz;   // esz = 원소 하나의 바이트 수
} GVec;

static void gvec_init(GVec *v, size_t esz) {
    v->data = NULL; v->len = v->cap = 0; v->esz = esz;
}

static int gvec_push(GVec *v, const void *item) {
    if (v->len == v->cap) {
        size_t cap = v->cap ? v->cap * 2 : 8;
        void *p = realloc(v->data, cap * v->esz);
        if (!p) return -1;
        v->data = p; v->cap = cap;
    }
    // 바이트 단위로 계산해야 하므로 char* 로 캐스트합니다
    memcpy((char *)v->data + v->len * v->esz, item, v->esz);
    v->len++;
    return 0;
}

#define GVEC_AT(v, T, i)  (((T *)(v)->data)[i])

// 사용
GVec v; gvec_init(&v, sizeof(double));
double x = 3.14;
gvec_push(&v, &x);                 // ★ 값이 아니라 주소를 넘깁니다
printf("%f\n", GVEC_AT(&v, double, 0));
장점 — 코드가 한 벌이라 바이너리가 작습니다. 단점 — 타입 검사가 전혀 없어서 sizeof 를 틀리면 컴파일은 되고 실행이 망가집니다. SQLite·libcurl 같은 실전 C 라이브러리가 이 방식을 씁니다.

② 매크로로 타입별 코드 찍어내기

// C++ 템플릿을 전처리기로 흉내 내는 방법입니다
#define VEC_DECLARE(T, name)                                   \
    typedef struct { T *data; size_t len, cap; } name;         \
    static inline void name##_init(name *v){                   \
        v->data=NULL; v->len=v->cap=0; }                       \
    static inline int name##_push(name *v, T x){               \
        if (v->len == v->cap) {                                \
            size_t c = v->cap ? v->cap*2 : 8;                  \
            T *p = realloc(v->data, c*sizeof(T));              \
            if (!p) return -1;                                 \
            v->data = p; v->cap = c; }                         \
        v->data[v->len++] = x; return 0; }                     \
    static inline void name##_free(name *v){                   \
        free(v->data); v->data=NULL; v->len=v->cap=0; }

VEC_DECLARE(int,    IntVec)
VEC_DECLARE(double, DblVec)

IntVec a; IntVec_init(&a); IntVec_push(&a, 7);
// a.data[0] 은 진짜 int* — 타입 검사가 살아 있습니다
매크로의 대가 — 에러 메시지가 한 줄로 뭉쳐 나와 읽기 어렵습니다. gcc -E 로 펼쳐 보면 원래 코드가 나오니, 매크로 자료구조를 디버깅할 땐 이 습관이 필수입니다. 실전에서는 stb_ds.h(공개 도메인, 헤더 한 장)나 klib 의 kvec.h 가 이 방식의 완성형입니다.

단일 연결 리스트 — 전체 코드와 흔한 실수

typedef struct Node {
    int          value;
    struct Node *next;      // ★ 자기 타입을 가리키려면 struct 태그가 필요
} Node;

typedef struct { Node *head; size_t len; } List;

void list_init(List *l) { l->head = NULL; l->len = 0; }

// 맨 앞에 넣기 — O(1)
int list_push_front(List *l, int v) {
    Node *n = malloc(sizeof *n);       // ★ sizeof(Node) 대신 sizeof *n
    if (!n) return -1;
    n->value = v;
    n->next  = l->head;
    l->head  = n;
    l->len++;
    return 0;
}

// 전부 해제 — next 를 먼저 저장해 두는 것이 핵심
void list_free(List *l) {
    Node *cur = l->head;
    while (cur) {
        Node *nx = cur->next;   // ★ free 뒤에는 cur->next 를 못 읽습니다
        free(cur);
        cur = nx;
    }
    l->head = NULL; l->len = 0;
}
// 값으로 찾아 지우기 — 이중 포인터 관용구 ★
// head 를 지우는 경우와 중간을 지우는 경우가
// 하나의 코드로 합쳐집니다
int list_remove(List *l, int v) {
    Node **pp = &l->head;          // '나를 가리키는 포인터'의 주소
    while (*pp) {
        Node *cur = *pp;
        if (cur->value == v) {
            *pp = cur->next;       // 앞사람의 next 를 바로 고침
            free(cur);
            l->len--;
            return 1;
        }
        pp = &cur->next;
    }
    return 0;
}

// 순회
for (Node *p = l.head; p; p = p->next)
    printf("%d ", p->value);

// 뒤집기 — 면접 단골
Node *prev = NULL, *cur = l.head;
while (cur) {
    Node *nx = cur->next;
    cur->next = prev;
    prev = cur;
    cur  = nx;
}
l.head = prev;
이중 포인터(Node **)가 어렵게 느껴진다면 — “다음 노드”가 아니라 “그 노드를 가리키고 있는 칸 자체”를 들고 다닌다고 생각하세요. 그 칸이 list->head 일 수도 있고 어떤 노드의 next 일 수도 있는데, 고쳐야 할 대상은 언제나 그 칸이기 때문에 특수 케이스가 사라집니다. 리눅스 커널 코드에 이 관용구가 도처에 나옵니다.
정리 — C 에는 vector 가 없으니 동적 배열 하나는 직접 갖고 시작합니다. 핵심은 2배 성장(평균 O(1))과 realloc 을 임시 포인터로 받기, 그리고 재할당하면 원소 주소가 바뀐다는 사실입니다. 연결 리스트는 “중간 삽입이 잦거나 주소가 고정돼야 할 때”만 꺼내면 되고, 그때는 이중 포인터 관용구가 코드를 절반으로 줄여 줍니다.
C 15

해시 테이블과 정렬 · 탐색 — 딕셔너리도 직접 만듭니다

파이썬의 dict 도 C 에는 없습니다. 다행히 해시 테이블은 100줄이면 만들 수 있고, 정렬과 이진 탐색은 표준 qsort·bsearch 가 이미 줍니다. 이 절에서는 오픈 어드레싱 해시 테이블 전체 코드와, 비교 함수를 넘기는 C 특유의 정렬 방식을 봅니다.

오픈 어드레싱 — 자리가 차 있으면 옆 칸으로 밀어 넣습니다 ① 해시 함수가 슬롯 번호를 정합니다 키 "apple" 임의 길이의 바이트 열 FNV-1a % 8 = 3 3번 슬롯에 저장 평균 O(1) 로 찾을 수 있게 됩니다 ② 같은 번호가 나오면 — 옆 칸을 차례로 봅니다(선형 탐사) 비어 있음 0 비어 있음 1 비어 있음 2 apple 3 ← 해시 그대로 grape 4 ← 밀려남 비어 있음 5 melon 6 비어 있음 7 "grape" 의 해시도 3이었지만 이미 차 있어 3 → 4 로 한 칸 밀려 저장됐습니다 찾을 때도 3, 4, 5 … 순으로 키를 비교하고, 빈 칸을 만나면 "없음"으로 확정합니다 두 가지를 놓치면 해시 테이블이 망가집니다 ① 적재율이 0.7 을 넘으면 탐사가 길어집니다 — 슬롯을 2배로 늘리고 전부 다시 넣습니다(rehash) ② 삭제할 때 빈 칸으로 만들면 뒤에 밀려 있던 키를 못 찾습니다 — 묘비(tombstone) 표시가 필요합니다
충돌은 피하는 게 아니라 다루는 것입니다 — 옆 칸으로 미는 방식이 가장 단순하고 캐시에 가장 좋습니다.

문자열 → 값 해시 테이블 — 전체 코드

#include <stdint.h>
#include <stdlib.h>
#include <string.h>

// FNV-1a — 짧고 빠르고 분포가 괜찮습니다
static uint64_t fnv1a(const char *s) {
    uint64_t h = 14695981039346656037ULL;   // offset basis
    while (*s) {
        h ^= (unsigned char)*s++;
        h *= 1099511628211ULL;             // FNV prime
    }
    return h;
}

typedef struct {
    char *key;      // NULL = 빈 칸, TOMB = 삭제됨
    int   val;
} Slot;

typedef struct {
    Slot  *slots;
    size_t cap;     // 항상 2의 거듭제곱
    size_t used;    // 살아 있는 항목 수
} Map;

static char TOMB_MARK;                    // 주소만 쓰는 묘비 표식
#define TOMB (&TOMB_MARK)

int map_init(Map *m, size_t cap) {
    m->slots = calloc(cap, sizeof(Slot)); // 전부 NULL 로
    if (!m->slots) return -1;
    m->cap = cap; m->used = 0;
    return 0;
}
static int map_grow(Map *m);   // 아래에 정의

int map_put(Map *m, const char *key, int val) {
    if ((m->used + 1) * 10 >= m->cap * 7)  // 적재율 0.7
        if (map_grow(m) != 0) return -1;

    size_t i = fnv1a(key) & (m->cap - 1);  // ★ % 대신 비트 AND (2의 거듭제곱)
    size_t first_tomb = (size_t)-1;

    for (;;) {
        Slot *s = &m->slots[i];
        if (s->key == NULL) {              // 빈 칸 = 없다는 뜻
            size_t at = (first_tomb != (size_t)-1) ? first_tomb : i;
            char *dup = strdup(key);
            if (!dup) return -1;
            m->slots[at].key = dup;
            m->slots[at].val = val;
            m->used++;
            return 0;
        }
        if (s->key == TOMB) {
            if (first_tomb == (size_t)-1) first_tomb = i;
        } else if (strcmp(s->key, key) == 0) {
            s->val = val;                  // 덮어쓰기
            return 0;
        }
        i = (i + 1) & (m->cap - 1);        // 옆 칸으로
    }
}

int *map_get(Map *m, const char *key) {
    size_t i = fnv1a(key) & (m->cap - 1);
    for (;;) {
        Slot *s = &m->slots[i];
        if (s->key == NULL) return NULL;    // 빈 칸 = 없음
        if (s->key != TOMB && strcmp(s->key, key) == 0)
            return &s->val;
        i = (i + 1) & (m->cap - 1);
    }
}
int map_del(Map *m, const char *key) {
    size_t i = fnv1a(key) & (m->cap - 1);
    for (;;) {
        Slot *s = &m->slots[i];
        if (s->key == NULL) return 0;                       // 없음
        if (s->key != TOMB && strcmp(s->key, key) == 0) {
            free(s->key);
            s->key = TOMB;                                  // ★ NULL 이 아니라 묘비
            m->used--;
            return 1;
        }
        i = (i + 1) & (m->cap - 1);
    }
}

static int map_grow(Map *m) {
    Map bigger;
    if (map_init(&bigger, m->cap * 2) != 0) return -1;
    for (size_t i = 0; i < m->cap; i++) {
        Slot *s = &m->slots[i];
        if (s->key && s->key != TOMB) {
            map_put(&bigger, s->key, s->val);   // 해시가 같아도 슬롯 번호는 달라집니다
            free(s->key);
        }
    }
    free(m->slots);
    *m = bigger;
    return 0;
}

// 사용
Map m; map_init(&m, 16);
map_put(&m, "apple", 3);
map_put(&m, "grape", 7);
int *p = map_get(&m, "grape");
if (p) printf("grape=%d\n", *p);
묘비를 빠뜨리면 생기는 버그 — apple 을 지우고 NULL 로 만들면, 4번에 밀려 있던 grape 를 찾을 때 3번이 빈 칸이라 즉시 “없음”으로 끝나 버립니다. 데이터가 있는데도 못 찾는, 재현이 어려운 종류의 버그입니다. 묘비가 너무 쌓이면 used 가 그대로여도 rehash 로 청소해야 합니다.

표준이 주는 것 — qsort 와 bsearch 🟢 기초

비교 함수를 넘기는 방식

#include <stdlib.h>

typedef struct { char name[32]; int age; } Person;

// 인자는 항상 const void* 두 개입니다
static int by_age(const void *a, const void *b) {
    const Person *x = a, *y = b;
    // ★ return x->age - y->age;  ← 넘침 가능. 쓰지 마세요
    return (x->age > y->age) - (x->age < y->age);
}

static int by_name(const void *a, const void *b) {
    return strcmp(((const Person *)a)->name,
                  ((const Person *)b)->name);
}

Person ps[] = {{"kim",31},{"lee",24},{"park",45}};
size_t n = sizeof ps / sizeof ps[0];

qsort(ps, n, sizeof ps[0], by_age);       // 나이 오름차순
qsort(ps, n, sizeof ps[0], by_name);      // 이름 사전순

// 반드시 '같은 기준으로 정렬된' 배열에서만 동작합니다
// 지금 ps 는 이름순이므로 나이로 찾기 전에 다시 나이순으로 정렬
qsort(ps, n, sizeof ps[0], by_age);
Person key = { .age = 31 };
Person *hit = bsearch(&key, ps, n, sizeof ps[0], by_age);
printf("%s\n", hit ? hit->name : "없음");
qsort 는 안정 정렬이 아닙니다 — 같은 값끼리의 원래 순서가 보장되지 않습니다. “나이순, 나이가 같으면 이름순”이 필요하면 비교 함수 안에서 2차 기준까지 직접 처리하세요. if (r != 0) return r; return strcmp(...); 형태가 됩니다.

직접 쓰는 이진 탐색 — 경계 조건이 전부

// v 이상인 첫 인덱스 (C++ 의 lower_bound)
size_t lower_bound(const int *a, size_t n, int v) {
    size_t lo = 0, hi = n;            // ★ 닫힌 구간이 아니라 [lo, hi)
    while (lo < hi) {
        size_t mid = lo + (hi - lo) / 2;   // ★ (lo+hi)/2 는 넘칠 수 있음
        if (a[mid] < v) lo = mid + 1;
        else            hi = mid;
    }
    return lo;                        // 없으면 '들어갈 자리'를 돌려줍니다
}

int a[] = {1,3,3,5,9};
size_t i = lower_bound(a, 5, 3);      // → 1
size_t j = lower_bound(a, 5, 4);      // → 3 (없지만 자리는 3)

// bsearch 와의 차이: bsearch 는 '있으면 아무거나 하나',
// lower_bound 는 '경계'를 줍니다. 중복이 있으면 후자가 유용합니다
상황선택
원소 100개 미만그냥 선형 탐색 — 캐시 때문에 더 빠를 때도
정렬 후 여러 번 조회qsort + bsearch
넣고 빼기가 섞여 있다해시 테이블
순서대로 순회도 필요정렬된 배열 유지 또는 트리
키가 정수이고 조밀하다그냥 배열 — 해시가 필요 없습니다

실무 관점 — 해시를 직접 만들지 말아야 할 때 🔴 고급

해시 DoS — 외부 입력이 키가 될 때

// 공격자가 '해시가 전부 같은 키'를 골라 보내면
// 모든 항목이 한 슬롯에 몰려 O(n) 이 됩니다.
// HTTP 헤더·JSON 키·쿼리 파라미터가 대표적인 통로입니다.

// 대응 ① 프로세스마다 무작위 시드를 섞습니다
static uint64_t g_seed;   // 시작할 때 난수로 채움

static uint64_t fnv1a_seeded(const char *s) {
    uint64_t h = 14695981039346656037ULL ^ g_seed;
    while (*s) { h ^= (unsigned char)*s++; h *= 1099511628211ULL; }
    return h;
}

// 대응 ② 키가 신뢰할 수 없는 입력이면 SipHash 를 씁니다
//   — 파이썬 3.4+, 러스트 HashMap, 리눅스 커널이 쓰는 방식
//   구현이 짧아(200줄) 그대로 넣어 쓸 수 있습니다
파이썬이 PYTHONHASHSEED 를 두는 이유가 정확히 이것입니다. 2011년에 여러 웹 프레임워크가 이 공격으로 한꺼번에 털렸고, 그 뒤로 “외부 입력을 키로 쓰는 해시는 시드를 섞는다”가 상식이 됐습니다.

가져다 쓰는 편이 나은 경우

라이브러리특징
stb_ds.h헤더 한 장, 공개 도메인. 동적 배열·해시맵. 가장 손쉬움
klib (khash.h)매크로 기반, 매우 빠름. samtools 등에서 실전 검증
uthash구조체에 필드를 심는 방식. 기존 구조체에 얹기 좋음
tommyds여러 자료구조 모음. 벤치마크 공개
glib (GHashTable)무겁지만 완성도 높음. GNOME 생태계
// stb_ds.h — 한 파일만 받아서 넣으면 끝
#define STB_DS_IMPLEMENTATION
#include "stb_ds.h"

int *nums = NULL;
arrput(nums, 10);              // 동적 배열
arrput(nums, 20);
printf("%zu개, 첫 값 %d\n", arrlen(nums), nums[0]);
arrfree(nums);

struct { char *key; int value; } *dict = NULL;
shput(dict, "apple", 3);       // 문자열 키 해시맵
printf("%d\n", shget(dict, "apple"));
shfree(dict);
직접 만드는 값어치는 “한 번 만들어 보고 원리를 아는 것”에 있습니다. 제품 코드에서는 검증된 헤더 한 장을 가져다 쓰는 편이 거의 항상 낫습니다 — 묘비·rehash·시드 같은 세부를 이미 다 처리해 두었기 때문입니다.
정리 — 해시 테이블의 핵심은 셋입니다. 2의 거듭제곱 크기 + 비트 마스크(% 보다 빠름), 적재율 0.7 에서 rehash, 그리고 삭제는 묘비로. 정렬·탐색은 qsort/bsearch 로 충분하되 비교 함수에서 뺄셈 대신 (x>y)-(x<y) 를 쓰세요. 실전에서는 stb_ds.h 한 장을 넣는 것이 대개 가장 좋은 선택입니다.
C 16

문자열 처리 실전 — 파싱 · 동적 버퍼 · UTF-8

C 에서 실제로 시간을 가장 많이 잡아먹는 일이 문자열 다루기입니다. 길이를 모르는 입력을 받아서, 자르고, 숫자로 바꾸고, 다시 이어 붙이는 과정에 C 의 버퍼 오버플로 사고 대부분이 숨어 있습니다. 여기서는 안전하게 자르는 법 · 자동으로 늘어나는 문자열 버퍼 · 한글이 깨지지 않게 다루는 법을 전부 도는 코드로 정리합니다.

UTF-8 — 한 글자가 1~4바이트, strlen 이 세는 것은 바이트입니다 ① 문자열 "가A" 는 메모리에 이렇게 놓입니다 0xEA 선두 바이트 0xB0 뒤따르는 바이트 0x80 뒤따르는 바이트 0x41 'A' — ASCII 그대로 0x00 널 종료 배열 크기는 5바이트 글자는 두 개뿐입니다 이 세 바이트가 합쳐서 한 글자 '가' 입니다 ② 그래서 이런 결과가 나옵니다 strlen("가A") 는 4 입니다 글자 수가 아니라 바이트 수를 셉니다 s[0] 하나만 찍으면 깨진 조각이 나옵니다 글자 수는 선두 바이트만 세면 됩니다 상위 두 비트가 10 이 아닌 바이트만 셉니다 코드로는 단 세 줄이면 끝납니다 UTF-8 이 사실상 표준이 된 이유 ① ASCII 는 그대로 1바이트 — 기존 C 코드와 프로토콜이 대부분 손대지 않고 돕니다 ② 선두와 뒤따름이 구분되므로, 아무 위치에서 잘려도 글자 경계를 다시 찾을 수 있습니다
C 의 char* 는 글자의 배열이 아니라 바이트의 배열입니다 — 한글이 깨지는 사고는 전부 여기서 시작합니다.

한 줄 읽어 CSV 로 자르기 — 안전한 방법

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <errno.h>      // errno · ERANGE

// "kim,31,seoul\n" 같은 줄을 필드로 자릅니다
void parse_line(char *line) {
    // ① 끝의 개행 제거 — fgets 는 '\n' 을 남깁니다
    line[strcspn(line, "\r\n")] = '\0';   // ★ 관용구. 개행이 없어도 안전

    // ② strtok_r — 상태를 saveptr 에 두므로 스레드 안전
    char *save = NULL;
    char *name = strtok_r(line, ",", &save);
    char *ages = strtok_r(NULL, ",", &save);   // ★ 두 번째부터는 NULL
    char *city = strtok_r(NULL, ",", &save);
    if (!name || !ages || !city) {            // 필드가 모자라면
        fprintf(stderr, "형식 오류\n");
        return;
    }

    // ③ 숫자 변환 — 실패를 알 수 있는 방법
    char *end;
    errno = 0;
    long age = strtol(ages, &end, 10);
    if (end == ages || *end != '\0' || errno == ERANGE) {
        fprintf(stderr, "나이가 숫자가 아닙니다: %s\n", ages);
        return;
    }
    printf("%s / %ld / %s\n", name, age, city);
}

int main(void) {
    char buf[512];
    while (fgets(buf, sizeof buf, stdin))   // ★ sizeof buf 로 크기를 넘김
        parse_line(buf);
}
윈도우에서 strtok_r 이 없다면 — MSVC 는 같은 기능을 strtok_s 로 줍니다. #ifdef _WIN32 #define strtok_r strtok_s #endif 한 줄이면 양쪽에서 같은 코드가 돕니다. 그냥 strtok 은 내부 전역 상태를 써서 다른 스레드나 중첩 호출과 충돌합니다.

해서는 안 되는 것들 — 실제 사고 목록

위험
char buf[64];
gets(buf);              // C11 에서 삭제됨. 무조건 금지
scanf("%s", buf);       // 길이 제한 없음 → 오버플로
strcpy(buf, argv[1]);   // 인자가 길면 그대로 넘침
sprintf(buf, "%s/%s", dir, file);   // 같은 문제

int n = atoi(argv[1]);  // 실패해도 0. 구분 불가
char *p = strtok(s, ","); // 전역 상태 · 원본 파괴
안전
char buf[64];
fgets(buf, sizeof buf, stdin);
scanf("%63s", buf);     // ★ 크기-1 을 반드시 명시

snprintf(buf, sizeof buf, "%s", argv[1]);
int need = snprintf(buf, sizeof buf, "%s/%s", dir, file);
if (need >= (int)sizeof buf)
    fprintf(stderr, "경로가 잘렸습니다\n");   // ★ 반환값 확인

long n = strtol(argv[1], &end, 10);
char *p = strtok_r(s, ",", &save);
snprintf 의 반환값이 핵심입니다 — 실제로 쓴 바이트 수가 아니라 “잘리지 않았다면 필요했을 길이”를 돌려줍니다. 그래서 ret >= 버퍼크기 이면 잘린 것입니다. 이 값을 먼저 NULL, 0 으로 호출해 재면, 필요한 만큼만 malloc 하는 관용구가 됩니다.

자동으로 늘어나는 문자열 — C 의 StringBuilder 🟡 중급

Str 버퍼 — 이어 붙이기가 잦은 코드에 필수

#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct { char *p; size_t len, cap; } Str;

static void str_init(Str *s) { s->p = NULL; s->len = s->cap = 0; }

static int str_reserve(Str *s, size_t need) {
    if (need + 1 <= s->cap) return 0;        // +1 은 널 자리
    size_t cap = s->cap ? s->cap : 32;
    while (cap < need + 1) cap *= 2;
    char *q = realloc(s->p, cap);
    if (!q) return -1;
    s->p = q; s->cap = cap;
    return 0;
}

int str_append(Str *s, const char *t) {
    size_t n = strlen(t);
    if (str_reserve(s, s->len + n) != 0) return -1;
    memcpy(s->p + s->len, t, n + 1);          // 널까지 복사
    s->len += n;
    return 0;
}

void str_free(Str *s) { free(s->p); str_init(s); }
// 서식 붙이기 — vsnprintf 로 필요 길이를 먼저 잽니다 ★
int str_appendf(Str *s, const char *fmt, ...) {
    va_list ap, ap2;
    va_start(ap, fmt);
    va_copy(ap2, ap);                       // ★ 두 번 쓰려면 복사 필수
    int need = vsnprintf(NULL, 0, fmt, ap); // 길이만 계산
    va_end(ap);
    if (need < 0) { va_end(ap2); return -1; }

    if (str_reserve(s, s->len + (size_t)need) != 0) {
        va_end(ap2); return -1;
    }
    vsnprintf(s->p + s->len, (size_t)need + 1, fmt, ap2);
    va_end(ap2);
    s->len += (size_t)need;
    return 0;
}

// 사용
Str out; str_init(&out);
str_append(&out, "결과: ");
for (int i = 0; i < 3; i++)
    str_appendf(&out, "[%d:%s] ", i, i % 2 ? "홀" : "짝");
puts(out.p);          // 결과: [0:짝] [1:홀] [2:짝]
printf("길이 %zu, 용량 %zu\n", out.len, out.cap);
str_free(&out);
va_list 는 한 번 쓰면 끝입니다 — vsnprintf 를 두 번 호출하려면 반드시 va_copy 로 복사본을 만들어야 합니다. 이걸 빠뜨리면 x86-64 리눅스에서는 우연히 돌고 ARM 이나 윈도우에서 깨지는 최악의 이식성 버그가 됩니다. 그리고 va_copy 한 것도 반드시 va_end 해야 합니다.

한글·이모지를 다루기 — UTF-8 실전 🔴 고급

글자 수 세기 · 안전하게 자르기

#include <stddef.h>
#include <stdbool.h>
#include <string.h>     // strlen

// 뒤따르는 바이트는 상위 두 비트가 10 입니다 (0x80~0xBF)
static bool is_cont(unsigned char c) { return (c & 0xC0) == 0x80; }

// 글자 수 = 뒤따르지 않는 바이트의 개수
size_t utf8_len(const char *s) {
    size_t n = 0;
    for (const unsigned char *p = (const unsigned char *)s; *p; p++)
        if (!is_cont(*p)) n++;
    return n;
}

// 글자 단위로 안전하게 자르기 — 최대 max 바이트 안에서
// 글자 중간이 잘리지 않는 위치를 돌려줍니다
size_t utf8_trunc(const char *s, size_t max) {
    size_t n = strlen(s);
    if (n <= max) return n;
    size_t cut = max;
    while (cut > 0 && is_cont((unsigned char)s[cut])) cut--;
    return cut;
}

// 코드포인트 하나를 읽고 다음 위치로 이동
const char *utf8_next(const char *s, unsigned *out) {
    unsigned char c = (unsigned char)*s;
    int extra = c < 0x80 ? 0 : c < 0xE0 ? 1 : c < 0xF0 ? 2 : 3;
    unsigned cp = c & (0xFF >> (extra + 2));
    if (extra == 0) cp = c;
    for (int i = 0; i < extra; i++)
        cp = (cp << 6) | (*++s & 0x3F);
    *out = cp;
    return s + 1;
}
대부분의 경우 디코딩까지 갈 필요가 없습니다 — 검색·비교·저장은 바이트 그대로 하면 정확히 동작합니다(UTF-8 의 설계가 그렇습니다). 디코딩이 필요한 순간은 글자 수 제한 · 커서 이동 · 폭 계산 정도입니다.

윈도우 콘솔과 파일 경로 — 최대 함정

// 리눅스·맥: 파일 경로도 소스도 전부 UTF-8. 신경 쓸 것 없음
// 윈도우: 기본 코드 페이지가 UTF-8 이 아닐 수 있습니다

#ifdef _WIN32
#include <windows.h>
static void setup_console(void) {
    SetConsoleOutputCP(CP_UTF8);   // 출력 코드 페이지를 UTF-8 로
    SetConsoleCP(CP_UTF8);         // 입력도
}
#else
static void setup_console(void) { }
#endif

int main(void) {
    setup_console();
    printf("한글이 깨지지 않습니다\n");
}

// 파일 경로에 한글이 있으면 윈도우에서는 fopen 이 실패할 수 있습니다.
// 방법 ① 실행 파일에 UTF-8 매니페스트를 넣습니다 (Win10 1903+)
// 방법 ② _wfopen 과 MultiByteToWideChar 로 UTF-16 을 씁니다
// 방법 ③ MSVC 라면 /utf-8 플래그로 소스 인코딩을 못박습니다
함수왜 쓰나
strcspn(s,"\r\n")개행 위치 찾기 — 개행이 없어도 안전
strspn앞쪽 공백 건너뛰기
memchr널이 섞인 바이너리에서 찾기
strnlen널이 없을 수도 있는 버퍼의 길이
snprintf(NULL,0,…)필요한 길이 미리 재기
strcasecmp대소문자 무시 비교(POSIX · MSVC 는 _stricmp)
파이썬·자바와의 결정적 차이 — 파이썬 str 은 코드포인트의 열이고 자바 String 은 UTF-16 의 열이라, len(s) 가 곧 글자 수입니다. C 는 바이트 열뿐이라 그 계층이 없습니다. 대신 인코딩을 내가 완전히 통제할 수 있어, 네트워크·파일 포맷 같은 경계에서는 오히려 유리합니다.
정리 — 문자열에서 지킬 규칙은 네 개입니다. 입력은 fgets, 조립은 snprintf(반환값 확인), 자르기는 strtok_r, 숫자 변환은 strtol(end 와 errno 확인). 길이가 변하는 문자열이 필요하면 Str 버퍼 40줄을 하나 만들어 두고 계속 씁니다. 한글은 바이트 열로 그대로 다루되, 자를 때만 글자 경계를 확인하면 됩니다.
C 17

POSIX — 파일 · 디렉터리 · 시간을 시스템 수준에서

C 표준에는 디렉터리를 읽는 함수조차 없습니다. 파일 크기, 수정 시각, 폴더 순회, 메모리 매핑은 전부 POSIX(리눅스·맥·BSD)나 Win32(윈도우)의 영역입니다. 이 절은 실무에서 실제로 쓰는 것만 골라, 양쪽 모두에서 도는 형태로 정리합니다. #ifdef _WIN32 분기가 어떻게 생기는지도 여기서 감을 잡게 됩니다.

파일을 읽는 세 가지 경로 — 어디까지 내려가는가 stdio — FILE* fopen · fgets · fprintf 이식성 최고 · 버퍼링 자동 mmap — 메모리 매핑 포인터로 그냥 접근 큰 파일 랜덤 접근에 유리 POSIX 파일 디스크립터 — open · read · write 시스템 콜을 직접 부릅니다. 버퍼링도 내가 관리합니다 stdio 는 사실 이 위에 얹혀 구현되어 있습니다 mmap 은 read 를 거치지 않고 페이지 폴트로 읽습니다 커널 — 페이지 캐시 · 파일 시스템 · 디스크 같은 파일을 두 번 읽으면 두 번째는 메모리에서 옵니다. 그래서 벤치마크가 왜곡됩니다 작은 텍스트 파일 · 이식성이 중요 → stdio 로 충분합니다 비블로킹 · 소켓 · 타임아웃 같은 정밀 제어 → 파일 디스크립터 · 수 GB 랜덤 접근 → mmap
셋은 경쟁 관계가 아니라 층입니다 — 아래로 내려갈수록 제어는 늘고 이식성은 줄어듭니다.

open · read · write — 시스템 콜 직접 쓰기

#include <fcntl.h>      // open, O_RDONLY
#include <unistd.h>     // read, write, close
#include <errno.h>
#include <string.h>

int copy_file(const char *src, const char *dst) {
    int in = open(src, O_RDONLY);
    if (in < 0) return -1;                      // ★ 실패는 -1, errno 에 이유

    int out = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (out < 0) { close(in); return -1; }

    char buf[65536];
    ssize_t n;
    while ((n = read(in, buf, sizeof buf)) > 0) {
        ssize_t off = 0;
        while (off < n) {
            // ★ write 는 요청한 만큼 다 안 쓸 수 있습니다 (부분 쓰기)
            ssize_t w = write(out, buf + off, (size_t)(n - off));
            if (w < 0) {
                if (errno == EINTR) continue;   // ★ 시그널에 끊긴 것뿐
                close(in); close(out); return -1;
            }
            off += w;
        }
    }
    int rc = (n < 0) ? -1 : 0;
    close(in);
    if (close(out) < 0) rc = -1;   // ★ close 실패도 데이터 유실입니다
    return rc;
}
초보가 반드시 밟는 두 함정 — ① write 가 일부만 쓰고 돌아올 수 있습니다(특히 소켓·파이프). 위처럼 루프로 감싸야 합니다. ② read/write 가 EINTR 로 실패하면 에러가 아니라 시그널에 끊긴 것이니 다시 부르면 됩니다.

stat · 디렉터리 순회

#include <sys/stat.h>
#include <dirent.h>
#include <stdio.h>
#include <string.h>     // strcmp
#include <time.h>

void list_dir(const char *path) {
    DIR *d = opendir(path);
    if (!d) { perror("opendir"); return; }

    struct dirent *e;
    while ((e = readdir(d)) != NULL) {
        if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, ".."))
            continue;

        char full[4096];
        snprintf(full, sizeof full, "%s/%s", path, e->d_name);

        struct stat st;
        if (stat(full, &st) != 0) continue;     // 심볼릭 링크가 깨졌을 수도

        char when[32];
        struct tm tmv;
        localtime_r(&st.st_mtime, &tmv);
        strftime(when, sizeof when, "%Y-%m-%d %H:%M", &tmv);

        printf("%c %10lld  %s  %s\n",
               S_ISDIR(st.st_mode) ? 'd' : '-',   // 종류
               (long long)st.st_size,             // 크기
               when, e->d_name);
    }
    closedir(d);
}
st_mode 검사뜻
S_ISREG일반 파일
S_ISDIR디렉터리
S_ISLNK심볼릭 링크(lstat 으로 봐야 보임)
st_size바이트 크기
st_mtime내용이 마지막으로 바뀐 시각

mmap — 파일을 포인터처럼 🔴 고급

수 GB 로그 파일에서 줄 수 세기

#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>

long count_lines(const char *path) {
    int fd = open(path, O_RDONLY);
    if (fd < 0) return -1;

    struct stat st;
    if (fstat(fd, &st) != 0) { close(fd); return -1; }
    if (st.st_size == 0) { close(fd); return 0; }

    // 파일 전체를 주소 공간에 붙입니다 — 실제로 읽지는 않습니다
    char *p = mmap(NULL, (size_t)st.st_size,
                   PROT_READ, MAP_PRIVATE, fd, 0);
    close(fd);                       // ★ 매핑 후에는 fd 를 닫아도 됩니다
    if (p == MAP_FAILED) return -1;

    // 커널에 접근 패턴을 알려 주면 미리 읽어 줍니다
    madvise(p, (size_t)st.st_size, MADV_SEQUENTIAL);

    long lines = 0;
    const char *cur = p, *end = p + st.st_size;
    while ((cur = memchr(cur, '\n', (size_t)(end - cur))) != NULL) {
        lines++; cur++;              // memchr 은 SIMD 로 최적화돼 매우 빠릅니다
    }
    munmap(p, (size_t)st.st_size);
    return lines;
}
왜 빠른가 — read 는 커널 버퍼에서 내 버퍼로 복사하지만, mmap 은 복사 없이 페이지 캐시를 그대로 내 주소 공간에 보여 줍니다. 큰 파일을 여러 번 훑거나 랜덤 접근할 때 차이가 큽니다. SQLite·Redis·Elasticsearch 가 모두 이 방식을 씁니다.

mmap 을 쓰면 안 되는 경우

상황이유
순차로 한 번만 읽음read 가 더 단순하고 비슷하게 빠릅니다
32비트 프로세스주소 공간이 부족해 큰 파일을 못 붙입니다
매핑 중 파일이 줄어듦접근하면 SIGBUS 로 즉사합니다 ★
네트워크 파일 시스템NFS 에서 동작이 예측하기 어렵습니다
작은 파일 다수매핑 비용(TLB·페이지 테이블)이 더 큽니다
// 쓰기 매핑 — 파일 내용을 메모리처럼 고칩니다
char *p = mmap(NULL, len, PROT_READ | PROT_WRITE,
               MAP_SHARED, fd, 0);   // ★ MAP_SHARED 여야 파일에 반영
p[0] = 'X';
msync(p, len, MS_SYNC);              // 디스크에 확실히 반영
munmap(p, len);

// 익명 매핑 — 파일 없이 큰 메모리 덩어리 얻기 (malloc 의 속살)
char *big = mmap(NULL, 1u << 30, PROT_READ | PROT_WRITE,
                 MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
// 1GB 를 '예약'만 합니다 — 실제로 건드린 페이지만 물리 메모리를 씁니다
MAP_ANONYMOUS 로 1GB 를 잡아도 메모리가 안 줄어드는 이유 — 리눅스는 건드리기 전까지 물리 메모리를 배정하지 않습니다(overcommit). top 의 VIRT 는 늘고 RSS 는 그대로인 현상이 이것입니다.

시간 재기와 윈도우 분기 🟡 중급

어떤 시계를 써야 하나

#include <time.h>

// ① 경과 시간 측정 — 반드시 MONOTONIC
//    시스템 시계가 NTP 로 뒤로 점프해도 영향받지 않습니다
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0);
/* ... 작업 ... */
clock_gettime(CLOCK_MONOTONIC, &t1);
double sec = (t1.tv_sec - t0.tv_sec)
           + (t1.tv_nsec - t0.tv_nsec) / 1e9;

// ② '지금 몇 시인가' — REALTIME (벽시계)
clock_gettime(CLOCK_REALTIME, &t0);

// ③ 이 스레드가 실제로 CPU 를 쓴 시간
clock_gettime(CLOCK_THREAD_CPUTIME_ID, &t0);

// ④ 잠깐 자기 (EINTR 을 견디는 형태)
struct timespec req = { .tv_sec = 0, .tv_nsec = 200000000 }; // 0.2초
while (nanosleep(&req, &req) == -1 && errno == EINTR) { }
벤치마크에 time(NULL) 이나 CLOCK_REALTIME 을 쓰면 안 됩니다 — 시간 동기화가 시계를 뒤로 돌리면 음수 경과 시간이 나옵니다. “왜 처리 시간이 -3초로 찍히지?”라는 장애 보고는 거의 전부 이것입니다.

POSIX ↔ Win32 대응표와 분기 관용구

POSIXWin32
open/read/writeCreateFile/ReadFile/WriteFile
closeCloseHandle
statGetFileAttributesEx · _stat64
opendir/readdirFindFirstFile/FindNextFile
mmapCreateFileMapping+MapViewOfFile
clock_gettimeQueryPerformanceCounter
nanosleepSleep(ms)
mkdir(p,mode)CreateDirectory · _mkdir(p)
// 얇은 이식 계층을 하나 만들어 두는 것이 정석입니다
#ifdef _WIN32
  #include <windows.h>
  static double now_sec(void) {
      LARGE_INTEGER f, c;
      QueryPerformanceFrequency(&f);
      QueryPerformanceCounter(&c);
      return (double)c.QuadPart / (double)f.QuadPart;
  }
  static void sleep_ms(int ms) { Sleep((DWORD)ms); }
#else
  #include <time.h>
  static double now_sec(void) {
      struct timespec t;
      clock_gettime(CLOCK_MONOTONIC, &t);
      return t.tv_sec + t.tv_nsec / 1e9;
  }
  static void sleep_ms(int ms) {
      struct timespec r = { ms / 1000, (long)(ms % 1000) * 1000000L };
      nanosleep(&r, NULL);
  }
#endif
// ★ 나머지 코드는 now_sec / sleep_ms 만 부릅니다 — #ifdef 가 번지지 않게
#ifdef 를 코드 곳곳에 흩뿌리지 마세요 — 플랫폼 함수 하나당 래퍼 하나를 만들고 본문에는 래퍼만 쓰는 것이 규칙입니다. 그러지 않으면 3년 뒤 아무도 못 고치는 파일이 됩니다. libuv 는 이 이식 계층 자체를 라이브러리로 만든 것입니다(c26 참고).
정리 — C 표준만으로는 폴더도 못 읽습니다. 파일 크기·시각은 stat, 폴더는 opendir, 큰 파일은 mmap, 시간 측정은 CLOCK_MONOTONIC 이 정답입니다. read·write 는 부분 전송과 EINTR 을 루프로 감싸는 것이 기본형이고, 윈도우 대응은 #ifdef 를 래퍼 함수 안에만 가두면 됩니다.
C 18

소켓 프로그래밍 — TCP 서버와 클라이언트를 처음부터

네트워크 라이브러리를 한 겹만 걷어 내면 전부 이 여섯 개의 C 함수가 나옵니다 — socket · bind · listen · accept · connect · close. 파이썬 socket 모듈도 이름까지 똑같습니다. 여기서는 복사해서 바로 도는 에코 서버와 클라이언트를 만들고, 접속이 늘어날 때 select · poll · epoll 로 어떻게 넘어가는지까지 봅니다.

TCP — 서버와 클라이언트가 부르는 순서, 그리고 만나는 지점 서버 쪽 클라이언트 쪽 socket(AF_INET, SOCK_STREAM, 0) 통신용 파일 디스크립터를 하나 만듭니다 bind(fd, 주소, 크기) 어느 IP 와 포트에서 들을지 못박습니다 listen(fd, backlog) 대기 큐를 열고 접속을 받을 준비를 합니다 accept(fd) → 새 fd 를 돌려줍니다 접속이 올 때까지 여기서 멈춰 기다립니다 read / write → close(새 fd) 듣는 fd 는 닫지 않고 다음 손님을 받습니다 socket(AF_INET, SOCK_STREAM, 0) 서버와 똑같이 시작합니다 connect(fd, 서버 주소) 3-way 핸드셰이크가 여기서 일어납니다 write / read → close(fd) 보낸 순서대로 도착하지만 경계는 없습니다 backlog 는 "핸드셰이크는 끝났지만 아직 accept 하지 않은 연결"이 쌓이는 큐의 길이입니다 accept 가 돌려준 새 fd 가 진짜 통신 통로이고, 듣는 fd 는 계속 살아 다음 접속을 받습니다
서버가 fd 를 두 개 갖는다는 점이 핵심입니다 — 듣는 소켓과 손님별 소켓은 완전히 다릅니다.

TCP 에코 서버 — 윈도우·리눅스·맥에서 그대로 도는 전체 코드

// echo_server.c
//   리눅스/맥 : gcc -std=c17 -O2 -Wall echo_server.c -o echo_server
//   윈도우    : gcc echo_server.c -o echo_server.exe -lws2_32
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#ifdef _WIN32
  #include <winsock2.h>
  #include <ws2tcpip.h>
  #pragma comment(lib, "ws2_32.lib")      // MSVC 용. gcc 는 -lws2_32
  typedef SOCKET sock_t;
  #define CLOSESOCK closesocket
  #define SOCK_ERR  WSAGetLastError()
  static int net_init(void) {
      WSADATA w; return WSAStartup(MAKEWORD(2,2), &w);   // ★ 윈도우만 필요
  }
  static void net_quit(void) { WSACleanup(); }
#else
  #include <sys/socket.h>
  #include <netinet/in.h>
  #include <arpa/inet.h>
  #include <unistd.h>
  #include <errno.h>
  typedef int sock_t;
  #define INVALID_SOCKET (-1)
  #define CLOSESOCK close
  #define SOCK_ERR  errno
  static int  net_init(void) { return 0; }
  static void net_quit(void) { }
#endif

#define PORT 9000

int main(void) {
    if (net_init() != 0) { fprintf(stderr, "네트워크 초기화 실패\n"); return 1; }

    sock_t srv = socket(AF_INET, SOCK_STREAM, 0);
    if (srv == INVALID_SOCKET) { perror("socket"); return 1; }

    // ★ 서버를 껐다 켤 때 "Address already in use" 를 막습니다
    int yes = 1;
    setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, (const char *)&yes, sizeof yes);

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof addr);
    addr.sin_family      = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);   // 모든 인터페이스
    addr.sin_port        = htons(PORT);         // ★ 네트워크 바이트 순서

    if (bind(srv, (struct sockaddr *)&addr, sizeof addr) != 0) {
        fprintf(stderr, "bind 실패 (%d)\n", SOCK_ERR); return 1;
    }
    if (listen(srv, 128) != 0) {
        fprintf(stderr, "listen 실패 (%d)\n", SOCK_ERR); return 1;
    }
    printf("포트 %d 에서 대기 중\n", PORT);

    for (;;) {
        struct sockaddr_in peer;
        socklen_t plen = sizeof peer;
        sock_t cli = accept(srv, (struct sockaddr *)&peer, &plen);
        if (cli == INVALID_SOCKET) continue;

        char ip[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof ip);
        printf("접속: %s:%d\n", ip, ntohs(peer.sin_port));

        char buf[4096];
        for (;;) {
            int n = recv(cli, buf, sizeof buf, 0);
            if (n == 0)  { printf("상대가 닫음\n"); break; }   // ★ 정상 종료
            if (n < 0)   { printf("오류 %d\n", SOCK_ERR); break; }

            // ★ send 도 부분 전송이 있습니다 — 다 보낼 때까지 루프
            int off = 0;
            while (off < n) {
                int w = send(cli, buf + off, n - off, 0);
                if (w <= 0) goto done;
                off += w;
            }
        }
    done:
        CLOSESOCK(cli);
    }
    CLOSESOCK(srv);
    net_quit();
    return 0;
}
가장 많이 틀리는 세 가지 — ① htons/htonl 을 빠뜨려 포트가 엉뚱한 값이 됩니다. ② recv 가 0 을 돌려주면 에러가 아니라 상대가 정상적으로 닫은 것입니다. ③ SO_REUSEADDR 없이 서버를 재시작하면 TIME_WAIT 때문에 1~2분 동안 bind 가 실패합니다.

클라이언트 — 이름 해석까지 제대로

// getaddrinfo 는 IPv4/IPv6 를 자동으로 처리합니다
// (inet_addr 은 IPv4 전용 + 낡았습니다 — 쓰지 마세요)
struct addrinfo hints, *res, *it;
memset(&hints, 0, sizeof hints);
hints.ai_family   = AF_UNSPEC;      // v4 든 v6 든
hints.ai_socktype = SOCK_STREAM;

if (getaddrinfo("127.0.0.1", "9000", &hints, &res) != 0) return 1;

sock_t fd = INVALID_SOCKET;
for (it = res; it; it = it->ai_next) {       // ★ 후보를 순서대로 시도
    fd = socket(it->ai_family, it->ai_socktype, it->ai_protocol);
    if (fd == INVALID_SOCKET) continue;
    if (connect(fd, it->ai_addr, (int)it->ai_addrlen) == 0) break;
    CLOSESOCK(fd);
    fd = INVALID_SOCKET;
}
freeaddrinfo(res);
if (fd == INVALID_SOCKET) { fprintf(stderr, "접속 실패\n"); return 1; }

const char *msg = "안녕 서버\n";
send(fd, msg, (int)strlen(msg), 0);

char buf[4096];
int n = recv(fd, buf, sizeof buf - 1, 0);
if (n > 0) { buf[n] = '\0'; printf("에코: %s", buf); }
CLOSESOCK(fd);
먼저 이렇게 시험해 보세요 — 서버를 띄운 뒤 다른 터미널에서 nc 127.0.0.1 9000(또는 telnet) 을 치면 클라이언트를 안 만들고도 바로 확인됩니다. 패킷이 궁금하면 tcpdump -i lo port 9000 또는 Wireshark 를 켜 두면 됩니다.

TCP 는 스트림입니다 — 메시지 경계가 없습니다 ★

// send 를 세 번 했다고 recv 가 세 번 나뉘어 오지 않습니다.
// 한 번에 뭉쳐 올 수도, 한 번이 여러 번에 나뉘어 올 수도 있습니다.
//   → '어디까지가 한 메시지인지'는 내가 정해야 합니다

// 방법 ① 길이 접두사 (가장 흔하고 안전)
int send_msg(sock_t fd, const void *p, uint32_t len) {
    uint32_t be = htonl(len);                 // 4바이트 길이 먼저
    if (send_all(fd, &be, 4) != 0) return -1;
    return send_all(fd, p, len);
}

int recv_msg(sock_t fd, char *out, uint32_t max, uint32_t *got) {
    uint32_t be;
    if (recv_all(fd, &be, 4) != 0) return -1;
    uint32_t len = ntohl(be);
    if (len > max) return -1;                 // ★ 길이 검증 필수(메모리 폭탄 방지)
    if (recv_all(fd, out, len) != 0) return -1;
    *got = len;
    return 0;
}

// 방법 ② 구분자 — 줄바꿈까지 읽기 (HTTP · Redis 프로토콜 방식)
// 방법 ③ 고정 길이 레코드 — 가장 단순하지만 확장이 어렵습니다
“내 컴퓨터에선 되던데요”의 정체 — 로컬에서는 데이터가 작아서 한 번에 다 옵니다. 실제 네트워크에서는 MTU·혼잡 제어 때문에 조각나 갑자기 깨집니다. recv 한 번 = 메시지 하나라고 가정한 코드는 배포한 날 터집니다.

접속이 늘어나면 — select · poll · epoll 🔴 고급

한 스레드로 여러 접속 다루기

// poll — select 보다 낫고 어디서나 됩니다(윈도우는 WSAPoll)
#include <poll.h>

struct pollfd fds[1024];
int nfds = 0;
fds[nfds].fd = srv; fds[nfds].events = POLLIN; nfds++;   // 듣는 소켓

for (;;) {
    int r = poll(fds, nfds, 1000);        // 1초 타임아웃
    if (r < 0) { if (errno == EINTR) continue; break; }

    for (int i = 0; i < nfds; i++) {
        if (!(fds[i].revents & POLLIN)) continue;

        if (fds[i].fd == srv) {           // 새 접속
            int cli = accept(srv, NULL, NULL);
            if (cli >= 0 && nfds < 1024) {
                set_nonblocking(cli);     // ★ 반드시 논블로킹으로
                fds[nfds].fd = cli;
                fds[nfds].events = POLLIN;
                nfds++;
            }
        } else {                          // 기존 접속에 데이터
            char buf[4096];
            int n = recv(fds[i].fd, buf, sizeof buf, 0);
            if (n <= 0) { close(fds[i].fd); fds[i] = fds[--nfds]; }
            else        { send_all(fds[i].fd, buf, n); }
        }
    }
}
// 논블로킹으로 바꾸기
#ifdef _WIN32
    u_long on = 1; ioctlsocket(fd, FIONBIO, &on);
#else
    int fl = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, fl | O_NONBLOCK);
#endif
// 논블로킹에서는 데이터가 없으면 recv 가 -1 을 돌려주고
// errno 가 EAGAIN / EWOULDBLOCK 입니다 — 에러가 아닙니다

셋 중 무엇을 쓰나

방식복잡도언제
selectO(n) 매번 전체 스캔fd 1024 제한. 새 코드에는 쓰지 않습니다
pollO(n)수백 접속까지. 이식성이 가장 좋음
epollO(준비된 것만)리눅스. 수만 접속 — 서버의 표준
kqueue같음맥·BSD
IOCP완료 통지형윈도우 고성능 서버
io_uring배치 제출리눅스 5.1+ — 최신 최고 성능
// epoll — 리눅스에서 접속 1만 개를 한 스레드로
#include <sys/epoll.h>

int ep = epoll_create1(0);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = srv };
epoll_ctl(ep, EPOLL_CTL_ADD, srv, &ev);

struct epoll_event out[512];
for (;;) {
    int n = epoll_wait(ep, out, 512, -1);
    for (int i = 0; i < n; i++) {      // ★ '준비된 것만' 돌아옵니다
        int fd = out[i].data.fd;
        /* ... 위와 같은 처리 ... */
    }
}
// EPOLLET(엣지 트리거)를 쓰면 더 빠르지만,
// "EAGAIN 이 날 때까지 전부 읽어야" 하는 규칙이 생깁니다
실무에서는 직접 안 씁니다 — libuv(Node.js 의 심장) 나 libevent 가 epoll·kqueue·IOCP 를 하나의 API 로 덮어 줍니다. 그래도 이 절을 아는 것이 중요한 이유는, 장애가 났을 때 로그에 나오는 것이 결국 이 층의 용어이기 때문입니다.
실서비스로 가기 전 최소 체크리스트 — ① SIGPIPE 무시(signal(SIGPIPE, SIG_IGN))하지 않으면 끊긴 연결에 send 하는 순간 프로세스가 죽습니다. ② 읽기·쓰기 타임아웃(SO_RCVTIMEO) 설정. ③ 길이 필드는 반드시 상한 검증. ④ TLS 가 필요하면 직접 만들지 말고 mbedTLS·OpenSSL 를 얹습니다. ⑤ 작은 메시지를 자주 보낸다면 TCP_NODELAY(Nagle 끄기)를 검토합니다.
정리 — TCP 서버는 socket → bind → listen → accept 네 줄이 전부이고, 클라이언트는 socket → connect 두 줄입니다. 가장 중요한 사실은 TCP 에 메시지 경계가 없다는 것 — 길이 접두사를 붙이는 습관 하나가 실전 버그의 절반을 막습니다. 접속이 많아지면 poll → epoll 로 올라가되, 실무에서는 대개 libuv 같은 래퍼를 씁니다.
C 19

스레드와 동기화 — pthreads · C11 threads · atomics

파이썬에는 GIL 이 있어서 스레드를 써도 CPU 가 하나뿐이지만, C 는 진짜로 코어를 다 씁니다. 그 대가로 데이터 레이스라는, 재현도 안 되고 디버거로도 안 잡히는 종류의 버그가 생깁니다. 이 절은 왜 counter++ 하나가 깨지는지부터 시작해서 뮤텍스·조건 변수·원자 연산과 TSan 으로 실제로 잡아내는 법까지 갑니다.

counter++ 는 한 동작이 아닙니다 — 읽고 · 더하고 · 쓰는 세 단계입니다 두 스레드가 각각 한 번씩 증가시킵니다. 결과는 2 여야 합니다 스레드 A load → 0 +1 = 1 store 1 스레드 B load → 0 +1 = 1 store 1 시간 결과는 2 가 아니라 1 입니다 B 가 읽은 값이 A 의 갱신 전 값이라 하나가 통째로 사라졌습니다 이것이 데이터 레이스입니다 — 대부분은 잘 돌다가 가끔 틀립니다 고치는 방법은 두 가지뿐입니다 ① 뮤텍스로 세 단계를 통째로 묶어 한 번에 한 스레드만 ② atomic_fetch_add 로 CPU 차원의 한 동작으로 만듭니다
데이터 레이스는 “가끔 값이 이상하다”로 나타납니다 — 디버거를 붙이면 느려져서 재현이 안 되는 것이 특징입니다.

pthreads — 리눅스·맥의 사실상 표준

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

typedef struct { int from, to; long sum; } Job;

// 스레드 함수의 모양은 고정입니다: void* (*)(void*)
static void *worker(void *arg) {
    Job *j = arg;
    long s = 0;
    for (int i = j->from; i < j->to; i++) s += i;
    j->sum = s;                 // ★ 각자 다른 Job 을 쓰므로 레이스 없음
    return NULL;
}

int main(void) {
    enum { N = 4 };
    pthread_t th[N];
    Job jobs[N];

    for (int i = 0; i < N; i++) {
        jobs[i] = (Job){ i * 250000, (i + 1) * 250000, 0 };
        if (pthread_create(&th[i], NULL, worker, &jobs[i]) != 0) {
            perror("pthread_create"); return 1;
        }
    }
    long total = 0;
    for (int i = 0; i < N; i++) {
        pthread_join(th[i], NULL);   // ★ 끝날 때까지 기다립니다
        total += jobs[i].sum;
    }
    printf("합계 %ld\n", total);
}
// 컴파일: gcc -std=c17 -O2 -pthread sum.c -o sum
-pthread 를 빼먹으면 링크는 되는데 런타임에 이상하게 동작할 수 있습니다 (일부 환경에서 스레드 지원 코드가 비활성화됩니다). -lpthread 가 아니라 -pthread 를 컴파일·링크 양쪽에 주는 것이 정확한 방법입니다.

C11 <threads.h> — 표준이지만 현실은

#include <threads.h>
#include <stdio.h>

static int worker(void *arg) {     // ★ 반환형이 int (pthreads 는 void*)
    printf("스레드 %d\n", *(int *)arg);
    return 0;
}

int main(void) {
    thrd_t t;
    int id = 7;
    if (thrd_create(&t, worker, &id) != thrd_success) return 1;
    thrd_join(t, NULL);

    mtx_t m;  mtx_init(&m, mtx_plain);
    mtx_lock(&m);  /* 임계 구역 */  mtx_unlock(&m);
    mtx_destroy(&m);
}
환경지원 상황(2026 기준)
glibc(리눅스)2.28+ 부터 지원
macOSlibc 에 없음 — 별도 구현 필요
MSVC최근 버전에서 지원 시작(오래 없었음)
임베디드대부분 없음
그래서 실무는 아직 pthreads 입니다 — 리눅스·맥·안드로이드가 pthreads 이고, 윈도우는 CreateThread/SRWLOCK 또는 pthreads-win32 를 씁니다. 새 프로젝트라면 스레드 래퍼를 얇게 하나 두고 그 안에서만 분기하는 것이 가장 안전합니다.

뮤텍스와 조건 변수 — 생산자·소비자 큐 🟡 중급

스레드 안전 큐 — 실무에서 가장 자주 쓰는 형태

#include <pthread.h>
#include <stdbool.h>

#define CAP 256

typedef struct {
    int   buf[CAP];
    size_t head, tail, count;
    bool  closed;
    pthread_mutex_t m;
    pthread_cond_t  not_empty;   // "넣었다" 알림
    pthread_cond_t  not_full;    // "뺐다" 알림
} Queue;

void q_init(Queue *q) {
    q->head = q->tail = q->count = 0;
    q->closed = false;
    pthread_mutex_init(&q->m, NULL);
    pthread_cond_init(&q->not_empty, NULL);
    pthread_cond_init(&q->not_full,  NULL);
}

void q_push(Queue *q, int v) {
    pthread_mutex_lock(&q->m);
    while (q->count == CAP)                    // ★ if 가 아니라 while
        pthread_cond_wait(&q->not_full, &q->m);
    q->buf[q->tail] = v;
    q->tail = (q->tail + 1) % CAP;
    q->count++;
    pthread_cond_signal(&q->not_empty);
    pthread_mutex_unlock(&q->m);
}
bool q_pop(Queue *q, int *out) {
    pthread_mutex_lock(&q->m);
    while (q->count == 0 && !q->closed)
        pthread_cond_wait(&q->not_empty, &q->m);

    if (q->count == 0) {                       // 닫혔고 비었다
        pthread_mutex_unlock(&q->m);
        return false;
    }
    *out = q->buf[q->head];
    q->head = (q->head + 1) % CAP;
    q->count--;
    pthread_cond_signal(&q->not_full);
    pthread_mutex_unlock(&q->m);
    return true;
}

// 더 이상 안 넣는다고 알리기 — 소비자들이 빠져나옵니다
void q_close(Queue *q) {
    pthread_mutex_lock(&q->m);
    q->closed = true;
    pthread_cond_broadcast(&q->not_empty);     // ★ 전원 깨우기
    pthread_mutex_unlock(&q->m);
}
while 이지 if 가 아닙니다 — cond_wait 는 아무도 신호를 안 보냈는데 깨어날 수 있습니다(가짜 깨어남). 조건을 다시 확인하지 않으면 빈 큐에서 꺼내는 버그가 납니다. 이건 자바 wait/notify, 파이썬 Condition 도 완전히 동일합니다.
왜 조건 변수가 필요한가 — 큐가 비었을 때 while(count==0){} 로 도는 것은 CPU 코어 하나를 통째로 태우는 짓입니다. 조건 변수는 커널에 재워 두었다가 신호가 왔을 때만 깨워 줍니다. cond_wait 는 뮤텍스를 자동으로 풀고 잠들었다가, 깨어날 때 다시 잠급니다 — 이 원자성이 조건 변수의 존재 이유입니다.

원자 연산과 데이터 레이스 잡기 🔴 고급

stdatomic.h — 뮤텍스 없이 안전하게

#include <stdatomic.h>

atomic_long counter = 0;

void *bump(void *_) {
    for (int i = 0; i < 1000000; i++)
        atomic_fetch_add(&counter, 1);   // ★ 읽기+더하기+쓰기가 한 동작
    return NULL;
}
// 뮤텍스보다 훨씬 빠릅니다 — 커널에 들어가지 않으니까요

// 플래그 하나로 스레드 멈추기 (가장 흔한 용도)
atomic_bool stop = false;

void *loop(void *_) {
    while (!atomic_load_explicit(&stop, memory_order_relaxed)) {
        /* 일 */
    }
    return NULL;
}
// 다른 스레드에서:
atomic_store(&stop, true);

// 락 없는 스택 push — CAS(compare-and-swap) 관용구
void push(_Atomic(Node *) *head, Node *n) {
    Node *old = atomic_load(head);
    do {
        n->next = old;
    } while (!atomic_compare_exchange_weak(head, &old, n));
    // ★ 실패하면 old 가 '현재 값'으로 갱신되어 다시 시도합니다
}
volatile 은 스레드 동기화 도구가 아닙니다 — 컴파일러 최적화만 막을 뿐 CPU 의 재배치와 캐시 일관성은 전혀 다루지 않습니다. 자바·C#의 volatile 과 의미가 다릅니다. C 에서 스레드 간 공유에는 반드시 _Atomic 이나 뮤텍스를 쓰세요. volatile 의 진짜 자리는 c22 의 하드웨어 레지스터입니다.

ThreadSanitizer — 실제로 잡아 봅니다

# 레이스가 있는 코드를 일부러 만들어 보세요
gcc -std=c17 -g -O1 -fsanitize=thread -pthread race.c -o race
./race
WARNING: ThreadSanitizer: data race (pid=12345)
  Write of size 8 at 0x55d1e8 by thread T2:
    #0 bump race.c:12 (race+0x1220)

  Previous write of size 8 at 0x55d1e8 by thread T1:
    #0 bump race.c:12 (race+0x1220)

  Location is global 'counter' of size 8 at 0x55d1e8

SUMMARY: ThreadSanitizer: data race race.c:12 in bump
도구잡는 것
-fsanitize=thread데이터 레이스 · 잘못된 락 순서. 5~15배 느림
-fsanitize=address버퍼 넘침 · use-after-free (TSan 과 동시 사용 불가)
helgrindvalgrind 계열 레이스 검출. 더 느리지만 재빌드 불필요
gdb thread apply all bt데드락 났을 때 모든 스레드 스택
데드락을 예방하는 규칙 하나 — 여러 뮤텍스를 잡을 때는 항상 같은 순서로 잡습니다. A→B 로 잡는 곳과 B→A 로 잡는 곳이 하나라도 있으면 언젠가 멈춥니다. 락에 번호를 매기고 번호가 커지는 방향으로만 잡는 것이 실무의 정석입니다.
정리 — C 의 스레드는 진짜 병렬이고, 그래서 공유 데이터를 만지는 순간 규칙이 필요합니다. 여럿이 읽고 쓰면 → 뮤텍스, 카운터·플래그 하나면 → _Atomic, 기다려야 하면 → 조건 변수(while 로 재확인). 그리고 스레드를 쓰는 프로젝트라면 CI 에 -fsanitize=thread 빌드를 하나 넣어 두세요 — 사람 눈으로 찾을 수 없는 버그를 기계가 찾아 줍니다.
C 20

프로세스 · 시그널 · 파이프 — 셸이 하는 일을 직접

ls | grep c 를 치면 셸이 무슨 일을 하는지 아십니까? fork 로 자기를 복제하고, pipe 로 둘을 잇고, exec 로 다른 프로그램이 되고, wait 으로 기다립니다 — 전부 이 절의 함수입니다. 파이썬 subprocess, Node child_process 도 안에서 정확히 이 일을 합니다. Ctrl+C 를 눌렀을 때 프로그램이 깨끗하게 끝나게 만드는 법도 여기서 배웁니다.

fork() — 한 번 부르고 두 번 돌아옵니다 부모 프로세스 (PID 1000) 여기까지는 평범한 프로그램 하나입니다 pid = fork(); 부모 — 반환값은 1001 (자식 PID) wait() 으로 자식이 끝나길 기다립니다 기다리지 않으면 좀비가 남습니다 자식 — 반환값은 0 execvp() 로 다른 프로그램이 됩니다 성공하면 이 아래 코드는 실행되지 않습니다 메모리는 그 자리에서 복사되지 않습니다 — copy-on-write 두 프로세스는 같은 물리 페이지를 공유하다가, 어느 한쪽이 쓰는 순간에만 그 페이지를 복사합니다 그래서 메모리를 1GB 쓰는 프로세스에서도 fork 자체는 밀리초 단위로 끝납니다
반환값이 0 이면 내가 자식, 0보다 크면 내가 부모 — 이 한 줄이 프로세스 프로그래밍의 출발점입니다.

fork + exec + wait — 외부 명령 실행하기

#include <unistd.h>
#include <sys/wait.h>
#include <stdio.h>
#include <errno.h>

int run(char *const argv[]) {
    pid_t pid = fork();
    if (pid < 0) { perror("fork"); return -1; }

    if (pid == 0) {                       // ── 자식 ──
        execvp(argv[0], argv);            // 성공하면 여기로 안 돌아옵니다
        perror("execvp");                 // 여기 왔다는 건 실패했다는 뜻
        _exit(127);                       // ★ exit 이 아니라 _exit
    }

    // ── 부모 ──
    int status;
    while (waitpid(pid, &status, 0) < 0)
        if (errno != EINTR) return -1;

    if (WIFEXITED(status))   return WEXITSTATUS(status);   // 정상 종료 코드
    if (WIFSIGNALED(status)) return 128 + WTERMSIG(status); // 시그널로 죽음
    return -1;
}

int main(void) {
    char *cmd[] = { "ls", "-l", NULL };   // ★ 마지막은 반드시 NULL
    printf("종료 코드 %d\n", run(cmd));
}
자식에서는 exit 대신 _exit 를 쓰세요. exit 은 atexit 훅을 돌리고 stdio 버퍼를 flush 하는데, 그 버퍼는 부모에게서 복사된 것이라 같은 출력이 두 번 찍힙니다. 실제로 로그가 중복되는 사고의 단골 원인입니다.

파이프로 자식의 출력을 받아 오기

// 간단하게: popen — 셸을 거칩니다
FILE *p = popen("git rev-parse HEAD", "r");
if (p) {
    char line[128];
    if (fgets(line, sizeof line, p)) printf("커밋 %s", line);
    int rc = pclose(p);     // ★ 반드시 pclose (종료 코드도 여기서)
}
// ⚠ popen 은 문자열을 셸에 넘깁니다 —
//   사용자 입력을 그대로 넣으면 명령 주입 취약점이 됩니다

// 정확하게: pipe + fork (셸을 안 거칩니다)
int fd[2];
if (pipe(fd) != 0) return -1;       // fd[0]=읽기, fd[1]=쓰기

pid_t pid = fork();
if (pid == 0) {
    close(fd[0]);                   // ★ 안 쓰는 쪽은 반드시 닫습니다
    dup2(fd[1], STDOUT_FILENO);     // 표준 출력을 파이프로 바꿔치기
    close(fd[1]);
    execlp("ls", "ls", "-l", (char *)NULL);
    _exit(127);
}
close(fd[1]);                       // 부모도 쓰기 쪽을 닫아야
char buf[4096];                     // 자식이 끝날 때 EOF 가 옵니다
ssize_t n;
while ((n = read(fd[0], buf, sizeof buf)) > 0)
    fwrite(buf, 1, (size_t)n, stdout);
close(fd[0]);
waitpid(pid, NULL, 0);
파이프에서 가장 흔한 버그 — 안 쓰는 쪽 fd 를 안 닫는 것입니다. 쓰기 쪽이 어딘가에 하나라도 열려 있으면 읽기 쪽이 EOF 를 영영 못 받아 프로그램이 멈춥니다. “왜 자식이 끝났는데 read 가 안 끝나지?”의 답은 거의 항상 이것입니다.

시그널 — Ctrl+C 를 우아하게 처리하기 🟡 중급

sigaction — signal() 보다 이쪽을 쓰세요

#include <signal.h>
#include <stdio.h>
#include <string.h>     // memset
#include <unistd.h>

// ★ 핸들러에서 건드릴 수 있는 유일한 전역 타입
static volatile sig_atomic_t g_stop = 0;

static void on_sigint(int sig) {
    (void)sig;
    g_stop = 1;          // 플래그만 세웁니다. 그 이상은 하지 않습니다
}

int main(void) {
    struct sigaction sa;
    memset(&sa, 0, sizeof sa);
    sa.sa_handler = on_sigint;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;      // 끊긴 시스템 콜을 자동 재시작

    sigaction(SIGINT,  &sa, NULL);  // Ctrl+C
    sigaction(SIGTERM, &sa, NULL);  // kill 기본 시그널
    signal(SIGPIPE, SIG_IGN);       // ★ 소켓 서버에는 거의 필수

    while (!g_stop) {
        /* 일을 합니다 */
        sleep(1);
    }
    printf("정리하고 종료합니다\n");   // 여기서 파일 닫기·플러시
    return 0;
}
핸들러 안에서 하면 안 되는 것 — printf·malloc·free는 모두 비동기 시그널 안전이 아닙니다. 락을 잡고 있는 중에 시그널이 오면 데드락이 납니다. 핸들러에서 허용되는 것은 volatile sig_atomic_t 플래그 설정과 write(2) 정도입니다. 나머지는 전부 메인 루프에서 하세요.

알아 둘 시그널과 종료 코드

시그널언제 · 무엇
SIGINT (2)Ctrl+C. 잡을 수 있음
SIGTERM (15)kill 기본 · 도커 stop. 정리하고 나가라
SIGKILL (9)잡을 수 없음. 즉시 사망
SIGSEGV (11)잘못된 메모리 접근 — 대개 포인터 버그
SIGPIPE (13)끊긴 파이프·소켓에 쓰기. 무시 설정 필수
SIGCHLD (17)자식이 끝남. 좀비 회수 신호
SIGABRT (6)abort()·assert 실패
SIGBUS (7)정렬 오류 · mmap 한 파일이 줄어듦
# 셸에서 종료 코드로 원인을 봅니다
./app; echo $?
#   0     정상
#   1~125 프로그램이 정한 값
#   130   = 128 + 2  → SIGINT (Ctrl+C)
#   139   = 128 + 11 → SIGSEGV (세그폴트)
#   137   = 128 + 9  → SIGKILL (OOM 킬러가 죽였을 가능성 큼)

# 도커에서 컨테이너가 137 로 죽으면 메모리 초과가 1순위 의심
dmesg | grep -i "killed process"
graceful shutdown 이 왜 중요한가 — 쿠버네티스·도커는 종료할 때 SIGTERM 을 보내고 몇 초 기다린 뒤 SIGKILL 합니다. 그 사이에 연결 정리·버퍼 플러시를 못 하면 데이터가 유실됩니다. 위의 플래그 패턴 하나면 그 요구를 만족합니다.

윈도우에서는 — fork 가 없습니다 🔴 고급

CreateProcess — fork+exec 이 한 함수

#include <windows.h>
#include <stdio.h>

int run_win(const char *cmdline) {
    STARTUPINFOA si;  PROCESS_INFORMATION pi;
    ZeroMemory(&si, sizeof si);  si.cb = sizeof si;
    ZeroMemory(&pi, sizeof pi);

    // ★ cmdline 은 쓰기 가능한 버퍼여야 합니다 (API 가 수정합니다)
    char buf[1024];
    snprintf(buf, sizeof buf, "%s", cmdline);

    if (!CreateProcessA(NULL, buf, NULL, NULL, FALSE,
                        0, NULL, NULL, &si, &pi)) {
        fprintf(stderr, "CreateProcess 실패 %lu\n", GetLastError());
        return -1;
    }
    WaitForSingleObject(pi.hProcess, INFINITE);   // wait() 에 해당

    DWORD code = 0;
    GetExitCodeProcess(pi.hProcess, &code);
    CloseHandle(pi.hProcess);                     // ★ 핸들 누수 주의
    CloseHandle(pi.hThread);
    return (int)code;
}
왜 윈도우에 fork 가 없나 — 윈도우의 프로세스는 생성 시점에 실행할 이미지를 정하는 구조라 “현재 프로세스를 복제한다”는 개념 자체가 없습니다. 그래서 이식성 있는 코드는 “외부 프로그램 실행”이라는 한 가지 기능으로 추상화해 두고 그 안에서 분기합니다.

대응표와 실무 선택

POSIXWindows
fork + execvpCreateProcess
waitpidWaitForSingleObject
pipeCreatePipe
popen_popen
kill(pid, SIGTERM)TerminateProcess(강제)
시그널 핸들러SetConsoleCtrlHandler
getpidGetCurrentProcessId
양쪽을 다 지원해야 한다면 — 프로세스 실행은 차이가 커서 #ifdef 로 덮기 힘듭니다. libuv 의 uv_spawn 이나 subprocess.h(단일 헤더 라이브러리)를 쓰면 파이프 연결·환경 변수·종료 코드까지 한 API 로 해결됩니다. 직접 만들 값어치가 별로 없는 영역입니다.
좀비 프로세스 — 자식이 끝났는데 부모가 wait 을 안 하면 커널이 종료 코드를 담은 껍데기를 계속 붙들고 있습니다(ps 에 Z 로 보임). 오래 도는 서버라면 SIGCHLD 핸들러에서 while (waitpid(-1, NULL, WNOHANG) > 0) {} 를 돌려 회수하세요.
정리 — fork 는 반환값으로 부모·자식을 구분하고, 자식은 exec 으로 다른 프로그램이 됩니다. 부모는 반드시 wait 으로 회수해야 좀비가 안 남습니다. 파이프는 안 쓰는 쪽을 닫는 것이 전부이고, 시그널 핸들러에서는 플래그만 세우고 나옵니다. 윈도우에는 fork 가 없으니 “프로세스 실행” 한 기능으로 추상화하는 것이 정답입니다.
C 21

C23 — 30년 만에 C 가 편해졌습니다

C 는 오랫동안 “변하지 않는 언어”였습니다. C11 이후 C17 은 버그 수정판이었고, 실무 기준은 늘 C99 였죠. 그런데 C23 은 다릅니다 — nullptr, constexpr, typeof, #embed, auto, 2진 리터럴까지 C++ 에서 부러워하던 것들이 대거 들어왔습니다. 다만 컴파일러 지원이 고르지 않아, 무엇을 지금 써도 되는지를 아는 것이 이 절의 목표입니다.

C 표준 연표 — 그리고 무엇이 지금 쓸 수 있는가 C89 / C90 1990 함수 프로토타입 C99 1999 stdint · inline C11 2011 _Atomic · threads C17 2018 버그 수정판 C23 2024 nullptr · #embed 시간 2026년 8월 기준 지원 현황 GCC 15 · Clang 20 은 -std=c23 으로 대부분 쓸 수 있습니다 (일부 기능은 여전히 실험적) MSVC 는 일부만 지원합니다 — 여러 플랫폼을 지원해야 한다면 아직 C17 이 안전한 기준선입니다
지금 혼자 쓰는 도구라면 C23 을 켜도 좋고, 남이 빌드할 코드라면 C17 이 기본값입니다.

바로 편해지는 것들

// ① nullptr — NULL 의 모호함이 사라집니다
void f(int);
void f_ptr(char *);
char *p = nullptr;          // 타입이 nullptr_t 로 확실합니다
// 예전에는 NULL 이 0 일 수도 (void*)0 일 수도 있어
// 가변 인자로 넘길 때 크기가 어긋나는 사고가 있었습니다
execl("/bin/ls", "ls", nullptr);   // ★ (char*)NULL 안 써도 됩니다

// ② bool 이 키워드가 됐습니다 — stdbool.h 불필요
bool ok = true;

// ③ 2진 리터럴 + 자릿수 구분자
unsigned mask  = 0b1010'1010;      // 비트 마스크가 눈에 보입니다
unsigned flags = 0b0000'0001;

// ④ auto — 타입 추론 (C++ 의 그것과 같습니다)
auto n = 42;                       // int
auto s = "hi";                     // char* (C 의 문자열 리터럴은 const 가 아님)
// 매크로 안에서 임시 변수를 만들 때 특히 유용합니다

// ⑤ typeof / typeof_unqual — GCC 확장이 표준이 됐습니다
#define SWAP(a, b) do { typeof(a) _t = (a); (a) = (b); (b) = _t; } while (0)

// ⑥ 열거형의 밑바탕 타입 지정
enum Color : unsigned char { RED, GREEN, BLUE };   // 1바이트 확정
typeof 가 표준이 된 것이 생각보다 큽니다 — 지금까지 매크로 안에서 임시 변수를 만들려면 타입을 인자로 또 받아야 했습니다. 이제 타입 안전한 매크로를 제대로 쓸 수 있어, c14 의 제네릭 자료구조가 훨씬 깔끔해집니다.

구조를 바꾸는 것들

// ① constexpr — 진짜 컴파일 시점 상수
constexpr int MAX = 1024;
int arr[MAX];               // enum 이나 #define 을 안 써도 됩니다
// (C++ 과 달리 C23 의 constexpr 은 '객체'에만 붙습니다. 함수는 아직 없습니다)

// ② #embed — 파일을 배열로 바로 넣기 ★
const unsigned char icon[] = {
    #embed "logo.png"
};
// 예전에는 xxd -i 로 헤더를 생성해 빌드에 끼워 넣었습니다.
// 폰트·셰이더·인증서를 실행 파일에 박을 때 압도적으로 편합니다

// ③ _BitInt(N) — 원하는 비트 수의 정수
_BitInt(24)  pixel;         // 정확히 24비트
unsigned _BitInt(3) tiny;   // 0~7
// 하드웨어 레지스터·비트 프로토콜을 다룰 때 유용합니다

// ④ [[attributes]] — C++ 문법이 그대로
[[nodiscard]] int must_check(void);
[[deprecated("v2 에서 제거됩니다")]] void old_api(void);
[[maybe_unused]] static int debug_only;
[[fallthrough]];            // switch 에서 의도적 관통 표시

// ⑤ 표준에 들어온 함수들
memset_explicit(key, 0, len);   // 최적화가 지우지 못하게 (비밀 삭제)
// strdup / strndup 도 드디어 표준이 됐습니다
memset_explicit 가 왜 필요한가 — 비밀번호 버퍼를 memset(buf, 0, n) 으로 지워도, 그 뒤에 buf 를 안 쓰면 컴파일러가 그 memset 을 통째로 삭제해 버립니다(“죽은 저장 제거”). 비밀이 메모리에 그대로 남아 덤프에 찍히는 실제 취약점이었습니다.

지금 켜도 되는가 — 기능별 판단표 🟡 중급

2026년 8월 기준, 기능별 현실

기능GCC · ClangMSVC판단
nullptr지원부분혼자 쓰는 코드면 ○
bool 키워드지원부분stdbool.h 를 계속 include 하면 양쪽 다 안전
2진 리터럴지원(오래전부터 확장으로도)지원◎ 사실상 안전
typeof지원(GNU 확장으로 30년)부분○
constexpr지원부분△ enum 이 여전히 무난
#embedGCC 15 · Clang 19+미지원△ 빌드 스크립트 대안을 남겨 두세요
auto지원부분△ 남용하면 가독성이 떨어집니다
_BitInt(N)지원미지원✕ 임베디드 전용으로 생각
[[nodiscard]]지원부분○ 매크로로 감싸면 안전
// 이식성을 지키면서 새 기능을 쓰는 관용구
#if __STDC_VERSION__ >= 202311L
  #define NODISCARD [[nodiscard]]
  #define NULLPTR   nullptr
#elif defined(__GNUC__)
  #define NODISCARD __attribute__((warn_unused_result))
  #define NULLPTR   NULL
#else
  #define NODISCARD
  #define NULLPTR   NULL
#endif

NODISCARD int parse(const char *s, int *out);
# 내 컴파일러가 어느 표준까지 되는지 확인
gcc -std=c23 -dM -E - < /dev/null | grep __STDC_VERSION__
#   #define __STDC_VERSION__ 202311L   ← C23 이면 이 값

gcc --version    # GCC 15 이상이면 C23 대부분 사용 가능
clang --version  # Clang 20 이상 권장
cl              # MSVC — /std:c17 또는 /std:clatest
실무 권장 — 라이브러리를 배포한다면 -std=c17 을 기준으로 두고, C23 기능은 위처럼 매크로로 감싸서 선택적으로 씁니다. 개인 도구·리눅스 전용 서비스라면 -std=c23 을 켜도 문제없습니다.

C23 이 바꾸지 않은 것 🔴 고급

여전히 그대로인 것들

항목C23 이후에도
메모리 안전그대로 없습니다 — 범위 검사도 소유권도 없음
모듈없음. 여전히 #include 와 헤더 가드
네임스페이스없음. 접두사로 이름 충돌을 피합니다
제네릭_Generic 과 매크로가 전부
표준 컨테이너없음. 직접 만들거나 라이브러리
예외없음. 반환값과 errno
RAII없음(GCC/Clang 의 cleanup 확장은 있음)
// GCC/Clang 확장이지만 실무에서 아주 유용한 것 — 자동 정리
static inline void free_ptr(void *p) { free(*(void **)p); }
#define AUTOFREE __attribute__((cleanup(free_ptr)))

void f(void) {
    AUTOFREE char *buf = malloc(1024);
    if (!buf) return;
    /* ... 중간에 어디서 return 해도 ... */
}   // ★ 스코프를 벗어날 때 자동으로 free 됩니다 (C++ 의 RAII 흉내)
// 표준은 아니지만 GCC·Clang 양쪽에서 되고, systemd 가 전면적으로 씁니다

_Generic — C 의 함수 오버로딩

// C11 부터 있던 기능인데 잘 안 알려져 있습니다
#include <math.h>

#define abs_any(x) _Generic((x),          \
        int:    abs,                      \
        long:   labs,                     \
        float:  fabsf,                    \
        double: fabs                      \
    )(x)

printf("%d %f\n", abs_any(-3), abs_any(-3.5));

// 타입에 따라 다른 출력 서식을 고르기
#define fmt_of(x) _Generic((x),           \
        int:         "%d",                \
        unsigned:    "%u",                \
        long long:   "%lld",              \
        double:      "%f",                \
        char *:      "%s",                \
        default:     "%p")

#define print_any(x) printf(fmt_of(x) "\n", (x))
print_any(42);        // %d
print_any("hello");   // %s
표준 헤더가 실제로 이렇게 만들어져 있습니다 — <tgmath.h> 의 sqrt(x) 가 인자 타입에 따라 sqrtf/sqrt/sqrtl 로 갈리는 것이 _Generic 의 대표 용례입니다. 다만 컴파일 시점 선택일 뿐이라 런타임 다형성은 여전히 함수 포인터(c07)의 몫입니다.
정리 — C23 은 C 가 처음으로 “편의성”을 챙긴 표준입니다. nullptr·bool·2진 리터럴·typeof 는 지금 써도 거의 안전하고, #embed·_BitInt 는 GCC/Clang 전용으로 생각하는 편이 마음 편합니다. 다만 메모리 안전·모듈·컨테이너는 여전히 없습니다 — C 를 쓰는 이유와 감수할 것은 그대로입니다.
C 22

임베디드 · 시스템 C — 운영체제 없이 도는 코드

전 세계에서 매년 만들어지는 마이크로컨트롤러는 수백억 개이고, 그 위에서 도는 코드는 대부분 C 입니다. malloc 도 printf 도 운영체제도 없는 환경에서 C 가 어떻게 쓰이는지를 보면, 지금까지 배운 포인터·비트 연산·volatile 이 왜 언어에 있는지가 비로소 이해됩니다. Arduino·ESP32·STM32 중 어디서 시작하면 되는지도 정리합니다.

고정 크기 링 버퍼 — 인터럽트와 메인 루프를 잇는 다리 head (쓰기) tail (읽기) D 0 E 1 다음에 쓸 칸 2 비어 있음 3 비어 있음 4 A 5 B 6 C 7 7번을 넘으면 0으로 돌아갑니다 — 나눗셈 없이 비트 마스크 하나로 왜 링 버퍼인가 malloc 이 없어도 되고 크기가 절대 변하지 않습니다 인터럽트에서 넣고 메인 루프에서 꺼내는 표준 패턴입니다 지켜야 할 두 가지 ① 크기는 2의 거듭제곱 — 인덱스는 비트 마스크로 감쌉니다 ② 쓰는 쪽 하나, 읽는 쪽 하나일 때만 락 없이 안전합니다
임베디드에서 malloc 을 안 쓰는 이유가 여기 있습니다 — 크기가 정해진 구조는 언제 터질지 알 수 있습니다.

volatile — 여기서가 진짜 쓰임새입니다

// 하드웨어 레지스터는 '내가 안 바꿔도 값이 변합니다'
// volatile 이 없으면 컴파일러가 두 번째 읽기를 생략해 버립니다

#define UART0_BASE  0x40011000u
#define UART_SR     (*(volatile uint32_t *)(UART0_BASE + 0x00))
#define UART_DR     (*(volatile uint32_t *)(UART0_BASE + 0x04))
#define SR_TXE      (1u << 7)   // 송신 버퍼 빔

void uart_putc(char c) {
    while (!(UART_SR & SR_TXE)) { }   // ★ volatile 이 없으면 무한 루프
    UART_DR = (uint32_t)(unsigned char)c;
}

// volatile 이 하는 일 / 하지 않는 일
//   ○ 매번 실제로 메모리를 읽고 씁니다 (최적화로 생략 안 됨)
//   ○ volatile 접근끼리의 순서를 유지합니다
//   ✕ 원자성을 보장하지 않습니다      → 스레드에는 _Atomic (c19)
//   ✕ CPU 재배치·캐시 일관성과 무관   → 배리어가 따로 필요

// 인터럽트 핸들러와 공유하는 변수에는 붙입니다
static volatile uint32_t g_ticks;      // 타이머 ISR 이 증가시킴

void SysTick_Handler(void) { g_ticks++; }   // 인터럽트에서
void delay_ms(uint32_t ms) {
    uint32_t start = g_ticks;
    while (g_ticks - start < ms) { }         // ★ 뺄셈이라 오버플로에도 안전
}
32비트 MCU 에서 64비트 변수를 ISR 과 공유하면 읽는 도중에 인터럽트가 끼어들어 위 32비트와 아래 32비트가 다른 시점의 값이 될 수 있습니다(찢어진 읽기). volatile 은 이걸 막아 주지 않습니다 — 인터럽트를 잠깐 끄거나 32비트 이하 타입만 공유하세요.

비트 연산 — 레지스터를 다루는 문법

#include <stdint.h>

#define BIT(n)            (1u << (n))
#define SET_BIT(r, m)     ((r) |=  (m))
#define CLR_BIT(r, m)     ((r) &= ~(m))
#define TGL_BIT(r, m)     ((r) ^=  (m))
#define GET_BIT(r, m)     (((r) & (m)) != 0)

// 여러 비트로 된 필드 쓰기 — 읽고, 지우고, 넣기 (read-modify-write)
#define FIELD(r, mask, shift, v) \
    ((r) = ((r) & ~(mask)) | (((uint32_t)(v) << (shift)) & (mask)))

// GPIO 예: 5번 핀을 출력으로 설정하고 켜기
#define GPIOA_MODER (*(volatile uint32_t *)0x40020000u)
#define GPIOA_ODR   (*(volatile uint32_t *)0x40020014u)

FIELD(GPIOA_MODER, 0x3u << 10, 10, 0b01);  // 5번 핀 모드 = 출력
SET_BIT(GPIOA_ODR, BIT(5));                // 5번 핀 HIGH
CLR_BIT(GPIOA_ODR, BIT(5));                // 5번 핀 LOW

// 자주 쓰는 비트 요령
uint32_t x = 0b1011'0000;
int lowest = x & -x;              // 가장 낮은 1 비트만 남기기
int count  = __builtin_popcount(x);   // 1 비트 개수 (GCC/Clang)
int lead   = __builtin_clz(x);        // 앞쪽 0 개수 → 로그2 계산에
x &= x - 1;                       // 가장 낮은 1 비트 지우기
비트필드(struct { unsigned a:3; })를 레지스터 맵에 쓰지 마세요 — 비트 배치 순서가 컴파일러·플랫폼마다 다릅니다. 데이터시트대로 동작한다는 보장이 없습니다. 레지스터는 마스크와 시프트를 명시하는 위 방식이 안전하고, 실제 벤더 헤더도 그렇게 되어 있습니다.

인터럽트 안전 링 버퍼 — 복붙해서 쓰는 코드 🟡 중급

단일 생산자 · 단일 소비자 큐 (락 없음)

#include <stdint.h>
#include <stdbool.h>

#define RB_SIZE 256                      // ★ 반드시 2의 거듭제곱
#define RB_MASK (RB_SIZE - 1)

typedef struct {
    uint8_t  buf[RB_SIZE];
    volatile uint16_t head;              // 쓰는 쪽(ISR)만 고칩니다
    volatile uint16_t tail;              // 읽는 쪽(메인)만 고칩니다
} Ring;

// ── 인터럽트 안에서 호출 (생산자) ──
bool rb_push(Ring *r, uint8_t v) {
    uint16_t next = (uint16_t)((r->head + 1) & RB_MASK);
    if (next == r->tail) return false;    // 가득 참 — 버립니다
    r->buf[r->head] = v;
    __atomic_thread_fence(__ATOMIC_RELEASE);   // 데이터가 먼저 보이도록
    r->head = next;                       // ★ 마지막에 head 갱신
    return true;
}

// ── 메인 루프에서 호출 (소비자) ──
bool rb_pop(Ring *r, uint8_t *out) {
    if (r->tail == r->head) return false; // 비었음
    __atomic_thread_fence(__ATOMIC_ACQUIRE);
    *out = r->buf[r->tail];
    r->tail = (uint16_t)((r->tail + 1) & RB_MASK);
    return true;
}

// UART 수신 인터럽트에서
void USART1_IRQHandler(void) {
    if (UART_SR & SR_RXNE)
        rb_push(&g_rx, (uint8_t)UART_DR);   // ★ 짧게 끝냅니다
}

// 메인 루프에서
int main(void) {
    hw_init();
    for (;;) {
        uint8_t c;
        while (rb_pop(&g_rx, &c))
            handle_byte(c);                 // 무거운 처리는 여기서
        idle_sleep();
    }
}
인터럽트 핸들러의 철칙 — ① 짧게. 수 마이크로초 안에 끝내고, 처리는 메인 루프로 넘깁니다. ② malloc·printf·블로킹 대기 금지. ③ 공유 변수는 volatile + 가능하면 한쪽만 쓰는 구조로 설계합니다. ④ 부득이하게 여러 변수를 함께 고쳐야 하면 인터럽트를 잠깐 끄고(critical section) 최소한만 감쌉니다.

운영체제가 없는 빌드 — freestanding 과 링커 스크립트 🔴 고급

-ffreestanding 이 무엇을 바꾸나

# 호스티드(hosted) — 우리가 지금까지 쓴 환경
#   main 이 시작점 · 표준 라이브러리 전부 사용 가능
gcc main.c -o app

# 프리스탠딩(freestanding) — OS 도 libc 도 없음
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb \
  -ffreestanding -nostdlib -nostartfiles \
  -T linker.ld -Wl,-Map=out.map \
  startup.c main.c -o firmware.elf

arm-none-eabi-objcopy -O binary firmware.elf firmware.bin
arm-none-eabi-size firmware.elf     # ROM/RAM 사용량 확인
프리스탠딩에서 쓸 수 있는 표준 헤더는 이것뿐입니다:
  <stddef.h> <stdint.h> <stdbool.h> <limits.h>
  <float.h> <stdarg.h> <iso646.h> <stdalign.h> <stdnoreturn.h>

없는 것: printf, malloc, 파일, 문자열 함수 일부
  → printf 는 UART 로 내보내는 미니 버전을 직접 만들거나
    nanoprintf 같은 단일 헤더 구현을 넣습니다
실제로는 벤더 SDK 를 씁니다 — STM32 는 STM32Cube(HAL/LL), ESP32 는 ESP-IDF, 노르딕은 nRF Connect SDK 가 시작 코드·링커 스크립트·드라이버를 전부 제공합니다. 위 명령을 손으로 칠 일은 드물지만, 빌드가 깨졌을 때 읽을 수 있어야 합니다.

링커 스크립트 맛보기

/* linker.ld — 메모리 어디에 무엇을 놓을지 정합니다 */
MEMORY {
  FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
  RAM  (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

SECTIONS {
  .text : {           /* 코드와 상수 — 플래시에 */
    KEEP(*(.isr_vector))   /* ★ 인터럽트 벡터는 맨 앞에 고정 */
    *(.text*)
    *(.rodata*)
  } > FLASH

  .data : {           /* 초기값 있는 전역 — RAM 에서 실행, 플래시에 원본 */
    _sdata = .;
    *(.data*)
    _edata = .;
  } > RAM AT > FLASH
  _sidata = LOADADDR(.data);   /* 플래시에 있는 .data 원본의 주소 — startup 이 씀 */

  .bss : {            /* 0으로 시작하는 전역 — RAM 만 차지 */
    _sbss = .;
    *(.bss*) *(COMMON)
    _ebss = .;
  } > RAM
}
/* startup.c — main 보다 먼저 도는 코드 */
extern uint32_t _sdata, _edata, _sbss, _ebss, _sidata;

void Reset_Handler(void) {
    // ① 초기값 있는 전역을 플래시에서 RAM 으로 복사
    uint32_t *src = &_sidata, *dst = &_sdata;
    while (dst < &_edata) *dst++ = *src++;

    // ② .bss 를 0 으로 (C 표준이 약속한 것)
    for (dst = &_sbss; dst < &_ebss; dst++) *dst = 0;

    main();                 // ★ 이제야 C 코드가 정상 동작합니다
    for (;;) { }
}
“전역 변수는 자동으로 0”이 공짜가 아니었습니다 — 위 .bss 초기화 루프가 그 약속을 지키는 코드입니다. PC 에서는 운영체제와 C 런타임이 대신 해 주던 일이라 보이지 않았을 뿐입니다.

어디서 시작하나 — 보드 고르기

보드가격대 · 특징이럴 때
Arduino Uno R4가장 쉬움. IDE 한 번 설치로 끝처음이라면 여기. C++ 처럼 보이지만 사실상 C
Raspberry Pi Pico 2싸고 강력(RP2350). C SDK 공식 지원C 로 제대로 배우기 가장 좋은 균형
ESP32-S3Wi-Fi·BLE 내장. ESP-IDF(C 기반)네트워크가 필요한 장치
STM32 Nucleo산업 표준. HAL·CubeMX취업·산업용을 노린다면
Raspberry Pi 5리눅스가 도는 컴퓨터임베디드가 아니라 작은 서버에 가깝습니다
첫 프로젝트 추천 순서 — ① LED 깜빡이기(GPIO) → ② UART 로 PC 와 대화(위 링 버퍼가 그대로 쓰입니다) → ③ 버튼 인터럽트 + 디바운싱 → ④ 타이머로 PWM → ⑤ I2C 센서 읽기. 여기까지 오면 데이터시트를 읽고 레지스터를 직접 설정할 수 있게 됩니다 — 그게 임베디드 C 의 전부라고 해도 과언이 아닙니다.
정리 — 임베디드 C 는 지금까지 배운 것에서 빼는 쪽입니다 — malloc 대신 고정 크기 링 버퍼, 예외 대신 반환값, 스레드 대신 인터럽트 + 메인 루프. volatile 은 여기서 진짜 역할을 하고, 비트 마스크와 시프트가 문법처럼 쓰입니다. 시작은 Raspberry Pi Pico 2 나 Arduino 로 LED 하나 깜빡이는 것부터면 충분합니다.
C 23

라이브러리 만들기 — 남이 쓸 수 있는 형태로 내보내기

C 가 지금도 중심에 있는 진짜 이유는 C ABI 가 모든 언어의 공통 언어이기 때문입니다. 파이썬·자바·C#·Go·러스트·자바스크립트가 전부 C 로 만든 .so·.dll 을 부를 수 있습니다. 이 절에서는 정적·동적 라이브러리를 빌드하고, 심볼을 골라 내보내고, 헤더를 설계하고, 다른 언어에서 한 줄로 불러 보는 것까지 한 번에 해 봅니다.

정적 링크와 동적 링크 — 라이브러리 코드가 언제 합쳐지는가 정적 — libmath.a / math.lib 동적 — libmath.so / math.dll 빌드: main.o + libmath.a 링커가 필요한 오브젝트를 골라 꺼내 옵니다 빌드: main.o + libmath.so 코드는 안 넣고 이름과 심볼만 적어 둡니다 실행 파일 안에 코드가 들어 있음 파일이 커지지만 자기 완결적입니다 실행 파일에는 참조만 남음 파일이 작지만 혼자서는 못 돕니다 실행: 찾을 것이 없습니다 복사해서 어디에 두든 그대로 돕니다 실행: 로더가 파일을 찾아 붙입니다 못 찾으면 시작조차 못 하고 죽습니다 고르는 기준은 결국 배포 방식입니다 배포가 단순해야 한다 · 버전 충돌이 싫다 · 컨테이너 이미지를 작게 → 정적 여러 프로그램이 공유한다 · 보안 패치를 따로 내려야 한다 · 플러그인 구조 → 동적
파이썬·자바가 부르는 것은 거의 항상 동적 라이브러리입니다 — 런타임에 붙일 수 있어야 하니까요.

만들어 봅니다 — 같은 소스로 .a 와 .so 를 둘 다

// mathlib.h  — 공개 헤더. 이 파일이 곧 API 문서입니다
#ifndef MATHLIB_H
#define MATHLIB_H

#include <stddef.h>

// ── 내보내기 매크로 (윈도우 때문에 필요합니다) ──
#if defined(_WIN32)
  #if defined(MATHLIB_BUILD)
    #define ML_API __declspec(dllexport)
  #else
    #define ML_API __declspec(dllimport)
  #endif
#else
  #define ML_API __attribute__((visibility("default")))
#endif

#ifdef __cplusplus
extern "C" {          // ★ C++ 에서 include 해도 되게
#endif

ML_API int    ml_add(int a, int b);
ML_API double ml_mean(const double *xs, size_t n);
ML_API const char *ml_version(void);

#ifdef __cplusplus
}
#endif
#endif  // MATHLIB_H
// mathlib.c
#define MATHLIB_BUILD      // ★ 라이브러리를 '만드는' 쪽임을 표시
#include "mathlib.h"

// 내부 전용 함수는 static — 밖에서 안 보입니다
static double sum(const double *xs, size_t n) {
    double s = 0;
    for (size_t i = 0; i < n; i++) s += xs[i];
    return s;
}

int ml_add(int a, int b) { return a + b; }

double ml_mean(const double *xs, size_t n) {
    return n ? sum(xs, n) / (double)n : 0.0;
}

const char *ml_version(void) { return "1.2.0"; }
# ── 리눅스/맥 ──
# 정적 라이브러리
gcc -std=c17 -O2 -c mathlib.c -o mathlib.o
ar rcs libmathlib.a mathlib.o

# 동적 라이브러리 (-fPIC 필수!)
gcc -std=c17 -O2 -fPIC -fvisibility=hidden \
    -shared mathlib.c -o libmathlib.so.1.2.0
ln -sf libmathlib.so.1.2.0 libmathlib.so.1
ln -sf libmathlib.so.1     libmathlib.so

# 쓰는 쪽
gcc main.c -L. -lmathlib -o app
LD_LIBRARY_PATH=. ./app          # 실행할 때 찾을 경로
# ── 윈도우 (MinGW/gcc) ──
gcc -O2 -DMATHLIB_BUILD -shared mathlib.c -o mathlib.dll -Wl,--out-implib,mathlib.lib
gcc main.c -L. -lmathlib -o app.exe        # app.exe 옆에 mathlib.dll 이 있어야 실행됩니다

# ── 윈도우 (MSVC) ──
cl /O2 /DMATHLIB_BUILD /LD mathlib.c        # → mathlib.dll + mathlib.lib
cl main.c mathlib.lib

# ── 무엇이 실제로 내보내졌는지 확인 ──
nm -D --defined-only libmathlib.so   # 리눅스: 공개 심볼 목록
objdump -p app | grep NEEDED         # 이 실행 파일이 요구하는 .so 들
ldd app                              # 실제로 어떤 파일에 연결됐는지 (맥: otool -L)
dumpbin /EXPORTS mathlib.dll         # 윈도우
-fPIC 을 빼면 x86-64 리눅스에서 relocation R_X86_64_32S against ... can not be used when making a shared object 라는 링크 에러가 납니다. 동적 라이브러리는 메모리 어디에 올라갈지 모르므로 위치 독립 코드여야 하기 때문입니다. 정적 라이브러리에 넣을 오브젝트도 나중에 .so 에 들어갈 수 있으니 처음부터 -fPIC 을 켜 두는 편이 좋습니다.

심볼 가시성 — 내보낼 것만 내보내기 🔴 고급

왜 숨겨야 하나

숨기면얻는 것
로딩 속도심볼 테이블이 작아져 동적 링크가 빨라집니다
이름 충돌다른 라이브러리의 같은 이름과 안 부딪힙니다 ★
최적화내부 함수를 인라인·제거할 수 있게 됩니다
API 안정성사용자가 내부 함수에 의존하는 것을 원천 차단
바이너리 크기안 쓰는 코드를 링커가 떼어낼 수 있습니다
# 기본값을 '숨김'으로 바꾸고 필요한 것만 default 로 표시
gcc -fvisibility=hidden -fPIC -shared ...

# 리눅스에서 버전 스크립트로 더 정밀하게
cat > export.map <<'EOF'
MATHLIB_1.0 {
  global:
    ml_add; ml_mean; ml_version;
  local:
    *;                # 나머지는 전부 숨김
};
EOF
gcc -shared -fPIC mathlib.c -Wl,--version-script=export.map -o libmathlib.so
이름 충돌이 실제로 얼마나 흔한가 — 파이썬 확장 모듈 두 개가 각각 정적으로 링크한 zlib 를 갖고 있을 때, 심볼이 공개되어 있으면 먼저 로드된 쪽이 이깁니다. 버전이 다르면 그대로 크래시입니다. 접두사(ml_)와 hidden 가시성 두 가지가 이걸 막습니다.

헤더 설계 · 버전 관리 규칙

// 좋은 공개 헤더의 조건
// ① 자기 완결적 — 이것만 include 해도 컴파일돼야 합니다
// ② 다른 헤더를 최소한으로 include (전방 선언 활용)
// ③ 매크로 이름에 접두사 (MATHLIB_ 처럼)
// ④ 구조체는 감춥니다 — 불투명 포인터 ★

typedef struct ml_ctx ml_ctx;      // 정의는 .c 안에만 있습니다

ML_API ml_ctx *ml_ctx_new(size_t cap);
ML_API void    ml_ctx_free(ml_ctx *c);
ML_API int     ml_ctx_push(ml_ctx *c, double v);

// 이렇게 하면 구조체 필드를 바꿔도 사용자를 다시 빌드시키지 않습니다.
// 반대로 헤더에 struct 정의를 노출하면 필드 하나만 추가해도
// 크기가 바뀌어 ABI 가 깨집니다 (사용자 프로그램이 그대로 죽습니다)

// 버전 정보를 컴파일 시점에도 노출
#define MATHLIB_VERSION_MAJOR 1
#define MATHLIB_VERSION_MINOR 2
#define MATHLIB_VERSION_PATCH 0
바꾸는 것버전을 어떻게
함수 추가minor 증가 — 기존 사용자 영향 없음
버그 수정patch 증가
구조체 크기 변경major 증가 — ABI 깨짐
함수 시그니처 변경major 증가
함수 제거major 증가
SONAME 을 붙이세요 — -Wl,-soname,libmathlib.so.1 을 주면 실행 파일에 major 버전까지만 기록됩니다. 그래야 1.2.0 → 1.3.0 업데이트가 재빌드 없이 반영됩니다. 이게 리눅스 배포판이 보안 패치를 뿌리는 방식입니다.

다른 언어에서 부르기 — 각 한 줄씩 🟢 기초

파이썬 · C#

# 파이썬 — 표준 라이브러리 ctypes 만으로 됩니다
import ctypes, platform

name = "mathlib.dll" if platform.system() == "Windows" else "./libmathlib.so"
lib = ctypes.CDLL(name)

lib.ml_add.argtypes = [ctypes.c_int, ctypes.c_int]   # ★ 반드시 명시
lib.ml_add.restype  = ctypes.c_int
print(lib.ml_add(2, 3))            # 5

lib.ml_mean.argtypes = [ctypes.POINTER(ctypes.c_double), ctypes.c_size_t]
lib.ml_mean.restype  = ctypes.c_double
arr = (ctypes.c_double * 4)(1.0, 2.0, 3.0, 4.0)
print(lib.ml_mean(arr, 4))         # 2.5

lib.ml_version.restype = ctypes.c_char_p
print(lib.ml_version().decode())   # 1.2.0
// C# — P/Invoke
using System.Runtime.InteropServices;

internal static partial class Native {
    [LibraryImport("mathlib")]            // .NET 7+ 소스 생성 방식
    internal static partial int ml_add(int a, int b);
}
Console.WriteLine(Native.ml_add(2, 3));

자바 (FFM) · Node · 러스트

// 자바 21+ — Foreign Function & Memory API (JNI 없이)
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;

try (Arena arena = Arena.ofConfined()) {
    SymbolLookup lib = SymbolLookup.libraryLookup("mathlib", arena);
    MethodHandle add = Linker.nativeLinker().downcallHandle(
        lib.find("ml_add").orElseThrow(),
        FunctionDescriptor.of(ValueLayout.JAVA_INT,
                              ValueLayout.JAVA_INT, ValueLayout.JAVA_INT));
    System.out.println((int) add.invokeExact(2, 3));   // 5
}
// Node 24 — koffi (네이티브 빌드 없이 로드)
const koffi = require('koffi');
const lib = koffi.load('./libmathlib.so');
const ml_add = lib.func('int ml_add(int, int)');
console.log(ml_add(2, 3));         // 5
// 러스트 — extern "C" 선언만 하면 됩니다
#[link(name = "mathlib")]
extern "C" { fn ml_add(a: i32, b: i32) -> i32; }

fn main() { unsafe { println!("{}", ml_add(2, 3)); } }
여기서 규칙 하나 — 언어 경계로 나가는 함수는 구조체를 값으로 주고받지 말고, int·포인터·길이로만 이야기하세요. 구조체 레이아웃·패딩·호출 규약은 언어마다 미묘하게 다릅니다. 단순한 시그니처가 곧 이식성입니다.
정리 — 라이브러리 만들기의 핵심은 네 가지입니다. -fPIC + -shared 로 빌드하고, -fvisibility=hidden 과 내보내기 매크로로 공개 심볼을 고르고, 불투명 포인터로 구조체를 감추고, SONAME 과 semver 로 버전을 관리합니다. 이렇게 만든 .so·.dll 하나면 파이썬·자바·C#·Node·러스트가 전부 부를 수 있습니다.
C 24

테스트와 디버깅 — 기계가 대신 찾게 만듭니다

C 의 버그는 증상이 원인에서 멀리 떨어져 나타납니다 — 100줄 앞에서 배열을 하나 넘어 썼는데 전혀 다른 함수에서 죽죠. 그래서 C 에서는 “눈으로 코드를 본다”가 가장 비효율적인 방법입니다. 새니타이저·valgrind·gdb 를 증상별로 어떤 걸 꺼내야 하는지 정리하고, 테스트 프레임워크를 붙여 CI 에서 자동으로 돌게 만드는 데까지 갑니다.

증상별로 어떤 도구를 꺼내는가 세그폴트로 즉시 죽는다 어디서 죽었는지부터 알아야 합니다 gdb ./app core → bt 스택을 거슬러 올라가면 대개 한 번에 나옵니다 가끔 값이 이상하다 잘 돌다가 어쩌다 틀립니다 -fsanitize=address 로 재빌드 넘침·해제 후 사용을 그 자리에서 잡아냅니다 메모리가 계속 늘어난다 오래 돌리면 결국 죽습니다 valgrind --leak-check=full 누가 할당하고 안 풀었는지 스택으로 보여 줍니다 멀티스레드에서만 깨진다 디버거를 붙이면 재현이 안 됩니다 -fsanitize=thread 실제로 충돌하지 않아도 레이스를 찾아냅니다 무엇을 하든 첫 단계는 -g -O0 -Wall -Wextra 로 다시 빌드하는 것입니다 — 최적화가 켜져 있으면 스택도 변수도 제대로 안 보입니다 재현이 안 되는 버그일수록 새니타이저가 답입니다. 디버거는 "재현되는 버그"의 도구입니다
C 디버깅의 8할은 도구를 고르는 일입니다 — 증상에 맞는 도구를 켜면 대부분 몇 분 안에 끝납니다.

새니타이저 — 가장 값싸고 강력한 도구

# ① 주소 새니타이저 — 메모리 오류 종합 검출 (2배 느림)
gcc -std=c17 -g -O1 -fsanitize=address,undefined \
    -fno-omit-frame-pointer app.c -o app
./app

# 잡아 주는 것:
#   heap-buffer-overflow    배열 밖 읽기/쓰기
#   stack-buffer-overflow   지역 배열 넘침
#   heap-use-after-free     free 뒤 접근
#   double-free             두 번 해제
#   memory leak             종료 시 안 푼 메모리 (LeakSanitizer 포함)

# ② UB 새니타이저 — 미정의 동작 검출
gcc -fsanitize=undefined -fno-sanitize-recover=all app.c -o app
#   부호 있는 정수 오버플로 · 널 역참조 · 정렬 위반 · 시프트 범위 초과

# ③ 옵션으로 더 엄격하게
ASAN_OPTIONS=detect_leaks=1:abort_on_error=1 ./app
UBSAN_OPTIONS=print_stacktrace=1 ./app
=================================================================
==31337==ERROR: AddressSanitizer: heap-buffer-overflow on address
0x602000000018 at pc 0x0001 bp 0x7ffd sp 0x7ffd
WRITE of size 4 at 0x602000000018 thread T0
    #0 0x1149 in main app.c:7          ← 여기서 넘어 썼습니다

0x602000000018 is located 0 bytes to the right of 8-byte region
allocated by thread T0 here:
    #0 0x7f4a in malloc
    #1 0x1122 in main app.c:5          ← 여기서 할당했습니다
이 두 줄이 전부입니다 — “어디서 넘어 썼는지”와 “그 메모리를 어디서 할당했는지”. printf 를 100개 박는 것보다 비교할 수 없이 빠릅니다. 개발·테스트 빌드에는 기본으로 켜 두는 것을 권합니다.

gdb — 외워야 할 명령은 열 개뿐

명령하는 일
run / r실행 (인자는 run a b)
bt백트레이스 — 가장 많이 씁니다
frame N / f NN번 스택 프레임으로 이동
break f.c:42중단점. break foo if x==3 도 됨
n / s / c다음 줄 / 함수 안으로 / 계속
p expr값 출력. p *arr@10 은 배열 10개
watch xx 가 바뀌는 순간 멈춤 — 값 오염 추적에 최고
info locals지역 변수 전부
thread apply all bt모든 스레드 스택 (데드락 분석)
x/16xb p메모리를 바이트 16개로 덤프
# 코어 덤프 켜기 (평소엔 꺼져 있습니다)
ulimit -c unlimited
cat /proc/sys/kernel/core_pattern      # 어디에 떨어지는지 확인
./app                                  # 죽으면 core 파일 생성

gdb ./app core
(gdb) bt full                          # 스택 + 각 프레임의 지역 변수
(gdb) f 2                              # 내 코드가 있는 프레임으로
(gdb) p *node                          # 구조체 통째로 출력
(gdb) p node->next->value

# 죽은 이유가 바로 보이는 경우가 많습니다:
#   $1 = (Node *) 0x0        → 널 포인터였습니다
#   value = <optimized out>  → -O0 로 다시 빌드하세요
맥이라면 lldb 를 씁니다 — 명령이 거의 같습니다(bt, frame select, p, br s -f a.c -l 42). VS Code·CLion 의 디버거도 결국 이 둘 위에 얹힌 UI 입니다.

테스트 붙이기 — C 에도 프레임워크가 있습니다 🟡 중급

Unity — 가장 가볍고 임베디드에서도 돕니다

// test_vec.c  (unity.c / unity.h 두 파일만 넣으면 끝)
#include "unity.h"
#include "vec.h"

void setUp(void)    { }      // 각 테스트 전
void tearDown(void) { }      // 각 테스트 후

void test_push_increases_len(void) {
    Vec v; vec_init(&v);
    TEST_ASSERT_EQUAL_INT(0, vec_push(&v, 42));
    TEST_ASSERT_EQUAL_size_t(1, v.len);
    TEST_ASSERT_EQUAL_INT(42, vec_get(&v, 0));
    vec_free(&v);
}

void test_grows_past_initial_cap(void) {
    Vec v; vec_init(&v);
    for (int i = 0; i < 1000; i++)
        TEST_ASSERT_EQUAL_INT(0, vec_push(&v, i));
    TEST_ASSERT_EQUAL_INT(999, vec_get(&v, 999));
    TEST_ASSERT_TRUE(v.cap >= 1000);
    vec_free(&v);
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_push_increases_len);
    RUN_TEST(test_grows_past_initial_cap);
    return UNITY_END();      // ★ 실패 개수가 종료 코드 → CI 가 이걸 봅니다
}
gcc -std=c17 -g -fsanitize=address \
    test_vec.c vec.c unity.c -o test_vec
./test_vec
# test_vec.c:27:test_push_increases_len:PASS
# test_vec.c:28:test_grows_past_initial_cap:PASS
# 2 Tests 0 Failures 0 Ignored  OK

고르기 · CTest 로 묶기

프레임워크특징
Unity파일 3개. 임베디드 표준. 의존성 0
cmocka모킹 지원(함수 가로채기). 시스템 콜 테스트에 강함
Criterion테스트마다 별도 프로세스 — 죽어도 나머지가 돕니다
greatest / utest.h헤더 한 장. 가장 간편
CTest프레임워크가 아니라 러너. CMake 에 내장
# CMakeLists.txt — 테스트를 CTest 에 등록
enable_testing()

add_executable(test_vec test_vec.c vec.c unity.c)
target_link_libraries(test_vec PRIVATE mathlib)
add_test(NAME vec COMMAND test_vec)

# 새니타이저를 켠 테스트 빌드를 따로 두는 것이 정석입니다
add_executable(test_vec_asan test_vec.c vec.c unity.c)
target_compile_options(test_vec_asan PRIVATE -fsanitize=address,undefined -g)
target_link_options(test_vec_asan    PRIVATE -fsanitize=address,undefined)
add_test(NAME vec_asan COMMAND test_vec_asan)
cmake -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j
ctest --test-dir build --output-on-failure

# valgrind 로 전체 테스트를 한 번에 돌리기
ctest --test-dir build -T memcheck
C 에서 테스트가 특히 값어치 있는 이유 — 리팩터링할 때 컴파일러가 잡아 주는 범위가 다른 언어보다 훨씬 좁기 때문입니다. 타입이 약하고 포인터가 자유로우니 테스트가 유일한 안전망입니다. 새니타이저를 켠 테스트 하나가 코드 리뷰 열 번보다 많이 잡아냅니다.

최적화 빌드와 디버그 빌드의 차이 🔴 고급

-O0 과 -O2 는 다른 프로그램입니다

플래그쓰는 곳주의
-O0 -g디버깅느림. 하지만 변수가 다 보입니다
-O1 -g새니타이저속도와 가시성의 균형 — 권장
-O2배포 기본인라인·루프 변형으로 스택이 뭉갬
-O3수치 계산항상 빠르진 않습니다. 재 보고 결정
-Og디버깅용 최적화GCC 전용. -O0 보다 빠르고 볼 만함
-O2 -g배포 + 심볼★ 성능은 그대로, 크래시 분석 가능
# 배포 바이너리에서 심볼만 따로 떼어 보관하는 정석
gcc -O2 -g app.c -o app
objcopy --only-keep-debug app app.debug     # 심볼 파일 분리
objcopy --strip-debug app                   # 배포용은 가볍게
objcopy --add-gnu-debuglink=app.debug app   # 연결 정보만 남김

# 나중에 코어 덤프가 오면
gdb -s app.debug -e app core
“-O0 에선 되는데 -O2 에선 깨져요” — 컴파일러 버그가 아니라 거의 항상 미정의 동작(UB)입니다. 부호 있는 오버플로, 초기화 안 한 변수, 엄격한 앨리어싱 위반 등이 최적화가 켜지면 다르게 해석되기 때문입니다. 이때야말로 -fsanitize=undefined 를 켤 자리입니다(c11 참고).

CI 에 넣을 최소 구성

# .github/workflows/ci.yml — 이 정도면 충분합니다
name: ci
on: [push, pull_request]

jobs:
  test:
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - name: 빌드
        run: |
          cmake -B build -DCMAKE_BUILD_TYPE=Debug
          cmake --build build -j
      - name: 테스트
        run: ctest --test-dir build --output-on-failure

  sanitize:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: ASan + UBSan
        run: |
          cmake -B build -DCMAKE_C_FLAGS="-fsanitize=address,undefined -g -O1"
          cmake --build build -j
          ctest --test-dir build --output-on-failure
세 OS 를 돌리는 것이 핵심입니다 — C 코드의 이식성 버그는 다른 컴파일러로 빌드해 보는 순간 절반이 드러납니다. long 크기, 구조체 패딩, 경로 구분자, 대소문자 구분 파일 시스템 — 전부 CI 가 대신 잡아 줍니다.
정리 — C 디버깅은 도구를 고르는 일입니다. 죽으면 gdb + bt, 이상하면 ASan, 메모리가 늘면 valgrind, 스레드면 TSan. 그리고 무엇을 하든 먼저 -g -O1 로 다시 빌드합니다. 테스트는 Unity 파일 세 개면 시작할 수 있고, 새니타이저를 켠 CI 잡 하나가 코드 리뷰 열 번의 값어치를 합니다.
C 25

실전 프로젝트 구조 · 빌드 — Makefile 과 CMake 완전판

파일이 세 개를 넘어가는 순간 gcc *.c 로는 감당이 안 됩니다. 무엇이 무엇에 의존하는지를 적어 두면 도구가 바뀐 것만 다시 빌드해 줍니다. 이 절은 복사해서 바로 쓰는 Makefile 완전판과 CMake 표준 구성, 그리고 디렉터리 배치 · .clang-format · GitHub Actions까지 “새 C 프로젝트를 시작하는 한 세트”를 통째로 만듭니다.

make 가 "무엇을 다시 빌드할지" 정하는 방법 — 의존 그래프와 시각 main.c vec.c util.c vec.h main.o vec.o util.o app — 링크 결과 실행 파일 오브젝트 중 하나라도 새로우면 다시 링크합니다 vec.h 를 고치면 main.o 와 vec.o 가 둘 다 낡은 것이 되어 다시 컴파일됩니다 util.c 만 고치면 util.o 하나만 다시 만들고 링크만 다시 합니다 — 그래서 큰 프로젝트도 빌드가 몇 초입니다
빌드 도구가 하는 일은 하나입니다 — 이 그래프를 따라가며 낡은 것만 다시 만드는 것.

Makefile 완전판 — 새 프로젝트에 그대로 복사

# ── 설정 ─────────────────────────────────────────────
TARGET   := app
SRCDIR   := src
INCDIR   := include
BUILDDIR := build

CC       := gcc
CSTD     := -std=c17
WARN     := -Wall -Wextra -Wpedantic -Wshadow -Wconversion \
            -Wstrict-prototypes -Wmissing-prototypes
CPPFLAGS := -I$(INCDIR) -MMD -MP        # ★ -MMD -MP 가 의존성 자동 생성
LDFLAGS  :=
LDLIBS   := -lm

# make DEBUG=1 로 디버그 빌드
ifdef DEBUG
  OPT := -O0 -g -fsanitize=address,undefined
  LDFLAGS += -fsanitize=address,undefined
else
  OPT := -O2 -DNDEBUG
endif
CFLAGS := $(CSTD) $(WARN) $(OPT)

# ── 파일 목록 (자동 수집) ────────────────────────────
SRCS := $(wildcard $(SRCDIR)/*.c)
OBJS := $(patsubst $(SRCDIR)/%.c,$(BUILDDIR)/%.o,$(SRCS))
DEPS := $(OBJS:.o=.d)

# ── 규칙 ─────────────────────────────────────────────
.PHONY: all clean run test format
all: $(TARGET)

$(TARGET): $(OBJS)
	$(CC) $(LDFLAGS) $^ $(LDLIBS) -o $@

$(BUILDDIR)/%.o: $(SRCDIR)/%.c | $(BUILDDIR)
	$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@

$(BUILDDIR):
	mkdir -p $@

run: $(TARGET)
	./$(TARGET)

# ※ 'test: DEBUG=1' 은 소용없음 — ifdef 는 파일을 읽을 때 한 번만 평가됩니다
#   새니타이저로 돌리려면: make clean && make DEBUG=1 test
test: $(TARGET)
	./$(TARGET) --self-test

format:
	clang-format -i $(SRCDIR)/*.c $(INCDIR)/*.h

clean:
	rm -rf $(BUILDDIR) $(TARGET)

-include $(DEPS)      # ★ 헤더 의존성을 여기서 읽어 들입니다
Makefile 의 규칙 줄은 반드시 탭(Tab) 으로 들여씁니다 — 스페이스면 missing separator 에러가 납니다. 그리고 -MMD -MP 가 이 파일의 핵심입니다. 이게 없으면 헤더를 고쳐도 다시 빌드되지 않아, “왜 수정이 반영이 안 되지?”로 몇 시간을 날립니다. -MMD 는 .d 파일에 의존 목록을 적고, 마지막 줄의 -include 가 그걸 읽어 옵니다.

디렉터리 배치 — 사실상의 표준

myproj/
├── CMakeLists.txt
├── Makefile              # 간단한 경우 둘 중 하나만
├── README.md
├── .clang-format
├── .gitignore
├── include/
│   └── myproj/           # ★ 폴더를 한 겹 더 — #include "myproj/vec.h"
│       ├── vec.h         #   이름 충돌을 원천 차단합니다
│       └── str.h
├── src/
│   ├── main.c
│   ├── vec.c
│   ├── str.c
│   └── internal.h        # ★ 내부 전용 헤더는 src 안에
├── tests/
│   ├── test_vec.c
│   └── unity/            # 테스트 프레임워크
├── third_party/          # 외부 라이브러리 원본 (stb 등)
├── examples/
│   └── demo.c
└── build/                # ★ .gitignore 대상. 절대 커밋하지 않습니다
규칙이유
include/<프로젝트>/설치했을 때 다른 라이브러리와 안 섞입니다
공개 헤더만 include 에내부 헤더는 src/ 에 두어 API 를 좁게
함수 이름에 접두사vec_push 처럼 — C 에 네임스페이스가 없으니
빌드 산출물은 별도 폴더소스와 섞이면 clean 이 위험해집니다
third_party/ 분리남의 코드에는 우리 경고 설정을 적용하지 않습니다

.clang-format · .gitignore

# .clang-format — 팀 규칙을 파일 하나로 고정
BasedOnStyle: LLVM
Language: Cpp
IndentWidth: 4
TabWidth: 4
UseTab: Never
ColumnLimit: 100
PointerAlignment: Right          # char *p  (C 관습)
AlignConsecutiveAssignments: Consecutive
AllowShortFunctionsOnASingleLine: Inline
BreakBeforeBraces: Attach
SortIncludes: CaseSensitive
IncludeBlocks: Regroup
SpaceAfterCStyleCast: true
# .gitignore
build/
*.o
*.d
*.a
*.so
*.dll
*.exe
compile_commands.json
core
core.*
.cache/
.vscode/
.idea/
# 편집기 자동완성이 되게 — 이 파일 하나면 clangd 가 다 알아냅니다
cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
ln -sf build/compile_commands.json .

# Makefile 프로젝트라면 bear 로 생성합니다
bear -- make

# 저장할 때마다 포맷 (git 훅)
echo 'clang-format -i $(git diff --cached --name-only | grep -E "\.[ch]$")' \
  > .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
compile_commands.json 이 없으면 편집기가 #include 경로도 매크로도 몰라서 빨간 줄이 잔뜩 뜨는데 빌드는 잘 되는 상태가 됩니다. C 프로젝트를 열자마자 이 파일부터 만드는 것이 습관이 되면 좋습니다.

CMake — 규모가 커지면 이쪽 🟡 중급

C 프로젝트용 CMakeLists.txt 표준형

cmake_minimum_required(VERSION 3.20)
project(myproj VERSION 1.2.0 LANGUAGES C)

set(CMAKE_C_STANDARD 17)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_C_EXTENSIONS OFF)          # ★ -std=c17 (gnu17 아님)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

# 라이브러리 — 실행 파일과 테스트가 함께 씁니다
add_library(myproj_core
    src/vec.c
    src/str.c)

target_include_directories(myproj_core
    PUBLIC  ${CMAKE_CURRENT_SOURCE_DIR}/include   # 쓰는 쪽에도 전파
    PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)      # 내부 헤더는 우리만

target_compile_options(myproj_core PRIVATE
    $<$<C_COMPILER_ID:MSVC>:/W4>
    $<$<NOT:$<C_COMPILER_ID:MSVC>>:-Wall;-Wextra;-Wshadow>)

# 실행 파일
add_executable(app src/main.c)
target_link_libraries(app PRIVATE myproj_core)

# 테스트
enable_testing()
add_subdirectory(tests)

# 설치 규칙 — make install 로 시스템에 배포
install(TARGETS app myproj_core
        RUNTIME DESTINATION bin
        LIBRARY DESTINATION lib
        ARCHIVE DESTINATION lib)
install(DIRECTORY include/ DESTINATION include)

외부 라이브러리 가져오기 · 빌드 명령

# ① 시스템에 설치된 것 찾기
find_package(CURL REQUIRED)
target_link_libraries(app PRIVATE CURL::libcurl)

find_package(Threads REQUIRED)
target_link_libraries(app PRIVATE Threads::Threads)   # -pthread

# ② 소스에서 받아 함께 빌드 (의존성 관리가 가장 간편)
include(FetchContent)
FetchContent_Declare(cjson
    GIT_REPOSITORY https://github.com/DaveGamble/cJSON.git
    GIT_TAG        v1.7.18)
set(ENABLE_CJSON_TEST OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(cjson)
target_link_libraries(app PRIVATE cjson)

# ③ pkg-config 로 찾기 (리눅스에서 흔합니다)
find_package(PkgConfig REQUIRED)
pkg_check_modules(SQLITE REQUIRED IMPORTED_TARGET sqlite3)
target_link_libraries(app PRIVATE PkgConfig::SQLITE)
# 빌드 — 어느 OS 에서나 같은 명령
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
ctest --test-dir build --output-on-failure
cmake --install build --prefix /usr/local

# 디버그 + 새니타이저 빌드를 따로 두기
cmake -B build-asan -DCMAKE_BUILD_TYPE=Debug \
      -DCMAKE_C_FLAGS="-fsanitize=address,undefined"
cmake --build build-asan -j

# 캐시가 꼬였을 때 (자주 있습니다)
rm -rf build && cmake -B build ...
Make 와 CMake 중 무엇을 — 파일 20개 미만에 리눅스만 지원한다면 Makefile 로 충분합니다. 윈도우도 지원 · 외부 라이브러리 사용 · IDE 연동 · 테스트 러너 중 하나라도 필요하면 CMake 로 갑니다. 2026년 기준 CMake 4.x 가 사실상 C/C++ 의 표준 빌드 시스템입니다.

배포까지 — GitHub Actions 로 바이너리 굽기 🔴 고급

태그를 밀면 세 OS 바이너리가 릴리스에 붙는 워크플로

# .github/workflows/release.yml
name: release
on:
  push:
    tags: ['v*']

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: ubuntu-latest
            name: linux-x86_64
            flags: "-static"          # 정적 링크 → 어디서나 도는 파일 하나
          - os: macos-latest
            name: macos-arm64
            flags: ""
          - os: windows-latest
            name: windows-x86_64
            flags: ""
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4

      - name: 설정
        run: cmake -B build -DCMAKE_BUILD_TYPE=Release
                   -DCMAKE_EXE_LINKER_FLAGS="${{ matrix.flags }}"

      - name: 빌드
        run: cmake --build build --config Release -j

      - name: 테스트
        run: ctest --test-dir build -C Release --output-on-failure

      - name: 산출물 업로드
        uses: actions/upload-artifact@v4
        with:
          name: app-${{ matrix.name }}
          path: |
            build/app
            build/Release/app.exe

  release:
    needs: build
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/download-artifact@v4
      - uses: softprops/action-gh-release@v2
        with:
          files: app-*/**
리눅스에서 -static 이 강력한 이유 — glibc 버전이 다른 서버에 복사해도 그대로 돕니다. 다만 glibc 정적 링크는 getaddrinfo 같은 일부 기능에서 경고가 나므로, 네트워크를 쓰는 프로그램이라면 musl(musl-gcc) 이나 Alpine 컨테이너 빌드가 더 깔끔합니다. C 프로그램이 수십 KB 파일 하나로 배포되는 것은 여전히 다른 언어가 못 따라오는 장점입니다.
정리 — C 프로젝트의 시작 세트는 다섯 개입니다 — src/·include/<프로젝트>/ 배치, -MMD -MP 가 들어간 Makefile(또는 CMake), .clang-format, compile_commands.json, 세 OS 를 도는 CI. 여기까지 갖추면 파일이 100개로 늘어도 빌드는 몇 초에 끝나고, 협업자가 cmake -B build 한 줄로 시작할 수 있습니다.
C 26

C 생태계 — 지금 무엇을 가져다 쓰나

C 에는 pip 도 npm 도 없지만, 세상에서 가장 많이 실행되는 라이브러리들이 C 로 되어 있습니다. 직접 만들 것과 가져다 쓸 것을 구분하는 감각이 실력의 절반입니다. 이 절은 2026년 기준으로 실제로 많이 쓰이는 라이브러리 아홉 개를 용도별로 정리하고, 각각 최소 예제 한 조각씩과 함께 C 로 만들어진 유명 프로젝트들이 왜 C 를 골랐는지를 봅니다.

2026년 기준 — C 프로젝트에서 실제로 손이 가는 라이브러리 SQLite 파일 하나짜리 관계형 DB 소스 한 장으로 넣습니다 libcurl HTTP · FTP · TLS 클라이언트 사실상 모든 기기에 있습니다 cJSON · yyjson JSON 파싱과 생성 쉬운 쪽 · 빠른 쪽 zlib · zstd 압축과 해제 호환성 · 속도로 나뉩니다 libuv 비동기 이벤트 루프 Node.js 의 심장입니다 libevent 이벤트 기반 네트워크 memcached 가 씁니다 raylib 게임 · 그래픽 · 소리 배우기 가장 쉽습니다 SDL3 창 · 입력 · 오디오 · GPU 상용 게임의 이식 표준 mbedTLS · OpenSSL TLS 와 암호 직접 만들지 마세요 C 로 만들어진 것들 — 지금 이 순간에도 돌고 있습니다 리눅스 커널 · Redis · CPython · Git · Nginx · SQLite · FFmpeg · PostgreSQL · curl · OpenSSL
공통점이 하나 있습니다 — 수십 년을 살아남아야 하고, 모든 플랫폼에서 돌아야 하는 것들입니다.

SQLite — 소스 한 장으로 DB 를 넣습니다

// sqlite3.c(약 25만 줄 한 파일) + sqlite3.h 를 받아 함께 컴파일하면 끝입니다
#include "sqlite3.h"
#include <stdio.h>

int main(void) {
    sqlite3 *db;
    if (sqlite3_open("app.db", &db) != SQLITE_OK) return 1;

    sqlite3_exec(db,
        "CREATE TABLE IF NOT EXISTS log(id INTEGER PRIMARY KEY, msg TEXT)",
        NULL, NULL, NULL);

    // ★ 항상 준비된 문장(prepared statement) — SQL 주입을 막습니다
    sqlite3_stmt *st;
    sqlite3_prepare_v2(db, "INSERT INTO log(msg) VALUES(?)", -1, &st, NULL);
    sqlite3_bind_text(st, 1, "첫 기록", -1, SQLITE_STATIC);
    sqlite3_step(st);
    sqlite3_finalize(st);

    sqlite3_prepare_v2(db, "SELECT id, msg FROM log", -1, &st, NULL);
    while (sqlite3_step(st) == SQLITE_ROW)
        printf("%d: %s\n", sqlite3_column_int(st, 0),
                           sqlite3_column_text(st, 1));
    sqlite3_finalize(st);
    sqlite3_close(db);
}
// gcc main.c sqlite3.c -o app -lpthread -ldl
세상에서 가장 많이 배포된 데이터베이스입니다 — 안드로이드·iOS·크롬·파이어폭스·항공기 시스템까지 전부 들어 있습니다. 테스트 코드가 본체보다 600배 많은 것으로도 유명합니다. “설정도 서버도 없는 DB”가 필요하면 고민할 것 없이 이것입니다.

libcurl + cJSON — API 를 부르고 파싱하기

#include <curl/curl.h>
#include <cjson/cJSON.h>
#include <stdlib.h>
#include <string.h>

typedef struct { char *p; size_t n; } Buf;

static size_t on_data(void *src, size_t sz, size_t nm, void *ud) {
    size_t total = sz * nm;
    Buf *b = ud;
    char *q = realloc(b->p, b->n + total + 1);
    if (!q) return 0;                       // 0 을 반환하면 전송이 중단됩니다
    b->p = q;
    memcpy(b->p + b->n, src, total);
    b->n += total;
    b->p[b->n] = '\0';
    return total;
}

int main(void) {
    curl_global_init(CURL_GLOBAL_DEFAULT);
    CURL *c = curl_easy_init();
    Buf buf = {0};

    curl_easy_setopt(c, CURLOPT_URL, "https://api.example.com/items");
    curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, on_data);
    curl_easy_setopt(c, CURLOPT_WRITEDATA, &buf);
    curl_easy_setopt(c, CURLOPT_TIMEOUT, 10L);          // ★ 타임아웃 필수
    curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);

    if (curl_easy_perform(c) == CURLE_OK) {
        cJSON *root = cJSON_Parse(buf.p);
        cJSON *name = cJSON_GetObjectItemCaseSensitive(root, "name");
        if (cJSON_IsString(name)) printf("이름: %s\n", name->valuestring);
        cJSON_Delete(root);                             // ★ 해제는 내 책임
    }
    free(buf.p);
    curl_easy_cleanup(c);
    curl_global_cleanup();
}
// gcc main.c -o app -lcurl -lcjson

용도별 선택표 — 무엇을 언제 🟢 기초

2026년 기준 실무 선택

하고 싶은 일라이브러리메모
데이터 저장SQLite파일 하나. 서버형이 필요하면 libpq(PostgreSQL)
HTTP 요청libcurlTLS·프록시·재시도까지 전부. 대안 없음
JSONcJSON / yyjson쉬운 쪽 / 빠른 쪽(수 GB/s). jansson 도 좋음
압축zlib / zstd호환성 / 속도·압축률. miniz 는 헤더 한 장
비동기 서버libuv / libeventepoll·kqueue·IOCP 를 하나로 덮어 줍니다
게임 · 그래픽raylib / SDL3배우기 쉬움 / 상용 수준 이식성
TLS · 암호mbedTLS / OpenSSL임베디드 · 소형 / 기능 완비. 직접 구현 금지
GUIGTK4 / Nuklear본격적 / 헤더 한 장짜리 즉시 모드
자료구조stb_ds / klib동적 배열·해시맵(c14·c15 참고)
테스트Unity / cmocka가벼움 / 모킹 지원(c24 참고)
로깅log.c / zlog한 파일 / 설정 파일 기반
명령행 인자argtable3 / 직접getopt_long 이면 대개 충분합니다
# 의존성을 관리하는 방법 — C 에는 표준이 없어서 셋 중 하나를 고릅니다
# ① 시스템 패키지 (가장 단순, 리눅스 전용에 적합)
sudo apt install libcurl4-openssl-dev libsqlite3-dev zlib1g-dev

# ② vcpkg — 윈도우 포함 크로스 플랫폼 (2026 기준 가장 무난)
vcpkg install curl sqlite3 zstd
cmake -B build -DCMAKE_TOOLCHAIN_FILE=$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake

# ③ CMake FetchContent — 소스째 받아 함께 빌드 (재현성 최고)
#   c25 의 예시 참고

# ④ 그냥 소스를 복사 (헤더 한 장짜리 라이브러리)
#   stb_ds.h · miniz.h · nuklear.h · log.c — third_party/ 에 넣고 커밋
라이브러리를 고를 때 확인할 세 가지 — ① 라이선스(MIT·BSD·zlib 는 자유롭고, GPL 은 내 코드까지 공개해야 할 수 있습니다). ② 최근 커밋(보안 패치가 도는지). ③ C 표준만 쓰는지(POSIX 전용이면 윈도우에서 못 씁니다). C 라이브러리는 한 번 넣으면 10년을 같이 갑니다.

C 로 만든 유명 프로젝트 — 왜 C 였나 🟡 중급

읽어 볼 만한 코드베이스

프로젝트규모C 를 고른 이유
Linux 커널3천만 줄+런타임 없이 하드웨어 직접 제어. 유일한 선택지였습니다
Redis약 15만 줄가장 읽기 좋은 C 코드로 유명. 자료구조 교재급
SQLite약 15만 줄어디서나 컴파일돼야 함. 의존성 0
CPython약 60만 줄모든 OS 에서 빌드 + C 확장 API 를 제공해야 함
Git약 30만 줄속도와 이식성. 초기 리눅스 개발자 문화
Nginx약 20만 줄이벤트 기반 · 메모리 풀 설계로 극한 성능
FFmpeg약 100만 줄SIMD 어셈블리와 섞어 쓰기 좋음
curl약 15만 줄세상 모든 장치에 이식돼야 함
공부용으로 하나만 고르라면 Redis 입니다 — sds.c(동적 문자열), dict.c(해시 테이블), adlist.c(연결 리스트), ae.c(이벤트 루프)가 이 탭에서 만든 것들의 실전판입니다. 파일 하나가 500~1500줄이라 읽기에도 부담이 없습니다.

2026년의 C — 어디로 가고 있나

// raylib — "10줄로 창 띄우기". 배우기 가장 즐거운 C 라이브러리
#include "raylib.h"

int main(void) {
    InitWindow(800, 450, "안녕 raylib");
    SetTargetFPS(60);
    while (!WindowShouldClose()) {
        BeginDrawing();
            ClearBackground(RAYWHITE);
            DrawText("C 로 만든 창입니다", 190, 200, 20, DARKGRAY);
            DrawCircle(GetMouseX(), GetMouseY(), 20, MAROON);
        EndDrawing();
    }
    CloseWindow();
}
// gcc main.c -o game -lraylib -lm -lpthread -ldl
흐름의미
러스트로의 이동커널·안드로이드가 신규 드라이버에 러스트를 허용. C 를 대체가 아니라 병행
메모리 안전 압박미국 정부 기관 권고 이후 새니타이저·정적 분석이 사실상 의무가 됐습니다
C23 채택GCC/Clang 이 앞서고 MSVC 가 따라가는 중
WebAssemblyEmscripten 으로 C 코드가 브라우저에서 돕니다
여전한 수요임베디드 · 커널 · 런타임 · 라이브러리 경계는 바뀌지 않습니다
C 를 배운 값어치 — 앞으로 러스트를 쓰든 고를 쓰든, 포인터·메모리 레이아웃·링크·시스템 콜을 아는 사람과 모르는 사람의 차이는 계속 벌어집니다. 그리고 모든 언어의 FFI 경계는 여전히 C ABI 입니다 — 이건 당분간 바뀌지 않습니다.
다음에 뭘 하면 좋은가 — ① 이 탭의 동적 배열·해시 테이블·문자열 버퍼를 한 파일로 묶어 자기 mylib.h 를 만들어 보세요. ② 그걸로 작은 도구(로그 파서·JSON 뷰어·TCP 채팅)를 하나 만듭니다. ③ 만든 것을 c23 처럼 .so 로 빌드해 파이썬에서 불러 봅니다. 이 세 단계를 마치면 C 를 “읽을 수 있는 언어”가 아니라 “쓸 수 있는 도구”로 갖게 됩니다.
정리 — C 에 패키지 매니저는 없지만 검증된 라이브러리는 넘칩니다 — DB 는 SQLite, HTTP 는 libcurl, JSON 은 cJSON/yyjson, 비동기는 libuv, 그래픽은 raylib/SDL3, TLS 는 mbedTLS. 의존성은 vcpkg 나 FetchContent 로 관리하고, 헤더 한 장짜리는 그냥 third_party/ 에 넣습니다. 그리고 Redis 소스 읽기는 이 탭 전체의 가장 좋은 복습입니다.
윈도우 10 기준 · MSYS2 GCC · MSVC · VS Code · CMake · avr-gcc · Arm GNU Toolchain 14.x · STM32Cube · ST-LINK · OpenOCD · PlatformIO

코드 한 줄이
칩 안에서 돌기까지
도구부터 깝니다

서버 개발은 JDK 하나, 파이썬 하나 깔면 시작할 수 있었습니다. 임베디드는 다릅니다 — 내 PC 에서 도는 C 컴파일러와 별개로, 다른 CPU 용 기계어를 만드는 크로스 컴파일러, 그 결과물을 칩에 밀어 넣는 플래셔, 칩 안을 들여다보는 디버그 프로브까지 최소 네 가지 도구가 맞물려야 LED 하나가 깜빡입니다. 이 탭은 그 전부를 윈도우 10 PC 에서 0 부터 세팅합니다(리눅스·맥은 다른 점만). 끝나면 아두이노 IDE 없이 Uno 에 직접 굽고, Nucleo-F446RE 를 VS Code 에서 한 줄씩 디버깅할 수 있습니다.

13SECTIONS
Win 10기준 (리눅스 · 맥 차이 표기)
2칩 계열 (AVR · Cortex-M)
0하드웨어 사전 지식
SET 01

전체 지도 — 여섯 가지 도구가 각각 하는 일

설치 안내서를 따라 하다 막히는 이유의 대부분은 지금 깔고 있는 것이 무슨 역할인지 모르기 때문입니다. 도구 이름이 수십 개로 보여도 역할로 묶으면 여섯 칸뿐입니다. 이 지도를 먼저 머리에 넣고 나머지 절을 읽으세요.

소스 한 장이 칩 안에서 돌기까지 — 가로줄 하나가 역할 하나입니다 PC 에서 편집 · 빌드 PC 에서 굽기 · 디버그 보드 USB 너머 편집기 VS Code 빌드 도구 make · CMake 컴파일러 arm-none-eabi-gcc 산출물 .elf · .hex · .bin 디버거 gdb 플래셔 avrdude · OpenOCD MCU — 플래시에 기록 → 실행 ATmega328P · STM32F446RE 프로브 ST-LINK · USB-시리얼 PC 프로그램이라면 둘째 · 셋째 줄이 없습니다 — 만든 exe 를 그 자리에서 실행하면 끝 펌웨어는 결과물을 USB 너머 다른 칩으로 옮겨야 해서 도구가 셋 더 붙습니다
펌웨어 개발 = 빌드(PC) → 굽기(PC → 프로브) → 실행(칩). 막히면 먼저 "몇째 줄에서 막혔나"를 봅니다.

여섯 칸 — 이름이 아니라 역할로 외웁니다

역할하는 일이 탭의 대표
호스트 컴파일러내 PC(x86-64)에서 도는 프로그램을 만듭니다MSYS2 GCC · MSVC cl.exe
크로스 컴파일러PC 에서 돌면서 다른 CPU(AVR · ARM)용 기계어를 만듭니다avr-gcc · arm-none-eabi-gcc
빌드 도구어떤 파일을 어떤 옵션으로 다시 컴파일할지 정합니다make · CMake + Ninja
플래셔산출물(.hex/.bin)을 칩의 플래시 메모리에 씁니다avrdude · STM32CubeProgrammer · OpenOCD
디버그 프로브USB ↔ 칩의 디버그 핀(SWD)을 이어 주는 하드웨어ST-LINK(Nucleo 내장) · J-Link
IDE위 다섯 개를 버튼 하나로 묶어 줍니다VS Code · STM32CubeIDE
서버 개발에 빗대면 — 크로스 컴파일러는 맥에서 리눅스용 도커 이미지를 빌드하는 것과 같습니다 (--platform linux/arm64). 플래셔는 배포 스크립트, 디버그 프로브는 원격 서버에 붙는 SSH + 원격 디버거의 하드웨어판입니다.

"크로스"라는 이름이 붙는 이유

# 같은 소스, 두 가지 컴파일러 — 결과물의 CPU 가 다릅니다
gcc               -c blink.c -o pc.o     # x86-64 기계어 (이 PC 에서 실행 가능)
arm-none-eabi-gcc -c blink.c -o mcu.o    # ARM Thumb 기계어 (PC 에서 실행 불가)
avr-gcc           -c blink.c -o avr.o    # AVR 기계어

# 파일 형식을 보면 차이가 그대로 보입니다 (MSYS2 · 리눅스에 file 명령이 있음)
file pc.o mcu.o avr.o
#  pc.o : ELF 64-bit LSB relocatable, x86-64            (윈도우 GCC 라면 COFF 형식)
#  mcu.o: ELF 32-bit LSB relocatable, ARM, EABI5
#  avr.o: ELF 32-bit LSB relocatable, Atmel AVR 8-bit

# 컴파일러 이름 읽는 법:  <CPU>-<벤더>-<OS>-<ABI>-gcc
#   arm - none - eabi           → ARM 용 · 운영체제 없음(none) · 임베디드 ABI
#   x86_64-w64-mingw32-gcc       → x86-64 용 · 윈도우(mingw) 대상
none 이 핵심입니다 — 운영체제가 없으니 printf 가 화면에 찍힐 곳도, malloc 이 메모리를 달라고 할 커널도 없습니다. 그래서 크로스 툴체인은 컴파일러와 함께 OS 없는 환경용 C 라이브러리(AVR 은 avr-libc, ARM 은 newlib)를 같이 들고 옵니다.

한눈에 — 무엇을 어떤 순서로 깔까 🟢 기초

이 탭을 따라 하는 순서 (총 2~3시간)

#설치확인 명령절
1MSYS2 UCRT64 GCCgcc --versionu02
2VS Code + clangd자동완성 동작u04
3CMake · Ninjacmake --versionu05
4avr-gcc · avrdudeavrdude -?u06
5Arm GNU Toolchainarm-none-eabi-gcc --versionu07
6ST-LINK 드라이버 · CubeProgrammer보드 연결 시 인식u08 · u09
7OpenOCD (또는 CubeCLT)openocd --versionu09
보드가 아직 없어도 1~5 는 전부 할 수 있고, u11 의 시뮬레이터로 굽기 직전까지 연습할 수 있습니다. 보드 고르기는 u12 에 있습니다.

설치가 끝났는지 한 번에 확인 (PowerShell)

# 이 탭을 다 따라 한 뒤 돌려 보면 빠진 것이 바로 보입니다
$tools = 'gcc','cl','cmake','ninja','clangd',
         'avr-gcc','avrdude','arm-none-eabi-gcc','openocd'
foreach ($t in $tools) {
    $c = Get-Command $t -ErrorAction SilentlyContinue
    if ($c) { "{0,-20} OK   {1}" -f $t, $c.Source }
    else    { "{0,-20} 없음" -f $t }
}
# cl 은 'Developer PowerShell for VS' 창에서만 OK 가 나오는 것이 정상입니다 (u02)
같은 이름이 두 번 깔린 경우가 제일 위험합니다 — 아두이노 IDE · Git · Strawberry Perl · 옛 MinGW 가 각자 gcc 나 make 를 PATH 에 넣습니다. where.exe gcc 로 몇 개가 나오는지부터 보세요(u13).

한 단계 더 — PC 프로그램과 펌웨어는 시작부터 다릅니다 🟡 중급

같은 C 인데 무엇이 달라지나

PC 프로그램펌웨어 (MCU)
누가 실행하나OS 가 exe 를 메모리에 올림전원이 켜지면 CPU 가 플래시 0번지부터
main 전에C 런타임이 알아서스타트업 코드가 .data 복사 · .bss 0 채우기
main 이 끝나면프로세스 종료갈 곳이 없습니다 — 보통 무한 루프
printf콘솔로UART 로 직접 이어 줘야 나옵니다
메모리GB 단위 · 가상 메모리2 KB ~ 수백 KB · 주소가 고정
뻗으면코어 덤프 · 재실행그냥 멈춤 — 워치독이 리셋
디버깅gdb 가 프로세스에 붙음gdb → GDB 서버 → 프로브 → 칩

빌드 산출물 세 가지 — 무엇을 칩에 굽나

app.elf   ← 컴파일·링크의 진짜 결과물
          기계어 + 각 조각이 놓일 주소 + 디버그 정보(심볼 · 줄 번호)
          gdb 와 OpenOCD · CubeProgrammer 는 이걸 바로 먹습니다 ★

app.hex   ← elf 에서 '굽기에 필요한 바이트 + 주소'만 텍스트로 뽑은 것
          :100000000C9434000C943E000C943E000C943E0082
          avrdude · 아두이노 업로드가 쓰는 형식 (Intel HEX)

app.bin   ← 주소 정보도 없는 날 바이트 덩어리
          굽는 쪽이 시작 주소를 알아야 합니다 (STM32 는 0x08000000)
          Nucleo 의 USB 드라이브에 끌어다 놓기 · OTA 업데이트 파일

# elf → hex / bin 변환은 objcopy 가 합니다 (u06 · u07)
디버깅은 반드시 .elf 로 — hex 와 bin 에는 함수 이름도 줄 번호도 없습니다. "굽기는 bin, 디버그 심볼은 같은 빌드의 elf" 가 원칙이고, 다른 빌드의 elf 를 붙이면 디버거가 엉뚱한 줄을 가리킵니다.
이 탭과 다른 탭의 경계 — C 문법과 PC 용 빌드의 깊은 내용(분할 컴파일 · Makefile 완전판)은 🅲 C 기초 탭 c10 · ⚙️ C 실전 탭 c25 에, 링커 스크립트 · 스타트업 코드의 속은 🟦 STM32 탭에 있습니다. 여기서는 깔고, 이어 붙이고, 첫 결과를 칩에서 보는 것까지만 합니다.
정리 — 도구는 호스트 컴파일러 · 크로스 컴파일러 · 빌드 도구 · 플래셔 · 디버그 프로브 · IDE 여섯 칸입니다. PC 프로그램은 앞의 셋으로 끝나고, 펌웨어는 결과물(.elf → .hex/.bin)을 USB 너머 칩으로 옮기는 뒤의 셋이 더 붙습니다. 문제가 생기면 "빌드 · 굽기 · 실행" 중 어느 줄인지부터 가립니다.
SET 02

윈도우 PC 용 C — MSYS2 GCC 와 MSVC

펌웨어를 짜더라도 PC 용 C 컴파일러는 따로 있어야 합니다 — 링 버퍼 · CRC · 파서 같은 순수 로직은 칩에 굽기 전에 PC 에서 테스트하는 것이 훨씬 빠르기 때문입니다(🐞 디버깅 · 테스트 탭). 윈도우에는 선택지가 둘입니다. 이 절은 둘 다 깔고 차이를 봅니다.

윈도우에서 C 컴파일러 고르기 — 마름모가 질문입니다 리눅스와 같은 GCC 환경이 필요한가? 아니오 MSVC (cl.exe) Win32 앱 · VS 디버거 예 fork · epoll 같은 리눅스 API 를 쓰나? 예 WSL2 · Ubuntu 진짜 리눅스 (u03) 아니오 MSYS2 UCRT64 GCC 이 탭의 기본값 ★ 셋을 함께 깔아도 됩니다 — 서로 간섭하지 않고 PATH 순서만 조심하면 됩니다 임베디드 크로스 컴파일러는 이 선택과 별개입니다 — 셋 중 어디서든 따로 설치합니다 (u06 · u07) 고민되면 MSYS2 — 리눅스 강좌의 gcc 명령이 거의 그대로 통합니다
기본은 MSYS2 UCRT64 GCC. 윈도우 전용 앱이면 MSVC, 리눅스 API 가 필요하면 WSL2 입니다.

① MSYS2 설치 — 10분

# 1) 설치 (msys2.org 의 설치 파일을 받아도 같습니다)
winget install MSYS2.MSYS2          # 기본 경로 C:\msys64

# 2) 시작 메뉴에서 'MSYS2 UCRT64' 터미널을 엽니다 ★ (MSYS · MINGW64 아님)
#    아래부터는 그 터미널 안에서 칩니다
# 3) 패키지 DB 갱신 — 창이 닫히면 다시 열고 한 번 더
pacman -Syu

# 4) GCC · GDB · make · CMake · Ninja · clangd 를 한 번에
pacman -S --needed mingw-w64-ucrt-x86_64-toolchain \
                   mingw-w64-ucrt-x86_64-cmake \
                   mingw-w64-ucrt-x86_64-ninja \
                   mingw-w64-ucrt-x86_64-clang-tools-extra
#   toolchain 그룹에서 '전부 설치?' 물으면 엔터(=all)

gcc --version     # gcc.exe (Rev?, Built by MSYS2 project) 15.x 처럼 나오면 성공
왜 UCRT64 인가 — MSYS2 에는 환경이 여럿(MSYS · MINGW64 · UCRT64 · CLANG64) 있습니다. UCRT64 는 윈도우 10 에 기본으로 들어 있는 새 C 런타임(UCRT)에 링크해서, 만든 exe 가 다른 PC 에서도 문제없이 돌고 MSVC 로 만든 라이브러리와도 잘 섞입니다. 패키지 이름 앞의 mingw-w64-ucrt-x86_64- 가 그 표시입니다.

② PowerShell · VS Code 에서도 gcc 가 보이게 (PATH)

# 사용자 PATH 끝에 UCRT64 의 bin 하나만 추가합니다
$p = [Environment]::GetEnvironmentVariable('Path', 'User')
[Environment]::SetEnvironmentVariable('Path', "$p;C:\msys64\ucrt64\bin", 'User')

# ⚠ 이미 열려 있는 터미널 · VS Code 는 옛 PATH 를 기억합니다 → 전부 닫고 새로 엽니다
where.exe gcc       # C:\msys64\ucrt64\bin\gcc.exe 가 '첫 줄'이어야 합니다
gcc --version
mingw32-make --version   # UCRT64 의 make 는 이 이름입니다 (make.exe 아님)
C:\msys64\usr\bin 은 PATH 에 넣지 마세요 — 그 폴더에는 리눅스 흉내용 find · sort · link 가 들어 있어서 윈도우 기본 명령과 MSVC 의 link.exe 를 가로챕니다. 필요한 건 ucrt64\bin 하나뿐입니다. GUI 로 하려면 Win+R → sysdm.cpl → 고급 → 환경 변수.

첫 컴파일 — 표준 버전을 눈으로 확인 🟢 기초

stdver.c — 지금 어느 C 표준으로 컴파일됐나

#include <stdio.h>

int main(void) {
#ifdef __STDC_VERSION__
    printf("__STDC_VERSION__ = %ldL\n", (long)__STDC_VERSION__);
#else
    puts("__STDC_VERSION__ 미정의 (MSVC 에 /std 를 안 준 경우)");
#endif
    return 0;
}
gcc -std=c17 -Wall -Wextra stdver.c -o stdver && ./stdver
#  __STDC_VERSION__ = 201710L

gcc -std=c23 -Wall -Wextra stdver.c -o stdver && ./stdver
#  __STDC_VERSION__ = 202311L      (GCC 14 이상)
#  GCC 13 이하는 -std=c2x 로 쓰고 202000L 이 나옵니다

gcc stdver.c -o stdver && ./stdver     # 플래그 없음
#  GCC 15 이상: 202311L (기본이 gnu23) · GCC 8~14: 201710L (gnu17)

경고 플래그 — 처음부터 이 조합으로

# 학습 · 개발용 기본 세트 (GCC · Clang 공통)
gcc -std=c17 -O0 -g3 \
    -Wall -Wextra -Wpedantic \
    -Wshadow -Wconversion -Wsign-conversion \
    -Wstrict-prototypes -Wmissing-prototypes \
    -Werror=implicit-function-declaration \
    main.c -o app

#  -Wpedantic              GNU 확장을 쓰면 알려 줌 (이식성)
#  -Wconversion            int → uint8_t 같은 '조용한 잘림' ★ 임베디드 필수
#  -Wstrict-prototypes     int f() 처럼 인자 목록 없는 선언 경고
#  -Werror=implicit-...    선언 없이 함수 호출 → 에러 (C99 부터 이미 금지)
#  -g3                     매크로까지 디버그 정보에 (gdb 에서 매크로 값 보기)
왜 -Wconversion 을 굳이 켜나 — 펌웨어는 uint8_t · uint16_t 를 끝없이 섞습니다. uint8_t x = adc_read(); 처럼 10비트 값이 8비트로 잘려도 C 는 아무 말을 안 합니다. 경고가 많이 나와 귀찮지만 그 하나하나가 실제 칩에서 값이 틀어지는 자리입니다(C 기초 탭 c02).
위는 표준이 나온 해, 아래는 GCC 가 기본값을 바꾼 해 — 둘은 몇 년씩 어긋납니다 1999 2011 2015 2018 2024 C99 // 주석 · stdint.h C11 _Generic · 스레드 C17 C11 결함 수정판 C23 bool · nullptr · auto GCC 5 기본 gnu11 GCC 8 기본 gnu17 GCC 15 · 2025 기본 gnu23 ★ GCC 15 부터는 플래그 없이 컴파일하면 C23 — 옛 코드가 갑자기 깨지는 이유입니다 그래서 -std= 를 항상 직접 적습니다 (팀 · CI · 보드 툴체인이 같은 C 를 보도록)
플래그를 안 적으면 컴파일러 버전이 C 버전을 정합니다 — -std=c17 이든 -std=c23 이든 직접 적는 것이 규칙입니다.

두 번째 선택지 — Visual Studio Build Tools (cl.exe) 🟡 중급

설치와 첫 컴파일

# 1) Build Tools 설치 (IDE 없이 컴파일러만 · 약 3~6 GB)
winget install Microsoft.VisualStudio.2022.BuildTools
#   설치 관리자에서 'C++를 사용한 데스크톱 개발' 워크로드를 체크
#   (MSVC 컴파일러 + Windows SDK 가 같이 들어옵니다)
#   더 새 버전의 Visual Studio 가 있다면 그쪽도 똑같습니다

# 2) 시작 메뉴 → 'Developer PowerShell for VS' 또는
#    'x64 Native Tools Command Prompt' 를 엽니다 ★
#    일반 PowerShell 에서는 cl 이 안 보이는 것이 정상입니다
cl /std:c17 /W4 /utf-8 /Zi stdver.c        # → stdver.exe · stdver.obj · stdver.pdb
.\stdver.exe
#  __STDC_VERSION__ = 201710L

cl /std:c17 /W4 /WX /utf-8 /Fe:app.exe main.c util.c   # 경고=에러, 출력 이름 지정
cl /std:clatest /W4 stdver.c     # C23 일부 기능 (MSVC 의 C23 지원은 아직 부분적)

GCC ↔ MSVC 플래그 대응표

하고 싶은 것GCC · ClangMSVC
C 표준 지정-std=c17 · -std=c23/std:c17 · /std:clatest
경고 켜기-Wall -Wextra/W4
경고 = 에러-Werror/WX
디버그 정보-g/Zi (.pdb 파일)
최적화-O2 · -Os/O2 · /O1
매크로 정의-DDEBUG=1/DDEBUG=1
헤더 경로-Iinclude/Iinclude
출력 이름-o app/Fe:app.exe
소스가 UTF-8(기본)/utf-8 ★ 한글 주석
AddressSanitizer-fsanitize=address ¹/fsanitize=address

¹ MSYS2 의 MinGW GCC 는 ASan 을 지원하지 않습니다. 윈도우에서 ASan 이 필요하면 MSVC 나 WSL 을 쓰세요.

실무에선 이렇게 — 한글 · 차이점 · 섞어 쓰기 🔴 실무

한글이 깨질 때 — 세 군데를 맞춥니다

# ① 소스 파일은 UTF-8 로 저장 (VS Code 기본값)
# ② MSVC 는 /utf-8 을 줘야 소스를 UTF-8 로 읽습니다
#    (안 주면 CP949 로 읽어 C4819 경고 + 문자열 깨짐)
# ③ 콘솔 출력 코드 페이지
chcp 65001                    # 이 창만 UTF-8 로
#   영구적으로: 설정 → 시간 및 언어 → 언어 → 관리 언어 설정
#   → '세계 언어 지원을 위해 Unicode UTF-8 사용(Beta)' 체크
#   (오래된 프로그램이 깨질 수 있어 회사 PC 에서는 신중히)
Windows Terminal(Microsoft Store · winget Microsoft.WindowsTerminal)을 쓰면 글꼴과 색 처리가 훨씬 낫습니다. 펌웨어 시리얼 출력을 볼 때도 같은 문제가 생기므로 보드가 보내는 문자열도 UTF-8 로 통일하세요.

MSVC 에서만 막히는 C 코드

// ① 가변 길이 배열(VLA) — MSVC 는 지원하지 않습니다 (C11 부터 선택 기능)
void f(int n) { int buf[n]; }        // MSVC: error C2057

// ② GCC 확장 문법 — __attribute__ 는 MSVC 에 없습니다
struct __attribute__((packed)) S { char a; int b; };   // struct 키워드 뒤 (맨 앞에 두면 무시되고 경고)
//   MSVC 는 #pragma pack(push, 1) ... #pragma pack(pop)

// ③ 이식 가능하게 — 매크로로 감쌉니다
#if defined(_MSC_VER)
  #define PACKED_BEGIN __pragma(pack(push, 1))
  #define PACKED_END   __pragma(pack(pop))
#else
  #define PACKED_BEGIN
  #define PACKED_END   __attribute__((packed))
#endif
펌웨어 로직을 PC 에서 테스트할 거라면 GCC 쪽이 유리합니다 — 칩용 컴파일러가 전부 GCC 계열이라 __attribute__ · 확장 문법이 그대로 통하기 때문입니다. MSVC 는 윈도우 GUI · DLL 을 만들 때 고르세요. Clang(winget install LLVM.LLVM)도 있지만 윈도우에서는 MSVC 의 헤더 · 링커를 빌려 쓰므로 Build Tools 가 같이 필요합니다.
정리 — 윈도우 PC 용 C 는 MSYS2 UCRT64 GCC 를 기본으로(pacman -S mingw-w64-ucrt-x86_64-toolchain + PATH 에 C:\msys64\ucrt64\bin), 윈도우 전용 앱이면 MSVC(cl /std:c17 /W4 /utf-8)입니다. 어느 쪽이든 표준 버전(-std=c17)과 경고(-Wall -Wextra -Wconversion)를 직접 적습니다 — GCC 15 부터 기본값이 C23 으로 바뀌었습니다.
SET 03

리눅스 · WSL2 · macOS — 다른 점만

임베디드 도구는 원래 리눅스에서 태어난 것이 많아서, 리눅스 · 맥에서는 설치가 오히려 쉽습니다. 윈도우 사용자도 WSL2 로 리눅스를 옆에 띄울 수 있지만, 그때는 USB 장치를 WSL 로 넘겨 주는 한 단계가 더 필요합니다.

Ubuntu · Debian

# PC 용 C (gcc · make · libc 헤더)
sudo apt update
sudo apt install build-essential gdb \
     cmake ninja-build clangd

# AVR (배포판 패키지로 충분)
sudo apt install gcc-avr avr-libc avrdude

# ARM — 배포판 것은 낡은 경우가 많아
#   Arm 공식 tar.xz 를 /opt 에 풀기 권장 (u07)
sudo apt install openocd gdb-multiarch

Fedora

sudo dnf group install development-tools
sudo dnf install gdb cmake ninja-build \
     clang-tools-extra

sudo dnf install avr-gcc avr-libc avrdude
sudo dnf install openocd
#   ARM 은 역시 Arm 공식 배포본 권장

#   Arch:  pacman -S base-devel avr-gcc
#          avr-libc avrdude arm-none-eabi-gcc
#          arm-none-eabi-newlib openocd

macOS

# Apple Clang (gcc 라고 쳐도 실제로는 clang)
xcode-select --install

# Homebrew 로 나머지
brew install cmake ninja llvm openocd avrdude
brew tap osx-cross/avr && brew install avr-gcc
brew install --cask gcc-arm-embedded

# Apple Silicon 이어도 크로스 컴파일은 똑같이 됩니다
#   (호스트 CPU 와 대상 칩은 아무 관계 없음)

운영체제별로 진짜 다른 것 — 장치 이름 · 권한

윈도우리눅스macOS
Uno R3 (16U2)COM5/dev/ttyACM0/dev/cu.usbmodem14101
CH340 클론COM7 (드라이버 필요)/dev/ttyUSB0/dev/cu.wchusbserial*
Nucleo 가상 COMCOM9/dev/ttyACM1/dev/cu.usbmodem*
드라이버필요한 경우 많음커널 내장대부분 내장
권한없음dialout 그룹 · udev없음

숫자(COM5 · 14101)는 꽂는 포트마다 바뀝니다. 이름의 앞부분(ACM = USB 표준 시리얼, USB = 변환 칩)을 보면 어떤 종류인지 압니다.

리눅스 — 처음 한 번 해 둘 권한 설정

# 시리얼 포트 권한 (Ubuntu · Debian = dialout, Arch = uucp)
sudo usermod -aG dialout $USER
#   ⚠ 로그아웃 후 다시 로그인해야 적용됩니다 (newgrp dialout 은 그 셸만)

# ST-LINK · J-Link 같은 USB 디버그 장치 권한 — OpenOCD 의 udev 규칙
sudo cp /usr/share/openocd/contrib/60-openocd.rules /etc/udev/rules.d/
#   (경로는 배포판마다 다름: find / -name '60-openocd.rules' 2>/dev/null)
sudo udevadm control --reload-rules && sudo udevadm trigger

ls -l /dev/ttyACM0      # crw-rw---- root dialout → 그룹에 들어 있으면 OK
groups                  # dialout 이 보여야 합니다
sudo avrdude 로 때우지 마세요 — 당장은 되지만 빌드 디렉터리에 root 소유 파일이 생기고, VS Code 에서 누른 업로드 버튼은 여전히 실패합니다. 그룹 · udev 로 한 번에 해결합니다(u13).

WSL2 — 윈도우 안의 리눅스에 USB 넘겨 주기 🟡 중급

세로가 시간 — USB 장치는 한순간에 한쪽(윈도우 또는 WSL)에만 붙습니다 보드 (USB) 윈도우 · usbipd WSL2 커널 avrdude (WSL) 1. 꽂으면 COM5 로 인식 2. usbipd bind (관리자 · 최초 1회) 3. usbipd attach --wsl 윈도우에서 COM5 사라짐 4. /dev/ttyACM0 생김 5. avrdude -P /dev/ttyACM0 → USB/IP 로 보드에 기록 6. detach → 윈도우로 복귀 윈도우 쪽 시리얼 모니터 · 아두이노 IDE 는 attach 동안 그 포트를 못 봅니다
WSL 은 USB 를 직접 못 봅니다 — usbipd-win 이 네트워크(USB/IP)로 장치를 넘겨 주는 구조입니다.

설치와 연결 — 윈도우 쪽 (관리자 PowerShell)

# WSL2 + Ubuntu (이미 있으면 건너뜀)
wsl --install -d Ubuntu
wsl --update                     # 커널을 최신으로 (USB/IP 지원 포함)

# usbipd-win 설치
winget install usbipd

usbipd list
#  BUSID  VID:PID    DEVICE                                  STATE
#  2-3    2341:0043  USB 직렬 장치(COM5)                       Not shared
#  2-4    0483:374b  ST-Link Debug, USB 대용량 저장 장치, ...   Not shared

usbipd bind --busid 2-3          # 관리자 권한 · 장치마다 한 번만
usbipd attach --wsl --busid 2-3  # 꽂을 때마다 (관리자 아니어도 됨)
#   --auto-attach 를 붙이면 뽑았다 꽂아도 계속 따라 붙습니다

usbipd detach --busid 2-3        # 윈도우로 돌려받기

WSL 안에서 확인

lsusb
#  Bus 001 Device 002: ID 2341:0043 Arduino SA Uno R3 (CDC ACM)

ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null
#  /dev/ttyACM0

sudo usermod -aG dialout $USER     # 권한 (한 번, WSL 재시작 후 적용)
#   PowerShell 에서: wsl --shutdown  → 다시 열기

avrdude -c arduino -p m328p -P /dev/ttyACM0 -b 115200 -U flash:r:backup.hex:i
#   먼저 '읽기'로 통신이 되는지 확인하는 습관 (u06)
VID:PID 를 외워 두면 편합니다 — 2341 아두이노 정품, 1a86:7523 CH340, 0483:374b ST-LINK/V2-1(Nucleo-F446RE), 0403:6001 FTDI, 10c4:ea60 CP2102. usbipd list 에서 장치 이름이 애매할 때 이 숫자로 찾습니다.

실무에선 이렇게 — 어느 쪽에서 빌드하고 어느 쪽에서 굽나 🔴 실무

WSL 을 쓸 때의 현실적인 분업

작업추천 위치이유
소스 · 빌드WSL 의 ~/proj/mnt/c 위 빌드는 수 배 느림
편집VS Code + WSL 확장창은 윈도우, 도구는 리눅스
STM32 굽기 · 디버그윈도우 쪽 도구도 가능.elf 만 \\wsl$\ 로 넘김
시리얼 모니터한쪽만포트는 동시에 두 곳에 못 붙음
CI 와 같은 환경WSLGitHub Actions 가 우분투이므로

흔한 함정 셋

① usbipd attach 가 "WSL kernel does not support USB/IP"
   → wsl --update 후 wsl --shutdown. 회사 PC 의 고정 커널이면 IT 문의

② 보드를 리셋하거나 펌웨어를 새로 구우면 장치가 끊겼다 다시 붙음
   (DFU 모드 · 부트로더 진입 시 VID:PID 가 바뀌는 보드는 특히)
   → attach --auto-attach 를 띄워 두거나 다시 attach

③ OpenOCD 를 WSL 에서 돌리니 ST-LINK 가 안 보임
   → udev 규칙(60-openocd.rules)을 WSL 안에도 넣어야 합니다
     WSL 은 udev 가 기본 꺼져 있어 /etc/wsl.conf 에
     [boot] systemd=true  를 켜는 편이 편합니다
처음이라면 윈도우 네이티브(MSYS2 + 윈도우용 avrdude · Arm 툴체인 · CubeProgrammer)로 시작하고, CI 와 똑같은 빌드가 필요해질 때 WSL 을 더하는 순서가 덜 아픕니다.
정리 — 리눅스는 build-essential(Fedora 는 development-tools) + gcc-avr avr-libc avrdude openocd, ARM 은 공식 배포본, 그리고 dialout 그룹과 udev 규칙이 전부입니다. 맥은 xcode-select --install + Homebrew. WSL2 는 usbipd bind(한 번) → usbipd attach --wsl(매번)으로 USB 를 넘겨야 장치가 보입니다.
SET 04

VS Code 세팅 — clangd · compile_commands.json · 빌드 · 디버그

VS Code 는 그 자체로는 아무것도 컴파일하지 않는 편집기입니다. 자동완성은 언어 서버가, 빌드는 make · CMake 가, 디버그는 gdb 가 합니다. 이 절은 그 셋을 설정 파일 네 개로 이어 붙여 "빨간 줄 없이, F5 로 디버그"까지 갑니다.

VS Code 는 가운데에서 도구를 부르기만 합니다 — 일은 바깥 원들이 합니다 VS Code 편집기 껍데기 clangd 자동완성 · 경고 · 정의로 이동 LLVM 공식 확장 launch.json F5 디버그 어떤 gdb 를 쓸지 Cortex -Debug 칩 디버그 (u09) 컴파일 DB compile_commands.json 파일마다 쓴 실제 플래그 ★ CMake Tools 구성 · 빌드 · 키트 tasks.json Ctrl+Shift+B 빌드 명령 빨간 줄이 뜨는데 빌드는 된다면 — 아래 원(컴파일 DB)이 끊긴 것입니다
자동완성의 근거는 compile_commands.json 하나입니다. 이 파일이 있으면 clangd 가 컴파일러가 본 것과 똑같이 봅니다.

확장 둘 중 하나 — clangd vs Microsoft C/C++

clangd (LLVM)C/C++ (Microsoft)
확장 IDllvm-vs-code-extensions.vscode-clangdms-vscode.cpptools
설정 근거compile_commands.jsonc_cpp_properties.json 또는 CMake Tools
속도 · 정확도빠르고 정확 (실제 컴파일러 프런트엔드)보통 · 큰 프로젝트에서 느려짐
clang-tidy내장 (실시간 경고)별도 실행
디버거없음있음 (cppdbg · cppvsdbg)
크로스 컴파일러--query-driver 필요compilerPath 지정
추천 조합 — clangd 로 편집하고, Microsoft C/C++ 는 디버거로만 씁니다. 둘이 같이 켜져 있으면 자동완성이 두 번 뜨므로 설정에 "C_Cpp.intelliSenseEngine": "disabled" 를 넣습니다.

.vscode/settings.json · extensions.json

// .vscode/settings.json
{
  "C_Cpp.intelliSenseEngine": "disabled",
  "clangd.arguments": [
    "--compile-commands-dir=${workspaceFolder}/build",
    "--background-index",
    "--header-insertion=never",
    "--query-driver=C:/msys64/ucrt64/bin/gcc.exe,C:/Program Files*/Arm*/**/bin/arm-none-eabi-gcc.exe"
  ],
  "cmake.generator": "Ninja",
  "files.encoding": "utf8",
  "files.eol": "\n"
}

// .vscode/extensions.json — 팀원이 열면 '추천 확장 설치' 가 뜹니다
{
  "recommendations": [
    "llvm-vs-code-extensions.vscode-clangd",
    "ms-vscode.cpptools",
    "ms-vscode.cmake-tools",
    "marus25.cortex-debug"
  ]
}

핵심 — compile_commands.json 만드는 세 가지 방법 🟢 기초

파일 모양과 만드는 법

[
  {
    "directory": "C:/proj/blink/build",
    "command": "arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -DSTM32F446xx -I../Inc -O0 -g3 -c ../Src/main.c -o main.o",
    "file": "C:/proj/blink/Src/main.c"
  }
]
# ① CMake — 플래그 한 줄 (가장 확실)
cmake -B build -G Ninja -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
#   CMakeLists.txt 에  set(CMAKE_EXPORT_COMPILE_COMMANDS ON)  을 넣어 둬도 됨

# ② Makefile 프로젝트 — make 를 감싸서 실제 호출을 기록
bear -- make               # 리눅스 · WSL (apt install bear)
pip install compiledb
compiledb make             # 윈도우에서도 동작 (make 출력을 파싱)

# ③ PlatformIO
pio run -t compiledb       # (u10)

.clangd — 크로스 컴파일 프로젝트용 보정

# 프로젝트 루트의 .clangd
CompileFlags:
  # clangd 는 clang 이라 GCC 전용 플래그를 모르면 경고를 띄웁니다
  Remove:
    - -fcyclomatic-complexity
    - -fanalyzer
    - -fno-tree-loop-distribute-patterns
  Add:
    - -Wno-unknown-warning-option
Diagnostics:
  UnusedIncludes: Strict
  ClangTidy:
    Add: [bugprone-*, misc-redundant-expression]
    Remove: [bugprone-easily-swappable-parameters]
--query-driver 를 빼먹으면 — clangd 가 arm-none-eabi-gcc 에게 "네 표준 헤더가 어디 있니?"를 물어보지 못해 stdint.h · string.h 를 PC 용 헤더로 찾거나 못 찾습니다. uint32_t 에 빨간 줄이 그어지면 십중팔구 이것입니다. 경로는 슬래시(/)로, 와일드카드 가능.

빌드 버튼과 F5 — tasks.json · launch.json 🟡 중급

.vscode/tasks.json — Ctrl+Shift+B

{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "configure",
      "type": "shell",
      "command": "cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug -DCMAKE_EXPORT_COMPILE_COMMANDS=ON"
    },
    {
      "label": "build",
      "type": "shell",
      "command": "cmake --build build",
      "dependsOn": "configure",
      "group": { "kind": "build", "isDefault": true },
      "problemMatcher": ["$gcc"]
    }
  ]
}
"problemMatcher": ["$gcc"] 가 GCC 의 file:line:col: error: 출력을 읽어 문제 패널과 빨간 밑줄로 바꿔 줍니다. MSVC 라면 "$msCompile".

.vscode/launch.json — PC 프로그램 디버그 (MSYS2 gdb)

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "PC: gdb",
      "type": "cppdbg",
      "request": "launch",
      "program": "${workspaceFolder}/build/app.exe",
      "cwd": "${workspaceFolder}",
      "MIMode": "gdb",
      "miDebuggerPath": "C:/msys64/ucrt64/bin/gdb.exe",
      "externalConsole": false,
      "preLaunchTask": "build",
      "setupCommands": [
        { "text": "-enable-pretty-printing", "ignoreFailures": true }
      ]
    }
  ]
}
// MSVC 로 빌드했다면  "type": "cppvsdbg"  (MIMode · miDebuggerPath 없이)
// 칩 디버그용 cortex-debug 설정은 u09 에서 이 파일에 추가합니다

실무에선 이렇게 — 팀이 같은 화면을 보게 🔴 실무

.vscode 중 무엇을 커밋하나

파일커밋?이유
extensions.json예필요한 확장을 팀에 알림
tasks.json예빌드 명령 통일
launch.json예 (경로는 변수로)C:/msys64 같은 절대 경로 주의
settings.json공통 부분만개인 취향(글꼴 · 테마)은 사용자 설정에
.clangd · .clang-format예경고 · 포맷 규칙 통일
compile_commands.json아니오빌드 산출물 — PC 마다 경로가 다름

절대 경로를 없애는 요령

// 개인마다 다른 경로는 환경 변수로 빼고 launch.json 에서 참조
"miDebuggerPath": "${env:MSYS2_ROOT}/ucrt64/bin/gdb.exe"

// 또는 PATH 에 있으면 이름만
"miDebuggerPath": "gdb"

// 크로스 툴체인 경로는 CMake 의 툴체인 파일 · 프리셋 하나에만 (u05)
//   → tasks · clangd · 디버거가 모두 그 결과(build/)를 봅니다
빨간 줄 진단 순서 — ① build/compile_commands.json 이 있나 → ② 출력 패널에서 clangd 로그를 골라 "Failed to find compilation database" 가 있나 → ③ 크로스 프로젝트면 --query-driver 경로가 실제 arm-none-eabi-gcc.exe 와 맞나 → ④ 명령 팔레트 clangd: Restart language server.
정리 — clangd(편집) + Microsoft C/C++(디버거만) + CMake Tools 를 깔고, 빌드할 때 -DCMAKE_EXPORT_COMPILE_COMMANDS=ON 으로 compile_commands.json 을 만들면 자동완성이 살아납니다. 빌드는 tasks.json(Ctrl+Shift+B), 디버그는 launch.json(F5) — 크로스 컴파일러를 쓰면 clangd 에 --query-driver 를 꼭 줍니다.
SET 05

Make · CMake · Ninja — C 전용 최소 프로젝트와 크로스 빌드

빌드 도구 자체의 문법은 C 기초 탭 c10(분할 컴파일)과 C 실전 탭 c25(Makefile · CMake 완전판)에 있습니다. 여기서는 같은 프로젝트를 Makefile 과 CMakeLists.txt 로 나란히 세운 뒤, 임베디드에서만 필요한 툴체인 파일 · 프리셋 · 빌드 후 처리(objcopy)를 붙입니다.

CMake 는 빌드를 '직접' 하지 않습니다 — 계획서를 쓰고, 실행은 Ninja 가 합니다 구성 툴체인 확인 생성 build.ninja 컴파일 바뀐 .c 만 링크 .ld → app.elf 후처리 .hex · .bin cmake -B build — 처음 한 번 cmake --build build — 고칠 때마다 CMakeLists.txt 를 고치면 다음 빌드에서 앞 두 단계가 자동으로 다시 돕니다 툴체인(어떤 gcc 를 쓸지)은 '구성' 때 한 번 정해지고 build 폴더에 고정됩니다
CMake = 계획(구성 · 생성), Ninja = 실행. 툴체인을 바꾸려면 build 폴더를 지우고 다시 구성합니다.

같은 프로젝트 — Makefile 판

# 폴더:  src/main.c  src/ring.c  include/ring.h
CC      ?= gcc
CFLAGS  := -std=c17 -Wall -Wextra -Wconversion -O2 -g -Iinclude -MMD -MP
SRCS    := $(wildcard src/*.c)
OBJS    := $(SRCS:src/%.c=build/%.o)

build/app: $(OBJS)
	$(CC) $^ -o $@

build/%.o: src/%.c | build
	$(CC) $(CFLAGS) -c $< -o $@

build:
	mkdir -p build

clean:
	rm -rf build

-include $(OBJS:.o=.d)      # 헤더 의존성 (-MMD 가 만든 .d)
.PHONY: clean
들여쓰기는 반드시 탭 — 스페이스면 missing separator 에러. 윈도우에서는 mingw32-make 로 실행하고, mkdir -p · rm -rf 가 없는 cmd 환경이라면 MSYS2 셸에서 돌립니다 — 이 셸 차이가 윈도우에서 CMake 를 쓰는 큰 이유입니다.

같은 프로젝트 — CMakeLists.txt 판

cmake_minimum_required(VERSION 3.21)
project(app LANGUAGES C)

set(CMAKE_C_STANDARD 17)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_C_EXTENSIONS OFF)          # -std=c17 (gnu17 아님)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

add_executable(app src/main.c src/ring.c)
target_include_directories(app PRIVATE include)
target_compile_options(app PRIVATE   # 여러 플래그는 ; 로 잇고 따옴표로 감쌉니다
  "$<$<C_COMPILER_ID:GNU,Clang>:-Wall;-Wextra;-Wconversion>"
  "$<$<C_COMPILER_ID:MSVC>:/W4;/utf-8>")
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake --build build          # 고칠 때마다 이 줄만
./build/app

# 설치 (윈도우)
winget install Kitware.CMake Ninja-build.Ninja
#   또는 MSYS2 의 mingw-w64-ucrt-x86_64-cmake · -ninja (u02)
MakefileCMake + Ninja
정체빌드 규칙을 직접 적음빌드 규칙을 생성 (메타 빌드)
윈도우 · 리눅스 · 맥셸 명령이 OS 마다 달라짐같은 파일로 셋 다
헤더 의존성-MMD -MP 를 직접자동
IDE 연동bear · compiledb 필요CLion · VS Code · CubeIDE 가 바로 읽음
병렬 빌드make -j8Ninja 가 기본으로 코어 수만큼
임베디드 현실AVR 소형 · 오래된 SDK 에 많음STM32CubeMX · Zephyr · Pico SDK · ESP-IDF 가 기본으로 생성

임베디드에서만 — 툴체인 파일로 크로스 컴파일 🟡 중급

cmake/arm-none-eabi.cmake — "어떤 gcc 로, 어떤 칩용"

set(CMAKE_SYSTEM_NAME Generic)          # OS 없음 → 크로스 모드
set(CMAKE_SYSTEM_PROCESSOR arm)

set(CMAKE_C_COMPILER   arm-none-eabi-gcc)
set(CMAKE_ASM_COMPILER arm-none-eabi-gcc)
set(CMAKE_OBJCOPY      arm-none-eabi-objcopy)
set(CMAKE_SIZE         arm-none-eabi-size)

# 테스트 컴파일 때 실행 파일을 링크하지 않게 (링커 스크립트가 아직 없으므로)
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)

set(MCU_FLAGS "-mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16")
set(CMAKE_C_FLAGS_INIT   "${MCU_FLAGS} -ffunction-sections -fdata-sections")
set(CMAKE_ASM_FLAGS_INIT "${MCU_FLAGS} -x assembler-with-cpp")
set(CMAKE_EXE_LINKER_FLAGS_INIT
    "${MCU_FLAGS} -Wl,--gc-sections --specs=nano.specs --specs=nosys.specs")

# 라이브러리 · 헤더는 크로스 쪽에서만 찾기
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)

펌웨어 타깃 — .elf 와 빌드 후 처리

project(blink LANGUAGES C ASM)

add_executable(blink
  Src/main.c Src/system_stm32f4xx.c
  Startup/startup_stm32f446xx.s)
set_target_properties(blink PROPERTIES SUFFIX ".elf")
target_compile_definitions(blink PRIVATE STM32F446xx)
target_include_directories(blink PRIVATE Inc Drivers/CMSIS/Include
  Drivers/CMSIS/Device/ST/STM32F4xx/Include)
target_link_options(blink PRIVATE
  -T${CMAKE_SOURCE_DIR}/STM32F446RETX_FLASH.ld
  -Wl,-Map=blink.map,--print-memory-usage)

add_custom_command(TARGET blink POST_BUILD
  COMMAND ${CMAKE_OBJCOPY} -O ihex   $<TARGET_FILE:blink> blink.hex
  COMMAND ${CMAKE_OBJCOPY} -O binary $<TARGET_FILE:blink> blink.bin
  COMMAND ${CMAKE_SIZE} $<TARGET_FILE:blink>)
cmake -B build-arm -G Ninja --toolchain cmake/arm-none-eabi.cmake
cmake --build build-arm
#  Memory region   Used Size  Region Size  %age Used
#    RAM:            1584 B      128 KB      1.21%
#    FLASH:          1252 B      512 KB      0.24%

실무에선 이렇게 — PC 테스트 빌드와 칩 빌드를 프리셋 하나로 🔴 실무

CMakePresets.json

{
  "version": 6,
  "configurePresets": [
    { "name": "host",  "generator": "Ninja",
      "binaryDir": "build/host",
      "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", "BUILD_TESTS": "ON" } },
    { "name": "f446",  "generator": "Ninja",
      "binaryDir": "build/f446",
      "toolchainFile": "${sourceDir}/cmake/arm-none-eabi.cmake",
      "cacheVariables": { "CMAKE_BUILD_TYPE": "MinSizeRel" } }
  ],
  "buildPresets": [
    { "name": "host", "configurePreset": "host" },
    { "name": "f446", "configurePreset": "f446" }
  ]
}
cmake --preset host  && cmake --build --preset host    # 링 버퍼 · CRC 단위 테스트
cmake --preset f446  && cmake --build --preset f446    # 칩용 .elf

자주 막히는 지점

① "The C compiler is not able to compile a simple test program"
   ... undefined reference to `_exit'
   → 툴체인 파일에 CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY 누락

② 툴체인 파일을 고쳤는데 그대로
   → 구성 단계에서 캐시됩니다.  build 폴더를 지우고 다시 구성
     (cmake --fresh --preset f446 — CMake 3.24 이상)

③ "Ninja" 생성기를 못 찾음
   → ninja 가 PATH 에 없음. where.exe ninja

④ 어셈블리 시작 파일(.s)이 컴파일 안 됨
   → project(... LANGUAGES C ASM) 에 ASM 이 빠짐
왜 프리셋인가 — "PC 에서 로직 테스트 → 칩에 굽기"를 같은 소스 트리 · 두 개의 build 폴더로 나누면, 테스트는 초 단위로 돌고 칩 빌드는 그 결과를 믿고 갑니다. VS Code 의 CMake Tools 도 프리셋을 드롭다운으로 보여 줍니다.
정리 — 작은 PC 프로젝트는 Makefile 도 충분하지만, 윈도우 · 여러 보드 · IDE 연동을 생각하면 CMake + Ninja 가 기본입니다. 임베디드에서 더해지는 것은 툴체인 파일(어떤 gcc · 어떤 칩 플래그), 링커 스크립트 -T, 빌드 후 objcopy 세 가지이고, CMakePresets.json 으로 PC 테스트 빌드와 칩 빌드를 한 트리에서 나눕니다.
SET 06

AVR 툴체인 — 아두이노 IDE 없이 Uno 에 직접 굽기

아두이노 IDE 의 업로드 버튼 뒤에서는 avr-gcc → avr-objcopy → avrdude 세 명령이 돕니다. 이 절은 그 셋을 손으로 쳐서 Arduino Uno R3(ATmega328P @ 16 MHz) 의 LED 를 깜빡입니다. 한 번 해 보면 IDE 가 숨기던 -mmcu · F_CPU · 부트로더 · .hex 가 무엇인지 전부 보입니다.

윈도우 설치 — 넷 중 하나

방법GCC 버전메모
독립 빌드 zip ★최신 (14 · 15 대)커뮤니티 빌드(Zak Kemble 의 avr-gcc 빌드 등)를 C:\avr 에 풀고 bin 을 PATH 에
MSYS2 패키지최신pacman -Ss avr 로 이름 확인 후 설치 (avr-gcc · avr-libc · avrdude)
Microchip 공식오래됨 (7.x)"AVR 8-bit Toolchain (Windows)" — 회사 표준이 이것인 곳이 많음
아두이노 IDE 내장7.3%LOCALAPPDATA%\Arduino15\packages\arduino\tools\avr-gcc\ 에 이미 있음
# avrdude 는 따로: github.com/avrdudes/avrdude 의 Releases 에서 윈도우 zip (8.x)
#   avrdude.exe 와 avrdude.conf 를 같은 폴더에 두고 그 폴더를 PATH 에

avr-gcc --version          # avr-gcc (GCC) 14.x ...
avrdude -?                 # 사용법이 나오면 OK (버전은 -v)

blink.c — 레지스터로 직접 (PB5 = 보드의 D13 · L LED)

#include <avr/io.h>        // DDRB · PORTB · PB5 정의 (-mmcu 로 칩이 골라짐)
#include <util/delay.h>     // _delay_ms — F_CPU 가 정의돼 있어야 함

int main(void) {
    DDRB |= (1 << DDB5);        // PB5 를 출력으로 (Data Direction Register)
    for (;;) {
        PORTB ^= (1 << PORTB5); // 켜고 끄기 뒤집기
        _delay_ms(500);
    }
}
아두이노 코드와 같은 일 — pinMode(13, OUTPUT) = DDRB |= 1<<DDB5, digitalWrite = PORTB 비트 조작입니다. 레지스터의 뜻은 🔷 AVR 탭에서 하나씩 뜯고, 여기서는 빌드해서 굽는 길만 봅니다.

세 명령으로 굽기 — 컴파일 · 변환 · 업로드 🟢 기초

손으로 한 줄씩

# ① 컴파일 + 링크 → blink.elf
avr-gcc -mmcu=atmega328p -DF_CPU=16000000UL -Os -std=gnu17 -Wall -Wextra `
        blink.c -o blink.elf

# ② elf → Intel HEX (EEPROM 영역은 빼고)
avr-objcopy -O ihex -R .eeprom blink.elf blink.hex

# ③ 크기 확인 — 32,256 B(부트로더 제외)를 넘으면 안 됩니다
avr-size blink.elf
#    text    data     bss     dec     hex filename
#     176       0       0     176      b0 blink.elf

# ④ 업로드 (COM 번호는 장치 관리자에서 — u13)
avrdude -c arduino -p m328p -P COM5 -b 115200 -U flash:w:blink.hex:i

PowerShell 의 줄 잇기는 백틱(`), bash 는 \ 입니다. 크기 176 B 는 예시 빌드 값으로, GCC · avr-libc 버전에 따라 몇 바이트씩 다릅니다.

플래그와 avrdude 옵션 해부

조각뜻
-mmcu=atmega328p칩 종류. 명령어 세트 · 레지스터 주소 · 메모리 크기 · 시작 코드가 정해짐 ★
-DF_CPU=16000000UL"이 칩은 16 MHz 로 돈다"는 약속. 칩 클럭을 바꾸지는 않음
-Os크기 최적화. _delay_ms 는 최적화가 꺼지면 시간이 틀어짐
-c arduino프로그래머 종류 = Uno 의 부트로더(STK500 v1 프로토콜)
-p m328p대상 칩 (서명 0x1E950F 로 대조)
-P COM5 -b 115200포트와 속도. Uno 의 Optiboot 은 115200
-U flash:w:blink.hex:i메모리:동작(w 쓰기 · r 읽기 · v 검증):파일:형식(i = Intel HEX)
F_CPU 를 틀리게 주면 — -DF_CPU=8000000UL 로 빌드해 16 MHz 칩에 구우면 지연 루프가 절반만 돌아 500 ms 가 250 ms 가 됩니다. 시리얼 보레이트 계산도 같은 값을 쓰므로 UART 글자가 깨지는 원인이 되기도 합니다(📡 통신 탭).
ATmega328P 플래시 32 KB — 위는 전체(크기 비례), 아래는 앞 256 B 확대 플래시 전체 Optiboot 512 B ↓ 응용 프로그램 영역 — 32,256 B (0x0000 – 0x7DFF) 0x0000 0x7E00 벡터 표 26개 × 4 B = 104 B init main · delay 빈칸 = 0xFF (지운 상태) 0x0000 0x0068 0x0080 0x0100 앱만 보면 — 0x0000 의 첫 벡터(jmp 0x68) → 초기화 → main 순서로 돕니다 Uno 는 퓨즈 설정 때문에 리셋 직후 0x7E00 의 부트로더가 먼저 돕니다
.hex 에 담기는 것은 칠한 칸뿐입니다. 부트로더 512 B 를 빼면 내 프로그램 한도는 32,256 B 입니다.

업로드 버튼 뒤에서 — 부트로더와 avrdude 의 대화 🟡 중급

세로가 시간 — 1 ~ 3 이 1초 안에 맞물려야 4 가 성공합니다 avrdude (PC) 16U2 · USB-시리얼 Optiboot 내 프로그램 1. 포트 열기 = DTR 내림 2. RESET 핀 펄스 3. 부트로더 시작 · 약 1초 대기 4. 동기화 0x30 0x20 → 응답 0x14 0x10 5. 서명 1E 95 0F = ATmega328P 6. 128 B 페이지씩 쓰기 → 다시 읽어 검증 7. 0x0000 으로 점프 1 ~ 4 가 안 맞으면 'not in sync' — 포트 · 케이블 · 드라이버 문제입니다 (u13)
아두이노가 프로그래머 없이 USB 로 구워지는 이유가 부트로더입니다. 대신 그 512 B 와 리셋 타이밍을 대가로 냅니다.

Makefile — 매번 치지 않게

MCU     := atmega328p
F_CPU   := 16000000UL
# make flash PORT=COM7 처럼 바꿔 쓰기 (같은 줄에 주석을 달면 값 뒤에 공백이 붙습니다)
PORT    ?= COM5
CC      := avr-gcc
OBJCOPY := avr-objcopy
CFLAGS  := -mmcu=$(MCU) -DF_CPU=$(F_CPU) -Os -std=gnu17 -Wall -Wextra \
           -ffunction-sections -fdata-sections
LDFLAGS := -mmcu=$(MCU) -Wl,--gc-sections

blink.hex: blink.elf
	$(OBJCOPY) -O ihex -R .eeprom $< $@
	avr-size $<

blink.elf: blink.c
	$(CC) $(CFLAGS) $(LDFLAGS) $^ -o $@

flash: blink.hex
	avrdude -c arduino -p m328p -P $(PORT) -b 115200 -U flash:w:$<:i

.PHONY: flash

.hex 한 줄 읽기 (Intel HEX)

:10 0000 00 0C9434000C943E000C943E000C943E00 82
 │   │   │   │                                │
 │   │   │   └ 데이터 16 B                     └ 체크섬
 │   │   └ 레코드 종류 00 = 데이터 (01 = 파일 끝)
 │   └ 주소 0x0000
 └ 바이트 수 0x10 = 16

데이터 앞 4 B  0C 94 34 00  = jmp 0x0068   ← 리셋 벡터 (워드 주소 0x34 × 2)
다음 4 B       0C 94 3E 00  = jmp 0x007C   ← 안 쓰는 인터럽트 → __bad_interrupt

체크섬 = (모든 바이트 합의 하위 8비트)의 2의 보수
  0x10+0x00+0x00+0x00+0x0C+0x94+... 합의 하위 바이트 0x7E → 0x100-0x7E = 0x82

마지막 줄은 항상   :00000001FF   (파일 끝)
업로드 전 백업 — -U flash:r:backup.hex:i 로 지금 칩 내용을 먼저 읽어 두면, 통신이 되는지 확인도 되고 원래 프로그램으로 되돌릴 수도 있습니다.

실무에선 이렇게 — 부트로더 없이 · 퓨즈 조심 🔴 실무

ISP 프로그래머로 굽기 (USBasp · Arduino as ISP)

# 6핀 ICSP 헤더(MISO · SCK · MOSI · RESET · VCC · GND)에 직접
avrdude -c usbasp -p m328p -U flash:w:blink.hex:i
#   부트로더를 덮어씁니다 → 0x7E00 까지 다 쓸 수 있지만
#   이후 USB 업로드(-c arduino)는 안 됩니다 (부트로더 다시 굽기 전까지)

# 다른 Uno 를 프로그래머로 (ArduinoISP 스케치를 올려 둔 상태)
avrdude -c stk500v1 -P COM5 -b 19200 -p m328p -U flash:w:blink.hex:i

# 퓨즈 읽기만 — 쓰기 전에 반드시 값을 기록
avrdude -c usbasp -p m328p -U lfuse:r:-:h -U hfuse:r:-:h -U efuse:r:-:h
#   Uno 공장값: lfuse 0xFF · hfuse 0xDE · efuse 0xFD (0xFD 는 0x05 로 보이기도 함)

벽돌을 만드는 두 가지 실수

① 클럭 퓨즈(CKSEL)를 '외부 클럭'으로 잘못 씀
   → 크리스털이 있어도 칩이 안 깨어남 → ISP 응답 없음
   → 복구: 다른 보드의 핀으로 클럭을 넣어 주거나 고전압 프로그래머

② hfuse 의 SPIEN 을 끄거나 RSTDISBL 을 켬
   → ISP 자체가 막힘 → 고전압(HV) 프로그래밍만 가능

예방
   · 퓨즈 계산은 웹 계산기(AVR fuse calculator)로 두 번 확인
   · avrdude -n (dry-run, 실제 쓰기 없음)으로 명령부터 확인
   · 학습용 Uno 는 퓨즈를 건드릴 일이 거의 없습니다
-c arduino 로는 퓨즈를 바꿀 수 없습니다 — 부트로더는 자기 자신과 퓨즈를 못 고칩니다. 그래서 USB 업로드만 하는 동안에는 칩을 벽돌로 만들 위험이 거의 없고, ISP 를 쓰기 시작할 때부터 조심하면 됩니다.
정리 — AVR 은 avr-gcc -mmcu=atmega328p -DF_CPU=16000000UL -Os → avr-objcopy -O ihex -R .eeprom → avrdude -c arduino -p m328p -P COM5 -b 115200 -U flash:w:blink.hex:i 세 줄입니다. -mmcu 는 칩을, F_CPU 는 클럭 약속을 정하고, Uno 는 부트로더 512 B 덕분에 프로그래머 없이 USB 로 구워집니다.
SET 07

ARM 툴체인 — arm-none-eabi-gcc 와 Cortex-M 플래그

STM32 · nRF · RP2040 · SAMD 등 Cortex-M 계열 칩은 전부 같은 컴파일러를 씁니다 — Arm 이 직접 배포하는 Arm GNU Toolchain. 칩 회사가 달라도 바뀌는 것은 플래그 몇 개와 링커 스크립트뿐입니다. 이 절은 그 플래그를 하나씩 뜯고, Nucleo-F446RE 용 .elf · .hex · .bin 을 손으로 만들어 봅니다.

설치 (윈도우)

# developer.arm.com → Arm GNU Toolchain Downloads
#   'Windows (mingw-w64-x86_64) hosted cross toolchains'
#   → AArch32 bare-metal target (arm-none-eabi) 의 .exe(설치) 또는 .zip
#   버전은 최신 안정 (2026 기준 14.x 이상)
#   설치 마지막 화면의 'Add path to environment variable' 체크 ★

arm-none-eabi-gcc --version
#  arm-none-eabi-gcc.exe (Arm GNU Toolchain 14.x.Rel1 ...) 14.x

# 이 툴체인이 미리 빌드해 둔 라이브러리 조합(multilib) 보기
arm-none-eabi-gcc -print-multi-lib | findstr v7e-m
#  thumb/v7e-m+fp/hard;@mthumb@march=armv7e-m+fp@mfloat-abi=hard
#  thumb/v7e-m+fp/softfp;...
STM32CubeCLT · CubeIDE 를 깔면 GCC 가 딸려 옵니다(u08). 그것을 써도 되지만, 여러 개가 PATH 에 섞이면 어느 것이 불리는지 헷갈립니다. 하나를 정해 PATH 에 두고 나머지는 IDE 안에서만 쓰게 두세요.

Cortex-M 플래그 — 한 줄 해부

플래그뜻
-mcpu=cortex-m4코어 종류 → 쓸 수 있는 명령(DSP · 나눗셈 등)과 파이프라인 튜닝
-mthumbThumb 명령어 세트. Cortex-M 은 Thumb 만 실행합니다(빠뜨리면 하드폴트)
-mfpu=fpv4-sp-d16F446 에 붙은 FPU 종류(단정밀도 float 만, 레지스터 16개)
-mfloat-abi=hardfloat 인자를 FPU 레지스터로 전달 + FPU 명령 사용
--specs=nano.specsC 라이브러리를 작은 newlib-nano 로
--specs=nosys.specsOS 호출(_write · _sbrk …)을 빈 껍데기로 채움
-ffunction-sections -fdata-sections
-Wl,--gc-sections
함수 · 변수를 따로 담고, 안 쓰는 것은 링커가 버림 → 크기 절감
-T STM32F446RETX_FLASH.ld링커 스크립트 — 플래시 · RAM 의 주소와 크기

손으로 빌드 — Nucleo-F446RE LED(PA5) 깜빡이기 🟢 기초

필요한 파일 다섯 개와 main.c

blink/
├─ Src/main.c                     ← 내가 쓰는 것
├─ Src/system_stm32f4xx.c         ← SystemInit (클럭 · FPU 켜기)
├─ Startup/startup_stm32f446xx.s  ← 벡터 표 · Reset_Handler
├─ STM32F446RETX_FLASH.ld         ← 링커 스크립트
└─ Drivers/CMSIS/...              ← 레지스터 정의 헤더
   (뒤의 넷은 STM32CubeMX 가 만든 프로젝트에서 그대로 가져옵니다 — u08)
#include "stm32f4xx.h"     // -DSTM32F446xx 로 F446 정의가 골라짐

static void delay(volatile uint32_t n) { while (n--) { } }

int main(void) {
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;   // GPIOA 에 클럭 공급
    (void)RCC->AHB1ENR;                    // 클럭이 실제로 켜질 때까지 한 번 읽기
    GPIOA->MODER = (GPIOA->MODER & ~GPIO_MODER_MODER5)
                 | GPIO_MODER_MODER5_0;    // PA5 = 범용 출력(01)
    for (;;) {
        GPIOA->ODR ^= GPIO_ODR_OD5;        // LD2 켜고 끄기
        delay(1000000);                    // 대략 0.5~1초 (클럭 · 최적화에 따라 다름)
    }
}

빌드 · 변환 · 크기

MCU="-mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16"

arm-none-eabi-gcc $MCU -DSTM32F446xx \
  -IDrivers/CMSIS/Include -IDrivers/CMSIS/Device/ST/STM32F4xx/Include \
  -std=gnu17 -O0 -g3 -Wall -Wextra -ffunction-sections -fdata-sections \
  Src/main.c Src/system_stm32f4xx.c Startup/startup_stm32f446xx.s \
  -T STM32F446RETX_FLASH.ld --specs=nano.specs --specs=nosys.specs \
  -Wl,--gc-sections -Wl,-Map=blink.map -o blink.elf

arm-none-eabi-objcopy -O binary blink.elf blink.bin   # 0x08000000 부터의 날 바이트
arm-none-eabi-objcopy -O ihex   blink.elf blink.hex

arm-none-eabi-size blink.elf
#    text    data     bss     dec     hex filename
#    1240      12    1572    2824     b08 blink.elf
#  플래시 사용 = text + data = 1252 B
#  RAM   사용 = data + bss  = 1584 B
#    (bss 중 1536 B 는 링커 스크립트가 잡아 둔 힙 0x200 + 스택 0x400)
숫자는 예시 빌드 값입니다. dec = 1240 + 12 + 1572 = 2824 = 0xB08 — 이 셈이 맞는지 직접 확인하는 습관이 메모리가 빠듯한 칩에서 큰 도움이 됩니다.
.elf 의 조각들이 칩 안 어디에 놓이나 — 크기 비례 아님, 순서만 플래시 512 KB .isr_vector .text — 코드 .rodata 상수 .data 초깃값 비어 있음 (0xFF) 0x0800 0000 0x0807 FFFF 스타트업 코드가 main 전에 복사 RAM 128 KB .data .bss → 0 채움 힙 → (malloc) ← 스택 0x2000 0000 0x2002 0000 = 첫 SP size 의 text 는 플래시에만, data 는 플래시와 RAM 둘 다, bss 는 RAM 에만 자리를 씁니다 그래서 플래시 = text + data, RAM = data + bss 로 셉니다
.data 는 두 번 자리를 차지합니다 — 초깃값은 플래시에, 실제 변수는 RAM 에. 이 복사가 "main 전에 일어나는 일"의 정체입니다.

한 단계 더 — float ABI · newlib-nano 🟡 중급

내 코드(행) × 라이브러리(열) — 섞어도 링크되나 lib: soft lib: softfp lib: hard FPU 명령 내 코드 soft 링크 OK 링크 OK 링크 에러 안 씀 (느림) 내 코드 softfp 링크 OK 링크 OK 링크 에러 씀 · 인자 r0~r3 내 코드 hard ★ 링크 에러 링크 에러 링크 OK 씀 · 인자 s0~s15 에러 문구: "X uses VFP register arguments, Y does not" — 모든 .o · .a 를 같은 ABI 로 F446 처럼 FPU 가 있는 칩은 hard 가 정답, M0+ 처럼 FPU 가 없으면 soft 뿐입니다
float ABI 는 프로젝트 전체가 하나여야 합니다. 벤더가 준 .a 라이브러리가 어느 ABI 인지 먼저 확인하세요.

코어별 플래그 표 — 다른 칩으로 옮길 때

코어 (예)플래그
M0+ (STM32G0 · RP2040)-mcpu=cortex-m0plus -mthumb (FPU 없음)
M3 (STM32F103)-mcpu=cortex-m3 -mthumb (FPU 없음)
M4F (STM32F4 · G4 · L4)-mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard
M7 (STM32F7, 단정밀도)-mcpu=cortex-m7 -mthumb -mfpu=fpv5-sp-d16 -mfloat-abi=hard
M7 (STM32H7, 배정밀도)-mcpu=cortex-m7 -mthumb -mfpu=fpv5-d16 -mfloat-abi=hard
M33 (STM32U5 · nRF5340)-mcpu=cortex-m33 -mthumb -mfpu=fpv5-sp-d16 -mfloat-abi=hard
FPU 는 플래그만으로 켜지지 않습니다 — 리셋 직후 FPU 는 꺼져 있고, SystemInit() 이 CPACR 레지스터로 켭니다. -mfloat-abi=hard 로 빌드했는데 system_stm32f4xx.c 를 빼먹으면 첫 float 연산에서 UsageFault 가 납니다.

newlib vs newlib-nano · printf 로 float 찍기

newlib (기본)newlib-nano
printf 하나 넣었을 때수십 KB수 KB
%f 지원기본 지원기본 꺼짐
malloc빠르고 큼작고 단순
선택(아무것도 안 줌)--specs=nano.specs
# nano 에서 printf("%f") 가 빈칸으로 나오면 → 링크 플래그 추가
-u _printf_float          # 약 수 KB 늘어남
-u _scanf_float

# 어떤 함수가 플래시를 많이 먹나 — 큰 것부터
arm-none-eabi-nm --size-sort -S -C blink.elf | tail -10
arm-none-eabi-objdump -d -S blink.elf > blink.lst   # 소스 + 어셈블리

최근 툴체인은 nosys.specs 를 쓰면 _close is not implemented and will always fail 같은 링커 경고를 냅니다 — 실제로 그 함수를 부르지 않는다면 무시해도 되고, printf 를 UART 로 보내려면 _write 를 직접 구현합니다(📡 통신 탭).

실무에선 이렇게 — 툴체인 버전을 고정합니다 🔴 실무

"내 PC 에서는 되는데"를 막는 방법

# 툴체인 파일에서 버전을 검사 — 다르면 구성 단계에서 멈춤
execute_process(COMMAND ${CMAKE_C_COMPILER} -dumpversion
                OUTPUT_VARIABLE GCC_VER OUTPUT_STRIP_TRAILING_WHITESPACE)
if(NOT GCC_VER VERSION_EQUAL "14.2.1")
  message(WARNING "팀 표준은 Arm GNU Toolchain 14.2.Rel1 입니다 (현재 ${GCC_VER})")
endif()
왜 고정하나 — GCC 메이저 버전이 바뀌면 최적화 결과가 달라져 코드 크기가 수 % 바뀌고, 타이밍에 민감한 코드나 volatile 을 빼먹은 코드가 갑자기 다르게 동작합니다. CI 는 Docker 이미지에 같은 버전을 넣어 둡니다(🚀 양산 탭).

.map 파일 — 링크 결과의 회계 장부

Memory Configuration
Name             Origin             Length
RAM              0x20000000         0x00020000      ← 128 KB
FLASH            0x08000000         0x00080000      ← 512 KB

.text           0x080001c4      0x2f4
 .text.main     0x080001c4       0x48 build/Src/main.o
                0x080001c4                main
 .text.SystemInit
                0x0800020c       0x24 build/Src/system_stm32f4xx.o

# '어떤 .o 의 어떤 함수가 어느 주소에 몇 바이트' — 크기 다이어트의 출발점
# 특정 함수가 안 보이면 --gc-sections 가 버린 것 (안 쓰였다는 뜻)
정리 — Cortex-M 은 Arm GNU Toolchain(arm-none-eabi-gcc) 하나로 끝나고, 칩마다 바뀌는 건 -mcpu · -mfpu · -mfloat-abi 와 링커 스크립트입니다. F446 은 -mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16. objcopy 로 .hex · .bin 을 뽑고, size 로 플래시 = text + data, RAM = data + bss 를 확인합니다.
SET 08

STM32 도구 — Cube 가 붙은 다섯 가지와 고르는 법

ST 는 이름이 비슷한 도구를 여럿 냅니다 — CubeMX · CubeIDE · CubeCLT · CubeProgrammer, 그리고 VS Code 확장. 처음 보면 전부 깔아야 할 것 같지만, 각자 맡은 칸이 다르고 대부분 겹칩니다. 이 절은 칸을 나눠 보고 세 가지 조합 중 하나를 고르게 합니다.

ST 도구 다섯 — 진한 칸(●)이 그 도구가 직접 하는 일입니다 설정 · 생성 편집 빌드 굽기 디버그 CLI · CI STM32CubeMX ● — — — — △ STM32CubeIDE △ 2.x 분리 ● ● ● ● △ STM32CubeCLT — — ● ● ● ● CubeProgrammer — — — ● — ● VS Code + STM32 확장 — ● ● CLT 사용 ● CLT 사용 ● CLT 사용 — CubeMX 는 '설정 · 생성'만, CubeCLT 는 '편집기 없는 나머지 전부' — 이 둘이 뼈대입니다 CubeIDE 는 둘을 이클립스 안에 묶은 것, VS Code 확장은 CubeCLT 를 VS Code 에 이은 것
열 하나에 ● 가 여럿이면 둘 중 하나만 있으면 됩니다. 가장 겹치지 않는 조합이 CubeMX + CubeCLT 입니다.

다섯 도구 — 한 줄씩

도구정체
STM32CubeMX칩 그림에서 핀 · 클럭 · 주변장치를 고르면 초기화 C 코드와 프로젝트(CMake · CubeIDE · Makefile 형식)를 생성
STM32CubeIDE이클립스 기반 통합 IDE. GCC · GDB · ST-LINK 서버가 들어 있어 설치 하나로 끝. 최신 2.x 는 CubeMX 를 따로 설치
STM32CubeCLT"Command Line Tools" — GCC · GDB · ST-LINK GDB 서버 · Programmer CLI · CMake · Ninja · SVD 파일 묶음
STM32CubeProgrammer굽기 · 지우기 · 옵션 바이트 · ST-LINK 펌웨어 업데이트. GUI 와 STM32_Programmer_CLI
VS Code STM32 확장"STM32Cube for Visual Studio Code" — CubeMX 가 만든 CMake 프로젝트를 열어 빌드 · 굽기 · 디버그
모두 st.com 에서 무료이고 다운로드에 ST 계정(myST) 이 필요합니다. 윈도우 · 리눅스 · 맥 설치 파일이 따로 있습니다. CubeIDE · CubeCLT 는 설치 중 ST-LINK USB 드라이버도 같이 깔 수 있습니다 — 체크해 두세요(u13).

CubeMX 로 Nucleo-F446RE 프로젝트 만들기

1. File → New Project → Board Selector → "NUCLEO-F446RE"
   → "Initialize all peripherals with their default Mode?" → Yes
     (LD2 = PA5, B1 = PC13, USART2 = ST-LINK 가상 COM 이 미리 잡힘)

2. Pinout & Configuration
   System Core → SYS → Debug = "Serial Wire"   ★ 반드시 확인 (u13)
   Clock Configuration 탭 → HCLK 180 MHz 까지 가능

3. Project Manager
   Project Name = blink
   Toolchain / IDE = CMake        (VS Code · CLT 조합)
                   = STM32CubeIDE (CubeIDE 조합)
   Code Generator → "Generate peripheral initialization as a pair of .c/.h"

4. GENERATE CODE

생성 결과 (CMake 선택 시)
blink/
├─ Core/Inc  Core/Src          ← main.c · 인터럽트 · HAL 설정
├─ Drivers/CMSIS  Drivers/STM32F4xx_HAL_Driver
├─ startup_stm32f446xx.s   STM32F446XX_FLASH.ld
├─ CMakeLists.txt  CMakePresets.json
└─ cmake/gcc-arm-none-eabi.cmake       ← u05 의 툴체인 파일과 같은 역할
USER CODE 규칙 — 처음에 가장 많이 날리는 것 — CubeMX 가 만든 파일에는 /* USER CODE BEGIN 2 */ ~ /* USER CODE END 2 */ 같은 표시가 있습니다. 그 사이에 쓴 코드만 다시 생성해도 살아남고, 바깥에 쓴 코드는 핀을 하나 바꿔 다시 생성하는 순간 사라집니다. 내 로직은 아예 app/ 같은 별도 폴더에 두고 main.c 의 USER CODE 안에서 app_init() · app_loop() 만 부르는 것이 실무 습관입니다.

어떤 조합을 고르나 — 셋 중 하나 🟢 기초

STM32 도구 조합 고르기 — 어느 길이든 CubeMX 가 만든 코드는 같습니다 버튼 하나로 끝내고 싶은가? 예 STM32CubeIDE 처음 · 혼자 · ST 칩만 아니오 VS Code 가 손에 익었나? 예 CubeMX + CubeCLT + VS Code STM32 확장 ★ 아니오 CubeMX + Arm GNU + OpenOCD · 아무 편집기 설치가 가장 쉽고 자료도 가장 많습니다 CI · 팀 협업에 맞고 다른 회사 칩과 같은 방식 벤더 도구에 묶이지 않는 순수 오픈소스 조합 — u05 의 CMake 와 u09 의 OpenOCD 로 끝까지 갑니다 학습은 CubeIDE 로 시작해 프로젝트가 커지면 CMake 조합으로 옮기는 경우가 가장 흔합니다
처음엔 CubeIDE, 팀 · CI 를 생각하면 CubeMX + CubeCLT + VS Code. 프로젝트 형식(CMake)은 셋이 공유합니다.

명령줄로 굽기 — STM32_Programmer_CLI 🟡 중급

자주 쓰는 다섯 줄

# 연결 확인 (보드 정보 · ST-LINK 시리얼 · 칩 ID 출력)
STM32_Programmer_CLI -c port=SWD

# .elf 굽기 → 검증 → 리셋해서 실행  (elf 는 주소를 알고 있음)
STM32_Programmer_CLI -c port=SWD -w build/blink.elf -v -rst

# .bin 은 시작 주소를 직접 줘야 합니다
STM32_Programmer_CLI -c port=SWD -w blink.bin 0x08000000 -v -rst

# 전체 지우기
STM32_Programmer_CLI -c port=SWD -e all

# 연결이 안 될 때 — 리셋을 누른 채로 붙기 (u13)
STM32_Programmer_CLI -c port=SWD mode=UR -e all

도구 없이 굽기 — Nucleo 의 USB 드라이브

# Nucleo 를 꽂으면 'NODE_F446RE' 라는 USB 드라이브가 생깁니다
#   (ST-LINK 의 대용량 저장 장치 기능)
Copy-Item build\blink.bin E:\
#   → LED 가 잠깐 깜빡인 뒤 자동으로 구워지고 리셋됩니다
#   → 실패하면 드라이브에 FAIL.TXT 가 생기고 이유가 적혀 있습니다

# .bin 만 됩니다 (보드에 따라 .hex 도 됨). .elf 는 안 됩니다
# 디버그는 안 되지만 '남에게 펌웨어만 넘길 때' 가장 쉬운 방법
ST-LINK 펌웨어 업데이트 — CubeProgrammer · CubeIDE 가 "ST-LINK firmware is old" 를 띄우면 CubeProgrammer 의 Firmware upgrade 버튼으로 올리세요. 오래된 펌웨어는 새 칩을 모르거나 가상 COM 이 불안정합니다.

실무에선 이렇게 — CubeMX 가 만든 CMake 프로젝트를 CLI 로 🔴 실무

CubeCLT 경로로 처음부터 끝까지

# CubeCLT 기본 설치 경로 (버전 폴더 이름은 설치본마다 다름)
#   C:\ST\STM32CubeCLT_1.x.x\GNU-tools-for-STM32\bin   ← arm-none-eabi-gcc
#   C:\ST\STM32CubeCLT_1.x.x\STM32CubeProgrammer\bin   ← STM32_Programmer_CLI
#   C:\ST\STM32CubeCLT_1.x.x\CMake\bin  ·  ...\Ninja\bin
#   설치 프로그램이 PATH 에 넣어 주는 것이 기본입니다

cd blink
cmake --preset Debug               # CubeMX 가 만든 CMakePresets.json 사용
cmake --build --preset Debug
STM32_Programmer_CLI -c port=SWD -w build\Debug\blink.elf -v -rst

어떤 조합이든 커밋할 것 · 말 것

파일커밋
blink.ioc (CubeMX 설정)예 ★ — 핀 · 클럭 설정의 원본
Core/ · app/예
Drivers/ (HAL · CMSIS)팀 선택 — 보통 커밋(재생성 버전 차이 방지)
CMakeLists.txt · 프리셋예
build/ · Debug/아니오
.metadata/ (CubeIDE 작업 공간)아니오 — 개인 설정
CubeMX 버전을 팀이 맞추세요 — .ioc 를 새 버전 CubeMX 로 열면 "마이그레이션" 을 묻고, 수락하면 HAL 버전이 올라가 코드 생성 결과가 달라집니다. 버전 업은 따로 커밋해서 차이를 확인합니다.
정리 — CubeMX 는 설정 · 코드 생성, CubeCLT 는 편집기 없는 빌드 · 굽기 · 디버그, CubeIDE 는 그 둘을 묶은 IDE, CubeProgrammer 는 굽기 전용, VS Code 확장 은 CubeCLT 의 앞단입니다. 처음엔 CubeIDE, 팀 · CI 는 CubeMX(CMake 생성) + CubeCLT + VS Code. 내 코드는 USER CODE 안이나 별도 폴더에 둡니다.
SET 09

디버그 프로브 — ST-LINK · J-Link · CMSIS-DAP 와 SWD 배선

PC 의 gdb 는 칩 안을 직접 볼 수 없습니다. 그 사이에 USB 를 SWD(Serial Wire Debug) 신호로 바꾸는 작은 하드웨어가 있어야 하고, 그것이 디버그 프로브입니다. Nucleo 에는 보드 위쪽에 ST-LINK 가 붙어 있어 USB 케이블 하나로 끝나지만, 직접 만든 보드나 Blue Pill 을 쓰려면 선 4~5개를 직접 이어야 합니다.

SWD 배선 — 필수 3선 + 기준 전압 1선, 리셋은 강력 권장 Nucleo ST-LINK 부 · CN4 대상 보드 (STM32) 1 VDD_TARGET 2 SWCLK 3 GND 4 SWDIO 5 NRST 6 SWO 3V3 (VDD) PA14 · SWCLK GND PA13 · SWDIO NRST PB3 · SWO 기준 전압 — 전원 공급 아님 필수 · 클럭 필수 · 공통 접지 필수 · 데이터 (양방향) 권장 · 막힌 칩 살리기 선택 · printf 추적 (SWV) 이 배선을 쓰려면 Nucleo 의 CN2 점퍼 두 개를 뺍니다 (끼우면 보드 위 F446 에 연결) 선은 20 cm 이내로 — 길면 SWD 속도를 낮춰야 붙습니다
SWD 는 SWCLK · SWDIO · GND 세 선이 본체입니다. NRST 까지 이으면 잘못된 펌웨어로 막힌 칩도 살릴 수 있습니다.

프로브 비교

프로브장점주의
ST-LINK/V2-1
(Nucleo-F446RE 내장)
추가 비용 0 · 가상 COM · USB 드라이브 굽기ST 칩 전용(공식 지원 기준)
ST-LINK/V3빠름 · 브리지(I²C/SPI) 기능최신 펌웨어 필요
J-Link (SEGGER)거의 모든 Cortex-M · 빠름 · RTT비쌈 · 학습용 EDU 판은 상업 사용 금지
CMSIS-DAP오픈 표준 · 드라이버 불필요 (Raspberry Pi Debug Probe · DAPLink)제품마다 속도 차이
ST-LINK V2 '클론' 동글아주 쌈SWO · NRST 가 없거나 핀 표기가 틀린 경우 많음
Nucleo 의 ST-LINK 를 J-Link 로 — SEGGER 의 "ST-LINK On-Board 변환 도구"로 펌웨어를 바꾸면 J-Link 처럼 쓸 수 있습니다. 단 라이선스상 ST 보드 위에서 ST 칩에만 쓸 수 있고, 되돌리는 도구도 있습니다.

표준 10핀 디버그 커넥터 (1.27 mm, Cortex Debug)

        ┌────────────┐
 VTref  │ 1        2 │  SWDIO
   GND  │ 3        4 │  SWCLK
   GND  │ 5        6 │  SWO
   KEY  │ 7        8 │  NC (JTAG 의 TDI)
GNDDet  │ 9       10 │  nRESET
        └────────────┘   ▲ 1번 핀 표시 = 케이블의 빨간 줄

J-Link · 많은 상용 보드가 이 배치를 씁니다.
J-Link 의 큰 20핀(2.54 mm)과 섞이면 어댑터가 필요합니다.

Nucleo CN4 (6핀 · 2.54 mm):
  1 VDD_TARGET  2 SWCLK  3 GND  4 SWDIO  5 NRST  6 SWO
VTref 로 대상 보드에 전원을 넣지 마세요 — 프로브는 그 핀으로 대상의 전압을 재서 신호 레벨을 맞출 뿐입니다. 대상 보드는 자기 전원(USB · 어댑터)으로 켜고, 두 전원의 3.3 V 끼리 연결하지 않는 것이 원칙입니다 — GND 만 공통으로.

GDB 서버 — OpenOCD · pyOCD · ST-LINK 서버 🟡 중급

OpenOCD — 굽기와 디버그 서버 겸용

# 설치: 윈도우는 xPack OpenOCD (github.com/xpack-dev-tools/openocd-xpack)
#        zip 을 풀고 bin 을 PATH 에.  리눅스 apt install openocd · 맥 brew

# ① 굽고 끝내기 — 한 줄
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg \
        -c "program build/blink.elf verify reset exit"
#   보드 설정 한 장으로도 됨: -f board/st_nucleo_f4.cfg

# ② 디버그 서버로 띄워 두기 (gdb 는 3333, telnet 은 4444 포트)
openocd -f board/st_nucleo_f4.cfg

# 다른 창에서
arm-none-eabi-gdb build/blink.elf
(gdb) target extended-remote :3333
(gdb) monitor reset halt        # 칩을 리셋하고 멈춤
(gdb) load                      # 플래시에 굽기
(gdb) break main
(gdb) continue

서버 고르기 — 프로브에 따라

GDB 서버쓰는 프로브기본 포트
OpenOCDST-LINK · CMSIS-DAP · J-Link 등 거의 전부3333
pyOCD (파이썬)CMSIS-DAP · ST-LINK · J-Link3333
ST-LINK GDB serverST-LINK (CubeIDE · CubeCLT 내장)61234
J-Link GDB ServerJ-Link2331
# pyOCD — pip 로 설치, 칩 팩을 한 번 받아 둠
pip install pyocd
pyocd pack install stm32f446re
pyocd list                              # 연결된 프로브
pyocd flash -t stm32f446retx build/blink.elf
pyocd gdbserver -t stm32f446retx        # :3333 에서 대기

실무에선 이렇게 — VS Code 에서 F5 로 칩 디버그 🔴 실무

launch.json 에 Cortex-Debug 설정 추가

{
  "name": "F446: OpenOCD",
  "type": "cortex-debug",
  "request": "launch",
  "servertype": "openocd",
  "cwd": "${workspaceFolder}",
  "executable": "build/f446/blink.elf",
  "configFiles": ["board/st_nucleo_f4.cfg"],
  "svdFile": "${workspaceFolder}/STM32F446.svd",
  "runToEntryPoint": "main",
  "preLaunchTask": "build"
}
// preLaunchTask 는 build/f446 을 만드는 태스크여야 합니다 (예: cmake --build --preset f446)
// ST-LINK GDB 서버를 쓰려면  "servertype": "stlink"
// J-Link 이면  "servertype": "jlink", "device": "STM32F446RE"
// arm-none-eabi-gdb 가 PATH 에 없으면  "armToolchainPath": "C:/.../bin"

SVD 파일 — 레지스터를 이름으로 보기

SVD 는 칩 회사가 주는 "레지스터 지도" XML입니다. Cortex-Debug 에 넣어 두면 디버그 중에 GPIOA → ODR → OD5 처럼 주변장치 레지스터를 이름과 비트 단위로 보고 고칠 수 있습니다. F446 용은 CubeCLT 설치 폴더의 STMicroelectronics_CMSIS_SVD 안이나 ST 웹의 칩 페이지(CAD Resources)에 있습니다.

디버거로 멈춘 동안에도 주변장치는 돕니다 — 브레이크포인트에 걸려도 타이머 · UART 수신은 계속돼서 "디버깅할 때만 데이터가 깨지는" 일이 생깁니다. STM32 는 DBGMCU 설정으로 디버그 정지 중 타이머를 멈출 수 있습니다(🐞 디버깅 탭).
정리 — 프로브는 USB 를 SWD(SWCLK · SWDIO · GND + VTref, 권장 NRST) 로 바꾸는 하드웨어입니다. Nucleo 는 ST-LINK 가 내장이라 케이블 하나면 되고, 외부 보드는 CN2 점퍼를 빼고 CN4 에서 선을 땁니다. PC 쪽은 OpenOCD(또는 pyOCD · ST-LINK 서버) 가 GDB 서버가 되고, VS Code 는 Cortex-Debug 로 F5 디버그를 합니다.
SET 10

PlatformIO — 여러 보드를 한 프로젝트 형식으로

지금까지는 칩마다 툴체인을 손으로 깔았습니다. PlatformIO 는 그것을 패키지 관리자처럼 처리합니다 — platformio.ini 에 "Uno, 아두이노 프레임워크"라고 적으면 맞는 컴파일러 · 라이브러리 · 업로더를 알아서 받아 옵니다. 서버 개발자에게는 Gradle · npm 과 같은 경험이라 진입이 가장 쉽습니다.

ini 하나 · 환경 셋 — 같은 src/ 를 서로 다른 칩용으로 빌드합니다 platformio.ini [env:uno] atmelavr · arduino 업로드 = avrdude [env:uno_bare] atmelavr · 프레임워크 없음 main() 직접 — u06 과 같음 [env:nucleo_f446re] ststm32 · stm32cube 업로드 · 디버그 = stlink …/uno/firmware.hex …/uno_bare/firmware.hex …/nucleo_f446re/firmware.elf 결과물은 .pio/build/<환경 이름>/ 아래에 따로 — 환경끼리 섞이지 않습니다 pio run -e nucleo_f446re -t upload 처럼 -e 로 하나만 골라 빌드 · 업로드합니다
PlatformIO 의 단위는 환경(env) 입니다. 보드를 바꾸는 일이 ini 의 한 블록을 추가하는 일이 됩니다.

설치 — 둘 중 하나

# ① VS Code 확장 'PlatformIO IDE' (platformio.platformio-ide)
#    설치하면 PlatformIO Core 와 전용 파이썬 환경을 스스로 받습니다
#    왼쪽 막대의 개미 머리 아이콘 → New Project

# ② 명령줄만 (CI · 다른 편집기)
pip install -U platformio        # 또는 pipx install platformio
pio --version

# 새 프로젝트
mkdir blink-pio; cd blink-pio
pio project init --board uno --board nucleo_f446re
이미 설치한 툴체인과는 별개입니다 — PlatformIO 는 %USERPROFILE%\.platformio\packages 에 자기 avr-gcc · arm-none-eabi-gcc 를 따로 받습니다. 버전이 Arm 공식 최신보다 늦을 수 있으니, 특정 버전이 필요하면 platform_packages 로 바꿔 끼웁니다.

platformio.ini — 세 환경 + PC 테스트

[platformio]
default_envs = uno

[env]                           ; 모든 환경 공통
build_flags = -Wall -Wextra
monitor_speed = 115200
; src/ 는 모든 env 가 함께 씁니다 — env 마다 소스가 다르면(uno · uno_bare · f446) build_src_filter 로 나눕니다

[env:uno]
platform = atmelavr
board = uno
framework = arduino
upload_port = COM5              ; 생략하면 자동 탐색

[env:uno_bare]                  ; 프레임워크 없이 레지스터 직접 (u06 의 blink.c)
platform = atmelavr
board = uno
board_build.f_cpu = 16000000L

[env:nucleo_f446re]
platform = ststm32
board = nucleo_f446re
framework = stm32cube           ; HAL. arduino · cmsis · libopencm3 · zephyr 도 가능
upload_protocol = stlink
debug_tool = stlink

[env:native]                    ; PC 에서 로직 단위 테스트 (보드 불필요 · PC 의 gcc 사용 — u02)
platform = native
test_framework = unity

자주 쓰는 명령 — pio 다섯 개 🟢 기초

명령줄

pio run                          # default_envs 빌드
pio run -e nucleo_f446re         # 한 환경만
pio run -e uno -t upload         # 빌드 + 업로드
pio device list                  # 연결된 시리얼 포트 목록 ★ COM 번호 찾기
pio device monitor -b 115200     # 시리얼 모니터 (Ctrl+C 종료)
pio test -e native               # PC 에서 test/ 아래 단위 테스트
pio run -t compiledb             # compile_commands.json (u04 의 clangd 용)
pio debug -e nucleo_f446re --interface=gdb -x .pioinit   # 명령줄 디버그

폴더 규칙

폴더들어가는 것
src/main.c(또는 main.cpp) · 내 소스
include/프로젝트 헤더
lib/내가 쓴 재사용 모듈 (폴더 하나 = 라이브러리 하나)
test/Unity 단위 테스트 (test_ring/test_main.c)
.pio/빌드 산출물 · 받은 라이브러리 — 커밋 안 함

외부 라이브러리는 ini 에 lib_deps = 이름@^버전 으로 적으면 레지스트리에서 받아 옵니다 — npm 의 dependencies 와 같습니다.

실무에선 이렇게 — 어디까지 PlatformIO 로 가나 🔴 실무

PlatformIO vs CubeIDE vs 직접 CMake

PlatformIOCubeIDECMake 직접
여러 회사 칩가장 쉬움ST 만가능 (손이 감)
설치자동설치 하나도구마다
CubeMX 연동약함가장 좋음좋음 (CMake 생성)
툴체인 버전 통제플랫폼 버전으로IDE 버전에 묶임완전히 내 것
양산 · 인증 프로젝트드묾있음가장 흔함

버전 고정 — 재현 가능한 빌드

[env:nucleo_f446re]
; 플랫폼 버전을 고정하지 않으면 업데이트 때 툴체인 · HAL 이 같이 바뀝니다
platform = ststm32@~19.0.0      ; 예: 19.0.x 안에서만 (실제 버전은 pio pkg list 로 확인)
board = nucleo_f446re
framework = stm32cube
lib_deps =
    bblanchon/ArduinoJson@^7     ; 라이브러리도 범위 고정 (arduino 환경 예)
학습 · 시제품은 PlatformIO, 제품은 CMake 로 가는 팀이 많습니다. PlatformIO 로 시작해도 src/ 의 C 코드는 그대로 옮겨지므로, 처음 선택을 너무 고민하지 않아도 됩니다.
정리 — PlatformIO 는 툴체인 · 프레임워크 · 업로더를 자동으로 받아 주는 빌드 도구이고, platformio.ini 의 [env:이름] 블록 하나가 보드 하나입니다. pio run -e uno -t upload · pio device monitor · pio test -e native 세 개면 굽기 · 시리얼 · PC 테스트가 끝납니다. 버전은 platform = 이름@~버전 으로 고정합니다.
SET 11

하드웨어 없이 연습 — 시뮬레이터로 어디까지 되나

보드가 오기 전에도, 출장 중에도, CI 서버에서도 펌웨어를 돌려 볼 수 있습니다. 다만 시뮬레이터마다 잘하는 칸이 전혀 다릅니다 — 브라우저에서 LED 를 보는 것, 회로의 전압을 보는 것, CPU 명령을 정확히 돌리는 것은 서로 다른 도구의 몫입니다. 그리고 시뮬레이터에서 된다고 실제 보드에서 되는 것은 아닙니다.

시뮬레이터 다섯 — ● 지원 · △ 일부 · — 없음 AVR STM32 외부 부품 UART GDB 설치 없이 CI 자동화 Wokwi ● △ 일부 ● ● ● VS Code ● 브라우저 ● CLI SimulIDE ● △ 일부 ● 회로 ● △ 자체 — — simavr ● — △ 코드로 ● ● — ● QEMU ● uno △ F405 등 — ● ● — ● Renode — ● F4 등 △ 센서 모델 ● ● — ● 눈으로 배우기는 Wokwi · SimulIDE, 자동 테스트(CI)는 simavr · QEMU · Renode 쪽입니다 지원 보드 목록은 자주 바뀝니다 — 쓰기 전에 각 도구의 문서에서 칩 이름을 확인하세요
위 두 줄은 "보는" 도구, 아래 세 줄은 "돌리는" 도구입니다. 학습엔 Wokwi, 회귀 테스트엔 Renode · QEMU.

Wokwi — 브라우저에서 Uno + LED (wokwi.com)

// diagram.json — 부품과 배선을 글로 적습니다 (화면에서 끌어다 놓아도 생성됨)
{
  "version": 1,
  "parts": [
    { "type": "wokwi-arduino-uno", "id": "uno", "top": 0, "left": 0, "attrs": {} },
    { "type": "wokwi-resistor", "id": "r1", "top": -40, "left": 130,
      "attrs": { "value": "220" } },
    { "type": "wokwi-led", "id": "led1", "top": -110, "left": 180,
      "attrs": { "color": "red" } }
  ],
  "connections": [
    [ "uno:13",  "r1:1",      "green", [] ],
    [ "r1:2",    "led1:A",    "green", [] ],
    [ "led1:C",  "uno:GND.1", "black", [] ]
  ]
}
# VS Code 의 Wokwi 확장 — 내가 avr-gcc 로 만든 결과물을 그대로 돌림
# 프로젝트 루트의 wokwi.toml
[wokwi]
version = 1
firmware = "build/blink.hex"
elf = "build/blink.elf"
gdbServerPort = 3333          # avr-gdb 로 붙어 한 줄씩 실행

명령줄 시뮬레이터 셋

# simavr — AVR 명령을 사이클 단위로 (리눅스 · WSL: apt install simavr)
simavr -m atmega328p -f 16000000 blink.elf
simavr -m atmega328p -f 16000000 -g blink.elf      # gdb 대기 (포트 1234)
avr-gdb blink.elf -ex "target remote :1234"

# QEMU — AVR Uno 머신
qemu-system-avr -machine uno -bios blink.elf -nographic
#   UART0 출력이 터미널에 찍힘. -s -S 를 붙이면 gdb(:1234) 대기

# QEMU — Cortex-M4 순수 로직 (보드 주변장치 없이 CPU 만)
#   mps2-an386 용 벡터 표(startup.c)와 링커 스크립트(mps2.ld)를 함께 넣습니다
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb --specs=rdimon.specs \
    -T mps2.ld startup.c test_ring.c ring.c -o test.elf
qemu-system-arm -M mps2-an386 -nographic -semihosting -kernel test.elf
#   printf 가 '세미호스팅'으로 PC 터미널에 나옵니다 → ARM 명령으로 단위 테스트
세미호스팅 = 칩(또는 에뮬레이터)이 디버거를 통해 PC 의 printf · 파일을 빌려 쓰는 방식입니다. 실제 보드에서도 OpenOCD 의 arm semihosting enable 로 되지만 아주 느리고 디버거 없이는 멈추므로 테스트 전용입니다.

STM32 를 통째로 — Renode 🟡 중급

blink.resc — 칩 하나를 스크립트로 조립

# renode.io 에서 설치 (윈도우 설치본 · 리눅스 패키지 · 맥)
# renode blink.resc  로 실행

mach create "f4"
machine LoadPlatformDescription @platforms/boards/stm32f4_discovery-kit.repl
sysbus LoadELF @build/blink.elf
showAnalyzer sysbus.usart2          # UART 출력 창
machine StartGdbServer 3333         # arm-none-eabi-gdb 로 붙기
start

F446 전용 보드 파일이 없더라도 같은 F4 계열의 플랫폼 설명(.repl)을 고쳐 쓰면 됩니다. Renode 는 여러 칩을 한 시뮬레이션에 넣고 UART · 네트워크로 잇는 것이 강점이라 "센서 노드 10개 + 게이트웨이" 같은 시스템 테스트를 CI 에서 돌리는 데 쓰입니다(Robot Framework 연동).

SimulIDE — 회로를 그리고 hex 를 넣기

SimulIDE 는 회로 시뮬레이터 + MCU 시뮬레이터입니다. 브레드보드처럼 저항 · LED · 포텐셔미터 · 오실로스코프를 놓고 ATmega328P 에 내가 만든 .hex 를 올리면 핀 전압이 실제로 오르내리는 것을 봅니다. "풀업 저항이 없으면 버튼 입력이 왜 떠다니나" 같은 전기 쪽 직관을 기르기에 가장 좋습니다(🔩 하드웨어 기초 탭).

처음 2주 추천 루트 — Wokwi 로 아두이노 예제 → u06 의 blink.hex 를 Wokwi(VS Code)로 → SimulIDE 로 풀업 · 분압 회로 → 보드 도착 후 같은 hex 를 실제로 굽기.

실무에선 이렇게 — 시뮬레이터가 거짓말하는 곳 🔴 실무

항목시뮬레이터실제 보드
타이밍대개 '대략' — 사이클 정확한 건 simavr 정도클럭 오차(내부 RC 는 ±1~2 %), 인터럽트 지연이 섞임
전기이상적인 전압 · 노이즈 없음버튼 채터링 · 긴 선의 노이즈 · 전원 강하
주변장치모델이 있는 것만, 에러 플래그는 거의 안 남오버런 · 프레이밍 에러 · DMA 경합이 실제로 남
초기값RAM 이 0 으로 시작하는 경우가 많음RAM 은 쓰레기 값 — 초기화 안 한 변수가 여기서 드러남
USB · 무선대부분 없음열거 · 드라이버 · 전파 환경
전력안 봄슬립 전류 · 배터리 수명 (🚀 양산 탭)
시뮬레이터 통과 = 로직이 맞다는 뜻까지만 — "시뮬레이터에서는 되는데 보드에서는 안 된다"의 대부분은 초기화하지 않은 변수 · volatile 누락 · 타이밍 가정입니다. 시뮬레이터는 순수 로직 회귀 테스트에 쓰고, 하드웨어 의존 부분은 반드시 실제 보드(가능하면 로직 분석기와 함께 — u12)로 확인합니다.
정리 — 눈으로 배우는 건 Wokwi(브라우저 · VS Code) 와 SimulIDE(회로), 자동으로 돌리는 건 simavr(AVR) · QEMU(uno · mps2-an386 세미호스팅) · Renode(STM32 · 다중 칩) 입니다. 시뮬레이터는 로직까지만 보증합니다 — 타이밍 · 전기 · 초기값 문제는 실제 보드에서만 드러납니다.
SET 12

첫 구매 가이드 — 보드 두 장과 측정 도구 둘

처음 장바구니에서 가장 많이 하는 실수는 보드만 사고 측정 도구를 안 사는 것, 그리고 이름이 비슷한 다른 칩을 사는 것입니다. 이 절의 목록은 이 가이드의 모든 탭(AVR · STM32 · 통신 · RTOS)을 따라 하는 데 필요한 최소 세트입니다. 보드 선택의 배경은 C 실전 탭 c22 에도 있으니 여기서는 살 것과 피할 것만 봅니다.

입문 보드 RAM 비교 (KB) — 같은 '아두이노'라도 64배 차이가 납니다 0 32 64 96 128 KB Uno R3 · ATmega328P 2 KB · 플래시 32 KB · 16 MHz 8비트 AVR Blue Pill · STM32F103C8 20 KB · 64 KB · 72 MHz Cortex-M3 Uno R4 · RA4M1 32 KB · 256 KB · 48 MHz M4 — AVR 아님 Nucleo-F446RE 128 KB · 512 KB · 180 MHz M4F AVR 탭은 Uno R3, STM32 · RTOS 탭은 Nucleo-F446RE — 두 장이면 이 가이드 전체를 따라갑니다 FreeRTOS 는 2 KB RAM 에서도 돌지만, 태스크 몇 개만 넣어도 꽉 찹니다
막대는 0 에서 시작합니다 — Uno R3 의 2 KB 가 얼마나 작은지가 이 그림의 요점입니다.

최소 장바구니

품목왜 · 고를 때 볼 것
Arduino Uno R3 (호환품 가능)AVR 탭 전체. 칩이 ATmega328P 인지 확인. 호환품은 USB 칩이 CH340 (드라이버 필요)
Nucleo-F446RESTM32 · RTOS 탭. ST-LINK 내장이라 프로브를 따로 안 사도 됨
로직 분석기 24 MHz 8채널UART · I²C · SPI 신호를 눈으로 봄. 무료 PulseView(sigrok)로 해석
디지털 멀티미터전압 · 저항 · 통전(삐 소리). 자동 범위 기능이 편함
브레드보드 · 점퍼 선830홀 1~2장, 수-수 · 수-암 · 암-암 점퍼 각 한 묶음
저항 · LED 세트220 Ω · 330 Ω · 1 kΩ · 10 kΩ 넉넉히, LED 여러 색, 택트 스위치
데이터용 USB 케이블Uno R3 = USB-B, Nucleo-F446RE = Mini-B, Uno R4 = USB-C

LED 저항값 — 직접 계산해 보기

옴의 법칙  I = (전원 전압 − LED 순방향 전압) ÷ R
  (순방향 전압 Vf = LED 가 켜지려면 먹는 전압. 빨강 약 2.0 V)

Uno (5 V 핀), R = 220 Ω
  I = (5.0 − 2.0) ÷ 220 = 0.0136 A ≈ 13.6 mA      ✓ 핀 권장 20 mA 이하

STM32 (3.3 V 핀), R = 220 Ω
  I = (3.3 − 2.0) ÷ 220 = 0.0059 A ≈ 5.9 mA       ✓ 충분히 보임

저항 없이 바로 연결하면?
  I = (5.0 − 2.0) ÷ (거의 0) → 핀의 한계까지 흐름
  ATmega328P 핀 절대 최대 40 mA · STM32F4 핀 절대 최대 25 mA
  → LED 나 핀이 상합니다 ✗
서버 개발에 빗대면 — 저항은 rate limiter 입니다. 없으면 요청(전류)이 상한 없이 몰려 서버(핀)가 죽습니다. 전압 · 전류 · 저항의 기초는 🔩 하드웨어 기초 탭에서 처음부터 다룹니다.

이름이 같아서 틀리는 것 — Uno R3 vs R4 🟢 기초

Uno R4 는 AVR 이 아닙니다

Uno R3Uno R4 (Minima · WiFi)
칩ATmega328P (AVR 8비트)Renesas RA4M1 (ARM Cortex-M4)
클럭16 MHz48 MHz
플래시 · RAM32 KB · 2 KB256 KB · 32 KB
DDRB · PORTB있음없음 — 컴파일 에러
avr-gcc · avrdude그대로못 씀 (ARM 툴체인)
아두이노 함수됨됨 (그래서 헷갈림)

digitalWrite 수준 코드는 둘 다 돌지만, 이 가이드의 AVR 탭(레지스터 직접 조작)은 R3 에서만 됩니다. "Uno" 만 보고 사지 말고 칩 이름을 확인하세요.

로직 분석기 — 가장 과소평가되는 도구

24 MHz · 8 채널 저가형 (Cypress FX2 칩 기반 '호환품')
  · 소프트웨어: sigrok PulseView (무료 · 윈도우 · 리눅스 · 맥)
  · 윈도우 드라이버: PulseView 설치 폴더의 Zadig 로 WinUSB 지정 (u13)
  · 디코더: UART · I²C · SPI · 1-Wire · CAN … 글자로 풀어 보여 줌

샘플링 정리
  24 MHz 로 샘플 → 신호 주파수의 4배 이상이면 모양이 보임
  UART 115200 bps, I²C 400 kHz, SPI 수 MHz 까지는 충분
  아날로그 파형(전압 크기)은 못 봄 → 오실로스코프의 일
입력 전압 확인 — 저가형은 대개 5 V 까지만 견딥니다. 12 V · 24 V 신호(산업용 센서, RS-232 원 신호)에 바로 물리면 망가집니다. GND 를 반드시 대상 보드 GND 에 함께 물려야 파형이 제대로 나옵니다.

실무에선 이렇게 — 짝퉁 칩 · 피할 물건 🔴 실무

Blue Pill(STM32F103C8) 의 함정

값이 싸서 많이 권해지지만 시장 물량 상당수가
  · 겉에 STM32F103C8T6 라고 찍힌 '다른 회사 호환 칩'
    (CKS32 · CS32 · APM32 · GD32 등)
  · 동작은 대체로 비슷하지만 플래시 속도 · ADC · 디버그 ID 가 다름

증상
  OpenOCD:  Warn : UNEXPECTED idcode: 0x2ba01477
            expected 1 of 1: 0x1ba01477
  CubeProgrammer · CubeIDE: 정품이 아니라며 연결 · 디버그 거부

임시 처방 (OpenOCD)
  openocd -f interface/stlink.cfg -c "set CPUTAPID 0x2ba01477" \
          -f target/stm32f1x.cfg

또 하나: 보드의 USB 풀업 저항(R10)이 규격(1.5 kΩ)과 다른 값으로
         달린 제품이 많아 USB 장치 인식이 불안정합니다

사기 전 체크리스트

확인이유
칩 이름이 상품명에 정확히"Uno 호환"만으로는 R3 인지 R4 인지 모름
USB-시리얼 칩 종류16U2 · CH340 · CP2102 — 드라이버가 다름
공식 보드 (Nucleo)ST 칩 정품 · ST-LINK 정품이 보장됨
로직 분석기 소프트웨어상용 프로그램을 호환품과 쓰면 라이선스 위반 — PulseView 를 쓰세요
케이블"충전 전용" 은 데이터 선이 없음 — 이후 u13 의 1순위 범인
전원보드는 PC USB 전원으로 충분. 모터 · LED 스트립은 별도 전원
Uno R4 · ESP32 · Raspberry Pi Pico 는 나중에 — 둘째 단계로 아주 좋은 보드지만, 이 가이드의 레지스터 예제와 칩이 달라 처음에는 헷갈림만 늘어납니다. Pico(RP2040)는 Raspberry Pi Debug Probe(CMSIS-DAP) 와 함께 사면 u09 의 내용을 그대로 씁니다.
정리 — Uno R3(ATmega328P) + Nucleo-F446RE + 24 MHz 8채널 로직 분석기 + 멀티미터 + 브레드보드 · 저항 · LED 가 최소 세트입니다. Uno R4 는 ARM(RA4M1) 이라 AVR 예제가 안 되고, Blue Pill 은 호환 칩 · USB 저항 문제가 흔합니다. LED 저항은 (V − Vf) ÷ R 로 직접 계산해 핀 한계 아래로 맞춥니다.
SET 13

트러블슈팅 — 보드가 안 보이고, 안 구워질 때

임베디드 입문자가 첫 주에 쓰는 시간의 절반은 코드가 아니라 "PC 가 보드를 못 찾는다"에 들어갑니다. 원인은 거의 항상 케이블 · 드라이버 · 포트 점유 · 권한 · PATH 다섯 가지 중 하나입니다. 이 절은 그 다섯을 위에서부터 차례로 지우는 순서와, 자주 만나는 에러 문구별 처방을 모았습니다.

위에서부터 하나씩 지웁니다 — 아래로 내려갈수록 드문 원인입니다 장치 관리자에 장치가 보이나? 아니오 케이블 · 포트 · 전원 충전 전용 케이블이 1순위 범인 예 노란 느낌표 · 알 수 없는 장치? 예 드라이버 설치 CH340 · ST-LINK · Zadig 아니오 포트 · 프로브를 열 때 에러? 예 다른 프로그램이 점유 시리얼 모니터 닫기 · 리눅스 권한 아니오 칩이 응답 안 함 통신은 되는데 대답이 없음 AVR — 보드 종류(-c) · 속도(-b) · 부트로더 · D0/D1 배선 STM32 — 리셋 상태로 연결(mode=UR) · BOOT0 · 읽기 보호 케이블을 바꾸고 다른 USB 포트(뒤쪽)로 아래 '드라이버' 표 Zadig 은 아무 데나 금지 '액세스 거부' · 'busy' · LIBUSB_ERROR_ACCESS 첫 질문의 답이 '아니오'라면 소프트웨어는 아무것도 할 수 없습니다 — 하드웨어부터
순서가 핵심입니다 — 보이나 → 드라이버 → 점유 · 권한 → 칩. 위 단계를 건너뛰고 아래를 고치면 시간만 갑니다.

윈도우 드라이버 — 무엇에 무엇을

장치 (VID:PID)드라이버
Uno R3 정품 (2341:0043)윈도우 10 기본 드라이버로 "USB 직렬 장치(COMx)"
CH340 (1a86:7523)WCH 의 CH341SER (wch-ic.com). 윈도우 업데이트가 자동으로 받기도 함
CP2102 (10c4:ea60)Silicon Labs CP210x VCP
FTDI (0403:6001)대개 자동 설치
ST-LINK (0483:374b 등)ST 의 STSW-LINK009 — CubeIDE · CubeProgrammer 설치 때 같이 설치 가능
USBasp · FX2 로직 분석기Zadig 로 WinUSB(또는 libusb-win32) 지정
Zadig 은 '드라이버를 갈아 끼우는' 도구입니다 — 목록에서 장치를 잘못 고르면 키보드 · CH340 · ST-LINK 의 드라이버를 바꿔 버립니다. Options → List All Devices 를 켠 뒤 VID:PID 로 대조해 정확히 고르고, ST-LINK · 시리얼 칩에는 쓰지 마세요. 잘못 바꿨다면 장치 관리자 → 그 장치 → 디바이스 제거(드라이버 삭제 체크) → 다시 꽂기.

COM 포트 번호 찾기

# ① GUI: Win+X → 장치 관리자 → '포트(COM & LPT)'
#    보드를 뽑았다 꽂으면서 사라졌다 생기는 항목이 그 보드입니다

# ② PowerShell — 이름과 상태까지
Get-PnpDevice -Class Ports -PresentOnly |
    Select-Object Status, FriendlyName, InstanceId
#  OK  USB-SERIAL CH340 (COM7)            USB\VID_1A86&PID_7523\...
#  OK  STMicroelectronics STLink Virtual COM Port (COM9)  USB\VID_0483&PID_374B...

# ③ 번호만
[System.IO.Ports.SerialPort]::GetPortNames()

# 꽂기 전/후 비교 트릭
$before = [System.IO.Ports.SerialPort]::GetPortNames()
Read-Host '보드를 꽂고 엔터'
Compare-Object $before ([System.IO.Ports.SerialPort]::GetPortNames())
# 리눅스 — 꽂는 순간 커널 로그
sudo dmesg -w            # cdc_acm 1-2:1.0: ttyACM0: USB ACM device
ls -l /dev/serial/by-id/ # 포트 번호가 바뀌어도 고정된 이름 ★

에러 문구별 처방 — AVR · avrdude 🟡 중급

가장 유명한 에러

avrdude: stk500_recv(): programmer is not responding
avrdude: stk500_getsync() attempt 1 of 10: not in sync: resp=0x00
  (avrdude 8.x 는 문구가 조금 다를 수 있음 — 뜻은 같음)

뜻: 포트는 열렸는데 부트로더가 대답하지 않는다 (u06 시퀀스의 4번 실패)

원인 · 처방 (자주 나오는 순)
  ① 다른 보드의 COM 포트를 골랐다        → 꽂기 전/후 비교로 다시 확인
  ② 보드 종류 · 속도가 다르다            → Uno=-b 115200, 옛 Nano 부트로더=-b 57600
  ③ D0(RX) · D1(TX) 에 무언가 연결됨     → 업로드 동안 선을 뽑기
  ④ 자동 리셋이 안 됨 (DTR 미연결 클론)  → 업로드 직전 리셋 버튼을 눌렀다 떼기
  ⑤ 부트로더가 없다 (ISP 로 덮어씀)      → ISP 로 부트로더 다시 굽기
  ⑥ 칩이 Uno 가 아니다 (Uno R4 등)        → u12

그 밖의 avrdude 문구

문구처방
ser_open(): can't open device "\\.\COM5" + 액세스 거부시리얼 모니터 · 아두이노 IDE · 다른 터미널이 포트를 잡고 있음 → 닫기
같은 문구 + 지정된 파일을 찾을 수 없음포트 번호 틀림 · 보드 안 꽂힘
Device signature = 0x1e9514ATmega328(P 없는 판) — -p m328
Device signature = 0x000000ISP 배선 · 칩 전원 · 클럭 퓨즈 문제 (u06)
verification error, first mismatch굽기는 됐는데 읽은 값이 다름 — 전원 불안정 · 케이블 · 플래시 수명
Permission denied (리눅스)dialout 그룹 · 재로그인 (u03)

에러 문구별 처방 — STM32 · ST-LINK · OpenOCD 🟡 중급

"No ST-LINK detected" 계열

CubeProgrammer : No ST-LINK detected
OpenOCD        : Error: open failed
                 (리눅스) LIBUSB_ERROR_ACCESS

= PC 가 프로브(ST-LINK)를 못 찾거나 못 연다 — 칩까지 가지도 않음

처방
  ① 장치 관리자에 'ST-Link Debug' 가 있나 → 없으면 케이블 · 드라이버
  ② CubeIDE 디버그 세션 · 다른 OpenOCD 가 살아 있나 → 전부 종료
     (프로브는 한 프로그램만 잡을 수 있습니다)
  ③ ST-LINK 펌웨어가 오래됐나 → CubeProgrammer 의 Firmware upgrade
  ④ 리눅스 → 60-openocd.rules (u03) · 다시 꽂기
  ⑤ Nucleo 의 CN2 점퍼는 '대상 칩' 연결만 바꿉니다 — 이 에러와 무관

"Can not connect to target" 계열 — 칩이 대답 안 함

CubeProgrammer : Error: No STM32 target found!
                 Can not connect to target
OpenOCD        : Error: init mode failed (unable to connect to the target)

= 프로브는 찾았는데 칩의 SWD 가 응답하지 않는다

가장 흔한 원인: 내 펌웨어가 SWD 핀을 막아 버림
  · CubeMX 의 SYS → Debug 를 'No Debug' 로 둠
  · PA13 · PA14 를 일반 GPIO 로 써 버림
  · 시작하자마자 깊은 슬립(STOP · STANDBY) 으로 들어감

처방 — 리셋을 누른 채 붙어서 지우기
  STM32_Programmer_CLI -c port=SWD mode=UR -e all
  openocd -f board/st_nucleo_f4.cfg \
    -c "reset_config srst_only srst_nogate connect_assert_srst" \
    -c "init; reset halt; flash erase_sector 0 0 last; exit"

그래도 안 되면 — BOOT0 을 VDD 에 (Nucleo CN7 의 7번 ↔ 5번) 연결하고
  리셋 → 내 펌웨어 대신 ST 내장 부트로더가 돌아 SWD 가 살아남 → 지우기 → BOOT0 원위치

실무에선 이렇게 — PATH · 리눅스 · 기록 🔴 실무

PATH 문제 — 증상과 처방

# 증상 1: 'avr-gcc' 용어가 cmdlet, 함수 ... 이름으로 인식되지 않습니다
#   → PATH 에 없음, 또는 PATH 를 바꾼 뒤 창을 새로 안 열었음
#   → VS Code 는 '모든 창'을 닫았다 열어야 새 PATH 를 받습니다

# 증상 2: 엉뚱한 버전이 불림
where.exe gcc
#  C:\Strawberry\c\bin\gcc.exe        ← 먼저 걸림 (Perl 이 깐 옛 gcc)
#  C:\msys64\ucrt64\bin\gcc.exe
#   → 사용자/시스템 PATH 에서 순서를 바꾸거나 필요 없는 쪽을 제거

# 증상 3: PATH 가 잘려 나감
#   ✗ setx PATH "%PATH%;C:\avr\bin"   ← 1024자에서 잘리고 시스템 PATH 가
#                                        사용자 PATH 로 복사됩니다. 쓰지 마세요
#   ✓ u02 의 [Environment]::SetEnvironmentVariable 방식 또는 sysdm.cpl
지금 PATH 를 한 줄에 하나씩 — $env:Path -split ';'. 같은 도구 폴더가 두 번 이상 보이면 정리할 때입니다.

리눅스에서만 생기는 두 가지

# ① ModemManager 가 /dev/ttyACM0 를 몇 초간 '모뎀인지' 찔러봄
#    → 꽂자마자 업로드하면 실패, 조금 있다 하면 성공하는 증상
sudo systemctl disable --now ModemManager    # 모뎀을 안 쓴다면

# ② Ubuntu 22.04 등에서 CH340 이 꽂자마자 사라짐
#    dmesg: usbfs: interface 0 claimed by ch341 while 'brltty' sets config
#    → 점자 단말 서비스(brltty)가 CH340 을 자기 장치로 착각
sudo apt remove brltty                       # 점자 단말을 안 쓴다면

# 권한 최종 점검
id                        # groups 에 dialout 이 있나 (재로그인 필요)
ls -l /dev/ttyACM0 /dev/ttyUSB0
lsusb                     # 0483:374b 가 보이면 ST-LINK 는 USB 까지 OK
해결한 것은 적어 두세요 — 팀 위키에 "증상 · 원인 · 처방 · 확인 명령" 네 칸짜리 표를 하나 두면, 새 팀원의 첫 주가 하루로 줄어듭니다. 이 절의 표들이 그 출발점입니다.
정리 — 보드 문제는 ① 보이나(케이블 · 전원) → ② 드라이버(CH340 · ST-LINK · Zadig) → ③ 점유 · 권한(시리얼 모니터 · dialout · udev) → ④ 칩 응답 순서로 지웁니다. not in sync 는 포트 · 속도 · 리셋 문제, No ST-LINK detected 는 프로브까지 못 간 것, Can not connect to target 은 펌웨어가 SWD 를 막은 것 — 리셋 상태로 연결(mode=UR) 해서 지웁니다. PATH 는 where.exe 로 몇 개가 걸리는지부터 봅니다.
ATmega328P · Arduino Uno R3 · 16 MHz · avr-gcc 14.x · avr-libc · avrdude 8.x · 레지스터 직접 제어 · 인터럽트 · 타이머 · UART · ADC · 저전력

Arduino 를
걷어 내면
칩이 보입니다

Uno 보드 위의 검은 칩 ATmega328P 를 Arduino 라이브러리 없이 다룹니다. digitalWrite 대신 레지스터에 비트를 직접 쓰고, millis() 를 타이머 인터럽트로 직접 만들고, Serial.print 대신 UART 와 링 버퍼와 printf 를 직접 연결합니다. 컴파일은 avr-gcc, 업로드는 avrdude — IDE 없이 Makefile 하나로 끝납니다. 이 탭을 마치면 데이터시트의 레지스터 표를 읽고 그대로 코드로 옮기는 법, 그리고 Arduino 가 그 위에서 무엇을 대신해 주고 있었는지(그리고 무엇을 희생했는지)를 정확히 알게 됩니다. 모든 예제는 #include 와 F_CPU 까지 들어 있는 그대로 빌드되는 완전한 파일입니다.

13SECTIONS
2 KBSRAM 하나로
16 MHzATmega328P
100%복붙해서 빌드되는 예제
AVR 01

AVR 과 ATmega328P — Uno 보드의 실체

"Arduino" 는 칩 이름이 아니라 보드 + 부트로더 + 라이브러리 + IDE 를 묶은 브랜드입니다. Uno R3 에서 실제로 코드를 돌리는 것은 Microchip(옛 Atmel) 의 8비트 MCU ATmega328P 하나입니다. 이 칩이 어떤 구조인지, 보드의 핀 번호가 칩의 어느 다리인지부터 맞춰 둡니다.

ATmega328P (DIP-28) — 안쪽은 칩 이름, 바깥은 Uno 보드 이름 ATmega328P 8비트 AVR · 16 MHz 1PC6RESET 2PD0D0 · RXD 3PD1D1 · TXD 4PD2D2 · INT0 5PD3D3 · INT1 · PWM 6PD4D4 7VCC전원 5V 8GND접지 9PB6XTAL1 · 16 MHz 공진자 10PB7XTAL2 · 16 MHz 공진자 11PD5D5 · PWM (OC0B) 12PD6D6 · PWM (OC0A) 13PD7D7 14PB0D8 28PC5A5 · SCL 27PC4A4 · SDA 26PC3A3 25PC2A2 24PC1A1 23PC0A0 22GND접지 21AREFAREF · ADC 기준전압 20AVCCAVCC · ADC 전원 19PB5D13 · SCK · 보드 LED 18PB4D12 · MISO 17PB3D11 · MOSI · PWM 16PB2D10 · SS · PWM 15PB1D9 · PWM (OC1A) 포트 D = D0~D7 · 포트 B = D8~D13 · 포트 C = A0~A5 — 이 세 줄만 외우면 됩니다
핀맵 — Arduino 의 D13 은 칩의 PB5, 즉 "포트 B 의 5번 비트"입니다. 이 탭의 모든 코드는 오른쪽 이름이 아니라 안쪽 이름으로 씁니다.

ATmega328P 한 장 요약

항목값서버 개발자 감각으로
코어8비트 AVR RISC · 레지스터 32개int 덧셈 하나가 명령 여러 개
클럭16 MHz (Uno 의 크리스털)명령 대부분 1클럭 = 62.5 ns
Flash32 KB (부트로더 0.5 KB 포함)실행 파일이 들어가는 디스크
SRAM2 KB힙 + 스택 + 전역 변수 전부
EEPROM1 KB · 10만 회 쓰기전원이 꺼져도 남는 작은 설정 파일
주변장치타이머 3 · UART 1 · SPI · I2C(TWI) · ADC 10비트 6채널"하드웨어로 된 라이브러리"
전압1.8~5.5 V (16 MHz 는 4.5 V 이상 권장)Uno 는 5 V 로 돌립니다
RISC = 명령을 단순하게 만들고 대부분을 한 클럭에 끝내는 설계. 8비트 = 한 번에 계산하는 단위가 1바이트라는 뜻입니다. uint32_t 덧셈은 내부적으로 명령 4개로 쪼개집니다 — 그래서 AVR 코드는 uint8_t 를 기본으로 씁니다.

하버드 구조 — 코드와 데이터가 다른 길로 다닙니다

PC·서버의 CPU 는 코드와 데이터를 같은 메모리 공간(폰 노이만)에 둡니다. AVR 은 하버드 구조라서 명령은 Flash 에서 16비트 프로그램 버스로, 데이터는 SRAM 에서 8비트 데이터 버스로 따로 가져옵니다. 덕분에 명령을 읽는 동안 데이터를 동시에 읽을 수 있어 대부분 명령이 1클럭에 끝납니다.

대가는 주소 공간이 둘이라는 점입니다. Flash 의 0x0100 번지와 SRAM 의 0x0100 번지는 전혀 다른 곳이고, C 의 포인터 하나로는 둘을 구별하지 못합니다. 그래서 Flash 에 둔 문자열은 pgm_read_byte() 같은 전용 명령(LPM)으로만 읽습니다 — a10 에서 이걸 제대로 다룹니다.

두 개의 버스 — 명령은 왼쪽 길, 데이터는 오른쪽 길 Flash 32 KB 프로그램 · 상수 테이블 16비트 명령 단위 쓰기 1만 회 AVR CPU 코어 ALU · PC · SREG r0~r31 범용 레지스터 대부분 1클럭 실행 SRAM 2 KB 전역 · 힙 · 스택 0x0100 ~ 0x08FF 전원 끄면 사라짐 16비트 8비트 I/O 레지스터 0x20~0xFF PORTB · TCCR0A · UDR0 … EEPROM 1 KB EEAR · EEDR 로만 접근 주변장치도 "메모리 주소"입니다 — PORTB 에 쓰는 것은 변수 대입과 같은 명령으로 컴파일됩니다 EEPROM 은 버스에 직접 붙지 않습니다 — 주소·데이터 레지스터를 거쳐 한 바이트씩
하버드 구조 — Flash 와 SRAM 은 서로 다른 주소 공간이고, 주변장치는 SRAM 쪽 주소에 붙은 특수한 변수입니다.

Uno R3 보드 — 칩 말고 무엇이 붙어 있나 🟢 기초

보드 위 부품과 역할

부품하는 일
ATmega328P우리가 프로그래밍하는 칩 (소켓에 꽂힌 DIP-28)
ATmega16U2USB ↔ 시리얼 변환 전용 칩. PC 에는 COM 포트로 보입니다
16 MHz 크리스털16U2 쪽 클럭. 328P 쪽은 세라믹 공진자(정밀도 약 ±0.5%)
5 V · 3.3 V 레귤레이터DC 잭 7~12 V 를 5 V 로 낮춤 · 3.3 V 핀은 최대 50 mA
D13 LEDPB5 에 연산 증폭기를 거쳐 연결된 'L' LED
ICSP 헤더 6핀ISP 프로그래머 연결용 (MISO·MOSI·SCK·RESET·VCC·GND)
리셋 버튼 · 자동 리셋16U2 의 DTR 신호가 커패시터를 통해 리셋 → 업로드 시작

헷갈리기 쉬운 것들

Uno R4 는 완전히 다른 칩입니다. R4 Minima/WiFi 는 Renesas RA4M1(32비트 Cortex-M4)이라 이 탭의 레지스터 코드가 하나도 돌지 않습니다. 이 탭은 Uno R3(및 같은 칩을 쓴 Nano · Pro Mini) 기준입니다. Cortex-M 은 STM32 탭에서 다룹니다.
호환 보드(클론)는 16U2 대신 CH340 같은 USB 칩을 쓰기도 합니다 — 윈도우에서 드라이버가 따로 필요할 수 있습니다. 옛 Nano 클론은 부트로더 속도가 57600 이라 업로드 명령의 -b 값이 다릅니다(a02).
핀 하나의 전류 한계는 절대 최대 40 mA, 권장 20 mA 이하입니다. LED 는 반드시 저항(220 Ω~1 kΩ)과 함께 — 저항값 계산과 회로 기초는 하드웨어 기초 탭에서 다룹니다.

레지스터 이름은 어디서 오나 🟡 중급

<avr/io.h> 가 주는 것

#include <avr/io.h>   // -mmcu=atmega328p 를 보고 iom328p.h 를 골라 줍니다

// iom328p.h 안에는 이런 정의가 수백 줄 들어 있습니다 (발췌 · 개념)
//   #define PINB   _SFR_IO8(0x03)   // I/O 주소 0x03 = 데이터 주소 0x23
//   #define DDRB   _SFR_IO8(0x04)
//   #define PORTB  _SFR_IO8(0x05)
//   #define PORTB5 5                 // 비트 번호
//   #define TCCR0A _SFR_IO8(0x24)
//   #define UDR0   _SFR_MEM8(0xC6)   // 확장 I/O — sbi/cbi 불가 영역
//
// _SFR_IO8(x) 는 결국 (*(volatile uint8_t *)((x) + 0x20)) 입니다
// → "volatile 포인터를 역참조한 변수" — C 실전 탭 c22 의 그 패턴

int main(void) {
    DDRB  |= _BV(DDB5);     // _BV(n) = (1 << n) — avr-libc 매크로
    PORTB |= _BV(PORTB5);   // PB5 = HIGH → D13 LED 켜짐
    for (;;) { }            // main 에서 돌아오면 안 됩니다 (돌아갈 OS 가 없음)
}

데이터시트 읽는 순서

Microchip 의 "ATmega48A/PA/88A/PA/168A/PA/328/P datasheet" 한 권이 이 칩의 전부입니다(약 300쪽). 처음부터 읽지 말고 주변장치 장(章)마다 같은 순서로 보세요.

#볼 곳얻는 것
1Overview · Block Diagram무엇이 무엇과 연결되나
2Modes of Operation 표모드 비트 조합 (예: WGM 표)
3Register Description비트 이름 · 리셋 값 · R/W
4Electrical Characteristics전류 · 타이밍 한계
5Errata칩 버그 — 의외로 자주 있습니다
avr-libc 의 비트 이름(WGM01, CS00)은 데이터시트 표기와 똑같습니다. 표에서 본 이름을 그대로 쓰면 되니 매직 넘버(TCCR0A = 0x02)를 쓸 이유가 없습니다.
정리 — Uno 의 실체는 32 KB Flash · 2 KB SRAM · 1 KB EEPROM 인 8비트 하버드 구조 MCU 하나이고, D13 은 PB5, A0 는 PC0 처럼 포트와 비트 번호로 부르는 것이 레지스터 프로그래밍의 출발점입니다.
AVR 02

Arduino 없이 첫 프로그램 — avr-gcc · avrdude · Makefile

Arduino IDE 의 "업로드" 버튼은 사실 avr-gcc → avr-objcopy → avrdude 를 차례로 부르는 단축키입니다. 그 세 명령을 직접 치면 무엇이 어떻게 칩에 들어가는지 보이고, 빌드 옵션도 마음대로 바꿀 수 있습니다. 툴체인 설치 자체는 설치 · 툴체인 탭에서 다루고, 여기서는 명령과 그 의미에 집중합니다.

IDE 의 업로드 버튼이 하는 일 — 다섯 단계 main.c 소스 main.o avr-gcc -c main.elf 링크 · 디버그 정보 main.hex avr-objcopy Flash avrdude F_CPU 정의 경고 0개 avr-size 로 크기 텍스트 형식 이미지 부트로더가 기록 PC 안에서 끝나는 일 — 보드가 없어도 됩니다 보드가 USB 에 꽂혀 있어야 하는 일 hex 는 "주소 + 바이트" 줄의 텍스트 파일 — 메모장으로 열어 볼 수 있습니다
빌드 파이프라인 — elf 까지는 컴파일러의 일, hex 부터는 칩의 일입니다. 업로드 실패는 거의 항상 오른쪽 두 칸에서 납니다.

blink.c — 라이브러리 없는 Blink

// blink.c — ATmega328P @16MHz (Arduino Uno R3), D13 LED 깜빡이기
#ifndef F_CPU
#define F_CPU 16000000UL      // util/delay.h 가 지연 계산에 씁니다
#endif

#include <avr/io.h>
#include <util/delay.h>

int main(void)
{
    DDRB |= _BV(DDB5);         // PB5(D13) 를 출력으로

    for (;;) {
        PINB = _BV(PINB5);     // PINx 에 1 쓰기 = 그 핀 토글 (a03)
        _delay_ms(500);        // 컴파일 시간 상수만 허용 · -O 필수
    }
}
F_CPU 는 칩 속도를 바꾸는 설정이 아닙니다. "이 칩은 16 MHz 로 돈다"고 컴파일러에게 알려 주는 숫자일 뿐입니다. 실제 클럭은 퓨즈와 크리스털이 정합니다(a11). 둘이 다르면 _delay_ms(500) 가 1초가 되거나 UART 가 깨집니다.

명령 세 줄로 빌드 → 업로드

# 1) 컴파일 + 링크 (-mmcu 가 레지스터 정의와 링커 스크립트를 고릅니다)
avr-gcc -mmcu=atmega328p -DF_CPU=16000000UL -Os -std=gnu11 \
        -Wall -Wextra -o blink.elf blink.c

# 2) 크기 확인 — Flash 사용량 = text + data, SRAM 사용량 = data + bss
avr-size blink.elf
#   text(코드+상수) · data(초기값 있는 전역) · bss(0 으로 시작하는 전역)
#   이 Blink 는 text 가 200 바이트 안쪽, data·bss 는 0 입니다 (툴체인 버전마다 조금 다름)

# 3) Flash 영역만 Intel HEX 로 뽑기 (EEPROM·퓨즈 섹션 제외)
avr-objcopy -O ihex -R .eeprom -R .fuse -R .lock blink.elf blink.hex

# 4) 업로드 — Uno 의 Optiboot 부트로더에게 보냅니다
avrdude -c arduino -p m328p -P COM3 -b 115200 -U flash:w:blink.hex:i
#   리눅스: -P /dev/ttyACM0 (Uno) · /dev/ttyUSB0 (CH340 클론)
#   옛 Nano 클론: -b 57600
같은 Blink 를 Arduino IDE 로 빌드하면 약 900 바이트대가 나옵니다. 차이는 Arduino 코어가 미리 켜 두는 타이머 0 인터럽트(millis), PWM 설정, 핀 번호 테이블입니다 — a13 에서 하나씩 뜯어봅니다.

옵션 하나하나의 의미 🟢 기초

옵션의미빼먹으면
-mmcu=atmega328p대상 칩. 레지스터 헤더 · 벡터 테이블 · 메모리 크기를 결정레지스터 이름이 정의되지 않아 컴파일 오류
-DF_CPU=16000000UL클럭 주파수(Hz). util/delay.h · util/setbaud.h 가 사용경고 후 1 MHz 로 가정 → 지연이 16배 짧아짐
-Os크기 우선 최적화. AVR 의 사실상 기본_delay_ms 가 부정확 + 부동소수점 코드가 끌려 들어옴
-ffunction-sections -fdata-sections + -Wl,--gc-sections쓰지 않는 함수·변수를 링크 단계에서 제거라이브러리를 쓸 때 Flash 낭비
-flto링크 시간 최적화 — 파일 경계를 넘어 인라인선택. Arduino IDE 는 켜 둡니다
-c arduinoavrdude 프로그래머 종류 = "Arduino 부트로더(STK500v1 프로토콜)"ISP 장비를 쓸 때는 -c usbasp 등 (a11)
-U flash:w:파일:i메모리:동작(w 쓰기 · r 읽기 · v 검증):파일:형식(i = Intel HEX)—

Makefile — 매번 치지 않도록 🟡 중급

Makefile (여러 .c 파일 지원)

# Makefile — ATmega328P / Arduino Uno R3 (레시피 줄 앞은 반드시 탭)
MCU     = atmega328p
F_CPU   = 16000000UL
PORT   ?= COM3
BAUD   ?= 115200
TARGET  = app
SRC     = $(wildcard *.c)
OBJ     = $(SRC:.c=.o)

CC      = avr-gcc
OBJCOPY = avr-objcopy
CFLAGS  = -mmcu=$(MCU) -DF_CPU=$(F_CPU) -Os -std=gnu11 -g \
          -Wall -Wextra -ffunction-sections -fdata-sections
LDFLAGS = -mmcu=$(MCU) -Wl,--gc-sections -Wl,-Map=$(TARGET).map

all: $(TARGET).hex size

%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@

$(TARGET).elf: $(OBJ)
	$(CC) $(LDFLAGS) $^ -o $@

$(TARGET).hex: $(TARGET).elf
	$(OBJCOPY) -O ihex -R .eeprom -R .fuse -R .lock $< $@

size: $(TARGET).elf
	avr-size $<

flash: $(TARGET).hex
	avrdude -c arduino -p m328p -P $(PORT) -b $(BAUD) -U flash:w:$<:i

clean:
	rm -f *.o *.elf *.hex *.map

.PHONY: all size flash clean

쓰는 법과 자주 나는 오류

make                 # 빌드 + 크기 표시
make flash PORT=COM5 # 포트만 바꿔 업로드
make clean

# 디스어셈블 — 내 C 가 어떤 명령이 됐는지 (a03 · a13 에서 씁니다)
avr-objdump -d -S app.elf | less
avrdude 메시지원인 · 해결
not in sync: resp=0x00포트·속도 불일치, 부트로더 없음, 시리얼 모니터가 포트를 잡고 있음
can't open device "COM3"포트 번호 틀림 — 장치 관리자에서 확인
verification errorFlash 쓰기 실패 — 전원 · 케이블 · 클론의 USB 칩 확인
Device signature = 0x000000(ISP 사용 시) 배선 · 클럭 퓨즈 문제 — a11
Makefile 의 레시피 줄은 공백이 아니라 탭이어야 합니다. 에디터가 탭을 공백으로 바꾸면 missing separator 오류가 납니다. CMake 를 쓰고 싶다면 툴체인 파일만 만들면 되지만, AVR 프로젝트는 대부분 이 정도 Makefile 로 충분합니다.
Arduino IDE 가 쓰는 툴체인을 그대로 빌려 쓰기 — IDE 를 설치했다면 이미 avr-gcc 와 avrdude 가 들어 있습니다 (Windows 는 보통 %LOCALAPPDATA%\Arduino15\packages\arduino\tools\ 아래). 다만 IDE 가 묶어 둔 avr-gcc 는 버전이 오래된 경우가 많아서, 새 프로젝트는 독립 설치한 최신 안정 avr-gcc 를 권합니다.
정리 — avr-gcc -mmcu -DF_CPU -Os 로 elf 를 만들고, avr-objcopy 로 hex 를 뽑고, avrdude -c arduino -b 115200 로 부트로더에게 보내면 끝입니다. 이 셋을 Makefile 에 묶어 두면 IDE 가 필요 없습니다.
AVR 03

GPIO — DDRx · PORTx · PINx 세 레지스터로 끝납니다

핀 하나를 켜고 끄고 읽는 일(GPIO, 범용 입출력)은 포트마다 레지스터 세 개가 전부입니다. 방향(DDR), 출력 값 또는 풀업(PORT), 입력 읽기(PIN). 이 셋의 조합 네 가지와 "PINx 에 1 을 쓰면 토글" 같은 AVR 고유의 요령, 그리고 기계식 버튼의 채터링을 타이머로 거르는 법까지 봅니다.

한 핀의 상태 = DDR 비트 × PORT 비트 PORTxn = 0 PORTxn = 1 DDRxn = 0 (입력) DDRxn = 1 (출력) 입력 · 하이 임피던스 아무것도 안 붙이면 값이 떠다님 리셋 직후 모든 핀의 상태 입력 · 내부 풀업 약 20~50 kΩ 으로 5 V 에 연결 버튼 → GND 배선의 표준 출력 LOW (0 V) 전류를 받아들임 (sink) 핀당 최대 20 mA 권장 출력 HIGH (5 V) 전류를 내보냄 (source) PINxn 에 1 쓰면 반전 PINx 는 언제나 "핀의 실제 전압"을 읽습니다 — 출력으로 설정해도 읽을 수 있습니다
GPIO 매트릭스 — 입력일 때 PORT 비트는 풀업 스위치가 되고, 출력일 때는 출력 값이 됩니다. 같은 비트가 두 가지 일을 합니다.

버튼(D2)으로 LED(D13) 켜기 — 폴링

// button_led.c — ATmega328P @16MHz (Uno R3)
// 배선: D2(PD2) ── 버튼 ── GND   (외부 저항 없음, 내부 풀업 사용)
#define F_CPU 16000000UL
#include <avr/io.h>
#include <stdint.h>

int main(void)
{
    DDRB  |=  _BV(DDB5);      // PB5(D13) 출력
    DDRD  &= ~_BV(DDD2);      // PD2(D2) 입력 (리셋 값이 이미 0 이지만 명시)
    PORTD |=  _BV(PORTD2);    // PD2 풀업 켜기 → 안 누르면 1

    for (;;) {
        uint8_t pressed = !(PIND & _BV(PIND2));  // 누르면 GND → 0 → 반전
        if (pressed) PORTB |=  _BV(PORTB5);
        else         PORTB &= ~_BV(PORTB5);
    }
}
풀업 + 버튼을 GND 로가 표준인 이유 — 외부 부품이 필요 없고, 선이 끊어져도 "안 눌림(1)"으로 안전하게 읽힙니다. 대신 논리가 뒤집힙니다(눌림 = 0). 이걸 잊는 것이 초보의 첫 버그입니다.

자주 쓰는 비트 조작 — 그리고 원자성

PORTB |=  _BV(PORTB5);              // 켜기   → sbi 0x05,5  (명령 1개)
PORTB &= ~_BV(PORTB5);              // 끄기   → cbi 0x05,5
PINB   =  _BV(PINB5);               // 토글   → 쓰기 1번 (읽기 없음)
if (PIND & _BV(PIND2)) { }          // 읽기   → sbis/sbic 로 컴파일

// ★ 두 비트를 한 번에 바꾸면 '읽고-고치고-쓰기' 3단계가 됩니다
PORTB |= _BV(PORTB5) | _BV(PORTB0); // in r24,0x05 / ori r24,0x21 / out 0x05,r24
// 이 3개 명령 사이에 인터럽트가 끼어들어 ISR 이 PORTB 의 다른 비트를 바꾸면
// 메인이 옛 값을 다시 써서 ISR 의 변경이 사라집니다 → a04 의 ATOMIC_BLOCK

// 모든 풀업을 한 번에 끄는 스위치도 있습니다
MCUCR |= _BV(PUD);                  // Pull-Up Disable — 저전력 설계에서 씀
sbi/cbi 는 I/O 주소 0x00~0x1F 범위의 레지스터에만 쓸 수 있는 한 비트 전용 명령이고, 끼어들 틈이 없어서 원자적입니다. PORTB/C/D · DDRx · PINx 는 모두 이 범위라 한 비트씩 바꾸면 안전합니다. TCCR1B, UCSR0B 같은 확장 I/O 는 범위 밖이라 한 비트라도 3단계로 컴파일됩니다.

PINx 에 1 쓰기 = 토글 — 그리고 포트 단위 쓰기 🟡 중급

토글 세 가지 방법 비교

코드명령원자적?
PORTB ^= _BV(5);in · eor · out (3개)✕
PINB = _BV(5);ldi · out (2개)○ 쓰기 한 번
PINB |= _BV(5);sbi (1개)△ 의도와 다를 수 있음
PINB |= ... 는 쓰지 마세요. 지금 컴파일러는 sbi 하나로 만들어 문제가 없지만, 두 비트 이상이면 in 으로 PINB 를 읽어 현재 HIGH 인 모든 핀에 1 을 다시 써서 전부 토글해 버립니다. 토글은 항상 PINx = 마스크; 대입으로 쓰세요.

8개 핀을 한 번에 — 포트 단위 쓰기

// D0~D7 (포트 D 전체)에 LED 8개 — 바이트 하나가 곧 LED 8개
#define F_CPU 16000000UL
#include <avr/io.h>
#include <util/delay.h>
#include <stdint.h>

int main(void)
{
    DDRD = 0xFF;                       // 8핀 모두 출력
    uint8_t v = 0x01;                  // ※ PD0/PD1 은 UART 와 공유 (a07)
    for (;;) {
        PORTD = v;                     // 8핀이 '동시에' 바뀝니다
        v = (uint8_t)((v << 1) | (v >> 7));   // 왼쪽으로 회전
        _delay_ms(100);
    }
}

digitalWrite 를 8번 부르면 핀이 수 µs 간격으로 하나씩 바뀌지만, 포트 대입은 한 클럭에 8핀이 동시에 바뀝니다. 7세그먼트·병렬 LCD 처럼 여러 선이 함께 움직여야 하는 장치에서는 이 차이가 중요합니다.

채터링 — 버튼은 한 번 눌러도 여러 번 튑니다 🟡 중급

원신호는 여러 번 튀고, 디바운스 출력은 한 번만 바뀝니다 원신호 PD2 5 V 0 V 채터링 수 ms 디바운스 출력 같은 값이 20 ms 연속 → 그때 확정 눌림 1회 확정 t 점 = 1 ms 타이머 인터럽트마다 한 번 읽은 값 (간격은 그림에서 과장)
디바운스 파형 — 판단 기준은 "바뀌었나"가 아니라 "바뀐 값이 N 번 연속 유지됐나"입니다. _delay_ms 로 기다리는 방식은 그동안 CPU 를 잡아 둡니다.

타이머 기반 디바운스 — 완전한 코드

// debounce.c — 1 ms 타이머 인터럽트에서 버튼 상태를 걸러 냄
#define F_CPU 16000000UL
#include <avr/io.h>
#include <avr/interrupt.h>
#include <util/atomic.h>
#include <stdint.h>

#define DEBOUNCE_MS 20

static volatile uint8_t g_press_events;   // ISR 이 올리고 main 이 가져감

ISR(TIMER0_COMPA_vect)                    // 1 ms 마다 (설정은 a05)
{
    static uint8_t stable = 1;            // 확정된 상태 (1 = 떼어짐)
    static uint8_t count  = 0;
    uint8_t raw = (PIND & _BV(PIND2)) ? 1 : 0;

    if (raw == stable) { count = 0; return; }
    if (++count >= DEBOUNCE_MS) {         // 바뀐 값이 20 번 연속
        stable = raw;
        count  = 0;
        if (stable == 0 && g_press_events < 255) g_press_events++;
    }
}

int main(void)
{
    DDRB  |= _BV(DDB5);
    PORTD |= _BV(PORTD2);                 // 풀업
    TCCR0A = _BV(WGM01);                  // CTC
    TCCR0B = _BV(CS01) | _BV(CS00);       // 분주 64 → 250 kHz
    OCR0A  = 249;                         // 250 카운트 = 1 ms
    TIMSK0 = _BV(OCIE0A);
    sei();

    for (;;) {
        uint8_t n;
        ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {   // 읽기와 지우기를 한 덩어리로
            n = g_press_events;
            g_press_events = 0;
        }
        while (n--) PINB = _BV(PINB5);    // 누를 때마다 LED 토글
    }
}

왜 이렇게 짰나

결정이유
ISR 안에서 판정메인 루프가 무슨 일을 하든 1 ms 마다 공평하게 샘플링
이벤트 '개수'메인이 바빠서 늦게 와도 누른 횟수를 잃지 않음
static 지역 변수ISR 만 쓰는 상태 — volatile 불필요
ATOMIC_BLOCK읽은 직후 ISR 이 올린 이벤트를 0 으로 덮어써 잃는 것을 막음
20 ms보통 버튼의 튐은 수 ms. 20 ms 면 사람은 지연을 못 느낌
버튼에 인터럽트(INT0)를 바로 거는 것은 디바운스가 아닙니다. 튈 때마다 ISR 이 불려 한 번 누름이 3~10번으로 셉니다. INT0 은 "잠든 칩을 깨우는 용도"로 쓰고(a12), 판정은 이렇게 주기 샘플링으로 하세요.
서버 개발에 빗대면 디바운스 = 이벤트 디바운싱/스로틀링입니다. 검색창 자동완성이 "타이핑이 300 ms 멈추면 요청"하는 것과 같은 생각을 하드웨어 신호에 적용한 것입니다.
정리 — DDR 은 방향, PORT 는 출력값 또는 풀업, PIN 은 읽기(그리고 1 쓰면 토글). 한 비트씩 바꾸면 sbi/cbi 로 원자적이고, 버튼은 풀업 + GND 배선에 1 ms 주기 샘플링으로 디바운스합니다.
AVR 04

인터럽트 — 하드웨어가 함수를 부릅니다

인터럽트는 "어떤 일이 생기면 지금 하던 것을 멈추고 이 함수를 불러라"를 하드웨어에 맡기는 장치입니다. 서버로 치면 이벤트 콜백이지만, 결정적인 차이가 있습니다 — 메인 코드의 명령과 명령 사이 어디에서든 끼어듭니다. 그래서 ISR 과 공유하는 변수는 volatile 과 원자적 접근이 필수이고, 8비트 칩에서는 16비트 변수 하나도 위험합니다.

INT0 하나의 일생 — 가로가 시간 (클럭 수는 ATmega328P 데이터시트 기준) main 실행 중 4 3 프롤로그 push ISR 본문 에필로그 pop 4 main D2 하강 → INTF0 = 1 현재 명령은 끝까지 PC 를 스택에 I 비트 끔 벡터의 jmp SREG · r0 · r1 · 쓰는 레지스터 reti — I 비트 다시 켬 최소 1 명령 실행 후에 다음 ISR 전체 지연: 본문이 짧아도 보통 수십 클럭 (16 MHz 에서 1~3 µs) 숫자가 적힌 칸은 하드웨어가 쓰는 고정 클럭 수입니다
인터럽트 타임라인 — ISR 이 도는 동안 I 비트가 꺼져 있어 다른 인터럽트는 기다립니다. ISR 이 길면 그만큼 다른 인터럽트(UART 수신 등)가 밀리고, 결국 데이터를 잃습니다.

외부 인터럽트 INT0 — 하강 에지에서

// int0.c — D2(PD2)가 HIGH→LOW 로 떨어질 때마다 ISR 호출
#define F_CPU 16000000UL
#include <avr/io.h>
#include <avr/interrupt.h>

ISR(INT0_vect)
{
    PINB = _BV(PINB5);           // D13 LED 토글 — ISR 은 이 정도로 짧게
}

int main(void)
{
    DDRB  |= _BV(DDB5);
    PORTD |= _BV(PORTD2);        // 풀업

    EICRA  = _BV(ISC01);         // ISC01:ISC00 = 10 → 하강 에지
    EIFR   = _BV(INTF0);         // ★ 이미 세워진 플래그 지우기 (1 을 써서 지움)
    EIMSK  = _BV(INT0);          // INT0 허용
    sei();                       // 전역 인터럽트 허용 (SREG 의 I 비트)

    for (;;) {
        /* 메인은 다른 일을 해도 됩니다 */
    }
}
ISC01 ISC00감지 조건
0 0LOW 레벨인 동안 계속 (잠에서 깨우기용)
0 1모든 변화
1 0하강 에지
1 1상승 에지

핀 변화 인터럽트 PCINT — 거의 모든 핀에서

// pcint.c — D8(PB0) 와 A0(PC0) 의 변화를 감지
#define F_CPU 16000000UL
#include <avr/io.h>
#include <avr/interrupt.h>
#include <stdint.h>

static volatile uint8_t g_b_changes;

ISR(PCINT0_vect)                 // PB0~PB7 중 허용된 핀 아무거나
{
    g_b_changes++;               // 어느 핀이 어느 방향인지는 PINB 를 읽어 판단
}

ISR(PCINT1_vect)                 // PC0~PC6
{
    if (!(PINC & _BV(PINC0)))    // 지금 LOW 면 하강 에지였던 것
        PINB = _BV(PINB5);
}

int main(void)
{
    DDRB  |= _BV(DDB5);
    PORTB |= _BV(PORTB0);        // 풀업
    PORTC |= _BV(PORTC0);

    PCMSK0 = _BV(PCINT0);        // PB0 = PCINT0
    PCMSK1 = _BV(PCINT8);        // PC0 = PCINT8
    PCIFR  = _BV(PCIF0) | _BV(PCIF1);
    PCICR  = _BV(PCIE0) | _BV(PCIE1);
    sei();
    for (;;) { }
}
INT0/INT1 vs PCINT — INT 는 핀 2개뿐이지만 에지 종류를 고를 수 있고 핀마다 벡터가 따로입니다. PCINT 는 23개 핀에 걸 수 있지만 포트(8핀)당 벡터 하나, 그리고 "변했다"만 알려 주므로 방향은 직접 읽어야 합니다.

16비트 변수 하나가 찢어지는 순간 🔴 고급

g_ms 가 255 → 256 으로 넘어가는 순간에 읽으면 main (읽는 쪽) SRAM 의 g_ms TIMER0 ISR 0x00FF ① lds r24 ← 하위 0xFF ② 끼어들어 g_ms++ 0x0100 ③ lds r25 ← 상위 0x01 r25:r24 = 0x01FF = 511 255 도 256 도 아닌 값 — 1 ms 마다 증가하는 카운터가 순간 256 ms 를 건너뜀 해결: 두 번의 lds 를 인터럽트가 끼어들 수 없는 한 덩어리로
찢어진 읽기 — volatile 은 "매번 메모리에서 읽어라"일 뿐 "한 번에 읽어라"가 아닙니다. 8비트 AVR 에서는 2바이트 이상이면 전부 이 문제가 있습니다.

ATOMIC_BLOCK — 정답 패턴

#include <avr/interrupt.h>
#include <util/atomic.h>
#include <stdint.h>

static volatile uint16_t g_ms;      // ISR 이 증가 — volatile 필수

uint16_t ms_now(void)
{
    uint16_t v;
    ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {
        v = g_ms;                   // cli → 두 바이트 읽기 → SREG 복원
    }
    return v;
}

// RESTORESTATE vs FORCEON
//   RESTORESTATE : 들어오기 전 I 비트를 저장했다가 그대로 되돌림
//                  → 이미 인터럽트가 꺼진 곳(다른 ISR 안)에서 불러도 안전
//   FORCEON      : 끝날 때 무조건 sei() — 조금 빠르지만
//                  인터럽트가 꺼져 있어야 하는 곳에서 부르면 사고

// 1바이트 변수는 lds 한 번이라 읽기 자체는 원자적입니다.
// 하지만 g_flag++ 같은 '읽고-고치고-쓰기'는 1바이트라도 원자적이지 않습니다.

ISR 규칙 다섯 가지

#규칙이유
1짧게 — 플래그·카운터·버퍼 넣기만ISR 중엔 다른 인터럽트가 대기
2_delay_ms · printf 금지수 ms 동안 UART 수신 등을 놓침
3공유 변수는 volatile메인 루프가 레지스터 사본만 보게 됨
42바이트 이상은 ATOMIC_BLOCK위 그림의 찢어진 읽기
5허용한 인터럽트엔 반드시 ISR없으면 기본 처리기가 0번지로 점프 = 리셋
"인터럽트를 켜자마자 보드가 계속 리셋돼요"의 원인 1순위가 5번입니다. avr-libc 는 ISR 이 없는 벡터를 __bad_interrupt 로 채우고, 이것은 리셋 벡터로 점프합니다. 디버깅용으로 ISR(BADISR_vect) { 에러 LED 켜고 멈춤 } 을 두면 바로 보입니다.
volatile 의 개념 자체(컴파일러 최적화와의 관계)는 C 실전 탭 c22 를 참고하세요. 여기서는 AVR 에서 실제로 어떤 명령으로 바뀌는지만 봅니다.

ATmega328P 벡터 이름 표 🟡 중급

#avr-libc 이름언제#avr-libc 이름언제
1(RESET)전원 · 리셋 · 워치독14TIMER1_OVF_vect타이머1 넘침
2INT0_vectD2 외부 인터럽트15TIMER0_COMPA_vect타이머0 비교 A
3INT1_vectD3 외부 인터럽트16TIMER0_COMPB_vect타이머0 비교 B
4PCINT0_vectPB0~PB7 변화17TIMER0_OVF_vect타이머0 넘침 (Arduino millis)
5PCINT1_vectPC0~PC6 변화18SPI_STC_vectSPI 전송 완료
6PCINT2_vectPD0~PD7 변화19USART_RX_vectUART 1바이트 수신
7WDT_vect워치독 타임아웃20USART_UDRE_vectUART 송신 버퍼 빔
8TIMER2_COMPA_vect타이머2 비교 A21USART_TX_vectUART 송신 완전 종료
9TIMER2_COMPB_vect타이머2 비교 B22ADC_vectADC 변환 완료
10TIMER2_OVF_vect타이머2 넘침23EE_READY_vectEEPROM 쓰기 가능
11TIMER1_CAPT_vect타이머1 입력 캡처24ANALOG_COMP_vect아날로그 비교기
12TIMER1_COMPA_vect타이머1 비교 A25TWI_vectI2C 상태 변화
13TIMER1_COMPB_vect타이머1 비교 B26SPM_READY_vect자가 프로그래밍
번호가 곧 우선순위입니다. 두 인터럽트가 동시에 대기 중이면 번호가 작은 쪽(INT0 이 가장 높음)이 먼저 실행됩니다. 선점(ISR 안에서 다른 ISR)은 기본적으로 없고, 필요하면 ISR(..., ISR_NOBLOCK) 으로 ISR 첫머리에서 sei 를 하게 할 수 있지만 스택 사용량이 예측 불가능해져 2 KB 칩에서는 권하지 않습니다.
정리 — 인터럽트는 명령 사이 어디서든 끼어듭니다. ISR 은 짧게, 공유 변수는 volatile, 2바이트 이상 공유 변수는 ATOMIC_BLOCK 으로 읽고, 켠 인터럽트에는 반드시 ISR 을 둡니다.
AVR 05

타이머 — millis() 를 직접 만듭니다

타이머는 클럭을 세는 하드웨어 카운터입니다. CPU 가 무엇을 하든 카운터는 혼자 올라가고, 정해 둔 값에 닿으면 인터럽트를 걸거나 핀을 뒤집습니다. Arduino 의 millis() · delay() · analogWrite() · tone() 이 전부 이 세 개의 타이머 위에 서 있습니다. 여기서는 CTC 모드로 정확히 1 ms 틱을 만드는 계산부터 시작합니다.

타이머 세 개 비교

Timer0Timer1Timer2
폭8비트 (0~255)16비트 (0~65535)8비트
분주1·8·64·256·10241·8·64·256·10241·8·32·64·128·256·1024
출력 핀OC0A=D6 · OC0B=D5OC1A=D9 · OC1B=D10OC2A=D11 · OC2B=D3
특기외부 클럭 T0 입력입력 캡처(ICP1=D8) · ICR1 을 TOP 으로비동기 32.768 kHz · 깊은 잠에서도 동작
Arduino 가 쓰는 곳millis · delay · PWM 5/6Servo 라이브러리 · PWM 9/10tone() · PWM 3/11
분주(prescaler) = 클럭을 N 번에 한 번만 세도록 나누는 장치. 16 MHz 를 64 로 나누면 카운터는 250 kHz, 즉 4 µs 에 1 씩 올라갑니다. Timer2 의 비동기 모드는 TOSC 핀(PB6/PB7)을 쓰는데, Uno 는 그 핀에 16 MHz 공진자가 붙어 있어 Uno 에서는 못 씁니다.

모드 네 가지 — 무엇을 TOP 으로 세나

모드카운터 동작쓰임
Normal0 → 255 → 0 (넘침 인터럽트)단순 주기 · Arduino millis
CTC0 → OCRnA → 0정확한 주기 만들기
Fast PWM0 → TOP 톱니, 비교 일치에서 핀 변화LED 밝기 · 모터 (a06)
Phase Correct0 → TOP → 0 삼각파대칭 PWM · 모터 (a06)
CTC = Clear Timer on Compare match. 카운터가 OCR0A 와 같아지면 0 으로 되돌아갑니다. Normal 모드(256 고정)로는 1 ms 를 정확히 만들 수 없어서 Arduino 의 millis 는 1.024 ms 마다 인터럽트를 받고 분수 보정을 따로 합니다. CTC 는 그럴 필요가 없습니다.
TCNT0 의 모양 — 톱니 하나가 정확히 1 ms 249 0 OCR0A = 249 ISR · g_ms++ ISR · g_ms++ ISR · g_ms++ 0 ms 1 ms 2 ms 3 ms 4 ms 250 틱 × 4 µs = 1 ms 1 틱 = 64 ÷ 16 MHz = 4 µs 계단 250 개를 직선으로 그렸습니다 — 실제로는 4 µs 마다 1 씩 오르는 계단입니다
CTC 파형 — 비교 일치가 톱니의 꼭대기를 잘라 주기를 정합니다. 0 부터 249 까지라 250 칸이고, 그래서 OCR0A 는 250 이 아니라 249 입니다.

계산 — OCR0A = 249 는 어디서 나왔나 🟢 기초

식 하나로 끝납니다

CTC 주파수     f = F_CPU / ( N × (1 + OCR) )
→ 거꾸로       OCR = F_CPU / ( N × f ) − 1

목표: f = 1000 Hz (1 ms),  F_CPU = 16 000 000

N = 1    → 16 000 000 / 1000      − 1 = 15 999   8비트(255) 초과
N = 8    →  2 000 000 / 1000      − 1 =  1 999   8비트 초과
N = 64   →    250 000 / 1000      − 1 =    249   ✓ 정수 · 255 이하
N = 256  →     62 500 / 1000      − 1 =   61.5   정수 아님 → 오차
N = 1024 →     15 625 / 1000      − 1 = 14.625   정수 아님

검산: 16 000 000 / (64 × (1 + 249)) = 16 000 000 / 16 000 = 1000 Hz  ✓
고르는 규칙: ① 결과가 정수이고 ② 타이머 폭(8비트면 255, 16비트면 65535) 이하인 분주 중 가장 작은 N. N 이 작을수록 해상도(1 틱의 길이)가 좋습니다.

목표 주기별로 미리 보면

필요한 카운트 수 = F_CPU ÷ N ÷ f (OCR 은 여기서 1 을 뺀 값) 1 ms 10 ms 1 s ÷1 (62.5 ns) 16 000 160 000 16 000 000 ÷8 (0.5 µs) 2 000 20 000 2 000 000 ÷64 (4 µs) 250 ★ 8비트 2 500 250 000 ÷256 (16 µs) 62.5 625 62 500 ÷1024 (64 µs) 15.625 156.25 15 625 8비트 OK 16비트만 불가 "불가" = 65535 초과이거나 정수가 아님 (근사는 되지만 시계가 밀립니다)

millis.c — Arduino 없이 millis()

// millis.c — Timer0 CTC 1 ms 틱 · ATmega328P @16MHz
#define F_CPU 16000000UL
#include <avr/io.h>
#include <avr/interrupt.h>
#include <util/atomic.h>
#include <stdint.h>

static volatile uint32_t g_ms;            // 49.7 일 후 0 으로 돌아감

ISR(TIMER0_COMPA_vect) { g_ms++; }

static void tick_init(void)
{
    TCCR0A = _BV(WGM01);                  // WGM02:0 = 010 → CTC, TOP = OCR0A
    TCCR0B = _BV(CS01) | _BV(CS00);       // CS02:0 = 011 → 분주 64 (여기서 시작)
    OCR0A  = 249;                         // 16 MHz / 64 / 250 = 1000 Hz
    TCNT0  = 0;
    TIFR0  = _BV(OCF0A);                  // 남아 있던 플래그 지우기
    TIMSK0 = _BV(OCIE0A);                 // 비교 일치 A 인터럽트 허용
}

uint32_t millis(void)
{
    uint32_t m;
    ATOMIC_BLOCK(ATOMIC_RESTORESTATE) { m = g_ms; }   // 4바이트 → 원자적으로
    return m;
}

int main(void)
{
    DDRB |= _BV(DDB5);
    tick_init();
    sei();

    uint32_t last = millis();
    for (;;) {
        if ((uint32_t)(millis() - last) >= 500) {   // 뺄셈 비교 = 넘침에도 안전
            last += 500;                           // 주기가 밀리지 않게 누적
            PINB = _BV(PINB5);
        }
        /* 여기서 다른 일을 해도 깜빡임은 정확합니다 — delay 가 없으니까 */
    }
}

micros() — 카운터 값까지 읽기

// 위 millis.c 에 추가 — 해상도 4 µs
uint32_t micros(void)
{
    uint32_t m;
    uint8_t  t;
    ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {
        m = g_ms;
        t = TCNT0;
        // 방금 249 → 0 으로 넘어갔는데 ISR 이 아직 못 돈 경우(인터럽트가 꺼져 있으니까)
        if ((TIFR0 & _BV(OCF0A)) && t < 249) m++;
    }
    return m * 1000UL + (uint32_t)t * 4U;   // 약 71.6 분마다 0 으로 돌아감
}
대기 중 플래그 확인을 빼면 아주 드물게 micros 가 1000 µs 뒤로 가는 값이 나옵니다. 하루에 몇 번 생기는 버그라 테스트로는 안 잡히고 현장에서 "가끔 튀는 측정값"으로 나타납니다. Arduino 코어의 micros 도 똑같은 처리를 합니다(TOV0 기준).

Timer1 로 1 초 주기 — 16비트 타이머

// 16 000 000 / (1024 × (1 + 15624)) = 1.000 Hz
TCCR1A = 0;
TCCR1B = _BV(WGM12) | _BV(CS12) | _BV(CS10);  // 모드 4 CTC · 분주 1024
OCR1A  = 15624;                               // 16비트 레지스터 — 한 번에 대입
TIMSK1 = _BV(OCIE1A);
// ISR(TIMER1_COMPA_vect) { ... 1초마다 ... }
16비트 타이머 레지스터(TCNT1 · OCR1A · ICR1)는 내부 TEMP 레지스터로 상·하위 바이트를 한 번에 옮깁니다. 컴파일러가 순서를 맞춰 주지만, ISR 에서도 같은 16비트 타이머 레지스터를 만지면 TEMP 를 공유하므로 메인 쪽 접근을 ATOMIC_BLOCK 으로 감싸야 합니다.
정리 — OCR = F_CPU ÷ (N × f) − 1 이 정수이고 타이머 폭 안에 들어가는 가장 작은 분주를 고릅니다. 1 ms 는 분주 64 · OCR0A 249, ISR 에서 카운터를 올리고 메인에서는 ATOMIC_BLOCK 으로 읽고 뺄셈으로 비교합니다.
AVR 06

PWM — 켜짐 비율로 아날로그 흉내

ATmega328P 에는 진짜 아날로그 출력(DAC)이 없습니다. 대신 핀을 아주 빨리 켰다 껐다 하면서 켜져 있는 비율(듀티)을 바꿉니다. LED 는 눈의 잔상 때문에, 모터는 관성 때문에 평균값처럼 반응합니다. Arduino 의 analogWrite() 가 이것이고, 서보 모터는 반대로 펄스의 폭 자체를 명령으로 읽습니다.

같은 OCR 값, 다른 카운터 모양 — 펄스가 놓이는 자리가 다릅니다 Fast PWM 976.6 Hz OCR 출력 Phase Correct 490.2 Hz 출력 펄스 중심 = 카운터 바닥 (대칭) 둘 다 "카운터 < OCR 인 동안 HIGH" — 비반전 모드 (COMnx1 = 1)
PWM 파형 — Fast PWM 은 톱니라 주파수가 2배, Phase Correct 는 삼각파라 펄스가 대칭입니다. 모터 제어처럼 위상이 중요하면 아래쪽을 씁니다.

LED 밝기 — Timer2 Fast PWM (D11)

// pwm_led.c — D11(PB3, OC2A) LED 페이드 · ATmega328P @16MHz
#define F_CPU 16000000UL
#include <avr/io.h>
#include <util/delay.h>
#include <stdint.h>

static void pwm2_init(void)
{
    DDRB  |= _BV(DDB3);                         // OC2A 핀을 출력으로 (필수)
    TCCR2A = _BV(COM2A1)                        // 비반전: 일치 시 LOW, 바닥에서 HIGH
           | _BV(WGM21) | _BV(WGM20);           // 모드 3: Fast PWM, TOP = 0xFF
    TCCR2B = _BV(CS22);                         // Timer2 의 100 = 분주 64 → 976.6 Hz
    OCR2A  = 0;
}

// 사람 눈은 밝기를 로그로 느낌 → 제곱으로 펴 주면 페이드가 고르게 보임
static uint8_t gamma2(uint8_t v)
{
    return (uint8_t)(((uint16_t)v * v + 255) >> 8);   // 0→0, 255→255
}

int main(void)
{
    pwm2_init();
    for (;;) {
        for (uint16_t i = 0; i <= 255; i++) { OCR2A = gamma2((uint8_t)i); _delay_ms(4); }
        for (int16_t  i = 255; i >= 0;  i--) { OCR2A = gamma2((uint8_t)i); _delay_ms(4); }
    }
}
Fast PWM 에서 OCR = 0 은 완전히 꺼지지 않습니다. 매 주기 바닥에서 한 틱짜리 가는 펄스가 남습니다(데이터시트에 명시된 동작). 완전히 끄려면 TCCR2A &= ~_BV(COM2A1) 로 핀을 타이머에서 떼고 PORT 로 LOW 를 쓰세요 — Arduino 의 analogWrite(pin, 0) 도 내부에서 그렇게 합니다.

반복문 변수 타입 — 8비트 칩의 함정

for (uint8_t i = 0; i <= 255; i++) { }   // ✕ 무한 루프!
// i 가 255 에서 ++ 되면 0 이 되고, 0 <= 255 는 항상 참

for (uint8_t i = 255; i >= 0; i--) { }    // ✕ 이것도 무한 루프
// 부호 없는 값은 항상 0 이상 — 컴파일러가 -Wextra 로 경고합니다

// 해결: 한 단계 넓은 타입 (위 코드처럼 uint16_t / int16_t)
// 또는 do { ... } while (i++ != 255);
PWM 핀타이머Arduino 기본 주파수
D5 · D6Timer0 (Fast)976.6 Hz = 16M / (64 × 256)
D9 · D10Timer1 (Phase, 8비트)490.2 Hz = 16M / (64 × 510)
D3 · D11Timer2 (Phase)490.2 Hz
Phase Correct 의 분모가 512 가 아니라 510 인 이유 — 카운터가 0→255→0 으로 돌 때 꼭대기 255 와 바닥 0 에서 한 번씩만 머물러 한 주기가 2 × 255 = 510 틱입니다.

서보 모터 — Timer1 로 정확한 50 Hz 🟡 중급

계산 — ICR1 = 39999, 분주 8

서보 신호: 주기 20 ms (50 Hz), HIGH 폭 1.0 ms ~ 2.0 ms 가 각도
(제품에 따라 0.5 ~ 2.5 ms — 데이터시트 확인)

모드 14 Fast PWM, TOP = ICR1:
  f = F_CPU / ( N × (1 + ICR1) )
  ICR1 = 16 000 000 / (8 × 50) − 1 = 40 000 − 1 = 39 999   (≤ 65535 ✓)

1 틱 = 8 / 16 MHz = 0.5 µs
  1.0 ms → 1.0e-3 / 0.5e-6 = 2000 틱  → OCR1A = 2000
  1.5 ms →                   3000 틱  → OCR1A = 3000  (가운데)
  2.0 ms →                   4000 틱  → OCR1A = 4000
  (엄밀히는 비반전 Fast PWM 이 OCR1A+1 틱 동안 HIGH — 정확히 맞추려면 1999 ·
   2999 · 3999. 0.5 µs 차이는 서보에 영향 없어 여기선 읽기 쉬운 값을 씀)

검산: 16 000 000 / (8 × 40 000) = 50.000 Hz ✓
분주 1 이면 ICR1 = 319 999 → 16비트 초과 ✕
분주 64 면 1 틱 = 4 µs → 각도 해상도가 8배 거칠어짐

servo.c — D9(PB1, OC1A)

// servo.c — ATmega328P @16MHz, 서보 신호선을 D9 에
#define F_CPU 16000000UL
#include <avr/io.h>
#include <util/delay.h>
#include <stdint.h>

#define SERVO_MIN 2000u     // 1.0 ms
#define SERVO_MAX 4000u     // 2.0 ms

static void servo_init(void)
{
    DDRB  |= _BV(DDB1);                          // OC1A 출력
    TCCR1A = _BV(COM1A1) | _BV(WGM11);           // 비반전 · WGM13:0 = 1110 (모드 14)
    TCCR1B = _BV(WGM13) | _BV(WGM12) | _BV(CS11);// 분주 8
    ICR1   = 39999;                              // TOP → 50 Hz
    OCR1A  = 3000;                               // 가운데
}

static void servo_write(uint8_t deg)             // 0 ~ 180
{
    if (deg > 180) deg = 180;
    // 180 × 2000 = 360 000 → 16비트를 넘으므로 32비트로 계산
    OCR1A = (uint16_t)(SERVO_MIN + (uint32_t)deg * (SERVO_MAX - SERVO_MIN) / 180u);
}

int main(void)
{
    servo_init();
    for (;;) {
        servo_write(0);   _delay_ms(1000);
        servo_write(90);  _delay_ms(1000);
        servo_write(180); _delay_ms(1000);
    }
}
서보 전원을 Uno 의 5 V 핀에서 따지 마세요. 서보는 움직이는 순간 수백 mA 를 끌어 USB 전원이 출렁이고, 칩이 브라운아웃 리셋됩니다 (증상: 서보가 움직일 때마다 보드가 재시작). 서보는 별도 전원, GND 만 공통으로 묶습니다.
상황모드 · 설정이유
LED 밝기Fast PWM 8비트 · 수백 Hz~수 kHz깜빡임만 안 보이면 됨
DC 모터 (H 브리지)Phase Correct 또는 Fast · 20 kHz 이상가청 주파수면 모터가 "윙" 하고 웁니다
20 kHz 만들기Timer1 모드 14 · 분주 1 · ICR1 = 79916 MHz / 800 = 20 kHz, 듀티 800 단계
서보Timer1 모드 14 · 분주 8 · ICR1 = 3999950 Hz + 0.5 µs 해상도
소리 (tone)CTC + 비교 일치 토글 (COMnA0)듀티 50% 사각파, 주파수만 바꿈
정리 — PWM 은 카운터와 OCR 을 비교해 핀을 뒤집는 하드웨어 기능이라 CPU 를 쓰지 않습니다. 밝기는 Fast PWM, 대칭이 필요하면 Phase Correct, 정확한 주기가 필요하면 Timer1 + ICR1 을 TOP 으로 씁니다.
AVR 07

UART — Serial.print 를 직접 만듭니다

UART 는 선 두 개(TX · RX)로 바이트를 한 비트씩 보내는 가장 오래되고 가장 흔한 통신입니다. Uno 의 D0/D1 은 보드의 USB 칩에 연결돼 있어서, UART 로 보낸 글자가 그대로 PC 의 시리얼 모니터에 찍힙니다. 여기서는 보레이트 레지스터 계산과 오차, 폴링 송수신, 인터럽트 + 링 버퍼, 그리고 printf 연결까지 만듭니다. UART 프로토콜 일반론(패리티 · 흐름 제어 · RS-485)은 통신 · 드라이버 탭에서 다룹니다.

'A' = 0x41 = 0b0100_0001 을 보내면 — 최하위 비트(D0)가 먼저 나갑니다 시작 D0D1 D2D3 D4D5 D6D7 정지 HIGH (1) LOW (0) 쉼 01 00 00 01 01 1 비트 = 104.2 µs 프레임 10 비트 = 1.04 ms → 9600 bps 는 초당 최대 960 바이트 점 = 수신 측이 값을 읽는 비트 한가운데 — 오차가 쌓이면 마지막 비트에서 경계를 넘습니다
UART 프레임 — 클럭 선이 없어서 양쪽이 같은 속도라고 믿고 시간을 잽니다. 그래서 보레이트 오차가 통신 성패를 가릅니다.

UBRR 계산과 오차율 🟢 기초

식 · 16 MHz 에서의 값

보통 모드 (U2X0 = 0):  UBRR = F_CPU / (16 × baud) − 1   (반올림)
2배속 모드 (U2X0 = 1): UBRR = F_CPU / ( 8 × baud) − 1
실제 속도 = F_CPU / (16 또는 8 × (UBRR + 1))
오차      = 실제 속도 / 목표 − 1
baudU2X0정확한 값UBRR실제 속도오차
96000103.171039 615.4+0.16%
1207.332079 615.4+0.16%
57600016.361658 823.5+2.12%
133.723457 142.9−0.79%
11520007.688111 111.1−3.55% ✕
116.3616117 647.1+2.12%
위 값은 식을 그대로 계산해 확인한 것입니다. 16 MHz 는 보레이트와 '딱 나눠떨어지지 않는' 클럭이라 고속에서 오차가 커집니다. 그래서 통신이 중요한 보드는 14.7456 MHz 같은 '보레이트 크리스털'을 씁니다(115200 × 128 = 14 745 600).

오차를 그림으로

보레이트 오차 (%) — 점선이 ±2% 경계 −2% 0 +2% 9600 · U2X=0 +0.16 9600 · U2X=1 +0.16 57600 · U2X=0 +2.12 57600 · U2X=1 −0.79 115200 · U2X=0 −3.55 115200 · U2X=1 +2.12 같은 보레이트라도 U2X0 하나로 오차가 바뀝니다 — 항상 둘 다 계산
±2% 는 "대개 된다"의 경계일 뿐입니다. 상대 장치의 오차와 더해지기 때문에, 115200 에서 2.1% 는 PC 의 USB 변환기에 따라 가끔 글자가 깨지는 수준입니다. 데이터를 확실히 주고받아야 하면 16 MHz 에서는 57600(U2X=1)이나 38400 이하를 고르세요.

폴링 송수신 — 가장 단순한 형태

// uart_poll.c — 에코: 받은 글자를 그대로 돌려줌 · 9600 8N1
#define F_CPU 16000000UL
#define BAUD  9600
#include <avr/io.h>
#include <util/setbaud.h>      // F_CPU 와 BAUD 로 UBRRH_VALUE · USE_2X 계산
#include <stdint.h>

static void uart_init(void)
{
    UBRR0H = UBRRH_VALUE;
    UBRR0L = UBRRL_VALUE;
#if USE_2X
    UCSR0A |= _BV(U2X0);
#else
    UCSR0A &= ~_BV(U2X0);
#endif
    UCSR0B = _BV(RXEN0) | _BV(TXEN0);        // 수신 · 송신 켜기
    UCSR0C = _BV(UCSZ01) | _BV(UCSZ00);      // 8비트 · 패리티 없음 · 정지 1 (8N1)
}

static void uart_putc(uint8_t c)
{
    while (!(UCSR0A & _BV(UDRE0))) { }       // 송신 버퍼가 빌 때까지
    UDR0 = c;
}

static uint8_t uart_getc(void)
{
    while (!(UCSR0A & _BV(RXC0))) { }        // 1바이트 들어올 때까지
    return UDR0;
}

static void uart_puts(const char *s) { while (*s) uart_putc((uint8_t)*s++); }

int main(void)
{
    uart_init();
    uart_puts("ready\r\n");
    for (;;) uart_putc(uart_getc());
}

레지스터 지도

레지스터중요 비트
UDR0쓰면 송신, 읽으면 수신 바이트 (실제로는 두 개의 버퍼)
UCSR0ARXC0 수신됨 · UDRE0 송신 버퍼 빔 · TXC0 완전히 다 나감 · FE0/DOR0 프레임 오류/넘침 · U2X0
UCSR0BRXCIE0 · UDRIE0 · TXCIE0 인터럽트 허용 · RXEN0 · TXEN0
UCSR0CUCSZ01:00 데이터 비트 · UPM01:00 패리티 · USBS0 정지 비트
UBRR012비트 보레이트 분주값 (H · L)
UDRE0 와 TXC0 의 차이 — UDRE0 은 "다음 바이트를 넣어도 된다", TXC0 는 "마지막 비트까지 선 밖으로 나갔다". RS-485 방향 전환이나 잠들기 직전처럼 정말 다 보냈는지가 중요할 때만 TXC0 를 봅니다.
폴링 수신의 문제 — uart_getc() 를 부르지 않는 동안 들어온 바이트는 하드웨어 버퍼(2바이트)를 넘으면 사라집니다(DOR0). 9600 bps 에서도 1 ms 마다 1바이트씩 오므로, 메인 루프가 3 ms 만 다른 일을 해도 잃습니다. 그래서 다음의 인터럽트 + 링 버퍼가 필요합니다.

인터럽트 + 링 버퍼 — 하나도 안 잃게 🟡 중급

링 버퍼 — 끝에 닿으면 처음으로 (8 칸만 그림, 실제 64 칸) 0 1 H E L L 6 7 시계 방향 tail = 2 main 이 다음에 읽을 칸 head = 6 ISR 이 쓸 칸 규칙 네 줄 비었음: head == tail 가득 참: (head + 1) & MASK == tail → 64 칸이면 63 바이트까지 담음 head 는 ISR 만, tail 은 main 만 고침 인덱스가 uint8_t → 읽기 1 명령 = ATOMIC_BLOCK 없이도 안전 크기를 2 의 거듭제곱으로 두면 나머지 연산(%) 대신 AND 한 번 — 8비트 칩에서 큰 차이
링 버퍼 — 쓰는 쪽과 읽는 쪽이 서로 다른 변수만 고치므로 락이 필요 없습니다. 일반 원리는 C 실전 탭 c22, 여기서는 AVR UART 에 붙이는 법입니다.

uart_irq.c — 수신 · 송신 모두 인터럽트

// uart_irq.c — 링 버퍼 UART · 115200 (U2X) · ATmega328P @16MHz
#define F_CPU 16000000UL
#include <avr/io.h>
#include <avr/interrupt.h>
#include <stdint.h>

#define RX_SIZE 64u                   // 2 의 거듭제곱
#define TX_SIZE 64u
#define RX_MASK (RX_SIZE - 1u)
#define TX_MASK (TX_SIZE - 1u)

// 버퍼까지 volatile — 메인의 "데이터 쓰기 → head 갱신" 순서를
// 컴파일러가 뒤바꾸지 못하게 (volatile 접근끼리는 순서가 유지됨)
static volatile uint8_t rx_buf[RX_SIZE], tx_buf[TX_SIZE];
static volatile uint8_t rx_head, rx_tail, tx_head, tx_tail;
static volatile uint8_t rx_overflow;

ISR(USART_RX_vect)
{
    uint8_t c    = UDR0;              // 읽어야 RXC0 가 내려감
    uint8_t next = (uint8_t)((rx_head + 1u) & RX_MASK);
    if (next != rx_tail) { rx_buf[rx_head] = c; rx_head = next; }
    else if (rx_overflow < 255) rx_overflow++;   // 가득 참 — 버리고 셈
}

ISR(USART_UDRE_vect)
{
    if (tx_head == tx_tail) {         // 보낼 것 없음 → 이 인터럽트 끄기
        UCSR0B &= ~_BV(UDRIE0);
        return;
    }
    UDR0    = tx_buf[tx_tail];
    tx_tail = (uint8_t)((tx_tail + 1u) & TX_MASK);
}

void uart_init(void)
{
    UBRR0  = 16;                      // 115200 · U2X → +2.12%
    UCSR0A = _BV(U2X0);
    UCSR0C = _BV(UCSZ01) | _BV(UCSZ00);
    UCSR0B = _BV(RXEN0) | _BV(TXEN0) | _BV(RXCIE0);
}

int uart_getc(void)                   // 없으면 -1 (블로킹하지 않음)
{
    if (rx_head == rx_tail) return -1;
    uint8_t c = rx_buf[rx_tail];
    rx_tail = (uint8_t)((rx_tail + 1u) & RX_MASK);
    return c;
}

void uart_putc(uint8_t c)             // ★ ISR 안에서 부르지 말 것 (가득 차면 영원히 대기)
{
    uint8_t next = (uint8_t)((tx_head + 1u) & TX_MASK);
    while (next == tx_tail) { }       // 가득 참 → UDRE ISR 이 비워 줄 때까지
    tx_buf[tx_head] = c;
    tx_head = next;
    UCSR0B |= _BV(UDRIE0);            // "보낼 게 있다" — 빈 즉시 ISR 이 불림
}

int main(void)
{
    uart_init();
    sei();
    for (;;) {
        int c = uart_getc();
        if (c >= 0) uart_putc((uint8_t)c);   // 에코
        /* 메인이 바빠도 63 바이트까지는 ISR 이 받아 둡니다 */
    }
}

printf 연결 — FDEV_SETUP_STREAM

// 위 uart_irq.c 의 main 을 이것으로 바꾸면 printf 가 UART 로 나갑니다
#include <stdio.h>
#include <avr/pgmspace.h>

static int uart_putchar(char c, FILE *stream)
{
    (void)stream;
    if (c == '\n') uart_putc('\r');   // 터미널용 CR LF
    uart_putc((uint8_t)c);
    return 0;
}

// C 전용 정적 초기화 매크로 (C++ 에서는 fdev_setup_stream() 함수를 씀)
static FILE uart_out = FDEV_SETUP_STREAM(uart_putchar, NULL, _FDEV_SETUP_WRITE);

int main(void)
{
    uart_init();
    stdout = &uart_out;
    sei();

    printf("boot ok, ticks=%u\n", 42u);
    printf_P(PSTR("서식 문자열도 Flash 에 (a10)\n"));
    for (;;) { }
}
링크 옵션printf 의 능력
(기본)정수 · 문자열. %f 는 ? 로 찍힘
-Wl,-u,vfprintf -lprintf_min더 작게 — 폭·정밀도 일부 생략
-Wl,-u,vfprintf -lprintf_flt -lm%f 지원 — Flash 를 수 KB 더 씀
printf 는 공짜가 아닙니다. 정수 printf 만으로도 Flash 가 1.5 KB 안팎 늘고, 호출마다 스택을 수십 바이트 씁니다. 2 KB SRAM 에서 깊은 호출 안의 printf 는 스택 넘침의 단골 원인입니다. 온도처럼 소수가 필요하면 %f 대신 정수 ×10 으로 계산해 "%d.%d" 로 찍는 것이 정석입니다(a13).
Arduino 의 HardwareSerial 과 같은 구조입니다. Arduino 도 수신 64 · 송신 64 바이트 링 버퍼 + RX/UDRE 인터럽트로 되어 있고 (그래서 Serial 을 쓰는 순간 SRAM 이 백수십 바이트 사라집니다), 57600 에서는 옛 부트로더 호환 때문에 일부러 U2X 를 끈 설정을 쓰는 예외 처리까지 들어 있습니다.
정리 — UBRR 은 직접 계산하고 U2X0 두 경우의 오차를 비교한 뒤 고릅니다. 수신은 RX 인터럽트로 링 버퍼에, 송신은 UDRE 인터럽트로 링 버퍼에서 — 그리고 FDEV_SETUP_STREAM 한 줄로 printf 를 연결합니다.
AVR 08

ADC — 전압을 숫자로

ADC(Analog-to-Digital Converter)는 핀의 전압을 0~1023 의 정수로 바꿉니다. 가변저항 · 조도 센서 · 아날로그 온도 센서 같은 대부분의 센서가 결국 "전압"을 내놓으므로 ADC 는 센서 세계로 들어가는 문입니다. 기준 전압 고르기, 125 kHz 클럭, 한 번 변환과 자유 실행, 그리고 칩 안의 온도 센서까지 다룹니다.

ADC 는 계단입니다 — 3비트로 크게 그림 (실제 10비트 = 1024 계단) 0 4 7 코드 0 V Vref 입력 전압 → 1 LSB 10비트 · 5 V 기준 코드 = Vin × 1024 / Vref 1 LSB = 5 V / 1024 ≈ 4.88 mV 1.1 V 내부 기준 1 LSB ≈ 1.07 mV 작은 신호를 더 곱게 최대 코드 = 1023 (Vref 직전) 점선(이상)과 계단(실제)의 차이 = 양자화 오차, 최대 ±0.5 LSB
양자화 계단 — 정밀도는 비트 수와 기준 전압이 정합니다. 작은 전압을 재면 기준을 1.1 V 로 내려 같은 1024 계단을 촘촘하게 씁니다.

가변저항(A0) → LED 밝기(D11)

// adc_pot.c — 배선: 가변저항 양끝 5V·GND, 가운데 → A0(PC0)
#define F_CPU 16000000UL
#include <avr/io.h>
#include <stdint.h>

static void adc_init(void)
{
    ADMUX  = _BV(REFS0);              // REFS1:0 = 01 → 기준 = AVcc(5 V), 오른쪽 정렬
    ADCSRA = _BV(ADEN)                // ADC 켜기
           | _BV(ADPS2) | _BV(ADPS1) | _BV(ADPS0);   // 분주 128 → 125 kHz
    DIDR0  = _BV(ADC0D);              // A0 의 디지털 입력 버퍼 끄기 (전류·노이즈 감소)
}

static uint16_t adc_read(uint8_t ch)  // 단일 변환 · 약 104 µs
{
    ADMUX   = (uint8_t)((ADMUX & 0xF0) | (ch & 0x0F));   // 기준 비트는 유지
    ADCSRA |= _BV(ADSC);              // 변환 시작
    while (ADCSRA & _BV(ADSC)) { }    // 끝나면 하드웨어가 0 으로
    return ADC;                       // ADCL 먼저, ADCH 나중 — 매크로가 순서 보장
}

static uint16_t adc_to_mv(uint16_t raw)
{
    return (uint16_t)((uint32_t)raw * 5000u / 1024u);   // 1023×5000 → 32비트 필요
}

int main(void)
{
    adc_init();
    DDRB  |= _BV(DDB3);                                  // OC2A (a06 과 같은 설정)
    TCCR2A = _BV(COM2A1) | _BV(WGM21) | _BV(WGM20);
    TCCR2B = _BV(CS22);

    for (;;) {
        uint16_t raw = adc_read(0);
        OCR2A = (uint8_t)(raw >> 2);                     // 10비트 → 8비트
        (void)adc_to_mv(raw);                            // 디버깅 출력용 (a07 printf)
    }
}

설정값 고르기

REFS1:0기준 전압언제
00AREF 핀 (외부)정밀 기준 칩을 따로 달 때
01AVcc (Uno 는 5 V)가변저항 · 5 V 센서 — 기본
11내부 1.1 V작은 전압 · 칩 온도 센서
ADC 클럭은 50 ~ 200 kHz 여야 10비트 정밀도를 보장
16 MHz / 64  = 250 kHz   ✕ 너무 빠름
16 MHz / 128 = 125 kHz   ✓ → 유일한 선택
변환 1회 = 13 ADC 클럭 = 13 / 125 kHz = 104 µs  (최대 약 9.6 kSPS)
첫 변환(ADEN 직후) = 25 클럭 = 200 µs
AREF 핀에 외부 전압을 넣은 채로 AVcc/1.1 V 기준을 고르면 칩 안에서 두 전압이 맞붙어 칩이 상할 수 있습니다. Uno 의 AREF 핀은 커패시터만 달려 있으니 아무것도 연결하지 않는 것이 기본입니다.
ADC 클럭 1 칸 = 8 µs (125 kHz) — 막대 길이가 곧 시간 0 8 클럭 16 24 32 40 첫 변환 (ADEN 직후) 25 클럭 = 200 µs 단일 변환 13 클럭 = 104 µs 자유 실행 ISR ISR ISR 세로 짧은 선 = 샘플 앤 홀드 — 이 순간의 전압을 붙잡고 나머지 시간은 계산
변환 타이밍 — 첫 변환은 두 배 가까이 길고, 자유 실행은 끝나자마자 다음 변환을 시작해 약 9.6 kSPS 로 끊김 없이 측정합니다.

자유 실행 + 인터럽트

// adc_free.c — A0 를 끊임없이 측정, 최신값만 보관
#define F_CPU 16000000UL
#include <avr/io.h>
#include <avr/interrupt.h>
#include <util/atomic.h>
#include <stdint.h>

static volatile uint16_t g_adc;

ISR(ADC_vect) { g_adc = ADC; }       // 104 µs 마다

int main(void)
{
    ADMUX  = _BV(REFS0);                       // AVcc · ADC0
    ADCSRB = 0;                                // ADTS2:0 = 000 → 자유 실행
    ADCSRA = _BV(ADEN) | _BV(ADATE) | _BV(ADIE)
           | _BV(ADPS2) | _BV(ADPS1) | _BV(ADPS0);
    sei();
    ADCSRA |= _BV(ADSC);                       // 첫 변환만 시작해 주면 계속 돔

    DDRB |= _BV(DDB5);
    for (;;) {
        uint16_t v;
        ATOMIC_BLOCK(ATOMIC_RESTORESTATE) { v = g_adc; }   // 16비트 공유 → 원자적으로
        if (v > 512) PORTB |= _BV(PORTB5); else PORTB &= ~_BV(PORTB5);
    }
}
자유 실행 중에 채널(MUX)을 바꾸면 이미 시작된 변환은 옛 채널로 끝나고, 그다음 변환부터 새 채널입니다. 여러 채널을 돌아가며 재려면 ISR 에서 MUX 를 바꾸고 결과를 한 칸 늦게 해석해야 합니다 — 헷갈리면 단일 변환을 쓰세요.

칩 내부 온도 센서 (ADC8)

// chip_temp.c — 칩 온도가 50 °C 를 넘으면 D13 LED 경고
#define F_CPU 16000000UL
#include <avr/io.h>
#include <util/delay.h>
#include <stdint.h>

// ★ 두 상수는 '내 칩'으로 직접 보정해야 합니다
#define TS_RAW_AT_25C   352   // 25 °C 에서 잰 원시값 (칩마다 수십 LSB 차이)
#define TS_LSB_PER_C100 100   // 기울기 × 100 (약 1 LSB/°C 안팎 — 두 온도에서 재서 구함)

int16_t chip_temp_c(void)
{
    ADMUX = _BV(REFS1) | _BV(REFS0) | _BV(MUX3);  // 내부 1.1 V 기준 · MUX3:0 = 1000 (온도)
    _delay_ms(2);                                 // 기준 전압이 자리 잡을 시간
    ADCSRA |= _BV(ADSC);                          // 기준을 바꾼 뒤 첫 결과는 버림
    while (ADCSRA & _BV(ADSC)) { }

    uint32_t sum = 0;
    for (uint8_t i = 0; i < 16; i++) {           // 16회 평균 → 노이즈 감소
        ADCSRA |= _BV(ADSC);
        while (ADCSRA & _BV(ADSC)) { }
        sum += ADC;
    }
    int32_t raw = (int32_t)(sum / 16u);
    return (int16_t)((raw - TS_RAW_AT_25C) * 100 / TS_LSB_PER_C100 + 25);
}

int main(void)
{
    ADCSRA = _BV(ADEN) | _BV(ADPS2) | _BV(ADPS1) | _BV(ADPS0);   // ÷128
    DDRB  |= _BV(DDB5);
    for (;;) {
        if (chip_temp_c() > 50) PORTB |= _BV(PORTB5);
        else                    PORTB &= ~_BV(PORTB5);
        _delay_ms(1000);
    }
}
내부 온도 센서는 '칩 온도'이고 정확도는 보정 전 ±10 °C 수준입니다. 내부 1.1 V 기준 자체가 칩마다 1.0~1.2 V 로 흩어지기 때문입니다. 실내 온도 측정용이 아니라 과열 감지 · 온도 보상용으로 쓰고, 실내 온도는 LM35 같은 외부 센서를 씁니다(a13).
정리 — ADC 는 REFS 로 기준 전압, 분주 128 로 125 kHz, ADSC 로 시작합니다. 결과 × Vref / 1024 를 계산할 때는 32비트로 곱한 뒤 나누고, 공유되는 16비트 결과는 ATOMIC_BLOCK 으로 읽습니다.
AVR 09

I2C(TWI) · SPI — 레지스터로 버스 다루기

센서 · RTC · EEPROM · 디스플레이는 대부분 I2C(선 2개, 주소로 여러 장치) 나 SPI(선 4개, 빠름) 로 붙습니다. ATmega328P 는 둘 다 하드웨어로 가지고 있습니다(Atmel 은 I2C 를 상표 문제로 TWI, Two-Wire Interface 라고 부릅니다). 여기서는 레지스터로 한 바이트를 주고받는 최소 드라이버까지만 만듭니다. 프로토콜의 전기적 세부 · 클럭 스트레칭 · 버스 복구 · DMA 는 통신 · 드라이버 탭에서 깊게 다룹니다.

DS3231(주소 0x68) 의 초 레지스터 읽기 — 오른쪽 숫자가 TWSR 상태 코드 ATmega328P (마스터) DS3231 (슬레이브) ① START 0x08 ② 0xD0 = 0x68 << 1 | 쓰기(0) ACK 0x18 ③ 레지스터 번호 0x00 (초) ACK 0x28 ④ 반복 START (STOP 없이) 0x10 ⑤ 0xD1 = 0x68 << 1 | 읽기(1) ACK 0x40 ⑥ 데이터 0x37 (= 37초, BCD) 마스터: NACK = "이게 마지막" 0x58 ⑦ STOP 상태 코드가 기대와 다르면 그 자리에서 STOP 하고 오류 — 이것이 TWI 드라이버의 전부입니다
I2C 시퀀스 — 매 단계 끝에 TWSR 상태 코드를 확인합니다. "레지스터 번호 쓰기 → 반복 START → 읽기"는 거의 모든 I2C 센서에서 똑같은 모양입니다.

TWBR 계산 — 100 kHz

SCL = F_CPU / ( 16 + 2 × TWBR × 4^TWPS )      (TWPS = 0 → 4^0 = 1)

TWBR = ( F_CPU / SCL − 16 ) / 2
100 kHz : (16 000 000 / 100 000 − 16) / 2 = (160 − 16) / 2 = 72
400 kHz : (16 000 000 / 400 000 − 16) / 2 = ( 40 − 16) / 2 = 12

검산: 16 000 000 / (16 + 2 × 72) = 16 000 000 / 160 = 100 000 Hz ✓
상수 (util/twi.h)값뜻
TW_START0x08START 보냄
TW_REP_START0x10반복 START 보냄
TW_MT_SLA_ACK0x18주소+W 에 ACK
TW_MT_SLA_NACK0x20주소+W 에 NACK — 장치 없음
TW_MT_DATA_ACK0x28데이터에 ACK
TW_MR_SLA_ACK0x40주소+R 에 ACK
TW_MR_DATA_ACK0x50받음 · ACK 보냄 (더 줘)
TW_MR_DATA_NACK0x58받음 · NACK 보냄 (끝)
I2C 는 풀업 저항이 필수입니다(선을 HIGH 로 끌어올리는 저항, 보통 4.7 kΩ). 칩 내부 풀업(20~50 kΩ)은 너무 약해 100 kHz 에서도 파형이 무너집니다. 대부분의 센서 모듈 기판에는 이미 달려 있으니 모듈을 여러 개 달면 병렬로 너무 세지는 것도 확인하세요.

twi.c — DS3231 초 읽기 (완전한 코드)

// twi_rtc.c — A4=SDA(PC4), A5=SCL(PC5) · ATmega328P @16MHz
#define F_CPU 16000000UL
#include <avr/io.h>
#include <util/twi.h>        // TW_STATUS, TW_START ...
#include <stdint.h>

#define DS3231_ADDR 0x68

static void twi_init(void)
{
    TWSR = 0;                                 // TWPS = 0 (분주 1)
    TWBR = 72;                                // 100 kHz
    TWCR = _BV(TWEN);
}
static void twi_wait(void) { while (!(TWCR & _BV(TWINT))) { } }

static uint8_t twi_start(uint8_t sla_rw)     // 0 = 성공
{
    TWCR = _BV(TWINT) | _BV(TWSTA) | _BV(TWEN);
    twi_wait();
    if (TW_STATUS != TW_START && TW_STATUS != TW_REP_START) return 1;
    TWDR = sla_rw;
    TWCR = _BV(TWINT) | _BV(TWEN);
    twi_wait();
    return (TW_STATUS == TW_MT_SLA_ACK || TW_STATUS == TW_MR_SLA_ACK) ? 0 : 2;
}
static uint8_t twi_write(uint8_t b)
{
    TWDR = b;
    TWCR = _BV(TWINT) | _BV(TWEN);
    twi_wait();
    return TW_STATUS == TW_MT_DATA_ACK ? 0 : 1;
}
static uint8_t twi_read(uint8_t ack)        // ack=1: 더 읽음, 0: 마지막
{
    TWCR = _BV(TWINT) | _BV(TWEN) | (ack ? _BV(TWEA) : 0);
    twi_wait();
    return TWDR;
}
static void twi_stop(void)
{
    TWCR = _BV(TWINT) | _BV(TWSTO) | _BV(TWEN);
    while (TWCR & _BV(TWSTO)) { }            // STOP 이 나갈 때까지
}

static int16_t rtc_seconds(void)             // 실패하면 -1
{
    int16_t r = -1;
    uint8_t bcd;
    if (twi_start((DS3231_ADDR << 1) | TW_WRITE)) goto out;
    if (twi_write(0x00))                      goto out;   // 초 레지스터
    if (twi_start((DS3231_ADDR << 1) | TW_READ))  goto out;
    bcd = twi_read(0);                                     // 1바이트만 → NACK
    r = (int16_t)((bcd >> 4) * 10 + (bcd & 0x0F));         // BCD → 10진
out:
    twi_stop();
    return r;
}

int main(void)
{
    twi_init();
    DDRB |= _BV(DDB5);
    int16_t last = -1;
    for (;;) {
        int16_t s = rtc_seconds();
        if (s < 0) { PORTB |= _BV(PORTB5); continue; }   // 장치 없음 → LED 켜진 채
        if (s != last) { last = s; PINB = _BV(PINB5); }   // 초가 바뀔 때마다 토글
    }
}
이 드라이버의 약점 — 무한 대기. twi_wait() 는 버스가 고장 나면(선이 빠지거나 슬레이브가 SDA 를 LOW 로 잡고 멈추면) 영원히 돌아오지 않습니다. 양산 코드는 a05 의 millis() 로 타임아웃을 두고, 실패하면 SCL 을 GPIO 로 9번 흔들어 버스를 푸는 버스 복구 절차를 넣습니다 — 통신 · 드라이버 탭의 주제입니다.

SPI 마스터 — 가장 단순한 고속 버스 🟡 중급

spi.c — 1바이트 보내고 1바이트 받기

// spi_master.c — D13=SCK(PB5), D12=MISO(PB4), D11=MOSI(PB3), D10=SS(PB2)
#define F_CPU 16000000UL
#include <avr/io.h>
#include <stdint.h>

#define CS_LOW()  (PORTB &= ~_BV(PORTB2))
#define CS_HIGH() (PORTB |=  _BV(PORTB2))

static void spi_init(void)
{
    // ★ SS(PB2)를 반드시 출력으로 — 입력인 채 LOW 가 되면 슬레이브 모드로 떨어짐
    DDRB |= _BV(DDB5) | _BV(DDB3) | _BV(DDB2);
    CS_HIGH();
    SPCR  = _BV(SPE) | _BV(MSTR) | _BV(SPR0);   // 켜기 · 마스터 · 모드 0 · f/16 = 1 MHz
    SPSR &= ~_BV(SPI2X);
}

static uint8_t spi_xfer(uint8_t out)            // SPI 는 보내는 동시에 받습니다
{
    SPDR = out;
    while (!(SPSR & _BV(SPIF))) { }
    return SPDR;
}

int main(void)
{
    spi_init();
    for (;;) {
        CS_LOW();
        uint8_t id = spi_xfer(0x9F);           // 예: SPI 플래시의 JEDEC ID 명령
        id = spi_xfer(0x00);                   // 더미를 보내야 응답이 들어옴
        CS_HIGH();
        (void)id;
    }
}

I2C vs SPI — 고르는 기준

I2C (TWI)SPI
선SDA · SCL (2개)SCK · MOSI · MISO + 장치마다 CS
속도 (328P)100 / 400 kHz최대 F_CPU/2 = 8 MHz
장치 선택7비트 주소CS 핀을 LOW 로
응답 확인ACK 비트가 있음없음 — 장치가 없어도 0xFF 를 '받음'
흔한 장치RTC · 온습도 · OLEDSD 카드 · 플래시 · TFT · 무선 칩
SPI2X SPR1 SPR0SCK16 MHz 에서
0 0 0f/44 MHz
0 0 1f/161 MHz
1 0 0f/28 MHz
0 1 1f/128125 kHz (SD 카드 초기화용)
모드 0~3 은 클럭의 쉬는 레벨(CPOL)과 읽는 에지(CPHA)의 조합입니다. 장치 데이터시트의 타이밍 그림에서 고르고 SPCR 의 CPOL · CPHA 비트에 씁니다. 이 핀들(D11~D13)은 ISP 업로드 핀과 같아서 업로드 중에는 SPI 장치가 방해할 수 있습니다.
정리 — TWI 는 TWBR = (F_CPU/SCL − 16)/2 로 속도를 정하고, 매 단계 TWSR 상태 코드를 확인하는 상태 기계입니다. SPI 는 SPDR 에 쓰고 SPIF 를 기다리면 끝 — 단, SS 핀은 반드시 출력으로 둡니다.
AVR 10

2 KB SRAM 아끼기 — PROGMEM 과 스택-힙 충돌

서버에서는 메모리가 GB 단위라 문자열 상수가 어디 놓이는지 신경 쓸 일이 없습니다. ATmega328P 의 SRAM 은 2048 바이트 — 이 문단의 글자 수보다 적습니다. 게다가 하버드 구조라 상수 문자열도 기본적으로 SRAM 에 복사됩니다. 어디에 무엇이 놓이는지, 스택과 힙이 어떻게 부딪히는지, Arduino 의 F() 가 무엇을 하는지 정확히 봅니다.

데이터 주소 공간 — 왼쪽이 낮은 주소, 폭은 바이트 수에 비례 레지스터 I/O .data .bss 힙 비어 있는 곳 이 폭이 0 이 되면 충돌 스택 malloc 할수록 → ← 함수 호출 · 지역 배열 · ISR 0x0000 0x0100 0x08FF SRAM 2048 바이트 — avr-size 의 data + bss 가 왼쪽 두 칸 .data = 초기값 있는 전역 · 문자열 상수 (시작할 때 Flash 에서 복사) .bss = 0 으로 시작하는 전역 · static 변수 (시작할 때 0 으로 채움) 스택의 최대 깊이는 avr-size 에 안 나옵니다 — 그래서 직접 재야 합니다
SRAM 배치 — 힙은 위로, 스택은 아래로 자랍니다. MMU 도 보호 페이지도 없어서 둘이 만나도 아무 경고 없이 서로의 값을 조용히 덮어씁니다.
같은 문자열 "Temperature sensor ready" (25 바이트) 를 두는 두 방법 const char msg[] = "…" const char msg[] PROGMEM = "…" Flash — 원본 25 바이트 (.data 의 초기값 이미지) 부팅 때 __do_copy_data 가 복사 SRAM .data — 25 바이트 사용 평생 비울 수 없음 Flash 에만 25 바이트 복사 없음 SRAM 사용 0 바이트 읽을 때만 LPM 명령으로 1바이트씩 읽기: msg[i] — 평범한 포인터 읽기: pgm_read_byte(&msg[i]) const 는 "바꾸지 않겠다"는 약속일 뿐, 놓이는 자리를 바꾸지 않습니다
좌우 대조 — AVR 에서 const 만으로는 SRAM 을 못 아낍니다. Flash 에 남기려면 PROGMEM 으로 자리를 지정하고 전용 함수로 읽어야 합니다.

PROGMEM 실전 — 문자열 · 표 · 문자열 표

// progmem_demo.c — a07 의 uart_putc 와 함께 링크
#define F_CPU 16000000UL
#include <avr/io.h>
#include <avr/pgmspace.h>
#include <stdint.h>

void uart_putc(uint8_t c);                 // a07 uart_irq.c

// ① 문자열 하나
static const char banner[] PROGMEM = "Temperature logger v1\r\n";

// ② 숫자 표 — 큰 룩업 테이블일수록 효과가 큽니다
static const uint8_t gamma8[16] PROGMEM = {
    0, 1, 2, 4, 7, 11, 16, 22, 29, 37, 46, 56, 67, 80, 93, 110
};

// ③ 문자열들의 표 — 표 자체도, 각 문자열도 Flash 에
static const char s_ok[]  PROGMEM = "OK";
static const char s_err[] PROGMEM = "ERROR";
static const char *const s_msgs[] PROGMEM = { s_ok, s_err };

static void uart_puts_P(const char *p)     // _P = "Flash 주소를 받는다"는 관례
{
    char c;
    while ((c = (char)pgm_read_byte(p++)) != '\0') uart_putc((uint8_t)c);
}

void demo(uint8_t i)
{
    uart_puts_P(banner);
    uint8_t g = pgm_read_byte(&gamma8[i & 15]);
    (void)g;
    const char *m = (const char *)pgm_read_word(&s_msgs[i & 1]);  // 포인터도 Flash 에서 읽기
    uart_puts_P(m);
    uart_puts_P(PSTR("\r\n"));             // PSTR = 그 자리에서 PROGMEM 문자열 리터럴
}
가장 흔한 버그: PROGMEM 데이터를 그냥 읽기. banner[0] 처럼 일반 접근을 하면 컴파일 오류 없이 같은 번호의 SRAM 주소를 읽어 엉뚱한 값이 나옵니다. GNU C 의 const __flash char msg[] 를 쓰면 컴파일러가 알아서 LPM 으로 읽어 주지만(C 전용, C++ 불가), 이식성 때문에 PROGMEM 이 더 흔합니다.

Arduino F() 매크로의 정체

// Arduino 코어 WString.h 에 실제로 이렇게 정의돼 있습니다
class __FlashStringHelper;   // 정의 없는 '꼬리표' 타입
#define F(string_literal) \
    (reinterpret_cast<const __FlashStringHelper *>(PSTR(string_literal)))

// Serial.print(F("hello"))
//  → PSTR 로 Flash 에 둔 문자열의 주소를 '다른 타입'으로 바꿔서 넘김
//  → Print::print(const __FlashStringHelper *) 오버로드가 골라지고
//  → 그 안에서 pgm_read_byte 로 한 글자씩 읽어 출력

즉 F() 는 마법이 아니라 PSTR + 타입 꼬리표입니다. C++ 오버로딩으로 "이 포인터는 Flash 주소"라는 정보를 타입에 실어 보낸 것이고, C 에서는 그 대신 printf_P · strcpy_P · strcmp_P 처럼 함수 이름 끝의 _P 로 구분합니다.

일반Flash 판
strcpy · strcmp · strlenstrcpy_P · strcmp_P · strlen_P
printf · putsprintf_P · puts_P
memcpymemcpy_P
x = tbl[i]pgm_read_byte · _word · _dword · _float

남은 RAM 재기 — 지금 값과 최악의 값 🔴 고급

① 지금 이 순간의 여유 — free_ram()

#include <stdint.h>

extern char  __heap_start;      // 링커 심볼: 힙이 시작하는 곳 (.bss 끝)
extern char *__brkval;          // avr-libc malloc: 지금 힙의 끝 (안 썼으면 0)

int free_ram(void)
{
    char top;                   // 이 지역 변수의 주소 ≈ 현재 스택 끝
    return (int)(&top - (__brkval ? __brkval : &__heap_start));
}
// 한계: '부른 그 순간'의 값입니다. 가장 깊은 호출이나 ISR 이
// 겹친 순간의 최악값은 놓칩니다 → ② 스택 칠하기

② 최악의 여유 — 스택 칠하기

#include <stdint.h>

extern uint8_t _end;            // 링커 심볼: .bss/.noinit 의 끝
extern uint8_t __stack;         // 스택 꼭대기 (기본값 RAMEND = 0x08FF)

// .init3 = 스택 포인터와 r1(=0) 설정이 끝난 직후, main 이전에 실행되는 구간
void stack_paint(void) __attribute__((naked, used, section(".init3")));
void stack_paint(void)
{
    uint8_t *p = &_end;
    while (p <= &__stack) *p++ = 0xC5;   // 아직 스택에 아무것도 없을 때 칠함
}

uint16_t stack_never_used(void)          // 한 번도 덮이지 않은 바이트 수
{
    const uint8_t *p = &_end;
    uint16_t n = 0;
    while (p <= &__stack && *p == 0xC5) { p++; n++; }
    return n;                            // 힙(malloc)을 안 쓰는 프로그램 기준
}

SRAM 다이어트 체크리스트

#할 일효과
1문자열 상수 → PROGMEM / F() / PSTR보통 가장 큼
2룩업 테이블 → PROGMEM표 크기만큼
3int → uint8_t (AVR 의 int 는 2바이트)배열이면 절반
4큰 지역 배열 → static 으로 (스택 대신 .bss)최악 스택이 줄고 측정 가능해짐
5malloc · String · 재귀 금지단편화 · 예측 불가 제거
6링 버퍼 크기 재검토 (64 → 32)버퍼마다 32 바이트
7printf 대신 직접 숫자 출력스택 수십 바이트
# 함수별 스택 사용량 — 컴파일할 때 .su 파일이 생깁니다
avr-gcc ... -fstack-usage -Wstack-usage=128 -c main.c
cat main.su        # main.c:42:6:handle_cmd  64  static
스택 넘침의 증상은 '이상한 동작'입니다. 전역 변수가 저절로 바뀌거나, 함수가 엉뚱한 곳으로 돌아가 리셋되거나, 특정 메뉴에서만 멈춥니다. 서버라면 StackOverflowError 가 났을 자리에서 아무 오류 없이 다른 데이터가 망가집니다. avr-size 의 data + bss 가 1.5 KB 를 넘으면 위험 신호로 보세요. avr-libc 의 malloc 은 스택과 __malloc_margin(기본 32 바이트)만큼만 거리를 둡니다.
정리 — AVR 에서 const 문자열도 SRAM 을 먹습니다. PROGMEM 으로 Flash 에 두고 pgm_read_* · _P 함수로 읽으며, F() 는 그 PSTR 에 타입 꼬리표를 붙인 것입니다. 남은 RAM 은 순간값(free_ram)과 최악값(스택 칠하기)을 둘 다 봅니다.
AVR 11

EEPROM · 퓨즈 · 부트로더 — 칩의 설정과 생애

전원이 꺼져도 남는 작은 저장소 EEPROM, 칩의 클럭과 부팅 방식을 정하는 퓨즈, 그리고 USB 케이블 하나로 업로드를 가능하게 하는 부트로더. 셋 다 "한 번 잘못 쓰면 오래 간다"는 공통점이 있습니다. 특히 퓨즈는 잘못 쓰면 칩이 응답하지 않는 상태(벽돌)가 되므로, 무엇이 위험한지부터 정확히 압니다.

EEPROM — 설정 저장 (완전한 코드)

// eeprom_cfg.c — 부팅 횟수와 설정을 EEPROM 에 · ATmega328P @16MHz
#define F_CPU 16000000UL
#include <avr/io.h>
#include <avr/eeprom.h>
#include <stdint.h>

typedef struct {
    uint8_t  version;          // 구조가 바뀌면 올림 → 옛 데이터 무시
    uint8_t  brightness;
    uint16_t log_interval_s;
    uint8_t  checksum;         // 나머지 바이트의 합의 보수
} Config;

// EEMEM = EEPROM 주소 공간에 배치 (.eeprom 섹션 → app.eep 파일)
static Config   EEMEM ee_cfg;
static uint16_t EEMEM ee_boot_count;

static uint8_t cfg_sum(const Config *c)
{
    const uint8_t *p = (const uint8_t *)c;
    uint8_t s = 0;
    for (uint8_t i = 0; i < sizeof(Config) - 1; i++) s += p[i];
    return (uint8_t)~s;
}

static void cfg_load(Config *c)
{
    eeprom_read_block(c, &ee_cfg, sizeof *c);
    if (c->version != 1 || c->checksum != cfg_sum(c)) {   // 처음이거나 깨짐
        c->version = 1; c->brightness = 128; c->log_interval_s = 60;
    }
}

static void cfg_save(Config *c)
{
    c->checksum = cfg_sum(c);
    eeprom_update_block(c, &ee_cfg, sizeof *c);  // ★ 바뀐 바이트만 실제로 씀
}

int main(void)
{
    Config cfg;
    cfg_load(&cfg);

    uint16_t boots = eeprom_read_word(&ee_boot_count);
    if (boots == 0xFFFF) boots = 0;               // 지워진 EEPROM 은 0xFF
    eeprom_update_word(&ee_boot_count, (uint16_t)(boots + 1));

    cfg.brightness = 200;
    cfg_save(&cfg);
    for (;;) { }
}

EEPROM 의 성질

항목값 · 의미
크기1024 바이트 (주소 0~1023)
지운 상태0xFF — 처음 읽으면 전부 255
쓰기 수명셀마다 10만 회 (Flash 는 1만 회)
쓰기 시간바이트당 약 3.3 ms — 그동안 함수가 기다림
읽기빠르고 수명 소모 없음
업로드 시부트로더 업로드는 EEPROM 을 건드리지 않음 · ISP 칩 지우기는 EESAVE 퓨즈에 따라 지움
10만 회는 생각보다 빨리 닳습니다. 1분마다 같은 칸에 쓰면 100 000 ÷ 1 440(하루 분 수) = 약 69 일 만에 수명이 끝납니다. eeprom_update_* 는 값이 같으면 쓰지 않아 수명을 아끼고, 자주 바뀌는 값은 여러 칸을 돌아가며 써야 합니다(a13 에서 구현).
전원이 쓰기 도중 떨어지면 그 바이트가 깨질 수 있습니다. 그래서 위 코드처럼 버전 + 체크섬으로 검증하고, 깨졌으면 기본값으로 돌아갑니다. Uno 의 퓨즈는 BOD(브라운아웃 감지) 2.7 V 가 켜져 있어 전압이 내려가면 쓰기 전에 리셋합니다 — 끄지 마세요.

퓨즈 — 잘못 쓰면 벽돌 🔴 고급

Uno 기본 퓨즈 — 0 = 켜짐(programmed), 1 = 꺼짐. 거꾸로라서 헷갈립니다 하이 0xDE bit7bit0 RSTDISBL1 DWEN1 SPIEN0 WDTON1 EESAVE1 BOOTSZ11 BOOTSZ01 BOOTRST0 0 → 벽돌 0 → ISP 불가 ISP 허용 부트 영역 512 B 부트로더로 시작 로우 0xFF CKDIV81 CKOUT1 SUT11 SUT01 CKSEL31 CKSEL21 CKSEL11 CKSEL01 0 → 클럭 ÷8 시작 지연 최대 1111 = 크리스털·공진자 8 MHz 이상 0000 = 외부 클럭 입력 → 클럭 없으면 먹통 확장 0xFD: BODLEVEL = 101 → 2.7 V 브라운아웃 감지 진한 칸 = 0(켜짐) — 붉은 글씨가 붙은 비트는 바꾸기 전에 세 번 확인
퓨즈 매트릭스 — 위험한 것은 리셋 핀을 없애는 비트(RSTDISBL · DWEN)와 클럭 소스(CKSEL)입니다. 둘 다 ISP 로 다시 고칠 길을 막습니다.

퓨즈 읽기 · 쓰기 — ISP 프로그래머 필요

# 퓨즈는 부트로더로는 못 바꿉니다 — USBasp 같은 ISP 장비를 ICSP 헤더에
# 1) 반드시 먼저 읽어서 기록해 둡니다
avrdude -c usbasp -p m328p -U lfuse:r:-:h -U hfuse:r:-:h -U efuse:r:-:h
#   → 0xff  0xde  0xfd   (Uno R3 기본)

# 2) 쓰기 — 값은 계산기로 두 번 확인한 뒤
avrdude -c usbasp -p m328p -U lfuse:w:0xFF:m -U hfuse:w:0xDE:m -U efuse:w:0xFD:m

# 다른 Uno 를 ISP 장비로 (예제 스케치 ArduinoISP 를 올린 보드)
avrdude -c stk500v1 -P COM5 -b 19200 -p m328p -U hfuse:r:-:h

# 클럭이 느린 칩(1 MHz 등)은 ISP 클럭도 낮춰야 응답합니다
avrdude -c usbasp -B 32 -p m328p -U lfuse:r:-:h
ISP(In-System Programming) = 칩을 보드에서 빼지 않고 SPI 핀(D11 · D12 · D13 + RESET)으로 직접 Flash · 퓨즈 · 부트로더를 쓰는 방법. 부트로더가 망가졌을 때의 최후 수단이기도 합니다. 새 ATmega328P 는 공장 출하 시 내부 8 MHz ÷ 8 = 1 MHz(로우 0x62)라서 ISP 를 느리게 해야 합니다.

벽돌 시나리오와 복구

잘못 쓴 것증상복구
CKSEL = 외부 클럭ISP 가 signature 0x000000XTAL1 에 외부 클럭(다른 보드의 PWM 핀)을 넣고 ISP
CKDIV8 = 0모든 게 8배 느림 · ISP 실패-B 로 ISP 를 느리게 하고 되돌림
RSTDISBL = 0리셋 핀이 GPIO 가 됨 · ISP 불가12 V 고전압 프로그래머만 가능
DWEN = 0debugWIRE 모드 · ISP 불가debugWIRE 지원 디버거로 해제
SPIEN = 1(ISP 로는 쓸 수 없음)고전압 프로그래머
퓨즈 값은 손으로 비트를 세지 말고 퓨즈 계산기(웹 도구 · 데이터시트 표)로 만든 뒤, 쓰기 전에 CKSEL 과 RSTDISBL 두 곳만은 눈으로 다시 보세요. "0 이 켜짐"이라는 거꾸로 된 규칙 때문에 한 비트 실수가 가장 많이 나는 곳입니다.

부트로더 — Optiboot 는 언제 무엇을 하나 🟡 중급

리셋 이후 — 원이 상태, 화살표 위 글자가 넘어가는 조건 리셋 0x7E00 부터 Optiboot 원인 확인 사용자 앱 0x0000 부터 업로드 Flash 쓰기 BOOTRST=0 전원 투입 · 워치독 리셋 → 즉시 점프 외부 리셋 후 명령 수신 끝나면 워치독 리셋 외부 리셋인데 약 1 초간 조용함 avrdude 가 DTR 로 리셋을 걸고 바로 명령을 보내는 이유가 이 1 초 창 때문입니다
부트로더 상태 기계 — Optiboot 는 Flash 끝 512 바이트에 살면서 외부 리셋 직후 잠깐만 업로드를 기다립니다. 그래서 앱이 쓸 수 있는 Flash 는 32 256 바이트입니다.

부트로더를 굽거나 버리기

# 부트로더 굽기 (Arduino IDE 의 "부트로더 굽기"가 하는 일)
avrdude -c usbasp -p m328p -e -U lock:w:0x3F:m \
        -U efuse:w:0xFD:m -U hfuse:w:0xDE:m -U lfuse:w:0xFF:m
avrdude -c usbasp -p m328p -U flash:w:optiboot_atmega328.hex:i -U lock:w:0x0F:m
#   lock 0x0F = 앱이 부트 영역을 덮어쓰지 못하게 보호

# 부트로더 없이 ISP 로만 쓰기 — 512 B 를 앱이 다 씀
#   BOOTRST 를 꺼서(1) 리셋 시 0x0000 에서 바로 시작하게
avrdude -c usbasp -p m328p -U hfuse:w:0xDF:m -U flash:w:app.hex:i
#   ※ ISP 업로드는 칩 지우기를 하므로 부트로더가 사라집니다

부트로더 vs ISP

부트로더 (USB)ISP 프로그래머
장비USB 케이블만USBasp · AVRISP · 다른 Arduino
쓸 수 있는 것Flash (+EEPROM 일부 부트로더)Flash · EEPROM · 퓨즈 · 락 비트
Flash 여유32 256 B32 768 B
부팅 지연외부 리셋 시 약 1 초없음
망가지면ISP 로 다시 구움고전압 프로그래머
양산 제품은 대개 ISP(또는 테스트 지그)로 굽고 부트로더를 쓰지 않거나, 무선 업데이트를 위해 자체 부트로더를 만듭니다. A/B 파티션 · 서명 검증 같은 펌웨어 업데이트 설계는 양산 · 펌웨어 운영 탭에서 다룹니다.
정리 — EEPROM 은 eeprom_update_* + 체크섬으로, 수명 10만 회를 계산해서 씁니다. 퓨즈는 먼저 읽어 기록하고, CKSEL · RSTDISBL 은 두 번 확인합니다. Optiboot 는 Flash 끝 512 B 에서 외부 리셋 직후 1 초만 업로드를 기다립니다.
AVR 12

저전력 — 대부분의 시간을 자게 만듭니다

배터리로 도는 센서는 "얼마나 빨리 일하나"보다 "얼마나 깊이, 오래 자나"가 수명을 정합니다. ATmega328P 는 깨어 있을 때 mA 단위, 가장 깊은 잠에서는 µA 아래까지 내려갑니다 — 만 배 이상 차이입니다. sleep 모드 고르기, 워치독으로 주기적으로 깨기, 안 쓰는 주변장치 끄기, 그리고 Uno 보드로는 왜 안 되는지까지 봅니다.

소비 전류 — 가로축이 로그 눈금 (한 칸 = 10 배) 0.1 µA 1 µA 10 µA 100 µA 1 mA 10 mA 100 mA Uno 보드 전체 ≈ 45 mA 칩 · 동작 16 MHz ≈ 10 mA 칩 · Idle ≈ 3 mA Power-down + WDT ≈ 6 µA Power-down 전부 끔 < 1 µA 칩을 µA 로 재워도 Uno 보드는 USB 칩·레귤레이터·LED 로 수십 mA 를 계속 씁니다
소비 전류(대략치, 5 V) — 동작과 깊은 잠의 차이는 약 1000 ~ 10000 배. 하지만 보드가 칩보다 수천 배 더 먹으면 칩을 재우는 의미가 없습니다.

sleep 모드 여섯 가지

모드멈추는 것깨우는 것
IdleCPU 만모든 인터럽트 (타이머 · UART 포함)
ADC Noise Red.CPU · I/O 클럭ADC 완료 · 외부 · WDT · Timer2 비동기
Power-down거의 전부 (발진기 정지)INT0/1 레벨 · 핀 변화 · WDT · TWI 주소 일치
Power-savePower-down + Timer2 비동기 동작위 + Timer2 (32 kHz 시계)
StandbyPower-down 이지만 발진기 유지Power-down 과 같음 · 6 클럭 만에 깸
Ext. StandbyPower-save + 발진기 유지Power-save 와 같음
Power-down 에서 INT0 의 '에지' 인터럽트는 깨우지 못합니다 — 에지 감지에는 I/O 클럭이 필요한데 꺼져 있기 때문입니다. 버튼으로 깨우려면 LOW 레벨(ISC01:00 = 00) 이나 핀 변화 인터럽트(PCINT)를 쓰세요. a04 의 하강 에지 설정 그대로는 잠에서 안 깹니다.

sleepy.c — 8 초마다 깨어 5 ms 일하기

// sleepy.c — 워치독 인터럽트로 주기 기상 · ATmega328P @16MHz
#define F_CPU 16000000UL
#include <avr/io.h>
#include <avr/interrupt.h>
#include <avr/sleep.h>
#include <avr/wdt.h>
#include <avr/power.h>
#include <util/delay.h>

ISR(WDT_vect) { }                       // 깨우기만 하면 됨 (비어 있어도 필수)

static void wdt_interrupt_8s(void)
{
    cli();
    wdt_reset();
    MCUSR  &= ~_BV(WDRF);               // WDRF 가 1 이면 WDE 를 끌 수 없음
    WDTCSR  =  _BV(WDCE) | _BV(WDE);     // 변경 허가 — 4 클럭 안에 다음 쓰기 (-Os 에서 성립)
    WDTCSR  =  _BV(WDIE) | _BV(WDP3) | _BV(WDP0);   // 인터럽트 모드만, 약 8 s
    sei();
}

static void sleep_power_down(void)      // avr-libc 문서의 권장 순서
{
    set_sleep_mode(SLEEP_MODE_PWR_DOWN);
    cli();
    sleep_enable();
    sleep_bod_disable();                // 잠든 동안 BOD 끄기 (약 20 µA 절약)
    sei();                              // sei 직후 한 명령은 인터럽트 없이 실행
    sleep_cpu();                        // → 여기서 잠듦 · WDT ISR 후 다음 줄부터
    sleep_disable();
}

int main(void)
{
    ADCSRA &= ~_BV(ADEN);               // ADC 먼저 끄고 (PRR 보다 앞에)
    ACSR   |=  _BV(ACD);                // 아날로그 비교기 끄기
    power_all_disable();                // PRR: TWI·타이머·SPI·USART·ADC 클럭 차단

    DDRB  |= _BV(DDB5);
    PORTC |= 0x3F;                      // 안 쓰는 입력은 풀업 — 떠 있는 입력은 전류를 먹음
    PORTD |= 0xFF;

    wdt_interrupt_8s();
    for (;;) {
        PORTB |=  _BV(PORTB5);          // (일하는 척) 5 ms
        _delay_ms(5);
        PORTB &= ~_BV(PORTB5);
        sleep_power_down();
    }
}

배터리 수명 — 평균 전류로 계산합니다 🟡 중급

계산

평균 전류 = (I동작 × t동작 + I잠 × t잠) / 주기

칩만 (8 초 주기, 5 ms 동작):
  = 10 mA × 0.005 s / 8 s + 0.006 mA
  = 0.00625 mA + 0.006 mA = 0.01225 mA (12.25 µA)
  2000 mAh / 0.01225 mA = 163 265 h ≈ 18.6 년  ← 이론값

Uno 보드 그대로:
  2000 mAh / 45 mA ≈ 44 h ≈ 이틀

→ 같은 코드, 같은 배터리로 약 3700 배 차이
18.6 년은 이론값입니다. 실제로는 배터리 자가 방전(알카라인 연 2~3%), 저온 용량 감소, 센서 자체의 전류가 먼저 한계가 됩니다. 그래도 "몇 년"과 "이틀"의 차이는 그대로입니다 — 대기 전류가 수명을 정한다는 것이 핵심입니다.

Uno 를 넘어 — 진짜 저전력으로 가려면

무엇을왜
보드 대신 칩만 (브레드보드 · 자체 PCB)USB 칩 · 레귤레이터 · LED 의 수십 mA 제거
Pro Mini 3.3 V 에서 LED · 레귤레이터 제거흔히 쓰는 현실적 타협
클럭 8 MHz 내부 발진기16 MHz 는 약 3.8 V 이상 필요 — 3 V 배터리로는 규격 밖
BOD 를 1.8 V 로 · 잠잘 때 끔BOD 자체가 수십 µA
센서 전원을 GPIO 로 켰다 끔잠든 동안 센서가 먹는 전류 제거
워치독 주기는 정확하지 않습니다. 128 kHz 내부 발진기로 세기 때문에 전압·온도에 따라 ±10% 이상 흔들립니다. "8 초"는 7~9 초쯤으로 생각하고, 정확한 시각이 필요하면 RTC 칩(a09 의 DS3231)의 알람 출력으로 깨우세요. 저전력 설계 전반(전원 회로 · 측정)은 하드웨어 기초 탭과 이어집니다.
정리 — Power-down + 워치독 인터럽트로 주기적으로 깨고, 깨기 전에 ADC · 비교기 · PRR · BOD 를 끄면 칩은 µA 단위가 됩니다. 수명은 평균 전류로 계산하며, Uno 보드 자체의 수십 mA 는 칩을 아무리 재워도 남습니다.
AVR 13

미니 프로젝트 온도 로거 + Arduino 해부

지금까지 만든 부품 — 1 ms 틱(a05) · UART 링 버퍼와 printf(a07) · ADC(a08) · EEPROM(a11) — 을 한 파일에 모아 1분마다 온도를 재서 EEPROM 에 쌓고, 시리얼로 명령을 받아 내보내는 로거를 만듭니다. 그리고 마지막으로 Arduino 의 digitalWrite 를 뜯어서, 우리가 직접 쓴 한 줄과 무엇이 다른지 클럭 단위로 비교합니다.

누가 무엇을 하나 — 위에서 아래로 갈수록 느리고 여유 있는 쪽 하드웨어 ISR main 루프 Timer0 · 1 ms USART0 · 57600 8N1 ADC · 1.1 V EEPROM 1 KB g_ms++ RX → 링 링 → UDR0 60 s 지났나? 명령 d · n · c printf 온도 읽기 log_append ISR 줄에는 바이트 하나 옮기는 일만 — 느린 일(ADC 평균 · EEPROM 쓰기)은 전부 main
스윔레인 — 실시간성이 필요한 일(틱 · 수신)만 ISR 에, 3.3 ms 씩 걸리는 EEPROM 쓰기 같은 느린 일은 main 에 둡니다. 그래도 수신은 링 버퍼가 받아 두므로 하나도 잃지 않습니다.

logger.c — 한 파일로 된 완전한 로거 (LM35 → A0, 시리얼 57600)

// logger.c — 온도 로거 · ATmega328P @16MHz (Arduino Uno R3)
// 배선: LM35 +Vs → 5V, GND → GND, Vout → A0(PC0)
// 빌드: avr-gcc -mmcu=atmega328p -DF_CPU=16000000UL -Os -std=gnu11 -Wall -Wextra -o logger.elf logger.c
#ifndef F_CPU
#define F_CPU 16000000UL
#endif
#include <avr/io.h>
#include <avr/interrupt.h>
#include <avr/eeprom.h>
#include <avr/pgmspace.h>
#include <util/atomic.h>
#include <stdint.h>
#include <stdio.h>

#define LOG_PERIOD_MS 60000UL
#define SLOTS  512u              // 1024 B ÷ 2 B
#define EMPTY  0xFFFFu           // 지워진 EEPROM
#define HEAD   0xFFFEu           // "다음에 쓸 칸" 표시
#define BIAS   2000              // 저장값 = 온도×10 + 2000 (음수도 담기 위해)

/* ───────── 1 ms 틱 (a05) ───────── */
static volatile uint32_t g_ms;
ISR(TIMER0_COMPA_vect) { g_ms++; }

static void tick_init(void)
{
    TCCR0A = _BV(WGM01);                    // CTC
    TCCR0B = _BV(CS01) | _BV(CS00);         // ÷64
    OCR0A  = 249;                           // 1 ms
    TIMSK0 = _BV(OCIE0A);
}
static uint32_t millis(void)
{
    uint32_t m;
    ATOMIC_BLOCK(ATOMIC_RESTORESTATE) { m = g_ms; }
    return m;
}

/* ───────── UART + 링 버퍼 + printf (a07) ───────── */
#define RB 64u
static volatile uint8_t rx_buf[RB], tx_buf[RB];
static volatile uint8_t rx_h, rx_t, tx_h, tx_t;

ISR(USART_RX_vect)
{
    uint8_t c = UDR0;
    uint8_t n = (uint8_t)((rx_h + 1u) & (RB - 1u));
    if (n != rx_t) { rx_buf[rx_h] = c; rx_h = n; }
}
ISR(USART_UDRE_vect)
{
    if (tx_h == tx_t) { UCSR0B &= ~_BV(UDRIE0); return; }
    UDR0 = tx_buf[tx_t];
    tx_t = (uint8_t)((tx_t + 1u) & (RB - 1u));
}
static void uart_init(void)                 // 57600 · U2X → UBRR 34, 오차 −0.79%
{
    UBRR0  = 34;
    UCSR0A = _BV(U2X0);
    UCSR0C = _BV(UCSZ01) | _BV(UCSZ00);
    UCSR0B = _BV(RXEN0) | _BV(TXEN0) | _BV(RXCIE0);
}
static void uart_putc(uint8_t c)
{
    uint8_t n = (uint8_t)((tx_h + 1u) & (RB - 1u));
    while (n == tx_t) { }
    tx_buf[tx_h] = c;
    tx_h = n;
    UCSR0B |= _BV(UDRIE0);
}
static int uart_getc(void)
{
    if (rx_h == rx_t) return -1;
    uint8_t c = rx_buf[rx_t];
    rx_t = (uint8_t)((rx_t + 1u) & (RB - 1u));
    return c;
}
static int put(char c, FILE *f) { (void)f; if (c == '\n') uart_putc('\r'); uart_putc((uint8_t)c); return 0; }
static FILE uart_out = FDEV_SETUP_STREAM(put, NULL, _FDEV_SETUP_WRITE);

/* ───────── ADC + LM35 (a08) ───────── */
static uint16_t adc_read(void)
{
    ADCSRA |= _BV(ADSC);
    while (ADCSRA & _BV(ADSC)) { }
    return ADC;
}
static void adc_init(void)
{
    ADMUX  = _BV(REFS1) | _BV(REFS0);       // 내부 1.1 V 기준 · ADC0 → 1 LSB ≈ 1.07 mV
    ADCSRA = _BV(ADEN) | _BV(ADPS2) | _BV(ADPS1) | _BV(ADPS0);
    DIDR0  = _BV(ADC0D);
    (void)adc_read();                       // 기준을 바꾼 뒤 첫 결과는 버림
}
static int16_t read_temp_x10(void)          // 0.1 °C 단위 (LM35 기본 회로: 0 ~ 110 °C)
{
    uint32_t sum = 0;
    for (uint8_t i = 0; i < 16; i++) sum += adc_read();
    uint16_t raw = (uint16_t)(sum / 16u);
    // LM35 = 10 mV/°C → mV 값이 곧 '°C × 10'.  mV = raw × 1100 / 1024
    return (int16_t)((uint32_t)raw * 1100u / 1024u);   // 1023×1100 은 16비트를 넘음
}
static void print_t(int16_t t)              // %f 없이 소수 한 자리
{
    if (t < 0) { putchar('-'); t = (int16_t)-t; }
    printf("%d.%d", t / 10, t % 10);
}

/* ───────── EEPROM 순환 기록 (a11) ───────── */
static uint16_t g_head;                     // HEAD 표시가 있는 칸 번호

static uint16_t *slot(uint16_t i) { return (uint16_t *)(uintptr_t)(i * 2u); }  // EEPROM 주소

static void log_find_head(void)             // 부팅 때 한 번: 어디까지 썼나 찾기
{
    uint16_t first_empty = SLOTS;
    for (uint16_t i = 0; i < SLOTS; i++) {
        uint16_t v = eeprom_read_word(slot(i));
        if (v == HEAD) { g_head = i; return; }          // 표시가 둘이면 낮은 번호
        if (v == EMPTY && first_empty == SLOTS) first_empty = i;
    }
    g_head = (first_empty == SLOTS) ? 0 : first_empty;  // 새 칩이면 0 번부터
    eeprom_update_word(slot(g_head), HEAD);
}
static void log_append(int16_t t_x10)
{
    uint16_t next = (uint16_t)((g_head + 1u) % SLOTS);
    eeprom_update_word(slot(next), HEAD);                       // ① 다음 칸에 표시 먼저
    eeprom_update_word(slot(g_head), (uint16_t)(t_x10 + BIAS)); // ② 그다음 데이터
    g_head = next;                          // ①과 ② 사이에 전원이 나가도 복구됨 (본문)
}
static void log_dump(void)                  // 오래된 것부터
{
    uint16_t n = 0;
    for (uint16_t k = 1; k < SLOTS; k++) {
        uint16_t v = eeprom_read_word(slot((uint16_t)((g_head + k) % SLOTS)));
        if (v == EMPTY || v == HEAD) continue;
        printf("%u,", n++);
        print_t((int16_t)((int32_t)v - BIAS));
        putchar('\n');
    }
    printf_P(PSTR("# %u records\n"), n);
}
static void log_clear(void)                 // 약 3.5 초 걸림 (512 × 2 바이트 쓰기)
{
    for (uint16_t i = 0; i < SLOTS; i++) eeprom_update_word(slot(i), EMPTY);
    g_head = 0;
    eeprom_update_word(slot(0), HEAD);
}

int main(void)
{
    tick_init();
    uart_init();
    adc_init();
    stdout = &uart_out;
    sei();

    log_find_head();
    printf_P(PSTR("\nlogger ready (d=dump n=now c=clear)\n"));

    uint32_t last = millis();
    for (;;) {
        if ((uint32_t)(millis() - last) >= LOG_PERIOD_MS) {
            last += LOG_PERIOD_MS;
            log_append(read_temp_x10());
        }
        switch (uart_getc()) {
            case 'n': print_t(read_temp_x10()); printf_P(PSTR(" C\n")); break;
            case 'd': log_dump();  break;
            case 'c': log_clear(); printf_P(PSTR("cleared\n")); break;
            default:  break;                // -1(없음) 포함
        }
    }
}

설계 결정과 근거

결정근거 (계산)
1.1 V 내부 기준LM35 는 25 °C 에서 250 mV. 5 V 기준이면 1 LSB = 0.49 °C, 1.1 V 면 0.107 °C
16회 평균16 × 104 µs ≈ 1.7 ms — 노이즈 감소, 시간은 무시할 수준
정수 ×10%f 를 쓰면 printf_flt 로 Flash 수 KB 증가
57600 · U2X오차 −0.79% (115200 은 +2.12%) — a07 표
칸 512 개 순환칸마다 한 바퀴에 2번 씀 → 100 000 ÷ 2 × 512 분 ≈ 48.7 년
(비교) 고정 칸에 위치 저장1분마다 같은 칸 → 100 000 분 ≈ 69 일
계산 검산 예 — 25.0 °C → 250 mV → raw = 250 × 1024 / 1100 = 232(내림) → 232 × 1100 / 1024 = 249(내림) → "24.9". 두 번 내림해서 0.1 °C 가 깎이는 것은 양자화 오차(±0.107 °C) 안입니다. 순환 기록의 동작(덮어쓰기 · 전원 차단 복구)은 같은 알고리즘을 PC 에서 시뮬레이션해 확인했습니다.

전원이 쓰기 중간에 나가면

log_append 는 ① 다음 칸에 HEAD 표시 → ② 지금 칸에 데이터 순서로 씁니다. ①과 ② 사이에 전원이 나가면 HEAD 표시가 두 개 남습니다. 부팅 때 log_find_head 는 번호가 낮은 쪽을 고르므로, 보통은 "② 를 안 한 칸"이 다시 HEAD 가 되어 그 기록 하나만 없던 일이 됩니다.

끝 칸(511)에서 0 번으로 넘어가는 순간이라면 0 번이 HEAD 로 뽑히고, 511 번에 남은 낡은 표시는 덤프에서 건너뛰다가 다음 바퀴에 자연히 덮입니다. 어느 경우에도 깨진 값이 데이터로 읽히는 일은 없습니다.

순서를 반대로(② → ①) 하면 사이에서 전원이 나갔을 때 HEAD 표시가 하나도 없어 기록 위치를 잃습니다. EEPROM · Flash 에 쓰는 코드는 언제나 "어느 줄 다음에 전원이 나가도 되는가"를 한 줄씩 물어야 합니다.

Arduino 해부 — digitalWrite 는 왜 느린가 🟡 중급

Arduino AVR 코어 wiring_digital.c (실제 코드 · 주석은 추가)

void digitalWrite(uint8_t pin, uint8_t val)
{
    uint8_t timer = digitalPinToTimer(pin);     // ① 표 조회 (Flash, LPM)
    uint8_t bit   = digitalPinToBitMask(pin);   // ② 표 조회
    uint8_t port  = digitalPinToPort(pin);      // ③ 표 조회
    volatile uint8_t *out;

    if (port == NOT_A_PIN) return;              // ④ 잘못된 핀 번호 방어

    // PWM 이 걸린 핀이면 타이머에서 떼어 냄
    if (timer != NOT_ON_TIMER) turnOffPWM(timer);   // ⑤ switch 문

    out = portOutputRegister(port);             // ⑥ 포트 → 레지스터 주소 표 조회

    uint8_t oldSREG = SREG;                     // ⑦ 인터럽트 끄기
    cli();
    if (val == LOW) *out &= ~bit;               // ⑧ 포인터로 읽고-고치고-쓰기
    else            *out |=  bit;
    SREG = oldSREG;                             //    인터럽트 상태 복원
}

핀 번호가 실행 중에 정해지는 변수라는 가정 위에서 쓰여 있습니다. 그래서 매번 "13 번 핀은 어느 포트의 몇 번 비트인가"를 Flash 의 표(pins_arduino.h 의 PROGMEM 배열)에서 찾고, 그 결과 주소가 상수가 아니니 sbi 를 못 쓰고 포인터 읽기-쓰기가 되며, 그 3단계가 ISR 과 엉키지 않도록 cli 까지 합니다.

무엇을 사고 무엇을 팔았나

단계얻는 것
①②③⑥ 표 조회"13" 같은 보드 번호로 어느 보드에서나 같은 코드
④ 범위 확인잘못된 번호로 엉뚱한 레지스터를 쓰지 않음
⑤ PWM 해제analogWrite 후 digitalWrite 해도 기대대로 동작
⑦ cliISR 이 같은 포트를 바꿔도 값이 사라지지 않음 (a03)
digitalWrite 가 나쁜 코드는 아닙니다. 초보자가 저지를 실수(잘못된 핀, PWM 충돌, 인터럽트 경합)를 전부 막아 주는 방어적인 API 이고, 그 값이 수 µs 입니다. 버튼 · LED · 릴레이에는 전혀 문제가 없습니다. 문제가 되는 것은 소프트웨어 통신(비트뱅잉) · WS2812 LED · 고속 펄스처럼 µs 단위 타이밍이 필요한 곳뿐입니다.
digitalWrite(13, HIGH) 한 번 — 막대 1칸 = 1 클럭 (대략치) 0 10 20 30 40 50 60 클럭 호출 · 복귀 ① 타이머 표 ② 비트 표 ③ 포트 표 ④⑤ 확인 · PWM ⑥ 주소 표 ⑦ SREG · cli ⑧ 읽고-고치고-쓰기 합계 ≈ 56 클럭 ≈ 3.5 µs PORTB |= _BV(5) sbi 1개 = 2 클럭 = 0.125 µs 핀 번호가 컴파일 시간 상수면 ①~⑦ 이 전부 사라집니다 — 남는 것은 sbi 하나 대략 25~30 배 차이 · D9 처럼 PWM 핀이면 ⑤ 의 turnOffPWM 이 더 붙습니다
digitalWrite 워터폴 — 시간의 대부분은 "13 이 무슨 핀인가"를 찾는 데 씁니다. 실제로 핀을 바꾸는 일(⑧)은 끝의 작은 조각입니다. 정확한 클럭 수는 avr-objdump -d 로 직접 세어 보세요.

같은 토글, 세 가지 — 루프 한 바퀴의 클럭

// A — Arduino 스케치 (참고)
void loop() {
    digitalWrite(13, HIGH);
    digitalWrite(13, LOW);
}   // 호출 2번 + loop() 바깥의 serialEventRun 확인까지
    // → 한 주기 100 클럭 이상, 출력은 대략 100 kHz 대

// B — 레지스터 (이 탭의 방식)
for (;;) {
    PORTB |=  _BV(PORTB5);   // sbi   2 클럭
    PORTB &= ~_BV(PORTB5);   // cbi   2 클럭
}                            // rjmp  2 클럭 → 6 클럭 = 2.67 MHz

// C — PINx 토글 (한 번 쓰기가 반 주기)
for (;;) PINB = _BV(PINB5);  // out 1 + rjmp 2 = 3 클럭마다 반전
                             // → 6 클럭 주기 = 2.67 MHz, 듀티 50%
B 와 C 의 클럭 수는 명령표(sbi · cbi · rjmp 각 2 클럭, out 1 클럭)에서 계산한 값이고, A 는 코어 버전 · 컴파일러 · 측정 방법에 따라 달라지는 대략치입니다. 확인은 avr-objdump -d -S app.elf 로 루프를 직접 읽거나 오실로스코프로 D13 을 재면 됩니다.

Arduino 가 setup() 전에 해 두는 일 — init()

대상Arduino 의 설정레지스터 코드와 충돌
Timer0Fast PWM · ÷64 · 넘침 ISR = millisa05 처럼 Timer0 CTC 를 쓰면 millis · delay 가 깨짐
Timer1 · 2Phase Correct 8비트 · ÷64 (PWM 용)서보 설정(a06) 시 Servo 라이브러리와 충돌
ADC÷128 · 켜 둠저전력에서는 꺼야 함 (a12)
USART0UCSR0B = 0 (D0/D1 을 GPIO 로)Serial.begin 이 다시 켬
전역 인터럽트sei()—
Arduino 스케치 안에서 레지스터를 섞어 쓸 수는 있지만, 위 표의 자원을 건드리면 Arduino 함수가 조용히 고장 납니다. 섞을 때는 Arduino 가 안 쓰는 것(예: Timer1 을 Servo 없이)만 만지고, 그 사실을 주석으로 남기세요. 이 탭처럼 main() 부터 직접 쓰면 이런 숨은 약속이 없습니다 — 대신 전부 내 책임입니다.
다음 단계 — 이 탭에서 익힌 "데이터시트의 레지스터 표 → C 코드" 감각은 그대로 32비트 MCU 로 이어집니다. STM32 · Cortex-M 탭에서는 레지스터가 32비트가 되고, 클럭 트리 · NVIC · DMA · HAL 이 더해집니다. 로거를 여러 작업으로 나누고 싶어지면 FreeRTOS 탭, 테스트와 디버거는 디버깅 · 테스트 탭으로 가세요.
정리 — 틱 · UART 링 버퍼 · ADC · EEPROM 순환 기록을 한 파일로 묶으면 Arduino 없이 2 KB 안에서 쓸 만한 로거가 됩니다. digitalWrite 는 핀 번호 표 조회 · PWM 해제 · 인터럽트 끄기 때문에 약 50~60 클럭, 레지스터 한 줄은 sbi 하나 2 클럭입니다.
FreeRTOS Kernel V11.x · STM32F446RE (Nucleo-F446RE) · Cortex-M4F · 네이티브 API + CMSIS-RTOS v2 대응표

한 칩 안에서
여러 일을 동시에,
마감 안에 끝냅니다

슈퍼루프(for(;;) 하나)로 버티던 펌웨어가 기능이 늘어나면 어느 날 버튼이 늦게 먹고, 통신이 끊기고, 센서 값이 튑니다. RTOS 는 그 문제를 태스크 · 우선순위 · 블로킹 큐로 풉니다 — 서버 개발자에게 익숙한 스레드 · 락 · BlockingQueue 를 메모리 128KB, MMU 없는 칩에 맞게 줄여 놓은 것이라고 보면 됩니다. 이 탭은 FreeRTOS 커널을 STM32F446RE 에서 실무로 쓰는 법을 다룹니다: 태스크와 스케줄러, 큐 · 세마포어 · 뮤텍스, 인터럽트와의 연결, 메모리 설계, 동시성 버그 잡기, 저전력, 그리고 센서 → 처리 → 통신 3태스크 시스템 설계까지. 이 탭을 마치면 우선순위 역전과 ISR 우선순위 설정을 틀리지 않는 RTOS 펌웨어를 설계할 수 있습니다.

13SECTIONS
V11.xFreeRTOS Kernel
F446RE기준 보드
20시각화
RTOS 01

왜 RTOS 인가 — 슈퍼루프가 무너지는 순간

RTOS 는 "빠르게" 만드는 도구가 아닙니다. 정해진 시간 안에 반드시 반응하게 만드는 도구입니다. CPU 는 하나인데 할 일은 여럿일 때, 무엇을 먼저 할지 규칙으로 정해 두는 것 — 그게 실시간 운영체제의 전부입니다.

t=3ms 에 버튼이 눌렸습니다 — 언제 처리되나 슈퍼루프 (한 줄) 센서 8ms 화면 갱신 12ms UART 센서 (다음 바퀴) 버튼 확인까지 약 25ms — 루프 한 바퀴를 통째로 기다림 버튼 태스크 · 높음 1ms 만에 처리 센서 태스크 · 중간 화면 태스크 · 낮음 남는 시간에 버튼 눌림 0 10 20 30 40 ms 슈퍼루프의 최악 반응 시간 = 루프 한 바퀴. 기능이 늘수록 같이 늘어납니다
슈퍼루프는 모든 일이 같은 우선순위입니다 — RTOS 는 급한 일이 덜 급한 일을 밀어내게(선점) 해 줍니다.

슈퍼루프 — 처음엔 이걸로 충분합니다

int main(void) {
    HAL_Init();
    SystemClock_Config();          // 180 MHz
    MX_GPIO_Init(); MX_I2C1_Init(); MX_USART2_UART_Init();

    for (;;) {
        read_sensor_blocking();    // I2C 폴링 8 ms — CPU 는 기다리기만
        update_display();          // 12 ms
        uart_send_status();        // HAL_UART_Transmit 폴링 6 ms
        if (HAL_GPIO_ReadPin(B1_GPIO_Port, B1_Pin) == GPIO_PIN_RESET) {
            on_button();           // ★ 한 바퀴(26 ms)에 한 번만 봅니다
        }
    }
}
무너지는 신호 세 가지 — ① 함수 하나가 길어지면 나머지 전부가 같이 늦어집니다. ② "UART 는 5ms 마다, 화면은 50ms 마다"처럼 주기가 다른 일이 섞이면 카운터와 플래그가 폭발합니다. ③ 기다리는 동안(I2C·UART 폴링) CPU 가 다른 일을 못 합니다. 이걸 인터럽트 + 상태 머신으로 끝까지 버티는 방법도 있지만(C 실전 탭 c22), 코드가 읽기 어려워집니다.

RTOS — 일마다 태스크 하나, 급한 순서는 숫자로

#include "FreeRTOS.h"
#include "task.h"

static void ButtonTask(void *arg);    // 우선순위 3 (가장 급함)
static void SensorTask(void *arg);    // 우선순위 2
static void DisplayTask(void *arg);   // 우선순위 1
static TaskHandle_t s_btnTask;        // ISR 이 깨울 때 쓸 핸들

int main(void) {
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init(); MX_I2C1_Init(); MX_USART2_UART_Init();

    //          함수         이름    스택(워드) 인자  우선순위 핸들
    xTaskCreate(ButtonTask,  "btn",  256,  NULL, 3, &s_btnTask);
    xTaskCreate(SensorTask,  "sens", 384,  NULL, 2, NULL);
    xTaskCreate(DisplayTask, "disp", 512,  NULL, 1, NULL);

    vTaskStartScheduler();            // ★ 정상이면 여기서 돌아오지 않습니다
    for (;;) { }                      // 힙이 모자라 Idle 태스크를 못 만들 때만 도달
}

static void ButtonTask(void *arg) {
    (void)arg;
    for (;;) {
        // EXTI 인터럽트가 vTaskNotifyGiveFromISR 로 깨울 때까지 잠듭니다 (r07)
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
        on_button();                  // 눌린 뒤 수 µs 안에 실행
    }
}
서버 개발에 빗대면 — 태스크는 스레드, ulTaskNotifyTake 는 BlockingQueue.take() 처럼 깨울 때까지 CPU 를 0% 쓰며 잠드는 호출입니다. 차이는 하나 — 자바 스레드 스케줄러는 "공평"하려 애쓰지만 RTOS 는 무조건 우선순위가 높은 쪽을 돌립니다. 공평함 대신 예측 가능성을 삽니다.

실시간의 뜻 — 하드 · 펌 · 소프트 🟡 중급

마감이 지나면 결과의 가치는 어떻게 되나 마감 가치 1 0 하드 실시간 늦으면 실패 = 사고 에어백 · 모터 제어 · ABS 마감 가치 1 펌 실시간 늦은 결과는 버림 (가치 0) 오디오 프레임 · 센서 샘플 마감 가치 1 소프트 실시간 늦을수록 가치가 줄어듦 화면 갱신 · 로그 전송
한 시스템 안에 세 등급이 섞여 있습니다 — 우선순위는 이 등급 순서로 매깁니다.

"실시간" 에 대한 흔한 오해

오해사실
실시간 = 빠르다최악의 경우가 보장된다. 평균 1µs·가끔 50ms 보다 항상 5ms 가 낫습니다
RTOS 를 쓰면 실시간이 된다우선순위 설계가 틀리면 RTOS 위에서도 마감을 놓칩니다(r06 · r11)
CPU 가 빠르면 해결된다바쁜 대기(while 폴링)는 클럭을 올려도 남의 시간을 계속 먹습니다
리눅스는 실시간이 안 된다PREEMPT_RT 가 6.12 커널에 들어가 수십 µs 지터까지 가능 — 다만 MMU 있는 큰 칩이 필요
지터(jitter) — 같은 일이 매번 시작되는 시각의 흔들림입니다. 서버의 p99 지연과 같은 감각입니다. RTOS 의 가치는 평균이 아니라 p100(최악값)을 작고 일정하게 만드는 데 있습니다.

베어메탈 · RTOS · 임베디드 리눅스

베어메탈RTOS임베디드 리눅스
칩AVR · M0Cortex-M · RISC-V MCUCortex-A + MMU
RAM수 KB수십 KB~수십 MB~
부팅즉시수 ms수 초
반응 지연ISR 수준µs 단위 · 결정적RT 패치 시 수십 µs
메모리 보호없음없음(MPU 선택)프로세스 격리
동시성ISR + 루프태스크 · 큐프로세스 · 스레드
예리모컨드론 FC · 계량기셋톱 · 게이트웨이
RTOS 가 과한 경우 — ATmega328P(RAM 2KB)처럼 작은 칩, 일이 두세 개뿐인 펌웨어는 인터럽트 + 이벤트 플래그 + 슈퍼루프가 더 단순하고 안전합니다. 태스크 하나가 스택만 1KB 를 먹습니다.

실무에선 이렇게 — RTOS 도입을 결정하는 질문 🔴 실무

체크리스트 — 셋 이상이면 RTOS

□ 주기가 서로 다른 일이 3개 이상이다     (1ms 제어 · 10ms 센서 · 1s 로그)
□ 기다리는 동안 다른 일을 해야 한다       (Wi-Fi 응답 대기 중에도 모터 제어)
□ 마감이 다른 일이 섞여 있다             (하드 + 소프트)
□ 통신 스택을 붙인다                     (lwIP · USB · BLE — 대부분 RTOS 를 전제)
□ 여러 사람이 모듈을 나눠 개발한다        (태스크 = 소유권 경계)
□ RAM 이 32KB 이상 남는다               (태스크당 스택 0.5~2KB)
통신 스택이 결정타인 경우가 많습니다 — lwIP 의 sys_arch, ST 의 USB 미들웨어, Wi-Fi 모듈 SDK 는 대부분 FreeRTOS 위에서 도는 것을 기본으로 씁니다. 베어메탈 모드도 있지만 기능이 제한됩니다.

FreeRTOS 를 고르는 이유

항목내용
라이선스MIT — 제품에 넣고 소스 공개 의무 없음
크기커널 코드 수 KB~10KB 남짓, RAM 은 태스크 스택이 대부분
포트Cortex-M 전 계열 · RISC-V · Xtensa(ESP32) 등 40여 아키텍처
생태계CubeMX · ESP-IDF · MCUXpresso 가 기본 탑재
관리AWS 가 관리, LTS 릴리스 제공
인증판SAFERTOS(IEC 61508 · ISO 26262) — FreeRTOS 기능 모델을 바탕으로 만든 별도 제품
ESP32 는 조금 다릅니다 — ESP-IDF 의 FreeRTOS 는 듀얼 코어(SMP)용으로 고친 판이라 xTaskCreatePinnedToCore 같은 추가 API 가 있습니다. 이 탭의 내용은 단일 코어 Cortex-M 기준입니다.
정리 — RTOS 는 "동시에 여러 일"보다 "급한 일이 언제나 먼저"를 사는 도구입니다. 주기와 마감이 다른 일이 섞이기 시작하면 도입할 때입니다.
RTOS 02

FreeRTOS 구조와 도입 — C 파일 몇 개와 설정 헤더 하나

FreeRTOS 는 라이브러리입니다. 별도의 OS 이미지가 아니라 내 펌웨어에 같이 컴파일되는 C 파일 몇 개이고, 동작은 FreeRTOSConfig.h 한 파일로 정합니다. 어디가 칩마다 바뀌고 어디가 고정인지 알면 도입이 무섭지 않습니다.

펌웨어 안의 FreeRTOS — 오른쪽은 누가 고치는가 내 태스크 · 드라이버 코드 SensorTask() · CommTask() · ISR 콜백 — xQueueSend · xSemaphoreTake 호출 내가 씀 커널 공통부 — 칩과 무관 tasks.c (스케줄러) · queue.c (큐·세마포어·뮤텍스) · list.c timers.c · event_groups.c · stream_buffer.c 손대지 않음 포트 계층 — portable/GCC/ARM_CM4F port.c · portmacro.h — SysTick(tick) · PendSV(문맥 전환) · SVC(첫 태스크 시작) FPU 레지스터 저장 · BASEPRI 로 인터럽트 마스킹 칩 계열마다 하나 고름 CMSIS 코어 · STM32 HAL · Cortex-M4F NVIC · SysTick 레지스터 · HAL_TIM · HAL_UART FreeRTOSConfig.h + heap_4.c — 내가 고름 커널을 고칠 일은 없습니다 — 바꾸는 것은 설정 헤더와 힙 파일 선택뿐
FreeRTOS 의 이식성은 포트 계층 두 파일에 몰려 있습니다 — 나머지는 어느 칩에서나 같은 C 코드입니다.

소스 트리 — 실제로 빌드에 넣는 것

FreeRTOS-Kernel/                 (V11.x, github.com/FreeRTOS/FreeRTOS-Kernel)
├─ tasks.c           ★ 태스크 · 스케줄러 · 태스크 알림
├─ queue.c           ★ 큐 · 세마포어 · 뮤텍스 (전부 큐로 구현)
├─ list.c            ★ 커널 내부 연결 리스트
├─ timers.c            소프트웨어 타이머 (configUSE_TIMERS 1 일 때)
├─ event_groups.c      이벤트 그룹
├─ stream_buffer.c     스트림 · 메시지 버퍼
├─ include/            FreeRTOS.h task.h queue.h semphr.h ...
└─ portable/
   ├─ GCC/ARM_CM4F/    ★ port.c · portmacro.h  ← F446RE (FPU 있는 M4)
   └─ MemMang/         heap_1.c … heap_5.c     ← 하나만 고름 (r10)

내 프로젝트/
└─ Core/Inc/FreeRTOSConfig.h   ★ 내가 쓰는 설정 — 커널은 이걸 include 합니다
포트 선택 실수 — F446RE 는 FPU 가 있으므로 ARM_CM4F 입니다. ARM_CM3 포트를 쓰면 빌드는 되는데 FPU 레지스터를 저장하지 않아 float 를 쓰는 태스크 두 개가 서로의 값을 망가뜨립니다. 증상이 랜덤이라 찾기 어렵습니다.

CMake 로 직접 붙이기 (커널 저장소의 방식)

# 설정 헤더가 있는 폴더를 "freertos_config" 라는 타깃으로 알려 줍니다
add_library(freertos_config INTERFACE)
target_include_directories(freertos_config SYSTEM INTERFACE Core/Inc)

set(FREERTOS_PORT "GCC_ARM_CM4F" CACHE STRING "" FORCE)
set(FREERTOS_HEAP "4"            CACHE STRING "" FORCE)

add_subdirectory(third_party/FreeRTOS-Kernel)   # git submodule

target_link_libraries(firmware PRIVATE freertos_kernel)
CubeMX 를 쓰면 이 과정이 Middlewares/Third_Party/FreeRTOS 폴더로 자동 생성됩니다. 다만 CubeF4 패키지에 들어 있는 커널은 V10.x 대일 수 있습니다 — 이 탭의 API 는 대부분 같지만, xTaskDelayUntil 처럼 V10.4 이후 이름은 옛 이름(vTaskDelayUntil)만 있을 수 있으니 생성된 소스를 확인하세요.

FreeRTOSConfig.h — F446RE 에 맞춘 핵심 항목 🟡 중급

#ifndef FREERTOS_CONFIG_H
#define FREERTOS_CONFIG_H
#include <stdint.h>
extern uint32_t SystemCoreClock;              // CMSIS 가 180000000 으로 갱신

/* ── 스케줄러 ── */
#define configUSE_PREEMPTION              1   // 선점형 (0 이면 협조형)
#define configUSE_TIME_SLICING            1   // 같은 우선순위는 tick 마다 교대
#define configCPU_CLOCK_HZ                (SystemCoreClock)
#define configTICK_RATE_HZ                1000 // 1 tick = 1 ms
#define configMAX_PRIORITIES              8    // 0(Idle) ~ 7
#define configMINIMAL_STACK_SIZE          128  // ★ 워드 단위 = 512 바이트
#define configMAX_TASK_NAME_LEN           16
#define configTICK_TYPE_WIDTH_IN_BITS     TICK_TYPE_WIDTH_32_BITS
#define configIDLE_SHOULD_YIELD           1
#define configUSE_IDLE_HOOK               0    // 둘 다 필수 항목 — 빠지면 FreeRTOS.h 가 #error
#define configUSE_TICK_HOOK               0

/* ── 기능 켜기 ── */
#define configUSE_MUTEXES                 1
#define configUSE_RECURSIVE_MUTEXES       1
#define configUSE_COUNTING_SEMAPHORES     1
#define configUSE_TASK_NOTIFICATIONS      1
#define configUSE_TIMERS                  1
#define configTIMER_TASK_PRIORITY         (configMAX_PRIORITIES - 1)
#define configTIMER_QUEUE_LENGTH          10
#define configTIMER_TASK_STACK_DEPTH      256

/* ── 메모리 ── */
#define configSUPPORT_DYNAMIC_ALLOCATION  1
#define configSUPPORT_STATIC_ALLOCATION   1
#define configTOTAL_HEAP_SIZE             (32 * 1024)  // heap_4 가 쓰는 배열 크기

/* ── 안전장치 (개발 중엔 반드시) ── */
#define configCHECK_FOR_STACK_OVERFLOW    2
#define configUSE_MALLOC_FAILED_HOOK      1
#define configASSERT(x) do { if ((x) == 0) { taskDISABLE_INTERRUPTS(); for (;;) { } } } while (0)

/* ── 인터럽트 우선순위 (r07) — STM32F4 는 우선순위 비트 4개 ── */
#ifdef __NVIC_PRIO_BITS
  #define configPRIO_BITS  __NVIC_PRIO_BITS     // = 4
#else
  #define configPRIO_BITS  4
#endif
#define configLIBRARY_LOWEST_INTERRUPT_PRIORITY       15
#define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY  5
#define configKERNEL_INTERRUPT_PRIORITY \
    (configLIBRARY_LOWEST_INTERRUPT_PRIORITY << (8 - configPRIO_BITS))        // 0xF0
#define configMAX_SYSCALL_INTERRUPT_PRIORITY \
    (configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY << (8 - configPRIO_BITS))   // 0x50

/* ── 커널 예외 핸들러 이름을 CMSIS 벡터 이름에 연결 ── */
#define vPortSVCHandler     SVC_Handler
#define xPortPendSVHandler  PendSV_Handler
#define xPortSysTickHandler SysTick_Handler   // 직접 이식할 때만 (CubeMX + CMSIS v2 는
                                              //  cmsis_os2.c 가 SysTick_Handler 를 제공)

/* ── 선택적 API 포함 ── */
#define INCLUDE_vTaskDelay                    1
#define INCLUDE_vTaskDelete                   1   // vTaskDelete(NULL) (r03) — 기본값 0
#define INCLUDE_xTaskDelayUntil               1
#define INCLUDE_vTaskSuspend                  1   // portMAX_DELAY 무한 대기에 필요
#define INCLUDE_uxTaskGetStackHighWaterMark   1
#endif

각 항목을 고르는 기준

항목기준
configTICK_RATE_HZ1000 이 무난. 높이면 tick 인터럽트 부담 ↑, 낮추면 지연 해상도 ↓
configMAX_PRIORITIES실제로 쓰는 수준만큼(5~8). 클수록 RAM 조금 ↑
configMINIMAL_STACK_SIZEIdle 태스크 스택. 워드 단위(Cortex-M 에서 ×4 바이트)
configTOTAL_HEAP_SIZEF446RE SRAM 128KB 중 동적 객체 몫. 정적 할당만 쓰면 0 가능(r10)
configCHECK_FOR_STACK_OVERFLOW개발 중 2. 문맥 전환 때 검사 — 비용은 작습니다
configASSERT절대 빼지 마세요. ISR 우선순위 실수를 잡는 유일한 장치
configMAX_SYSCALL_…이 값보다 숫자가 작은(더 급한) ISR 은 FreeRTOS API 금지
왜 시프트(<< 4)를 하나 — Cortex-M 의 우선순위 레지스터는 8비트인데 STM32F4 는 위쪽 4비트만 씁니다. HAL 함수(HAL_NVIC_SetPriority(irq, 5, 0))는 0~15 를 받아 알아서 시프트하지만, 커널은 BASEPRI 레지스터에 날값을 쓰므로 미리 시프트한 0x50 이 필요합니다.
NVIC 우선순위 그룹 — 4비트를 전부 선점 우선순위로 써야 합니다(NVIC_PRIORITYGROUP_4, 서브 우선순위 0비트). HAL_Init() 이 기본으로 이렇게 설정하니 바꾸지 마세요. 커널이 configASSERT 로 검사합니다.

CubeMX 로 켤 때 — HAL 타임베이스를 TIM6 으로 옮기는 이유 🔴 실무

CubeMX 설정 순서

1) System Core → SYS → Timebase Source : SysTick → TIM6   ★
2) Middleware → FREERTOS → Interface : CMSIS_V2
   · Config parameters : TICK_RATE 1000, MAX_PRIORITIES 56(CMSIS 기본)
   · USE_PREEMPTION Enabled, CHECK_FOR_STACK_OVERFLOW Option2
   · Memory Management scheme : heap_4
3) Tasks and Queues 탭에서 태스크 · 큐를 만들거나, 코드로 직접 생성
4) NVIC 탭 : FreeRTOS API 를 부르는 IRQ 는 Preemption Priority 5 이상
   (CubeMX 가 "Uses FreeRTOS functions" 체크 시 5 미만을 막아 줍니다)
/* CubeMX 가 main.c 에 생성 — HAL 의 1 ms 시계는 이제 TIM6 이 돌립니다 */
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) {
    if (htim->Instance == TIM6) {
        HAL_IncTick();
    }
}

SysTick 하나를 둘이 쓰면 생기는 일

주인SysTick 에 바라는 것
FreeRTOStick 인터럽트를 가장 낮은 우선순위(15)로 · 스케줄러가 멈추면 같이 멈춰도 됨 · tickless 때 재설정
HALHAL_GetTick() 이 항상 증가 · 스케줄러 시작 전 HAL_Delay · 다른 ISR 안의 타임아웃
요구가 충돌합니다 — 커널이 SysTick 을 최저 우선순위로 낮추면, 그보다 급한 ISR 안에서 HAL 타임아웃을 기다릴 때 tick 이 영영 안 올라 멈춥니다. tickless idle(r12)은 SysTick 을 멈추고 다시 프로그램하므로 HAL 시계도 같이 어긋납니다. 그래서 SysTick 은 커널에 주고, HAL 은 남는 기본 타이머 TIM6/TIM7 로 보냅니다. CubeMX 도 그대로 두면 경고를 띄웁니다.
CMSIS-RTOS v2 래퍼는 무엇인가 — cmsis_os2.c 가 osThreadNew 같은 Arm 표준 이름을 FreeRTOS 함수로 옮겨 주는 얇은 층입니다. 이 탭은 네이티브 API 를 기본으로 하고 각 섹션에 대응표를 둡니다. 두 API 는 한 프로젝트에서 섞어 써도 됩니다 — 결국 같은 커널 객체입니다.
정리 — 커널 C 파일 + 포트(ARM_CM4F) + 힙 하나 + FreeRTOSConfig.h. CubeMX 를 쓰면 HAL 타임베이스를 TIM6 으로 옮기는 것부터 합니다.
RTOS 03

태스크 — 끝나지 않는 함수 하나와 그 스택

태스크는 자기 스택과 우선순위를 가진 무한 루프 함수입니다. 스레드와 거의 같지만 세 가지가 다릅니다 — 스택 크기를 내가 정확히 정해야 하고, 절대 return 하지 않으며, 우선순위 숫자가 클수록 급합니다.

태스크 상태 — 원이 상태, 화살표 옆 글자가 넘어가는 조건 Running CPU 를 쥔 하나 Ready 차례 기다림 Blocked 이벤트 기다림 Suspended 강제 정지 스케줄러가 고름 선점 · taskYIELD() vTaskDelay · xQueueReceive 이벤트 도착 · 타임아웃 vTaskSuspend() 어느 상태에서든 vTaskResume() Blocked 태스크는 CPU 를 0% 씁니다 — while 폴링과의 결정적 차이
대부분의 태스크는 대부분의 시간을 Blocked 로 보냅니다 — 그래야 낮은 우선순위에게 차례가 갑니다.

xTaskCreate — 인자 하나하나

BaseType_t xTaskCreate(
    TaskFunction_t        pxTaskCode,    // void f(void *)
    const char * const    pcName,        // 디버거 · 트레이스용 이름
    const configSTACK_DEPTH_TYPE uxStackDepth, // ★ 워드 수 (바이트 아님)
    void * const          pvParameters,  // 태스크에 넘길 포인터
    UBaseType_t           uxPriority,    // 0 ~ configMAX_PRIORITIES-1
    TaskHandle_t * const  pxCreatedTask  // 핸들을 받을 곳 (NULL 가능)
);   // 반환: pdPASS 또는 errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY

static TaskHandle_t s_sensor;

void app_start(void) {
    BaseType_t ok = xTaskCreate(SensorTask, "sensor",
                                384,          // 384 워드 = 1536 바이트
                                NULL, 2, &s_sensor);
    configASSERT(ok == pdPASS);               // 힙 부족을 조용히 넘기지 않기
}
워드 vs 바이트 — Cortex-M 에서 StackType_t 는 uint32_t 이므로 256 = 1024 바이트입니다. ESP-IDF 의 FreeRTOS 는 이 인자를 바이트로 받고, CMSIS v2 의 osThreadAttr_t.stack_size 도 바이트입니다. 코드를 옮길 때 4배 차이로 스택이 넘치거나 RAM 이 바닥납니다.

태스크 함수의 모양 — 절대 return 하지 않습니다

static void SensorTask(void *arg) {
    (void)arg;
    sensor_init();                        // ① 한 번만 하는 준비

    for (;;) {                            // ② 영원히
        int16_t t = sensor_read_temp();
        publish(t);
        vTaskDelay(pdMS_TO_TICKS(100));   // ③ 반드시 어딘가에서 Blocked
    }
    // 여기 도달하면 안 됩니다
}

/* 일을 마치고 사라져야 하는 태스크라면 — return 대신 */
static void OneShotTask(void *arg) {
    (void)arg;
    do_calibration();
    vTaskDelete(NULL);                    // 자기 자신 삭제 (TCB · 스택은 Idle 이 회수)
}
return 하면 어떻게 되나 — 태스크 함수의 복귀 주소는 포트가 넣어 둔 prvTaskExitError() 입니다. 여기서 configASSERT 에 걸려 멈춥니다. 자바의 run() 이 끝나면 스레드가 정리되는 것과 달리, FreeRTOS 는 "실수로 끝났다"고 봅니다.

우선순위 — 숫자가 클수록 급합니다 (NVIC 와 반대) 🟡 중급

번호쓰임 예 (configMAX_PRIORITIES 8)
7타이머 서비스 태스크 (configTIMER_TASK_PRIORITY)
5~6모터 제어 · 안전 감시 — 하드 실시간
3~4ISR 지연 처리 태스크 · 센서 샘플링
2데이터 처리 · 프로토콜
1로그 · 화면 · 셸 — 소프트 실시간
0Idle 태스크 전용 (내 태스크를 0 에 두면 Idle 과 교대)
헷갈리는 두 숫자 — 태스크 우선순위는 클수록 급하고, STM32 NVIC 인터럽트 우선순위는 작을수록 급합니다. 그리고 어떤 태스크도 어떤 인터럽트보다 덜 급합니다 — 태스크 7 도 인터럽트 15 에게 밀립니다.

우선순위를 런타임에 보고 바꾸기

UBaseType_t p = uxTaskPriorityGet(NULL);      // NULL = 나 자신
vTaskPrioritySet(s_sensor, 4);                 // 다른 태스크를 올림

TaskHandle_t me = xTaskGetCurrentTaskHandle();
const char *name = pcTaskGetName(me);          // "sensor"

vTaskSuspend(s_sensor);                        // 강제 정지 — 어디서 멈췄는지 모름
vTaskResume(s_sensor);
vTaskSuspend 는 거의 쓰지 않습니다 — 태스크가 뮤텍스를 쥔 채로 멈출 수 있습니다. "잠깐 멈춰"가 필요하면 그 태스크가 스스로 큐나 알림을 기다리게 설계하는 게 맞습니다. 자바에서 Thread.suspend() 가 폐기된 것과 같은 이유입니다.

정적 생성 · 인자 전달 — 실무에서 터지는 두 곳 🔴 실무

xTaskCreateStatic — 힙 없이, 링크 때 크기 확정

#define COMM_STACK_WORDS 512

static StackType_t  s_commStack[COMM_STACK_WORDS];   // .bss 에 자리 잡음
static StaticTask_t s_commTcb;                       // TCB 도 내가 제공

void comm_start(void) {
    TaskHandle_t h = xTaskCreateStatic(
        CommTask, "comm", COMM_STACK_WORDS, NULL, 1,
        s_commStack, &s_commTcb);
    configASSERT(h != NULL);       // 버퍼가 NULL 일 때만 실패
}

/* configSUPPORT_STATIC_ALLOCATION 1 이면 Idle 태스크 메모리도 내가 줘야 합니다
   (V11 은 configKERNEL_PROVIDED_STATIC_MEMORY 1 로 커널에 맡길 수도 있음) */
void vApplicationGetIdleTaskMemory(StaticTask_t **ppxTcb,
                                   StackType_t **ppxStack,
                                   configSTACK_DEPTH_TYPE *puxStackSize) {
    static StaticTask_t tcb;
    static StackType_t  stack[configMINIMAL_STACK_SIZE];
    *ppxTcb = &tcb;
    *ppxStack = stack;
    *puxStackSize = configMINIMAL_STACK_SIZE;
}
버전 차이 — V10.x 에서는 마지막 인자가 uint32_t *pulIdleTaskStackSize 입니다. 타이머를 켰다면 같은 모양의 vApplicationGetTimerTaskMemory 도 필요합니다.

pvParameters — main 의 지역 변수를 넘기면 안 됩니다

❌ 시작하자마자 값이 깨짐
int main(void) {
    LedCfg cfg = { GPIOA, GPIO_PIN_5, 500 };
    xTaskCreate(LedTask, "led", 128, &cfg, 1, NULL);
    vTaskStartScheduler();   // ★ MSP 가 초기값으로 되감김
}
✅ static 이나 전역 — 수명이 프로그램 전체
static const LedCfg s_led1 = { GPIOA, GPIO_PIN_5, 500 };

int main(void) {
    xTaskCreate(LedTask, "led", 128, (void *)&s_led1, 1, NULL);
    vTaskStartScheduler();
}

static void LedTask(void *arg) {
    const LedCfg *c = arg;
    for (;;) {
        HAL_GPIO_TogglePin(c->port, c->pin);
        vTaskDelay(pdMS_TO_TICKS(c->period_ms));
    }
}
왜 깨지나 — Cortex-M 포트는 스케줄러를 시작할 때 main 이 쓰던 스택(MSP)을 처음 위치로 되돌려 인터럽트 전용 스택으로 재활용합니다. main 의 지역 변수 자리는 첫 인터럽트가 덮어씁니다. main 은 "끝나지 않으니 괜찮다"는 직관이 여기서 틀립니다.

CMSIS-RTOS v2 대응 — 태스크

FreeRTOS 네이티브CMSIS-RTOS v2 (cmsis_os2.h)주의
xTaskCreate(f, "n", 256, arg, 2, &h)osThreadNew(f, arg, &attr)attr.stack_size 는 바이트(1024)
xTaskCreateStatic(…, stack, &tcb)attr.stack_mem · attr.cb_memcb_size = sizeof(StaticTask_t)
우선순위 2osPriorityNormal (= 24)CubeMX 가 MAX_PRIORITIES 를 56 으로 두는 이유
vTaskDelete(NULL)osThreadExit()
vTaskStartScheduler()osKernelInitialize() + osKernelStart()Initialize 를 객체 생성 전에
정리 — 태스크 = for(;;) 안에서 반드시 Blocked 되는 함수. 스택은 워드 단위, 인자는 static 으로, 끝내려면 return 대신 vTaskDelete(NULL).
RTOS 04

스케줄러 — 가장 높은 Ready 태스크가 돈다, 규칙은 이것 하나

FreeRTOS 스케줄러의 규칙은 놀랄 만큼 단순합니다. Ready 중 우선순위가 가장 높은 태스크가 Running, 같은 우선순위끼리는 tick 마다 번갈아. 이 두 줄과 "언제 다시 고르나"만 알면 모든 타이밍 그림을 손으로 그릴 수 있습니다.

한 칸 = 1 tick (1 ms) — 막대가 있는 줄이 그 순간 Running H · 우선순위 3 M1 · 우선순위 2 M2 · 우선순위 2 Idle · 우선순위 0 0 2 4 6 8 10 12 14 16 18 20 ① 번갈아 (타임슬라이스) ② 선점 ③ Idle ① 같은 우선순위(M1 · M2)는 tick 인터럽트마다 차례를 넘깁니다 ② t=10 에 H 가 깨어나면 다음 tick 을 기다리지 않고 M2 를 즉시 밀어냅니다 ③ Ready 인 태스크가 하나도 없으면 Idle 이 돕니다 — 여기서 잠들 수 있습니다(r12)
스케줄러는 tick · 블로킹 해제 · ISR 의 양보 요청 때 다시 고릅니다 — 고르는 기준은 언제나 우선순위 숫자 하나입니다.

tick — 커널의 심장 박동

// SysTick 이 1 ms 마다 xPortSysTickHandler() 를 부릅니다
//   → xTaskIncrementTick(): tick 카운트 +1,
//     깨어날 시각이 된 Blocked 태스크를 Ready 로
//   → 바꿔야 하면 PendSV 를 걸어 둠 → 모든 ISR 이 끝난 뒤 문맥 전환

TickType_t now = xTaskGetTickCount();        // 태스크에서
TickType_t t2  = xTaskGetTickCountFromISR(); // ISR 에서

// ms ↔ tick 변환은 반드시 매크로로 — tick 주기를 바꿔도 코드가 맞습니다
vTaskDelay(pdMS_TO_TICKS(250));              // 1000 Hz 면 250 tick
uint32_t ms = now * portTICK_PERIOD_MS;      // 거꾸로 (정수 나눗셈 주의)

// 32 비트 tick 은 1 kHz 에서 약 49.7 일 만에 0 으로 되감깁니다
// 경과 시간은 뺄셈으로 — 되감겨도 부호 없는 산술이라 맞습니다
if ((TickType_t)(xTaskGetTickCount() - start) >= pdMS_TO_TICKS(500)) {
    on_timeout();
}
PendSV 가 왜 필요한가 — 문맥 전환을 ISR 한가운데서 하면 다른 ISR 의 레지스터가 섞입니다. 그래서 커널은 PendSV 를 가장 낮은 우선순위로 두고 "전환 예약"만 합니다. 다른 인터럽트가 다 끝난 순간 PendSV 가 돌아 태스크를 바꿉니다. Cortex-M4 @180MHz 에서 전환 자체는 1µs 안팎입니다.

스케줄러가 다시 고르는 순간

사건결과
tick 인터럽트지연이 끝난 태스크가 Ready 로. 같은 우선순위면 타임슬라이스 교대
태스크가 대기 API 호출자신이 Blocked → 다음으로 높은 Ready 가 Running
xQueueSend 등이 더 높은 태스크를 깨움그 API 안에서 즉시 전환 (다음 tick 을 기다리지 않음)
ISR 이 portYIELD_FROM_ISR(pdTRUE)ISR 이 끝나자마자 전환 (r07)
taskYIELD()같은 우선순위에게 자발적으로 양보
굶주림(starvation) — 높은 우선순위 태스크가 Blocked 되지 않고 계속 돌면 낮은 태스크는 영원히 못 돕니다. 리눅스 일반 스케줄러(CFS · EEVDF)처럼 "오래 기다린 쪽을 봐주는" 장치가 없습니다. 높은 우선순위일수록 짧게 일하고 빨리 잠들어야 합니다.

vTaskDelay vs xTaskDelayUntil — 주기가 밀리는 이유 🟡 중급

매 주기 3 ms 일하고 10 ms 쉬라고 했을 때 — 깨어나는 시각 vTaskDelay(10) — 일이 끝난 뒤부터 10 ms → 주기 13 ms +3 +6 +9 xTaskDelayUntil(&last, 10) — 지난 기상 시각 기준 → 정확히 10 ms 0 10 20 30 40 ms 1초 동안 100번 돌아야 할 제어 루프가 vTaskDelay 로는 약 77번만 돕니다 일하는 시간이 매번 다르면 주기도 매번 흔들립니다 — 지터
vTaskDelay 는 "지금부터 N", xTaskDelayUntil 은 "지난번 기상 + N" — 주기 작업에는 언제나 후자입니다.

주기 태스크의 정석

static volatile uint32_t s_overruns;

static void ControlTask(void *arg) {
    (void)arg;
    const TickType_t period = pdMS_TO_TICKS(10);
    TickType_t last = xTaskGetTickCount();      // ★ 루프 밖에서 한 번만

    for (;;) {
        // last 를 period 만큼 전진시키고 그 시각까지 잠듭니다
        BaseType_t slept = xTaskDelayUntil(&last, period);
        if (slept == pdFALSE) {
            s_overruns++;      // 이미 마감을 넘김 — 잠들지 않고 바로 반환
        }
        control_step();        // 3 ms 걸려도 다음 기상은 정확히 +10 ms
    }
}
V10.4 이전 이름 — vTaskDelayUntil(&last, period) (반환값 없음, INCLUDE_vTaskDelayUntil). V11 에도 호환 매크로로 남아 있지만 마감 초과를 알 수 없으니 새 이름을 쓰세요.

vTaskDelay(1) 은 1 ms 가 아닙니다

tick:   |-------|-------|-------|
                ^ 여기서 vTaskDelay(1) 호출 (tick 직전)
                 ^ 다음 tick 에 바로 깨어남 → 실제 대기 ≈ 0.1 ms

vTaskDelay(n) 의 실제 대기 = (n-1) ~ n tick 사이
→ "최소 2 ms" 가 필요하면 vTaskDelay(pdMS_TO_TICKS(2) + 1)
→ µs 단위 지연은 RTOS 가 아니라 하드웨어 타이머로
HAL_Delay 는 태스크에서 쓰지 마세요 — HAL_Delay 는 HAL_GetTick() 을 while 로 돌며 기다리는 바쁜 대기라 그동안 낮은 우선순위 태스크가 하나도 못 돕니다. 태스크 안에서는 언제나 vTaskDelay 입니다.

Idle 태스크 · 훅 · 우선순위 정하기 — 비율 단조 규칙 🔴 실무

Idle 훅과 Tick 훅

// configUSE_IDLE_HOOK 1 — Idle 태스크가 한 바퀴 돌 때마다 호출
void vApplicationIdleHook(void) {
    s_idleCount++;          // CPU 여유를 대충 재는 카운터
    // ✕ 블로킹 API 금지 (vTaskDelay · xQueueReceive(…, 대기))
    // ✕ 오래 걸리는 일 금지 — vTaskDelete 된 태스크의 메모리를 Idle 이 회수합니다
}

// configUSE_TICK_HOOK 1 — 매 tick, ★ ISR 안에서 호출
void vApplicationTickHook(void) {
    // FromISR API 만, 수 µs 안에
}
Idle 태스크의 일 — ① 아무도 Ready 가 아닐 때 CPU 를 받기 ② 스스로 삭제한 태스크의 TCB · 스택 해제 ③ tickless 모드면 잠들기(r12). 그래서 Idle 을 굶기면(우선순위 0 위에서 쉬지 않는 태스크) 메모리가 회수되지 않습니다.

우선순위는 "중요도"가 아니라 "주기"로

태스크주기실행점유율우선순위
센서 샘플10 ms1 ms10%3
신호 처리20 ms4 ms20%2
통신 보고100 ms10 ms10%1
합계40%≤ 78% 이면 안전
비율 단조(Rate Monotonic) 규칙 — 주기가 짧을수록 높은 우선순위를 줍니다. 독립적인 주기 태스크 n 개는 CPU 점유율 합이 n(21/n − 1) 이하면 모두 마감을 지킨다는 것이 증명되어 있습니다(3개면 약 78%, 개수가 늘면 69% 로 수렴). "통신이 더 중요하니 높게"처럼 중요도로 매기면 짧은 주기 태스크가 마감을 놓칩니다.

CMSIS-RTOS v2 대응 — 시간

FreeRTOSCMSIS-RTOS v2주의
vTaskDelay(t)osDelay(t)단위는 tick
xTaskDelayUntil(&last, period)osDelayUntil(abs)절대 시각을 받음 — next += period; osDelayUntil(next);
xTaskGetTickCount()osKernelGetTickCount()ISR 에서도 호출 가능
taskYIELD()osThreadYield()
정리 — 가장 높은 Ready 가 돌고, 같은 레벨은 tick 마다 교대. 주기 작업은 xTaskDelayUntil, 우선순위는 주기가 짧은 순으로.
RTOS 05

큐 — 태스크 사이의 BlockingQueue, 단 값을 복사합니다

태스크끼리 데이터를 주고받는 기본 수단은 큐입니다. 전역 변수 + 플래그로 주고받으면 레이스가 생기지만, 큐는 넣고 빼는 순간이 원자적이고 비면 잠들고 차면 기다립니다. 자바의 ArrayBlockingQueue 와 같은데, 결정적인 차이가 하나 — 참조가 아니라 바이트를 복사합니다.

큐 저장소 = xQueueCreate(5, sizeof(Msg)) → 5 × 12 바이트 센서 태스크 생산자 xQueueSend 빈칸 빈칸 Msg 3 Msg 2 Msg 1 ← 뒤 (새로 들어옴) 앞 (먼저 나감) → 한 칸 = 12 바이트를 memcpy — 먼저 들어온 것이 먼저 나감 (FIFO) xQueueReceive 처리 태스크 소비자 가득 차면 대기 비면 Blocked 구조체 값을 넣음 — 안전 Msg m = {...}; xQueueSend(q, &m, 0); 함수가 끝나 m 이 사라져도 큐 안 사본은 그대로 지역 버퍼의 포인터를 넣음 — 위험 char buf[64]; char *p = buf; xQueueSend(q, &p, 0); 받는 쪽이 읽을 때 buf 는 이미 덮어써졌을 수 있음 큐는 "주소가 가리키는 곳"이 아니라 "주소에 있는 바이트"를 복사합니다
큐는 복사로 소유권 문제를 없앱니다 — 대신 큰 데이터는 복사 비용과 큐 RAM 이 커집니다.

기본형 — 구조체를 값으로

#include "queue.h"

typedef struct {
    uint32_t tick;        // 4
    int16_t  temp_x10;    // 2   (23.5 ℃ → 235)
    uint16_t humid_x10;   // 2
    uint8_t  sensor_id;   // 1  + 패딩 3 → sizeof = 12
} Msg;

static QueueHandle_t s_q;

void app_init(void) {
    s_q = xQueueCreate(5, sizeof(Msg));    // 저장소 60 B + 관리 구조체
    configASSERT(s_q != NULL);
}

static void SensorTask(void *arg) {
    (void)arg;
    for (;;) {
        Msg m = { .tick = xTaskGetTickCount(),
                  .temp_x10 = read_temp_x10(),
                  .humid_x10 = read_humid_x10(),
                  .sensor_id = 1 };
        if (xQueueSend(s_q, &m, pdMS_TO_TICKS(5)) != pdPASS) {
            s_dropped++;                   // 5 ms 기다려도 자리가 없음 → 버림
        }
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

static void ProcTask(void *arg) {
    (void)arg;
    Msg m;
    for (;;) {
        if (xQueueReceive(s_q, &m, portMAX_DELAY) == pdPASS) {
            filter_and_store(&m);          // m 은 내 스택의 사본
        }
    }
}

자바 BlockingQueue 와 1:1 대응

JavaFreeRTOS
offer(e) 즉시 실패xQueueSend(q, &e, 0) → errQUEUE_FULL
offer(e, 5, MILLISECONDS)xQueueSend(q, &e, pdMS_TO_TICKS(5))
put(e) 무한 대기xQueueSend(q, &e, portMAX_DELAY)
take()xQueueReceive(q, &out, portMAX_DELAY)
poll(t)xQueueReceive(q, &out, t) → pdFALSE
peek()xQueuePeek(q, &out, 0)
addFirst(Deque)xQueueSendToFront(q, &e, t) — 긴급 메시지
size()uxQueueMessagesWaiting(q)
portMAX_DELAY 의 조건 — INCLUDE_vTaskSuspend 가 1 이어야 "무한"입니다. 0 이면 portMAX_DELAY 는 그냥 0xFFFFFFFF tick(약 49.7일)짜리 타임아웃입니다.
길이 1 큐 = 우편함 — "항상 최신값 하나만" 필요하면 xQueueCreate(1, sizeof(T)) 에 xQueueOverwrite(q, &v) 로 덮어쓰고, 읽는 쪽은 xQueuePeek 으로 지우지 않고 읽습니다. 설정값 · 최신 센서값 공유에 딱 맞습니다.

큰 데이터 — 포인터를 넘기되 소유권을 같이 넘깁니다 🟡 중급

버퍼 풀 + 큐 두 개 (free / full)

#define NBUF 4
typedef struct { uint16_t len; uint8_t data[256]; } Frame;

static Frame s_frames[NBUF];               // 실제 메모리는 여기 한 곳
static QueueHandle_t s_free, s_full;       // 둘 다 Frame* 를 담음

void frames_init(void) {
    s_free = xQueueCreate(NBUF, sizeof(Frame *));
    s_full = xQueueCreate(NBUF, sizeof(Frame *));
    for (int i = 0; i < NBUF; i++) {
        Frame *f = &s_frames[i];
        xQueueSend(s_free, &f, 0);         // 포인터 '값' 4 바이트를 복사
    }
}

/* 생산자 — 빈 버퍼를 빌려 채우고 넘김 */
Frame *f;
if (xQueueReceive(s_free, &f, pdMS_TO_TICKS(5)) == pdPASS) {
    f->len = (uint16_t)fill(f->data, sizeof f->data);
    xQueueSend(s_full, &f, portMAX_DELAY); // ★ 이 줄 뒤로 f 를 만지면 안 됨
}

/* 소비자 — 다 쓰면 반드시 반납 */
Frame *g;
xQueueReceive(s_full, &g, portMAX_DELAY);
handle(g->data, g->len);
xQueueSend(s_free, &g, 0);                 // 반납 누락 = 버퍼 누수

왜 이 모양인가

방식복사량위험
Frame 을 값으로258 B × 2번없음. 큐 RAM 이 NBUF × 258
지역 버퍼 포인터4 B수명 문제 — 쓰면 안 됨
풀 + 포인터 큐4 B반납 누락 · 넘긴 뒤 접근
pvPortMalloc + 포인터4 B단편화 · 해제 누락 (r10)
소유권이라는 말 — 러스트의 move 와 같은 생각입니다. 큐에 넣는 순간 버퍼의 주인이 바뀌고, 보낸 쪽은 그 포인터를 잊어야 합니다. C 는 이걸 검사해 주지 않으니 변수 이름과 주석으로 규칙을 드러냅니다. UART DMA 수신처럼 하드웨어가 채우는 버퍼도 같은 모양으로 굴립니다(📡 통신 · 드라이버 탭).

여러 큐를 한 번에 기다리기 — 큐 셋, 그리고 더 나은 대안 🔴 실무

xQueueSelectFromSet — select() 의 RTOS 판

// FreeRTOSConfig.h : #define configUSE_QUEUE_SETS 1
static QueueHandle_t     q_cmd;     // 길이 8
static SemaphoreHandle_t sem_rx;    // 이진 세마포어 (길이 1)
static QueueSetHandle_t  set;

void init(void) {
    q_cmd  = xQueueCreate(8, sizeof(Cmd));
    sem_rx = xSemaphoreCreateBinary();
    set    = xQueueCreateSet(8 + 1);        // ★ 멤버 길이의 합
    xQueueAddToSet(q_cmd, set);             // 멤버는 비어 있을 때 추가
    xQueueAddToSet(sem_rx, set);
}

static void Worker(void *arg) {
    (void)arg;
    for (;;) {
        QueueSetMemberHandle_t m = xQueueSelectFromSet(set, portMAX_DELAY);
        if (m == q_cmd) {
            Cmd c;
            xQueueReceive(q_cmd, &c, 0);     // 반드시 꺼내야 다음 select 가 맞음
            run_cmd(&c);
        } else if (m == sem_rx) {
            xSemaphoreTake(sem_rx, 0);
            drain_rx();
        }
    }
}

대부분은 큐 하나 + 태그로 충분합니다

typedef enum { EV_CMD, EV_RX_READY, EV_TIMEOUT } EvType;

typedef struct {
    EvType type;
    union {                   // 종류별 내용 — 크기는 가장 큰 멤버
        Cmd      cmd;
        uint16_t rx_len;
    } u;
} Event;

static QueueHandle_t s_events;   // 이 태스크의 '수신함' 하나

for (;;) {
    Event e;
    xQueueReceive(s_events, &e, portMAX_DELAY);
    switch (e.type) {
    case EV_CMD:      run_cmd(&e.u.cmd);   break;
    case EV_RX_READY: drain_rx(e.u.rx_len); break;
    case EV_TIMEOUT:  retry();             break;
    }
}
FreeRTOS 문서도 큐 셋보다 이 방식을 권합니다 — 큐 셋은 멤버마다 추가 비용이 있고 "select 했으면 반드시 꺼낸다"는 규칙을 어기면 꼬입니다. 태그 붙은 이벤트 큐 하나는 r13 의 액티브 오브젝트 패턴의 바탕입니다.

CMSIS-RTOS v2 대응 — 큐

FreeRTOSCMSIS-RTOS v2주의
xQueueCreate(5, sizeof(Msg))osMessageQueueNew(5, sizeof(Msg), NULL)
xQueueSend(q, &m, t)osMessageQueuePut(q, &m, 0, t)세 번째 인자(우선순위)는 FreeRTOS 래퍼에서 무시
xQueueReceive(q, &m, t)osMessageQueueGet(q, &m, NULL, t)
xQueueSendFromISR(…)같은 osMessageQueuePutISR 이면 래퍼가 알아서 FromISR 호출 · 타임아웃은 0 이어야 함
정리 — 큐는 값 복사 + 블로킹. 작은 구조체는 값으로, 큰 버퍼는 풀 + 포인터 큐로 소유권을 넘깁니다.
RTOS 06

세마포어 · 뮤텍스 — 소유권 하나가 가르는 차이와 우선순위 역전

FreeRTOS 의 세마포어와 뮤텍스는 둘 다 xSemaphoreTake / xSemaphoreGive 로 씁니다. 모양이 같아서 섞어 쓰기 쉽지만 뮤텍스에는 주인이 있고, 그 덕분에 우선순위 역전을 막을 수 있습니다. 화성 탐사선을 멈춰 세웠던 바로 그 버그입니다.

세마포어 — 신호와 개수

#include "semphr.h"

/* 이진 세마포어 — "일이 생겼다" 신호 (ISR → 태스크) */
static SemaphoreHandle_t s_rxDone;
/* 초기화 함수 안에서 */
s_rxDone = xSemaphoreCreateBinary();      // ★ 처음엔 비어 있음 (Take 하면 대기)

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *h) {   // ISR 문맥
    BaseType_t woken = pdFALSE;
    xSemaphoreGiveFromISR(s_rxDone, &woken);
    portYIELD_FROM_ISR(woken);
}

static void RxTask(void *arg) {
    (void)arg;
    for (;;) {
        if (xSemaphoreTake(s_rxDone, pdMS_TO_TICKS(100)) == pdTRUE) {
            parse_frame();
        } else {
            report_rx_timeout();          // 100 ms 동안 아무것도 안 옴
        }
    }
}

/* 카운팅 세마포어 — 같은 자원 N 개 (예: 송신 버퍼 슬롯 3개) */
static SemaphoreHandle_t s_slots;
/* 초기화 함수 안에서 */
s_slots = xSemaphoreCreateCounting(3, 3); // 최대 3, 처음 3 개 사용 가능
ISR → 태스크 신호에는 이진 세마포어보다 태스크 알림이 더 가볍고 빠릅니다(r08). 세마포어가 필요한 건 여러 태스크가 같은 신호를 기다릴 때나 카운팅이 필요할 때입니다.

뮤텍스 — 공유 자원을 한 명씩

static SemaphoreHandle_t s_i2cLock;

void i2c_bus_init(void) {
    s_i2cLock = xSemaphoreCreateMutex();  // 처음엔 '사용 가능'
    configASSERT(s_i2cLock);
}

HAL_StatusTypeDef i2c_read(uint16_t addr7, uint8_t reg,
                           uint8_t *buf, uint16_t n) {
    if (xSemaphoreTake(s_i2cLock, pdMS_TO_TICKS(20)) != pdTRUE) {
        return HAL_TIMEOUT;               // 20 ms 동안 버스를 못 얻음
    }
    HAL_StatusTypeDef st = HAL_I2C_Mem_Read(&hi2c1, (uint16_t)(addr7 << 1),
                                            reg, I2C_MEMADD_SIZE_8BIT,
                                            buf, n, 10);
    xSemaphoreGive(s_i2cLock);            // ★ 모든 경로에서 반납
    return st;
}
이진 세마포어뮤텍스
주인없음 — 누구나 GiveTake 한 태스크만 Give
초기 상태비어 있음사용 가능
우선순위 상속없음있음
ISR 에서Give 가능금지
용도신호 · 동기화상호 배제

우선순위 역전 — 높은 태스크가 중간 태스크를 기다리는 사고 🟡 중급

상속 없음 — H 가 7 tick 막힘 H 마감 t=7 H (높음) 뮤텍스 대기 늦음 M (중간) M 이 L 을 밀어냄 (5 tick) L (낮음) 우선순위 상속 — H 는 2 tick 만 막힘 H 마감 t=7 H (높음) 제때 M (중간) M 실행 (5 tick) L (낮음) H 우선순위로 승격 0 2 4 6 8 10 12 tick L 이 뮤텍스를 쥐고 실행 Blocked (뮤텍스 대기) Ready 지만 못 돎
역전의 진짜 범인은 뮤텍스와 아무 상관 없는 M 입니다 — 상속은 L 을 잠깐 올려 M 이 끼어들 틈을 없앱니다.

FreeRTOS 가 해 주는 것 · 안 해 주는 것

상황FreeRTOS 뮤텍스
H 가 L 이 쥔 뮤텍스를 기다림L 을 H 우선순위로 상속
L 이 Give원래 우선순위로 복귀
H 가 타임아웃으로 포기L 의 상속 우선순위를 다시 낮춤
L 이 뮤텍스 여러 개를 쥠복귀가 늦어질 수 있음 — 한 번에 하나만 쥐세요
이진 세마포어로 잠금상속 없음 → 역전 그대로 발생
데드락 (r11)막아 주지 않음
제일 흔한 실수 — "어차피 Take/Give 는 같으니까" 하고 공유 자원 잠금에 xSemaphoreCreateBinary() 를 쓰는 것. 평소엔 똑같이 동작하다가, 부하가 높을 때만 H 가 마감을 놓칩니다. 잠금에는 언제나 xSemaphoreCreateMutex().

1997 화성 패스파인더 — 실제로 일어난 일

L : 기상 데이터 수집 태스크   (공유 정보 버스 뮤텍스를 짧게 쥠)
M : 통신 태스크              (오래 걸림)
H : 버스 관리 태스크          (주기적으로 그 뮤텍스가 필요)

→ M 이 L 을 밀어내는 동안 H 가 마감을 넘김
→ 워치독이 "H 가 안 돌았다"를 감지 → 시스템 리셋 반복
→ 원인: VxWorks 뮤텍스의 우선순위 상속 옵션이 꺼져 있었음
→ 지구에서 설정을 바꾸는 패치를 올려 해결
상속보다 나은 설계 — 자원을 아예 공유하지 않는 것입니다. I2C 버스를 쓰는 태스크가 여럿이면 버스 전담 태스크(게이트키퍼) 하나만 HAL 을 부르고, 나머지는 큐로 요청을 보냅니다. 뮤텍스가 사라지면 역전도 사라집니다(r13).

재귀 뮤텍스 · 대응표 — 필요하면 설계를 의심하세요 🔴 실무

같은 태스크가 두 번 잡아야 할 때

// configUSE_RECURSIVE_MUTEXES 1
static SemaphoreHandle_t s_log;
/* 초기화 함수 안에서 */  s_log = xSemaphoreCreateRecursiveMutex();

void log_line(const char *s) {
    xSemaphoreTakeRecursive(s_log, portMAX_DELAY);
    uart_puts(s);
    uart_puts("\r\n");
    xSemaphoreGiveRecursive(s_log);
}

void log_block(const char *a, const char *b) {
    xSemaphoreTakeRecursive(s_log, portMAX_DELAY);   // 1 회
    log_line(a);                                      // 안에서 2 회 — 통과
    log_line(b);
    xSemaphoreGiveRecursive(s_log);                   // Take 횟수만큼 Give 해야 풀림
}
일반 뮤텍스로 이렇게 하면 두 번째 Take 에서 자기 자신을 영원히 기다립니다(자기 데드락). 다만 재귀 뮤텍스가 필요하다는 것은 대개 "잠금 범위가 함수 경계와 안 맞는다"는 신호입니다 — 자바의 ReentrantLock 과 같은 함정.

CMSIS-RTOS v2 대응 — 세마포어 · 뮤텍스

FreeRTOSCMSIS-RTOS v2
xSemaphoreCreateBinary()osSemaphoreNew(1, 0, NULL)
xSemaphoreCreateCounting(3, 3)osSemaphoreNew(3, 3, NULL)
xSemaphoreTake(s, t)osSemaphoreAcquire(s, t)
xSemaphoreGive(s)osSemaphoreRelease(s)
xSemaphoreCreateMutex()osMutexNew(&attr), attr_bits = osMutexPrioInherit
xSemaphoreCreateRecursiveMutex()attr_bits = osMutexRecursive | osMutexPrioInherit
xSemaphoreTake(m, t) (뮤텍스)osMutexAcquire(m, t)
Java 로 옮기면 — 이진 세마포어 ≈ new Semaphore(0), 카운팅 ≈ new Semaphore(3), 뮤텍스 ≈ 재진입 안 되는 Lock. 단 자바 락에는 우선순위 상속이 없습니다 — 서버에선 우선순위가 의미가 없으니까요.
정리 — 신호에는 세마포어(또는 알림), 잠금에는 반드시 뮤텍스. 뮤텍스만 우선순위 상속으로 역전을 막고, 더 좋은 방법은 공유하지 않는 설계입니다.
RTOS 07

인터럽트와 RTOS — ISR 은 알리기만, 일은 태스크가

RTOS 펌웨어가 처음 멈추는 곳은 십중팔구 여기입니다. ISR 에서 일반 API 를 부르거나, NVIC 우선순위를 기본값 0 으로 둔 채 FromISR API 를 부르면 configASSERT 에 걸려 그대로 섭니다. 규칙은 세 가지 — FromISR 만 부른다 · 우선순위 숫자는 5 이상 · 깨운 태스크가 더 급하면 양보한다.

STM32F4 NVIC 우선순위 (4비트, 0~15) ← 숫자가 작을수록 더 급함 덜 급함 → 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 커널이 절대 막지 않음 FreeRTOS API 호출 금지 FromISR API 호출 가능 — BASEPRI = 0x50 (5 << 4) 경계 taskENTER_CRITICAL 동안은 잠깐 늦게 처리됨 커널 PendSV HAL 기본 우선순위 0 그대로 FromISR 을 부르면 → configASSERT 에서 멈춤 0~4 는 "RTOS 와 무관하게 µs 단위로 반드시" 돌아야 하는 ISR 전용입니다
경계선 하나 — 5 보다 작은 숫자의 ISR 에서는 FreeRTOS 함수를 하나도 부르지 않습니다.

ISR → 태스크 — 표준 모양 세 줄

static TaskHandle_t s_btnTask;     // xTaskCreate 의 마지막 인자로 받아 둠

/* PC13(B1) → EXTI15_10_IRQHandler → HAL 이 이 콜백을 ISR 안에서 호출 */
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
    if (GPIO_Pin == B1_Pin) {
        BaseType_t woken = pdFALSE;               // ① 반드시 pdFALSE 로 시작
        vTaskNotifyGiveFromISR(s_btnTask, &woken);// ② FromISR 판만
        portYIELD_FROM_ISR(woken);                // ③ 더 급한 태스크가 깼으면 전환 예약
    }
}

void MX_NVIC_Init_custom(void) {
    HAL_NVIC_SetPriority(EXTI15_10_IRQn, 6, 0);   // ★ 5 이상 (0 이면 assert)
    HAL_NVIC_EnableIRQ(EXTI15_10_IRQn);
}

static void ButtonTask(void *arg) {
    (void)arg;
    for (;;) {
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);  // ISR 이 줄 때까지 잠
        on_button();                              // 시간이 걸려도 되는 처리
    }
}
woken 은 왜 필요한가 — ISR 이 끝나면 CPU 는 원래 돌던 태스크로 돌아갑니다. 방금 깨운 ButtonTask 가 더 급해도 다음 tick(최대 1ms)까지 기다리게 됩니다. portYIELD_FROM_ISR(pdTRUE) 는 PendSV 를 걸어 ISR 이 끝나자마자 전환합니다. 여러 FromISR 을 불렀다면 같은 변수를 넘기면 OR 로 누적됩니다.

ISR 안에서 지킬 규칙

하지 말 것대신
xQueueSend · xSemaphoreGivexQueueSendFromISR · xSemaphoreGiveFromISR
대기 (타임아웃 인자)FromISR 에는 대기 인자가 아예 없음 — 실패하면 버리거나 카운트
뮤텍스 Take/Give금지. 신호는 알림 · 세마포어로
taskENTER_CRITICAL()x = taskENTER_CRITICAL_FROM_ISR() / taskEXIT_CRITICAL_FROM_ISR(x)
printf · HAL_Delay · 파싱태스크로 넘김 (지연 처리)
xTaskGetTickCount()xTaskGetTickCountFromISR()
HAL 콜백의 함정 — HAL_UART_RxCpltCallback 은 DMA 스트림 IRQ 안에서 불릴 수 있습니다. USART2 IRQ 를 6 으로 올려 놓고 DMA1_Stream5_IRQn 은 기본 0 으로 두면 DMA 완료 때만 assert 가 납니다. 콜백에서 FreeRTOS 를 부르는 모든 IRQ 소스(UART · DMA · EXTI · TIM)를 5 이상으로 맞추세요.

지연 처리 — ISR 이 끝난 직후 태스크가 받는 순서 🟡 중급

세로가 시간 — ISR 은 짧게, 처리는 RxTask 가 LogTask (1) USART2 ISR 커널 RxTask (3) 1. 바이트 도착 → 선점 2. DR 읽어 링 버퍼에 (수 µs) 3. vTaskNotifyGiveFromISR() 4. woken = pdTRUE 5. portYIELD_FROM_ISR(woken) 6. ISR 복귀 직후 PendSV 전환 7. ulTaskNotifyTake 반환 → 프레임 파싱 · 큐로 전달 8. RxTask 가 다시 잠들면 LogTask 이어서 woken 을 무시하면 6번이 최대 1 tick 늦어집니다 — LogTask 가 그동안 계속 돕니다
지연 처리(deferred interrupt) — ISR 의 길이는 짧게 고정하고, 무거운 일은 우선순위로 제어되는 태스크가 합니다.

태스크 하나 만들기 아까울 때 — 함수 호출을 미루기

// INCLUDE_xTimerPendFunctionCall 1, configUSE_TIMERS 1
static void handle_overrun(void *p1, uint32_t ul2) {
    (void)p1;
    log_warn("UART overrun, SR=0x%08lx", (unsigned long)ul2);
}

void USART2_IRQHandler(void) {
    uint32_t sr = USART2->SR;
    if (sr & USART_SR_ORE) {
        (void)USART2->DR;                      // SR → DR 순서로 읽어 ORE 해제
        BaseType_t woken = pdFALSE;
        xTimerPendFunctionCallFromISR(handle_overrun, NULL, sr, &woken);
        portYIELD_FROM_ISR(woken);
    }
    HAL_UART_IRQHandler(&huart2);
}
handle_overrun 은 타이머 서비스 태스크 안에서 실행됩니다(r09). 드물게 생기는 일을 처리할 때 좋고, 자주 생기는 일이면 전용 태스크를 두는 편이 우선순위를 따로 줄 수 있어 낫습니다.

configASSERT 에 멈췄을 때 — 어디서 멈췄나 보면 원인이 나옵니다

/* port.c — vPortValidateInterruptPriority() */
configASSERT( ucCurrentPriority >= ucMaxSysCallPriority );
/*  → 지금 ISR 의 NVIC 우선순위 숫자가 5 보다 작다
    → HAL_NVIC_SetPriority(해당 IRQn, 5 이상, 0) */

configASSERT( ( portAIRCR_REG & portPRIORITY_GROUP_MASK ) <= ulMaxPRIGROUPValue );
/*  → 우선순위 그룹을 서브 우선순위가 생기게 바꿨다
    → HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4) 로 되돌림 */
assert 를 무한 루프 대신 쓸모 있게 — 파일명 · 줄 번호를 받는 함수로 바꿔 두면 디버거 없이도 원인을 압니다: #define configASSERT(x) if(!(x)) assert_failed_rtos(__FILE__, __LINE__). 양산에서는 이 함수가 로그를 남기고 리셋하게 합니다(🚀 양산 탭).

우선순위 배정 예 — F446RE 3태스크 시스템 🔴 실무

IRQNVICFreeRTOS 호출이유
TIM1 (모터 PWM 갱신)2없음µs 지터도 안 됨
TIM6 (HAL tick)TICK_INT_PRIORITY (생성값 확인)없음ISR 안에서 HAL 타임아웃을 기다린다면 그 ISR 보다 급하게
USART2 · DMA1 Stream56알림수신 → RxTask
I2C1 EV/ER · DMA7알림센서 완료 → SensorTask
EXTI15_10 (버튼)10알림사람 손은 느림
SysTick · PendSV15(커널)커널이 직접 설정

CMSIS-RTOS v2 에서는

/* cmsis_os2 래퍼는 지금 ISR 안인지 스스로 판별합니다 */
void HAL_GPIO_EXTI_Callback(uint16_t pin) {
    if (pin == B1_Pin) {
        osThreadFlagsSet(btnThreadId, 0x0001U);  // ISR 이면 내부에서 FromISR + yield
    }
}

void ButtonThread(void *arg) {
    for (;;) {
        osThreadFlagsWait(0x0001U, osFlagsWaitAny, osWaitForever);
        on_button();
    }
}
편하지만 같은 규칙 — 래퍼가 FromISR 을 골라 줄 뿐 NVIC 우선순위 5 이상 규칙은 그대로입니다. 또 ISR 에서 osMessageQueuePut 등을 부를 때 타임아웃은 반드시 0 이어야 하며, 0 이 아니면 에러를 돌려줍니다.
정리 — ISR 은 FromISR 로 알리고 portYIELD_FROM_ISR, 처리는 태스크가. FreeRTOS 를 부르는 모든 IRQ 는 NVIC 5~15.
RTOS 08

태스크 알림 · 이벤트 그룹 · 스트림/메시지 버퍼 — 언제 무엇을

큐와 세마포어만으로 모든 걸 할 수 있지만, FreeRTOS 에는 더 가볍거나 더 잘 맞는 도구가 세 가지 더 있습니다. 고르는 기준은 "받는 쪽이 몇인가 · 무엇을 보내는가"입니다.

위에서 아래로 — 위쪽 답일수록 가볍고 빠릅니다 받는 쪽이 태스크 하나, 신호 · 숫자 하나면 되나? 예 태스크 알림 추가 RAM 0 · 가장 빠름 아니오 여러 비트를 AND · OR 로 기다리거나 여럿이 깨나? 예 이벤트 그룹 비트 24개 · 여러 태스크 동시 기상 아니오 바이트 흐름 · 가변 길이, 보내는 쪽 · 받는 쪽 각 하나? 예 스트림 · 메시지 버퍼 UART 수신 · 가변 길이 패킷 아니오 큐 구조체 · 여러 생산자 · 여러 소비자 TCB 안의 32비트 칸 하나 세마포어 대비 훨씬 빠름 ISR 에서 Set 하면 타이머 태스크를 거쳐 늦게 반영 락 없는 링 버퍼 구조 쓰는 쪽이 둘이면 직접 잠가야
기본값은 태스크 알림, 안 되면 아래로 — 모든 갈래가 큐에서 끝납니다.

태스크 알림 — 세 가지 얼굴

/* ① 이진 세마포어처럼 — 몇 번 줘도 한 번 깨어남 */
xTaskNotifyGive(h);                              // 태스크에서
vTaskNotifyGiveFromISR(h, &woken);               // ISR 에서
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);         // pdTRUE: 받으면 0 으로 지움

/* ② 카운팅 세마포어처럼 — 준 횟수만큼 처리 */
uint32_t n = ulTaskNotifyTake(pdFALSE, portMAX_DELAY);  // pdFALSE: 1 만 뺌
// n = 빼기 직전 값 (지금 쌓여 있던 개수)

/* ③ 이벤트 비트처럼 — 무엇이 일어났는지 비트로 */
#define EV_RX    (1u << 0)
#define EV_TX    (1u << 1)
#define EV_ERR   (1u << 2)

xTaskNotifyFromISR(h, EV_RX, eSetBits, &woken); // 비트 OR

uint32_t bits;
if (xTaskNotifyWait(0,            // 들어갈 때 지울 비트 없음
                    UINT32_MAX,   // 나올 때 전부 지움
                    &bits, pdMS_TO_TICKS(500)) == pdTRUE) {
    if (bits & EV_ERR) { recover(); }
    if (bits & EV_RX)  { drain_rx(); }
} else {
    on_idle_500ms();              // 500 ms 동안 아무 알림도 없음
}
알림의 한계 — ① 받는 태스크가 정확히 하나(핸들로 지정) ② 값 하나뿐이라 여러 개를 쌓아 둘 수 없음 (eSetValueWithoutOverwrite 는 안 읽힌 값이 있으면 실패) ③ ISR 쪽에서 받을 수는 없음. V10.4 부터는 태스크마다 알림 칸을 여러 개 둘 수 있습니다(configTASK_NOTIFICATION_ARRAY_ENTRIES, …Indexed API).

이벤트 그룹 — "준비가 다 됐을 때" 한꺼번에

#include "event_groups.h"

#define BIT_SENSOR_OK  (1u << 0)
#define BIT_LINK_UP    (1u << 1)
#define BIT_TIME_SYNC  (1u << 2)
#define ALL_READY      (BIT_SENSOR_OK | BIT_LINK_UP | BIT_TIME_SYNC)

static EventGroupHandle_t s_sys;
/* 초기화 함수 안에서 */  s_sys = xEventGroupCreate();

/* 각자 준비되면 비트를 켬 */
xEventGroupSetBits(s_sys, BIT_LINK_UP);

/* 보고 태스크 — 셋 다 켜질 때까지 대기 (여러 태스크가 동시에 기다려도 됨) */
EventBits_t b = xEventGroupWaitBits(
    s_sys,
    ALL_READY,          // 기다릴 비트
    pdFALSE,            // 나갈 때 지우지 않음 (다른 태스크도 봐야 하니까)
    pdTRUE,             // ★ 전부(AND). pdFALSE 면 하나라도(OR)
    pdMS_TO_TICKS(10000));
if ((b & ALL_READY) != ALL_READY) {
    report_not_ready(b);   // 10 초 타임아웃 — 무엇이 빠졌는지 b 로 앎
}
쓸 수 있는 비트 수 — 32비트 tick 설정에서 24개(위 8비트는 커널 몫). xEventGroupSetBitsFromISR 는 비트를 바로 켜지 않고 타이머 서비스 태스크에 맡겨 켭니다 — 여러 태스크를 깨우는 비결정적인 일을 ISR 안에서 하지 않으려는 설계입니다.

스트림 버퍼 · 메시지 버퍼 — 바이트를 흘려보낼 때 🟡 중급

UART 수신 바이트 → 파서 태스크

#include "stream_buffer.h"

static StreamBufferHandle_t s_rx;

void rx_init(void) {
    s_rx = xStreamBufferCreate(512,   // 버퍼 크기 (바이트)
                               1);    // 트리거: 1 바이트만 와도 깨움
    configASSERT(s_rx);
}

/* USART2 수신 ISR — 쓰는 쪽은 이 ISR 하나뿐 */
void uart2_on_byte(uint8_t b) {
    BaseType_t woken = pdFALSE;
    size_t n = xStreamBufferSendFromISR(s_rx, &b, 1, &woken);
    if (n == 0) { s_rxOverflow++; }        // 가득 참 — ISR 은 기다릴 수 없음
    portYIELD_FROM_ISR(woken);
}

/* 파서 태스크 — 읽는 쪽도 하나뿐 */
static void ParserTask(void *arg) {
    (void)arg;
    uint8_t chunk[64];
    for (;;) {
        size_t n = xStreamBufferReceive(s_rx, chunk, sizeof chunk,
                                        pdMS_TO_TICKS(20));
        if (n > 0) { parser_feed(chunk, n); }
        else       { parser_idle_timeout(); }   // 20 ms 무수신 = 프레임 끝
    }
}

메시지 버퍼 — 길이까지 같이

#include "message_buffer.h"

static MessageBufferHandle_t s_mb;
/* 초기화 함수 안에서 */  s_mb = xMessageBufferCreate(256);

/* 보내는 쪽 — 길이가 매번 다른 패킷 */
size_t sent = xMessageBufferSend(s_mb, pkt, pkt_len, pdMS_TO_TICKS(10));
// 버퍼 안에는 [길이 4B][데이터 pkt_len B] 로 저장 → 10 B 메시지 = 14 B 소비

/* 받는 쪽 — 한 번에 메시지 하나 통째로 */
uint8_t buf[128];
size_t len = xMessageBufferReceive(s_mb, buf, sizeof buf, portMAX_DELAY);
// buf 가 다음 메시지보다 작으면 0 을 돌려주고 메시지는 그대로 남음
알림이벤트 그룹스트림/메시지큐
받는 쪽1 태스크여럿1여럿
보내는 쪽여럿여럿1(기본)여럿
데이터32비트비트 24바이트 열고정 크기 항목
쌓임✕(카운트만)✕○○
추가 RAM0작음버퍼길이×크기
단일 송신 · 단일 수신 가정 — 스트림/메시지 버퍼는 락 없이 동작하도록 만들어져서, 쓰는 태스크가 둘이면 쓰기 앞뒤를 직접 크리티컬 섹션이나 뮤텍스로 감싸야 합니다. 그럴 바엔 큐가 낫습니다.

CMSIS-RTOS v2 대응 — 이름이 꽤 다릅니다 🔴 실무

FreeRTOSCMSIS-RTOS v2주의
xTaskNotify(h, bits, eSetBits)osThreadFlagsSet(id, flags)Thread Flags = 태스크 알림 위에 구현
xTaskNotifyWait(…)osThreadFlagsWait(flags, osFlagsWaitAny, t)osFlagsWaitAll · osFlagsNoClear 옵션
xEventGroupCreate()osEventFlagsNew(NULL)
xEventGroupWaitBits(g, b, clr, all, t)osEventFlagsWait(g, b, osFlagsWaitAll, t)기본은 지움 — 안 지우려면 osFlagsNoClear
xStreamBufferCreate · xMessageBufferCreate(대응 없음)CMSIS v2 에는 없음 — 네이티브 API 를 그대로 쓰면 됩니다
정리 — 한 태스크에게 신호 → 알림, 여러 조건 · 여러 대기자 → 이벤트 그룹, 바이트 흐름 → 스트림/메시지 버퍼, 나머지 → 큐.
RTOS 09

소프트웨어 타이머 — 태스크 하나가 모든 콜백을 돌립니다

"500 ms 뒤에 LED 끄기", "2 초 동안 응답 없으면 재전송" 같은 일마다 태스크를 만들면 스택이 아깝습니다. 소프트웨어 타이머는 타이머 서비스 태스크(데몬) 하나가 모든 타이머의 콜백을 차례로 실행해 줍니다. 그래서 규칙도 하나 — 콜백에서 절대 기다리지 않는다.

API 호출은 명령이 되어 큐로 — 실행은 서비스 태스크 하나에서 SensorTask xTimerStart(t, 0) EXTI ISR xTimerResetFromISR() CommTask xTimerChangePeriod() 타이머 명령 큐 길이 = configTIMER_QUEUE_LENGTH Tmr Svc 우선순위 7 led_off_cb() debounce_cb() comm_timeout_cb() 콜백은 Tmr Svc 의 스택에서, 한 번에 하나씩 돕니다 한 콜백이 멈추면 모든 타이머가 같이 멈춥니다
소프트웨어 타이머는 하드웨어 타이머가 아니라 "tick 을 세는 태스크 하나"입니다 — 정밀도는 tick, 지연은 그 태스크의 우선순위가 정합니다.

원샷 · 주기 타이머 만들기

#include "timers.h"

static TimerHandle_t s_ledOff, s_heartbeat;

static void led_off_cb(TimerHandle_t t) {
    (void)t;
    HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_RESET);
}

static void heartbeat_cb(TimerHandle_t t) {
    uint32_t *count = pvTimerGetTimerID(t);   // 타이머마다 붙여 둔 데이터
    (*count)++;
    HAL_GPIO_TogglePin(LD2_GPIO_Port, LD2_Pin);
}

static uint32_t s_beats;

void timers_init(void) {
    s_ledOff = xTimerCreate("ledOff", pdMS_TO_TICKS(500),
                            pdFALSE,          // 원샷
                            NULL, led_off_cb);
    s_heartbeat = xTimerCreate("hb", pdMS_TO_TICKS(1000),
                               pdTRUE,        // 주기 (auto-reload)
                               &s_beats, heartbeat_cb);
    configASSERT(s_ledOff && s_heartbeat);
    xTimerStart(s_heartbeat, 0);   // 스케줄러 시작 전 호출도 OK (큐에 쌓임)
}

/* 어디서든: LED 켜고 500 ms 뒤에 끄기 — 다시 부르면 500 ms 가 연장됨 */
void blink_once(void) {
    HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_SET);
    xTimerReset(s_ledOff, 0);
}
반환값 확인 — xTimerStart(t, 0) 은 명령 큐가 가득 차면 pdFAIL 입니다. 여러 태스크가 한꺼번에 타이머를 건드리면 일어나니 configTIMER_QUEUE_LENGTH 를 넉넉히(10~20) 잡습니다.

디바운스 — ISR 과 타이머의 조합

static TimerHandle_t s_debounce;   // 원샷 20 ms

void HAL_GPIO_EXTI_Callback(uint16_t pin) {
    if (pin == B1_Pin) {
        BaseType_t woken = pdFALSE;
        // 튈 때마다 20 ms 를 다시 셈 → 마지막 변화 후 20 ms 조용해야 만료
        xTimerResetFromISR(s_debounce, &woken);
        portYIELD_FROM_ISR(woken);
    }
}

static void debounce_cb(TimerHandle_t t) {
    (void)t;
    if (HAL_GPIO_ReadPin(B1_GPIO_Port, B1_Pin) == GPIO_PIN_RESET) {
        xTaskNotifyGive(s_btnTask);   // ✓ 대기 없는 API 는 OK
    }
}
콜백 안에서가능?
xQueueSend(q, &v, 0)○ (대기 0)
xQueueSend(q, &v, portMAX_DELAY)✕ — 데몬이 멈춤
vTaskDelay · HAL_Delay✕
HAL_UART_Transmit(폴링)✕ — 숨은 대기
다른 타이머 Start/Stop (대기 0)○

콜백이 기다리면 — 다른 타이머가 전부 밀립니다 🟡 중급

Tmr Svc 태스크 안에서 일어나는 일 — 콜백 A 가 28 ms 를 붙잡으면 콜백 A HAL_UART_Transmit 폴링 28 ms ✕ 타이머 B (5 ms 예정) 23 ms 늦음 타이머 C (10 ms 주기) 놓친 두 번을 몰아서 0 10 20 30 40 50 ms 빈 원 = 원래 실행됐어야 할 시각 · 막대 = 실제로 실행된 시각
콜백은 짧고, 기다리지 않게 — 오래 걸리는 일은 콜백에서 태스크에 알림만 보냅니다.

설정 세 개가 타이머의 성격을 정합니다

항목권장 · 이유
configTIMER_TASK_PRIORITY보통 최고(configMAX_PRIORITIES−1). 낮으면 바쁜 태스크 뒤에서 만료가 밀림
configTIMER_QUEUE_LENGTH10~20. 동시에 타이머를 건드리는 곳 수만큼
configTIMER_TASK_STACK_DEPTH콜백 중 가장 깊은 것 기준. printf 를 부르면 크게
우선순위 최고의 부작용 — 콜백이 오래 걸리면 모든 태스크를 밀어냅니다. 그래서 "짧게"가 더욱 중요합니다. 반대로 우선순위를 낮추면 콜백이 짧아도 만료 시각이 흔들립니다. 정밀도가 필요한 일(µs)은 하드웨어 TIM 으로 하세요(🟦 STM32 탭).

CMSIS-RTOS v2 대응 — 타이머

static void led_off(void *arg) { (void)arg; led_set(0); }

osTimerId_t t = osTimerNew(led_off, osTimerOnce, NULL, NULL);
osTimerStart(t, 500U);        // 이 함수가 주기(tick)를 받음
osTimerStop(t);

osTimerId_t hb = osTimerNew(beat, osTimerPeriodic, &s_beats, NULL);
osTimerStart(hb, 1000U);
차이 — 네이티브는 생성할 때 주기를 정하고 콜백이 TimerHandle_t 를 받지만, CMSIS 는 Start 할 때 주기를 정하고 콜백이 void *arg 를 받습니다(래퍼가 ID 로 감싸 전달). 내부는 같은 데몬 태스크입니다.
정리 — 소프트웨어 타이머 = 명령 큐 + 데몬 태스크 하나. 콜백은 짧게, 대기 0 으로, 무거운 일은 태스크에 알림으로 넘깁니다.
RTOS 10

메모리 — 힙 다섯 가지, 정적 할당, 그리고 스택 크기 잡기

태스크 · 큐 · 세마포어는 전부 RAM 을 먹습니다. 서버라면 GC 나 OS 가 알아서 하지만, 128KB SRAM 에서는 어디서 얼마를 가져오는지 내가 정합니다. FreeRTOS 는 힙 구현을 다섯 가지 중 고르게 하고, 아예 힙 없이 쓰는 길도 열어 둡니다. 그리고 RTOS 펌웨어가 죽는 1순위 원인은 힙이 아니라 스택 넘침입니다.

무엇을 고를까 — 진할수록 그 항목에서 유리합니다 해제 (free) 빈칸 병합 시간 결정적 여러 영역 쓰임 heap_1 ✕ 없음 해당 없음 ○ 항상 같음 ✕ 만들고 안 지움 heap_2 ○ ✕ 단편화 ✕ ✕ 구형 → heap_4 heap_3 ○ libc free libc 에 따름 ✕ ✕ malloc 래핑 heap_4 ★ ○ ○ 병합 ✕ (빠른 편) ✕ 기본 추천 heap_5 ○ ○ 병합 ✕ (빠른 편) ○ 여러 SRAM CCM · 외부 RAM 시작할 때 다 만들고 지우지 않는 시스템이면 heap_1 이 가장 안전합니다 지우고 다시 만든다면 heap_4, 메모리가 여러 덩어리면 heap_5
빌드에 heap_N.c 하나만 넣습니다 — 둘 이상 넣으면 pvPortMalloc 이 중복 정의되어 링크 에러가 납니다.

heap_4 — 크기 정하고 감시하기

/* FreeRTOSConfig.h */
#define configTOTAL_HEAP_SIZE         (32 * 1024)   // static uint8_t ucHeap[] 로 .bss 에
#define configUSE_MALLOC_FAILED_HOOK  1

/* 할당 실패하면 커널이 부릅니다 — 조용히 NULL 이 되는 걸 막기 */
void vApplicationMallocFailedHook(void) {
    taskDISABLE_INTERRUPTS();
    crash_record("malloc failed");      // 양산이면 기록 후 리셋
    for (;;) { }
}

/* 운영 중 감시 — 셸 명령이나 주기 로그로 */
void heap_report(void) {
    size_t now = xPortGetFreeHeapSize();             // 지금 남은 양
    size_t low = xPortGetMinimumEverFreeHeapSize();  // 부팅 후 최저 기록 ★
    log_info("heap free=%u min=%u", (unsigned)now, (unsigned)low);
}
최저 기록이 핵심 — 지금 남은 양은 순간값이라 의미가 적습니다. 부하 시험을 충분히 돌린 뒤 xPortGetMinimumEverFreeHeapSize() 가 전체의 20~30% 이상 남으면 안전권, 그 아래면 늘리거나 객체를 줄입니다. 서버의 "최대 힙 사용량" 지표와 같은 감각입니다.

heap_5 — 떨어진 메모리를 하나로

/* 예: STM32F407 은 SRAM(0x2000_0000) 과 CCM(0x1000_0000, 64KB) 이 따로 있음.
   (F446RE 는 SRAM1 112KB + SRAM2 16KB 가 붙어 있어 heap_4 로 충분) */
static uint8_t s_ccmHeap[48 * 1024]  __attribute__((section(".ccmram")));
static uint8_t s_sramHeap[32 * 1024];

static const HeapRegion_t s_regions[] = {
    { s_ccmHeap,  sizeof s_ccmHeap  },   // ★ 주소가 낮은 영역부터
    { s_sramHeap, sizeof s_sramHeap },
    { NULL, 0 }                          // 끝 표시
};

int main(void) {
    vPortDefineHeapRegions(s_regions);   // ★ 어떤 객체보다도 먼저
    /* … xTaskCreate … */
}
CCM 의 함정 — F4 의 CCM RAM 은 DMA 가 접근할 수 없습니다. 힙에서 받은 버퍼를 UART DMA 에 넘기면 아무 에러 없이 데이터가 안 옵니다. DMA 버퍼는 반드시 일반 SRAM 의 정적 배열로 두세요. 링커 스크립트에 .ccmram 섹션이 있어야 위 속성이 먹습니다(🟦 STM32 탭).

정적 할당만 쓰기 — RAM 사용량을 링크할 때 확정 🟡 중급

/* FreeRTOSConfig.h */
#define configSUPPORT_STATIC_ALLOCATION   1
#define configSUPPORT_DYNAMIC_ALLOCATION  0   // ★ pvPortMalloc 자체가 사라짐
#define configKERNEL_PROVIDED_STATIC_MEMORY 1 // V11: Idle · 타이머 태스크 메모리를 커널이

/* 모든 객체를 *Static 판으로 */
static uint8_t       s_qStore[8 * sizeof(Msg)];
static StaticQueue_t s_qBuf;
static StaticSemaphore_t s_i2cMutexBuf;
static StaticEventGroup_t s_sysBuf;
static StaticTimer_t s_ledTimerBuf;

void objects_init(void) {
    s_q       = xQueueCreateStatic(8, sizeof(Msg), s_qStore, &s_qBuf);
    s_i2cLock = xSemaphoreCreateMutexStatic(&s_i2cMutexBuf);
    s_sys     = xEventGroupCreateStatic(&s_sysBuf);
    s_ledOff  = xTimerCreateStatic("ledOff", pdMS_TO_TICKS(500), pdFALSE,
                                   NULL, led_off_cb, &s_ledTimerBuf);
}
# 빌드 결과에서 RAM 사용량이 바로 보입니다
arm-none-eabi-size build/firmware.elf
   text    data     bss     dec     hex filename
  41236     412   58944  100592   188f0 build/firmware.elf
#   .bss 58,944 B = 태스크 스택 + 큐 저장소 + 그 밖 전역 — 실행 중엔 늘지 않음

동적 vs 정적

동적 (heap_4)정적
RAM 확정 시점실행 중링크 시점
실패 지점런타임 NULL링크 에러 (RAM 넘침)
코드 양짧음버퍼 선언이 늘어남
단편화지웠다 만들면 가능없음
안전 규격(MISRA 등)설명 필요선호
현실적인 절충 — 둘 다 켜 두고(DYNAMIC 1 · STATIC 1), 부팅 때만 동적으로 만들고 이후로는 절대 vTaskDelete · vQueueDelete 를 하지 않는 규칙을 둡니다. 그러면 heap_1 처럼 결정적이면서 코드는 짧습니다.
V10 에서는 configKERNEL_PROVIDED_STATIC_MEMORY 가 없으므로 vApplicationGetIdleTaskMemory · vApplicationGetTimerTaskMemory 를 직접 구현해야 합니다(r03 코드). 빠뜨리면 링크 에러로 바로 알려 줍니다.

스택 크기 잡기 — 재고, 늘리고, 감시합니다 🔴 실무

태스크 스택 한 개 — 높은 주소에서 낮은 주소로 자랍니다 스택이 자라는 방향 (SP 감소) 다른 힙 블록 16 B 검사 0xA5 그대로 한 번도 안 쓴 여유 한때 썼던 곳 최악 경로의 흔적 지금 사용 중 ← SP TCB uxTaskGetStackHighWaterMark() = 이 폭 (워드) 부팅 후 최대 사용량 낮은 주소 높은 주소 방법 1: 문맥 전환 때 SP 가 스택 범위 안인지만 봄 — 순간 넘침은 놓침 방법 2: 맨 끝 16 B 가 0xA5 그대로인지도 봄 — 거의 다 잡지만 건너뛴 쓰기는 놓침 스택은 TCB 반대쪽으로 자라도록 할당됩니다 — 넘치면 남의 블록을 덮습니다
high water mark 는 "남았던 최소량"입니다 — 0 에 가까우면 이미 넘쳤거나 넘치기 직전입니다.

재기 · 감시 · 넘침 훅

/* 모든 태스크의 여유 스택을 주기적으로 기록 */
void stack_report(void) {
    static TaskHandle_t *const tasks[] = { &s_sensor, &s_proc, &s_comm };
    for (size_t i = 0; i < sizeof tasks / sizeof tasks[0]; i++) {
        TaskHandle_t h = *tasks[i];
        UBaseType_t freeWords = uxTaskGetStackHighWaterMark(h);
        log_info("%-8s min free %4u words (%u B)", pcTaskGetName(h),
                 (unsigned)freeWords, (unsigned)(freeWords * sizeof(StackType_t)));
    }
}

/* configCHECK_FOR_STACK_OVERFLOW 2 일 때 커널이 호출 */
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) {
    (void)xTask;
    taskDISABLE_INTERRUPTS();
    crash_record_task(pcTaskName);   // 이름 정도만 — 스택이 이미 망가졌을 수 있음
    for (;;) { }                     // 워치독이 리셋 (r13)
}
넘침 검사는 사후 탐지입니다 — 훅이 불릴 때는 이미 옆 메모리를 덮은 뒤입니다. 개발 중 원인 찾기용이고, 진짜 보호가 필요하면 MPU 포트(ARM_CM4_MPU)로 스택 끝을 접근 금지 영역으로 만듭니다.

스택을 먹는 것들 — 크기 정하는 절차

항목대략
문맥 저장 (FPU 안 쓰는 태스크)17 워드 = 68 B
문맥 저장 (FPU 쓰는 태스크)51 워드 = 204 B
printf / snprintf (newlib)수백 B ~ 1 KB 이상
지역 배열 uint8_t buf[256]256 B 그대로
HAL 호출 깊이수십~수백 B
ISR 지역 변수0 — ISR 은 MSP 를 씀
① 넉넉히 시작 (예: 512 워드)
② 최악 경로를 모두 실행 (에러 처리 · 재연결 · 로그 폭주)
③ high water mark 로 실제 최대 사용량 측정
④ 크기 = 최대 사용량 × 1.3 이상 + printf 를 부르면 더
⑤ 양산 빌드에서도 주기 로그로 계속 감시
CMSIS v2 — osThreadGetStackSpace(id) 가 같은 값을 바이트로 돌려줍니다(워드 × 4).
정리 — 힙은 heap_4(또는 부팅 후 삭제 금지 규칙), 가능하면 정적 할당으로 RAM 을 링크 때 확정. 스택은 재서 정하고 개발 중엔 넘침 검사 2.
RTOS 11

동시성 버그와 디버깅 — 데드락 · 레이스 · 크리티컬 섹션 남용, 그리고 트레이스

RTOS 버그는 "가끔, 부하가 높을 때만, 디버거를 붙이면 사라지는" 모양으로 옵니다. 서버의 동시성 버그와 똑같은 종류지만 스레드 덤프도 로그 수집기도 없습니다. 대신 런타임 통계와 트레이스 도구로 시간 축을 눈으로 보는 것이 가장 빠른 길입니다.

쥠(실선)과 기다림(붉은 선)이 고리를 이루면 데드락입니다 T1 SensorTask T2 CommTask A I2C B SPI 쥠 기다림 쥠 기다림 둘 다 Blocked CPU 사용 0% 에러 없이 멈춤 해결: 모든 태스크가 A → B 순서로만 잡기 — 고리가 생길 수 없습니다
데드락은 순환입니다 — 잠금 순서를 하나로 정하면 그래프에 고리가 생길 수 없습니다.

데드락 — 순서와 타임아웃

❌ 반대 순서
/* T1 */
xSemaphoreTake(mA, portMAX_DELAY);
xSemaphoreTake(mB, portMAX_DELAY);   // T2 가 B 를 쥐고 있으면 영원히
/* T2 */
xSemaphoreTake(mB, portMAX_DELAY);
xSemaphoreTake(mA, portMAX_DELAY);
✅ 같은 순서 + 타임아웃으로 드러내기
/* 규칙: 언제나 A(I2C) → B(SPI) */
if (xSemaphoreTake(mA, pdMS_TO_TICKS(50)) == pdTRUE) {
    if (xSemaphoreTake(mB, pdMS_TO_TICKS(50)) == pdTRUE) {
        copy_i2c_to_spi();
        xSemaphoreGive(mB);
    } else {
        report_lock_timeout("B");    // 데드락 · 긴 점유를 로그로
    }
    xSemaphoreGive(mA);
}
portMAX_DELAY 를 잠금에 쓰지 않는 습관 — 무한 대기는 데드락을 "조용한 정지"로 만듭니다. 타임아웃을 두고 실패를 기록하면 데드락이 로그 한 줄로 드러납니다. 자바의 tryLock(timeout) 과 같은 생각입니다.

레이스 — 구조체를 반만 읽는다

typedef struct { int16_t temp_x10; uint32_t tick; } Reading;
static Reading s_last;            // SensorTask 가 쓰고 CommTask 가 읽음

/* ❌ 쓰는 도중 선점되면 temp 는 새 값, tick 은 옛 값 */
s_last.temp_x10 = t;
s_last.tick     = now;

/* ✅ ① 아주 짧은 복사는 크리티컬 섹션 */
taskENTER_CRITICAL();
s_last = (Reading){ t, now };
taskEXIT_CRITICAL();

taskENTER_CRITICAL();
Reading copy = s_last;           // 읽는 쪽도 반드시
taskEXIT_CRITICAL();

/* ✅ ② 길이 1 큐 우편함 — xQueueOverwrite / xQueuePeek (r05) */

/* 카운터 하나는 C11 atomics 로 (Cortex-M4 는 LDREX/STREX) */
#include <stdatomic.h>
static atomic_uint s_drops;
atomic_fetch_add_explicit(&s_drops, 1, memory_order_relaxed);
"32비트 대입은 원자적"의 함정 — 정렬된 32비트 하나의 읽기·쓰기는 원자적이지만 count++ 는 읽기 · 더하기 · 쓰기 세 명령이라 아닙니다. 그리고 여러 필드의 일관성은 어떤 단일 명령도 보장하지 않습니다. volatile 은 이 중 아무것도 해결하지 못합니다(C 실전 탭 c19 · c22).

크리티컬 섹션 남용 — 모두의 지연을 늘립니다 🟡 중급

도구막는 것길이 한계안에서 블로킹
taskENTER_CRITICAL()NVIC 5~15 인터럽트 + 전환수십 사이클✕ 절대
vTaskSuspendAll()태스크 전환만 (인터럽트는 OK)짧게✕
뮤텍스같은 뮤텍스를 쓰는 태스크만길어도 됨○ (타임아웃 권장)
메시지 전달 (큐)공유 자체를 없앰——
흔한 남용 — "레이스가 의심되면 일단 크리티컬 섹션"으로 HAL_UART_Transmit 이나 printf 를 감싸는 것. 그동안 UART 수신 인터럽트까지 막혀 바이트를 잃고(ORE), tick 도 밀립니다. 크리티컬 섹션 안에는 변수 몇 개 복사만 둡니다.

서버 개발자의 번역표

Java / 서버FreeRTOS
synchronized 블록뮤텍스 Take/Give
AtomicIntegerstdatomic.h · 짧은 크리티컬 섹션
스레드 덤프 (jstack)디버거의 RTOS 태스크 뷰 · vTaskListTasks
CPU 프로파일러런타임 통계 · 트레이스
APM 분산 트레이스SystemView · Tracealyzer 타임라인
데드락 감지 (JVM)없음 — 타임아웃 · 워치독으로 드러냄
디버거 RTOS 뷰 — STM32CubeIDE · VS Code(Cortex-Debug) · Ozone 은 FreeRTOS 를 인식해 태스크 목록, 상태, 스택 여유를 보여 줍니다. 멈춘 순간 "누가 무엇을 기다리는지"가 스레드 덤프처럼 보입니다.

런타임 통계 · 트레이스 — CPU 를 누가 먹는지 숫자로 🔴 실무

vTaskGetRunTimeStats() — 100 초 동안 누가 CPU 를 썼나 IDLE 62% — 여유 comm 18% — 예상 5%, 폴링 송신 의심 proc 12% sensor 5% Tmr Svc 3% 숫자가 예상과 다른 태스크가 범인 후보입니다 — 막대는 0 에서 시작
런타임 통계 — 설계 때 예상한 점유율과 비교해야 의미가 생깁니다.

켜는 법 — TIM2(32비트)를 시계로

/* FreeRTOSConfig.h */
#define configGENERATE_RUN_TIME_STATS         1
#define configUSE_TRACE_FACILITY              1
#define configUSE_STATS_FORMATTING_FUNCTIONS  1
void     rt_stats_timer_init(void);
uint32_t rt_stats_now(void);
#define portCONFIGURE_TIMER_FOR_RUN_TIME_STATS()  rt_stats_timer_init()
#define portGET_RUN_TIME_COUNTER_VALUE()          rt_stats_now()

/* rt_stats.c — tick(1 kHz)보다 10배 이상 빠른 시계가 필요 */
void rt_stats_timer_init(void) {
    __HAL_RCC_TIM2_CLK_ENABLE();
    TIM2->PSC = 899;            // 타이머 클럭 90 MHz / 900 = 100 kHz (10 µs)
    TIM2->ARR = 0xFFFFFFFFu;    // 32비트 끝까지 — 약 11.9 시간 뒤 되감김
    TIM2->EGR = TIM_EGR_UG;     // PSC 를 즉시 반영
    TIM2->CR1 = TIM_CR1_CEN;
}
uint32_t rt_stats_now(void) { return TIM2->CNT; }

/* 출력 — V11 은 버퍼 길이를 받는 판을 권장 */
static char s_buf[512];
vTaskGetRunTimeStatistics(s_buf, sizeof s_buf);   // V10: vTaskGetRunTimeStats(s_buf)
uart_puts(s_buf);
90 MHz 인 이유 — SYSCLK 180 MHz 에서 APB1 은 ÷4 = 45 MHz 이고, APB 분주가 1 이 아니면 타이머 클럭은 그 2배인 90 MHz 입니다. DWT 사이클 카운터(DWT->CYCCNT)를 써도 되지만 180 MHz 에서 약 23.9초마다 되감겨 긴 측정에 맞지 않습니다.

트레이스 — 시간 축을 눈으로 보기

도구방식
SEGGER SystemViewRTT 로 실시간 스트리밍. J-Link 필요 (Nucleo 의 ST-LINK 는 J-Link OB 펌웨어로 바꿀 수 있음)
Percepio Tracealyzer스트리밍 · 스냅숏(RAM 링 버퍼). 상용, 분석 화면이 풍부
직접 만들기traceTASK_SWITCHED_IN() 매크로에서 GPIO 토글 → 로직 분석기
트레이스에서 보이는 모양뜻
높은 태스크가 길게 Ready 인데 안 돎크리티컬 섹션 · 인터럽트 폭주
H 가 뮤텍스 대기 중 M 이 돎우선순위 역전 (바이너리 세마포어로 잠금?)
두 태스크가 서로 대기 후 정지데드락
Idle 이 거의 0%바쁜 대기 · 과부하
주기 태스크 시작 시각이 흔들림vTaskDelay 로 주기 구현(r04)
GPIO 한 핀의 힘 — 의심 구간 앞뒤에서 핀을 올렸다 내리고 오실로스코프로 보면 µs 단위 지연이 그대로 보입니다. 도구가 없을 때 가장 정확한 프로파일러입니다(🐞 디버깅 · 테스트 탭).
정리 — 잠금은 한 순서 · 타임아웃, 공유는 복사나 큐로, 크리티컬 섹션은 몇 사이클만. 이상하면 런타임 통계 → 트레이스 순으로 시간 축을 봅니다.
RTOS 12

저전력 — tickless idle, 그리고 Stop 모드와 묶을 때

배터리 기기에서 RTOS 의 tick 은 골칫거리입니다. 할 일이 없어도 1 ms 마다 깨어나 tick 을 세기 때문입니다. tickless idle 은 "다음 할 일이 40 ms 뒤라면 40 ms 동안 tick 을 끄고 한 번에 잔다"는 기능입니다. 켜는 건 한 줄이지만, 더 깊은 Stop 모드와 묶으려면 클럭 · 타이머 · 디버거를 같이 다뤄야 합니다.

전류 — 일하는 시간은 같은데 잠자는 모양이 다릅니다 일반 tick — 할 일이 없어도 1 ms 마다 깨어남 (Idle 에서 WFI) tickless — 다음 일(40 ms 뒤)까지 tick 을 끄고 한 번에 잠 태스크 실행 37 ms — 깨어남 0번 태스크 실행 0 20 40 50 ms
깨어날 때마다 클럭이 돌고 플래시를 읽습니다 — 깨어나는 횟수 자체가 전력입니다.

켜기 — 포트 기본 구현 (Sleep 모드 + WFI)

/* FreeRTOSConfig.h */
#define configUSE_TICKLESS_IDLE               1   // 포트의 기본 구현 사용
#define configEXPECTED_IDLE_TIME_BEFORE_SLEEP 2   // 2 tick 이상 한가할 때만 (최소 2)

/* 잠들기 직전 · 깬 직후에 내 코드를 끼워 넣기 */
void app_pre_sleep(TickType_t *expected);
void app_post_sleep(TickType_t *expected);
#define configPRE_SLEEP_PROCESSING(x)   app_pre_sleep(&(x))
#define configPOST_SLEEP_PROCESSING(x)  app_post_sleep(&(x))

/* power.c */
void app_pre_sleep(TickType_t *expected) {
    HAL_GPIO_WritePin(DBG_GPIO_Port, DBG_Pin, GPIO_PIN_RESET); // 스코프로 잠 확인
    if (uart_tx_busy()) {
        *expected = 0;          // ★ 0 으로 바꾸면 이번엔 WFI 를 건너뜀
    }
}
void app_post_sleep(TickType_t *expected) {
    (void)expected;
    HAL_GPIO_WritePin(DBG_GPIO_Port, DBG_Pin, GPIO_PIN_SET);
}
Idle 태스크 안에서 일어나는 일 — ① 다음에 깨어날 태스크까지 몇 tick 인지 계산 ② SysTick 을 멈추고 그 시간만큼으로 재설정 ③ WFI 로 잠 ④ SysTick 이나 다른 인터럽트로 깨어나면 실제로 잔 tick 수만큼 tick 카운트를 한 번에 보정. 태스크 코드는 아무것도 바꿀 필요가 없습니다.

한 번에 몇 ms 까지 잘 수 있나 — 직접 계산

SysTick 은 24비트 카운터 → 최대 0xFFFFFF = 16,777,215 클럭

SysTick 클럭 = 코어 180 MHz 일 때
  1 tick(1 ms) = 180,000 클럭
  최대 연속 수면 = 16,777,215 / 180,000 = 93 tick (≈ 93.2 ms)

SysTick 클럭 = HCLK/8 = 22.5 MHz 로 두면  (configSYSTICK_CLOCK_HZ)
  1 tick = 22,500 클럭
  최대 연속 수면 = 16,777,215 / 22,500 = 745 tick (≈ 0.75 s)

→ 그보다 긴 대기는 중간에 한 번 깨어 다시 잠듭니다 (기능상 문제는 없음)
기상 지연 — tickless 는 "인터럽트 하나에 깨어나 → 클럭 · tick 보정 → 스케줄"을 거치므로 첫 태스크 실행이 일반 모드보다 조금 늦습니다. Sleep 모드(WFI)는 수 µs 수준이라 대개 무시해도 되지만, 아래 Stop 모드는 다릅니다.

STM32F4 저전력 모드 — 무엇이 꺼지고 무엇으로 깨나 🟡 중급

모드꺼지는 것깨우는 것복귀전류 규모
SleepCPU 클럭만모든 인터럽트수 사이클mA
StopPLL · HSI/HSE · 거의 모든 클럭 (SRAM · 레지스터 유지)EXTI 라인 · RTC 알람/기상µs 단위 + 클럭 재설정수십~수백 µA
Standby거의 전부 (SRAM 내용 사라짐)WKUP 핀 · RTC · 리셋리셋부터 다시µA
정확한 전류와 복귀 시간은 STM32F446 데이터시트의 전기적 특성 표(레귤레이터 모드 · 온도별)를 보세요. 자릿수만 기억하면 됩니다 — Sleep 은 mA, Stop 은 µA 수백, Standby 는 µA 몇.

Stop 모드가 tickless 를 어렵게 만드는 이유

문제대응
SysTick 이 멈춤 (코어 클럭 정지)깨울 타이머를 RTC 기상 타이머(WUT)로. F446 에는 LPTIM 이 없습니다
깨면 클럭이 HSI 16 MHzSystemClock_Config() 로 PLL 180 MHz 복구 — 빼먹으면 UART 보레이트가 틀어짐
USART 는 Stop 에서 수신 못 함RX 핀을 EXTI 로 잡아 깨우고, 첫 바이트는 버리는 프로토콜로
디버거 연결이 끊김HAL_DBGMCU_EnableDBGStopMode() (개발 중에만)
잔 시간 계산RTC 시각 차이로 — 다른 인터럽트로 일찍 깼을 수 있음

Stop 모드 tickless — 직접 구현하는 뼈대 🔴 실무

vPortSuppressTicksAndSleep() 를 내가 쓸 때 — 순서가 곧 정답 ① 기상 계산 N tick 뒤 ② 포기 확인 eAbortSleep? ③ RTC 설정 WUT = N tick ④ Stop 진입 WFI ⑤ 클럭 복구 PLL 180 MHz ⑥ tick 보정 vTaskStepTick ⑤ 를 빼먹으면 16 MHz 로 돌아 UART · 타이머 설정이 전부 틀어집니다 복귀에 걸리는 시간만큼 N 을 줄여 잡아야 다음 일을 놓치지 않습니다 ②~⑥ 은 인터럽트를 막은 채(PRIMASK) — 막혀 있어도 WFI 는 깨어납니다
Stop 모드 tickless 는 "자고, 깨고, 클럭을 되살리고, 잔 만큼 시간을 맞추는" 여섯 단계입니다.
/* FreeRTOSConfig.h : configUSE_TICKLESS_IDLE 2  (포트 기본 구현 대신 내 함수)
   #define portSUPPRESS_TICKS_AND_SLEEP(x) vPortSuppressTicksAndSleep(x)
   ※ 뼈대입니다 — RTC 클럭 · 보정 오차 처리는 제품마다 다듬어야 합니다 */
extern RTC_HandleTypeDef hrtc;               // LSE 32.768 kHz
#define WUT_HZ  2048u                        // RTCCLK / 16
uint32_t rtc_now_wut_units(void);            // RTC 시각을 1/2048 s 단위로 (구현 생략)

void vPortSuppressTicksAndSleep(TickType_t xExpectedIdleTime) {
    if (xExpectedIdleTime > 30000u) xExpectedIdleTime = 30000u;  // WUT 16비트 한계 안
    SysTick->CTRL &= ~SysTick_CTRL_ENABLE_Msk;                // ① tick 정지
    __disable_irq();                                          // PRIMASK — WFI 깨움은 유지
    __DSB(); __ISB();

    if (eTaskConfirmSleepModeStatus() == eAbortSleep) {       // ② 그사이 일이 생김
        SysTick->CTRL |= SysTick_CTRL_ENABLE_Msk;
        __enable_irq();
        return;
    }

    uint32_t wut = (xExpectedIdleTime * WUT_HZ) / configTICK_RATE_HZ;   // ③ 40 tick → 81
    if (wut < 2u) { SysTick->CTRL |= SysTick_CTRL_ENABLE_Msk; __enable_irq(); return; }
    uint32_t t0 = rtc_now_wut_units();
    HAL_RTCEx_SetWakeUpTimer_IT(&hrtc, wut - 1u, RTC_WAKEUPCLOCK_RTCCLK_DIV16);

    HAL_PWR_EnterSTOPMode(PWR_LOWPOWERREGULATOR_ON, PWR_STOPENTRY_WFI);  // ④

    SystemClock_Config();                                     // ⑤ HSI → PLL 180 MHz
    HAL_RTCEx_DeactivateWakeUpTimer(&hrtc);
    uint32_t slept = ((rtc_now_wut_units() - t0) * configTICK_RATE_HZ) / WUT_HZ;
    if (slept > xExpectedIdleTime) slept = xExpectedIdleTime;
    vTaskStepTick(slept);                                     // ⑥ tick 보정

    SysTick->VAL = 0;
    SysTick->CTRL |= SysTick_CTRL_ENABLE_Msk;
    __enable_irq();                                           // 깨운 인터럽트가 여기서 처리됨
}

숫자로 보는 함정

WUT 해상도 = 1/2048 s ≈ 0.488 ms  (tick 1 ms 보다 거침)

40 tick 자려고 하면 → 40 × 2048 / 1000 = 81.92 → 81 카운트
실제 수면 = 81 / 2048 = 39.55 ms → 0.45 ms 일찍 깸
→ 잔 시간을 RTC 로 다시 재서 보정하므로 tick 은 틀어지지 않지만
  나머지(0.92 카운트)를 버리는 오차가 매번 누적되지 않게 하려면
  rtc_now_wut_units() 처럼 '절대 시각'으로 계산해야 합니다

WUT 16비트 · RTCCLK/16 → 최대 65,536 / 2,048 = 32 s
Stop 에서 깨는 데 걸리는 시간 — 레귤레이터를 저전력 모드로 두면 복귀가 더 느리고, 거기에 PLL 재잠금 시간이 더해집니다. 데이터시트의 tWUSTOP 값과 실제 측정(GPIO + 스코프)으로 확인하고, 그만큼 N 을 줄여 잡습니다. 1 ms 안에 반응해야 하는 태스크가 있는 동안에는 Stop 대신 Sleep 에 머무는 것이 맞습니다.
더 쉬운 길 — STM32L4 · U5 · WB 처럼 LPTIM 이 있는 저전력 계열은 ST 가 FreeRTOS tickless 예제를 제공합니다. 배터리 수명이 핵심인 제품이라면 칩 선택 단계에서 F4 대신 그쪽을 검토하세요(🔩 하드웨어 기초 탭의 배터리 계산 참고).
정리 — configUSE_TICKLESS_IDLE 1 한 줄로 Sleep 모드 tickless 를 얻습니다. Stop 모드까지 가려면 RTC 기상 · 클럭 복구 · tick 보정을 직접 짜고, 기상 지연을 재서 반영합니다.
RTOS 13

실전 설계 — 센서 → 처리 → 통신 3태스크와 워치독 감시

지금까지의 도구를 한 시스템으로 묶습니다. 핵심 질문은 "태스크를 어떻게 나누나"이고, 답은 주기 · 마감 · 책임(소유한 하드웨어)입니다. 그리고 RTOS 시스템에는 "태스크 하나가 조용히 멈췄다"를 잡는 장치가 반드시 필요합니다 — 워치독 감시 태스크.

데이터는 왼쪽에서 오른쪽으로만, 생존 신호는 아래로만 흐릅니다 I2C DMA 완료 ISR 알림 Sensor (3) 10 ms 주기 q_raw Sample × 8 Proc (2) 평균 · 경보 q_out Report × 4 Comm (1) 문자열 · 송신 UART2 DMA 체크인 비트 0 · 1 · 2 Wdg (4) 1 s 창 셋 다 오면 IWDG (2 s) HAL_IWDG_Refresh 하나라도 멈추면 갱신 안 함 → 2 s 뒤 리셋 태스크끼리 공유 변수가 하나도 없습니다 — 뮤텍스도 필요 없습니다
설계의 뼈대 — 큐로 이어진 단방향 파이프라인 + 모두의 생존을 확인해야만 워치독을 갱신하는 감시자.

태스크를 나누는 기준

기준예이유
주기10 ms 샘플 / 100 ms 보고주기가 다르면 우선순위가 달라야 함(r04)
마감모터 제어 vs 로그하드 · 소프트를 한 태스크에 섞으면 하드가 소프트를 기다림
소유한 하드웨어I2C 버스 전담 · UART 전담게이트키퍼 — 뮤텍스가 사라짐(r06)
블로킹 지점네트워크 응답 대기기다리는 곳마다 태스크 하나면 코드가 순차적으로 읽힘
너무 잘게 나누지 마세요 — 태스크 하나 = 스택 0.5~2KB + 문맥 전환 + 큐 하나. "함수 하나에 태스크 하나"는 RAM 을 금방 바닥냅니다. MCU 펌웨어는 보통 5~10개면 충분합니다. 같은 주기 · 같은 마감 · 같은 하드웨어면 한 태스크로 합칩니다.

이 시스템의 숫자

태스크우선주기스택(워드)근거
Wdg4~1 s192감시자가 굶으면 안 됨
Sensor310 ms256가장 짧은 주기
Proc2샘플마다256정수 연산만
Comm1100 ms512snprintf 사용
IWDG : LSI 32 kHz, 분주 64, 리로드 999
  타임아웃 = 64 × (999 + 1) / 32,000 = 2.0 s
  ※ LSI 는 17~47 kHz 로 흔들림 → 실제 1.36 ~ 3.76 s
  → Wdg 의 확인 창(1 s)은 최악 1.36 s 보다 충분히 짧게

전체 코드 뼈대 — app.c 하나로 🟡 중급

/* app.c — FreeRTOS V11 · STM32F446RE · CubeMX 가 hi2c1 · huart2 · hiwdg 를 생성했다고 가정 */
#include "main.h"
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "event_groups.h"
#include <stdio.h>
#include <stdlib.h>

extern I2C_HandleTypeDef  hi2c1;           /* CubeMX 가 main.c 에 정의 — main.h 는 선언해 주지 않음 */
extern UART_HandleTypeDef huart2;
extern IWDG_HandleTypeDef hiwdg;

#define TMP_ADDR  0x48u                    /* 예: 온도 센서 7비트 주소 */
#define TMP_REG   0x00u

typedef struct { uint32_t tick; int16_t temp_x10; } Sample;                 /* 8 B */
typedef struct { uint32_t tick; int16_t avg_x10; uint8_t alarm; } Report;  /* 8 B */

enum { PRIO_COMM = 1, PRIO_PROC = 2, PRIO_SENSOR = 3, PRIO_WDG = 4 };
#define CHK_SENSOR (1u << 0)
#define CHK_PROC   (1u << 1)
#define CHK_COMM   (1u << 2)
#define CHK_ALL    (CHK_SENSOR | CHK_PROC | CHK_COMM)

static QueueHandle_t      q_raw, q_out;
static EventGroupHandle_t eg_alive;
static TaskHandle_t       h_sensor, h_comm;

int16_t tmp_to_x10(const uint8_t raw[2]);  /* 센서 데이터시트 변환식 (생략) */
void    log_missing(EventBits_t got);      /* 빠진 체크인 기록 (생략) */

static void must(BaseType_t r) { configASSERT(r == pdPASS); (void)r; }  /* assert 가 꺼져도 호출은 남음 */

/* ── Sensor: 10 ms 주기 · I2C 는 DMA, 완료는 알림으로 ── */
static void SensorTask(void *arg) {
    (void)arg;
    static uint8_t raw[2];                 /* DMA 가 쓰는 버퍼 — 스택 말고 static */
    TickType_t last = xTaskGetTickCount();
    for (;;) {
        xTaskDelayUntil(&last, pdMS_TO_TICKS(10));
        (void)ulTaskNotifyTake(pdTRUE, 0); /* 지난번 늦게 온 알림 비우기 */
        if (HAL_I2C_Mem_Read_DMA(&hi2c1, TMP_ADDR << 1, TMP_REG,
                                 I2C_MEMADD_SIZE_8BIT, raw, 2) == HAL_OK &&
            ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(5)) != 0) {
            Sample s = { .tick = last, .temp_x10 = tmp_to_x10(raw) };
            (void)xQueueSend(q_raw, &s, 0); /* 가득 차면 버림 — 센서는 기다리지 않음 */
        }
        xEventGroupSetBits(eg_alive, CHK_SENSOR);
    }
}

void HAL_I2C_MemRxCpltCallback(I2C_HandleTypeDef *hi2c) {   /* DMA IRQ — NVIC 5 이상 */
    if (hi2c == &hi2c1) {
        BaseType_t woken = pdFALSE;
        vTaskNotifyGiveFromISR(h_sensor, &woken);
        portYIELD_FROM_ISR(woken);
    }
}

/* ── Proc: 10 개 평균 · 60.0 ℃ 초과면 경보 ── */
static void ProcTask(void *arg) {
    (void)arg;
    int32_t acc = 0;
    int n = 0;
    for (;;) {
        Sample s;
        if (xQueueReceive(q_raw, &s, pdMS_TO_TICKS(100)) == pdPASS) {
            acc += s.temp_x10;
            if (++n == 10) {
                int32_t avg = acc / 10;
                Report r = { .tick = s.tick, .avg_x10 = (int16_t)avg,
                             .alarm = (uint8_t)(avg > 600) };
                (void)xQueueSend(q_out, &r, 0);
                acc = 0;
                n = 0;
            }
        }
        xEventGroupSetBits(eg_alive, CHK_PROC);   /* 데이터가 끊겨도 100 ms 마다 체크인 */
    }
}

/* ── Comm: 문자열로 만들어 UART DMA 송신, 완료 알림까지 버퍼 보호 ── */
static void CommTask(void *arg) {
    (void)arg;
    static char line[48];                  /* DMA 가 읽는 동안 살아 있어야 함 */
    for (;;) {
        Report r;
        if (xQueueReceive(q_out, &r, pdMS_TO_TICKS(500)) == pdPASS) {
            int m = abs(r.avg_x10);
            int len = snprintf(line, sizeof line, "T=%lu AVG=%s%d.%d%s\r\n",
                               (unsigned long)r.tick, r.avg_x10 < 0 ? "-" : "",
                               m / 10, m % 10, r.alarm ? " ALARM" : "");
            if (len > 0 && len < (int)sizeof line &&
                HAL_UART_Transmit_DMA(&huart2, (uint8_t *)line, (uint16_t)len) == HAL_OK) {
                (void)ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(50));
            }
        }
        xEventGroupSetBits(eg_alive, CHK_COMM);
    }
}

void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) {
    if (huart == &huart2) {
        BaseType_t woken = pdFALSE;
        vTaskNotifyGiveFromISR(h_comm, &woken);
        portYIELD_FROM_ISR(woken);
    }
}

/* ── Wdg: 1 s 안에 셋 다 체크인했을 때만 IWDG 갱신 ── */
static void WdgTask(void *arg) {
    (void)arg;
    for (;;) {
        EventBits_t b = xEventGroupWaitBits(eg_alive, CHK_ALL,
                                            pdTRUE,    /* 셋 다 왔으면 지움 → 다음 창에서 다시 받아야 */
                                            pdTRUE,    /* AND */
                                            pdMS_TO_TICKS(1000));
        if ((b & CHK_ALL) == CHK_ALL) {
            HAL_IWDG_Refresh(&hiwdg);
        } else {
            log_missing(b);                /* 갱신하지 않음 → 계속 빠지면 IWDG 가 리셋 */
        }
    }
}

void app_start(void) {                     /* main() 에서 MX_*_Init() 다음에 호출 */
    q_raw    = xQueueCreate(8, sizeof(Sample));
    q_out    = xQueueCreate(4, sizeof(Report));
    eg_alive = xEventGroupCreate();
    configASSERT(q_raw && q_out && eg_alive);

    must(xTaskCreate(WdgTask,    "wdg",    192, NULL, PRIO_WDG,    NULL));
    must(xTaskCreate(SensorTask, "sensor", 256, NULL, PRIO_SENSOR, &h_sensor));
    must(xTaskCreate(ProcTask,   "proc",   256, NULL, PRIO_PROC,   NULL));
    must(xTaskCreate(CommTask,   "comm",   512, NULL, PRIO_COMM,   &h_comm));

    __HAL_DBGMCU_FREEZE_IWDG();            /* 디버거로 멈춰 있는 동안 리셋 방지 (개발용) */
    vTaskStartScheduler();
}
읽는 법 — 태스크는 서로를 모르고 큐와 이벤트 그룹만 압니다. 모든 대기에 타임아웃이 있어서, 무언가 끊겨도 각 태스크는 계속 돌며 체크인합니다. 그래서 Wdg 가 리셋을 거는 경우는 "어딘가가 정말로 멈췄다"(데드락 · 무한 루프 · HardFault)뿐입니다. Proc 가 데이터가 안 와도 체크인하는 것은 의도입니다 — 센서 고장은 워치독이 아니라 경보로 다룰 일입니다.

액티브 오브젝트 · 대안 비교 — 규모가 커질 때 🔴 실무

액티브 오브젝트 — 태스크 + 수신함 + 상태 머신

typedef struct ActiveObject ActiveObject;
struct ActiveObject {
    TaskHandle_t  task;
    QueueHandle_t queue;                                  /* 이 객체만의 수신함 */
    void (*dispatch)(ActiveObject *me, const Event *e);   /* 상태 머신 */
};

static void ao_thread(void *arg) {
    ActiveObject *me = arg;
    Event e;
    for (;;) {
        xQueueReceive(me->queue, &e, portMAX_DELAY);       /* 기다리는 곳은 여기 하나 */
        me->dispatch(me, &e);                             /* 끝까지 실행 — 안에서 블로킹 금지 */
    }
}

void ao_start(ActiveObject *me, const char *name, UBaseType_t prio,
              UBaseType_t qlen, configSTACK_DEPTH_TYPE stackWords) {
    me->queue = xQueueCreate(qlen, sizeof(Event));
    configASSERT(me->queue);
    must(xTaskCreate(ao_thread, name, stackWords, me, prio, &me->task));
}

BaseType_t ao_post(ActiveObject *me, const Event *e) {
    return xQueueSend(me->queue, e, 0);                   /* 보내는 쪽도 기다리지 않음 */
}
BaseType_t ao_post_from_isr(ActiveObject *me, const Event *e, BaseType_t *woken) {
    return xQueueSendFromISR(me->queue, e, woken);
}
서버 개발에 빗대면 — Akka 액터 · Go 의 "채널로 통신하라"와 같은 생각입니다. 객체마다 스레드 하나와 메일박스 하나, 공유 상태 없음, 메시지를 한 번에 하나씩 끝까지 처리. 뮤텍스 · 역전 · 데드락이 설계에서 사라집니다. Quantum Leaps 의 QP/C 프레임워크가 이 패턴을 완성형으로 제공합니다. Event 는 r05 의 태그 붙은 구조체입니다.

FreeRTOS 말고 무엇이 있나 (2026)

성격강점약점
FreeRTOS커널 라이브러리 · MIT작고 단순, 어디나 있음드라이버 · 네트워크는 따로 조립
ZephyrOS 플랫폼 · Apache 2.0드라이버 · BLE · 네트워크 · MCUboot 통합, devicetree · Kconfig학습 곡선 · 빌드 체계가 무거움
Eclipse ThreadX커널 + 미들웨어 · MIT안전 인증 이력, FileX · NetX Duo · USBX, ST 공식 지원커뮤니티가 상대적으로 작음
RT-Thread커널 + 컴포넌트 · Apache 2.0패키지 생태계, 중국 MCU 지원 폭영문 자료가 적은 편
베어메탈ISR + 슈퍼루프RAM 최소 · 완전한 예측성기능이 늘면 복잡도 폭발(r01)
고르는 법 — 커널만 필요하고 팀이 C 에 익숙하면 FreeRTOS. BLE · 네트워크 · 보안 부팅 · 여러 보드 지원이 한꺼번에 필요하면 Zephyr. 안전 인증 경로가 필요하면 SAFERTOS 나 ThreadX 계열. 어느 쪽이든 태스크 · 큐 · 우선순위 · ISR 규칙은 이 탭에서 배운 그대로입니다. 이 구조를 큰 코드베이스로 키우는 방법은 🏗️ 대규모 C 구조 탭에서 이어집니다.
정리 — 태스크는 주기 · 마감 · 소유 하드웨어로 나누고, 큐로 단방향으로 잇고, 모든 대기에 타임아웃을 두고, 모두의 체크인을 확인한 감시자만 워치독을 갱신합니다.
전기 · 핀 · 레지스터 · 비트 · 클럭 · 인터럽트 · 타이머 · ADC · 부팅 · 링커 — ATmega328P 와 STM32F446RE 를 나란히

주소에 값을 쓰면
LED 가 켜지는
세계로

서버 개발자에게 "메모리에 쓴다"는 변수를 바꾸는 일이었습니다. 마이크로컨트롤러에서는 정해진 주소에 숫자를 쓰는 순간 핀의 전압이 바뀌고, 모터가 돌고, 전파가 나갑니다. 이 탭은 그 사이에 있는 바닥 — 옴의 법칙 한 줄, 핀 하나의 전기적 성질, 레지스터와 비트, 클럭과 인터럽트, 그리고 main 보다 먼저 도는 코드 — 를 하드웨어 경험이 0 인 사람 기준으로 처음부터 쌓습니다. 예시는 모두 ATmega328P(Arduino Uno R3, 16 MHz) 와 STM32F446RE(Nucleo-F446RE) 두 칩으로 나란히 보여 드려 "칩이 바뀌어도 변하지 않는 것"이 보이게 했습니다. 이 탭을 마치면 HAL 없이 레지스터만으로 LED 를 깜빡이고 버튼을 읽는 펌웨어를 처음부터 끝까지 설명할 수 있습니다.

13SECTIONS
2칩 (AVR · Cortex-M4)
0하드웨어 사전 지식
20그림
HW 01

전기 최소한 — 전압 · 전류 · 저항과 옴의 법칙

펌웨어 버그의 상당수는 코드가 아니라 전기에서 납니다. LED 가 안 켜지는 이유, 핀이 타 버리는 이유, 3.3 V 칩에 5 V 센서를 꽂으면 안 되는 이유 — 전부 이 섹션의 공식 세 개로 설명됩니다. 회로 이론을 배우자는 게 아니라 "이 핀에 무엇을 연결해도 되는가"를 스스로 판단할 수 있는 만큼만 다룹니다.

핀 → 저항 → LED → GND — 전압은 고리를 한 바퀴 돌며 전부 쓰입니다 MCU 핀 HIGH = 5 V R = 330 Ω 여기서 3 V 소모 적색 LED Vf ≈ 2 V I → GND (0 V) 옴의 법칙 V = I × R R = (Vcc − Vf) ÷ I = (5 − 2) ÷ 0.010 A = 300 Ω → 표준값 330 Ω 으로 올림 → 실제 I = 3 ÷ 330 ≈ 9.1 mA 저항을 빼면 LED 는 전류를 무한정 끌어갑니다 — 핀과 LED 가 함께 상합니다
LED 저항 계산은 (전원 − LED 전압) ÷ 원하는 전류 한 줄입니다 — 계산값보다 큰 표준값을 고릅니다.

세 단어를 물에 빗대면

양단위물 비유펌웨어에서 만나는 곳
전압 V볼트 V수압 — 밀어내는 힘HIGH = 3.3 V 또는 5 V, ADC 입력
전류 I암페어 A흐르는 물의 양핀당 한계(수십 mA), 배터리 수명
저항 R옴 Ω파이프의 좁기LED 저항, 풀업, 전압 분배
전력 P와트 W물레방아가 하는 일발열, 저항의 정격(1/4 W)
V = I × R          옴의 법칙 — 셋 중 둘을 알면 나머지 하나가 나옵니다
P = V × I          전력 — 같은 식을 바꿔 쓰면  P = I² × R = V² ÷ R

단위 감각 (이 세 개만 외우면 됩니다)
  1 mA  = 0.001 A        LED 하나 · 핀 하나는 mA 단위
  1 kΩ  = 1,000 Ω        풀업 저항은 kΩ 단위 (4.7k, 10k)
  mV ÷ mA = Ω    ·    V ÷ mA = kΩ    ← 계산기 없이 암산할 때
서버 개발에 빗대면 — 전압은 요청을 보내는 압력, 전류는 초당 처리량, 저항은 레이트 리미터입니다. 리미터(저항) 없이 LED 를 핀에 바로 물리면, 처리량(전류)이 폭주해 서버(핀)와 클라이언트(LED)가 같이 죽습니다.

LED 저항 계산 — PC 에서 돌려 보는 정수 계산기

#include <stdio.h>
#include <stdint.h>

/* E12 표준 저항값의 가수 — 실제로 살 수 있는 값은 이것 × 10ⁿ 뿐 */
static const uint8_t E12[12] = {10,12,15,18,22,27,33,39,47,56,68,82};

/* ohm 이상인 가장 작은 표준값 — 전류가 목표를 '넘지 않게' 위로 올림 */
static uint32_t e12_ceil(uint32_t ohm) {
    for (uint32_t dec = 1; dec <= 100000u; dec *= 10u)
        for (int i = 0; i < 12; i++)
            if ((uint32_t)E12[i] * dec >= ohm) return (uint32_t)E12[i] * dec;
    return 0;                                   /* 10 MΩ 초과 — 범위 밖 */
}

/* mV 와 µA 로 받아 정수로만 계산: mV ÷ µA = kΩ 이므로 ×1000 */
static uint32_t led_r(uint32_t vcc_mv, uint32_t vf_mv, uint32_t i_ua) {
    if (vcc_mv <= vf_mv || i_ua == 0) return 0; /* 전원이 Vf 보다 낮으면 안 켜짐 */
    return (vcc_mv - vf_mv) * 1000u / i_ua;
}

int main(void) {
    uint32_t r  = led_r(5000, 2000, 10000);     /* 5 V · 적색 · 10 mA */
    uint32_t rs = e12_ceil(r);
    printf("계산 %lu Ω → 표준 %lu Ω, 실제 %lu µA\n",
           (unsigned long)r, (unsigned long)rs,
           (unsigned long)((5000u - 2000u) * 1000u / rs));
    return 0;   /* 출력: 계산 300 Ω → 표준 330 Ω, 실제 9090 µA */
}
표준값은 반드시 "위로" — 300 Ω 이 안 팔린다고 270 Ω 을 쓰면 전류가 11.1 mA 로 목표를 넘습니다. 330 Ω 이면 9.1 mA 로 살짝 어두워질 뿐 아무도 다치지 않습니다. 밝기 차이는 눈으로 거의 구분되지 않습니다.

한 단계 더 — 3.3 V 와 5 V, 그리고 LED 색 🟡 중급

전원 전압이 어디로 가는가 — 가로 1 V = 100 px 5 V · 적색 저항 3.0 V LED 2.0 V 3.3 V · 적색 저항 1.3 V LED 2.0 V 130 Ω → 150 Ω · 8.7 mA 3.3 V · 청색 0.3 LED 3.0 V 저항 몫이 0.3 V 뿐 — 위험 저항에 걸리는 전압 LED 순방향 전압 Vf 저항 몫이 작을수록 Vf 가 0.1 V 만 달라도 전류가 크게 출렁입니다
저항에 남는 전압이 전류를 안정시키는 여유입니다 — 청색·백색 LED 를 3.3 V 로 바로 켜는 회로가 불안정한 이유.

두 칩의 전원 계열

ATmega328P (Uno)STM32F446RE (Nucleo)
동작 전압1.8~5.5 V (Uno 는 5 V)1.7~3.6 V (Nucleo 는 3.3 V)
HIGH 출력≈ 5 V≈ 3.3 V
HIGH 로 인정되는 입력0.6 × Vcc = 3.0 V 이상데이터시트 I/O 특성 표 기준
LOW 로 인정되는 입력0.3 × Vcc = 1.5 V 이하데이터시트 I/O 특성 표 기준
5 V 를 입력에 넣으면정상FT 표시 핀만 견딤 (HW 02)
3.3 V 칩 출력 → 5 V AVR 입력은 3.3 > 3.0 이라 대개 동작하지만 여유가 0.3 V 뿐입니다. 반대 방향(5 V 출력 → 3.3 V 입력)은 FT 핀이 아니면 칩이 상할 수 있습니다. 그래서 "전압 계열이 다른 두 칩을 잇는다" = 레벨을 먼저 확인한다, 입니다.

전력 — 저항이 타는지, 배터리가 얼마나 가는지

저항이 먹는 전력  P = I² × R
  330 Ω, 9.1 mA  →  0.0091² × 330 ≈ 0.027 W = 27 mW
  흔한 저항 정격 1/4 W = 250 mW  →  9배 여유, 문제 없음

LED 가 먹는 전력  P = Vf × I = 2 × 0.0091 ≈ 18 mW

보드 전체 대략 (5 V 로 50 mA 를 쓴다면)
  P = 5 × 0.05 = 0.25 W

배터리 수명 감 잡기 (조정기 손실은 무시한 거친 추정)
  2000 mAh ÷ 50 mA = 40 시간
  2000 mAh ÷ 0.05 mA(슬립 50 µA) = 40,000 시간 ≈ 4.5 년
  → 펌웨어가 "얼마나 자주 잠드는가"가 수명을 1000배 바꿉니다
펌웨어 개발자에게 전력이 중요한 이유 — 배터리 제품에서 전류의 대부분은 CPU 가 깨어 있는 시간에 씁니다. 인터럽트(HW 08)로 깨어나 일하고 다시 자는 구조가 곧 배터리 수명입니다. 저전력 설계는 🚀 양산 탭에서 계산까지 다룹니다.

실무에선 이렇게 — 계측기 없이 추측하지 않기 🔴 실무

멀티미터로 확인하는 순서

① 전원부터 — 보드의 3V3 · 5V 핀과 GND 사이 전압 (DC V 모드)
     3.3 V 보드가 2.9 V 면 USB 케이블·전원이 약한 것 — 코드 볼 필요 없음

② 핀 출력 — 코드로 HIGH 를 쓴 핀과 GND 사이
     3.3 V(또는 5 V) 근처가 아니면: 클럭 게이트 · 모드 설정 · 핀 번호 의심

③ 전류 — 측정할 선을 끊고 미터를 '직렬로' (A 모드, 단자 옮겨 꽂기 ★)
     전압 모드처럼 병렬로 대면 미터 퓨즈가 나갑니다

④ 저항 — 반드시 전원을 끈 회로에서 (Ω 모드)
"코드는 맞는데 LED 가 안 켜진다"의 절반은 LED 를 거꾸로 꽂은 것입니다. LED 는 긴 다리(애노드)가 + 쪽입니다. 나머지 절반은 브레드보드 줄을 한 칸 잘못 꽂은 것. 코드를 의심하기 전에 ②번 측정 하나면 갈립니다.

자주 쓰는 값 — 외워 두면 회로도가 읽힙니다

부품흔한 값왜 그 값인가
LED 저항220~1 kΩ2~15 mA. 지시등은 1~2 mA 로도 충분히 보임
풀업 / 풀다운4.7k · 10k소비 전류와 노이즈 내성의 타협 (HW 02)
I²C 풀업2.2k~4.7k속도가 빠를수록 작게 (📡 통신 탭)
디커플링 콘덴서100 nF칩 전원 핀마다 하나 — 순간 전류 공급
리셋 풀업10k리셋 핀을 평소 HIGH 로
콘덴서(커패시터)는 아주 작은 순간 배터리입니다. 칩이 클럭마다 전류를 확 당길 때 전원선 대신 먼저 대 줍니다. 이게 빠진 보드는 랜덤 리셋처럼 "소프트웨어 버그 같은 하드웨어 버그"를 냅니다.
정리 — V = I × R 하나로 LED 저항을 정하고((Vcc − Vf) ÷ I, 표준값은 위로), P = V × I 로 발열과 배터리를 가늠합니다. 그리고 칩마다 3.3 V 계열인지 5 V 계열인지부터 확인하세요 — 연결해도 되는지가 거기서 갈립니다.
HW 02

디지털 입출력의 전기 — 플로팅 · 풀업 · 오픈드레인 · 바운스

if (pin == HIGH) 한 줄 뒤에는 생각보다 많은 전기가 있습니다. 아무것도 연결 안 한 입력은 0 도 1 도 아닌 값을 읽고, 버튼은 한 번 눌러도 수십 번 눌린 것처럼 튀며, 출력 핀은 정해진 전류 이상을 내면 망가집니다. 코드가 기대하는 깔끔한 0 과 1 을 만드는 법을 봅니다.

버튼 한 번의 실제 모습 — 접점은 붙는 순간 튑니다 핀 전압 HIGH LOW 바운스 수 ms 하강 엣지 엣지 4번 → 버튼을 4번 누른 것으로 착각 디바운스 후 20 ms 안정 눌림 1번으로 확정 시간 스위치는 기계입니다 — 소프트웨어가 흔들림이 멎을 때까지 기다려 줘야 합니다
바운스는 버그가 아니라 물리입니다 — 엣지를 세지 말고 일정 시간 안정된 값을 세세요(코드는 HW 13).

플로팅 입력과 풀업 — 두 칩에서 같은 일을 하는 코드

/* 아무것도 안 꽂은 입력 핀 = '플로팅(떠 있음)'
   주변 전기 잡음·손가락만 대도 0 과 1 을 오락가락합니다.
   → 저항으로 한쪽에 '살짝' 묶어 둡니다 = 풀업(HIGH 쪽) / 풀다운(LOW 쪽) */

/* ── ATmega328P : Uno D2 = PD2, 버튼은 PD2 ↔ GND ── */
#include <avr/io.h>
DDRD  &= ~(1 << DDD2);     /* 방향 = 입력 */
PORTD |=  (1 << PORTD2);   /* 입력일 때 PORT 에 1 = 내부 풀업 켬 (20~50 kΩ) */
uint8_t pressed = !(PIND & (1 << PIND2));   /* 눌리면 GND 에 붙어 0 */

/* ── STM32F446RE : PB4 를 같은 용도로 (CMSIS 헤더) ── */
#include "stm32f446xx.h"
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOBEN;                 /* 클럭 먼저 (HW 07) */
GPIOB->MODER &= ~(3u << (4 * 2));                    /* 00 = 입력 */
GPIOB->PUPDR  = (GPIOB->PUPDR & ~(3u << (4 * 2)))
              | (1u << (4 * 2));                     /* 01 = 풀업 (약 40 kΩ) */
uint32_t pressed2 = !(GPIOB->IDR & (1u << 4));
버튼은 왜 GND 쪽에 다나 — 풀업 + "누르면 GND" 구성이 업계 기본입니다. 선이 끊기거나 버튼이 빠져도 안전한 HIGH(안 눌림)로 읽히고, 모든 칩이 내부 풀업은 갖고 있지만 내부 풀다운은 없는 경우(AVR)가 많기 때문입니다. 대신 눌림 = 0 이라 논리가 뒤집힙니다(active-low).

출력의 두 방식 — 푸시풀 vs 오픈드레인

푸시풀 (기본)오픈드레인
HIGH 일 때핀이 직접 Vcc 로 끌어올림핀은 손을 놓음 — 외부 풀업이 올림
LOW 일 때GND 로 끌어내림GND 로 끌어내림
두 핀을 이으면한쪽 HIGH·한쪽 LOW 면 단락누가 당겨도 안전 (와이어드 AND)
쓰는 곳LED, 일반 신호, SPII²C, 공유 인터럽트선, 다른 전압 계열
STM32OTYPER 비트 = 0OTYPER 비트 = 1
AVR기본모드 없음 — DDR 토글로 흉내
/* AVR 에서 오픈드레인 흉내 — PORTB0 는 0 으로 고정해 두고 방향만 바꿈 */
PORTB &= ~(1 << PORTB0);
static inline void line_release(void) { DDRB &= ~(1 << DDB0); } /* 입력 = 놓기 */
static inline void line_low(void)     { DDRB |=  (1 << DDB0); } /* 출력 0 = 당기기 */

/* STM32 는 하드웨어가 지원 */
GPIOB->OTYPER |= (1u << 7);   /* PB7 오픈드레인 (I²C1 SDA 로 쓸 때) */

이것도 알아야 한다 — 핀이 버티는 전류와 전압 🟡 중급

핀 하나가 감당하는 전류 — 축 끝 50 mA 0 10 20 30 40 50 mA LED 1개 (330 Ω) 9.1 mA AVR 실무 권장 20 mA STM32 절대 최대 ±25 mA AVR 절대 최대 40 mA 모터 · 릴레이 코일 100 mA 이상 — 핀으로 직접 불가 절대 최대는 "여기까지는 즉사하지 않는다"이지 "써도 된다"가 아닙니다
핀은 신호를 내는 곳이지 전력을 대는 곳이 아닙니다 — 수십 mA 를 넘으면 트랜지스터·드라이버 IC 를 거칩니다.

데이터시트 숫자 읽는 법 — Absolute Maximum Ratings

항목ATmega328PSTM32F446
핀 하나 (절대 최대)40 mA±25 mA
칩 전체 합계Vcc·GND 핀 200 mAΣIIO ±120 mA
출력 전압 보장 조건20 mA 에서 VOH ≥ 4.2 V8 mA 에서 VOH ≥ VDD − 0.4 V
내부 풀업20~50 kΩ약 30~50 kΩ
"절대 최대"는 설계 목표가 아닙니다 — 그 값에서 칩이 즉시 죽지 않는다는 뜻일 뿐, 수명과 출력 전압은 보장되지 않습니다. 실무 기준은 "전기적 특성(Electrical Characteristics)" 표의 시험 조건(위 20 mA·8 mA)입니다. 그보다 많이 끌면 HIGH 가 3.3 V 가 아니라 2.6 V 로 처집니다.

5 V 톨러런트(FT)와 레벨 시프트

STM32 데이터시트의 핀 표(Pinouts and pin description)에 'I/O 구조' 열이 있습니다
  FT   5 V 를 견디는 핀 (5V tolerant)        ← 대부분의 GPIO
  TT   3.3 V 까지만 (아날로그 겸용 등)       ← 예: PA4 · PA5 는 DAC 겸용이라 TTa
  주의: FT 라도 '아날로그 모드'로 설정하면 5 V 를 견디지 못합니다
        FT 는 '입력으로 받을 수 있다'일 뿐, 5 V 를 '출력'하지는 못합니다

레벨이 다른 두 칩을 잇는 방법
  ① 3.3 V → 5 V 입력        AVR 은 3.0 V 이상을 HIGH 로 봄 → 직결해도 대개 동작
  ② 5 V → 3.3 V (FT 아님)    저항 분배 (예 1.8k / 3.3k) — 단방향·저속만
  ③ 양방향 (I²C 등)          MOSFET 레벨 시프터 (BSS138 모듈)
  ④ 빠른 단방향 다채널       74LVC 계열 버퍼 / 전용 레벨 변환 IC
Nucleo 의 LD2 가 PA5 인 이유를 생각해 보면 — PA5 는 5 V 를 못 견디는 핀이지만 LED 출력에는 상관없습니다. 입력으로 5 V 를 받을 핀은 반드시 표에서 FT 를 확인하세요. "옆 핀은 됐는데 이 핀만 죽었다"의 원인이 이것입니다.

실무에선 이렇게 — 바운스를 코드로 다루는 두 갈래 🔴 실무

❌ 엣지 인터럽트에서 바로 세기
volatile uint8_t g_count;

ISR(INT0_vect) {          /* 하강 엣지마다 호출 */
    g_count++;            /* 버튼 한 번에 1~10 씩 오릅니다 */
}

/* "잠금 시간"으로 막으면 조금 낫지만 */
ISR(INT0_vect) {
    uint32_t now = millis_isr_safe();
    if (now - g_last < 50) return;   /* 50 ms 안의 엣지는 무시 */
    g_last = now;
    g_count++;
}
/* 문제: 손을 '뗄 때'의 바운스도 하강 엣지를 만들 수 있고,
   노이즈 한 번에도 반응합니다 — 확정이 아니라 '첫 엣지'를 믿는 방식 */
✅ 주기적 샘플링 + 연속 N 회 일치
/* 1 ms 마다 호출 (타이머 ISR 이나 메인 루프의 1 ms 틱에서) */
#include <stdbool.h>
#include <stdint.h>

bool button_pressed_event(bool raw_pressed) {
    static bool    stable = false;   /* 확정된 상태 */
    static uint8_t cnt    = 0;

    if (raw_pressed == stable) { cnt = 0; return false; }
    if (++cnt < 20) return false;    /* 20 ms 연속으로 달라야 인정 */
    stable = raw_pressed;
    cnt = 0;
    return stable;                   /* '안 눌림 → 눌림' 순간만 true */
}
왜 이게 표준인가 — 노이즈 한 번은 카운터를 0 으로 되돌릴 뿐이고, 누를 때와 뗄 때를 똑같이 처리하며, 인터럽트가 필요 없습니다. 하드웨어로 막으려면 버튼에 RC 필터(10 kΩ + 100 nF ≈ 1 ms) 를 달고 슈미트 트리거 입력을 씁니다 — 둘 다 하는 제품도 많습니다.
정리 — 입력은 풀업으로 묶어 두고(누르면 0), 출력은 신호용(수 mA)으로만 쓰며, 5 V 를 받을 3.3 V 칩 핀은 FT 인지 확인합니다. 버튼은 반드시 튀므로 "N ms 동안 같은 값"일 때만 믿습니다.
HW 03

MCU 안을 들여다보기 — 코어 · 메모리 · 버스 · 주변장치

마이크로컨트롤러(MCU)는 CPU · 메모리 · 입출력 장치를 칩 하나에 몰아넣은 작은 컴퓨터입니다. PC 와 같은 부품 목록인데 크기가 1/100,000 이고, 운영체제가 대신 해 주던 일이 전부 빠져 있습니다. 그 빈자리가 곧 펌웨어 개발자의 일입니다.

STM32F446 한 칩 안 — 코어는 버스를 통해서만 나머지를 만납니다 CPU 코어 Cortex-M4F · 180 MHz 버스 매트릭스 누가 어디로 가나 Flash 512 KB 코드 · 상수 · 0x0800 0000 · 전원 꺼져도 남음 SRAM 128 KB 변수 · 스택 · 0x2000 0000 · 전원 끄면 사라짐 AHB1 GPIOA~H · RCC(클럭) · DMA — 빠른 버스 APB1 · 최대 45 MHz TIM2~7 · USART2 · I2C1~3 · SPI2/3 APB2 · 최대 90 MHz TIM1·8 · USART1·6 · ADC1~3 · SPI1 · SYSCFG RCC (클럭) 모든 블록에 박자 공급 주변장치 = 주소가 붙은 레지스터 ATmega328P 도 같은 모양 — 8비트 AVR 코어 · Flash 32 KB · SRAM 2 KB · 16 MHz
MCU 는 코어 + 메모리 + 주변장치이고, 코어 입장에서 주변장치는 "특별한 주소의 메모리"일 뿐입니다 — 이것이 HW 04 의 출발점.

두 칩의 명세 — 같은 항목, 다른 크기

ATmega328PSTM32F446RE
코어AVR 8비트Arm Cortex-M4 32비트 + FPU
최대 클럭20 MHz (Uno 는 16)180 MHz
Flash (코드)32 KB (부트로더 0.5 KB)512 KB
SRAM (변수)2 KB128 KB
EEPROM1 KB없음 (Flash 로 흉내)
int 크기16비트 ★32비트
메모리 구조하버드 (주소 공간이 둘)주소 공간 하나 + 버스 분리
인터럽트 우선순위고정 (벡터 번호 순)NVIC 16단계 조절
AVR 에서 int 는 16비트입니다 — int x = 40000; 은 넘치고, 1 << 15 는 음수가 됩니다. PC·STM32 에서 맞던 코드가 AVR 에서 틀리는 1순위 원인이라, 이 가이드는 항상 uint8_t·uint16_t·uint32_t 로 씁니다(C 기초 탭 c02 참고).

내 변수는 어느 메모리에 사나

#include <stdint.h>

uint32_t boot_count;               /* .bss  → SRAM, 시작할 때 0 으로 채워짐 */
uint32_t baud = 115200;            /* .data → SRAM, 초기값 사본은 Flash 에 */
const uint8_t crc_table[256] = {0};/* .rodata → Flash (STM32)  ★ AVR 은 다름 */

void handler(void) {
    uint8_t frame[64];             /* 스택 → SRAM, 함수가 끝나면 사라짐 */
    static uint16_t seen;          /* .bss → 함수 밖에서도 유지 */
    (void)frame; seen++;
}

/* SRAM 2 KB 인 AVR 에서 frame[64] 를 재귀로 30번 부르면?
   64 × 30 = 1,920 바이트 — 스택이 전역 변수를 덮어씁니다.
   에러 메시지 없이 '가끔 값이 이상해지는' 버그가 됩니다 (HW 12). */
Flash 는 읽기 전용이 아니라 "쓰기가 느리고 횟수 제한이 있는" 메모리입니다. 페이지/섹터 단위로 지우고 다시 써야 하고 수명은 1만~10만 회 정도라, 설정값 저장은 🚀 양산 탭에서 따로 다룹니다.

PC 와 무엇이 다른가 — 없는 것들의 목록 🟡 중급

진할수록 "하드웨어·OS 가 해 줌", 옅을수록 "내가 직접 함" PC (x86-64) STM32F446 ATmega328P 운영체제 Linux · Windows 없음 (RTOS 는 선택) 없음 MMU · 가상 메모리 있음 없음 — 주소 = 실제 주소 없음 메모리 보호 프로세스마다 격리 MPU 8영역 (켜야 동작) 없음 부동소수 연산 double 까지 하드웨어 float 만 하드웨어 소프트웨어 — 느림 캐시 L1 · L2 · L3 ART (Flash 읽기 가속) 없음 — 클럭이 곧 속도 RAM 16 GB 128 KB (10만 분의 1) 2 KB 널 포인터에 써도 아무 일 없을 수 있습니다 — 0x0000 0000 도 진짜 주소이기 때문입니다
MCU 에는 세그폴트가 없습니다 — 잘못된 쓰기는 멈추지 않고 다른 무언가를 조용히 바꿉니다. 그래서 정적 할당과 경계 검사가 기본입니다.

하버드 vs 폰 노이만 — AVR 에서 실제로 문제가 되는 곳

/* AVR 은 '하버드' — Flash 와 SRAM 이 서로 다른 주소 공간입니다.
   주소 0x0100 이 Flash 의 0x0100 인지 SRAM 의 0x0100 인지 포인터만 봐서는 모름.
   그래서 const 문자열도 기본으로는 시작할 때 SRAM 으로 '복사'됩니다. */

const char help[] = "usage: led on|off|blink <ms>\r\n";  /* SRAM 31 바이트 소모! */

/* 해결: Flash 에 두고 전용 명령으로 읽기 */
#include <avr/pgmspace.h>
const char help_P[] PROGMEM = "usage: led on|off|blink <ms>\r\n";

void uart_puts_P(const char *s) {          /* s 는 'Flash 주소' */
    char c;
    while ((c = (char)pgm_read_byte(s++)) != '\0')
        uart_putc(c);
}

/* STM32(Cortex-M)는 주소 공간이 하나라 const 면 그냥 Flash 에 남고
   보통 포인터로 바로 읽힙니다 — PROGMEM 같은 것이 필요 없습니다. */
Cortex-M 은 "수정된 하버드"입니다 — 명령과 데이터를 다른 버스(I-Code · D-Code · System)로 동시에 가져와 속도를 얻되, 주소는 4 GB 하나로 통일했습니다. 프로그래머는 폰 노이만처럼 쓰고, 하드웨어는 하버드처럼 달립니다.

칩이 스스로를 소개하게 하기 (STM32F446)

#include <stdint.h>

/* 칩마다 정해진 주소에 정보가 박혀 있습니다 (레퍼런스 매뉴얼 기재) */
#define DBGMCU_IDCODE  (*(volatile const uint32_t *)0xE0042000u)
#define FLASH_SIZE_KB  (*(volatile const uint16_t *)0x1FFF7A22u)
#define UID_BASE_ADDR  ((volatile const uint32_t *)0x1FFF7A10u)

void chip_info(void) {
    uint32_t dev_id = DBGMCU_IDCODE & 0x0FFFu;   /* F446 은 0x421 */
    uint32_t rev_id = DBGMCU_IDCODE >> 16;       /* 실리콘 리비전 */
    uint16_t kb     = FLASH_SIZE_KB;             /* RE 면 512 */
    uint32_t uid0   = UID_BASE_ADDR[0];          /* 칩마다 다른 96비트 ID */
    (void)dev_id; (void)rev_id; (void)kb; (void)uid0;
}
고유 ID(UID)는 양산에서 시리얼 번호·암호 키 파생에 그대로 씁니다. 리비전 ID 는 에라타(HW 06)에서 "이 버그는 Rev A 에만"을 확인할 때 봅니다. AVR 은 시그니처 바이트 3개(0x1E 0x95 0x0F)를 프로그래머(avrdude)가 읽어 칩을 확인합니다.

실무 감각 — MCU 를 고를 때 보는 것 🔴 실무

기준질문예
RAM버퍼·스택·RTOS 태스크가 다 들어가나TLS 를 쓰면 RAM 64 KB 이상이 사실상 하한
주변장치 수UART·SPI·I²C·타이머 채널이 핀 충돌 없이 나오나CubeMX 에 핀 배치를 먼저 그려 보고 칩을 확정
전력슬립 전류가 배터리 목표에 맞나µA 단위 저전력은 STM32L·nRF 계열
생태계HAL·예제·디버거·커뮤니티STM32 · ESP32 · RP2040/2350 · nRF
공급10년 공급 보장, 대체 칩2021~22 반도체 대란 때 이걸로 제품이 멈췄습니다
학습용 순서 — AVR(작아서 데이터시트 한 권으로 칩 전체가 보임) → STM32(산업 표준, 레지스터 수십 배) 순서가 가장 덜 막힙니다. 이 가이드의 다음 두 탭(🔷 AVR · 🟦 STM32)이 정확히 그 순서입니다.
정리 — MCU = 코어 + Flash + SRAM + 버스 + 주변장치 + 클럭이고, 주변장치는 코어에게 주소로 보이는 레지스터입니다. OS·MMU·가상 메모리가 없으니 메모리 보호도 세그폴트도 없다는 것을 늘 기억하세요.
HW 04

메모리 맵과 레지스터 — 주소에 값을 쓰면 핀이 움직인다

임베디드 C 의 가장 큰 깨달음은 이것 하나입니다: 주변장치를 조작하는 특별한 명령은 없습니다. 칩 설계자가 어떤 주소들을 메모리 대신 하드웨어 스위치에 연결해 두었고, 우리는 포인터로 그 주소에 숫자를 쓸 뿐입니다. 이 연결표가 메모리 맵이고, 스위치 한 묶음(보통 32비트)이 레지스터입니다.

4 GB 주소 공간 중 실제로 쓰는 곳 — 위로 갈수록 높은 주소 (크기 비례 아님) Cortex 내부 레지스터 0xE000 0000 주변장치 레지스터 GPIO · RCC · TIM · USART 0x4000 0000 SRAM 128 KB 0x2000 0000 부트로더 (ROM) 0x1FFF 0000 Flash 512 KB 0x0800 0000 부팅 별칭 (Flash 를 비춤) 0x0000 0000 GPIOA — 0x4002 0000 부터 +0x00 MODER — 핀 모드 (2비트씩) +0x04 OTYPER — 푸시풀/오픈드레인 +0x08 OSPEEDR — 엣지 속도 +0x0C PUPDR — 풀업/풀다운 +0x10 IDR — 핀에서 읽은 값 +0x14 ODR — 핀으로 낼 값 ★ +0x18 BSRR — 원자적 켜기/끄기 0x4002 0000 + 0x14 = 0x4002 0014 — 이 주소의 5번 비트가 PA5 의 전압입니다
주변장치는 메모리 맵 위의 한 구간이고, 레지스터는 그 안의 "베이스 주소 + 오프셋"입니다 — 주소는 전부 레퍼런스 매뉴얼에 있습니다.

방법 ① 주소를 직접 — 원리가 그대로 보이는 형태

#include <stdint.h>

/* "0x40020014 번지를 32비트 부호 없는 정수로 보고, 거기에 써라" */
#define GPIOA_ODR  (*(volatile uint32_t *)0x40020014u)

void led_on(void) {
    GPIOA_ODR |= (1u << 5);       /* PA5 = HIGH → Nucleo 의 LD2 켜짐 */
}

/* 한 겹씩 벗겨 보면
   0x40020014u                     그냥 정수
   (volatile uint32_t *)0x40020014u 그 정수를 '포인터'로 해석
   *(…)                            그 포인터가 가리키는 곳 = 레지스터 자체
   → 변수처럼 읽고 쓸 수 있는 lvalue 가 됩니다 */

/* ATmega328P 도 똑같습니다 — avr/io.h 가 이렇게 정의해 둡니다 */
#define MY_PORTB  (*(volatile uint8_t *)0x25)   /* I/O 주소 0x05 + 0x20 */
/* MY_PORTB |= (1 << 5);  → Uno 의 D13(PB5) LED 켜짐 */
AVR 주소에 0x20 이 더해지는 이유 — AVR 데이터 공간은 0x00~0x1F 가 CPU 레지스터 r0~r31 이라, I/O 레지스터는 그 뒤 0x20 부터 놓입니다. 데이터시트 표는 I/O 주소(0x05)와 메모리 주소(0x25)를 둘 다 적어 둡니다.

방법 ② 구조체로 덮어씌우기 — CMSIS 가 쓰는 방식

#include <stdint.h>
#include <stddef.h>

/* 레지스터 순서와 간격(4바이트)을 구조체 멤버로 그대로 옮김 */
typedef struct {
    volatile uint32_t MODER;    /* 0x00 */
    volatile uint32_t OTYPER;   /* 0x04 */
    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 · 0x24 — 대체 기능 선택 */
} GPIO_Regs;

_Static_assert(offsetof(GPIO_Regs, ODR) == 0x14, "레지스터 배치가 어긋남");

#define MY_GPIOA  ((GPIO_Regs *)0x40020000u)
#define MY_GPIOC  ((GPIO_Regs *)0x40020800u)

MY_GPIOA->ODR |= (1u << 5);       /* 방법 ①과 완전히 같은 기계어 */
MY_GPIOC->IDR;                    /* 포트만 바꾸면 같은 구조체로 C 포트도 */
실무에서는 직접 만들지 않습니다 — ST 가 배포하는 stm32f446xx.h(CMSIS 디바이스 헤더)에 GPIO_TypeDef · GPIOA · RCC_AHB1ENR_GPIOAEN 같은 이름이 수천 개 정의돼 있습니다. 거기서 volatile 은 __IO 라는 매크로로 보입니다.

volatile 이 왜 필수인가 — 최적화가 지워 버리는 코드 🟡 중급

volatile 의 정의와 "원자성은 아니다"라는 점은 C 실전 탭 c22 에 있습니다. 여기서는 레지스터에서 실제로 무엇이 사라지는지를 기계어 수준에서 봅니다.

❌ volatile 을 빠뜨린 대기 루프
#define USART2_SR (*(uint32_t *)0x40004400u)   /* volatile 없음 */
#define TXE       (1u << 7)

void wait_tx_empty(void) {
    while (!(USART2_SR & TXE)) { }
}

/* arm-none-eabi-gcc -O2 결과 (대략 이런 모양)
     ldr   r3, =0x40004400
     ldr   r3, [r3]        @ 딱 한 번 읽음
     tst   r3, #128
     bne   .done
   .spin:
     b     .spin           @ 다시 읽지 않고 제자리 — 영원히
   컴파일러 입장: "이 루프 안에서 아무도 그 메모리를 안 바꾼다" */
✅ volatile — 매번 실제로 읽으라는 약속
#define USART2_SR (*(volatile uint32_t *)0x40004400u)

void wait_tx_empty(void) {
    while (!(USART2_SR & TXE)) { }
}

/*   ldr   r2, =0x40004400
   .loop:
     ldr   r3, [r2]        @ 매 바퀴 하드웨어를 다시 읽음
     tst   r3, #128
     beq   .loop
   "디버그 빌드(-O0)에선 되는데 릴리스(-O2)에서 멈춘다"의
   대표 원인이 바로 이 volatile 누락입니다 */

사라지는 지연 루프 — 두 번째 함정

/* 초보 Blinky 의 지연 */
void delay_bad(void) {
    for (uint32_t i = 0; i < 1000000u; i++) { }   /* -O2 에서 통째로 삭제 */
}
/* 결과: LED 가 MHz 단위로 깜빡여 '반쯤 켜진' 것처럼 보임 */

/* 땜질 — 동작은 하지만 시간이 클럭·최적화 수준에 따라 달라짐 */
void delay_hack(void) {
    for (volatile uint32_t i = 0; i < 1000000u; i++) { }
}

/* 정답 — 하드웨어 타이머로 시간을 잰다 (HW 09 · HW 13 의 SysTick) */
extern volatile uint32_t g_ms;
void delay_ms(uint32_t ms) {
    uint32_t start = g_ms;
    while ((uint32_t)(g_ms - start) < ms) { }
}

volatile 체크리스트

대상필요?이유
주변장치 레지스터필수하드웨어가 값을 바꾸고, 읽기 자체가 동작인 경우도 있음
ISR 과 main 이 같이 쓰는 전역필수컴파일러는 ISR 이 언제 끼어들지 모름 (HW 08)
DMA 가 채우는 버퍼필요CPU 모르게 메모리가 바뀜
함수 안 지역 변수불필요지연 루프 땜질 외엔 쓸 일 없음
RTOS 태스크 간 공유부족뮤텍스·큐를 써야 함 (⏱️ FreeRTOS 탭)
"읽기만 해도 무언가 일어나는" 레지스터가 있습니다 — 예를 들어 STM32F4 의 USART 는 SR 을 읽고 DR 을 읽으면 오류 플래그가 지워집니다. 디버거의 레지스터 창을 열어 두는 것만으로 플래그가 사라지는 일이 생기는 이유입니다.

한 단계 더 — 헤더의 이름을 실제 주소로 풀어 보기 🔴 고급

stm32f446xx.h 를 따라가면 (요약)
  #define PERIPH_BASE        0x40000000UL
  #define AHB1PERIPH_BASE    (PERIPH_BASE + 0x00020000UL)      → 0x4002 0000
  #define GPIOA_BASE         (AHB1PERIPH_BASE + 0x0000UL)      → 0x4002 0000
  #define GPIOC_BASE         (AHB1PERIPH_BASE + 0x0800UL)      → 0x4002 0800
  #define RCC_BASE           (AHB1PERIPH_BASE + 0x3800UL)      → 0x4002 3800
  #define GPIOA              ((GPIO_TypeDef *) GPIOA_BASE)

  RCC->AHB1ENR   = 0x4002 3800 + 0x30 = 0x4002 3830
  GPIOA->ODR     = 0x4002 0000 + 0x14 = 0x4002 0014
  GPIOC->IDR     = 0x4002 0800 + 0x10 = 0x4002 0810
  SysTick->CTRL  = 0xE000 E010           (Cortex-M 공통 — 모든 STM32 에서 같음)

avr/iom328p.h 를 따라가면
  #define PORTB  _SFR_IO8(0x05)     → 메모리 주소 0x25
  #define DDRB   _SFR_IO8(0x04)     → 0x24
  #define PINB   _SFR_IO8(0x03)     → 0x23
디버거에서 이 숫자들이 그대로 보입니다 — 메모리 창에 0x40020014 를 치면 ODR 값이 뜨고, 거기에 0x20 을 써 넣으면 코드 없이 LED 가 켜집니다. "레지스터 = 주소"를 몸으로 확인하는 가장 빠른 실험입니다(🐞 디버깅 탭).
정리 — 레지스터 접근은 *(volatile uint32_t *)주소 가 전부이고, CMSIS 의 GPIOA->ODR 은 같은 것을 구조체로 예쁘게 쓴 것입니다. volatile 을 빠뜨리면 -O2 에서 대기 루프가 멈추고 지연 루프가 사라집니다 — 레지스터와 ISR 공유 변수에는 예외 없이 붙입니다.
HW 05

비트 조작 — 마스크 · 시프트 · 원자적 쓰기

레지스터 하나에는 서로 다른 스위치가 수십 개 들어 있습니다. 그중 하나만 바꾸고 나머지는 그대로 두는 것이 펌웨어 코드의 절반입니다. 기본 매크로는 C 실전 탭 c22 에 있으니, 여기서는 여러 비트짜리 필드, 읽기-수정-쓰기가 인터럽트와 부딪히는 순간, 그리고 그것을 하드웨어가 해결해 주는 레지스터를 봅니다.

GPIOA→MODER — 한 칸 = 2비트 = 핀 하나 리셋 직후 0xA800 0000 (PA13~15 는 디버거 연결용) 10 10 10 00 00 00 00 00 00 00 00 00 00 00 00 00 15 14 13 12 11 10 9 8 7 6 핀 5 4 3 2 1 0 MODER5 = 01 (범용 출력) 을 쓴 뒤 0xA800 0400 10 10 10 00 00 00 00 00 00 00 01 00 00 00 00 00 31:30 11:10 1:0 ① 지우기 MODER &= ~(3u << (5*2)) → 비트 11:10 = 00 ② 넣기 MODER |= (1u << (5*2)) → 비트 11:10 = 01 핀 n 의 필드는 비트 2n+1 : 2n — "지우고 넣기" 를 빼먹으면 이전 값이 섞입니다
여러 비트 필드는 OR 만으로는 못 씁니다 — 10 에 01 을 OR 하면 11 이 됩니다. 반드시 마스크로 지우고 → 넣기.

필드 쓰기를 함수 하나로

#include <stdint.h>

/* reg 의 [shift, shift+width) 구간에 val 을 넣는다 */
static inline void reg_field(volatile uint32_t *reg,
                             uint32_t shift, uint32_t width, uint32_t val) {
    uint32_t mask = ((width >= 32u) ? 0xFFFFFFFFu : ((1u << width) - 1u)) << shift;
    *reg = (*reg & ~mask) | ((val << shift) & mask);    /* 읽기 → 지우기 → 넣기 → 쓰기 */
}

/* 사용 — 숫자로 말하면 레퍼런스 매뉴얼과 1:1 로 대조됩니다 */
reg_field(&GPIOA->MODER,  5 * 2, 2, 0x1);   /* PA5  = 출력        */
reg_field(&GPIOA->MODER,  2 * 2, 2, 0x2);   /* PA2  = 대체 기능    */
reg_field(&GPIOA->AFR[0], 2 * 4, 4, 7);     /* PA2  = AF7 (USART2_TX) */

/* CMSIS 헤더의 같은 일 — 이름이 붙은 _Msk / _Pos 와 MODIFY_REG 매크로 */
MODIFY_REG(GPIOA->MODER, GPIO_MODER_MODER5_Msk, 1u << GPIO_MODER_MODER5_Pos);
1u 의 u 가 중요합니다 — 1 << 31 은 부호 있는 int 를 넘쳐 미정의 동작이고, AVR 에서는 int 가 16비트라 1 << 15 는 음수, 1 << 16 은 미정의 동작입니다. AVR 의 32비트 레지스터 값은 1UL << n 으로 씁니다.

네 가지 기본 동작 — 두 칩에서

동작식AVR (PB5)STM32 (PA5)
켜기r |= mPORTB |= _BV(5)GPIOA->BSRR = 1u<<5
끄기r &= ~mPORTB &= ~_BV(5)GPIOA->BSRR = 1u<<21
뒤집기r ^= mPINB = _BV(5) ★GPIOA->ODR ^= 1u<<5
검사(r & m) != 0PINB & _BV(5)GPIOA->IDR & (1u<<5)
★ ATmega328P 의 숨은 기능 — 입력 레지스터 PINx 에 1 을 쓰면 그 비트의 출력이 뒤집힙니다. 읽기-수정-쓰기 없이 한 번의 쓰기라 원자적입니다. STM32 의 BSRR 은 아래 16비트가 "켜기", 위 16비트가 "끄기"이고 0 을 쓴 비트는 아무 일도 하지 않습니다 — 그래서 다른 핀을 건드릴 걱정이 없습니다.

읽기-수정-쓰기와 인터럽트 — 비트 하나가 사라지는 순간 🔴 고급

ODR |= (1u << 5) 는 명령 세 개 — 그 사이에 인터럽트가 끼면? main 루프 GPIOA→ODR 타이머 ISR ① LDR — ODR 읽기 → r0 = 0x00 인터럽트! ② ODR |= 1u<<6 → 0x40 ③ STR — r0 | 0x20 = 0x20 쓰기 ODR = 0x20 — ② 에서 켠 PA6 가 꺼졌습니다 끼어든 쓰기는 덮어써집니다 — BSRR 은 쓰기 한 번이라 이 틈이 없습니다
레이스는 스레드가 없어도 생깁니다 — main 과 ISR 이 같은 레지스터를 RMW 하면 가끔 비트가 사라집니다.

고치는 세 가지 방법

/* ① 원자적 레지스터를 쓴다 (가장 좋음) */
GPIOA->BSRR = (1u << 5);            /* PA5 켜기 — 쓰기 한 번 */
GPIOA->BSRR = (1u << (5 + 16));     /* PA5 끄기 */

/* ② 잠깐 인터럽트를 막는다 — 이전 상태를 저장했다가 되돌림 (CMSIS) */
uint32_t primask = __get_PRIMASK();
__disable_irq();
GPIOA->ODR ^= (1u << 5);            /* 이 사이엔 ISR 이 끼지 못함 */
__set_PRIMASK(primask);             /* 원래 꺼져 있었다면 꺼진 채로 */

/* ③ AVR — avr-libc 의 원자 블록 */
#include <util/atomic.h>
ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {
    TIMSK1 |= (1 << OCIE1A);        /* 아래 표 참고 — 이건 RMW 가 맞음 */
}
__enable_irq() 로 무조건 켜지 마세요 — 호출한 쪽이 이미 인터럽트를 꺼 둔 상태였다면 그 의도를 깨뜨립니다. "저장 → 끄기 → 되돌리기"가 표준 형태이고, 막는 구간은 수 마이크로초 이내로 짧게 둡니다.

AVR 은 어디까지 원자적인가

C 코드기계어원자적?
PORTB |= (1<<5)sbi 0x05,5예 — 명령 1개
PORTB |= 0x30 (비트 2개)in · ori · out아니오
TIMSK1 |= (1<<OCIE1A)lds · ori · sts아니오 — 주소 0x6F
PINB = (1<<5)ldi · out예 — 쓰기뿐
uint16_t 변수 읽기lds · lds아니오 — 8비트 CPU
sbi/cbi(비트 하나 세트/클리어) 명령은 I/O 주소 0x00~0x1F 레지스터에만 쓸 수 있습니다. 그 밖의 레지스터나 비트 여러 개는 컴파일러가 세 명령으로 풀어 RMW 가 됩니다. "AVR 은 한 줄이면 원자적"이라고 일반화하면 안 됩니다.

한 단계 더 — 비트밴딩과 "쓰면 지워지는" 비트 🧬 전문가

Cortex-M4 비트밴딩 — 비트 하나를 워드 하나로

/* 주변장치 영역 0x4000 0000~0x400F FFFF 의 비트마다
   0x4200 0000 부터 '별칭 워드'가 하나씩 있습니다.
   별칭 주소 = 0x42000000 + (바이트 오프셋 × 32) + (비트 번호 × 4) */
#define BITBAND_PERI(addr, bit) \
    (*(volatile uint32_t *)(0x42000000u + (((uint32_t)(addr) - 0x40000000u) * 32u) + ((bit) * 4u)))

/* GPIOA->ODR(0x40020014) 의 5번 비트 → 0x42400294 */
BITBAND_PERI(&GPIOA->ODR, 5) = 1;    /* 하드웨어가 원자적으로 그 비트만 바꿈 */
Cortex-M3/M4 의 선택 기능이라 M0+·M7·M33 에는 없습니다. 이식성이 떨어져 요즘은 BSRR 같은 전용 레지스터나 짧은 임계 구역을 더 많이 씁니다.

"1 을 써서 지우는" 플래그를 |= 로 다루면

/* EXTI->PR 은 rc_w1: 읽으면 '대기 중'인 비트가 1, 1 을 쓰면 그 비트가 지워짐 */

EXTI->PR |= EXTI_PR_PR13;   /* ❌ 읽은 값에 1 인 다른 비트(PR0 등)까지 다시 써서 */
                            /*    아직 처리 안 한 다른 인터럽트까지 지워 버림 */
EXTI->PR  = EXTI_PR_PR13;   /* ✅ 13번만 지움 — 0 을 쓴 비트는 그대로 */

/* AVR 의 인터럽트 플래그도 같은 규칙 (1 을 써서 지움) */
TIFR1 = (1 << OCF1A);       /* ✅ */

/* 반대로 TIMx->SR 은 rc_w0: 0 을 써야 지워지고 1 은 무시 */
TIM2->SR = ~TIM_SR_UIF;     /* ✅ UIF 만 0 으로, 나머지는 1(=무시) */
정리 — 한 비트는 |= · &= ~ · ^= · &, 여러 비트 필드는 지우고 → 넣기. main 과 ISR 이 같은 레지스터를 고치면 RMW 레이스가 나므로 BSRR 같은 원자적 레지스터를 먼저 찾고, 없으면 짧은 임계 구역으로 감쌉니다. "1 을 써서 지우는" 플래그는 |= 가 아니라 =.
HW 06

데이터시트 읽는 법 — 어느 문서의 어느 표를 펼치나

펌웨어 개발자의 실력은 문서에서 답을 찾는 속도입니다. 문제는 칩 하나에 문서가 여러 권이고 합치면 2,000 쪽이 넘는다는 것. 처음부터 읽는 사람은 없습니다. 어떤 질문은 어느 문서에 있는지, 그리고 레지스터 표 한 장을 코드 한 줄로 옮기는 법만 알면 됩니다.

레퍼런스 매뉴얼의 레지스터 한 항목 — 여섯 군데만 읽으면 됩니다 GPIO port bit set/reset register (GPIOx_BSRR) Address offset: 0x18 Reset value: 0x0000 0000 Bits 31:16 BRy: Port x reset bit y w 1: Resets the ODRx bit · 0: No action Bits 15:0 BSy: Port x set bit y w 1: Sets the ODRx bit · 0: No action x = A~H 포트 베이스 + 0x18 전원 켤 때 값 w = 쓰기 전용 1 이면 끄기 0 은 무시 ★ 이 항목을 코드로 옮기면 GPIOA->BSRR = (1u << 5); PA5 켜기 · 나머지 핀은 그대로 GPIOA->BSRR = (1u << (5 + 16)); PA5 끄기
레지스터 항목은 이름 · 오프셋 · 리셋값 · 접근 형식 · 비트 의미가 전부 — "0: No action" 한 줄이 원자적 쓰기의 근거입니다.

문서 네 종류 — 질문별로 펼칠 곳

문서들어 있는 것이런 질문이면
데이터시트
(Datasheet)
핀 배치 · 전기 특성 · 패키지 · 대체 기능 표"이 핀이 5 V 를 견디나" "PA2 로 UART 가 나오나"
레퍼런스 매뉴얼
(RM0390)
주변장치 동작 · 레지스터 전부"타이머를 PWM 모드로 하려면 어느 비트"
프로그래밍 매뉴얼
(PM0214)
Cortex-M4 코어 · 명령어 · NVIC · SysTick"인터럽트 우선순위 비트가 몇 개"
에라타
(Errata sheet)
칩의 알려진 버그와 우회법"매뉴얼대로 했는데 안 된다" ★
AVR 은 한 권 — ATmega328P 는 데이터시트 하나(약 300 쪽)에 전기 특성과 레지스터가 같이 들어 있고, 명령어는 "AVR Instruction Set Manual" 이 따로 있습니다. STM32 는 칩 고유(데이터시트·RM)와 Arm 공통(PM)이 나뉘어 있다는 점이 가장 큰 차이입니다.

접근 형식 약어 — 이걸 모르면 코드가 틀립니다

표기뜻코드에서
rw읽고 쓰기보통의 설정 비트 — RMW 가능
r읽기 전용써도 무시 (상태 비트, IDR)
w쓰기 전용읽으면 0 — |= 금지 (BSRR)
rc_w11 을 쓰면 지워짐REG = bit; (EXTI_PR)
rc_w00 을 쓰면 지워짐REG = ~bit; (TIMx_SR)
rc_r읽으면 지워짐디버거로 보기만 해도 사라짐
rs1 만 쓸 수 있음소프트웨어가 0 으로 못 되돌림
Res.예약 비트리셋값 그대로 유지 — 함부로 0 쓰지 말 것

핀 배치와 대체 기능 표 — 한 핀, 여러 얼굴 🟡 중급

STM32F446 데이터시트 "Alternate function" 표 일부

핀AF1AF3AF4AF5AF7
PA2TIM2_CH3TIM9_CH1——USART2_TX
PA3TIM2_CH4TIM9_CH2——USART2_RX
PA5TIM2_CH1TIM8_CH1N—SPI1_SCK—
PB8…TIM10_CH1I2C1_SCL——
PB9…TIM11_CH1I2C1_SDASPI2_NSS—
/* (… 은 이 발췌에서 생략한 칸 — 전체는 데이터시트 표로)
   표 → 코드: PA2 를 USART2_TX 로
   ① MODER2 = 10 (대체 기능)   ② AFR[0] 의 2번 칸(비트 11:8) = 7 */
GPIOA->MODER  = (GPIOA->MODER  & ~(3u   << (2 * 2))) | (2u << (2 * 2));
GPIOA->AFR[0] = (GPIOA->AFR[0] & ~(0xFu << (2 * 4))) | (7u << (2 * 4));
/* 핀 0~7 은 AFR[0](AFRL), 8~15 는 AFR[1](AFRH) — 칸 하나가 4비트 */

Uno(ATmega328P) — 핀 이름이 세 겹

보드 표기칩 핀겸하는 기능
D0 / D1PD0 / PD1RXD / TXD — USB 시리얼과 공유
D2PD2INT0 (외부 인터럽트)
D9PB1OC1A — 16비트 타이머 PWM
D11 / D13PB3 / PB5MOSI / SCK — 그리고 D13 은 LED
A4 / A5PC4 / PC5SDA / SCL (I²C) · ADC4 / ADC5
AVR 은 대체 기능을 고르는 레지스터가 없습니다 — 주변장치를 켜면 그 핀을 그냥 가져갑니다(예: UART 를 켜면 PD0/PD1). STM32 는 핀마다 AF 번호를 직접 골라야 하는 대신, 같은 기능을 여러 핀으로 뺄 수 있습니다. 보드 표기(D13)와 칩 핀(PB5)을 혼동하지 마세요.

실무에선 이렇게 — 처음 보는 주변장치를 켜는 순서 🔴 실무

레퍼런스 매뉴얼 한 장(章) 읽는 순서

① Introduction / Main features   — 이 장치로 할 수 있는 것 (1쪽)
② Functional description 의 그림 — 블록도 하나로 신호 흐름 파악
③ "How to ..." / 절차 문단        — 'Procedure: 1. … 2. …' 로 적힌 설정 순서 ★
④ Interrupts / DMA 표             — 어떤 플래그가 어떤 인터럽트를 내나
⑤ Register map (장 마지막 표)     — 오프셋·리셋값 한눈에, 코드는 여기서 씀
⑥ 에라타에서 이 주변장치 이름 검색 — 마지막에 반드시

거꾸로 하지 마세요: ⑤부터 읽으면 비트 이름은 알아도 순서를 몰라
"레지스터는 다 맞게 썼는데 동작을 안 한다"가 됩니다.

에라타가 실제로 말해 주는 것 — 예시 유형

STM32F4 계열 에라타에 실린 대표 항목 (유형)
  · 주변장치 클럭을 켠 직후 2 클럭 안에 그 레지스터에 접근하면 무시될 수 있음
      → 우회: 클럭 켠 뒤 RCC 레지스터를 한 번 읽는다 (HAL 이 하는 일)
  · 특정 조건에서 I²C 가 BUSY 에 갇힘 → 버스 복구 절차
  · 특정 리비전에서만 나는 ADC·Flash 관련 제약

읽는 법
  1. 내 칩의 리비전 확인 (DBGMCU->IDCODE 의 REV_ID, 칩 표면 마킹)
  2. 쓰는 주변장치 이름으로 검색
  3. "Workaround" 를 코드 주석에 에라타 항목 번호와 함께 남김
"매뉴얼대로 했는데 안 된다" 사흘째라면 에라타를 펴세요. 하드웨어도 버그가 있고, 제조사는 그걸 이 문서에만 적습니다. HAL 소스의 이상해 보이는 더미 읽기·지연은 대개 에라타 우회 코드입니다 — 지우기 전에 이유부터 찾으세요.
정리 — 핀·전기는 데이터시트, 레지스터는 레퍼런스 매뉴얼, 코어·NVIC 는 프로그래밍 매뉴얼, 안 될 때는 에라타. 레지스터 표는 오프셋 · 리셋값 · 접근 형식(rw/w/rc_w1…) 세 가지만 정확히 읽어도 코드 실수의 대부분이 사라집니다.
HW 07

클럭 — 모든 것의 박자, 그리고 초보 1순위 버그

MCU 안의 모든 블록은 클럭(일정한 박자의 사각파)에 맞춰 움직입니다. 클럭은 속도를 정하고, 타이머·UART 보레이트의 기준이 되며, 무엇보다 켜지 않은 장치는 아예 동작하지 않습니다. "레지스터에 썼는데 읽어 보면 0" — 처음 STM32 를 만지는 사람의 첫 사흘을 잡아먹는 버그가 여기 있습니다.

HSI 16 MHz 에서 180 MHz 까지 — 나누고, 곱하고, 다시 나눕니다 HSI 내부 RC 16 MHz ÷ M (8) 2 MHz × N (180) VCO 360 MHz ÷ P (2) 180 MHz SYSCLK 180 MHz AHB ÷1 → 180 MHz 코어 · GPIO · DMA · SysTick APB1 ÷4 → 45 MHz USART2 · I2C · 타이머는 ×2 = 90 APB2 ÷2 → 90 MHz USART1 · ADC · 타이머는 180 클럭 게이트 RCC_xxxENR 비트 = 0 → 레지스터 쓰기 무시 리셋 직후 SYSCLK = HSI 16 MHz PLL 꺼짐 · 버스 ÷1 180 MHz 전에 Flash 대기 5 WS · 전압 스케일 1 · 오버드라이브가 먼저입니다
클럭 트리는 원천 → PLL → 버스별 분주이고, 장치마다 게이트 스위치가 하나씩 있습니다 — 기본은 전부 꺼짐(STM32).

클럭 원천 — 무엇이 박자를 만드나

원천정확도쓰는 곳
내부 RC
HSI 16 MHz · AVR 8 MHz
±1% 정도, 온도에 흔들림부품 없이 바로 동작. 리셋 직후 기본
외부 크리스털
HSE 4~26 MHz · Uno 16 MHz
±0.005% 수준UART 고속 통신, USB, 정확한 시간
저속 32.768 kHz
LSE
시계용 정확도RTC — 2¹⁵ 이라 1초 세기 쉬움
PLL원천을 따름곱해서 고속 클럭 생성
Nucleo-F446RE 는 보드에 HSE 크리스털이 기본으로 달려 있지 않고, ST-LINK 쪽 칩이 8 MHz 를 MCO 로 넘겨줍니다(바이패스 모드). 정확도가 필요하면 이걸 HSE 로 쓰고, 이 탭의 예제는 단순하게 HSI 16 MHz 그대로 둡니다.

PLL 계산 — 식 하나

f_VCO_in = f_src ÷ M        1~2 MHz 여야 함 (2 MHz 권장 — 지터 적음)
f_VCO    = f_VCO_in × N      100~432 MHz 범위
SYSCLK   = f_VCO ÷ P         P ∈ {2, 4, 6, 8}

HSI 16 MHz 로 180 MHz 만들기
  M = 8    → 16 ÷ 8   = 2 MHz      ✓
  N = 180  → 2 × 180  = 360 MHz    ✓ (100~432)
  P = 2    → 360 ÷ 2  = 180 MHz    ✓ (F446 최대)

버스
  AHB  ÷1 → 180 MHz
  APB1 ÷4 →  45 MHz  (최대 45)    APB1 타이머 = 2 × 45 = 90 MHz
  APB2 ÷2 →  90 MHz  (최대 90)    APB2 타이머 = 2 × 90 = 180 MHz
  ※ APB 분주가 1 이 아니면 그 버스의 타이머 클럭은 2배 — 타이머 계산의 함정 ★

초보 1순위 버그 — 클럭을 안 켜고 레지스터를 쓴다 🟡 중급

❌ GPIO 설정이 전부 무시된다
int main(void) {
    /* RCC->AHB1ENR 를 안 켬 */
    GPIOA->MODER |= (1u << (5 * 2));
    GPIOA->BSRR   = (1u << 5);
    for (;;) { }
}
/* 디버거로 보면 GPIOA->MODER 가 여전히 0xA800 0000
   GPIOA 블록에 박자가 안 들어가서 쓰기를 '받지 않은' 것
   에러도, 예외도, 경고도 없습니다 */
✅ 클럭 먼저 — 그리고 한 번 읽기
int main(void) {
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;   /* 비트 0 */
    (void)RCC->AHB1ENR;   /* 켜진 뒤 2 클럭 대기 — 에라타 우회 */
    GPIOA->MODER = (GPIOA->MODER & ~(3u << (5 * 2)))
                 | (1u << (5 * 2));
    GPIOA->BSRR  = (1u << 5);
    for (;;) { }
}
/* 장치별 게이트: AHB1ENR(GPIO·DMA) · APB1ENR(TIM2~7·
   USART2·I2C) · APB2ENR(USART1·ADC·SYSCFG·TIM1) */

AVR 은 반대 — 기본이 켜짐, 끄는 레지스터가 있음

#include <avr/io.h>
#include <avr/power.h>
#include <avr/interrupt.h>

/* PRR(Power Reduction Register): 비트 = 1 이면 그 장치의 클럭을 '끔'
   리셋값 0 → 모든 주변장치가 처음부터 켜져 있음 (STM32 와 반대) */
power_adc_disable();     /* PRR |= (1 << PRADC)  — 전류 절약 */
power_twi_disable();     /* I²C 끄기 */
/* 이후 ADC 레지스터를 써도 동작 안 함 — STM32 와 같은 증상이 '반대 원인'으로 */

/* 시스템 클럭 분주 — 쓰기 순서가 정해진 보호 레지스터 */
cli();
CLKPR = (1 << CLKPCE);   /* ① 변경 허가 */
CLKPR = 0;               /* ② 4 클럭 안에 값 쓰기 → ÷1 */
sei();
/* avr-libc 의 clock_prescale_set(clock_div_1) 이 같은 일을 합니다 */
AVR 의 클럭 원천은 퓨즈(fuse)로 정합니다 — 공장 출고 칩은 내부 8 MHz ÷ 8 = 1 MHz 로 돕니다(CKDIV8 퓨즈). Uno 의 칩은 외부 16 MHz 로 퓨즈가 맞춰져 나옵니다. 퓨즈를 외부 크리스털로 바꿨는데 크리스털이 없으면 프로그래머로도 접속이 안 되는 칩이 됩니다.

"보레이트가 이상해요"의 정체

증상: UART 로 글자가 깨져서 나온다 (예: 'Hello' → '�x�')

원인 1 — 코드는 180 MHz 라고 믿는데 실제는 HSI 16 MHz
         (SystemClock_Config 를 안 불렀거나 PLL 이 안 잠김)
         → 보레이트가 180/16 ≈ 11배 틀어짐

원인 2 — F_CPU 매크로와 실제 클럭이 다름 (AVR)
         #define F_CPU 16000000UL 인데 퓨즈는 8 MHz
         → _delay_ms 도 2배 길어짐, UART 도 깨짐

원인 3 — 내부 RC 의 오차 + 높은 보레이트
         UART 는 양쪽 오차 합이 대략 ±2~3% 를 넘으면 깨지기 시작
         115200 이상은 외부 크리스털 권장

확인법: MCO 핀으로 클럭을 내보내 측정하거나,
        SysTick 1 ms 로 LED 를 1초마다 토글해 스톱워치로 잰다

레지스터로 180 MHz 올리기 — 순서가 전부입니다 🔴 고급

#include "stm32f446xx.h"

void clock_180mhz_from_hsi(void) {
    /* ① 전원부: 전압 스케일 1 (PLL 을 켜기 전에) */
    RCC->APB1ENR |= RCC_APB1ENR_PWREN;
    (void)RCC->APB1ENR;
    PWR->CR |= PWR_CR_VOS;                          /* VOS = 11 */

    /* ② PLL: 원천 HSI, M=8, N=180, P=2 (PLLP 비트 00) — 다른 필드(Q·R)는 유지 */
    RCC->PLLCFGR = (RCC->PLLCFGR & ~(RCC_PLLCFGR_PLLM | RCC_PLLCFGR_PLLN |
                                     RCC_PLLCFGR_PLLP | RCC_PLLCFGR_PLLSRC))
                 | (8u   << RCC_PLLCFGR_PLLM_Pos)
                 | (180u << RCC_PLLCFGR_PLLN_Pos);
    RCC->CR |= RCC_CR_PLLON;
    while (!(RCC->CR & RCC_CR_PLLRDY)) { }          /* PLL 이 잠길 때까지 */

    /* ③ 180 MHz 는 오버드라이브가 있어야 허용 */
    PWR->CR |= PWR_CR_ODEN;
    while (!(PWR->CSR & PWR_CSR_ODRDY)) { }
    PWR->CR |= PWR_CR_ODSWEN;
    while (!(PWR->CSR & PWR_CSR_ODSWRDY)) { }

    /* ④ Flash 가 180 MHz 를 못 따라오므로 대기 5 클럭 + 가속기 켬 — 클럭 올리기 '전에' ★ */
    FLASH->ACR = FLASH_ACR_LATENCY_5WS | FLASH_ACR_PRFTEN | FLASH_ACR_ICEN | FLASH_ACR_DCEN;

    /* ⑤ 버스 분주: AHB ÷1, APB1 ÷4, APB2 ÷2 */
    RCC->CFGR = (RCC->CFGR & ~(RCC_CFGR_HPRE | RCC_CFGR_PPRE1 | RCC_CFGR_PPRE2))
              | RCC_CFGR_PPRE1_DIV4 | RCC_CFGR_PPRE2_DIV2;

    /* ⑥ 시스템 클럭을 PLL 로 전환하고 실제로 바뀌었는지 확인 */
    RCC->CFGR = (RCC->CFGR & ~RCC_CFGR_SW) | RCC_CFGR_SW_PLL;
    while ((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL) { }

    SystemCoreClock = 180000000u;                   /* CMSIS 전역 — SysTick 계산이 이걸 봄 */
}
④ 와 ⑥ 의 순서를 바꾸면 — 180 MHz 로 먼저 올라간 CPU 가 대기 0 클럭으로 Flash 를 읽다가 엉뚱한 명령을 실행하고 HardFault 로 갑니다. 실무에서는 CubeMX 가 이 코드를 SystemClock_Config() 로 만들어 주지만, 클럭이 이상할 때 무엇을 의심할지는 이 여섯 단계로 압니다.
정리 — 리셋 직후 STM32F446 은 HSI 16 MHz, Uno 는 외부 16 MHz로 돕니다. STM32 는 주변장치 클럭이 기본으로 꺼져 있어 RCC 의 ENR 비트를 켜지 않으면 레지스터 쓰기가 조용히 무시되고, AVR 은 반대로 PRR 로 끄는 방식입니다. PLL 로 올릴 때는 Flash 대기 → 클럭 전환 순서를 지킵니다.
HW 08

인터럽트 — 하드웨어가 CPU 의 어깨를 두드리는 법

버튼이 눌렸는지 알려면 두 가지 방법이 있습니다. CPU 가 계속 물어보거나(폴링), 버튼이 CPU 를 부르게 하거나(인터럽트). 서버로 치면 폴링은 1초마다 DB 를 조회하는 배치이고, 인터럽트는 웹훅입니다. 펌웨어의 구조 — 반응 속도, 전력, 버그의 종류 — 가 이 선택에서 갈립니다.

같은 버튼, 두 가지 구조 — 붉은 선이 버튼이 눌린 순간 폴링 최대 한 주기 늦음 막대 = "눌렸나?" 확인 인터럽트 ISR flag 처리 메인 루프 · 슬립 다시 슬립 가능 시간 폴링 — CPU 가 계속 깨어 있어야 하고, 반응은 확인 주기만큼 늦습니다 인터럽트 — 사건이 CPU 를 깨우고 ISR 은 짧게 표시만, 처리는 메인 루프가
인터럽트의 이득은 반응 시간과 잠들 수 있는 시간입니다 — 대신 언제든 끼어든다는 대가로 공유 변수 문제가 생깁니다.

벡터 테이블 — "이 사건엔 이 함수"의 주소록

ATmega328PCortex-M4 (STM32F446)
위치Flash 0x0000 부터Flash 0x0800 0000 (VTOR 로 이동 가능)
한 칸의 내용jmp 명령 (4바이트)함수 주소 (4바이트)
항목 수26코어 16 + 주변장치 90여 개
함수 이름 규칙ISR(INT0_vect)void EXTI15_10_IRQHandler(void)
우선순위고정 — 벡터 번호가 작을수록 먼저NVIC 에서 0(최고)~15 설정
중첩기본 없음 (진입 시 전역 차단)높은 우선순위가 낮은 것을 선점
진입 비용최소 4 클럭 + 레지스터 저장12 클럭 (8개 레지스터 자동 저장)
Cortex-M 의 ISR 은 그냥 C 함수입니다 — 하드웨어가 진입 때 r0~r3·r12·lr·pc·xPSR 를 스택에 쌓아 주기 때문에 특별한 키워드가 필요 없습니다. 이름만 스타트업 파일의 벡터 테이블과 한 글자도 틀리지 않게 맞추면 됩니다.

버튼 인터럽트 — 두 칩 나란히

/* ── ATmega328P: Uno D2(PD2) = INT0, 하강 엣지 ── */
#include <avr/io.h>
#include <avr/interrupt.h>
volatile uint8_t g_btn_flag;

ISR(INT0_vect) { g_btn_flag = 1; }          /* 표시만 하고 끝 */

void btn_irq_init_avr(void) {
    DDRD  &= ~(1 << DDD2);  PORTD |= (1 << PORTD2);   /* 입력 + 풀업 */
    EICRA  = (1 << ISC01);                  /* ISC01:00 = 10 → 하강 엣지 */
    EIFR   = (1 << INTF0);                  /* 남아 있던 플래그 지우기 */
    EIMSK |= (1 << INT0);                   /* INT0 허용 */
    sei();                                  /* 전역 인터럽트 허용 */
}

/* ── STM32F446: Nucleo B1 = PC13 (누르면 LOW) → EXTI13 ── */
volatile uint32_t g_btn_flag32;

void EXTI15_10_IRQHandler(void) {           /* EXTI10~15 가 한 벡터를 공유 */
    if (EXTI->PR & EXTI_PR_PR13) {
        EXTI->PR = EXTI_PR_PR13;            /* rc_w1 — '=' 로 지움 (HW 05) */
        g_btn_flag32 = 1;
    }
}

void btn_irq_init_stm32(void) {
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOCEN;
    RCC->APB2ENR |= RCC_APB2ENR_SYSCFGEN;   /* EXTI 핀 선택은 SYSCFG 에 */
    (void)RCC->APB2ENR;
    GPIOC->MODER &= ~(3u << (13 * 2));      /* 입력 */
    SYSCFG->EXTICR[3] = (SYSCFG->EXTICR[3] & ~(0xFu << 4)) | (2u << 4); /* EXTI13 ← 포트 C */
    EXTI->FTSR |= EXTI_FTSR_TR13;           /* 하강 엣지 */
    EXTI->IMR  |= EXTI_IMR_MR13;            /* 마스크 해제 */
    NVIC_SetPriority(EXTI15_10_IRQn, 5);
    NVIC_EnableIRQ(EXTI15_10_IRQn);
}

ISR 의 규칙 — 짧게 · 막지 말고 · 공유는 조심 🟡 중급

ISR 철칙 네 가지(짧게, malloc·printf 금지, volatile, 임계 구역)의 기본은 C 실전 탭 c22 에 있습니다. 여기서는 각 규칙을 어기면 실제로 무엇이 깨지는지를 봅니다.

찢어진 읽기 — volatile 로는 안 막히는 것

/* AVR: 16비트 변수는 8비트 CPU 에게 '두 번' 읽어야 하는 값 */
volatile uint16_t g_ticks;              /* 타이머 ISR 이 1 ms 마다 ++ */
ISR(TIMER1_COMPA_vect) { g_ticks++; }

uint16_t now_bad(void) {
    return g_ticks;     /* 하위 바이트 읽고 → (ISR: 0x00FF→0x0100) → 상위 바이트 읽기
                           → 0x01FF 같은 '존재한 적 없는 값' */
}

#include <util/atomic.h>
uint16_t now_ok(void) {
    uint16_t t;
    ATOMIC_BLOCK(ATOMIC_RESTORESTATE) { t = g_ticks; }   /* 읽는 동안만 차단 */
    return t;
}

/* STM32: 정렬된 32비트 읽기·쓰기는 명령 하나라 원자적.
   하지만 uint64_t 나 '읽고-더하고-쓰기'(g_count++) 는 아님 —
   main 과 ISR 이 둘 다 ++ 하면 PRIMASK 로 감쌉니다 (HW 05) */

규칙을 어기면 생기는 일

어긴 규칙증상
ISR 에서 printf · UART 대기그동안 같은·낮은 우선순위 인터럽트가 전부 밀림 → 수신 바이트 유실
ISR 에서 delay_ms()SysTick 보다 우선순위가 같거나 높은 ISR 안이면 틱이 오르지 않아 영원히 대기
공유 변수에 volatile 누락main 이 플래그 변화를 못 봄 (-O2 에서만)
플래그를 안 지움ISR 이 끝나자마자 다시 진입 — main 이 멈춘 것처럼 보임
핸들러 이름 오타STM32: Default_Handler 무한 루프 · AVR: 0번지부터 재시작 반복
이름 오타는 컴파일이 됩니다 — 스타트업 파일의 핸들러들은 weak 별칭이라 내 함수 이름이 틀리면 기본 핸들러가 남습니다. AVR 은 허용했는데 ISR 이 없는 인터럽트가 오면 __bad_interrupt 가 0번지로 점프해 프로그램이 처음부터 다시 돕니다(레지스터는 리셋되지 않은 채로). avr-gcc 는 misspelled signal handler 경고를 내니 경고를 켜 두세요.

우선순위 · 중첩 · 지연 처리 — NVIC 의 상태 기계 🔴 고급

인터럽트 하나의 일생 — 원이 상태, 화살표가 전이 비활성 Inactive 대기 Pending 실행 중 Active 실행+대기 또 걸림 사건 발생 플래그 = 1 CPU 가 받음 우선순위가 이김 ISR 끝 (예외 복귀) 실행 중에 또 발생 ISR 끝 → 곧바로 다시 더 높은 우선순위가 오면 실행 중인 ISR 위에 겹쳐 실행 (중첩 · 선점) 같은 인터럽트가 실행 중에 세 번 와도 '대기' 표시는 하나 — 횟수는 세지 않습니다
인터럽트는 횟수가 아니라 "일이 있다"는 표시입니다 — 몇 번 왔는지 세야 하면 ISR 안에서 하드웨어 카운터나 큐로 셉니다.

우선순위 설정 — 숫자가 작을수록 높다

/* STM32F4 는 우선순위 비트 4개 → 0(최고) ~ 15(최저) */
NVIC_SetPriorityGrouping(3);              /* 4비트 전부 '선점' 단계로 (그룹 4) */

NVIC_SetPriority(USART2_IRQn,   2);       /* 바이트 유실이 치명적 → 높게 */
NVIC_SetPriority(TIM2_IRQn,     4);       /* 제어 주기 */
NVIC_SetPriority(EXTI15_10_IRQn,6);       /* 버튼 — 사람은 느리다 */
/* SysTick 은 SysTick_Config() 가 최저(15)로 둠 */

/* 우선순위 4 이상(숫자 ≥ 4)만 잠깐 막고, 0~3 은 계속 받기 — BASEPRI */
__set_BASEPRI(4u << (8u - __NVIC_PRIO_BITS));  /* 상위 4비트에 정렬해서 씀 */
/* … 임계 구역 … */
__set_BASEPRI(0);                          /* 0 = 막지 않음 */
Cortex-M 의 우선순위는 레지스터의 상위 비트에 놓입니다 — 4비트 칩에서 "5"는 실제로 0x50 으로 저장됩니다. CMSIS 함수는 이 정렬을 대신 해 주지만 BASEPRI 를 직접 쓸 때는 위처럼 시프트가 필요합니다. FreeRTOS 설정에서 다시 만납니다(⏱️ FreeRTOS 탭).

지연 처리 패턴 — ISR 은 표시, 일은 main 이

#include <stdint.h>
#include <stdbool.h>

/* 사건별 비트 — ISR 이 켜고 main 이 끔 */
enum { EV_BTN = 1u << 0, EV_TICK = 1u << 1, EV_RX = 1u << 2 };
static volatile uint32_t g_events;

void EXTI15_10_IRQHandler(void) { EXTI->PR = EXTI_PR_PR13; g_events |= EV_BTN; }
void SysTick_Handler(void)      { g_events |= EV_TICK; }

static uint32_t take_events(void) {          /* 읽고 비우기를 원자적으로 */
    uint32_t pm = __get_PRIMASK();
    __disable_irq();
    uint32_t ev = g_events;
    g_events = 0;
    __set_PRIMASK(pm);
    return ev;
}

int main(void) {
    /* … 초기화 … */
    for (;;) {
        uint32_t ev = take_events();
        if (ev & EV_BTN)  on_button();        /* 무거운 일은 전부 여기서 */
        if (ev & EV_TICK) on_tick();
        if (ev == 0) __WFI();                 /* 할 일 없으면 다음 인터럽트까지 잠 */
    }
}
"확인하고 잠들기" 사이의 틈 — ev == 0 을 본 직후 인터럽트가 와서 플래그를 세우면, 그 인터럽트가 이미 CPU 를 깨운 뒤라 __WFI() 가 다음 인터럽트까지 잠들어 처리가 한 틱 늦습니다. SysTick 이 1 ms 마다 깨워 주면 최대 1 ms 지연으로 끝나지만, 엄밀히 막으려면 PRIMASK=1 상태에서 확인하고 WFI(깨어나되 ISR 은 PRIMASK 를 푼 뒤 실행) 패턴을 씁니다. 그리고 ISR 의 하드웨어 플래그는 앞부분에서 지우세요 — 마지막 줄에서 지우면 쓰기가 반영되기 전에 ISR 이 끝나 한 번 더 들어올 수 있습니다.
정리 — 인터럽트는 벡터 테이블의 주소록대로 ISR 을 부르고, ISR 은 플래그를 지우고 표시만 한 뒤 끝냅니다. 공유 변수는 volatile + 원자적 접근(AVR 16비트·STM32 의 ++ 는 임계 구역), 우선순위는 숫자가 작을수록 높고, 같은 인터럽트는 횟수가 아니라 표시 하나입니다.
HW 09

타이머 · PWM — 세는 하드웨어로 시간과 밝기를 만든다

타이머는 클럭을 세는 카운터입니다. 그런데 이 단순한 카운터 하나로 정확한 주기 인터럽트, LED 밝기 조절, 서보 모터 각도, 신호의 주파수 측정까지 합니다. CPU 는 설정만 해 두고 손을 떼며, 하드웨어가 클럭 단위로 정확하게 일합니다. 코드로 만든 지연과는 정확도의 차원이 다릅니다.

카운터가 CCR 보다 작은 동안 HIGH — 이게 PWM 의 전부입니다 ARR 999 CCR 250 0 카운터 PA5 출력 HIGH LOW 주기 1 ms = 16×1000 ÷ 16 MHz HIGH 0.25 ms → 듀티 25 % CCR 만 0~1000 사이로 바꾸면 LED 밝기가 0~100 % 로 바뀝니다
PWM 은 빠르게 켰다 껐다를 반복해 평균 전력을 조절합니다 — 1 kHz 면 눈에는 깜빡임이 아니라 밝기로 보입니다.

주기 계산 — 두 칩의 식

STM32 (업카운트)
  f_update = f_TIM ÷ ((PSC + 1) × (ARR + 1))
  TIM2 는 APB1 타이머 클럭 — 리셋 기본(HSI, 분주 없음)이면 16 MHz
    PSC = 15   → 16 MHz ÷ 16 = 1 MHz  (1 틱 = 1 µs)
    ARR = 999  → 1 MHz ÷ 1000 = 1 kHz (1 ms 주기)
    CCR1 = 250 → HIGH 250 틱 → 듀티 = CCR ÷ (ARR+1) = 25 %
  180 MHz 설정이면 APB1 타이머 = 90 MHz → PSC = 89 로 같은 1 µs

ATmega328P Timer1 (16비트)
  CTC 모드:      f = f_clk ÷ (N × (1 + OCR1A))
    N = 64, OCR1A = 249 → 16 MHz ÷ (64 × 250) = 1000 Hz
  Fast PWM(TOP=ICR1): f = f_clk ÷ (N × (1 + ICR1))
    N = 8, ICR1 = 1999  → 16 MHz ÷ (8 × 2000) = 1 kHz
    OCR1A = 499 → 듀티 = (OCR1A + 1) ÷ (ICR1 + 1) = 25 %
    ※ 비반전 Fast PWM 은 OCR+1 틱 동안 HIGH — STM32 와 '+1' 위치가 다름
"+1" 을 빼먹으면 1 kHz 를 원했는데 1.001 kHz 가 됩니다. 시계·통신처럼 오차가 쌓이는 곳에서는 하루 86초 차이입니다. PSC 와 ARR 은 "값 + 1" 로 나눈다를 주석에 적어 두세요.

1 ms 주기 인터럽트 — AVR Timer1 CTC

#include <avr/io.h>
#include <avr/interrupt.h>

volatile uint32_t g_ms;                       /* 32비트 — 읽을 땐 ATOMIC_BLOCK */

ISR(TIMER1_COMPA_vect) { g_ms++; }

void tick_init(void) {
    TCCR1A = 0;                               /* 출력 핀 안 씀 */
    TCCR1B = (1 << WGM12)                     /* 모드 4: CTC (TOP = OCR1A) */
           | (1 << CS11) | (1 << CS10);       /* 분주 64 → 250 kHz */
    OCR1A  = 249;                             /* 250 틱마다 → 1 kHz */
    TIMSK1 = (1 << OCIE1A);                   /* 비교 일치 인터럽트 허용 */
    sei();
}
/* Arduino 코어는 Timer0 으로 millis() 를 만들어 두었으므로,
   Arduino 위에서 섞어 쓸 때는 Timer1·2 를 고릅니다 */
STM32 에서는 같은 일을 SysTick 이 합니다 — 모든 Cortex-M 에 있는 24비트 코어 타이머라 칩이 바뀌어도 코드가 같습니다(HW 13). 범용 타이머(TIM2 등)는 PWM·캡처처럼 핀과 엮이는 일에 아껴 둡니다.

PWM 실전 — LD2 밝기와 서보 모터 🟡 중급

STM32: PA5 = TIM2_CH1 (AF1) 로 LED 밝기

void led_pwm_init(void) {          /* 클럭은 리셋 기본 HSI 16 MHz 가정 */
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
    RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;
    (void)RCC->APB1ENR;

    /* PA5: MODER = 10(대체 기능), AFRL5 = 1 (TIM2_CH1) — HW 06 의 표 */
    GPIOA->MODER  = (GPIOA->MODER  & ~(3u   << (5 * 2))) | (2u << (5 * 2));
    GPIOA->AFR[0] = (GPIOA->AFR[0] & ~(0xFu << (5 * 4))) | (1u << (5 * 4));

    TIM2->PSC   = 15;                  /* 16 MHz ÷ 16 = 1 MHz */
    TIM2->ARR   = 999;                 /* 1 kHz */
    TIM2->CCR1  = 250;                 /* 25 % */
    TIM2->CCMR1 = (6u << TIM_CCMR1_OC1M_Pos)   /* 110 = PWM 모드 1 */
                | TIM_CCMR1_OC1PE;             /* CCR 변경은 다음 주기부터 */
    TIM2->CCER  = TIM_CCER_CC1E;       /* 채널 1 출력 켬 */
    TIM2->CR1   = TIM_CR1_ARPE;
    TIM2->EGR   = TIM_EGR_UG;          /* 미리 쓴 PSC·CCR 을 지금 적재 ★ */
    TIM2->CR1  |= TIM_CR1_CEN;         /* 카운터 시작 */
}

void led_set_percent(uint32_t pct) {   /* 0~100 */
    if (pct > 100u) pct = 100u;
    TIM2->CCR1 = pct * 10u;            /* (ARR+1) = 1000 → 1 % = 10 틱 */
}
EGR 의 UG 를 빼먹으면 — PSC 는 "다음 업데이트 때" 적용되는 버퍼 레지스터라, 첫 주기가 분주 없이(16 MHz 로) 돌아 첫 펄스가 이상해집니다. 한 번은 괜찮아 보여도 측정 장비에는 그대로 찍힙니다.

AVR: D9(PB1, OC1A) 로 서보 50 Hz

#include <avr/io.h>

/* 서보: 20 ms 주기, 펄스 1.0 ms(0°) ~ 1.5 ms(90°) ~ 2.0 ms(180°)
   N = 8 → 1 틱 = 0.5 µs,  ICR1 = 39999 → 40000 틱 = 20 ms = 50 Hz */
void servo_init(void) {
    DDRB  |= (1 << DDB1);                          /* OC1A 핀을 출력으로 */
    TCCR1A = (1 << COM1A1)                         /* 비반전 */
           | (1 << WGM11);                         /* 모드 14 = WGM13:10 = 1110 */
    TCCR1B = (1 << WGM13) | (1 << WGM12)
           | (1 << CS11);                          /* 분주 8 */
    ICR1   = 39999;                                /* TOP */
    OCR1A  = 2999;                                 /* (2999+1) × 0.5 µs = 1.5 ms */
}

void servo_set_us(uint16_t us) {                   /* 1000 ~ 2000 µs */
    if (us < 1000) us = 1000;
    if (us > 2000) us = 2000;
    OCR1A = (uint16_t)(us * 2u - 1u);              /* µs → 0.5 µs 틱, +1 보정 */
}
us * 2u 는 AVR 에서 16비트 unsigned 연산이지만 최대 4000 이라 넘치지 않습니다. 범위를 더 넓힐 때는 (uint32_t) 로 먼저 올리세요 — 정수 폭 함정은 HW 10 에서 실제로 터지는 예를 봅니다.

입력 캡처 — 들어오는 신호의 시각을 하드웨어가 찍는다 🔴 고급

주파수 재기 — AVR ICP1(D8, PB0)

#include <avr/io.h>
#include <avr/interrupt.h>

volatile uint16_t g_period_ticks;          /* 0.5 µs 단위 */

ISR(TIMER1_CAPT_vect) {
    static uint16_t last;
    uint16_t now = ICR1;                   /* 엣지 순간의 카운터 값 — 하드웨어가 찍어 둠 */
    g_period_ticks = (uint16_t)(now - last);   /* 한 번 넘침은 부호 없는 뺄셈이 흡수 */
    last = now;
}

void capture_init(void) {
    TCCR1A = 0;                            /* 일반 모드, 0~65535 반복 */
    TCCR1B = (1 << ICNC1)                  /* 노이즈 캔슬러 (4 샘플 확인) */
           | (1 << ICES1)                  /* 상승 엣지 */
           | (1 << CS11);                  /* 분주 8 → 0.5 µs */
    TIMSK1 = (1 << ICIE1);
    sei();
}
/* 주파수 = 2,000,000 ÷ 주기틱.  2000 틱이면 1 kHz
   한계: 65536 × 0.5 µs = 32.8 ms 보다 긴 주기(약 30 Hz 미만)는
   넘침을 세거나 분주를 키워야 함 */

타이머로 하는 일 — 한 장 요약

기능하드웨어가 하는 일예
업데이트 인터럽트ARR 에 닿을 때마다 사건1 ms 틱, 제어 루프 주기
출력 비교 / PWMCCR 과 같아지면 핀을 바꿈LED 밝기, 모터 속도, 서보
입력 캡처엣지 순간의 카운터 값을 저장주파수·펄스 폭, 초음파 거리
엔코더 모드A/B 두 신호로 위·아래 세기모터 회전 위치
원펄스 · 트리거한 번만 정확한 폭의 펄스ADC 변환 시작 신호
인터럽트 지연과 무관하게 정확한 것이 핵심입니다 — 캡처 값은 엣지가 온 그 클럭의 카운터이고, ISR 이 언제 그걸 읽든 값은 같습니다. 소프트웨어로 millis() 를 찍으면 ISR 진입 지연만큼 흔들립니다.
정리 — 타이머 주기는 f ÷ ((PSC+1)(ARR+1)), PWM 듀티는 CCR ÷ (ARR+1)(AVR Fast PWM 은 OCR+1). APB 분주가 있으면 타이머 클럭은 2배이고, 설정 뒤에는 UG 로 한 번 적재합니다. 시간을 "기다리지" 말고 하드웨어가 세게 하세요.
HW 10

ADC — 전압을 숫자로 바꾸는 법과 그 숫자를 믿는 법

세상은 아날로그입니다. 온도·빛·배터리 잔량·조이스틱 위치가 전부 연속적인 전압으로 들어오고, ADC(아날로그-디지털 변환기)가 그걸 0~1023 이나 0~4095 같은 정수로 바꿉니다. 변환식은 한 줄이지만, 그 숫자가 얼마나 정확하고 얼마나 흔들리는지를 모르면 "센서 값이 튀어요"라는 이슈가 끝나지 않습니다.

ADC 는 계단입니다 — 이해를 위해 3비트(8칸)로 줄여 그림 0 7 코드 0 V 1.65 V 3.3 V 1 LSB 1 LSB = Vref ÷ 2ⁿ 12비트 · 3.3 V → 0.806 mV 10비트 · 5 V → 4.88 mV 점선 = 이상적인 직선 계단과의 틈 = 양자화 오차 전압 = raw × Vref ÷ 4095 (ST 관례) · raw × Vref ÷ 1024 (AVR 데이터시트)
ADC 결과는 "기준 전압의 몇 분의 몇"입니다 — 그래서 기준 전압이 흔들리면 모든 값이 같이 흔들립니다.

두 칩의 ADC

ATmega328PSTM32F446
해상도10비트 (0~1023)12비트 (0~4095)
기준 전압AVcc(5 V) · 내부 1.1 V · AREF 핀VREF+ (Nucleo 는 3.3 V 에 연결)
ADC 클럭50~200 kHz 권장 → 16 MHz ÷ 128 = 125 kHz최대 36 MHz (PCLK2 ÷ 2/4/6/8)
변환 시간13 ADC 클럭 ≈ 104 µs (첫 변환 25)샘플링 3~480 + 12 사이클
채널6개 (Uno A0~A5)16개 외부 + 온도 · VREFINT
입력 허용0 ~ AVcc0 ~ VREF+ (3.3 V 넘기면 안 됨)
1024 냐 4095 냐 — AVR 데이터시트는 ADC = Vin × 1024 ÷ Vref 로, ST 의 예제와 HAL 매크로는 4095 로 나눕니다. 둘의 차이는 1 LSB 미만이라 실무 오차(수 LSB)에 묻힙니다. 쓰는 칩 문서의 식을 그대로 따르면 됩니다.

한 번 읽기 — 두 칩 나란히

/* ── ATmega328P: A0 ── */
#include <avr/io.h>
void adc_init_avr(void) {
    ADMUX  = (1 << REFS0);                           /* 기준 AVcc, 채널 0 */
    ADCSRA = (1 << ADEN)
           | (1 << ADPS2) | (1 << ADPS1) | (1 << ADPS0);  /* ÷128 → 125 kHz */
}
uint16_t adc_read_avr(uint8_t ch) {
    ADMUX   = (uint8_t)((ADMUX & 0xF0) | (ch & 0x0F));
    ADCSRA |= (1 << ADSC);                           /* 변환 시작 */
    while (ADCSRA & (1 << ADSC)) { }                 /* 끝나면 하드웨어가 0 으로 */
    return ADC;                                      /* ADCL→ADCH 순서는 매크로가 보장 */
}

/* ── STM32F446: PA0 = ADC1_IN0 (Nucleo A0) ── */
void adc_init_stm32(void) {
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
    RCC->APB2ENR |= RCC_APB2ENR_ADC1EN;
    (void)RCC->APB2ENR;
    GPIOA->MODER |= (3u << (0 * 2));                 /* 11 = 아날로그 모드 */
    ADC123_COMMON->CCR &= ~ADC_CCR_ADCPRE;           /* 00 = PCLK2 ÷ 2 (16 → 8 MHz) */
    ADC1->SMPR2 = (ADC1->SMPR2 & ~ADC_SMPR2_SMP0)
                | (7u << ADC_SMPR2_SMP0_Pos);        /* 111 = 480 사이클 — 가장 느리고 안정 */
    ADC1->SQR1 = 0;                                  /* 변환 개수 1 */
    ADC1->SQR3 = 0;                                  /* 1번째 = 채널 0 */
    ADC1->CR2  = ADC_CR2_ADON;                       /* 켜기 — 수 µs 안정화 시간 필요 */
}
uint16_t adc_read_stm32(void) {
    ADC1->CR2 |= ADC_CR2_SWSTART;
    while (!(ADC1->SR & ADC_SR_EOC)) { }
    return (uint16_t)ADC1->DR;                       /* DR 을 읽으면 EOC 가 지워짐 */
}

raw → 전압 — 정수 폭 함정이 실제로 터지는 곳 🟡 중급

❌ AVR 에서 조용히 틀리는 한 줄
uint16_t raw = adc_read_avr(0);          /* 1023 이라 하자 */
uint16_t mv  = raw * 5000 / 1024;

/* AVR 의 int 는 16비트 — uint16_t × int 는 16비트 unsigned 로 계산
   1023 × 5000 = 5,115,000 → 65536 으로 나눈 나머지 3,192
   3,192 ÷ 1024 = 3
   → 5 V 를 넣었는데 "3 mV" — PC 에서 테스트하면 4995 가 나와 못 잡습니다 */
✅ 곱하기 전에 32비트로 올리기
uint16_t mv = (uint16_t)((uint32_t)raw * 5000u / 1024u);   /* 4995 */

/* STM32 (12비트 · 3.3 V) */
uint32_t mv32 = (uint32_t)raw12 * 3300u / 4095u;   /* 2048 → 1650 */

/* float 를 피하는 이유: AVR 은 FPU 가 없어 나눗셈 하나에
   수백 클럭 + 코드 1~2 KB. 정수 mV 로 충분합니다.
   STM32F4 는 float(단정밀)가 하드웨어라 써도 되지만,
   double 을 쓰면 소프트웨어로 떨어집니다 (3.3 이 아니라 3.3f) */

노이즈 줄이기 — 평균 두 가지

#include <stdint.h>

/* ① 이동 평균 8개 — 합을 들고 다녀 매번 8개를 더하지 않음 */
#define MA_N 8u                                /* 2의 거듭제곱 */
static uint16_t ma_buf[MA_N];
static uint8_t  ma_idx;
static uint32_t ma_sum;

uint16_t ma_push(uint16_t x) {
    ma_sum = ma_sum - ma_buf[ma_idx] + x;      /* 빠지는 값 빼고, 새 값 더하고 */
    ma_buf[ma_idx] = x;
    ma_idx = (uint8_t)((ma_idx + 1u) & (MA_N - 1u));
    return (uint16_t)(ma_sum / MA_N);
}   /* 처음 7개는 0 이 섞여 낮게 나옴 — 시작 때 버퍼를 첫 값으로 채우면 해결 */

/* ② 지수 이동 평균 — 배열 없이 변수 하나 (acc ≈ 평균 × 8) */
static uint32_t ema_acc;
uint16_t ema_push(uint16_t x) {
    ema_acc = ema_acc - (ema_acc >> 3) + x;    /* 새 값 1/8, 이전 7/8 */
    return (uint16_t)(ema_acc >> 3);
}   /* 일정한 입력이면 약 20 샘플에 93 %, 40 샘플에 99 % 따라감 */

값이 튀는 원인 — 소프트웨어보다 먼저

원인처방
기준 전압(=전원)이 흔들림AVcc 에 LC 필터·100 nF, 정밀하면 외부 기준 IC
센서 출력 임피던스가 큼샘플링 시간 늘리기, 입력에 100 nF, 버퍼 증폭기
CPU·디지털 잡음AVR 의 ADC Noise Reduction 슬립 모드, 측정 중 핀 토글 금지
무작위 잡음평균 — 16개 평균이면 잡음 약 1/4 (√16)
해상도가 모자람오버샘플링 — 4ⁿ 개 더해 n 비트 더 얻기
필터는 지연을 만듭니다 — 8개 이동 평균은 값이 바뀐 뒤 8 샘플 동안 옛 값이 섞입니다. 배터리 잔량엔 괜찮지만 모터 전류 보호처럼 빨라야 하는 곳에는 평균 대신 하드웨어 비교기(아날로그 워치독)를 씁니다.

큰 전압 재기 — 전압 분배기 🔴 실무

12 V 배터리를 3.3 V ADC 로 — 저항 두 개로 1/4 로 줄입니다 배터리 + (최대 13.2 V) R1 30 kΩ R2 10 kΩ Vout GND PA0 · ADC1_IN0 Vout = Vin × R2 ÷ (R1 + R2) = 12 × 10 ÷ 40 = 3.0 V Vin 13.2 V → 3.3 V (상한) 되돌리기: Vin = Vout × 4 늘 흐르는 전류 12 V ÷ 40 kΩ = 0.3 mA ADC 가 보는 저항 R1 ∥ R2 = 7.5 kΩ 저항을 키우면 배터리는 덜 닳지만 ADC 가 느리게 차오릅니다 — 둘의 타협
분배기는 비율로 줄입니다 — 비율을 고를 때는 입력의 최댓값이 Vref 를 넘지 않게, 저항값은 소모 전류와 ADC 입력 조건 사이에서.

배터리 전압 읽기 — 끝까지 정수로

/* R1 = 30k, R2 = 10k → 배율 (R1+R2)/R2 = 4 */
#define DIV_NUM  40u
#define DIV_DEN  10u

uint32_t battery_mv(void) {
    uint32_t sum = 0;
    for (int i = 0; i < 16; i++)              /* 16번 평균 — 잡음 1/4 */
        sum += adc_read_stm32();
    uint32_t raw = sum / 16u;                 /* 0~4095 */
    uint32_t pin_mv = raw * 3300u / 4095u;    /* 핀 전압 */
    return pin_mv * DIV_NUM / DIV_DEN;        /* 배터리 전압 */
}
/* 예: raw 3723 → 핀 3000 mV → 배터리 12000 mV
   최대 중간값: 4095 × 3300 = 13,513,500 — uint32_t 로 충분 */

/* 저항 오차 ±1% 두 개면 배율 오차가 최대 ±1.5% 안팎 —
   양산에서는 기준 전압으로 한 번 재서 보정값을 Flash 에 저장 */

분배기 값 고르기

R1 / R2소모 전류 (12 V)평가
3k / 1k3 mA배터리 제품엔 과함 — 하루 72 mAh
30k / 10k0.3 mA무난. AVR 권장(10 kΩ 이하)에도 근접
300k / 100k0.03 mA샘플링 시간 길게 + 100 nF 필수
MOSFET 스위치 + 분배잴 때만저전력 제품의 정석
입력이 Vref 를 넘으면 — 값이 4095 에 붙는 것으로 끝나지 않고, 보호 다이오드로 전류가 흘러 옆 채널 값까지 틀어지거나 핀이 상합니다. 분배기 비율은 "평소 값"이 아니라 "충전 중 최고 전압 + 여유"로 정합니다(납축전지는 충전 때 14.4 V 까지 올라갑니다 — 이 예의 13.2 V 로는 부족).
정리 — ADC 값은 raw × Vref ÷ (2ⁿ 또는 2ⁿ−1) 이고, 곱셈은 32비트로 올린 뒤(AVR 에서 특히) 합니다. 값이 튀면 기준 전압·임피던스·샘플링 시간을 먼저 보고, 그다음 평균. 큰 전압은 분배기로 줄이되 상한을 최고 전압 기준으로 잡습니다.
HW 11

부팅의 진실 — main 보다 먼저 도는 것들

PC 의 C 프로그램은 운영체제가 메모리를 준비하고 main 을 불러 줍니다. MCU 에는 그 운영체제가 없으니 누군가는 전원이 들어온 첫 클럭부터 main 까지를 직접 해야 합니다. 그 "누군가"는 하드웨어 몇 단계와 스타트업 코드 수십 줄이고, 이걸 알면 전역 변수가 0 이 아닌 버그, main 전에 죽는 버그, HardFault 의 위치가 읽힙니다.

리셋에서 main 까지 — 위아래 번갈아 적었습니다 리셋 해제 전원 · NRST 핀 PC ← [0x0000 0004] = Reset_Handler 주소 .data 복사 (Flash→RAM) .bss 를 0 으로 SP ← [0x0000 0000] = _estack (0x2002 0000) SystemInit() FPU 켜기 · VTOR(선택) 생성자 → main() for (;;) 로 영원히 하드웨어가 직접 — 코드 0줄 startup_stm32f446xx.s + system_stm32f4xx.c AVR 도 같은 줄거리 — 0번지의 jmp → SP = RAMEND → .data 복사 · .bss 0 → main
Cortex-M 은 벡터 테이블의 처음 두 워드만으로 C 함수를 부를 수 있게 설계됐습니다 — 스택과 시작 주소가 거기 있습니다.

벡터 테이블을 C 로 쓰면 — 실제 구조가 보입니다

#include <stdint.h>

extern uint32_t _estack;                       /* 링커 스크립트가 정한 RAM 끝 */
void Reset_Handler(void);

void Default_Handler(void) { for (;;) { } }    /* 정의 안 된 인터럽트의 종착지 */
void NMI_Handler(void)       __attribute__((weak, alias("Default_Handler")));
void HardFault_Handler(void) __attribute__((weak, alias("Default_Handler")));
void MemManage_Handler(void) __attribute__((weak, alias("Default_Handler")));
void BusFault_Handler(void)  __attribute__((weak, alias("Default_Handler")));
void UsageFault_Handler(void)__attribute__((weak, alias("Default_Handler")));
void SVC_Handler(void)       __attribute__((weak, alias("Default_Handler")));
void DebugMon_Handler(void)  __attribute__((weak, alias("Default_Handler")));
void PendSV_Handler(void)    __attribute__((weak, alias("Default_Handler")));
void SysTick_Handler(void)   __attribute__((weak, alias("Default_Handler")));

__attribute__((section(".isr_vector"), used))
void (* const g_vectors[])(void) = {
    (void (*)(void))&_estack,   /* [0]  초기 SP — 함수가 아니라 '주소' */
    Reset_Handler,              /* [1]  첫 실행 위치 (Thumb 비트 1 은 링커가 붙임) */
    NMI_Handler,                /* [2] */
    HardFault_Handler,          /* [3]  모든 폴트의 마지막 정류장 */
    MemManage_Handler, BusFault_Handler, UsageFault_Handler,   /* [4]~[6] */
    0, 0, 0, 0,                 /* [7]~[10] 예약 */
    SVC_Handler, DebugMon_Handler, 0, PendSV_Handler,          /* [11]~[14] */
    SysTick_Handler,            /* [15] */
    /* [16]~ 주변장치 IRQ — EXTI15_10 은 [16+40], 오프셋 0xE0 */
};
내가 SysTick_Handler 를 정의하면 weak 별칭보다 강한 심볼이라 링커가 그쪽을 테이블에 넣습니다. 이것이 "이름만 맞추면 ISR 이 된다"의 원리이고, 동시에 이름 오타가 컴파일 에러가 안 되는 이유입니다(HW 08).

Reset_Handler 를 C 로 — 스타트업이 하는 일 전부

#include <stdint.h>

extern uint32_t _sidata, _sdata, _edata, _sbss, _ebss;  /* 링커 심볼 (HW 12) */
extern void SystemInit(void);
extern void __libc_init_array(void);
extern int  main(void);

void Reset_Handler(void) {
    /* SP 는 이미 하드웨어가 [0] 에서 넣어 두었습니다 */

    uint32_t *src = &_sidata, *dst = &_sdata;
    while (dst < &_edata) *dst++ = *src++;      /* ① .data: Flash 사본 → RAM */

    for (dst = &_sbss; dst < &_ebss; ) *dst++ = 0;   /* ② .bss: 0 으로 */

    SystemInit();                               /* ③ FPU 접근 허용 등 */
    __libc_init_array();                        /* ④ 생성자 · init 함수 (newlib) */
    (void)main();                               /* ⑤ 드디어 */
    for (;;) { }                                /* ⑥ 돌아오면 여기서 멈춤 */
}
/* 이 함수가 끝나기 전까지는 '전역 변수의 초기값'을 믿으면 안 됩니다 */
CubeF4 의 실제 startup_stm32f446xx.s 는 버전에 따라 SystemInit 을 ① 보다 먼저 부릅니다 — 그래서 SystemInit 안에서는 초기값 있는 전역을 쓰면 안 됩니다. 또 -O2 의 GCC 는 ①② 루프를 memcpy·memset 호출로 바꿀 수 있어, -nostdlib 로 빌드하면 링크 에러가 납니다(-fno-tree-loop-distribute-patterns).

AVR 의 부팅 — avr-objdump 로 직접 보기 🟡 중급

LED 만 깜빡이는 프로그램의 맨 앞 (예 — 주소는 프로그램마다 다름)

$ avr-objdump -d blink.elf | head -30

00000000 <__vectors>:
   0:  0c 94 34 00   jmp  0x68     ; 리셋 → __ctors_end
   4:  0c 94 3e 00   jmp  0x7c     ; INT0 → __bad_interrupt (ISR 없음)
   …                               ; 26개 × 4바이트 = 0x68 바이트
00000068 <__ctors_end>:
  68:  11 24         eor  r1, r1   ; r1 = 0 (gcc 가 '항상 0' 으로 쓰는 레지스터)
  6a:  1f be         out  0x3f, r1 ; SREG = 0 (인터럽트 꺼진 채 시작)
  6c:  cf ef         ldi  r28, 0xFF
  6e:  d8 e0         ldi  r29, 0x08
  70:  de bf         out  0x3e, r29
  72:  cd bf         out  0x3d, r28 ; SP = 0x08FF (RAMEND — SRAM 마지막)
  74:  0e 94 40 00   call 0x80     ; main
  78:  0c 94 4d 00   jmp  0x9a     ; main 이 돌아오면 _exit
0000007c <__bad_interrupt>:
  7c:  0c 94 00 00   jmp  0        ; 처음부터 다시
…
0000009a <_exit>:
  9a:  f8 94         cli           ; 인터럽트 끄고
0000009c <__stop_program>:
  9c:  ff cf         rjmp .-2      ; 제자리 무한 루프
전역 변수가 있으면 __ctors_end 와 call main 사이에 __do_copy_data · __do_clear_bss 가 끼어듭니다 — 필요할 때만 링크되는 avr-libc 의 조각입니다.

main 이 끝나면? — 두 칩의 답

무슨 일이 일어나나
AVR (avr-libc)exit() → 인터럽트를 끄고 무한 루프. 핀 상태는 마지막 그대로
STM32 (CubeF4 스타트업)main 뒤에 확실한 정지 코드가 없거나 버전마다 달라 어디로 갈지 보장 안 됨
직접 쓴 Reset_Handler위 코드처럼 for (;;) 로 막아 둠
RTOSmain 은 스케줄러를 시작하고 돌아오지 않음
/* 그래서 펌웨어의 main 은 항상 이 모양입니다 */
int main(void) {
    hw_init();
    for (;;) {          /* 슈퍼 루프 — 절대 return 하지 않음 */
        poll_and_handle();
    }
}

실무에선 이렇게 — 부팅 모드와 리셋 원인 🔴 실무

STM32 부트 핀 — 0번지에 무엇을 비추나

BOOT1 (PB2)BOOT00x0000 0000 에 보이는 것
x0메인 Flash (0x0800 0000) — 평소. Nucleo 기본
01시스템 메모리 — ST 내장 부트로더(UART·USB DFU 로 굽기)
11내장 SRAM — 디버그용
"SWD 가 안 붙는 벽돌"을 살리는 법 — 잘못된 펌웨어가 SWD 핀을 다른 용도로 바꾸거나 바로 저전력 모드로 들어가면 디버거가 못 붙습니다. BOOT0 를 1 로 두고 리셋하면 내 코드가 아예 안 돌므로 그때 지우면 됩니다. Uno 는 같은 역할을 부트로더(optiboot)가 하고, 퓨즈 BOOTRST 가 리셋을 그쪽으로 보냅니다.

왜 재부팅됐나 — 리셋 원인 레지스터

/* STM32F4: RCC->CSR 의 플래그는 리셋해도 남음 → 읽고 지우기 */
const char *reset_cause_stm32(void) {
    uint32_t csr = RCC->CSR;
    RCC->CSR |= RCC_CSR_RMVF;                 /* 다음 부팅을 위해 지움 */
    if (csr & RCC_CSR_IWDGRSTF) return "watchdog";      /* 펌웨어가 멈췄었다 ★ */
    if (csr & RCC_CSR_SFTRSTF)  return "software";      /* NVIC_SystemReset() */
    if (csr & RCC_CSR_PORRSTF)  return "power-on";      /* 이때 BOR·PIN 도 같이 섬 */
    if (csr & RCC_CSR_BORRSTF)  return "brown-out";     /* POR 없이 BOR 만 → 전원이 처졌다 */
    if (csr & RCC_CSR_PINRSTF)  return "reset pin";
    return "unknown";
}

/* ATmega328P: MCUSR — 부트로더가 지워 버리는 경우가 있어 일찍 읽기 */
uint8_t reset_flags_avr(void) {
    uint8_t f = MCUSR;          /* PORF · EXTRF · BORF · WDRF */
    MCUSR = 0;
    return f;
}
순서에 주의 — STM32F4 는 전원 투입 때 PORRSTF 와 함께 BORRSTF · PINRSTF 도 같이 켜집니다. 그래서 위처럼 더 구체적인 원인부터 검사해야 "켤 때마다 brown-out" 같은 오판이 없습니다. 현장 장비가 "가끔 재부팅된다"면 이 값을 로그에 남기는 것만으로 원인의 절반이 드러납니다(🚀 양산 탭).
정리 — 리셋되면 Cortex-M 하드웨어가 [0] 을 SP 로, [4] 를 PC 로 읽고, 스타트업 코드가 .data 복사 · .bss 0 · SystemInit · 생성자를 끝낸 뒤 main 을 부릅니다. main 은 절대 돌아오지 않게 쓰고, 핸들러 이름은 벡터 테이블과 정확히 맞춥니다. 재부팅 이슈는 리셋 원인 레지스터부터 읽습니다.
HW 12

링커 스크립트 · 메모리 섹션 — 무엇이 어디에 놓이나

PC 에서는 실행 파일의 각 부분을 어느 주소에 올릴지 운영체제가 정합니다. MCU 에서는 링커 스크립트(.ld) 한 파일이 그 역할입니다 — 코드는 Flash 몇 번지부터, 변수는 RAM 몇 번지부터, 스택은 어디서 시작하는지. 기본 문법은 C 실전 탭 c22 에 맛보기가 있으니, 여기서는 Flash 와 RAM 에 실제로 펼쳐진 모습과 빌드 결과물에서 메모리 사용량을 읽는 법을 다룹니다.

Flash 와 RAM 의 실제 배치 — 위가 낮은 주소입니다 Flash — 0x0800 0000 부터 .isr_vector (벡터 테이블) .text — 기계어 main · ISR · 라이브러리 .rodata — const 상수 .data 초기값 사본 (LMA) 비어 있음 끝 0x0808 0000 RAM — 0x2000 0000 부터 .data — 초기값 있는 전역 (VMA) .bss — 0 으로 시작 힙 ↓ (malloc — 보통 안 씀) 빈 공간 = 안전 여유 둘이 만나면 조용히 깨짐 스택 ↑ 지역 변수 · 복귀 주소 끝 0x2002 0000 = _estack Reset_Handler 가 복사 (LMA → VMA) Flash 사용량 = text + data · RAM 사용량 = data + bss (+ 힙·스택 예약) 스택이 자라 .bss 를 덮어도 아무도 알려 주지 않습니다 — 빈 공간이 곧 수명
.data 는 두 번 존재합니다 — Flash 에 초기값 사본(LMA), RAM 에 실제 변수(VMA). 이 둘을 잇는 것이 HW 11 의 복사 루프입니다.

STM32F446RE 링커 스크립트 — 뼈대만 남긴 것

ENTRY(Reset_Handler)                     /* 디버거·ELF 의 시작점 표시 */

_estack = ORIGIN(RAM) + LENGTH(RAM);     /* 0x2002 0000 — 벡터 [0] 에 들어감 */
_Min_Heap_Size  = 0x200;                 /* 이만큼 남지 않으면 링크 실패 */
_Min_Stack_Size = 0x400;                 /* 1 KB — '최소 확인용', 제한이 아님 */

MEMORY
{
  RAM   (xrw) : ORIGIN = 0x20000000, LENGTH = 128K
  FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
}

SECTIONS
{
  .isr_vector : { . = ALIGN(4); KEEP(*(.isr_vector)) . = ALIGN(4); } >FLASH

  .text : {
    . = ALIGN(4);
    *(.text) *(.text*)                   /* 모든 .o 의 코드 */
    KEEP(*(.init)) KEEP(*(.fini))
    . = ALIGN(4);
    _etext = .;
  } >FLASH

  .rodata : { . = ALIGN(4); *(.rodata) *(.rodata*) . = ALIGN(4); } >FLASH

  .init_array : {                        /* 생성자 목록 — __libc_init_array 가 돎 */
    PROVIDE_HIDDEN(__init_array_start = .);
    KEEP(*(SORT(.init_array.*))) KEEP(*(.init_array*))
    PROVIDE_HIDDEN(__init_array_end = .);
  } >FLASH

  _sidata = LOADADDR(.data);             /* Flash 쪽 사본의 주소 */
  .data : {
    . = ALIGN(4);
    _sdata = .;  *(.data) *(.data*)  . = ALIGN(4);  _edata = .;
  } >RAM AT> FLASH                       /* ★ 실행은 RAM, 보관은 FLASH */

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

  ._user_heap_stack : {                  /* 공간 확인용 가짜 섹션 */
    . = ALIGN(8);
    PROVIDE(end = .);                    /* newlib 의 _sbrk 가 힙 시작으로 씀 */
    . = . + _Min_Heap_Size;
    . = . + _Min_Stack_Size;
    . = ALIGN(8);
  } >RAM
}

문법 다섯 개만 읽을 줄 알면 됩니다

표기뜻
MEMORY { … }칩의 실제 메모리 구간 — 데이터시트의 메모리 맵 그대로
*(.text*)모든 입력 파일의 .text… 섹션을 여기 모아라
>RAM AT> FLASH주소는 RAM(VMA) 기준으로 매기고, 내용은 FLASH(LMA)에 실어라
KEEP(…)--gc-sections 가 "안 쓰인다"고 지우지 못하게 — 벡터 테이블 필수
_sdata = .;현재 위치를 심볼로 — C 에서 extern uint32_t _sdata; 로 받아 &_sdata 로 씀
링커 심볼은 "변수"가 아니라 "주소"입니다 — extern uint32_t _sdata; 로 선언하고 반드시 &_sdata 로 씁니다. _sdata 를 값으로 읽으면 그 주소에 들어 있는 엉뚱한 데이터를 읽습니다. HW 11 의 Reset_Handler 가 전부 & 를 붙인 이유입니다.
/* 내 변수를 특정 섹션에 — 예: 리셋해도 안 지워지는 영역 */
__attribute__((section(".noinit")))
uint32_t g_crash_magic;      /* 링커 스크립트에 .noinit 을 (NOLOAD) 로 추가해야 함 */

/* 자주 부르는 ISR 을 RAM 에서 실행 (Flash 대기 없이) */
__attribute__((section(".RamFunc")))
void fast_isr_work(void) { /* … */ }

빌드 결과 읽기 — size · .map · nm 🟡 중급

arm-none-eabi-size 한 줄 해석 (HW 13 Blinky 예)

$ arm-none-eabi-size blinky.elf
   text    data     bss     dec     hex filename
   1452      12    1580    3044     be4 blinky.elf

 text = .isr_vector + .text + .rodata + .init_array …   → Flash 에만
 data = .data                                         → Flash(사본) + RAM
 bss  = .bss + ._user_heap_stack                      → RAM 에만
 dec  = 셋의 합 (1452 + 12 + 1580 = 3044 = 0xBE4)

 Flash 사용 = text + data = 1,464 바이트
 RAM   사용 = data + bss  = 1,592 바이트
          그중 1,536(0x600) 은 힙 512 + 스택 1024 예약 → 실제 전역은 56 바이트 (.data 12 + .bss 44)

# 링크할 때 바로 보기 — 링커 옵션 하나
$ … -Wl,--print-memory-usage
Memory region         Used Size  Region Size  %age Used
             RAM:        1592 B       128 KB      1.21%
           FLASH:        1464 B       512 KB      0.28%

.map 파일 — "이 바이트는 누구 것인가"

$ grep -A6 '^.data ' blinky.map      (발췌)
.data           0x20000000        0xc load address 0x080005ac
                0x20000000                _sdata = .
 …
.bss            0x2000000c       0x2c
                0x2000000c                _sbss = .
 .bss.g_ms      0x2000000c        0x4 build/main.o
 …
  → .data 의 사본은 Flash 0x080005AC (= 0x08000000 + text 1452)
  → g_ms 는 RAM 0x2000000C, 4바이트, main.o 가 만들었다

# 큰 것부터 — 무엇이 Flash 를 먹나
$ arm-none-eabi-nm --size-sort -S -C blinky.elf | tail -5
#  (주소) (크기) (종류) (이름)   T = 코드 · D = .data · B = .bss · R = 상수

# AVR 은 .data 주소가 0x800100 으로 보입니다
#   데이터 공간을 Flash 와 구분하려고 ELF 에서 0x800000 을 더해 둔 것 (SRAM 시작 0x0100)
$ avr-size blink.elf
printf 하나에 Flash 20 KB — newlib 의 printf 는 부동소수 지원까지 끌고 와 덩치가 큽니다. --specs=nano.specs(newlib-nano)를 쓰고 float 출력이 필요할 때만 -u _printf_float 를 붙이세요. 이런 걸 찾는 도구가 위의 nm --size-sort 입니다.

실무에선 이렇게 — 스택이 얼마나 남았나 재기 🔴 실무

스택 칠하기 — 높이 물의 흔적 찾기

#include <stdint.h>
#include "stm32f446xx.h"

extern uint32_t _ebss;                  /* .bss 끝 = 스택이 내려올 수 있는 한계 (malloc 안 쓸 때) */
#define PAINT 0xA5A5A5A5u

/* main 맨 앞에서 한 번 — 현재 SP 아래(아직 안 쓴 곳)를 무늬로 칠함 */
void stack_paint(void) {
    uint32_t *p   = &_ebss;
    uint32_t *top = (uint32_t *)__get_MSP() - 16;   /* 지금 쓰는 프레임은 건드리지 않게 */
    while (p < top) *p++ = PAINT;
}

/* 아무 때나 — 무늬가 남아 있는 바이트 수 = 한 번도 안 쓴 여유 */
uint32_t stack_unused_bytes(void) {
    const uint32_t *p = &_ebss;
    while (*p == PAINT) p++;
    return (uint32_t)((const uint8_t *)p - (const uint8_t *)&_ebss);
}
정적 분석도 같이 — GCC 의 -fstack-usage 는 함수마다 스택 크기를 .su 파일로 남깁니다. 재귀와 함수 포인터는 계산이 안 되므로 임베디드에서 재귀를 피하는 이유가 여기 있습니다. RTOS 를 쓰면 태스크마다 스택이 따로라 같은 측정을 태스크별로 합니다(⏱️ FreeRTOS 탭).

섹션별 체크리스트

섹션어디줄이는 법
.textFlash-Os, -ffunction-sections + --gc-sections, LTO
.rodataFlash표·문자열 정리. AVR 은 PROGMEM 으로 RAM 복사 방지
.data둘 다초기값이 0 이면 .bss 로 감 — 0 초기화는 공짜
.bssRAM큰 버퍼는 static 으로 한곳에, 크기는 2의 거듭제곱 고집 금지
스택RAM지역 배열을 줄이고, 재귀 금지, 측정 후 여유 20~30 %
힙RAM쓰지 않거나, 시작할 때만 할당하고 해제하지 않기
ATmega328P 의 2 KB 에서는 이 표가 생존 문제입니다 — Arduino IDE 가 "전역 변수가 동적 메모리의 75% 를 사용"이라고 경고하면 남은 500 바이트를 스택이 씁니다. String 객체와 PROGMEM 없는 문자열이 대표 원인입니다.
정리 — 링커 스크립트는 MEMORY(칩의 구간) + SECTIONS(무엇을 어디에)이고, .data 만 Flash 에 싣고 RAM 에서 실행합니다. size 의 text+data = Flash, data+bss = RAM 을 기억하고, 스택은 칠해서 실측합니다.
HW 13

베어메탈 Blinky 전 과정 — 레지스터만으로 LED 와 버튼

지금까지의 열두 섹션을 한 프로그램에 모읍니다. HAL 도 Arduino 도 없이 STM32F446RE 의 레지스터만으로 Nucleo 의 LD2(PA5)를 깜빡이고, SysTick 으로 1 ms 를 세고, B1 버튼(PC13)을 디바운스해 깜빡임 속도를 바꿉니다. 코드는 90 줄이 안 되지만 클럭 게이트 · 필드 쓰기 · 원자적 레지스터 · 인터럽트 · 공유 변수 · 슬립이 전부 들어 있습니다.

main 의 순서 그대로 — 괄호는 그 내용을 다룬 섹션 1 RCC->AHB1ENR |= GPIOAEN | GPIOCEN 클럭 게이트부터 — 이게 없으면 아래 쓰기가 전부 무시됩니다 (HW 07) 2 GPIOA->MODER 비트 11:10 = 01 PA5 를 범용 출력으로 — 지우고 넣기 (HW 04 · HW 05) 3 GPIOC->MODER 비트 27:26 = 00 PC13 입력 — 풀업은 보드의 저항이 담당, 누르면 0 (HW 02) 4 SysTick_Config(16000) 16 MHz ÷ 16000 = 1 kHz → 1 ms 마다 SysTick_Handler (HW 08 · HW 09) 5 for (;;) — __WFI() · 디바운스 · BSRR 1 ms 마다 깨어 버튼을 보고, 주기가 되면 BSRR 로 켜고 끄기 (HW 05 · HW 08)
초기화는 클럭 → 핀 모드 → 시간원 → 루프 순서입니다 — 어떤 MCU, 어떤 주변장치든 이 순서는 바뀌지 않습니다.

main.c — 전체 (STM32F446RE · HSI 16 MHz · CMSIS 헤더만 사용)

/* main.c — Nucleo-F446RE 베어메탈 Blinky + 버튼
 *  LD2 = PA5 (HIGH 면 켜짐) · B1 = PC13 (누르면 LOW, 보드에 풀업)
 *  클럭: 리셋 기본값 HSI 16 MHz 그대로 — PLL 을 쓰지 않으므로 SystemInit 외 설정 없음 */
#include <stdint.h>
#include <stdbool.h>
#include "stm32f446xx.h"           /* CMSIS 디바이스 헤더 (core_cm4.h 포함) */

#define LED_PIN   5u
#define BTN_PIN   13u
#define DEBOUNCE_MS 20u

static volatile uint32_t g_ms;     /* SysTick 만 쓰고 main 은 읽기만 — 32비트라 읽기는 원자적 */

void SysTick_Handler(void) { g_ms++; }   /* 이름이 벡터 테이블과 글자 하나까지 같아야 함 */

static inline void led_on(void)  { GPIOA->BSRR = 1u << LED_PIN; }
static inline void led_off(void) { GPIOA->BSRR = 1u << (LED_PIN + 16u); }
static inline bool btn_raw_pressed(void) { return (GPIOC->IDR & (1u << BTN_PIN)) == 0u; }

/* 1 ms 마다 한 번 호출 — DEBOUNCE_MS 연속으로 다른 값이어야 상태를 바꿈 (HW 02) */
static bool button_pressed_event(void) {
    static bool    stable = false;      /* 확정된 상태: 눌림? */
    static uint8_t cnt    = 0;
    bool raw = btn_raw_pressed();
    if (raw == stable) { cnt = 0; return false; }
    if (++cnt < DEBOUNCE_MS) return false;
    stable = raw;
    cnt = 0;
    return stable;                      /* 떼짐 → 눌림 순간에만 true */
}

int main(void) {
    /* ① 클럭 게이트 */
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN | RCC_AHB1ENR_GPIOCEN;
    (void)RCC->AHB1ENR;                 /* 켜진 뒤 2 클럭 — 에라타 우회용 더미 읽기 */

    /* ② PA5 출력 (MODER5 = 01). OTYPER(푸시풀) · OSPEEDR(저속)은 리셋값 그대로 */
    GPIOA->MODER = (GPIOA->MODER & ~(3u << (LED_PIN * 2u))) | (1u << (LED_PIN * 2u));

    /* ③ PC13 입력 (MODER13 = 00), 내부 풀업·풀다운 끔 (보드 저항 사용) */
    GPIOC->MODER &= ~(3u << (BTN_PIN * 2u));
    GPIOC->PUPDR &= ~(3u << (BTN_PIN * 2u));

    /* ④ SysTick: LOAD = 16000 - 1, 코어 클럭, 인터럽트 켬, 시작 — 실패하면 1 반환 */
    if (SysTick_Config(16000u) != 0u) { for (;;) { } }

    uint32_t period_ms   = 500u;        /* 반주기 — 500 이면 1 Hz 깜빡임 */
    uint32_t last_toggle = g_ms;
    uint32_t last_seen   = g_ms;
    bool     on = false;

    /* ⑤ 슈퍼 루프 */
    for (;;) {
        __WFI();                                    /* 다음 인터럽트(≤ 1 ms)까지 잠 */
        uint32_t now = g_ms;
        if (now == last_seen) continue;             /* 다른 인터럽트로 깼으면 건너뜀 */
        last_seen = now;

        if (button_pressed_event())                 /* 누를 때마다 500 ↔ 100 ms */
            period_ms = (period_ms == 500u) ? 100u : 500u;

        if ((uint32_t)(now - last_toggle) >= period_ms) {   /* 뺄셈 비교 — 49.7일 뒤 넘쳐도 안전 */
            last_toggle = now;
            on = !on;
            if (on) led_on(); else led_off();
        }
    }
}
읽어 볼 곳 세 군데 — ① now - last_toggle 처럼 뺄셈으로 비교하면 g_ms 가 2³² ms(약 49.7일) 뒤 0 으로 돌아가도 맞게 동작합니다(now >= last + period 는 틀림). ② LED 는 BSRR 로만 건드려 RMW 레이스가 없습니다. ③ __WFI() 덕분에 CPU 는 대부분 시간 잠들어 있습니다 — 같은 일을 바쁜 대기로 하면 전류가 몇 배입니다.

버튼 로직을 그림으로 — 디바운스 상태 기계 🟡 중급

1 ms 마다 핀을 한 번 읽고 한 칸씩 — 확인 중에 튀면 되돌아갑니다 떼어짐 RELEASED 누름 확인 카운트 중 눌림 PRESSED 뗌 확인 카운트 중 핀 = 0 20회 연속 0 → 눌림 이벤트 1번 중간에 1 → 처음부터 핀 = 1 20회 연속 1 확인 중에 0 이 섞이면 눌림으로 복귀 button_pressed_event() 의 stable · cnt 두 변수가 이 네 상태를 표현합니다
디바운스는 "N 번 연속 같은 값"이라는 상태 기계 — 인터럽트도 지연 함수도 필요 없고, 누를 때와 뗄 때를 똑같이 다룹니다.

빌드 · 굽기 — 명령 그대로

# 필요한 것 (🧰 설치 · 툴체인 탭): Arm GNU Toolchain(arm-none-eabi-gcc 14.x),
#   STM32CubeF4 의 CMSIS 헤더 + startup_stm32f446xx.s + system_stm32f4xx.c,
#   STM32CubeIDE 가 만든 STM32F446RETX_FLASH.ld

arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard \
  -O2 -g3 -Wall -Wextra -ffunction-sections -fdata-sections \
  -DSTM32F446xx -Icmsis/Include -Icmsis/Device/ST/STM32F4xx/Include \
  main.c system_stm32f4xx.c startup_stm32f446xx.s \
  -T STM32F446RETX_FLASH.ld -Wl,--gc-sections -Wl,-Map=blinky.map \
  -Wl,--print-memory-usage --specs=nano.specs --specs=nosys.specs \
  -o blinky.elf

arm-none-eabi-size blinky.elf

# 굽기 — Nucleo 의 ST-LINK 로 (둘 중 하나)
STM32_Programmer_CLI -c port=SWD -w blinky.elf -v -rst
openocd -f board/st_nucleo_f4.cfg -c "program blinky.elf verify reset exit"
Nucleo 는 USB 드라이브로도 굽힙니다 — PC 에 "NODE_F446RE" 디스크가 보이면 arm-none-eabi-objcopy -O binary blinky.elf blinky.bin 으로 만든 .bin 을 끌어다 놓으면 끝입니다.

같은 일을 Uno(ATmega328P) 에서 — 차이가 곧 요약

#define F_CPU 16000000UL           /* _delay_ms 가 쓰는 클럭 값 — 실제와 같아야 함 */
#include <avr/io.h>
#include <util/delay.h>

int main(void) {
    /* 클럭 게이트 없음 — AVR 주변장치는 기본으로 켜져 있음 (HW 07) */
    DDRB |= (1 << DDB5);           /* PB5 = D13 = LED 'L' 출력 */
    for (;;) {
        PINB = (1 << PINB5);       /* PIN 에 1 쓰기 = 토글, 원자적 (HW 05) */
        _delay_ms(500);            /* 바쁜 대기 — 간단하지만 CPU 를 잡아둠 */
    }
}
avr-gcc -mmcu=atmega328p -Os -Wall -o blink.elf main.c
avr-objcopy -O ihex -R .eeprom blink.elf blink.hex
avrdude -c arduino -p m328p -P COM3 -b 115200 -U flash:w:blink.hex:i
#        └ Uno 의 부트로더(optiboot)로 USB 시리얼을 통해 굽기

안 될 때 — 증상별로 의심할 곳 🔴 실무

증상가장 흔한 원인확인법
LED 가 전혀 안 켜짐클럭 게이트 누락 · MODER 필드를 OR 만 함 · 다른 핀디버거로 GPIOA->MODER 가 0xA8000400 인지
LED 가 꺼진 채 멈춤SysTick_Handler 이름 오타 → 첫 틱에 Default_Handler 무한 루프디버거로 멈춰 보면 PC 가 Default_Handler 안
깜빡임이 11배 빠름/느림클럭을 바꿨는데 SysTick 값은 16 MHz 기준SysTick_Config(SystemCoreClock / 1000) 로
버튼 한 번에 여러 번 반응디바운스 없이 엣지를 셈DEBOUNCE_MS 를 늘려 보기
-O0 에선 되고 -O2 에서 멈춤g_ms 에 volatile 누락선언 확인 (HW 04)
디버거가 자꾸 끊김__WFI() 슬립 중 디버그 연결DBGMCU->CR |= DBGMCU_CR_DBG_SLEEP
main 전에 HardFault링커 스크립트 RAM 크기 오류 · 벡터 테이블 미포함.map 에서 .isr_vector 가 0x0800 0000 인지 (HW 12)
다음은 어디로 — 같은 원리를 칩별로 깊게: 🔷 AVR · ATmega 탭(타이머·UART·EEPROM·저전력을 데이터시트 순서로) → 🟦 STM32 · Cortex-M 탭(CubeMX · HAL/LL · DMA · NVIC 심화) → 📡 통신 · 드라이버 탭(UART · I²C · SPI 를 드라이버로). 막히면 🐞 디버깅 · 테스트 탭의 SWD 디버거·HardFault 해석을 먼저 보세요.
정리 — 베어메탈 Blinky 는 클럭 켜기 → MODER 필드 쓰기 → SysTick 1 ms → WFI 루프에서 BSRR 다섯 줄기이고, 버튼은 1 ms 샘플링 + 20회 연속으로 디바운스합니다. 이 90 줄을 설명할 수 있으면 HAL 이 내부에서 무엇을 하는지 전부 읽을 수 있습니다.
STM32F446RE (Nucleo-F446RE) · Cortex-M4F 180 MHz · STM32CubeMX · HAL · LL · CMSIS · DMA · NVIC · HardFault · 저전력

마우스 클릭으로 만든
코드 아래에서
무슨 일이 일어나나

현업 STM32 펌웨어의 대부분은 STM32CubeMX 가 생성한 코드 + HAL 위에서 시작합니다. 클릭 몇 번이면 UART 가 돌고 DMA 가 붙습니다. 문제는 그다음입니다 — 재생성했더니 코드가 사라지고, ISR 에서 HAL_Delay 를 불렀더니 멈추고, DMA 버퍼가 깨지고, HardFault 가 납니다. 이 탭은 Nucleo-F446RE(Cortex-M4F, 180 MHz, Flash 512 KB, SRAM 128 KB) 하나를 기준으로 CubeMX 프로젝트를 실무 구조로 짜는 법, 클럭·GPIO·NVIC·UART·타이머·ADC·DMA·Flash 를 HAL 로 다루는 법, 그리고 HAL 아래(LL · CMSIS · 레지스터)에서 실제로 무슨 일이 일어나는지를 함께 봅니다. 이 탭을 마치면 CubeMX 프로젝트를 받아 구조를 읽고, 주변장치를 붙이고, HardFault 를 스스로 추적할 수 있게 됩니다.

13SECTIONS
F446RE기준 칩 (Nucleo-64)
HALSTM32CubeF4 최신 안정
4계층 (레지스터→HAL)
STM 01

STM32 와 Cortex-M — 시리즈 지도와 보드 고르기

"STM32" 는 칩 하나가 아니라 수천 개 품번의 가족입니다. 모두 ARM 의 Cortex-M 코어를 쓰고, ST 가 그 둘레에 주변장치(UART · 타이머 · ADC …)를 붙여 시리즈별로 성격을 나눴습니다. 서버로 치면 EC2 인스턴스 패밀리를 고르는 일과 같습니다 — 계산형(c), 메모리형(r), 범용(m)처럼 STM32 도 고성능(H7) · 저전력(L4/U5) · 모터 제어(G4) · 범용(F4/G0)으로 나뉩니다.

STM32 시리즈 지도 — 진할수록 그 성질이 강합니다 (가격은 진할수록 비쌈) 최대 클럭 저전력 보안 가격대 C0 · M0+ 48 MHz 보통 — 최저 · 8비트 대체 F0 · M0 48 MHz 보통 — 저가 · 구형 G0 · M0+ 64 MHz 좋음 일부 저가 · 보급형 F1 · M3 72 MHz 낮음 — 중 · 신규 비추천 F4 · M4F ★ 84~180 MHz 낮음 — 중 · 범용 G4 · M4F 170 MHz 보통 일부 중 · 모터 · 전원 L4 · M4F 80~120 MHz 매우 좋음 일부 중 · 배터리 U5 · M33 160 MHz 최고 TrustZone 중상 · IoT H5 · M33 250 MHz 보통 TrustZone 중상 · 신규 고성능 H7 · M7 480~600 MHz 낮음 일부 상 · 캐시 · 그래픽 같은 시리즈 안에서도 품번마다 핀 수 · Flash · RAM 이 다릅니다 — 표는 시리즈의 성격만 C0 · G0 · G4 · H5 · U5 가 요즘 신규 설계의 주력, F1 · F0 는 유지보수용입니다
시리즈는 코어 + 주변장치 성격으로 나뉩니다 — 이 탭의 기준은 범용 고성능 F4 (F446RE) 입니다.

Cortex-M 코어 다섯 가지 — 무엇이 다른가

코어아키텍처핵심 차이
M0 / M0+Armv6-M하드웨어 나눗셈 없음 · 비정렬 접근 불가 · 우선순위 2비트(4단계)
M3Armv7-M하드웨어 나눗셈 · 비트필드 명령 · 우선순위 최대 8비트
M4(F)Armv7E-MM3 + DSP(SIMD) 명령 + 단정밀도 FPU(F 표시)
M7Armv7E-M6단 슈퍼스칼라 · I/D 캐시 · TCM · 배정밀도 FPU(선택)
M33Armv8-MTrustZone(보안/비보안 분리) · 스택 한계 레지스터
F446RE 의 "F"는 FPU 입니다 — 코어는 Cortex-M4 이고 float(32비트) 연산이 하드웨어로 됩니다. double 은 소프트웨어로 흉내 내므로 수십 배 느립니다. 1.0 대신 1.0f, sin 대신 sinf 를 습관으로 만드세요. -Wdouble-promotion 경고가 도와줍니다.

품번 읽기 — STM32F446RET6

STM32 F 4 46 R E T 6
      │ │ │  │ │ │ └ 온도 범위   6 = -40~85 ℃   7 = -40~105 ℃
      │ │ │  │ │ └── 패키지     T = LQFP   U = UFQFPN   H = BGA
      │ │ │  │ └──── Flash 크기 8=64K  B=128K  C=256K  E=512K  G=1M  I=2M
      │ │ │  └────── 핀 수      K=32  C=48  R=64  V=100  Z=144
      │ │ └───────── 라인       46 = F446 (180 MHz, USB OTG, SAI, QSPI)
      │ └─────────── 시리즈 번호 4 = Cortex-M4F 계열
      └───────────── 종류       F = 범용(Foundation)  L = 저전력  H = 고성능  G = 차세대 범용
/* 내 보드의 칩이 정말 F446 인지 코드로 확인 */
uint32_t dev  = HAL_GetDEVID();                       /* 0x421 = STM32F446 */
uint32_t rev  = HAL_GetREVID();                       /* 실리콘 리비전 */
uint16_t kb   = *(const uint16_t *)FLASHSIZE_BASE;   /* 0x1FFF7A22 → 512 */
uint32_t uid0 = HAL_GetUIDw0();                       /* 96비트 고유 ID 중 첫 워드 */
고유 ID(UID) 는 공장에서 칩마다 다르게 새겨진 96비트 값입니다 — 서버의 머신 ID 처럼 기기 일련번호 · 라이선스 키 · 블루투스 MAC 주소 파생에 씁니다(양산 탭).

보드 고르기 — 처음 하나는 Nucleo-64 🟡 중급

흔히 만나는 보드

보드칩평가
Nucleo-F446REF446RE★ 이 탭 기준. ST-LINK 디버거 내장 · 가상 COM 포트 · 아두이노 헤더
Nucleo-G431RB / G474REG4모터 · 디지털 전원 공부용
Nucleo-H743ZI2 / H753ZIH7이더넷 · 캐시 · 고성능. 첫 보드로는 복잡
Nucleo-U575ZI-QU5저전력 · TrustZone 실습
Black Pill (F411CE)F411작고 싸다. 디버거 별도 필요
Blue Pill (F103C8)F103?복제 칩이 많음 — 비추천

Nucleo-F446RE 에서 이 탭이 쓰는 핀

보드 표기핀용도
LD2 (초록 LED)PA5GPIO 출력 · TIM2_CH1 PWM
B1 (파란 버튼)PC13누르면 0 (외부 풀업) · EXTI13
가상 COM 포트PA2 / PA3USART2 TX / RX → ST-LINK → PC USB
HSE 입력PH0ST-LINK 의 8 MHz MCO (바이패스)
SWDPA13 / PA14디버거 전용 — 다른 용도로 쓰지 말 것
PA13 · PA14 를 GPIO 로 바꾸면 다음 리셋부터 디버거가 붙지 않습니다. 살리려면 NRST 를 누른 채 연결(STM32CubeProgrammer 의 "Connect under reset")하세요. HSE 가 ST-LINK MCO 로 연결돼 있는지는 보드 리비전 · 납땜 브리지(SB)에 따라 다르니 사용자 매뉴얼 UM1724 에서 확인합니다.

실무에선 이렇게 — 칩을 고르는 순서 🔴 실무

체크리스트 — 위에서부터 걸러 냅니다

1. 필요한 주변장치 개수   UART 4개? CAN-FD? USB HS? 이더넷?
   → STM32CubeMX 의 MCU Selector 에서 "Peripheral" 필터로 거르기
2. 핀 수 · 패키지          손납땜이면 LQFP, 공간이 급하면 QFN
3. Flash · RAM             OTA(양산 탭)를 하면 Flash 를 2배 이상 잡는다
4. 전력                    배터리면 L4 · U5 · G0, 상시 전원이면 무관
5. 공급 · 수명             ST "Longevity commitment" 10년 이상 품번인지
6. 가격                    마지막에 본다 — 수량 1만 개 이상일 때만 의미 있음
같은 패밀리 안에서 핀 호환 되는 상위 품번을 골라 두면, 개발 중 Flash 가 모자랄 때 보드를 다시 그리지 않고 칩만 바꿀 수 있습니다(예: F446RC → F446RE).

F4 와 다른 시리즈 — 코드가 얼마나 달라지나

항목F4G0 · G4 · L4 · U5 · H7
HAL API기준거의 같음 — 함수명 대부분 동일
GPIO 레지스터MODER/BSRR같음 (F1 만 CRL/CRH 로 다름)
UART 레지스터SR · DRISR · RDR · TDR 로 바뀜
DMA스트림 + 채널DMAMUX (아무 요청이나 아무 채널에)
Flash 쓰기 단위바이트~워드더블워드(64비트) · 쿼드워드
HAL 의 존재 이유가 이 표입니다 — 레지스터는 시리즈마다 달라도 HAL_UART_Transmit() 은 같습니다. 다음 절에서 그 대가(크기 · 속도)를 봅니다.
정리 — STM32 는 Cortex-M 코어 + ST 주변장치의 가족이고, 시리즈는 용도별 인스턴스 타입입니다. 처음엔 디버거가 내장된 Nucleo-64 로 시작하고, 칩 선택은 주변장치 → 핀 → 메모리 → 전력 → 수명 → 가격 순서로 거릅니다.
STM 02

소프트웨어 계층 — 레지스터 · CMSIS · LL · HAL, 같은 LED 를 네 번 켭니다

STM32 코드는 네 가지 높이에서 쓸 수 있습니다. 맨 아래는 메모리 주소에 직접 값을 쓰는 것이고, 맨 위는 HAL 이라는 ST 의 공식 드라이버 라이브러리입니다. 서버로 치면 소켓을 직접 여는 것과 HTTP 클라이언트 라이브러리를 쓰는 것의 차이 — 위로 갈수록 편하고 이식성이 높지만 크고 느립니다. 실무 펌웨어는 대부분 HAL 로 시작하고, 시간이 빠듯한 곳만 LL 이나 레지스터로 내려갑니다.

아래가 위를 떠받칩니다 — 앱은 어느 층이든 직접 부를 수 있습니다 내 애플리케이션 (App/) 기능 · 상태 머신 · 프로토콜 — 칩을 몰라야 좋은 층 HAL — stm32f4xx_hal_*.c 핸들 구조체 · 상태(READY/BUSY) · 타임아웃 · 콜백 시리즈가 바뀌어도 함수 이름이 거의 그대로 LL — stm32f4xx_ll_*.h 레지스터 필드 하나 = static inline 함수 하나 상태도 콜백도 없음 · 레지스터 수준과 거의 같은 크기 CMSIS — stm32f446xx.h · core_cm4.h GPIOA->MODER 처럼 주소에 이름을 붙인 구조체 NVIC · SysTick · SCB 함수는 ARM 공통 하드웨어 레지스터 0x4002 0000 같은 주소 — 쓰면 핀 전압이 바뀝니다 작고 빠름 칩종속 HAL 의 일부 모듈(USB · SDMMC · FMC)은 내부에서 LL 을 부릅니다 — 둘은 섞어 써도 됩니다
레이어 스택 — 내려갈수록 작고 빠르고 칩에 묶입니다. 앱 층만큼은 칩을 모르게 짭니다.

① 레지스터 — 헤더 없이 주소에 직접

#include <stdint.h>
/* RM0390(F446 레퍼런스 매뉴얼)의 메모리 맵에서 주소를 직접 옮겨 적습니다 */
#define RCC_AHB1ENR (*(volatile uint32_t *)0x40023830u)  /* RCC 0x40023800 + 0x30 */
#define GPIOA_MODER (*(volatile uint32_t *)0x40020000u)  /* GPIOA + 0x00 */
#define GPIOA_ODR   (*(volatile uint32_t *)0x40020014u)  /* GPIOA + 0x14 */

int main(void)
{
    RCC_AHB1ENR |= (1u << 0);            /* bit0 GPIOAEN — 클럭부터 켠다 */
    (void)RCC_AHB1ENR;                   /* 한 번 읽어 클럭이 실제로 켜질 시간을 번다 */
    GPIOA_MODER = (GPIOA_MODER & ~(3u << (5 * 2)))
                |  (1u << (5 * 2));      /* PA5 의 2비트 = 01 (출력) */
    for (;;) {
        GPIOA_ODR ^= (1u << 5);          /* 읽고-바꾸고-쓰기 (원자적이지 않음) */
        for (volatile uint32_t i = 0; i < 500000u; i++) { }   /* 대충 기다리기 */
    }
}
volatile 이 빠지면 컴파일러가 "같은 주소에 두 번 쓰네?" 하고 앞의 쓰기를 지웁니다. 하드웨어 레지스터 포인터에는 반드시 붙입니다(volatile 자체는 C 실전 탭 c22 참고).

② CMSIS — 주소에 이름이 붙었을 뿐

#include "stm32f4xx.h"        /* -DSTM32F446xx 로 stm32f446xx.h 를 고름 */

int main(void)
{
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
    __DSB();                                      /* 쓰기가 끝날 때까지 대기 */
    GPIOA->MODER = (GPIOA->MODER & ~GPIO_MODER_MODER5)
                 | GPIO_MODER_MODER5_0;           /* 01 = 출력 */
    for (;;) {
        GPIOA->BSRR = GPIO_BSRR_BS5;              /* 켜기 — 원자적 */
        for (volatile uint32_t i = 0; i < 500000u; i++) { }
        GPIOA->BSRR = GPIO_BSRR_BR5;              /* 끄기 — 원자적 */
        for (volatile uint32_t i = 0; i < 500000u; i++) { }
    }
}

GPIOA 는 ((GPIO_TypeDef *)GPIOA_BASE) 매크로이고, GPIO_TypeDef 는 MODER · OTYPER · OSPEEDR · PUPDR · IDR · ODR · BSRR · LCKR · AFR[2] 를 주소 순서대로 늘어놓은 구조체입니다. ①과 기계어가 사실상 같습니다. BSRR 은 아래 16비트에 1을 쓰면 켜지고 위 16비트에 1을 쓰면 꺼지는 레지스터라, 다른 핀을 건드리지 않고 한 번의 쓰기로 끝납니다(m05).

③ LL — 레지스터 한 줄을 인라인 함수 한 줄로

#include "stm32f4xx_ll_bus.h"
#include "stm32f4xx_ll_gpio.h"
#include "stm32f4xx_ll_utils.h"

int main(void)
{
    LL_Init1msTick(SystemCoreClock);     /* SysTick 을 1 ms 로 (인터럽트 없이 폴링용) */
    LL_AHB1_GRP1_EnableClock(LL_AHB1_GRP1_PERIPH_GPIOA);
    LL_GPIO_SetPinMode(GPIOA, LL_GPIO_PIN_5, LL_GPIO_MODE_OUTPUT);
    LL_GPIO_SetPinOutputType(GPIOA, LL_GPIO_PIN_5, LL_GPIO_OUTPUT_PUSHPULL);
    for (;;) {
        LL_GPIO_TogglePin(GPIOA, LL_GPIO_PIN_5);   /* 내부: ODR 읽고 BSRR 한 번 쓰기 */
        LL_mDelay(500);
    }
}

④ HAL — CubeMX 가 만들어 주는 모양

#include "main.h"                    /* stm32f4xx_hal.h 를 끌고 옴 */

int main(void)
{
    HAL_Init();                         /* SysTick 1 ms 인터럽트 · NVIC 그룹 설정 */
    SystemClock_Config();               /* 180 MHz (m04) */

    __HAL_RCC_GPIOA_CLK_ENABLE();
    GPIO_InitTypeDef g = {0};
    g.Pin   = GPIO_PIN_5;
    g.Mode  = GPIO_MODE_OUTPUT_PP;
    g.Pull  = GPIO_NOPULL;
    g.Speed = GPIO_SPEED_FREQ_LOW;
    HAL_GPIO_Init(GPIOA, &g);           /* MODER·OTYPER·OSPEEDR·PUPDR 를 한꺼번에 */

    for (;;) {
        HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);
        HAL_Delay(500);                 /* SysTick 이 올려 주는 uwTick 을 기다림 */
    }
}
LED 깜빡이 하나의 Flash 크기 — 대략 (arm-none-eabi-gcc -Os, 벡터 테이블 포함) 0 1 2 3 4 5 KB ① 레지스터 ≈ 0.6 KB · 토글 수 사이클 ② CMSIS ≈ 0.8 KB · SystemInit 포함 ③ LL ≈ 1.2 KB · 인라인이라 ②와 비슷 ④ HAL ≈ 4.5 KB · RCC 설정이 대부분 512 KB 칩에서 4 KB 는 1% 미만 — 크기보다 "어디서 시간이 새나"가 더 중요합니다
막대 — HAL 의 비용은 고정비입니다. 기능이 늘어도 차이는 비례해서 커지지 않습니다.

비교표 — 언제 무엇을 쓰나 🟡 중급

레지스터CMSISLLHAL
코드 크기최소최소작음큼 (수 KB 고정비)
호출 비용명령 1~3개같음같음 (인라인)함수 호출 + 상태 검사
시리즈 이식성없음낮음중간 (이름 규칙 유사)높음
DMA · 인터럽트 관리전부 직접전부 직접직접 (도우미 함수만)상태 머신 + 콜백 제공
CubeMX 코드 생성——지원 (주변장치별 선택)지원 (기본값)
읽는 사람매뉴얼 필요매뉴얼 필요이름으로 짐작이름으로 이해
추천 자리부트로더 · 학습ISR 안 한두 줄고속 루프 · 작은 칩나머지 전부

섞어 쓰는 실무 패턴

/* 초기화는 HAL(CubeMX 생성), 고속 경로만 LL·레지스터 */
void TIM6_DAC_IRQHandler(void)          /* 100 kHz 로 불리는 ISR */
{
    if (LL_TIM_IsActiveFlag_UPDATE(TIM6)) {
        LL_TIM_ClearFlag_UPDATE(TIM6);
        GPIOA->BSRR = (sample() > THRESH) ? GPIO_BSRR_BS5
                                          : GPIO_BSRR_BR5;
    }
    /* HAL_TIM_IRQHandler(&htim6) 를 부르지 않음
       → 플래그 8종을 차례로 검사하는 분배 비용을 아낌 */
}
CubeMX 에서 섞는 법 — Project Manager → Advanced Settings 에서 주변장치마다 HAL / LL 을 고를 수 있습니다. LL 을 고르면 그 주변장치의 초기화 코드도 LL 로 생성됩니다.

HAL_GPIO_TogglePin 의 실제 구현

/* STM32CubeF4 HAL (요즘 버전) — 요약 */
void HAL_GPIO_TogglePin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin)
{
    uint32_t odr;
    assert_param(IS_GPIO_PIN(GPIO_Pin));      /* USE_FULL_ASSERT 일 때만 검사 */
    odr = GPIOx->ODR;
    /* 켜진 핀은 위 16비트(리셋)로, 꺼진 핀은 아래 16비트(셋)로 */
    GPIOx->BSRR = ((odr & GPIO_Pin) << GPIO_NUMBER) | (~odr & GPIO_Pin);
}
HAL 은 마법이 아닙니다 — 열어 보면 대부분 레지스터 몇 줄 + 검사입니다. Drivers/STM32F4xx_HAL_Driver/Src 의 소스를 읽는 습관이 HAL 함정(m13)을 피하는 가장 빠른 길입니다. 예전 버전은 ODR ^= Pin 이었는데, ISR 과 겹치면 다른 핀이 되돌아가는 문제가 있어 BSRR 방식으로 바뀌었습니다.

실무에선 이렇게 — 읽고-바꾸고-쓰기 경쟁 🔴 실무

❌ ODR 을 main 과 ISR 이 함께 고침
/* main */
GPIOA->ODR |= (1u << 5);   /* ① ODR 읽음 ── 여기서 인터럽트 ──┐  */
                            /* ③ 옛 값 | bit5 를 씀 → bit6 꺼짐!  */
/* ISR */
GPIOA->ODR |= (1u << 6);   /* ② bit6 켬                         */
✅ BSRR 한 번 쓰기 — 다른 비트는 안 건드림
/* main */
GPIOA->BSRR = GPIO_BSRR_BS5;   /* 읽기가 없으니 끼어들 틈이 없음 */

/* ISR */
GPIOA->BSRR = GPIO_BSRR_BS6;
같은 함정이 다른 레지스터에도 있습니다 — TIMx->DIER |= … 처럼 main 과 ISR 이 같은 레지스터를 읽고-바꾸고-쓰면 똑같이 깨집니다. BSRR 같은 "셋/리셋 전용" 레지스터가 없는 곳은 __disable_irq() / __enable_irq() 로 짧게 감싸거나, 한쪽에서만 쓰도록 설계합니다.
정리 — 레지스터 → CMSIS → LL → HAL 은 같은 레지스터 쓰기를 점점 두껍게 포장한 것입니다. 초기화와 일반 경로는 HAL, 마이크로초가 아까운 ISR 만 LL·레지스터로 내려가는 것이 실무의 기본 배합입니다.
STM 03

CubeMX 프로젝트 해부 — 무엇이 생성되고, 무엇이 내 것인가

STM32CubeMX 는 핀 · 클럭 · 주변장치를 그림으로 고르면 초기화 코드를 생성해 주는 도구입니다. 스프링 이니셜라이저처럼 프로젝트 뼈대를 만들어 주는데, 결정적인 차이가 하나 있습니다 — 설정을 바꿀 때마다 다시 생성하고, 그때 정해진 영역 밖에 쓴 코드는 지워집니다. 그래서 "어느 파일이 누구 것인가"를 먼저 알아야 합니다.

CubeMX 생성 폴더 — 점의 색이 "재생성 때 살아남나"입니다 my-app/ my-app.ioc CubeMX 설정 원본 — git 에 반드시 올림 CMakeLists.txt 최상위 — 처음 한 번만 생성, 내 소스를 여기 추가 CMakePresets.json · cmake/*.cmake 빌드 프리셋 · 툴체인 (컴파일 플래그) cmake/stm32cubemx/CMakeLists.txt 생성 소스 목록 — 매번 재생성 Core/Inc/ main.h · stm32f4xx_hal_conf.h · _it.h USER CODE 영역 안에서만 Core/Src/ main.c · stm32f4xx_it.c · _hal_msp.c USER CODE 영역 안에서만 system_stm32f4xx.c · syscalls.c · sysmem.c SystemInit · printf 연결 · 힙(_sbrk) Drivers/CMSIS · Drivers/STM32F4xx_HAL_Driver 펌웨어 패키지에서 복사 — 고치지 말 것 startup_stm32f446xx.s 벡터 테이블 · Reset_Handler (어셈블리) STM32F446XX_FLASH.ld 링커 스크립트 — 고칠 땐 이름을 바꾼 복사본으로 App/ app.c · app.h · drivers/ · tests/ 내가 만든 폴더 — CubeMX 가 모름 마음대로 영역 안에서만 · 복사본으로 재생성 때 덮어씀
트리 — 내 코드는 초록 자리(App/) 에 두고, 생성 파일에는 호출 한 줄만 남깁니다.

main.c — USER CODE 영역 규칙

/* USER CODE BEGIN Includes */
#include "app.h"                       /* ← 살아남음 */
/* USER CODE END Includes */

int main(void)
{
  HAL_Init();
  SystemClock_Config();
  MX_GPIO_Init();
  MX_DMA_Init();                       /* DMA 를 쓰는 주변장치보다 먼저! */
  MX_USART2_UART_Init();
  /* USER CODE BEGIN 2 */
  app_init();                          /* ← 살아남음 */
  /* USER CODE END 2 */

  /* USER CODE BEGIN WHILE */
  while (1)
  {
    /* USER CODE END WHILE */
    /* USER CODE BEGIN 3 */
    app_loop();                        /* ← 살아남음 */
  }
  /* USER CODE END 3 */
}

void helper(void) { }                  /* ← 영역 밖! 재생성하면 사라짐 */
BEGIN · END 주석은 지우지도 고치지도 마세요. CubeMX 는 이 주석 쌍의 이름으로 내용을 옮겨 붙입니다. USER CODE BEGIN 2 를 USER CODE BEGIN two 로 바꾸면 그 안의 코드는 그냥 버려집니다.

실제로 겪는 재생성 사고 세 가지

사고원인 → 예방
함수 하나가 통째로 사라짐영역 밖에 작성 → App/ 로 옮기고 호출만 영역 안에
DMA 수신이 처음부터 안 됨예전 CubeMX 가 MX_DMA_Init() 을 UART 초기화 뒤에 생성 → Advanced Settings 에서 순서 확인
핀 이름 매크로가 바뀌어 빌드 깨짐.ioc 에서 라벨 변경 → main.h 의 LED_Pin 같은 이름도 바뀜. 라벨은 처음에 정하고 고정
링커 스크립트 수정이 원복원본 .ld 를 직접 수정 → 복사본을 만들고 CMake 에서 그걸 가리킴
Project Manager → Code Generator 에서 "Generate peripheral initialization as a pair of '.c/.h' files per peripheral" 을 켜면 usart.c · gpio.c 처럼 주변장치별 파일로 나뉘어 main.c 가 짧아지고 diff 가 읽기 쉬워집니다.
리셋에서 main() 까지 — startup_stm32f446xx.s 가 하는 일 벡터 읽기 SP · PC SystemInit FPU 켬 .data 복사 Flash → RAM .bss = 0 전역 0 초기화 libc init 생성자 배열 main() C 코드 시작 main 안에서는 HAL_Init → SystemClock_Config → MX_GPIO_Init · MX_DMA_Init · MX_xxx_Init → app_init 순서 전역 변수가 이상한 값이면 .data 복사 전에 무엇을 실행했는지부터 의심합니다
파이프라인 — C 의 전역 변수가 초기값을 갖는 것도 누군가 복사해 준 결과입니다.

CMake 로 생성해 VS Code 로 빌드 — IDE 에 묶이지 않는 구조 🟡 중급

CubeMX 설정 — Project Manager 탭

Project
  Toolchain / IDE        : CMake          ← STM32CubeIDE 대신
  Linker Settings        : Minimum Heap 0x200 · Stack 0x400 (필요시 키움)
Code Generator
  [v] Copy only the necessary library files
  [v] Generate peripheral initialization as a pair of .c/.h
  [v] Keep User Code when re-generating   ← 기본값, 끄지 말 것
Advanced Settings
  Driver Selector        : 주변장치별 HAL / LL
  Generated Function Calls : 초기화 순서 (DMA 를 위로)
# 생성된 CMakePresets.json 의 프리셋으로 빌드 (툴체인 설치는 설치 탭)
cmake --preset Debug
cmake --build --preset Debug
#  → build/Debug/my-app.elf

# 굽기 — 둘 중 하나
STM32_Programmer_CLI -c port=SWD -w build/Debug/my-app.elf -v -rst
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg \
        -c "program build/Debug/my-app.elf verify reset exit"
VS Code 에서는 ST 의 STM32 VS Code Extension(CubeMX 프로젝트 가져오기 · 디버그 설정)이나 Cortex-Debug 확장으로 브레이크포인트를 겁니다. 빌드가 CMake 라 CI 에서도 같은 두 줄로 돕니다.

최상위 CMakeLists.txt — 내 소스 붙이기

# (생성된 파일의 표시된 자리에 추가)
target_sources(${CMAKE_PROJECT_NAME} PRIVATE
    # Add user sources here
    App/app.c
    App/drivers/led.c
    App/drivers/uart_rx.c
)

target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE
    # Add user defined include paths
    App
)

target_compile_options(${CMAKE_PROJECT_NAME} PRIVATE
    -Wall -Wextra -Wdouble-promotion -Wshadow
)

# 링커 스크립트를 고쳐 쓰려면 복사본을 가리키게
# target_link_options(${CMAKE_PROJECT_NAME} PRIVATE
#     -T${CMAKE_SOURCE_DIR}/App/app_F446_FLASH.ld)
cmake/stm32cubemx/CMakeLists.txt 에는 아무것도 추가하지 마세요 — 생성된 소스 목록이라 재생성 때 새로 써집니다. 링커 스크립트를 바꿀 때는 툴체인 파일의 기존 -T 옵션과 겹치지 않는지 확인합니다.

실무 구조 — App/ 로 빼고 main.c 는 세 줄만 🔴 실무

App/app.h · App/app.c

/* App/app.h — main.c 가 아는 유일한 인터페이스 */
#ifndef APP_H
#define APP_H
void app_init(void);
void app_loop(void);
#endif

/* App/app.c */
#include "app.h"
#include "main.h"            /* huart2, LD2_Pin 같은 생성 심볼 */
#include "drivers/led.h"
#include "drivers/uart_rx.h"

extern UART_HandleTypeDef huart2;   /* usart.c 에 정의돼 있음 */

void app_init(void)
{
    led_init(LD2_GPIO_Port, LD2_Pin);   /* .ioc 에서 PA5 에 붙인 라벨 */
    uart_rx_start(&huart2);           /* m07 의 IDLE + DMA 수신 */
}

void app_loop(void)
{
    uart_rx_poll();                    /* 받은 줄 처리 */
    led_tick(HAL_GetTick());           /* 블로킹 없이 깜빡이기 */
}

콜백도 App/ 쪽에 — 생성 파일에서 떼어 놓기

/* App/drivers/uart_rx.c
   HAL 의 __weak 콜백(m06)은 어느 파일에서 정의해도 됩니다.
   main.c 의 USER CODE 4 에 넣을 필요가 없습니다. */
#include "main.h"

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    if (huart->Instance == USART2) {
        /* m07 에서 채웁니다 */
    }
}
# .gitignore
build/
.cache/
*.elf  *.bin  *.hex  *.map
# Drivers/ 는 올리는 쪽을 권장 — 팀원 CubeMX 버전이 달라도 같은 코드로 빌드
이 구조의 이득 — App/ 은 HAL 헤더만 알면 되므로 칩을 바꿔도 대부분 그대로 쓰고, HAL 호출을 얇은 드라이버 층으로 감싸 두면 PC 에서 단위 테스트까지 됩니다(대규모 C 구조 탭 · 디버깅 · 테스트 탭).
정리 — CubeMX 는 .ioc 가 원본이고 C 코드는 생성물인 도구입니다. 생성 파일에는 USER CODE 영역 안의 호출 한 줄만 두고, 실제 코드는 App/ 에 둡니다. CMake 로 생성하면 VS Code · CI 에서 같은 명령으로 빌드됩니다.
STM 04

클럭 트리 — 16 MHz 를 180 MHz 로 만드는 법

칩 안의 모든 회로는 클럭(일정한 간격으로 0/1 을 반복하는 박자 신호)에 맞춰 움직입니다. 리셋 직후 F446 은 내부 발진기 HSI 16 MHz 로 돌고, 180 MHz 를 쓰려면 PLL(주파수를 곱해 주는 회로)을 거쳐야 합니다. UART 보레이트 · 타이머 주기 · ADC 속도가 전부 이 트리에서 갈라져 나오므로, 클럭을 모르면 m07~m09 의 계산이 하나도 맞지 않습니다.

F446 클럭 트리 — 왼쪽에서 오른쪽으로 곱하고 나눕니다 HSI 16 MHz 내부 RC · ±1% HSE 8 MHz ST-LINK MCO ÷M 4 2 MHz ×N 180 VCO 360 ÷P 2 180 MHz VCO 입력 1~2 MHz · 출력 100~432 MHz 맨 위 가는 선 — HSI · HSE 를 PLL 없이 바로 SYSCLK 로 쓸 수도 있습니다 SYSCLK 180 MHz AHB ÷1 → 180 HCLK · 코어 · DMA · SysTick APB1 ÷4 → 45 USART2 · I2C · TIM2~7 = 90 APB2 ÷2 → 90 USART1 · ADC · TIM1/8 = 180 APB 분주가 1 이 아니면 타이머 클럭은 APB 의 2배 — 오른쪽 "= 90 / = 180" 이 타이머 클럭 Flash 대기 상태(2.7~3.6 V) — HCLK 가 높을수록 Flash 를 기다려야 합니다 0 WS ≤ 30 MHz 1 WS ≤ 60 2 WS ≤ 90 3 WS ≤ 120 4 WS ≤ 150 5 WS ≤ 180 180 MHz → FLASH_LATENCY_5. 대기 상태가 모자라면 명령을 잘못 읽어 곧바로 HardFault 입니다 ART 가속기(명령 캐시 · 프리페치)가 대기 상태 대부분을 가려 줍니다 — HAL_Init 이 켭니다
트리 — 8 ÷ 4 × 180 ÷ 2 = 180 MHz. 버스마다 상한(APB1 45 · APB2 90)이 있어 다시 나눕니다.

계산을 손으로 확인하기

f_VCO_in  = f_HSE / PLLM       = 8 MHz / 4    = 2 MHz    (1~2 MHz, 2 권장)
f_VCO     = f_VCO_in × PLLN    = 2 × 180      = 360 MHz  (100~432 MHz)
f_SYSCLK  = f_VCO / PLLP       = 360 / 2      = 180 MHz  (PLLP = 2,4,6,8)
f_PLLQ    = f_VCO / PLLQ       = 360 / Q      → 48 MHz 를 만들 정수 Q 가 없음 (7.5)

HCLK  = SYSCLK / 1 = 180 MHz
PCLK1 = HCLK / 4   =  45 MHz   (APB1 상한 45)  → 타이머 ×2 = 90 MHz
PCLK2 = HCLK / 2   =  90 MHz   (APB2 상한 90)  → 타이머 ×2 = 180 MHz

HSI 로 시작한다면 PLLM = 8 (16 / 8 = 2 MHz), 나머지 동일
USB 가 필요하면 48 MHz 가 정확해야 합니다. 180 MHz 에서는 PLLQ 로 못 만들어서 F446 은 PLLSAI 로 48 MHz 를 따로 만듭니다(CubeMX 클럭 탭이 자동 계산). PLLQ 하나로 끝내고 싶다면 168 MHz(N=168 → VCO 336, P=2, Q=7 → 48 MHz)가 깔끔한 선택입니다.

HSI · HSE — 무엇을 쓰나

HSI (내부)HSE (외부)
정확도±1% (온도에 따라 흔들림)±수십 ppm (수정 진동자)
부품없음크리스털 + 콘덴서 2개, 또는 외부 클럭
기동즉시수 ms (발진 안정 대기)
UART보통 OK (허용 오차 ~2%)OK
USB · CAN · 이더넷부적합필수
RCC_HSE_BYPASS 와 RCC_HSE_ON 의 차이 — ON 은 칩이 크리스털을 직접 발진시키고, BYPASS 는 이미 만들어진 클럭 신호를 받기만 합니다. Nucleo 는 ST-LINK 가 8 MHz 를 만들어 주므로 BYPASS 입니다. 자체 보드에 크리스털을 달았는데 BYPASS 로 두면 HSE 가 안 떠서 HAL_RCC_OscConfig() 가 타임아웃으로 HAL_TIMEOUT 을 돌려줍니다.

SystemClock_Config — 한 줄씩 읽기 🟡 중급

CubeMX 가 생성하는 180 MHz 설정 (HSE 바이패스)

void SystemClock_Config(void)
{
  RCC_OscInitTypeDef RCC_OscInitStruct = {0};
  RCC_ClkInitTypeDef RCC_ClkInitStruct = {0};

  __HAL_RCC_PWR_CLK_ENABLE();                                  /* ① */
  __HAL_PWR_VOLTAGESCALING_CONFIG(PWR_REGULATOR_VOLTAGE_SCALE1);

  RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE;   /* ② */
  RCC_OscInitStruct.HSEState       = RCC_HSE_BYPASS;
  RCC_OscInitStruct.PLL.PLLState   = RCC_PLL_ON;
  RCC_OscInitStruct.PLL.PLLSource  = RCC_PLLSOURCE_HSE;
  RCC_OscInitStruct.PLL.PLLM = 4;
  RCC_OscInitStruct.PLL.PLLN = 180;
  RCC_OscInitStruct.PLL.PLLP = RCC_PLLP_DIV2;
  RCC_OscInitStruct.PLL.PLLQ = 2;
  RCC_OscInitStruct.PLL.PLLR = 2;
  if (HAL_RCC_OscConfig(&RCC_OscInitStruct) != HAL_OK) Error_Handler();

  if (HAL_PWREx_EnableOverDrive() != HAL_OK) Error_Handler();  /* ③ */

  RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_SYSCLK
                              | RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2;
  RCC_ClkInitStruct.SYSCLKSource   = RCC_SYSCLKSOURCE_PLLCLK; /* ④ */
  RCC_ClkInitStruct.AHBCLKDivider  = RCC_SYSCLK_DIV1;
  RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV4;
  RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV2;
  if (HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_5) != HAL_OK)
    Error_Handler();                                           /* ⑤ */
}

번호별 해설

#무엇을 하나
①PWR 주변장치 클럭을 켜고 전압 스케일 1(가장 높은 코어 전압)로. 168 MHz 를 넘기려면 필수
②HSE 를 켜고 PLL 을 설정 · 기동. 내부에서 HSERDY · PLLRDY 플래그가 설 때까지 타임아웃을 두고 기다립니다
③오버드라이브 — 코어 전압을 한 단계 더 올리는 모드. 168 → 180 MHz 구간에 필요. 소비 전류가 늘어납니다
④SYSCLK 를 PLL 로 전환하고 버스 분주 설정
⑤Flash 대기 상태를 먼저 올리고 클럭을 올립니다(내릴 땐 반대 순서). 끝에 SystemCoreClock 갱신 + HAL_InitTick() 으로 SysTick 재설정
Error_Handler() 는 기본적으로 무한 루프입니다(__disable_irq(); while (1) {}). 보드가 "아무 반응이 없다"면 여기서 멈춰 있는 경우가 많습니다. USER CODE 영역에 LED 를 빠르게 깜빡이거나 __BKPT(0) 을 넣어 두면 디버거가 바로 그 자리에서 멈춥니다.

실무에선 이렇게 — 클럭은 재서 확인합니다 🔴 실무

MCO 핀으로 클럭을 밖으로 꺼내 측정

/* PA8 = MCO1. PLL 출력(180 MHz)을 4로 나눠 45 MHz 로 내보냄
   → 오실로스코프 · 주파수 카운터로 확인 */
HAL_RCC_MCOConfig(RCC_MCO1, RCC_MCO1SOURCE_PLLCLK, RCC_MCODIV_4);

/* 코드에서 확인 — 설정이 기대와 다르면 바로 보임 */
printf("SYSCLK=%lu HCLK=%lu PCLK1=%lu PCLK2=%lu\r\n",
       HAL_RCC_GetSysClockFreq(), HAL_RCC_GetHCLKFreq(),
       HAL_RCC_GetPCLK1Freq(),   HAL_RCC_GetPCLK2Freq());
/* 기대: 180000000 180000000 45000000 90000000 */
HSE_VALUE 를 확인하세요 — stm32f4xx_hal_conf.h 의 HSE_VALUE 가 실제 크리스털과 다르면 HAL 이 주파수를 잘못 계산해 UART 보레이트가 통째로 틀어집니다(25 MHz 크리스털 보드에 8000000 이 남아 있는 사고가 흔합니다).

HSE 가 죽으면 — 클럭 보안 시스템(CSS)

/* HSE 가 멈추면 하드웨어가 HSI 로 자동 전환하고 NMI 를 띄웁니다 */
HAL_RCC_EnableCSS();

void HAL_RCC_CSSCallback(void)       /* NMI_Handler → HAL_RCC_NMI_IRQHandler → 여기 */
{
    /* 지금 SYSCLK = HSI 16 MHz. PLL 도 꺼졌음.
       보레이트·타이머가 모두 틀어진 상태 → 안전 상태로 전환 후 기록 */
    fault_log_write(FAULT_HSE_LOST);
    NVIC_SystemReset();
}
서버로 치면 NTP 서버가 죽었을 때 로컬 시계로 넘어가는 것과 같습니다 — 계속 돌긴 하지만 정확도가 떨어지므로 통신 · 계측 장비라면 "클럭이 바뀌었다"는 사실을 반드시 기록하고 대응해야 합니다.
정리 — 180 MHz = HSE 8 ÷ M4 × N180 ÷ P2, 그리고 스케일 1 + 오버드라이브 + Flash 5 WS 세 가지가 함께 맞아야 합니다. APB1 은 45, APB2 는 90 이고 타이머는 그 두 배라는 것을 외워 두면 이후의 모든 계산이 맞습니다.
STM 05

GPIO · EXTI — 핀 하나를 설정하는 레지스터 일곱 개와 외부 인터럽트

GPIO(범용 입출력)는 핀을 0 V / 3.3 V 로 내보내거나 읽는 가장 기본 주변장치입니다. F446 의 핀 하나는 입력 · 출력 · 대체 기능(UART 같은 주변장치에 넘김) · 아날로그 네 모드 중 하나이고, 이 선택이 레지스터 몇 개의 비트로 저장됩니다. 그 위에 EXTI 가 핀의 변화를 인터럽트로 바꿔 줍니다 — 폴링 대신 이벤트 리스너를 다는 것과 같습니다.

GPIOA 레지스터 두 개를 비트 단위로 — PA5(LED)의 자리 MODER (0x00) — 핀마다 2비트 · 00 입력 · 01 출력 · 10 대체 기능 · 11 아날로그 PA15 … PA6 (비트 31 ~ 12) 01 PA4 … PA0 (비트 9 ~ 0) PA5 = 비트 11:10 BSRR (0x18) — 쓰기 전용 · 1 을 쓴 비트만 동작 · 0 은 무시 BR15 … BR0 (비트 31 ~ 16) — 끄기 BS15 … BS0 (비트 15 ~ 0) — 켜기 BR5 = 비트 21 BS5 = 비트 5 GPIOA->BSRR = 1u << 5 → 켜기 · 1u << 21 → 끄기 · 다른 핀은 그대로 ODR 은 읽고 쓸 수 있지만 BSRR 은 쓰기만 — 그래서 끼어들 틈이 없습니다 나머지 OTYPER · OSPEEDR · PUPDR 도 같은 방식 (핀당 1비트 또는 2비트)
메모리 배치 — 핀 설정은 결국 정해진 자리의 비트 2개입니다. HAL_GPIO_Init 은 이걸 레지스터 네 개에 나눠 씁니다.

GPIO 레지스터 지도 (F4 공통)

레지스터핀당뜻
MODER2비트입력 / 출력 / 대체 기능 / 아날로그
OTYPER1비트0 푸시풀 · 1 오픈드레인(0 만 끌어내리고 1 은 놓아줌 — I2C)
OSPEEDR2비트출력 전환 속도(엣지 가파름). 높을수록 노이즈 ↑
PUPDR2비트00 없음 · 01 풀업 · 10 풀다운 (내부 약 40 kΩ)
IDR1비트지금 핀 전압 읽기 (읽기 전용)
ODR1비트출력 값 (읽고 쓰기)
BSRR2×1비트원자적 셋/리셋 (쓰기 전용)
AFR[0] · AFR[1]4비트대체 기능 번호 AF0~AF15 (핀 0~7 · 8~15)
풀업이란 — 아무것도 연결되지 않은 입력 핀은 0 도 1 도 아닌 채 떠서(floating) 노이즈를 읽습니다. 저항으로 3.3 V 쪽에 약하게 당겨 두면 기본값이 1 이 되고, 버튼이 GND 로 누르면 0 이 됩니다. 버튼 입력의 기본 구성입니다.

HAL_GPIO_Init — 출력 · 입력 · 대체 기능

__HAL_RCC_GPIOA_CLK_ENABLE();            /* 포트 클럭부터. 안 켜면 설정이 그냥 무시됨 */
__HAL_RCC_GPIOC_CLK_ENABLE();

GPIO_InitTypeDef g = {0};

/* PA5 — LED 출력 */
g.Pin = GPIO_PIN_5;  g.Mode = GPIO_MODE_OUTPUT_PP;
g.Pull = GPIO_NOPULL; g.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOA, &g);

/* PC13 — 버튼 입력 (Nucleo 에는 외부 풀업이 이미 있음) */
g.Pin = GPIO_PIN_13; g.Mode = GPIO_MODE_INPUT; g.Pull = GPIO_NOPULL;
HAL_GPIO_Init(GPIOC, &g);
GPIO_PinState s = HAL_GPIO_ReadPin(GPIOC, GPIO_PIN_13);   /* 누르면 GPIO_PIN_RESET */

/* PA2 / PA3 — USART2 TX / RX (대체 기능 7번) */
g.Pin = GPIO_PIN_2 | GPIO_PIN_3;
g.Mode = GPIO_MODE_AF_PP;  g.Pull = GPIO_PULLUP;
g.Speed = GPIO_SPEED_FREQ_VERY_HIGH;
g.Alternate = GPIO_AF7_USART2;           /* 데이터시트 "Alternate function mapping" 표 */
HAL_GPIO_Init(GPIOA, &g);
5 V 를 넣어도 되는 핀은 데이터시트 핀 표에 FT 로 표시된 것뿐입니다. DAC 핀인 PA4 · PA5 는 FT 가 아닙니다. 아날로그 모드나 출력 모드에서는 FT 핀도 5 V 를 견디지 못하는 조건이 있으니 하드웨어 기초 탭의 레벨 변환을 참고하세요.

EXTI — 핀 변화를 인터럽트로 🟡 중급

버튼 한 번 = 인터럽트 다섯 번 — 접점이 튑니다 (튀는 구간은 확대해서 그림) 3.3 V 0 V ● 하강 엣지마다 EXTI 인터럽트 떼는 순간에도 튐 누름 무시 창 20 ms 첫 엣지만 인정 → 1회 무시 창 20 ms 무시됨 튀는 시간은 버튼마다 수 µs ~ 수 ms — 넉넉히 10~50 ms 를 잡습니다 "한 번 눌렀는데 두 번 토글된다"는 버그 보고의 대부분이 이것입니다
파형 — 기계식 접점은 깨끗한 엣지 하나를 만들지 않습니다. EXTI 는 그 튐까지 전부 셉니다.

EXTI 설정 — CubeMX 가 생성하는 것과 같은 코드

/* PC13 하강 엣지 인터럽트 */
g.Pin  = B1_Pin;                       /* GPIO_PIN_13 */
g.Mode = GPIO_MODE_IT_FALLING;         /* 엣지 종류: RISING · FALLING · RISING_FALLING */
g.Pull = GPIO_NOPULL;
HAL_GPIO_Init(B1_GPIO_Port, &g);       /* 내부에서 SYSCFG 클럭 켜고 EXTICR4 에 포트 C 기록 */

HAL_NVIC_SetPriority(EXTI15_10_IRQn, 5, 0);
HAL_NVIC_EnableIRQ(EXTI15_10_IRQn);

/* stm32f4xx_it.c — 생성됨 */
void EXTI15_10_IRQHandler(void)
{
  HAL_GPIO_EXTI_IRQHandler(B1_Pin);    /* 펜딩 비트 지우고 → 콜백 호출 */
}
/* HAL 내부 (요약) */
void HAL_GPIO_EXTI_IRQHandler(uint16_t GPIO_Pin)
{
  if (__HAL_GPIO_EXTI_GET_IT(GPIO_Pin) != RESET) {
    __HAL_GPIO_EXTI_CLEAR_IT(GPIO_Pin);   /* EXTI->PR = GPIO_Pin  (1 을 써서 지움) */
    HAL_GPIO_EXTI_Callback(GPIO_Pin);     /* __weak — 내가 재정의 (m06) */
  }
}

EXTI 라인 규칙 — 한 번은 꼭 막힙니다

규칙결과
라인 번호 = 핀 번호PA0 · PB0 · PC0 은 전부 EXTI0. 동시에 둘은 못 씀 (SYSCFG_EXTICR 가 포트 하나만 고름)
IRQ 공유EXTI0~4 는 각자 IRQ, 5~9 는 EXTI9_5_IRQn, 10~15 는 EXTI15_10_IRQn 하나
공유 IRQ 처리핸들러에서 핀마다 HAL_GPIO_EXTI_IRQHandler() 를 부르고, 콜백에서 GPIO_Pin 으로 구분
16 이상 라인PVD · RTC 알람 · USB 깨우기 같은 내부 이벤트용
/* 디바운스 — 첫 엣지만 인정하고 20 ms 무시 */
volatile uint32_t btn_events;          /* ISR 만 씀, main 은 읽기만 */

void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    static uint32_t last_ms;
    if (GPIO_Pin != B1_Pin) return;
    uint32_t now = HAL_GetTick();
    if (now - last_ms >= 20u) {         /* 부호 없는 뺄셈 — 49.7일 넘김에도 안전 */
        last_ms = now;
        btn_events++;
    }
}

/* main 루프 — 32비트 읽기는 M4 에서 원자적 */
static uint32_t seen;
uint32_t ev = btn_events;
if (ev != seen) { seen = ev; HAL_GPIO_TogglePin(LD2_GPIO_Port, LD2_Pin); }

실무에선 이렇게 — EXTI 의 함정 두 가지 🔴 실무

❌ 펜딩 레지스터에 |= — 다른 라인까지 지움
EXTI->PR |= (1u << 13);
/* PR 은 "1 을 쓰면 지워지는" 레지스터.
   |= 는 읽은 값(다른 라인의 펜딩 1 포함)을 다시 써서
   처리 안 된 EXTI10 · EXTI11 의 인터럽트까지 날려 버림 */
✅ 지울 비트만 써넣기
EXTI->PR = (1u << 13);         /* 다른 비트는 0 → 영향 없음 */
__HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_13);   /* HAL 도 같은 방식 */
ISR 맨 끝에서 플래그를 지우면 두 번 들어옵니다 — 펜딩 비트를 지우는 쓰기가 주변장치 버스를 지나 실제로 반영되기 전에 ISR 이 끝나면, NVIC 가 아직 남은 펜딩을 보고 같은 인터럽트에 한 번 더 들어옵니다. HAL 처럼 ISR 앞쪽에서 지우거나, 끝에서 지웠다면 __DSB() 를 붙입니다.
버튼이 많으면 EXTI 대신 타이머 폴링 — 1 ms 타이머에서 핀을 읽어 8번 연속 같은 값이면 확정하는 방식이 튐에 강하고, 인터럽트 폭주도 없습니다. EXTI 는 깨우기(m13)나 짧은 펄스를 놓치면 안 되는 신호에 씁니다.
정리 — GPIO 설정은 MODER · OTYPER · OSPEEDR · PUPDR 의 비트이고, 출력은 BSRR 로 원자적으로 씁니다. EXTI 는 핀 번호 = 라인 번호이며 10~15 는 IRQ 하나를 나눠 쓰고, 기계식 버튼은 반드시 디바운스합니다.
STM 06

NVIC · SysTick · HAL 콜백 — 누가 누구를 끊고 들어오는가

인터럽트는 "지금 하던 일을 잠깐 멈추고 이 함수를 실행하라"는 하드웨어의 요청입니다. NVIC(Nested Vectored Interrupt Controller)는 그 요청들의 우선순위를 매기고 끼어들기(선점)를 결정하는 교통정리 담당입니다. HAL 의 모든 비동기 기능(_IT · _DMA · 콜백 · HAL_Delay)이 이 위에서 돌기 때문에, 여기를 모르면 "가끔 멈춘다" 같은 버그를 영영 못 잡습니다.

선점 타임라인 — 숫자가 작을수록 높은 우선순위 (F4 는 0~15) 우선순위 1 · TIM6 우선순위 5 · EXTI 우선순위 15 · SysTick 스레드 · main() ① EXTI 진입 ② TIM6 가 선점 ③ SysTick 대기 ④ 꼬리 물기 ⑤ main 복귀 흐린 막대 = 펜딩(요청은 왔지만 더 높은 것이 돌고 있어 대기) — 순서가 지켜지는 원리입니다
타임라인 — 높은 우선순위는 낮은 것을 끊고 들어오고, 같거나 낮은 것은 끝날 때까지 기다립니다.

우선순위 숫자 — 4비트, 작을수록 높음

/* HAL_Init() 안에서 */
HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4);
/* 4비트 전부를 '선점 우선순위'로 → 0~15 단계, 서브 우선순위 없음 */

HAL_NVIC_SetPriority(TIM6_DAC_IRQn, 1, 0);    /* (IRQ, 선점, 서브) */
HAL_NVIC_SetPriority(EXTI15_10_IRQn, 5, 0);
HAL_NVIC_SetPriority(USART2_IRQn,    6, 0);
HAL_NVIC_EnableIRQ(TIM6_DAC_IRQn);

/* 레지스터로 보면 — IPR 은 8비트 칸인데 F4 는 위 4비트만 구현 */
/* NVIC 의 IP[EXTI15_10_IRQn] 칸에 실제로 들어간 값 = 0x50  (5 << (8 - __NVIC_PRIO_BITS)) */
"높은 우선순위 = 작은 숫자" 입니다. 리눅스 nice 값과 같은 방향이고, 직관과 반대라 실수가 잦습니다. Reset(-3) · NMI(-2) · HardFault(-1)는 고정이라 어떤 IRQ 보다 높습니다.

선점 · 서브 · 펜딩 — 용어 정리

용어뜻
선점 우선순위이 값이 더 작아야 실행 중인 ISR 을 끊을 수 있음
서브 우선순위선점이 같은 둘이 동시에 대기 중일 때 누가 먼저냐만 결정. 끊지는 못함
펜딩요청은 왔지만 아직 실행 못 함. 같은 IRQ 가 또 와도 1번으로 합쳐짐
꼬리 물기ISR 이 끝날 때 펜딩이 있으면 스레드로 돌아가지 않고 바로 다음 ISR 로 (레지스터 복원 생략)
지연 도착스택에 쌓는 중에 더 높은 것이 오면 그쪽을 먼저 실행
펜딩이 합쳐진다는 것이 중요합니다 — 같은 인터럽트가 처리 전에 세 번 오면 ISR 은 한 번만 돕니다. 그래서 "인터럽트 횟수 = 이벤트 횟수"로 세면 빠뜨립니다. UART 는 수신 레지스터가 1바이트라 늦으면 오버런(ORE) 이 납니다.

SysTick 과 HAL_Delay — ISR 에서 멈추는 이유 🟡 중급

서로를 기다립니다 — SysTick 이 EXTI 보다 낮거나 같은 우선순위일 때 EXTI ISR (prio 5) HAL_Delay(10) uwTick SysTick (15) 1. HAL_Delay(10) 호출 2. HAL_GetTick() 읽기 3. 같은 값 (1234) 4. 다시 읽기 … 무한 반복 펜딩 — 실행 못 함 prio 15 는 5 를 끊을 수 없음 HAL_IncTick() 이 와야 하는데 SysTick 우선순위를 0 으로 올리면 "돌긴" 하지만, ISR 안에서 기다리는 설계 자체가 틀린 것입니다
시퀀스 — HAL_Delay 는 SysTick 이 uwTick 을 올려 줘야 끝나는 바쁜 대기입니다. 그 SysTick 을 막고 있으면 영원히 끝나지 않습니다.

HAL 의 시간 — 실제 구현

/* stm32f4xx_hal.c (요약) */
__IO uint32_t uwTick;

__weak void HAL_IncTick(void)  { uwTick += uwTickFreq; }  /* SysTick_Handler 가 1 ms 마다 */
__weak uint32_t HAL_GetTick(void) { return uwTick; }

__weak void HAL_Delay(uint32_t Delay)
{
  uint32_t tickstart = HAL_GetTick();
  uint32_t wait = Delay;
  if (wait < HAL_MAX_DELAY) {
    wait += (uint32_t)(uwTickFreq);      /* 최소 Delay ms 를 보장하려고 1 틱 더함 */
  }
  while ((HAL_GetTick() - tickstart) < wait) { }    /* 바쁜 대기 */
}
HAL_Delay(1) 은 1~2 ms 입니다 — 호출 시점이 틱 경계의 어디냐에 따라 달라서, 최소 1 ms 를 보장하려고 1 을 더합니다. 마이크로초 지연이 필요하면 DWT 사이클 카운터(DWT->CYCCNT, 180 MHz 에서 1 µs = 180 사이클)를 씁니다.

ISR 에서 기다리지 않는 구조

/* ❌ */
void HAL_GPIO_EXTI_Callback(uint16_t pin)
{
    HAL_Delay(20);                         /* 멈추거나, 20 ms 동안 다른 IRQ 막힘 */
    if (HAL_GPIO_ReadPin(B1_GPIO_Port, B1_Pin) == GPIO_PIN_RESET) handle();
}

/* ✅ 플래그와 시각만 남기고 판단은 main 에서 */
volatile uint8_t  btn_flag;
volatile uint32_t btn_ms;
void HAL_GPIO_EXTI_Callback(uint16_t pin)
{
    if (pin == B1_Pin) { btn_ms = HAL_GetTick(); btn_flag = 1; }
}

void app_loop(void)
{
    if (btn_flag && HAL_GetTick() - btn_ms >= 20u) {
        btn_flag = 0;
        if (HAL_GPIO_ReadPin(B1_GPIO_Port, B1_Pin) == GPIO_PIN_RESET) handle();
    }
}
RTOS 를 쓰면 SysTick 은 RTOS 커널이 가져가고 HAL 의 타임베이스는 TIM6 같은 기본 타이머로 옮깁니다(CubeMX: SYS → Timebase Source). 자세한 것은 FreeRTOS 탭에서 다룹니다.

__weak 콜백 — 링커가 고르는 함수 🔴 실무

약한 심볼 재정의 원리

/* HAL 쪽: stm32f4xx_hal_uart.c */
__weak void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
  UNUSED(huart);           /* 아무것도 안 함 — "기본 구현" */
}
/* __weak 은 __attribute__((weak)) 매크로 */

/* 내 쪽: App/comm.c */
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)   /* 강한 심볼 */
{
  if (huart->Instance == USART2) tx_busy = 0;
}
/* 링크할 때 같은 이름이 둘이면 링커는 '강한' 쪽을 고름
   → HAL 이 부르는 콜백이 내 함수로 바뀜. 가상 함수 오버라이드와 같은 효과 */
호출 경로 (UART 송신 완료)누가
USART2_IRQHandler벡터 테이블 → stm32f4xx_it.c
HAL_UART_IRQHandler(&huart2)상태 레지스터 보고 분기
UART_EndTransmit_IT()TC 인터럽트 끄고 상태 READY 로
HAL_UART_TxCpltCallback()내 함수 (ISR 문맥!)

콜백이 "조용히 안 불리는" 원인 셋

원인왜 · 어떻게 확인
이름 오타HAL_UART_TxCompleteCallback 처럼 틀리면 그냥 새 함수. 에러 없음 → arm-none-eabi-nm app.elf | grep Callback 으로 W(약)/T(강) 확인
C++ 파일에 작성이름이 맹글링되어 다른 심볼 → extern "C" 로 감쌈
같은 콜백을 두 곳에 정의링크 에러 multiple definition. 특히 HAL_TIM_PeriodElapsedCallback 은 TIM 타임베이스용으로 main.c 에 이미 생성되어 있을 수 있음 → 그 함수의 USER CODE 안에 추가
/* 대안 — 핸들별 콜백 등록 (stm32f4xx_hal_conf.h 에서 켬) */
#define USE_HAL_UART_REGISTER_CALLBACKS  1U

HAL_UART_RegisterCallback(&huart2, HAL_UART_TX_COMPLETE_CB_ID, on_uart2_tx);
/* UART 가 여러 개일 때 if (huart->Instance == …) 분기가 사라짐 */
임계 구역이 필요하면 이전 상태를 저장해 복원합니다 — uint32_t pm = __get_PRIMASK(); __disable_irq(); … __set_PRIMASK(pm); 중첩 호출에서 무심코 인터럽트를 켜 버리는 사고를 막습니다.
정리 — F4 의 우선순위는 0~15, 작을수록 높음이고, 같은 우선순위끼리는 끊지 못합니다. HAL 콜백은 ISR 안에서 도는 __weak 함수라서 짧게 끝내야 하며, HAL_Delay 같은 대기는 절대 넣지 않습니다.
STM 07

UART + DMA + IDLE — 길이를 모르는 메시지를 놓치지 않고 받기

UART 는 선 두 개(TX · RX)로 바이트를 하나씩 보내는 가장 흔한 통신입니다(프로토콜 자체는 통신 탭에서 깊게). HAL 로 보내는 것은 쉽습니다. 어려운 것은 받기입니다 — 상대가 언제 몇 바이트를 보낼지 모르기 때문입니다. 이 절은 블로킹 → 인터럽트 → DMA 순서로 올라가서, 현업 표준인 순환 DMA + IDLE 감지 (HAL_UARTEx_ReceiveToIdle_DMA)로 끝납니다. 서버로 치면 소켓 recv 를 비동기 이벤트 루프로 바꾸는 과정입니다.

UART 파형 — 위는 'A'(0x41) 한 바이트, 아래는 메시지 하나와 IDLE 1 (쉼) 0 시작 d0=1 d1 d2 d3 d4 d5 d6=1 d7 정지 한 프레임 = 10비트 = 115200 보에서 86.8 µs $ T E M P LF 1 프레임 IDLE 플래그 $ H 다음 메시지 IDLE = "선이 한 프레임 동안 조용했다" — 길이를 몰라도 메시지 끝을 알 수 있습니다 데이터는 최하위 비트(d0)부터 나갑니다 — 0x41 = 0100 0001 이 1,0,0,0,0,0,1,0 순서로 보입니다
파형 — 바이트 사이가 한 프레임 이상 비면 IDLE. 이 하드웨어 신호가 가변 길이 수신의 열쇠입니다.

보내기 세 단계 — 블로킹 · IT · DMA

static const char hello[] = "hello\r\n";

/* ① 블로킹 — 다 보낼 때까지 CPU 가 기다림 (100 = 타임아웃 ms) */
HAL_UART_Transmit(&huart2, (const uint8_t *)hello, sizeof hello - 1, 100);

/* ② 인터럽트 — 즉시 리턴. 바이트마다 TXE 인터럽트로 한 바이트씩 */
HAL_UART_Transmit_IT(&huart2, (const uint8_t *)hello, sizeof hello - 1);

/* ③ DMA — 즉시 리턴. DMA 가 메모리 → USART2->DR 로 옮김, 끝나면 콜백 1번 */
HAL_UART_Transmit_DMA(&huart2, (const uint8_t *)hello, sizeof hello - 1);

void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { /* ②③ 완료 */ }

/* ②③ 은 이전 전송이 안 끝났으면 HAL_BUSY 를 돌려줌 — 반환값 확인 필수 */
if (HAL_UART_Transmit_DMA(&huart2, buf, n) == HAL_BUSY) { /* 큐에 쌓기 */ }
②③ 의 버퍼는 콜백이 올 때까지 살아 있어야 합니다. 함수 안 지역 배열을 넘기고 리턴하면 스택이 덮여 엉뚱한 바이트가 나갑니다(m10). 상수 문자열 · static · 전역 버퍼만 넘기세요.

세 방식 비교 (115200 보, 100바이트)

블로킹_IT_DMA
CPU 가 묶이는 시간8.7 ms 전부바이트당 ISR시작 · 끝만
인터럽트 수0~1001~2
코드 난이도쉬움보통보통 (+ 버퍼 수명)
어울리는 곳부팅 로그 · 디버그짧은 응답로그 · 대량 전송
보레이트 확인 — USART2 는 APB1 45 MHz, 16배 오버샘플링
USARTDIV = 45 000 000 / (16 × 115200) = 24.414
 → BRR 정수부 24, 소수부 round(0.414 × 16) = 7 → 0x187
실제 보레이트 = 45e6 / (16 × 24.4375) = 115 089.5  (오차 -0.096 %)
허용 오차는 대략 ±2 % — 문제없음
printf 를 UART 로 — CubeMX 의 syscalls.c 는 _write() 에서 __io_putchar() 를 부르므로 이것만 정의하면 됩니다: int __io_putchar(int ch){ uint8_t c = (uint8_t)ch; HAL_UART_Transmit(&huart2, &c, 1, 10); return ch; } (블로킹이라 ISR 안에서는 쓰지 않습니다.)

받기 — 바이트 인터럽트에서 순환 DMA + IDLE 로 🟡 중급

❌ 1바이트씩 _IT 로 다시 걸기
uint8_t rx_byte;
HAL_UART_Receive_IT(&huart2, &rx_byte, 1);

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    push(rx_byte);
    HAL_UART_Receive_IT(&huart2, &rx_byte, 1);  /* 다시 걸기 */
}
/* 115200 보면 초당 11 520 번 인터럽트.
   더 높은 IRQ 가 한 프레임(86.8 µs) 넘게 잡고 있으면
   다음 바이트가 덮어써져 ORE(오버런) → 수신 중단 */
✅ 순환 DMA 가 계속 받고, 이벤트 때만 깨움
static uint8_t rx_dma[256];          /* DMA 전용 — static 필수 */

/* CubeMX: USART2_RX DMA → Mode = Circular */
HAL_UARTEx_ReceiveToIdle_DMA(&huart2, rx_dma, sizeof rx_dma);

/* 콜백은 세 경우에 옴 — 절반 찼을 때(HT) · 끝까지(TC) · IDLE
   Size = 버퍼 안에서 DMA 가 지금까지 채운 끝 위치 */
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart,
                                uint16_t Size);
순환 버퍼 256 B — 한 칸이 16 바이트, 초록 칸이 이번에 처리할 새 데이터 경우 A · pos > old → 한 조각 처리 끝 48 ~ 111 (64 B) 아직 안 옴 (이전 바퀴의 낡은 값) old = 48 pos = 112 경우 B · pos < old → 끝까지 + 처음부터, 두 조각 0~31 처리 끝 208~255 pos = 32 old = 208 DMA 가 끝(255)에서 0 으로 되돌아감 처리 순서 ① rx_dma[208..255] 48 B → ② rx_dma[0..31] 32 B — 합 80 B Size 가 256(TC)으로 오면 처리 후 old 를 0 으로 되돌립니다. 한 번에 256 B 넘게 밀리면 구분할 방법이 없습니다
메모리 배치 — 새 데이터는 [old, pos), pos 가 old 보다 작으면 한 바퀴 돈 것이라 두 조각입니다.

App/drivers/uart_rx.c — 실무에서 그대로 쓰는 모양

#include "main.h"

#define RX_DMA_SZ 256u
static uint8_t  rx_dma[RX_DMA_SZ];   /* DMA 가 쓰는 곳 (전역 수명) */
static uint16_t rx_old;               /* 지난번 처리 끝 위치 */
static UART_HandleTypeDef *rx_uart;

extern void rx_push(const uint8_t *p, uint16_t n);   /* 앱 링 버퍼로 복사 */

void uart_rx_start(UART_HandleTypeDef *huart)
{
    rx_uart = huart;
    rx_old  = 0;
    HAL_UARTEx_ReceiveToIdle_DMA(huart, rx_dma, RX_DMA_SZ);
}

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    if (huart != rx_uart) return;
    uint16_t pos = Size;                         /* 1 .. RX_DMA_SZ */
    if (pos != rx_old) {
        if (pos > rx_old) {
            rx_push(&rx_dma[rx_old], pos - rx_old);
        } else {                                 /* 한 바퀴 돎 */
            rx_push(&rx_dma[rx_old], RX_DMA_SZ - rx_old);
            rx_push(&rx_dma[0], pos);
        }
        rx_old = pos;
    }
    if (rx_old == RX_DMA_SZ) rx_old = 0;         /* TC 로 끝까지 왔으면 처음으로 */
}

void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart)
{
    if (huart != rx_uart) return;
    /* ORE 등으로 HAL 이 DMA 수신을 멈췄으면 다시 건다 */
    if (huart->RxState == HAL_UART_STATE_READY) uart_rx_start(huart);
}
이 계산은 HT · TC · IDLE 이벤트를 무작위로 섞은 시뮬레이션(바이트 수만 개)으로 순서 · 누락 · 중복 없음을 확인했습니다. rx_push() 는 C 실전 탭 c22 의 단일 생산자 · 단일 소비자 링 버퍼를 쓰면 됩니다.

이벤트별 Size 값 · 주의점

이벤트Size언제
HT128DMA 가 절반 채움
TC256끝까지 채움 → 순환이면 0 부터 다시
IDLE256 − NDTR선이 1 프레임 쉼 (NDTR = DMA 남은 개수)
/* 어떤 이벤트였는지 알고 싶으면 (최근 HAL) */
HAL_UART_RxEventTypeTypeDef t = HAL_UARTEx_GetRxEventType(huart);
/* HAL_UART_RXEVENT_TC · HAL_UART_RXEVENT_HT · HAL_UART_RXEVENT_IDLE */

/* Normal(비순환) DMA 로 쓴다면 — 매 이벤트 후 다시 걸고, HT 는 끔 */
HAL_UARTEx_ReceiveToIdle_DMA(&huart2, rx_dma, RX_DMA_SZ);
__HAL_DMA_DISABLE_IT(&hdma_usart2_rx, DMA_IT_HT);
버퍼 크기는 "가장 긴 이벤트 간격 × 보레이트"보다 커야 합니다. HT · TC 가 최소 절반마다 콜백을 보장하므로 보통은 안전하지만, 더 높은 우선순위 ISR 이 오래 잡고 있으면 DMA 가 처리 안 된 데이터를 덮어써도 아무 에러가 나지 않습니다. 메시지에 길이 · CRC 를 넣어 응용 계층에서 잡는 이유입니다(통신 탭).

실무에선 이렇게 — 콜백은 복사만, 해석은 main 에서 🔴 실무

한 줄 단위 명령 처리 (main 루프)

extern int rx_pop(uint8_t *out);        /* 링 버퍼에서 1바이트, 없으면 0 */

void uart_rx_poll(void)
{
    static char line[64];
    static uint8_t n, skip;
    uint8_t c;
    while (rx_pop(&c)) {
        if (c == '\n' || c == '\r') {
            if (n && !skip) { line[n] = '\0'; cmd_handle(line); }
            n = 0; skip = 0;                /* 다음 줄 시작 */
        } else if (skip) {
            /* 넘친 줄의 나머지 — 줄 끝까지 버림 */
        } else if (n < sizeof line - 1) {
            line[n++] = (char)c;
        } else {
            skip = 1;                       /* 너무 긴 줄은 통째로 버림 (꼬리를 명령으로 오인 방지) */
        }
    }
}

현장에서 만나는 UART 증상표

증상먼저 볼 것
깨진 글자만 나옴보레이트 · HSE_VALUE · 클럭 설정 (m04)
처음 몇 바이트 후 수신 멈춤ORE 후 재시작 안 함 → ErrorCallback
DMA 수신이 아예 안 옴MX_DMA_Init() 순서 · DMA 인터럽트 NVIC 활성
가끔 앞 메시지 꼬리가 붙음버퍼 크기 부족 · 콜백에서 무거운 처리
송신 함수가 HAL_BUSY이전 _IT/_DMA 미완료 — 송신 큐 필요
PC 에서 아무것도 안 보임TX/RX 교차 연결 · GND 공통 · COM 포트 번호
콜백 안에서 하는 일은 "복사 + 플래그"까지입니다. 파싱 · printf · Flash 쓰기는 main 이나 RTOS 태스크로 넘깁니다. 콜백은 USART2 와 DMA 인터럽트 우선순위로 도는 ISR 이므로, 길어지면 다른 인터럽트까지 지연시킵니다.
정리 — 보내기는 _DMA + 살아 있는 버퍼, 받기는 순환 DMA + ReceiveToIdle + [old, pos) 계산이 현업 표준입니다. 콜백은 복사만 하고, ORE 같은 오류 뒤에는 수신을 다시 거는 코드가 반드시 있어야 합니다.
STM 08

타이머 — PWM · 입력 캡처 · 엔코더, 숫자 세 개로 시간을 만든다

STM32 타이머의 본질은 클럭을 세는 카운터입니다. 이 카운터 하나에 "몇 클럭마다 한 번 셀지(PSC)", "어디까지 세고 0 으로 돌아갈지(ARR)", "어느 값에서 무엇을 할지(CCR)" 세 숫자를 붙이면 LED 밝기 조절(PWM), 신호 주파수 측정(입력 캡처), 모터 회전 위치 읽기(엔코더)가 전부 됩니다. F446 에는 타이머가 14개 있고 기본 · 범용 · 고급(TIM1/TIM8) 세 등급으로 나뉩니다.

PWM 모드 1 — CNT 가 CCR 보다 작은 동안만 출력이 켜집니다 ARR 999 CCR 250 0 CNT (1 MHz 로 1씩 증가) 출력 1 0 한 주기 1 ms (1 kHz) 25 % f = 90 MHz ÷ (PSC+1 = 90) ÷ (ARR+1 = 1000) = 1 000 Hz 듀티 = CCR / (ARR+1) = 250/1000
파형 — ARR 이 주기, CCR 이 켜진 길이입니다. CCR 만 바꾸면 주파수는 그대로 두고 밝기만 바뀝니다.

계산 — TIM2 (APB1 타이머 클럭 90 MHz)

f_PWM = f_TIM / ((PSC + 1) × (ARR + 1))
duty  = CCR / (ARR + 1)

LED 밝기   PSC 89 · ARR 999   → 90e6 / (90 × 1000)   = 1 kHz,  해상도 1000 단계
서보 모터  PSC 89 · ARR 19999 → 90e6 / (90 × 20000)  = 50 Hz,  CCR 1000~2000 = 1~2 ms
모터 구동  PSC 0  · ARR 4499  → 90e6 / (1 × 4500)    = 20 kHz, 해상도 4500 단계(≈12비트)

TIM1 · TIM8 (APB2 타이머 180 MHz) 로 20 kHz 면 ARR 8999 → 해상도 9000 단계
PSC 를 작게, ARR 을 크게 잡을수록 듀티 해상도가 좋아집니다. 같은 1 kHz 라도 PSC 899 · ARR 99 면 100 단계뿐입니다. 모터는 사람 귀에 들리지 않도록 20 kHz 이상으로 돌리는 게 보통입니다.

HAL — PA5(LD2) 를 TIM2_CH1 PWM 으로

TIM_HandleTypeDef htim2;

void pwm_init(void)
{
  htim2.Instance               = TIM2;
  htim2.Init.Prescaler         = 89;
  htim2.Init.CounterMode       = TIM_COUNTERMODE_UP;
  htim2.Init.Period            = 999;               /* ARR */
  htim2.Init.ClockDivision     = TIM_CLOCKDIVISION_DIV1;
  htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE;
  HAL_TIM_PWM_Init(&htim2);        /* MSP 에서 TIM2 클럭 + PA5 를 AF1 로 */

  TIM_OC_InitTypeDef oc = {0};
  oc.OCMode     = TIM_OCMODE_PWM1;
  oc.Pulse      = 250;                               /* CCR */
  oc.OCPolarity = TIM_OCPOLARITY_HIGH;
  oc.OCFastMode = TIM_OCFAST_DISABLE;
  HAL_TIM_PWM_ConfigChannel(&htim2, &oc, TIM_CHANNEL_1);
  HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);
}

/* 실행 중 밝기 바꾸기 — 다음 주기부터 반영(프리로드) */
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 500);   /* 50 % */
등급F446 타이머할 수 있는 것
기본TIM6 · TIM7주기 인터럽트 · DAC 트리거만 (핀 없음). HAL 타임베이스로 자주 씀
범용TIM2 · TIM5 (32비트)
TIM3 · TIM4 (16비트)
TIM9~14
PWM · 입력 캡처 · 출력 비교 · 엔코더(TIM2~5) · 다른 타이머/ADC 트리거
고급TIM1 · TIM8위 전부 + 상보 출력(CHxN) · 데드타임 · 브레이크 입력 — 3상 모터 · 인버터용

입력 캡처 — 들어오는 신호의 주파수 재기 🟡 중급

TIM5 CH1(PA0) — 상승 엣지 사이 간격

/* CubeMX: TIM5 · Channel1 = Input Capture direct mode
   PSC = 0 (90 MHz 로 셈) · ARR = 0xFFFFFFFF (32비트 전체) · 상승 엣지 */
HAL_TIM_IC_Start_IT(&htim5, TIM_CHANNEL_1);

static volatile uint32_t ic_prev, ic_period, ic_count;

void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{
    if (htim->Instance == TIM5 && htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1) {
        uint32_t now = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1);
        ic_period = now - ic_prev;   /* 카운터가 한 바퀴 넘어가도 부호 없는 뺄셈이 맞음 */
        ic_prev   = now;
        ic_count++;                  /* 첫 값은 ic_prev=0 기준이라 버림 */
    }
}

/* main — 주파수 = 타이머 클럭 / 간격 */
if (ic_count >= 2) {
    uint32_t p = ic_period;                       /* 32비트 읽기는 원자적 */
    float hz = 90000000.0f / (float)p;            /* 1 kHz 입력 → p = 90000 */
}

측정 범위와 한계

항목TIM5 · 90 MHz · 32비트
시간 분해능1 틱 = 11.1 ns
가장 낮은 주파수2³² / 90e6 ≈ 47.7 초 주기까지
1 kHz 의 정밀도90000 틱 중 ±1 → 약 0.001 %
높은 주파수엣지마다 인터럽트 — 수백 kHz 부터 CPU 포화
16비트 타이머(TIM3 등)로 같은 코드를 쓰면 now - ic_prev 가 32비트 뺄셈이라 한 바퀴 넘었을 때 틀립니다. ARR 을 0xFFFF 로 두고 (uint16_t)(now - ic_prev) 로 잘라야 맞고, 그래도 65 536 틱 넘는 주기는 못 잽니다. 높은 주파수는 IC 프리스케일러(TIM_ICPSC_DIV8 — 8번째 엣지마다 캡처)나 DMA 캡처로 인터럽트를 줄입니다.

엔코더 모드 — 하드웨어가 회전을 세게 하기 🔴 실무

엔코더 모드가 하는 일 — A·B 두 비트의 상태 순환 (TI12 = 전이마다 1 카운트) 00 A0 B0 10 A1 B0 11 A1 B1 01 A0 B1 +1 +1 +1 +1 한 바퀴 = 4 카운트 바깥(초록) = 정방향 +1 A 가 B 보다 먼저 바뀜 안쪽(빨강) = 역방향 −1 B 가 A 보다 먼저 바뀜 00 ↔ 11 같은 대각선 전이는 없어야 정상 — 노이즈 · 놓친 엣지 600 펄스/회전 × 4 = 2400 카운트/회전 CPU 개입 0 — 카운터만 읽음 방향 판별 · 노이즈 필터(ICxFilter)까지 타이머 하드웨어가 합니다
상태 순환 — 도는 방향이 회전 방향이고 전이 수가 거리입니다. 인터럽트 없이 카운터 값만 읽으면 됩니다.

TIM3 엔코더 — 위치와 속도

TIM_Encoder_InitTypeDef e = {0};
e.EncoderMode  = TIM_ENCODERMODE_TI12;       /* A·B 엣지 전부 → ×4 */
e.IC1Polarity  = TIM_ICPOLARITY_RISING;
e.IC1Selection = TIM_ICSELECTION_DIRECTTI;
e.IC1Prescaler = TIM_ICPSC_DIV1;
e.IC1Filter    = 6;                          /* 짧은 튐 무시 (0~15) */
e.IC2Polarity  = TIM_ICPOLARITY_RISING;
e.IC2Selection = TIM_ICSELECTION_DIRECTTI;
e.IC2Prescaler = TIM_ICPSC_DIV1;
e.IC2Filter    = 6;
htim3.Instance           = TIM3;
htim3.Init.Prescaler     = 0;
htim3.Init.CounterMode   = TIM_COUNTERMODE_UP;
htim3.Init.Period        = 0xFFFF;           /* 16비트 끝까지 */
htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
HAL_TIM_Encoder_Init(&htim3, &e);
HAL_TIM_Encoder_Start(&htim3, TIM_CHANNEL_ALL);

/* 10 ms 마다 호출 — 16비트 넘김을 int16_t 차이로 흡수 */
int32_t enc_pos;                             /* 누적 위치 (카운트) */
void enc_update(void)
{
    static uint16_t last;
    uint16_t now   = (uint16_t)__HAL_TIM_GET_COUNTER(&htim3);
    int16_t  delta = (int16_t)(uint16_t)(now - last);
    last     = now;
    enc_pos += delta;                        /* rpm = delta × 100 × 60 / 2400 */
}

고급 타이머를 쓸 때 알아 둘 것

항목설명
MOE 비트TIM1/TIM8 은 주 출력 허용(MOE) 이 꺼져 있으면 핀이 안 움직임. HAL_TIM_PWM_Start 가 켜 줌. LL · 레지스터로 직접 쓰면 직접 켜야 함
상보 출력HAL_TIMEx_PWMN_Start() 로 CHxN 을 반대 위상으로
데드타임위아래 스위치가 동시에 켜지는 순간(단락)을 막는 공백. HAL_TIMEx_ConfigBreakDeadTime()
브레이크 입력과전류 신호가 오면 하드웨어가 즉시 출력을 끔 — 소프트웨어보다 빠름
int16_t 캐스팅이 핵심 — 카운터가 65535 → 0 으로 넘어갈 때 now - last 는 정수 승격 때문에 int 로 계산됩니다. (uint16_t) 로 먼저 자르고 (int16_t) 로 해석해야 +1 이 됩니다(10 ms 동안 ±32767 카운트 미만이라는 가정). 정수 승격 규칙은 C 기초 탭 c02 를 보세요.
정리 — f = f_TIM / ((PSC+1)(ARR+1)), duty = CCR/(ARR+1) 두 식이면 PWM 은 끝입니다. 입력 캡처는 32비트 타이머와 부호 없는 뺄셈으로, 엔코더는 하드웨어 카운터와 int16_t 차이로 — 둘 다 정수 넘김 처리가 실수 지점입니다.
STM 09

ADC + DMA — 여러 채널을 끊김 없이 읽는 이중 버퍼

ADC(아날로그-디지털 변환기)는 핀의 전압을 숫자로 바꿉니다. F446 의 ADC 는 12비트라 0~3.3 V 를 0~4095 로 나눕니다. 한 번 읽기는 HAL_ADC_PollForConversion 으로 충분하지만, 센서 여러 개를 일정한 간격으로 계속 읽으려면 타이머 트리거 + 스캔 + 순환 DMA 조합이 정석입니다. CPU 는 버퍼가 반쯤 찰 때마다 깨어나 계산만 합니다 — 서버의 배치 처리 + 더블 버퍼링과 똑같은 구조입니다.

이중 버퍼 — DMA 가 한쪽을 채우는 동안 CPU 는 다른 쪽을 처리합니다 DMA 가 쓰는 곳 앞 절반 [0, N) 뒤 절반 [N, 2N) 앞 절반 뒤 절반 HalfCplt Cplt HalfCplt Cplt CPU 처리 (정상) 앞 처리 뒤 처리 앞 처리 CPU 처리 (너무 김) 앞 절반 처리 중… DMA 가 이미 앞 절반을 덮어씀 0 T/2 T 3T/2 마감 = 반 주기 T/2. 처리 시간이 이보다 길면 데이터가 조용히 섞입니다
타임라인 — 콜백은 방금 다 찬 절반을 알려 줍니다. 처리는 다음 콜백 전에 끝나야 합니다.

ADC1 — 3채널 스캔 · TIM8 트리거 1 kHz · 순환 DMA

/* Nucleo A0 = PA0 (IN0), A1 = PA1 (IN1), A2 = PA4 (IN4) */
hadc1.Instance                   = ADC1;
hadc1.Init.ClockPrescaler        = ADC_CLOCK_SYNC_PCLK_DIV4;   /* 90/4 = 22.5 MHz */
hadc1.Init.Resolution            = ADC_RESOLUTION_12B;
hadc1.Init.ScanConvMode          = ENABLE;           /* 여러 채널 차례로 */
hadc1.Init.ContinuousConvMode    = DISABLE;          /* 트리거 1번 = 시퀀스 1번 */
hadc1.Init.DiscontinuousConvMode = DISABLE;
hadc1.Init.ExternalTrigConv      = ADC_EXTERNALTRIGCONV_T8_TRGO;
hadc1.Init.ExternalTrigConvEdge  = ADC_EXTERNALTRIGCONVEDGE_RISING;
hadc1.Init.DataAlign             = ADC_DATAALIGN_RIGHT;
hadc1.Init.NbrOfConversion       = 3;
hadc1.Init.DMAContinuousRequests = ENABLE;           /* 순환 DMA 에 필수 */
hadc1.Init.EOCSelection          = ADC_EOC_SEQ_CONV;
HAL_ADC_Init(&hadc1);

ADC_ChannelConfTypeDef ch = {0};
ch.SamplingTime = ADC_SAMPLETIME_84CYCLES;
ch.Channel = ADC_CHANNEL_0; ch.Rank = 1; HAL_ADC_ConfigChannel(&hadc1, &ch);
ch.Channel = ADC_CHANNEL_1; ch.Rank = 2; HAL_ADC_ConfigChannel(&hadc1, &ch);
ch.Channel = ADC_CHANNEL_4; ch.Rank = 3; HAL_ADC_ConfigChannel(&hadc1, &ch);

/* TIM8: 180 MHz / (179+1) / (999+1) = 1 kHz, 업데이트 이벤트를 TRGO 로 */
TIM_MasterConfigTypeDef m = {0};
m.MasterOutputTrigger = TIM_TRGO_UPDATE;
m.MasterSlaveMode     = TIM_MASTERSLAVEMODE_DISABLE;
HAL_TIMEx_MasterConfigSynchronization(&htim8, &m);

버퍼 · 시작 · 콜백

#define NCH   3
#define NSMP  50                         /* 반쪽에 50 시퀀스 = 50 ms 분량 */
static uint16_t adc_buf[2 * NSMP * NCH]; /* 300 개 · DMA 는 Half Word · Circular */

void adc_start(void)
{
    HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, 2 * NSMP * NCH);
    HAL_TIM_Base_Start(&htim8);          /* 트리거가 와야 변환이 시작됨 */
}

static const uint16_t *volatile ready_half;   /* main 에 넘길 반쪽 (포인터 자체가 volatile) */
uint32_t adc_mv[NCH];                          /* 채널별 평균 mV */

void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{
    if (hadc->Instance == ADC1) ready_half = &adc_buf[0];
}
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
    if (hadc->Instance == ADC1) ready_half = &adc_buf[NSMP * NCH];
}

/* main — 반쪽 하나를 채널별 평균으로 */
void adc_poll(void)
{
    const uint16_t *p = ready_half;
    if (!p) return;
    ready_half = NULL;
    uint32_t sum[NCH] = {0};
    for (int i = 0; i < NSMP; i++)
        for (int c = 0; c < NCH; c++) sum[c] += p[i * NCH + c];   /* [A0,A1,A2, A0,A1,A2, …] */
    for (int c = 0; c < NCH; c++) adc_mv[c] = (sum[c] / NSMP) * 3300u / 4095u;
}
포인터를 main 에서 읽는 시간도 마감 안에 있어야 합니다 — 50 ms 안에 adc_poll() 이 돌지 못하면 DMA 가 그 반쪽을 다시 덮어씁니다. 마감이 빠듯하면 콜백 안에서 합계만 계산해 두고 main 은 결과만 가져갑니다. HAL_ADC_Start_DMA 의 버퍼 인자가 uint32_t * 인 것은 HAL 의 역사적 시그니처일 뿐, DMA 폭은 CubeMX 의 Half Word 설정을 따릅니다.

샘플링 시간 — 빠를수록 좋은 게 아닙니다 🟡 중급

변환 1회 시간 = (샘플링 + 12) 사이클 ÷ 22.5 MHz 0 5 10 15 20 µs 3 사이클 0.67 µs 15 1.20 28 1.78 56 3.02 84 (위 예제) 4.27 — 3채널 시퀀스 12.8 µs 112 5.51 144 6.93 480 (온도 센서) 21.9 온도 센서 최소 샘플링 10 µs 샘플링 = 내부 콘덴서를 충전하는 시간 — 신호원 저항이 크면 길게 잡아야 정확합니다
막대 — 1 kHz 트리거에 12.8 µs 시퀀스면 여유가 78배입니다. 남는 시간은 정확도(긴 샘플링)에 쓰는 게 이득입니다.

샘플링 시간을 고르는 기준

신호원권장
op-amp 출력 · 저임피던스15~56 사이클이면 충분
분압 저항 10 kΩ 이상112~480 사이클, 또는 핀에 100 nF 콘덴서
내부 온도 센서 · VREFINT10 µs 이상 → 22.5 MHz 에선 480 사이클
오디오 · 고속 신호짧게 + 트리거 주기 엄수 (나이퀴스트)
왜 저항이 크면 길게? — ADC 는 변환 직전에 내부의 작은 콘덴서를 입력 전압까지 충전합니다. 신호원 저항이 크면 충전이 느려서, 샘플링 시간이 짧으면 덜 찬 전압을 읽습니다. 값이 "조금씩 낮게" 나오는 버그의 단골 원인입니다.

VDDA 를 직접 재서 보정하기 (VREFINT)

/* 공장 보정값: VDDA = 3.3 V 에서 VREFINT 를 잰 원시값 (F446 시스템 메모리) */
#define VREFINT_CAL  (*(const uint16_t *)0x1FFF7A2Au)

ch.Channel = ADC_CHANNEL_VREFINT;          /* 내부 채널 17 */
ch.Rank = 1; ch.SamplingTime = ADC_SAMPLETIME_480CYCLES;
HAL_ADC_ConfigChannel(&hadc1, &ch);       /* HAL 이 TSVREFE 비트도 켜 줌 */

/* 한 번 변환한 값 raw_vref 로 실제 VDDA 계산 */
uint32_t vdda_mv = 3300u * VREFINT_CAL / raw_vref;
/* 다른 채널을 VDDA 기준으로 환산 */
uint32_t mv = (uint32_t)raw * vdda_mv / 4095u;
배터리 기기에서 특히 중요 — 전원이 3.0 V 로 내려가면 같은 센서 전압도 원시값이 커집니다. VREFINT 로 VDDA 를 재서 나누면 전원 전압과 무관한 mV 값을 얻습니다.

실무에선 이렇게 — ADC 값이 이상할 때 🔴 실무

증상원인처방
DMA 가 한 번 돌고 멈춤DMAContinuousRequests = DISABLE 또는 DMA 가 Normal 모드둘 다 켜기 (Circular + ENABLE)
채널 값이 서로 섞임버퍼 길이가 채널 수의 배수가 아님 · 중간에 Stop/Start길이 = 2 × NSMP × NCH, 순서 [ch0,ch1,ch2] 반복
값이 ±20 정도 계속 흔들림전원 노이즈 · 샘플링 짧음평균 · 긴 샘플링 · VDDA 에 필터 (하드웨어 기초 탭)
한 채널만 옆 채널 값을 닮음고임피던스 입력의 잔류 전하샘플링 시간 늘리기 · 채널 사이 더미 변환
OVR 에러 콜백DMA 가 데이터를 못 가져감 (우선순위 · 버스 혼잡)DMA 우선순위 High · 트리거 속도 낮추기
정리 — 여러 채널을 주기적으로 읽을 땐 타이머 TRGO 트리거 + 스캔 + 순환 DMA + Half/Cplt 콜백이 정석입니다. 처리 마감은 반 주기이고, 남는 시간은 긴 샘플링과 평균으로 정확도에 씁니다.
STM 10

DMA 와 메모리 — CPU 몰래 메모리를 바꾸는 손을 다루는 규칙

DMA(Direct Memory Access)는 CPU 대신 데이터를 옮기는 별도의 하드웨어입니다. 편하지만 위험합니다 — CPU 가 모르는 사이에 메모리가 바뀌기 때문입니다. 서버로 치면 다른 프로세스가 내 메모리를 직접 쓰는 공유 메모리와 같아서, 버퍼의 수명 · 위치 · 캐시를 사람이 책임져야 합니다. m07 · m09 에서 쓴 DMA 를 이번에는 아래에서 봅니다.

DMA2 스트림 × 요청 — 칸의 숫자가 채널, 진한 칸이 겹치지 않게 고른 배정 ADC1 SPI1_RX SPI1_TX USART1_RX USART1_TX USART6_RX Stream 0 ch0 ✓ ch3 Stream 1 ch5 ✓ Stream 2 ch3 ✓ ch4 ch5 Stream 3 ch3 ✓ Stream 4 ch0 Stream 5 ch3 ch4 ✓ Stream 6 Stream 7 ch4 ✓ ADC1 이 Stream 0 을 쓰면 SPI1_RX 는 2 로, 그러면 USART1_RX 는 5 로, SPI1_TX 는 3 으로 밀려납니다 일부 발췌 — 전체 표는 RM0390 의 DMA1 · DMA2 요청 매핑 표. CubeMX 는 고를 수 있는 스트림만 목록에 보여 줍니다
매트릭스 — F4 DMA 는 요청마다 갈 수 있는 스트림이 정해져 있어 주변장치를 늘리면 퍼즐이 됩니다(G4 · L4 · H7 은 DMAMUX 로 해결).

F4 DMA 구조 요약

항목내용
컨트롤러DMA1 · DMA2, 각각 스트림 8개
채널스트림마다 요청선 8개 중 하나를 고르는 선택기 (동시에 하나만)
메모리 → 메모리DMA2 만 가능 (DMA1 은 주변장치 포트가 APB1 에만 연결)
우선순위스트림마다 Low~Very High, 같으면 스트림 번호가 작은 쪽
FIFO4워드 FIFO — 바이트로 받아 워드로 메모리에 쓰는 폭 변환 · 버스트
모드Normal(한 번) · Circular(끝나면 처음부터) · 더블 버퍼(M0AR/M1AR)
USART2 는 DMA1(RX = Stream5 · ch4, TX = Stream6 · ch4), ADC1 은 DMA2(Stream0 또는 4 · ch0) 입니다. CubeMX 가 생성한 hdma_usart2_rx 같은 핸들은 __HAL_LINKDMA() 로 주변장치 핸들에 묶여 있습니다.

버퍼 수명 — 가장 흔한 DMA 버그

/* ❌ 스택 버퍼를 DMA 에 넘기고 리턴 */
void send_status(int temp)
{
    char buf[32];                                  /* 스택 — 함수가 끝나면 남의 것 */
    int n = snprintf(buf, sizeof buf, "T=%d\r\n", temp);
    HAL_UART_Transmit_DMA(&huart2, (uint8_t *)buf, n);
}   /* ← 여기서 리턴. DMA 는 아직 보내는 중인데
         다음 함수 호출이 같은 스택 자리를 덮어씀 → 깨진 글자 */

/* ✅ 수명이 긴 버퍼 + 사용 중 표시 */
static char tx_buf[64];
static volatile uint8_t tx_busy;

int send_status_ok(int temp)
{
    if (tx_busy) return -1;                        /* 아직 이전 전송 중 */
    int n = snprintf(tx_buf, sizeof tx_buf, "T=%d\r\n", temp);
    tx_busy = 1;
    if (HAL_UART_Transmit_DMA(&huart2, (uint8_t *)tx_buf, (uint16_t)n) != HAL_OK) {
        tx_busy = 0; return -1;
    }
    return 0;
}
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART2) tx_busy = 0;
}

메모리 영역 — DMA 가 닿지 않는 RAM 🟡 중급

RAM 배치 — 칸 폭이 크기에 비례 (1 KB ≈ 3.6 px) F446 ★ SRAM1 112 KB · 0x2000 0000 · DMA ✓ SRAM2 16 KB · DMA ✓ CCM RAM 없음 — 128 KB 전부 DMA 가능. 백업 SRAM 4 KB 는 별도(전원 유지용) F407 · F429 SRAM1 112 KB · DMA ✓ SRAM2 CCM 64 KB · 0x1000 0000 · DMA ✗ CPU 데이터 버스 전용 — 스택 · 변수엔 최고, DMA 버퍼엔 불가 CCM 의 버퍼로 DMA 를 돌리면 전송 오류(TEIF) → HAL ErrorCallback — 코드만 봐선 원인이 안 보입니다 H7 은 DTCM(0x2000 0000)이 DMA1 · DMA2 에서 안 보입니다 — AXI SRAM · SRAM1~3 에 둡니다
메모리 배치 — 같은 F4 라도 CCM 이 있는 품번은 링커가 버퍼를 어디 두는지 확인해야 합니다. F446 은 해당 없음.

D-Cache 일관성 — F7 · H7 로 옮길 때 반드시 만나는 벽 🔴 고급

Cortex-M7 — CPU 는 캐시를, DMA 는 SRAM 을 봅니다 CPU D-Cache (32 B 줄) SRAM DMA 송신 1. buf[] = "OK" 쓰기 캐시에만 있음 2. Clean → SRAM 에 써냄 3. DMA 가 읽음 ✓ 수신 4. DMA 가 새 값 씀 5. 그냥 읽으면 옛 값 6. Invalidate → 캐시 줄 버림 7. 다시 읽으면 SRAM 의 새 값 Clean = 캐시 → 메모리로 내보내기 · Invalidate = 캐시 내용을 버리고 다음에 메모리에서 다시 읽기
시퀀스 — DMA 에 넘기기 전에 Clean, DMA 가 쓴 뒤 읽기 전에 Invalidate. F4(M4)에는 D-Cache 가 없어 해당 없습니다.

H7 · F7 에서의 올바른 모양

#define CACHE_LINE 32u
#define ALIGN_UP(n) (((n) + CACHE_LINE - 1u) & ~(CACHE_LINE - 1u))

/* 32 B 정렬 + 크기도 32 의 배수 — 옆 변수와 캐시 줄을 공유하지 않게 */
__attribute__((aligned(32))) static uint8_t tx_buf[ALIGN_UP(100)];   /* 128 B */
__attribute__((aligned(32))) static uint8_t rx_buf[ALIGN_UP(256)];

/* 송신 */
SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, sizeof tx_buf);
HAL_UART_Transmit_DMA(&huart3, tx_buf, len);

/* 수신 — 시작 전에도 한 번 버려 둠(더러운 줄이 나중에 덮어쓰는 것 방지) */
SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, sizeof rx_buf);
HAL_UARTEx_ReceiveToIdle_DMA(&huart3, rx_buf, sizeof rx_buf);

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, sizeof rx_buf);  /* 읽기 직전 */
    /* 이제 rx_buf 를 읽어도 됨 */
}

왜 32바이트 정렬인가 · 다른 해법

상황무슨 일이
버퍼가 줄 중간에서 시작Invalidate 가 같은 줄의 옆 변수까지 버려서, CPU 가 방금 쓴 그 변수 값이 사라짐
크기가 32 배수 아님끝 줄에서 같은 문제
MPU 로 비캐시 영역 지정DMA 버퍼 전용 구역을 캐시 안 되게 — Clean/Invalidate 불필요, 대신 CPU 접근이 느림
D-Cache 를 아예 끔문제는 사라지지만 M7 성능의 상당 부분을 버림
이 버그가 고약한 이유 — 디버거로 메모리를 보면 SRAM 의 값이 보여서 "정상"으로 보이고, printf 를 넣으면 캐시가 밀려나 증상이 사라집니다. F4 에서 잘 돌던 코드를 H7 으로 옮겼더니 "가끔" 깨진다면 1순위로 의심합니다.
F4 에서도 지킬 것 — 캐시가 없어도 컴파일러는 버퍼 값을 레지스터에 들고 있을 수 있습니다. 완료 플래그는 volatile 로, 플래그를 본 뒤 버퍼를 읽기 전에는 __DMB() 를 두면 순서가 보장됩니다.
정리 — DMA 버퍼는 전역 수명 · DMA 가 닿는 RAM · (M7 이면) 32 B 정렬 + Clean/Invalidate 세 조건을 지켜야 합니다. F4 는 요청별 스트림 표를 보고 충돌을 피해 배정하고, F446 에는 CCM 이 없어 RAM 위치 걱정은 덜합니다.
STM 11

내부 Flash 쓰기 · 옵션 바이트 — 전원이 꺼져도 남는 설정 저장하기

보정값 · 장치 설정 · 부팅 횟수처럼 전원이 꺼져도 남아야 하는 작은 데이터는 F446 에서 따로 EEPROM 이 없으므로 프로그램이 들어 있는 그 Flash에 씁니다. 서버의 파일 쓰기와 다른 점은 세 가지 — 지우기는 섹터(16~128 KB) 단위이고, 지우는 동안 CPU 가 멈추며, 0 → 1 은 지우기로만 됩니다.

F446 Flash 512 KB — 칸 폭이 크기에 비례 (16 KB 섹터는 아주 좁습니다) 섹터 번호 · 크기 · 시작 주소 S0~S3 16 KB×4 S4 64K S5 128 KB S6 128 KB S7 128 KB · 설정 0x0802 0000 0x0806 0000 끝 배치 A — 프로그램 384 KB (링커 스크립트 LENGTH = 384K) S3~S7 프로그램 (464 KB) S0 벡터 · 시작 코드 S1 · S2 = 설정 A/B 교대 (16 KB 라 지우기가 빠름) 배치 B 는 링커 스크립트를 두 영역으로 나눠야 하지만, 지우기 시간 · 수명 면에서 유리합니다
메모리 배치 — 섹터 크기가 들쭉날쭉합니다. 설정 1 KB 를 쓰려고 128 KB 를 지우는 것이 배치 A 의 대가입니다.

HAL 로 Sector 7 에 설정 저장

#define CFG_ADDR  0x08060000u          /* Sector 7 시작 */

HAL_StatusTypeDef cfg_save(const uint32_t *data, uint32_t nwords)
{
    HAL_StatusTypeDef st;
    uint32_t sector_err = 0;
    FLASH_EraseInitTypeDef er = {0};
    er.TypeErase    = FLASH_TYPEERASE_SECTORS;
    er.Sector       = FLASH_SECTOR_7;
    er.NbSectors    = 1;
    er.VoltageRange = FLASH_VOLTAGE_RANGE_3;   /* 2.7~3.6 V → 32비트 단위 */

    HAL_FLASH_Unlock();                         /* KEYR 에 키 두 개를 써서 잠금 해제 */
    __HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_EOP | FLASH_FLAG_OPERR | FLASH_FLAG_WRPERR |
                           FLASH_FLAG_PGAERR | FLASH_FLAG_PGPERR | FLASH_FLAG_PGSERR);

    st = HAL_FLASHEx_Erase(&er, &sector_err);   /* 약 1 s — 그동안 CPU 멈춤 */
    for (uint32_t i = 0; st == HAL_OK && i < nwords; i++) {
        st = HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, CFG_ADDR + 4u * i, data[i]);
    }
    HAL_FLASH_Lock();                           /* 반드시 다시 잠금 */
    return st;    /* 지우기 실패면 sector_err 에 실패 섹터 번호, 성공이면 0xFFFFFFFF */
}

/* 읽기는 그냥 메모리 읽기 */
const app_cfg_t *cfg = (const app_cfg_t *)CFG_ADDR;

Flash 쓰기 규칙

규칙설명
지우면 전부 1지운 섹터는 모든 바이트가 0xFF
쓰기는 1 → 0 만0 을 1 로 되돌리는 건 섹터 지우기뿐. F4 는 ECC 가 없어 같은 워드에 비트를 더 0 으로 만드는 덧쓰기가 됨 (L4 · G4 · H7 은 불가)
쓰기 단위전압 범위 3 에서 바이트 · 하프워드 · 워드. DOUBLEWORD 는 외부 VPP(8~9 V)가 있을 때만
수명섹터당 약 1만 번 지우기 (데이터시트 보증값)
지우기 시간대략 16 KB 0.25 s · 64 KB 0.55 s · 128 KB 1 s (최대는 약 2배)
/* 링커 스크립트 복사본(App/app_F446_FLASH.ld) — Sector 7 을 코드에서 제외 */
MEMORY
{
  RAM   (xrw) : ORIGIN = 0x20000000, LENGTH = 128K
  FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 384K   /* 원래 512K */
}
링커에서 빼지 않으면 프로그램이 커지는 날 코드가 Sector 7 까지 자라고, 첫 설정 저장에서 자기 코드를 지웁니다. 빌드 결과의 .map 파일에서 _etext 끝 주소를 CI 에서 확인하는 습관이 안전합니다.

지우는 동안 일어나는 일 — CPU 가 1초 멈춥니다 🟡 중급

Sector 7 지우기 동안 — F446 은 Flash 뱅크가 하나라 읽기와 지우기를 동시에 못 합니다 0 0.25 s 0.5 s 0.75 s 1 s Flash 지우기 CPU 명령 읽기 멈춤 — ISR 도 Flash 에 있으면 못 돎 UART _IT 수신 2번째 바이트부터 ORE — 1초 분량 유실 UART 순환 DMA DMA 는 SRAM 에 계속 받음 — 버퍼 ≥ 11.5 KB 면 무사 IWDG 0.5 s 여기서 리셋 — 지우기 중단, 섹터 반쯤 지워짐 처방: 지우기 전에 IWDG 갱신 · 타임아웃은 지우기 최대 시간보다 길게 · 통신이 조용할 때 · DMA 수신 듀얼 뱅크 칩(F42x 2 MB · G4 · H7)은 다른 뱅크에서 코드를 읽으며 지울 수 있습니다 (RWW)
간트 — 지우기 1 초는 인터럽트 1 초 정지와 같습니다. 통신 · 워치독 · 모터 제어가 모두 영향을 받습니다.

실행 중인 섹터를 지우면?

/* ❌ Sector 0 을 지우는 순간 — 벡터 테이블과 시작 코드가 사라짐 */
er.Sector = FLASH_SECTOR_0;
HAL_FLASHEx_Erase(&er, &sector_err);
/* ① 지우는 동안: CPU 가 HAL_FLASHEx_Erase 의 다음 명령을 읽으려다 멈춤
   ② 끝나고 Sector 0 에 있던 코드로 돌아가는 순간: 명령 자리가 0xFFFF...
      → 정의되지 않은 명령 → UsageFault(UNDEFINSTR · NOCP) → HardFault
   ③ 다음 인터럽트 때 벡터 테이블도 0xFFFFFFFF → 역시 폴트
   ④ 리셋해도 부팅할 코드가 없음 → 디버거로 다시 구워야 함 */
HAL_FLASHEx_Erase 자신도 Flash 에 있다는 점을 잊지 마세요. 부트로더처럼 자기 영역 밖만 지우는 구조가 아니면 펌웨어가 스스로를 고치는 코드는 RAM 에서 실행해야 합니다(__attribute__((section(".RamFunc"))) + 링커 스크립트). 자세한 OTA 구조는 양산 탭에서 다룹니다.

정전에 강한 설정 저장 — 두 섹터 교대

typedef struct {
    uint32_t magic;      /* 0xC0FFEE01 — 지워진 0xFFFFFFFF 와 구분 */
    uint32_t seq;        /* 저장할 때마다 +1 */
    uint32_t baud;
    int32_t  offset_mv;
    uint32_t crc;        /* 위 필드들의 CRC32 — 반쯤 쓰다 꺼진 것을 걸러 냄 */
} app_cfg_t;

/* 읽기: A · B 중 magic 과 crc 가 맞고 seq 가 큰 쪽을 채택
   쓰기: 채택되지 않은 쪽을 지우고 seq+1 로 기록
   → 쓰는 도중 전원이 나가도 다른 쪽이 온전히 남아 있음 */
서버로 치면 원자적 파일 교체(임시 파일에 쓰고 rename)와 같은 발상입니다. CRC 계산은 디버깅 · 테스트 탭, 더 작은 단위로 여러 번 쓰는 EEPROM 에뮬레이션 기법은 ST 의 AN3969 를 참고하세요.

옵션 바이트 — 칩 자체의 설정 🔴 실무

무엇이 들어 있나

항목뜻
RDP읽기 보호 레벨 0 / 1 / 2 — 디버거로 Flash 를 읽을 수 있나 (양산 탭)
nWRP섹터별 쓰기 보호 — 부트로더 섹터를 실수로 지우지 않게
BOR_LEV브라운아웃 리셋 전압 — 전원이 이보다 낮으면 리셋 유지 (Flash 쓰기 중 저전압 방지)
WDG_SWIWDG 를 소프트웨어로 켤지, 전원 켜자마자 하드웨어로 켤지
nRST_STOP · nRST_STDBYStop · Standby 진입 시 리셋할지
# 지금 값 보기
STM32_Programmer_CLI -c port=SWD -ob displ

HAL 로 BOR 레벨 올리기

FLASH_OBProgramInitTypeDef ob = {0};
HAL_FLASHEx_OBGetConfig(&ob);              /* 현재 값 읽기 */
if (ob.BORLevel != OB_BOR_LEVEL3) {
    HAL_FLASH_Unlock();
    HAL_FLASH_OB_Unlock();
    ob.OptionType = OPTIONBYTE_BOR;
    ob.BORLevel   = OB_BOR_LEVEL3;           /* 약 2.7 V 미만이면 리셋 */
    HAL_FLASHEx_OBProgram(&ob);
    HAL_FLASH_OB_Launch();                   /* 새 옵션 바이트 적용 */
    HAL_FLASH_OB_Lock();
    HAL_FLASH_Lock();
}
RDP 레벨 2 는 되돌릴 수 없습니다 — 디버그 포트가 영구히 닫혀 칩을 다시 굽지 못합니다. 레벨 1 → 0 으로 내리면 Flash 전체가 지워집니다. 실습 보드에서 RDP 를 건드리기 전에 양산 탭을 먼저 읽으세요.
정리 — F446 의 설정 저장은 섹터 지우기 → 워드 쓰기이고, 지우는 동안 CPU 가 멈춘다는 걸 설계에 넣어야 합니다. 저장 섹터는 링커에서 반드시 빼고, 두 섹터 교대 + CRC 로 정전에 대비합니다. 옵션 바이트의 RDP 는 양산 탭의 몫입니다.
STM 12

HardFault 첫 대응 — 무한 루프 대신 범인의 주소를 남긴다

HardFault 는 Cortex-M 의 "프로세스가 죽었다"입니다. 서버라면 코어 덤프와 스택 트레이스가 남지만, CubeMX 가 생성한 HardFault_Handler 는 그냥 무한 루프라 아무것도 남지 않습니다. 다행히 CPU 는 폴트 순간 레지스터 여덟 개를 스택에 쌓아 두고, 폴트 원인을 상태 레지스터(CFSR · HFSR)에 기록합니다. 이 절은 그 둘을 읽어 "어느 줄에서 왜"까지 가는 첫 대응을 다룹니다. 더 깊은 디버깅(GDB · 트레이스)은 디버깅 · 테스트 탭에서 이어집니다.

예외 스택 프레임 — 폴트 순간 하드웨어가 자동으로 쌓는 32 바이트 SP + 0x00 R0 (첫 번째 인자) + 0x04 R1 + 0x08 R2 + 0x0C R3 + 0x10 R12 + 0x14 LR + 0x18 PC ★ + 0x1C xPSR S0~S15 · FPSCR (+72 B) 폴트가 난 명령의 주소 (정밀 폴트일 때) → arm-none-eabi-addr2line 으로 파일:줄 그 함수가 돌아갈 주소 = 호출한 쪽 비트 9 = 정렬용 4 B 패딩 여부 · 비트 24 = Thumb FPU 를 쓰던 중이면 추가 — 합계 104 B 낮은 주소 — 폴트 순간의 MSP 또는 PSP 주소 증가 어느 스택(MSP · PSP)에 쌓였는지는 핸들러 진입 시 LR(EXC_RETURN)의 비트 2 가 알려 줍니다
메모리 배치 — SP + 0x18 의 PC 하나만 꺼내도 범인 줄을 찾는 경우가 절반입니다.

덤프를 남기는 HardFault 핸들러

/* CubeMX: System Core → NVIC → Code generation 탭에서
   "Hard fault interrupt" 의 Generate IRQ handler 체크 해제 → 직접 정의 */
#include "main.h"

typedef struct { uint32_t r0, r1, r2, r3, r12, lr, pc, xpsr; } exc_frame_t;

typedef struct {
    exc_frame_t f;
    uint32_t cfsr, hfsr, mmfar, bfar, exc_return;
} fault_dump_t;

volatile fault_dump_t g_fault;    /* .noinit 에 두면 리셋 후에도 남김 (양산 탭) */

void hardfault_c(exc_frame_t *sf, uint32_t exc_return) __attribute__((used));

__attribute__((naked)) void HardFault_Handler(void)
{
    __asm volatile(
        "tst   lr, #4      \n"   /* EXC_RETURN 비트2: 0 = MSP, 1 = PSP */
        "ite   eq          \n"
        "mrseq r0, msp     \n"
        "mrsne r0, psp     \n"
        "mov   r1, lr      \n"
        "b     hardfault_c \n");
}

void hardfault_c(exc_frame_t *sf, uint32_t exc_return)
{
    g_fault.f          = *sf;
    g_fault.cfsr       = SCB->CFSR;
    g_fault.hfsr       = SCB->HFSR;
    g_fault.mmfar      = SCB->MMFAR;
    g_fault.bfar       = SCB->BFAR;
    g_fault.exc_return = exc_return;

    if (CoreDebug->DHCSR & CoreDebug_DHCSR_C_DEBUGEN_Msk) {
        __BKPT(0);               /* 디버거가 붙어 있을 때만 — 없으면 BKPT 가 또 폴트 */
    }
    NVIC_SystemReset();
}

왜 naked 와 어셈블리인가

보통 C 함수는 시작하자마자 레지스터를 스택에 더 쌓습니다(프롤로그). 그러면 "폴트 순간의 SP" 를 C 에서 정확히 알 수 없습니다. naked 는 프롤로그를 없애서, 진입 직후의 MSP/PSP 를 그대로 첫 인자(R0)로 넘깁니다. RTOS 태스크 안에서 난 폴트는 PSP 에, main · ISR 에서 난 폴트는 MSP 에 프레임이 쌓입니다.

EXC_RETURN뜻
0xFFFFFFF9스레드 모드 · MSP · 기본 프레임
0xFFFFFFFD스레드 모드 · PSP (RTOS 태스크) · 기본 프레임
0xFFFFFFF1핸들러 모드 (ISR 안에서 폴트)
0xFFFFFFE9 / ED / E1위와 같고 FPU 확장 프레임(비트 4 = 0)
# 덤프의 PC = 0x08001A3C 였다면
arm-none-eabi-addr2line -e build/Debug/my-app.elf -f -C 0x08001A3C
#   sensor_read
#   App/drivers/sensor.c:42

CFSR · HFSR 읽기 — 원인 레지스터 해석 순서 🟡 중급

HardFault 원인 찾기 — 마름모가 질문, 오른쪽 상자가 볼 비트 HFSR.FORCED (비트 30) = 1 ? 아니오 VECTTBL(비트 1) — 벡터 테이블을 읽다 실패 VTOR · 부트 핀 · 벡터 테이블이 지워졌는지(m11) 예 — 다른 폴트가 승격됨 CFSR 의 어느 바이트? UFSR (비트 31~16) — 명령 · 상태 문제 INVSTATE · UNDEFINSTR · UNALIGNED · DIVBYZERO · NOCP BFSR (비트 15~8) — 버스 문제 PRECISERR + BFARVALID → BFAR 주소 · IMPRECISERR · STKERR MMFSR (비트 7~0) — 메모리 보호(MPU · XN) 문제 IACCVIOL · DACCVIOL + MMARVALID → MMFAR 주소 리셋 직후엔 개별 폴트 핸들러가 꺼져 있어 전부 HardFault 로 모입니다 — 그래서 FORCED 가 대부분
결정 흐름도 — HFSR → CFSR 의 세 바이트 → 주소 레지스터 순으로 좁혀 가면 원인이 거의 정해집니다.
흔한 원인남는 흔적설명 · 대응
NULL 함수 포인터 호출UFSR.INVSTATE · 프레임 PC = 0x00000000Thumb 비트(최하위 비트)가 0 인 주소로 분기 → 상태 오류. 프레임의 LR 이 호출한 곳. 콜백 포인터 미등록이 단골
NULL 포인터 읽기폴트 없음!F4 는 Flash 부팅 시 0x0000 0000 이 Flash 의 별칭이라 초기 SP 값 같은 엉뚱한 값을 읽고 계속 돎. MPU 로 0 번지를 막아 두면 DACCVIOL 로 잡힘
비정렬 접근UFSR.UNALIGNEDM4 는 LDR/STR 비정렬은 허용하지만 LDRD · LDM · STM 은 폴트. *(uint64_t *)(buf + 1), 패킹된 구조체의 double 필드
스택 오버플로BFSR.STKERR 또는 증상 없이 메모리 오염M4 엔 스택 한계 레지스터가 없음(M33 엔 있음). 링커 스크립트의 _Min_Stack_Size 확인, 스택 칠하기(0xDEADBEEF)로 사용량 측정
클럭 안 켠 주변장치 접근F4 는 대개 폴트 없음쓰기는 무시, 읽기는 0. "설정했는데 안 된다"로 나타남. __HAL_RCC_xxx_CLK_ENABLE() 누락 확인
없는 주소 접근BFSR.PRECISERR + BFAR초기화 안 된 포인터 · FMC 미설정 외부 메모리. BFAR 이 그 주소
FPU 를 안 켜고 floatUFSR.NOCP직접 만든 startup 에서 SystemInit() 의 CPACR 설정을 빠뜨린 경우
0 으로 나누기기본은 폴트 없음 (결과 0)SCB->CCR |= SCB_CCR_DIV_0_TRP_Msk 로 켜면 DIVBYZERO

실무에선 이렇게 — 폴트를 잡기 쉽게 미리 켜 두기 🔴 실무

개발 빌드에서 켜 두는 것들

void fault_debug_setup(void)
{
    /* 개별 폴트 핸들러 켜기 — HardFault 로 뭉치지 않고 종류별로 */
    SCB->SHCSR |= SCB_SHCSR_USGFAULTENA_Msk
                |  SCB_SHCSR_BUSFAULTENA_Msk
                |  SCB_SHCSR_MEMFAULTENA_Msk;

    /* 0 으로 나누기를 폴트로 */
    SCB->CCR |= SCB_CCR_DIV_0_TRP_Msk;

#ifdef DEBUG
    /* 쓰기 버퍼 끄기 — IMPRECISERR 를 PRECISERR 로 바꿔 정확한 PC 를 얻음
       (느려지므로 디버깅할 때만) */
    SCnSCB->ACTLR |= SCnSCB_ACTLR_DISDEFWBUF_Msk;
#endif
}
/* 개별 핸들러(UsageFault_Handler 등)도 HardFault 와 같은 덤프 코드를 공유 */

IMPRECISERR — PC 가 거짓말을 하는 경우

Cortex-M4 는 쓰기를 버퍼에 맡기고 다음 명령으로 넘어갑니다. 그 쓰기가 나중에 실패하면 폴트가 몇 명령 뒤에 나서, 프레임의 PC 는 범인이 아니라 범인 근처를 가리킵니다. BFAR 도 유효하지 않습니다. 위 코드처럼 쓰기 버퍼를 끄고 재현하면 정확한 위치가 나옵니다.

디버거 없이도 정보를 남기려면 — 덤프 구조체를 링커의 .noinit 영역에 두고, 리셋 후 부팅 때 매직 값을 보고 UART 로 출력하거나 Flash 에 기록합니다. 현장 장비의 "가끔 리셋된다"를 잡는 유일한 방법입니다(양산 탭 · 디버깅 탭).
폴트 핸들러 안에서 HAL 을 부르지 마세요 — HAL_Delay 는 SysTick 이 못 돌아 멈추고(m06), HAL_UART_Transmit 의 타임아웃도 같은 SysTick 기반이라 작동하지 않습니다. 레지스터로 직접 한 바이트씩 내보내거나, 기록만 하고 리셋합니다.
정리 — HardFault 를 무한 루프로 두지 말고 스택 프레임 + CFSR · HFSR · BFAR · MMFAR 를 남기게 바꿉니다. PC 를 addr2line 에 넣고, CFSR 의 바이트로 종류를 좁히면 대부분의 원인은 표 안에 있습니다.
STM 13

저전력 · HAL 함정 모음 — 잠들 줄 알고, 멈추지 않는 펌웨어

펌웨어의 대부분 시간은 아무 일도 하지 않는 시간입니다. 그 시간에 CPU 를 재우면 배터리 수명이 몇 달에서 몇 년으로 바뀝니다. F446 은 Sleep · Stop · Standby 세 단계 잠을 지원하고, 깊이 잘수록 덜 먹지만 깨어나는 비용이 큽니다. 절의 뒷부분은 이 탭 전체에서 나온 HAL 함정을 실무 체크리스트로 묶습니다.

모드별 소비 전류 — 로그 눈금, 한 칸 = 10배 (대략의 범위, 25 ℃) 0.1 µA 1 µA 10 µA 100 µA 1 mA 10 mA 100 mA Run 180 MHz 수십 mA Run 16 MHz (HSI) 수 mA Sleep 180 MHz CPU 만 멈춤 Stop 수십~수백 µA · RAM 유지 Standby 수 µA · RAM 사라짐 VBAT (RTC 만) 1 µA 안팎 CR2032(220 mAh) 기준: Stop 100 µA → 약 92일 · Standby 3 µA → 이론상 8년 이상(자가 방전이 먼저) 정확한 값은 DS10693(F446 데이터시트)의 소비 전류 표 — 클럭 · 켜 둔 주변장치 · 온도에 따라 크게 달라집니다
막대 — Run 과 Standby 는 만 배 차이입니다. 배터리 수명은 "얼마나 빨리 도나"보다 "얼마나 오래 자나"가 정합니다.
잠의 깊이 — 원이 모드, 화살표에 적힌 것이 들어가고 깨는 조건 Run 180 MHz Sleep CPU 클럭만 끔 Stop 클럭 전부 끔 Standby 코어 전원 끔 WFI 아무 인터럽트 SLEEPDEEP + WFI EXTI 이벤트 → HSI 로 깸 PDDS + WFI WKUP 핀 · RTC 알람 · NRST · IWDG → 리셋처럼 처음부터
상태 기계 — Sleep · Stop 은 그 자리에서 이어서, Standby 는 리셋으로 다시 시작합니다. 깊을수록 깨는 조건이 좁아집니다.

세 모드의 HAL 코드

/* Sleep — main 루프의 할 일이 없을 때 */
HAL_SuspendTick();                 /* 안 끄면 SysTick 이 1 ms 마다 깨움 */
HAL_PWR_EnterSLEEPMode(PWR_MAINREGULATOR_ON, PWR_SLEEPENTRY_WFI);
HAL_ResumeTick();

/* Stop — 버튼(EXTI)이나 RTC 깨우기 타이머로 깨어남 */
void enter_stop(void)
{
    HAL_SuspendTick();
    HAL_PWREx_EnableFlashPowerDown();          /* Stop 중 Flash 도 끔 (깨기 약간 느려짐) */
    HAL_PWR_EnterSTOPMode(PWR_LOWPOWERREGULATOR_ON, PWR_STOPENTRY_WFI);
    /* ← 깨어나면 여기부터. 이때 SYSCLK = HSI 16 MHz! */
    SystemClock_Config();                      /* 180 MHz 로 복구 — 빠뜨리면 UART 가 깨짐 */
    HAL_ResumeTick();
}

/* Standby — 깨면 리셋. 부팅 때 원인 확인 */
void enter_standby(void)
{
    HAL_PWR_DisableWakeUpPin(PWR_WAKEUP_PIN1);
    __HAL_PWR_CLEAR_FLAG(PWR_FLAG_WU);         /* 남아 있으면 즉시 깨어남 */
    HAL_PWR_EnableWakeUpPin(PWR_WAKEUP_PIN1);  /* PA0 상승 엣지 */
    HAL_PWR_EnterSTANDBYMode();                /* 돌아오지 않음 */
}

/* main 초기화 직후 */
if (__HAL_PWR_GET_FLAG(PWR_FLAG_SB) != RESET) {
    __HAL_PWR_CLEAR_FLAG(PWR_FLAG_SB);         /* Standby 에서 깨어난 부팅 */
}

모드 비교

SleepStopStandby
RAM · 레지스터유지유지사라짐 (백업 SRAM · RTC 만)
깨우는 것아무 인터럽트EXTI 라인 (핀 · RTC · USB 등)WKUP 핀 · RTC · NRST · IWDG
깨는 시간거의 즉시수 µs~수십 µs리셋 부팅 (ms 단위)
깬 뒤 할 일없음클럭 재설정처음부터 초기화
어울리는 곳상시 전원 장비의 유휴버튼 · 센서로 깨는 기기하루 몇 번 측정하는 기기
Stop 에 들어가면 디버거 연결이 끊깁니다. 개발 중에는 HAL_DBGMCU_EnableDBGStopMode() 로 유지할 수 있지만 그만큼 전류가 늘어나므로 양산 빌드에서는 반드시 끕니다. "책상에선 10 µA 인데 현장에선 1 mA" 의 단골 원인입니다.
남는 핀은 아날로그 모드로 — 떠 있는 입력 핀은 노이즈로 계속 0/1 을 오가며 전류를 먹습니다. 안 쓰는 GPIO 를 GPIO_MODE_ANALOG 로 두고 포트 클럭을 끄면 Stop 전류가 눈에 띄게 줄어듭니다.

HAL 함정 체크리스트 — 코드 리뷰에서 이것부터 🟡 중급

#함정왜 문제인가이렇게
1ISR · 콜백 안의 HAL_DelaySysTick 이 같거나 낮은 우선순위면 영원히 멈춤 (m06)플래그 + 시각만 남기고 main 에서 판단
2콜백 안의 무거운 작업콜백은 ISR. printf · 파싱 · Flash 쓰기는 다른 인터럽트를 밀리게 하고 UART 오버런 유발복사 + 플래그, 처리는 main · 태스크
3HAL_MAX_DELAY 타임아웃I2C 버스가 SDA 를 잡고 멈추면 영원히 대기예상 시간 × 수 배의 유한 타임아웃 + 워치독
4반환값 무시HAL_BUSY · HAL_TIMEOUT 를 버리면 "가끔 안 보내짐"이 됨모든 HAL 호출의 반환값 확인 · 기록
5타임아웃 뒤 그대로 재시도주변장치 상태가 BUSY 로 남아 이후 호출이 전부 실패HAL_xxx_DeInit() → Init(), I2C 는 버스 복구(통신 탭)
6같은 핸들을 main 과 ISR 에서 호출__HAL_LOCK 은 원자적이지 않은 검사 · 대입 — 재진입하면 상태가 꼬임한 핸들은 한 문맥에서만, 나머지는 큐로 넘김
7DMA 버퍼가 지역 변수리턴 후 스택이 덮임 (m10)static · 전역 버퍼 + 완료 콜백까지 보존
8콜백 이름 오타 · C++ 맹글링약한 기본 함수가 그대로 불려 아무 일도 안 일어남nm 으로 확인 · extern "C"
9재생성 후 사라진 코드USER CODE 영역 밖에 작성 (m03)App/ 로 분리, 생성 파일엔 호출만
10Stop 에서 깬 뒤 클럭 미복구HSI 16 MHz 로 돌아 보레이트 · 타이머가 전부 틀어짐깨어난 직후 SystemClock_Config()

실무에선 이렇게 — 타임아웃 있는 HAL 호출의 표준형 🔴 실무

❌ 흔한 코드 → ✅ 리뷰 후

/* ❌ 센서가 빠지거나 버스가 멈추면 장비 전체가 멈춤 */
HAL_I2C_Mem_Read(&hi2c1, 0x48 << 1, 0x00, I2C_MEMADD_SIZE_8BIT,
                 buf, 2, HAL_MAX_DELAY);

/* ✅ 유한 타임아웃 · 반환값 처리 · 복구 · 기록 */
#define I2C_TMO_MS  10u          /* 400 kHz 에서 주소 포함 5바이트 ≈ 0.11 ms — 약 90배 여유 */

int sensor_read(int16_t *out)
{
    uint8_t b[2];
    HAL_StatusTypeDef st = HAL_I2C_Mem_Read(&hi2c1, 0x48 << 1, 0x00,
                               I2C_MEMADD_SIZE_8BIT, b, 2, I2C_TMO_MS);
    if (st != HAL_OK) {
        err_count[ERR_I2C]++;                   /* 현장 진단용 카운터 */
        if (st == HAL_TIMEOUT || st == HAL_BUSY) {
            HAL_I2C_DeInit(&hi2c1);             /* 상태 머신 초기화 */
            HAL_I2C_Init(&hi2c1);
        }
        return -1;
    }
    *out = (int16_t)((b[0] << 8) | b[1]);
    return 0;
}

main 루프의 기본 골격 — 슈퍼 루프 + 잠

void app_loop(void)
{
    uart_rx_poll();                     /* m07 */
    adc_poll();                         /* m09 */
    if (btn_pressed()) handle_button(); /* m05 · m06 */
    led_tick(HAL_GetTick());

    HAL_IWDG_Refresh(&hiwdg);           /* 루프가 멈추면 리셋되도록 */

    /* 할 일이 없으면 다음 인터럽트까지 잔다 — 1 ms SysTick 이 깨우므로
       Sleep 로도 평균 전류가 크게 줄어듦 */
    __WFI();
}
이 골격의 한계 — 작업이 늘어나 한 바퀴가 길어지면 응답 시간이 서로의 처리 시간에 묶입니다. 그때가 FreeRTOS 탭으로 넘어갈 때입니다. 태스크 우선순위 · 큐 · ISR 에서 FromISR API 를 부르는 규칙이 여기서 본 NVIC 우선순위(m06)와 그대로 이어집니다.
워치독 갱신은 "모든 작업이 정상일 때"만 — 인터럽트 안에서 갱신하면 main 이 멈춰도 워치독이 계속 만족해 버립니다. Flash 지우기(m11)처럼 긴 작업 앞뒤로 갱신 시점을 설계합니다.
정리 — 배터리 기기는 얼마나 깊이, 얼마나 오래 자나로 수명이 정해지고, Stop 에서 깬 뒤엔 클럭을 다시 맞춥니다. HAL 은 편하지만 ISR 안의 대기 · 무한 타임아웃 · 반환값 무시 · 재진입 네 가지가 현장 장애의 대부분입니다 — 리뷰는 이 표부터.
C17 기준 (C23 표기) · 배열 스택 · 원형 버퍼 · 덱 · 침입형 리스트 · 이진 힙 · pthread 큐 · stdatomic SPSC · ISR 링

표준에 없으니
잘 만드는 법이
전부입니다

자바에는 ArrayDeque, 파이썬에는 deque 가 있습니다. C 에는 스택도 큐도 표준에 없습니다. 그래서 C 코드베이스마다 자기 링 버퍼와 자기 스택이 있고, 버그도 거의 같은 자리에서 납니다 — 가득 참과 비어 있음을 헷갈리는 한 칸 차이, 부호 있는 인덱스의 넘침, realloc 실패 때 날아가는 포인터, 그리고 인터럽트와 메인 루프가 같이 만지는 변수. 이 탭은 이론이 아니라 C 로 이 자료구조를 "제대로" 만드는 법을 다룹니다 — API 규약, 타입 일반화 세 가지, 2의 거듭제곱 마스크와 무한 증가 인덱스, 리눅스 커널식 침입형 리스트, 비교 함수 포인터로 만드는 힙, 그리고 memory_order_acquire/release 를 정확히 쓴 락 없는 SPSC 링까지. 인덱스 넘침처럼 손으로 틀리기 쉬운 계산은 같은 알고리즘을 uint32 산술로 옮겨 실제로 돌려 출력 주석을 맞췄습니다.

12SECTIONS
C17기준 (C23 배지)
17시각화
0malloc 없는 큐 구현
SQ 01

한 장 지도 — C 에는 표준 스택·큐가 없다

C 표준 라이브러리가 주는 자료구조 도구는 qsort 와 bsearch 가 사실상 전부입니다. 그러니 "무슨 타입을 쓰나"가 아니라 "어떤 모양으로 만드나"를 먼저 정해야 합니다. 답을 가르는 질문은 셋뿐입니다 — 누가 같이 쓰나, 순서가 우선순위인가, 최대 개수를 아는가. 동적 배열·연결 리스트의 기본은 ⚙️ C 실전 탭 c14 에 있으니, 여기서는 그 위에 스택·큐·덱을 세웁니다.

무엇으로 만들까 — 마름모가 질문, 초록 상자가 C 의 답입니다 스레드 · ISR 이 같이 쓰나? 예 1 대 1 → SPSC 링 (SQ 07) N 대 M → 뮤텍스 큐 (SQ 06) 아니오 우선순위대로 꺼내나? 예 배열 이진 힙 (SQ 05) 비교 함수 포인터 하나 아니오 최대 개수를 아는가? 예 정적 배열 · 2ⁿ 링 malloc 0 회 (SQ 02 · 03) 아니오 realloc 2배 스택 · 덱 피할 것 원소마다 malloc 하는 큐 → 단편화 · 캐시 미스 int 인덱스 + % 연산 → 넘치면 UB · 음수 나머지 재귀로 하는 깊은 탐색 → MCU 호출 스택 붕괴 ISR · 메인이 같이 count++ → 갱신 유실 (SQ 07) 질문 셋에 답하면 모양이 정해집니다
C 의 선택은 타입 이름이 아니라 모양입니다 — 같이 쓰는 사람, 순서 규칙, 최대 개수 세 가지가 모양을 정합니다.
필요한 것C 에서 권장하는 모양쓰지 말 것 (흔한 옛 방식)섹션
스택 (LIFO)배열 + top 인덱스. 최대치 모르면 realloc 2배노드마다 malloc 하는 연결 스택SQ 02
큐 (FIFO)2의 거듭제곱 원형 버퍼 + 마스크memmove 로 앞을 당기는 배열 큐SQ 03
덱 (양끝)원형 버퍼에 head + len, 자랄 때 두 조각 복사원형인데 그냥 realloc 만 하는 코드SQ 04
할당 없는 큐침입형 리스트 (list_head + container_of)포인터를 감싸는 노드를 따로 mallocSQ 04
우선순위 큐 · 타이머배열 이진 힙 + 비교 함수 포인터매번 정렬된 연결 리스트에 삽입 O(n)SQ 05
스레드 사이 (N:M)뮤텍스 + 조건 변수 2개 + 닫힘 플래그usleep 폴링 · volatile 플래그SQ 06
스레드 · ISR 사이 (1:1)SPSC 링 — _Atomic 인덱스 acquire/release공유 count 를 양쪽에서 증감SQ 07
RTOS 태스크 사이RTOS 가 주는 큐 (xQueueSend)RTOS 위에서 직접 만든 블로킹 큐FreeRTOS 탭 r05

표준과 그 주변에 실제로 있는 것

어디에무엇이현실
ISO C17 / C23없음 (qsort · bsearch 뿐)직접 만듭니다
POSIX <search.h>insque · remque1980년대 API, 거의 안 씀
BSD <sys/queue.h>STAILQ · TAILQ 매크로 (침입형)glibc · macOS 에 있음, musl 등엔 없어 복사
리눅스 커널list.h · kfifo커널 안에서만 (GPL)
GLibGQueue · GAsyncQueue데스크톱 앱이면 편함, MCU 엔 무거움
헤더 한 장stb_ds.h · klib · lwrb복사해 넣는 방식, 임베디드 친화

이 탭 전체에 걸쳐 지키는 API 규약 다섯 가지

// ring.h — 헤더 첫머리에 "계약"을 적어 둡니다
//
// 1. 실패할 수 있는 연산은 bool 을 돌려주고 값은 out 으로.
//      bool ring_pop(Ring *r, int *out);   // false 면 *out 손대지 않음
//    → -1 같은 "특수값"은 정상 값과 겹치면 구분이 안 됩니다
//
// 2. 용량은 2의 거듭제곱, 컴파일 타임에 검사 (SQ 03 · SQ 09)
// 3. 인덱스는 부호 없는 타입 (uint32_t / size_t) — 넘쳐도 정의된 동작
// 4. 메모리는 init 에서 한 번. push/pop 안에서는 malloc 금지
// 5. 동시성 계약을 글로:
//      "ring_push 는 ISR 하나에서만, ring_pop 은 main 하나에서만"
//    → 이 한 줄이 없으면 다음 사람이 두 번째 ISR 에서 push 합니다
자바와 비교하면 — 자바의 offer/poll 이 "특수값(null) 반환", add/remove 가 "예외" 쌍인 것처럼, C 에서는 bool + out 매개변수가 예외 없는 언어의 정석입니다. C 에는 예외가 없으니 반환값을 무시하지 못하게 C23 [[nodiscard]] 를 붙이면 더 좋습니다 C23.

왜 C 에선 직접 만드는 게 정답일 때가 많은가 🟡 중급

제네릭 컨테이너가 없는 대신 얻는 것

다른 언어의 범용 큐는 "어떤 상황에서도 동작"하도록 만들어져 있습니다 — 성장, 예외, 박싱, 락. C 로 직접 만들면 상황에 딱 맞는 것만 남길 수 있습니다.

  • 크기가 컴파일 타임에 고정 → 링커 맵에서 RAM 사용량이 바이트 단위로 보입니다
  • 원소가 값으로 박혀 있음 → 포인터 추적 없이 연속 메모리 (SQ 09)
  • 동시성 모델이 하나 → ISR 하나·메인 하나면 락이 필요 없습니다 (SQ 07)
  • 실패 정책을 고름 → 가득 차면 버릴지, 가장 오래된 것을 덮을지 (SQ 12 로그 링)

그래도 가져다 쓰는 편이 나은 경우

  • RTOS 위 — 블로킹·타임아웃·우선순위 상속까지 RTOS 큐가 이미 합니다. 직접 만든 condvar 큐보다 정확합니다 (FreeRTOS 탭 r05)
  • 리눅스 사용자 공간의 MPMC 고성능 큐 — 다중 생산자·다중 소비자 락프리는 직접 만들지 마세요. liblfds · concurrencykit 같은 검증된 구현을 씁니다
  • GLib 기반 앱 — GAsyncQueue 가 메인 루프와 잘 맞물립니다
직접 만들 때의 최소 조건 — 경계 테스트(0개, 1개, 가득, 가득−1, 인덱스 넘침 직전)를 단위 테스트로 고정해 두세요. 링 버퍼 버그의 대부분은 이 다섯 점 중 하나에서 납니다. 테스트 프레임워크는 ⚙️ C 실전 탭 c24 의 Unity 를 그대로 씁니다.
정리 — C 에는 표준 스택·큐가 없으므로 배열 스택 · 2의 거듭제곱 링 · 이진 힙 · 침입형 리스트 네 모양을 손에 익히고, 같이 쓰는 주체(스레드·ISR)에 따라 뮤텍스 큐나 SPSC 링을 고르면 실무의 거의 전부가 덮입니다.
SQ 02

스택 — 배열과 top 하나, 그리고 타입 일반화 세 가지

스택은 C 로 만들 수 있는 가장 단순한 자료구조입니다. 배열 하나와 정수 하나면 끝납니다. 단순한 만큼 차이는 규약에서 납니다 — top 이 "마지막 원소"인지 "다음 빈칸"인지, 빈 스택에서 pop 하면 무엇을 돌려주는지, 늘어날 때 실패하면 기존 데이터가 살아 있는지. 그리고 int 전용을 넘어 아무 타입이나 담는 방법이 C 에는 세 가지 있습니다.

용량 4 스택 — top 은 "다음에 넣을 칸" = 지금 개수 ① 초기화 ← top=0 ② push 10, 20 20 10 ← top=2 ③ push 30, 40 40 30 20 10 top=4 가득 ④ pop → 40 40 (잔해) 30 20 10 ← top=3 칸 번호 3 · 2 · 1 · 0 (위에서 아래로) ⑤ push 50 → top == 4 이므로 false — 배열 밖에 쓰지 않습니다 (오버플로 검사) pop 은 값을 지우지 않습니다 — top 만 내려갑니다. 비밀번호면 직접 0 으로 덮으세요 빈 스택 pop → top == 0 이므로 false — 언더플로 검사
스택의 상태는 top 하나가 전부입니다. 칸에 남은 값은 top 위에 있는 한 없는 것입니다.

고정 크기 스택 — 전체 코드 (malloc 0회)

#include <stdio.h>
#include <stdbool.h>
#include <stddef.h>

#define STACK_CAP 4

typedef struct {
    int    items[STACK_CAP];
    size_t top;                 // 다음에 넣을 칸 = 현재 개수
} IntStack;

static void stack_init(IntStack *s)        { s->top = 0; }
static bool stack_empty(const IntStack *s) { return s->top == 0; }
static bool stack_full(const IntStack *s)  { return s->top == STACK_CAP; }

static bool stack_push(IntStack *s, int v) {
    if (stack_full(s)) return false;         // 오버플로 — 거절
    s->items[s->top++] = v;                  // 쓰고 나서 올림
    return true;
}
static bool stack_pop(IntStack *s, int *out) {
    if (stack_empty(s)) return false;        // 언더플로 — *out 그대로
    *out = s->items[--s->top];               // 내리고 나서 읽음
    return true;
}
static bool stack_peek(const IntStack *s, int *out) {
    if (stack_empty(s)) return false;
    *out = s->items[s->top - 1];
    return true;
}

int main(void) {
    IntStack s;
    stack_init(&s);
    for (int i = 1; i <= 5; i++)
        if (!stack_push(&s, i * 10))
            printf("push %d 실패: 가득 참\n", i * 10);
    int v;
    while (stack_pop(&s, &v)) printf("pop %d\n", v);
    if (!stack_pop(&s, &v)) printf("빈 스택 pop: false\n");
    return 0;
}
// 출력:
// push 50 실패: 가득 참
// pop 40
// pop 30
// pop 20
// pop 10
// 빈 스택 pop: false

top 규약 두 가지 — 하나로 통일하세요

top = 다음 빈칸 (권장)top = 마지막 원소
초기값0-1 → 부호 있는 타입 필요
pusha[top++] = va[++top] = v
popv = a[--top]v = a[top--]
개수toptop + 1
가득 참top == CAPtop == CAP - 1

"다음 빈칸" 규약은 size_t 를 그대로 쓸 수 있고(−1 이 필요 없음), 개수와 top 이 같아 off-by-one 이 생길 자리가 하나 줄어듭니다. 교과서의 top = -1 은 int 를 강요하고, top == CAP - 1 같은 "−1 붙은 비교"를 낳습니다.

빈 스택 처리 세 갈래 — ① bool 반환(위 코드, 라이브러리용) · ② assert(!stack_empty(s))(빈 pop 이 곧 로직 버그인 내부 코드) · ③ 특수값 반환(-1, INT_MIN) — ③ 은 정상 값과 겹치는 순간 버그이므로 피합니다. assert 는 NDEBUG 빌드에서 사라진다는 점도 기억하세요.

최대 크기를 모를 때 — realloc 2배 성장 🟡 중급

동적 스택 — 실패해도 기존 데이터를 지킵니다

#include <stdlib.h>
#include <stdint.h>
#include <stdbool.h>

typedef struct { int *data; size_t len, cap; } DynStack;

#define DYNSTACK_INIT { NULL, 0, 0 }

bool dstack_push(DynStack *s, int v) {
    if (s->len == s->cap) {
        size_t ncap = s->cap ? s->cap * 2 : 8;
        if (ncap > SIZE_MAX / sizeof *s->data)     // 곱셈 넘침 검사
            return false;
        int *p = realloc(s->data, ncap * sizeof *p);
        if (!p) return false;   // ★ s->data 는 아직 유효 — 원소 그대로
        s->data = p;            //   성공한 뒤에만 덮어씁니다
        s->cap  = ncap;
    }
    s->data[s->len++] = v;
    return true;
}

bool dstack_pop(DynStack *s, int *out) {
    if (s->len == 0) return false;
    *out = s->data[--s->len];
    return true;
}

void dstack_free(DynStack *s) {
    free(s->data);
    *s = (DynStack)DYNSTACK_INIT;     // 해제 후 재사용해도 안전
}

// 1000 번 push 하면: realloc 8 회 (cap 8→16→…→1024)
// 옮겨진 원소 최악 8+16+…+512 = 1016 개 < 2n → 분할 상환 O(1)

줄일 때는 1/4 에서 절반으로 — 진동 방지

// pop 뒤에 호출 (선택 사항)
static void dstack_shrink(DynStack *s) {
    // len 이 cap 의 1/4 이하일 때만 절반으로.
    // 1/2 에서 줄이면 경계에서 push/pop 반복 시
    // 매번 realloc 이 일어납니다 (스래싱)
    if (s->cap > 8 && s->len <= s->cap / 4) {
        size_t ncap = s->cap / 2;
        int *p = realloc(s->data, ncap * sizeof *p);
        if (p) { s->data = p; s->cap = ncap; }
        // 줄이기 실패는 무시해도 됩니다 — 큰 버퍼 그대로 써도 정답
    }
}
2배를 고정하지 않아도 됩니다 — 1.5배(cap + cap/2)는 해제된 이전 블록을 나중에 재사용할 여지가 생겨 메모리 효율이 조금 낫다는 이유로 쓰이기도 합니다. 핵심은 곱하기라는 점이고, +16 같은 더하기 성장은 O(n²) 입니다. 근거와 그래프는 c14 에 있습니다.
임베디드에서는 쓰지 않습니다 — 2 KB SRAM 에서 realloc 은 단편화로 "남은 메모리는 충분한데 할당 실패"를 만듭니다. 최대치를 계산해 고정 크기로 두세요 (SQ 09).

아무 타입이나 담기 — void* · 매크로 생성 · _Generic 🔴 고급

타입 일반화 세 방식 — 색이 진할수록 유리 (칸 글자가 근거) 타입 검사 속도 코드 크기 디버깅 호출 이름 ① void* + 크기 없음 memcpy 한 벌 평범한 함수 하나 + 캐스트 ② 매크로 생성 완전 인라인 대입 타입마다 복제 한 줄에 뭉침 int_stack_push ③ ② + _Generic 완전 인라인 대입 타입마다 복제 더 뭉침 push(s, v) 하나 라이브러리 경계(.so · 다른 모듈)를 넘기려면 ① — 함수 하나로 모든 타입 한 파일 안의 뜨거운 경로면 ② — 컴파일러가 int 대입 한 줄로 만듭니다 ③ 은 호출부 편의일 뿐 — 내부는 ② 와 같은 코드입니다
하나가 정답이 아닙니다 — 경계를 넘으면 void*, 안쪽 뜨거운 경로면 매크로 생성입니다.

① void* + 원소 크기 — memcpy 로 복사

#include <string.h>   // memcpy
#include <stdlib.h>
#include <stdbool.h>

typedef struct {
    unsigned char *data;   // 바이트 단위로 계산하려고 char 계열
    size_t len, cap, esz;  // esz = 원소 하나의 바이트 수
} GStack;

bool gstack_init(GStack *s, size_t esz, size_t cap) {
    s->data = malloc(cap * esz);     // (cap * esz 넘침 검사 생략)
    s->len = 0; s->cap = cap; s->esz = esz;
    return s->data != NULL;
}
bool gstack_push(GStack *s, const void *elem) {
    if (s->len == s->cap) return false;
    memcpy(s->data + s->len * s->esz, elem, s->esz);
    s->len++;
    return true;
}
bool gstack_pop(GStack *s, void *out) {
    if (s->len == 0) return false;
    s->len--;
    memcpy(out, s->data + s->len * s->esz, s->esz);
    return true;
}

// 사용 (함수 안에서) — 값이 아니라 주소를 넘깁니다
typedef struct { short x, y; } Pt;
GStack st;
gstack_init(&st, sizeof(Pt), 64);        // ★ sizeof(Pt*) 로 쓰면 조용히 망가짐
Pt p = { 3, 4 };
gstack_push(&st, &p);
Pt q;
gstack_pop(&st, &q);                     // q = {3, 4}
타입 검사 0 — gstack_push(&st, &some_int) 도 컴파일됩니다. esz 가 상수로 보이면 컴파일러가 memcpy 를 대입으로 바꾸기도 하지만, 다른 번역 단위에서 부르면 진짜 함수 호출이 남습니다.

② 매크로로 타입별 생성 + ③ _Generic 디스패치

#include <stdbool.h>
#include <stddef.h>

// ② T 하나에 구조체 + 함수 세 개를 찍어냅니다
#define DEFINE_STACK(T, CAP)                                      \
    typedef struct { T items[CAP]; size_t top; } T##_stack;       \
    static inline void T##_stack_init(T##_stack *s) { s->top = 0; } \
    static inline bool T##_stack_push(T##_stack *s, T v) {        \
        if (s->top == (CAP)) return false;                        \
        s->items[s->top++] = v; return true; }                    \
    static inline bool T##_stack_pop(T##_stack *s, T *out) {      \
        if (s->top == 0) return false;                            \
        *out = s->items[--s->top]; return true; }

DEFINE_STACK(int, 32)       // int_stack, int_stack_push ...
DEFINE_STACK(double, 16)    // double_stack ...

// ③ C11 _Generic — 스택 포인터의 타입을 보고 함수를 고릅니다
#define stack_push(s, v) _Generic((s),          \
        int_stack *:    int_stack_push,         \
        double_stack *: double_stack_push)(s, v)
#define stack_pop(s, out) _Generic((s),         \
        int_stack *:    int_stack_pop,          \
        double_stack *: double_stack_pop)(s, out)

void demo(void) {
    int_stack a;    int_stack_init(&a);
    double_stack b; double_stack_init(&b);
    stack_push(&a, 7);       // → int_stack_push
    stack_push(&b, 2.5);     // → double_stack_push
    double d;
    stack_pop(&b, &d);       // d = 2.5
    // stack_pop(&a, &d);    // ✗ 컴파일 에러: double* 를 int* 로 못 넘김
}
토큰 붙이기의 한계 — DEFINE_STACK(unsigned int, 8) 이나 DEFINE_STACK(char *, 8) 은 이름에 공백·* 가 들어가 깨집니다. typedef unsigned int uint; 처럼 한 단어 이름을 먼저 만드세요. _Generic 의 기본 문법은 ⚙️ C 실전 탭 c21 에 있습니다.
정리 — 스택은 top = 다음 빈칸 규약 + bool 반환 + out 매개변수로 만들고, 최대치를 알면 고정 배열, 모르면 성공한 뒤에만 포인터를 덮는 realloc 2배. 타입 일반화는 모듈 경계면 void*, 뜨거운 내부면 매크로 생성(+ 필요하면 _Generic).
SQ 03

큐(FIFO) — 원형 버퍼와 "가득 참 vs 비어 있음" 세 가지 해법

배열로 큐를 만들면 앞에서 뺄 때마다 남은 원소를 당겨야 합니다(memmove, O(n)). 그래서 C 의 큐는 거의 언제나 원형 버퍼입니다 — 쓰는 자리(head)와 읽는 자리(tail)가 배열 위를 돌고, 끝에 닿으면 처음으로 돌아갑니다. 문제는 하나입니다. head == tail 이 "비었다"인지 "꽉 찼다"인지 구분이 안 됩니다. 해법이 셋 있고, 무엇을 고르느냐가 나중에 락 없이 쓸 수 있는지(SQ 07)를 결정합니다.

용량 8 이 "가득 찬" 순간 — 세 방식이 같은 상태를 다르게 기록합니다 0 1 2 3 4 5 6 7 ① 한 칸 비우기 빈칸 H T 7 개만 담음 (H+1)&7 == T ② count 유지 H = T = 3 count == 8 양쪽이 count 수정 ③ 무한 증가 인덱스 H&7 = T&7 = 6 H − T == 8 각자 자기 것만 ③ 의 실제 값: T = 4294967294, H = 6 — 32비트가 한 바퀴 돌았지만 H − T = 8 초록 칸 = 데이터 · H = 다음에 쓸 자리 · T = 다음에 읽을 자리
한 칸 비우기는 용량 하나를 버리고, count 는 공유 변수 하나를 늘리고, 무한 증가 인덱스는 둘 다 안 합니다.
방식비었다가득 찼다쓸 수 있는 칸쓰는 변수ISR ↔ 메인 락 없이
① 한 칸 비우기h == t((h+1) & M) == tN − 1생산자 h · 소비자 t가능
② count 유지count == 0count == NN양쪽이 count불가 (갱신 유실)
③ 무한 증가 인덱스h == th - t == NN생산자 h · 소비자 t가능 — 권장

③ 무한 증가 인덱스 — 권장 구현 (전체)

#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>   // PRIu32
#include <stdbool.h>

#define Q_CAP  8u                       // 반드시 2의 거듭제곱
#define Q_MASK (Q_CAP - 1u)
_Static_assert(Q_CAP != 0 && (Q_CAP & (Q_CAP - 1u)) == 0,
               "Q_CAP must be a power of two");

typedef struct {
    int      buf[Q_CAP];
    uint32_t head;     // 지금까지 넣은 총 개수 (계속 증가, 넘쳐도 됨)
    uint32_t tail;     // 지금까지 뺀 총 개수
} Queue;

static inline uint32_t q_count(const Queue *q) { return q->head - q->tail; }
static inline bool q_empty(const Queue *q) { return q->head == q->tail; }
static inline bool q_full(const Queue *q)  { return q_count(q) == Q_CAP; }

bool q_push(Queue *q, int v) {
    if (q_full(q)) return false;
    q->buf[q->head & Q_MASK] = v;       // 칸 번호는 마스크로
    q->head++;                          // 인덱스는 그냥 +1
    return true;
}
bool q_pop(Queue *q, int *out) {
    if (q_empty(q)) return false;
    *out = q->buf[q->tail & Q_MASK];
    q->tail++;
    return true;
}

int main(void) {
    // 넘침 직전에서 시작해 경계를 일부러 밟습니다 (테스트에도 이렇게)
    Queue q = { .head = 0xFFFFFFFEu, .tail = 0xFFFFFFFEu };
    for (int i = 1; i <= 9; i++)
        if (!q_push(&q, i))
            printf("push %d 거절 (count=%" PRIu32 ")\n", i, q_count(&q));
    printf("head=%" PRIu32 " tail=%" PRIu32 " count=%" PRIu32 "\n",
           q.head, q.tail, q_count(&q));
    int v;
    while (q_pop(&q, &v)) printf("%d ", v);
    printf("\nhead=%" PRIu32 " tail=%" PRIu32 "\n", q.head, q.tail);
    return 0;
}
// 출력:
// push 9 거절 (count=8)
// head=6 tail=4294967294 count=8
// 1 2 3 4 5 6 7 8
// head=6 tail=6

① · ② — 크기가 2의 거듭제곱이 아닐 때

// ① 한 칸 비우기 — N 이 아무 값이어도 됩니다 (N-1 개 저장)
#define N 10
typedef struct { int buf[N]; uint16_t h, t; } Ring1;

static inline uint16_t next(uint16_t i) {
    return (uint16_t)(i + 1 == N ? 0 : i + 1);   // % 대신 비교 — 나눗셈 없음
}
bool r1_push(Ring1 *r, int v) {
    uint16_t nh = next(r->h);
    if (nh == r->t) return false;      // 한 칸 남았으면 '가득'
    r->buf[r->h] = v;
    r->h = nh;
    return true;
}
bool r1_pop(Ring1 *r, int *out) {
    if (r->h == r->t) return false;
    *out = r->buf[r->t];
    r->t = next(r->t);
    return true;
}

// ② count 유지 — 단일 스레드라면 가장 읽기 쉽습니다
typedef struct { int buf[N]; uint16_t h, t, count; } Ring2;

bool r2_push(Ring2 *r, int v) {
    if (r->count == N) return false;
    r->buf[r->h] = v;
    r->h = next(r->h);
    r->count++;           // ★ 소비자도 count-- 를 합니다 → 공유 쓰기
    return true;
}
③ 은 2의 거듭제곱에서만 — 크기가 10 이면 무한 증가 인덱스를 쓸 수 없습니다(아래 계산). 크기를 바꿀 수 없다면 ① 을 쓰고, count 방식(②)은 스레드·ISR 가 끼는 순간 버립니다 — count++ 는 읽기·더하기·쓰기 세 동작이라 양쪽에서 하면 하나가 사라집니다(SQ 07).

무한 증가 인덱스가 넘쳐도 안전한 이유 — 계산으로 확인 🔴 고급

uint32_t 는 직선이 아니라 원입니다 — 맨 위가 0 = 2³² 0 = 2³² tail 4294967294 head 6 head − tail = 8 (mod 2³²) 초록 호 = 큐 안의 8 개 1 부호 없는 뺄셈은 mod 2³² — 넘침이 아니라 한 바퀴 6 − 4294967294 = −4294967288 → +2³² → 8 2 칸 번호 = 인덱스 & 7 — 2³² 이 8 의 배수 …294 → 6 · …295 → 7 · 0 → 0 · 1 → 1 (끊김 없음) 3 조건은 하나 — 실제 개수 < 2³² 개수는 늘 N 이하라 언제나 성립 N = 6 이면 2³² mod 6 = 4 — 한 바퀴 돌 때 칸 번호가 튑니다 (아래 표)
핵심 두 줄 — 부호 없는 산술은 원 위의 산술이고, N 이 2³² 를 나누면 원을 몇 바퀴 돌아도 칸 번호가 이어집니다.

경계에서 실제로 돌려 본 값

상황계산결과
uint32, N=8, 넘침 통과3u - 0xFFFFFFFEu5 ✔
uint32, 칸 번호0xFFFFFFFEu & 76 ✔
uint8, N=6, 255→0255 % 6, 0 % 63 → 0 (4 여야 함) ✘
uint8, N=256256 개 넣은 뒤 (uint8_t)(h - t)0 = "비었음" ✘
uint8, N=128128 개 넣은 뒤128 = 가득 ✔
uint16 (int 32비트 CPU)h - t (h=2, t=65534)−65532 ✘
같은 식에 캐스트(uint16_t)(h - t)4 ✔

규칙으로 정리하면 — N 은 2의 거듭제곱, N < 2^(인덱스 비트 수)(uint8 이면 N ≤ 128), 인덱스 타입이 int 보다 좁으면 뺄셈 결과를 다시 그 타입으로 캐스트.

uint16_t 의 함정 — 정수 승격

uint16_t h = 2, t = 65534;   // 4 개 들어 있음

// ✘ int 가 32비트인 ARM · x86 에서:
//   h, t 가 int 로 승격된 뒤 빼므로 2 - 65534 = -65532
if (h - t == 4) { /* 절대 안 들어옴 */ }
if (h - t >= 8)  { /* -65532 >= 8 은 거짓 → '가득' 검사 실패 */ }

// ✔ 결과를 원래 폭으로 되돌립니다
uint16_t cnt = (uint16_t)(h - t);           // 4

// AVR (int 가 16비트) 에서는 uint16_t 가 곧 unsigned int 라
// 승격이 없어 우연히 맞습니다 — 같은 코드를 STM32 로 옮기면 깨집니다
얼마나 빨리 한 바퀴 도나 — 초당 100만 번 push 하면 uint32 는 약 72분(2³² ÷ 10⁶ ≈ 4295 초)에 한 바퀴 돕니다. "언젠가"가 아니라 오늘 오후에 일어나는 일이므로 넘침이 정의된 동작인 부호 없는 타입이어야 합니다.
int 인덱스는 금지 — 부호 있는 정수 넘침은 미정의 동작입니다. 같은 속도면 INT_MAX 까지 약 36분이고, 그 뒤 컴파일러는 "넘치지 않는다"고 가정한 코드를 냅니다 (SQ 10).
정리 — C 의 큐는 2의 거듭제곱 원형 버퍼, 빈/가득 판정은 무한 증가 uint32 인덱스(h - t)가 기본값입니다. 크기가 2의 거듭제곱이 아니면 한 칸 비우기, count 방식은 단일 스레드 전용으로만 씁니다.
SQ 04

덱 — 양끝이 열린 원형 버퍼, 연결 큐, 그리고 침입형 리스트

덱은 원형 버퍼에 앞쪽 push/pop 을 더한 것입니다. 자바 ArrayDeque, 러스트 VecDeque 가 정확히 이 구조이고, C 에서는 head(맨 앞 칸)와 len(개수) 두 값으로 표현하는 것이 가장 깔끔합니다. 까다로운 곳은 자랄 때입니다 — 데이터가 배열 끝에서 감겨 있으면 realloc 만으로는 순서가 깨집니다. 뒤쪽 절반은 노드를 쓰는 큐 두 가지, 특히 임베디드와 커널이 사랑하는 침입형 리스트입니다.

덱이 자랄 때 — 감긴 데이터를 두 조각으로 펴서 복사합니다 cap 4 · head=2 1 2 -1 0 ↑ head 조각 ① head → 끝 (2 개) 조각 ② 0 → 감긴 부분 (2 개) cap 8 · head=0 -1 0 1 2 3 새 원소 realloc 만 하면 1 2 -1 0 head=2 에서 4 칸을 읽으면 -1, 0, ?, ? — 1 과 2 를 잃음 자바 ArrayDeque · 러스트 VecDeque 도 자랄 때 같은 "펴기"를 합니다 조각 크기 = min(len, cap − head) 와 그 나머지 — memcpy 두 번이면 끝
원형 버퍼를 키울 때는 배열 순서가 아니라 논리 순서로 복사해야 합니다 — memcpy 두 번.

덱 — 전체 코드 (용량 2의 거듭제곱, 자동 성장)

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>

typedef struct {
    int   *buf;
    size_t cap;    // 항상 2의 거듭제곱
    size_t head;   // 맨 앞 원소가 있는 칸
    size_t len;    // 원소 개수
} Deque;

#define DQ_AT(d, i) ((d)->buf[((d)->head + (i)) & ((d)->cap - 1)])

static bool dq_init(Deque *d, size_t cap_pow2) {
    d->buf = malloc(cap_pow2 * sizeof *d->buf);
    d->cap = cap_pow2; d->head = 0; d->len = 0;
    return d->buf != NULL;
}

static bool dq_grow(Deque *d) {
    size_t ncap = d->cap * 2;
    int *nb = malloc(ncap * sizeof *nb);
    if (!nb) return false;                  // 실패해도 기존 덱은 멀쩡
    size_t first = d->cap - d->head;        // head 부터 배열 끝까지
    if (first > d->len) first = d->len;
    memcpy(nb, d->buf + d->head, first * sizeof *nb);           // 조각 ①
    memcpy(nb + first, d->buf, (d->len - first) * sizeof *nb); // 조각 ②
    printf("grow %zu->%zu (조각 %zu + %zu)\n",
           d->cap, ncap, first, d->len - first);
    free(d->buf);
    d->buf = nb; d->cap = ncap; d->head = 0;
    return true;
}

static bool dq_push_back(Deque *d, int v) {
    if (d->len == d->cap && !dq_grow(d)) return false;
    d->buf[(d->head + d->len) & (d->cap - 1)] = v;
    d->len++;
    return true;
}
static bool dq_push_front(Deque *d, int v) {
    if (d->len == d->cap && !dq_grow(d)) return false;
    d->head = (d->head - 1) & (d->cap - 1);  // 0-1 = SIZE_MAX → & → cap-1
    d->buf[d->head] = v;
    d->len++;
    return true;
}
static bool dq_pop_front(Deque *d, int *out) {
    if (d->len == 0) return false;
    *out = d->buf[d->head];
    d->head = (d->head + 1) & (d->cap - 1);
    d->len--;
    return true;
}
static bool dq_pop_back(Deque *d, int *out) {
    if (d->len == 0) return false;
    d->len--;
    *out = DQ_AT(d, d->len);
    return true;
}

int main(void) {
    Deque d;
    if (!dq_init(&d, 4)) return 1;
    dq_push_back(&d, 1);   dq_push_back(&d, 2);
    dq_push_front(&d, 0);  dq_push_front(&d, -1);
    printf("buf=[%d,%d,%d,%d] head=%zu len=%zu\n",
           d.buf[0], d.buf[1], d.buf[2], d.buf[3], d.head, d.len);
    dq_push_back(&d, 3);                  // 가득 → 두 조각 복사
    printf("head=%zu len=%zu cap=%zu\n", d.head, d.len, d.cap);
    printf("앞→뒤:");
    for (size_t i = 0; i < d.len; i++) printf(" %d", DQ_AT(&d, i));
    int a, b;
    dq_pop_front(&d, &a);
    dq_pop_back(&d, &b);
    printf("\npop_front=%d pop_back=%d len=%zu\n", a, b, d.len);
    free(d.buf);
    return 0;
}
// 출력:
// buf=[1,2,-1,0] head=2 len=4
// grow 4->8 (조각 2 + 2)
// head=0 len=5 cap=8
// 앞→뒤: -1 0 1 2 3
// pop_front=-1 pop_back=3 len=3
연산비용
양끝 push분할 상환 O(1) (성장 시 O(n))
양끝 pop · peekO(1)
i 번째 읽기 DQ_ATO(1) — 마스크 한 번
가운데 삽입·삭제O(n) — 덱으로 하지 말 것
size_t 의 0 − 1 은 안전합니다 — 부호 없는 넘침은 정의된 동작이라 SIZE_MAX 가 되고, 마스크를 걸면 cap - 1 입니다. 같은 코드를 int head 로 쓰고 % 를 쓰면 -1 % 4 == -1 이 되어 배열 앞을 씁니다. 덱은 부호 없는 인덱스 + 마스크가 가장 짧고 안전한 조합입니다.

노드 기반 큐 — 꼬리 포인터가 있어야 O(1) 🟡 중급

연결 큐 — head 에서 빼고 tail 에 붙입니다

typedef struct Node { int v; struct Node *next; } Node;
typedef struct { Node *head, *tail; } LQueue;   // {NULL, NULL} = 빈 큐

bool lq_push(LQueue *q, int v) {
    Node *n = malloc(sizeof *n);
    if (!n) return false;
    n->v = v;
    n->next = NULL;
    if (q->tail) q->tail->next = n;   // 비어 있지 않으면 꼬리 뒤에
    else         q->head = n;         // 비어 있었으면 머리도 n
    q->tail = n;
    return true;
}

bool lq_pop(LQueue *q, int *out) {
    Node *n = q->head;
    if (!n) return false;
    *out = n->v;
    q->head = n->next;
    if (!q->head) q->tail = NULL;     // ★ 마지막을 뺐으면 꼬리도 비움
    free(n);
    return true;
}
// ★ 줄을 빠뜨리면: tail 이 해제된 노드를 가리킨 채 남고,
//   다음 push 가 그 해제된 메모리에 next 를 씁니다 (use-after-free)

배열 링 vs 연결 큐

배열 원형 버퍼malloc 연결 큐
push 비용대입 한 번malloc 한 번 (수십~수백 ns)
원소당 메모리원소 크기 그대로+ next 포인터 + malloc 머리
메모리 배치연속 — 캐시 친화흩어짐 — 노드마다 캐시 미스
최대 크기정해야 함메모리가 허락하는 만큼
원소 주소링 안에서 고정노드가 살아 있는 동안 고정
ISR 에서 push가능불가 — malloc 은 ISR 금지
연결 큐가 이기는 경우는 거의 하나 — 원소가 이미 다른 곳에 있는 큰 객체여서 복사하고 싶지 않을 때입니다. 그런데 그 경우에도 노드를 malloc 하는 대신, 객체 안에 고리를 박아 두는 아래 방식이 더 낫습니다.

침입형 리스트 — list_head 와 container_of 로 할당 없는 큐 🔴 고급

Job 하나 (64비트) — 큐는 node 의 주소만 압니다 id 패딩 name (포인터) node.next node.prev 0 4 8 16 24 32 offsetof(Job, node) = 16 container_of(n, Job, node) n = ready.next ready 머리 (데이터 없음) job 1 read .node job 2 parse .node job 3 send .node 마지막 next 는 다시 머리로 — NULL 이 없는 원형 (prev 는 반대 방향, 생략) 고리(node)가 객체 안에 있으니 큐에 넣을 때 할당이 없습니다 빈 큐 = ready.next 가 자기 자신 — 특수 경우(NULL) 검사가 사라집니다
침입형 = 고리를 객체 안에. 큐는 고리만 잇고, container_of 가 고리에서 객체로 되돌아갑니다.

리눅스 커널 방식 — 전체 코드 (malloc 0회)

#include <stdio.h>
#include <stddef.h>     // offsetof
#include <stdbool.h>

struct list_head { struct list_head *next, *prev; };

#define LIST_HEAD_INIT(name) { &(name), &(name) }
#define container_of(ptr, type, member) \
    ((type *)((char *)(ptr) - offsetof(type, member)))

static inline void list_init(struct list_head *h) { h->next = h->prev = h; }
static inline bool list_empty(const struct list_head *h) { return h->next == h; }

// h 바로 앞 = 꼬리에 끼움 → 큐의 push
static inline void list_add_tail(struct list_head *n, struct list_head *h) {
    n->prev = h->prev;
    n->next = h;
    h->prev->next = n;
    h->prev = n;
}
// 어디에 있든 O(1) 로 빼고 자기 자신을 가리키게
static inline void list_del_init(struct list_head *n) {
    n->prev->next = n->next;
    n->next->prev = n->prev;
    list_init(n);
}

typedef struct {
    int id;
    const char *name;
    struct list_head node;          // ★ 큐에 걸리는 고리
} Job;

static Job jobs[3] = { {1, "read"}, {2, "parse"}, {3, "send"} };
static struct list_head ready = LIST_HEAD_INIT(ready);
static struct list_head done  = LIST_HEAD_INIT(done);

static Job *job_pop(struct list_head *q) {
    if (list_empty(q)) return NULL;
    struct list_head *n = q->next;          // 맨 앞
    list_del_init(n);
    return container_of(n, Job, node);      // 고리 → 객체
}

int main(void) {
    for (int i = 0; i < 3; i++)
        list_add_tail(&jobs[i].node, &ready);
    Job *j;
    while ((j = job_pop(&ready)) != NULL) {
        printf("run %d %s\n", j->id, j->name);
        list_add_tail(&j->node, &done);     // 같은 고리로 다른 큐에
    }
    printf("ready empty=%d, done first=%s\n", list_empty(&ready),
           container_of(done.next, Job, node)->name);
    return 0;
}
// 출력:
// run 1 read
// run 2 parse
// run 3 send
// ready empty=1, done first=read

왜 임베디드 · 커널은 이 방식을 쓰나

  • 할당이 없다 — 객체가 정적 배열이든 스택이든 메모리 풀이든, 큐에 넣는 순간 메모리가 늘지 않습니다. ISR 에서도 넣고 뺄 수 있습니다
  • O(1) 삭제 — 객체 포인터만 있으면 큐의 어디에 있든 바로 뺍니다. 타임아웃으로 대기 큐에서 작업을 취소할 때 결정적입니다
  • 한 객체, 여러 큐 — 고리를 두 개 두면(run_node, all_node) 실행 큐와 전체 목록에 동시에 걸립니다
  • 타입 무관 — 리스트 코드는 list_head 만 알고, 타입은 container_of 가 되돌립니다. void* 캐스트가 없습니다
어디서같은 아이디어
리눅스 커널struct list_head · list_entry(= container_of)
BSD · glibc<sys/queue.h> 의 TAILQ_ENTRY 매크로
FreeRTOSTCB 안의 ListItem_t + pvOwner 역포인터
Zephyrsys_dlist_t · CONTAINER_OF
대가 — 객체의 수명을 큐가 관리하지 않습니다. 큐에 걸린 채로 객체를 해제(또는 스택 변수가 함수 밖으로 사라짐)하면 리스트가 죽은 메모리를 가리킵니다. 해제 전에 list_del_init 를 부르는 규칙을 지키고, 디버그 빌드에서는 해제 시 !list_empty(&obj->node) 면 assert 로 잡으세요.
정리 — 덱은 head + len + 마스크로 양끝 O(1), 자랄 때는 memcpy 두 번으로 펴서 복사합니다. 노드 큐가 필요하면 malloc 노드 대신 침입형 list_head — 할당 없고, O(1) 삭제, ISR 에서도 안전합니다.
SQ 05

우선순위 큐 — 배열 이진 힙과 비교 함수 포인터, 그리고 타이머 큐

"가장 급한 것부터" 꺼내는 큐는 C 에서도 배열 위의 이진 힙이 정답입니다. 트리를 포인터로 만들지 않습니다 — i 번 칸의 부모는 (i-1)/2, 자식은 2i+1 · 2i+2 라는 산수가 곧 트리입니다. 순서 규칙은 qsort 와 같은 비교 함수 포인터로 받습니다. 그리고 임베디드에서 힙을 가장 많이 쓰는 곳, 소프트웨어 타이머를 32비트 tick 넘침까지 고려해 만들어 봅니다.

트리는 그림일 뿐, 메모리에는 배열 한 줄 — 포인터가 하나도 없습니다 irq 0 [0] tick 0 [1] ui 2 [2] log 3 [3] net 1 [4] save 2 [5] 부모 ≤ 자식 만 보장 형제끼리는 순서 없음 (ui 2 > net 1 이어도 OK) irq 0 tick 0 ui 2 log 3 net 1 save 2 배열 parent(i) = (i − 1) / 2 · left(i) = 2i + 1 · right(i) = 2i + 2
힙은 정렬된 배열이 아닙니다 — 루트만 최소가 보장되고, 꺼낼 때마다 log n 단계로 다시 맞춥니다.

고정 용량 이진 힙 — 비교 함수 포인터

#include <stddef.h>
#include <stdbool.h>

// qsort 와 같은 규약: 음수면 a 가 먼저 나와야 함
typedef int (*heap_cmp)(const void *a, const void *b);

typedef struct {
    void   **a;          // 원소 포인터 배열 (원소 자체는 호출자 소유)
    size_t   len, cap;
    heap_cmp cmp;
} Heap;

static void swap_p(void **x, void **y) { void *t = *x; *x = *y; *y = t; }

static void sift_up(Heap *h, size_t i) {
    while (i > 0) {
        size_t p = (i - 1) / 2;
        if (h->cmp(h->a[i], h->a[p]) >= 0) break;  // 부모가 먼저 → 멈춤
        swap_p(&h->a[i], &h->a[p]);
        i = p;
    }
}
static void sift_down(Heap *h, size_t i) {
    for (;;) {
        size_t l = 2 * i + 1, r = l + 1, m = i;
        if (l < h->len && h->cmp(h->a[l], h->a[m]) < 0) m = l;
        if (r < h->len && h->cmp(h->a[r], h->a[m]) < 0) m = r;
        if (m == i) break;
        swap_p(&h->a[i], &h->a[m]);
        i = m;
    }
}

bool heap_push(Heap *h, void *item) {
    if (h->len == h->cap) return false;      // 고정 용량 — 임베디드형
    h->a[h->len] = item;
    sift_up(h, h->len);
    h->len++;
    return true;
}
void *heap_peek(const Heap *h) { return h->len ? h->a[0] : NULL; }
void *heap_pop(Heap *h) {
    if (h->len == 0) return NULL;
    void *top = h->a[0];
    h->a[0] = h->a[--h->len];               // 마지막을 루트로
    if (h->len) sift_down(h, 0);
    return top;
}

다중 키 비교 + 도착 순번 — 같은 우선순위의 순서

#include <stdio.h>
#include <stdint.h>

typedef struct { const char *name; int pri; uint32_t seq; } Task;

static int task_cmp(const void *x, const void *y) {
    const Task *a = x, *b = y;
    if (a->pri != b->pri)                       // 1순위: 작은 pri 먼저
        return (a->pri > b->pri) - (a->pri < b->pri);
    return (a->seq > b->seq) - (a->seq < b->seq); // 2순위: 먼저 온 것
}

int main(void) {
    static Task t[] = { {"log",3}, {"irq",0}, {"ui",2},
                        {"net",1}, {"tick",0}, {"save",2} };
    void *slots[8];
    Heap h = { slots, 0, 8, task_cmp };
    uint32_t seq = 0;
    for (size_t i = 0; i < 6; i++) { t[i].seq = seq++; heap_push(&h, &t[i]); }

    for (size_t i = 0; i < h.len; i++) {        // 배열 그대로 — 정렬 아님
        const Task *k = h.a[i];
        printf("%s(%d) ", k->name, k->pri);
    }
    printf("\n");
    Task *k;
    while ((k = heap_pop(&h)) != NULL) printf("%s(%d) ", k->name, k->pri);
    printf("\n");
    return 0;
}
// 출력:
// irq(0) tick(0) ui(2) log(3) net(1) save(2)
// irq(0) tick(0) net(1) ui(2) save(2) log(3)
return a->pri - b->pri; 금지 — 값이 크면 뺄셈이 넘칩니다(INT_MIN - 1 은 UB). (a > b) - (a < b) 가 -1/0/1 을 넘침 없이 만드는 C 관용구입니다.
seq 를 빼면? — 같은 우선순위 1 인 A·B·C·D·E 를 넣고 0 인 z 를 넣은 뒤 다 꺼내면 z C E D A B 순서가 나옵니다(실제로 돌린 결과). 힙은 안정적이지 않습니다. "같으면 먼저 온 것"이 요구 사항이면 순번을 마지막 키로 넣어야 합니다.

소프트웨어 타이머 큐 — tick 넘침에 안전한 비교 🔴 고급

tick 이 0 으로 돌아가기 256 ms 전 — 시간축은 이어지지만 숫자는 끊깁니다 0 으로 넘침 지금 …7040 adc 4294967140 uart 44 +400 ms blink 244 wdt 744 숫자 크기 비교: uart · blink · wdt · adc — adc 가 49.7일 뒤로 밀림 ✘ (int32_t)(a − b) < 0 비교: adc · uart · blink · wdt ✔ 채운 점 = 400 ms 뒤 만료 · 빈 점 = 아직
tick 비교는 값이 아니라 차이로 — (int32_t)(a − b) 의 부호가 "어느 쪽이 먼저"입니다.

타이머 힙 — 위의 Heap 그대로 재사용

#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>

typedef struct { uint32_t deadline; const char *name; } SwTimer;

// a 가 b 보다 먼저 울려야 하면 음수
static int timer_cmp(const void *x, const void *y) {
    const SwTimer *a = x, *b = y;
    int32_t d = (int32_t)(a->deadline - b->deadline);   // ★ 차이의 부호
    return (d > 0) - (d < 0);
}

int main(void) {
    uint32_t now = 0xFFFFFF00u;          // 부팅 약 49.7일 — 넘침 256 ms 전
    static SwTimer t[] = { {0,"blink"}, {0,"adc"}, {0,"wdt"}, {0,"uart"} };
    const uint32_t after[] = { 500, 100, 1000, 300 };
    void *slots[8];
    Heap h = { slots, 0, 8, timer_cmp };
    for (int i = 0; i < 4; i++) {
        t[i].deadline = now + after[i];  // 부호 없는 덧셈 — 넘쳐도 정의됨
        heap_push(&h, &t[i]);
    }

    now += 400;                          // 400 ms 경과 → now == 144
    SwTimer *top;
    while ((top = heap_peek(&h)) != NULL &&
           (int32_t)(now - top->deadline) >= 0) {   // ★ now >= deadline 금지
        heap_pop(&h);
        printf("fire %s (deadline=%" PRIu32 ")\n", top->name, top->deadline);
    }
    top = heap_peek(&h);
    printf("next %s in %" PRId32 " ms\n", top->name,
           (int32_t)(top->deadline - now));
    return 0;
}
// 출력:
// fire adc (deadline=4294967140)
// fire uart (deadline=44)
// next blink in 100 ms

조건과 대안

  • 조건 — 대기 중인 모든 마감이 서로 2³¹ tick 이내여야 합니다. 1 ms tick 이면 2³¹ ms ≈ 24.8일. 그보다 긴 타이머는 "하루짜리를 여러 번"으로 쪼갭니다
  • 캐스트 — 범위 밖 uint32_t → int32_t 변환은 표준상 구현 정의지만 GCC · Clang · Arm 컴파일러는 모두 2의 보수로 잘라 냅니다. 리눅스 커널의 time_after() 도 같은 식입니다
  • ISR 에서 타이머 추가 — 힙은 여러 칸을 고치므로 SPSC 가 아닙니다. ISR 은 "추가 요청"만 SPSC 링(SQ 07)에 넣고, 힙 조작은 메인 루프 한 곳에서
타이머 자료구조추가가장 이른 것쓰는 곳
정렬된 연결 리스트O(n)O(1)FreeRTOS 지연·타이머 리스트 (개수 적음)
이진 힙O(log n)O(1) · 꺼내기 O(log n)libevent · 사용자 공간 이벤트 루프
타이머 휠O(1)tick 마다 슬롯 하나리눅스 커널 · 네트워크 타임아웃 수만 개
타이머 휠 한 줄 요약 — 슬롯 256 개짜리 원형 배열의 각 칸이 침입형 리스트(SQ 04)입니다. slot = (now + delay) & 255 에 끼우고, tick 마다 현재 슬롯 하나만 훑습니다. 255 tick 보다 먼 것은 "남은 바퀴 수"를 같이 적거나 상위 휠에 둡니다. 타이머가 수십 개면 힙으로 충분합니다.
정리 — 우선순위 큐는 배열 이진 힙 + 비교 함수 포인터, 같은 우선순위의 순서가 필요하면 순번을 마지막 키로. 타이머 큐의 비교는 (int32_t)(a − b) — tick 넘침을 지나도 순서가 맞습니다.
SQ 06

스레드 안전 큐 — 뮤텍스 + 조건 변수, 타임아웃과 깔끔한 종료

여러 스레드가 넣고 여러 스레드가 빼는(N:M) 큐는 뮤텍스 하나 + 조건 변수 둘이 표준 해법입니다. 기본형(while 로 다시 확인하는 이유, closed 플래그)은 ⚙️ C 실전 탭 c19 에 있으니, 여기서는 실무에서 바로 부딪히는 세 가지를 더합니다 — 타임아웃 대기(시계가 바뀌어도 흔들리지 않게), 닫은 뒤 남은 것 비우기(drain), 그리고 포인터를 싣는 범용 메시지.

세로가 시간 — 잠든 동안 뮤텍스는 풀려 있습니다 소비자 큐 (뮤텍스) 생산자 main 1. lock · 비었음 cond_wait — 풀고 잠듦 2. lock · 넣기 · signal 3. 깨움 4. 다시 lock · while 재확인 · 꺼냄 5. 생산자 join 후 close() → broadcast 6. 남은 것 다 꺼낸 뒤 BQ_CLOSED 루프 탈출 → 스레드 종료 ③ 과 ④ 사이에 다른 소비자가 먼저 꺼낼 수 있습니다 — 그래서 if 가 아니라 while
조건 변수는 "알림"일 뿐 상태가 아닙니다 — 깨어나면 반드시 큐를 다시 봅니다.

bq.c — 유한 · 블로킹 · 타임아웃 · drain 종료 (전체 코드)

// cc -std=c17 -O2 -pthread bq.c
#define _POSIX_C_SOURCE 200809L
#include <pthread.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <time.h>
#include <errno.h>

#define BQ_CAP 4u                 // 일부러 작게 — 배압이 실제로 걸리도록
_Static_assert((BQ_CAP & (BQ_CAP - 1u)) == 0, "BQ_CAP: power of two");

typedef enum { BQ_OK, BQ_TIMEOUT, BQ_CLOSED } BqStatus;

typedef struct {
    void           *slot[BQ_CAP];
    uint32_t        head, tail;   // 무한 증가 인덱스 (SQ 03)
    bool            closed;
    pthread_mutex_t mu;
    pthread_cond_t  not_empty, not_full;
} BQueue;

int bq_init(BQueue *q) {
    q->head = q->tail = 0;
    q->closed = false;
    pthread_condattr_t ca;
    pthread_condattr_init(&ca);
    // ★ 벽시계(REALTIME)가 NTP 로 바뀌어도 타임아웃이 흔들리지 않게
    pthread_condattr_setclock(&ca, CLOCK_MONOTONIC);   // macOS 에는 없음
    int rc = pthread_mutex_init(&q->mu, NULL);
    if (!rc) rc = pthread_cond_init(&q->not_empty, &ca);
    if (!rc) rc = pthread_cond_init(&q->not_full, &ca);
    pthread_condattr_destroy(&ca);
    return rc;
}

// 가득이면 기다림(배압). 닫힌 큐에는 넣지 않음
BqStatus bq_push(BQueue *q, void *item) {
    pthread_mutex_lock(&q->mu);
    while (q->head - q->tail == BQ_CAP && !q->closed)
        pthread_cond_wait(&q->not_full, &q->mu);
    if (q->closed) {
        pthread_mutex_unlock(&q->mu);
        return BQ_CLOSED;
    }
    q->slot[q->head & (BQ_CAP - 1u)] = item;
    q->head++;
    pthread_cond_signal(&q->not_empty);
    pthread_mutex_unlock(&q->mu);
    return BQ_OK;
}

static void deadline_in(struct timespec *ts, long ms) {
    clock_gettime(CLOCK_MONOTONIC, ts);
    ts->tv_sec  += ms / 1000;
    ts->tv_nsec += (ms % 1000) * 1000000L;
    if (ts->tv_nsec >= 1000000000L) { ts->tv_sec++; ts->tv_nsec -= 1000000000L; }
}
// 비면 최대 timeout_ms 기다림.
// 닫혀도 남은 것은 다 꺼내 준 뒤에야 BQ_CLOSED (drain)
BqStatus bq_pop(BQueue *q, void **out, long timeout_ms) {
    struct timespec dl;
    deadline_in(&dl, timeout_ms);       // 절대 시각 — 가짜 깨어남에도 고정
    pthread_mutex_lock(&q->mu);
    while (q->head == q->tail && !q->closed) {
        int rc = pthread_cond_timedwait(&q->not_empty, &q->mu, &dl);
        if (rc == ETIMEDOUT && q->head == q->tail) {
            pthread_mutex_unlock(&q->mu);
            return BQ_TIMEOUT;
        }
    }
    if (q->head == q->tail) {           // 닫혔고, 다 비었음
        pthread_mutex_unlock(&q->mu);
        return BQ_CLOSED;
    }
    *out = q->slot[q->tail & (BQ_CAP - 1u)];
    q->tail++;
    pthread_cond_signal(&q->not_full);
    pthread_mutex_unlock(&q->mu);
    return BQ_OK;
}

void bq_close(BQueue *q) {
    pthread_mutex_lock(&q->mu);
    q->closed = true;
    pthread_cond_broadcast(&q->not_empty);   // 기다리는 소비자 전원
    pthread_cond_broadcast(&q->not_full);    // 기다리는 생산자 전원
    pthread_mutex_unlock(&q->mu);
}

// ───── 사용: 생산자 2 · 소비자 2 ─────
static BQueue q;
static long total[2];
static int  got[2];

static void *producer(void *arg) {
    intptr_t base = (intptr_t)arg;               // 100 또는 200
    for (intptr_t i = 1; i <= 5; i++)
        bq_push(&q, (void *)(base + i));         // 작은 정수는 포인터에 실어 보냄
    return NULL;
}
static void *consumer(void *arg) {
    int id = (int)(intptr_t)arg;
    void *item;
    BqStatus st;
    while ((st = bq_pop(&q, &item, 500)) != BQ_CLOSED) {
        if (st == BQ_TIMEOUT) continue;          // 하트비트 같은 주기 작업 자리
        total[id] += (long)(intptr_t)item;       // 자기 칸만 씀 — 경쟁 없음
        got[id]++;
    }
    return NULL;
}

int main(void) {
    if (bq_init(&q) != 0) return 1;
    pthread_t p[2], c[2];
    for (intptr_t i = 0; i < 2; i++)
        pthread_create(&c[i], NULL, consumer, (void *)i);
    pthread_create(&p[0], NULL, producer, (void *)(intptr_t)100);
    pthread_create(&p[1], NULL, producer, (void *)(intptr_t)200);
    pthread_join(p[0], NULL);
    pthread_join(p[1], NULL);
    bq_close(&q);                    // ★ 생산자가 모두 끝난 뒤에 닫기
    pthread_join(c[0], NULL);
    pthread_join(c[1], NULL);
    printf("consumed=%d sum=%ld\n", got[0] + got[1], total[0] + total[1]);
    return 0;
}
// 출력 (소비자별 몫은 매번 다르지만 합은 고정):
// consumed=10 sum=1530
타임아웃은 절대 시각으로 한 번만 계산 — 루프 안에서 매번 "지금 + 500 ms" 를 다시 계산하면 가짜 깨어남이 일어날 때마다 마감이 뒤로 밀려 영원히 기다릴 수 있습니다. 위 코드는 dl 을 루프 밖에서 고정합니다.

종료 설계 — 닫힘 플래그 vs 독약(poison pill) 🟡 중급

두 방식 비교

닫힘 플래그 (위 코드)독약 메시지
방법bq_close() 한 번소비자 수만큼 NULL 같은 특수 원소 push
소비자 수를 알아야아니오예 — 하나 모자라면 한 스레드가 영원히 대기
큐가 가득일 때즉시 전원 깨움독약도 줄을 서야 함
닫힌 뒤 pushBQ_CLOSED 로 거절막을 방법 없음
남은 작업drain 또는 abort 선택독약 앞의 것만 처리

abort(즉시 중단)가 필요하면 bq_pop 의 "닫혔고 비었음" 검사를 "닫혔음"으로 바꾸면 됩니다. 둘 다 필요하면 enum { OPEN, DRAINING, ABORTED } 상태 하나로 갑니다.

pthread 와 C11 <threads.h>

pthreadsC11 threads.h
대기pthread_cond_waitcnd_wait
타임아웃cond_timedwait + MONOTONIC 가능cnd_timedwait — TIME_UTC 고정
지원리눅스 · macOS · (윈도우는 별도)glibc 2.28+, 최근 MSVC
C11 의 함정 — cnd_timedwait 는 마감을 벽시계(UTC)로만 받습니다. 서버 시계가 NTP 로 뒤로 1분 가면 500 ms 타임아웃이 1분 넘게 늘어납니다. 타임아웃이 중요하면 pthreads 의 MONOTONIC 을 씁니다.
signal 과 broadcast — 원소 하나를 넣었으면 깨울 소비자도 하나면 충분하니 signal. 상태가 바뀌어 모두가 다시 봐야 하는 일(닫힘)에는 broadcast. 반대로 쓰면 각각 "불필요한 깨움 폭풍"과 "영원히 자는 스레드"가 됩니다.
정리 — N:M 스레드 큐는 뮤텍스 + not_empty/not_full + closed, 대기는 언제나 while, 타임아웃은 MONOTONIC 절대 시각을 한 번만 계산, 종료는 독약보다 close + drain 이 안전합니다.
SQ 07

락 없는 큐 — SPSC 링의 acquire/release, 그리고 ISR ↔ 메인 루프

C 에는 async/await 도 채널도 없습니다. 대신 C 의 "비동기"는 두 가지입니다 — 다른 스레드, 그리고 인터럽트. 둘 다 생산자와 소비자가 정확히 하나씩이면 락 없이 큐를 만들 수 있습니다(SPSC: single-producer single-consumer). 조건은 SQ 03 의 무한 증가 인덱스(각자 자기 인덱스만 씀)와, 이 섹션의 주제인 메모리 순서입니다. volatile 만으로 되는 경우와 안 되는 경우를 정확히 가릅니다.

release 로 내놓고 acquire 로 받는다 — 화살표 두 개가 각각 하나의 약속 생산자 소비자 ① buf[h] = v 평범한 쓰기 ② head = h+1 release ⑥ t = tail acquire ③ h = head acquire ④ v = buf[t] 평범한 읽기 ⑤ tail = t+1 release 동기화: ① 이 ④ 보다 먼저 ④ 끝난 칸만 덮음 release 저장을 acquire 로 읽으면, 저장 앞의 모든 쓰기가 읽은 뒤에 보입니다 자기 인덱스 읽기(생산자의 head, 소비자의 tail)는 relaxed 로 충분합니다 전부 relaxed 면 ARM 에서 ④ 가 옛 값을 읽습니다 — x86 은 우연히 동작해 더 위험
SPSC 링의 순서 규칙은 두 쌍뿐입니다 — head: release→acquire(데이터 공개), tail: release→acquire(칸 반납).

spsc.c — C11 stdatomic SPSC 링 (전체 코드, 스레드 둘로 검증)

// cc -std=c17 -O2 -pthread spsc.c
#include <stdatomic.h>
#include <pthread.h>
#include <stdint.h>
#include <stdbool.h>
#include <stdio.h>

#define SPSC_CAP  1024u
#define SPSC_MASK (SPSC_CAP - 1u)
_Static_assert((SPSC_CAP & SPSC_MASK) == 0, "SPSC_CAP: power of two");

typedef struct {
    // 서로 다른 코어가 쓰는 변수는 캐시 줄(64 B)을 나눠 둡니다
    _Alignas(64) _Atomic uint32_t head;   // 생산자만 씀
    _Alignas(64) _Atomic uint32_t tail;   // 소비자만 씀
    _Alignas(64) int buf[SPSC_CAP];
} Spsc;

// 생산자 스레드 하나에서만 호출
bool spsc_push(Spsc *q, int v) {
    uint32_t h = atomic_load_explicit(&q->head, memory_order_relaxed); // 내 것
    uint32_t t = atomic_load_explicit(&q->tail, memory_order_acquire); // ⑥
    if (h - t == SPSC_CAP) return false;           // 가득
    q->buf[h & SPSC_MASK] = v;                      // ①
    atomic_store_explicit(&q->head, h + 1, memory_order_release);     // ②
    return true;
}

// 소비자 스레드 하나에서만 호출
bool spsc_pop(Spsc *q, int *out) {
    uint32_t t = atomic_load_explicit(&q->tail, memory_order_relaxed); // 내 것
    uint32_t h = atomic_load_explicit(&q->head, memory_order_acquire); // ③
    if (h == t) return false;                        // 비었음
    *out = q->buf[t & SPSC_MASK];                    // ④
    atomic_store_explicit(&q->tail, t + 1, memory_order_release);     // ⑤
    return true;
}
// ───── 검증: 100만 개를 넘겨 합을 맞춰 봅니다 ─────
#define N 1000000

static Spsc q;   // 정적 → head = tail = 0 으로 초기화

static void *producer(void *arg) {
    (void)arg;
    for (int i = 1; i <= N; i++)
        while (!spsc_push(&q, i))
            ;                          // 가득이면 돌며 기다림 (실무는 yield/sleep)
    return NULL;
}

static void *consumer(void *arg) {
    long long *sum = arg;
    int got = 0, v;
    while (got < N)
        if (spsc_pop(&q, &v)) { *sum += v; got++; }
    return NULL;
}

int main(void) {
    long long sum = 0;
    pthread_t p, c;
    pthread_create(&c, NULL, consumer, &sum);
    pthread_create(&p, NULL, producer, NULL);
    pthread_join(p, NULL);
    pthread_join(c, NULL);
    printf("sum=%lld\n", sum);         // 1 + 2 + … + 1000000
    return 0;
}
// 출력:
// sum=500000500000
읽기·쓰기순서이유
자기 인덱스 읽기relaxed나만 쓰는 값 — 경쟁 없음
head 저장 ②release① 데이터 쓰기를 앞에 묶음
head 읽기 ③acquire④ 데이터 읽기를 뒤에 묶음
tail 저장 ⑤release④ 읽기가 끝난 뒤 칸 반납
tail 읽기 ⑥acquire반납된 칸에만 ① 쓰기
⑥ 의 acquire 를 relaxed 로 바꾸면 — "칸이 비었다"는 걸 보고 덮어쓰기 시작했는데, 소비자의 ④ 읽기가 아직 끝나지 않은 것처럼 재배치될 수 있습니다(읽기-후-쓰기 위험). 테스트에선 거의 안 나타나고 ARM 서버에서 드물게 터지는 종류라, 순서를 "필요할 때 낮추는" 최적화는 하지 마세요. 기본형(atomic_load/atomic_store = seq_cst)도 정답이고, 조금 느릴 뿐입니다.

ISR ↔ 메인 루프 — 락 없이 되는 조건과 volatile 의 한계 🔴 고급

락 없이 되는 네 가지 조건 — 하나라도 깨지면 인터럽트를 끄세요

  1. 생산자 하나 · 소비자 하나 — 같은 큐에 push 하는 ISR 이 둘이면(우선순위가 달라 서로 끼어들면) 같은 칸에 씁니다
  2. 각 인덱스는 한쪽만 씀 — head 는 ISR, tail 은 main. count 방식(SQ 03 ②)은 탈락
  3. 인덱스 읽기·쓰기가 명령 하나 — Cortex-M 의 정렬된 32비트는 OK. AVR 의 16비트는 두 명령 → 읽는 도중 ISR 이 바꾸면 반쪽짜리 값(찢어진 읽기)
  4. 데이터 쓰기 → 인덱스 갱신 순서가 지켜짐 — 컴파일러와 CPU 둘 다에게
환경④ 를 지키는 최소 도구
AVR (8비트, 캐시 없음)uint8_t 인덱스 + 컴파일러 배리어
Cortex-M0/M3/M4 (단일 코어)C11 atomic (위 코드) 또는 배리어
Cortex-M7 + D-캐시ISR↔main 은 위와 같음. DMA 버퍼는 캐시 정리 필요
듀얼 코어 (RP2040 등)반드시 C11 atomic (DMB 포함)

AVR 판 — uint8_t 인덱스 + 컴파일러 배리어

#include <avr/io.h>
#include <avr/interrupt.h>
#include <stdint.h>
#include <stdbool.h>

#define RX_N 64u           // 2의 거듭제곱 · uint8 무한 증가면 ≤ 128
_Static_assert((RX_N & (RX_N - 1u)) == 0 && RX_N <= 128u, "RX_N");
#define BARRIER() __asm__ __volatile__("" ::: "memory")

static uint8_t rx_buf[RX_N];
static volatile uint8_t rx_head;   // ISR 만 씀 — 8비트라 읽기가 명령 하나
static volatile uint8_t rx_tail;   // main 만 씀

ISR(USART_RX_vect) {               // 생산자: 이 ISR 하나뿐
    uint8_t b = UDR0;
    uint8_t h = rx_head;
    if ((uint8_t)(h - rx_tail) == RX_N) return;   // 가득 — 버림
    rx_buf[h & (RX_N - 1u)] = b;
    BARRIER();                     // ① 데이터가 ② head 보다 먼저
    rx_head = (uint8_t)(h + 1);
}

bool rx_get(uint8_t *out) {        // 소비자: main 루프 하나뿐
    uint8_t t = rx_tail;
    if (rx_head == t) return false;
    BARRIER();                     // head 를 본 뒤에 데이터 읽기
    *out = rx_buf[t & (RX_N - 1u)];
    BARRIER();                     // 다 읽은 뒤에 칸 반납
    rx_tail = (uint8_t)(t + 1);
    return true;
}
UART 레지스터 · 보레이트까지 포함한 완성형 드라이버는 🔷 AVR 탭 a07, STM32 의 순환 DMA 수신은 🟦 STM32 탭 m07, 인터럽트 자체의 규칙은 🔩 하드웨어 탭 h08 에 있습니다. 이 코드는 "왜 이 모양이어야 하는가"를 보여 주는 최소형입니다.
volatile 만 쓴 코드무엇이 깨지나고치는 법
인덱스만 volatile, buf 는 일반 배열컴파일러가 buf 쓰기를 head 갱신 뒤로 옮길 수 있음 — volatile 끼리만 순서가 보장됨배리어 또는 C11 release/acquire
AVR 에서 volatile uint16_t headmain 이 하위 바이트를 읽은 직후 ISR 이 head 를 0x00FF→0x0100 으로 바꾸면 0x01FF 를 읽음uint8_t 인덱스, 또는 ATOMIC_BLOCK 으로 읽기
volatile uint8_t count 를 양쪽에서 ++/--count++ = 읽기·더하기·쓰기 — 사이에 ISR 의 count-- 가 끼면 하나 유실count 를 없애고 무한 증가 인덱스
듀얼 코어에서 volatile 인덱스다른 코어가 쓰기 순서를 다르게 관찰 (CPU 재배치)C11 atomic — 하드웨어 배리어(DMB) 포함
정리 — 생산자·소비자가 하나씩이면 무한 증가 인덱스 + release/acquire 로 락 없는 큐가 됩니다. ISR ↔ main 에서는 인덱스가 명령 하나로 읽히는 폭인지(AVR 은 uint8_t), 데이터 → 인덱스 순서를 배리어로 지키는지 확인하세요 — volatile 은 그중 절반만 합니다.
SQ 08

알고리즘 패턴 — C 다운 코드로 쓰는 여섯 가지

코딩 테스트와 펌웨어 양쪽에서 같은 패턴이 반복됩니다. C 로 쓸 때의 요령은 하나입니다 — 최대 크기를 먼저 계산해서 고정 배열로. "각 원소가 한 번만 들어간다"를 증명할 수 있으면 원형 버퍼조차 필요 없고, 길이 n 짜리 배열에 인덱스 두 개면 됩니다. 아래 출력은 같은 알고리즘을 그대로 옮겨 실제로 돌린 값입니다.

① 괄호 짝 검사 — 문자 스택

#include <stdio.h>
#include <string.h>

// 짝이 맞으면 -1, 아니면 문제가 드러난 위치
static long check_brackets(const char *s) {
    char st[256];                         // 깊이 상한 — 넘으면 거절
    size_t top = 0, n = strlen(s);
    for (size_t i = 0; i < n; i++) {
        char c = s[i];
        if (c == '(' || c == '[' || c == '{') {
            if (top == sizeof st) return (long)i;
            st[top++] = c;
        } else if (c == ')' || c == ']' || c == '}') {
            char want = c == ')' ? '(' : c == ']' ? '[' : '{';
            if (top == 0 || st[--top] != want) return (long)i;
        }
    }
    return top ? (long)n : -1;            // 남은 여는 괄호
}

int main(void) {
    const char *t[] = { "{a[i] = (b + c)}", "f(x[1)]", "((a)", "x)" };
    for (int i = 0; i < 4; i++)
        printf("%-18s %ld\n", t[i], check_brackets(t[i]));
    return 0;
}
// 출력:
// {a[i] = (b + c)}   -1
// f(x[1)]            5
// ((a)               4
// x)                 1

② 단조 스택 — 오른쪽의 다음 큰 수

// 스택에는 '아직 답을 못 찾은' 인덱스가 값이 줄어드는 순서로 쌓입니다
static void next_greater(const int *a, int n, int *res) {
    int st[64], top = 0;                  // n ≤ 64 가정 (호출부에서 검사)
    for (int i = 0; i < n; i++) res[i] = -1;
    for (int i = 0; i < n; i++) {
        while (top > 0 && a[st[top - 1]] < a[i])
            res[st[--top]] = a[i];        // a[i] 가 그들의 '다음 큰 수'
        st[top++] = i;
    }
}

int main(void) {
    int a[] = { 2, 7, 3, 5, 4, 6, 8 }, res[7];
    next_greater(a, 7, res);
    for (int i = 0; i < 7; i++) printf("%d ", res[i]);
    printf("\n");
    return 0;
}
// 출력:
// 7 8 5 6 6 8 -1
왜 O(n) 인가 — 각 인덱스는 스택에 정확히 한 번 들어가고 최대 한 번 나옵니다. 안쪽 while 이 있어도 전체 반복은 2n 을 넘지 않습니다. 같은 이유로 스택 크기 상한은 n 입니다.
창 크기 3 — 진한 칸 = 덱의 맨 앞(최댓값) · 옅은 칸 = 창 안 1 3 -1 -3 5 3 6 7 덱에 남은 값 출력 i=0 1 — i=1 3 (1 은 밀려남) — i=2 3, -1 3 i=3 3, -1, -3 3 i=4 5 (셋 다 밀려남) 5 i=5 5, 3 5 i=6 6 6 i=7 7 7 새 값보다 작은 뒤쪽은 다시는 최댓값이 될 수 없으니 버립니다 덱 = 값이 줄어드는 인덱스 목록 · 앞 = 답 · 앞이 창을 벗어나면 pop_front
단조 덱은 앞에서 답을 읽고, 뒤에서 쓸모없는 것을 버립니다 — 각 인덱스가 한 번씩만 드나들어 O(n).

③ 슬라이딩 윈도우 최댓값 — 원형도 필요 없는 덱

// 각 인덱스는 덱에 한 번 들어가고 한 번 나옴
// → h, t 가 절대 n 을 넘지 않으니 길이 n 배열이면 충분
static void window_max(const int *a, int n, int k, int *out) {
    int dq[64], h = 0, t = 0, o = 0;      // n ≤ 64 가정
    for (int i = 0; i < n; i++) {
        if (h < t && dq[h] <= i - k) h++;            // 창 밖으로 나간 앞
        while (h < t && a[dq[t - 1]] <= a[i]) t--;   // 나보다 작은 뒤 버림
        dq[t++] = i;
        if (i >= k - 1) out[o++] = a[dq[h]];
    }
}

int main(void) {
    int a[] = { 1, 3, -1, -3, 5, 3, 6, 7 }, out[6];
    window_max(a, 8, 3, out);
    for (int i = 0; i < 6; i++) printf("%d ", out[i]);
    printf("\n");
    return 0;
}
// 출력:
// 3 3 5 5 6 7
펌웨어에서 — "최근 1초 동안의 최대 전류" 같은 피크 검출이 이 패턴입니다. 샘플이 끝없이 들어오면 n 이 무한이니 그때는 SQ 04 의 원형 덱(용량 = 창 크기 k 이상의 2의 거듭제곱)으로 바꿉니다.

④ BFS · ⑤ 0-1 BFS — 격자 최단 거리

#define ROWS 5
#define COLS 8
static const int DR[4] = { -1, 1, 0, 0 }, DC[4] = { 0, 0, -1, 1 };

// ④ 칸마다 큐에 한 번만 → 선형 배열 큐 (원형 불필요)
static int bfs(const char *const g[ROWS]) {
    int dist[ROWS * COLS], q[ROWS * COLS], head = 0, tail = 0, s = 0, goal = 0;
    for (int i = 0; i < ROWS * COLS; i++) {
        dist[i] = -1;
        if (g[i / COLS][i % COLS] == 'S') s = i;
        if (g[i / COLS][i % COLS] == 'G') goal = i;
    }
    dist[s] = 0; q[tail++] = s;
    while (head < tail) {
        int cur = q[head++], r = cur / COLS, c = cur % COLS;
        for (int d = 0; d < 4; d++) {
            int nr = r + DR[d], nc = c + DC[d];
            if (nr < 0 || nr >= ROWS || nc < 0 || nc >= COLS) continue;
            if (g[nr][nc] == '#' || dist[nr * COLS + nc] != -1) continue;
            dist[nr * COLS + nc] = dist[cur] + 1;
            q[tail++] = nr * COLS + nc;
        }
    }
    return dist[goal];
}

// ⑤ 벽(#)을 부수면 비용 1, 빈칸은 0 → 0 은 앞에, 1 은 뒤에
typedef struct { int a[128]; unsigned head, len; } IntDeque;  // 40칸 × 최대 2회 ≤ 128
static void pf(IntDeque *d, int v) { d->head = (d->head - 1u) & 127u; d->a[d->head] = v; d->len++; }
static void pb(IntDeque *d, int v) { d->a[(d->head + d->len) & 127u] = v; d->len++; }
static int  popf(IntDeque *d) { int v = d->a[d->head]; d->head = (d->head + 1u) & 127u; d->len--; return v; }

static int zero_one_bfs(const char *const g[ROWS]) {
    int dist[ROWS * COLS], s = 0, goal = 0;
    IntDeque dq = { .head = 0, .len = 0 };
    for (int i = 0; i < ROWS * COLS; i++) {
        dist[i] = 1 << 30;
        if (g[i / COLS][i % COLS] == 'S') s = i;
        if (g[i / COLS][i % COLS] == 'G') goal = i;
    }
    dist[s] = 0; pf(&dq, s);
    while (dq.len) {
        int cur = popf(&dq), r = cur / COLS, c = cur % COLS;
        for (int d = 0; d < 4; d++) {
            int nr = r + DR[d], nc = c + DC[d];
            if (nr < 0 || nr >= ROWS || nc < 0 || nc >= COLS) continue;
            int id = nr * COLS + nc, w = g[nr][nc] == '#';
            if (dist[cur] + w < dist[id]) {
                dist[id] = dist[cur] + w;
                if (w) pb(&dq, id); else pf(&dq, id);
            }
        }
    }
    return dist[goal];
}

int main(void) {
    const char *const g1[ROWS] = { "S.#.....", ".##.###.", "....#...",
                                   ".#.##.#.", ".#....#G" };
    const char *const g2[ROWS] = { "S..#....", ".#.#.##.", ".#.#..#.",
                                   "...#.#..", ".#.#...G" };
    printf("BFS 최단 거리: %d\n", bfs(g1));
    printf("0-1 BFS 부술 벽: %d\n", zero_one_bfs(g2));
    return 0;
}
// 출력:
// BFS 최단 거리: 15
// 0-1 BFS 부술 벽: 1

⑥ undo / redo — 오래된 기록을 버리는 링 스택 🟡 중급

크기가 정해진 undo — 스택인데 바닥이 움직입니다

#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>

#define HIST 4u                       // 2의 거듭제곱 — 넘치면 가장 오래된 것 버림
typedef struct { int v[HIST]; uint32_t top, bottom; } RingStack;

static void rs_push(RingStack *s, int x) {
    s->v[s->top & (HIST - 1u)] = x;
    s->top++;
    if (s->top - s->bottom > HIST) s->bottom++;   // 바닥이 한 칸 올라감
}
static bool rs_pop(RingStack *s, int *out) {
    if (s->top == s->bottom) return false;
    s->top--;
    *out = s->v[s->top & (HIST - 1u)];
    return true;
}

static RingStack undo_s, redo_s;
static int value = 0;

static void set_value(int v) {
    rs_push(&undo_s, value);
    redo_s.top = redo_s.bottom;       // ★ 새 동작은 redo 를 비웁니다
    value = v;
}
static bool undo(void) {
    int p;
    if (!rs_pop(&undo_s, &p)) return false;
    rs_push(&redo_s, value);
    value = p;
    return true;
}
static bool redo(void) {
    int n;
    if (!rs_pop(&redo_s, &n)) return false;
    rs_push(&undo_s, value);
    value = n;
    return true;
}

int main(void) {
    for (int v = 10; v <= 50; v += 10) set_value(v);
    printf("set x5 -> %d\n", value);
    int k = 0;
    while (undo()) k++;
    printf("undo %d회 -> %d\n", k, value);
    redo(); redo();
    printf("redo x2 -> %d\n", value);
    set_value(99);
    bool r = redo();
    printf("set 99 -> %d, redo=%d\n", value, r);
    return 0;
}
// 출력:
// set x5 -> 50
// undo 4회 -> 10        ← 기록이 4칸이라 처음 값 0 까지는 못 감
// redo x2 -> 30
// set 99 -> 99, redo=0

패턴별 정리 — 무엇을, 얼마만큼

패턴자료구조C 에서의 크기 상한
괄호 검사스택최대 중첩 깊이 (입력 길이 이하)
다음 큰 수단조 스택n
윈도우 최댓값단조 덱n (스트림이면 원형 k)
BFS큐칸 수 (방문 표시를 push 때)
0-1 BFS덱칸 수 × 2 (값이 한 번 더 줄 수 있음)
undo/redo링 스택 2개보관할 기록 수 (정책)
재귀 없는 DFS스택칸 수 — SQ 12
BFS 의 방문 표시는 push 할 때 — pop 할 때 표시하면 같은 칸이 여러 이웃에게서 중복으로 들어가 큐 상한이 "칸 수"를 넘습니다. 고정 배열이면 그대로 버퍼 넘침입니다.
undo 에 무엇을 저장하나 — 여기선 값 하나지만 실무에서는 "명령과 그 역연산"(command 패턴)이나 변경 전 구조체 사본을 저장합니다. 사본이 크면 링에는 풀 인덱스(uint8_t)만 두고 본체는 SQ 09 의 정적 풀에 둡니다.
정리 — C 로 알고리즘을 쓸 때는 "각 원소가 몇 번 들어가나"로 크기 상한을 증명하고 고정 배열을 씁니다. 한 번이면 선형 배열, 끝없이 흐르면 원형, 오래된 것을 버려야 하면 바닥이 움직이는 링 스택입니다.
SQ 09

성능과 메모리 — malloc 없는 정적 풀과 바이트 단위 예산

C 의 큐 성능은 대부분 연산이 아니라 메모리에서 갈립니다 — 원소가 연속해 있나, 노드마다 할당하나, 크기를 몇 바이트로 잡았나. 임베디드에서는 한발 더 나가 RAM 이 바이트 단위 예산입니다. 이 섹션은 원소당 실제 바이트를 계산하고, 큐·풀의 크기를 컴파일 타임에 검사하는 방법을 정리합니다.

int 100만 개를 담는 비용 (64비트 · glibc) — 막대 끝이 계산값 0 10 20 30 MB 배열 링 (2²⁰ 칸) 4.2 MB · 원소당 4 B realloc 2배 스택 6.3 MB (재할당 순간 최대) 침입형 객체 배열 24 MB · int+패딩+16 B malloc 노드 큐 32 MB (노드 16 B → 청크 32 B) 같은 데이터가 8배 차이 — 노드 큐는 거기에 캐시 미스까지 얹습니다
원소가 작을수록 포인터와 malloc 머리가 본체보다 커집니다 — int 4 바이트에 32 바이트를 쓰는 것이 노드 큐입니다.

계산 근거

구조식바이트
배열 링2²⁰ × 44,194,304
동적 스택 최대옛 2¹⁹×4 + 새 2²⁰×46,291,456
침입형10⁶ × (4+4+16)24,000,000
malloc 노드10⁶ × 32 (청크)32,000,000

struct { int v; Node *next; } 는 4 + 패딩 4 + 8 = 16 B, glibc 의 64비트 malloc 은 요청 16 B 에 머리 8 B 를 붙여 최소 청크 32 B 로 줍니다. 침입형의 16 B 는 list_head 의 포인터 둘입니다. 캐시 효과는 기기마다 달라 숫자로 적지 않았습니다 — 직접 재 보면 노드 순회가 배열 순회보다 여러 배 느린 것이 보통입니다.

% 와 & — 무엇이 실제로 느린가

코드컴파일 결과
u % 256 (unsigned, 상수 2ⁿ)AND 한 번 — 컴파일러가 알아서 바꿈
i % 256 (int)음수 보정 명령이 붙음 — 그리고 음수 결과
u % n (n 이 변수)진짜 나눗셈
↳ Cortex-M3/M4UDIV 명령 (2~12 사이클)
↳ Cortex-M0 · AVR나눗셈 명령 없음 → 라이브러리 루틴, 수백 사이클
결론 — 마스크를 손으로 쓰는 진짜 이유는 속도보다 "2의 거듭제곱이라는 가정"을 코드에 드러내는 것입니다. 그 가정은 아래처럼 _Static_assert 로 못 박습니다. 용량을 런타임 값으로 받는 범용 링이라면 마스크 덕을 가장 크게 봅니다.

임베디드 — 정적 풀과 크기 예산 🟡 중급

ATmega328P SRAM 2048 B — 큐 다섯 개의 몫 450 B 22 % 큐 · 버퍼 내역 (AVR 은 정렬 1 바이트) UART 수신 링 64 × 1 B 64 UART 송신 링 64 × 1 B 64 이벤트 큐 16 × 2 B 32 명령 큐 8 × 3 B 24 로그 링 32 × 8 B 256 인덱스 5쌍 × uint8 2 B 10 합계 450 나머지 1598 B 에 전역 변수 · 호출 스택 로그 링이 절반 이상 — 줄일 1순위 숫자는 이 예시 설계 기준 · 실제 값은 avr-size 와 맵 파일로 확인합니다
큐 크기는 "넉넉히"가 아니라 예산입니다 — 합계와 비율을 표로 들고 있어야 줄일 곳이 보입니다.

정적 풀 — 빈 칸 목록 자체가 스택

#include <stdint.h>
#include <stddef.h>

#define POOL_N 16u
typedef struct { uint8_t data[60]; uint16_t len; } Packet;   // 62 B

static Packet  pool[POOL_N];                 // .bss — 부팅 때 0
static uint8_t free_stk[POOL_N];             // 빈 칸 번호들의 스택
static uint8_t free_top;

void pool_init(void) {
    for (uint8_t i = 0; i < POOL_N; i++)
        free_stk[i] = (uint8_t)(POOL_N - 1u - i);   // 0 번이 맨 위에 오도록
    free_top = POOL_N;
}
Packet *pool_alloc(void) {                   // O(1), 단편화 없음
    return free_top ? &pool[free_stk[--free_top]] : NULL;
}
void pool_free(Packet *p) {
    free_stk[free_top++] = (uint8_t)(p - pool);     // 포인터 → 칸 번호
}

// 큐에는 62 B 사본 대신 1 B 칸 번호만 넣습니다
//   생산자: Packet *p = pool_alloc(); 채움; q_push(&q, idx)
//   소비자: q_pop(&q, &idx); 처리; pool_free(&pool[idx])
// 메모리: 풀 16×62 = 992 B + 빈칸 스택 16 B + 인덱스 큐 16 B
ISR 이 alloc, main 이 free 하면 free_top 을 양쪽이 고칩니다 — SQ 07 의 count 와 같은 문제입니다. 이때는 빈 칸 목록도 SPSC 링으로 만들어 "빈 칸 링(main→ISR) + 찬 칸 링(ISR→main)" 두 개로 순환시킵니다. FreeRTOS 탭 r05 의 free/full 큐 두 개가 같은 모양입니다.

컴파일 타임에 못 박기 — _Static_assert

#include <assert.h>     // C11: static_assert 매크로
#include <stdint.h>
// (POOL_N · Packet 은 왼쪽 카드의 정의를 그대로 씁니다)

#define IS_POW2(x)  ((x) != 0 && ((x) & ((x) - 1)) == 0)

#define RX_N   64u
#define LOG_N  32u

typedef struct { uint32_t t; uint16_t code, arg; } LogRec;

_Static_assert(IS_POW2(RX_N),  "RX_N must be a power of two");
_Static_assert(IS_POW2(LOG_N), "LOG_N must be a power of two");
_Static_assert(RX_N <= 128u,   "uint8_t free-running index needs N <= 128");
_Static_assert(sizeof(LogRec) == 8, "LogRec layout changed");
_Static_assert(POOL_N <= 255u, "pool index must fit uint8_t");

// RAM 예산 자체를 검사할 수도 있습니다
#define QUEUE_RAM (2u*RX_N + LOG_N*sizeof(LogRec) + POOL_N*sizeof(Packet))
_Static_assert(QUEUE_RAM <= 1536u, "queues exceed RAM budget");

// C23 — 메시지 생략 가능, 키워드가 됨
// static_assert(IS_POW2(RX_N));
C 버전별 — C11 은 _Static_assert(식, "메시지") 키워드 + <assert.h> 의 static_assert 매크로, C23 은 static_assert 가 키워드가 되고 메시지를 생략할 수 있습니다. 구조체 레이아웃 검사는 🅲 C 기초 탭 c06 의 패딩 이야기와 짝입니다.
필드 순서로 큐를 줄이기 — Cortex-M 에서 {uint8_t type; uint32_t ts; uint16_t len;} 는 패딩 때문에 12 B, {uint32_t ts; uint16_t len; uint8_t type;} 는 8 B 입니다. 32 칸 큐면 384 B → 256 B. (AVR 은 정렬이 1 이라 둘 다 7 B)
정리 — 원소가 작을수록 노드 큐는 손해가 커서(int 하나에 32 B) 배열 링이 기본입니다. 임베디드에서는 정적 풀 + 1 바이트 인덱스 큐로 큰 데이터를 옮기고, 2의 거듭제곱·인덱스 폭·RAM 예산을 _Static_assert 로 못 박습니다.
SQ 10

흔한 실수 모음 — 컴파일은 되고 가끔만 틀리는 여덟 가지

C 의 스택·큐 버그는 대부분 경고 없이 컴파일되고, 테스트에서는 잘 돌고, 현장에서 가끔 터집니다. 인덱스가 넘치는 순간(몇 시간 뒤), 큐가 정확히 가득 찬 순간, 메모리가 부족한 순간에만 드러나기 때문입니다. 아래 여덟 가지는 코드 리뷰에서 보이는 즉시 고쳐야 하는 목록입니다.

① 한 칸 비우기인데 감기를 빠뜨림
// N = 8, h = 7, t = 0 → 실제로는 '가득'
if (r->h + 1 == r->t) return false;   // 8 == 0 ? 거짓
r->buf[r->h] = v;                     // 마지막 빈칸에 씀
r->h = (r->h + 1) & 7;                // h = 0 == t
// → 이제 h == t 라서 큐가 '비었다'고 판정됨
//   8 개가 통째로 사라집니다
비교도 같은 마스크로
if (((r->h + 1) & MASK) == r->t) return false;
// 또는 SQ 03 ③: if (h - t == N) return false;
② 부호 있는 인덱스 + %
int head, tail;                 // ✘
q->buf[head++ % N] = v;         // INT_MAX 에서 ++ = 미정의 동작
// 초당 100만 개면 약 36분 뒤
// 덱의 앞 push: (head - 1) % N  →  head 가 0 이면 -1
// buf[-1] = 배열 앞의 다른 변수를 덮어씀
부호 없는 타입 + 마스크
uint32_t head, tail;            // 넘침 = 한 바퀴 (정의됨)
q->buf[head & MASK] = v; head++;
head = (head - 1u) & MASK;      // 0 - 1 → MASK
③ realloc 결과를 원래 포인터에 바로
s->data = realloc(s->data, ncap * sizeof *s->data);
if (!s->data) return false;
// 실패하면 NULL 이 덮어써져 옛 블록 주소를 잃음
// → 메모리 누수 + 스택의 원소 전부 소실
//   "메모리 부족이니 하나만 버리자"가 "전부 잃음"이 됨
임시 변수로 받고 성공 후 대입
int *p = realloc(s->data, ncap * sizeof *p);
if (!p) return false;        // s->data 그대로 살아 있음
s->data = p;
s->cap  = ncap;
④ 비어 있는 큐에서 pop
int stack_pop(IntStack *s) {
    return s->items[--s->top];   // top 이 0 이면?
}
// size_t top: 0 - 1 = SIZE_MAX → 배열 밖 읽기
// int top: -1 → items[-1] 읽기
// 둘 다 쓰레기 값을 '정상 값'처럼 돌려줌
bool + out, 또는 assert
bool stack_pop(IntStack *s, int *out) {
    if (s->top == 0) return false;
    *out = s->items[--s->top];
    return true;
}
⑤ 2의 거듭제곱이 아닌데 마스크
#define N 10
#define MASK (N - 1)            // 9 = 0b1001
buf[i & MASK] = v;
// 100 번 넣으면 칸별 횟수:
//   [0]26 [1]26 [2..7]0 [8]24 [9]24
// 열 칸 중 네 칸만 쓰면서 덮어씁니다
가정을 컴파일러에게 검사시킴
_Static_assert((N & (N - 1)) == 0,
               "N must be a power of two");
// 크기를 못 바꾸면 SQ 03 ① 의 비교 방식으로
⑥ uint8_t 무한 증가 인덱스에 N = 256
#define N 256u
uint8_t h, t;
if ((uint8_t)(h - t) == N) return false;  // 절대 참이 안 됨
// 256 개를 넣으면 h - t = 256 → uint8 로 0
// → '가득'이 아니라 '비었음'으로 보이고,
//   다음 push 가 가장 오래된 데이터를 덮어씀
N < 2^(인덱스 비트 수)
_Static_assert(N <= 128u, "uint8 index: N <= 128");
// 256 칸이 꼭 필요하면 uint16_t 인덱스
// (AVR 이면 찢어진 읽기 주의 — SQ 07)

⑦ 호출 스택과 자료구조 스택 — 이름만 같고 다른 것 🔴 고급

ATmega328P SRAM 2048 B — 호출 스택은 위에서 아래로 자랍니다 (예시 배치) 전역 .data · .bss 600 큐 · 버퍼 450 여유 798 스택 200 0x0100 0x08FF 자라는 방향 재귀 flood fill 16×16 — 깊이 256 × 프레임 약 10 B 호출 스택 약 2560 B — 여유 · 큐 · 전역 변수를 차례로 덮어씀 SRAM 밖까지 — 예외도 경고도 없음 자료구조 스택은 크기를 정해 .bss 에 두니 넘쳐도 push 가 false 로 알려 줍니다
"스택 오버플로"는 두 가지입니다 — 호출 스택은 조용히 데이터를 덮고, 자료구조 스택은 false 를 돌려줍니다.

둘을 구분하기

호출 스택 (call stack)자료구조 스택
누가 관리컴파일러 · CPU (SP 레지스터)내 코드 (top)
크기링커 스크립트 · RTOS 태스크 생성 시배열 크기
넘치면MCU: 조용한 메모리 파괴, PC: SIGSEGVpush 가 false
깊이를 아는 법정적 분석 -fstack-usage, 워터마크top 최고값 기록

그래서 MCU 에서 깊이가 입력에 따라 변하는 재귀(트리 순회, flood fill, 파서)는 명시적 스택으로 바꾸는 것이 규칙입니다. 크기 상한을 계산할 수 있고, 넘쳐도 감지됩니다. 같은 flood fill 을 명시적 스택으로 바꾼 코드는 SQ 12 에 있습니다(실측 최대 121 칸).

⑧ 나머지 — 리뷰 체크리스트

증상원인고침
가끔 바이트 하나가 사라짐ISR · main 이 같이 count++/--count 제거, 무한 증가 인덱스
AVR 에서 드물게 이상한 길이16비트 인덱스 찢어진 읽기uint8_t 인덱스 · ATOMIC_BLOCK
void* 스택 값이 깨짐sizeof(T*) 를 원소 크기로sizeof *ptr 습관 · 매크로판
tail 이 해제된 노드마지막 pop 에서 tail 미갱신if (!head) tail = NULL
침입형 리스트가 엉킴리스트에 걸린 채 객체 해제해제 전 list_del_init
타임아웃이 1분 늘어남REALTIME 시계로 대기MONOTONIC (SQ 06)
49.7일째 타이머 멈춤now >= deadline 직접 비교(int32_t)(now - d) >= 0
힙 순서가 가끔 뒤섞임요소를 넣은 뒤 우선순위 수정빼고 → 고치고 → 다시 넣기
도구로 잡히는 것 — ①②④⑤ 는 경계 단위 테스트 + AddressSanitizer · UBSan(-fsanitize=address,undefined)이 대부분 잡습니다. ISR 경쟁은 도구로 안 잡히니 설계(SPSC 규약)로 막아야 합니다. 새니타이저 사용법은 ⚙️ C 실전 탭 c24.
정리 — 경계(가득·빔·0−1·넘침 직전)와 공유(ISR·스레드)에서 터집니다. 부호 없는 인덱스 + 마스크 + _Static_assert + bool 반환을 기본으로 두면 여덟 가지 중 여섯이 구조적으로 사라집니다.
SQ 11

다른 언어와 비교 — C 가 직접 만드는 것을 그들은 무엇으로 하나

C 로 만든 링 버퍼·힙은 다른 언어 표준 라이브러리의 무엇과 같은가, 반대로 다른 언어 코드를 C 로 옮길 때 어떤 기본값이 바뀌는가를 한 장에 모읍니다. 특히 조심할 것은 두 가지 — 빈 컨테이너에서 꺼낼 때의 동작과 우선순위 큐의 방향(최소 힙인가 최대 힙인가)입니다.

같은 일, 다른 기본값 — 둘째 열은 붉을수록 빈 pop 이 위험 기본 타입 빈 상태 pop 힙 방향 스레드 큐 고정 크기 C 직접 (배열 · 링) 직접 검사 (bool) cmp 로 정함 pthread · atomic 정적 배열 C++ std::deque 어댑터 UB (검사 없음) 최대 힙 없음 (직접) array + 직접 Java ArrayDeque null · 예외 최소 힙 BlockingQueue 없음 Python list · deque IndexError 최소 힙 queue.Queue 없음 Rust Vec · VecDeque Option (None) 최대 힙 mpsc 채널 heapless Go 슬라이스 · chan panic (인덱스) Less 로 정함 chan 없음 C++ 와 Rust 는 최대 힙, Java 와 Python 은 최소 힙 — 옮길 때 가장 많이 틀리는 줄
C 는 모든 칸이 "직접"입니다 — 대신 빈 pop 정책과 힙 방향을 내가 고른다는 점이 장점이자 책임입니다.
할 일C (이 탭)C++JavaPythonRustGo
스택배열 + top (SQ 02)std::stack / vectorArrayDequelistVec슬라이스
큐2ⁿ 원형 버퍼 (SQ 03)std::queue / dequeArrayDequedequeVecDeque슬라이스 · chan
덱링 head+len (SQ 04)std::deque (청크 맵)ArrayDequedeque (블록 사슬)VecDeque없음 (container/list)
우선순위배열 힙 + cmp (SQ 05)priority_queue (최대)PriorityQueue (최소)heapq (최소)BinaryHeap (최대)container/heap
스레드 N:Mmutex + cond (SQ 06)직접 (condition_variable)ArrayBlockingQueuequeue.Queuempsc · crossbeamchan
SPSC 락 없음atomic 링 (SQ 07)직접 (std::atomic)JCTools 등—rtrb 등 크레이트chan 로 충분
할당 없는 노드 큐list_head (SQ 04)Boost.Intrusive——intrusive-collections—

C++ 로 옮길 때 — 같은 이름, 다른 계약

#include <queue>
#include <stack>

std::stack<int> st;            // 기본 컨테이너는 std::deque
st.push(1);
int top = st.top();            // 비어 있으면 UB — 검사는 내 몫
st.pop();                      // void 반환! 값은 top() 으로 먼저

std::priority_queue<int> pq;   // ★ 최대 힙 (C 코드의 최소 힙과 반대)
std::priority_queue<int, std::vector<int>, std::greater<int>> minq;

// C 의 bool stack_pop(s, &out) 를 그대로 옮기려면:
bool try_pop(std::stack<int> &s, int &out) {
    if (s.empty()) return false;
    out = s.top(); s.pop();
    return true;
}
C++ 의 pop() 이 값을 돌려주지 않는 이유는 예외 안전성 때문입니다(복사 중 예외가 나면 원소를 잃음). C 에는 예외가 없어 pop(s, &out) 한 번에 해도 됩니다.

자바 · 파이썬 개발자가 C 에서 놀라는 지점

  • 용량을 정해야 한다 — "가득 차면 어떻게 하나"(거절 · 덮어쓰기 · 대기)를 설계 단계에서 고릅니다
  • 빈 pop 이 예외가 아니다 — 검사하지 않으면 쓰레기 값이 정상처럼 돌아옵니다
  • 제네릭이 없다 — void* · 매크로 · _Generic 중 하나를 고릅니다 (SQ 02)
  • volatile 의 뜻이 다르다 — 자바의 volatile(가시성 + 순서)에 해당하는 것은 C 의 _Atomic 입니다. C 의 volatile 은 하드웨어 레지스터용
  • GC 가 없다 — 큐에 포인터를 넣으면 "누가 해제하나"를 큐 API 문서에 적습니다(소유권 이전)
같은 이름, 같은 내부 — 자바 ArrayDeque · 러스트 VecDeque 는 SQ 04 의 덱과 거의 같은 코드입니다 (2의 거듭제곱 용량 · 마스크 · 자랄 때 두 조각 복사). 반대로 C++ std::deque 와 파이썬 deque 는 고정 크기 블록을 이어 붙인 구조라 원소 주소가 성장 중에도 유지됩니다.
정리 — C 의 링·힙·list_head 는 다른 언어의 ArrayDeque · PriorityQueue · Boost.Intrusive 에 해당합니다. 옮길 때는 빈 pop 동작과 힙 방향(C++·Rust 최대, Java·Python 최소)부터 확인하세요.
SQ 12

실무 사례 — 이벤트 큐 · 명령 큐 · 로그 링 · 재귀 없는 탐색

펌웨어의 메인 루프는 거의 언제나 같은 모양입니다 — 인터럽트가 큐에 넣고, 메인 루프가 꺼내 처리합니다. 그 위에 상태 머신이 얹히고, 사람이 보내는 명령은 줄 단위로 파싱되어 명령 큐로, 무슨 일이 있었는지는 로그 링으로 갑니다. 네 가지 모두 이 탭의 링 버퍼 하나로 만들어지며, 출력은 같은 로직을 그대로 옮겨 돌린 값입니다.

이벤트 큐 + 상태 머신 — 원이 상태, 화살표 글자가 이벤트 시작 IDLE RUN ticks 0~2 DONE PAUSE BTN 3번째 TICK TICK: ticks++ BTN BTN TICK 무시 BTN — 처음으로 ISR · 타이머가 ev_post() → 2 B 이벤트 링 (16 칸) main 이 하나씩 dispatch() 상태 전이는 메인 루프 한 곳에서만 — ISR 은 사실(이벤트)만 알립니다
ISR 은 무슨 일이 있었는지만 큐에 넣고, 무엇을 할지는 상태 머신이 한 곳에서 정합니다.

① 이벤트 큐 + 상태 머신 (PC 에서 시뮬레이션)

#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>

typedef enum { EV_BTN, EV_TICK } EvType;
typedef struct { uint8_t type, arg; } Event;      // 2 바이트

#define EQ_N 16u
static Event    eq[EQ_N];
static uint32_t eq_h, eq_t;  // 실기에서는 SQ 07 의 atomic · 배리어 판으로

static bool ev_post(Event e) {                    // ISR 쪽 (생산자 하나)
    if (eq_h - eq_t == EQ_N) return false;        // 가득 — 버리고 알림
    eq[eq_h & (EQ_N - 1u)] = e;
    eq_h++;
    return true;
}
static bool ev_get(Event *e) {                    // main 쪽 (소비자 하나)
    if (eq_h == eq_t) return false;
    *e = eq[eq_t & (EQ_N - 1u)];
    eq_t++;
    return true;
}

typedef enum { ST_IDLE, ST_RUN, ST_PAUSE, ST_DONE } State;
static const char *const ST_NAME[] = { "IDLE", "RUN", "PAUSE", "DONE" };
static State st = ST_IDLE;
static int ticks;

static void dispatch(Event e) {
    State prev = st;
    switch (st) {
    case ST_IDLE:  if (e.type == EV_BTN) { ticks = 0; st = ST_RUN; } break;
    case ST_RUN:   if (e.type == EV_BTN) st = ST_PAUSE;
                   else if (++ticks == 3) st = ST_DONE;
                   break;
    case ST_PAUSE: if (e.type == EV_BTN) st = ST_RUN; break;   // TICK 무시
    case ST_DONE:  if (e.type == EV_BTN) st = ST_IDLE; break;
    }
    if (st != prev)
        printf("%-5s --%s--> %s (ticks=%d)\n", ST_NAME[prev],
               e.type == EV_BTN ? "BTN" : "TICK", ST_NAME[st], ticks);
}

int main(void) {
    const uint8_t script[] = { EV_BTN, EV_TICK, EV_TICK, EV_BTN,
                               EV_TICK, EV_BTN, EV_TICK, EV_BTN };
    for (size_t i = 0; i < sizeof script; i++)    // ISR 들이 넣었다고 가정
        ev_post((Event){ script[i], 0 });
    Event e;
    while (ev_get(&e)) dispatch(e);              // 메인 루프 한 바퀴
    return 0;
}
// 출력:
// IDLE  --BTN--> RUN (ticks=0)
// RUN   --BTN--> PAUSE (ticks=2)
// PAUSE --BTN--> RUN (ticks=2)
// RUN   --TICK--> DONE (ticks=3)
// DONE  --BTN--> IDLE (ticks=3)

② 명령 파서 — 바이트 → 줄 → 명령 큐 → 실행

#include <stdio.h>
#include <string.h>
#include <stdint.h>
#include <inttypes.h>
#include <stdbool.h>

enum { CMD_LED, CMD_PWM, CMD_RESET };
typedef struct { uint8_t op; int16_t arg; } Cmd;

#define CQ_N 4u
static Cmd cq[CQ_N];
static uint32_t cq_h, cq_t, cq_drop;

static void cq_push(Cmd c) {
    if (cq_h - cq_t == CQ_N) { cq_drop++; return; }   // 넘침은 세어 둠
    cq[cq_h++ & (CQ_N - 1u)] = c;
}
static bool cq_pop(Cmd *c) {
    if (cq_h == cq_t) return false;
    *c = cq[cq_t++ & (CQ_N - 1u)];
    return true;
}

static void parse_line(const char *s) {
    char name[8];
    int arg = 0;
    int n = sscanf(s, "%7s %d", name, &arg);
    if (n < 1) return;                                 // 빈 줄
    if (!strcmp(name, "LED") && n == 2 && (arg == 0 || arg == 1))
        cq_push((Cmd){ CMD_LED, (int16_t)arg });
    else if (!strcmp(name, "PWM") && n == 2 && arg >= 0 && arg <= 100)
        cq_push((Cmd){ CMD_PWM, (int16_t)arg });
    else if (!strcmp(name, "RESET") && n == 1)
        cq_push((Cmd){ CMD_RESET, 0 });
    else
        printf("ERR %s\n", s);                         // 검증은 넣기 전에
}

// UART 링(SQ 07)에서 꺼낸 바이트를 하나씩 먹임
static void feed(char c) {
    static char line[32];
    static size_t len;
    if (c == '\r') return;
    if (c == '\n') { line[len] = '\0'; parse_line(line); len = 0; return; }
    if (len < sizeof line - 1) line[len++] = c;       // 긴 줄은 잘라 냄
}

static void execute_all(void) {
    Cmd c;
    while (cq_pop(&c)) {
        switch (c.op) {
        case CMD_LED:   printf("LED %s\n", c.arg ? "on" : "off"); break;
        case CMD_PWM:   printf("PWM %d%%\n", c.arg); break;
        case CMD_RESET: printf("RESET\n"); break;
        }
    }
}

int main(void) {
    const char *rx = "LED 1\r\nPWM 75\nFOO 3\nPWM 300\nRESET\n";
    for (const char *p = rx; *p; p++) feed(*p);
    execute_all();
    printf("dropped=%" PRIu32 "\n", cq_drop);
    return 0;
}
// 출력:
// ERR FOO 3
// ERR PWM 300
// LED on
// PWM 75%
// RESET
// dropped=0

③ 로그 링 — 가득 차면 가장 오래된 것을 덮음

#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>

#define LOG_N 4u                     // 실기에서는 32~256
typedef struct { uint32_t t; uint16_t code, arg; } LogRec;
_Static_assert(sizeof(LogRec) == 8, "LogRec");

static LogRec   logs[LOG_N];
static uint32_t log_n;               // 지금까지 쓴 총 개수

void log_put(uint32_t t, uint16_t code, uint16_t arg) {
    // 거절하지 않습니다 — 최근 것이 더 중요하므로 덮어씀
    logs[log_n & (LOG_N - 1u)] = (LogRec){ t, code, arg };
    log_n++;
}
void log_dump(void) {                // 오래된 것 → 최근 것
    uint32_t n = log_n < LOG_N ? log_n : LOG_N;
    printf("최근 %" PRIu32 "건 (덮어쓴 것 %" PRIu32 "건)\n", n, log_n - n);
    for (uint32_t i = log_n - n; i != log_n; i++) {   // != 라서 넘침에도 안전
        const LogRec *r = &logs[i & (LOG_N - 1u)];
        printf("  t=%" PRIu32 " code=%u arg=%u\n", r->t, r->code, r->arg);
    }
}

int main(void) {
    for (uint16_t k = 1; k <= 6; k++)
        log_put(k * 100u, k, (uint16_t)(k * k));
    log_dump();
    return 0;
}
// 출력:
// 최근 4건 (덮어쓴 것 2건)
//   t=300 code=3 arg=9
//   t=400 code=4 arg=16
//   t=500 code=5 arg=25
//   t=600 code=6 arg=36
리셋 뒤에도 남기기 — 로그 링을 링커 스크립트의 초기화하지 않는 영역(GCC 관례 .noinit)에 두면 워치독 리셋 후에도 RAM 에 남아, 부팅 직후 "죽기 직전 무엇을 했나"를 꺼내 볼 수 있습니다. 섹션 이름과 지원 여부는 툴체인 · 링커 스크립트마다 다르니 맵 파일로 확인하세요.

④ 재귀 없는 flood fill — 크기가 증명된 명시적 스택

#include <stdio.h>
#include <stdint.h>

#define W 16
#define H 16
static uint8_t grid[H][W];        // 0 빈칸 · 1 벽 · 2 칠함
static uint8_t stk[W * H];        // ★ 칸마다 최대 한 번 push → 256 이면 못 넘침
_Static_assert(W <= 16 && H <= 16, "row/col packed into one uint8_t");

static const int8_t DR[4] = { -1, 1, 0, 0 }, DC[4] = { 0, 0, -1, 1 };

static int flood(int r0, int c0, int *max_depth) {
    int top = 0, filled = 0, maxd = 0;
    if (grid[r0][c0] != 0) return 0;
    grid[r0][c0] = 2;                          // push 할 때 표시 — 중복 방지
    stk[top++] = (uint8_t)(r0 << 4 | c0);
    while (top > 0) {
        uint8_t p = stk[--top];
        int r = p >> 4, c = p & 15;
        filled++;
        for (int d = 0; d < 4; d++) {
            int nr = r + DR[d], nc = c + DC[d];
            if (nr < 0 || nr >= H || nc < 0 || nc >= W) continue;
            if (grid[nr][nc] != 0) continue;
            grid[nr][nc] = 2;
            stk[top++] = (uint8_t)(nr << 4 | nc);
            if (top > maxd) maxd = top;
        }
    }
    *max_depth = maxd;
    return filled;
}

int main(void) {
    int maxd;
    int n = flood(0, 0, &maxd);           // 벽 없는 16×16 — 최악에 가까운 경우
    printf("filled=%d max_stack=%d B\n", n, maxd);
    return 0;
}
// 출력:
// filled=256 max_stack=121 B
// (같은 칸 순서의 재귀판은 깊이 256 — 프레임마다 수~수십 B)
상한의 증명 — 칸을 push 할 때 표시하므로 한 칸은 평생 한 번만 스택에 들어갑니다. 그래서 스택 크기 = 칸 수(256 B)면 어떤 입력에도 넘치지 않습니다. 재귀판은 이 보장이 호출 스택으로 넘어가 SQ 10 그림처럼 조용히 RAM 을 덮습니다.
실무 사례모양가득 찼을 때 정책동시성
UART 수신uint8 링 64~256 B버리고 drops++ISR → main SPSC (SQ 07)
이벤트 큐2 B 구조체 링버리고 알림 (설계 결함 신호)여러 ISR 이면 인터럽트 잠깐 끄고 push
명령 큐구조체 링 4~8 칸거절하고 "BUSY" 응답main 단독
로그 링8 B 레코드 링가장 오래된 것 덮음main 단독 (ISR 이면 SPSC 로)
센서 이동 평균샘플 링 + 누적 합덮음 (창이 곧 용량)main 단독
소프트웨어 타이머이진 힙 (SQ 05)등록 실패 반환등록 요청만 큐로
깊은 탐색명시적 스택상한 증명으로 불가능하게—
스레드 작업 큐 (리눅스)mutex + cond 링 (SQ 06)대기 (배압)N:M
이벤트 큐에 ISR 이 여럿이면 — 생산자가 둘 이상이 되어 SPSC 가 깨집니다. 선택지는 셋입니다: ① ISR 마다 큐를 따로 두고 main 이 차례로 비우기(가장 단순, 권장), ② push 하는 동안만 인터럽트를 잠깐 끄기(__disable_irq() / cli(), 수 마이크로초 이내), ③ RTOS 를 쓴다면 xQueueSendFromISR — 이 경우는 ⏱️ FreeRTOS 탭 r05 의 큐를 그대로 씁니다.
정리 — 펌웨어의 뼈대는 ISR → 링 → 메인 루프 → 상태 머신입니다. 사례마다 다른 것은 단 하나, 가득 찼을 때의 정책(버림 · 거절 · 덮어쓰기 · 대기)이고, 그 결정을 코드 주석과 API 이름에 드러내는 것이 C 다운 설계입니다.
C17 · C23 · CMake 4.x · Arm GNU Toolchain 14.x · MISRA C:2023 · STM32F446RE 펌웨어 + PC 라이브러리 양쪽

수십만 줄이 되어도
고칠 수 있는
C 코드의 뼈대

C 에는 패키지도, 클래스도, 접근 제어자도, 의존성 주입 컨테이너도 없습니다. 그래서 코드가 1만 줄을 넘고 사람이 셋을 넘고 보드가 두 종류가 되는 순간, 자바 · 파이썬에서는 언어와 프레임워크가 막아 주던 사고가 그대로 터집니다 — 전역 변수 그물, 헤더 하나 고치면 전부 다시 빌드, #ifdef 미로, 순환 의존. 이 탭은 그 빈자리를 규칙과 빌드 도구로 메우는 법을 다룹니다: 불투명 포인터로 만드는 모듈, 헤더 규칙, 레이어와 의존 방향, 여러 보드 · 여러 제품을 담는 디렉터리, CMake 타깃으로 의존을 강제하는 법, vtable 로 만드는 인터페이스와 테스트 대역, 에러 · 메모리 · 이벤트 규약, MISRA 와 정적 분석, 그리고 리눅스 커널 · Zephyr · ESP-IDF · SQLite · Redis 가 실제로 쓰는 구조까지. 이 탭을 마치면 "팀이 함께 오래 고칠 수 있는" C 프로젝트의 골격을 처음부터 세우고, 무너진 프로젝트를 어디서부터 고칠지 판단할 수 있습니다.

13SECTIONS
C17기준 (C23 표시)
PC + MCU양쪽 예제
20시각화
ARCH 01

C 프로젝트가 커지면 무엇이 무너지나 — 규모별 증상

C 프로젝트는 갑자기 망가지지 않습니다. 줄 수 · 사람 수 · 보드 수가 하나씩 늘 때마다 정해진 순서로 금이 갑니다. 증상을 먼저 알아 두면 "지금 우리 프로젝트가 어디쯤인지", 그리고 이 탭의 어느 섹션이 그 처방인지가 보입니다. 헤더/소스 분리 · static · Makefile 기초는 C 기초 탭 c10, 라이브러리 배포는 C 실전 탭 c23, 기본 디렉터리 · CMake 표준형은 c25 에 있습니다 — 여기서는 그 다음 이야기입니다.

규모가 커질 때 차례로 나타나는 증상 — 위는 코드, 아래는 사람과 보드 1천 줄 1만 줄 5만 줄 20만 줄 50만 줄+ main.c 하나 전역 변수 몇 개 전역이 그물이 됨 만능 헤더 등장 헤더 수정 = 전부 재빌드 #ifdef 미로 시작 순환 include 빌드 8분 · 충돌 잦음 누구도 전체를 모름 고치면 딴 데가 깨짐 1명 · 보드 1 2~3명 5명 · 보드 2종 10명+ · 제품 3종 여러 팀 · 보드 N 줄 수보다 "사람 × 보드 수"가 늘 때 구조가 먼저 무너집니다 숫자는 흔히 겪는 구간의 예 — 팀과 도메인에 따라 앞당겨지기도 합니다
규모별 증상 — 전역 → 헤더 → #ifdef → 순환 순서로 무너집니다. 이 순서가 곧 이 탭의 목차입니다.

증상 1 — 전역 변수 그물

❌ 누가 언제 바꾸는지 아무도 모름
/* globals.h — 1만 줄 프로젝트에서 흔히 보는 모습 */
extern volatile uint8_t  g_rx_buf[256];
extern volatile uint16_t g_rx_len;
extern int32_t           g_temp_mc;     /* 센서 모듈이 씀 */
extern uint8_t           g_mode;        /* 7개 파일이 씀 */
extern bool              g_wifi_ok;
extern uint32_t          g_err_flags;   /* 아무나 비트를 켬 */

/* display.c */
if (g_mode == 3 && g_wifi_ok) { ... }   /* 3 이 뭐였더라? */

/* protocol.c — 화면 코드가 모르는 사이에 */
g_mode = 3;
g_rx_len = 0;          /* ISR 이 쓰는 중이었다면? */
왜 치명적인가 — 전역 변수 하나는 "그 변수를 건드리는 모든 파일"을 서로 묶습니다. g_mode 를 7개 파일이 쓰면, 버그 하나를 찾으려고 7개 파일을 다 읽어야 합니다. 서버 개발에 빗대면 모든 클래스가 public static 필드 하나를 직접 고치는 상태입니다. 처방은 k02 — 상태를 모듈 안에 static 으로 가두고 함수로만 접근하게 합니다.

증상 2 — 만능 헤더와 #ifdef 미로

/* common.h — 모든 .c 가 이것 하나만 include 합니다 */
#include "stm32f4xx_hal.h"     /* HAL 전체 — 수만 줄 */
#include "uart.h"
#include "sensor.h"
#include "display.h"
#include "protocol.h"
#include "wifi.h"
/* ... 40개 더 ... */

/* sensor.c — 보드 · 제품 · 디버그 조건이 뒤엉킴 */
int32_t sensor_read(void) {
#if defined(BOARD_REV_A) || defined(BOARD_REV_B)
  #ifdef PRODUCT_PRO
    #if !defined(NO_CALIBRATION) && DEBUG_LEVEL > 2
    ...
    #endif
  #else
    ...
  #endif
#elif defined(BOARD_REV_C)
    ...
#endif
}
만능 헤더는 처음엔 편합니다. 하지만 display.h 에 매크로 하나만 추가해도 그 헤더를 볼 일도 없는 파일 400개가 다시 컴파일됩니다(아래 그림). #ifdef 미로는 조합 수만큼 다른 프로그램이 숨어 있다는 뜻이라 — 조건 4개면 16가지 — 한 번도 빌드되지 않은 조합이 반드시 생깁니다. 처방은 k03(헤더 규칙) 과 k07(설정 관리).
헤더 하나를 고쳤을 때 다시 컴파일되는 .c 개수 (소스 420개 프로젝트 예) 0 105 210 315 420 만능 헤더 common.h 412개 모듈 공개 헤더 uart.h 23개 — 실제로 UART 를 쓰는 파일만 내부 헤더 ring_internal.h 1개 — ring.c 뿐 빌드 시간은 "고친 헤더를 몇 파일이 보느냐"로 정해집니다
재빌드 범위 — 헤더를 좁게 쪼갤수록 고칠 때 다시 컴파일되는 범위가 줄어듭니다.

왜 C 에서만 이렇게 아픈가 — 자바 · 파이썬과 비교 🟡 중급

언어가 대신해 주던 일 — C 는 직접 해야 합니다

필요한 것자바 · 파이썬C 에서 쓰는 대체물
이름 공간package · 모듈접두사 uart_ sensor_ (k02)
정보 은닉private · _name파일 범위 static · 불투명 포인터 (k02)
의존 선언import · Maven/pip#include + CMake 타깃 PUBLIC/PRIVATE (k06)
인터페이스interface · ABC함수 포인터 구조체(vtable) (k08)
예외throw · try에러 코드 규약 + goto cleanup (k09)
메모리 관리GC소유권 규약 · 정적 할당 · 풀 (k10)
설정 · 프로필application.ymlconfig.h · 링크 타임 선택 · Kconfig (k07)
린트 · 규칙Checkstyle · ruffMISRA · clang-tidy · cppcheck (k12)
핵심 통찰 — C 의 대형 프로젝트는 "언어 기능"이 아니라 규약(convention) + 빌드 시스템 + 정적 분석의 세 다리로 섭니다. 규약만 있고 도구가 강제하지 않으면 6개월 안에 무너집니다. 이 탭의 모든 섹션이 "규칙 → 그 규칙을 강제하는 도구" 짝으로 되어 있는 이유입니다.

자가 진단 — 지금 우리 프로젝트는 어디쯤인가

# ① 전역 심볼이 몇 개인가 — 외부 연결(링커에 보이는) 변수
arm-none-eabi-nm -g --defined-only build/fw.elf | grep -c ' [BDR] '

# ② 가장 많이 include 되는 헤더 TOP 10
grep -rhoE '#include +"[^"]+"' src/ | sort | uniq -c | sort -rn | head

# ③ #ifdef 가 가장 많은 파일
grep -rcE '^\s*#\s*(if|ifdef|ifndef|elif)' src/ --include='*.c' \
  | sort -t: -k2 -rn | head

# ④ 헤더 하나 고치면 몇 개가 다시 빌드되나 (Ninja)
touch include/common.h && ninja -C build -n | grep -c 'Building C'

# ⑤ 순환 include 찾기 — include 그래프를 그려 봅니다
cmake -B build --graphviz=build/deps.dot   # 타깃 수준
# 파일 수준은 clang -H 또는 include-what-you-use (k03)
읽는 법 — ① 이 수십 개를 넘고 ② 의 1등이 전체 파일 수에 가깝고 ④ 가 전체의 절반을 넘으면 k02 · k03 부터 손대야 합니다. ③ 의 상위 파일은 k07 의 대상입니다. nm 의 B=.bss, D=.data, R=읽기 전용 데이터입니다.

실무에선 이렇게 — 무너진 프로젝트를 고치는 순서 🔴 실무

증상 → 원인 → 처방 섹션

증상원인처방
버그 하나에 파일 10개를 읽음전역 상태 공유k02 모듈화 · k10 소유권
빌드 5분 이상, 헤더 수정이 두려움만능 헤더 · 헤더 안 정의k03 헤더 규칙
드라이버가 앱 함수를 부름계층 역전k04 레이어 · 콜백
보드 추가 = 폴더 통째 복사보드 코드가 곳곳에k05 디렉터리 · k06 빌드
조합마다 빌드가 깨짐#ifdef 남용k07 설정 관리
하드웨어 없이 테스트 못 함구현에 직접 의존k08 인터페이스
현장에서 "가끔 멈춤"에러 무시 · 힙 단편화k09 · k10
main 루프가 플래그 지옥암묵적 상태k11 상태 머신

리팩터링은 "작게, 빌드가 깨지지 않게"

1주차  정적 분석 기준선 — 지금 경고 수를 기록하고 "늘지 않게"만 CI 에 겁니다
2주차  만능 헤더 해체 — common.h 를 지우지 말고, 각 .c 가 필요한 것만
       include 하게 바꾼 뒤 common.h 를 빈 껍데기로 (k03)
3주차  전역 변수 → 모듈 static + getter (가장 많이 쓰는 것부터) (k02)
4주차  HAL 호출을 드라이버 뒤로 — 앱에서 HAL_ 이 사라질 때까지 (k04)
5주차  CMake 타깃 분리 — 의존 위반이 "컴파일 에러"가 되게 (k06)
이후   새 보드 · 새 기능은 반드시 새 구조로만
"전부 다시 짜자"는 거의 항상 실패합니다. 펌웨어는 현장에서 검증된 타이밍 · 예외 처리가 코드 곳곳에 숨어 있어서, 새로 짜면 그 지식이 사라집니다. 서버 쪽의 스트랭글러 패턴처럼 — 새 구조를 옆에 세우고 모듈 하나씩 옮기며, 매 단계 실제 보드에서 돌려 봅니다.
정리 — C 프로젝트는 전역 → 헤더 → #ifdef → 순환 의존 순서로 무너집니다. 언어가 막아 주지 않으니 규약을 정하고 빌드 · 분석 도구로 강제하는 것이 대형 C 의 전부입니다.
ARCH 02

모듈 = 공개 헤더 + 비공개 소스 — 불투명 포인터로 정보 은닉

C 의 "클래스"는 .h 한 장 + .c 한 장입니다. 헤더는 계약서, 소스는 구현이고, 구현 안의 상태는 파일 범위 static 으로 가둡니다. 한 걸음 더 나가면 구조체의 내용까지 감출 수 있습니다 — 불투명 포인터(opaque pointer). 필드를 못 보면 못 고치고, 못 고치면 모듈 밖에서 깨뜨릴 수 없습니다. static 기초는 C 기초 탭 c10 에 있습니다.

같은 객체를 두 파일이 보는 모습 — 32비트 MCU 기준, 한 칸 4바이트 main.c 가 아는 것 (ring.h 만 include) ring_t *rx struct ring = ? (불완전) 크기도 필드도 모름 → rx->head 는 컴파일 에러 · sizeof(*rx) 도 에러 ring.c 안 — 정의를 아는 유일한 파일 +0 +4 +8 +12 buf * mask head tail 16 B 데이터 버퍼 64 B (capacity) buf 가 가리키는 곳 — PC 는 malloc, 펌웨어는 호출자가 준 정적 배열 헤더에 struct 를 안 보여 주면 main.c 는 필드에 손댈 방법이 없습니다
불투명 포인터 — 주소는 넘기되 모양은 숨깁니다. 필드를 바꿔도 ring.c 만 다시 컴파일됩니다.

include/ring.h — 계약서 (전부 공개)

#ifndef RING_H
#define RING_H

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

#ifdef __cplusplus
extern "C" {
#endif

/* 불완전 타입 — 내용은 ring.c 만 압니다 */
typedef struct ring ring_t;

/* capacity 는 2의 거듭제곱(2^31 이하). 실패하면 NULL */
ring_t *ring_create(size_t capacity);
void    ring_destroy(ring_t *r);          /* NULL 이면 아무것도 안 함 */

bool    ring_push(ring_t *r, uint8_t byte);   /* 가득 차면 false */
bool    ring_pop(ring_t *r, uint8_t *out);    /* 비었으면 false */
size_t  ring_count(const ring_t *r);

#ifdef __cplusplus
}
#endif
#endif /* RING_H */
서버 개발에 빗대면 — 자바의 interface + private 필드를 합친 것입니다. ring_t 를 받은 쪽은 함수로만 다룰 수 있습니다. 필드 순서를 바꾸든 head 를 uint16_t 로 줄이든 ring.h 가 그대로면 다른 파일은 다시 컴파일조차 되지 않습니다 — 라이브러리라면 ABI 도 유지됩니다(c23).

src/ring.c — 구현 (상태는 전부 여기)

#include "ring.h"      /* ★ 자기 헤더를 맨 먼저 (k03) */

#include <stdlib.h>

struct ring {
    uint8_t *buf;
    size_t   mask;      /* capacity - 1 */
    uint32_t head;      /* 지금까지 넣은 총 개수 — 넘쳐도 됨 */
    uint32_t tail;      /* 지금까지 꺼낸 총 개수 */
};

static bool is_pow2(size_t n) { return (n != 0u) && ((n & (n - 1u)) == 0u); }

ring_t *ring_create(size_t capacity) {
    if (!is_pow2(capacity) || capacity > 0x80000000u) { return NULL; }
    ring_t *r = malloc(sizeof *r);
    if (r == NULL) { return NULL; }
    r->buf = malloc(capacity);
    if (r->buf == NULL) { free(r); return NULL; }
    r->mask = capacity - 1u;
    r->head = 0u;
    r->tail = 0u;
    return r;
}

void ring_destroy(ring_t *r) {
    if (r != NULL) { free(r->buf); free(r); }
}

size_t ring_count(const ring_t *r) {
    return (size_t)(uint32_t)(r->head - r->tail);  /* 넘침이 있어도 차이는 정확 */
}

bool ring_push(ring_t *r, uint8_t byte) {
    if (ring_count(r) > r->mask) { return false; }  /* count == capacity */
    r->buf[r->head & r->mask] = byte;
    r->head++;
    return true;
}

bool ring_pop(ring_t *r, uint8_t *out) {
    if (r->head == r->tail) { return false; }
    *out = r->buf[r->tail & r->mask];
    r->tail++;
    return true;
}
head · tail 을 계속 증가시키는 이유 — 부호 없는 정수 뺄셈은 232 로 나눈 나머지라서 head 가 0xFFFFFFFF → 0 으로 넘쳐도 head - tail 은 정확한 개수입니다. 덕분에 "가득"과 "빔"을 구분하려고 칸 하나를 버릴 필요가 없습니다. (이 로직은 node 로 옮겨 0xFFFFFFFE 시작 넘침까지 확인했습니다.) ISR 과 메인이 나눠 쓸 때의 volatile · 순서 보장은 통신 · 드라이버 탭에서 다룹니다.

펌웨어에서는 — malloc 없이 불투명 타입 만들기 🟡 중급

불투명 포인터의 약점은 호출자가 크기를 모르니 모듈이 메모리를 대신 잡아야 한다는 것입니다. PC 라면 malloc 이면 끝이지만, 펌웨어는 대개 "초기화 이후 malloc 금지"(k10) 입니다. 실무에서 쓰는 세 가지 방법이 있습니다.

① 모듈 안 정적 풀 — 가장 흔한 방법

/* ring.h 에 한 줄 추가 */
ring_t *ring_create_static(uint8_t *storage, size_t capacity);

/* ring.c */
#ifndef RING_MAX_INSTANCES
#define RING_MAX_INSTANCES 4u        /* config.h 에서 덮어씀 (k07) */
#endif

static struct ring s_pool[RING_MAX_INSTANCES];
static size_t      s_pool_used;      /* 초기화 단계에서만 증가 */

ring_t *ring_create_static(uint8_t *storage, size_t capacity) {
    if ((storage == NULL) || !is_pow2(capacity) ||
        (s_pool_used >= RING_MAX_INSTANCES)) {
        return NULL;
    }
    ring_t *r = &s_pool[s_pool_used];
    s_pool_used++;
    r->buf  = storage;
    r->mask = capacity - 1u;
    r->head = 0u;
    r->tail = 0u;
    return r;
}

/* app.c — 버퍼는 호출자가 정적으로 */
static uint8_t s_rx_mem[256];
static ring_t *s_rx;

void app_init(void) {
    s_rx = ring_create_static(s_rx_mem, sizeof s_rx_mem);
    FW_ASSERT(s_rx != NULL);         /* 개수 부족은 개발 중에 터지게 (k09) */
}
초기화 때만 부르는 함수입니다 — 해제가 없고 s_pool_used++ 가 원자적이지 않아서 태스크 · ISR 에서 부르면 안 됩니다. RAM 사용량이 링크 시점에 확정되어 arm-none-eabi-size 로 보이는 것이 큰 장점입니다.

② 크기만 공개하는 저장소 블롭 · ③ 정직하게 공개

/* ② ring.h — 크기와 정렬만 알려 주고 내용은 비공개 */
typedef struct {
    uintptr_t priv_[4];        /* 32비트 16 B · 64비트 32 B */
} ring_storage_t;

ring_t *ring_init(ring_storage_t *mem, uint8_t *buf, size_t capacity);

/* ring.c — 크기가 모자라면 컴파일이 멈춤 */
_Static_assert(sizeof(struct ring) <= sizeof(ring_storage_t),
               "ring_storage_t 를 키우세요");
_Static_assert(_Alignof(struct ring) <= _Alignof(ring_storage_t),
               "ring_storage_t 정렬 부족");

/* app.c — 스택에도, 전역에도 둘 수 있음 */
static ring_storage_t s_rx_obj;
static uint8_t        s_rx_mem[256];
static ring_t        *s_rx;

void app_init(void) {
    s_rx = ring_init(&s_rx_obj, s_rx_mem, sizeof s_rx_mem);
}
방법장점단점 · 쓰는 곳
① 정적 풀완전한 은닉, 단순개수 상한을 미리 정해야 함
② 블롭개수 제한 없음, 호출자가 배치크기 상수 관리 · FreeRTOS StaticQueue_t 가 이 방식
③ 헤더에 공개가장 단순, 인라인 가능"건드리지 마" 주석에 의존 · HAL 핸들(UART_HandleTypeDef)
②의 주의점 — 엄밀히는 ring_storage_t 로 선언한 메모리를 struct ring 으로 읽는 것이 엄격한 별칭 규칙의 회색 지대입니다. FreeRTOS 도 같은 방식을 쓰고 configASSERT(sizeof) 로 크기를 확인합니다. 팀 규칙으로 정하고 static assert 두 줄을 빼먹지 않는 것이 핵심입니다.

네임스페이스 대신 — 접두사 규칙과 공개/내부 헤더 🟢 기초

이름 규칙 — 팀 문서에 그대로 넣는 표

대상규칙예
공개 함수모듈_동사[_목적어]uart_write · sensor_read_temp
공개 타입모듈[_이름]_tring_t · uart_cfg_t
매크로 · 상수대문자 + 접두사UART_BAUD_DEFAULT
enum 값대문자 + 타입 접두사SENSOR_STATE_IDLE
파일 범위 변수s_ + staticstatic uint32_t s_tick;
내부 함수static, 접두사 생략 가능static bool is_pow2(...)
전역 변수 (최후 수단)g_ + 모듈 접두사g_log_level
예약어 주의 — _ + 대문자, __ 로 시작하는 이름은 표준이 컴파일러 · 라이브러리용으로 예약했습니다(_Ring, __ring 금지). _t 로 끝나는 이름은 POSIX 가 예약했지만 펌웨어 업계에선 사실상 관례라 그대로 쓰는 경우가 많습니다 — 대신 모듈 접두사로 충돌을 막습니다.

공개 헤더 vs 내부 헤더 — 모듈이 여러 .c 일 때

modules/wifi/
├── include/wifi/
│   └── wifi.h               ← 공개: 다른 모듈이 include 가능
└── src/
    ├── wifi_internal.h      ← 내부: wifi 의 .c 끼리만 공유
    ├── wifi_core.c
    ├── wifi_at.c            ← AT 명령 파서
    └── wifi_scan.c
/* src/wifi_internal.h — 같은 모듈의 .c 들이 공유하는 상태 · 함수 */
#ifndef WIFI_INTERNAL_H
#define WIFI_INTERNAL_H
#include "wifi/wifi.h"

typedef struct {
    wifi_state_t state;
    uint8_t      retry;
    char         ssid[33];
} wifi_ctx_t;

wifi_ctx_t *wifi_ctx(void);                 /* wifi_core.c 가 정의 */
fw_err_t    wifi_at_send(const char *cmd);  /* wifi_at.c 가 정의 */
#endif
내부 헤더를 다른 모듈이 include 못 하게 막는 것은 규칙이 아니라 빌드 설정입니다 — CMake 에서 src/ 를 PRIVATE 인클루드 경로로만 주면 다른 모듈에서 #include "wifi_internal.h" 가 파일을 못 찾아 컴파일 에러가 납니다(k06).
정리 — 모듈은 공개 헤더(계약) + 비공개 소스(상태)입니다. 구조체까지 감추려면 불투명 포인터, 펌웨어에선 정적 풀이나 크기 블롭으로 malloc 없이 같은 효과를 냅니다.
ARCH 03

헤더 규칙 — 자기 완결 · 최소 포함 · 전방 선언으로 의존 끊기

C 에서 "의존"은 곧 #include 입니다. 헤더가 헤더를 끌고 오면 의존이 전이되어, 한 파일을 고칠 때 수백 파일이 흔들립니다. 규칙은 다섯 줄이면 됩니다 — 헤더는 혼자 컴파일되게, 필요한 것만 포함, 포인터만 쓰면 전방 선언, 헤더에 정의 금지, 순환 금지. 그리고 다섯 줄 모두 도구로 검사할 수 있습니다.

화살표 = #include — 왼쪽은 고리가 있고 오른쪽은 없습니다 순환 의존 app.c uart.h protocol.h 가드가 두 번째 진입을 막아서 포함 순서에 따라 "unknown type" 에러 순서를 바꾸면 고쳐진 듯 보임 — 함정 비순환 (DAG) app.c uart.h protocol.h protocol.c struct uart; 전방 선언만 고리를 끊는 곳은 언제나 .c 쪽 — 헤더는 포인터만 쓰면 전방 선언으로 충분합니다
include 그래프 — 헤더끼리는 고리가 없어야 하고, 무거운 포함은 .c 로 내립니다.

규칙 1 — 자기 완결 헤더 (self-contained)

❌ 누가 먼저 stdint.h 를 포함해 줘야 동작
/* sensor.h */
#ifndef SENSOR_H
#define SENSOR_H
int32_t sensor_read(void);     /* int32_t 는 어디서? */
#endif
✅ 필요한 것을 스스로 포함
/* sensor.h */
#ifndef SENSOR_H
#define SENSOR_H
#include <stdint.h>
int32_t sensor_read(void);
#endif
/* sensor.c — ★ 자기 헤더를 맨 첫 줄에.
   빠진 include 가 있으면 여기서 바로 에러가 납니다 */
#include "sensor.h"

#include <string.h>
#include "drv/i2c.h"
#include "log/log.h"
왜 "맨 처음"인가 — sensor.h 앞에 다른 헤더가 있으면 그게 <stdint.h> 를 몰래 가져와서 빠진 포함이 가려집니다. 그러다 누가 다른 파일에서 sensor.h 를 첫 줄에 쓰는 날 터집니다.

자동 검사 — 모든 공개 헤더를 "혼자" 컴파일

# cmake/header_check.cmake — 헤더마다 한 줄짜리 .c 를 만들어 컴파일
file(GLOB_RECURSE PUB_HEADERS CONFIGURE_DEPENDS
     ${CMAKE_SOURCE_DIR}/modules/*/include/*.h)

set(HDR_SRCS "")
foreach(h IN LISTS PUB_HEADERS)
    string(MAKE_C_IDENTIFIER "${h}" id)          # 같은 파일명도 안전
    set(src ${CMAKE_BINARY_DIR}/hdrcheck/${id}.c)
    file(WRITE ${src} "#include \"${h}\"\n")
    list(APPEND HDR_SRCS ${src})
endforeach()

add_library(header_check OBJECT ${HDR_SRCS})
target_link_libraries(header_check PRIVATE drv_uart drv_i2c svc_log app_logic)
target_compile_options(header_check PRIVATE -Wall -Wextra -Werror)
#ifndef 가드#pragma once
표준표준 C비표준 (GCC · Clang · MSVC · IAR · Keil 모두 지원)
실수 여지매크로 이름 복붙 충돌같은 파일이 두 경로(심볼릭 링크)로 보이면 혼동
MISRADir 4.10 을 충족하는 표준 방법도구에 따라 별도 승인 필요
권장 — 안전 인증(MISRA)을 받는 펌웨어는 #ifndef 가드, 그 밖에는 팀 취향입니다. 섞어 쓰지만 마세요. 가드 이름은 경로를 반영합니다: DRV_UART_UART_H — UART_H 는 벤더 SDK 와 겹치기 쉽습니다.

의존을 끊는 기술 — 전방 선언 · 포함 순서 · 헤더에 정의 금지 🟡 중급

전방 선언 — 포인터만 쓰면 정의가 필요 없습니다

❌ 헤더가 헤더를 끌고 옴
/* protocol.h */
#include "uart.h"     /* uart.h 가 HAL 을, HAL 이 CMSIS 를… */

fw_err_t proto_attach(uart_t *u);
✅ 이름만 알려 주고 포함은 .c 로
/* protocol.h */
struct uart;                  /* 전방 선언 */
fw_err_t proto_attach(struct uart *u);

/* protocol.c */
#include "protocol.h"
#include "uart.h"             /* 실제로 필드 · 함수를 쓰는 곳 */
typedef 는 두 번 선언하면 안 되나? — C11 부터 같은 typedef 의 재선언이 허용됩니다(C99 는 에러). 그래도 헤더 여러 곳에 typedef struct uart uart_t; 를 흩뿌리기보다 태그 이름 struct uart 로 전방 선언하는 편이 깔끔합니다. 값으로 쓰거나(struct uart u;) sizeof 가 필요하면 전방 선언으로는 안 됩니다.

포함 순서 규칙 + clang-format 으로 고정

#include "drv/uart.h"         /* 1. 자기 헤더 */

#include <stdbool.h>            /* 2. C 표준 */
#include <string.h>

#include "stm32f4xx_hal.h"      /* 3. 벤더 · 서드파티 */
#include "FreeRTOS.h"

#include "core/fw_err.h"        /* 4. 프로젝트 공용 */
#include "drv/dma.h"            /* 5. 같은 레이어 형제 */
# .clang-format 일부 — 저장할 때 자동 정렬
IncludeBlocks: Regroup
IncludeCategories:
  - Regex: '^<(std|string|limits|stdio|stdlib|assert)'
    Priority: 2
  - Regex: '^"(stm32|FreeRTOS|cmsis|core_cm)'
    Priority: 3
  - Regex: '^"core/'
    Priority: 4
  - Regex: '.*'
    Priority: 5
IncludeIsMainRegex: '$'
clang-format 은 uart.c 의 "drv/uart.h" 처럼 파일 이름이 같은 헤더를 "메인 헤더"로 보고 우선순위 0 으로 맨 위에 올립니다. 규칙을 문서로만 두면 지켜지지 않습니다 — 포매터 설정 파일이 곧 규칙입니다.

헤더에 둘 수 있는 것 · 없는 것

둘 수 있음두면 안 됨 (→ .c 로)
타입 · enum · 매크로 · 함수 선언함수 정의 (static inline 제외)
extern 변수 선언변수 정의 int g_x; · static 변수
static inline 짧은 함수큰 테이블 const uint8_t lut[256] = {…}
_Static_assert쓰지 않는 #include
/* 헤더의 static inline — 레지스터 한 줄짜리 접근자에 적합 */
static inline bool led_is_on(void) {
    return (GPIOA->ODR & GPIO_ODR_OD5) != 0u;
}
/* static 변수를 헤더에 두면 포함한 .c 마다 "따로" 생깁니다.
   static uint32_t s_count;  ← 파일 10개면 변수 10개, 서로 모름 */

include-what-you-use · 포함 트리 보기

# ① 필요한데 안 한 include · 필요 없는데 한 include 를 알려 줌
cmake -B build -DCMAKE_C_INCLUDE_WHAT_YOU_USE="include-what-you-use"
cmake --build build 2>&1 | tee iwyu.log
# protocol.h should remove these lines:
# - #include "uart.h"  // lines 3-3
# protocol.h should add these lines:
# struct uart;

# ② 한 파일이 실제로 끌어오는 헤더 트리 (점 개수 = 깊이)
arm-none-eabi-gcc -H -fsyntax-only $(CFLAGS) src/app.c 2>&1 | head -40
# . include/app/app.h
# .. modules/drv/include/drv/uart.h
# ... Drivers/STM32F4xx_HAL_Driver/Inc/stm32f4xx_hal.h   ← 여기서 수백 개

# ③ 의존 파일 목록 (빌드 시스템이 쓰는 것과 같음)
gcc -MM -Iinclude src/protocol.c
IWYU 를 그대로 믿지는 마세요 — 벤더 헤더(HAL)처럼 "우산 헤더를 통해서만 포함하라"는 라이브러리에 대해선 엉뚱한 내부 헤더를 추천합니다. 매핑 파일(.imp)로 예외를 주거나 경고만 보고 사람이 고칩니다. -H 출력에서 HAL 이 app.c 까지 올라오면 레이어가 새고 있다는 신호입니다(k04).
정리 — 헤더는 혼자 컴파일되고, 필요한 것만 포함하고, 포인터는 전방 선언으로 받고, 정의는 두지 않습니다. 고리는 언제나 .c 쪽으로 포함을 내려서 끊고, 규칙은 header_check · clang-format · IWYU 로 강제합니다.
ARCH 04

레이어 구조 — 의존은 위에서 아래로만, 알림은 콜백으로 올린다

펌웨어의 레이어는 "하드웨어에서 얼마나 먼가"로 나눕니다. 앱은 무엇을 할지 알고, 아래로 갈수록 어떻게를 압니다. 규칙은 하나 — 위층은 바로 아래층만 부르고, 아래층은 위층을 모른다. 아래에서 위로 알려야 할 일(데이터 도착 · 에러)은 위층이 미리 등록해 둔 콜백으로만 올립니다. STM32 의 HAL · LL · CMSIS 자체의 차이는 STM32 탭 m02 에 있습니다.

여섯 층 — 오른쪽은 그 층만 아는 것 앱 (App) 제품 규칙 · "몇 도면 경보" — 핀 번호는 모름 서비스 · 미들웨어 로그 · 설정 저장 · 프로토콜 · FatFs · lwIP 디바이스 드라이버 BME280 · W25Q 플래시 — 외부 칩 하나씩 BSP (보드 지원) "이 보드에선 센서가 I2C1, LED 는 PA5" HAL · 주변장치 드라이버 UART · I2C · DMA — MCU 내부 장치 레지스터 · CMSIS USART2->DR · NVIC_EnableIRQ 호출 콜백 붉은 점선 = 앱이 HAL 을 바로 부르는 "건너뛰기" — 보드가 바뀌면 앱을 고쳐야 합니다
레이어 스택 — 호출은 아래로, 알림은 콜백으로 위로. 이 두 방향만 있으면 어느 층이든 갈아 끼울 수 있습니다.

레이어별 "포함해도 되는 헤더" 표

레이어include 가능금지
앱서비스 · 디바이스 드라이버 공개 헤더stm32f4xx_hal.h · bsp/*
서비스디바이스 드라이버 · 다른 서비스 · OS 추상화앱 헤더 · HAL
디바이스 드라이버버스 인터페이스(i2c_bus.h)HAL 직접 · 앱 · 서비스
BSPHAL · 주변장치 드라이버앱 · 서비스
HALCMSIS 디바이스 헤더위쪽 전부
디바이스 드라이버가 HAL 을 직접 부르지 않는 이유 — BME280 드라이버는 "I2C 로 3바이트 읽기"만 필요합니다. HAL_I2C_Mem_Read 를 직접 부르면 그 드라이버는 STM32 에서만 돕니다. i2c_bus_t 인터페이스(k08)만 받으면 같은 드라이버가 ESP32 · nRF · PC 테스트에서도 돕니다.

건너뛰기가 왜 나쁜가 — 한 줄의 대가

❌ 앱이 핀을 직접
/* app/alarm.c */
#include "stm32f4xx_hal.h"
void alarm_raise(void) {
    HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);
}
✅ 앱은 "무엇"만
/* app/alarm.c */
#include "bsp/bsp_led.h"
void alarm_raise(void) {
    bsp_led_set(BSP_LED_ALARM, true);
}
상황❌ 건너뛴 코드✅ 레이어 지킨 코드
Rev B 보드 — LED 가 PB0 로앱 수정BSP 한 줄
MCU 를 ESP32 로앱 전체 수정BSP · HAL 교체
PC 에서 단위 테스트HAL 헤더 때문에 불가가짜 bsp_led.c 링크

아래 → 위 알림 — 콜백 등록으로 의존을 뒤집기 🟡 중급

위로 올라가는 화살표는 전부 "등록된 함수"를 통해서입니다 HAL (ISR) uart 드라이버 proto 서비스 앱 (메인 루프) 0. init 때 콜백 등록 1. HAL_UART_RxCpltCallback 2. cb(ctx, byte) — 등록된 함수 3. evq_post(EV_FRAME_READY) 4. proto_take_frame() 점선(1~3)은 인터럽트 문맥에서 짧게 · 실선(0 · 4)은 위층이 아래층을 부르는 보통 호출
콜백 시퀀스 — 드라이버는 서비스 이름을 모르고, 받은 함수 포인터를 부를 뿐입니다. 이것이 의존성 역전의 가장 작은 형태입니다.

드라이버 — 콜백을 받는 쪽

/* drv/include/drv/uart.h */
typedef enum { UART_ID_DEBUG = 0, UART_ID_MODEM, UART_ID_COUNT } uart_id_t;
typedef void (*uart_rx_cb_t)(void *ctx, uint8_t byte);   /* ISR 문맥! */

fw_err_t uart_open(uart_id_t id, uint32_t baud);
void     uart_set_rx_cb(uart_id_t id, uart_rx_cb_t cb, void *ctx);
fw_err_t uart_write(uart_id_t id, const uint8_t *p, size_t n, uint32_t timeout_ms);

/* drv/src/uart_stm32.c */
#include "drv/uart.h"
#include "bsp/bsp_uart.h"      /* id → UART_HandleTypeDef* 는 BSP 가 앎 */

typedef struct { uart_rx_cb_t cb; void *ctx; uint8_t byte; } uart_slot_t;
static uart_slot_t s_slot[UART_ID_COUNT];

void uart_set_rx_cb(uart_id_t id, uart_rx_cb_t cb, void *ctx) {
    s_slot[id].cb  = cb;      /* uart_open() 전에 등록 — init 단계 */
    s_slot[id].ctx = ctx;
}
/* 첫 HAL_UART_Receive_IT() 는 uart_open() 이 UART 초기화 직후에 겁니다.
   초기화 전에 부르면 HAL 이 READY 상태가 아니라서 HAL_BUSY 로 거절합니다 */

/* HAL 의 weak 함수를 드라이버가 소유 — main.c 에 두지 않습니다 */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
    uart_id_t id = bsp_uart_id(huart);
    if (id < UART_ID_COUNT) {
        uart_slot_t *s = &s_slot[id];
        if (s->cb != NULL) { s->cb(s->ctx, s->byte); }
        (void)HAL_UART_Receive_IT(huart, &s->byte, 1u);   /* 다음 바이트 */
    }
}
콜백은 인터럽트 문맥에서 돕니다 — 콜백 안에서 블로킹 · printf · 긴 파싱 금지. 링 버퍼에 넣고 이벤트를 하나 올리는 것까지만. 1바이트 인터럽트 수신은 이해용 예이고, 실전 고속 수신은 DMA + IDLE 라인(STM32 탭 m07) 입니다.

서비스 — 콜백을 등록하는 쪽, 그리고 ❌ 역방향 호출

/* svc/src/proto.c */
#include "svc/proto.h"
#include "drv/uart.h"
#include "core/evq.h"

static proto_parser_t s_parser;

static void proto_on_byte(void *ctx, uint8_t b) {   /* ISR 문맥 */
    proto_parser_t *p = ctx;
    if (proto_feed(p, b) == PROTO_FRAME_DONE) {
        (void)evq_post_from_isr(EV_FRAME_READY, 0u);
    }
}

fw_err_t proto_init(void) {
    proto_parser_reset(&s_parser);
    uart_set_rx_cb(UART_ID_MODEM, proto_on_byte, &s_parser);
    return uart_open(UART_ID_MODEM, 115200u);
}
❌ 드라이버가 위층 이름을 부름
/* drv/src/bme280.c */
#include "app/alarm.h"          /* 아래가 위를 앎 */
if (t > 80000) { alarm_raise(); }
✅ 판단은 위층이
/* app/monitor.c */
int32_t t;
if ((bme280_read_mc(&dev, &t) == FW_OK)
    && (t > 80000)) { alarm_raise(); }
❌ 쪽의 진짜 비용 — 드라이버 라이브러리가 앱 라이브러리에 링크 의존을 갖게 되어 순환이 생기고, "80도"라는 제품 규칙이 칩 드라이버 안에 숨습니다. 다른 제품에서 BME280 을 재사용하려는 순간 문제가 드러납니다.
정리 — 레이어는 앱 · 서비스 · 디바이스 드라이버 · BSP · HAL · 레지스터. 호출은 아래로만, 알림은 위층이 등록한 콜백으로만 올립니다. 그러면 보드 · MCU · 테스트 환경마다 아래층만 바꾸면 됩니다.
ARCH 05

디렉터리 구조 템플릿 3종 — PC 라이브러리 · 단일 보드 · 여러 보드와 제품군

디렉터리는 레이어(k04)와 소유권을 눈에 보이게 만든 것입니다. 폴더 이름만 보고 "이 코드는 어느 보드에 묶여 있나, 누가 고치나"가 답해져야 합니다. 기본 배치(include/ · src/ · tests/)는 C 실전 탭 c25, CubeMX 가 만드는 폴더는 STM32 탭 m03 에 있습니다. 여기서는 규모별 세 가지 템플릿과, 보드가 3개 · 제품이 2개가 되었을 때도 복사 없이 버티는 구조를 봅니다.

(c) 여러 보드 · 여러 제품 — 왼쪽은 폴더, 오른쪽은 그 폴더의 책임 fw-platform/ core/ 에러 · 로그 · 이벤트 큐 · 링 버퍼 — 보드를 전혀 모름 drivers/ 외부 칩(BME280 · W25Q) — 버스 인터페이스에만 의존 services/ 프로토콜 · 설정 저장 · OTA · 셸 hal/ MCU 계열별 구현: stm32f4/ · stm32g0/ · host/ boards/ 보드별 BSP — 핀 맵 · 클럭 · 링커 스크립트 · CubeMX 출력 nucleo_f446re/ 개발 보드 (F446RE · 512 KB) acme_rev_b/ 양산 보드 B (F446RC · 256 KB) acme_rev_c/ 양산 보드 C (G0B1 — hal/stm32g0 사용) products/ 제품 = 앱 코드 + 쓸 보드 목록 + 기능 설정 thermo_basic/ 보드 rev_b · 기능 최소 thermo_pro/ 보드 rev_c · Wi-Fi · 로깅 켬 third_party/ 벤더 원본 그대로 — CubeF4 HAL · FreeRTOS · cJSON tests/ cmake/ tools/ 호스트 테스트 · 툴체인 파일 · 빌드 스크립트 products 가 boards 와 core 를 고르고, core 는 그 누구도 모릅니다
디렉터리 트리 — 보드(boards/)와 제품(products/)을 분리하면 "보드 추가"도 "제품 추가"도 폴더 하나 추가로 끝납니다.

(a) PC 라이브러리 + CLI — 서버 · 도구용

libtelemetry/
├── CMakeLists.txt
├── CMakePresets.json
├── include/telemetry/        ← 설치되는 공개 헤더만
│   ├── telemetry.h           ← 우산 헤더 (사용자는 이것 하나)
│   ├── decoder.h
│   └── version.h             ← 빌드 때 생성 (k13)
├── src/
│   ├── decoder/              ← 하위 모듈은 폴더로
│   │   ├── decoder.c
│   │   ├── frame.c
│   │   └── decoder_internal.h
│   ├── io/
│   └── util/
├── cli/                      ← 라이브러리를 쓰는 실행 파일
│   └── main.c
├── tests/
│   ├── unit/                 ← 모듈별
│   └── fuzz/                 ← 파서는 퍼징 (디버깅·테스트 탭)
├── bench/
├── docs/
└── third_party/
핵심은 cli/ 가 라이브러리의 첫 번째 사용자라는 것 — CLI 도 include/ 의 공개 헤더만 써야 합니다. CLI 가 src/ 의 내부 헤더를 쓰기 시작하면 공개 API 가 부족하다는 신호입니다.

(b) 단일 보드 펌웨어 — 보드 1개, 제품 1개

thermo-fw/
├── CMakeLists.txt
├── CMakePresets.json
├── cmake/arm-gcc.cmake       ← 툴체인 파일 (k06)
├── app/                      ← 제품 로직 · 상태 머신 (k11)
│   ├── app_main.c
│   └── monitor.c
├── middleware/               ← 서비스: 로그 · 설정 · 프로토콜
├── drivers/                  ← 외부 칩: bme280/ · w25q/
├── bsp/                      ← 이 보드의 핀 맵 · board_init()
├── hal/                      ← 주변장치 래퍼 (uart · i2c)
├── cube/                     ← ★ CubeMX 출력 격리
│   ├── thermo.ioc
│   ├── Core/  Drivers/
│   └── cmake/stm32cubemx/
├── third_party/              ← FreeRTOS · cJSON
├── tests/                    ← 호스트에서 도는 단위 테스트
└── tools/                    ← 플래시 · 버전 스크립트
(b) 에서 (c) 로 가는 시점 — 두 번째 보드가 생기는 날입니다. bsp/ · cube/ 를 boards/<이름>/ 아래로 옮기고, app/ 을 products/<이름>/ 로 옮기면 나머지는 그대로입니다. 처음부터 보드에 묶인 코드를 bsp/ · cube/ 에만 두었다면 반나절 작업입니다.

(c) 를 더 자세히 — 보드 폴더 · 제품 폴더 안에는 무엇이 🟡 중급

boards/<보드>/ — 이 보드에만 있는 사실

boards/acme_rev_b/
├── board.cmake            ← MCU 계열 · 컴파일 플래그 · hal 선택
├── board.h                ← 핀 · 버스 배정 (아래)
├── bsp.c                  ← board_init() · bsp_led_set() ...
├── STM32F446RCTX_FLASH.ld ← 링커 스크립트 (메모리 크기가 보드마다 다름)
└── cube/                  ← 이 보드의 .ioc 와 생성 코드
/* boards/acme_rev_b/board.h — "어느 핀에 무엇"은 여기에만 */
#ifndef BOARD_ACME_REV_B_H
#define BOARD_ACME_REV_B_H
#define BOARD_NAME             "acme_rev_b"
#define BOARD_HAS_EXT_FLASH    1
#define BOARD_SENSOR_I2C       I2C_BUS_1
#define BOARD_SENSOR_ADDR      0x76u     /* BME280 SDO=GND */
#define BOARD_LED_ALARM_PORT   GPIOB
#define BOARD_LED_ALARM_PIN    GPIO_PIN_0
#define BOARD_MODEM_UART       UART_ID_MODEM
#endif
board.h 를 BSP 와 board 전용 코드 밖에서 include 하면 안 됩니다. 앱이 BOARD_LED_ALARM_PIN 을 쓰는 순간 k04 의 건너뛰기가 생깁니다. 이 규칙도 CMake 의 PRIVATE 인클루드로 강제합니다(k06).

products/<제품>/ — 무엇을 조합할지

products/thermo_pro/
├── CMakeLists.txt         ← 보드 허용 목록 · 링크할 모듈
├── product_config.h       ← 기능 스위치 (k07)
├── app/                   ← 이 제품만의 로직
│   ├── app_main.c
│   └── ui_states.c
└── release.json           ← 제품명 · 하드웨어 리비전 매핑 (k13)
# products/thermo_pro/CMakeLists.txt
set(PRODUCT_BOARDS acme_rev_c nucleo_f446re)   # 허용 보드
if(NOT FW_BOARD IN_LIST PRODUCT_BOARDS)
    message(FATAL_ERROR "thermo_pro 는 ${FW_BOARD} 를 지원하지 않습니다")
endif()

add_executable(thermo_pro app/app_main.c app/ui_states.c)
target_include_directories(thermo_pro PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(thermo_pro PRIVATE
    board               # boards/${FW_BOARD} 가 만든 타깃
    svc_proto svc_log svc_wifi
    drv_bme280
    core)
서버 개발에 빗대면 — core/ drivers/ services/ 는 공용 라이브러리, boards/ 는 배포 환경(dev/stage/prod) 설정, products/ 는 그 라이브러리를 조합한 서비스 하나하나입니다. 모노레포의 libs/ + apps/ 구조와 같습니다.

실무에선 이렇게 — CubeMX 생성 코드 격리 · 소유권 🔴 실무

CubeMX 출력을 "서드파티"처럼 다루기

규칙방법
보드마다 따로boards/<보드>/cube/ — .ioc 도 보드 폴더에
주변장치 초기화를 파일로 분리Project Manager → Code Generator → "Generate peripheral initialization as a pair of .c/.h files" — MX_USART2_UART_Init 이 usart.c 로 나와 BSP 가 부를 수 있음
main.c 는 한 줄만USER CODE BEGIN 2 에 app_main(); (m03)
경고 끄기cube 타깃에만 -w 또는 SYSTEM 인클루드 — 우리 코드의 -Werror 를 지키려고
재생성 검사CI 에서 재생성 후 git diff --exit-code — 손으로 고친 흔적을 잡음

CODEOWNERS — 폴더가 곧 책임 경계

# .github/CODEOWNERS (GitLab 도 같은 문법)
/core/                 @fw-platform-team
/hal/                  @fw-platform-team
/drivers/              @fw-drivers
/boards/acme_rev_b/    @hw-rev-b-owner @fw-platform-team
/boards/acme_rev_c/    @hw-rev-c-owner @fw-platform-team
/products/thermo_pro/  @thermo-pro-team
/third_party/          @fw-platform-team    # 업데이트는 플랫폼 팀만
/cmake/                @fw-build            # 한 줄에 패턴 하나
/CMakePresets.json     @fw-build
제품 팀이 core/ 를 고치려면 플랫폼 팀 리뷰가 필요하게 됩니다 — 귀찮아 보이지만 이게 "모든 제품을 동시에 깨뜨리는 커밋"을 막는 유일한 장치입니다. third_party 는 수정 금지가 원칙이고, 꼭 패치해야 하면 third_party/patches/*.patch 로 남겨 업그레이드 때 다시 적용합니다.
정리 — PC 는 include/ · src/ · cli/ · tests/, 단일 보드는 app · middleware · drivers · bsp · hal · cube, 여러 보드 · 제품은 core · boards · products 를 분리합니다. 보드에 묶인 사실은 boards/ 에만, 조합은 products/ 에만 둡니다.
ARCH 06

빌드 구조 — CMake 타깃 = 모듈, 의존 규칙을 컴파일 에러로

k02~k05 의 규칙(내부 헤더 금지 · 레이어 방향 · board.h 격리)은 문서로 두면 3개월 안에 깨집니다. CMake 의 타깃마다 PUBLIC / PRIVATE 를 정확히 적으면 규칙을 어긴 #include 가 파일을 못 찾아 컴파일 에러가 됩니다. 여기에 툴체인 파일 · 프리셋 · 보드 선택 변수 · 링커 스크립트를 더하면, 같은 소스로 PC 테스트 · 개발 보드 · 양산 보드를 명령 한 줄씩으로 빌드합니다. CMake 기본 문법(add_library · target_link_libraries)은 C 실전 탭 c25 에 있습니다.

타깃 그래프 — 실선은 PUBLIC(전파), 점선은 PRIVATE(감춤) thermo_pro 실행 파일 svc_proto 서비스 drv_bme280 드라이버 board BSP core 에러 · 큐 bus_if INTERFACE hal_stm32 감춤 cube 생성 코드 PUBLIC 은 "내 헤더가 그 타입을 드러낸다", PRIVATE 는 "내 .c 만 쓴다" thermo_pro 의 인클루드 경로에는 HAL · cube 가 아예 없습니다 — 쓰려 해도 못 씁니다
타깃 의존 그래프 — PRIVATE 가 많을수록 바깥으로 새는 헤더가 줄고, 재빌드 범위도 줄어듭니다.

모듈 하나 = 타깃 하나

# drivers/bme280/CMakeLists.txt
add_library(drv_bme280 STATIC src/bme280.c src/bme280_comp.c)

target_include_directories(drv_bme280
    PUBLIC  include          # include/drv/bme280.h — 쓰는 쪽에 보임
    PRIVATE src)             # src/bme280_internal.h — 우리만

target_link_libraries(drv_bme280
    PUBLIC  bus_if core      # bme280.h 가 i2c_bus_t* · fw_err_t 를 노출
    PRIVATE fw_warnings)     # 경고 설정은 전파하지 않음

# core/CMakeLists.txt — 인터페이스(헤더만) 타깃
add_library(bus_if INTERFACE)
target_include_directories(bus_if INTERFACE include)   # i2c_bus.h · uart.h

# cmake/warnings.cmake — 우리 코드에만 엄격하게
add_library(fw_warnings INTERFACE)
target_compile_options(fw_warnings INTERFACE
    -Wall -Wextra -Wshadow -Wconversion -Wundef
    -Wstrict-prototypes -Wmissing-prototypes -Werror)
판단 기준 한 줄 — "내 공개 헤더에 그 모듈의 타입이 나오는가?" 나오면 PUBLIC, .c 에서만 쓰면 PRIVATE. -Wmissing-prototypes 는 static 을 빼먹은 내부 함수를 잡아 줍니다 — 헤더에 선언이 없는데 외부 연결이면 경고.

최상위 — 보드와 제품을 변수로 고르기

# CMakeLists.txt (최상위)
cmake_minimum_required(VERSION 3.25)
project(fw_platform VERSION 2.4.1 LANGUAGES C ASM)   # 버전 → k13 fw_info
set(CMAKE_C_STANDARD 17)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_C_EXTENSIONS ON)          # GCC 확장(__attribute__) 허용 → gnu17

set(FW_BOARD   "nucleo_f446re" CACHE STRING "대상 보드")
set(FW_PRODUCT "thermo_pro"    CACHE STRING "대상 제품")
option(FW_BUILD_TESTS "호스트 단위 테스트 빌드" OFF)
set_property(CACHE FW_BOARD PROPERTY STRINGS
             nucleo_f446re acme_rev_b acme_rev_c host_sim)

include(cmake/warnings.cmake)
include(boards/${FW_BOARD}/board.cmake)  # 보드 변수(BOARD_HAS_EXT_FLASH 등)를 전체에 공개
add_subdirectory(core)
add_subdirectory(hal)                    # 안에서 FW_BOARD 의 MCU 계열을 봄
add_subdirectory(boards/${FW_BOARD})     # → 타깃 이름은 언제나 "board"
add_subdirectory(drivers)
add_subdirectory(services)

if(FW_BUILD_TESTS)
    enable_testing()
    add_subdirectory(tests)              # 호스트에서만
else()
    add_subdirectory(products/${FW_PRODUCT})
endif()
보드 폴더마다 같은 이름의 타깃(board)을 만들게 약속하면 상위 코드는 보드가 무엇인지 몰라도 됩니다 — 다형성을 빌드 시스템에서 하는 셈입니다. set_property(... STRINGS) 는 cmake-gui · VS Code 에서 드롭다운으로 보이게 합니다.

크로스 컴파일 — 툴체인 파일 · 프리셋 · 링커 스크립트 🟡 중급

cmake --preset → cmake --build --preset 두 줄이 하는 일 preset 무엇을 빌드 configure 툴체인 · 보드 compile 타깃별 .o link .ld · gc-sections post .bin · size host-test 프리셋은 여기서 gcc · host_sim 보드로 갈라짐 링커 스크립트가 바뀌면 다시 링크되도록 LINK_DEPENDS 를 꼭 겁니다
빌드 파이프라인 — 프리셋이 입력, .elf/.bin 과 메모리 사용량이 출력입니다.

cmake/arm-cm4f.cmake — 툴체인 파일

set(CMAKE_SYSTEM_NAME      Generic)        # OS 없음 (베어메탈/RTOS)
set(CMAKE_SYSTEM_PROCESSOR arm)

set(CMAKE_C_COMPILER   arm-none-eabi-gcc)
set(CMAKE_ASM_COMPILER arm-none-eabi-gcc)
set(CMAKE_OBJCOPY      arm-none-eabi-objcopy)
set(FW_SIZE_TOOL       arm-none-eabi-size)

# 컴파일러 확인 때 링크까지 하지 않게 (startup · .ld 가 아직 없음)
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)

# F446 = Cortex-M4 + 단정밀도 FPU
set(MCU_FLAGS "-mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard")
set(CMAKE_C_FLAGS_INIT   "${MCU_FLAGS} -ffunction-sections -fdata-sections")
set(CMAKE_ASM_FLAGS_INIT "${MCU_FLAGS} -x assembler-with-cpp")
set(CMAKE_EXE_LINKER_FLAGS_INIT
    "${MCU_FLAGS} --specs=nano.specs --specs=nosys.specs -Wl,--gc-sections")

set(CMAKE_EXECUTABLE_SUFFIX_C   .elf)
set(CMAKE_EXECUTABLE_SUFFIX_ASM .elf)

# 크로스 환경에서 호스트 라이브러리를 집어 오지 않게
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
MCU 플래그는 툴체인 파일에 — -mfloat-abi=hard 를 일부 타깃에만 주면 링크 단계에서 "uses VFP register arguments, … does not" 에러가 납니다. 모든 오브젝트가 같은 ABI 여야 하므로 타깃별 옵션이 아니라 전역 초기값으로 둡니다. Cortex-M0+ 보드(G0B1)는 arm-cm0p.cmake 를 따로 둡니다.

CMakePresets.json — 명령 이름을 팀이 공유

{
  "version": 6,
  "configurePresets": [
    { "name": "base", "hidden": true, "generator": "Ninja",
      "binaryDir": "${sourceDir}/build/${presetName}",
      "cacheVariables": { "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" } },
    { "name": "host-test", "inherits": "base",
      "cacheVariables": { "FW_BOARD": "host_sim", "FW_BUILD_TESTS": "ON",
                          "CMAKE_BUILD_TYPE": "Debug" } },
    { "name": "target-debug", "inherits": "base",
      "toolchainFile": "${sourceDir}/cmake/arm-cm4f.cmake",
      "cacheVariables": { "FW_BOARD": "nucleo_f446re",
                          "FW_PRODUCT": "thermo_pro",
                          "CMAKE_BUILD_TYPE": "Debug" } },
    { "name": "target-release", "inherits": "target-debug",
      "cacheVariables": { "CMAKE_BUILD_TYPE": "MinSizeRel" } }
  ],
  "buildPresets": [
    { "name": "host-test",      "configurePreset": "host-test" },
    { "name": "target-debug",   "configurePreset": "target-debug" },
    { "name": "target-release", "configurePreset": "target-release" }
  ],
  "testPresets": [
    { "name": "host-test", "configurePreset": "host-test",
      "output": { "outputOnFailure": true } }
  ]
}
cmake --preset host-test && cmake --build --preset host-test \
  && ctest --preset host-test
cmake --preset target-release && cmake --build --preset target-release
cmake --preset target-debug -DFW_BOARD=acme_rev_c      # 보드만 바꿔서 (thermo_pro 허용 목록 안)

실무에선 이렇게 — 링커 스크립트와 산출물 관리 🔴 실무

boards/nucleo_f446re/CMakeLists.txt

add_library(board STATIC bsp.c startup_stm32f446xx.s)
target_include_directories(board
    PUBLIC  include                 # bsp/bsp_led.h — 앱이 쓰는 BSP API
    PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})   # board.h — 숨김
target_link_libraries(board
    PUBLIC  bus_if core
    PRIVATE hal_stm32 cube fw_warnings)

set(FW_LDSCRIPT ${CMAKE_CURRENT_SOURCE_DIR}/STM32F446RETX_FLASH.ld
    CACHE INTERNAL "")
target_link_options(board INTERFACE
    -T${FW_LDSCRIPT} -Wl,--print-memory-usage
    -Wl,--undefined=g_pfnVectors)   # ★ 정적 라이브러리 속 startup 이
                                    #   링크에서 빠지지 않게 강제로 끌어옴
# products/thermo_pro/CMakeLists.txt 끝부분
set_property(TARGET thermo_pro APPEND PROPERTY LINK_DEPENDS ${FW_LDSCRIPT})
target_link_options(thermo_pro PRIVATE
    -Wl,-Map=$<TARGET_FILE_DIR:thermo_pro>/thermo_pro.map)
add_custom_command(TARGET thermo_pro POST_BUILD
    COMMAND ${CMAKE_OBJCOPY} -O binary $<TARGET_FILE:thermo_pro>
            $<TARGET_FILE_DIR:thermo_pro>/thermo_pro.bin
    COMMAND ${FW_SIZE_TOOL} $<TARGET_FILE:thermo_pro>
    VERBATIM)

링크 결과 읽기 — 매 빌드마다 보이게

Memory region         Used Size  Region Size  %age Used
             RAM:       83460 B       128 KB     63.67%
           FLASH:      214312 B       512 KB     40.88%

   text    data     bss     dec     hex filename
 213840     472   82988  297300   48954 thermo_pro.elf
# FLASH 사용 = text + data (초기값은 Flash 에 있다가 RAM 으로 복사)
# RAM   사용 = data + bss  (+ 힙 · 스택 영역은 .ld 가 따로 잡음)
관리 규칙이유
.ld 는 보드 폴더에, 생성본을 복사해 소유F446RE(512K) · F446RC(256K) 크기가 다름
CI 에서 RAM · FLASH % 를 기록"이번 PR 이 RAM 4KB 늘림"을 리뷰에서 봄
.map 파일을 아티팩트로 보관현장 크래시 주소 → 함수 역추적
여유 10~20% 를 예산으로양산 후 패치가 들어갈 자리
위 숫자를 맞춰 보면 FLASH 는 213840 + 472 = 214312 B, RAM 은 472 + 82988 = 83460 B 로 두 출력이 일치합니다. --print-memory-usage 는 링커 스크립트의 MEMORY 영역 기준이라 .ld 에 잡은 힙 · 스택 예약분도 포함될 수 있어 size 와 다를 수 있습니다.
정리 — 모듈마다 타깃을 만들고 PUBLIC/PRIVATE 로 의존 규칙을 컴파일 에러로 바꿉니다. MCU 플래그는 툴체인 파일, 보드 · 제품은 캐시 변수, 명령은 프리셋, 링커 스크립트는 보드 폴더가 소유합니다.
ARCH 07

설정 관리 — #ifdef 지옥 대신 설정 헤더 · 링크 타임 선택 · Kconfig

보드 3종 × 제품 2종 × 디버그/릴리스 — 차이를 모두 #ifdef 로 처리하면 코드 한 줄 한 줄이 "어느 조합에서 컴파일되는지" 추적해야 하는 문서가 됩니다. 차이에는 종류가 있고, 종류마다 처리할 자리가 다릅니다: 실행 중에 바뀌는 값, 구현이 통째로 다른 것, 기능을 켜고 끄는 것, 그냥 상수. 자리를 정해 두면 #if 는 설정 헤더 한 곳과 몇 개의 경계에만 남습니다.

이 차이는 어디서 처리하나 — 마름모가 질문입니다 실행 중에 값이 바뀌나? 예 런타임 설정 캘리브레이션 · Flash 저장 아니오 구현이 통째로 다른가? 예 링크 타임 선택 같은 .h · 다른 .c 아니오 기능을 켜고 끄는 것인가? 예 #if FEATURE_X (0/1) fw_config.h 한 곳에서만 정의 조합이 수십 개면 Kconfig 로 아니오 → static const · 일반 if 아래로 갈수록 #if 가 늘어납니다 — 위쪽 답을 먼저 찾으세요
결정 흐름도 — "구현이 다르다"면 #if 가 아니라 다른 .c 파일입니다. #if 는 기능 스위치에만 씁니다.

fw_config.h — 모든 설정의 유일한 입구

/* core/include/fw_config.h */
#ifndef FW_CONFIG_H
#define FW_CONFIG_H

#define LOG_LEVEL_ERROR  1
#define LOG_LEVEL_WARN   2
#define LOG_LEVEL_INFO   3
#define LOG_LEVEL_DEBUG  4

#include "product_config.h"  /* products/<제품>/ — 제품이 고른 것 */
#include "board_caps.h"      /* boards/<보드>/  — 보드가 가진 것 */

/* 기본값: 제품이 정하지 않았을 때만 */
#ifndef FEATURE_WIFI
#define FEATURE_WIFI        0
#endif
#ifndef FEATURE_LOG_FLASH
#define FEATURE_LOG_FLASH   0
#endif
#ifndef LOG_LEVEL
#define LOG_LEVEL           LOG_LEVEL_INFO
#endif
#ifndef RING_MAX_INSTANCES
#define RING_MAX_INSTANCES  4u
#endif

/* 조합 검사 — 말이 안 되는 조합은 빌드가 멈춤 */
#if FEATURE_WIFI && !BOARD_HAS_MODEM_UART
#error "FEATURE_WIFI 는 모뎀 UART 가 있는 보드에서만 켤 수 있습니다"
#endif
#if FEATURE_LOG_FLASH && !BOARD_HAS_EXT_FLASH
#error "FEATURE_LOG_FLASH 에는 외부 Flash 가 필요합니다"
#endif

#endif /* FW_CONFIG_H */
board_caps.h 와 board.h 를 나눈 이유 — board_caps.h 는 "외부 Flash 가 있다"처럼 능력만 공개하고, "PB0 에 LED"같은 배선은 BSP 안의 board.h 에 숨깁니다(k05). 앱은 능력은 알아도 되지만 배선은 몰라야 합니다.

규칙 — #ifdef 가 아니라 #if, 가능하면 그냥 if

❌ 오타가 조용히 "꺼짐"이 됨
#ifdef FEATURE_WIFII     /* 오타 — 경고 없음 */
    wifi_start();
#endif
✅ 0/1 로 정의 + -Wundef
#if FEATURE_WIFII        /* warning: "FEATURE_WIFII"
                            is not defined [-Wundef] */
    wifi_start();
#endif
/* 더 좋은 방법 — 일반 if. 두 갈래 모두 컴파일 검사를 받고,
   상수 조건이라 최적화(-Og 이상)에서 죽은 쪽은 사라집니다 */
void app_start(void) {
    sensor_start();
    if (FEATURE_WIFI) {
        wifi_start();          /* FEATURE_WIFI=0 이어도 문법 · 타입 검사됨 */
    }
}
일반 if 의 함정 — 꺼진 쪽이 부르는 wifi_start 도 선언은 있어야 컴파일되고, -O0 에선 호출이 남아 링크 에러가 날 수 있습니다. 그래서 꺼진 모듈은 빈 구현(wifi_stub.c)을 링크하거나, 모듈 경계에서만 #if 를 씁니다. -Wundef 는 k06 의 fw_warnings 에 이미 들어 있습니다.

구현이 다르면 — 링크 타임 선택 🟡 중급

같은 헤더, 세 가지 .c — 고르는 건 CMake

services/storage/
├── include/svc/storage.h       ← 인터페이스 하나
└── src/
    ├── storage_int_flash.c     ← STM32 내부 Flash 섹터 (m11)
    ├── storage_w25q.c          ← 외부 SPI Flash
    └── storage_ram.c           ← 호스트 테스트용 (메모리 배열)
# services/storage/CMakeLists.txt
if(FW_BOARD STREQUAL "host_sim")
    set(STORAGE_IMPL src/storage_ram.c)
elseif(BOARD_HAS_EXT_FLASH)            # board.cmake 가 정한 변수
    set(STORAGE_IMPL src/storage_w25q.c)
else()
    set(STORAGE_IMPL src/storage_int_flash.c)
endif()
add_library(svc_storage STATIC ${STORAGE_IMPL})
target_include_directories(svc_storage PUBLIC include)
target_link_libraries(svc_storage PUBLIC core PRIVATE board fw_warnings)
.c 파일 안에는 #if 가 하나도 없습니다 — 각 파일은 자기 하드웨어만 압니다. 서버 개발에 빗대면 스프링 프로필별로 다른 @Bean 구현을 주입하는 것을 링커가 하는 셈입니다. 같은 심볼이 두 .c 에 있으면 링커가 "multiple definition" 으로 막아 주니 실수도 드러납니다.

weak 심볼 — 기본 구현 + 덮어쓰기

/* core/src/fw_hooks.c — 기본은 아무것도 안 함 */
__attribute__((weak)) void fw_on_idle(void) { }
__attribute__((weak)) void fw_on_fatal(uint32_t code) { (void)code; }

/* products/thermo_pro/app/hooks.c — 제품이 필요하면 덮어씀 */
void fw_on_idle(void) {
    __WFI();                 /* 할 일 없으면 잠듦 */
}
방법좋은 점함정
다른 .c 링크명시적 · 중복이면 링크 에러CMake 분기가 늘어남
weak 심볼기본값이 있어 편함이름 오타 시 조용히 기본값 — HAL 콜백 사고의 단골
vtable (k08)실행 중 교체 가능간접 호출 비용 · RAM
weak 는 정적 라이브러리(.a)와 궁합이 나쁩니다 — 링커는 아직 풀리지 않은 심볼을 찾을 때만 .a 에서 오브젝트를 꺼내므로, weak 기본 정의가 이미 있으면 강한 정의가 든 오브젝트를 꺼내지 않을 수 있습니다. 덮어쓰는 쪽은 실행 파일 소스에 두거나 오브젝트 라이브러리로 링크하세요.

조합이 수십 개가 되면 — Kconfig 와 컴파일 타임 검증 🔴 고급

Kconfig — 리눅스 · Zephyr · ESP-IDF 의 방식

# Kconfig — 설정 항목과 의존 관계를 선언
config FW_WIFI
	bool "Wi-Fi 모듈 지원"
	depends on BOARD_HAS_MODEM_UART
	select FW_RING_BUFFER
	help
	  ESP-AT 모뎀을 통한 Wi-Fi 연결.

config FW_WIFI_RETRY_MAX
	int "재접속 최대 횟수"
	range 1 20
	default 5
	depends on FW_WIFI
# prj.conf (Zephyr) · sdkconfig.defaults (ESP-IDF) — 제품이 고른 값
CONFIG_FW_WIFI=y
CONFIG_FW_WIFI_RETRY_MAX=8

# 빌드가 생성하는 헤더 (Zephyr: autoconf.h · ESP-IDF: sdkconfig.h)
#define CONFIG_FW_WIFI 1
#define CONFIG_FW_WIFI_RETRY_MAX 8
#define CONFIG_FW_RING_BUFFER 1       /* select 로 자동 켜짐 */
Kconfig 의 가치는 의존 관계(depends on · select · range)를 도구가 풀어 준다는 것 — 말이 안 되는 조합은 메뉴에서 아예 고를 수 없습니다. bool 이 꺼지면 매크로가 정의되지 않으므로 Zephyr 는 IS_ENABLED(CONFIG_X) 매크로로 일반 if 에 씁니다. 자체 프로젝트에선 파이썬 kconfiglib(menuconfig · genconfig)으로 같은 방식을 쓸 수 있습니다.

_Static_assert — 설정이 말이 되는지 컴파일러가 검사

#include "fw_config.h"
#include <stdint.h>

/* 링 버퍼 크기는 2의 거듭제곱 (k02) */
#define UART_RX_SIZE 256u
_Static_assert((UART_RX_SIZE & (UART_RX_SIZE - 1u)) == 0u,
               "UART_RX_SIZE 는 2의 거듭제곱");

/* Flash 에 저장하는 설정 구조체 — 레이아웃이 바뀌면 호환이 깨짐 */
typedef struct {
    uint32_t magic;          /* 0x43464731 'CFG1' */
    uint16_t version;
    uint16_t length;
    int32_t  temp_offset_mc;
    uint8_t  wifi_ssid[33];
    uint8_t  reserved[7];    /* 앞으로 필드를 추가할 자리 */
    uint32_t crc32;
} persist_cfg_t;
_Static_assert(sizeof(persist_cfg_t) == 56u, "persist_cfg_t 레이아웃 변경 — version 올리세요");
_Static_assert(sizeof(persist_cfg_t) % 8u == 0u, "F4 Flash 는 8바이트 단위로 쓰기 편하게");

/* 설정값 범위 */
_Static_assert(LOG_LEVEL >= LOG_LEVEL_ERROR && LOG_LEVEL <= LOG_LEVEL_DEBUG, "LOG_LEVEL 범위");

/* C23 에서는 static_assert 가 키워드이고 메시지를 생략할 수 있습니다 */
계산해 보면 — 오프셋 magic 0 · version 4 · length 6 · temp 8 · ssid 12~44 · reserved 45~51 · crc32 52 → 합계 56 B, crc32 오프셋 52 가 4의 배수라 패딩이 없습니다. 누가 reserved 를 [3] 으로 줄이면 crc32 가 48 로 당겨져 52 B 가 되고, 위 assert 가 빌드를 멈춰 Flash 에 저장된 옛 설정과의 불일치를 막아 줍니다.
정리 — 실행 중 값은 런타임 설정, 다른 구현은 다른 .c 를 링크, 기능 스위치는 fw_config.h 의 0/1 매크로 + -Wundef, 조합이 폭발하면 Kconfig. 설정의 일관성은 #error 와 _Static_assert 로 컴파일러에게 맡깁니다.
ARCH 08

인터페이스와 의존성 역전 — 함수 포인터 구조체(vtable)와 테스트 대역

k04 의 콜백은 "함수 하나"를 넘겼습니다. 함수 여러 개를 표 하나로 묶어 넘기면 그게 C 의 인터페이스입니다 — C++ 의 가상 함수 표(vtable)를 손으로 만드는 것입니다. 리눅스 커널의 struct file_operations, Zephyr 의 드라이버 API 가 전부 이 모양입니다. 이것으로 앱이 구체 드라이버 대신 인터페이스에 의존하게 만들면(의존성 역전), 하드웨어 없이 PC 에서 앱 로직을 테스트할 수 있습니다.

같은 모양, 다른 표 — 첫 칸의 포인터 하나가 구현을 정합니다 RAM · bme280_t s_bme (16 B) ops * bus * addr+pad cal Flash .rodata · bme280_ops (12 B) init read_mc sleep bme_init bme_read bme_sleep .text RAM · fake_sensor_t s_fake (테스트) ops * values * idx calls fake_ops — 같은 세 칸 init read_mc sleep fake_read 미리 넣은 값을 차례로 돌려줌 호출 쪽 temp_sensor_read(s, &t) 는 s->ops->read_mc 를 따라갈 뿐 — 어느 표인지 모르고, 알 필요도 없습니다
vtable 메모리 — 인스턴스는 RAM, 표는 const 라서 Flash. 인스턴스가 100개여도 표는 하나입니다.

인터페이스 — drivers/include/drv/temp_sensor.h

#ifndef DRV_TEMP_SENSOR_H
#define DRV_TEMP_SENSOR_H
#include <stdint.h>
#include "core/fw_err.h"          /* fw_err_t — k09 */

typedef struct temp_sensor temp_sensor_t;

typedef struct {
    fw_err_t (*init)(temp_sensor_t *self);
    fw_err_t (*read_mc)(temp_sensor_t *self, int32_t *out_milli_c);
    void     (*sleep)(temp_sensor_t *self);
} temp_sensor_ops_t;

struct temp_sensor {
    const temp_sensor_ops_t *ops;   /* ★ 반드시 첫 멤버 */
};

/* 호출 쪽이 쓰는 얇은 래퍼 — ops 를 직접 만지지 않게 */
static inline fw_err_t temp_sensor_init(temp_sensor_t *s) {
    return s->ops->init(s);
}
static inline fw_err_t temp_sensor_read(temp_sensor_t *s, int32_t *out) {
    return s->ops->read_mc(s, out);
}
static inline void temp_sensor_sleep(temp_sensor_t *s) {
    if (s->ops->sleep != NULL) { s->ops->sleep(s); }   /* 선택 기능 */
}
#endif
서버 개발에 빗대면 — temp_sensor_ops_t 가 자바의 interface TempSensor, 각 드라이버가 implements. self 를 첫 인자로 넘기는 것이 this 입니다. 선택 기능은 NULL 을 허용하고 래퍼에서 검사합니다.

구현 — drivers/bme280/src/bme280.c

/* include/drv/bme280.h */
typedef struct {
    temp_sensor_t base;      /* ★ 첫 멤버 = "상속" */
    i2c_bus_t    *bus;       /* 버스 인터페이스 (이것도 vtable) */
    uint8_t       addr;
    int32_t       cal_offset_mc;
} bme280_t;

void bme280_setup(bme280_t *d, i2c_bus_t *bus, uint8_t addr);

/* src/bme280.c */
static fw_err_t bme_init(temp_sensor_t *self);
static fw_err_t bme_read(temp_sensor_t *self, int32_t *out);
static void     bme_sleep(temp_sensor_t *self);

static const temp_sensor_ops_t s_bme280_ops = {   /* const → .rodata */
    .init = bme_init, .read_mc = bme_read, .sleep = bme_sleep,
};

void bme280_setup(bme280_t *d, i2c_bus_t *bus, uint8_t addr) {
    d->base.ops      = &s_bme280_ops;
    d->bus           = bus;
    d->addr          = addr;
    d->cal_offset_mc = 0;
}

static fw_err_t bme_read(temp_sensor_t *self, int32_t *out) {
    bme280_t *d = (bme280_t *)self;   /* base 가 첫 멤버라 유효 (C17 6.7.2.1) */
    uint8_t raw[3];
    fw_err_t e = i2c_bus_read_reg(d->bus, d->addr, 0xFAu, raw, sizeof raw);
    if (e != FW_OK) { return e; }
    *out = bme280_compensate_t(d, raw) + d->cal_offset_mc;
    return FW_OK;
}
MISRA 를 따르는 프로젝트라면 이 캐스트가 Rule 11.3 에 걸립니다. 그때는 리눅스의 container_of 처럼 offsetof 로 바깥 구조체 주소를 계산하는 매크로를 한 곳에 두고 편차(deviation) 문서를 남깁니다(k12). 0xFA 는 BME280 의 temp_msb 레지스터 주소입니다.

의존성 주입 — init 때 넘기고, 조립은 한 곳에서 🟡 중급

앱 로직은 인터페이스만 받는다

/* app/monitor.h */
typedef struct {
    temp_sensor_t *sensor;      /* 무엇이 들어올지 모름 */
    int32_t        limit_mc;
    bool           alarm;
} monitor_t;

void     monitor_init(monitor_t *m, temp_sensor_t *s, int32_t limit_mc);
fw_err_t monitor_step(monitor_t *m);

/* app/monitor.c — HAL · BME280 이름이 한 번도 안 나옵니다 */
void monitor_init(monitor_t *m, temp_sensor_t *s, int32_t limit_mc) {
    m->sensor   = s;
    m->limit_mc = limit_mc;
    m->alarm    = false;
}

fw_err_t monitor_step(monitor_t *m) {
    int32_t t;
    fw_err_t e = temp_sensor_read(m->sensor, &t);
    if (e != FW_OK) { return e; }               /* 상태는 그대로 */
    if (!m->alarm && (t >= m->limit_mc)) {
        m->alarm = true;
    } else if (m->alarm && (t < (m->limit_mc - 2000))) {
        m->alarm = false;                       /* 2 °C 히스테리시스 */
    } else {
        /* 변화 없음 */
    }
    return FW_OK;
}

조립 지점(composition root) — 제품의 app_main.c

/* products/thermo_pro/app/app_main.c — 구체 타입을 아는 유일한 파일 */
#include "drv/bme280.h"
#include "bsp/bsp_bus.h"
#include "app/monitor.h"

static bme280_t  s_bme;          /* 전부 정적 할당 (k10) */
static monitor_t s_mon;

void app_main(void) {
    bme280_setup(&s_bme, bsp_sensor_bus(), BME280_ADDR_SDO_LOW);
    FW_ASSERT(temp_sensor_init(&s_bme.base) == FW_OK);
    monitor_init(&s_mon, &s_bme.base, 80000);   /* 80.000 °C */

    for (;;) {
        (void)monitor_step(&s_mon);   /* 실제 제품은 k09 정책대로 에러 처리 */
        bsp_led_set(BSP_LED_ALARM, s_mon.alarm);
        bsp_delay_ms(100u);
    }
}
스프링의 @Configuration 클래스를 손으로 쓰는 것과 같습니다 — 객체를 만들고 서로 꽂아 주는 일을 한 파일에 모읍니다. DI 컨테이너는 없어도 됩니다. 조립이 한 곳에 있으면 "이 제품에 어떤 드라이버가 들어가나"가 그 파일 하나로 답해집니다.

테스트 대역 — 가짜 구현을 끼워 PC 에서 돌리기 🔴 실무

추상화 고르기 — 가로는 정해지는 시점, 세로는 호출 빈도 빌드 때 정해짐 실행 중에 바뀜 매우 잦음 드묾 static inline · 매크로 GPIO 토글 1 MHz vtable + 블록 API 오디오 48 kHz → 64개씩 링크 타임 교체 (k07) 저장소 백엔드 vtable — 이 칸 센서 읽기 10 Hz · 테스트 대역 테스트만이 목적이면 왼쪽 아래(링크 교체)로도 충분합니다
사분면 — 실행 중에 바뀌고 드물게 불리는 것이 vtable 의 자리입니다. 잦은 호출은 묶어서 횟수를 줄입니다.

tests/fakes/fake_sensor.c + Unity 테스트

typedef struct {
    temp_sensor_t  base;
    const int32_t *values;
    size_t         n, idx;
    fw_err_t       fail_with;       /* FW_OK 가 아니면 이 에러를 돌려줌 */
    unsigned       read_calls;
} fake_sensor_t;

static fw_err_t fake_init(temp_sensor_t *s) { (void)s; return FW_OK; }
static fw_err_t fake_read(temp_sensor_t *s, int32_t *out) {
    fake_sensor_t *f = (fake_sensor_t *)s;
    f->read_calls++;
    if (f->fail_with != FW_OK) { return f->fail_with; }
    if (f->idx >= f->n)        { return FW_ERR_NODATA; }
    *out = f->values[f->idx++];
    return FW_OK;
}
static const temp_sensor_ops_t s_fake_ops = { .init = fake_init, .read_mc = fake_read };

void fake_sensor_setup(fake_sensor_t *f, const int32_t *v, size_t n) {
    *f = (fake_sensor_t){ .base = { .ops = &s_fake_ops }, .values = v, .n = n };
}

/* tests/test_monitor.c */
void test_alarm_has_hysteresis(void) {
    static const int32_t seq[] = { 79000, 80000, 79000, 77999 };
    fake_sensor_t f;  monitor_t m;
    fake_sensor_setup(&f, seq, 4u);
    monitor_init(&m, &f.base, 80000);
    monitor_step(&m); TEST_ASSERT_FALSE(m.alarm);   /* 79.0 */
    monitor_step(&m); TEST_ASSERT_TRUE(m.alarm);    /* 80.0 → 켜짐 */
    monitor_step(&m); TEST_ASSERT_TRUE(m.alarm);    /* 79.0 ≥ 78.0 유지 */
    monitor_step(&m); TEST_ASSERT_FALSE(m.alarm);   /* 77.999 → 꺼짐 */
}
sleep 은 지정 초기화에서 빠져 NULL 이 됩니다 — 래퍼가 NULL 을 검사하므로 안전합니다. 이 히스테리시스 시나리오는 node 로 옮겨 같은 결과(꺼짐 → 켜짐 → 유지 → 꺼짐)를 확인했습니다. 테스트 프레임워크 자체는 디버깅 · 테스트 탭에서 다룹니다.

언제 vtable 을 쓰나 — 비용과 판단

비용크기
간접 호출ops 읽기 + 함수 포인터 읽기 + blx — 호출당 몇 사이클
인라인 불가작은 함수에선 이쪽이 더 큼 (최적화 기회 상실)
메모리인스턴스당 포인터 4 B, 표는 const 라 Flash 에 하나
안전RAM 의 ops 포인터가 깨지면 엉뚱한 곳으로 점프 — HardFault (m12)
정리 — C 의 인터페이스는 const 함수 포인터 표 + 그 표를 가리키는 첫 멤버입니다. 앱은 인터페이스만 받고(주입), 구체 타입은 조립 파일 한 곳에서만 알며, 테스트에선 가짜 표를 끼웁니다. 아주 잦은 호출에는 쓰지 말고 블록 단위로 묶습니다.
ARCH 09

에러 처리 규약 — 공통 에러 enum · goto cleanup · assert vs 검사 · 복구 불가 정책

C 에는 예외가 없으니 에러는 반환값으로 흐릅니다. 모듈마다 -1, 0, false, NULL 을 제멋대로 쓰면 호출하는 쪽은 매번 문서를 찾아야 하고, 결국 반환값을 무시하기 시작합니다. 대형 프로젝트는 에러 타입 하나, 반환 규약 하나, 정리 패턴 하나로 통일하고, "이건 버그다(assert)"와 "이건 바깥 세상의 실패다(검사)"를 구분합니다. 그리고 펌웨어는 마지막 질문에 답해야 합니다 — 복구할 수 없을 때 어떻게 죽을 것인가.

에러는 아래에서 위로 — 각 층은 "처리"하거나 "더 위로" 둘 중 하나만 앱 (monitor) bme280 드라이버 i2c 버스 fault 정책 1. temp_sensor_read() 2. i2c_bus_read_reg() 3. FW_ERR_NACK 4. 2회 재시도 · 실패 5. FW_ERR_IO + LOG_W 한 번 6. 연속 실패 5회째 7. fw_fault(SENSOR_LOST) 안전 상태 → 기록 → 리셋 로그는 원인을 아는 곳에서 한 번 — 층마다 찍으면 같은 에러가 네 줄이 됩니다
에러 전파 시퀀스 — 드라이버는 재시도와 로그, 앱은 정책(언제 포기할지), fault 모듈은 죽는 방법을 책임집니다.

core/include/core/fw_err.h — 프로젝트 공통 에러

#ifndef CORE_FW_ERR_H
#define CORE_FW_ERR_H

typedef enum {
    FW_OK            =  0,
    FW_ERR_PARAM     = -1,   /* 호출자가 잘못된 값을 줌 */
    FW_ERR_STATE     = -2,   /* 지금 상태에서 할 수 없음 (init 전 등) */
    FW_ERR_TIMEOUT   = -3,
    FW_ERR_BUSY      = -4,   /* 나중에 다시 */
    FW_ERR_NOMEM     = -5,   /* 풀 · 버퍼 부족 */
    FW_ERR_IO        = -6,   /* 하드웨어 · 버스 실패 */
    FW_ERR_NACK      = -7,   /* I2C 주소 응답 없음 */
    FW_ERR_CRC       = -8,
    FW_ERR_NODATA    = -9,
    FW_ERR_UNSUPPORTED = -10
} fw_err_t;

const char *fw_err_str(fw_err_t e);     /* 로그용 이름 */

/* 반환값을 버리면 경고 — C23 은 [[nodiscard]] */
#if defined(__GNUC__)
#define FW_MUST_CHECK __attribute__((warn_unused_result))
#else
#define FW_MUST_CHECK
#endif

/* 위로 전달만 할 때 — 함수 안에서만 쓰는 짧은 매크로 */
#define FW_TRY(expr) do { fw_err_t err_ = (expr); \
                          if (err_ != FW_OK) { return err_; } } while (0)
#endif
이름을 err_t 로 하지 마세요 — lwIP 가 이미 err_t 를 정의해서 네트워크를 붙이는 날 충돌합니다. 모듈 접두사 규칙(k02)은 에러 타입에도 적용됩니다. 값을 음수로 두면 "개수 또는 에러"를 한 반환값으로 돌려주는 POSIX 식 함수와도 섞어 쓸 수 있지만, 한 프로젝트 안에서는 한 방식만 씁니다.

반환 규약 — 에러는 반환값, 결과는 out 매개변수

❌ 반환값의 의미가 섞임
int uart_read(uint8_t *buf, int max);
/* 개수? 에러? 0 은 "없음"? "실패"? */
✅ 에러 하나 + 결과는 포인터로
FW_MUST_CHECK
fw_err_t uart_read(uart_id_t id, uint8_t *buf,
                   size_t cap, size_t *out_n);
/* 서비스 계층 — FW_TRY 로 "전달만" 하는 곳은 짧게 */
fw_err_t cfg_save(const persist_cfg_t *c) {
    if (c == NULL) { return FW_ERR_PARAM; }        /* 외부 입력 검사 */
    FW_TRY(storage_erase(STORAGE_SLOT_CFG));
    FW_TRY(storage_write(STORAGE_SLOT_CFG, c, sizeof *c));
    FW_TRY(storage_verify(STORAGE_SLOT_CFG, c, sizeof *c));
    return FW_OK;
}

/* 실패해도 out 은 건드리지 않는다 — 이것도 규약에 적어 둡니다 */
fw_err_t sensor_get(int32_t *out_mc) {
    int32_t v;
    fw_err_t e = read_raw(&v);
    if (e == FW_OK) { *out_mc = v; }
    return e;
}
FW_TRY 처럼 숨은 return 이 있는 매크로는 MISRA 에서 권고 위반(Dir 4.9 · Rule 15.5)입니다. 안전 인증 프로젝트라면 매크로 대신 if (e == FW_OK) { e = ...; } 를 이어 쓰는 "에러 체인" 스타일을 씁니다.

자원 정리 — goto cleanup 과 assert 의 자리 🟡 중급

PC 쪽 — 획득 순서의 역순으로 한 곳에서 해제

fw_err_t log_export(const char *path, const log_store_t *ls) {
    fw_err_t err = FW_OK;
    uint8_t *buf = NULL;
    FILE    *f   = NULL;

    buf = malloc(EXPORT_CHUNK);
    if (buf == NULL) { err = FW_ERR_NOMEM; goto cleanup; }

    f = fopen(path, "wb");
    if (f == NULL) { err = FW_ERR_IO; goto cleanup; }

    for (size_t off = 0; off < ls->size; off += EXPORT_CHUNK) {
        size_t n = log_store_copy(ls, off, buf, EXPORT_CHUNK);
        if (fwrite(buf, 1, n, f) != n) { err = FW_ERR_IO; goto cleanup; }
    }
    if (fflush(f) != 0) { err = FW_ERR_IO; }

cleanup:
    if (f != NULL) {
        if ((fclose(f) != 0) && (err == FW_OK)) { err = FW_ERR_IO; }
    }
    free(buf);                 /* free(NULL) 은 안전 */
    return err;
}
자바의 try-with-resources 를 손으로 쓰는 것입니다. 규칙 셋 — 앞으로만 점프, 레이블은 함수 끝 하나, 자원 변수는 맨 위에서 NULL 로 초기화. 리눅스 커널 전체가 이 패턴입니다. MISRA 는 goto 를 권고(15.1)로 말리지만 앞으로 · 같은 블록 안 점프(15.2 · 15.3)는 허용 범위라 편차로 처리하는 팀이 많습니다.

assert 는 "내 버그", 검사는 "바깥의 실패"

상황도구예
절대 일어나면 안 되는 일FW_ASSERTinit 전 호출 · NULL 핸들 · 범위 밖 enum
외부 입력 · 하드웨어검사 + fw_err_tUART 프레임 · 센서 NACK · Flash 쓰기 실패
설정 · 크기_Static_assert버퍼 크기 · 구조체 레이아웃 (k07)
/* core/include/core/fw_assert.h */
void fw_assert_failed(uint16_t file_id, uint16_t line);   /* 돌아오지 않음 */

#define FW_ASSERT(cond) \
    do { if (!(cond)) { fw_assert_failed(FW_FILE_ID, (uint16_t)__LINE__); } } while (0)

/* 파일마다 맨 위에: #define FW_FILE_ID 0x0123
   __FILE__ 문자열 대신 숫자 → Flash 절약, 로그에서 표로 역변환 */

/* core/src/fw_assert.c */
void fw_assert_failed(uint16_t file_id, uint16_t line) {
#ifndef NDEBUG
    if ((CoreDebug->DHCSR & CoreDebug_DHCSR_C_DEBUGEN_Msk) != 0u) {
        __BKPT(0);             /* 디버거가 붙어 있으면 그 자리에서 멈춤 */
    }
#endif
    fw_fault(FAULT_ASSERT, ((uint32_t)file_id << 16) | line);
}
릴리스에서 assert 를 끄지 마세요(펌웨어) — PC 의 assert 는 NDEBUG 에서 사라지지만, 현장에서 일어난 "불가능한 일"이야말로 가장 알고 싶은 정보입니다. 끄는 대신 기록하고 안전하게 리셋합니다. __BKPT 를 디버거 없이 실행하면 HardFault 가 나므로 DHCSR 로 확인합니다.

복구 불가일 때 — 안전 상태 → 기록 → 리셋 🔴 실무

fw_fault() — 죽는 방법을 한 곳에

typedef struct {
    uint32_t magic;              /* 0xFA17C0DE 면 유효 */
    uint32_t code;
    uint32_t arg;
    uint32_t lr;                 /* 누가 불렀나 — .map 으로 역추적 */
    uint32_t consecutive;        /* 연속 크래시 횟수 */
} crash_rec_t;

/* 리셋해도 지워지지 않는 RAM — 링커 스크립트에 .noinit 추가 필요 */
__attribute__((section(".noinit"))) static volatile crash_rec_t s_crash;

__attribute__((noreturn))
void fw_fault(uint32_t code, uint32_t arg) {
    __disable_irq();
    fw_on_fatal(code);           /* 제품이 덮어쓰는 weak 훅(k07) — 히터 OFF ·
                                    모터 정지. core 는 BSP 를 모릅니다 (k04) */
    uint32_t prev = (s_crash.magic == 0xFA17C0DEu) ? s_crash.consecutive : 0u;
    s_crash.code        = code;
    s_crash.arg         = arg;
    s_crash.lr          = (uint32_t)__builtin_return_address(0);
    s_crash.consecutive = prev + 1u;
    s_crash.magic       = 0xFA17C0DEu;     /* 마지막에 써서 "완성됨" 표시 */
    __DSB();
    NVIC_SystemReset();          /* 돌아오지 않음 */
}
/* STM32F446RETX_FLASH.ld — .bss 다음에 추가 (startup 이 0 으로 지우지 않음) */
.noinit (NOLOAD) :
{
  . = ALIGN(4);
  *(.noinit*)
  . = ALIGN(4);
} >RAM

부팅 때 — 왜 리셋됐나 읽고, 반복되면 안전 모드

reset_reason_t boot_reset_reason(void) {
    reset_reason_t r = RESET_UNKNOWN;
    /* ★ 순서 중요: F4 는 IWDG · 소프트웨어 리셋에서도 NRST 핀이
       눌린 것처럼 PINRST 가 같이 켜지고, 전원 투입 때는 POR 과 BOR 이 함께 켜짐 */
    if      (__HAL_RCC_GET_FLAG(RCC_FLAG_IWDGRST)) { r = RESET_WATCHDOG; }
    else if (__HAL_RCC_GET_FLAG(RCC_FLAG_WWDGRST)) { r = RESET_WATCHDOG; }
    else if (__HAL_RCC_GET_FLAG(RCC_FLAG_SFTRST))  { r = RESET_SOFTWARE; }
    else if (__HAL_RCC_GET_FLAG(RCC_FLAG_PORRST))  { r = RESET_POWER_ON; }
    else if (__HAL_RCC_GET_FLAG(RCC_FLAG_BORRST))  { r = RESET_BROWNOUT; }
    else if (__HAL_RCC_GET_FLAG(RCC_FLAG_PINRST))  { r = RESET_PIN; }
    else { /* LPWR 등 */ }
    __HAL_RCC_CLEAR_RESET_FLAGS();
    return r;
}

void boot_check_crash_loop(void) {
    if ((s_crash.magic == 0xFA17C0DEu) && (s_crash.consecutive >= 3u)) {
        app_enter_safe_mode();     /* 통신 · 업데이트만 살린 최소 모드 */
    }
}
/* 정상적으로 10분 돌면 s_crash.consecutive = 0 으로 되돌립니다 */
제품 종류복구 불가 정책
센서 · 계량기기록 후 즉시 리셋 — 몇 초 공백은 허용
모터 · 히터 제어먼저 출력 차단, 리셋 후에도 사람 확인 전 재가동 금지
의료 · 자동차표준(IEC 62304 · ISO 26262)이 정한 안전 상태로
리셋 루프를 막는 연속 크래시 카운터가 없으면, 부팅 직후 죽는 버그 하나로 기기가 영원히 재부팅만 합니다. 워치독(IWDG)과의 조합 · 현장 크래시 수집은 양산 · 펌웨어 운영 탭에서 이어집니다.
정리 — 에러는 프로젝트 공통 enum 하나를 반환값으로, 결과는 out 매개변수로. 정리는 goto cleanup 한 곳, 버그는 assert · 바깥 실패는 검사로 나누고, 복구 불가면 안전 상태 → 기록 → 리셋을 한 함수가 책임집니다.
ARCH 10

메모리 정책 — 정적 할당 우선 · 고정 크기 풀 · 아레나 · 소유권 이름 규칙

GC 가 없는 언어에서 메모리 버그는 "누가 해제하나"를 정하지 않은 데서 옵니다. 펌웨어는 여기에 두 가지가 더 붙습니다 — 힙 단편화(총량은 남았는데 큰 덩어리가 없음)와 실패 시점을 예측할 수 없다는 것. 그래서 대형 펌웨어는 "초기화 이후 malloc 금지"를 규칙으로 두고, 필요한 동적 메모리는 크기가 고정된 풀과 통째로 비우는 아레나로 해결합니다. FreeRTOS 의 힙 방식(heap_1~5) · 태스크 스택 측정은 FreeRTOS 탭 r10 에 있습니다.

RAM 128 KB 를 어떻게 나누나 — 막대 폭이 곧 크기 정적 할당 설계 22 KB 32 KB 28 KB 46 KB — 링크 때 확정 힙 중심 설계 22 KB 힙 64 KB — 최대 사용량 모름 28 KB 14 ? .data · .bss 풀 · 버퍼 / 힙 스택 (태스크 + MSP) 여유 위 막대는 arm-none-eabi-size 한 번으로 전부 보입니다 아래 막대의 "여유"는 몇 달 돌려 봐야 알고, 그때 알면 늦습니다
RAM 예산 — 정적 설계는 여유가 숫자로 확정되고, 힙 설계는 여유가 확률이 됩니다.

규칙 — "초기화 이후 malloc 금지"를 도구로 강제

/* core/include/core/fw_poison.h — 앱 · 서비스 .c 가 시스템 헤더 "다음에" 포함
   (먼저 포함하면 <stdlib.h> 의 선언 자체가 에러가 됩니다) */
#if defined(__GNUC__) && !defined(FW_ALLOW_HEAP)
#pragma GCC poison malloc calloc realloc free
#endif
/* 이후 malloc 을 쓰면: error: attempt to use poisoned "malloc" */
# 서드파티가 몰래 부르는 malloc 까지 잡기 — 링커 래핑
target_link_options(thermo_pro PRIVATE -Wl,--wrap=malloc -Wl,--wrap=free)
/* core/src/heap_guard.c */
extern void *__real_malloc(size_t n);
static bool s_init_done;

void heap_guard_lock(void) { s_init_done = true; }   /* app_main 초기화 끝에 */

void *__wrap_malloc(size_t n) {
    FW_ASSERT(!s_init_done);       /* 초기화 이후 호출 = 버그 */
    return __real_malloc(n);
}
void __wrap_free(void *p) {
    FW_ASSERT(p == NULL);          /* 정적 설계에선 해제할 일이 없음 */
}
MISRA C:2023 는 Dir 4.12(동적 메모리 할당 금지)와 Rule 21.3(<stdlib.h> 의 할당 함수 금지)로 같은 것을 요구합니다(둘 다 Required). -Wl,--wrap=malloc 은 다른 오브젝트의 malloc 호출을 __wrap_malloc 으로 돌립니다 — 서드파티 코드가 몰래 힙을 쓰는지 드러납니다. 단 printf 같은 newlib 내부 함수는 malloc 이 아니라 _malloc_r 을 직접 부르므로, 그것까지 보려면 -Wl,--wrap=_malloc_r 도 함께 겁니다.

소유권은 이름과 주석으로 — 팀 공통 접미사

함수 이름메모리는 누가 · 언제 해제
x_create / x_destroy모듈이 잡고, 호출자가 destroy 할 의무
x_init / x_deinit호출자가 메모리를 줌 (정적 · 스택). 모듈은 안 잡음
x_get_* · _borrow빌려줌 — 해제 금지, 다음 호출 전까지만 유효
x_take / x_give소유권 이동 — take 한 쪽이 give(반납) 책임
x_copy_*호출자 버퍼에 복사 — 소유권 문제 없음
/* 헤더 주석에 소유권을 반드시 적습니다 */

/** 마지막 프레임을 빌려줌. 반환 포인터는 proto_take_frame() 을
 *  다시 부르기 전까지만 유효. 해제하지 말 것. */
const proto_frame_t *proto_peek_frame(void);

/** 풀에서 메시지를 꺼냄. 호출자가 msg_free() 할 책임 (소유권 이동).
 *  풀이 비었으면 NULL. ISR 에서 호출 가능. */
msg_t *msg_alloc(void);
러스트의 소유권 · 빌림을 컴파일러 대신 이름 규칙으로 흉내 내는 것입니다. 규칙이 있으면 코드 리뷰에서 "_get 으로 받은 걸 왜 free 하나요?"라고 한 줄로 지적할 수 있습니다.

동적 메모리가 꼭 필요할 때 — 고정 크기 풀 🟡 중급

msg 풀 — 빈칸은 자기 안의 next 로 줄을 섭니다 #0 사용 #1 빈칸 #2 사용 #3 사용 #4 빈칸 #5 빈칸 #6 사용 #7 빈칸 free_head next = NULL 각 칸 64 B · 붉은 칸 = 지금 누군가 쓰는 중 alloc 은 맨 앞을 떼고, free 는 맨 앞에 끼움 — 둘 다 O(1), 단편화 0
고정 크기 풀 — 크기가 모두 같으니 "맞는 빈칸 찾기"가 없습니다. 그래서 시간이 일정하고 조각나지 않습니다.

core/src/msg_pool.c — ISR 에서도 쓰는 풀

typedef struct msg {
    struct msg *next;      /* 빈칸일 때만 의미 있음 */
    uint8_t     len;
    uint8_t     data[59];
} msg_t;
#if UINTPTR_MAX == 0xFFFFFFFFu     /* 32비트 타깃: 4 + 1 + 59 = 64 */
_Static_assert(sizeof(msg_t) == 64u, "msg_t 는 64 B");
#endif                            /* 64비트 호스트 테스트에선 72 B */

#define MSG_POOL_N 16u
static msg_t    s_msgs[MSG_POOL_N];        /* 1 KB, .bss */
static msg_t   *s_free;
static uint16_t s_in_use, s_peak;

static inline uint32_t irq_save(void) {
    uint32_t pm = __get_PRIMASK(); __disable_irq(); return pm;
}
static inline void irq_restore(uint32_t pm) { __set_PRIMASK(pm); }

void msg_pool_init(void) {
    s_free = NULL;
    for (size_t i = MSG_POOL_N; i > 0u; i--) {   /* #0 이 맨 앞에 오게 역순 */
        s_msgs[i - 1u].next = s_free;
        s_free = &s_msgs[i - 1u];
    }
}

msg_t *msg_alloc(void) {
    uint32_t pm = irq_save();
    msg_t *m = s_free;
    if (m != NULL) {
        s_free = m->next;
        s_in_use++;
        if (s_in_use > s_peak) { s_peak = s_in_use; }
    }
    irq_restore(pm);
    return m;              /* NULL = 고갈 → 호출자 정책 (버리고 카운트) */
}

void msg_free(msg_t *m) {
    FW_ASSERT((m >= &s_msgs[0]) && (m < &s_msgs[MSG_POOL_N]));
    uint32_t pm = irq_save();
    m->next = s_free;
    s_free  = m;
    s_in_use--;
    irq_restore(pm);
}

uint16_t msg_pool_peak(void) { return s_peak; }  /* 셸 · 로그로 노출 */

아레나 — 한 번에 통째로 비우는 할당

typedef struct {
    uint8_t *base;     /* max_align_t 로 정렬된 버퍼여야 함 */
    size_t   cap;
    size_t   off;
} arena_t;

void *arena_alloc(arena_t *a, size_t size, size_t align) {   /* align: 2의 거듭제곱 */
    size_t p = (a->off + (align - 1u)) & ~(align - 1u);
    if ((p > a->cap) || (size > (a->cap - p))) { return NULL; }
    a->off = p + size;
    return a->base + p;
}
void arena_reset(arena_t *a) { a->off = 0u; }   /* free 는 없음 */

/* 사용: 요청(프레임) 하나 처리하는 동안만 쓰는 임시 메모리 */
static _Alignas(max_align_t) uint8_t s_scratch_mem[2048];
static arena_t s_scratch = { s_scratch_mem, sizeof s_scratch_mem, 0u };

void proto_handle(const proto_frame_t *f) {
    arena_reset(&s_scratch);                 /* 지난 요청 것은 통째로 버림 */
    json_doc_t *doc = json_parse(f->payload, f->len, &s_scratch);
    ...
}
정렬 계산 확인 — 64 B 아레나에서 (3 B, 정렬 1) → 오프셋 0, (8 B, 정렬 4) → 4, (2 B, 정렬 8) → 16, (40 B, 정렬 4) → 20 (끝 60), 다음 (8 B, 정렬 4) → 남은 4 B 라 NULL. node 로 같은 식을 돌려 확인했습니다. size > cap - p 로 비교하는 이유는 p + size 가 넘칠 수 있어서입니다. 서버의 요청 범위(request-scoped) 메모리 — Nginx 의 ngx_pool_t, Apache 의 APR pool 이 같은 생각입니다.

실무에선 이렇게 — 스택 사용량 예산 🔴 실무

컴파일러에게 스택을 물어보기

# 함수마다 스택 사용량을 .su 파일로 — CMake 에서:
#   target_compile_options(fw_warnings INTERFACE -fstack-usage -Wstack-usage=512)

# 결과 (build/.../proto.c.su)
proto.c:88:10:proto_handle      96      static
json.c:210:13:json_parse_value  64      dynamic,bounded
log.c:41:6:log_printf           312     static     ← 큰 지역 버퍼
crc.c:12:10:crc32_update        16      static

# 가장 큰 것 20개
find build -name '*.su' -exec cat {} + | sort -t$'\t' -k2 -rn | head -20

# GCC 10+ — 호출 그래프까지 (최악 경로 계산의 재료)
-fcallgraph-info=su
.su 는 함수 하나의 사용량입니다. 실제 필요량은 가장 깊은 호출 경로의 합 + 인터럽트 중첩분입니다. 재귀(MISRA Rule 17.2 금지)와 함수 포인터 호출이 있으면 정적 계산이 끊기므로, 그 지점은 실측(high water mark)으로 메웁니다.

예산표 — 리뷰 때 보는 숫자

스택정적 최악 경로실측 최대할당
MSP (ISR)1.1 KB0.9 KB4 KB
sensor 태스크0.6 KB0.5 KB1 KB
proto 태스크2.3 KB2.0 KB4 KB
wifi 태스크모름 (SDK)5.1 KB8 KB
/* 실측 — FreeRTOS 태스크는 r10 의 uxTaskGetStackHighWaterMark().
   MSP 는 부팅 때 패턴을 칠해 두고 나중에 남은 패턴을 셉니다 */
extern uint32_t _estack, _Min_Stack_Size;   /* .ld 의 심볼 */
#define STACK_PAINT 0xC0FFEE11u
할당 = 실측 최대 × 1.5~2 가 흔한 규칙입니다. 표를 CI 산출물로 남겨 두면 "이번 PR 에서 proto 태스크 최악 경로가 0.8 KB 늘었다"가 리뷰에 보입니다. PC 쪽에서는 같은 문제를 ASan · Valgrind 로 잡습니다(디버깅 · 테스트 탭).
정리 — 펌웨어 메모리는 정적 할당이 기본이고, 초기화 이후 malloc 은 poison · wrap 으로 막습니다. 동적이 필요하면 고정 크기 풀(O(1) · 단편화 0) 이나 아레나(통째로 비움), 소유권은 _create · _init · _get · _take 이름 규칙으로, 스택은 -fstack-usage + 실측 예산표로 관리합니다.
ARCH 11

이벤트 기반 구조 — 플래그 슈퍼루프에서 이벤트 큐 + 상태 머신으로

펌웨어가 커지면 main 루프는 if (flag_rx && !busy && mode == 3) 같은 조건의 숲이 됩니다. 상태가 변수 여러 개에 흩어져 암묵적으로 존재하기 때문입니다. 처방은 둘 — 일어난 일을 이벤트로 만들어 큐 하나에 모으고, 상태를 명시적인 상태 머신으로 만드는 것. 상태 머신을 구현하는 세 가지 방법(switch · 표 · 계층형)과, 이것을 태스크와 합친 액티브 오브젝트까지 봅니다. RTOS 위의 3태스크 설계는 FreeRTOS 탭 r13 에서 이어집니다.

이벤트 루프 한 바퀴 — 큐가 비면 잠들고, 이벤트가 오면 한 개씩 끝까지 잠듦 __WFI() · 전류 최소 ISR evq_post() evq_get() 꺼내서 전달 상태 머신 dispatch(e) 끝까지 실행 run-to-completion 큐에 남은 게 있으면 잠들지 않고 계속 핸들러 안에서 기다리지 않습니다 — 기다림은 "타이머 이벤트"로 바꿉니다
이벤트 루프 — 잠듦 → ISR → 꺼내기 → 상태 머신이 돌고, 한 이벤트는 다음 이벤트 전에 끝까지 처리됩니다.

❌ 플래그 슈퍼루프 → ✅ 이벤트 큐

❌ 상태가 플래그 6개에 흩어짐
volatile bool f_rx, f_btn, f_tick;
static bool joining, online; static uint8_t retry;
for (;;) {
    if (f_tick && joining && !online && retry < 5) {...}
    if (f_rx && joining) { ... online = true; }
    if (f_btn && online && !joining) {...}
    /* f_tick 을 누가 지우지? 두 if 가 같이 보면? */
}
/* core/src/evq.c — 이벤트 큐 (ISR 여럿 + 메인 하나) */
typedef struct { uint16_t sig; uint16_t arg; } event_t;
#define EVQ_SIZE 32u                  /* 2의 거듭제곱 */
static event_t  s_q[EVQ_SIZE];
static uint32_t s_head, s_tail;       /* CMSIS 의 __disable_irq 는 컴파일러
                                         메모리 장벽도 겸합니다 */
bool evq_post_from_isr(uint16_t sig, uint16_t arg) {
    uint32_t pm = irq_save();         /* ISR 중첩 대비 — k10 과 같은 함수 */
    bool ok = (s_head - s_tail) < EVQ_SIZE;
    if (ok) {
        s_q[s_head & (EVQ_SIZE - 1u)] = (event_t){ sig, arg };
        s_head++;
    }
    irq_restore(pm);
    return ok;                        /* false = 넘침 → 카운터 증가 */
}

bool evq_get(event_t *out) {
    uint32_t pm = irq_save();
    bool ok = (s_head != s_tail);
    if (ok) { *out = s_q[s_tail & (EVQ_SIZE - 1u)]; s_tail++; }
    irq_restore(pm);
    return ok;
}
bool evq_is_empty(void) { return s_head == s_tail; }   /* 인터럽트 꺼진 채 호출 */

메인 루프 — "확인 후 잠들기"의 경쟁을 막는 법

void app_run(void) {
    event_t e;
    for (;;) {
        __disable_irq();
        if (evq_is_empty()) {
            __WFI();          /* PRIMASK=1 이어도 대기 중인 인터럽트가 깨움 */
        }
        __enable_irq();       /* ← 여기서 밀려 있던 ISR 이 실행됨 */

        while (evq_get(&e)) {
            wifi_fsm_dispatch(&s_wifi, &e);   /* 관심 있는 신호만 처리 */
            ui_fsm_dispatch(&s_ui, &e);
        }
    }
}
인터럽트를 끄지 않고 if (empty) __WFI(); 를 쓰면 — 비었음을 확인한 직후 · WFI 직전에 ISR 이 이벤트를 넣는 경우, 그 이벤트는 다음 인터럽트가 올 때까지 처리되지 않습니다(다음 인터럽트가 1초 뒤 틱이면 1초 지연). Cortex-M 의 WFI 는 PRIMASK 로 막힌 인터럽트에도 깨어나므로, 끄고 확인하고 잠드는 것이 정석입니다.
규칙이유
핸들러에서 블로킹 금지다른 이벤트가 전부 밀림 — 기다림은 타이머 이벤트로
한 이벤트는 끝까지처리 중간에 상태가 바뀌지 않음 → 락이 필요 없음
ISR 은 이벤트만 넣음판단은 상태 머신이, ISR 은 짧게
큐 넘침은 계측evq_post 가 false 면 카운트 · 크기 조정 근거

상태 머신 구현 3가지 — switch · 표 · 계층형(HSM) 🟡 중급

Wi-Fi 모뎀 연결 — 점선 상자는 부모 상태 POWERED POWERED (부모) OFF BOOTING AT 응답 대기 JOINING AP 접속 중 ONLINE 정상 BACKOFF 1 → 2 → 4 s POWER_ON READY JOINED TIMEOUT TIMEOUT 대기 끝 LINK_LOST POWER_OFF 자식 어디서든 부모가 공통 전이를 맡으면 자식 넷에 같은 줄을 네 번 쓰지 않아도 됩니다
상태 기계 — 전이 조건을 화살표에 적고, 공통 전이(POWER_OFF)는 부모 상태가 맡습니다. 이것이 계층형 상태 머신(HSM)입니다.

② 표 기반 — 전이가 "데이터"가 됨

typedef enum { WS_OFF, WS_BOOTING, WS_JOINING, WS_ONLINE, WS_BACKOFF,
               WS_POWERED,            /* 부모 — 현재 상태가 되지는 않음 */
               WS_NONE } wifi_state_t;
enum { EV_POWER_ON = 1, EV_READY, EV_JOINED, EV_TIMEOUT, EV_LINK_LOST, EV_POWER_OFF };

typedef struct wifi wifi_t;
typedef struct {
    wifi_state_t from; uint16_t sig; wifi_state_t to; void (*action)(wifi_t *w);
} wifi_tr_t;

static const wifi_tr_t k_tr[] = {          /* 그림의 화살표 = 표의 한 줄 */
    { WS_OFF,     EV_POWER_ON,  WS_BOOTING, act_power_on },
    { WS_BOOTING, EV_READY,     WS_JOINING, act_join     },
    { WS_BOOTING, EV_TIMEOUT,   WS_BACKOFF, act_backoff  },
    { WS_JOINING, EV_JOINED,    WS_ONLINE,  act_online   },
    { WS_JOINING, EV_TIMEOUT,   WS_BACKOFF, act_backoff  },
    { WS_ONLINE,  EV_LINK_LOST, WS_BACKOFF, act_backoff  },
    { WS_BACKOFF, EV_TIMEOUT,   WS_JOINING, act_join     },
    { WS_POWERED, EV_POWER_OFF, WS_OFF,     act_power_off },  /* ③ 부모 전이 */
};

static const wifi_state_t k_parent[WS_NONE] = {
    [WS_OFF] = WS_NONE,        [WS_BOOTING] = WS_POWERED, [WS_JOINING] = WS_POWERED,
    [WS_ONLINE] = WS_POWERED,  [WS_BACKOFF] = WS_POWERED, [WS_POWERED] = WS_NONE,
};

void wifi_fsm_dispatch(wifi_t *w, const event_t *e) {
    /* ③ HSM: 자기 상태에서 못 찾으면 부모에게 물어봄 */
    for (wifi_state_t s = w->state; s != WS_NONE; s = k_parent[s]) {
        for (size_t i = 0u; i < (sizeof k_tr / sizeof k_tr[0]); i++) {
            if ((k_tr[i].from == s) && (k_tr[i].sig == e->sig)) {
                if (k_tr[i].action != NULL) { k_tr[i].action(w); }
                w->state = k_tr[i].to;
                return;
            }
        }
    }
    /* 어디에도 없음 = 이 상태에선 무시하는 이벤트 */
}
/* 입력 POWER_ON, READY, TIMEOUT, TIMEOUT, JOINED, READY, LINK_LOST, TIMEOUT, JOINED
   → BOOTING JOINING BACKOFF JOINING ONLINE ONLINE(무시) BACKOFF JOINING ONLINE
   ONLINE · BACKOFF 에서 POWER_OFF → OFF (부모가 처리) — node 로 같은 표를 돌려 확인 */

① switch · 세 방법 비교 · 액티브 오브젝트

/* ① switch — 상태 5개 이하면 가장 읽기 쉬움 */
switch (w->state) {
case WS_JOINING:
    if (e->sig == EV_JOINED)  { act_online(w);  w->state = WS_ONLINE;  }
    else if (e->sig == EV_TIMEOUT) { act_backoff(w); w->state = WS_BACKOFF; }
    else { /* 무시 */ }
    break;
/* ... 상태마다 case ... */
default: FW_ASSERT(false); break;
}
① switch② 표③ HSM
맞는 규모상태 ~55~20, 평평함공통 전이 많음
장점단순 · 디버깅 쉬움그림 ↔ 표 1:1 · 리뷰 쉬움중복 제거 · 진입/퇴장 동작
단점커지면 중첩 지옥조건(guard) 표현이 어색구현 복잡 → 프레임워크
액티브 오브젝트(Active Object) — "상태 머신 + 전용 이벤트 큐 + 전용 실행 흐름(태스크)"을 한 덩어리로 묶은 것입니다. 객체끼리는 이벤트만 주고받고 상태를 공유하지 않으므로 뮤텍스가 거의 필요 없습니다. 서버의 액터 모델(Akka · Erlang)과 같은 생각입니다. FreeRTOS 위에서는 "태스크 하나 + xQueueReceive 루프 + dispatch" 로 직접 만들고(r13), 프레임워크로는 Quantum Leaps 의 QP/C(HSM 진입/퇴장 · 시간 이벤트 · 발행/구독, 그래픽 모델링 도구 QM)가 대표적입니다.
위 HSM 은 "부모에게 묻기"만 구현한 최소판입니다. 실제 HSM 은 전이할 때 떠나는 상태들의 exit · 들어가는 상태들의 entry 동작을 올바른 순서로 실행해야 하고(예: POWER_OFF 때 BACKOFF 의 타이머 해제), 이 부분이 직접 짜기 가장 까다롭습니다 — 여기서부터는 프레임워크를 쓰는 편이 낫습니다.
정리 — 플래그 대신 이벤트 큐, 흩어진 변수 대신 명시적 상태 머신. 작으면 switch, 커지면 전이 표, 공통 전이가 많으면 HSM, 여럿이 동시에 돌면 액티브 오브젝트로 갑니다. 핸들러는 기다리지 않고 한 이벤트를 끝까지 처리합니다.
ARCH 12

코딩 표준과 정적 분석 — MISRA · CERT · BARR-C 를 도구로 굴리기

C 는 컴파일이 되면서도 정의되지 않은 동작을 하는 코드를 너무 쉽게 허락합니다. 그래서 C 의 코딩 표준은 "예쁘게 쓰기"보다 위험한 기능을 쓰지 않기로 약속하는 문서에 가깝습니다. 자동차 · 의료 · 산업 장비는 MISRA 준수가 계약 조건이고, 보안이 중요하면 CERT C, 일반 펌웨어 팀은 BARR-C 를 출발점으로 삼습니다. 그리고 어떤 표준이든 사람이 아니라 정적 분석기가 검사해야 지켜집니다.

무엇을 고를까 — 색이 진할수록 그 관점에 강합니다 기능 안전 보안 스타일 · 가독성 자동 검사 도입 비용 MISRA C:2023 핵심 일부 거의 없음 상용 도구 큼 CERT C 간접 핵심 거의 없음 tidy cert-* 중간 BARR-C:2018 버그 예방 약함 핵심 포매터 일부 작음 tidy · format 기본 약함 흔한 버그 자동 정리 전부 자동 거의 0 실무 조합 — 기본 도구는 무조건 켜고, 위 셋 중 하나를 "계약"이 고릅니다 안전 인증이면 MISRA, 네트워크에 붙으면 CERT 를 더하고, 나머지는 BARR-C 로 시작
표준 비교 매트릭스 — 표준은 목적이 다릅니다. 섞어 쓰되 "무엇을 위해 이 규칙을 지키나"를 적어 둡니다.

MISRA C:2023 — 구조와 대표 규칙

구분뜻
Mandatory예외 불가 — 편차(deviation)도 안 됨
Required지켜야 함 — 근거를 문서화한 편차로만 예외
Advisory권고 — 팀이 따르지 않기로 정할 수 있음
Rule / DirRule 은 코드만 보고 판정, Directive 는 설계 · 문서까지 봐야 판정
규칙분류내용 (요지)
9.1Mandatory자동 변수를 값 넣기 전에 읽지 않는다
17.3Mandatory함수를 암묵적으로 선언하지 않는다
10.3Required더 좁은 · 다른 범주의 타입에 대입 금지
14.4Requiredif · while 조건은 본질적으로 Boolean
15.6Requiredif · 반복문 몸체는 항상 중괄호
16.4Requiredswitch 에는 default 가 있어야
17.2Required재귀 금지 (직접 · 간접)
17.7Requiredvoid 아닌 반환값은 사용해야
21.3Requiredmalloc · free 사용 금지 (k10)
15.5Advisory함수 출구는 끝에 하나
MISRA C:2023 은 2012 판(3판)에 개정 1~4 를 합친 통합본으로 C11 · C17 까지 다룹니다. 규칙 원문은 유료 문서라 여기서는 요지만 적었습니다. 계약서에 적힌 판(edition)을 따르세요 — 고객이 "MISRA C:2012 + AMD2" 처럼 정확히 지정하는 경우가 많습니다.

규칙이 막아 주는 실제 버그

❌ 컴파일은 되는 코드
uint8_t  len = frame_len(f);
uint16_t crc;
if (len) crc = crc16(f, len);        /* 14.4 · 15.6 */
uart_write(UART_ID_MODEM, f, len, 10);   /* 17.7 */
uint8_t total = len + HDR_SIZE;      /* 10.3 — 250+8=2 */
send_crc(crc);      /* 9.1 — len==0 이면 쓰레기 */
switch (state) {
case ST_IDLE: start(); break;
case ST_RUN:  step();  break;
}                                    /* 16.4 */
✅ 규칙대로
uint8_t  len = frame_len(f);
uint16_t crc = 0u;
if (len != 0u) {
    crc = crc16(f, len);
}
if (uart_write(UART_ID_MODEM, f, len, 10u) != FW_OK) {
    stats.tx_err++;
}
uint16_t total = (uint16_t)len + HDR_SIZE;
send_crc(crc);
switch (state) {
case ST_IDLE: start(); break;
case ST_RUN:  step();  break;
default:      FW_ASSERT(false); break;
}
uint8_t total = len + HDR_SIZE; 는 len = 250, HDR_SIZE = 8 이면 int 로 258 을 계산한 뒤 uint8_t 에 잘려 2가 됩니다 — 경고 없이. -Wconversion 과 MISRA 10.3 이 정확히 이 줄을 잡습니다. CERT C 의 INT31-C(정수 변환으로 데이터 손실 금지)도 같은 문제입니다.

정적 분석기 설정 — clang-tidy · cppcheck · 포매터 🟡 중급

.clang-tidy — 저장소 루트

Checks: >
  -*,
  bugprone-*,
  cert-*,
  clang-analyzer-*,
  misc-*,
  readability-braces-around-statements,
  readability-implicit-bool-conversion,
  readability-magic-numbers,
  -bugprone-easily-swappable-parameters,
  -misc-include-cleaner
WarningsAsErrors: 'bugprone-*,cert-*,clang-analyzer-*'
HeaderFilterRegex: '.*/(core|drivers|services|products)/.*'
CheckOptions:
  - key:   readability-magic-numbers.IgnoredIntegerValues
    value: '0;1;2;255'
# ★ 호스트 빌드의 compile_commands 로 돌리면 크로스 플래그 문제가 없습니다
run-clang-tidy -p build/host-test -j 8 'core/|drivers/|services/'

# 타깃 빌드로 돌려야 할 때 — clang 에게 ARM 대상임을 알려 줌
clang-tidy -p build/target-debug src/x.c \
  --extra-arg=--target=arm-none-eabi \
  --extra-arg=-isystem/opt/arm-gnu-toolchain/arm-none-eabi/include
clang-analyzer-* 는 경로를 따라가는 분석(NULL 역참조 · 해제 후 사용 · 초기화 안 된 값)이라 가장 값진 경고를 냅니다. cert-* 는 CERT C 규칙 일부(cert-err33-c 반환값 검사 등)를 직접 검사합니다.

cppcheck · 편차 기록 · 기준선(ratchet)

cppcheck --project=build/host-test/compile_commands.json \
  --enable=warning,style,performance,portability \
  --inline-suppr --error-exitcode=1 \
  --suppressions-list=tools/cppcheck-suppress.txt \
  -i third_party -i boards \
  --checkers-report=build/cppcheck-checkers.txt

# MISRA 애드온 — 오픈소스판은 규칙 일부만, 전체는 Premium · 상용 도구
cppcheck --addon=misra --project=build/host-test/compile_commands.json
/* 편차는 코드 옆에 ID 를 남기고, 근거는 별도 문서(DEV-007)에 */
bme280_t *d = (bme280_t *)self;   /* cppcheck-suppress misra-c2012-11.3 ; DEV-007 */
도입 단계CI 규칙
1. 기준선지금 경고 수를 파일로 저장 — 늘면 실패
2. 새 코드변경된 파일은 경고 0
3. 정리모듈 단위로 0 을 만들고 -Werror 전환
포맷은 c25 의 .clang-format 을 쓰고, CI 에서 clang-format --dry-run --Werror $(git ls-files '*.c' '*.h') 로 검사합니다. 포맷 논쟁은 설정 파일 하나로 끝내고, 리뷰는 설계 · 버그에만 씁니다.

실무에선 이렇게 — 도구가 못 잡는 것을 보는 리뷰 체크리스트 🔴 실무

펌웨어 코드 리뷰 체크리스트

항목무엇을 보나도구
ISR 공유 변수volatile · 임계 구역 · 읽기-수정-쓰기 원자성△
타임아웃portMAX_DELAY · while(!flag) 무한 대기×
레이어 위반새 #include 가 아래→위인가 (k04)○ CMake
에러 경로실패 시 자원 해제 · 상태 원복 (k09)△
소유권_get 으로 받은 것을 저장 · 해제하지 않나 (k10)×
정수 폭uint8_t 누적 · 부호 섞임 · 시프트 폭○
새 #if기능 스위치인가, 다른 .c 여야 하나 (k07)×
메모리 예산RAM · FLASH · 스택 증가량 (k06 · k10)○ CI
로그ISR 안 로그 · 고빈도 경로 로그×
테스트로직 변경에 호스트 테스트가 따라왔나△

CERT C — 보안 쪽 대표 규칙

규칙내용
INT30-C부호 없는 정수 연산이 감싸 돌지(wrap) 않게
INT32-C부호 있는 정수 넘침 금지 (정의되지 않은 동작)
ARR30-C배열 범위 밖 포인터 · 인덱스 금지
STR31-C문자열 저장 공간에 NUL 까지 들어가게
MEM30-C해제한 메모리 접근 금지
ERR33-C표준 라이브러리 에러 반환을 검사
/* 외부에서 온 길이 — 네트워크 · UART 입력은 언제나 의심 */
fw_err_t proto_copy_payload(const uint8_t *frame, size_t frame_len,
                            uint8_t *dst, size_t dst_cap, size_t *out_n) {
    if (frame_len < HDR_SIZE) { return FW_ERR_PARAM; }
    size_t n = frame[1];                       /* 공격자가 정하는 값 */
    if ((n > (frame_len - HDR_SIZE)) || (n > dst_cap)) {   /* ARR30-C */
        return FW_ERR_PARAM;
    }
    memcpy(dst, &frame[HDR_SIZE], n);
    *out_n = n;
    return FW_OK;
}
"도구 ×" 항목이 리뷰어의 일입니다. 도구가 잡는 것(포맷 · 정수 폭 · 반환값)을 사람이 지적하고 있다면 그건 CI 설정이 빠진 것입니다.
정리 — 표준은 목적으로 고릅니다 — 안전은 MISRA, 보안은 CERT, 기본은 BARR-C. 무엇을 고르든 clang-tidy · cppcheck · clang-format 을 CI 에 걸고 기준선부터 시작해 늘지 않게 하며, 사람은 도구가 못 잡는 타임아웃 · 소유권 · 레이어를 봅니다.
ARCH 13

실제 대형 C 프로젝트에서 배우기 — 커널 · Zephyr · ESP-IDF · SQLite · Redis, 그리고 버전과 릴리스

이 탭의 규칙들은 새로 지어낸 것이 아니라 수십 년 살아남은 C 프로젝트들이 이미 쓰는 방식입니다. 각 프로젝트에서 구조 아이디어 1~2개씩만 가져옵니다 — 통째로 따라 할 필요는 없고, 우리 규모에 맞는 조각을 고르면 됩니다. 마지막으로 코드 구조를 완성하는 마지막 조각, "지금 기기에서 도는 것이 정확히 무엇인가"에 답하는 버전 · 빌드 정보 · 변경 로그를 정리합니다. 유명 C 프로젝트의 규모와 C 를 고른 이유는 C 실전 탭 c26 에 있습니다.

여섯 프로젝트에서 하나씩 — 화살표는 아이디어가 들어오는 방향 우리 플랫폼 가져올 아이디어 리눅스 커널 Kconfig · 서브시스템 드라이버 모델 Zephyr devicetree + Kconfig ESP-IDF components REQUIRES SQLite amalgamation 한 파일로 배포 Redis ae 이벤트 루프 백엔드 선택 FreeRTOS portable/ 로 칩 의존 격리 공통점 — 모두 "규칙 + 그 규칙을 강제하는 빌드 도구"를 함께 가지고 있습니다
허브 — 여섯 프로젝트의 공통 주제는 설정 · 하드웨어 기술 · 의존 선언을 빌드 도구로 강제하는 것입니다.

프로젝트별 — 가져올 것 한 줄씩

프로젝트구조의 핵심우리에게 가져올 것
리눅스 커널서브시스템마다 관리자(MAINTAINERS) · 드라이버 모델(bus/device/driver, probe) · Kconfig + Kbuildobj-$(CONFIG_X) += x.o — 설정이 곧 링크 목록 (k07) · CODEOWNERS (k05)
Zephyr하드웨어를 devicetree 로 기술 → 빌드가 C 매크로로 변환 · 드라이버 API 는 함수 포인터 구조체보드 차이를 코드가 아니라 데이터(overlay)로 · vtable 드라이버 (k08)
ESP-IDF모든 것이 components/ — 컴포넌트마다 REQUIRES / PRIV_REQUIRESCMake 의 PUBLIC / PRIVATE 와 같은 생각 (k06)
SQLite100여 개 파일로 개발, sqlite3.c 한 파일(amalgamation)로 배포 · 브랜치 커버리지 100% 테스트배포 형태와 개발 형태를 분리 · 테스트 문화
Redis단일 스레드 ae 이벤트 루프 · epoll/kqueue/select 를 컴파일 타임에 선택작은 인터페이스 + 백엔드 교체 (k07) · run-to-completion (k11)
FreeRTOS커널은 공통 C, 칩 의존은 portable/[컴파일러]/[아키텍처]/ 두 파일HAL 계층 · 이식 경계를 폴더로 (k04 · k05)
읽는 순서 추천 — Redis ae.c(이벤트 루프, 수백 줄) → FreeRTOS portable/GCC/ARM_CM4F/port.c(이식 경계) → Zephyr samples/basic/blinky(devicetree 가 코드에 들어오는 방식) → ESP-IDF 컴포넌트 하나의 CMakeLists.txt. 각각 한 시간이면 구조가 보입니다.

Redis ae.c — 컴파일 타임 백엔드 선택 (실제 구조)

/* ae.c 맨 위 — 플랫폼에서 가장 좋은 구현 하나를 .c 째로 포함 */
#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
    #ifdef HAVE_EPOLL
    #include "ae_epoll.c"
    #else
        #ifdef HAVE_KQUEUE
        #include "ae_kqueue.c"
        #else
        #include "ae_select.c"
        #endif
    #endif
#endif
/* 각 파일은 같은 static 함수 묶음을 구현:
   aeApiCreate · aeApiAddEvent · aeApiDelEvent · aeApiPoll ... */
/* Zephyr — 보드가 바뀌어도 이 코드는 그대로. 핀은 devicetree 가 앎 */
#include <zephyr/kernel.h>
#include <zephyr/drivers/gpio.h>

static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(DT_ALIAS(led0), gpios);

int main(void) {
    if (!gpio_is_ready_dt(&led)) { return 0; }
    gpio_pin_configure_dt(&led, GPIO_OUTPUT_ACTIVE);
    for (;;) { gpio_pin_toggle_dt(&led); k_msleep(500); }
}
# ESP-IDF — components/bme280/CMakeLists.txt
idf_component_register(SRCS "bme280.c"
                       INCLUDE_DIRS "include"
                       REQUIRES driver           # 공개 헤더가 씀 (= PUBLIC)
                       PRIV_REQUIRES esp_timer)  # .c 만 씀   (= PRIVATE)
Redis 방식은 "인터페이스 = 같은 이름의 static 함수 묶음, 선택 = #include 하나"라 간접 호출 비용이 0입니다 — 실행 중에 바꿀 필요가 없을 때 vtable(k08)보다 낫습니다.

버전 · 빌드 정보 — "이 기기에서 도는 것이 정확히 무엇인가" 🟡 중급

버전 문자열 한 줄 — 부분마다 뜻이 있습니다 (SemVer 2.0 형식) thermo_pro 2.4.1-rc.2+g3fa9c2e.dirty board=acme_rev_c · CI #4812 제품 이름 .bin 파일명에도 MAJOR 2 호환 깨짐 MINOR 4 기능 추가 PATCH 1 버그 수정 -rc.2 출시 전 후보 +g3fa9c2e 빌드한 커밋 .dirty 커밋 안 된 변경 양산 금지 펌웨어의 "호환"은 설정 포맷 · 통신 프로토콜 · 부트로더 인터페이스입니다
버전 해부도 — 숫자 셋 + 커밋 + dirty 가 있으면 현장 기기 하나에서 정확한 소스까지 거슬러 갈 수 있습니다.

빌드 정보를 바이너리에 — 고정 위치 구조체

# cmake/fw_version.cmake
find_package(Git QUIET)
execute_process(
    COMMAND ${GIT_EXECUTABLE} describe --tags --dirty --always --match "v[0-9]*"
    WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
    OUTPUT_VARIABLE FW_GIT_DESCRIBE OUTPUT_STRIP_TRAILING_WHITESPACE)
# 예: v2.4.1-rc.2-5-g3fa9c2e-dirty (태그 뒤 5커밋)
configure_file(${CMAKE_SOURCE_DIR}/cmake/fw_version.c.in
               ${CMAKE_BINARY_DIR}/gen/fw_version.c @ONLY)
target_sources(core PRIVATE ${CMAKE_BINARY_DIR}/gen/fw_version.c)
/* cmake/fw_version.c.in — ★ 헤더가 아니라 .c 하나에만 넣음:
   커밋이 바뀌어도 이 파일 하나만 다시 컴파일 (k01 · k03) */
#include "core/fw_version.h"

__attribute__((section(".fw_info"), used))
const fw_info_t g_fw_info = {
    .magic   = 0x46574931u,                 /* 'FWI1' */
    .major   = @PROJECT_VERSION_MAJOR@,
    .minor   = @PROJECT_VERSION_MINOR@,
    .patch   = @PROJECT_VERSION_PATCH@,
    .git     = "@FW_GIT_DESCRIBE@",
    .board   = "@FW_BOARD@",
    .product = "@FW_PRODUCT@",
};
/* .ld — 벡터 테이블 바로 뒤에 두면 오프셋이 고정되어
   부트로더 · OTA 도구 · 생산 검사기가 .bin 에서 바로 읽음 */
.fw_info :
{
  . = ALIGN(4);
  KEEP(*(.fw_info))
} >FLASH
execute_process 는 configure 때만 돕니다 — 로컬에서 커밋한 뒤 build 만 하면 옛 해시가 남습니다. CI 는 매번 새로 configure 하니 문제없고, 로컬까지 정확하게 하려면 add_custom_target(ALL) + cmake -P 스크립트로 매 빌드 갱신합니다. KEEP 이 없으면 --gc-sections 가 아무도 참조하지 않는 이 구조체를 지웁니다.

재현 가능한 빌드 · 변경 로그 · 릴리스 묶음

❌ 빌드할 때마다 바이너리가 달라짐
const char build[] = __DATE__ " " __TIME__;
/* 절대 경로도 __FILE__ · 디버그 정보에 박힘 */
✅ 같은 커밋 = 같은 바이트
export SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)
# CMake: -ffile-prefix-map=${CMAKE_SOURCE_DIR}=.
# CHANGELOG.md — Keep a Changelog 형식
## [2.4.1] - 2026-09-30
### Fixed
- BME280 NACK 재시도 뒤에도 경보 LED 가 켜지던 문제 (#412)
### Changed
- persist_cfg_t version 3 → 4 (reserved 7 B, 부팅 때 자동 이전)

## [2.4.0] - 2026-09-02
### Added
- thermo_pro: Wi-Fi 재접속 지수 백오프 (1 → 2 → 4 s)
릴리스 묶음왜 필요한가
.bin + SHA-256공장 · OTA 로 내보내는 실물과 무결성
.elf + .map현장 크래시 주소(k09 의 lr)를 함수로 역추적
git 태그 v2.4.1소스 위치 고정 · git describe 의 기준
CHANGELOG 항목현장 · QA 가 "무엇이 바뀌었나"를 코드 없이 읽음
펌웨어 서명 · 롤백 방지 · A/B 슬롯 같은 배포 쪽 이야기는 양산 · 펌웨어 운영 탭에서 이어집니다. 여기서는 "모든 바이너리가 자기 출신을 말할 수 있게" 하는 것까지가 구조의 일입니다.
정리 — 커널에서 설정 = 링크 목록, Zephyr 에서 하드웨어 = 데이터, ESP-IDF 에서 의존 선언, SQLite 에서 배포 형태 분리, Redis 에서 작은 인터페이스 + 백엔드 선택, FreeRTOS 에서 이식 경계를 가져옵니다. 그리고 모든 바이너리는 SemVer + 커밋 + dirty + 보드를 고정 위치에 품고 나갑니다.
STM32F446RE (Nucleo-F446RE) · HAL · UART · RS-485 · I²C · SPI · CAN · USB CDC · 프레이밍 · COBS · CRC-16 · BME280 · SSD1306 · W25Q · Modbus RTU · 로직 분석기

선 몇 가닥으로
바이트를 믿을 수 있게
주고받는 법

펌웨어가 하는 일의 절반은 다른 칩과 이야기하는 것입니다. 센서를 읽고, 화면을 그리고, Flash 에 쓰고, PC 나 PLC 와 명령을 주고받습니다. 서버 개발자에게 익숙한 TCP 는 순서 · 재전송 · 경계를 커널이 대신 해 주지만, MCU 의 UART 는 바이트가 흘러올 뿐입니다 — 메시지가 어디서 끝나는지, 중간에 깨졌는지, 버퍼가 넘쳤는지는 전부 내 코드가 판단합니다. 이 탭은 Nucleo-F446RE 와 HAL 을 기준으로 UART · RS-485 · I²C · SPI · CAN · USB 를 전기 신호부터 드라이버 코드까지 내려가 보고, COBS · CRC 로 프레임을 설계하고, BME280 · SSD1306 · W25Q · Modbus 장치를 버스와 분리된 드라이버로 붙입니다. 이 탭을 마치면 데이터시트만 보고 새 센서 드라이버를 쓰고, 통신이 안 될 때 로직 분석기로 원인을 집어낼 수 있게 됩니다.

13SECTIONS
7버스 · 프로토콜
F446RE기준 칩 · HAL
0x29B1CRC 직접 검증
IO 01

통신 지도 — UART · RS-485 · I²C · SPI · CAN · USB · 1-Wire, 무엇을 언제 쓰나

MCU 의 통신 방식은 일곱 가지 정도면 현업의 95% 를 덮습니다. 모두 "바이트를 옮긴다"는 점은 같고, 다른 것은 선 몇 가닥 · 얼마나 빠르게 · 얼마나 멀리 · 몇 대를 · 누가 말을 시작하나 다섯 가지뿐입니다. 서버로 치면 프로세스 간 통신 수단을 고르는 일과 같습니다 — 같은 머신이면 공유 메모리(SPI), 같은 랙이면 유닉스 소켓(I²C), 건물 반대편이면 TCP(RS-485 · CAN), 사람 PC 와 연결이면 HTTP(USB)처럼 거리와 규모가 답을 정합니다.

통신 일곱 가지 — 속도 · 거리 칸은 진할수록 큽니다 (F446RE 기준) 선 수 최고 속도 거리 연결 모양 대표 용도 UART 2 + GND 수 Mbps ~1 m (TTL) 1:1 디버그 · 모듈 RS-485 2 (A/B) 10 Mbps 1200 m 버스 · 32대 산업 · Modbus I²C 2 (SDA/SCL) 0.1 ~ 1 Mbps 보드 안 버스 · 주소 센서 · EEPROM SPI 3 + CS×N 45 Mbps 보드 안 1:N · CS 선 Flash · 화면 CAN 2 (H/L) 1 · FD 5 Mbps 40 m~1 km 멀티마스터 자동차 · 로봇 USB FS 4 12 Mbps 5 m 호스트 중심 PC 연결 1-Wire 1 + GND 15.4 kbps 수십 m 버스 · 64비트 ID 온도 센서 빠른 것(SPI)은 가깝고, 먼 것(RS-485 · CAN)은 차동 신호를 씁니다 — 둘 다 가진 것은 없습니다 속도는 대표값입니다. 실제 한계는 배선 길이 · 정전용량 · 상대 칩이 정합니다 (SPI 45 Mbps 는 F446 SPI1 의 상한)
매트릭스 — 속도와 거리는 맞바꾸는 관계입니다. 멀리 가려면 차동 신호(RS-485 · CAN), 빠르려면 짧은 배선(SPI).

모든 버스를 가르는 세 가지 질문

질문갈래예
클럭선이 있나동기식 — 마스터가 박자를 줌
비동기식 — 양쪽이 속도를 약속
I²C · SPI
UART · CAN · RS-485
누가 말하나전이중 — 동시에 양방향
반이중 — 번갈아
단방향
UART · SPI
I²C · RS-485 · CAN
SPI 화면(MISO 없음)
전압을 어떻게 재나단일 종단 — GND 기준 한 선
차동 — 두 선의 차이
UART(TTL) · I²C · SPI
RS-485 · CAN · USB
차동 신호(differential)는 같은 정보를 두 선에 반대로 실어 보내고 받는 쪽은 두 선의 차이만 봅니다. 모터가 만든 잡음은 두 선에 똑같이 실리므로 빼면 사라집니다. 그래서 공장 · 자동차처럼 시끄럽고 먼 곳은 거의 예외 없이 차동입니다.

1 KB 를 옮기는 데 걸리는 시간 (오버헤드 포함, 손계산)

UART 115200 8N1  : 바이트당 10비트 → 11 520 B/s
                   1024 B → 88.9 ms
I²C 400 kHz      : 바이트당 9비트(ACK 포함), 주소·레지스터 2바이트 추가
                   (1024 + 2) × 9 = 9 234 비트 → 23.1 ms
SPI 22.5 MHz     : 바이트당 8비트, 오버헤드 거의 없음
                   1024 × 8 / 22.5 M → 0.36 ms
CAN 500 kbit/s   : 표준 프레임 8바이트 = 111비트(스터핑 제외)
                   128 프레임 × 111 / 500 k → 28.4 ms
USB FS CDC       : 벌크 최대 19 패킷 × 64 B / 1 ms ≈ 1.2 MB/s
                   → 1 ms 안팎 (호스트 상황에 따라)
1-Wire 15.4 kbps : 1024 × 8 / 15 400 → 532 ms
숫자의 자릿수가 곧 용도입니다. OLED 한 화면(1 KB) 을 I²C 로 보내면 23 ms(초당 43장이 한계), SPI 면 1 ms 아래입니다. p10 에서 이 계산이 화면 갱신 설계를 정합니다.
무엇으로 붙일까 — 마름모가 질문, 초록 상자가 답입니다 PC 와 USB 로 직접 붙이나? 예 USB CDC 가상 COM (p08) PC 에선 시리얼 포트로 보임 아니오 10 m 넘는 배선 · 잡음 많은 현장? 예 RS-485 (p02 · p12) · CAN (p07) 묻고 답하면 485 · 누구나 말하면 CAN 아니오 화면 · Flash 처럼 초당 MB 급? 예 SPI (p06) 수십 MHz · 장치마다 CS 핀 하나 아니오 센서 여러 개를 핀 2개로? 예 I²C (p05) 주소로 골라 부름 · 400 kHz 아니오 UART (p02) 1:1 · 가장 단순 거리가 먼저입니다 — I²C 를 3 m 끌고 가면 속도를 낮춰도 깨집니다 모듈(GPS · BLE · LTE)은 대개 UART 로 고정 — 고를 일이 없습니다
결정 흐름도 — PC 연결 → 거리 → 속도 → 핀 수 순서로 물으면 대부분 한 갈래로 떨어집니다.

기준 보드에서 — F446RE 에 들어 있는 통신 블록과 핀 🟡 중급

STM32F446RE 통신 주변장치

블록개수 · 버스메모
USART / UARTUSART1·6 (APB2 90 MHz)
USART2·3 · UART4·5 (APB1 45 MHz)
USART2 = Nucleo ST-LINK 가상 COM (PA2/PA3)
I²CI2C1~3 + FMPI2C1FMPI2C 는 1 MHz(Fm+) 지원
SPISPI1~4SPI1·4 는 APB2 → ÷2 로 45 MHz
CANbxCAN 1·2 (클래식 CAN)CAN-FD 없음 → G4 · H7 의 FDCAN
USBOTG_FS · OTG_HSHS 는 외부 ULPI PHY 없으면 FS 로 동작
그 밖QUADSPI · SAI · SPDIF-RX · HDMI-CECQSPI 는 외부 Flash 를 메모리처럼
핀이 겹칩니다. I2C1 의 Arduino 핀(PB8/PB9 = D15/D14)은 CAN1 대체 핀과 같고, CAN1 의 다른 자리(PA11/PA12)는 USB 와 같습니다. 통신을 셋 이상 쓰면 CubeMX 에서 핀 배치부터 그려 보고 회로를 확정하세요(하드웨어 기초 탭 h06).

비유로 한 번에 — 서버 개발자의 언어로

버스비유그래서 생기는 일
UART전화 한 통 (1:1, 끊김 없는 스트림)메시지 경계가 없다 → 프레이밍(p03)
RS-485무전기 — 한 번에 한 명만송신 후 말 끝났다를 정확히(p02)
I²C회의실에서 이름 부르기대답 없으면 NACK · 한 명이 버스를 붙잡으면 전원 정지(p05)
SPI손가락으로 지목(CS)하고 빠르게지목선이 장치 수만큼 필요
CAN우선순위 있는 단체 채팅방ID 가 곧 우선순위 · 충돌해도 데이터 안 깨짐(p07)
USBHTTP 폴링 — 호스트만 묻는다장치는 물어볼 때까지 못 보냄(p08)
/* 같은 HAL 모양 — 버스마다 함수 이름만 바뀝니다 */
HAL_UART_Transmit(&huart2, buf, n, 100);
HAL_SPI_TransmitReceive(&hspi1, tx, rx, n, 100);
HAL_I2C_Mem_Read(&hi2c1, 0x76 << 1, 0xD0,
                 I2C_MEMADD_SIZE_8BIT, &id, 1, 100);
HAL_CAN_AddTxMessage(&hcan1, &hdr, data, &mailbox);
/* 반환값 HAL_OK · HAL_ERROR · HAL_BUSY · HAL_TIMEOUT
   — 전부 확인하는 습관이 드라이버 품질의 절반입니다 */
신호 레벨부터 맞추세요. F446 의 I/O 는 3.3 V 입니다. 5 V 아두이노 모듈 · RS-232 포트(±12 V) · 12 V 센서를 핀에 바로 꽂으면 운이 좋으면 통신이 안 되고, 나쁘면 칩이 죽습니다. 5 V 허용(FT) 핀인지는 데이터시트 핀 표의 I/O 구조 열에서 핀마다 확인하고, 아니면 레벨 시프터(BSS138 양방향 · TXS0108 등)를 둡니다. I²C 처럼 오픈드레인인 버스는 풀업을 3.3 V 로 두는 것만으로 해결되는 경우가 많습니다.
정리 — 거리 먼저(멀면 RS-485 · CAN), 그다음 속도(빠르면 SPI), 그다음 핀 수(적으면 I²C), 나머지는 UART. PC 와 직접이면 USB CDC. 고른 뒤에는 핀 충돌과 전압 레벨을 CubeMX · 데이터시트로 확인합니다.
IO 02

UART 깊이 — 프레임 · 오차 허용 · 흐름 제어, 그리고 RS-485 방향 전환

UART 의 기초(시작 비트 · 데이터 · 정지 비트, 보레이트 레지스터)는 AVR 탭 a07 과 STM32 탭 m07 에서 다뤘습니다. 여기서는 현장에서 실제로 부딪히는 것들을 봅니다 — 보레이트가 몇 % 틀려도 되나, 패리티를 켜면 왜 HAL 설정이 9비트가 되나, RTS/CTS 는 누가 누구에게 거는 신호인가, RS-232 포트를 핀에 꽂으면 왜 안 되나, 그리고 RS-485 에서 송신을 끝낸 뒤 언제 수신으로 돌아서야 하나. 마지막 것은 산업 현장 통신 장애의 단골 원인입니다.

'A'(0x41) 를 8E1 로 — 점은 수신기가 비트를 읽는 순간입니다 시작 에지 1 0 S d0 d1 d2 d3 d4 d5 d6 d7 P stop 오차 0 % 빠름 +3 % 빠름 +6 % 오차는 비트마다 쌓입니다 — 10.5 비트 뒤 정지 비트에서 반 비트를 넘으면 FE +6 % 의 마지막 샘플(빨강)은 정지 비트가 아니라 P 구간(0)에 떨어집니다. 이론 한계는 0.5 / 10.5 ≈ 4.8 % (11비트 프레임)
파형 · 샘플 위치 — 수신기는 시작 에지 하나로만 박자를 맞춥니다. 그래서 오차가 프레임 끝으로 갈수록 커집니다.

프레임 형식과 HAL 설정 — 패리티의 함정

표기비트 수HAL WordLengthParity
8N11+8+1 = 10UART_WORDLENGTH_8BNONE
8E11+8+1+1 = 11UART_WORDLENGTH_9BEVEN
7E11+7+1+1 = 10UART_WORDLENGTH_8BEVEN
8N21+8+2 = 11UART_WORDLENGTH_8B + STOPBITS_2NONE
/* Modbus RTU 표준 형식 8E1 — STM32 의 WordLength 는 "패리티 포함" 길이 */
huart1.Init.BaudRate     = 19200;
huart1.Init.WordLength   = UART_WORDLENGTH_9B;   /* 8 데이터 + 1 패리티 */
huart1.Init.StopBits     = UART_STOPBITS_1;
huart1.Init.Parity       = UART_PARITY_EVEN;
huart1.Init.Mode         = UART_MODE_TX_RX;
huart1.Init.HwFlowCtl    = UART_HWCONTROL_NONE;
huart1.Init.OverSampling = UART_OVERSAMPLING_16;
HAL_UART_Init(&huart1);

/* 8B + EVEN 으로 두면 데이터가 7비트가 되어 0x80 이상이 깨집니다.
   수신 바이트에서도 패리티 비트를 떼야 합니다 — 9B+패리티면 HAL 이 처리 */
"PC 에선 되는데 장비에 붙이면 반만 맞는다"의 단골 원인입니다. 7비트로 줄어든 데이터는 ASCII 문자는 멀쩡해 보여서 바이너리 프로토콜(Modbus)에서야 드러납니다. 패리티를 켰으면 WordLength 를 한 칸 올렸는지 먼저 보세요.

보레이트 오차 — 얼마까지 괜찮나

오차 예산 = 송신 측 오차 + 수신 측 오차 (방향이 반대면 더해짐)

이론: 시작 에지 기준, 마지막(정지) 비트 가운데까지 N − 0.5 비트
      그 사이 누적 오차가 반 비트를 넘으면 안 됨
      10비트(8N1) : 0.5 / 9.5  = 5.26 %
      11비트(8E1) : 0.5 / 10.5 = 4.76 %

실제(RM0390, 16배 오버샘플링 · 8비트 · 분주 소수부 0): 수신 허용 3.75 %
      (ONEBIT=1 이면 4.375 %, 소수부가 있으면 약 3.3 %)
      — 샘플 3번 다수결 · 에지 검출 지연 몫을 뺀 값

F446 USART2 (APB1 45 MHz) 115200 → BRR 0x187, 오차 −0.096 %
F446 USART1 (APB2 90 MHz) 115200 → BRR 0x30D, 오차 +0.032 %
HSI(내부 RC, ±1 % 공장 보정)만 쓰면 온도에 따라 수 % 까지 → 외부 크리스털
실무 기준은 양쪽 합 ±2 % 이내입니다. 상대가 USB-UART 브리지면 그쪽 오차는 거의 0 이지만, 상대도 MCU 내부 RC 면 각자 1~2 % 가 겹칩니다. 높은 보레이트(921600 · 1 M)일수록 정수 분주가 어려워 오차가 커지니 쓰려는 보레이트마다 BRR 오차를 계산해 두세요(m07 의 식).

흐름 제어 · 전압 레벨 — RTS/CTS 와 RS-232 · TTL 🟡 중급

RTS/CTS — "나 지금 받을 수 있어" 신호

  MCU                       모뎀 · 다른 장치
  TX   ─────────────────────▶  RX
  RX   ◀─────────────────────  TX
  RTS  ─────────────────────▶  CTS    MCU 가 "보내도 돼"(LOW = 준비됨)
  CTS  ◀─────────────────────  RTS    상대가 "보내도 돼"
  GND  ──────────────────────  GND

규칙: 내 CTS 가 HIGH 면 내 송신기는 다음 바이트를 멈춘다 (하드웨어가)
      내 수신 버퍼가 차면 내 RTS 를 HIGH 로 올린다
/* F446 USART2: CTS = PA0, RTS = PA1 (AF7) */
huart2.Init.HwFlowCtl = UART_HWCONTROL_RTS_CTS;
STM32 USART 의 RTS 는 수신 데이터 레지스터 한 칸이 찼는지만 봅니다 — 내 링 버퍼(p04)가 찼는지는 모릅니다. 큰 버퍼를 쓰면서 상대를 멈추고 싶으면 RTS 를 GPIO 로 직접 올리고 내리는 편이 정확합니다. 셀룰러 · Wi-Fi 모듈은 고속에서 흐름 제어를 사실상 필수로 요구합니다.

같은 "시리얼"이라도 전압이 다릅니다

종류1 (mark · 쉼)0 (space)거리
TTL 3.3 V3.3 V0 V보드 사이 1 m 안팎
RS-232−3 ~ −15 V+3 ~ +15 V15 m 정도
RS-485A−B > +200 mVA−B < −200 mV1200 m (MAX485 류 표기)
RS-232 는 극성이 반대이고 음전압입니다. PC 뒤의 DB9 포트나 산업 장비의 RS-232 를 MCU 핀에 바로 물리면 −12 V 가 들어가 핀이 손상됩니다. 사이에 MAX3232(3.3 V 용 레벨 변환 · 반전) 같은 트랜시버를 둡니다.
USB-UART 브리지특징
ST-LINK VCPNucleo 에 내장 · USART2 에 연결 · 별도 장비 불필요
CP2102 · CP2104드라이버 안정 · RTS/CTS 지원
FT232R · FT2232업계 기준 · 정품 확인 필요
CH340 · CH343저가 모듈 대부분 · Windows 10 이후 드라이버 자동

RS-485 반이중 — 말이 끝난 순간을 정확히 알기 🔴 실무

RS-485 는 두 선(A/B)을 여러 장치가 공유하는 차동 버스입니다. 한 번에 한 장치만 선을 구동할 수 있어서, MCU 는 MAX3485 같은 트랜시버의 DE(Driver Enable) 핀을 올려 "지금 내가 말한다"를 알리고, 다 말하면 내려서 듣는 모드로 돌아갑니다. 문제는 언제 내리느냐입니다. HAL 의 "송신 완료"가 정확히 무엇을 뜻하는지 알아야 합니다.

RS-485 송신 끝 — DE 는 TXE 가 아니라 TC 에서 내립니다 TX 선 N−2 N−1 N (마지막) TXE 마지막 바이트를 DR 에 쓴 직후 TXE=1 (아직 송신 중) TC 정지 비트까지 끝 → TC=1 DE 틀림 TXE 에서 내림 → N 의 뒷부분이 버스에 안 나감 DE 맞음 TC 뒤에 내림 → 수신 모드 상대 응답 응답 1 2 한 프레임 = 86.8 µs (115200) F4 의 USART 에는 하드웨어 DE 가 없어 GPIO 로 직접 — TC 가 유일한 신호입니다 F0 · F7 · L4 · G0 · G4 · H7 의 USART 는 DEM 비트로 DE 를 하드웨어가 처리 (HAL_RS485Ex_Init)
타임라인 — TXE = 레지스터가 비었다, TC = 선이 비었다. 방향 전환은 언제나 뒤의 것입니다.

rs485.c — DMA 송신 + TC 콜백에서 DE 내리기

#include "main.h"

extern UART_HandleTypeDef huart1;       /* PA9 TX · PA10 RX · 8E1 */
#define DE_PORT GPIOA
#define DE_PIN  GPIO_PIN_8              /* 트랜시버 DE 와 /RE 를 묶음 */

static volatile uint8_t tx_busy;

HAL_StatusTypeDef rs485_send(const uint8_t *p, uint16_t n)
{
    if (tx_busy) return HAL_BUSY;
    tx_busy = 1;
    HAL_GPIO_WritePin(DE_PORT, DE_PIN, GPIO_PIN_SET);      /* 말하기 시작 */
    HAL_StatusTypeDef st = HAL_UART_Transmit_DMA(&huart1, p, n);
    if (st != HAL_OK) {                                     /* 실패하면 원상복구 */
        HAL_GPIO_WritePin(DE_PORT, DE_PIN, GPIO_PIN_RESET);
        tx_busy = 0;
    }
    return st;
}

/* F4 HAL: DMA 가 마지막 바이트를 DR 에 넣으면 TCIE 를 켜고,
   TC(정지 비트까지 끝)에서 이 콜백을 부릅니다. _IT 송신도 같습니다. */
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart == &huart1) {
        HAL_GPIO_WritePin(DE_PORT, DE_PIN, GPIO_PIN_RESET);  /* 듣기로 */
        tx_busy = 0;
    }
}
버퍼 수명 — p 는 콜백이 올 때까지 살아 있어야 합니다(m07). 그리고 DE 를 내리기 전에 상대가 응답을 시작하면 첫 바이트를 놓치므로, Modbus 같은 프로토콜은 응답 전 최소 대기(p12 의 3.5 문자 시간)를 규정해 둡니다.

배선 · 회로 체크리스트

항목규칙
종단 저항버스 양 끝 두 곳에만 120 Ω. 전원 끈 채 A–B 사이를 재면 약 60 Ω
바이어스(fail-safe)아무도 말하지 않을 때 A>B 로 고정 — 풀업 · 풀다운 수백 Ω~수 kΩ, 또는 fail-safe 내장 트랜시버
배선꼬임선(twisted pair) · 데이지 체인(가지 치지 않기) · GND 도 함께
트랜시버3.3 V: MAX3485 · SP3485 · THVD 계열 / 5 V: MAX485
/RE 핀DE 와 묶으면 송신 중 수신 꺼짐. 따로 두고 켜 두면 내 송신이 에코로 들어옴 → 충돌 감지에 활용
A/B 이름제조사마다 반대인 경우가 있음 — 안 되면 먼저 A/B 를 바꿔 본다
"자동 방향 전환" 모듈(DE 핀이 없는 저가 RS-485 모듈)은 TX 신호로 방향을 추정합니다. 저속에선 되지만 보레이트에 맞춘 RC 시정수가 들어 있어 보레이트를 바꾸면 깨집니다. 제품에는 DE 를 MCU 가 직접 쥐는 회로를 씁니다.
정리 — 오차 예산은 양쪽 합 ±2 % 이내, 패리티를 켜면 WordLength 를 9B 로, RS-232 는 반드시 트랜시버를 거칩니다. RS-485 의 DE 는 TC(선이 빔)에서 내리고 — F4 HAL 에선 HAL_UART_TxCpltCallback 이 그 시점입니다.
IO 03

프레이밍 · 프로토콜 설계 — 바이트 스트림을 메시지로 자르기, COBS 와 CRC-16

UART 는 TCP 처럼 경계 없는 바이트 스트림입니다. "온도=25" 를 보냈다고 상대가 한 번에 받는다는 보장이 없고, 잡음 한 번이면 비트가 뒤집히며, 전원이 켜지는 순간 메시지 한가운데부터 듣기 시작할 수도 있습니다. 그래서 모든 통신 프로토콜은 세 가지를 정합니다 — 어디서 시작해 어디서 끝나나(프레이밍), 깨졌는지 어떻게 아나(CRC), 엇갈렸을 때 어떻게 다시 맞추나(재동기화). 서버에서 길이 접두 + 체크섬을 붙인 TCP 프로토콜을 직접 짜 본 적이 있다면 같은 문제입니다.

네 가지 프레이밍 방식

방식모양장점약점
구분자TEMP=25\n사람이 읽음 · 터미널로 디버깅데이터에 \n 이 오면 끝 · 바이너리 불가
길이 접두AA len … crc오버헤드 고정 · 단순길이 바이트가 깨지면 재동기화가 어렵다
SLIP… C0 (C0 → DB DC)RFC 1055 · 구현 10줄최악 크기 2배(전부 C0 · DB 일 때)
COBS… 00 (0 이 프레임 안에 없음)최악 오버헤드 254 바이트당 1 · 0x00 만 찾으면 경계인코딩에 버퍼 한 벌
사람이 볼 명령줄은 구분자, 기계끼리의 바이너리는 COBS 나 길이 접두 + 타임아웃이 현업의 정석입니다. Modbus RTU(p12)는 "3.5 문자 시간 침묵"이라는 시간 구분자를 씁니다 — 이것도 프레이밍 방식의 하나입니다.

SLIP — 가장 짧은 바이트 스터핑

#define SLIP_END     0xC0
#define SLIP_ESC     0xDB
#define SLIP_ESC_END 0xDC
#define SLIP_ESC_ESC 0xDD

/* put() 은 한 바이트를 UART 송신 큐에 넣는 함수 */
void slip_send(const uint8_t *p, size_t n, void (*put)(uint8_t))
{
    put(SLIP_END);                    /* 앞에도 END — 앞선 쓰레기를 끊음 */
    for (size_t i = 0; i < n; i++) {
        if (p[i] == SLIP_END)      { put(SLIP_ESC); put(SLIP_ESC_END); }
        else if (p[i] == SLIP_ESC) { put(SLIP_ESC); put(SLIP_ESC_ESC); }
        else                       put(p[i]);
    }
    put(SLIP_END);
}
/* 예: 11 C0 22  →  C0 11 DB DC 22 C0 */
수신 측은 END 를 만나면 프레임을 닫고, ESC 다음 바이트를 원래 값으로 되돌립니다. 데이터에 0xC0 이 많으면 크기가 들쭉날쭉해져 버퍼 크기를 최악(2배)으로 잡아야 한다는 것이 COBS 가 나온 이유입니다.

COBS — 0 을 지우고 "다음 0 까지 거리"를 적는다 🟡 중급

COBS 인코딩 — 11 22 00 33 → 03 11 22 02 33 | 00 원본 4 B 11 22 00 33 COBS 6 B 03 11 22 02 33 00 03 → 3칸 뒤가 0 02 → 2칸 뒤가 끝 붉은 칸 = 원래의 0 (지워짐) 빗금선 = 바이트가 한 칸씩 밀림 붉은 채움 = 코드 바이트 "다음 0 까지 몇 칸" 초록 = 프레임 끝 구분자 디코더는 코드 바이트를 따라 점프하며 0 을 되살립니다 — 프레임 안에 0 이 없으니 경계가 확실 0 이 없는 254 바이트마다 코드 바이트 0xFF 가 하나 더 붙습니다 — 최악 오버헤드 ⌈n/254⌉ 바이트
메모리 변환 — 코드 바이트가 사슬처럼 다음 0 을 가리킵니다. 그래서 수신 측은 0x00 하나로 경계를 찾습니다.

cobs.c — 인코더 · 디코더 (표준 COBS)

#include <stddef.h>
#include <stdint.h>

/* dst 는 n + n/254 + 2 바이트 이상. 반환 = 인코딩 길이(끝 0x00 미포함) */
size_t cobs_encode(const uint8_t *src, size_t n, uint8_t *dst)
{
    size_t  w = 1, code_i = 0;          /* code_i: 코드 바이트를 적을 자리 */
    uint8_t code = 1;
    for (size_t r = 0; r < n; r++) {
        if (src[r] != 0) { dst[w++] = src[r]; code++; }
        if (src[r] == 0 || code == 0xFF) {   /* 0 을 만났거나 254개 꽉 참 */
            dst[code_i] = code;
            code   = 1;
            code_i = w;
            if (src[r] == 0 || r + 1 < n) w++;  /* 마지막 꽉 찬 블록 뒤엔 자리 안 만듦 */
        }
    }
    dst[code_i] = code;
    return w;
}

/* src = 끝 0x00 을 뺀 프레임. 반환 = 디코딩 길이, 형식 오류면 -1 */
int cobs_decode(const uint8_t *src, size_t n, uint8_t *dst)
{
    size_t r = 0, w = 0;
    while (r < n) {
        uint8_t code = src[r++];
        if (code == 0) return -1;             /* 프레임 안의 0 은 있을 수 없음 */
        for (uint8_t i = 1; i < code; i++) {
            if (r >= n || src[r] == 0) return -1;
            dst[w++] = src[r++];
        }
        if (code != 0xFF && r < n) dst[w++] = 0;
    }
    return (int)w;
}

손으로 따라가 보기 — 검증한 예

원본                     COBS (끝의 00 별도)
00                    →  01 01
00 00                 →  01 01 01
00 11 00              →  01 02 11 01
11 22 00 33           →  03 11 22 02 33
11 22 33 44           →  05 11 22 33 44
11 00 00 00           →  02 11 01 01 01
01 02 … FE (254 B)    →  FF 01 … FE           (255 B)
01 02 … FF (255 B)    →  FF 01 … FE 02 FF     (257 B)

11 22 00 33 인코딩 따라가기:
 r=0 11: dst[1]=11 code=2
 r=1 22: dst[2]=22 code=3
 r=2 00: dst[0]=03, code_i=3, w=4
 r=3 33: dst[4]=33 code=2
 끝   : dst[3]=02  → 03 11 22 02 33 (w=5)
위 표는 이 C 코드와 같은 알고리즘을 node 로 옮겨 계산한 값이며, 길이 0~700 의 무작위 입력 5만 개로 왕복(인코딩 → 디코딩) 일치 · 결과에 0 없음 · 오버헤드 ⌈n/254⌉ 이내를 확인했습니다.
수신 측 사용법 — 링 버퍼에서 바이트를 꺼내 0x00 이 나올 때까지 모으고, 나오면 cobs_decode → CRC 검사. 중간에 잡음으로 0x00 이 생기거나 빠져도 다음 0x00 에서 자동으로 다시 맞춰집니다.

CRC-16/CCITT — 표 한 장으로 바이트당 한 번 🟡 중급

crc16.c — 비트 방식 · 표 방식 (같은 결과)

/* CRC-16/CCITT-FALSE: 다항식 0x1021, 초깃값 0xFFFF, 반사 없음, 최종 XOR 없음 */
uint16_t crc16_bitwise(const uint8_t *p, size_t n)
{
    uint16_t crc = 0xFFFF;
    while (n--) {
        crc ^= (uint16_t)(*p++ << 8);
        for (int k = 0; k < 8; k++)
            crc = (crc & 0x8000u) ? (uint16_t)((crc << 1) ^ 0x1021u)
                                  : (uint16_t)(crc << 1);
    }
    return crc;
}

static uint16_t crc_tab[256];          /* 512 B — const 로 만들면 Flash 에 */

void crc16_init(void)
{
    for (uint16_t i = 0; i < 256; i++) {
        uint16_t c = (uint16_t)(i << 8);
        for (int k = 0; k < 8; k++)
            c = (c & 0x8000u) ? (uint16_t)((c << 1) ^ 0x1021u) : (uint16_t)(c << 1);
        crc_tab[i] = c;               /* tab[0..3] = 0000 1021 2042 3063 */
    }
}

/* 스트리밍용 — 파서가 바이트 하나 받을 때마다 부름 */
static inline uint16_t crc16_update(uint16_t crc, uint8_t b)
{
    return (uint16_t)((crc << 8) ^ crc_tab[((crc >> 8) ^ b) & 0xFFu]);
}

/* 자가 시험 — 부팅 때 한 번 돌려 두면 표가 깨진 것도 잡힘 */
int crc16_selftest(void)
{
    static const uint8_t v[] = "123456789";
    uint16_t c = 0xFFFF;
    for (size_t i = 0; i < 9; i++) c = crc16_update(c, v[i]);
    return c == 0x29B1 && crc16_bitwise(v, 9) == 0x29B1;
}

"CRC-16" 은 하나가 아닙니다 — 테스트 벡터로 구분

이름다항식 · 초깃값 · 반사"123456789"
CCITT-FALSE0x1021 · 0xFFFF · 없음0x29B1
XMODEM0x1021 · 0x0000 · 없음0x31C3
MODBUS0x8005(반사 0xA001) · 0xFFFF · 있음0x4B37
상대 문서에 "CRC-16" 이라고만 적혀 있으면 반드시 이 벡터 값을 물어보세요. 다항식이 같아도 초깃값 · 반사 · 최종 XOR 이 다르면 전혀 다른 값이 나옵니다. 위 세 값은 모두 node 로 계산해 확인했습니다(비트 방식 = 표 방식).
왜 CRC 인가 (체크섬 합계 대비)
· 합계: 바이트 두 개가 서로 바뀌면 못 잡음 (01 02 ↔ 02 01)
· CRC-16: 16비트 이하 연속 오류(burst) 전부 · 홀수 비트 오류 전부
          그 밖은 1/65536 확률로만 놓침
· 속도: 표 방식은 바이트당 시프트 1 + 표 조회 1 + XOR 2
        STM32F4 의 내장 CRC 장치는 CRC-32(0x04C11DB7) 고정 — 16비트는 소프트웨어로

상태 머신 파서 — 길이 접두 프레임 + 타임아웃 재동기화 🔴 실무

프레임 AA | LEN | CMD | DATA×LEN | CRC_HI CRC_LO — 원이 상태입니다 SOF LEN CMD DATA CRC_H CRC_L 0xAA ≤ 64 len>0 n=len 그 외 무시 n < len len == 0 CRC 일치 → on_frame() · 불일치 → err_crc++ · 어느 쪽이든 SOF 어느 상태든 바이트 간격 > 5 ms 면 SOF 로 — 잘린 프레임이 다음 프레임을 삼키지 않게 LEN 이 64 를 넘으면 그 바이트가 0xAA 일 때만 LEN 에 남고, 아니면 SOF 로 (길이 바이트 오류 방어)
상태 기계 — 타임아웃이 재동기화의 안전망입니다. 없으면 잘린 프레임 하나가 다음 정상 프레임까지 깨뜨립니다.

frame_parser.c

#define SOF          0xAAu
#define MAX_PAYLOAD  64u
#define GAP_MS       5u

typedef enum { ST_SOF, ST_LEN, ST_CMD, ST_DATA, ST_CRC_HI, ST_CRC_LO } pstate_t;

typedef struct {
    pstate_t st;
    uint8_t  len, cmd, n;
    uint8_t  buf[MAX_PAYLOAD];
    uint16_t crc, rx_crc;
    uint32_t last_ms;
    uint32_t err_crc, err_len, err_timeout;   /* 진단 카운터 — 꼭 남기세요 */
} parser_t;

extern void on_frame(uint8_t cmd, const uint8_t *p, uint8_t len);

void parser_feed(parser_t *p, uint8_t b, uint32_t now_ms)
{
    if (p->st != ST_SOF && (uint32_t)(now_ms - p->last_ms) > GAP_MS) {
        p->err_timeout++;
        p->st = ST_SOF;                       /* 끊긴 프레임 버림 */
    }
    p->last_ms = now_ms;

    switch (p->st) {
    case ST_SOF:
        if (b == SOF) p->st = ST_LEN;
        break;
    case ST_LEN:
        if (b > MAX_PAYLOAD) { p->err_len++; p->st = (b == SOF) ? ST_LEN : ST_SOF; break; }
        p->len = b; p->n = 0;
        p->crc = crc16_update(0xFFFF, b);     /* CRC 범위 = LEN..DATA */
        p->st  = ST_CMD;
        break;
    case ST_CMD:
        p->cmd = b;
        p->crc = crc16_update(p->crc, b);
        p->st  = p->len ? ST_DATA : ST_CRC_HI;
        break;
    case ST_DATA:
        p->buf[p->n++] = b;
        p->crc = crc16_update(p->crc, b);
        if (p->n == p->len) p->st = ST_CRC_HI;
        break;
    case ST_CRC_HI:
        p->rx_crc = (uint16_t)(b << 8);
        p->st = ST_CRC_LO;
        break;
    case ST_CRC_LO:
        p->rx_crc |= b;
        if (p->rx_crc == p->crc) on_frame(p->cmd, p->buf, p->len);
        else                     p->err_crc++;
        p->st = ST_SOF;
        break;
    }
}
/* 예: AA 03 10 01 02 03 85 48  → cmd 0x10, payload 01 02 03 */

시뮬레이션으로 확인한 것

같은 로직을 node 로 옮겨 30만 프레임 주입
· 5 % 는 중간에 잘림, 5 % 는 비트 1개 뒤집힘
· 5 % 는 앞에 쓰레기 3바이트 (간격 없이)
· 프레임 사이 간격 6 ms (> GAP_MS)

결과: 정상 프레임 270 009 중 269 982 수신 (99.99 %)
      깨진 프레임이 통과한 경우 0
      놓친 27개 = 쓰레기 속 0xAA 가 정상 프레임 앞부분을 삼킨 경우

같은 실험을 "간격 없이" 붙여 보내면
      잘린 프레임 1000개 → 뒤따르는 정상 프레임 1000개도 손실
마지막 줄이 이 절의 핵심입니다. 길이 접두 방식은 잘린 프레임 뒤에 곧바로 다음 프레임이 오면, 파서가 다음 프레임의 바이트를 앞 프레임의 데이터로 먹어 버립니다. 막는 방법은 셋 — ① 송신 측이 프레임 사이에 간격을 둔다(Modbus 방식) ② 타임아웃 ③ CRC 실패 시 버퍼를 한 바이트 뒤부터 다시 훑는다(가장 견고하지만 버퍼 필요). COBS 는 구조적으로 이 문제가 없습니다.
프로토콜에 꼭 넣을 것 — 버전 바이트(나중에 형식을 바꿀 수 있게) · 시퀀스 번호(유실 · 중복 탐지) · 응답에 요청 번호 되돌려 주기 · 최대 길이 명시. 서버 API 를 버전 없이 냈다가 고생한 경험과 똑같습니다.
정리 — 바이너리 메시지는 COBS + CRC-16(가장 견고) 또는 길이 접두 + CRC + 타임아웃(가장 흔함)으로 자릅니다. CRC 는 이름이 아니라 "123456789" 테스트 벡터로 맞추고, 파서는 상태 머신 + 진단 카운터로 짭니다.
IO 04

수신 버퍼 전략 — ISR 링 · DMA 순환 · 오버런 정책, 그리고 크기 계산

바이트는 하드웨어의 박자로 도착하고, 해석은 내 코드의 박자로 일어납니다. 둘 사이에 완충지대가 없으면 하나라도 늦는 순간 데이터가 사라집니다. 서버로 치면 소켓 수신 버퍼와 애플리케이션 사이의 큐입니다. 링 버퍼 자체의 원리(가득 참 판정 · 단일 생산자-단일 소비자)는 🥞 스택 · 큐 · 덱 탭과 C 실전 탭 c22 에서 다루고, 여기서는 통신 드라이버에 붙일 때의 결정 — 누가 채우나(ISR 인가 DMA 인가), 넘치면 무엇을 버리나, 몇 바이트면 충분한가 — 를 봅니다.

uart_rx_isr.c — 바이트 인터럽트 + 링 (LL 없이 레지스터로)

#include "main.h"

#define RX_SZ 512u                         /* 2의 거듭제곱 → % 대신 & */
static uint8_t           rx_buf[RX_SZ];
static volatile uint16_t rx_head;          /* ISR 만 씀 (생산자) */
static volatile uint16_t rx_tail;          /* main 만 씀 (소비자) */
volatile uint32_t rx_drop, rx_ore, rx_fe;  /* 진단 카운터 */

void USART2_IRQHandler(void)               /* RXNEIE 만 켠 상태 */
{
    uint32_t sr = USART2->SR;
    if (sr & (USART_SR_RXNE | USART_SR_ORE)) {
        uint8_t b = (uint8_t)USART2->DR;   /* SR 다음 DR 읽기 = RXNE·ORE·FE·NE 해제 */
        if (sr & USART_SR_ORE) rx_ore++;   /* 이 바이트 앞에서 최소 1바이트 잃음 */
        if (sr & (USART_SR_FE | USART_SR_NE)) { rx_fe++; return; }  /* 믿을 수 없는 바이트 */

        uint16_t h    = rx_head;
        uint16_t next = (uint16_t)((h + 1u) & (RX_SZ - 1u));
        if (next == rx_tail) { rx_drop++; return; }  /* 가득 참 → 새 것 버림 */
        rx_buf[h] = b;
        __COMPILER_BARRIER();              /* 데이터를 쓴 뒤에 head 를 공개 */
        rx_head = next;
    }
}

int rx_pop(uint8_t *out)                   /* main 루프 · 태스크에서 */
{
    uint16_t t = rx_tail;
    if (t == rx_head) return 0;
    *out = rx_buf[t];
    __COMPILER_BARRIER();                  /* 읽은 뒤에 tail 을 넘김 */
    rx_tail = (uint16_t)((t + 1u) & (RX_SZ - 1u));
    return 1;
}
head 는 ISR 만, tail 은 main 만 쓰므로 잠금이 필요 없습니다. Cortex-M4 에서 정렬된 16비트 읽기 · 쓰기는 한 명령이라 찢어지지 않고, __COMPILER_BARRIER()(CMSIS)는 컴파일러가 "버퍼 쓰기"와 "head 갱신" 순서를 바꾸지 못하게 막습니다. 한 칸을 비워 두는 방식이라 실제 용량은 RX_SZ − 1 = 511 바이트입니다.

수신 오류 플래그 — 무엇을 뜻하고 어떻게 지우나 (F4 USART)

플래그뜻흔한 원인
ORE오버런 — DR 을 안 읽었는데 다음 바이트가 다 들어옴ISR 지연 · 인터럽트 꺼진 구간이 1 프레임(86.8 µs) 초과
FE프레이밍 — 정지 비트 자리에 0보레이트 불일치 · 선 끊김 · 브레이크 신호
NE잡음 — 한 비트 안 세 샘플이 서로 다름긴 배선 · GND 불량
PE패리티 불일치잡음 · 형식 불일치(8N1 ↔ 8E1)
/* F4: 네 플래그 모두 "SR 읽고 → DR 읽기" 순서로 지워집니다.
   ORE 를 안 지우면 RXNEIE 인터럽트가 계속 들어와 main 이 멈춥니다.
   (ORE 인터럽트는 RXNEIE 로 함께 켜짐) */
__HAL_UART_CLEAR_OREFLAG(&huart2);   /* 같은 순서를 매크로로 */

/* HAL 의 _IT · _DMA 수신은 ORE 가 나면 수신을 "중단"하고
   HAL_UART_ErrorCallback 을 부릅니다 → 거기서 다시 걸어야 함 (m07) */
void HAL_UART_ErrorCallback(UART_HandleTypeDef *h)
{
    if (h->ErrorCode & HAL_UART_ERROR_ORE) rx_ore++;
    if (h->RxState == HAL_UART_STATE_READY)
        uart_rx_start(h);                  /* m07 의 재시작 함수 */
}
ORE 는 링이 가득 찬 것과 다릅니다. ORE 는 ISR 이 제때 못 돈 것(하드웨어 한 칸 버퍼가 넘침)이고, rx_drop 은 main 이 제때 못 비운 것입니다. 카운터를 나눠 두면 원인이 우선순위 문제인지 처리 속도 문제인지 바로 보입니다.

DMA 순환 버퍼 — 에러 없이 조용히 덮어쓴다 🟡 중급

115200 보 이상 · 연속 스트림이면 바이트마다 인터럽트를 받는 대신 DMA 순환 모드 + IDLE(STM32 탭 m07 의 HAL_UARTEx_ReceiveToIdle_DMA)가 표준입니다. CPU 부담은 거의 0 이 되지만 대가가 있습니다 — DMA 는 소비자가 어디까지 읽었는지 모르므로 처리가 밀리면 경고 없이 덮어씁니다. ORE 같은 플래그도 서지 않습니다. 아래 그림은 그 순간을 누적 바이트로 그린 것입니다.

누적 바이트 — 쓰기(청록)가 처리(호박) + 256 을 넘으면 데이터가 사라집니다 고우선 ISR 이 26 ms 붙잡음 0 128 256 384 0 10 20 30 40 ms DMA 쓰기 — 11.52 B/ms, 쉬지 않음 처리 위치 — 6 ms 부터 멈춤 한계 = 처리 위치 + 256 28.2 ms · 덮어쓰기 플래그 없음 256 B ÷ 11.52 B/ms = 22.2 ms — 이보다 오래 못 비우면 반드시 잃습니다 HT · TC 콜백이 절반마다 깨워 주지만, 그 콜백조차 못 도는 상황(더 높은 IRQ · 인터럽트 금지 구간)이면 소용없습니다
라인 차트 — 두 선의 간격 = 미처리 데이터. 간격이 버퍼 크기를 넘는 순간이 오버런이고, DMA 는 그걸 알려 주지 않습니다.

DMA 오버런을 "보이게" 만드는 세 가지

/* ① 콜백 사이 간격을 재서 한계(22 ms)에 가까우면 경고 */
static uint32_t last_evt_cyc, worst_gap_cyc;
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *h, uint16_t Size)
{
    uint32_t now = DWT->CYCCNT;                 /* 180 MHz 사이클 카운터 */
    uint32_t gap = now - last_evt_cyc;
    if (gap > worst_gap_cyc) worst_gap_cyc = gap;   /* 최악값을 로그로 */
    last_evt_cyc = now;
    /* … m07 의 [old, pos) 복사 … */
}

/* ② 프로토콜에 시퀀스 번호 — 1, 2, 4 처럼 건너뛰면 유실 */
if ((uint8_t)(seq - last_seq) != 1u) seq_gap++;
last_seq = seq;

/* ③ 앱 링이 넘치면 그 사실을 파서에게 알림 → 진행 중 프레임 폐기 */
if (ring_free(&app_rx) < n) { app_rx_overflow = 1; return; }
DMA 버퍼 → 앱 링으로 한 번 더 복사하는 구조(m07)에서는 넘침이 두 곳에서 생깁니다 — DMA 버퍼(콜백이 못 돎)와 앱 링(main 이 못 비움). 앞의 것은 ①로, 뒤의 것은 ③으로 잡습니다.

ISR 1바이트 vs DMA 순환 — 언제 무엇을

ISR + 링DMA 순환 + IDLE
인터럽트 수 (115200)초당 11 520메시지당 1~3
넘침 감지ORE · drop 카운터직접 만들어야 함
바이트별 시각있음 (타임아웃 파서에 유리)없음 — 이벤트 단위
메시지 끝 감지파서가 판단IDLE 이 알려 줌
어울리는 곳9600~115200 명령 · Modbus921600 이상 · GPS · 연속 스트림
CPU 부하로 환산하면 ISR 하나가 진입 · 복귀 포함 약 50~100 사이클일 때 115200 보는 180 MHz 의 0.3~0.6 % 입니다. "인터럽트가 많으니 무조건 DMA" 는 아닙니다 — 921600 보 이상이거나, 다른 ISR 이 오래 걸려 ORE 가 나는 경우가 DMA 로 넘어갈 때입니다.

넘치면 무엇을 버리나 — 오버런 정책 세 가지 🔴 실무

정책 비교

정책동작어울리는 데이터주의
새 것 버리기가득 차면 들어온 바이트를 버리고 카운트명령 · 요청-응답SPSC 그대로 · 가장 단순
옛 것 버리기가장 오래된 것을 덮어씀센서 값 · 최신만 의미 있는 텔레메트리ISR 이 tail 을 건드림 → 소비자 쪽 잠금 필요
플래그 + 폐기넘쳤다는 표시만 남기고, 파서가 진행 중 프레임 · 버퍼를 비움CRC · 재전송이 있는 프로토콜깨진 프레임을 해석하지 않음
바이트 스트림에서 "옛 것 버리기"는 대부분 틀린 선택입니다. 프레임 중간이 잘려 나가 남은 조각이 다음 프레임과 붙기 때문입니다. 최신 값만 필요하면 바이트가 아니라 해석이 끝난 메시지 단위로 덮어쓰세요(최신 값 한 칸 + 시퀀스 번호).

플래그 + 폐기 — 파서와 연결

volatile uint8_t rx_overflow;     /* ISR 에서 drop 시 1 로 */

void comm_poll(parser_t *p)
{
    uint8_t b;
    if (rx_overflow) {
        rx_overflow = 0;
        p->st = ST_SOF;               /* 진행 중이던 프레임은 믿을 수 없음 */
        p->err_len++;                 /* 별도 카운터를 두어도 좋음 */
    }
    while (rx_pop(&b))
        parser_feed(p, b, HAL_GetTick());
}

/* 주의 — rx_pop 으로 꺼낸 바이트의 "도착 시각"은 HAL_GetTick() 이 아니라
   꺼낸 시각입니다. main 이 10 ms 늦게 돌면 정상 프레임도 GAP_MS 를
   넘은 것처럼 보입니다 → 타임아웃 파서를 쓰려면 ISR 에서 시각도 함께 저장하거나,
   GAP_MS 를 main 루프 최악 주기보다 크게 잡습니다. */

버퍼 크기 계산 — 보레이트 × 최악 지연 × 여유 🟡 중급

필요 크기 ≥ (보레이트 / 프레임 비트) × 최악 처리 지연 × 여유(2)
             + 가장 긴 메시지 1개 (해석 전에 통째로 모일 수 있으므로)

예) 115200 8N1, main 루프 최악 20 ms, 최대 메시지 64 B
    11 520 B/s × 0.020 s = 230.4 B
    × 2 = 461 → + 64 = 525 → 2의 거듭제곱으로 올림 → 1024
    (최대 메시지가 작으면 512 로도 충분)

예) 921600 8N1, 같은 20 ms
    92 160 × 0.020 = 1 843 B → × 2 → 4096

예) 9600 8E1 Modbus, 50 ms
    9600 / 11 = 873 B/s × 0.050 = 44 B → 최대 프레임 256 B 가 지배 → 512
"최악 지연"에 들어가는 것잴 방법
main 루프 한 바퀴의 최악루프 시작마다 DWT 사이클 차이의 최댓값 기록
Flash 지우기 · 쓰기 (CPU 정지)F4 섹터 지우기는 수백 ms~초 — 이것만으로 링이 넘침
긴 크리티컬 섹션 · 인터럽트 금지금지 구간 진입 · 해제에 GPIO 토글 → 로직 분석기(p13)
RTOS 라면 소비 태스크보다 높은 태스크트레이스(RTOS 탭) · 태스크 우선순위 표
내부 Flash 를 지우는 동안 같은 뱅크에서 코드를 실행하면 CPU 가 멈춥니다(F446 은 단일 뱅크). 설정 저장 버튼을 누를 때마다 UART 가 깨진다면 링이 아니라 이것이 원인입니다 — 지우기 전에 상대에게 "잠깐 멈춤"을 알리거나, 외부 Flash(p11)로 옮깁니다.
정리 — 저속 · 요청-응답은 ISR + SPSC 링 + "새 것 버리기", 고속 스트림은 DMA 순환 + IDLE 에 넘침 감지를 직접 붙입니다. 크기는 보레이트 × 최악 지연 × 2 + 최대 메시지 — 그리고 ORE 와 drop 을 따로 세어 원인을 가릅니다.
IO 05

I²C — 풀업 저항 계산 · 리피티드 스타트 · 버스 락업 복구

I²C 는 선 두 개(SDA 데이터 · SCL 클럭)에 센서 수십 개를 매달 수 있는 버스입니다. 레지스터로 한 바이트를 주고받는 최소 드라이버는 AVR 탭 a09 에서 만들었고, 여기서는 양산에서 문제가 되는 세 가지를 다룹니다 — 풀업 저항을 얼마로 할지(감이 아니라 계산으로), 레지스터를 읽을 때 버스에서 실제로 무슨 일이 일어나는지, 그리고 슬레이브가 SDA 를 붙잡고 멈춰 버스 전체가 죽는 "락업"을 어떻게 푸는지. 마지막 것은 리셋 버튼으로만 고쳐지던 현장 장애의 주범입니다.

BME280 칩 ID 읽기 — 위는 첫 바이트의 실제 파형, 아래는 거래 전체 SCL SDA START 1 1 1 0 1 1 0 0 0 주소 0x76 (7비트, 큰 비트부터) W ACK 해석 S 0x76 W A 0xD0 (ID) A Sr 0x76 R A 0x60 N P 레지스터 읽기 = 주소+W → 레지스터 번호 → Sr(STOP 없이 다시 START) → 주소+R → 데이터 초록 = 슬레이브가 SDA 를 구동(ACK · 데이터). 마지막 바이트 뒤 마스터의 NACK 은 "그만 보내라"는 뜻입니다
파형 + 해석 — 주소 바이트의 마지막 비트가 방향(R/W), 9번째 클럭이 ACK. HAL 의 7비트 주소를 왼쪽으로 한 칸 미는 이유가 이것입니다.

오픈드레인 — 누구도 HIGH 를 "밀지" 않습니다

I²C 의 모든 장치는 선을 LOW 로 당기거나 놓기만 할 수 있습니다(오픈드레인 — 하드웨어 기초 탭 h02). HIGH 는 풀업 저항이 끌어올려서 만듭니다. 그래서 두 장치가 동시에 말해도 쇼트가 나지 않고(LOW 가 이김), 슬레이브가 SCL 을 붙잡아 마스터를 기다리게 하는 클럭 스트레칭도 가능합니다. 대가는 속도입니다 — 올라가는 에지는 저항 × 버스 정전용량이라는 RC 곡선을 따라 천천히 올라갑니다.

최소 저항 — 장치가 LOW 로 당길 수 있는 전류 한계 (3 mA 에서 0.4 V)
  Rp(min) = (VDD − VOL) / IOL = (3.3 − 0.4) / 0.003 = 967 Ω

최대 저항 — 상승 시간 tr(30 %→70 %) = 0.8473 × Rp × Cb 가 규격 이내
  표준 모드 100 kHz : tr ≤ 1000 ns
  고속 모드 400 kHz : tr ≤  300 ns
  Rp(max) = tr / (0.8473 × Cb)

  Cb = 100 pF : 표준 11.8 kΩ · 고속 3.54 kΩ
  Cb = 200 pF : 표준  5.9 kΩ · 고속 1.77 kΩ
  Cb = 400 pF : 표준  2.95 kΩ · 고속 0.89 kΩ (최소 저항 근처 → 버스가 너무 큼)

Cb 어림 = 장치당 핀 용량 ~10 pF × 장치 수 + 배선 ~50~100 pF/m

모듈을 여러 개 꽂았을 때 — 병렬 풀업

대부분의 센서 모듈은 기판에 풀업(4.7 k ~ 10 k)을 달고 나옵니다.
모듈 세 개를 한 버스에 꽂으면 풀업 세 개가 병렬:

  4.7 k × 3 병렬 = 1.57 kΩ  → 싱크 전류 3.3 / 1567 = 2.1 mA (OK)
  10 k  × 4 병렬 = 2.5 kΩ   → 문제없음
  2.2 k × 4 병렬 = 550 Ω    → 6 mA, 최소 저항(967 Ω) 미만 ✕
                               LOW 가 0.4 V 아래로 안 내려감
STM32 내부 풀업(약 40 kΩ)으로 I²C 를 돌리지 마세요. 100 pF 에서 tr ≈ 3.4 µs — 표준 모드 규격(1 µs)의 세 배입니다. 책상 위 짧은 배선에선 "되는 것처럼" 보이다가 선을 늘리거나 장치를 더하면 무너집니다.
스코프로 확인하는 법 — SCL 상승 에지를 확대해 30 %(1.0 V)에서 70 %(2.3 V)까지 걸린 시간을 잽니다. 사각파가 아니라 상어 지느러미 모양이면 풀업이 너무 크거나 버스 용량이 너무 큰 것입니다(p13).
SCL 이 풀린 뒤의 전압 — 버스 100 pF, 풀업 저항만 다를 때 0 V 0.99 2.31 3.3 0 1 µs 2 µs 3 µs VIH VIL 2.2 kΩ · tr 186 ns — 400 kHz OK 4.7 kΩ · tr 398 ns — 100 kHz 만 10 kΩ · tr 847 ns — 100 kHz 한계 점은 VIH(0.7 VDD)에 닿는 순간 — 이 전에는 수신기가 아직 0 으로 읽습니다 tr = 0.8473 × R × C · 고속 모드 한계 300 ns, 표준 모드 1000 ns (I²C 규격 UM10204)
RC 곡선 — "4.7 kΩ 이 표준값"은 100 kHz 에서만 맞습니다. 400 kHz 로 올리면 저항도 같이 내려야 합니다.

HAL 로 레지스터 읽기 — Mem_Read 한 줄 아래에서 일어나는 일 🟡 중급

버스 스캔 · 칩 ID · 버스트 읽기

extern I2C_HandleTypeDef hi2c1;          /* PB8 SCL · PB9 SDA · 400 kHz */

/* ① 버스 스캔 — 처음 보드를 받으면 무조건 이것부터 */
void i2c_scan(void)
{
    for (uint8_t a = 0x08; a < 0x78; a++)          /* 0x00~07, 0x78~7F 는 예약 */
        if (HAL_I2C_IsDeviceReady(&hi2c1, (uint16_t)(a << 1), 2, 5) == HAL_OK)
            printf("found 0x%02X\r\n", a);         /* BME280 → 0x76 또는 0x77 */
}

/* ② 칩 ID — S 0x76W A 0xD0 A Sr 0x76R A [0x60] N P 를 HAL 이 만들어 줌 */
uint8_t id = 0;
HAL_StatusTypeDef st = HAL_I2C_Mem_Read(&hi2c1,
        0x76 << 1,               /* ★ 7비트 주소를 왼쪽으로 한 칸 */
        0xD0,                    /* 레지스터 번호 */
        I2C_MEMADD_SIZE_8BIT,    /* EEPROM 은 16BIT 인 경우가 많음 */
        &id, 1, 10);             /* 1바이트, 타임아웃 10 ms */
if (st != HAL_OK) {
    uint32_t e = HAL_I2C_GetError(&hi2c1);   /* HAL_I2C_ERROR_AF = NACK */
}

/* ③ 연속 레지스터 버스트 — 주소 자동 증가 (BME280 0xF7~0xFE 8바이트) */
uint8_t raw[8];
HAL_I2C_Mem_Read(&hi2c1, 0x76 << 1, 0xF7, I2C_MEMADD_SIZE_8BIT, raw, 8, 10);

HAL I²C 오류 코드와 뜻

코드뜻 · 먼저 볼 것
HAL_I2C_ERROR_AFACK 실패(NACK) — 주소 틀림(시프트 안 함) · 장치 전원 · 배선
HAL_I2C_ERROR_BERR버스 오류 — 엉뚱한 자리의 START/STOP · 잡음
HAL_I2C_ERROR_ARLO중재 패배 — 다른 마스터가 있거나 SDA 가 눌려 있음
HAL_I2C_ERROR_TIMEOUTBUSY 가 안 풀림 · 클럭 스트레칭이 너무 김 → 락업 의심
HAL_BUSY (반환값)이전 거래가 안 끝남 · 핸들 상태 꼬임
클럭 스트레칭 — 슬레이브가 "아직 준비 안 됨"이라며 SCL 을 LOW 로 붙잡는 것. 마스터는 SCL 을 놓은 뒤 실제로 HIGH 가 되었는지 확인하고 기다립니다. STM32 I²C 마스터는 이를 자동으로 따르지만, 타임아웃이 없으면 영원히 기다릴 수 있습니다 — HAL 함수의 마지막 인자가 그 안전망입니다.
F446 의 I2C1~3 은 최대 400 kHz, FMPI2C1 은 1 MHz(Fm+)까지입니다. SSD1306 처럼 큰 데이터를 보내는 장치를 1 MHz 로 돌리고 싶다면 FMPI2C 핀에 배치하세요(단, 상대 장치도 Fm+ 를 지원해야 합니다).

버스 락업 — SCL 9펄스로 슬레이브를 풀어 주기 🔴 실무

시나리오: 마스터가 센서에서 바이트를 읽는 도중 MCU 만 리셋됩니다(워치독 · 디버거 리셋 · 브라운아웃). 센서는 리셋되지 않았으니 아직 "바이트를 보내는 중"이고, 마침 보내던 비트가 0 이라 SDA 를 LOW 로 붙잡은 채 다음 SCL 을 기다립니다. 다시 깨어난 마스터는 SDA 가 LOW 라서 버스가 바쁘다고(BUSY) 보고 START 를 못 겁니다. 서로가 서로를 기다리는 교착 상태입니다. 해법은 마스터가 SCL 을 직접 흔들어 슬레이브가 남은 비트를 다 내보내게 하는 것 — 최악이 8비트 + ACK 이므로 최대 9펄스입니다.

i2c_recover.c — I2C1 (PB8 SCL · PB9 SDA)

/* delay_us() 는 DWT 사이클 카운터로 만든 µs 지연 (STM32 탭) */
static void scl(GPIO_PinState s) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8, s); delay_us(5); }
static void sda(GPIO_PinState s) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_9, s); delay_us(5); }

void i2c1_bus_recover(void)
{
    GPIO_InitTypeDef g = {0};
    HAL_I2C_DeInit(&hi2c1);                    /* 주변장치 끄고 핀 해제 */

    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8 | GPIO_PIN_9, GPIO_PIN_SET);  /* 먼저 놓아 둠 */
    g.Pin   = GPIO_PIN_8 | GPIO_PIN_9;
    g.Mode  = GPIO_MODE_OUTPUT_OD;             /* 오픈드레인 — 외부 풀업이 HIGH 를 만듦 */
    g.Pull  = GPIO_NOPULL;
    g.Speed = GPIO_SPEED_FREQ_LOW;
    HAL_GPIO_Init(GPIOB, &g);                   /* OD 출력에 1 = 놓음 → 읽기도 가능 */

    /* ① SDA 가 풀릴 때까지 최대 9번 클럭 (약 100 kHz) */
    for (int i = 0; i < 9 && HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_9) == GPIO_PIN_RESET; i++) {
        scl(GPIO_PIN_RESET);
        scl(GPIO_PIN_SET);
    }
    /* ② STOP 만들기 — SCL HIGH 인 동안 SDA LOW → HIGH */
    scl(GPIO_PIN_RESET);
    sda(GPIO_PIN_RESET);
    scl(GPIO_PIN_SET);
    sda(GPIO_PIN_SET);

    /* ③ 주변장치 자체도 리셋 (BUSY 플래그 고착 대비) 후 재초기화 */
    __HAL_RCC_I2C1_FORCE_RESET();
    __HAL_RCC_I2C1_RELEASE_RESET();
    HAL_I2C_Init(&hi2c1);                      /* MspInit 이 핀을 다시 AF4 로 */
}

언제 부르나 — 드라이버 규칙

/* ① 부팅 때 무조건 한 번 — MCU 만 리셋된 경우를 처리 */
MX_I2C1_Init();
i2c1_bus_recover();

/* ② 오류가 연속될 때 — 한 번의 NACK 으로는 부르지 않음 */
static uint8_t fail_streak;
HAL_StatusTypeDef i2c_read(uint8_t a7, uint8_t reg, uint8_t *p, uint16_t n)
{
    HAL_StatusTypeDef st = HAL_I2C_Mem_Read(&hi2c1, (uint16_t)(a7 << 1), reg,
                                            I2C_MEMADD_SIZE_8BIT, p, n, 10);
    if (st == HAL_OK) { fail_streak = 0; return st; }
    if (++fail_streak >= 3 || st == HAL_TIMEOUT ||
        __HAL_I2C_GET_FLAG(&hi2c1, I2C_FLAG_BUSY)) {
        i2c1_bus_recover();
        fail_streak = 0;
    }
    return st;
}
F4 의 I²C 는 정오표(errata)에 "BUSY 플래그가 풀리지 않는" 항목이 있습니다. 아날로그 필터가 잡음을 잘못 걸러 주변장치가 버스가 바쁘다고 믿어 버리는 경우로, 공식 회피책이 바로 위 ③ — GPIO 로 핀을 흔들고 주변장치를 리셋하는 절차입니다. 쓰는 칩의 정오표를 한 번은 읽어 두세요.
근본 대책은 회로에 있습니다 — 센서 전원을 MCU 가 GPIO(또는 로드 스위치)로 끊었다 켤 수 있게 설계하면 어떤 락업도 전원 재투입으로 풀립니다. 양산 보드라면 이 핀 하나를 꼭 남기세요.
정리 — 풀업은 tr = 0.8473 RC 로 계산해 400 kHz 면 2.2 kΩ 근처, HAL 주소는 7비트 << 1. 레지스터 읽기는 리피티드 스타트로 이어지고, 락업은 SCL 9펄스 + STOP + 주변장치 리셋을 부팅 때와 연속 실패 때 부릅니다.
IO 06

SPI — 모드 0~3 · CS 관리 · DMA, 그리고 빠른 버스의 배선 한계

SPI 는 가장 단순하고 가장 빠른 버스입니다. 마스터가 클럭(SCK)을 내보내면 한 클럭마다 한 비트씩 양쪽이 동시에 맞바꿉니다(MOSI 로 나가고 MISO 로 들어옴). 주소도 ACK 도 없고, 대신 장치마다 CS(칩 선택) 선 하나로 "지금 너" 를 지목합니다. 규칙이 적은 만큼 맞춰야 할 것은 클럭의 극성과 위상(모드), CS 를 언제 올리고 내리나, 선이 얼마나 길어도 되나 세 가지입니다.

SPI 모드 — 점이 데이터를 읽는 에지입니다 (CPOL = 쉴 때 레벨, CPHA = 몇 번째 에지) CS 모드 0 CPOL 0 · CPHA 0 상승 에지에서 읽음 모드 1 CPOL 0 · CPHA 1 하강 에지에서 읽음 모드 2 CPOL 1 · CPHA 0 하강 에지에서 읽음 모드 3 CPOL 1 · CPHA 1 상승 에지에서 읽음 MOSI · CPHA 0 b7 b6 b5 b4 MOSI · CPHA 1 b7 b6 b5 b4 CS 와 함께 첫 비트 첫 에지에서 첫 비트 CPHA 0 은 데이터 칸 한가운데가 첫 에지, CPHA 1 은 반 칸 늦게 시작합니다 모드가 틀리면 "가끔 맞는" 값 대신 한 비트 밀린 값(×2 · ÷2)이 나옵니다 — p13 증상표
파형 — 모드 = 쉴 때 클럭 레벨 × 몇 번째 에지에서 읽나. 데이터시트 타이밍 그림에서 "SCK 가 쉴 때 어디 있나"부터 보면 됩니다.

자주 쓰는 장치의 모드 · 최대 클럭

장치모드최대 SCK메모
BME2800 · 310 MHz레지스터 주소 bit7 = 1 이면 읽기
W25Q128JV (NOR)0 · 3읽기 0x03 은 50 MHz빠른 읽기 명령은 더 높음
SSD1306 (4선 SPI)0 (· 3)10 MHzD/C 핀으로 명령 · 데이터 구분
SD 카드 (SPI 모드)0초기화 400 kHz → 이후 25 MHz초기화 중엔 느리게
ADXL34535 MHz모드 0 으로는 안 됨
"모드 0 · 3 둘 다 지원"이 많은 이유 — 두 모드 모두 상승 에지에서 읽기라 장치 입장에선 같습니다. 다른 점은 쉴 때 클럭 레벨뿐입니다.

CubeMX 가 만드는 초기화 (SPI1 · PA5/6/7)

hspi1.Instance               = SPI1;              /* APB2 90 MHz */
hspi1.Init.Mode              = SPI_MODE_MASTER;
hspi1.Init.Direction         = SPI_DIRECTION_2LINES;
hspi1.Init.DataSize          = SPI_DATASIZE_8BIT;
hspi1.Init.CLKPolarity       = SPI_POLARITY_LOW;  /* CPOL 0 */
hspi1.Init.CLKPhase          = SPI_PHASE_1EDGE;   /* CPHA 0 → 모드 0 */
hspi1.Init.NSS               = SPI_NSS_SOFT;      /* CS 는 GPIO 로 직접 */
hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_16;  /* 90/16 = 5.625 MHz */
hspi1.Init.FirstBit          = SPI_FIRSTBIT_MSB;
hspi1.Init.TIMode            = SPI_TIMODE_DISABLE;
hspi1.Init.CRCCalculation    = SPI_CRCCALCULATION_DISABLE;
HAL_SPI_Init(&hspi1);

/* 분주는 2의 거듭제곱만: 90 MHz / 2·4·8·16·32·64·128·256
   → 45 · 22.5 · 11.25 · 5.625 · 2.81 · 1.41 MHz …
   BME280(10 MHz 한계)면 /16 이 가장 빠른 안전값 */
하드웨어 NSS 를 쓰지 마세요(대부분의 경우). F4 의 하드웨어 NSS 는 SPI 가 켜져 있는 동안 계속 LOW 라 바이트 사이에 CS 를 올릴 수 없고, 장치가 여럿이면 쓸 수도 없습니다. SPI_NSS_SOFT + GPIO 가 현업 표준입니다.

CS 관리와 거래 단위 — CS 가 곧 "트랜잭션 경계" 🟡 중급

spi_dev.c — 장치 하나 = (SPI 핸들, CS 핀)

typedef struct {
    SPI_HandleTypeDef *hspi;
    GPIO_TypeDef      *cs_port;
    uint16_t           cs_pin;
} spi_dev_t;

static inline void cs_low(const spi_dev_t *d)  { HAL_GPIO_WritePin(d->cs_port, d->cs_pin, GPIO_PIN_RESET); }
static inline void cs_high(const spi_dev_t *d) { HAL_GPIO_WritePin(d->cs_port, d->cs_pin, GPIO_PIN_SET); }

/* 레지스터 읽기 — 첫 바이트(주소)를 보내는 동안 들어오는 값은 버림 */
HAL_StatusTypeDef bme_spi_read(const spi_dev_t *d, uint8_t reg, uint8_t *p, uint16_t n)
{
    uint8_t cmd = reg | 0x80u;              /* bit7 = 1 → 읽기 */
    cs_low(d);
    HAL_StatusTypeDef st = HAL_SPI_Transmit(d->hspi, &cmd, 1, 10);
    if (st == HAL_OK) st = HAL_SPI_Receive(d->hspi, p, n, 10);  /* p 의 기존 내용을 더미로 내보내며 받음 */
    cs_high(d);                              /* 오류여도 반드시 올림 */
    return st;
}

/* W25Q JEDEC ID — 한 번에 4바이트 맞바꾸기 */
uint32_t w25q_jedec_id(const spi_dev_t *d)
{
    uint8_t tx[4] = { 0x9F, 0, 0, 0 }, rx[4] = { 0 };
    cs_low(d);
    HAL_SPI_TransmitReceive(d->hspi, tx, rx, 4, 10);
    cs_high(d);
    return ((uint32_t)rx[1] << 16) | ((uint32_t)rx[2] << 8) | rx[3];  /* W25Q128JV → 0xEF4018 */
}

/* 부팅 직후: 모든 CS 를 먼저 HIGH 로 — 초기화 순서 때문에 잠깐 LOW 면
   장치들이 동시에 MISO 를 구동해 첫 거래가 깨집니다 */

DMA 전송 — CS 는 콜백에서 올림

static const spi_dev_t *dma_dev;          /* 지금 DMA 중인 장치 */

HAL_StatusTypeDef spi_xfer_dma(const spi_dev_t *d,
                               const uint8_t *tx, uint8_t *rx, uint16_t n)
{
    dma_dev = d;
    cs_low(d);
    HAL_StatusTypeDef st = HAL_SPI_TransmitReceive_DMA(d->hspi, tx, rx, n);
    if (st != HAL_OK) cs_high(d);
    return st;
}

void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi)
{
    if (dma_dev && dma_dev->hspi == hspi) {
        cs_high(dma_dev);                     /* 마지막 비트까지 받은 뒤 */
        dma_dev = NULL;
        /* 태스크에 완료 알림 (RTOS 탭 r07) */
    }
}
송신 전용으로 직접 레지스터 코드를 짤 때 — TXE(송신 버퍼 빔)는 "다 나갔다"가 아닙니다. UART 의 TXE/TC 와 같은 함정으로, CS 를 올리기 전에 BSY 플래그가 0 이 될 때까지 기다려야 마지막 바이트가 잘리지 않습니다(HAL 은 내부에서 기다려 줌).
HAL 의 HAL_SPI_Receive_DMA 도 마스터 2선 모드에서는 내부적으로 송수신 DMA 를 둘 다 씁니다(클럭을 만들려고 더미를 보냄). CubeMX 에서 SPI1_RX 만 DMA 를 걸면 동작하지 않으니 TX 스트림도 함께 추가하세요.

여러 장치 · 속도 · 배선 — MHz 가 올라가면 선이 안테나가 된다 🔴 실무

배선 길이별 현실적인 SCK

연결현실적 상한대책
같은 PCB, 5 cm 이내20~45 MHzGND 플레인 · 짧은 리턴 경로
점퍼선 10~20 cm (브레드보드)수 MHzGND 선을 신호 옆에 · 직렬 22~33 Ω
케이블 30 cm 이상1 MHz 이하SPI 를 보드 밖으로 내지 말 것 → RS-485 · CAN
왜 MISO 가 먼저 깨지나 — 왕복 지연
 마스터가 SCK 에지를 내보냄 → 선을 타고 슬레이브 도착(ns/10 cm 수준)
 → 슬레이브 출력 지연(데이터시트 tV, 수~수십 ns) → MISO 타고 되돌아옴
 이 합이 SCK 반주기보다 길면 마스터가 이전 비트를 읽음

 22.5 MHz 반주기 = 22 ns  ← 출력 지연 15 ns 짜리 장치면 이미 빠듯

한 버스에 여러 장치 — 체크리스트

항목규칙
CS 풀업CS 마다 10 kΩ 풀업 — 리셋 · 부팅 중 떠 있지 않게
모드가 다르면모든 CS 가 HIGH 일 때만 CPOL/CPHA 변경(SPE 끈 상태)
속도가 다르면장치 구조체에 분주값을 넣고 거래 시작 때 설정
MISO 를 안 놓는 장치CS 가 HIGH 여도 MISO 를 구동하는 칩이 있음 → 버퍼(74LVC1G125) 또는 다른 SPI
RTOS버스 하나 = 뮤텍스 하나, CS 내리기부터 올리기까지가 임계 구역
GPIO 속도 설정VERY_HIGH 는 링잉(울림)을 키움 — 되는 범위에서 가장 낮게
SD 카드와 다른 장치를 한 버스에 두면 SD 의 초기화(400 kHz · CS HIGH 상태에서 74 클럭 이상)가 다른 장치 설정과 엉키기 쉽습니다. 핀 여유가 있으면 SD 는 SDIO(F446 에 있음)나 별도 SPI 로 떼어 두는 편이 디버깅 시간을 아낍니다.
정리 — 모드는 쉴 때 클럭 레벨(CPOL) × 읽는 에지(CPHA), CS 는 소프트웨어 GPIO 로 거래마다 내리고 올립니다. DMA 면 완료 콜백에서 CS 를 올리고, 배선이 길어지면 MHz 를 내리거나 SPI 를 보드 안에 가둡니다.
IO 07

CAN · CAN-FD — 충돌해도 깨지지 않는 버스, 비트 중재와 bus-off

CAN 은 자동차 한 대에 수십 개의 ECU 를 두 가닥 선으로 묶으려고 만든 버스입니다. 마스터가 없고 누구나 아무 때나 말을 시작할 수 있습니다. 두 노드가 동시에 말하면? 이더넷은 충돌로 둘 다 깨지고 재시도하지만, CAN 은 ID 가 작은 쪽이 한 비트도 손상 없이 이기고 진 쪽은 조용히 물러납니다. 그리고 고장 난 노드가 버스를 망치지 않도록 스스로 빠지는(bus-off) 규칙이 하드웨어에 들어 있습니다. 서버로 치면 우선순위 큐가 내장된 메시지 버스 + 서킷 브레이커입니다.

비트 중재 — 0x123 과 0x127 이 동시에 시작하면 노드 A 0x123 노드 B 0x127 → 수신으로 전환 버스 SOF 10 9 8 7 6 5 4 3 2 1 0 RTR 위 = 열성(1) · 아래 = 우성(0) — 한 노드라도 0 을 내면 버스는 0 입니다 ID 를 큰 자리부터 비교해 처음 갈라지는 비트(2)에서 0 을 낸 A, 즉 작은 ID 가 이깁니다. A 의 프레임은 손상 없음
파형 · 중재 — ID 가 곧 우선순위입니다. 브레이크 메시지에 작은 ID 를 주는 이유가 이것입니다.

물리 계층 — 우성과 열성

상태CANHCANL차이논리
열성≈ 2.5 V≈ 2.5 V≈ 0 V1 (아무도 안 당김)
우성≈ 3.5 V≈ 1.5 V≈ 2 V0 (누군가 당김)

I²C 의 오픈드레인과 같은 원리입니다 — 0(우성)은 적극적으로 구동하고 1(열성)은 놓아 두기만 하므로, 여럿이 동시에 말해도 0 이 이기고 아무것도 타지 않습니다. 송신하는 노드는 자기가 보낸 비트를 버스에서 다시 읽어 비교하고, 다르면 중재에 진 것으로 알고 물러납니다.

종단 저항 120 Ω 을 양 끝에. 전원을 끈 채 CANH–CANL 을 재서 약 60 Ω(120 ∥ 120)이면 정상, 120 Ω 이면 한쪽이 빠졌고, 40 Ω 이면 세 개가 달린 것입니다. Nucleo 두 대를 책상 위에서 이을 때도 종단이 없으면 ACK 에러만 쌓입니다.

프레임 구조 (클래식 CAN 2.0A · 표준 ID)

SOF | ID 11 | RTR | IDE | r0 | DLC 4 | DATA 0~64비트 | CRC 15 | 구분 | ACK 2 | EOF 7 | IFS 3
 1      11      1     1     1     4        0~8 바이트     15      1      2       7      3

데이터 8바이트 프레임 = 111 비트 (스터핑 전)
비트 스터핑: 같은 값이 5번 연속되면 반대 비트 1개 삽입 → 최악 약 +20 %

확장 ID(2.0B) = 29비트, IDE = 1, 프레임이 약 20비트 김
ACK 슬롯: 송신자는 열성을 보내고, 정상 수신한 노드 하나라도 우성으로 덮음
          → 버스에 혼자면 ACK 를 못 받아 계속 재전송 (TEC 증가)
CAN 에는 "주소"가 없습니다. ID 는 받는 노드가 아니라 메시지의 종류(예: 0x0C1 = 바퀴 속도)입니다. 모든 노드가 모든 프레임을 받고, 필터로 관심 있는 ID 만 남깁니다 — 서버로 치면 pub/sub 의 토픽입니다.

F446 bxCAN 설정 — 비트 타이밍 · 필터 · 송수신 🟡 중급

500 kbit/s 비트 타이밍 계산 (APB1 45 MHz)

한 비트 = Sync(1 tq) + BS1 + BS2      (tq = 시간 양자)
tq = Prescaler / 45 MHz

Prescaler 5 → tq = 111.1 ns → 500 kbit/s 면 한 비트 = 2 µs = 18 tq
  18 = 1 + BS1 15 + BS2 2   → 샘플 지점 (1+15)/18 = 88.9 %
Prescaler 6 → 15 tq = 1 + 12 + 2           → 86.7 %

같은 방식으로:  1 Mbit/s  = 3 × (1+12+2)   → 86.7 %
              250 kbit/s = 10 × (1+15+2)  → 88.9 %
              125 kbit/s = 20 × (1+15+2)  → 88.9 %

CANopen · 차량 권장 샘플 지점 87.5 % 근처 — 버스의 모든 노드가 비슷해야 함
hcan1.Instance                  = CAN1;
hcan1.Init.Prescaler            = 5;
hcan1.Init.Mode                 = CAN_MODE_NORMAL;   /* 혼자 시험은 LOOPBACK */
hcan1.Init.SyncJumpWidth        = CAN_SJW_1TQ;
hcan1.Init.TimeSeg1             = CAN_BS1_15TQ;
hcan1.Init.TimeSeg2             = CAN_BS2_2TQ;
hcan1.Init.TimeTriggeredMode    = DISABLE;
hcan1.Init.AutoBusOff           = ENABLE;   /* ABOM: bus-off 에서 자동 복귀 */
hcan1.Init.AutoWakeUp           = DISABLE;
hcan1.Init.AutoRetransmission   = ENABLE;
hcan1.Init.ReceiveFifoLocked    = DISABLE;
hcan1.Init.TransmitFifoPriority = DISABLE;  /* 메일박스 3개 중 ID 순 송신 */
HAL_CAN_Init(&hcan1);

필터 · 시작 · 송신 · 수신

/* 0x120~0x12F 표준 ID 만 FIFO0 으로 — 32비트 마스크 모드
   레지스터 배치: STID[10:0] = bit31..21, IDE = bit2, RTR = bit1 */
CAN_FilterTypeDef f = {0};
f.FilterBank           = 0;
f.FilterMode           = CAN_FILTERMODE_IDMASK;
f.FilterScale          = CAN_FILTERSCALE_32BIT;
f.FilterIdHigh         = 0x120 << 5;      /* 상위 16비트의 [15:5] = STID */
f.FilterIdLow          = 0x0000;          /* IDE = 0 (표준) */
f.FilterMaskIdHigh     = 0x7F0 << 5;      /* 아래 4비트는 상관없음 */
f.FilterMaskIdLow      = 0x0004;          /* IDE 비트는 반드시 비교 */
f.FilterFIFOAssignment = CAN_RX_FIFO0;
f.FilterActivation     = ENABLE;
f.SlaveStartFilterBank = 14;              /* 28개 뱅크 중 14~ 는 CAN2 몫 */
HAL_CAN_ConfigFilter(&hcan1, &f);

HAL_CAN_Start(&hcan1);
HAL_CAN_ActivateNotification(&hcan1, CAN_IT_RX_FIFO0_MSG_PENDING |
        CAN_IT_ERROR_PASSIVE | CAN_IT_BUSOFF | CAN_IT_LAST_ERROR_CODE | CAN_IT_ERROR);

/* 송신 — 빈 메일박스가 있을 때만 */
CAN_TxHeaderTypeDef th = { .StdId = 0x123, .IDE = CAN_ID_STD,
                           .RTR = CAN_RTR_DATA, .DLC = 8 };
uint8_t  data[8] = { 1, 2, 3, 4, 5, 6, 7, 8 };
uint32_t mbox;
if (HAL_CAN_GetTxMailboxesFreeLevel(&hcan1) > 0)
    HAL_CAN_AddTxMessage(&hcan1, &th, data, &mbox);

/* 수신 — FIFO 는 3단뿐, 콜백에서 바로 비움 */
void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *h)
{
    CAN_RxHeaderTypeDef rh;
    uint8_t d[8];
    while (HAL_CAN_GetRxFifoFillLevel(h, CAN_RX_FIFO0) > 0) {
        if (HAL_CAN_GetRxMessage(h, CAN_RX_FIFO0, &rh, d) != HAL_OK) break;
        can_rx_push(rh.StdId, d, (uint8_t)rh.DLC);   /* 앱 큐로 복사만 */
    }
}

에러 카운터와 bus-off — 고장 난 노드가 스스로 빠지는 규칙 🔴 실무

CAN 오류 상태 — 카운터가 상태를 정합니다 (ISO 11898-1) Active 정상 참여 Passive 조용히 참여 Bus-off 송신 금지 TEC 또는 REC > 127 둘 다 ≤ 127 TEC > 255 열성 11비트 × 128회 관찰 → 재가입 (ABOM 또는 재초기화) 송신 오류 +8 · 성공 −1 Passive 노드는 오류를 "조용히"만 알립니다 — 고장 난 노드가 남을 방해하지 못하게 500 kbit/s 에서 복귀 최소 시간 = 128 × 11 비트 × 2 µs ≈ 2.8 ms (버스가 조용할 때)
상태 기계 — 오류가 쌓이면 스스로 물러납니다. 서킷 브레이커가 하드웨어에 들어 있는 셈입니다.

오류를 읽고 대응하기

void HAL_CAN_ErrorCallback(CAN_HandleTypeDef *h)
{
    uint32_t esr = h->Instance->ESR;
    uint8_t  tec = (uint8_t)((esr >> 16) & 0xFF);   /* 송신 오류 카운터 */
    uint8_t  rec = (uint8_t)((esr >> 24) & 0xFF);   /* 수신 오류 카운터 */
    uint8_t  lec = (uint8_t)((esr >> 4) & 0x7);     /* 마지막 오류 종류 */
    can_diag.tec = tec; can_diag.rec = rec; can_diag.lec = lec;

    if (esr & CAN_ESR_BOFF) can_diag.busoff_cnt++;  /* ABOM 이면 자동 복귀 */
    HAL_CAN_ResetError(h);
}
/* LEC: 1 스터프 · 2 형식 · 3 ACK(아무도 안 받음) · 4 열성 비트 · 5 우성 비트 · 6 CRC */
증상먼저 볼 것
LEC = ACK, TEC 계속 증가버스에 혼자 · 상대 꺼짐 · 종단 없음 · 보레이트 불일치
LEC = 스터프 · 형식비트 타이밍 · 샘플 지점이 노드마다 다름
가끔 bus-offCANH/CANL 뒤바뀜 · 트랜시버 전원 · 접지 루프
수신이 띄엄띄엄FIFO(3단) 넘침 — 콜백이 늦거나 필터가 너무 넓음

bxCAN(F4) vs FDCAN(G4 · H7) · 트랜시버

bxCAN (F446)FDCAN (G4 · H7 · U5)
프로토콜클래식 CAN 2.0A/B클래식 + CAN-FD
데이터 길이최대 8 B최대 64 B
속도최대 1 Mbit/s중재 1 M · 데이터 구간 수 Mbit/s(BRS)
수신 버퍼FIFO 2개 × 3단메시지 RAM(구성 가능)
HALHAL_CAN_*HAL_FDCAN_* — API 가 다름
/* FDCAN 송신 모양 (G4 · H7) — 필드 이름부터 다릅니다 */
FDCAN_TxHeaderTypeDef t = {
    .Identifier = 0x123, .IdType = FDCAN_STANDARD_ID,
    .TxFrameType = FDCAN_DATA_FRAME, .DataLength = FDCAN_DLC_BYTES_64,
    .BitRateSwitch = FDCAN_BRS_ON, .FDFormat = FDCAN_FD_CAN,
    .ErrorStateIndicator = FDCAN_ESI_ACTIVE,
    .TxEventFifoControl = FDCAN_NO_TX_EVENTS };
HAL_FDCAN_AddMessageToTxFifoQ(&hfdcan1, &t, buf64);
트랜시버는 3.3 V 로 고르세요 — SN65HVD230 계열 · TJA1051T/3(VIO 핀으로 3.3 V 로직) · MCP2562. 5 V 전용 TJA1050 · MCP2551 은 RX 출력이 5 V 라 레벨을 따져야 합니다. CAN-FD 를 쓸 거면 트랜시버도 FD 대응(데이터 구간 2~5 Mbit/s)인지 확인합니다.
정리 — CAN 은 작은 ID 가 이기는 비파괴 중재와 TEC/REC 로 움직이는 오류 상태가 핵심입니다. F446 은 bxCAN(8 B)이고, 64 B 가 필요하면 FDCAN 이 있는 G4 · H7 로 갑니다. 종단 60 Ω 확인이 첫 번째 디버깅입니다.
IO 08

USB 디바이스 기초 — 엔드포인트 · 디스크립터 · 열거, 그리고 CDC 가상 COM

USB-UART 브리지 칩 없이 MCU 가 직접 USB 장치가 되면, PC 에는 COM 포트(또는 키보드 · 저장 장치)가 나타납니다. 그런데 USB 는 UART 와 완전히 다른 세계입니다 — 호스트(PC)만 말을 걸 수 있고, 장치는 꽂히는 순간 자기소개서(디스크립터)를 정확한 형식으로 내놓아야 하며, 그 과정(열거)을 수십 ms 안에 끝내야 합니다. 서버로 치면 장치는 HTTP 서버이고 PC 가 폴링하는 클라이언트입니다. 장치는 "보낼 게 있다"고 먼저 연락할 수 없고, 호스트가 물어볼 때만 응답합니다.

엔드포인트와 전송 종류

엔드포인트(EP)는 장치 안의 우편함입니다. 번호(0~15)와 방향(IN = 장치 → 호스트, OUT = 호스트 → 장치)으로 구분하고, EP0 은 모든 장치에 있는 제어용입니다. 나머지는 클래스가 정합니다.

전송보장FS 패킷예
Control전달 · 재시도8~64 BEP0 — 열거 · 설정
Bulk전달 보장, 대역은 남는 만큼64 BCDC 데이터 · 저장 장치
Interrupt정해진 주기로 폴링≤ 64 B키보드 · 마우스 · CDC 알림
Isochronous주기 보장, 재시도 없음≤ 1023 B오디오 · 영상
"인터럽트 전송"도 결국 폴링입니다 — 호스트가 1 ms 마다 물어보겠다고 약속하는 것일 뿐, 장치가 호스트를 깨우는 게 아닙니다. USB FS 는 1 ms 프레임 단위로 움직이고, 벌크는 한 프레임에 64 B 패킷 최대 19개 → 이론상 약 1.2 MB/s 입니다.

디스크립터 — 장치의 자기소개서 트리

장치 디스크립터 (18 B)          VID · PID · 클래스 · EP0 크기 · 구성 개수
└─ 구성 디스크립터 (CDC 합계 67 B)  전력 · 인터페이스 개수
   ├─ 인터페이스 0 : CDC 통신       (클래스 0x02, ACM)
   │   ├─ 기능 디스크립터 ×4         Header · Call Mgmt · ACM · Union
   │   └─ EP 0x82  Interrupt IN     8 B — 직렬 상태 알림
   └─ 인터페이스 1 : CDC 데이터     (클래스 0x0A)
       ├─ EP 0x01  Bulk OUT          64 B — PC → 장치
       └─ EP 0x81  Bulk IN           64 B — 장치 → PC
문자열 디스크립터                  제조사 · 제품명 · 일련번호 (UTF-16LE)
Windows 는 VID/PID(+일련번호)로 드라이버 결정을 기억합니다. 디스크립터를 고쳤는데 PC 가 예전처럼 인식하면 장치 관리자에서 장치를 제거하거나 PID 를 바꿔 보세요. 제품에는 남의 VID/PID 를 쓰지 않습니다 — USB-IF 에서 VID 를 받거나 칩 제조사의 하위 PID 프로그램을 씁니다.
열거 — 꽂고 나서 COM 포트가 생길 때까지 (세로가 시간) 호스트 (PC) USB 스택 내 코드 1. 버스 리셋 → 주소 0 으로 시작 2. GET_DESCRIPTOR (장치) 18 B · VID/PID · EP0 64 B 3. SET_ADDRESS (5) 4. GET_DESCRIPTOR (구성) 67 B · 인터페이스 2 · EP 3 5. SET_CONFIGURATION (1) 구성 완료 → CDC 클래스 시작 6. SET_CONTROL_LINE_STATE · DTR=1 tud_cdc_line_state_cb · 포트 열림 7. 벌크 OUT 데이터 (≤ 64 B 패킷) CDC_Receive_FS · tud_cdc_rx_cb 1~5 는 스택이 처리 — 내 코드가 할 일은 정확한 디스크립터와 6 · 7 의 콜백뿐입니다
시퀀스 — 모든 대화는 호스트가 시작합니다. 장치가 보낼 데이터(IN)도 호스트가 물어볼 때 나갑니다.

CDC 가상 COM — ST USB Device 라이브러리 vs TinyUSB 🟡 중급

ST 라이브러리 (CubeMX: USB_OTG_FS Device_Only + USB_DEVICE → CDC)

/* USB_DEVICE/App/usbd_cdc_if.c — 생성 파일의 USER CODE 안에만 씁니다 */
static int8_t CDC_Receive_FS(uint8_t *Buf, uint32_t *Len)
{
  /* USER CODE BEGIN 6 */
  rx_push(Buf, (uint16_t)*Len);                    /* 링으로 복사만 (p04) */
  USBD_CDC_SetRxBuffer(&hUsbDeviceFS, &Buf[0]);
  USBD_CDC_ReceivePacket(&hUsbDeviceFS);           /* 다음 패킷을 받도록 재무장 */
  return (USBD_OK);
  /* USER CODE END 6 */
}

/* 송신 — 이전 전송이 안 끝났으면 USBD_BUSY */
uint8_t usb_send(const uint8_t *p, uint16_t n)
{
    static uint8_t txbuf[256];                     /* 끝날 때까지 살아 있어야 함 */
    if (n > sizeof txbuf) n = sizeof txbuf;
    USBD_CDC_HandleTypeDef *h = (USBD_CDC_HandleTypeDef *)hUsbDeviceFS.pClassData;
    if (h == NULL || h->TxState != 0) return USBD_BUSY;   /* 미구성 · 송신 중 */
    memcpy(txbuf, p, n);
    return CDC_Transmit_FS(txbuf, n);
}
CDC_Receive_FS 는 USB 인터럽트 안에서 불립니다. 여기서 파싱 · printf · 블로킹을 하면 다음 패킷과 SOF 처리가 밀립니다. 그리고 USBD_CDC_ReceivePacket 을 빼먹으면 첫 패킷 이후 아무것도 안 들어옵니다 — 처리가 끝날 때까지 일부러 늦추면 그게 곧 흐름 제어(호스트가 NAK 을 받고 기다림)가 됩니다.

TinyUSB — 칩을 가리지 않는 오픈소스 스택

/* tusb_config.h */
#define CFG_TUSB_MCU            OPT_MCU_STM32F4
#define CFG_TUD_ENABLED         1
#define CFG_TUD_ENDPOINT0_SIZE  64
#define CFG_TUD_CDC             1
#define CFG_TUD_CDC_RX_BUFSIZE  256    /* 스택 안의 FIFO — 링을 따로 안 만들어도 됨 */
#define CFG_TUD_CDC_TX_BUFSIZE  256

/* main.c — 클럭 · PA11/PA12(AF10) · OTG_FS 클럭은 직접 켠 뒤 */
void OTG_FS_IRQHandler(void) { tud_int_handler(0); }

int main(void)
{
    board_init_clock_gpio();           /* 48 MHz USB 클럭 포함 (아래 표) */
    tud_init(0);                       /* 최신 버전은 tusb_init(rhport, &cfg) 도 제공 */
    for (;;) {
        tud_task();                    /* 이벤트 처리 — 자주 불러야 함 */
        if (tud_cdc_available()) {
            uint8_t buf[64];
            uint32_t n = tud_cdc_read(buf, sizeof buf);
            tud_cdc_write(buf, n);     /* 에코 */
            tud_cdc_write_flush();
        }
    }
}

static volatile bool term_open;

void tud_cdc_line_state_cb(uint8_t itf, bool dtr, bool rts)
{
    (void)itf; (void)rts;
    term_open = dtr;                   /* 터미널이 포트를 열었는지 */
}
/* 디스크립터는 tud_descriptor_device_cb · _configuration_cb · _string_cb 로 제공 */
ST USB DeviceTinyUSB
설정CubeMX 클릭설정 헤더 + 디스크립터 코드
버퍼직접 (링 필요)FIFO 내장
복합 장치(CDC+HID)손이 많이 감설정으로 조합
이식성STM32 전용STM32 · RP2040 · nRF · ESP32-S3 …

USB 가 UART 보다 어려운 지점 — F446 에서 실제로 막히는 곳 🔴 실무

걸림돌설명 · 해결
48 MHz 클럭USB FS 는 ±0.25 % 정확도가 필요 → HSE 필수(HSI ±1 % 로는 불가). SYSCLK 180 MHz 에선 PLLQ 로 48 이 안 나와 PLLSAI 에서 만들거나 SYSCLK 를 168 MHz 로(336 ÷ 7 = 48)
Nucleo 에 USB 단자 없음Nucleo-F446RE 의 USB 는 ST-LINK 용. 사용자 USB 는 PA11(D−) · PA12(D+)를 브레이크아웃 보드로 따로 연결
VBUS 감지VBUS 핀을 안 이었으면 CubeMX 에서 VBUS sensing 을 끔 — 안 그러면 "연결 안 됨"으로 판단
열거 실패디스크립터 길이 · 바이트 하나 틀림 → "알 수 없는 USB 장치(장치 설명자 요청 실패)"
64 B 배수 길이 송신호스트는 64 B 보다 짧은 패킷(또는 0 바이트 패킷)이 와야 전송 끝으로 봄 — 스택이 ZLP 를 보내는지 확인
재연결펌웨어만 리셋하면 PC 는 장치가 그대로라고 믿음 → 시작 시 D+ 풀업을 잠깐 끊어(soft disconnect) 재열거 유도

"보레이트"는 무시됩니다

CDC 가상 COM 은 진짜 시리얼선이 아니므로 PC 터미널에서 9600 을 고르든 921600 을 고르든 속도는 USB 가 정합니다. 호스트가 보낸 보레이트는 SET_LINE_CODING 요청으로 장치에 전달만 되고, 장치가 USB-RS485 변환기처럼 실제 UART 를 내보낼 때만 의미가 있습니다.

/* TinyUSB — 터미널이 고른 보레이트를 실제 UART 에 반영하는 브리지 */
void tud_cdc_line_coding_cb(uint8_t itf, cdc_line_coding_t const *lc)
{
    (void)itf;
    huart1.Init.BaudRate = lc->bit_rate;   /* 9600, 115200 … */
    HAL_UART_Init(&huart1);
}
DTR 로 "누가 듣고 있나" 판단 — 터미널이 포트를 열지 않은 상태에서 계속 송신하면 스택 FIFO 가 차서 막힙니다. tud_cdc_connected()(DTR 기준)가 거짓이면 로그를 버리도록 해 두면 PC 를 안 꽂아도 펌웨어가 멈추지 않습니다. Windows 10 이후는 CDC-ACM 용 드라이버(usbser.sys)가 기본 내장이라 별도 설치가 필요 없습니다.
정리 — USB 는 호스트가 모든 대화를 시작하고, 장치는 디스크립터와 엔드포인트로 자신을 설명합니다. F446 에서 CDC 를 띄우려면 HSE 기반 48 MHz · PA11/PA12 연결 · VBUS 감지 설정을 먼저 맞추고, 수신 콜백은 복사 + 재무장만 합니다.
IO 09

센서 드라이버 작성법 — BME280 을 버스와 분리된 드라이버로

인터넷에서 가져온 센서 예제는 대개 HAL_I2C_Mem_Read(&hi2c1, …) 가 드라이버 한가운데 박혀 있습니다. 그러면 SPI 로 바꿀 때 다시 짜야 하고, PC 에서 테스트할 수도 없고, 같은 센서 두 개를 붙일 수도 없습니다. 이 절은 온습도 · 기압 센서 BME280 을 예로, 데이터시트에서 드라이버를 만드는 순서 — 레지스터 맵 → 칩 ID → 보정 데이터 → 보정식 → 버스 독립 인터페이스 → 에러 규약 — 를 따라갑니다. 서버로 치면 리포지토리 인터페이스를 두고 DB 구현을 갈아 끼우는 구조입니다.

계층 — 드라이버는 인터페이스만 부르고, 버스는 아래에서 갈아 끼웁니다 앱 · 태스크 — bme280_measure(&dev, &m) 측정값만 안다 (0.01 ℃ · Pa · %RH) bme280.c — 레지스터 · 보정식 · 에러 규약 버스를 모른다 HAL 을 include 하지 않음 bme280_bus_t { read · write · delay_ms · ctx } 함수 포인터 3개 + 문맥 = 자바의 인터페이스 I²C 어댑터 HAL_I2C_Mem_Read SPI 어댑터 reg | 0x80 · CS 테스트 페이크 레지스터 배열 256 B 바꿔 끼우는 층 — 위쪽 코드는 그대로 HAL · I2C1 HAL · SPI1 PC gcc · 단위 테스트 호출 방향 같은 bme280.c 가 보드에서는 I²C 로, CI 에서는 페이크로 돕니다
레이어 스택 — 드라이버가 아는 것은 인터페이스 하나. 그래서 버스 교체 · 센서 두 개 · PC 테스트가 공짜로 따라옵니다.

① 데이터시트에서 뽑는 레지스터 맵

주소이름쓰는 법
0x88~0xA1calib00~25T1~T3 · P1~P9 (16비트 LE) · H1
0xD0id0x60 (BMP280 은 0x58 — 습도 없음)
0xE0reset0xB6 쓰면 소프트 리셋
0xE1~0xE7calib26~32H2~H6 (H4 · H5 는 12비트로 쪼개짐)
0xF2ctrl_hum습도 오버샘플링 — ctrl_meas 를 써야 반영
0xF3statusbit3 measuring · bit0 im_update
0xF4ctrl_measosrs_t[7:5] · osrs_p[4:2] · mode[1:0]
0xF5config대기 시간 · IIR 필터
0xF7~0xFEpress · temp · hum20 · 20 · 16비트 원시값, 한 번에 버스트로
데이터 레지스터는 한 번의 버스트로 읽으세요. BME280 은 버스트 읽기 동안 값이 바뀌지 않도록 잠가 줍니다(섀도잉). 온도와 기압을 따로 두 번 읽으면 서로 다른 측정의 값이 섞일 수 있습니다.

② bme280.h — 인터페이스와 에러 규약

#pragma once
#include <stdint.h>
#include <stddef.h>

/* 버스 함수: 0 = 성공, 그 외 = 실패 (값은 어댑터 마음대로) */
typedef struct {
    int  (*read)(void *ctx, uint8_t reg, uint8_t *buf, size_t len);
    int  (*write)(void *ctx, uint8_t reg, uint8_t val);
    void (*delay_ms)(void *ctx, uint32_t ms);
    void *ctx;                          /* I²C 핸들+주소, SPI 장치, 페이크 … */
} bme280_bus_t;

typedef enum {                          /* 0 = 성공, 음수 = 실패 — 한 가지 규약 */
    BME280_OK        =  0,
    BME280_E_PARAM   = -1,              /* NULL · 잘못된 인자 */
    BME280_E_BUS     = -2,              /* 버스 함수가 실패 */
    BME280_E_CHIP_ID = -3,              /* 다른 칩이 대답함 */
    BME280_E_TIMEOUT = -4,              /* 상태 비트가 안 바뀜 */
} bme280_err_t;

typedef struct {
    uint16_t T1; int16_t T2, T3;
    uint16_t P1; int16_t P2, P3, P4, P5, P6, P7, P8, P9;
    uint8_t  H1, H3; int16_t H2, H4, H5; int8_t H6;
} bme280_calib_t;

typedef struct {
    bme280_bus_t   bus;
    bme280_calib_t cal;
    int32_t        t_fine;              /* 온도 → 기압 · 습도 보정에 쓰임 */
} bme280_t;

typedef struct {
    int32_t  t_c100;                    /* 0.01 ℃ (2508 = 25.08 ℃) */
    uint32_t p_q24_8;                   /* Pa × 256 */
    int32_t  adc_h;                     /* 습도 원시값 (보정은 데이터시트 식) */
} bme280_meas_t;

bme280_err_t bme280_init(bme280_t *dev);
bme280_err_t bme280_measure(bme280_t *dev, bme280_meas_t *m);

초기화와 측정 — 칩 ID · 리셋 · 보정 데이터 · 강제 모드 🟡 중급

③ bme280.c — init · measure

#include "bme280.h"

#define RD(d, r, b, n) ((d)->bus.read((d)->bus.ctx, (r), (b), (n)))
#define WR(d, r, v)    ((d)->bus.write((d)->bus.ctx, (r), (v)))
#define DLY(d, ms)     ((d)->bus.delay_ms((d)->bus.ctx, (ms)))

static uint16_t u16le(const uint8_t *p) { return (uint16_t)(p[0] | (p[1] << 8)); }
static int16_t  s16le(const uint8_t *p) { return (int16_t)u16le(p); }
static int32_t  comp_t(bme280_t *d, int32_t adc_T);   /* ④ 에 정의 */
static uint32_t comp_p(bme280_t *d, int32_t adc_P);

bme280_err_t bme280_init(bme280_t *dev)
{
    uint8_t id, st, c[26], e[7];
    int i;
    if (!dev || !dev->bus.read || !dev->bus.write || !dev->bus.delay_ms)
        return BME280_E_PARAM;

    if (RD(dev, 0xD0, &id, 1)) return BME280_E_BUS;
    if (id != 0x60)            return BME280_E_CHIP_ID;

    if (WR(dev, 0xE0, 0xB6))   return BME280_E_BUS;     /* 소프트 리셋 */
    DLY(dev, 2);
    for (i = 0; i < 10; i++) {                         /* NVM → 보정 레지스터 복사 대기 */
        if (RD(dev, 0xF3, &st, 1)) return BME280_E_BUS;
        if (!(st & 0x01)) break;                        /* im_update = 0 이면 끝 */
        DLY(dev, 1);
    }
    if (i == 10) return BME280_E_TIMEOUT;

    if (RD(dev, 0x88, c, 26) || RD(dev, 0xE1, e, 7)) return BME280_E_BUS;
    bme280_calib_t *k = &dev->cal;
    k->T1 = u16le(&c[0]);  k->T2 = s16le(&c[2]);  k->T3 = s16le(&c[4]);
    k->P1 = u16le(&c[6]);  k->P2 = s16le(&c[8]);  k->P3 = s16le(&c[10]);
    k->P4 = s16le(&c[12]); k->P5 = s16le(&c[14]); k->P6 = s16le(&c[16]);
    k->P7 = s16le(&c[18]); k->P8 = s16le(&c[20]); k->P9 = s16le(&c[22]);
    k->H1 = c[25];                                      /* 0xA1 (0xA0 은 비어 있음) */
    k->H2 = s16le(&e[0]);
    k->H3 = e[2];
    k->H4 = (int16_t)(((int16_t)(int8_t)e[3] * 16) | (e[4] & 0x0F));   /* 0xE4[7:0] 0xE5[3:0] */
    k->H5 = (int16_t)(((int16_t)(int8_t)e[5] * 16) | (e[4] >> 4));     /* 0xE6[7:0] 0xE5[7:4] */
    k->H6 = (int8_t)e[6];

    if (WR(dev, 0xF2, 0x01)) return BME280_E_BUS;       /* 습도 ×1 (다음 F4 쓰기에서 반영) */
    return BME280_OK;
}

bme280_err_t bme280_measure(bme280_t *dev, bme280_meas_t *m)
{
    uint8_t d[8], st;
    if (!dev || !m) return BME280_E_PARAM;
    if (WR(dev, 0xF4, 0x25)) return BME280_E_BUS;       /* 001 001 01 = T×1 P×1 강제 모드 */
    DLY(dev, 10);                                        /* ×1 셋이면 최대 약 9.3 ms */
    for (int i = 0; ; i++) {
        if (RD(dev, 0xF3, &st, 1)) return BME280_E_BUS;
        if (!(st & 0x08)) break;                         /* measuring = 0 */
        if (i >= 5) return BME280_E_TIMEOUT;
        DLY(dev, 1);
    }
    if (RD(dev, 0xF7, d, 8)) return BME280_E_BUS;        /* 한 번의 버스트 */
    int32_t adc_p = (int32_t)(((uint32_t)d[0] << 12) | ((uint32_t)d[1] << 4) | (d[2] >> 4));
    int32_t adc_t = (int32_t)(((uint32_t)d[3] << 12) | ((uint32_t)d[4] << 4) | (d[5] >> 4));
    m->adc_h  = (int32_t)(((uint32_t)d[6] << 8) | d[7]);
    m->t_c100 = comp_t(dev, adc_t);                      /* t_fine 을 먼저 채움 */
    m->p_q24_8 = comp_p(dev, adc_p);
    return BME280_OK;
}

④ 보정식 — 데이터시트의 정수 버전 그대로

/* 데이터시트 4.2.3 — 결과 0.01 ℃. t_fine 은 기압 · 습도에 다시 쓰임 */
static int32_t comp_t(bme280_t *d, int32_t adc_T)
{
    const bme280_calib_t *k = &d->cal;
    int32_t var1 = ((((adc_T >> 3) - ((int32_t)k->T1 << 1))) * ((int32_t)k->T2)) >> 11;
    int32_t var2 = (((((adc_T >> 4) - ((int32_t)k->T1)) *
                      ((adc_T >> 4) - ((int32_t)k->T1))) >> 12) *
                    ((int32_t)k->T3)) >> 14;
    d->t_fine = var1 + var2;
    return (d->t_fine * 5 + 128) >> 8;
}

/* 결과 Pa × 256 (Q24.8) — 64비트 정수 버전 */
static uint32_t comp_p(bme280_t *d, int32_t adc_P)
{
    const bme280_calib_t *k = &d->cal;
    int64_t var1, var2, p;
    var1 = ((int64_t)d->t_fine) - 128000;
    var2 = var1 * var1 * (int64_t)k->P6;
    var2 = var2 + ((var1 * (int64_t)k->P5) << 17);
    var2 = var2 + (((int64_t)k->P4) << 35);
    var1 = ((var1 * var1 * (int64_t)k->P3) >> 8) + ((var1 * (int64_t)k->P2) << 12);
    var1 = (((((int64_t)1) << 47) + var1)) * ((int64_t)k->P1) >> 33;
    if (var1 == 0) return 0;                       /* 0 으로 나누기 방지 */
    p = 1048576 - adc_P;
    p = (((p << 31) - var2) * 3125) / var1;
    var1 = (((int64_t)k->P9) * (p >> 13) * (p >> 13)) >> 25;
    var2 = (((int64_t)k->P8) * p) >> 19;
    p = ((p + var1 + var2) >> 8) + (((int64_t)k->P7) << 4);
    return (uint32_t)p;
}
검증 — 데이터시트 예제 보정값 (Bosch BMP280 문서의 예, T · P 식은 BME280 과 동일)
 T1=27504 T2=26435 T3=-1000 · adc_T=519888
   var1=128793 var2=-371 t_fine=128422 → T = 2508 (25.08 ℃) ✓ 문서와 일치
 P1=36477 P2=-10685 P3=3024 P4=2855 P5=140 P6=-7 P7=15500 P8=-14600 P9=6000
 adc_P=415148 → p = 25767233 → 100653.25 Pa (문서의 부동소수 결과 100653.27)
 (int32 · int64 넘침 · 시프트 · 나눗셈 방향까지 BigInt 로 흉내 내 계산)
음수의 왼쪽 시프트(var1 * P5 << 17 처럼 var1 이 음수일 때)는 C 표준상 정의되지 않은 동작입니다. 데이터시트 식 그대로이고 GCC · Clang 은 2의 보수대로 계산하지만, MISRA · UBSan 을 통과해야 한다면 * 131072 처럼 곱셈으로 바꾸세요(결과 동일). 습도는 데이터시트의 bme280_compensate_H_int32 를 같은 방식으로 옮기며, 결과는 Q22.10(값 ÷ 1024 = %RH)입니다.

어댑터와 페이크 — 보드에선 I²C, CI 에선 PC 🔴 실무

bme280_port_stm32.c — I²C · SPI 어댑터

#include "bme280.h"
#include "main.h"

typedef struct { I2C_HandleTypeDef *h; uint8_t addr7; } i2c_ctx_t;

static int i2c_rd(void *c, uint8_t reg, uint8_t *b, size_t n)
{
    i2c_ctx_t *x = c;
    return HAL_I2C_Mem_Read(x->h, (uint16_t)(x->addr7 << 1), reg,
                            I2C_MEMADD_SIZE_8BIT, b, (uint16_t)n, 10) != HAL_OK;
}
static int i2c_wr(void *c, uint8_t reg, uint8_t v)
{
    i2c_ctx_t *x = c;
    return HAL_I2C_Mem_Write(x->h, (uint16_t)(x->addr7 << 1), reg,
                             I2C_MEMADD_SIZE_8BIT, &v, 1, 10) != HAL_OK;
}
static void hal_dly(void *c, uint32_t ms) { (void)c; HAL_Delay(ms); }  /* RTOS 면 vTaskDelay */

/* SPI 버전 — 읽기는 reg|0x80, 쓰기는 reg&0x7F (p06 의 spi_dev_t) */
static int spi_rd(void *c, uint8_t reg, uint8_t *b, size_t n)
{ return bme_spi_read(c, reg, b, (uint16_t)n) != HAL_OK; }

/* 같은 센서 두 개 = ctx 두 개 */
static i2c_ctx_t in_ctx  = { &hi2c1, 0x76 };     /* SDO → GND */
static i2c_ctx_t out_ctx = { &hi2c1, 0x77 };     /* SDO → VDDIO */
bme280_t indoor  = { .bus = { i2c_rd, i2c_wr, hal_dly, &in_ctx  } };
bme280_t outdoor = { .bus = { i2c_rd, i2c_wr, hal_dly, &out_ctx } };

test_bme280.c — PC 에서 gcc 로 (보드 없이)

#include "bme280.h"
#include <assert.h>
#include <string.h>

typedef struct { uint8_t r[256]; int fail_at; int calls; } fake_t;

static int f_rd(void *c, uint8_t reg, uint8_t *b, size_t n)
{
    fake_t *f = c;
    if (++f->calls == f->fail_at) return -1;      /* 원하는 순번에서 버스 오류 주입 */
    memcpy(b, &f->r[reg], n);
    return 0;
}
static int  f_wr(void *c, uint8_t reg, uint8_t v) { ((fake_t *)c)->r[reg] = (reg == 0xE0) ? 0 : v; return 0; }
static void f_dly(void *c, uint32_t ms) { (void)c; (void)ms; }

static void put16(fake_t *f, uint8_t reg, int v) { f->r[reg] = (uint8_t)v; f->r[reg + 1] = (uint8_t)((unsigned)v >> 8); }

int main(void)
{
    fake_t f = { .fail_at = -1 };
    bme280_t dev = { .bus = { f_rd, f_wr, f_dly, &f } };
    bme280_meas_t m;

    f.r[0xD0] = 0x58;                               /* BMP280 이 대답하면 */
    assert(bme280_init(&dev) == BME280_E_CHIP_ID);

    f.r[0xD0] = 0x60;
    const int cal[] = { 27504, 26435, -1000, 36477, -10685, 3024,
                        2855, 140, -7, 15500, -14600, 6000 };
    for (int i = 0; i < 12; i++) put16(&f, (uint8_t)(0x88 + 2 * i), cal[i]);
    f.r[0xF7] = 0x65; f.r[0xF8] = 0x5A; f.r[0xF9] = 0xC0;   /* adc_P = 415148 */
    f.r[0xFA] = 0x7E; f.r[0xFB] = 0xED; f.r[0xFC] = 0x00;   /* adc_T = 519888 */

    assert(bme280_init(&dev) == BME280_OK);
    assert(bme280_measure(&dev, &m) == BME280_OK);
    assert(m.t_c100 == 2508);
    assert(m.p_q24_8 == 25767233u);

    f.calls = 0; f.fail_at = 1;                    /* 첫 읽기에서 실패 */
    assert(bme280_init(&dev) == BME280_E_BUS);
    return 0;
}
/* gcc -std=c11 -Wall -Wextra test_bme280.c bme280.c && ./a.out */
페이크의 레지스터 배치(리틀 엔디언 보정값 · 20비트 원시값)와 기대값 2508 · 25767233 은 같은 파싱 · 보정 로직을 node 로 옮겨 확인했습니다. 테스트 전략과 CI 연결은 🐞 디버깅 · 테스트 탭에서 더 다룹니다.
새 센서 드라이버를 만들 때의 순서 — ① 데이터시트에서 레지스터 맵 표를 옮긴다 ② 칩 ID 읽기로 배선부터 확인 ③ 리셋 · 시작 대기 조건(상태 비트)을 찾는다 ④ 보정 · 변환식은 데이터시트의 예제 값으로 PC 에서 먼저 맞춘다 ⑤ 버스 인터페이스 구조체 · 에러 enum 을 정한다 ⑥ 어댑터는 마지막에. 제조사 공식 드라이버(Bosch 의 BME280 SensorAPI 처럼)가 있으면 같은 구조로 되어 있으니, 어댑터만 써서 붙이는 것이 가장 빠르고 안전합니다.
정리 — 드라이버는 read · write · delay 함수 포인터 + ctx 하나에만 의존하게 짜고, 에러는 0 / 음수 enum 한 규약으로 돌려줍니다. 보정식은 데이터시트 예제 값(T = 2508)으로 PC 에서 먼저 검증합니다.
IO 10

디스플레이 — SSD1306 OLED 128×64, 1 KB 프레임버퍼와 페이지 주소

0.96 인치 OLED 모듈(SSD1306 컨트롤러, 128×64 흑백)은 디버그 화면 · 소형 장비 UI 의 사실상 표준입니다. 화면 자체는 단순하지만 메모리 배치가 직관과 다릅니다 — 한 바이트가 가로 8픽셀이 아니라 세로 8픽셀입니다. 이걸 이해하면 점 찍기 · 글자 그리기 · 부분 갱신이 전부 몇 줄로 끝나고, 모르면 글자가 세로로 뒤집혀 나옵니다. 그리고 p01 의 계산대로 I²C 400 kHz 로는 전체 화면 갱신에 23 ms 가 걸리므로 바뀐 부분만 보내는 설계가 필요합니다.

한 바이트 = 세로 8픽셀 — 픽셀 (5, 10) 은 buf[133] 의 bit 2 P0 P1 P2 P3 P4 P5 P6 P7 화면 128 × 64 — 페이지 8개 × 열 128개 x=5 열의 페이지 1 바이트 0 0 1 0 0 0 0 0 bit0 — y = 8 (페이지 맨 위) bit2 — y = 10 ← 이 점 bit7 — y = 15 (맨 아래) 바이트 값 = 0x04 페이지 = y ÷ 8 = 1 비트 = y % 8 = 2 P0 0~127 P1 128~ P2 256~ P3 384~ P4 512~ P5 640~ P6 768~ P7 ~1023 1 KB buf[x + (y / 8) × 128] |= 1 << (y % 8) — 점 찍기는 이 한 줄입니다 메모리 순서가 화면의 "페이지 줄" 순서라서, 한 페이지(128 B)만 골라 보내는 부분 갱신이 자연스럽습니다
메모리 레이아웃 — 페이지(8줄) 단위 · 세로 바이트. 가로 줄 단위 비트맵을 그대로 보내면 화면이 90° 꼬여 나옵니다.

I²C 로 말 거는 법 — 제어 바이트

주소 0x3C (모듈에 따라 0x3D) — HAL 에는 0x78 (= 0x3C << 1)

[S] [0x78] [제어 바이트] [바이트들 …] [P]
   제어 0x00 → 뒤따르는 바이트는 전부 "명령"
   제어 0x40 → 뒤따르는 바이트는 전부 "그림 데이터" (GDDRAM)

→ HAL_I2C_Mem_Write 의 "메모리 주소" 자리에 제어 바이트를 넣으면 딱 맞음
#define OLED_ADDR (0x3C << 1)
extern I2C_HandleTypeDef hi2c1;

static uint8_t fb[8][128];          /* [페이지][열] — 메모리 순서 = 화면 RAM 순서 */
static uint8_t dirty;               /* bit p = 페이지 p 가 바뀜 */

static HAL_StatusTypeDef oled_cmd(const uint8_t *c, uint16_t n)
{
    return HAL_I2C_Mem_Write(&hi2c1, OLED_ADDR, 0x00, I2C_MEMADD_SIZE_8BIT,
                             (uint8_t *)c, n, 50);
}

static const uint8_t init_seq[] = {
    0xAE,            /* 화면 끔 */
    0xD5, 0x80,      /* 내부 클럭 분주 */
    0xA8, 0x3F,      /* MUX = 64 행 */
    0xD3, 0x00,      /* 세로 오프셋 0 */
    0x40,            /* 시작 행 0 */
    0x8D, 0x14,      /* 차지 펌프 켬 — 대부분의 모듈은 내부 승압 */
    0x20, 0x00,      /* 수평 주소 모드 (페이지 끝에서 다음 페이지로) */
    0xA1, 0xC8,      /* 좌우 · 상하 뒤집기 (모듈 방향에 맞춤) */
    0xDA, 0x12,      /* COM 핀 배치 — 128×64 */
    0x81, 0xCF,      /* 대비 */
    0xD9, 0xF1,      /* 프리차지 */
    0xDB, 0x40,      /* VCOMH */
    0xA4, 0xA6,      /* RAM 내용 표시 · 반전 아님 */
    0xAF             /* 화면 켬 */
};

HAL_StatusTypeDef oled_init(void)
{
    HAL_Delay(100);                    /* 전원 안정 대기 — 모듈 리셋 회로 */
    return oled_cmd(init_seq, sizeof init_seq);
}

점 · 글자 · 전송

void oled_pixel(int x, int y, int on)
{
    if ((unsigned)x >= 128u || (unsigned)y >= 64u) return;   /* 음수도 한 번에 거름 */
    uint8_t m = (uint8_t)(1u << (y & 7));
    if (on) fb[y >> 3][x] |= m;
    else    fb[y >> 3][x] &= (uint8_t)~m;
    dirty |= (uint8_t)(1u << (y >> 3));
}

/* 5×7 글꼴 — 한 글자 = 열 5개, 각 열이 세로 바이트 (bit0 = 맨 위)
   화면 메모리와 같은 모양이라 복사만 하면 됨 */
static const uint8_t glyph_A[5] = { 0x7C, 0x12, 0x11, 0x12, 0x7C };
static const uint8_t glyph_0[5] = { 0x3E, 0x51, 0x49, 0x45, 0x3E };

/* y 가 8 의 배수가 아니어도 되는 버전 — 두 페이지에 걸쳐 쪼갬 */
void oled_glyph(int x, int y, const uint8_t g[5])
{
    int page = y >> 3, sh = y & 7;
    for (int i = 0; i < 5 && x + i < 128; i++) {
        if (page < 8) {
            fb[page][x + i] |= (uint8_t)(g[i] << sh);
            dirty |= (uint8_t)(1u << page);
        }
        if (sh && page + 1 < 8) {
            fb[page + 1][x + i] |= (uint8_t)(g[i] >> (8 - sh));
            dirty |= (uint8_t)(1u << (page + 1));
        }
    }
}

/* 바뀐 페이지만 보냄 — 페이지 하나 ≈ 3 ms (400 kHz) */
void oled_flush(void)
{
    for (uint8_t p = 0; p < 8; p++) {
        if (!(dirty & (1u << p))) continue;
        const uint8_t win[] = { 0x21, 0, 127, 0x22, p, p };   /* 열 0~127, 페이지 p */
        oled_cmd(win, sizeof win);
        HAL_I2C_Mem_Write(&hi2c1, OLED_ADDR, 0x40, I2C_MEMADD_SIZE_8BIT,
                          fb[p], 128, 20);
    }
    dirty = 0;
}
oled_glyph 는 OR 로 그리므로 지우려면 해당 영역을 먼저 0 으로 채웁니다. 글꼴 데이터는 const 라 Flash 에 놓이고 RAM 을 쓰지 않습니다. 한글은 글자당 16×16 = 32 B · 2,350자 기준 약 75 KB 라, 쓰는 글자만 뽑아 넣는 것이 보통입니다.

갱신 속도와 설계 — 23 ms 를 어떻게 숨기나 🔴 실무

전송 시간 계산

전체 화면, I²C 400 kHz
 [주소 1 + 제어 1 + 데이터 1024] × 9비트 + START/STOP ≈ 9 236 비트
 → 23.1 ms   (100 kHz 면 92 ms — 눈에 보이게 느림)

페이지 하나 (명령 6 B 거래 + 데이터 128 B 거래)
 (1+1+6)×9 + (1+1+128)×9 = 72 + 1 170 = 1 242 비트 → 3.1 ms

전체 화면, SPI 8 MHz (4선, D/C 핀)
 1024 × 8 / 8 M → 1.0 ms
상황설계
숫자 몇 개만 바뀜dirty 페이지 — 1~2 페이지 = 3~6 ms
애니메이션 · 그래프SPI 모델 사용 또는 FMPI2C 1 MHz
CPU 를 잡지 말아야 함HAL_I2C_Mem_Write_DMA + 이중 버퍼
RTOS화면 전용 태스크 · 낮은 우선순위 · 버스 뮤텍스(센서와 공유 시)

흔한 함정

증상원인
화면 오른쪽 2열이 쓰레기 · 왼쪽이 밀림사실은 SH1106(1.3인치 모듈에 흔함) — RAM 이 132열 · 오프셋 2 · 수평 주소 모드 없음 → 페이지마다 B0+p · 열 주소 명령
아무것도 안 나옴차지 펌프(8D 14) 누락 · 주소 0x3D · 모듈 전원 3.3/5 V
위아래 · 좌우 뒤집힘A0/A1 · C0/C8 조합 바꾸기
글자가 세로로 눕거나 깨짐가로 비트맵(일반 BMP) 형식 글꼴 — 세로 바이트로 변환 필요
DMA 로 보냈는데 반쯤 지워진 그림전송 중에 fb 를 수정 → 이중 버퍼 또는 완료 콜백 뒤에 그리기
센서 읽기가 가끔 늦음같은 I²C 버스에서 23 ms 화면 전송이 버스를 점유
OLED 번인 — 같은 픽셀을 수천 시간 켜 두면 흔적이 남습니다. 상시 표시 장비는 일정 시간 입력이 없으면 끄거나(0xAE) 대비를 낮추고(0x81), 고정 아이콘 위치를 조금씩 옮기는 처리를 넣습니다.
정리 — SSD1306 의 1 KB 는 페이지 8개 × 세로 바이트 128개입니다. 점은 fb[y/8][x] |= 1 << (y%8), 갱신은 바뀐 페이지만(3 ms) 보내고, 1.3 인치 모듈은 SH1106 인지 먼저 확인합니다.
IO 11

외부 Flash · EEPROM — W25Q 명령 · 256 B 페이지 · 4 KB 지우기, 마모와 littlefs

로그 · 설정 · 펌웨어 업데이트 이미지를 저장하려면 MCU 내부 Flash(512 KB)로는 부족하거나 위험합니다(지우는 동안 CPU 가 멈춤 — p04). 그래서 몇백 원짜리 SPI NOR Flash(Winbond W25Q 계열이 사실상 표준)를 붙입니다. 규칙은 RAM 과 전혀 다릅니다 — 쓰기는 1 을 0 으로만 바꿀 수 있고, 0 을 1 로 되돌리려면 4 KB 단위로 통째로 지워야 하며, 한 번에 쓸 수 있는 건 256 B 페이지 안뿐이고, 한 섹터는 약 10만 번 지우면 닳습니다. 서버로 치면 SSD 의 FTL(플래시 변환 계층)이 하던 일을 펌웨어가 직접 해야 합니다.

W25Q128 (16 MB) — 칩 › 블록 › 섹터 › 페이지 칩 16 MB 블록 × 256 B0 블록 1 ~ 255 (64 KB 씩) 블록 64 KB 지우기 D8h S0 S1 S2 섹터 3 ~ 15 (4 KB 씩) 섹터 4 KB 지우기 20h P0 P1 P2 페이지 3 ~ 15 (256 B 씩) 페이지 256 B 쓰기 02h 84 B 가 앞을 덮음 ✕ 0x54 ~ 0xEF 그대로 16 B 0xF0 에 100 B 를 한 번에 쓰면 → 페이지 끝에서 처음으로 감깁니다 드라이버가 페이지 경계에서 잘라 0x100 부터 따로 써야 합니다 (16 B + 84 B 두 번)
메모리 계층 — 지우기는 4 KB, 쓰기는 256 B 페이지 안. 경계를 넘는 쓰기는 오류 없이 엉뚱한 곳을 덮습니다.

자주 쓰는 명령 (W25Q128JV)

명령이름뒤따르는 것시간 (전형 / 최대)
9FhJEDEC ID읽기 3 B → EF 40 18—
03hRead Data주소 3 B → 계속 읽기SCK ≤ 50 MHz
06hWrite Enable— (쓰기 · 지우기 전마다)—
02hPage Program주소 3 B + 1~256 B0.4 / 3 ms
20hSector Erase 4 KB주소 3 B45 / 400 ms
D8hBlock Erase 64 KB주소 3 B150 / 2000 ms
C7hChip Erase—40 / 200 초
05hRead Status-1읽기 1 B: bit0 BUSY · bit1 WEL—
B9h / ABhPower-down / 해제—저전력 대기
지우기 시간의 "최대"로 설계하세요. 섹터 하나에 최대 400 ms 동안 칩은 BUSY 입니다. 그동안 같은 칩을 읽을 수 없고, 블로킹으로 기다리면 400 ms 동안 다른 일이 멈춥니다. 128 Mbit 를 넘는 W25Q256 부터는 4바이트 주소 모드가 필요합니다.

NOR Flash 의 세 가지 법칙

① 지우면 전부 1 (0xFF)
② 쓰기(program)는 1 → 0 만 가능 — 이미 0 인 비트는 그대로
   0xFF 에 0xA5 쓰기 → 0xA5
   0xA5 에 0x5A 쓰기 → 0xA5 & 0x5A = 0x00   (덮어쓰기가 아니라 AND)
③ 0 을 1 로 되돌리는 유일한 방법 = 그 비트가 든 섹터 4 KB 전체 지우기

그래서 "설정 4바이트 바꾸기" =
   섹터 4 KB 를 RAM 으로 읽기 → RAM 에서 수정 → 섹터 지우기 → 16 페이지 다시 쓰기
   (도중에 전원이 나가면 4 KB 가 통째로 사라짐!)
EEPROM(24C256 등 I²C)은 바이트 단위로 덮어쓸 수 있고 지우기가 따로 없어 설정값 몇 개에는 더 간단합니다. 대신 쓰기가 페이지(64 B) 단위로 5 ms 씩 걸리고 용량이 KB 단위입니다. 수 MB 의 로그 · 펌웨어 이미지는 NOR, 몇 바이트 설정은 EEPROM 이나 아래 littlefs.

w25q.c — BUSY 대기 · 페이지 경계 자르기 · 섹터 지우기 🟡 중급

/* spi_dev_t · cs_low · cs_high 는 p06 의 것 */
static int w25q_wait(const spi_dev_t *d, uint32_t timeout_ms)
{
    uint8_t  cmd = 0x05, sr = 0;
    uint32_t t0  = HAL_GetTick();
    do {
        cs_low(d);
        HAL_SPI_Transmit(d->hspi, &cmd, 1, 10);
        HAL_SPI_Receive(d->hspi, &sr, 1, 10);
        cs_high(d);
        if (!(sr & 0x01)) return 0;                   /* BUSY = 0 → 끝 */
    } while (HAL_GetTick() - t0 < timeout_ms);
    return -1;
}

static void w25q_wren(const spi_dev_t *d)
{
    uint8_t c = 0x06;
    cs_low(d); HAL_SPI_Transmit(d->hspi, &c, 1, 10); cs_high(d);
}

int w25q_read(const spi_dev_t *d, uint32_t addr, uint8_t *p, uint16_t n)
{
    uint8_t h[4] = { 0x03, (uint8_t)(addr >> 16), (uint8_t)(addr >> 8), (uint8_t)addr };
    cs_low(d);
    HAL_StatusTypeDef st = HAL_SPI_Transmit(d->hspi, h, 4, 10);
    if (st == HAL_OK) st = HAL_SPI_Receive(d->hspi, p, n, 100);
    cs_high(d);
    return st == HAL_OK ? 0 : -1;
}
/* 임의 길이 쓰기 — 페이지 경계마다 잘라 Page Program 반복
   (대상 영역은 미리 지워져 있어야 함) */
int w25q_write(const spi_dev_t *d, uint32_t addr, const uint8_t *p, uint32_t n)
{
    while (n) {
        uint32_t room  = 256u - (addr & 0xFFu);        /* 이 페이지에 남은 칸 */
        uint32_t chunk = n < room ? n : room;
        uint8_t  h[4]  = { 0x02, (uint8_t)(addr >> 16), (uint8_t)(addr >> 8), (uint8_t)addr };

        w25q_wren(d);
        cs_low(d);
        HAL_StatusTypeDef st = HAL_SPI_Transmit(d->hspi, h, 4, 10);
        if (st == HAL_OK)
            st = HAL_SPI_Transmit(d->hspi, (uint8_t *)p, (uint16_t)chunk, 100);
        cs_high(d);                                    /* CS 가 올라가는 순간 굽기 시작 */
        if (st != HAL_OK || w25q_wait(d, 5)) return -1;   /* tPP 최대 3 ms */

        addr += chunk; p += chunk; n -= chunk;
    }
    return 0;
}
/* 0xF0 에 100 B → 16 B (0xF0~0xFF) + 84 B (0x100~0x153) 두 번 */

int w25q_erase_4k(const spi_dev_t *d, uint32_t addr)  /* addr 의 하위 12비트는 무시됨 */
{
    uint8_t h[4] = { 0x20, (uint8_t)(addr >> 16), (uint8_t)(addr >> 8), (uint8_t)addr };
    w25q_wren(d);
    cs_low(d); HAL_SPI_Transmit(d->hspi, h, 4, 10); cs_high(d);
    return w25q_wait(d, 500);                          /* tSE 최대 400 ms */
}
쓰고 나서 읽어 비교(read-back verify)하는 습관을 들이세요. 쓰기 보호 비트(상태 레지스터의 BP · SRP)가 켜진 칩이나 WP# 핀이 LOW 인 회로는 명령이 조용히 무시됩니다. F446 에는 QUADSPI 가 있어 같은 칩을 4선으로 빠르게, 또는 메모리 맵(주소로 바로 읽기) 방식으로도 쓸 수 있습니다.

마모와 웨어 레벨링 — littlefs 에게 맡기기 🔴 실무

한 섹터만 계속 지우면

10 초마다 64 B 기록 — 하루 8 640 개
섹터 하나(4 KB)에 64 개 → 하루 135 번 지우기

한 섹터만 돌려 쓰면 : 100 000 / 135 = 740 일 ≈ 2 년 후 고장
1 MB(256 섹터)에 고르게 퍼뜨리면 : 256 배 → 약 520 년

= 웨어 레벨링(wear leveling) — 쓰기를 여러 섹터에 고르게 분산
방법특징
순환 로그섹터를 차례로 쓰고 끝에서 처음으로 — 레코드에 순번 · CRC
A/B 두 벌설정을 두 섹터에 번갈아 — 전원 차단에도 하나는 온전
littlefs파일 시스템이 웨어 레벨링 · 전원 차단 안전을 함께 처리
FAT (FatFs)PC 호환이 필요할 때(SD 카드). NOR 에선 마모 · 전원 차단에 약함

littlefs 를 W25Q 에 올리기

#include "lfs.h"

static int f_read(const struct lfs_config *c, lfs_block_t b, lfs_off_t off,
                  void *buf, lfs_size_t n)
{ return w25q_read(c->context, b * c->block_size + off, buf, (uint16_t)n) ? LFS_ERR_IO : 0; }
static int f_prog(const struct lfs_config *c, lfs_block_t b, lfs_off_t off,
                  const void *buf, lfs_size_t n)
{ return w25q_write(c->context, b * c->block_size + off, buf, n) ? LFS_ERR_IO : 0; }
static int f_erase(const struct lfs_config *c, lfs_block_t b)
{ return w25q_erase_4k(c->context, b * c->block_size) ? LFS_ERR_IO : 0; }
static int f_sync(const struct lfs_config *c) { (void)c; return 0; }

static spi_dev_t flash = { &hspi1, GPIOB, GPIO_PIN_6 };
static const struct lfs_config cfg = {
    .context = &flash,
    .read = f_read, .prog = f_prog, .erase = f_erase, .sync = f_sync,
    .read_size = 16, .prog_size = 256,
    .block_size = 4096, .block_count = 4096,       /* 4 KB × 4096 = 16 MB */
    .cache_size = 256, .lookahead_size = 32, .block_cycles = 500,
};

uint32_t boot_count(void)
{
    static lfs_t lfs;
    lfs_file_t f;
    uint32_t cnt = 0;
    if (lfs_mount(&lfs, &cfg)) {                     /* 첫 부팅이면 포맷 */
        lfs_format(&lfs, &cfg);
        lfs_mount(&lfs, &cfg);
    }
    lfs_file_open(&lfs, &f, "boot_count", LFS_O_RDWR | LFS_O_CREAT);
    lfs_file_read(&lfs, &f, &cnt, sizeof cnt);
    cnt++;
    lfs_file_rewind(&lfs, &f);
    lfs_file_write(&lfs, &f, &cnt, sizeof cnt);
    lfs_file_close(&lfs, &f);                         /* 닫아야(또는 sync) 확정 */
    lfs_unmount(&lfs);
    return cnt;
}
"마운트 실패 → 포맷"은 첫 부팅에만 맞는 처리입니다. 양산품에서 일시적 SPI 오류로 마운트가 실패했는데 포맷하면 사용자 데이터가 전부 사라집니다. 공장 출하 때 포맷하고, 현장에서는 재시도 · 오류 보고를 먼저 하세요. block_cycles 는 몇 번 지운 블록을 다른 곳으로 옮길지(웨어 레벨링 주기)입니다.
정리 — NOR Flash 는 지우면 1, 쓰기는 1→0, 지우기는 4 KB, 쓰기는 256 B 페이지 안. 드라이버는 WREN → 명령 → BUSY 대기와 페이지 경계 자르기가 전부이고, 마모 · 전원 차단은 직접 짜기보다 littlefs 에 맡깁니다.
IO 12

Modbus RTU — 산업 현장의 공용어, 프레임 · 3.5 문자 침묵 · 03/06/16

인버터 · 전력계 · 온도 조절기 · PLC 를 MCU 와 이어야 한다면 거의 확실히 Modbus RTU over RS-485 입니다. 1979년에 나온 단순한 프로토콜이지만 지금도 공장의 표준이고, 사양이 공개되어 있어 누구나 구현할 수 있습니다. 구조는 p03 에서 설계한 것과 같습니다 — 주소 · 명령 · 데이터 · CRC. 다른 점은 프레임 경계를 "침묵 시간"으로 정한다는 것 하나입니다. 서버로 치면 마스터가 묻고 슬레이브가 답하는 동기 RPC이고, 슬레이브는 절대 먼저 말하지 않습니다.

Modbus RTU 프레임 — 주소 · 함수 · 데이터 · CRC(하위 바이트 먼저) 요청 01 03 00 00 00 02 C4 0B 주소 1 함수 3 시작 주소 0 개수 2 CRC 0x0BC4 보유 레지스터 0~1 을 읽어 줘 응답 01 03 04 00 64 00 65 7B C7 4 B reg0 = 100 reg1 = 101 CRC 값은 큰 바이트 먼저 예외 응답 01 83 02 C0 F1 03 | 0x80 잘못된 주소 reg 63~64 요청 → 64 개뿐이라 예외 코드 02 CRC 는 주소부터 데이터 끝까지 · 0xA001(반사) · 초깃값 0xFFFF — p03 표의 MODBUS
해부도 — 값은 큰 바이트 먼저, CRC 만 작은 바이트 먼저. 처음 구현할 때 가장 많이 틀리는 두 가지입니다.

데이터 모델과 함수 코드

영역단위 · 권한읽기쓰기관례 표기
코일1비트 · 읽기/쓰기0105 · 150xxxx
이산 입력1비트 · 읽기02—1xxxx
입력 레지스터16비트 · 읽기04—3xxxx
보유 레지스터16비트 · 읽기/쓰기0306 · 16(0x10)4xxxx
"40001" 은 프로토콜 주소 0 입니다. 장비 매뉴얼의 "레지스터 40001" 을 그대로 0x9C41 로 보내면 예외 02 가 돌아옵니다. 앞자리 4 는 영역 표시이고 번호는 1 부터 세므로, 실제로 보낼 주소는 번호 − 40001 입니다. 매뉴얼마다 0 부터 세는 곳도 있으니 하나를 먼저 읽어 확인하세요.
예외뜻
01지원하지 않는 함수
02잘못된 데이터 주소 (범위 밖)
03잘못된 데이터 값 (개수 · 길이 오류)
04슬레이브 장치 고장

함수 06 · 16 — 실제 바이트 (계산 확인)

06 단일 쓰기 : reg 1 ← 3
 요청 01 06 00 01 00 03 98 0B
 응답 01 06 00 01 00 03 98 0B        (정상이면 요청을 그대로 되돌림)

16 다중 쓰기 : reg 1 ← 10, reg 2 ← 258
 요청 01 10 00 01 00 02 04 00 0A 01 02 92 30
          주소  개수  바이트수 값1  값2   CRC
 응답 01 10 00 01 00 02 10 08        (시작 주소 · 개수만 되돌림)

03 읽기 (위 그림) : 01 03 00 00 00 02 C4 0B → 01 03 04 00 64 00 65 7B C7
지원 안 하는 04  : 01 04 00 00 00 01 31 CA → 01 84 01 82 C0
다른 주소(2)     : 02 03 … → 응답 없음 (침묵)
위 바이트열은 아래 C 핸들러와 같은 로직을 node 로 옮겨 실제로 주고받은 결과입니다. 01 03 00 00 00 02 C4 0B 는 여러 Modbus 문서에 예로 나오는 프레임이라 CRC 구현을 맞춰 볼 때 좋은 기준입니다.

프레임 경계 — 3.5 문자 시간의 침묵 🟡 중급

시간이 구분자 — 한 칸 = 1 문자 (8E1 = 11비트) t1.5 t3.5 4 문자 ≥ 3.5 → 경계 2 문자 ✕ 프레임 A (8 B) 프레임 B — 1.5 < 침묵 < 3.5 → 버림 9600 4.01 ms 19200 2.005 ms 38400 ↑ 1.75 ms 고정 (계산값 1.0) 115200 1.75 ms 고정 (계산값 0.33) t3.5 = 3.5 × 11 ÷ 보레이트 — 19200 보를 넘으면 규격이 1.75 ms 로 고정 t1.5 도 같은 식(1.5 문자)이며 19200 보 초과에서는 0.75 ms 고정입니다 (Modbus over Serial Line 규격)
타임라인 + 막대 — 3.5 문자 이상 조용하면 프레임 끝. 고속에서는 계산값이 너무 짧아 규격이 하한을 고정해 둡니다.

F446 에서 t3.5 재기 — TIM6 원샷

/* F4 의 USART 에는 수신 타임아웃(RTO) 기능이 없습니다(F7 · L4 · G0 · H7 엔 있음).
   IDLE 은 "1 문자" 침묵이라 3.5 를 직접 재야 합니다 → 기본 타이머 TIM6 */

/* TIM6: 타이머 클럭 90 MHz, PSC 89 → 1 µs 단위, ARR = t3.5 − 1 (19200 → 2004)
   CubeMX 가 HAL 타임베이스로 TIM6 을 쓰고 있으면(FreeRTOS 프로젝트에 흔함) TIM7 로 */
void mb_timer_init(void)
{
    htim6.Instance->CR1 |= TIM_CR1_OPM;               /* 한 번 넘치면 스스로 멈춤 */
    __HAL_TIM_CLEAR_FLAG(&htim6, TIM_FLAG_UPDATE);   /* Init 의 UG 가 남긴 플래그 */
    __HAL_TIM_ENABLE_IT(&htim6, TIM_IT_UPDATE);
}

static uint8_t           fr[256];
static volatile uint16_t fr_len;
static volatile uint8_t  fr_ready;

void mb_rx_byte(uint8_t b)          /* USART1 수신 ISR 에서 바이트마다 */
{
    if (fr_ready) return;           /* 앞 프레임 처리 전 — 마스터는 응답 전에 안 보냄 */
    if (fr_len < sizeof fr) fr[fr_len++] = b;
    htim6.Instance->CNT = 0;        /* 바이트가 올 때마다 침묵 시계를 0 으로 */
    htim6.Instance->CR1 |= TIM_CR1_CEN;
}

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *h)
{
    if (h->Instance == TIM6) fr_ready = 1;   /* t3.5 동안 아무것도 안 옴 = 프레임 끝 */
}

void mb_poll(void)                  /* main 루프 */
{
    static uint8_t rsp[256];        /* DMA 송신 중에도 살아 있게 static */
    if (!fr_ready) return;
    size_t n = mb_handle(fr, fr_len, rsp);
    fr_len = 0;
    fr_ready = 0;
    if (n) rs485_send(rsp, (uint16_t)n);      /* p02 — DE 제어 포함 */
}

mb_handle — 03 · 06 · 16 슬레이브

#define MB_ADDR 1
#define NREG    64
static uint16_t hreg[NREG];                       /* 보유 레지스터 */

uint16_t mb_crc(const uint8_t *p, size_t n)
{
    uint16_t c = 0xFFFF;
    while (n--) {
        c ^= *p++;
        for (int k = 0; k < 8; k++)
            c = (c & 1u) ? (uint16_t)((c >> 1) ^ 0xA001u) : (uint16_t)(c >> 1);
    }
    return c;
}
static size_t exc(uint8_t *r, uint8_t a, uint8_t fc, uint8_t code)
{ r[0] = a; r[1] = (uint8_t)(fc | 0x80u); r[2] = code; return 3; }

#define U16(p) ((uint16_t)(((p)[0] << 8) | (p)[1]))

size_t mb_handle(const uint8_t *q, size_t n, uint8_t *r)
{
    if (n < 4 || mb_crc(q, n - 2) != (uint16_t)(q[n - 2] | (q[n - 1] << 8)))
        return 0;                                    /* CRC 틀림 → 침묵 (마스터가 재시도) */
    uint8_t a = q[0], fc = q[1];
    if (a != MB_ADDR && a != 0) return 0;           /* 0 = 브로드캐스트 */
    size_t len;
    switch (fc) {
    case 0x03: {
        if (n != 8) { len = exc(r, a, fc, 3); break; }
        uint16_t start = U16(&q[2]), cnt = U16(&q[4]);
        if (cnt < 1 || cnt > 125)          { len = exc(r, a, fc, 3); break; }
        if ((uint32_t)start + cnt > NREG)  { len = exc(r, a, fc, 2); break; }
        r[0] = a; r[1] = fc; r[2] = (uint8_t)(cnt * 2); len = 3;
        for (uint16_t i = 0; i < cnt; i++) {
            r[len++] = (uint8_t)(hreg[start + i] >> 8);   /* 큰 바이트 먼저 */
            r[len++] = (uint8_t)hreg[start + i];
        }
        break; }
    case 0x06: {
        if (n != 8) { len = exc(r, a, fc, 3); break; }
        uint16_t reg = U16(&q[2]);
        if (reg >= NREG) { len = exc(r, a, fc, 2); break; }
        hreg[reg] = U16(&q[4]);
        memcpy(r, q, 6); len = 6;                    /* 요청 그대로 되돌림 */
        break; }
    case 0x10: {
        if (n < 9) { len = exc(r, a, fc, 3); break; }
        uint16_t start = U16(&q[2]), cnt = U16(&q[4]);
        uint8_t  bc = q[6];
        if (cnt < 1 || cnt > 123 || bc != cnt * 2 || n != 9u + bc)
            { len = exc(r, a, fc, 3); break; }
        if ((uint32_t)start + cnt > NREG) { len = exc(r, a, fc, 2); break; }
        for (uint16_t i = 0; i < cnt; i++) hreg[start + i] = U16(&q[7 + 2 * i]);
        memcpy(r, q, 6); len = 6;                    /* 시작 주소 · 개수만 */
        break; }
    default:
        len = exc(r, a, fc, 1);
    }
    if (a == 0) return 0;                            /* 브로드캐스트엔 응답 안 함 */
    uint16_t c = mb_crc(r, len);
    r[len++] = (uint8_t)c;                           /* CRC 는 작은 바이트 먼저 */
    r[len++] = (uint8_t)(c >> 8);
    return len;
}

마스터 쪽과 현장 함정 — 타임아웃 · 재시도 · 32비트 값 🔴 실무

항목규칙
응답 타임아웃보통 100 ms~1 s (장비 매뉴얼). 넘으면 재시도 2~3회 후 "통신 이상"
요청 간격응답을 다 받은 뒤 최소 t3.5 쉬고 다음 요청
슬레이브 주소1~247 · 0 은 브로드캐스트(쓰기만, 응답 없음)
한 번에 읽는 개수03 은 최대 125 레지스터(250 B) — RTU 프레임 최대 256 B
형식기본 8E1. 패리티 없음(8N1)이면 정지 비트 2 가 규격 — 실제 장비는 8N1 도 흔함 (p02 의 WordLength 주의)
버스 배선RS-485 2선 · 종단 120 Ω 양끝 · 바이어스 (p02)

32비트 값 — 워드 순서는 장비마다 다릅니다

/* 레지스터 두 개로 float 25.5 (0x41CC0000) 를 보낼 때
   ABCD (큰 워드 먼저) : reg n = 0x41CC, reg n+1 = 0x0000
   CDAB (작은 워드 먼저): reg n = 0x0000, reg n+1 = 0x41CC
   레지스터 안의 바이트는 항상 큰 바이트 먼저 — 바뀌는 건 "워드 순서" */
float mb_float_abcd(const uint16_t r[2])
{
    uint32_t u = ((uint32_t)r[0] << 16) | r[1];
    float f;
    memcpy(&f, &u, sizeof f);            /* 포인터 캐스팅 대신 memcpy (엄격한 별칭 규칙) */
    return f;
}
장비를 처음 붙일 때는 PC 에서 USB-RS485 동글 + 마스터 툴(QModMaster · mbpoll 등)로 먼저 읽어 보고, 같은 요청을 MCU 로 옮기세요. 값이 이상하면 ① 주소 오프셋(40001) ② 워드 순서 ③ 배율(값 × 0.1) 순서로 의심하면 대부분 풀립니다.
정리 — Modbus RTU 는 주소 · 함수 · 데이터 · CRC(작은 바이트 먼저) 이고, 프레임 경계는 3.5 문자 침묵(19200 보 초과면 1.75 ms)입니다. F446 은 RTO 가 없으니 TIM 원샷으로 재고, 슬레이브는 CRC 가 틀리면 침묵, 범위 밖이면 예외 응답을 돌려줍니다.
IO 13

로직 분석기로 디버깅 — sigrok · PulseView, 증상에서 원인으로

"센서가 대답을 안 해요"의 원인은 코드 · 설정 · 배선 · 전기 중 어디에나 있을 수 있고, printf 로는 선 위에서 실제로 무슨 일이 일어났는지 볼 수 없습니다. 로직 분석기는 여러 선의 0/1 을 동시에 기록해 시간축에 펼치고, 프로토콜 디코더가 그 위에 "주소 0x76 쓰기 · NACK" 같은 해석을 얹어 줍니다. 서버 개발자에게 Wireshark 가 있다면 펌웨어 개발자에게는 이것이 있습니다. 1~2만 원짜리 8채널 장비와 무료 소프트웨어(sigrok)로 시작할 수 있고, 통신 문제의 대부분이 캡처 한 장으로 끝납니다.

장비와 샘플링 속도

장비특징
FX2 기반 8채널 클론24 MHz · sigrok 의 fx2lafw 펌웨어 · 가장 싸고 흔함
DSLogic 계열100 MHz 이상 · 자체 버퍼 · DSView(sigrok 파생)
Saleae Logic업계 표준 UI · 아날로그 채널 · 비쌈
오실로스코프(디코드 지원)파형의 모양(상승 시간 · 링잉 · 전압)을 봄
샘플링 속도 = 신호 최고 주파수 × 4 이상 (권장 10)
  UART 115200   → 1 MHz 이상     (비트 8.7 µs)
  I²C 400 kHz   → 4 MHz 이상
  SPI 10 MHz    → 40~100 MHz    ← 24 MHz 장비로는 부족!
  CAN 500 k     → 4 MHz 이상
  USB FS 12 M   → 100 MHz 이상 (사실상 전용 장비)

측정 시간 × 샘플링 속도 = 메모리 — 24 MHz × 1 초 = 2400만 샘플
로직 분석기는 문턱 전압으로 0/1 만 봅니다. 풀업이 약해 둥글게 올라가는 I²C 에지도 "언젠가 1 이 됨"으로 깔끔하게 그려 줄 수 있습니다. 디지털로는 멀쩡한데 통신이 깨지면 오실로스코프로 모양을 보세요. 그리고 GND 를 반드시 대상 보드 GND 에 연결합니다.

PulseView 첫 캡처 — 순서

1. 연결: D0 = SCL(또는 TX) · D1 = SDA · GND = GND
2. 장치 선택: fx2lafw → 샘플링 4 MHz · 샘플 수 4 M (1 초)
3. 트리거: D0 의 하강 에지 (I²C START · UART 시작 비트)
4. Run → 펌웨어에서 통신을 일으킴
5. 디코더 추가: "I²C" → SCL=D0, SDA=D1
   (UART 는 보레이트 · 패리티 · 비트 수를 맞게 입력)
6. 디코더 위에 쌓기: I²C 위에 "24xx EEPROM", UART 위에 "Modbus" 등
7. 이상한 곳을 확대 → 시간 커서로 간격 측정
# 명령줄(sigrok-cli) — 반복 측정 · CI 에서 자동 확인할 때
sigrok-cli --scan                                      # 장비 찾기
sigrok-cli -d fx2lafw --config samplerate=4m --samples 4m -o cap.sr
sigrok-cli -i cap.sr -P i2c:scl=D0:sda=D1             # I²C 해석 출력
sigrok-cli -i cap.sr -P uart:rx=D0:baudrate=115200
sigrok-cli -i cap.sr -P spi:clk=D0:mosi=D1:miso=D2:cs=D3:cpol=0:cpha=0
흔한 파형 오류 네 가지 — 점선이 정상, 실선이 실제 ① 풀업 없음 · 약함 SCL VIH 상어 지느러미 — 클럭이 VIH 에 겨우 닿음 ② 보레이트 불일치 RX 해석: 엉뚱한 값 · FE 파형은 깨끗한데 글자만 깨짐 — 비트 폭을 재 보세요 ③ CS 가 일찍 올라감 CS SCK 8번째 클럭 도중 CS 가 올라가 마지막 비트 유실 ④ SPI 모드 불일치 MOSI SCK 읽는 에지(점)에서 데이터가 바뀜 — 한 비트 밀림 ①은 스코프로 모양을, ②~④는 로직 분석기 타이밍으로 잡습니다
스파크 격자 — 모양 하나가 원인 하나입니다. 증상을 읽으면 아래 표의 어느 줄인지 바로 좁혀집니다.

증상 → 원인 표 — 캡처를 보고 첫 번째로 의심할 것 🟡 중급

버스캡처에서 보이는 것원인확인 · 해결
I²CSDA · SCL 이 항상 LOW 이거나 0 V 근처에서 안 올라옴풀업 없음 · 전원 안 들어감멀티미터로 쉬는 상태 전압 3.3 V 확인 · 풀업 추가 (p05)
I²C주소 바이트 뒤 9번째 클럭에서 SDA HIGH (NACK)주소 틀림(시프트 안 함 / 0x76↔0x77) · 장치 미응답버스 스캔 · addr7 << 1 · SDO 핀 레벨
I²CSDA 가 LOW 로 고정, SCL 은 HIGH, START 안 나감버스 락업 — 슬레이브가 읽기 도중 멈춤SCL 9펄스 복구 (p05) · 센서 전원 리셋
I²CSCL LOW 가 수 ms 늘어짐클럭 스트레칭(정상일 수 있음) · 슬레이브 처리 중HAL 타임아웃이 그보다 긴지 확인
UART파형은 깨끗한데 해석이 엉뚱 · FE보레이트 불일치 · 클럭 설정(HSE_VALUE)시작 비트 폭을 커서로 재서 1/폭 = 실제 보레이트
UARTRX 선에 아무것도 없음TX↔TX 연결(교차 안 함) · GND 미연결TX 는 상대 RX 로 · GND 공통
UART해석은 맞는데 0x80 이상만 틀림패리티 설정에 WordLength 8B (p02)8E1 이면 UART_WORDLENGTH_9B
RS-485마지막 바이트가 잘림 · 응답 첫 바이트 유실DE 전환 시점 틀림 (TXE 에서 내림 / 늦게 내림)DE 를 채널에 함께 찍어 TC 와 비교 (p02)
SPI읽은 값이 정확히 ×2 또는 ÷2모드(CPOL/CPHA) 불일치데이터시트 타이밍 그림과 모드 비교 (p06)
SPICS 가 클럭 도중 올라감 · 마지막 바이트 이상DMA/IT 완료 전에 CS HIGH · TXE 기준 판단완료 콜백 · BSY 대기 후 CS (p06)
SPIMISO 가 항상 0xFF 또는 0x00CS 핀 틀림 · 장치 전원 · MISO 를 다른 장치가 잡음CS 를 채널에 찍어 실제로 내려가는지
SPI저속에선 되고 고속에서 비트가 섞임배선 길이 · 링잉 · MISO 왕복 지연분주 올리기 · 직렬 저항 · 스코프로 모양 확인
CAN프레임 뒤 ACK 슬롯이 열성 · 같은 프레임 반복ACK 없음 — 상대 없음 · 종단 없음 · 보레이트CANH–CANL 60 Ω 확인 · 루프백 모드로 단독 시험 (p07)

펌웨어를 함께 찍는 법 — GPIO 하나로 코드의 시간을 보기 🔴 실무

디버그 핀 — 버스와 코드를 같은 시간축에

/* 남는 핀 하나(PC8)를 출력으로 — BSRR 쓰기는 원자적이고 수십 ns */
#define PROBE_ON()   (GPIOC->BSRR = GPIO_PIN_8)
#define PROBE_OFF()  (GPIOC->BSRR = (uint32_t)GPIO_PIN_8 << 16)
/* ON 바로 뒤 OFF 면 펄스가 수 ns — 24 MHz 장비(샘플 간격 41.7 ns)는 놓칩니다.
   HIGH · LOW 를 각각 잠깐(≈0.5 µs) 유지해야 깜빡임이 찍힘 */
#define PROBE_HOLD()  for (volatile int i_ = 0; i_ < 20; i_++) { }
#define PROBE_PULSE() do { PROBE_ON(); PROBE_HOLD(); PROBE_OFF(); PROBE_HOLD(); } while (0)

void USART2_IRQHandler(void)
{
    PROBE_ON();                 /* ISR 진입 */
    /* … p04 의 수신 처리 … */
    PROBE_OFF();                /* ISR 끝 — 펄스 폭 = ISR 실행 시간 */
}

void HAL_UART_ErrorCallback(UART_HandleTypeDef *h)
{
    PROBE_PULSE(); PROBE_PULSE();   /* 오류는 두 번 깜빡 — 캡처에서 바로 찾기 */
    /* … */
}
D0~D1 에 버스, D2 에 디버그 핀을 물리면 "ORE 가 난 순간 ISR 이 늦게 들어갔나?" "DE 를 내린 시점이 정지 비트 전인가?"에 추측이 아니라 µs 단위 측정으로 답할 수 있습니다. printf 는 시간을 바꾸지만 BSRR 한 번은 거의 바꾸지 않습니다.

통신이 안 될 때 — 바닥부터 올라가는 순서

단계질문도구
1전원 · GND 가 들어가나, 쉬는 상태 전압이 맞나멀티미터
2MCU 핀에서 신호가 나가기는 하나로직 분석기 (트리거)
3타이밍 · 보레이트 · 모드가 맞나디코더 · 커서
4상대가 대답하나 (ACK · 응답 바이트)디코더
5파형 모양이 정상인가 (상승 · 링잉)오실로스코프
6내 코드가 제때 처리하나디버그 핀 · 진단 카운터 (p03 · p04)
"코드부터 의심"하는 버릇을 버리세요. 통신 장애의 상당수는 1~2단계(배선 · 전원 · 풀업)에서 끝납니다. 아래에서 위로 올라가면 한 단계에서 반드시 "여기서부터 이상하다"가 보이고, 그 경계가 원인입니다. 서버 장애에서 네트워크 → 프로세스 → 로그 순서로 보는 것과 같습니다.
정리 — 샘플링은 신호의 4~10배, GND 는 반드시 연결, 디코더로 해석하고 커서로 잽니다. 모양 문제(풀업 · 링잉)는 스코프, 타이밍 문제(보레이트 · CS · 모드 · DE)는 로직 분석기 — 그리고 디버그 핀으로 코드의 시간을 같은 화면에 올립니다.
GDB · OpenOCD · Cortex-Debug · SEGGER RTT · Unity · CMock · FFF · libFuzzer · GitHub Actions · pytest — STM32F446RE + Arm GNU Toolchain 14.x

보이지 않는 칩 안을
들여다보고,
기계가 버그를 잡게 합니다

서버라면 로그 파일 · APM · 코어 덤프가 있습니다. MCU 에는 화면도, 디스크도, 스택 트레이스도 없습니다. 그래서 펌웨어 디버깅은 "무엇으로 칩 안을 볼 것인가"를 고르는 일부터 시작합니다. 이 탭은 GDB 와 온칩 디버거를 깊게 쓰는 법, 시간을 망가뜨리지 않는 로그(SWO · RTT), 리셋 뒤에도 남는 크래시 덤프, 스택 · 메모리 문제를 숫자로 보는 법을 먼저 다룹니다. 후반부는 하드웨어 없이 PC 에서 펌웨어를 테스트하는 기술 — Unity · CMock · FFF · 레지스터 페이크 · 새니타이저 · 퍼징 — 과 그것을 CI 와 HIL(실제 보드 자동 테스트) 로 묶는 실무까지 갑니다. 이 탭을 마치면 "가끔 리셋된다" 같은 현장 버그를 덤프로 잡고, 드라이버를 보드 없이 TDD 로 만들고, PR 마다 펌웨어 크기 · 테스트 · 커버리지가 자동으로 검사되는 파이프라인을 세울 수 있습니다.

13SECTIONS
F446RE기준 보드
21시각화
70%+보드 없이 도는 테스트
QA 01

임베디드 디버깅 지도 — LED 한 개부터 오실로스코프까지

펌웨어 디버깅 도구는 사다리처럼 늘어서 있습니다. LED 하나 깜빡이기는 공짜지만 1비트밖에 못 보고, 온칩 디버거는 모든 변수를 보지만 CPU 를 멈추는 순간 시간이 망가집니다. 도구마다 무엇을 보여 주는지와 무엇을 대가로 치르는지를 알아야 증상에 맞는 도구를 첫 번째로 꺼낼 수 있습니다.

도구 여덟 개 × 기준 넷 — 진할수록 그 기준에서 유리합니다 무엇을 보나 간섭이 적나 시간 정밀도 준비 비용 ① LED · GPIO 토글 1비트 수십 ns 스코프로 ns 0원 ② UART printf 문자열 ms 블로킹 ms VCP 내장 ③ SWO · ITM 문자열 · PC µs / 글자 타임스탬프 SWO 핀 ④ SEGGER RTT 문자열 · 양방향 memcpy 뿐 µs 프로브만 ⑤ 온칩 디버거 (GDB) 모든 메모리 시간 정지 없음 ST-LINK ⑥ 트레이스 (SystemView) 태스크 · ISR 낮음 사이클 라이선스 ⑦ 로직 분석기 디지털 핀 0 ~10 ns 수만 원~ ⑧ 오실로스코프 아날로그 파형 0 ns 이하 수십만 원~ 한 줄이 모든 칸에서 진한 도구는 없습니다 — 그래서 둘 이상을 섞어 씁니다 "디버거로는 안 보이는데 printf 넣으면 사라진다" = 시간이 원인이라는 신호
디버깅 도구 매트릭스 — 많이 보는 도구일수록 시간을 더 망가뜨립니다. 타이밍 버그엔 아래쪽, 논리 버그엔 ⑤.

증상 → 먼저 꺼낼 도구

증상첫 도구 · 이유
부팅 직후 아무 반응 없음⑤ GDB — monitor reset halt 후 한 줄씩. 클럭 설정에서 멈춘 경우가 많음
값이 틀리게 계산됨호스트 단위 테스트(t07) → 재현되면 PC 에서 고침
가끔 HardFault크래시 덤프(t05) + addr2line
현장 장비가 가끔 리셋리셋 원인 레지스터 + .noinit 덤프(t05 · t13)
통신이 가끔 깨짐⑦ 로직 분석기 — 프로토콜 디코더로 실제 바이트 확인
ISR 이 늦게 돈다① GPIO 토글 + 스코프 · 또는 ⑥ 트레이스
디버거 붙이면 증상이 사라짐④ RTT 로 로그만 남기고 멈추지 않기
전원 켤 때만 오동작⑧ 스코프 — 전원 상승 · 리셋 핀 파형

① 디버그 핀 — 가장 싸고 가장 정확한 스톱워치

/* debug_pin.h — PA5(Nucleo LD2) 나 남는 핀 하나를 "측정 전용"으로 */
#include "stm32f4xx.h"

/* BSRR: 아래 16비트 = set, 위 16비트 = reset.
   한 번의 32비트 쓰기라 읽기-수정-쓰기(RMW) 가 없어
   ISR 안에서 써도 다른 핀과 경쟁하지 않습니다. */
#define DBG_PIN_HI()  (GPIOA->BSRR = GPIO_BSRR_BS5)
#define DBG_PIN_LO()  (GPIOA->BSRR = GPIO_BSRR_BR5)

void TIM2_IRQHandler(void)
{
    DBG_PIN_HI();                  /* ← 스코프 상승 엣지 = ISR 진입 */
    TIM2->SR = ~TIM_SR_UIF;        /* 플래그 지우기 (rc_w0 비트) */
    control_loop_step();
    DBG_PIN_LO();                  /* ← 하강 엣지 = ISR 끝          */
}
/* 스코프에서 보이는 것:
   · 펄스 폭      = ISR 실행 시간 (최악값을 무한 지속 모드로)
   · 펄스 간격    = 실제 주기 (지터 포함)
   · 펄스가 빠짐  = 인터럽트를 놓쳤다는 증거 */
서버 개발에 빗대면 — APM 의 "구간 타이머"를 하드웨어로 만든 것입니다. 핀 쓰기 한 번은 수십 ns 라 측정 자체가 결과를 거의 바꾸지 않습니다. 핀이 모자라면 핀 두 개로 상태 4가지를 인코딩하는 요령도 씁니다.

관찰자 효과 — 도구가 버그를 숨기는 세 가지 방식 🟡 중급

물리학의 "관찰하면 결과가 바뀐다"가 펌웨어에선 일상입니다. 이런 버그를 현장에선 하이젠버그(Heisenbug) 라고 부릅니다. 도구를 고를 때 그 도구가 시스템의 무엇을 바꾸는지 알아야 "버그가 사라졌다"를 "고쳐졌다"로 착각하지 않습니다.

도구무엇을 바꾸나그래서 숨는 버그
printf (UART 폴링)호출마다 수 ms 지연 · 코드 크기 · 스택 수백 바이트레이스 · 타이밍 의존 버그 · 스택이 아슬아슬한 태스크(오히려 터짐)
브레이크포인트CPU 만 멈추고 주변장치는 계속 돎UART 수신 오버런 · 타이머 만료 · 워치독 리셋이 디버깅 중에만 생김
-O0 디버그 빌드변수가 전부 메모리에 · 인라인 없음 · 코드 2~3배volatile 누락 · 미정의 동작 · 최적화가 지운 지연 루프(t13)
디버거 연결 자체저전력 모드에서도 디버그 클럭 유지 · DHCSR.C_DEBUGENStop 모드 진입 실패 · BKPT 명령이 디버거 없을 땐 HardFault

DWT 사이클 카운터 — 코드 안의 스톱워치

/* Cortex-M3/M4/M7 의 DWT->CYCCNT: CPU 클럭마다 1 증가하는 32비트 카운터.
   180 MHz 면 2^32 / 180e6 ≈ 23.8 초마다 한 바퀴 돕니다. */
static inline void cyc_init(void)
{
    CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; /* DWT 전원 켜기 */
    DWT->CYCCNT = 0;
    DWT->CTRL  |= DWT_CTRL_CYCCNTENA_Msk;
}
static inline uint32_t cyc_now(void) { return DWT->CYCCNT; }

void measure(void)
{
    uint32_t t0 = cyc_now();
    fir_filter(buf, 256);
    uint32_t dt = cyc_now() - t0;   /* 부호 없는 뺄셈 → 한 바퀴 넘쳐도 맞음 */
    /* dt = 9000 이면 9000 / 180 = 50 µs */
    LOG_INF("fir: %lu cyc (%lu us)", (unsigned long)dt,
            (unsigned long)(dt / (SystemCoreClock / 1000000u)));
}
M7(F7 · H7) 는 잠금이 있습니다 — DWT->LAR = 0xC5ACCE55 로 풀어야 CYCCNT 가 켜지는 칩이 있습니다. M0/M0+ 에는 DWT 사이클 카운터가 아예 없어 SysTick 이나 타이머로 대신합니다.

디버깅 중에 주변장치 멈추기 — DBGMCU

/* 브레이크포인트에 걸린 동안 타이머 · 워치독도 같이 멈추게.
   #ifdef DEBUG 로 감싸 양산 빌드에는 넣지 않습니다. */
void debug_freeze_setup(void)
{
#ifdef DEBUG
    __HAL_DBGMCU_FREEZE_IWDG();   /* 멈춘 동안 독립 워치독 정지 */
    __HAL_DBGMCU_FREEZE_WWDG();
    __HAL_DBGMCU_FREEZE_TIM2();   /* 제어 루프 타이머 */
    HAL_DBGMCU_EnableDBGSleepMode();  /* WFI 중에도 SWD 유지 */
    HAL_DBGMCU_EnableDBGStopMode();   /* Stop 모드에서도 디버거 연결 유지 */
#endif
}
UART 수신은 멈출 수 없습니다 — 상대 장치는 계속 보내므로 멈춘 사이 ORE(오버런) 가 쌓입니다. 디버깅 뒤 "수신이 영영 안 된다"면 HAL 이 ORE 를 지우지 않아 생긴 일입니다(통신 탭 참고). 통신 경로는 멈추지 말고 RTT 로그(t04)로 보세요.

실무에선 이렇게 — 팀의 디버깅 기본값을 정해 두기 🔴 고급

개발 보드에 미리 준비해 둘 것

  • SWD + SWO + NRST 를 커넥터로 뺀다 — 양산 보드에서도 테스트 패드로 남깁니다(SET 탭 u09).
  • 디버그 핀 2개를 헤더로 — ISR · 태스크 측정용. 보드 설계 때 안 빼 두면 나중엔 납땜입니다.
  • UART 콘솔 1개 — 디버거 없이도 현장에서 덤프를 꺼내는 마지막 통로.
  • 리셋 원인 출력 — 부팅 첫 줄에 RCC->CSR 를 찍게 (t13).

빌드 구성 세 가지

구성플래그용도
Debug-Og -g3 · 로그 DEBUG매일 개발 · 단계 실행
RelWithDebInfo-O2 -g · 로그 INFO양산과 같은 기계어로 재현
Release-O2 -g · 로그 WARN양산. .elf 는 꼭 보관(t11)
-g 는 .hex · .bin 크기에 영향이 없습니다 — 디버그 정보는 .elf 에만 들어가고 칩에는 안 올라갑니다. 그러니 양산 빌드도 -g 로 만들어 .elf 를 보관하세요. 현장 덤프의 주소를 줄 번호로 바꿀 유일한 열쇠입니다.
정리 — 많이 보는 도구일수록 시간을 망가뜨립니다. 논리 버그는 GDB · 호스트 테스트로, 타이밍 버그는 GPIO 토글 · RTT · 트레이스로, 현장 버그는 리셋 뒤에도 남는 덤프로 — 증상에 맞춰 첫 도구를 고르는 것이 디버깅 시간의 절반을 줄입니다.
QA 02

GDB 실전 — 멈추고, 들여다보고, 감시하기

명령 열 개짜리 기초는 C 실전 탭 c24 에 있습니다. 여기서는 그 위에서 펌웨어를 GDB 로 디버깅할 때만 생기는 일을 다룹니다 — 조건부 브레이크포인트가 실시간 루프를 망가뜨리는 이유, HAL 안으로 빨려 들어가지 않는 단계 실행, 매크로로 된 레지스터(GPIOA->ODR)를 출력하는 법, 하드웨어 와치포인트의 개수 한계, 그리고 최적화가 디버거 화면을 어떻게 속이는지.

bt 한 화면에서 읽어야 할 여섯 가지 ① 프레임 0 = 지금 멈춘 곳 ② 그 주소의 변수 이름 ④ 소스 위치 — list 로 주변 보기 ③ 스택 주소 #0 sensor_read (dev=0x20000a40 <g_bme>, out=0x2001ffb0) at App/sensor.c:88 #1 0x08001c3e in sensor_task (arg=0x0) at App/tasks.c:41 ⑥ 이 함수가 sensor_read 를 불렀음 ⑤ 돌아갈 주소 — 0x08 로 시작하면 Flash 의 코드 0x2000_0000 대 = RAM 의 전역 · 0x2001_Fxxx = 스택(F446 RAM 끝 0x2002_0000) frame 1 · info frame 으로 위 프레임의 지역 변수와 저장된 레지스터까지 봅니다
bt 해부 — 주소의 앞자리만 봐도 어디인지 압니다: 0x08 은 Flash(코드), 0x2000 은 전역, 0x2001F 는 스택.

브레이크포인트 — 조건 · 횟수 · 자동 명령

(gdb) break sensor.c:88                 # 1번
(gdb) tbreak main                       # 한 번 걸리면 사라짐
(gdb) break uart_rx_isr if len > 64     # 조건부
(gdb) condition 1 raw > 4000            # 이미 있는 1번에 조건 추가
(gdb) ignore 1 100                      # 100번은 그냥 지나감
(gdb) info breakpoints                  # 걸린 횟수(hits)까지 표시
Num Type           Disp Enb Address    What
1   hw breakpoint  keep y   0x08001a3c in sensor_read at App/sensor.c:88
        stop only if raw > 4000
        breakpoint already hit 3 times

# 멈추지 않고 값만 찍는 브레이크포인트
(gdb) dprintf pid.c:57,"err=%d out=%d\n",err,out
# 걸리면 명령 묶음 실행 후 계속
(gdb) commands 1
> silent
> printf "raw=%u state=%d\n", raw, g_state
> bt 2
> continue
> end
조건부 · dprintf 도 매번 CPU 를 멈춥니다 — 칩은 "조건"을 모릅니다. 걸릴 때마다 정지 → USB 로 GDB 에 알림 → GDB 가 조건 평가 → 재개 순서라 한 번에 수 ms가 듭니다. 1 kHz 제어 루프에 걸면 루프가 무너집니다. 자주 지나가는 곳은 코드에 조건을 넣고(if (raw > 4000) __NOP(); 줄에 BP) 거기에 거세요.

단계 실행 — HAL 안으로 빨려 들어가지 않기

(gdb) next        # n  — 다음 줄 (함수 호출은 한 줄로)
(gdb) step        # s  — 함수 안으로
(gdb) finish      # 현재 함수 끝까지 → 반환값 출력
Run till exit from #0  crc16 (p=0x20000310 "123456789", n=9)
Value returned is $3 = 0x29b1
(gdb) until 120   # 루프를 120번 줄까지 한 번에
(gdb) advance pid_update   # 그 함수에 들어갈 때까지

# HAL · CMSIS · newlib 은 step 해도 들어가지 않게
(gdb) skip -gfi */Drivers/*
(gdb) skip -gfi */newlib/*
(gdb) skip function HAL_GetTick

# 기계어 단위 — 최적화 빌드 · 폴트 직전 분석
(gdb) display/i $pc   # 멈출 때마다 현재 명령 표시
(gdb) stepi           # si — 명령 하나
(gdb) nexti           # ni — bl 은 건너뜀
(gdb) disassemble /s  # 소스와 기계어를 섞어서
finish 가 이상한 값을 낼 때 — 반환값은 R0 에서 읽습니다. 64비트 반환은 R0:R1, 구조체 반환은 메모리라 -O2 에선 틀린 값이 나오기도 합니다. 그럴 땐 호출한 쪽 줄에서 변수에 담긴 값을 보세요.

데이터 보기 — print · x · display, 그리고 매크로 레지스터 🟡 중급

print 와 x 의 형식 문자

(gdb) p/x g_flags          # 16진   $1 = 0x15
(gdb) p/t g_flags          # 2진    $2 = 10101
(gdb) p/d (int8_t)0xF0     # 부호   $3 = -16
(gdb) p *frame             # 구조체 통째로
(gdb) p frame->buf[3]@8    # 배열 일부: 인덱스 3부터 8개
(gdb) p *g_rx@32           # 포인터를 배열처럼 32개
(gdb) p $1 | 0x40          # 값 히스토리 재사용
(gdb) ptype struct pkt     # 타입 정의
(gdb) p sizeof(struct pkt) # 패딩 확인 (C 기초 탭)
(gdb) p/x &g_ctx          # 주소 → 어느 메모리 영역인지

# x/NFU 주소   N개, 형식 F(x d u t s i), 단위 U(b h w g)
(gdb) x/16xb rx_buf        # 바이트 16개
(gdb) x/8xw $sp            # 스택 꼭대기 워드 8개
(gdb) x/s  g_version       # 문자열
(gdb) x/4i $pc             # 다음 명령 4개

(gdb) display/x g_state    # 멈출 때마다 자동 출력
(gdb) info display
(gdb) set var g_mode = 2   # 실행 중 값 바꾸기 (테스트 분기 강제)

GPIOA->ODR 를 출력하려면

(gdb) p GPIOA->ODR
No symbol "GPIOA" in current context.
# GPIOA 는 변수가 아니라 #define 매크로입니다:
#   #define GPIOA ((GPIO_TypeDef *) GPIOA_BASE)

# 방법 1 — 타입은 알므로 주소로 캐스트
(gdb) p/x ((GPIO_TypeDef *)0x40020000)->ODR
$1 = 0x20                 # 비트 5 = PA5(LD2) 켜짐
(gdb) p/x *(GPIO_TypeDef *)0x40020000   # 레지스터 전부

# 방법 2 — -g3 로 빌드하면 매크로 정보가 .elf 에 들어감
(gdb) p/x GPIOA->ODR
$2 = 0x20
(gdb) macro expand GPIOA
expands to: ((GPIO_TypeDef *) ((0x40000000UL + 0x00020000UL) + 0x0000UL))

# 방법 3 — SVD 로 이름 그대로 (t03)
읽기만 해도 상태가 바뀌는 레지스터가 있습니다 — USART 의 SR 을 읽고 DR 을 읽으면 RXNE · ORE 가 지워집니다. 디버거로 p USART2->DR 를 하면 펌웨어가 받아야 할 바이트를 디버거가 먹어 버립니다. "디버거로 보면 데이터가 사라진다"의 정체입니다. 수신 레지스터는 직접 읽지 말고 펌웨어의 버퍼를 보세요.

와치포인트 — 누가 이 변수를 망가뜨렸나 🔴 고급

값이 바뀌는 순간 멈추기

(gdb) watch g_cfg.baud            # 쓰기 + 값이 달라질 때
Hardware watchpoint 4: g_cfg.baud
(gdb) continue
Hardware watchpoint 4: g_cfg.baud
Old value = 115200
New value = 0
memset (dst=0x20000400, c=0, n=96) at ...
(gdb) bt                          # ★ 누가 memset 을 불렀나
#1  0x0800215a in parse_frame (...) at App/proto.c:77

(gdb) rwatch g_secret             # 읽을 때 멈춤
(gdb) awatch g_ring.head          # 읽기 · 쓰기 모두
(gdb) watch -l ctx->state         # 주소를 고정 (지역 변수 범위를 벗어나도)
(gdb) watch g_mode if g_mode == 3 # 조건까지
Cortex-M4 의 DWT 비교기는 4개입니다 — 와치포인트를 동시에 최대 4개(크기 · 정렬이 맞을 때) 둘 수 있습니다. 넘치면 GDB 가 "소프트웨어 와치포인트"로 바꾸는데, 이건 명령 하나마다 멈춰 값을 비교하므로 사실상 못 씁니다. set can-use-hw-watchpoints 1 상태를 유지하고, Software watchpoint 라는 말이 보이면 지우세요.

최적화가 디버거 화면을 속이는 방식

보이는 것실제로 일어난 일
<optimized out>변수가 레지스터에만 있다가 이미 덮어써짐
줄이 위아래로 튐명령 재배치 — 한 줄의 명령이 여러 곳에 흩어짐
BP 가 안 걸림그 줄이 인라인되거나 지워짐 · 다른 BP 위치로 옮겨짐
bt 에 함수가 빠짐꼬리 호출(tail call) — b 로 점프해 프레임이 없음
루프 변수가 이상한 값루프가 거꾸로 세거나 포인터 증가로 바뀜
-Og 는 "디버깅을 해치지 않는 최적화만" 켭니다(GCC). -O0 보다 코드가 작고 빠르면서 변수는 대부분 보입니다. -O2 에서만 나는 버그를 볼 땐 문제 함수 하나만 __attribute__((optimize("O0"))) 로 낮추는 방법도 있지만, GCC 문서도 "디버깅 용도로만"이라 적을 만큼 거친 도구입니다 — 고친 뒤 반드시 지우세요.

프로젝트 .gdbinit — 팀이 같은 명령을 쓰게

# 프로젝트 루트/.gdbinit  — GDB 는 보안상 이 파일을 바로 읽지 않습니다.
# 한 번만: ~/.config/gdb/gdbinit (또는 ~/.gdbinit) 에
#   add-auto-load-safe-path /home/me/work/fw/.gdbinit

set pagination off
set confirm off
set print pretty on
set history save on
target extended-remote :3333
skip -gfi */Drivers/*

define rh
  monitor reset halt
end
document rh
리셋 후 첫 명령에서 멈춤
end

define flash
  monitor reset halt
  load
  monitor reset halt
end

# 폴트 레지스터 한 번에 (주소는 Cortex-M 공통 SCB)
define fault
  printf "CFSR  = 0x%08x\n", *(unsigned int *)0xE000ED28
  printf "HFSR  = 0x%08x\n", *(unsigned int *)0xE000ED2C
  printf "MMFAR = 0x%08x\n", *(unsigned int *)0xE000ED34
  printf "BFAR  = 0x%08x\n", *(unsigned int *)0xE000ED38
end
정리 — 조건부 브레이크포인트도 매번 칩을 멈추니 자주 지나는 곳엔 코드에 조건을 넣고, skip 으로 HAL 을 건너뛰고, 매크로 레지스터는 캐스트나 -g3 로, 망가진 변수는 하드웨어 와치포인트(최대 4개)로 범인을 잡습니다. -Og 로 개발하되 양산과 같은 -O2 빌드에서도 한 번은 재현해 보세요.
QA 03

온칩 디버깅 — GDB 와 칩 사이에서 일어나는 일

프로브 배선 · OpenOCD 설치 · 기본 launch.json 은 설치 탭 u09 · u04 에서 끝냈습니다. 이 절은 그 연결이 실제로 어떻게 동작하는지를 봅니다. 브레이크포인트를 걸면 칩 안의 무엇이 바뀌는지 알면 "BP 가 안 걸린다 · 6개 넘게 못 건다 · 붙이면 칩이 멈춘다" 같은 증상이 전부 설명됩니다. 그리고 SVD 로 주변장치 레지스터를 이름으로 보는 법, 돌고 있는 장치에 리셋 없이 붙는 법까지 다룹니다.

break main → continue → 멈춤 — 실제로 오가는 것 GDB OpenOCD ST-LINK Cortex-M4 코어 1. Z1,8000abc,2 2. SWD 쓰기: FP_COMP0 ← 0x08000abc | 사용 3. OK 4. vCont;c 5. DHCSR ← C_HALT 해제 → 코어 달림 6. PC == 0x08000abc → 스스로 정지 7. DHCSR 폴링 → S_HALT = 1 발견 8. T05 (SIGTRAP) 9. g · m 패킷 — 레지스터 · 메모리 읽기 칩이 멈추는 판단은 FPB 하드웨어가 합니다 — GDB 는 나중에 소식을 들을 뿐 ST-LINK 의 USB 왕복이 패킷마다 1 ms 안팎 — 변수 창이 클수록 단계 실행이 느려집니다
GDB 원격 프로토콜(RSP) 시퀀스 — Z1 = 하드웨어 브레이크포인트. 칩 안의 FPB 비교기가 PC 를 감시합니다.

칩 안의 디버그 부품 — CoreSight

부품하는 일M4 개수
DAP (DP · AHB-AP)SWD 신호를 버스 접근으로 바꿈 — CPU 를 안 거치고 메모리를 읽음1
FPBPC 가 특정 주소가 되면 정지 (하드웨어 BP)명령 6 + 리터럴 2
DWT주소 접근 감시(와치포인트) · CYCCNT · PC 샘플링비교기 4
ITM펌웨어가 쓴 바이트를 SWO 로 내보냄 (t04)채널 32
TPIUITM · DWT 출력을 SWO 핀 직렬 신호로1
ETM명령 실행 흐름 전체 기록(트레이스 핀 4개 필요)있음 · RE(64핀)는 트레이스 핀 없음
DAP 가 CPU 를 거치지 않는다는 점이 중요합니다. 그래서 칩이 달리는 중에도 메모리를 읽을 수 있고(Live Watch · RTT 의 원리), HardFault 로 코어가 잠겨(lockup) 있어도 RAM 을 덤프할 수 있습니다.

플래시에서 도는 코드에 소프트웨어 BP 가 안 되는 이유

PC 프로그램의 GDB 는 브레이크포인트 자리의 명령을 BKPT 명령으로 덮어써서 겁니다(소프트웨어 BP, 개수 무제한). 펌웨어는 Flash 에서 직접 실행되는데, Flash 는 섹터 단위로 지워야만 다시 쓸 수 있습니다.

F446RE Flash 섹터크기
0 ~ 316 KB
464 KB
5 ~ 7128 KB

2바이트를 바꾸려고 128 KB 섹터를 지우고 다시 쓰면 수 초가 걸리고, Flash 수명(섹터당 1만 회)도 깎입니다. 그래서 OpenOCD 는 GDB 에 "이 영역은 Flash" 라는 메모리 지도를 넘기고, GDB 는 그 영역에 자동으로 하드웨어 BP(Z1)를 씁니다.

RAM 에 올려 실행하는 함수(__attribute__((section(".RamFunc"))))에는 소프트웨어 BP 가 걸립니다. SEGGER J-Link 의 "Flash Breakpoints" 는 Flash 를 영리하게 다시 써서 무제한 BP 를 흉내 내는 유료 기능입니다.

하드웨어 BP 6개 — 생각보다 빨리 바닥납니다 🟡 중급

FPB 명령 비교기 6칸 — 내가 건 것만 쓰는 게 아닙니다 COMP0 BP 1 COMP1 BP 2 COMP2 BP 3 COMP3 BP 4 COMP4 finish 임시 COMP5 main 진입 사용자가 건 브레이크포인트 GDB · Cortex-Debug 가 몰래 쓰는 것 7번째: Error — can't add breakpoint: resource not available BP 는 4개 이하로 두고, 필요 없는 것은 disable 이 아니라 delete 로 지웁니다
비교기 여섯 칸 — finish · until · runToEntryPoint 도 한 칸씩 씁니다. disable 한 BP 는 칸을 반납하지만, 다시 enable 하면 또 필요합니다.

달리는 장치에 리셋 없이 붙기 · 안 붙는 칩 살리기

# ① 지금 상태 그대로 붙기 — 현장에서 "멈춘 것 같은" 장치를 볼 때
openocd -f board/st_nucleo_f4.cfg
arm-none-eabi-gdb build/fw.elf
(gdb) target extended-remote :3333    # 연결 순간 OpenOCD 가 코어를 halt
(gdb) bt                              # ★ load · reset 은 하지 않음
(gdb) info registers                  # 어디서 무엇을 하던 중인지
(gdb) continue                        # 멈췄던 그대로 계속

# ② SWD 핀을 GPIO 로 바꿔 버렸거나, 부팅 즉시 Stop 모드로 들어가는 펌웨어
#    → 리셋을 누른 채로 연결해서 첫 명령 전에 잡기
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg \
        -c "reset_config srst_only srst_nogate connect_assert_srst" \
        -c "init; reset halt; flash erase_sector 0 0 last; exit"
#   (NRST 선이 연결돼 있어야 합니다 — Nucleo 는 내장 ST-LINK 에 이어져 있음)

# ③ 무엇이 오가는지 보기 — "BP 가 안 걸린다" 진단
(gdb) set debug remote 1    # RSP 패킷 원문 출력

GDB 가 보는 Cortex-M 레지스터

(gdb) info registers
r0   0x20000a40   r1  0x0   ...  r12 0x0
sp   0x20003b08              # 지금 쓰는 스택 = 여기선 PSP
lr   0x08001c3f              # 돌아갈 주소 (최하위 비트 1 = Thumb)
pc   0x08001a3c <sensor_read+16>
xpsr 0x61000000              # N Z C V 플래그 · 비트24 T=1 · 하위 9비트 = 예외 번호
msp  0x2001ffd0              # 메인 스택 (ISR · 부팅 코드)
psp  0x20003b08              # RTOS 태스크 스택
primask 0x0                  # 1 이면 인터럽트 전부 막힘
basepri 0x50                 # 이 우선순위 이하는 막힘 (FreeRTOS 임계 구역)
control 0x2                  # 비트1 = 1 → 스레드가 PSP 사용 중

# 위는 태스크 안에서 멈춘 경우(xPSR 하위 9비트 = 0). 다른 때 멈췄다면:
(gdb) p $xpsr & 0x1ff        # 0 = 스레드 모드, 15 = SysTick, 16+n = IRQn
$1 = 0x2c                    # 44 → 44 - 16 = IRQ 28 = TIM2 안에서 멈춤
xPSR 의 하위 9비트(IPSR) 하나로 "지금 인터럽트 안인가"를 압니다. 3 = HardFault, 11 = SVCall, 14 = PendSV, 15 = SysTick. F446 의 TIM2 는 IRQ 28 이라 예외 번호 44 로 보입니다.

실무에선 이렇게 — Cortex-Debug 를 끝까지 쓰기 · SVD 🔴 고급

launch.json — 실행 · 붙기 두 구성

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "F446 Debug",
      "type": "cortex-debug",
      "request": "launch",
      "servertype": "openocd",
      "executable": "build/Debug/fw.elf",
      "configFiles": ["board/st_nucleo_f4.cfg"],
      "svdFile": "${workspaceFolder}/tools/STM32F446.svd",
      "runToEntryPoint": "main",
      "preLaunchTask": "build-debug",
      "liveWatch": { "enabled": true, "samplesPerSecond": 4 },
      "rttConfig": {
        "enabled": true, "address": "auto",
        "decoders": [{ "port": 0, "type": "console" }]
      },
      "postLaunchCommands": ["skip -gfi */Drivers/*"]
    },
    {
      "name": "F446 Attach (리셋 없이)",
      "type": "cortex-debug",
      "request": "attach",
      "servertype": "openocd",
      "executable": "build/Debug/fw.elf",
      "configFiles": ["board/st_nucleo_f4.cfg"],
      "svdFile": "${workspaceFolder}/tools/STM32F446.svd"
    }
  ]
}
attach 는 지금 칩에 들어 있는 펌웨어와 .elf 가 같아야 의미가 있습니다. 다른 빌드의 .elf 면 함수 이름과 줄 번호가 엉뚱하게 나옵니다. 그래서 빌드마다 버전 문자열을 .elf 와 펌웨어에 같이 넣어 두고(x/s g_version) 먼저 확인합니다.

SVD 로 주변장치 보기 — 무엇을 확인하나

SVD 를 넣으면 디버그 사이드바에 XPERIPHERALS(최근 버전은 Peripheral Viewer 패널) 트리가 생기고, RCC → AHB1ENR → GPIOAEN 처럼 비트 이름으로 보고 고칠 수 있습니다. 실무에서 가장 자주 여는 곳:

확인할 레지스터답하는 질문
RCC · AHB1ENR / APB1ENR그 주변장치에 클럭이 들어가나 (안 들어가면 쓰기가 무시됨)
GPIOx · MODER · AFR[L/H]핀이 정말 AF7(USART) 로 잡혔나
USARTx · SRORE · FE 가 서 있나 (DR 은 클릭하지 말 것)
TIMx · CR1 · CNT · ARR타이머가 도나 · 주기가 맞나
NVIC · ISER · IABR인터럽트가 켜졌나 · 지금 실행 중인가
SCB · CFSR · HFSR폴트 원인 (t05)
SVD 를 읽는 순간 실제 레지스터를 읽습니다 — 패널을 펼쳐 둔 채 단계 실행하면 멈출 때마다 전부 다시 읽어 느려지고, 읽기로 지워지는 비트가 사라집니다. 필요한 주변장치만 펼치세요. 명령줄 GDB 라면 PyCortexMDebug 같은 SVD 파이썬 확장으로 같은 일을 할 수 있습니다.
정리 — GDB 는 OpenOCD 에 RSP 패킷을 보내고, OpenOCD 는 SWD 로 칩의 FPB · DWT 를 설정합니다. Flash 는 덮어쓸 수 없어 BP 는 하드웨어 비교기 6개가 전부이고, GDB 가 몰래 쓰는 칸까지 셈해야 합니다. 돌고 있는 장치는 attach 로 리셋 없이 들여다보고, 주변장치는 SVD 로 "클럭 · 핀 · 플래그" 순서로 확인합니다.
QA 04

로그 출력 방식 — UART printf · SWO · RTT · 바이너리 로그

로그는 멈추지 않는 디버거입니다. 하지만 펌웨어에선 로그 한 줄이 제어 루프 한 주기보다 길 수 있습니다. 같은 80글자를 내보내는 데 UART 폴링은 7 ms, RTT 는 몇 µs 입니다. 어떤 통로로 내보내는지, 무엇을 칩 안에서 하고 무엇을 PC 로 미룰지가 로그 설계의 전부입니다. 레벨별로 컴파일 시점에 통째로 지우는 매크로까지 만듭니다.

80글자 한 줄에 CPU 가 붙잡히는 시간 — 눈금 한 칸이 10배 (대략값) 0.1µs 1µs 10µs 100µs 1ms 10ms UART 폴링 115200 6.9 ms SWO 2 Mbit/s 0.8 ms RTT + printf 포맷 ~20 µs RTT 고정 문자열 ~2 µs 바이너리 로그 ~0.5 µs UART 는 전송 시간, RTT 는 포맷 시간이 비용의 대부분 — 그래서 포맷을 PC 로 미룹니다
로그 비용 — UART 폴링 한 줄 = 1 kHz 루프 7주기. 같은 정보를 바이너리로 보내면 만 배 넘게 싸집니다.

① UART printf — 비용을 직접 계산해 보기

/* newlib 의 printf → _write() 로 내려옴. 이걸 재정의 */
#include <errno.h>
extern UART_HandleTypeDef huart2;     /* Nucleo: ST-LINK 가상 COM */

int _write(int fd, char *ptr, int len)
{
    if (fd != 1 && fd != 2) { errno = EBADF; return -1; }
    HAL_UART_Transmit(&huart2, (uint8_t *)ptr, (uint16_t)len, HAL_MAX_DELAY);
    return len;                       /* ★ 끝날 때까지 블로킹 */
}

/* 115200 bps, 8N1 → 바이트당 10비트(시작 1 + 데이터 8 + 정지 1)
     1 바이트 = 10 / 115200      = 86.8 µs
    80 바이트 = 80 × 86.8 µs     = 6.94 ms
   180 MHz 에서 6.94 ms         = 약 125만 사이클을 기다림 */
ISR 안에서 printf 금지 — 7 ms 동안 같거나 낮은 우선순위 인터럽트가 전부 밀리고, newlib 의 printf 는 재진입 안전하지 않으며 malloc 을 부를 수도 있습니다. DMA + 링 버퍼로 바꾸면 블로킹은 사라지지만 대역폭(11.5 KB/s)은 그대로라 로그가 많으면 결국 버퍼가 넘칩니다.

② ITM / SWO — 디버그 핀 하나로 나가는 printf

/* PB3 = TRACESWO. Nucleo-F446RE 는 내장 ST-LINK 에 이어져 있음.
   TPIU 속도 · ITM 채널 켜기는 디버거(OpenOCD · Cortex-Debug)가 설정 */
#include "stm32f4xx.h"

int _write(int fd, char *ptr, int len)
{
    (void)fd;
    for (int i = 0; i < len; i++)
        ITM_SendChar((uint32_t)ptr[i]);   /* core_cm4.h — 채널 0 */
    return len;
}
/* ITM_SendChar 는 ITM 이 꺼져 있으면(디버거 없음) 바로 돌아옵니다.
   켜져 있으면 FIFO 가 빌 때까지 기다리므로 대역폭만큼은 블로킹. */
// launch.json 에 추가 — Cortex-Debug 가 TPIU 를 설정하고 출력 창에 표시
"swoConfig": {
  "enabled": true,
  "cpuFrequency": 180000000,
  "swoFrequency": 2000000,
  "source": "probe",
  "decoders": [{ "type": "console", "label": "ITM", "port": 0 }]
}
cpuFrequency 가 틀리면 글자가 깨집니다 — SWO 는 UART 처럼 클럭 없는 직렬이라 분주비가 맞아야 합니다. SystemClock_Config() 전에 찍은 로그는 16 MHz(HSI)로 나가서 깨지는 게 정상입니다. M0/M0+ 에는 ITM · SWO 가 없습니다 — 그쪽은 RTT 가 답입니다.

③ SEGGER RTT — RAM 의 링 버퍼를 디버거가 몰래 읽습니다 🟡 중급

RTT — 펌웨어는 RAM 에 쓰기만, 읽기는 프로브가 CPU 몰래 _SEGGER_RTT (제어 블록 · RAM) "SEGGER RTT" ID 버퍼 개수 업 버퍼 0 기술자 다운 버퍼 0 pBuffer · Size WrOff · RdOff 링 버퍼 (1024 B 중 16칸만 그림) t = 2 5 \n o k RdOff 프로브가 여기부터 읽음 WrOff 펌웨어가 다음에 쓸 자리 아직 안 읽힌 로그 방금 씀 펌웨어: memcpy + WrOff 갱신이 전부 — 잠금 없음 (쓰는 쪽 하나, 읽는 쪽 하나) 프로브: DAP 로 RAM 을 읽고 RdOff 만 갱신 — 코어는 한 번도 멈추지 않음 가득 차면 기본은 새 로그를 버림(NO_BLOCK_SKIP) — 디버거 없어도 멈추지 않음
RTT 링 버퍼 — 쓰는 쪽과 읽는 쪽이 각자 자기 오프셋만 고치므로 잠금이 필요 없습니다(서버의 SPSC 큐와 같은 원리).

펌웨어 쪽 — 파일 네 개 추가

/* SEGGER 의 "RTT" 소스 묶음에서
   SEGGER_RTT.c · SEGGER_RTT_printf.c · SEGGER_RTT.h · SEGGER_RTT_Conf.h 복사
   (J-Link 소프트웨어 설치 폴더 Samples/RTT, 또는 SEGGER 공개 저장소) */
#include "SEGGER_RTT.h"

int main(void)
{
    HAL_Init();
    SEGGER_RTT_Init();
    /* 가득 차면: SKIP(기본) 버림 · TRIM 들어가는 만큼 · BLOCK 기다림 */
    SEGGER_RTT_ConfigUpBuffer(0, NULL, NULL, 0, SEGGER_RTT_MODE_NO_BLOCK_TRIM);
    SEGGER_RTT_WriteString(0, "boot\r\n");
    SEGGER_RTT_printf(0, "reset cause=%x\r\n", (unsigned)RCC->CSR);
    ...
}

/* printf 를 RTT 로 — newlib 의 printf 를 계속 쓰고 싶을 때 */
int _write(int fd, char *ptr, int len)
{
    (void)fd;
    return (int)SEGGER_RTT_Write(0, ptr, (unsigned)len);
}
/* 버퍼 크기: SEGGER_RTT_Conf.h 의 BUFFER_SIZE_UP (기본 1024) */

PC 쪽 — J-Link 가 없어도 OpenOCD 로

# OpenOCD 0.11+ 에 RTT 가 들어 있습니다 (ST-LINK 로도 동작)
openocd -f board/st_nucleo_f4.cfg \
  -c "init" \
  -c "rtt setup 0x20000000 0x20000 \"SEGGER RTT\"" \
  -c "rtt start" \
  -c "rtt server start 19021 0"
#   ↑ RAM 128 KB 를 훑어 "SEGGER RTT" 문자열로 제어 블록을 찾음

# 다른 창에서
nc localhost 19021          # 윈도우: telnet localhost 19021

# J-Link 라면
JLinkRTTClient              # 또는 GUI: JLinkRTTViewer

# 제어 블록 주소를 정확히 주면 찾는 시간이 줄어듦
arm-none-eabi-nm build/fw.elf | grep _SEGGER_RTT
# 20000c40 B _SEGGER_RTT
다운 버퍼로 PC → 칩 방향 입력도 됩니다(SEGGER_RTT_GetKey()) — 디버그 셸을 UART 없이 만들 수 있습니다. ST-LINK 는 J-Link 보다 메모리 읽기 주기가 느려 로그가 아주 많으면 버퍼를 키워야 합니다.

로그 레벨 · 컴파일 타임 제거 · 바이너리 로그 🔴 고급

log.h — 레벨 미달은 코드에서 사라지게

#pragma once
#define LOG_LVL_NONE 0
#define LOG_LVL_ERR  1
#define LOG_LVL_WRN  2
#define LOG_LVL_INF  3
#define LOG_LVL_DBG  4

#ifndef LOG_LEVEL                 /* 빌드 구성(-DLOG_LEVEL=2) 또는 모듈별 */
#define LOG_LEVEL LOG_LVL_INF
#endif
#ifndef LOG_TAG
#define LOG_TAG "?"               /* __FILE__ 대신 짧은 태그 — Flash 절약 */
#endif

void log_write(char lvl, const char *tag, const char *fmt, ...)
    __attribute__((format(printf, 3, 4)));   /* 형식 문자열 검사 */

/* 상수 조건 if — 최적화 빌드에서 통째로 지워지지만
   형식 문자열 · 인자 타입 검사는 항상 받습니다(#if 로 지우면 못 받음) */
#define LOG_(lvl, c, fmt, ...) do { \
    if (LOG_LEVEL >= (lvl)) log_write(c, LOG_TAG, fmt, ##__VA_ARGS__); \
} while (0)

#define LOG_ERR(fmt, ...) LOG_(LOG_LVL_ERR, 'E', fmt, ##__VA_ARGS__)
#define LOG_WRN(fmt, ...) LOG_(LOG_LVL_WRN, 'W', fmt, ##__VA_ARGS__)
#define LOG_INF(fmt, ...) LOG_(LOG_LVL_INF, 'I', fmt, ##__VA_ARGS__)
#define LOG_DBG(fmt, ...) LOG_(LOG_LVL_DBG, 'D', fmt, ##__VA_ARGS__)

/* 사용: 파일 맨 위에서
   #define LOG_TAG   "imu"
   #define LOG_LEVEL LOG_LVL_DBG     // 이 모듈만 자세히
   #include "log.h" */
##__VA_ARGS__ 는 인자가 없을 때 앞 쉼표를 지우는 GCC · Clang 확장입니다. C23 표준 방식은 __VA_OPT__(,) 입니다.

바이너리(지연 포맷) 로그 — 포맷은 PC 가

/* 칩은 "형식 문자열의 주소 + 인자"만 보냄. 문자열은 .elf 에 있으니
   PC 가 .elf 를 열어 주소 → 문자열로 바꾸고 포맷합니다.
   업 버퍼 1 은 시작 때 한 번 설정:
   static uint8_t s_bin[2048];
   SEGGER_RTT_ConfigUpBuffer(1, "bin", s_bin, sizeof s_bin,
                             SEGGER_RTT_MODE_NO_BLOCK_SKIP);
   ★ 바이너리는 SKIP — TRIM 이면 레코드가 반쯤 잘려 뒤가 전부 어긋남 */
#define LOGB(fmt, a, b) do {                                   \
    static const char s_fmt[] = fmt;  /* .rodata (Flash) */    \
    const uint32_t rec[3] = { (uint32_t)(uintptr_t)s_fmt,      \
                              (uint32_t)(a), (uint32_t)(b) };  \
    SEGGER_RTT_Write(1, rec, sizeof rec);  /* 12 바이트 */      \
} while (0)

LOGB("adc ch=%u raw=%u", ch, raw);   /* vsnprintf 없음 → 0.5 µs 안팎 */
# 호스트 디코더 — pip install pyelftools
import struct
from elftools.elf.elffile import ELFFile

with open("build/fw.elf", "rb") as f:
    sec = ELFFile(f).get_section_by_name(".rodata")
    base, blob = sec["sh_addr"], sec.data()

def fmt_at(addr: int) -> str:
    off = addr - base
    return blob[off:blob.index(b"\0", off)].decode()

def decode(rec: bytes) -> str:
    addr, a, b = struct.unpack("<III", rec)   # 리틀 엔디언 32비트 셋
    return fmt_at(addr) % (a, b)              # %u · %x 만 쓰는 규칙
실무 도구는 한 걸음 더 갑니다 — Trice · Pigweed pw_tokenizer 는 문자열을 ID 나 해시로 바꿔 Flash 에서도 빼 버립니다. 대가는 같은 빌드의 .elf(또는 토큰 DB)가 없으면 로그를 못 읽는다는 것 — t11 의 아티팩트 보관이 필수가 됩니다.
통로핀디버거 필요대역폭(대략)현장 장비에서고를 때
UART (DMA)TX 1개아니오11.5 KB/s @115200예 — 유일한 상시 통로현장 콘솔 · 덤프 출력
SWO / ITMSWO 1개예수백 KB/s아니오M3/M4/M7 · PC 샘플링도 같이
RTTSWD 만예(연결 시만 출력)프로브 따라 수십 KB/s~MB/s아니오(버퍼엔 남음)개발 기본값 · M0 포함 전부
세미호스팅SWD 만예 — 없으면 HardFault매우 느림절대 금지파일 입출력이 필요한 테스트만
정리 — UART 폴링 로그 한 줄은 약 7 ms 로 실시간 루프를 깨뜨립니다. 개발 중에는 RTT(쓰기는 memcpy, 읽기는 프로브)를, 현장에는 UART 를 남기고, 로그 레벨은 상수 if 매크로로 컴파일 시점에 지웁니다. 더 줄여야 하면 포맷을 PC 로 미루는 바이너리 로그로 갑니다.
QA 05

HardFault 깊이 — 스택 프레임 복원부터 리셋 뒤에 남는 크래시 덤프까지

naked 핸들러로 프레임을 잡고 addr2line 에 PC 를 넣는 첫 대응은 STM32 탭 m12 에 있습니다. 이 절은 그다음 단계입니다 — FPU 확장 프레임과 정렬 패딩까지 정확히 복원하고, 스택이 터져서 난 폴트에서도 핸들러가 살아남게 하고, 프레임 밖 스택을 훑어 호출 경로를 추정하고, 무엇보다 디버거가 없는 현장 장비에서 난 폴트를 리셋 뒤에 꺼내 보는 법입니다.

예외 스택 프레임 — 한 칸이 1워드(4 B), 위가 높은 주소 정렬 패딩 (있을 수도) 예약 FPSCR S0 ~ S15 (16워드) xPSR PC (반환 주소) LR R12 R3 R2 R1 R0 0x68 0x64 0x60 0x20~0x5C 0x1C 0x18 0x14 0x10 0x00 SP (진입 직후) 기본 프레임 32 B EXC_RETURN 비트4 = 1 FPU 확장 +72 B = 104 B EXC_RETURN 비트4 = 0 진입 전 SP 가 8 정렬이 아니면 +4 B PC — 폴트를 낸 명령(정확한 폴트) LR — 그 함수를 부른 곳 + 1 폴트 직전 SP = 프레임 + (비트4 ? 0x20 : 0x68) + (xPSR 비트9 ? 4 : 0) 이 값을 알아야 프레임 위쪽 스택(호출자들의 흔적)을 훑을 수 있습니다
예외 프레임 배치 — FPU 확장 · 정렬 패딩을 셈하지 않으면 "폴트 직전 SP"가 틀리고, 그 위의 호출 경로를 잘못 읽습니다.

스택이 터져도 살아남는 핸들러 — 전용 폴트 스택

/* 스택 오버플로로 난 폴트(STKERR · MSTKERR)라면 MSP 가 이미 RAM 밖이거나
   가드 영역에 있습니다. 거기서 C 함수를 부르면 또 폴트 → lockup.
   그래서 C 로 가기 전에 MSP 를 전용 스택으로 옮깁니다. */
__attribute__((used, aligned(8))) uint32_t g_fault_stack[128];   /* 512 B */

void hardfault_c(const uint32_t *sf, uint32_t exc_return,
                 const uint32_t *r4_r11) __attribute__((used, noreturn));

__attribute__((naked)) void HardFault_Handler(void)
{
    __asm volatile(
        "tst   lr, #4                 \n"  /* 비트2: 0=MSP, 1=PSP       */
        "ite   eq                     \n"
        "mrseq r0, msp                \n"  /* r0 = 프레임 주소           */
        "mrsne r0, psp                \n"
        "mov   r1, lr                 \n"  /* r1 = EXC_RETURN            */
        "ldr   r2, =g_fault_stack+512 \n"
        "msr   msp, r2                \n"  /* ★ 이제 SP 는 안전한 곳    */
        "push  {r4-r11}               \n"  /* 프레임에 없는 레지스터 보존 */
        "mov   r2, sp                 \n"  /* r2 = &r4 ~ r11            */
        "b     hardfault_c            \n");
}
/* UsageFault · BusFault · MemManage 핸들러도 같은 본문으로 —
   SHCSR 로 개별 폴트를 켰다면(m12) 그쪽으로 먼저 옵니다. */
프레임 주소를 믿기 전에 범위 검사 — STKERR 면 프레임이 반만 쌓였거나 아예 없습니다. sf 가 RAM(0x2000_0000 ~ 0x2002_0000 − 32) 안이고 4바이트 정렬일 때만 읽으세요. 밖이면 "프레임 없음"으로 기록하고 CFSR 만 남깁니다.

CFSR 전체 비트 — 바이트 셋으로 나뉩니다

비트이름 · 뜻
MMFSR (0~7) — MPU · 실행 금지 영역
0 · 1IACCVIOL 실행 금지 영역 실행 · DACCVIOL 데이터 접근 위반
3 · 4 · 5MUNSTKERR 복귀 · MSTKERR 진입 스택 · MLSPERR FPU 지연 저장
7MMARVALID — MMFAR 에 주소가 유효
BFSR (8~15) — 버스 오류
8 · 9 · 10IBUSERR 명령 · PRECISERR 정확 · IMPRECISERR 부정확
11 · 12 · 13UNSTKERR · STKERR 진입 스택 · LSPERR
15BFARVALID — BFAR 에 주소가 유효
UFSR (16~31) — 명령 · 상태
16 · 17 · 18 · 19UNDEFINSTR · INVSTATE · INVPC · NOCP
24 · 25UNALIGNED · DIVBYZERO
$ python decode_fault.py 00008200 40000000
HFSR FORCED 하위 폴트가 승격됨 → CFSR 를 보라
CFSR PRECISERR 데이터 버스 오류 · PC 정확
CFSR BFARVALID BFAR 에 주소 있음
# → BFAR 의 주소를 PC 의 명령이 읽다가 버스 오류
FORCED 만 보고 멈추지 마세요 — 개별 폴트 핸들러가 꺼져 있으면 모든 폴트가 HardFault 로 승격되고 HFSR.FORCED 가 섭니다. 진짜 원인은 언제나 CFSR 에 있습니다. CFSR 은 1 을 써야 지워지는(write-1-to-clear) 레지스터라 덤프 뒤 SCB->CFSR = SCB->CFSR;.

프레임 밖까지 — 호출 경로 추정 · addr2line 일괄 🟡 중급

"가난한 자의 백트레이스" — 스택에서 코드 주소 줍기

/* 폴트 직전 SP 위쪽에는 호출자들이 push 한 LR 이 흩어져 있습니다.
   Flash 범위 + 최하위 비트 1(Thumb) 인 워드를 모으면 호출 경로 후보가 됩니다.
   (프레임 포인터가 없는 -O2 빌드에서 쓰는 근사법 — 오탐 가능) */
#define FLASH_LO 0x08000000u
#define FLASH_HI 0x08080000u   /* F446RE: 512 KB */

static uint32_t collect_ret_addrs(const uint32_t *sp, const uint32_t *top,
                                  uint32_t *out, uint32_t max)
{
    uint32_t n = 0;
    for (const uint32_t *p = sp; p < top && n < max; p++) {
        uint32_t v = *p;
        if (v >= FLASH_LO && v < FLASH_HI && (v & 1u))
            out[n++] = v & ~1u;     /* Thumb 비트 떼고 저장 */
    }
    return n;
}
/* top: MSP 면 링커의 _estack, PSP 면 그 태스크 스택의 끝
        (FreeRTOS: pxStack + 스택 크기) */

덤프의 주소 한꺼번에 → 줄 번호

# PC · LR · 후보 주소들을 한 번에 (-i: 인라인 펼침, -a: 주소도 출력)
arm-none-eabi-addr2line -e fw-1.4.2.elf -a -f -C -i -p \
    0x08001a3c 0x08001c3e 0x08002f10 0x08000d9a
# 0x08001a3c: sensor_read at App/sensor.c:88
# 0x08001c3e: sensor_task at App/tasks.c:41
# 0x08002f10: ?? ??:0              ← 오탐 (상수 값이 우연히 Flash 범위)
# 0x08000d9a: main at Core/Src/main.c:112

# 그 주소 근처 기계어로 무엇을 하던 중인지
arm-none-eabi-objdump -d -S --start-address=0x08001a30 \
    --stop-address=0x08001a48 fw-1.4.2.elf
#  8001a3c: e9d2 2300  ldrd r2, r3, [r2]   ← LDRD + 비정렬 주소 = UNALIGNED

# GDB 로 덤프 값을 "다시 살려" 보기 — 같은 .elf 를 올린 보드를 멈춘 채로
# 레지스터에 덤프 값을 써 넣고 bt (보기만 하고 continue 는 하지 말 것)
(gdb) set $sp = 0x2001ff50
(gdb) set $lr = 0x08001c3f
(gdb) set $pc = 0x08001a3c
(gdb) bt
.elf 는 빌드마다 다릅니다 — 같은 소스라도 컴파일러 버전 · 플래그가 다르면 주소가 바뀝니다. 덤프에 빌드 ID(git 해시 앞 8자리)를 넣고, CI 가 그 해시 이름으로 .elf 를 보관하게 하세요(t11).

현장 장비 — .noinit RAM 에 덤프를 남기고 리셋 뒤에 꺼내기 🔴 고급

덤프의 일생 — 위는 칩이 하는 일, 아래는 .noinit 의 g_crash ① 폴트 핸들러 진입 ② 덤프 기록 매직 + 체크섬 ③ SystemReset RAM 은 전원 유지 ④ startup .data 복사 · .bss 0 ⑤ main 이 확인 UART · Flash 로 보고 ⑥ 매직 지움 이전 값 유효 덤프 유효 덤프 그대로 유지 읽는 중 무효 NOLOAD 영역이라 시작 코드가 건드리지 않는 구간 전원을 새로 켜면(POR) RAM 은 무작위 — 매직과 체크섬이 쓰레기를 걸러 냅니다 워치독 리셋 · 핀 리셋 · 소프트웨어 리셋에서는 RAM 내용이 그대로 남습니다
.noinit 덤프의 타임라인 — 시작 코드가 0 으로 채우지 않는 RAM 한 조각이 리셋을 건너는 다리입니다.

링커 스크립트 + 덤프 구조체

/* STM32F446RETX_FLASH.ld — .bss 다음, ._user_heap_stack 앞에 추가 */
  .noinit (NOLOAD) :
  {
    . = ALIGN(4);
    *(.noinit)
    *(.noinit.*)
    . = ALIGN(4);
  } >RAM
/* crash.h */
#include <stddef.h>
#include <stdint.h>
#define CRASH_MAGIC 0xC0DEFA17u
typedef struct {
    uint32_t magic;
    uint32_t build_id;          /* git 해시 앞 8자리 (빌드 시 -D 로 주입) */
    uint32_t r0, r1, r2, r3, r12, lr, pc, xpsr;   /* 프레임 (없으면 0) */
    uint32_t r4_r11[8];
    uint32_t cfsr, hfsr, mmfar, bfar, exc_return, sp_before;
    uint32_t ret[8];            /* 스택에서 주운 주소 후보 */
    uint32_t sum;               /* ★ 맨 끝 — 앞 전체의 체크섬 */
} crash_dump_t;

extern crash_dump_t g_crash;

/* crash.c */
__attribute__((section(".noinit"))) crash_dump_t g_crash;

static uint32_t chk32(const uint32_t *w, uint32_t n)
{
    uint32_t s = 0x5A5A5A5Au;
    while (n--) s = (s << 5 | s >> 27) ^ *w++;   /* 회전 + XOR */
    return s;
}
#define CRASH_WORDS ((uint32_t)(offsetof(crash_dump_t, sum) / 4u))

폴트 때 쓰고, 부팅 때 읽기

void hardfault_c(const uint32_t *sf, uint32_t exc_return,
                 const uint32_t *r4_r11)
{
    crash_dump_t *d = &g_crash;
    int ok = ((uint32_t)sf & 3u) == 0 &&
             (uint32_t)sf >= 0x20000000u && (uint32_t)sf <= 0x2001FFE0u;
    if (ok) {                       /* 범위가 맞을 때만 프레임 복사 */
        d->r0 = sf[0]; d->r1 = sf[1]; d->r2 = sf[2]; d->r3 = sf[3];
        d->r12 = sf[4]; d->lr = sf[5]; d->pc = sf[6]; d->xpsr = sf[7];
        uint32_t sz = (exc_return & 0x10u) ? 0x20u : 0x68u;
        if (d->xpsr & (1u << 9)) sz += 4u;          /* 정렬 패딩 */
        d->sp_before = (uint32_t)sf + sz;
    } else {
        d->pc = d->lr = d->sp_before = 0;
    }
    for (int i = 0; i < 8; i++) d->r4_r11[i] = r4_r11[i];
    d->cfsr = SCB->CFSR;  d->hfsr = SCB->HFSR;
    d->mmfar = SCB->MMFAR; d->bfar = SCB->BFAR;
    d->exc_return = exc_return;
    d->build_id = BUILD_ID;
    d->magic = CRASH_MAGIC;
    d->sum = chk32((const uint32_t *)d, CRASH_WORDS);   /* 마지막에 */

    if (CoreDebug->DHCSR & CoreDebug_DHCSR_C_DEBUGEN_Msk) __BKPT(0);
    NVIC_SystemReset();
}

/* main() 앞부분 — 클럭 · UART 초기화 직후 */
void crash_check_at_boot(void)
{
    crash_dump_t *d = &g_crash;
    if (d->magic == CRASH_MAGIC &&
        d->sum == chk32((const uint32_t *)d, CRASH_WORDS)) {
        LOG_ERR("CRASH build=%08lx pc=%08lx lr=%08lx cfsr=%08lx bfar=%08lx",
                (unsigned long)d->build_id, (unsigned long)d->pc,
                (unsigned long)d->lr, (unsigned long)d->cfsr,
                (unsigned long)d->bfar);
        persist_to_flash(d, sizeof *d);   /* 다음 통신 때 서버로 (양산 탭) */
    }
    d->magic = 0;                          /* ★ 같은 덤프를 두 번 보고하지 않게 */
}
생략한 ret[] 는 앞의 collect_ret_addrs(sp_before, _estack, ...) 로 채웁니다(MSP 일 때). RAM 보다 오래 남겨야 하면 F446 의 백업 SRAM 4 KB(VBAT 로 유지)나 RTC 백업 레지스터를 씁니다. 폴트 핸들러에서 Flash 를 직접 쓰는 건 피하세요 — 지우기에 수백 ms~초가 걸리고 그 사이 또 폴트가 나면 Flash 가 반쯤 지워집니다.
정리 — 프레임 크기는 EXC_RETURN 비트4(FPU)와 xPSR 비트9(정렬 패딩)로 정해지고, 이걸 알아야 폴트 직전 SP 와 그 위의 호출 경로를 읽습니다. 핸들러는 전용 스택으로 갈아탄 뒤 C 로 넘어가고, 덤프는 매직 + 체크섬과 함께 .noinit 에 남겨 리셋 뒤 부팅 코드가 보고합니다. 빌드 ID 로 맞는 .elf 를 찾으면 현장 크래시도 줄 번호까지 내려갑니다.
QA 06

스택 · 메모리 문제 — 추측하지 말고 재고, 넘치면 바로 죽게

MCU 에는 MMU 가 없습니다. 스택이 넘치면 세그폴트 대신 옆 변수를 조용히 덮어씁니다 — 증상은 엉뚱한 곳에서, 한참 뒤에 나타납니다. 그래서 펌웨어의 메모리 디버깅은 두 갈래입니다: 얼마나 쓰는지 숫자로 재기(스택 페인팅 · -fstack-usage · .map)와 넘치는 순간 즉시 폴트 나게 만들기(MPU 가드). 힙 단편화가 왜 "남는데 못 받는" 상황을 만드는지도 봅니다.

F446RE RAM 128 KB — 힙은 위로, 스택은 아래로 (비율 아님) .data .bss .noinit 힙 → 빈 곳 가드 ← 메인 스택 (MSP) 0x2000_0000 0x2002_0000 = _estack MPU: 접근 금지 256 B 닿는 순간 MemManage malloc · _sbrk 위로 자람 함수 호출 · ISR 아래로 자람 시작 코드가 0 으로 .noinit 은 그대로(t05) 가드가 없으면: 스택이 힙 → .bss 순으로 조용히 덮어씀 — 증상은 한참 뒤 가드가 있으면: 넘치는 그 명령에서 즉시 폴트 — PC 가 범인을 가리킴 RTOS 태스크 스택은 힙 안의 작은 배열들 — 각자 가드가 필요(FreeRTOS 탭 r09)
RAM 배치 — 스택과 힙이 마주 보고 자라는 빈 곳이 유일한 완충지대입니다. 가드는 그 경계에 세우는 울타리입니다.

① 스택 페인팅 — 0xA5 로 칠하고 얼마나 지워졌나 보기

/* CubeMX 링커 스크립트가 주는 심볼 — "주소"가 곧 값입니다 */
extern uint32_t _estack;           /* 스택 꼭대기 = RAM 끝     */
extern uint32_t _Min_Stack_Size;   /* 예약 크기 (0x800 등)     */
#define STACK_TOP    ((uint32_t)&_estack)
#define STACK_SIZE   ((uint32_t)&_Min_Stack_Size)
#define STACK_BOTTOM (STACK_TOP - STACK_SIZE)
#define PAINT        0xA5A5A5A5u

/* main() 맨 처음에 한 번 — 지금 SP 아래 64 B 는 남겨 둠 */
void stack_paint(void)
{
    uint32_t *p  = (uint32_t *)STACK_BOTTOM;
    uint32_t *sp = (uint32_t *)__get_MSP();
    while (p < sp - 16) *p++ = PAINT;
}

/* 바닥부터 올라가며 칠이 남은 곳을 셈 → 최대 사용량 */
uint32_t stack_high_water(void)
{
    const uint32_t *p = (const uint32_t *)STACK_BOTTOM;
    while (p < (const uint32_t *)STACK_TOP && *p == PAINT) p++;
    return STACK_TOP - (uint32_t)p;     /* 바이트 */
}
/* 1분마다 / 시험 끝에: LOG_INF("msp max %lu / %lu", hw, STACK_SIZE); */
측정은 최악 경로를 지나간 뒤에만 의미가 있습니다 — 오류 처리 경로 · 모든 인터럽트가 겹친 순간 · 로그 레벨 DEBUG. HIL 시험(t12) 마지막에 high-water 를 읽어 기록하면 버전마다 추이가 보입니다. FreeRTOS 태스크는 커널이 같은 방식(0xA5)으로 칠해 두니 uxTaskGetStackHighWaterMark() 를 쓰세요(r 탭).

② MPU 스택 가드 — 넘치면 그 자리에서 폴트

/* core_cm4.h 가 mpu_armv7.h 를 포함합니다(__MPU_PRESENT = 1).
   F446 MPU: 영역 8개, 크기는 2의 거듭제곱(최소 32 B), 시작 주소는 크기의 배수 */
#define GUARD_SIZE 256u

void mpu_stack_guard(void)
{
    uint32_t base = (STACK_BOTTOM + GUARD_SIZE - 1u) & ~(GUARD_SIZE - 1u);

    ARM_MPU_Disable();
    ARM_MPU_SetRegion(
        ARM_MPU_RBAR(0u, base),                    /* 영역 0 */
        ARM_MPU_RASR(1u,                           /* 실행 금지 */
                     ARM_MPU_AP_NONE,              /* 읽기 · 쓰기 금지 */
                     0u, 0u, 0u, 0u,               /* TEX · S · C · B */
                     0x00u,                        /* 서브영역 전부 사용 */
                     ARM_MPU_REGION_SIZE_256B));
    /* PRIVDEFENA: 영역 밖은 기본 메모리 맵 그대로 → 가드만 막힘 */
    ARM_MPU_Enable(MPU_CTRL_PRIVDEFENA_Msk);
    SCB->SHCSR |= SCB_SHCSR_MEMFAULTENA_Msk;     /* MemManage 개별 핸들러 */
}
/* ★ 가드를 켠 뒤에는 stack_paint · stack_high_water 도 base + GUARD_SIZE 부터
     훑어야 합니다 — 측정 코드가 가드를 읽는 순간 그 자체가 MemManage 폴트 */
가드보다 큰 지역 배열은 가드를 건너뜁니다 — uint8_t buf[512]; 처럼 SP 를 한 번에 512 B 내리면 가드(256 B)를 밟지 않고 그 아래 힙에 바로 씁니다. 가드는 가장 큰 스택 프레임보다 크게 잡거나, 큰 배열을 스택에 두지 마세요. Cortex-M33(STM32U5 · L5)부터는 MSPLIM · PSPLIM 레지스터가 이 일을 하드웨어로 합니다.

정적 분석으로 재기 — -fstack-usage · -Wstack-usage · 최악 경로 🟡 중급

MSP 2048 B 의 최악 순간 — 가장 깊은 호출 + 중첩 인터럽트 parse log_write vsnprintf ~600 TIM2 UART 여유 352 호출 체인 1360 B — main 64 · app_loop 96 · parse 288 · log 312 · printf 600 중첩 ISR 2단 336 B 프레임 104 + 핸들러 printf 계열 하나가 체인의 44% — 로그를 RTT 바이너리로 바꾸면 여유가 두 배 FPU 를 쓰는 ISR 은 프레임이 32 B 가 아니라 104 B — 중첩 단수만큼 곱합니다
최악 스택 = 가장 깊은 호출 체인 + 동시에 중첩될 수 있는 ISR 들. 각 함수의 숫자는 -fstack-usage 가 줍니다.

컴파일러에게 함수별 스택을 물어보기

# CMakeLists.txt — 펌웨어 타깃에
target_compile_options(fw PRIVATE
    -fstack-usage            # 소스마다 .su 파일 생성
    -Wstack-usage=256        # 256 B 넘는 함수는 경고
    -fcallgraph-info=su)     # GCC 10+: 호출 그래프 + 스택(.ci)
$ cat build/App/proto.c.su build/App/log.c.su
proto.c:40:5:parse_frame       288   static
proto.c:95:13:crc16_update      16   static
log.c:12:6:log_write           312   dynamic,bounded

$ arm-none-eabi-gcc ... -Wstack-usage=256
proto.c:40:5: warning: stack usage is 288 bytes [-Wstack-usage=]

# 전체를 큰 순서로
$ find build -name '*.su' -exec cat {} + | sort -t$'\t' -k2 -n -r | head
static = 컴파일 시점에 확정 · dynamic,bounded = 상한이 있음 · dynamic = alloca · VLA 로 상한 없음(금지 대상). .su 는 함수 하나의 프레임만 셉니다. 체인 합계는 .ci 를 읽는 도구(puncover 등)나 손으로 — 함수 포인터 · 재귀가 있으면 어떤 도구도 자동으로 못 셉니다. MISRA 가 재귀를 금지하는 이유 중 하나입니다.

.map · size — Flash 와 RAM 을 누가 먹나

# 링크 때 한 줄 요약 (-Wl,--print-memory-usage)
Memory region         Used Size  Region Size  %age Used
             RAM:       10984 B       128 KB      8.38%
           FLASH:       25224 B       512 KB      4.81%

$ arm-none-eabi-size build/fw.elf
   text    data     bss     dec     hex filename
  24812     412   10572   35796    8bd4 build/fw.elf
# Flash = text + data(초깃값)   = 25224
# RAM   = data + bss(힙·스택 예약 포함) = 10984

# 큰 심볼 20개 — 크기 순
$ arm-none-eabi-nm -S --size-sort -t d build/fw.elf | tail -20
#  주소(10진)  크기  종류  이름
134239580 00002260 T _svfprintf_r  ← printf 엔진 (nano 기준)
536871936 00003072 B g_rx_ring
536875008 00004096 b s_log_buf     ← 소문자 = static (오름차순 → 맨 아래가 가장 큼)

# .map 에서 "어느 .o 가 끌려왔나": -Wl,-Map=fw.map,--cref
#   Archive member included to satisfy reference by file (symbol)
#   .../libc_nano.a(libc_a-vsnprintf.o)
#                                 App/log.o (vsnprintf)
--cref 와 "Archive member included" 블록이 "이 몇 KB 를 누가 끌고 왔나"에 답합니다. 숫자는 CI 가 PR 마다 비교하게 만드세요(t11) — 한 번에 2 KB 늘어난 PR 은 리뷰에서 바로 보입니다.

힙 단편화 — 남는데 못 받는 이유와 고정 블록 풀 🔴 고급

단편화가 생기는 순서

/* 힙 8 KB 라고 가정 */
void *a = malloc(3000);   /* [a 3000][        5192 남음         ] */
void *b = malloc(2000);   /* [a 3000][b 2000][   3192 남음      ] */
void *c = malloc(3000);   /* [a 3000][b 2000][c 3000][  192    ] */
free(a);                  /* [ 3000 ][b 2000][c 3000][  192    ] */
free(c);                  /* [ 3000 ][b 2000][     3192        ] */
void *d = malloc(4000);   /* ★ NULL — 빈 곳 합계 6192 인데
                             한 덩어리가 4000 이 안 됨 */
/* 서버라면 GC · 압축이 해결하지만 C 는 포인터가 주소를 들고 있어
   블록을 옮길 수 없습니다. 장비가 몇 주 돌다 죽는 전형적 원인. */

/* 관찰: newlib 의 mallinfo() */
#include <malloc.h>
struct mallinfo mi = mallinfo();
/* mi.arena    : _sbrk 로 받아 온 총량
   mi.uordblks : 사용 중
   mi.fordblks : 빈 곳 합계 (가장 큰 덩어리가 아님!) */
(블록 헤더 · 정렬 바이트는 생략한 그림입니다.) 실무 규칙은 단순합니다 — 초기화 때만 malloc, 운영 중엔 금지. 운영 중 할당이 꼭 필요하면 크기별 고정 블록 풀을 씁니다.

고정 블록 풀 — 단편화가 원리적으로 불가능

/* 같은 크기 블록만 주고받으니 "빈 곳은 있는데 못 받는" 일이 없음 */
#define BLK_SIZE  64u
#define BLK_COUNT 32u

typedef union blk { union blk *next; uint8_t data[BLK_SIZE]; } blk_t;
static blk_t  s_pool[BLK_COUNT];
static blk_t *s_free;
static uint32_t s_used, s_peak;

void pool_init(void)
{
    s_free = NULL;
    for (uint32_t i = 0; i < BLK_COUNT; i++) {
        s_pool[i].next = s_free;
        s_free = &s_pool[i];
    }
    s_used = s_peak = 0;
}

void *pool_alloc(void)                /* O(1) · ISR 에서 쓰려면 임계 구역 */
{
    blk_t *b = s_free;
    if (b) {
        s_free = b->next;
        if (++s_used > s_peak) s_peak = s_used;   /* 최대 사용 기록 */
    }
    return b;
}

void pool_free(void *p)
{
    blk_t *b = p;
    b->next = s_free;
    s_free = b;
    s_used--;
}
/* s_peak 를 HIL 시험 끝에 읽어 BLK_COUNT 를 정합니다 — 스택 페인팅과 같은 발상 */
정리 — 스택은 0xA5 페인팅으로 실측하고 -fstack-usage 로 함수별 숫자를 받아 최악 체인 + 중첩 ISR 로 합산합니다. MPU 가드(가장 큰 프레임보다 크게)를 세우면 넘치는 순간 폴트가 나 범인이 드러납니다. Flash · RAM 은 --print-memory-usage 와 .map 의 --cref 로 추적하고, 운영 중 malloc 은 고정 블록 풀로 바꿉니다.
QA 07

호스트에서 단위 테스트 — 보드 없이 펌웨어 로직을 1초 안에

보드에 구워서 확인하는 한 바퀴는 빌드 · 플래시 · 리셋 · 눈으로 확인까지 30초가 넘고, 자동화도 어렵습니다. 그런데 펌웨어 코드의 대부분 — 프로토콜 파서, 링 버퍼, 상태 머신, 필터, 단위 변환 — 은 레지스터를 한 번도 만지지 않습니다. 그 부분을 PC 의 GCC 로 컴파일해 돌리면 수천 개 테스트가 1초 안에 끝납니다. Unity 의 기본 사용법은 C 실전 탭 c24 에 있으니, 여기서는 펌웨어를 호스트에서 테스트할 때만 생기는 문제와 프로젝트 구성에 집중합니다.

펌웨어 테스트 피라미드 — 아래가 넓어야 매 커밋마다 돌릴 수 있습니다 현장 · 수동 실제 설치 환경 · 장기 구동 주 단위 · 재현이 가장 어려움 HIL 시스템 수십 실제 보드 + 지그 + pytest (t12) 수십 분 · 야간 · 릴리스 전 온타깃 테스트 수백 칩 위의 Unity · 타이밍 · 주변장치 수 분 · 보드 필요 호스트 단위 테스트 수천 PC 의 GCC · 페이크 (t08 · t09) 수 초 · 커밋마다 · 원인 즉시 느림 비쌈 빠름 쌈 "보드가 있어야 테스트한다"고 생각하면 피라미드가 뒤집힙니다 — 위층만 남음
테스트 피라미드 — 로직의 70% 이상을 호스트에서. 칩이 꼭 필요한 것(타이밍 · 주변장치)만 위층으로 올립니다.

왜 가능한가 — 하드웨어를 모르는 코드

fw/
├─ app/            ← 순수 C. #include "stm32f4xx.h" 금지
│  ├─ ringbuf.c/h      링 버퍼
│  ├─ debounce.c/h     버튼 디바운스 (시간은 인자로 받음)
│  ├─ proto.c/h        프레임 파서 · CRC (t10)
│  └─ controller.c/h   상태 머신 — 하드웨어는 포트로만
├─ port/           ← 인터페이스(헤더)만: gpio_port.h, i2c_port.h ...
├─ bsp/            ← 포트의 STM32 구현 (HAL · 레지스터)
├─ drivers/        ← 레지스터 드라이버 (t09 에서 페이크로 테스트)
├─ test/           ← Unity 테스트 · 페이크
│  ├─ test_ringbuf.c
│  ├─ test_debounce.c
│  └─ fakes/
└─ CMakeLists.txt
규칙은 하나 — app/ 의 코드는 하드웨어를 직접 부르지 않고 port/ 의 함수 선언만 봅니다. 이렇게 의존 방향을 뒤집는 설계는 대규모 C 구조 탭 k08(의존성 역전)에서 자세히 다룹니다. 서버의 "리포지토리 인터페이스 + 테스트용 인메모리 구현"과 똑같은 발상입니다.

테스트 대상 — 링 버퍼 (ISR 과 메인이 공유)

/* ringbuf.h — 크기는 2의 거듭제곱, head/tail 은 계속 증가(자연 오버플로) */
#include <stdbool.h>
#include <stdint.h>
typedef struct {
    uint8_t *buf;
    uint16_t mask;                 /* size - 1 */
    volatile uint16_t head, tail;  /* 쓰는 쪽 head, 읽는 쪽 tail */
} ringbuf_t;

bool     rb_init(ringbuf_t *rb, uint8_t *mem, uint16_t size);
bool     rb_put(ringbuf_t *rb, uint8_t b);
bool     rb_get(ringbuf_t *rb, uint8_t *out);
uint16_t rb_count(const ringbuf_t *rb);

/* ringbuf.c */
bool rb_init(ringbuf_t *rb, uint8_t *mem, uint16_t size)
{
    if (size == 0 || (size & (size - 1u)) != 0 || size > 32768u) return false;
    rb->buf = mem; rb->mask = (uint16_t)(size - 1u);
    rb->head = rb->tail = 0;
    return true;
}
uint16_t rb_count(const ringbuf_t *rb)
{
    return (uint16_t)(rb->head - rb->tail);   /* ★ 캐스트: int 승격 후 음수 방지 */
}
bool rb_put(ringbuf_t *rb, uint8_t b)
{
    if (rb_count(rb) > rb->mask) return false;   /* 가득 참 */
    rb->buf[rb->head & rb->mask] = b;
    rb->head++;
    return true;
}
bool rb_get(ringbuf_t *rb, uint8_t *out)
{
    if (rb->head == rb->tail) return false;      /* 비었음 */
    *out = rb->buf[rb->tail & rb->mask];
    rb->tail++;
    return true;
}

Unity 테스트 — 경계 · 랩어라운드 · 주입한 시간 🟡 중급

test_ringbuf.c — 보드에선 며칠 걸릴 경계를 바로

#include "unity.h"
#include "ringbuf.h"

static ringbuf_t rb;
static uint8_t   mem[8];

void setUp(void)    { TEST_ASSERT_TRUE(rb_init(&rb, mem, sizeof mem)); }
void tearDown(void) { }

void test_rejects_non_power_of_two(void)
{
    ringbuf_t r; uint8_t m[6];
    TEST_ASSERT_FALSE(rb_init(&r, m, sizeof m));
}

void test_full_after_size_puts(void)
{
    for (uint8_t i = 0; i < 8; i++) TEST_ASSERT_TRUE(rb_put(&rb, i));
    TEST_ASSERT_FALSE(rb_put(&rb, 99));             /* 9번째는 거절 */
    TEST_ASSERT_EQUAL_UINT16(8, rb_count(&rb));
}

void test_fifo_order(void)
{
    uint8_t out[3];
    rb_put(&rb, 0xA1); rb_put(&rb, 0xB2); rb_put(&rb, 0xC3);
    for (int i = 0; i < 3; i++) TEST_ASSERT_TRUE(rb_get(&rb, &out[i]));
    const uint8_t expect[3] = { 0xA1, 0xB2, 0xC3 };
    TEST_ASSERT_EQUAL_HEX8_ARRAY(expect, out, 3);
}

/* ★ 실제 장비에선 65536 바이트를 받은 뒤에야 지나가는 경계 */
void test_index_wraps_past_65535(void)
{
    rb.head = rb.tail = 65534u;                    /* 화이트박스: 경계 직전 */
    for (uint8_t i = 0; i < 4; i++) TEST_ASSERT_TRUE(rb_put(&rb, (uint8_t)(0x10 + i)));
    TEST_ASSERT_EQUAL_UINT16(2, rb.head);          /* 65538 → 2 로 넘어감 */
    TEST_ASSERT_EQUAL_UINT16(4, rb_count(&rb));    /* 그래도 개수는 4 */
    uint8_t b;
    for (uint8_t i = 0; i < 4; i++) {
        TEST_ASSERT_TRUE(rb_get(&rb, &b));
        TEST_ASSERT_EQUAL_HEX8(0x10 + i, b);
    }
    TEST_ASSERT_FALSE(rb_get(&rb, &b));
}

int main(void)
{
    UNITY_BEGIN();
    RUN_TEST(test_rejects_non_power_of_two);
    RUN_TEST(test_full_after_size_puts);
    RUN_TEST(test_fifo_order);
    RUN_TEST(test_index_wraps_past_65535);
    return UNITY_END();
}

시간을 인자로 — HAL_GetTick() 을 부르지 않는 디바운스

/* debounce.c — "지금 시각"을 받기만 하므로 테스트가 시간을 조종 */
#define DEBOUNCE_MS 20u            /* 이 줄과 typedef 는 debounce.h 에 */
typedef struct { uint8_t stable, last_raw; uint32_t since_ms; } debounce_t;

bool debounce_update(debounce_t *d, uint8_t raw, uint32_t now_ms)
{
    if (raw != d->last_raw) {            /* 바뀌었으면 시계 다시 시작 */
        d->last_raw = raw;
        d->since_ms = now_ms;
        return false;
    }
    if (raw != d->stable && (uint32_t)(now_ms - d->since_ms) >= DEBOUNCE_MS) {
        d->stable = raw;
        return true;                     /* 안정된 상태가 바뀜 = 이벤트 */
    }
    return false;
}
/* test_debounce.c — include 와 main 은 앞 파일과 같은 모양 */
static debounce_t d;
void setUp(void)    { d = (debounce_t){ 0 }; }
void tearDown(void) { }

void test_bounce_then_stable_20ms(void)
{
    TEST_ASSERT_FALSE(debounce_update(&d, 1, 100));   /* 튐 */
    TEST_ASSERT_FALSE(debounce_update(&d, 0, 103));
    TEST_ASSERT_FALSE(debounce_update(&d, 1, 105));   /* 여기서 시계 시작 */
    TEST_ASSERT_FALSE(debounce_update(&d, 1, 124));   /* 19 ms */
    TEST_ASSERT_TRUE (debounce_update(&d, 1, 125));   /* 20 ms → 눌림 */
    TEST_ASSERT_EQUAL_UINT8(1, d.stable);
}

/* ★ 49.7일(2^32 ms)마다 오는 틱 넘침 — 보드로는 사실상 시험 불가 */
void test_survives_tick_wraparound(void)
{
    TEST_ASSERT_FALSE(debounce_update(&d, 1, 0xFFFFFFF0u));
    TEST_ASSERT_FALSE(debounce_update(&d, 1, 0xFFFFFFFFu));  /* 15 ms */
    TEST_ASSERT_TRUE (debounce_update(&d, 1, 0x00000004u));  /* 20 ms */
}
시간 · 난수 · 하드웨어 상태를 인자나 포트로 받는 코드만 테스트할 수 있습니다. HAL_GetTick() 을 함수 안에서 직접 부르면 테스트가 시간을 못 바꿉니다 — "49.7일 뒤 죽는 장비" 버그가 바로 이 자리에서 나옵니다.

CMake 로 한 저장소 · 두 빌드 — 그리고 호스트와 ARM 의 차이 🔴 고급

CMakeLists.txt — 툴체인 파일 유무로 갈라짐

cmake_minimum_required(VERSION 3.25)
project(fw C ASM)

# 하드웨어를 모르는 코드는 라이브러리 하나로 — 두 빌드가 함께 씀
add_library(app STATIC app/ringbuf.c app/debounce.c app/proto.c)
target_include_directories(app PUBLIC app port)
target_compile_options(app PRIVATE -Wall -Wextra -Wconversion)

if(CMAKE_CROSSCOMPILING)                # -DCMAKE_TOOLCHAIN_FILE=cmake/arm-gcc.cmake
    add_executable(fw.elf main.c bsp/... drivers/... startup_stm32f446xx.s)
    target_link_libraries(fw.elf PRIVATE app)
else()                                  # 호스트 빌드 = 테스트
    target_compile_options(app PRIVATE -funsigned-char)   # ★ 아래 표
    include(FetchContent)
    FetchContent_Declare(unity
        GIT_REPOSITORY https://github.com/ThrowTheSwitch/Unity.git
        GIT_TAG        v2.6.0)          # 최신 태그로 고정
    FetchContent_MakeAvailable(unity)

    enable_testing()
    foreach(t ringbuf debounce proto)
        add_executable(test_${t} test/test_${t}.c)
        target_compile_options(test_${t} PRIVATE -funsigned-char)
        target_link_libraries(test_${t} PRIVATE app unity)
        add_test(NAME ${t} COMMAND test_${t})
    endforeach()
endif()
cmake -B build-host && cmake --build build-host -j
ctest --test-dir build-host --output-on-failure     # 수 초

cmake -B build-fw -DCMAKE_TOOLCHAIN_FILE=cmake/arm-gcc.cmake
cmake --build build-fw -j                           # 같은 app 을 ARM 으로

호스트(x86-64 리눅스)와 Cortex-M 은 같은 C 가 아닙니다

항목arm-none-eabix86-64 GCC대응
char 부호unsignedsigned호스트에 -funsigned-char
long32비트64비트int32_t 등 고정 폭만
포인터32비트64비트(uint32_t)ptr 금지 → uintptr_t
int32비트32비트같음 (AVR 은 16비트!)
엔디언리틀리틀같음 — 그래도 직렬화는 명시적으로
비정렬 접근LDRD 등은 폴트항상 허용UBSan alignment(t10)
double소프트웨어(F4 FPU 는 float 만)하드웨어결과는 같고 속도만 다름
호스트에서 통과 = 칩에서 통과가 아닙니다 — 위 표의 차이 · 타이밍 · volatile · 인터럽트 동시성은 호스트가 못 봅니다. 그래서 같은 테스트를 칩 위에서도 한 번 돌립니다: Unity 는 putchar 만 있으면 되므로 테스트 러너를 펌웨어로 빌드해 결과를 RTT/UART 로 받으면 됩니다(UNITY_OUTPUT_CHAR(c) 재정의). 리눅스라면 -m32 빌드로 포인터 폭까지 맞추는 팀도 있습니다.
정리 — 하드웨어를 직접 부르지 않는 코드(app/)를 라이브러리로 떼어 호스트 GCC 로 Unity 테스트를 돌립니다. 시간 · 하드웨어 상태를 인자로 받게 짜면 65536 바이트 경계, 49.7일 틱 넘침 같은 "보드로는 못 기다리는" 경계를 1초 안에 시험합니다. char 부호 · long · 포인터 폭 차이는 컴파일 옵션과 고정 폭 타입으로 맞추고, 같은 테스트를 칩 위에서도 한 번 돌립니다.
QA 08

목 · 페이크 — CMock · FFF · Ceedling 으로 하드웨어 자리 채우기

센서 드라이버나 제어 로직은 결국 I²C 를 읽고 GPIO 를 씁니다. 테스트에서 그 자리를 무엇으로 채울지가 이 절의 주제입니다. 자바의 Mockito 처럼 C 에도 도구가 있지만 동작 원리가 다릅니다 — C 에는 리플렉션이 없어서 "진짜 함수 대신 가짜 함수를 링크한다"(링크 타임 치환)가 기본 기법입니다. 헤더에서 목을 자동 생성하는 CMock, 매크로 한 줄로 가짜를 만드는 FFF, 그 둘을 묶는 빌드 도구 Ceedling 을 실제 센서(TMP117) 예제로 씁니다.

같은 소스, 다른 링크 — 헤더 아래만 갈아 끼웁니다 양산 빌드 (arm-none-eabi) 테스트 빌드 (호스트 GCC) controller.c · tmp117.c controller.c · tmp117.c (같은 파일) i2c_port.h · gpio_port.h (선언만) i2c_port.h · gpio_port.h (같은 헤더) bsp_i2c.c — HAL_I2C_Mem_Read 호출 fake_i2c.c (FFF) 또는 mock_i2c_port.c STM32 HAL → I2C1 레지스터 → 센서 호출 기록 · 정해 둔 응답 · 오류 주입 다른 것은 세 번째 줄 하나 — 링커에 어떤 .c 를 주느냐 #ifdef TEST 를 소스에 넣지 않아도 됩니다
링크 타임 치환 — 헤더는 계약, 구현은 빌드마다 다르게. 서버의 "인터페이스 + 테스트용 구현"을 링커가 해 줍니다.

테스트 대상 — TMP117 드라이버 (포트만 앎)

/* i2c_port.h — 계약. 0 = 성공, 음수 = 오류 */
#include <stddef.h>
#include <stdint.h>
int i2c_read_reg(uint8_t addr7, uint8_t reg, uint8_t *buf, size_t len);

/* tmp117.c — TI TMP117: 온도 레지스터 0x00 (16비트 2의 보수),
   1 LSB = 7.8125 m°C = 125/16 m°C, 장치 ID 레지스터 0x0F = 0x0117 */
#include <errno.h>
#include "i2c_port.h"
#define TMP117_ADDR  0x48u          /* ADD0 = GND */
#define REG_TEMP     0x00u
#define REG_DEV_ID   0x0Fu

int tmp117_probe(void)
{
    uint8_t b[2];
    if (i2c_read_reg(TMP117_ADDR, REG_DEV_ID, b, 2) != 0) return -EIO;
    uint16_t id = (uint16_t)((b[0] << 8) | b[1]);    /* MSB 먼저 */
    return ((id & 0x0FFFu) == 0x0117u) ? 0 : -ENODEV;
}

int tmp117_read_mc(int32_t *out_mc)             /* 밀리 °C */
{
    uint8_t b[2];
    int rc = i2c_read_reg(TMP117_ADDR, REG_TEMP, b, 2);
    if (rc != 0) return rc;
    int16_t raw = (int16_t)((b[0] << 8) | b[1]);  /* 0xFF80 → -128 (GCC: 모듈로) */
    *out_mc = (int32_t)raw * 125 / 16;           /* 0x0C80=3200 → 25000 */
    return 0;
}

FFF — 헤더 하나, 매크로 한 줄로 가짜

/* test_tmp117.c — fff.h 는 헤더 하나(github.com/meekrosoft/fff) */
#include <errno.h>
#include <string.h>
#include "unity.h"
#include "fff.h"
#include "tmp117.h"
DEFINE_FFF_GLOBALS;

/* 반환형, 이름, 인자형들 — i2c_read_reg 의 가짜 + 기록 구조체 생성 */
FAKE_VALUE_FUNC(int, i2c_read_reg, uint8_t, uint8_t, uint8_t *, size_t);

static uint8_t s_reply[2];
static int reply_fake(uint8_t a, uint8_t r, uint8_t *buf, size_t len)
{
    (void)a; (void)r;
    memcpy(buf, s_reply, len);          /* 출력 인자 채우기 */
    return 0;
}

void setUp(void)    { RESET_FAKE(i2c_read_reg); FFF_RESET_HISTORY(); }
void tearDown(void) { }

void test_reads_25_degrees(void)
{
    s_reply[0] = 0x0C; s_reply[1] = 0x80;          /* 3200 LSB */
    i2c_read_reg_fake.custom_fake = reply_fake;
    int32_t mc;
    TEST_ASSERT_EQUAL_INT(0, tmp117_read_mc(&mc));
    TEST_ASSERT_EQUAL_INT32(25000, mc);
    TEST_ASSERT_EQUAL_UINT(1, i2c_read_reg_fake.call_count);
    TEST_ASSERT_EQUAL_HEX8(0x48, i2c_read_reg_fake.arg0_val);  /* 주소 */
    TEST_ASSERT_EQUAL_HEX8(0x00, i2c_read_reg_fake.arg1_val);  /* 레지스터 */
}

void test_negative_temperature(void)
{
    s_reply[0] = 0xFF; s_reply[1] = 0x80;          /* -128 LSB */
    i2c_read_reg_fake.custom_fake = reply_fake;
    int32_t mc;
    tmp117_read_mc(&mc);
    TEST_ASSERT_EQUAL_INT32(-1000, mc);            /* -1.000 °C */
}

void test_bus_error_is_propagated(void)
{
    i2c_read_reg_fake.return_val = -EIO;           /* 오류 주입 */
    int32_t mc = 12345;
    TEST_ASSERT_EQUAL_INT(-EIO, tmp117_read_mc(&mc));
    TEST_ASSERT_EQUAL_INT32(12345, mc);            /* 실패하면 출력 불변 */
}

void test_probe_rejects_wrong_chip(void)
{
    s_reply[0] = 0x01; s_reply[1] = 0x19;           /* 0x0119 — 엉뚱한 칩 ID */
    i2c_read_reg_fake.custom_fake = reply_fake;
    TEST_ASSERT_EQUAL_INT(-ENODEV, tmp117_probe());
}

/* 재시도 로직을 시험할 땐 반환값 순서를 정해 둠 (custom_fake 는 비워 둠):
   static int seq[] = { -EIO, -EIO, 0 };
   SET_RETURN_SEQ(i2c_read_reg, seq, 3);   // 실패 두 번 뒤 성공 */

CMock + Ceedling — 헤더에서 목을 자동 생성, 기대를 먼저 적기 🟡 중급

기대(Expect) → 실행 → 자동 검증

/* test/test_controller.c — Ceedling 은 include 를 보고
   mock_i2c_port.c/h 를 i2c_port.h 에서 자동 생성해 링크합니다 */
#include "unity.h"
#include "controller.h"
#include "tmp117.h"            /* 진짜 드라이버를 같이 씀 */
#include "mock_i2c_port.h"     /* ← 이 한 줄이 목 생성 지시 */
#include "mock_gpio_port.h"

void test_alarm_led_turns_on_above_limit(void)
{
    static uint8_t hot[2] = { 0x0F, 0xA0 };   /* 4000 LSB = 31250 m°C */

    /* ① 기대를 먼저 적습니다 — "이렇게 불려야 한다" */
    i2c_read_reg_ExpectAndReturn(0x48, 0x00, NULL, 2, 0);
    i2c_read_reg_IgnoreArg_buf();                       /* 버퍼 주소는 상관 없음 */
    i2c_read_reg_ReturnArrayThruPtr_buf(hot, 2);        /* 출력 인자 채우기 */
    gpio_write_Expect(GPIO_ALARM_LED, 1);

    /* ② 실행 */
    controller_step(30000);                             /* 한계 30.000 °C */

    /* ③ 검증은 자동 — 덜 불리거나, 더 불리거나, 인자가 다르면 실패 */
}

void test_retries_twice_then_reports_fault(void)
{
    i2c_read_reg_ExpectAnyArgsAndReturn(-5);
    i2c_read_reg_ExpectAnyArgsAndReturn(-5);
    i2c_read_reg_ExpectAnyArgsAndReturn(-5);
    gpio_write_Expect(GPIO_FAULT_LED, 1);
    controller_step(30000);
    TEST_ASSERT_EQUAL(CTRL_SENSOR_FAULT, controller_state());
}

project.yml — Ceedling 1.x 최소 구성

:project:
  :build_root: build/ceedling
  :test_file_prefix: test_

:paths:
  :test:    [test/**]
  :source:  [app/**]
  :include: [app/**, port/**]

:defines:
  :test: [UNIT_TEST]

:cmock:
  :mock_prefix: mock_
  :enforce_strict_ordering: true      # 다른 함수 사이 순서까지 검사
  :plugins:
    - ignore                          # foo_Ignore()
    - ignore_arg                      # foo_IgnoreArg_x()
    - expect_any_args                 # foo_ExpectAnyArgs...()
    - return_thru_ptr                 # foo_ReturnThruPtr_x()
    - callback                        # foo_Stub(콜백)

:plugins:
  :enabled:
    - report_tests_pretty_stdout
    - gcov
gem install ceedling            # 루비 기반
ceedling test:all               # 목 생성 → 컴파일 → 실행
ceedling test:test_controller   # 하나만
ceedling gcov:all               # 커버리지 포함 (t10)
CMock 의 기본은 엄격 모드입니다 — 기대하지 않은 호출이 한 번만 와도 실패합니다("Called more times than expected"). 그래서 CMock 테스트는 "무엇을 했나"(상호작용)까지 고정하고, FFF 테스트는 "결과가 맞나"에 집중하기 쉽습니다.

무엇을 목으로, 무엇을 진짜로 — 과한 목은 리팩터링을 막습니다 🔴 고급

이 의존 대상을 테스트에서 무엇으로 채울까 — 마름모가 질문 호스트에 없는 것인가? (하드웨어 · 시간 · RTOS) 아니오 진짜 코드 그대로 링 버퍼 · CRC · 변환식 예 호출 순서 · 횟수 자체가 요구사항인가? 예 목 (CMock) 센서 초기화 명령 순서 아니오 상태를 기억하며 반응해야 하나? 예 동작하는 페이크 레지스터(t09) · RAM 플래시 아니오 → 스텁 (FFF) 반환값 · 오류만 정함 목이 많을수록 테스트가 구현을 베낀 사본이 됩니다
대역(테스트 더블) 고르기 — 순수 로직은 진짜로, 상호작용이 요구사항일 때만 목, 나머지는 페이크 · 스텁.

CMock · FFF · 손으로 만든 페이크 비교

CMockFFF손 페이크
만드는 법헤더에서 자동 생성(루비)매크로 한 줄직접 .c 작성
검증기대 vs 실제 자동 비교기록을 보고 직접 단언상태를 보고 단언
엄격함높음(예상 밖 호출 = 실패)낮음설계하기 나름
잘 맞는 곳프로토콜 · 초기화 순서HAL · 포트 스텁 · 오류 주입Flash · EEPROM · 시계
약점리팩터링 때 대량으로 깨짐함수형 매크로라 가변 인자 제약만드는 비용

STM32 HAL 을 직접 가짜로? — 가능하지만 얇은 포트가 낫습니다

/* HAL 함수를 직접 FFF 로 — 테스트 빌드에 HAL 헤더가 필요합니다
   (-DSTM32F446xx, CMSIS · HAL Inc 경로) */
FAKE_VALUE_FUNC(HAL_StatusTypeDef, HAL_I2C_Mem_Read,
                I2C_HandleTypeDef *, uint16_t, uint16_t, uint16_t,
                uint8_t *, uint16_t, uint32_t);   /* 인자 7개 */
/* 주의:
   · HAL 주소는 8비트 형식(0x48 << 1) — 포트에선 7비트로 통일
   · HAL 헤더가 core_cm4.h 의 ARM 인라인 어셈블리를 끌고 옴
     → 호스트 컴파일러 버전이 바뀔 때마다 깨질 수 있음
   · 테스트가 HAL 의 호출 모양(인자 7개)에 묶여
     LL · 레지스터 드라이버로 바꾸면 테스트도 다시 써야 함 */
규칙 — 내가 소유한 얇은 인터페이스(포트)를 목으로 두고, 그 포트의 STM32 구현(bsp_i2c.c)은 HIL(t12)에서만 검증합니다. "남의 API 는 목으로 두지 말라(Don't mock what you don't own)"는 서버 쪽 격언이 그대로 통합니다.
정리 — C 의 목 · 페이크는 링크 타임 치환입니다. FFF 는 매크로 한 줄로 호출 기록과 반환값 · 오류 주입을, CMock 은 헤더에서 목을 생성해 "이 순서로 불려야 한다"를 자동 검증합니다. Ceedling 이 둘을 묶어 ceedling test:all 한 줄로 돌립니다. 순수 로직은 진짜로 두고, 상호작용 자체가 요구사항일 때만 목을 씁니다.
QA 09

레지스터 페이크로 드라이버 TDD — 주변장치를 RAM 의 구조체로 바꿔치기

t08 은 포트 위의 코드를 시험했습니다. 그럼 포트 아래, 레지스터를 직접 쓰는 드라이버는 보드 없이 시험할 수 없을까요? 할 수 있습니다. CMSIS 의 USART_TypeDef 는 그냥 C 구조체이고, USART2 는 그 구조체를 가리키는 주소 상수일 뿐입니다. 드라이버가 주소를 상수로 박지 않고 포인터로 받게 하면, 테스트는 RAM 에 만든 같은 모양의 구조체를 넘겨 드라이버가 어느 레지스터에 무엇을 썼는지 그대로 읽을 수 있습니다. 이걸로 TDD 한 바퀴를 돌려 봅니다.

같은 구조체, 다른 주소 — 드라이버는 포인터만 받습니다 실제 칩 · USART2 = 0x4000_4400 (APB1) SR DR BRR CR1 CR2 CR3 GTPR +0x00 +0x04 +0x08 +0x0C +0x10 +0x14 +0x18 테스트 · static USART_TypeDef fake (RAM) — init 직후 값 FFFFFFFF FFFFFFFF 00000187 0000200C 00000000 00000000 FFFFFFFF 45 MHz / 115200 → 391 UE · TE · RE (8N1) 0xFF 그대로 = 안 씀 처음에 0xFF 로 채워 두면 "쓰지 말아야 할 레지스터를 건드렸나"까지 보입니다
레지스터 페이크 — 오프셋과 타입이 같으니 드라이버는 진짜인지 모릅니다. 테스트는 쓰인 값을 그냥 읽으면 됩니다.

주소를 박지 말고 받는 드라이버

/* uart_drv.h — CMSIS 기기 헤더의 구조체 정의만 씀
   (호스트 빌드도 -DSTM32F446xx 로 stm32f4xx.h 를 include —
    USART2 같은 주소 매크로를 쓰지 않으면 호스트에서도 문제 없음) */
#include "stm32f4xx.h"
#include "reg_access.h"

typedef struct { USART_TypeDef *regs; } uart_t;

void uart_init(uart_t *u, USART_TypeDef *regs, uint32_t pclk_hz, uint32_t baud);
int  uart_putc(uart_t *u, uint8_t c, uint32_t spin_limit);  /* 한도 인자는
                                    아래 TDD 한 바퀴에서 생김 */

/* uart_drv.c */
void uart_init(uart_t *u, USART_TypeDef *regs, uint32_t pclk_hz, uint32_t baud)
{
    u->regs = regs;
    REG_WRITE(regs->CR1, 0u);                        /* UE=0 에서 설정 */
    /* OVER8=0: BRR = USARTDIV × 16 = pclk / baud (반올림)
       45 000 000 / 115 200 = 390.6 → 391 = 0x187 (가수 24 · 소수 7/16) */
    REG_WRITE(regs->BRR, (pclk_hz + baud / 2u) / baud);
    REG_WRITE(regs->CR2, 0u);                        /* 정지 비트 1 */
    REG_WRITE(regs->CR3, 0u);                        /* 흐름 제어 없음 */
    REG_WRITE(regs->CR1, USART_CR1_TE | USART_CR1_RE);
    REG_WRITE(regs->CR1, regs->CR1 | USART_CR1_UE);  /* ★ 마지막에 켬 */
}

/* main.c (양산) — 여기서만 진짜 주소가 등장 */
uart_t g_console;
/* main() 안에서 */
uart_init(&g_console, USART2, HAL_RCC_GetPCLK1Freq(), 115200u);
포인터를 받으면 느려지지 않나? — uart_t 를 전역 하나로 두면 컴파일러가 대부분 상수로 접어 줍니다. 채널이 여럿(USART1 · 2 · 6)인 드라이버라면 원래 포인터로 받아야 하므로 비용이 0 입니다.

최종 상태 검사 — 구조체를 그냥 읽기

/* test_uart_drv.c */
#include <string.h>
#include "unity.h"
#include "uart_drv.h"

static USART_TypeDef fake;          /* ★ RAM 의 가짜 USART */
static uart_t u;

void setUp(void)    { memset(&fake, 0xFF, sizeof fake); }   /* 쓰레기로 */
void tearDown(void) { }

void test_brr_for_115200_at_45MHz(void)
{
    uart_init(&u, &fake, 45000000u, 115200u);
    TEST_ASSERT_EQUAL_HEX32(0x187, fake.BRR);
}

void test_cr1_enables_ue_te_re_only(void)
{
    uart_init(&u, &fake, 45000000u, 115200u);
    TEST_ASSERT_EQUAL_HEX32(USART_CR1_UE | USART_CR1_TE | USART_CR1_RE,
                            fake.CR1);                /* 0x200C — M · PCE 0 */
    TEST_ASSERT_EQUAL_HEX32(0, fake.CR2);
    TEST_ASSERT_EQUAL_HEX32(0xFFFFFFFFu, fake.GTPR);  /* 건드리지 않았음 */
}

void test_putc_writes_dr_when_txe_set(void)
{
    uart_init(&u, &fake, 45000000u, 115200u);
    fake.SR = USART_SR_TXE;                /* "송신 버퍼 비었음"을 흉내 */
    TEST_ASSERT_EQUAL_INT(0, uart_putc(&u, 'A', 10));
    TEST_ASSERT_EQUAL_HEX32('A', fake.DR);
}

void test_putc_gives_up_when_txe_never_sets(void)
{
    uart_init(&u, &fake, 45000000u, 115200u);
    fake.SR = 0;                           /* 하드웨어가 멈춘 상황 */
    TEST_ASSERT_EQUAL_INT(-1, uart_putc(&u, 'A', 10));
}
보드에선 만들기 어려운 상황("TXE 가 영영 안 선다")을 한 줄로 만듭니다. 실제 장비에서 이런 일은 클럭이 꺼졌거나 다른 코드가 UE 를 내렸을 때 생기고, 타임아웃 없는 드라이버는 그 자리에서 영원히 멈춥니다.

쓰기 순서까지 검사 — REG_WRITE 훅으로 기록 남기기 🟡 중급

reg_access.h — 양산에선 그냥 대입

/* 구조체의 "최종 값"으로는 순서를 알 수 없습니다.
   (UE 를 먼저 켜고 BRR 을 바꿔도 최종 값은 같음 — 그런데 칩에선 틀린 동작) */
#pragma once
#include <stdint.h>

#ifdef UNIT_TEST
void reg_write_hook(volatile uint32_t *reg, uint32_t val);
#define REG_WRITE(reg, val)  reg_write_hook(&(reg), (uint32_t)(val))
#else
#define REG_WRITE(reg, val)  ((reg) = (uint32_t)(val))     /* 비용 0 */
#endif
/* test/fakes/reg_log.c — 쓰기를 기록하고 실제로도 씀 */
typedef struct { uint32_t off, val; } reg_wr_t;
reg_wr_t g_log[32];
unsigned g_nlog;
volatile void *g_base;                 /* 지금 시험 중인 가짜의 시작 */

void reg_write_hook(volatile uint32_t *reg, uint32_t val)
{
    if (g_nlog < 32)
        g_log[g_nlog++] = (reg_wr_t){
            (uint32_t)((uintptr_t)reg - (uintptr_t)g_base), val };
    *reg = val;
}

"UE 는 마지막에" 를 테스트로 고정

#include "reg_log.h"   /* reg_wr_t · g_log · g_nlog · g_base 의 extern 선언 */

enum { OFF_SR = 0x00, OFF_BRR = 0x08, OFF_CR1 = 0x0C,
       OFF_CR2 = 0x10, OFF_CR3 = 0x14 };

void test_ue_is_set_last_and_only_once(void)
{
    g_base = &fake; g_nlog = 0;
    uart_init(&u, &fake, 45000000u, 115200u);

    /* 기록: CR1←0, BRR←0x187, CR2←0, CR3←0, CR1←0x000C, CR1←0x200C */
    TEST_ASSERT_EQUAL_UINT(6, g_nlog);
    TEST_ASSERT_EQUAL_HEX32(OFF_CR1, g_log[0].off);
    TEST_ASSERT_EQUAL_HEX32(0, g_log[0].val & USART_CR1_UE);  /* 먼저 끔 */

    for (unsigned i = 0; i + 1 < g_nlog; i++)                 /* 마지막 전엔 */
        if (g_log[i].off == OFF_CR1)                          /* UE 가 서면 안 됨 */
            TEST_ASSERT_BITS_LOW(USART_CR1_UE, g_log[i].val);

    TEST_ASSERT_EQUAL_HEX32(OFF_CR1, g_log[g_nlog - 1].off);
    TEST_ASSERT_BITS_HIGH(USART_CR1_UE, g_log[g_nlog - 1].val);
}
순서 테스트는 꼭 필요한 곳에만 — 레퍼런스 매뉴얼이 순서를 요구하는 곳(UE 전에 BRR, 클럭 켠 뒤 설정, 플래그 지우는 순서)에만 겁니다. 모든 쓰기 순서를 고정하면 무해한 리팩터링도 테스트를 깨뜨립니다(t08 의 과한 목과 같은 문제). 읽기에 반응하는 하드웨어(DR 을 읽으면 RXNE 가 지워짐)가 필요하면 같은 방식으로 REG_READ 훅을 둡니다.

TDD 한 바퀴 — 실패하는 테스트부터 🔴 고급

빨강 → 초록 → 리팩터 — 한 바퀴를 몇 분 안에 끝냅니다 빨강 실패하는 테스트 먼저 쓰기 초록 통과할 만큼만 최소 코드 리팩터 초록 유지하며 구조 정리 한 바퀴 < 1분 호스트 테스트라 가능 ① TXE 안 서면 -1 을 기대 → 무한 루프 · 타임아웃 실패 ② spin_limit 인자 추가 ③ wait_flag() 로 TXE · TC 대기 통합
TDD 순환 — 빨강을 먼저 보는 이유는 그 테스트가 정말 무언가를 검사한다는 증거이기 때문입니다.

① 빨강 — 무한 루프도 "실패"로 만들기

# 드라이버 테스트는 하드웨어 대기 루프 때문에 멈출 수 있음 → 시간 제한
add_test(NAME uart_drv COMMAND test_uart_drv)
set_tests_properties(uart_drv PROPERTIES TIMEOUT 5)
target_compile_definitions(test_uart_drv PRIVATE UNIT_TEST STM32F446xx)
$ ctest --test-dir build-host -R uart_drv
1/1 Test #1: uart_drv .........***Timeout   5.01 sec
# 기존 코드: while (!(SR & TXE)) { }  ← 테스트가 이 결함을 증명

② 초록 → ③ 리팩터

/* ② 최소 수정 — putc 에 한도만 추가 (테스트 통과) */
/* ③ 정리 — 같은 패턴(TXE · TC · RXNE 대기)을 함수 하나로 */
static int wait_flag(USART_TypeDef *r, uint32_t flag, uint32_t spin_limit)
{
    while ((r->SR & flag) == 0u) {
        if (spin_limit-- == 0u) return -1;
    }
    return 0;
}

int uart_putc(uart_t *u, uint8_t c, uint32_t spin_limit)
{
    if (wait_flag(u->regs, USART_SR_TXE, spin_limit) != 0) return -1;
    REG_WRITE(u->regs->DR, c);
    return 0;
}

int uart_flush(uart_t *u, uint32_t spin_limit)   /* 다음 빨강의 대상 */
{
    return wait_flag(u->regs, USART_SR_TC, spin_limit);
}
스핀 횟수 대신 시간(ms) 한도가 더 정확하지만, 그러려면 시계도 주입해야 합니다(t07 의 디바운스처럼). 테스트하기 쉬운 형태를 찾다 보면 설계가 저절로 "의존을 밖에서 받는" 모양이 됩니다 — TDD 가 설계 도구라 불리는 이유입니다.
정리 — 드라이버가 USART_TypeDef * 를 받게 하면 테스트는 RAM 의 같은 구조체를 넘겨 쓰인 값을 그대로 읽습니다. 0xFF 로 채워 두면 "안 건드려야 할 레지스터"까지 보이고, REG_WRITE 훅으로 매뉴얼이 요구하는 쓰기 순서를 고정합니다. 무한 대기는 CTest TIMEOUT 으로 실패시키고, 빨강 → 초록 → 리팩터를 1분 안에 돌립니다.
QA 10

호스트 도구 — 새니타이저 · Valgrind · libFuzzer · 커버리지

칩에는 ASan 도 Valgrind 도 없습니다. 하지만 t07 처럼 로직을 호스트로 꺼내 두면 PC 의 모든 검사 도구가 펌웨어 코드에 그대로 적용됩니다. 새니타이저 기본은 C 실전 탭 c24 에 있으니, 여기서는 펌웨어에서 특히 값진 쓰임 — 비정렬 접근 · 정수 잘림 검출, 통신 프로토콜 파서 퍼징(외부에서 들어오는 바이트는 언제나 공격 표면), 그리고 "테스트가 어디를 안 지나갔나"를 보는 커버리지를 다룹니다.

무엇이 무엇을 잡나 — 진한 칸이 그 도구의 전문 버퍼 넘침 정수 UB 비정렬 미초기화 안 지난 분기 ASan 잡음 – – – – UBSan bounds 잡음 잡음 – – Valgrind memcheck 힙만 – – 잡음 – libFuzzer + 새니타이저 입력 발견 입력 발견 입력 발견 MSan 빌드 시 찾아 들어감 gcov · gcovr – – – – 보여 줌 새니타이저는 "지나간 경로"의 버그만 잡습니다 — 경로를 넓히는 것이 퍼저의 일 ASan 과 Valgrind 는 같이 못 씁니다 — 빌드를 나눠 CI 에서 따로 돌립니다(t11)
호스트 도구 매트릭스 — 잡는 도구(새니타이저)와 경로를 찾는 도구(퍼저 · 커버리지)를 짝지어 씁니다.

UBSan — 칩에선 HardFault, PC 에선 조용한 버그를 PC 에서

/* 수신 버퍼 rx[0] 은 명령, rx[1..4] 는 리틀 엔디언 32비트 값 */
uint32_t v = *(const uint32_t *)&rx[1];   /* ✗ 비정렬 포인터 역참조 */
/* x86: 그냥 동작.  Cortex-M4: LDR 은 동작하지만 컴파일러가 LDRD/LDM 으로
   묶으면 UNALIGNED 폴트 (t13). C 표준으로는 미정의 동작. */

uint32_t v2;
memcpy(&v2, &rx[1], sizeof v2);           /* ✓ 정렬 무관 · -O2 면 LDR 하나 */
clang -g -O1 -fsanitize=address,undefined -fsanitize=alignment \
      -fsanitize=implicit-conversion -fno-sanitize-recover=all \
      -funsigned-char test_cmd.c app/cmd.c unity.c -o test_cmd
./test_cmd
# cmd.c:41:18: runtime error: load of misaligned address 0x6020000000f1
#   for type 'const uint32_t', which requires 4 byte alignment
# cmd.c:57:22: runtime error: implicit conversion from type 'int' of
#   value 70000 (32-bit, signed) to type 'uint16_t' changed the value to 4464
-fsanitize=implicit-conversion 은 Clang 전용이고, 펌웨어에 흔한 "uint16_t 로 넣었더니 잘림"을 잡습니다 (70000 mod 65536 = 4464). -fno-sanitize-recover=all 이 있어야 첫 오류에서 종료 코드가 0 이 아니게 되어 CI 가 실패합니다.

Valgrind — ASan 이 못 보는 "초기화 안 된 값"

int cfg_load(cfg_t *out)
{
    cfg_t tmp;                               /* 일부만 채움 */
    if (flash_read(CFG_ADDR, &tmp, 8) != 0) return -1;   /* 앞 8바이트만 */
    if (tmp.version != CFG_VER) return -2;
    if (tmp.flags & CFG_F_DHCP) ...          /* ✗ flags 는 9번째 바이트 — 쓰레기 */
    *out = tmp;
    return 0;
}
# ASan 빌드가 아닌 일반 -g -O0 빌드로
valgrind --error-exitcode=1 --leak-check=full --track-origins=yes ./test_cfg
# ==2211== Conditional jump or move depends on uninitialised value(s)
# ==2211==    at 0x109A3E: cfg_load (cfg.c:31)
# ==2211==  Uninitialised value was created by a stack allocation
# ==2211==    at 0x109A00: cfg_load (cfg.c:24)

# 힙 최대 사용량 — 고정 풀 크기 정할 때(t06)
valgrind --tool=massif ./sim_traffic && ms_print massif.out.*
칩에선 이런 버그가 "전원을 껐다 켜면 가끔 DHCP 가 켜진다"로 나타납니다 — RAM 의 이전 값에 따라 달라지니까요. Clang 의 MSan(-fsanitize=memory, 리눅스 전용)도 같은 것을 더 빠르게 잡지만 모든 코드를 MSan 으로 빌드해야 합니다.

libFuzzer — 프로토콜 파서에 무작위가 아닌 "영리한" 쓰레기를 🟡 중급

대상 — 길이 필드가 있는 프레임 파서

/* 프레임: 0x7E | LEN | PAYLOAD[LEN] | CRC_H | CRC_L
   CRC-16/CCITT-FALSE (초깃값 0xFFFF, 다항식 0x1021) — LEN 과 PAYLOAD 에 대해
   예: 7E 03 01 02 03 7E 2D   ← CRC 가 0x7E2D. 0x7E 가 본문에도 나올 수 있어
       길이로 끝을 아는 방식입니다 */
#define PROTO_SOF 0x7Eu
#define PROTO_MAX 32u
typedef enum { ST_SOF, ST_LEN, ST_DATA, ST_CRC_H, ST_CRC_L } pstate_t;
typedef enum { P_NONE, P_FRAME, P_ERR } presult_t;
typedef struct {
    pstate_t st;
    uint8_t  len, idx;
    uint8_t  buf[PROTO_MAX];
    uint16_t crc_rx;
} parser_t;

presult_t parser_feed(parser_t *p, uint8_t b)
{
    switch (p->st) {
    case ST_SOF:
        if (b == PROTO_SOF) p->st = ST_LEN;
        return P_NONE;
    case ST_LEN:
        /* ✗ 처음 버전엔 이 검사가 없었습니다 — 퍼저가 3초 만에 찾음 */
        if (b == 0u || b > PROTO_MAX) { p->st = ST_SOF; return P_ERR; }
        p->len = b; p->idx = 0; p->st = ST_DATA;
        return P_NONE;
    case ST_DATA:
        p->buf[p->idx++] = b;
        if (p->idx == p->len) p->st = ST_CRC_H;
        return P_NONE;
    case ST_CRC_H:
        p->crc_rx = (uint16_t)(b << 8); p->st = ST_CRC_L;
        return P_NONE;
    case ST_CRC_L: {
        p->crc_rx |= b; p->st = ST_SOF;
        uint16_t c = crc16_ccitt(&p->len, 1u, 0xFFFFu);
        c = crc16_ccitt(p->buf, p->len, c);
        return (c == p->crc_rx) ? P_FRAME : P_ERR;
    }
    }
    p->st = ST_SOF;
    return P_ERR;
}

하니스 — 함수 하나가 전부

/* fuzz/fuzz_proto.c */
#include <stddef.h>
#include <stdint.h>
#include "proto.h"

int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
{
    parser_t p = { 0 };
    for (size_t i = 0; i < size; i++) {
        if (parser_feed(&p, data[i]) == P_FRAME) {
            /* 불변식도 검사 — 크래시가 아니어도 틀린 상태를 잡음 */
            if (p.len == 0u || p.len > PROTO_MAX) __builtin_trap();
        }
    }
    return 0;          /* 0 외의 값은 쓰지 않음 */
}
# 리눅스 · WSL 의 clang
clang -g -O1 -fsanitize=fuzzer,address,undefined -funsigned-char \
      fuzz/fuzz_proto.c app/proto.c app/crc16.c -o fuzz_proto

mkdir -p corpus
printf '\x7e\x03\x01\x02\x03\x7e\x2d' > corpus/valid_3byte   # 씨앗
printf 'sof="\\x7E"\n' > proto.dict                           # 사전

./fuzz_proto corpus -dict=proto.dict -max_len=64 -max_total_time=60
#2      INITED cov: 21 ft: 22 corp: 1/7b exec/s: 0
#1184   NEW    cov: 27 ft: 35 corp: 6/58b  L: 41/41
# proto.c:31:25: runtime error: index 32 out of bounds for type 'uint8_t [32]'
# ==9912== ERROR: ... artifact_prefix='./'; Test unit written to
#   ./crash-5f3c9a...

./fuzz_proto crash-5f3c9a...                        # 재현
./fuzz_proto -minimize_crash=1 -runs=10000 crash-5f3c9a...   # 최소화
ASan 만으로는 늦게 잡힙니다 — buf[32], buf[33] 은 구조체 안의 crc_rx 자리라 ASan 이 보기엔 "같은 객체 안"이라 정상입니다. UBSan 의 bounds 검사가 배열 크기를 알고 있어 첫 넘침에서 멈춥니다. 그래서 퍼징은 address,undefined 를 같이 켭니다.

크래시를 회귀 테스트로 · 커버리지 — gcov · gcovr · MC/DC 🔴 고급

퍼저가 찾은 입력은 Unity 테스트로 영구 보관

/* test/test_proto.c */
static parser_t p;
void setUp(void)    { p = (parser_t){ 0 }; }
void tearDown(void) { }

static presult_t feed_all(const uint8_t *d, size_t n)
{
    presult_t last = P_NONE;
    for (size_t i = 0; i < n; i++) {
        presult_t r = parser_feed(&p, d[i]);
        if (r != P_NONE) last = r;
    }
    return last;
}

void test_valid_frame(void)
{
    const uint8_t f[] = { 0x7E, 0x03, 0x01, 0x02, 0x03, 0x7E, 0x2D };
    TEST_ASSERT_EQUAL(P_FRAME, feed_all(f, sizeof f));
    TEST_ASSERT_EQUAL_UINT8(3, p.len);
}

void test_bad_crc_rejected(void)
{
    const uint8_t f[] = { 0x7E, 0x03, 0x01, 0x02, 0x03, 0x7E, 0x2E };
    TEST_ASSERT_EQUAL(P_ERR, feed_all(f, sizeof f));
}

/* crash-5f3c9a 최소화 결과: 7E 40 41 41 ... (길이 0x40 = 64 > 32) */
void test_regress_fuzz_len_overflow(void)
{
    uint8_t f[2 + 40] = { 0x7E, 0x40 };
    memset(&f[2], 0x41, 40);
    TEST_ASSERT_EQUAL(P_ERR, feed_all(f, sizeof f));   /* 넘치지 않고 거절 */
}

커버리지 — 테스트가 지나가지 않은 분기

# 커버리지 빌드는 따로 (최적화 끔)
cmake -B build-cov -DCMAKE_BUILD_TYPE=Debug \
      -DCMAKE_C_FLAGS="--coverage -O0"
cmake --build build-cov -j && ctest --test-dir build-cov

# gcovr — HTML + Cobertura XML(CI 업로드용) + 기준 미달이면 실패
pip install gcovr
mkdir -p cov
gcovr -r . --filter app/ \
      --html-details cov/index.html \
      --cobertura cov/coverage.xml \
      --fail-under-line 80 --print-summary
# lines: 91.4% (414 out of 453)
# branches: 76.2% (138 out of 181)

# lcov 를 쓰는 팀이라면
lcov --capture --directory build-cov --output-file cov.info
genhtml cov.info --output-directory cov-html
지표뜻쓰는 곳
라인그 줄이 한 번이라도 실행됨기본 · 하한선 80%
분기if 의 참 · 거짓 양쪽이 모두 실행됨오류 처리 경로 확인
MC/DC조건 하나하나가 결과를 바꾸는 경우를 모두항공 · 자동차 안전 등급
GCC 14 부터 -fcondition-coverage 로 MC/DC 를 잴 수 있습니다(gcov --conditions). 퍼저의 커버리지(cov:)가 오르지 않고 멈추면 씨앗 · 사전이 부족하다는 신호 — CRC 처럼 "맞혀야 통과"하는 검사는 퍼징 빌드에서만 끄는(#ifdef FUZZING) 요령도 씁니다.
정리 — 호스트로 꺼낸 펌웨어 로직엔 PC 의 모든 검사가 붙습니다. UBSan 은 비정렬 · 정수 잘림 · 배열 범위를, Valgrind 는 초기화 안 된 값을, libFuzzer 는 그런 버그를 일으키는 입력을 찾아 줍니다. 찾은 입력은 Unity 회귀 테스트로 남기고, gcovr 로 "아직 안 지나간 분기"를 보며 테스트를 늘립니다.
QA 11

CI 파이프라인 — PR 마다 빌드 · 테스트 · 분석 · 크기 감시

앞 절들의 도구는 사람이 기억해서 돌리면 한 달 안에 아무도 안 돌립니다. CI 는 그것을 PR 마다 자동으로 돌리고 결과로 머지를 막습니다. 펌웨어 CI 에는 서버 CI 에 없는 항목이 둘 있습니다 — 크로스 컴파일(ARM 툴체인으로 실제 이미지 빌드)과 크기 감시(Flash 512 KB 는 늘릴 수 없습니다). 그리고 양산 이미지의 .elf · .map 보관은 t05 의 현장 덤프를 읽는 유일한 열쇠입니다. 정적 분석 규칙(MISRA · clang-tidy 검사 목록)은 대규모 C 구조 탭 k12 에 있고, 여기서는 그것을 CI 에 붙이는 실무만 다룹니다.

PR 하나가 지나가는 길 — 어느 칸에서든 실패하면 머지 금지 체크아웃 PR + 기준 커밋 호스트 테스트 ASan · UBSan 정적 분석 cppcheck · tidy ARM 빌드 GCC 14 · Release 크기 비교 +1 KB 넘으면 실패 아티팩트 .elf .map .hex 보드 없이 1~2분 — 개발자가 기다릴 수 있는 시간 빌드 해시로 이름 붙여 90일 보관 HIL(t12)은 이 뒤에 붙습니다 — 실제 보드는 느리고 귀하니 앞 단계를 통과한 것만 "빨리 실패하는 것을 앞에" — 수 초짜리 검사가 수 분짜리보다 먼저 옵니다
CI 파이프라인 — 싼 검사가 앞에, 보드가 필요한 검사는 맨 뒤. 크기 비교가 펌웨어 CI 만의 단계입니다.

툴체인 파일 — CI 와 로컬이 같은 컴파일러를 쓰게

# cmake/arm-gcc.cmake
set(CMAKE_SYSTEM_NAME      Generic)
set(CMAKE_SYSTEM_PROCESSOR arm)
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)   # 링크 시험 생략

set(CMAKE_C_COMPILER   arm-none-eabi-gcc)
set(CMAKE_ASM_COMPILER arm-none-eabi-gcc)
set(CMAKE_OBJCOPY      arm-none-eabi-objcopy)
set(CMAKE_SIZE         arm-none-eabi-size)

set(MCU "-mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard")
set(CMAKE_C_FLAGS_INIT   "${MCU} -ffunction-sections -fdata-sections")
set(CMAKE_ASM_FLAGS_INIT "${MCU}")
set(CMAKE_EXE_LINKER_FLAGS_INIT
    "${MCU} --specs=nano.specs -Wl,--gc-sections -Wl,--print-memory-usage")
# CMakeLists.txt (펌웨어 쪽) — .map · .hex · .bin 과 빌드 ID
set(BUILD_ID "00000000" CACHE STRING "git 해시 앞 8자리")
target_compile_definitions(fw.elf PRIVATE BUILD_ID=0x${BUILD_ID}u)
target_link_options(fw.elf PRIVATE
    -T${CMAKE_SOURCE_DIR}/STM32F446RETX_FLASH.ld
    -Wl,-Map=${CMAKE_BINARY_DIR}/fw.map,--cref)
add_custom_command(TARGET fw.elf POST_BUILD
    COMMAND ${CMAKE_OBJCOPY} -O ihex   fw.elf fw.hex
    COMMAND ${CMAKE_OBJCOPY} -O binary fw.elf fw.bin
    COMMAND ${CMAKE_SIZE} -B fw.elf)

tools/size_diff.sh — 기준과 비교해 표 + 실패

#!/usr/bin/env bash
# 사용: size_diff.sh <기준.elf> <새.elf> <Flash 허용 증가 바이트>
set -euo pipefail
sz() { arm-none-eabi-size -B "$1" | awk 'NR==2 {print $1, $2, $3}'; }
read -r bt bd bb < <(sz "$1")
read -r nt nd nb < <(sz "$2")
bflash=$((bt + bd)); nflash=$((nt + nd))     # Flash = text + data
bram=$((bd + bb));   nram=$((nd + nb))       # RAM   = data + bss
dflash=$((nflash - bflash)); dram=$((nram - bram))

out="${GITHUB_STEP_SUMMARY:-/dev/stdout}"    # Actions 요약 페이지에 표
{
  echo "### 펌웨어 크기 (기준 → PR)"
  echo "| 영역 | 기준 | PR | 차이 |"
  echo "|---|---:|---:|---:|"
  echo "| Flash | $bflash | $nflash | $(printf '%+d' "$dflash") |"
  echo "| RAM | $bram | $nram | $(printf '%+d' "$dram") |"
} >> "$out"

if (( dflash > $3 )); then
  # ::error:: 는 stdout 으로 — 요약 파일이 아니라 로그에 써야 주석이 됨
  echo "::error title=Flash 크기::Flash 가 ${dflash} B 늘었습니다 (허용 $3 B)"
  exit 1
fi
### 펌웨어 크기 (기준 → PR)        ← 실제 출력 예
| 영역  | 기준  | PR    | 차이  |
| Flash | 25224 | 26560 | +1336 |
| RAM   | 10984 | 10992 | +8    |
::error title=Flash 크기::Flash 가 1336 B 늘었습니다 (허용 1024 B)
허용치는 0 이 아니라 "리뷰가 필요한 크기"로 — 기능을 넣으면 커지는 게 정상입니다. 실패는 "막는다"보다 "설명하라"는 신호입니다. 의도한 증가면 PR 라벨(size-ok)로 넘기는 조건을 둬도 됩니다.

.github/workflows/firmware.yml — 완전한 워크플로 🟡 중급

name: firmware-ci

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

concurrency:                                  # 같은 PR 의 이전 실행은 취소
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  # ───────────── 1. 호스트 테스트 + 새니타이저 + 커버리지 ─────────────
  host-test:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - name: 도구 설치
        run: sudo apt-get update && sudo apt-get install -y cmake ninja-build gcovr

      - name: 단위 테스트 (ASan + UBSan)
        run: |
          cmake -B build-asan -G Ninja -DCMAKE_BUILD_TYPE=Debug \
            -DCMAKE_C_FLAGS="-fsanitize=address,undefined -fno-sanitize-recover=all -fno-omit-frame-pointer"
          cmake --build build-asan
          ctest --test-dir build-asan --output-on-failure

      - name: 커버리지
        run: |
          cmake -B build-cov -G Ninja -DCMAKE_BUILD_TYPE=Debug -DCMAKE_C_FLAGS="--coverage -O0"
          cmake --build build-cov
          ctest --test-dir build-cov
          mkdir -p cov
          gcovr -r . --filter app/ --cobertura cov/coverage.xml \
                --html-details cov/index.html --fail-under-line 80 --print-summary

      - uses: codecov/codecov-action@v5
        with:
          files: cov/coverage.xml
          token: ${{ secrets.CODECOV_TOKEN }}
      - uses: actions/upload-artifact@v4
        with:
          name: coverage-html
          path: cov/

  # ───────────── 2. 정적 분석 (규칙은 대규모 C 구조 탭 k12) ─────────────
  static-analysis:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - name: 도구 설치
        run: sudo apt-get update && sudo apt-get install -y cmake ninja-build cppcheck clang-tidy
      - name: compile_commands.json
        run: cmake -B build-host -G Ninja -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
      - name: cppcheck
        run: |
          cppcheck --project=build-host/compile_commands.json \
                   --file-filter='*/app/*' \
                   --enable=warning,style,performance,portability \
                   --inline-suppr --suppress=missingIncludeSystem \
                   --error-exitcode=1
      - name: clang-tidy (.clang-tidy 설정 사용)
        run: git ls-files 'app/*.c' | xargs clang-tidy -p build-host --warnings-as-errors='*'

  # ───────────── 3. ARM 빌드 + 크기 비교 + 아티팩트 ─────────────
  firmware:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0                      # 기준 커밋을 꺼내려면 전체 이력
      - uses: carlosperate/arm-none-eabi-gcc-action@v1
        with:
          release: '14.2.Rel1'                # 로컬과 같은 버전으로 고정
      - name: 도구 설치
        run: sudo apt-get update && sudo apt-get install -y cmake ninja-build

      - name: 빌드 (Release)
        run: |
          cmake -B build-fw -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/arm-gcc.cmake \
                -DCMAKE_BUILD_TYPE=Release -DBUILD_ID=$(git rev-parse --short=8 HEAD)
          cmake --build build-fw

      - name: 기준 브랜치와 크기 비교
        if: github.event_name == 'pull_request'
        run: |
          git worktree add ../base ${{ github.event.pull_request.base.sha }}
          cmake -S ../base -B build-base -G Ninja -DCMAKE_BUILD_TYPE=Release \
                -DCMAKE_TOOLCHAIN_FILE=$(realpath ../base/cmake/arm-gcc.cmake)
          cmake --build build-base
          ./tools/size_diff.sh build-base/fw.elf build-fw/fw.elf 1024

      - name: 아티팩트 보관 (.elf 없이는 현장 덤프를 못 읽음)
        uses: actions/upload-artifact@v4
        with:
          name: fw-${{ github.sha }}
          path: |
            build-fw/fw.elf
            build-fw/fw.map
            build-fw/fw.hex
            build-fw/fw.bin
          retention-days: 90

실무에선 이렇게 — 병렬 · 보관 · 재현성 🔴 고급

작업 배치 — 서로 기다릴 필요 없는 것은 동시에 (시간은 예시) 0분 2 4 6 8 10분 host-test static-analysis firmware hil (self-hosted) main · 야간만 — 약 7분 needs: [firmware, host-test] PR 결과 약 3분 PR 은 3분, 보드 시험은 main 에 들어간 뒤 — 개발 속도와 안전의 타협점
간트 — 가장 긴 작업이 PR 대기 시간을 정합니다. 보드가 필요한 HIL 은 PR 경로에서 빼 둡니다.

자주 겪는 문제와 대응

문제대응
로컬은 되는데 CI 빌드만 다름툴체인 버전 고정(14.2.Rel1) · 같은 버전을 개발 PC 에도 · 또는 Docker 이미지 하나로 통일
같은 커밋인데 .bin 해시가 매번 다름__DATE__ · __TIME__ 제거, 경로는 -ffile-prefix-map=$PWD=. — 재현 가능한 빌드
양산 펌웨어의 .elf 를 잃어버림태그 빌드는 아티팩트가 아니라 릴리스 자산으로(만료 없음) · 빌드 ID 로 검색
정적 분석 경고 수백 개로 시작기존 경고는 기준선(suppression 파일)에 넣고 새로 생긴 것만 실패 처리
CubeMX 생성 코드가 경고 폭탄분석 범위를 app/ · drivers/ 로 제한 · 생성 코드는 별도 라이브러리

릴리스 — 태그를 밀면 영구 보관

# firmware.yml 의 firmware 작업 끝에 덧붙임
      - name: 릴리스 자산 (v* 태그일 때만)
        if: startsWith(github.ref, 'refs/tags/v')
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh release create "${GITHUB_REF_NAME}" \
             build-fw/fw.elf build-fw/fw.map build-fw/fw.hex build-fw/fw.bin \
             --title "${GITHUB_REF_NAME}" --generate-notes
# 이 단계만은 쓰기 권한이 필요 — 작업에
#   permissions: { contents: write }
# 그리고 on.push 에 tags: ['v*'] 를 추가 — branches 만 적혀 있으면
#   태그 push 로는 워크플로가 아예 실행되지 않습니다
서명 키는 CI 비밀 값에 그대로 두지 마세요 — 펌웨어 서명 · 보안 부트 이미지 생성은 별도 보호 환경(environment 승인, HSM)에서 합니다. 서명과 OTA 배포 파이프라인은 양산 · 펌웨어 운영 탭에서 다룹니다.
정리 — 호스트 테스트(새니타이저 · 커버리지) · 정적 분석 · ARM 빌드를 병렬로 돌리고, 기준 커밋을 같이 빌드해 Flash · RAM 차이를 요약 표로 남기며 허용치를 넘으면 실패시킵니다. 툴체인 버전을 고정하고, .elf · .map · .hex 를 빌드 해시 이름으로 보관(태그는 릴리스 자산으로 영구)해야 t05 의 현장 덤프를 줄 번호로 바꿀 수 있습니다.
QA 12

HIL 테스트 — 실제 보드를 CI 의 손발로

호스트 테스트는 로직을, HIL(Hardware-In-the-Loop)은 진짜 칩 위의 진짜 펌웨어를 시험합니다 — 클럭 설정, 인터럽트 우선순위, 워치독, 부팅 시간, 통신 타이밍처럼 PC 가 흉내 낼 수 없는 것들입니다. 서버로 치면 스테이징 환경의 E2E 테스트이고, 똑같이 느리고 비싸고 가끔 이유 없이 실패(플래키)합니다. 지그(시험 장치) 구성, pytest + pyserial 로 명령 · 응답을 검사하는 법, 전원 사이클 자동화, 그리고 플래키를 다루는 요령까지 갑니다.

HIL 한 바퀴 — 줄을 넘는 화살표가 PC 와 하드웨어의 경계 CI 러너 self-hosted 지그 허브 · 릴레이 DUT Nucleo-F446RE openocd 굽기 Flash 기록 · 검증 uhubctl cycle USB 전원 끊고 켬 부팅 · BOOT 배너 명령 · 응답 검사 PONG · OK · BOOT 결과는 JUnit XML 로 — CI 화면에서 실패한 시험 이름이 바로 보입니다
HIL 스윔레인 — 전원까지 소프트웨어로 조종해야 사람 없이 밤새 돌릴 수 있습니다.

지그(테스트 벤치) 구성 — 최소에서 본격까지

부품역할값싼 시작
러너 PCGitHub Actions self-hosted 러너라즈베리 파이 5 · 남는 PC
프로브굽기 · 디버그 · 가상 COMNucleo 내장 ST-LINK
전원 제어전원 재투입(콜드 부팅 시험)포트별 전원 스위칭 USB 허브 + uhubctl
입출력 확인LED · 릴레이 · 버튼 흉내DUT 자신이 핀 상태 보고 · 두 번째 Nucleo
측정전류 · 파형INA219 전류 센서 · USB 로직 분석기(sigrok)
본격 장비여러 보드 · 아날로그 주입프로그래머블 전원(SCPI) · 릴레이 보드
"DUT 가 자기 상태를 보고"하는 방식은 싸지만 한계가 있습니다 — 펌웨어가 거짓말을 하면(핀 설정이 틀렸는데 레지스터 값만 읽어 보고) 못 잡습니다. 중요한 출력은 외부에서 측정(두 번째 보드의 GPIO 입력, 로직 분석기)하는 쪽으로 점점 옮깁니다.

펌웨어 쪽 — 시험용 명령 셸

/* 한 줄 명령 → 한 줄 응답. 사람도 터미널로 쓸 수 있게 텍스트로 */
static void shell_line(const char *s)
{
    if      (!strcmp(s, "PING"))  puts_line("PONG");
    else if (!strcmp(s, "VER?"))  printf_line("FW %s %08lx", FW_VERSION,
                                              (unsigned long)BUILD_ID);
    else if (!strncmp(s, "LED ", 4)) { led_set(s[4] == '1'); puts_line("OK"); }
    else if (!strcmp(s, "GPIO? PA5"))
        printf_line("PA5 %d", (GPIOA->IDR & GPIO_IDR_ID5) ? 1 : 0);
    else if (!strcmp(s, "STACK?"))
        printf_line("MSP %lu/%lu", (unsigned long)stack_high_water(),
                    (unsigned long)STACK_SIZE);           /* t06 */
    else if (!strcmp(s, "SENSOR?"))
        printf_line("T=%ld", (long)sensor_read_mc());     /* 밀리 °C */
#ifdef HIL_TEST
    else if (!strcmp(s, "HANG"))  for (;;) { }            /* 워치독 시험용 */
#endif
    else puts_line("ERR?");
}

/* 부팅 첫 줄 — 리셋 원인까지 (t13) */
printf_line("BOOT fw=%s rst=%s", FW_VERSION, reset_cause_str());
HANG 같은 위험한 명령은 HIL 전용 빌드에만 넣습니다. 양산 이미지에 남으면 그대로 공격 표면이 됩니다. HIL 이 양산과 다른 빌드를 시험한다는 사실도 기억하세요 — 차이는 이 셸 몇 줄로 최소화합니다.

pytest + pyserial — 보드를 픽스처로 🟡 중급

hil/conftest.py — 굽기 · 전원 사이클 · 포트 찾기

# hil/conftest.py — 보드 하나를 pytest 픽스처로
import os
import subprocess
import time

import pytest
import serial
from serial.tools import list_ports

FW_ELF = os.environ.get("FW_ELF", "build-fw/fw.elf")
DUT_SERIAL = os.environ["DUT_SERIAL"]          # ST-LINK 일련번호 (보드 식별)
HUB_LOC, HUB_PORT = os.environ.get("HUB", "1-1:3").split(":")


def find_port(timeout=10.0):
    """ST-LINK 가상 COM 을 일련번호로 찾음 — 전원 사이클 뒤 재열거를 기다림"""
    end = time.monotonic() + timeout
    while time.monotonic() < end:
        for p in list_ports.comports():
            if p.vid == 0x0483 and p.serial_number == DUT_SERIAL:
                return p.device
        time.sleep(0.2)
    raise RuntimeError(f"DUT {DUT_SERIAL} 의 COM 포트가 안 보임")


class Dut:
    def __init__(self):
        self.ser = None

    def open(self):
        self.ser = serial.Serial(find_port(), 115200, timeout=0.5)
        self.ser.reset_input_buffer()             # 이전 실행의 찌꺼기 버림

    def close(self):
        if self.ser:
            self.ser.close()
            self.ser = None

    def wait_for(self, prefix, timeout=3.0):
        """prefix 로 시작하는 줄이 올 때까지 — sleep 으로 때우지 않음"""
        end = time.monotonic() + timeout
        while time.monotonic() < end:
            line = self.ser.readline().decode(errors="replace").strip()
            if line.startswith(prefix):
                return line
        raise TimeoutError(f"'{prefix}' 를 {timeout}s 안에 못 받음")

    def cmd(self, text, expect="", timeout=1.0):
        self.ser.reset_input_buffer()
        self.ser.write((text + "\n").encode())
        return self.wait_for(expect, timeout)

    def power_cycle(self):
        self.close()                              # 포트가 사라지기 전에 닫음
        subprocess.run(["uhubctl", "-l", HUB_LOC, "-p", HUB_PORT,
                        "-a", "cycle", "-d", "2"], check=True)
        self.open()                               # find_port 가 재열거를 기다림


@pytest.fixture(scope="session")
def flashed():
    """세션마다 한 번 — 굽고 검증하고 리셋"""
    subprocess.run(["openocd", "-f", "board/st_nucleo_f4.cfg",
                    "-c", f"adapter serial {DUT_SERIAL}",
                    "-c", f"program {FW_ELF} verify reset exit"],
                   check=True, timeout=60)
    return FW_ELF


@pytest.fixture
def dut(flashed):
    d = Dut()
    d.open()
    d.power_cycle()                                # 테스트마다 같은 출발점
    d.wait_for("BOOT", timeout=3.0)
    yield d
    d.close()

hil/test_basic.py — 명령 · 응답 · 워치독 · 스택

# hil/test_basic.py
import os
import re

import pytest


def test_ping(dut):
    assert dut.cmd("PING", expect="PONG") == "PONG"


def test_version_matches_build(dut):
    line = dut.cmd("VER?", expect="FW")              # "FW 1.4.2 0a1b2c3d"
    build_id = os.environ.get("BUILD_ID")
    if build_id:
        assert line.split()[-1] == build_id          # 구운 것과 같은 빌드인가


def test_led_on_off(dut):
    assert dut.cmd("LED 1", expect="OK") == "OK"
    assert dut.cmd("GPIO? PA5", expect="PA5") == "PA5 1"
    assert dut.cmd("LED 0", expect="OK") == "OK"
    assert dut.cmd("GPIO? PA5", expect="PA5") == "PA5 0"


def test_survives_garbage_then_answers(dut):
    dut.ser.write(bytes(range(256)) * 4)              # 1 KB 쓰레기
    assert dut.cmd("PING", expect="PONG", timeout=2.0) == "PONG"


@pytest.mark.slow
def test_watchdog_recovers_from_hang(dut):
    dut.ser.write(b"HANG\n")                         # HIL_TEST 빌드에만 있는 명령
    line = dut.wait_for("BOOT", timeout=10.0)        # IWDG 최악 7.7 s + 부팅
    assert "rst=IWDG" in line


def test_stack_keeps_15_percent_margin(dut):
    for _ in range(50):                              # 부하를 먼저 주고
        dut.cmd("SENSOR?", expect="T=")
    m = re.match(r"MSP (\d+)/(\d+)", dut.cmd("STACK?", expect="MSP"))
    used, size = int(m.group(1)), int(m.group(2))
    assert used <= size * 0.85, f"스택 {used}/{size}"
pip install pytest pyserial pytest-rerunfailures
DUT_SERIAL=066DFF575251717867123456 FW_ELF=build-fw/fw.elf \
  pytest hil -v -m "not slow" --junitxml=hil-report.xml
sleep 대신 wait_for — "부팅에 1초면 되겠지"로 time.sleep(1) 을 두면 느린 날 실패하고 빠른 날 시간을 버립니다. 배너 · 응답 같은 신호를 기다리고, 기다림의 상한만 타임아웃으로 둡니다. 워치독 시험의 10초는 IWDG 타임아웃 최악값(LSI 오차로 7.7 s, t13 계산) + 부팅 여유입니다.

CI 에 붙이기 · 플래키 다루기 🔴 고급

firmware.yml 에 HIL 작업 추가

  hil:
    needs: [firmware, host-test]
    if: github.ref == 'refs/heads/main'     # PR 이 아니라 main · 야간
    runs-on: [self-hosted, linux, hil-f446] # 보드가 꽂힌 러너의 라벨
    timeout-minutes: 20
    concurrency: hil-bench-1                # 보드 하나 = 한 번에 하나
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          name: fw-${{ github.sha }}         # t11 이 올린 그 빌드
          path: fw
      - run: python3 -m pip install pytest pyserial pytest-rerunfailures
      - name: HIL
        env:
          FW_ELF: fw/fw.elf
          DUT_SERIAL: ${{ vars.DUT_SERIAL }}
        run: python3 -m pytest hil -v --junitxml=hil-report.xml --reruns 1
      - uses: actions/upload-artifact@v4
        if: always()                        # 실패해도 보고서는 남김
        with:
          name: hil-report
          path: hil-report.xml

# 야간 전체(느린 시험 포함)는 schedule 로:
# on:
#   schedule:
#     - cron: '0 18 * * *'                  # UTC 18시 = 한국 새벽 3시

플래키 — 원인별 대응

증상원인 · 대응
가끔 첫 명령이 응답 없음부팅 중 보낸 명령 — 배너를 기다린 뒤 보냄
응답이 엉뚱한 줄이전 시험의 찌꺼기 — 명령마다 reset_input_buffer()
전원 사이클 뒤 포트 없음USB 재열거 지연 — 일련번호로 다시 찾기 + 재시도
굽기가 가끔 실패이전 openocd 가 프로브를 쥐고 있음 — exit 확인 · 프로세스 정리
오후에만 실패온도 · 전원 잡음 — 실제 버그일 수 있음. 재시도로 덮지 말고 기록
--reruns 1 은 통계를 남길 때만 씁니다. 재시도로 통과한 시험은 보고서에 "rerun" 으로 남으니 주마다 세어서, 같은 시험이 반복되면 격리(@pytest.mark.quarantine 같은 사용자 마커)하고 원인을 찾습니다. 펌웨어의 플래키는 대개 진짜 레이스 컨디션입니다 — 현장에선 그게 "가끔 멈추는 장비"가 됩니다.
정리 — HIL 은 지그(러너 PC · 프로브 · 전원 스위칭 허브)와 pytest 픽스처로 "굽기 → 전원 재투입 → 배너 대기 → 명령 · 응답 검사"를 자동화합니다. sleep 대신 신호를 기다리고, 포트는 일련번호로 찾고, 보드 하나에 작업 하나만 돌게 합니다. 재시도는 기록용일 뿐 — 반복되는 플래키는 펌웨어의 진짜 레이스일 가능성이 큽니다.
QA 13

현장 버그 사례집 — 증상 → 원인 → 찾는 법 → 수정

펌웨어 버그는 종류가 생각보다 적습니다. 현장에서 반복해서 만나는 여덟 가지를 증상부터 정리했습니다. 공통점은 "코드를 읽어서는 안 보이고, 도구를 대야 보인다"는 것입니다 — 앞 절들의 도구가 각각 어느 버그를 잡는지 연결해 두면, 다음에 같은 증상을 만났을 때 첫 10분을 아낄 수 있습니다. 대표 세 건은 기계어와 계산까지 따라가 봅니다.

버그증상원인찾는 법수정
volatile 누락-O0 은 되는데 -O2 에서 멈춤ISR 이 바꾸는 변수를 컴파일러가 한 번만 읽음disassemble 에 b . (t02)volatile · 또는 _Atomic
지연 루프 소멸-O2 에서 LED 가 계속 켜진 것처럼 보임부작용 없는 빈 루프는 지워도 됨(표준)objdump 에 루프가 없음DWT · 타이머 기반 지연
ISR RMW 레이스가끔 플래그 · 카운트 하나가 사라짐x |= m 은 LDR · ORR · STR 세 명령공유 변수 목록 대조 · HIL 장시간임계 구역 · atomic_fetch_or · 변수 분리
정렬 폴트특정 패킷에서만 UsageFault바이트 버퍼를 uint32_t* · double* 로 캐스트 → LDRD · VLDRCFSR.UNALIGNED (t05) · 호스트 UBSan (t10)memcpy 로 꺼내기
스택 오버플로무관한 전역 변수가 깨짐 · 드물게 폴트깊은 호출 + 큰 지역 배열 + 중첩 ISR페인팅 · MPU 가드 · .su (t06)스택 증가 · 큰 배열은 static
클럭 미설정UART 글자 깨짐 · 타이머 주기가 몇 배씩 틀림 · 주변장치 무반응PLL 실패로 HSI 16 MHz · HSE_VALUE 불일치 · RCC enable 누락SystemCoreClock 출력 · MCO1(PA8) 스코프 · SVD RCC클럭 트리 재확인 · 실패 시 에러 처리
%f 가 빈칸printf("%.2f") 가 아무것도 안 찍음newlib-nano 는 float 포맷을 기본으로 뺌링크 옵션에 --specs=nano.specs-u _printf_float · 또는 정수로 출력
워치독 리셋 루프부팅 로그가 몇 초마다 반복초기화가 IWDG 타임아웃보다 김 · 킥 위치 잘못RCC->CSR IWDGRSTF · 부팅 카운터초기화 중 킥 · 연속 N회면 안전 모드
"계속 리셋된다" — 부팅 첫 줄에 RCC->CSR 를 찍어 두면 여기서 시작합니다 IWDGRSTF · WWDGRSTF 인가? 예 워치독 — 어디서 막혔나 킥 위치 · 초기화 시간 (사례 ③) 아니오 SFTRSTF 인가? (SystemReset) 예 폴트 핸들러가 리셋했나 .noinit 크래시 덤프 (t05) 아니오 BORRSTF 만? (PORRSTF 없이) 예 전원 전압 강하 스코프로 VDD · 모터 · 무선 송신 순간 아니오 → PIN NRST 잡음 · 디버거 PINRSTF 는 모든 리셋에서 같이 서므로 마지막에 봅니다 — 순서가 중요합니다 읽은 뒤 RMVF 로 지워야 다음 리셋의 원인이 섞이지 않습니다
리셋 원인 결정 흐름 — RCC_CSR 한 번 읽기가 "왜 재부팅됐나"의 절반을 답합니다. 나머지 절반은 .noinit 덤프입니다.

리셋 원인 — 부팅 첫 줄에 남기기

/* F4 의 RCC_CSR 리셋 플래그. 읽고 나서 RMVF 로 지움.
   PINRSTF 는 내부 리셋에서도 NRST 가 눌리므로 거의 항상 같이 섬 → 마지막에 */
static uint32_t s_csr;              /* 부팅 때 한 번 읽어 보관 */

void reset_cause_capture(void)
{
    s_csr = RCC->CSR;
    RCC->CSR |= RCC_CSR_RMVF;
}

const char *reset_cause_str(void)
{
    if (s_csr & RCC_CSR_LPWRRSTF) return "LPWR";
    if (s_csr & RCC_CSR_WWDGRSTF) return "WWDG";
    if (s_csr & RCC_CSR_IWDGRSTF) return "IWDG";
    if (s_csr & RCC_CSR_SFTRSTF)  return "SW";    /* NVIC_SystemReset */
    if (s_csr & RCC_CSR_PORRSTF)  return "POR";   /* 전원 켬 (BOR 도 같이 섬) */
    if (s_csr & RCC_CSR_BORRSTF)  return "BOR";   /* 전압 강하 */
    if (s_csr & RCC_CSR_PINRSTF)  return "PIN";   /* 리셋 버튼 · 디버거 */
    return "?";
}

%f 가 안 나올 때 — 두 가지 해결

# ① float 포맷을 다시 넣기 — Flash 가 수 KB 늘어남 (t11 크기 비교에 보임)
target_link_options(fw.elf PRIVATE --specs=nano.specs "SHELL:-u _printf_float")
#   scanf 도 필요하면  "SHELL:-u _scanf_float"
#   ★ SHELL: 이 없으면 CMake 가 중복 옵션을 지우면서 두 번째 -u 를 떼어 내
#     _scanf_float 이 입력 파일로 넘어갑니다
/* ② 정수로 출력 — float 포맷 코드가 아예 필요 없음 */
int32_t mc = 23456;                         /* 밀리 °C (t08 TMP117) */
int32_t a  = mc < 0 ? -mc : mc;
printf("T=%s%ld.%03ld\n", mc < 0 ? "-" : "",
       (long)(a / 1000), (long)(a % 1000)); /* T=23.456 */
부호 처리를 따로 하는 이유 — -500 m°C 를 mc / 1000 으로 나누면 몫이 0 이라 "-" 가 사라져 0.500 이 찍힙니다. 절댓값으로 나누고 부호를 앞에 붙여야 -0.500 이 됩니다. 이 함수 하나도 호스트 단위 테스트감입니다(t07).

사례 ① volatile 누락 · 지연 루프 — 최적화가 지운 코드 🟡 중급

✗ -O2 에서 영원히 멈춤
static uint8_t g_rx_done;            /* volatile 없음 */

void USART2_IRQHandler(void)
{
    /* ... 수신 처리 ... */
    g_rx_done = 1;
}

void wait_rx(void)
{
    while (!g_rx_done) { }           /* 컴파일러: "이 루프 안에서
                                        g_rx_done 을 바꾸는 코드가 없다" */
}

void blink(void)
{
    for (;;) {
        LED_TOGGLE();
        for (uint32_t i = 0; i < 1000000; i++) { }   /* 부작용 없음 → 삭제 */
    }
}
wait_rx:                     @ -O2 결과 (대략)
    ldr   r3, .L5              @ &g_rx_done
    ldrb  r3, [r3]             @ ★ 딱 한 번 읽음
    cbz   r3, .L3
    bx    lr
.L3:
    b     .L3                  @ 0 이었으면 영원히
✓ 읽을 때마다 메모리에서 · 지연은 하드웨어 시계로
static volatile uint8_t g_rx_done;   /* 매번 메모리에서 읽게 */

void wait_rx(void)
{
    while (!g_rx_done) { __WFI(); }  /* 기다리는 동안 잠들기까지 */
}

/* 지연: volatile 루프 카운터는 "지워지지 않을" 뿐
   걸리는 시간은 최적화 수준마다 다름 → 사이클 카운터로 */
void delay_us(uint32_t us)           /* t01 의 cyc_init() 이후 */
{
    uint32_t start = DWT->CYCCNT;
    uint32_t ticks = us * (SystemCoreClock / 1000000u);
    while ((DWT->CYCCNT - start) < ticks) { }   /* CYCCNT 는 volatile */
}
volatile 은 "매번 읽고 쓴다"만 보장합니다 — 원자성도, 다른 메모리와의 순서도 보장하지 않습니다. 여러 바이트짜리 값(uint64_t 타임스탬프, 구조체)이나 읽고-고치고-쓰기는 volatile 만으로 안전하지 않습니다 → 사례 ②. us * (SystemCoreClock / 1000000u) 는 180 MHz 에서 us 가 약 23.8 초를 넘으면 32비트를 넘칩니다.

사례 ② ISR 레이스 — |= 한 줄이 세 명령일 때 🔴 고급

g_flags |= TX (메인) 와 g_flags |= RX (ISR) 가 겹치는 순간 메인 루프 USART2 ISR g_flags (RAM) 1. LDR r0 ← g_flags = 0x00 — 인터럽트 — 2. LDR = 0x00 3. ORR RX 4. STR → 0x02 5. ORR TX (r0 = 옛 값 0x00) 6. STR → 0x01 — ISR 이 세운 RX 비트를 덮어씀 수백만 번에 한 번 — 인터럽트가 정확히 1 과 6 사이에 와야 합니다. 그래서 현장에서만 납니다
RMW 레이스 시퀀스 — 메인이 들고 있던 옛 값이 ISR 의 쓰기를 지웁니다. volatile 로는 막을 수 없습니다.

고치는 세 가지

/* ① 임계 구역 — PRIMASK 를 저장 · 복원 (중첩돼도 안전) */
static inline void flags_set(uint32_t m)
{
    uint32_t pm = __get_PRIMASK();
    __disable_irq();
    g_flags |= m;                    /* 이 세 명령 동안 인터럽트 없음 */
    __set_PRIMASK(pm);
}

/* ② C11 원자 연산 — Cortex-M3/M4 에선 LDREX/STREX 반복으로 컴파일.
   중간에 인터럽트가 끼면 STREX 가 실패하고 다시 시도 → 인터럽트를 안 막음 */
#include <stdatomic.h>
static _Atomic uint32_t g_flags2;
static inline void flags2_set(uint32_t m)       /* 메인 · ISR 어디서든 */
{
    atomic_fetch_or_explicit(&g_flags2, m, memory_order_relaxed);
}

/* ③ 공유를 없앰 — 쓰는 쪽이 하나인 변수로 나누기 */
static volatile uint8_t g_rx_pending;   /* ISR 만 1 로 씀, 메인만 0 으로 */
static volatile uint8_t g_tx_pending;   /* 바이트 쓰기는 한 명령(STRB) */
Cortex-M0/M0+ 에는 LDREX/STREX 가 없습니다 — ② 가 라이브러리 호출(내부에서 인터럽트 금지)로 바뀌거나 링크 오류가 납니다. RTOS 를 쓴다면 같은 문제를 태스크 사이에서도 겪습니다 — FreeRTOS 탭 r11 의 크리티컬 섹션 · 뮤텍스 구분을 함께 보세요.

찾는 법 — 공유 변수 목록과 정렬 폴트 짝

변수 접근안전?
ISR 이 쓰고 메인이 읽기 (8 · 16 · 32비트 정렬)volatile 이면 됨
양쪽이 |= · ++ · -=위험 — ① ② ③
uint64_t · 구조체를 ISR 이 갱신위험 — 반쯤 쓴 값을 읽음
비트필드 구조체 필드 하나 쓰기위험 — 같은 워드의 이웃 필드를 RMW
GPIO ODR |= pin위험 → BSRR 사용 (t01)
/* 사례 표의 "정렬 폴트" 짝 — 패킹 구조체를 수신 버퍼에 겹치기 */
typedef struct __attribute__((packed)) {
    uint8_t cmd; double setpoint;       /* setpoint 는 오프셋 1 */
} msg_t;
const msg_t *m = (const msg_t *)rx;     /* rx 는 바이트 버퍼 */
pid_set(m->setpoint);  /* 컴파일러가 packed 를 알면 바이트 단위로 읽어 안전.
   그러나 &m->setpoint 를 double* 로 넘기는 순간 그쪽은 정렬을 가정
   → LDRD/VLDR → UNALIGNED 폴트. GCC 9+ 는
   -Waddress-of-packed-member 로 경고해 줍니다 */

사례 ③ 워치독 리셋 루프 — 계산부터 틀렸을 때 🔴 고급

IWDG 타임아웃 — LSI 오차까지 계산

/* 타임아웃 = (RLR + 1) × 분주비 / f_LSI
   분주 /32, RLR = 4095:
     전형값 32 kHz → 4096 × 32 / 32 000 = 4.096 s
     F4 데이터시트의 LSI 범위 17 ~ 47 kHz →
       47 kHz → 2.79 s  (가장 빨리 리셋)
       17 kHz → 7.71 s  (가장 늦게 리셋)
   "4초 안에만 킥하면 된다"가 아니라 "2.79 초 안에"가 설계 기준 */
IWDG_HandleTypeDef hiwdg = {
    .Instance = IWDG,
    .Init = { .Prescaler = IWDG_PRESCALER_32, .Reload = 4095 },
};

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    reset_cause_capture();
    boot_guard();                       /* ↓ 연속 리셋 감지 */
    HAL_IWDG_Init(&hiwdg);              /* ★ 켜는 순간부터 시계가 돎 · 끌 수 없음 */
    sd_card_init();                     /* ✗ 카드에 따라 3~5 초 → 리셋 루프 */
    ...
}
IWDG 는 한 번 켜면 리셋 말고는 끌 수 없습니다. 옵션 바이트의 WDG_SW 가 "하드웨어 워치독"이면 전원을 켜자마자 켜져 있습니다. 디버거로 멈춰 있는 동안 리셋되는 것은 t01 의 __HAL_DBGMCU_FREEZE_IWDG() 로 막습니다. t12 의 HIL 워치독 시험이 최악값 7.7 s 보다 긴 대기를 두는 이유도 이 계산입니다.

고치기 — 긴 초기화 쪼개기 · 연속 리셋이면 안전 모드

/* ① 긴 초기화 안에서 킥 — 단, 루프가 "진행 중"일 때만 */
int sd_card_init(void)
{
    for (int tries = 0; tries < 200; tries++) {   /* 최대 200 × 20 ms */
        if (sd_poll_ready()) return 0;
        HAL_IWDG_Refresh(&hiwdg);                  /* 진행하는 동안만 킥 */
        HAL_Delay(20);
    }
    return -1;                                     /* 무한히 킥하지 않음 */
}

/* ② 부팅 카운터 — .noinit 은 워치독 리셋에도 남음 (t05) */
uint32_t reset_cause_raw(void) { return s_csr; }  /* 앞에서 보관한 CSR */

__attribute__((section(".noinit"))) static uint32_t s_boot_magic;
__attribute__((section(".noinit"))) static uint32_t s_wdg_streak;

void boot_guard(void)
{
    if (s_boot_magic != 0xB007B007u) {             /* 전원 처음 켬 */
        s_boot_magic = 0xB007B007u;
        s_wdg_streak = 0;
    }
    if (RCC_CSR_IWDGRSTF & reset_cause_raw()) s_wdg_streak++;
    else                                      s_wdg_streak = 0;

    if (s_wdg_streak >= 3) enter_safe_mode();     /* 통신만 살린 최소 모드 */
}
/* 정상 동작이 1분 넘게 이어지면 s_wdg_streak = 0 으로 리셋 */
킥은 "살아 있다"가 아니라 "제대로 돌고 있다"의 증거여야 합니다 — 타이머 ISR 에서 킥하면 메인 루프가 멈춰도 워치독이 모릅니다. RTOS 라면 각 태스크가 체크인하고, 감시 태스크가 전원 체크인을 확인한 뒤에만 킥합니다(FreeRTOS 탭). 안전 모드에서는 OTA 로 고친 펌웨어를 받을 수 있어야 합니다(양산 탭).
정리 — 여덟 가지 현장 버그는 각자 잡는 도구가 정해져 있습니다: 최적화 버그는 기계어, 레이스는 공유 변수 목록과 원자 연산, 정렬은 CFSR · UBSan, 스택은 페인팅 · 가드, 클럭은 MCO 와 SVD, 리셋 루프는 RCC_CSR 과 .noinit 카운터. 부팅 첫 줄에 리셋 원인을 찍고, 워치독 타임아웃은 LSI 오차의 최악값으로 설계하세요.
STM32F446RE 기준 · EVT/DVT/PVT · 부트로더 · OTA · A/B 슬롯 · MCUboot · IWDG/WWDG · 리셋 원인 · 정전 안전 저장 · littlefs · 전력 예산 · 생산 지그 · RDP · CRA · 재현 가능 빌드

책상 위 보드를
만 대의 제품으로
내보내는 법

개발 보드에서 LED 가 깜빡이면 공부는 끝나지만 제품은 거기서 시작합니다. 만 대가 고객 손에 들어가면 펌웨어는 디버거 없이 몇 년을 돌아야 하고, 멈추면 스스로 일어나야 하며, 버그가 나오면 공장으로 회수하지 않고 원격으로 고쳐야 합니다. 이 탭은 STM32F446RE(Flash 512 KB, 섹터 0~7)를 기준으로 부트로더 설계, 전원이 끊겨도 벽돌이 되지 않는 OTA, 이미지 서명, 워치독을 "제대로" 쓰는 법, 리셋 원인 기록, 정전 안전 저장, 배터리 수명 계산, 생산 라인 기록, 읽기 보호와 규제, 버전 관리까지 제품으로 내보내고 현장에서 운영하는 관점으로 묶었습니다. 이 탭을 마치면 "양산 펌웨어 체크리스트"를 스스로 만들고, 부트로더 · OTA · 워치독 · 저장 구조를 설계 문서로 설명할 수 있게 됩니다.

13SECTIONS
F446RE기준 칩 · 512 KB
A/B롤백 되는 업데이트
0현장 회수 목표
PROD 01

개발 보드에서 제품까지 — EVT · DVT · PVT, 단계마다 펌웨어가 할 일

하드웨어 제품은 한 번에 만들어지지 않습니다. 업계는 보통 EVT → DVT → PVT → 양산(MP) 네 관문을 거치고, 관문마다 보드가 새로 나옵니다. 서버 개발에 빗대면 dev → staging → canary → prod 와 같지만 결정적인 차이가 하나 있습니다 — 배포된 뒤에는 서버처럼 SSH 로 들어갈 수 없습니다. 그래서 펌웨어는 출하 전에 "현장에서 혼자 살아남는 장치"를 다 갖춰야 합니다.

제품 개발 관문 — 위는 하드웨어가 증명할 것, 아래는 펌웨어가 할 일 프로토타입 EVT DVT PVT 양산 MP 개발 보드 · 점퍼선 되는지 본다 첫 자체 PCB 수십 장 설계가 동작하나 금형 · 인증 시험 요구를 만족하나 실제 생산 라인 공정이 수율을 내나 출하 · 현장 운영 수천~수만 대 HAL 예제로 기능 확인 브링업 · 드라이버 보드 테스트 펌웨어 기능 완성 · OTA 저전력 · 생산 테스트 릴리스 후보 · RDP 기록 스크립트 확정 OTA 로 개선 현장 로그 분석 펌웨어 일이 몰리는 구간 관문 하나가 보통 수 주~수 개월 — 관문마다 보드 리비전이 바뀝니다 (PROD 12)
타임라인 — 하드웨어는 관문마다 무엇을 증명하나가 바뀌고, 펌웨어 일은 DVT 에 가장 많이 몰립니다.

관문별 펌웨어 산출물

관문펌웨어가 내놓아야 하는 것
EVT클럭 · 전원 · 각 주변장치를 하나씩 켜 보는 브링업 펌웨어, 회로 실수 목록(풀업 누락 · 핀 바뀜)
DVT기능 완성, 부트로더 + OTA, 저전력 측정, 워치독 · 리셋 원인 기록, EMC 시험용 펌웨어(무선 연속 송신 등), 생산 테스트 펌웨어 초안
PVT릴리스 후보(RC), 공장 기록 스크립트, 시리얼 · 보정값 기록, RDP 잠금, 불량 판정 기준
MP버전 태그 · 심볼(.elf) 보관, OTA 서버 운영, 현장 로그 분석 → 패치
EVT 는 Engineering, DVT 는 Design, PVT 는 Production Validation Test 의 약자입니다. 회사마다 이름은 조금씩 다르지만 "설계가 되나 → 설계가 맞나 → 공장에서 되나" 순서는 같습니다.

펌웨어는 세 벌 — 개발 · 생산 테스트 · 출하

항목개발생산 테스트출하
디버그 포트열림 (RDP 0)열림 (공정이 씀)RDP 1 이상 (PROD 11)
워치독디버그 중 정지긴 타임아웃항상 켜짐, 정지 해제
assert 실패BKPT 로 멈춤FAIL 응답원인 기록 후 리셋
로그DEBUG · printf결과 숫자만WARN 이상 · Flash 링 로그
세미호스팅써도 됨금지절대 금지
셸 명령전부 열림지그 전용 명령인증 뒤 일부만
HW 시험 코드수동전 항목 자동바이너리에 없음
버전"dev"태그 + factory태그 + 빌드 ID (PROD 12)

세 벌을 한 소스에서 — 빌드 스위치와 컴파일 가드 🟡 중급

표의 세 벌은 같은 소스에서 컴파일 스위치로 뽑습니다. 생산 테스트 펌웨어는 공장에서 한 번 돌고 출하 펌웨어로 덮어써지므로, 공정에 "덮어쓰기" 단계가 반드시 있어야 합니다(PROD 10). 테스트 코드를 출하 이미지에 섞어 두면 숨은 공장 모드가 되어 보안 구멍이 됩니다. 출하 빌드는 개발자 PC 가 아니라 CI 만 만들게 합니다.

CMake 로 세 벌 뽑기

# 같은 소스, 다른 정의 — 출하 빌드는 CI 파이프라인에서만
set(FW_VARIANT "dev" CACHE STRING "dev | factory | prod")

if(FW_VARIANT STREQUAL "dev")
  target_compile_definitions(app PRIVATE FW_DEV=1 LOG_LEVEL=0)
elseif(FW_VARIANT STREQUAL "factory")
  target_compile_definitions(app PRIVATE FW_FACTORY=1 LOG_LEVEL=1)
  target_sources(app PRIVATE src/factory/selftest.c)  # 출하 이미지엔 아예 없음
elseif(FW_VARIANT STREQUAL "prod")
  target_compile_definitions(app PRIVATE FW_PROD=1 LOG_LEVEL=2)
else()
  message(FATAL_ERROR "FW_VARIANT 는 dev | factory | prod 중 하나")
endif()
"코드에는 있는데 꺼 둔 것"과 "코드에 아예 없는 것"은 다릅니다. 공장 전용 명령은 #if 로 감추지 말고 파일째 빌드에서 빼세요. 바이너리에 문자열 하나라도 남으면 누군가 찾아냅니다.

양산 빌드에 개발 설정이 섞이면 컴파일 실패

/* prod_guard.h — 모든 .c 가 include 하는 설정 헤더 끝에 */
#define LOG_DEBUG 0
#define LOG_INFO  1
#define LOG_WARN  2

#if defined(FW_PROD)
#  if defined(USE_SEMIHOSTING)
#    error "양산 빌드에 세미호스팅이 켜져 있습니다"
#  endif
#  if LOG_LEVEL < LOG_WARN
#    error "양산 빌드의 로그 레벨이 DEBUG/INFO 입니다"
#  endif
#  if defined(FW_FACTORY) || defined(FW_DEV)
#    error "변형 스위치가 둘 이상 켜졌습니다"
#  endif
#endif
세미호스팅이 왜 금지 1순위인가 — 세미호스팅 printf 는 BKPT 0xAB 명령으로 디버거를 부릅니다. 디버거가 없으면 Cortex-M 은 이 BKPT 를 HardFault 로 격상시킵니다. 책상에선 잘 돌던 펌웨어가 "USB 케이블만 빼면 바로 죽는" 전형적인 원인입니다.

실무에선 이렇게 — 부팅할 때 스스로 점검 🔴 실무

출하 공정이 빠뜨린 것을 펌웨어가 잡기

/* 출하 빌드만 — 옵션 바이트가 기대값인지 부팅마다 확인 */
void prod_boot_checks(void)
{
#if defined(FW_PROD)
    FLASH_OBProgramInitTypeDef ob = {0};
    HAL_FLASHEx_OBGetConfig(&ob);            /* RDP · BOR · WRP 읽기 */

    if (ob.RDPLevel == OB_RDP_LEVEL_0)
        event_log(EV_RDP_OPEN);               /* 공정에서 잠금을 빠뜨림 → 서버 경보 */
    if (ob.BORLevel != OB_BOR_LEVEL3)
        event_log(EV_BOR_LOW);

    /* 개발 중 켜 둔 "디버그 정지"가 남아 있으면 끈다 */
    DBGMCU->APB1FZ &= ~(DBGMCU_APB1_FZ_DBG_IWDG_STOP |
                       DBGMCU_APB1_FZ_DBG_WWDG_STOP);
#endif
}
스스로 잠그지는 않습니다 — 펌웨어가 RDP 를 올리게 만들 수도 있지만, 공정 중 실수로 RDP 2 가 걸리면 그 보드는 영원히 다시 못 굽습니다. 잠금은 공정의 마지막 단계(PROD 10), 펌웨어는 확인과 보고만 하는 분업이 안전합니다.

양산 출하 전 체크리스트 (요약)

✓항목섹션
□부트로더가 앱 유효성을 확인하고, 깨진 앱이면 복구 모드로PROD 02
□업데이트 도중 전원을 뽑아도 부팅됨 (실제로 100번 뽑아 봄)PROD 03
□이미지 서명 검증 · 키는 개발자 PC 가 아니라 서명 서버에PROD 04
□워치독은 모든 태스크 체크인 후에만 갱신PROD 05
□리셋 원인 · 리셋 루프 횟수 기록PROD 06
□설정 저장이 정전에 안전PROD 07
□잠자는 전류를 실측해 배터리 수명 계산과 대조PROD 08
□빌드 ID 별 .elf 보관PROD 09 · 12
□RDP · WRP · BOR 이 공정에서 설정됨PROD 10 · 11
정리 — 제품은 EVT · DVT · PVT 관문을 지나며 만들어지고, 펌웨어 일은 DVT 에 몰립니다. 펌웨어는 개발 · 생산 테스트 · 출하 세 벌로 나눠 빌드하고, 출하 빌드에 개발 설정이 섞이면 컴파일이 실패하게 막아 둡니다.
PROD 02

부트로더 설계 — Flash 를 나누고, 앱으로 깨끗하게 점프하기

부트로더는 리셋 직후 가장 먼저 도는 작은 두 번째 프로그램입니다. 할 일은 셋뿐입니다 — 앱이 멀쩡한지 확인하고, 필요하면 새 앱을 받고, 앱으로 넘어간다. 서버로 치면 GRUB 이나 컨테이너의 init 과 같은 자리입니다. 리셋에서 main 까지의 기본 줄거리(벡터 테이블 [0] · [4], Reset_Handler)는 하드웨어 기초 탭 h11 · 링커 스크립트는 h12 를 참고하고, 여기서는 두 프로그램이 한 Flash 를 나눠 쓰는 법에 집중합니다.

F446RE Flash 512 KB — 칸 폭이 크기에 비례합니다 (1 KB ≈ 1.2 px) 부트로더 S0~S1 · 32 KB · 0x0800 0000 BL CFG 슬롯 A · 실행 192 KB 0x0801 0000 ~ 0x0803 FFFF 슬롯 B · 256 KB 0x0804 0000 ~ 0x0807 FFFF S4 64K S5 128K S6 128K S7 128K 설정 A/B S2~S3 · 16 KB × 2 · 0x0800 8000 (PROD 07) 단일 슬롯 배치 BL CFG 앱 448 KB (S4~S7) — 새 이미지는 외부 SPI Flash 에 받음 섹터가 16 · 16 · 16 · 16 · 64 · 128 · 128 · 128 KB 로 들쭉날쭉 — 경계는 반드시 섹터에 A/B 배치는 슬롯 B 가 더 크지만 앱 최대 크기는 작은 쪽(192 KB)에 묶입니다
메모리 배치 — 부트로더를 넣는 순간 앱이 쓸 Flash 가 줄어듭니다. A/B 를 하면 절반 이하로.

부트로더의 main — 판단은 이것뿐

/* bootloader/main.c — 0x0800 0000, 32 KB 안에 들어가야 함 */
#define APP_BASE   0x08010000u
#define APP_MAX    (192u * 1024u)

int main(void)
{
    HAL_Init();                    /* 클럭은 HSI 16 MHz 그대로 — 단순할수록 안전 */

    if (boot_request_pending())    /* 앱이 "업데이트 받을래" 하고 리셋했나 */
        recovery_mode();           /* UART · USB 로 새 이미지 받기 (돌아오지 않음) */

    if (!app_valid(APP_BASE, APP_MAX))
        recovery_mode();           /* 앱이 없거나 깨졌으면 앱으로 가지 않는다 */

    jump_to_app(APP_BASE);         /* 돌아오지 않음 */
}
부트로더는 거의 고치지 않는 코드입니다. 현장에 나간 부트로더는 업데이트하지 않는 것이 원칙이라(업데이트 도중 꺼지면 복구 수단이 없음) 기능을 최소로, 클럭 설정도 최소로 둡니다. 32 KB 를 넘으면 HAL 대신 LL 을 쓰거나 -Os 로 줄이세요.

앱 유효성 검사 — 점프하기 전에 반드시

static bool app_valid(uint32_t base, uint32_t max)
{
    const uint32_t sp = *(const volatile uint32_t *)base;          /* 벡터 [0] */
    const uint32_t pc = *(const volatile uint32_t *)(base + 4u);   /* 벡터 [1] */

    /* 지워진 Flash 는 0xFFFFFFFF — 이 검사 하나로 "앱 없음"이 걸린다 */
    if (sp < 0x20000000u || sp > 0x20020000u) return false;  /* SRAM 128 KB, 끝 포함 */
    if ((sp & 0x3u) != 0u)                    return false;  /* 스택은 워드 정렬 */
    if ((pc & 0x1u) == 0u)                    return false;  /* Thumb 비트가 없으면 가짜 */
    if (pc < base || pc >= base + max)        return false;  /* 앱 영역 밖을 가리킴 */

    return image_digest_ok(base);   /* 내용 전체 확인 — CRC 또는 SHA-256 (PROD 04) */
}
SP · PC 검사만으로는 부족합니다 — 업데이트가 앞 1 KB 만 쓰고 끊기면 벡터 테이블은 멀쩡한데 나머지가 0xFF 입니다. 점프는 성공하고 몇 초 뒤 HardFault 가 납니다. 이미지 전체의 해시를 함께 봐야 합니다.

앱으로 점프 — 순서가 곧 정답 🟡 중급

점프 일곱 단계 — 왼쪽이 할 일, 오른쪽이 빼먹었을 때의 증상 1 앱 유효성 — SP · PC · 해시 빈 Flash 로 점프 → 즉시 HardFault 2 __disable_irq() — 정리 중 끼어들기 차단 반쯤 해제된 주변장치의 ISR 이 돎 3 RCC · 주변장치 원복, SysTick 정지 앱의 클럭 설정이 PLL 켜진 상태에서 실패 4 NVIC ICER · ICPR 전부 지움 남은 펜딩이 앱에서 엉뚱한 핸들러로 5 SCB->VTOR = 앱 주소 · DSB · ISB 인터럽트가 부트로더 핸들러로 감 6 CONTROL = 0 · PRIMASK 해제 · MSP = 앱 [0] HAL_Delay 가 영원히 안 끝남 (PRIMASK) 7 앱 [4] = Reset_Handler 로 분기 앱이 .data 복사 · .bss 0 부터 다시 시작
단계 — 핵심은 "앱이 리셋 직후의 칩을 만나게" 하는 것입니다. 하나라도 빠지면 증상이 몇 초 뒤에 엉뚱한 곳에서 납니다.

jump_to_app — 위 일곱 단계 그대로

/* MSP 를 바꾼 뒤엔 C 지역 변수(스택)를 믿을 수 없다 → 어셈블리 두 줄 */
__attribute__((naked, noreturn))
static void start_app(uint32_t sp, uint32_t pc)
{
    __asm volatile("msr msp, r0 \n"    /* r0 = sp (AAPCS 첫 인자) */
                   "bx  r1      \n");  /* r1 = pc, Thumb 비트 1 그대로 */
}

void jump_to_app(uint32_t base)
{
    const uint32_t sp = *(const volatile uint32_t *)base;
    const uint32_t pc = *(const volatile uint32_t *)(base + 4u);

    __disable_irq();                                  /* ② */
    HAL_DeInit();                                     /* ③ 주변장치 RCC 리셋 */
    HAL_RCC_DeInit();                                 /*   HSI 로 복귀 (안에서 SysTick 재설정) */
    SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0;  /* 그래서 맨 마지막에 정지 */

    for (uint32_t i = 0; i < 8u; i++) {               /* ④ F446 IRQ 97개 — 넉넉히 8워드 */
        NVIC->ICER[i] = 0xFFFFFFFFu;
        NVIC->ICPR[i] = 0xFFFFFFFFu;
    }
    SCB->ICSR = SCB_ICSR_PENDSTCLR_Msk;               /*   SysTick 펜딩도 */

    SCB->VTOR = base;                                 /* ⑤ 0x200 정렬 필수 */
    __DSB(); __ISB();

    __set_CONTROL(0u);                                /*   ⑥ 앞정리: PSP · FPU 문맥 흔적 제거 */
    __ISB();
    __enable_irq();                                   /*   NVIC 를 다 껐으니 안전 */
    start_app(sp, pc);                                /* ⑥ MSP ⑦ 분기 */
}

왜 이 순서인가

결정이유
SysTick 정지를 맨 뒤에F4 HAL 의 HAL_RCC_DeInit() 은 끝에서 HAL_InitTick() 으로 SysTick 을 다시 켭니다
__enable_irq()점프는 리셋이 아니라 PRIMASK=1 이 그대로 넘어갑니다. CubeMX 앱은 이걸 풀지 않으므로 SysTick 이 안 돌고 HAL_Delay 에서 멈춤
CONTROL = 0부트로더가 RTOS 를 썼다면 PSP 사용 · 비특권 상태일 수 있음. 앱의 스타트업은 MSP 특권을 가정
naked 함수C 로 __set_MSP(sp) 뒤 ((void(*)(void))pc)() 를 쓰면 최적화 수준에 따라 pc 를 바뀐 스택에서 읽음
DSB · ISBVTOR 쓰기가 끝나기 전에 인터럽트가 들어오지 않게
VTOR 정렬 — Cortex-M4 의 벡터 테이블은 "벡터 개수 × 4 를 2 의 거듭제곱으로 올린 값"에 정렬돼야 합니다. F446 은 113개 × 4 = 452 B → 512 B(0x200) 정렬. CMSIS 의 system_stm32f4xx.c 주석이 "0x200 의 배수"라고 적어 둔 이유입니다.

실무에선 이렇게 — 앱 쪽 준비와 "리셋 거쳐 점프" 🔴 실무

앱이 바꿀 두 곳

/* app/STM32F446RETX_FLASH.ld */
MEMORY
{
  RAM    (xrw) : ORIGIN = 0x20000000, LENGTH = 128K - 256
  NOINIT (rw)  : ORIGIN = 0x2001FF00, LENGTH = 256     /* 부트로더와 같은 자리 */
  FLASH  (rx)  : ORIGIN = 0x08010000, LENGTH = 192K    /* 슬롯 A = S4 + S5 */
}
/* app/Core/Src/system_stm32f4xx.c — 주석 해제 + 값 변경 */
#define USER_VECT_TAB_ADDRESS
#define VECT_TAB_OFFSET  0x00010000U   /* 0x200 의 배수 */
/* SystemInit() 이 SCB->VTOR = FLASH_BASE | VECT_TAB_OFFSET 을 씀 */
부트로더가 이미 VTOR 를 썼는데 앱이 또 쓰는 이유 — 디버거로 앱만 올려 돌릴 때도 맞게 돌게 하려는 것입니다. 디버깅할 때는 부트로더를 한 번 구워 두고 앱 .elf 만 내려받으면 됩니다.

앱 → 부트로더: 리셋을 거쳐 가기

/* 양쪽 링커 스크립트가 같은 주소에 두는 .noinit (startup 이 0 으로 안 지움) */
#define BOOT_REQ_MAGIC  0xB007B007u
__attribute__((section(".noinit")))
volatile uint32_t g_boot_req;

/* 앱: OTA 명령을 받으면 */
void enter_bootloader(void)
{
    g_boot_req = BOOT_REQ_MAGIC;
    NVIC_SystemReset();            /* 칩 전체를 진짜 리셋 상태로 */
}

/* 부트로더: 맨 처음, HAL_Init 보다 먼저 */
bool boot_request_pending(void)
{
    if (g_boot_req != BOOT_REQ_MAGIC) return false;
    g_boot_req = 0u;               /* 한 번만 — 안 지우면 영원히 복구 모드 */
    return true;
}
전원 투입(POR) 직후의 SRAM 은 쓰레기 값입니다. 매직 값이 우연히 맞을 확률은 2-32 이지만, 더 확실히 하려면 매직과 그 비트 반전 값을 함께 써 두고 둘 다 맞을 때만 인정합니다. 반대 방향(부트로더 → 앱)도 이렇게 리셋 후 점프하면 위 일곱 단계 중 ②~④가 저절로 해결됩니다.
정리 — F446 에서 부트로더는 S0~S1(32 KB), 앱은 0x0801 0000 부터가 흔한 배치입니다. 점프 전에 SP · PC · 해시로 앱을 검사하고, 인터럽트 · 클럭 · NVIC 를 리셋 직후 상태로 돌린 뒤 VTOR → MSP → Reset_Handler 순서로 넘깁니다.
PROD 03

펌웨어 업데이트 — 전원이 끊겨도 벽돌이 되지 않는 OTA

업데이트 경로는 두 가지입니다. 사람이 케이블을 꽂는 DFU(Device Firmware Update) 와 무선 · 네트워크로 받는 OTA. 어느 쪽이든 원칙은 하나입니다 — 어느 순간에 전원이 끊겨도, 다음 부팅에서 반드시 "돌아가는 펌웨어 하나"가 있어야 한다. 서버의 블루-그린 배포와 같은 발상이지만, 로드 밸런서 대신 부트로더가 트래픽을 돌린다고 보면 됩니다.

DFU — ST 내장 부트로더 vs 자체 부트로더

ST 시스템 부트로더자체 부트로더 (PROD 02)
위치시스템 메모리 0x1FFF 0000 (ROM, 지울 수 없음)메인 Flash S0~S1
들어가는 법리셋 때 BOOT0 = 1매직 값 · 버튼 · 앱 무효
인터페이스USART · I2C · SPI · CAN · USB DFU (핀은 AN2606 표)내가 고른 것
서명 검증없음 — 아무 이미지나 받음넣을 수 있음
RDP 1 에서Flash 읽기 불가, 지우기 · 쓰기는 제한적으로내 정책대로
# ST 내장 부트로더 — BOOT0 를 올리고 리셋한 뒤 UART 로 (8E1, 동기 바이트 0x7F)
STM32_Programmer_CLI -c port=COM5 br=115200 -w app.bin 0x08010000 -v
# USB DFU (PA11/PA12) 로
STM32_Programmer_CLI -c port=usb1 -w app.bin 0x08010000 -v

무엇을 고르나

시스템 부트로더는 "최후의 구조 수단"으로 남겨 두세요. 벽돌이 된 보드도 BOOT0 테스트 패드만 있으면 살아납니다. PCB 에 BOOT0 · NRST · UART1(PA9/PA10) 테스트 패드를 꼭 남기라고 하드웨어 담당에게 EVT 때 요청합니다.
현장 업데이트는 자체 부트로더로 — 시스템 부트로더는 서명을 검사하지 않아 누구든 BOOT0 만 올리면 임의 펌웨어를 올릴 수 있습니다. 양산품에서 이 경로를 막는 것이 RDP(PROD 11)의 역할 중 하나입니다. 소프트웨어에서 0x1FFF 0000 으로 점프해 시스템 부트로더를 부르는 방법도 있지만 PROD 02 의 원복 단계를 전부 거쳐야 해서 까다롭습니다.

OTA 한 번의 왕복 — 받고, 검증하고, 시험하고, 확정한다 🟡 중급

세로가 시간 — 확정은 "새 펌웨어가 서버에 다시 붙은 뒤"입니다 OTA 서버 앱 (슬롯 A) Flash 슬롯 B · 설정 부트로더 1. v1.5.0 · 크기 · SHA-256 2. 청크 요청 (오프셋) 3. 4 KB 청크 × N 4. 섹터 지우고 쓰기 5. 해시 · 서명 검증 6. 상태 = 대기(PENDING) 7. NVIC_SystemReset() 8. 재검증 · 시험(TRIAL) 9. 슬롯 B 로 점프 10. 확정(CONFIRMED) 11. 새 버전 보고 실선은 요청 · 점선은 응답 — 5 와 8 에서 두 번 검증합니다 (받은 쪽과 실행하는 쪽이 각자)
시퀀스 — 새 펌웨어는 시험(TRIAL)으로 한 번 부팅해 보고, 서버에 다시 붙은 뒤에야 확정합니다.
A/B 슬롯 상태 기계 — 원이 상태, 화살표 글이 전이 조건 자가 진단 · 서버 접속 성공 → B 확정 (A · B 역할 교대) 정상 A 확정 받는 중 B 에 쓰기 대기 PENDING 시험 TRIAL · B 롤백 A 로 부팅 새 버전 검증 OK 리셋 끊김 · 검증 실패 확정 전 리셋 3회 (워치독 · 폴트 포함) 이전 펌웨어로 복귀 · 서버에 실패 보고
상태 기계 — 확정되지 않은 새 펌웨어는 손님입니다. 정해진 횟수 안에 확정하지 못하면 부트로더가 이전 슬롯으로 되돌립니다.

부트로더 쪽 — 시험 횟수를 세고 되돌리기

typedef struct {
    uint32_t active;     /* 확정된 슬롯 0=A 1=B */
    uint32_t pending;    /* 시험할 슬롯 */
    uint32_t state;      /* 0=IDLE 1=PENDING 2=TRIAL */
    uint32_t tries;      /* TRIAL 부팅 횟수 */
} boot_ctrl_t;           /* PROD 07 의 정전 안전 저장으로 보관 */

#define MAX_TRIES 3u

uint32_t choose_slot(void)
{
    boot_ctrl_t bc;
    ctrl_load(&bc);
    if (bc.state == 1u) { bc.state = 2u; bc.tries = 0u; }    /* PENDING → TRIAL */
    if (bc.state == 2u) {
        if (bc.tries >= MAX_TRIES || !app_valid(slot_base(bc.pending), SLOT_MAX)) {
            bc.state = 0u;                         /* 롤백 — active 는 그대로 */
            ctrl_save(&bc);
            evt_log(EV_ROLLBACK);
            return bc.active;
        }
        bc.tries++;                                /* 점프 "전에" 먼저 기록 */
        ctrl_save(&bc);
        return bc.pending;
    }
    return bc.active;
}
tries 를 점프 전에 저장하는 것이 핵심입니다. 점프 뒤에 세면 새 펌웨어가 부팅하자마자 죽을 때 카운트가 영원히 오르지 않아 무한 리셋 루프가 됩니다.

앱 쪽 — 언제 확정하나

/* 새 펌웨어: 부팅 후 핵심 기능을 확인한 뒤에만 */
void ota_confirm_if_trial(void)
{
    boot_ctrl_t bc;
    ctrl_load(&bc);
    if (bc.state != 2u) return;

    if (!selftest_sensors_ok())   return;   /* 센서 응답 */
    if (!cloud_connected_once())  return;   /* ★ 다음 업데이트를 받을 수 있나 */

    bc.active = bc.pending;                 /* 역할 교대 */
    bc.state  = 0u;
    bc.tries  = 0u;
    ctrl_save(&bc);
}
"서버에 다시 붙을 수 있나"를 확정 조건에 넣으세요. 통신 코드가 망가진 펌웨어를 확정하면 그 장비는 다시는 업데이트를 받지 못합니다. 부팅만 되면 확정하는 구현이 현장 사고의 단골 원인입니다.
슬롯 B 에서 바로 실행(XIP)하려면 이미지가 그 주소로 링크돼 있어야 합니다. 그래서 A/B 방식은 0x0801 0000 용과 0x0804 0000 용 두 벌을 빌드하고, 서버는 장비가 보고한 활성 슬롯의 반대쪽 이미지를 내려 줍니다.

실무에선 이렇게 — A/B · 스왑 · 단일 슬롯 중 고르기 🔴 실무

업데이트 방식 고르기 (F446RE) — 가로는 앱 크기, 세로는 되돌리기 128 KB 256 KB 384 KB 512 KB 앱이 쓸 수 있는 내부 Flash → 즉시 롤백 복구 모드만 롤백도 되고 앱도 큼 — 이 칸 스왑 + 스크래치 · 128 KB A/B 직접 실행 · 192 KB 덮어쓰기 · 롤백 없음 단일 슬롯 + 복구 모드 · 448 KB 외부 Flash 보조 슬롯 스왑은 가장 큰 섹터(128 KB) 크기의 스크래치가 필요해 F446 에선 앱이 가장 작아집니다
사분면 — F446 처럼 섹터가 크고 하나뿐인 Flash에서는 롤백과 앱 크기가 서로를 깎아 먹고, 외부 Flash 가 둘 다 얻는 절충입니다.

전원이 끊기는 지점별로 따져 보기

끊긴 순간다음 부팅
청크 받는 중상태는 아직 IDLE → A 로 부팅, 받은 오프셋부터 이어 받기
슬롯 B 섹터 지우는 중B 는 원래 버릴 곳 — 영향 없음
상태 저장 중PROD 07 의 이중 뱅크가 이전 값이나 새 값 중 하나를 보장
TRIAL 부팅 중tries 가 이미 올라가 있음 → 3회 뒤 롤백
확정 저장 중이전 값이면 다시 TRIAL — 다시 확정하면 됨
"모든 단계가 다시 해도 같은 결과"(멱등)가 되게 짜면 정전 분석이 쉬워집니다. 그리고 실제로 검증하세요 — 릴레이로 전원을 무작위 시점에 끊는 지그를 하룻밤 돌려 수천 번 업데이트해 보는 것이 업계의 표준 시험입니다.

받는 쪽 코드의 뼈대 — 이어 받기

/* 슬롯 B 에 오프셋 순서대로만 쓴다 — 섹터 첫 청크에서 그 섹터를 지움 */
int ota_write_chunk(uint32_t off, const uint8_t *buf, uint32_t len)
{
    if (off != g_ota.next_off)      return OTA_E_ORDER;  /* 서버에 next_off 재요청 */
    if (off + len > SLOT_MAX)       return OTA_E_SIZE;
    if (len % 4u != 0u)             return OTA_E_ALIGN;  /* 워드 쓰기 */

    uint32_t addr = slot_base(other_slot()) + off;
    if (is_sector_start(addr) && flash_erase_sector_at(addr) != 0)
        return OTA_E_FLASH;
    if (flash_write(addr, buf, len) != 0) return OTA_E_FLASH;

    sha256_update(&g_ota.sha, buf, len);  /* 받으면서 해시 — 다시 읽을 필요 없음 */
    g_ota.next_off = off + len;
    return OTA_OK;
}
재부팅 뒤 이어 받을 때는 해시 문맥이 사라졌으므로 이미 쓴 부분을 Flash 에서 다시 읽어 해시를 이어 가거나 처음부터 받습니다. 섹터 지우기 동안 CPU 가 멈추는 문제(STM32 탭 m11)를 잊지 말고 워치독 · 통신 타임아웃을 그보다 길게 잡으세요.
정리 — 업데이트는 받기 → 검증 → 대기 → 시험 부팅 → 확정의 상태 기계이고, 확정 전에 정해진 횟수만큼 죽으면 롤백합니다. F446 에선 A/B 가 앱 크기를 절반 이하로 줄이므로 외부 Flash 를 보조 슬롯으로 쓰는 절충이 흔합니다.
PROD 04

이미지 무결성 · 서명 — "깨지지 않았나"와 "우리가 만든 것인가"는 다른 질문

부트로더가 앱을 실행하기 전에 묻는 질문은 두 가지입니다. 전송 중에 비트가 깨지지 않았나(무결성), 그리고 우리 회사가 만든 이미지가 맞나(진본성). CRC 는 앞의 질문만, 서명은 둘 다 답합니다. 웹 개발에 빗대면 CRC 는 TCP 체크섬, 서명은 HTTPS 인증서 검증입니다 — 체크섬이 맞는다고 그 서버를 믿지는 않습니다.

무결성 도구 네 가지 — 진할수록 그 일을 잘 합니다 CRC32 SHA-256 ECDSA P-256 Ed25519 우연한 손상 잡음 잡음 잡음 잡음 악의적 변조 못 막음 단독으론 못 막음 막음 막음 출처 증명 — — 개인키 소유자 개인키 소유자 기기에 비밀 필요 아니오 아니오 공개키만 공개키만 덧붙는 크기 4 B 32 B 64 B (r‖s) 64 B 해시는 공격자도 다시 계산해 붙일 수 있습니다 — 변조를 막는 건 개인키뿐입니다 서명 검증 비용은 라이브러리 · 클럭에 따라 수십~수백 ms, 부팅마다 한 번이면 감당할 만합니다
매트릭스 — CRC · SHA-256 은 사고를, 서명은 공격을 막습니다. 서명 검증에 기기 쪽 비밀은 필요 없습니다.

CRC-32 — 스트리밍으로 계산

/* IEEE 802.3 CRC-32 (zlib 과 같은 값). 표 없이 비트 단위 — 부트로더용으로 작게 */
uint32_t crc32_update(uint32_t crc, const uint8_t *p, size_t n)
{
    crc = ~crc;
    while (n--) {
        crc ^= *p++;
        for (int k = 0; k < 8; k++)
            crc = (crc & 1u) ? (crc >> 1) ^ 0xEDB88320u : (crc >> 1);
    }
    return ~crc;
}
/* 검산: crc32_update(0, "123456789", 9) == 0xCBF43926
   나눠 넣어도 같음: crc = crc32_update(0, a, 4); crc = crc32_update(crc, a + 4, 5); */
STM32 의 하드웨어 CRC 유닛은 F4 에서 다항식 0x04C11DB7 고정 · 비트 반전 없음이라 위 값(zlib)과 다르게 나옵니다. PC 쪽 도구와 맞추려면 입력 · 출력 반전을 소프트웨어로 해 주거나, 반전 설정이 있는 F7 · L4 · G4 등의 CRC 유닛을 씁니다.

이미지 헤더 설계 — 0x200 바이트인 이유

typedef struct {
    uint32_t magic;        /* 0x46574844 — 덤프에선 "DHWF"(리틀엔디언) */
    uint16_t hdr_ver;      /* 헤더 형식 버전 — 나중에 필드를 늘릴 여지 */
    uint16_t hdr_size;     /* 0x200 */
    uint32_t img_size;     /* 헤더 뒤 바이트 수 */
    uint32_t fw_ver;       /* (major<<24) | (minor<<16) | patch */
    uint32_t sec_cnt;      /* 롤백 방지 카운터 — 낮으면 거부 */
    uint32_t hw_mask;      /* 지원 보드 리비전 비트 (PROD 12) */
    uint32_t load_addr;    /* 링크된 슬롯 주소 — 엉뚱한 슬롯 방지 */
    uint8_t  sha256[32];   /* 이미지 해시 */
    uint8_t  sig[64];      /* 위 60 바이트에 대한 ECDSA P-256 서명 */
    uint8_t  _pad[388];
} fw_hdr_t;
_Static_assert(sizeof(fw_hdr_t) == 0x200, "헤더는 정확히 0x200");
헤더를 256 B 로 잡으면 앱이 안 돕니다 — 헤더 바로 뒤에 앱의 벡터 테이블이 오는데, F446 의 VTOR 는 0x200 정렬이 필요합니다(PROD 02). 그래서 앱은 ORIGIN = 0x08010200 으로 링크하고, 부트로더는 jump_to_app(base + 0x200) 합니다. MCUboot 가 흔히 헤더를 0x200 으로 잡는 것도 같은 이유입니다.

서명 이미지의 구조 — MCUboot 형식을 보며 🟡 중급

슬롯 하나 안의 서명 이미지 — 왼쪽이 낮은 주소 헤더 0x200 벡터 +0x200 앱 코드 · 상수 · .data 초기값 TLV 해시 · 서명 빈칸 0xFF 트레일러 상태 서명이 덮는 범위 (헤더 + 본문) 헤더: ih_magic 0x96f3b83d · ih_hdr_size · ih_img_size · ih_ver(major.minor.revision+build) TLV: SHA-256 · 서명(ECDSA P-256 / Ed25519 / RSA) · 공개키 해시 · 보안 카운터 트레일러: 스왑 진행 상태 · image_ok(확정) · copy_done · 매직 — 슬롯 맨 끝 고정 위치 트레일러는 서명 밖입니다 — 기기가 업데이트 중에 계속 고쳐 써야 하기 때문입니다 PROD 03 의 상태 기계(PENDING · TRIAL · 확정)를 MCUboot 는 이 트레일러에 기록합니다
메모리 배치 — 서명되는 부분(헤더 + 본문 + TLV 일부)과 기기가 고쳐 쓰는 부분(트레일러)을 나눠 둡니다.

MCUboot 와 imgtool

# 키 만들기 — 개인키는 서명 서버(HSM)에만, 저장소에 커밋 금지
imgtool keygen -k root-ec-p256.pem -t ecdsa-p256
imgtool getpub -k root-ec-p256.pem > keys.c   # 부트로더에 넣을 공개키 C 배열

# 서명 — 헤더 자리 0x200 을 0 으로 채워 앞에 붙이고, TLV 를 뒤에
# --slot-size 는 슬롯 A 192 KB(0x30000) — PROD 02 의 Flash 배치와 같아야 한다
imgtool sign -k root-ec-p256.pem \
  --header-size 0x200 --pad-header \
  --align 4 --slot-size 0x30000 \
  --version 1.5.0+42 --security-counter 7 \
  app.bin app-signed.bin

imgtool verify -k root-ec-p256.pem app-signed.bin
MCUboot 은 Zephyr · ESP-IDF · TF-M 등이 공통으로 쓰는 오픈소스 부트로더입니다. PROD 03 의 스왑 · 덮어쓰기 · 직접 실행 방식과 롤백을 모두 구현해 두었고, 서명 형식도 정해져 있습니다. 자체 부트로더를 처음부터 쓰기 전에 먼저 검토할 후보입니다. Zephyr 에선 앱이 boot_request_upgrade() · boot_write_img_confirmed() 로 PROD 03 의 "대기 · 확정"을 표시합니다.

자체 부트로더라면 — 검증 코드의 모양

/* TinyCrypt (MCUboot 도 쓰는 작은 암호 라이브러리) */
#include "tinycrypt/sha256.h"
#include "tinycrypt/ecc_dsa.h"
extern const uint8_t g_pubkey[64];          /* X‖Y — 부트로더에 상수로 */

bool image_signature_ok(const fw_hdr_t *h)
{
    uint8_t d[32];
    struct tc_sha256_state_struct s;

    /* ① 본문 해시가 헤더에 적힌 값과 같은가 */
    tc_sha256_init(&s);
    tc_sha256_update(&s, (const uint8_t *)h + h->hdr_size, h->img_size);
    tc_sha256_final(d, &s);
    if (memcmp(d, h->sha256, 32) != 0) return false;

    /* ② 헤더 앞 60 바이트(해시 포함)에 대한 서명이 맞는가 */
    tc_sha256_init(&s);
    tc_sha256_update(&s, (const uint8_t *)h, offsetof(fw_hdr_t, sig));
    tc_sha256_final(d, &s);
    return uECC_verify(g_pubkey, d, sizeof d, h->sig, uECC_secp256r1()) == 1;
}
OpenSSL 서명은 DER 형식(가변 길이 70~72 B)입니다. uECC 는 r‖s 64 B 원시 형식을 받으므로 서명 서버에서 변환해야 합니다 (예: 파이썬 cryptography 의 decode_dss_signature). 이걸 놓치면 "서명이 맞는데 검증이 실패"합니다. h->img_size 를 믿기 전에 슬롯 크기 안인지부터 확인하세요.

실무에선 이렇게 — 신뢰 사슬은 맨 아래 고리가 정합니다 🔴 실무

신뢰 사슬 — 각 고리는 다음 고리를 검증한 뒤에만 실행을 넘깁니다 불변 시작점 BL + WRP + RDP2 앱 슬롯 A 새 이미지 슬롯 B 서명 서버 개인키 · HSM 검증 검증 서명 공개키를 품고 스스로는 안 바뀜 버전 · 보안 카운터 낮으면 거부 여기가 털리면 전 제품이 털림 F446 엔 칩이 주는 불변 부트 영역이 없어 부트로더를 WRP · RDP 로 스스로 굳혀야 합니다
신뢰 사슬 — 맨 앞 고리가 바뀔 수 있으면 뒤의 서명 검증은 전부 의미가 없습니다.

사슬을 끊는 흔한 실수

실수결과
부트로더 섹터에 WRP 없음앱의 버그 하나로 부트로더가 지워짐 · 공격자가 교체
RDP 0 출하SWD 로 아무 이미지나 굽고 공개키도 바꿔치기
개인키가 개발자 PC 에퇴사자 노트북 = 전 제품의 마스터 키
롤백 방지 없음서명된 옛 취약 버전으로 내려가는 공격
검증 후 다시 읽어 실행외부 Flash 라면 검증과 실행 사이에 바꿔치기(TOCTOU)

키를 어떻게 다루나

개인키는 서명 서버 하나에만 — CI 가 빌드한 산출물을 서명 서버(클라우드 KMS · HSM)에 보내 서명만 받아 옵니다. 개발 중엔 별도의 개발용 키를 쓰고, 개발 키로 서명된 이미지는 양산 부트로더가 거부하게 합니다.
키 교체 계획을 처음부터 넣어 두세요 — 부트로더에 공개키 슬롯을 2~3개 두고 "폐기된 키 목록"을 업데이트로 보낼 수 있게 하면, 키가 유출됐을 때 현장 장비를 버리지 않아도 됩니다. 새 시리즈(STM32 H5 · U5 등)는 칩 자체의 불변 부트 영역과 TrustZone 으로 이 사슬의 첫 고리를 하드웨어가 맡습니다(PROD 11).
정리 — CRC 는 사고, 서명은 공격을 막습니다. 이미지에 0x200 헤더(버전 · 크기 · 해시 · 서명)를 붙이고, 부트로더는 공개키만 품습니다. 사슬의 첫 고리인 부트로더를 WRP · RDP 로 굳히지 않으면 나머지는 장식입니다 — 처음이라면 MCUboot 부터 검토하세요.
PROD 05

워치독 — "살아 있다"가 아니라 "일하고 있다"를 확인하는 타이머

워치독은 정해진 시간 안에 "갱신(킥)"하지 않으면 칩을 리셋하는 하드웨어 타이머입니다. 쿠버네티스의 liveness probe 와 같은 역할이지만, probe 가 HTTP 200 만 확인하면 의미가 없듯이 아무 데서나 킥하는 워치독도 아무것도 지키지 못합니다. F446 에는 성격이 다른 두 개 — IWDG(독립 · 끌 수 없음)와 WWDG(창 · 너무 일찍 킥해도 리셋)가 있습니다. RTOS 에서의 감시 태스크 패턴은 FreeRTOS 탭 r13 에 있고, 여기서는 타이밍 계산과 제품 운영 관점을 봅니다.

IWDG 카운터 — 0.5 s 마다 킥하다가 2 s 에 멈추면 1000 0 0 1 s 2 s 3 s 4 s 5 s 6 s 킥 — 1000 으로 복귀 메인 루프 멈춤 리셋 (명목 2.0 s 뒤) LSI 오차 범위 1.36~3.76 s LSI 는 17~47 kHz 로 흔들립니다 — 킥 주기는 가장 짧은 경우(1.36 s)보다 넉넉히 짧게
라인 — 킥이 끊긴 순간부터 타임아웃만큼 뒤에 리셋됩니다. 그 타임아웃은 LSI 오차 때문에 3배 가까이 흔들립니다.

IWDG 타임아웃 계산

타임아웃 = (4 × 2^PR) × (RLR + 1) / f_LSI
           └ 분주 4·8·…·256  └ 12비트 0~4095

예) 분주 64 (PR=4), RLR = 999
  f_LSI = 32 kHz (전형) → 64 × 1000 / 32000 = 2.00 s
  f_LSI = 17 kHz (최소) → 64 000 / 17 000  = 3.76 s
  f_LSI = 47 kHz (최대) → 64 000 / 47 000  = 1.36 s

최대: 분주 256, RLR 4095 → 256 × 4096 / 32000 = 32.8 s
최소: 분주 4,   RLR 0    → 4 / 32000       = 0.125 ms
IWDG_HandleTypeDef hiwdg;

void wdg_init(void)
{
    hiwdg.Instance       = IWDG;
    hiwdg.Init.Prescaler = IWDG_PRESCALER_64;   /* 32 kHz / 64 = 2 ms 틱 */
    hiwdg.Init.Reload    = 999;                 /* (999 + 1) × 2 ms = 2.0 s */
    HAL_IWDG_Init(&hiwdg);                     /* 이 순간 시작 — 리셋 전엔 못 끔 */
}

IWDG vs WWDG

IWDGWWDG
클럭LSI (메인 클럭과 독립)PCLK1 / 4096 / 분주
클럭이 죽으면그래도 리셋 ✓같이 멈춤 ✗
끄기불가 (리셋까지)불가 (리셋까지)
너무 일찍 킥괜찮음 (F4 는 창 없음)리셋
조기 경고없음EWI 인터럽트 (0x40)
정밀도LSI 라 거침PCLK 라 정확
용도기본 안전망"너무 빨리 도는" 폭주까지 잡기
양산품은 IWDG 를 기본으로 켭니다. 옵션 바이트 WDG_SW = 0(하드웨어 워치독)으로 두면 전원 켜자마자 IWDG 가 돌아 부트로더 단계의 멈춤까지 잡습니다. 대신 부트로더도 Flash 를 지우는 동안 킥해야 합니다(섹터 지우기 최대 2 s 안팎).

어디서 킥하나 — 모두가 체크인했을 때만 🟡 중급

체크인 후 킥 — 점은 체크인, 세로선은 감시 함수의 판정 (0.5 s 마다) 0 0.5 s 1.0 s 1.5 s 2.0 s 센서 (100 ms) 통신 (250 ms) 0.9 s 데드락 — 이후 체크인 없음 제어 (50 ms) 감시 → 킥 셋 다 → 킥 셋 다 → 킥 통신 없음 통신 없음 센서 · 제어는 멀쩡해도 킥은 멈춥니다 — 타임아웃 뒤 리셋, 빠진 비트는 .noinit 에 기록
타임라인 — 워치독을 킥하는 조건은 "모든 작업이 이번 창 안에 체크인했다"입니다. 창은 가장 느린 작업 주기보다 길게.
❌ 타이머 인터럽트에서 킥
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *h)
{
    HAL_IWDG_Refresh(&hiwdg);   /* 메인 루프가 데드락이어도
                                   인터럽트는 계속 돎 → 영원히 킥 */
}
✅ 메인 루프의 감시 함수에서, 체크인을 모아서
enum { CK_SENSOR = 1u << 0, CK_COMM = 1u << 1,
       CK_CTRL = 1u << 2, CK_ALL = 0x7u };
static volatile uint32_t g_ck;
__attribute__((section(".noinit"))) uint32_t g_wdg_missing;

void wdg_checkin(uint32_t bit)          /* ISR 에서도 호출 가능 */
{
    uint32_t pm = __get_PRIMASK();
    __disable_irq();
    g_ck |= bit;                        /* 읽고-고치고-쓰기를 보호 */
    __set_PRIMASK(pm);                  /* 원래 상태로 (중첩 안전) */
}

void wdg_service(void)                  /* 메인 루프에서 매번 */
{
    static uint32_t t0;
    if ((uint32_t)(HAL_GetTick() - t0) < 500u) return;
    t0 = HAL_GetTick();

    uint32_t pm = __get_PRIMASK();
    __disable_irq();
    uint32_t got = g_ck;  g_ck = 0u;    /* 꺼내고 비우기를 한 번에 */
    __set_PRIMASK(pm);

    if (got == CK_ALL) HAL_IWDG_Refresh(&hiwdg);
    else               g_wdg_missing = CK_ALL & ~got;  /* 리셋 뒤 보고 */
}

실무에선 이렇게 — WWDG 창 · 디버그 정지 · 잠자는 동안 🔴 실무

WWDG 창 — PCLK1 45 MHz · 분주 8 · Counter 0x7F · Window 0x50 갱신 금지 — 여기서 갱신하면 리셋 (0x7F → 0x50, 47틱) 갱신 창 0 34.2 ms 46.6 ms 리셋 0x40 조기 경고 (EWI) — 1틱 728 µs 남음 한 틱 = 4096 × 8 / 45 MHz = 728 µs · 리셋까지 64틱 = 46.6 ms 너무 빨리 도는 루프(타이밍 버그 · 조건 누락으로 일을 건너뜀)까지 잡는 것이 창의 의미입니다
구간 — WWDG 는 늦어도, 일러도 리셋합니다. 주기가 정확해야 하는 제어 루프의 감시에 씁니다.

WWDG 설정과 조기 경고

WWDG_HandleTypeDef hwwdg;

void wwdg_init(void)                    /* SYSCLK 180 MHz, APB1 /4 = 45 MHz 가정 */
{
    hwwdg.Instance       = WWDG;
    hwwdg.Init.Prescaler = WWDG_PRESCALER_8;
    hwwdg.Init.Window    = 0x50;
    hwwdg.Init.Counter   = 0x7F;
    hwwdg.Init.EWIMode   = WWDG_EWI_ENABLE;
    HAL_WWDG_Init(&hwwdg);             /* MspInit 에서 클럭 · NVIC 활성 */
}

/* 40 ms 주기 제어 루프의 끝에서 — 34.2~46.6 ms 창 안 */
HAL_WWDG_Refresh(&hwwdg);

/* 리셋 728 µs 전 — 할 수 있는 건 아주 짧은 기록뿐 */
void HAL_WWDG_EarlyWakeupCallback(WWDG_HandleTypeDef *h)
{
    g_crash.reason = CRASH_WWDG;        /* .noinit 구조체 (PROD 09) */
    g_crash.pc     = last_loop_marker;
}

디버깅 · 잠 · 업데이트와의 충돌

#if !defined(FW_PROD)
__HAL_DBGMCU_FREEZE_IWDG();   /* 브레이크포인트에 멈춰 있는 동안 정지 */
__HAL_DBGMCU_FREEZE_WWDG();
#endif
상황처방
디버거로 멈춤DBGMCU freeze — 개발 빌드만 (PROD 01 의 부팅 점검이 해제)
Stop 모드로 60 s 잠F4 는 Stop 중에도 IWDG 가 돕니다 (멈추는 옵션 없음 · L4 · G4 엔 있음). RTC 웨이크업으로 깨어 킥 — 깨우는 주기는 최대 설정(전형 32.8 s)이 LSI 가 빠를 때(47 kHz) 줄어드는 약 22 s 보다 짧게
Flash 섹터 지우기128 KB 지우기는 최대 2 s 안팎 — 타임아웃을 그보다 길게, 지우기 직전에 킥
긴 서명 검증해시 계산 루프 중간에 킥 (부트로더)
워치독 리셋은 "고친 것"이 아닙니다. 리셋 원인과 g_wdg_missing 을 다음 부팅에 서버로 올리지 않으면 버그가 매일 수천 번 일어나도 아무도 모릅니다(PROD 06 · 09).
정리 — IWDG 타임아웃은 (4 × 2PR) × (RLR + 1) / fLSI 이고 LSI 오차로 3배 가까이 흔들립니다. 킥은 모든 작업이 체크인했을 때만, 인터럽트가 아니라 메인 루프의 감시 함수에서 합니다. WWDG 는 너무 이른 킥까지 잡는 정밀 감시용입니다.
PROD 06

리셋 원인 · 브라운아웃 — "가끔 재부팅된다"를 숫자로 바꾸기

현장에서 가장 흔한 불만은 "가끔 꺼졌다 켜진다"입니다. 원인은 워치독일 수도, 폴트일 수도, 전원이 잠깐 처진 것일 수도 있습니다. 다행히 STM32 는 왜 리셋됐는지를 RCC_CSR 레지스터에 남깁니다. 이 값을 부팅마다 읽어 기록하고 서버로 올리는 것만으로 서버의 종료 코드(exit code) 대시보드 같은 것이 생깁니다. 플래그 읽기의 기본은 하드웨어 기초 탭 h11 에 있고, 여기서는 판정 순서 · 브라운아웃 설정 · 리셋 루프 대응을 다룹니다.

리셋 원인 판정 — 위에서부터 묻습니다 (순서가 틀리면 오판) WWDGRSTF 또는 IWDGRSTF ? 예 워치독 — 어딘가 멈춤 · 빠진 체크인 비트(PROD 05) SFTRSTF ? 예 소프트웨어 — .noinit 이유 코드로 OTA · 폴트 · 명령 구분 PORRSTF ? 예 전원 투입 — 같이 켜진 BOR · PIN 플래그는 무시 BORRSTF ? 예 브라운아웃 — 전압이 처짐 · 배터리 · 돌입 전류 의심 PINRSTF ? 예 NRST 핀 — 리셋 버튼 · 외부 감시 IC · 디버거 아니오 — LPWRRSTF 면 저전력 진입 리셋(nRST_STOP 옵션), 그 밖은 "알 수 없음"으로 기록
결정 흐름도 — 내부 리셋도 NRST 핀을 끌어내리므로 PIN 은 맨 마지막, 전원 투입 때 같이 켜지는 BOR 때문에 POR 을 BOR 보다 먼저 봅니다.

플래그를 비트마스크로 남기기

typedef enum {
    RST_LPWR = 1u << 0, RST_WWDG = 1u << 1, RST_IWDG = 1u << 2,
    RST_SFT  = 1u << 3, RST_POR  = 1u << 4, RST_BOR  = 1u << 5,
    RST_PIN  = 1u << 6,
} rst_flag_t;

/* 맨 처음 한 번 — 문자열 하나가 아니라 "켜진 것 전부"를 남긴다 */
uint32_t reset_flags_take(void)
{
    const uint32_t csr = RCC->CSR;
    uint32_t f = 0u;
    if (csr & RCC_CSR_LPWRRSTF) f |= RST_LPWR;
    if (csr & RCC_CSR_WWDGRSTF) f |= RST_WWDG;
    if (csr & RCC_CSR_IWDGRSTF) f |= RST_IWDG;
    if (csr & RCC_CSR_SFTRSTF)  f |= RST_SFT;
    if (csr & RCC_CSR_PORRSTF)  f |= RST_POR;
    if (csr & RCC_CSR_BORRSTF)  f |= RST_BOR;
    if (csr & RCC_CSR_PINRSTF)  f |= RST_PIN;
    RCC->CSR |= RCC_CSR_RMVF;   /* 지우지 않으면 다음 부팅에 섞임 */
    return f;
}
원본 비트를 다 남기고 해석은 서버에서 — 펌웨어에서 문자열 하나로 줄이면 판정 규칙을 고칠 때마다 업데이트가 필요합니다. 비트마스크 1 바이트와 부팅 번호를 올리면 대시보드에서 "IWDG 비율이 v1.5.0 부터 3배" 같은 걸 바로 봅니다.

부트로더가 있으면 누가 읽나

먼저 읽는 쪽이 지우면 나중 쪽은 못 봅니다. 부트로더가 RMVF 로 지우면 앱에는 항상 "원인 없음"이 보입니다. 규칙을 하나로 정하세요 — 보통 부트로더가 읽고 지우되, 값을 .noinit 의 공유 구조체에 넘겨 줍니다(PROD 02 와 같은 주소).
플래그 조합읽는 법
POR + BOR + PIN평범한 전원 투입
BOR + PIN전원이 BOR 레벨 아래로 처졌다가 회복
IWDG + PIN워치독 — PIN 은 내부 리셋 펄스
SFT + PINNVIC_SystemReset() — 의도? 폴트?
PIN 만버튼 · 외부 감시 IC · 디버거의 리셋

브라운아웃 — 죽지도 살지도 않은 전압 🟡 중급

전압이 처지는 순간 — BOR 이 없으면 CPU 가 "반쯤 살아" 돕니다 3.3 V 2.7 V BOR 레벨 3 1.7 V POR / PDR (BOR 꺼짐일 때 유일한 기준) 2.3 V 모터 기동 · 무선 송신 · 약한 배터리 BOR3: 리셋 유지 BOR 꺼짐: 180 MHz 그대로 — Flash 읽기 · 쓰기 불안정 공장 출하 옵션 바이트는 BOR 꺼짐 — 양산에선 레벨을 정해 공정에서 써 넣습니다
라인 — 2.7 V 아래로 내려간 짧은 순간이 가장 위험합니다. BOR 은 그 순간을 "깨끗한 리셋"으로 바꿉니다.

BOR 레벨 고르기

BOR_LEV 비트레벨하강 기준(약)
00레벨 32.7 V
01레벨 22.4 V
10레벨 12.1 V
11꺼짐 (공장 기본)POR/PDR 만
# 공정에서 — 비트 값 0 이 "가장 높은 레벨"인 점에 주의
STM32_Programmer_CLI -c port=SWD -ob BOR_LEV=0
# 펌웨어에서 쓰는 HAL 코드는 STM32 탭 m11 참고
3.3 V 전원이면 레벨 3 이 보통의 선택입니다 — F4 의 Flash 쓰기 32비트 병렬(전압 범위 3)과 180 MHz 대기 상태 설정이 2.7 V 이상을 가정하기 때문입니다. 정확한 문턱값 · 히스테리시스는 데이터시트의 BOR 표를 확인하세요.

브라운아웃이 잦다면 — 하드웨어 쪽 질문

의심확인
배터리 내부 저항CR2032 는 수 mA 펄스에도 전압이 크게 처짐 — 무선 송신 순간 측정
돌입 전류모터 · 릴레이 · 무선 모듈 켜는 순간 오실로스코프로 VDD
벌크 커패시터송신 펄스를 버틸 만큼 큰가 (수십~수백 µF)
레귤레이터 여유입력이 낮아질 때 드롭아웃 전압
펌웨어 쪽 완화책 — 무선 송신과 Flash 쓰기를 같은 순간에 하지 않기, 모터 기동을 소프트 스타트로, PVD(프로그래머블 전압 감지) 인터럽트로 "곧 꺼진다"를 미리 알아 진행 중인 쓰기를 마무리하기.

실무에선 이렇게 — 리셋 루프 감지와 안전 모드 🔴 실무

연속 비정상 부팅을 세기

#define BI_MAGIC 0x5AFEB007u
typedef struct {
    uint32_t magic, magic_inv;   /* 둘 다 맞아야 유효 — POR 직후 쓰레기 걸러 냄 */
    uint32_t bad_boots;          /* 연속 비정상 부팅 수 */
    uint32_t sw_reason;          /* NVIC_SystemReset 직전에 적은 이유 */
} boot_info_t;
__attribute__((section(".noinit"))) boot_info_t g_bi;

void boot_info_update(uint32_t rst)
{
    if (g_bi.magic != BI_MAGIC || g_bi.magic_inv != ~BI_MAGIC || (rst & RST_POR)) {
        g_bi = (boot_info_t){ .magic = BI_MAGIC, .magic_inv = ~BI_MAGIC };
    }
    const bool bad = (rst & (RST_IWDG | RST_WWDG)) ||
                     ((rst & RST_BOR) && !(rst & RST_POR)) ||   /* 전원 투입 때의 BOR 은 정상 */
                     ((rst & RST_SFT) && g_bi.sw_reason == SWR_FAULT);
    g_bi.bad_boots = bad ? g_bi.bad_boots + 1u : 0u;
    g_bi.sw_reason = SWR_NONE;

    if (g_bi.bad_boots >= 5u) safe_mode();     /* 돌아오지 않음 */
}

/* 60 s 동안 문제없이 돌면 "건강함"으로 */
void boot_info_mark_healthy(void) { g_bi.bad_boots = 0u; }

안전 모드가 하는 일

켠다끈다
통신 · OTA 수신 · 로그 업로드 · LED 로 상태 표시센서 드라이버 · 모터 · 히터 · 사용자 기능 · 실험 기능
안전 모드는 "고쳐 받을 수 있는 최소 상태"입니다. 리셋 루프의 원인은 보통 특정 주변장치 드라이버나 깨진 설정이므로, 그것들을 건너뛰고 통신만 살리면 서버가 원인 로그를 받고 수정 펌웨어를 보낼 수 있습니다. 안드로이드의 복구 모드와 같은 발상입니다.
.noinit 카운터는 전원을 껐다 켜면 사라집니다. 전원이 반복해서 끊기는 루프(브라운아웃이 POR 까지 내려가는 경우)까지 세려면 PROD 07 의 Flash 저장이나 F446 의 백업 레지스터 · 4 KB 백업 SRAM(VBAT 로 유지)을 씁니다. PROD 03 의 A/B 롤백(TRIAL 3회)과 이 카운터는 서로 다른 층입니다 — 롤백이 먼저, 그래도 안 되면 안전 모드.
정리 — 부팅마다 RCC_CSR 을 읽고 지우고, 비트 전부를 부팅 번호와 함께 기록합니다. 판정은 워치독 → 소프트웨어 → POR → BOR → PIN 순. 양산품은 BOR 레벨 3을 공정에서 설정하고, 연속 비정상 부팅이 쌓이면 안전 모드로 들어가 고쳐 받을 수 있게 합니다.
PROD 07

설정 · 데이터 저장 — 어느 순간 꺼져도 이전 값이나 새 값, 둘 중 하나

F446 내부 Flash 에 쓰는 기본(섹터 지우기 · 워드 쓰기 · 지우는 동안 CPU 정지 · A/B 두 섹터 교대)은 STM32 탭 m11 에서 다뤘습니다. 제품에서는 두 가지가 더 문제가 됩니다. 수명 — 섹터당 약 1만 번 지우면 끝이고, 전원 차단 — 쓰는 도중 꺼진 반쪽 기록을 믿으면 안 됩니다. 데이터베이스의 WAL(로그 선행 기록) + 커밋 레코드와 같은 원리로 둘을 함께 풉니다.

기록 칸 하나의 일생 — 읽는 쪽은 "확정"만 믿습니다 지워짐 전부 0xFF 본문 기록 완료 표시 없음 확정 읽기 대상 지난 기록 더 큰 순번 있음 반쪽 무시 · 재사용 불가 ① 본문 · CRC ② 완료 워드 새 기록 확정 ① 도중 정전 섹터 통째로 지우기 (교대할 때) ② 도중 정전은? 워드 하나라 "확정 전"이나 "확정" — 이전 확정 기록이 그대로 살아 있음 순서가 전부입니다 — 완료 표시를 먼저 쓰면 반쪽 본문이 "확정"으로 읽힙니다
상태 기계 — 본문을 다 쓴 뒤 완료 워드를 마지막에 씁니다. 0xFF 는 쓰기로만 0 이 되므로 완료 표시는 되돌아가지 않습니다.

덧붙여 쓰는 기록 — 지우기를 수백 분의 1 로

#define REC_COMMIT 0x600DC0DEu
typedef struct {
    uint32_t seq;        /* 쓸 때마다 +1 — 가장 큰 확정 기록이 현재 값 */
    uint16_t len;
    uint16_t ver;        /* 설정 구조체 형식 버전 — 업데이트 뒤 변환용 */
    uint8_t  data[48];
    uint32_t crc;        /* seq ~ data 의 CRC32 (PROD 04) */
    uint32_t commit;     /* ② 맨 마지막에 쓰는 완료 표시 */
} rec_t;
_Static_assert(sizeof(rec_t) == 64, "16 KB 섹터에 256 칸");

int cfg_append(const void *d, uint16_t len)
{
    if (len > sizeof(((rec_t *)0)->data)) return -1;
    rec_t r;
    memset(&r, 0, sizeof r);
    r.seq = g_last_seq + 1u;  r.len = len;  r.ver = CFG_VER;
    memcpy(r.data, d, len);
    r.crc = crc32_update(0u, (const uint8_t *)&r, offsetof(rec_t, crc));

    uint32_t a = next_blank_slot();         /* 64 B 전부 0xFF 인 칸 */
    if (a == 0u) a = switch_sector();       /* 다른 섹터를 지우고 첫 칸 */
    if (flash_write(a, &r, offsetof(rec_t, commit)) != 0) return -2;   /* ① */
    const uint32_t c = REC_COMMIT;
    if (flash_write(a + offsetof(rec_t, commit), &c, 4u) != 0) return -3;  /* ② */
    g_last_seq = r.seq;
    return 0;
}

읽을 때의 규칙

/* 두 섹터(S2 · S3)의 모든 칸을 훑어 가장 큰 seq 의 확정 기록 */
const rec_t *cfg_latest(void)
{
    const rec_t *best = NULL;
    for (int s = 0; s < 2; s++) {
        const rec_t *p = (const rec_t *)sector_base(s);
        for (uint32_t i = 0; i < 256u; i++, p++) {
            if (p->commit != REC_COMMIT) continue;          /* 반쪽 · 빈칸 */
            if (crc32_update(0u, (const uint8_t *)p, offsetof(rec_t, crc)) != p->crc)
                continue;                                    /* 비트 손상 */
            if (best == NULL || p->seq > best->seq) best = p;
        }
    }
    return best;   /* NULL 이면 공장 기본값 */
}
빈칸 판정은 64 B 전부를 봐야 합니다. 첫 워드만 보고 0xFF 라서 "비었다"고 하면, 본문 중간까지 쓰다 꺼진 칸에 다시 쓰게 되고 F4 는 이미 0 인 비트를 1 로 못 되돌리므로 엉뚱한 값이 섞입니다.
섹터 교대 순서 — 다른 섹터를 지우고, 최신 기록을 거기 첫 칸에 쓴 다음에야 옛 섹터를 버립니다. 지우는 도중 꺼져도 옛 섹터에 최신 값이 남아 있습니다. 부팅 때 g_last_seq 는 cfg_latest()->seq 로 복원합니다.

수명 계산 — 1분에 한 번 저장하면 언제 닳나 🟡 중급

1분에 1회 저장 · 섹터당 지우기 1만 회 — 가로축은 로그 눈금입니다 1일 1달 1년 10년 100년 128 KB 섹터 매번 지움 6.9 일 (1만 회) 두 섹터 통째 교대 13.9 일 (2만 회) 64 B 덧붙이기 · 16 KB × 2 9.7 년 (512만 회) EEPROM 에뮬 · 변수 10개 155 년 한 번 지울 때 몇 번 쓸 수 있나가 수명을 정합니다 — 256 칸이면 256배 계산: 수명(회) = 섹터 수 × 1만 × 섹터당 기록 수 · 1년 = 525,600 분
막대(로그) — 같은 Flash 라도 지우기 한 번에 몇 번 쓰느냐로 수명이 일주일에서 백 년까지 갈립니다.

EEPROM 에뮬레이션 — ST 응용 노트 방식

페이지(섹터) 두 개, 각 페이지 맨 앞에 상태: ERASED · RECEIVE_DATA · VALID_PAGE

VALID 페이지에 (가상 주소 16비트, 값 16비트) 4 B 쌍을 뒤에 덧붙임
  [VALID][0x0001=25][0x0002=300][0x0001=26][0x0001=27] ...
                                          └ 같은 주소면 뒤의 것이 최신

페이지가 차면: 다른 페이지를 RECEIVE_DATA 로 → 각 주소의 최신 값만 복사
              → 옛 페이지 지움 → 새 페이지를 VALID 로
(F4 는 AN3969, 시리즈마다 응용 노트 · X-CUBE-EEPROM 이 따로 있음)

수명: 16 KB 페이지 ≈ 4 095 칸 − 변수 10개 복사분 = 4 085 회 / 지우기
      × 2 페이지 × 1만 회 ≈ 8 170만 회 → 1분에 1회면 약 155 년
위의 64 B 덧붙이기와 원리가 같고, 기록 단위를 변수 하나(4 B)로 쪼갠 것이 차이입니다. 값이 자주 바뀌는 카운터 몇 개에 강하고, 큰 구조체를 통째로 저장하는 데는 64 B 기록이 단순합니다.

무엇을 어디에

데이터바뀌는 빈도저장소
시리얼 · 보정값공장에서 1회OTP · 전용 섹터 (PROD 10)
사용자 설정하루 몇 번내부 Flash 덧붙이기
카운터 · 누적량분 단위EEPROM 에뮬 · 백업 SRAM
측정 로그 · 파일초 단위 · 대용량외부 SPI Flash + littlefs
OTA 이미지가끔 · 수백 KB슬롯 B · 외부 Flash (PROD 03)
"자주 저장"의 함정 — 슬라이더를 움직일 때마다 저장하면 하루에 수천 번이 됩니다. 값이 바뀐 뒤 몇 초 조용해지면 한 번 저장하는 디바운스, 그리고 PVD(PROD 06)로 "전원이 꺼지려 할 때 한 번"이 표준 처방입니다.

실무에선 이렇게 — 외부 Flash 와 littlefs 🔴 실무

W25Q128(16 MB · 4 KB 지우기)에 littlefs 붙이기

#include "lfs.h"

static int bd_read(const struct lfs_config *c, lfs_block_t b,
                   lfs_off_t off, void *buf, lfs_size_t n)
{ return w25q_read(b * c->block_size + off, buf, n) ? LFS_ERR_IO : 0; }

static int bd_prog(const struct lfs_config *c, lfs_block_t b,
                   lfs_off_t off, const void *buf, lfs_size_t n)
{ return w25q_program(b * c->block_size + off, buf, n) ? LFS_ERR_IO : 0; }

static int bd_erase(const struct lfs_config *c, lfs_block_t b)
{ return w25q_erase_4k(b * c->block_size) ? LFS_ERR_IO : 0; }

static int bd_sync(const struct lfs_config *c) { (void)c; return 0; }

static const struct lfs_config g_lfs_cfg = {
    .read = bd_read, .prog = bd_prog, .erase = bd_erase, .sync = bd_sync,
    .read_size      = 16,
    .prog_size      = 256,     /* W25Q 페이지 프로그램 단위 */
    .block_size     = 4096,    /* 섹터 지우기 단위 */
    .block_count    = 4096,    /* 16 MB / 4 KB */
    .cache_size     = 256,
    .lookahead_size = 32,
    .block_cycles   = 500,     /* 이만큼 쓰면 블록을 옮겨 마모 분산 */
};

파일처럼 쓰고, 닫을 때 원자적으로

lfs_t lfs;
lfs_file_t f;
uint32_t boots = 0;

if (lfs_mount(&lfs, &g_lfs_cfg) != 0) {
    fs_mount_failed();          /* ⚠ 여기서 자동 format 하지 말 것 */
    return;                     /* 마운트 안 된 lfs 로 아래 파일 함수를 부르면 안 됨 */
}
lfs_file_open(&lfs, &f, "boot_count", LFS_O_RDWR | LFS_O_CREAT);
lfs_file_read(&lfs, &f, &boots, sizeof boots);
boots++;
lfs_file_rewind(&lfs, &f);
lfs_file_write(&lfs, &f, &boots, sizeof boots);
lfs_file_close(&lfs, &f);     /* 이 순간 반영 — 그 전에 꺼지면 이전 값 */
littlefs 는 쓰기 시 복사(copy-on-write)와 메타데이터 쌍으로 정전 안전 · 마모 분산을 파일 시스템 수준에서 해 줍니다. RAM 은 캐시 몇 개 분량(위 설정이면 1 KB 안팎)이면 됩니다. FAT 는 정전에 약하므로 SD 카드처럼 PC 와 주고받을 때만 씁니다.
"마운트 실패 → 포맷" 은 개발용 코드입니다. 현장에서 SPI 배선 접촉 불량 한 번으로 마운트가 실패하면 사용자 데이터가 전부 지워집니다. 포맷은 공장 공정(PROD 10)에서만, 현장에선 오류를 기록하고 읽기 전용으로 버팁니다. F446 내부 Flash(128 KB 섹터)는 블록이 너무 커서 littlefs 와 잘 맞지 않습니다.
정리 — 정전 안전은 본문 → 완료 표시 순서와 순번 + CRC로, 수명은 지우기 한 번에 여러 번 쓰는 덧붙이기로 해결합니다. 작은 설정은 내부 Flash 기록, 자주 바뀌는 값은 EEPROM 에뮬레이션, 대용량은 외부 Flash + littlefs. 현장에서 자동 포맷은 금지입니다.
PROD 08

저전력 제품 설계 — 전력 예산표로 배터리 수명을 먼저 계산한다

Sleep · Stop · Standby 모드에 들어가는 법은 STM32 탭 m13 · AVR 탭 a12 · FreeRTOS 탭 r12 에 있습니다. 제품에서 묻는 것은 다른 질문입니다 — "이 배터리로 몇 년 가나?" 답은 평균 전류 하나로 정해지고, 평균 전류는 모드별 전류 × 그 모드에 머무는 시간 비율의 합입니다. 클라우드 비용을 "인스턴스 단가 × 가동 시간"으로 미리 계산하듯, 배터리도 설계 단계에서 예산표를 먼저 씁니다.

센서 노드의 전류 — 60 s 마다 한 번 깨어납니다 (축은 비례가 아님) 4 µA 3 mA 8 mA 측정 50 ms 송신 4 ms 잠 59.946 s — Stop · LDO · 센서 대기 합계 주기 60 s 깨어 있는 시간은 0.09% — 그런데도 전하의 43% 를 씁니다 (아래 비율 막대)
파형 — 짧고 높은 펄스와 길고 낮은 바닥, 둘의 넓이(전하)를 더한 것이 배터리를 씁니다.

전력 예산표 — 한 주기의 전하를 더한다

상태전류시간 / 60 s전하
잠4 µA59.946 s239.8 µC
측정3 mA50 ms150.0 µC
무선 송신8 mA4 ms32.0 µC
합60 s421.8 µC
평균 전류 = 421.8 µC / 60 s = 7.03 µA

CR2032 220 mAh : 0.220 / 7.03e-6 = 31 296 h ≈ 3.6 년 (이론)
                 실사용 80% 가정        ≈ 2.9 년
2 × AA 2500 mAh: ≈ 40.6 년 (이론) → 실제 상한은 자가 방전 · 유효 기간(10년 안팎)
잠 4 µA 는 저전력 시리즈(L4 · U5 급) MCU + LDO 정지 전류 + 센서 대기를 더한 가정값입니다. F446 은 고성능 시리즈라 Stop 전류가 저전력 시리즈보다 한 자릿수 이상 큽니다 — 배터리 제품이면 MCU 선택부터 다시 합니다.

같은 계산을 스크립트로 — 가정이 바뀔 때마다 다시

# power_budget.py — 1 주기(60 s) 동안의 전하를 더해 평균 전류로
states = [  # (이름, 전류 A, 시간 s)
    ("measure",  3e-3, 0.050),
    ("radio_tx", 8e-3, 0.004),
]
PERIOD  = 60.0
SLEEP_I = 4e-6                      # MCU Stop + LDO Iq + 센서 대기 합계

awake = sum(t for _, _, t in states)
q = sum(i * t for _, i, t in states) + SLEEP_I * (PERIOD - awake)
avg = q / PERIOD
print(f"평균 {avg * 1e6:.2f} uA")
for name, mah in [("CR2032", 220), ("2xAA", 2500)]:
    h = mah / 1000 / avg
    print(f"{name}: 이론 {h / 8760:.1f} 년, 80% 가정 {0.8 * h / 8760:.1f} 년")

# 출력
# 평균 7.03 uA
# CR2032: 이론 3.6 년, 80% 가정 2.9 년
# 2xAA: 이론 40.6 년, 80% 가정 32.5 년
이 표를 요구사항 문서에 넣고 DVT 실측값으로 갱신하세요. 마케팅이 "주기를 10 s 로"라고 할 때 바로 답할 수 있습니다 — 10 s 주기면 평균 22.2 µA, CR2032 로 약 1.1 년입니다.

어디에 쓰이나 — 주기를 줄이면 깨어 있는 비용이 지배 🟡 중급

한 주기 전하의 구성 — 가로 폭이 비율입니다 60 s 주기 잠 57% 측정 36% 8% 7.0 µA 10 s 주기 잠 18% 측정 68% 송신 14% 22.2 µA 잠 (4 µA) 측정 (3 mA × 50 ms) 송신 (8 mA × 4 ms) 60 s 에서는 잠 전류를, 10 s 에서는 측정 시간을 줄이는 것이 효과가 큽니다 측정 50 ms 를 20 ms 로 줄이면 10 s 주기의 평균이 22.2 → 13.2 µA 로 내려갑니다
비율 막대 — 가장 큰 조각부터 줄입니다. 어느 조각이 큰지는 깨우는 주기에 따라 바뀝니다.

측정 도구 — 성격만

도구성격
멀티미터평균값만 · 펄스를 놓침 · 전류 범위를 바꾸는 순간 전압 강하로 MCU 가 리셋되기도
Nordic PPK2저가 전류 프로파일러 · 전원 공급 겸 측정 · µA 바닥과 mA 펄스를 한 화면에 · 입문 표준
Joulescope더 넓은 범위를 빠르게 자동 전환 · 정확도 높음 · 고가 · 제품 인증용 측정에
Otii Arc 등전원 공급 + 측정 + 스크립트 자동화 · 배터리 방전 프로파일 흉내
µCurrent + 오실로스코프션트 증폭기 — 파형을 직접 봄 · 손이 많이 감
Nucleo 에서 MCU 전류만 재기 — 보드의 IDD 점퍼(JP6)를 빼고 그 자리에 측정기를 겁니다. ST-LINK 쪽 전류가 섞이지 않습니다. 디버거가 붙어 있으면 디버그 회로가 켜진 채라 잠 전류가 크게 나오므로, 측정할 땐 디버거를 떼고 전원부터 다시 넣습니다.

흔한 누설 원인 — 예산을 날리는 것들

원인크기 (3.3 V)처방
떠 있는 입력 핀수~수백 µA안 쓰는 핀은 아날로그 모드
눌린 버튼의 10 kΩ 풀업330 µA풀업 값 키우기 · 잠들기 전 풀 끄기
전원 표시 LED1~5 mA양산 BOM 에서 빼거나 GPIO 로
레귤레이터 정지 전류AMS1117 급 수 mA · 저전력 LDO 는 µA 이하Iq 를 보고 고르기
배터리 측정 분압기100 kΩ + 100 kΩ 에 3 V 면 15 µAMOSFET 으로 측정할 때만 연결
꺼진 칩으로 역전류수십 µA~전원이 꺼진 주변 칩 쪽 핀은 Low 또는 아날로그
예산 7 µA 에 LED 1 mA 하나가 섞이면 평균 1 007 µA — CR2032 는 약 9 일 만에 끝납니다. 풀업 330 µA 하나면 약 27 일. 누설은 "조금 더"가 아니라 수명 단위가 바뀌는 문제입니다.

실무에선 이렇게 — 잠들기 전 정리와 배터리의 현실 🔴 실무

안 쓰는 핀을 아날로그로 — 단, SWD 는 빼고

static void gpio_park_unused(void)
{
    GPIO_InitTypeDef g = {0};
    g.Mode = GPIO_MODE_ANALOG;           /* 입력 버퍼를 끊어 떠 있는 핀의 관통 전류 제거 */
    g.Pull = GPIO_NOPULL;

    __HAL_RCC_GPIOA_CLK_ENABLE();
    g.Pin = GPIO_PIN_All & ~(GPIO_PIN_13 | GPIO_PIN_14 | USED_PA_PINS);
    HAL_GPIO_Init(GPIOA, &g);            /* PA13 · PA14 = SWDIO · SWCLK — 건드리면 디버거 끊김 */

    __HAL_RCC_GPIOB_CLK_ENABLE();
    g.Pin = GPIO_PIN_All & ~USED_PB_PINS;
    HAL_GPIO_Init(GPIOB, &g);
    /* … C · D · H 도 같은 방식. 쓰는 핀이 하나라도 있는 포트(A · B)의 클럭은 켜 둔다 */
    __HAL_RCC_GPIOH_CLK_DISABLE();       /* 핀을 하나도 안 쓰는 포트만 클럭을 끈다 (예: H) */
}
양산 빌드에서 SWD 핀까지 아날로그로 돌리면 몇 µA 를 더 아낄 수 있지만, 현장 불량품을 회수했을 때 디버거가 붙지 않습니다. RDP(PROD 11)로 이미 잠겨 있다면 득실을 따져 결정합니다.

배터리 용량은 "조건부"입니다

현실설계에 반영
CR2032 의 220 mAh 는 작은 연속 부하 기준수 mA 펄스에선 내부 저항으로 전압이 처지고 쓸 수 있는 용량이 줄어듦 → 송신 펄스를 커패시터로 받쳐 줌
저온영하에선 용량 · 펄스 능력이 크게 감소 — 사용 온도 범위로 계산
자가 방전알칼리 AA 는 이론 40 년이어도 10 년 안팎이 상한
종료 전압MCU · 무선 · BOR(PROD 06)이 요구하는 최저 전압에서 "빈 배터리"
현장에서 배터리 전압을 올리세요 — 송신 직후(가장 처졌을 때) 전압을 매번 기록해 서버에서 장비별 추세를 그리면, 예산표와 실제가 어긋나는 장비(누설 불량)를 출하 몇 주 만에 찾아냅니다.
정리 — 배터리 수명 = 용량 ÷ 평균 전류, 평균 전류 = Σ(전류 × 시간) ÷ 주기. 예산표를 먼저 쓰고, PPK2 같은 프로파일러로 실측해 맞춥니다. 떠 있는 핀 · 풀업 · LED · 레귤레이터 Iq 같은 누설 하나가 수명을 년에서 일 단위로 바꿉니다.
PROD 09

현장 로그 · 크래시 리포트 운영 — 만 대 중 한 대가 왜 죽었나

서버라면 로그가 ELK 로 흘러가고 예외는 Sentry 가 묶어 줍니다. 펌웨어에는 디스크도 상시 연결도 없습니다. 그래서 장비 안에 작은 링 로그를 두고, 다음에 연결될 때 올리고, 서버에서 빌드 ID 로 심볼을 붙여 묶는 파이프라인을 직접 만듭니다. HardFault 순간 레지스터를 .noinit 에 남기는 기술적 구현은 🐞 디버깅 · 테스트 탭에서 다루고, 여기서는 그것을 현장에서 모아 운영하는 법을 봅니다.

현장 로그 파이프라인 — 앞의 둘은 장비, 뒤의 둘은 서버 기록 이벤트 · 크래시 보존 Flash · .noinit 업로드 + 빌드 ID 심볼화 .elf 로 주소 해석 묶기 · 알림 서명별 · 버전별 장비 — 전원이 꺼져도 남아야 함 서버 — .elf 가 없으면 주소만 남음 빌드 ID 하나가 장비의 주소와 서버의 소스 줄 번호를 잇는 유일한 열쇠입니다 업로드가 실패해도 장비 쪽 기록은 지우지 않습니다 — 서버가 받았다고 답한 순번까지만 지웁니다
파이프라인 — 기록 · 보존 · 업로드 · 심볼화 · 묶기. 어느 한 칸이 빠지면 현장 버그는 "재현 불가"로 닫힙니다.

무엇을 기록하나 — 문자열 대신 토큰

/* 장비엔 숫자만 — 문자열 "I2C timeout addr=%02x" 는 .elf · 사전 파일에만 */
typedef struct __attribute__((packed)) {
    uint16_t magic;     /* 0xA55A — 0xFFFF 이면 빈 칸 */
    uint16_t len;       /* 뒤따르는 인자 바이트 수 */
    uint32_t seq;       /* 전역 순번 — 업로드 확인(ack) 기준 */
    uint32_t t_ms;      /* 부팅 후 ms (RTC 가 있으면 UTC 도) */
    uint16_t boot_no;   /* 몇 번째 부팅인가 — 리셋 전후 구분 */
    uint16_t event;     /* 이벤트 토큰 */
    uint32_t crc;       /* 헤더 + 인자 */
} log_hdr_t;            /* 20 B + 인자 몇 바이트 */

#define LOG_W(ev, ...)  log_put(LV_WARN, (ev), __VA_ARGS__)
LOG_W(EV_I2C_TIMEOUT, addr, retries);   /* 20 + 2 B 남짓 */
토큰 로깅은 printf 문자열 대신 번호와 인자만 남기고 해석은 PC 에서 합니다. Flash · 대역폭이 10배 가까이 줄고 문자열이 바이너리에 남지 않아 보안에도 좋습니다. Pigweed tokenizer · Zephyr 사전 로깅 · Trice 같은 구현이 있습니다.

기록 수준 — 현장에선 무엇을 켜 두나

종류예보존
부팅 기록리셋 플래그 · 부팅 번호 · 버전 (PROD 06)항상
크래시폴트 레지스터 · PC · LR · 스택 일부항상 · 최우선
경고통신 재시도 · 센서 범위 밖 · 저전압링 로그
지표업타임 · 최대 스택 사용량 · 배터리 전압주기 요약
디버그상세 흐름원격 명령으로 잠시만
개인정보 — 위치 · 사용 패턴이 담기면 개인정보가 됩니다. 수집 항목을 문서화하고, 필요 없는 것은 장비에서 아예 기록하지 않습니다.

링 로그를 Flash 에 — 가장 오래된 섹터부터 지운다 🟡 중급

외부 Flash 링 로그 64 KB — 4 KB 섹터 16칸이 시계 방향으로 돕니다 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 64 KB 쓰기 방향 ↻ 올렸거나 오래된 기록 — 지워도 됨 5~8 아직 안 올린 기록 9 지금 쓰는 칸 10 다 차면 지울 칸 — 가장 오래된 4 KB 서버 확인(ack) 순번은 4번 칸 중간 → 그 앞은 이미 서버에 있음 업로드가 밀려 쓰는 위치(9)가 한 바퀴 돌아 5 에 닿으면 안 올린 기록부터 잃습니다
순환 — 링은 섹터 단위로 지우며 돕니다. 업로드가 얼마나 밀려도 되는지는 링 크기 ÷ 기록 속도로 정해집니다.

덧붙이기 — 섹터를 넘으면 다음 섹터를 지움

#define LOG_SECT   4096u
#define LOG_NSECT  16u
static uint32_t g_wr;          /* 다음에 쓸 오프셋 (0 ~ 64 KB) */

int log_append(const log_hdr_t *h, const void *args)
{
    const uint32_t n = sizeof *h + h->len;
    if (n > LOG_SECT) return -1;

    /* 섹터 끝을 넘으면 다음 섹터 처음으로 (기록이 섹터를 걸치지 않게) */
    if ((g_wr % LOG_SECT) + n > LOG_SECT)
        g_wr = ((g_wr / LOG_SECT + 1u) % LOG_NSECT) * LOG_SECT;
    if (g_wr % LOG_SECT == 0u)
        w25q_erase_4k(LOG_BASE + g_wr);       /* 가장 오래된 4 KB 를 버림 */

    /* w25q_program 이 안에서 256 B 페이지 경계마다 나눠 쓴다고 가정 (안 나누면 넘친 부분이 같은 페이지 앞을 덮음) */
    w25q_program(LOG_BASE + g_wr, h, sizeof *h);
    w25q_program(LOG_BASE + g_wr + sizeof *h, args, h->len);
    g_wr += n;
    if (g_wr >= LOG_NSECT * LOG_SECT) g_wr = 0u;   /* 링 끝에 딱 맞게 찼으면 처음으로 */
    return 0;
}
부팅 때는 각 섹터의 첫 기록 seq 를 읽어 가장 큰 섹터를 찾고, 그 안을 끝까지 훑어 g_wr 를 복원합니다. CRC 가 깨진 마지막 기록(쓰다 꺼짐)은 건너뜁니다 — PROD 07 의 "확정만 믿는다"와 같은 규칙입니다.

다음 부팅 — 크래시를 리포트로 감싸 보내기

typedef struct {
    uint8_t  dev_id[12];     /* 칩 고유 ID (UID 96비트) */
    uint32_t fw_ver;
    uint8_t  build_id[20];   /* GNU build-id (PROD 12) */
    uint32_t reset_flags;    /* PROD 06 */
    uint32_t boot_no;
    crash_rec_t crash;       /* .noinit 에서 꺼낸 것 — 형식은 디버깅 탭 */
} crash_report_t;

void report_crash_if_any(void)
{
    crash_rec_t c;
    if (!crash_take(&c)) return;          /* 꺼내면서 .noinit 을 지움 */
    crash_report_t r = { .fw_ver = FW_VERSION,
                         .reset_flags = g_rst, .boot_no = g_boot_no, .crash = c };
    memcpy(r.dev_id, (const void *)UID_BASE, 12);
    memcpy(r.build_id, fw_build_id(), 20);
    outbox_put(REC_CRASH, &r, sizeof r);   /* 먼저 Flash 에 — 연결되면 전송 */
}
.noinit 에서 바로 보내지 마세요. 연결되기 전에 또 크래시가 나면 첫 번째 기록이 덮입니다. 먼저 Flash 의 보낼 함(outbox)에 옮기고, 서버가 받았다고 답한 뒤에만 지웁니다. 같은 크래시가 반복되면 개수만 세는 압축도 필요합니다.

실무에선 이렇게 — 심볼 보관과 원격 진단 🔴 실무

CI 가 .elf 를 빌드 ID 로 보관 · 서버가 해석

# CI — 릴리스 빌드마다 (바이너리와 같은 커밋에서 나온 .elf 만 의미가 있음)
BID=$(arm-none-eabi-readelf -n build/app.elf | awk '/Build ID/ {print $3}')
aws s3 cp build/app.elf "s3://fw-symbols/${BID}.elf"

# 서버 — 리포트의 build_id 로 .elf 를 찾아 주소를 함수 · 줄로
arm-none-eabi-addr2line -e "${BID}.elf" -f -C -i 0x08012A4C 0x08011F30
#   sensor_read      Core/Src/sensor.c:142
#   app_task_loop    Core/Src/app.c:88
스택에 쌓인 LR 값은 Thumb 비트(1)가 켜진 복귀 주소입니다. 호출한 줄을 찾으려면 1 을 빼서(0x08011F31 → 0x08011F30) 넣습니다. 서버는 (폴트 종류 · PC 함수 · LR 함수)를 서명으로 삼아 같은 버그를 한 묶음으로 세고, 버전별 발생률을 그립니다.

원격 진단 명령 — 인증 · 만료 · 기록

명령용도
info버전 · 빌드 ID · 업타임 · 리셋 통계
logs since=<seq>링 로그 일부를 즉시 업로드
loglevel debug ttl=3600한 시간만 상세 로그 — 끝나면 저절로 복귀
metrics스택 최고 수위 · 힙 · 배터리 · 통신 품질
reboot · safe-mode원격 재시작 · 안전 모드 진입 (PROD 06)
원격 명령은 곧 원격 공격 표면입니다. 명령마다 서버 서명 + 장비 ID + 만료 시각을 붙여 검증하고(PROD 04 의 공개키 재사용), 같은 명령을 다시 보내는 재전송 공격을 막기 위해 순번을 둡니다. 메모리 읽기 · 쓰기 같은 범용 명령은 출하 빌드에 두지 않습니다.
정리 — 장비는 토큰 링 로그 + 크래시 기록을 Flash 에 남기고, 다음 연결 때 버전 · 빌드 ID와 함께 올립니다. 서버는 빌드 ID 로 보관한 .elf 로 주소를 줄 번호로 바꿔 서명별로 묶습니다. 원격 진단 명령은 서명 · 만료 · 순번 없이는 열지 않습니다.
PROD 10

생산 공정 — 하루 천 대를 같은 손으로 굽고, 시험하고, 기록하기

책상에선 보드 한 장을 디버거로 굽지만 공장에선 사람이 아니라 스크립트가 굽습니다. 펌웨어 엔지니어가 넘겨야 하는 것은 바이너리 하나가 아니라 생산 테스트 펌웨어 · 기록 스크립트 · 합격 기준 · 기록 형식입니다. 서버로 치면 배포 파이프라인과 헬스 체크와 감사 로그를 한꺼번에 공장에 넘기는 일입니다.

생산 라인 — 각 칸의 결과가 시리얼 번호로 기록 시스템에 쌓입니다 실장 SMT · AOI 굽기 테스트 FW 기능 시험 핀 · 통신 · 전류 보정 · ID OTP 기록 출하 FW BOR · RDP 최종 검사 라벨 · 포장 생산 기록 (MES) — 시리얼 · 측정값 · 판정 펌웨어가 관여하는 칸은 가운데 넷 — 그중 RDP 설정이 되돌릴 수 없는 마지막 단계입니다
파이프라인 — 생산 테스트 펌웨어로 시험 · 보정 · ID 기록을 하고, 출하 펌웨어로 덮어쓴 뒤 맨 마지막에 잠급니다.

프로그래밍 지그

방식언제
Tag-Connect 같은 케이블시제품 · 수백 대 — PCB 에 패드만, 커넥터 부품 없음
포고핀 지그양산 — 스프링 핀이 테스트 패드를 누름. 보드를 얹고 레버 한 번
갱(gang) 프로그래머여러 장을 동시에 — 패널 단위 굽기
칩 사전 기록유통사 · 제조 서비스가 실장 전 칩에 구워 줌 (부트로더만 굽는 경우 많음)
EVT 때 테스트 패드를 정하세요 — SWDIO · SWCLK · NRST · GND · VDD 에 UART TX/RX · BOOT0 까지 한쪽 면에 모아 두면 지그가 단순해집니다. 양산 직전에 패드를 추가하면 보드 리비전이 하나 더 생깁니다.

STM32CubeProgrammer CLI 로 한 스테이션

#!/usr/bin/env bash
set -euo pipefail
PROBE="$1"                                  # 이 스테이션의 ST-LINK 시리얼
CLI=STM32_Programmer_CLI
C="-c port=SWD mode=UR reset=HWrst sn=${PROBE}"

$CLI $C -e all                              # ① 전체 지우기
$CLI $C -w factory_test-1.5.0.hex -v -rst   # ② 생산 테스트 펌웨어
python run_tests.py --port /dev/ttyJIG0     # ③④ 시험 · 보정 · OTP 기록 (MES 기록 포함)

$CLI $C -e all                              # ⑤ 출하 펌웨어 (부트로더 + 앱 합친 hex)
$CLI $C -w release-1.5.0.hex -v
$CLI $C -ob BOR_LEV=0                       #    BOR 레벨 3
$CLI $C -ob RDP=0xBB                        #    RDP 1 — 맨 마지막 (이후 읽기 검증 불가)
전체 지우기는 OTP 를 지우지 않습니다 — 그래서 보정값 · 시리얼을 OTP 에 두면 ⑤에서 출하 펌웨어로 갈아 끼워도 남습니다. 반대로 메인 Flash 의 섹터에 뒀다면 ⑤의 -e all 이 지웁니다. 공정 순서와 저장 위치는 함께 정해야 합니다.

시리얼 · MAC · 보정값은 어디에 — OTP 와 고유 ID 🟡 중급

F446 OTP 512 B — 32 B 블록 16개 + 잠금 바이트 16개 (지울 수 없음) 0x1FFF 7800 0 ID 1 보정 v1 2 재보정 3 4 … 15 0x1FFF 7A00 00 잠금 00 잠금 FF FF FF FF FF 0x1FFF 7A10 UID 96비트 — ST 가 칩마다 기록 · 읽기 전용 블록 0 = 매직 · 시리얼 16자 · MAC 6 B · HW 리비전 · CRC — 정확히 32 B 보정을 다시 해야 하면 새 블록에 쓰고 펌웨어는 "잠긴 블록 중 마지막 보정"을 씁니다 — 16칸이 전부입니다 RDP 레벨을 내려 Flash 를 지워도 OTP 와 UID 는 남습니다
메모리 배치 — OTP 는 한 번 쓰고 잠그는 공장 전용 영역입니다. 잠금 바이트를 0x00 으로 쓰는 순간 그 블록은 영구히 고정됩니다.

생산 테스트 펌웨어의 OTP 기록

#define OTP_BASE  0x1FFF7800u
#define OTP_LOCK  0x1FFF7A00u
typedef struct {
    uint32_t magic;        /* 0x1D0B10C0 */
    char     serial[16];   /* MES 가 발급 — 라벨과 같은 값 */
    uint8_t  mac[6];
    uint8_t  hw_rev;
    uint8_t  _rsv;
    uint32_t crc;          /* 앞 28 B 의 CRC32 */
} otp_ident_t;
_Static_assert(sizeof(otp_ident_t) == 32, "OTP 블록 하나");

HAL_StatusTypeDef otp_write_block(uint32_t blk, const void *src)
{
    if (blk >= 16u) return HAL_ERROR;
    const uint32_t a = OTP_BASE + 32u * blk;
    const uint32_t *w = src;
    for (uint32_t i = 0; i < 8u; i++)            /* 이미 쓰인 블록이면 거부 */
        if (((const volatile uint32_t *)a)[i] != 0xFFFFFFFFu) return HAL_ERROR;

    HAL_StatusTypeDef st = HAL_OK;
    HAL_FLASH_Unlock();
    for (uint32_t i = 0; st == HAL_OK && i < 8u; i++)
        st = HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, a + 4u * i, w[i]);
    if (st == HAL_OK && memcmp((const void *)a, src, 32u) != 0) st = HAL_ERROR;
    if (st == HAL_OK)                               /* 읽어서 맞을 때만 잠금 */
        st = HAL_FLASH_Program(FLASH_TYPEPROGRAM_BYTE, OTP_LOCK + blk, 0x00u);
    HAL_FLASH_Lock();
    return st;
}

값마다 출처가 다릅니다

값출처 · 저장
시리얼 번호MES 가 발급(사람이 읽는 형식) → OTP · 라벨 · DB 셋이 같은 값. 칩 UID 는 보조 키로 함께 기록
MAC 주소IEEE 에서 산 주소 블록 중 MES 가 배정 · 또는 EUI-48 내장 EEPROM 칩 · 무선 모듈에 이미 들어 있는 주소
보정값지그의 기준 전압 · 온도로 측정 → ADC 오프셋 · 이득 → OTP (재보정 여유 블록)
장비별 비밀클라우드 인증서 · 키 — OTP 에 평문으로 두지 말 것 (PROD 11)
OTP 칸이 모자라거나 F446 처럼 작다면 메인 Flash 의 마지막 섹터를 "공장 영역"으로 두고 WRP 로 보호하는 방법도 흔합니다. 이때는 ⑤ 단계에서 -e all 대신 섹터를 골라 지워야 합니다.

실무에선 이렇게 — 시험 결과는 한 줄도 버리지 않는다 🔴 실무

한 대의 생산 기록

{
  "serial": "SN26A0012345",
  "uid": "003A00253238511834383931",
  "station": "FT-02", "fixture": "JIG-07", "operator": "kim",
  "fw_test": "factory_test-1.5.0+42", "fw_release": "1.5.0+42",
  "build_id": "3f2a9c1e…",
  "measurements": [
    {"name": "vdd_3v3",      "value": 3.312, "lo": 3.20, "hi": 3.40, "pass": true},
    {"name": "sleep_ua",     "value": 3.8,   "lo": 0,    "hi": 6.0,  "pass": true},
    {"name": "adc_offset_mv","value": -4.2,  "lo": -15,  "hi": 15,   "pass": true}
  ],
  "result": "PASS",
  "started": "2026-09-30T08:14:02Z", "duration_s": 41.7
}
측정값을 합격 · 불합격이 아니라 숫자로 남기세요. 잠 전류가 "합격이지만 6 µA 상한에 붙은" 보드가 늘기 시작하면 부품 로트 문제를 불량이 터지기 전에 봅니다. 현장 반품이 오면 이 기록이 첫 번째 증거입니다.

생산 테스트 펌웨어가 지켜야 할 것

규칙이유
명령-응답 프로토콜 (UART)지그 PC 의 스크립트가 순서를 정함 — 펌웨어는 시키는 것만
항목마다 숫자 결과판정 기준은 PC 쪽 설정에 — 기준을 바꿔도 펌웨어는 그대로
시간 상한한 대에 수십 초 — 라인 속도(택트 타임)가 비용
재시험 정책몇 번까지 다시 해도 되나, 재시험 이력도 기록
출하 이미지에 남기지 않기PROD 01 — 테스트 명령은 출하 빌드에 없음
지그도 고장 납니다. 포고핀이 닳으면 접촉 불량이 "보드 불량"으로 기록됩니다. 하루 시작마다 골든 보드(합격이 확실한 기준 보드)로 지그를 먼저 시험하고, 지그별 불량률을 대시보드에 둡니다.
정리 — 공장은 테스트 펌웨어로 시험 · 보정 · ID 기록 → 출하 펌웨어 → BOR · RDP 잠금 순서로 갑니다. 시리얼 · 보정값은 지워지지 않는 OTP(또는 보호된 공장 섹터)에, 모든 측정값은 숫자로 MES 에 남깁니다.
PROD 11

보안 — 읽기 보호 · 키 · 공격 · 규제, 제품이 지켜야 할 최소선

서버는 데이터센터 안에 있지만 제품은 공격자의 책상 위에 있습니다. 납땜인두와 디버거, 전원 장치를 가진 사람이 몇 주를 들여도 되는 환경입니다. 그래서 펌웨어 보안은 "못 뚫게"가 아니라 "한 대를 뚫어도 나머지는 안전하게, 뚫는 비용이 얻는 것보다 크게"가 목표입니다. 그리고 2026 년 지금, 보안은 선택이 아니라 판매 조건이 되어 가고 있습니다.

RDP 레벨 — 진할수록 잠겨 있습니다 (옵션 바이트 RDP: AA = 0 · CC = 2 · 그 밖 = 1) 디버거 접속 Flash 읽기 RAM · ROM 부팅 옵션 바이트 되돌리기 레벨 0 (개발) 가능 가능 가능 변경 가능 — 레벨 1 (흔한 양산) 붙기는 함 차단 Flash 못 읽음 변경 가능 →0 전체 지움 레벨 2 (영구) 영구 차단 차단 막힘 고정 불가 레벨 2 에서도 내 부트로더는 앱 영역을 지우고 쓸 수 있습니다 — OTA 는 계속 됩니다 레벨 2 는 불량 분석(RMA)도 불가 — ST 도 칩을 열어 주지 못합니다 OTP · UID 는 레벨 1 → 0 의 전체 지우기에서도 남습니다 (PROD 10)
매트릭스 — 대부분의 양산품은 레벨 1, 되돌릴 수 없는 레벨 2 는 위협 모델이 요구할 때만 고릅니다.

잠금 세 가지 — RDP · WRP · 디버그 포트

장치막는 것주의
RDP외부에서 Flash 읽기 (펌웨어 복제 · 분석)레벨 1 → 0 은 전체 지움, 레벨 2 는 영구
WRP (nWRP)섹터별 지우기 · 쓰기 — 부트로더 S0~S1 보호레벨 1 에선 풀 수 있음 · 레벨 2 와 함께면 영구
디버그 포트SWD 로 실행 제어 · RAM 읽기RDP 2 만이 하드웨어적으로 닫음 — SWD 핀을 GPIO 로 바꾸는 건 리셋 직후 틈이 남음
/* 부트로더 섹터 0 · 1 쓰기 보호 — 생산 테스트 펌웨어나 공정 단계에서 한 번 */
FLASH_OBProgramInitTypeDef ob = {0};
ob.OptionType = OPTIONBYTE_WRP;
ob.WRPState   = OB_WRPSTATE_ENABLE;
ob.WRPSector  = OB_WRP_SECTOR_0 | OB_WRP_SECTOR_1;
ob.Banks      = FLASH_BANK_1;
HAL_FLASH_Unlock();
HAL_FLASH_OB_Unlock();
HAL_FLASHEx_OBProgram(&ob);
HAL_FLASH_OB_Launch();          /* 적용 — 확인은 STM32_Programmer_CLI -ob displ */
HAL_FLASH_OB_Lock();
HAL_FLASH_Lock();

RDP 2 를 걸기 전에

레벨 2 는 되돌릴 수 없습니다. 개발 보드에 실수로 걸면 그 보드는 다시 굽지 못하는 쇳덩이가 됩니다. 공정 스크립트에서 RDP 2 를 쓰는 줄은 별도 확인 단계를 두고, 펌웨어가 스스로 레벨 2 를 거는 코드는 두지 않는 것이 안전합니다.
RDP 는 금고가 아니라 자물쇠입니다. 공개 연구에서 여러 STM32 계열(특히 오래된 F0 · F1 · F2 등)의 읽기 보호를 전압 글리치나 설계상의 틈으로 우회한 사례가 발표됐습니다. "RDP 를 걸었으니 키를 Flash 에 둬도 된다"는 결론은 위험합니다 — 아래 키 문제로 이어집니다.

키를 어디에 두나 · TrustZone · 사이드채널 🟡 중급

키 저장 — 위험한 순서대로

방식판정
모든 장비가 같은 대칭키를 Flash 에❌ 한 대 추출 = 전 제품 위조 · 복호화
장비마다 다른 키를 Flash 에 (RDP 1)△ 피해가 그 한 대로 한정 — F4 의 현실적 하한
검증용 공개키만 Flash 에✅ 서명 검증(PROD 04)엔 비밀이 필요 없음
보안 소자에 개인키 (밖으로 안 나옴)✅ ATECC608 · SE050 · STSAFE-A 같은 I2C 칩 — 키는 칩 안에서 서명만
TrustZone 의 보안 영역 + 키 저장소✅ U5 · H5 · L5 같은 Armv8-M 칩
"모든 장비 공통 비밀"을 만들지 않는 것이 설계의 첫 원칙입니다. 클라우드 접속도 장비별 인증서(공장에서 발급 · PROD 10)로 합니다.

TrustZone-M — 한 칩 안의 두 세계

/* 보안 쪽 (gcc -mcmse) — 비보안 앱이 부를 수 있는 입구 */
__attribute__((cmse_nonsecure_entry))
int secure_sign(const uint8_t *msg, uint32_t len, uint8_t sig[64])
{
    /* 개인키는 보안 메모리에만 — 비보안 쪽은 주소조차 못 읽음
       포인터 인자는 cmse_check_address_range() 로 비보안 영역인지 확인 */
    return do_sign_with_device_key(msg, len, sig);
}
/* 비보안 앱(통신 스택 · UI)은 이 함수만 부를 수 있고, 버그로 장악당해도 키는 못 꺼냄 */
Armv8-M(Cortex-M33 등)은 메모리를 보안 / 비보안으로 나누고(SAU · IDAU), 비보안 코드는 정해진 입구(NSC 영역의 SG 명령)로만 보안 함수를 부릅니다. 서버에 빗대면 같은 머신 안의 별도 권한 프로세스입니다. 보안 쪽 서비스(암호 · 보안 저장 · 증명)는 Trusted Firmware-M 이 표준 구현을 제공합니다. F446(M4)에는 없습니다.
❌ memcmp 로 MAC · 해시 비교 · bool 하나로 분기
if (memcmp(tag, expect, 16) == 0)   /* 첫 바이트가 틀리면 바로 끝남
                                       → 걸린 시간으로 한 바이트씩 추측 */
    unlock();

if (image_signature_ok(h))           /* 전압 글리치로 분기 한 번만
    jump_to_app(base);                  건너뛰면 서명 검사 우회 */
✅ 상수 시간 비교 · 이중 확인 · 큰 해밍 거리 값
int ct_memeq(const void *a, const void *b, size_t n)
{
    const volatile uint8_t *x = a, *y = b;
    uint8_t d = 0;
    for (size_t i = 0; i < n; i++) d |= x[i] ^ y[i];   /* 항상 n 번 */
    return d == 0;
}

#define SEC_OK 0x3CA5965Au              /* 1 대신 비트가 고르게 섞인 값 */
volatile uint32_t r1 = verify_image(h); /* 성공이면 SEC_OK */
if (r1 != SEC_OK) fail_secure();
volatile uint32_t r2 = verify_image(h); /* 다시 — 글리치 한 번으로 둘 다 넘기 어렵게 */
if (r2 != SEC_OK || r1 != r2) fail_secure();
jump_to_app(base);
사이드채널 · 글리치는 개념만 알아도 코드가 달라집니다. 사이드채널은 연산 시간 · 전력 소모 · 전자파로 비밀을 엿보는 공격(타이밍 · SPA/DPA), 글리치(오류 주입)는 전압 · 클럭 · 전자기 펄스를 순간적으로 흔들어 명령 하나를 건너뛰게 하는 공격입니다. 방어의 기본은 위처럼 비밀에 의존하는 분기 · 조기 종료 없애기와 중요한 판정을 두 번, 다른 표현으로입니다. 암호는 직접 짜지 말고 검증된 라이브러리를 씁니다.

규제 동향 — 보안 업데이트가 법적 의무가 되어 갑니다 🔴 실무

연결 제품 보안 규제 — 위는 시작된 것, 세로 점선이 지금(2026-10) 2024 2025 2026 2027 2028 2024-04 영국 PSTI 공통 기본 비밀번호 금지 2024-12 EU CRA 발효 2025-08 EU 무선기기 사이버보안 요건 적용 2026-09 CRA 보고 의무 악용 취약점 · 사고 통보 지금 2027-12 CRA 전면 적용 CE 표시의 조건 EU 에 파는 "디지털 요소가 있는 제품"이면 펌웨어 팀의 일이 됩니다 — 원문 · 법률 검토는 별도로
타임라인 — 규제는 이미 시작됐고, 2027 년 말이면 EU 판매 제품의 보안 업데이트 체계가 CE 표시의 조건이 됩니다.

펌웨어 팀에 떨어지는 일 (CRA 를 중심으로, 요지만)

요구 (요지)이 탭의 어디와 이어지나
지원 기간 동안 보안 업데이트 제공OTA · 롤백 · 서명 (PROD 03 · 04)
알려진 악용 가능 취약점 없이 출시의존 라이브러리 버전 추적 · 재현 가능 빌드 (PROD 12)
안전한 기본값공통 기본 비밀번호 금지 · 디버그 잠금 (이 섹션)
SBOM(소프트웨어 구성 목록)HAL · RTOS · 암호 라이브러리 · 버전 목록을 빌드마다 생성
취약점 처리 · 보고현장 로그 · 버전별 집계 (PROD 09) · 신고 창구

기억할 것

ETSI EN 303 645(소비자 IoT 보안 기본 요건)가 여러 나라 제도의 공통 출발점입니다. 공통 기본 비밀번호 금지 · 취약점 신고 창구 · 소프트웨어 업데이트 · 통신 보호 · 개인정보 최소화 같은 항목은 지금 설계에 넣어 두면 어느 시장이든 대부분 겹칩니다.
이 표는 엔지니어용 요약이지 법률 자문이 아닙니다. 제품군 분류(일반 · 중요 제품 등), 적용 날짜, 세부 의무는 원문과 회사의 규제 담당과 확인하세요. 분명한 것은 하나 — OTA 가 안 되는 연결 제품은 앞으로 팔기 점점 어려워진다는 점입니다.
정리 — 양산품은 RDP 1 + 부트로더 WRP 가 기본, 되돌릴 수 없는 RDP 2 는 위협 모델이 요구할 때만. 모든 장비 공통 비밀을 만들지 말고, 키는 보안 소자 · TrustZone 에. 비밀 비교는 상수 시간, 중요한 판정은 두 번. EU CRA 로 보안 업데이트는 판매 조건이 되어 갑니다.
PROD 12

버전 · 릴리스 관리 — 현장의 이 바이너리는 어느 커밋인가

현장에서 크래시 리포트가 오면 첫 질문은 "어느 버전?"입니다. 서버라면 이미지 태그와 배포 기록이 답해 주지만, 펌웨어는 바이너리 안에 스스로 답을 품고 있어야 합니다. 그리고 같은 커밋에서 다시 빌드하면 바이트 단위로 같은 결과가 나와야 "이 .elf 가 그 장비의 코드"라고 확신할 수 있습니다. 여기에 보드 리비전과 펌웨어의 짝까지 관리하는 것이 릴리스 관리입니다.

git describe --tags --always --dirty 의 출력 — 네 조각 v1.5.0 -17 -g3f2a9c1 -dirty 가장 가까운 태그 = 릴리스 번호 태그 뒤 커밋 17개 g + 커밋 해시 앞자리 — 정확한 커밋 커밋 안 된 수정 있음 — 출하 빌드면 실패 build-id 3f2a9c1e… (20 B) — 링커가 바이너리 내용으로 계산한 지문 태그는 사람이 읽는 이름, 빌드 ID 는 기계가 대조하는 지문 — 둘 다 바이너리에 넣습니다
해부도 — 출하 빌드의 이름은 "v1.5.0" 하나여야 합니다. 꼬리가 붙어 있으면 태그 없는 커밋이거나 더러운 작업 트리입니다.

빌드마다 version.h 생성 (CMake)

# CMakeLists.txt — configure 때 한 번이 아니라 빌드할 때마다
add_custom_target(gen_version ALL
  COMMAND ${CMAKE_COMMAND} -DSRC=${CMAKE_SOURCE_DIR} -DOUT=${CMAKE_BINARY_DIR}/gen
          -P ${CMAKE_SOURCE_DIR}/cmake/gen_version.cmake
  BYPRODUCTS ${CMAKE_BINARY_DIR}/gen/version.h)
add_dependencies(app gen_version)
target_include_directories(app PRIVATE ${CMAKE_BINARY_DIR}/gen)

# cmake/gen_version.cmake
execute_process(COMMAND git describe --tags --always --dirty --match "v*"
  WORKING_DIRECTORY ${SRC} OUTPUT_VARIABLE GIT_DESC
  OUTPUT_STRIP_TRAILING_WHITESPACE)
execute_process(COMMAND git log -1 --format=%ct
  WORKING_DIRECTORY ${SRC} OUTPUT_VARIABLE GIT_TIME
  OUTPUT_STRIP_TRAILING_WHITESPACE)
configure_file(${SRC}/cmake/version.h.in ${OUT}/version.h @ONLY)
# configure_file 은 내용이 같으면 파일을 안 건드림 → 불필요한 재컴파일 없음
/* cmake/version.h.in */
#define FW_GIT_DESC     "@GIT_DESC@"
#define FW_COMMIT_TIME  @GIT_TIME@u   /* 빌드 시각 대신 커밋 시각 */

고정 위치의 빌드 정보 구조체

typedef struct {
    uint32_t magic;          /* 0xF1F0F1F0 — 덤프에서 찾기 쉬운 값 */
    uint32_t version;        /* (1 << 24) | (5 << 16) | 0 */
    char     git[32];        /* "v1.5.0" 또는 "v1.5.0-17-g3f2a9c1" */
    uint32_t hw_mask;        /* 이 빌드가 지원하는 보드 리비전 비트 */
    uint32_t commit_time;
} fw_info_t;

__attribute__((section(".fw_info"), used))
const fw_info_t g_fw_info = {
    0xF1F0F1F0u, FW_VERSION, FW_GIT_DESC, FW_HW_MASK, FW_COMMIT_TIME };
/* 링커 스크립트 — 벡터 테이블(F446 은 0x200 미만) 바로 뒤 고정 오프셋 */
.isr_vector : { KEEP(*(.isr_vector)) } >FLASH
.fw_info ORIGIN(FLASH) + 0x200 : { KEEP(*(.fw_info)) } >FLASH
.note.gnu.build-id : {
    PROVIDE(g_build_id_note = .);
    KEEP(*(.note.gnu.build-id))       /* -Wl,--build-id 필요 */
} >FLASH
고정 오프셋이면 부트로더 · 공장 도구 · 서버가 앱을 실행하지 않고도 ORIGIN + 0x200 만 읽어 버전을 압니다. 빌드 ID 는 노트 헤더(이름 길이 · 설명 길이 · 종류 · "GNU\0" = 16 B) 뒤의 20 B 입니다 — fw_build_id() 는 g_build_id_note + 16 을 돌려주면 됩니다.

재현 가능 빌드 — 같은 커밋 = 같은 바이트 🟡 중급

바이너리를 흔드는 것들과 처방

흔드는 것처방
__DATE__ · __TIME__쓰지 않기 · 또는 SOURCE_DATE_EPOCH 를 커밋 시각으로 (GCC 가 따름)
__FILE__ · 디버그 정보의 절대 경로-ffile-prefix-map=/home/ci/work=.
컴파일러 버전Arm GNU Toolchain 을 정확한 버전으로 고정한 컨테이너 이미지
라이브러리 · HAL 버전서브모듈 · 버전 고정 · SBOM 에 기록 (PROD 11)
링크 순서소스 목록을 glob 대신 명시 · 정렬

CI 에서 확인

export SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)
# -ffile-prefix-map 은 툴체인 파일의 CMAKE_C_FLAGS 에 고정해 둠

# 경로가 다른 두 곳에서 각각 빌드 → 해시 비교
cmake --preset prod -B /tmp/a && cmake --build /tmp/a
cmake --preset prod -B /tmp/b && cmake --build /tmp/b
sha256sum /tmp/a/app.bin /tmp/b/app.bin     # 두 줄의 해시가 같아야 함
arm-none-eabi-gcc --version | head -1       # 릴리스 노트에 그대로 기록
재현 가능하면 "현장의 바이너리 = 이 커밋"을 해시로 증명할 수 있고, 보관해 둔 .elf 를 잃어도 다시 빌드해 같은 심볼을 얻습니다. 서명(PROD 04)은 재현 가능한 빌드 결과에 걸어야 의미가 분명해집니다.

실무에선 이렇게 — 보드 리비전 감지와 호환성 표 🔴 실무

ADC 한 핀으로 보드 리비전 8가지 — 위 10 kΩ 고정, 아래 저항만 바꿉니다 A 0Ω B 1k C 2.2k D 4.7k E 10k F 22k G 47k H 없음 0 372 738 1309 2048 2815 3377 4095 점선이 판정 경계(이웃 값의 중간) — 1% 저항 오차는 ±20 카운트 안팎이라 여유가 큽니다 ADC 값 = 4095 × R아래 / (10 kΩ + R아래) · 분압기를 VDDA 에 물리면 전원 오차가 상쇄됩니다
수직선 — 리비전마다 저항 하나만 바꾸면 펌웨어 하나가 보드 여덟 종류를 구분합니다.

리비전 읽기 — ADC 분압 · GPIO 스트랩

/* 이웃 ADC 값의 중간 — 위 그림의 점선 */
static const uint16_t k_thr[7] = { 186, 555, 1024, 1679, 2432, 3096, 3736 };

uint8_t hw_rev_from_adc(uint16_t code)       /* 여러 번 읽어 평균한 값 */
{
    uint8_t r = 0;
    while (r < 7u && code > k_thr[r]) r++;
    return r;                                /* 0 = Rev A … 7 = Rev H */
}

/* 또는 GPIO 세 개(PB12~PB14)에 리비전마다 풀업 · 풀다운 저항 → 8가지 */
uint8_t hw_rev_from_straps(void)
{
    uint8_t r = (uint8_t)(((GPIOB->IDR >> 12) & 0x7u));  /* 입력 · 내부 풀 없음 */
    /* 읽은 뒤 아날로그 모드로 — 저항으로 새는 전류 차단 (PROD 08) */
    return r;
}

/* 부팅 때: 이 빌드가 이 보드를 지원하나 */
if ((g_fw_info.hw_mask & (1u << hw_rev)) == 0u) safe_mode();

릴리스마다 남기는 호환성 표

펌웨어보드최소 부트로더설정 형식비고
1.3.xA · B1.0v1지원 종료 예정
1.4.xA · B · C1.0v2v1 → v2 자동 변환
1.5.0B · C · D1.1v2A 보드 제외 · 서명 키 2번
서버는 이 표로 업데이트를 막아야 합니다. 부트로더 1.0 장비에 1.5.0 을 보내면 새 서명 키를 몰라 거부되고, A 보드에 보내면 핀이 맞지 않습니다. 장비는 (보드 리비전, 부트로더 버전, 펌웨어 버전) 을 보고하고, 서버가 맞는 이미지를 고릅니다. 이미지 헤더의 hw_mask(PROD 04)는 마지막 방어선입니다.
릴리스 노트엔 사용자용 변경 사항 외에 내부용으로 툴체인 버전 · 빌드 ID · 서명 키 번호 · 롤백 가능 하한(보안 카운터)을 적습니다. 설정 구조체의 ver 필드(PROD 07)가 바뀌는 릴리스는 되돌아갈 때의 변환까지 시험합니다.
정리 — 바이너리에 git describe · 빌드 ID를 고정 위치로 넣고, SOURCE_DATE_EPOCH · -ffile-prefix-map · 고정 툴체인으로 같은 커밋이 같은 바이트가 되게 합니다. 보드 리비전은 ADC 분압 · GPIO 스트랩으로 읽고, 펌웨어 · 보드 · 부트로더의 짝은 호환성 표로 서버가 지킵니다.
PROD 13

다음 단계 — Zephyr · ESP-IDF · 임베디드 리눅스 · Rust, 그리고 로드맵

여기까지 오면 레지스터에서 양산까지 한 바퀴를 돈 것입니다. 이제 갈림길은 "무엇을 더 깊게"입니다. 서버 개발자가 프레임워크 하나를 익힌 뒤 클라우드 · 데이터 · 플랫폼 중 길을 고르듯, 펌웨어도 무선 IoT · 표준 RTOS 생태계 · 리눅스 · 새 언어 중에서 고릅니다. 어느 길이든 이 탭에서 다룬 부트로더 · OTA · 워치독 · 저장 · 보안의 원리는 그대로 따라갑니다 — 이름만 바뀝니다.

다음 단계 비교 — 진할수록 그 기준에서 유리합니다 C 경험 재사용 무선 · 네트워크 OTA 기본 제공 학습 비용 하드웨어 Zephyr 그대로 IP · BLE 내장 MCUboot DT · Kconfig MCU 수백 종 ESP-IDF 그대로 Wi-Fi · BLE 칩 OTA · 보안 부팅 낮음 ESP32 계열만 임베디드 리눅스 + 리눅스 지식 전부 A/B rootfs 도구 가장 높음 MPU + DRAM Rust (embassy) 새 언어 성장 중 직접 조립 높음 STM32 · nRF 등 셋은 "어디서 돌리나"의 선택, Rust 는 "무엇으로 쓰나"의 선택 — 겹쳐 고를 수도 있습니다
매트릭스 — C 를 그대로 쓰며 넓히려면 Zephyr · ESP-IDF, 리눅스가 필요한 제품이면 MPU, 언어부터 바꾸려면 Rust.

Zephyr — 같은 Nucleo-F446RE 에서 시작

pip install west
west init ~/zephyrproject && cd ~/zephyrproject && west update
west build -b nucleo_f446re zephyr/samples/basic/blinky
west flash
#include <zephyr/kernel.h>
#include <zephyr/drivers/gpio.h>

/* 핀 번호가 코드에 없다 — 보드의 디바이스트리가 led0 을 PA5 로 정의 */
static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(DT_ALIAS(led0), gpios);

int main(void)
{
    if (!gpio_is_ready_dt(&led)) return 0;
    gpio_pin_configure_dt(&led, GPIO_OUTPUT_ACTIVE);
    while (1) {
        gpio_pin_toggle_dt(&led);
        k_msleep(500);
    }
}
Zephyr 는 리눅스 재단 산하의 RTOS 로, 하드웨어 기술을 디바이스트리에, 기능 선택을 Kconfig 에 둡니다(리눅스 커널과 같은 방식). 이 탭의 MCUboot(PROD 04) · 로그 · 셸 · 설정 저장 · 네트워크가 서브시스템으로 들어 있어 "직접 만들던 것"을 고르는 일로 바뀝니다.

Rust + embassy — 같은 LED 를 async 로

#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_stm32::gpio::{Level, Output, Speed};
use embassy_time::Timer;
use {defmt_rtt as _, panic_probe as _};

#[embassy_executor::main]
async fn main(_spawner: Spawner) {
    let p = embassy_stm32::init(Default::default());
    let mut led = Output::new(p.PA5, Level::High, Speed::Low);  // Nucleo LD2
    loop {
        led.toggle();
        Timer::after_millis(500).await;   // 기다리는 동안 다른 태스크가 돎
    }
}
embassy 는 RTOS 태스크 대신 async/await 로 동시성을 표현하고, 소유권 시스템이 "주변장치 하나를 두 곳에서 잡는" 실수를 컴파일 단계에서 막습니다. 로그 크레이트 defmt 는 PROD 09 의 토큰 로깅을 언어 차원에서 한 것이고, probe-rs 가 굽기 · 디버깅을 맡습니다.

MCU 와 MPU — 언제 리눅스가 필요한가 🟡 중급

MCU (이 가이드)MPU + 리눅스
메모리내장 Flash · SRAM (KB~수 MB)외부 DRAM (수백 MB~) · eMMC
MMU없음 — 주소 = 물리 주소있음 — 프로세스 격리
부팅수 ms수 초
실시간µs 단위 결정적PREEMPT_RT 로도 MCU 만큼은 어려움
전력µA 대기mA~W
업데이트PROD 03 의 슬롯SWUpdate · RAUC · Mender 의 A/B 루트 파일시스템
이미지 빌드CMake 하나Buildroot(단순) · Yocto(산업 표준 · 레이어)
리눅스가 필요한 신호 — 화면 UI 가 복잡하다, 카메라 · 영상 처리, 파이썬 · 컨테이너 같은 범용 소프트웨어 생태계, 수백 MB 데이터. MCU 가 맞는 신호 — 배터리, 즉시 켜짐, µs 단위 제어, 낮은 BOM 비용.
둘 다 필요하면 둘 다 씁니다. STM32MP1 · MP2 처럼 리눅스용 Cortex-A 코어 옆에 Cortex-M 코어를 둔 칩이 있고, 실시간 제어는 M 코어가, 네트워크 · UI 는 리눅스가 맡습니다. 이 가이드의 지식이 M 코어 쪽에 그대로 쓰입니다.
# Buildroot — 보드 설정 하나로 부트로더 · 커널 · 루트 FS 까지 이미지 한 장
make stm32mp157c_dk2_defconfig
make                      # output/images/sdcard.img
# ESP-IDF — 무선 MCU 의 표준 경로
idf.py set-target esp32c3 && idf.py build flash monitor

펌웨어 엔지니어 로드맵 — 무엇을 어떤 순서로 🔴 실무

로드맵 — 위는 할 줄 알게 되는 것, 아래는 만들어 볼 것 0~3 개월 3~6 개월 6~12 개월 1~2 년 2 년~ C · 베어메탈 데이터시트 읽기 드라이버 · DMA RTOS · 디버거 부트로더 · OTA 테스트 · CI (이 탭) 무선 · 보안 저전력 · 생산 아키텍처 · 리뷰 기능 안전 표준 레지스터 Blinky UART 에코 센서 3태스크 DMA 로거 A/B OTA 센서 노드 전원 뽑기 시험 BLE · Wi-Fi 기기 배터리 1년 달성 Zephyr · 리눅스 팀 플랫폼 설계 기능 안전: 산업 IEC 61508 · 자동차 ISO 26262 · 의료 IEC 62304 — 분야를 정하면 따라옵니다 기간은 업무로 매일 다룰 때의 대략치 — 순서가 기간보다 중요합니다
타임라인 — 6~12 개월 구간에서 "돌아가는 코드"가 "팔 수 있는 제품"으로 바뀝니다. 이 탭이 그 구간입니다.

졸업 과제 — 이 탭을 한 프로젝트로

✓Nucleo-F446RE 센서 노드에 넣을 것섹션
□32 KB 부트로더 + 슬롯 A/B + 서명 검증02 · 04
□UART 로 받는 OTA, TRIAL 3회 롤백, 확정 조건03
□체크인 워치독 · 리셋 원인 · 안전 모드05 · 06
□덧붙이기 설정 저장 · 정전 시험 지그(릴레이)07
□PPK2 로 잠 전류 실측 → 예산표 갱신08
□링 로그 · 빌드 ID · addr2line 심볼화09 · 12
□CubeProgrammer CLI 공정 스크립트 · OTP 기록10

계속 읽을 것

자료무엇을
Making Embedded Systems (Elecia White)설계 · 아키텍처 · 현업 감각
The Definitive Guide to ARM Cortex-M3 and M4 (Joseph Yiu)예외 · 폴트 · 메모리 모델의 정답지
Interrupt 블로그 (Memfault)부트로더 · 크래시 리포트 · 빌드 ID 같은 이 탭 주제의 실전 글
Zephyr 문서 · The Embedded Rust Book각 길의 공식 입문
칩 회사 응용 노트부트로더(AN2606) · EEPROM 에뮬레이션 · 저전력 — 그 칩의 정답
서버 개발 경험은 무기입니다. CI · 관측성 · 배포 전략 · 장애 회고 — 펌웨어 팀에 가장 부족한 경우가 많은 것들이고, 이 탭의 절반(OTA · 로그 · 버전 · 생산 기록)이 바로 그 감각으로 푸는 문제였습니다.
정리 — C 를 그대로 쓰며 넓히려면 Zephyr(범용) · ESP-IDF(Wi-Fi/BLE), 리눅스가 필요한 제품이면 MPU + Buildroot/Yocto, 언어를 바꾸려면 Rust + embassy. 어느 길이든 부트로더 · OTA · 워치독 · 저장 · 보안의 원리는 이 탭 그대로입니다.