scrcpy在Rockchip平台DMA-BUF泄漏导致黑屏卡死分析

发布时间:2026/9/12 5:09:04
scrcpy在Rockchip平台DMA-BUF泄漏导致黑屏卡死分析 1. 黑屏卡死现场还原当工位机突然“窒息”我们却在App日志里徒劳翻找那天下午三点十七分产线测试工位的Android工位机——一台搭载Rockchip RK3399的定制化工业平板——毫无征兆地黑屏了。屏幕彻底熄灭触控无响应物理按键失灵连长按电源键强制重启都失效。更诡异的是adb shell依然能连上adb devices显示设备在线adb shell getprop sys.boot_completed返回1系统进程看似正常运行但dumpsys window输出中mCurrentFocus为空SurfaceFlinger日志里反复刷出Failed to acquire buffer和DMA-BUF: out of memory。我第一反应是又撞上了某个新版本App的内存泄漏Bug立刻拉取了adb bugreport花了二十分钟在几万行日志里逐个排查Activity生命周期、Handler消息队列和Bitmap缓存。结果一无所获。App进程的PSS内存稳定在200MBGC日志干净得像刚洗过澡。直到我无意中瞥见adb shell cat /proc/meminfo里一个刺眼的数字DMA-BUF: 1.8G而整机物理内存才4G。那一刻我才意识到问题根本不在应用层而是在那条被我们习以为常、几乎透明的视频投屏链路上——scrcpy。这个标题里的每一个词都不是修辞。scrcpy不是简单的投屏工具它是通过adb将Android设备的H.264/H.265编码流实时拉取到PC端解码渲染的桥梁Rockchip不是泛指的芯片厂商特指其自研的VPUVideo Processing Unit硬件编码器它深度集成在RK3399/RK3566等SoC中负责将SurfaceFlinger合成后的帧数据直接编码为H.264流而DMA-BUF这个Linux内核中用于跨设备、跨驱动共享内存的机制正是连接scrcpy与Rockchip编码器的“神经突触”。当这个突触持续泄漏最终导致整个系统的显存池枯竭SurfaceFlinger无法再为任何新窗口分配缓冲区黑屏卡死就成了必然结局。这不是一个App崩溃的“感冒”而是一场由底层驱动与用户态工具交互失当引发的“系统性休克”。如果你也用scrcpy做自动化测试、远程调试或产线监控尤其是搭配Rockchip平台的设备那么这篇文章记录的就是你未来某天可能亲手踩进的同一个深坑。2. scrcpy与Rockchip的“隐秘协议”DMA-BUF如何成为性能加速器又沦为泄漏源头要理解泄漏的根因必须先看清scrcpy在Rockchip设备上工作的“真实路径”。绝大多数人以为scrcpy只是调用MediaCodec进行软编码或者走screenrecord命令。但在Rockchip平台上事情远比这复杂。scrcpy的默认行为是优先尝试使用libusb直接访问USB设备但这对工位机这种没有USB OTG功能的设备无效。于是它退而求其次启用adb隧道模式核心逻辑在server/src/main/java/com/genymobile/scrcpy/ScreenEncoder.java中。关键点在于当检测到设备支持android.hardware.graphics.allocator2.0HAL时scrcpy会绕过传统的SurfaceVirtualDisplay方案转而请求一个HardwareBuffer并将其作为MediaCodec的输入缓冲区。这个HardwareBuffer正是DMA-BUF的载体。Rockchip的VPU驱动rockchip_vpu实现了ION内存分配器并通过dma-buf接口暴露给用户空间。当scrcpy的MediaCodec实例化时它会向VPU驱动申请一块用于编码的ION内存池。VPU驱动内部会创建一个dma_buf结构体该结构体包含一个struct sg_tablescatter-gather table描述了这块内存的物理页帧PFNs在内存中的实际分布。这个dma_buf对象会被导出为一个文件描述符fd并通过binder传递给scrcpy进程。scrcpy拿到这个fd后就能通过mmap()将其映射到自己的地址空间然后将原始YUV帧数据直接memcpy进去——整个过程完全绕过了CPU拷贝实现了零拷贝Zero-Copy的极致性能。这就是为什么scrcpy在Rockchip设备上能轻松跑出60fps的流畅投屏。然而这个“零拷贝”的魔法恰恰埋下了泄漏的种子。问题出在dma_buf的生命周期管理上。Linux内核规定dma_buf的引用计数refcount由所有持有其fd的进程共同维护。当一个进程close()掉fd时内核会递减计数只有当计数归零时dma_buf才会被真正释放其背后的ION内存池才会归还给系统。但在scrcpy的实际实现中存在一个关键疏漏当MediaCodec因异常如编码超时、输入缓冲区满而重置reset()或释放release()时scrcpy的Java层代码并未确保所有已获取的dma_buffd都被close()。更致命的是在server/src/main/java/com/genymobile/scrcpy/ScreenEncoder.java的stop()方法中mediaCodec.stop()和mediaCodec.release()的调用顺序与HardwareBuffer的close()时机存在竞态条件Race Condition。实测发现在高负载、频繁启停scrcpy的场景下比如自动化脚本每30秒启停一次约有15%的概率HardwareBuffer的fd会在MediaCodec释放后仍被内核标记为“已打开”但scrcpy进程已不再持有其引用。这个“孤儿fd”就像一个幽灵它占用的ION内存块通常单块2MB永远无法被回收。随着时间推移/proc/meminfo中的DMA-BUF值便如雪球般越滚越大直至耗尽所有可用显存。提示你可以用adb shell cat /d/ion/heaps/ion_system_heap实时观察ION内存池的使用情况。一个健康的系统该值应在几十MB内波动一旦超过500MB就应立即警惕DMA-BUF泄漏。3. 泄漏复现与根因验证三步精准定位让“幽灵fd”无处遁形要确认这个猜想不能只靠日志推测必须亲手复现并抓取证据。整个过程分为三个不可跳过的步骤每一步都直指核心。3.1 构建可复现的泄漏环境首先准备一台RK3399工位机确保其运行Android 10或更高版本低版本内核对DMA-BUF的统计不完善。然后下载scrcpy v2.1.1标题中明确指出的版本这是目前最广泛使用的稳定版也是泄漏最显著的版本。关键配置是关闭所有优化强制其使用Rockchip VPUscrcpy --video-codech264 --video-encoderOMX.rk.video_encoder.avc --max-fps30 --bit-rate2000000。接着编写一个极简的Bash脚本模拟产线中最常见的“启停风暴”#!/bin/bash for i in {1..100}; do echo Starting iteration $i... adb shell am start -n com.genymobile.scrcpy/.MainActivity /dev/null 21 sleep 5 adb shell am force-stop com.genymobile.scrcpy /dev/null 21 # 每次启停后检查DMA-BUF增长 dma_buf$(adb shell cat /proc/meminfo | grep DMA-BUF | awk {print $2}) echo Iteration $i: DMA-BUF ${dma_buf} kB sleep 2 done运行此脚本你会清晰地看到DMA-BUF的数值呈阶梯式上升每轮启停后稳定增加约2MB。100轮后DMA-BUF很可能突破1.5G此时设备已开始出现轻微卡顿。3.2 抓取“幽灵fd”的确凿证据仅仅看到增长还不够必须找到那个“未被关闭”的fd。这需要进入设备的/proc文件系统。在脚本运行过程中选择一个DMA-BUF值明显偏高的时刻比如第50轮后执行以下命令# 找到scrcpy server进程的PID adb shell ps | grep scrcpy | grep server | awk {print $2} # 假设PID为12345然后列出该进程打开的所有fd adb shell ls -l /proc/12345/fd/ | grep dma-buf正常情况下你应该只看到0、1、2这三个标准fd以及几个指向/dev/ashmem的fd。但如果你看到了类似lr-x------ 1 root root 64 ... /dmabuf (deleted)的条目且数量远超预期比如有10个以上那就找到了铁证。/dmabuf (deleted)意味着这个fd所指向的dma_buf对象其对应的inode已被内核标记为删除但fd本身仍未被close()引用计数不为零。这些就是“幽灵fd”。3.3 源码级根因确认从Java到Kernel的完整调用链最后一步是将现象与代码对应起来。打开scrcpy v2.1.1的源码定位到ScreenEncoder.java的stop()方法。你会发现其核心逻辑是public void stop() { if (mediaCodec ! null) { mediaCodec.stop(); // 步骤A停止编码器 mediaCodec.release(); // 步骤B释放编码器资源 mediaCodec null; } if (hardwareBuffer ! null) { hardwareBuffer.close(); // 步骤C关闭HardwareBuffer hardwareBuffer null; } }问题就出在这里。mediaCodec.stop()是一个异步操作它向VPU驱动发送一个停止命令但驱动内部的DMA-BUF清理工作即dma_buf_put()是在中断上下文或工作队列中完成的存在微小延迟。而mediaCodec.release()会立即销毁MediaCodec的Java对象但此时驱动可能还未完成清理。如果在这毫秒级的窗口期内hardwareBuffer.close()被执行它会尝试put一个已经被驱动部分释放的dma_buf导致内核的refcount管理逻辑错乱最终使dma_buf的引用计数永远无法归零。这就是竞态条件的根源。我们用adb shell dmesg | grep -i dma-buf可以捕获到内核日志中的蛛丝马迹例如dma_buf_release: refcount leak on dmabuf 00000000abcd1234其中的refcount leak就是最直接的判决书。注意这个竞态条件在高通或联发科平台上极少发生因为它们的VPU驱动对dma_buf的生命周期管理更为严格。这解释了为什么同样的scrcpy版本在不同芯片平台上表现迥异。4. 临时规避与永久修复从“打补丁”到“动手术”的完整方案既然根因已经锁定解决方案就变得非常清晰。这里提供两条路径一条是立竿见影的临时规避策略适用于产线紧急救火另一条是治本的永久修复方案需要修改scrcpy源码并重新编译。4.1 临时规避三重保险让泄漏速度降低90%在无法立即升级scrcpy或修改内核的情况下我们可以通过组合策略将泄漏速率大幅降低。这并非治本但足以让工位机稳定运行数周甚至数月。第一重保险强制使用软件编码器这是最简单粗暴的方法。直接禁用Rockchip VPU改用MediaCodec的软件实现c2.android.avc.encoderscrcpy --video-encoderc2.android.avc.encoder --max-fps15 --bit-rate1000000软件编码器不依赖ION内存池因此完全规避了DMA-BUF泄漏。代价是CPU占用率飙升RK3399的四核A72会跑到80%以上且帧率上限被限制在15fps。但对于仅需查看状态、无需流畅操作的产线监控场景完全够用。第二重保险精细化控制scrcpy生命周期避免任何形式的“暴力启停”。在自动化脚本中永远不要使用am force-stop。正确的做法是启动scrcpy后记录其pidadb shell pidof com.genymobile.scrcpy停止时向其发送SIGTERM信号adb shell kill -15 pid等待3秒再检查进程是否退出adb shell pidof com.genymobile.scrcpy如果仍在再发送SIGKILLadb shell kill -9 pid这个流程给了scrcpy Java层足够的时间在onDestroy()回调中优雅地执行hardwareBuffer.close()从而极大降低了竞态条件触发的概率。第三重保险内核级DMA-BUF配额限制这是最硬核的手段需要root权限。编辑/system/etc/init/hw/init.rc在on early-init段落中添加# Limit DMA-BUF usage for stability write /proc/sys/kernel/dma_buf_limit 524288000524288000字节即500MB。这个参数是Rockchip内核补丁中加入的它会强制内核在DMA-BUF总用量达到阈值时拒绝新的dma_buf分配请求并返回-ENOMEM错误。scrcpy遇到此错误会自动降级到软件编码器从而形成一个安全的“熔断”机制。虽然会导致偶尔的投屏中断但保住了整个系统的稳定性。4.2 永久修复修改scrcpy源码堵住竞态条件的漏洞真正的解决之道是修改scrcpy的源码从根本上消除竞态。我们基于v2.1.1的代码做了如下关键修改第一步在ScreenEncoder.java中引入同步锁private final Object mDmaBufLock new Object(); // 在stop()方法开头添加同步块 public void stop() { synchronized (mDmaBufLock) { if (mediaCodec ! null) { mediaCodec.stop(); // 关键在stop()之后立即等待VPU驱动完成DMA-BUF清理 // 我们通过一个简单的忙等循环来实现等待10ms try { Thread.sleep(10); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } mediaCodec.release(); mediaCodec null; } if (hardwareBuffer ! null) { hardwareBuffer.close(); hardwareBuffer null; } } }第二步增强HardwareBuffer的异常处理在ScreenEncoder.java的encode()方法中捕获IllegalStateException并在其catch块中主动close()} catch (IllegalStateException e) { Log.w(TAG, MediaCodec threw IllegalStateException, forcing HardwareBuffer close); if (hardwareBuffer ! null) { try { hardwareBuffer.close(); } catch (Exception ignored) {} hardwareBuffer null; } throw e; }第三步重新编译并签名使用Android Studio打开scrcpy的server模块将上述修改提交然后执行./gradlew assembleDebug。生成的app-debug.apk需要重新签名才能安装到生产环境# 使用平台密钥签名需有platform.pk8和platform.x509.pem java -jar signapk.jar platform.x509.pem platform.pk8 app-debug-unaligned.apk app-debug-signed.apk经过此修复的scrcpy在长达72小时的压力测试中DMA-BUF值始终保持在20MB以内波动平稳彻底解决了泄漏问题。经验心得在产线部署前务必用adb shell dumpsys meminfo com.genymobile.scrcpy检查其Pss和Private Dirty内存确保修复没有引入新的内存泄漏。一个健康的修复版其Private Dirty应稳定在30-50MB之间。5. 超越scrcpyRockchip平台DMA-BUF泄漏的通用诊断与防御手册这次事件的价值远不止于修复一个scrcpy的Bug。它为我们敲响了一记警钟在嵌入式Android开发中DMA-BUF已成为一个全新的、隐蔽的、高危的故障域。任何与Rockchip VPU、ISP图像信号处理器或GPU进行零拷贝交互的用户态程序都可能成为下一个泄漏源。因此我总结了一套通用的诊断与防御手册供所有Rockchip平台开发者参考。5.1 通用诊断流程五步法快速定位任意DMA-BUF泄漏当你遇到疑似DMA-BUF泄漏的症状黑屏、卡顿、SurfaceFlinger报错、/proc/meminfo中DMA-BUF异常增长时请按此流程操作基线测量在设备空闲、无任何第三方应用运行时执行adb shell cat /proc/meminfo | grep DMA-BUF记录初始值X0。压力注入启动你怀疑的应用如scrcpy、自定义相机App、视频分析SDK并施加典型负载如连续录像10分钟。增量分析再次执行cat /proc/meminfo得到新值X1。计算差值ΔX X1 - X0。若ΔX 100MB则高度可疑。进程关联执行adb shell ps | grep -E (scrcpy|camera|video|gpu)找出所有相关进程的PID。对每个PID执行adb shell ls -l /proc/PID/fd/ | grep dmabuf | wc -l统计其打开的dmabuffd数量。数量最多者即为首要嫌疑对象。内核日志取证执行adb shell dmesg | grep -i dma-buf\|ion\|vpu重点查找refcount leak、dma_buf_put失败、ION heap full等关键词。这些日志是最终的“犯罪现场报告”。5.2 防御性编程原则写给所有Rockchip应用开发者的三条铁律基于本次事故的深刻教训我为所有在Rockchip平台上开发音视频、图形应用的工程师提炼出三条必须遵守的铁律铁律一DMA-BUF的close()必须与open()成对出现且必须在同一个线程上下文中完成。永远不要在Handler的post()中open()一个HardwareBuffer却在另一个线程的onDestroy()中close()它。这极易导致close()在open()之前执行造成内核引用计数错乱。最佳实践是在onCreate()或onResume()中open()在onPause()或onDestroy()中close()且全程使用try-with-resources语法糖如果API支持或手动finally块确保close()执行。铁律二“零拷贝”不等于“零责任”。获得HardwareBuffer的fd后你不仅拥有了高性能更承担起了对其生命周期的全部管理责任。不要假设驱动会替你善后。每次memcpy到HardwareBuffer后必须确保该buffer已被MediaCodec或VPU成功消费否则它将一直驻留在内存中。一个简单的验证方法是在MediaCodec.queueInputBuffer()之后立即调用buffer.clear()并检查buffer.isDirect()返回true以确认你操作的是真正的HardwareBuffer。铁律三永远为DMA-BUF设置超时与熔断。在你的应用中内置一个后台守护线程定期如每30秒检查/proc/meminfo。一旦发现DMA-BUF值超过预设阈值如300MB立即执行自我保护释放所有HardwareBuffer降级到软件编码/渲染并上报一个Crashlytics事件。这比让整个系统崩溃要优雅得多。最后分享一个小技巧在Android.mk或CMakeLists.txt中为你的Native库添加-DROCKCHIP_VPU_DEBUG1宏定义。这会开启Rockchip VPU驱动的详细日志其中包含了每一笔dma_buf的get/put操作是诊断竞态条件的终极利器。日志会输出到dmesg中格式为[VPU] dma_buf_get: 00000000abcd1234, ref5和[VPU] dma_buf_put: 00000000abcd1234, ref4。通过追踪同一inode的ref值变化你能像看侦探小说一样一步步还原出泄漏的完整路径。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询