임베디드 학습 노트 목차

테스트 — 하드웨어 없이, 그리고 하드웨어와 함께

"보드에 구워서 돌려 봤다" 가 테스트의 전부인 현장이 많다. 그런데 그 방식은 느리고(굽는 데 수십 초), 범위가 좁고(정상 경로만), 재현이 안 된다(센서 값을 마음대로 못 만든다).

임베디드 테스트의 핵심은 하드웨어에 의존하는 부분과 아닌 부분을 갈라내는 것이다.


무엇이 PC 에서 테스트 가능한가

하드웨어가 필요한 것        레지스터 접근 · 타이밍 · 전기 특성
하드웨어가 필요 없는 것      계산 · 상태 기계 · 프로토콜 파싱 · 필터 · 링 버퍼

후자가 보통 코드의 70% 이상이고, 버그도 대부분 거기 있다. 그런데 그 코드가 레지스터 접근과 뒤엉켜 있으면 PC 에서 못 돌린다.

// ✗ 계산과 하드웨어가 섞여 있다 — 테스트 불가
void control_step(void) {
    uint16_t raw = ADC1->DR;                    // 하드웨어
    float err = setpoint - raw * 3.3f / 4095;   // 계산
    integral += err * DT;                        // 계산
    float out = KP * err + KI * integral;         // 계산
    TIM2->CCR1 = (uint32_t)(out * 1000);          // 하드웨어
}
// ✓ 계산을 순수 함수로 뽑아낸다
typedef struct { float integral, kp, ki, dt; } Pid;

float pid_step(Pid *p, float setpoint, float measured) {   // 하드웨어 없음
    float err = setpoint - measured;
    p->integral += err * p->dt;
    return p->kp * err + p->ki * p->integral;
}

// 하드웨어 계층은 얇게 남긴다
void control_step(void) {
    float m = adc_read_volts();
    float out = pid_step(&pid, setpoint, m);
    pwm_set_duty(out);
}

이 분리 하나로 PID 로직 전체가 PC 에서 테스트 가능해진다. 그리고 이것은 테스트를 위한 희생이 아니라 더 나은 설계다 — 계산 로직이 어느 MCU 에서도 쓰이게 된다.

단위 테스트 — PC 에서 돌린다

// test_pid.c — 호스트 컴파일러로 빌드한다
#include "unity.h"
#include "pid.h"

void test_pid_proportional_only(void) {
    Pid p = { .kp = 2.0f, .ki = 0.0f, .dt = 0.001f };
    TEST_ASSERT_FLOAT_WITHIN(0.001f, 2.0f, pid_step(&p, 10.0f, 9.0f));
}

void test_pid_integral_accumulates(void) {
    Pid p = { .kp = 0.0f, .ki = 1.0f, .dt = 1.0f };
    pid_step(&p, 1.0f, 0.0f);                    // 오차 1, 1초
    TEST_ASSERT_FLOAT_WITHIN(0.001f, 2.0f, pid_step(&p, 1.0f, 0.0f));
}

void test_pid_windup_clamped(void) {             // ★ 보드에서는 만들기 어려운 상황
    Pid p = { .kp = 1.0f, .ki = 1.0f, .dt = 1.0f, .i_max = 5.0f };
    for (int i = 0; i < 1000; i++) pid_step(&p, 100.0f, 0.0f);
    TEST_ASSERT_TRUE(p.integral <= 5.0f);        // 적분 포화가 막히는가
}
gcc -o test test_pid.c pid.c unity.c && ./test
# test_pid_proportional_only:PASS
# test_pid_integral_accumulates:PASS
# test_pid_windup_clamped:PASS

밀리초 안에 끝난다. 보드에 굽는 30초와 비교하면 반복 속도가 완전히 다르다.

세 번째 테스트가 특히 값어치가 있다 — 적분 포화(windup)는 실제 장비에서 만들기 위험하거나 어려운 상황인데, 테스트에서는 한 줄이다.

자주 쓰는 프레임워크

성격
UnityC 전용, 가볍다. 타깃에서도 돌릴 수 있다
CeedlingUnity + CMock + 빌드 자동화
CppUTestC/C++, 메모리 누수 검출 내장
GoogleTestC++ 이 필요하면

하드웨어를 모킹한다

하드웨어 접근을 인터페이스로 가르고 테스트에서는 가짜 구현으로 바꿔 끼우는 것을 모킹이라 한다. 이렇게 하면 보드 없이도 로직을 전부 돌려 볼 수 있다.

// hal.h — 인터페이스로 가른다
typedef struct {
    uint16_t (*adc_read)(uint8_t ch);
    void     (*pwm_set)(uint8_t ch, uint16_t duty);
    uint32_t (*millis)(void);
} Hal;

// 제품 코드는 이 인터페이스만 안다
void control_task(const Hal *hal) {
    uint16_t v = hal->adc_read(0);
    ...
    hal->pwm_set(0, duty);
}
// 테스트에서 가짜를 끼운다
static uint16_t fake_adc_values[] = { 100, 200, 4095, 0 };
static int fake_idx;
static uint16_t fake_adc(uint8_t ch) { return fake_adc_values[fake_idx++]; }

