본문으로 건너뛰기
고급약 45분

임베디드 C 단위 테스트 — 하드웨어 없이 돌리기 위한 경계 설계

임베디드 C 코드를 호스트에서 단위 시험하려면 하드웨어에 닿는 부분을 떼어낼 수 있어야 한다. 레지스터 접근과 로직이 섞인 코드를 분리하는 설계, 하드웨어 추상화 계층을 모킹하는 방법, 인터럽트·타이머·전역 상태를 다루는 패턴, 그리고 호스트 결과로 대상 동작을 보장할 수 없는 경계를 다룬다.

  • #임베디드
  • #단위테스트
  • #Unity
  • #CppUTest
  • #HAL모킹
  • #C언어
  • #테스트가능성

개념

왜 임베디드에서 단위 테스트가 어려운가

레슨 1 에서 MCU·펌웨어·드라이버·RTOS 계층을 봤다. 그 구조에서 단위 테스트가 어려운 이유는 코드가 하드웨어를 직접 만지기 때문 이다.

void pump_control(void) {
    if (ADC1->DR > PRESSURE_LIMIT) {   /* 레지스터를 직접 읽는다 */
        GPIOA->BSRR = PUMP_OFF_BIT;    /* 레지스터에 직접 쓴다 */
        error_count++;
    }
}

이 함수를 호스트(개발 PC) 에서 실행하면 ADC1 주소가 존재하지 않아 터진다. 그래서 많은 팀이 "임베디드는 단위 테스트가 안 된다" 고 결론 내리고, 전부 대상 하드웨어에서 통합 시험으로 확인한다. 그러면 세 가지를 잃는다.

  1. 빠른 되먹임 — 한 함수의 경계 조건을 확인하려고 빌드·플래시·수동 조작을 거친다
  2. 만들기 어려운 조건 — 센서 값이 범위를 벗어난 순간, 오류 카운터가 넘치는 순간을 실제 하드웨어로 만들려면 장비와 시간이 든다
  3. 구조 커버리지 — 어느 분기가 한 번도 실행되지 않았는지 알 수 없다

단위 테스트가 안 되는 것이 아니다. 코드가 그렇게 쓰여 있어서 안 되는 것 이다. 그래서 이 레슨의 절반은 시험 기법이 아니라 경계 설계 다.

경계를 긋는다 — 로직과 하드웨어 접근을 분리

위 함수를 두 조각으로 나눈다.

/* 1) 하드웨어 접근 — 얇게. 로직이 없다 */
uint16_t hal_adc_read(void)        { return ADC1->DR; }
void     hal_pump_set(bool on)     { GPIOA->BSRR = on ? PUMP_ON_BIT : PUMP_OFF_BIT; }
 
/* 2) 로직 — 하드웨어를 모른다. 호스트에서 돌아간다 */
void pump_control(uint16_t pressure, pump_state_t *st) {
    if (pressure > PRESSURE_LIMIT) {
        st->pump_on = false;
        st->error_count++;
    }
}

분리의 기준은 "레지스터 주소가 나오는가" 다. 나오면 1번 쪽, 안 나오면 2번 쪽. 그리고 1번은 얇게 유지한다 — 한 줄짜리 함수들이 된다. 1번 쪽은 호스트에서 시험할 수 없고, 대상에서 확인한다.

이 분리가 주는 것은 셋이다.

분리 전분리 후
로직 시험대상 하드웨어 필요호스트에서 즉시
조건 만들기실제 센서 조작인자로 값을 넣는다
커버리지측정 어려움호스트 도구로 측정

얇은 계층은 시험하지 않고 검토한다. hal_adc_read 한 줄에 단위 시험을 붙이는 것은 의미가 없다. 대신 레지스터 주소와 비트 정의가 데이터시트와 맞는지 를 코드 검토로 확인하고, 대상에서 한 번 확인한다. 이 구분을 분명히 해 두면 "HAL 도 100% 커버해야 하나" 하는 소모적 논쟁이 사라진다.

그런데 기존 코드는 이미 섞여 있다

