内核角 - 为什么以及如何使用 Netlink 套接字

作者:Kevin Kaichuan He

由于开发和维护内核的复杂性,只有最重要和性能关键的代码才会被放置在内核中。其他事情,例如 GUI、管理和控制代码,通常被编程为用户空间应用程序。这种在内核和用户空间之间拆分某些功能实现的做法在 Linux 中非常常见。现在的问题是内核代码和用户空间代码如何相互通信?

答案是内核和用户空间之间存在的各种 IPC 方法,例如系统调用、ioctl、proc 文件系统或 netlink 套接字。本文讨论了 netlink 套接字,并揭示了其作为网络功能友好的 IPC 的优势。

简介

Netlink 套接字是一种特殊的 IPC,用于在内核和用户空间进程之间传输信息。它通过用户空间进程的标准套接字 API 和内核模块的特殊内核 API,在两者之间提供全双工通信链路。与 TCP/IP 套接字使用的 AF_INET 相比,Netlink 套接字使用地址族 AF_NETLINK。每个 netlink 套接字功能都在内核头文件 include/linux/netlink.h 中定义了自己的协议类型。

以下是 netlink 套接字当前支持的功能及其协议类型的子集

  • NETLINK_ROUTE:用户空间路由守护进程(例如 BGP、OSPF、RIP)和内核数据包转发模块之间的通信通道。用户空间路由守护进程通过此 netlink 协议类型更新内核路由表。

  • NETLINK_FIREWALL:接收 IPv4 防火墙代码发送的数据包。

  • NETLINK_NFLOG:用户空间 iptable 管理工具和内核空间 Netfilter 模块的通信通道。

  • NETLINK_ARPD:用于从用户空间管理 arp 表。

上述功能为什么使用 netlink 而不是系统调用、ioctl 或 proc 文件系统来在用户和内核世界之间进行通信?为新功能添加系统调用、ioctl 或 proc 文件是一项艰巨的任务;我们有污染内核并损害系统稳定性的风险。但是,Netlink 套接字很简单:只需要将一个常量(协议类型)添加到 netlink.h 中即可。然后,内核模块和应用程序可以立即使用套接字风格的 API 进行通信。

Netlink 是异步的,因为与任何其他套接字 API 一样,它提供了一个套接字队列来平滑消息突发。用于发送 netlink 消息的系统调用将消息排队到接收者的 netlink 队列,然后调用接收者的接收处理程序。接收者在接收处理程序的上下文中,可以决定是立即处理消息还是将消息留在队列中,稍后在不同的上下文中处理。与 netlink 不同,系统调用需要同步处理。因此,如果我们使用系统调用将消息从用户空间传递到内核,如果处理该消息的时间很长,则可能会影响内核调度粒度。

在内核中实现系统调用的代码在编译时静态链接到内核;因此,在可加载模块中包含系统调用代码是不合适的,而大多数设备驱动程序都是这种情况。使用 netlink 套接字,Linux 内核的 netlink 核心和驻留在可加载内核模块中的 netlink 应用程序之间不存在编译时依赖性。

Netlink 套接字支持多播,这是优于系统调用、ioctl 和 proc 的另一个好处。一个进程可以将消息多播到 netlink 组地址,并且任意数量的其他进程可以监听该组地址。这为从内核到用户空间的事件分发提供了近乎完美的机制。

系统调用和 ioctl 是单工 IPC,因为这些 IPC 的会话只能由用户空间应用程序启动。但是,如果内核模块有发给用户空间应用程序的紧急消息怎么办?使用这些 IPC 无法直接做到这一点。通常,应用程序需要定期轮询内核以获取状态更改,尽管密集轮询成本很高。Netlink 通过允许内核也启动会话来优雅地解决了这个问题。我们称之为 netlink 套接字的双工特性。

最后,netlink 套接字提供了 BSD 套接字风格的 API,这被软件开发社区很好地理解。因此,与使用相当晦涩的系统调用 API 和 ioctl 相比,培训成本更低。

与 BSD 路由套接字相关

