第三讲:编写第一个字符设备驱动

从零开始创建一个简单的虚拟传感器驱动,实现 open、read、ioctl 等基本操作,并注册到 PX4 设备树。

好,咱们直接动手。这一讲的目标很明确——写一个能跑起来的字符设备驱动。说白了,就是让 PX4 认识你的虚拟传感器,能打开它、读数据、还能通过 ioctl 调调参数。

我记得刚接触 PX4 驱动时,最头疼的就是不知道从哪下手。设备树、注册函数、文件操作接口……一堆概念堆在一起。后来我发现,其实核心就三件事:定义操作、注册设备、实现回调。今天我们就按这个思路来。

1. 驱动骨架:先搭个空壳

每个字符设备驱动,本质上就是一组函数指针的集合。你想想看,Linux 内核怎么知道你的设备能干啥?就是靠 file_operations 这个结构体。

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/uaccess.h>
#include <linux/cdev.h>

#define DEVICE_NAME "px4_virt_sensor"
#define CLASS_NAME  "virt_sensor"

static int major_num;
static struct class *virt_sensor_class = NULL;
static struct device *virt_sensor_device = NULL;

static int virt_sensor_open(struct inode *inodep, struct file *filep) {
    pr_info("virt_sensor: device opened\n");
    return 0;
}

static int virt_sensor_release(struct inode *inodep, struct file *filep) {
    pr_info("virt_sensor: device closed\n");
    return 0;
}

static struct file_operations fops = {
    .open = virt_sensor_open,
    .release = virt_sensor_release,
};

嗯,这里要注意:pr_info 是内核打印函数,调试时特别好用。我习惯在 open 和 release 里加打印,这样能确认驱动是否被正确调用。

2. 实现 read 操作:让数据流动起来

虚拟传感器嘛,总得吐出点数据。我们模拟一个温度传感器,每次读取返回一个递增的整数。

static int sensor_value = 25;  // 初始温度

static ssize_t virt_sensor_read(struct file *filep, char __user *buffer,
                                size_t len, loff_t *offset) {
    char msg[32];
    int msg_len;

    sensor_value += 1;  // 模拟温度变化
    msg_len = snprintf(msg, sizeof(msg), "%d\n", sensor_value);

    if (copy_to_user(buffer, msg, msg_len)) {
        pr_err("virt_sensor: copy_to_user failed\n");
        return -EFAULT;
    }

    return msg_len;
}

这里有个坑,我曾经踩过——copy_to_user 必须检查返回值。内核空间和用户空间的内存不能直接赋值,必须用这个专用函数。如果返回非零,说明拷贝失败,得返回 -EFAULT

然后把 read 挂到 fops 里:

static struct file_operations fops = {
    .open = virt_sensor_open,
    .release = virt_sensor_release,
    .read = virt_sensor_read,
};

3. ioctl 操作:让用户能控制你

光能读还不够,有时候用户想重置传感器值,或者改变模拟步长。这时候 ioctl 就派上用场了。

#define IOCTL_RESET_VALUE  _IO('v', 1)
#define IOCTL_SET_STEP     _IOW('v', 2, int)

static int step = 1;

static long virt_sensor_ioctl(struct file *filep, unsigned int cmd,
                              unsigned long arg) {
    int tmp;

    switch (cmd) {
        case IOCTL_RESET_VALUE:
            sensor_value = 25;
            pr_info("virt_sensor: value reset to 25\n");
            break;

        case IOCTL_SET_STEP:
            if (copy_from_user(&tmp, (int __user *)arg, sizeof(tmp))) {
                return -EFAULT;
            }
            step = tmp;
            pr_info("virt_sensor: step set to %d\n", step);
            break;

        default:
            return -ENOTTY;
    }
    return 0;
}

注意 ioctl 的命令定义格式:_IO_IOW_IOR 分别对应无参数、写参数、读参数。我个人习惯用 _IOW 传参数,因为大多数控制操作都需要输入值。

同样,把 ioctl 挂到 fops:

static struct file_operations fops = {
    .open = virt_sensor_open,
    .release = virt_sensor_release,
    .read = virt_sensor_read,
    .unlocked_ioctl = virt_sensor_ioctl,
};

4. 注册到 PX4 设备树

驱动写好了,怎么让 PX4 知道它?答案是设备树。在 PX4 的 boards/ 目录下找到你的板子配置文件,添加节点。

