[关闭]
@lambeta 2019-05-22T15:51:47.000000Z 字数 2667 阅读 383

解构solidity compiler和evm

evm solidty blockchain


这是一篇前传,目的是给之后讲解solidity compiler以及evm(以go-etheruem virtual machine为例)提供必要的编译、链接、重定位,指令设计以及优化方面的技术基础。

程序最初的解耦

鲍勃大叔在《架构整洁之道》这本书中,从古老的PDP-8程序需要指定绝对的起始地址谈到重定位技术对于程序解耦的重要性——用符号(Symbol)替换绝对地址,然后在装载(Load)的时候替换成正确的内存地址。现如今,程序员基本不用考虑程序要装载的起始内存地址。用程序员的话来说,这是一种抽象能力,它分离了关注点,让我们不再需要关心底层细节。

随着汇编语言的兴起,程序规模逐步变大,模块化成为控制复杂度必需的手段。而且在内存有限的情况下,编译过程需要多次从缓慢的存储设备中读取源代码,全量编译所耗费的时间逐渐变得让人难以忍受。为了缩短编译时间,程序员们就更加倾向把可复用的函数库源代码单独编译成可共享的对象(Shared Object),比如Linux下的libc.so文件。

鲍勃大叔在书中提到早期函数库的共享方法是一种静态共享库(Static Shared Library)。操作系统会在某个特定的地址划分出地址块,为那些已知的模块预留足够的空间。这种做法很简单,但是缺点也很明显,除了书中所提及的应用程序和函数库代码如果超出预留空间就会导致地址分配碎片化的问题之外,还存在多个共享库装入内存时,地址可能产生冲突,导致某个库被另一个库覆盖的问题。而且如果共享库升级,其包含的全局变量或函数的地址发生改变,那么已经装入内存的可执行文件大概率会因为依赖旧的地址而发生运行时错误。我们很容易想到,只要不让共享库装载到某块固定的内存区域,那么这些问题都能迎刃而解。于是,聪明的计算机先辈开始思考如何让共享对象装载到任意地址上?

这里用到了分离可变性的思路,顺道引出装载时重定位和地址无关代码(PIC)的概念。装载时重定位将重定位这件事情延迟到了装载时期,但无奈共享库的指令部分需要在多个进程间共享,这是共享的基本要求;然而重定位恰恰会因为不同的进程对虚拟地址的要求修改指令,共享指令被多个进程修改,这就不可能共享。于是,遵从共享的就是不可变的指导原则,我们需要将那部分可变的指令从指令段.text中分离出去,放入可变的数据段.data中,这也是将可变从不可变状态中分离的基本原则。分离的方法就是建立一个全局偏移表(Global Offset Table),在这个段中维护一个指向变量或函数的指针数组。

函数库也可以被编译成中间目标文件(Linux下的Relocatable的ELF[^ELF]),简称目标文件,比如:C语言运行库Glibc中的的printf.o,这些目标文件有特定的格式(Linux上用的是ELF格式,Windows上用的是PE格式),方便以后组合使用。

这些目标文件里面记载了程序被链接时需要的信息,其中最重要的就是符号表(Symbol tables,段记录的一种)。顾名思义,符号表就是记录符号的表格,它会记载原始程序当中的全局变量、局部静态变量,函数等符号的名字、类型、绑定信息(是局部绑定还是全局绑定)以及位于哪个段记录(.text、.data、.rodata ...)中。这些符号有的是外部引用,其定义是在其它的文件里;有的是本地引用,定义和引用都在本文件中。在最终合并外部目标文件并链接成可执行文件时,所有符号的地址(此处是程序进程虚拟地址 VMA, Virtual Memory Area)都能被确定下来,所以对应的符号就可以被解析成地址,所有引用这些符号的地方也会被替换成地址,这个解析和替换的过程就是重定位。

经过链接后的可执行文件就能被装载进内存,然后和进程的VMA进行映射。准备来说,操作系统通过VMA来管理进程的地址空间,包括进程的堆、栈也是如此,而虚拟地址到物理地址的映射是通过CPU的MMU(Memory Managment Unit)部件实现的。

当然,链接加载器是在摩尔定律下的硬件飞速发展中脱颖而出的,程序规模的增长最终被硬件的革新速率打败,所以编译和链接不再是程序开发过程中的瓶颈。

解耦机器码编程

回顾历史有利于帮我们厘清概念。在谈高级程序语言是如何解耦之前,我们先回到最初只能用打孔纸带编写程序的年代,那时汇编语言还没有被发明出来,所有的指令和数据都是二进制的字节序列,就像下面这段机器代码。

  1. 地址 指令
  2. 0 0001 0100
  3. 1 ...
  4. 2 ...
  5. 3 ...
  6. 4 1000 0111

第0号地址上的这条指令0001 0100,它的高4位0001表示这是一条跳转指令,低4位0100表示的是跳转的目的地址,即序号4(地址),当机器执行到这个位置时,会自动跳转到第4号地址,执行1000 0111这条指令。

思考一下,这里面有两个问题。

第一是0001这条指令不容易记忆,而且一旦指令序列发生改变(有可能硬件设计时指令改变了),所有用到这条跳转指令的地方都得随之发生改变。

解决这个问题的方式就是用符号(Symbol)替换原来的绝对地址。汇编语言将一些二进制的字节序列替换成了比较容易记忆的符号,比如:jmp表示跳转,divide表示除法子程序的起始地址。这种做法不仅是为了帮助记忆,还可以帮助程序实现解耦。

第二是0100这个目标地址是硬编码的,硬编码目的地址的方式会带来很多麻烦,这就和我们在程序里硬编码魔术数是一样的道理:一旦目的地址发生改变,所有指向这个地址的指令都得随之改变。比如,我们想改动这段代码,在第0号和第4号添加或删除一条指令,那么原来的目的地址就会发生变动。这还只是一种简单的情况,更复杂地,如果这条指令被多处引用,那么操作起来就会更加繁琐。

和解决第一个问题的思路类似,我们用提取变量的重构手法,把原来第4号地址上的1000 0111指令命名为foo这个名称,那么第一条指令的汇编指令就可以表达成如下的简洁形式。

  1. jmp foo

foo这个标记替换掉绝对地址之后,不管这段程序被如何修改,汇编器都会在汇编的过程中,重新计算foo这个符号的地址,然后把所有引用该符号的地方都修正到这个正确的地址上。而此处的修正过程就是链接(Linking)下的重定位(Relocation)。

所以,从程序语言的发展史来看,链接过程其实是先于编译器出现,只不过那时链接重定位的工作是纯人工计算和修改的。随后,汇编语言用符号替换了指令和绝对地址,这时重定位就成了链接加载器的职责。

待续。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注