持久化存储

本篇是《Ariel OS 深度实战:Rust 库式 IoT 操作系统全解析》系列第 27 篇。上一篇:以太网、设备元数据与身份,下一篇:测试:embedded-test 与硬件在环,目录见文末。

持久化存储

导读

设备断电重启后还能"记得"什么?Wi-Fi 配网信息、CoAP 的 EDHOC 私钥(第 23 篇的 storage 安全策略就依赖它)、传感器校准系数、运行计数器——这些跨启动状态需要一个持久化层。本篇讲 Ariel OS 的键值存储:ariel_os::storage,一个构建在 sequential-storage 之上的嵌入式键值 API,以官方 examples/storage 为蓝图,把写入、读取、原子读改写、类型陷阱与 flash 磨损全部过一遍。

这个子系统再次体现了 Ariel OS 的选型哲学:不发明新的存储格式,直接采用社区验证过的 sequential-storage crate(日志结构、追加写、断电安全),Ariel OS 贡献的是三件事:flash 分区的自动划分(链接器决定存储页在固件之后)、以 Mutex 封装的并发安全 API、以及 serde + postcard 的序列化集成。应用侧的体验是"存取任何 serde 类型,key 是字符串"。

版本基线:仓库 dev(0.6.0)。注意第 10 篇的提醒在这里同样适用:alloc 现在是 laze 模块而非 Cargo feature——但存储本身不依赖堆(序列化目标都是 heapless 类型),这是它能在最小设备上运行的前提。主线板 ESP32-C6 的内部 flash 即存储介质,无需任何额外硬件。

为什么需要日志结构的键值存储

嵌入式持久化的第一直觉是"在 flash 某个固定地址存一个结构体"。这个直觉方案有三个致命伤:flash 写前必须擦除(NOR flash 擦除单位是整页,4 KiB 量级),改一个字段要擦整页;擦写次数有限(典型 NOR 每页 10 万次),频繁更新的字段会把单页磨穿;断电时机不可控,写到一半的数据结构就是垃圾。 Ariel OS 的 storage 底层 sequential-storage 的应对是经典的日志结构:新值追加写入而非原地覆盖,读取时从后往前找到该 key 的最新条目,页写满后做整理(把最新条目搬去新页、擦旧页)。断电安全来自追加式的天然属性:任何时刻断电,已完整写入的条目有效、未写完的条目会被识别丢弃——Book 原文的承诺是"断电可恢复(sequential-storage 保证)"。

Ariel OS 在其上提供键值语义时的一个关键决定是:key 恒为 &str,value 是任意 serde::Serialize + Deserialize 类型,底层 postcard 序列化。postcard 是 serde 生态里为嵌入式设计的紧凑二进制格式(变长整数、无 schema 冗余)。这带来一个必须刻在脑子里的约束(Book 明示):读写必须用同一类型——类型不同不会 unsafe,但 postcard 是自描述性很弱的格式,把 u32 存进去按 [u8; 4] 读出来,得到的是"乱数据"而非报错。官方 storage 示例专门演示了三种"错误读法"的现场(下文逐段解析),这是全仓库最贴心的反面教材之一。

空间布局:存储页在哪里

存储区由链接符号 __storage_start/__storage_end 圈定,规则是:至少 2 个 flash 页;可用空间为 (N-1) x PAGE_SIZE;页面在 rodata 之后分配。为什么至少 2 页、可用只有 N-1?日志结构需要至少一页作为"整理时的搬家目标"——单页存储无法整理。为什么要求"页大小均匀"?整理算法依赖整页搬移。为什么在 rodata 之后?固件(代码 + 常量)从 flash 起点向上生长,存储区在其后,heapsize_required 式的链接期检查保证固件增长不会撞进存储区。

但有一个反向风险 Book 特别提醒:固件大小变化可能移动/失效存储页——如果你改代码后固件变大、越过存储区起点,链接器会把存储区整体后移,旧固件存的键值在物理 flash 上的位置对新固件而言落在"存储区之外"(实际还在 flash 里但地址映射变了),数据看起来"丢了"。开发迭代期这是最常踩的坑,解决方案在本篇末尾。