현실의 작업은 백지에서 시작하지 않는다. 섞여 있는 코드를 한 번에 다 고치지 않고 점진적으로 분리하는 순서가 있다.

  1. 시험하고 싶은 함수를 하나 고른다. 결함이 자주 나는 곳, 복잡한 분기가 있는 곳, 곧 바꿀 곳
  2. 그 함수 안의 하드웨어 접근만 추출 한다. 함수 이름을 바꾸지 않는다
  3. 추출한 접근을 교체 가능하게 만든다(아래 세 방법 중 하나)
  4. 단위 시험을 붙이고, 그 뒤에 리팩터링 한다

4번 순서가 중요하다. 시험 없이 리팩터링하면 동작이 바뀐 것을 모른다. 먼저 시험을 걸 수 있는 최소한의 변경만 하고, 시험이 생긴 뒤에 구조를 고친다.

하드웨어 접근을 교체하는 세 방법

방법어떻게장점단점
링크 시 교체대상용 hal_xxx.c 와 시험용 hal_xxx_fake.c 를 따로 빌드코드 변경 없음시험마다 동작을 바꾸기 어렵다
함수 포인터 주입구조체에 함수 포인터를 담아 넘긴다시험마다 바꿀 수 있다간접 호출 비용, 코드 변경 필요
모킹 도구 생성헤더에서 모의 함수를 자동 생성호출 횟수·인자 검증까지도구 의존, 빌드 설정이 늘어난다

링크 시 교체 가 가장 먼저 시도할 방법이다. 코드를 건드리지 않고 빌드 구성만 바꾸므로 기존 코드에 적용하기 쉽다. 시험용 구현에 전역 변수로 반환값을 설정 해 두면 시험마다 조건을 바꿀 수 있다.

/* hal_adc_fake.c */
uint16_t fake_adc_value;                       /* 시험이 설정한다 */
uint16_t hal_adc_read(void) { return fake_adc_value; }

함수 포인터 주입 은 설계를 바꾸므로 새로 쓰는 코드에 쓴다. 다만 성능이 빠듯한 경로에서는 간접 호출 비용을 고려한다.

모킹 도구 는 "호출되었는가, 몇 번, 어떤 인자로" 를 검증해야 할 때 쓴다. C 쪽에서 쓰이는 단위 시험·모킹 도구 묶음이 공개되어 있고, 빌드 체계와 함께 쓰는 형태가 일반적이다. 도구 이름·버전·설정 방법은 각 프로젝트의 공식 문서에서 확인한다(원문 확인) — 이 영역은 도구 선택이 조직마다 다르다.

모킹을 어디까지 할 것인가 — 가짜와 모의

구분해 두면 설계가 깔끔해진다. 모듈 10 레슨 3 에서 다룬 구분이 임베디드에서는 이렇게 쓰인다.

가짜(fake) — 동작하는 대체 구현. 플래시 저장을 메모리 배열로 흉내 내는 것. 상태를 갖고 읽고 쓴 결과가 일관된다. 저장·로드 로직을 시험할 때 쓴다.

모의(mock) — 호출을 검증하는 대체 구현. "워치독을 매 주기 재설정했는가", "오류 시 안전 핀을 내렸는가" 처럼 호출 자체가 요구사항 일 때 쓴다.

임베디드에서 모의가 특히 유용한 경우 가 있다. 안전 관련 요구는 종종 "무엇을 한다" 가 아니라 "반드시 호출한다" 의 형태다. 예 — 고장을 감지하면 안전 상태 진입 함수를 호출해야 한다. 그 호출 여부를 검증하는 것이 모의의 일이고, 이것은 대상 하드웨어로 확인하기 어렵다.

과하게 모킹하면 시험이 구현을 따라 하게 되어, 리팩터링마다 시험이 깨진다. 기준은 하나다 — 요구사항이 "호출" 을 말하면 모의, "결과" 를 말하면 가짜.

임베디드 특유의 어려움 네 가지

1) 전역 상태. 임베디드 C 코드는 전역·정적 변수를 많이 쓴다. 시험 사이에 상태가 남으면 실행 순서에 따라 결과가 달라진다.

해결은 모든 시험 앞에서 상태를 초기화 하는 것이다. 초기화 함수가 없으면 만든다 — module_init() 같은 진입점이 없는 모듈은 그 자체로 시험하기 어렵다. 정적 변수에 접근할 수 없으면 시험 빌드에서만 노출 하는 방법이 쓰인다.

