1. 从“Hello World”到真正的字符设备

上一篇文章里,我们成功在x86的宿主机上编译并加载了一个最简单的内核模块,看到了经典的“Hello World”打印。这就像我们学编程时写的第一个程序,它证明了我们的环境是通的,编译链是好的,模块加载卸载的基本流程是走顺的。但说实话,那个模块除了打印几行日志,啥也干不了。它不是一个“设备驱动”,因为它没有和任何硬件(哪怕是虚拟的硬件)打交道,也没有为用户程序提供一个可以操作的接口。

今天,我们要玩点真格的。我们的目标是在Qemu模拟的ARM开发板(比如Vexpress-A9)上,从零开始构建一个字符设备驱动。什么叫字符设备?你可以把它理解成一个一个字节流式访问的设备,比如键盘、鼠标、串口、普通的文件。我们这次要做的驱动,就是要在/dev目录下创建一个设备文件,比如/dev/my_char_dev。用户程序可以像读写普通文件一样,用open、read、write、close这些标准系统调用来操作它,而我们写的驱动代码,就在内核里响应这些操作,实现具体的逻辑。

这整个过程,就像是你在系统里“凭空”创造了一个虚拟硬件。用户层感觉是在操作一个文件,内核层则是我们写的驱动在忙碌。这个“从无到有”的过程,会涉及到几个核心的环节:首先,我们需要向内核注册我们的设备,告诉内核“嗨,我这有个新设备,主设备号是XXX,请给我在/dev下安个家”;然后,我们需要实现一整套文件操作函数集,也就是定义当用户进行读、写、打开、关闭等操作时,我们驱动该干什么;最后,我们还需要编写用户态的测试程序,来验证整个通路是否畅通。

听起来有点复杂?别怕,我们一步步拆解。我会把我在实际移植和调试过程中踩过的坑、总结的技巧都揉进去,保证你跟着做下来,不仅能跑通,还能真正理解背后的“为什么”。我们先从最基础的概念和准备工作开始。

2. 驱动核心:字符设备驱动的骨架

一个完整的字符设备驱动,其代码骨架比之前的“Hello World”要丰满得多。它不再仅仅是init和exit,而是围绕着一个核心数据结构struct cdev(字符设备结构体)和struct file_operations(文件操作集)展开的。我来打个比方:struct cdev就像是这个设备的“身份证”和“户口本”,里面记录了设备号、操作集指针等关键信息;而struct file_operations则是这个设备的“能力清单”或“服务菜单”,明确列出了这个设备支持哪些操作(读、写、控制等),以及每个操作对应哪个函数来处理。

2.1 关键数据结构与函数

我们先来看看几个最重要的API和结构体,理解了它们,代码写起来就顺了。

  1. 设备号(dev_t):这是内核中标识一个设备的唯一数字,一个32位的数,通常由**主设备号(major)和次设备号(minor)**组成。主设备号用来标识设备的大类(比如所有的SCSI磁盘驱动共享一个主设备号),次设备号用来区分同类设备中的具体个体。我们可以手动指定一个设备号,也可以让内核动态分配一个空闲的给我们。对于我们自己学习用的虚拟设备,让内核动态分配更省事,避免冲突。
  2. cdev结构体与操作:struct cdev需要我们用cdev_init()函数来初始化,这个函数会把我们的file_operations操作集绑定到这个cdev上。初始化后,还需要用cdev_add()函数把这个cdev“添加”到内核系统中,这样内核才知道有这个设备的存在。
  3. file_operations结构体:这是驱动的“灵魂”。我们需要定义这个结构体的一个实例,并把我们实现的函数地址填进去。最基本的几个成员是:
    • .owner = THIS_MODULE: 表明这个操作集属于哪个模块,通常就填这个宏。
    • .read = mydev_read: 当用户空间调用read()系统调用时,内核就会调用我们这里的mydev_read函数。
    • .write = mydev_write: 对应write()系统调用。
    • .open = mydev_open: 对应open()系统调用。
    • .release = mydev_release: 对应close()系统调用(注意不是.close)。
    • .unlocked_ioctl = mydev_ioctl: 对应ioctl()系统调用,用于实现一些自定义的控制命令。

2.2 驱动代码框架示例

光说理论有点干,我们直接看一个最简化的驱动框架代码my_char_dev.c。这个驱动会在内核中创建一个字符设备,并实现最基本的打开、关闭、读、写操作。为了简单,我们用一个全局的字符数组来模拟设备的“内存”,读操作就是从这个数组里取数据,写操作就是把数据存到这个数组里。

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>        // 包含 file_operations 和 设备号相关定义
#include <linux/cdev.h>      // 包含 cdev 结构体和操作函数
#include <linux/uaccess.h>   // 包含 copy_to_user / copy_from_user

