jklincn


QEMU EDU 设备驱动


QEMU EDU 设备是 QEMU 中用于设备驱动程序教学的设备。在马萨里克大学的 Linux 内核课程中,学生可以使用这个虚拟设备编写一个包含 I/O、IRQ、DMA 等的驱动程序。

设备规范:EDU device — QEMU documentation

设备源码:https://github.com/qemu/qemu/blob/master/hw/misc/edu.c

本文参考已有的项目重新从零实现 QEMU EDU 的驱动程序。参考项目:

由于文章中的代码是由浅入深,不断叠加修改,因此若文中有不合理或疏忽的地方请参考最终的源码。

源码地址:https://github.com/jklincn/qemu_edu_driver

QEMU 启动

本文使用的 QEMU 版本为 8.2.5,QEMU 的安装以及磁盘镜像的准备可以参考在 WSL 中使用 QEMU 搭建 PCIe 模拟环境

qemu-system-x86_64 -enable-kvm \
        -M q35 \
        -cpu SapphireRapids-v2 \
        -smp 8 \
        -m 16G \
        -hda ubuntu.qcow2 \
        -netdev user,id=net0,hostfwd=tcp::10022-:22 \
        -device e1000,netdev=net0 \
        -device edu

此处对网络设备的设置是为了可以使用 vscode 进行远程连接,方便开发。

进入系统后使用 lspci 应该可以看到 edu 设备,即 00:03.0 Unclassified device [00ff]: Device 1234:11e8 (rev 10)

$ lspci
00:00.0 Host bridge: Intel Corporation 82G33/G31/P35/P31 Express DRAM Controller
00:01.0 VGA compatible controller: Device 1234:1111 (rev 02)
00:02.0 Ethernet controller: Intel Corporation 82540EM Gigabit Ethernet Controller (rev 03)
00:03.0 Unclassified device [00ff]: Device 1234:11e8 (rev 10)
00:1f.0 ISA bridge: Intel Corporation 82801IB (ICH9) LPC Interface Controller (rev 02)
00:1f.2 SATA controller: Intel Corporation 82801IR/IO/IH (ICH9R/DO/DH) 6 port SATA Controller [AHCI mode] (rev 02)
00:1f.3 SMBus: Intel Corporation 82801I (ICH9 Family) SMBus Controller (rev 02)

编写 PCI 驱动程序

这一部分可以先阅读 Linux 官方文档:1. How To Write Linux PCI Drivers — The Linux Kernel documentation

简单驱动模板

先搭一个大体的框架,这和 PCI 无关,这是为了测试当前的环境配置,该文件命名为 qemu_edu_driver.c

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#include <linux/module.h>

#define DRIVER_NAME "qemu_edu"

static int edu_init() {
    printk(KERN_INFO "[%s] Init sucessful. \n", DRIVER_NAME);
    return 0;
}

static void edu_exit() {}

module_init(edu_init);
module_exit(edu_exit);

MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("QEMU EDU Device Driver");

再创建一个 Makefile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
obj-m	:= qemu_edu_driver.o
KERNELDIR ?= /lib/modules/$(shell uname -r)/build
CFLAGS=-Wall

modules:
	make -C $(KERNELDIR) M=$(PWD) modules

clean:
	make -C $(KERNELDIR) M=$(PWD) clean

.PHONY: modules clean

安装编译工具与内核头文件

sudo apt install build-essential linux-headers-$(uname -r)

进行编译和加载

make
sudo insmod qemu_edu_driver.ko
sudo dmesg | grep qemu_edu

如果一切正常,可以看到有 [qemu_edu] Init sucessful. 这样的输出

注册驱动程序

这里主要涉及到 pci_register_driver() 函数,其接口是

1
2
3
4
5
6
7
/* Proper probing supporting hot-pluggable devices */
int __must_check __pci_register_driver(struct pci_driver *, struct module *,
				       const char *mod_name);

/* pci_register_driver() must be a macro so KBUILD_MODNAME can be expanded */
#define pci_register_driver(driver)		\
	__pci_register_driver(driver, THIS_MODULE, KBUILD_MODNAME)

因此我们只需要准备好 pci_driver 结构体

1
2
3
4
5
6
static struct pci_driver pci_driver = {
    .name = DRIVER_NAME,
    .id_table = pci_ids,
    .probe = edu_probe,
    .remove = edu_remove,
};

其中还涉及到 pci_device_id 结构体,我们把 EDU 设备的信息填充进去,并使用 MODULE_DEVICE_TABLE 进行导出。

1
2
3
4
static struct pci_device_id pci_ids[] = {{PCI_DEVICE(0x1234, 0x11e8)},
                                         {
                                             0,
                                         }};

这里对于 probe 和 remove 的处理我们先定义两个空函数,其声明可见 pci_driver 结构体。

