4、字符设备驱动实战:编写一个简单的字符设备驱动

字符设备驱动,是嵌入式Linux驱动开发里最基础、也是最常打交道的一类。说白了,就是那些以字节流方式读写数据的设备——串口、GPIO、LED、按键,都属于这个范畴。

我记得刚入行那会儿,带我的老工程师扔给我一句话:「字符驱动写好了,Linux驱动开发你就入门了一半。」当时不以为然,后来踩的坑多了,才明白这话的分量。

这一章,我们就手写一个完整的字符设备驱动。从零开始,注册设备号、实现 file_operations 接口、编译加载,一步不落。

本章核心目标: 写一个能在用户态通过 open/read/write/close 操作的虚拟字符设备。它不操作真实硬件,但驱动框架是完整的——你以后接任何字符设备,都是这个套路。

4.1 字符设备驱动的基本框架

先看一张整体流程图,把脉络理清楚。

字符设备驱动加载与交互流程 insmod 加载模块 module_init() 入口 register_chrdev_region() cdev_init() + cdev_add() 用户 open/read/write/close 设备号 = 主设备号 + 次设备号 主设备号标识驱动类型 cdev 结构体关联 file_operations 操作集

流程不复杂:加载模块 → 入口函数 → 注册设备号 → 初始化 cdev → 用户态操作。卸载时反向操作就行。

4.2 设备号:驱动在系统中的身份证

每个字符设备在 Linux 内核里都有一个唯一的编号——设备号。它由两部分组成:

组成部分 位数 作用
主设备号 12 位 标识设备对应的驱动程序
次设备号 20 位 区分同一驱动下的不同设备实例

内核用 dev_t 类型表示设备号,本质上就是一个 32 位无符号整数。主设备号占高 12 位,次设备号占低 20 位。

我的习惯: 调试阶段用动态分配设备号,省心。产品定型后,再申请固定主设备号,方便 udev 规则管理。

4.3 注册与注销设备号

内核提供了两种注册方式:

  • 静态注册: 调用 register_chrdev_region(),需要提前知道主设备号。
  • 动态注册: 调用 alloc_chrdev_region(),由内核自动分配。

我个人更推荐动态注册。为什么?你想想看,如果多个驱动都写死了同一个主设备号,加载时就冲突了。动态分配省去这些麻烦。

看代码:

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

#define DEVICE_NAME "my_char_dev"
#define CLASS_NAME  "my_class"

static int major;
static struct class *my_class;
static struct device *my_device;
static dev_t dev_num;

static int __init my_driver_init(void)
{
    // 动态分配设备号
    int ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
    if (ret < 0) {
        pr_err("Failed to allocate device number\n");
        return ret;
    }

    major = MAJOR(dev_num);
    pr_info("Allocated major number: %d\n", major);

    // 创建设备类(用于自动生成设备节点)
    my_class = class_create(THIS_MODULE, CLASS_NAME);
    if (IS_ERR(my_class)) {
        unregister_chrdev_region(dev_num, 1);
        return PTR_ERR(my_class);
    }

    // 创建设备节点 /dev/my_char_dev
    my_device = device_create(my_class, NULL, dev_num, NULL, DEVICE_NAME);
    if (IS_ERR(my_device)) {
        class_destroy(my_class);
        unregister_chrdev_region(dev_num, 1);
        return PTR_ERR(my_device);
    }

    pr_info("Device driver loaded successfully\n");
    return 0;
}

static void __exit my_driver_exit(void)
{
    device_destroy(my_class, dev_num);
    class_destroy(my_class);
    unregister_chrdev_region(dev_num, 1);
    pr_info("Device driver unloaded\n");
}

module_init(my_driver_init);
module_exit(my_driver_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple character device driver");
我曾经踩过的坑: 卸载模块时忘记注销设备号,结果下次加载时设备号被占用,驱动死活起不来。后来养成习惯——exit 函数里按 init 的逆序释放资源,一个都不能少。

4.4 实现 file_operations 接口

设备号注册好了,用户态怎么操作设备?答案就是 file_operations 结构体。它定义了驱动暴露给用户态的所有操作函数。

常用的接口有这些:

接口 用户态调用 驱动侧实现
open fd = open("/dev/xxx", O_RDWR) 初始化设备、递增引用计数
release close(fd) 清理资源、递减引用计数
read read(fd, buf, count) 从内核缓冲区拷贝数据到用户空间
write write(fd, buf, count) 从用户空间拷贝数据到内核缓冲区
llseek lseek(fd, offset, whence) 调整文件读写位置

来看完整实现:

#define BUFFER_SIZE 1024

static char device_buffer[BUFFER_SIZE];
static int buffer_len = 0;

static int my_open(struct inode *inode, struct file *filp)
{
    pr_info("Device opened\n");
    return 0;
}

static int my_release(struct inode *inode, struct file *filp)
{
    pr_info("Device closed\n");
    return 0;
}

static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t count, loff_t *f_pos)
{
    int bytes_to_read;

    // 检查是否已读到末尾
    if (*f_pos >= buffer_len)
        return 0;

    // 计算实际可读字节数
    bytes_to_read = min(count, (size_t)(buffer_len - *f_pos));

    // 拷贝到用户空间
    if (copy_to_user(buf, device_buffer + *f_pos, bytes_to_read)) {
        pr_err("Failed to copy data to user\n");
        return -EFAULT;
    }

    *f_pos += bytes_to_read;
    return bytes_to_read;
}