#define DEVICE_NAME "my_char_dev"
#define BUFFER_SIZE 1024

static int major_num;                // 主设备号,动态分配
static struct cdev my_cdev;          // 字符设备结构体
static char device_buffer[BUFFER_SIZE]; // 模拟的设备内存
static int buffer_offset = 0;        // 当前缓冲区读写位置

// 当用户程序打开设备文件时调用
static int mydev_open(struct inode *inode, struct file *file)
{
    printk(KERN_INFO "my_char_dev: Device opened\n");
    return 0; // 返回0表示成功打开
}

// 当用户程序关闭设备文件时调用
static int mydev_release(struct inode *inode, struct file *file)
{
    printk(KERN_INFO "my_char_dev: Device closed\n");
    return 0;
}

// 当用户程序从设备读取数据时调用
static ssize_t mydev_read(struct file *file, char __user *user_buf, size_t count, loff_t *offset)
{
    int bytes_to_read;
    int ret;

    // 计算还能读多少字节(从buffer_offset开始到缓冲区末尾)
    bytes_to_read = BUFFER_SIZE - buffer_offset;
    if (bytes_to_read > count)
        bytes_to_read = count;
    if (bytes_to_read <= 0)
        return 0; // 没有数据可读了

    // 将内核空间的数据拷贝到用户空间。这是驱动中数据交换的关键步骤!
    ret = copy_to_user(user_buf, &device_buffer[buffer_offset], bytes_to_read);
    if (ret) {
        printk(KERN_ERR "my_char_dev: Failed to copy %d bytes to user\n", ret);
        return -EFAULT; // 返回错误码
    }

    printk(KERN_INFO "my_char_dev: Read %d bytes from device\n", bytes_to_read);
    buffer_offset += bytes_to_read; // 更新内部偏移量
    return bytes_to_read; // 返回实际读取的字节数
}

// 当用户程序向设备写入数据时调用
static ssize_t mydev_write(struct file *file, const char __user *user_buf, size_t count, loff_t *offset)
{
    int bytes_to_write;
    int ret;

    // 计算还能写多少字节(从buffer_offset开始到缓冲区末尾)
    bytes_to_write = BUFFER_SIZE - buffer_offset;
    if (bytes_to_write > count)
        bytes_to_write = count;
    if (bytes_to_write <= 0)
        return -ENOMEM; // 缓冲区满了

    // 将用户空间的数据拷贝到内核空间。
    ret = copy_from_user(&device_buffer[buffer_offset], user_buf, bytes_to_write);
    if (ret) {
        printk(KERN_ERR "my_char_dev: Failed to copy %d bytes from user\n", ret);
        return -EFAULT;
    }

    printk(KERN_INFO "my_char_dev: Write %d bytes to device\n", bytes_to_write);
    buffer_offset += bytes_to_write;
    return bytes_to_write; // 返回实际写入的字节数
}

// 定义文件操作集
static struct file_operations mydev_fops = {
    .owner = THIS_MODULE,
    .open = mydev_open,
    .release = mydev_release,
    .read = mydev_read,
    .write = mydev_write,
};

// 模块初始化函数
static int __init mydev_init(void)
{
    dev_t dev_num;
    int ret;

    // 1. 动态申请一个主设备号(次设备号从0开始)
    ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
    if (ret < 0) {
        printk(KERN_ERR "my_char_dev: Failed to allocate char device region\n");
        return ret;
    }
    major_num = MAJOR(dev_num); // 提取出主设备号保存起来
    printk(KERN_INFO "my_char_dev: Allocated major number %d\n", major_num);

    // 2. 初始化cdev结构体,并绑定文件操作集
    cdev_init(&my_cdev, &mydev_fops);
    my_cdev.owner = THIS_MODULE;

    // 3. 将cdev添加到内核系统
    ret = cdev_add(&my_cdev, dev_num, 1);
    if (ret < 0) {
        printk(KERN_ERR "my_char_dev: Failed to add cdev\n");
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }

    printk(KERN_INFO "my_char_dev: Character device driver loaded successfully!\n");
    printk(KERN_INFO "my_char_dev: Create device node with: 'mknod /dev/%s c %d 0'\n", DEVICE_NAME, major_num);
    return 0;
}

// 模块退出函数
static void __exit mydev_exit(void)
{
    dev_t dev_num = MKDEV(major_num, 0); // 根据主设备号和次设备号0生成完整的设备号

    // 1. 从系统删除cdev
    cdev_del(&my_cdev);
    // 2. 释放设备号
    unregister_chrdev_region(dev_num, 1);

    printk(KERN_INFO "my_char_dev: Character device driver unloaded\n");
}