2) 인터럽트. 인터럽트 핸들러는 호스트에서 "발생" 시킬 수 없다. 해법은 핸들러를 함수로 직접 호출 하는 것이다. 핸들러가 얇고(플래그 설정·큐 적재만) 실제 처리가 별도 함수에 있으면, 그 함수를 호스트에서 시험할 수 있다.

핸들러가 얇아야 하는 것은 실시간 설계의 원칙이기도 하다 — 레슨 9 에서 다시 본다. 시험 가능성과 실시간 설계가 같은 방향을 가리키는 드문 경우 다.

3) 시간과 타이머. delay_ms() 가 코드에 박혀 있으면 시험이 실제로 기다린다. 시간을 주입 한다 — 현재 시각을 반환하는 함수를 HAL 로 빼고, 시험에서 그 값을 전진시킨다. 그러면 "24시간 뒤" 조건도 즉시 시험된다.

/* 시험에서 */
fake_tick = 0;        logic_step();
fake_tick = 86400000; logic_step();   /* 하루 경과를 즉시 만든다 */

이것은 레슨 9 의 타이머 분석과 다른 축이다. 여기서는 논리적 시간 경과 를 다루고, 거기서는 실제 실행 시간 을 다룬다.

4) 자료형과 비트 폭. 호스트의 int 가 32비트이고 대상이 16비트면, 오버플로가 나는 경계가 다르다. 호스트 시험이 통과한 코드가 대상에서 넘친다.

해결은 고정 폭 자료형을 쓰는 것(uint16_t 등) 과, 경계 시험에서 대상 폭을 기준으로 값을 고르는 것 이다. 그리고 호스트 컴파일에서 경고를 최대한 켜 두면 암묵적 변환을 잡아낸다. 모듈 63 레슨 4 의 여섯 축 중 "자료형·정렬" 이 이 문제의 일반형이다.

호스트 결과로 보장할 수 없는 것

이것을 분명히 해 두는 것이 이 레슨의 책임이다. 호스트 단위 시험이 통과했다고 대상에서 동작한다는 뜻이 아니다.

호스트에서 확인 가능호스트에서 확인 불가
분기·계산·상태 전이 로직실제 실행 시간(레슨 9)
경계 조건과 오류 처리레지스터 주소·비트 정의의 정확성
호출 여부(모의로)인터럽트 우선순위·중첩 동작
구조 커버리지스택 사용량, 메모리 배치
상태 머신의 금지 전이컴파일러 최적화의 영향

그래서 보고서에 쓸 문장이 정해진다. "호스트 단위 시험 N 건 통과" 는 로직 검증의 증거이고, 대상 동작의 증거가 아니다. 어느 결과를 어느 수준의 증거로 쓸 수 있는지는 모듈 63 레슨 4 에서 다룬 원칙 그대로다.

반대로 대상에서만 시험하면 놓치는 것 도 분명하다 — 위 왼쪽 열의 대부분이 대상에서는 만들기 어렵다. 두 층을 함께 쓰는 것이 답이고, 무엇을 어디서 확인할지 배분표 를 만드는 것이 QA 의 일이다.

커버리지를 쓰는 방식

호스트 빌드에서 커버리지를 측정할 수 있게 되면 분기 누락 이 보인다. 다만 모듈 63 레슨 3 에서 다룬 원칙이 여기도 적용된다 — 커버리지는 시험 설계의 기준이 아니라 사후 측정 이다. 요구에서 시험을 만들고, 남은 빈 곳을 네 가지로 분류한다(시험 부족 / 요구 누락 / 실행 불가 코드 / 의도적 비활성).

임베디드에서 특히 자주 나오는 빈 곳이 방어적 코드 다. "일어날 수 없는" 기본 분기, 하드웨어 오류 경로, 포인터 널 검사. 이것들은 의도적으로 실행되지 않는 코드 로 분류하고 근거를 적는다. 기능안전 규격이 걸린 프로젝트라면 이 분류와 근거가 산출물이 된다(모듈 35, 모듈 63 레슨 3).

실무에서 왜 필요한가

