以下Linux 内核模块实现了一个虚拟网络设备驱动程序,其作用和意义如下:
1. 作用
(1)创建虚拟网络设备对
- 驱动程序动态创建了两个虚拟网络设备(
nic_dev[0]
和nic_dev[1]
),模拟物理网卡的功能。这两个设备可以像真实网卡一样配置 IP 地址、启用 / 禁用接口,并参与网络通信。(2)模拟数据包的环回传输
- 当数据包通过其中一个虚拟设备发送时(如
eth0
),驱动程序的nic_hw_xmit
函数会直接将数据包传递给另一个虚拟设备(如eth1
)的接收函数nic_rx
,实现数据包在两个虚拟设备之间的 "环回" 传输。这种模拟避免了对物理网络硬件的依赖。(3)提供网络协议栈接口
- 驱动程序实现了网络设备的核心操作(如打开、关闭、发送数据包、验证 MAC 地址等),通过
net_device_ops
结构体与 Linux 内核网络协议栈无缝对接。这使得用户空间的网络工具(如ping
、ifconfig
)可以像操作真实网卡一样操作虚拟设备。(4)支持基本网络功能
- 驱动程序支持设置 MAC 地址、修改 MTU、校验和计算等基本网络功能,能够满足简单网络通信的需求。
2. 意义
(1)简化网络开发与测试
- 虚拟网络设备为开发者提供了一个无需物理硬件的测试环境。例如,可以在同一台主机上通过这两个虚拟设备测试网络协议(如 IP、TCP)的实现,验证数据包的路由、转发和处理逻辑。
(2)降低开发成本
- 无需真实网卡和网络环境,减少了硬件依赖,降低了开发和调试的成本。开发者可以在隔离的虚拟环境中复现网络问题,提高开发效率。
(3)演示网络驱动原理
- 驱动程序的代码结构清晰,展示了 Linux 内核网络驱动的基本框架(如设备注册、数据包收发、统计信息维护等),适合作为学习网络驱动开发的示例。
(4)支持特殊网络场景
- 虚拟设备对可以用于模拟网桥、隧道或其他虚拟网络拓扑,满足特定场景下的网络需求(如容器网络、网络虚拟化)。
一、nic.c
cpp
#include <linux/module.h>
#include <linux/netdevice.h>
#include <linux/etherdevice.h>
#include <linux/inetdevice.h>
#include <linux/ip.h>
#include <linux/skbuff.h>
MODULE_AUTHOR("jerry");
MODULE_DESCRIPTION("Kernel module for nic");
MODULE_LICENSE("GPL");
//定义以太网帧的最大缓冲区大小,用于驱动中发送(TX)和接收(RX)缓冲区的内存分配。标准以太网帧的最大长度为 1518 字节(14 字节头部 + 1500 字节数据 + 4 字节 FCS 校验)
//但在某些场景下(如包含 VLAN 标签、QinQ 封装或其他扩展头部),以太网帧的总长度会超过 1518 字节。
#define MAX_ETH_FRAME_SIZE 1792
//定义调试信息输出的默认控制位掩码,用于启用或禁用不同级别的日志输出,这里所有 16 位均为 1,表示 启用所有调试信息
#define DEF_MSG_ENABLE 0xffff
struct nic_priv {
//发送的数据放在tx里
unsigned char *tx_buf;
unsigned int tx_len;
unsigned char *rx_buf;
unsigned int rx_len;
u32 msg_enable;
};
static struct net_device *nic_dev[2];
static int nic_open(struct net_device *dev);
static int nic_stop(struct net_device *dev);
static netdev_tx_t nic_start_xmit(struct sk_buff *skb, struct net_device *dev);
static int nic_validate_addr(struct net_device *dev);
static int nic_change_mtu(struct net_device *dev, int new_mtu);
static int nic_set_mac_addr(struct net_device *dev, void *addr);
//这个 dump 函数的功能是将缓冲区 buffer 中的以太网头部、IP 头部以及负载的前 4 个字节以十六进制字符串的形式打印出来,用于调试网络数据包的内容。
static void dump(unsigned char *buffer){ //参数 unsigned char *buffer:指向要转储(打印)的数据包缓冲区,通常包含以太网帧、IP 数据包等数据。
unsigned char *p; //用于操作字符数组 sbuf 的指针,指向当前字符串拼接的位置。
//每个字节需要用 2 个十六进制字符表示(例如 0A),因此缓冲区大小为 2 * (以太网头部长度 + IP 头部长度)。
unsigned char sbuf[2*(sizeof(struct ethhdr) + sizeof(struct iphdr))];
int i;
//将指针 p 指向 sbuf 的起始位置,准备开始拼接字符串
p = sbuf;
//打印以太网头部(Ethernet Header)
for (i = 0; i < sizeof(struct ethhdr); i++) {
//将 buffer 中第 i 个字节格式化为两位大写十六进制字符串(例如 0A),并拼接到 sbuf 中
p += sprintf(p, "%02X", buffer[i]);
}
printk("eth %s\n", sbuf);
//打印 IP 头部(IP Header)
p = sbuf;
for (i = 0; i < sizeof(struct iphdr); i++) {
p += sprintf(p, "%02X", buffer[sizeof(struct ethhdr) + i]);
}
printk("iph %s\n", sbuf);
//打印负载前 4 个字节(Payload)
p = sbuf;
for (i = 0; i < 4; i++) {
p += sprintf(p, "%02X", buffer[sizeof(struct ethhdr) + sizeof(struct iphdr) + i]);
}
printk("payload %s\n", sbuf);
}
//nic_rx 函数的主要功能是处理网络设备接收到的数据包。它会为接收到的数据包分配一个 sk_buff(socket buffer)结构体,
//将数据包内容复制到 sk_buff 中,设置 sk_buff 的相关属性,更新网络设备的统计信息,最后将 sk_buff 传递给上层网络协议栈进行进一步处理
/*
struct net_device *dev:指向网络设备结构体 net_device 的指针,代表接收数据包的网络设备。通过这个指针可以访问该网络设备的各种属性和操作函数。
int len:表示接收到的数据包的长度,以字节为单位。
unsigned char *buf:指向接收到的数据包数据的指针,存储着实际的数据包内容。
*/
static void nic_rx(struct net_device *dev,int len,unsigned char *buf){
//存储接收到的数据包
struct sk_buff *skb;
struct nic_priv *priv = netdev_priv(dev);
netif_info(priv, hw, dev, "%s(#%d), rx:%d\n",
__func__, __LINE__, len);
//调用 dev_alloc_skb 函数为接收到的数据包分配一个 sk_buff 结构体,分配的大小为 len + 2 字节,多分配 2 字节可能是为了预留一些空间用于后续操作
skb = dev_alloc_skb(len + 2);
if (!skb) {
netif_err(priv, rx_err, dev,
"%s(#%d), rx: low on mem - packet dropped\n",
__func__, __LINE__);
dev->stats.rx_dropped++;
return;
}
skb_reserve(skb, 2);
//此时此刻网卡接收到的数据已经从buf复制到skb中,包括协议类型
memcpy(skb_put(skb, len), buf, len);
skb->dev = dev;
//但是这里解析并赋值是为了将协议类型单独提出来,防止每次都需要解析
skb->protocol = eth_type_trans(skb, dev);
//表示不需要对该数据包进行校验和计算
skb->ip_summed = CHECKSUM_UNNECESSARY;
//将网络设备的统计信息中的接收数据包数量加 1
dev->stats.rx_packets++;
//将网络设备的统计信息中的接收字节数增加 len
dev->stats.rx_bytes += len;
//调用 netif_rx 函数将处理好的 sk_buff 传递给上层网络协议栈进行进一步处理,比如 IP 层、TCP 层等
netif_rx(skb);
}
//当使用ifconfig eth2 192.168.186.138 up 的时候会调用到这个函数
//该命令是:为指定的网络接口(eth2)分配一个静态的 IPv4 地址(192.168.186.138),并且激活该网络接口
static int nic_open(struct net_device *dev) {
struct nic_priv *priv = netdev_priv(dev);
priv->tx_buf = kmalloc(MAX_ETH_FRAME_SIZE, GFP_KERNEL);
if (!priv->tx_buf) {
return -ENOMEM;
}
priv->rx_buf = kmalloc(MAX_ETH_FRAME_SIZE, GFP_KERNEL);
if (!priv->rx_buf) {
kfree(priv->tx_buf); // 释放已分配的 tx_buf
return -ENOMEM;
}
netif_start_queue(dev);
return 0;
}
//ifconfig eth2 down
int nic_stop(struct net_device *dev){
struct nic_priv *priv = netdev_priv(dev);
kfree(priv->tx_buf);
kfree(priv->rx_buf);
netif_stop_queue(dev);
return 0;
}
//nic_hw_xmit 函数的主要功能是模拟网络设备的硬件传输过程。模拟发送(不是真的发送只是组织好数据包,并直接自己接收),目的是降低成本,便于调试等
//重新计算 IP 头部校验和,更新设备的发送统计信息,最后将修改后的数据包模拟为接收到的数据包,调用 nic_rx 函数进行处理。
static void nic_hw_xmit(struct net_device *dev) {
struct nic_priv *priv = netdev_priv(dev);
//声明一个指向 iphdr 结构体的指针 iph,用于指向 IP 头部
struct iphdr *iph;
//声明两个指向 32 位无符号整数的指针 saddr 和 daddr,分别用于存储源 IP 地址和目的 IP 地址
u32 *saddr, *daddr;
//检查发送缓冲区中的数据包长度 priv->tx_len 是否小于以太网头部长度和 IP 头部长度之和。
if (priv->tx_len < sizeof(struct ethhdr) + sizeof(struct iphdr)) {
netif_info(priv, hw, dev, "%s(#%d), too short\n",
__func__, __LINE__);
return ;
}
//打印信息
dump(priv->tx_buf);
iph = (struct iphdr*)(priv->tx_buf + sizeof(struct ethhdr));
saddr = &iph->saddr;
daddr = &iph->daddr;
netif_info(priv, hw, dev, "%s(#%d), orig, src:%pI4, dst:%pI4, len:%d\n",
__func__, __LINE__, saddr, daddr, priv->tx_len);
//将 IP 头部的校验和字段 iph->check 置为 0
iph->check = 0;
//调用 ip_fast_csum 函数重新计算 IP 头部的校验和,并将结果赋值给 iph->check
iph->check = ip_fast_csum((unsigned char*)iph, iph->ihl);
//dev->stats.tx_packets ++;
dev->stats.tx_packets ++;
//dev->stats.tx_bytes += priv->tx_len;
dev->stats.tx_bytes += priv->tx_len;
//调用 nic_rx 函数,将修改后的数据包模拟为接收到的数据包,传递给另一个网络设备进行处理
nic_rx(nic_dev[(dev == nic_dev[0] ? 1 : 0)], priv->tx_len, priv->tx_buf);
}
//该函数是网络设备驱动的核心发送函数,负责将内核传递的 skb 数据包转换为硬件可发送的格式,并触发实际的发送操作
/*
struct sk_buff *skb:套接字缓冲区(Socket Buffer),包含待发送的数据包。
struct net_device *dev:当前网络设备结构体,代表数据包要从哪个设备发送。
*/
netdev_tx_t nic_start_xmit(struct sk_buff *skb,struct net_device *dev){
struct nic_priv *priv = netdev_priv(dev);
netif_info(priv, drv, dev, "%s(#%d), orig, src:%pI4, dst:%pI4\n",
__func__, __LINE__, &(ip_hdr(skb)->saddr), &(ip_hdr(skb)->daddr));
priv->tx_len = skb->len;
if (likely(priv->tx_len < MAX_ETH_FRAME_SIZE)) {
if (priv->tx_len < ETH_ZLEN) {
memset(priv->tx_buf, 0, ETH_ZLEN);
priv->tx_len = ETH_ZLEN;
}
//函数将 skb 中的数据复制到驱动的发送缓冲区 priv->tx_buf,并计算硬件校验和(如 CRC)。这一步是为了让硬件可以直接发送数据,无需再次计算校验和
skb_copy_and_csum_dev(skb, priv->tx_buf);
//数据包数据已复制到 tx_buf,不再需要 skb,调用 dev_kfree_skb_any 释放 skb 内存,避免内存泄漏
dev_kfree_skb_any(skb);
}else { //如果数据包长度超过 MAX_ETH_FRAME_SIZE,直接释放 skb,增加设备统计中的发送丢弃计数(tx_dropped),并返回 NETDEV_TX_OK
dev_kfree_skb_any(skb);
dev->stats.tx_dropped++;
return NETDEV_TX_OK;
}
//调用模拟的发送函数
nic_hw_xmit(dev);
return NETDEV_TX_OK;
}
//设置网络设备的 MAC 地址
static int nic_set_mac_addr(struct net_device *dev, void *addr) {
// 获取设备的私有数据结构指针
struct nic_priv *priv = netdev_priv(dev);
// 打印调试信息:函数名、行号、私有数据指针
netif_info(priv, drv, dev, "%s(#%d), priv:%p\n", __func__, __LINE__, priv);
// 调用内核的通用以太网 MAC 地址设置函数
return eth_mac_addr(dev, addr);
}
//验证网络设备的 MAC 地址是否有效
int nic_validate_addr(struct net_device *dev){
// 获取设备的私有数据结构指针
struct nic_priv *priv = netdev_priv(dev);
// 打印调试信息:函数名、行号、私有数据指针
netif_info(priv, drv, dev, "%s(#%d), priv:%p\n", __func__, __LINE__, priv);
// 调用内核的通用以太网 MAC 地址验证函数
return eth_validate_addr(dev);
}
//修改网络设备的 MTU(Maximum Transmission Unit,最大传输单元)
static int nic_change_mtu(struct net_device *dev, int new_mtu) {
struct nic_priv *priv = netdev_priv(dev);
netif_info(priv, drv, dev, "%s(#%d), priv:%p, mtu%d\n",
__func__, __LINE__, priv, new_mtu);
// 直接设置新的MTU值并返回0(成功)
dev->mtu = new_mtu;
return 0;
}
netmap,将网卡内容映射到内存中,可以从用户空间直接拿到数据
//为网络数据包创建以太网头部,包含了源 MAC 地址、目的 MAC 地址以及协议类型等信息
static int nic_header_create (struct sk_buff *skb, struct net_device *dev,
unsigned short type, const void *daddr,
const void *saddr, unsigned int len) {
/*
struct sk_buff *skb:指向 sk_buff 结构体的指针,sk_buff 是 Linux 内核中用于存储网络数据包的结构体,它包含了数据包的数据和相关的元信息。
struct net_device *dev:指向 net_device 结构体的指针,代表当前发送数据包的网络设备。
unsigned short type:表示以太网帧的协议类型,例如 ETH_P_IP 表示 IPv4 协议,ETH_P_IPV6 表示 IPv6 协议。
const void *daddr:指向目的 MAC 地址的指针,如果为 NULL,则需要使用其他默认地址。
const void *saddr:指向源 MAC 地址的指针,如果为 NULL,则使用当前网络设备的 MAC 地址。
unsigned int len:数据包的长度。
*/
struct nic_priv *priv = netdev_priv(dev);
//将预留空间的起始地址强制转换为 struct ethhdr 类型的指针,并赋值给 eth 指针,这样就可以通过 eth 指针来操作以太网头部
struct ethhdr *eth = (struct ethhdr*)skb_push(skb, ETH_HLEN);
struct net_device *dst_netdev;
//输出当前函数名和行号的调试信息。其中 priv 是网络设备的私有数据,drv 表示调试信息的类别,dev 是当前网络设备,__func__ 是当前函数名,__LINE__ 是当前行号
netif_info(priv, drv, dev, "%s(#%d)\n",
__func__, __LINE__);
//根据当前网络设备 dev 来确定目的网络设备。如果 dev 是 nic_dev[0],则目的网络设备是 nic_dev[1];反之,如果 dev 是 nic_dev[1],则目的网络设备是 nic_dev[0]
dst_netdev = nic_dev[(dev == nic_dev[0] ? 1 : 0)];
//将传入的协议类型 type 转换为网络字节序后,赋值给以太网头部的 h_proto 字段,表示该以太网帧所承载的协议类型
eth->h_proto = htons(type);
//复制源MAC地址和目的MAC地址
memcpy(eth->h_source, saddr ? saddr : dev->dev_addr, dev->addr_len);
memcpy(eth->h_dest, dst_netdev->dev_addr, dst_netdev->addr_len);
//返回当前网络设备的硬件头部长度,通常为 14 字节。这个返回值可以让调用者知道添加的以太网头部的长度
return dev->hard_header_len;
}
static const struct header_ops nic_header_ops = {
.create = nic_header_create,
};
static struct net_device_ops nic_device_ops = {
.ndo_open = nic_open,
.ndo_stop = nic_stop,
.ndo_start_xmit = nic_start_xmit,
.ndo_set_mac_address = nic_set_mac_addr,
.ndo_validate_addr = nic_validate_addr,
.ndo_change_mtu = nic_change_mtu,
};
static struct net_device *nic_alloc_netdev(void){
//alloc_etherdev:内核函数,用于分配一个以太网设备(struct net_device)的内存,参数是私有数据区域的大小
struct net_device *netdev = alloc_etherdev(sizeof(struct nic_priv));
if (!netdev) {
pr_err("%s(#%d): alloc dev failed", __func__, __LINE__);
return NULL;
}
//设置随机 MAC 地址
eth_hw_addr_random(netdev);
//绑定网络设备操作函数,:将设备的操作函数表绑定到新分配的网络设备,定义其行为(例如如何处理数据包发送、打开/关闭设备等)
netdev->netdev_ops = &nic_device_ops;
//表示该设备需要 ARP(地址解析协议)
netdev->flags &= ~IFF_NOARP;
//启用硬件校验和功能,告诉内核网络协议栈,设备可以自行处理 IP/TCP/UDP 等协议的校验和计算,无需软件参与
netdev->features |= NETIF_F_HW_CSUM;
//设置头部操作函数,nic_header_create 用于在发送数据包时构造以太网头部
return netdev;
}
//加载设备
static int __init nic_init(void){
int ret = 0;
struct nic_priv *priv;
pr_info("%s(#%d): install module\n", __func__, __LINE__);
//1.malloc net_device
nic_dev[0] = nic_alloc_netdev();
if (!nic_dev[0]) {
printk("%s(#%d): alloc netdev[0] failed\n", __func__, __LINE__);
return -ENOMEM;
}
nic_dev[1] = nic_alloc_netdev();
if (!nic_dev[1]) {
printk("%s(#%d): alloc netdev[1] failed\n", __func__, __LINE__);
goto alloc_2nd_failed;
}
//2.register
ret = register_netdev(nic_dev[0]);
if (ret) {
printk("%s(#%d): reg net driver failed. ret: %d\n", __func__, __LINE__, ret);
goto reg1_failed;
}
ret = register_netdev(nic_dev[1]);
if (ret) {
printk("%s(#%d): reg net driver failed. ret:%d\n", __func__, __LINE__, ret);
goto reg2_failed;
}
//初始化两个网络设备的私有数据结构,具体来说是为每个设备的 msg_enable 字段赋值为 DEF_MSG_ENABLE(即 0xffff)
/*
在 Linux 内核里,struct net_device 结构体代表一个网络设备。
为了让驱动程序能够存储与特定网络设备相关的私有数据,内核采用了一种特殊的存储方式。
当使用 alloc_etherdev 等函数分配网络设备时,
会额外分配一块内存空间用于存储私有数据,这块空间紧跟在 struct net_device 结构体之后。
*/
priv = netdev_priv(nic_dev[0]); // 获取 nic_dev[0] 的私有数据指针
priv->msg_enable = DEF_MSG_ENABLE; // 设置 msg_enable 为 0xffff
priv = netdev_priv(nic_dev[1]); // 获取 nic_dev[1] 的私有数据指针
priv->msg_enable = DEF_MSG_ENABLE; // 设置 msg_enable 为 0xffff
return 0;
reg2_failed:
unregister_netdev(nic_dev[0]);
reg1_failed:
free_netdev(nic_dev[1]);
alloc_2nd_failed:
free_netdev(nic_dev[0]);
return ret;
}
//卸载设备
static void __exit nic_exit(void){
int i = 0;
pr_info("%s(#%d): remove module\n", __func__, __LINE__);
for (i = 0;i < ARRAY_SIZE(nic_dev);i ++) {
unregister_netdev(nic_dev[i]);
free_netdev(nic_dev[i]);
}
}
module_init(nic_init);
module_exit(nic_exit);
二、Makefile
bash
#!/bin/bash
ccflags_y += -O2
ifneq ($(KERNELRELEASE),)
obj-m := nic.o
else
KERNELDIR ?= /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
default:
$(MAKE) -C $(KERNELDIR) M=$(PWD) modules
endif
clean:
rm -rf *.o *.ko *.mod.c
depend .depend dep:
$(CC) -M *.c > .depend
三、编译插入模块
1.make

