임베디드 학습 노트 목차

임베디드 C 함정 — 컴파일은 되는데 틀린 것들

PC 에서는 넘어가던 것이 MCU 에서 터진다. 이유는 셋이다. 정수 크기가 다르고, 정렬 위반이 폴트를 내고, 최적화가 하드웨어를 모른다.

여기서 다루는 것들은 전부 컴파일이 통과하고 경고도 안 나오는데 틀린 경우다. 그래서 알고 피하는 수밖에 없다.


정수 승격 — 부호가 조용히 섞인다

uint8_t  a = 200;
uint8_t  b = 100;
if (a + b > 255) {          // 참이다. uint8_t 로 안 잘린다
    /* 여기로 온다 */
}

C 는 int 보다 작은 타입을 연산 전에 int 로 승격한다. 200 + 100uint8_t 안에서 넘치지 않고 int 로 300 이 된다. 이걸 모르면 "8비트니까 44 가 되겠지" 라고 잘못 예상한다.

반대 방향이 더 위험하다.

uint16_t crc = 0xFFFF;
crc = crc >> 1 ^ (crc & 1 ? 0xA001 : 0);   // 중간 결과가 int 다
int      len = -1;
uint32_t n   = 10;
if (len < n) {              // ✗ len 이 4294967295 로 변환돼 거짓이 된다
    /* 여기로 오지 않는다 */
}

부호 있는 것과 없는 것을 비교하면 부호 있는 쪽이 없는 쪽으로 변환된다. -1 이 거대한 양수가 되어 조건이 뒤집힌다. 버퍼 길이 검사에서 이 패턴이 보안 취약점이 된다.

if (len >= 0 && (uint32_t)len < n) { }     // ✓ 부호를 먼저 확인하고 캐스팅

크기를 명시한다

int      x;      // ✗ 16비트일 수도 32비트일 수도 있다 (플랫폼 의존)
uint32_t y;      // ✓ 항상 32비트

int 의 크기는 표준이 정하지 않는다. 8비트 MCU 에서 int 가 16비트인 경우가 실제로 있다. 레지스터·프로토콜·버퍼 크기에는 반드시 <stdint.h> 의 고정 폭 타입을 쓴다.

#include <stdint.h>
uint8_t  · int8_t      정확히 8비트
uint16_t · int16_t
uint32_t · int32_t
uintptr_t              포인터를 담을 수 있는 정수

구조체 패딩 — 크기가 예상과 다르다

struct Bad {
    uint8_t  flag;      // 1바이트
    uint32_t value;     // 4바이트 — 4의 배수 주소에 놓여야 한다
    uint8_t  id;
};
// sizeof(struct Bad) == 12
오프셋  0    1  2  3    4  5  6  7    8    9 10 11
       flag [ 패딩 ]   [  value  ]   id  [ 패딩 ]
            └ 3바이트 낭비          └ 3바이트 낭비 (구조체 정렬 맞춤)

컴파일러가 각 멤버를 자기 크기의 배수 주소에 놓으려고 빈틈을 넣는다. 정렬이 안 맞으면 접근이 느리거나(x86) 폴트가 나기(일부 ARM 설정) 때문이다.

struct Good {
    uint32_t value;     // 큰 것부터
    uint8_t  flag;
    uint8_t  id;
};
// sizeof(struct Good) == 8   — 4바이트 절약

멤버를 큰 것부터 배치하면 패딩이 줄어든다. SRAM 이 128KB 인 환경에서 구조체 배열이 1,000개면 4KB 차이가 난다.

실제로 컴파일해 오프셋을 찍어 보면 배치가 눈에 보인다.

printf("Bad : size=%zu  flag@%zu value@%zu id@%zu\n",
       sizeof(struct Bad), offsetof(struct Bad,flag),
       offsetof(struct Bad,value), offsetof(struct Bad,id));
// Bad : size=12  flag@0 value@4 id@8      ← flag 뒤 3바이트, id 뒤 3바이트가 패딩
// Good: size=8   value@0 flag@4 id@5      ← 빈틈이 2바이트뿐

