Devin.KR

종합 실습 - 컨베이어 제어기

개발자KR 조회 4

이 장에서 배우는 것

컨베이어 제어기는 센서 신호를 받는 것만으로 완성되지 않는다. 신호가 들어온 순서가 제어 판단에 보존되어야 하고, 모터 출력은 상태와 일치해야 하며, 작업이 멈추었을 때에는 정해진 시간 안에 정지 상태로 돌아가야 한다. 앞 장에서 다룬 펌웨어 교체 이후에도 확인해야 할 대상은 이러한 동작 계약이다.

이 장에서는 센서 인터럽트, 상태 기계, 협동형 스케줄러, 워치독을 하나의 실행 가능한 프로그램으로 묶는다. 공장 컨베이어의 입구에서 물체를 감지하면 모터를 켜고, 출구에서 감지하면 모터를 끈다. 정상 운전과 제어 작업 정지를 같은 입력으로 실행하여 결과와 타이밍 보고서를 비교한다. 개별 기법의 구현 원리를 다시 펼치기보다, 기법 사이에 어떤 정보를 주고받아야 하는지 확인하는 데 집중한다.

  • 센서 사건의 발생 시각과 처리 시각을 구분하여 상태 전이를 결정한다.
  • 주기 작업의 실행 순서와 실행 비용을 함께 고려하여 응답 시간을 기록한다.
  • 필수 작업의 진행 여부를 확인한 뒤에만 워치독을 갱신한다.
  • 정상 실행과 정지 결함 주입 결과를 구분한 타이밍 보고서를 작성한다.

문제 상황

작은 이송 구간에 입구 센서와 출구 센서가 하나씩 설치되어 있다. 상위 설비는 이 구간이 비었을 때에만 물체 하나를 공급한다. 제어기는 입구 감지 후 모터를 켜고, 출구 감지 후 모터를 끈다. 출구까지의 허용 시간은 입구 사건이 발생한 때부터 30밀리초 미만이다. 여기서 수치는 실습용이며 실제 이송 장치의 속도나 안전 규격을 나타내지 않는다.

현장에서 발생한 문제는 두 가지다. 첫째, 센서 신호를 처리한 시각으로 이동 시간을 계산하여 스케줄링 지연만큼 제한 시간이 늘어났다. 둘째, 제어 작업이 진행하지 않는데도 다른 주기 작업이 워치독을 계속 갱신했다. 로그에는 주기 메시지가 남았지만 모터 출력은 마지막 상태에 머물렀다.

통합 실습에서는 사건마다 발생 시각을 붙이고, 모터를 변경하는 경로를 상태 전이 함수로 모은다. 워치독 갱신 작업은 입출력 점검과 제어 작업의 진행 번호가 모두 바뀌었는지 검사한다. 결함 주입에서는 제어 작업이 실행권을 반환하지 않는 상황을 만든다. 이때 협동형 스케줄러의 뒤쪽 작업이 실행되지 않아도 워치독 시간은 흘러야 한다.

실행 결과를 매번 비교할 수 있도록 컴퓨터의 실제 대기 시간은 사용하지 않는다. 시뮬레이터의 1틱을 1밀리초로 정의하고, 작업이 실행 중인 구간에도 센서 사건과 워치독 만료를 처리한다. 따라서 출력 숫자는 호스트 운영체제의 부하에 따라 흔들리지 않는다. 대신 이 숫자를 실제 보드의 측정값으로 해석해서는 안 된다.

통합 경계와 동작 계약

인터럽트 서비스 루틴(ISR)의 역할은 사건 종류와 발생 시각을 작은 선입선출 큐에 넣는 데까지다. 상태 기계는 제어 작업에서만 실행한다. 입출력 점검 작업은 이번 실습에서 점검 완료를 진행 번호로 표시하는 역할만 한다. 실제 장치에서는 이 자리에 출력 피드백 읽기와 입력 진단 등을 배치하고, 성공 조건을 만족한 경우에만 진행 번호를 올린다.

상태는 대기, 이동, 고장 세 가지다. 대기 상태의 입구 사건은 이동을 시작하고, 이동 상태의 출구 사건은 대기로 돌아간다. 대기 상태의 출구 사건과 이동 상태의 추가 입구 사건은 공급 계약을 벗어난 사건으로 처리한다. 고장 상태에서는 모터를 끄고 정상 운전으로 자동 복귀하지 않는다.

사건의 종류와 시각이 상태 전이를 결정한다
현재 상태조건다음 상태모터
대기입구 사건 수신이동켜짐
이동제한 시각보다 이른 출구 사건대기꺼짐
이동유효한 출구 사건 없이 제한 시각 도달고장꺼짐
대기 또는 이동순서 위반 또는 사건 큐 넘침고장꺼짐
모든 상태워치독 만료리셋 후 정지 상태꺼짐

