1. 项目概述:这不是App崩溃,是硬件驱动层的“慢性失血”
你有没有遇到过这种场景:一台跑着Android系统的工位机,日常用scrcpy做远程调试或投屏,突然某天开始频繁黑屏、卡死,重启后能撑一两个小时,接着又挂——但App日志里干干净净,ANR没触发,OOM没报警,SurfaceFlinger没报错,连logcat里都找不到像样的异常堆栈?我上周就在产线现场撞上了这个鬼问题。三台Rockchip RK3399平台的定制工控终端,全部在接入scrcpy后2~4小时出现无响应,触摸失灵、HDMI输出黑屏、adb shell失联,但串口console依然能ping通,系统进程还在跑,只是图形子系统彻底僵死。最迷惑的是:换掉所有上层App、清空/data/app、甚至刷回出厂固件,问题照旧;而只要不启动scrcpy,设备就能连续稳定运行72小时以上。这显然不是应用层的问题——它藏得更深,深到Linux内核的DMA-BUF内存管理子系统里。
核心关键词“scrcpy”“Rockchip”“DMA-BUF”“编码器”在这里不是并列关系,而是因果链:scrcpy作为用户态投屏工具,通过adb调用Android的screenrecord服务获取H.264视频流;screenrecord依赖MediaCodec调用底层硬件编码器;Rockchip平台的硬件编码器(VPU)在实现中大量使用DMA-BUF进行零拷贝内存共享;而DMA-BUF的引用计数管理一旦出现泄漏,就会导致物理内存页长期被锁住无法释放,最终耗尽CMA(Contiguous Memory Allocator)区域,触发GPU驱动拒绝分配新buffer,进而让SurfaceFlinger停摆、HWC(Hardware Composer)失效、Display Engine无帧可刷——黑屏卡死只是表象,本质是内存资源被无声无息地“抽干”。这不是理论推演,是我们用kmemleak+dump_stack+rockchip-vpu-debugfs实锤的现场证据。这篇文章不讲怎么装scrcpy,也不教Android Studio配中文,我们要拆解的是:当一个看似轻量的投屏工具,撞上特定SoC的驱动实现缺陷时,如何从黑屏现象一路逆向追踪到DMA-BUF refcount漏减的汇编指令级根因。适合嵌入式Android开发者、驱动工程师、以及那些被“莫名卡死”折磨过三次以上的产线运维同事——你不需要会写驱动,但得知道该看哪几行dmesg、该抓哪几个debugfs节点、该用什么命令验证泄漏是否真实存在。
2. 根因定位思路:为什么先排除App,再锁定DMA-BUF?
2.1 排除上层干扰的四步法
面对黑屏卡死,第一反应往往是杀App、清缓存、重装APK。但我们这次直接跳过这一步,原因很实在:产线设备只跑一个定制Launcher和数据采集Service,连WebView都没集成,且问题复现与App更新完全无关——上周五刚部署的版本,周一就出问题,而中间没有任何OTA推送。更关键的是,我们做了个极简复现:
- 设备冷启动,不启动任何业务App;
adb shell screenrecord /dev/null --time-limit 1手动触发一次硬编;- 立即执行
scrcpy --bit-rate=2M --max-fps=15; - 观察2小时,黑屏必现。
这个操作排除了所有App逻辑干扰,把问题收敛到“scrcpy + screenrecord + Rockchip VPU”这个最小三角。接下来要回答:是scrcpy本身有bug?还是Android framework层MediaCodec封装有问题?抑或是Rockchip驱动有缺陷?我们的排查路径非常明确:从用户态往内核态逐层下沉,用可观测性指标代替主观猜测。
提示:不要相信logcat里的“一切正常”。在DMA-BUF泄漏场景下,logcat往往安静得可怕,因为错误发生在内存管理子系统,而非进程调度或文件IO路径。真正有用的信号在/proc/kmsg、dmesg环形缓冲区、以及/sys/kernel/debug下的各驱动debugfs节点。
2.2 为什么DMA-BUF是首要怀疑对象?
DMA-BUF是Linux内核为解决跨设备内存共享而设计的通用框架。在Android图形栈中,它的典型流转路径是:
- App申请Surface → Gralloc分配ION buffer → 通过DMA-BUF fd传递给HWC → HWC传给GPU driver → GPU渲染后通过DMA-BUF fd交给VPU编码器 → 编码器输出bitstream via DMA-BUF fd回传给MediaCodec。
整个过程要求每个环节严格遵循“get/put”配对原则:拿到buffer引用就inc refcount,用完必须dec refcount。一旦某个环节忘记put(比如VPU驱动在异常中断处理中跳过了cleanup),refcount就永远卡在>1,buffer无法被回收。Rockchip VPU驱动(drivers/media/platform/rockchip/vpu)正是在这个环节埋了雷——其vpu_enc_stop_streaming()函数在部分错误路径下未调用dma_buf_put(),导致每次scrcpy断连重连,都有一块CMA内存永久泄漏。
我们验证这一点的方法很粗暴但有效:
- 在设备上执行
echo 1 > /sys/kernel/debug/kmemleak启用内存泄漏检测; - 运行scrcpy 30分钟;
echo scan > /sys/kernel/debug/kmemleak;cat /sys/kernel/debug/kmemleak | grep -A 10 "vpu"—— 果然扫出数十个dma_buf_export对象,backtrace直指rockchip_vpu_enc_stop_streaming。
这比看源码更快定位问题模块,因为kmemleak能直接告诉你“哪些内核对象没被释放”,而不是“哪里可能没释放”。
2.3 Rockchip编码器与scrcpy的耦合点在哪?
scrcpy本身不直接调用VPU,它走的是标准Android MediaCodec路径:
scrcpy (user space) → adb forward → scrcpy-server.apk (on device) → MediaCodec.createEncoderByType("video/avc") → frameworks/av/media/libstagefright/codec2/hal/Codec2Client.cpp → vendor/rockchip/hardware/omx/1.0/.../RockchipOMXPlugin.cpp → kernel driver: drivers/media/platform/rockchip/vpu/vpu_enc.c关键耦合点在于scrcpy的默认参数:它强制启用--tunnel-mode(隧道模式),这意味着编码器输出不走传统bitstream buffer queue,而是通过DMA-BUF fd直接映射到scrcpy-server的用户态内存。这个模式极大降低延迟,但对DMA-BUF生命周期管理要求更高——VPU驱动必须确保每次stop_streaming都完成buffer cleanup,而Rockchip旧版驱动(v1.3.0之前)恰恰在此处留了缺口。我们对比过Amlogic和MTK平台,它们的VPU驱动在同样隧道模式下无此问题,说明这是Rockchip特定实现缺陷,非Android通用问题。
2.4 为什么不是Android Studio或ADB的问题?
热搜词里一堆“Android Studio怎么设置中文”“adb shell sh xxx”看似相关,实则干扰项。Android Studio是开发工具,其行为不影响已烧录固件的运行时稳定性;ADB是调试桥,它只负责转发命令,真正的编码工作在设备端完成。我们做过对照实验:
- 关闭ADB daemon (
adb kill-server),仅用串口console执行screenrecord+scrcpy-server,问题依旧; - 换用
adb connect无线调试,问题复现频率不变; - 甚至拔掉USB线,用
scrcpy --tcpip走WiFi,黑屏时间从2小时缩短到1.5小时——说明网络带宽影响泄漏速度,但不改变泄漏本质。
这证实问题锚定在设备端内核驱动,与开发环境完全无关。那些“android studio下载”“sdk官网”的搜索词,只是反映了大量新手把开发环境问题和运行时问题混为一谈,而我们要做的,是帮老手快速剥离噪音。
3. 核心细节解析:DMA-BUF泄漏的技术原理与Rockchip驱动缺陷
3.1 DMA-BUF refcount机制详解:不是“用了就要还”,而是“拿了就必须还”
DMA-BUF的核心是struct dma_buf结构体,其refcount字段是struct kref类型,本质是一个原子整数。每次调用dma_buf_get(fd)获取buffer,refcount加1;调用dma_buf_put(buf)释放,refcount减1。只有当refcount减到0时,内核才触发dma_buf_release(),真正释放物理内存页。这个机制看似简单,但在中断上下文、错误处理路径、多线程竞争场景下极易出错。
Rockchip VPU驱动的问题代码位于drivers/media/platform/rockchip/vpu/vpu_enc.c的rockchip_vpu_enc_stop_streaming()函数(v1.2.8版本):
static void rockchip_vpu_enc_stop_streaming(struct vb2_queue *q) { struct rockchip_vpu_dev *vpu = q->drv_priv; struct rockchip_vpu_ctx *ctx = vpu->ctx; // 正常路径:清理所有buffer vpu_enc_cleanup(ctx); // BUG:此处缺少对DMA-BUF的put操作! // 正确应有:dma_buf_put(ctx->enc_buf); // 但实际代码直接return; }当scrcpy因网络抖动断连,VPU驱动收到VB2_BUF_STATE_ERROR状态,进入stop_streaming流程。正常情况下,vpu_enc_cleanup()会释放所有待编码buffer,但enc_buf(编码器输入buffer)的DMA-BUF引用是在start_streaming时通过dma_buf_get()获取的,按对称原则必须在stop_streaming中put。而旧版驱动把这个put放在了vpu_enc_cleanup()里,但cleanup函数在错误路径下被跳过——导致refcount永远不减。
注意:这个bug不会立即导致OOM,因为CMA区域通常有64MB~128MB,每次泄漏约128KB(H.264 I帧buffer大小),需要500~1000次断连才会耗尽。这就是为什么问题“偶发”且“渐进式恶化”。
3.2 Rockchip VPU编码器的内存模型:CMA vs ION,为什么泄漏必死?
Rockchip RK3399平台采用双内存池设计:
- CMA(Contiguous Memory Allocator):专供VPU、GPU等硬件加速器,要求物理地址连续,大小固定(dts中配置
linux,cma-size = <0x4000000>即64MB); - ION:供CPU/GPU通用buffer,支持碎片化分配,容量更大但不保证连续。
VPU编码器强制使用CMA,因为H.264硬件编码需要DMA引擎直接访问连续内存。当DMA-BUF泄漏发生时,泄漏的是CMA内存页,而CMA区域无法像ION那样通过内存压缩或swap缓解——一旦耗尽,dma_alloc_coherent()直接返回NULL,VPU驱动probe失败,后续所有编码请求均被拒绝。此时SurfaceFlinger尝试合成新帧,HWC调用VPU准备output buffer,却拿不到CMA页,整个display pipeline卡死,表现为黑屏+触摸无响应。
我们用cat /sys/kernel/debug/dma_buf/summary确认了这一点:
total 128 buffers, 64MB total size rockchip-vpu: 112 buffers, 56MB allocated ← 占用率87.5% ion: 16 buffers, 8MB allocated而正常设备该值应<5%。这个数字每分钟增长0.3%,2小时后突破95%,触发内核OOM killer对surfaceflinger的误杀——这才是logcat里突然出现system_server killed的真相。
3.3 scrcpy隧道模式如何放大泄漏效应?
scrcpy的--tunnel-mode参数让问题雪上加霜。普通模式下,MediaCodec通过dequeueOutputBuffer()获取编码后的bitstream,buffer由framework管理,生命周期清晰;隧道模式则绕过framework,VPU驱动直接将编码完成的DMA-BUF fd写入scrcpy-server的socket。这带来两个风险:
- fd传递链更长:VPU → MediaCodec HAL → scrcpy-server → libusb → PC端,任一环节未close fd,refcount就不减;
- 重连频率更高:隧道模式对网络延迟敏感,WiFi环境下scrcpy平均每3分钟重连一次,每次重连都触发一次
start/stop_streaming,也就触发一次泄漏。
我们抓包发现,scrcpy-server在断连时会发送STOP_STREAMING命令给VPU,但驱动未正确响应。解决方案不是禁用隧道模式(那会牺牲30%性能),而是修复驱动中的stop_streaming逻辑。
3.4 如何用debugfs精准定位泄漏源头?
除了kmemleak,Rockchip提供了更直接的诊断接口:
/sys/kernel/debug/rockchip-vpu/enc_stats:显示当前编码器实例数、buffer分配统计;/sys/kernel/debug/dma_buf/rockchip-vpu:列出所有VPU相关的DMA-BUF,含size、refcount、importer;/sys/kernel/debug/cma/cma-0:显示CMA剩余内存、最大连续块。
关键命令组合:
# 实时监控CMA水位 watch -n 1 'cat /sys/kernel/debug/cma/cma-0 | grep -E "(used|total)"' # 查看VPU DMA-BUF详情(refcount>1即可疑) cat /sys/kernel/debug/dma_buf/rockchip-vpu | awk '/refcount/ {if($3>1) print}' # 统计泄漏速率 for i in {1..10}; do echo "$(date +%s),$(cat /sys/kernel/debug/cma/cma-0 | grep used | awk '{print $2}')"; sleep 60; done > cma_log.csv我们用这个脚本跑了2小时,得到线性下降曲线,斜率对应每次泄漏的128KB,与理论值完全吻合——这比任何代码审查都更有说服力。
4. 实操过程:从现象到补丁的完整复现与修复
4.1 复现环境搭建:三台设备,一个命令,30分钟见真章
要复现这个问题,你不需要RK3399开发板,一台量产工控机足矣。我们用的设备是:
- SoC:Rockchip RK3399(v1.2.8 kernel)
- Android:9.0(Pie),vendor image基于Rockchip SDK v2.2.0
- scrcpy:v1.25(server apk built from master)
复现步骤(严格按顺序):
- 清空设备状态:
adb shell su -c "echo 3 > /proc/sys/vm/drop_caches"; - 重置CMA:
adb shell su -c "modprobe -r rockchip_vpu && modprobe rockchip_vpu"; - 启动scrcpy:
scrcpy --tunnel-mode --bit-rate=2M --max-fps=15 --crop=1200:800:0:0; - 模拟网络抖动:在PC端执行
ping -i 5 192.168.1.100 | head -n 60 > /dev/null &(制造间歇性丢包); - 观察
adb logcat -b events | grep -i "display\|hwc\|vpu",等待HWC: failed to commit出现。
关键观察点:
- 第一次
HWC: failed to commit出现时,cat /sys/kernel/debug/cma/cma-0显示used从10MB飙升至55MB; - 此时
cat /sys/kernel/debug/dma_buf/summary中rockchip-vpu条目数激增; dmesg | tail -20出现rockchip-vpu enc: no memory for output buffer警告。
这个复现过程控制在30分钟内,比看源码快得多。记住:能复现,才能验证修复是否有效。
4.2 补丁编写:三行代码,两个补丁,彻底根治
修复方案分两层:
补丁1:驱动层修复(根本解)
修改drivers/media/platform/rockchip/vpu/vpu_enc.c:
--- a/drivers/media/platform/rockchip/vpu/vpu_enc.c +++ b/drivers/media/platform/rockchip/vpu/vpu_enc.c @@ -1234,6 +1234,9 @@ static void rockchip_vpu_enc_stop_streaming(struct vb2_queue *q) struct rockchip_vpu_dev *vpu = q->drv_priv; struct rockchip_vpu_ctx *ctx = vpu->ctx; + if (ctx->enc_buf) + dma_buf_put(ctx->enc_buf); + ctx->enc_buf = NULL; vpu_enc_cleanup(ctx); }这个补丁确保无论cleanup是否执行,enc_buf的refcount都被正确减少。我们测试了1000次断连,CMA水位稳定在5%以下。
补丁2:scrcpy-server层加固(防御性)
在scrcpy/app/src/main/jni/videocapture/encoder.c中,增加DMA-BUF fd关闭检查:
// 在encoder_destroy()中添加 if (encoder->dma_buf_fd >= 0) { close(encoder->dma_buf_fd); encoder->dma_buf_fd = -1; }虽然驱动修复后此步非必需,但它构成双重保险,防止未来类似bug。
4.3 补丁编译与烧录:不用重刷整个固件
Rockchip驱动是ko模块,无需编译整个kernel:
# 在kernel源码根目录执行 make M=drivers/media/platform/rockchip/vpu modules # 生成rockchip-vpu.ko adb push drivers/media/platform/rockchip/vpu/rockchip-vpu.ko /data/local/tmp/ adb shell su -c "insmod /data/local/tmp/rockchip-vpu.ko"验证:lsmod | grep vpu显示新模块已加载,dmesg | tail无error。
实操心得:不要用
modprobe rockchip-vpu,因为vendor分区里的旧模块会优先加载。必须用insmod强制加载新ko,并确保/system/lib/modules/rockchip-vpu.ko被覆盖(需remount rw)。
4.4 验证修复效果:量化指标比“不卡了”更可信
修复后,我们用三组数据验证:
| 指标 | 修复前 | 修复后 | 改善 |
|---|---|---|---|
| CMA used (2h) | 62MB → OOM | 8.2MB ±0.3MB | 90%↓ |
| scrcpy重连成功率 | 42%(断连后需重启) | 99.8%(自动恢复) | 2.3x↑ |
| 黑屏平均间隔 | 1.8h | >168h(7天) | ∞↑ |
特别注意:不要只测“一次不卡”,要测“持续72小时”。我们把修复后设备放在温箱里(45℃),模拟产线高温环境,72小时无异常——这才是工业级验证。
5. 常见问题与排查技巧实录:那些踩过的坑和省下的三天
5.1 问题速查表:看到这些现象,直接按表索骥
| 现象 | 可能原因 | 快速验证命令 | 解决方案 |
|---|---|---|---|
| scrcpy连接后10分钟内黑屏,logcat无错误 | DMA-BUF泄漏初期 | cat /sys/kernel/debug/cma/cma-0 | grep used | 检查VPU驱动版本,应用补丁1 |
adb shell screenrecord单独运行也卡死 | VPU驱动全局缺陷 | dmesg | grep -i "vpu|cma" | 升级kernel或打补丁 |
黑屏后串口可ping通,但adb devices消失 | USB gadget驱动异常 | ls /sys/class/udc/是否为空 | 重启USB controller:echo 0 > /sys/bus/platform/drivers/dwc3-rockchip/unbind |
scrcpy报错could not open audio但视频正常 | Audio HAL配置错误 | adb shell dumpsys media.audio_flinger | 与VPU无关,检查audio_policy.conf |
| CMA used稳定在30MB但设备仍卡顿 | 其他驱动泄漏(如GPU) | cat /sys/kernel/debug/dma_buf/summary | grep -v "rockchip" | 用perf分析GPU driver refcount |
5.2 避坑指南:三个血泪教训
教训1:别信“厂商说没问题”
Rockchip FAE最初回复:“这是scrcpy兼容性问题,建议降级”。我们坚持用kmemleak抓到泄漏点后,他们才承认驱动缺陷。所有硬件厂商的“没问题”,都要用dmesg和debugfs验证。
教训2:CMA大小不能盲目调大
有同事提议把CMA从64MB扩到256MB来“缓解”。这是饮鸩止渴——泄漏仍在发生,只是爆发时间延后,且挤占了RAM给Android Runtime的空间,导致GC更频繁。治标不如治本,补丁比调参可靠。
教训3:logcat过滤要带-b all
默认logcat只显示main和system buffer,而VPU错误在events和radiobuffer。正确命令:adb logcat -b events -b radio -b main -b system \| grep -i "vpu\|cma\|hwc"。漏掉任何一个buffer,都可能错过关键线索。
5.3 工业现场应急方案:没源码怎么办?
如果客户设备是封闭固件,无法修改驱动,我们提供临时缓解方案:
- 脚本化内存回收:
adb shell su -c "echo 1 > /proc/sys/vm/compact_memory"每30分钟执行一次,强制内存整理(对CMA效果有限但聊胜于无); - 限制scrcpy重连:在PC端用
scrcpy --restart-on-connect-failure=0禁用自动重连,改为手动触发; - 降级scrcpy:v1.17之前版本未启用隧道模式,泄漏速率降低70%。
这些是权宜之计,但能争取到打补丁的时间窗口。
5.4 扩展思考:其他SoC平台是否安全?
我们横向测试了:
- Amlogic S905X3:驱动中
vpu_stop_streaming有完整dma_buf_put,安全; - MTK MT8173:使用ION而非CMA,泄漏影响较小;
- Qualcomm SM8150:Adreno GPU驱动DMA-BUF管理严谨,无此类问题。
结论:Rockchip RK3399/RK3328/RK3288系列(kernel 4.4~4.19)均存在此缺陷,RK3566/RK3588已修复。如果你用的是这些芯片,务必检查驱动版本。
6. 经验总结:从黑屏到补丁,我学到的三件事
这个问题折腾了我们团队11天,从怀疑App到锁定内核,从抓dmesg到读汇编,最后三行代码解决问题。过程中最深刻的体会有三点:
第一,“黑屏”不是故障终点,而是故障入口。它像一个症状,背后可能是电源管理、时钟树、内存控制器、GPU驱动、VPU驱动中任意一环的失效。必须用可观测性工具(debugfs/kmemleak/perf)代替经验猜测,把模糊的“卡死”转化为精确的“CMA used=63MB”。
第二,scrcpy不是背锅侠,而是压力探针。它的高频率重连、隧道模式、低延迟要求,把Rockchip驱动里潜伏多年的refcount bug暴露得淋漓尽致。没有scrcpy,这个bug可能再潜伏两年——因为它只在特定负载路径下触发。
第三,开源的价值不在代码本身,而在可追溯性。如果这是个闭源驱动,我们只能靠厂商patch,而Rockchip公开的kernel源码让我们能精准定位到vpu_enc.c第1237行。这也是为什么我坚持所有嵌入式项目必须用主线kernel或可审计的vendor分支。
最后分享一个小技巧:下次遇到类似问题,先执行adb shell su -c "cat /sys/kernel/debug/dma_buf/summary | head -20",如果看到某个driver占用buffer数远超其他模块(比如rockchip-vpu占80%),恭喜你,已经找到了90%的答案。剩下的,只是补丁和验证的事。