在 BSD TCP/IP 堆栈实现中,有一个特殊的套接字称为路由套接字。它具有地址族 AF_ROUTE、协议族 PF_ROUTE 和套接字类型 SOCK_RAW。BSD 中的路由套接字供进程用于在内核路由表中添加或删除路由。

在 Linux 中,路由套接字的等效功能由 netlink 套接字协议类型 NETLINK_ROUTE 提供。Netlink 套接字提供了 BSD 路由套接字的功能超集。

Netlink 套接字 API

用户空间应用程序可以使用标准套接字 API—socket()、sendmsg()、recvmsg() 和 close()—来访问 netlink 套接字。有关这些 API 的详细定义,请查阅手册页。在这里,我们仅在 netlink 套接字的上下文中讨论如何为这些 API 选择参数。对于任何编写过使用 TCP/IP 套接字的普通网络应用程序的人来说,这些 API 应该很熟悉。

要使用 socket() 创建套接字,请输入

int socket(int domain, int type, int protocol)

套接字域(地址族)是 AF_NETLINK,套接字类型是 SOCK_RAW 或 SOCK_DGRAM,因为 netlink 是一种面向数据报的服务。

协议(协议类型)选择套接字用于哪个 netlink 功能。以下是一些预定义的 netlink 协议类型:NETLINK_ROUTE、NETLINK_FIREWALL、NETLINK_ARPD、NETLINK_ROUTE6 和 NETLINK_IP6_FW。您也可以轻松添加自己的 netlink 协议类型。

每个 netlink 协议类型最多可以定义 32 个多播组。每个多播组由一个位掩码表示,1<<i,其中 0<=i<=31。当一组进程和内核进程协调以实现相同功能时,这非常有用——发送多播 netlink 消息可以减少使用的系统调用数量,并减轻应用程序维护多播组成员关系的负担。

bind()

对于 TCP/IP 套接字,netlink bind() API 将本地(源)套接字地址与打开的套接字关联。netlink 地址结构如下

struct sockaddr_nl
{
  sa_family_t    nl_family;  /* AF_NETLINK   */
  unsigned short nl_pad;     /* zero         */
  __u32          nl_pid;     /* process pid */
  __u32          nl_groups;  /* mcast groups mask */
} nladdr;

与 bind() 一起使用时,sockaddr_nl 的 nl_pid 字段可以用调用进程自身的 pid 填充。nl_pid 在这里充当此 netlink 套接字的本地地址。应用程序负责选择一个唯一的 32 位整数来填充 nl_pid

NL_PID Formula 1:  nl_pid = getpid();

公式 1 使用应用程序的进程 ID 作为 nl_pid,对于给定的 netlink 协议类型,如果进程只需要一个 netlink 套接字,这是一个自然的选择。

在同一进程的不同线程希望在同一 netlink 协议下打开不同的 netlink 套接字的情况下,可以使用公式 2 生成 nl_pid

NL_PID Formula 2: pthread_self() << 16 | getpid();

这样,同一进程的不同 pthreads 都可以为同一 netlink 协议类型拥有自己的 netlink 套接字。实际上,即使在单个 pthread 中,也可以为同一协议类型创建多个 netlink 套接字。但是,开发人员需要在生成唯一的 nl_pid 方面更具创造性,我们认为这不是一个常用的用例。

如果应用程序想要接收目标为某些多播组的协议类型的 netlink 消息,则应将所有感兴趣的多播组的位掩码进行或运算,以形成 sockaddr_nl 的 nl_groups 字段。否则,nl_groups 应清零,以便应用程序仅接收目标为应用程序的协议类型的单播 netlink 消息。在填写 nladdr 后,执行如下绑定

bind(fd, (struct sockaddr*)&nladdr, sizeof(nladdr));

发送 Netlink 消息

为了向内核或其他用户空间进程发送 netlink 消息,需要提供另一个 struct sockaddr_nl nladdr 作为目标地址,这与使用 sendmsg() 发送 UDP 数据包相同。如果消息的目标是内核,则 nl_pid 和 nl_groups 都应设置为 0。

