Devin.KR

부트로더와 펌웨어 업데이트

개발자KR 조회 2

이 장에서 배우는 것

컨베이어 제어기의 프로그램은 출하 뒤에도 바뀐다. 센서 판정 기준을 보정하거나 모터 제어 명령을 추가하려면 현장에서 펌웨어를 교체해야 한다. 그런데 새 프로그램을 기록하는 중에 전원이 끊기면 다음 전원 인가 때 실행할 프로그램부터 사라질 수 있다. 업데이트 설계는 새 파일을 전달하는 기능뿐 아니라, 기록이 끝나지 않았을 때 무엇을 실행할지 정하는 작업이다.

앞 장에서 다룬 결함 처리가 실행 중의 이상에 대응했다면, 여기서는 실행을 시작할 이미지의 선택을 다룬다. 부트로더(bootloader)는 응용 프로그램보다 먼저 실행되어 저장된 이미지를 검사하고 실행 대상을 고른다. 이 장에서는 통신 방식과 실제 프로세서의 점프 코드를 제외하고, 메모리 배치와 검증, 교체 중단에 대한 복구 규칙을 C로 구현한다.

  • 부트로더, 응용 이미지, 설정 영역의 경계를 나누고 쓰기 권한을 정한다.
  • 순환 중복 검사(CRC)로 이미지의 길이와 내용이 기록 당시와 일치하는지 확인한다.
  • A/B 슬롯(slot)을 이용하여 실행 중인 이미지와 교체할 이미지를 분리한다.
  • 기록 중단, 데이터 손상, 시험 실행 실패를 구분하고 이전 이미지로 복귀한다.
  • PC 시뮬레이션에서 확인한 규칙을 실제 플래시와 RTOS 환경에 대응시킨다.

문제 상황

공장 컨베이어 제어기에 버전 1이 설치되어 있다. 버전 2는 포장 상자가 센서를 통과하는 시간을 다르게 해석한다. 작업자는 컨베이어를 정지시키고 업데이트를 시작한다. 새 이미지의 절반을 기록했을 때 제어반 전원이 내려간다. 기존 프로그램 위에 바로 덮어쓰는 구조라면, 다음 부팅에서는 앞부분이 버전 2이고 뒷부분이 버전 1인 데이터를 실행하려 할 수 있다.

파일 전체를 받은 뒤 검사하는 방법만으로는 이 문제를 해결하지 못한다. 수신 버퍼가 정상이어도 플래시에 기록하는 도중 실패할 수 있다. 따라서 실제로 저장된 내용을 읽어 검증하는 단계와 검증된 이미지를 실행 후보로 공개하는 단계를 구분해야 한다. 공개 표시가 없는 영역은 일부 데이터가 그럴듯해 보여도 실행하지 않는다.

또 다른 문제는 정상적으로 기록된 버전 2가 보드에서 제대로 동작하지 않는 경우다. CRC는 일치하지만 센서 초기화가 실패하거나 작업 설정을 읽지 못할 수 있다. 따라서 새 이미지는 처음부터 확정된 프로그램이 아니다. 한 번 시험 실행하고, 응용 프로그램이 필요한 점검을 마친 뒤 확정하는 절차가 필요하다.

예제의 업데이트 조건은 컨베이어가 정지한 상태다. 부팅 직후 출력도 정지 상태로 유지한다. 새 프로그램의 시험 실행과 생산 재개는 별개의 결정이다. 업데이트가 성공했다는 이유만으로 모터를 자동으로 구동하지 않는다.

메모리 분할과 기록 권한

플래시를 역할에 따라 나누면 업데이트 코드가 기록할 수 있는 범위를 좁힐 수 있다. 부트로더 영역에는 이미지 검사와 선택 코드가 들어간다. A와 B에는 각각 하나의 응용 이미지가 들어간다. 설정 영역은 생산 조건처럼 프로그램과 수명이 다른 데이터를 담는다. 예제에서는 주소 계산을 쉽게 확인하도록 전체 크기를 1,024바이트로 줄인다. 실제 제품의 용량을 제안하는 수치는 아니다.

시뮬레이터의 플래시 배치와 쓰기 범위
영역주소 범위크기업데이트 중 처리
부트로더0x000~0x0FF256바이트쓰기와 지우기 금지
A 이미지0x100~0x1FF256바이트현재 실행 영역이면 유지
B 이미지0x200~0x2FF256바이트현재 실행 영역이면 유지
설정0x300~0x3FF256바이트이 예제의 갱신 대상에서 제외
실행 중인 A를 유지하면서 B만 기록하고 각 이미지의 상태를 본문과 분리한다

