Categories:
【转】程序是如何运行的
本篇会先从汇编聊起, 带大家熟悉最接近机器码的运转方式, 之后会再来看在基于虚拟机的编程语言中如何模拟这种类似的环境。
0.计算机的组成
我们现在的计算机大都基于冯诺依曼体系结构, 最早由冯诺依曼(John von Neumann, 1903年12月28日-1957年2月8日)提出, 他也被叫做“现代计算机之父” (我心目中的计算机之父是图灵)。 该结构下, 计算机被描述为五个部分组成:
- 输入设备: 顾名思义, 就是信息的输入, 常见的设备如鼠标, 键盘等
- 输出设备: 将计算结果输出的设备, 常见的如打印机输出, 显示器输出, 又如我们在玩嵌入式时, 灯也是输出设备, 灯的亮和灭也是一种输出
- 存储器: 存储器是程序和数据的存放处, 供运算器和控制器使用, 常见设备如内存和缓存器
- 运算器: 也叫算数逻辑单元(ALU), 是实际执行计算的元件
- 控制器: 也叫控制单元(CU), 主要任务是控制程序和数据的运行, 以及处理运算结果。 ALU 与 CU 一起组成了我们现在常说的中央处理器(CPU), 也叫芯片
这五种设备还能进一步简化, 输入设备和输出设备共同组成 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
- text: 主要存储程序执行所需的指令, 这一节是只读的, 尝试写入会造成段错误(Segment Error)。
- static: 主要存储全局变量与静态变量, 这一段还可以往下划分为
data
和bss
,data
存放全局初始化的变量,bss
主要存储没有初始化的全局变量。另外文字常量也存储在该段。 - heap: 这是堆区域,用户根据需要在这里开辟空间使用,对应 C 语言中的
malloc
, 但是使用完毕后需要自行释放,对应 C 语言的free
。GC 垃圾回收主要处理的就是这块区域。 - stack: 主要存储的是函数的调用,还有局部变量。
- env: 存放环境信息以及运转信息。
一个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 的参数就直接加载到 rdi
和 esi
寄存器里调用。还有一种调用方式在 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. 这些程序都是使用汇编编写的, 感兴趣的同学可以在自己学习的时候去研究下, 但是不建议在本地调试哦。 希望本篇文章能给到你们启发, 探索的过程是最快乐的 :)