1
2
3
4
5
static int edu_probe(struct pci_dev *pdev, const struct pci_device_id *id) {
    return 0;
}

static void edu_remove(struct pci_dev *pdev) {}

从 __pci_register_driver() 的描述中可以看到其返回值定义:如果有错误发生则返回负数的错误码,否则返回 0,因此做一个判断检查函数是否执行成功。

目前完整的代码如下

 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
#include <linux/module.h>
#include <linux/pci.h>

#define DRIVER_NAME "qemu_edu"

static struct pci_device_id pci_ids[] = {{PCI_DEVICE(0x1234, 0x11e8)},
                                         {
                                             0,
                                         }};
MODULE_DEVICE_TABLE(pci, pci_ids);

static int edu_probe(struct pci_dev *pdev, const struct pci_device_id *id) {
    return 0;
}

static void edu_remove(struct pci_dev *pdev) {}

static struct pci_driver pci_driver = {
    .name = DRIVER_NAME,
    .id_table = pci_ids,
    .probe = edu_probe,
    .remove = edu_remove,
};

static int __init edu_init(void) {
    int ret;
    if ((ret = pci_register_driver(&pci_driver)) < 0) {
        printk(KERN_INFO "[%s] Init failed. \n", DRIVER_NAME);
        return ret;
    }

    printk(KERN_INFO "[%s] Init sucessful. \n", DRIVER_NAME);
    return ret;
}

static void __exit edu_exit(void) {
    pci_unregister_driver(&pci_driver);
    printk(KERN_INFO "[%s] exit sucessful. \n", DRIVER_NAME);
}

module_init(edu_init);
module_exit(edu_exit);

MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("QEMU EDU Device Driver");

实现 probe 和 remove 函数

