ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

LWIP接收零拷贝:无MPU配置下的实现与Cache维护

LWIP接收零拷贝:无MPU配置下的实现与Cache维护 LWIP的接收路径默认并不是零拷贝的DMA把帧搬进内存后驱动还要再用memcpy把数据挪进pbuf一次转发就要多付一次几十微秒的CPU开销。项目标题里的[LWIP] Rx buffers zero copy no MPU config说白了就是在不配置MPUMemory Protection Unit的条件下把接收缓冲区直接交给协议栈省掉中间那次拷贝同时还得保证Cache一致性不出问题。我这次是在GD32F407平台上做的跑FreeRTOS LwIP配合LAN8720A后面也会提到带D-Cache的Cortex-M7平台怎么做因为无MPU场景下真正的坑全在Cache那边。这篇文章面向的是已经在MCU上移植过LWIP、想进一步榨取网络性能的开发者。零拷贝的原理不难但真正落地的时候描述符管理、内存池回收、Cache维护时机、对齐问题任何一个细节没顾到表现出来的就是丢包、乱码、甚至硬错误。我把自己调试过程中的思路和踩坑记录整理出来重点放在“无MPU配置”这个前提下怎么把接收路径做干净。1. LWIP接收零拷贝项目到底在解决什么问题1.1 传统接收路径里“多出来的那次拷贝”先看默认的接收流程。以太网帧到达后MAC的DMA会把数据写到驱动预先设置好的接收缓冲区通常是一个静态数组或者DMA描述符自带的buffer然后产生接收中断。驱动在中断里把数据从DMA缓冲区拷贝到pbuf的payload区域再调用netif-input把pbuf交给协议栈。这个memcpy就是接收路径上最大的不必要开销。假设你跑100M以太网线速12.5MB/s每个包平均500字节每秒大概2.5万个包。如果每个包都多拷贝一次500字节那就是每秒12.5MB的额外内存搬移。对跑在168MHz的Cortex-M4来说这个负担占到CPU资源的一大部分而且它本身不产生任何业务价值。零拷贝的思路很直接DMA描述符的buffer地址直接指向pbuf的payload区域。DMA收到帧后自动把数据写进这个pbuf驱动在中断里只需要检查描述符状态、做必要的Cache维护、然后把pbuf上送协议栈。数据从头到尾只有一次写入DMA写入CPU不会再搬动它。1.2 “不配MPU”为什么是个值得讨论的话题在带Cache的高性能M核比如Cortex-M7上MPU通常是用来解决DMA与Cache一致性问题的常用手段。手段很简单用MPU把DMA缓冲区所在的SRAM区域配置成non-cacheable这样CPU不缓存这块内存DMA写入后CPU直接读到的就是真实数据。但在很多实际工程里项目没有启用MPU也可能是产品代码里压根没人写过MPU配置。这时候麻烦就来了如果D-Cache是打开的默认情况下所有SRAM区域都是cacheableDMA和CPU之间没有自动同步机制。这种情况还能不能做零拷贝答案是可以但你必须自己维护Cache一致性而且需要注意的细节比配MPU多得多。如果你的平台本身是Cortex-M4比如GD32F407内部没有L1 D-Cache那“无MPU配置”做零拷贝反而没有任何额外负担只需要处理描述符和内存池逻辑即可。但如果你迁移到STM32H7这类M7芯片同样的代码跑起来就可能会乱码或死机原因就是Cache。所以这篇文章会把两种情况都讲清楚避免你在平台切换时掉坑。2. LWIP接收路径与零拷贝的原理解读2.1 pbuf结构和接收数据流要理解零拷贝先得知道pbuf是什么。pbuf是LWIP管理网络数据的基本单元分PBUF_RAM、PBUF_POOL、PBUF_ROM等类型。接收路径上最常用的是PBUF_POOL它由内存池分配每个pbuf包含一个管理头pbuf结构体和一段数据区payload指针指向数据区起始位置。默认接收流程是这样的DMA收到帧写入驱动预设的DMA buffer。驱动在接收中断或轮询里发现描述符的OWN位被DMA清掉说明帧已完成接收。驱动调用pbuf_alloc分配一个PBUF_POOL类型的pbuf。驱动调用memcpy把帧数据从DMA buffer拷贝到pbuf-payload。驱动把pbuf交给netif-input协议栈后续处理。零拷贝的做法是倒过来在初始化的时候驱动就预先分配好一批PBUF_POOL的pbuf并且把每个RX描述符的buffer地址设置为对应pbuf的payload。这样第3、4步不再需要DMA直接写入协议栈能用的数据区。驱动要做的只是维护描述符和pbuf之间的对应关系。2.2 零拷贝的关键描述符与pbuf的绑定和回收这里有个核心设计描述符环中的每个RX描述符必须能通过某种方式找到它对应的pbuf。最简单的办法是在驱动里维护一个静态数组比如buf_to_pbuf[RX_DESC_NUM]初始化时填充接收时用描述符索引直接取出pbuf。但这引出另一个问题当协议栈处理完pbuf后会调用pbuf_free释放的内存回到LWIP的内存池里。此时驱动并不知道这个pbuf已经被释放了如果不做任何处理对应描述符的buffer指针就悬空了下一次DMA往这块内存写入数据时这块内存可能已经被其他模块占用造成数据踩踏。常用的解决办法是在主循环或中断出口处做一次“描述符补充”操作rx_recycle。驱动扫描描述符环凡是当前处于空闲状态DMA不拥有且之前绑定的pbuf已经被释放掉的描述符就重新从内存池分配一个新的pbuf把buffer指针更新后再重新把描述符交给DMA。如果内存池里的pbuf一个都没被释放那就说明应用层持有数据太久了此时只能放弃补充等下一轮再试。2.3 Cache一致性真正的“隐形敌人”如果平台带D-Cache且没有MPU配置最容易被忽视的就是Cache一致性问题。DMA写内存绕过CPU的Cache它把新数据写到了物理SRAM里但如果CPU的Cache里还留着这块地址的旧数据CPU读到的依然是旧值表现出来就是“收到的包内容不变”或者偶发乱码。反过来也一样CPU把数据写进缓冲区后数据可能还滞留在Cache里没有写回物理内存DMA去读物理内存时读到的还是旧数据。发送路径上这个问题同样存在。无MPU、只靠软件维护时通常使用两条指令Clean写回/清理和Invalidate失效。把Cache line里的数据写回物理内存叫Clean把Cache line标记为无效让CPU下次读的时候从物理内存重新加载叫Invalidate。在接收方向DMA写完数据后、CPU读数据前必须对接收缓冲区的地址范围做Invalidate。在发送方向CPU写完数据后、DMA启动传输前必须对发送缓冲区做Clean必要时CleanInvalidate确保数据真正到达物理内存。同时要注意对齐。Cache维护的最小单位是cache lineCortex-M7的D-Cache line一般是32字节。如果你的buffer起始地址或长度没有对齐到32字节Invalidate操作可能会把同一cache line里CPU刚写入的有效数据一并失效掉造成数据丢失。这就是为什么零拷贝缓冲区的地址和大小都要做对齐处理不能随便给个结构体指针就用。3. 实操无MPU配置下的LWIP零拷贝接收3.1 平台准备与LWIP关键配置我这次实际调通的平台是GD32F407VET6 LAN8720A FreeRTOS LwIP 2.1.2。GD32F407是Cortex-M4F内核没有L1 D-Cache因此这个平台适合先验证零拷贝的逻辑不需要考虑Cache维护。同套逻辑拿到Cortex-M7平台时再补上Cache操作即可。先看LWIP这边需要调整的宏定义。lwipopts.h里几个关键配置如下#define MEM_ALIGNMENT 4 #define ETH_PAD_SIZE 0 #define PBUF_POOL_SIZE 16 #define PBUF_POOL_BUFSIZE 1524 #define LWIP_ETHERNET 1 #define LWIP_NETIF_STATUS_CALLBACK 1 #define LWIP_DHCP 0 #define LWIP_UDP 1 #define LWIP_TCP 1 #define CHECKSUM_GEN_IP 1 #define CHECKSUM_GEN_UDP 1 #define CHECKSUM_GEN_TCP 1 #define CHECKSUM_CHECK_IP 1 #define CHECKSUM_CHECK_UDP 1 #define CHECKSUM_CHECK_TCP 1PBUF_POOL_SIZE决定接收缓冲池大小PBUF_POOL_BUFSIZE决定每个pbuf的容量。以太网最大帧1518字节算上VLAN标签15221524足够覆盖再多出一点余量给对齐用。如果你的应用需要接收超长帧这个值得同步调大。DMA描述符数量方面GD32的以太网控制器支持可配置的描述符数量我设置为16个与PBUF_POOL_SIZE保持一致这样每个描述符对应一个pbuf管理最简单。如果描述符数量比pool数量多就会有描述符缺少pbuf可绑定的情况处理起来会麻烦很多。3.2 驱动层改造把pbuf挂到DMA描述符上核心思路是在描述符初始化时不创建独立的DMA buffer而是从LWIP内存池里拿pbuf然后把描述符的buffer地址指向pbuf-payload。以GD32的enet驱动为例改造后的初始化流程类似这样#define RX_DESC_NUM 16 #define TX_DESC_NUM 4 struct eth_dma_desc { uint32_t status; uint32_t control; uint32_t buffer_addr; uint32_t next_desc_addr; void *pbuf_ptr; /* 驱动自定义字段保存pbuf指针 */ }; static struct eth_dma_desc rx_desc[RX_DESC_NUM]; static struct pbuf *rx_pbuf_pool[RX_DESC_NUM]; static void low_level_init(struct netif *netif) { int i; /* ... MAC地址配置、PHY复位、DMA全局配置等 ... */ for (i 0; i RX_DESC_NUM; i) { struct pbuf *p pbuf_alloc(PBUF_POOL, PBUF_POOL_BUFSIZE, PBUF_POOL); if (p NULL) { /* 分配失败要处理实际代码里这里应该报错并停止初始化 */ while (1); } rx_pbuf_pool[i] p; rx_desc[i].pbuf_ptr p; rx_desc[i].buffer_addr (uint32_t)(p-payload); /* 描述符给DMA清除OWN位前配置好控制字 */ rx_desc[i].status ENET_RX_DESC_OWN; rx_desc[i].control (RX_DESC_BUFSIZE ENET_RX_DESC_BUFFER_SIZE_MASK) | ENET_RX_DESC_CTRL_RX_EN; } /* 建立描述符链表 */ for (i 0; i RX_DESC_NUM; i) { rx_desc[i].next_desc_addr (i RX_DESC_NUM - 1) ? (uint32_t)rx_desc[i 1] : (uint32_t)rx_desc[0]; } enet_dma_rx_desc_chain_init(rx_desc); enet_dma_enable(); }这里要特别注意描述符的buffer_addr必须直接取p-payload而不是取pbuf结构体首地址。payload才是数据区pbuf结构体头里存的是next指针、len、ref等管理字段DMA往那里写会把协议栈内部数据踩坏。另外PBUF_POOL类型的pbuf其payload默认会做对齐但不同平台对齐粒度不同。如果你后续要在带Cache的M7上跑建议把pbuf的payload地址手动强制对齐到32字节或者用宏#define LWIP_MEM_ALIGN 32将全局内存对齐调整到Cache line大小这样Cache操作会省心很多。3.3 low_level_input的实现从描述符直接上送pbuf接收数据时驱动要做的事情比传统方式少很多。low_level_input的基本逻辑如下static struct pbuf *low_level_input(struct netif *netif) { uint32_t desc_idx; struct pbuf *p; uint32_t len; uint32_t status; desc_idx rx_current_index; status rx_desc[desc_idx].status; if (status ENET_RX_DESC_OWN) { /* DMA还拥有这个描述符没有新数据 */ return NULL; } p (struct pbuf *)rx_desc[desc_idx].pbuf_ptr; if (status ENET_RX_DESC_ERR_MASK) { /* CRC错误、长度错误等直接释放重配 */ pbuf_free(p); p pbuf_alloc(PBUF_POOL, PBUF_POOL_BUFSIZE, PBUF_POOL); if (p NULL) { return NULL; } rx_pbuf_pool[desc_idx] p; rx_desc[desc_idx].pbuf_ptr p; rx_desc[desc_idx].buffer_addr (uint32_t)(p-payload); rx_desc[desc_idx].status ENET_RX_DESC_OWN; rx_current_index (desc_idx 1) % RX_DESC_NUM; return NULL; } /* 帧长度 */ len (status ENET_RX_DESC_FRAME_LEN_MASK) ENET_RX_DESC_FRAME_LEN_SHIFT; /* 无MPU且开启D-Cache的平台上这里必须加Invalidate操作。 Cortex-M4/M4F没有D-Cache这步直接跳过 */ /* SCB_InvalidateDCache_by_Addr((uint32_t *)(p-payload), len); */ p-len len; p-tot_len len; /* 当前描述符暂时交给协议栈驱动侧不再拥有这个pbuf。 重新分配新pbuf并补齐描述符的操作放到rx_recycle里统一做。 */ rx_desc[desc_idx].pbuf_ptr NULL; rx_pbuf_pool[desc_idx] NULL; rx_desc[desc_idx].buffer_addr 0; rx_desc[desc_idx].status 0; rx_current_index (desc_idx 1) % RX_DESC_NUM; return p; }注意这里有个细节描述符一旦上送协议栈驱动必须立刻把该描述符标记为无效“暂时退出环”。否则DMA拥有这个描述符后会往一块已经被协议栈占用的内存区域写数据轻则覆盖数据重则踩坏协议栈内部指针。3.4 描述符回收防止内核池枯竭和硬件停顿当协议栈处理完pbuf后会调用pbuf_free释放它回内存池。但驱动没有直接的回调钩子所以需要在主循环里做回收。最简单的方式是在ethernetif_input外面再加一个rx_recycle函数每次poll完所有接收帧后调用一次static void rx_recycle(void) { int i; for (i 0; i RX_DESC_NUM; i) { if (rx_desc[i].pbuf_ptr NULL) { /* 这个描述符空闲重新分配pbuf绑定 */ struct pbuf *p pbuf_alloc(PBUF_POOL, PBUF_POOL_BUFSIZE, PBUF_POOL); if (p NULL) { /* 池子被占满了说明协议栈还没释放足够的pbuf稍后再试 */ break; } rx_pbuf_pool[i] p; rx_desc[i].pbuf_ptr p; rx_desc[i].buffer_addr (uint32_t)(p-payload); /* 如果上层带Cache这里最好做一次CleanInvalidate 把该缓冲区中可能残留的脏Cache行清干净再交给DMA */ /* SCB_CleanInvalidateDCache_by_Addr((uint32_t *)(p-payload), PBUF_POOL_BUFSIZE); */ rx_desc[i].status ENET_RX_DESC_OWN; } } }主循环结构变成for ( ;; ) { ethernetif_input(g_netif); rx_recycle(); /* 其他任务 */ }这个recycle放在主循环里有个好处不会在中断上下文里做pbuf_alloc这类耗时操作避免中断关闭时间过长。中断里收到帧后只把事件记录真正处理放在ethernetif_input的轮询逻辑中符合高实时性系统的常规做法。3.5 带D-Cache平台无MPU下的Cache维护示范如果你把上面的代码直接搬到一个带D-Cache的Cortex-M7平台比如STM32H750、STM32H743并且D-Cache是打开的那么必须补上Cache维护。核心位置就三处第一处low_level_input里取到帧长度后、返回到协议栈之前对payload区域做InvalidateSCB_InvalidateDCache_by_Addr((uint32_t *)(p-payload), len);第二处rx_recycle里把新分配的pbuf重新挂到描述符之前对整块缓冲区做CleanInvalidateSCB_CleanInvalidateDCache_by_Addr((uint32_t *)(p-payload), PBUF_POOL_BUFSIZE);为什么recycle这里要用Clean加Invalidate因为这块pbuf在上一次被协议栈使用期间CPU可能往里面写过数据比如应用层修改了payload这些数据可能还残留在Cache里。如果不Clean掉DMA可能把残留数据发出或覆盖掉新数据。做一个CleanInvalidate能把脏数据写回物理内存同时清掉Cache里的旧内容保证接下来DMA写入时CPU不会读到陈旧数据。第三处发送路径上如果要像接收路径一样做零拷贝发送在DMA启动发送之前必须对发送缓冲区做CleanSCB_CleanDCache_by_Addr((uint32_t *)(tx_buf-payload), tx_len);发送路径上只Clean还不够保险因为发送完成后DMA可能又修改了描述符状态但CPU读取描述符状态时也可能读到错误值所以描述符所在内存区域也得配合Invalidate处理。这也是为什么很多人建议发送时干脆用普通拷贝方式避免这套Cache维护的麻烦只做接收零拷贝性价比最高。3.6 应用层读取与pbuf生命周期管理零拷贝接收的收益最终要体现在应用层。拿UDP接收举例传统方式下驱动已经拷贝过一次应用收到pbuf后如果又用pbuf_copy_partial拷贝到自己的大数组那前面省下的开销又白费了。正确的做法是直接在pbuf的payload上做业务解析。static void udp_echo_recv(void *arg, struct udp_pcb *upcb, struct pbuf *p, const ip_addr_t *addr, u16_t port) { /* 直接在p-payload上处理数据不要memcpy到自己数组 */ /* 这里做一个回环示例 */ udp_sendto(upcb, p, addr, port); pbuf_free(p); }但要注意数据在payload里是线性的吗PBUF_POOL的pbuf理论上可以链成多个节点但实际上只要PBUF_POOL_BUFSIZE大于单帧长度协议栈接收时拆包后的payload是连续的你可以直接读p-payload。如果做了IP分片重组情况会复杂一些需要遍历pbuf链。普通局域网环境MTU为1500单帧不会走到分片路径。另一个关键点是pbuf的释放时机。零拷贝模式下pbuf就是DMA缓冲区你不释放它rx_recycle就永远分不到新pbuf整个接收环就会停转。实际项目里最常见的死机/停网都是因为这个应用层为了性能把收到的数据指针保存到全局队列延迟处理结果dpbuf一直被占着。4. 常见问题与排查技巧4.1 数据乱码、内容不更新先查Cache症状接收第一次数据正确后续收到的包内容永远是第一个包的内容或者偶发出现错位字节。这种情况优先怀疑Cache。在带D-Cache平台上如果low_level_input里没有做InvalidateCPU读到的永远是Cache里的旧数据。第一次收到数据时Cache里没有旧内容所以还能正常工作第二次开始就出问题了。还有一种情况Invalidate的长度不对。比如帧长度是250字节你只Invalidate了前面200字节那后面50字节可能读的是Cache旧值。注意SCB_InvalidateDCache_by_Addr要求长度按32字节向上对齐最好这样写uint32_t aligned_len (len 31) ~31U; SCB_InvalidateDCache_by_Addr((uint32_t *)(p-payload), aligned_len);另外如果pbuf的payload地址没有对齐到32字节Invalidate会波及相邻cache line。所以建议在初始化时就把pbuf payload地址强制对齐或者在lwipopts.h里设置#define MEM_ALIGNMENT 32这样LWIP在内存池分配时会把内存地址对齐到32字节省去后续很多麻烦。4.2 收几个包后网络就“死了”通常是池子耗尽了症状刚启动时收发正常跑几秒或收几十个包后网卡再也不收包了但ping还能通或者是完全不通复位后才能恢复。这种问题大部分是pbuf池耗尽导致的。协议栈在处理pbuf时如果应用迟迟不释放内存池里的pbuf就被占光rx_recycle分配不到新pbuf描述符环上的所有描述符都处于空闲状态DMA没有buffer可用网络自然就停了。排查方法很简单在rx_recycle里加一个计数器当分配失败时累加打印出来。如果发现持续分配失败就去看是不是应用层占着pbuf不放。另外PBUF_POOL_SIZE设置太小也会出现这种问题。16个pbuf在TCP多连接、高突发场景下可能不够尤其是有TCP接收窗口数据重排时协议栈会额外持有很多pbuf。建议先设32个看看余量确认稳定后再往回收。4.3 DMA描述符永远不回收只在一个位置死循环症状零拷贝代码运行后网卡能收包但驱动里描述符状态不正常rx_recycle里一直在扫描同一个位置其他位置的pbuf_ptr都变成NULL了但新pbuf分配不出来。这种情况有两种可能。一种就是上面说的pool耗尽。另一种是mpb结构里ref计数没清干净。pbuf_free不等于真正释放如果协议栈对pbuf做了多次引用比如TCP重传ref计数会大于1pbuf_free只是ref--不会释放内存pbuf_ptr自然也不会变成可回收状态。排查时可以打印每个描述符的状态和对应pbuf的ref值。如果ref一直大于1说明协议栈还没处理完这个包不用管它继续等。4.4 开启D-Cache后系统跑飞先加屏障指令症状加上Cache操作后系统偶尔死机尤其在高负载时Hard Fault中断频发。这通常是总线同步问题。Cache维护指令需要配合内存屏障使用确保指令执行顺序不被编译器或CPU乱序优化破坏。安全做法是在Cache操作前后加上__DSB(); __ISB();例如SCB_InvalidateDCache_by_Addr((uint32_t *)(p-payload), aligned_len); __DSB();尤其是DMA启动之前必须确保所有Cache写入已经完成否则DMA可能读到半写状态的数据。这块在正式代码里一定不能省。4.5 常见问题速查表现象可能原因排查手段收包内容固定不变忘记Invalidate D-Cache在low_level_input里补上Invalidate偶发乱码/丢包缓冲区地址未对齐Cache line将MEM_ALIGNMENT设为32跑一会收不到包PBUF_POOL耗尽检查应用是否释放pbufHard Fault随机出现缺DSB/ISB内存屏障Cache操作后补屏障DMA描述符状态异常描述符被协议栈数据踩踏确认buffer_addr指向payload而非pbuf头高负载时性能不升反降Cache维护开销过大考虑开MPU配置non-cacheable区域5. 个人实操经验与后续扩展建议5.1 哪些场景值得上零拷贝并非所有项目都适合零拷贝。如果只是低速率采集、控制类通信每秒几十个包传统拷贝方式完全够用没必要引入额外复杂度。零拷贝真正带来收益的场景是高吞吐、小包密集或者CPU还需要同时跑其他计算密集任务。我在实际测试中测过GD32F407跑UDP echo传统拷贝方式下百兆带宽跑到60Mbps左右CPU占用就已经很高了。改成零拷贝后同样吞吐下CPU占用能低10到20个百分点。但这只是经验值不同平台和协议栈版本有差异建议按项目实际情况做对比测试。另外如果平台带D-Cache做零拷贝前先想清楚能不能接受Cache维护的开销。在STM32H7上Invalidate一个1500字节缓冲区的开销大约几微秒一秒钟几万个包就是几十毫秒的额外耗时。这种场景下配一个MPU region把缓冲区设为non-cacheable反而更划算一次配置永久省心。5.2 后续可以继续做的事如果零拷贝接收已经跑稳可以继续往两个方向扩展。一是发送路径零拷贝把协议栈下发的pbuf直接挂到DMA发送描述符上减少一次拷贝但要注意TCP重传时pbuf生命周期管理更复杂建议先从UDP场景验证。二是多网口场景比如同时跑以太网和USB网卡LwIP的netif层天然支持多个接口但描述符分配和回收逻辑都要跟着改成per interface不能共用静态数组这块需要认真设计。5.3 最后分享一个小技巧在调试零拷贝接收时我习惯在代码里加一个调试钩子统计每个描述符从“上送协议栈”到“重新挂上DMA”之间的时间。如果这个时间经常超过几毫秒说明应用层拿住pbuf太久接收环随时可能断。这个指标比单纯看丢包率更早暴露问题。我个人的体会是零拷贝接收本身不算难写难的是让整个链路在所有边界情况下都不出错。先把描述符管理逻辑理清楚再谈Cache优化顺序不要反。如果你也在做类似的LWIP接收优化卡在某一步始终调不通可以把描述符状态、pbuf ref、Cache指令执行顺序这三个点先检查一遍大部分问题都藏在这三处里。
返回列表