임베디드 조직에서 "단위 테스트를 도입하자" 는 제안이 실패하는 전형적 경로가 있다. 도구를 고르고 설정하고 몇 개 시험을 쓰다가, 기존 코드에 붙지 않아서 멈춘다. 하드웨어 접근이 로직과 섞여 있어 호스트에서 컴파일조차 되지 않는다.

그래서 이 작업의 실제 순서는 도구가 아니라 경계 다. 함수 하나를 골라 하드웨어 접근을 추출하고 링크 교체로 시험을 붙여 보이면, 그 하나가 다음을 설득한다. 작게 시작해 보여 주는 것 이 조직에서 이 변화를 만드는 유일한 방법이다.

QA 가 기여하는 자리도 분명하다. "이 코드는 시험할 수 없습니다" 를 테스트 가능성 지적으로 올리는 것 이다. 모듈 9 레슨 9 의 테스트 가능성 판단이 임베디드에서는 이 형태로 나타난다. 그리고 그 지적은 품질만이 아니라 실시간 설계와 같은 방향 을 가리키는 경우가 많다 — 얇은 인터럽트 핸들러, 주입된 시간, 분리된 계층.

가장 큰 실질적 소득은 만들기 어려운 조건의 시험 이다. 센서 고장, 값 범위 초과, 카운터 오버플로, 24시간 경과. 이것들을 실제 하드웨어로 만들려면 장비와 시간이 들고, 어떤 것은 아예 만들 수 없다. 호스트에서는 인자 하나다.

실습 과제

과제 1 — 섞인 코드 분리 (30분)

하드웨어 접근과 로직이 섞인 C 함수를 하나 만든다(또는 실제 코드를 가져온다). 최소 조건 — 레지스터 읽기 2곳, 레지스터 쓰기 2곳, 분기 4개, 전역 상태 1개.

  1. 레지스터 주소가 나오는가 기준으로 두 조각으로 나눈다
  2. 얇은 계층의 함수들을 목록으로 적고, 각각 한 줄인지 확인한다. 한 줄이 아닌 것은 로직이 섞인 것이므로 다시 나눈다
  3. 로직 쪽 함수의 시그니처 를 쓴다. 하드웨어를 모르게, 상태를 인자로
  4. 얇은 계층을 어떻게 확인할지 적는다(단위 시험이 아니라 검토와 대상 확인). 데이터시트와 대조할 항목을 목록으로 만든다

과제 2 — 세 가지 교체 방법 비교 (25분)

과제 1 의 얇은 계층에 대해 세 방법을 모두 적용한 설계를 쓴다.

  1. 링크 시 교체 — 파일 구성과 빌드 설정, 시험용 구현의 전역 변수
  2. 함수 포인터 주입 — 구조체 정의와 호출부 변경
  3. 모킹 도구 생성 — 어떤 검증을 할 수 있는지(호출 횟수·인자)

각 방법에 대해 기존 코드에 적용하는 비용 을 적고, 우리 상황에서 무엇을 먼저 쓸지 결정하고 근거를 쓴다. 도구 이름·설정은 공식 문서 확인 표시를 둔다.

과제 3 — 가짜와 모의 가르기 (25분)

요구사항 10개를 만든다. 절반은 결과 를 말하고 절반은 호출 을 말하게 한다. (예: "저장된 값을 다시 읽으면 같다" / "고장 감지 시 안전 상태 진입을 호출한다")

  1. 각 요구를 가짜·모의 중 무엇으로 시험할지 판정하고 근거를 적는다
  2. 모의로 판정한 것 중 하나를 골라 검증 항목 을 구체적으로 쓴다 (호출 여부, 횟수, 인자, 순서)
  3. 과하게 모킹한 예 를 하나 만들고, 그 시험이 리팩터링에서 깨지는 과정을 적는다
  4. 안전 관련 요구가 "호출" 형태로 나타나는 이유를 한 문단 쓴다

과제 4 — 임베디드 특유의 네 어려움 (30분)

