跳到主要内容

2.6 通用 Netlink 协议

还记得上一节结尾抛出的那个问题吗?

如果你写了一个非标准的内核模块——比如控制某个炫酷的硬件——想给它在用户空间配个控制接口,你会怎么做?

此时你面临着两难:用 IOCTL?太老了,而且搞不定多播和异步通知。用 Netlink?很现代,但 Netlink 的协议族 ID 资源(MAX_LINKS)只有 32 个。 NETLINK_ROUTENETLINK_KOBJECT_UEVENT……这些大个子已经占了好位子,剩下的坑位寥寥无几。如果你的每个模块都去申请一个专用的 Netlink 协议族,系统很快就会弹尽粮绝。

为了解决这个「插座不够用了」的危机,内核引入了 Generic Netlink

你可以把 Generic Netlink 想象成一个多路复用器。它只占用标准 Netlink 协议族中的一个位置——NETLINK_GENERIC——但在这个单一的通道之上,它能承载无限个自定义的「家族」。这就像是你在一条物理网线上跑了很多个 VLAN,或者在一个大厅里安排了一个总是能帮你找到对应办事员的「总服务台」。


核心机制:总服务台

从开发者的角度看,Generic Netlink 做的最关键的一件事,就是把「硬编码的 ID」变成了「运行时动态分配的 ID」。

在标准的 Netlink 里,协议族 ID 是写死在头文件里的。但在 Generic Netlink 里,你只需要定义一个名字(比如 "nlctrl" 或 "nl80211"),内核会在你注册时自动分配一个唯一的数字 ID。

为了管理这个动态分配的过程,Generic Netlink 引入了一个特殊的家族,叫做 Controllernlctrl)。它是所有家族的「总服务台」,它的 ID 是固定的(GENL_ID_CTRL,即 0x10),它是整个 Generic Netlink 机制的基石。

用户空间程序如果不知道某个家族(比如无线配置家族 "nl80211")的数字 ID 是多少,会先发一条消息去问 nlctrl:「嘿,那个叫 "nl80211" 的家伙 ID 是多少?」nlctrl 查表后回复:「是 21。」然后用户空间才拿着 ID 21 去发真正的配置命令。

到这里,类比的边界开始清晰了: 「总服务台」这个比喻只解释了查找功能,但它漏掉了关键的一点——nlctrl 本身也是一个被注册的 Netlink 家族,它和普通家族在内核代码层面是平级的,只是功能特殊罢了。

内核里的初始化

Generic Netlink 的内核入口在 net/netlink/genetlink.c。它是随着网络命名空间一起初始化的:

static int __net_init genl_pernet_init(struct net *net) {
..
struct netlink_kernel_cfg cfg = {
.input = genl_rcv,
.cb_mutex = &genl_mutex,
.flags = NL_CFG_F_NONROOT_RECV,
};
net->genl_sock = netlink_kernel_create(net, NETLINK_GENERIC, &cfg);
...
}

注意这里,它调用 netlink_kernel_create() 时传的是 NETLINK_GENERIC。这里创建了 Generic Netlink 的总入口 socket。

每一句都很关键:

  1. .input = genl_rcv: 所有从用户空间发来的 NETLINK_GENERIC 消息,都会进到这个 genl_rcv() 回调函数。它是所有 Generic Netlink 消息的总入口。
  2. net->genl_sock: 这个 socket 指针被存在了网络命名空间对象里。这意味着 Generic Netlink 是网络命名空间感知的。不同的容器可以看到不同的 Netlink 家族吗?不,家族通常是全局的,但 socket 的生命周期 是跟随命名空间的。
  3. cb_mutex: 这个全局锁 genl_mutex 保护着家族注册表。这说明 Generic Netlink 的核心操作是加锁的,不是完全无锁的。

紧接着,代码会注册那个最重要的「总服务台」——genl_ctrl

static struct genl_family genl_ctrl = {
.id = GENL_ID_CTRL, // 固定 ID:16
.name = "nlctrl",
.version = 0x2,
.maxattr = CTRL_ATTR_MAX,
.netnsok = true,
};

static int __net_init genl_pernet_init(struct net *net) {
...
err = genl_register_family_with_ops(&genl_ctrl, &genl_ctrl_ops, 1);
...
}

genl_ctrl唯一一个拥有硬编码 ID 的家族(GENL_ID_CTRL,即 16)。除此之外,其他所有家族在注册时,ID 都会被填为 GENL_ID_GENERATE(实际是 0),然后内核会在 genl_register_family() 里通过 find_first_zero_bit() 给它分配一个 16 到 1023 之间的唯一数字。

回到「总服务台」的类比:现在你能看出来,genl_ctrl 不仅仅是提供服务,它是整个 Generic Netlink 机制启动后注册的第一个家族。没有它,后续的查找流程就转不起来。


如何定义自己的家族

假设你在写一个无线驱动,或者像书里例子那样写 NFC 子系统,你想用 Generic Netlink 通信。你需要做两件事:

  1. 定义并注册一个 genl_family(家族)。
  2. 定义并注册一个或多个 genl_ops(操作)。

