22 | 生成汇编代码(一):汇编语言其实不难学

敲黑板:课程用的是GNU汇编器,macOS和Linux已内置,本文的汇编语言的写法是GNU汇编器规定的写法。Windows系统可安装MinGW或Linux虚拟机。

对于静态编译型语言,比如C语言和Go语言,编译器后端的任务就是生成汇编代码,然后再由汇编器生成机器码,生成的文件叫目标文件,最后再使用链接器就能生成可执行文件或库文件了。

就算像JavaScript这样的解释执行的语言,也要在运行时利用类似的机制生成机器码,以便调高执行的速度。Java的字节码,在运行时通常也会通过JIT机制编译成机器码。而汇编语言是完成这些工作的基础。

对你来说,掌握汇编语言是十分有益的,因为哪怕掌握一小点儿汇编技能,就能应用到某项工作中,比如,在C语言里嵌入汇编,实现某个特殊功能;或者读懂某些底层类库或驱动程序的代码,因为它可能是用汇编写的。

本节课,我先带你了解一下汇编语言,来个破冰之旅。然后在接下来的课程中再带你基于AST手工生成汇编代码,破除你对汇编代码的恐惧,了解编译期后端生成汇编代码的原理。

以后,当你看到高级语言的代码,以及IR时,就可以想象出来它对应的汇编代码是什么样子,实现从上层到底层认知的贯通。

了解汇编语言

机器语言都是0101的二进制的数据,不适合我们阅读。而汇编语言,简单来说,是可读性更好的机器语言,基本上每条指令都可以直接翻译成一条机器码。

跟你日常使用的高级语言相比,汇编语言的语法特别简单,但它要跟硬件(CPU和内存)打交道,我们来体会一下。

计算机的处理器有很多不同的架构,比如x86-64、ARM、Power等,每种处理器的指令集都不相同,那也就意味着汇编语言不同。我们目前用的电脑,CPU一般是x86-64架构,是64位机。(如不做特别说明,本课程都是以x86-64架构作为例子的)。

说了半天,汇编代码长什么样子呢?我用C语言写的例子来生成一下汇编代码。

#include <stdio.h>
int main(int argc, char* argv[]){
    printf("Hello %s!\n", "Richard");
    return 0;
}

在macOS中输入下面的命令,其中的-S参数就是告诉编译器把源代码编译成汇编代码,而-O2参数告诉编译器进行2级优化,这样生成的汇编代码会短一些:

clang -S -O2 hello.c -o hello.s
或者:
gcc -S -O2 hello.c -o hello.s

生成的汇编代码是下面的样子:

 .section    __TEXT,__text,regular,pure_instructions
    .build_version macos, 10, 14    sdk_version 10, 14
    .globl  _main                   ## -- Begin function main
    .p2align    4, 0x90
_main:                                  ## @main
    .cfi_startproc
## %bb.0:
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset %rbp, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register %rbp
    leaq    L_.str(%rip), %rdi
    leaq    L_.str.1(%rip), %rsi
    xorl    %eax, %eax
    callq   _printf
    xorl    %eax, %eax
    popq    %rbp
    retq
    .cfi_endproc
                                        ## -- End function 
    .section    __TEXT,__cstring,cstring_literals
L_.str:                                 ## @.str
    .asciz  "Hello %s!\n"

L_.str.1:                               ## @.str.1
    .asciz  "Richard"

.subsections_via_symbols

你如果再打下面的命令,就会把这段汇编代码编译成可执行文件(在macOS或Linux执行as命令,就是调用了GNU的汇编器):

as hello.s -o hello.o   //用汇编器编译成目标文件
gcc hello.o -o hello   //链接成可执行文件 
./hello                 //运行程序

以上面的代码为例,来看一下汇编语言的组成元素。这是汇编语言入门的基础,也是重点内容,在阅读时,你不需要死记硬背,而是要灵活掌握,比如CPU的指令特别多,我们记住常用的就行了,不太常用的可以去查手册。

1.汇编语言的组成元素

这段代码里有指令、伪指令、标签和注释四种元素,每个元素单独占一行。

指令(instruction)是直接由CPU进行处理的命令,例如:

pushq   %rbp
movq    %rsp, %rbp

其中,开头的一个单词是助记符(mnemonic),后面跟着的是操作数(operand),有多个操作数时以逗号分隔。第二行代码的意思是把数据从这里(源)拷贝到那里(目的),这跟“请倒杯咖啡给我”这样的自然语句是一样的,先是动词(倒),然后是动词的作用对象(咖啡),再就是目的地(给我)。

伪指令以“.”开头,末尾没有冒号“:”。

