持久化存储
本篇是《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)实践。
系列目录
- 系列总览:Ariel OS 设计理念与生态全景
- 环境搭建:Rust 工具链与 laze 构建系统
- laze 元构建系统:contexts、modules 与 builders
- 第一个应用:从 minimal 到 hello-world
- 板卡描述体系:boards yaml 与结构化配置
- 烧录与调试:probe-rs、RTT 与日志查看
- 日志系统:defmt 与 ariel-os-log
- 调试控制台:shell 与交互调试
- 异步执行器:Embassy 集成与任务宏
- 内存布局与全局分配器
- 时钟体系与启动流程
- 引脚与 GPIO:静态配置与运行时切换
- 多线程(上):抢占式线程与线程宏
- 多线程(下):Channel、Event 与互斥
- 线程与异步互操作:multicore 与定时器
- 低功耗与电源管理
- I2C 实战:总线扫描与设备通信
- SPI 与 UART 实战
- 传感器框架:sensor API 与注册表
- 随机数与熵源
- 网络栈(上):初始化、Wi-Fi 与 UDP
- 网络栈(下):TCP 客户端与回显服务
- 应用协议:HTTP 与 CoAP
- USB 实战:串口与 HID 键盘
- BLE 实战:广播与扫描
- 以太网、设备元数据与身份
- 持久化存储
- 测试:embedded-test 与硬件在环
- 原生目标、基准测试与 Ferrocene
- 收官:新增板卡支持、横向对比与路线图

浙公网安备 33010602011771号