設定データのバージョン管理

ユーザ設定や校正値をフラッシュメモリ(NORフラッシュなど)・EEPROMに保存する機能は、多くの機器に搭載されています。問題は、製品の機能追加が進むにつれ設定項目が増えることです。設定構造体をそのまま保存する素朴な設計は、ファームウェア更新で構造体を変更した瞬間に「設定が初期化された」「異常な値で動き出した」という市場トラブルを起こします。本記事では、構造体の変更に耐える保存フォーマットの定石、つまりマジックナンバー・バージョン・サイズ・CRCによるヘッダ設計と、バージョン移行の実装を解説します。

素朴な設計は何が問題か

「config_t をそのまま flash_write する」設計は、書いた時点では完璧に動きます。不整合が生じるのはファームウェア更新後です。新ファームウェアが構造体にメンバを追加・並び替え・型変更していると、保存時と読み出し時でレイアウトが一致しなくなります。その結果、フラッシュメモリに残った旧レイアウトのデータを新レイアウトとして読み、別のメンバがある位置の値を読んでしまいます。マジックナンバーもCRCも無ければ「データが書かれているか」「壊れていないか」すら判定できず、不正値のまま動き出すのが最悪のケースです。設定の保存は、書く瞬間ではなく数年後の読み出しまで含めて設計する必要があります。

ヘッダの定石:マジックナンバー・バージョン・サイズ・CRC

保存データの先頭に、次の4点を持つヘッダを付けるのが定石です。

  • マジックナンバー:そもそも有効な設定が書かれているかの目印。未書き込み(多くのNOR型フラッシュメモリでは消去後が0xFFですが、未書き込み値は媒体やドライバの仕様に依存するため、必ずデータシートやドライバ仕様で確認します)や別データとの区別に使う。
  • バージョン:本体レイアウトの版数。構造体を変更するたびに上げる。
  • サイズ:本体(ヘッダおよびCRCを含まない設定項目部)のバイト数。版ごとの読み出し範囲の確認に使える。
  • CRC:データ化けの検出。CRC32には互換性のない複数の定義があるため、多項式・初期値・入力反転(RefIn)・出力反転(RefOut)・最終XOR(XorOut)を仕様として固定しておきます(この記事の例ではCRC-32/ISO-HDLC相当を想定しており、poly=0x04C11DB7、init=0xFFFFFFFF、RefIn/RefOut=true、XorOut=0xFFFFFFFF(check=0xCBF43926)とし、対象範囲はmagicからcrc直前まで(ヘッダを含む)とします)。マイコン起動時にCRCで保存データを照合するのはROM自己診断でも使われる実績のある手法です(計算と実装の勘どころは「CRC実装時のポイント」参照)。 A面・B面を切り替えて運用する設計にする場合は、この4点に加えて世代番号(generation:更新のたびに増加させる更新カウンタ)と書き込み完了フラグ(commit)を推奨フィールドとして持たせます。読み出し時は、マジックナンバーとCRCが一致することを確認したうえで、世代番号が大きい面を採用します。

読み出しフローの実装例

※以下の例は説明のために構造体をそのまま扱っていますが、実製品ではエンディアンやパディングの影響を受けないTLV(Type-Length-Value)形式や固定バイト列で保存フォーマットを定義するのが安全です。構造体のまま扱う場合は、static_assert(offsetof(config_t, crc) == 期待値, "layout changed"); のようにレイアウトを検証してください。また、可変長データを扱う場合はCRCフィールドの位置がレイアウトによって変動するため、実際のCコードではオフセット計算が必要です。以下のコードはあくまで概念を示す擬似コードです。