根据文档,probe 函数主要做的事情包括:

  1. 启用设备
  2. 请求 MMIO/IOP 资源
  3. 设置 DMA 掩码大小(包括一致性 DMA 和流式 DMA)
  4. 分配和初始化共享控制数据(pci_allocate_coherent())
  5. 访问设备配置空间(如果需要)
  6. 注册 IRQ 处理程序(request_irq()
  7. 初始化非 PCI(即芯片的 LAN/SCSI/ 等部分)
  8. 启用 DMA/处理 引擎

我们根据这个顺序来依次实现它:

 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
void __iomem *mmio_base;
static int edu_probe(struct pci_dev *pdev, const struct pci_device_id *id) {
    int ret;

    // Enable the PCI device
    if ((ret = pci_enable_device(pdev)) < 0) {
        printk(KERN_ERR "[%s] pci_enable_device failed. \n", DRIVER_NAME);
        return ret;
    }

    // Request MMIO/IOP resources
    if ((ret = pci_request_region(pdev, BAR, DRIVER_NAME)) < 0) {
        printk(KERN_ERR "[%s] pci_request_region failed. \n", DRIVER_NAME);
        goto disable_device;
    }

    // Set the DMA mask size
    // EDU device supports only 28 bits by default
    if ((ret = dma_set_mask_and_coherent(&(pdev->dev), DMA_BIT_MASK(28)) < 0)) {
        dev_warn(&pdev->dev, "[%s] No suitable DMA available\n", DRIVER_NAME);
        goto release_regions;
    }

    // Map the BAR register
    mmio_base = pci_iomap(pdev, BAR, pci_resource_len(pdev, BAR));
    if (!mmio_base) {
        printk(KERN_ERR "[%s] Cannot iomap BAR\n", DRIVER_NAME);
        ret = -ENOMEM;
        goto release_regions;
    }

    // Allow device to initiate DMA operations
    pci_set_master(pdev);

    // Register IRQ handler
    if ((ret = request_irq(pdev->irq, edu_irq_handler, IRQF_SHARED, DRIVER_NAME,
                           pdev) < 0)) {
        printk(KERN_ERR "[%s] Failed to request IRQ %d\n", DRIVER_NAME,
               pdev->irq);
        goto unmap_bar;
    }

    printk(KERN_INFO "[%s] probe sucessfully.\n", DRIVER_NAME);
    return 0;

unmap_bar:
    pci_iounmap(pdev, mmio_base);
release_regions:
    pci_release_regions(pdev);
disable_device:
    pci_disable_device(pdev);

    return ret;
}

这里的中断处理函数目前为空:

1
2
3
4
static irqreturn_t edu_irq_handler(int irq, void *dev_id) {
    printk(KERN_INFO "[%s] Interrupt handled, IRQ: %d\n", DRIVER_NAME, irq);
    return IRQ_HANDLED;
}

edu_probe 函数现在完成了设备启用、请求 MMIO/IOP 资源、设置 DMA 掩码大小和注册 IRQ 处理程序的功能,并且加入了完善的错误处理。

相应地,edu_remove 需要以相反的顺序完成这些操作:

1
2
3
4
5
6
7
static void edu_remove(struct pci_dev *pdev) {
    free_irq(pdev->irq, pdev);
    pci_iounmap(pdev, mmio_base);
    pci_release_region(pdev, BAR);
    pci_disable_device(pdev);
    printk(KERN_INFO "[%s] removed.\n", DRIVER_NAME);
}

用户空间交互

我们已经完成了 PCI 设备基本的驱动实现,接下来就是想办法使用这个 EDU 设备,根据文档可以知道这个设备可以进行阶乘计算,因此我们需要提供一个能够与用户空间交互的手段,即注册一个字符设备。这部分可以参考 Linux Device Drivers, Third Edition: Chapter 3: Char Drivers

注册字符设备

要做的工作有:分配设备主编号,创建类,创建设备节点,把它们加入到 probe 函数中,同时在错误处理和 remove 中也要添加相应的注销/销毁函数,这里不再展示,可以看源码。

 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
	···
	// Allocate the device major number
    major = register_chrdev(0, DRIVER_NAME, &fops);
    if (major < 0) {
        printk(KERN_ERR "[%s] Failed to register char device\n", DRIVER_NAME);
        ret = major;
        goto disable_device;
    }

    // Create Class
    edu_class = class_create(THIS_MODULE, DRIVER_NAME);
    if (IS_ERR(edu_class)) {
        printk(KERN_ERR "[%s] Failed to create class\n", DRIVER_NAME);
        ret = PTR_ERR(edu_class);
        goto unregister_chrdev;
    }

    // Create device node: /dev/edu
    dev_num = MKDEV(major, MINOR_NUMBER);
    if (device_create(edu_class, NULL, dev_num, NULL, "edu") == NULL) {
        printk(KERN_ERR "[%s] Failed to create device node\n", DRIVER_NAME);
        ret = -EINVAL;
        goto destroy_class;
    }
    ···

实现文件操作

上述代码引用了 fops 变量,这是一个文件操作函数集。结合 EDU 设备规范,我们目前实现 2 个函数,分别是 read(读取设备寄存器)、write(写入设备寄存器)。

1
2
static struct file_operations fops = {
    .owner = THIS_MODULE, .read = edu_read, .write = edu_write};

read 和 write 都根据规范先对偏移量做了有效性判断和对写入大小的判断,然后在 read 中使用 io_read32 和 copy_to_user,在 write 中使用 copy_from_user 和 io_write32 来完成实际的读取和写入操作。(使用 ioread64 和 iowrite64 会报错,可能是内核配置问题,因此使用两个 32 位操作做替代)

 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
static ssize_t edu_read(struct file *filp, char __user *buf, size_t count,
                        loff_t *ppos) {
    uint32_t value32;
    uint64_t value64;

    // Check if the offset is valid
    if (*ppos != 0x00 && *ppos != 0x04 && *ppos != 0x08 && *ppos != 0x20 &&
        *ppos != 0x24 && *ppos != 0x80 && *ppos != 0x88 && *ppos != 0x90 &&
        *ppos != 0x98) {
        return -EINVAL;
    }

    // Check read size
    if (*ppos < 0x80) {
        if (count != 4) {
            return -EINVAL;
        }
    } else {
        if (count != 4 && count != 8) {
            return -EINVAL;
        }
    }

    if (count == 4) {
        value32 = ioread32(mmio_base + *ppos);
        if (copy_to_user(buf, &value32, sizeof(value32))) {
            return -EFAULT;
        }
        *ppos += sizeof(value32);
    } else {
        uint32_t low = ioread32(mmio_base + *ppos);
        uint32_t high = ioread32(mmio_base + *ppos + 4);
        value64 = ((uint64_t)high << 32) | low;
        if (copy_to_user(buf, &value64, sizeof(value64))) {
            return -EFAULT;
        }
        *ppos += sizeof(value64);
    }

    return count;
}

static ssize_t edu_write(struct file *filp, const char __user *buf,
                         size_t count, loff_t *ppos) {
    uint32_t value32;
    uint64_t value64;

    // Check if the offset is valid
    if (*ppos != 0x04 && *ppos != 0x08 && *ppos != 0x20 && *ppos != 0x60 &&
        *ppos != 0x64 && *ppos != 0x80 && *ppos != 0x88 && *ppos != 0x90 &&
        *ppos != 0x98) {
        return -EINVAL;
    }

    // Check write size
    if (*ppos < 0x80) {
        if (count != 4) {
            return -EINVAL;
        }
    } else {
        if (count != 4 && count != 8) {
            return -EINVAL;
        }
    }

    if (count == 4) {
        if (copy_from_user(&value32, buf, sizeof(value32))) {
            return -EFAULT;
        }
        iowrite32(value32, mmio_base + *ppos);
        *ppos += sizeof(value32);
    } else {
        if (copy_from_user(&value64, buf, sizeof(value64))) {
            return -EFAULT;
        }
        iowrite32((uint32_t)value64, mmio_base + *ppos);
        iowrite32((uint32_t)(value64 >> 32), mmio_base + *ppos + 4);
        *ppos += sizeof(value64);
    }

    return count;
}

static struct file_operations fops = {
    .owner = THIS_MODULE, .read = edu_read, .write = edu_write};

用户代码测试

此时我们已经可以写一段用户空间代码来测试我们创建的 /dev/edu 接口是否可以正常工作了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <errno.h>
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <unistd.h>

#define EDU_DEVICE "/dev/edu"

int main() {
    int fd;
    uint32_t read_value, write_value;
    fd = open(EDU_DEVICE, O_RDWR);
    if (fd < 0) {
        perror("Failed to open the device");
        return errno;
    }
    write_value = 10;
    ssize_t bytes_write = pwrite(fd, &write_value, sizeof(write_value), 0x08);
    printf("Write Value: %d, Write Size: %zd\n", write_value, bytes_write);
    pread(fd, &read_value, sizeof(read_value), 0x08);
    printf("Result: %d\n", read_value);
    return 0;
}

这段代码先使用 open 系统调用打开了设备节点,然后使用 pwrite 往 0x08 偏移量(即规范上的 factorial computation)写入 10 进行 10 的阶乘计算,最后使用 pread 读取计算结果。

$ gcc user_test.c -o user_test
$ sudo ./user_test
Write Value: 10, Write Size: 4
Result: 3628800

这里需要注意的是,查阅 QEMU EDU 的实现源码可以知道计算结果是用 uint32_t 类型来存储,因此如果计算溢出(输入的数超过 12)就会进行截断取低位。

中断处理

在上面的用户代码中我们并没有检查计算是否已经完成,假设输入的是一个非常大的数,需要一些时间进行计算,那么我们在写入后立刻进行读取时可能就会读取到错误的数值。

就比如之前的程序,多运行几次的结果是有所不同的,其中输出 0xa 就表示计算还没有完成。

$ sudo ./user_test
4
0x375f00
$ sudo ./user_test
4
0xa
$ sudo ./user_test
4
0x375f00

QEMU EDU 设备规范中描述了其中断信息,我们可以利用中断来准确得到计算完成的时间。

共享中断

之前我们使用了 IRQF_SHARED 来注册了中断处理程序,即共享中断,因此我们需要考虑中断是否是由当前设备发起。在两个参考项目中,使用 request_irq 注册中断时分别传递了主设备号、自定义结构体参数用来后续的判断,这里我们为了规范,使用自定义结构体(即设备特定的结构体)来处理。

因此我们需要定义一个 edu_device 结构体,并且可以把之前定义的全局变量都放进去。

1
2
3
4
5
6
7
8
struct edu_device {
    dev_t dev_num;
    struct pci_dev *pdev;
    void __iomem *mmio_base;
    bool complete;
    struct cdev cdev;
    wait_queue_head_t wait_queue;
};

这样要同步修改 edu_probe() 函数,并且在其中使用 pci_set_drvdata() 函数将我们自己的 edu_device 绑定到 pci_dev 中,方便其他的接口从 pci_dev 中取得 edu_device 结构体。

1
2
3
4
5
6
7
8
	···
	// Allocate an edu_device structure
    edu_dev = kzalloc(sizeof(*edu_dev), GFP_KERNEL);
    if (!edu_dev) return -ENOMEM;
    edu_dev->pdev = pdev;
	···
    pci_set_drvdata(pdev, edu_dev);
	···

pci_set_drvdata() 函数的实质是把传入的指针绑定到其关联的 device 结构体的 driver_data 字段。后续就可以使用 pci_get_drvdata() 来进行获取。edu_remove() 函数也要做相应的处理。两个修改后的函数具体可见源码。

注意到结构体中还加入了一个 cdev 结构体,这是为了在 edu_read() 和 edu_write() 两个函数中能获得 mmio_base 变量(它目前已不是全局变量)。具体的寻找方法是:

  1. 在 edu_probe() 过程中初始化 cdev 结构体(而不是原先简单地使用 register_chrdev() 函数);
  2. 实现 edu_open(),在其中通过 container_of 宏基于 cdev 结构体来找到 edu_device 结构体,并把它绑定到 file 结构体的 private_data 中。

由此,在 edu_read() 和 edu_write() 中我们就可以通过 filp->private_data 来找到 edu_device。

1
2
3
4
5
	···
    struct edu_device *edu_dev;
    edu_dev = filp->private_data;
    value32 = ioread32(edu_dev->mmio_base + *ppos);
	···

最后,我们可以通过设备规范中的中断状态寄存器(interrupt status register)来确认中断是否由当前设备发起。

1
2
3
4
5
6
7
8
	···
	// Check:
    // 1. dev_id is not null;
    // 2. Interrupt status register is not zero.
    if (!edu_dev || (status = ioread32(edu_dev->mmio_base + 0x24)) == 0) {
        return IRQ_NONE;
    }
	···

等待队列

当用户空间程序使用 pread() 来读取阶乘计算结果时,我们不妨把这个操作设计成同步读取,即计算没有完成之前,该调用不返回,以此来确保读取到的结果一定是计算已经完成的结果。

最简单的思路就是对中断状态寄存器进行轮询,当最低一位的比特消失时就表示计算已经完成,但这会导致 CPU 时间的浪费,等待期间 CPU 做不了其他的事情。而中断加等待队列的方式就可以解决这个问题。这部分可以参考 Linux Device Drivers, Third Edition: Chapter 6: Advanced Char Driver Operations 中的 Blocking I/O 一节。

我们先在 edu_probe() 中对等待队列和等待条件进行初始化:

1
2
3
4
    ···
	init_waitqueue_head(&edu_dev->wait_queue);
    edu_dev->complete = true;
	···

然后在 edu_write() 中修改等待条件:

1
2
3
4
5
6
	···
	// Factorial computation
    if (*ppos == 0x08) {
        edu_dev->complete = false;
    }
	···

在 edu_read() 中进行等待,使用 wait_event_interruptible() 是为了能够处理一些信号:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
	···
	// Get factorial computation result
    if (*ppos == 0x08) {
        int ret;
        ret = wait_event_interruptible(edu_dev->wait_queue, edu_dev->complete);
        // Deal with signal interruptions
        if (ret == -ERESTARTSYS) {
            return ret;
        }
    }
	···

这样,当用户写入数据时,等待条件 edu_dev->complete 会变成 false,设备开始计算阶乘;当用户读取计算结果时,如果计算还没有结束,则会进行等待。

最后,我们在 edu_irq_handler() 设置等待条件并唤醒进程:

1
2
3
4
5
	···
    // Wake up so that edu_read() can return correct value.
    edu_dev->complete = true;
    wake_up_interruptible(&edu_dev->wait_queue);
	···

硬件设置

这部分取决于具体的硬件规范,主要是启用阶乘计算中断和消除中断。

根据规范,在 edu_probe() 函数中往 0x20 偏移量写入 0x80 启用硬件计算完成后触发中断的功能。

1
2
3
4
5
	···
	// Device initialize
    // Raise interrupt after finishing factorial computation
    iowrite32(0x80, edu_dev->mmio_base + 0x20);
	···

同时在 edu_irq_handler() 中接收到中断时,检查并获取中断状态寄存器的值,最后向中断确认寄存器(interrupt acknowledge register)写入相同的值来让设备停止产生中断:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    ···
    if (!edu_dev || (status = ioread32(edu_dev->mmio_base + 0x24)) == 0) {
        return IRQ_NONE;
    }
    ···
    // Acknowledge Interrupt
    iowrite32(status, edu_dev->mmio_base + 0x64);

    return IRQ_HANDLED;
}