static ssize_t my_write(struct file *filp, const char __user *buf,
                        size_t count, loff_t *f_pos)
{
    int bytes_to_write;

    // 限制写入长度
    bytes_to_write = min(count, (size_t)(BUFFER_SIZE - *f_pos));

    // 从用户空间拷贝数据
    if (copy_from_user(device_buffer + *f_pos, buf, bytes_to_write)) {
        pr_err("Failed to copy data from user\n");
        return -EFAULT;
    }

    *f_pos += bytes_to_write;
    buffer_len = *f_pos;

    return bytes_to_write;
}

static struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_release,
    .read    = my_read,
    .write   = my_write,
};
关键点: 用户空间和内核空间的数据拷贝必须用 copy_to_user() 和 copy_from_user(),不能直接用 memcpy。这两个函数会做地址合法性检查,防止用户态传入非法指针导致内核崩溃。

4.5 把 cdev 和 file_operations 关联起来

有了设备号,有了操作函数,还需要一个桥梁把它们连起来——cdev 结构体。

在 init 函数里加上这段:

static struct cdev my_cdev;

static int __init my_driver_init(void)
{
    // ... 前面的设备号注册代码 ...

    // 初始化 cdev
    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;

    // 将 cdev 添加到内核
    ret = cdev_add(&my_cdev, dev_num, 1);
    if (ret < 0) {
        pr_err("Failed to add cdev\n");
        device_destroy(my_class, dev_num);
        class_destroy(my_class);
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }

    pr_info("Character device registered with major %d\n", major);
    return 0;
}

static void __exit my_driver_exit(void)
{
    cdev_del(&my_cdev);  // 先删除 cdev
    device_destroy(my_class, dev_num);
    class_destroy(my_class);
    unregister_chrdev_region(dev_num, 1);
    pr_info("Driver unloaded\n");
}

4.6 编译与测试

写个 Makefile:

obj-m += my_char_drv.o

KDIR := /lib/modules/$(shell uname -r)/build
PWD  := $(shell pwd)

all:
    $(MAKE) -C $(KDIR) M=$(PWD) modules

clean:
    $(MAKE) -C $(KDIR) M=$(PWD) clean

编译加载:

$ make
$ sudo insmod my_char_drv.ko
$ ls /dev/my_char_dev
/dev/my_char_dev

写个用户态测试程序:

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

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

    fd = open("/dev/my_char_dev", O_RDWR);
    if (fd < 0) {
        perror("Failed to open device");
        return -1;
    }

    write(fd, write_buf, strlen(write_buf));
    lseek(fd, 0, SEEK_SET);
    read(fd, read_buf, sizeof(read_buf));

    printf("Read from device: %s\n", read_buf);
    close(fd);
    return 0;
}
调试小技巧: 用 dmesg 查看内核打印信息。我调试驱动时,习惯在终端开一个 watch -n 1 'dmesg | tail -20',实时看日志。

4.7 避坑指南

  • 设备节点权限: 默认生成的设备节点可能只有 root 能访问。用 sudo chmod 666 /dev/my_char_dev 临时解决,产品中通过 udev 规则管理。
  • 并发访问: 上面的代码没加锁。如果多个进程同时读写,数据会乱。实际项目中要用 mutex 或 spinlock 保护共享数据。
  • 内存泄漏: 每次 open 分配的内存,必须在 release 里释放。我见过一个驱动,open 分配了内存但 release 忘了 free,系统跑了三天后 OOM 挂了。
  • copy_to_user 返回值: 它返回未拷贝成功的字节数,不是错误码。判断是否全部拷贝成功,应该检查返回值是否为 0。

嗯,字符设备驱动的基本框架就是这样。代码量不大,但每个环节都踩过坑才算真正掌握。你把这个驱动跑通了,后面学 platform 驱动、设备树、中断处理,都会顺手很多。