x86汇编orb指令_Win x64 GCC AT&T汇编程序分析

06f51ecb539b2fb051900b84e0eeeb96.png

这是今年2月份清川发在CSDN上的文章。由于看不惯CSDN的发展走向,清川重写了他的个人网站,并将一部分内容搬运过来。

人间纪行 - 清川​liushangyu.xyz
2d80004694c2632da17be14447979b9b.png

时过境迁,这篇文章读起来仍然津津有味。楔子里提到的验收学长后来和我实习时碰巧同一个实验室,我才明白课设被怼不是我的问题,是学长本身比较有性格,和谁说话都那样。后来还参加了公司组织的趣味运动会,和验收时的老师一起合了影,而她应该都不记得我是谁。有些事情该放下就要放下,人不能活在偏执里,这样才能成长。


写在前面

话痨预警!

我永远忘不了本科时代编译原理的课程设计,那是我成年后的第一次崩溃。选题要求可以做编译器的前端、后端或者两者都做,评估了一下所带领的开发团队的实力,毅然决然选择只做前端,把前端做精,并且为后端开发留好接口。两个星期,我艰(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中的寄存器:

49400c97d6bb10bd1005157da035ce70.png

很好记的是,这和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 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声明了一个字符串,地址标记为.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 = %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寄存器指出格式字符串地址,接下来依次使用rdxr8r9,超出的将会使用栈来传递,这是函数调用的通用传递方法。压栈顺序为参数的逆序入栈。

3e16c868b5196ba6679be4d2dce6db66.png

上图是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加入到了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"]
}

写在后面

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值