系列导读:这是 MediaCodec 全链路系列 Part I 的收束篇。前七篇按分层顺序拆过一次——从 Kotlin API 到 Native 消息、从 CCodec 到 Codec2、从 GraphicBuffer 到 SurfaceFlinger。分层拆解的代价是每篇只讲一段管道,读完之后端到端的因果链容易散。这一篇把 01 到 07 拉回一张全景图,只讲主线,不再下钻实现细节。读完这一篇,一句话复述”从码流字节到像素点亮”该说清楚哪些环节,就能自己填出来。


一、回到

系列第一篇的问题:点下播放键之后,一帧从 MP4 里的字节到面板上一个像素,中间到底发生了什么?当时给的答案是三个数字——3 次跨进程、7 次关键跳跃、4 种硬件协同

前七篇每一篇拆的都是这条链路上的某一段:

  • 01、02 定位 MediaCodec 的身份和 Java 侧状态机;
  • 03 讲 Native 侧AMessage +ALooper怎么把同步 API 支起来;
  • 04 讲 MediaCodec 和 CCodec 的分工,BufferChannel是做什么的;
  • 05 讲C2Work跨进程到 vendor,BufferPool 的引用计数;
  • 06 讲 GraphicBuffer 与 BufferQueue 这套零拷贝底座;
  • 07 讲 Surface、SurfaceFlinger、HWC、VSYNC 这一段,一直到 DPU 扫描出去。

每一篇是”一段管道”,Part I 收束就是把七段管道拼成一张地图,让 01 那道题能被完整回答。


二、一帧的完整生命周期

先给那张全景图。这是整条主线唯一一张时序图,横跨三个进程、六个软件层、五种硬件角色。

sequenceDiagram
    autonumber
    participant App as App 进程
(Kotlin/Java) participant Frw as App 进程
(Native Framework) participant Med as mediaserver / codec 进程
(CCodec + Codec2 client) participant Ven as vendor 进程
(Codec2 HAL + VPU 驱动) participant Sf as SurfaceFlinger 进程 participant Hw as HWC / DPU (硬件) App->>Frw: queueInputBuffer(index, ...) Frw->>Frw: JNI 进入 JMediaCodec
PostAndAwaitResponse 挂起 Frw->>Med: Binder: kWhatQueueInputBuffer Med->>Med: CCodecBufferChannel
组装 C2Work Med->>Ven: HIDL/AIDL: queue(work) Ven->>Ven: VPU 硬解 → 写入 dma-buf
产出 GraphicBuffer(slot) Ven-->>Med: onWorkDone(work)
output buffer index Med-->>Frw: kWhatDrainThisBuffer Frw-->>App: onOutputBufferAvailable(index) App->>Frw: releaseOutputBuffer(index, renderTimeNs) Frw->>Sf: Surface::queueBuffer
(通过 IGraphicBufferProducer) Note over Sf,Hw: VSYNC-sf 到达 Sf->>Sf: latchBuffer / prepare
validateDisplay Sf->>Hw: presentDisplay(layer 集合) Hw->>Hw: DPU 直接读 dma-buf
overlay 扫描到面板 Hw-->>Sf: presentFence signal Sf-->>Ven: releaseFence signal
slot 归还

这张图上每一根箭头都对应前七篇的一节内容,不打算再复述。它的作用是提供一个可以对着 trace 去核对的骨架——Perfetto 里能看到的每一条MediaCodec::onQueueInputBufferC2Work::processSurfaceFlinger::onMessageReceived都能落到这张图上的某一步。

有三条正交的线索沿着这张图并行流动。分开看能看清各自的规律,混在一起看就是一团乱:控制流、数据流、同步流。下面三节各拆一条。


三、控制流:命令怎么走完六层

控制流是”一次调用是怎么被路由到硬件寄存器”的路径。App 侧看到的是同步的方法调用,底下六层每一层都有自己的边界。

按调用发生的顺序排列,一条queueInputBuffer的路径是这样:

Kotlin: codec.queueInputBuffer(idx, 0, size, ptsUs, 0)
Java: MediaCodec.queueInputBuffer // 02: 状态机守护
JNI: android_media_MediaCodec_queueInputBuffer // 03: JMediaCodec 桥
Native: MediaCodec::queueInputBuffer // 04: 打包成 AMessage
AMessage post → CodecLooper // 03: 独立 looper 线程
MediaCodec::onQueueInputBuffer // 04: 状态检查后转交
CCodecBufferChannel::queueInputBuffer // 04: 组装 C2Work
CCodec::queue // 05: HIDL/AIDL 送入 vendor
Codec2 HAL: work → 组件 process() // 05: vendor 侧执行
VPU driver: ioctl 下发硬件寄存器 // 硬件真正开工

