汇编语言


图灵在数理逻辑方面的理论研究,间接地创造了机器指令设计的基本方法,计算机界最高奖项也因此称图灵奖;现代计算机之父冯诺依曼,于1945年提出了“存储程序通用电子计算方案”,提出了指令数据存储思想,并在 1951 年成功研制出了冯诺依曼结构的 IAS 计算机(现在计算机的原型机),奠定了现代计算机的微体系设计和程序结构设计。

1   基础知识

1.1   冯诺依曼的“存储程序”思想

任何要计算完成的工作都要先被编写成程序,然后将程序和原始数据送入主存并启动。一旦程序被启动,计算机应能在不需操作的人员干预下,自己完成逐条取出指令和执行指令的任务。

1.2   计算机系统抽象层

计算机就是一层层的抽象设计,比如 ISA 指令集体系结构是对硬件的抽象,机器语言、汇编语言、高级语言都是通过 ISA 使用硬件的。计算机专业课程基本上是围绕其中一个或多个抽象层开展教学,但从业人员要知道你所工作的层处在整个层次的哪一部分,跟哪些相关,要有整个的背景知识。 计算机系统抽象层

对程序员来说,硬件部分要理解微体系架构的实现方式,这部分我们放在第二部分讲;软件部分大致可以分为语言处理系统操作系统这两大系统。

1.3   语言处理系统

语言处理系统由“语言处理程序”和“语言运行时系统”组成:

1.4   操作系统

汇编语言及以上层都通过操作系统抽象层使用指令集,操作系统主要由“人机接口”和“操作系统内核”组成:

1.5   什么叫自举

初学者可能会误认为是鸡和蛋的问题,要理解自举,就是清楚编译器本身就是一个程序,它的功能就是把源代码翻译为计算机可执行的程序,它和被翻译的语言并没有关系。下面以 Go 语言作为例子:

  1. Go 语言:发明了 Go 语言,Go 源代码由 Go 程序和 Go 汇编(plan9 语法)组成;
  2. C 写的编译器程序:前期得用 C 语言写一个 Go 源程序的编译器,用来把 Go 源代码编译成汇编文本;
  3. Go 写的编译器程序:用 Go 写一个把 Go 源代码编译成汇编文本的程序,并用“步骤 2”生成的 C 编译器编译成可执行程序;
  4. 用 “Go 写的编译器程序” 去编译 “Go 源代码”,不再用“步骤 2”生成的 C 编译器去编译。

其中第 3 步就是实现了自举,因为编译器既是一个复杂的工程,也有一套成熟的评价体系,一般能实现自举的程序可以说是完备成熟的编程语言了。

1.6   既简单又复杂的汇编

简单是因为汇编语言采用助记符来编写程序。复杂是因为汇编并不指代单一语言,相反,一种特定的汇编语言使用来自单一处理器指令集和操作数。因此,存在着多种汇编语言,每种都对应一类处理器。程序员可能会讨论 MIPS 汇编语言,也可能是 Intel x86 汇编语言。 总之,由于汇编语言是包含了特定处理器特性(如指令集、操作数寻址、寄存器数量和种类、寄存器存储的数值)的低级语言,因此存在着多种汇编语言。即使是同一 CPU,也可以采用不同的汇编语法并与之对应的汇编器,翻译成机器语言;

汇编语言这一特性程序员造成的影响是显然的:当编程工作从一种处理器迁移到另一种处理器时,汇编语言程序员必须学习新的语言。不利的一面是,指令集、操作数类型、寄存器在不同的汇编语言中通常是不同的;有利的一面是,大多数汇编语言倾向于遵从相同的基本模式。因此,一旦程序员学会了一种汇编语言,就能够迅速学会其他汇编语言,并且通常是学习新的细节,而不是学习新的编程风格。一个了解了汇编语言基本范式的程序员可以快速学会新的汇编语言。

pep/9 汇编、plan9 汇编、x86 汇编、MIPS 汇编,相当于不同的语言,Go 用的就是 plan9 汇编。

1.7   汇编与反汇编

汇编语言和机器语言基本上是一一对应的,将汇编语言编写的程序转化成机器语言的过程称为汇编;反之,机器语言程序转化成汇编语言程序的过程则称为反汇编。这一点和高级编程语言有很大不同,因此懂得汇编必然就懂得 CPU 指令集和内存的微体系架构。

反汇编工具有 objdump、otool,与 objdump -Sl 能力接近的命令是 otool -tV,表示列出指令段。 对于 Go 自己提供了汇编与反汇编工具,

1.8   函数与过程

所以说函数的概念隶属于过程。

2   微体系架构

微体系架构

2.1   内存