启用方式

laze 模块 sw/storage(映射到 Cargo feature storage):

apps:
  - name: example-storage
    selects:
      - sw/storage

Cargo.toml:

[dependencies]
ariel-os = { path = "../../src/ariel-os", features = ["storage"] }
ariel-os-boards = { path = "../../src/ariel-os-boards" }
heapless = { workspace = true, features = ["serde"] }
serde = { workspace = true, default-features = false, features = ["derive"] }

heapless 的 serde feature 让 heapless::String<64>/heapless::Vec<u8, N> 可序列化——存储 value 类型的主力军。

代码案例:storage 示例全程

src/main.rs(官方示例,保留全部教学分支):

#![no_main]
#![no_std]

use ariel_os::{
    debug::{ExitCode, exit},
    log::{Hex, info},
};

// Imports for using [`ariel_os::storage`]
use ariel_os::storage;
use serde::{Deserialize, Serialize};

/// Example object.
///
/// The serde Serialize / Deserialize traits are required for storage.
#[derive(Serialize, Deserialize, Debug)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
struct MyConfig {
    val_one: heapless::String<64>,
    val_two: u64,
}

#[ariel_os::task(autostart)]
async fn main() {
    info!("Start storage example");

    // Storing a primitive type (e.g., u32)
    let value: Option<u32> = storage::get("counter").await.unwrap();
    let value = if let Some(value) = value {
        info!("got counter value {} from storage", value);
        value
    } else {
        info!("no counter value in storage. Is this the first time running this example?");
        0
    };

    if value > 10 {
        info!("counter value > 10, aborting test to save flash cycles");
        exit(ExitCode::SUCCESS);
    }
    info!("");

    storage::insert("counter", value + 1).await.unwrap();

    // By getting the Storage mutex directly, changing e.g., a counter,
    // can be done atomically w.r.t. concurrent access from the same firmware:
    {
        let mut s = storage::lock().await;
        let value: Option<u32> = s.get("another_counter").await.unwrap();
        let value = value.unwrap_or_default();
        s.insert("another_counter", value + 1).await.unwrap();
        info!("Old 'another_counter' value at {}", value);
    }
    info!("");

    // Storing a string value
    // For insertion, a literal can be used.
    info!("Don't try this in your code!");
    info!("Storing \"string_key\": \"string_value\" into storage");
    storage::insert("string_key", "string_value").await.unwrap();

    // Retrieve a string value
    if let Some(string) = storage::get::<heapless::String<64>>("string_key")
        .await
        .unwrap()
    {
        info!("got heapless string value: \"{}\"", string);
    }
    if let Some(string) = storage::get::<arrayvec::ArrayString<64>>("string_key")
        .await
        .unwrap()
    {
        info!(
            "Attempting to retrieve string value as ArrayString: {}",
            Hex(string.as_bytes())
        );
    }
    info!("");

    // Storing an object
    let cfg = MyConfig {
        val_one: heapless::String::<64>::try_from("some value").unwrap(),
        val_two: 99,
    };
    info!("Storing cfg object {:?} as struct", cfg);
    storage::insert("my_config", cfg).await.unwrap();

    // Getting an object
    // Type used for `get()` needs to match what was used for `insert()`.
    let cfg: Option<MyConfig> = storage::get("my_config").await.unwrap();
    if let Some(cfg) = cfg {
        info!("got cfg object: {:?}", cfg);
    }

    // Getting a value as raw bytes probably does not return what you want due
    // to the way postcard works
    let cfg_array: Option<arrayvec::ArrayVec<u8, 256>> = storage::get("my_config").await.unwrap();
    if let Some(cfg) = cfg_array.as_ref() {
        info!(
            "Attempting to retrieve cfg as ArrayVec: {}",
            Hex(cfg.as_slice())
        );
    }

    // Same for byte arrays
    let cfg_array: Option<[u8; 10]> = storage::get("my_config").await.unwrap();
    if let Some(cfg) = cfg_array.as_ref() {
        info!("Attempting to retrieve cfg as array: {}", Hex(cfg));
    }
    info!("");

    // raw bytes
    let bytes: [u8; 5] = [0, 1, 2, 3, 4];
    info!("Storing raw bytes {}", Hex(bytes));
    storage::insert("some_raw_bytes", bytes).await.unwrap();

    let bytes: Option<[u8; 5]> = storage::get("some_raw_bytes").await.unwrap();
    if let Some(bytes) = bytes.as_ref() {
        info!("got bytes as array: {}", Hex(bytes));
    }

    let bytes: Option<heapless::Vec<u8, 256>> = storage::get("some_raw_bytes").await.unwrap();
    if let Some(bytes) = bytes.as_ref() {
        info!(
            "Attempting to retrieve bytes as heapless vec arr: {}",
            Hex(bytes)
        );
    }
    info!("");

    info!("Exit storage example");

    exit(ExitCode::SUCCESS);
}

