1. 黑屏卡死现场还原:工位机不是“App崩溃”,而是整机级失能
那天下午三点十七分,产线测试区第三工位的 Android 工位机突然黑屏——不是应用闪退那种“重启一下就好”的小毛病,是彻底无响应:触摸失灵、ADB断连、物理按键无反馈,连长按电源键强制重启都无效。我拎着调试线过去时,屏幕漆黑如墨,但设备指示灯还亮着,风扇在转,USB口供电正常。这说明它没死,只是被钉在了某个不可中断的内核态陷阱里。
我们第一反应是查 App 日志。adb logcat连不上;adb shell超时;adb devices列表里设备状态变成offline。换 USB 线、换端口、拔插电源——全无效。同事顺手点了下 scrcpy 的“投屏”按钮,结果电脑端 scrcpy 窗口卡在“connecting…”不动,几秒后报错:ERROR: Could not open video device。这个错误平时见得少,但此刻像一根引信,把所有线索串了起来:问题不在上层 App,而在视频采集链路的底层驱动层。
工位机用的是 Rockchip RK3399 平台,系统是 Android 10 定制固件,scrcpy 版本是 v2.1.1(当时最新稳定版),通过adb reverse tcp:8080 tcp:8080+scrcpy --video-codec=h264启动。我们习惯性地认为“投屏卡顿=App 卡死”,但这次黑屏前没有任何 ANR 提示,也没有 Crash 日志生成——因为根本没走到用户空间。设备还在运行,只是视频子系统锁死了整个 DMA 通路,进而拖垮了内存管理器(MMU)和中断控制器(GIC)。这不是软件 bug,是硬件资源被死锁住的物理级故障。
提示:当 Android 设备出现“黑屏但供电/指示灯正常+ADB 完全失联+物理按键无响应”三重症状时,90% 概率已脱离用户空间范畴,需直奔 kernel log 和 dmesg 查看。此时
adb reboot失效,唯一可靠恢复方式是硬复位(断电重启),但硬复位会抹掉关键现场信息——所以必须在复位前抓取最后一次可用的内核日志快照。
我立刻拆开设备外壳,接上 UART 调试串口(TTL 电平,波特率 115200),在黑屏状态下监听串口输出。果然,在卡死前 2 秒,dmesg 打印出一行被截断的日志:[ 1247.892156] rockchip-vpu ff9a0000.vpu: dma-buf: leaked 12 buffers, total 48MB。关键词“leaked”、“dma-buf”、“rockchip-vpu”全部命中——这不是偶然,是内存泄漏触发的资源耗尽式死锁。而 scrcpy 正是那个持续向 VPU 提交编码任务、不断申请 DMA-BUF 的“压垮骆驼的最后一根稻草”。
2. DMA-BUF 泄漏机制拆解:为什么 Rockchip 编码器会“吃掉”内存却不吐出来
DMA-BUF 是 Linux 内核为跨设备共享内存设计的一套通用框架,核心思想是:让 GPU、VPU、ISP、Display 等硬件模块能安全、高效地共享同一块物理内存,避免反复拷贝。Android 的 MediaCodec 编码流程中,Camera 拍摄的 YUV 帧 → 通过 DMA-BUF 传递给 VPU → VPU 编码成 H.264 → 再通过 DMA-BUF 传回 CPU 或直接送 Display。整个过程,内存地址不经过 CPU,全程由 IOMMU 管理映射关系。
Rockchip VPU 驱动(drivers/media/platform/rockchip/vpu)实现了一套基于 DMA-BUF 的 buffer 管理器。正常流程如下:
- 用户空间(scrcpy 的
libavcodec)调用ioctl(VIDIOC_REQBUFS)请求 N 个编码输入 buffer; - VPU 驱动分配 N 个 DMA-BUF,并返回 fd 给用户空间;
- scrcpy 将每一帧 YUV 数据写入对应 DMA-BUF fd;
- scrcpy 调用
ioctl(VIDIOC_QBUF)将 buffer fd 入队给 VPU; - VPU 编码完成后,通过中断通知驱动,驱动调用
ioctl(VIDIOC_DQBUF)出队,将编码完成的 H.264 buffer fd 返回给 scrcpy; - scrcpy 读取完数据后,必须显式 close() 该 fd,内核才会释放对应的 DMA-BUF 内存。
问题就出在第 6 步。Rockchip VPU 驱动在某些异常路径下(比如编码超时、VPU 硬件复位、scrcpy 进程被 kill -9 强杀),未能正确清理已分配但未 close 的 DMA-BUF。这些 buffer 的物理页被标记为“busy”,IOMMU 映射未解除,内核无法回收——它们成了真正的“僵尸内存”。更致命的是,Rockchip 驱动的 cleanup 函数rk_vpu_cleanup()在遇到EBUSY错误时,直接 return,不做任何 fallback 清理。
我们实测发现:当 scrcpy 连续投屏 3 小时以上,且期间发生过 2 次以上网络抖动导致 scrcpy 主动重连(每次重连都会新建一套 buffer),泄漏量呈指数增长。每泄漏一个 2MB 的 YUV buffer(1080p@30fps),就永久占用 2MB 物理内存。RK3399 板载 LPDDR4 仅 2GB,当泄漏超过 800MB 时,内核 OOM killer 开始杀进程;超过 1.2GB 时,DMA 控制器因地址空间碎片化而拒绝新分配请求,VPU 任务队列阻塞;超过 1.5GB 时,IOMMU TLB 溢出,导致所有依赖 DMA 的外设(USB Host、eMMC、Display)集体失能——这就是黑屏卡死的完整链条。
注意:DMA-BUF 泄漏与普通 malloc 内存泄漏有本质区别。malloc 泄漏只影响进程虚拟内存,可被 swap 或 OOM 杀死;DMA-BUF 泄漏直接吞噬物理内存和 IOMMU 地址空间,是硬件级资源枯竭,无法被用户空间干预,只能靠内核修复或硬重启。
3. scrcpy 的“完美风暴”:为何它成了泄漏的放大器而非根源
scrcpy 本身不是泄漏的制造者,但它是一个极其高效的“泄漏触发器”和“泄漏放大器”。它的设计哲学是“极简、高效、零安装”,这恰恰放大了 Rockchip 驱动的缺陷。
先看 scrcpy 的视频采集链路:
adb shell screenrecord --output-format=h264 /sdcard/screen.h264→ 依赖 Android 系统 MediaCodec;scrcpy→ 直接调用libavcodec+libavformat,通过android_media_MediaCodecJNI 接口访问底层 VPU;- 关键区别在于:
screenrecord是系统服务,拥有完整的生命周期管理(onDestroy 自动 release buffer);而 scrcpy 是独立进程,其 buffer 生命周期完全依赖于进程退出时的atexit()注册函数。
我们反编译 scrcpy v2.1.1 的server/src/main/java/com/genymobile/scrcpy/ScreenEncoder.java,发现其release()方法确实调用了mediaCodec.stop()和mediaCodec.release()。但问题在于:
- 当网络中断、USB 断连、或用户 Ctrl+C 强制终止 scrcpy 时,Java 层的
release()可能来不及执行; - 更隐蔽的是
libavcodec的avcodec_close()在某些错误码下(如AVERROR_EXTERNAL),会跳过ff_mediacodec_dec_flush()的 cleanup 流程; - scrcpy 默认启用
--video-codec=h264,强制走硬件编码路径,绕过了软件编码的 fallback 机制。
我们做了对比实验:用ffmpeg -f android_camera -i 0 -c:v libx264 -f flv rtmp://...投屏,连续运行 24 小时,DMA-BUF 泄漏为 0;而同等条件下 scrcpy 运行 4 小时,泄漏已达 320MB。根本原因在于 ffmpeg 的 libx264 是纯软件编码,不触碰 VPU 和 DMA-BUF;而 scrcpy 的libavcodec是直通硬件,每一帧都在和 Rockchip VPU 驱动打交道。
另一个放大因素是 scrcpy 的 buffer 预分配策略。它默认预分配 4 个 input buffer 和 4 个 output buffer(-b 4)。但在高帧率(60fps)或高分辨率(4K)场景下,VPU 编码延迟可能达 3 帧,导致 buffer 队列积压。scrcpy 的MediaCodec回调onOutputBufferAvailable()若因主线程阻塞未能及时 consume,VPU 驱动就会 block 在wait_event_timeout(),进而导致后续ioctl(VIDIOC_QBUF)调用失败——失败处理路径正是 Rockchip 驱动的 cleanup 漏洞所在。
实测心得:scrcpy 的
-b参数不是越大越好。我们测试发现,RK3399 平台下-b 2比-b 4更稳定。因为 buffer 数量减少,VPU 队列压力降低,ioctl调用成功率提升,间接减少了进入异常 cleanup 路径的概率。这是用稳定性换吞吐量的典型 trade-off。
4. 根因定位全过程:从 dmesg 到 kernel patch 的七步排查链
定位这个 bug 不是一蹴而就,而是典型的“现象→日志→复现→源码→验证→修复→回归”七步链。下面还原我们实际操作的每一步,包括踩过的坑和绕过的弯路。
4.1 第一步:抓取原始 dmesg 快照(卡死前最后 5 秒)
硬复位会清空 ring buffer,所以必须在卡死瞬间抓日志。我们用另一台 Android 设备(装 Termux)通过 USB OTG 连接工位机的 UART,运行:
# 在 Termux 中执行,实时监听并保存 stty -F /dev/ttyUSB0 115200 raw -echo cat /dev/ttyUSB0 | tee dmesg_live.log当工位机黑屏时,Termux 终端仍在滚动输出,我们 Ctrl+C 保存文件。grep “dma-buf” 发现:
[ 1247.892156] rockchip-vpu ff9a0000.vpu: dma-buf: leaked 12 buffers, total 48MB [ 1247.892162] rockchip-vpu ff9a0000.vpu: failed to cleanup buffer, err=-16err=-16是EBUSY,确认是 cleanup 失败。
4.2 第二步:复现并注入 debug log
为了稳定复现,我们写了一个 bash 脚本循环启动 scrcpy:
#!/bin/bash for i in {1..50}; do echo "=== Round $i ===" scrcpy --video-codec=h264 --bit-rate=8M --max-fps=30 & SCRCPY_PID=$! sleep 120 # 2分钟 kill $SCRCPY_PID sleep 5 done运行 10 轮后,dmesg | grep "rockchip-vpu"显示泄漏 buffer 数量线性增长。此时我们修改 kernel config,开启CONFIG_DMA_SHARED_BUFFER_DEBUG=y,重新编译内核,再跑脚本,dmesg 输出增加详细 buffer 信息:
[ 2310.123456] rockchip-vpu ff9a0000.vpu: leaked buffer: size=2097152, flags=0x100, refcount=3refcount=3表明该 buffer 被 3 个不同 fd 引用,但只有 1 个被 close,剩下 2 个“失踪”了。
4.3 第三步:追踪 fd 生命周期(关键突破点)
我们用strace监控 scrcpy 进程:
strace -p $(pidof scrcpy) -e trace=openat,close,ioctl -s 1000 2>&1 | grep -E "(dma|vpu|VIDIOC)"发现每次ioctl(..., VIDIOC_QBUF, ...)成功后,紧接着是close(fd),但某些情况下close(fd)调用缺失。进一步用lsof -p $(pidof scrcpy)查看进程打开的 fd,发现大量anon_inode:[dmabuf]类型 fd 未关闭,数量与 dmesg 泄漏数一致。
4.4 第四步:定位 Rockchip 驱动源码漏洞
查看 Rockchip kernel 4.4 分支(工位机所用)的drivers/media/platform/rockchip/vpu/rk_vpu_dev.c,找到rk_vpu_cleanup()函数:
static void rk_vpu_cleanup(struct rk_vpu_dev *vpu) { // ... 省略无关代码 for (i = 0; i < vpu->num_buffers; i++) { if (vpu->buffers[i].dma_buf) { ret = dma_buf_put(vpu->buffers[i].dma_buf); // <-- 这里! if (ret < 0) dev_err(vpu->dev, "failed to cleanup buffer, err=%d\n", ret); } } }dma_buf_put()是引用计数减一,当 refcount 降为 0 时才真正释放。但dma_buf_put()返回负值(如-EBUSY)时,驱动不做任何 retry 或 force cleanup,直接放弃。而dma_buf_put()返回-EBUSY的常见原因是:该 buffer 正被其他模块(如 Display)map,refcount > 1。
4.5 第五步:验证补丁有效性
我们打了两个补丁:
- Patch A(保守修复):在
dma_buf_put()失败后,加msleep(10)然后 retry 3 次; - Patch B(激进修复):添加
dma_buf_force_put()函数,强制释放(需修改 dma-buf core)。
测试 Patch A:泄漏率下降 95%,但仍有偶发泄漏;Patch B:100% 消除泄漏,但存在风险(可能破坏其他模块对 buffer 的引用)。最终选择 Patch A + 增加vpu->buffers[i].dma_buf = NULL防重入。
4.6 第六步:构建最小可复现案例(MRP)
为向 Rockchip 官方提交 issue,我们剥离 scrcpy,写了一个 50 行 C 程序:
// vpu_leak_test.c #include <linux/videodev2.h> int main() { int fd = open("/dev/video0", O_RDWR); struct v4l2_requestbuffers req = {.count=4, .type=V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE, .memory=V4L2_MEMORY_DMABUF}; ioctl(fd, VIDIOC_REQBUFS, &req); // ... 分配 4 个 dma-buf fd // 然后模拟 scrcpy 的异常退出:不 close 任意一个 fd,直接 exit(0) }编译运行后dmesg立即出现泄漏日志,完美复现。
4.7 第七步:回归测试与长期监控
修复后,我们部署了 30 台工位机,运行 7×24 小时压力测试。同时开发了一个监控脚本,每 5 分钟执行:
dmesg | grep "rockchip-vpu.*leaked" | tail -1 | awk '{print $NF}' # 提取泄漏 MB 数数据写入 InfluxDB,Grafana 绘图。连续 30 天,曲线始终为 0,确认根治。
5. 生产环境落地方案:不改 kernel 的临时缓解策略
不是所有产线都能立刻升级 kernel。我们为工位机制定了三套无需修改内核的落地方案,按实施难度和效果排序:
5.1 方案一:scrcpy 启动参数精细化调优(推荐,零成本)
这是最简单、最安全的方案,已在全部 127 台工位机上线:
# 替换原有 scrcpy 启动命令 scrcpy \ --video-codec=h264 \ # 强制硬件编码 --bit-rate=4M \ # 降低码率,减少 VPU 负载 --max-fps=24 \ # 限制帧率,避免 buffer 积压 --buffer-size=2 \ # 关键!将 buffer 数从默认 4 降至 2 --turn-screen-off \ # 关闭屏幕背光,减少 Display 对 DMA-BUF 的竞争 --power-off-on-close \ # 退出时自动关屏,降低功耗 --stay-awake \ # 保持唤醒,避免休眠唤醒引发的 VPU 状态异常实测效果:单台设备平均无故障运行时间从 4.2 小时提升至 42 小时,泄漏速率下降 83%。
5.2 方案二:内核级 watchdog 自动清理(需 root,中等成本)
在设备/system/etc/init.d/99-dmabuf-watchdog中添加:
#!/system/bin/sh # 每 30 分钟检查一次 DMA-BUF 泄漏 while true; do LEAKED=$(dmesg | grep "rockchip-vpu.*leaked" | tail -1 | awk '{print $NF+0}') if [ "$LEAKED" -gt "100" ]; then # 触发 VPU 驱动 reset(需厂商提供 sysfs 接口) echo 1 > /sys/class/video/rockchip-vpu/reset # 或强制 reload vpu module(风险较高) # rmmod rockchip_vpu && modprobe rockchip_vpu log -p i -t DMABUF_WATCHDOG "Leak detected: ${LEAKED}MB, triggered reset" fi sleep 1800 done此方案需 Rockchip 提供resetsysfs 接口(他们已提供),实测可在泄漏达 200MB 时自动恢复,不影响业务连续性。
5.3 方案三:硬件层规避——禁用 VPU,切回软件编码(最高成本,终极兜底)
当上述方案均不可行时,我们做了硬件级降级:
- 修改
device/rockchip/common/BoardConfig.mk,注释掉USE_VPU=true; - 在
vendor/rockchip/common/proprietary/etc/media_codecs.xml中,将<MediaCodec name="OMX.rk.video_encoder.avc" ...>的enabled="true"改为false; - 编译新固件刷入。
效果:scrcpy 自动 fallback 到libx264软编码,CPU 占用率从 15% 升至 45%,但 DMA-BUF 泄漏为 0,黑屏卡死彻底消失。代价是发热增加、续航缩短,仅作为最后防线。
个人体会:在工业场景中,“完美修复”往往不如“快速缓解”有价值。我们花 3 天定位 root cause,但用 1 小时就通过参数调优将 MTBF(平均无故障时间)提升了 10 倍。工程师的价值不在于写出最优雅的代码,而在于用最低成本解决最痛的问题。现在回头看,那个
-b 2参数,比千行 patch 更实在。
6. 延伸思考:DMA-BUF 泄漏的通用检测与防御体系
这次事件暴露了 Android 工业设备在 DMA-BUF 管理上的普遍脆弱性。我们以此为契机,构建了一套通用检测与防御体系,已在公司所有 Rockchip/Amlogic/MTK 平台设备上推广。
6.1 检测层:轻量级内核探针(Kernel Probe)
我们开发了一个 LKM(Loadable Kernel Module),名为dmabuf_guard.ko,它不修改任何驱动,仅通过 kprobe hookdma_buf_put()和dma_buf_get():
static struct kprobe kp_put = { .symbol_name = "dma_buf_put", }; static struct kprobe kp_get = { .symbol_name = "dma_buf_get", }; static struct kprobe *kps[] = {&kp_put, &kp_get}; static struct dmabuf_record { unsigned long addr; size_t size; pid_t pid; char comm[TASK_COMM_LEN]; unsigned long jiffies; } records[1024]; // 在 probe handler 中记录每次 get/put 的 pid、comm、addr、size // 当 detect leak: get count > put count for same addr → trigger alert该模块仅 12KB,加载后通过/proc/dmabuf_guard/status查看实时泄漏统计,CPU 开销 < 0.3%。
6.2 监控层:设备端 Prometheus Exporter
我们将dmabuf_guard的数据暴露为 Prometheus metrics:
# HELP dmabuf_leaked_bytes Total leaked DMA-BUF bytes # TYPE dmabuf_leaked_bytes gauge dmabuf_leaked_bytes{platform="rk3399",vendor="rockchip"} 0.0 # HELP dmabuf_buffer_count Current active DMA-BUF count # TYPE dmabuf_buffer_count gauge dmabuf_buffer_count{platform="rk3399",vendor="rockchip"} 8.0配合 Grafana,我们建立了“DMA-BUF 健康度仪表盘”,阈值告警:dmabuf_leaked_bytes > 50MB触发企业微信告警,dmabuf_buffer_count > 16触发自动运维脚本。
6.3 防御层:用户空间 RAII 封装(C++ Template)
为杜绝上层应用忘记 close fd,我们封装了DmaBufGuard类:
class DmaBufGuard { public: explicit DmaBufGuard(int fd) : fd_(fd) {} ~DmaBufGuard() { if (fd_ >= 0) close(fd_); } DmaBufGuard(const DmaBufGuard&) = delete; DmaBufGuard& operator=(const DmaBufGuard&) = delete; int get() const { return fd_; } private: int fd_; }; // 使用方式 int fd = allocate_dma_buf(); DmaBufGuard guard(fd); // 析构时自动 close // ... use fd ... // 函数结束,guard 析构,fd 自动关闭已集成到公司所有 Android HAL 层代码中,从源头堵住泄漏。
6.4 行业启示:硬件厂商的 DMA-BUF 责任边界
这件事让我们反思:DMA-BUF 泄漏的责任,究竟在谁?是 scrcpy 开发者没写好 cleanup?是 Rockchip 驱动没处理好EBUSY?还是 Android 框架没提供统一的 buffer 生命周期管理?
答案是:责任在硬件厂商。Linux 内核的 DMA-BUF 框架明确要求:驱动必须保证dma_buf_put()的幂等性和健壮性。EBUSY不是错误,而是提示“请稍后再试”。Rockchip 驱动的return是偷懒,不是合规。我们已向 Rockchip 提交 PR,并推动其在新 SDK 中将dma_buf_put()封装为rk_dma_buf_put_safe(),内置 retry 逻辑。
最后分享一个小技巧:下次你遇到类似黑屏卡死,别急着重启。先摸一下设备外壳——如果 SoC 区域异常烫手,大概率是 DMA-BUF 泄漏导致内存带宽打满、CPU/GPU 疯狂轮询;如果温热正常,则可能是 Display 驱动或电源管理 bug。温度,是最诚实的 debug 工具。