上周我处理了一个很有意思的稳定性工单设备在“相机连拍 后台应用同时跑”的高负载场景下持续二十分钟后开始明显卡顿随后桌面被回收、输入法被杀、后台应用反复拉起又反复被杀。用户侧看到的是“桌面不停重启”工程师这边看到的是 logcat 里成片的am_low_memory和 lmkd kill 记录。第一反应是查堆内存Java 堆、Native 堆、GPU 内存都看了一遍每个进程的 PSS 看起来都很正常最大的一户也不到 900MB可系统可用内存就是起不来。后来把矛头转向 dma_buf才在/sys/kernel/debug/dma_buf/bufinfo里发现异常——某个exp_name为gralloc的 buffer 重复出现了上千次单块 4MB光这一项就吃掉了 1.5GB 物理内存。这个案例非常适合当作一次 Android 内存泄漏排查的实战样本来讲。如果你也是做系统稳定性、性能优化、framework 或 BSP 相关工作的这篇文章大概率能帮你节省几个通宵即使你平时只写应用层弄懂 dma_buf 这个“看不见的内存大户”对你理解整个 Android 内存生态也会很有帮助。1. 现象背后的奇怪账本内存明明不够进程却个个“清白”这类问题最折磨人的地方在于系统的内存压力是真实的但你用常规手段去看每一个进程却找不到一个明显的“内存凶手”。我先把现场和排查思路完整还原出来方便你对照自己的处境。1.1 压力场景下的系统表现先从现象说起。设备正常开机后待机内存状态还算健康一旦进入相机连拍、录像、播放视频这类多媒体链路几分钟后进程就开始大面积死亡。查看 logcat 事件日志能看到大量am_low_memory和am_proc_died记录lmkd 的 kernel log 里也会出现针对前台进程或高优先级进程的 kill 记录。这时候执行adb shell cat /proc/meminfo一个典型的现场是MemFree长期徘徊在几十 MBMemAvailable也不高Slab和PageTables没有明显异常Cached也不算夸张。看起来物理内存被“吃光”了但具体是谁吃的常规的工具回答不了。另一个容易被忽略的指标是dumpsys meminfo输出的Lost RAM。这个值表示物理内存总数中无法归因到任何进程 PSS、页面缓存、内核栈等已知项的部分。那段时间我抓到的Lost RAM高达 1.3GB这个数字本身就指向了“存在大量游离在常规统计之外的内存”。1.2 为什么 Java 堆和 Native 堆检查都查不出问题先别急着上 dma_buf我们得搞清楚为什么常规手段失效。第一Java 堆有 GC 管控dumpsys meminfo pid里的 Java Heap 部分能看得比较清楚。Native 堆可以用malloc debug或AddressSanitizer去跟踪但这些工具关注的是进程地址空间里的分配。dma_buf 的物理内存未必完整映射到某个进程的地址空间甚至未必归属于某个用户态进程。第二dma_buf 在设计上就是为了让多个进程、多个硬件模块共享同一块物理内存。比如相机采集到的帧需要同时给 ISP、GPU 纹理、视频编码器、预览 Surface 使用。这种情况下多个进程各自只映射一部分从每个进程的 PSS 来看都不高但物理内存只有一块实际占用是重复计算的盲区。第三很多 dma_buf 的 fd 只是“代持”。底层驱动或硬件模块通过某些 service 进程持有 buffer 的引用但这个进程本身并没有做多少分配它的 PSS 里看不到大块内存。传统方法天然覆盖不到这个区域。1.3 Low Memory 状态下 lmkd 的“无奈”lmkd 的工作原理是基于可用内存阈值和内存压力等级去选择“最不值得保留”的进程杀掉。当 dma_buf 这类内核侧缓冲把物理内存吃光后lmkd 只能反复杀进程来腾出空间但杀完用户态进程内存也不会显著回升因为被杀的进程根本没有持有那些 buffer。于是出现一个死循环杀进程 → 内存短暂回升 → 又被某个多媒体链路申请走 → 再杀进程。我当时就是看到 lmkd 日志里同一个进程反复被杀才意识到问题不在进程本身而在进程之外。2. dma_buf一撮看不见的共享内存为什么会变成“黑洞”要揪出元凶必须先理解 dma_buf。简单说dma_buf 是内核提供的一套统一 DMA 缓冲区框架目的是让不同硬件、不同进程之间共享物理内存而不用反复拷贝。相机的帧、GPU 的纹理、视频编解码的输入输出底层基本都是 dma_buf。2.1 dma_buf 在用户态和内核态分别长什么样从用户态看dma_buf 就是一个文件描述符fd。进程打开一个 dma_buf fd可以做mmap映射、传给另一个进程、传给硬件驱动这一切都像操作普通文件一样。从内核态看每个 dma_buf 对应一个dma_buf对象内部有size、exp_name导出者名称、引用计数、attach 链表等元数据。你可以把它想成一张仓库提货单dma_buf 是仓库里的货架fd 是提货单。用户态进程拿到提货单可以对货架进行各种操作但想真正释放货架必须满足一个条件——所有提货单都交还并且仓库内部的登记记录也全部注销。2.2 双重引用为什么 fd 关了 buffer 还不一定释放这是整个排查里最反直觉的地方。dma_buf 的释放需要满足两个条件用户态对 fd 的引用计数归零同时内核态通过dma_buf_get、dma_buf_attach等接口持有的引用计数也归零。只有两边都清零内核才会真正调用dma_buf_release把物理内存还给页分配器。所以泄漏有两种典型路径类型 A进程持有 fd 不放比如某个 native 库申请了 buffer在异常分支里没有关闭 fd。这种相对好查因为能通过/proc/pid/fdinfo统计到。类型 Bfd 已经关闭了但内核驱动侧还有引用没释放比如某个硬件模块在异步处理完成后忘了调用dma_buf_put。这种最难查因为从用户态看进程已经“干净”了但物理页还被内核驱动攥着。2.3 谁是 dma_buf 的持有大户基于我见过的案例dma_buf 的常见持有者集中在下面几条链路上Camera 链路preview buffer、拍照 buffer、ISP 输出 buffer。视频编解码OMX 或 Codec2 解码器的输入输出 buffer、DPB解码参考帧buffer。GPU / 渲染硬件加速纹理、EGL 共享 buffer、HardwareBuffer。Display / Composer图层 buffer、HWC 引用的 buffer。音频 / DSP 等小众通路。换句话说只要设备有相机、有视频播放、有游戏渲染dma_buf 的使用就无处不在。这也意味着一旦出现 dma_buf 泄漏它往往藏在多媒体或者渲染链路的某个“不起眼”的环节里。3. 从全局 bufinfo 到进程 fd 归属一套能直接抄的排查命令理论部分说清楚后接下来是实战。下面这套方法我在多个项目里用过每一步都有明确的意图不是说教而是真的能一步步帮你把问题钉死。3.1 第一步先确认物理内存的分配全景先别急着进 dma_buf。第一步是抓取一份系统的内存全景确认问题到底是不是出在不可归因内存上。adb shell cat /proc/meminfo | head -30 adb shell dumpsys meminfo | grep -E Total RAM|Free RAM|Used RAM|Lost RAM重点关注MemAvailable和Lost RAM两个值。如果MemAvailable持续走低同时Lost RAM明显偏大比如超过几百 MB基本可以怀疑存在大块不可归因内存。这时候再对比一下dumpsys meminfo的进程列表倘若 Top 进程 PSS 总量加起来不到可用内存的一半那向导就很明确了。在 Android 9 及之后的版本建议顺手把bugreport也抓一份作为后续分析的状态基线。3.2 第二步抓取 dma_buf 全局快照dma_buf 的全局信息挂在内核的 debugfs 下面需要 root 权限。不同内核版本节点路径略有差别常见的是/sys/kernel/debug/dma_buf/bufinfo也有部分内核是/sys/kernel/debug/dma_buf/dma_buf_info个别厂商还会放到/d/dma_buf/bufinfo。adb root adb shell mount -t debugfs debugfs /sys/kernel/debug adb shell cat /sys/kernel/debug/dma_buf/bufinfo输出的内容大致是size flags mode exp_name ino 0x00400000 0x0 0x1 gralloc 12345 0x00001000 0x0 0x1 system 67890注意实际的字段顺序和数量会受内核版本和厂商定制影响但核心内容基本一致。这里的关键信息是size和exp_name。exp_name表示这块 buffer 是谁导出的比如gralloc、ion、system、camera等这是我们快速判断“大户”的最直观线索。把输出重定向到本地文件方便后面统计adb shell cat /sys/kernel/debug/dma_buf/bufinfo bufinfo_1.txt3.3 第三步按进程统计持有量全局 bufinfo 能看出总量但看不出是谁持有的。这时候需要逐进程扫描/proc/pid/fdinfo目录。每个 dma_buf fd 对应的 fdinfo 文件里会列出这块 buffer 的 size 和 exp_name 等信息格式大概是size: 0x00400000 count: 1 exp_name: gralloc如果每个进程都手动去翻效率太低。我一般直接用下面这个 Python 脚本在 PC 端跑把每个进程持有的 dma_buf 大小累加起来按总量排序输出 Top 20。脚本兼容性做得比较宽size:字段可能是十六进制也可能是十进制都能处理。#!/usr/bin/env python3 import os def parse_size(text): text text.strip() try: return int(text, 16) if text.lower().startswith(0x) else int(text) except ValueError: return 0 def get_proc_name(pid): try: with open(f/proc/{pid}/comm) as f: return f.read().strip() except Exception: return ? def read_fdinfo_sizes(pid): sizes [] fdinfo_dir f/proc/{pid}/fdinfo if not os.path.isdir(fdinfo_dir): return sizes for fd in os.listdir(fdinfo_dir): path os.path.join(fdinfo_dir, fd) try: with open(path) as fp: exp_name size 0 for line in fp: if line.startswith(exp_name): exp_name line.split(:, 1)[1].strip() elif line.startswith(size): size parse_size(line.split(:, 1)[1]) if size 0: sizes.append((exp_name, size)) except (FileNotFoundError, PermissionError, ValueError): continue return sizes result [] for pid in os.listdir(/proc): if not pid.isdigit(): continue sizes read_fdinfo_sizes(pid) if sizes: total sum(sz for _, sz in sizes) result.append((pid, total, sizes)) result.sort(keylambda x: x[1], reverseTrue) for pid, total, sizes in result[:20]: print(f{pid:6s} {get_proc_name(pid):20s} {total/1024/1024:8.1f} MB) for exp_name, sz in sizes[:5]: print(f {exp_name:15s} {sz/1024/1024:6.1f} MB)把脚本放到 PC 上直接运行会自动遍历当前设备上所有进程的 fdinfo。我在排查那个工单时脚本跑完的瞬间就很直观某个 media server 相关进程持有接近 1GB 的 dma_bufexp_name几乎一水儿都是gralloc。3.4 第四步顺着 fd 找到那根“不还的线”锁定可疑进程后进入进程内部查看它到底持有了哪些 dma_buf fdadb shell ls -l /proc/pid/fd | grep dma_buf这会列出进程持有的所有 dma_buf fd。挑几个大块的查看 fdinfo 里的具体信息adb shell cat /proc/pid/fdinfo/fd到了这一步基本能把“哪个进程、持有哪个导出者、多大尺寸的 buffer”这三元组锁定。剩下的问题就是为什么这些 fd 没有被释放这条线索可以直接交给应用团队或驱动团队去查代码但如果你想自己继续深挖下一节的动态复现方法会更实用。4. 动态复现与对比把“泄漏”和“正常缓存”区分开一次静态快照只能说明“这一刻谁在占用”不能说明“这是不是泄漏”。真正要确认泄漏必须做动态对比让设备在特定操作路径下多跑几轮观察 dma_buf 总量是否出现持续增长增长趋势能否与某个操作路径对应上。4.1 场景重现用固定操作脚本反复触发以那个相机连拍案例为例我写了一个简单的 shell 循环反复触发相机拍照和退出for i in $(seq 1 50); do adb shell am start -a android.media.action.IMAGE_CAPTURE sleep 2 adb shell input keyevent KEYCODE_BACK sleep 1 done每跑 10 轮就抓一次 dma_buf 快照和MemAvailableadb shell cat /sys/kernel/debug/dma_buf/bufinfo bufinfo_round_10.txt adb shell cat /proc/meminfo | grep -E MemAvailable|MemFree这个过程不需要什么复杂工具关键是要保持操作路径一致控制变量。4.2 多次快照的对比方法把多轮快照里的关键数据整理成一张表趋势会非常明显。比如当时我统计到的数据大致如下操作轮次dma_buf 总量gralloc buffer 数量MemAvailable第 0 轮568 MB1421.6 GB第 10 轮720 MB1801.1 GB第 20 轮913 MB228720 MB第 30 轮1.1 GB275410 MB第 40 轮1.3 GB322180 MB第 50 轮1.5 GB36865 MB单块 gralloc buffer 的大小固定是 4MB数量几乎线性增长每拍一轮照片就多出十几块。这已经不是“缓存池波动”能解释的范畴了可以基本断定是泄漏。正常的多媒体缓存池会在预热后达到一个稳定的上限反复操作后总量只会小范围波动不会一路涨到把内存打满。4.3 进一步定位这堆 buffer 到底卡在哪个模块数量增长指向某个明确的操作路径但还要回答“为什么释放不掉”。常见做法是用strace跟踪进程的 fd 操作看它在拍照结束后有没有执行对应的close()adb shell strace -f -e traceclose,dup,mmap -p pid如果发现某些 buffer 对应的 fd 在业务完成后从未被 close那就是用户态代码漏了释放。如果 fd 已经 close 了但 dma_buf 仍然存在于 bufinfo 里那就说明内核驱动侧没把引用计数减干净需要把问题移交到驱动层。这时可以尝试在内核的dma_buf_release附近开启 tracepointadb shell echo 0 /sys/kernel/debug/tracing/tracing_on adb shell echo /sys/kernel/debug/tracing/trace adb shell echo 1 /sys/kernel/debug/tracing/events/dma_buf/dma_buf_release/enable adb shell echo 1 /sys/kernel/debug/tracing/tracing_on注意具体 tracepoint 是否可用取决于内核配置不过思路是一样的观察每个 dma_buf 的最终释放路径看最后一次引用是哪个模块持有的。5. 修复落地与验证没有刹住板的修复不算修复找到根因只是第一步真正有价值的是修复方案能够通过压力测试验证。下面按“用户态修复”和“驱动侧修复”两条线展开最后给出验证指标。5.1 用户态代码常见修复点用户态持有 dma_buf fd 的泄漏大部分集中在几个典型场景第一类是ImageReader或相机预览路径中没有关闭Image。很多人在 Java 层用了reader.acquireLatestImage()但忘记在 finally 块或 try-with-resources 里调用image.close()。这里要注意的是不少开发者以为ImageReader本身的close()就够了实际上每一帧的Image对象也要单独关闭否则 BufferQueue 里的 buffer 就等于被客户端借走不还。第二类是AHardwareBuffer类型的泄漏。native 层用AHardwareBuffer_allocate分配后一定要在生命周期结束时调用AHardwareBuffer_release。如果整个模块是用对象管理的我建议复用 RAII 机制避免手动释放遗漏。第三类是 native 文件描述符本身泄漏。比如用dma_buf相关 API 或厂商私有接口拿到 fd 后后续操作直接复用了 C 语言的裸 fd异常分支没有close(fd)。在 C 代码里尽量用android::base::unique_fd这样的 RAII 包装器而不是裸int fd。一个看起来不起眼的return语句可能就能造成几百 MB 的泄漏。5.2 驱动侧排查与修复如果确认是内核驱动侧的引用计数泄漏用户态改代码解决不了问题需要对照驱动模块的 attach/detach 生命周期逐段检查。常见的场景是某个硬件模块在异步操作完成后的回调路径里漏掉了对dma_buf_put的调用或者某个 error path 里提前 return 导致 detach 逻辑没执行。这种问题修起来很细建议在代码审查时特别注意“错误处理分支里的资源释放”。我的习惯是把每个硬件模块的 attach 和 detach 调用点用表格列出来一一对应检查凡是“attach 可能成功但 detach 不在同一错误路径”的地方都是高风险点。5.3 验证指标与回归测试设计修复后不能只跑一次“感觉没问题”。我一般会用下面这套验证指标做回归指标修复前50轮连拍修复后50轮连拍MemAvailable65 MB460 MBdma_buf 总量1.5 GB610 MBlmkd 杀进程次数200Lost RAM1.3 GB210 MB另外我建议在自动化测试机器上常驻一个脚本每隔几分钟自动抓一次/proc/meminfo和 dma_buf 总量跑一整晚的高压混合场景相机、视频播放、浏览器滚动、游戏渲染同时进行。数据曲线出来后如果 dma_buf 总量长期维持在一个固定水位附近波动说明系统已经达到稳定状态如果还在像爬楼梯一样往上涨那就说明还有别的泄漏点。6. 排查 dma_buf 时最容易被误导的几个地方最后这部分想专门聊一下我在实际排查中踩过的坑。有些问题看起来很玄学其实是因为踩进了错误的统计口径或者工具限制里。一个很常见的坑是 user 版设备根本没有 debugfs 权限。/sys/kernel/debug在很多量产 user 版本上是空的命令敲下去什么都拿不到。这种时候要么用 userdebug/eng 版本复现要么让测试机长期保留一台 root 权限样机。我个人的做法是稳定性专项开始前先跟测试团队确认至少有一台 userdebug 设备可以随时候命。第二个坑是“进程死了buffer 还在”。前面提到的那套 fdinfo 脚本统计的是活跃进程持有的 fd如果持有 buffer 的进程已经被 lmkd 杀掉了但驱动侧引用还在那么脚本统计不到它容易造成“没有进程持有这些内存”的错觉。这时候必须回到全局 bufinfo按exp_name size inode组合来分析孤儿 buffer。第三个坑是“预分配缓存池的干扰”。相机、视频解码这些模块通常会做 buffer pool 预分配系统刚启动时 dma_buf 总量可能已经有几百 MB这部分并不是泄漏。所以判断泄漏一定要看动态趋势而不是盯着一次快照的数字大小。第四个坑是 fdinfo 字段不统一。不同内核版本里fdinfo 里的size字段有时是十六进制有时是十进制有的内核还会输出dma-buf size:这种前缀。写统计脚本时一定要做兼容解析不然数值会差得离谱。第五个坑是把 page cache 和 dma_buf 混为一谈。/proc/meminfo里的Cached、Shmem字段和 dma_buf 是两个维度的概念。page cache 是文件页缓存可以被系统回收dma_buf 是硬件和设备共享的内存很多情况下是不可以被回收的。如果分不清这两类内存很容易在排查时被误导。最后一个提醒不要把共享 buffer 的 PSS 加总看漏了。同一块 dma_buf 被多个进程映射时每个进程的 PSS 都只算自己那份但物理内存只有一块。如果只从进程维度加总会觉得内存没那么多但从系统维度看这块物理内存已经被占死了。这也是为什么 dma_buf 问题必须结合全局 bufinfo 和进程 fdinfo 两个维度一起看缺一个都容易误判。