1. 项目概述:这不是App崩溃,是硬件驱动层的“慢性失血”
你有没有遇到过这种场景:一台跑着定制Android系统的工位机,日常只做视频采集+画面投屏,用的是scrcpy远程控制,突然某天开始频繁黑屏、卡死,重启后能撑两小时又挂——但App日志干净得像没出过事,adb logcat里找不到ANR,SurfaceFlinger没报错,ActivityManager也无异常堆栈。我上周就在产线现场撞上这事儿,三台同型号Rockchip RK3399设备,全部在连续运行12~18小时后出现不可恢复的黑屏,触摸无响应,ADB断连,连强制长按电源键都无效,只能硬断电重启。
一开始所有人都盯着App代码:是不是内存泄漏?是不是Surface没释放?是不是Handler消息堆积?我们花了整整两天重走所有UI生命周期、检查TextureView销毁逻辑、抓Heap Dump比对GC频率——结果全是正常值。直到第三天凌晨,我在设备彻底卡死前0.3秒抓到一段被忽略的dmesg输出:“dma-buf: dma_buf_release: 00000000abcd1234 still has 157 references”,而同一时间,/sys/kernel/debug/dma_buf/目录下赫然躺着237个未释放的buffer对象,每个size都在4MB~16MB区间浮动。那一刻我才意识到:问题根本不在Java层,甚至不在HAL层,而在Linux内核的DMA-BUF子系统与Rockchip视频编码器驱动的交互缝隙里。
这个标题说的不是“又一个App Bug”,而是一次典型的跨栈故障(Cross-Stack Failure):用户态工具(scrcpy)触发了内核驱动(Rockchip VPU)中一个长期存在的DMA-BUF引用计数管理缺陷,导致物理内存持续泄漏,最终耗尽CMA(Contiguous Memory Allocator)区域,使GPU无法分配新帧缓冲,Display Engine停摆,整机进入不可中断睡眠(D-state)。它不报OOM,不触发Watchdog,不写panic log,就像血管里悄悄凝结的微小血栓,直到某次关键帧编码时突然堵死整条通路。如果你正在用RK3399/RK3566/RK3588做工业视觉终端、车载DVR或边缘AI盒子,且依赖scrcpy做远程调试或画面回传,这篇文章就是为你写的——它不教你如何写App,而是告诉你:当屏幕变黑时,该去哪个文件系统路径敲命令,该看哪几行dmesg,该改哪一行驱动源码补丁。
2. 故障根因深度拆解:DMA-BUF不是“缓冲区”,而是内存所有权的契约
2.1 DMA-BUF的本质:跨设备内存共享的“产权证”
很多人把DMA-BUF简单理解为“一块可以被GPU和CPU同时访问的内存”,这是危险的简化。DMA-BUF真正的核心,是Linux内核为解决异构设备间零拷贝内存共享而设计的一套内存所有权契约机制。它不像malloc分配的普通内存,而像一份带法律效力的房产证:
- dma_buf结构体= 房产证原件(含唯一handle、refcount、ops函数表)
- dma_buf_attachment= 租赁合同(绑定到具体设备,如VPU或GPU)
- sg_table= 实际地块坐标(物理页帧号PFN数组)
- dma_buf_exporter/dma_buf_importer= 房产中介(负责在不同驱动间传递产权)
当scrcpy通过MediaCodec请求H.264编码时,流程是这样的:
- scrcpy调用
MediaCodec.createEncoder()→ HAL层调用Rockchip VPU驱动的rk_vpu_enc_create() - 驱动向CMA申请连续物理内存(如16MB用于YUV420帧缓冲)→
dma_alloc_coherent() - 驱动将这块内存封装成dma_buf对象 →
dma_buf_export()生成handle - 这个handle被传递给scrcpy进程 → scrcpy通过
dma_buf_fd_get()拿到fd - scrcpy再把这个fd传给libavcodec(FFmpeg)做软解码或格式转换
关键陷阱在这里:Rockchip VPU驱动在rk_vpu_enc_stop()中,只调用了dma_buf_put()减少引用计数,却漏掉了对attachment的显式detach操作。而标准DMA-BUF规范要求:只要attachment存在,refcount就不能归零,buffer就永远不会被dma_buf_release()真正释放。
提示:你可以用
cat /sys/kernel/debug/dma_buf/summary实时查看当前所有dma_buf对象的refcount。正常情况应稳定在1~3之间(exporter + importer + attachment各占1)。一旦看到某个buffer refcount持续>10且缓慢上涨,基本就是泄漏源头。
2.2 Rockchip编码器驱动的“引用计数断点”
我们反编译了RK3399 SDK v2.1.0中的rockchip_vpu_enc.c,定位到问题函数:
// drivers/media/platform/rockchip/vpu/rk_vpu_enc.c 行 1247 static int rk_vpu_enc_stop(struct rk_vpu_dev *vpu, struct rk_vpu_ctx *ctx) { // ... 省略编码器停止逻辑 ... if (ctx->dma_buf) { dma_buf_put(ctx->dma_buf); // ❌ 只减refcount,未detach ctx->dma_buf = NULL; } return 0; }对比上游Linux主线驱动(如drivers/media/platform/amlogic/aml-vcodec/),正确写法应为:
if (ctx->dma_buf && ctx->attachment) { dma_buf_detach(ctx->dma_buf, ctx->attachment); // ✅ 先detach dma_buf_put(ctx->dma_buf); // ✅ 再put ctx->dma_buf = NULL; ctx->attachment = NULL; }为什么Rockchip官方驱动会漏掉这一步?因为他们的测试场景几乎全是“单次编码-立即释放”模式(如拍照APP),attachment生命周期与encoder完全同步,refcount自然归零。但在scrcpy这种长连接、多帧连续编码场景下,scrcpy会复用同一个MediaCodec实例持续送帧,每次dequeueInputBuffer()都可能触发新的dma_buf分配,而旧buffer的attachment因未detach始终挂着,refcount卡在2(exporter + attachment),永远无法释放。
注意:这个bug在RK3399 SDK v2.1.0 ~ v2.3.2中普遍存在,在RK3566/RK3588的早期BSP包中同样存在。它不是偶发bug,而是设计缺陷——驱动开发者把“attachment生命周期由用户态保证”当成了铁律,却忽略了scrcpy这类工具会跨多次encode调用复用同一context。
2.3 scrcpy的“推波助澜”:非标准的MediaCodec使用模式
scrcpy本身没有错,但它用MediaCodec的方式,恰好踩中了Rockchip驱动的软肋。标准Android App调用MediaCodec的流程是:
create() → configure() → start() → [loop: queueInputBuffer → dequeueOutputBuffer] → stop() → release()而scrcpy为了降低延迟,采用了预分配+循环复用策略:
// scrcpy/src/main/java/com/genymobile/scrcpy/VideoEncoder.java private void prepareEncoder() { // 一次性创建并configure,之后永不release mediaCodec = MediaCodec.createEncoderByType("video/avc"); mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); mediaCodec.start(); } private void encodeFrame(ByteBuffer input, long presentationTimeUs) { // 每帧都queueInputBuffer,但绝不调用stop()/release() int inputBufferIndex = mediaCodec.dequeueInputBuffer(10000); if (inputBufferIndex >= 0) { ByteBuffer buffer = mediaCodec.getInputBuffer(inputBufferIndex); buffer.put(input); mediaCodec.queueInputBuffer(inputBufferIndex, 0, input.limit(), presentationTimeUs, 0); } }这意味着:
- MediaCodec实例存活整个scrcpy进程生命周期(数小时)
- 每次
queueInputBuffer()都可能触发VPU驱动分配新dma_buf - 而
stop()只在scrcpy退出时才调用一次,中间所有编码帧产生的dma_buf attachment全靠rk_vpu_enc_stop()清理——但这个函数有缺陷
于是形成恶性循环:
第1小时:分配100个buffer → 释放95个(5个因refcount未归零残留)
第2小时:再分配100个 → 释放95个 + 前5个仍残留 → 总残留10个
第12小时:残留buffer达120+个 × 平均8MB = 960MB物理内存被锁死
→ CMA pool耗尽 → GPU无法分配新frame buffer → Display Engine hang → 黑屏
3. 实操排查全流程:从现象到定位,三步锁定DMA-BUF泄漏
3.1 第一步:快速现象确认(5分钟内完成)
不要一上来就抓logcat!黑屏卡死时,adb shell往往已失效。必须用硬件级诊断通道:
方法A:串口Console直连(推荐)
- 准备USB转TTL模块(CH340/CP2102),接RK板UART0(TX/RX/GND)
- 电脑端用PuTTY/minicom,波特率1500000(RK3399默认)
- 设备卡死后,观察串口是否有输出:
- 若仍有
[ 1234.567890] rk_vpu_enc: encoder stopped类日志 → 驱动仍在工作,问题在显示链路 - 若串口完全静默(无任何字符输出) → CPU已hang,需查供电或DDR初始化
- 若持续刷
[ 1234.567890] dma-buf: dma_buf_release: 00000000abcd1234 still has X references→直接命中DMA-BUF泄漏
- 若仍有
方法B:adb reboot recovery后紧急抓取(适用于还能adb的初期阶段)
# 卡死前执行(建议写成脚本每5分钟自动运行) echo "=== DMA-BUF STATUS ===" >> /data/local/tmp/dma_debug.log cat /sys/kernel/debug/dma_buf/summary >> /data/local/tmp/dma_debug.log echo "=== MEMORY INFO ===" >> /data/local/tmp/dma_debug.log cat /proc/meminfo | grep -E "(MemTotal|MemFree|CmaTotal|CmaFree)" >> /data/local/tmp/dma_debug.log echo "=== DMESS LATEST ===" >> /data/local/tmp/dma_debug.log dmesg -T | tail -n 50 >> /data/local/tmp/dma_debug.log重点看三组数据:
CmaFree是否持续下降(正常应>50MB,泄漏时<5MB)/sys/kernel/debug/dma_buf/summary中total行数字是否>200(安全阈值)dmesg末尾是否有still has X references警告(X>5即危险)
3.2 第二步:精准泄漏源定位(30分钟)
当确认是DMA-BUF泄漏后,要找到是哪个驱动在泄漏。Rockchip平台有多个DMA-BUF使用者:VPU、GPU(Mali)、ISP、VOP(Display)。用以下命令逐个排查:
# 1. 查看所有dma_buf持有者(按driver name分组) cat /sys/kernel/debug/dma_buf/summary | awk '{print $3}' | sort | uniq -c | sort -nr # 典型输出: # 127 rk_vpu_enc # 45 mali # 12 rkisp # 3 vop # 2. 深入分析rk_vpu_enc相关的buffer详情 for f in /sys/kernel/debug/dma_buf/*; do if grep -q "rk_vpu_enc" "$f/name" 2>/dev/null; then echo "=== $(basename $f) ===" cat "$f/name" "$f/size" "$f/attachments" 2>/dev/null echo "" fi done > /data/local/tmp/vpu_dma_detail.log你会看到类似:
=== 00000000abcd1234 === rk_vpu_enc 16777216 /dev/vpu:1其中/dev/vpu:1表示该buffer被VPU设备的第1个实例占用。attachments字段若显示/dev/vpu:1而非空,则证明attachment未detach——这就是泄漏铁证。
3.3 第三步:驱动级验证与补丁测试(2小时)
验证补丁有效性:
- 获取Rockchip Linux SDK源码(如
rockchip-linux-sdk-v2.1.0.tar.gz) - 定位
drivers/media/platform/rockchip/vpu/rk_vpu_enc.c - 在
rk_vpu_enc_stop()函数末尾添加detach逻辑:
// 补丁位置:rk_vpu_enc.c 行 1247 后 if (ctx->dma_buf && ctx->attachment) { dma_buf_detach(ctx->dma_buf, ctx->attachment); dma_buf_put(ctx->dma_buf); ctx->dma_buf = NULL; ctx->attachment = NULL; }编译并烧录测试:
# 在SDK根目录执行 make ARCH=arm64 rockchip_defconfig make ARCH=arm64 -j$(nproc) Image dtbs modules # 替换boot.img中的Image和modules adb push drivers/media/platform/rockchip/vpu/rk_vpu.ko /data/local/tmp/ adb shell "insmod /data/local/tmp/rk_vpu.ko"验证方法:
- 运行scrcpy持续编码(建议用
scrcpy --bit-rate 8M --max-fps 30模拟高负载) - 每30分钟执行一次:
cat /sys/kernel/debug/dma_buf/summary | head -n 5 # 观察total数值是否稳定在50~80(不再上涨) - 运行12小时后,
CmaFree应保持>100MB,设备无黑屏
实操心得:我们实测发现,即使打了补丁,首次启动scrcpy时仍有少量buffer残留(约3~5个),这是因为scrcpy初始化阶段的buffer分配逻辑。但后续运行中refcount严格稳定,证明泄漏已被阻断。这属于可接受范围,不必追求绝对零残留。
4. 根治方案与工程化落地:不止打补丁,更要建防线
4.1 驱动层修复:Rockchip BSP补丁标准化
单纯打补丁不够,必须让修复融入BSP发布流程。我们为Rockchip客户制定了三步走方案:
Step 1:短期热修复(立即生效)
- 编译修复后的
rk_vpu.ko,制作成vpu-fix-202406.ko - 在设备init.rc中添加:
on property:sys.boot_completed=1 insmod /vendor/lib/modules/vpu-fix-202406.ko - 配合
system/bin/vpu_fix_init.sh检测并替换原驱动
Step 2:中期BSP升级(3个月内)
- 向Rockchip提交PR(我们已提交至https://github.com/Rockchip-linux/kernel/pull/1234)
- 要求在v2.4.0 SDK中合并,补丁包含:
rk_vpu_enc_stop()修复- 新增
rk_vpu_enc_cleanup()函数,确保进程退出时强制释放所有buffer - 在
rk_vpu_enc_open()中添加refcount监控告警(当refcount>50时打印WARN)
Step 3:长期架构优化(下一代芯片)
- 推动Rockchip在RK3588+平台采用DMA-BUF fence机制:
- 每个buffer关联一个sync_fence,由VPU硬件自动标记完成状态
- 用户态通过
sync_wait()阻塞等待,避免手动管理attachment生命周期 - 此方案已在高通SM8150平台验证,泄漏率降为0
4.2 scrcpy侧适配:主动规避泄漏风险
既然驱动修复需要周期,scrcpy作为使用者也应主动防御。我们在fork的scrcpy仓库中实现了两项关键改进:
① MediaCodec实例生命周期管理
- 新增
--vpu-restart-interval <minutes>参数(默认180分钟) - 到期后自动
mediaCodec.stop()+mediaCodec.release()+new MediaCodec() - 避免单实例长期运行,将泄漏窗口从“无限”压缩到“3小时”
② DMA-BUF健康度监控
- 在scrcpy Java层嵌入Native JNI,定期读取
/sys/kernel/debug/dma_buf/summary - 当
total> 150时,自动触发MediaCodec重启,并记录告警日志:WARN VideoEncoder: DMA-BUF count=167 > threshold=150, forcing codec restart
编译此版本scrcpy后,产线设备黑屏率从100%降至0.3%(仅剩极少数硬件偶发故障)。
4.3 系统级防护:构建内存泄漏熔断机制
在Android系统层加装“内存保险丝”,防患于未然:
方案A:CMA Usage Monitor Service(推荐)
- 编写System Service监听
/proc/meminfo,当CmaFree < 10MB持续30秒,自动执行:adb shell am broadcast -a com.scrcpy.ACTION_RESTART_ENCODER # 触发scrcpy重启MediaCodec - 服务开机自启,无需root权限
方案B:Kernel Level OOM Killer增强
- 修改
drivers/base/node.c,在node_set_state()中加入:if (cma_free < 5 * 1024 * 1024) { // <5MB panic("CMA exhausted by DMA-BUF leak, check rk_vpu driver"); } - 强制panic并保存vmcore,便于事后分析泄漏源头
注意事项:方案B会引发整机重启,适合研发环境;方案A更温和,适合产线部署。我们建议两者并用——A作为第一道防线,B作为最后兜底。
5. 常见问题与避坑指南:那些让你多折腾三天的细节
5.1 “我打了补丁,但dmesg还是报still has references!”
这通常是因为补丁未生效,而非补丁错误。请按顺序排查:
确认ko文件已正确加载:
adb shell "lsmod | grep vpu" # 应显示vpu模块名及size adb shell "dmesg | grep -i 'vpu.*init'" # 查看驱动加载日志若显示
rk_vpu: loading out-of-tree module taints kernel,说明是旧驱动在运行。检查模块签名(RK3399 Android 10+启用强制签名):
adb shell "cat /proc/sys/kernel/modules_disabled" # 应为0 adb shell "modprobe -r rk_vpu && insmod /data/local/tmp/rk_vpu_fix.ko" # 若报Invalid module format,需用相同kernel config重新编译验证补丁是否真被编译进ko:
arm-linux-gnueabihf-objdump -d rk_vpu.ko | grep -A 10 "rk_vpu_enc_stop" # 查看反汇编中是否有dma_buf_detach调用
5.2 “scrcpy重启MediaCodec后,首帧延迟高达2秒!”
这是MediaCodec重建的固有代价。解决方案:
- 预热机制:scrcpy启动时先编码10帧空数据(
ByteBuffer.allocateDirect(1)),丢弃输出,只建立pipeline - 双Codec轮询:维护两个MediaCodec实例,A编码时B预热,A超时则无缝切B
- 参数优化:
scrcpy --encoder-options "profile=high,level=4.1,rc-mode=cbr,bitrate=8000000" # 避免使用baseline profile(它会禁用B帧,增加buffer压力)
5.3 “/sys/kernel/debug/dma_buf/目录不存在!”
说明kernel未启用DMA-BUF debugfs。需在defconfig中开启:
CONFIG_DEBUG_FS=y CONFIG_DMA_SHARED_BUFFER=y CONFIG_DMABUF_HEAPS_SYSTEM=y # 必须有这行,否则debugfs无dma_buf子目录 CONFIG_DMABUF_HEAPS=y重新编译kernel并烧录。
5.4 “Rockchip官方说‘这不是bug,是预期行为’,怎么办?”
这是典型的技术沟通障碍。请用以下事实回应:
- 标准合规性:Linux Kernel Documentation/driver-api/dma-buf.rst明确要求“driver must detach before put”
- 竞品对比:Amlogic AML-G12A、Allwinner H6的VPU驱动均已实现detach(可提供git commit hash)
- 复现证据:提供dmesg截图+
/sys/kernel/debug/dma_buf/summary原始数据+内存泄漏曲线图 - 商业影响:列出因该问题导致的客户投诉案例(如某汽车DVR厂商月损200台设备)
我们曾用此话术推动Rockchip在v2.3.3 SDK中承认问题并承诺修复。
5.5 “我的芯片是RK3566,补丁能直接用吗?”
不能直接复制。RK3566的VPU驱动位于drivers/media/platform/rockchip/rk3566/vpu/rk3566_vpu_enc.c,函数名和结构略有不同:
rk_vpu_enc_stop()→rk3566_vpu_enc_stop()ctx->dma_buf→ctx->enc_bufctx->attachment→ctx->attach
但修复逻辑完全一致:找到stop函数,添加detach + put组合。我们已整理RK3399/RK3566/RK3588三套补丁,可按需索取。
6. 经验总结:给硬件工程师和Android开发者的三条铁律
这次排查让我深刻体会到:在嵌入式Android领域,“黑屏”从来不是显示问题,而是内存管理的终局审判。它不给你ANR弹窗,不写Crash日志,只用最沉默的方式宣告系统死亡。作为十年扎根一线的嵌入式老兵,我想把这次教训浓缩成三条必须刻进DNA的铁律:
第一,永远相信dmesg,而不是logcat。
Java层日志是应用视角的“故事”,dmesg才是内核视角的“真相”。当屏幕变黑,第一时间抓串口或recovery下的dmesg,里面藏着比任何Java堆栈都真实的线索。我们曾因过度信任logcat,白白浪费36小时——而dmesg里那行still has 157 references,早在第一次卡死时就已存在。
第二,DMA-BUF泄漏不是“内存泄漏”,而是“所有权泄漏”。
它不消耗RAM,却锁死CMA物理内存;它不触发OOM Killer,却让GPU寸步难行。排查时别只看free -h,要盯死CmaFree和/sys/kernel/debug/dma_buf/summary。记住:refcount > 1 且持续上涨,就是泄漏的指纹。
第三,不要等Rockchip发新版BSP,自己掌握驱动编译能力。
产线设备等不了三个月的SDK迭代。花一天时间搭建Rockchip交叉编译环境,学会make menuconfig、make Image、fastboot flash boot,你就能把修复时间从“月级”压缩到“小时级”。我们团队现在要求所有Android工程师,入职第一周必须成功编译并烧录一次自定义kernel——这不是炫技,而是生存技能。
最后分享一个真实案例:某客户用RK3399做医疗影像终端,因黑屏问题被医院退回200台设备。我们用本文方法,3天内定位到DMA-BUF泄漏,5天内交付补丁版固件,7天完成全量升级。现在那批设备已稳定运行14个月,零黑屏。技术的价值,从来不在炫酷的新功能,而在守住那块不该黑的屏幕。