hk816

情報技術、投資に役立つ情報を発信します。「長期・分散・低コスト」の投資の普及活動をしています。余剰資金で若手研究者を支援しています。

【検査】SSDのデータロストを防ぐ!GC(ガベージコレクション)のテスト設計

🚀 GC(ガベージコレクション)のテスト設計

SSDの寿命と性能を決定づける「ガベージコレクション(GC)」。 しかし、裏を返せばGCとは「ユーザーのデータを読み出し、別の場所に書き込み、元のデータを物理的に消去する」という、一歩間違えれば致命的なデータロストを引き起こす最も危険な処理でもあります。

もし、データの引っ越し中にNANDの読み出しエラーが起きたら? 新しい場所への書き込みが失敗したのに、元のブロックを「消去(Erase)」してしまったら? ユーザーのデータは宇宙のチリとなり、製品はリコール(全量回収)の憂き目に遭います。

今回は、「ESTR 組込みソフトウェア開発における品質向上の勧め[テスト編]」に準拠し、前回のGC実装に対し、モック(ダミー関数)を用いてハードウェアエラーを意図的に注入し、「いかなる異常時でもデータを消去しない(フェイルセーフ)」ことを証明するUnity単体テストコードを紹介します!


💡 組み込みテスト設計のポイント

ESTR事例集が提唱する「アルゴリズムの妥当性」と「異常発生時のフェイルソフト(安全側への倒れ込み)」を、GC特有の要件に適用します。

1. 境界値分析とGreedyアルゴリズムの証明(Algorithm Verification)

GC対象(Victim)を選定する際、最も「ゴミが多い(Validページが少ない)」ブロックが正確に選ばれるかをテストします。すべてのブロックが「OPEN(書き込み中)」で対象外のケースや、Validページが「0(全てゴミ)」のブロックを発見した際に探索を打ち切る(最適化)ロジックが正しく働いているかを同値分割で網羅します。

2. データフローとアトミック性の検証(Data Flow Testing)

データの退避が「NANDからのRead → 新ブロックへのWrite → L2Pの更新」という絶対に間違えてはならない順序(アトミックなトランザクション)で行われ、全てが完了して初めて元のブロックがEraseされることを、コールカウントを用いて証明します。

3. フォールトインジェクション(異常注入)とフェイルセーフ

ESTRが最も重視する検証です。データの退避中、NANDフラッシュのハードウェア異常(ReadエラーやWriteエラー)をモックでシミュレートします。 この時、FWがパニックを起こさず、データの移動を即座に中断し、「元のブロックの消去(Erase)を絶対に実行しない(データを死守する)」というフェイルセーフ機構が完璧に動作することを証明します。


💻 テストコード

#include "unity.h"
#include <stdint.h>
#include <stdbool.h>

/* テスト対象のCファイルを直接インクルード(ホワイトボックステスト用) */
#include "ftl_gc.c"

/* --- モック用制御・状態記録変数 --- */
static uint32_t g_mock_valid_page_target = 0U; /* このページだけValidとする */

static bool     g_mock_read_success = true;
static uint32_t g_call_read_nand = 0U;

static bool     g_mock_write_success = true;
static uint32_t g_call_write_nand = 0U;

static uint32_t g_call_update_l2p = 0U;

static bool     g_mock_erase_success = true;
static uint32_t g_call_erase_block = 0U;

/* --- 外部HAL/FW関数のダミー実装 (モック) --- */
bool FTL_IsPageValid(uint32_t pba_page) {
    /* テスト用: 指定した1ページだけを有効(Valid)データとして扱う */
    return (pba_page == g_mock_valid_page_target);
}

bool HAL_ReadNandPage(uint32_t pba_page, uint8_t* p_buffer) {
    g_call_read_nand++;
    return g_mock_read_success;
}

uint32_t FTL_WriteToOpenBlock(const uint8_t* p_buffer) {
    g_call_write_nand++;
    return g_mock_write_success ? 9999U : FTL_INVALID_BLOCK_ID; /* 成功時は適当なPBAを返す */
}

uint32_t FTL_GetLbaFromPba(uint32_t pba_page) {
    return pba_page * 10U; /* ダミーLBA */
}

void FTL_UpdateL2pMapping(uint32_t lba, uint32_t new_pba_page) {
    g_call_update_l2p++;
}

bool HAL_EraseBlock(uint32_t pba_block) {
    g_call_erase_block++;
    return g_mock_erase_success;
}

/* --- テストのセットアップ --- */
void setUp(void) {
    /* モック変数の初期化 */
    g_mock_valid_page_target = 0U;
    g_mock_read_success = true;
    g_mock_write_success = true;
    g_mock_erase_success = true;
    
    g_call_read_nand = 0U;
    g_call_write_nand = 0U;
    g_call_update_l2p = 0U;
    g_call_erase_block = 0U;
    
    /* ブロックメタデータの初期化 */
    for (uint32_t i = 0U; i < FTL_TOTAL_BLOCKS; i++) {
        s_block_metadata[i].state = BLOCK_STATE_FREE;
        s_block_metadata[i].valid_page_count = 0U;
    }
}
void tearDown(void) {}

/* =========================================================
 *  GC_SelectVictimBlock のテストケース
 * ========================================================= */

/* 1. 正常系(アルゴリズム検証): 最もValidページが少ない(ゴミが多い)ブロックを選定する */
void test_GC_SelectVictimBlock_ShouldReturnBlockWithMinValidPages(void) {
    /* ブロック10: 100ページ有効 (GC候補) */
    s_block_metadata[10].state = BLOCK_STATE_CLOSED;
    s_block_metadata[10].valid_page_count = 100U;
    
    /* ブロック20: 5ページ有効 (最適候補: 引っ越しコストが最も低い) */
    s_block_metadata[20].state = BLOCK_STATE_CLOSED;
    s_block_metadata[20].valid_page_count = 5U;
    
    /* ブロック30: 2ページ有効だが書き込み中(OPEN)のため除外されるべき */
    s_block_metadata[30].state = BLOCK_STATE_OPEN;
    s_block_metadata[30].valid_page_count = 2U;
    
    uint32_t victim = GC_SelectVictimBlock();
    
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(20U, victim, "CLOSED状態で最もValidページが少ないブロック(20)が選ばれること");
}

/* =========================================================
 *  GC_ExecuteSingleBlock のテストケース (ESTR準拠)
 * ========================================================= */

/* 2. 正常系(データフロー): データの退避と消去(Erase)のアトミックな完了 */
void test_GC_ExecuteSingleBlock_Success_ShouldMoveDataAndErase(void) {
    uint32_t target_block = 5U;
    s_block_metadata[target_block].state = BLOCK_STATE_CLOSED;
    s_block_metadata[target_block].valid_page_count = 1U;
    
    /* ブロック5の先頭ページだけをValidに設定 */
    g_mock_valid_page_target = target_block * FTL_PAGES_PER_BLOCK;
    
    bool result = GC_ExecuteSingleBlock();
    
    TEST_ASSERT_TRUE_MESSAGE(result, "正常にデータ退避と消去が完了した場合はtrueを返すこと");
    
    /* アトミックなデータ移動(トランザクション)の証明 */
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(1U, g_call_read_nand, "NANDからの読み出しが行われること");
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(1U, g_call_write_nand, "新ブロックへの書き込みが行われること");
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(1U, g_call_update_l2p, "書き込み完了後にL2Pが更新されること");
    
    /* 消去とメタデータ更新の証明 */
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(1U, g_call_erase_block, "退避が完了した古いブロックが消去(Erase)されること");
    TEST_ASSERT_EQUAL_MESSAGE(BLOCK_STATE_FREE, s_block_metadata[target_block].state, "ブロックが空き(FREE)状態としてシステムに返却されること");
}

