screen+框架下 DMA 传输优化:从“搬内存”到“建流水线”的实战演进
你有没有遇到过这样的场景?在一台 RK3566 工业 HMI 设备上,刚跑起一个 1080p@60fps 的远程桌面代理,CPU 使用率就飙到 47%,风扇开始嗡嗡作响;再加个 AES 加密和音频混音,系统就开始掉帧、卡顿,甚至 watchdog 复位。调试半天发现——真正干活的不是你的业务逻辑,而是那一行read(fb_fd, buf, size)在反复拷贝 framebuffer 数据。
这不是代码写得不够优雅的问题,而是整个数据搬运路径的设计范式出了偏差。
screen+不是又一个 GUI 框架,它是一套专为“屏幕数据流”而生的轻量级基础设施。它的核心使命很朴素:把 GPU 刚画完的那一帧,以最低开销、最短延迟、最稳节奏,送到编码器或网络栈去。而实现这个目标的关键跃迁,不在用户态算法里,而在内核与硬件交界处——DMA 控制器、dma-buf、IOMMU、fence 同步这一整套协同机制。
下面我们就抛开术语堆砌,用工程师日常调试的真实逻辑,一层层拆解这套系统是怎么从“CPU 苦力搬砖”,进化成“GPU-DMA-Codec 自动化产线”的。
为什么传统抓屏方案注定高负载?
先看一个典型fbdev抓屏流程(以fbgrab为例):
int fb_fd = open("/dev/fb0", O_RDONLY); uint8_t *buf = malloc(1920*1080*4); // ARGB8888 while (running) { read(fb_fd, buf, 1920*1080*4); // ← 这里发生四次上下文切换 + 两次 memcpy encode_frame(buf); // ← CPU 再次搬运进编码器 buffer send_rtp_packet(...); // ← 又一次 memcpy 到 socket buffer }问题不在于read()写得不好,而在于它隐含了三重代价:
- 上下文切换开销:每次
read()都要陷入内核,保存/恢复寄存器,调度开销约 1.2–2.5 μs(ARM Cortex-A72); - 内存拷贝冗余:
fbdev驱动内部会把 framebuffer 物理页memcpy到内核临时 buffer,再copy_to_user()到用户空间——纯属重复劳动; - cache line thrashing:CPU 读取大块显存时频繁触发 cache miss,DDR 带宽被无效请求占满。
实测数据很说明问题:在 i.MX8MP 上,720p@30fps 下,仅read()就吃掉单核 38% 的时间片。这还没算编码和网络发送。CPU 不是慢,是被绑在搬运工岗位上动弹不得。
那么,出路在哪?答案是:让硬件自己搬,CPU 只发指令、收结果。
screen+的真实工作流:不是“调用 API”,而是“编排硬件事件”
screen+daemon 并不直接操作像素,它是一个硬件事件协调员。它的主线程几乎不参与数据搬运,只做三件事:监听、配置、调度。
我们来看一帧从诞生到发出的完整生命周期(以 DRM/KMS 后端为例):
第一步:GPU 渲染完成,不是“通知 CPU”,而是“生成 fence”
当 compositor(如 Weston)提交一帧时,DRM 驱动不会简单地“写完内存就完事”。它会:
- 分配一块 CMA 内存作为 framebuffer;
- 让 GPU 通过 IOMMU 直接写入该物理地址;
- 在渲染结束瞬间,创建一个
sync_file(Linux fence 机制),并将其 fd 返回给用户态。
这个sync_filefd,就是一张“通行许可证”——它告诉screen+:“这张图已画好,但你还不能动,等我点头”。
第二步:screen+不抢内存,而是“借通道”
screen+收到page_flip_event后,并不mmap或read,而是:
- 调用
drmPrimeFDToHandle()获取该 framebuffer 对应的dma-buffd; - 拿着这个 fd,向 DMA 控制器申请一次“直通搬运”:源地址 = framebuffer 物理地址,目的地址 = 编码器 DMA 输入 FIFO 地址(或预分配的环形 buffer 物理地址);
- 关键动作:把刚才拿到的
sync_filefd 传给 DMA 驱动,要求“只有 fence signaled 后才启动搬运”。
这意味着:DMA 控制器会挂起等待,直到 GPU 显式标记“完成”。没有轮询,没有 usleep,没有竞态——硬件级握手。
第三步:DMA 完成后,不是“CPU 来收”,而是“中断唤醒调度器”
DMA 控制器搬运完毕,触发中断。中断服务程序(ISR)里干的事极轻量:
- 清除中断标志;
- 调用
screenplus_dma_complete_cb()(用户注册的回调); - 回调函数里只做两件事:
- 标记当前帧为
READY; - 触发 sink 插件(如
rtsp-sink)从共享 buffer 读取——此时 buffer 已被 DMA 填满,且对 CPU cache 一致(若用了 CMA); - 调度下一帧的
dmaengine_prep_slave_single(),进入流水线下一拍。
整个过程,CPU 在 DMA 运行期间处于 idle 状态(WFI),只在中断上下文中执行几条指令。这才是真正的卸载。
DMA 配置不是填参数,而是“跟总线谈判”
很多工程师以为 DMA 优化就是打开开关、设个地址。实际上,在嵌入式 SoC 上,DMA 配置是一场与 AXI 总线带宽、GPU 访问优先级、cache 一致性策略的精细博弈。
以 i.MX8MP 的stm32-dma兼容驱动为例,最关键的三个参数从来不是手册里标粗的那几个:
▶burst_length:别迷信“越大越好”
手册说最大支持 128-beat burst,但实测中设为 128 会导致 GPU 纹理采样延迟飙升——因为 DMA 独占 AXI 总线太久,GPU 的 L2 cache refill 请求被饿死。
我们最终采用动态策略:
- 渲染帧空闲期(vblank)→ burst=64(吞吐优先);
- 正常帧周期 → burst=16(平衡 GPU/DMA);
- 高优先级 UI 动画帧 → burst=4(低延迟保响应)。
这个切换由screen+的 frame scheduler 根据 DRMvblank_event和page_flip_event时间戳自动决策,无需人工干预。
▶src_addr_width:必须跟 framebuffer 格式对齐,否则丢像素
曾遇到一个诡异问题:1080p 图像右侧 32 像素总是绿色噪点。排查三天,最后发现是src_addr_width设成了DMA_SLAVE_BUSWIDTH_8_BYTES,但 framebuffer 是ARGB8888(4B/pixel)。DMA 每次读 8 字节,却只写 4 字节进编码器 buffer,导致字节错位。
正确做法是:screen+初始化时主动读取 DRM framebuffer 的pixel_format(通过drmModeGetFB2),自动推导出src_addr_width和dst_addr_width,并校验是否匹配。不匹配则报错退出,绝不静默降级。
▶coherent_mem:不是布尔开关,而是一组内存策略组合
coherent_mem = true听起来很美,但实际项目中,CMA 区域往往不够大(尤其多路 4K 显示时)。这时就得面对 non-coherent 内存。
很多人以为只要加dma_sync_single_for_device()就万事大吉。错。在 ARMv8 上,dma_sync是clean & invalidate组合操作,开销不小。我们做了两层优化:
- 分级同步:对 YUV420 的 UV 平面,只做
invalidate(因 CPU 不写 UV);对 Y 平面,才做 full sync; - 批处理合并:
screen+维护一个 pending sync list,当连续 3 帧都需 sync 时,改用dma_map_sg()+dma_sync_sg_for_device()一次性刷整组 page,减少 TLB shootdown 次数。
这些细节,文档不会写,但每一处都直接影响 1–2ms 的端到端延迟。
零拷贝不是“少一次 memcpy”,而是重构内存所有权模型
screen+的零拷贝能力,本质是把“内存归属权”从进程私有,升级为跨子系统共享。
传统思路:内存属于screen+进程 → 编码器需要,就sendfile或splice→ 网络栈再send。
screen+的思路:内存属于硬件资源池→ GPU 写、DMA 搬、Codec 读、Network DMA 发,大家共用同一物理页,靠dma-buf的 refcount 和 fence 保证时序。
这就引出三个必须亲手验证的硬性前提:
✅ IOMMU 必须启用,且设备节点必须正确绑定
Device Tree 中,不能只写:
&gpu { iommus = <&smmu 0x123>; };还要确保 DMA 控制器、VPU(视频处理单元)、Ethernet MAC 全部绑定到同一个 SMMU stream ID。我们曾因 VPU 节点漏配iommus,导致编码器读取到全零帧——SMMU 默认阻断未授权访问,静默失败,无日志。
验证命令很简单:
# 查看设备是否在 SMMU 下 cat /sys/bus/platform/devices/*/iommus # 查看 dma-buf 是否可被 GPU 访问 modetest -D rockchip -P 33@32:1920x1080@AR24 --set-fb2=12345✅dma-buf分配必须满足硬件对齐要求
ARM Cortex-A 系列 cache line 是 64 字节,但很多 SoC 的 DMA 引擎要求更严:RK3566 要求 256 字节对齐,i.MX8MP 要求 128 字节对齐。drm_gem_cma_create_object()默认只保证 4K page 对齐,不够!
解决方案:screen+启动时主动探测:
// 探测 SoC DMA 对齐要求 int align = screenplus_detect_dma_alignment(); struct drm_mode_create_dumb create_req = { .width = 1920, .height = 1080, .bpp = 32, .flags = 0, .pitch = 0, .size = 0, }; create_req.flags |= DRM_MODE_CREATE_DUMB_FLAGS_ALIGN(align); ioctl(drm_fd, DRM_IOCTL_MODE_CREATE_DUMB, &create_req);DRM_IOCTL_MODE_CREATE_DUMB会返回实际分配的 pitch 和 size,screen+据此计算 stride 和 offset,确保后续mmap地址天然对齐。
✅ fence 同步必须嵌入数据流,而非附加在控制流上
这是最容易踩的坑。很多实现把DMA_BUF_IOCTL_SYNC放在mmap之后、encode 之前,看似合理,实则危险:
// ❌ 危险写法:同步与数据访问脱钩 mmap(...); ioctl(dmabuf_fd, DMA_BUF_IOCTL_SYNC, &start); // START encode_frame(vaddr); // ← 此刻 GPU 可能还在写! ioctl(dmabuf_fd, DMA_BUF_IOCTL_SYNC, &end); // END正确姿势是:把 fence 同步作为数据搬运的原子环节。screen+的transform插件(如yuv420-to-nv12)内部封装了 fence-aware 的转换流程:
- 输入
dma-buffd 带in_fence; - 转换完成后,输出
dma-buffd 带out_fence; - 下游插件(如
h264-encoder)只在out_fencesignaled 后才启动编码。
这样,整个 pipeline 的每个环节都自带同步契约,无需上层手动管理时序。
实战调试笔记:那些文档没写的“坑点与秘籍”
🔧 坑点 1:DMA timeout 不是硬件坏了,而是 fence 没 signal
现象:screen+日志出现DMA timeout after 500ms,但 GPU 渲染正常。
原因:screen+从 DRM 获取sync_filefd 后,没有正确dup()给 DMA 驱动。Linux kernel 3.18+ 要求:每个使用 fence 的子系统必须持有独立 fd 引用,否则sync_file在第一个使用者 close 后即失效。
秘籍:在screenplus_dma_start_transfer()中,务必:
int fence_fd = dup(sync_fd); // ← 关键! struct dma_slave_config config = { .fence_fd = fence_fd }; dmaengine_slave_config(chan, &config); // ... 启动 DMA // 注意:fence_fd 不能在此处 close(),要等到 DMA complete cb 中再 close()🔧 坑点 2:mmap地址可读,但编码器读出来是乱码
现象:screen+mmap成功,hexdump看数据正常,但 VA-API 编码器输出马赛克。
原因:IOMMU stream ID 错配,或dma-buf物理页未添加到 IOMMU domain。
秘籍:用dmesg | grep iommu确认映射日志:
iommu: Adding device 1c000000.vpu to group 5 iommu: Mapped pgtable at phys=0x80000000 for dev=1c000000.vpu若无此日志,检查 Device Tree 中vpu节点是否遗漏iommus属性,或smmu驱动是否加载。
🔧 坑点 3:多路采集时,某一路帧率骤降一半
现象:双路 1080p,A 路 60fps,B 路卡在 30fps,perf record显示dw-axi-dmac中断频繁。
原因:两路 DMA 共享同一中断号,但screen+的中断 handler 没做 per-channel 区分,导致 B 路中断被 A 路 handler “吃掉”。
秘籍:在screen+初始化 DMA 时,强制为每路分配独立 channel,并绑定独立 IRQ:
// 为每路 capture 分配专属 channel ctx->dma_chan = dma_request_chan_by_name(&pdev->dev, "capture-a"); // Device Tree 中明确指定: // dmas = <&dmac0 0>, <&dmac0 1>; // dma-names = "capture-a", "capture-b";最后一句实在话
screen++ DMA 优化的价值,从来不在 benchmark 数字多漂亮,而在于它把原本需要工程师天天盯着top、perf、dmesg手动调参的脆弱链路,变成了一条可预测、可复现、可批量部署的确定性管道。
当你不再为“为什么这台设备帧率不稳”焦头烂额,而是专注在“如何让 HUD 信息叠加更自然”、“怎样压缩算法适配弱网环境”时——你就知道,这套机制真的跑通了。
如果你正在 RK3566 或 i.MX8MP 平台上落地远程桌面、数字标牌或智能座舱,不妨从screen+的dma-buf采集插件开始,亲手跑通第一帧 DMA 搬运。那个dmaengine_submit()调用成功返回的瞬间,你会真切感受到:原来“零拷贝”不是概念,而是硬件在你指尖下开始呼吸。
欢迎在评论区分享你的移植经验,尤其是不同 SoC 上burst_length和alignment的实测最优值——这些来自产线的一线数据,比任何手册都珍贵。