windows x64 GCC AT&T汇编程序分析 - 编译器设计前的铺垫

写在前面

话痨预警!

我永远忘不了本科时代编译原理的课程设计,那是我成年后的第一次崩溃。选题要求可以做编译器的前端、后端或者两者都做,评估了一下所带领的开发团队的实力,毅然决然选择只做前端,把前端做精,并且为后端开发留好接口。两个星期,我艰(dan)苦(da)开(du)发(dou),几乎都熬到凌晨3:00,发布前还通宵调试,终于我认定我做了一个优秀的前端,拥有完美而丰富的功能。

发布那天,验收的研究生学长说不想听我的介绍,只想看我打印出来的符号表。我说为了控制局部变量的作用域,我的符号表是树状的,不好输出,可以在调试界面里更方便地查看所有变量。(实际上我也考虑过这一点,所以安排组员写过,也给他们讲了详细思路,但他们写了三天也没动静。)

他瞥了一眼我CLion下的符号表,撇了撇嘴:“我可看不懂”。

“那我给你讲讲怎么看吧。”

“我不听,我就要看你的输出。”

“可是我说了我没有输出啊。”

“那你不就什么都没做吗?”他叫来老师,“老师,你看他们还整个什么树状的啥,听都没听说过,还没输出,这事儿我管不了了,再管要干架。”

我和老师复述了一遍情况,老师说:“那你这不就是啥都没做吗?你再看看要求,我们要的是输出,你别跟我扯没用的,连最基本的要求都达不到,你以后上企业也得被开除!”

“我,我…我原来什么都没做。”我没忍住眼泪,垂下头慢慢哭了出来。全班同学开始帮我求情,“老师,你仔细看看吧,他们组做的可好了,比我们都复杂。”

“你和他一组的?”

“不是啊”

“不是你帮他说什么话。这样吧,再给你个机会,我还没上成绩,明天你们组把输出拿出来,我还可以考虑考虑。本来就做的少,想得高分没那么简单。”

那一天我喝了几罐啤酒,思考着为什么教学不尊重创新和实用,为什么十几项特色的功能会被一个没有用的输出抹掉,为什么别的组只是把GCC包个壳子也能得到优秀。思考良久,我觉得成年人要学会看领导脸色。我用一个小时写完了树表输出,用上了前一年在数据结构课设中发明的树表输出算法。我给老师写了一封致歉信,并把输出贴了上去,最终得到了一个卑微的优秀。

从此,我对编译器设计耿耿于怀,几乎和认识的学弟学妹都说过:“你们参考一下ANSIC的开源文法还有GCC的内联汇编,你们可以编译到这种汇编,然后用GCC内置工具直接汇编成Win10下的可执行程序,一定很优秀。(这样就圆了老学长当年的遗憾)”

GAS汇编

为了编译到GCC内联汇编,我们必须先掌握这种汇编的语法。经过一番苦查,我几乎什么有用的都没找到,直到现在,我才意识到是关键词打开的不对。不是AT&T汇编,也不是GCC内联汇编,也不是windows x64 汇编,而应该是GAS汇编

基于x86架构的处理器所使用的汇编指令一般有两种格式:

  • Intel汇编
  • AT&T汇编

汇编器也包括两大派系(当然也有其他的,在此不逐一枚举):

  • MS VC编译器所使用的
  • GNU CC编译器所使用的

前者只支持Intel格式,在x86处理器上的汇编器是MASM.EXE,链接器是LINK.EXE,在x64下的汇编器是ml64.exe,链接器是64位的link.exex86上的Intel汇编就包括我们耳熟能详的王爽8086汇编。当年想在Win10上运行这种16位的程序,只有调用DOSBox虚拟机。我曾经尝试编写支持8086汇编的IDE,读了DOSBox长篇README,已经实现了IO流和错误流的传递,唯独无法实现虚拟机窗口的隐藏。尝试从源码修改rebuild,又因为没有Visual Studio而以失败告终。微软x64下的toolchain实际上已经包含在VS里了,但对于一个电脑带不动VS的苦逼,我只能绕道。我尝试过NASM汇编器,但也由于得不到微软的链接器而走到死胡同。