/* 3. 異常系(フォールトインジェクション): 退避中のReadエラーに対するフェイルセーフ(データ保護) */
void test_GC_ExecuteSingleBlock_ReadError_ShouldAbortAndNotErase(void) {
    uint32_t target_block = 5U;
    s_block_metadata[target_block].state = BLOCK_STATE_CLOSED;
    s_block_metadata[target_block].valid_page_count = 1U;
    g_mock_valid_page_target = target_block * FTL_PAGES_PER_BLOCK;
    
    /* 【異常注入】NANDからのデータ読み出し(Read)がハードウェアエラーで失敗する */
    g_mock_read_success = false;
    
    bool result = GC_ExecuteSingleBlock();
    
    TEST_ASSERT_FALSE_MESSAGE(result, "エラー発生時はGC失敗(false)を返すこと");
    
    /* 【ESTR フェイルセーフの証明】 */
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(1U, g_call_read_nand, "読み出しは試行されるが...");
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(0U, g_call_write_nand, "エラー時は新しい場所へ書き込まないこと(キメラデータ防止)");
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(0U, g_call_update_l2p, "エラー時はL2Pを更新しないこと(ポインタロスト防止)");
    
    /* 【最重要検証】データロストの防止 */
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(0U, g_call_erase_block, "【フェイルセーフ】退避に失敗した場合は、絶対に元のブロックを消去(Erase)してはならない");
    TEST_ASSERT_EQUAL_MESSAGE(BLOCK_STATE_CLOSED, s_block_metadata[target_block].state, "ブロック状態はCLOSEDのままで維持され、上位層のデータ復旧に委ねること");
}

🎯 組み込みエンジニアとして得られる知見

組み込みエンジニアとしての評価の分かれ目は「最悪の事態(ハードウェアエラー)を想定し、それをテストで証明できるか」にあります。

  1. フェイルセーフ(データ死守)のテストによる保証 テストケース3(ReadError_ShouldAbortAndNotErase)は、ストレージ開発の真髄です。NANDフラッシュは劣化によって必ず「読み出しエラー(UNC)」を起こします。もしGC中にこのエラーを引き当てた際、そのまま処理を進めて元のブロックをErase(消去)してしまえば、データは永遠に失われます。 「g_call_erase_block が 0U であること」をアサーション(TEST_ASSERT)に組み込むことで、「異常時には絶対にデータを消さない(フェイルセーフ)」という設計思想を、コードとして確固たる証拠にしている点がQA・評価部門から信頼を得られます。
  2. 「Greedyアルゴリズム」のコーナーケース検証 アルゴリズム検証(テストケース1)において、「BLOCK_STATE_OPEN(書き込み中のブロック)」をテストデータに含め、それが正しく除外されることを確認しています。GCは「書き込みが終わった(CLOSED)ブロック」のみを対象にしなければならず、ここをミスすると「今まさにホストから送られてきたデータ」をGCが横取りして破壊してしまうため、このドメイン知識に基づく境界値テストは高く評価されます。

【実装】SSDの寿命と速度を支配する!「ガベージコレクション(GC)」の実装

🚀 ガベージコレクション(GC)の実装

NANDフラッシュメモリの宿命である「上書き不可」。これを克服するためにSSDの裏側で絶え間なく走り続けるのがガベージコレクション(GC)です。 GCのアルゴリズムが少しでも非効率であれば、SSDは無駄な「引っ越し作業」にリソースを奪われ、速度は激減し、あっという間に書き込み寿命(TBW)を食いつぶしてしまいます。

今回は、前回のアーキテクチャ設計に基づき、「最もゴミの多いブロックを狙い撃ちにするGreedy(貪欲)アルゴリズム」と、「データの退避から消去までのアトミックな移動フロー」を、「組み込みソフトウェア向けコーディング作法ガイド(ESCR Ver. 3.0)」に厳格に準拠して実装します。


💻 実装コード

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

/* [ESCR] マジックナンバーの排除: 'U' サフィックスを使用して符号なし定数を明示 */
#define FTL_TOTAL_BLOCKS            (1024U) /* デモ用の総物理ブロック数 */
#define FTL_PAGES_PER_BLOCK         (256U)  /* 1ブロックあたりのページ数 */
#define FTL_INVALID_BLOCK_ID        (0xFFFFFFFFU)

/* ブロックの状態定義 */
#define BLOCK_STATE_FREE            (0U) /* 空きブロック (消去済み) */
#define BLOCK_STATE_OPEN            (1U) /* 書き込み中 (GC対象外) */
#define BLOCK_STATE_CLOSED          (2U) /* 全ページ書き込み完了 (GC対象候補) */
#define BLOCK_STATE_BAD             (3U) /* 故障ブロック (使用不可) */

/* NAND物理ブロックの管理メタデータ */
typedef struct {
    uint16_t valid_page_count; /* ブロック内の有効データ(Valid Page)数 */
    uint8_t  state;            /* 現在のブロック状態 */
} BlockInfo_t;

/* [ESCR] 情報隠蔽: 外部モジュールから直接見えないよう static 宣言 */
static BlockInfo_t s_block_metadata[FTL_TOTAL_BLOCKS];

/* --- 外部モジュール(HAL/FTL)のモック宣言 --- */
/* 指定ページのデータが有効(Valid)かを判定する */
extern bool FTL_IsPageValid(uint32_t pba_page);
/* NANDから1ページ分のデータをSRAM/DRAMへ読み込む */
extern bool HAL_ReadNandPage(uint32_t pba_page, uint8_t* p_buffer);
/* 現在書き込み用に開いているOpenブロックへデータを書き込み、新しい物理アドレスを返す */
extern uint32_t FTL_WriteToOpenBlock(const uint8_t* p_buffer);
/* L2Pテーブルを新しい物理アドレスへ更新する */
extern void FTL_UpdateL2pMapping(uint32_t lba, uint32_t new_pba_page);
/* 指定された論理アドレス(LBA)の逆引き(物理から論理を取得する) ※OOB領域等から取得 */
extern uint32_t FTL_GetLbaFromPba(uint32_t pba_page);
/* ブロック全体を物理消去(Erase)する */
extern bool HAL_EraseBlock(uint32_t pba_block);

/**
 * @brief Greedyアルゴリズムに基づき、最も「有効データが少ない(ゴミが多い)」ブロックをGC対象に選定する
 * 
 * @return 選定されたブロックID。見つからない場合は FTL_INVALID_BLOCK_ID
 */
static uint32_t GC_SelectVictimBlock(void) {
    uint32_t victim_block = FTL_INVALID_BLOCK_ID;
    uint16_t min_valid_pages = FTL_PAGES_PER_BLOCK + 1U; /* 最小値探しの初期値(あり得ない最大値) */

    for (uint32_t i = 0U; i < FTL_TOTAL_BLOCKS; i++) {
        /* GC対象は「書き込みが完了したブロック(CLOSED)」のみ */
        if (s_block_metadata[i].state == BLOCK_STATE_CLOSED) {
            
            /* 有効ページ数が最も少ない(引越しコストが低い)ブロックを探す */
            if (s_block_metadata[i].valid_page_count < min_valid_pages) {
                min_valid_pages = s_block_metadata[i].valid_page_count;
                victim_block = i;
                
                /* [最適化] 有効データが0(全部ゴミ)なら、これ以上の探索は不要 */
                if (min_valid_pages == 0U) {
                    break;
                }
            }
        }
    }
    return victim_block;
}

/**
 * @brief ガベージコレクション(GC)を1ブロック分実行する
 * 
 * @return true: GC成功(空きブロックが1つ増えた), false: GC失敗または対象なし
 */
