本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:H264(AVC)是IT领域中广泛使用的高效视频压缩标准,广泛应用于多媒体处理和视频流传输。本文深入解析基于C++的H264编解码源代码,涵盖熵编码、变换编码、宏块处理、帧内/帧间预测、运动估计与补偿、CABAC编码及解码流程等关键技术。通过分析“H264编码”与“H264解码”文件夹中的完整源码,开发者可掌握视频编解码核心算法的C++实现方法,提升在高性能计算、流媒体服务和嵌入式系统中的实战能力。

H.264 编解码技术深度解析与高性能 C++ 实现

在流媒体、视频会议、安防监控乃至元宇宙内容传输中,H.264(又称 AVC)依然是最广泛使用的视频编码标准。它不仅支撑着 YouTube、Netflix 等平台的海量分发,也深深嵌入到从手机摄像头到 8K 电视的每一环硬件生态中。但你有没有想过:为什么一段高清视频能被压缩到原始大小的几十分之一,却依然清晰可辨?这背后的核心秘密,就藏在 宏块处理、帧内预测、帧间补偿、整数 DCT 和熵编码 这一套精密协作的机制里。

更进一步地,当我们要实现一个真正高效的编码器——比如 x264 或 FFmpeg 中的 libx264——光懂理论远远不够。我们必须深入到底层,用 C++ 构建出内存友好、SIMD 友好、缓存友好的数据结构和算法流程。否则,哪怕逻辑完全正确,性能也可能差上数倍。

今天,我们就来揭开这层“黑箱”,带你从零开始构建一个现代 H.264 编码器的关键模块。不是教科书式的罗列概念,而是像一位实战工程师那样,边写代码、边调优、边思考:“这个结构体要不要对齐?”“这条循环能不能展开?”“这块内存会不会伪共享?”准备好了吗?Let’s roll!🚀


宏块:H.264 的基本计算单元

一切都要从“宏块”说起。在 H.264 中,一幅图像会被划分为多个 16×16 像素的宏块(Macroblock, MB),它是所有编码操作的基本单位。你可以把它想象成一幅拼图中的每一块——每一块都要独立完成预测、变换、量化和编码。

但这不仅仅是“切块”那么简单。宏块的设计直接影响了:

  • 压缩效率 :如何划分子块才能更好地匹配物体边缘?
  • 计算复杂度 :是否启用 8×8 变换?是否递归划分?
  • 内存访问模式 :像素怎么存储?参考帧如何索引?

我们先来看一个典型的 NAL 单元结构,它是最终输出的比特流基本单位:

struct NALUnit {
    uint8_t forbidden_bit;
    uint8_t nal_ref_idc;   // 优先级,0 表示非参考帧
    uint8_t nal_unit_type; // 类型:1=Slice, 5=IDR, 7=SPS, 8=PPS
};

没错,这就是整个视频流的“外包装”。而里面真正的“内容”,就是由一个个宏块组成的 Slice 数据。那么,宏块内部又长什么样呢?

宏块头信息:控制一切的开关

每个宏块都有自己的“身份证”,记录它的编码方式、预测模式、量化参数等。我们用一个结构体来封装这些元数据:

struct MacroblockHeader {
    uint8_t mb_type;           // I_16x16, P_Skip, B_Direct 等
    uint8_t partition_mode;    // 16x16, 16x8, 8x16, 8x8 等
    int8_t  qp_delta;          // QP 相对增量
    bool    transform_size_8x8_flag; // 是否启用 8x8 DCT
};
字段 类型 描述
mb_type uint8_t 编码类型标识,决定是帧内还是帧间
partition_mode uint8_t 子块划分方式,影响运动估计粒度
qp_delta int8_t 局部调整量化强度,范围 -26 ~ +25
transform_size_8x8_flag bool High Profile 特性,提升细节保留

💡 小贴士 :别小看这几个字节!以 1080p 视频为例,一帧就有约 8100 个宏块。如果每个字段都用 int 而不是 uint8_t ,仅这一项就会多消耗近 24KB 内存/帧。积少成多,在实时系统中可是致命的。