크기를 "가정"하지 말고 컴파일 타임에 잠가라

통신 프로토콜 구조체나 Flash 에 저장하는 설정 구조체는 크기가 바뀌면 호환이 깨진다. 그런데 멤버를 하나 추가하면 패딩 때문에 예상과 다르게 커지는 일이 흔하다.

#include <assert.h>
static_assert(sizeof(struct Packet) == 16, "패킷 크기가 바뀌었다 — 프로토콜 호환 확인");
static_assert(offsetof(struct Packet, crc) == 14, "crc 위치가 밀렸다");

컴파일 타임에 막아 두면 "실기에서 통신이 깨진 뒤에" 발견하는 사고를 없앤다. (C11 static_assert, 그 이전이면 typedef char x[(cond)?1:-1] 관용구.)

함정 — __attribute__((packed)) 은 만능이 아니다. 패딩을 없애 크기는 맞출 수 있지만, 그 안의 멤버는 정렬되지 않은 주소에 놓인다. 그 멤버의 주소를 얻어 포인터로 넘기면 Cortex-M 에서 UsageFault(UNALIGNED) 가 난다(18편의 폴트 표에 있는 그 항목이다).

struct __attribute__((packed)) P { uint8_t t; uint32_t v; };
struct P p;
uint32_t x = p.v;          // ✓ 컴파일러가 바이트 단위로 나눠 읽어 준다
uint32_t *ptr = &p.v;      // ✗ 정렬 안 된 주소 — 이걸 역참조하면 폴트

packed 구조체 멤버의 주소는 넘기지 않는다가 규칙이다.

통신 프로토콜에 구조체를 그대로 쓰면 안 된다

struct Packet {          // ✗ 이걸 그대로 전송하면
    uint8_t  cmd;
    uint32_t addr;       // 여기 앞에 패딩 3바이트가 낀다
    uint16_t len;
};
send(&pkt, sizeof pkt);  // 상대 컴파일러의 패딩이 다르면 파싱이 깨진다
// ✓ 방법 ① — 패딩을 없앤다 (컴파일러 확장)
struct __attribute__((packed)) Packet {
    uint8_t  cmd;
    uint32_t addr;
    uint16_t len;
};                        // sizeof == 7

packed 는 편하지만 대가가 있다 — 정렬이 깨진 멤버 접근이 생긴다. ARM 에서 컴파일러가 바이트 단위로 쪼개 읽는 코드를 만들어 느려지고, 그 멤버의 주소를 포인터로 넘기면 정렬 위반이 된다.

// ✓ 방법 ② — 바이트 배열로 직접 직렬화한다 (가장 안전하다)
size_t pack(uint8_t *buf, const Packet *p) {
    size_t i = 0;
    buf[i++] = p->cmd;
    buf[i++] = (p->addr >> 24) & 0xFF;      // 빅엔디안으로 명시
    buf[i++] = (p->addr >> 16) & 0xFF;
    buf[i++] = (p->addr >>  8) & 0xFF;
    buf[i++] =  p->addr        & 0xFF;
    buf[i++] = (p->len  >>  8) & 0xFF;
    buf[i++] =  p->len         & 0xFF;
    return i;
}

길어 보이지만 패딩·엔디안·정렬 문제가 한꺼번에 사라진다. 프로토콜 코드는 이렇게 쓰는 것이 정석이다.

엔디안 — 같은 값이 다르게 저장된다

uint32_t v = 0x12345678;
uint8_t *p = (uint8_t *)&v;

// 리틀엔디안 (ARM 기본, x86)
p[0]=0x78  p[1]=0x56  p[2]=0x34  p[3]=0x12    ← 낮은 바이트가 먼저

// 빅엔디안 (네트워크 표준, 일부 MCU)
p[0]=0x12  p[1]=0x34  p[2]=0x56  p[3]=0x78
// 실행 중에 확인하는 법
uint16_t t = 0x0001;
bool little = (*(uint8_t *)&t == 0x01);