准备好这一切后就可以开始测试,通过系统日志的输出时间我们可以看到执行流程和我们预期的一致:开始计算——读取等待——计算完毕发起中断——中断处理——读取返回。(这些输出代码在最终源码中已删去,读者可自行添加)

[38231.735887] [qemu_edu] device starts calculating.
[38231.735965] [qemu_edu] edu_read() start to wait.
[38231.736000] [qemu_edu] Receive interrupt, status: 1
[38231.736032] [qemu_edu] edu_read() wake up!

使用 DMA 传输数据

继续阅读设备规范,EDU 设备还支持 DMA 操作,0x80、0x88、0x90 这几个偏移量对应的寄存器分别定义了 DMA 传输中的参数(注意这些寄存器是 64 位大小),0x98 是 DMA 控制寄存器,控制 DMA 传输开始、传输方向和是否在传输结束后发起中断(值为 0x100)。在规范的 DMA controller 中还提到 EDU 设备在 0x40000 偏移量处有一个 4096 字节的缓冲区。

设置传输参数

了解规范后我们就可以开始设计要怎么处理 DMA 操作,首先要清楚的是 DMA 地址的一个设置于变化。当用户空间程序想要发起一次从内存到设备的 DMA 传输时,它提供源地址和传输大小,目标地址是 0x40000。但要注意这里的地址是用户空间的地址,因此内核在设置设备寄存器时不能直接使用(即 DMA 传输操作理应是内核空间和设备进行传输,尽管想要传输的数据来自用户空间)。所以我们需要使用 copy_from_user() 函数把来自用户的数据拷贝到内核空间中,并把 DMA 的源地址设置成内核空间的地址。