而且,这些字段通常是按位打包存储的,甚至可以用 位域(bitfield) 进一步压缩。但在高性能场景下,我们往往宁愿牺牲一点空间换取更快的访问速度——毕竟 CPU 缓存比内存快上百倍。

YUV 4:2:0:为什么色度只有一半?

H.264 普遍采用 YUV 4:2:0 采样格式,而不是 RGB。这是基于人眼对亮度(Y)敏感、对色度(Cb/Cr)不敏感的心理视觉特性。简单说:颜色稍微模糊点,你也看不出来 😄

所以,一个 16×16 的亮度宏块,对应的是两个 8×8 的色度块:

+---------------------+
|       Y (16x16)      |
+----------+----------+
| Cb (8x8) | Cr (8x8) |
+----------+----------+

在 C++ 中,我们通常使用 平面式(planar)布局 来存储 YUV 数据:

class YuvFrame {
public:
    uint8_t* y_data;     // 亮度平面指针
    uint8_t* cb_data;    // 色度 Cb 平面指针
    uint8_t* cr_data;    // 色度 Cr 平面指针
    int width, height;
    int stride_y, stride_c;  // 每行字节数(考虑内存对齐)
};

为什么不用 packed 格式(如 NV12)?因为 planar 更适合 SIMD 操作!比如你要对一行 Y 像素做 SAD 计算,可以直接用 SSE 加载 16 字节连续数据,无需解包。而且 stride 字段确保每行起始地址按 16/32 字节对齐,避免跨缓存行访问带来的性能损耗。

下面是图像从采集到宏块分解的完整路径:

graph TD
    A[原始RGB图像] --> B[转换为YUV]
    B --> C{YUV 4:2:0采样}
    C --> D[Y Plane: Full Resolution]
    C --> E[Cb Plane: Half Width & Height]
    C --> F[Cr Plane: Half Width & Height]
    D --> G[Split into 16x16 Macroblocks]
    E --> H[Split into 8x8 Chroma Blocks]
    F --> I[Split into 8x8 Chroma Blocks]
    G --> J[Process Luma MB]
    H --> K[Process Cb Block]
    I --> L[Process Cr Block]
    J --> M[Combine into Final MB Output]
    K --> M
    L --> M

子宏块划分:越细越好吗?

虽然叫“宏块”,但 H.264 允许将其进一步划分为更小的子块(Sub-macroblock)。这种机制叫 可变块尺寸运动补偿(VBSMC) ,能让编码器更灵活地适应局部纹理变化。

块类型 尺寸 适用场景
16×16 大块 平滑背景、静态区域
16×8 / 8×16 中块 边缘过渡、运动方向明确
8×8 小块 支持更精细 MV
8×4 / 4×8 / 4×4 微块 高频细节、快速运动

我们用枚举和结构体来表示这些划分信息:

enum PartitionType {
    PART_16x16,
    PART_16x8,
    PART_8x16,
    PART_8x8,
    PART_8x4,
    PART_4x8,
    PART_4x4
};

struct SubBlockInfo {
    int x, y;                     // 相对于宏块左上角的偏移
    PartitionType part_type;       // 当前子块划分类型
    int mv_x, mv_y;               // 运动矢量(1/4像素精度)
    bool uses_long_term_ref;      // 是否使用长期参考帧
};

实际编码中,划分决策通常采用 四叉树搜索策略 ,自顶向下评估不同划分的率失真代价(RD Cost):

flowchart TD
    A[尝试16x16] --> B{RD Cost低?}
    B -->|是| C[接受16x16]
    B -->|否| D[尝试16x8/8x16]
    D --> E{哪个更好?}
    E --> F[选择较优者]
    F --> G{是否继续细分?}
    G -->|是| H[进入8x8模式]
    H --> I[递归评估8x4/4x8/4x4]
    I --> J[确定最终划分]
    G -->|否| K[停止分割]