네 가지 각각에 대해 시험 코드를 설계한다.

  1. 전역 상태 — 초기화 함수를 설계한다. 정적 변수에 접근할 수 없을 때의 방법을 적고, 시험 간 격리를 어떻게 보장할지 쓴다. 격리가 깨지면 어떤 증상이 나오는지도 적는다
  2. 인터럽트 — 얇은 핸들러 + 별도 처리 함수 구조로 코드를 쓴다. 핸들러를 직접 호출하는 시험을 설계한다. 플래그·큐가 올바르게 설정되었는지 검증
  3. 시간 — 시각 반환 함수를 HAL 로 빼고, "24시간 경과" 와 "카운터 랩어라운드" 조건을 각각 즉시 만드는 시험을 쓴다
  4. 자료형 폭 — 대상과 호스트의 int 폭이 다른 상황을 가정하고, 오버플로 경계를 대상 폭 기준 으로 고른 시험 값을 적는다. 켜 둘 컴파일러 경고를 적는다

과제 5 — 확인 배분표와 보고 문장 (25분)

  1. 본문의 두 열 표를 확장해 확인 배분표 를 만든다. 시험 항목 20개를 "호스트에서 가능 / 대상에서만 가능 / 양쪽" 으로 분류한다
  2. "양쪽" 항목에 대해 각 층에서 무엇을 확인하는지 를 나눠 적는다
  3. 호스트 단위 시험 결과를 보고할 문장 틀 을 만든다. 로직 검증의 증거이고 대상 동작의 증거가 아님이 드러나게 쓴다
  4. 커버리지 보고에서 방어적 코드로 인한 빈 곳 을 분류하는 규칙을 쓴다. "의도적 비활성" 으로 분류할 근거의 형태를 정한다

자가 체크리스트

  • 임베디드 단위 시험이 어려운 이유가 코드 구조에 있다는 것을 설명할 수 있다
  • 단위 시험이 없을 때 잃는 셋(되먹임·조건 만들기·커버리지) 을 들 수 있다
  • "레지스터 주소가 나오는가" 로 계층을 가를 수 있다
  • 얇은 계층은 단위 시험이 아니라 검토·대상 확인으로 다룬다는 것을 안다
  • 기존 코드를 점진적으로 분리하는 네 단계 순서를 설명할 수 있다
  • 시험을 먼저 걸고 리팩터링하는 순서가 왜 중요한지 안다
  • 하드웨어 접근 교체 세 방법의 장단점을 비교할 수 있다
  • 링크 시 교체가 기존 코드에 적용하기 쉬운 이유를 안다
  • 가짜와 모의를 "결과 vs 호출" 기준으로 가를 수 있다
  • 안전 관련 요구가 "호출" 형태로 나타나는 경우를 설명할 수 있다
  • 과한 모킹이 리팩터링에서 시험을 깨뜨리는 이유를 안다
  • 전역 상태가 시험 순서 의존을 만드는 구조와 초기화 해법을 안다
  • 인터럽트 핸들러를 얇게 두면 시험 가능성과 실시간 설계가 함께 좋아진다는 것을 안다
  • 시간을 주입해 "24시간 경과" 조건을 즉시 만들 수 있다
  • 호스트와 대상의 자료형 폭 차이가 오버플로 경계를 바꾼다는 것을 안다
  • 호스트에서 확인 가능한 것과 불가능한 것을 각각 들 수 있다
  • "호스트 단위 시험 통과" 가 대상 동작의 증거가 아니라는 것을 보고에 반영할 수 있다
  • 확인 배분표를 만들어 두 층을 함께 쓸 수 있다
  • 커버리지가 사후 측정이며 방어적 코드의 빈 곳을 분류해야 함을 안다

흔한 실수

"임베디드는 단위 테스트가 안 된다" 로 결론 낸다. 안 되는 것이 아니라 코드가 그렇게 쓰여 있어서 안 되는 것이다. 경계 설계가 먼저다.

도구부터 고른다. 도구를 설정하고 나서 기존 코드에 붙지 않는 것을 발견한다. 함수 하나를 골라 분리해 보이는 것이 먼저다.

얇은 계층에 단위 시험을 붙인다. 한 줄짜리 레지스터 접근에 시험을 붙이는 것은 의미가 없다. 검토와 대상 확인으로 다룬다.