我们在 edu_write() 中做一个中间转换层,对于 DMA 相关参数的设置,先把来自用户的参数保存到 edu_device 中,在用户开始 DMA 传输(往 DMA 控制寄存器写入数据)时,再进行数据从用户空间到内核空间的拷贝。

这里做“延迟拷贝”会有一个隐含的问题就是用户空间的数据不能被释放,如果是一个函数的局部变量,在函数退出时变量被释放,在 edu_device 中保存的指针也就变成了野指针。

在 edu_device 结构体中加入存储的变量,这里的 dma_buffer 和 dma_addr 用于后续中断处理函数中进行缓冲区的释放。

1
2
3
4
5
6
7
8
9
struct edu_device {
    ···
    uint64_t dma_src_address;
    uint64_t dma_dst_address;
    uint64_t dma_count;
    void *dma_buffer;
    uint32_t dma_direction;
    dma_addr_t dma_addr;
};

注意这里对 edu_write() 进行了重写,先根据 count 来进行判断并读取用户传来的数据,再根据写入的偏移量做实际的处理。这里限定用户只能对下面指定的 5 个偏移量(寄存器)进行写入操作。

 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
    ···
	switch (*ppos) {
        case EDU_FACT_CALC:
            ···
        case EDU_DMA_SRC_ADDR:
            edu_dev->dma_src_address = count == 4 ? value32 : value64;
            break;
        case EDU_DMA_DST_ADDR: {
            edu_dev->dma_dst_address = count == 4 ? value32 : value64;
            break;
        }
        case EDU_DMA_COUNT:
            edu_dev->dma_count = count == 4 ? value32 : value64;
            break;
        case EDU_DMA_CMD: {
            uint64_t cmd = count == 4 ? value32 : value64;
            void *buffer_addr;
            dma_addr_t dma_addr;
            struct device *dev = &edu_dev->pdev->dev;

            // Set transfer count
            size_t size = edu_dev->dma_count;
            SET_DMA(size, EDU_DMA_COUNT);

            // Prepare DMA buffer
            buffer_addr = dma_alloc_coherent(dev, size, &dma_addr, GFP_KERNEL);
            if (!buffer_addr) {
                printk(KERN_ERR
                       "[%s] DMA_CMD: Failed to allocate memory for dma\n",
                       DRIVER_NAME);
                return -ENOMEM;
            }
            if (cmd & DMA_EDU2RAM) {
                // EDU to RAM
                // Check transfer count
                if (edu_dev->dma_src_address + size >=
                    EDU_BUFFER_ADDRESS + BUFFER_SIZE) {
                    printk(KERN_ERR "[%s] DMA_CMD: Memory out of bounds\n",
                           DRIVER_NAME);
                    dma_free_coherent(dev, size, buffer_addr, dma_addr);
                    return -EFAULT;
                }

                SET_DMA(edu_dev->dma_src_address, EDU_DMA_SRC_ADDR);
                SET_DMA(dma_addr, EDU_DMA_DST_ADDR);
                printk(KERN_INFO
                       "[%s] Start DMA: Direction: EDU to RAM, Source Address: "
                       "0x%llx\n, Destination Address: 0x%llx, count:%ld",
                       DRIVER_NAME, edu_dev->dma_src_address, dma_addr, size);
            } else {
                // RAM to EDU
                // Check transfer count
                if (edu_dev->dma_dst_address + size >=
                    EDU_BUFFER_ADDRESS + BUFFER_SIZE) {
                    printk(KERN_ERR "[%s] DMA_CMD: Memory out of bounds\n",
                           DRIVER_NAME);
                    dma_free_coherent(dev, size, buffer_addr, dma_addr);
                    return -EFAULT;
                }

                // Copy user data to buffer
                if (copy_from_user(buffer_addr,
                                   (const void *)edu_dev->dma_src_address,
                                   sizeof(edu_dev->dma_count))) {
                    printk(KERN_ERR "[%s] DMA_CMD: Failed to copy_from_user\n",
                           DRIVER_NAME);
                    dma_free_coherent(dev, size, buffer_addr, dma_addr);
                    return -EFAULT;
                }

                SET_DMA(dma_addr, EDU_DMA_SRC_ADDR);
                SET_DMA(edu_dev->dma_dst_address, EDU_DMA_DST_ADDR);
                printk(KERN_INFO
                       "[%s] Start DMA: Direction: RAM to EDU, Source Address: "
                       "0x%llx\n, Destination Address: 0x%llx, count:%ld",
                       DRIVER_NAME, dma_addr, edu_dev->dma_dst_address, size);
            }

            // Start DMA
            SET_DMA(value64 | DMA_START | DMA_IRQ, EDU_DMA_CMD);

            dma_free_coherent(dev, edu_dev->dma_count, buffer_addr, dma_addr);

            break;
        }
        default:
            return -EINVAL;
    }
    ···

