4. Sensor驱动框架(V4L2):从架构到实战

好,咱们进入第四章。这一章我打算聊聊V4L2——Video for Linux 2。说实话,这玩意儿是Linux下视频设备驱动的基石。你调图像质量,写sensor驱动,绕不开它。

我刚开始接触V4L2的时候,觉得它就是个“大筐”,啥都往里装。后来踩了几个坑才明白,它的设计其实很精巧。说白了,V4L2就是给摄像头、视频采集卡这类设备,定了一套统一的接口规范。你写驱动,用户空间用标准API就能操作,不用管底层是啥硬件。

4.1 V4L2架构:三层结构

V4L2的架构,我习惯把它分成三层:

  • 用户空间层:应用程序调用的API,比如open、ioctl、mmap、poll。你写个拍照App,用的就是这层。
  • 内核核心层:V4L2框架本身。它负责管理设备节点(/dev/videoX)、处理ioctl命令、维护视频缓冲队列。这部分你不用动,内核已经写好了。
  • 设备驱动层:这才是咱们干活的地方。sensor驱动、ISP驱动、MIPI控制器驱动,都挂在这层下面。

这三层的关系,我画了张图,你看一眼就明白了。

V4L2 三层架构图 V4L2 三层架构 用户空间层 应用程序:open / ioctl / mmap / poll 设备节点:/dev/video0, /dev/video1, ... 内核核心层(V4L2框架) 设备节点管理 / ioctl分发 / 缓冲队列管理 videobuf2 / 事件机制 / 控制框架 设备驱动层 sensor驱动 / ISP驱动 / MIPI控制器驱动 子设备注册 / s_ctrl / 中断处理 帧同步 / 硬件控制 驱动开发者的主要战场:设备驱动层

嗯,这张图把层次关系说清楚了。你写sensor驱动,主要工作在第三层。但你要理解第二层怎么跟你交互,不然ioctl发下来你都不知道该干啥。

4.2 Sensor子设备注册

V4L2里有个重要的概念——子设备(subdev)。为啥要有子设备?你想,一个摄像头模组里,有sensor、有ISP、有闪光灯、有马达。如果每个都注册成一个独立的video设备,那应用层得打开好几个节点,协调起来太麻烦。

V4L2的做法是:把sensor注册成子设备,挂到主设备(比如ISP)下面。主设备负责跟应用层打交道,子设备负责干具体的活。

4.2.1 注册流程

注册一个sensor子设备,核心步骤就这几步:

  1. 分配v4l2_subdev结构体:用v4l2_subdev_alloc或者直接静态定义。
  2. 填充操作函数表:核心是v4l2_subdev_ops,里面包含core、video、pad等操作。
  3. 初始化子设备:调用v4l2_subdev_init。
  4. 注册到V4L2框架:调用v4l2_device_register_subdev。

我贴一段简化代码,你看个意思:

static const struct v4l2_subdev_ops sensor_subdev_ops = {
    .core = &sensor_core_ops,
    .video = &sensor_video_ops,
    .pad = &sensor_pad_ops,
};

static int sensor_probe(struct i2c_client *client) {
    struct sensor_dev *sensor = devm_kzalloc(&client->dev, sizeof(*sensor), GFP_KERNEL);
    v4l2_subdev_init(&sensor->sd, &sensor_subdev_ops);
    sensor->sd.owner = THIS_MODULE;
    snprintf(sensor->sd.name, sizeof(sensor->sd.name), "sensor_%s", client->name);
    v4l2_set_subdevdata(&sensor->sd, sensor);
    v4l2_device_register_subdev(sd->v4l2_dev, &sensor->sd);
    return 0;
}

这里有个坑,我提醒一下。v4l2_device_register_subdev调用之前,一定要确保父设备(v4l2_device)已经注册好了。我曾经在一个项目里,sensor先probe了,ISP还没起来,结果子设备注册失败,查了半天才发现是顺序问题。

4.3 控制链路:s_ctrl / g_ctrl

控制链路是V4L2里最常用的机制之一。说白了,就是应用层想设置sensor的某个参数——比如曝光时间、增益、帧率——通过ioctl发下来,驱动这边用s_ctrl回调接收。

反过来,应用层想读当前参数值,就用g_ctrl。

4.3.1 控制定义

每个控制都有一个ID,V4L2定义了一大堆标准控制:

控制ID 含义 典型范围
V4L2_CID_EXPOSURE 曝光时间 1 ~ 100000 (us)
V4L2_CID_GAIN 模拟增益 0 ~ 480 (dB*100)
V4L2_CID_FRAME_RATE 帧率 1 ~ 120 (fps)
V4L2_CID_HFLIP 水平翻转 0 / 1
V4L2_CID_VFLIP 垂直翻转 0 / 1