각 이미지의 앞 16바이트는 헤더(header)로 사용한다. 버전, 본문 길이, CRC를 각각 4바이트로 저장하고, 그 뒤에 상태 1바이트와 예약 공간 3바이트를 둔다. 본문은 오프셋 16부터 시작하므로 최대 길이는 240바이트다. 저장 형식은 하위 바이트부터 배치하도록 직접 정한다. C 구조체를 그대로 저장하지 않아 구조체 사이의 빈 공간이나 PC의 바이트 배치에 의존하지 않는다.

메모리 지도에 선을 긋는 것만으로 보호가 생기지는 않는다. 실제 보드에서는 링커 설정으로 코드의 배치를 맞추고, 플래시 보호 기능으로 부트로더의 덮어쓰기를 제한해야 한다. 또한 경계는 지우기 단위에 맞춰야 한다. A의 마지막 부분과 B의 첫 부분이 같은 지우기 구역에 속하면 B를 지우다가 A도 지워질 수 있다.

두 영역에 저장할 수 있다는 사실과 두 주소에서 실행할 수 있다는 사실도 다르다. 특정 주소를 기준으로 링크한 프로그램을 다른 주소에 저장하면 참조 주소가 맞지 않을 수 있다. 실제 구현은 영역별로 링크한 이미지, 주소 재배치 기능, 하드웨어의 뱅크 전환 등 실행 위치를 해결하는 방식을 정해야 한다. 여기서는 본문을 실행 코드가 아닌 바이트 데이터로 다루고, 선택 결과만 출력한다.

CRC와 실행 후보의 공개

CRC는 저장된 바이트가 달라졌는지 검사하는 값이다. 이 예제는 CRC-32/ISO-HDLC의 반사형 계산을 사용한다. 초기값과 마지막 배타적 논리합 값은 모두 0xFFFFFFFF이며, 비트를 오른쪽으로 이동하는 계산에서 사용하는 다항식 값은 0xEDB88320이다. 매개변수가 달라지면 같은 입력에서도 결과가 달라지므로 송신 도구와 부트로더가 계산 규칙을 공유해야 한다.

검사 대상에는 버전 4바이트, 길이 4바이트, 지정된 길이의 본문을 순서대로 넣는다. CRC 필드 자체와 예약 공간은 제외한다. 상태는 기록 완료 이후에도 바뀌므로 CRC에 넣지 않는다. 대신 부트로더가 허용하는 상태 값만 정확히 받아들인다. 상태를 포함해 계산한 뒤 상태만 바꾸면, 정상적인 상태 전환이 이미지 손상으로 판정된다.

저장된 길이는 신뢰할 수 있는 값이 아니다. 먼저 1~240 범위인지 확인한 다음 본문을 읽는다. 버전은 0을 예약값으로 두고 거부한다. 검증 순서를 바꾸어 길이만큼 읽은 뒤 범위를 확인하면, 손상된 헤더 때문에 영역 밖의 데이터를 읽을 수 있다. CRC 검사기는 잘못된 길이를 안전하게 거부하는 코드까지 포함해야 한다.

기록은 대상 영역 지우기, 헤더 기록, 본문 기록, 저장 내용 검증, 완료 상태 기록 순서로 진행한다. 마지막 상태 기록이 실행 후보를 공개하는 지점이다. 본문이 전부 기록되었더라도 완료 상태가 없으면 부팅에서 제외한다. 전원 차단이 지우기나 기록을 중단시키더라도, 기존의 확정 이미지에 손을 대지 않았다는 사실이 복구의 바탕이 된다.

CRC 일치는 배포자의 신원을 증명하지 않는다. 이미지와 CRC를 함께 바꿀 수 있는 공격자는 검사에 통과하는 다른 이미지를 만들 수 있다. 이 장의 검사는 기록 중단과 우발적인 데이터 변경을 다룬다. 배포 권한을 확인하려면 서명 검증과 신뢰할 키의 저장을 별도로 설계해야 한다.

A/B 선택과 시험 실행의 확정

A/B 구조에서는 현재 실행 영역을 남겨 두고 다른 영역에 새 이미지를 쓴다. 별도의 활성 영역 포인터를 갱신하는 대신, 예제는 두 이미지의 상태와 버전을 읽어 대상을 고른다. 먼저 검사에 통과한 확정 이미지 중 버전이 가장 큰 것을 찾는다. 그보다 버전이 높은 준비 이미지가 있으면 시험 실행하고, 없으면 확정 이미지를 실행한다.

상태 바이트의 의미와 부팅 선택 규칙
값이름의미부팅 처리
0xFF미완료아직 실행 후보로 공개하지 않음제외
0xFE준비기록 후 CRC 검증 완료더 새 버전이면 시험 실행
0xFC시험 중실행 기회를 이미 소비함다음 부팅에서는 제외
0xF8확정응용 프로그램이 실행을 승인함복귀 대상으로 사용