이동 제한 시각은 입구 사건의 발생 시각에 30을 더해 구한다. 출구 사건의 발생 시각이 제한 시각과 같아도 늦은 것으로 판정한다. 다만 제한 시각보다 앞서 발생한 출구 사건이 큐에 남아 있다면, 제어 작업은 그 사건을 먼저 소비한다. 사건이 제때 발생했는지와 모터 출력을 제때 바꾸었는지는 별개의 평가 항목이다.

예를 들어 입구 사건이 시각 2에 발생하고 시각 3에 처리되면 이동 제한 시각은 32다. 출구 사건이 시각 18에 발생하여 시각 23에 처리되면 이송 사건은 정상이다. 모터는 시각 23에 꺼지므로 출구 감지에서 정지 명령까지의 지연은 5밀리초다. 공정 요구가 이보다 짧다면 사건 판정이 정상이어도 출력 응답 요구는 충족하지 못한다.

센서 사건은 큐를 거쳐 상태와 출력을 바꾸고 필수 작업의 진행은 워치독 갱신 조건이 된다

큐는 저장 공간 여덟 칸 중 한 칸을 비워 두므로 사건 일곱 개를 보관한다. 큐가 가득 차면 가장 오래된 사건을 덮어쓰지 않고 넘침 표시를 유지한다. 사건 하나가 빠지면 이후의 입구와 출구를 잘못 짝지을 수 있기 때문이다. 제어 작업은 넘침을 발견하면 고장으로 전이하고, 점검 작업은 그 이후의 워치독 갱신을 중단한다.

이 프로그램은 한 실행 흐름에서 인터럽트 도착을 모사하므로 큐 접근이 실제로 겹치지 않는다. 그러므로 이 큐 구현 자체를 보드의 인터럽트와 주 실행 흐름 사이에 그대로 복사하면 안 된다. 보드에서는 앞서 다룬 데이터 공유 규칙에 따라 인덱스 갱신과 사건 공개 순서를 보장해야 한다. 여기서 재사용하는 것은 사건 형식과 넘침 정책, 상태 전이 계약이다.

시간을 진행시키는 스케줄러와 보고서

스케줄러는 입출력 점검, 제어, 생존 점검 순서로 실행 가능한 작업을 찾는다. 한 작업을 시작하면 그 작업이 끝날 때까지 다른 작업을 시작하지 않는다. 같은 시각에 여러 작업이 준비되었을 때의 배열 순서가 선택 순서가 된다. 실행 도중에 더 앞선 작업의 주기가 돌아와도 현재 작업을 선점하지 않는다.

시뮬레이터는 작업별 실행 비용만큼 실행권을 점유시킨 다음 완료 콜백을 호출한다. 상태 변경과 진행 번호 증가는 이 완료 콜백에서 일어난다. 이는 작업 내부의 명령어를 재현하는 모델이 아니라, 점유 구간과 완료 시점의 효과를 재현하는 모델이다. 실행 도중 도착한 사건도 완료 시점의 제어 콜백에서 읽을 수 있다는 점을 결과 해석에 포함해야 한다.

정상 시나리오의 작업 설정과 모델상 응답 시간
작업주기와 상대 마감실행 비용최대 응답 시간
입출력 점검 io5밀리초1밀리초1밀리초
제어 ctrl10밀리초2밀리초3밀리초
생존 점검 health20밀리초1밀리초4밀리초

작업의 응답 시간은 예정된 활성화 시각부터 완료 시각까지다. 제어 작업은 시각 0에 준비되지만 입출력 점검이 먼저 실행되어 시각 1에 시작하고 시각 3에 끝난다. 실행 비용은 2밀리초이고 응답 시간은 3밀리초다. 타이밍 보고서에서 이 둘을 섞으면 대기 시간을 놓치게 된다.

다음 활성화 시각은 완료 시각에 주기를 더해서 구하지 않는다. 이전에 예정했던 활성화 시각에 주기를 더한다. 그래야 실행 지연이 주기의 기준점을 계속 밀어내지 않는다. 이 예제에서는 밀린 실행을 건너뛰지 않고 차례로 수행한다. 과부하가 길어지면 앞쪽 작업의 밀린 실행 때문에 뒤쪽 작업이 지연될 수 있으므로, 이 정책 역시 시스템 요구에 맞추어 검토해야 한다.

정상 시나리오의 관찰 구간은 시각 0부터 60까지이며, 시각 60에서는 완료와 만료만 처리하고 새 작업을 시작하지 않는다. 작업의 실행 비용 합은 27밀리초이고 구간 대비 점유율은 45퍼센트다. 이 값에는 인터럽트 진입 비용, 큐 처리량에 따른 증가분, 출력 함수 비용이 포함되어 있지 않다. 점유율이 낮다는 사실만으로 마감 준수를 일반화하지 않는다.

