02-原理篇:指令和运算(05-08)

216 阅读10分钟

05 | 计算机指令:让我们试试用纸带编程

计算机或者CPU本身只能处理机器码,一连串的01数字。

高级语言程序如何变成一串串的01?

在软硬件接口中,CPU 帮我们做了什么事?

CPU:

  1. 硬件角度:CPU 就是一个超大规模集成电路,通过电路实现了加法、乘法乃至各种各样的处理逻辑。
  2. 软件角度:CPU 就是一个执行各种计算机指令(Instruction Code)的逻辑机器。

不同的 CPU 能够听懂的语言不太一样。不同的计算机指令集,英文叫 Instruction Set。

从编译到汇编,代码怎么变成机器码?

从高级语言到汇编代码(编译),再到机器码(汇编),就是一个日常开发程序,最终变成了 CPU 可以执行 的计算机指令的过程。

解析指令和机器码

常见的指令可以分成五大类:

  1. 第一类是算术类指令。我们的加减乘除,在 CPU 层面,都会变成一条条算术类指令。
  2. 第二类是数据传输类指令。给变量赋值、在内存里读写数据,用的都是数据传输类指令。
  3. 第三类是逻辑类指令。逻辑上的与或非,都是这一类指令。
  4. 第四类是条件分支类指令。日常我们写的“if/else”,其实都是条件分支类指令。
  5. 最后一类是无条件跳转指令。写一些大一点的程序,我们常常需要写一些函数或者方法。在 调用函数的时候,其实就是发起了一个无条件跳转指令。

image.png

汇编器是怎么把对应的汇编代码,翻译成为机器码的。

MIPS 是一组由 MIPS 技术公司在 80 年代中期设计出来的 CPU 指令集。

打孔卡,其实就是一种存储程序型计算机。

C 这样的编译型的语言之外,不管是 Python 这样的解释型语言,还是 Java 这样使用 虚拟机的语言,其实最终都是由不同形式的程序,把我们写好的代码,转换成 CPU 能够理 解的机器码来执行的。
只是解释型语言,是通过解释器在程序运行的时候逐句翻译,而 Java 这样使用虚拟机的语 言,则是由虚拟机对编译出来的中间代码进行解释,或者即时编译成为机器码来最终执行。

06 | 指令跳转:原来if...else就是goto

写好的代码变成了指令之后,是一条一条顺序执行的。

先不管几百亿的晶体管的背后是怎么通过电路运转起来的,逻辑上,我们可以认为, CPU 其实就是由一堆寄存器组成的。而寄存器就是 CPU 内部,由多个触发器(FlipFlop)或者锁存器(Latches)组成的简单电路。

触发器和锁存器,其实就是两种不同原理的数字电路组成的逻辑门。

N 个触发器或者锁存器,就可以组成一个 N 位(Bit)的寄存 器,能够保存 N 位的数据。比方说,我们用的 64 位 Intel 服务器,寄存器就是 64 位的。

一个 CPU 里面会有很多种不同功能的寄存器。三种比较特殊的:

  1. PC 寄存器(Program Counter Register),我们也叫指令地址寄存器 (Instruction Address Register)。顾名思义,它就是用来存放下一条需要执行的计算机指令的内存地址。
  2. 指令寄存器(Instruction Register),用来存放当前正在执行的指令。
  3. 条件码寄存器(Status Register),用里面的一个一个标记位(Flag),存放 CPU 进行算术或者逻辑计算的结果。

除了这些特殊的寄存器,CPU 里面还有更多用来存储数据和内存地址的寄存器。这样的寄 存器通常一类里面不止一个。我们通常根据存放的数据内容来给它们取名字,比如整数寄存 器、浮点数寄存器、向量寄存器和地址寄存器等等。有些寄存器既可以存放数据,又能存放 地址,我们就叫它通用寄存器。

一个程序的一条条指令,在内存里面是连续保存的,也会一条条顺序加载。
而有些特殊指令,比如上一讲我们讲到 J 类指令,也就是跳转指令,会修改 PC 寄存器里面 的地址值。这样,下一条要执行的指令就不是从内存里面顺序加载的了。

cmp DWORD PTR [rbp-0x4],0x0

cmp 指令比较了前后两个操作数的值,这里的 DWORD PTR 代表操作的数据类型是 32 位 的整数,而 [rbp-0x4] 则是一个寄存器的地址。所以,第一个操作数就是从寄存器里拿到 的变量 r 的值。第二个操作数 0x0 就是我们设定的常量 0 的 16 进制表示。cmp 指令的比 较结果,会存入到条件码寄存器当中去。

jne 4a <main+0x4a> 

jne 指令,是 jump if not equal 的意思,它会查看对应的零标志位。如果为 0,会 跳转到后面跟着的操作数 4a 的位置。这个 4a,对应这里汇编代码的行号,也就是上面设 置的 else 条件里的第一条指令。当跳转发生的时候,PC 寄存器就不再是自增变成下一条 指令的地址,而是被直接设置成这里的 4a 这个地址。这个时候,CPU 再把 4a 地址里的指 令加载到指令寄存器中来执行。

mov DWORD PTR [rbp-0x8],0x2

第一个操作数和前面的 cmp 指令 一样,是另一个 32 位整型的寄存器地址,以及对应的 2 的 16 进制值 0x2。mov 指令把 2 设置到对应的寄存器里去,相当于一个赋值操作。