bool GC_ExecuteSingleBlock(void) {
    /* [ESCR] 単一出口(Single Exit)の原則 */
    bool b_is_success = false;
    uint8_t page_buffer[4096]; /* 1ページ分の退避バッファ (実際はDRAM上のDMAバッファ等を使用) */
    
    /* 1. Victim(犠牲)ブロックの選定 */
    uint32_t victim_block = GC_SelectVictimBlock();
    
    if (victim_block != FTL_INVALID_BLOCK_ID) {
        bool b_move_error = false;
        uint32_t start_page = victim_block * FTL_PAGES_PER_BLOCK;
        
        /* 2. Victimブロック内の全ページをスキャンし、有効データを退避する */
        for (uint32_t offset = 0U; offset < FTL_PAGES_PER_BLOCK; offset++) {
            uint32_t current_page = start_page + offset;
            
            if (FTL_IsPageValid(current_page) == true) {
                
                /* [アトミックなデータ移動フロー] */
                if (HAL_ReadNandPage(current_page, page_buffer) == true) {
                    
                    uint32_t new_pba = FTL_WriteToOpenBlock(page_buffer);
                    if (new_pba != FTL_INVALID_BLOCK_ID) {
                        /* 引っ越しが完了したらL2Pテーブルを更新し、旧データを無効化する */
                        uint32_t target_lba = FTL_GetLbaFromPba(current_page);
                        FTL_UpdateL2pMapping(target_lba, new_pba);
                    } else {
                        b_move_error = true; /* 新規書き込み失敗 */
                        break;
                    }
                } else {
                    b_move_error = true; /* 読み出し失敗 (UNCエラー等) */
                    break;
                }
            }
        }
        
        /* 3. 全有効データの退避がエラーなく完了した場合のみ、元のブロックを消去する */
        if (b_move_error == false) {
            if (HAL_EraseBlock(victim_block) == true) {
                
                /* メタデータを更新し、空きブロック(FREE)としてシステムに返却 */
                s_block_metadata[victim_block].state = BLOCK_STATE_FREE;
                s_block_metadata[victim_block].valid_page_count = 0U;
                
                b_is_success = true;
            }
        }
    }
    
    return b_is_success;
}

💡 組み込みエンジニア視点での設計のポイント

GCアルゴリズムの真髄は以下の3点です。

1. 寿命(WAF)を最適化する「Greedyアルゴリズム」

GC_SelectVictimBlock() 関数は、配列を走査して valid_page_count が最も小さいブロックを探します。 もし有効データが200ページあるブロックをGCしてしまうと、空きを作るために「200回」もNANDへの退避書き込みが発生し、SSDの寿命(TBW)を無駄に削ってしまいます(Write Amplification)。 逆に、有効データが0(全ページがInvalidなゴミ)のブロックを見つければ、ループ内の break; で即座に探索を打ち切り、データを1バイトも退避することなく HAL_EraseBlock だけで空きブロックを丸々1つ回収できます。この「引っ越しコストの最小化」こそが、SSDの性能と寿命を維持する要です。

2. トランザクション(アトミック性)を意識したデータ退避

GC_ExecuteSingleBlock() 内のループ処理順序に注目してください。

  1. HAL_ReadNandPage (読む)
  2. FTL_WriteToOpenBlock (新ブロックへ書く)
  3. FTL_UpdateL2pMapping (L2Pを切り替える)

この順番は絶対です。もし新しい場所に書き終わる前にL2Pを更新してしまうと、その瞬間に電源断(SPOR)が起きた場合、データが迷子になって修復不可能になります。 「新しい場所にデータが完全に定着したことを確認してから、初めてポインタ(L2P)を切り替える」というデータベースのコミットと同じアトミックな設計が、ストレージの堅牢性を担保します。

3. 消去(Erase)前の安全装置(フェイルソフト)

ループを抜けた後の if (b_move_error == false) の判定が、データの消失を防ぐ最後の砦です。 退避中にNANDの読み出しエラー(UNC)や書き込みエラー(Program Fail)が1ページでも発生した場合、b_move_error が true となり、元のブロックの消去(HAL_EraseBlock)を絶対にスキップします。 もしエラーを無視してブロックを消去してしまうと、退避に失敗したユーザーデータが永久に失われてしまいます。異常時は元のデータを死守し、上位のリカバリ機能(RAIDやエラーハンドリング)へ委ねる「フェイルソフト」がESCR/ESTRの基本思想です。

【設計】SSDの速度と寿命を支配する裏の主役!「ガベージコレクション(GC)」アーキテクチャ設計

🚀 ガベージコレクション(GC)アーキテクチャ設計

NANDフラッシュメモリには「上書きができない」という致命的な宿命があります。 データを更新する際、SSDは古いデータを直接上書きせず、常に「新しい空きページ」にデータを書き込みます(アウトオブプレイス・アップデート)。これを繰り返すと、SSDの内部は「最新のデータ(Valid)」と「OSからは消されたゴミデータ(Invalid)」が混在したスイスチーズのような状態になります。

フラッシュメモリの消去(Erase)は、ページ単位ではなく巨大な「ブロック単位」でしか行えません。 空きブロックが枯渇した際、ゴミが混ざったブロックから「有効なデータだけを別のブロックへ引っ越し(退避)させ、元のブロックを丸ごと消去して空きブロックを取り戻す」という極めて重い処理が必要になります。これが「ガベージコレクション(GC)」です。

GCのアルゴリズムが稚拙だと、SSDは「引っ越し作業」ばかりに忙殺され、寿命(TBW)は激減し、ランダムライト性能はHDD並みに落ち込みます(Write Amplification)。

本記事では、「組込みソフトウェア開発向け設計ガイド(ESDR [事例編])」に準拠し、SSDの真の性能を決定づけるGC機能のアーキテクチャ設計を紹介します!


1. 🏗️ 構造設計(コンポーネント図と責務定義)

ESDRにおける「関心事の分離」に基づき、GCのトリガー判定、対象ブロックの選定、データ移動、そして消去処理を疎結合なコンポーネントに分割します。

モジュール名 役割(ESDRに基づく責務定義)
GC Manager GC全体の統制。空きブロック残量(Watermark)を監視し、アイドル時のバックグラウンドGC(BGC)や、枯渇時のフォアグラウンドGC(FGC)をトリガーする。
Victim Selector 全ブロックのメタデータをスキャンし、最も「引っ越しコストが低い(ゴミが多い)」ブロックをGCの犠牲(Victim)として選定する。
Data Relocator Victimブロック内に残っている「有効データ(Valid Page)」を読み出し、現在書き込み用の新しい空きブロックへコピー(移動)する。
L2P / FTL Mgr Data Relocatorによるデータの引っ越しに合わせて、論理アドレス(LBA)が指し示す物理アドレス(PBA)を新しい場所へ更新する。
Block Eraser 引っ越しが完了し、100%ゴミだけになったVictimブロックに対し、NANDの物理Erase(消去)コマンドを発行。空きブロックプール(Free Pool)へ返却する。

2. 🔄 振る舞い設計(GCのアクティビティ図)

空きブロックが枯渇し始めた際、どのようにデータを退避し、ブロックを再生するかのシーケンス設計です。ここでは、データの移動とL2Pの更新を「アトミック(不可分)」に行うことがデータの整合性を守る鍵となります。

[開始] GC Manager が空きブロックの枯渇(Watermark割れ)を検知し、GCを起動
  │
  ▼
[処理] Victim Selector がメタデータを検索し、「最もValidページが少ないブロック」を選定
  │    (例: 1ブロック256ページ中、Validが10ページ、Invalid(ゴミ)が246ページのブロック)
  ▼
[ループ] Victimブロック内の有効(Valid)ページを1つずつ引っ越し
  │
  ├─▶ [処理] Data Relocator が有効データをNANDからSRAM/DRAMへRead
  │
  ├─▶ [処理] Data Relocator が新しい書き込み用ブロックへデータをProgram(Write)
  │
  ├─▶ [処理] FTL Mgr がL2Pテーブルを「新しい物理アドレス」へ更新
  │
  └─▶ (Victimブロック内の全Validページを退避し終わるまで繰り返す)
  │
  ▼
[処理] Victimブロック内の全データが「無効(Invalid)」になる
  │
  ▼
[処理] Block Eraser が対象ブロックに NAND Erase コマンドを発行 (数ミリ秒かかる重い処理)
  │
  ▼
[処理] 消去されたクリーンなブロックを Free Block Pool へ追加 (空きブロック回復)
  │
  ▼
[終了] GC Manager がWatermarkを確認し、十分回復していればGC停止

3. 📦 データ設計(ブロック管理メタデータ構造)

GCを高速かつ低オーバーヘッドで動作させるためには、「どのブロックにどれくらいゴミがあるか」をDRAM上で瞬時に検索できるメタデータ設計が不可欠です。

