本篇会先从汇编聊起, 带大家熟悉最接近机器码的运转方式, 之后会再来看在基于虚拟机的编程语言中如何模拟这种类似的环境。

0.计算机的组成

我们现在的计算机大都基于冯诺依曼体系结构, 最早由冯诺依曼(John von Neumann, 1903年12月28日-1957年2月8日)提出, 他也被叫做“现代计算机之父” (我心目中的计算机之父是图灵)。
该结构下, 计算机被描述为五个部分组成:

这五种设备还能进一步简化, 输入设备和输出设备共同组成 I/0设备, 运算器和控制器组成 CPU, 以及最后的主存作为存储器。

然后有两个元件需要注意的:
缓存作为存储器是集成在 CPU 里的, 用于加速数据读取。
硬盘并不是存储器的存在, 作为一个持久化的存储设备, 它应该被归为 I/O 设备, 程序要执行时会先从硬盘把程序和数据读入主存, 计算完成后输出到硬盘的文件。

1.x86

x86 有两种意思, 一种是指 x86 的处理器(CPU), 该型号最早由英特尔在 1976 年开发, 因其型号以86结尾, 如80186,80286, 80386, 所以叫x86。还有一种就是指 x86 的操作指令集, 就是配套 x86 处理器使用的。由于技术的发展, 现在又出现了 x86-64 或者 amd64, 这个名字很熟悉, 我们在安装软件的时候经常会让我们选择是 i386 还是 amd64, 前者是一般 32 位的 x86 版本, 后者是 x86 的 64 位扩展, 因为最早由 AMD 公司开发发布, 所以以 amd64 命名。

今天我们会了解一下基本的 x86 汇编的知识。

现在的汇编大致分为两种流派:

x86 为代表的使用 CISC(Complex Instruction Set Computer) 复杂指令集并通过栈对数据进行操作, x86-64 中由于增加了寄存器, 在函数参数传递时也会使用寄存器来传递。复杂指令集会各种功能强大的处理指令供用户使用, 如 call 指令调用函数时, 实际上会组合使用多条基础指令。x86 大量应用于 PC 及服务器。

下次还要介绍的 ARM 为代表的 RISC(Reduced Instruction Set Computer) 精简指令集并通过寄存器来操作。相对于复杂指令集, 精简指令集提供的指令类型较少。好处就是需要学习的指令相对较少也不复杂。ARM 大量用于手机, 还有嵌入式开发, 其中我们比较熟悉的开发板树梅派也搭载了 ARM CPU。

2.一个汇编程序

上次我们简单的看过 CPython 里的字节码, 字节码和操作指令是一一对应的。同样的汇编指令也是和机器码对应的, 一个被编译到平台的程序(像由 C 语言这种不通过虚拟机运行的程序), 最后就只剩下二进制机器码, 对我们来说基本不可读。但是我们还是可以通过工具翻译成汇编来了解程序的逻辑。这一块是一个单独的领域,可以叫二进制分析或者逆向工程, 比较多用于计算机病毒的分析, 软件破解等, 是一个非常 有趣 且 枯燥 且 有挑战性 的领域 :)

我们来结合实例来看看一个汇编程序它到底长什么样, 是不是真的像传说中那么晦涩难懂。 我准备了一个 C 语言程序 test.c:

#include<stdio.h>

int step = 2;

int
main()
{
    int n = 1;
    int count = 0;
    while (count<10) {
        n += step;
        count++;
    }
    printf("result: %d\n", n);
}

这是一个简单的累加程序, 函数内部定义了两个局部变量 n 和 count 用于计算, 函数外部定义了全局变量 step 来决定累加的跨度。
我们运行 gcc test.c -S test.asm, 来看看输出的 x86-64 汇编, 以下我只截取了关键部分:

        .file   "test.c"
        .text
        .globl  step            # int step
        .data
        .align 4
        .type   step, @object
        .size   step, 4
step:
        .long   2               # step = 2
        .section        .rodata
.LC0:
        .string "result: %d\n"
        .text
        .globl  main
        .type   main, @function
main:
.LFB0:
        .cfi_startproc
        endbr64
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        subq    $16, %rsp
        movl    $1, -8(%rbp)      # n = 1
        movl    $0, -4(%rbp)      # count = 0
        jmp     .L2               # goto .L2