module_init(mydev_init);
module_exit(mydev_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple character device driver for Qemu ARM");

这段代码已经是一个功能完整的字符设备驱动雏形了。在mydev_init中,我们完成了“上户口”的三部曲:申请设备号、初始化并绑定操作集、添加到系统。在mydev_read/write中,我们使用了copy_to_user和copy_from_user这两个关键函数来完成内核空间和用户空间的安全数据拷贝,这是驱动编程中必须严格遵守的规则,绝对不能直接用指针赋值,否则会导致系统崩溃或安全漏洞。

3. 为ARM交叉编译驱动模块

代码写好了,但这是给x86架构的Linux内核写的。我们的战场在Qemu模拟的ARM开发板上,所以必须进行交叉编译。这意味着我们需要用ARM架构的编译器(比如arm-linux-gnueabi-gcc)和ARM版本的内核头文件/源码来编译这个.c文件,生成一个ARM平台的内核模块文件(.ko)。

3.1 准备交叉编译环境

假设你已经按照上一篇文章搭建好了Qemu ARM环境,并且已经编译好了ARM Linux内核(生成了zImage和dtb文件),同时也准备好了根文件系统。那么交叉编译工具链和内核源码路径应该是现成的。

我们重点来看Makefile的编写。一个能同时支持宿主机(x86)和开发板(ARM)交叉编译的Makefile非常实用。下面是我在项目中使用的Makefile,它比上一篇文章里的更清晰一些:

# 驱动源码目录
PWD := $(shell pwd)

# 默认目标
all: modules

# 判断是否为ARM架构编译
ifeq ($(ARCH),arm)
    # ARM交叉编译配置
    CROSS_COMPILE ?= arm-linux-gnueabi-
    KERNEL_DIR    ?= /home/your_workspace/linux-5.10.186 # 你的ARM内核源码绝对路径
    # 如果你编译内核时指定了输出目录(O=...),这里也需要指定
    KERNEL_OUT    ?= /home/your_workspace/objects/vexpress-v2p-ca9
else
    # 本地(x86)编译配置
    CROSS_COMPILE ?=
    KERNEL_DIR    ?= /lib/modules/$(shell uname -r)/build
    KERNEL_OUT    ?=
endif

# 模块编译目标
modules:
	$(MAKE) ARCH=arm CROSS_COMPILE=$(CROSS_COMPILE) -C $(KERNEL_DIR) M=$(PWD) O=$(KERNEL_OUT) modules

# 清理目标
clean:
	$(MAKE) ARCH=arm CROSS_COMPILE=$(CROSS_COMPILE) -C $(KERNEL_DIR) M=$(PWD) O=$(KERNEL_OUT) clean
	rm -f *.order *.symvers

# 安装模块到根文件系统(用于ARM)
install:
	$(MAKE) ARCH=arm CROSS_COMPILE=$(CROSS_COMPILE) -C $(KERNEL_DIR) M=$(PWD) O=$(KERNEL_OUT) INSTALL_MOD_PATH=/home/your_workspace/rootfs modules_install

# 告诉内核构建系统要编译哪个模块
obj-m += my_char_dev.o

关键点解读:

  • ARCH=arm: 明确告诉内核构建系统,目标架构是ARM。
  • CROSS_COMPILE: 指定交叉编译工具链的前缀。arm-linux-gnueabi-意味着会使用arm-linux-gnueabi-gcc, arm-linux-gnueabi-ld等工具。
  • -C $(KERNEL_DIR): 改变目录到内核源码根目录,使用那里的顶层Makefile。
  • M=$(PWD): 告诉内核构建系统,模块的源码位于当前目录。
  • O=$(KERNEL_OUT): 如果内核编译时使用了单独的输出目录,这里必须指定相同的路径,以确保找到正确的配置和头文件。
  • obj-m += my_char_dev.o: 这行最重要!它告诉内核构建系统,需要将my_char_dev.c编译成一个内核模块(my_char_dev.ko)。

3.2 执行编译

在驱动源码目录下,执行编译命令:

# 首先,确保你正在为ARM架构编译
export ARCH=arm
export CROSS_COMPILE=arm-linux-gnueabi-

# 然后执行make
make

如果一切顺利,你会在当前目录下看到生成的文件,其中最重要的是my_char_dev.ko。用file命令检查一下:

file my_char_dev.ko

你应该看到输出中包含ARM和ELF 32-bit LSB relocatable等字样,确认这是ARM平台的可重定位目标文件。如果显示的是x86-64,那就说明编译目标错了,请检查ARCH和CROSS_COMPILE环境变量以及Makefile。

4. 在Qemu ARM中加载与测试驱动

编译出.ko文件只是成功了一半,接下来我们要把它放到ARM开发板的环境里跑起来。

4.1 部署驱动模块到根文件系统

我们需要把my_char_dev.ko文件放到Qemu虚拟机的根文件系统里。通常有两种方法:

  1. 通过scp或sftp在运行时传输:如果Qemu虚拟机配置了网络并且宿主机和虚拟机能互通,这很方便。但初始环境搭建时网络可能还没配好。
  2. 直接放入根文件系统镜像:更可靠的方法。我们之前制作根文件系统时(比如用busybox制作的rootfs.img),可以把它挂载到宿主机,然后把.ko文件拷贝进去。

假设你的根文件系统是一个ext4格式的镜像文件rootfs.img,可以这样操作:

# 在宿主机创建一个挂载点
sudo mkdir -p /mnt/rootfs

# 挂载镜像(注意需要root权限,并且offset参数可能因镜像类型而异,这里假设是纯ext4镜像)
sudo mount -o loop rootfs.img /mnt/rootfs

# 将驱动模块拷贝到根文件系统的合适目录,例如 /lib/modules/$(uname -r) 或自定义的 /root/drivers
sudo cp my_char_dev.ko /mnt/rootfs/root/

# 卸载镜像
sudo umount /mnt/rootfs

4.2 启动Qemu并加载驱动

使用你的Qemu启动脚本启动ARM开发板。确保启动参数中正确指定了内核(-kernel)、设备树(-dtb)和根文件系统(-drive或-rootfs)。

进入Qemu虚拟机的命令行后(通常是busybox的ash),找到我们拷贝的驱动模块:

# 在Qemu虚拟机中操作
cd /root
ls -l

你应该能看到my_char_dev.ko。现在加载它:

insmod my_char_dev.ko

使用dmesg命令查看内核日志,你应该能看到驱动初始化函数mydev_init中打印的信息,特别是动态分配到的主设备号,比如:

[   12.345678] my_char_dev: Allocated major number 247
[   12.345690] my_char_dev: Character device driver loaded successfully!
[   12.345700] my_char_dev: Create device node with: 'mknod /dev/my_char_dev c 247 0'

注意: 动态分配的主设备号每次加载可能不同,请记下你实际看到的数字,这里是247。

4.3 创建设备节点并测试

加载了驱动,内核里已经有了这个设备,但/dev目录下还没有对应的设备文件,用户程序无法访问。我们需要手动创建:

# 在Qemu虚拟机中操作,使用你看到的主设备号,次设备号我们用0
mknod /dev/my_char_dev c 247 0

现在,/dev/my_char_dev就创建好了。我们可以写一个简单的用户态测试程序test_char.c来验证驱动功能:

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int main() {
    int fd;
    char write_buf[] = "Hello from userspace!";
    char read_buf[1024] = {0};
    ssize_t ret;

    // 1. 打开设备
    fd = open("/dev/my_char_dev", O_RDWR);
    if (fd < 0) {
        perror("Failed to open device");
        return -1;
    }
    printf("Device opened successfully.\n");

    // 2. 向设备写入数据
    ret = write(fd, write_buf, strlen(write_buf));
    if (ret < 0) {
        perror("Failed to write to device");
        close(fd);
        return -1;
    }
    printf("Wrote %zd bytes to device: %s\n", ret, write_buf);

    // 3. 为了演示,我们重置一下设备内部的偏移(实际驱动可以增加ioctl实现)
    // 这里我们简单关闭再打开,因为我们的驱动没有实现lseek,且buffer_offset在打开时未重置。
    // 更好的做法是在驱动open函数中重置buffer_offset,这里我们先这样测试。
    close(fd);
    fd = open("/dev/my_char_dev", O_RDWR);

    // 4. 从设备读取数据
    ret = read(fd, read_buf, sizeof(read_buf) - 1);
    if (ret < 0) {
        perror("Failed to read from device");
        close(fd);
        return -1;
    }
    read_buf[ret] = '\0'; // 添加字符串结束符
    printf("Read %zd bytes from device: %s\n", ret, read_buf);

    // 5. 关闭设备
    close(fd);
    printf("Test finished.\n");
    return 0;
}

在宿主机上用交叉编译工具链编译这个测试程序:

arm-linux-gnueabi-gcc -static -o test_char test_char.c

把生成的test_char可执行文件也拷贝到根文件系统镜像的/root目录下(方法同拷贝.ko文件)。然后在Qemu虚拟机中运行它:

# 在Qemu虚拟机中操作
cd /root
./test_char

同时,在另一个终端或通过dmesg -w实时查看内核日志,你应该能看到驱动中mydev_open、mydev_write、mydev_read、mydev_release函数被依次调用时打印的信息。如果一切正常,测试程序会成功打印出写入和读出的字符串。

4.4 卸载驱动

测试完毕后,可以卸载驱动模块:

# 在Qemu虚拟机中操作
rmmod my_char_dev

再次查看dmesg,应该能看到mydev_exit函数中打印的卸载信息。同时,记得手动删除设备节点(或者可以在驱动退出函数中自动删除,这需要更复杂的代码,比如使用device_destroy和class_destroy,我们后续再探讨):

rm /dev/my_char_dev

5. 进阶:自动创建设备节点与更完善的驱动

手动mknod太不优雅了,而且主设备号动态分配,每次都要查。在生产环境中,我们期望驱动加载后,/dev下的设备节点能自动创建。这就要用到udev(或mdev,在嵌入式busybox环境中常用)机制配合内核的sysfs文件系统。

核心是使用class_create和device_create这一对函数。我们在驱动初始化时创建一个设备类,然后在这个类下创建设备。udev/mdev会监视sysfs,一旦发现新设备,就根据规则自动在/dev下创建节点。

修改我们的驱动初始化部分:

#include <linux/device.h> // 添加这个头文件

static struct class *mydev_class;
static struct device *mydev_device;

static int __init mydev_init(void)
{
    dev_t dev_num;
    int ret;

    // 1. 动态申请设备号
    ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
    if (ret < 0) {
        printk(KERN_ERR "my_char_dev: Failed to allocate char device region\n");
        return ret;
    }
    major_num = MAJOR(dev_num);
    printk(KERN_INFO "my_char_dev: Allocated major number %d\n", major_num);

    // 2. 创建设备类(会在/sys/class下出现)
    mydev_class = class_create(THIS_MODULE, "my_char_class");
    if (IS_ERR(mydev_class)) {
        printk(KERN_ERR "my_char_dev: Failed to create device class\n");
        ret = PTR_ERR(mydev_class);
        goto fail_class;
    }

    // 3. 初始化并添加cdev
    cdev_init(&my_cdev, &mydev_fops);
    my_cdev.owner = THIS_MODULE;
    ret = cdev_add(&my_cdev, dev_num, 1);
    if (ret < 0) {
        printk(KERN_ERR "my_char_dev: Failed to add cdev\n");
        goto fail_cdev;
    }

    // 4. 在类下创建设备(这会触发udev/mdev自动创建/dev节点)
    mydev_device = device_create(mydev_class, NULL, dev_num, NULL, DEVICE_NAME);
    if (IS_ERR(mydev_device)) {
        printk(KERN_ERR "my_char_dev: Failed to create device\n");
        ret = PTR_ERR(mydev_device);
        goto fail_device;
    }

    printk(KERN_INFO "my_char_dev: Character device driver loaded successfully!\n");
    // 不再需要手动mknod的提示了
    return 0;

// 错误处理,按创建顺序的逆序清理资源
fail_device:
    cdev_del(&my_cdev);
fail_cdev:
    class_destroy(mydev_class);
fail_class:
    unregister_chrdev_region(dev_num, 1);
    return ret;
}

static void __exit mydev_exit(void)
{
    dev_t dev_num = MKDEV(major_num, 0);

    // 按创建顺序的逆序销毁
    device_destroy(mydev_class, dev_num);
    cdev_del(&my_cdev);
    class_destroy(mydev_class);
    unregister_chrdev_region(dev_num, 1);

    printk(KERN_INFO "my_char_dev: Character device driver unloaded\n");
}

这样修改后,重新编译并加载驱动,你就会发现/dev/my_char_dev被自动创建了,无需手动mknod。在/sys/class/my_char_class/目录下也能看到对应的设备信息。这是编写一个“正规”驱动的重要一步。

另外,我们之前的驱动读写共用一个buffer_offset,这显然不合理。一个完善的驱动应该分别管理读和写的偏移量,并且需要实现llseek函数来支持lseek系统调用。这些就留给大家作为练习了。驱动开发就是这样,从一个最简单的框架开始,根据实际需求,一点点添加功能,处理边界条件(比如缓冲区满、空、并发访问等),最终打磨成一个稳定可靠的模块。在Qemu这个安全的沙盒里,你可以大胆尝试,即使写崩了也只需要重启虚拟机,这是学习驱动开发最理想的实验场。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