文章目录
- 入口函数
- glibc入口函数
- _start
- __libc_start_main
- MSVC入口函数
- 堆初始化
- IO初始化
- glibc C运行库
- glibc启动文件
- gcc补充C++全局构造与析构
- 运行库对于多线程的改进
- 线程局部存储
入口函数
使用C语言编写的一个hello world程序在用户看来的确非常简单,源代码仅需要短短几行便可实现。但是这个程序在操作系统看来可一点都不复杂,仅从语言层面来看的话,main函数是程序的起点,这没有错。但是从操作系统来看,把main函数当作程序起点是一种误解。执行main函数之前系统已经为我们做了很多初始化工作(以printf库函数可以被使用来说,是因为系统在main函数之前已经为我们做了IO初始化)。main函数返回后系统还需要进行收尾工作,进行进程资源释放。
因此程序真正的起点应该是在调用main函数之前的那些代码,这些代码被称为入口函数或入口点。
程序的完整生命历程
- 进程创建后调用入口函数
- 入口函数完成堆栈初始化、IO初始化、全局变量初始化……
- 入口函数负责调用main函数执行程序主体
- 入口函数等待main返回并做资源释放
Linux环境和Windows环境的入口函数实现各异,但基本思想一致。
glibc入口函数
_start
glibc下的入口函数为_start,其定义由汇编直接给出。_start函数本身并没有做很多的事情,它主要的工作是提取命令行参数的个数和命令行参数+环境变量数组,注意此时的命令行参数和环境变量数组被糅合在一块,它俩分离要在下一步实现。同时它获取了main函数地址和其他2个函数地址(顾名思义这2个函数对于初始化和收尾工作具有重要作用)
在调用_start之前,装载器会把用户的参数和环境变量压入栈中
随后_start会调用一个__libc_start_main函数进行各项初始化工作和main函数调用。
__libc_start_main
__libc_start_main的声明形式如下:
int __libc_start_main(int (*main)(int,char**,char**), //main函数地址int argc, //命令行参数个数char* ubp_av, //参数+环境数组__typeof (main)int, //init函数地址void (*fini)(), //finit函数地址void (*rtld_fini)(), //动态链接收尾函数地址void* stack_end); //栈顶
① __lib_start_main的第一步就是要获取环境变量表的首地址,这一步很简单,ubp_ev偏移量为argc+1处即环境变量表的首地址,确定位置后将全局变量__environ指向该位置
关键代码
char** ubp_ev=&ubp_av[1+argc];
__environ=ubp_ev;
②随后__lib_start_main注册收尾函数并调用init函数进行资源初始化
关键代码
__cxa_atexit(rtld_fini,0,0);
__lib_init_first(argc,argv,__environ);
__cxa_atexit(fini,0,0);
(*init)(argc,argv,__environ);
__cxa_atexit的效果等价于atexit,它们都用于注册收尾函数,注册的收尾函数在main返回时会按照注册的顺序逆向调用,有一个全局函数指针数组用来保存注册的收尾函数,在main返回时通过遍历调用该数组中的函数就可以实现收尾工作。
③调用main并接受其退出结果
关键代码
exit(main(argc,argv,__environ));
由于glibc的入口函数编写的不够清晰,书中省略了glibc中关于各项具体初始化的介绍,但是可以确定的是这些初始化一定是有对应的函数来处理的。
MSVC入口函数
MSVC的入口函数是mainCRTStartup,它没有像glibc中的入口函数一样分2步走,而是一步到位。基本步骤如下
- 获取系统信息
- 堆初始化
- IO初始化
- 获取命令行参数和环境变量
- C标准库设置
- 调用main并返回
堆初始化
堆初始化在代码上实现是最简单,直接调用了一个_heap_init系统调用,之所以堆初始化放在第一位的原因在于后续初始化工作大量借助堆。如果堆初始化失败程序就运行不了。
IO初始化
每一个进程都有私有的打开文件表,打开文件表需要在入口函数中进行初始化操作。
基本任务为
- 建立进程打开文件表
- 决定从父进程继承而来的文件句柄是否保留
- 初始化标准输入输出错误
_cinit 对于全局变量和全局对象的初始化和释放需要牵涉到更多关于运行库的知识,下文以glibc运行库为例
glibc C运行库
一个C程序能够运行,它必须依赖C运行库。上文概况的入口函数属于C运行库的一部分。C运行库由入口函数及其所依赖的函数和大量的C标准库函数构成,它的基本功能如下
- 启动与退出——入口函数
- 标准库函数——printf、scanf……
- 封装IO和堆操作
- 语言特性和调试信息
C标准库是C运行库的主体,C运行库中的crt1.o、crti.o、crtn.o是三个重要的辅助文件,入口函数及其所以来的函数基本都在与这三个辅助文件息息相关。它们三又称为glibc启动文件
glibc启动文件
crt1.o中定义了_start、__libc_main_start函数;crti.o中定义了.init函数的开头;crtn.o定义了.finit函数的结尾;最终生成的可执行文件中会存在.init段和.finit段,顾名思义这两个段就是用于初始化和收尾的,_init函数就定义在.init段,_finit函数就定义在.finit段。_start中所获取的2个函数就是_init和_finit
crti.o和crtn.o只是init和finit函数的一部分,在链接时链接器会合并各个目标文件的.init和.finit段使之拼凑成一个完整的_init和_finit函数
链接器在链接时会自动添加这三个辅助文件
gcc补充C++全局构造与析构
C++全局对象的构造函数和析构函数不放在.init段和.finit段,但却由_init和_finit负责调用,glibc只是一个C运行库,它不能很好的兼容C++,这项任务就交给了gcc。gcc所配置的crtbegin.o和crtend.o就是专门用来扩展C++全局对象构造和析构2个目标文件。
_init的反汇编
全局对象的构造函数(__do_global_ctors_aux)在可执行文件中的.ctor段,其中维护了一个指针数组(__CTOR_LIST),数组中存放的正是main函数之前需要被构造的全局对象析构函数地址。
cpp code eg
//module1.cc
struct A{};
A a;
int main(){return 0;}
//module2.cc
struct B{};
B b;
//module3.cc
struct C{};
C c
gcc编译期间会为每一个编译单元的全局对象生成一个特殊函数,这个特殊函数负责对全局对象的初始化和析构函数绑定。之后编译器为该目标文件生成.ctor段,并构建一个_CTOR_LIST数组用于保存该编译单元内的全局对象构造函数。并且链接阶段由链接器将各个目标文件的.ctor段合并拼凑成一个完整的_CTOR_LIST数组。.crtbeginT.o就是.ctor段的开头,而==.crtend就是.ctor段的末尾==
对于全局对象的析构函数gcc并没有提供类似于.ctor段的.dtor段,而是巧妙地利用了atexit函数进行绑定,atexit所绑定的函数在main返回时会按注册顺序逆向调用,这正好于析构函数应该调用的顺寻相吻合。__tcf_1、__tcf_2、_tcf_3就是在调用全局对象的析构函数
运行库对于多线程的改进
C/C++标准库并不考虑多线程环境,多线程环境的支持主要有运行库额外提供,例如glibc提供的pthread线程库。C/C++标准库中的malloc和printf都是线程不安全的函数,线程库通过加锁实现了malloc和printf的线程安全,使得我们现在在编写多线程C/C++时不需要显示对malloc和printf之类的函数加锁操作。此外线程库还引入了线程局部存储用于定义线程私有的数据。
线程局部存储
所谓线程局部存储即定义在主线程作用域范围内,但每个线程都有该变量副本的一种机制。在gcc下通过关键字__thread声明,msvc下通过__declspec(thread)声明
gcc:
__thread int number;
msvc:
__deslspec(thread) int number;