보고서는 시작 횟수, 완료 횟수, 완료된 실행의 최대 응답 시간을 출력한다. 시작 횟수가 완료 횟수보다 크면 실행 중에 관찰이 끝난 작업이 있다는 뜻이다. 아직 시작하지 못한 활성화는 시작 횟수에 포함되지 않는다. 따라서 결함 시나리오의 작은 최대 응답 시간을 보고 정상이라고 판단해서는 안 된다.

진행 확인과 워치독의 연결

생존 점검 작업은 두 진행 번호를 이전 갱신 시점의 값과 비교한다. 입출력 점검과 제어가 모두 한 번 이상 진행했고 상태가 고장이 아니면 워치독 만료 시각을 현재 시각에서 25밀리초 뒤로 옮긴다. 처음에는 두 이전 값이 0이므로 두 작업이 실제로 완료되기 전에는 갱신할 수 없다.

두 진행 번호는 같은 순간에 증가할 필요가 없다. 이번 모델의 갱신 조건은 이전 워치독 갱신 이후 두 작업이 각각 진행했다는 것이다. 서로 같은 공정 주기를 처리했는지 확인해야 하는 장치라면 단순 진행 번호 외에 공정 번호나 처리한 사건 번호도 비교해야 한다. 워치독이 확인하는 증거의 범위를 요구사항에 맞춰 정해야 한다.

정상 실행에서는 시각 4, 24, 44에 갱신한다. 갱신 간격은 20밀리초이고 만료 간격은 25밀리초이므로 모델상 여유는 5밀리초다. 결함 시나리오에서는 제어 작업의 세 번째 실행이 시각 21에 멈춘다. 시각 20에 준비된 생존 점검은 실행권을 받지 못하고, 마지막 갱신 시각 4에 25를 더한 시각 29에 워치독이 만료된다.

정상 실행은 시각 24에 워치독을 갱신하고 제어 작업이 멈춘 실행은 시각 29에 만료된다

워치독 검사는 스케줄러가 실행하는 작업이 아니라 시뮬레이션 장치의 동작이다. 따라서 제어 작업이 실행권을 반환하지 않아도 검사를 계속한다. 실제 장치에서는 독립적으로 동작하는 하드웨어 워치독이 이 역할을 맡는다. 이 예제의 리셋 처리는 모터 허가 출력이 하드웨어 리셋으로 꺼진다는 가정을 표현하며, 멈춘 프로그램이 정지 함수를 호출한다고 가정하지 않는다.

실제 보드에서는 리셋 중 출력 핀의 전기적 상태, 구동기의 허가 입력, 부팅 직후 출력 설정까지 확인해야 한다. 프로그램의 고장 상태만으로 리셋 구간의 출력을 설명할 수는 없다. 이 장의 범위는 제어 소프트웨어 통합이며, 설비의 비상정지 회로나 필요한 안전 기능을 대체하는 모델은 아니다.

협동형 모델을 FreeRTOS로 옮길 때 대응시킬 요소
이 예제의 요소FreeRTOS 대응이식할 때 확인할 점
Task 배열과 작업 콜백xTaskCreateStatic()스택과 제어 블록을 제공하며 태스크 문맥을 분리한다.
예정 시각 기준 주기 갱신xTaskDelayUntil()틱 단위 변환과 이미 지난 활성화 시각의 처리 정책을 확인한다.
시각을 포함한 센서 사건 큐xQueueCreateStatic(), xQueueSendFromISR(), xQueueReceive()전송 실패를 검사하고 필요한 경우 인터럽트 종료 시 문맥 전환을 요청한다.
작업 진행 번호큐, 알림 또는 보호된 공유 데이터동시 접근을 보호하며 진행 증거를 잃지 않게 전달한다.
워치독 갱신과 리셋보드의 하드웨어 드라이버커널 타이머 콜백만으로 하드웨어 워치독을 대체하지 않는다.

이 표는 의미의 대응표다. 선점형 실시간 운영체제(RTOS)에서는 우선순위, 문맥 전환, 인터럽트 허용 우선순위가 실행 순서에 영향을 준다. 그러므로 이 예제에서 얻은 응답 시간을 그대로 가져갈 수 없다. 이식 시 API의 전제는 인터럽트용 큐 전송 공식 설명과 주기 지연 공식 설명에서 확인할 수 있다.

완성 코드

다음 프로그램을 conveyor.c로 저장한다. 하드웨어 시뮬레이션 계층인 hal_sim과 스케줄러를 한 파일에 넣었다. 별도의 라이브러리나 실제 대기 함수는 사용하지 않는다. 요청한 빌드 옵션의 -pthread는 함께 사용할 수 있지만 이 모델에서는 스레드를 생성하지 않는다. 두 시나리오는 각각 초기화된 상태에서 실행된다.

