一、GCC 汇编语法
1. 源操作数和目的操作数顺序
AT&T 语法的操作数方向和 Intel 语法的刚好相反。在Intel 语法中,第一操作数为目的操作数,第二操作数为源操作数,然而在 AT&T 语法中,第一操作数为源操作数,第二操作数为目的操作数。
也就是说,Intel 语法中的 “Op-code dst src” 变为 AT&T 语法中的 “Op-code src dst”。
2. 寄存器命名
寄存器名称有 “%” 前缀,即如果必须使用 “eax”,它应该用作 “%eax”。
3. 立即数
AT&T 立即数以 “$” 为前缀。静态 “C” 变量也使用 “$” 前缀。在 Intel 语法中,十六进制常量以 “h” 为后缀,然而 AT&T 不使用这种语法,这里我们给常量添加前缀 “0x”。所以,对于十六进制,我们首先看到一个 “$”,然后是 “0x”,最后才是常量。
4. 操作数大小
在 AT&T 语法中,存储器操作数的大小取决于操作码名字的最后一个字符。操作码后缀 ’b’ 、’w’、’l’ 分别指明了字节(8位)、字(16位)、长型(32位)存储器引用。Intel 语法通过给存储器操作数添加 “byte ptr”、 “word ptr” 和 “dword ptr” 前缀来实现这一功能。
因此,Intel的 “mov al, byte ptr foo” 在 AT&T 语法中为 “movb foo, %al”。
5. 存储器操作数
在 Intel 语法中,基址寄存器包含在 “[“ 和 “]” 中,然而在 AT&T 中,它们变为 “(“ 和 “)”。另外,在 Intel 语法中, 间接内存引用为
“section:[base + index*scale + disp]”,在 AT&T中变为 “section:disp(base, index, scale)”。
需要牢记的一点是,当一个常量用于 disp 或 scale,不能添加 “$” 前缀。
6. Intel 语法和 AT&T 语法区别
1 | +------------------------------+------------------------------------+ |
二、基本内联
1 | asm("movl %ecx %eax"); // 将 ecx 寄存器的内容移至 eax |
asm 和 __asm__
两者都是有效的。
如果我们的指令多于一条,我们可以每个一行,并用双引号圈起,同时为每条指令添加 ’/n’ 和 ’/t’ 后缀。这是因为 gcc 将每一条当作字符串发送给 as(GAS)(LCTT 译注: GAS 即 GNU 汇编器),并且通过使用换行符/制表符发送正确格式化后的行给汇编器。如下:
1 | __asm__ ("movl %eax, %ebx/n/t" |
如果在代码中,我们涉及到一些寄存器(即改变其内容),但在没有恢复这些变化的情况下从汇编中返回,这将会导致一些意想不到的事情。这是因为 GCC 并不知道寄存器内容的变化,这会导致问题,特别是当编译器做了某些优化。在没有告知 GCC 的情况下,它将会假设一些寄存器存储了一些值,而我们可能已经改变却没有告知 GCC,它会像什么事都没发生一样继续运行(LCTT 译注:什么事都没发生一样是指GCC不会假设寄存器装入的值是有效的,当退出改变了寄存器值的内联汇编后,寄存器的值不会保存到相应的变量或内存空间)。我们所可以做的是使用那些没有副作用的指令,或者当我们退出时恢复这些寄存器,要不就等着程序崩溃吧。这是为什么我们需要一些扩展功能,扩展汇编给我们提供了那些功能。
三、扩展汇编
在扩展汇编中,我们可以同时指定操作数。它允许我们指定输入寄存器、输出寄存器以及修饰寄存器列表。GCC 不强制用户必须指定使用的寄存器。我们可以把头疼的事留给 GCC ,这可能可以更好地适应 GCC 的优化。基本格式如下:
1 | asm ( 汇编程序模板 |
汇编程序模板由汇编指令组成。每一个操作数由一个操作数约束字符串所描述,其后紧接一个括弧括起的 C 表达式。冒号用于将汇编程序模板和第一个输出操作数分开,另一个(冒号)用于将最后一个输出操作数和第一个输入操作数分开(如果存在的话)。逗号用于分离每一个组内的操作数。总操作数的数目限制在 10 个,或者机器描述中的任何指令格式中的最大操作数数目,以较大者为准。
如果没有输出操作数但存在输入操作数,你必须将两个连续的冒号放置于输出操作数原本会放置的地方周围。比如:
1 | int a=10, b; |
这里我们所做的是使用汇编指令使 ‘b’ 变量的值等于 ‘a’ 变量的值。
- ‘b’ 为输出操作数,用 ‘%0’ 引用,并且 ‘a’ 为输入操作数,用 ‘%1’ 引用
- ‘r’ 为操作数约束,‘r’ 告诉 gcc 可以使用任一寄存器存储操作数。输出操作数约束应该有一个约束修饰符 ‘=’,这修饰符表明他是一个只读的输出操作数
- 寄存器名字以两个 % 为前缀,这有利于区分操作数和寄存器。操作数以一个 % 为前缀
- 第三个冒号之后的修饰寄存器
%eax
用于告诉 gcc%eax
的值将会在 asm 内部被修改,所以 gcc 将不会使用此寄存器存储任何其他值
当 asm 执行完,b 变量会被更新
1. 扩展汇编模板
汇编程序模板包含了被插入到 C 程序的汇编指令集。其格式为:每条指令用双引号圈起,或者整个指令组用双引号圈起。同时每条指令应以分界符结尾。有效的分界符有换行符(”/n”)和分号(”;”)。”/n” 可以紧随一个制表符(”/t”)。
2. 操作数
如果我们使用的操作数多于一个,那么每一个操作数用逗号操作。
在汇编程序模板中,每个操作数用数字引用。编号方式如下。如果总共有 n 个操作数(包括输入和输出操作数),那么第一个输出操作数编号为 0 ,逐项递增,并且最后一个输入操作数编号为 n - 1 。
输出操作数表达式必须为左值。输入操作数的要求不像这样严格。它们可以为表达式。扩展汇编特性常常用于编译器所不知道的机器指令。如果输出表达式无法直接寻址(即,它是一个位域),我们的约束字符串必须给定一个寄存器。在这种情况下,GCC 将会使用该寄存器作为汇编的输出,然后存储该寄存器的内容到输出。
3. 修饰寄存器列表
一些指令会破坏一些硬件寄存器内容。我们不得不在修饰寄存器中列出这些寄存器,即汇编函数内第三个 :
之后的域。这可以通知 gcc 我们将会自己使用和修改这些寄存器,这样 gcc 就不会假设存入这些寄存器的值是有效的。
我们不用在这个列表里列出输入、输出寄存器。因为 gcc 知道 “asm” 使用了它们(因为它们被显式地指定为约束了)。如果指令隐式或显式地使用了任何其他寄存器,(并且寄存器没有出现在输出或者输出约束列表里),那么就需要在修饰寄存器列表中指定这些寄存器。
如果我们的指令可以修改条件码寄存器(cc),我们必须将 “cc” 添加进修饰寄存器列表。
如果我们的指令以不可预测的方式修改了内存,那么需要将 “memory” 添加进修饰寄存器列表。这可以使 GCC 不会在汇编指令间保持缓存于寄存器的内存值。如果被影响的内存不在汇编的输入或输出列表中,我们也必须添加 “volatile” 关键词。
1 | asm ("movl %0,%%eax; |
4. volatile
使用关键字 volatile,防止此汇编语句被 gcc 优化(放置到其他地方执行)
如果担心发生冲突,请使用 __volatile__
如果我们的汇编只是用于一些计算并且没有任何副作用,不使用 “volatile” 关键词会更好。不使用 “volatile” 可以帮助 gcc 优化代码并使代码更漂亮。
四、更多约束
1. 寄存器操作数约束
当使用这种约束指定操作数时,他们存储在通用寄存器(GPR)中。
1 | asm ("movl %%eax, %0/n" : "=r"(myval)); |
变量 myval 保存在寄存器中,寄存器 eax 的值被复制到该寄存器中,并且 myval 的值从寄存器更新到了内存。
当指定 “r” 约束时,gcc 可以将变量保存在任何可用的 GPR 中。要指定寄存器,你必须使用 特定寄存器约束 直接的指定寄存器的名字。如下:
1 | +---+--------------------+ |
2. 内存操作数约束
当操作数位于内存时,任何对它们的操作将直接发生在内存位置,这与寄存器约束相反,后者首先将值存储在要修改的寄存器中,然后将它写回到内存位置。但寄存器约束通常用于一个指令必须使用它们或者它们可以大大提高处理速度的地方。当需要在 “asm” 内更新一个 C 变量,而又不想使用寄存器去保存它的值,使用内存最为有效。例如,IDTR 寄存器的值存储于内存位置 loc 处:
1 | asm("sidt %0/n" : :"m"(loc)); |
3. 匹配(数字)约束
在某些情况下,一个变量可能既充当输入操作数,也充当输出操作数。可以通过使用匹配约束在 “asm” 中指定这种情况
1 | asm ("incl %0" :"=a"(var):"0"(var)); |
在这个匹配约束的示例中,寄存器 “%eax” 既用作输入变量,也用作输出变量。 var 输入被读进 %eax,并且等递增后更新的 %eax 再次被存储进 var。这里的 “0” 用于指定与第 0 个输出变量相同的约束。也就是说,它指定 var 输出实例应只被存储在 “%eax” 中。该约束可用于:
- 在输入从变量读取或变量修改后且修改被写回同一变量的情况
- 在不需要将输入操作数实例和输出操作数实例分开的情况
使用匹配约束最重要的意义在于它们可以有效地使用可用寄存器。其他一些约束:
- “m” : 允许一个内存操作数,可以使用机器普遍支持的任一种地址。
- “o” : 允许一个内存操作数,但只有当地址是可偏移的。即,该地址加上一个小的偏移量可以得到一个有效地址。
- “V” : 一个不允许偏移的内存操作数。换言之,任何适合 “m” 约束而不适合 “o” 约束的操作数。
- “i” : 允许一个(带有常量)的立即整形操作数。这包括其值仅在汇编时期知道的符号常量。
- “n” : 允许一个带有已知数字的立即整形操作数。许多系统不支持汇编时期的常量,因为操作数少于一个字宽。对于此种操作数,约束应该使用 ‘n’ 而不是’i’。
- “g” : 允许任一寄存器、内存或者立即整形操作数,不包括通用寄存器之外的寄存器。
以下约束为 x86 特有。
- “r” : 寄存器操作数约束,查看上面给定的表格。
- “q” : 寄存器 a、b、c 或者 d。
- “I” : 范围从 0 到 31 的常量(对于 32 位移位)。
- “J” : 范围从 0 到 63 的常量(对于 64 位移位)。
- “K” : 0xff。
- “L” : 0xffff。
- “M” : 0、1、2 或 3 (lea 指令的移位)。
- “N” : 范围从 0 到 255 的常量(对于 out 指令)。
- “f” : 浮点寄存器
- “t” : 第一个(栈顶)浮点寄存器
- “u” : 第二个浮点寄存器
- “A” : 指定 “a” 或 “d” 寄存器。这主要用于想要返回 64 位整形数,使用 “d” 寄存器保存最高有效位和 “a” 寄存器保存最低有效位。
4. 约束修饰符
gcc 给我们提供了约束修饰符。最常用的约束修饰符为:
- “=” : 意味着对于这条指令,操作数为只写的;旧值会被忽略并被输出数据所替换。
- “&” : 意味着这个操作数为一个早期改动的操作数,其在该指令完成前通过使用输入操作数被修改了。因此,这个操作数不可以位于一个被用作输出操作数或任何内存地址部分的寄存器。如果在旧值被写入之前它仅用作输入而已,一个输入操作数可以为一个早期改动操作数。
五、实用诀窍
1. 两数相加的程序
1 | int foo = 10, bar = 15; |
这里我们要求 gcc 将 foo 存放于 %eax,将 bar 存放于 %ebx,同时我们也想要在 %eax 中存放结果。“=” 符号表示他是一个输出寄存器。我们还有其他方式将一个整数加到一个变量
1 | __asm__ __volatile__( |
这是一个原子加法,lock 指令保证原子性。在输出域中,“=m” 表明 myval 是一个输出且位于内存。“ir” 表明 my_int 是一个整形,并应该存在于其他寄存器。没有寄存器位于修饰寄存器列表中。
2. 在一些寄存器/变量上展示一些操作,并比较值
1 | __asm__ __volatile__( "decl %0; sete %1" |
这里,my_var 的值减 1 ,并且如果结果的值为 0,则变量 cond 置 1。
这里需要注意的地方是(i)my_var 是一个存储于内存的变量。(ii)cond 位于寄存器 eax、ebx、ecx、edx 中的任何一个。约束 “=q” 保证了这一点。(iii)同时我们可以看到 memory 位于修饰寄存器列表中。也就是说,代码将改变内存中的内容。
3. 如何置 1 或清 0 寄存器中的一个比特位
1 | __asm__ __volatile__( "btsl %1,%0" |
这里,ADDR 变量(一个内存变量)的 ‘pos’ 位置上的比特被设置为 1。我们可以使用 ‘btrl’ 来清除由 ‘btsl’ 设置的比特位。pos 的约束 “Ir” 表明 pos 位于寄存器,并且它的值为 0-31(x86 相关约束)。也就是说,我们可以设置/清除 ADDR 变量上第 0 到 31 位的任一比特位。因为条件码会被改变,所以我们将 “cc” 添加进修饰寄存器列表。
4. 字符串拷贝
1 | static inline char * strcpy(char * dest,const char *src) { |
源地址存放于 esi,目标地址存放于 edi,同时开始拷贝,当我们到达 0 时,拷贝完成。约束 “&S”、”&D”、”&a” 表明寄存器 esi、edi 和 eax 早期修饰寄存器,也就是说,它们的内容在函数完成前会被改变。这里很明显可以知道为什么 “memory” 会放在修饰寄存器列表。
5. 系统调用的实现
如下是带有三个参数的系统调用
1 | type name(type1 arg1,type2 arg2,type3 arg3) / |
无论何时调用带有三个参数的系统调用,以上展示的宏就会用于执行调用。系统调用号位于 eax 中,每个参数位于 ebx、ecx、edx 中。最后 “int 0x80” 是一条用于执行系统调用的指令。返回值被存储于 eax 中。
每个系统调用都以类似的方式实现,Exit 是一个单一参数的系统调用,如下:
1 | { |
Exit 的系统调用号是 1,同时它的参数是 0。因此我们分配 eax 包含 1,ebx 包含 0,同时通过 “int $0x80” 执行 “exit(0)”。这就是 exit 的工作原理。