FFmpeg 汇编语言第一课
简介
欢迎来到 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)标记为 r0
、r1
、r2
等,让开发者无需记住寄存器的名称。正如之前提到的,通用寄存器通常只是“支架”,所以这样的抽象让开发更加轻松。
一个简单的标量汇编代码片段
让我们看一个简单(且人为构造的)标量汇编代码片段来理解其机制:
mov r0q, 3
inc r0q
dec r0q
imul r0q, 5
逐行解释:
- 第一行将立即数
3
(存储在汇编代码中的一个直接值,而不是从内存中提取的数据)存储到寄存器r0
中,作为一个 quadword(64位) 数据。
注意,在 Intel 汇编语法中,源操作数(提供数据的值或位置,位于右侧)被传输到目标操作数(接收数据的位置,位于左侧),行为类似于memcpy
。你可以将它理解为r0q = 3
,因为顺序一致。r0
后的q
后缀表示寄存器用作一个 quadword。 - 第二行
inc
将值递增 1,因此r0q
变为4
。 - 第三行
dec
将值递减 1,回到3
。 - 最后一行
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
逐行解析:
-
%include "x86inc.asm"
这是一个由 x264、FFmpeg 和 dav1d 社区开发的“头文件”,提供了许多辅助工具、预定义名称和宏(如下面的cglobal
),以简化汇编编写。 -
SECTION .text
表示你想执行的代码部分,区别于.data
部分(后者用于放置常量数据)。 -
;static void add_values(const uint8_t *src, const uint8_t *src2); INIT_XMM sse2
第一行是注释(汇编中的;
类似于 C 语言的//
),显示了这个函数在 C 语言中的参数形式。
第二行表示初始化函数以使用 XMM 寄存器,并指定使用 SSE2 指令集,因为后面的paddb
是 SSE2 指令(稍后会详细介绍 SSE2)。 -
cglobal add_values, 2, 2, 2, src, src2
定义了一个名为add_values
的 C 函数。
参数解析:- 第一个参数表明这个函数有 两个参数。
- 第二个参数表示我们需要 两个通用寄存器(GPRs) 来处理参数。如果需要更多寄存器,我们可以通过调整参数告诉
x86util
。 - 第三个参数表示我们会使用 两个 XMM 寄存器。
- 最后两个参数是函数参数的标签。
注意:早期的代码可能没有参数标签,而是直接用
r0
、r1
等访问 GPR。 -
movu m0, [srcq]
和movu m1, [src2q]
movu
是movdqu
(移动未对齐的双倍四字数据)的简写。关于对齐问题将在后续课程中介绍。此处可以将movu
理解为从[srcq]
读取 128 位数据。- 汇编中的
[]
表示对地址的解引用,相当于 C 语言中的*src
。这被称为“加载”。 - 注意
q
后缀表示指针的大小(例如在 C 中sizeof(src) == 8
,在 64 位系统中为 8 字节)。但底层加载操作是 128 位。
此外,矢量寄存器并不直接以其完整名称表示(如
xmm0
),而是用抽象形式m0
。 - 汇编中的
-
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
- 前缀
-
movu [srcq], m0
这是一个“存储”操作,将数据写回srcq
指向的地址。 -
RET
一个宏,表示函数返回。几乎所有的 FFmpeg 汇编函数都是通过修改参数数据而不是返回值来完成工作的。