#include <stdbool.h>
#include <stdint.h>
#include <inttypes.h>
#include <stdio.h>

enum {
    QUEUE_SIZE = 8,
    TASK_COUNT = 3,
    IO_TASK = 0,
    CTRL_TASK = 1,
    HEALTH_TASK = 2,
    WATCHDOG_MS = 25,
    TRAVEL_MS = 30,
    END_MS = 60
};

typedef enum { ENTRY, EXIT } EventKind;
typedef enum { IDLE, MOVING, FAULT } State;

typedef struct {
    EventKind kind;
    uint64_t at;
} Event;

typedef struct {
    const char *name;
    uint64_t period;
    uint64_t cost;
    uint64_t next_release;
    unsigned started;
    unsigned done;
    uint64_t max_response;
} Task;

typedef struct {
    uint64_t now;
    Event queue[QUEUE_SIZE];
    unsigned head;
    unsigned tail;
    bool overflow;
    State state;
    bool motor;
    bool reset;
    uint64_t travel_deadline;
    uint64_t watchdog_deadline;
    uint64_t progress[2];
    uint64_t seen[2];
    Task tasks[TASK_COUNT];
    int running;
    uint64_t finish_at;
    uint64_t job_release;
    bool inject_stall;
    bool hung;
} Sim;

/* [A] State changes are the normal owner of the motor output. */
static const char *state_name(State state)
{
    switch (state) {
    case IDLE:   return "IDLE";
    case MOVING: return "MOVING";
    case FAULT:  return "FAULT";
    }
    return "UNKNOWN";
}

static void hal_sim_motor(Sim *s, bool on)
{
    s->motor = on;
}

static void change_state(Sim *s, State next)
{
    if (s->state == next) {
        return;
    }
    s->state = next;
    hal_sim_motor(s, next == MOVING);
    printf("t=%" PRIu64 " state=%s\n",
           s->now, state_name(next));
}

/* [B] ISR producer and controller consumer run serially here. */
static void sensor_isr(Sim *s, EventKind kind)
{
    unsigned next = (s->head + 1U) % QUEUE_SIZE;
    if (next == s->tail) {
        s->overflow = true;
        return;
    }
    s->queue[s->head] = (Event){kind, s->now};
    s->head = next;
}

static bool pop_event(Sim *s, Event *event)
{
    if (s->tail == s->head) {
        return false;
    }
    *event = s->queue[s->tail];
    s->tail = (s->tail + 1U) % QUEUE_SIZE;
    return true;
}

static void hal_sim_irq(Sim *s)
{
    if (s->now == 2U || s->now == 32U) {
        sensor_isr(s, ENTRY);
    }
    if (s->now == 18U || s->now == 48U) {
        sensor_isr(s, EXIT);
    }
}

/* [C] Event timestamps decide validity; completion time drives output. */
static void control_step(Sim *s)
{
    Event event;

    if (s->overflow) {
        change_state(s, FAULT);
    }
    if (s->state == FAULT) {
        return;
    }

    while (pop_event(s, &event)) {
        if (s->state == MOVING &&
            event.at >= s->travel_deadline) {
            change_state(s, FAULT);
            return;
        }

        if (s->state == IDLE && event.kind == ENTRY) {
            s->travel_deadline = event.at + TRAVEL_MS;
            change_state(s, MOVING);
        } else if (s->state == MOVING && event.kind == EXIT) {
            change_state(s, IDLE);
        } else {
            change_state(s, FAULT);
            return;
        }
    }

    if (s->state == MOVING &&
        s->now >= s->travel_deadline) {
        change_state(s, FAULT);
    }
}

/* [D] Feed only after both required workers have made progress. */
static void health_step(Sim *s)
{
    if (s->state == FAULT || s->overflow) {
        return;
    }
    if (s->progress[IO_TASK] != s->seen[IO_TASK] &&
        s->progress[CTRL_TASK] != s->seen[CTRL_TASK]) {
        s->seen[IO_TASK] = s->progress[IO_TASK];
        s->seen[CTRL_TASK] = s->progress[CTRL_TASK];
        s->watchdog_deadline = s->now + WATCHDOG_MS;
    }
}

/* [E] A task takes its modeled time before this callback runs. */
static void complete_job(Sim *s)
{
    int id = s->running;
    Task *task = &s->tasks[id];
    uint64_t response = s->now - s->job_release;

    task->done++;
    if (response > task->max_response) {
        task->max_response = response;
    }

    switch (id) {
    case IO_TASK:
        s->progress[IO_TASK]++;
        break;
    case CTRL_TASK:
        control_step(s);
        s->progress[CTRL_TASK]++;
        break;
    case HEALTH_TASK:
        health_step(s);
        break;
    default:
        break;
    }
    s->running = -1;
}

