最近调试一个视频采集驱动的时候,又被/dev/videoX节点的生命周期问题恶心了一把:驱动明明注册成功了,dmesg里也打印了正常初始化信息,但用户在应用层打开节点时,ioctl偶尔就会直接把系统搞崩;换成另一个平台,节点干脆就不出现。问题排查到最后,矛头全部指向同一个结构体——video_device。只要把 V4L2 子系统里这个最核心的数据结构吃透,搞明白它的注册、注销、回调机制,视频驱动开发中相当一部分疑难杂症都能顺着逻辑理出线索。这篇内容适合正在写 V4L2 驱动、或者对 Linux 视频框架感兴趣的同学,我今天把video_device相关的函数和数据结构完整拆一遍,尽量讲清楚它背后的设计和容易踩的坑。
1. video_device 在V4L2框架中的定位:设备和节点是两回事
1.1 v4l2_device 与 video_device 的分工
很多刚开始接触 V4L2 驱动的同学,会把v4l2_device和video_device搞混,觉得这两个东西差不多。实际上它们的职责有非常明确的边界:v4l2_device是更上层的“容器”,代表一个完整的视频硬件实例或者一组逻辑上归属同一硬件模块的设备;而video_device才是真正对应用户空间节点的那个实体,对应/dev/video0、/dev/video1这样的字符设备。
打个比方,v4l2_device像是一套房子的产权登记,它告诉你这套房子属于谁、在哪一栋;而video_device是挂在门口的那个门牌号,用户要进屋,必须先找到门牌号。一个v4l2_device下面可以挂多个video_device,比如同时支持采集和输出的设备,通常会创建两个video_device节点,一个给 capture,一个给 output。反过来,一个video_device也一定归属且仅归属一个v4l2_device。
在驱动代码里,这个归属关系通过video_device的v4l2_dev字段来建立。注册时内核会检查这个字段是否已经初始化,如果没有挂到某个v4l2_device上,注册过程会直接报错。所以写驱动时的第一个顺序问题就是:先初始化v4l2_device,再初始化并注册video_device,顺序不能反过来。
1.2 video_device 到底负责哪些事情
从职责边界来看,video_device承担了四件核心事情。
第一,它向 VFS 层提供file_operations,也就是用户态open、read、write、ioctl、mmap、poll这些系统调用最终会进到内核的哪一组函数,都是由video_device的fops字段决定的。
第二,它维护字符设备相关的内核对象,包括struct device、struct cdev以及主次设备号。字符设备框架要求一个设备对应一个cdev,video_device内部就嵌了这些基础设施,注册时内核会帮你把 cdev 创建好并添加到系统中。
第三,它承接 V4L2 ioctl 的分发逻辑。V4L2 框架把VIDIOC_QUERYCAP、VIDIOC_S_FMT、VIDIOC_STREAMON等一大堆 ioctl 的解析和处理集中在了核心层,核心层再通过video_device里的ioctl_ops回调到你驱动自己实现的函数上。驱动本身不需要去逐个解析_IOR/_IOWR编码。
第四,它是驱动私有数据和应用层之间的桥梁。通过video_get_drvdata和video_set_drvdata,可以在video_device上挂载驱动自定义的结构体指针;在用户态打开节点后,V4L2 核心层会把文件指针的private_data设置成这个video_device,这样后续所有操作都能从文件对象一路找回驱动上下文。
简单说,video_device就是 V4L2 驱动面向内核 VFS 层的一张“门面”,所有外部可见的设备行为,都通过它来组织和转发。
2. struct video_device 核心字段逐个拆解
2.1 fops、ioctl_ops 和 lock 的分工合作
struct video_device里最容易被忽略但又最关键的一组字段,是fops、ioctl_ops和lock。这三个字段往往决定了一个驱动是稳定运行还是频繁崩溃。
fops指向struct file_operations,它定义的是最底层的系统调用入口。V4L2 核心层在v4l2-dev.c里维护了一组公共的 fops,包括v4l2_open、v4l2_read、v4l2_write、v4l2_ioctl、v4l2_mmap、v4l2_poll等。驱动在编写自己的fops时,通常会把大部分成员直接赋值为这些核心函数,然后只覆盖少数需要特殊处理的成员。比如:
static const struct file_operations my_fops = { .owner = THIS_MODULE, .open = v4l2_open, .unlocked_ioctl = video_ioctl2, .read = v4l2_read, .mmap = v4l2_mmap, .poll = v4l2_poll, };这里需要注意的是video_ioctl2。它本身不是v4l2_*库函数,而是 V4L2 核心提供的 ioctl 分发器。v4l2_ioctl和video_ioctl2在历史上有个区别:v4l2_ioctl是老的持锁版本,video_ioctl2是推荐的新版分发入口。现在的新内核里,两者差别已经越来越小,但驱动代码里用video_ioctl2更常见。
ioctl_ops指向struct v4l2_ioctl_ops,这组 ops 才是驱动真正要实现的 ioctl 处理逻辑。video_ioctl2在收到一个 ioctl 命令后,会先做一层通用处理,比如判断设备类型、检查权限、填充struct v4l2_format的默认值,然后根据命令号从ioctl_ops对应的回调函数转发给驱动。例如VIDIOC_QUERYCAP对应ioctl_ops->vidioc_querycap,VIDIOC_S_FMT对应ioctl_ops->vidioc_s_fmt_vid_cap。
lock则是驱动提供的一把互斥锁,类型是struct mutex *。这个锁的语义很关键:V4L2 核心层在调用驱动的 ioctl 回调前后,会自动完成mutex_lock和mutex_unlock。也就是说,只要你在video_device里设置了lock,核心层就帮你保证同一时刻只有一个 ioctl 在执行,驱动里的vidioc_*回调函数就不需要再互相加锁。这个设计大大简化了驱动的并发处理。但也有一个副作用:如果某个回调内部又去调用另一个需要同样锁的路径,就可能造成死锁。我自己就遇到过在vidioc_s_fmt里同步等待中断处理完,而中断处理又尝试调用同一个驱动里另一个 ioctl 路径的情况,最后只能把锁拆细,或者用trylock来处理。
2.2 device_caps 与 vfl_dir:用户态眼中的设备类型
device_caps是一个u32位图,它描述了这个设备节点对外宣称的能力。常见的包括V4L2_CAP_VIDEO_CAPTURE、V4L2_CAP_VIDEO_OUTPUT、V4L2_CAP_STREAMING、V4L2_CAP_READWRITE等。用户在应用层调用VIDIOC_QUERYCAP时,内核会把这组能力返回过去。注意内核在返回之前还会把驱动自身的能力做一次逻辑合并:video_device的device_caps表示单个节点能力,而v4l2_device级别的capabilities可以表示整颗芯片的能力。
vfl_dir是enum v4l2_video_type,它的取值决定了节点在内核里的设备类型,也就是/dev下节点的前缀和 minor 号的分配范围。常见取值有:
| 取值 | 节点前缀示例 | 说明 |
|---|---|---|
VFL_TYPE_VIDEO | /dev/video0 | 常规视频采集/输出设备,也是用得最多的类型 |
VFL_TYPE_VBI | /dev/vbi0 | 垂直消隐期数据,多见于老式模拟采集卡 |
VFL_TYPE_RADIO | /dev/radio0 | 收音机设备 |
VFL_TYPE_SUBDEV | /dev/v4l-subdev0 | 子设备节点,用于媒体控制器框架 |
VFL_TYPE_SDR | /dev/swradio0 | 软件无线电设备 |
VFL_TYPE_TOUCH | /dev/v4l-touch0 | 触摸传感器设备 |
这里需要特别注意:vfl_dir直接影响video_register_device第三个参数的缺省分配逻辑。如果驱动把采集设备错误地设置成VFL_TYPE_VBI,内核会把它注册成/dev/vbi0,应用层按/dev/video0去找就永远找不到。这属于比较隐蔽的配置错误,编译期不会报错,只有检查注册后的节点才能发现。
2.3 v4l2_dev、parent 与 dev 的归属链路
video_device里还有一组描述设备拓扑的字段:v4l2_dev、dev_parent、以及内嵌的struct device dev。
v4l2_dev指向它所属的struct v4l2_device上层容器。dev_parent则是设备模型里的父设备指针,通常指向平台设备或者 PCI 设备对应的struct device。内嵌的dev是video_device自身在设备模型里的实体,它的 parent 指针会被设置成dev_parent,这样 sysfs 里就能呈现出“平台设备 -> v4l2_device -> video_device”这样的层级结构。
在内核设备模型里,video_device的dev字段同时承担了生命周期管理的职责。注册时,内核会调用device_register把这个设备添加到内核设备模型中,并创建对应的 sysfs 目录;注销时,再通过device_unregister移除。理解这一点对驱动生命周期管理很重要:video_device的释放不能想当然地直接 kfree,必须走完设备模型的注销流程,等引用计数归零后,再由release回调来真正释放内存。
我在早期写驱动的时候就犯过这个错:自以为注册完的video_device可以直接 kfree,结果设备模型里还保留着指向这块内存的引用,模块卸载后系统很快出现野指针崩溃。正确的做法永远是:把release回调设置好,让设备模型在最后一步释放时调用它。
2.4 name、index、minor:命名与编号背后的小心机
name字段会出现在 sysfs 和内核日志里,通常设置成类似"my-video-capture"这样的可读字符串。它和用户态看到的/dev/videoX节点名不是一回事,但用户态通过VIDIOC_QUERYCAP的card字段可以看到它,所以不要随便乱填。
index是设备节点在同类设备里的序号。minor是次设备号。在注册新设备时,如果video_register_device的第三个参数传的是-1,内核会根据vfl_dir自动寻找空闲的 minor 号,然后index也会根据同类型设备的计数自动递增。index的语义对用户态有一个重要影响:很多应用层程序会用index来区分同一块硬件上的多个视频节点,比如一个摄像头模组既有主采集又有辅助采集,用户态希望video0是主采集、video1是辅助采集。如果驱动注册顺序不稳定,index可能发生错位,这时候就要考虑在设备树或驱动参数里固定编号,而不是完全依赖自动分配。
3. 一次注册背后发生了什么:从 video_register_device 到 /dev/videoX
3.1 video_register_device 的完整调用链
video_register_device是整个video_device注册流程的入口,函数原型如下:
int video_register_device(struct video_device *vdev, enum v4l2_video_type type, int nr);type参数与vdev->vfl_dir是对应的,nr是请求的设备编号。nr = -1表示自动分配,nr >= 0表示尝试使用指定编号,如果该编号已被占用,注册会失败或被替换,具体取决于内核版本和配置。
__video_register_device这个核心函数内部会依次做几件事:
// 简化后的调用顺序 int __video_register_device(struct video_device *vdev, ...) { /* 1. 基本参数检查 */ if (vdev->v4l2_dev == NULL) return -EINVAL; /* 2. 分配次设备号 */ vdev->minor = video_nr[type]; // 自动分配时从全局位图取空闲号 /* 3. 创建字符设备 */ vdev->cdev = cdev_alloc(); vdev->cdev->ops = &v4l2_fops; /* 4. 注册设备模型 */ vdev->dev.vfl_type = type; device_register(&vdev->dev); /* 5. 创建 /dev 节点 */ device_create(...); // 生成 video0 这样的设备节点 }第一步会检查v4l2_dev是否已经设置。很多初学者在注册前忘了给video_device设置v4l2_dev,结果注册直接返回-EINVAL,dmesg又看不到具体原因,只能一脸懵。第二步是从内核维护的次设备号位图里找一个空闲 minor,不同vfl_type有不同的 minor 段。第三步创建一个cdev,它的 fops 指向核心层的v4l2_fops,而不是驱动自己的fops。这里有个容易误解的地方:驱动自己的fops并不会直接挂在 cdev 上,cdev 的 fops 固定是核心的v4l2_fops,核心再根据video_device内部的转发逻辑,把真正的工作交给驱动实现的函数。第四步和第五步则是把设备模型和/dev节点打通。
3.2 老式分配与嵌入式初始化的选择差异
在较老的内核版本里,驱动通常用video_device_alloc()动态分配video_device,然后用video_device_release()作为release回调来释放。这种模式到今天仍然大量存在于现网驱动中,代码形态大概是:
struct video_device *vdev; vdev = video_device_alloc(); if (!vdev) return -ENOMEM; vdev->fops = &my_fops; vdev->v4l2_dev = &mydev->v4l2_dev; vdev->ioctl_ops = &my_ioctl_ops; vdev->device_caps = V4L2_CAP_VIDEO_CAPTURE | V4L2_CAP_STREAMING; vdev->vfl_dir = VFL_TYPE_VIDEO; vdev->release = video_device_release; vdev->queue = &mydev->vb2_queue; vdev->lock = &mydev->mutex; ret = video_register_device(vdev, VFL_TYPE_VIDEO, -1);而新内核更推荐把struct video_device直接嵌入到驱动自定义的上下文结构体里,然后用vb2_video_device_register或者手动初始化:
struct my_device { struct v4l2_device v4l2_dev; struct video_device vdev; struct vb2_queue queue; struct mutex mutex; ... }; static void my_vdev_release(struct video_device *vdev) { struct my_device *mydev = container_of(vdev, struct my_device, vdev); /* 补充收尾工作 */ } /* probe 中初始化 */ vfd = &mydev->vdev; vfd->fops = &my_fops; vfd->v4l2_dev = &mydev->v4l2_dev; vfd->ioctl_ops = &my_ioctl_ops; vfd->device_caps = V4L2_CAP_VIDEO_CAPTURE | V4L2_CAP_STREAMING; vfd->vfl_dir = VFL_TYPE_VIDEO; vfd->release = my_vdev_release; vfd->queue = &mydev->queue; vfd->lock = &mydev->mutex; ret = video_register_device(vfd, VFL_TYPE_VIDEO, -1);两种方式的本质差别在于内存所有权:老方式下video_device是独立分配的,框架释放时直接 kfree;新方式下video_device是某个大结构体的成员,释放时必须通过container_of找回整个结构体并释放。如果release回调写错,比如在新方式里仍然调用了老的video_device_release,内核就会在错误的内存地址上执行 kfree,结果往往是指针崩溃。实践建议是:新写的驱动统一用嵌入方式,release回调里做完整结构体的释放;维护老驱动时再沿用老的分配 API。
3.3 注销流程与 release 回调为什么不能省略
注销函数是video_unregister_device(struct video_device *vdev)。注销时,内核会先移除设备节点和 sysfs 属性,然后把video_device的引用计数降下来。这里有个关键细节:如果用户空间正持有这个设备的文件描述符,video_unregister_device不会立即释放内存,它会等待所有 fd 全部关闭。只有最后一个引用被释放后,设备模型才调用video_device->release回调。
所以release回调是绝对不能省略的。如果你不设置release,内核在引用计数归零时不知道该怎么释放这块内存,就会产生一个警告,同时大概率泄漏内存或者留下悬空指针。我在调试一些第三方驱动时,见过不少驱动作者把release设成NULL,以为只要注册时成功就够了,结果模块反复加载卸载几次之后,系统出现refcount_t相关的报错,问题就在这里。
正确的注销顺序应该是:先调用video_unregister_device断开对外可见的节点,再释放驱动内部的缓冲区、停止中断等资源,最后等待release回调来完成结构体本身的释放。因为release回调可能在用户关闭 fd 时才被触发,所以在release回调里去访问已经被清理的中断和 DMA 资源,是不安全的。稳妥的做法是,把硬件资源释放放在注销函数里,把结构体内存释放放在release回调里。
4. 注册 API 家族怎么选:不同场景下的姿势对比
4.1 video_register_device 的三个参数到底怎么传
video_register_device的第三个参数nr是一个经常被忽视的参数。它表示驱动希望注册为第几个同类设备。传-1就是完全交给内核自动分配,一般不建议传具体数字,除非你有明确的用户空间约定,比如某个设备必须固定在/dev/video0。
传具体编号时,要注意nr与vfl_dir的关系。内核实际上按类型分开管理编号空间,VFL_TYPE_VIDEO的video0和VFL_TYPE_RADIO的radio0互不影响。如果同一个类型里编号已经存在,注册可能会失败或者替换掉旧设备。替换行为在新内核里默认是允许的,但容易造成用户态瞬时看到节点消失和重建,对稳定性要求高的产品最好避免依赖这种覆盖逻辑。
另一个实际建议:尽量在probe阶段完成注册,不要在设备工作到一半时反复注册注销。节点注册和注销是有成本的,而且会对用户态的udev产生事件风暴。某些场景下需要支持多个实例,比如同一颗芯片挂两路采集通道,那就应该在循环里按顺序注册两个video_device,并给index做好区分,而不是共用一个结构体。
4.2 devm 版本接口用起来需要留个心眼
新内核提供了devm_video_register_device(),它把注销动作绑定到设备生命周期上,驱动在probe失败或remove时不需要手动调用video_unregister_device。对简化错误处理路径很有帮助,尤其是那些需要逐步回滚的驱动,可以用devm接口少写不少代码。
但用devm接口有个需要小心的地方:它的注销顺序和release回调之间的关联。因为注册设备的生命周期跟随父设备,如果驱动的remove里还有等待线程、中断处理等异步资源,而这些异步资源又引用了video_device,那么在父设备释放时,video_device可能先于异步资源被注销,导致异步代码访问到已经拆掉的设备对象。也就是说,devm只保证“设备被注销”,不保证“引用的其他资源还在”。
所以我个人的建议是:devm_video_register_device适合生命周期简单、没有复杂异步逻辑的驱动;如果驱动里有 DMA 中断回调、工作队列、高频率内核线程访问视频节点,还是手动管理video_unregister_device的位置更可靠,可以把注销动作放在所有异步资源停止之后再执行。
4.3 驱动私有数据与 video_device 的绑定方式
V4L2 框架提供了一对很重要的工具函数:
void video_set_drvdata(struct video_device *vdev, void *data); void *video_get_drvdata(struct video_device *vdev);这两个函数实际上是把data存储到vdev->dev.driver_data里。需要注意的是,注册前后的调用时机有讲究:video_set_drvdata可以在注册前设置,但最好在probe里注册完成后统一设置一次,然后在所有用到video_get_drvdata的地方都确认video_device没有被注销。因为 V4L2 核心层在open后会把video_device放到文件对象的private_data里,用户态后续调用 ioctl 时,核心层再把private_data取出来转成video_device指针。驱动在自己的回调函数里,一般通过video_get_drvdata获取自己的上下文结构体:
int my_vidioc_streamon(struct file *file, void *fh) { struct video_device *vdev = video_devdata(file); struct my_device *mydev = video_get_drvdata(vdev); ... }绑定好私有数据之后,一个常见的设计问题是:fh和video_get_drvdata返回的上下文到底有什么区别。V4L2 核心层在open时还会调用v4l2_fh_open,它会把struct v4l2_fh文件句柄结构挂在file->private_data里,而fh是这个句柄结构。驱动可以在vidioc_open回调里通过fh拿到v4l2_fh,进而访问事件订阅、优先级管理等功能。但驱动自己的业务上下文,通常还是用video_get_drvdata从video_device取。两者不要混为一谈,否则很容易出现类型不匹配导致崩溃。
5. 用户空间打开 /dev/videoX 后,内核走了哪些路
5.1 open() 之后发生了什么
用户空间执行open("/dev/video0", ...)后,VFS 根据 cdev 找到注册时挂载的核心 fops,也就是v4l2_fops里的v4l2_open。核心v4l2_open会做三件事:创建并初始化struct video_device对应的文件句柄上下文、把vdev设置到file->private_data、然后调用驱动vidioc_open回调(如果ioctl_ops里有定义)。
static int v4l2_open(struct inode *inode, struct file *filp) { /* 从 inode 找回 video_device */ struct video_device *vdev = video_devdata(inode); /* 创建并初始化 v4l2_fh */ ret = v4l2_fh_init(&fh, vdev); ... filp->private_data = fh; /* 调用驱动 open 回调 */ if (vdev->ioctl_ops->vidioc_open) ret = vdev->ioctl_ops->vidioc_open(filp, fh); ... }在这个流程里,驱动自定义的 fops 并不会直接出现在调用链上,真正的入口永远是核心的v4l2_fops。所以你在驱动 fops 里写的.open必须是v4l2_open或一个包装了v4l2_open的函数。如果驱动想拦截 open 事件做额外的初始化,正确的方式是实现ioctl_ops->vidioc_open,而不是自己重写 fops 的 open 函数。
5.2 ioctl 分发链路:VIDIOC_QUERYCAP 从哪来回哪去
当用户空间调用ioctl(fd, VIDIOC_QUERYCAP, &cap)时,系统调用会先进入video_ioctl2。该函数会完成参数合法性检查,然后根据命令码找到ioctl_ops里对应的回调。VIDIOC_QUERYCAP对应的回调是vidioc_querycap。
有意思的是,V4L2 核心层里VIDIOC_QUERYCAP的处理逻辑并不是简单的“拷贝驱动返回值”,而是分两步:核心先根据video_device的device_caps和v4l2_device的capabilities填充struct v4l2_capability里的能力和卡信息,然后把控制权交给驱动的vidioc_querycap,让驱动可以修改变量值或补充自定义信息。所以驱动里实现vidioc_querycap时,不需要自己盲目清空结构体,标准做法是先调用核心提供的默认填充函数,再覆盖个别字段。
static int my_vidioc_querycap(struct file *file, void *fh, struct v4l2_capability *cap) { struct video_device *vdev = video_devdata(file); strscpy(cap->driver, "my_driver", sizeof(cap->driver)); strscpy(cap->card, "My Capture Card", sizeof(cap->card)); snprintf(cap->bus_info, sizeof(cap->bus_info), "platform:%s", dev_name(vdev->dev.parent)); return 0; }5.3 sysfs 和 uevent 里能观察到什么
video_device注册成功后,会出现在 sysfs 对应的设备目录下。通过readlink查看/dev/videoX,可以回溯到/sys/devices/.../video4linux/video0。在这里可以看到name、index、dev(主次设备号)等信息。
uevent会在设备节点创建时发出。用户态的udev规则可以通过环境变量里的MAJOR、MINOR、V4L_INDEX等字段为视频节点创建自定义符号链接。比如多媒体产品常用的做法,是根据设备的name创建/dev/camera_main这样稳定的链接,避免应用层写死/dev/videoN。
这里有个常见需求:希望在uevent里带上更多硬件属性。实现方式是设置video_device里dev的groups或者自定义属性组。注意这些 sysfs 属性的生命周期同样挂在video_device上,属性读取函数里不能访问已经释放的驱动上下文。
6. 调试中反复踩过的 video_device 相关坑
6.1 注册成功但 /dev 下没有节点
有一次排查一个驱动,dmesg里没有报错,注册函数返回值也是 0,但/dev/video0就是不存在。最后发现是设备树里给这个平台设备配置了status = "disabled",平台总线根本没有执行probe,而驱动模块却被手动加载了,注册函数返回的成功来自一个没有被真正启用的实例。这类问题一般要先去检查probe是否执行、父设备的dev是否注册成功。
另一个导致节点不出现的原因是device_create阶段的错误被video_register_device吞掉或打印不明显。这时候可以打开内核的动态调试,聚焦观察v4l2-dev.c里的日志。实际操作中,我会先用ls -l /dev/video*看节点是否存在,再在/sys/class/video4linux/看有没有对应的目录。如果 sysfs 目录存在但/dev下没有节点,多半是udev规则没生效;如果 sysfs 目录都不存在,问题基本出在注册内部。
6.2 vfl_dir 设错导致节点类型完全不对
另外一个生僻但真实发生过的坑,是把一个高清采集设备错误地设置为VFL_TYPE_VBI。当时驱动注册正常,/dev下出现的不是video0,而是vbi0。应用层一直尝试打开/dev/video0,于是总是报“设备不存在”。这种问题的隐蔽性在于它只表现为找不到节点,而不是直接报错。
排查时我建议在probe完成后立即打印vdev->vfl_dir和vdev->minor,和实际出现在/dev下的节点名对照,基本可以定位。V4L2 框架在创建节点时用vfl_dir选择设备号前缀,所以任何vfl_dir与硬件实际功能不匹配的配置,都会产生这种“错位”效果。
6.3 fops 或者 ioctl_ops 为空导致的系统崩溃
驱动里如果vdev->fops没有赋值就注册,内核在某些版本上不会立即报错,只有用户态open时才通过空指针调用函数,直接触发 oops。ioctl_ops为空的情况更隐蔽,因为注册时可能刚好没有走到需要ioctl_ops的路径,但用户态一个VIDIOC_QUERYCAP就会崩。
这里要养成的习惯是:在注册前集中设置所有字段,并且做一个防御性检查,类似这样:
WARN_ON(vdev->fops == NULL); WARN_ON(vdev->ioctl_ops == NULL); WARN_ON(vdev->release == NULL); WARN_ON(vdev->v4l2_dev == NULL);这些检查在调试早期能节省大量时间。特别是用嵌入方式管理video_device时,很多字段因为结构体零初始化而默认是 NULL,一不留神就会漏设。
6.4 注销顺序错乱引发的 use-after-free
最后讲一个最让我印象深刻的坑:驱动卸载时,先释放了 DMA 缓冲区,再调用video_unregister_device。表面看顺序合理,但实际上用户态还持有一个未关闭的 fd。注销调用只是把设备从系统中移除,release回调要等到用户关闭 fd 才执行。而驱动自己的上下文结构体是在remove函数里直接释放的,结果用户关闭 fd 时,V4L2 核心层访问已经释放的结构体,系统直接崩溃。
正确的顺序应该倒过来:在remove里先调用video_unregister_device,让内核把所有引用计数都降下来,然后再释放 DMA 缓冲区和驱动上下文。如果驱动里有中断和工作队列,必须先停止中断、flush 工作队列,再注销设备。我通常的做法是:第 1 步停止所有硬件活动;第 2 步video_unregister_device;第 3 步释放资源和上下文;第 4 步等待release回调里只做最后的容器结构体释放或日志收尾。
另外提醒一个使用container_of的细节:在release回调里拿到struct my_device指针后,如果这个结构体还包含kref或者其他引用计数管理,要确保release回调只被调用一次。多路径释放同一个嵌入结构体,container_of找出来的地址虽然是对的,但重复释放仍然会导致内存损坏。
这组数据结构我在不同项目和平台上反复用过多次,说句实话,video_device本身的代码量不大,复杂的是它跟设备模型、字符设备框架、ioctl 分发器和用户态生命周期之间的耦合。写驱动之前先把这些交互理清,后面遇到问题时会从容很多。最后再分享一个经验:在probe函数的开头打一条包含设备树节点名的日志,在整个驱动生命周期里给每个video_device一个可识别的name,排查问题时能少走很多弯路。