核心公式是:
$$
J = D + \lambda \cdot R
$$
其中 $D$ 是失真(如 SAD),$R$ 是比特消耗,$\lambda$ 是拉格朗日乘子,受 QP 控制。最小化 $J$ 的方案即为最优。


宏块的 C++ 建模:不只是封装,更是性能艺术

现在我们有了宏块的概念模型,接下来要用 C++ 把它变成高效的数据结构。记住:在编码器中, 每一个宏块对象都会被成千上万次访问 ,它的设计直接决定了整体性能上限。

面向对象 vs 性能:如何平衡?

我们可以定义一个 Macroblock 类来封装所有相关数据:

class Macroblock {
private:
    static const int BLOCK_16x16 = 256;
    static const int BLOCK_8x8   = 64;
    static const int BLOCK_4x4   = 16;

public:
    alignas(32) int16_t orig_luma[BLOCK_16x16];     // 原始亮度
    alignas(32) int16_t recon_luma[BLOCK_16x16];    // 重建亮度
    alignas(32) int16_t coeff_luma[BLOCK_16x16];    // 变换系数

    IntraPredMode intra_mode;         // 帧内预测模式
    InterPredInfo inter_info;         // 帧间预测信息
    uint8_t qp;                       // 量化参数

    Macroblock() : qp(28), intra_mode(INTRA_DC) {
        std::fill(orig_luma, orig_luma + BLOCK_16x16, 0);
        std::fill(recon_luma, recon_luma + BLOCK_16x16, 0);
        std::fill(coeff_luma, coeff_luma + BLOCK_16x16, 0);
    }

    void set_qp(uint8_t new_qp) { 
        qp = std::clamp(new_qp, 0, 51); 
    }
};

几个关键点:

  • alignas(32) :强制 32 字节对齐,适配 AVX2 指令集,避免未对齐加载导致的性能下降。
  • 使用 int16_t 而不是 uint8_t :中间计算(如残差、DCT 输出)可能溢出,需要更大动态范围。
  • 构造函数初始化并清零缓冲区,防止脏数据干扰。

听起来很完美?等等—— 在多线程环境下,这个类可能会有严重问题

缓存友好设计:告别“伪共享”

现代 CPU 缓存行大小一般是 64 字节。如果两个频繁修改的变量位于同一缓存行,但被不同线程访问,就会引发 伪共享(False Sharing) ,导致缓存一致性协议频繁刷新,性能急剧下降。

来看一个真实案例:假设我们有 12 个宏块分布在两个相邻缓存行上。线程 1 修改第 1 个宏块的运动矢量,线程 2 修改第 2 个宏块的。即使它们互不相干,CPU 也会认为整个缓存行被污染,强制同步。😱

解决方案: 热冷分离(Hot-Cold Splitting)

// 热数据:频繁修改
struct MbMotionData {
    int mv_x[4][4];  // 支持子块 MV
    int mv_y[4][4];
} __attribute__((aligned(64)));

// 冷数据:几乎不变
struct MbStaticInfo {
    uint8_t qp;
    IntraPredMode mode;
} __attribute__((aligned(64)));

这样,不同线程操作各自的 MbMotionData ,不会互相干扰缓存行。

另一个技巧是填充对齐:

struct CACHE_ALIGNED MacroblockAligned {
    int16_t data[256];              // 主数据区 ~512 bytes
    char pad[64 - (sizeof(int16_t)*256) % 64];  // 填充至64字节倍数
} __attribute__((aligned(64)));
结构体 总大小 缓存行占用 伪共享风险
普通宏块 770 bytes ≈12行
对齐填充后 768 bytes 正好12行

多帧缓冲区与宏块索引优化

在 B 帧编码中,我们需要同时访问前向和后向参考帧。这就需要一个 解码图像缓冲区(DPB, Decoded Picture Buffer) 来管理这些帧。

class FrameBufferPool {
private:
    std::vector<YuvFrame*> frames;
    int curr_idx;
public:
    YuvFrame* get_reference(int offset);  // 获取相对当前帧的参考帧
    void insert_decoded(YuvFrame* f);     // 插入新解码帧
};

