MediaCodec 全链路深度剖析(五):从 C2Work 到 vendor 进程
系列导读:第四篇停在
CCodecBufferChannel::queueInputBufferInternal里那一行mComponent->queue(&items)——一个C2Work列表打过去,HAL 那边解完通过onWorkDone回来。这一篇接着拆开这条线:
C2Work到底装了什么、为什么是这个形状?mComponent->queue()这一下从 App 进程到 vendor 进程到底走过哪些环节?- 解出来的 YUV 是怎么跨进程零拷贝交回来的?
Codec2 的几个核心数据结构会先用一节快速过完,主线放在
C2Work的来回与跨进程机制。
Codec2 数据结构速览
后面追queue / onWorkDone这条线时会反复用到这几个名字,这里先一张表对齐:
| 结构 | 一句话定义 | 用在哪 |
|---|---|---|
C2Work |
一次编解码任务的描述对象,HAL 唯一的输入单元 | comp->queue()传进去、onWorkDone回来 |
C2FrameData |
一帧的数据载体——时间戳 + buffers + 配置更新 + flags | C2Work::input和worklet->output |
C2Buffer |
数据容器,分 Linear(码流 / PCM)和 Graphic(YUV) | 装在C2FrameData::buffers里 |
C2Block |
底层 DMA 内存的抽象,持有 ION/dma-buf fd | C2Buffer内部组件 |
C2BlockPool |
Block 分配器,决定内存来源 | 输入用 LINEAR pool,输出 Surface 时用 BUFFERQUEUE pool |
C2Fence |
硬件同步原语,对应一个 sync fd | 跨硬件等”buffer 真的可读 / 可写”的信号 |
C2Param |
统一的参数协议,所有配置项都用它表达 | MediaFormat翻成一组C2Param下发 |
层级上C2Work套C2FrameData套C2Buffer套C2Block,越往里越接近物理内存。C2BlockPool在外侧管分配,C2Fence横在中间管时序,C2Param独立成另一条线管配置。
后面真正要追的就两条:C2Work在queue / onWorkDone这条来回上是怎么填、怎么对回来的,以及它内部那几个shared_ptr<C2Buffer>是怎么跨进程不丢内存的。
C2Work 长什么样
打开C2Work.h,结构体本身只有四五个字段:
struct C2Work { |
第四篇queueInputBufferInternal那段填的就是这两个结构。先记三条,后面跨进程时还会回来用:
输入和输出共用同一种容器C2FrameData。input是一份C2FrameData,每个worklet->output也是一份C2FrameData。差别只在装的内容:input 装压缩码流(Linear C2Buffer),output 装解码 YUV(Graphic C2Buffer)。这种对称让 HAL 那边写代码很省事——同一套 buffer 处理逻辑两边都能用。
worklets是输出占位,不是预先填好的输出。CCodecBufferChannel 在queueInputBufferInternal里只emplace_back(new C2Worklet)塞了一个空 worklet 进去,里面什么都没有。HAL 解完一帧后,把 YUV C2Buffer填到worklet->output.buffers里再回传——这个”留位 → 回填”的设计是异步路径的关键。
input_ordinal三个字段各管各的:
struct C2WorkOrdinalStruct { |
timestamp是 PTS,给 A/V 同步看的;frameIndex是 BufferChannel 自己维护的单调递增序号——HAL 处理完回调时只带这个 index,BufferChannel 拿它在内部映射表里查”这条 work 是哪一帧、对应哪个 input slot”。第四篇那行mFrameIndex++填的就是这个字段。customOrdinal这里就是 PTS 的副本,给上层做去重用。
为什么不直接拿timestamp认领?因为 B 帧场景下 PTS 不单调、可能重复(seek 后两帧 PTS 相同),靠它做主键会撞车。frameIndex保证一帧一个 ID,认领时不会出歧义。
一次 queue 的进程内调用
mComponent->queue()这行后面,从 App 进程到 vendor 进程之间隔着两个明显的边界:一个是 HAL 客户端代理Codec2Client,一个是 Binder 内核调用。
两个边界各自承担一件不同的事:
Codec2Client这一层做的是类型转换——把 App 进程内部用的 C++ 对象C2Work(带std::list、std::shared_ptr<C2Buffer>、虚表指针这些没法跨进程的东西)翻译成 HIDL/AIDL 定义的、纯数据的WorkBundle。这一步只在 App 进程内做,不涉及内核。- Binder 这一层做的是搬运——把上面那个
WorkBundle走 binder 驱动送到 vendor 进程,期间内核负责 fd 的复制和数据拷贝。
为什么要拆成两层?因为 HIDL/AIDL 自动生成的 binder proxy 只认 IDL 里声明的纯数据类型,而CCodecBufferChannel手里拿的是 C++ 业务对象。中间这层Codec2Client就是”业务对象 ↔ IDL 数据”的翻译层——上层永远拿C2Work写代码,下层永远收WorkBundle传 binder,互不打扰。
先看 App 进程这一侧从 BufferChannel 走到代理为止的路径。
flowchart TB
A["CCodecBufferChannel::queueInputBufferInternal
构造 C2Work、填 frameIndex、切 C2Buffer 视图"]
B["std::list<unique_ptr<C2Work>> items"]
C["mComponent->queue(&items)
(Codec2Client::Component 客户端代理)"]
D["objcpy(C2Work -> WorkBundle)
把 shared_ptr<C2Buffer> 序列化成可跨进程的形式"]
E["IComponent::queue(workBundle)
(HIDL/AIDL proxy)"]
F["binder driver"]
A --> B --> C --> D --> E --> F
最关键的一步是objcpy这个序列化函数,对应Codec2Client::Component::queue大致是这样:
c2_status_t Codec2Client::Component::queue( |
WorkBundle是 HIDL/AIDL 自动生成的可序列化结构,对应C2Work但只装可跨进程传的字段(POD 数据 + handle)。objcpy做的事情就是把每个C2Work里的shared_ptr<C2Buffer>拆开、把底层C2Block的native_handle(也就是 ION/dma-buf 的 fd)抽出来塞进WorkBundle。
std::shared_ptr<C2Buffer>本身没法跨进程——它是 App 进程里的 C++ 引用计数对象。能跨过去的只有里面那个 fd。Binder 跨进程传 fd 时,内核会在目标进程里复制出一个新 fd 编号,但指向同一块物理内存。WorkBundle这一头是”原料清单”,到 vendor 那头objcpy再反向走一遍——根据 fd 重建一个本地的C2Buffer/C2Block对象。两边都拿着各自的shared_ptr管自己的引用计数,不互相干扰。
flowchart LR
subgraph App["App 进程"]
direction TB
A1["C2Buffer"]
A2["C2Block"]
A3["C2Handle
fd = 42"]
A1 --> A2 --> A3
end
subgraph Vendor["vendor 进程"]
direction TB
V1["C2Buffer"]
V2["C2Block"]
V3["C2Handle
fd = 78"]
V1 --> V2 --> V3
end
DMA[("同一块
ION / dma-buf")]
A3 -. Binder 传 fd .-> V3
A3 --> DMA
V3 --> DMA
但只靠 fd 还有一个问题没解决:生产侧什么时候 free 这块内存?App 进程把shared_ptr一释放,本地引用计数归零,但 vendor 那边可能还在用。这事归 BufferPool 管。
BufferPool:跨进程的引用计数
android.hardware.media.bufferpool@2.0是一个独立的 HAL 服务,专门做一件事——让一块共享内存的引用计数能跨进程算清楚。
机制概括成一句:每块 buffer 有一个全局唯一的 BufferID,所有持有者通过 BufferPool 注册和注销,BufferPool 数到 0 才真正释放。
sequenceDiagram
participant App as App 进程
participant Pool as BufferPool 服务
participant V as vendor 进程
V->>Pool: register(fd, BufferID=X)
V->>App: queue WorkBundle (含 BufferID=X)
App->>Pool: receive(BufferID=X)
Note over App: refcount(X) = 2
App->>App: 用户 releaseOutputBuffer
App->>Pool: postSendMessage(release, X)
Note over Pool: refcount(X) = 1
V->>Pool: postSendMessage(release, X)
Note over Pool: refcount(X) = 0 → 真正释放
之所以要单独做这层,是因为 Binder 自己的 fd 复制语义不带”全局引用计数”概念——A 关 fd 不影响 B 那边的 fd,但物理内存到底还在不在用,只看 fd 是不够的(fd 能复制无数份)。BufferPool 就在这上面补了一层全局账本。
第四篇queueInputBuffer走到这里能看到的是:releaseBuffer(buffer, &c2buffer, /*release*/ false)切出来的c2buffer一旦塞进WorkBundle,跨过 Binder 后 vendor 那边会收到一份”已注册的”C2Buffer——背后就是 BufferPool 在管着。
跨进程之后:vendor 进程的处理
前面三节分别讲了 App 进程内部的序列化、BufferPool 的引用计数、跨过 binder 之后的解封装——这些片段拼起来才是一帧完整的”App → vendor”路径。先把它们叠成一张端到端的时序图,再回来看 vendor 这一侧的细节:
sequenceDiagram
participant CC as CCodecBufferChannel
(App 进程)
participant CL as Codec2Client::Component
(App 进程)
participant BD as binder driver
(kernel)
participant ST as BnComponent stub
(vendor 进程)
participant CI as ComponentImpl::queue
(vendor 进程)
participant WT as vendor 工作线程
CC->>CC: 构造 C2Work、填 frameIndex
切 C2Buffer 视图
CC->>CL: queue(&items)
CL->>CL: objcpy(C2Work → WorkBundle)
抽出 native_handle (fd)
CL->>BD: IComponent::queue(workBundle)
BD->>BD: 复制 fd 到目标进程
BD->>ST: onTransact(QUEUE)
ST->>CI: Component::queue(workBundle)
CI->>CI: objcpy(WorkBundle → C2Work)
按 fd 重建 C2Buffer
CI->>WT: queue_nb(入队后立即返回)
CI-->>BD: Status::OK
BD-->>CL: 同步返回
CL-->>CC: c2_status_t = C2_OK
Note over WT: 异步:取 work、调 VPU
填 worklet->output、回调 onWorkDone
图里的关键节点对应前面三节:objcpy两次(去程一次拆、vendor 一次装)来自”一次 queue 的进程内调用”;binder driver 复制 fd 的语义在”BufferPool”那节展开;queue_nb和异步工作线程就是这一节要讲的重点。
接着看 vendor 这一侧调用栈大致是:
[kernel: binder driver] |
注意最后是queue_nb——non-blocking。vendor 这边收到WorkBundle、反序列化回C2Work列表、塞进自己的内部队列,整条链就立即沿着 Binder 返回了。真正的解码动作发生在 vendor 进程的另一条工作线程上:那条线程从队列里取C2Work,调 VPU 驱动 ioctl,硬件解码完成后,从输出 BlockPool 里分配一块 Graphic Block,构造C2Buffer填到worklet->output.buffers,置workletsProcessed = 1,然后通过 Listener 把这条C2Work回传。
这个分裂——“queue同步返回 + 解码异步进行 + onWorkDone异步回调”——是第四篇里反复说的”comp->queue()是 fire-and-forget”在协议这一层的具体形状。
onWorkDone 的回路
输出方向有一个不太直觉的点:onWorkDone这条回调链是 vendor 主动调 App,方向反过来。
flowchart TB
V["vendor 进程
解码完成、填好 worklet->output"]
L["IComponentListener::onWorkDone
(server->client 回调,HIDL/AIDL 都支持)"]
K["binder driver"]
P["App 进程
BnComponentListener::onTransact"]
O["objcpy(WorkBundle -> C2Work)
反序列化,重建 shared_ptr<C2Buffer>"]
C["CCodecBufferChannel::onWorkDone(workItems)"]
V --> L --> K --> P --> O --> C
要让 vendor 能”反过来”调 App,初始化时 App 这一侧得先把一个Listener注册过去。第四篇CCodec::start里那条不起眼的注释”订阅事件”做的就是这件事——App 进程构造一个BufferPoolSender + Listener远端对象,通过 HIDL/AIDL 发到 vendor 那边,vendor 拿着这个 binder proxy 在解完一帧后回调。
输出 buffer 的零拷贝分配也是这一步的关键。回想一下第四篇CCodec::configure第 6 步:mChannel->setSurface(surface)。它干的事是创建一个BUFFERQUEUE 类型的C2BlockPool,把 Surface 的IGraphicBufferProducer挂上去。这个 pool 的 handle 跨进程发到 vendor 之后,vendor 解码出每帧 YUV 时,从这个 pool 里fetchGraphicBlock——内部直接走到 SurfaceFlinger 的 BufferQueue,dequeue 出一个真正的GraphicBuffer slot。
也就是说,vendor 解码出来的那块 YUV 内存,物理上一开始就分配在 SurfaceFlinger 的 BufferQueue 里。C2Buffer只是它在 vendor 进程里的一个引用视图。onWorkDone回到 App 那一刻,BufferChannel 拿到的是同一块内存的另一个视图。等到renderOutputBuffer把它queueBuffer给 BufferQueue 时,Consumer 那边发现”这块 slot 我之前给出去过、现在又回来了”,直接 latch 即可——全程没有一次内存拷贝。
vendor 解码 → SurfaceFlinger 显示,三个进程之间走的是同一块物理内存的不同视图,靠 fd + BufferPool + BufferQueue 三套机制接力。
总结
这一篇沿着第四篇结尾mComponent->queue()那行,跨过进程边界把整条协议链拆开来看:
C2Work是 HAL 唯一的输入单元,input + worklets两段、各自一个C2FrameData,靠frameIndex认领回路。worklets是预留的输出占位,由 vendor 回填。- 跨进程靠的是
WorkBundle序列化——shared_ptr<C2Buffer>本身不能传,能传的只有底层 ION/dma-buf 的native_handle。objcpy两端各做一次拆装,内存不动,引用计数靠 BufferPool 跨进程算。 - 解码输出的零拷贝靠 BUFFERQUEUE pool:
configure阶段把 Surface 的 BufferQueue 包成C2BlockPool发给 vendor,vendor 解码时直接从 BufferQueue 拿 slot,物理内存全程不动,三个进程拿的都是同一块内存的不同视图。 onWorkDone是 server→client 反向回调,依赖 App 注册的 Listener proxy。queue同步返回 + vendor 内部异步处理 +onWorkDone异步回传——这一对组合是 fire-and-forget 在协议层的具体形状。
MediaCodec 整条管线在 native 这一层的”零拷贝”不是一个孤立技巧,是C2BlockPool + BufferPool + BufferQueue三套机制叠出来的——前者管”内存来自哪”,中间管”还有谁在用”,后者管”显示端怎么接”。三层都用 fd 作为跨进程的硬通货,C++ 对象只是各自进程里的视图。