상태는 0xFF에서 0xFE, 0xFC, 0xF8 순으로 바뀐다. 각 단계에서는 일부 비트가 1에서 0으로만 변한다. 시뮬레이터는 이 방향의 기록만 허용하며, 0을 1로 되돌리려면 해당 영역을 지워야 한다. 다만 이 규칙만으로 모든 실제 플래시에 같은 기록 방식이 허용되는 것은 아니다. 최소 프로그램 단위와 동일 단위에 대한 반복 기록 제한을 확인해야 한다.

부트로더는 준비 이미지를 실행하기 전에 상태를 시험 중으로 바꾼다. 새 프로그램이 확정하지 못한 채 리셋되면, 다음 부팅은 그 이미지를 다시 시험하지 않고 이전 확정 이미지로 돌아간다. 시험 표시 직후 전원이 끊어져 실제 실행을 못 했어도 시험 기회를 소비한 것으로 처리한다. 성공 가능성보다 복귀 경로를 우선하는 보수적인 규칙이다.

준비 이미지는 실행 전에 시험 중으로 바꾸며 확정하지 못하고 재부팅하면 이전 이미지로 돌아간다

응용 프로그램의 확정 시점은 제품 요구에 맞춰 정한다. 컨베이어에서는 필수 센서의 초기화와 설정 해석, 정지 출력 유지 여부를 확인한 뒤 확정할 수 있다. 단순히 시작 함수에 진입했다는 이유만으로 확정하면 그 직후 발생하는 초기화 실패를 복귀 절차가 처리하지 못한다. 시험 프로그램이 멈춰도 스스로 리셋되지는 않으므로, 앞 장에서 다룬 워치독 등 재시작 경로도 필요하다.

버전은 이 예제에서 크기 비교가 가능한 증가 번호다. 문자열 버전이나 날짜를 비교하는 코드는 아니다. 같은 번호의 재배포, 번호 소진, 의도적인 이전 버전 설치는 별도의 정책이 필요하다. 또한 새 이미지가 설정 형식을 바꾼 뒤 이전 이미지로 복귀하면, 프로그램은 살아 있어도 설정을 읽지 못할 수 있다. 이미지 확정 전에는 복귀 프로그램이 읽어야 할 영속 데이터를 어떻게 유지할지도 함께 결정해야 한다.

완성 코드

다음 프로그램을 boot_sim.c로 저장한다. 하드웨어 추상화 계층(HAL) 역할의 hal_sim 함수는 플래시 범위와 비트 기록 규칙을 검사한다. 간단한 협동형 스케줄러는 호출 한 번에 본문을 최대 8바이트 기록한다. 실행은 단일 스레드이며, 컴파일 명령의 pthread 옵션은 실행 환경에 맞춰 포함한다.

프로그램은 최초 이미지 설치, 기록 중단, 본문 손상, 시험 실행 실패, 정상 확정을 차례로 재현한다. 재부팅은 RAM의 실행 위치를 초기화하고 선택 함수를 다시 호출하는 것으로 표현한다. 플래시 배열은 호출 사이에 유지되지만 프로세스를 종료하면 사라진다. 실제 전원 차단의 전기적 동작이나 장치별 지우기 지연을 재현하는 모델은 아니다.

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>

enum {
    FLASH_SIZE = 1024,
    SLOT_SIZE = 256,
    HEADER_SIZE = 16,
    STATE_OFFSET = 12,
    PAYLOAD_MAX = SLOT_SIZE - HEADER_SIZE,
    CHUNK_SIZE = 8
};

enum {
    READY = 0xFE,
    TRIED = 0xFC,
    CONFIRMED = 0xF8
};

static uint8_t flash_mem[FLASH_SIZE];
static int running_slot = -1;
static bool conveyor_stopped = true;

static bool slot_ok(int slot)
{
    return slot == 0 || slot == 1;
}

static size_t slot_base(int slot)
{
    return (size_t)(slot + 1) * SLOT_SIZE;
}

static char slot_name(int slot)
{
    return slot == 0 ? 'A' : 'B';
}

static void put_u32(uint8_t *p, uint32_t value)
{
    for (unsigned i = 0; i < 4; ++i) {
        p[i] = (uint8_t)(value >> (8u * i));
    }
}

static uint32_t get_u32(const uint8_t *p)
{
    uint32_t value = 0;
    for (unsigned i = 0; i < 4; ++i) {
        value |= (uint32_t)p[i] << (8u * i);
    }
    return value;
}

static void hal_sim_init(void)
{
    memset(flash_mem, 0xFF, sizeof flash_mem);
}

