더그 슈미트의 한 문장이 이 장의 출발점이다 — 소프트웨어는 닳지 않는다. 그러나 하드웨어와 펌웨어에 대한 관리되지 않은 의존성이 안에서부터 소프트웨어를 파괴한다. 물리적으로 마모되지 않는 코드가 왜 몇 년 만에 손댈 수 없는 덩어리가 되는가. 임베디드 세계는 이 물음이 가장 날카롭게 드러나는 무대다. 여기서는 코드가 특정 칩, 특정 레지스터, 특정 센서에 붙어 태어나고, 그 하드웨어가 단종되는 순간 코드 전체가 함께 무덤으로 끌려간다. 아이러니는, 임베디드 프로그래머 대부분이 이 죽음을 자초하면서도 그것을 “동작하는 코드”라 부르며 만족한다는 데 있다.
소프트웨어는 오래 살고, 펌웨어는 하드웨어와 함께 죽는다
먼저 두 낱말을 갈라야 한다. 우리는 흔히 펌웨어를 “ROM이나 플래시에 구워진 코드”라고, 즉 저장 위치로 정의한다. 마틴(과 이 장의 논지)은 그 정의를 거부한다. 펌웨어냐 소프트웨어냐를 가르는 것은 코드가 어디에 저장되는가가 아니라, 코드가 무엇에 의존하는가다. 하드웨어의 세부사항 — 레지스터 주소, 비트 배치, 타이밍, 특정 칩의 변덕 — 이 코드 곳곳에 스며들어 있으면, 그것은 플래시가 아니라 RAM에서 돌아도 펌웨어다. 하드웨어가 바뀌는 순간 함께 뜯어고쳐야 하기 때문이다.
이 구분이 왜 중요한가. 소프트웨어는 오래 산다. 하나의 업무 규칙 — 이를테면 “배터리 전압이 임계치 아래로 떨어지면 절전 모드로 전환한다” — 은 십 년이 지나고 프로세서가 세 번 바뀌어도 그대로 유효하다. 반면 그 규칙을 표현한 코드가 특정 ADC 레지스터를 직접 읽고 특정 GPIO 핀을 직접 토글하도록 짜여 있으면, 규칙은 살아 있는데 코드는 하드웨어와 함께 죽는다. 오래 살아야 할 자산(업무 규칙)을 가장 빨리 죽는 것(하드웨어)에 묶어 버린 것이다.
판단 기준: 어떤 코드 조각을 두고 “이 칩을 다른 칩으로 바꾸면 이 줄을 고쳐야 하나”를 물어라. 고쳐야 한다면 그것은 펌웨어고, 하드웨어 쪽 경계로 밀어내야 할 후보다. 함정: “우리 제품은 이 칩으로 평생 간다”는 확신이 격리를 생략할 근거가 되곤 한다. 칩은 단종되고, 원가 절감 압력은 반드시 부품 교체를 부른다 — 하드웨어가 안 바뀐다는 가정은 임베디드에서 가장 자주 배신당하는 가정이다.
”돌아가니 됐다”는 앱-티튜드 테스트를 통과했을 뿐이다
임베디드 프로젝트가 무너지는 첫 장면은 거의 언제나 같다. 마감에 쫓긴 엔지니어가 코드를 짜서 타깃 보드에 올리고, LED가 깜빡이고 센서 값이 찍히는 걸 확인하고는 “됐다”며 다음 기능으로 넘어간다. 마틴은 이 지점을 냉소적으로 명명한다 — 이것은 앱-티튜드 테스트(app-titude test), 즉 “일단 앱이 돌아가느냐”라는 시험을 통과한 것일 뿐이다. 그리고 대부분의 프로그래머는 이 시험 통과를 결승선으로 착각한다.
동작은 두 가치 중 하나일 뿐이라던 2장의 논리가 여기서 그대로 되울린다. 돌아가는 임베디드 코드는 첫 관문을 넘었을 뿐, 정작 값나가는 두 번째 관문 — 하드웨어가 바뀌어도, 요구가 바뀌어도 값싸게 고쳐지는가 — 앞에는 서 보지도 않았다. “돌아가니 됐다”로 멈추는 순간, 레지스터 접근과 업무 규칙과 UI 로직이 한 함수 안에 뒤엉킨 채로 굳는다. 그 뒤 하드웨어 리비전이 한 번만 올라와도, 뒤엉킨 실타래 어디를 당겨야 할지 아무도 모른다.
판단 기준: “동작한다”를 완료의 신호가 아니라 리팩터링을 시작해도 좋다는 신호로 읽어라 — 초록불이 켜진 다음에 하드웨어 의존을 경계로 밀어내는 작업이 남아 있다. 함정: 임베디드에서는 “돌게 만드는 것” 자체가 워낙 고되서(타이밍·인터럽트·메모리 제약과의 싸움), 그 고생 끝에 켜진 초록불이 유난히 눈부시게 느껴진다 — 그 성취감이 정리 단계를 통째로 건너뛰게 만드는 가장 강력한 유혹이다.
목표 하드웨어 병목 — 타깃 위에서만 돌 수 있는 코드는 느리게 죽는다
하드웨어 의존이 코드에 스며들면 눈에 잘 안 보이는 세금이 하나 더 붙는다. **목표 하드웨어 병목(target-hardware bottleneck)**이다. 코드가 특정 레지스터와 특정 주변장치에 직접 말을 걸도록 짜여 있으면, 그 코드는 오직 타깃 보드 위에서만 실행되고 검증될 수 있다. 개발자 노트북에서는 컴파일조차 안 되거나, 되더라도 실행할 수 없다.
그 결과는 조용하지만 치명적이다. 테스트하려면 보드가 있어야 하고, 보드는 대개 몇 대 안 되며, 여럿이 나눠 쓴다. 크로스 컴파일하고, 플래시에 굽고, 보드를 리셋하고, 시리얼 로그를 들여다보는 한 사이클이 수 분씩 걸린다. 이 마찰이 쌓이면 개발자는 테스트를 점점 덜 돌리고, 큰 덩어리를 한꺼번에 올려 확인하며, 결국 “돌아가니 됐다”의 유혹에 다시 굴복한다. 병목은 단지 느림의 문제가 아니라, 느림을 통해 코드 품질 전체를 끌어내리는 경사면이다.
탈출구는 하드웨어에 의존하지 않는 코드가 되도록 만드는 것이다. 업무 규칙과 대부분의 로직이 순수하게 하드웨어와 무관해지면, 그 코드는 호스트(개발 PC) 위에서 컴파일되고 테스트된다. 보드가 없어도, 심지어 하드웨어가 아직 완성되기 전에도 로직을 검증할 수 있다. 타깃은 이제 병목이 아니라 마지막 통합 지점으로 물러난다.
판단 기준: “이 로직을 검증하려면 반드시 타깃 보드가 필요한가”를 물어, 필요하다면 그 로직에 하드웨어 의존이 새어 들어온 것이다 — 순수 로직과 하드웨어 접근을 갈라 순수 쪽을 호스트에서 테스트 가능하게 만들어라. 함정: “실물 하드웨어에서 돌려봐야 진짜 테스트”라는 믿음이 호스트 테스트를 폄하하게 만든다. 실물 통합 테스트는 반드시 필요하지만, 그것을 이유로 로직을 하드웨어에 묶어 두면 병목 안에 스스로를 가두는 것이다 — 두 테스트는 대체재가 아니라 층위가 다른 보완재다.
HAL — 하드웨어를 세부사항으로 밀어내는 경계
그렇다면 어떻게 가르는가. 답은 이 책이 처음부터 반복해 온 그 도구다 — 경계를 긋고, 그 경계를 추상에 걸어라. 임베디드에서 이 경계의 이름이 **HAL(Hardware Abstraction Layer, 하드웨어 추상 계층)**이다. HAL은 소프트웨어가 하드웨어에게 무엇을 원하는지를 인터페이스로 선언하고, 그 인터페이스가 실제로 어떤 레지스터를 어떻게 건드리는지는 경계 너머에 숨긴다.
원칙은 단순하다. 소프트웨어는 HAL이 제공하는 서비스만 안다. “이 GPIO 핀을 켜라”, “이 센서에서 온도를 읽어라” 같은 의도만 말하고, 그 의도가 어떤 하드웨어로 실현되는지는 모른다. C에서 이 경계는 헤더 파일 하나로 그어진다 — 구현이 아니라 계약만 노출하는 헤더다.
// hal_led.h — 소프트웨어가 아는 것은 이 계약이 전부다.
// 레지스터 주소도, 어느 포트인지도 여기엔 없다.
typedef enum { LED_STATUS, LED_FAULT } led_id;
void led_on(led_id id);
void led_off(led_id id);// hal_led_stm32.c — 하드웨어 세부는 전부 경계 너머 이쪽에 갇힌다.
#include "hal_led.h"
#include "stm32_registers.h"
void led_on(led_id id) {
switch (id) {
case LED_STATUS: GPIOB_BSRR = (1u << 5); break; // 이 한 줄이 '펌웨어'다
case LED_FAULT: GPIOB_BSRR = (1u << 7); break;
}
}
void led_off(led_id id) {
switch (id) {
case LED_STATUS: GPIOB_BSRR = (1u << (5 + 16)); break;
case LED_FAULT: GPIOB_BSRR = (1u << (7 + 16)); break;
}
}
// 칩이 바뀌면 이 파일만 새로 쓴다. led_on()을 호출하는
// 업무 로직은 GPIOB도 BSRR도 5번 핀도 영영 모른 채 그대로 산다.경계 위쪽 코드는 led_on(LED_FAULT)라고만 부른다. 프로세서가 STM32에서 다른 칩으로 바뀌면, 새 파일(hal_led_nxp.c)을 써서 같은 계약을 다르게 구현하고 링크만 갈아 끼운다. 이것이 5장·11장의 의존성 역전이 실리콘 위에서 실현된 모습이다 — 소스 의존성이 하드웨어를 향하지 않고, 하드웨어 구현이 소프트웨어가 정한 계약을 향한다.
판단 기준: HAL 인터페이스에 하드웨어 어휘(레지스터 이름, 핀 번호, 비트 마스크)가 새어 나오면 경계가 새는 것이다 — 인터페이스는 하드웨어의 용어가 아니라 소프트웨어의 의도(켜라·읽어라·측정하라)로만 말해야 한다. 함정: HAL을 만든다면서 레지스터 접근을 그저 함수로 한 겹 감싸고 마는 경우가 흔하다(write_reg(0x40020018, 0x20)을 set_gpiob_bsrr()로 이름만 바꾸는 식). 하드웨어 개념이 이름에 그대로 남아 있으면 그것은 추상 계층이 아니라 하드웨어를 그대로 노출하는 얇은 별칭일 뿐이다.
OS도 세부사항이다 — OS 추상 계층
하드웨어만 세부사항인 것이 아니다. 임베디드 코드는 실시간 운영체제(RTOS) 위에서 돌아가는 일이 많고, 그 OS의 API — 태스크 생성, 뮤텍스, 큐, 타이머 — 가 하드웨어만큼이나 강하게 코드를 붙든다. FreeRTOS API가 업무 로직 곳곳에 박히면, 그 OS를 다른 것으로 바꾸거나 OS 없는 환경으로 내려가는 순간 코드가 무너진다. OS 역시 언젠가 바뀔 수 있는 세부사항이다.
그래서 HAL 아래(혹은 곁)에 **OS 추상 계층(OSAL, Operating System Abstraction Layer)**을 하나 더 둔다. 소프트웨어는 “이 작업을 지연시켜라”, “이 자원을 잠가라” 같은 추상적 서비스만 알고, 그것이 FreeRTOS의 세마포어인지 다른 RTOS의 뮤텍스인지, 아니면 OS 없는 베어메탈 루프인지는 경계 너머에 숨긴다. 그러면 소프트웨어에서 본 계층은 이렇게 정돈된다 — 소프트웨어(업무 규칙) → OS 추상 계층 → 펌웨어/HAL → 하드웨어. 위로 갈수록 오래 살고 변하지 않으며, 아래로 갈수록 잘 바뀌는 세부사항이다. 소스 의존성은 오직 위(안정적인 고수준)를 향하고, 하드웨어와 OS는 이 사슬의 가장 바깥, 언제든 갈아 끼울 수 있는 플러그인 자리로 밀려난다.
판단 기준: 업무 로직 파일에서 #include한 헤더 목록을 훑어라 — 하드웨어 헤더나 OS 헤더가 거기 끼어 있으면, 그 파일은 자신이 몰라야 할 세부사항을 이미 알고 있는 것이다. 함정: RTOS는 “그냥 우리 개발 환경의 기본값”처럼 느껴져서 세부사항으로 인식조차 안 되곤 한다. 하드웨어는 언젠가 바꿔야 할 후보로 경계 밖에 두면서 OS만 코드 전역에 그대로 흘려보내면, 애써 그은 HAL 경계의 절반을 OS 의존이 다시 뚫어 버린다.
정리 — 하드웨어가 바뀌어도 사는 코드
임베디드 아키텍처의 핵심은 특별한 무엇이 아니다. 이 책 내내 반복된 원칙 — 세부사항을 경계 밖으로 밀어내고, 소스 의존성을 안정적인 고수준으로 향하게 하라 — 을 가장 물리적인 세부사항인 하드웨어에 적용한 것뿐이다. 다만 임베디드에서는 그 세부사항이 눈앞의 보드로 손에 잡히기에, 격리를 게을리했을 때의 대가도 그만큼 빨리, 그만큼 아프게 청구된다.
- 펌웨어냐 소프트웨어냐는 저장 위치가 아니라 의존성으로 갈린다. 하드웨어 세부에 묶인 코드는 어디에 있든 펌웨어이며, 하드웨어와 함께 죽는다.
- “돌아가니 됐다”(앱-티튜드 테스트)는 첫 관문일 뿐이다. 초록불은 리팩터링의 끝이 아니라 시작 신호다.
- 타깃 위에서만 돌 수 있는 코드는 목표 하드웨어 병목에 걸린다. 로직을 하드웨어와 분리해 호스트에서 테스트되게 하면 병목이 풀린다.
- HAL과 OS 추상 계층으로 하드웨어와 OS를 세부사항으로 밀어낸다. 인터페이스는 하드웨어의 용어가 아니라 소프트웨어의 의도로 말해야 한다.
- 목표는 하나다 — 하드웨어가 바뀌어도, 프로세서가 세 번 바뀌어도, 업무 규칙을 담은 코드는 한 줄도 고치지 않고 살아남게 하는 것.
닳지 않는 소프트웨어를 안에서부터 죽이는 것은 시간이 아니라 관리되지 않은 의존성이다. 임베디드 클린 아키텍처는 그 의존성을 경계 하나로 다스려, 실리콘은 흘려보내되 로직은 남기는 기술이다.
다음장으로 30장