/* [F] Fixed scan order; a running task is never preempted. */
static void dispatch(Sim *s)
{
    if (s->running >= 0) {
        return;
    }

    for (int id = 0; id < TASK_COUNT; ++id) {
        Task *task = &s->tasks[id];
        if (s->now < task->next_release) {
            continue;
        }

        s->running = id;
        s->job_release = task->next_release;
        task->next_release += task->period;
        task->started++;
        s->finish_at = s->now + task->cost;

        if (s->inject_stall && id == CTRL_TASK &&
            task->started == 3U) {
            s->hung = true;
            printf("t=%" PRIu64 " ctrl_stalled\n", s->now);
        }
        return;
    }
}

/* [G] Model reset hardware, not a callback in the stalled task. */
static void hal_sim_watchdog_reset(Sim *s)
{
    s->reset = true;
    s->state = FAULT;
    hal_sim_motor(s, false);
    printf("t=%" PRIu64 " watchdog_reset\n", s->now);
}

static void report(const Sim *s)
{
    printf("end=%" PRIu64 " state=%s motor=%d reset=%d\n",
           s->now, state_name(s->state),
           (int)s->motor, (int)s->reset);

    for (int id = 0; id < TASK_COUNT; ++id) {
        const Task *task = &s->tasks[id];
        printf("%s: start=%u done=%u max_R=%" PRIu64 "\n",
               task->name, task->started, task->done,
               task->max_response);
    }
}

/* [H] Device events keep advancing even when a task is hung. */
static void run_case(const char *name, bool inject_stall)
{
    Sim s = {0};

    s.state = IDLE;
    s.running = -1;
    s.inject_stall = inject_stall;
    s.watchdog_deadline = WATCHDOG_MS;
    s.tasks[IO_TASK] =
        (Task){"io", 5, 1, 0, 0, 0, 0};
    s.tasks[CTRL_TASK] =
        (Task){"ctrl", 10, 2, 0, 0, 0, 0};
    s.tasks[HEALTH_TASK] =
        (Task){"health", 20, 1, 0, 0, 0, 0};

    printf("case %s\n", name);

    for (s.now = 0; s.now <= END_MS; ++s.now) {
        hal_sim_irq(&s);

        if (s.running >= 0 && !s.hung &&
            s.now == s.finish_at) {
            complete_job(&s);
        }

        if (s.now >= s.watchdog_deadline) {
            hal_sim_watchdog_reset(&s);
            break;
        }

        if (s.now == END_MS) {
            break;
        }
        dispatch(&s);
    }
    report(&s);
}

int main(void)
{
    run_case("normal", false);
    putchar('\n');
    run_case("stalled", true);
    return 0;
}

줄별 해설

자료형 선언부에서 시간은 uint64_t로 통일한다. 출력에는 해당 자료형에 맞는 PRIu64를 사용한다. 시뮬레이션 길이가 60밀리초로 제한되어 있으므로 시간 덧셈의 순환은 이 실행에서 발생하지 않는다. 실제 보드의 짧은 틱 카운터로 바꿀 때에는 시간 비교 방식도 함께 바꾸어야 한다.

Task의 next_release는 아직 시작하지 않은 가장 오래된 실행의 예정 시각이다. started와 done을 나눈 이유는 완료되지 않은 실행을 보고서에서 드러내기 위해서다. Sim의 job_release는 현재 실행의 기준 시각이며, 협동형 모델에서는 실행 중인 작업이 하나뿐이므로 하나만 있으면 된다.

[A]의 change_state()는 상태 변경과 모터 출력을 함께 갱신한다. 이동 상태일 때에만 모터가 켜진다. 같은 상태로의 요청은 로그를 남기지 않는다. 하드웨어 리셋은 정상 제어 경로와 다른 사건이므로 뒤의 리셋 함수에서 별도로 출력을 정지시킨다.

[B]에서 생산자는 사건 본문을 저장한 다음 머리 인덱스를 옮긴다. 소비자는 꼬리 위치의 사건을 복사한 다음 꼬리 인덱스를 옮긴다. overflow는 한 번 참이 되면 유지된다. 제어 작업이 늦게 실행되더라도 앞서 발생한 정보 손실을 확인할 수 있게 하는 표시다.

hal_sim_irq()는 시각 2와 32에 입구 사건을, 시각 18과 48에 출구 사건을 주입한다. 시뮬레이션 반복문이 매 틱 호출하므로 작업 실행 중에도 사건이 쌓인다. 실제 스레드를 만들지 않고도 비선점 작업이 센서 처리를 지연시키는 효과를 관찰할 수 있다.

