驱动接口
主线位置:中断主线在网络栈里的样子。
Runner调驱动的receive,没有帧,驱动把 waker 存进一个AtomicWaker;以太网控制器收到一帧、触发中断,bind_interrupts!接上的处理函数只做一件事:叫醒那个 waker;Runner被 poll,再调receive,这次拿到令牌。
sequenceDiagram participant Sm as smoltcp participant Ad as DriverAdapter participant Dr as Driver participant Hw as ETH 中断 Sm->>Ad: phy receive Ad->>Dr: receive cx Dr-->>Ad: None 登记 waker Hw->>Dr: on_interrupt wake Sm->>Ad: phy receive Ad->>Dr: receive cx Dr-->>Ad: Some rx tx 令牌 Sm->>Ad: rx consume Ad->>Dr: RxToken consume Dr-->>Sm: 帧字节
embassy-net-driver 0.2.0 只有一个文件,217 行,没有依赖(defmt 可选)。它 2023-10-18 发布以后没有再发过新版本(之后仓库里只有改 edition、rustfmt 这样的提交),embassy-net 从 0.2.0 到 0.9.1 的每个版本都依赖它。README 里说得很清楚:驱动只依赖这个 crate 或 embassy-net-driver-channel,永远不依赖 embassy-net 本身,这样 embassy-net 发新的大版本时,驱动不用跟着发。本页的 std 实验在 exp-embassy/net-std(release,M2 Max、macOS 27、rustc 1.99.0),QEMU 实验在 exp-embassy/net-qemu(QEMU 11.1.2 b-l475e-iot01a,embassy-stm32 用注释掉 rcc/bd.rs 里 while !cr.read().dbp() {} 那一行的补丁版本,时间驱动是模板里的 1 kHz SysTick 驱动)。
Driver:令牌,而不是缓冲区
pub trait Driver {
type RxToken<'a>: RxToken
where
Self: 'a;
type TxToken<'a>: TxToken
where
Self: 'a;
fn receive(&mut self, cx: &mut Context) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)>;
fn transmit(&mut self, cx: &mut Context) -> Option<Self::TxToken<'_>>;
fn link_state(&mut self, cx: &mut Context) -> LinkState;
fn capabilities(&self) -> Capabilities;
fn hardware_address(&self) -> HardwareAddress;
}
Driver 的五个方法里,带 cx 的三个是同一个约定:能做就返回 Some,不能做就返回 None,并且在能做的时候叫醒 cx.waker()。这个 waker 是 Runner 任务的(上一页 Inner::poll 把同一个 cx 一路传下来),驱动叫醒它,Runner 就会再 poll 一次栈。
receive / transmit 不收发数据,只返回令牌。真正的收发在令牌被消费时发生:
pub trait RxToken {
fn consume<R, F>(self, f: F) -> R
where
F: FnOnce(&mut [u8]) -> R;
}
pub trait TxToken {
fn consume<R, F>(self, len: usize, f: F) -> R
where
F: FnOnce(&mut [u8]) -> R;
}
RxToken::consume 把驱动自己缓冲区里的那一帧借给闭包;TxToken::consume 给闭包一块 len 字节的空缓冲区,让协议栈直接在里面拼帧,闭包返回后驱动把它发出去。两个方向都不拷贝:帧一直在驱动的内存里(DMA 描述符环、SPI 芯片的缓冲区),协议栈原地读写。consume 拿走 self,一个令牌只能用一次。
两条容易漏掉的要求,都写在 trait 的文档里:
receive同时返回一个发送令牌,「makes it possible to generate a reply packet based on the contents of the received packet」:smoltcp 处理一帧时可能要立刻回一帧(ARP 应答、ICMP echo、TCP 的 RST),手里已经有发送令牌,就不用先把收到的内容拷出来。所以驱动只有在收、发两边都有空位时才能返回Some。TxToken::consume不能失败,所以「it is not allowed to return a token if there is no free space and fail later」。
link_state 要在链路状态变化时叫醒;capabilities 报告 MTU、一次最多几帧、哪些校验和由硬件算;hardware_address 返回 Ethernet([u8; 6])、Ieee802154([u8; 8]) 或 Ip,介质也由它决定:embassy-net 的 to_smoltcp_hardware_address 把 Ethernet 映射成 smoltcp 的 Medium::Ethernet(要做 ARP),Ip 映射成 Medium::Ip(不做邻居发现),对应的 feature 没开就 panic。
0.1.0 的 Capabilities 第一个字段是 pub medium: Medium,另有一个 fn ethernet_address(&self) -> [u8; 6]。一个 IP 介质的驱动(PPP、tun)也得返回一个 MAC 地址,802.15.4 的 8 字节地址放不进去。0.2.0 之前的几个提交先加了 HardwareAddress,最后 #2076 里的「net/driver: remove Medium, make HardwareAddress non_exhaustive.」删掉了 Medium 枚举和 ethernet_address。0.2.0 的 CHANGELOG(2023-10-18)记作「Removed Medium enum. The medium is deduced out of the hardware address.」。现在介质跟着地址走,embassy-net 每次 Inner::poll 都调一次 hardware_address(),重新算介质:地址可以在运行时被驱动换掉(embassy-net-driver-channel 有 set_hardware_address),以太网和 802.15.4 介质每次都把新地址写进 smoltcp。
DriverAdapter:接到 smoltcp 上
smoltcp 有自己的设备 trait phy::Device,形状几乎一样,只是不带 cx。DriverAdapter 把 Inner::poll 的 cx 存在自己身上,在 smoltcp 调 receive / transmit 时转交给驱动:
fn receive(&mut self, _timestamp: Instant) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)> {
self.inner
.receive(unwrap!(self.cx.as_deref_mut()))
.map(|(rx, tx)| (RxTokenAdapter(rx), TxTokenAdapter(tx)))
}
cx 是 Option:new 里建 Interface 时也要传一个设备,那时没有 Context,传的是 None。smoltcp 在 Interface::new 里读一次 capabilities 存起来,之后的 MTU、校验和设置都用这份,驱动后来改 capabilities 的返回值不起作用;那一次不会收发。transmit 里多记一个 tx_exhausted(上一页的 #5870)。capabilities 把 embassy-net-driver 的类型逐个字段换成 smoltcp 的,medium 由 hardware_address 算出来填进去。
令牌适配器里有 packet-trace feature 的两行:
#[cfg(feature = "packet-trace")]
trace!("embassy device rx: {:02x}", buf);
{:02x} 对 defmt 有效,对 core::fmt 的 LowerHex 无效。同时开 packet-trace 和 log:
error[E0277]: the trait bound `[u8]: LowerHex` is not satisfied
--> ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/embassy-net-0.9.1/src/driver_util.rs:92:49
|
92 | trace!("embassy device rx: {:02x}", buf);
| ------ ^^^ the trait `LowerHex` is not implemented for `[u8]`
| |
| required by this formatting parameter
...
error: could not compile `embassy-net` (lib) due to 2 previous errors
(另一个在 :113:49,发送方向。)只开 packet-trace 不开 log 能编过,那时 trace! 什么也不展开。原来的写法是 trace!("rx: {:?}", buf),2024-11-01 的提交 af694d233「chore: improve some log msgs」(#3488,首次进入 0.5.0)改成了 {:02x}。Cargo.toml 里的 CI 构建矩阵只在 defmt 下开过 packet-trace,所以一直没被发现。main 上 driver_util.rs 已经随 smoltcp 一起被换掉了。
手写一个驱动
本部分 std 实验用的 PipeDriver(net-std/src/lib.rs)把两个栈背靠背接起来:每个方向一个有界的帧队列 Port,带两个 waker 槽,consumer(等帧的 Runner)和 producer(等空位的 Runner):
fn receive(&mut self, cx: &mut Context) -> Option<(PipeRx<'_>, PipeTx<'_>)> {
let frame = self.inbox.has_frame();
let space = self.outbox.has_space();
if frame && space {
Some((
PipeRx { inbox: &self.inbox, stats: self.stats },
PipeTx { outbox: &self.outbox, stats: self.stats },
))
} else {
if !frame {
*self.inbox.consumer.borrow_mut() = Some(cx.waker().clone());
}
if !space {
*self.outbox.producer.borrow_mut() = Some(cx.waker().clone());
}
None
}
}
fn transmit(&mut self, cx: &mut Context) -> Option<PipeTx<'_>> {
if self.outbox.has_space() {
Some(PipeTx { outbox: &self.outbox, stats: self.stats })
} else {
*self.outbox.producer.borrow_mut() = Some(cx.waker().clone());
None
}
}
(计数的几行删掉了。)PipeTx::consume 把帧推进对端的 inbox,叫醒对端的 consumer;PipeRx::consume 弹出一帧,叫醒对端的 producer。缺了哪一次叫醒,哪一边就会睡死:Runner 只在 waker 被叫醒或定时器到期时才会再来调 receive。link_state 永远返回 Up;capabilities 设 MTU。
Capabilities 派生了 Default,max_transmission_unit 的默认值是 0。把 PipeDriver 的 MTU 设成 0(相当于 capabilities 直接返回 Capabilities::default()),两个栈之间跑 TCP,接收方缓冲区 16384 字节,release 构建。平均每个数据帧多大?debug 构建呢?答案在本页末尾。
真实的驱动:stm32 以太网
embassy-stm32 0.6.0 的以太网驱动(eth/)直接实现 Driver,不经过 channel。它的 receive:
fn receive(&mut self, cx: &mut Context) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)> {
WAKER.register(cx.waker());
if self.rx.available().is_some() && self.tx.available().is_some() {
Some((RxToken { rx: &mut self.rx }, TxToken { tx: &mut self.tx }))
} else {
None
}
}
rx、tx 是两个 DMA 描述符环,available() 看下一个描述符是不是已经归 CPU 所有。WAKER 是文件级的 static AtomicWaker,收、发、每次调用都往里登记(先登记再检查,避免检查之后、登记之前来的中断被漏掉)。中断处理函数(v2 的 on_interrupt):
unsafe fn on_interrupt() {
WAKER.wake();
// TODO: Check and clear more flags
let dma = ETH.ethernet_dma();
dma.dmacsr().modify(|w| {
w.set_ti(true);
w.set_ri(true);
w.set_nis(true);
});
// ...
}
叫醒 waker、清掉 DMA 的收发完成标志,没有别的。和 GPIO 与 EXTI 里 ExtiInput 的处理函数是同一个形状:中断里不做事,只把任务叫起来,事情留给任务在线程模式里做。收、发共用一个 waker 槽没有问题,因为两边等的是同一个任务(Runner)。
链路状态来自 PHY 芯片,要通过 MDIO 总线去读寄存器,没有中断。GenericPhy::poll_link:
fn poll_link(&mut self, cx: &mut Context) -> bool {
#[cfg(not(feature = "time"))]
cx.waker().wake_by_ref();
#[cfg(feature = "time")]
let _ = Timer::after(self.poll_interval).poll_unpin(cx);
开了 time feature,它用和 Runner 一样的办法给自己设一个闹钟:建一个 Timer::after(poll_interval)(默认 500 ms),poll 一次就扔掉。没开 time,它每次都 wake_by_ref,Runner 刚 poll 完就又被叫醒。net-std 的 PipeDriver 可以在 link_state 里模仿这两种写法,两个栈都空闲,数 1 s 里的 poll 次数:
== phy mode 0
idle: in 1 s, A polled 0 times, B polled 0 times, frames A->B 0, B->A 0
== phy mode 1
idle: in 1 s, A polled 2 times, B polled 2 times, frames A->B 0, B->A 0
...
== phy mode 2
idle: in 1 s, A polled 5210873 times, B polled 5210872 times, frames A->B 0, B->A 0
模式 1(500 ms 定时器)每秒 2 次;模式 2(wake_by_ref)每秒 521 万次。真板子上 embassy-stm32 的 time feature 通常会被 time-driver-* 间接打开(_time-driver 依赖 time),只有不用 embassy 时间驱动的程序才会落到第二种。
tuntap:只在 Linux 上
embassy-net-tuntap 0.1.1(tag embassy-net-tuntap-v0.1.1,2026-05-13 发布,比本卷锁定的其他 crate 都晚,新项目解析到的就是它)是 std 上的驱动,打开 Linux 的 /dev/net/tun,用 async-io 的 Async 等 fd 可读。它的 receive 每次分配一个 MTU 大小的 Vec,read 返回 WouldBlock 时 poll_readable(cx) 登记 waker;transmit 永远返回 Some,TxToken::consume 里 write 遇到 WouldBlock 就打一行 info!("transmit WouldBlock"),这一帧丢掉。这正是 trait 文档不允许的「return a token if there is no free space and fail later」,在测试用的 tap 设备上无伤大雅,丢掉的帧由 TCP 重传补上。
它用到 libc::TUNSETIFF 这些 Linux 专有的常量,在 macOS 上编不过:
error[E0425]: cannot find value `IFF_TAP` in crate `libc`
--> ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/embassy-net-tuntap-0.1.1/src/lib.rs:75:36
|
75 | ifreq.ifr_data = libc::IFF_TAP | libc::IFF_NO_PI;
| ^^^^^^^
...
error: could not compile `embassy-net-tuntap` (lib) due to 3 previous errors
本机没有 tun 设备,所以本卷的实验都用进程内的驱动。
embassy-net-driver-channel:把驱动放进自己的任务
实现 Driver 意味着把收发状态机写成「poll 一下、没好就登记 waker」的样子。对 SPI 接的网卡芯片(W5500、ENC28J60)、WiFi 模块(cyw43、esp-hosted)、USB 网卡(CDC-NCM)来说,收发本身就是一串 .await(SPI 传输、等芯片中断引脚),写成同步的 poll 状态机很别扭。embassy-net-driver-channel 0.4.0(410 行)换了一个形状:驱动作者写一个普通的 async 任务,和 embassy-net 之间用两个帧队列交换数据。
State<MTU, N_RX, N_TX> 是 N_RX + N_TX 个 PacketBuf<MTU>(len 加 [u8; MTU])和一个延迟初始化的内部结构:
struct StateInner<'d, const MTU: usize> {
rx: zerocopy_channel::Channel<'d, NoopRawMutex, PacketBuf<MTU>>,
tx: zerocopy_channel::Channel<'d, NoopRawMutex, PacketBuf<MTU>>,
shared: Mutex<NoopRawMutex, RefCell<Shared>>,
}
两条 zerocopy_channel:发送方借出一个空槽原地写、send_done 交出去,接收方借出一个满槽原地读、receive_done 还回来。Shared 放链路状态、硬件地址和一个 waker。new 返回两半:
Device:交给embassy_net::new的那一半,实现Driver。它的receive是self.rx.poll_receive(cx).is_ready() && self.tx.poll_send(cx).is_ready():RX 队列里有帧、TX 队列里有空槽,才给令牌;transmit只看 TX 有没有空槽。令牌的consume用try_receive/try_send借出槽、调闭包、receive_done/send_done。Runner:驱动作者的那一半,split()成StateRunner(设链路状态、硬件地址)、RxRunner(rx_buf().await借一个空槽,写入收到的帧,rx_done(len))、TxRunner(tx_buf().await借出一个待发的帧,发出去,tx_done())。
队列和锁都是 NoopRawMutex,所以 Device 也不是 Send:
error[E0277]: `*mut ()` cannot be shared between threads safely
--> src/bin/sendcheck.rs:5:17
|
5 | assert_send::<embassy_net_driver_channel::Device<'static, 1514>>();
...
= note: required for `Mutex<NoopRawMutex, RefCell<Shared>>` to implement `Sync`
驱动任务、Runner 任务和用套接字的任务都在同一个执行器上,这和上一页 Stack 的要求一致。
TxRunner::tx_buf 的文档写着「Wait until there is space for more outbound packets and return a slice they can be copied into」,代码是 self.tx_chan.receive().await,等的是一个待发出的帧,返回的切片是要读出去的内容,不是要写进去的空间。文档是从 rx_buf 抄过来的。
用 channel 接两个栈
net-std/src/bin/chan.rs:两个 State<1514, 4, 4>,各接一个栈;每个方向一个 wire 任务,从一边的 TxRunner 取帧、拷进另一边的 RxRunner:
#[embassy_executor::task(pool_size = 2)]
async fn wire(dir: usize, mut tx: ch::TxRunner<'static, MTU>, mut rx: ch::RxRunner<'static, MTU>) {
loop {
let frame = tx.tx_buf().await;
let len = frame.len();
let dst = rx.rx_buf().await;
dst[..len].copy_from_slice(frame);
rx.rx_done(len);
tx.tx_done();
FRAMES[dir].fetch_add(1, Ordering::Relaxed);
}
}
一个真正的驱动任务长得差不多,只是一头换成 SPI / USB:embassy-usb 的 CDC-NCM 适配(class/cdc_ncm/embassy_net.rs)就是 rx_buf → read_packet → rx_done 和 tx_buf → write_packet → tx_done 两个循环 select 在一起(见 USB 类)。A 往 B 发 200 MB(接收缓冲区 65535 字节,三次):
link_up set: true; A is_link_up true is_config_up true
connect Ok(()) in 17 us
server: 200000000 bytes in 0.076 s = 2631.9 MB/s, frames A->B 137335, B->A 34335
...
server: 200000000 bytes in 0.077 s = 2608.3 MB/s, frames A->B 137335, B->A 34335
和下一页 PipeDriver 队列深度 4 的结果几乎一样(2.5 GB/s,ACK 帧数是数据帧的四分之一),多出来的那次拷贝在这个量级上看不出来。
问: channel 的链路状态初始是 LinkState::Down。忘了调 set_link_state(Up),TCP 还通吗?
答: 通。同一个程序不设链路状态,is_link_up() 是 false,connect 照样成功,1 MB 照样送到(link_up set: false; A is_link_up false is_config_up true / connect Ok(()) in 12 us)。embassy-net 里链路状态只影响三件事:wait_link_up / wait_link_down 什么时候返回、DHCP 套接字什么时候工作(链路 down 时不 poll 它,从 up 变 down 时清掉租到的地址)、is_link_up() 的返回值。smoltcp 本身不知道链路状态,静态地址下它照常收发。
README 里还有一段「A note about deadlocks」:一个驱动任务如果先等「芯片有帧」、再 rx_buf().await 等 RX 空槽,满负荷时会卡住。原因就在 Device::receive 的那个 &&:TX 队列满了,embassy-net 不收 RX 队列里的帧;驱动任务卡在等 RX 空槽,又不去清 TX 队列。README 给的解法是在等 RX 空槽的同时继续服务 TX。上面的 wire 每个方向一个任务,A→B 的任务等 B 的 RX 空槽时,B→A 的任务还在搬另一个方向的帧;net-std/src/bin/chanbidi.rs 让两边同时互发 20 MB(队列深度 1 和 2,各三次)都跑完了,但这只说明没撞上,不证明不会。
在 Cortex-M4 上多大
net-qemu/src/bin/netecho.rs 把同样的两栈结构搬到 QEMU 上:两个 State<1514, 4, 4>、两个 StackResources<2>、四块 2 KB 的 TCP 缓冲区,全是 static;embassy-net 只开 tcp、medium-ethernet、proto-ipv4,opt-level = "s":
size_of: StackResources<2> 1144, ch::State<1514,4,4> 12264, Stack 4, Runner 36, TcpSocket 8
connected after 6 ms
100 echo round trips of 32 B in 475 ms
- 一个
State<1514, 4, 4>是 12264 字节,几乎全是 8 个PacketBuf(每个 1514 + 4 字节)。帧缓冲区是网络栈最大的一块内存,N_RX、N_TX每加一,多 1.5 KB。 llvm-size:只有两个定时任务的模板程序.text14624 字节,两栈 echo 程序 55968 字节,多出的 41 KB 是 smoltcp(TCP、IPv4、ARP)、embassy-net、driver-channel 和 echo 程序自己的几个任务。.bss36144 字节,最大的符号是STATE(0x5fd8 = 24536)、四块套接字缓冲区(各 0x800)、RES(0x900)。- 这一次用了
-icount shift=8,sleep=off(每条指令 256 ns 虚拟时间,SysTick 按虚拟时间走):100 次 32 字节的往返 475 ms,一次往返约 4.75 ms,约合 1.9 万条指令,包括两个栈、两个wire任务和执行器。这是 QEMU 的指令计数,不是真实芯片的时间。
动脑筋答案
net-std 的 tput 0 <缓冲区> <字节数>,MTU 设成 0:
4096 B 1250.0 MB/s 24 data frames, avg 4050 B 24 ack frames polls A 48 B 49 0.000 s
16384 B 3246.8 MB/s 61 data frames, avg 16178 B 61 ack frames polls A 122 B 123 0.000 s
- release:缓冲区 16384 时平均每帧 16178 字节(缓冲区 4096 时是 4050)。smoltcp 的
ip_mtu对以太网是self.max_transmission_unit - 14,0 减 14 在 release 下回绕成一个接近usize::MAX的数,MTU 等于没有限制,一个 TCP 段只受对端接收窗口约束。进程内的PipeDriver什么帧都收,真网卡会把超过 1514 字节的帧丢掉,表现为「连接能建上,一发数据就卡住」。 - debug:
thread 'main' (4929030) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/smoltcp-0.13.1/src/phy/mod.rs:286:17:
attempt to subtract with overflow
同一行减法,debug 下溢出检查直接 panic。写驱动时 Capabilities::default() 之后一定要设 max_transmission_unit(以太网是 IP MTU 加 14,通常 1514)。
小结
Driver只交令牌:receive要收、发两边都有空位才返回Some,transmit必须真有空位;返回None时登记cx.waker(),能做时叫醒它,那是Runner的 waker。- 介质由
hardware_address决定(#2076,0.2.0),embassy-net每次 poll 都重新读一次。 - stm32 以太网驱动:
receive往一个static AtomicWaker登记,中断处理函数只wake和清标志;PHY 链路靠 500 ms 定时器轮询,没开time时wake_by_ref,空闲时每秒 poll 五百多万次。 packet-trace+log从 0.5.0 起编不过({:02x}只对 defmt 有效)。Capabilities::default()的 MTU 是 0:debug 下attempt to subtract with overflow,release 下帧长不受限。embassy-net-driver-channel:两条NoopRawMutex的zerocopy_channel,驱动作者写rx_buf/rx_done、tx_buf/tx_done;Device不是Send;State<1514, 4, 4>在 Cortex-M4 上 12264 字节。- 链路状态只影响 DHCP 和
wait_link_*,静态地址下链路 down 也能通信。 - tuntap 驱动只在 Linux 上编得过,
transmit永远给令牌、写不进去就丢帧。