4、第一个字符设备驱动:实现简单的open/read/write/close操作,加载模块并创建设备节点,使用echo/cat测试

字符设备驱动是嵌入式Linux驱动开发的基石。本章将带领读者从零编写一个完整的字符设备驱动,实现open、read、write、close四个核心文件操作接口,并完成模块加载、设备节点创建以及基于echo/cat的用户态验证。通过这一完整闭环,读者将理解驱动与内核、驱动与设备节点、内核态与用户态数据交互的本质关系,为后续复杂驱动开发打下坚实基础。

字符设备驱动完整链路:从内核接口到用户态测试 内核空间 insmod module_init() register_chrdev() 主设备号 240 struct file_operations .open = my_open .read = my_read .write = my_write .release = my_close mknod /dev/chardev0 c 240 0 用户空间 echo "hello" > /dev/chardev0 cat /dev/chardev0 读取内核缓冲区 VFS 虚拟文件系统 sys_read() / sys_write() 系统调用 copy_to_user() / copy_from_user() 内核缓冲区 <--> 用户缓冲区 内核缓冲区 kernel_buf[128] 系统调用

4.1 字符设备驱动的核心原理与框架

字符设备(Character Device)是Linux三大设备类型(字符设备、块设备、网络设备)中最基础的一种,其特点是数据以字节流形式按顺序访问,不支持随机定位。典型的字符设备包括串口、GPIO、LED、按键、I2C/SPI控制器等。字符设备驱动的核心任务就是实现struct file_operations结构体中定义的各种接口函数,并将这些函数注册到内核,使得用户空间通过文件系统接口(open/read/write/close)能够间接操作硬件。

驱动开发的基本框架包含四个关键部分:模块加载/卸载函数(module_init/module_exit)、设备号管理(register_chrdev/unregister_chrdev)、文件操作集合(file_operations结构体)以及数据交换机制(copy_to_user/copy_from_user)。模块加载时,内核调用module_init指定的初始化函数,完成设备号注册和字符设备注册;模块卸载时,module_exit负责释放资源。设备号由主设备号和次设备号组成,主设备号标识设备类型对应的驱动,次设备号标识同一驱动管理的不同设备实例。

关键理解:用户空间的echo "hello" > /dev/chardev0实际上经历了以下调用链:echo进程 → C库write() → 系统调用sys_write() → VFS层根据文件路径找到inode → 根据inode中的设备号找到已注册的file_operations → 调用驱动中的.write函数。整个链路中,驱动开发者只需要关注file_operations的实现,其余部分由内核完成。

4.2 驱动代码实现:从零构建字符设备

下面我们编写一个完整的字符设备驱动,该驱动维护一个128字节的内核缓冲区,支持写入和读取操作。代码分为头文件包含、设备结构体定义、函数实现、模块加载卸载四个部分。

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

#define DEVICE_NAME "chardev0"
#define BUF_SIZE 128

static int major_num = 240;  // 主设备号
static char *kernel_buf;      // 内核缓冲区
static int buf_len = 0;       // 当前数据长度

// open 操作
static int my_open(struct inode *inode, struct file *filp)
{
    printk(KERN_INFO "chardev0: open() called\n");
    return 0;
}

// read 操作
static ssize_t my_read(struct file *filp, char __user *buf, 
                       size_t count, loff_t *offset)
{
    int ret;
    size_t bytes_to_read;
    
    if (*offset >= buf_len) {
        return 0;  // EOF
    }
    
    bytes_to_read = min(count, (size_t)(buf_len - *offset));
    ret = copy_to_user(buf, kernel_buf + *offset, bytes_to_read);
    if (ret) {
        printk(KERN_ERR "chardev0: copy_to_user failed\n");
        return -EFAULT;
    }
    
    *offset += bytes_to_read;
    printk(KERN_INFO "chardev0: read %zu bytes\n", bytes_to_read);
    return bytes_to_read;
}

