4、第一个字符设备驱动:实现简单的open/read/write/close操作,加载模块并创建设备节点,使用echo/cat测试
字符设备驱动是嵌入式Linux驱动开发的基石。本章将带领读者从零编写一个完整的字符设备驱动,实现open、read、write、close四个核心文件操作接口,并完成模块加载、设备节点创建以及基于echo/cat的用户态验证。通过这一完整闭环,读者将理解驱动与内核、驱动与设备节点、内核态与用户态数据交互的本质关系,为后续复杂驱动开发打下坚实基础。
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文件。接下来按照以下流程加载模块并创建设备节点:
- 加载模块:
sudo insmod chardev.ko,使用dmesg | tail查看内核日志确认注册成功。 - 查看设备号:
cat /proc/devices | grep chardev0,确认主设备号为240。 - 创建设备节点:
sudo mknod /dev/chardev0 c 240 0,其中c表示字符设备,240为主设备号,0为次设备号。 - 设置权限:
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),再编写自动化测试脚本覆盖边界条件。