当我们在嵌入式设备上写一个 TCP 服务、做一个 UDP 透传程序、运行一个 MQTT 客户端,甚至用 ping 命令测试网络时,所有数据的起点和终点,都是 Socket。
很多开发者经常在用 Socket,却只停留在 socket()、connect()、send()、recv() 这几个函数调用上,完全不清楚一个字节从应用程序发出后,到底经历了怎样的旅程,才真正从网口发送出去;也不知道一个网口进来的数据包,又是如何层层过滤、解析,最终送到应用的缓冲区里。
一旦出现发送卡住、收不到数据、延迟高、丢包、流量上不去、CPU 占用异常、断连不感知、抓包看不到数据等嵌入式常见网络问题时,就会完全无从下手。
本篇我们从工程实践角度,把 Socket 的本质、内核对应的结构、数据包的完整发送/接收路径、sk_buff 存在的意义、以及如何用 Socket 直接访问底层网络设备一次性理清楚。本文参考Bootlin资料进行总结归纳。
一、Socket 到底是什么?它为什么是唯一入口?
Socket 常常被翻译为“套接字”,这个词听起来很抽象,但它的本质非常简单:它是用户程序与内核网络栈之间的标准化通信接口。
Linux 的设计逻辑非常明确: 应用程序运行在用户态,权限低、受保护,绝对不能直接访问硬件、不能直接操作内核内存、不能直接调用驱动。而网络协议栈、网卡驱动、数据包队列全都在内核态。
那么应用程序要发数据、要监听端口、要建立连接,只能通过内核提供的 系统调用 完成。而 Socket 就是为网络通信专门设计的一套统一接口。
它的强大之处在于: 不管你用的是 TCP、UDP、原始链路层,还是本地进程间通信,甚至是蓝牙、CAN 总线,用户态看到的接口几乎是一样的。一套接口,统一语义,适配所有网络类型。这也是为什么 Linux 网络可以如此灵活扩展。
换句话说: 任何网络数据,只要是从应用发出去、或者从外部进入应用,必经 Socket。它不是可选的,而是唯一入口。
二、一个 Socket 在内核里到底长什么样?
我们在应用程序里看到的只是一个整数 int sockfd,一个文件描述符。但在内核里,一个 Socket 对应一组完整的结构,这是理解数据通路的关键。
内核会用三层结构来描述一个 Socket:
- 第一层是
struct file,因为 Linux 中一切文件描述符最终都要对应一个文件结构体,这是为了兼容 VFS(虚拟文件系统),让 read/write/close 这些通用接口可以工作。 - 第二层是
struct socket,这是网络层面的通用结构。它记录了 Socket 的类型:是流式(TCP)还是数据报式(UDP),是 IPv4 还是原始数据包,它持有操作函数集合,决定如何建立连接、如何发送数据。 - 第三层是
struct sock,这才是内核网络真正的核心结构。它维护着收发队列、缓存、连接状态、定时器、丢包统计、绑定的网卡信息、路由信息等。TCP 的滑动窗口、重传队列、拥塞控制,全都在这一层。
所以,文件描述符背后是一套完整的连接上下文。 当我们调用 send 时,并不是直接把数据扔给网卡,而是把数据交给内核的 Socket 队列,由协议栈异步处理、分片、排队、路由、发送。
这也解释了一个经典问题: 为什么 send 成功返回,不代表数据已经发出去了? 因为它只是成功放进了内核发送队列。
三、最关键的载体:struct sk_buff 数据包
在内核网络栈里,不管是 TCP、UDP、IP、以太网帧,所有数据包都用同一个结构表示:struct sk_buff。
它被简称为 skb,是 Linux 网络的“数据包通用载体”。
我们可以把 sk_buff 理解为一个带头部空间、数据区、尾部空间、偏移标记、网卡信息、优先级、时间戳的智能数据包。
它最精妙的设计,是支持动态去头、扩容、截断、分片、克隆等,而不需要频繁拷贝内存。
- 从网卡上来的数据,先进入 skb,此时指针指向以太网帧头。
- 进入 L2 处理,调用
skb_pull 剥掉以太网头,指针指向 IP 头。 - 进入 L3 处理,再剥掉 IP 头,指向 TCP/UDP 头。
发送流程则完全相反: 应用数据 → 进入 skb → 加上 TCP 头 → 加上 IP 头 → 加上以太网头 → 交给驱动。
整个过程几乎无内存拷贝,效率极高。
绝大多数嵌入式网络性能问题、丢包问题、延迟问题,本质上都和 skb 的队列长度、挂载位置、被丢弃的位置、被延迟的环节有关。
四、发送路径:应用 send 之后,数据究竟经历了什么?
当应用程序调用 send 写入一段数据时,首先触发系统调用进入内核。内核根据文件描述符找到对应的 struct sock,把数据拷贝到内核空间,封装进 sk_buff。
接下来进入传输层。 如果是 TCP,内核会给数据加上 TCP 头部,填写端口号、序号、确认号、窗口等信息,然后把 skb 交给 IP 层。 如果是 UDP,就加上简单的 UDP 头部,直接交给 IP 层。
进入网络层(L3),内核会查询路由表,决定这包数据从哪张网卡发出。然后加上 IP 头部,填写源 IP、目标 IP、TTL、协议类型。如果数据包超过 MTU,还会执行分片。
然后进入链路层(L2)。 内核会查询 ARP 表,获取目标 MAC 地址。如果找不到,就先发送 ARP 请求,数据包会被暂时缓冲。拿到 MAC 后,内核给 skb 加上以太网头:目标 MAC、源 MAC、类型字段。
此时一个完整的以太网帧已经构造完成。 skb 进入 TC 流量控制系统,进行限速、优先级排队、流量整形。
等调度器允许发送时,内核调用网卡驱动的 ndo_start_xmit 函数,把 skb 交给驱动。驱动将数据映射为 DMA 地址,交给网卡硬件。
硬件通过 DMA 方式把数据从内存发到 PHY,再通过网线发送出去。
发送完成后,硬件产生中断,驱动回收 skb,释放内存。
五、接收路径:网口进来的数据,如何到达应用?
接收流程是发送的反向过程,但更加复杂,因为它是异步、中断驱动、批量处理的。
网线上来的模拟电信号被 PHY 接收,解码后交给 MAC。 MAC 校验无误后,通过 DMA 把数据写入内核提前分配好的内存缓冲区。
然后硬件触发中断,通知 CPU 有数据到达。
网卡驱动为了避免频繁中断占用 CPU,会采用 NAPI 机制:关闭中断,进入轮询模式,一次性批量收取多个数据包。
每收到一帧数据,驱动就构造一个 skb,把 DMA 数据挂载上去,然后交给内核协议栈。
首先进入 L2 处理:解析以太网头,判断是 IPv4、ARP 还是 VLAN。 如果是 VLAN 帧,会先处理标签,再继续向上传递。
进入 L3 处理:解析 IP 头,判断是交给本机,还是需要转发。 如果是本机数据,继续向上送到 L4。
进入 L4 处理:解析 TCP/UDP 头,根据目标端口,找到对应的 Socket,把数据放进 Socket 的接收队列。
此时应用程序可以通过 recv 读取到数据。
整个过程完全在内核异步完成,应用完全感知不到底层的复杂性。
六、如何用 Socket 直接访问底层网络设备?
在很多嵌入式场景里,我们并不想使用 TCP/UDP,而是直接发以太网帧、直接抓二层包、自定义私有协议、透传、工业总线数据封装。
这时就需要用到 AF_PACKET 原始套接字。
它是一种特殊的 Socket,可以直接绕开 L4、L3,让应用程序直接读写以太网帧。
socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL))
它的特点非常鲜明:
- tcpdump、wireshark 底层就是用它实现的
对于做工业网关、串口转以太网、私有协议、抓包工具、透传程序的嵌入式开发者来说,AF_PACKET 是必须掌握的工具。
七、Socket 与 net_device 的关系
一个 Socket 可以绑定到指定网卡,只让它从该网卡收发数据,不走路由表判断。 这就是 SO_BINDTODEVICE 选项。
内核内部的关系非常清晰:
- Socket 最终要选择一个 net_device 发送数据
八、总结
到这里,我们完整梳理了 Socket 的本质、内核结构、数据包的完整收发路径、skb 的意义,以及原始套接字访问底层设备的方式。
Socket 是用户态进入内核的唯一桥梁, sk_buff 是内核网络的统一载体, 而这条从应用到硬件的完整通路,就是理解一切网络行为的关键。