/* NAND物理ブロックごとの管理メタデータ (DRAM上に配置) */
typedef struct {
    uint16_t valid_page_count;   /* このブロックに存在する有効データの数 (GCコストの指標) */
    uint32_t erase_count;        /* このブロックが消去された回数 (ウェアレベリング/寿命管理用) */
    
    /* ブロックの状態フラグ */
    #define BLOCK_STATE_FREE     (0U) /* 完全に空 (書き込み可能) */
    #define BLOCK_STATE_OPEN     (1U) /* 現在書き込み中 (Active) */
    #define BLOCK_STATE_CLOSED   (2U) /* 全ページ書き込み完了 (GC対象候補) */
    #define BLOCK_STATE_BAD      (3U) /* 故障ブロック (GBB) */
    uint8_t  state;
    
    /* どのブロックからGCするかを高速に探すための双方向リストポインタ等 */
    struct BlockInfoNode* p_next;
    struct BlockInfoNode* p_prev;
} BlockInfo_t;

/* GC管理ルート構造体 */
typedef struct {
    uint32_t total_free_blocks;  /* 現在の空きブロック総数 */
    uint32_t gc_threshold;       /* GC発動のWatermark */
    /* Validページ数ごとにブロックをリスト化し、O(1)でVictimを探す構造などが用いられる */
} GcManager_t;

4. 🧠 組込み設計の勘所

エンタープライズSSDの開発において、GCのアルゴリズムは各社のトップシークレットであり、最も技術力が問われる領域です。

① WAF(Write Amplification Factor)の極限抑制

もし valid_page_count が250のブロック(ほぼ有効データ)をGCしてしまうと、「ゴミ6ページ分」を回収するために「250ページ分の引っ越し(無駄な書き込み)」が発生します。これが寿命を削る元凶である Write Amplification (WA) です。 Victim Selector は、常に「Validデータが最も少ない(=ゴミが最も多い)」ブロックを狙い撃ちにするGreedy(貪欲)アルゴリズムを採用します。これにより、わずかな引っ越しの手間で巨大な空き領域を回収でき、SSDの寿命(TBW)を劇的に延ばすことができます。

② BGC(バックグラウンドGC)によるレイテンシの隠蔽

ホストから大量の書き込み要求が来て空きブロックが底をつき、その場でGC(FGC: フォアグラウンドGC)を回し始めると、ホストのWrite要求はGCが終わるまで待たされ、レイテンシが数十ミリ秒に跳ね上がります(いわゆる「プチフリ」)。 これを防ぐため、ESDRに基づく設計では、SSDが暇な時間(アイドル状態)を検知し、裏側でこっそりGCを回して空きブロックを貯金しておくバックグラウンドGC(BGC)を実装します。ユーザーにGCの存在を気づかせない「非同期処理のスケジューリング」が、最高峰のファームウェア設計です。

③ ウェアレベリング(Wear Leveling)との協調

Greedyアルゴリズムに従うと、「OSのシステムファイル」のように全く更新されないデータ(Cold Data)が入ったブロックは、いつまで経ってもGCされません。すると、特定のブロックだけが何度も消去されて寿命を迎え、SSD全体が壊れてしまいます。 そのため、GCは単にゴミを回収するだけでなく、時折「消去回数(erase_count)が少ないCold Dataブロック」を意図的にGCの対象(Victim)に選び、データを他のブロックへ強制的に引っ越しさせます。これにより、NANDフラッシュ全域の劣化具合を均一にならす(Wear Leveling)という、ストレージ特有の長寿命化設計がGCと密接に連携して動いています。

【検査】ホストの裏切りを検知!SSD「HMB(Host Memory Buffer)」のゼロトラスト・テスト設計

🚀 HMB(Host Memory Buffer)のゼロトラスト・テスト設計

DRAMレスSSDを爆速にする魔法の機能「HMB(Host Memory Buffer)」。 しかし、組み込みソフトウェアエンジニアにとって、HMBは「いつOSに取り上げられるか分からない」「マルウェアに改ざんされるかもしれない」という、全く信用できない他人の土地(アントラステッド領域)です。

もし、ホストメモリ上のL2Pテーブルが化けていた時に、それを信じてNANDに書き込んでしまえば、ユーザーのデータは一瞬で破壊されます。

今回は、「ESTR 組込みソフトウェア開発における品質向上の勧め[テスト編]」に準拠し、モックを用いて「SRAM → HMB → NAND」の多段キャッシュ制御と、「HMB改ざん検知からの安全なリカバリ(フェイルバック)」を証明するUnity単体テストコードを紹介します!


💡 組み込みテスト設計のポイント

ESTR事例集が提唱する「網羅的なデータフロー検証とフェイルソフトの証明」を、HMBの階層キャッシュ要件に落とし込みます。

1. 同値分割によるキャッシュ階層の網羅(Cache Hierarchy Testing)

「SRAMヒット」「HMBヒット」「完全ミス(NAND読み込み)」という3つの代表的な状態(同値クラス)をシミュレートし、最も低コストな経路が選択され、不要なハードウェアアクセス(NANDやPCIe DMA)が発生しないことをコールカウントで証明します。

2. データフローと「キャッシュ昇格」の検証

下位層(HMBやNAND)からL2Pデータをロードした際、必ず上位層(SRAMやHMB)へデータを書き戻す(キャッシュする)フローが走ることを、モック関数(Store/Write)の呼び出し順序と回数で厳密に検証します。これにより、空間的局所性を活かしたパフォーマンス向上のロジックが正常に働くことを保証します。

3. フォールトインジェクションとフェイルソフト(Zero-Trust Verification)

ESTRが最重要視するアプローチです。HMBから読み出したL2Pデータに対し、意図的に「CRC(チェックサム)エラー」を注入します。 この時、システムが不正なアドレスを信じて暴走するのではなく、「HMBのデータを即座に破棄し、安全なNANDから読み直す(フェイルバック)」という、ゼロトラストアーキテクチャの真髄(フェイルソフト)が機能することをテストで証明します。


💻 テストコード

#include "unity.h"
#include <stdint.h>
#include <stdbool.h>
#include <string.h>

/* テスト対象のCファイルを直接インクルード(ホワイトボックステスト用) */
#include "ftl_hmb_cache.c"

/* --- モック用制御・状態記録変数 --- */
static bool g_mock_sram_hit = false;
static bool g_mock_hmb_hit = false;
static bool g_mock_nand_hit = false;

/* CRC改ざんシミュレート用フラグ */
static bool g_mock_hmb_crc_corrupted = false;

/* コールカウント */
static uint32_t g_call_sram_find = 0U;
static uint32_t g_call_sram_store = 0U;
static uint32_t g_call_hmb_read = 0U;
static uint32_t g_call_hmb_write = 0U;
static uint32_t g_call_nand_read = 0U;
static uint32_t g_call_crc_calc = 0U;

/* テスト用のダミー物理アドレス */
#define MOCK_PBA_SRAM  (1000U)
#define MOCK_PBA_HMB   (2000U)
#define MOCK_PBA_NAND  (3000U)

/* --- 外部HAL/FW関数のダミー実装 (モック) --- */
uint32_t UTIL_CalculateCrc32(const uint32_t* p_data, uint32_t length) {
    g_call_crc_calc++;
    return 0xCAFEBABE; /* テスト用の固定CRC値 */
}

bool SRAM_FindL2pSegment(uint32_t lba, L2pSegment_t* p_out_seg) {
    g_call_sram_find++;
    if (g_mock_sram_hit) {
        memset(p_out_seg, 0, sizeof(L2pSegment_t));
        p_out_seg->pba_entries[lba % FTL_L2P_SEGMENT_ENTRIES] = MOCK_PBA_SRAM;
        return true;
    }
    return false;
}

void SRAM_StoreL2pSegment(const L2pSegment_t* p_seg) {
    g_call_sram_store++;
}

bool HMB_DmaReadSegment(uint32_t lba, L2pSegment_t* p_out_seg) {
    g_call_hmb_read++;
    if (g_mock_hmb_hit) {
        memset(p_out_seg, 0, sizeof(L2pSegment_t));
        p_out_seg->pba_entries[lba % FTL_L2P_SEGMENT_ENTRIES] = MOCK_PBA_HMB;
        
        /* 異常注入: CRCエラーを起こすかどうか */
        p_out_seg->crc32 = g_mock_hmb_crc_corrupted ? 0xDEADBEEF : 0xCAFEBABE;
        return true;
    }
    return false;
}

bool HMB_DmaWriteSegment(const L2pSegment_t* p_seg) {
    g_call_hmb_write++;
    return true;
}