// write 操作
static ssize_t my_write(struct file *filp, const char __user *buf,
                        size_t count, loff_t *offset)
{
    int ret;
    size_t bytes_to_write;
    
    if (count > BUF_SIZE) {
        printk(KERN_WARNING "chardev0: write size %zu exceeds buffer\n", count);
        return -ENOSPC;
    }
    
    bytes_to_write = count;
    ret = copy_from_user(kernel_buf, buf, bytes_to_write);
    if (ret) {
        printk(KERN_ERR "chardev0: copy_from_user failed\n");
        return -EFAULT;
    }
    
    buf_len = bytes_to_write;
    *offset = 0;  // 重置偏移量
    printk(KERN_INFO "chardev0: write %zu bytes\n", bytes_to_write);
    return bytes_to_write;
}

// close (release) 操作
static int my_close(struct inode *inode, struct file *filp)
{
    printk(KERN_INFO "chardev0: close() called\n");
    return 0;
}

// 文件操作集合
static struct file_operations fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .read    = my_read,
    .write   = my_write,
    .release = my_close,
};

// 模块加载
static int __init chardev_init(void)
{
    int ret;
    
    // 分配内核缓冲区
    kernel_buf = kzalloc(BUF_SIZE, GFP_KERNEL);
    if (!kernel_buf) {
        printk(KERN_ERR "chardev0: kmalloc failed\n");
        return -ENOMEM;
    }
    
    // 注册字符设备
    ret = register_chrdev(major_num, DEVICE_NAME, &fops);
    if (ret < 0) {
        printk(KERN_ERR "chardev0: register_chrdev failed with %d\n", ret);
        kfree(kernel_buf);
        return ret;
    }
    
    printk(KERN_INFO "chardev0: registered, major=%d\n", major_num);
    return 0;
}

// 模块卸载
static void __exit chardev_exit(void)
{
    unregister_chrdev(major_num, DEVICE_NAME);
    kfree(kernel_buf);
    printk(KERN_INFO "chardev0: unregistered\n");
}

module_init(chardev_init);
module_exit(chardev_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Embedded Course");
MODULE_DESCRIPTION("Simple Character Device Driver");

代码中值得注意的细节包括:copy_to_user和copy_from_user是内核与用户空间数据交换的安全接口,它们会检查目标地址的合法性,避免用户传入非法指针导致内核崩溃。__user宏标记了用户空间指针,供静态检查工具使用。offset指针用于支持文件定位,在read/write中需要手动维护。缓冲区使用kzalloc分配并清零,避免残留数据泄露。

4.3 模块编译、加载与设备节点创建

驱动代码编写完成后,需要编写Makefile进行编译。对于嵌入式Linux开发,通常使用交叉编译工具链,但本章以PC上的Linux系统为例进行演示。Makefile内容如下:

obj-m := chardev.o
KERNELDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)

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

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

编译完成后生成chardev.ko文件。接下来按照以下流程加载模块并创建设备节点:

  1. 加载模块:sudo insmod chardev.ko,使用dmesg | tail查看内核日志确认注册成功。
  2. 查看设备号:cat /proc/devices | grep chardev0,确认主设备号为240。
  3. 创建设备节点:sudo mknod /dev/chardev0 c 240 0,其中c表示字符设备,240为主设备号,0为次设备号。
  4. 设置权限:sudo chmod 666 /dev/chardev0,允许普通用户读写。
步骤 命令 预期输出/说明
编译模块 make 生成chardev.ko文件
加载模块 sudo insmod chardev.ko 无输出,dmesg显示注册信息
验证注册 cat /proc/devices 看到240 chardev0
创建设备节点 sudo mknod /dev/chardev0 c 240 0 生成/dev/chardev0
修改权限 sudo chmod 666 /dev/chardev0 所有用户可读写
卸载模块 sudo rmmod chardev dmesg显示注销信息