네트워크와 대부분의 시리얼 프로토콜은 빅엔디안이다. 그래서 변환이 필요하다.

uint32_t swap32(uint32_t v) {
    return __REV(v);          // Cortex-M 단일 명령
}
// 또는 이식성 있게
uint32_t swap32_portable(uint32_t v) {
    return ((v & 0xFF000000) >> 24) | ((v & 0x00FF0000) >> 8)
         | ((v & 0x0000FF00) <<  8) | ((v & 0x000000FF) << 24);
}

정렬 위반 — MCU 에서는 폴트다

uint8_t buf[16];
uint32_t *p = (uint32_t *)(buf + 1);    // 1바이트 오프셋 — 4의 배수가 아니다
uint32_t v = *p;                         // UsageFault (정렬 검사가 켜져 있으면)
// ✓ 안전하게 — 바이트씩 읽어 조립한다
uint32_t read_u32_le(const uint8_t *b) {
    return (uint32_t)b[0] | ((uint32_t)b[1] << 8)
         | ((uint32_t)b[2] << 16) | ((uint32_t)b[3] << 24);
}

// ✓ 또는 memcpy — 컴파일러가 정렬을 알아서 처리한다
uint32_t v;
memcpy(&v, buf + 1, sizeof v);

memcpy 가 느릴 것 같지만 최적화가 켜지면 대개 인라인되어 최소 명령으로 바뀐다. 정렬을 확신할 수 없는 버퍼에서 값을 꺼낼 때 가장 안전한 방법이다.

volatile 을 붙일 자리와 안 붙일 자리

volatile uint32_t *reg;      // ✓ 주변장치 레지스터 — 하드웨어가 바꾼다
volatile bool flag;          // ✓ ISR 이 바꾸고 메인이 읽는 변수
volatile int counter;        // ✗ 원자성을 주지는 않는다

volatile 은 "매번 실제로 읽고 쓰라" 는 뜻일 뿐 원자성과는 무관하다.

컴파일러가 실제로 무슨 짓을 하는지 — 어셈블리로 확인

말로만 "최적화로 사라진다"고 하면 잘 안 와닿는다. 같은 코드를 volatile 만 빼고 컴파일해 비교하면 즉시 보인다.

int          flag_plain;      // volatile 없음
volatile int flag_vol;

void wait_plain(void) { while (flag_plain == 0) { } }
void wait_vol  (void) { while (flag_vol   == 0) { } }

-O2 로 컴파일한 결과다.

wait_plain:
        ret                       ← 루프가 통째로 사라졌다. 함수가 즉시 반환한다

wait_vol:
        adrp    x8, flag_vol
        ldr     x8, [x8, ...]
LBB1_1:
        ldr     w9, [x8]          ← 매 반복 실제로 메모리를 읽는다
        cbz     w9, LBB1_1        ← 0 이면 다시
        ret

wait_plainret 한 줄로 줄었다. 컴파일러의 논리는 이렇다 — "이 루프 안에서 flag_plain 을 바꾸는 코드가 없다. 그러면 진입할 때 0 이면 영원히 0 이라 무한 루프이고, 0 이 아니면 즉시 빠져나온다. 무한 루프는 관측 가능한 동작이 없으니 지워도 된다."

컴파일러는 ISR 이나 DMA 나 하드웨어가 그 변수를 바꾼다는 사실을 모른다. C 표준의 관점에서 그것은 존재하지 않는 일이다. volatile 이 바로 그 사실을 알려 주는 유일한 수단이다.

함정 — 이 버그는 디버그 빌드(-O0)에서는 안 나타난다. -O0 은 최적화를 안 하므로 매번 읽기 때문이다. "디버거로는 잘 되는데 릴리스로 구우면 멈춘다" 의 대표 원인이 이것이다. 반대로 volatile 을 남발하면 최적화가 막혀 느려지므로, 하드웨어가 바꾸는 것 · ISR 이 바꾸는 것 · setjmp 로 넘나드는 지역변수에만 붙인다.

volatile uint32_t ticks;

void SysTick_Handler(void) { ticks++; }      // ISR 에서 증가