对于 EDU_DMA_SRC_ADDR、EDU_DMA_DST_ADDR、EDU_DMA_COUNT 这三个寄存器的写入,我们只是把写入的数据做了保存。当用户往 EDU_DMA_CMD 寄存器写入时,才开始做处理:

  1. 根据传入的数据判断传输方向(内存到设备还是设备到内存)
  2. 从 edu_dev->dma_count 中读取传输的大小,并写入设备的 DMA 传输大小寄存器。
  3. 根据传输大小分配 DMA 缓冲区,其中 buffer_addr 是内核空间的虚拟地址,dma_addr 是 DMA 控制器使用的物理地址。
  4. 检查传输地址和传输大小的合法性,主要是不能超出 EDU 设备所规定的区域(数据区域必须在 0x40000 和 0x40000+4096 之间)
  5. 如果是内存往设备传输,则需要将用户空间的数据拷贝至缓冲区中。
  6. 写入设备的 DMA 地址寄存器,设置 DMA 传输的源和目的地址。
  7. 写入设备的 DMA 控制寄存器,开始 DMA 传输。这里我们自动填充 DMA_START 和 DMA_IRQ 比特位,即用户空间程序只需要设置传输方向。
  8. 如果是设备往内存传输,则 DMA 传输完成后把内核缓冲区的数据拷贝到用户空间的工作由中断处理函数来做。