.L3:
        movl    step(%rip), %eax  # eax = 2
        addl    %eax, -8(%rbp)    # n += eax
        addl    $1, -4(%rbp)      # count += 1
.L2:
        cmpl    $9, -4(%rbp)      # if 9 >= count
        jle     .L3               # goto .L3
        movl    -8(%rbp), %eax    # eax = n
        movl    %eax, %esi        # esi = eax
        leaq    .LC0(%rip), %rdi  # rdi = "result: %d\n"
        movl    $0, %eax          # eax = 0
        call    printf@PLT        # printf(rdi, esi);
        movl    $0, %eax          # eax = 0
        leave
        .cfi_def_cfa 7, 8
        ret                       # return eax
        .cfi_endproc

如果对汇编不太了解的同学, 看起来可能会一头雾水, 我在代码中加入了注释, 我们来对照 C 语言的代码逐行分析一下。
首先为了能更好的理解这些汇编的含义, 我们需要补充一些内存的知识。
一个程序在加载到内存以后, 进程会被分成多个内存段。我们来看几个主要的段:

------------------------------------------
 |text|static|heap | stack|.env|
------------------------------------------
 /\                        /\   /\     /\
 0x00000000                rsp  rbp    0xffffffff

一个32位的程序启动后会创建一个4G的虚拟内存, 这是 32 位系统能表示的最大内存, 地址可表示的范围为0x00000000-0xffffffff, 单位是字节(Byte), 操作系统会帮我们把虚拟内存映射到物理地址上, 这些在程序里几乎无感。

有一种情况, 当物理内存接近满载的时候, 虚拟内存就不会再映射到主存上了, 而是通过 SWAP 直接与硬盘交互数据。因为众所周知的原因, 直接和硬盘交互效率和内存天差地别, 这样频繁的活动就造成了 “磁盘抖动”, 也就是我们肉眼可见的屏幕卡顿, 卡死的情况。

好了, 我们再倒回去看汇编。 main: 之前那段能看出些什么逻辑呢?
我们可以看到 test.c 代码中的 step 全局变量和一个字符串常量 “result: %d\n” 被单独拿出来声明,分别用 .global step .size step, 4 .long 2 来定义了 C 代码中的 int step = 2;, 以及 .string "result: %d\n" 声明了这个字符串常量。按照我们上面的描述,这两个数据应该是存储在 heap 前面的 static 部分。

下面我们再来看从 main: 开始的 main 函数。main 函数开始, 所有的数据都在 stack 上动态存储了, 也是程序的重点。
首先我们看 n 和 count 的赋值:

--------------------------------------------
|         |         |     n    |   count   |               
--------------------------------------------
/\                  /\         /\          /\
0xfffffff0(rsp)    0xfffffff8  0xfffffffc  0xffffffff(rbp)

如上图所示,首先这里用到两个寄存器, rbp 指向栈(stack)底端, rsp 指向栈顶。初始化时 movq %rsp, %rbp 将 rsp 和 rbp 指向同一处,也就是清空栈, movq 指令在 64 位系统中赋值, 当只需要操作 32 位的数据的时候, 就只需要使用 movl 指令。
然后我们需要为 n 和 count 变量开辟内存空间, 因为内存栈(stack) 的结构有些特殊是从高位往低位走的, 所以开辟空间的时候是栈顶减掉需要的空间长度,就是这里的 subq $16, %rsp, $16 是常数 16。
然后是给 n 和 count 赋值, 在 C 语言里我们学过 int 类型需要 4 个字节的空间, 所以这两个指令 movl $1, -8(%rbp)movl $1, -4(%rbp) 使用相对寻址方式找到变量的内存空间进行赋值。-4(%rbp) 是根据rbp指向的内存地址进行偏移量计算,换个写法可以写成: %rbp - 4

这里有个小疑点,为什么 rsp 要偏移 16, 多分配 8 个字节?
这里涉及到一个内存对齐的问题,CPU 读取内存的时候是按 2, 4, 8 的倍数读取的, 有读取颗粒, 具体的对齐方式和系统有关, 我的 64 位系统是 16 字节的对齐方式, 换句话说 esp 寄存器的最后一位只有可能是 0 或者 f。因为对齐, 就算我只定义一个 int 也会分配 16 字节, 如果我定义了五个 int 变量, 则会分配 32 字节。

