十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Rockchip平台scrcpy黑屏根因:DMA-BUF内存泄漏分析与修复

Rockchip平台scrcpy黑屏根因:DMA-BUF内存泄漏分析与修复 1. 黑屏卡死现场还原工位机不是“突然罢工”而是被无声拖垮那天下午三点十七分产线测试工位的 Android 工控机屏幕毫无征兆地变黑——不是重启不是崩溃弹窗就是彻底静止触控无响应ADB 连接断开连物理按键长按都唤不醒。我放下手头正在调试的 OTA 升级脚本走过去敲了敲屏幕边框确认不是背光故障插上 USB 线电脑端adb devices列表里那个熟悉的序列号已经消失用另一台设备 ping 它的 IP通但adb connect失败telnet也连不上任何端口。这不是系统级死锁也不是 Kernel Panic串口 console 仍有日志输出而是一种更隐蔽、更顽固的“半死”状态CPU 负载不高内存没爆满网络层还在跑唯独图形子系统和输入子系统彻底失联。我们第一反应是查 App。毕竟这台机器上跑着一个定制化的工业监控 APK它持续采集摄像头流、渲染多路视频画布、上报传感器数据。日志里确实有几条ANR in com.xxx.monitor的痕迹但 ANR 日志时间戳比黑屏早了整整 42 分钟——它不是导火索更像是火灾后飘出的第一缕烟。我们 kill 掉所有用户进程、清空 data 分区、甚至刷回出厂固件问题依旧复现只要连续使用 scrcpy 投屏超过 6 小时必黑屏卡死。更诡异的是同一套固件烧录到同型号但不同批次的主板上有的稳定运行两周有的 3 小时就挂。硬件差异驱动版本还是某个被忽略的底层交互链路在悄悄泄漏关键词里反复出现的scrcpy、Rockchip、DMA-BUF和编码器像四把钥匙指向同一个锈蚀的锁芯。scrcpy 是投屏工具Rockchip 是 SoC 厂商DMA-BUF 是 Linux 内核中用于跨设备共享内存的机制编码器则是视频处理的核心模块。它们本不该直接耦合——scrcpy 只负责抓帧、编码、转发Rockchip 编码器只负责把 YUV 数据压成 H.264DMA-BUF 只是让它们能高效传递数据块。但当三者在特定负载下长期共存一个微小的引用计数错误就能让 DMA-BUF 的内存页永远无法释放最终耗尽 GPU 的物理地址空间PA导致显示控制器Display Controller申请新帧缓冲失败画面冻结输入中断。这不是 App 的 Bug而是驱动层与用户态工具之间一场静默的资源战争。提示这种“黑屏但不死”的现象在 Rockchip 平台的工控/车载/教育类 Android 设备上并非孤例。它往往被误判为“App 卡死”或“系统不稳定”实际根源深埋在内核驱动与用户态工具的边界地带。排查时切忌一上来就重装系统或替换 App必须先锁定图形栈的健康度。2. scrcpy 的“合法越界”它如何绕过 SurfaceFlinger 直接触碰编码器scrcpy 的设计哲学是“轻量、高效、零安装”。它不依赖 Android 端的 APK而是通过 ADB 启动一个 native servicescrcpy-server.jar这个 service 利用 Android 的screenrecord系统服务接口直接从 SurfaceFlinger 的合成输出缓冲区抓取帧数据。标准流程是SurfaceFlinger →screenrecordservice →MediaCodec编码 → socket 发送。但 scrcpy 的 server 为了极致性能做了关键改造它跳过了screenrecord的中间封装直接调用MediaCodec的createInputSurface()创建一个 InputSurface再把这个 Surface 的ANativeWindow句柄传给 SurfaceFlinger让 SurfaceFlinger 把合成结果直接绘制到这个 Surface 上。这相当于在图形管线里开了个“VIP 通道”绕过了screenrecord的帧率控制、缓冲区管理等安全阀。问题就出在这个 VIP 通道的“门禁系统”上。Rockchip 的rk_vpu驱动Video Processing Unit在实现MediaCodec的 H.264 编码器时为提升吞吐量默认启用了 DMA-BUF 共享模式。当 scrcpy 的 server 创建 InputSurface 时rk_vpu驱动会为每一帧分配一块 DMA-BUF 内存并将其映射到 VPU 的物理地址空间供硬件编码器直接读取。正常情况下编码完成后MediaCodec会调用releaseOutputBuffer()驱动随之释放对应的 DMA-BUF 引用计数内存归还。但 scrcpy 的 server 在异常退出如网络断开、客户端崩溃或长时间高负载运行时存在一个竞态条件MediaCodec的stop()调用可能未完全完成而releaseOutputBuffer()的回调却已触发导致驱动中的 DMA-BUF 引用计数被错误地减了两次或者一次都没减。这块内存页的引用计数变成负值或零但物理页本身并未被回收因为它还被 VPU 的 TLBTranslation Lookaside Buffer缓存着VPU 认为它仍是有效地址。我们用adb shell dumpsys meminfo -a查看发现Graphics类别下的Native Heap并未暴涨说明应用层没泄漏但用adb shell cat /proc/meminfo | grep DMA发现DMA字段的数值在 scrcpy 运行期间缓慢但持续上升从初始的 8MB 涨到 256MB 后不再增长——这正是 DMA-BUF 泄漏的典型特征内核为 DMA 分配的连续物理内存池被耗尽。此时SurfaceFlinger 尝试为新帧分配缓冲区时dma_alloc_coherent()返回 NULL整个图形管线瘫痪屏幕黑死输入事件队列也因 InputReader 依赖图形上下文而停滞。注意scrcpy 的官方文档从未提及此风险因为它的设计目标是手机/平板这些设备通常不会 24 小时不间断投屏。但在工控场景下scrcpy 成了“常驻服务”其与 Rockchip 驱动的兼容性缺陷就被无限放大。这不是 scrcpy 的 bug而是它在非标场景下暴露了驱动层的脆弱性。3. Rockchip 编码器的“隐性债务”DMA-BUF 引用计数的双重陷阱Rockchip 的rk_vpu驱动代码以 RK3399 平台 v4.4 内核为例在vpu_enc.c中处理 DMA-BUF 的逻辑存在两个关键设计选择共同构成了泄漏的温床第一个陷阱是“懒释放”策略。驱动在vpu_enc_release_buffer()函数中并非立即释放 DMA-BUF而是将释放请求放入一个 workqueue工作队列由内核线程异步执行。这样做的本意是避免在中断上下文或高优先级线程中做耗时的内存释放操作提升实时性。但问题在于当 scrcpy server 高频创建/销毁编码会话例如频繁切换分辨率、断线重连workqueue 中积压的释放任务会越来越多。如果此时系统发生内存压力内核的shrink_slab机制会尝试回收 slab 缓存但它无法感知到这些 pending 的 DMA-BUF 释放任务导致大量 DMA-BUF 内存页被标记为“可回收”却因 workqueue 未执行而无法真正释放形成“僵尸内存”。第二个陷阱是“引用计数绑定松散”。Rockchip 驱动将 DMA-BUF 的引用计数与vpu_ctxVPU 上下文结构体的生命周期强绑定但vpu_ctx的释放时机并不严格对应 DMA-BUF 的释放时机。vpu_ctx在vpu_enc_close()中被释放而 DMA-BUF 的最终释放则在 workqueue 的vpu_enc_release_work()中完成。如果vpu_enc_close()被多次调用scrcpy 在异常状态下可能重复 closevpu_ctx结构体可能被提前释放但 workqueue 中的任务仍持有对已释放内存的指针导致dma_buf_put()调用失败引用计数未被正确减少。我们通过在vpu_enc_release_work()中添加printk日志证实在卡死前 10 分钟日志中出现了大量vpu_enc_release_work: dma_buf_put failed, buf0x%p的报错而buf地址指向的内存早已被kfree。这两个陷阱叠加形成了一个正反馈循环DMA-BUF 泄漏 → 物理内存池耗尽 → SurfaceFlinger 分配失败 → scrcpy server 重试连接 → 创建更多编码会话 → 触发更多vpu_enc_close()→ 加剧引用计数混乱 → 泄漏加速。最终/dev/dma_heap/system下的可用内存降至 0dmesg中开始出现rk_vpu: dma_alloc_coherent failed的警告系统进入不可逆的图形冻结状态。我们对比了同一固件在 Amlogic 平台上的表现Amlogic 的aml_vcodec驱动采用同步释放策略且引用计数管理更为严格即使在相同 scrcpy 负载下DMA-BUF 内存波动始终在 ±2MB 范围内。这印证了问题根源不在 scrcpy而在 Rockchip 驱动对 DMA-BUF 生命周期的管理缺陷。4. 根因定位实录从 dmesg 到 kernel memory leak detector 的完整链路定位这个根因我们走了三条并行路径每一条都指向同一个结论。第一条是dmesg 日志的“时间戳指纹”。在每次黑屏前 3-5 分钟dmesg输出中必然出现两条关键信息[12345.678901] rk_vpu: dma_alloc_coherent for 0x100000 bytes failed [12345.678902] rk_vpu: alloc dma buffer fail, size0x100000这两行日志的出现频率与 scrcpy 运行时长呈线性关系。我们写了一个简单的 shell 脚本每 30 秒adb shell dmesg | tail -n 20 | grep dma_alloc_coherent并将结果绘制成折线图发现日志出现次数与黑屏时间高度相关R²0.98。这排除了随机硬件故障的可能性确认是确定性的资源耗尽。第二条路径是DMA-BUF 使用量的量化追踪。Linux 内核提供了/sys/kernel/debug/dma_buf/接口我们可以精确看到每个 DMA-BUF 的大小、引用计数和所属进程。我们编写了一个 Python 脚本通过 ADB 持续轮询import subprocess import time def get_dma_buf_stats(): cmd adb shell cat /sys/kernel/debug/dma_buf/* 2/dev/null | grep -E \(size|refcnt|name)\ result subprocess.run(cmd, shellTrue, capture_outputTrue, textTrue) # 解析输出统计 total_size 和 max_refcnt return parsed_stats while True: stats get_dma_buf_stats() print(fTotal DMA-BUF size: {stats[total_size]} KB, Max refcnt: {stats[max_refcnt]}) time.sleep(60)运行 8 小时后数据清晰显示total_size从 8192 KB 稳定增长至 262144 KB256MB之后持平而max_refcnt值在 1-3 之间跳动但从未归零——这意味着有大量 DMA-BUF 的引用计数被“卡住”无法释放。第三条也是最硬核的路径是启用Kernel Memory Leak Detector (Kmemleak)。这是一个内核内置的内存泄漏检测器它能扫描内核堆内存标记那些“无法被任何指针访问到但又未被释放”的内存块。我们修改了内核配置启用CONFIG_DEBUG_KMEMLEAKy并编译烧录。在 scrcpy 运行 4 小时后执行echo scan /sys/kernel/debug/kmemleak然后cat /sys/kernel/debug/kmemleak输出中赫然列出数百个0xffff8880xxxxxxx地址类型为dma-buf大小为0x1000001MB并标注unreferenced object 0xffff8880xxxxxxx (size 1048576)。这些地址正是rk_vpu驱动中dma_alloc_coherent()分配的内存页。Kmemleak 的报告为 DMA-BUF 泄漏提供了无可辩驳的内核级证据。这三条路径交叉验证让我们确信问题不在用户空间而在rk_vpu驱动的 DMA-BUF 管理逻辑。它不是一个偶发的 race condition而是一个在特定负载下必然触发的设计缺陷。5. 临时规避方案不改驱动也能让工位机“活”过 72 小时既然根因在驱动而 Rockchip 官方补丁遥遥无期我们提交的 issue 在 GitHub 上已沉寂 11 个月我们必须找到能在现有固件上稳定运行的临时方案。核心思路是切断泄漏的源头而非修复泄漏本身。我们围绕 scrcpy 的使用方式设计了三层防护第一层是强制会话超时与优雅退出。我们放弃了直接运行scrcpy命令转而用一个 Bash wrapper 脚本控制#!/bin/bash # scrcpy-safe.sh TIMEOUT1800 # 30分钟超时 SCRCOPY_CMDscrcpy --bit-rate2M --max-fps15 --crop1280:720:0:0 # 启动 scrcpy 并记录 PID $SCRCOPY_CMD SCRCPY_PID$! # 设置超时监控 sleep $TIMEOUT if kill -0 $SCRCPY_PID 2/dev/null; then echo scrcpy timeout, forcing clean exit... # 先发送 SIGTERM等待 5 秒 kill -TERM $SCRCPY_PID sleep 5 # 若未退出强制 SIGKILL if kill -0 $SCRCPY_PID 2/dev/null; then kill -KILL $SCRCPY_PID fi # 关键手动触发 VPU reset adb shell echo 1 /sys/class/vpu/vpu0/reset fi这个脚本确保 scrcpy 不会“长生不老”每次运行最多 30 分钟且退出时强制重置 VPU清空其 TLB 缓存和 DMA-BUF 映射表。实测下来单次运行最长可达 32 小时10 个 30 分钟会话远超原先的 6 小时极限。第二层是DMA-BUF 内存池的动态扩容。我们发现/sys/module/dma_heap/parameters/下有system_heap_size参数它定义了 DMA-BUF 系统堆的初始大小。默认值是134217728128MB我们将其翻倍adb shell echo 268435456 /sys/module/dma_heap/parameters/system_heap_size但这只是治标。真正的扩容需要在内核启动参数中加入dma_heap.size256M我们修改了bootargs并重新打包 boot.img。扩容后黑屏时间从 6 小时延至 12 小时证明泄漏速率恒定只是“油箱”变大了。第三层是绕过 VPU启用软件编码。这是最彻底的规避。我们修改 scrcpy 的 server 源码在server/src/main/java/com/genymobile/scrcpy/ScreenEncoder.java中强制将MediaCodec的 codec name 从OMX.rk.video_encoder.avc改为OMX.google.h264.encoder。后者是 Google 提供的纯软件编码器不依赖 Rockchip VPU自然不走 DMA-BUF 路径。虽然 CPU 占用率从 15% 升至 45%但对于工控机的四核 Cortex-A53 来说完全可承受且稳定性达到 99.99%。我们为此专门编译了一个scrcpy-server-sw.jar并部署到所有工位机。经验之谈在产线环境中“稳定压倒一切”。与其花 3 周等待一个不确定的驱动补丁不如用 2 天部署一个经过千次验证的 wrapper 脚本。我们的scrcpy-safe.sh已在 127 台设备上稳定运行 89 天零黑屏事故。技术选型没有绝对优劣只有场景适配。6. 驱动层修复实践为 rk_vpu 打上“引用计数保险栓”临时方案解决了燃眉之急但要一劳永逸必须修复驱动。我们基于 Rockchip 公开的rk3399-linux-sdk对drivers/media/platform/rockchip/vpu/rk_vpu_enc.c进行了两处关键修改核心思想是让引用计数的增减操作原子化、可审计、可兜底。第一处修改在vpu_enc_create_input_buffer()函数中。原代码在分配 DMA-BUF 后直接dma_buf_get()增加引用计数但未检查返回值// 原代码 dbuf dma_buf_get(fd); if (IS_ERR(dbuf)) { ret PTR_ERR(dbuf); goto err; } // ... 后续使用 dbuf我们增加了健壮性检查并在失败时主动清理// 修改后 dbuf dma_buf_get(fd); if (IS_ERR(dbuf)) { ret PTR_ERR(dbuf); dev_err(dev, dma_buf_get failed, fd%d, ret%d\n, fd, ret); goto err; } // 关键增加引用计数前先记录当前 refcnt old_refcnt dma_buf_get_refcount(dbuf); ret dma_buf_get(dbuf); // 此处为增加引用 if (ret) { dev_err(dev, dma_buf_get failed after get, old_refcnt%d\n, old_refcnt); goto err_put; }第二处修改是重构vpu_enc_release_work()。原函数在 workqueue 中直接调用dma_buf_put()我们将其改为一个带重试和日志的原子操作static void vpu_enc_release_work(struct work_struct *work) { struct vpu_enc_ctx *ctx container_of(work, struct vpu_enc_ctx, release_work); int i; for (i 0; i ctx-num_buffers; i) { if (ctx-dma_bufs[i]) { // 新增循环重试直到 refcnt 归零或超时 int retry 0; while (dma_buf_get_refcount(ctx-dma_bufs[i]) 0 retry 5) { dma_buf_put(ctx-dma_bufs[i]); msleep(10); // 等待 workqueue 完成 retry; } if (dma_buf_get_refcount(ctx-dma_bufs[i]) 0) { dev_warn(ctx-dev, dma_buf_put failed after %d retries, refcnt%d\n, retry, dma_buf_get_refcount(ctx-dma_bufs[i])); // 兜底强制释放避免僵尸内存 dma_buf_put_force(ctx-dma_bufs[i]); } ctx-dma_bufs[i] NULL; } } }其中dma_buf_put_force()是我们新增的内核 API它绕过引用计数检查直接调用dma_buf_release()确保内存被回收。这相当于给引用计数机制加了一道“保险栓”当常规释放失效时兜底机制启动。我们编译了新驱动模块rk_vpu.ko并通过insmod动态加载测试。在 72 小时的压力测试中/sys/kernel/debug/dma_buf/的total_size波动始终在 ±1MB 内dmesg中再未出现dma_alloc_coherent failed。我们将 patch 提交给了 Rockchip 的开源社区并附上了完整的测试报告和复现步骤。虽然官方尚未合并但这个修复已在我们内部产线全面铺开成为新固件的标准配置。7. 给所有嵌入式 Android 开发者的忠告别让“便利”成为系统的定时炸弹这次排查耗费了我们团队 17 个人日从最初的 App 怀疑到最终定位到rk_vpu驱动的vpu_enc_release_work()函数过程曲折。但它带来的教训远比解决一个 Bug 更深刻。我想对所有在 Rockchip、Allwinner、Amlogic 等平台做 Android 嵌入式开发的同行说请敬畏每一行驱动代码尤其是那些与 DMA-BUF、IOMMU、GPU 相关的模块。它们不是黑盒而是你系统的命脉。很多开发者习惯于“拿来主义”拿到 SDK编译烧录App 能跑就行。scrcpy 的便利性让我们忽略了它与底层驱动的耦合深度Rockchip 驱动的“开箱即用”让我们放松了对内存管理逻辑的审查。但工业场景的严苛性恰恰会把这种疏忽放大成灾难。一个在手机上运行完美的功能在工控机上可能就是 24 小时后的黑屏。这不是 Android 的问题也不是 Rockchip 的问题而是我们作为系统集成者未能履行好“最后一公里”的责任。我的建议很具体建立驱动健康度基线在项目启动初期就用dmesg、/sys/kernel/debug/dma_buf/、/proc/meminfo等工具为你的平台建立一份“内存健康报告”。记录正常负载下的 DMA-BUF 使用峰值、GPU 内存占用、TLB miss rate 等指标。这将成为日后排查的黄金标尺。对所有第三方工具做“压力透支测试”不要只测它“能用”要测它“用多久会坏”。给 scrcpy、ffmpeg、GStreamer 等工具设定 48 小时、72 小时的极限压力测试观察内存、温度、日志的细微变化。很多泄漏都是在第 36 小时才开始显现。拥抱内核调试工具Kmemleak、KASAN、ftrace 不是给内核开发者准备的玩具而是嵌入式工程师的听诊器。花两天时间学会它们能让你在问题爆发前就听到“杂音”。最后分享一个小技巧在init.rc中加入一行write /proc/sys/vm/swappiness 1。这能显著降低内核 swap 活动让 DMA-BUF 泄漏的迹象更早、更明显地暴露在dmesg中为我们争取宝贵的排查时间。技术没有银弹但严谨的方法论永远是我们对抗未知最可靠的武器。
返回列表