
工位机黑屏这种问题大多数时候大家都默认先去翻 App 的崩溃日志、看业务代码结果往往绕一大圈都是白费。这次我遇到的 Android 工位机周期黑屏卡死表面看像极了业务 App 内存溢出引发的系统崩溃实际排查到最后根因却藏在 scrcpy 投屏工具和 Rockchip 硬件编码器的 DMA-BUF 泄漏上。整个过程走了不少弯路花了两天才把证据链补齐。这文章我就按当时排查的真实顺序来写把每个关键节点、判断依据、验证方法都摊开希望对做 Android 工控、自助终端维护的朋友有帮助。这批工位机是 Rockchip 平台的 Android 系统设备本身不带触摸显示屏业务上全靠 scrcpy 投屏到中控台做远程操作现场大概有三四十台分布在厂区不同工位。故障复现有很强的规律开机头半小时一切正常大约两小时后画面开始掉帧再过十几分钟直接黑屏主机上的指示灯还亮着但整个系统失去响应只能断电重启。1. 故障现场还原规律性黑屏是现代工控系统最值得留意的信号1.1 黑屏卡死的具体表现和影响范围我先说清楚“黑屏”到底是个什么状态这直接影响后续排查方向。工位机当时的黑屏不是单纯显示无信号而是整个 Android 系统进入了半死状态。正常情况下 scrcpy 窗口哪怕网络波动最多也就是画面卡住鼠标操作无响应但窗口还是活的。这次不同scrcpy 窗口直接黑掉中控台这边用 adb shell 敲命令返回非常慢有些命令十几秒才出结果甚至干脆超时。设备端的串口日志如果有接能看到内核还在跑但关键服务已经在反复重启。另外一个很关键的细节重启之后能恢复正常然后大概两小时左右又进入同样的状态非常有规律。这种“重启即好、运行数小时必挂”的模式基本排除硬件偶发故障和单纯的 App 死循环更多指向资源泄漏类的软件问题。常见的是内存泄漏、文件描述符泄漏、Binder 事务堆积、内核 slab 异常增长等。1.2 为什么大家第一反应都会怀疑 App我接手时前一个同事已经把锅扣在了工位机主业务 App 上理由是黑屏之前 App 界面特别卡ANR 弹窗不断Android 系统弹了“系统界面无响应”随后就黑了。再加上工位机安装的 App 是自己团队写的项目进度紧、代码质量一般谁都愿意相信是 App 内存吃满导致系统把它杀了甚至把 SurfaceFlinger 拖崩了。但这里有一个很明显的矛盾如果是 App 内存泄漏杀掉 App 进程之后系统应该能恢复不至于整机卡死到 adb 都难敲命令。而且黑屏之后 App 进程实际上已经不在了系统仍然不响应说明低内存崩溃只是结果不是根因。真正的问题还藏在系统底层只是 App 成了最先被牺牲的对象。这个直觉上的反差不提前建立起来后面很容易一直在业务层打转。2. 三层排查法从最上层业务 App 一路挖到内核日志2.1 第一层业务 App 的崩溃、ANR 与内存占用既然要排除 App 因素那就按标准套路先把业务 App 体检一遍。我抓了黑屏前后的 logcat 缓冲、Anr traces、崩溃堆栈也调了 amstat 和 dumpsys meminfo 看进程级内存还专门盯着 App 的 Java heap、Native heap、GraphicBuffer 大小变化。排查结果是App 的 Java heap 一直稳定在合理区间没有大对象堆积GC 频率正常Native heap 稍微高一些但没有持续爬升GraphicBuffer 占用有波动但没到吓人的程度。再看 ANR 触发点全部发生在主线程执行文件读写的时候而且时间点都集中在系统整体性能已经严重劣化之后属于被“拖死”不是自己“作死”。这个结论基本把业务 App 从根因嫌疑里摘出来了。不过这里也留了一个值得注意的观察App 的GraphicBuffer 在运行一小时之后出现过一次突然升高、随后又恢复的情况当时的异常时间点正好和 scrcpy 窗口重连时间吻合但我那会儿还没意识到这会是后面 DMA-BUF 泄漏案的关键线索。2.2 第二层CPU、内存、IO 的整体资源画像App 排除后我把视野拉到了系统整体资源上。用 top 和 dumpsys cpuinfo 观察各进程 CPU 占用发现一个扎眼的情况mediacodec、media.hwcodec 这两个进程的 CPU 占用随着运行时间在缓慢增加从最初的 3% 左右涨到黑屏前的 15% 上下。工位机业务本身对编解码需求很低这个异常增长强烈暗示有视频编码任务在持续吃掉 CPU。内存方面用 free 命令看 MemAvailable 曲线整个过程是单调下降的越到后面掉得越快有一种“内存水龙头越开越大”的既视感。因为工位机内存配置不大4GB 的机器两小时后 Available 能掉到三四百 MB触发 lowmemorykiller 杀进程系统面板先崩接着是 SurfaceFlinger 也被杀整个显示链路就断了。IO 方面没有明显异常闪存读写速率正常也没有 io wait 飙升。这说明问题大概率不是磁盘卡 IO 引起的而更偏向 CPU 内存的组合异常。既然 CPU 的异常集中在 mediaserver / media.hwcodec下一步必须去解这几个进程到底在干什么。2.3 第三层内核日志和内存统计里的“奇怪”告警到了这一层终于开始出有效信息。抓 /proc/kmsg 和 dmesg发现黑屏前大量刷以下日志page allocation failure: order:4, mode:0xcc0(GFP_KERNEL)DMA-BUF: 长时间未回收……ion_cma_heap: 无法分配连续物理内存lowmemorykiller: 大量杀进程包括 system_server 关联的 persistent 进程page allocation failure 说明内核已经连续多次无法分配高阶内存页而且是在 CMA 堆上失败的这基本可以断定问题不是普通的内存碎片而是有模块占用了大量 DMA-BUF 资源和物理内存没归还。dmesg 里还反复出现 scrcpy 相关设备节点的 open/close 行为配合 meminfo 里的 ION heap 占用曲线看一个恐怖的画面逐渐清晰投屏连接一开内存就源源不断被吃掉投屏一停内存不再减少。到这一步我基本把根因方向锁定在了 scrcpy 到 Rockchip 编码器这一条链路上的缓冲区泄漏。3. 证据链锁定DMA-BUF 数量单调递增与 ION 堆耗尽3.1 用内核 debugfs 统计 DMA-BUF 数量和归属进程要想实锤 DMA-BUF 泄漏不能只靠猜得有量化证据。Android 内核如果开启了 CONFIG_DMA_BUF_DEBUG 或 debugfs 节点可以直接通过 bufinfo 看当前所有 DMA-BUF 的归属、大小和引用状态。Rockchip 平台一般路径是 /sys/kernel/debug/dma_buf/bufinfo或者也有 /proc/rk_dma_buf_info 这类 BSP 私有的节点。我当时的做法是定时采样一分钟抓一次连续抓一个小时while true; do echo $(date %T) /data/dma_buf_trace.txt cat /sys/kernel/debug/dma_buf/bufinfo /data/dma_buf_trace.txt cat /sys/kernel/debug/ion/heaps/system /data/dma_buf_trace.txt sleep 60 done抓出来的数据非常有说服力。系统里 DMA-BUF 的总数量从开机后的 500 多个到运行一小时后涨到 1200 多个卡死前接近 2000 个其中由 media.hwcodec 进程持有的 bufinfo 数量增长最快而且单个 buf 的 size 大多在 1MB 到 4MB 之间一看就是视频编码帧缓冲。对比正常设备同样运行两小时DMA-BUF 总量应该稳定在 600 以内。另一个更直接的指标是 ION heap 的使用量。Rockchip 的硬件编码器主要是走 ion_cma_heap 和 ion_system_heap 分配内存通过 cat /sys/kernel/debug/ion/heaps/ 下面对应 heap 的统计能看到 cma heap 的总 usage 从几百 MB 一路爬到接近 1GB直到 heap 耗尽触发分配失败。这些分配失败的调用栈里清一色指向 rk_vcodec_enc_buf_alloc 相关的符号。到这里整个逻辑链已经闭环了scrcpy 触发设备端硬件编码器不断申请 DMA-BUF → Rockchip 编码器驱动部分缓冲区没有按预期释放 → cma heap 耗尽 → 内核无法分配内存 → 系统全面卡死。3.2 做一个干净的对照实验确认元凶证据链虽然已经很有指向性但我还是补了一个对照实验避免被噪声误导。实验条件很简单设备重启后保持业务 App 正常运行但不启动 scrcpy让系统自行跑两个小时。结果是 DMA-BUF 总数始终稳定在 500 左右MemAvailable 曲线基本水平CPU 占用也没有出现逐渐爬升的迹象一切正常。然后把业务 App 拉起来的同时也开 scrcpy但限制在 15fps 低码率刚跑 20 分钟就能看到 media.hwcodec 的 DMA-BUF 数量开始稳步上升free 内存开始往下走。连续做三组实验三组结果高度一致。实验到这里如果还有人怀疑是业务 App 的问题那只能说证据没看全。3.3 排除版本干扰scrcpy 本身有没有漏洞查到这里还需要回答一个问题到底是 scrcpy 不按 Android MediaCodec 规范释放缓冲区还是 Rockchip 编码器驱动在底层没正确回收 DMA-BUF要区分这两者可以用系统自带的 MediaCodec 接口写一个小工具脱离 scrcpy 直接推屏幕内容给编码器。我在测试机上用 MediaCodec 以 surface 输入模式跑 H.264 硬编速率恒定在 30fps跑了一个小时DMA-BUF 数量增长非常有限说明 Rockchip 编码器在正常调用路径下基本是可靠的。那问题就更大概率出在 scrcpy 与编码器交互的特殊路径上尤其是 scrcpy 在低延迟模式下频繁请求编码器重置、动态切换参数、或者异常断开重连时驱动里有些 buffer 引用没有被妥善归还。市面上 scrcpy 版本五花八门工位机上跑的是一个比较老的 2.0 分支定制版官方后续若干版本里对 MediaCodec 的 buffer 管理方式做了不少修正这个版本干扰因素必须先扣掉。4. scrcpy 与 Rockchip 编码器之间到底发生了什么4.1 scrcpy 的投屏链路和硬件编码器的角色定位要彻底理解这次泄漏得先把 scrcpy 的运行链路讲透。scrcpy 能实现低延迟投屏靠的是设备端 Android 系统用 MediaCodec 把屏幕内容编码成 H.264 视频流再通过 ADB 通道传给 PC 端解码显示。整个过程里设备端有两个环节会碰 DMA-BUF一是 SurfaceFlinger 把屏幕的 GraphicBuffer 传给编码器作为输入二是编码器内部要为自己分配存放编码帧数据的 DMA-BUF。Rockchip 硬件编码器是典型的多进程共享模型驱动通过 ION/DMA-BUF 把物理内存映射给用户态进程使用。正常流程下编码器每编完一帧调用方拿到编码后的数据并返回给系统这一帧对应的输入计数和输出计数都应该清掉驱动维护的内部引用计数归零缓冲区随即可被复用或释放。一旦某个环节少了一次 release或者驱动在某种异常状态下忘了把 buffer 回收到空闲队列这个缓冲区就会永久挂在进程的 fd 表里变成“僵尸块”。4.2 泄漏产生的常见路径重连、断流、动态码率切换我用 strace 跟踪 media.hwcodec 的 fd 变化再配合 dumpsys media.codec 观察编码器状态最终看到的泄漏路径主要出现在下面几个场景。第一个场景是 scrcpy 网络抖动的瞬间。工位机所在厂区 WiFi 环境很不稳定scrcpy 会频繁尝试重连。正常情况下的重连会先 stop 掉当前编码器实例再重新 start但实际跟踪发现每次重连后 media.hwcodec 的 open fd 数量都会比之前多一截而不是回到基准值。这说明前一个编码器实例的有些 buffer 并没有随着 stop 调用被完全释放。第二个场景是动态码率调整。scrcpy 会根据网络带宽实时调整编码参数而 Rockchip 编码器驱动在某些版本上处理码率切换时存在一个已知的竞态编码器工作线程还在处理旧配置的输入帧用户态已经把 codec 的输入 surface 释放了驱动里的引用计数减不到零buffer 直接泄漏。这个和具体内核版本强相关我在社区里翻到过类似的反馈不是孤例。第三个场景是分辨率切换。工位机偶尔会接到不同外接屏幕的投屏请求分辨率一变编码器就得重新分配一组更大的 buffer旧 buffer 理论上应该被释放。实测中旧 buffer 的释放并不彻底每次分辨率切换都残留几十 MB日积月累就会非常可观。4.3 工位机场景下的“帮凶”长时间运行 低内存配置同样的泄漏路径在用户手机上可能没那么严重因为手机内存普遍 8GB/12GB泄漏几十 MB 根本不起眼而且用户也不会一整天持续投屏。但工位机是 4GB 内存的嵌入式设备scrcpy 是常驻连续运行的一天 24 小时几乎不停每小时的泄漏速率哪怕只有五六十 MB十个小时就是五六百 MB到了晚上内存必然告急。再加上工位机主业务 App 本身就常驻一些二维码识别、语音播放等模块内存基线已经不低再叠加 scrcpy 泄漏的内存整个系统的内存水位被推到了非常危险的边缘。等到 lowmemorykiller 开始批量杀进程几乎所有前台服务和系统界面都逃不掉这时表现出的“黑屏卡死”其实就是系统在内存耗尽前做的一系列挣扎动作只是用户感知上觉得 App 才是万恶之源。5. 止血、根治与长期防治我们最后是怎么收场的5.1 临时止血先让产线不要继续断线根因定位到这一步第一优先级是恢复产线稳定不能为了等完美补丁让几十台工位机一直死循环。我和团队商量后从两个方向同时止血。第一个方向是把 scrcpy 的帧率限制到 10fps码率上限调低同时关闭 scrcpy 的自动重连机制改为中控台侧脚本检测断线后手动或定时重连。这样做的目的是降低触发竞态的频率让编码器不至于频繁被 stop/start 和动态切换参数。实测下来单纯限帧率就能把泄漏速率降低约 70%系统从两小时卡死延长到七八小时才出现明显恶化。第二个方向是直接改投屏链路舍弃 scrcpy 的画面采集方式。我们队里临时用 Android 原生 MediaProjection 软编 X264 方案做个了内网投屏工具CPU 占用确实高一些但内存曲线非常稳定跑了一整天也没有 DMA-BUF 暴涨的迹象。这个方案适合小规模先顶着用产线几十台机器勉强顶得住再往后还是得靠彻底修复。5.2 根治升级 BSP 内核驱动与 scrcpy 版本止血之后根治方案分成上游升级和本地修复两路推进。上游路径是把 Rockchip BSP 里的 ION/DMA-BUF 和编码器驱动升级到官方修复版本重点是更新 rk_vcodec_enc 和相关内存管理模块同时把 scrcpy 从定制老版本升级到 2.4 以上的官方稳定版期间对比验证新版本在重连和动态码率切换下 fd 数量是否还异常增长。我们另外也把 MediaCodec 调用代码里的自定义修改点逐一 review去掉了一份画质增强补丁因为该补丁改了编码器的输入 surface 引用方式泄漏风险更高。这里的经验是定制的系统级组件越少越好尤其是编解码这种底层链路任何一层的“小改动”都可能掩盖驱动层的引用计数问题。升级后先在测试机上连续跑满 72 小时确认 DMA-BUF 数量稳定、MemAvailable 曲线平稳才敢往产线几十台机器上批量刷。5.3 监控和预警把 DMA-BUF 泄漏消灭在萌芽阶段无论升级多彻底现场环境千奇百怪还是要有监控预案。我给这批工位机加了两个轻量巡检。第一个是内存水位巡检每分钟读取 /proc/meminfo 的 MemAvailable连续十分钟低于 500MB 就告警同时自动抓取 dmesg 尾部、dumpsys meminfo、dma_buf 统计方便事后追溯。第二个是 DMA-BUF 专项巡检通过 debugfs 统计 media.hwcodec 进程持有的 bufinfo 数量和总大小设定阈值如果两小时内持续增长超过 200MB 就自动重启 scrcpy 服务而不是让整个系统走到崩溃边缘。#!/system/bin/sh while true; do MEM$(cat /proc/meminfo | grep MemAvailable | awk {print $2}) if [ $MEM -lt 500000 ]; then echo $(date) low memory: ${MEM}kB /data/monitor.log cat /sys/kernel/debug/dma_buf/bufinfo /data/dma_on_lowmem.log fi sleep 60 done预警脚本不用写得多复杂关键是有人会去看日志、去设阈值否则跟没监控一样。6. 这次排障留下的通用方法论别被表象带节奏整个排查过程花了两天时间如果回头复盘最想拿出来分享的其实不是 scrcpy 这个具体工具本身的问题而是“当系统表象指向 App 的时候怎么坚定地往下层走”。我个人的体会是面对工控类 Android 设备的卡死问题先建立一份完整的系统级资源画像包括 CPU 各核心占用、进程内存、内核内存、DMA-BUF 数量、内核日志和 IO 统计先别急着打开 Android Studio 去看崩溃堆栈。崩溃日志只能告诉你哪一层先倒告诉不了你谁是推倒它的那只手。这次要不是坚持用 bufinfo 和 ion heap 统计抓到了 DMA-BUF 数量的单调递增趋势大概率还陷在反复加固 App 的泥潭里产线停机损失只会更大。另外还有一个小技巧凡是遇到周期性问题第一时间怀疑“泄漏类”而不是“偶发异常类”然后做对照实验验证比盲目读代码效率高很多。我们后来在另一个项目里用同样的方法论只花了半天就定位到一台设备显示屏异常花屏根因是 GPU 驱动的 framebuffer 引用没有释放套路完全是相通的。