bool NAND_ReadL2pSegment(uint32_t lba, L2pSegment_t* p_out_seg) {
    g_call_nand_read++;
    if (g_mock_nand_hit) {
        memset(p_out_seg, 0, sizeof(L2pSegment_t));
        p_out_seg->pba_entries[lba % FTL_L2P_SEGMENT_ENTRIES] = MOCK_PBA_NAND;
        return true;
    }
    return false;
}

/* --- テストのセットアップ --- */
void setUp(void) {
    g_mock_sram_hit = false;
    g_mock_hmb_hit = false;
    g_mock_nand_hit = false;
    g_mock_hmb_crc_corrupted = false;
    
    g_call_sram_find = 0U;
    g_call_sram_store = 0U;
    g_call_hmb_read = 0U;
    g_call_hmb_write = 0U;
    g_call_nand_read = 0U;
    g_call_crc_calc = 0U;
}
void tearDown(void) {}

/* =========================================================
 *  FTL_GetPhysicalAddress のテストケース (ESTR準拠)
 * ========================================================= */

/* 1. 正常系(最高速): 内部SRAMでキャッシュヒットした場合 */
void test_FTL_GetPhysicalAddress_SramHit_ShouldReturnFastest(void) {
    g_mock_sram_hit = true;
    
    uint32_t pba = FTL_GetPhysicalAddress(10U);
    
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(MOCK_PBA_SRAM, pba, "SRAM上のPBAを正しく返すこと");
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(1U, g_call_sram_find, "SRAM検索が行われること");
    
    /* 余計なハードウェアアクセスが発生していないことの証明 */
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(0U, g_call_hmb_read, "SRAMヒット時はHMBへのPCIeアクセスを行わないこと");
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(0U, g_call_nand_read, "NANDへのアクセスを行わないこと");
}

/* 2. 正常系(データフロー): HMBでヒットし、SRAMへ昇格(キャッシュ)される場合 */
void test_FTL_GetPhysicalAddress_HmbHit_ValidCrc_ShouldPromoteToSram(void) {
    g_mock_sram_hit = false;
    g_mock_hmb_hit = true;
    g_mock_hmb_crc_corrupted = false; /* CRC正常 */
    
    uint32_t pba = FTL_GetPhysicalAddress(10U);
    
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(MOCK_PBA_HMB, pba, "HMBから取得したPBAを返すこと");
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(1U, g_call_hmb_read, "HMBからの読み出しが行われること");
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(0U, g_call_nand_read, "NANDへのアクセスを行わないこと");
    
    /* データフロー(キャッシュの昇格)の証明 */
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(1U, g_call_sram_store, "HMBから読み出したデータは、次回の高速化のためにSRAMへ保存されること");
}

/* 3. 異常系(フォールトインジェクション): HMBのデータ改ざん(CRCエラー)を検知し、NANDからリカバリする(フェイルソフト) */
void test_FTL_GetPhysicalAddress_HmbCorrupted_ShouldFallbackToNand(void) {
    g_mock_sram_hit = false;
    g_mock_hmb_hit = true;
    g_mock_nand_hit = true;
    
    /* 【異常注入】ホストメモリ上のデータが改ざんされた状態をシミュレート */
    g_mock_hmb_crc_corrupted = true; 
    
    uint32_t pba = FTL_GetPhysicalAddress(10U);
    
    /* 【フェイルソフトの証明】 */
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(MOCK_PBA_NAND, pba, "改ざんされたHMBのデータは破棄し、安全なNANDから読み直したPBAを返すこと");
    
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(1U, g_call_hmb_read, "HMBの読み出しは行われるが...");
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(1U, g_call_nand_read, "CRCエラーを検知し、フォールバック(NAND Read)が確実に実行されること");
    
    /* リカバリ後のキャッシュ再構築の証明 */
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(1U, g_call_hmb_write, "NANDから読み直した正常なデータで、壊れたHMBを上書き修復(キャッシュ)すること");
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(1U, g_call_sram_store, "SRAMへのキャッシュも同時に行われること");
}

/* 4. 正常系(完全キャッシュミス): NANDから読み出し、全階層へキャッシュ(昇格)する場合 */
void test_FTL_GetPhysicalAddress_AllMiss_ShouldReadNandAndCacheEverywhere(void) {
    g_mock_sram_hit = false;
    g_mock_hmb_hit = false;
    g_mock_nand_hit = true;
    
    uint32_t pba = FTL_GetPhysicalAddress(10U);
    
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(MOCK_PBA_NAND, pba, "NANDから取得したPBAを返すこと");
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(1U, g_call_nand_read, "最終手段としてNANDアクセスが行われること");
    
    /* 多段キャッシュ構築の証明 */
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(1U, g_call_hmb_write, "取得したデータをHMB(L2キャッシュ)へ保存すること");
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(1U, g_call_sram_store, "取得したデータをSRAM(L1キャッシュ)へ保存すること");
}

🎯 組み込みエンジニアとして得られる知見

DRAMレスSSDの心臓部であるHMBのテストコードは強力なアピール材料になります。

  1. 「ゼロトラストアーキテクチャ」のテストによる証明 テストケース3(HmbCorrupted_ShouldFallbackToNand)は、セキュリティと可用性の両立を示す例です。「ホストメモリの改ざん(CRCエラー)」という極限状態をモックで意図的に引き起こし、システムがパニックを起こさず、自律的にHMBのデータを破棄してNANDから安全にリカバリ(フェイルバック)する振る舞いをPC上の単体テストで証明できる点は、QAやファームウェア開発のエキスパートとして高く評価されます。
  2. キャッシュ階層(データフロー)の最適化の保証 「NANDから読んだら、次回のためにSRAMとHMBの両方に書く」というパフォーマンス最適化のロジックが、実装忘れなどで欠落していないか。単にアドレス(PBA)が返ってくるだけでなく、g_call_hmb_write と g_call_sram_store のカウントをアサーションに含めることで、「性能要件(キャッシュヒット率の向上)」までもテストコードで担保する高いソフトウェア工学の視座を得られます。

【実装】DRAMレスSSDの真骨頂!「HMB階層キャッシュとゼロトラスト防御」の実装

🚀 HMB階層キャッシュとゼロトラスト防御

高価なDRAMを搭載しなくても、NVMe SSDが爆速で動作する秘密兵器「HMB(Host Memory Buffer)」。 OSのシステムメモリ(DRAM)をSSDが間借りし、巨大なL2P(論理-物理)マッピングテーブルをキャッシュすることで、ランダムアクセス性能を飛躍的に向上させます。

しかし、借り物のメモリである以上、「いつOSに奪われるか分からない」「ホスト側のマルウェアにL2Pを改ざんされるリスクがある」というシビアな前提に立つ必要があります。

今回は、前回のアーキテクチャ設計に基づき、「SRAM → HMB → NAND の3段構えの階層キャッシュ制御」と、ホストメモリ上のデータ改ざんを検知する「CRCによるゼロトラスト防御」のコアロジックを、「組み込みソフトウェア向けコーディング作法ガイド(ESCR Ver. 3.0)」に準拠して実装します。


💻 実装コード

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

/* [ESCR] マジックナンバーの排除: 定数はマクロやenumで明示 */
#define FTL_UNMAPPED_PBA            (0xFFFFFFFFU)
#define FTL_L2P_SEGMENT_ENTRIES     (1024U) /* 1セグメントあたりのLBA数 */

/* L2Pテーブルのセグメント構造体 (HMBへ保存される単位) */
typedef struct {
    uint32_t pba_entries[FTL_L2P_SEGMENT_ENTRIES]; /* 物理アドレスの配列 */
    uint32_t segment_lba_start;                    /* このセグメントが担当する開始LBA */
    uint32_t crc32;                                /* データの完全性を保証するチェックサム */
} L2pSegment_t;

/* キャッシュのヒット状態を表す列挙型 */
typedef enum {
    CACHE_HIT_SRAM = 0U,
    CACHE_HIT_HMB,
    CACHE_MISS_ALL
} CacheStatus_e;

/* --- 外部HAL/FWモ関数のモック宣言 --- */
/* 内部SRAMキャッシュからL2Pセグメントを検索する */
extern bool SRAM_FindL2pSegment(uint32_t lba, L2pSegment_t* p_out_seg);
/* 内部SRAMへL2Pセグメントを保存する */
extern void SRAM_StoreL2pSegment(const L2pSegment_t* p_seg);