如果消息是目标为另一个进程的单播消息,则 nl_pid 是另一个进程的 pid,nl_groups 为 0,假设系统中使用的是 nlpid 公式 1。

如果消息是目标为一个或多个多播组的多播消息,则应将所有目标多播组的位掩码进行或运算,以形成 nl_groups 字段。然后,我们可以将 netlink 地址提供给 sendmsg() API 的 struct msghdr msg,如下所示

struct msghdr msg;
msg.msg_name = (void *)&(nladdr);
msg.msg_namelen = sizeof(nladdr);

netlink 套接字也需要自己的消息头。这是为了为所有协议类型的 netlink 消息提供一个公共基础。

由于 Linux 内核 netlink 核心假定每个 netlink 消息中都存在以下头部,因此应用程序必须在它发送的每个 netlink 消息中提供此头部

struct nlmsghdr
{
  __u32 nlmsg_len;   /* Length of message */
  __u16 nlmsg_type;  /* Message type*/
  __u16 nlmsg_flags; /* Additional flags */
  __u32 nlmsg_seq;   /* Sequence number */
  __u32 nlmsg_pid;   /* Sending process PID */
};

nlmsg_len 必须填写 netlink 消息的总长度(包括头部),这是 netlink 核心所要求的。nlmsg_type 可以供应用程序使用,并且对于 netlink 核心是不透明的值。nlmsg_flags 用于向消息提供额外的控制;它由 netlink 核心读取和更新。nlmsg_seq 和 nlmsg_pid 供应用程序用于跟踪消息,它们对于 netlink 核心也是不透明的。

因此,netlink 消息由 nlmsghdr 和消息负载组成。输入消息后,它将进入 nlh 指针指向的缓冲区。我们也可以将消息发送到 struct msghdr msg

struct iovec iov;

iov.iov_base = (void *)nlh;
iov.iov_len = nlh->nlmsg_len;

msg.msg_iov = &iov;
msg.msg_iovlen = 1;

完成上述步骤后,调用 sendmsg() 将发送 netlink 消息

sendmsg(fd, &msg, 0);

接收 Netlink 消息

接收应用程序需要分配一个足够大的缓冲区来容纳 netlink 消息头和消息负载。然后,它填写如下所示的 struct msghdr msg,并使用标准 recvmsg() 接收 netlink 消息,假设缓冲区由 nlh 指向

struct sockaddr_nl nladdr;
struct msghdr msg;
struct iovec iov;

iov.iov_base = (void *)nlh;
iov.iov_len = MAX_NL_MSG_LEN;
msg.msg_name = (void *)&(nladdr);
msg.msg_namelen = sizeof(nladdr);

msg.msg_iov = &iov;
msg.msg_iovlen = 1;
recvmsg(fd, &msg, 0);

正确接收消息后,nlh 应指向刚接收到的 netlink 消息的头部。nladdr 应保存接收到的消息的目标地址,该地址由 pid 和消息发送到的多播组组成。并且,在 netlink.h 中定义的宏 NLMSG_DATA(nlh) 返回指向 netlink 消息负载的指针。调用 close(fd) 关闭由文件描述符 fd 标识的 netlink 套接字。

内核空间 Netlink API

内核空间 netlink API 由内核中的 netlink 核心 net/core/af_netlink.c 支持。从内核侧来看,API 与用户空间 API 不同。内核模块可以使用该 API 访问 netlink 套接字并与用户空间应用程序通信。除非您利用现有的 netlink 套接字协议类型,否则您需要通过向 netlink.h 添加常量来添加自己的协议类型。例如,我们可以通过将此行插入 netlink.h 来为测试目的添加 netlink 协议类型

#define NETLINK_TEST  17

之后,您可以在 Linux 内核中的任何位置引用添加的协议类型。

在用户空间中,我们调用 socket() 来创建 netlink 套接字,但在内核空间中,我们调用以下 API

struct sock *
netlink_kernel_create(int unit,
           void (*input)(struct sock *sk, int len));

