drm+v4l2零拷贝:嵌入式Linux视频采集显示性能优化实战

drm+v4l2零拷贝:嵌入式Linux视频采集显示性能优化实战
简介在嵌入式Linux开发中摄像头数据的高效处理与显示常受多次内存拷贝困扰而drmv4l2零拷贝方案正是解决这一问题的有效手段。资源内含capture.cpp源文件演示如何利用v4l2_buffer的m.userptr字段将摄像头缓冲区映射到用户空间并借助DRM的DMA-BUF机制映射到GPU地址空间使数据无需经过CPU即可由GPU直接访问真正实现零拷贝。压缩包仅1个cpp文件大小4KB轻量易读涵盖v4l2设备初始化、捕获格式设置、DRM帧缓冲分配、ioctl挂接缓冲区及启动捕获等关键步骤。目前已有3178人学习下载适合嵌入式工程师参考尤其适用于监控系统、视频会议、增强现实等实时性要求高的场景有助于快速掌握零拷贝原理并复用代码框架降低开发成本。 在嵌入式Linux上折腾摄像头采集到屏幕显示这条链路几乎是每个做多媒体、工业视觉、车载方案的兄弟都绕不过去的坎。最近我在一块RK平台的板子上把v4l2采集drm显示的完整通路调通了中间用到的就是标题里这个组合drmv4l2零拷贝。这个需求看起来简单实际难点在于视频帧怎么从v4l2驱动框架“无缝”交给drm子系统显示。如果按最直接的做法采集缓冲是一份内存显示缓冲是另一份内存中间CPU搬运一遍720p30fps还能忍一到1080p60fps就直接卡到怀疑人生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 buffer2次高低新手好上手分辨率低、帧率低、验证功能v4l2 mmap dmabuf导出 drm prime导入0次极低中需要理解dma-buf嵌入式产品落地首选v4l2 dmabuf直接输出 显示专用buffer0次极低高依赖驱动能力深度定制、专业视频设备第一条路的成本我已经算过了。第二条路就是今天文章的主角v4l2侧通过VIDIOC_EXPBUF把采集buffer导出成dma-buf fddrm侧通过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子系统到底是什么drmDirect 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驱动框架的角色v4l2Video4Linux2是Linux内核的视频采集框架。它把摄像头、HDMI采集卡、ISP这类视频源抽象成/dev/videoX设备节点通过标准的ioctl接口跟用户态交互。采集侧核心概念是“buffer”驱动维护一块环形缓冲区队列硬件DMA把图像数据填进buffer用户态通过VIDIOC_DQBUF取到一帧处理完再用VIDIOC_QBUF把buffer还回去继续采。v4l2的buffer有三种内存模型V4L2_MEMORY_MMAP由驱动分配内存用户态mmap访问V4L2_MEMORY_USERPTR由用户态传地址驱动直接往地址里DMAV4L2_MEMORY_DMABUF由用户态传入外部dma-buf fd驱动直接采集到外部buffer。这里要特别强调一下我们做“采集-显示”零拷贝用的是MMAPEXPBUF而不是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-buf3.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宽度的NV12bytesperline可能是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在offsetw*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*hpitch全填成宽offset当作0。结果往往是整个画面变成绿屏或者上下劈开。还有个隐蔽问题pitches必须以drm驱动接受的对齐为准而v4l2返回的bytesperline正是camera DMA写入时的行字节数这两者一定要对齐。如果摄像头是1920宽的NV12bytesperline1920填给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. 后续还能怎么扩展这套drmv4l2零拷贝的框架打底之后能扩展出不少玩法。比如多个摄像头拼接显示每个摄像头一帧对应一个overlay plane通过drm的plane属性控制位置、缩放、透明度就能做简单的多路拼接墙全程依旧零拷贝。再比如把dma-buf fd通过socket传给另一个进程的drm上下文可以实现多进程显示方案把“采集”和“显示”彻底解耦成独立模块视频通路做成插件化架构。我在实际调试中最大的体会是零拷贝的代码本身不难难的是一整套流程里对“所有权”和“生命周期”的管理。dma-buf fd、gem handle、fb_id三层句柄任何一个生命周期没理清要么泄漏fd要么显示跟采集打架。建议写代码时把每个buffer的状态机画清楚idle - queued - captured - displaying - idle每一帧都在这个状态机里流转这样调试时候的思路会清晰很多。本文还有配套的精品资源点击获取

最新新闻

日新闻

周新闻

月新闻