static bool hal_sim_erase(int slot)
{
    if (!slot_ok(slot) || slot == running_slot) {
        return false;
    }
    memset(flash_mem + slot_base(slot), 0xFF, SLOT_SIZE);
    return true;
}

static bool hal_sim_program(size_t address,
                            const uint8_t *source, size_t length)
{
    const size_t first = SLOT_SIZE;
    const size_t end = 3u * SLOT_SIZE;

    if (address < first || address > end ||
        length > end - address) {
        return false;
    }
    for (size_t i = 0; i < length; ++i) {
        if ((flash_mem[address + i] & source[i]) != source[i]) {
            return false;
        }
    }
    for (size_t i = 0; i < length; ++i) {
        flash_mem[address + i] = source[i];
    }
    return true;
}

static uint32_t crc_feed(uint32_t crc,
                         const uint8_t *data, size_t length)
{
    for (size_t i = 0; i < length; ++i) {
        crc ^= data[i];
        for (unsigned bit = 0; bit < 8; ++bit) {
            if ((crc & 1u) != 0u) {
                crc = (crc >> 1) ^ UINT32_C(0xEDB88320);
            } else {
                crc >>= 1;
            }
        }
    }
    return crc;
}

static uint32_t image_crc(const uint8_t *header,
                          const uint8_t *payload, size_t length)
{
    uint32_t crc = crc_feed(UINT32_C(0xFFFFFFFF), header, 8);
    crc = crc_feed(crc, payload, length);
    return crc ^ UINT32_C(0xFFFFFFFF);
}

static bool image_integrity(int slot)
{
    if (!slot_ok(slot)) {
        return false;
    }
    const uint8_t *p = flash_mem + slot_base(slot);
    uint32_t version = get_u32(p);
    uint32_t length = get_u32(p + 4);

    if (version == 0 || length == 0 || length > PAYLOAD_MAX) {
        return false;
    }
    return get_u32(p + 8) ==
           image_crc(p, p + HEADER_SIZE, (size_t)length);
}

static uint8_t image_state(int slot)
{
    return flash_mem[slot_base(slot) + STATE_OFFSET];
}

static uint32_t image_version(int slot)
{
    return get_u32(flash_mem + slot_base(slot));
}

static bool write_state(int slot, uint8_t state)
{
    if (!slot_ok(slot)) {
        return false;
    }
    return hal_sim_program(slot_base(slot) + STATE_OFFSET,
                           &state, 1);
}

struct update_job {
    int slot;
    const uint8_t *data;
    size_t length;
    size_t offset;
    bool active;
    bool success;
};

static void update_task(struct update_job *job)
{
    size_t remaining = job->length - job->offset;
    size_t count = remaining > CHUNK_SIZE ? CHUNK_SIZE : remaining;
    size_t address = slot_base(job->slot) +
                     HEADER_SIZE + job->offset;

    if (!hal_sim_program(address, job->data + job->offset, count)) {
        job->active = false;
        return;
    }
    job->offset += count;
    if (job->offset == job->length) {
        job->success = image_integrity(job->slot) &&
                       write_state(job->slot, READY);
        job->active = false;
    }
}

static void scheduler_step(struct update_job *job)
{
    if (job->active) {
        update_task(job);
    }
}

static bool install_image(int slot, uint32_t version,
                          const uint8_t *data, size_t length,
                          bool cut_after_first_step)
{
    uint8_t header[12];

    if (!slot_ok(slot) || slot == running_slot ||
        !conveyor_stopped || version == 0 ||
        length == 0 || length > PAYLOAD_MAX) {
        return false;
    }

    put_u32(header, version);
    put_u32(header + 4, (uint32_t)length);
    put_u32(header + 8, image_crc(header, data, length));

    if (!hal_sim_erase(slot) ||
        !hal_sim_program(slot_base(slot), header, sizeof header)) {
        return false;
    }

    struct update_job job = {
        .slot = slot,
        .data = data,
        .length = length,
        .offset = 0,
        .active = true,
        .success = false
    };

    while (job.active) {
        scheduler_step(&job);
        if (cut_after_first_step && job.active) {
            return false;
        }
    }
    return job.success;
}

