C 언어 핵심 — 임베디드 면접 (실제 기출 기반·심화)
임베디드/펌웨어 면접은 C에서 당락이 갈린다. 현대모비스 SW 신입 코딩테스트는 4문제 중 최소 1문제가 C/C++ 필수(180분)이고, 삼성·LG·SK하이닉스·방산(LIG넥스원·한화)·자동차(현대오토에버) 모두 volatile, 메모리 영역, 구조체 패딩, 포인터를 반드시 묻는다. 이 노트는 실제로 나온 질문을 토대로, 왜 그렇게 동작하는지와 면접관이 파고드는 꼬리질문·함정까지 다룬다.
면접 흐름은 항상 개념 → "왜?" → 꼬리질문 → 함정 확인으로 간다. 정의만 외우면 두 번째 질문에서 무너진다.
1. 메모리 구조 — 모든 펌웨어 질문의 출발점 (★★★ 최빈출)
실제 질문: "메모리의 4가지(코드/데이터/힙/스택) 영역을 설명하라" · "초기화된 전역변수와 초기화 안 된 전역변수는 각각 어디에 저장되나?" · "스택과 힙의 차이는?" (velog 다수·gyoogle OS 챕터)
C 프로그램이 메모리에 올라가면 용도별로 영역이 나뉜다. 임베디드에선 어디에 얼마나 들어가는지가 RAM(KB 단위) 설계와 직결돼 더 깊이 묻는다.
| 영역 | 무엇이 | 특징 |
|---|---|---|
| .text(코드) | 기계어 명령·const 문자열 | 읽기 전용·실행. 펌웨어는 Flash(ROM) |
| .rodata | const 상수 | 읽기 전용. 보통 Flash |
| .data | 초기값 있는 전역·static | 초기값을 Flash에 보관 → 부팅 시 RAM으로 복사 |
| .bss | 초기값 0/없는 전역·static | 실행 파일에 데이터 없이 크기만 기록 → 부팅 시 RAM을 0으로 클리어 |
| heap(힙) | malloc 동적 할당 | 낮은→높은 주소로 성장·수동 관리 |
| stack(스택) | 지역변수·인자·복귀주소 | 높은→낮은 주소로 성장·자동(LIFO) |
int g_init = 7; // .data — 초기값 7을 Flash에 두었다가 RAM으로 복사
int g_zero; // .bss — Flash엔 "크기"만, 부팅 시 RAM에서 0
const char msg[] = "hi";// .rodata(Flash)
void f(void) {
int local; // stack — 초기화 안 하면 쓰레기값
static int s = 0; // 수명=프로그램 전체. 0이라 보통 .bss (명시적 =0의 배치는 구현 정의)
int *p = malloc(16); // p는 stack, p가 가리키는 16바이트는 heap
}
깊이 — 펌웨어 부팅과의 연결(★ 단골 꼬리): .data와 .bss는 main 전에 **startup 코드(crt0)**가 준비한다. ① .data: Flash에 저장된 초기값을 RAM의 .data 영역으로 복사(memcpy). ② .bss: RAM의 .bss 영역을 0으로 클리어(memset). 이게 "초기화 안 한 전역은 0 보장(C 표준), 지역변수는 쓰레기값"의 진짜 이유다 — 전역은 .bss라 startup이 0으로 채우고, 지역은 그냥 스택 위치라 이전 값이 남는다.
왜 .bss가 따로 있나? 0으로 초기화될 수백 KB 배열을 실행 파일에 전부 0으로 저장하면 바이너리가 거대해진다. .bss는 크기 정보만 담아 펌웨어 이미지(Flash 사용량)를 줄인다.
실전 꼬리질문
- "전역변수가 0으로 자동 초기화되는 이유는?" → .bss를 startup이 클리어하기 때문.
- "
.data의 초기값은 어디 있다가 RAM에 올라오나?" → Flash(ROM)에 보관 → 부팅 시 복사. (그래서 Flash엔 코드 + .data 초기값이 같이 산다) - "스택은 위로 자라나 아래로 자라나? 왜?" → 보통 높은 주소→낮은 주소. 힙과 반대 방향으로 자라 서로 가운데서 만날 때까지 공간을 공유.
함정/오개념
- ❌ "
static지역변수는 스택에 있다" → .data/.bss다(수명이 프로그램 전체). - ❌ "동적 할당은 스택에서 한다" →
malloc은 힙. - ❌ "초기화 안 한 지역변수도 0이다" → 아니다(스택 쓰레기값). 0 보장은 전역/static만.
2. volatile — 가장 많이 묻고 가장 많이 틀린다 (★★★)
실제 질문: "C에서
volatile을 언제 사용해야 하는가?" · "임베디드에서 volatile이 왜 중요한가?" (nhtranngoc embedded-interview Q3·velog @llsuzn·lunakimvision)
메커니즘: 컴파일러는 최적화를 위해 변수를 레지스터에 캐싱하고, "코드가 안 바꿨으니 값이 그대로일 것"이라 가정해 반복 읽기를 생략하거나 연속 쓰기를 하나로 합친다. volatile은 "이 변수는 프로그램 흐름 밖에서 비동기로 바뀐다. 매 접근마다 실제 메모리 주소에서 읽고 써라(최적화 금지)"는 지시다.
3대 사용처:
// (1) 메모리 맵 하드웨어 레지스터 — 하드웨어가 값을 바꾼다
#define UART_SR (*(volatile uint32_t*)0x40011000)
while (!(UART_SR & RXNE)) { } // volatile 없으면 SR을 한 번만 읽고 무한 루프
// (2) ISR이 갱신하는 전역 플래그 — 인터럽트가 비동기로 바꾼다
volatile uint8_t g_rx_done = 0;
void USART_IRQHandler(void){ g_rx_done = 1; }
while (!g_rx_done) { } // volatile 없으면 0으로 캐싱 → 영원히 못 빠져나옴
// (3) 다른 실행 흐름이 바꾸는 플래그 — 단, 멀티스레드 "동기화"엔 volatile이 답이 아니다(아래 함정)
⚠️ (3)을 오해 말 것: C/C++에서 **멀티스레드 공유 동기화는
volatile이 아니라_Atomic/<stdatomic.h>**가 정답이다.volatile은 컴파일러 최적화만 막고 메모리 배리어·원자성을 보장하지 않아 멀티코어에서 순서·가시성이 깨진다. 단일 코어에서 ISR↔메인 단일 워드 플래그엔 volatile로 충분하다.
왜 (1)에서 치명적인가? 같은 주소에 연속으로 쓰면 컴파일러는 "마지막 쓰기만 의미 있다"며 앞 쓰기를 제거한다. 하지만 하드웨어 레지스터엔 각 쓰기가 별개 동작(예: FIFO에 바이트 푸시)이라 합치면 데이터가 날아간다.
const volatile (★ 꼬리질문 단골): 읽기 전용이지만 하드웨어가 값을 바꾸는 레지스터(상태·카운터 레지스터)에 쓴다. "코드는 여기 쓰지 마라(const) + 매번 다시 읽어라(volatile)".
포인터 위치 (★ 꼬리): volatile int *p(가리키는 값이 volatile) vs int * volatile p(포인터 자체가 volatile).
함정/오개념 (면접관이 노리는 핵심)
- ❌ "volatile = 스레드 안전/원자적" → 틀림. volatile은 가시성(매번 메모리 접근)만 보장하고 원자성·메모리 배리어는 보장하지 않는다.
g_counter++(읽기-수정-쓰기 3단계)는 volatile이어도 ISR과 겹치면 깨진다 → 임계구역(인터럽트 disable)·_Atomic·락 필요. - ❌ "volatile = CPU 캐시 끄기" → 틀림. 막는 건 컴파일러 최적화지 CPU 데이터 캐시가 아니다. DMA-캐시 일관성은 별개 문제(메모리·아키텍처 편).
3. const · static · extern — 한정자와 기억부류 (★ 단골)
실제 질문: "
static이 무엇인가?" · "const와volatile의 차이는?" (falinux 강좌·frontjang 기억부류 정리)
const — 컴파일러 차원의 읽기 전용 약속(런타임 강제가 아님). const 객체는 .rodata(임베디드는 Flash)에 배치돼 RAM을 아낀다. 포인터 위치별 의미:
const int *p; // 가리키는 값이 const (p는 이동 가능)
int *const p; // 포인터 p가 const (값은 변경 가능)
const int *const p; // 둘 다
static의 두 의미 (★ 꼬리):
- 전역/함수에 붙으면 → 내부 링키지(internal linkage): 그
.c파일 밖에서 안 보임 → 심볼 캡슐화·네임스페이스 오염 방지. - 지역에 붙으면 → 수명이 프로그램 전체(.data/.bss). 스코프는 함수 안이지만 호출 간 값이 유지된다.
extern — 정의가 다른 곳에 있음을 선언만. 헤더에 extern int g;, .c 한 곳에 실제 정의 int g;.
함정/오개념
- ❌ "
const는 메모리 보호다" → 일반 변수의 const는 컴파일 검사일 뿐, 포인터 캐스팅으로 우회 가능. 진짜 보호는 MPU/MMU(아키텍처 편). - ❌ "
static지역변수는 호출마다 0으로 초기화된다" → 최초 1회만. - ❌ "지역
static은 재진입 안전하다" → 아니다. 전역 상태를 갖는 함수는 비재진입(ISR과 공유 시 위험).strtok같은 정적 버퍼 함수가 대표적.
4. 포인터·배열 (★★ 손코딩 단골)
실제 질문: "함수 포인터를 선언하라" · "배열 이름에 새 주소를 할당하면?" → 컴파일 에러(배열명은 붙박이 주소) · "메모리 주소가 4바이트 경계에 정렬됐는지 판단하라" →
(addr & 0x3) == 0(nhtranngoc Q15·Q21·VEDA 후기)
// 함수 포인터 — 콜백·상태머신·인터럽트 벡터 테이블의 핵심
void (*isr_vector[16])(void); // ISR 점프 테이블
typedef void (*EventCb)(uint8_t); // 콜백 타입
void on_rx(EventCb cb, uint8_t b){ cb(b); } // 이벤트 → 등록된 콜백 호출
// 4바이트 정렬 판정 — 하위 2비트가 0이면 4의 배수
int aligned4 = ((uintptr_t)addr & 0x3) == 0;
깊이
- 포인터 산술:
p + 1은 바이트 1이 아니라 가리키는 타입 크기만큼 이동. - 배열은 포인터가 아니다: 배열명은 주소 상수(재대입 불가).
sizeof(arr)는 배열 전체 크기지만, 함수 인자로 넘기면 포인터로 decay(sizeof는 포인터 크기 → 길이를 따로 넘겨야). - 함수 포인터: 펌웨어의 HAL·드라이버 콜백·인터럽트 벡터가 전부 함수 포인터다.
꼬리질문
- "
int a[5]에서&a와a의 타입 차이?" →a는int*(decay),&a는int(*)[5]→&a + 1은 배열 전체만큼 점프. - "NULL 포인터를 역참조하면 임베디드에선?" → MMU 없는 MCU는 주소 0(보통 Flash/벡터테이블)을 실제로 접근 → 크래시가 아니라 엉뚱한 동작. MPU로 0번지 보호 가능.
함정: ❌ "배열 = 포인터" → 동작이 비슷할 뿐 별개 타입(sizeof·& 의미가 다름).
5. 구조체 패딩·정렬 (★★ sizeof 계산 단골)
실제 질문: "구조체 패딩이 무엇이고 왜 생기나?" · "이 구조체의
sizeof는?"(멤버를 주고 계산시킴) · "패딩을 없애려면?" (velog @merju·wnsgml972)
CPU는 데이터를 워드 경계에서 효율적으로 읽는다. 컴파일러는 각 멤버를 자기 크기의 배수 오프셋에 놓으려 사이/끝에 패딩을 넣는다(시작엔 안 넣음). 구조체 전체 크기는 가장 큰 멤버 정렬의 배수로 맞춘다(tail padding).
struct A { // 4바이트 정렬 가정
char c; // offset 0
// 3바이트 패딩 (int를 4의 배수 오프셋에 두려고)
int i; // offset 4
short s; // offset 8
// 2바이트 패딩 (전체 크기를 4의 배수로)
}; // sizeof = 12
struct B { // 멤버를 큰 것 → 작은 것 순으로 재배치
int i; // 0
short s; // 4
char c; // 6
// 1바이트 패딩
}; // sizeof = 8 ← 같은 멤버인데 4바이트 절약
깊이
- 멤버 순서를 크기 내림차순으로 두면 패딩이 줄어 RAM을 아낀다(임베디드 실전).
#pragma pack(1)/__attribute__((packed)): 패딩 제거(프로토콜·플래시 포맷 정확 매핑). 대가는 비정렬 접근 → ARM Cortex-M0(ARMv6-M)는 HardFault, M3/M4는 일반 접근은 되나 LDM/STM·FPU는 불가. packed 멤버의 주소를 취해 일반 포인터로 넘기면 컴파일러가 정렬됐다 오해 → 하드폴트.
꼬리질문
- "
struct {char; int; char;}의 크기는?" → 보통 12(위 struct A 논리). - "구조체를 네트워크/플래시로 그대로 전송하면?" → 패딩 + 엔디안 차이로 깨짐 →
packed+ 엔디안 변환, 또는 멤버별 직렬화. - "packed의 단점은?" → 비정렬 접근 페널티·하드폴트 위험.
함정: ❌ "패딩은 낭비니 항상 pack(1)이 좋다" → 속도 손해 + 하드폴트. ❌ "멤버 순서는 sizeof에 영향 없다" → 있다.
6. 비트 연산 — 레지스터 제어의 기본기 (★★ 손코딩)
실제 질문: "특정 비트 set/clear/toggle/check 매크로를 작성하라" · "임의 수를 다음 2의 거듭제곱으로 올림하라" · "CLZ를 구현하라" (nhtranngoc Q8·Q54)
reg |= (1u << n); // SET n번 비트
reg &= ~(1u << n); // CLEAR
reg ^= (1u << n); // TOGGLE
if (reg & (1u << n)) { } // CHECK
reg = (reg & ~(0xFu << 4)) | (val << 4); // 4~7비트 필드에 val 쓰기(read-modify-write)
int is_pow2 = x && !(x & (x - 1)); // x & (x-1)은 최하위 1비트 제거 → 0이면 2의 거듭제곱
// CLZ(상위 0 개수) → 다음 2의 거듭제곱으로 올림 (32비트)
uint32_t round_up_pow2(uint32_t x){
return x <= 1 ? 1u : 1u << (32 - __builtin_clz(x - 1)); // GCC/Clang 내장
}
깊이
1u로 쓰는 이유(★):1 << 31은 signed 오버플로(UB),1u << 31은 안전.- 비트필드(
struct{ unsigned a:3; })는 이식성 주의: 비트 배치 순서·패딩이 구현 정의(엔디안·컴파일러마다 다름) → 통신 프로토콜·하드웨어 레지스터 매핑엔 마스크/시프트가 안전.
꼬리질문: "1 << 31을 int로 하면?" → 부호 비트 오버플로 UB → 1u. / "signed 우측 시프트는?" → C17까지 산술/논리가 구현 정의(C++20·C23부터 산술 시프트로 정의).
함정: ❌ "비트 순서 = 바이트 순서"(엔디안과 혼동). ❌ "비트필드로 패킷을 그대로 파싱하면 이식 가능".
7. 엔디안 (★ 단골)
실제 질문: "빅엔디안과 리틀엔디안 차이?" · "현재 시스템이 어느 엔디안인지 C로 확인하라"
uint32_t n = 0x12345678;
// 리틀엔디안: 최저 주소에 0x78 / 빅엔디안: 최저 주소에 0x12
int little = (*(uint8_t*)&n == 0x78);
깊이: 네트워크 바이트 오더 = 빅엔디안(htonl/ntohl — 단, 이 함수들은 데스크톱 <arpa/inet.h> 것이라 베어메탈 MCU엔 없을 수 있어 __builtin_bswap32·수동 스왑을 쓴다). x86·대부분 ARM은 리틀(ARM은 bi-endian이나 보통 리틀). 서로 다른 엔디안 기기·파일·플래시 데이터를 공유하면 바이트 순서가 뒤집혀 깨진다 → 직렬화 시 변환.
꼬리질문: "x86은? ARM은?" → x86 리틀, ARM bi-endian(보통 리틀). / "비트필드 순서도 엔디안 영향?" → 받는다(구현 정의·이식성 함정).
함정: ❌ "엔디안은 비트 순서다" → 바이트 순서다.
8. 정수 승격·타입 — 숨은 버그의 단골 (★ 심화)
실제 질문: "
unsigned와signed를 비교하면?" · "uint8_t a=200, b=100; a+b의 타입과 값은?"
깊이
- 정수 승격(integer promotion):
int보다 작은 타입(char·short)은 연산 시int로 승격.uint8_t a=200,b=100; a+b는 int로 계산되어 300(255로 wrap 안 됨). - 부호 변환 함정(usual arithmetic conversion): 부호가 다르면 unsigned 쪽에 맞춰진다 →
-1 > 0u가 참(-1이 거대한 unsigned로).if (i < size)에서size가 unsigned면 음수i가 거대한 양수가 돼 무한 루프. char의 부호는 구현 정의(ARM은 보통 unsigned char). signed 오버플로는 UB, unsigned는 모듈로 wrap.- → 폭이 의미 있으면
<stdint.h>의uint32_t·int8_t고정폭 타입을 써라(int는 16/32비트로 플랫폼마다 다름).
꼬리질문: "if (-1 < sizeof(x)) 결과는?" → sizeof가 unsigned라 -1이 큰 양수 → 거짓. / "고정폭 타입을 쓰는 이유?" → 레지스터·프로토콜 폭 이식성.
함정: ❌ "char는 항상 signed". ❌ "작은 타입끼리 연산은 그 타입으로 계산된다"(→ int 승격).
9. 매크로·전처리기·빌드 (★ 단골)
실제 질문: "C 빌드 과정(전처리→컴파일→어셈블→링크)을 설명하라" · "MIN/MAX 매크로의 함정?" · "
#definevsconst/inline" (velog @ryuwon·@maantano)
#define SQ(x) ((x) * (x)) // 인자·전체를 괄호로 — 안 그러면 SQ(a+b) → a+b*a+b
#define SWAP(t,a,b) do { t tmp=a; a=b; b=tmp; } while(0) // 다중 문장은 do-while(0)
빌드 파이프라인: .c → (전처리 cpp) .i → (컴파일 cc1) .s → (어셈블 as) .o → (링크 ld) 실행/.elf. 임베디드는 여기에 링커 스크립트(.text=Flash, .data/.bss=RAM 배치)와 .bin/.hex 변환이 붙는다(펌웨어·빌드 편).
깊이: 전처리기는 타입·스코프 없는 텍스트 치환 → 인자 부작용이 중복 평가된다(MAX(a++, b)). 그래서 가능하면 static inline 함수(타입 안전·디버깅 쉬움)나 enum(디버거에 이름 보임).
꼬리질문: "헤더 가드 #ifndef vs #pragma once?" / "inline이 매크로보다 나은 점?" → 타입 검사·부작용 1회 평가·디버깅. / "정적(.a) vs 동적(.so) 라이브러리, 임베디드 선택은?" → 보통 정적(런타임 로더·메모리 부담↓).
함정: ❌ "매크로는 타입 검사를 한다". ❌ "매크로가 항상 inline보다 빠르다".
(보너스) ISR·재진입 — 메모리/동시성과의 접점
실제 질문: "ISR에서 절대 하면 안 되는 것은?" → 블로킹 호출·비재진입 함수·긴 함수·동적 할당(malloc) 금지 · "재진입(reentrant) 함수란?" (nhtranngoc Q2)
재진입 함수 조건: 전역/static 데이터를 쓰지 않고, 비재진입 함수를 호출하지 않으며, 자신의 호출자 상태를 인자/지역으로만 다룬다. ISR과 메인이 같은 함수를 부를 수 있으면 반드시 재진입 안전해야 한다. (인터럽트 편에서 심화)
한국 면접 단골 Q&A (답변 골격)
| 질문 | 핵심 답 |
|---|---|
| 메모리 4영역 | code(.text)/data(.data+.bss)/heap/stack·용도와 성장 방향 |
| 초기화 전역 vs 미초기화 전역 | .data(Flash→RAM 복사) vs .bss(startup이 0 클리어) |
| 전역은 0, 지역은? | 전역 .bss 0보장 / 지역 스택 쓰레기값 |
| volatile 언제 | 최적화 금지·매번 메모리 / MMIO·ISR 공유·멀티스레드 |
| volatile이면 race 안전? | 아니다(원자성·배리어 아님) → 임계구역·atomic |
| const volatile | 읽기 전용 + 하드웨어가 바꾸는 상태 레지스터 |
| static 두 의미 | 내부 링키지(파일 캡슐화) / 지역 수명 프로그램 전체 |
| 배열 = 포인터? | 별개 타입(sizeof·& 다름)·인자 전달 시 decay |
| 함수 포인터 어디 | 콜백·상태머신·인터럽트 벡터·HAL |
| struct 패딩 왜·sizeof | 정렬(자기 크기 배수)·재배치로 절약·packed→하드폴트 |
| 비트 set/clear/toggle | |=(1u<<n) / &=~(1u<<n) / ^=(1u<<n) |
| 엔디안 | 바이트 순서·리틀(ARM)/빅(네트워크)·확인 코드 |
| 정수 승격 함정 | 작은 타입 int 승격·-1 > 0u 참·고정폭 타입 |
| 빌드 과정 | 전처리→컴파일→어셈블→링크·링커 스크립트 |
| ISR 금기 | 블로킹·malloc·printf·긴 작업·비재진입 함수 |
꼬리질문 대비 (상 난이도)
- "volatile인데
count++가 깨진다?" → 읽기-수정-쓰기 비원자 → 인터럽트 막고(임계구역) 처리. - "왜 임베디드는 malloc을 피하나?" → 힙 단편화·할당 실패·비결정적 지연 → 정적 할당·메모리 풀·고정 블록.
- "packed 구조체로 패킷 파싱했더니 하드폴트?" → 비정렬 멤버 접근 →
memcpy로 정렬 버퍼에 옮겨 처리. - "초기화 안 한 전역이 0인 걸 믿어도 되나?" → C 표준 보장이나 startup이 .bss를 클리어해야 — 부트로더·링커 스크립트 확인.
- "
uint8_t끼리 곱했는데 오버플로가 안 난다?" → int 승격 후 계산 → 결과를 다시uint8_t에 담을 때 잘림.
한 줄 요약 — 변수는 .text/.rodata(Flash)·.data(Flash→RAM 복사)·.bss(startup 0 클리어)·heap(지양)·stack(쓰레기값)에 산다. volatile=최적화 금지·매번 메모리(MMIO·ISR·멀티스레드)이나 원자성 아님.
static은 내부 링키지+지역 수명,const는 읽기 전용 약속(보호 아님). 배열≠포인터(decay), 함수 포인터=콜백/벡터. struct 패딩(정렬·재배치 절약·packed→Cortex-M0 HardFault). 비트\|= &=~ ^=, 엔디안=바이트 순서, 정수 승격·-1>0u함정, 빌드 4단계.
(출처 — 한국 면접 기출·1차자료 교차검증 2026-06: nhtranngoc/embedded-interview-questions(Q2·Q3·Q8·Q15·Q21·Q54)·velog 메모리 구조(code/data/BSS/heap/stack)·velog @merju 구조체 패딩 최적화·lunakimvision volatile·velog @maantano 컴파일 과정 · ISO/IEC 9899(C 표준 — 저장 클래스·정수 승격)·ARM Architecture Reference Manual(비정렬 접근)·임베디드 레시피(히언) 교차검증.)