分析Linux镜像的加载与启动流程前,先看一下嵌入式操作系统的启动流程,以RT-thread系统为例。
RTOS系统启动流程
rtthread系统中,上电冷启动之后会执行固化到ROM中的代码,主要是ARM芯片必要的初始化,之后跳转到flash镜像区域执行镜像启动代码。一般启动地址是一张中断向量表,向量表中的第一项是一个栈起始地址,第二项是复位中断函数Reset_Handler的地址。Linux中断编程 这篇文章有提过二次跳转,这个就是二次跳转。
Reset_Handler函数流程:
设置栈指针MSP,通过ldr汇编指令实现:ldr sp, =_estack;
初始化中断向量,对应函数LoopCopyVectorInit;
初始化data段,对应函数LoopCopyDataInit;如果是支持C++的rtos系统,这里会初始化全局/静态变量;
初始化bss段,对应函数LoopFillZerobss;
初始化系统时钟,跳转到函数SystemInit;
然后跳转到main函数,main里面会初始化硬件驱动,配置动态内存池、任务数队列数等,之后配置Tick时钟中断向量周期中断启动调度
Linux镜像启动流程
《程序员的自我修养》第三章《目标文件的格式》介绍了ELF文件结构,跟嵌入式系统bin镜像结构类似:
内核(操作系统)首先读ELF文件的头部,然后根据头部的数据指示分别读入各种数据结构,找到标记为可加载(loadable)的段(也就是text段和data段),并调用函数mmap()把段内容加载到内存中,加载之前内核会检查该段可读可写权限。引申一下,被static修饰的元素所有对象仅此一份,存储在data段,虚函数表(函数数组也是数组)也存在rodata段。此外需要理解一点,bin文件在磁盘中的布局并不是内存中的布局,比如bss段在文件中就不存在,仅仅是在ELF header中有一个字段标识bss段大小而已(节省文件空间),但是bin文件加载到内存以后就要为bss段真实分配空间。
接下来内核从标记为PT_INTERP的段中所对应的动态连接器名称(也就是一串字符串),并加载动态连接器,通常是/lib/ld-linux.so.2(动态连接器自己也是动态链接库,因此需要自己实现自举功能),动态链接通过GOT段实现,动态连接机制是ELF格式代替a.out格式的决定性原因。
golang一般使用静态链接,所以golang的elf格式中是没有PT_INTERP类型的段的。
内核在新进程的堆栈中设置一些信息,包括可执行文件有几个段、程序的入口地址等,还有进程执行环境和命令行参数等信息供动态连接器使用,之后内核把控制权传递给动态连接器。
动态连接器读取.dynamic段检查程序对外部文件(共享库)的依赖性,并在需要时对其进行加载。之后对程序的外部引用进行重定位,就是告诉程序其引用的外部变量/函数的地址,此地址位于共享库被加载在内存的区间内。动态连接还有一个延迟(Lazy)定位的特性,即只在"真正"需要引用符号时才重定位,这对提高程序运行效率有极大帮助。
动态连接器执行ELF文件中.init段的代码,进行程序运行的初始化。也就是全局/静态对象构造函数(为支持C++)、__attribute((constructor))标记的函数。另外说一句,init段对应还有结束段.fini,用于main函数结束后调用全局对象析构函数、atexit注册的收尾函数。
动态连接器把控制传递给程序,从 ELF 文件头部中定义的程序进入点开始执行。
nux下无法双击打开,反之也一样
再引申一下,windows下的进程在Linux下无法双击打开,反之也一样,但是C语言编写的程序却可以跨平台编译,在Linux或windows下编译都可以执行(前提是不调用系统有关的系统调用函数)。像这种情况叫做二进制不兼容,也即ABI不同。ABI会规定底层的调用和参数传递顺序,二进制文件布局的具体格式,也就是规定了进程做系统调用时函数参数该以何种顺序压入堆栈,该如何进行系统调用(linux和windows陷入系统调用的方式不一样)。这些都导致exe格式的 文件无法在Linux系统下运行。