← 返回课程

Buffer管理

Camera全栈开发(Qcom Camx) 第 11 / 30 节

第 9 章:Buffer 管理


本章导读

第 5 章讲了 App 怎么创建 Surface 传给 Camera,第 7 章讲了 CameraService 怎么把 Buffer 转给 App。

但有一个问题始终没有讲透:一帧 12MP 的 YUV 数据,在内存中到底长什么样?谁分配了它?它怎么在 ISP、HAL、BufferQueue、App 之间传递?

这章就讲这个。做完 Camera 开发之后你会发现,大部分性能问题和稳定性问题,都出在 Buffer 管理上。


9.1 一帧 Buffer 的物理旅程

对照这个图看

先看一条完整的路:一帧 Preview YUV 数据,从分配到最后释放,经历了什么。

阶段 1:分配(createCaptureSession 时)

App 调用 createCaptureSession([previewSurface])
  │
  ▼ HAL configure_streams()
HAL 知道需要输出一帧 1920×1080 NV12 到 previewSurface
  │
  ▼ HAL 调用 Gralloc 分配 Buffer 池
gralloc.alloc(width=1920, height=1080, format=NV12_YUV, count=8)
  │
  ▼ 分配结果
8 个 Gralloc Buffer,每个 ~3MB(1920×1080×1.5)
总计 ~24MB 的 Buffer 池
  │
  ▼ 关联到 BufferQueue
HAL(Producer)→ BufferQueue → previewSurface(Consumer)

💡 为什么一帧 NV12 是 1920×1080×1.5 字节?

第 6 章讲过:YUV420 的 Y 平面是完整分辨率(1×),UV 平面是半分辨率(0.5×)。所以总大小 = width × height × 1.5。
1920 × 1080 × 1.5 = 3,110,400 字节 ≈ 3MB。

阶段 2:写入(ISP 处理每一帧)

Sensor 输出 RAW → IFE → IPE → YUV 数据就绪(预览主路径跳过 BPS;快照/拍照才经 BPS 做完整 demosaic)
  │
  ▼ HAL dequeueBuffer()
从 BufferQueue 空闲池拿一个 Buffer
拿到的是 buffer_handle_t(一个指针/句柄),不是数据本身
  │
  ▼ ISP DMA 引擎
把 YUV 数据直接写入 Buffer 的物理内存
  │
  ▼ HAL queueBuffer()
把填好的 Buffer 还给 BufferQueue

阶段 3:传递(BufferQueue → Consumer)

HAL queueBuffer() 后
  │
  ▼ BufferQueue 内部
  - buffer 标记为 QUEUED
  - 通知 Consumer 端:"第 N 帧数据好了"
  - 等待 Consumer acquireBuffer()
  │
  ▼ Consumer(App 端)
  │
  ├── 路径 A:到 SurfaceView/TextureView
  │     SurfaceFlinger acquireBuffer()
  │     → GPU 把 YUV 转成 RGB 纹理
  │     → 合成到屏幕 framebuffer
  │     → SurfaceFlinger releaseBuffer()
  │
  └── 路径 B:到 ImageReader
        ImageReader acquireBuffer()
        → Image 对象可用 → onImageAvailable() 触发
        → App 通过 Image.getPlanes() 读取像素
        → App image.close() → releaseBuffer()

阶段 4:回收

Consumer releaseBuffer() 后
  │
  ▼ Buffer 回到 BufferQueue 空闲池
标记为 FREE — 可以被 HAL dequeueBuffer() 再次拿走
  │
  ▼ 下一帧
HAL dequeueBuffer() 再次拿到同一个 Buffer → ISP 写入 → queue → ...

整个过程,Buffer 的内容被多次读写(ISP 写、GPU 读、CPU 读),但 Buffer 本身没有发生过拷贝——所有模块都是在操作同一块物理内存。这是 Android 图形栈性能高效的根本原因。


9.2 Buffer 是怎么传的——句柄(Handle),不是数据

对照这个图理解本节

这是理解 Buffer 机制最关键的认知:App 和 HAL 之间传递的不是像素数据,而是一个句柄(buffer_handle_t)。

句柄是什么

// Gralloc 分配一个 Buffer 后,返回的不是数据指针,而是句柄
buffer_handle_t handle = gralloc.alloc(width, height, format, usage);