void main(void) {
    uint32_t t = ticks;      // 32비트 읽기는 원자적이다 (정렬돼 있으면)
    ticks++;                 // ✗ 읽기·수정·쓰기 — ISR 과 겹치면 증가가 사라진다
}
volatile uint64_t us_count;   // ✗ 64비트는 32비트 두 번에 나뉘어 읽힌다
uint64_t t = us_count;         //   중간에 ISR 이 바꾸면 상·하위가 어긋난 값이 나온다

64비트 변수를 ISR 과 공유하면 찢어진 값(torn read)을 읽는다. 임계 구역으로 감싸거나, 두 번 읽어 같은지 확인하는 방법을 쓴다.

uint64_t read_us(void) {
    uint32_t hi, lo;
    do {
        hi = hi_word;
        lo = lo_word;
    } while (hi != hi_word);       // 상위가 안 바뀌었으면 일관된 값이다
    return ((uint64_t)hi << 32) | lo;
}

동적 할당 — 대개 안 쓴다

char *buf = malloc(256);      // ✗ 임베디드에서 피하는 것이 기본

이유가 셋이다.

  • 실패할 수 있다 — 그런데 실패했을 때 할 수 있는 일이 없다
  • 단편화 — 오래 돌면 전체 여유는 있는데 연속 공간이 없어 실패한다. 재부팅 전까지 회복되지 않는다
  • 시간이 불확정 — 할당에 걸리는 시간이 상황마다 달라 실시간성이 깨진다
// ✓ 정적 할당 — 크기가 컴파일 시점에 정해진다
static uint8_t rx_buf[256];

// ✓ 고정 크기 풀 — 동적 느낌이 필요하면 이렇게
static Block pool[16];
static uint16_t used_mask;

Block *pool_alloc(void) {
    int i = __builtin_ffs(~used_mask) - 1;    // 빈 자리를 O(1) 로 찾는다
    if (i < 0 || i >= 16) return NULL;
    used_mask |= (1U << i);
    return &pool[i];
}

정적 할당의 진짜 이점은 "링크 시점에 메모리 부족을 안다" 는 것이다. SRAM 이 넘치면 빌드가 실패하므로, 운영 중에 메모리가 모자랄 일이 없다.

재진입성 — ISR 에서 부르면 안 되는 함수

실행 도중에 다시 불려도 결과가 망가지지 않는 함수를 재진입 가능하다고 한다. 내부에 정적 변수나 공유 버퍼를 두면 재진입 안전하지 않고, 인터럽트가 있는 환경에서는 그것이 곧 버그가 된다.

void ISR_Handler(void) {
    printf("event\r\n");       // ✗ 내부 버퍼를 공유해 재진입 안전하지 않다
    char *p = malloc(16);      // ✗ 힙 자료구조가 깨질 수 있다
    time_t t = time(NULL);     // ✗ 내부 정적 상태
    sprintf(buf, "%d", x);     // ✗ 느리고 스택을 많이 쓴다
}
void ISR_Handler(void) {       // ✓ ISR 은 짧게, 신호만 남긴다
    event_flag = 1;            // volatile 플래그를 세우고
    ring_push(&q, value);      // 링 버퍼에 넣고
}                              // 실제 처리는 메인 루프에서

ISR 의 규칙은 하나로 요약된다 — "빨리 나오라." 그 안에서 오래 걸리는 일을 하면 다른 인터럽트가 지연되고, 같은 인터럽트가 다시 들어오면 놓친다.

부동소수점 — 있는지 확인하고 쓴다

float x = a * b + c;      // FPU 가 있으면 몇 클럭, 없으면 소프트웨어 에뮬레이션
double y = a * b;         // ✗ Cortex-M4 FPU 는 단정도(32비트)만 지원한다

double 을 쓰면 FPU 가 있어도 소프트웨어 라이브러리로 떨어져 수십 배 느려진다. 상수도 조심해야 한다.

float f = 1.5;            // ✗ 1.5 는 double 리터럴 — 승격·강등이 낀다
float f = 1.5f;           // ✓ float 리터럴

