这是今年2月份清川发在CSDN上的文章。由于看不惯CSDN的发展走向,清川重写了他的个人网站,并将一部分内容搬运过来。
人间纪行 - 清川liushangyu.xyz时过境迁,这篇文章读起来仍然津津有味。楔子里提到的验收学长后来和我实习时碰巧同一个实验室,我才明白课设被怼不是我的问题,是学长本身比较有性格,和谁说话都那样。后来还参加了公司组织的趣味运动会,和验收时的老师一起合了影,而她应该都不记得我是谁。有些事情该放下就要放下,人不能活在偏执里,这样才能成长。
写在前面
话痨预警!
我永远忘不了本科时代编译原理的课程设计,那是我成年后的第一次崩溃。选题要求可以做编译器的前端、后端或者两者都做,评估了一下所带领的开发团队的实力,毅然决然选择只做前端,把前端做精,并且为后端开发留好接口。两个星期,我艰(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.exe
。x86
上的Intel
汇编就包括我们耳熟能详的王爽8086汇编
。当年想在Win10上运行这种16位的程序,只有调用DOSBox
虚拟机。我曾经尝试编写支持8086汇编的IDE,读了DOSBox
长篇README,已经实现了IO流和错误流的传递,唯独无法实现虚拟机窗口的隐藏。尝试从源码修改rebuild,又因为没有Visual Studio
而以失败告终。微软x64
下的toolchain
实际上已经包含在VS
里了,但对于一个电脑带不动VS
的苦逼,我只能绕道。我尝试过NASM
汇编器,但也由于得不到微软的链接器而走到死胡同。
后者同时支持两个格式,并且跨平台,在Windows
、Linux
、Unix
、Mac OS
、iOS(模拟器)
上都可。它的汇编器即为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
寄存器中,退出程序,返回。这就是一个程序的最小框架。我们总结一些摸索出来的知识: + 指令的b
、w
、l
、q
后缀分别表示操作数为1Byte
、2Byte
(1 word)、4Byte
(1 long word)、8Byte
(1 quadra word)。在x64中,64位地址刚好对应q
。参考AT&T汇编-参考。 + x64
中的寄存器:
很好记的是,这和8086Intel汇编
的寄存器有相似之处。ax
,bx
,cx
,dx
,si
,di
,bp
,sp
都一样。只不过32位的在前面加上e
,64位的在前面加上r
。可能是extend
和re-extend
吧,我猜测。作用也相似,前面带x
是通用寄存器,然后分别为变址、目的变址、基址和栈顶指针寄存器。剩下的寄存器名则是从r8
~ r15
,分别用后缀d
,w
,b
指定位宽。 + 栈顶在低地址,栈底在高地址,入栈时rsp
减小。这也与我们C语言所讲的堆栈分配相统一。 + 使用#
作为注释开头,而不是Intel汇编
的;
。 + AT&T汇编
指令的源、目的操作数顺序和Intel汇编
相反:mov src, dst
。但在算术运算时两者相同,sub 减数, 被减数
。 + leave
指令等价于movq %rbp, %rsp
,popq %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语言局部变量存在栈区相一致,而且能看出当前平台下,编译器认为int
占4
个字节。那么rsp
移动4
不就行了,为啥动了16
。我们多定义几个变量试试,实验表明,每次分配超出栈顶,就会增长16
,这是个增量为16
的顺序栈。也就是说,定义5
个int
型变量时,这里会变成subq $64, %rsp
。补充摸索出来的知识: + 立即数以$
开头,寄存器以%
开头 + movl $99, -4(%rbp)
的目标操作数是一种寄存器基址寻址,会将rbp
的值与-4
相加。寻址方法可以参考AT&T指令集的操作数格式一节。
输入输出
我们在源码中加入输出语句:
#include <stdio.h>
int main() {
printf("hello worldn");
return 0;
}
得到GAS汇编代码:
.LC0:
.ascii "hello world0"
.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
声明了一个字符串,地址标记为.LC0
。lea 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 = %dn", 1, 1, 2);
return 0;
}
.LC0:
.ascii "I Know %d + %d = %d120"
.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
寄存器指出格式字符串地址,接下来依次使用rdx
、r8
、r9
,超出的将会使用栈来传递,这是函数调用的通用传递方法。压栈顺序为参数的逆序入栈。
上图是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 "%d0"
.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 jumpedn", array[i]);
return 0;
}
汇编代码一下子长了很多:
.LC0:
.ascii "%d0"
.LC1:
.ascii "%d sheep jumped120"
.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 "%d0"
.LC1:
.ascii "%d sheep jumped120"
.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加入到了sublime
的build 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"]
}
写在后面
这篇文章的实验抛转引玉,之后的工作可以参照这个模式进行。根据设计的编译器文法考虑所需了解的汇编语法,然后逐个攻破。将汇编链接的命令行加入到编译器中,就可以发明一个跨平台、可执行的语言了。