弄懂 sk_buff 底层,才算真正入门 Linux 网络栈

📅 2026/7/7 8:25:49
弄懂 sk_buff 底层,才算真正入门 Linux 网络栈
大家好前几天收到有留言说看 Linux 内核源码像是在读天书。特别是看到网络协议栈那一块满屏幕的 sk_buff 绕得人头晕。想真正搞懂LInux 网络协议栈先过了sk_buff这一关再说别的。这还真不是我在危言耸听。sk_buff —— 套接字缓存它是 Linux 内核网络子系统的绝对核心。所有的数据包不管是收还是发都被装在这个结构体里。不夸张地说你写的每一行涉及网络的操作背后都有它在兜底。所以我今天想和大家聊聊 sk_buff。读完这篇文章我不敢打包票说你马上就精通内核但你至少敢去改改内核代码了。一、为什么 sk_buff 是网络栈的命根子很多人学Linux网络一开始都在背协议。TCP 三次握手、滑动窗口、拥塞控制。背得飞起背完以为自己入门了。其实不然。协议栈里的所有数据不管是应用层的HTTP请求还是链路层的以太网帧都要靠一个“容器”来承载、传递、处理。这个容器就是 sk_buff全称 socket buffer。你可以把它理解成网络数据的“快递盒”。数据包从网卡进来到应用程序出去每一层协议的解析、封装本质上都是在操作这个“快递盒”——要么给盒子加层包装加协议头要么拆层包装解协议头要么调整盒子里的东西修改数据。sk_buff 被设计成整个网络栈的枢纽几乎所有协议操作都离不开它。不理解它的设计逻辑、数据结构和操作方式你对协议栈的理解大概率还停留在“会背八股”的阶段。没法真正上手开发、排查问题。不服气请你作答以下问题CPU 飙高了为什么驱动丢包了为什么GRO 没生效为什么内核 panic 了又为什么二、sk_buff 是什么Linux 网络栈本质上是“包”的流动而 sk_buff。就是 Linux 内核里“数据包”的唯一合法载体。注意这句话不是“之一”。而是“唯一”。不管是网卡接收的原始数据还是应用程序发送的业务数据无论你是 TCP、UDP、ICMP、VLAN、VXLAN、Netfilter、eBPF、qdisc 最后处理的都是 sk_buff。它不仅装着数据包本身还记录着数据包的长度、协议类型、内存位置等关键信息供内核各层协议调用。你可以把它理解成 网络栈中的“快递盒子” 真正的数据放里面 协议头贴外面 内核各层模块不断修改这个盒子最后交给网卡所以 sk_buff 有点像“网络栈里的 std::string”。看着简单 实际上内部还蛮复杂的如果你是第一次看源码真的容易裂开。sk_buff 的设计设计哲学可以用六个字概括高性能、零拷贝。理解这6个字就能抓住它的本质我也不知道这么说对不对反正我是这么记的。第一个零拷贝支撑。传统的内存操作数据会在用户态和内核态之间反复拷贝开销极大。而 sk_buff 通过指针偏移实现了“数据不动指针动”——比如给数据包加协议头时不用拷贝整个数据只需要移动指针预留出头部空间即可极大减少了拷贝开销。这一点被广泛用在高吞吐场景中比如DPDK就借鉴了类似思路。第二个内存弹性管理。网络数据包的长度是不固定的从几十字节比如ARP包到几KB比如TCP包都有可能。sk_buff 支持线性区和分页区结合的内存布局既能高效存储小数据包也能灵活处理大数据包避免内存浪费。第三个协议无关抽象。链路层、网络层、传输层的协议各不相同对数据的处理方式也不一样。sk_buff 把这些差异屏蔽掉给所有协议提供了统一的操作接口——不管是以太网头、IP头还是TCP头都能通过 sk_buff 的API快速访问、修改不用关心底层内存细节。很多人问为什么不直接用一个简单的字符数组因为网络包在传输过程中每一层协议都要往上贴标签加包头或者撕标签剥离包头。如果是普通数组每次加头都要进行内存拷贝CPU 早就被累瘫了。所以 sk_buff 被设计成了一个内存弹性管理的怪胎。它在内存里预留了一堆空间通过移动几个指针就能实现“加头”和“去尾”完全不需要动真实的数据。三、数据结构剖析很多人怕看 sk_buff 的源码觉得太复杂。其实不用死记所有成员真正高频的成员。其实就几个能应对80%的开发和排查场景。3.1 必须掌握的核心成员data、tail、end这三个核心指针决定了数据包在 sk_buff 中的位置。data指向数据包的起始位置也就是有效数据的开头tail指向数据包的结束位置有效数据的末尾end指向 sk_buff 缓冲区的末尾整个内存块的最后。len、data_len、mac_len。len 是整个数据包的总长度有效数据所有协议头data_len 是分页区的数据长度如果数据包太大会分成线性区和分页区存储mac_len 是链路层头部比如以太网头的长度用来快速定位网络层头部。还有一个关键结构skb_shared_info它用来管理分页数据。当数据包超过线性区的长度时多余的数据会被存放在分页区skb_shared_info 就记录着分页的数量、每个分页的地址等信息避免数据丢失。这里容易被忽略后面避坑指南会重点说。举个例子当你给数据包加一个TCP头时不需要拷贝数据只需要把 data 指针向前移动skb_push操作腾出的空间就用来存放TCP头——这就是零拷贝的核心实现是不是很巧妙3.2 内存布局sk_buff 的内存布局其实可以分成三个部分Headroom、Data、Tailroom新手记这三个分区就够了。| Headroom | Data | Tailroom | ^ ^ data tail end --------------------^Headroom头部预留空间用来存放后续要添加的协议头比如从链路层到网络层需要加IP头就用这里的空间。这个空间是在分配 sk_buff 时预留的避免后续添加协议头时需要重新扩容、拷贝数据这也是提升效率的关键技巧之一。Data有效数据区存放实际的数据包内容从应用层数据到各层协议头data 和 tail 指针之间的区域就是Data区。Tailroom尾部预留空间用来存放后续要添加的尾部数据比如CRC校验码或者临时扩展数据。sk_buff 的内存分为线性区和分页区线性区是连续的内存适合存小数据包速度快分页区是离散的内存页适合存放大数据包由 skb_shared_info 管理。这样的设计特别合理既能保证小数据包的处理效率又能灵活应对大数据包避免出现连续内存分配失败的情况。还有一点要注意sk_buff 有自己的内存分配器和缓存池内核会提前分配一批 sk_buff 缓存不用每次用的时候都重新分配、释放这样能减少很多开销——这也是高吞吐场景下优化 sk_buff 性能的关键大家可以记一下。3.3 链表与引用计数sk_buff 通常是用双向链表的形式存在的通过 next 和 prev 指针串起来这样内核就能批量处理多个数据包比如一次接收多个数据包形成一个链表统一处理效率会高很多。refcnt 引用计数记着当前有多少个内核组件比如协议层、驱动、应用程序在使用这个 sk_buff只有当 refcnt 减到0的时候内核才会释放这个 sk_buff 的内存就掌握着 sk_buff 的“生死大权”。这里特别容易踩坑我跟大家说句实在的如果引用计数处理不好要么导致内存泄漏refcnt一直不为0内存没法释放要么导致重复释放refcnt减到0后又去操作 sk_buff这两种情况都会引发内核panic——我之前就踩过这个坑。四、生命周期全流程sk_buff 的生命周期其实就是分配→初始化→接收/发送→释放全程闭环。大家只要掌握这个流程就知道每个阶段该做什么、不该做什么不容易出错。4.1 分配与初始化分配 sk_buff 主要用两个函数alloc_skb() 和 dev_alloc_skb()。dev_alloc_skb(size)这是最常用的分配函数特别省心它会自动预留16字节的Headroom适合链路层处理比如加以太网头而且会把 sk_buff 加入内核缓存池效率更高适合网卡接收路径这种高频场景大家平时用优先选这个。alloc_skb(size, gfp_mask)这个函数更灵活但也更麻烦需要手动预留Headroom用 skb_reserve() 函数gfp_mask 参数还能指定内存分配的场景比如是否允许睡眠适合一些自定义场景。但新手很容易忘记预留Headroom导致后面操作报错大家用的时候一定要注意。初始化的核心其实就是预留Headroom。一般用 skb_reserve(skb, len) 函数比如预留16字节就写 skb_reserve(skb, 16)这样后面加以太网头、IP头的时候就不用临时扩容了省很多事。这里提醒一句skb_reserve() 只能在分配 sk_buff 后、添加数据前调用不然会覆盖有效数据这点一定要记牢。4.2 接收路径数据包的接收路径说简单点就是网卡接收数据→DMA填充到 sk_buff→协议栈逐层解析。经典 RX 流程NIC DMA ↓ Ring Buffer ↓ NAPI poll ↓ 构造 skb ↓ eth_type_trans() ↓ IP 层 ↓ TCP/UDP这里的重点是 DMA 并不会直接写 skb 结构。 DMA 写的是 data buffer。 skb 只是描述符。网卡收到数据后不会直接通知CPU主要是避免频繁中断太耗资源而是通过DMA直接内存访问把数据直接写入内核提前分配好的 sk_buff 里——这一步也是零拷贝的体现数据不用经过CPU中转效率特别高。然后网卡会触发硬中断通知内核“有数据进来了”内核会通过ksoftirqd线程在软中断上下文中轮询接收环缓冲区把填充好数据的 sk_buff 交给协议栈。之后就从链路层开始逐层解析协议头先解以太网头再解IP头最后解TCP/UDP头最终把有效数据交给应用程序。这里必须提醒大家一句接收路径中sk_buff 是内核分配的应用程序只能读不能直接修改不然会破坏协议栈的正常处理很容易出问题。4.3 发送路径发送路径和接收路径正好相反就是应用程序数据→协议栈逐层封装→sk_buff 写入网卡发送队列。用户态 write/send ↓ TCP 封装 ↓ IP header ↓ MAC header ↓ qdisc ↓ driver xmit ↓ DMA应用程序调用send()函数后数据就进入内核了内核会分配一个 sk_buff把应用数据写入Data区然后从传输层开始逐层加协议头先加TCP/UDP头再加IP头最后加以太网头——每加一层协议头就用 skb_push() 移动一下 data 指针腾出头部空间操作起来很简单。封装完成后sk_buff 会被交给网卡驱动驱动会把 sk_buff 里的数据转换成网卡能理解的格式填充到发送环缓冲区的描述符里最后由网卡发送到网络中。发送完成后驱动会处理中断把已经发送的 sk_buff 释放掉整个发送流程就结束了。4.4 销毁与释放sk_buff 的释放核心就是管理好引用计数常用的函数有 kfree_skb() 和 consume_skb()还有 skb_clone() 和 skb_copy() 两个辅助函数大家记清楚它们的用法别用混了。kfree_skb(skb)这个函数会直接减少 sk_buff 的引用计数如果引用计数减到0就会释放内存。但有个坑这个函数不能在软中断中直接调用后面避坑指南里我会详细说这个问题。consume_skb(skb)函数就安全多了专门用于软中断上下文能安全地减少引用计数避免内存泄漏或内核panic比 kfree_skb() 靠谱软中断里释放 sk_buff优先用这个。还有一个常用场景当需要复制一个 sk_buff 时新手很容易直接用 skb_copy()完全拷贝数据和结构都复制但这样开销很大没必要。如果只是需要共享数据不用修改建议用 skb_clone()——只拷贝 sk_buff 的结构不拷贝数据数据通过引用计数共享效率高很多。但要注意克隆后不能修改共享数据不然会导致多个 sk_buff 数据混乱踩过这个坑的都懂。往期文章推荐图解 TCP/IP协议看图秒懂图解 C/C 多线程看图秒懂爆肝整理嵌入式开发必知10种调试手段【图解】SSH安全外壳协议工作原理【就业避坑】C 就业前景全解析为什么劝退声不断大厂核心岗仍刚需 C【大厂标准】Linux C/C 后端开发系统学习路线【音视频】音视频流媒体高级开发核心学习路径【Qt进阶】C Qt 桌面 嵌入式开发一条龙学习攻略【内核底层】Linux 内核硬核修炼指南【面试冲刺】C/C 高频八股面试题 1000 题三五、必API跟大家说句实在的不用死记所有API掌握下面这几个就能应对大部分 sk_buff 的操作场景真的绝了我平时开发也主要用这几个。5.1 数据指针调整四个核心API这四个API都是用来调整 data 和 tail 指针的对应不同的操作场景大家记清楚它们的作用别用反了用反了很容易出问题。skb_reserve(skb, len)作用是预留Headroom把 data 和 tail 指针同时向后移动 len 字节腾出头部空间。只能在分配 sk_buff 后、添加数据前调用主要用来预留协议头空间这个之前咱们说过就不啰嗦了。skb_put(skb, len)用来向 sk_buff 的尾部添加数据把 tail 指针向后移动 len 字节len 就是添加的数据长度。比如把应用数据写入 sk_buff 时就用这个函数如果数据本身包含完整的协议头也可以用这个函数直接拷贝整个数据包很方便。skb_push(skb, len)用来向 sk_buff 的头部添加数据比如加协议头把 data 指针向前移动 len 字节len 就是协议头的长度。比如加IP头时就写 skb_push(skb, sizeof(struct iphdr))简单好记。skb_pull(skb, len)用来从 sk_buff 的头部移除数据比如解析完协议头后把协议头丢弃把 data 指针向后移动 len 字节len 就是要移除的长度。比如解析完以太网头后用 skb_pull(skb, skb-mac_len) 丢弃以太网头特别方便。5.2 协议头快捷访问解析协议头的时候大家别傻乎乎地手动计算指针偏移太麻烦了sk_buff 早就给我们提供了快捷方式能快速获取L2链路层、L3网络层、L4传输层的头指针省很多事。比如skb-mac_header 指向L2头以太网头skb-network_header 指向L3头IP头skb-transport_header 指向L4头TCP/UDP头。直接通过这些指针就能快速访问协议头的成员不用自己计算 data 指针的偏移量效率高多了。还有一个常用标志ip_summed用来控制校验和卸载。开启校验和卸载后内核会把校验和计算交给网卡硬件处理能减少CPU开销这也是高吞吐场景的优化技巧之一。比如设置 skb-ip_summed CHECKSUM_UNNECESSARY就表示网卡已经完成校验和计算内核不用再处理大家可以记一下。5.3 高级特性对于大数据包超过MTUsk_buff 支持分片链表 frag_list能把大数据包分成多个小分片每个分片都是一个独立的 sk_buff通过 frag_list 串起来。这样不用一次性分配大量连续内存也能处理大数据包特别灵活。还有GSO/TSO支持大家也可以了解一下GSO通用分段卸载和TSOTCP分段卸载都是把分段操作交给网卡硬件处理内核只需要处理一个大数据包能减少内核的处理压力提升发送效率。另外sk_buff 还支持时间戳记录能记录数据包的接收、发送时间排查延迟问题时特别有用。六、性能优化光会用 sk_buff 还不够实际开发中经常会遇到 sk_buff 导致的性能瓶颈比如高吞吐下分配延迟高、CPU飙高等。下面结合我自己遇到的实战场景给大家分享我的“问题→方案→验证”经验。6.1 问题高吞吐下 sk_buff分配延迟过高场景高并发、高吞吐的场景比如网关、负载均衡经常出现 sk_buff 分配失败导致丢包、延迟升高特别影响业务。方案调大 skbmem_max 参数这是内核 sk_buff 内存池的最大容量同时启用内存池预分配让内核提前分配足够的 sk_buff 缓存避免频繁分配内存减少延迟。具体操作修改 /etc/sysctl.conf 文件添加 net.core.skbmem_max 16777216也就是16MB大家可以根据自己的实际场景调整然后执行 sysctl -p 生效很简单。验证执行 netstat -s | grep -i socket fails查看 sk_buff 分配失败的次数如果次数为0或者明显减少就说明优化生效了亲测好用。6.2 问题线性区频繁扩容导致拷贝开销场景数据包大小波动很大经常出现线性区空间不足需要扩容导致频繁拷贝数据CPU开销居高不下影响性能。方案分配 sk_buff 时合理估算Headroom和数据长度一次分配足够的空间避免后续扩容。比如根据自己的业务场景预留20字节Headroom应对各种协议头数据长度按最大可能值分配这样就能减少拷贝开销。验证用 perf top -g 观察内核函数热点如果 skb_copy 函数的占比明显降低就说明优化生效了。我之前优化过一个网关就用的这个方法CPU使用率直接降了20%有点东西吧6.3 问题硬件卸载未生效CPU飙高场景明明开启了校验和卸载、GSO/TSO但CPU使用率依然很高排查了半天发现硬件卸载根本没生效等于白设置了。方案先确认网卡及驱动是否支持TSO/GROGRO是接收侧的分段卸载如果支持就用 ethtool 命令开启相应选项如果不支持就升级网卡驱动。具体操作执行 ethtool -K eth0 tso on gro on开启TSO和GROeth0是网卡名称大家根据自己的网卡名称修改。另外对于ARM嵌入式设备还要确认CPU调速器是否设置为performance模式避免CPU低频导致处理瓶颈这个细节很容易被忽略。验证执行 ethtool -k eth0 | grep generic查看TSO、GRO是否为on如果显示on就说明硬件卸载已经生效了。6.4 零拷贝技术选用指南零拷贝是提升 sk_buff 性能的核心常用的有两种方式大家根据自己的场景选择不用盲目跟风。DMA映射适合网卡接收/发送场景数据直接通过DMA在网卡和内核内存之间传输不经过CPU中转能减少CPU开销这也是内核默认的零拷贝方式。如果是高频访问的DMA缓冲区建议用流式DMA映射显式同步性能会更好。sendfile()/splice()适合应用程序向网卡发送文件的场景比如静态文件服务器数据直接从用户态文件缓存通过 sk_buff 发送到网卡避免用户态和内核态之间的拷贝能提升发送效率大家可以根据自己的业务场景选用。6.5 调试与监控快速定位问题遇到 sk_buff 相关的问题比如丢包、延迟、内存泄漏大家不用慌用下面这两个工具能快速定位问题节省排查时间我平时排查问题全靠它们。bpftrace能追踪 sk_buff 的流动过程比如追踪 sk_buff 的分配、释放、协议头修改等操作。命令示例bpftrace -e kprobe:alloc_skb { printf(alloc skb: %p\n, arg0); }能快速找到 sk_buff 分配异常的地方特别好用。dropwatch专门用来定位丢包点执行 dropwatch -l kas就能实时查看内核丢包的位置。比如丢包发生在 __netif_receive_skb_core 函数大概率是bonding模式配置错误导致的我之前排查银行系统丢包时就用这个工具找到了问题根源省了很多事。七、避坑指南这一节全是事故。真的每个坑我都见过。7.1 错误在软中断中直接调用 kfree_skb()错误代码示例// 软中断上下文 static void softirq_handler(struct softirq_action *h) { struct sk_buff *skb get_skb(); if (skb) { kfree_skb(skb); // 错误软中断中直接调用kfree_skb() } }严重后果软中断上下文是不能睡眠的而 kfree_skb() 在某些情况下会触发内存回收可能导致睡眠进而引发内核panic或者内存泄漏skb无法被正确释放。我之前就因为这个错误导致服务器重启被领导骂了一顿现在想起来还记忆犹新。正确修复改用 consume_skb()这个函数专门用于软中断上下文能安全地释放 sk_buff同时还会检查 skb-destructor如果有自定义的销毁函数会自动调用比 kfree_skb() 安全多了。// 正确写法 static void softirq_handler(struct softirq_action *h) { struct sk_buff *skb get_skb(); if (skb) { consume_skb(skb); // 正确软中断中用consume_skb() } }7.2 错误克隆后修改私有数据未复制错误模式用 skb_clone() 克隆 sk_buff 后直接修改克隆后的 sk_buff 的私有数据比如 skb-cb还以为克隆后的 sk_buff 是独立的随便改都没事。严重后果skb_clone() 只克隆 sk_buff 的结构不克隆私有数据和有效数据私有数据是共享的。你修改克隆后的私有数据原 sk_buff 的私有数据也会被修改进而引发协议状态错误比如TCP连接异常、数据包解析错误排查起来特别麻烦。正确修复如果需要修改私有数据或者需要独立的数据空间就别用 skb_clone() 了改用 skb_copy()完全拷贝 sk_buff 的结构和数据确保数据独立如果不需要修改数据只是共享再用 skb_clone()这样就不会踩坑了。7.3 错误忽略 skb_shared_info中的分页引用错误模式释放 sk_buff 时只调用 kfree_skb()完全忽略了 skb_shared_info 中的分页数据frag_list没有释放分页内存以为这样就完事了。严重后果分页内存没法被释放会导致内存泄漏长期运行后内核内存会被耗尽服务器会出现卡顿、丢包甚至panic。我之前排查一个内存泄漏问题查了整整三天最后才发现就是忽略了分页引用的释放当时真的欲哭无泪。正确修复释放 sk_buff 前先遍历 skb_shared_info 中的 frag_list对每个分页调用 put_page() 释放分页内存然后再调用 kfree_skb() 释放 sk_buff 本身一步都不能少。7.4 错误在 NAPI 轮询中睡眠或调度错误模式在NAPINew API轮询函数中调用 msleep()、schedule() 等会导致睡眠或调度的函数或者执行一些耗时操作觉得没什么问题。严重后果NAPI轮询运行在软中断上下文是不能睡眠或调度的否则会导致软死锁整个软中断被阻塞没法处理其他数据包进而导致吞吐量崩塌服务器无法正常处理网络请求。尤其是在ARM嵌入式设备上这个问题会更明显因为单队列网卡的中断处理全靠一个CPU核心。正确修复在NAPI轮询中严格使用原子操作避免耗时操作如果有耗时任务比如复杂的数据处理就把它推入工作队列workqueue由内核线程在进程上下文处理这样就不会阻塞NAPI轮询了。最后想跟大家说一句sk_buff 是Linux网络栈的“基石”也是入门网络栈的“敲门砖”。它其实不是“一个结构体”。而是Linux 网络栈的统一数据模型协议栈所有层都围绕它协作。你理解了 skb。很多以前模模糊糊的问题。会突然串起来。比如为什么 GRO 能减少 CPU为什么 TSO 能提升吞吐为什么 clone 比 copy 快为什么 headroom 不够性能会崩为什么 page frag 能减少大包开销这些东西。会彻底通。如果大家想进一步深入学习下面这几个方向可以看看都是 sk_buff 的进阶内容适合有一定基础后再学难度不算太高eBPF 如何直接操作 sk_buffeBPF 是Linux内核的动态追踪工具通过 bpf_skb_load_bytes、bpf_skb_store_bytes 等函数可以直接读取、修改 sk_buff 中的数据不用修改内核源码适合调试和性能优化DPDK 绕过内核的 sk_buff 思路对比DPDK 为了追求极致性能绕开了内核协议栈自己实现了一套类似 sk_buff 的数据结构mbuf通过用户态驱动直接操作网卡减少内核开销对比两者的设计思路能更深入理解 sk_buff 的局限性和优势现代多队列网卡与 NAPI 协同原理简述多队列网卡可以将不同的数据包分配到不同的CPU核心结合NAPI轮询能提升并发处理能力而 sk_buff 的链表和引用计数机制是实现多队列协同的关键理解两者的协同原理能更好地优化高并发场景的性能。