- 教程
- 文档
【免费下载链接】asm-lessons
FFmpeg Assembly Language Lessons
本文基于 FFmpeg 汇编语言系列课程第二课(lesson_02/index.md,土耳其语版本见 lesson_02/index.tr.md)整理编写。课程假定读者已完成第一课,掌握
mov、inc、dec、imul等基础指令,并熟悉x86inc.asm抽象层与cglobal函数声明。读完本文,你将掌握如何在 FFmpeg 手写汇编中利用标签(label)与跳转构造循环、通过 FLAGS 寄存器驱动条件分支、声明只读常量表、手工计算内存偏移量,并用lea以单条指令完成乘加运算,为后续编写真正的 SIMD 处理函数打下基础。
一、从第一个函数到控制流:标签与跳转
第一课中你已写出了第一个汇编函数(见 lesson_01/index.md 中的add_values示例)。第二课的核心目标是引入分支(branch)与循环(loop)——它们是所有非平凡函数(例如逐像素处理视频帧的循环)的骨架。
汇编没有 C 那样的if/for/while语法,控制流完全靠**标签(label)与跳转指令(jump)**实现。看下面这个刻意简化的人工示例:
mov r0q, 3 .loop: dec r0q jmp .loop逐行解读:
mov r0q, 3:把立即数 3 装入r0寄存器(q后缀表示以 quadword,即 64 位宽度使用该寄存器,这与第一课的约定一致)。.loop::这是一个标签。标签本质是汇编器记录的一个位置标记,jmp .loop会把指令流"搬回"标签处执行。dec r0q:将r0q减 1。jmp .loop:无条件跳回标签,因此这段代码是一个无限循环。
注意标签名前的小数点.loop::它表示这是一个局部标签(local label)。局部标签允许你在同一个文件的不同函数中重复使用相同的名字(例如每个函数都可以有自己的.loop、.ret),而不会与全局符号冲突。FFmpeg 的手写汇编中几乎全部使用这种局部标签风格,你会在 lesson_03/index.md 的add_values实例中再次看到.loop。
二、FLAGS 寄存器:跳转的决策依据
要让循环"在合适的时机退出",jmp的无条件跳转显然不够。汇编器为此提供条件跳转指令,而它们读取的正是 CPU 内部的FLAGS 寄存器。
第二课不会深入 FLAGS 的全部细节(在 FFmpeg 汇编中,GPR 操作很大程度上只是"脚手架",这一点第一课已说明),但需要认识几个关键标志位:
- 零标志(Zero-Flag,ZF):运算结果为 0 时置 1,否则为 0。
- 符号标志(Sign-Flag,SF):运算结果的最高有效位(符号位)为 1 时置 1,即结果视为有符号数为负。
- 溢出标志(Overflow-Flag,OF):有符号运算发生溢出时置 1。
这些标志由大多数非mov指令(对标量数据的算术运算、移位等)根据运算输出来设置。也就是说,dec、inc、add、cmp、sub、imul等指令都会"顺带"更新 FLAGS,随后紧跟的条件跳转指令就依据这些标志决定是否跳转。
2.1 倒计数循环:dec + jg
下面是一个"真实"的循环:循环计数器递减到零为止,jg(jump if greater,大于零则跳转)作为循环条件:
mov r0q, 3 .loop: ; 做一些事情 dec r0q jg .loop ; 如大于零则跳转dec r0q执行后,CPU 依据r0q的新值更新 FLAGS(是否为零、符号位等),jg .loop再依据这些标志判断:只要r0q仍大于 0 就跳回循环体。它等价于下面的 C 代码:
int i = 3; do { // 做一些事情 i--; } while(i > 0)即 C 中的do-while结构——先执行循环体,再判断条件。这正是汇编循环与 C 循环之间的典型对应关系。
三、模拟 for 循环:xor 清零、inc、cmp、jl
不过上面的 C 写法并不自然,C 里更常见的循环是for:
int i; for(i = 0; i < 3; i++) { // 做一些事情 }这段 C 代码在汇编中大致等价于(原文特别强调:没有简单方式能"精确匹配"这个for循环):
xor r0q, r0q .loop: ; 做一些事情 inc r0q cmp r0q, 3 jl .loop ; 如果 (r0q - 3) < 0 跳转, 即 (r0q < 3)这个片段有两个值得深挖的技术点:
1.xor r0q, r0q:一条指令完成清零。用寄存器对自身异或,结果是全 0。这是汇编中把寄存器清零的惯用写法,在某些系统上比mov r0q, 0更快——因为根本没有发生真正的"加载/写入立即数",CPU 可以直接用异或逻辑电路得到零值。同样的技巧也适用于 SIMD 寄存器:pxor m0, m0可以一次性清空整个向量寄存器(m0 即 xmm0 在x86inc.asm抽象层中的名字)。你会反复在 FFmpeg 汇编中看到这种写法。
2.cmp指令:隐式减法。cmp r0q, 3在效果上等价于"用r0q减去 3",但结果不写入任何寄存器,只更新 FLAGS。随后jl .loop(jump if less,小于则跳转)读取这些标志:若(r0q - 3) < 0,即r0q < 3,则跳回循环体继续执行。
3.1 为什么前一个循环更优:指令越少越快
注意对比:模拟for的片段比倒计数片段多了一条cmp指令。一般而言,指令越少,代码越快(更少的分派、更少的执行周期、更小的指令缓存占用)。因此前面dec + jg的写法更受青睐——它让算术指令dec本身去设置 FLAGS,从而省掉了独立的cmp。
这一点贯穿整个 FFmpeg 汇编风格:正如第二课原文所强调的,我们不是写汇编去"精确匹配" C 循环,而是把循环写成汇编中尽可能快的形式。后续课程中还会看到更多类似的技巧——例如 lesson_03/index.md 中的"指针偏移技巧":把负的宽度同时当作指针偏移量与循环计数器,用add widthq, mmsize+jl省掉一条cmp。这正是本课末尾预告的"用单个add替代循环里的add和dec"的进阶玩法。
四、常用条件跳转助记符速查表
实际编写循环时你会用到下面这些常见的跳转助记符(FLAGS 条件列出来是为了完整性,写循环时你并不需要记住每个标志的细节——查表即可):
| 助记符 | 描述 | FLAGS 条件 |
|---|---|---|
| JE/JZ | 相等/为零时跳转(Jump if Equal/Zero) | ZF = 1 |
| JNE/JNZ | 不相等/不为零时跳转(Jump if Not Equal/Not Zero) | ZF = 0 |
| JG/JNLE | 大于/不小于等于时跳转(有符号)(Jump if Greater/Not Less or Equal) | ZF = 0 且 SF = OF |
| JGE/JNL | 大于等于/不小于时跳转(有符号)(Jump if Greater or Equal/Not Less) | SF = OF |
| JL/JNGE | 小于/不大于等于时跳转(有符号)(Jump if Less/Not Greater or Equal) | SF ≠ OF |
| JLE/JNG | 小于等于/不大于时跳转(有符号)(Jump if Less or Equal/Not Greater) | ZF = 1 或 SF ≠ OF |
值得留意几点:
- 每组助记符成对出现(如
JE/JZ),同一条件的两种写法语义完全相同,选哪种纯属个人风格。 JG/JGE/JL/JLE系列面向有符号数比较(依赖 SF 与 OF 的组合);若要比较无符号数,则需要另一组助记符(如JA/JB),本课未展开。- 跳转条件基于
cmp或算术指令(dec、inc、add、sub等)设置的 FLAGS,因此跳转指令必须紧跟设置 FLAGS 的指令,中间不能插入会改变 FLAGS 的操作。
五、常量:SECTION_RODATA、db/dw 与 times 宏
循环写好后,自然需要数据。FFmpeg 汇编中常量数据放在只读数据段,典型写法如下:
SECTION_RODATA constants_1: db 1,2,3,4 constants_2: times 2 dw 4,3,2,1逐行解释:
SECTION_RODATA:声明这是一个只读数据段(read-only data section)。注意这是一个宏而非裸的 section 指令——因为不同操作系统使用的目标文件格式(ELF、Mach-O、COFF 等)声明只读段的方式各不相同,x86inc.asm用宏把它们统一起来。constants_1: db 1,2,3,4:db是 "declare byte"(声明字节)的缩写。标签constants_1指向这段数据的起始地址,等价于 C 的uint8_t constants_1[4] = {1, 2, 3, 4};。constants_2: times 2 dw 4,3,2,1:dw声明 word(16 位数据),times 2是汇编器宏,表示"把后面的内容重复两次"。因此它等价于uint16_t constants_2[8] = {4, 3, 2, 1, 4, 3, 2, 1};(4、3、2、1 共 4 个 word,重复 2 遍共 8 个 word)。
汇编器会把标签转换为内存地址。由于这段数据是只读的,这些地址只能用于加载(load),不能用于存储(store)。另外,有些指令(如 SIMD 加载指令)可以直接把内存地址作为操作数,无需先显式加载到寄存器——这有优点(省一条加载指令)也有缺点(内存操作数不如寄存器操作数灵活),后续课程会结合具体场景讨论。
六、偏移量(Offsets):手写汇编必须自己算
偏移量(offset)是内存中连续元素之间的距离(以字节为单位),由数据结构中每个元素的大小决定。
看这个 C 循环:
uint32_t data[3]; int i; for(i = 0; i < 3; i++) { data[i]; }data是uint32_t数组,元素间相距 4 字节。这个4 字节偏移量由 C 编译器自动预先计算——data[i]会被编译成"基地址 + i * 4"的寻址。但当手写汇编时,这些偏移量必须你自己算。这是手写汇编与 C 之间最直接的差异之一。
6.1 内存地址计算的通用语法
x86 的内存寻址有一个统一的语法形式,适用于所有类型的内存地址:
[base + scale*index + disp]四个组成部分:
- base(基址):一个 GPR,通常是来自 C 函数参数的指针。
- scale(比例因子):可以是 1、2、4、8,默认值为 1。
- index(索引):一个 GPR,通常用作循环计数器。
- disp(位移量):一个整数(最大 32 位),是对数据内部的一个偏移量。
这套[base + scale*index + disp]语法是 x86 寻址的核心,也是后面lea指令的基础。
6.2 mmsize 常量
x86inc.asm提供了常量mmsize,它表示当前正在使用的 SIMD 寄存器的大小(字节数)。例如用INIT_XMM sse2初始化时,mmsize为 16(xmm 是 128 位 = 16 字节)。它的价值在于:你可以在循环里用add srcq, mmsize让指针按寄存器宽度推进,而无需硬编码 16、32 或 64——代码将来切换到 ymm/zmm 时依然正确。
6.3 实战示例:从自定义偏移量加载
下面是一个刻意简化(甚至可以说"无实际意义")的示例,用于演示如何从自定义偏移量加载数据:
;static void simple_loop(const uint8_t *src) INIT_XMM sse2 cglobal simple_loop, 1, 2, 2, src movq r1q, 3 .loop: movu m0, [srcq] movu m1, [srcq+2*r1q+3+mmsize] ; 做一些事情 add srcq, mmsize dec r1q jg .loop RET对照第一课已经讲过的cglobal约定解读(详见 lesson_01/index.md):
cglobal simple_loop, 1, 2, 2, src:声明一个 C 函数simple_loop,1 个参数(src),使用 2 个 GPR、2 个 XMM 寄存器。movq r1q, 3:把循环计数初值 3 装入r1q(注意这里是movq,因为要移动一个 quadword 值到 GPR)。movu m0, [srcq]:movu是movdqu(move double quad unaligned,非对齐的 128 位移动)的缩写,从srcq指向的地址加载 128 位数据到m0。方括号表示解引用,等价于 C 的*src。movu m1, [srcq+2*r1q+3+mmsize]:核心的一行。这里使用了完整的寻址语法base + scale*index + disp:基址srcq,比例因子 2 乘以索引r1q,再加位移量3+mmsize。注意,汇编器会自动把2*r1q+3+mmsize预先折算成正确的位移量常量,无需你在源代码里手工展开。add srcq, mmsize:每轮循环让指针前进一个 SIMD 寄存器宽度(16 字节)。dec r1q/jg .loop:递减计数器,大于 0 则继续循环。RET:宏,表示函数返回(FFmpeg 汇编函数几乎总是通过修改参数指向的内存来"返回"结果,而不是返回值)。
第二课原文在这里预告了一个后续技巧:下一课将展示如何避免在循环里同时做add和dec,用单个add替换它们——这个技巧正是 lesson_03/index.md 中的"指针偏移技巧"(负宽度既当指针偏移又当循环计数器)。
七、LEA:一条指令完成乘法与加法
理解了偏移量之后,就能使用lea(Load Effective Address,加载有效地址)了。它让你用一条指令完成乘法与加法,通常比用多条指令更快:
lea r0q, [base + scale*index + disp]尽管名字叫"加载有效地址",LEA 的实际用途远超地址计算——它完全可以当作普通算术指令使用。例如,你可以做这样"复杂"的计算:
lea r0q, [r1q + 8*r2q + 5]即r0q = r1q + r2q*8 + 5。关键特性有三点:
- 不改变源操作数:
r1q、r2q的内容不受影响。 - 不设置 FLAGS:因此你不能根据 LEA 的输出做条件跳转(这一点与
add不同)。 - 避免临时寄存器:下面的代码需要 3 条指令外加 1 个临时寄存器才能完成同样的计算(注意这段代码并不与 LEA 完全等价,因为
add会改变 FLAGS):
movq r0q, r1q movq r3q, r2q sal r3q, 3 ; 算术左移 3 = * 8 add r3q, 5 add r0q, r3q(这里sal r3q, 3是算术左移 3 位,等价于乘以 8。)
LEA 在 FFmpeg 汇编中有两大典型用途:
- 在循环开始前设置地址:预先计算好基地址,让循环体里的加载/存储指令直接引用。
- 执行类似上面的算术计算:把"乘以 1、2、4、8 + 加上固定偏移量"这类常见组合压进一条指令。
当然,LEA 并非万能:你能乘的系数限于 1、2、4、8(对应 0/1/2/3 位移位),加的数必须是一个固定立即数偏移。但"乘以 2 的幂 + 加固定偏移"恰恰是数组/缓冲区寻址中出现频率最高的模式,所以 LEA 是这条课程线中最实用的指令之一。
八、小结与本课作业
本课内容可概括为一条从"控制流"到"数据访问"的学习路径:
- 标签与跳转:
.loop:局部标签 +jmp/条件跳转构成循环; - FLAGS 寄存器:算术指令(
dec、inc、cmp等)设置 ZF/SF/OF,条件跳转据此决策; - 循环写法对比:
dec + jg比inc + cmp + jl少一条指令,更快——牢记"指令越少越快"; - 只读常量:
SECTION_RODATA+db/dw/times声明常量表,标签即地址,只可加载不可存储; - 偏移量:C 编译器自动算,手写汇编必须自己算,使用
[base + scale*index + disp]语法; - LEA:一条指令完成
乘 1/2/4/8 + 加固定偏移,不碰源操作数、不碰 FLAGS。
作业预告:在对应课时的作业中,你将需要加载一个常量,并在循环中把常量的值累加到一个 SIMD 向量上——这是对本课"常量加载 + 循环 + SIMD 寄存器"三项能力的综合检验。建议动手前先回看 lesson_01/index.md 中paddb(packed add bytes,逐字节相加)的用法,因为向量加法正是作业的核心操作。
下一课(lesson_03/index.md)将讲解指令集历史(SSE2/AVX 等)、用单个add替代循环内add+dec的指针偏移技巧、内存对齐(movavsmovu)、以及punpcklbw等数据展开/打包与 shuffle 技术。课程总览与学习前提可参见 README.md。
- 教程
- 文档
【免费下载链接】asm-lessons
FFmpeg Assembly Language Lessons
相关推荐
FFmpeg 汇编语言第二课:分支、循环、常量、偏移量与 LEA 的实战指南
FFmpeg 汇编语言第二课:分支、循环、常量、偏移量与 LEA 的实战指南 导读 本指南对应 lesson_02/index.md https://link.
教程文档使用 Bun.serve() 构建零配置 HTTP 服务并部署到 Vercel:framework-boilerplates/bun 模板深度解析
使用 Bun.serve 构建零配置 HTTP 服务并部署到 Vercel:framework boilerplates/bun 模板深度解析 本文以仓库 fr
教程文档在 learn-go-with-tests 中学习整数运算:用 TDD 编写 Add 函数与可测试的 Go 文档示例
在 learn go with tests 中学习整数运算:用 TDD 编写 Add 函数与可测试的 Go 文档示例 导读 本文基于 learn go with
教程文档
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考