简介:在嵌入式Linux开发中,摄像头数据的高效处理与显示常受多次内存拷贝困扰,而drm+v4l2零拷贝方案正是解决这一问题的有效手段。资源内含capture.cpp源文件,演示如何利用v4l2_buffer的m.userptr字段将摄像头缓冲区映射到用户空间,并借助DRM的DMA-BUF机制映射到GPU地址空间,使数据无需经过CPU即可由GPU直接访问,真正实现零拷贝。压缩包仅1个cpp文件,大小4KB,轻量易读,涵盖v4l2设备初始化、捕获格式设置、DRM帧缓冲分配、ioctl挂接缓冲区及启动捕获等关键步骤。目前已有3178人学习下载,适合嵌入式工程师参考,尤其适用于监控系统、视频会议、增强现实等实时性要求高的场景,有助于快速掌握零拷贝原理并复用代码框架,降低开发成本。 在嵌入式Linux上折腾摄像头采集到屏幕显示这条链路,几乎是每个做多媒体、工业视觉、车载方案的兄弟都绕不过去的坎。最近我在一块RK平台的板子上把v4l2采集+drm显示的完整通路调通了,中间用到的就是标题里这个组合:drm+v4l2零拷贝。这个需求看起来简单,实际难点在于视频帧怎么从v4l2驱动框架“无缝”交给drm子系统显示。如果按最直接的做法,采集缓冲是一份内存,显示缓冲是另一份内存,中间CPU搬运一遍,720p@30fps还能忍,一到1080p@60fps就直接卡到怀疑人生,CPU占用率飙到80%以上。本文把这个方案从原理到代码完整拆一遍,适合正要调视频通路、被性能问题卡住的同学参考。
1. 为什么必须做零拷贝
1.1 传统方案到底慢在哪
先捋一下最常见、最朴素的视频通路实现方式。很多初学嵌入式视频开发的人,第一版代码通常是v4l2采集一帧,从内核缓冲区mmap到用户空间,拿到一帧yuv数据,然后调用memcpy拷进显示缓冲,再交给drm显示。这个流程在逻辑上完全正确,但性能上非常不划算。
举个具体数字例子:1080p的NV12图像,一帧数据量是1920*1080*3/2 = 3110400字节,约3MB。如果每秒30帧,CPU每秒钟需要搬运约90MB数据;60帧就是180MB。别觉得180MB不多,在ARM嵌入式平台,比如常见的Cortex-A53/A55,内存拷贝大概能到2-4GB/s,看似够用,但拷贝链路里经过L2 cache、内存控制器,多个总线竞争,再叠加摄像头DMA、显示控制器DMA同时读写内存,总线带宽立马吃紧。而且CPU干完拷贝就干不了别的了,整个系统卡顿感非常明显。
更关键的是,这个“内存搬运”在很多SoC上是完全可以避免的。摄像头sensor写入的是物理内存,显示控制器读取的也是物理内存,都是走DMA的外设,只要让它们指向同一块物理内存,数据根本不需要经过CPU。这就是零拷贝的核心逻辑:消除数据通路上的CPU复制,让外设DMA直接交换数据。
1.2 三种实现路径的对比
在嵌入式Linux里,摄像头到显示器的零拷贝方案跑过不止一种,这里把常见的三条路放一起对比,方便大家选型。
| 方案 | 拷贝次数 | CPU占用 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| v4l2 mmap + memcpy + drm dumb buffer | 2次 | 高 | 低,新手好上手 | 分辨率低、帧率低、验证功能 |
| v4l2 mmap + dmabuf导出 + drm prime导入 | 0次 | 极低 | 中,需要理解dma-buf | 嵌入式产品落地首选 |
| v4l2 dmabuf直接输出 + 显示专用buffer | 0次 | 极低 | 高,依赖驱动能力 | 深度定制、专业视频设备 |
第一条路的成本我已经算过了。第二条路就是今天文章的主角:v4l2侧通过VIDIOC_EXPBUF把采集buffer导出成dma-buf fd,drm侧通过DRM_IOCTL_PRIME_FD_TO_HANDLE把fd导入成gem handle,再创建framebuffer并完成显示。两条腿走路,中间没有任何image data的拷贝。第三条路更偏底层,需要驱动层面配合,一般做产品方案时才会碰。
选第二条路还有一个现实原因:现在主流SoC厂商(Rockchip、Allwinner、Amlogic、NXP等)的v4l2驱动和drm驱动都已经支持dma-buf互通,这是一条被广泛验证过的成熟路径。只要代码写对,不依赖厂商私有API,可移植性很强。
2. 先理清三个核心概念
2.1 drm子系统到底是什么
drm(Direct Rendering Manager)是Linux内核的显示子系统,现代Linux图形栈的基石。从内核角度看,它管理GPU/显示控制器的显存、分辨率、刷新率、图层等资源。用户空间的libdrm库封装了一套ioctl,让我们可以通过/dev/dri/card0这个设备节点操作它。
drm里几号角色得先认清:CRTC负责把内存里的画面按时序扫描输出到显示器;Encoder负责把CRTC输出的信号编码成物理接口协议(HDMI、DSI、LVDS等);Connector代表物理连接器,管理显示器是否插入、支持哪些分辨率;Plane则对应当前帧的画面层,一个屏幕通常有primary plane和多个overlay plane。
做显示的时候流程是固定的:拿到resources -> 找到connected的connector -> 找到可用的encoder和crtc -> 挑一个mode -> 创建framebuffer -> 绑到crtc上。这个流程看似长,但每一步都有固定套路,后面代码里会说。
2.2 v4l2驱动框架的角色
v4l2(Video4Linux2)是Linux内核的视频采集框架。它把摄像头、HDMI采集卡、ISP这类视频源抽象成/dev/videoX设备节点,通过标准的ioctl接口跟用户态交互。采集侧核心概念是“buffer”,驱动维护一块环形缓冲区队列,硬件DMA把图像数据填进buffer,用户态通过VIDIOC_DQBUF取到一帧,处理完再用VIDIOC_QBUF把buffer还回去继续采。
v4l2的buffer有三种内存模型:V4L2_MEMORY_MMAP由驱动分配内存,用户态mmap访问;V4L2_MEMORY_USERPTR由用户态传地址,驱动直接往地址里DMA;V4L2_MEMORY_DMABUF由用户态传入外部dma-buf fd,驱动直接采集到外部buffer。这里要特别强调一下:我们做“采集->显示”零拷贝用的是MMAP+EXPBUF,而不是DMABUF模式。DMABUF模式是相反方向的应用,通常是显示/GPU侧把buffer给v4l2驱动写数据用,后面会再解释这个区别。
2.3 dma-buf:整个方案的核心粘合剂
dma-buf机制是Linux内核专门为外设共享内存设计的框架。它允许不同设备驱动之间共享一块物理内存,每个设备都拿到一个可以“导入”的句柄(fd),但内存只有一份。dma-buf还提供了缓存同步机制,确保不同设备在访问共享buffer时的cache一致性能满足要求。
用生活化方式理解:dma-buf就像一块“公共黑板”,v4l2驱动和drm驱动都可以在黑板上写或者读,但黑板只有一块。v4l2的DMA引擎把摄像头画面直接写到黑板上,drm显示控制器再从同一块黑板上把画面读走,CPU全程不碰这块黑板。这就是整个零拷贝方案能成立的最底层机制。
3. 实操:v4l2侧导出dma-buf
3.1 前置条件与驱动能力确认
动手写码之前,先确认板子上的驱动链路是通的。打开摄像头和drm设备后,第一步要检查v4l2设备能力:
struct v4l2_capability cap; ioctl(v4l2_fd, VIDIOC_QUERYCAP, &cap); if (!(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE) || !(cap.capabilities & V4L2_CAP_STREAMING)) { // 当前设备不是视频采集streaming设备,直接退出 }要支持dma-buf导出,驱动必须实现vidioc_expbuf回调,几乎所有现代v4l2驱动都支持。但稳妥起见,还是用VIDIOC_EXPBUF的实际调用结果为准。另一个需要注意的点是drm设备权限,显示帧缓冲需要drm master权限才能操作CRTC完成真正的显示,如果是在ssh终端或后台服务里跑,可能需要root或者video组权限,否则drmModeSetCrtc会报EACCES。
3.2 申请缓冲区和导出fd的关键步骤
先把v4l2的采集格式配置好,这个不是重点,但格式直接影响后面drm侧的参数,必须先把目标格式确认清楚。摄像头输出NV12还是YUYV,决定了drm侧添加framebuffer时用哪个fourcc。我的板子上摄像头默认输出NV12,那就以NV12为例继续。
接着是核心步骤:申请缓冲区。注意,这里虽然最终目的是导出dma-buf,但申请buffer时还是要走V4L2_MEMORY_MMAP,驱动分配内存并管理,只是我们不再通过mmap把它映射到用户空间去读,而是用VIDIOC_EXPBUF把它导出成fd。
struct v4l2_requestbuffers req = {0}; req.count = 4; // 4个buffer,后面说为什么不是2或3 req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; req.memory = V4L2_MEMORY_MMAP; if (ioctl(v4l2_fd, VIDIOC_REQBUFS, &req) < 0) { // 申请失败,多半驱动不支持该buffer数量 } // 逐个buffer查询并导出dma-buf fd int dma_fds[4]; for (int i = 0; i < 4; i++) { struct v4l2_buffer buf = {0}; buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory = V4L2_MEMORY_MMAP; buf.index = i; ioctl(v4l2_fd, VIDIOC_QUERYBUF, &buf); struct v4l2_exportbuffer exp = {0}; exp.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; exp.index = i; exp.flags = O_RDONLY; if (ioctl(v4l2_fd, VIDIOC_EXPBUF, &exp) < 0) { // 驱动不支持EXPBUF,说明该平台无法走这条路 } dma_fds[i] = exp.fd; }这里多提一句bytesperline的事。VIDIOC_QUERYBUF返回的buf.bytesused、buf.m.offset、buf.length都别丢,尤其是从struct v4l2_pix_format里拿的bytesperline,它代表一行数据实际占多少字节。很多硬件要求行对齐,比如1920宽度的NV12,bytesperline可能是1920,也可能是1924、 1984等对齐值。这个值等下传给drm侧当pitch用,填错了画面直接斜掉花屏。
3.3 采集循环的正确用法:dqbuf与qbuf的时机
常规采集循环很容易写,但零拷贝链路里buffer的入队时机有讲究。最稳的做法是:一开始把所有buffer都QBUF进队列,启动STREAMON,然后循环DQBUF拿帧。DVQid不存在。代码如下:
// 所有buffer入队 for (int i = 0; i < 4; i++) { struct v4l2_buffer buf = {0}; buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory = V4L2_MEMORY_MMAP; buf.index = i; ioctl(v4l2_fd, VIDIOC_QBUF, &buf); } enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE; ioctl(v4l2_fd, VIDIOC_STREAMON, &type); while (1) { struct v4l2_buffer buf = {0}; buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory = V4L2_MEMORY_MMAP; ioctl(v4l2_fd, VIDIOC_DQBUF, &buf); // 阻塞等待一帧 int idx = buf.index; // 零拷贝关键:把第idx个buffer对应的dma_fds[idx]交给drm显示 display_frame(dma_fds[idx], idx); ioctl(v4l2_fd, VIDIOC_QBUF, &buf); }相当多的人在这里会翻车:DQBUF拿到一帧后马上memcpy再QBUF,这样做零拷贝就白做了,因为内存的搬运已经发生了。要忍住,别拷贝。在第idx个buffer还在屏幕上显示、还没被page flip替代之前,不要急着QBUF它,否则摄像头DMA会把新一帧数据写到正在被显示控制器读取的内存上,出现画面撕裂。这个“显示完成再还buffer”的同步,我放在第5部分讲,那是全链路最容易踩的坑。
4. 实操:drm侧导入并显示
4.1 打开drm设备与资源枚举
drm侧的操作从打开设备开始。通常用第一个card节点/dev/dri/card0。然后按固定套路拿到资源、找到显示器连接的connector。这个过程比较机械,但很容易在crtc选择上出问题,尤其是多屏或者HDMI没插好的时候。
int drm_fd = open("/dev/dri/card0", O_RDWR); drmModeRes *res = drmModeGetResources(drm_fd); if (!res) { // 别继续了,先确认drm设备正常 } drmModeConnector *conn = NULL; for (int i = 0; i < res->count_connectors; i++) { conn = drmModeGetConnector(drm_fd, res->connectors[i]); if (conn->connection == DRM_MODE_CONNECTED) { // 找到接显示器的那一个 break; } } drmModeEncoder *enc = NULL; for (int i = 0; i < res->count_encoders; i++) { enc = drmModeGetEncoder(drm_fd, res->encoders[i]); if (enc->encoder_id == conn->encoder_id) { break; } } int crtc_id = enc->crtc_id; drmModeModeInfo mode = conn->modes[0]; // 选第一个分辨率enc->crtc_id未必非零。有些encoder没被绑定到crtc,或者当前显示状态下没有分配crtc,导致这个值是0。更可靠的做法是根据enc->possible_crtcs位掩码去res里挑一个crtc,不过多数双屏场景下,按照“connector->encoder->crtc”这条路走,够用。
4.2 prime fd转gem handle:从v4l2到drm的跨越
现在我们手里有v4l2侧导出的dma_fds[idx],这是dma-buf的fd。要让drm识别这块内存,必须把它导入成gem handle。gem是drm的显存管理机制,显存对象在用户态体现为gem handle,后续创建framebuffer时填的就是handle而不是fd。
uint32_t gem_handle; int ret = drmPrimeFDToHandle(drm_fd, dma_fds[idx], &gem_handle); if (ret) { // 导入失败,常见原因是drm驱动不支持PRIME导入 }这里有个容易忽略的点:drmPrimeFDToHandle把同一个fd多次导入,多次调用返回的gem_handle是同一个,因为内核会为同一个dma-buf缓存对应的gem对象。所以初始化时预先为4个dma_fds各导入一次得到4个gem_handle,显示循环里直接用就行,不必每帧都调ioctl。
4.3 创建framebuffer并完成显示
framebuffer是drm拿来描述“一块内存要按什么格式显示”的对象,它关联内存、宽高、格式、行距、偏移这些信息。以NV12为例,NV12是半平面格式,Y平面一整个区域,UV平面是另外一块区域。所以drmModeAddFB2需要填两个plane的信息。
uint32_t handles[4] = {gem_handle, gem_handle, 0, 0}; uint32_t pitches[4] = {v4l2_bytesperline, v4l2_bytesperline, 0, 0}; uint32_t offsets[4] = {0, width * height, 0, 0}; // NV12: Y在offset0, UV在offset=w*h uint32_t fb_id; ret = drmModeAddFB2(drm_fd, width, height, DRM_FORMAT_NV12, handles, pitches, offsets, &fb_id, 0); if (ret) { // 创建失败,先查格式和pitch是否匹配 }这里最容易错的是把NV12当成packed格式,只填一个plane,填两个handle都指向同一块内存offset一个为0一个为w*h,pitch全填成宽,offset当作0。结果往往是整个画面变成绿屏或者上下劈开。还有个隐蔽问题:pitches必须以drm驱动接受的对齐为准,而v4l2返回的bytesperline正是camera DMA写入时的行字节数,这两者一定要对齐。如果摄像头是1920宽的NV12,bytesperline=1920,填给drm是没问题的;但有些ISP输出的bytesperline可能是2064这种奇怪的数,如果drv的primary plane不认,就要走overlay plane。
创建完framebuffer后,首帧用drmModeSetCrtc上屏,后续用page flip切换:
// 首帧 drmModeSetCrtc(drm_fd, crtc_id, fb_id, 0, 0, &conn->connector_id, 1, &mode); // 后续帧:page flip到新悬的fb drmModePageFlip(drm_fd, crtc_id, new_fb_id, DRM_MODE_PAGE_FLIP_EVENT, NULL);注意page flip是异步的,第二帧我们是创建了新fb,但旧fb在本次flip完成后、下一次flip前都可以释放,否则会闪烁或黑屏。更优雅的工程做法是给每个v4l2 buffer在初始化时创建好一个对应的drm framebuffer对象和gem handle,显示循环里直接flip到对应的fb,避免每帧都创建销毁。这样做既减少系统调用,又天然保证显存池固定,性能更稳。
5. 联调中常见的坑
5.1 buffer数量和帧率失配
采集端用几个buffer,这个抉择直接影响显示的流畅度和延迟。常见的选择是3-4个。2个buffer在采集和显示都跑满帧率时,经常出现一方还在等buffer、另一方已经把buffer用掉的情况,画面卡顿明显。我这次直接用了4个,实测下来就算摄像头30fps、屏幕60Hz这种不对称场景,稳定性也好很多。
帧率失配时的核心原则是“宁可多等一帧,不可少一个可用buffer”。屏幕刷新率比摄像头帧率高没问题,显示器会反复显示最新一帧,画面是流畅的,只是没有60帧的丝滑感。反过来,如果摄像头帧率比显示高,就必须让page flip的触发受到vblank事件节流,不然从camera dqbuf到flip的循环会积聚,延迟会越来越大。
5.2 cache一致性问题
做了零拷贝,不等于代码就完事了。ARM平台上,dma-buf缓冲可能涉及cache一致性问题。摄像头DMA是异步硬件访问,如果它有cache属性(比如内核把buffer映射为write-back),那么DMA写的内容对CPU或显示控制器来说可能存在cache line和主内存不一致的情况。平时我们用mmap读v4l2 buffer时,驱动已经处理好了同步;但把buffer交给drm时,显示控制器的读路径也走DMA,一般硬件会通过系统总线保持一致,所以很多时候能直接跑通。
真正出问题的场景是:你把buffer导入drm后又用CPU去读(比如做OpenGL纹理上传、做人脸检测输入),这时候就要记得显式调用dmabuf的sync ioctl,或者通过其他同步接口保证数据一致。实操建议是:零拷贝链路里,尽量别让CPU碰这份数据,一旦CPU读了,就要承担cache同步的责任,做不好就是莫名的花屏、图像错位。
5.3 排查顺序:从错误码到波形
如果联调时显示不出画面,按这个顺序排查,效率最高。第一步看错误码,drmModeAddFB2返回EINVAL十有八九是格式/pitch/offset不对;返回ENOSPC通常是显存不足,减少buffer数量或者降低分辨率。第二步确认drm用的plane支持NV12格式。很多SoC的primary plane只支持ARGB8888、RGB888这类RGB格式,NV12要靠overlay plane,用drmModeGetPlane查一下plane支持的格式列表,别一头扎进drmModeSetCrtc。第三步确认时序,用示波器或者逻辑分析仪看MIPI CSI和显示接口的同步信号,这一步已经到硬件级,但确实遇到过摄像头时钟配置不稳导致的上屏花屏。
提示:以上排查思路按“软件栈由下至上、内核到用户态”的顺序来,每步都要验证,不要直接重编内核。遇到问题先在现有驱动能力范围内找原因,通常是参数不匹配,不是驱动bug。
6. 后续还能怎么扩展
这套drm+v4l2零拷贝的框架,打底之后能扩展出不少玩法。比如多个摄像头拼接显示,每个摄像头一帧对应一个overlay plane,通过drm的plane属性控制位置、缩放、透明度,就能做简单的多路拼接墙,全程依旧零拷贝。再比如把dma-buf fd通过socket传给另一个进程的drm上下文,可以实现多进程显示方案,把“采集”和“显示”彻底解耦成独立模块,视频通路做成插件化架构。
我在实际调试中最大的体会是:零拷贝的代码本身不难,难的是一整套流程里对“所有权”和“生命周期”的管理。dma-buf fd、gem handle、fb_id三层句柄,任何一个生命周期没理清,要么泄漏fd,要么显示跟采集打架。建议写代码时把每个buffer的状态机画清楚:idle -> queued -> captured -> displaying -> idle,每一帧都在这个状态机里流转,这样调试时候的思路会清晰很多。
本文还有配套的精品资源,点击获取