
嵌入式 TCP/IP 协议栈零拷贝发送机制详解从应用层 Buffer 到 DMA 描述符的 scatter-gather 链路一、数据搬运的隐性开销一次 TCP 发送背后的 4 次 memcpy在标准的嵌入式 Linux TCP/IP 协议栈中应用程序调用send()发送一段数据时数据在内核中至少经历以下拷贝用户态 → 内核态copy_from_user()将数据从用户缓冲区拷贝到内核sk_buff的线性数据区。线性区 → 分段区当数据超过sk_buff线性区容量通常为SKB_MAX_HEADER_LEN时通过pskb_expand_head()重新分配。sk_buff → DMA 映射网卡驱动通过dma_map_single()将 sk_buff 数据映射为 DMA 可访问的物理地址。驱动层 fragment 拼接对于sendfile()等场景页面缓存中的文件页需通过get_user_pages()锁定并映射。在 ARM Cortex-A 平台上每 GB 数据的memcpy消耗约 200ms1.8GHz A55NEON 优化的memcpy。若应用层以 200Mbps 速率发送数据光数据拷贝就消耗 CPU 时间的 4%~5%。对于流媒体服务器或日志采集网关这类以数据发送为主要负载的场景零拷贝带来的 CPU 节省是显著的。二、scatter-gather DMA 与 TCP 分段卸载的硬件协同机制零拷贝发送的核心是让网卡 DMA 引擎直接从应用层的非连续物理内存中收集数据而非经过中间的内核拷贝。这依赖两个硬件/协议栈特性scatter-gather DMA (SG-DMA)网卡 TX descriptor 不指向单个连续缓冲区而是持有多个{地址, 长度}对scatter-gather list。DMA 引擎按顺序从每个片段读取数据并拼装成完整的以太网帧。TCP Segmentation Offload (TSO)协议栈构建一个大的超级 sk_buff可包含多达 64KB 的 TCP 数据网卡硬件负责将其拆分为多个 MTU 大小的 TCP 段每个段自动计算 IP 校验和与 TCP 序列号。下图展示了从应用层 Buffer 经 TCP 协议栈到网卡 DMA 的 scatter-gather 数据路径frags 数组的关键角色skb_shared_info中的frags[]数组中每个条目包含一个struct page指针、页内偏移和数据长度。当应用层通过sendfile()或splice()发送文件内容时文件页缓存中的页面直接被引用到 frags 中不经过任何拷贝。网卡驱动的dma_map_page()调用将这些页面映射为 DMA 可访问的物理地址。以 DesignWare MACstmmac驱动为例TX descriptor 在 Enhanced Descriptor 模式下可携带最多STMMAC_MAX_TX_FRAGS个片段指针。驱动在stmmac_tx_clean()中遍历描述符链为每个描述符调用dma_unmap_page()释放 DMA 映射。三、零拷贝发送的驱动层与协议栈层实现3.1 协议栈层使用 sendfile 消除用户态拷贝/* * zero_copy_sender.c — 零拷贝文件传输服务 * * 使用 sendfile() 系统调用直接将文件内容从页缓存发送到 TCP socket * 内核路径: sendfile → do_sendfile → splice_direct_to_actor * → 页缓存页面 → skb_fill_page_desc → TX DMA * * 全程无任何 memcpy 介入除 TCP/IP 头部构建所需的少量栈拷贝外 */ #include stdio.h #include stdlib.h #include string.h #include unistd.h #include fcntl.h #include sys/socket.h #include sys/sendfile.h #include sys/stat.h #include netinet/in.h #include arpa/inet.h #include errno.h /* * 通过 sendfile 零拷贝传输文件 * 返回已发送字节数失败返回 -1 * * 性能注意 * - sendfile 内部使用 splice 管道机制页缓存的页面被直接引用到 * sk_buff 的 frags 中不产生数据拷贝 * - 但 sendfile 要求 fd 对应的文件在页缓存中有映射即不能是 O_DIRECT * 打开的文件O_DIRECT 绕过了页缓存sendfile 会退化为 read write */ ssize_t sendfile_zero_copy(int sock_fd, const char *filepath) { /* 以普通方式打开文件确保页缓存映射 */ int file_fd open(filepath, O_RDONLY); if (file_fd 0) { fprintf(stderr, [ERROR] open(%s) 失败: %s\n, filepath, strerror(errno)); return -1; } /* 获取文件大小 */ struct stat st; if (fstat(file_fd, st) 0) { fprintf(stderr, [ERROR] fstat 失败: %s\n, strerror(errno)); close(file_fd); return -1; } off_t offset 0; size_t remaining (size_t)st.st_size; ssize_t total_sent 0; /* * sendfile 循环内核可能一次无法发送全部数据 * 返回值为本次实际发送的字节数 * 当返回 0 时表示文件已发送完毕 */ while (remaining 0) { ssize_t sent sendfile(sock_fd, file_fd, offset, remaining); if (sent 0) { if (errno EAGAIN || errno EWOULDBLOCK) { /* * socket 发送缓冲区满 —— 非阻塞模式下的正常反压 * 使用 poll/epoll 等待 socket 可写后重试 */ continue; } fprintf(stderr, [ERROR] sendfile 失败: %s\n, strerror(errno)); close(file_fd); return -1; } if (sent 0) { /* 文件已读取完毕 */ break; } total_sent sent; remaining - sent; } close(file_fd); return total_sent; } /* * 验证零拷贝是否实际生效 —— 读取 /proc/net/sockstat 中 TCP 的内存占用 * 如果零拷贝生效TCP 层的 memory 占用量应与发送窗口大小相当 * 而非整个文件大小 */ static void check_tcp_memory_usage(void) { FILE *fp fopen(/proc/net/sockstat, r); if (!fp) return; char line[256]; while (fgets(line, sizeof(line), fp)) { if (strstr(line, TCP:)) { printf([INFO] %s, line); break; } } fclose(fp); }3.2 驱动层验证stmmac TX Descriptor 的 scatter-gather 确认在 stmmac 驱动中TX descriptor 的填充逻辑位于stmmac_xmit()函数中。以下是其 scatter-gather 片段填充的核心逻辑简化版/* * stmmac SG-TX 片段填充逻辑简化自 drivers/net/ethernet/stmicro/stmmac * * 每个 TX descriptor (enhanced mode) 携带一个缓冲区的 DMA 地址和长度 * sk_buff 的线性部分通过 dma_map_single 映射 * frags[] 中的每个片段通过 dma_map_page 映射 * 最后一个 descriptor 设置 TDESC_LAST 标志触发 DMA 引擎发送 */ static int stmmac_tx_fill_descriptors(struct stmmac_priv *priv, struct sk_buff *skb, struct dma_desc *first_desc) { struct dma_desc *desc first_desc; int nfrags skb_shinfo(skb)-nr_frags; int entry priv-cur_tx; int i; /* * 第一步填充 sk_buff 线性数据区的 DMA 映射 * dma_map_single 返回设备可访问的 DMA 地址 */ dma_addr_t dma_addr dma_map_single(priv-device, skb-data, skb_headlen(skb), DMA_TO_DEVICE); if (dma_mapping_error(priv-device, dma_addr)) { dev_err(priv-device, [ERROR] DMA 映射 skb-data 失败\n); return -ENOMEM; } desc-des0 dma_addr; /* 缓冲区物理地址 */ desc-des1 0; desc-des2 skb_headlen(skb); /* 缓冲区长度 */ /* 如果是最后一个片段设置 TDESC_LAST 和 TDESC_IC (中断完成) */ if (nfrags 0) { desc-des0 | TDESC_LAST | TDESC_IC; } /* * 第二步遍历 frags[] 中的每个页面片段 * 每个片段通过 dma_map_page 映射 * 而非 dma_map_single —— 因为 frags 指向的是页面缓存中的页面 * 不是通过 kmalloc 分配的连续内存 */ for (i 0; i nfrags; i) { const skb_frag_t *frag skb_shinfo(skb)-frags[i]; entry (entry 1) % DMA_TX_SIZE; desc priv-dma_tx entry; /* * dma_map_page 的第三个参数是页内偏移 * 第四个参数是映射长度 * * 这里的关键是页面本身已经在页缓存中 * 不需要任何数据拷贝。DMA 引擎直接从页面物理地址读取数据 */ dma_addr dma_map_page(priv-device, skb_frag_page(frag), /* struct page * */ skb_frag_off(frag), /* 页内偏移 */ skb_frag_size(frag), /* 数据长度 */ DMA_TO_DEVICE); if (dma_mapping_error(priv-device, dma_addr)) { dev_err(priv-device, [ERROR] DMA 映射 frag[%d] 失败\n, i); /* 回滚已建立的映射 */ goto dma_unmap_error; } desc-des0 dma_addr; desc-des1 0; desc-des2 skb_frag_size(frag); /* 最后一个 fragment 设置 LAST IC */ if (i nfrags - 1) { desc-des0 | TDESC_LAST | TDESC_IC; } } /* 更新驱动内部状态并启动 DMA */ priv-cur_tx entry; stmmac_set_tx_owner(priv, first_desc); stmmac_enable_dma_transmission(priv); return 0; dma_unmap_error: /* 错误处理逆序解除 DMA 映射并回收 descriptor */ while (i 0) { dma_unmap_page(priv-device, desc-des0, desc-des2, DMA_TO_DEVICE); desc-des0 0; i--; entry (entry - 1 DMA_TX_SIZE) % DMA_TX_SIZE; desc priv-dma_tx entry; } dma_unmap_single(priv-device, first_desc-des0, first_desc-des2, DMA_TO_DEVICE); return -ENOMEM; }四、零拷贝的硬件前提与协议栈约束DMA 物理地址连续性要求scatter-gather DMA 需要网卡硬件支持非连续的物理地址列表。不支持 SG-DMA 的廉价 MAC 控制器如部分 ENC28J60 类 SPI 网卡只能通过单个连续 DMA 缓冲区发送此时协议栈必须通过skb_linearize()将 sk_buff 的非连续页面拼接到连续内存区域这实际上是一次额外的拷贝。部署前必须确认网卡的能力位NETIF_F_SG已置位。页缓存锁定期间的写保护sendfile将页面标记为锁定状态通过get_page()增加引用计数。在 DMA 传输完成即网卡发送完成中断触发dma_unmap_page之前文件系统的写操作不能修改这些页面。对于大文件传输GB 级页面锁定可能导致可观的写延迟。缓解方案是限制单次sendfile的大小为 64KB~256KB 的块。TCP Checksum Offload 协同如果启用了 TSO 但未启用 TX Checksum OffloadNETIF_F_HW_CSUM每个分段都需要 CPU 重新计算校验和。这不仅浪费 CPU还需要重新读取 payload 数据。因此零拷贝发送应与 checksum offload 同时启用。禁用场景场景原因网卡不支持 SG-DMA协议栈回退到 skb_linearize产生额外拷贝小数据量频繁发送 ( 512 bytes)零拷贝的初始化开销超过拷贝本身需要通过 TLS/SSL 加密后发送加密在应用层必须先拷贝到用户态缓冲区需修改 payload 的场景 (如应用层压缩)修改意味着必须持有数据的可写拷贝五、总结嵌入式平台 TCP 零拷贝发送的核心在于建立从应用层物理页面到网卡 DMA descriptor 的直通链路架构层面sendfile()/splice()将页缓存页面直接引用到 sk_buff 的frags[]中驱动通过dma_map_page()将页面映射到 TX descriptor。全程无 CPU 参与的memcpy。硬件前提网卡必须支持 scatter-gather DMANETIF_F_SG和 TX checksum offloadNETIF_F_HW_CSUM。TSOTCP Segmentation Offload是加分项可将大段切分和序列号计算完全卸载到网卡。内核机制零拷贝的可行性依赖于页缓存的页面锁定get_page和 DMA 完成中断驱动的页面解锁put_page。这两个操作必须在同一协议栈路径上配对。适用场景大文件传输、日志/监控数据发送、流媒体服务等以数据搬移为主要负载的场景受益显著。小包频繁发送或需要应用层修改 payload 的场景零拷贝收益不明显。