2、硬件加速架构概览:SoC视频编解码模块(VPU/CODEC)的典型架构、硬件Pipeline与软件交互模型

好,我们直接进入正题。上一章聊了视频编解码的基础概念,这一章我们来看看,这些算法在芯片里到底是怎么跑的。

说白了,视频编解码硬件加速,就是把那些计算密集的活儿——比如运动估计、DCT变换、熵编码——从CPU手里抢过来,交给专用的硬件模块去干。这个模块,业界通常叫它VPU(Video Processing Unit)或者CODEC(Coder-Decoder)。

我个人习惯把VPU看作一个“小型的、高度定制化的处理器”。它不跑通用指令,而是跑硬件流水线。你想想看,一个1080p@60fps的视频流,每秒要处理超过1.2亿个像素点。CPU要是干这活,功耗直接起飞。所以,必须上硬件。

2.1 SoC中的VPU/CODEC:它到底长什么样?

我们先从宏观上看看,一个典型的SoC里,视频编解码模块是怎么嵌入进去的。

核心要点:VPU不是孤岛。它通过系统总线(如AXI总线)与DDR内存、CPU、以及其他外设(如Display Controller、Camera ISP)相连。

下面这张图是我用SVG画的,展示了一个典型的SoC视频子系统架构。你可以把它当作一个“地图”,后面讲的所有细节,都能在这张图上找到位置。

典型SoC视频编解码子系统架构 CPU Cluster (驱动/控制面) DDR 内存 (帧缓冲/码流) 系统总线(AXI / AHB) VPU / CODEC 核心 熵编码器 运动估计 变换/量化 环路滤波 参考帧缓存 像素处理单元 控制寄存器接口(MMIO) 控制路径 数据路径

这张图里,CPU通过控制寄存器接口(MMIO)给VPU下指令,比如“开始解码第5帧”。VPU自己则通过AXI总线,从DDR里拉取码流数据,再把解码后的YUV帧写回DDR。数据流和控制流是分开的,这是硬件加速设计的核心思想之一。

我的经验: 我在做一款4K解码芯片时,发现VPU和DDR之间的带宽经常成为瓶颈。后来我们在VPU内部加了一个128KB的SRAM作为临时缓冲区,专门缓存参考帧的局部数据。就这么一个小改动,总线带宽占用直接降了40%。所以,别小看内部缓存的设计。

2.2 硬件Pipeline:编解码的“流水线工厂”

VPU内部是怎么工作的?答案是:流水线(Pipeline)。

你可以把硬件Pipeline想象成一条汽车装配线。每个工位只干一件事,干完就传给下一个。这样,虽然单个任务耗时不变,但整体吞吐量可以做到每个时钟周期出一个结果。

一个典型的视频解码硬件Pipeline,大致包含以下几个阶段:

  1. 码流解析(Parsing):从DDR读取压缩码流,解析出语法元素(如宏块类型、运动矢量)。
  2. 熵解码(Entropy Decoding):对CAVLC或CABAC进行解码,得到量化后的变换系数。
  3. 反量化/反变换(IQ/IDCT):把系数变回残差像素块。
  4. 运动补偿(Motion Compensation):根据运动矢量,从参考帧中取出预测块。
  5. 重建(Reconstruction):预测块 + 残差块 = 重建块。
  6. 环路滤波(Loop Filter):去块效应滤波,消除方块效应。

编码的Pipeline基本是反过来的,但多了个运动估计(Motion Estimation)的步骤。这步最耗计算,通常占编码器70%以上的算力。

关键点:硬件Pipeline的深度和平衡性,直接决定了VPU的性能。如果某个阶段处理得慢,它就会成为整个流水线的“瓶颈”,拖慢整体帧率。

我记得有一次调试一个H.265解码器,发现帧率始终上不去。用示波器抓了各个模块的握手信号,发现是熵解码模块的CABAC引擎处理一个宏块需要300个时钟,而运动补偿只需要80个时钟。这就是典型的流水线不平衡。后来我们给CABAC引擎增加了双发射能力,才把帧率拉回来。

