基于树莓派5的 IoT 摄像头驱动开发的研究
过去这几年,自己对视频链路的理解基本停在安卓应用层。视频帧在 MediaCodec、Surface、GraphicBuffer 这些地方数据流转,如何实现滤镜、贴纸等是比较清晰的,但至于帧数据是如何在硬件层 GPU CPU 是如何流转的,以及摄像头捕获数据画面的话是如何在系统里面流转的,了解比较少,有诸多的疑问:
- 摄像头里的那一帧,进编码器之前到底躺在谁的内存里?
- 所谓零拷贝,是像素没被复印,还是 API 没让我看见这次复印?
- 特效写进了送给编码器的像素,还是留在显示控制器的另一层?这两种观众不一样。
GraphicBuffer、HWC、Surface在一块没有安卓的板子上,分别能指到什么?
没有理解硬件的数据流转这一层,后面再读 MediaCodec 的输入 Surface,还是只能停在一次调用上。遇到「为什么这里拷了一次」「为什么贴纸有的人看得到、有的人看不到」,也缺少理论知识的参考。
于是对硬件这块的能力进行了一些探索,树莓派 5、Ubuntu 24.04,先接 USB 摄像头,后来又接上 CSI 的 IMX219。学习到了 V4L2、libcamera、GStreamer、dma-buf 这些新东西。尝试写了一些 demo 跑起来之后,它就是一台能推流、能在运行时控制、还能送显的摄像头。
最终实现了什么?
最终跑通的是一条从传感器到观看端的摄像头通路。USB 摄像头或 CSI 上的 IMX219 把帧交进同一条 GStreamer 管道。管道一边把像素编成码流,经 RTSP 转到浏览器里播放,滤镜、贴纸、抓拍和人脸框都在这条流还活着的时候从旁边改;另一边把 dma-buf 交给显示控制器,由它自己扫到 HDMI。树莓派 5 上没有能用的 H.264 硬编,所以推流走软编。送显不经过这一步,而且在这块板子上和编码出口是二选一,要不就直接展示在 HDMI 要么利用 RTSP 可以外部进行链接预览播放摄像头的数据。
总体的流程分以下几个部分:
采集。 USB 摄像头走 V4L2,CSI 上的 IMX219 走 libcamera。两种入口把帧交进同一条 GStreamer 管道。设备承认什么格式、分辨率和帧率,管道就用什么。
为什么会有
V4L2和libcamera的两种形式?
USB 摄像头的固件已经输出 JPEG 或 YUV,应用用V4L2直接取帧。IMX219 从 CSI 送出来的是 raw,曝光和转成能看的画面要靠libcamera。V4L2是内核接口,libcamera是站在它上面的用户态相机栈。
推流。 管道把像素编成码流。浏览器里打开的是实时画面,可以多处预览,摄像头和编码器仍是这一份。网页侧用 WebRTC,延迟低一些;网络不好就切 HLS。浏览器不直接吃 RTSP,旁边用 MediaMTX 转一道。
送显。 同一帧的内存交给 HDMI。显示控制器自己去扫这块缓冲,CPU 不去把像素搬一遍。时间戳和动画是在输出的时候叠上去的。和推流二选一。
控制。 对数据流进行处理。流还在跑的时候,滤镜、贴纸、抓拍都能换。人脸框出现在打开控制台的网页上,推出去的那路流是干净的。USB 摄像头拔掉再插上,守护进程不用重启,页面也不用刷新,过一两秒到三秒,画面自己回来。
硬件示意图:
板子是 Raspberry Pi 5,系统是 Ubuntu 24.04。摄像头可以是 USB,也可以是 CSI 上的 IMX219。推流进浏览器,送显上 HDMI。
| 环节 | 借助什么 | 得到什么 |
|---|---|---|
| 采集 | V4L2 / libcamera |
送进管道的一帧 |
| 推流 | GStreamer 软编 H.264 / H.265,RTSP,MediaMTX |
浏览器里的 WebRTC / HLS,多人共用一路 |
| 送显 | dma-buf + 显示控制器 |
HDMI 上的画面,像素不经 CPU 搬运 |
| 控制 | 管道旁边的控制通道 | 流不停就能换滤镜、贴纸、抓拍、开关人脸 |
捕获到的视频帧是怎么进来的
USB 摄像头进 Linux,靠的是 V4L2。启动的时候先问这块驱动真正能给出什么:哪种格式,多大分辨率,多少帧。问完再挑一组最接近想要的。设备承认什么,管道就用什么。省得协商到一半,才发现摄像头根本给不出这个组合。
两条入口在这里汇合,再往后才分成推流和送显。
- USB 在分辨率高一点的时候,经常先给出 JPEG,后面再解成像素。这一步会写出一块新的图像缓冲。
- CSI 上的 IMX219 走
libcamera,帧以dma-buf出来。从这一步起,下一段硬件可以接同一块内存。
一帧离开传感器之后发生了什么
总结成一句话:
帧离开传感器后,进入同一条 GStreamer 管道。USB 经 V4L2 进来,CSI 经 libcamera 进来。出管道只有两条路:编成码流送到浏览器,或者把这块内存交给 HDMI。而换滤镜、抓拍这些是走的旁路逻辑,不改变帧往哪走。
USB 接口走 V4L2,CSI 接口走 libcamera。帧交出来以后,后面的处理、编码、显示都挂在同一条管道上。想看数据走到哪,就看这条管道,不用先钻进某一个元件。
两个消费端做的是不同的事。一个要把画面变成别人能打开的流,一个要把这块内存交给眼前的屏幕。缓冲还能不能被下一段硬件接走,和这帧要不要变成码流,在这块板子上是分开的。
安卓里,采集这一步通常收在 Camera HAL 后面,应用拿到的已经是交出来的帧。在这块板子上,格式、分辨率、帧率都是先问过设备,再往下走。
同一帧的两个出口
离开摄像头之后,主要看两个出口。
推流那一路,像素还会被滤镜写一遍,然后做成码流。树莓派 5 上没有能用的 H.264 硬编,所以这一路是软件 H.264,或者 H.265。码流先走 RTSP,浏览器打开的是 MediaMTX 转出来的 WebRTC 或 HLS。多一个人看,只是多一个观看端。摄像头和编码器还是原来那一份。
送显那一路,交出去的是这块内存本身。dma-buf 交给显示控制器的主平面,直接扫到 HDMI。CPU 不参与搬像素。硬编用不了的时候,HDMI 上照样有一条画面。
dma-buf 在这条通路里只做一件事:递出去的是这块内存的位置,下一段硬件自己去读。显示可以读它。GPU 画动画的时候,写的是另一块,再和主画面一起送出去。
也有几步绕不开,必须新写一块数据。USB 过来的 JPEG 要先解成像素。软编要把像素写成码流。发出去之前还要打成网络包。这几步是在生产新的数据。把原来那块内存的位置递下去,是另一件工作。
一个限制:在这块板子上,编码出口和 HDMI 直显是二选一。同一套采集,两种消费。上面把它们画在一起,是为了对照。两边并没有同时开着。
特效的三个落点
做安卓特效的时候,滤镜、贴纸、检测框经常收在同一套 API 下面。换一个参数,或者往 Surface 上画一层,调用上看起来差不多。到了这块板子上,同一类东西有三个位置。位置决定观众。
烤进画面里的,是 GLSL 滤镜和 libpag 贴纸。它们写在送给编码器的那一帧上。谁打开这路流,谁都看得到。把特效画进 MediaCodec 的输入,发生的就是这件事。滤镜写在同一个着色器里,切换只改变体,不用重新编译,画面也不用停下来重来。贴纸可以换文件、改文字,也可以把当前画面嵌进动画里。
挂在屏幕上的,是另一块缓冲,再加一个 overlay 平面。时间戳大概一秒画一次,就是一小块位图。PAG 动画由 GPU 画进另一块 dma-buf,挂到 overlay 平面上,和主画面在输出时由 HVS 合成到 HDMI。只有这块屏幕看得到。摄像头那一块缓冲还是原样。HWC 做设备合成,发生的就是这件事:合成在显示控制器里完成。
画在网页上的,是人脸框。检测跑在旁边一条帧率更低的支路上,坐标送到浏览器,框画在视频上面。打开控制台的人看得到。推出去的流不带框。
| 落点 | 谁画的 | 谁看得到 | 安卓里更接近 |
|---|---|---|---|
| 写进送给编码器的帧 | GLSL、libpag | 每一个打开流的人 | 特效画进 MediaCodec 输入 |
| HDMI 的 overlay 平面 | 小块位图、GPU 上的 PAG | 只有这块屏幕 | HWC 设备合成 |
| 网页上的视频上面 | 浏览器里的画布 | 打开控制台的人 | UI 叠在 Surface 上,不进码流 |
安卓上这三种经常糊在一次调用里。树莓派上它们是三条通路。以后再加一个特效,先问它落在哪。
运行时控制
换滤镜、换贴纸、截图、把人脸检测打开或关掉。都走控制通道,不重建媒体管道。视频帧继续往下送。
滤镜切变体不用重编译。贴纸可以在播放过程中换。抓拍从主路取出一帧,写成 JPEG。人脸检测跑在一条帧率更低的支路上,不改主路的像素。USB 拔掉再插上,守护进程和网页都还在,画面过一会儿自己回来。
守护进程、MediaMTX、网页是三个进程。画面这一路和控制这一路是分开的。一边停了,另一边还可以留着。
下面是实机上的样子。桌面和手机都能开控制台。
Web 控制台 · 桌面
|
控制台里切换
|
|
实时画面
|
人脸检测
|
|
再回来看 MediaCodec
开头那几个问题,在这块板子上都能对到一件具体的事。安卓里它们经常收在同一次调用里:Camera 交出一帧,configure 传一个 Surface,帧送进 MediaCodec,贴纸画在输入上,合成留到显示的时候。树莓派上没有这层封装,这些对象是分开的。
Camera / ImageReader 对应采集。 USB 走 V4L2,应用直接拿到 JPEG 或 YUV,接近 ImageReader 取出的一帧。CSI 上的 IMX219 走 libcamera,帧以 dma-buf 出来,接近 Camera 把一块 Surface 交给下一段,应用不必先把像素读回 CPU。
GraphicBuffer 对应 dma-buf。 这块缓冲底下是一个文件描述符,下一段硬件拿到的是内存的位置。送显把这个 fd 交给 HDMI,像素不用复印。推流要先把 JPEG 解成像素,再写成码流,这两步都会写出新的数据。
Gralloc 负责分配这块缓冲,这里是 libcamera 或 V4L2。BufferQueue 把同一块缓冲交给下一个环节,管道里的 tee 只增加引用。SurfaceTexture 让 GPU 把它当纹理用,读回 CPU 时就要拷贝。
Surface 是缓冲的交接口。 安卓里 configure(surface) 把解码结果交给显示,createInputSurface() 把 GPU 画完的帧交给编码器。这块板子上没有这个对象。送显时交出去的是 dma-buf 的 fd,推流时编码器吃的是软编之前那块 CPU 内存。
HWC 对应 overlay 平面。 安卓里设备合成是 HWC 把好几层 GraphicBuffer 交给显示硬件,不先在 GPU 里烘成一张图。这块板子上是同一件事:主画面在主平面,时间戳和 PAG 在 overlay 平面,HVS 在扫到 HDMI 之前把它们合在一起。摄像头那块缓冲没有被改写,打开流的人也看不到这块屏幕上的水印。特效挂在显示层上,和特效画进编码器输入,看到的人不是同一拨。
MediaCodec 在这块板子上只剩下软编。 Pi 5 没有能用的 H.264 硬编,推流是软件 H.264 / H.265 把像素写成码流,再经 RTSP 出去。安卓里一次 createInputSurface() 常常同时做两件事:让 GPU 往一块缓冲上画,再让编码器直接消费这块缓冲。这里这两件事是分开的。编码是软编;把内存位置交给下一段硬件发生在送显。两边还是二选一。
框架和名词解释
在接触整个硬件层的过程中学习到很多东西,这里再统一整理一下:
框架
V4L2 是 Linux 内核给视频采集设备的接口,全称 Video for Linux 2。应用打开 /dev/videoN,先问驱动支持哪些格式、分辨率和帧率,再把帧取出来。这篇里 USB 摄像头走的就是它。安卓 Camera HAL 下面,不少机型的相机驱动也停在这一层。
libcamera 是用户态的相机框架,用来管 CSI 相机那一套传感器和 ISP。树莓派上的 IMX219 不是插上就能当 UVC 用,需要 libcamera 去配置传感器,再把帧以 dma-buf 交出来。GStreamer 里对应的采集元件是 libcamerasrc。Ubuntu 24.04 源里的版本和 Pi 5 对不上,这篇里用的是自己编译的一份。
GStreamer 是把采集、处理、编码、显示、推流串成一条管道的多媒体框架。一个环节是一个 element,帧以 buffer 往下游传。这篇把它当成观察这条通路的地方:帧离开摄像头之后,推流和送显都挂在它上面。
MediaMTX 是一台媒体服务器。守护进程对外给的是 RTSP,浏览器不直接播这个协议。MediaMTX 把它转成 WebRTC 和 HLS,网页才能打开。好几个人看的时候,摄像头和编码器仍是原来那一条管道,分发发生在 MediaMTX 这一侧。
名词
| 名词 | 是什么 | 在这条通路里 |
|---|---|---|
| CSI | Camera Serial Interface,摄像头和主控之间的串行接口 | IMX219 接到树莓派上的那条线,不是 USB |
| IMX219 | Sony 的摄像头传感器,树莓派摄像头模组里很常见的一颗 | CSI 这一路的传感器,经 libcamera 出 dma-buf |
| UVC | USB Video Class,一类免驱的 USB 摄像头协议 | 插在 USB 口上的那颗摄像头 |
| dma-buf | Linux 里让 CPU、GPU、显示控制器共享同一块物理内存的机制,用户态拿到的是一个文件描述符 | 送显时把这块内存的位置交给显示控制器 |
| RTSP | 拉实时流常用的协议 | 守护进程监听的地址,浏览器用它之前要先转一道 |
| WebRTC | 浏览器里的低延迟实时通信 | MediaMTX 转给网页的主预览 |
| HLS | 把流切成一小段一小段文件来播,兼容性更好,延迟更高 | 同一路预览的兜底 |
| HVS | 树莓派显示控制器里做合成的硬件 | 主画面和 overlay 在输出到 HDMI 之前合到一起 |
| overlay 平面 | 显示控制器里叠在主画面之上的一层 | 时间戳和 PAG 动画挂在这里,摄像头那块缓冲保持原样 |
总结
树莓派 5 上先接了 USB,后来又接上 CSI 的 IMX219。采集是第一件要弄明白的事。USB 用 V4L2 就能取出已经做好的 JPEG 或 YUV,IMX219 送出来的是 raw,得靠 libcamera 配好再交成一帧。帧进了 GStreamer 之后,后面两件事才分开:推流是把像素写成码流,送给浏览器;送显是把 dma-buf 的位置交给 HDMI,显示控制器自己去读。特效也是走到这里才分开的。写进送给编码器的那一帧,看流的人都看得见;叠在 HDMI 的 overlay 上,只有这块屏幕看得见;人脸框画在网页上,推出去的流还是干净的。
同一次探索的代码在 vm_iot_camera 和 vm_camera_dma_buf。