前面几篇把字符设备的零件拆着讲完了:read/write 的阻塞语义,等待队列,唤醒竞争.这篇把它们组装起来,写一个完整但迷你的字符设备:内核环形缓冲收数据,读端没数据可以睡,poll/epoll 挂进来,写端一脚踢醒所有人.设备叫 vchar0,不到一百行,但骨架和一个正经驱动的数据通路是一样的.
先搭骨架.真正的字符设备要自己分配设备号(cdev_init + register_chrdev),misc 框架帮你包办:misc_register 给你一个动态次设备号,自动在 /dev 下出节点,一行注册.学习设备和简单设备用它最省事:
真正的字符设备要自己分配设备号(cdev_init + register_chrdev),misc 框架帮你包办:misc_register 给你一个动态次设备号,自动在 /dev 下出节点,一行注册.学习设备和简单设备用它最省事:
static struct miscdevice vchar_dev = {
.minor = MISC_DYNAMIC_MINOR,
.name = "vchar0",
.fops = &vchar_fops,
};
static int __init vchar_init(void)
{
INIT_KFIFO(rx_fifo);
return misc_register(&vchar_dev);
}
接着是数据缓冲,用内核自带的 kfifo(include/linux/kfifo.h),宏声明一个元素类型和容量,容量自动向上取整到 2 的幂:
数据缓冲用内核自带的 kfifo(include/linux/kfifo.h),宏声明一个元素类型和容量,容量自动向上取整到 2 的幂:
static DECLARE_KFIFO(rx_fifo, unsigned char, 128);
为什么不用自己写的 ring buffer + 头尾指针:kfifo 的实现是单生产单消费无锁的(用了索引的 generation 位技巧,读写各走各的无符号回绕),读端只写"出队索引",写端只写"入队索引",互相看到对方的索引都是单调递增的,天然内存序安全.自己手搓很容易在"满和空怎么区分"上栽跟头(头等于尾,是满还是空?).当然,多个读者或多个写者还是得加锁,我们这个场景一个用户写一个用户读,刚好是它的舒适区.
和用户空间交换数据的两个函数,自带 copy_to/from_user,失败返回非零,比手工循环老实多了:
ret = kfifo_from_user(&rx_fifo, buf, n, &copied); // 写端: 进队
ret = kfifo_to_user(&rx_fifo, buf, n, &copied); // 读端: 出队
还有一批常用小函数:kfifo_is_empty/is_full/len/avail,看名字就懂.
然后写读端,上一篇的等待队列模板直接套:
读端逻辑,上一篇的等待队列模板直接套:
static ssize_t vchar_read(struct file *f, char __user *buf, size_t n, loff_t *off)
{
unsigned int copied;
int ret;
if (kfifo_is_empty(&rx_fifo)) {
if (f->f_flags & O_NONBLOCK)
return -EAGAIN;
if (wait_event_interruptible(readq, !kfifo_is_empty(&rx_fifo) || removed))
return -ERESTARTSYS;
if (removed)
return -ENODEV;
}
ret = kfifo_to_user(&rx_fifo, buf, n, &copied);
if (ret)
return ret;
return copied;
}
顺序是刻意的:先查空不空,空且非阻塞才返回 -EAGAIN,阻塞才睡.有人图省事把 O_NONBLOCK 检查放最前面,不空也返回 -EAGAIN,就违背了"非阻塞 = 有数据照常工作,没数据才表态"的语义,上层轮询循环会直接坏掉.
removed 标志是模块卸载的体面:exit 时置位并 wake_up,睡着的读者醒来发现设备没了,返回 -ENODEV 而不是抱着一个即将消失的 fifo 继续读.上一篇说过,睡眠条件里要包含所有该醒的理由,这里"设备要拆了"就是一条.
写端简单,fifo 满了非阻塞就 -EAGAIN(简化处理,满了丢,不做写端阻塞),塞完 wake_up_interruptible 一脚踢醒读端:
static ssize_t vchar_write(struct file *f, const char __user *buf, size_t n, loff_t *off)
{
unsigned int copied;
int ret;
if (kfifo_is_full(&rx_fifo)) {
if (f->f_flags & O_NONBLOCK)
return -EAGAIN;
n = min_t(size_t, n, kfifo_avail(&rx_fifo));
}
ret = kfifo_from_user(&rx_fifo, buf, n, &copied);
if (ret)
return ret;
wake_up_interruptible(&readq);
return copied;
}
read/write 之外,再把 poll 挂上.poll 回调回答一个问题:"这个 fd 现在能不能读/写".驱动能控制的只有两件事:把 poll_table 里的等待队列注册进去(poll_wait,本质就是 add_wait_queue 的马甲),和返回当前的就绪掩码:
poll 回调回答一个问题:"这个 fd 现在能不能读/写".驱动能控制的只有两件事:把 poll_table 里的等待队列注册进去(poll_wait,本质就是 add_wait_queue 的马甲),和返回当前的就绪掩码:
static __poll_t vchar_poll(struct file *f, poll_table *wait)
{
__poll_t mask = EPOLLOUT | EPOLLWRNORM;
poll_wait(f, &readq, wait);
if (!kfifo_is_empty(&rx_fifo))
mask |= EPOLLIN | EPOLLRDNORM;
return mask;
}
poll_wait 不会阻塞,它只是登记.真正的事件循环在 VFS 层:select/poll/epoll 把自己挂到登记过的队列上睡觉,驱动 wake_up 时核心层被叫醒,重新调一遍每个 fd 的 poll 回调算掩码,有事件就返回用户态.epoll 的高效在于这个"重新调一遍"只在事件到来时做,不用每次系统调用全量扫.fd 上没有数据时返回 0 个就绪,轮询超时;有数据返回 EPOLLIN,用户态再 read 就一定能拿到东西(单读情况下).
注意 poll 回调是"随时可能被调"的轻量查询,里面绝不能睡,也不能做重活,它跑在调用者的上下文里.
齐活,上板.
模块在 RK3399 上 insmod,测试程序用 O_NONBLOCK 打开设备,poll 等读事件,3 秒超时,跑两轮,期间往设备写两次:
$ ( /tmp/polltest & PT=$!; sleep 1; echo -n "hello" > /dev/vchar0;
sleep 1; echo -n "world" > /dev/vchar0; wait $PT )
round0: poll=1 revents=0x1(POLLIN) read=5 [hello]
round1: poll=1 revents=0x1(POLLIN) read=5 [world]
两轮 poll 都在写入后立即返回 POLLIN(revents=0x1),read 拿到的正是写入的内容,时间线 1 秒一拍,链路完整:write 进 fifo → wake_up → poll 核心(挂在同一条 readq 上)被唤醒 → 重新调 vchar_poll → EPOLLIN → 用户态 read → kfifo_to_user 出队.
非阻塞读空 fifo:
$ /tmp/nb
read=-1 errno=11
-EAGAIN,和上一章的行为闭环.
最后补一个 epoll 的进阶验证,加 EPOLLET(边缘触发):ET 模式下事件只在"从不就绪变就绪"时报一次,fifo 里如果已经有一半数据没读完,再来新数据也不会再报(状态一直是从就绪到就绪),读端必须一口气读到 EAGAIN 才算干完.这是 epoll ET 的经典坑,自己写个循环读不干净的用例一测就重现,这里留给读者,提示是把 read 的长度参数故意写小.
最后留几个容易忽略的细节:
- kfifo 声明之后一定要 INIT_KFIFO(或用 INIT 的宏变体),DECLARE_KFIFO 只是定义结构体,不初始化索引,忘了我听说过的下场是第一笔数据就错乱.
- wake_up_interruptible 只叫得醒 wait_event_interruptible 睡的人.读者用什么状态睡,写者就用什么家族叫,配错就是"偶尔叫不醒",上一章踩过的坑.
- 这个驱动的 wake_up 在进程上下文,拿着用户传进来的数据,先 copy 进 fifo 再叫人是正确顺序:先叫后拷,被叫醒的人可能在你拷完前就查了一遍 fifo,扑空又睡回去,白折腾一次唤醒(不是错误,但浪费,高吞吐下能测出来).
- misc 设备的 fops 少了 .owner = THIS_MODULE 的话,模块卸载时设备还在被用,直接引用计数翻车,虽然 misc 框架帮你兜了一层,规矩还是要守.
- fifo 大小的选择:128 字节是玩具,真实设备要看突发量,丢数据的位置(满时丢新 or 丢旧)要想清楚并文档化,kfifo 是满即拒收,录波类设备往往宁可丢旧.