第 5 章讲了 App 怎么创建 Surface 传给 Camera,第 7 章讲了 CameraService 怎么把 Buffer 转给 App。
但有一个问题始终没有讲透:一帧 12MP 的 YUV 数据,在内存中到底长什么样?谁分配了它?它怎么在 ISP、HAL、BufferQueue、App 之间传递?
这章就讲这个。做完 Camera 开发之后你会发现,大部分性能问题和稳定性问题,都出在 Buffer 管理上。

先看一条完整的路:一帧 Preview YUV 数据,从分配到最后释放,经历了什么。
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。
Sensor 输出 RAW → IFE → IPE → YUV 数据就绪(预览主路径跳过 BPS;快照/拍照才经 BPS 做完整 demosaic)
│
▼ HAL dequeueBuffer()
从 BufferQueue 空闲池拿一个 Buffer
拿到的是 buffer_handle_t(一个指针/句柄),不是数据本身
│
▼ ISP DMA 引擎
把 YUV 数据直接写入 Buffer 的物理内存
│
▼ HAL queueBuffer()
把填好的 Buffer 还给 BufferQueue
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()
Consumer releaseBuffer() 后
│
▼ Buffer 回到 BufferQueue 空闲池
标记为 FREE — 可以被 HAL dequeueBuffer() 再次拿走
│
▼ 下一帧
HAL dequeueBuffer() 再次拿到同一个 Buffer → ISP 写入 → queue → ...
整个过程,Buffer 的内容被多次读写(ISP 写、GPU 读、CPU 读),但 Buffer 本身没有发生过拷贝——所有模块都是在操作同一块物理内存。这是 Android 图形栈性能高效的根本原因。

这是理解 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 到自己的地址空间就可以访问同一块物理内存。
📊 这张图是关键:
/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 层你写的是:
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 主动要求的拷贝,不是框架强制的
普通的 malloc/new:
→ 分配的是 CPU 虚拟地址空间的非连续页
→ ISP/GPU/Display 这些硬件无法直接访问
→ 需要 CPU 做一次拷贝 → 慢
Gralloc:
→ 分配物理连续的 ION/DMA-BUF 内存
→ ISP/GPU/Display/CPU 都可以直接读写
→ 不需要拷贝 → 快
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 可以读
Gralloc 自己不分配物理内存,它调用底层的内存分配器来实际分配。这块经历了从 ION 到 DMA-BUF 的演进。
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 是高通(和三星)各自维护的私有内核补丁,从未进入 Linux 主线:
① 碎片化:高通、三星、MTK 各有一套 ION 实现,互不兼容
② 内核升级困难:每次 Linux 大版本升级,ION 补丁都要重做
③ 和主线不兼容:上游驱动(DRM/KMS、V4L2)已统一用 DMA-BUF
④ 双份维护:OEM 既要管 ION heap,又要适配 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 是现在的标准路线。
方法 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)这块自动管理,不需要干预。
// camx/src/core/camxnode.h
static const UINT32 BufferCountForRealTimeNodes = 2; // BPS/IPE 等
static const UINT32 BufferCountForIFE = 4; // IFE(入口)
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 太多(如 8 个):
✅ 流水线不容易堵
❌ 内存占用大(每个 3MB, 8 个 = 24MB,两条 Pipeline = 48MB)
❌ 延迟增大(从 dequeue 到 release 需要多等几个 Buffer 周转)
Buffer 太少(如 2 个):
✅ 内存省
✅ 延迟低
❌ Producer 可能在等 Consumer release → 流水线堵 → 掉帧
💡 做性能优化时的第一件事
看 Session Dump 中 Free Buffer 的数量。如果长期为 0,说明 Buffer 不够用——要么增加 BufferCount,要么优化 Consumer 的释放速度。
一个 Buffer 可能同时被 ISP 写、CPU 读、GPU 读。每个硬件有自己的 cache:
ISP 写入了新的 YUV 数据 → ISP 的 cache 中有最新值
CPU 来读 → CPU 的 cache 中可能还是旧值!
↑
CPU 读到旧数据 → 花屏 / 帧不更新
// 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 的修改
① 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) — 确保读到最新硬件数据
相机工作一段时间后:
- 预览画面卡住不动
- 拍照按钮点了没反应
- logcat 中:
[CamX] [CSL] ERROR: No free buffer available!
[CamX] Node::ProcessRequest failed - buffer timeout!
Buffer 被 acquire 了但从来没 release。BufferQueue 的空闲池逐渐被耗尽,最终所有 Buffer 都在 "IN USE" 状态。
// ❌ 最常见的泄漏写法
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,即使异常也能释放
// ❌ 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 正常 → 其他类型的内存泄漏
一帧 Buffer 的生命周期:
分配(Gralloc) → 写入(ISP DMA) → 入队(queueBuffer)
→ 传递(BufferQueue handle) → 消费(SurfaceFlinger/ImageReader)
→ 释放(releaseBuffer) → 回收(下一帧复用)
分配器演进: ION(老平台) → DMA-BUF(新内核)
Buffer 数量: IFE=4(多下游),其他=2(串联)
Cache 管理: CacheOps(invalidate/clean) 保证多硬件数据一致性
泄漏检测: dumpsys 看 Free Buffer 数量是否持续下降
打开相机预览,执行:
adb shell dumpsys media.camera | grep -A 15 "Stream"
找到每个 Stream 的 Buffer 配置:分辨率、格式、Buffer 数量。
观察 Buffer 周转:
# 每 2 秒执行一次,预览保持打开
watch -n 2 'adb shell dumpsys media.camera | grep -A 5 "Buffer"'
看 Free Buffer 数量在 30 秒内是否稳定(应该稳定在某个值附近)。
模拟泄漏:在 App 的 ImageReader 回调中故意不调 image.close(),连续拍照 10 次后观察 Free Buffer 是否降到 0。
configure_streams 时分配一次。