有两个边界值得单独记住,因为它们最容易被误当成”魔法”:

同步 API 的假象由PostAndAwaitResponse维持。 03 里讲过,App 线程在这一步挂起,等AReplyToken被 signal 才继续。上层看起来是同步方法,底下是异步 Looper——从 Java 一直到 CCodec 都在跑异步,同步语义只在最外层薄薄一层。理解这一点之后,”为什么 configure 卡了 200ms 会导致 UI 卡顿”就不需要额外解释。

跨进程只发生在两个位置。 一个是 App 进程 → mediaserver(或 codec 进程),走的是 MediaCodec 的 Binder 接口;另一个是 mediaserver → vendor 进程,走的是 Codec2 的 HIDL/AIDL 接口。加上后面 App → SurfaceFlinger 的queueBuffer,01 那句”3 次跨进程”到这里就落地了。

控制流方向的另一半——回程——不是复用去程通道,而是走 MediaCodec 的回调 AMessage:kWhatDrainThisBuffer往上冒到 Java 层的onOutputBufferAvailable。这也是 03 里独立讲过的那条 looper 单向消息,不再重复。


四、数据流:那块 YUV 到底住在哪儿

控制流拆完之后,数据流反而更容易讲,因为整条链上真正搬像素的机会只有一次——没有。这是 06 讲清楚过的核心结论:稳态播放下 CPU 一字节像素都不搬。

原因在于所有进程看到的都是同一块 dma-buf 的不同视图:

物理内存: 1 块 dma-buf (由 Gralloc 分配)
├─ vendor 进程: fd = 27, GraphicBuffer 视图 (Producer 侧)
├─ mediaserver 进程: fd = 42, GraphicBuffer 视图 (途径, 不真读)
├─ App 进程: fd = 15, GraphicBuffer 视图 (走 Surface 转手)
└─ SF 进程: fd = 88, GraphicBuffer 视图 (Consumer 侧, 交给 DPU)

同一块物理页,四个进程里四个不同的 fd 数值,因为 fd 是每个进程各自的文件描述符表里的索引,Binder 传递时会做 fd 复制。这也是为什么 05 里那种”跨进程零拷贝”的说法在物理层面站得住脚——搬的不是像素,是引用。

VPU 把解码结果直接写进这块 dma-buf。之后 vendor → mediaserver → App → SF 的四次”传递”,传的都是slot 编号 + fence fd。DPU 最终来读的时候,读到的还是 VPU 当初写入的那块物理内存。从头到尾这块内存只被写一次、读一次,中间没有任何拷贝

有一个例外场景,07 里点过一次:如果 HWC 判定视频层不能走 DEVICE composition(比如 dataspace 不支持、overlay plane 已经用完),会 fallback 到 CLIENT composition,SF 会用 GPU 把这一层合到 framebuffer 上——那一次 GPU 会真读像素、写到另一块 buffer。不过这属于异常路径,主链路上不发生。

一句话记住数据流:主链路只有 VPU 一次写、DPU 一次读;GPU 只在 fallback 时才碰像素


五、同步流:三条 fence 链条串起硬件流水线

前面两条线索讲完,还剩最后一个问题:VPU 什么时候真的写完?DPU 什么时候可以安全去读?如果 CPU 全程不搬像素、也不忙等,靠什么保证时序不错乱?

答案是三条 fence 链条。它们贯穿全链路,是 06、07 反复出现但没有集中过的一个骨架:

fence 类型 谁 signal 谁 wait 保证的事情
acquire fence VPU(写完 buffer 时) DPU/GPU(读之前) 消费方不会读到尚未写完的 buffer
release fence DPU(扫描完成时) VPU(复用 slot 之前) 生产方不会复写尚未扫完的 buffer
present fence 显示硬件(面板真正刷新时) SF / App(用于统计和调帧) 端到端时延闭环,播放器可校准

三条 fence 都不是 CPU 忙等——都是内核级的 sync_file,被硬件 signal,被驱动 poll。这带来一个直觉上不太自然的结论:这条流水线上真正让”硬件流水线”名副其实的,不是芯片能力,是 fence 机制。VPU、GPU、DPU 三块芯片可以完全异步跑,CPU 只在协议层转手 fd,硬件之间的顺序关系全部由 fence 表达。

把这条同步线单独拎出来看,才能理解为什么 07 反复强调”CPU 全程不 wait”——不是不需要等,而是等的动作被下沉到硬件里去了。


六、五种硬件在这条链路里各干什么