virt_sensor: virt-sensor {
    compatible = "px4,virt-sensor";
    status = "okay";
};

然后在驱动初始化时,通过 of_match_table 匹配:

static const struct of_device_id virt_sensor_of_match[] = {
    { .compatible = "px4,virt-sensor" },
    { /* end of list */ }
};
MODULE_DEVICE_TABLE(of, virt_sensor_of_match);

static int __init virt_sensor_init(void) {
    // 注册字符设备
    major_num = register_chrdev(0, DEVICE_NAME, &fops);
    if (major_num < 0) {
        pr_err("virt_sensor: failed to register device\n");
        return major_num;
    }

    // 创建类
    virt_sensor_class = class_create(THIS_MODULE, CLASS_NAME);
    if (IS_ERR(virt_sensor_class)) {
        unregister_chrdev(major_num, DEVICE_NAME);
        return PTR_ERR(virt_sensor_class);
    }

    // 创建设备
    virt_sensor_device = device_create(virt_sensor_class, NULL,
                                       MKDEV(major_num, 0), NULL,
                                       DEVICE_NAME);
    if (IS_ERR(virt_sensor_device)) {
        class_destroy(virt_sensor_class);
        unregister_chrdev(major_num, DEVICE_NAME);
        return PTR_ERR(virt_sensor_device);
    }

    pr_info("virt_sensor: driver loaded, major=%d\n", major_num);
    return 0;
}

static void __exit virt_sensor_exit(void) {
    device_destroy(virt_sensor_class, MKDEV(major_num, 0));
    class_destroy(virt_sensor_class);
    unregister_chrdev(major_num, DEVICE_NAME);
    pr_info("virt_sensor: driver unloaded\n");
}

module_init(virt_sensor_init);
module_exit(virt_sensor_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("PX4 Driver Course");
MODULE_DESCRIPTION("A virtual sensor driver for PX4");

核心流程总结:

  • 定义 file_operations 结构体,挂载 open/read/ioctl/release
  • 实现 read 时用 copy_to_user 传递数据
  • 实现 ioctl 时用 copy_from_user 接收参数
  • 在设备树中添加 compatible 节点
  • 初始化时注册字符设备、创建 class 和 device

5. 驱动加载与测试

编译后,用 insmod 加载模块:

# insmod virt_sensor.ko
# dmesg | tail
[ 1234.5678] virt_sensor: driver loaded, major=240

# 创建设备节点
# mknod /dev/px4_virt_sensor c 240 0

# 测试 read
# cat /dev/px4_virt_sensor
26
27
28

# 测试 ioctl
# ./test_ioctl reset
# cat /dev/px4_virt_sensor
25

调试小技巧: 如果 cat 时卡住,检查 read 函数是否返回了正确的长度。我曾经因为返回值写错,导致用户态一直读不到数据,排查了半天才发现是少了个换行符。

注意: 在 PX4 真实环境中,设备节点通常由 udev 自动创建。但开发阶段手动 mknod 更可控。另外,ioctl 的命令号不要和其他驱动冲突,建议用 _IO 宏生成唯一命令。

6. 知识体系图

下面这张图展示了本章的核心逻辑,从用户态到内核态的完整链路:

用户态应用 系统调用层 VFS 虚拟文件系统 字符设备驱动 驱动核心组件 file_operations 结构体 ├─ .open → virt_sensor_open() ├─ .read → virt_sensor_read() ├─ .unlocked_ioctl → virt_sensor_ioctl() └─ .release → virt_sensor_release() 设备树匹配 compatible = "px4,virt-sensor" 注册流程 register_chrdev() → 分配主设备号 class_create() → 创建设备类 device_create() → 创建设备节点

这张图把整个调用链路画清楚了。用户态应用通过系统调用进入 VFS,VFS 根据设备号找到对应的驱动,然后调用你注册的 file_operations 函数。说白了,驱动就是个中间人,负责把内核和硬件(或者虚拟硬件)连接起来。

好了,这一讲的内容就是这些。代码量不大,但每个细节都值得推敲。尤其是 copy_to_user 和 ioctl 命令定义,这两个地方最容易出 bug。我当年第一次写驱动时,就因为 ioctl 命令号写错,导致应用层一直返回 -1,查了两天才发现是宏定义拼写错误……嗯,都是经验。


公众号:蓝海资料掘金营,微信deep3321