常见错误提示:如果insmod失败并提示Operation not permitted,通常是因为模块签名验证失败或当前内核不允许加载第三方模块。可以尝试sudo dmesg | tail查看具体原因。如果register_chrdev返回负值,说明主设备号240已被占用,可以改为register_chrdev(0, ...)让内核动态分配,然后通过/proc/devices查看实际分配的主设备号。

4.4 用户态测试:echo/cat验证驱动功能

设备节点创建完成后,我们可以通过标准的Linux命令行工具测试驱动的读写功能。测试流程如下:

# 1. 写入数据
echo "Hello, Embedded Linux!" > /dev/chardev0

# 2. 读取数据
cat /dev/chardev0

# 3. 查看内核日志
dmesg | tail -20

执行echo命令时,shell将字符串写入设备文件,触发驱动的my_write函数,数据被拷贝到内核缓冲区。执行cat命令时,触发my_read函数,将缓冲区数据拷贝回用户空间并打印。通过dmesg可以观察到每次open/read/write/close调用的日志记录,验证驱动函数的执行流程。

测试技巧:可以使用dd命令进行更精确的读写测试,例如dd if=/dev/chardev0 bs=10 count=1读取10字节。也可以使用hexdump /dev/chardev0查看二进制内容。对于嵌入式开发,还可以编写简单的C测试程序调用open()/read()/write()函数,更贴近实际应用场景。

4.5 案例拆解:一次完整的驱动调试过程

在实际开发中,我们遇到了一个典型问题:echo写入数据后,cat读取时总是多出几个空字符。通过分析,我们定位到问题根源在于echo命令默认会在字符串末尾追加换行符\n,而我们的驱动缓冲区没有对数据进行任何处理,导致读取时包含了这个换行符。虽然这不是驱动本身的bug,但暴露了驱动设计中一个重要的考虑点:数据格式约定。

进一步调试中,我们发现另一个问题:连续两次echo写入时,第二次写入的数据会覆盖第一次的数据,因为我们的驱动只维护了一个固定大小的缓冲区,且每次写入都重置了buf_len。这在某些应用场景下是不合理的。改进方案是使用环形缓冲区或链表结构,支持追加写入。但为了保持示例简洁,我们保留了当前设计,并在代码注释中明确说明这一限制。

调试过程中,我们使用了以下工具和方法:dmesg查看内核日志、strace跟踪系统调用、/proc/device确认设备注册状态。其中strace -e open,read,write cat /dev/chardev0可以清晰看到用户态发起的系统调用序列,帮助确认问题出在驱动还是用户程序。

4.6 常见误区与开发经验总结

在字符设备驱动开发中,初学者容易犯以下几类错误,需要特别注意:

  • 缓冲区溢出:未检查用户传入的count参数,导致写入数据超过内核缓冲区大小,破坏内核内存。务必使用min()宏或显式判断。
  • 指针误用:直接解引用用户空间指针,而不是使用copy_to_user/copy_from_user。用户空间指针在内核态不能直接访问,会导致页错误。
  • 设备号冲突:静态指定主设备号时未检查是否已被占用。建议使用动态分配方式register_chrdev(0, ...)。
  • 并发访问:多个进程同时读写同一设备时,未使用锁机制保护共享缓冲区,导致数据竞争。本章示例未涉及并发,但实际驱动必须考虑。
  • 资源泄漏:模块卸载时未释放缓冲区或未注销设备,导致内存泄漏或设备号残留。

开发经验:在驱动开发中,建议遵循以下流程:先在PC上开发调试,确认逻辑正确后再移植到嵌入式平台;使用printk时注意日志级别,开发阶段使用KERN_DEBUG,正式发布前移除或降级;每次修改代码后,使用make clean && make重新编译,避免旧文件干扰;测试时先使用简单命令(如echo/cat),再编写自动化测试脚本覆盖边界条件。