Skip to content

2.3 Netlink 消息头

上一节我们看完了内核是怎么把消息扔进发送队列的——函数调用链一路从 rtnetlink 走到通用的 netlink 模块。

但这就像是你知道邮局怎么分拣信件,却还没看过信封上写了什么。Netlink 通信的本质是数据包,而数据包不是随便写的,它得遵守 RFC 3549("Linux Netlink as an IP Services Protocol")里定下的规矩。

这一节,我们不再围着函数调用转,而是把数据包拆开,盯着里面的字节看。

头部结构

每个 Netlink 数据包的必经之路,是先经过一个固定的 16 字节头部。这个头部在内核里的定义是 struct nlmsghdr,就在 include/uapi/linux/netlink.h 里:

c
struct nlmsghdr
{
    __u32 nlmsg_len;   /* 消息总长度(包含头部) */
    __u16 nlmsg_type;  /* 消息类型 */
    __u16 nlmsg_flags; /* 标志位 */
    __u32 nlmsg_seq;   /* 序列号 */
    __u32 nlmsg_pid;   /* 端口 ID (Port ID) */
};

这就是 Netlink 的“身份证”。我们可以把这 16 个字节分成五个字段来看:

  1. nlmsg_len:这不仅指数据长度,而是整个消息的长度,包括头本身。解析器拿到这个数,就知道读多少个字节后该停下来处理下一条。

  2. nlmsg_type:这决定了这个包是干嘛的。它有一些通用的控制类型(小于 NLMSG_MIN_TYPE,即 0x10):

    • NLMSG_NOOP:空操作,收到直接忽略。
    • NLMSG_ERROR:表示出错了。如果消息发送方要求了应答(NLM_F_ACK),出错时内核就会回这么一个包。
    • NLMSG_DONE:用于多部分消息的分段标记。收到这个,说明大消息传输结束了。
    • NLMSG_OVERRUN:缓冲区溢出,数据丢了——这是个严重的错误信号。

    但更有意思的是,每个具体的 Netlink 协议族(比如我们上一节提到的 rtnetlink)都会在这个字段里定义自己的“方言”。例如,NETLINK_ROUTE 定义了 RTM_NEWLINK(新建网卡)、RTM_DELLINK(删除网卡)、RTM_NEWROUTE(新建路由)等一大堆类型。内核就是靠这个字段,把消息分发到对应的处理函数去的。

  3. nlmsg_flags:这是消息的“行为指令”。最常用的几个包括:

    • 请求与应答
      • NLM_F_REQUEST:表明这是一个请求消息。
      • NLM_F_ACK:要求对方收到后回一个确认包。调试时很有用,生产环境通常为了性能会关掉。
    • 数据转储
      • NLM_F_DUMP:这很关键。当你想要“把路由表全给我”或者“把所有网卡信息给我”时,就要设这个标志。
      • NLM_F_MULTI:配合 DUMP 使用。因为返回的数据量通常很大,一个包装不下,内核会分批发送。除了最后一个包,其他的都会带 MULTI 标志。
    • 创建与修改(用于 CRUD 操作):
      • NLM_F_CREATE:如果不存在就创建。
      • NLM_F_EXCL:如果已存在就报错(配合 CREATE 使用,实现“Create if not exists”)。
      • NLM_F_REPLACE:覆盖已存在的条目。
  4. nlmsg_seq:序列号。这跟 TCP 的序列号不太一样,Netlink 层面没有强制要求它的连续性。它主要是给用户空间程序用来匹配“请求”和“应答”的——我发出去是序列号 5,回来的 ACK 只要也是 5,我就知道这是对我那条消息的回应。

  5. nlmsg_pid:这是发送方的“端口号”。

    • 如果是内核发出的消息,这个字段固定为 0
    • 如果是用户空间发出的,通常是发送进程的 PID(Process ID)。
    • 这就解释了为什么内核知道该把回复发给谁——它直接抄 nlmsg_pid 作为目标地址就行了。

(Figure 2-3. nlmsg header 展示了这 16 个字节的内存布局)


载荷与 TLV 编码

头部后面跟着的就是载荷。

