MediaCodec 全链路深度剖析(六):GraphicBuffer 和 BufferQueue
系列导读:第五篇结尾留了一句话——“vendor 解码出来的那块 YUV 内存,物理上一开始就分配在 SurfaceFlinger 的 BufferQueue 里”。这句话里压了两层底层概念:那块”内存”长什么样,那条”BufferQueue”怎么搬运它。这一篇把这两层拆开讲,分两大部分:
- GraphicBuffer——Android 图形栈的通用容器,从字段、usage、底层 dma-buf、Gralloc 演进到跨进程语义,一次过完。
- BufferQueue——所有图形数据流共用的管道,从抽象模型、slot 状态机、典型路径到 fence 同步,逐层下钻。
GraphicBuffer
GraphicBuffer 是整个 Android 图形世界的通用数据:
- MediaCodec 解码输出 → GraphicBuffer
- GPU 渲染目标 → GraphicBuffer
- SurfaceFlinger 合成输入 → GraphicBuffer
- HWC 扫描输出 → GraphicBuffer
没有它就没有零拷贝,GraphicBuffer 的数据结构如下:
class GraphicBuffer : public ANativeWindowBuffer, public RefBase { |
第五篇里反复提到的”C2Block持有的native_handle“和这里的native_handle是同一个东西——Codec2 那一侧把它装在C2Buffer里看,图形这一侧把它装在GraphicBuffer里看,两边只是不同的视图,物理内存共用。
usage:分配时就确定访问权限
GraphicBuffer分配前必须指定 usage。它决定这块内存分配在哪个 heap、谁能访问、谁不能。常见 flag:
| Flag | 含义 |
|---|---|
GRALLOC_USAGE_SW_READ_OFTEN / RARELY |
CPU 读(对性能有要求) |
GRALLOC_USAGE_SW_WRITE_OFTEN / RARELY |
CPU 写 |
GRALLOC_USAGE_HW_TEXTURE |
GPU 作为纹理采样 |
GRALLOC_USAGE_HW_RENDER |
GPU 作为渲染目标 |
GRALLOC_USAGE_HW_VIDEO_ENCODER |
VPU 读(编码器输入) |
GRALLOC_USAGE_HW_COMPOSER |
HWC 可扫描 |
GRALLOC_USAGE_PROTECTED |
DRM 保护内存(CPU 不可读) |
usage 是分配前对消费者列表的预声明。Gralloc 拿到 usage 后会反查这种组合的访问者需要什么样的内存——是不是物理连续、要不要 IOMMU 映射、是不是走 secure heap。错误的 usage 会直接导致 GPU 采样失败、HWC 拒绝扫描,或者 secure 路径下解码出全黑帧。
三种最常见的组合:
// MediaCodec 解码输出(送 GL 做纹理 + 送 HWC 显示) |
第一种就是第五篇mChannel->setSurface(surface)那一步背后会用到的 usage——既要给 GL/HWC 合成,又要让 VPU 直接写进来。
dma-buf:一块内存被多方共享的机制
usage 声明了”谁要访问这块内存”,但还没回答一个更根本的问题——多个硬件设备和多个用户态进程,怎么可能同时看到同一块物理内存。答案是dma-buf(Direct Memory Access Buffer),Linux 内核 3.3 起进入主线的一套通用共享机制,解决的问题只有一个:让不同硬件设备(CPU、VPU、GPU、DPU、Camera ISP)共享同一块物理内存,且不同用户态进程可以引用它。Android 图形栈里所有”零拷贝”的说法,最后都能追到这一层。
原理分三层看:
flowchart TB
subgraph 用户态["用户态(多个进程)"]
FD1["vendor 进程
fd = 27"]
FD2["App 进程
fd = 15"]
FD3["SF 进程
fd = 88"]
end
subgraph 内核态["内核态"]
DMABUF["struct dma_buf
(引用计数 + ops 表)"]
SGT["sg_table
(散列物理页列表)"]
DMABUF -->|指向| SGT
end
subgraph 硬件["硬件(通过 IOMMU 各自映射)"]
VPU["VPU:写入解码结果"]
DPU["DPU:读出扫描到面板"]
GPU["GPU:可选采样/合成"]
end
subgraph 物理内存["物理内存(DDR)"]
PAGES["实际数据页
(YUV 像素)"]
end
FD1 -->|引用| DMABUF
FD2 -->|引用| DMABUF
FD3 -->|引用| DMABUF
SGT -->|物理地址| PAGES
VPU -.dma_buf_map_attachment.-> PAGES
DPU -.dma_buf_map_attachment.-> PAGES
GPU -.dma_buf_map_attachment.-> PAGES
三层的作用一句话钉死:
- 用户态 fd:应用层的把柄。fd 只是每个进程各自文件描述符表的索引,Binder 传递时内核会给目标进程新分配一个 fd 数字,但底下指向的是同一个
struct dma_buf——这就是本篇最开头native_handle_t结构里data[0]那个 fd 的语义,也是下文”跨进程”一节里 fd 数值不同、物理内存同一块的机制来源。 - 内核
struct dma_buf:所有权归属。带引用计数,最后一个 fd 关闭时才释放物理页;带一张ops表,暴露map_dma_buf、unmap_dma_buf、begin_cpu_access等回调,供不同硬件驱动接入。 - 物理页 + IOMMU:数据实际躺的地方。VPU、DPU、GPU 各自通过 IOMMU 建立设备地址映射,读写的都是同一块 DDR 物理页——硬件层面共享同一块内存,不需要任何一方把数据搬到另一方能看见的地方。
Android 里 dma-buf 的分配入口是 Gralloc HAL——vendor 侧的ion或dmabuf heap分配器负责从合适的物理区域(比如 secure heap、CMA heap)拿一块,返回 fd 给上层。GraphicBuffer 就是把这个 fd 加上宽高、格式、stride、usage 这些元数据打包起来的 C++ 容器,让上层可以把它当”一块图像”用,而不用关心底下是哪块 dma-buf。
顺着这条线往后,下一小节看 Android 里两代分配器(ION 和 DMA-BUF heaps)具体怎么产出这块 dma-buf。
底层:从 ION 到 DMA-BUF
Gralloc只是分配壳子,真正出内存的人在底下:
flowchart TB
APP["用户态
new GraphicBuffer(...)"]
GR["Gralloc HAL
allocate(width, height, format, usage)"]
SEL{"usage
是否带 PROTECTED?"}
SH["secure-heap"]
NH["system-heap
(普通 DMA 内存)"]
IOCTL["ioctl(heap_fd,
DMA_HEAP_IOCTL_ALLOC)"]
DMA[("内核 struct dma_buf")]
FD["返回 dma-buf fd"]
HANDLE["native_handle_create
(打包 fd + 元数据)"]
APP --> GR --> SEL
SEL -- 是 --> SH --> IOCTL
SEL -- 否 --> NH --> IOCTL
IOCTL --> DMA --> FD --> HANDLE
两套底层分配器先后登场过:
- ION(Android 12 之前的主流):Android 最早的 DMA 内存分配器,提供 system / contig / carveout / secure 几个 heap,支持跨进程共享和 IOMMU 映射。
- DMA-BUF heaps(Android 12+ 标准):Linux 内核主线的统一 DMA 机制,ION 已经从内核主线移除,新设备一律走 DMA-BUF。
用户态接口看起来很像(都是 ioctl + fd),但内核侧实现完全不同。简化后的 Gralloc allocate 逻辑:
int allocate(width, height, format, usage) { |
usage 在这一步决定开哪个 heap——secure 内容必须走 secure heap(CPU 不可见),普通帧走 system heap。这是 usage 标志被真实使用的地方。
跨进程:fd 数值不同,物理内存同一块
GraphicBuffer跨进程时传的不是 C++ 对象,而是里面的native_handle_t。Binder 走 fd 时内核会在目标进程里复制出新 fd——fd 数值变了,但内核里指向的是同一个struct dma_buf,物理内存同一块。
flowchart LR
subgraph P1["App 进程"]
A["GraphicBuffer
handle->data[0] = 42"]
end
subgraph P2["vendor 进程"]
B["GraphicBuffer
handle->data[0] = 78"]
end
DMA[("内核 dma_buf 对象
(同一块物理内存)")]
A -. Binder dup fd .-> B
A --> DMA
B --> DMA
对应 Parcel 接口:
// 发送端 |
这就是 Android 图形栈零拷贝的物理底座。fd 是门票,门票编号可以无数次复制,但门内的物理内存只此一份。第五篇里讲的C2Buffer跨进程、BufferPool跨进程引用计数,根上都是这一套机制。
BufferQueue 所有图形数据流共用的管道
知道了”容器”长什么样,下一个问题是——这些容器如何从一个角色流到下一个?答案是 BufferQueue。
所有 Android 图形数据流动都经过 BufferQueue:
- Camera 出帧 → BufferQueue → App
- GL 渲染 → BufferQueue → SurfaceFlinger
- MediaCodec 解码 → BufferQueue → SurfaceFlinger
- GL 渲染 → BufferQueue → MediaCodec 编码(Input Surface)
- SurfaceFlinger 合成 → BufferQueue → DPU
吃透 BufferQueue,后面所有图形问题都不慌。
抽象模型:两个角色 + 一组 slot
BufferQueue 的抽象只有两个角色——Producer(写数据的)和 Consumer(读数据的)——中间夹着一组GraphicBuffer槽位:
flowchart LR
subgraph Producer
P1["GL / MediaCodec / Camera"]
end
subgraph BufferQueue
SLOTS["Slots[MAX_BUFFERS=64]"]
QUEUE["mQueue (FIFO)"]
end
subgraph Consumer
C1["SurfaceFlinger /
ImageReader /
SurfaceTexture"]
end
P1 -- "dequeueBuffer / queueBuffer" --> SLOTS
SLOTS --> QUEUE
QUEUE -- "acquireBuffer / releaseBuffer" --> C1
接口本身非常对称:
// 生产者侧 |
Producer 走”申请-填充-提交”,Consumer 走”获取-使用-释放”,两条路在同一组 slot 上交替转手。
Slot 状态机
每个 slot 当下处于一个明确状态。一帧的”一生”就是在这个状态机上转一圈:
stateDiagram-v2
[*] --> FREE
FREE --> DEQUEUED: dequeueBuffer
(producer 申请)
DEQUEUED --> QUEUED: queueBuffer
(producer 提交)
DEQUEUED --> FREE: cancelBuffer
(producer 放弃)
QUEUED --> ACQUIRED: acquireBuffer
(consumer 拿走)
ACQUIRED --> FREE: releaseBuffer
(consumer 释放)
四个状态归属:
| 状态 | 含义 | 持有者 |
|---|---|---|
| FREE | 空闲 | BufferQueue |
| DEQUEUED | 生产者申请到,待填充 | Producer |
| QUEUED | 已提交,等待消费 | BufferQueue 内部队列 |
| ACQUIRED | 消费者持有中 | Consumer |
整张图压成一个核心约束:一块 slot 同时只能有一个所有者。Producer 申请它就脱离 BufferQueue 掌控,提交回去 BufferQueue 又拿到所有权,Consumer 拉走再脱离一次。两端永远不会同时改写同一块物理内存。
底下的数据结构相当朴素:
class BufferQueueCore { |
每个 slot 持有一个GraphicBuffer和它的状态。mQueue是 FIFO 列表,记的是已经 QUEUED 但还没被 Consumer 拉走的 item——Consumer 调acquireBuffer按顺序从这里出队。
一帧从 MediaCodec 到 SurfaceFlinger 的实际走法
把第四、第五篇的输出路径接进来,对照接口看一次端到端时序:
sequenceDiagram
participant V as Vendor (VPU)
participant P as Producer 端
(CCodecBufferChannel)
participant BQ as BufferQueue
participant SF as Consumer 端
(SurfaceFlinger)
participant G as GPU
V->>V: 解码一帧 YUV
(目标内存来自 BUFFERQUEUE pool)
V->>V: 触发 acquireFence
P->>BQ: dequeueBuffer(&slot, &fence, ...)
P->>BQ: queueBuffer(slot, QueueBufferInput{
timestamp, dataSpace, transform,
acquireFence, hdrMetadata})
BQ->>BQ: slot 转 QUEUED
BQ->>SF: 唤醒 Consumer (onFrameAvailable)
SF->>BQ: acquireBuffer(&item)
BQ->>SF: 返回 BufferItem (含 acquireFence)
SF->>G: 合成命令 + wait(acquireFence)
G->>G: 等 fence signal 后采样 buffer
G->>SF: 触发 releaseFence
SF->>BQ: releaseBuffer(slot, releaseFence)
BQ->>P: slot 回到 FREE (供下一次 dequeue 等 releaseFence)
生产者侧实际写法:
// 1. 申请空闲 slot(同步模式没空 slot 会阻塞) |
这就是第五篇结尾说的”BufferChannelrenderOutputBuffer把它queueBuffer给 BufferQueue 时 Consumer 直接 latch”——对应的接口就是这几行。这块 slot 的物理内存早在 vendor 解码时已经写完,App 进程的renderOutputBuffer没有任何拷贝,只在协议层走一遍queueBuffer。
消费者侧(SurfaceFlinger 把这帧 latch 到 Layer 上):
BufferQueue::BufferItem item; |
releaseFence传回去后,Producer 下一轮要复写这块 slot 时会等这个 fence。整把循环就接上了。
fence:流水线全程零 CPU 等待
BufferQueue 接口里反复出现两个 fence:
- Acquire Fence(Producer → Consumer):填充完成信号。Consumer 用前要 wait。
- Release Fence(Consumer → Producer):消费完成信号。Producer 复写前要 wait。
两个 fence 加起来构成一帧 buffer 在两端之间的完整同步握手:
sequenceDiagram
participant P as Producer (VPU)
participant BQ as BufferQueue
participant C as Consumer (GPU 合成)
P->>BQ: queueBuffer(slot, acquireFence)
Note over P: acquireFence 由 VPU 解码完成触发
BQ->>C: acquireBuffer(item) 含 acquireFence
C->>C: GPU 命令流插入 wait(acquireFence)
C->>C: GPU 异步渲染
C->>BQ: releaseBuffer(slot, releaseFence)
Note over C: releaseFence 由 GPU 合成完成触发
BQ->>P: 下次 dequeueBuffer 返回 releaseFence
P->>P: VPU 入队前 wait(releaseFence)
整张图的关键是没有任何一处 CPU 主动wait()阻塞。Producer 提交 acquireFence 后立刻返回,Consumer 拿到 fence 不是马上等,而是把 wait 操作插进 GPU 命令流——GPU 内部排队等 VPU 信号,VPU 那边硬件触发 fence 时 GPU 命令流自然解锁。Release 方向也一样,VPU 收到 releaseFence 是丢给硬件去等,CPU 这条线一刻不停。
硬件流水线全程零 CPU 等待的本质在这里。BufferQueue 不光是数据通道,也是 fence 的中转站——所有跨硬件的时序契约都借queueBuffer / acquireBuffer / releaseBuffer这三个接口顺手传递。
QueueBufferInput:一次 queue 携带的全部信息
最后看queueBuffer这一次调用到底带了什么。QueueBufferInput几乎把一帧的所有元信息都囊括了:
struct QueueBufferInput { |
把这些字段对应到第四、第五篇的链路:
timestamp来自C2Work的input_ordinal.timestamp,一路 forward 过来dataSpace是 vendor 解码时根据 SPS/VUI 推出来的色彩空间transform对应MediaFormat里的KEY_ROTATIONfence是上一节那条 acquire fencehdrMetadata在 Gralloc 4.0 之前必须走的 side channel,4.0 之后可以走GraphicBuffer元数据通道,接口本身保留兼容
CCodecBufferChannel::renderOutputBuffer做的事,就是把这些字段从 Codec2 的C2Buffer + C2Info体系翻译过来,组装成一个QueueBufferInput扔过去。SurfaceFlinger 拿到之后,把这些信息一路传给 RenderEngine 或 HWC——颜色空间转换、HDR tonemap、旋转/裁剪全靠这一坨字段。
总结
这一篇按”容器 + 管道”两层把 MediaCodec 输出端拆完:
GraphicBuffer 部分:
- 它是 Android 图形栈的通用容器,里面包元数据,外面套引用计数,最内层是一个
native_handle_t里的 dma-buf fd。MediaCodec / GPU / SurfaceFlinger / HWC 全都只认它。 - usage flag 是分配前对访问者的预声明——决定走哪个 heap、能不能 CPU 读、能不能 HWC 扫描,写错直接报废。MediaCodec 输出走的是
HW_TEXTURE | HW_COMPOSER。 - 底层从 ION 切到 DMA-BUF 是一次平滑替换。用户态接口几乎没变,内核换了实现。Gralloc 4.0 之后元数据并入 buffer 本身,HDR 元数据不再需要单独 side channel。
- 跨进程靠 fd dup——fd 数值各进程不同,但内核里指向同一个
dma_buf,物理内存同一块。这是零拷贝的物理底座。
BufferQueue 部分:
- 它是所有图形数据流的共用管道。Camera / GL / MediaCodec / SurfaceFlinger 互通走的都是它。
- slot 在 FREE / DEQUEUED / QUEUED / ACQUIRED 四态间轮转,一块物理内存同时只有一个所有者。
- 同步模式保不丢帧但可能阻塞;异步模式保不阻塞但丢最旧那帧。MediaCodec Output 默认开足 5-12 块 slot 喂硬件 VPU pipeline。
- acquireFence / releaseFence 把零 CPU 等待做成机制——Producer/Consumer 之间所有跨硬件的时序契约都靠 fence 传递,CPU 全程只做转手,等待发生在 GPU/VPU 命令流内部。
回到第五篇结尾那句话——vendor 解码出来的 YUV”物理上一开始就分配在 SurfaceFlinger 的 BufferQueue 里”——展开成一句更完整的话:
vendor 进程通过BUFFERQUEUE类型的C2BlockPool,向 SurfaceFlinger 的 BufferQueue 调一次dequeueBuffer,拿到一块 slot 对应的GraphicBuffer;这块GraphicBuffer底层是一个 dma-buf fd,vendor 那边的C2Buffer、App 这边的GraphicBuffer、SurfaceFlinger 那边的GraphicBuffer,是同一块物理内存在三个进程里的三个视图。VPU 解码完成时挂一个 acquire fence,App 这边只走协议把 slot 和 fence 移交一下,SurfaceFlinger 拿到后用 fence 安排 GPU 等硬件信号——整条链上没人拷过一字节。