参数 unit 实际上是 netlink 协议类型,例如 NETLINK_TEST。函数指针 input 是当消息到达此 netlink 套接字时调用的回调函数。

内核为协议 NETLINK_TEST 创建 netlink 套接字后,每当用户空间向内核发送 NETLINK_TEST 协议类型的 netlink 消息时,都会调用由 netlink_kernel_create() 注册的回调函数 input()。以下是回调函数 input 的示例实现

void input (struct sock *sk, int len)
{
 struct sk_buff *skb;
 struct nlmsghdr *nlh = NULL;
 u8 *payload = NULL;

 while ((skb = skb_dequeue(&sk->receive_queue))
       != NULL) {
 /* process netlink message pointed by skb->data */
 nlh = (struct nlmsghdr *)skb->data;
 payload = NLMSG_DATA(nlh);
 /* process netlink message with header pointed by
  * nlh	and payload pointed by payload
  */
 }
}

此 input() 函数在发送进程调用的 sendmsg() 系统调用的上下文中调用。如果在 input() 中处理 netlink 消息的速度很快,则可以在 input() 中处理 netlink 消息。但是,当 netlink 消息的处理花费很长时间时,我们希望将其排除在 input() 之外,以避免阻止其他系统调用进入内核。相反,我们可以使用专用内核线程无限期地执行以下步骤。使用skb = skb_recv_datagram(nl_sk)其中nl_sk是由 netlink_kernel_create() 返回的 netlink 套接字。然后,处理 skb->data 指向的 netlink 消息。

当 nl_sk 中没有 netlink 消息时,此内核线程将休眠。因此,在回调函数 input() 内部,我们只需要唤醒休眠的内核线程,如下所示

void input (struct sock *sk, int len)
{
  wake_up_interruptible(sk->sleep);
}

这是一个用户空间和内核之间更具可扩展性的通信模型。它还提高了上下文切换的粒度。

从内核发送 Netlink 消息

就像在用户空间中一样,在发送 netlink 消息时,需要设置源 netlink 地址和目标 netlink 地址。假设保存要发送的 netlink 消息的套接字缓冲区是 struct sk_buff *skb,则可以使用以下方法设置本地地址

NETLINK_CB(skb).groups = local_groups;
NETLINK_CB(skb).pid = 0;   /* from kernel */

可以使用以下方法设置目标地址

NETLINK_CB(skb).dst_groups = dst_groups;
NETLINK_CB(skb).dst_pid = dst_pid;

此类信息不存储在 skb->data 中。相反,它存储在套接字缓冲区 skb 的 netlink 控制块中。

要发送单播消息,请使用

int
netlink_unicast(struct sock *ssk, struct sk_buff
                *skb, u32 pid, int nonblock);

其中ssk是由 netlink_kernel_create() 返回的 netlink 套接字,skb->data指向要发送的 netlink 消息,并且pid是接收应用程序的 pid,假设使用 NLPID 公式 1。nonblock指示当接收缓冲区不可用时 API 应阻塞还是立即返回失败。

您也可以发送多播消息。以下 API 将 netlink 消息传递到 pid 指定的进程和 group 指定的多播组

void
netlink_broadcast(struct sock *ssk, struct sk_buff
         *skb, u32 pid, u32 group, int allocation);

group是所有接收多播组的或运算位掩码。allocation是内核内存分配类型。通常,如果来自中断上下文,则使用 GFP_ATOMIC;否则使用 GFP_KERNEL。这是因为 API 可能需要分配一个或多个套接字缓冲区来克隆多播消息。

从内核关闭 Netlink 套接字

给定 netlink_kernel_create() 返回的 struct sock *nl_sk,我们可以调用以下内核 API 来关闭内核中的 netlink 套接字

sock_release(nl_sk->socket);

到目前为止,我们仅展示了最基本的代码框架,以说明 netlink 编程的概念。现在,我们将使用我们的 NETLINK_TEST netlink 协议类型,并假设它已添加到内核头文件中。此处列出的内核模块代码仅包含与 netlink 相关的部分,因此应将其插入完整的内核模块骨架中,您可以从许多其他参考资料来源中找到该骨架。