[C]는 넘침을 먼저 검사하고, 이미 고장이면 운전 판단을 끝낸다. 큐를 소비하는 동안 이동 제한 이후의 사건인지 먼저 검사한 뒤 상태별 사건을 적용한다. 마지막 시간 초과 검사는 큐가 비었거나 유효한 출구 사건이 없었던 경우를 처리한다. 이 순서 덕분에 제때 발생하여 큐에 들어 있는 출구 사건을 처리 지연만으로 늦은 사건으로 오해하지 않는다.

[D]의 논리곱은 두 작업이 모두 진행해야 한다는 뜻이다. 이전 값은 워치독을 실제로 갱신할 때에만 저장한다. 제어 콜백이 고장을 판정한 경우에도 완료 자체는 기록되지만, 고장 상태 검사가 갱신을 차단한다. 따라서 진행 번호만 증가하면 계속 운전할 수 있는 구조가 아니다.

[E]에서는 완료 통계를 먼저 기록하고 작업별 효과를 적용한다. 응답 시간은 now - job_release이며 실제 시작 시각은 계산에 넣지 않는다. 작업이 기다린 시간도 포함해야 하기 때문이다. 마지막에 실행 중 표시를 지워 같은 틱에서 다음 작업을 시작할 수 있게 한다.

[F]는 실행 중인 작업이 있으면 즉시 돌아간다. 작업을 선택하면 예정 시각을 보관하고 다음 예정 시각을 한 주기만큼 증가시킨다. 결함 주입은 세 번째 제어 실행에서 hung을 설정한다. 실제 무한 반복을 실행하지 않으므로 시뮬레이터 자체는 멈추지 않지만, 완료 콜백과 후속 작업 실행은 중단된다.

[G]는 리셋과 정지 출력을 기록하고 실행을 끝낼 준비를 한다. 보고서의 FAULT는 리셋으로 정지한 결과를 표현한다. 여기에는 재부팅 과정이나 고장 이력의 비휘발성 저장을 넣지 않았다. 리셋 전후 복구 절차를 추가할 때에도 마지막 모터 출력이 그대로 재개되지 않도록 초기 상태 계약을 유지해야 한다.

[H]의 한 틱은 센서 주입, 작업 완료, 워치독 검사, 새 작업 선택 순서다. 같은 틱에 완료 콜백이 워치독을 갱신하면 이번 모델에서는 갱신이 먼저 인정된다. 실제 하드웨어의 경계 동작에 의존하지 않도록 정상 갱신은 만료 시각보다 충분히 앞서 설계해야 한다. 시각 60에서 새 실행을 만들지 않는 조건도 관찰 구간의 일부다.

실행 결과

macOS와 Linux의 C11 컴파일러에서 다음 명령으로 빌드하고 실행한다. 소스는 표준 C 헤더만 사용한다. 아래 출력의 시각과 max_R 단위는 모두 밀리초다.

cc -std=c11 -Wall -Wextra -pthread conveyor.c -o conveyor
./conveyor

예상 출력은 다음과 같다.

case normal
t=3 state=MOVING
t=23 state=IDLE
t=33 state=MOVING
t=53 state=IDLE
end=60 state=IDLE motor=0 reset=0
io: start=12 done=12 max_R=1
ctrl: start=6 done=6 max_R=3
health: start=3 done=3 max_R=4

case stalled
t=3 state=MOVING
t=21 ctrl_stalled
t=29 watchdog_reset
end=29 state=FAULT motor=0 reset=1
io: start=5 done=5 max_R=1
ctrl: start=3 done=2 max_R=3
health: start=1 done=1 max_R=4

정상 시나리오에서 두 입구 사건은 각각 1밀리초 뒤에 모터 켜짐으로 이어진다. 두 출구 사건은 각각 5밀리초 뒤에 모터 꺼짐으로 이어진다. 제어 작업의 최대 응답 시간 3밀리초와 출구 사건 응답 5밀리초가 다른 이유는, 사건이 다음 제어 활성화를 기다리는 시간도 있기 때문이다.

결함 시나리오에서는 출구 사건이 시각 18에 큐에 들어가지만 이를 읽을 제어 실행이 끝나지 않는다. 모터는 시각 29의 리셋 모델이 끈다. 정지 결함 발생에서 리셋까지는 8밀리초이며, 마지막 워치독 갱신에서 리셋까지는 25밀리초다. 워치독 제한과 개별 결함 검출 지연을 같은 값으로 기록하지 않는다.

정상 시나리오의 완료된 작업은 모두 설정한 상대 마감 안에 끝났다. 결함 시나리오에서는 제어 실행 하나가 미완료이며, 시각 20의 생존 점검은 시작하지도 못했다. max_R=3은 완료된 제어 실행 두 개에 대한 값일 뿐, 정지한 실행의 응답 시간이 아니다.

