Rust in Everything
基于 embassy-net-driver 0.2.0

驱动接口

主线位置:中断主线在网络栈里的样子。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。

📝NOTE
冷知识:介质以前是 Capabilities 里的一个字段

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。

❗IMPORTANT
动脑筋:忘了设 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 帧数是数据帧的四分之一),多出来的那次拷贝在这个量级上看不出来。

📝NOTE
没有蠢问题

问: 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:只有两个定时任务的模板程序 .text 14624 字节,两栈 echo 程序 55968 字节,多出的 41 KB 是 smoltcp(TCP、IPv4、ARP)、embassy-net、driver-channel 和 echo 程序自己的几个任务。.bss 36144 字节,最大的符号是 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 永远给令牌、写不进去就丢帧。

💬 关联讨论

发起讨论
加载中...