임베디드 면접 학습 노트 목차

C 언어 핵심 — 임베디드 면접 (실제 기출 기반·심화)

임베디드/펌웨어 면접은 C에서 당락이 갈린다. 현대모비스 SW 신입 코딩테스트는 4문제 중 최소 1문제가 C/C++ 필수(180분)이고, 삼성·LG·SK하이닉스·방산(LIG넥스원·한화)·자동차(현대오토에버) 모두 volatile, 메모리 영역, 구조체 패딩, 포인터를 반드시 묻는다. 이 노트는 실제로 나온 질문을 토대로, 왜 그렇게 동작하는지면접관이 파고드는 꼬리질문·함정까지 다룬다.

면접 흐름은 항상 개념 → "왜?" → 꼬리질문 → 함정 확인으로 간다. 정의만 외우면 두 번째 질문에서 무너진다.


1. 메모리 구조 — 모든 펌웨어 질문의 출발점 (★★★ 최빈출)

실제 질문: "메모리의 4가지(코드/데이터/힙/스택) 영역을 설명하라" · "초기화된 전역변수와 초기화 안 된 전역변수는 각각 어디에 저장되나?" · "스택과 힙의 차이는?" (velog 다수·gyoogle OS 챕터)

C 프로그램이 메모리에 올라가면 용도별로 영역이 나뉜다. 임베디드에선 어디에 얼마나 들어가는지가 RAM(KB 단위) 설계와 직결돼 더 깊이 묻는다.

영역무엇이특징
.text(코드)기계어 명령·const 문자열읽기 전용·실행. 펌웨어는 Flash(ROM)
.rodataconst 상수읽기 전용. 보통 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.bssmain 전에 **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이 무엇인가?" · "constvolatile의 차이는?" (falinux 강좌·frontjang 기억부류 정리)

const — 컴파일러 차원의 읽기 전용 약속(런타임 강제가 아님). const 객체는 .rodata(임베디드는 Flash)에 배치돼 RAM을 아낀다. 포인터 위치별 의미:

const int *p;       // 가리키는 값이 const (p는 이동 가능)
int *const p;       // 포인터 p가 const (값은 변경 가능)
const int *const p; // 둘 다

static의 두 의미 (★ 꼬리):

  1. 전역/함수에 붙으면내부 링키지(internal linkage): 그 .c 파일 밖에서 안 보임 → 심볼 캡슐화·네임스페이스 오염 방지.
  2. 지역에 붙으면수명이 프로그램 전체(.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]에서 &aa의 타입 차이?" → aint*(decay), &aint(*)[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 << 31int로 하면?" → 부호 비트 오버플로 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. 정수 승격·타입 — 숨은 버그의 단골 (★ 심화)

실제 질문: "unsignedsigned를 비교하면?" · "uint8_t a=200, b=100; a+b의 타입과 값은?"

깊이

  • 정수 승격(integer promotion): int보다 작은 타입(char·short)은 연산 시 int로 승격. uint8_t a=200,b=100; a+bint로 계산되어 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 매크로의 함정?" · "#define vs const/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(비정렬 접근)·임베디드 레시피(히언) 교차검증.)

메모리·아키텍처 — 메모리맵·MMU/MPU·캐시·DMA·엔디안·정렬