/* PCIe(DMA)経由でホストメモリ(HMB)からL2Pセグメントを読み込む */
extern bool HMB_DmaReadSegment(uint32_t lba, L2pSegment_t* p_out_seg);
/* PCIe(DMA)経由でホストメモリ(HMB)へL2Pセグメントを書き出す */
extern bool HMB_DmaWriteSegment(const L2pSegment_t* p_seg);

/* 最終手段: NANDフラッシュからL2Pセグメントを読み込む(激重処理) */
extern bool NAND_ReadL2pSegment(uint32_t lba, L2pSegment_t* p_out_seg);

/* CRC32計算ユーティリティ */
extern uint32_t UTIL_CalculateCrc32(const uint32_t* p_data, uint32_t length);

/**
 * @brief HMBから取得したセグメントの改ざん/破損をチェックする
 * 
 * @param[in] p_seg チェック対象のL2Pセグメント
 * @return true: データ正常, false: CRC不一致(改ざんまたはメモリ化け)
 */
static bool ValidateSegmentCrc(const L2pSegment_t* p_seg) {
    bool b_is_valid = false;
    
    if (p_seg != NULL) {
        uint32_t expected_crc = UTIL_CalculateCrc32(p_seg->pba_entries, FTL_L2P_SEGMENT_ENTRIES);
        if (expected_crc == p_seg->crc32) {
            b_is_valid = true;
        }
    }
    return b_is_valid;
}

/**
 * @brief SRAM -> HMB -> NAND の階層キャッシュを用いて論理アドレス(LBA)から物理アドレス(PBA)を取得する
 * 
 * @param[in] lba 変換対象の論理アドレス
 * @return 取得した物理アドレス (未マッピングや深刻なエラー時は FTL_UNMAPPED_PBA を返す)
 */
uint32_t FTL_GetPhysicalAddress(uint32_t lba) {
    /* [ESCR] 単一出口(Single Exit)の原則: 戻り値用変数を初期化 */
    uint32_t target_pba = FTL_UNMAPPED_PBA;
    L2pSegment_t current_seg;
    CacheStatus_e hit_status = CACHE_MISS_ALL;

    /* 1. 【最速】SSD内部の超高速SRAMから検索 */
    if (SRAM_FindL2pSegment(lba, &current_seg) == true) {
        hit_status = CACHE_HIT_SRAM;
    } 
    /* 2. 【中速】SRAMになければ、ホストメモリ(HMB)からPCIe経由でDMA Read */
    else if (HMB_DmaReadSegment(lba, &current_seg) == true) {
        
        /* 
         * 【ゼロトラスト防御】
         * ホストメモリは信用できない。マルウェアによる改ざんや宇宙線によるビット化けを疑う。
         * もしCRCが一致しなければ、HMBのデータは破棄し、NANDから読み直す(フェイルソフト)。
         */
        if (ValidateSegmentCrc(&current_seg) == true) {
            hit_status = CACHE_HIT_HMB;
            /* 次回アクセスのためSRAMへ昇格(キャッシュ)しておく */
            SRAM_StoreL2pSegment(&current_seg);
        }
    }
    
    /* 3. 【低速/最終手段】SRAMにもHMBにも無い(またはHMBが壊れていた)場合はNANDから読み込む */
    if (hit_status == CACHE_MISS_ALL) {
        if (NAND_ReadL2pSegment(lba, &current_seg) == true) {
            /* 読み込んだデータをSRAMとHMBの両方にキャッシュし、次回の速度ペナルティを防ぐ */
            current_seg.crc32 = UTIL_CalculateCrc32(current_seg.pba_entries, FTL_L2P_SEGMENT_ENTRIES);
            
            SRAM_StoreL2pSegment(&current_seg);
            HMB_DmaWriteSegment(&current_seg);
            
            /* NANDからの読み込み成功を示すために状態を(論理的に)更新 */
            hit_status = CACHE_HIT_SRAM; /* ロード完了扱い */
        }
    }

    /* 正常にセグメントが取得できていれば、セグメント内から該当PBAを抽出 */
    if (hit_status != CACHE_MISS_ALL) {
        uint32_t offset = lba % FTL_L2P_SEGMENT_ENTRIES;
        target_pba = current_seg.pba_entries[offset];
    }

    return target_pba;
}

💡 組み込みエンジニア視点での設計のポイント

HMB特有のアーキテクチャの真髄は以下の3点です。

1. 「SRAM → HMB → NAND」の3段フォールバック設計

このコードは、CPUにおける「L1キャッシュ(SRAM) → L2キャッシュ(HMB) → メインメモリ(NAND)」の階層アーキテクチャそのものです。 FTL_GetPhysicalAddress() では、まず内部SRAMを探し(数ナノ秒)、無ければPCIe越しにHMBを探し(数マイクロ秒)、それでもダメなら最終手段としてNANDへアクセス(数十マイクロ秒〜)します。 HMBで見つかったデータや、NANDから読み出したデータをすかさず上位のキャッシュ層へ昇格(Store/Write)させることで、局所性(Locality)を活かし、DRAMレスSSDのランダム性能を底上げしています。

2. 「ゼロトラスト(Zero Trust)」に基づく改ざん検知

コード内の ValidateSegmentCrc() こそが、HMB設計における最も重要なセキュリティ・フェイルセーフ機構です。 HMBは「PCのメインメモリ」である以上、OSのバグや悪意のあるプログラムによって、SSDが預けたL2Pデータを勝手に書き換えられるリスクがあります。もし改ざんされたL2Pを信じてしまうと、ユーザーの全く違うファイルを上書きして破壊してしまいます。 そのため、SSDがHMBからデータを読み戻した際は、必ずCRC32で自律的に整合性をチェックし、少しでも疑わしければHMBのデータを躊躇なく破棄してNANDから読み直すという、ホストを一切信用しない防御的プログラミングが徹底されています。

3. HMBは「いつ奪われても良い」ステートレス設計

HMBアーキテクチャの掟は、「未書き込みのユーザーデータ(Dirty Data)を絶対にHMBに置かないこと」です。 OSはメモリ不足に陥ると、SSDに一切の通告なしにHMBの領域を剥奪することがあります。今回のコードでHMBに保存しているのは、あくまで「NANDに既に保存されているL2Pテーブルのコピー(Clean Data)」のみです。そのため、仮にHMBが突如消滅したりCRCエラーを起こしても、単にキャッシュミス(CACHE_MISS_ALL)としてNANDから再ロードするだけで済み、SSDがシステムクラッシュ(文鎮化)することはありません。

【設計】DRAMレスでも爆速!SSDのHMB(Host Memory Buffer)アーキテクチャ設計

🚀 HMB(Host Memory Buffer)アーキテクチャ設計

近年のSSD市場を席巻している、安価で高性能な「DRAMレスSSD」。 通常、SSDからDRAMを省いてしまうと、数GBに及ぶL2P(論理-物理)マッピングテーブルを全て低速なNANDフラッシュに置くことになり、ランダムアクセス性能が壊滅的に低下します。

このジレンマを打ち破るNVMe規格の魔法の機能が「HMB(Host Memory Buffer)」です。 HMBは、「SSD内にDRAMが無いなら、ホスト(PC)に搭載されている余っているDRAM(システムメモリ)をPCIe経由で少し借りてしまえ」という逆転の発想で作られました。

しかし、組み込みソフトウェアの観点から見ると「いつOSにメモリを取り上げられるか分からない」「PCIe越しのアクセスはSSD内DRAMよりレイテンシが大きい」というシビアな制約との戦いになります。

本記事では、「組込みソフトウェア開発向け設計ガイド(ESDR [事例編])」に準拠し、コストと性能の限界を突破するHMBアーキテクチャの設計を紹介します!


1. 🏗️ 構造設計(コンポーネント図と責務定義)

ESDRの「関心事の分離」に基づき、ホストOSとのメモリ交渉、キャッシュアルゴリズム、およびPCIe経由のDMA制御を明確に分割します。

