임베디드 C 함정 — 컴파일은 되는데 틀린 것들
PC 에서는 넘어가던 것이 MCU 에서 터진다. 이유는 셋이다. 정수 크기가 다르고, 정렬 위반이 폴트를 내고, 최적화가 하드웨어를 모른다.
여기서 다루는 것들은 전부 컴파일이 통과하고 경고도 안 나오는데 틀린 경우다. 그래서 알고 피하는 수밖에 없다.
정수 승격 — 부호가 조용히 섞인다
uint8_t a = 200;
uint8_t b = 100;
if (a + b > 255) { // 참이다. uint8_t 로 안 잘린다
/* 여기로 온다 */
}
C 는 int 보다 작은 타입을 연산 전에 int 로 승격한다. 200 + 100 은 uint8_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_plain 은 ret 한 줄로 줄었다. 컴파일러의 논리는 이렇다 — "이 루프 안에서 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) — 정렬 규칙