如果标准控制不够用,你可以定义私有控制。我建议尽量用标准控制,这样应用层不用猜你的私有ID是啥意思。

4.3.2 s_ctrl实现

s_ctrl回调的实现,核心就是根据控制ID,调用sensor的硬件操作函数:

static int sensor_s_ctrl(struct v4l2_ctrl *ctrl) {
    struct sensor_dev *sensor = container_of(ctrl->handler, struct sensor_dev, ctrl_handler);
    switch (ctrl->id) {
    case V4L2_CID_EXPOSURE:
        return sensor_set_exposure(sensor, ctrl->val);
    case V4L2_CID_GAIN:
        return sensor_set_gain(sensor, ctrl->val);
    case V4L2_CID_FRAME_RATE:
        return sensor_set_frame_rate(sensor, ctrl->val);
    default:
        return -EINVAL;
    }
}

这里有个细节要注意:s_ctrl可能在中断上下文被调用吗?答案是——不会。V4L2框架保证s_ctrl在进程上下文执行,所以你可以在里面调用i2c_transfer、msleep这些可能阻塞的函数。

我的习惯:在s_ctrl里只做参数校验和硬件配置。如果配置需要耗时(比如PLL重新锁定),我会用工作队列异步处理,避免阻塞应用层太久。

4.4 帧同步与中断处理

帧同步是sensor驱动里最考验实时性的部分。sensor每输出一帧图像,硬件会触发一个中断。驱动在中断处理函数里,要完成:

  • 读取帧号/时间戳:记录当前帧的硬件信息。
  • 更新缓冲队列状态:把填满数据的buffer标记为done,通知应用层取走。
  • 启动下一帧采集:如果还有buffer排队,就触发下一帧。

4.4.1 中断处理流程

我画个流程图,把中断处理的逻辑串起来:

帧同步中断处理流程 帧同步中断处理流程 VSYNC中断触发 读取帧号 / 时间戳 buffer出队 / 标记done 唤醒等待队列 (poll) 启动下一帧采集 注意:中断处理函数必须快速返回,耗时操作请使用tasklet或workqueue

4.4.2 中断处理代码骨架

实际代码里,中断处理函数大概长这样:

static irqreturn_t sensor_irq_handler(int irq, void *dev_id) {
    struct sensor_dev *sensor = dev_id;
    struct vb2_buffer *vb;
    unsigned long flags;

    // 1. 读取硬件状态,确认是帧同步中断
    u32 status = readl(sensor->base + REG_IRQ_STATUS);
    if (!(status & BIT_VSYNC)) {
        return IRQ_NONE;
    }

    // 2. 记录时间戳
    struct timespec64 ts;
    ktime_get_real_ts64(&ts);

    // 3. 从队列取出当前buffer
    spin_lock_irqsave(&sensor->queue_lock, flags);
    vb = list_first_entry_or_null(&sensor->active_list, struct vb2_buffer, queued_entry);
    if (vb) {
        list_del(&vb->queued_entry);
        vb2_set_plane_payload(vb, 0, sensor->frame_size);
        vb2_buffer_done(vb, VB2_BUF_STATE_DONE);
    }
    spin_unlock_irqrestore(&sensor->queue_lock, flags);

    // 4. 唤醒poll等待队列
    wake_up(&sensor->wait_queue);

    // 5. 清除中断标志
    writel(BIT_VSYNC, sensor->base + REG_IRQ_CLEAR);

    return IRQ_HANDLED;
}
我曾经踩过的坑:在中断处理函数里调用vb2_buffer_done时,一定要确保buffer确实是从active_list里取出来的。有一次我忘了检查list是否为空,结果空指针解引用,系统直接panic。嗯,从那以后我每次都会加list_empty判断。

4.5 实战经验总结

最后,我把自己做sensor驱动的一些经验列出来,供你参考:

  • 子设备注册顺序:先注册父设备(ISP/主控),再注册子设备(sensor)。反过来会导致子设备找不到父设备。
  • 控制链路的同步:多个控制可能同时被设置(比如曝光和增益一起调)。我习惯在s_ctrl里加一个mutex,保证硬件配置的原子性。
  • 中断处理要快:中断里只做最必要的事——读状态、更新buffer、清中断。耗时操作(比如I2C通信)放到workqueue里做。
  • 时间戳精度:帧同步中断里记录的时间戳,尽量用硬件时间戳(如果sensor支持),或者用ktime_get_real_ts64。别用jiffies,精度不够。

好了,这一章的内容就到这儿。V4L2的框架其实不复杂,但细节很多。你写驱动的时候,多看看内核里现成的sensor驱动代码,比如ov5640、imx219这些,能少走不少弯路。