モジュール名 役割(ESDRに基づく責務定義)
HMB Configurator ホストOSとNVMe Adminコマンドを用いて交渉(Set Features)を行い、借り受けるDRAMのサイズと物理アドレス(Scatter-Gatherリスト)を取得・設定する。
L2P Cache Mgr 限られたHMB領域(例: 16MB〜64MB)を最大限に活用するため、アクセス頻度の高いL2Pテーブルの断片を管理する。OSの仮想メモリのような「ページング」を行う。
HMB DMA Engine SSDコントローラからホストメモリ(HMB)へ向けて、PCIe経由でデータのRead/Write DMAを自律的に発行する。
SRAM Buffer Mgr コントローラ内部にある数MBの超高速SRAM。HMBからロードしたL2Pデータを一時的に格納し、NANDアクセスを仲介する。
FTL Backend キャッシュミス時など、最終的なL2PデータのNANDへの退避(Eviction)やロードを行う。

2. 🔄 振る舞い設計(L2Pキャッシュページングのアクティビティ図)

ホストからランダムRead要求が来た際、FWがどのようにHMBを活用してL2Pアドレスを解決し、NANDへアクセスするかのシーケンス設計です。

[開始] ホストより Read要求 (Target LBA: 100) を受信
  │
  ▼
[処理] L2P Cache Mgr が内部SRAMに LBA:100 のマッピングがあるか確認
  │
  ├─(SRAMにHit) ──▶ 即座にPBA(物理アドレス)を取得 ──▶ [NAND Readへ]
  │
  ▼(SRAMにMiss)
[処理] 次に、ホストメモリ上(HMB)に該当L2Pセグメントが存在するか確認
  │
  ├─(HMBにHit) 
  │  │
  │  ▼
  │ [処理] HMB DMA Engine がPCIe経由でホストメモリからSRAMへL2Pを転送
  │  │
  │  ▼
  │ [処理] PBAを取得 ──▶ [NAND Readへ]
  │
  ▼(HMBにもMiss:最悪のケース)
[処理] FTL Backend が低速なNANDフラッシュから対象のL2PセグメントをSRAMへロード
  │
  ▼
[処理] 取得したL2Pセグメントを、今後のアクセスのため HMB へキャッシュ(DMA Write)
  │
  ▼
[処理] PBAを取得 ──▶ [NAND Readへ]

3. 📦 データ設計(HMBディスクリプタ構造:HSI定義)

ホストがSSDに貸してくれるメモリは「連続した物理アドレス」とは限りません。バラバラに断片化したメモリ空間をSSDが正しく認識するための「HMBディスクリプタ(Host Memory Descriptor List)」のデータ構造を定義します。

/* ホストから提供されるHMBメモリチャンクの記述子 (NVMe規格準拠) */
typedef struct {
    uint64_t host_physical_address; /* ホストDRAM上の物理ベースアドレス */
    uint32_t size_in_bytes;         /* このチャンクのサイズ (通常4KB境界) */
    uint32_t reserved;
} HmbChunkDescriptor_t;

/* SSD内部で管理するHMBコンテキスト */
typedef struct {
    bool                  is_hmb_enabled;  /* ホストからHMBの利用が許可されているか */
    uint32_t              total_hmb_size;  /* 割り当てられた総容量 (例: 64MB) */
    uint32_t              chunk_count;     /* 記述子リストの要素数 */
    HmbChunkDescriptor_t* p_chunk_list;    /* 内部SRAMに保持するチャンクリストのポインタ */
} HmbContext_t;

4. 🧠 組込み設計の勘所

DRAMレスSSDを「安かろう悪かろう」から「コスパ最強の爆速ドライブ」へ昇華させるには、以下の3つの高度なフェイルセーフとセキュリティ設計が不可欠です。

① 「突然のメモリ剥奪(Surprise Removal)」へのフェイルセーフ

OSがスリープ(S3/S4ステート)に入る際や、システムのメモリが極端に不足した場合、OSはSSDに対して「今すぐHMBを返せ」と通告してきます(あるいは通告なしに電源が切れます)。 そのため、HMBには絶対に「ホストから送られてきた未書き込みのユーザーデータ(Dirty Data)」を置いてはいけません。 HMBに置くのは、最悪消えてもNANDから再構築できる「L2Pテーブルのクローン(Clean Data)」のみに限定します。これにより、いつHMBが剥奪されてもシステムが破綻しない「ステートレスなキャッシュ設計」を実現しています。

② PCIeレイテンシの隠蔽(プリフェッチ)

SSD内部のDRAMへのアクセスレイテンシが数十ナノ秒であるのに対し、PCIeを経由してホストメモリ(HMB)へアクセスすると数マイクロ秒の遅延が発生します。 これを隠蔽するため、L2P Cache Mgrは要求されたLBAだけでなく、「その周辺のLBA領域もまとめてHMBからSRAMへ先読み(Prefetch)しておく」という空間的局所性(Spatial Locality)を利用したアルゴリズムを実装します。これにより、連続したアクセスにおいてPCIeの往復ペナルティを劇的に削減します。

③ セキュリティ(ホスト側の改ざんへの耐性)

HMBは「ホストのメモリ」である以上、悪意のあるマルウェアやバグを持ったOSドライバによって「SSDが書き込んだL2Pデータが、ホスト側から勝手に書き換えられる」リスクが常に存在します。 もし改ざんされたL2PテーブルをSSDが信じてNANDにアクセスすると、全く別のファイルが読み出されたり、関係ないブロックを上書き破壊したりします。これを防ぐため、HMBへ書き出すL2Pデータには必ずCRC(チェックサム)やMAC(メッセージ認証コード)を付与し、HMBからSRAMへ読み戻す際に「データが改ざんされていないか」を毎回検証するゼロトラスト(Zero Trust)な防御設計が組み込まれています。

【検査】数ミリ秒のデータ消去をテストで証明!SSD「SIE(瞬時暗号鍵破棄)」のテスト設計

🚀 SIE(瞬時暗号鍵破棄)のテスト設計

組み込みソフトウェアにおいて、「データを消す」処理のバグは、機能不全ではなく「機密情報の漏洩」という致命的なセキュリティインシデントに直結します。 特にSSDのSIE(Sanitize Instant Erase)機能は、「鍵を壊している最中に電源が落ちた」「新しい鍵の生成にハードウェアが失敗した」といった極限状態(エッジケース)においても、絶対にデータが復元不可能な状態(あるいはシステムを安全にロックする状態)を担保しなければなりません。

今回は、「ESTR 組込みソフトウェア開発における品質向上の勧め[テスト編]」に準拠し、前回のSIE実装に対し、モック(ダミー関数)を用いてハードウェアのあらゆる異常状態を注入し、システムのフェイルセーフを証明するUnity単体テストコードを紹介します!


💡 組み込みテスト設計のポイント

ESTR事例集が提唱する「異常状態の網羅とフェイルソフトの検証」を、セキュリティ機能特有のシビアな要件に落とし込みます。

1. 状態遷移と先行書き込みログの検証(State Transition & Write-Ahead Log)

SIEにおいて最も重要なのは、「鍵を壊す前に、必ずNANDへ『消去中(IN_PROGRESS)』を記録しているか」という順序の証明です。 モック関数の呼び出し順序(Call Order)を記録し、「鍵の破棄(KMS_Destroy)」が「状態のNAND保存(HAL_Commit)」より絶対に後に呼ばれることを検証します。これにより、電源断からの再開(Resume)要件を満たすアーキテクチャであることをテストで保証します。

2. アトミック性の証明(割り込み禁止制御の検証)

暗号鍵の破棄と再生成の最中は、一切の割り込み(I/Oやタイマー)が禁止されなければなりません。テストコード内で「割り込み禁止」から「許可」までの間に、正しく鍵操作関数が呼ばれ、かつ異常終了時でも「割り込み禁止のままロックされる(デッドロック)」事態を防いでいるかをコールカウントで証明します。

3. フォールトインジェクション(異常注入)とフェイルソフト

ESTRが最重要視するアプローチです。ハードウェア乱数生成器(TRNG)の故障などにより「新しい鍵の生成に失敗する」という最悪の異常をモックで注入します。 この時、FWがパニックを起こさず、状態を「FAILED(失敗)」としてNANDへコミットし、以降のドライブアクセスを封鎖するフェイルソフト(安全なシステム停止)に正しく遷移することを検証します。


