3. 字符设备驱动入门
字符设备是 Linux 驱动开发中最基础、也最容易建立完整认知的一类设备。它按字节流方式访问,典型代表包括串口、按键、LED、ADC、RTC 等。要写出第一个可加载、可访问的字符设备驱动,必须掌握四个核心对象:设备号、cdev、file_operations 以及注册与注销流程。本节围绕这四者展开,帮助你把“驱动如何被内核识别、如何被用户态打开、如何最终调用到自己的函数”这条链路彻底打通。
一、核心原理:从用户态 open 到驱动回调
用户态执行 open("/dev/mychar", O_RDWR) 时,内核并不会直接跳到你的驱动函数。它先通过 VFS 找到该路径对应的 inode,再根据 inode 中的设备号(i_rdev)在字符设备映射表中查找已注册的 struct cdev,然后从 cdev 中取出 file_operations 指针,最终调用其中的 .open 回调。因此,设备号是“钥匙”,cdev 是“挂载点”,file_operations 是“函数入口表”。
内核提供 MKDEV(major, minor) 合成设备号,MAJOR(dev) 与 MINOR(dev) 拆分设备号。设备号有两种分配方式:静态指定(register_chrdev_region)和动态申请(alloc_chrdev_region)。现代驱动推荐动态申请,避免主设备号冲突。
二、方法流程:字符设备驱动的标准骨架
一个规范的字符设备驱动通常按以下顺序组织:定义 file_operations → 申请设备号 → 初始化并添加 cdev → 创建类与设备节点 → 实现回调 → 模块退出时逆序释放。下面给出最小可用骨架:
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
static dev_t devno;
static struct cdev my_cdev;
static struct class *my_class;
static struct device *my_device;
static int my_open(struct inode *inode, struct file *filp)
{
pr_info("mychar: open, minor=%d\n", iminor(inode));
return 0;
}
static ssize_t my_read(struct file *filp, char __user *buf,
size_t len, loff_t *off)
{
return 0; /* 示例:无数据可读 */
}
static const struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.read = my_read,
};
static int __init mychar_init(void)
{
int ret;
ret = alloc_chrdev_region(&devno, 0, 1, "mychar");
if (ret < 0)
return ret;
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
ret = cdev_add(&my_cdev, devno, 1);
if (ret < 0) {
unregister_chrdev_region(devno, 1);
return ret;
}
my_class = class_create("mychar");
my_device = device_create(my_class, NULL, devno, NULL, "mychar%d", 0);
pr_info("mychar: major=%d minor=%d\n", MAJOR(devno), MINOR(devno));
return 0;
}
static void __exit mychar_exit(void)
{
device_destroy(my_class, devno);
class_destroy(my_class);
cdev_del(&my_cdev);
unregister_chrdev_region(devno, 1);
}
module_init(mychar_init);
module_exit(mychar_exit);
MODULE_LICENSE("GPL");
加载后可用 dmesg 查看主次设备号,用 ls -l /dev/mychar0 确认设备节点,用 cat /proc/devices 验证字符设备注册结果。
三、关键对象对照清单
| 对象 | 作用 | 关键 API | 释放方式 |
|---|---|---|---|
| dev_t 设备号 | 标识驱动与设备实例 | alloc_chrdev_region / register_chrdev_region | unregister_chrdev_region |
| struct cdev | 把设备号与 fops 绑定 | cdev_init / cdev_add | cdev_del |
| file_operations | 提供 open/read/write/ioctl 等回调 | 静态定义并赋值 .owner | 随模块卸载自动失效 |
| class / device | 自动创建 /dev 节点 | class_create / device_create | device_destroy / class_destroy |
四、注册与注销流程可视化
五、案例场景:一个可读的“版本号”字符设备
假设我们要实现一个只读设备,用户 cat /dev/ver0 时返回驱动版本字符串。核心只需在 my_read 中把字符串拷贝到用户缓冲区,并正确维护 *off 偏移:
static char ver_str[] = "mychar v1.0\n";
static ssize_t my_read(struct file *filp, char __user *buf,
size_t len, loff_t *off)
{
if (*off >= sizeof(ver_str))
return 0; /* EOF */
if (len > sizeof(ver_str) - *off)
len = sizeof(ver_str) - *off;
if (copy_to_user(buf, ver_str + *off, len))
return -EFAULT;
*off += len;
return len;
}
加载模块后执行 cat /dev/ver0,应输出 mychar v1.0。这个例子虽小,却完整覆盖了设备号申请、cdev 注册、fops 回调、用户态访问四条主线。
六、常见误区与复盘
register_chrdev 就以为万事大吉。该接口会一次性占用 0~255 全部次设备号,现代内核已不推荐,应改用 alloc_chrdev_region + cdev_add。误区二:忘记设置
my_cdev.owner = THIS_MODULE,导致模块在使用中被卸载,引发内核崩溃。误区三:注销顺序写反。正确顺序是先
device_destroy/class_destroy,再 cdev_del,最后 unregister_chrdev_region,与注册顺序严格逆序。误区四:在
read/write 中直接访问用户指针。必须使用 copy_to_user / copy_from_user,否则会触发 oops。
cat /proc/devices | grep mychar 确认主设备号;用 ls -l /dev/mychar0 检查节点;用 dmesg | tail 观察 open 回调是否被触发。若 open 返回 ENODEV,优先排查 cdev 是否成功 add、设备号是否一致。
掌握本节内容后,你已经具备编写一个最小字符设备驱动的完整能力。后续更复杂的 ioctl、阻塞、poll、并发控制,都是在这套“设备号 + cdev + fops + 注册注销”骨架上逐步扩展出来的。