laze.yml 还演示了示例级自定义模块的写法(可选 defmt 支持):

apps:
  - name: example-storage
    selects:
      - ?example-storage-defmt
      - sw/storage

modules:
  - name: example-storage-defmt
    selects:
      - defmt
    env:
      global:
        FEATURES:
          - defmt

运行(每次烧录后重启都会再执行一轮):

laze -C examples/storage build -b esp32-c6-devkitc-1 run

逐段解析

第一段:读-判断-写

let value: Option<u32> = storage::get("counter").await.unwrap();
let value = if let Some(value) = value { ... } else { 0 };
storage::insert("counter", value + 1).await.unwrap();

storage::get::<V>(key) 的返回类型是 Result<Option<V>, Error<FlashError>>:Ok(None) 表示"key 不存在"(首次启动的正常状态),Ok(Some(v)) 是命中,Err 才是 flash 层故障。三层错误/缺省语义(故障/不存在/有值)被 Rust 的类型系统排得明明白白——unwrap() 外层、if let 内层,是这个 API 的标准读法。类型标注 Option<u32> 写在 let 上,因为 get 是泛型函数,V 从调用侧推断。

value > 10 后 exit(SUCCESS) 的保护分支是给 flash 寿命的:示例每次运行都写 counter,不加保护的话反复测试会持续消耗擦写周期。这个细节是把"NOR 寿命有限"写进了示例行为的教科书处理——Book 在要点里特别点名"注意写入次数(尤其启动时崩溃循环)":如果固件在"启动时写存储"之后、exit 之前崩溃重启,会形成写循环,单页寿命很快烧完。生产固件的启动写入要有节流(版本号比对、写入计数上限)。

第二段:lock() 与原子读改写

let mut s = storage::lock().await;
let value: Option<u32> = s.get("another_counter").await.unwrap();
let value = value.unwrap_or_default();
s.insert("another_counter", value + 1).await.unwrap();

storage::lock() 返回 MutexGuard<Storage>——底层存储被一个全局互斥锁保护。为什么需要它:get/insert 独立函数各自拿锁、各自释放,两次调用之间另一个任务可能插入自己的写——"读旧值 + 写新值"被撕开。lock() 在锁的临界区内完成读+写,对同一固件内的并发访问是原子的。示例注释精确限定:"atomically w.r.t. concurrent access from the same firmware"——它不是跨设备/跨固件版本的原子性,只解决多任务竞争。unwrap_or_default() 是"不存在则从 0 开始"的紧凑写法。Guard 的 Drop 释放锁,作用域块 { ... } 显式圈定临界区是可读性最好的写法。

第三段:字符串与类型陷阱

storage::insert("string_key", "string_value").await.unwrap();
if let Some(string) = storage::get::<heapless::String<64>>("string_key").await.unwrap() { ... }

