系列导读:第四篇停在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::inputworklet->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下发

层级上C2WorkC2FrameDataC2BufferC2Block,越往里越接近物理内存。C2BlockPool在外侧管分配,C2Fence横在中间管时序,C2Param独立成另一条线管配置。

后面真正要追的就两条:C2Workqueue / onWorkDone这条来回上是怎么填、怎么对回来的,以及它内部那几个shared_ptr<C2Buffer>是怎么跨进程不丢内存的

C2Work 长什么样

打开C2Work.h,结构体本身只有四五个字段:

struct C2Work {
C2FrameData input; // 输入:一帧压缩数据
std::list<std::unique_ptr<C2Worklet>> worklets; // 输出占位

uint32_t workletsProcessed = 0; // 完成的 worklet 数
c2_status_t result = C2_OK; // 处理结果

C2WorkOrdinalStruct input_ordinal;
};

struct C2FrameData {
uint32_t flags; // EOS / CODEC_CONFIG / DROP_FRAME ...
C2WorkOrdinalStruct ordinal; // 时间戳 / 帧号
std::vector<std::shared_ptr<C2Buffer>> buffers; // 实际数据
std::vector<std::unique_ptr<C2Param>> configUpdate; // 动态配置(如 bitrate 调整)
std::vector<std::shared_ptr<C2InfoBuffer>> infoBuffers;
};

第四篇queueInputBufferInternal那段填的就是这两个结构。先记三条,后面跨进程时还会回来用:

输入和输出共用同一种容器C2FrameDatainput是一份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 {
c2_cntr64_t timestamp; // PTS(纳秒),可以不单调(B 帧重排)
c2_cntr64_t frameIndex; // 全局单调递增的帧号
c2_cntr64_t customOrdinal; // 客户端自定义
};

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::liststd::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(
std::list<std::unique_ptr<C2Work>> *const items) {
// 1. 把 C2Work 列表打包成跨进程能传的 WorkBundle
WorkBundle workBundle;
Status status = objcpy(&workBundle, *items, &mBufferPoolSender);

// 2. Binder 调用,把 workBundle 投给 vendor
Return<Status> transStatus = mBase->queue(workBundle);

// 3. 清空原列表——所有权已经过去了
items->clear();
return /* ... */;
}

WorkBundle是 HIDL/AIDL 自动生成的可序列化结构,对应C2Work但只装可跨进程传的字段(POD 数据 + handle)。objcpy做的事情就是把每个C2Work里的shared_ptr<C2Buffer>拆开、把底层C2Blocknative_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]

BnComponent::onTransact // HIDL/AIDL 自动生成的 stub

Component::queue // HAL 接口实现层

ComponentImpl::queue (vendor 实现) // 厂商私有逻辑

C2Component::queue_nb // 真正的组件入口(non-blocking)

入队组件内部 work queue,立即返回

注意最后是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_handleobjcpy两端各做一次拆装,内存不动,引用计数靠 BufferPool 跨进程算。
  • 解码输出的零拷贝靠 BUFFERQUEUE poolconfigure阶段把 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++ 对象只是各自进程里的视图。