后者同时支持两个格式,并且跨平台,在WindowsLinuxUnixMac OSiOS(模拟器)上都可。它的汇编器即为GNU Assembler,简称GAS,这就是我们今天议题的由来了。GNU的核心精神是自由与分享,所以GAS亦是自由软件,你看,这多好。GCC配套的链接工具是ld.exe,不过我们可以直接使用gcc从上层调用。

换了关键词后,我一下子找到了GAS的文档:Using as。当然我猜这是一份Linux上的文档,因为Linux上的GAS就是as命令。接下来喜欢研究轮子的我就从头实验了。当然,了解这些,除了为编译器设计做铺垫,对于学习C语言嵌入GCC内联汇编以及读懂linux核心代码都会有帮助,也会加深对C语言底层的理解。

实验分析

最小框架

我是通过gcc -S这个命令入的坑。我相信写过C语言的人对GCC都不会陌生(VS党除外)。这个指令大家也应该熟悉,它可以生成GAS汇编中间代码。例如如下最简单的C语言源程序test.c

int main() {return 0;}

使用如下命令:

gcc -S -fno-asynchronous-unwind-tables test.c

你可以得到test.s

	.file	"hello_test.c"
	.def	__main;	.scl	2;	.type	32;	.endef
	.text
	.globl	main
	.def	main;	.scl	2;	.type	32;	.endef
main:
	pushq	%rbp
	movq	%rsp, %rbp
	subq	$32, %rsp
	call	__main
	movl	$0, %eax
	leave
	ret
	.ident	"GCC: (GNU) 5.2.0"

-fno-asynchronous-unwind-tables参数是为了去除复杂的Seh指令,你也可以尝试不加参数看一下结果。关于seh_*,在stack overflow中有提到。大概是说这是gas对MASM中为了生成可执行程序的.pdata和.xdata段的框架处理伪指令的实现。

These are gas’s implementation of MASM’s frame handling pseudos for generating an executable’s .pdata and .xdata sections

那么以上汇编程序每行的含义是什么呢?其中.file.def.ident都是一些表示调试信息的伪指令,可以忽略,下文的代码中会将它们删掉,不影响运行。当然也可以参考GAS汇编器伪指令大全.开头的一般都是伪指令。接下来.text表示代码段的开始,.globl main声明了全局的函数main。因为gcc默认要求程序入口必须是main,所以这里和下面的main:都是固定的。(也可以使用ld.exe手动链接并在参数中指定主函数入口。)接下来将寄存器rbp的值入栈,将栈顶指针寄存器rsp保存在rbp中,将栈顶指针rsp减去32,调用__main内置入口,什么都不做,然后将返回值0保存在eax寄存器中,退出程序,返回。这就是一个程序的最小框架。我们总结一些摸索出来的知识:

  • 指令的bwlq后缀分别表示操作数为1Byte2Byte(1 word)、4Byte(1 long word)、8Byte(1 quadra word)。在x64中,64位地址刚好对应q。参考AT&T汇编-参考
  • x64中的寄存器:
    在这里插入图片描述
    很好记的是,这和8086Intel汇编的寄存器有相似之处。axbxcxdxsidibpsp都一样。只不过32位的在前面加上e,64位的在前面加上r。可能是extendre-extend吧,我猜测。作用也相似,前面带x是通用寄存器,然后分别为变址、目的变址、基址和栈顶指针寄存器。剩下的寄存器名则是从r8 ~ r15,分别用后缀dwb指定位宽。
  • 栈顶在低地址,栈底在高地址,入栈时rsp减小。这也与我们C语言所讲的堆栈分配相统一。
  • 使用#作为注释开头,而不是Intel汇编;
  • AT&T汇编指令的源、目的操作数顺序和Intel汇编相反:mov src, dst。但在算术运算时两者相同,sub 减数, 被减数
  • leave指令等价于movq %rbp, %rsppopq %rbp
变量定义

接下来我们尝试插入最简单的变量定义:

int main() {
	int var = 99;
	return 0;
}

生成GAS后可以看到变化:

	.text
	.globl	main
main:
	pushq	%rbp
	movq	%rsp, %rbp
	subq	$48, %rsp		# 这里
	call	__main
	movl	$99, -4(%rbp)	# 这里
	movl	$0, %eax
	leave
	ret