你可以用 genl_register_family_with_ops() 一次性把家族和操作数组都注册进去。书里以无线子系统 nl80211 为例,这是最经典的实战案例。

家族定义:nl80211

无线子系统定义的家族长这样:

static struct genl_family nl80211_fam = {
.id = GENL_ID_GENERATE, // 让内核自动分配 ID
.name = "nl80211", // 用户空间通过这个名字查找
.hdrsize = 0, // 没有私有头部
.version = 1, // 版本号(实际意义不大)
.maxattr = NL80211_ATTR_MAX, // 支持的属性最大值(用于校验)
.netnsok = true, // 支持网络命名空间
.pre_doit = nl80211_pre_doit, // 操作前的钩子(比如加锁)
.post_doit = nl80211_post_doit,// 操作后的钩子(比如解锁)
};

字段拆解

  • .name: "nl80211"。这是用户空间和内核之间的契约。用户空间不知道 ID 是 20 还是 21,但它知道名字叫 "nl80211"。
  • .maxattr: NL80211_ATTR_MAX。这个数字定义了这个家族支持多少种属性。配合 nla_policy 使用,用来做参数校验。如果用户发来一个属性 ID 超过了这个值,内核会直接拒绝。
  • .pre_doit / .post_doit: 这两个钩子非常实用。在执行具体命令(doit)之前,内核通常会调用 pre_doit 来获取锁(比如 RTNL 锁)或者引用网络设备;执行完后,post_doit 负责清理。这避免了在每个命令函数里写重复的加锁/解锁代码。
  • .netnsok: 设为 true 表示这个家族可以在容器中使用。

操作定义:genl_ops

有了家族,还需要具体的命令。Generic Netlink 用 genl_ops 结构体来描述一个命令的处理方式:

struct genl_ops {
u8 cmd; // 命令 ID
u8 internal_flags; // 内部标志位
unsigned int flags; // 操作标志(如权限)
const struct nla_policy *policy; // 属性校验策略
int (*doit)(struct sk_buff *skb, struct genl_info *info);
int (*dumpit)(struct sk_buff *skb, struct netlink_callback *cb);
int (*done)(struct netlink_callback *cb);
struct list_head ops_list;
};

这是一个典型的 C 语言面向对象 设计。

  • .cmd: 命令的编号。比如 NL80211_CMD_GET_SCAN(扫一波看看)。
  • .doit: 处理「单体」请求的回调。比如「设置某个 SSID」。
  • .dumpit: 处理「列表」转储的回调。比如「列出所有扫描到的 AP」。这是一个非常关键的设计:如果你只是想获取一个对象的信息,走 doit;如果你想获取一堆对象的列表,走 dumpit。Netlink 框架会负责处理分段发送(因为消息可能超过 MTU),你只需要在 dumpit 里逐个返回对象即可。
  • .policy: 指向一个 nla_policy 数组,用于校验用户发进来的参数。比如「这里必须是一个 32 位整数」,「那里必须是一个字符串」。如果不匹配,内核在进到你函数之前就拦截了。
  • .flags: 可以设置 GENL_ADMIN_PERM,表示这个命令需要 CAP_NET_ADMIN 权限(也就是 root)。

nl80211 里,命令表长这样:

static struct genl_ops nl80211_ops[] = {
{
...
.cmd = NL80211_CMD_GET_SCAN,
.policy = nl80211_policy,
.dumpit = nl80211_dump_scan,
},
...
};

这里只定义了 .dumpit,没有定义 .doit。这说明 NL80211_CMD_GET_SCAN 是一个用来「列出结果」的命令。

⚠️ 注意: 注册 genl_ops 时,.doit.dumpit 至少要指定一个,否则内核会直接返回 -EINVAL。你既然定义了一个命令,却不给它处理函数,这肯定是不对的。


用户空间的交互流程

好了,内核侧的 socket 已经建好了,家族也注册了。用户空间怎么玩?

如果你在命令行敲 iw dev wlan0 scaniw 这个工具背后其实用 libnl-genl 库走了一套完整的流程。

这套流程非常标准,任何 Generic Netlink 程序都得这么走:

  1. 创建 Socket: socket(AF_NETLINK, SOCK_RAW, NETLINK_GENERIC)
  2. 解析家族名字: 发送 CTRL_CMD_GETFAMILY 命令给内核的 nlctrl,询问 "nl80211" 的 ID 是多少。
  3. 收到 ID: 内核回复,告诉它 "nl80211" 的 ID 是 21。
  4. 发送真实命令: 发送 NL80211_CMD_GET_SCAN 消息,此时的 Netlink 头部里的目标协议族 ID 填 21。

用代码看更清楚。这是 iw 工具初始化时的简化逻辑:

// 1. 分配 socket
state->nl_sock = nl_socket_alloc();

// 2. 连接并绑定 (内部做了 socket() 和 bind())
genl_connect(state->nl_sock);

// 3. 解析 "nl80211" 家族名称到 ID
// 内部会发送 CTRL_CMD_GETFAMILY,并阻塞等待回复
int family_id = genl_ctrl_resolve(state->nl_sock, "nl80211");