首先有必要了解一下随机存取存储器(RAM) ,顾名思义,RAM 是针对随机(而不是顺序)访问进行优化的,可以直接认为随机访问过程是直接命中的;此外,RAM 提供读写功能,一般写入时间比读取时间要长得多,这需要选择合适的内存技术;最后 RAM 是易失性的,在计算机断电后,值不会持续存在。内存结构,有以下四种引脚

  1. VCC、GND:电源
  2. A0-A9:地址信号,决定多少个地址编号,现在一般都是64位了
  3. D0-D7:数据信号,决定一次可读取的数据,一直以来都是8位
  4. RD、WR:控制信号

2.2   寄存器

CPU 我们只认识寄存器就可以,其它的对编程来说相对透明:

3   ISA 指令集

ISA 也正是介于软硬件的交界处,是高级语言和操作系统操作的抽象硬件(对硬件的抽象)。

这里自行去查找不同的架构指令集即可(cat /proc/cpuinfo | grep flags | head -1) macOS 可以用 sysctl -a | grep machdep.cpu

4   汇编

4.1   汇编基本范式

前面我们讲了,学汇编语言要学其基本范式,这部分建议学习《计算机体系结构精髓》,它是站在专业程序开发人员的角度进行讲解的。

4.2 调用规约

程序寄存器组是唯一能被所有函数共享的资源。虽然某一时刻只有一个函数在执行,但需保证当某个函数调用其他函数时,被调函数不会修改或覆盖主调函数稍后会使用到的寄存器值。因此,IA32采用一套统一的寄存器使用约定,所有函数(包括库函数)调用都必须遵守该约定。主要是要知道哪些寄存器是需要 caller-save,哪些是 callee-save,在编写汇编程序时应注意遵守惯例。

根据惯例:

plan9 调用规约可以参见参考资料[6]。

4.3 不同汇编的差异

程序员经常要在不同的汇编语言中切换调试,所以了解常见汇编语言的差异是很有必要的。不过这部分我也无法体系的讲,也找不比较全面的资料,只能凭经验列举一些差异,以后遇到不会感到迷惑。

IA64 RAX RBX RCX RDX RDI RSI RBP RSP R8 R9 R10 R11 R12 R13 R14 RIP
Plan9 AX BX CX DX DI SI BP SP R8 R9 R10 R11 R12 R13 R14 PC
int AddNum(int a, int b)
{
    return a + b;
}
int main()
{
    int c;
    c = AddNum(123, 456);
    return 0;
}

内存指令或数据是分段的,比如: _TEXT:指令的段定义 _DATA:是被初始化(有初始值)的数据段定义 _BSS:尚未初始化的数据的段定义

我们主要关注指令段,即 _TEXT,对编译或者反汇编来说差异是不大的,实例分析我们就以编译结果进行分析。

  1. 编译结果如下:
adadeMacBook-Pro:debug ada$ cat main.s
    .section    __TEXT,__text,regular,pure_instructions
    .build_version macos, 10, 14    sdk_version 10, 14
    .globl    _AddNum                 ## -- Begin function AddNum
    .p2align    4, 0x90
_AddNum:                                ## @AddNum
    .cfi_startproc

## %bb.0:

    pushq    %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset %rbp, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register %rbp
    movl    %edi, -4(%rbp)
    movl    %esi, -8(%rbp)
    movl    -4(%rbp), %esi
    addl    -8(%rbp), %esi
    movl    %esi, %eax
    popq    %rbp
    retq
    .cfi_endproc
                                        ## -- End function
    .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
    subq    $16, %rsp
    movl    $0, -4(%rbp)
    movl    $123, %edi
    movl    $456, %esi              ## imm = 0x1C8
    callq    _AddNum
    xorl    %esi, %esi
    movl    %eax, -8(%rbp)
    movl    %esi, %eax
    addq    $16, %rsp
    popq    %rbp
    retq
    .cfi_endproc
                                        ## -- End function
  1. 反汇编结果如下:
adadeMacBook-Pro:debug ada$ otool -tV a.out
a.out:
(__TEXT,__text) section
_AddNum:
0000000100000f60    pushq    %rbp
0000000100000f61    movq    %rsp, %rbp
0000000100000f64    movl    %edi, -0x4(%rbp)
0000000100000f67    movl    %esi, -0x8(%rbp)
0000000100000f6a    movl    -0x4(%rbp), %esi
0000000100000f6d    addl    -0x8(%rbp), %esi
0000000100000f70    movl    %esi, %eax
0000000100000f72    popq    %rbp
0000000100000f73    retq
0000000100000f74    nopw    %cs:(%rax,%rax)
0000000100000f7e    nop
_main:
0000000100000f80    pushq    %rbp
0000000100000f81    movq    %rsp, %rbp
0000000100000f84    subq    $0x10, %rsp
0000000100000f88    movl    $0x0, -0x4(%rbp)
0000000100000f8f    movl    $0x7b, %edi
0000000100000f94    movl    $0x1c8, %esi
0000000100000f99    callq    _AddNum
0000000100000f9e    xorl    %esi, %esi
0000000100000fa0    movl    %eax, -0x8(%rbp)
0000000100000fa3    movl    %esi, %eax
0000000100000fa5    addq    $0x10, %rsp
0000000100000fa9    popq    %rbp
0000000100000faa    retq