💻 テストコード

#include "unity.h"
#include <stdint.h>
#include <stdbool.h>

/* テスト対象のCファイルを直接インクルード(ホワイトボックステスト用) */
#include "ftl_sanitize.c"

/* --- モック用制御・状態記録変数 --- */
static uint32_t g_call_commit_nand = 0U;
static SanitizeContext_t g_last_committed_ctx;

static uint32_t g_call_disable_irq = 0U;
static uint32_t g_call_enable_irq = 0U;

static bool     g_mock_destroy_key_success = true;
static uint32_t g_call_destroy_key = 0U;

static bool     g_mock_generate_key_success = true;
static uint32_t g_call_generate_key = 0U;

static uint32_t g_call_invalidate_l2p = 0U;

/* 実行順序(順序性担保)の記録用 */
#define MAX_CALL_TRACE 10
static const char* g_call_trace[MAX_CALL_TRACE];
static uint32_t g_trace_idx = 0U;

static void record_trace(const char* func_name) {
    if (g_trace_idx < MAX_CALL_TRACE) {
        g_call_trace[g_trace_idx++] = func_name;
    }
}

/* --- 外部HAL/FW関数のダミー実装 (モック) --- */
bool HAL_CommitSanitizeStateToNand(const SanitizeContext_t* p_context) {
    record_trace("HAL_CommitSanitizeStateToNand");
    g_call_commit_nand++;
    if (p_context != NULL) {
        g_last_committed_ctx = *p_context;
        return true;
    }
    return false;
}

void HAL_DisableInterrupts(void) {
    record_trace("HAL_DisableInterrupts");
    g_call_disable_irq++;
}

void HAL_EnableInterrupts(void) {
    record_trace("HAL_EnableInterrupts");
    g_call_enable_irq++;
}

bool KMS_DestroyMediaEncryptionKey(void) {
    record_trace("KMS_DestroyMediaEncryptionKey");
    g_call_destroy_key++;
    return g_mock_destroy_key_success;
}

bool KMS_GenerateNewMediaEncryptionKey(void) {
    record_trace("KMS_GenerateNewMediaEncryptionKey");
    g_call_generate_key++;
    return g_mock_generate_key_success;
}

void FTL_InvalidateL2pTable(void) {
    record_trace("FTL_InvalidateL2pTable");
    g_call_invalidate_l2p++;
}

/* --- テストのセットアップ --- */
void setUp(void) {
    g_call_commit_nand = 0U;
    g_call_disable_irq = 0U;
    g_call_enable_irq = 0U;
    g_call_destroy_key = 0U;
    g_call_generate_key = 0U;
    g_call_invalidate_l2p = 0U;
    
    g_mock_destroy_key_success = true;
    g_mock_generate_key_success = true;
    
    g_trace_idx = 0U;
    for (int i = 0; i < MAX_CALL_TRACE; i++) g_call_trace[i] = NULL;
}
void tearDown(void) {}

/* =========================================================
 *  FTL_ExecuteSanitizeCryptoErase のテストケース (ESTR準拠)
 * ========================================================= */

/* 1. 正常系: SIE(鍵破棄と再生成)の完全フローとアトミック性の証明 */
void test_FTL_ExecuteSanitizeCryptoErase_Success_FlowAndAtomicity(void) {
    
    bool result = FTL_ExecuteSanitizeCryptoErase();
    
    TEST_ASSERT_TRUE_MESSAGE(result, "全ての処理が正常に完了した場合はtrueを返すこと");
    
    /* 【状態遷移の検証】 */
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(2U, g_call_commit_nand, "開始時と完了時の計2回、NANDへ状態がコミットされること");
    TEST_ASSERT_EQUAL_MESSAGE(SANITIZE_STATE_IDLE, g_last_committed_ctx.current_state, "最終的なNAND状態はIDLE(完了)になっていること");
    
    /* 【アトミック操作の検証】 */
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(1U, g_call_disable_irq, "鍵操作の前に割り込みが禁止されること");
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(1U, g_call_enable_irq, "鍵操作の後に割り込みが許可されること");
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(1U, g_call_invalidate_l2p, "L2Pテーブルのフォーマットが行われること");
    
    /* 【順序性の検証: 先行書き込みログの証明】 */
    TEST_ASSERT_EQUAL_STRING_MESSAGE("HAL_CommitSanitizeStateToNand", g_call_trace[0], "【最重要】鍵を触る前に、必ずNANDへIN_PROGRESS状態をコミットすること");
    TEST_ASSERT_EQUAL_STRING_MESSAGE("HAL_DisableInterrupts", g_call_trace[1], "NAND記録後に割り込みを禁止すること");
    TEST_ASSERT_EQUAL_STRING_MESSAGE("KMS_DestroyMediaEncryptionKey", g_call_trace[2], "割り込み禁止区間で鍵の破棄を行うこと");
}

/* 2. 異常系(フォールトインジェクション): 新しい暗号鍵の生成にハードウェアが失敗した場合のフェイルソフト検証 */
void test_FTL_ExecuteSanitizeCryptoErase_GenerateKeyFails_ShouldFailSafe(void) {
    
    /* TRNG(ハードウェア乱数生成器)の故障をシミュレート */
    g_mock_generate_key_success = false;
    
    bool result = FTL_ExecuteSanitizeCryptoErase();
    
    TEST_ASSERT_FALSE_MESSAGE(result, "ハードウェア異常発生時はエラー(false)を返すこと");
    
    /* 【フェイルソフトの検証】 */
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(2U, g_call_commit_nand, "エラー発生時も、最終状態をNANDへコミットすること");
    TEST_ASSERT_EQUAL_MESSAGE(SANITIZE_STATE_FAILED, g_last_committed_ctx.current_state, "【ESTR フェイルソフト】状態をFAILEDとして記録し、以降の起動をロックすること");
    
    /* 二次災害の防止検証 */
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(0U, g_call_invalidate_l2p, "鍵生成に失敗した場合は、既存のテーブルフォーマットをスキップすること");
    TEST_ASSERT_EQUAL_UINT32_MESSAGE(1U, g_call_enable_irq, "エラー経路でも必ず割り込み許可を行い、システムデッドロックを防ぐこと");
}

/* 3. 異常系: 初期状態のNANDコミット自体が失敗した場合(ストレージ寿命到達等) */
void test_FTL_ExecuteSanitizeCryptoErase_InitialCommitFails_ShouldAbort(void) {
    
    /* モックの振る舞いを変更(setUpで初期化できない構造のため一時的に直接変更) */
    /* 実環境ではHAL_CommitSanitizeStateToNand内でカウントをチェックしてfalseを返すよう細工する想定 */
    /* ここでは簡略化し、最初のコミットが失敗したと仮定したロジックをテスト */
    
    /* 注: C言語のテストフレームワークにおいて、モックの挙動を動的に変えるにはフック等を用いる。
       今回はロジックが単純なため、概要として記載。
       「最初のHAL_CommitSanitizeStateToNandがfalseを返した場合、鍵の破棄には絶対に進まないこと」を証明する。 */
}

🎯 組み込みエンジニアとして得られる知見

エンタープライズ製品の開発において、セキュリティ要件に対するこのテストアプローチは高い評価を得られます。以下のポイントを押さえておきましょう。

  1. 「Call Trace(呼び出し順序)」を用いた状態不揮発化の証明 単にカバレッジ(C0/C1)を100%にするだけでなく、テストコード内に「関数呼び出しの履歴(g_call_trace)」を実装し、「NANDへの書き込みが、鍵の破壊よりも絶対に先に実行されること(先行書き込みログ)」をテストレベルで制約・証明しています。これは「電源断時のデータ保護」という、ストレージ最難関の要件をソフトウェア工学的に担保する高度なスキルです。
  2. 割り込み禁止/許可の「漏れ」を検知する設計 異常系(テストケース2)において、単にfalseが返ることを確認するだけでなく、g_call_enable_irq が呼ばれていることをAssertionに含めています。エラーハンドリング(例外処理)の経路で割り込み許可を忘れ、システムがフリーズする組み込み特有のバグを、単体テストで検知することができます。