跳到主要内容

2.3 Netlink 消息头

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

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

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

头部结构

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

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 定义的小头部:

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 几乎一模一样:

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 消息到底长什么样,以及它们是如何控制路由表和网卡状态的。