임베디드 · 심화
인터럽트·RTOS·실시간 설계
상태 기계로 펌웨어 설계하기
이벤트·상태 표, 계층 상태, 테스트 쉬운 구조
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서 데이터 전송을 CPU의 반복 작업과 분리했다. 이제 컨베이어 제어기가 받은 정보를 어떤 동작으로 바꿀지 정리한다. 물체가 도착했다는 사실만으로 모터 동작이 정해지지는 않는다. 이송 중이라면 정지해야 하지만, 이미 고장 상태라면 정지를 유지해야 한다. 같은 입력도 현재 상태에 따라 의미가 달라진다.
이 장에서는 상태 기계(state machine)로 동작 규칙을 표현한다. 먼저 이벤트와 상태의 조합을 표로 정리하고, 여러 상태가 공유하는 규칙을 계층으로 묶는다. 이어서 하드웨어 없이 실행할 수 있는 C 프로그램으로 규칙을 검증한다. 시간에 따라 입력을 공급하는 작은 실행기는 두지만, 여러 작업의 실행 주기를 설계하는 내용은 다음 장에서 다룬다.
- 상태, 이벤트, 전이 조건, 동작을 구별하고 이벤트·상태 표를 작성한다.
- 공통 규칙을 상위 상태에 모으고 처리 우선순위를 명시한다.
- 상태 판단과 하드웨어 출력을 분리해 PC에서 같은 제어 코드를 실행한다.
- 이벤트 순서와 기대 결과를 데이터로 작성해 정상 경로와 거부 경로를 검사한다.
문제 상황
공장 컨베이어가 물체를 검사 위치까지 옮긴다고 하자. 시작 명령을 받으면 모터가 돌고, 위치 센서가 물체를 감지하면 모터를 멈춘다. 외부 검사 장치가 반출 허가를 보내면 다시 움직인다. 정지 명령은 정상 운전 중 어느 시점에나 받아야 하며, 걸림 감지는 고장 상태로 이어져야 한다.
처음에는 running, waiting, fault 같은 불리언 변수를 추가하는 방식으로 구현하기 쉽다. 그러나 변수가 세 개면 표현 가능한 조합은 여덟 개다. 그중에는 이송 중이면서 검사 대기 중인 조합도 있다. 실제 요구 사항에 없는 조합까지 프로그램이 표현하면, 모든 분기에서 그 조합을 방어해야 한다.
문제는 플래그의 개수보다 규칙이 흩어지는 데 있다. 시작 처리에서는 고장 여부를 검사하지만 반출 허가 처리에서는 빠뜨릴 수 있다. 정지 처리에서 모터만 끄고 대기 플래그를 남기면, 뒤늦게 도착한 반출 허가가 모터를 다시 켤 수도 있다. 상태 기계는 현재 동작 모드를 하나로 제한하고, 허용한 입력에만 다음 동작을 부여한다.
예제의 범위도 먼저 정한다. 물체 개수, 속도 제어, 검사 결과 저장은 다루지 않는다. 고장 해제 이벤트에는 외부에서 확인한 해제 가능 여부가 담긴다. 실제 설비의 안전 정지 회로나 센서 진단까지 이 소프트웨어 모형이 대신하는 것은 아니다. 여기서 검증할 대상은 명시한 입력에 대한 제어 규칙이다.
이벤트·상태 표로 동작을 고정한다
상태(state)는 다음 입력을 해석하는 데 필요한 현재의 동작 모드다. 이벤트(event)는 제어기가 처리할 하나의 발생 사실이다. 센서 핀이 계속 켜져 있는 것은 입력 수준이며, 물체 도착으로 해석해 한 번 전달한 것은 이벤트다. 둘을 구별해야 같은 물체를 여러 번 처리하는 일을 피할 수 있다.
이 예제에서는 준비 상태인 IDLE, 이송 상태인 FEED, 검사 대기 상태인 HOLD, 고장 상태인 FAULT를 사용한다. 모터가 켜지는 상태는 FEED 하나뿐이다. HOLD와 IDLE은 모두 모터가 꺼져 있지만, 반출 허가를 해석하는 방식이 다르므로 별도 상태가 필요하다. 출력이 같다고 상태도 같지는 않다.
전이(transition)는 현재 상태에서 다음 상태로 이동하는 일이다. 전이 조건(guard)은 그 이동을 허용할지 판단하는 조건이다. RESET의 safe 값이 참이어야 고장을 해제한다는 규칙이 이에 해당한다. 동작(action)은 전이에 따라 수행하는 일이며, 여기서는 모터 출력 요구값을 바꾼다.
| 현재 상태 | 이벤트 | 조건 | 다음 상태 |
|---|---|---|---|
| IDLE | START | 없음 | FEED |
| FEED | ITEM | 없음 | HOLD |
| HOLD | RELEASE | 없음 | FEED |
| IDLE, FEED, HOLD | STOP | 없음 | IDLE |
| IDLE, FEED, HOLD | JAM | 없음 | FAULT |
| FAULT | RESET | safe가 참 | IDLE |
| FAULT | RESET | safe가 거짓 | 유지, 처리 거부 |
| 모든 상태 | 위에 없는 조합 | 없음 | 유지, 처리 거부 |
표에 없는 조합을 어떻게 취급하는지도 규칙이다. 이 예제에서는 상태를 유지하고 처리 결과로 거짓을 반환한다. 고장 중 START, 준비 중 ITEM, 이송 중 RELEASE가 여기에 해당한다. 무시할 입력과 프로그램 오류를 같은 것으로 취급하지 않도록, 외부 입력의 거부를 정상적인 반환값으로 표현한다.
이벤트를 처리했다는 말과 상태가 달라졌다는 말도 구별한다. IDLE에서 STOP을 받으면 정지 요구를 만족하므로 처리 결과는 참이다. 다만 이미 목적 상태이므로 진입·이탈 동작은 반복하지 않는다. 이 규칙은 모터 정지처럼 반복 요청이 자연스러운 명령을 설명하기에 유용하다.
반대로 현재 상태로 다시 전이하면서 진입 동작을 재실행해야 하는 설계도 있다. 예를 들어 재시도 횟수를 올리거나 대기 시간을 새로 시작해야 할 수 있다. 그런 경우에는 같은 상태를 지정했을 때의 의미를 별도로 정의해야 한다. 이 장의 transition()은 같은 상태에 대한 요청을 아무 동작 없이 끝낸다.
계층 상태로 공통 규칙을 모은다
계층 상태(hierarchical state)는 여러 하위 상태에 적용되는 규칙을 상위 상태에 둔다. IDLE, FEED, HOLD를 정상 운전이라는 상위 상태로 묶으면 STOP과 JAM을 각 상태에 반복해서 적지 않아도 된다. FAULT는 이 묶음 밖에 두므로 정상 운전의 STOP 규칙을 물려받지 않는다.
계층을 구현한다고 반드시 상위 상태까지 열거형에 넣을 필요는 없다. 여기서는 현재 활성화된 하위 상태 하나만 저장한다. FAULT가 아니면 정상 운전의 공통 처리 함수를 먼저 호출한다. 공통 함수가 처리하지 않은 이벤트만 현재 하위 상태로 전달한다. 저장 공간은 평면적인 열거형이지만, 동작 규칙은 두 단계로 나뉜다.
이 예제의 처리 순서는 상위 규칙 우선이다. STOP과 JAM을 하위 상태의 동작보다 먼저 판단하려는 선택이다. 하위 상태가 먼저 처리하고 남은 이벤트를 상위로 올리는 설계도 가능하다. 어느 방식이든 문서와 구현이 같아야 한다. 이 코드를 확장할 때 하위 상태에 STOP 분기를 추가해도 그 분기에 도달하지 않는다는 점에 주의한다.
상위 규칙 우선이라는 말은 대기 중인 여러 이벤트의 우선순위를 뜻하지 않는다. START 다음에 JAM을 순서대로 공급하면 START가 먼저 처리된다. 고장 이벤트를 다른 이벤트보다 앞세우려면 입력 전달 규칙이 별도로 필요하다. 이 장의 실행기는 주어진 입력 순서를 그대로 보존한다.
계층에는 진입·이탈 동작의 규칙도 필요하다. 이 예제의 상위 상태에는 별도 진입·이탈 동작이 없다. 따라서 FEED에서 HOLD로 이동할 때 정상 운전 자체를 나갔다가 들어오는 처리는 없다. 하위 상태를 바꾸면서 FEED의 이탈 동작으로 모터 요구값을 끄는 것만 수행한다.
FEED 진입 시 켜고 FEED 이탈 시 끄도록 규칙을 모으면, 정지·검사 대기·고장 전이가 같은 출력 처리를 공유한다. 새로운 전이를 추가할 때마다 모터를 끄는 문장을 복사할 필요가 줄어든다. 다만 전이 함수 바깥에서 현재 상태를 직접 바꾸면 이 규칙을 건너뛴다. 초기화 이후의 상태 변경은 전이 함수로 모아야 한다.
상태 판단과 실행 환경을 분리한다
테스트하기 쉬운 구조에서는 제어기가 입력 이벤트를 받아 상태와 출력 요구값을 결정한다. 하드웨어 추상화 계층(HAL)은 그 요구값을 실제 출력에 반영한다. 제어기는 GPIO 레지스터 주소나 PC의 출력 장치에 대해 알 필요가 없다. 테스트는 제어기에 입력을 전달한 뒤 상태, 출력, 처리 여부를 함께 검사한다.
완성 코드의 hal_sim 계층은 모터 핀을 불리언 값으로 보관한다. 간단한 협동형 스케줄러 시뮬레이터는 매 논리 틱마다 표의 다음 입력을 하나 실행한다. 한 번의 이벤트 처리가 반환된 뒤에 다음 입력을 실행하므로, 상태 변경 도중에 다른 이벤트가 끼어들지 않는다. 이 실행 규칙을 끝까지 처리(run-to-completion) 방식이라고 부른다.
여기서 틱은 실제 시간이 아니다. 운영체제를 재우거나 벽시계를 읽지 않고 배열 인덱스를 한 칸씩 증가시킨다. 따라서 실행 속도가 다른 PC에서도 같은 입력 순서와 출력을 얻는다. 타이머의 정확도나 작업 간 실행 시간은 이 테스트로 확인할 수 없다. 대신 이벤트에 대한 기능적 결과를 빠르게 반복 검사할 수 있다.
이 구조를 실제 RTOS로 옮겨도 상태 기계 자체에 RTOS 호출을 넣을 필요는 없다. 한 태스크가 제어기 객체를 소유하고, 받은 이벤트를 차례로 처리하게 구성할 수 있다. 다음 표는 실행 환경의 대응 지점만 보여 준다. 큐 용량, 대기 정책, 태스크 간 동기화는 여기서 구현하지 않는다.
| PC 예제 | FreeRTOS의 대응 | 역할과 차이 |
|---|---|---|
| sim_run의 실행 문맥 | xTaskCreate | 제어기 객체를 소유할 태스크를 생성한다. |
| 배열에서 이벤트 읽기 | xQueueReceive | 태스크가 이벤트를 수신한 뒤 dispatch를 호출한다. |
| 테스트 입력 배열 | xQueueSend | 다른 태스크가 이벤트 값을 큐에 전달한다. |
| 배열 인덱스인 논리 틱 | xTaskGetTickCount | 실제 커널 틱을 읽는다. 예제의 틱과 시간 의미는 다르다. |
| hal_sim_apply | 보드의 GPIO 드라이버 | GPIO 제어는 FreeRTOS의 공통 API가 아니다. |
xQueueSend는 태스크 문맥의 대응으로 적었다. 인터럽트에서 이벤트를 전달할 때는 해당 용도의 API와 제약을 따로 검토해야 한다. 함수의 상세 계약은 FreeRTOS 공식 API 참고 자료에서 확인할 수 있다. 아래 코드는 FreeRTOS 라이브러리 없이 실행된다.
완성 코드
다음 내용을 state_machine.c로 저장한다. 하나의 파일 안에서 제어기, 출력 모형, 입력 실행기를 구분했다. 테스트의 기대 모터 값은 기대 상태에서 계산하지 않고 각 행에 직접 적었다. 구현과 기대값이 같은 계산식을 공유하면 잘못된 규칙을 함께 통과시킬 수 있기 때문이다.
#include <stdbool.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
/* 01: States, events, and owned data. */
typedef enum {
ST_IDLE, ST_FEED, ST_HOLD, ST_FAULT
} State;
typedef enum {
EV_START, EV_ITEM, EV_RELEASE, EV_STOP, EV_JAM, EV_RESET
} EventKind;
typedef struct {
EventKind kind;
bool safe;
} Event;
typedef struct {
State state;
bool motor;
} Controller;
typedef struct {
bool motor_pin;
} HalSim;
typedef struct {
Event event;
State expected_state;
bool expected_motor;
bool expected_handled;
} TestStep;
/* 02: Initialization and transition actions. */
static void controller_init(Controller *c)
{
c->state = ST_IDLE;
c->motor = false;
}
static void transition(Controller *c, State next)
{
if (c->state == next) {
return;
}
if (c->state == ST_FEED) {
c->motor = false;
}
c->state = next;
if (next == ST_FEED) {
c->motor = true;
}
}
/* 03: Shared rules of the operational parent. */
static bool handle_operational(Controller *c, Event event)
{
switch (event.kind) {
case EV_STOP:
transition(c, ST_IDLE);
return true;
case EV_JAM:
transition(c, ST_FAULT);
return true;
default:
return false;
}
}
/* 04: One event is handled completely before returning. */
static bool dispatch(Controller *c, Event event)
{
if (c->state == ST_FAULT) {
if (event.kind == EV_RESET && event.safe) {
transition(c, ST_IDLE);
return true;
}
return false;
}
if (handle_operational(c, event)) {
return true;
}
switch (c->state) {
case ST_IDLE:
if (event.kind == EV_START) {
transition(c, ST_FEED);
return true;
}
break;
case ST_FEED:
if (event.kind == EV_ITEM) {
transition(c, ST_HOLD);
return true;
}
break;
case ST_HOLD:
if (event.kind == EV_RELEASE) {
transition(c, ST_FEED);
return true;
}
break;
case ST_FAULT:
break;
}
return false;
}
/* 05: Simulated hardware output. */
static void hal_sim_apply(HalSim *hal, const Controller *c)
{
hal->motor_pin = c->motor;
}
/* 06: Names and checks belong to the PC harness. */
static const char *state_name(State state)
{
switch (state) {
case ST_IDLE: return "IDLE";
case ST_FEED: return "FEED";
case ST_HOLD: return "HOLD";
case ST_FAULT: return "FAULT";
}
return "?";
}
static const char *event_name(EventKind kind)
{
switch (kind) {
case EV_START: return "START";
case EV_ITEM: return "ITEM";
case EV_RELEASE: return "RELEASE";
case EV_STOP: return "STOP";
case EV_JAM: return "JAM";
case EV_RESET: return "RESET";
}
return "?";
}
static void check(bool condition, size_t tick, const char *what)
{
if (!condition) {
fprintf(stderr, "FAIL t=%zu: %s\n", tick, what);
exit(EXIT_FAILURE);
}
}
/* 07: One job per logical tick, without wall-clock waiting. */
static void sim_run(const TestStep *steps, size_t count)
{
Controller c;
HalSim hal = { false };
controller_init(&c);
hal_sim_apply(&hal, &c);
check(c.state == ST_IDLE && !hal.motor_pin, 0, "init");
for (size_t tick = 0; tick < count; ++tick) {
const TestStep *step = &steps[tick];
bool handled = dispatch(&c, step->event);
hal_sim_apply(&hal, &c);
check(c.state == step->expected_state, tick, "state");
check(c.motor == step->expected_motor, tick, "request");
check(hal.motor_pin == step->expected_motor, tick, "pin");
check(handled == step->expected_handled, tick, "handled");
check(c.motor == (c.state == ST_FEED), tick, "invariant");
printf("t=%zu event=%s state=%s motor=%d handled=%d\n",
tick, event_name(step->event.kind), state_name(c.state),
hal.motor_pin ? 1 : 0, handled ? 1 : 0);
}
printf("PASS %zu steps\n", count);
}
/* 08: Inputs and independently written expectations. */
int main(void)
{
static const TestStep steps[] = {
{ { EV_RESET, true }, ST_IDLE, false, false },
{ { EV_START, false }, ST_FEED, true, true },
{ { EV_ITEM, false }, ST_HOLD, false, true },
{ { EV_RELEASE, false }, ST_FEED, true, true },
{ { EV_STOP, false }, ST_IDLE, false, true },
{ { EV_START, false }, ST_FEED, true, true },
{ { EV_ITEM, false }, ST_HOLD, false, true },
{ { EV_JAM, false }, ST_FAULT, false, true },
{ { EV_START, false }, ST_FAULT, false, false },
{ { EV_RESET, false }, ST_FAULT, false, false },
{ { EV_RESET, true }, ST_IDLE, false, true },
{ { EV_START, false }, ST_FEED, true, true },
{ { EV_JAM, false }, ST_FAULT, false, true },
{ { EV_STOP, false }, ST_FAULT, false, false },
{ { EV_RESET, true }, ST_IDLE, false, true },
{ { EV_STOP, false }, ST_IDLE, false, true },
{ { EV_ITEM, false }, ST_IDLE, false, false }
};
sim_run(steps, sizeof steps / sizeof steps[0]);
return EXIT_SUCCESS;
}
줄별 해설
01의 열거형과 구조체. State는 서로 배타적인 동작 모드다. EventKind는 입력 종류이며, Event.safe는 RESET에만 의미가 있는 부가 정보다. 다른 이벤트의 safe는 읽지 않는다. 모든 입력을 값으로 전달하므로 호출자가 나중에 원본을 바꾸더라도 처리 중인 이벤트는 바뀌지 않는다.
Controller.state와 Controller.motor는 제어기의 소유 데이터다. 모터 값은 실제 핀을 읽은 결과가 아니라 원하는 출력이다. HalSim.motor_pin은 그 요구가 반영된 출력 모형이다. 이 둘을 나눠야 상태 판단의 오류와 출력 연결의 오류를 따로 검사할 수 있다.
02의 초기화. controller_init()은 이전 메모리 내용에 의존하지 않고 준비 상태와 정지 출력을 설정한다. 초기화할 때는 아직 이전 상태의 의미가 없으므로 전이 함수를 호출하지 않는다. 재현 가능한 테스트는 매번 같은 초기 조건에서 출발해야 한다.
02의 전이. 첫 조건문은 같은 상태로 향하는 요청을 끝낸다. 다음 조건문은 기존 상태가 FEED일 때 이탈 동작을 수행한다. 그 뒤 현재 상태를 바꾸고, 목적 상태가 FEED일 때 진입 동작을 수행한다. c->state = next;의 앞뒤로 이탈과 진입이 분리되어 있다는 순서가 핵심이다.
03의 공통 처리. STOP과 JAM은 목적 상태만 다르다. 둘 다 전이 함수를 호출하고 참을 반환한다. default의 거짓은 이벤트를 버리라는 뜻이 아니라 이 상위 처리기가 담당하지 않았다는 뜻이다. 호출자는 이어서 하위 상태에 처리를 맡길 수 있다.
04의 고장 분기. FAULT를 먼저 검사하므로 고장 상태에는 정상 운전 규칙이 적용되지 않는다. RESET과 safe가 모두 참일 때만 IDLE로 이동한다. 그 밖의 입력은 즉시 거짓을 반환한다. 고장을 해제해도 FEED로 곧바로 돌아가지 않으므로 재시작에는 별도 START가 필요하다.
04의 정상 운전 분기. 공통 함수가 참을 반환하면 즉시 종료한다. 그렇지 않으면 상태별 switch로 내려간다. 각 상태는 자신이 해석할 이벤트 하나만 받아들인다. 마지막 거짓 반환은 정상 운전에서도 정의되지 않은 조합을 일관되게 거부한다. FAULT의 case는 앞의 분기 때문에 실행되지 않지만 열거형의 항목을 명시적으로 남겼다.
05의 출력 반영. const Controller *는 이 함수가 제어기 내용을 바꾸지 않음을 표현한다. 현재 구현은 값 하나를 복사하지만, 보드에서는 해당 위치에 GPIO 드라이버 호출을 연결할 수 있다. 상태 기계 안에서 직접 핀을 쓰지 않았으므로 전이 규칙은 그대로 유지된다.
06의 검사. 이름 변환 함수는 상태 판단에 참여하지 않으며 출력용 문자열만 제공한다. check()는 불일치한 틱과 검사 항목을 표준 오류로 내보내고 실패 종료한다. 조건 검사를 직접 작성했으므로 NDEBUG 정의 여부에 따라 검사가 사라지지 않는다.
07의 실행 순서. 각 틱에서 이벤트 처리, 출력 반영, 결과 검사, 로그 출력을 순서대로 수행한다. handled는 해당 입력을 받아들였는지 보관한다. 거부 입력은 상태와 모터가 그대로여도 반환값이 잘못될 수 있으므로 별도로 검사한다. %zu는 size_t인 틱과 배열 길이를 출력한다.
마지막 검사는 불변식(invariant)이다. 모든 이벤트 처리가 끝난 경계에서 모터 요구값은 FEED 여부와 같아야 한다. 이 조건은 각 행의 기대값 검사를 대체하지 않는다. 예를 들어 잘못된 전이로 FEED에 도착했어도 모터가 켜져 있으면 불변식만은 만족할 수 있다.
08의 입력 시나리오. 배열 한 행은 이벤트, 기대 상태, 기대 모터 값, 기대 처리 여부를 담는다. sizeof steps / sizeof steps[0]은 실제 배열이 선언된 위치에서 원소 수를 계산한다. 배열을 포인터로 받은 sim_run() 안에서는 같은 방식으로 길이를 구할 수 없으므로 길이를 별도 인자로 전달한다.
실행 결과
macOS 또는 Linux의 C11 컴파일러로 다음과 같이 빌드하고 실행한다. 스레드는 만들지 않지만 지정한 실행 환경에 맞춰 -pthread 옵션을 포함한다. 외부 라이브러리나 추가 헤더는 필요하지 않다.
cc -std=c11 -Wall -Wextra -pthread state_machine.c -o state_machine
./state_machine
예상 출력은 다음과 같다. 각 줄의 상태와 모터 값은 해당 이벤트를 처리한 뒤의 값이다.
t=0 event=RESET state=IDLE motor=0 handled=0
t=1 event=START state=FEED motor=1 handled=1
t=2 event=ITEM state=HOLD motor=0 handled=1
t=3 event=RELEASE state=FEED motor=1 handled=1
t=4 event=STOP state=IDLE motor=0 handled=1
t=5 event=START state=FEED motor=1 handled=1
t=6 event=ITEM state=HOLD motor=0 handled=1
t=7 event=JAM state=FAULT motor=0 handled=1
t=8 event=START state=FAULT motor=0 handled=0
t=9 event=RESET state=FAULT motor=0 handled=0
t=10 event=RESET state=IDLE motor=0 handled=1
t=11 event=START state=FEED motor=1 handled=1
t=12 event=JAM state=FAULT motor=0 handled=1
t=13 event=STOP state=FAULT motor=0 handled=0
t=14 event=RESET state=IDLE motor=0 handled=1
t=15 event=STOP state=IDLE motor=0 handled=1
t=16 event=ITEM state=IDLE motor=0 handled=0
PASS 17 steps
틱 9와 10은 모두 RESET이지만 입력의 safe 값이 다르다. 첫 요청은 거부되고 두 번째 요청은 준비 상태로 이동한다. 틱 12에서는 이송 중 JAM을 받아 모터가 꺼진다. 틱 15는 상태 변화가 없어도 처리 결과가 참일 수 있음을 보여 준다.
통과 문구는 작성한 17개 입력의 기대 결과를 만족했다는 뜻이다. 가능한 모든 입력 순서를 검사했다는 뜻은 아니다. 예를 들어 HOLD에서 STOP을 받는 경로와 IDLE에서 JAM을 받는 경로는 이 시나리오에 없다. 테스트를 늘릴 때에는 로그 줄 수보다 전이 표에서 아직 검사하지 않은 행과 조건을 기준으로 삼는다.
실무에서 자주 틀리는 것
상태만 바꾸고 전이 동작을 건너뛴다
다음은 제어기 내부에서 발생할 수 있는 잘못된 부분 코드다. FEED에서 실행되면 상태는 FAULT지만 모터 요구값은 참으로 남는다.
/* Wrong: exit action is skipped. */
c->state = ST_FAULT;
상태 변경을 전이 함수로 모으면 FEED의 이탈 동작이 함께 실행된다. 아래 수정은 다른 고장 진입 경로에도 같은 규칙을 적용한다.
/* Correct: use the transition boundary. */
transition(c, ST_FAULT);
모터 값을 외부에서 임의로 바꾸는 것도 같은 문제를 만든다. 상태와 출력 사이의 관계를 유지하려면 두 필드를 변경하는 책임을 제어기 내부로 제한해야 한다.
고장 상태에도 정상 운전 규칙을 적용한다
다음 순서는 잘못되었다. 공통 처리기가 먼저 STOP을 받아 FAULT를 IDLE로 바꾸므로 고장 해제 조건을 우회한다. 아래 두 예는 dispatch() 앞부분의 배치 차이를 보여 준다.
/* Wrong order. */
if (handle_operational(c, event)) {
return true;
}
if (c->state == ST_FAULT) {
return false;
}
고장 상태를 먼저 처리한 뒤 정상 운전의 상위 규칙으로 내려가야 한다. 코드의 호출 순서가 상태 계층의 경계 역할을 한다.
/* Correct order. */
if (c->state == ST_FAULT) {
if (event.kind == EV_RESET && event.safe) {
transition(c, ST_IDLE);
return true;
}
return false;
}
if (handle_operational(c, event)) {
return true;
}
처리기 안에서 다음 이벤트를 기다린다
검사 대기에 들어간 뒤 함수 안에서 반출 허가를 기다리면 호출이 반환되지 않는다. 다음은 외부 입력을 기다리는 잘못된 형태를 나타낸 부분 코드다.
/* Wrong: no other event can be dispatched here. */
while (c->state == ST_HOLD) {
/* Wait for permission. */
}
이 구조에서는 같은 실행 문맥이 STOP이나 JAM을 전달할 기회를 잃는다. HOLD는 기다리는 동안 유지할 상태이지, CPU를 묶어 둘 반복문이 아니다. 해당 상태의 분기는 입력 하나를 판단한 뒤 반환해야 한다.
/* Correct: fragment inside the state switch. */
case ST_HOLD:
if (event.kind == EV_RELEASE) {
transition(c, ST_FEED);
return true;
}
break;
실제 반출 허가는 다음 이벤트로 들어온다. 검사 장치의 응답 시간이 길어도 이벤트 처리 함수의 실행 시간이 함께 길어지지는 않는다.
구현으로 기대값을 다시 계산한다
테스트의 기대 상태를 같은 제어 함수로 구하면 구현이 잘못되어도 비교 결과가 같아질 수 있다. 다음은 기존 제어기 c와 입력 event를 사용한 잘못된 검사 형태다.
/* Wrong: both sides execute the same implementation. */
Controller expected = c;
(void)dispatch(&expected, event);
(void)dispatch(&c, event);
check(c.state == expected.state, 0, "state");
기대값은 전이 표에서 직접 가져온다. 다음 조각은 초기화부터 입력 두 개를 공급하고, 검사 대기 상태의 결과를 독립적으로 지정한다.
/* Correct: expectations come from the specification. */
Controller probe;
controller_init(&probe);
check(dispatch(&probe, (Event){ EV_START, false }), 0, "start");
check(dispatch(&probe, (Event){ EV_ITEM, false }), 1, "item");
check(probe.state == ST_HOLD, 1, "state");
check(!probe.motor, 1, "motor");
순서가 긴 시나리오는 실제 운전 흐름을 확인하기 좋고, 짧은 독립 테스트는 실패 원인을 좁히기 좋다. 두 형태를 함께 사용하되 각각의 시작 조건을 명확히 둔다.
한눈에 보기
| 항목 | 이 예제의 결정 | 확인할 질문 |
|---|---|---|
| 상태 | IDLE, FEED, HOLD, FAULT | 같은 출력이어도 입력 해석이 다른가 |
| 정의되지 않은 입력 | 상태 유지, 거짓 반환 | 거부 정책이 모든 상태에서 일관적인가 |
| 정상 운전의 상위 규칙 | STOP과 JAM을 먼저 처리 | 하위 상태보다 우선할 규칙인가 |
| 고장 해제 조건 | FAULT에서 RESET과 safe 확인 | 다른 경로가 조건을 우회하지 않는가 |
| 진입·이탈 | FEED 진입 시 켜고 이탈 시 끔 | 상태를 직접 대입한 곳이 없는가 |
| 이벤트 처리 경계 | 입력 하나를 처리하고 반환 | 내부에서 외부 응답을 기다리지 않는가 |
| 검증 | 상태·출력·처리 여부와 불변식 검사 | 기대값이 구현과 독립적인가 |
상태 기계는 무엇을 해야 하는지를 정리하고, 실행 환경은 언제 입력을 전달할지를 정한다. 이 경계를 유지하면 다음 장에서 실행기를 여러 작업을 다루는 협동형 스케줄러로 바꾸더라도 전이 표와 제어기 테스트를 계속 사용할 수 있다.
연습 문제
- 새로 초기화한 제어기에 START, ITEM, STOP을 순서대로 전달한다. 각 입력 뒤의 상태, 모터 값, 처리 여부를 적고 독립 테스트용
TestStep배열을 작성하라. - HOLD에서 RESET을 받으면 IDLE로 돌아가도록 요구 사항이 바뀌었다고 하자. 이때도
safe가 참이어야 한다. 수정할 전이 표의 행과 코드 위치를 설명하고, 참과 거짓 조건을 검사할 입력 순서를 작성하라. - 기존 코드에서 IDLE에 JAM을 전달한 다음 STOP, RESET을 전달한다. RESET의
safe는 참이다. 결과를 예측하고, STOP이 고장을 해제하면 안 되는 이유를 계층 처리 순서와 연결해 설명하라. - 검사 위치를 벗어났다는 LEFT 이벤트를 추가하려 한다. 이를 FEED에서 받아도 상태와 출력은 유지하되 처리 여부는 참으로 반환해야 한다. 같은 상태 전이와 이벤트 수용의 차이를 설명하고 필요한 수정 위치를 적어라.
정답과 해설
-
START 뒤에는 FEED, 모터 참, 처리 참이다. ITEM 뒤에는 HOLD, 모터 거짓, 처리 참이다. STOP 뒤에는 IDLE, 모터 거짓, 처리 참이다. STOP은 HOLD의 개별 분기보다 먼저 정상 운전의 상위 규칙에서 처리된다. 다음 배열을
sim_run()에 넘기면 별도 초기화에서 이 경로를 검사할 수 있다.static const TestStep stop_from_hold[] = { { { EV_START, false }, ST_FEED, true, true }, { { EV_ITEM, false }, ST_HOLD, false, true }, { { EV_STOP, false }, ST_IDLE, false, true } }; sim_run(stop_from_hold, sizeof stop_from_hold / sizeof stop_from_hold[0]); -
HOLD와 RESET의 조합에 조건부 전이를 추가한다.
safe가 참이면 IDLE로 전이하고, 거짓이면 HOLD를 유지하며 거부한다. 정상 운전 전체의 규칙이 아니므로handle_operational()에 넣지 않는다.dispatch()의case ST_HOLD:안에 다음 조건을 추가한다.if (event.kind == EV_RESET && event.safe) { transition(c, ST_IDLE); return true; }초기화 뒤 START, ITEM, RESET(false), RESET(true)를 전달한다. 기대 상태는 FEED, HOLD, HOLD, IDLE이고 처리 여부는 참, 참, 거짓, 참이다. 마지막 두 입력 모두 모터는 꺼져 있어야 한다. 고장 상태의 RESET 규칙은 그대로 유지한다.
-
JAM 뒤에는 FAULT와 정지 출력이며 처리 여부는 참이다. STOP 뒤에도 FAULT와 정지 출력이며 처리 여부는 거짓이다. 마지막 RESET 뒤에는 IDLE과 정지 출력이며 처리 여부는 참이다. FAULT는 정상 운전의 하위 상태가 아니므로 그 상위 STOP 규칙을 적용하면 안 된다. 코드에서도 FAULT 분기를 먼저 끝내어 해제 조건을 보존한다.
-
EventKind에EV_LEFT를 추가하고event_name()에도 이름을 추가한다. FEED 분기에서 LEFT이면 상태를 바꾸지 않고 참을 반환한다. 이 경우에는 진입·이탈 동작을 실행할 이유가 없다. 입력을 수용한다는 사실은 상태 변화와 별개다./* Add inside case ST_FEED. */ if (event.kind == EV_LEFT) { return true; }START, LEFT를 전달했을 때 두 번째 결과는 FEED, 모터 참, 처리 참이다. 초기화 직후 LEFT를 전달하는 별도 검사에서는 IDLE, 모터 거짓, 처리 거짓이어야 한다. 두 검사를 함께 두면 이벤트를 너무 넓은 범위에서 받아들이는 오류도 찾을 수 있다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.