直接把数据硬塞进去是不行的,那样内核解析起来太痛苦。Netlink 采用了一种极其经典的编码格式:TLV(Type-Length-Value,类型-长度-值)

这玩意儿在网络协议里到处都是(比如 IPv6 的扩展头),它的核心思想是自描述。你想传一个 IP 地址?没问题,你要传一个字符串?也没问题。只要你在头部说清楚它是什么类型、有多长,解析器就能读懂。

每个 Netlink 属性的前面,都有一个由 struct nlattr 定义的小头部:

c
struct nlattr {
    __u16 nla_len;  /* 该属性的总长度(包含头部) */
    __u16 nla_type; /* 属性类型 */
};
  • nla_len:告诉解析器,读多少个字节后能跳过这个属性。
  • nla_type:定义这个属性里装的是什么。
    • NLA_U32:装的是一个 32 位无符号整数。
    • NLA_STRING:装的是字符串。
    • NLA_NESTED:表示这个属性的 Value 里,装的竟然还是一套 TLV 结构——也就是嵌套属性。这允许我们构建出复杂的树状数据结构。

⚠️ 注意:对齐问题 虽然结构体定义看起来很随意,但在内存布局上,每个 Netlink 属性都必须按 4 字节边界对齐NLA_ALIGNTO)。如果你在手动构造包的时候没做对齐补齐,内核那边解析时可能会因为对齐错误而直接丢包,或者读出来一堆乱码。


属性验证策略

内核不是来者不拒的。收到一个 Netlink 消息后,它必须得知道里面的属性合不合法。

每个协议族都会定义一个属性验证策略,用一个叫 struct nla_policy 的数组来表示。你会发现它的结构跟 struct nlattr 几乎一模一样:

c
struct nla_policy {
    u16 type; /* 期望的类型,如 NLA_U32 */
    u16 len;  /* 期望的长度限制 */
};

这个数组是按属性索引排列的。当内核调用 nlmsg_parse() 解析消息时,它会顺带调用 validate_nla()(在 lib/nlattr.c 里)拿着这个策略表去对表。

验证规则很细致:

  • 如果是定长类型(比如 NLA_U32),策略里的 len 通常就可以忽略,因为长度是固定的。
  • 如果是字符串NLA_STRING),len 代表的是最大允许长度(不包含结尾的 \0)。超过这个长度的字符串会被拒绝。
  • 如果是标记位NLA_FLAG),len 根本没用。因为这个属性存在就是 true,不存在就是 false,值本身没意义。

这里有个坑:如果收到的属性类型超过了策略数组定义的 maxtype,内核会静默忽略它(Silently ignore)。这是为了向后兼容——老内核收到新用户空间发来的扩展属性时,不至于直接报错,而是跳过看不懂的部分。但这也意味着,如果你发现自己新加的属性怎么都不生效,先检查一下是不是填错了 nla_type 导致它被当成“未知扩展”给扔了。

内核接收流程

最后,让我们把这些碎片拼起来,看看内核是怎么处理一个 Generic Netlink 消息的(入口在 genl_rcv_msg()):

  1. 它是 Dump 吗?:先检查 nlmsg_flags 里有没有 NLM_F_DUMP
    • 如果有,调用 netlink_dump_start()。这会触发内核遍历指定的表(比如路由表),把所有条目打包发回给用户。
  2. 不是 Dump?那就解析
    • 调用 nlmsg_parse()。这会根据我们刚才说的 nla_policy 验证每一个属性。
    • 如果验证失败,流程直接中断,返回错误码。
  3. 执行操作
    • 只有验证通过了,才会继续走下一步,调用你在 genl_ops 里注册的 doit() 回调函数。

这就像一个严苛的海关:先看你的签证(头部),再检查你的行李(属性验证),两样都过了,才会放行让你入境(执行回调)。

Netlink 消息头和 TLV 机制构成了整个 IPC 协议的基石。既然地基打好了,下一节我们就要往上盖楼了——去看看网络子系统里最常用的那些 NETLINK_ROUTE 消息到底长什么样,以及它们是如何控制路由表和网卡状态的。

基于 VitePress 构建