실제 보드의 타이밍 보고서에는 펌웨어 식별값, 클록 설정, 입력 부하, 관찰 시간, 인터럽트 부하를 함께 남긴다. 작업 응답과 센서에서 출력까지의 시간을 따로 측정하고, 미완료 실행과 사건 손실도 보고한다. 이번 결과는 고정된 네 사건과 일정한 실행 비용에 대한 재현 가능한 기준이며, 모든 입력 위상을 포괄한 최악 시간 증명은 아니다.

실무에서 자주 틀리는 것

처리 시각으로 이동 제한을 다시 시작한다

다음 코드는 입구 사건이 오래 기다렸을수록 더 늦게 고장을 판정한다. 제한 시간이 무엇을 기준으로 하는지 먼저 고정해야 한다. 이 예제의 계약은 입구 감지 시각부터다.

/* 잘못된 예: 큐에서 기다린 시간이 제한에서 빠진다. */
s->travel_deadline = s->now + TRAVEL_MS;

/* 고친 예: 원래 사건의 시각을 사용한다. */
s->travel_deadline = event.at + TRAVEL_MS;

모터가 실제로 켜진 뒤부터의 운전 시간을 별도로 제한하려면 다른 시각 필드를 둔다. 서로 다른 요구를 하나의 제한 시각에 겹쳐 담지 않는다.

생존 점검이 실행되었다는 이유만으로 갱신한다

한 작업만 계속 실행되는 상황에서도 워치독이 갱신되면 필수 기능의 정지를 놓칠 수 있다. 갱신 함수에 도달했다는 사실 외에, 필요한 작업들이 유효한 진행을 했다는 증거가 있어야 한다.

/* 잘못된 예 */
s->watchdog_deadline = s->now + WATCHDOG_MS;

/* 고친 예 */
if (s->state != FAULT && !s->overflow &&
    s->progress[IO_TASK] != s->seen[IO_TASK] &&
    s->progress[CTRL_TASK] != s->seen[CTRL_TASK]) {
    s->seen[IO_TASK] = s->progress[IO_TASK];
    s->seen[CTRL_TASK] = s->progress[CTRL_TASK];
    s->watchdog_deadline = s->now + WATCHDOG_MS;
}

실제 태스크로 분리하면 이 비교와 진행 번호 전달에도 동기화가 필요하다. 두 값을 읽는 동안 갱신이 끼어들 수 있는 실행 환경인지 함께 검토한다.

실행 비용을 응답 시간으로 보고한다

작업 자체의 실행 비용만 저장하면 준비된 뒤 기다린 시간이 보고서에서 사라진다. 다음의 고친 계산은 작업이 예정 시각을 넘겨 시작한 경우도 포함한다.

/* 잘못된 예 */
uint64_t response = task->cost;

/* 고친 예 */
uint64_t response = s->now - s->job_release;

두 선언은 대안을 보여 주므로 같은 블록에 함께 넣지 않는다. 사건 응답까지 보고하려면 여기에 더해 사건 발생 시각과 출력 변경 시각을 연결한 별도 기록이 필요하다.

빈 큐와 가득 찬 큐를 같은 상태로 만든다

다음의 잘못된 생산자는 소비 위치를 따라잡아도 계속 기록한다. 머리와 꼬리가 같아지면 소비자는 큐가 비었다고 해석할 수 있다. 넘침을 검출하고 상태 판단에 전달해야 한다.

/* 잘못된 예 */
s->queue[s->head] = (Event){kind, s->now};
s->head = (s->head + 1U) % QUEUE_SIZE;

/* 고친 예 */
unsigned next = (s->head + 1U) % QUEUE_SIZE;
if (next == s->tail) {
    s->overflow = true;
    return;
}
s->queue[s->head] = (Event){kind, s->now};
s->head = next;

넘침 표시만 남기고 계속 운전하는 정책도 자동으로 정당화되지 않는다. 이송 물체를 구분할 근거를 잃었으므로 이 예제에서는 정지한다. 다른 정책을 택하려면 유실 후 상태를 다시 확인할 방법이 있어야 한다.

한눈에 보기

통합 결과를 판단할 때 유지해야 할 구분
항목이 예제의 기준보고하거나 확인할 값
센서 사건발생 시각과 순서를 보존한다.큐 넘침과 사건에서 출력까지의 지연
상태와 출력이동 상태에서만 모터를 켠다.전이 시각과 고장 시 출력
작업 응답예정 활성화부터 완료까지 잰다.완료 최대값과 미완료 실행
워치독 갱신두 필수 작업의 진행과 정상 상태를 확인한다.갱신 간격과 결함 후 리셋 지연
시뮬레이션 결과고정 입력과 설정된 실행 비용의 결과다.모델에 포함하지 않은 비용과 입력 조건
보드 이식상태 계약을 유지하고 시간은 다시 측정한다.동기화, 선점, 리셋 중 출력 상태

