4、字符设备驱动实战:编写一个简单的字符设备驱动
字符设备驱动,是嵌入式Linux驱动开发里最基础、也是最常打交道的一类。说白了,就是那些以字节流方式读写数据的设备——串口、GPIO、LED、按键,都属于这个范畴。
我记得刚入行那会儿,带我的老工程师扔给我一句话:「字符驱动写好了,Linux驱动开发你就入门了一半。」当时不以为然,后来踩的坑多了,才明白这话的分量。
这一章,我们就手写一个完整的字符设备驱动。从零开始,注册设备号、实现 file_operations 接口、编译加载,一步不落。
本章核心目标: 写一个能在用户态通过 open/read/write/close 操作的虚拟字符设备。它不操作真实硬件,但驱动框架是完整的——你以后接任何字符设备,都是这个套路。
4.1 字符设备驱动的基本框架
先看一张整体流程图,把脉络理清楚。
流程不复杂:加载模块 → 入口函数 → 注册设备号 → 初始化 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 驱动、设备树、中断处理,都会顺手很多。