实际上 DMA 传输的相关参数也可以通过 ioctl() 接口来设置,但这里为了简洁,统一归到了 write() 中

信号机制

虽然从示例代码上来看,DMA 传输结束可以根据 DMA 控制寄存器的最低 1 位来轮询判断,但这也会造成 CPU 浪费。因此我们也需要考虑当 DMA 传输完成时,怎么通知到用户空间程序。

由于一般的 DMA 传输是异步的,因此不能和之前的阶乘计算一样使用等待队列阻塞 read() 来完成。这里的处理方法有 I/O 多路复用(select()/poll()/epoll())或者信号机制,由于 EDU 设备较简单,只需要知道单次 DMA 传输是否完成,没有高并发的场景,因此我们选择使用信号机制。

信号机制可以先参考这篇文章:Linux 驱动实践:驱动程序如何发送【信号】给应用程序?

首先,我们要知道信号发送给哪一个进程,我们通过在用户空间程序执行 open 时进行记录来完成。(这并不是一个通用做法,尤其是当设备可能会被多个进程打开时,这会导致错误。目前我们假设在同一时间,只有一个进程打开设备)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
static int edu_open(struct inode *inode, struct file *filp) {
    struct edu_device *edu_dev;

    edu_dev = container_of(inode->i_cdev, struct edu_device, cdev);

    // Store current process id
    edu_dev->user_pid = get_pid(task_pid(current));

    // Store edu_device point in filp->private_data
    filp->private_data = edu_dev;

    return 0;
}

然后我们就可以在中断处理函数中进行信号的发送:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
	···
    struct task_struct *task;
    struct kernel_siginfo info;

    memset(&info, 0, sizeof(struct kernel_siginfo));
    info.si_signo = SIGUSR1;
    info.si_code = SI_QUEUE;
    info.si_int = DMA_RAM2EDU;
	···
	task = get_pid_task(edu_dev->user_pid, PIDTYPE_PID);
    if (task) {
        if (send_sig_info(SIGUSR1, &info, task) < 0) {
            printk(KERN_ERR
                   "[%s] DMA IRQ: Failed to send signal to pid %d\n",
                   DRIVER_NAME, task_pid_nr(task));
        }
    	put_task_struct(task);
	}
	···

这里使用 kernel_siginfo 结构体,其主要是可以附带一个额外的数据,我们用它来指示传输的方向,方便测试代码获取使用;使用 get_pid_task() 获取指定 pid 的 task 结构体;使用 send_sig_info() 向该进程发送一个附带 info 的 SIGUSER1 信号。最后使用 put_task_struct() 平衡引用计数,避免内存泄漏。

中断上下文的坑

在上面的设计中,我们留了两个工作交给中断处理函数:

  1. 当 DMA 传输方向是由设备往内存时,需要把数据从内核空间拷贝至用户空间。
  2. 释放由 dma_alloc_coherent() 函数分配的 DMA 缓冲区。

但实际操作下来发现,这两件事情都不能简单地在中断处理函数中处理。

在中断处理函数中使用 copy_to_user() 时会返回错误,使用 dma_free_coherent() 时系统日志会提示警告:

WARNING: CPU: 0 PID: 0 at kernel/dma/mapping.c:528 dma_free_attrs+0x42/0x60

这都是因为错误的上下文(即中断上下文和普通的进程上下文不同,在中断上下文中,用户空间是不可控的)(PS:这句话有错误可以联系纠正)

因此我们需要其他方法来在中断处理函数中完成以上两个工作。

我们使用工作队列(workqueue)来处理缓冲区释放的问题,在 edu_device 结构体中添加工作队列:

1
2
3
4
struct edu_device {
	···
    struct work_struct dma_work;
};

