news 2026/9/12 21:59:57

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 小时就挂。硬件差异?驱动版本?还是某个被忽略的底层交互链路在悄悄泄漏?

关键词里反复出现的scrcpyRockchipDMA-BUF编码器,像四把钥匙,指向同一个锈蚀的锁芯。scrcpy 是投屏工具,Rockchip 是 SoC 厂商,DMA-BUF 是 Linux 内核中用于跨设备共享内存的机制,编码器则是视频处理的核心模块。它们本不该直接耦合——scrcpy 只负责抓帧、编码、转发;Rockchip 编码器只负责把 YUV 数据压成 H.264;DMA-BUF 只是让它们能高效传递数据块。但当三者在特定负载下长期共存,一个微小的引用计数错误,就能让 DMA-BUF 的内存页永远无法释放,最终耗尽 GPU 的物理地址空间(PA),导致显示控制器(Display Controller)申请新帧缓冲失败,画面冻结,输入中断。这不是 App 的 Bug,而是驱动层与用户态工具之间一场静默的资源战争。

提示:这种“黑屏但不死”的现象,在 Rockchip 平台的工控/车载/教育类 Android 设备上并非孤例。它往往被误判为“App 卡死”或“系统不稳定”,实际根源深埋在内核驱动与用户态工具的边界地带。排查时,切忌一上来就重装系统或替换 App,必须先锁定图形栈的健康度。

2. scrcpy 的“合法越界”:它如何绕过 SurfaceFlinger 直接触碰编码器

scrcpy 的设计哲学是“轻量、高效、零安装”。它不依赖 Android 端的 APK,而是通过 ADB 启动一个 native service(scrcpy-server.jar),这个 service 利用 Android 的screenrecord系统服务接口,直接从 SurfaceFlinger 的合成输出缓冲区抓取帧数据。标准流程是:SurfaceFlinger →screenrecordservice →MediaCodec编码 → socket 发送。但 scrcpy 的 server 为了极致性能,做了关键改造:它跳过了screenrecord的中间封装,直接调用MediaCodeccreateInputSurface()创建一个 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 在异常退出(如网络断开、客户端崩溃)或长时间高负载运行时,存在一个竞态条件:MediaCodecstop()调用可能未完全完成,而releaseOutputBuffer()的回调却已触发,导致驱动中的 DMA-BUF 引用计数被错误地减了两次,或者一次都没减。这块内存页的引用计数变成负值或零,但物理页本身并未被回收,因为它还被 VPU 的 TLB(Translation 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_ctx(VPU 上下文结构体)的生命周期强绑定,但vpu_ctx的释放时机并不严格对应 DMA-BUF 的释放时机。vpu_ctxvpu_enc_close()中被释放,而 DMA-BUF 的最终释放则在 workqueue 的vpu_enc_release_work()中完成。如果vpu_enc_close()被多次调用(scrcpy 在异常状态下可能重复 close),vpu_ctx结构体可能被提前释放,但 workqueue 中的任务仍持有对已释放内存的指针,导致dma_buf_put()调用失败,引用计数未被正确减少。我们通过在vpu_enc_release_work()中添加printk日志证实:在卡死前 10 分钟,日志中出现了大量vpu_enc_release_work: dma_buf_put failed, buf=0x%p的报错,而buf地址指向的内存早已被kfree

这两个陷阱叠加,形成了一个正反馈循环:DMA-BUF 泄漏 → 物理内存池耗尽 → SurfaceFlinger 分配失败 → scrcpy server 重试连接 → 创建更多编码会话 → 触发更多vpu_enc_close()→ 加剧引用计数混乱 → 泄漏加速。最终,/dev/dma_heap/system下的可用内存降至 0,dmesg中开始出现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, size=0x100000

这两行日志的出现频率与 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, shell=True, capture_output=True, text=True) # 解析输出,统计 total_size 和 max_refcnt return parsed_stats while True: stats = get_dma_buf_stats() print(f"Total DMA-BUF size: {stats['total_size']} KB, Max refcnt: {stats['max_refcnt']}") time.sleep(60)

运行 8 小时后,数据清晰显示:total_size从 8192 KB 稳定增长至 262144 KB(256MB),之后持平;而max_refcnt值在 1-3 之间跳动,但从未归零——这意味着有大量 DMA-BUF 的引用计数被“卡住”,无法释放。

第三条也是最硬核的路径,是启用Kernel Memory Leak Detector (Kmemleak)。这是一个内核内置的内存泄漏检测器,它能扫描内核堆内存,标记那些“无法被任何指针访问到,但又未被释放”的内存块。我们修改了内核配置,启用CONFIG_DEBUG_KMEMLEAK=y,并编译烧录。在 scrcpy 运行 4 小时后,执行echo scan > /sys/kernel/debug/kmemleak,然后cat /sys/kernel/debug/kmemleak,输出中赫然列出数百个0xffff8880xxxxxxx地址,类型为dma-buf,大小为0x100000(1MB),并标注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 TIMEOUT=1800 # 30分钟超时 SCRCOPY_CMD="scrcpy --bit-rate=2M --max-fps=15 --crop=1280: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 系统堆的初始大小。默认值是134217728(128MB),我们将其翻倍:

adb shell "echo 268435456 > /sys/module/dma_heap/parameters/system_heap_size"

但这只是治标。真正的扩容需要在内核启动参数中加入dma_heap.size=256M,我们修改了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中,为我们争取宝贵的排查时间。技术没有银弹,但严谨的方法论,永远是我们对抗未知最可靠的武器。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 21:58:10

Chrome插件实现Office在线预览:兼容86+内网的完整实践

简介&#xff1a;面向网页开发者与在线协作场景的浏览器插件&#xff0c;适用于谷歌浏览器86及以上版本&#xff0c;可直接在浏览器中预览Word、Excel、PowerPoint及PDF文档&#xff0c;无需额外安装桌面办公套件。压缩包共398个文件&#xff0c;以HTML页面、CSS样式、JavaScri…

作者头像 李华
网站建设 2026/9/12 21:58:05

CNN犬种识别完整链路:从双通道设计到迁移学习落地

简介&#xff1a;本资源是一个基于卷积神经网络&#xff08;CNN&#xff09;实现狗品种图像识别与分类的完整项目实践包&#xff0c;面向计算机、电子信息、人工智能等专业的本科生及初学者&#xff0c;适用于课程设计、期末大作业或毕业设计参考。项目涵盖从图像预处理、特征提…

作者头像 李华
网站建设 2026/9/12 21:57:52

H8013A工业宽压DC-DC芯片深度解析:高压输入稳低压输出的工程实现

1. 为什么H8013A在工业级宽压场景里突然被大量复用——不是参数漂亮&#xff0c;而是它把“高压输入稳住低压输出”这件事做成了“傻瓜式工程件” 你有没有遇到过这样的现场&#xff1a;一台户外LED广告屏控制器&#xff0c;供电来自老旧配电箱&#xff0c;实测电压波动在78V–…

作者头像 李华
网站建设 2026/9/12 21:56:58

汽修厂业绩增长:问题解决能力与管理系统选型

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 21:56:04

YOLOv5交通标志检测:从数据预处理到ONNX部署完整指南

简介&#xff1a;YOLOv5交通标志物检测完整工程包&#xff0c;面向计算机相关专业正在准备课程设计、期末大作业的学生&#xff0c;以及需要完整项目进行实战练习的深度学习学习者。项目以YOLOv5为检测框架&#xff0c;整合了源代码、训练好的权重模型、标注数据与训练配置&…

作者头像 李华