网络栈(下):TCP 客户端与回显服务
本篇是《Ariel OS 深度实战:Rust 库式 IoT 操作系统全解析》系列第 22 篇。上一篇:网络栈(上):初始化、Wi-Fi 与 UDP,下一篇:应用协议:HTTP 与 CoAP,目录见文末。
网络栈(下):TCP 客户端与回显服务
导读
上一篇我们用 UDP 完成了 Ariel OS 联网首秀。UDP 无连接、无可靠性,够用但不够稳:IoT 现实中固件升级、配置下发、与云端的长会话,都需要 TCP 的连接语义。本篇进入网络栈下篇,覆盖两个互补的示例:tcp-client 展示出站连接——设备主动连上公网服务器收发数据,这是遥测上报与命令轮询的标准姿势;tcp-echo 展示入站服务——设备监听端口、接受连接、回显数据,这是调试网络栈与本地协作的瑞士军刀。
两个示例加起来不到 150 行,但背后牵出的概念不少:TCP socket 的缓冲模型(与 UDP 的四缓冲有何不同)、超时与错误恢复、wait_config_up() 的意义、Ok(0) 即 EOF 的流语义、以及嵌入式场景下"每连接重建 socket"的资源哲学。本篇继续以 ESP32-C6-DevKitC-1 为主线板卡,Wi-Fi 凭据仍用占位符 your-ssid/your-password。
版本基线不变:仓库 dev(0.6.0)。两个示例的代码均原样摘自 examples/tcp-client 与 examples/tcp-echo。
为什么需要面向连接的传输
UDP 快而薄,但它把所有麻烦丢给应用:丢包、乱序、重复、拥塞,统统要自己扛。IoT 设备最常见的两类流量——"设备把数据可靠送到云"与"云端把指令完整下发"——在公网环境下几乎必然经过 NAT 与防火墙,UDP 打洞与保活是专家活;而 TCP 的 NAT 映射维护、重传、流控由内核(这里是 smoltcp 用户态协议栈)处理,应用只管读写字节流。
在嵌入式侧,TCP 还有两个工程意义。其一,它是应用协议的地基:HTTP 跑在 TCP 上(下一篇的 reqwless HTTP 客户端就是 TcpClient 的封装),MQTT、TLS 也都建立在 TCP 之上。把 TCP 用顺了,应用协议篇才水到渠成。其二,它是网络栈健康度的试金石:TCP 的握手、序列号、窗口管理对内存与时序最敏感,TCP 通了,UDP 不可能不通;反之 UDP 通而 TCP 断,多半是缓冲或并发数配置问题。
Ariel OS 的 TCP 支持同样来自 embassy-net(经 ariel_os::reexports::embassy_net 使用),Cargo feature 是 tcp。与 UDP 一样,应用代码拿到的是 NetworkStack 句柄,socket 类型直接用 embassy-net 的 TcpSocket——Ariel OS 没有再包一层"便携 socket 抽象",这是一个刻意的设计决策:embassy-net 本身已是跨 MCU 的统一网络 API,Ariel OS 负责的是栈的建立与生命周期,socket 层保持 Embassy 原生生态,所有 embassy-net 文档与示例都适用。
客户端与服务端的资源视角
出站与入站在内存模型上有细微但重要的差别。客户端每发起一个连接需要一个 socket(一对 rx/tx 缓冲);服务端同样是一个 socket 一个连接——embassy-net 没有"监听 socket"与"连接 socket"的分离,accept(1234) 直接在绑定端口的 socket 上等待。这意味着单个 socket 的服务器一次只能服务一个连接,要并发就得多个 socket(下一篇 http-server 用任务池演示了这一点)或者用 TcpClientState 这类共享资源池。理解这个模型,才能解释为什么示例代码的循环结构是"新建 socket → accept → 服务 → 回到循环顶"。
代码案例:TCP 客户端与 TCP 回显
案例 A:tcp-client——连上公网回显服务
Cargo.toml(节选):
[dependencies]
ariel-os = { path = "../../src/ariel-os", features = ["tcp", "time"] }
ariel-os-boards = { path = "../../src/ariel-os-boards" }
embedded-io-async = "0.7.0"
laze.yml:
apps:
- name: tcp-client
selects:
- network
src/main.rs:
// modification of https://github.com/embassy-rs/embassy/blob/main/examples/nrf9160/src/bin/modem_tcp_client.rs
#![no_main]
#![no_std]
use core::str::FromStr as _;
use embedded_io_async::Write as _;
use ariel_os::time::Timer;
use ariel_os::{
log::{info, warn},
net,
reexports::embassy_net,
time::Duration,
};
#[ariel_os::task(autostart)]
async fn tcp_echo() {
let stack = net::network_stack().await.unwrap();
// Increase the buffer size if you want to send bigger packets.
let mut rx_buffer = [0; 256];
let mut tx_buffer = [0; 256];
info!("waiting for interface to come up...");
stack.wait_config_up().await;
loop {
let mut socket = embassy_net::tcp::TcpSocket::new(stack, &mut rx_buffer, &mut tx_buffer);
socket.set_timeout(Some(Duration::from_secs(10)));
info!("Connecting...");
// Connect to https://tcpbin.com/ without using DNS
let host_addr = embassy_net::Ipv4Address::from_str("45.79.112.203").unwrap();
if let Err(e) = socket.connect((host_addr, 4242)).await {
warn!("connect error: {:?}", e);
Timer::after_secs(10).await;
continue;
}
info!("Connected to {:?}", socket.remote_endpoint());
let msg = b"Hello world!\n";
for _ in 0..10 {
#[allow(unused_variables, reason = "log macro sometimes doesn't use this")]
if let Err(e) = socket.write_all(msg).await {
warn!("write error: {:?}", e);
break;
}
// We put trust in the server to return a valid utf8 string
info!("txd: {}", core::str::from_utf8(msg).unwrap());
Timer::after_secs(1).await;
}
Timer::after_secs(4).await;
}
}
案例 B:tcp-echo——本机 1234 端口回显
laze.yml:
apps:
- name: tcp-echo
env:
global:
executor_stacksize_required:
- "16384"
selects:
- network
src/main.rs:
#![no_main]
#![no_std]
use ariel_os::{log::*, net, reexports::embassy_net, reexports::embassy_time};
use embassy_net::tcp::TcpSocket;
use embassy_time::Duration;
use embedded_io_async::Write;
// Setting this to a small value would make packet handling slow and choppy, but would not cause
// packets to be dropped.
const RX_BUFFER_SIZE: usize = 128;
// There is a memory–performance trade-off with these, but small values seem to be working fine for
// this example.
const TX_BUFFER_SIZE: usize = 8;
const RW_BUFFER_SIZE: usize = 8;
#[ariel_os::task(autostart)]
async fn tcp_echo() {
let stack = net::network_stack().await.unwrap();
let mut rx_buffer = [0; RX_BUFFER_SIZE];
let mut tx_buffer = [0; TX_BUFFER_SIZE];
let mut buf = [0; RW_BUFFER_SIZE];
loop {
let mut socket = TcpSocket::new(stack, &mut rx_buffer, &mut tx_buffer);
socket.set_timeout(Some(Duration::from_secs(10)));
info!("Listening on TCP:1234...");
if let Err(e) = socket.accept(1234).await {
info!("accept error: {:?}", e);
continue;
}
info!("Received connection from {:?}", socket.remote_endpoint());
loop {
let n = match socket.read(&mut buf).await {
Ok(0) => {
info!("read EOF");
break;
}
Ok(n) => n,
Err(e) => {
info!("read error: {:?}", e);
break;
}
};
match socket.write_all(&buf[..n]).await {
Ok(()) => {}
Err(e) => {
info!("write error: {:?}", e);
break;
}
};
}
}
}
两个示例的运行命令(Wi-Fi 凭据占位符):
CONFIG_WIFI_NETWORK='your-ssid' CONFIG_WIFI_PASSWORD='your-password' \
laze -C examples/tcp-client build -b esp32-c6-devkitc-1 run
CONFIG_WIFI_NETWORK='your-ssid' CONFIG_WIFI_PASSWORD='your-password' \
laze -C examples/tcp-echo build -b esp32-c6-devkitc-1 run
tcp-echo 的主机侧测试(设备 IP 以 DHCP 分配为准,如 192.168.1.23):
nc 192.168.1.23 1234
hello tcp
hello tcp # 设备回显,按 Ctrl+C 断开
tcp-client 则不需要任何主机配合:它每 10 秒连一次公网 tcpbin.com 的 4242 端口,写 10 次 Hello world!\n 后断开,4 秒后重连。可以到 tcpbin.com 网页上看到设备发来的内容。
逐段解析
wait_config_up:等"配置生效"而非"栈存在"
info!("waiting for interface to come up...");
stack.wait_config_up().await;
tcp-client 特意加了这一行,tcp-echo 没加。这不是疏忽,是两种角色的语义差异。
wait_config_up() 等待的是接口拿到网络配置——IPv4 走 DHCP 时即 DHCP 完成、地址可用。客户端在连接前必须等它:没有源地址(或地址还在协商),connect() 只会白白失败。服务端理论上可以先 bind(端口绑定不依赖本地地址),所以 tcp-echo 省略了这一步;但更稳妥的写法是服务端也等——尤其在静态 IP 直连场景,主机没配好地址前 accept 到的连接没有任何意义。
与上一篇的 network_stack() 对比:network_stack() 返回即代表栈在跑(驱动任务已启动),但链路可能还没 join 上 AP、DHCP 可能还没租到地址;wait_config_up() 之后才真正"可通信"。客户端逻辑应串起两者:先拿句柄,再等配置,再连。
TCP socket 的两缓冲模型
let mut rx_buffer = [0; 256];
let mut tx_buffer = [0; 256];
let mut socket = TcpSocket::new(stack, &mut rx_buffer, &mut tx_buffer);
对比 UDP 的四缓冲(数据 + 元数据各一对),TCP 只要两块数据缓冲——TCP 是字节流协议,没有"每个报文的元数据"概念。缓冲大小直接决定吞吐与内存的折中:rx buffer 决定对端一次能"推"多少数据进来(也决定 TCP 窗口上限),tx buffer 决定本地能缓冲多少待发数据。
tcp-echo 的常量选择非常有教学价值,注释原文值得逐句读:
// Setting this to a small value would make packet handling slow and choppy, but would not cause
// packets to be dropped.
const RX_BUFFER_SIZE: usize = 128;
// There is a memory–performance trade-off with these, but small values seem to be working fine for
// this example.
const TX_BUFFER_SIZE: usize = 8;
const RW_BUFFER_SIZE: usize = 8;
_rx buffer 128 而_tx buffer 只有 8:回显服务的发送速率受"收到多少回多少"限制,8 字节的 tx buffer 意味着应用每轮最多写 8 字节进 socket,协议栈会分多个 TCP 段发出。慢但绝不丢数据——这正是 TCP 与 UDP 的本质差异:UDP 缓冲不足丢包(上一篇的教训),TCP 缓冲不足只是反压(write Future 挂起等待窗口腾空)。这个"小缓冲也能正确工作,只是慢"的观察,是理解流式协议内存模型的钥匙。
connect 与超时
socket.set_timeout(Some(Duration::from_secs(10)));
let host_addr = embassy_net::Ipv4Address::from_str("45.79.112.203").unwrap();
if let Err(e) = socket.connect((host_addr, 4242)).await {
warn!("connect error: {:?}", e);
Timer::after_secs(10).await;
continue;
}
三个细节:
set_timeout(Some(...))给 socket 上全局超时:后续的 connect/read/write 任一操作超过 10 秒返回超时错误。没有超时的嵌入式 TCP 客户端是定时炸弹——对端假死(收到 SYN 无回应、或连接建立后断网)会让connect/read永久挂起,任务泄漏且 socket 占用不还。10 秒是公网 TCP 的合理值。- 示例故意不用 DNS:直接
Ipv4Address::from_str("45.79.112.203")写死 tcpbin.com 的 IP,注释说明"without using DNS"。DNS 是可选 feature(dns),下一篇 http-client 会启用它并展示DnsSocket。写死 IP 让 tcp-client 只需tcpfeature,是最小依赖的教学选择;生产代码建议走 DNS(IP 会变)。 connect失败后Timer::after_secs(10).await再重试——重试退避。网络抖动时的无间隔重试会制造风暴(Wi-Fi 侧表现为 esp-radio 日志刷屏),固定退避是最朴素的工程解。
remote_endpoint() 在连接建立后返回对端地址端口,用于日志确认连到了哪里。
流语义:Ok(0) 与 write_all
服务端读取循环:
let n = match socket.read(&mut buf).await {
Ok(0) => { info!("read EOF"); break; }
Ok(n) => n,
Err(e) => { info!("read error: {:?}", e); break; }
};
Ok(0) 的语义必须刻进肌肉记忆:对 TCP 字节流,read 返回 0 仅在对端关闭写方向(收到 FIN)时发生,它不是"暂时没数据"——没数据时 read 挂起等待。把 Ok(0) 当空转处理(continue 重读)是最经典的死循环 bug。示例正确地把它视为 EOF 并跳出内层循环,回到外层重建 socket 等下一个连接。
发送用 write_all(embedded_io_async::Write trait 提供,Cargo.toml 里那个依赖就是为它)。write 本身允许部分写(返回已写字节数),直接 write 一次就当发完是 bug 源;write_all 循环写满才返回。回显时 socket.write_all(&buf[..n]) 保证客户端发多少回多少——如果用 write,8 字节的 tx buffer 下必然出现部分写,回显就不完整了。
外层循环与 socket 重建
两个示例都有双层循环,与 UDP 回显同构:外层新建 socket,内层服务,出错 break 回外层。对 TCP 多一层含义:内层退出时连接已死(EOF 或错误),socket 对象必须重建——embassy-net 的 TcpSocket 一旦连接关闭就进入 Closed 状态,可以在同一个 socket 上重新 accept/connect,但示例选择整个重建(连缓冲一起),换来的是每个连接从零开始、无历史状态泄漏。TcpSocket::new 只是借用缓冲并构造状态机,重建成本接近零。这种"廉价重建优于状态清理"的模式在下一篇的 HTTP 客户端(每请求新建 handle)里还会再现。
系统侧:并发 socket 资源
CONFIG_NETWORK_MAX_CONCURRENT_SOCKETS(默认 4)决定 embassy-net StackResources 预留的 socket 槽位。tcp-echo 一个 socket 就够;如果你写"多个 TCP 服务端口 + 客户端"的复合应用,记得用环境变量调大它,否则运行时新建 socket 会因资源不足失败。此外本系列反复出现的 executor_stacksize_required: 16384:tcp-client 没声明(官方判断它的调用深度可控),tcp-echo 声明了。自己的应用拿不准就声明——它只占编译期常量,不占运行时资源,最坏情况是 ISR 栈比需要的略大。
运行现象
tcp-client(连接公网):
INFO waiting for interface to come up...
INFO Connecting...
INFO Connected to 45.79.112.203:4242
INFO txd: Hello world!
INFO txd: Hello world!
...(每秒一条,共 10 条)
随后连接关闭,4 秒后重新 Connecting。在 tcpbin.com 的会话页面能看到 10 行 Hello world!。把路由器断网做实验:connect error 后设备按 10 秒节律重试,网络恢复后自动续上——这就是带退避的鲁棒客户端形态。
tcp-echo(本机服务):
INFO Listening on TCP:1234...
INFO Received connection from 192.168.1.10:51444
nc 里输入什么就回显什么(一行一送,因为 nc 按行发送)。断开 nc(Ctrl+C)后设备打印 read EOF,回到 Listening。值得做的实验:
- 并发连接排队:开两个 nc 连过去。第二个会卡住(第一个 socket 正忙,内核未完成握手或未被 accept),第一个退出后第二个立即接上——直观感受"一个 socket 一个连接"。
- 小 tx buffer 的"慢":把
TX_BUFFER_SIZE调回 8 编译版与 1024 版对比nc的回显延迟手感;再观察两种版本下n的分布(把info!加上打印 n)——8 字节版本每轮 read/write 几乎都是 8 字节。 - 超时实验:nc 连上后不输入任何数据,设备侧无输出;用路由器防火墙静默丢弃该连接的包,10 秒后设备打印超时错误并回到监听——
set_timeout生效的现场证明。
常见问题
Q1:connect 总是返回超时或 ConnectionReset,但 UDP 回显在同一板卡上工作正常?
成因排查顺序:对端端口真的开放吗(nc -vz 45.79.112.203 4242 主机侧先验证);写死的 IP 是否已失效(tcpbin 之类公共服务会换 IP,教学示例尤其如此);路由器开了"AP 隔离"导致设备是纯客户端无法发起外部连接的情况较少,但公司 Wi-Fi 的防火墙策略常见;MTU/分片问题在超过一个 TCP 段的大 connect 载荷(TLS ClientHello)时出现,纯 connect 不会。解决方案:先主机验证对端、再换局域网内自建服务端(主机上 nc -l 1234,设备连主机 IP,把三个变量一次隔离干净);公网目标改用域名 + dns feature。
Q2:设备能 connect 成功,但 write 几次后卡死或报错?
成因:典型是对端不读导致的 tx 窗口耗尽——TCP 有流控,对端接收窗口为 0 时 write 永久挂起(有超时则超时报错);另一个常见成因是缓冲太小且单次 write 的数据大于缓冲,write_all 需要多轮但代码在外层没给它机会(例如把 write 放进了被 cancel 的 select 分支)。解决方案:一是 set_timeout 兜底(本篇示例的标配),二是发送侧对大数据分块写并尊重 write 的部分写语义(或统一用 write_all),三是服务端读循环不要"写完才读"造成死锁——回显模式(读 n 写 n)天然安全。
Q3:accept 之后 read 一次就 read EOF,客户端明明还开着?
成因:客户端主动关了连接(nc 退出、程序 close),或对端发了 RST——TCP 半关闭下对端关闭写方向就是本地 read 的 EOF;也可能是主机侧 VPN/代理劫持连接后立即断开。解决方案:先看 remote_endpoint() 日志确认对端是不是预期主机;nc 的行为是 stdin 关闭即关连接,测试时保持 nc 开着;如果用脚本测试,注意脚本退出即断连是正常现象不是 bug。设备侧代码对 EOF 的正确反应就是本篇示例这样:记日志、回外层等下一个连接。
Q4:想把 tcp-echo 改成同时服务多个连接,最小改动是什么?
成因/背景:单 socket 单连接是 embassy-net 的资源模型决定的。最小改动有两个方向。方向一:多个任务,每个任务自持一对静态缓冲与 socket,都 accept(1234)——embassy-net 允许多个 socket 绑定同一端口(内核按连接四元组分发,类似 SO_REUSEPORT),每个任务服务一个并发连接;并发数受 CONFIG_NETWORK_MAX_CONCURRENT_SOCKETS 限制,记得调大。方向二:下一篇 http-server 的任务池模式——pool_size = N 的非 autostart task 池,主任务 spawn N 份服务任务共享监听。两个方向都不需要互斥锁:socket 是各自任务的私有数据,栈句柄 Stack 本身可共享。
Q5:tcp-client 为什么不需要声明 executor_stacksize_required,而 tcp-echo 需要?我自己写客户端要加吗?
成因:官方示例的声明反映了对调用深度的保守估计,不是硬性规则。tcp-echo 的读写循环 + 日志宏(defmt 格式化帧较占栈)叠加被认为需要更多余量;tcp-client 逻辑更线性。但这不是精确科学——真实需求取决于日志级别、DNS/TLS 叠加(下一篇的 http-client 直接要 65536)、以及编译器优化。解决方案:默认照抄同类官方示例的声明;怀疑栈不足时用第 10 篇的栈绘制(ariel_os_rt::stack 模块)实测水位,overflow 的症状(ISR 栈区的 hard fault)与网络逻辑错误容易混淆,宁可早声明。
面试考点
考点 1:对比 embassy-net TCP 与 UDP socket 的缓冲模型,并说明缓冲大小分别影响什么。
参考答案:UDP socket 需要四块缓冲:rx/tx 数据缓冲加 rx/tx 元数据缓冲(PacketMetadata 数组),元数据逐报文记录源端点等信息,数据缓冲大小决定单个报文上限,元数据槽数决定可缓存的报文数,超限报文被丢弃(UDP 无流控)。TCP socket 只要 rx/tx 两块数据缓冲:TCP 是字节流,缓冲不足表现为反压(tx 挂起等待窗口、rx 窗口收缩),不会丢数据,但影响吞吐与 TCP 窗口上限。因此 UDP 缓冲要按"最大报文 + 突发深度"设计,TCP 缓冲按"带宽时延积 + 内存预算"设计,小缓冲的 TCP 依然正确只是慢——tcp-echo 的 8 字节 tx buffer 就是活教材。
考点 2:为什么嵌入式 TCP 客户端必须设置超时?没有超时会发生什么连锁反应?
参考答案:TCP 的多类故障是无信号的:SYN 无响应(防火墙静默丢弃)、连接后对端假死、路径 MTU 黑洞——这些状态下 connect/read/write 会永久挂起。连锁反应:持有 socket 的任务泄漏(Future 永远不完成);静态缓冲被这个 socket 永久占用(内存泄漏);若该任务还负责系统级职责(如遥测上报),整个业务链路静默死亡,而设备日志毫无异常——这类"活的尸体"在生产 IoT 中极难排查。set_timeout(Some(...)) 把所有操作变成有限时间内的成功或错误,配合重试退避构成最小健壮客户端。更完备的方案是 embassy 的 with_timeout 组合单操作超时。
考点 3:解释 network_stack() 与 wait_config_up() 两级等待的区别,客户端与服务端分别应该怎么用?
参考答案:network_stack() 返回 Option<NetworkStack> 的 Future,代表"栈已初始化、句柄可用",但链路层(Wi-Fi join)与网络配置(DHCP)可能尚未完成;wait_config_up() 等待接口配置生效(拿到 IP 地址),之后端到端通信才可靠可用。客户端必须在 connect 前等 config up:没有本地配置时连接必失败。服务端可以更早 accept(端口绑定不依赖地址),但依赖收到连接才有意义,稳妥做法是同样等待。两者都用异步等待而非轮询,系统侧由 embassy-net 的配置状态机唤醒。设计层面这是"资源就绪"与"服务就绪"两级状态机的经典分离。
考点 4:Ok(0) 在 embedded-io-async 的 read 上意味着什么?把它当作"暂无数据"处理会导致什么?
参考答案:对字节流接口,read 返回 Ok(0) 仅表示流已到达末尾(对端关闭了该方向,本地收到 FIN),绝不是"暂时没数据"——没数据时 read 返回挂起的 Future,数据到达才完成。若把 Ok(0) 当空转(continue 重读),代码会以极高频率轮询已关闭的连接,永不退出:CPU 空转、看门狗可能饿死、任务永远不释放 socket。正确处理是立即视作 EOF 收尾。这个语义沿袭自 POSIX read/Rust std::io::Read 的约定,embedded-io-async 保持一致,是所有嵌入式流处理代码的第一道考点。
考点 5:Ariel OS 的 TCP 服务端为何没有"监听 socket 与连接 socket 分离"的抽象?这种设计的利弊是什么?
参考答案:embassy-net 的 TcpSocket::accept(port) 直接在 socket 上监听并完成握手,一个 socket 一次服务一个连接。利:资源模型极简——socket 数等于并发连接数,每份资源(缓冲)显式对应一条连接,静态内存预算一目了然,符合 no_std 嵌入式"没有隐藏动态资源"的原则;实现上省去 backlog 队列与半连接池。弊:没有内核级监听队列,未 accept 的连接依赖协议栈的握手缓存,并发服务要靠多 socket/多任务显式扩展,且 CONFIG_NETWORK_MAX_CONCURRENT_SOCKETS 成为硬上限。对于 MCU 上的轻量服务(几个并发封顶),这个取舍是合理的;对比 Linux 的 listen backlog,是"能撑多大"与"内存花在哪"的典型权衡。
小结
本篇用 tcp-client 与 tcp-echo 两个官方示例补全了 Ariel OS 的 TCP 版图:出站客户端的三件套(wait_config_up → set_timeout → 带退避重试的 connect),入站服务端的双层循环与 EOF 处理,以及贯穿两者的 TCP 缓冲模型——小缓冲只慢不丢,反压代替丢包。TCP 通了,下一篇的应用协议就只剩"在字节流上讲什么话":HTTP 客户端(reqwless + TLS)、picoserve HTTP 服务与 CoAP 的加密通信,都会以本篇的 socket 功底为地基。
系列目录
- 系列总览: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号