QEMU edu设备驱动编写

学校开了虚拟化可信化定制这一门课,蛮有意思的,课后给了几个大作业,一个是 QEMU edu 驱动的编写,一个是 QEMU 中断拦截的定制,还有一个是磁盘访问命令的定制。我做的是中断拦截的定制,但也涉及到了 edu 驱动的编写,因此分享一下 edu 设备驱动的编写(MMIO交互),并映射成 character device。

这篇文章将带你从开发环境开始,一路杀到用户空间的程序。

写在前面: 在我写这篇 blog 的时候,我注意到一个更优秀的解决方案,可以尝试使用 Buildroot 来搭建开发环境,也许比本文提到的方案更加优美!

注意! 当前实现的驱动并 不支持 多台设备并行存在。

由于本文引用了 CC-BY-SA 4.0 协议的内容,因此本文也采用 CC-BY-SA 4.0 协议

环境

如果你在 Windows 上使用虚拟机操作的话,请准备你用起来最顺手的 SSH 客户端与支持远程开发的 IDE 比如 Visual Studio Code + Remote SSH 插件

  • Archlinux (任意Linux都可以,首先确保有完整的开发环境)
  • QEMU

对于提示缺少命令或者缺少某某头文件的情况下,请使用 Packages for Linux and Unix 或者使用 StackOverflow 搜索。

edu 设备简介

edu 为 QEMU 中的一个教学用设备,这个设备是一个 PCI 设备,该设备主要作用为取反、计算阶乘,主要通过 MMIO 与 CPU 进行交互,当然,这个设备也支持 DMA,但这里不讨论 DMA。哪天有心情写了我再做个 DMA 更新到本篇文章。

edu 设备的寄存器可以参考 edu.txt

其中主要寄存器如下(不关注 DMA ):

  • 0x04(RW) 设备存活状态检测,实际作用为对写入的值进行取反,并放回原处
  • 0x08(RW) 阶乘寄存器,写入一个数值,返回该数值的阶乘
  • 0x20(RW) 状态寄存器,其低 1 位为 只读,记录设备是否在进行阶乘操作,完成则为 0,否则为 1;其从低向高第 8 位为 读写,记录设备是否在完成阶乘操作后发起中断,发起则为 1,否则为 0
  • 0x24(RO) 中断状态寄存器,记录中断状态,0x00000001为阶乘中断,0x00000100为 DMA 中断
  • 0x60(WO) 中断发起寄存器,用于手动发起中断,中断状态将存入 0x24 寄存器
  • 0x64(WO) 中断确认寄存器,用于清除中断,将 0x24 寄存器清零并阻止设备继续发出中断

环境准备

为了方便我们调试驱动,我们需要创建一个简单的内核与 initramfs,内核我们使用最小化编译,initramfs 我们使用 busybox,在完成编译后,我们直接通过 qemu 启动内核,然后通过 serial 与 qemu 进行交互。

安装工具并准备工作目录

1
2
sudo pacman -Syy
sudo pacman -S qemu git base-devel wget

选择一个自己最喜欢的目录,然后创建一个工作目录,这里我选择了 ~/virt

1
2
3
4
5
6
7
8
9
10
mkdir -p ~/qemu/edu-driver
cd ~/qemu/edu-driver
# src: linux,busybox 源码存放
# build: 编译目录
# staging: 用于存放 initramfs
# kmod: 用于存放驱动源码
# program: 用于存放用户空间程序
mkdir -p {src,build/{linux,busybox},staging,kmod,program}
# 为了方便下文的操作,我们将工作目录设置为环境变量
WORKDIR=$(pwd)

编译内核

首先我们 kernel.org 上获取一个新鲜的内核,这里我们选定了最新的 stable 6.6.2,并把它拖回本地。

1
2
3
4
5
6
7
8
cd $WORKDIR/src
wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.6.2.tar.xz
tar -xvf linux-6.6.2.tar.xz
cd linux-6.6.2
# 去除掉多余的模块
make O=$WORKDIR/build/linux allnoconfig
# 选择我们需要的配置
make O=$WORKDIR/build/linux nconfig