.section    __TEXT,__text,regular,pure_instructions
.globl  _main        
.asciz  "Hello %s!\n"

伪指令是是辅助性的,汇编器在生成目标文件时会用到这些信息,但伪指令不是真正的CPU指令,就是写给汇编器的。每种汇编器的伪指令也不同,要查阅相应的手册。

标签以冒号“:”结尾,用于对伪指令生成的数据或指令做标记。例如L_.str: 标签是对一个字符串做了标记。其他代码可以访问标签,例如跳转到这个标签所标记的指令。

L_.str:                                 ## @.str
    .asciz  "Hello %s!\n"

标签很有用,它可以代表一段代码或者常量的地址(也就是在代码区或静态数据区中的位置)。可一开始,我们没法知道这个地址的具体值,必须生成目标文件后,才能算出来。所以,标签会简化汇编代码的编写。

第四种元素,注释,以“#”号开头,这跟C语言中以//表示注释语句是一样的。

因为指令是汇编代码的主要部分,所以我们再把与指令有关的知识点展开讲解一下。

2.详细了解指令这个元素

在代码中,助记符“movq”“xorl”中的“mov”和“xor”是指令,而“q”和“l”叫做后缀,表示操作数的位数。后缀一共有b, w, l, q四种,分别代表8位、16位、32位和64位。

比如,movq中的q代表操作数是8个字节,也就是64位的。movq就是把8字节从一个地方拷贝到另一个地方,而movl则是拷贝4个字节。

而在指令中使用操作数,可以使用四种格式,它们分别是:立即数、寄存器、直接内存访问和间接内存访问。

立即数以$开头, 比如$40。(下面这行代码是把40这个数字拷贝到%eax寄存器)。

movl $40, %eax

除此之外,我们在指令中最常见到的就是对寄存器的访问,GNU的汇编器规定寄存器一定要以%开头。

直接内存访问:当我们在代码中看到操作数是一个数字时,它其实指的是内存地址。不要误以为它是一个数字,因为数字立即数必须以$开头。另外,汇编代码里的标签,也会被翻译成直接内存访问的地址。比如“callq _printf”中的“_printf”是一个函数入口的地址。汇编器帮我们计算出程序装载在内存时,每个字面量和过程的地址。

间接内存访问:带有括号,比如(%rbp),它是指%rbp寄存器的值所指向的地址。

间接内存访问的完整形式是:

偏移量(基址,索引值,字节数)这样的格式。

其地址是:

基址 + 索引值*字节数 + 偏移量

举例来说:

8(%rbp),是比%rbp寄存器的值加8。
-8(%rbp),是比%rbp寄存器的值减8。
(%rbp, %eax, 4)的值,等于%rbp + %eax*4。这个地址格式相当于访问C语言中的数组中的元素,数组元素是32位的整数,其索引值是%eax,而数组的起始位置是%rbp。其中字节数只能取1,2,4,8四个值。

你现在应该对指令的格式有所了解了,接下来,我们再学几个常用的指令:

mov指令

mov 寄存器|内存|立即数, 寄存器|内存

这个指令最常用到,用于在寄存器或内存之间传递数据,或者把立即数加载到内存或寄存器。mov指令的第一个参数是源,可以是寄存器、内存或立即数。第二个参数是目的地,可以是寄存器或内存。

lea指令,lea是“load effective address”的意思,装载有效地址。

lea 源,目的

比如前面例子代码中的leaq指令,是把字符串的地址加载到%rdi寄存器。

leaq    L_.str(%rip), %rdi

add指令是做加法运算,它可以采取下面的格式:

add 立即数, 寄存器 
add 寄存器, 寄存器 
add 内存, 寄存器 
add 立即数, 内存 
add 寄存器, 内存

比如,典型的c=a+b这样一个算术运算可能是这样的:

movl -4(%rbp), %eax    #把%rbp-4的值拷贝到%eax
addl -8(%rbp), %eax   #把%rbp-8地址的值加到%eax上
movl %eax, -12(%rbp)   #把%eax的值写到内存地址%rbp-12

这三行代码,分别是操作a、b、c三个变量的地址。它们的地址分别比%rbp的值减4、减8、减12,因此a、b、c三个变量每个都是4个字节长,也就是32位,它们是紧挨着存放的,并且是从高地址向低地址延伸的,这是栈的特征。

除了add以外,其他算术运算的指令:

与栈有关的操作:

跳转类:

过程调用:

比较操作:

以上我列举的指令,是你在编写汇编代码时,经常会用到的,比较重要,会满足你编写简单汇编程序的需求,所以你需要重点关注。