内核和应用程序之间的单播通信

在此示例中,用户空间进程向内核模块发送 netlink 消息,内核模块将消息回显给发送进程。这是用户空间代码

#include <sys/socket.h>
#include <linux/netlink.h>

#define MAX_PAYLOAD 1024  /* maximum payload size*/
struct sockaddr_nl src_addr, dest_addr;
struct nlmsghdr *nlh = NULL;
struct iovec iov;
int sock_fd;

void main() {
 sock_fd = socket(PF_NETLINK, SOCK_RAW,NETLINK_TEST);

 memset(&src_addr, 0, sizeof(src_addr));
 src__addr.nl_family = AF_NETLINK;
 src_addr.nl_pid = getpid();  /* self pid */
 src_addr.nl_groups = 0;  /* not in mcast groups */
 bind(sock_fd, (struct sockaddr*)&src_addr,
      sizeof(src_addr));

 memset(&dest_addr, 0, sizeof(dest_addr));
 dest_addr.nl_family = AF_NETLINK;
 dest_addr.nl_pid = 0;   /* For Linux Kernel */
 dest_addr.nl_groups = 0; /* unicast */

 nlh=(struct nlmsghdr *)malloc(
		         NLMSG_SPACE(MAX_PAYLOAD));
 /* Fill the netlink message header */
 nlh->nlmsg_len = NLMSG_SPACE(MAX_PAYLOAD);
 nlh->nlmsg_pid = getpid();  /* self pid */
 nlh->nlmsg_flags = 0;
 /* Fill in the netlink message payload */
 strcpy(NLMSG_DATA(nlh), "Hello you!");

 iov.iov_base = (void *)nlh;
 iov.iov_len = nlh->nlmsg_len;
 msg.msg_name = (void *)&dest_addr;
 msg.msg_namelen = sizeof(dest_addr);
 msg.msg_iov = &iov;
 msg.msg_iovlen = 1;

 sendmsg(fd, &msg, 0);

 /* Read message from kernel */
 memset(nlh, 0, NLMSG_SPACE(MAX_PAYLOAD));
 recvmsg(fd, &msg, 0);
 printf(" Received message payload: %s\n",
	NLMSG_DATA(nlh));

 /* Close Netlink Socket */
 close(sock_fd);
}

这是内核代码

struct sock *nl_sk = NULL;

void nl_data_ready (struct sock *sk, int len)
{
  wake_up_interruptible(sk->sleep);
}

void netlink_test() {
 struct sk_buff *skb = NULL;
 struct nlmsghdr *nlh = NULL;
 int err;
 u32 pid;

 nl_sk = netlink_kernel_create(NETLINK_TEST,
                                   nl_data_ready);
 /* wait for message coming down from user-space */
 skb = skb_recv_datagram(nl_sk, 0, 0, &err);

 nlh = (struct nlmsghdr *)skb->data;
 printk("%s: received netlink message payload:%s\n",
        __FUNCTION__, NLMSG_DATA(nlh));

 pid = nlh->nlmsg_pid; /*pid of sending process */
 NETLINK_CB(skb).groups = 0; /* not in mcast group */
 NETLINK_CB(skb).pid = 0;      /* from kernel */
 NETLINK_CB(skb).dst_pid = pid;
 NETLINK_CB(skb).dst_groups = 0;  /* unicast */
 netlink_unicast(nl_sk, skb, pid, MSG_DONTWAIT);
 sock_release(nl_sk->socket);
}

加载执行上述内核代码的内核模块后,当我们运行用户空间可执行文件时,我们应该会看到从用户空间程序转储的以下内容

Received message payload: Hello you!

并且,以下消息应出现在 dmesg 的输出中

netlink_test: received netlink message payload:
Hello you!
内核和应用程序之间的多播通信

在此示例中,两个用户空间应用程序正在监听同一 netlink 多播组。内核模块通过 netlink 套接字向多播组弹出一个消息,并且所有应用程序都接收到它。这是用户空间代码