그리고 ISR 에서 부동소수점을 쓰면 FPU 레지스터까지 저장해야 해서 컨텍스트 저장 비용이 커진다. 지연이 중요한 ISR 에서는 정수 연산으로 처리한다.

// 고정소수점 — FPU 없이 소수를 다루는 방법
#define Q16(x)   ((int32_t)((x) * 65536.0f))     // 16.16 형식
int32_t mul_q16(int32_t a, int32_t b) {
    return (int32_t)(((int64_t)a * b) >> 16);     // 중간을 64비트로
}

배열·문자열에서 데는 곳

char buf[8];
strcpy(buf, "0123456789");        // ✗ 넘친다. 경고도 안 난다
snprintf(buf, sizeof buf, "%s", src);   // ✓ 항상 크기를 넘긴다
void f(uint8_t arr[16]) {
    sizeof(arr);      // ✗ 16 이 아니라 포인터 크기(4) 다
}
void g(uint8_t *arr, size_t n) { }      // ✓ 길이를 따로 받는다

배열은 함수 인자로 넘어가는 순간 포인터가 된다. sizeof 로 길이를 알 수 없으므로 반드시 함께 넘긴다.


한눈에 정리

  • 정수 승격int 보다 작은 타입은 연산 전에 int 가 된다. 부호 있는 것과 없는 것을 비교하면 부호가 사라진다
  • int 의 크기는 표준이 정하지 않는다 — 레지스터·프로토콜엔 <stdint.h> 고정 폭 타입
  • 구조체 패딩 — 큰 멤버부터 배치하면 크기가 준다. 통신에는 구조체를 그대로 쓰지 않고 바이트 배열로 직렬화한다
  • packed정렬 깨진 접근을 만든다 — 편하지만 느려지고 포인터로 넘기면 위험하다
  • 정렬 위반은 MCU 에서 폴트 — 확신 없는 버퍼는 바이트씩 조립하거나 memcpy
  • volatile원자성이 아니다 — 32비트 읽기는 원자적이지만 ++ 는 아니고, 64비트는 찢어진다
  • 동적 할당은 피한다 — 단편화는 재부팅까지 회복되지 않는다. 정적 할당은 링크 시점에 부족을 알려 준다
  • ISR 은 짧게printf·malloc·sprintf 금지. 플래그와 링 버퍼만 건드리고 나온다
  • double 은 FPU 가 안 받는다(Cortex-M4 는 단정도). 리터럴에 f 를 붙인다. ISR 에서는 정수·고정소수점
  • 배열은 인자로 넘어가면 포인터sizeof 가 안 통하니 길이를 함께 넘긴다

꼬리질문 대비

  • "uint8_t a = 0xF0; ~a 의 값은?" → 0xFFFFFF0F — 정수 승격으로 int 에서 연산된다. 8비트를 원하면 (uint8_t)~a
  • "디버그 빌드는 되는데 릴리스에서 멈춘다. 대표 원인은?" → volatile 누락. -O0 은 매번 읽지만 -O2 는 루프를 통째로 지운다
  • "packed 구조체에서 하면 안 되는 것은?" → 멤버의 주소를 얻어 포인터로 넘기는 것 — 정렬 위반으로 UsageFault(UNALIGNED)
  • "구조체 크기를 컴파일 타임에 고정하려면?" → static_assert(sizeof(T)==N), offsetof 로 멤버 위치까지 잠근다

출처 — ISO/IEC 9899:2018 (C17) §6.3.1.1 정수 승격 · §6.3.1.8 통상 산술 변환 · §6.7.2.1 구조체 · MISRA C:2012 Rule 10.x(타입 변환) · Rule 21.3(동적 할당) · Arm, Cortex-M4 Technical Reference Manual (정렬·FPU) · Arm, Procedure Call Standard for the Arm Architecture(AAPCS) — 정렬 규칙

비트 조작 — 레지스터를 정확히 건드리는 법부팅 — 전원을 넣고 main 까지