4.5 实例分析

我们对编译结果进行简化,以便于我们分析。在 amd64 中,C 语言中 int 是 4 位,在 Go 中 int 是 8 位。

_AddNum:                #(编号2)
    pushq    %rbp            #(编号3)
    movq    %rsp, %rbp        #(编号4)
    movl    %edi, -4(%rbp)        # 在 5d0 之后把 123 压入栈
    movl    %esi, -8(%rbp)        # 继续把 456 压入栈
    movl    -4(%rbp), %esi
    addl    -8(%rbp), %esi
    movl    %esi, %eax
    popq    %rbp             # (编号5)
    retq

## -- End function

_main:
    pushq    %rbp
    movq    %rsp, %rbp        #(编号0)
    subq    $16, %rsp        #(编号1)
    movl    $0, -4(%rbp)
    movl    $123, %edi
    movl    $456, %esi
    callq    _AddNum            #(编号6)
    xorl    %esi, %esi
    movl    %eax, -8(%rbp)
    movl    %esi, %eax
    addq    $16, %rsp        #(编号7)
    popq    %rbp
    retq

## -- End function

接下来附上一张内存图及 rsp 和 rbp 的指针,图片的编号对应代码标识的编号,图中编号所在列表示编号中的代码执行后的状态。调试工具的使用请参见本文下一小节的介绍。 调试汇编代码

C 语言函数规约一般开头和结尾是固定的,那为什么要把 rbp 压入栈临时保存,并用 rbp 来代替 rsp 进行偏移呢?主要是 rsp 受 push、pop、subq、addq 等的影响,所以一般都用 rbp 来进行偏移。

_main
    pushq    %rbp        ;将 rbp 寄存器的值存入栈中
    movq    %rsp, %rbp    ;将 rsp 寄存器的值存入 rbp 寄存器
    ...                  ;函数操作
    popq    %rbp        ;读出栈中的数值存入 rbp 寄存器
    retq            ;结束 main 函数,返回到调用源,返回 rbp 指令处

## -- End function

系统调用就是按规定把该系统调用的数据存放到寄存器,然后执行系统调用就会去读取依赖的寄存器进行调用。 注意上面函数操作处,最后一定要进行栈清理处理,即把 esp 指回 ebp 那个单元,以便下一步 pop ebp 比如函数操作如下:

    subq    $16, %rsp    ;这里分配 16 个字节的内存
    ...                  ;加法操作
    addq    $16, %rsp    ;这里就是把 rsp 指回 push 数据之前(没用的数据并不会清空),也就是指向存放 rbp 的那个单元

4.6 lldb 调试汇编代码

最方便的是直接在 xcode 里调试,选中菜单 Debug/Debug Workflow/Always Show Disassembly,在 C 源码中设置断点。调试的时候要注意,像 step over 其实有三个可选项step over/step over instruction(hold Control)/step over thread(hold Control-Shift),最好还是直接在 lldb 命令提示符中执行调试指令。对汇编的单步执行,要用 step over instruction 而非高级语言的 step over。下面列举一些常用的调试指令及其简写。

step over instruction/ni step into instruction/si register read/reg read thread list thread backtrace x/16xb 0x00007ffeefbff5f0-16 显示16个单元,每个单元占1个内存单元(g,1*8=8位),以16进制(x)显示,可以进行加减操作

    

参考文献

[1] Go assembly language complementary reference. https://quasilyte.dev/blog/post/go-asm-complementary-reference/#external-resources
[2] 矢泽久雄(日). 程序是怎样跑起来的. 版次:2015年4月第1版
[3] J.Stanley Warford. 计算机系统 核心概念及软硬件实现. 版次:2019年1月第1版
[4] 南京大学 袁春风教授. 计算机系统基础(一):程序的表示、转换与链接 https://www.icourse163.org/learn/NJU-1001625001?tid=1206622249#/learn/content?type=detail&id=1211390547&cid=1214042236&replay=true
[5] 汇编 is so easy. https://github.com/cch123/asmshare/blob/master/layout.md
[6] 调用规约. https://github.com/cch123/llp-trans/blob/master/part3/translation-details/function-calling-sequence/calling-convention.md
[7] Douglas Comer(美). 计算机体系结构精髓. 版次:2019年6月第1版