[内核学习]虚拟字符设备

/ 0评 / 0赞

前面几篇把字符设备的零件拆着讲完了: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 的长度参数故意写小.

最后留几个容易忽略的细节:

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注