static int boot_select(void)
{
    int confirmed = -1;
    int candidate = -1;
    uint32_t confirmed_version = 0;
    uint32_t candidate_version = 0;

    running_slot = -1;
    conveyor_stopped = true;

    for (int slot = 0; slot < 2; ++slot) {
        if (image_state(slot) == CONFIRMED &&
            image_integrity(slot) &&
            image_version(slot) > confirmed_version) {
            confirmed = slot;
            confirmed_version = image_version(slot);
        }
    }

    candidate_version = confirmed_version;
    for (int slot = 0; slot < 2; ++slot) {
        if (image_state(slot) == READY &&
            image_integrity(slot) &&
            image_version(slot) > candidate_version) {
            candidate = slot;
            candidate_version = image_version(slot);
        }
    }

    int chosen = confirmed;
    const char *mode = "confirmed";
    if (candidate >= 0) {
        if (!write_state(candidate, TRIED)) {
            puts("boot: trial mark failed");
            return -1;
        }
        chosen = candidate;
        mode = "trial";
    }

    if (chosen < 0) {
        puts("boot: recovery required");
        return -1;
    }

    running_slot = chosen;
    printf("boot: %c v%lu %s\n", slot_name(chosen),
           (unsigned long)image_version(chosen), mode);
    return chosen;
}

static bool app_confirm(bool checks_passed)
{
    if (!checks_passed || !slot_ok(running_slot) ||
        image_state(running_slot) != TRIED ||
        !image_integrity(running_slot)) {
        return false;
    }
    return write_state(running_slot, CONFIRMED);
}

static bool expect(bool condition, const char *message)
{
    if (!condition) {
        fprintf(stderr, "check failed: %s\n", message);
    }
    return condition;
}

int main(void)
{
    static const uint8_t v1[] = "belt-control-v1";
    static const uint8_t v2[] = "belt-control-v2";

    hal_sim_init();

    if (!expect(install_image(0, 1, v1, sizeof v1, false),
                "factory image") ||
        !expect(boot_select() == 0, "factory boot") ||
        !expect(app_confirm(true), "factory confirmation")) {
        return 1;
    }
    puts("confirm: A");

    puts("case: interrupted write");
    if (!expect(!install_image(1, 2, v2, sizeof v2, true),
                "interrupted installation") ||
        !expect(image_state(1) == 0xFF, "unpublished image") ||
        !expect(boot_select() == 0, "boot after interruption")) {
        return 1;
    }

    puts("case: corrupted payload");
    if (!expect(install_image(1, 2, v2, sizeof v2, false),
                "image before corruption")) {
        return 1;
    }
    /* Test-only fault injection, outside the flash driver. */
    flash_mem[slot_base(1) + HEADER_SIZE] ^= UINT8_C(1);
    if (!expect(!image_integrity(1), "detect corruption") ||
        !expect(boot_select() == 0, "boot after corruption")) {
        return 1;
    }

    puts("case: unconfirmed trial");
    if (!expect(install_image(1, 2, v2, sizeof v2, false),
                "trial installation") ||
        !expect(boot_select() == 1, "trial boot") ||
        !expect(!app_confirm(false), "failed application checks") ||
        !expect(boot_select() == 0, "fallback boot")) {
        return 1;
    }

    puts("case: confirmed update");
    if (!expect(install_image(1, 2, v2, sizeof v2, false),
                "final installation") ||
        !expect(boot_select() == 1, "final trial") ||
        !expect(app_confirm(true), "final confirmation")) {
        return 1;
    }
    puts("confirm: B");
    if (!expect(boot_select() == 1, "confirmed boot")) {
        return 1;
    }

    puts("all checks passed");
    return 0;
}

줄별 해설

주소 계산과 바이트 저장

冒頭의 상수는 영역 크기와 헤더의 위치를 한곳에서 정의한다. PAYLOAD_MAX를 SLOT_SIZE에서 HEADER_SIZE를 빼서 계산하므로 헤더 크기를 바꿀 때 본문 한도를 따로 고칠 필요가 없다. slot_base는 A에 256, B에 512를 대응시킨다. 외부 입력을 받는 함수는 slot_ok로 인덱스를 먼저 검사하고, 내부 조회 함수는 검증된 인덱스만 받는다.

put_u32는 8비트씩 이동하며 네 바이트를 저장한다. get_u32는 각 바이트를 uint32_t로 바꾼 뒤 이동한다. 변환을 먼저 수행하면 부호 있는 int의 이동 연산에 기대지 않고 32비트 값을 조립할 수 있다. 정렬되지 않은 주소를 uint32_t 포인터로 바꾸어 읽는 방식도 사용하지 않는다.

hal_sim이 검사하는 범위

hal_sim_erase는 실행 중인 영역의 지우기를 거부한다. hal_sim_program은 부트로더와 설정 영역에 대한 기록을 거부하고, 기존 비트와 새 비트의 논리곱이 새 값과 같은지 검사한다. 모든 바이트를 먼저 검사한 다음 복사하므로, 이 함수의 실패는 부분 기록을 만들지 않는다. 이는 시뮬레이터의 편의상 정한 동작이며 실제 플래시의 실패 특성은 아니다.