下面是 while 的表达, 上面初始化数据以后, 使用 jmp 命令可以跳到指定的代码段, 对应 C 语言中的 goto 语句。然后是 cmp 命令判断条件是否达成, 达成则 goto 到 L3 代码段。这里需要使用 RIP 寻址从 static 内存段中读取全局变量 step 的值。后边的 addl 就是对指定变量做加法了。

最后再来看一下 printf 的函数调用, 64位系统中增加了很多个通用寄存器, 这里 printf 的参数就直接加载到 rdiesi 寄存器里调用。还有一种调用方式在 32 位系统中比较常见, 32位系统一共就 8 个通用寄存器, 于是就把参数压入栈中调用, 取参数时还是按照偏移量取, 像这样:

|----|
|----| <- printf 的 rsp 栈顶
|----| <- printf 的 rbp 栈底
|main| <- main 函数的栈底地址,用于函数执行完毕后返回 main 函数
|str-| <- printf 的第一个参数 指向字符串常量 ""result: %d\n"", 这里是引用类型和 C 语言调用完全对的上
| n  | <- printf 的第二个参数 C 语言也是值传递
|----| <- main 函数的 rsp 栈顶
|....|
|....|
|----| <- main 函数的 rbp 栈底 

函数的参数还是通过 rbp 的相对寻址来获得。当printf函数执行完毕后, 会将 rbp 重置到原来 main 函数的栈底。

最后就是把 0 赋值给 eax 寄存器, ret 会把 eax 寄存器里的值当返回值返回。

小结

通过这个汇编的例子, 相信大家多少对汇编和程序底层的运行逻辑有些理解了吧。汇编做的事很简单就是对内存数据计算, 我们的程序就是这些简单操作的复杂组合。
这让我想起有一个极小化语言叫 BrainFuck, 只有八个操作指令,就具备了图灵完备, 是一门非常有趣的语言,有时间我会再说。
我自己也实现了一个简单的 bf 语言的解释器: https://github.com/00Kai0/myBrainFk 。
最近比较懒, 还有很多问题有待我慢慢改善。

这里也出现了后面要讲的具体的虚拟机的时候要说的 栈式虚拟机 和 寄存器虚拟机 的原型。比如 movl $1, -8(%rbp) 就是一个栈式操作, 又比如这段 x86-64 中的函数调用就是把操作数加载到寄存器进行的寄存器式操作。

后面我们从内存角度了解了, 局部变量是怎么在函数调用完后失效的。
其实就是调用完后栈顶 RSP 重新返回到调用前的位置, 当再次回到之前的局部变量的位置时, 是会覆盖老旧数据的。这里还要提个醒, 在 C 语言中声明一个新的局部变量的时候记得初始化, 不然里面可能是之前的数据残留。

全局变量有一点不同, 它被分配到静态空间, 这个空间在程序编译时就是确定的, 直到程序终止才会释放。

抛出一个小问题: 我们在高级语言中使用的闭包要去怎么实现呢?
闭包在这个结构里应该是不存在的了, 不然为什么 C 语言没有闭包。如果以后讲到虚拟机的时候可以再研究一下吧。

后面我们也看到了 CPU 是怎么处理函数的参数传递的, 引用类型时就传递引用内存地址, 值类型时就直接复制到参数列表就行了。不过我也看到过一些语言的处理有点不一样, 比如 Golang 里参数传递都是值传递的。参数包含数组的时候是将数组复制过去的, 相对的传递切片类型的时候效率要高一点, 因为切片的结构体里只是包含一个数组的引用(一个指针)。
这些都非常有意思。

内存里还有一个堆(heap), 篇幅有限, 以及没有在例子中用到, 就没说了, 下次一定吧 :)

最后我分享一个搜集了一些古老病毒的库: https://github.com/rdebath/viruses.
这些程序都是使用汇编编写的, 感兴趣的同学可以在自己学习的时候去研究下, 但是不建议在本地调试哦。
希望本篇文章能给到你们启发, 探索的过程是最快乐的 :)