Intel 指令集相对于之前的 MIPS 指令集要复杂一些,一方面,所有的指令是变长的,从 1 个字节到 15 个字节不等;另一方面,即使是汇编代码,还有很多针对操作数据的长度不同 有不同的后缀。

07 | 函数调用:为什么会发生stack overflow?

从程序的函数调用开始,讲讲函数间的相互调用,在计算机指令层面是怎么实现的,以及什么情况下会发生栈溢出这个错误。

push rbp
mov  rbp,rsp
pop  rbp
ret

真实的程序里,压栈的不只有函数调用完成后的返回地址。比如函数 A 在调用 B 的时候,需要传输一些参数数据,这些参数数据在寄存器不够用的时候也会被压入栈中。整个函数A 所占用的所有内存空间,就是函数 A 的栈帧(Stack Frame)。

们在调用call 指令时,会把当前的PC寄存器里的下一条指令的地址压栈,保 留函数调用结束后要执行的指令地址。而 add 函数的第 0 行,push rbp 这个指令,就是在进行压栈。这里的 rbp 又叫栈帧指针(Frame Pointer),是一个存放了当前栈帧位置的寄存器。push rbp 就把之前调用函数,也就是 main 函数的栈帧的栈底地址,压到栈顶。

mov rbp, rsp 里,则是把 rsp 这个栈指针(Stack Pointer)的 值复制到 rbp 里,而 rsp 始终会指向栈顶。这个命令意味着,rbp 这个栈帧指针指向的地 址,变成当前最新的栈顶,也就是 add 函数的栈帧的栈底地址了。

而在函数 add 执行完成之后,又会分别调用第 12 行的 pop rbp 来将当前的栈顶出栈,这部分操作维护好了我们整个栈帧。然后,我们可以调用第 13 行的 ret 指令,这时候同时要 把 call 调用的时候压入的 PC 寄存器里的下一条指令出栈,更新到 PC 寄存器中,将程序的 控制权返回到出栈后的栈顶。

内联带来的优化是,CPU 需要执行的指令数变少了,根据地址跳转的过程不需要了,压栈 和出栈的过程也不用了。 不过内联并不是没有代价,内联意味着,我们把可以复用的程序指令在调用它的地方完全展 开了。如果一个函数在很多地方都被调用了,那么就会展开很多次,整个程序占用的空间就会变大了。

08 | ELF和静态链接:为什么程序无法同时在Linux和Windows下运行?

既然我们 的程序最终都被变成了一条条机器码去执行,那为什么同一个程序,在同一台计算机上,在 Linux 下可以运行,而在 Windows 下却不行呢?反过来,Windows 上的程序在 Linux 上 也是一样不能执行的。可是我们的 CPU 并没有换掉,它应该可以识别同样的指令呀?

编译、链接和装载:拆解程序执行

无论是这里的运行报错,还是 objdump 出来的汇编代码里面的重复地址,都是因为 add_lib.o 以及 link_example.o 并不是一个可执行文件(Executable Program), 而是目标文件(Object File)。只有通过链接器(Linker)把多个目标文件以及调用的各种函数库链接起来,我们才能得到一个可执行文件。

“C 语言代码 - 汇编代码 - 机器码” 这个过程,在我们的计算机上进行的时候是由两部分组成的。

  1. 第一个部分由编译(Compile)、汇编(Assemble)以及链接(Link)三个阶段组成。在 这三个阶段完成之后,我们就生成了一个可执行文件。
  2. 第二部分,我们通过装载器(Loader)把可执行文件装载(Load)到内存中。CPU 从内存 中读取指令和数据,来开始真正执行程序。

image.png

ELF 格式和链接:理解链接过

在 Linux 下,可执行文件和目标文件所使用的都是一种叫ELF(Execuatable and Linkable File Format)的文件格式,中文名字叫可执行与可链接文件格式,这里面不仅存 放了编译成的汇编指令,还保留了很多别的数据。

add、 main 等等,乃至你自己定义的全局可以访问的变量名称,都存放在这个 ELF 格式文件里。 这些名字和它们对应的地址,在 ELF 文件里面,存储在一个叫作符号表(Symbols Table)的位置里。符号表相当于一个地址簿,把名字和地址关联了起来。

image.png

  1. 首先是.text Section,也叫作代码段或者指令段(Code Section),用来保存程序的代 码和指令;
  2. 接着是.data Section,也叫作数据段(Data Section),用来保存程序里面设置好的初 始化数据信息;
  3. 然后就是.rel.text Secion,叫作重定位表(Relocation Table)。重定位表里,保留的 是当前的文件里面,哪些跳转地址其实是我们不知道的。比如上面的 link_example.o 里 面,我们在 main 函数里面调用了 add 和 printf 这两个函数,但是在链接发生之前,我 们并不知道该跳转到哪里,这些信息就会存储在重定位表里;
  4. 最后是.symtab Section,叫作符号表(Symbol Table)。符号表保留了我们所说的当 前文件里面定义的函数名称和对应地址的地址簿。

Windows 的可执行文件格式是一种叫作 PE(Portable Executable Format)的文件格式。Linux 下的装载器只能解析 ELF 格式而 不能解析 PE 格式。

ELF其实是一种文件格式的标准,ELF文件有三类:可重定向文件、可执行文件、共享目标文 件。代码经过预处理、编译、汇编后形成可重定向文件,可重定向文件经过链接后生成可执行文件。