static uint16_t last_duty;
static void fake_pwm(uint8_t ch, uint16_t d) { last_duty = d; }

void test_saturates_at_max_input(void) {
    Hal hal = { fake_adc, fake_pwm, fake_millis };
    fake_idx = 2;                          // 4095 를 주게 한다
    control_task(&hal);
    TEST_ASSERT_EQUAL(PWM_MAX, last_duty); // 상한에서 잘리는가
}

센서가 극단값을 낼 때를 재현할 수 있다. 실제 센서로는 4095 를 만들기 어렵고, 고장 상황(0 또는 최대 고정)은 더 어렵다.

// 시간도 마음대로 만든다 — 타임아웃 테스트
static uint32_t fake_now;
static uint32_t fake_millis(void) { return fake_now; }

void test_timeout_after_5s(void) {
    start_operation();
    fake_now += 4999;  step();  TEST_ASSERT_FALSE(is_timeout());
    fake_now += 2;     step();  TEST_ASSERT_TRUE(is_timeout());
}

실제로 5초를 기다릴 필요가 없다. 시간을 주입하면 타임아웃·재시도·주기 로직을 즉시 검증할 수 있다.

프로토콜 파싱은 테스트하기 가장 좋은 대상

void test_frame_split_across_chunks(void) {     // 실제로 자주 나는 버그
    parser_reset();
    parser_feed((uint8_t[]){0xAA, 0x03}, 2);     // 헤더만 왔다
    TEST_ASSERT_FALSE(parser_has_frame());
    parser_feed((uint8_t[]){0x01, 0x02, 0x03, 0x5C}, 4);
    TEST_ASSERT_TRUE(parser_has_frame());
}

void test_garbage_before_header_is_skipped(void) {
    parser_feed((uint8_t[]){0xFF, 0x00, 0x12, 0xAA, 0x01, 0x05, 0x8B}, 7);
    TEST_ASSERT_TRUE(parser_has_frame());        // 앞의 쓰레기를 건너뛰는가
}

void test_bad_crc_rejected(void) { ... }
void test_length_field_overflow_rejected(void) { ... }   // 보안

"패킷이 쪼개져 들어오는 경우" 가 실장비에서 가장 흔한 버그인데, 재현하기가 까다롭다. 테스트에서는 원하는 대로 쪼갤 수 있다.

정적 분석 — 돌리기 전에 잡는다

# 컴파일러 경고를 최대로 — 가장 싸고 효과가 크다
arm-none-eabi-gcc -Wall -Wextra -Wshadow -Wconversion \
                  -Wpointer-arith -Wcast-align -Werror ...

-Werror 로 경고를 오류로 만드는 것이 실무에서 가장 효과가 크다. 경고가 쌓이면 아무도 안 보게 되기 때문이다.

