产线有一台 Android 工位机,用着用着就黑屏,接着整机卡死,连 adb 都连不上,只能断电重启。反复出现,业务方一开始怀疑是自研 App 的内存问题,开发同学查了两周,把大图加载、列表复用、WebView 全排查了一遍,也没找到明显的泄漏点。最后我介入才发现,问题根本不在 App,而是出在 scrcpy 投屏调试工具和 Rockchip 编码器之间:编码会话拿走的 DMA-BUF 内存只借不还,把系统内存耗干了,系统直接进入不可用状态。
这篇文章就把这次的排查过程完整记录下来。从现象、误判、到怎么一步步坐实 DMA-BUF 泄漏,再到根因和修复方案,给同样在 Android 工位机、一体机这类设备上做开发的同行一个参考。尤其是设备上长期挂着 scrcpy 做远程监控或调试的场景,大概率会遇到类似问题,这篇文章能帮你省下几天的排查时间。
1. 问题现场与第一轮排查
1.1 先描述一下现场情况
这台工位机是典型的产线安卓一体机,Rockchip 方案,8 核 CPU 配 4GB 内存,Android 系统,主要跑一个自研的 MES 工单显示 App。问题表现很明确:
- 设备运行一段时间后,屏幕先闪一下,然后黑屏,触摸失效
- 过几分钟后整机完全卡死,网络 adb 断开,USB adb 也连不上
- 必须断电重启才能恢复,重启后能正常用,但过几个小时又会复发
- 故障间隔没有固定规律,有时候 3 小时,有时候 8 小时
产线环境没有太多外部干扰,设备固定安装,散热正常,App 也是长时间挂在前台跑。所以业务方的第一反应是 App 内存泄漏,这完全可以理解,毕竟症状最像的就是内存耗尽导致系统 OOM,进而触发 watchdog 黑屏重启。
但我把 App 的代码大概走了一遍,发现这其实是一个很轻量的应用,没有复杂的动画,没有大量 Bitmap,列表也很短。单看内存占用,App 自己的 Java 堆就算泄漏,也很难在几个小时内把 4GB 内存全部吃光。真正值得怀疑的,是系统底层的 native 内存和内核内存。
1.2 初步取证:内存到底去哪了
我当时在现场第一时间做了两件事。第一件事是看free内存,第二件事是抓dmesg。dmesg在没有完全卡死之前一般还能抓到内容,这台设备当时刚好在故障前还能操作,我抓到了几条非常关键的日志。
free显示的情况是:MemFree已经掉到了不到 200MB,但buff/cache也不高,也就是说内存既不在 App 的 Java 堆里,也不在文件缓存里,而是被某种内核态机制吃掉了。
dmesg里的表现是典型的分配失败:
[ 4936.521843] ion: cma_alloc: 8 pages failed, (order: 3) [ 4936.521890] rockchip-vpu: failed to allocate buffer [ 4936.523410] [drm:rockchip_drm_gem_object_create] *ERROR* Failed to allocate buffer这段日志出现的位置距黑屏大概十几秒。可以确认一个方向:内存是由ion或者 CMA 分配失败触发的,而请求分配的人是rockchip-vpu,也就是 VPU 视频硬件编码单元。到这儿,问题已经从“App 内存泄漏”转到了系统视频编码这一层。
1.3 为什么业务 App 加班排查了两周还没找到问题
业务方没找到问题,不是他们能力不行,而是排查工具的视野天然有盲区。Android 上大家最熟的dumpsys meminfo只统计 App 进程的 PSS/Java 堆,根本看不到内核态的 DMA-BUF 占用。adb shell top看到的 CPU 也很正常,因为编码器不是忙等,它是硬件在干活,CPU 占用率很低。
这种情况很像以前遇到的“图形内存泄漏”:进程没吃多少内存,但/sys/kernel/debug/dma_buf/bufinfo里能看到大量 buffer 挂着不释放。所以我后面直接调整了排查方向,不再看 App,而是盯系统、盯内核、盯硬件模块。
2. 把矛头转向 scrcpy:投屏调试工具为什么会惹祸
2.1 scrcpy 的连接模型和编码链路
熟悉 scrcpy 的人都知道,它的工作原理是:手机/工位机端跑一个scrcpy-server,这个 server 会把设备屏幕的 Surface 数据交给 MediaCodec 做硬编码,编码成 H.264 码流,然后通过 adb 的 socket 通道推到电脑端解码显示。整个链路是:
App 界面 -> SurfaceFlinger -> Surface 输入到 MediaCodec -> 硬件编码器(VPU) -> H.264 数据 -> adb socket -> PC 端 scrcpy 窗口
这个设计的妙处在于,scrcpy 本身不读像素,而是拿了一个 Surface 往 MediaCodec 里塞数据。编码器在处理每一帧时,需要从内存池里申请 buffer,编码完再释放。如果申请和释放不对称,buffer 就会不断积累。
2.2 Rockchip 平台的编码器与 MPP
Rockchip 的硬件编码器一般叫 VPU,软件层对应的库叫 MPP(Media Process Platform)。在 Android 系统里,上层 MediaCodec 通过 HAL 层走到 MPP,MPP 再调用内核的 VPU 驱动。这中间涉及的内存管理方式,就是标题里说的 DMA-BUF。
Rockchip 平台的内存管理相对特殊,它会从系统内存里划一块叫ion的区域,或者直接通过 DMA-BUF heap 来管理多媒体 buffer。编码器需要内存时,不是走普通的malloc,而是向内核的 DMA-BUF 子系统申请,因为这块内存既要给 CPU 访问,也要给 VPU 通过设备地址访问。这意味着,申请到的内存是“跨设备共享”的,有独立的生命周期管理。
如果用一句话概括 DMA-BUF 泄漏:共享内存的“引用计数”没有归零,内核认为这块 buffer 还在被使用,所以不会归还给系统。每次编码会话泄漏几个 buffer,每个 buffer 几 MB,日积月累,几小时以后内存就空了。
2.3 DMA-BUF 到底是怎么工作的
这里给不太熟悉内核的读者补点基础。DMA-BUF 是 Linux 内核提供的一种跨设备内存共享机制,Android 上最常见的使用者是图形栈(GPU/显示控制器)和多媒体栈(编解码器)。
开发者使用 DMA-BUF 的典型流程:
- 从 dma-buf heap(或者旧的 ION)里分配一块内存,拿到一个
fd - 把这个
fd映射到自己设备的地址空间 - 使用完以后,关闭
fd,对应的引用计数减一 - 所有设备都关闭了
fd,引用计数归零,内存才会真正释放
问题往往出在第三步:某个环节没有关闭fd,或者某个 BufferQueue 的缓存槽没有正确归还 buffer,引用计数一直大于零,内核就只能一直养着这块内存。等到系统累计泄漏的内存超过某个阈值,CMA 分配连续物理页面失败,VPU 申请不到 buffer,编码器就罢工了。编码器罢工后,投屏内容卡住,整个 SurfaceFlinger 的行为也会变得异常,最终表现为黑屏和系统卡死。
3. 关键证据:怎么坐实 DMA-BUF 泄漏
3.1 用 sysfs 和 debugfs 看 DMA-BUF 占用
要确认 DMA-BUF 泄漏,最有用的手段是看内核的 debug 节点。Rockchip 平台一般开启CONFIG_DMA_BUF和对应的 debug 信息,可以这样看:
adb shell cat /sys/kernel/debug/dma_buf/bufinfo输出里会列出所有当前存活的 DMA-BUF 对象,包括名字、大小、进程引用数。
不过我遇到的情况比较特殊:设备一旦跑起来,这个文件越来越大,几万行都打不住。所以我一般先做个统计,数一下不同类型的 buffer 占了多少总大小:
adb shell "cat /sys/kernel/debug/dma_buf/bufinfo | grep -A1 'vpu\|rkvdec\|mpp' | grep size | awk '{sum += $2} END {print sum}'"在我排查的这台机器上,VPU 相关的 DMA-BUF 总大小在运行 4 小时后已经超过了 1.5GB。而正常情况下,VPU 的 buffer 占用应该稳定在几十 MB 的量级。这个数字基本可以定性了:编码器链路存在内存泄漏。
另外还可以配合看/proc/meminfo里的Ion相关字段,或者查阅dumpsys meminfo里的 native 内存趋势。下面的表格是我在排查时整理的常用内存诊断命令,后面排查同类型问题可以直接套用。
| 排查目标 | 命令 | 看到什么说明有问题 |
|---|---|---|
| 系统总内存 | adb shell cat /proc/meminfo | MemFree持续下降且不回升 |
| DMA-BUF 占用量 | adb shell cat /sys/kernel/debug/dma_buf/bufinfo | 特定模块 buffer 数量只增不减 |
| 进程 PSS | adb shell dumpsys meminfo <pid> | App 内存正常不代表系统没问题 |
| 编码器状态 | adb shell dumpsys media.codec | 编码会话/实例异常堆积 |
| 内核日志 | adb shell dmesg | grep -i vpu | failed to allocate、timeout等错误 |
| CMA 分配情况 | adb shell cat /proc/buddyinfo | 连续内存碎片化严重 |
3.2 关键对比实验:控制变量锁定 scrcpy
拿到 DMA-BUF 持续增长的证据还不够,必须证明这个增长和 scrcpy 有直接因果关系。我当时设计了三组对比实验:
第一组:正常业务,不跑 scrcpy,连续跑 12 小时,dma_buf/bufinfo里 VPU 相关 buffer 数量非常平稳,没有明显增长,系统内存曲线正常。
第二组:跑业务的同时,用 scrcpy 连接设备并保持投屏,连续跑 4 小时,VPU DMA-BUF 数量线性增长,内存持续下降,最后复现了黑屏卡死。
第三组:跑业务 + scrcpy,但把投屏断开,观察内存曲线:断开后 VPU buffer 数量停止增长,已经申请的 buffer 部分回落,系统恢复稳定。
这个结果非常干净地证明了:DMA-BUF 泄漏是 scrcpy 触发的编码链路导致的,和业务 App 没有直接关系。而且断开 scrcpy 后 buffer 数量不涨,说明泄漏点大概率是在编码会话建立或帧数据传递的某个环节,而不是一直在后台偷偷跑。
3.3 从 scrcpy-server 的代码层面找线索
如果你愿意翻 scrcpy 的源码,也能找到一些蛛丝马迹。scrcpy-server 的核心逻辑是把显示器的VirtualDisplay和MediaCodec绑定,然后从编码器输出端读取数据。它有一个Codec类和一个SurfaceEncoder类,负责管理 MediaCodec 的生命周期。
常规情况下,scrcpy 会在投屏结束时调用stop()和release()。但在异常断开(比如 USB 松动、adb 掉线、PC 端强退)时,server 的释放流程有可能没有完整走到。更底层的问题是,Rockchip 的 MPP 库在释放编码会话时,如果还有 buffer 挂在驱动未回收,驱动层面的free函数没有被正确调用,DMA-BUF 引用计数就会永久大于 0。
这一点在 Rockchip 的驱动源码里也能看到,rockchip_vpu_enc的release函数需要逐个释放msg->buffers里的 DMA-BUF。如果上层传入的 buffer 列表不完整,或者某个 buffer 已经被上层 close 但驱动还在使用,就有可能出现泄漏。国产 SoC 的编码器驱动在边界场景下处理得比较粗糙,这种问题并不罕见。
4. 根因梳理与修复方案
4.1 泄漏点到底在哪
结合实验数据和驱动代码分析,这次泄漏的根因可以归纳为一条链路:
- 使用 scrcpy 连接工位机,开始视频投屏
- scrcpy-server 创建 MediaCodec 编码会话,Rockchip VPU 开始工作,通过 MPP 向内核申请了大量 DMA-BUF
- 在某个时刻,连接异常断开(adb 服务重启、网络波动、PC 端窗口关闭),scrcpy-server 的编码器释放流程没有完整走到
release - 部分 DMA-BUF 没有被释放,引用计数没有归零
- 下一次投屏重新建立编码会话,又申请了一批新 buffer,旧的还挂着
- 反复多次后,DMA-BUF 累积至系统内存耗尽,VPU 申请不到 buffer,编码器卡死,SurfaceFlinger 也随之异常,屏幕黑屏
这里有一个重点:这个问题不是典型的“稳定复现”的 bug,它依赖“异常断开”这个触发条件。产线环境网络波动频繁,或者维护人员经常拔插 USB,就容易反复触发。
4.2 修复可以从几个方向入手
修复思路分三个层面:应用层、驱动层、使用方式。
第一层,应用层:如果是自己集成了 scrcpy 或类似投屏功能的客户端,务必确保在onDestroy、断线回调、异常退出路径里都调用完整的释放流程。Android 的 MediaCodec 释放顺序是:先stop(),再release(),中间不要跳过异常分支。最好用try-finally保证释放逻辑一定执行。
第二层,驱动层:Rockchip 的 MPP 或 VPU 驱动有更新版本的话,尽量升级到官方修复了 buffer 释放问题的版本。很多这类问题在 SoC 原厂的新 BSP 里已经有补丁,只是产线设备用的是出厂旧版本系统,一直没升。联系 Rockchip 原厂或者方案商要补丁时,可以直接描述现象:scrcpy 投屏异常断开后 VPU DMA-BUF 泄漏,dmesg 里出现 ion: cma_alloc failed。他们一听就明白。
第三层,使用方式:如果短期内无法改代码,也不方便升级系统,可以考虑从运维侧规避。具体做法是给 scrcpy 连接加一道保活检查,一旦断线,强制把设备端 scrcpy-server 进程杀掉再重连:
adb shell killall com.genymobile.scrcpy或者在 scrcpy 连接时加--max-size和--max-fps限制编码负载,虽然不能解决泄漏,但可以降低单次泄漏的速度,给维护争取时间。另外,尽量用有线连接,减少 adb 掉线的概率。
4.3 修复后的验证方式
我在验证修复效果时,用了最土但最有效的办法:压测。
修复后,我在设备上反复执行下面的操作循环跑了一整天:
- 启动 scrcpy 投屏,保持 15 分钟
- 通过
adb kill-server模拟异常断开 - 重新启动 adb 和 scrcpy
- 观察
/sys/kernel/debug/dma_buf/bufinfo中 VPU buffer 数量
对比数据我整理成了下面这个表:
| 测试轮次 | 操作 | VPU buffer 数量 |
|---|---|---|
| 第 1 轮 | 初始状态 | 5 |
| 第 2 轮 | 正常投屏 15 分钟 | 12 |
| 第 3 轮 | kill-server 异常断开 | 17 |
| 第 4 轮 | 重启 adb + scrcpy | 19 |
| 第 5 轮 | kill-server 异常断开 | 21 |
| 修复前 10 轮后 | 持续累积 | 200+ |
| 修复后 10 轮后 | 持续运行 | 25 左右 |
修复后,VPU buffer 的数量明显收敛,不再无限制增长。连续 72 小时压测没有再出现黑屏卡死,问题闭环。
5. 这类问题的通用排查套路与避坑清单
5.1 黑屏卡死类问题,先分清是哪个层
经过这次排查,我最大的感悟是:黑屏卡死这类问题,第一步不是看代码,而是给问题分层。层级排错了,后面全白费。
我的分层思路是这样的:
- 第 1 层:硬件层。先排除供电、散热、屏幕排线、通断问题。产线设备尤其容易忽略供电,电源适配器老化导致电压不稳,也会黑屏。
- 第 2 层:内核/驱动层。看
dmesg、/proc/meminfo、/sys/kernel/debug/dma_buf/bufinfo。重点关注 DMA-BUF、ION、CMA 相关报错。如果有 VPU、GPU、ISP 之类的硬件模块参与,优先怀疑多媒体内存泄漏。 - 第 3 层:HAL/系统服务层。看
logcat里 SurfaceFlinger、AudioFlinger、MediaCodec 相关的崩溃或 warning。dumpsys是这里最常用的工具。 - 第 4 层:应用层。最后才看 App。应用层问题通常是 Java 堆 OOM、主线程阻塞、死锁,症状和系统级不完全一样。
这次的坑就在于业务方直接从第 4 层开始查,查了两周没结果,因为问题根本不在那里。
5.2 DMA-BUF 泄漏的典型“长相”
DMA-BUF 泄漏这个问题,在外企设备、国产设备上都有可能碰到,特别是涉及到视频编解码、相机、GPU 渲染的长跑场景。它有几个典型特征,你如果看到这几条,就可以往这个方向想:
- 现象是周期性复发,重启后好转,运行越久越严重
free里内存持续下降,但dumpsys meminfo看不出是哪个 App 在吃dmesg里有ion: cma_alloc failed、rockchip-vpu: failed to allocate buffer、Out of memory之类的关键字- 设备有投屏、录屏、视频通话、相机预览之类的多媒体功能在跑
- 故障前往往有一次异常断开、信号不稳、设备重启等非正常操作
还有一种很容易被忽视的场景:像工位机这种设备,维护人员图省事会挂一个scrcpy进程做远程看护,但看护本身变成了杀手。这也是我写这篇文章的另一个用意——调试工具在产线设备上的使用,真的需要评估它自身的稳定性。
5.3 几个工程上的实操建议
最后说几个这次排查过程中沉淀下来的实操建议,都是踩过的坑换来的。
第一,产线设备一定要开内核的dma_bufdebug 节点。很多 BSP 默认只开了CONFIG_DMA_BUF,没开对应的 debug,导致出了问题没法查。如果你发现自己看不到/sys/kernel/debug/dma_buf/bufinfo,可以试试先挂载 debugfs:
adb shell mount -t debugfs none /sys/kernel/debug如果 BSP 连内核配置都不支持,那建议提前把内核的CONFIG_DEBUG_FS、CONFIG_DMA_BUF打开,否则排查这类问题会非常被动。
第二,远程维护工位机时,给 scrcpy 这类工具加个简单的“可用性探针”。比如每隔 5 分钟检查一次adb devices状态和 VPU buffer 数量,超过阈值自动重启 scrcpy-server。产线场景宁可让投屏短暂中断,也不能让它带着泄漏跑到黑屏。
第三,如果是自己开发的投屏工具或者定制系统,注意 MediaCodec 的异常路径。测试用例里一定要加“边编码边杀进程”、“传输通道强制断掉”、“系统休眠唤醒”这类破坏性场景。芯片原厂的 BSP 代码在异常路径的处理上普遍比应用层糙,需要自己做好兜底。
这次排查给我留下的一个重要习惯:现在遇到任何 Android 设备黑屏卡死,我会先看dmesg,再看dma_buf/bufinfo,最后才打开 Android Studio 查代码。工具错位才是排查效率低的最大元凶。希望这篇记录能帮你少走这段弯路。