引言
- 功能越来越复杂了,用汇编实现实在太麻烦了,还是转成 C 语言吧
- 问题:如何从汇编语言过渡到 C 语言呢?
C 与 汇编混合编程的基础条件
- 不同语言想要实现混合编程,那么它们就必须可编译得到相同格式的目标文件
- 如下图,C 文件与编译链接最后生成可执行程序 APP,正是由于 C 源文件与汇编源文件都可以被编译生成统一格式的 .o 目标文件,然后链接器才能把同一格式的 .o 目标文件链接生成最终可执行程序 APP
函数调用约定
- 要将 C 语言和汇编语言结合编程,还有一些事情需要提前知晓,那就是函数调用约定
- 不同的语言它们的调用约定并不是唯一的,其它的咱不关心,对于 C 语言,遵守的是 cdecl 约定。cdecl(C declaration,即 C 声明),约定内容如下:
- 参数是在栈中传递的
- 函数参数是从右到左的顺序入栈的
- EAX、ECX 和 EDX 寄存器是由调用者保存的,其余的寄存器由被调用者保存
- 函数的返回值存储在 EAX 寄存器
- 由调用者清理栈空间
- 下面我们就来一一具体分析一下这些约定
- 首先,参数是在栈中传递的。对于这一点,应该是个常识了,参数除了可以放在栈中,也可以放在寄存器中,又或者内存中。事实证明,最好的方式就是放在栈中来保存,这有以下两个好处
- 每个进程都有自己的栈,这就是每个内存自己的专用内存空间
- 保存参数的内存地址不用再花精力维护,已经有栈机制来维护地址变化了,参数在栈中的位置可以通过栈顶的偏移量来得到
- 以一个减法函数举例
int subtract(int a, int b) // 被调用者 { return a-b; } int sub = subtract(3, 2); // 主调用者 ...
- 从 C 语言的角度来看,函数 subtract 返回 a 减去 b 的差,即 3-2。但是,在其被编译成汇编语言时,参数是要压入栈中的。以上的 C 代码对应的汇编代码如下:
; 主调用者 push 2 ; 压入参数 b push 3 ; 压入参数 a call subtract ; 调用函数 subtract add esp, 8 ; 回收(清理)栈空间 ... ; 被调用者 subtract: push ebp ; 备份 ebp,为以后用 ebp 作为基址来寻址参数 mov ebp, esp ; 将当前栈顶赋值给 ebp mov eax, [ebp+8] ; [ebp+8]: 参数 a sub eax, [ebp+12] ; [ebp+12]: 参数 b ;pop ebp ; 推荐使用 leave leave ret
- 让我们一步一步的分析栈中情况
混合编程格式
- 因为我们用 gcc 编译 C 代码时,gcc 编译器默认使用 cdecl 约定,所以无论是 C 调用汇编还是汇编调用 C,我们能做的都只是让汇编去遵守 C 的约定,也就是说我们只需要关注汇编中的格式
- 汇编作为主调用者,即汇编调用 C 中函数时,汇编中的格式:
push 从右向左第一个参数 push 从右向左第二个参数 ... call c_func add esp, 4*参数个数
- 汇编作为被调用者,即 C 调用汇编中函数时,汇编中的格式:
asm_func: push ebp mov ebp, esp ... ; 以 ebp 为基准 ... ; [ebp+int*4] 这种形式可取得传入的参数值 ... ; [ebp-int*4] 这种形式可取得被调用者函数内部的局部变量值 ;pop ebp ; 推荐使用 leave leave ret
混合编程实战
- 实验名称:字符串打印
- 目的:熟悉 C 与汇编混合编程
- 具体要求:程序入口在汇编中(提示:_start),由汇编调用 C 函数 c_print,c_print 函数又调用汇编函数 asm_print,由 asm_print 打印打印字符串 "Hello World"
- 注意:本实验所在环境为通用 ubuntu(linux)环境,所生成的可执行程序格式为 elf
- 先提供实验完整代码:hello.asm、hello.c
int 0x80 实现打印功能
- 在实验开始前,需要做个准备工作,那就是借助 int 0x80 系统调用实现打印功能
- 新建名为 hello.asm 的文件,其中代码如下:
[section .data] vstr db "Hello world", 0xA ; 0xA:换行 vlen equ 12 [section .text] global _start _start: mov ebx, 1 ; fd=1:stdout mov ecx, vstr ; char * buf mov edx, vlen ; count mov eax, 4 ; sys_write int 0x80 mov ebx, 0 ; 0:表示无错误 mov eax, 1 ; sys_exit int 0x80
- 编译(由于当前平台为 i386 64 位,但是我们实现的汇编代码全部都是 32 位的。所以需要将其编译成 32 位 elf 格式,目标文件名:hello_asm.o)
nasm -f elf32 hello.asm -o hello_asm.o
- 链接 (将 hello_asm.o 链接成最终可执行程序 hello,由于当前平台为 i386 架构,链接时需要指明)
ld -s -m elf_i386 -o hello hello_asm.o
- 执行
./hello
- 结果:成功打印出 "Hello world"
- global _start 声明汇编程序入口,就像 C 中的 main 一样,当做固定格式,就这么写就行了
- 有了 BIOS 中的中断使用经验,linux 下汇编实现打印函数可以借助 int 0x80 系统调用实现,可以从 linux 内核源码中找到 sys_call_table 这个数组,系统调用函数都在里面,eax 看做数组下标,传参方式是通过寄存器的,每个系统函数传参都不同,这个需要具体问题具体对待。
- 示例中打印是通过 sys_write 系统实现的,其在数组 sys_call_table 中的下标为 4。sys_write 的函数原型为 int sys_write(unsigned int fd, char * buf, int count) 我们把字符串写入 fd=1(stdout) 即可实现屏幕打印效果
- global 关键字:从汇编中导出符号
- extern 关键字:使用外部文件中定义的符号
封装函数 asm_print
- asm_print 作为被调用者,其格式为:
asm_print: push ebp mov ebp, esp xxx ;pop ebp ; 推荐使用 leave leave ret
- 然后再替换 xxx,参数用 [ebp+int*4] 形式替换,最终封装好的 asm_print 函数如下:
asm_print: push ebp mov ebp, esp mov ebx, 1 ; fd=1:stdout mov ecx, [ebp+8] ; char * buf mov edx, [ebp+12] ; count mov eax, 4 ; sys_write int 0x80 mov ebx, 0 ; 0:表示无错误 mov eax, 1 ; sys_exit int 0x80 ;pop ebp ; 推荐使用 leave leave ret
C 中实现 c_print 函数
- 创建 hello.c, 其中代码如下:
extern void asm_print(char * buf, int len); int c_print(char * buf, int len) { asm_print(buf, len); // 汇编中的函数可以直接以 C 的函数调用形式使用 return 0; }
在 hello.asm 中调用 c_print
- 首先,我们想要使用外部定义的符号 c_print ,需要使用 extern 关键字声明
extern c_print ; 汇编中的 extern 声明是不带参数的
- 其次,由于 C 中用到了 asm_print 函数,所以需要导出 asm_print
global asm_print
- 最后,在汇编中(主调用者)调用 C 函数 c_print:
push vlen ; 参数入栈(从右向左) push vstr ; 参数入栈(从右向左) call c_print add esp, 8 ; 回收栈空间
编译&链接&运行
- 将改动后的程序重新编译链接
nasm -f elf32 hello.asm -o hello_asm.o gcc -m32 -c hello.c -o hello_c.o ld -s -m elf_i386 -o hello hello_asm.o hello_c.o
- 运行一下,成功显示出 "Hello world"
./hello
内嵌汇编
- 前面我们介绍过了一种汇编方法,就是 C 代码和汇编代码分别编译,最后通过链接的方式结合在一起形成可执行文件
- 还有另外一种形式的混合编程,那就是在 C 代码中直接嵌入汇编语言,我们称之为内嵌汇编,也称之为内联汇编
- GCC 支持在 C 代码中直接嵌入汇编代码,但是,内嵌汇编中所用的汇编语言,其语法是 AT&T,并不是咱们熟悉的 Intel 汇编语法,GCC 只支持它
AT&T 语法简介
- AT&T 与 Intel 汇编指令关键字并没有太大出入,在指令名字的 最后加上了操作数大小后缀,b 表示 1 字节,w 表示 2 字节,l 表示 4 字节,接下来我们对照着简单学习一下吧
区别 | Intel | AT&T |
寄存器 | 寄存器前无前缀 | 寄存器前有前缀 % |
操作数顺序 | 从右向左 | 从左向右 |
操作数指定大小 | 有关内存的操作数前要加数据类型修饰符:byte表示 8 位,word 表示 16位,dword 表示 32 位,如 mov byte[0x1234],eax | 指令的最后一个字母表示操作数大小,b 表示 8 位,w 表示 16 位,l 表示 32 位。如 movl %eax, var |
立即数 | 无前缀 | 有前缀 $ ,如 $6 |
远跳转 | jmp far segment(段基址):offset(偏移量) | ljmp $segment (段基址):$offset (偏移量) |
远调用 | call far segment(段基址):offset(偏移量) | lcall $segment (段基址):$offset (偏移量) |
远返回 | ret far n | Iret $n |
- AT&T 中的内存寻址还是挺独特的,需要单独说一下,其固定格式如下:
base_address(offset_address, index, size)
- 该格式对应的表达式为:
base_address + offset_address + index*size
- base_address 是基地址,可以为整数、变量名,可正可负
- offset_address 是偏移地址,必须是那 8 个通用寄存器之一
- index 是索引值,必须是那 8 个通用寄存器之一
- size 是个长度,只能是 1、2、4、8
寻址方式 | 说明 |
直接寻址 | 此寻址中只有 base_address 项,即后面括号中的内容全不要,base_address 便为内存啦,比如 movl $255 ,0xc00008F0 |
寄存器间接寻址 | 此寻址中只有 offset_address 项,即格式为(offset_address),要记得,offset_address 只能是通用寄存器。寄存器中是地址,不要忘记格式中的圆括号,如 mov (%eax), %ebx,功能是将地址(eax)所指向的内存复制 4 字节到寄存器 ebx |
寄存器相对寻址 | 此寻址中有 offset_address 项和 base_address 项,即格式为 base_address(offset_address)。这样得出的内存地址是基址+偏移地址之和。 movb -4(%ebx), %al,功能是将地址(ebx-4)所指向的内存复制 1 字节到寄存器 al |
变址寻址 | 一共有以下 4 种变址寻址组合1. 无 base_address,无 offset_address:movl %eax, (,%esi,2),功能是将 eax 的值写入 esi* 2 所指向的内存2. 无 base_address,有 offset_address:movl %eax, (%ebx, %esi, 2),功能是将 eax 的值写入 ebx+esi* 2 所指向的内存3. 有 base_address,无 offset_address:movl %eax, base_value(, %esi, 2),功能是将 eax 的值写入 base_value+esi* 2 所指向的内存4. 有 base_address,有 offset_address:movl %eax, base_value(%ebx, %esi, 2),功能是将 eax 的值写入 base_value+ebx+esi* 2 所指向的内存 |
基本内嵌汇编
- 基本内嵌汇编是最简单的内嵌形式,其格式为:
asm [volatile] ("assembly code")
- asm 和
__asm__
是一样的,是由 gcc 定义的宏:#define__asm__
asm。同样常见的还有 volatile 和__volatile__
是一样的,是由 gcc 定义的宏:#define__volatile__
volatile - “assembly code”是咱们所写的汇编代码,它必须位于圆括号中,而且必须用双引号引起来。这是格 式要求,只要满足了这个格式 asm [volatile] (""),assembly code 甚至可以为空
- 下面说下 assembly code 的规则
- 指令必须用双引号引起来,无论双引号中是一条指令或多条指令
- 一对双引号不能跨行,如果跨行需要在结尾用反斜杠
'\'
转义 - 指令之间用分号
';'
、换行符'\n'
或换行符加制表符'\n''\t'
分隔
- 提醒一下,即使是指令分布在多个双引号中,gcc 最终也要把它们合并到一起来处理,合并之后,指令间必须要有分隔符
asm("movl $9,%eax;""pushl %eax") 正确 asm("movl $9,%eax""pushl %eax") 错误
AT&T 编程实战
- 前面的实验当中,我们已经做过了使用 Intel 汇编语法实现打印字符串,现在再用 AT&T 汇编语法实现打印“Hello world”吧
- 创建 C 文件 hello.c,内容如下:
char * vstr = "Hello world\n"; int main() { asm(" \ movl $1, %ebx; \ movl vstr, %ecx; \ movl $12, %edx; \ movl $4, %eax; \ int $0x80; \ movl $0, %ebx; \ movl $1, %eax; \ int $0x80 \ "); return 0; }
- 编译
gcc hello.c
- 报错
/usr/bin/ld: /tmp/ccd0c0Vm.o: relocation R_X86_64_32S against symbol `vstr' can not be used when making a PIE object; recompile with -fPIE collect2: error: ld returned 1 exit status
- 按照提示加 -fPIE
gcc -fPIE hello.c
- 结果还是提示相同的错误,经过反复摸索,最终解决办法:加 -no-pie
gcc -no-pie hello.c
- 运行,成功打印出 “Hello world”
扩展内嵌汇编
- 由于基本内联汇编功能太薄弱了,所以才对它进行了扩展以使其功能强大
- 扩展内嵌汇编的格式:
asm [volatile] (“assembly code” : output : input : clobber/modify)
- 和前面的基本内联汇编相比,扩展内联汇编在圆括号中变成了 4 部分,多了 output、input 和 clobber/modify 三项。其中的每一部分都可以省略,甚至包括 assembly code。省略的部分要保留冒号分隔符来占位,如果省略的是后面的一个或多个连续的部分,分隔符也不用保留,比如省略了 clobber/modify,不需要保留 input 后面的冒号
- output: 用来指定汇编代码的数据如何输出给 C 代码使用
- input: 用来指定 C 中数据如何输入给汇编使用
- clobber/modify:汇编代码执行后会破坏一些内存或寄存器资源,通过此项通知编译器,可能造成寄存器或内存数据的破坏,这样 gcc 就知道哪些寄存器或内存需要提前保护起来,至于怎么保护咱不管,其实就使用前入栈,使用后出栈恢复,就像汇编函数的入栈和出栈,想一下我们内嵌汇编前是不是入栈保存,内嵌汇编执行后是不是也没有出栈恢复操作?这里其实就是显性的通知 gcc 编译器让 gcc 自动添加入栈和出栈操作
- 直接用一个实际例子体会一下扩展内嵌汇编 C 语言变量与汇编寄存器中间的数据传输
void main() { int in_a = 1, in_b = 2, out_sum; asm(" \ addl %%ebx, %%eax" \ : "=a"(out_sum) \ : "a"(in_a),"b"(in_b) \ ); // 等价于: // eax = in_a // ebx = in_b // eax = eax + ebx // out_sum = eax }
- 在基本内联汇编中的寄存器用单个 % 做前缀,在扩展内联汇编中,单个 % 有了新的用途,用来表示占位符(一会儿细讲),所以在扩展内联汇编中的寄存器前面用两个 % 做前缀
- output/input 格式:
"寄存器约束名"(C 变量名)
- 寄存器约束名是要求 gcc 使用哪个寄存器的,常见的约束有
- a:表示寄存器 eax/ax/al
- b:表示寄存器 ebx/bx/bl
- c:表示寄存器 ecx/cx/cl
- d:表示寄存器 edx/dx/dl
- D:表示寄存器 edi/di
- S:表示寄存器 esi/si
- q:表示任意这 4 个通用寄存器之一:eax/ebx/ecx/edx
- r:表示任意这 6 个通用寄存器之一:eax/ebx/ecx/edx/esi/edi
- g:表示可以存放到任意地点(寄存器和内存)。相当于除了同 q 一样外,还可以让 gcc 安排在内存中
- A:把 eax 和 edx 组合成 64 位整数
- f:表示浮点寄存器
- t:表示第 1 个浮点寄存器
- u:表示第 2 个浮点寄存器
- output 部分:"=a"(out_sum) 表示把寄存器 eax/ax/al 寄存器的值传递给 out_sum 变量中,就是 out_sum = eax 的意思
- input 部分:"a"(in_a),"b"(in_b) 表示 eax = in_a, ebx = in_b
- 好了,内嵌汇编暂时就了解这么多吧