给定宏块坐标 (mb_x, mb_y) ,如何快速定位参考帧中的对应位置?

inline uint8_t* get_mb_base_addr(const YuvFrame& ref, int mb_x, int mb_y) {
    int pixel_x = mb_x * 16;
    int pixel_y = mb_y * 16;
    return ref.y_data + pixel_y * ref.stride_y + pixel_x;
}

这个函数会被高频调用(尤其是运动估计阶段),必须内联。为了进一步加速,可以预建索引表:

class MbIndexMap {
    std::unique_ptr<int[]> mb_to_frame_offset;
public:
    void build_map(int pic_width_in_mbs, int pic_height_in_mbs, int stride) {
        int total_mbs = pic_width_in_mbs * pic_height_in_mbs;
        mb_to_frame_offset = std::make_unique<int[]>(total_mbs);
        for (int y = 0; y < pic_height_in_mbs; ++y)
            for (int x = 0; x < pic_width_in_mbs; ++x)
                mb_to_frame_offset[y * pic_width_in_mbs + x] = 
                    y * 16 * stride + x * 16;
    }
};

这对于随机访问密集型场景(如全局运动搜索)特别有用。


帧内预测:让平滑区域“无损压缩”

如果说帧间预测是消除时间冗余的利器,那帧内预测就是对付空间冗余的大杀器。尤其是在 I 帧中,它能把一大片天空或墙壁的像素值,压缩成几个模式编号 + 少量残差。

空间冗余的本质:邻域强相关性

自然图像中,相邻像素往往高度相似。H.264 利用这一点,用已编码的左侧和上方像素来预测当前块。

graph TB
    subgraph "Current 4x4 Block"
        B0(( )) --> B1(( ))
        B1 --> B2(( ))
        B2 --> B3(( ))
        B4(( )) --> B5(( ))
        B5 --> B6(( ))
        B6 --> B7(( ))
        B8(( )) --> B9(( ))
        B9 --> B10(( ))
        B10 --> B11(( ))
        B12(( )) --> B13(( ))
        B13 --> B14(( ))
        B14 --> B15(( ))
    end

    TopRow[T-3 T-2 T-1 T0] -->|Above| B0
    LeftCol[L3; L2; L1; L0] -->|Left| B0
    Corner[TL] -->|Top-left| B0

注意:所有参考像素都必须来自 已解码且重建完成的块 ,这样才能保证解码端能复现相同预测。

4×4 与 16×16 预测模式详解

H.264 提供了丰富的预测模式:

4×4 模式(共 9 种)
模式 名称 描述
0 Vertical 上方行复制到各列
1 Horizontal 左侧行复制到各行
2 DC 上+左平均填充全块
3 Diagonal Down-Left 对角线下左外推
4 Diagonal Down-Right 主对角线方向预测
16×16 模式(共 4 种)
模式 名称 特点
0 Vertical 扩展版垂直预测
1 Horizontal 扩展版水平预测
2 DC 同 4×4 DC
3 Plane 双线性平面拟合,适合渐变背景

Plane 模式是唯一真正的二维插值方法,表达能力强,常用于自然场景。

C++ 实现:从提取参考像素到 SIMD 加速

第一步:安全提取参考像素,边界用中性灰(128)填充:

struct IntraReference {
    uint8_t top[32];
    uint8_t left[32];
    uint8_t topleft;
};

void ExtractReferencePixels(const uint8_t* reconBuf, int stride,
                            int x, int y, int blockSize, IntraReference& ref) {
    memset(ref.top, 128, sizeof(ref.top));
    memset(ref.left, 128, sizeof(ref.left));

    bool hasTop = (y > 0);
    bool hasLeft = (x > 0);

    if (hasTop) {
        for (int i = 0; i < blockSize; ++i) {
            ref.top[i] = reconBuf[-stride + i];
        }
    }
    if (hasLeft) {
        for (int i = 0; i < blockSize; ++i) {
            ref.left[i] = reconBuf[i * stride - 1];
        }
    }
    ref.topleft = hasTop && hasLeft ? reconBuf[-stride - 1] : 128;
}