범위 조건은 address + length를 먼저 계산하지 않는다. address가 끝 주소 이내인지 검사한 뒤 end - address와 length를 비교한다. 덧셈이 넘쳐 작은 주소가 되는 문제를 피하기 위한 순서다. 실행 영역의 일반 데이터 기록 금지는 상위 설치 함수가 맡고, 상태 기록은 시험 확정을 위해 별도로 허용한다. 실제 시스템에서는 이 구분이 우회되지 않도록 드라이버 접근 경계도 정해야 한다.

CRC 계산과 기록 작업

crc_feed는 전달받은 누적값을 이어서 계산한다. image_crc가 처음 한 번 초기값을 넣고 헤더와 본문을 차례로 전달한 다음 마지막 반전을 수행한다. 본문을 나누어 계산하더라도 조각마다 초기값을 다시 넣거나 마지막 반전을 반복하지 않아야 같은 결과를 얻는다.

image_integrity는 상태를 검사하지 않는다. 아직 준비 상태를 쓰기 전에도 저장 내용의 검증이 필요하기 때문이다. 부팅 함수는 상태 조건과 이 함수를 함께 사용한다. 이름과 책임을 나누면 “기록된 바이트가 정상이다”와 “실행 대상으로 선택해도 된다”를 같은 판단으로 섞지 않을 수 있다.

install_image는 헤더 배열의 처음 8바이트를 채운 뒤 CRC를 계산하고, 계산 결과를 나머지 4바이트에 넣는다. data가 가리키는 원본은 설치가 끝날 때까지 유지되어야 한다. 예제의 원본은 정적 배열이며 마지막 널 문자도 sizeof가 반환한 길이에 포함된다. 문자열 함수로 길이를 다시 구하지 않고 전달된 길이를 일관되게 사용한다.

update_task는 남은 길이와 8 중 작은 값을 기록한다. 마지막 조각까지 저장하면 플래시 배열에서 다시 계산한 CRC를 비교하고 준비 상태를 쓴다. 중단 조건은 첫 작업 호출 뒤에도 남은 작업이 있을 때만 적용된다. 예제의 본문은 16바이트이므로 8바이트가 기록된 지점에서 중단된다. 헤더의 상태 바이트는 여전히 0xFF다.

부팅 선택과 확인 코드

boot_select의 첫 반복문은 복귀 가능한 확정 이미지를 찾는다. 두 번째 반복문은 그 버전보다 큰 준비 이미지만 후보로 삼는다. 따라서 한쪽 영역에 낮은 버전의 준비 이미지가 남아 있어도 새 확정 이미지보다 먼저 실행하지 않는다. 시험 중 상태는 두 반복문 어디에도 포함되지 않는다.

후보를 고른 뒤 write_state가 성공해야 running_slot을 설정한다. 실제 시스템에서는 시험 표시가 비휘발성 저장소에 반영되었는지 확인한 뒤 제어를 넘겨야 한다. app_confirm은 현재 실행 영역이 시험 중이고 내용 검증에도 통과했을 때만 확정 상태를 쓴다. 검사 성공 여부는 예제에서 bool 값으로 전달하지만, 실제 제품에서는 필요한 점검 결과를 모아 결정한다.

main의 expect는 시나리오가 의도한 분기로 진행했는지 확인한다. 확인에 실패하면 표준 오류로 원인을 출력하고 종료값 1을 반환한다. 손상 주입 한 줄은 드라이버를 거치지 않고 배열을 바꾼다. 정상 기록 절차를 구현한 줄이 아니라, 저장 내용의 변화가 검출되는지 확인하는 시험 장치다.

PC 시뮬레이터와 FreeRTOS 환경의 역할 대응
PC 예제FreeRTOS에서의 대응적용 시 주의점
scheduler_stepxTaskCreate로 만든 업데이트 태스크예제는 직접 호출하며 우선순위 선점을 재현하지 않음
8바이트 작업 후 반환작업 분할과 필요에 따른 vTaskDelay지연 호출이 플래시 하드웨어의 정지 시간을 줄이지 않음
정적 원본 배열xQueueReceive로 받은 블록 또는 버퍼 소유권기록이 끝날 때까지 원본의 수명 유지
app_confirm 호출초기화 점검을 마친 응용 태스크확정 기록은 장치별 플래시 드라이버가 수행
boot_select스케줄러 시작 전의 부트로더 코드이미지 선택과 실행 이전은 FreeRTOS API가 대신하지 않음

8바이트로 나누어 쓴다는 사실만으로 실제 보드의 응답 시간이 보장되지는 않는다. 지우기나 프로그램 동작 중 같은 플래시 뱅크의 명령어 읽기가 정지할 수 있다. 드라이버가 RAM에서 실행되어야 하는지, 인터럽트 코드가 어느 영역에 있는지, 지우기 시간이 얼마인지 장치 문서로 확인해야 한다. 관련 API의 정의는 FreeRTOS 태스크 생성 문서와 큐 수신 문서에서 확인할 수 있다.