这一步 genl_ctrl_resolve 是最关键的。它屏蔽了底层的「询问-等待-解析」细节。如果不使用 libnl,你就得自己构造 CTRL_CMD_GETFAMILY 消息,手动解析返回的 Netlink 属性(TLV)。


消息的解剖学

让我们深入到消息的字节级。Generic Netlink 消息是一层套一层的,就像俄罗斯套娃。

从外到里,顺序是:

  1. Netlink 头部 (struct nlmsghdr):这是标准 Netlink 都有的,包含长度、类型、标志。
  2. Generic Netlink 头部 (struct genlmsghdr):这是 Generic Netlink 特有的。
  3. 私有头部(可选):某些协议(如 nl80211)可能会在这里加一点自定义字段,hdrsize 就是干这个用的。
  4. 载荷(TLV 属性):真正的数据。

来看看 struct genlmsghdr

struct genlmsghdr {
__u8 cmd; // 命令类型
__u8 version; // 版本号
__u16 reserved; // 保留字段
};
  • cmd: 就是你之前在 genl_ops 里定义的那个命令 ID(比如 NL80211_CMD_GET_SCAN)。
  • version: 允许协议演进。如果你改了消息格式,把版本号加 1,内核就能识别并兼容旧版本。

内核端发送消息: 如果你想从内核发一个消息给用户空间(比如事件通知),流程是这样的:

// 1. 分配 skb 缓冲区
struct sk_buff *skb = genlmsg_new(payload_size, GFP_KERNEL);

// 2. 添加 Generic Netlink 头部(返回指向 payload 起始的指针)
void *hdr = genlmsg_put(skb, 0, 0, &nl80211_fam, 0, NL80211_CMD_NEW_AP);

// 3. 填充 TLV 属性
nla_put_u32(skb, NL80211_ATTR_WIPHY, phy_id);
nla_put_string(skb, NL80211_ATTR_IFNAME, "wlan0");

// 4. 发送单播
genlmsg_unicast(genl_info_net(info), skb, info->snd_portid);
// 或者广播
genlmsg_multicast(&nl80211_fam, skb, 0, 0, GFP_KERNEL);

这里 genlmsg_put() 会帮你把 nlmsghdrgenlmsghdr 都安好,并留出空间让你填充属性。


实战案例:Socket 监控

作为 Generic Netlink 的一个应用案例,书里提到了 Socket Monitoring Interface (NETLINK_SOCK_DIAG)。

你可能用过 ss 命令(ss -tntp)。它比老古董 netstat 强大得多。ss 为什么能知道哪个 socket 属于哪个进程?为什么能精确显示 TCP 的内部状态(比如 retransmit 计数)?

靠的就是这个 NETLINK_SOCK_DIAG

为什么要搞这个? /proc 文件系统虽然暴露了 socket 信息,但它有两个缺点:

  1. 格式是给人看的,不好解析。
  2. 关键信息缺失。最典型的例子是 UNIX Domain Socket:/proc 不会告诉你这个 socket 连到了谁。但如果你想做进程迁移(CRIU),你必须知道 peer 是谁才能把连结重建起来。

所以,内核引入了 sock_diag

它在内核里的实现也是通过 Generic Netlink 机制。虽然是 NETLINK_SOCK_DIAG 这个专用协议族,但它的设计思路和 Generic Netlink 是一脉相承的:注册一个处理表,根据协议类型分发到不同的 handler。

struct sock_diag_handler {
__u8 family; // 协议族,如 AF_INET, AF_UNIX
int (*dump)(struct sk_buff *skb, struct nlmsghdr *nlh);
};

比如 UNIX socket 的诊断模块:

static const struct sock_diag_handler unix_diag_handler = {
.family = AF_UNIX,
.dump = unix_diag_handler_dump,
};

注册后,用户空间的 ss 工具发送一个 AF_UNIX 的查询请求,内核就会调用 unix_diag_handler_dump 把所有 UNIX socket 的详细信息(包括那些 /proc 里没有的)打包成 Netlink 消息发回去。

这就是为什么你在用 ss -x 时能看到极其详细的 UNIX socket 连接图。


本章小结

Generic Netlink 解决了 Netlink 协议族数量受限的问题,它通过引入一个「Controller」家族,实现了基于名字的动态家族注册

这一节我们建立的核心认知是:

  1. 多路复用NETLINK_GENERIC 是一条高速公路,上面跑了无数个自定义协议家族。
  2. 动态查找:用户空间通过名字找内核家族,不再依赖硬编码的 ID。
  3. 标准化操作doit (单体操作) 和 dumpit (列表转储) 的分离,以及 policy 校验机制,构成了内核 Netlink 接口的标准开发模式。

理解了 Generic Netlink,你就拿到了一把钥匙。现代 Linux 网络子系统(尤其是无线)的大门已经向你打开了。

但这还不够。网络不仅需要管理和配置,还需要诊断探测。下一章,我们将不再关注「怎么发控制命令」,而是关注网络本身发出的「声音」——ICMP 协议。当网络出问题时,它是那个报信的人。