#define CFG_MAGIC    0x43464731u   /* "CFG1" */
#define CFG_VERSION  3u
#define CFG_HDR_SIZE 8u            /* magic(4) + version(2) + size(2) */
#define CFG_AREA_SIZE 256u         /* 設定を保存する領域の総サイズ */
#define CFG_BODY_MAX (CFG_AREA_SIZE - CFG_HDR_SIZE - sizeof(uint32_t))
typedef struct {
    uint32_t magic;
    uint16_t version;
    uint16_t size;             /* 本体サイズ */
    /* ---- 本体:追加は末尾のみ ---- */
    uint16_t motor_speed;      /* v1から */
    uint16_t alarm_temp;       /* v1から */
    uint16_t lcd_contrast;     /* v2で追加 */
    uint16_t retry_max;        /* v3で追加 */
    /* ------------------------------ */
    uint32_t crc;              /* magic〜本体末尾(crc直前)までのCRC32 */
} config_t;
bool config_load(config_t *out)
{
    config_t raw;
    flash_read(CFG_ADDR, &raw, CFG_HDR_SIZE);   /* (1) まずヘッダだけ読む */
    config_set_default(out);               /* まず全項目をデフォルトに */
    if (raw.magic != CFG_MAGIC)  { return false; }  /* 未書き込み */
    if (raw.size > CFG_BODY_MAX) { return false; }  /* 異常なサイズは捨てる */
    if (raw.size > (sizeof(config_t) - CFG_HDR_SIZE - sizeof(uint32_t))) { return false; }  /* 現在のconfig_tが確保できる本体サイズを超える場合も捨てる(バッファオーバーフロー対策) */
    /* (2) ヘッダ+size分の本体+CRCだけを読む */
    flash_read(CFG_ADDR, &raw, CFG_HDR_SIZE + raw.size + sizeof(raw.crc));
    if (!cfg_crc_ok(&raw))       { return false; }  /* データ化け */
    if (raw.version > CFG_VERSION) { return false; } /* 未知の新版 */
    /* 古い版ほど下のcaseで、読める範囲だけ上書きする */
    switch (raw.version) {
    case 3u: out->retry_max    = raw.retry_max;    /* fallthrough */
    case 2u: out->lcd_contrast = raw.lcd_contrast; /* fallthrough */
    case 1u: out->motor_speed  = raw.motor_speed;
             out->alarm_temp   = raw.alarm_temp;
             break;
    default: return false;
    }
    return true;
}

ポイントは「まず全項目をデフォルト値で埋めてから、保存データに含まれる版の項目だけ上書きする」流れです。こうすると、v1のデータをv3のファームウェアで読んでも、v2・v3で追加された項目は安全なデフォルト値で補完され、ユーザが設定済みの項目はそのまま引き継がれます。読み込み後に新フォーマットで書き戻せば移行(マイグレーション)完了です。逆に、保存データの方が新しいとき(ダウングレード時)は安全側に倒し、デフォルトに戻すのが無難です。ただし、どうしても引き継ぎたい項目がある場合は、既知の項目だけを部分的に移行する設計も検討できます。なお、この例のsizeフィールドは読み出し範囲の上限確認に使っています。版ごとに実際どこまで読むかはバージョンによる分岐(switch文)で制御しているため、sizeと各版のレイアウトの対応はコード側の前提として管理します。

構造体側の設計ルール

  • 追加は末尾のみ:既存メンバの位置を動かさない。これがフォーマット互換の生命線です。
  • 削除は「欠番」に:メンバを消さず予約領域(reserved)として残すと、位置ずれが起きません。
  • 意味や単位の変更は新バージョンで対応:同じ位置のまま意味を変えると、障害や不具合の原因となります。バージョンを上げて移行コードで変換します。
  • レイアウトを固定する:stdint.hの固定幅型を使い、パディングが版間で揺れないようメンバの並びを整えます。他機器とデータを共有するならエンディアンも仕様に明記します。なお、末尾に追加した場合でも、追加したメンバの直前や構造体の末尾にコンパイラやABI(アプリケーションバイナリインターフェース)のパディング規則で隙間が入り、版間でsizeofが変わることがあります(既存メンバのあいだに隙間が入ることはありません)(とくにuint16_tとuint32_tが混在する場合)。保存フォーマットはpacked構造体ではなく固定バイト列で定義するのがより安全で、構造体のまま扱うなら、C11以降であればstatic_assert(それ以前の規格ではコンパイル時アサートのマクロ)でサイズと各メンバのオフセットを固定します。

運用面の設計:書き込み回数と電源断

設定保存には、フォーマット以外にあと2つの注意すべき課題があります。1つは書き込み回数の上限で、フラッシュメモリやEEPROMには消去・書き込み回数の寿命があります。値が変わったときだけ書く、こまめな自動保存をまとめる、といった配慮が必要です。もう1つは書き込み中の電源断で、対策の定石は2面持ちです。A面・B面に交互に書き、読み出し時は「①マジックナンバーが一致する ②CRCが一致する ③そのうえで世代番号(更新カウンタ)が大きい」の順に判定して、採用する面を決めます。世代番号は単調増加させ(型のオーバーフローやラップアラウンドへの対処が必要かどうかも設計時に確認します)、書き込み完了を示すフラグ(コミット)を最後に書くようにすると、途中で電源が落ちた面を確実に捨てられます。なお工場出荷値はフラッシュメモリのデータに頼らず、必ずコード側(config_set_default)に持たせる必要があります。保存領域が全滅しても機器が安全に立ち上がれることが最後の砦になります。

まとめ

設定保存の設計は「マジックナンバー・バージョン・サイズ・CRCのヘッダ」「デフォルトで埋めてから版の分だけ上書き」「構造体は末尾追加のみ」の3点が骨子です。最初の製品出荷前にこの形にしておけば、その後何年経っても、構造体にメンバが追加されても、ユーザの設定を壊さずにファームウェアを更新し続けられます。

組み込みソフトの世界 トップへ戻る