简介:面向Linux内核驱动初学者与网络子系统学习者的snull虚拟网卡驱动原版代码,改编自《Linux Device Drivers》配套示例,通过两个虚拟接口之间的回环收发演示网络设备驱动的注册、打开关闭、报文发送接收、中断处理与NAPI轮询等核心流程,并保留锁、skb、统计信息及模块参数等实验要素。压缩包共7个文件,约14KB,包含C源文件、头文件、Makefile以及加载卸载脚本和备份文件,结构紧凑,便于对照书中章节在旧版内核环境中阅读和动手编译。已有1579人学习下载。读者可据此理解虚拟网卡如何模拟硬件收发、如何挂接内核网络栈,并借助lockup、timeout、use_napi、pool_size等参数开展中断、超时与轮询模式实验,积累驱动调试和排错思路。
1. 手上只有一台机器,怎么验证收发逻辑通不通
手上没有第二台机器,也不方便动真实网卡的时候,怎么验证一段收发逻辑到底通不通?我的做法是先跑一遍 snull:它只做一件事,在内存里造出两块虚拟网卡,再把它们当一根网线插在一起,从 snull0 发出的帧会原样从 snull1 收进来。整个驱动不到千行,却完整走了一遍内核 networks 子系统的注册、发送、接收、统计四条主线。它适合三类人:刚接手驱动维护、想弄明白数据包到底从哪进内核的开发者;要给上层协议写测试桩的人;以及只想在容器或虚拟机里造一个可控网络环境、不想碰真实硬件的运维。
2. 项目定位与技术栈:snull 用两块虚拟网卡拼出一条“网线”
snull 不是那种能替代真实网卡的生产级驱动,它的价值在于“最小可运行”。真实网卡要处理 DMA 环、寄存器、PHY 协商、硬件中断,代码里一大半篇幅都在跟硬件时序较劲;snull 把这些统统砍掉,只保留驱动模型必须实现的那几个回调。所以读它的收益不是学会写某个型号的网卡驱动,而是搞清楚内核网络栈给驱动留了哪几个口子、每个口子的契约是什么。
2.1 回声模型:snull0 发出的帧,从 snull1 收进来
理解这个项目只要记住一条数据流:上层协议栈把 skb 交给ndo_start_xmit,驱动把数据复制一份塞进对端设备的接收缓冲,再从接收路径重新送回协议栈。发送方看到的是“我发出去了”,接收方看到的是“我收到了一个以太网帧”,中间没有任何物理介质。
这里有个容易被忽略的设计取舍:两个设备共用同一份内核内存,所以“发出的包”和“收到的包”其实是同一条主机上的一次内存拷贝,不会真的经过网卡队列,也不会触发硬件中断。驱动里那段“中断模拟”是刻意写给人看的,它把“硬件什么时候通知 CPU 有包到了”这件事显式化,让你能清楚地看到中断上下文和协议栈之间的边界在哪里。
也正因为两端在同一台机器同一份网络栈里内,地址配置会变得很别扭——你给两块卡配同一网段的 IP,内核会认为目的地址是本机,包压根不会走到驱动里。这个问题后面第 5 章会单独拆,它是新手第一个翻车点。
2.2 技术栈与内核接口清单
项目是纯 C 写的内核模块,不依赖任何用户态库,编译产物就是一个.ko。用到的接口全部来自内核网络子系统,没有第三方依赖,这也是它适合当教材的原因——你把下面这张表里的每个接口查一遍文档,基本就掌握了网卡驱动的骨架。
| 内核接口 | 在项目里承担的角色 |
|---|---|
alloc_netdev/register_netdev | 分配 net_device 结构并注册进网络栈 |
ether_setup | 填好以太网类型的默认参数(MTU、头长、type) |
netdev_priv | 取出跟在 net_device 后面的私有数据区 |
ndo_start_xmit | 协议栈唯一的发送入口 |
dev_alloc_skb/skb_reserve/skb_put_data | 造接收缓冲、做头部对齐、填数据 |
eth_type_trans | 按以太网头判定上层协议和包类型 |
netif_rx | 把帧挂回协议栈的接收队列 |
timer_list | 模拟“硬件中断延迟到达” |
module_param | 运行期调整驱动行为,免去反复编译 |
值得说一下netif_rx这个选择。它是老接口,把 skb 挂到 per-CPU 的 backlog 队列,随后在软中断里走netif_receive_skb;新代码更推荐直接调用netif_receive_skb或走 NAPI 的napi_gro_receive。snull 用netif_rx是因为它要演示“中断上下文不能直接跑协议栈”这件事,用 NAPI 反而把这层教学意图盖住了。你要把它改成生产可用的虚拟设备,第一件事就是把这条路径换成 NAPI。
2.3 源码文件与职责分布
拿到源码包先别急着make,花两分钟看清文件分工,后面改代码时能省掉一半的搜索时间。典型的目录长这样:
| 文件 | 内容 |
|---|---|
snull.c | 全部驱动逻辑:注册、发送、接收、统计、ioctl |
snull.h | 私有结构体snull_priv与常量定义 |
Makefile | 标准的外部模块编译规则,走M=$(PWD) |
snull_load/snull_unload | 装载脚本,封装 insmod + 配地址 + 加路由的一串命令 |
README | 编译与验证步骤,通常还带着原始作者的测试记录 |
snull.c是唯一需要精读的文件,其余都是脚手架。我一般会先通读一遍snull.c里所有static函数的签名,不进去看实现——签名会告诉你这个驱动实现了哪些回调、哪些是内部辅助函数,比从头读快得多。
3. 核心链路拆解:从 alloc_netdev 到 netif_rx 的完整一跳
这一章按数据包的生命周期走一遍:设备怎么被造出来、发送时驱动拿到什么、帧怎么被“送过网线”、接收侧怎么把它交回去。代码是基于原始版本做的现代化整理(原始代码用的是init_module那套老写法,新内核上编译不过),逻辑没动。
3.1 设备注册:snull_init_module 与 alloc_netdev
模块加载函数只干三件事:分配两块设备、写死 MAC 地址、注册进网络栈。注意alloc_netdev的第二个参数是格式串"snull%d",%d由内核自动填索引,所以两块卡自然叫 snull0 和 snull1,不用你手动拼名字。
/* snull.c —— 模块加载:一次性注册两块互连的虚拟网卡 */ static struct net_device *snull_devs[2]; static int __init snull_init_module(void) { int i, err; /* 1. 分配 net_device,并在它后面预留 sizeof(struct snull_priv) 的私有区 */ snull_devs[0] = alloc_netdev(sizeof(struct snull_priv), "snull%d", NET_NAME_UNKNOWN, snull_init); snull_devs[1] = alloc_netdev(sizeof(struct snull_priv), "snull%d", NET_NAME_UNKNOWN, snull_init); if (!snull_devs[0] || !snull_devs[1]) { err = -ENOMEM; goto out_free; } /* 2. “烧”两个不会冲突的 MAC,真实网卡是从 EEPROM 读,这里是编的 */ snull_devs[0]->dev_addr[0] = 0x00; snull_devs[0]->dev_addr[1] = 0x11; snull_devs[0]->dev_addr[5] = 0x01; memcpy(snull_devs[1]->dev_addr, snull_devs[0]->dev_addr, ETH_ALEN); snull_devs[1]->dev_addr[5] = 0x02; /* 3. 注册,成功后 /sys/class/net/ 下立刻出现两个目录 */ for (i = 0; i < 2; i++) { err = register_netdev(snull_devs[i]); if (err) { pr_err("snull: register_netdev(%d) failed: %d\n", i, err); goto out_unreg; } } pr_info("snull: loaded, bring snull0/snull1 up to start\n"); return 0; out_unreg: while (--i >= 0) unregister_netdev(snull_devs[i]); out_free: free_netdev(snull_devs[0]); free_netdev(snull_devs[1]); return err; } module_init(snull_init_module);alloc_netdev的第三个参数NET_NAME_UNKNOWN是给dev->name_assign_type用的,老内核里根本没这个参数,从 3.13 之后才有。第四个参数snull_init是 setup 回调,在内核内部被调用,用来填充默认字段——这个回调不是在你这行代码里直接执行的,它是被alloc_netdev转手调用的,很多人第一次读会以为是笔误。
出错路径的写法值得学一下:goto out_unreg之前先把i自减,这样清理循环只会回滚已经注册成功的设备。这种“逆序回滚”是内核代码的标准姿势,比每个分支写一遍清理更不容易漏。
3.2 私有数据结构:netdev_priv 里的那块自留地
驱动的状态不能挂在全局变量上,否则多设备就完蛋。内核的做法是让 net_device 后面跟一段变长内存,用netdev_priv(dev)取指针。这段内存大小就是你传给alloc_netdev的第一个参数。
/* snull.h —— 每个设备一份的私有区,netdev_priv() 返回的就是它 */ struct snull_priv { struct net_device_stats stats; /* 老式统计结构,ifconfig 直接读 */ struct net_device *dev; /* 反查用,定时器回调里需要 */ struct sk_buff *rx_skb; /* “硬件接收缓冲”,只有一格 */ int rx_int_enabled; /* 是否开启接收中断 */ unsigned long rx_int_delay; /* 中断延迟,单位 jiffies */ struct timer_list rx_timer; /* 中断模拟用的定时器 */ spinlock_t lock; /* 保护 rx_skb 这格缓冲 */ }; static const struct net_device_ops snull_netdev_ops = { .ndo_open = snull_open, .ndo_stop = snull_release, .ndo_start_xmit = snull_tx, .ndo_get_stats = snull_get_stats, .ndo_do_ioctl = snull_ioctl, .ndo_set_mac_address = eth_mac_addr, .ndo_validate_addr = eth_validate_addr, };struct net_device_stats是新内核里已经被pcpu_sw_netstats/rtnl_link_stats64取代的东西,ndo_get_stats也逐步让位给ndo_get_stats64。原始 snull 用的是前者,编译在新内核上会警告甚至报错。移植时最省事的做法是保留net_device_stats但把回调换成ndo_get_stats64,在回调里把字段搬到struct rtnl_link_stats64。
setup 回调里除了初始化私有区,还要把netdev_ops挂上去、设置IFF_NOARP、打开校验和卸载。IFF_NOARP是这个项目的关键开关:关掉 ARP 之后,内核不会为这个设备发 ARP 请求,而是直接把包扔给ndo_start_xmit,所谓“点对点链路”就是靠它实现的。
static void snull_init(struct net_device *dev) { struct snull_priv *priv = netdev_priv(dev); ether_setup(dev); /* 补齐 MTU、hard_header_len、type 等默认值 */ memset(priv, 0, sizeof(*priv)); spin_lock_init(&priv->lock); priv->dev = dev; priv->rx_int_enabled = 1; priv->rx_int_delay = timeout; /* 模块参数,默认 5 个 jiffies */ dev->netdev_ops = &snull_netdev_ops; dev->flags |= IFF_NOARP; /* 对端固定,不需要地址解析 */ dev->features |= NETIF_F_HW_CSUM; /* 告诉上层:校验和交给“硬件”,别重复算 */ init_timer(&priv->rx_timer); /* 新版内核用 timer_setup() */ priv->rx_timer.function = snull_rx_timer; priv->rx_timer.data = (unsigned long)dev; }ether_setup必须在改dev->type之类的字段之前调用,因为它会一次性覆盖掉一批以太网默认值。这个顺序如果搞反,你会看到 MTU 变成 1500 但hard_header_len还是 0,抓包时帧头解析全乱——这个坑我在别的地方踩过,排查了大半天才发现是两行代码写反了。
3.3 发送路径:ndo_start_xmit 与 snull_hw_tx
ndo_start_xmit是协议栈唯一的发送入口,签名是netdev_tx_t (*)(struct sk_buff *, struct net_device *)。老内核返回int,2.6.31 之后改成netdev_tx_t枚举,这是移植时必改的一处。
static netdev_tx_t snull_tx(struct sk_buff *skb, struct net_device *dev) { struct snull_priv *priv = netdev_priv(dev); unsigned char shortpkt[ETH_ZLEN]; unsigned char *data = skb->data; int len = skb->len; /* 以太网最小帧 60 字节,不足要填充,否则对端网卡会当成残帧丢掉 */ if (len < ETH_ZLEN) { memset(shortpkt, 0, ETH_ZLEN); memcpy(shortpkt, skb->data, skb->len); len = ETH_ZLEN; data = shortpkt; } priv->stats.tx_packets++; priv->stats.tx_bytes += skb->len; spin_lock(&priv->lock); snull_hw_tx(data, len, dev); /* 交给“硬件”发出去 */ spin_unlock(&priv->lock); dev_kfree_skb(skb); /* 发完就把上层这份还回去,别留着 */ return NETDEV_TX_OK; }几个参数要注意:ETH_ZLEN是 60,不含 CRC,所以抓包工具看到的以太网帧长度是 60 而不是 64;NETDEV_TX_OK表示帧已被驱动接管,返回NETDEV_TX_BUSY会让上层重试,用错了表现是发送队列卡死、ifconfig里tx dropped一路涨。
dev_kfree_skb放在最后是必须的。skb 的所有权在调用ndo_start_xmit之后完全归驱动,你不释放就是内存泄漏;反过来,释放之后再去读skb->data就是访问已释放内存,这种错误在测试时往往看不出来,跑久了才 oops。
真正的“硬件发送”在这个函数里:
/* 把帧从 dev 送到对端设备,模拟一根网线连着两块卡的两端 */ static void snull_hw_tx(unsigned char *buf, int len, struct net_device *dev) { struct snull_priv *priv = netdev_priv(dev); struct net_device *dest = (dev == snull_devs[0]) ? snull_devs[1] : snull_devs[0]; struct snull_priv *dest_priv = netdev_priv(dest); struct sk_buff *skb; skb = dev_alloc_skb(len + 2); if (!skb) { priv->stats.tx_dropped++; return; } /* 对端那格“硬件缓冲”还占着,就当线路拥塞丢掉,别覆盖 */ if (dest_priv->rx_skb) { priv->stats.tx_dropped++; dev_kfree_skb(skb); return; } skb_reserve(skb, 2); /* 让 IP 头 4 字节对齐,省掉一次 memmove */ skb_put_data(skb, buf, len); /* 老内核写法:memcpy(skb_put(skb, len), buf, len) */ dest_priv->rx_skb = skb; /* 中断关闭时立刻“响”;打开时按 timeout 延迟,模拟硬件时延 */ mod_timer(&dest_priv->rx_timer, jiffies + (dest_priv->rx_int_enabled ? dest_priv->rx_int_delay : 0)); }skb_reserve(skb, 2)那两字节不是凑数。以太网头是 14 字节,加上 2 之后 IP 头正好落在 16 字节边界上,CPU 取iphdr里的 32 位字段不用跨缓存行;这个技巧在真实驱动里到处都是,属于性能细节里性价比最高的一类。
dest_priv->rx_skb只有一格,这是刻意设计的“硬件只有一块接收缓冲”。真实网卡是环形队列,满了就丢包并计入rx_over_errors;snull 用一格 + 丢弃来简化,效果一样:你能在统计里看到丢包。
3.4 接收路径:中断模拟、定时器与 netif_rx
定时器到点后执行的就是“中断服务程序”。它把硬件缓冲里的帧取出来,补上dev和protocol,然后交给netif_rx挂进协议栈队列。这一步不能直接调netif_receive_skb(老接口语义下),因为你还在定时器上下文里,协议栈可能睡眠。
/* “中断服务程序”:定时器到点后被调用,把帧转成 skb 交给协议栈 */ static void snull_rx_timer(unsigned long data) { struct net_device *dev = (struct net_device *)data; struct snull_priv *priv = netdev_priv(dev); struct sk_buff *skb = priv->rx_skb; if (!skb) /* 中断是共享的,别的设备可能已经处理过 */ return; priv->rx_skb = NULL; skb->dev = dev; /* 必须设,eth_type_trans 要用它判 pkt_type */ skb->protocol = eth_type_trans(skb, dev); skb->ip_summed = CHECKSUM_UNNECESSARY; /* 硬件已经校验过,协议栈别再算 */ priv->stats.rx_packets++; priv->stats.rx_bytes += skb->len; netif_rx(skb); /* 挂到 per-CPU 后备队列,软中断里再往上走 */ }eth_type_trans干的活比名字看起来多:它从以太网头的h_proto里读出上层协议号填到skb->protocol,根据目的 MAC 判断是单播、广播还是发给别人的(设置skb->pkt_type),还会顺手把skb_pull掉 14 字节以太网头,让skb->data指向 IP 头。所以调完它之后你不能再读eth_hdr(skb),那个位置的数据已经被“逻辑上移走”了。
CHECKSUM_UNNECESSARY是配合前面NETIF_F_HW_CSUM用的。虚拟设备根本没算校验和,但上层会因为这两个标志跳过校验,所以抓包看到的是完整可用的包。如果你只设了其中一个,就会出现“包收到了但 TCP 校验和错误”的玄学现象——这类 bug 查起来是真的费劲,因为 ping 通、telnet 不通。
3.5 统计与 ioctl:让 ifconfig 有数字可看
统计回调把私有区里攒的计数搬给上层,逻辑很简单,但没有它你就失去了最有效的调试手段。发包不通的时候,ip -s link show snull0里 tx/rx 四个数字谁在涨,直接决定了问题出在发送前还是接收后。
/* 打开设备:启动发送队列、拿回统计基线的时机 */ static int snull_open(struct net_device *dev) { netif_start_queue(dev); /* 允许上层把包交给 ndo_start_xmit */ return 0; } static int snull_release(struct net_device *dev) { netif_stop_queue(dev); /* 停止接收新的发送请求 */ del_timer_sync(&((struct snull_priv *)netdev_priv(dev))->rx_timer); return 0; } /* 用户态通过 SIOCDEVPRIVATE 开关“中断”,调试时不用重编模块 */ static int snull_ioctl(struct net_device *dev, struct ifreq *rq, int cmd) { struct snull_priv *priv = netdev_priv(dev); if (!netif_running(dev)) return -EINVAL; if (!capable(CAP_NET_ADMIN)) return -EPERM; switch (cmd) { case SIOCDEVPRIVATE: { int val; if (get_user(val, (int __user *)rq->ifr_data)) return -EFAULT; priv->rx_int_enabled = !!val; return 0; } default: return -EOPNOTSUPP; } }netif_start_queue/netif_stop_queue这对函数控制的是“协议栈能不能往这个设备发”。如果ndo_open里忘了调netif_start_queue,现象特别迷惑:设备是 UP 的,路由也有,ping就是不发包,tx计数永远是 0。我在别处被这个坑过一回,后来养成习惯,写ndo_open时第一行就把netif_start_queue敲上。
4. 编译与运行:把 snull.ko 装进内核并抓到第一个包
4.1 Makefile 与内核头文件准备
外部模块编译只要一个三行的 Makefile,重点是M=$(PWD)这个参数——它告诉内核的构建系统“源码在当前目录,别去内核树里找”。
obj-m := snull.o KDIR ?= /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) cleanKDIR指向的是内核源码的构建目录,不是/usr/src下的头文件目录。很多系统的/lib/modules/$(uname -r)/build是个软链接,指向完整内核树;如果这个软链接断了,make会报 “No such file or directory”,而你ls /usr/src又能看到一堆头文件目录,很容易误判成缺包。
准备步骤通常是装build-essential和对应版本的内核头文件包,然后直接make。编译过程中出现的最多的是 API 对不上的错误,不是语法错——这部分留到第 5 章细讲。
4.2 加载与配置:让包真的走驱动
insmod之后设备就出现在系统里了,但默认是 DOWN 状态,也不会自动配地址。手动走一遍:
sudo insmod ./snull.ko ip link show | grep snull # 应该能看到 snull0 / snull1 两个 DOWN 状态的设备 sudo ip link set snull0 up sudo ip link set snull1 up # 关键:两块卡配不同网段,再手动指定路由,否则内核认为目的地在本地,包不会进驱动 sudo ip addr add 192.168.0.1/24 dev snull0 sudo ip addr add 192.168.1.1/24 dev snull1 sudo ip route add 192.168.1.0/24 dev snull0为什么非要配不同网段?因为两台“网卡”在同一条主机上,如果你给它们配同一网段的地址,内核查路由表时看到目的地址是本机地址,会直接把包从 loopback 送回去,`ndo_start_xmit
本文还有配套的精品资源,点击获取