실행 결과

macOS 또는 Linux의 터미널에서 다음 명령으로 빌드하고 실행한다. 코드는 C11 표준 헤더만 사용하며 운영체제별 파일이나 장치에 접근하지 않는다.

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

예상 표준 출력은 다음과 같다. 모든 검사에 통과하면 종료값은 0이다.

boot: A v1 trial
confirm: A
case: interrupted write
boot: A v1 confirmed
case: corrupted payload
boot: A v1 confirmed
case: unconfirmed trial
boot: B v2 trial
boot: A v1 confirmed
case: confirmed update
boot: B v2 trial
confirm: B
boot: B v2 confirmed
all checks passed

기록 중단과 본문 손상에서는 B를 실행하지 않는다. 시험 실행 실패에서는 B를 한 번 선택하지만, 확정 없이 다시 부팅하면 A를 선택한다. 마지막 경우에는 B를 확정하므로 그 뒤의 부팅에서도 B가 선택된다. A의 버전 1은 이 과정에서 지워지지 않는다.

처음 설치한 A도 시험 실행 뒤 확정한다. 최초 설치에는 이전 확정 이미지가 없으므로 이 시험이 실패하면 복귀 대상이 없다. 그때는 recovery required를 출력하고 정지 상태에 남는다. 제품에서는 이 상태를 공장 재기록이나 별도 복구 수신 경로와 연결해야 한다.

실무에서 자주 틀리는 것

완료 표시를 본문보다 먼저 기록한다

다음은 틀린 순서다. READY를 쓴 뒤 본문 기록이 중단되면 완료 표시가 저장 내용보다 앞서간다. CRC가 이를 거부할 수 있더라도, 상태의 의미가 흐려지고 검사를 빠뜨린 경로에서 문제가 생긴다.

/* 잘못된 순서를 보여 주는 조각이다. */
write_state(slot, READY);
hal_sim_program(slot_base(slot) + HEADER_SIZE, data, length);

고친 순서는 기록과 저장 내용 검증에 모두 성공한 뒤 상태를 바꾼다. 아래 조각은 bool을 반환하는 기록 함수의 내부에 해당한다. 입력 범위와 헤더 기록은 먼저 완료되어 있어야 한다.

if (!hal_sim_program(slot_base(slot) + HEADER_SIZE, data, length)) {
    return false;
}
if (!image_integrity(slot)) {
    return false;
}
return write_state(slot, READY);

검증되지 않은 길이로 본문을 읽는다

다음 코드는 헤더에서 읽은 값을 바로 CRC 함수에 전달한다. 길이 필드가 손상되면 CRC를 비교하기 전에 잘못된 메모리 접근이 발생할 수 있다.

uint32_t length = get_u32(p + 4);
return get_u32(p + 8) ==
       image_crc(p, p + HEADER_SIZE, (size_t)length);

고친 코드는 저장 형식이 허용하는 길이를 먼저 검사한다. 더 큰 이미지 형식을 만들 때는 저장 길이를 size_t로 표현할 수 있는지도 확인해야 한다.

uint32_t length = get_u32(p + 4);
if (length == 0 || length > PAYLOAD_MAX) {
    return false;
}
return get_u32(p + 8) ==
       image_crc(p, p + HEADER_SIZE, (size_t)length);

시험 실행을 매번 새 기회로 취급한다

다음 부팅 코드 조각은 준비 이미지를 선택하면서 상태를 그대로 둔다. 응용 프로그램이 초기화 도중 리셋되면 다음 부팅에서도 같은 이미지를 선택한다.

if (candidate >= 0) {
    running_slot = candidate;
    return candidate;
}

고친 코드는 실행을 넘기기 전에 시험 기회를 사용했다는 사실을 저장한다. 확정하지 못한 이미지에 다시 기회를 주려면 명시적인 재설치나 복구 정책을 거쳐야 한다.

if (candidate >= 0) {
    if (!write_state(candidate, TRIED)) {
        return -1;
    }
    running_slot = candidate;
    return candidate;
}

현재 실행 영역을 업데이트 대상으로 허용한다

다음 입력 검사는 인덱스만 확인한다. 현재 실행 중인 영역을 넘겨도 설치 작업이 시작될 수 있다. 완성 코드의 지우기 함수가 이를 한 번 더 거부하더라도, 상위 함수 역시 대상 선택 규칙을 표현해야 한다.

if (!slot_ok(slot)) {
    return false;
}

고친 검사는 현재 실행 영역과 운전 상태를 함께 확인한다. 실제 시스템에서 여러 태스크가 설치를 요청할 수 있다면 검사와 설치 시작 사이에 상태가 바뀌지 않도록 업데이트 요청의 소유자를 하나로 정해야 한다.