插入时直接传 &str 字面量(&str 实现了 Serialize),读回时必须指定一个可拥有的目标类型——heapless::String<64> 是最自然的选择,能正确还原。示例的 info!("Don't try this in your code!") 反语指的是下一行:

if let Some(string) = storage::get::<arrayvec::ArrayString<64>>("string_key").await.unwrap() {
    info!("Attempting to retrieve string value as ArrayString: {}", Hex(string.as_bytes()));
}

用 ArrayString<64> 读同一 key——同为字符串容器,但 postcard 编码布局不同,Hex(string.as_bytes()) 打出的是乱序字节,不是"string_value"。没有 panic、没有 Err、只有乱数据——Book"读写须用同一类型"警告的具体形态。工程对策:把 (key, type) 的配对集中封装——为每个 key 写一个专属的 load/save 函数(或宏),调用方永远不接触裸 get::<T>,类型配对由封装保证。

第四段:结构体与其错误读法

storage::insert("my_config", cfg).await.unwrap();          // MyConfig
let cfg: Option<MyConfig> = storage::get("my_config").await.unwrap();   // 正确
let cfg_array: Option<arrayvec::ArrayVec<u8, 256>> = storage::get("my_config").await.unwrap();  // 错法一
let cfg_array: Option<[u8; 10]> = storage::get("my_config").await.unwrap();  // 错法二

MyConfig { val_one: heapless::String<64>, val_two: u64 } 存进去再按 MyConfig 读回,字段原样还原。两种错误读法都"成功"返回 Some,但内容是 postcard 字节流被错误解释的产物——ArrayVec<u8,256> 读出的十六进制里能认出 "some value" 的 ASCII 码(postcard 对 heapless::String 的编码是变长长度前缀 + UTF-8 字节),[u8;10] 读出的是流的前 10 字节。这段反例的价值在于让读者亲眼看到"类型不匹配不报错"的后果,从此对裸泛型 get 保持敬畏。

