技术知识库

共享设备状态与每次 open 私有状态如何决定进程能否互相唤醒

共享设备状态与每次 open 私有状态如何决定进程能否互相唤醒

摘要

字符设备的每次 open() 都产生独立的 struct file,但这些文件是否能够互相传递数据和唤醒,取决于它们的 file->private_data 是否最终指向同一个共享设备对象。结构体成员地址可以理解为“对象基地址 + 成员偏移”:基地址相同就会得到同一个 KFIFO 和等待队列地址;基地址不同则是互不关联的对象。这里的关键是对象身份和显式指针关系,而不是内存是否连续或代码是否在同一次注册中执行。

问题/背景

在实验 6-7 的多个设备 I/O 多路复用和实验 6-8“为什么不能唤醒读写进程”中,需要回答两个问题:

  1. write() 为什么能够唤醒正在等待同一设备的 read() 或 epoll_wait()?
  2. 相同设备节点被两个进程分别打开后,为什么有时调用了 wake_up_interruptible() 仍无法唤醒读者?

三种对象及其生命周期

对象典型生命周期是否应共享
struct kfifo_device模块创建设备到设备注销同一设备的打开者共享
struct file每次 open() 到对应 close()每次打开独立
file->private_data随 struct file 存在可以指向共享设备,也可以指向每次打开的私有上下文

同一设备被打开两次时,两个 struct file 一定不同;决定通信关系的是驱动放入 private_data 的指针。

基地址加成员偏移

访问结构体成员:

&device->read_queue

可以抽象为:

read_queue 地址
    = device 对象基地址
    + offsetof(struct kfifo_device, read_queue)

假设 read_queue 的偏移为 0x20。

共享同一个设备对象

cat  的 private_data = 0x1000
echo 的 private_data = 0x1000

双方的 read_queue = 0x1000 + 0x20 = 0x1020

读者等待 0x1020,写者也唤醒 0x1020,因此能够配对。

每次打开分别创建对象

cat  的 private_data = 0x1000
echo 的 private_data = 0x2000

cat  的 read_queue = 0x1020
echo 的 read_queue = 0x2020

内核能够分别找到两个等待队列,但不会推断它们在业务上应当关联。wake_up_interruptible() 只处理传入地址代表的那个队列。

正确的共享设备模型

模块为每个设备创建一份长期存在的状态:

/dev/epoll_kfifo_mailbox_3
            │
            ▼
      kfifo_device 3
      ├── misc_kfifo
      ├── read_queue
      └── write_queue

每次打开设备 3 都有自己的 struct file,但 open() 最终令它们指向同一个 kfifo_device 3:

cat  的 struct file ──┐
                      ├──→ kfifo_device 3
echo 的 struct file ──┘

于是 echo 写入的正是 cat 读取的 KFIFO,写者唤醒的也正是读者等待的队列。

实验 6-8 的故意错误

实验 6-8 故意把 KFIFO、read_queue 和 write_queue 移入每次 open() 分配的私有数据:

struct mydemo_private_data {
	struct mydemo_device *device;
	struct kfifo mydemo_fifo;
	wait_queue_head_t read_queue;
	wait_queue_head_t write_queue;
};

这会形成:

cat open()  → private A → fifo A、read_queue A
echo open() → private B → fifo B、read_queue B

cat 睡在队列 A,而 echo 写入 FIFO B 并唤醒队列 B,所以读者不会醒来。问题不是 wake_up_interruptible() 失效,而是驱动选择了错误的状态归属和对象生命周期。

poll 与 epoll 如何定位具体设备

8 个设备是 8 个事件源,不等于 8 个消费者。每个设备拥有独立等待队列:

fd0 → device0 → read_queue0
fd1 → device1 → read_queue1
...
fd7 → device7 → read_queue7

.poll 通过 poll_wait() 把对应文件的 epoll 回调登记到本设备的队列。写入 device3 后,驱动只调用:

wake_up_interruptible(&device3->read_queue);

内核由该等待队列中的 epoll 注册记录找到对应的文件事件,并把用户在 epoll_event.data 中保存的文件描述符、编号或指针返回给 epoll_wait()。

多个消费者监听同一个设备

若多个进程监听同一个共享 FIFO,它们会登记在同一个等待队列上。普通唤醒可能使多个等待者变为可运行,但共享数据通常只会被先执行读取的消费者取走;其他消费者需要重新检查条件。若业务要求每个消费者都收到一份消息,驱动必须维护订阅者列表并逐个投递,内核不会自动复制消息。

指针成员与嵌入成员

嵌入成员可以直接通过基地址和固定偏移定位:

struct example {
	struct miscdevice misc;
};

这时可以用 container_of() 从 misc 的地址反推出 example 的基地址:

结构体基地址 = 成员地址 - 成员偏移

若结构体保存的是指针:

struct example {
	struct miscdevice *misc;
};

miscdevice 可以位于另一次分配的内存中,二者依靠显式指针连接,不能仅靠成员偏移互相反推。

证据与来源

  • 实验手册 v2.0.7,第 6.7 节“向虚拟设备中添加 I/O 多路复用支持”。
  • 实验手册 v2.0.7,第 6.8 节“为什么不能唤醒读写进程”。
  • 实验内核:Running Linux Kernel 5.0,ARM64,5.0.0+。
  • 当前多设备驱动:/home/martin-zou/workspace/gcc-lab/char_mod/epollkfifo_mailbox/epollkfifo_mailbox.c。
  • Linux 5.0 epoll 等待队列回调:fs/eventpoll.c::ep_ptable_queue_proc()、ep_poll_callback()。
  • 已观察到阻塞邮箱在 FIFO 为空时 cat 等待,写入后通过同一设备等待队列继续读取。

关联

待验证

  • 在目标 ARM64 Debian 中加载 epollkfifo_mailbox.ko,确认 8 个 misc 节点均创建成功。
  • 编写并运行用户空间 poll/epoll 程序,验证写入设备 0 和设备 1 时只返回对应事件源。
  • 按实验 6-8 故意把 KFIFO 和等待队列移入每次打开的私有对象,实际复现“写者唤醒错误队列”的现象。