接下来我们选择以下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
-> General setup
[*] Initial RAM filesystem and RAM disk (initramfs/initrd) support # 打开 initramfs 支持
-> Configure standard kernel features
[*] Enable support for printk # 打开printk支持
[*] 64-bit kernel # 64 位内核
[*] Enable loadable module support # 打开模块支持
-> Executable file format # 可执行文件格式支持 ELF 与 #! 开头的脚本,用于用户空间程序与脚本
[*] Kernel support for ELF binaries
[*] Kernel support for scripts starting with #!
-> Device Drivers
[*] PCI Support # PCI 支持
-> Generic Driver Options
[*] Maintain a devtmpfs filesystem to mount at /dev # 打开 devtmpfs 支持,不然 /dev 下没设备
-> Character devices
[*] Enable TTY # 启用 TTY
-> Serial drivers
[*] 8250/16550 and compatible serial support # 打开 8250/16550 串口支持
-> [*] Console on 8250/16550 and compatible serial port # 打开串口控制台,不然串口启动会 kernel panic
-> File systems
-> Pseudo filesystems
[*] /proc file system support # procfs 支持
[*] sysfs file system support # sysfs 支持

开始编译 Linux:

1
2
3
4
make O=$WORKDIR/build/linux -j$(nproc)
cd $WORKDIR
# 将成品 bzImage 复制出来
cp build/linux/arch/x86/boot/bzImage .

编译 busybox

busybox 的作用就是提供一个简单的用户空间,一个简单的 shell,方便我们运行接下来的程序。首先,我们从 https://busybox.net/downloads/ 上获取一个顺眼的版本,这里我们选定了 1.36.1,并把它拖回本地。

1
2
3
4
5
6
7
8
9
10
11
cd $WORKDIR/src
wget https://busybox.net/downloads/busybox-1.36.1.tar.bz2
tar -xvf busybox-1.36.1.tar.bz2
cd busybox-1.36.1
make O=$WORKDIR/build/busybox defconfig
make O=$WORKDIR/build/busybox menuconfig
# 选择以下配置
# Busybox Settings -> Build Options -> [*] Build static binary (no shared libs)
# 即将 busybox 编译为静态二进制,不依赖动态库,因为我们的环境没有 glibc
make O=$WORKDIR/build/busybox -j$(nproc)
make O=$WORKDIR/build/busybox install

创建 initramfs

我们接下来要创建一个 initramfs,这个 initramfs 将包含我们的驱动、用户空间程序与 busybox。

