本篇会先从汇编聊起, 带你们熟悉最接近机器码的运转方式, 以后会再来看在基于虚拟机的编程语言中如何模拟这种相似的环境。git
0.计算机的组成
咱们如今的计算机大都基于冯诺依曼体系结构, 最先由冯诺依曼(John von Neumann, 1903年12月28日-1957年2月8日)提出, 他也被叫作“现代计算机之父” (我心目中的计算机之父是图灵)。
该结构下, 计算机被描述为五个部分组成:github
- 输入设备: 顾名思义, 就是信息的输入, 常见的设备如鼠标, 键盘等
- 输出设备: 将计算结果输出的设备, 常见的如打印机输出, 显示器输出, 又如咱们在玩嵌入式时, 灯也是输出设备, 灯的亮和灭也是一种输出
- 存储器: 存储器是程序和数据的存放处, 供运算器和控制器使用, 常见设备如内存和缓存器
- 运算器: 也叫算数逻辑单元(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
寄存器里调用, 返回值会放在 eax
寄存器内。还有一种调用方式在 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 寄存器里的值当返回值返回。
小结
经过这个汇编的例子, 相信你们多少对汇编和程序底层的运行逻辑有些理解了吧。汇编作的事很简单就是对内存数据计算, 咱们的程序就是这些简单操做的复杂组合。
这让我想起有一个极小化语言叫 BrainFxxk, 只有八个操做指令,就具有了图灵完备, 是一门很是有趣的语言,有时间我会再说。
我本身也实现了一个简单的 bf 语言的解释器: https://github.com/00Kai0/myBrainFk 。 最近比较懒, 还有不少问题有待我慢慢改善。
这里也出现了后面要讲的具体的虚拟机的时候要说的 栈式虚拟机 和 寄存器虚拟机 的原型。好比 movl $1, -8(%rbp)
就是一个栈式操做, 又好比这段 x86-64 中的函数调用就是把操做数加载到寄存器进行的寄存器式操做。
后面咱们从内存角度了解了, 局部变量是怎么在函数调用完后失效的。
其实就是调用完后栈顶 RSP 从新返回到调用前的位置, 当再次回到以前的局部变量的位置时, 是会覆盖老旧数据的。这里还要提个醒, 在 C 语言中声明一个新的局部变量的时候记得初始化, 否则里面多是以前的数据残留。
全局变量有一点不一样, 它被分配到静态空间, 这个空间在程序编译时就是肯定的, 直到程序终止才会释放。
抛出一个小问题: 咱们在高级语言中使用的闭包要去怎么实现呢?
闭包在这个结构里应该是不存在的了, 否则为何 C 语言没有闭包。若是之后讲到虚拟机的时候能够再研究一下吧。
后面咱们也看到了 CPU 是怎么处理函数的参数传递的, 引用类型时就传递引用内存地址, 值类型时就直接复制到参数列表就好了。不过我也看到过一些语言的处理有点不同, 好比 Golang 里参数传递都是值传递的。参数包含数组的时候是将数组复制过去的, 相对的传递切片类型的时候效率要高一点, 由于切片的结构体里只是包含一个数组的引用(一个指针)。
这些都很是有意思。
内存里还有一个堆(heap), 篇幅有限, 以及没有在例子中用到, 就没说了, 下次必定吧 :)
最后我分享一个搜集了一些古老病毒的库: https://github.com/rdebath/viruses.
这些程序都是使用汇编编写的, 感兴趣的同窗能够在本身学习的时候去研究下, 可是不建议在本地调试哦。
但愿本篇文章能给到大家启发, 探索的过程是最快乐的 :)