编译,链接与装载执行
大概是rCore-Tutorial系列文章的第一篇。该tutorial大致教学从从头用rust写一个miniOS,加强一下对操作系统的理解
首先来到迷之补基础环节
编译
最初的问题是来自于当初写go的时候的,因为虽然接触了java,js,python,PHP等一众语言,但编译型语言终究只学了大一时的那一点点C++,因此对go,rust等语言的编译情况产生了简单的困惑。
C/go/rust编译差异
第一个问题是他们编译出的二进制代码的形式和C的关系,因为go和rust似乎有着自己的一套实现,而现代操作系统都是用C实现的,如果go和rust完全不依赖于C,那么就意味着其需要从最底层实现例如IO内存分配等方法,至少要实现到和操作系统交互的系统调用级别。好像听起来就不太科学。。。尤其是对于windows这种不开源底层还乱变的操作系统。。。
实际上,标准库可以说已经是融入到操作系统一级的存在了,其向外提供了操作系统系统调用的接口,C和rust都会老老实实的依赖标准库,go在windows上也会听标准库的。然而go的堆栈结构自己魔改了一下,与传统C的堆栈结构不一致,如果控制流直接跳转到C的库上数据结构会炸,所以整体套了一层goruntime的环境来协调C和go,一开始就是因为这个原因我以为go自己实现了所有底层困惑了半天,最后用ldd命令看动态链接库情况发现链接了一堆动态库上去。。。
总而言之,无论是什么编译型语言,最终都是要符合平台ABI规范的,程序的结构,系统调用,函数调用的约定(这个好像自己实现自己的栈结构就无所谓了,go的栈和C的栈不一致就应该函数调用的情况不一致)什么的都是一致的,否则程序运行不起来。
go在linux上的表现有点狂野,真的自己实现了一套底层到系统调用的代码,完全依赖于本身的goruntime运行,可惜在windows上就狂不起来了。rust虽然也有许多自身的实现,但自身实现的目的是对接标准库的,最终还是依赖于标准库。
说起来,还有一个nm
指令可以查看文件导出的符号表,总之,如果用上述语句去看编译到linux平台的go二进制的话,会看到一个究极巨大的goruntime。。。里面有go自己实现的一大套运行时,rust也有一套自己的对接libc的东西
交叉编译
第二个问题是交叉编译,即在windows平台上编译出linux上的elf文件或是其他平台的文件等。之前我们一直知道,跨平台与跨架构的情况下,二进制文件是无法成功执行的。跨平台的主要问题在于操作系统的系统调用差异和标准库之间的差异,而跨架构则是CPU指令集以及寄存器之间的差异。交叉编译的原理实际上就是实现一个能将高级语言转化到目标架构和平台机器码的编译器罢了,没有什么玄幻高深的原理。
唯一需要考虑的问题在于标准库。现代操作系统都提供了一套标准库对外提供接口与功能,我们编写的程序从未考虑从系统调用层面对硬件进行控制,我们平时使用的似乎是“自带”的函数都是由操作系统提供的标准库提供的。然而,对于同一个操作系统,使用的标准库是可能存在出入的,如linux下的经典glibc与musl,甚至glibc的不同版本之间也会存在一定的兼容性问题。
在普遍的情况下,编译出一个可执行文件一定会用上对应平台的标准库,因此,交叉编译同样通过携带一份目标的标准库,使得用户可以正常的使用标准库携带的功能而不是手搓系统调用。
以rust为例,使用rustc --print target-list
查看rust支持的交叉编译选项,可以看到如下形式的内容
...
mipsel-unknown-linux-uclibc
mipsel-unknown-none
mipsisa32r6-unknown-linux-gnu
mipsisa32r6el-unknown-linux-gnu
mipsisa64r6-unknown-linux-gnuabi64
mipsisa64r6el-unknown-linux-gnuabi64
msp430-none-elf
nvptx64-nvidia-cuda
powerpc-unknown-freebsd
powerpc-unknown-linux-gnu
powerpc-unknown-linux-gnuspe
...
第一列为操作系统,第二列为CPU厂商,第三列为OS,第四列为标准库,可以看到,对应同样架构同样的操作系统,当目标平台上存在不同的标准库时,交叉编译则需要携带对应的标准库方可成功
平台与目标三元组
但是还有一个怪问题,交叉编译还可以进行静态链接,rust如果交叉编译+静态链接的话,似乎会把其自带的标准库打包进去(未考证),go在静态交叉编译到linux平台的时候,会用go自己实现的一套标准库,直接手搓系统调用的那种,但是windows这种系统调用疯狂变化的操作系统,加上时常变动的底层API,感觉自实现系统调用不太现实?可能就打包比较稳定的ntdll进去?否则应该会高强度出现兼容问题(未考证)
链接
编译完了就是链接,比较完整的编译过程应该是把源代码加工到可执行文件范围,编译仅仅是将文件从源码变为目标文件(linux下的.o后缀),而链接则实现了把目标文件组合成可执行文件的环节
编译器在目标文件中导出了文件的符号表,而链接器则将多个目标文件的各个sector组合起来,这会导致原先符号表中导出的地址与实际地址产生差异,因此链接器同样负责最终决定组合完成后各内部变量以及函数的绝对地址(对于位置相关代码),对于位置无关代码则直接塞进GOT表中等待运行时重定位,位置无关代码移步之前文章ASLR与PIC与PIE
装载执行
go在linux下的表现太过狂野,此处以标准C程序为例,可以用readelf -h
来查看可执行文件的头部信息,其中有一项entry标记了程序的入口地址。程序的入口地址并非我们的main函数,而是libc中的_start
函数,该函数会对main方法启动前的环境进行初始化,比如初始化main函数的栈然后把argc argv和env压到栈上之类的。
可以使用objdump -D
查看汇编情况找到_start
函数,然而go的话会找到一个go自己实现的究极runtime环境。。。此处不做考虑,rust的话还是走start,但是后续就乱七八糟的了
由于子进程是由父进程fork出来的,据某位windows大师所言,windows如ntdll等系统模块在每个进程中的地址都是不变的,是由于经典copy on write,因此,每次机器上电开机后,本次机器中所有进程ntdll等模块的地址是不变的
Stack Overflow上有一个回答提到过这个问题
DLL Injection with CreateRemoteThread
参考链接
程序内存布局与编译流程
彻底理解链接器:四,重定位
美团技术团队的文章真不错
高级语言的编译:链接及装载过程介绍