05-计算机指令:让我们试试用纸带编程
在软硬件接口中,CPU帮我们做了什么事h2>
-
从硬件的角度来看,CPU就是一个超大规模集成电路,通过电路实现了加法、乘法乃至各 种各样的处理逻辑。
-
从软件工程师的角度来讲,CPU就是一个执行各种计算机指令的逻辑机器。
计算机指令(Instruction Code), CPU支持的语言,就是计算机指令 集,英文叫Instruction Set。
从编译到汇编,代码怎么变成机器码h2>
从硬件的角度来看,CPU就是一个超大规模集成电路,通过电路实现了加法、乘法乃至各 种各样的处理逻辑。
从软件工程师的角度来讲,CPU就是一个执行各种计算机指令的逻辑机器。
计算机指令(Instruction Code), CPU支持的语言,就是计算机指令 集,英文叫Instruction Set。
我们需要把整个程序翻译成一个汇编语言的程序,这个过程我们一般叫编译成汇编代码。 针对汇编代码,我们可以再用汇编器翻译成机器码
汇编语言(ASM,Assembly Language),编译(Compile),汇编器(Assembler),机器码(Machine Code)
解析指令和机器码
常见的指令分为五大类。
- 第一类是算术类指令。我们的加减乘除,在CPU层面,都会变成一条条算术类指令。
- 第二类是数据传输类指令。给变量赋值、在内存里读写数据,用的都是数据传输类指令。
- 第三类是逻辑类指令。逻辑上的与或非,都是这一类指令。
- 第四类是条件分支类指令。日常我们写的“if/else”,其实都是条件分支类指令。
- 第五类是无条件跳转指令。写一些大一点的程序,我们常常需要写一些函数或者方法。在调用函数的时 候,其实就是发起了一个无条件跳转指令
MIPS的指令是一个32位的整数,高6位叫操作码(Opcode),也就是代表这条指令具体是一条什么样的指 令,剩下的26位有三种格式,分别是R、I和J。
我以一个简单的加法算术指令add $t0, $s1, $s2,为例
对应的MIPS指令里opcode是0,rs代表第一个寄存器s1的地址是17,rt代表第二个寄存器s2的地址是18,rd 代表目标的临时寄存器t0的地址,是8。因为不是位移操作,所以位移量是0。
循环结构本质上是条件判断+跳转。for是先初始化计步器后跳转到循环体,循环体结尾有判断+跳转。dowhile则不需要先跳转到循环体这一步
压栈有函数调用完成后的返回地址,以及原函数的上下文。整个函数A所占用的所有内存空间,就是函数A的栈帧(Stack Frame)。
总结延伸
通过加入了程序栈,我们可以在指令跳转的过程中,记忆断点位置能够实现更加丰富和灵活的指令执行流程。这个也为我们提供了“函数”这样一个抽象,使得我们可以复用代码和指令
08 | ELF和静态链接:为什么程序无法同时在Linux和Windows下运行h1>
编译、链接和装载:拆解程序执行
“C语言代码-汇编代码-机器码” 这个过程,是由两部分组成的。
第一个部分由编译、汇编,链接三个阶段组成。在这三个阶段完成之后,我们就生成了一个可执行文件。
第二部分,我们通过装载器把可执行文件装载到内存中。CPU从内存中读取指令和数据,来开始真正执行程序。
编译(Compile)、汇编(Assemble)以及链接(Link)装载器(Loader)装载(Load)
大部分程序还有这么一些Section:
- 首先是.text Section,也叫作代码段或者指令段(Code Section),用来保存程序的代码和指令;
- 接着是.data Section,也叫作数据段(Data Section),用来保存程序里面设置好的初始化数据信息;
- 然后就是.rel.text Secion,叫作重定位表(Relocation Table)。重定位表里,保留的是当前的文件里面,哪些跳转地址其实是我们不知道的。比如上面的 link_example.o 里面,我们在main函数里面调用了 add 和 printf 这两个函数,但是在链接发生之前,我们并不知道该跳转到哪里,这些信息就会存储在重定位表里;
- 最后是.symtab Section,叫作符 表(Symbol Table)。符 表保留了当前文件里面定义的函数名称和对应地址的地址簿。
链接器会扫描所有输入的目标文件,然后把所有符 表里的信息收集起来,构成一个全局的符 表。然后再根据重定位表,把所有不确定要跳转地址的代码,根据符 表里面存储的地址,进行一次修正。最后,把所有的目标文件的对应段进行一次合并,变成了最终的可执行代码。这也是为什么,可执行文件里面的函数调用的地址都是正确的。
连接器会整合各个符 表
-
问题:产生外部碎片(Memory Fragmentation)的问题。
-
解决:内存交换。 先把内存移到硬盘再整合回内存
- 问题:硬盘速度慢,即使程序比较大也不能拆分只能整个交换所以会“卡”
内存碎片(Memory Fragmentation)内存交换(Memory Swapping)
内存分页
分页是把整个物理内存空间切成一段段固定尺寸的大小**。
地址无关很重要,相对地址解烦恼
我们编译出来的共享库文件的指令代码,是地址无关码(Position-Independent Code)。这段代码,无论加载在哪个内存地址,都能够正常执行
于所有动态链接共享库的程序来讲,虽然我们的共享库用的都是同一段物理内存地址,但是在不同的应用程序里,它所在的虚拟内存地址是不同的(基址不同)。
- 问题:每个程序都使用各自的页表,虚拟内存,以及对应物理内存的基址。共享库的物理地址是固定的,而且需要被公共访问
- 问题:我们要怎么样才能做到,动态共享库编译出来的代码指令,都是地址无关码呢li>
- 解决:动态代码库内部的变量和函数调用都很容易解决,我们只需要使用相对地址(Relative Address)就好了。给共享库预留一段连续的虚拟内存地址中的,程序再什么位置都能指向共享库。
PLT和GOT,动态链接的解决方案
首先,lib.h 定义了动态链接库的一个函数 show_me_the_money。
lib.c包含了lib.h的实际实现
然后,show_me_poor.c 调用了 lib 里面的函数。
最后,我们把 lib.c 编译成了一个动态链接库,也就是 .so 文件。
在编译的过程中,我们指定了一个 -fPIC 的参数。这个参数其实就是Position Independent Code的意思,也就是我们要把这个编译成一个地址无关代码。
我们再通过gcc编译show_me_poor 动态链接了lib.so的可执行文件。
们把show_me_poor这个文件通过objdump出来看一下。
在动态链接对应的共享库,我们在共享库的data section里面,保存了一张全局偏移表(GOT,Global Offset Table)。**虽然共享库的代码部分的物理内存是共享的,但是数据部分是各个动态链接它的应用程序里面各加载一份的。**所有需要引用当前共享库外部的地址的指令,都会查询GOT,来找到当前运行程序的虚拟内存里的对应位置。而GOT表里的数据,则是在我们加载一个个共享库的时候写进去的。
不同的进程,调用同样的lib.so,各自GOT里面指向最终加载的动态链接库里面的虚拟内存地址是不同的。
这样,虽然不同的程序调用的同样的动态库,各自的内存地址是独立的,调用的又都是同一个动态库,但是不需要去修改动态库里面的代码所使用的地址,而是各个程序各自维护好自己的GOT,能够找到对应的动态库就好了。
我们的GOT表位于共享库自己的数据段里。GOT表在内存里和对应的代码段位置之间的偏移量,始终是确定的。这样,我们的共享库就是地址无关的代码,对应的各个程序只需要在物理内存里面加载同一份代码。而我们又要通过各个可执行程序在加载时,生成的各不相同的GOT表,来找到它需要调用到的外部变量和函数的地址。
这是一个典型的、不修改代码,而是通过修改“地址数据”来进行关联的办法。
总结延伸
这一讲,我们终于在静态链接和程序装载之后,利用动态链接把我们的内存利用到了极致。同样功能的代码生成的共享库,我们只要在内存里面保留一份就好了。这样,我们不仅能够做到代码在开发阶段的复用,也能做到代码在运行阶段的复用。
声明:本站部分文章及图片源自用户投稿,如本站任何资料有侵权请您尽早请联系jinwei@zod.com.cn进行处理,非常感谢!