defmt::Format 的条件派生(#[cfg_attr(feature = "defmt", derive(defmt::Format))])是日志兼容的标准写法:defmt facade 下 info!("{:?}", cfg) 需要 defmt 的 Format trait,log facade 下走 core Debug——示例在两种日志栈间可移植的秘密。

第五段:原始字节数组

let bytes: [u8; 5] = [0, 1, 2, 3, 4];
storage::insert("some_raw_bytes", bytes).await.unwrap();
let bytes: Option<[u8; 5]> = storage::get("some_raw_bytes").await.unwrap();      // 正确还原
let bytes: Option<heapless::Vec<u8, 256>> = storage::get("some_raw_bytes").await.unwrap(); // 错法

固定长数组 [u8; 5] 是 postcard 里的确定长度编码(无长度前缀),存取对称。换 heapless::Vec<u8,256> 读(变长:长度前缀 + 数据)就错位了。习惯上"原始字节"用 [u8; N](尺寸编译期已知)或与写侧完全相同的类型。三种 value 形态的最佳实践清单:标量/结构体(自定义类型,两端同型)、字符串(写 &str、读 heapless::String<N>)、字节块([u8; N] 两侧同型)。

系统侧:init 标记与平台细节

ariel-os-storage 的 init 流程(init_task 中 block_on 执行):从链接符号取存储区(各平台有地址 OFFSET 换算,如 rp=0x1000_0000、stm32=0x0800_0000——flash 别名区的地址基差异),首次启动写入 ARIEL_INIT_MARK 标记并 erase_all 清区;RP 平台 init 后 block 10 ms 等 defmt RTT 头被解析(存储初始化的串行输出不干扰日志流)。remove(key) 删除单键(日志结构里写删除标记),erase_all 清空全区。CoAP 的 coap-server-config-storage 策略(第 23 篇)是存储的首个系统级用户:设备首启生成 EDHOC 私钥并写入存储——这印证了存储在 Ariel OS 安全架构中的位置:系统组件自己也要持久状态,storage 是它们的公共底座。

运行现象

首次运行:

INFO Start storage example
INFO no counter value in storage. Is this the first time running this example?
INFO Old 'another_counter' value at 0
INFO Don't try this in your code!
INFO Storing "string_key": "string_value" into storage
INFO got heapless string value: "string_value"
INFO Attempting to retrieve string value as ArrayString: <一串非 ASCII 的 hex 字节>
INFO Storing cfg object ... as struct
INFO got cfg object: MyConfig { val_one: "some value", val_two: 99 }
INFO Attempting to retrieve cfg as ArrayVec: <hex,其中可见 73 6f 6d 65 20 76 61 6c 75 65 ("some value")>
INFO Storing raw bytes 00 01 02 03 04
INFO got bytes as array: 00 01 02 03 04
INFO Exit storage example

按复位(不重新烧录)再跑一轮:

INFO got counter value 1 from storage
INFO Old 'another_counter' value at 1

counter 与 another_counter 各加一——跨重启持久化的直接证明。连跑十轮后触发 counter value > 10, aborting test to save flash cycles 保护退出。错误读法的乱数据行在每轮都出现(同 key 反复错误读、结果稳定复现),这是"乱数据可复现"的取证视角:如果你在自家应用里看到"存储读回的内容每次一致但语义不对",第一嫌疑就是类型配对错误。

常见问题

Q1:重新烧录固件后,之前存的数据"丢了"?

成因:最常见的是固件大小变化移动了存储区——存储页在 rodata 之后分配,新固件更大则存储区起点后移,旧数据在物理 flash 上对新固件的存储区而言位置错乱;其次可能是存储区重叠冲突(链接期报错)或首次 init 的 erase_all(理论上只发生在首启)。解决方案:开发期接受"固件大改后存储失效"的现实,用示例式的版本计数(把 schema 版本存进 MyConfig)检测不兼容;量产时锁定固件布局(存储相关的地址在发布固件间保持稳定),OTA 升级保证固件不越过存储起点;紧急手段是 flash-erase-all 后重初始化。预防性做法:给固件留足余量再定存储页布局。

Q2:get 返回 Ok(Some) 但内容是乱码/字段值荒谬?

成因:读写类型不匹配——本篇花了最大篇幅的反面教材。postcard 编码近乎无自描述性,错误类型大多能"解码成功"。解决方案:封装 key 的类型配对(为每个 key 提供专属 load/save API);把关键结构加"魔数+版本"字段(struct Store { magic: u32, ver: u8, ... }),读取后校验 magic/ver 不符即视为损坏走默认值;避免对不同 key 复用同一编码猜测逻辑。记住三条安全配对:&str 写配 heapless::String<N> 读;[u8;N] 两侧同型;自定义结构体两侧同型且字段演进要写迁移逻辑。

Q3:启动时崩溃循环把 flash 写坏/写入次数暴涨?

成因:Book 点名的场景——启动路径上无条件写存储 + 崩溃重启 = 写循环;NOR 页寿命典型 10 万次,指数级消耗。示例的 value > 10 保护分支就是示范对策。解决方案:启动写入必须节流(计数上限、版本比对、写前判断值是否真的变化);把"写"从崩溃恢复路径上挪走(先确认系统健康再持久化状态);监控写入量(对 counter 类 key 读出频率做遥测)。设计原则:存储写入是昂贵操作,按"每页总擦写预算"规划写入频率——例如每秒写一次的单 key 会在一天内消耗约 8.6 万次(整理放大后更多),这种频率根本不该走 flash。

Q4:两个任务同时写存储会不会互相破坏?原子性边界在哪里?

成因/边界:storage::lock() 的 Mutex 保证同一固件内互斥,独立 get/insert 各自原子,但"读-改-写"跨两次锁——并发下丢失更新。解决方案:读改写一律走 lock() 临界区(示例第二段的模式);进一步收窄——把某组 key 的所有访问收敛到一个专属任务(actor 模式),其它任务经 Channel 请求,临界区边界自然消失。边界外的事项:lock() 不保护"跨重启的原子性"(断电写入由 sequential-storage 的追加日志保证到"条目级"——要么完整新值要么旧值,不会半条);也不保护跨固件的并发(不存在这种场景)。理解三层原子性:条目级(框架保证)、临界区级(lock() 提供)、业务级(应用自己设计)。

Q5:能不能把存储当文件系统用,放固件包、日志文件这种大数据?

成因:当前 API 是键值 + postcard,value 尺寸受单条目与整理缓冲约束,大数据会放大擦写;Ariel OS 也没有文件系统抽象(ROADMAP 方向)。解决方案:大数据暂走两路——小固件块(OTA 分片)用 [u8; N] 定长分片 key(ota/0、ota/1...)加元数据 key 记录进度,但要先做写入预算(数十 KiB 固件 x 分片数对页寿命的影响);或者等/贡献 ariel-os-update-transports 方向的专用机制。原则重申:storage 的甜区是"小、低频、结构化"状态——配置、密钥、计数器;连续大块数据不是它的战场。

面试考点

考点 1:日志结构键值存储为什么断电安全?描述一次"写入中途断电"后的读取过程。

参考答案:追加写不覆盖旧数据:insert 总是在日志尾部写"新条目"(key + postcard value + 完整性信息),旧条目原样保留。写入中途断电:新条目不完整,读取时 sequential-storage 从日志尾部向前扫描,遇到不完整/校验失败的尾部条目将其视为无效(下次整理时回收),继续向前找到该 key 最近的有效条目返回——结果要么是完整新值要么是旧值,绝无中间态。页满触发整理(compact):把所有 key 的最新有效条目搬到新页,旧页整页擦除——擦除中途断电同样安全,因为数据已在新页。这就是"断电可恢复"承诺的机制基础,代价是写放大与至少两页的空间预留。

考点 2:为什么 (N-1) x PAGE_SIZE 是可用空间?存储区为何要求页大小均匀且位于 rodata 之后?

参考答案:N 页中至少 1 页必须充当整理时的目标页(搬家目的地),否则页满无法回收旧条目——可用上限是 N-1 页,实际还要扣掉日志管理结构。"页大小均匀"是整理算法的前提:整理按整页搬移与擦除操作,页不等大则搬移计划无法静态确定。"位于 rodata 之后"是链接期布局:固件从 flash 起点向上长,存储区顶部对齐于固定末尾(或按容量),__storage_start/__storage_end 符号由链接脚本按"固件结束后、页对齐"推导。三者共同换来一个保证:固件增长撞入存储区会在链接期暴露(空间不足即链接失败),而不是运行时踩坏数据。

考点 3:postcard 作为存储序列化格式的利弊?与 JSON/protobuf 相比在嵌入式键值场景的取舍?

参考答案:利:紧凑(变长整数、无字段名、无 padding)——flash 字节即寿命;编解码零依赖堆(配合 heapless);与 serde 生态无缝(任何 serde 类型直接可用);编解码 CPU 开销极小。弊:无自描述性——读写类型强耦合,类型演进(加字段)后旧数据无法自解释地迁移(本篇乱码现象的根源);不可人工读(调试要 Hex 对照);无跨语言 schema 契约。对比:JSON 可读但体积大且需要堆或精心设计的流式解析;protobuf 有 schema 演进机制(字段号)但生成代码与 no_std 集成成本高。Ariel OS 选 postcard 押注"值小、类型由代码锁定"的键值场景,schema 演进责任交给应用(版本字段 + 显式迁移)。

考点 4:storage::lock() 提供的原子性与 sequential-storage 的条目原子性是什么关系?举一个两者边界外的问题。

参考答案:条目原子性(框架层):单次 insert 是追加写,断电要么完整要么无效——保护"单条目一致性",跨断电有效。lock() 临界区(应用层):Mutex 保证同一固件内多任务互斥,保护"读-改-写序列"不被并发任务撕裂——正常运行期有效,与断电无关。两者正交且都不覆盖的场景:业务级多 key 一致性——写 key A 成功、写 key B 前断电,重启后 A 是新值 B 是旧值(每个条目都原子,但组合状态撕裂)。应对:把关联字段合并为一个结构体单 key 存储(一次 insert 即一条目原子);或设计成"可从 A 单独恢复"的顺序(先写可推导的,后写权威源)。这是所有持久化系统(含数据库)的共同课题,嵌入式键值没有事务,靠数据建模绕开。

考点 5:CoAP coap-server-config-storage 策略依赖 storage 存 EDHOC 私钥,从安全视角评价"私钥存于 NOR flash 键值区"的威胁模型。

参考答案:威胁面:物理攻击者读 flash(拆片/编程器)可直接取走私钥——NOR 无加密、无访问控制;固件级攻击者(恶意任务)经 storage::get 同样能读。缓解因素:EDHOC 私钥是设备身份凭据而非长期 CA 密钥,泄露的影响半径是"冒充该设备";首次启动生成意味着离线预置环节不涉及密钥分发;键值区可与固件同受安全启动保护(恶意固件替换是另一道防线的事)。对比方案:真安全要素应入芯片安全存储(eFuse/secure element/OTP),但支持的芯片面窄、成本高。Ariel OS 的选择是工程平衡:用 storage 提供开箱即用的密钥持久化(比硬编码密钥的 demokeys 强一档),同时把"更高保障"留给硬件安全能力路线。评价:部署在物理可控场景足够, hostile-field 场景需要补强。

小结

本篇把"设备记性"补齐了:sw/storage 模块一行启用,get/insert/lock() 三件套覆盖键值读写与原子读改写;postcard + serde 让任意类型(heapless 字符串、定长字节、自定义结构)直接入库;日志结构与双页布局换来断电安全,代价是写放大与"固件变大会搬家"的布局敏感。反面教材(三种错误类型读法)给出了这条铁律的全部证据:读写同型,配对封装,魔数校验。至此,第 23 篇 CoAP 安全策略、本篇的状态持久化,都指向了同一个未解的问题——这一切代码写完之后,怎么验证它是对的?

下一篇进入测试:embedded-test 框架与硬件在环(HIL)实践。

系列目录

  1. 系列总览:Ariel OS 设计理念与生态全景
  2. 环境搭建:Rust 工具链与 laze 构建系统
  3. laze 元构建系统:contexts、modules 与 builders
  4. 第一个应用:从 minimal 到 hello-world
  5. 板卡描述体系:boards yaml 与结构化配置
  6. 烧录与调试:probe-rs、RTT 与日志查看
  7. 日志系统:defmt 与 ariel-os-log
  8. 调试控制台:shell 与交互调试
  9. 异步执行器:Embassy 集成与任务宏
  10. 内存布局与全局分配器
  11. 时钟体系与启动流程
  12. 引脚与 GPIO:静态配置与运行时切换
  13. 多线程(上):抢占式线程与线程宏
  14. 多线程(下):Channel、Event 与互斥
  15. 线程与异步互操作:multicore 与定时器
  16. 低功耗与电源管理
  17. I2C 实战:总线扫描与设备通信
  18. SPI 与 UART 实战
  19. 传感器框架:sensor API 与注册表
  20. 随机数与熵源
  21. 网络栈(上):初始化、Wi-Fi 与 UDP
  22. 网络栈(下):TCP 客户端与回显服务
  23. 应用协议:HTTP 与 CoAP
  24. USB 实战:串口与 HID 键盘
  25. BLE 实战:广播与扫描
  26. 以太网、设备元数据与身份
  27. 持久化存储
  28. 测试:embedded-test 与硬件在环
  29. 原生目标、基准测试与 Ferrocene
  30. 收官:新增板卡支持、横向对比与路线图
posted @ 2026-10-28 00:45  navysummer_hardware  阅读(8)  评论(0)    收藏  举报