// handle 本质上是一个结构体指针,包含:
//   - fd(文件描述符)→ 指向 ION/DMA-BUF 物理内存
//   - width, height, format → Buffer 的元数据
//   - stride → 行跨度(第 6 章讲过的 RowStride)
//   - usage → 这个 Buffer 被谁用(ISP/CPU/GPU/Display)

句柄可以在进程间传递(通过 Binder),但物理内存始终在原地。接收方拿到句柄后,mmap 到自己的地址空间就可以访问同一块物理内存。

Buffer 轮转——反复用同一个 Buffer

📊 这张图是关键/api/course-files/camera-fullstack/documents/diagrams/09_buffer_rotation.png

假设 Buffer 池有 4 个 Buffer(#1, #2, #3, #4)。一帧接一帧的轮转过程:

帧 1:
  ① HAL dequeueBuffer() → 拿到 Buffer #1(空闲池)
  ② ISP DMA 写入 Buffer #1 的物理内存
  ③ HAL queueBuffer(#1) → Buffer #1 从 Producer 到 BufferQueue

  ④ Consumer acquireBuffer() → 拿到 Buffer #1 的 handle
  ⑤ Consumer 读 Buffer #1 的像素(通过 handle → mmap → 虚拟地址 → 读)
  ⑥ Consumer releaseBuffer(#1) → Buffer #1 回到空闲池

帧 2:
  ① HAL dequeueBuffer() → 再次拿到 Buffer #1(刚回到空闲池)
  ② ISP DMA 写入 Buffer #1 ← 同一块内存,覆写新数据
  ③ HAL queueBuffer(#1)
  ... 循环往复

四个 Buffer 足够让 Producer 和 Consumer 各自的"写"和"读"交替进行
而不会相互阻塞。

为什么这样设计

如果每次传数据(拷贝方案):
  帧 1: ISP → 写 Buffer → CPU memcpy → Binder 传数据 → App → 释放 Buffer → 下一帧
         ↑ memcpy 一帧 3MB 的 YUV 数据,30fps = 90MB/s 的 CPU 开销

如果传句柄(Android 方案):
  帧 1: ISP → 写 Buffer → queue(handle) → Binder 传 handle → App → mmap → 读 → release(handle)
         ↑ 只传一个 12 字节的 handle,没有数据拷贝
         数据始终在 ISP 写入的位置,每个模块通过 handle 直接访问

💡 handle 传递的关键——dma-buf fd

buffer_handle_t 里包含一个 dma-buf fd(文件描述符)。这个 fd 是一个"跨进程密钥"——谁持有这个 fd,谁就可以通过 mmap(fd) 拿到同一块物理内存的虚拟地址。

Binder 支持传递 fd。所以:

HAL 进程:  dequeueBuffer() → 拿到 fd → ISP 写入 → queueBuffer(fd)
                                                                  │
                                          Binder 传递 fd(不是数据!)
                                                                  │
App 进程:  acquireBuffer() → 收到 fd → mmap(fd) → 虚拟地址 → 读像素

App 端代码中看不到 handle,但它在幕后

// App 层你写的是:
Image image = reader.acquireLatestImage();
ByteBuffer yBuffer = image.getPlanes()[0].getBuffer();
byte[] data = new byte[yBuffer.remaining()];
yBuffer.get(data);  // ← 这一步内部做了什么?
yBuffer.get(data) 内部:
  ① ImageReader 持有 buffer_handle_t(从 BufferQueue acquire 来的)
  ② gralloc.lock(handle) → 把 handle 对应的物理内存 mmap 到 CPU 地址空间
  ③ 返回 mmap 后的虚拟地址 → Java ByteBuffer 可以读了
  ④ yBuffer.get(data) → CPU 从虚拟地址拷贝到 Java byte[] ← 这里有拷贝!
  ⑤ 但这是 App 主动要求的拷贝,不是框架强制的

9.3 Buffer 的分配器:Gralloc / ION / DMA-BUF

为什么需要专门的分配器?

普通的 malloc/new:
  → 分配的是 CPU 虚拟地址空间的非连续页
  → ISP/GPU/Display 这些硬件无法直接访问
  → 需要 CPU 做一次拷贝 → 慢

Gralloc:
  → 分配物理连续的 ION/DMA-BUF 内存
  → ISP/GPU/Display/CPU 都可以直接读写
  → 不需要拷贝 → 快

Gralloc

Gralloc(Graphics Memory Allocator)是 Android 的 HAL 层内存分配器。不同平台有不同的实现:

App 侧: 调用 Gralloc HAL
  Surface::lock() / ImageReader::lockImage() → 内部调用 gralloc.lock()
  Surface::unlock() / image.close() → 内部调用 gralloc.unlock()

HAL 侧: CamX 通过 Gralloc 分配 Buffer
  gralloc.alloc(width, height, format, usage_flags)
  → 返回 buffer_handle_t(可以跨进程传递的句柄)

usage_flags 决定 Buffer 的使用场景

GRALLOC_USAGE_HW_CAMERA_WRITE   → ISP 可以写入
GRALLOC_USAGE_HW_TEXTURE        → GPU 可以读
GRALLOC_USAGE_HW_COMPOSER       → Display 可以读
GRALLOC_USAGE_SW_READ_OFTEN     → CPU 可以读

ION vs DMA-BUF——从高通私有到内核主线

Gralloc 自己不分配物理内存,它调用底层的内存分配器来实际分配。这块经历了从 ION 到 DMA-BUF 的演进。

ION 是什么

ION 是 Android 早期(2011~2020)为了解决不同硬件模块共享内存而发明的一套内存管理框架。

问题的根源:
  Camera ISP 需要连续物理内存(DMA 引擎只能访问物理地址)
  GPU 需要连续物理内存(纹理映射)
  Display 需要连续物理内存(framebuffer)
  CPU 需要虚拟地址来读写

  传统 Linux 内存分配器(buddy/slab)不保证物理连续
  → 需要一种能分配"物理连续 + 可跨硬件共享"的分配器

ION 的设计:

// 打开 ION 设备
int ion_fd = open("/dev/ion", O_RDONLY);

// 从指定 heap 分配
struct ion_allocation_data alloc = {
    .len = 3 * 1024 * 1024,               // 3MB
    .heap_id_mask = 1 << ION_CAMERA_HEAP_ID,  // Camera 专用堆
    .flags = 0,
};
ioctl(ion_fd, ION_IOC_ALLOC, &alloc);
// → 返回 fd,可跨进程通过 Binder 传递
// → 接收方 mmap(fd) 拿到同一块物理内存的虚拟地址

ION 的 heap 类型

ION_HEAP_SYSTEM        → 通用(不保证物理连续)
ION_HEAP_SYSTEM_CONTIG → 通用物理连续
ION_HEAP_CARVEOUT      → 启动时预留的专用连续内存(Camera/Display)
ION_HEAP_CMA           → 连续 + 可回收(Camera 不用时系统收回)
ION_HEAP_SECURE        → DRM 保护内容(TEE 环境下才能访问)

ION 的问题

ION 是高通(和三星)各自维护的私有内核补丁,从未进入 Linux 主线:

① 碎片化:高通、三星、MTK 各有一套 ION 实现,互不兼容
② 内核升级困难:每次 Linux 大版本升级,ION 补丁都要重做
③ 和主线不兼容:上游驱动(DRM/KMS、V4L2)已统一用 DMA-BUF
④ 双份维护:OEM 既要管 ION heap,又要适配 DMA-BUF 导出

DMA-BUF 是什么

DMA-BUF 是 Linux 内核主线(3.3+)提供的标准跨设备内存共享框架。它不是"ION 的替代品"——它本来就在那里,只是 Android 之前没用。

// DMA-BUF 分配(内核 5.4+ 引入 dma-heap 框架)
int heap_fd = open("/dev/dma_heap/camera", O_RDONLY);
struct dma_heap_allocation_data alloc = {
    .len = 3 * 1024 * 1024,
    .fd_flags = O_RDWR | O_CLOEXEC,
};
ioctl(heap_fd, DMA_HEAP_IOCTL_ALLOC, &alloc);
// → 同样返回 fd,同样可以通过 Binder 传递

区别总结

┌──────────────┬─────────────────────┬──────────────────────┐
│              │ ION                 │ DMA-BUF              │
├──────────────┼─────────────────────┼──────────────────────┤
│ 内核主线      │ 否(vendor 私有补丁)│ 是(Linux 3.3+)      │
│ 谁维护       │ 高通/三星/MTK 各自写 │ Linux 内核社区         │
│ 接口稳定性    │ Android 版本升级     │ 主线保证向后兼容       │
│              │ 可能不兼容           │                       │
│ heap 管理    │ 自己定义 heap ID     │ dma-heap 框架(5.4+)  │
│ 厂商适配成本  │ 高(打补丁)        │ 低(主线原生支持)     │
│ DRM/V4L2兼容 │ 需要适配层           │ 原生兼容              │
└──────────────┴─────────────────────┴──────────────────────┘

一句话:ION 和 DMA-BUF 做的事情一样(分配可跨硬件共享的物理内存 → 返回 fd),ION 是旧时代的权宜之计,DMA-BUF 是现在的标准路线。

怎么判断当前平台用的是 ION 还是 DMA-BUF

方法 1:看内核版本

adb shell uname -r
# 5.4+  → 大概率已切到 DMA-BUF
# 4.19  → 大部分还是 ION
# 4.14  → ION
# 5.10+ → DMA-BUF(高通 5.10 内核起已全面切换)

方法 2:看 /dev 下有什么

# 有 /dev/ion → 走了 ION
adb shell ls /dev/ion

# 有 /dev/dma_heap/ → 走了 DMA-BUF heap
adb shell ls /dev/dma_heap/
# /dev/dma_heap/system
# /dev/dma_heap/camera       ← Camera 用的 heap

方法 3:看 Gralloc 或 CamX 依赖了什么库

# xxx 平台:
adb shell ldd /vendor/lib64/hw/camera.qcom.so 2>/dev/null | grep -iE "ion|dma|alloc"

# 如果看到 libion.so → 用了 ION
# 如果看到 libdmabufheap.so → 用了 DMA-BUF

💡 对 Camera HAL 开发意味着什么

不管底层是 ION 还是 DMA-BUF,对你上层代码是透明的——你通过 Gralloc 接口分配 Buffer,拿到的始终是 buffer_handle_t / fd。上层代码不需要改。

区别在于:老平台(ION)bringup 时你可能需要调 ION_HEAP_CARVEOUT 的大小(在 kernel dtsi 里改 qcom,ion-heap-camera)。新平台(DMA-BUF dma-heap)这块自动管理,不需要干预。


9.4 Buffer 数量和队列深度

CamX 中的 Buffer 数量配置

// camx/src/core/camxnode.h
static const UINT32 BufferCountForRealTimeNodes = 2;   // BPS/IPE 等
static const UINT32 BufferCountForIFE          = 4;   // IFE(入口)

为什么 IFE 需要 4 个 Buffer?

IFE 的输出同时去两个下游:BPS + Stats Node

Buffer #0: BPS 正在读
Buffer #1: Stats Node 正在读
Buffer #2: Sensor 正在写入
Buffer #3: 空闲,等待 dequeue
──────────────────────────────────
需要 4 个才能保证流水线不堵塞

而 BPS → IPE 是纯串联:
Buffer #0: IPE 在读写的那个
Buffer #1: BPS 在写/空闲
→ 2 个就够

Buffer 数量 = 流水线深度的权衡

Buffer 太多(如 8 个):
  ✅ 流水线不容易堵
  ❌ 内存占用大(每个 3MB, 8 个 = 24MB,两条 Pipeline = 48MB)
  ❌ 延迟增大(从 dequeue 到 release 需要多等几个 Buffer 周转)

Buffer 太少(如 2 个):
  ✅ 内存省
  ✅ 延迟低
  ❌ Producer 可能在等 Consumer release → 流水线堵 → 掉帧

💡 做性能优化时的第一件事

看 Session Dump 中 Free Buffer 的数量。如果长期为 0,说明 Buffer 不够用——要么增加 BufferCount,要么优化 Consumer 的释放速度。


9.5 Cache 管理——多硬件共享的坑

问题

一个 Buffer 可能同时被 ISP 写、CPU 读、GPU 读。每个硬件有自己的 cache:

ISP 写入了新的 YUV 数据 → ISP 的 cache 中有最新值
CPU 来读 → CPU 的 cache 中可能还是旧值!
  ↑
  CPU 读到旧数据 → 花屏 / 帧不更新

解决方法:CacheOps

// CamX Node 中处理输出 Buffer 后
pImageBuffer->CacheOps(invalidate=TRUE, clean=TRUE);
CacheOps(invalidate, clean):

  invalidate: CPU 读之前调用
    → 让 CPU cache 的这一段失效
    → CPU 下次读时从 DDR 重新加载 → 读到 ISP 写的最新数据

  clean: CPU 写之后调用
    → 把 CPU cache 中修改过的数据写回 DDR
    → 让 ISP/GPU 看到 CPU 的修改

什么时候需要 CacheOps?

① ISP 写完 → CPU 要读(Watermark 水印场景)
   CacheOps(invalidate=TRUE, clean=FALSE) — 让 CPU 看到 ISP 最新输出

② CPU 写完 → ISP 要读(Watermark 叠加后)
   CacheOps(invalidate=FALSE, clean=TRUE) — 让 ISP 看到 CPU 的修改

③ 跨硬件模块读之前(做 image dump 时)
   CacheOps(invalidate=TRUE, clean=FALSE) — 确保读到最新硬件数据

9.6 Buffer Leak——最常见的相机稳定性问题

症状

相机工作一段时间后:
  - 预览画面卡住不动
  - 拍照按钮点了没反应
  - logcat 中:
    [CamX] [CSL] ERROR: No free buffer available!
    [CamX] Node::ProcessRequest failed - buffer timeout!

根因

Buffer 被 acquire 了但从来没 release。BufferQueue 的空闲池逐渐被耗尽,最终所有 Buffer 都在 "IN USE" 状态。

App 层的泄漏

// ❌ 最常见的泄漏写法
Image image = reader.acquireLatestImage();
ByteBuffer buf = image.getPlanes()[0].getBuffer();
byte[] data = new byte[buf.remaining()];
buf.get(data);
// 忘记 image.close() → Buffer 永远不会回到空闲池!

// ✅ 正确:close 放在 finally 或 try-with-resources
try (Image image = reader.acquireLatestImage()) {
    // 处理数据...
} // 自动 close,即使异常也能释放

HAL 层的泄漏

// ❌ CSL 资源泄漏
buffer = CSLResourceAlloc(size);
// 用... 忘记 CSLResourceFree() → 物理内存泄漏

// ❌ Node BufferManager 泄漏
// Node Destroy 时没有释放 Create 时申请的 Buffer

怎么检测泄漏

# 周期性执行这个命令,看 Free 数量是否持续下降
adb shell dumpsys media.camera | grep -A 5 "Buffer"
# Free: 4 → 3 → 2 → 1 → 0  ← 持续下降 = 泄漏

# 还可以看 Provider 进程内存是否持续增长
adb shell dumpsys meminfo | grep camera.provider
# PSS 持续增长而 Free Buffer 正常 → 其他类型的内存泄漏

9.7 本章总结

一帧 Buffer 的生命周期:
  分配(Gralloc) → 写入(ISP DMA) → 入队(queueBuffer)
    → 传递(BufferQueue handle) → 消费(SurfaceFlinger/ImageReader)
      → 释放(releaseBuffer) → 回收(下一帧复用)

分配器演进: ION(老平台) → DMA-BUF(新内核)
Buffer 数量: IFE=4(多下游),其他=2(串联)
Cache 管理: CacheOps(invalidate/clean) 保证多硬件数据一致性
泄漏检测: dumpsys 看 Free Buffer 数量是否持续下降

动手验证

  1. 打开相机预览,执行:

    adb shell dumpsys media.camera | grep -A 15 "Stream"
    

    找到每个 Stream 的 Buffer 配置:分辨率、格式、Buffer 数量。

  2. 观察 Buffer 周转:

    # 每 2 秒执行一次,预览保持打开
    watch -n 2 'adb shell dumpsys media.camera | grep -A 5 "Buffer"'
    

    看 Free Buffer 数量在 30 秒内是否稳定(应该稳定在某个值附近)。

  3. 模拟泄漏:在 App 的 ImageReader 回调中故意不调 image.close(),连续拍照 10 次后观察 Free Buffer 是否降到 0。

常见误解