前七篇没有一次把五种硬件放在同一张图上讲过。到收束这里补上,作为整个 Part I 的另一张核心图。

flowchart LR
    subgraph 稳态主链路
        direction LR
        VPU["VPU
(硬件解码器)"] -->|dma-buf| DPU["DPU
(HWC overlay)"] DPU -->|MIPI/eDP| Panel["显示面板"] end subgraph 辅助与旁路 direction TB CPU["CPU
控制流 + fence 转手"] GPU["GPU
特效 / SF fallback"] NPU["NPU
超分 / 插帧 (opt-in)"] end CPU -.协议层调度.-> VPU CPU -.协议层调度.-> DPU GPU -.CLIENT composition 兜底.-> DPU GPU -.App 侧 shader 特效.-> DPU NPU -.可选中间处理.-> DPU style VPU fill:#FFF9C4,stroke:#F9A825 style DPU fill:#FFF9C4,stroke:#F9A825 style Panel fill:#FFF9C4,stroke:#F9A825 style CPU fill:#E3F2FD,stroke:#1976D2 style GPU fill:#F3E5F5,stroke:#7B1FA2 style NPU fill:#F3E5F5,stroke:#7B1FA2

图上颜色区分了两组:黄色是稳态主链路——VPU 到 DPU 到面板,视频播放稳定运行时数据流只经过这三块硬件。紫色和蓝色是辅助——CPU 只在协议层做调度、GPU 只在特效或 fallback 时介入、NPU 是可选旁路。

每一块硬件的职责用一句话钉死:

  • CPU:只搬 fd 和消息,不搬像素。稳态每帧的 CPU 占用几乎全部来自 Binder、AMessage、fence 转手。
  • VPU:真正的解码工人。产物直接落在 dma-buf 上,不经过 CPU 内存。
  • GPU:三种角色——App 侧做滤镜和 shader 特效(走 SurfaceTexture)、SF 做色彩空间转换和图层混合(部分设备上)、CLIENT composition 兜底。视频稳态不走 GPU 路径。
  • DPU / HWC:视频稳态的最后一公里。overlay plane 直读 dma-buf,扫描到面板,硬件级完成 YUV → RGB 转换和 scaling。走 CLIENT 是异常。
  • NPU:目前是 opt-in 的可选处理,比如超分、插帧。不在主链路上。

这张图值得记住的一句话:主链路稳态下只有 VPU 到 DPU 两颗芯片在动,中间那些复杂结构是为了让这两颗芯片不需要认识彼此就能配合。这句话概括了整个下行链路的工程价值。


七、六个边界的备忘录

站在这张全景图前,读者最容易忘的是两个域之间到底怎么衔接。前七篇每一篇的重心是”域内的事”,域和域之间的边界只是一笔带过。这里把六个边界列成备忘录:

边界 前一域交出的东西 后一域怎么接 章节
Java ↔ JNI MediaCodec.java一次方法调用 JMediaCodec翻成 Native 指针 + AMessage 02 → 03
同步 API ↔ 异步 Looper 上层同步阻塞调用 PostAndAwaitResponse +AReplyToken挂起 03
MediaCodec ↔ CCodec 抽象接口CodecBase CCodecBufferChannel落到 Codec2 语义 04
CCodec ↔ HAL 一个C2Work对象 HIDL/AIDL 送到 vendor 进程 05
Codec2 ↔ 图形栈 vendor 从 BUFFERQUEUE pool 拿的 GraphicBuffer 那个 dma-buf fd 就是 SF 侧 slot 的物理内存 05 → 06
Surface ↔ SurfaceFlinger Producer 侧queueBuffer塞进 slot Consumer 侧latchBuffer拉走,进入 SF 四步 06 → 07

六个边界串起来就是一条完整的下行链路。每一行的”章节”列指向的是可以回查细节的位置——这一节不再展开任何一个。


八、把 01 那道题的答卷交出去

回到开篇那三个数字,现在可以填得更实:

维度 数字 落到哪
像素拷贝 0 次 主链路稳态;CLIENT fallback 时 GPU 会读一次
跨进程 3 次 App ↔ mediaserver ↔ vendor ↔ SurfaceFlinger
硬件协同 4 到 5 种 CPU + VPU + DPU 是主链路;GPU 视场景介入;NPU opt-in
关键跳跃 7 次 Java → JNI → CCodec → Codec2 client → Codec2 HAL → BufferQueue → SF/HWC
同步机制 3 条 fence 链 acquire / release / present

数字对得上 01 的开篇。这不是巧合——这几个数字是 01 起笔时倒推 07 结尾的落点得到的,Part I 就是为了把这条推导过程铺满。