x86-64是复杂指令集的处理器,有非常多的指令,差不多有上千条,全部记住是比较难的。更好的办法,是记住主要的指令,其他指令在使用时去查Intel公司的手册,在这里我就不举例了。

x86-64架构的寄存器

在汇编代码中,我们经常要使用各种以%开头的寄存器的符号。初学者阅读这些代码时,通常会有一些疑问:有几个寄存器可以用?我可以随便用它们吗?使用不当会不会造成错误?等等。所以,有必要让你熟悉一下这些寄存器,了解它们的使用方法。

x86-64架构的CPU里有很多寄存器,我们在代码里最常用的是16个64位的通用寄存器,分别是:

%rax,%rbx,%rcx,%rdx,%rsi,%rdi,%rbp,%rsp, %r8,%r9,%r10,%r11,%r12,%r13,%r14,%r15。

这些寄存器在历史上有各自的用途,比如,rax中的“a”,是Accumulator(累加器)的意思,这个寄存器是累加寄存器。

但随着技术的发展,这些寄存器基本上都成为了通用的寄存器,不限于某种特定的用途。但是,为了方便软件的编写,我们还是做了一些约定,给这些寄存器划分了用途。针对x86-64架构有多个调用约定(Calling Convention),包括微软的x64调用约定(Windows系统)、System V AMD64 ABI(Unix和Linux系统)等,下面的内容属于后者:

  • %rax 除了其他用途外,通常在函数返回的时候,把返回值放在这里。

  • %rsp 作为栈指针寄存器,指向栈顶。

  • %rdi,%rsi,%rdx,%rcx,%r8,%r9 给函数传整型参数,依次对应第1参数到第6参数。超过6个参数怎么办?放在栈桢里,我们21讲已经讲过了。

  • 如果程序要使用%rbx,%rbp,%r12,%r13,%r14,%r15 这几个寄存器,是由被调用者(Callee)负责保护的,也就是写到栈里,在返回的时候要恢复这些寄存器中原来的内容。其他寄存器的内容,则是由调用者(Caller)负责保护,如果不想这些寄存器中的内容被破坏,那么要自己保护起来。

上面这些寄存器的名字都是64位的名字,对于每个寄存器,我们还可以只使用它的一部分,并且另起一个名字。比如对于%rax,如果使用它的前32位,就叫做%eax,前16位叫%ax,前8位(0到7位)叫%al,8到15位叫%ah。

其他的寄存器也有这样的使用方式,当你在汇编代码中看到这些名称的时候,你就知道其实它们有可能在物理上是同一个寄存器。

除了通用寄存器以外,有可能的话,还要了解下面的寄存器和它们的用途,我们写汇编代码时也经常跟它们发生关联:

  • 8个80位的x87寄存器,用于做浮点计算;

  • 8个64位的MMX寄存器,用于MMX指令(即多媒体指令),这8个跟x87寄存器在物理上是相同的寄存器。在传递浮点数参数的时候,要用mmx寄存器。

  • 16个128位的SSE寄存器,用于SSE指令。我们将在应用篇里使用SSE指令,讲解SIMD的概念。

  • 指令寄存器,rip,保存指令地址。CPU总是根据这个寄存器来读取指令。

  • flags(64位:rflags, 32位:eflags)寄存器:每个位用来标识一个状态。比如,它们会用于比较和跳转的指令,比如if语句翻译成的汇编代码,就会用它们来保存if条件的计算结果。

总的来说,我们的汇编代码处处要跟寄存器打交道,正确和高效使用寄存器,是编译期后端的重要任务之一。

课程小结

本节课,我讲解了汇编语言的一些基础知识,由于汇编语言的特点,涉及的知识点和细节比较多,在这个过程中,你无需死记硬背,只需要掌握几个重点内容:

1.汇编语言是由指令、标签、伪指令和注释构成的。其中主要内容都是指令。指令包含一个该指令的助记符和操作数。操作数可以使用直接数、寄存器,以及用两种方式访问内存地址。

2.汇编指令中会用到一些通用寄存器。这些寄存器除了用于计算以外,还可以根据调用约定帮助传递参数和返回值。使用寄存器时,要区分由调用者还是被调用者负责保护寄存器中原来的内容。

另外,我们还要注意按照一定的规则维护和使用栈桢,这个知识点会在后面的加餐中展开来讲一个例子。

鉴于你可能是第一次使用汇编语言,所以我提供两个建议,让你快速上手汇编语言:

1.你可以用C语言写一些示例代码,然后用编译器生成汇编代码,看看能否看懂。

2.模仿文稿中的例子,自己改写并运行你自己的汇编程序,这个过程中,你会发现真的没那么难。