第二步:计算预测误差(SAD/SSD):

int ComputeSAD(const uint8_t* orig, const uint8_t* pred, int blockSize) {
    int sad = 0;
    int size = blockSize * blockSize;
    for (int i = 0; i < size; ++i) {
        sad += abs(orig[i] - pred[i]);
    }
    return sad;
}

但这个循环太慢了!我们可以用 SSE 优化:

#ifdef USE_SSE
#include <emmintrin.h>
int ComputeSAD_SSE(const uint8_t* a, const uint8_t* b, int n) {
    __m128i sum = _mm_setzero_si128();
    for (int i = 0; i < n; i += 16) {
        __m128i va = _mm_loadu_si128((__m128i*)&a[i]);
        __m128i vb = _mm_loadu_si128((__m128i*)&b[i]);
        __m128i vdiff = _mm_sub_epi8(va, vb);
        __m128i vabs = _mm_sign_epi8(vdiff, vdiff);
        sum = _mm_add_epi32(sum, _mm_sad_epu8(vabs, _mm_setzero_si128()));
    }
    return _mm_extract_epi32(sum, 0) + _mm_extract_epi32(sum, 4);
}
#endif

性能提升可达 3~5 倍!

第三步:Vertical 模式的 SIMD 优化实现:

void PredictVertical_4x4_SIMD(uint8_t* dst, int stride, const IntraReference& ref) {
    __m128i row = _mm_cvtsi32_si128(*reinterpret_cast<const int*>(&ref.top[0]));
    row = _mm_unpacklo_epi8(row, row);
    row = _mm_unpacklo_epi16(row, row);

    *(uint32_t*)&dst[0*stride] = _mm_cvtsi128_si32(row);
    *(uint32_t*)&dst[1*stride] = _mm_cvtsi128_si32(row);
    *(uint32_t*)&dst[2*stride] = _mm_cvtsi128_si32(row);
    *(uint32_t*)&dst[3*stride] = _mm_cvtsi128_si32(row);
}

通过广播和一次性写入,极大减少内存访问次数。

自适应预测选择器:引入 RDO 决策

单纯选 SAD 最小的模式还不够。更好的做法是结合比特成本,使用 率失真优化(RDO)

$$
\text{RD-Cost} = SSD + \lambda \cdot R
$$

其中 $R$ 是编码该模式所需的比特数,$\lambda$ 与 QP 相关。这样可以在保真度和码率之间取得最佳平衡。


帧间预测:时间维度的魔法

如果说帧内预测是“左右逢源”,那帧间预测就是“穿越时空”。通过在参考帧中寻找最匹配的区域,生成预测块,从而大幅减少残差能量。

P 帧 vs B 帧:单向与双向的权衡

特性 P 帧 B 帧
预测方向 单向(List0) 双向(List0 + List1)
压缩效率 中等
编码延迟
是否参与后续预测 否(通常)

B 帧虽然压缩率高,但由于依赖未来帧,不适合实时通信。

graph TD
    A[当前编码帧] --> B{是否为I帧?}
    B -- 是 --> C[不使用帧间预测]
    B -- 否 --> D{是否为P帧?}
    D -- 是 --> E[从List0中选取参考帧]
    D -- 否 --> F[从List0和List1中联合预测]
    E --> G[整数/亚像素匹配 + 运动补偿]
    F --> G
    G --> H[生成预测块]

1/4 像素精度与 6 抽头滤波器

H.264 支持高达 1/4 像素精度的运动矢量。但真实图像只有整数像素,怎么办?插值!

H.264 定义了一套 6 抽头 Wiener 滤波器

q = (a · [-1, 5, 20, 20, 5, -1]) / 32

实现时用整数运算避免浮点开销:

void interpolate_horiz_6tap(const uint8_t* src, int stride, uint8_t* dst, int width) {
    static const int coeff[6] = {-1, 5, 20, 20, 5, -1};
    for (int x = 0; x < width; ++x) {
        for (int y = 0; y < 4; ++y) {
            int val = 0;
            for (int i = 0; i < 6; ++i) {
                val += src[y * stride + x + i - 1] * coeff[i];
            }
            dst[y * width + x] = clip_uint8((val + 16) >> 5);
        }
    }
}