얇은 계층에 로직을 남긴다. 조건이나 계산이 섞여 있으면 그 부분은 호스트에서 시험할 수 없는 채로 남는다. 한 줄이 되게 더 나눈다.

시험 없이 먼저 리팩터링한다. 동작이 바뀐 것을 모른다. 시험을 걸 수 있는 최소 변경만 하고, 시험이 생긴 뒤에 구조를 고친다.

전역 상태를 초기화하지 않는다. 시험이 실행 순서에 따라 통과·실패를 바꾸고, 원인을 찾는 데 오래 걸린다.

인터럽트 핸들러에 처리 로직을 둔다. 호스트에서 시험할 수 없고, 실시간 설계에도 나쁘다. 핸들러는 플래그·큐만 다룬다.

코드에 delay 를 박아 둔다. 시험이 실제로 기다리고, 긴 시간 조건은 아예 시험할 수 없다. 시각을 주입한다.

호스트의 자료형 폭으로 경계를 고른다. 대상에서 넘치는 경계를 호스트에서는 통과한다. 고정 폭 자료형을 쓰고 대상 폭으로 값을 고른다.

과하게 모킹한다. 시험이 구현을 따라 하게 되어 리팩터링마다 깨진다. 요구가 "호출" 을 말할 때만 모의를 쓴다.

호스트 결과를 대상 동작의 증거로 보고한다. 타이밍·레지스터·인터럽트 중첩· 스택은 호스트에서 확인되지 않는다. 보고 문장에 범위를 적는다.

대상에서만 시험한다. 반대 방향의 실수다. 경계 조건과 오류 경로는 대상에서 만들기 어렵고, 결국 시험되지 않은 채 남는다.

커버리지를 채우려 시험을 만든다. 요구에서 만들고 사후에 측정한다. 방어적 코드의 빈 곳은 분류하고 근거를 붙인다.

참고 자료

  • 사용할 C 단위 시험·모킹 도구의 공식 문서 — 도구 선택은 조직마다 다르다. 설정 방법, 빌드 체계와의 결합, 지원 범위를 공식 문서에서 확인한다(원문 확인)
  • 대상 MCU 의 데이터시트와 레퍼런스 매뉴얼 — 얇은 계층의 레지스터 주소·비트 정의를 검토할 때의 정본
  • 호스트 컴파일러의 경고 옵션 문서 — 암묵적 변환·폭 축소를 잡는 설정
  • 커버리지 도구 문서 — 호스트 빌드에서의 측정 방법과 결과 해석(원문 확인)
  • 모듈 46 레슨 1 — 임베디드 시스템 해부: 계층 구조의 전제
  • 모듈 46 레슨 5 — xIL 환경 계층: 호스트 시험이 xIL 어디에 놓이는가
  • 모듈 46 레슨 9 — RTOS 스케줄링과 WCET: 실제 실행 시간은 그쪽이 맡는다
  • 모듈 10 레슨 3 — 모의·스텁·스파이: 가짜와 모의 구분의 일반형
  • 모듈 10 레슨 4 — 비결정성 다루기: 시간 주입의 일반형
  • 모듈 9 레슨 9 — 테스트 가능성 판단: "시험할 수 없습니다" 지적의 근거
  • 모듈 63 레슨 3 — 구조 커버리지: 빈 곳의 네 분류와 비활성 코드 처리
  • 모듈 63 레슨 4 — 호스트와 대상의 차이: 어느 결과를 증거로 쓸 수 있는가

낯선 말 풀이

이 레슨에 나온 약어입니다. 본문에서도 처음 나올 때 점선으로 표시해 두었습니다 (제목과 링크 글자는 그대로 둡니다).

MCU — 마이크로컨트롤러
아주 작은 컴퓨터 한 칩. 가전·자동차·산업 기기 안에 들어간다.
RTOS — 실시간 운영체제
"정해진 시간 안에 반드시 처리"를 보장하도록 만든 바탕 프로그램.
QA — 품질보증
만든 것이 쓸 만한지 확인하고, 애초에 덜 망가지게 만드는 방법을 정하는 일. "테스트"는 그 일의 일부다.
WCET — 최악 실행 시간
어떤 처리가 가장 오래 걸릴 때 얼마나 걸리는지의 상한값.

용어집 전체 보기