一课一思

你之前学习过或者在项目中使用过汇编语言吗?感受是什么呢?有什么经验和体会呢?欢迎在留言区分享你的经验与感受。

最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。

精选留言

  • 沉淀的梦想

    2019-10-12 00:47:45

    没太看懂文稿中的"leaq L_.str(%rip), %rdi"里面的"L_.str(%rip)"的含义,能再解释一下吗?
    作者回复

    就是一个间接内存访问。基数是%rip,也就是指令寄存器中的值,也就是leaq这行代码的下一行代码的地址。L_.str会被汇编器算出一个直接数来,比如0x20,也就是32个字节。这行指令就等价于:0x20(%rip)。
    0x20是什么意思?就是这个字符串常量的地址与%rip值的差。这种寻址方式叫做rip相对寻址。因为代码区在下面,静态数据区在上面,所以静态数据的地址一定大于指令的地址。在这里,是多0x20个字节,也就是32个字节。
    为什么要这么麻烦呢?为什么不使用这个字符串的确定地址呢?这主要是为了让程序代码更加位置无关。在使用动态库之类的场景的时候有好处。

    另外,lea这个指令除了获取地址以外,还有一个“奇怪”的用法,它可以用计算地址的方法,实际上对寄存器的值同时完成加法和乘法的计算,相当于一个三元计算,并且只使用一个时钟周期。比如:lea 0x20(,%eax,2) %eax,本来是用来算一个地址,结果被用来一次性的做了一个乘法和一个加法计算。

    提供3篇文章作为参考:
    https://stackoverflow.com/questions/40329260/why-is-the-address-of-static-variables-relative-to-the-instruction-pointer

    https://stackoverflow.com/questions/3250277/how-to-use-rip-relative-addressing-in-a-64-bit-assembly-program

    https://zhuanlan.zhihu.com/p/58774036

    2019-10-14 17:08:34

  • sugar

    2020-05-01 17:47:30

    我觉得读到这节 依然发现宫老师花费了很多笔墨在字里行间穿插探讨后端技术的重要性和应用场景。我也来贡献一个应用场景吧: 几年前我曾经听组里一位资深的后端(服务器后端)工程师分享过一道golang面试题,题目很简单:“写一个for循环输出从1到10 用go比用c更快,为什么?” ,当时的我不明觉厉,于是去查了些资料发现似乎考点是一个叫达夫机器的概念。如果熟悉汇编,其实大可不必被这些听起来高大上的理念吓到,一个forloop在汇编层面代码也不会长得可怕,直接读读编译后的代码就可以直观研究这样的问题了~
  • 不的

    2019-10-23 11:45:43

    老师,为啥要设计成区分调用者、被调用者保护的寄存器,统一被调用者或者调用者保护,有啥问题么
    作者回复

    这里有一个效率的问题。

    如果全部都是调用者保护,那么其实你调用的对象根本不会破坏你的寄存器,你也要保护起来,那就增加了成本,对于逻辑比较简单的被调用者,是用不到几个寄存器的。

    如果全部都是被调用者保护,也是一样的逻辑。如果调用者用了很少几个寄存器,被调用者却要保护很多,也不划算。

    所以最优的方法,其实是比较中庸主义的。我觉得这可以用概率的方法做比较严谨的证明。

    2019-11-09 21:55:39

  • 有学识的兔子

    2020-06-16 23:57:51

    一直想着学习逆向工程,奈何汇编理解不足 学习摸不着头脑,老师的课程给我带来了启发。
    作者回复

    Great!
    计算机科学里很多知识都是通着的,可以触类旁通!

    2020-06-19 15:58:34

  • 李圣悦

    2020-06-08 21:59:14

    确实汇编并不难,最近一直在查内核宕机问题,阅读内涵汇编代码没什么阻碍。难就难在如何恢复c代码…
    作者回复

    感谢分享心得!

    2020-06-09 19:50:16

  • 青南

    2022-03-30 00:04:14

    老师,请教一下汇编里面的“寄存器”是物理意义上的CPU里面的寄存器还是逻辑意义上的寄存器?如果是物理意义上的,那么,为什么多个程序同时操作同一个寄存器的时候,他们的数据不会冲突呢?因为操作系统里面肯定上有很多程序在运行的,如果A程序现在mov一个数字到rsp寄存器里面, 而B程序此时也mov了一个数字到rsp寄存器里面,那么当a程序从rsp寄存器里面读取数据的时候,不就读到B程序写进去的数据了吗?
  • coder

    2021-11-02 16:45:02

    mov指令的源和目的不能同时为内存地址吧?
  • Geek_656245

    2021-10-16 11:14:33

    很有用
  • David

    2021-06-22 09:23:50

    大学时微机原理,工作后看gcc后汇编代码发现和大学时讲的不一样了,很头疼,老师这篇文章一扫阴霾,解了我多年的疑惑。
  • sugar

    2020-05-03 11:55:33

    我一直不太明白 汇编里的push和pop所操作的这个栈,是不是就是操作系统在内存中给当前进程分配的应用内存空间当中的栈区(旁边还有堆区 静态区 常量区 代码区)呢?还是说汇编这里边的栈是单独的一套寄存器/高速缓存 之类的硬件设备? 望解答
  • 牛逼中…

    2020-03-26 22:17:47

    这里面说到地址,指的是可执行文件中的相对地址么?肯定不是内存吧,这个时候还没加载运行,和内存无关吧?
    作者回复

    汇编代码中到处会用到地址,这个地址就是内存地址。因为汇编代码中的操作数会采用直接内存访问和间接内存访问两种方式。
    不过,这些地址一般都不会指定一个绝对数值,而是一个相对值,比如-8(%rsp)是比%rsp的值减8的地址。这是在栈里使用内存的标准方式。其中%rsp指向的是栈顶的地址。而栈顶的地址,是在运行期可以确定的。
    至于函数(过程)和全局变量的地址,在链接的时候是可以确定的。

    2020-03-27 07:57:12

  • Dylan

    2020-03-21 17:09:23

    记得汇编入门的时候是从写一个简单的斐波那契数列开始~~当时真的觉得神奇,后来和内核驱动程序打交道,看多了大神们写的汇编,感觉好像也没那么难
    作者回复

    你说的是对的。并没有太难。
    如有可能,大家都加强一下汇编的素养。这样,在思考某些技术问题的时候,因为知道最底层的机制,会思考得比较彻底。

    2020-03-25 16:52:50

  • 宝鹏

    2020-02-26 22:21:28

    老师,as工具生成的文件不能直接运行,as hello.s -o hello,生成的hello并不是可执行文件
    作者回复

    没错!是我的疏忽。在其他章节里是先生成.o,然后再用gcc链接一下。这里疏忽了。我已经把文稿改了!

    2020-04-12 11:12:08

  • 好雨知时节

    2020-02-23 17:36:45

    宫老师,想问下:文中在示例中提到:
    8(%rbp),是比 %rbp 寄存器的值加 8。 //想知道这里的%rbp 寄存器中存储的内容一个地址还是具体的数据值?8(%rbp) 的含义是:%rbp 寄存器中存储的地址加8后的新地址 还是 %rbp 寄存器中具体数据值+8 得到的具体值?
    -8(%rbp),是比 %rbp 寄存器的值减 8。//
    作者回复

    在寄存器里存的就是一个数。至于这个数的语义是地址,还是别的数字,汇编代码是不管的。你完全可以用%rbp来做纯数学计算。因为根据现在的系统调用约定,其实不强制要求%rbp一定用来表示栈底地址,只对%rsp有要求。

    2020-03-26 09:53:45

  • 权华

    2019-11-12 22:56:56

    刚刚的问题,xorl %eax, %eax,将返回值置为0。
    作者回复

    对的。

    2019-11-25 19:14:42

  • 权华

    2019-11-12 22:24:59

    leaq L_.str(%rip), %rdi
    leaq L_.str.1(%rip), %rsi
    xorl %eax, %eax
    callq _printf
    xorl %eax, %eax

    宫老师你好,这是文章前面的汇编代码的一部分,我不明白 xorl %eax, %eax 这两行代码,它的作用是什么?为什么没有给寄存器 eax 就直接 xorl 操作了。
    作者回复

    修昂党羽给eax赋值为0,但用xorl使用的时钟周期更少。

    2019-11-25 19:14:30

  • 沉淀的梦想

    2019-10-14 18:04:09

    看了老师的回答与讲解"RIP相对寻址"的文章,但是还是不太理解:%rip存储的是下一条要执行的代码的地址,他存储值应该在不停地变,所以他和静态数据的偏移应该也在不停地变,为什么这里的偏移(L_.str)看起来是个常量啊?
    作者回复

    对的。
    如果你在不同的代码里调用使用L_.str(%rip),前面的L_.str对应的立即数是不同的。这个计算工作是由汇编器来算,不影响我们编程的效率。

    2019-10-14 22:27:30

  • 2019-10-12 21:54:00

    感受是写了一大坨,天都黑了,还没写完。。。
    作者回复

    就是搬砖的工作太多,创造性的工作太少:-)

    2019-10-14 11:24:22