注意要赋值,否则编译器会将未使用的变量优化没。栈顶rsp向下多移动了16,很明显,变量var被存在了地址rsp - 4,这与C语言局部变量存在栈区相一致,而且能看出当前平台下,编译器认为int4个字节。那么rsp移动4不就行了,为啥动了16。我们多定义几个变量试试,实验表明,每次分配超出栈顶,就会增长16,这是个增量为16的顺序栈。也就是说,定义5int型变量时,这里会变成subq $64, %rsp。补充摸索出来的知识:

  • 立即数以$开头,寄存器以%开头
  • movl $99, -4(%rbp)的目标操作数是一种寄存器基址寻址,会将rbp的值与-4相加。寻址方法可以参考AT&T指令集的操作数格式一节。

输入输出

我们在源码中加入输出语句:

#include <stdio.h>
int main() {
	printf("hello world\n");
	return 0;
}

得到GAS汇编代码:

.LC0:
	.ascii "hello world\0"
	.text
	.globl	main
main:
	pushq	%rbp
	movq	%rsp, %rbp
	subq	$32, %rsp
	call	__main
	leaq	.LC0(%rip), %rcx
	call	puts
	movl	$0, %eax
	leave
	ret

可以看到输出纯字符串的时候,会调用C语言的puts,前面使用.ascii声明了一个字符串,地址标记为.LC0lea src, dst指令的意思是将src的值直接送到dst中,而不会对src再用一次间接寻址。配合rip寄存器(估计这里面默认存的是汇编程序开始的地址),leaq .LC0(%rip), %rcx一句会将offset .LC0存入rcx,也就是为puts准备字符串首地址。这种做法也叫相对寻址,参考从机器码理解RIP 相对寻址

那么我们让printf不单纯一下:

#include <stdio.h>
int main() {
	printf("I Know %d + %d = %d\n", 1, 1, 2);
	return 0;
}
.LC0:
	.ascii "I Know %d + %d = %d\12\0"
	.text
	.globl	main
main:
	pushq	%rbp
	movq	%rsp, %rbp
	subq	$32, %rsp
	call	__main
	movl	$2, %r9d
	movl	$1, %r8d
	movl	$1, %edx
	leaq	.LC0(%rip), %rcx
	call	printf
	movl	$0, %eax
	leave
	ret

可以看到汇编调用了printf函数,第一个参数是通过rcx寄存器指出格式字符串地址,接下来依次使用rdxr8r9,超出的将会使用栈来传递,这是函数调用的通用传递方法。压栈顺序为参数的逆序入栈。
在这里插入图片描述
上图是x86中的,32位CPU只使用栈来传递参数,而Win64的前4个参数使用寄存器代替了。然而栈仍然会保存前4个参数的位置,以便回写,参考windows x64编程中寄存器的使用。逆序入栈让我想起了舍友问过我的一道考研C语言题,大概是这样:

int a = 1, b = 2;
printf("%d %d %d", a + b, a ++, b ++);

问输出结果,实际上执行顺序是b ++ => a ++ => a + b,所以结果应该是5 1 2

我们加入scanf试一下:

#include <stdio.h>
int main() {
	int var;
	scanf("%d", &var);
	return 0;
}

我们可以得到类似的结果

.LC0:
	.ascii "%d\0"
	.text
	.globl	main
main:
	pushq	%rbp
	movq	%rsp, %rbp
	subq	$48, %rsp
	call	__main
	leaq	-4(%rbp), %rax
	movq	%rax, %rdx
	leaq	.LC0(%rip), %rcx
	call	scanf
	movl	$0, %eax
	leave
	ret

注意这里的不同之处,leaq -4(%rbp), %rax,使用的是leaq而不是movq。这提醒我们C语言使用scanf的时候一定要考虑&的问题。输入整型不加和号,必然Runtime Error

循环 & 数组

加入循环和数组, 让代码逐渐变态:

#include <stdio.h>
int main() {
	int array[3];
	for (int i = 0; i < 3; ++ i)
		scanf("%d", &array[i]);
	for (int i = 0; i < 3; ++ i)
		printf("%d sheep jumped\n", array[i]);
	return 0;
}

汇编代码一下子长了很多:

.LC0:
	.ascii "%d\0"
.LC1:
	.ascii "%d sheep jumped\12\0"
	.text
	.globl	main