if (!slot_ok(slot) || slot == running_slot ||
    !conveyor_stopped) {
    return false;
}

한눈에 보기

업데이트 단계별로 유지해야 하는 조건
단계확인할 조건실패했을 때
대상 선택정지 상태이며 현재 실행 영역이 아님설치를 시작하지 않음
지우기와 기록다른 영역의 확정 이미지를 유지함미완료 이미지를 부팅에서 제외
내용 검증길이 범위와 저장된 바이트의 CRC가 일치함준비 상태를 쓰지 않음
시험 실행확정 이미지보다 새 버전이며 시험 표시 기록에 성공함실행을 넘기지 않음
응용 확정필수 점검을 통과하고 확정 상태를 저장함재부팅 시 이전 확정 이미지 선택
복구 대상 없음실행할 검증 이미지가 없음정지 출력과 별도 복구 경로 유지

실제 하드웨어에서는 상태 바이트의 반복 기록을 지원하지 않을 수도 있다. 그 경우 각 상태 전환을 서로 다른 프로그램 단위의 추가 기록으로 표현하고, 기록 번호와 검사값으로 마지막 유효 상태를 판별하는 형식을 설계할 수 있다. 상태 기록 도중 전원이 끊겼을 때의 결과도 장치의 보장 범위 안에서 해석해야 한다. 이 예제의 바이트 기록 모델을 그대로 하드웨어 보장으로 받아들이지 않는다.

부트로더에서 응용 프로그램으로 넘어갈 때는 이미지의 실행 위치뿐 아니라 초기 스택, 인터럽트 벡터, 실행 중인 주변장치의 인계 상태도 맞아야 한다. 여기서는 그 동작을 출력으로 대체했다. 다음 장의 종합 실습에서는 업데이트 요청이 정지 상태에서만 받아들여지고, 부팅 선택이 끝나도 생산 재개 조건을 따로 확인하도록 컨베이어 전체 흐름에 연결할 수 있다.

연습 문제

  1. A는 버전 4의 확정 이미지다. B는 버전 5이며 헤더와 본문 기록이 끝났지만 상태가 0xFF다. 부팅에서 어느 이미지를 선택하는지 설명하라. B의 CRC가 맞는 경우도 포함하라.
  2. B의 버전 5를 시험 실행하면서 TRIED를 기록한 직후 전원이 끊겼다. B의 첫 명령은 실행되지 않았다. 다음 부팅의 선택과 이 정책의 장단점을 설명하라.
  3. 완성 코드에서 B의 길이 필드를 241로 바꾸는 손상 시험을 추가한다고 가정하라. image_integrity가 어느 조건에서 실패하는지 설명하고, 길이 검사보다 CRC 계산을 앞에 두면 안 되는 이유를 적어라.
  4. 본문은 바꾸지 않고 버전 필드만 수정하면 현재 CRC 검사에 어떤 영향을 주는지 설명하라. 이어서 CRC가 일치하는 이미지가 배포자의 승인을 받은 이미지인지 판단할 수 있는지 설명하라.

정답과 해설

  1. A를 선택한다. B의 0xFF는 공개되지 않은 상태이므로 준비 이미지 검색 대상이 아니다. CRC가 맞더라도 기록 절차의 마지막 단계가 완료되었다고 판단하지 않는다. 확정과 공개를 명시적인 기록으로 표현하면 중간 상태의 해석이 단순해진다.
  2. A가 여전히 유효한 확정 이미지라면 A를 선택한다. B는 TRIED이므로 새 시험 후보에서 제외한다. 실제로 실행되지 않은 이미지의 기회까지 소비한다는 단점이 있지만, 실행 여부를 확신할 수 없는 전원 차단 경계에서 같은 후보를 반복 실행하는 상황을 피한다.
  3. PAYLOAD_MAX는 240이므로 length > PAYLOAD_MAX 조건에서 false를 반환한다. CRC 함수는 호출되지 않는다. 길이는 검사할 메모리 범위를 결정하므로 먼저 검증해야 한다. CRC가 잘못된 입력을 발견하더라도, 그 계산 과정에서 범위 밖을 읽었다면 안전한 검증기가 아니다.
  4. 버전은 CRC 입력의 처음 4바이트에 포함된다. 다른 데이터를 그대로 두고 이 필드만 바꾸면 기존 CRC와 일치하지 않는다. 하지만 수정자가 새로운 CRC까지 계산해 기록할 수 있으므로 배포 승인은 증명되지 않는다. 배포 권한은 서명 검증과 키 신뢰 체계로 별도로 확인해야 한다.

댓글 0

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

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