2.insmod插入模块

四、运行
1.查看网卡 ifconfig -a
,发现新增两个设备eth0和eth1

2.配置虚拟网卡的ip
bash
ip link set eth0 down
ip link set eth0 up
ip link set eth1 down
ip link set eth1 up
ip addr add 192.168.1.1/24 dev eth0
ip addr add 192.168.1.2/24 dev eth1
3.再次查看网卡

4.执行ping操作
ping 192.168.1.1
ping 192.168.1.2

5.卸载模块rmmod
rmmod nic.ko
五、心得解读
在使用这两个虚拟网卡执行ping命令(比如ping 192.168.1.2,这个是eth1),首先会进行目标ip匹配,此时eth(192.168.1.1/24)和eth1(192.168.1.2/24)都符合,此时路由决策都匹配,则会使用先启用的接口或者默认主接口(这里是eth0),因此选择eth0发送数据包。
当 eth0
发送 ICMP 请求到 192.168.1.2
(即 eth1
的 IP)时,驱动通过 nic_hw_xmit
直接调用 eth1
的 nic_rx
,模拟 eth1
接收到该数据包。nic_rx
提交的 数据包内容 是 eth0
发送的 ICMP 请求, 内核协议栈识别到该数据包是发送给 eth1
的本地 IP,会触发 ICMP 响应生成 (Echo Reply)。在nic_rx函数的最后使用netif_rx(skb);提交给内核协议栈处理,内核协议栈会生成响应并回发,具体流程如下:
(1)ICMP 请求阶段
bash
// eth0 发送请求
nic_start_xmit(eth0) -> nic_hw_xmit(eth0) -> nic_rx(eth1)
// eth1 接收请求
nic_rx(eth1) -> 协议栈生成响应 -> nic_start_xmit(eth1) -> nic_hw_xmit(eth1) -> nic_rx(eth0)
(2)ICMP 响应阶段
bash
// eth1 发送响应
nic_start_xmit(eth1) -> nic_hw_xmit(eth1) -> nic_rx(eth0)
// eth0 接收响应
nic_rx(eth0) -> 协议栈处理 -> 用户空间 `ping` 进程收到响应。