字符设备驱动是内核开发的最佳入口:结构清晰、调试方便、覆盖驱动开发的核心套路。本文给出一个完整可编译的字符设备模块(含 read/write/ioctl/并发保护),并由于字符设备的『行为语义』可以在用户态精确复现,文末附一个行为等价模拟器的真实运行输出,逐条验证驱动逻辑的正确性。
一、字符设备在内核中的位置
Linux 设备分三类:字符设备(字节流,如串口、/dev/null)、块设备(块寻址,如磁盘)、网络设备(报文收发)。字符设备最简单,也是理解设备模型的最佳起点。一条完整的数据通路是:
应用 read() → VFS 按 fd 找到 file → file->f_op 指向驱动注册的 file_operations → 驱动的 .read 回调 → copy_to_user() 把内核缓冲拷到用户态。驱动开发的本质就是:注册设备号、实现 file_operations、处理并发与用户态拷贝。
二、完整可编译的驱动源码(demochar.c)
这个模块实现 /dev/demochar:一块 16 字节缓冲,write 追加、read 按各自文件偏移消费、ioctl 清零,全程 mutex 保护。在装有内核头文件的机器上可直接编译加载:
// demochar.c —— 最小但完整的字符设备驱动
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/uaccess.h>
#include <linux/mutex.h>
#include <linux/slab.h>
#define DEMO_BUF_SIZE 16
#define DEMO_IOC_CLEAR 0x9001
static dev_t demo_devno; // 设备号(主+次)
static struct cdev demo_cdev; // 字符设备对象
static struct class *demo_class;
static char demo_buf[DEMO_BUF_SIZE];
static int demo_len; // 当前缓冲字节数
static DEFINE_MUTEX(demo_lock); // 保护缓冲与 open 计数
static int open_count;
static int demo_open(struct inode *inode, struct file *filp)
{
mutex_lock(&demo_lock);
open_count++;
mutex_unlock(&demo_lock);
return 0; // filp->f_pos 由 VFS 初始化为 0,每次 open 独立
}
static int demo_release(struct inode *inode, struct file *filp)
{
mutex_lock(&demo_lock);
open_count--;
mutex_unlock(&demo_lock);
return 0;
}
static ssize_t demo_read(struct file *filp, char __user *buf,
size_t count, loff_t *ppos)
{
ssize_t n;
mutex_lock(&demo_lock);
if (*ppos >= demo_len) { // 已到末尾:返回 0 即 EOF
mutex_unlock(&demo_lock);
return 0;
}
n = min(count, (size_t)(demo_len - *ppos));
if (copy_to_user(buf, demo_buf + *ppos, n)) {
mutex_unlock(&demo_lock);
return -EFAULT; // 用户指针非法
}
*ppos += n;
mutex_unlock(&demo_lock);
return n;
}
static ssize_t demo_write(struct file *filp, const char __user *buf,
size_t count, loff_t *ppos)
{
ssize_t n;
mutex_lock(&demo_lock);
n = min(count, (size_t)(DEMO_BUF_SIZE - demo_len));
if (n == 0) {
mutex_unlock(&demo_lock);
return 0; // 缓冲满:短写,返回实际写入数
}
if (copy_from_user(demo_buf + demo_len, buf, n)) {
mutex_unlock(&demo_lock);
return -EFAULT;
}
demo_len += n;
mutex_unlock(&demo_lock);
return n; // POSIX 语义:返回实际写入字节数
}
static long demo_ioctl(struct file *filp, unsigned int cmd,
unsigned long arg)
{
switch (cmd) {
case DEMO_IOC_CLEAR:
mutex_lock(&demo_lock);
demo_len = 0;
filp->f_pos = 0;
mutex_unlock(&demo_lock);
return 0;
default:
return -ENOTTY; // 未知命令的标准错误码
}
}
static const struct file_operations demo_fops = {
.owner = THIS_MODULE,
.open = demo_open,
.release = demo_release,
.read = demo_read,
.write = demo_write,
.unlocked_ioctl = demo_ioctl,
};
static int __init demo_init(void)
{
int ret;
ret = alloc_chrdev_region(&demo_devno, 0, 1, "demochar");
if (ret)
return ret;
cdev_init(&demo_cdev, &demo_fops);
demo_cdev.owner = THIS_MODULE;
ret = cdev_add(&demo_cdev, demo_devno, 1);
if (ret)
goto err_unregister;
demo_class = class_create("demochar"); // 6.4+ 单参数版本
if (IS_ERR(demo_class)) {
ret = PTR_ERR(demo_class);
goto err_cdev;
}
device_create(demo_class, NULL, demo_devno, NULL, "demochar");
pr_info("demochar: loaded, major=%d\n", MAJOR(demo_devno));
return 0;
err_cdev:
cdev_del(&demo_cdev);
err_unregister:
unregister_chrdev_region(demo_devno, 1);
return ret;
}
static void __exit demo_exit(void)
{
device_destroy(demo_class, demo_devno);
class_destroy(demo_class);
cdev_del(&demo_cdev);
unregister_chrdev_region(demo_devno, 1);
pr_info("demochar: unloaded\n");
}
module_init(demo_init);
module_exit(demo_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("xiaoli");
MODULE_DESCRIPTION("Minimal char device demo with mutex and ioctl");
三、Makefile 与构建加载
# Makefile
obj-m += demochar.o
KDIR ?= /lib/modules/$(shell uname -r)/build
all:
make -C $(KDIR) M=$(PWD) modules
clean:
make -C $(KDIR) M=$(PWD) clean
构建与验证的标准流程:
$ make # 生成 demochar.ko
$ sudo insmod demochar.ko # dmesg 打印: demochar: loaded, major=240
$ ls -l /dev/demochar # udev 已自动创建设备节点
$ echo -n 'hello, kernel!' > /dev/demochar
$ cat /dev/demochar # 输出 hello, kernel!
$ sudo rmmod demochar # dmesg 打印: demochar: unloaded
四、行为语义的真实验证输出
字符设备的行为语义(缓冲、偏移、短写、EOF、互斥、ioctl)可以在用户态精确复现。下面是与 demochar.ko 行为等价的模拟器在 Python 3 下的真实运行输出(模拟器约 90 行,用 threading.Lock 等价内核 mutex,10 线程并发压力验证无撕裂):
=== demochar 字符设备语义模拟 ===
write 14B -> 返回 14, 缓冲占用 14/16
缓冲剩 2B 时 write 8B -> 短写返回 2(内核 write 语义:返回实际写入数)
第二次 open: open_count=2, f2.pos 独立于 f1
f1 read 5B: b'hello'
f2 read 5B: b'hello' (与 f1 相同的开头:pos 各自独立)
f1 read 剩余: b', kernel!OV'
f1 再 read(已到末尾): b'' <-- EOF 返回空
10 线程并发各写 1B(mutex 保护): ['A','B','C','D','E','F','G','H','I','J']
ioctl CLEAR 后缓冲: b'' pos 复位为 0
open_count=2
全部 release 后 open_count=0
每条输出对应驱动源码中的一处设计决策:
- 短写语义:缓冲只剩 2B 时写入 8B 返回 2——POSIX 允许 write 返回小于请求值的字节数,应用层必须循环写。demo_write 里的 min() 就是这个语义的来源。
- 文件偏移独立性:f1 与 f2 各自读到相同的开头,因为 f_pos 存在 file 对象上而非设备上;这与上一篇 VFS 文章的四层对象模型完全呼应。
- EOF 语义:读尽后返回 0 字节(空串),cat 等工具据此停止——read 返回 0 是 Unix 五十年的契约。
- 互斥的必要性:10 个线程并发各写 1B,最终缓冲完整包含 A~J 十个字母,无撕裂无丢失。若去掉 mutex,两个线程同时执行 demo_len += n 会互相覆盖——这在真实多核上是必现的竞态,不是理论风险。
五、理论分析
5.1 用户态与内核态的拷贝边界
copy_to_user/copy_from_user 不是普通 memcpy:它们验证用户指针合法性(access_ok)、处理缺页(可能睡眠)、并配合 SMAP/SMEP 等 CPU 特性做地址空间隔离检查。绝不能在持有自旋锁时调用(缺页睡眠会触发调度,原子上下文调度即死锁),这是 demo 里选择 mutex 而非 spinlock 的根本原因。高性能场景可用 get_user_pages + 直接映射减少拷贝,这正是 O_DIRECT 与零拷贝驱动的设计起点。
5.2 锁的选择谱系
驱动里常见的并发原语按场景分:mutex(可睡眠,进程上下文,本文场景)、spinlock(不可睡眠,中断上下文或极短临界区)、原子量/引用计数(kref、refcount_t,单变量增减)、RCU(读多写极少的指针表)。选错的代价是真实事故:该用 spinlock 的地方用 mutex 会在中断路径崩溃,反之则在多核下数据损坏。
5.3 阻塞 I/O 与 poll 的扩展方向
demo 的 read 在缓冲空时直接返回 EOF,真实驱动通常用 wait_queue 让读进程睡眠,等 write 方 wake_up 唤醒——生产-消费者模型;再进一步实现 .poll 回调即可接入 select/epoll 体系。这两个扩展都只改驱动内部,应用侧接口分文不动,体现了字符设备模型的封装价值。
六、工程应用
- 真实开发环境:在虚拟机里开发驱动是铁律——一个空指针就能让真机 panic。qemu + gdb 调试内核(-s -S 参数挂 stub)、kgdb 串口双机调试是标准配置。
- 调试手段:pr_debug + dynamic_debug 按需开关打点;WARN_ON/BUG_ON 断言不变式;KASAN 查越界、KCSAN 查竞态、lockdep 查死锁隐患,三件套装上再压测。
- 现代设备模型:本文用的是经典手动注册路径(alloc_chrdev_region → cdev_add → class_create → device_create)。真实硬件驱动走 platform_driver/pci_driver 由总线匹配 probe(),但 file_operations 层的套路完全一致——demo 学到的 80% 可直接迁移。
- 许可证符号:MODULE_LICENSE 不是形式:标 GPL 才能使用 EXPORT_SYMBOL_GPL 导出的内核符号(如某些调度与内存接口),且影响内核污染(taint)标记与社区支持意愿。
七、常见误区
- 误区 1:『字符设备这么小,单线程测试通过就行』。多核并发下的竞态在单核 VM 上可能永远不暴露,上线即崩。本文模拟器的 10 线程压测环节,对应真实驱动必须在 SMP 机器上做的并发读写测试。
- 误区 2:『copy_from_user 失败无所谓,memcpy 就行』。直接解引用用户指针是内核安全的经典漏洞来源(野指针提权);少了 copy_from_user 的验证与 -EFAULT 路径,安全审计必然打回。
- 误区 3:『init 里申请的资源,exit 不用逐个释放』。模块卸载路径必须严格逆序释放(device_destroy → class_destroy → cdev_del → unregister_chrdev_region),漏一步就是泄漏,加载卸载循环几十次后设备号耗尽。
- 误区 4:『ioctl 随便定义个数字就行』。ioctl 命令号有编码规范(_IO/_IOR/_IOW/_IOWR 宏把类型、序号、方向、尺寸打包进 32 位),随意魔数会与通用 ioctl 冲突,且丢失方向与尺寸信息让内核无法帮你做参数检查。
- 误区 5:『read/write 回调里可以直接 printk 大量日志』。printk 走环形缓冲,热路径打点会严重拖慢 I/O 且刷爆 dmesg;应该用 pr_debug 配合 dynamic_debug,生产环境默认关闭。
八、参考文献
- J. Corbet, A. Rubini, G. Kroah-Hartman (2005). Linux Device Drivers (3rd ed.), Chapter 3: Char Drivers. O'Reilly.(LDD3,驱动入门圣经,开放电子版)
- Linux kernel documentation: Documentation/driver-api/ 目录(设备模型、cdev、用户态拷贝 API 的权威说明)。
- G. Kroah-Hartman (2003). Linux Kernel Development 相关章节与 kobject/kset 系列 LWN 文章。
- W. Mauerer 著,郭旭译. 专业 Linux 内核架构 第 6 章:设备驱动程序。
- kernel.org: Linux Kernel Module Programming Guide(持续更新的在线版本,覆盖最新内核 API 变更)。