[译文]FFmpeg 汇编语言第一课

FFmpeg 汇编语言第一课

file

简介

欢迎来到 FFmpeg 汇编语言学校!您已踏上编程中最有趣、最具挑战性且最有回报的旅程。这些课程将为您奠定在 FFmpeg 中编写汇编语言的基础,并让您深入了解计算机实际执行的工作。

必备知识

  • C 语言知识,特别是指针。如果您不了解 C 语言,建议通读《The C Programming Language》一书。
  • 高中数学知识(标量与向量,加法,乘法等)。

什么是汇编语言?

汇编语言是一种与 CPU 指令直接对应的编程语言。人类可读的汇编代码会被“汇编”为 CPU 能理解的二进制数据(即机器码)。汇编语言通常简称为“assembly”或“asm”。

在 FFmpeg 中,绝大部分汇编代码属于 SIMD(单指令多数据)范畴。有时,SIMD 被称为向量编程。这意味着特定指令可同时操作多个数据元素。而大多数编程语言每次只能操作一个数据元素,这被称为标量编程。

如您所料,SIMD 非常适合处理图像、视频和音频这类内存中顺序存储的大量数据。现代 CPU 提供了专门的指令,帮助我们高效处理这些顺序数据。

在 FFmpeg 中,您会见到以下术语互换使用:“汇编函数”、“SIMD”和“向量(化)”。它们均指通过手写汇编语言编写的函数,一次处理多个数据元素。在一些项目中,这些函数也被称为“汇编内核(assembly kernels)”。

尽管上述概念听起来复杂,但需要记住,在 FFmpeg 中,甚至有高中生编写过汇编代码。学习过程无非是“50% 掌握术语 + 50% 实际学习”。

为什么要使用汇编语言?

为了加速多媒体处理。通过编写汇编代码,性能通常可提升 10 倍或更多,这在需要无卡顿实时播放视频时尤为重要。同时,它还能节省能耗并延长电池寿命。此外,视频编码和解码函数是全球使用最频繁的功能,无论是终端用户还是大型数据中心。因此,哪怕是小幅优化,都会带来巨大的累计效益。

在互联网上,您可能见过有人使用“内嵌指令(intrinsics)”。内嵌指令是类似 C 的函数,可映射到汇编指令,以加速开发过程。但在 FFmpeg 中,我们不使用内嵌指令,而是直接手写汇编代码。虽然有争议,但内嵌指令通常比手写汇编代码慢 10-15%(支持者可能不同意),具体取决于编译器。而在 FFmpeg 中,任何性能提升都很重要,这就是为何选择直接编写汇编代码的原因。

您可能还会发现,FFmpeg 的部分历史代码或类似 Linux 内核的项目中,存在“内联汇编”。这是一种将汇编代码直接嵌入 C 代码的方式。然而,在 FFmpeg 中,这种代码被认为难以阅读,不被编译器广泛支持且难以维护。

此外,有所谓的“专家”认为,现代编译器已经能自动完成这些“向量化”操作。至少在学习阶段,请忽略这些说法。例如,dav1d 项目最近的测试显示,自动向量化的代码性能提升约为 2 倍,而手写汇编代码则可达到 8 倍提升。

汇编语言的类型

本课程聚焦 x86 64 位汇编语言,也称为 amd64,它同样适用于 Intel CPU。其他 CPU(如 ARM 和 RISC-V)也有各自的汇编语言,未来可能会扩展到这些内容。

x86 汇编语法有两种:AT&T 和 Intel。AT&T 语法较旧且难以阅读,而 Intel 语法更清晰易懂,因此我们采用 Intel 语法。

支持材料

您可能会感到惊讶:市面上的书籍或 StackOverflow 等资源并不适合作为参考。这部分是因为我们选择手写 Intel 语法的汇编代码,同时也因为许多在线资源专注于操作系统或硬件编程,且多为非 SIMD 代码。而 FFmpeg 汇编代码主要关注高性能图像处理,采用了特别独特的编程方法。不过,一旦完成这些课程,其它汇编用例也会变得易于理解。

许多书籍会在教授汇编前详细讲解计算机架构,这很好,但从我们的角度来看,这就像在学开车之前先研究发动机。

尽管如此,《The Art of 64-bit Assembly》的部分图表(特别是关于 SIMD 指令和行为的可视化)对理解 SIMD 非常有帮助:https://artofasm.randallhyde.com/

还有一个 Discord 服务器可供解答问题:
https://discord.gg/fYzkxPNJ

寄存器

寄存器是 CPU 中的数据处理区域。CPU 不会直接对内存操作,而是将数据加载到寄存器中处理,然后再写回内存。在汇编语言中,无法直接从一个内存位置将数据复制到另一个内存位置,必须先经过寄存器。

通用寄存器

通用寄存器(GPR)可以存储数据(最多 64 位值)或内存地址(指针)。它可以通过加法、乘法、移位等操作处理值。

在大多数汇编教材中,通用寄存器有整章描述其微妙之处和历史背景。然而,在 FFmpeg 的汇编代码中,通用寄存器更多充当“脚手架”,它们的复杂性通常被抽象掉。

向量寄存器

向量(SIMD)寄存器顾名思义包含多个数据元素,主要包括以下几种:

  • mm 寄存器:MMX 寄存器,64 位大小,历史用途较多,现在很少使用。
  • xmm 寄存器:128 位大小,广泛可用。
  • ymm 寄存器:256 位大小,使用时略有复杂性。
  • zmm 寄存器:512 位大小,使用受限。