并在 edu_probe() 中进行初始化

1
2
3
	···
	INIT_WORK(&edu_dev->dma_work, dma_work_fn);
	···

free_dma_work_fn() 函数以及它在中断处理函数中的延迟调用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
void free_dma_work_fn(struct work_struct *work) {
    struct edu_device *edu_dev =
        container_of(work, struct edu_device, free_dma_work);

    dma_free_coherent(&edu_dev->pdev->dev, edu_dev->dma_count,
                      edu_dev->dma_buffer, edu_dev->dma_addr);
}

static irqreturn_t edu_irq_handler(int irq, void *dev_id) {
	···
    // Check if DMA interrupt
    if (edu_dev->user_pid && status == DMA_IRQ_VALUE) {
        schedule_work(&edu_dev->dma_work);
    }
	···
}

然而实际测试下来 copy_to_user() 也无法在工作队列中正常执行(相似问题),因此只能选择其他的方法。为了避免引入其他机制让驱动程序更复杂,我们选择让用户空间程序收到信号通知后手动取得数据,即在 edu_read() 中约定一个偏移量。(同上,也可以通过 ioctl() 接口来获得)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#define EDU_DMA_GET 0x1234
static ssize_t edu_read(struct file *filp, char __user *buf, size_t count,
                        loff_t *ppos) {
	···
    case EDU_DMA_GET: {
        // Get data back, ignore 'buf' and 'count' parameters
        if (copy_to_user((void __user *)edu_dev->dma_dst_address,
                         edu_dev->dma_buffer, edu_dev->dma_count)) {
            printk(KERN_ERR "[%s] DMA GET: Failed to copy_to_user\n",
                   DRIVER_NAME);
            return -EFAULT;
        }
        break;
    }
    ···
}

(注意,修改后的 edu_read() 只接受 EDU_FACT_CALC 和 EDU_DMA_GET 两个偏移量的读取,读者有需求可以自行添加)

测试代码

具体代码可见源码 user_test.c

测试流程:

  1. 首先是对阶乘计算寄存器(factorial computation)写入和读取来验证阶乘计算功能的正确性(包括阻塞读取)。
  2. 然后是测试 DMA 从内存往设备传输的功能,把 write_buffer 的内容传输到设备中。
  3. 再测试 DMA 从设备往主存传输的功能,把设备中的内存传输到 read_buffer 这片空间中。
  4. 最后对比 write_buffer 和 read_buffer 的内容是否一致来检测 DMA 传输的正确性。

进一步封装

这部分和设备驱动程序已经无关了,是个人的一些想法。由于我们目前的用户空间程序需要手动进行 open 和 write 等系统调用,还是属于比较底层的编程,那有没有办法对用户使用设备更友好呢?参考了 NVIDIA Driver 和 CUDA Toolkit 的关系,我们可以自己再做一个类似 EDU Toolkit,提供头文件和动态链接库,进一步包装 EDU 设备的底层操作。这部分代码见 edu_lib 文件夹。

首先创建一个头文件,声明想要提供的函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#include <fcntl.h>
#include <stdint.h>
#include <unistd.h>

#define EDU_DEVICE "/dev/edu"

#define EDU_FACT_CALC 0x08

int edu_init(void);

uint32_t edu_fact(uint32_t x);

然后在 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
#include "edu.h"

int fd;

int edu_init(void) {
    fd = open(EDU_DEVICE, O_RDWR);
    if (fd < 0) {
        return -1;
    }
    return 0;
}

uint32_t edu_fact(uint32_t x) {
    uint32_t read_value, write_value;
    write_value = x;
    if (pwrite(fd, &write_value, sizeof(write_value), EDU_FACT_CALC) ==
        sizeof(write_value)) {
    } else {
        close(fd);
        return -1;
    }

    // Get factorial result
    if (pread(fd, &read_value, sizeof(read_value), EDU_FACT_CALC) ==
        sizeof(read_value)) {
    } else {
        close(fd);
        return -1;
    }

    return read_value;
}

这样,用户代码就可以简单地写为

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#include <stdio.h>

#include "edu.h"

int main() {
    unsigned int x = 10, result = 0;
    if (edu_init() < 0) {
        printf("Failed to init edu device.\n");
        return -1;
    }
    if ((result = edu_fact(x)) < 0) {
        printf("Failed to factorial computation.\n");
        return -1;
    } else {
        printf("The factorial of %d is %d\n", x, result);
    }
}

进行测试

# 编译动态链接库
gcc -fPIC -shared -o libedu.so edu.c

# 编译用户程序
gcc test.c -L. -ledu -o test

# 运行用户程序
sudo LD_LIBRARY_PATH=. ./test

这里只是做一个最简单的示例,体现了更高级别的封装。


本站不记录浏览量,但如果您觉得本内容有帮助,请点个小红心,让我知道您的喜欢。