(val + 16) >> 5 实现 /32 并四舍五入。

运动矢量预测(MVP):差值编码的秘密

MV 本身也有冗余!H.264 用 中值预测 生成候选 MV:

MotionVector median_predict(const MotionVector* left, 
                           const MotionVector* top, 
                           const MotionVector* tr) {
    int cand_x[3] = {left->mv_x, top->mv_x, tr->mv_x};
    int cand_y[3] = {left->mv_y, top->mv_y, tr->mv_y};
    std::sort(cand_x, cand_x + 3);
    std::sort(cand_y, cand_y + 3);
    return {static_cast<int16_t>(cand_x[1]), 
            static_cast<int16_t>(cand_y[1]), 0, 0};
}

最终传输的是 ΔMV,数值小,利于变长编码。


DCT 与量化:从像素到频率的跃迁

整数 DCT:为何不用浮点?

传统 DCT 涉及三角函数,计算慢且不可逆。H.264 改用 整数 DCT ,核心矩阵如下:

$$
T =
\begin{bmatrix}
1 & 1 & 1 & 1 \
2 & 1 & -1 & -2 \
1 & -1 & -2 & 1 \
1 & -2 & 1 & -1 \
\end{bmatrix}
$$

变换过程全为整数加减移位,速度快且严格可逆。

void forward_dct_4x4(const int16_t input[16], int32_t output[16]) {
    int temp[16];
    // 行变换
    for (int i = 0; i < 4; ++i) {
        int a0 = input[i*4+0] + input[i*4+3];
        int a1 = input[i*4+1] + input[i*4+2];
        int a2 = input[i*4+1] - input[i*4+2];
        int a3 = input[i*4+0] - input[i*4+3];

        temp[i*4+0] = a0 + a1;
        temp[i*4+1] = (a3 << 1) + a2;
        temp[i*4+2] = a0 - a1;
        temp[i*4+3] = a3 - (a2 << 1);
    }
    // 列变换(略)
}

量化:控制质量的阀门

量化公式:

$$
QLevel = \text{sign}(Level) \cdot \left\lfloor \frac{|Level| \cdot Scale}{2^{QP/6}} + 0.5 \right\rfloor
$$

QP 越大,压缩越强,失真越明显。量化矩阵还可针对不同频率位置做感知加权。


熵编码:最后一道压缩关卡

H.264 支持两种熵编码:

  • CAVLC :简单高效,适合移动端
  • CABAC :压缩率高 10%-20%,但复杂度高

CABAC 使用上下文自适应算术编码,通过概率建模实现极致压缩。

最终,所有语法元素被打包进 NAL 单元,形成标准比特流:

graph TD
    A[原始宏块] --> B{帧内/帧间预测}
    B --> C[生成残差]
    C --> D[DCT变换]
    D --> E[量化]
    E --> F{选择熵编码器}
    F -->|CABAC| G[CABAC编码]
    F -->|CAVLC| H[CAVLC编码]
    G --> I[NAL封装]
    H --> I
    I --> J[添加CRC]
    J --> K[写入环形缓冲区]
    K --> L[输出至网络/文件]

整个编码链环环相扣,任何一个环节的优化都能带来全局收益。而这一切的起点,正是那个看似简单的 16×16 宏块。💡

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:H264(AVC)是IT领域中广泛使用的高效视频压缩标准,广泛应用于多媒体处理和视频流传输。本文深入解析基于C++的H264编解码源代码,涵盖熵编码、变换编码、宏块处理、帧内/帧间预测、运动估计与补偿、CABAC编码及解码流程等关键技术。通过分析“H264编码”与“H264解码”文件夹中的完整源码,开发者可掌握视频编解码核心算法的C++实现方法,提升在高性能计算、流媒体服务和嵌入式系统中的实战能力。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