前阵子调试一个模拟I2C从设备的字符驱动时,我踩了一个特别“基础”但很多人都会忽略的坑:程序A打开设备节点后,程序B竟然也能打开,两边同时往寄存器里写,数据全乱。我第一反应是自己驱动代码写错了,查了整整半天,最后才发现问题根本不在我的代码,而是我误解了一个内核层面默认得不能再默认的行为——同一个字符设备节点,默认就允许多个进程同时打开,驱动不显式拒绝,第二次open就是成功的。
这篇东西就是围绕这个问题展开的。我会把“多进程打开同一字符设备”这件事拆开讲清楚:内核为什么放行、驱动层应该在哪里拦截、多open之后并发和资源管理怎么做、什么场景该禁止什么场景该放开,最后用两个小程序和一个最小驱动实测一遍边界。适合正在写字符设备驱动、或者刚接触Linux驱动开发的朋友,看完你至少不会再被这个默认行为坑一次。
1. 同一个设备节点被多个进程打开,内核为什么“不管”——从VFS的常规路径说起
1.1 open系统调用走到驱动面前的完整路径
先别急着写代码,把调用链捋一遍。你在用户态调用open("/dev/mydrv", O_RDWR),内核路径大致是:
sys_openat()进入VFS层;- 路径查找阶段,
namei系列函数沿着目录项(dentry)找到设备节点对应的inode; do_dentry_open()检查权限、初始化struct file,然后看这个inode是不是字符设备;- 如果是字符设备,会根据设备号在
chrdev映射表里找到注册过的struct cdev,拿到驱动注册的file_operations; - 最后,如果
f_op->open存在,就调用驱动层open。
注意,整条链路里没有一处逻辑是“这个设备已经被某个进程打开了,所以拒绝后续open”。VFS层检查的是路径是否存在、权限是否允许、打开标志是否合法,它压根不关心“同一inode是不是已经被打开了”。
这就是关键认知:字符设备的“是否允许重复打开”,不是内核强制的规则,而是驱动作者自己写的策略。
1.2 file和inode的分工:每次open都是新的“会话”
很多新手会把“设备节点”想象成一个锁死的对象,以为一个进程打开之后,别人就不能碰了。实际上,内核把底层资源(设备本身)和进程视角(被打开的“文件描述”)分得很开:
inode对应的是设备节点在文件系统里的身份,是静态的;struct file是每次open调用创建的动态对象,里面带着f_mode、f_flags、f_pos,还有那个驱动最爱用的private_data。
两个进程打开同一个设备节点,会得到两个独立的struct file,它们的private_data是分开的,可以各自保存自己的状态。这就好比两个人用同一个卡槽的钥匙去开同一扇门,门是同一个门,但各自的开门记录、当前位置是独立的。
普通文件也完全一样:多个进程同时用fopen读同一个磁盘文件,谁也挡不住谁。字符设备只是继承了文件系统的这套通用规则,并没有额外增加“独占”语义。
1.3 驱动能干预的地方只有open和release
正因为VFS不出面管这事,设备驱动就成了唯一能决定“允许多开”还是“只准一个人玩”的关卡。
file_operations结构体里,驱动通常会写:
open:准入控制 + 初始化private_data;release:最后一个关闭时做清理;read/write/ioctl:真正的业务操作。
如果某驱动根本不注册.open,会发生什么?do_dentry_open()发现f_op->open是空的,也就通过了。也就是说:“什么都不写”就等于默认放行多进程打开。只有在你自己的open函数里主动返回错误码,才能拦下来。
1.4 这种“默认允许”是设计缺陷吗?恰恰相反
可能有人会问:难道不应该默认禁止,驱动再主动放开吗?我的看法是,这恰恰是刻意设计。
字符设备是个大筐,什么东西都往里装:串口、GPIO控制器、传感器、GPU、网卡、声音设备、虚拟调试设备……每种设备的并发语义完全不同。有的必须独占(同一组硬件寄存器不能让两个进程各写各的),有的天生就该被多个进程同时打开(比如一个只读统计计数器),有的还要靠多open来实现多客户端的复用(比如pty)。
内核只想提供“文件操作”这个统一抽象的框架,把“如何对待多open”这个策略问题留给驱动作者。灵活归灵活,代价就是你写任何字符驱动之前,必须先想清楚一个问题:我的设备,到底是“一资源一客户端”,还是“一资源多客户端”?
2. 驱动层阻止重复打开的惯用手法:计数器、互斥与错误码怎么搭配才不坑
2.1 最朴素的计数器方案:atomic_open_cnt
如果你确认设备必须单open,第一反应通常是在open里做个计数。最直接的是用原子变量:
static atomic_t mydev_open_cnt = ATOMIC_INIT(0); static int mydev_open(struct inode *inode, struct file *filp) { if (atomic_inc_return(&mydev_open_cnt) > 1) { atomic_dec(&mydev_open_cnt); return -EBUSY; } return 0; } static int mydev_release(struct inode *inode, struct file *filp) { atomic_dec(&mydev_open_cnt); return 0; }这段代码能挡住99%的“第二个进程直接open”的情况。但你细看会发现一个隐患:open是长路径,后续还要分配private_data,如果分配失败了,计数已经加过了,必须记得回退,不然设备从此“永久占用”。这个坑我在2.4节再展开。
如果驱动里本身就有了一把mutex,也可以直接用普通int变量,反正open和release之间用同一把锁串起来:
static DEFINE_MUTEX(mydev_lock); static int mydev_opened; static int mydev_open(struct inode *inode, struct file *filp) { int ret = 0; mutex_lock(&mydev_lock); if (mydev_opened) { ret = -EBUSY; goto unlock; } mydev_opened = 1; unlock: mutex_unlock(&mydev_lock); return ret; } static int mydev_release(struct inode *inode, struct file *filp) { mutex_lock(&mydev_lock); mydev_opened = 0; mutex_unlock(&mydev_lock); return 0; }两种写法都行。没有并发极高端的情况,用mutex版本更直白,排查问题也好追踪。
2.2 第二个进程open时,错误码到底该返回哪一个
这是很多初学者最纠结的地方:拦下来了,但return -EBUSY、-EPERM、-EAGAIN有什么区别?我整理了一张表:
| 错误码 | 内核语义 | 适合场景 |
|---|---|---|
-EBUSY | 设备或资源忙 | 明确表示“正在被占用”,单open拒绝时最直观 |
-EAGAIN | 暂时不可用,可稍后重试 | 打算配合poll/fasync做“等待释放后再打开”的机制时 |
-EPERM | 操作被禁止 | 多用于权限、能力不足,设备占用用它不够准确 |
-EACCES | 权限不足 | 打开方式与文件权限冲突时由VFS返回,驱动少用 |
-ENODEV | 设备不存在 | 设备未初始化或已被移除,不适合表达“被占用了” |
-ENXIO | 无此设备或地址 | 设备未配置,同ENODEV,别拿来当占用错误 |
-ENOMEM | 内存不足 | open中申请内存失败,这个必须原样返回 |
我自己的经验是:普通单open驱动,-EBUSY是最省事、最不容易让用户态误判的选择。用户看一眼errno是EBUSY,去搜一下就知道是设备正忙。
有一种情况可以上-EAGAIN:你有明确的业务需求,希望第二个进程不报错,而是“等第一个进程关闭后再自动打开”。比如某个测试工具希望碰运气抢占设备。这时候代码就要复杂不少,光靠一个计数器不够,还得有等待队列和O_NONBLOCK分支处理。没有这种需求就别自找麻烦。
2.3 单open驱动也逃不过的一个隐蔽坑:fork与dup的引用结构
你以为计算“open次数”就是数有多少个进程进来了?天真了。open和release并不是一一对应进程的,它们对应的是struct file的引用计数。
举个例子:
- 进程A调用
open(),得到fd=5,此时file引用计数是1; - 进程A调用
fork(),子进程B继承了fd=5,父子两个fd其实指向同一个struct file,引用计数变成2; - 进程A调用
close(5),引用计数减到1,release不会被调用; - 进程B再
close(5),引用计数减到0,驱动层的release才执行。
也就是说,release是“最后一个持有者关闭”时才触发,不是“每个进程关闭”时触发。同理,dup()、dup2(),甚至通过SCM_RIGHTS把fd传给另一个进程,都会让同一个file的引用计数变多。
这解释了为什么你看到的现象往往是“明明只有一个人打开了设备,计数却一直不为0”。如果一个驱动在open时把mydev_opened设成1,release时才清0,那在fork场景下是安全的——因为release只会在所有引用关闭后触发一次。但如果驱动在 close 路径用f_count以外的逻辑乱减计数,就真的会漂移。
所以,单open驱动的正确姿势是:open里只做一次准入判断,release里做一次释放标记,别去数有多少个close调用。
2.4 不写release和漏写错误回滚都是在埋雷
再补一个实操细节。open函数里经常要做这些事:
- 分配
private_data(比如kzalloc); - 初始化自旋锁/互斥锁;
- 启动某个设备状态机;
- 给硬件上电。
对应的release就要逐一释放。很多人单open驱动写得很快,open里抢到锁就忘了后面还有一连串初始化操作,任何一个步骤失败都会留下后遗症。
比如这样:
static int mydev_open(struct inode *inode, struct file *filp) { struct mydev_priv *priv; if (atomic_inc_return(&mydev_cnt) > 1) { atomic_dec(&mydev_cnt); return -EBUSY; } priv = kzalloc(sizeof(*priv), GFP_KERNEL); if (!priv) { atomic_dec(&mydev_cnt); // 这行非常容易漏 return -ENOMEM; } filp->private_data = priv; return 0; }如果漏了atomic_dec(&mydev_cnt),进程第一次open会因为内存不足失败,之后这个驱动就永远“忙”了。类似的问题也出现在cdev_add失败、device_create失败等分支上。“err_out”标签配合goto是内核社区惯用写法,核心思路只有一个:所有失败分支都必须恢复之前已经改动的状态。
3. 允许多进程打开之后,真正的并发难题:会话私有状态与应用共享状态
3.1 private_data是天然的会话隔离工具
如果你的设备设计上就必须支持多进程并发打开,那恭喜你,麻烦刚刚开始。第一个原则是利用好file->private_data。
每个进程open的时候,驱动给它分配独立的内存,放各自的游标、缓冲、会话编号。比如一个模拟多通道的设备,进程A想读通道0,进程B想读通道1,open阶段就把通道号记进各自的private_data:
static int mydev_open(struct inode *inode, struct file *filp) { struct mydev_priv *priv; priv = kzalloc(sizeof(*priv), GFP_KERNEL); if (!priv) return -ENOMEM; priv->session_id = atomic_inc_return(&g_session_cnt); filp->private_data = priv; return 0; } static long mydev_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { struct mydev_priv *priv = filp->private_data; switch (cmd) { case MYDEV_GET_SESSION: return put_user(priv->session_id, (int __user *)arg); ... } }这样两个进程虽然访问同一个设备节点,但各自的会话ID是独立的,互不干扰。很多驱动在ioctl里动不动就container_of到全局结构体,然后到处读全局变量,这就是并发打开后数据互相污染的根源。
3.2 硬件本身是共享的:什么时候必须动全局锁
上面那种“会话完全隔离”是最理想的情况。现实中的设备往往是:进程可以多开,但底层硬件资源只有一份。比如一个UART控制器,两个进程都能open,但寄存器是同一组,发送FIFO是同一个,如果两边同时往TX写,字节会拧成一团。
这种场景下,驱动必须区分两件事:
- 会话上下文:每个file独立,放各进程的配置偏好;
- 硬件访问保护:全局唯一的锁,保护对寄存器和FIFO的操作。
合并起来就是经典写法:open时给每个file分配私有数据,read/write/ioctl里先操作自己的私有数据,真正碰硬件寄存器的时候再拿全局锁。
static DEFINE_MUTEX(hw_lock); static ssize_t mydev_write(struct file *filp, const char __user *buf, size_t count, loff_t *pos) { struct mydev_priv *priv = filp->private_data; char tmp[64]; int ret; if (count > sizeof(tmp)) count = sizeof(tmp); ret = copy_from_user(tmp, buf, count); if (ret) return -EFAULT; mutex_lock(&hw_lock); /* 把tmp写到硬件FIFO, 修改寄存器 */ hw_write_fifo(tmp, count); mutex_unlock(&hw_lock); priv->bytes_written += count; return count; }先把用户态数据复制到内核临时缓冲,再拿锁写硬件,这个顺序很重要。道理后面说。
3.3 锁选择:这表能帮你避免多数panic
写驱动最痛苦的莫过于选错锁。我用一张表总结内核里常见的同步手段在多open场景下的适用性:
| 同步手段 | 临界区能否睡眠 | 开销 | 典型使用场景 |
|---|---|---|---|
mutex | 可以 | 低 | 保护设备状态机、硬件操作序列,最常用 |
spinlock | 绝对不能睡眠 | 极低 | 保护某个标志位、链表头、寄存器单次读改写 |
atomic_t | — | 极低 | 计数器、flag,不需要锁围绕它转 |
rcu | 读临界区可以(写需要延后释放) | 中 | 全局配置热读冷写,多进程频繁读取设备属性 |
completion | 可以 | 中 | 等待设备中断、异步完成事件 |
最常见的错误是在spinlock临界区里调用了msleep、copy_to_user、kmalloc(..., GFP_KERNEL)这类可能睡眠的函数。一旦发生,内核直接报“BUG: sleeping function called from invalid context”。排查这种问题你会疯掉的。
3.4 copy_to_user时为什么要先复制到临时缓冲
很多新手在read里这么写:
ssize_t mydev_read(struct file *filp, char __user *buf, size_t count, loff_t *pos) { unsigned long flags; spin_lock_irqsave(&dev->lock, flags); /* 直接从设备内存 copy_to_user */ ret = copy_to_user(buf, dev->buffer, count); spin_unlock_irqrestore(&dev->lock, flags); return ret; }这个写法看着简洁,实际上很危险。copy_to_user访问用户态页而缺页时,会触发进程调度睡眠。在spinlock持有期间睡眠,上面说过,是要panic的。就算用的mutex,长时间持锁也会让其他进程阻塞在内核里,用户态表现为“卡住”。
标准做法是:先拿到全局锁,从设备缓冲复制到内核临时空间,释放锁,再copy_to_user。锁的临界区要尽量短,只保护真正的共享数据,不保护耗时的用户态拷贝动作。
3.5 真正的共享状态是全局缓冲时,会话里要放私有位置游标
举个我实际做过的场景:一个字符设备模拟了环形调试缓冲,设计上允许多个进程同时打开来读日志。如果每个read都用同一个loff_t *pos,两个进程会串着读,谁也读不完整。正确设计是:
- 硬件/全局层面只有一份数据环形缓冲,用自旋锁保护写索引;
- 每个进程的
private_data里保存自己的“本次读位置”; - read的时候,读取自己的位置,从环形缓冲取一段,更新私有位置,全程只短暂锁环形缓冲本身。
这样多个进程读同一条日志流,每个人都能从头读到尾,互不挤占。这类“同源数据、多路读取”的设备特别适合多open。你想把设备做成什么语义,完全取决于私有数据和全局锁的划分。
4. 什么时候该禁多开、什么时候放开多开:由设备资源属性决定驱动设计
4.1 独占硬件资源:默认就要单open
有一类设备节点背后是真实的、不可虚拟化的硬件单元。典型代表:
- GPIO控制器寄存器组;
- I2C/SPI总线上的某块传感器;
- 一个DMA通道;
- 某个ADC的单一配置寄存器。
这种设备如果允许两个进程同时open,后果就是两个进程对同一组寄存器做无协调的读写,硬件状态乱成一锅粥。指望应用进程自己去协商跨进程协议,大概率是不现实的。这类驱动的open里写死-EBUSY属于最稳妥的选择。
有些开发者的观点是“反正用户可以自己用文件锁”,驱动不用管。我不太认同,因为字符设备上能否用flock跟具体文件系统、驱动实现强相关,用户态锁并不可靠。硬件独占就是驱动自己一个人的事。
4.2 可虚拟化的设备:多open不但无害而且是基本功
相反,下面这些设备类型往往需要放开多open:
- 只读统计节点:比如读某个计数器、温度、版本信息,多个进程同时读完全没风险;
- 会话可分配的虚拟设备:比如多路复用器,每次open分配一个逻辑通道,通道之间互不相关;
- 调试/性能节点:比如某个驱动提供的trace数据源,不同进程各读各的;
- 类tty的协议流设备:多个进程同时打开本身是常态,协议层自己需要处理读写竞争,前提是驱动内部有完善的状态机和锁。
一个常见误区是“我的设备是虚拟的,所以不需要任何锁”。不对,虚拟设备只是不需要“独占open”这个准入限制,但并发访问同一个全局缓冲、同一份配置,照样需要锁。开放多open和内部加锁是两个维度的事,别混在一起。
4.3 一张决策表帮你快速定位open策略
我踩过很多坑之后总结了一套判断方法,每次新驱动开工前先回答三个问题:设备底层资源能不能被多个上下文同时“有意义地”操作?每个上下文需要独立维护什么状态?并发访问共享资源的最小临界区是哪里?
| 设备场景 | 底层资源 | open策略 | 并发保护重点 |
|---|---|---|---|
| 单通道ADC | 单个硬件寄存器 | 禁止多open | open/release计数 |
| 多通道ADC | 同一控制器多通道 | 允许多open,每open绑定一个通道 | 通道分配表 + 寄存器锁 |
| 调试环形缓冲 | 一个全局内存缓冲 | 允许多open | 缓冲索引锁 + 私有读位置 |
| 模拟I2C从设备 | 一组状态寄存器 | 禁止多open | 写入序列必须单客户端 |
| 只读版本/统计节点 | 全局只读数据 | 允许多open | 基本不需要锁,或RCU即可 |
| 串口UART | FIFO/移位寄存器 | 多数情况禁多open(除非协议定制) | 硬协议有序性无法跨进程保证 |
这张表不能覆盖所有设备,但能帮你建立一个思维习惯:先看资源,再看状态,最后才写open。
4.4 设备节点的permission和ACL并不能替代open准入
还有一个非常常见的误解:是不是给设备节点设了权限chmod 660,只允许某个用户组访问,就能实现“独占”?不是。权限解决的是“谁有资格打开”,不是“同一时刻能有几个进程打开”。只要两个进程都属于那个用户组,它们就都能打开。真正的“同一时刻只允许一个打开的语义”,只能由驱动open函数返回错误来实现。
权限、capability、SELinux/AppArmor这些安全机制跟并发准入是两套东西,它们负责的门不同。你要想清楚自己的驱动堵的是哪道门。
5. 实测:两个小程序与最小字符驱动验证多open边界
5.1 写一个没有任何限制的最小驱动
光说不练假把式。我实际写了个极简字符驱动,故意不限制open数量,只在open时自增一个计数,release时递减,并把计数注册到/proc/mydrv_count。
#include <linux/module.h> #include <linux/fs.h> #include <linux/cdev.h> #include <linux/device.h> #include <linux/uaccess.h> #include <linux/slab.h> static int my_major; static struct cdev my_cdev; static struct class *my_class; static atomic_t open_count = ATOMIC_INIT(0); static int mydev_open(struct inode *inode, struct file *filp) { struct mydev_priv *priv; atomic_inc(&open_count); priv = kzalloc(sizeof(*priv), GFP_KERNEL); if (!priv) { atomic_dec(&open_count); return -ENOMEM; } priv->session_id = atomic_read(&open_count); filp->private_data = priv; return 0; } static int mydev_release(struct inode *inode, struct file *filp) { kfree(filp->private_data); filp->private_data = NULL; atomic_dec(&open_count); return 0; } static ssize_t mydev_read(struct file *filp, char __user *buf, size_t count, loff_t *pos) { struct mydev_priv *priv = filp->private_data; char tmp[64]; int len; len = snprintf(tmp, sizeof(tmp), "session=%d\n", priv->session_id); return simple_read_from_buffer(buf, count, pos, tmp, len); } static struct file_operations my_fops = { .owner = THIS_MODULE, .open = mydev_open, .release = mydev_release, .read = mydev_read, }; static int __init mydev_init(void) { dev_t dev; alloc_chrdev_region(&dev, 0, 1, "mydrv"); my_major = MAJOR(dev); cdev_init(&my_cdev, &my_fops); cdev_add(&my_cdev, dev, 1); my_class = class_create("mydrv_class"); device_create(my_class, NULL, dev, NULL, "mydrv_%d", MINOR(dev)); return 0; } static void __exit mydev_exit(void) { dev_t dev = MKDEV(my_major, 0); device_destroy(my_class, dev); class_destroy(my_class); cdev_del(&my_cdev); unregister_chrdev_region(dev, 1); } module_init(mydev_init); module_exit(mydev_exit); MODULE_LICENSE("GPL");这个驱动没有做任何“第二次open拒绝”,所以你可以同时开多个fd,每次read都会返回不同的session id。这验证了本篇文章的核心题目:内核默认允许多进程打开,并且每个open的file实例天然独立。
5.2 验证“活锁不在驱动,而在会话隔离”
写两个小程序,同时打开/dev/mydrv_0,然后分别read。程序A和程序B各读三次。
程序A输出:
session=1 session=1 session=1程序B输出:
session=2 session=2 session=2两个进程各自的private_data保存了各自的session id,互不影响。如果我在open里不分配私有数据,而是用全局变量覆盖,程序B的输出就会把程序A覆盖掉,这是很多多open驱动并发bug的原型。
5.3 验证release的延迟触发:fork之后不能靠close回收
接着做一件很容易吓到人的事:程序A打开设备后fork(),父进程马上close,子进程再读,你会发现设备状态还在,read依然正常。为什么?因为file引用计数从1变成了2,父进程close只是把计数减回1,release完全没触发。
这种现象在“单open”驱动里的影响尤其严重。你以为你close了,设备“释放”了,但子进程还握着同一个file结构体。如果你在release里做了关硬件、停中断这种重量级操作,而这个release根本没触发,硬件其实还在跑。
所以我的建议是:驱动里涉及资源释放的日志,一定要在release里加,不要只依赖用户态close的“感觉”。真要确认是否释放,看驱动日志的release打印比看应用层日志可靠得多。
5.4 验证单open模式下失败后必须能重新打开
把驱动改成单open版本,逻辑是open里判断atomic_inc_return > 1就回滚。测试过程:
- 进程A打开成功;
- 进程B打开失败,返回
-EBUSY; - 进程A关闭;
- 进程C再打开,应该成功。
很多人测到第2步就停了,不会测第3、4步。实际上出错率最高的正是“失败后计数没回滚”,导致第4步永远失败。这种问题不写自动化脚本很难发现。
5.5 我把常踩的边界坑汇总成了这张小抄
| 现象 | 根因 | 对策 |
|---|---|---|
| 第二次open成功且状态错乱 | 驱动没做准入控制 | 明确资源语义,必要时返回-EBUSY |
| 第一次open失败后设备永久“忙” | 失败分支漏恢复计数器 | 所有goto err路径统一回滚 |
| close后设备状态不释放 | fork/dup令file引用计数>1 | release按最后一个close设计,别在close调用处误判断 |
| 两个进程读同一个全局buf相互污染 | 会话状态放进了全局变量 | 每个open分配private_data |
| 内核报“sleeping function called from invalid context” | spinlock临界区里调用可睡眠函数 | 先拷临时缓冲再解锁,或改用mutex |
| 模块卸载时open_count不为0 | 有进程还开着节点 | 卸载前检查count,或强制设备资源不可用并返回错误 |
| open后立刻热拔插,进程访问崩溃 | 设备对象已释放但file还活着 | 驱动引入独立refcount或依赖内核设备模型的生命周期管理,禁止在open后直接裸访问外部设备指针 |
这页小抄基本是我实际调试多open驱动时,翻来覆去遇到的所有问题的浓缩版。
6. 最后一个经验:写驱动前先把“会话模型”说清楚
我个人的体会是,多进程打开同一个字符设备这件事,真正难的不是“怎么实现限制”,而是“设计驱动前你有没有把自己的设备归类想明白”。很多驱动被改来改去,就是因为一开始没想清楚:这个设备是单客户端独占,还是多客户端可分区?如果一开始就定好,Open策略、锁方案、private_data结构,都顺理成章。
哪怕你暂时只需要支持单open,我依然建议你把private_data的结构体先定义好放那儿。因为需求说变就变,一旦产品要支持多实例访问,没有会话隔离的设计改起来就是一次大手术。
最后再分享一个小技巧:我给新设备写驱动时,一定会先在open里打印一条带有“PID+session”的日志,release里同样打印。这样配合用户态测试工具,任何“多开”、“fork继承了fd”、“重复关闭”的异常都能从驱动日志一眼定位。别小看这个笨办法,它帮我省掉的排查时间,比写驱动本身还多。