cppcheck --enable=all --std=c11 --platform=arm32-wchar_t4 src/
clang-tidy src/*.c -- -I include/
자주 잡히는 것
  배열 경계 넘기 · 널 역참조 가능성
  초기화 안 된 변수 사용
  부호 비교 문제 · 암시적 형변환
  자원 누수 (열고 안 닫음)

MISRA C — 규칙으로 위험을 줄인다

자주 걸리는 규칙
  Rule 8.4   외부 연결 객체는 선언이 있어야 한다
  Rule 10.x  암시적 타입 변환 금지
  Rule 11.x  포인터 변환 제한
  Rule 15.5  함수는 출구가 하나 (return 하나)
  Rule 21.3  동적 메모리 할당 금지

전부 지킬 필요는 없다. 자동차·의료 등 인증이 필요한 분야가 아니면, 의미 있는 규칙만 골라 적용하는 것이 실용적이다. 특히 타입 변환 규칙(10.x)은 인증과 무관하게 값어치가 있다.

HIL — 실제 하드웨어로 자동 테스트

PC (테스트 러너)
  ├─ ST-LINK ──> 타깃 보드    펌웨어를 굽고 실행
  ├─ USB-시리얼 ──> 타깃      명령을 보내고 응답을 받는다
  ├─ 릴레이 보드 ──> 타깃     전원을 껐다 켠다 (리셋·전원 이상 시험)
  └─ 계측기 ──> 타깃          전류·전압·파형을 잰다
# 파이썬 테스트 러너 예
def test_boot_time_under_100ms():
    power.off(); time.sleep(0.5)
    t0 = time.time(); power.on()
    line = serial.readline(timeout=1)            # 부팅 완료 메시지
    assert b"READY" in line
    assert time.time() - t0 < 0.1

def test_survives_brownout():
    for _ in range(50):
        power.set_voltage(3.3); time.sleep(0.2)
        power.set_voltage(2.0); time.sleep(0.05)  # 순간 전압 강하
    power.set_voltage(3.3)
    assert b"READY" in serial.readline(timeout=2)  # 복구되는가

전원 이상·리셋 반복처럼 손으로는 못 하는 시험HIL 의 값어치다. 50번을 자동으로 반복하면 "가끔 부팅이 안 된다" 를 재현할 수 있다.

타깃에서 도는 자체 시험

// 제품에 넣는 자가 진단 — 생산 라인과 현장 모두에서 쓴다
typedef struct { const char *name; bool (*fn)(void); } SelfTest;

static const SelfTest tests[] = {
    { "flash_crc",   test_flash_crc },
    { "sram",        test_sram_march },
    { "i2c_sensor",  test_sensor_id },
    { "adc_ref",     test_vrefint_range },
};

void run_self_test(void) {
    for (size_t i = 0; i < ARRAY_LEN(tests); i++)
        printf("%-12s %s\r\n", tests[i].name, tests[i].fn() ? "OK" : "FAIL");
}

생산 라인에서 불량을 거르고, 현장에서 고장을 진단한다. 특히 i2c_sensor 같은 연결 확인은 조립 불량을 즉시 잡아 준다.

코드 커버리지

# 호스트 테스트에 커버리지를 붙인다
gcc --coverage -o test test_*.c src/*.c && ./test
gcov src/parser.c
# Lines executed:87.50% of 96
lcov --capture --directory . --output-file cov.info
genhtml cov.info --output-directory cov_html

커버리지 100% 가 목표가 아니다. 안 덮인 곳을 보고 "이 경로는 왜 테스트가 없나" 를 묻는 도구로 쓴다. 대개 오류 처리 경로가 비어 있는데, 거기가 실제로 버그가 사는 곳이다.

CI 에 무엇을 넣나

# 매 커밋마다
- 호스트 단위 테스트           
- 정적 분석 (cppcheck)        
- 크로스 컴파일              펌웨어가 빌드되는가
- 크기 확인                  Flash/SRAM  한계를 넘지 않는가
- 커버리지 리포트

# 야간 또는 병합 전
- HIL 테스트                 실제 보드에서
# 크기 회귀를 자동으로 잡는다
SIZE=$(arm-none-eabi-size -A firmware.elf | awk '/\.text/{print $2}')
LIMIT=$((480 * 1024))
[ "$SIZE" -lt "$LIMIT" ] || { echo "Flash 초과: $SIZE > $LIMIT"; exit 1; }

앞 편에서 본 "매 빌드 크기 확인" 을 CI 가 대신하게 하는 것이다. 어느 커밋에서 용량이 튀었는지 즉시 드러난다.

테스트하기 어려운 것들

대상접근
인터럽트 타이밍HIL + 오실로스코프. 단위 테스트로는 불가
전기 특성계측기. 소프트웨어 범위 밖
장기 안정성수십 시간 연속 운전 + 지표 수집
드문 경쟁 조건스트레스 테스트(부하를 최대로) + 이벤트 로그

전부를 자동화하려 하지 않는다. 자동화 비용이 이득을 넘는 지점이 있고, 임베디드에서는 그 지점이 PC 소프트웨어보다 빨리 온다. 로직은 자동으로, 물리는 사람이 가 현실적인 경계다.


한눈에 정리

  • 계산과 하드웨어를 가르는 것이 임베디드 테스트의 출발점 — 그리고 그것 자체가 더 나은 설계다
  • 로직의 70% 는 PC 에서 테스트 가능하고 버그도 대부분 거기 있다
  • 적분 포화·센서 극단값·고장 상황은 실장비에서 만들기 어렵지만 테스트에서는 한 줄
  • HAL 을 함수 포인터 인터페이스로 두면 가짜를 끼울 수 있다
  • 시간을 주입하면 타임아웃 테스트에 실제로 기다릴 필요가 없다
  • "패킷이 쪼개져 들어오는 경우" 는 실장비에서 흔한 버그인데 테스트에서는 재현이 쉽다
  • -Werror 가 가장 싸고 효과 큰 정적 분석 — 경고가 쌓이면 아무도 안 본다
  • MISRA 는 전부 지킬 필요 없다 — 타입 변환 규칙만으로도 값어치가 있다
  • HIL 은 전원 이상·리셋 반복처럼 손으로 못 하는 시험에 쓴다
  • 자가 진단을 제품에 넣으면 생산 불량과 현장 고장을 모두 잡는다
  • 커버리지 100% 가 목표가 아니라 "이 경로는 왜 테스트가 없나" 를 묻는 도구
  • 로직은 자동으로, 물리는 사람이 — 자동화 비용이 이득을 넘는 지점이 있다

꼬리질문 대비

  • "펌웨어에서 PC 로 테스트 가능한 부분은?" → 하드웨어에 의존하지 않는 로직 — 프로토콜 파싱·상태 기계·필터·계산
  • "하드웨어 의존 코드를 어떻게 테스트하나?" → 레지스터 접근을 함수로 감싸(seam) 테스트에서 가짜 구현으로 바꿔 끼운다
  • "시간에 의존하는 코드는?" → now() 를 주입해 가짜 시계로 시간을 직접 전진시킨다 — 실제로 기다리지 않는다

출처 — James Grenning, Test Driven Development for Embedded C · Unity/CMock/Ceedling 문서 · MISRA C:2012 Guidelines · Cppcheck·gcov/lcov 문서

트레이스와 프로파일링 — 멈추지 않고 관찰하기최적화 — 크기와 속도를 재고 줄인다