目录
一、ATT 与 Intel
x86架构的处理器的汇编指令一般使用有两种:
ATT
汇编Intel
汇编
常用的汇编器:
MS VC
编译器:只支持 Intel 格式GNU CC
编译器:支持 ATT 格式和 Intel 格式,一般从 gcc 的上层开始调用像cc、ar 等工具。
ATT 与 Intel 汇编代码格式区别如下:
- Intel 代码省略了指示大小的后缀。我们看到指令 push 和 mov,而不是 pushq 和 movq
- Intel 代码省略了寄存器名字前面的 ‘ % ’ 符号,用的是 rbx,而不是 %rbx
- Intel 代码用不同的方式来描述内存中的位置,例如是 ‘ QWORD PTR [rbx] ’ 而不是 ‘ (%rbx) ’
- 在带有多个操作数的指令情况下,列出操作数的顺序相反。例如,ATT格式:mov 源操作数, 目的操作数;Intel格式:mov 目的操作数, 源操作数
- ATT 注释使用 ’ # ',Intel 注释使用 ’ ; ’
详细的区别参考:AT&T与Intel格式的汇编语法 🚀
思考: 既然 gcc 可以编译 ATT 和 Intel 编程风格,它是如何区分的?
回答: 默认的情况下,gcc 按照 ATT 的格式进行编译的,如果在代码前面加上.intel_syntax noprefix
那么 gcc 会按照 Intel 的格式进行编译,直到出现.att_syntax
时,汇编代码会切换为 ATT 格式。
参考: GCC Inline ASM 🚀
GCC 将 .c 文件编译成汇编代码,默认是编译成ATT 格式的,如果产生 Intel 格式可以使用如下命令:
gcc -Og -S -masm=intel main.c
参考:windows x64 GCC AT&T汇编程序分析 - 编译器设计前的铺垫 🚀
二、函数调用的约定
- 在x86架构下,C语言的实参是要入栈的(ARM 架构下是放到寄存器中,当实参数量比较多的时候,其余的入栈),C编译器使用cdecl约定,入栈的顺序是从右往左。
- 函数的返回值是存放在
%eax
寄存器中。 - 被调用者创建栈空间,调用者回收栈空间。
- call add 语句:将下一条指令地址入栈,然后将add函数的地址加载如EIP寄存器中。 (等价于 push %eip + movl <add>, %eip)
- ret 语句:将ESP指向的栈空间数据恢复到EIP寄存器中,然后跳转到EIP指向的地址,同时ESP指针上移。(等价于 pop %eip)
- leave 主要恢复栈空间,相当于:
movl %ebp, %esp 释放被调函数栈空间
popl %ebp 恢复ebp为调用函数基址
三、C语言调用汇编程序
C程序:
#include <stdio.h>
int add(int, int);
int main(int argc, const char *argv[])
{
int ret = 0;
ret = add(5, 11);
printf("The return value is %d. \n", ret);
return 0;
}
汇编代码:
.type add, @function
.global add # 设置 add 函数为全局可见
add:
pushl %ebp
movl %esp, %ebp
movl 8(%ebp), %eax
addl 12(%ebp), %eax # 参数的返回值是放在 %eax 中
popl %ebp
ret # 这个语句和 call 是搭配使用的
编译命令:
gcc -o app main.c add.s
C语言函数编译成汇编代码后的一般结构为:
pushl %ebp ; 调用者栈底寄存器入栈保存
movl %esp, %ebp ; 针对于被调用者,赋值新的栈底寄存器
sub $0x8, %esp ; 为被调用者申请一段栈空间
... ; 被调用者主体执行块
leave ; 释放栈空间
ret ; 同调用者的call 搭配使用,恢复EIP寄存器,返回调用者
(调用者和被调用者的结构是基本相似的:保存调用者的状态、申请栈空间、从上一个调用者栈段中获取参数、执行函数功能函数、往eax寄存器中赋值作为参数输出、清空栈空间、返回调用者程序)
四、汇编程序调用C语言
C程序:
#include <stdio.h>
int add(int a, int b)
{
return (a+b);
}
int main(int argc, const char *argv[])
{
int ret = 0;
ret = func(1, 10);
printf("The return value is %d. \n", ret);
return 0;
}
汇编代码:
.type func, @function
.global func ; 设置 func 函数为全局可见
func:
pushl %ebp
movl %esp, %ebp
sub $0x08, %esp ; 开辟一段栈空间
movl 8(%ebp), %eax
movl %eax, (%esp)
movl 12(%ebp), %eax
movl %eax, 4(%esp)
call add
add $0x08, %esp ; 清除栈空间
popl %ebp
ret ; 这个语句和 call 是搭配使用的
编译命令:
gcc -o app main.c func.s
汇编语句调用C语句主要需要考虑,如果有参数的情况下,如何提供为C语言提供参数。当前例子的情况是C调用汇编,再由汇编调用C,根据编译的约定,栈空间的布局是约定好的,依次为参数 —> 返回地址 —> 调用者栈段的栈底指针 —> 新的栈段
,C函数编译成汇编代码,编译器不会这么智能的知道参数在什么位置,只是按约定往上偏移一定的距离取参数,所以这里汇编多了④⑤4条语句,目的就是把参数放到C函数待会取参数的位置。
因此,汇编调用C函数的时候需要在栈空间中布置好传递给C函数的参数。
五、内联汇编
5.1、基本asm格式
;基本 asm 格式
asm [volatile]("汇编指令");
说明:
- 可以在一对双引号中全部写出,也可将一条指令放在一对双引号中;
- 当一对双引号内有多条指令时,必须用
\n
分隔符进行分割,为了排版,一般会加上\t
;- volatile 关键字之后,告诉编译器不要优化手写的内联汇编代码。
;举例1:一个引号中多个指令
asm (“nop\n\tnop\n\t”);
;举例2:基本内联汇编中,寄存器名称前只有一个百分号,注意区别扩展asm
asm volatile ("movl a, %eax\n\t"
"addl b, %eax\n\t"
"movl %eax, c");
示例:
#include <stdio.h>
int result = 10;
int main(int argc, const char *argv[])
{
asm volatile("addl $0x01, result\n\t"
"subl $0x02, result\n\t");
printf("The result is %d. \n", result);
return 0;
}
思考: 为什么在汇编代码中,可以使用变量result?
答案: 变量 result 被 .global 修饰,相当于是把它们导出为全局的,所以可以在汇编代码中使用。如果是一个局部变量,在汇编代代码中就不会用 .global 导出,此时在内联汇编指令中,还可以直接使用吗?【解决方法:扩展asm格式】
5.2、扩展asm格式
;扩展 asm 格式:
asm [volatile]("汇编指令":”输出操作数列表“:”输入操作数列表“:“改动的寄存器”);
说明:
- 扩展asm格式中的寄存器名称前面必须使用两个百分号(
%%
),基本内联汇编中的寄存器名称前面只有一个百分号(%
);- 输出操作数列表:汇编代码如何把处理结果传递到 C 代码中;
- 输入操作数列表:C 代码如何把数据传递给内联汇编代码;
- 改动的寄存器:告诉编译器,在内联汇编代码中,我们使用了哪些寄存器,这样的话
gcc
就会避免在其它地方使用这些寄存器;- “改动的寄存器”可以省略,此时最后一个冒号可以不要,但是前面的冒号必须保留,即使输出/输入操作数列表为空。
;输出/输入操作数列表格式
“[输出修饰符]约束”(寄存器或内存地址)
输出修饰符:(这里的操作数是指寄存器或内存地址)
+
:被修饰的操作数可以读取,可以写入;
=
:被修饰的操作数只能写入;
%
:被修饰的操作数可以和下一个操作数互换;
&
: 在内联函数完成之前,可以删除或者重新使用被修饰的操作数;
约束: 通过不同的字符,来告诉编译器使用哪些寄存器,或者内存地址。
a
:使用 eax/ax/al 寄存器;
b
: 使用 ebx/bx/bl 寄存器;
c
: 使用 ecx/cx/cl 寄存器;
d
: 使用 edx/dx/dl 寄存器;
r
: 使用任何可用的通用寄存器;(占位符用法)
m
: 使用变量的内存位置;
其他的约束选项还有:D, S, q, A, f, t, u等等
示例:
#include <stdio.h>
int main(int argc, const char *argv[])
{
int data1 = 1;
int data2 = 2;
int data3 = 0;
asm("addl %%ebx, %%ecx\n\t"
"movl %%ecx, %%eax\n\t"
: "=a"(data3)
: "b"(data1), "c"(data2));
printf("The data3 is %d. \n", data3);
return 0;
}
/*
运行结果:
The data3 is 3.
*/
gcc -S main.c -o main.s
编译成汇编代码:
由此可见,输入/输出操作数列表中,在内联汇编开始前,将输入操作数放到指定的寄存器中,在内联汇编结束后,将指定寄存器输出到输出操作数中。
编译成汇编代码中,内联汇编开始用
#APP
,结束的时候用#NO_APP
5.3、使用占位符来替代寄存器名称
5.3.1、使用占位符来替代寄存器名称
在上面的示例中,只使用了 2 个寄存器来操作 2 个局部变量,如果操作数有很多,那么在内联汇编代码中去写每个寄存器的名称,就显得很不方便。就让编译器帮我们指定寄存器。
规定: 内联汇编代码中的占位符,从输出操作数列表中的寄存器开始从
0
编号,一直编号到输入操作数列表中的所有寄存器。
示例:
#include <stdio.h>
int main(int argc, const char *argv[])
{
int data1 = 1;
int data2 = 2;
int data3 = 0;
asm("addl %1, %2\n\t"
"movl %2, %0\n\t"
: "=r"(data3)
: "r"(data1), "r"(data2));
printf("The data3 is %d. \n", data3);
return 0;
}
/*
运行结果:
The data3 is 3.
*/
5.3.2、给占位符重命名
给这些占位符重命名,也就是给每一个寄存器起一个别名,然后在内联汇编代码中使用别名来操作寄存器。
示例:
#include <stdio.h>
int main(int argc, const char *argv[])
{
int data1 = 1;
int data2 = 2;
int data3 = 0;
asm("addl %[v1], %[v2]\n\t"
"movl %[v2], %[v3]\n\t"
: [v3]"=r"(data3)
: [v1]"r"(data1), [v2]"r"(data2));
printf("The data3 is %d. \n", data3);
return 0;
}
/*
运行结果:
The data3 is 3.
*/
5.4、使用内存地址
我们可以指定使用哪个寄存器,也可以交给编译器来选择使用哪些寄存器,也可以直接使用变量的内存地址来操作变量。
#include <stdio.h>
int main(int argc, const char *argv[])
{
int data1 = 1;
int data2 = 2;
int data3 = 0;
asm("movl %1, %%eax\n\t"
"addl %2, %%eax\n\t"
"movl %%eax, %0\n\t"
: "=m"(data3)
: "m"(data1), "m"(data2));
printf("The data3 is %d. \n", data3);
return 0;
}
/*
运行结果:
The data3 is 3.
*/
1、输出操作数列表 “=m”(data3):直接使用变量 data3 的内存地址;
2、输入操作数列表 “m”(data1),“m”(data2):直接使用变量 data1, data2 的内存地址;
规定: 内联汇编代码中,从输出操作数列表中的寄存器开始从
0
编号,一直编号到输入操作数列表中的所有寄存器。所以变量 data3 在内联汇编中用%0表示,变量data1和变量data2用%1和%2表示。
🔍 如理解有误,望不吝指正。
参考文献:
[1]: 内联汇编很可怕吗?看完这篇文章,终结它! 🚀
[2]: linux平台学x86汇编(十八):内联汇编 🚀