main:
	pushq	%rbp
	movq	%rsp, %rbp
	subq	$64, %rsp
	call	__main
	movl	$0, -4(%rbp)
	jmp	.L2
.L3:
	leaq	-32(%rbp), %rax
	movl	-4(%rbp), %edx
	movslq	%edx, %rdx
	salq	$2, %rdx
	addq	%rdx, %rax
	movq	%rax, %rdx
	leaq	.LC0(%rip), %rcx
	call	scanf
	addl	$1, -4(%rbp)
.L2:
	cmpl	$2, -4(%rbp)
	jle	.L3
	movl	$0, -8(%rbp)
	jmp	.L4
.L5:
	movl	-8(%rbp), %eax
	cltq
	movl	-32(%rbp,%rax,4), %eax
	movl	%eax, %edx
	leaq	.LC1(%rip), %rcx
	call	printf
	addl	$1, -8(%rbp)
.L4:
	cmpl	$2, -8(%rbp)
	jle	.L5
	movl	$0, %eax
	leave
	ret

这里解释几个没见过的用法,剩下的就很简单了:

  • movslq:作符号扩展的4字节复制到8字节。
  • salq:算术左移,低位补0。
  • cltq:将4个字节扩展为8个字节,高字节填充符号位,参考回答
  • cmpl $2, -4(%rbp):相当于i-2,配合后面的jle,如果结果小于等于0就跳转。jmp是无条件跳转。
  • \12就是\n,都可以。字符串要以\0结尾。
  • -32(%rbp,%rax,4)采用伸缩化变址寻址,即为rbp + rax * 4 - 32

这里唯一让人费解的是偏移量i在加到array地址上的时候为什么要左移2,后面变址寻址的时候也让rax * 4,相当于也左移了2。之前基址寻址时对地址的加减是货真价实的直接加减,比如-4(%rbp)。有种说法是Intel的CPU高两位总线不用,这显然不合适,因为这里是低两位没用。还有说法认为是内存对齐,可是内存对齐不应该只发生在结构体中吗,而且64位CPU应该按照8位对齐,而且,array占用的栈空间是货真价实的12字节。还请了解的大佬们不吝赐教,我是真的不会。

这里可以明显看到代码有很大的冗余,我们可以在编译的时候加入优化参数,如:

gcc -O test.c -S

优化方式和区别参考GCC -O 优化等级详解。优化出来的代码不太好懂,这里不再展开。但是我倒是可以利用刚才掌握的知识重写一份汇编,来实现同样的功能(好吧我只是抖个机灵):

.LC0:
	.ascii "%d\0"
.LC1:
	.ascii "%d sheep jumped\12\0"
	.text
	.globl	main
main:
	pushq	%rbp
	movq	%rsp, %rbp
	subq	$48, %rsp
	call	__main
	movl	$0, -4(%rbp)
	jmp	.L2
.L3:
	leaq	-16(%rbp), %rax
	movl	-4(%rbp), %edx
	movslq	%edx, %rdx
	salq	$2, %rdx
	addq	%rax, %rdx
	leaq	.LC0(%rip), %rcx
	call	scanf
	movl	-4(%rbp), %eax
	cltq
	movl	-16(%rbp,%rax,4), %edx
	leaq	.LC1(%rip), %rcx
	call	printf
	addl	$1, -4(%rbp)
.L2:
	cmpl	$2, -4(%rbp)
	jle	.L3
	movl	$0, %eax
	leave
	ret

然后使用下面的命令将它汇编链接成可执行程序:

gcc test.s -o test.exe

也可以加入-v参数看看gas汇编链接的过程,你会发现gcc先是调用了as.exe来汇编,然后调用了collect2.exe,这其中又调用了ld.exe链接。为了方便,我把GAS加入到了sublimebuild system中。gas.sublime-build

{
	"working_dir": "${file_path}",
    "shell_cmd": "gcc ${file} -o ${file_base_name}.exe && start cmd /c \"${file_base_name}.exe & pause\"",
    "selector": ["source.s"]
}

写在后面

这篇文章的实验抛转引玉,之后的工作可以参照这个模式进行。根据设计的编译器文法考虑所需了解的汇编语法,然后逐个攻破。将汇编链接的命令行加入到编译器中,就可以发明一个跨平台、可执行的语言了。

  • 6
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值