ARTICLE DETAIL

资讯详情

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

理解struct sk_buff:Linux网络包的灵魂与排障实战

理解struct sk_buff:Linux网络包的灵魂与排障实战 Linux网络子系统里真正让每个网络包“活”起来的不是网卡的DMA描述符也不是协议栈里的某个函数而是那个几乎无处不在的结构体——struct sk_buff。在内核开发者的行话里它通常被缩写为SKB。你可以把它理解成网络包在内核中的“灵魂”从网卡中断、NAPI收包到TCP/IP协议栈一层层处理再到套接字排队和发送路径报文的所有状态都跟着SKB在走。内核网络代码本质上就是围绕这个结构体展开的。很多朋友学Linux网络源码也翻了协议栈流程也能背了可一遇到线上丢包、性能调优还是不知道从哪下手。我觉得根本原因就在于没把SKB看透它是底层收发包、转发、排队逻辑的载体不理解它你看到的tcpdump数据只是表象。今天我就想带你把它讲透从一条真实报文的流转路径出发拆解SKB的设计逻辑、内存管理方式以及排障时真正用得上的观测手段。不管你是做内核网络开发、容器网络方案还是只负责线上Linux机器的运维这篇文章都能帮你建立一张更清晰的内核网络地图。1. 网络包进内核的第一站从驱动收包到SKB诞生1.1 NAPI在收包路径上扮演什么角色先有中断后有轮询先看收包方向。现代网卡驱动普遍走NAPI模式工作方式大致是这样的网卡把帧通过DMA直接写进内存里预先分配好的ring buffer然后触发一个硬中断。注意中断只是通知CPU“有新包来了”真正的收包处理被放到软中断上下文里以轮询方式批量取包。为什么非要绕个弯而不是每个包都立刻处理因为中断的开销远比很多人想象的大。每次硬中断都要保存现场、执行中断处理函数、再恢复现场高吞吐时如果每秒几十万包CPU大部分时间都被中断淹没甚至可能出现“中断风暴”连正经协议栈处理的时间都没有。NAPI用一次软中断换一批包的批量处理把内核从“中断驱动”拽到“事件驱动”这是Linux网络性能的基础设计之一。驱动在轮询poll里拿到一批收包描述符逐包调用类似napi_gro_receive()或netif_receive_skb()的函数把包“推”向协议栈。到这一步包已经不再是网卡眼中那段原始DMA内存而是一个有结构的sk_buff。这个结构是怎么“变”出来的常见做法是驱动从自己管理的页面池或缓冲区池里取一块内存调用build_skb()或netdev_alloc_skb()把它包装起来。也就是说数据本身并没有被大规模复制而是被组织成了内核认识的对象。这里有个关键点值得说透驱动侧通常有接收缓冲区池DMA直接写入这些预分配页面。如果驱动能把这块内存直接包装成SKB的数据区那就省掉了一次从驱动缓冲区到协议栈缓冲区的拷贝。很多高性能驱动和内核实验室都在这个环节死磕目的就是少搬一次字节。1.2 四个指针怎么配合reserve、put、push、pull的视角切换一个sk_buff的内部视图由head、data、tail、end四个指针加上len字段共同确定。这四个指针的初始化和后续移动对应着几个经典操作reserve在头部预留空间put把数据划入有效区pull让data向后移动push让data向前移动。收包时驱动一般先调用skb_reserve()预留一点空间比如常见的NET_IP_ALIGN让IP头能落在更友好的内存对齐偏移上。数据填充完后包进入协议栈从以太网类型开始逐层向上每剥一层就pull一次。示意流程如下/* 收包侧驱动视角的示意流程 */ skb napi_alloc_skb(napi, length); skb_reserve(skb, NET_IP_ALIGN); // 预留2字节让IP头更好对齐 skb_put(skb, length); // 把DMA写进来的数据划入len范围 /* 协议栈视角进入IP层前剥掉以太网头 */ skb_pull(skb, ETH_HLEN); // data从帧头移动到IP头位置这段代码不是某个真实驱动的完整实现但它精确对应了收包路径上的核心动作。驱动把数据交出去之后协议栈每上一层data指针就往后推一段到TCP层时data已经指向TCP头。整个过程数据没有移动只是“视图”变了。发送方向正好相反。TCP把payload填进来之后IP层要加IP头以太网驱动还要加以太网头。这时候不需要把已经写好的数据往后挪只要用skb_push()把data往前推再往空出来的headroom里填头就行。用一句大白话说数据不动指针动。理解了这一点你就理解了SKB设计里最精妙的一半。1.3 数据不连续怎么办frags与skb_shared_info还有一个容易被忽略但要命的点SKB里的数据并不总是连续的一段内存。TCP大包分段、IP分片、网卡把数据DMA到多个页面都会导致一个skb由“一段线性区”加“多个不连续的片段”组成。skb-len记录的是包的总长度skb-data_len专门记录那些不连续部分的长度。每个片段的信息挂在数据区末尾的skb_shared_info结构里通过frags[]数组管理。这个结构还承担另一个重要角色保存GSO相关信息。比如网卡支持TSO时驱动拿到一个超大skb会根据gso_size、gso_type把payload拆成符合MSS的多个TCP段再发出去。GRO则是反向操作把多个小包合并进同一个skb的shared_info减少协议栈处理次数。可以这么类比skb本身像一篇博文的“标题和目录”数据区是正文frags是散落在不同页面上的段落skb_shared_info则是文末的索引。没有这套组织方式内核要在“连续大内存”里反复搬运数据早就被高速网络拖垮了。2. 为什么SKB能成为“灵魂”双层结构与指针滑动的设计逻辑2.1 外层控制块和内层数据区各管什么SKB最容易被误读的一点是把它当成“一块装着网络数据的内存”。实际上它比这更有层次。第一层是struct sk_buff本身你可以把它看成一个薄薄的控制块。它不装业务数据装的是这台报文在Linux内部行走所需的一切元信息device、protocol、mark、hash、时间戳、队列指针、cb[]私有数据区等等。有些字段是为了让网卡驱动、协议栈、netfilter、socket层能接力协作有些则纯粹是性能统计用的。真正装着网络包内容的是另一块内存也就是head指针指向的数据区。控制块与这块内存之间没有强行的位置约束数据区可以是kmalloc出来的独立内存也可以来自驱动预先DMA好的页面甚至可以是某个大页里的fragment。正是这种分离让skb_clone()变得非常廉价——复制几十个字段数据区继续共享。这种双层结构也解释了为什么网络包在内核里可以被反复传递而不发生数据拷贝。协议栈每一层拿到的都是同一个控制块指针只是data指针所指的位置不同。对Linux内核来说“一个网络包”的实体从来不是那串字节而是围绕那串字节构建出的整个SKB对象。2.2 head、data、tail、end一场不搬数据的“换装秀”四个指针是理解SKB的钥匙。head指向数据区最前端data指向当前协议层的起始位置tail指向当前协议层结束的位置end指向数据区尾部的skb_shared_info。中间的空隙分别是headroom和tailroomheadroom是给将来往上加协议头用的tailroom是给将来追加数据用的。常用操作与指针的关系可以归纳成一张表操作影响典型用途skb_reserve()data和tail同时前移预留headroom收包对齐、发包前预留给各层协议头skb_put()tail后移len增加把新写入的数据纳入有效范围skb_push()data前移len增加添加新的协议头skb_pull()data后移len减少剥离当前层协议头skb_trim()tail前移len减少裁剪尾部多余数据设计哲学就一句话协议栈每处理一层只需要调整指针的“视图”而不是把整个包搬来搬去。这也解释了为什么headroom是必须的。如果发送时没有提前预留空间TCP写完payload之后IP层要加IP头就只好把几十KB数据整体后移这种代价在高速数据面上完全不可接受。所以发送路径的分配函数通常一次就预留类似LL_MAX_HEADER级别的空间后面各层只需要push填头。从缓存角度看这种“只动指针”的方式极好地照顾了cache命中率。数据区保持相对固定CPU各级缓存里的内容不会被频繁失效。内核里很多复杂优化本质上都与这个简单的指针滑动模型有关。2.3 引用共享机制users、dataref与skb_clone再聊内核里另一个藏得很深但又极其重要的设计引用共享。一个包从驱动进协议栈tcpdump要从旁边抓一份netfilter可能要克隆一份处理二层交换可能要向多个口转发如果每次都把整个payload复制一遍高速网络环境下根本扛不住。解决方案是两层引用计数skb-users管着控制块本身被引用的次数skb_shared_info里的dataref管着数据区被共享的次数。skb_clone()只复制一份控制块数据区引用计数加一当最后一个引用释放数据区才真正释放。这里有一个必须记住的边界clone出来的skb和原skb共享同一份payload和frags数组。如果只读它完全没问题如果谁要改写payload或者frags就要小心串扰。所以需要改包的逻辑比如NAT改写、模拟注入、抓包篡改测试在需要保留原始包时会更倾向于skb_copy()或者先确认自己是唯一引用再动手。这一点在实际内核开发中踩坑概率极高后面我还会再提。3. 内存管理是性能分水岭alloc、clone、copy的真实取舍3.1 SKB的内存来自哪里slab缓存、kmalloc与页面池的组合SKB的内存来源不像大多数人想得那么简单。struct sk_buff控制块是在一个专门的slab cache里分配的名称叫skbuff_head_cache。这个cache的object大小完全一致分配和释放特别快同一类结构很容易留在同一页里对cache命中非常友好。数据区是另一条路径小包可以直接kmalloc大包或驱动收包则多用页面池、fragments尽量避免大块连续内存的分配压力。所以当你看到一个SKB被释放不能简单认为“一整块内存没了”。更准确的理解是控制块返还给skbuff_head_cache数据区则通过skb_release_data()归还给kmalloc或页面池。在驱动收包热路径上很多高性能驱动还会用page_pool批量管理接收页让DMA内存和SKB的数据区指向同一块内存。这样连“把数据从驱动缓冲区搬到协议栈缓冲区”的这一步都可以省掉。理解了这套结构就能理解为什么skb_clone()和skb_copy()的开销差异如此巨大。clone只增加一个引用计数copy要把payload完整铺一遍两者的性能差距不再只是“少拷贝几个字段”而是“完全不需要触碰数据区”。3.2 skb_clone与skb_copy怎么选一张对照表讲清楚在实际开发和问题排查里判断该用clone还是copy几乎是一个必考题。我做了张对照表维度skb_clone()skb_copy()复制范围只复制控制块控制块数据区一并复制payload是否共享共享dataref1独立复制互不影响时间开销固定很小随包长线性增长修改payload会影响原skb不影响原skb修改frags/gso字段会影响原skb不会影响原skb典型场景抓包、镜像、多播、多路径分发改包应用、需要保留原始版本怎么选我的经验判断法则就三条只读不写或者写但允许影响原skb用clone改了还要保留原始包只能copy如果确认自己已经独占这个skb那什么都不用做直接改data区就行。别在“可能被共享”的状态下贸然改skb_shared_info里的字段尤其是nr_frags和gso_size。真遇到这种情况先用skb_cloned()判断一下必要时先linearize或copy否则你在一个函数里改的字段可能正被另一个CPU上的消费者使用。很多线上抓包工具导致正常流量异常根因就是这里某个挂了AF_PACKET的程序改动了共享的数据区而它自己以为只是在看包。3.3 头部预留与缓存线对齐多分配几十字节反而更快头部预留听起来浪费内存实际是典型的用空间换时间。收包方向为了不让IP头落在奇数地址破坏对齐驱动在build_skb之前往往预留NET_SKB_PAD和NET_IP_ALIGN发送方向更是要预留足够大的headroom保证从TCP payload往前的所有协议头都能直接填进去。你多分配的那几十字节换来的是整个数据面不用做memmove。另一个值得在意的问题是cache line。网络包处理是最讲究缓存命中率的场景之一同一批包最好在同一个CPU上完成处理控制块、数据区、ring描述符分布要尽量合理。很多内核优化比如批量收包、分组处理、bulk dequeuing本质上都是在减少cache miss。所以看SKB的时候别只盯着内存“省不省”有时候多几十字节、多几次对齐反而是更快更稳的做法。这是个反直觉但真实存在的经验。4. 把理论落地亲手跟踪一份真实报文的SKB流转4.1 观测工具的选择tracepoint、perf与bpftrace想验证上面这些原理不能光看文档要亲手让包走一遍。我的首选是tracepoint因为它比kprobe稳定事件字段基本够用。比如用perf trace挂net:netif_receive_skb这个tracepoint就能看到每次协议栈入口的skb地址、长度和协议号。多数发行版内核上可以直接这么跑perf trace --no-syscalls --event net:netif_receive_skb --event net:netif_rx ping -c 1 对端IP如果机器上有bpftrace也可以自定义打印更多信息bpftrace -e tracepoint:net:netif_receive_skb { printf(skb%llx len%d proto%04x dev%s\n, args-skbaddr, args-len, args-protocol, str(args-dev-name)); }需要注意不同内核版本的事件字段名可能略有差异跑之前用bpftrace -l tracepoint:net:*先确认一下字段名就好。这种方式的好处是稳定适合快速看“协议栈入口发生了什么”。kprobe则更适合看具体函数比如你想跟踪__netif_receive_skb_core、ip_rcv、udp_rcv这些点。风险是这些函数可能被inline内核一升级签名就可能对不上。我的建议是先用tracepoint验证大方向再上kprobe深挖细节顺序反了容易把自己陷进内核版本差异的泥潭。4.2 用一次ping摸清收包与回包的函数链我习惯用一次简单的ping做实验因为ICMP Echo请求短小、路径清晰、没有连接状态的干扰。把function_graph或者kprobe打开追踪ip_rcv你会看到类似这样的链路驱动poll进入__netif_receive_skb_core然后ip_rcvip_local_deliver再根据包类型走到icmp_rcv。如果是TCP或UDP对应位置则会走到tcp_v4_rcv或udp_rcv。收到ICMP请求后回包会走发送侧路径icmp_reply或对应ICMP回显函数然后路由查找进入ip_output、dev_queue_xmit最后交给驱动发送。整条链路像个U型管收包路径从网络设备一路爬到传输层回包路径又从传输层一路落回网络设备。看的时候重点不是背函数名而是体会“数据不动、指针动”。在ip_rcv里skb-data已经指向IP头到icmp_rcv时data又指向ICMP头head从头到尾没变过。如果你在tracepoint里多打一个head字段会发现同一个skb的head在整个链路上一直相同变化的只是data、tail和len。这就是SKB“视图滑动”最直观的证据。4.3 同一个包的“两个分身”如何识别skb_clone现场如果你同时开着tcpdump做观察还会发现一个很有意思的现象同一个“网络包”出现了两个skb。一个在tcpdump进程的AF_PACKET套接字队列里一个还在协议栈真实路径上。这两个skb的地址不同但它们的head指针或者数据区地址往往是相同的——这正是skb_clone()的产物。抓包监听对实时路径的影响被这种共享机制压到了最低。反过来也说明一个问题如果某个抓包工具改动了共享数据比如改了payload或者动了frags字段那么真实流量也会被波及。很多“抓包软件一开业务就开始报错”的灵异事件根子就在这里。想验证也很简单在tracepoint里同时打印skbaddr和head两个值你会发现不同路径上skbaddr在变而head在共享阶段保持不变。这个细节在分析线上问题时特别有用能帮你快速判断一个包到底是“复制”出去的还是“共享”出去的。5. 奔着问题去丢包定位、校验和陷阱与GRO/GSO大包5.1 /proc/net/softnet_stat怎么读协议栈入口的堵点讲完原理进入排障视角。先看一个最常见的入口丢包计数/proc/net/softnet_stat。它每一行对应一个CPU列数在不同内核版本会有差异但前几列相对稳定。一般第一列是processed第二列是dropped第三列是time_squeeze。如果dropped持续增长通常意味着协议栈入口的backlog排队溢出也就是内核来不及处理这么多包。cat /proc/net/softnet_stat sysctl net.core.netdev_max_backlog这个backlog可以理解为协议栈入口的第二层队列网卡ring buffer之后如果CPU软中断处理不过来包会先积压在这里。调大backlog能撑住瞬时峰值但如果CPU长时间不够用看drop掉包的同时还得抓软中断打满的原因——RPS分配是否合理、硬中断是否都压在一个核上、业务进程是否有忙等。别指望只靠一个参数解决所有丢包先定位是哪一层堵了。5.2 tcpdump能抓到但应用收不到三个常见去向另一个很经典的怪象tcpdump明明抓到了包业务进程却收不到。先别怀疑tcpdump它有可能是真的“看着包从旁边过去了”而包在某个层已经被丢弃。常见去向有三个。第一个去向是校验和失败。很多网卡在收包时已经算过IP/TCP/UDP校验和驱动会把状态标记成CHECKSUM_UNNECESSARY上层就不再重复计算。但如果链路有误码或者驱动与网卡在offload配置上不一致坏包会一路走到协议层才被丢弃。查的办法是netstat -s | grep -i -E checksum|bad segment|csum ethtool -k eth0 | grep -i checksum第二个去向是套接字接收队列满。UDP场景尤其明显协议栈已经把skb放到了socket的接收队列但应用取太慢队列超出rmem限制udp_rcv路径会把新包直接丢弃对应计数器是UdpRcvbufErrors。这种丢包发生在包已经进入协议栈并完成校验之后tcpdump当然抓不到“案发现场”。第三个去向是路由或防火墙链路上被drop比如rp_filter反向路径过滤、iptables/nftables规则拦掉等。这类丢包可以通过iptables计数器和conntrack状态判断。所以我的排查顺序固定是先ethtool -S看驱动层再softnet_stat看协议栈入口再netstat -s看协议层最后ss看套接字队列。卡到哪一步就回到那一步对应的SKB逻辑上去分析。很多朋友一上来就抓包或者调参往往会白忙一场因为内核网络排障的第一原则是先圈定丢包发生在整个链路的哪一段。5.3 GRO/GSO带来的“假象”与排查对照最后说一个非常常见的困惑tcpdump抓到的单个包比MTU还大比如看到一个几千字节的TCP包很多人第一反应是“网络栈是不是坏了”。其实这很可能是GSO大包或者是GRO在收方向把多个小包合并过的大skb。tcpdump在协议栈入口附近抓包看到的是合并后的skb而不是线缆上的真实帧。skb_shared_info里的gso_size标记了拆分单位真正到线网才会被拆成符合MSS的段。排查时如果怀疑offload影响了观测可以临时关掉再对比ethtool -K eth0 gro off gso off注意这不是修复问题只是把观测视角切回“经典模式”。问题定位清楚后再把offload打开。很多性能评审看到pps不高就以为出问题了其实在GRO/GSO打开的情况下包处理次数被大幅降下来了吞吐反而更高。理解SKB的意义就在这里它决定了你看网络包时的视角。同样是那串字节在网卡视角是DMA描述符在抓包视角是AF_PACKET队列里的skb clone在协议栈视角是data指针不断滑动的同一个对象。你能不能在排障时快速切换这些视角取决于你对struct sk_buff这套机制的理解深度。最后说个我自己的习惯。遇到网络丢包我很少一上来就翻内核源码而是先用ethtool -S、/proc/net/softnet_stat、netstat -s这三板斧把丢包位置缩小到驱动入口、协议栈入口、还是socket队列。一旦圈定某一步再顺着SKB的指针走一遍看看是头部预留、校验和状态、clone/copy共享还是GSO合并出了问题。这套思路帮我省了不少时间也让我真正体会到SKB为什么被叫做网络子系统里的“灵魂”——它不是一段单纯的数据而是整个收包、处理、转发、排队的经纬线。
返回列表