多数视频压缩和解压运算基于整数。例如,一个 xmm 寄存器中可以包含:

  • 16 个字节(8 位整数):a, b, c ...
  • 8 个字(16 位整数):a, b, c, d ...
  • 4 个双字(32 位整数):a, b, c, d
  • 2 个四字(64 位整数):a, b

数据单位:

  • bytes - 8位数据
  • words - 16位数据
  • doublewords - 32位数据
  • quadwords - 64位数据
  • double quadwords - 128位数据

加粗的术语将在后面介绍中非常重要。

x86inc.asm 包含文件

在很多示例中,我们会包含文件 x86inc.asm
x86inc.asm 是一个轻量级抽象层,被广泛用于 FFmpeg、x264 和 dav1d 中,旨在简化汇编程序员的工作。它的功能多样,但首先,它通过将通用寄存器(GPRs)标记为 r0r1r2 等,让开发者无需记住寄存器的名称。正如之前提到的,通用寄存器通常只是“支架”,所以这样的抽象让开发更加轻松。

一个简单的标量汇编代码片段

让我们看一个简单(且人为构造的)标量汇编代码片段来理解其机制:

mov  r0q, 3
inc  r0q
dec  r0q
imul r0q, 5

逐行解释:

  1. 第一行将立即数 3(存储在汇编代码中的一个直接值,而不是从内存中提取的数据)存储到寄存器 r0 中,作为一个 quadword(64位) 数据。
    注意,在 Intel 汇编语法中,源操作数(提供数据的值或位置,位于右侧)被传输到目标操作数(接收数据的位置,位于左侧),行为类似于 memcpy。你可以将它理解为 r0q = 3,因为顺序一致。r0 后的 q 后缀表示寄存器用作一个 quadword
  2. 第二行 inc 将值递增 1,因此 r0q 变为 4
  3. 第三行 dec 将值递减 1,回到 3
  4. 最后一行 imul 将值乘以 5,最终 r0q 的值为 15
理解一个基本的矢量函数

以下是第一个 SIMD 函数:

%include "x86inc.asm"

SECTION .text

;static void add_values(const uint8_t *src, const uint8_t *src2)
INIT_XMM sse2
cglobal add_values, 2, 2, 2, src, src2 
    movu  m0, [srcq]
    movu  m1, [src2q]

    paddb m0, m1

    movu  [srcq], m0

    RET

逐行解析:

  1. %include "x86inc.asm"
    这是一个由 x264、FFmpeg 和 dav1d 社区开发的“头文件”,提供了许多辅助工具、预定义名称和宏(如下面的 cglobal),以简化汇编编写。

  2. SECTION .text
    表示你想执行的代码部分,区别于 .data 部分(后者用于放置常量数据)。

  3. ;static void add_values(const uint8_t *src, const uint8_t *src2); INIT_XMM sse2
    第一行是注释(汇编中的 ; 类似于 C 语言的 //),显示了这个函数在 C 语言中的参数形式。
    第二行表示初始化函数以使用 XMM 寄存器,并指定使用 SSE2 指令集,因为后面的 paddb 是 SSE2 指令(稍后会详细介绍 SSE2)。

  4. cglobal add_values, 2, 2, 2, src, src2
    定义了一个名为 add_values 的 C 函数。
    参数解析:

    • 第一个参数表明这个函数有 两个参数
    • 第二个参数表示我们需要 两个通用寄存器(GPRs) 来处理参数。如果需要更多寄存器,我们可以通过调整参数告诉 x86util
    • 第三个参数表示我们会使用 两个 XMM 寄存器
    • 最后两个参数是函数参数的标签。

    注意:早期的代码可能没有参数标签,而是直接用 r0r1 等访问 GPR。

  5. movu m0, [srcq]movu m1, [src2q]
    movumovdqu(移动未对齐的双倍四字数据)的简写。关于对齐问题将在后续课程中介绍。此处可以将 movu 理解为从 [srcq] 读取 128 位数据。

    • 汇编中的 [] 表示对地址的解引用,相当于 C 语言中的 *src。这被称为“加载”。
    • 注意 q 后缀表示指针的大小(例如在 C 中 sizeof(src) == 8,在 64 位系统中为 8 字节)。但底层加载操作是 128 位

    此外,矢量寄存器并不直接以其完整名称表示(如 xmm0),而是用抽象形式 m0

  6. paddb m0, m1
    paddb(读作“p-add-b”)执行逐字节的矢量加法:

    • 前缀 p 表示“packed”(打包数据),用于区分矢量指令和标量指令。
    • 后缀 b 表示这是逐字节操作。

    加法的过程如下:

    a  b  c  d  e  f  g  h  i  j  k  l  m  n  o  p
    +
    q  r  s  t  u  v  w  x  y  z  aa ab ac ad ae af
    =
    a+q b+r c+s d+t e+u f+v g+w h+x i+y j+z k+aa l+ab m+ac n+ad o+ae p+af
  7. movu [srcq], m0
    这是一个“存储”操作,将数据写回 srcq 指向的地址。

  8. RET
    一个宏,表示函数返回。几乎所有的 FFmpeg 汇编函数都是通过修改参数数据而不是返回值来完成工作的。

参考链接

Comments

No comments yet. Why don’t you start the discussion?

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注