#include <sys/socket.h>
#include <linux/netlink.h>

#define MAX_PAYLOAD 1024  /* maximum payload size*/
struct sockaddr_nl src_addr, dest_addr;
struct nlmsghdr *nlh = NULL;
struct iovec iov;
int sock_fd;

void main() {
 sock_fd=socket(PF_NETLINK, SOCK_RAW, NETLINK_TEST);

 memset(&src_addr, 0, sizeof(local_addr));
 src_addr.nl_family = AF_NETLINK;
 src_addr.nl_pid = getpid();  /* self pid */
 /* interested in group 1<<0 */
 src_addr.nl_groups = 1;
 bind(sock_fd, (struct sockaddr*)&src_addr,
      sizeof(src_addr));

 memset(&dest_addr, 0, sizeof(dest_addr));

 nlh = (struct nlmsghdr *)malloc(
                          NLMSG_SPACE(MAX_PAYLOAD));
 memset(nlh, 0, NLMSG_SPACE(MAX_PAYLOAD));

 iov.iov_base = (void *)nlh;
 iov.iov_len = NLMSG_SPACE(MAX_PAYLOAD);
 msg.msg_name = (void *)&dest_addr;
 msg.msg_namelen = sizeof(dest_addr);
 msg.msg_iov = &iov;
 msg.msg_iovlen = 1;

 printf("Waiting for message from kernel\n");

 /* Read message from kernel */
 recvmsg(fd, &msg, 0);
 printf(" Received message payload: %s\n",
        NLMSG_DATA(nlh));
 close(sock_fd);
}

这是内核代码

#define MAX_PAYLOAD 1024
struct sock *nl_sk = NULL;

void netlink_test() {
 sturct sk_buff *skb = NULL;
 struct nlmsghdr *nlh;
 int err;

 nl_sk = netlink_kernel_create(NETLINK_TEST,
                               nl_data_ready);
 skb=alloc_skb(NLMSG_SPACE(MAX_PAYLOAD),GFP_KERNEL);
 nlh = (struct nlmsghdr *)skb->data;
 nlh->nlmsg_len = NLMSG_SPACE(MAX_PAYLOAD);
 nlh->nlmsg_pid = 0;  /* from kernel */
 nlh->nlmsg_flags = 0;
 strcpy(NLMSG_DATA(nlh), "Greeting from kernel!");
 /* sender is in group 1<<0 */
 NETLINK_CB(skb).groups = 1;
 NETLINK_CB(skb).pid = 0;  /* from kernel */
 NETLINK_CB(skb).dst_pid = 0;  /* multicast */
 /* to mcast group 1<<0 */
 NETLINK_CB(skb).dst_groups = 1;

 /*multicast the message to all listening processes*/
 netlink_broadcast(nl_sk, skb, 0, 1, GFP_KERNEL);
 sock_release(nl_sk->socket);
}

假设用户空间代码被编译成可执行文件 nl_recv,我们可以运行 nl_recv 的两个实例

./nl_recv &
Waiting for message from kernel
./nl_recv &
Waiting for message from kernel

然后,在我们加载执行内核空间代码的内核模块后,nl_recv 的两个实例都应接收到以下消息

Received message payload: Greeting from kernel!
Received message payload: Greeting from kernel!
结论

Netlink 套接字是用户空间应用程序和内核模块之间通信的灵活接口。它为应用程序和内核提供了易于使用的套接字 API。它提供了高级通信功能,例如全双工、缓冲 I/O、多播和异步通信,这些功能在其他内核/用户空间 IPC 中不存在。

Kevin Kaichuan He (hek_u5@yahoo.com) 是 Solustek Corp. 的首席软件工程师。他目前正在从事嵌入式系统、设备驱动程序和网络协议项目。他之前的工作经验包括在思科系统担任高级软件工程师和在普渡大学 CS 担任研究助理。在他的业余时间,他喜欢数字摄影、PS2 游戏和文学。

加载 Disqus 评论