통합의 완료 기준은 각 기능이 한 번씩 실행되었다는 사실이 아니다. 사건 손실을 발견할 수 있고, 시간 기준을 일관되게 적용하며, 멈춘 실행까지 보고서에 드러낼 수 있어야 한다. 컨베이어에 새 기능을 더할 때에도 이 기준을 유지하면 변경이 운전 시간과 정지 경로에 주는 영향을 비교할 수 있다.

연습 문제

  1. 정상 시나리오에서 입출력 점검의 실행 비용만 1에서 2밀리초로 바꾼다. 세 작업의 최대 응답 시간과 첫 워치독 갱신 시각을 구한다. 결함 시나리오에서는 제어 작업이 멈추는 시각과 리셋 시각이 어떻게 바뀌는지도 구한다.
  2. 입구 사건 시각 32와 출구 사건 시각 48을 제거하고, 첫 출구 사건을 시각 18에서 32로 옮긴다. 제어 작업이 이 사건을 읽는 시각과 최종 판정을 구한다. 출구 사건이 시각 31이었다면 무엇이 달라지는지 설명한다.
  3. 정상 시나리오에서 제어 작업만 주기를 10에서 20밀리초로 바꾼다. 첫 물체의 모터 켜짐과 꺼짐 시각을 구한다. 두 번째 물체 처리 때 어떤 상태 전이가 발생하는지도 확인한다. 워치독이 갱신된다는 사실만으로 공정 요구 충족을 판단할 수 있는지 설명한다.
  4. 관찰 구간 안에서 실행 비용은 소비했지만 완료되지 않은 시간을 점유율에 포함하고 싶다. 완료 횟수에 실행 비용을 곱하는 방식의 한계를 설명하고, 정상 및 결함 시나리오에 모두 적용할 수 있는 계수 위치를 제안한다.

정답과 해설

  1. 입출력 점검은 예정 시각에 시작하여 2밀리초 뒤에 끝나므로 최대 응답 시간은 2다. 제어 작업은 그 뒤에 2밀리초를 실행하므로 최대 응답 시간은 4다. 생존 점검은 시각 4부터 5까지 실행하므로 최대 응답 시간은 5이고 첫 갱신은 시각 5다. 이후 갱신은 25와 45에 일어난다. 결함 시나리오의 세 번째 제어 실행은 시각 22에 시작하여 멈춘다. 마지막 갱신이 5이므로 리셋은 시각 30이다.

  2. 입구 사건은 시각 2에 발생하므로 이동 제한 시각은 32다. 출구 사건은 시각 32에 큐에 들어가고, 시각 31부터 33까지 실행하는 제어 작업의 완료 콜백이 시각 33에 읽는다. 사건 시각이 제한 시각과 같으므로 고장이다. 출구 사건이 시각 31이었다면 시각 33에 읽더라도 유효한 출구로 판정하여 대기로 전이한다. 다만 모터가 꺼지는 시각은 여전히 33이므로, 출력 지연에 대한 요구는 따로 확인해야 한다.

  3. 첫 제어 실행은 시각 3에 완료되어 모터를 켠다. 다음 제어 실행은 시각 23에 완료되어 첫 출구 사건을 처리하고 모터를 끈다. 두 번째 입구 사건은 시각 32에 발생하지만 시각 43에 처리되어 모터가 켜진다. 시각 48의 출구 사건은 종료 시각 60까지 처리되지 않는다. 따라서 출력은 마지막에 이동 상태이며 모터가 켜져 있다. 두 필수 작업은 계속 진행하여 워치독은 정상적으로 갱신되므로, 워치독 정상 여부만으로 출력 지연 요구를 판단할 수 없다. 관찰을 연장하면 시각 63에 출구 사건을 처리하여 정지한다.

  4. 완료 횟수에 비용을 곱하면 멈춘 실행이 차지한 시간을 누락한다. 매 틱의 완료 처리와 새 작업 선택이 끝난 뒤, 다음 틱까지 실행 중인 작업이 있으면 점유 계수를 하나 늘리는 방식이 적합하다. 리셋이나 관찰 종료로 반복문을 벗어나는 틱에서는 더하지 않는다. 정상 실행은 60밀리초 중 27밀리초다. 결함 실행은 시각 21까지의 정상 작업 점유 10밀리초와 시각 21부터 29까지의 정지 작업 점유 8밀리초를 합해 18밀리초다. 이는 실행권 점유 시간이며, 호스트 프로세서의 실제 사용 시간을 뜻하지 않는다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.