2.3 软件交互模型:驱动怎么“指挥”硬件?

硬件再强,也得有软件来调度。VPU的软件交互模型,说白了就是驱动(Driver)固件(Firmware)之间的配合。

我习惯把这种交互分成三个层次:

层次 运行位置 职责 典型接口
应用层 CPU (Linux/Android) 调用V4L2或Media API ioctl, mmap
内核驱动层 CPU (Kernel Space) 管理硬件资源、提交任务 中断、DMA、寄存器读写
硬件固件层 VPU内部微控制器 调度硬件Pipeline、处理错误 Mailbox、共享内存

具体流程是这样的:

  1. 应用程序(比如播放器)通过V4L2接口,向驱动提交一个解码请求。
  2. 驱动把码流缓冲区的物理地址、输出帧缓冲区的地址,打包成一个“任务描述符”,写入VPU的寄存器。
  3. 驱动写一个“启动”寄存器,触发VPU开始工作。
  4. VPU内部的固件(一个轻量级的RISC-V或ARM核)解析任务描述符,然后按顺序启动硬件Pipeline的各个模块。
  5. 解码完成后,VPU通过中断通知CPU。驱动在中断处理函数中,把完成的任务返回给应用层。

避坑指南: 我曾经遇到过一个非常隐蔽的bug。驱动在提交任务后,立即修改了码流缓冲区的数据。结果VPU正在读取那块内存,读到的数据是错的,导致解码花屏。后来我们强制要求:驱动在VPU工作期间,绝对不能碰正在使用的缓冲区。这个叫“缓冲区的所有权转移”。

2.4 一个简单的交互代码示例

下面是一个伪代码,展示了驱动如何向VPU提交一个解码任务。注意,这不是真实的Linux驱动代码,而是为了说明交互逻辑。

// 驱动侧:提交解码任务
struct vpu_task {
    uint32_t bitstream_addr;  // 码流物理地址
    uint32_t bitstream_size;  // 码流大小
    uint32_t output_addr[2];  // YUV输出地址(双缓冲)
    uint32_t pic_width;
    uint32_t pic_height;
};

// 1. 填充任务描述符
struct vpu_task task;
task.bitstream_addr = dma_map_single(dev, bitstream_buf, size, DMA_TO_DEVICE);
task.output_addr[0] = dma_map_single(dev, frame_buf0, size, DMA_FROM_DEVICE);
task.output_addr[1] = dma_map_single(dev, frame_buf1, size, DMA_FROM_DEVICE);

// 2. 写入VPU寄存器(MMIO)
writel(VPU_REG_TASK_ADDR, &task);
writel(VPU_REG_CMD, VPU_CMD_START_DECODE);

// 3. 等待中断
wait_for_completion(&vpu_done);

// 4. 中断处理函数中
irq_handler() {
    // 读取状态寄存器
    status = readl(VPU_REG_STATUS);
    if (status & VPU_STATUS_DECODE_DONE) {
        // 通知应用层
        complete(&vpu_done);
    }
}

这段代码看起来简单,但实际工程中要考虑的事情多得多。比如:多个任务如何排队?硬件出错时怎么恢复?如何支持H.264和H.265两种模式的切换?这些我们后面会慢慢展开。

2.5 总结一下

这一章我们聊了VPU在SoC中的位置、硬件Pipeline的典型阶段、以及软件驱动的交互模型。说白了,硬件加速就是“CPU下命令,VPU干苦力,DDR当仓库”。理解了这个框架,后面再深入细节时,你就知道每个模块是干什么用的了。

嗯,这里要注意一点:不同厂商的VPU架构差异很大。高通、联发科、海思,各有各的玩法。但万变不离其宗,核心的Pipeline和交互模型是相通的。你只要吃透了我上面讲的这套逻辑,换任何平台都能快速上手。


专注资料整理