1
2
3
4
5
6
7
8
9
10
cd $WORKDIR/staging
mkdir -p {bin,dev,etc,lib,proc,root,sbin,sys}
# 创建设备节点,我们直接用操作系统的就行
sudo cp -a /dev/{null,console,tty} dev/
# 复制 busybox
cp -a $WORKDIR/build/busybox/_install/* .
# 编写一个简单的 init
touch init
chmod +x init
vim init

init 文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/bin/sh

# 挂载 procfs, sysfs, devtmpfs
mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs none /dev

echo -e "\nHello, World!\n"

# 加载驱动,这里我们暂且注释,因为我们的驱动暂时还没写
# insmod /edu.ko

# 启动 shell
exec /bin/sh

为了方便我们接下来的操作,我们编写一个简单的脚本来帮助我们快速制作 initramfs,并启动 qemu。

1
2
3
4
cd $WORKDIR
touch run.sh
chmod +x run.sh
vim run.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#!/bin/bash

# 使用 set -e 使得脚本在出现错误时退出,方便查看错误信息
set -e

# 编译驱动
function compile_kmod() {
pushd kmod
# 编译驱动
make clean
make
# 将编译好的驱动复制到 staging 目录
cp edu.ko ../staging/edu.ko
popd
}
# 编译用户空间程序
function compile_program() {
pushd program
# 编译用户空间程序,这里使用 -static 使得程序静态链接,不依赖动态库
gcc edu.c -o edu -static
# 将编译好的程序复制到 staging 目录
cp edu ../staging/bin/edu
popd
}
# 制作 initramfs
function makeinitcpio() {
pushd staging
find . | cpio -H newc -o > ../initramfs.cpio
popd
cat initramfs.cpio | gzip > initramfs.cpio.gz
rm initramfs.cpio
}
# 运行
function run() {
# 运行 qemu,指定内核、initramfs、加速器、edu 设备、内核启动参数,其中内核启动参数中的 console=ttyS0 用于开启串口输出,earlyprintk=serial,ttyS0 用于内核早期输出打印,loglevel=3 为内核日志等级,oops=panic 用于内核出现 oops 时直接 panic,panic=1 用于内核出现 panic 时直接重启,然后我们 qemu 设置为 -no-reboot,这样重启过程中 qemu就会自动退出,不然我们需要手动退出 qemu
qemu-system-x86_64 -no-reboot -nographic -kernel ./bzImage -initrd initramfs.cpio.gz -machine accel=kvm -device edu -append "earlyprintk=serial,ttyS0 console=ttyS0 loglevel=3 oops=panic panic=1"
}

# 暂且注释掉,因为我们的驱动暂时还没写
# compile_kmod
# compile_program
makeinitcpio
run

接下来我们尝试运行一下:

1
./run.sh

准备基本框架

我们接下来准备程序的基本框架:

1
2
cd $WORKDIR/kmod
vim Makefile

首先,编辑 Makefile,内容如下:

1
2
3
4
5
6
7
obj-m += edu.o

all:
make -C ../build/linux M=$(PWD) modules

clean:
make -C ../build/linux M=$(PWD) clean

然后,我们创建一个 edu.c,内容如下:

1
vim edu.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <linux/module.h>

static int __init edu_init(void)
{
printk(KERN_INFO "edu: Hello, World!\n");
return 0;
}

static void __exit edu_exit(void)
{
printk(KERN_INFO "edu: Goodbye, World!\n");
}

module_init(edu_init);
module_exit(edu_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("SpartaEN <[email protected]>");
MODULE_DESCRIPTION("edu driver");
MODULE_VERSION("0.0.1");

我们再准备一下用户空间的程序:

1
2
cd $WORKDIR/program
vim edu.c
1
2
3
4
5
6
7
#include <stdio.h>

int main()
{
printf("Hello, World!\n");
return 0;
}

我们去掉 init 中的驱动加载注释:

1
2
3
cd $WORKDIR
vim staging/init
# 去掉 insmod /edu.ko 的注释

接下来我们就可以去掉 run.sh 中的注释了,然后运行一下:

1
2
3
vim run.sh
# 去掉 compile_kmod,compile_program 的注释
./run.sh

执行一下 dmesgedu,看看效果:

到这里,我们的开发环境已经完成啦!

驱动编写

接下来是重头戏:驱动编写。

驱动主要包含两个部分:与设备交互的部分与与用户空间交互的部分。

与设备交互

首先我们完成与设备交互的部分,设备具体的情况可以查阅 QEMU 的文档 edu.txt

在本文撰写时,qemu 的最新 tag 为 stable-8.1,因此本文给出的 edu.txt 为 stable-8.1 的版本,如果现在查看主分支,请注意 qemu 已将文件格式改为 rst 格式,但内容一致。

首先 QEMU 是一个 PCI 设备,因此我们要做以下工作:

  • 定义 PCI 设备 ID
  • 定义 PCI 设备的 probe 与 remove 函数

由于设备支持中断,因此我们还要做以下工作:

  • 定义中断处理函数

接下来我们开始编写驱动的基本功能:

1
2
cd $WORKDIR/kmod
vim edu.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
#include <linux/module.h>
#include <linux/pci.h>
#include <linux/interrupt.h>

// 定义 PCI 设备 ID
#define EDU_DEVICE_VENDOR_ID 0x1234
#define EDU_DEVICE_DEVICE_ID 0x11e8

// 定义要用到的寄存器偏移量
#define IO_DEV_CARD_LIVENESS 0x04
#define IO_DEV_VALUE 0x08
#define IO_DEV_STATUS 0x20
#define IO_DEV_IRQ_STATUS 0x24
#define IO_DEV_IRQ_ACK 0x64

static irqreturn_t edu_irq_handler(int irq, void *dev_id);
static int edu_probe(struct pci_dev *dev, const struct pci_device_id *id);
static void edu_remove(struct pci_dev *dev);

// 定义设备结构体
struct pci_card
{
void __iomem *ioaddr;
int irq;
};

static struct pci_card *mypci;

// 定义 PCI 设备表
static const struct pci_device_id edu_table[] = {
{PCI_DEVICE(EDU_DEVICE_VENDOR_ID, EDU_DEVICE_DEVICE_ID)},
{0}
};

MODULE_DEVICE_TABLE(pci, edu_table);

// 定义中断处理函数
static irqreturn_t edu_irq_handler(int irq, void *dev_id)
{
// 读取中断状态
u32 irq_status = readl(mypci->ioaddr + IO_DEV_IRQ_STATUS);

printk(KERN_INFO "edu: IRQ %d triggered, %d\n", irq, irq_status);

// 将中断状态写入中断确认寄存器,清除中断
writel(irq_status, mypci->ioaddr + IO_DEV_IRQ_ACK);

// 读取阶乘结果
u32 value = readl(mypci->ioaddr + IO_DEV_VALUE);

// 输出到 dmesg
printk(KERN_INFO "edu: Value read from device: %d\n", value);

return IRQ_HANDLED;
}

// 定义 PCI 设备的 probe 函数
static int edu_probe(struct pci_dev *dev, const struct pci_device_id *id)
{
// 首先定义一个 retval,用于返回错误码,我们下面的 goto 要用到
int retval;

// 首先打开设备
if (pci_enable_device(dev))
{
printk(KERN_ERR "edu: Cannot enable PCI device\n");
retval = -EIO;
goto out_edu_all;
}

// 为结构体分配内存
mypci = kmalloc(sizeof(struct pci_card), GFP_KERNEL);
if (!mypci)
{
printk(KERN_ERR "edu: Cannot allocate memory for the device\n");
retval = -ENOMEM;
goto out_edu_all;
}

// 复制设备的中断号到结构体并检查
mypci->irq = dev->irq;
if (mypci->irq < 0)
{
printk(KERN_ERR "edu: Invalid IRQ number\n");
goto out_mypci;
}

// 请求设备的内存区域
retval = pci_request_regions(dev, "edu");
if (retval)
{
printk(KERN_ERR "edu: Cannot request regions\n");
goto out_mypci;
}

// 映射设备的内存区域
mypci->ioaddr = pci_ioremap_bar(dev, 0);
if (!mypci->ioaddr)
{
printk(KERN_ERR "edu: Cannot map device memory\n");
retval = -ENOMEM;
goto out_regions;
}

// 设置中断处理函数
retval = request_irq(dev->irq, edu_irq_handler, IRQF_SHARED, "edu", dev);
if (retval)
{
printk(KERN_ERR "edu: Cannot set up IRQ handler\n");
goto out_ioremap;
}

// 启用设备的中断发起
writel(0x80, mypci->ioaddr + IO_DEV_STATUS);

// 将结构体指针存入设备的私有数据区
pci_set_drvdata(dev, mypci);

// 我们测试一下,让他算个阶乘
writel(5, mypci->ioaddr + IO_DEV_VALUE);

return 0;

out_ioremap:
pci_iounmap(dev, mypci->ioaddr);
out_regions:
pci_release_regions(dev);
out_mypci:
kfree(mypci);
out_edu_all:
return retval;
}

// 定义 PCI 设备的 remove 函数
static void edu_remove(struct pci_dev *dev)
{
// 释放中断
free_irq(mypci->irq, mypci);
// 释放内存区域
pci_iounmap(dev, mypci->ioaddr);
pci_release_regions(dev);
// 释放结构体内存
kfree(mypci);
// 停用设备
pci_disable_device(dev);
}

// 定义 PCI 驱动
static struct pci_driver edu_driver = {
.name = "edu",
.id_table = edu_table,
.probe = edu_probe,
.remove = edu_remove,
};

// 驱动的初始化函数与卸载函数
static int __init edu_init(void)
{
return pci_register_driver(&edu_driver);
}

static void __exit edu_exit(void)
{
pci_unregister_driver(&edu_driver);
}

// 注册驱动的初始化函数与卸载函数
module_init(edu_init);
module_exit(edu_exit);

// 作者信息
MODULE_LICENSE("GPL");
MODULE_AUTHOR("SpartaEN <[email protected]>");
MODULE_DESCRIPTION("edu driver");
MODULE_VERSION("0.0.1");

保存,执行 ./run.sh,然后执行 dmesg,看看效果:

那么上面的代码是怎么来的呢?

首先,Linux 内核模块是通过 module_initmodule_exit 宏来定义初始化函数与卸载函数的,其次对于 edu 这种 PCI 设备,会通过 pci_register_driver 对设备进行注册。

这里使用的 Linux 内核为 v6.6.2,对于其他版本的 Linux 内核也许行数不一样

pci_register_driver 使用的参数为 pci_driver 结构体,对于这个结构体我们直接看 Linux 源码比较直观:include/linux/pci.h#L918

这里我们会用到 nameid_tableproberemove字段,对于其所有字段的说明,在源码的注释上都有详细的说明,具体请移步源码查看。

其中 name 为驱动的名称,id_table 为设备的 id 号,内核就是根据这个 id 号来实现对驱动的”对号入座”的,probe 函数当设备被插入或者已经存在一个没有驱动的设备时会被调用,remove 函数当设备被移除时会被调用。

很显然,我们代码中的设备相关结构体指针是一个全局变量,这是因为接下来我们将其与 character device 进行关联时会用到,这种设计也决定了 edu 设备只能有一个,除非我们将其对应的结构体指针改为数组,但这里不做讨论。

下面我们再看看 edu_probe 函数,首先我们要先启用设备,再为其请求内存区域、重新映射内存以便我们访问,最后挂上中断处理函数,在这个过程中我们也会对设备的部分寄存器进行初始化。在这个过程中我们难免会涉及到错误处理,资源必须有借有还,同样我们的初始化操作出了错误也必须回滚。

小贴士:回滚操作可以使用 goto 语句来进行,前面资源如果是 A->B->C 的顺序进行分配,那么我们后面的释放操作就要按 C->B->A 的顺序进行释放,并为其打上对应的标签。

edu_remove 函数与回滚操作一样,其实就是把 edu_probe 中的回滚操作全量执行一遍。

与用户空间交互

这里我们要将设备做成一个 character device,这样我们就可以通过 /dev/edu 来访问设备了。

我们希望用户空间程序以这样的方式与设备进行交互:

  • 通过 open() 操作打开设备文件,且设备每次仅允许一个程序打开
  • 通过 read()write() 操作来实现文件读写
  • 通过 ioctl() 来实现设备模式的控制(如切换计算到阶乘/取反)

因此我们在原来代码的基础上做以下修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/pci.h>
#include <linux/interrupt.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/slab.h>
#include <linux/uaccess.h>

// 定义 character device 的名称以及类名
#define DEVICE_NAME "edu"
#define CLASS_NAME "edu"

// 定义 ioctl 的操作号
#define USER_OP_INVERSE 0
#define USER_OP_FACT_MMIO 1

// character device 所需的内容
static int major;
static struct class *edu_class = NULL;
static struct device *edu_device = NULL;

// 我们在 pci_card 结构体里加上新的内容
struct pci_card
{
void __iomem *ioaddr;
int irq;
// 等待队列,用于阻塞读请求,以避免用户在设备未完成阶乘操作时读取到错误值
wait_queue_head_t wq;
// 用于记录设备是否在进行阶乘操作,主要也用于阻塞读请求,作为条件变量存在
bool irq_handled;
// 操作
u32 op;
// 计算结果
u32 value;
// 设备是否被打开,这里我们做一个简单的标志位,使用原子变量防止data race
atomic_t is_opened;
};

static struct pci_card *mypci;
// 定义文件操作函数
static int edu_open(struct inode *inode, struct file *file);
static int edu_release(struct inode *inode, struct file *file);
static ssize_t edu_read(struct file *file, char __user *buf, size_t count, loff_t *ppos);
static ssize_t edu_write(struct file *file, const char __user *buf, size_t count, loff_t *ppos);
static long edu_ioctl(struct file *file, unsigned int cmd, unsigned long arg);

// 文件打开操作
static int edu_open(struct inode *inode, struct file *file)
{
// 如果设备没有完成计算,直接返回 EBUSY,告诉用户设备在忙
if (!mypci->irq_handled)
{
printk(KERN_INFO "edu: irq not handled, rejecting");
return -EBUSY;
}
// 使用 atomic_cmpxchg 尝试改变标志位,如果不是预期内的值,则是还有程序在使用,返回 EBUSY,该函数具体文档见 https://elixir.bootlin.com/linux/v6.6.2/source/include/linux/atomic/atomic-instrumented.h#L1191
if (atomic_cmpxchg(&mypci->is_opened, 0, 1) == 0)
{
return 0;
}
else
{
printk(KERN_INFO "edu: already opened, rejecting");
return -EBUSY;
}
}

// 设备释放操作
static int edu_release(struct inode *inode, struct file *file)
{
// 标志位置为 0
atomic_set(&mypci->is_opened, 0);
return 0;
}

// 设备读取操作
static ssize_t edu_read(struct file *file, char __user *buf, size_t count, loff_t *ppos)
{
// 首先看操作类型,因为取反操作并不会产生中断,直接读入即可
switch (mypci->op)
{
case USER_OP_INVERSE:
mypci->value = readl(mypci->ioaddr + IO_DEV_CARD_LIVENESS);
break;
case USER_OP_FACT_MMIO:
// 对于阶乘操作,我们需要阻塞读请求,直到设备完成阶乘操作,对于 wait_event_interruptible 这个宏的文档,请参考 https://elixir.bootlin.com/linux/v6.6.2/source/include/linux/wait.h#L499
wait_event_interruptible(mypci->wq, mypci->irq_handled);
break;
default:
// 其他操作直接返回 EINVAL
return -EINVAL;
}
// 将结果写入用户空间
if (copy_to_user(buf, &mypci->value, sizeof(mypci->value)))
{
return -EFAULT;
}
else
{
return sizeof(mypci->value);
}
}

// 写操作函数
static ssize_t edu_write(struct file *file, const char __user *buf, size_t count, loff_t *ppos)
{
u32 user_buf;
// 从用户空间读取数据
if (copy_from_user(&user_buf, buf, sizeof(user_buf)))
{
return -EFAULT;
}
// 读取操作类型,写入不同寄存器
switch (mypci->op)
{
case USER_OP_INVERSE:
writel(user_buf, mypci->ioaddr + IO_DEV_CARD_LIVENESS);
break;
case USER_OP_FACT_MMIO:
writel(user_buf, mypci->ioaddr + IO_DEV_VALUE);
mypci->irq_handled = false;
break;
default:
return -EINVAL;
}
printk(KERN_INFO "edu: Writing %d to device\n", user_buf);
return sizeof(user_buf);
}

// ioctl 操作
static long edu_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
{
// 直接根据不同的 cmd 来设置操作类型
switch (cmd)
{
case USER_OP_INVERSE:
mypci->op = USER_OP_INVERSE;
break;
case USER_OP_FACT_MMIO:
mypci->op = USER_OP_FACT_MMIO;
break;
default:
return -EINVAL;
}
return 0;
}

// 定义文件操作函数结构体,具体都有什么操作呢?看内核源码 https://elixir.bootlin.com/linux/v6.6.2/source/include/linux/fs.h#L1852
static struct file_operations edu_fops =
{
.read = edu_read,
.write = edu_write,
.open = edu_open,
.release = edu_release,
.unlocked_ioctl = edu_ioctl,
};

// 接下来 irq_handler 与 edu_probe 也需要进行修改
static irqreturn_t edu_irq_handler(int irq, void *dev_id)
{
u32 irq_status = readl(mypci->ioaddr + IO_DEV_IRQ_STATUS);

printk(KERN_INFO "edu: IRQ %d triggered, %d\n", irq, irq_status);

// clear irq
writel(irq_status, mypci->ioaddr + IO_DEV_IRQ_ACK);

// 我们直接把阶乘结果读入结构体
mypci->value = readl(mypci->ioaddr + IO_DEV_VALUE);

printk(KERN_INFO "edu: Value read from device: %d\n", mypci->value);

// 将中断处理状态设置为 true,再唤醒等待队列,如果两者颠倒,会导致死锁,一定要注意!
mypci->irq_handled = true;
wake_up_interruptible(&mypci->wq);

return IRQ_HANDLED;
}

// 现在这个函数就是完全体了!
static int edu_probe(struct pci_dev *dev, const struct pci_device_id *id)
{
int retval;

// 我们先初始化charracter device,这里就指定了他的操作
// 首先先注册一个 major number
major = register_chrdev(0, DEVICE_NAME, &edu_fops);

if (major < 0)
{
printk(KERN_ERR "edu: Cannot register char device\n");
goto out_edu_all;
}

// 创建一个 class,注意这里在 kernel 6.4 换 API 了,原先是需要一个 MODULE_OWNER 的,现在不需要了
// API Changed since kernel 6.4, refer https://cdn.kernel.org/pub/linux/kernel/v6.x/ChangeLog-6.4
edu_class = class_create(CLASS_NAME);

if (IS_ERR(edu_class))
{
printk(KERN_ERR "edu: Cannot create class\n");
goto out_edu_chr_device;
}

// 创建一个 device,这时候他就能出现在 /dev 下了
edu_device = device_create(edu_class, NULL, MKDEV(major, 0), NULL, DEVICE_NAME);
if (IS_ERR(edu_device))
{
printk(KERN_ERR "edu: Cannot create device\n");
goto out_edu_class;
}

// 为结构体分配内存
mypci = kmalloc(sizeof(struct pci_card), GFP_KERNEL);
if (!mypci)
{
printk(KERN_ERR "edu: Cannot allocate memory for the driver\n");
retval = -ENOMEM;
goto out_edu_device;
}

// 初始化成员变量
atomic_set(&mypci->is_opened, 0);
mypci->value = 0;
mypci->irq_handled = true;

init_waitqueue_head(&mypci->wq);

// 下面同上文,初始化 PCI 设备
if (pci_enable_device(dev))
{
printk(KERN_ERR "edu: Cannot enable PCI device\n");
retval = -EIO;
goto out_mypci;
}

mypci->irq = dev->irq;
if (mypci->irq < 0)
{
printk(KERN_ERR "edu: Invalid IRQ number\n");
goto out_pci_enable_dev;
}

retval = pci_request_regions(dev, "edu");
if (retval)
{
printk(KERN_ERR "edu: Cannot request regions\n");
goto out_pci_enable_dev;
}

mypci->ioaddr = pci_ioremap_bar(dev, 0);
if (!mypci->ioaddr)
{
printk(KERN_ERR "edu: Cannot map device memory\n");
retval = -ENOMEM;
goto out_regions;
}

retval = request_irq(dev->irq, edu_irq_handler, IRQF_SHARED, "edu", dev);
if (retval)
{
printk(KERN_ERR "edu: Cannot set up IRQ handler\n");
goto out_ioremap;
}

writel(0x80, mypci->ioaddr + IO_DEV_STATUS);

pci_set_drvdata(dev, mypci);

return 0;

// 资源释放部分不要忘记更新!
out_ioremap:
pci_iounmap(dev, mypci->ioaddr);
out_regions:
pci_release_regions(dev);
out_pci_enable_dev:
pci_disable_device(dev);
out_mypci:
kfree(mypci);
out_edu_device:
device_destroy(edu_class, MKDEV(major, 0));
out_edu_class:
class_destroy(edu_class);
out_edu_chr_device:
unregister_chrdev(major, DEVICE_NAME);
out_edu_all:
return retval;
}

// edu_remove 也需要更新保证资源都能够释放干净!
static void edu_remove(struct pci_dev *dev)
{
struct pci_card *mypci = pci_get_drvdata(dev);
free_irq(mypci->irq, mypci);
pci_iounmap(dev, mypci->ioaddr);
pci_release_regions(dev);
kfree(mypci);
pci_disable_device(dev);
device_destroy(edu_class, MKDEV(major, 0));
class_unregister(edu_class);
class_destroy(edu_class);
unregister_chrdev(major, DEVICE_NAME);
}

到这里我们的设备驱动就完成了,我们不妨编译一下,看看效果先,代码可以在这里下载到:edu.c

可以看到我们的 edu 设备出现在了 /dev 下。

用户空间程序

下面我们就可以按照我们刚才写的驱动来编写用户空间程序了。

1
2
cd $WORKDIR/program
vim edu.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
#include <stdio.h>
#include <string.h>
#include <stdint.h>
#include <errno.h>

// 首先是我们在驱动定义的 ioctl cmd
#define OP_INVERSE 0
#define OP_FACT_MMIO 1

// 为了让我们取反操作看起来更直观,我们定义宏来打印二进制,参考 https://stackoverflow.com/a/3208376
#define BYTE_TO_BINARY_PATTERN "%c%c%c%c%c%c%c%c"
#define BYTE_TO_BINARY(byte) \
((byte) & 0x80 ? '1' : '0'), \
((byte) & 0x40 ? '1' : '0'), \
((byte) & 0x20 ? '1' : '0'), \
((byte) & 0x10 ? '1' : '0'), \
((byte) & 0x08 ? '1' : '0'), \
((byte) & 0x04 ? '1' : '0'), \
((byte) & 0x02 ? '1' : '0'), \
((byte) & 0x01 ? '1' : '0')

void print_bin(uint32_t value)
{
char *val = &value;
printf("0x");
// 因为 x86 是小端序,因此倒序输出
for (int i = 3; i >= 0; i--)
{
printf(BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(val[i]));
}
printf("\n");
}

int main(int argc, char **argv)
{
uint32_t value;
int ret;
if (argc != 3)
{
printf("Usage: %s <i|f> [value]\n", argv[0]);
return 1;
}
FILE *fp;
fp = fopen("/dev/edu", "r+");
if (fp == NULL)
{
// 如果错误代码为 EBUSY,说明设备在忙,我们直接退出
if (errno == EBUSY)
{
printf("Device seems busy\n");
return 1;
}
else
{
printf("Error opening device\n");
return 1;
}
}
// 输入的字符串转整型
value = atoi(argv[2]);
if (strcmp(argv[1], "i") == 0)
{
// 先用 ioctl 设置操作类型
// 调用 fwrite 写入数值
// 再调用 fread 读取结果,所有的操作这么进行
ioctl(fileno(fp), OP_INVERSE, 0);
printf("Original Value ");
print_bin(value);
fwrite(&value, sizeof(value), 1, fp);
fread(&value, sizeof(value), 1, fp);
printf("Result: ");
print_bin(value);
}
else if (strcmp(argv[1], "f") == 0)
{
ioctl(fileno(fp), OP_FACT_MMIO, 0);
fwrite(&value, sizeof(value), 1, fp);
fread(&value, sizeof(value), 1, fp);
printf("Result: %d\n", value);
}
else
{
printf("Usage: %s <i|f> [value]\n", argv[0]);
return 1;
}
return 0;
}

编译执行,查看结果:

1
2
cd $WORKDIR
./run.sh

下集预告: qemu i440fx 中断拦截设计,最近突然被塞了一堆活,可能就要咕好久了