原文:https://www.cnblogs.com/yueqian-scut/p/3952263.html (有改动)
其它说法:
对C语言有所了解的人都知道main函数是整个程序的入口,但是其实不然,在内核中可以使用链接器来设置程序的开始地方。当内核使⽤⼀个exec函数执⾏C程序时,在调⽤main函数之前先调⽤⼀个特殊的启动例程,可执⾏程序将此例程指定为程序的起始地址。启动例程从内核获取命令⾏参数和环境变量,然后为调⽤main函数做好准备。
前面我们关注的是程序开始进入时的调用函数,而atexit函数是一个特殊的函数,它是在正常程序退出时调用的函数,我们把他叫为登记函数.⼀个进程可以登记若⼲个(具体⾃⼰验证⼀下)个函数,这些函数由exit⾃动调⽤,这些函数被称为终⽌处理函数, atexit函数可以登记这些函数。 exit调⽤终⽌处理函数的顺序和atexit登记的顺序相反(网上很多说造成顺序相反的原因是参数压栈造成的,参数的压栈是先进后出,和函数的栈帧相同),如果⼀个函数被多次登记,也会被多次调⽤。
(https://www.cnblogs.com/cthon/p/9196723.html)
(下面这2条也是运行时库提供的功能,下文第四节有讲到)
1. 在main之前会先初始化全局变量,如果这些全局变量是某些函数的返回值的话,会先调用这些函数
2. 还可以用有些编译器扩展提供的相关机制,例如GNUC中,可以指定一个函数的属性为constructor,使其在main之前执行(同理设置deconstructor属性可以在main之后执行):
static __attribute__((constructor)) void f()
{
printf("f\n");
}
(https://www.bilibili.com/read/cv23445740/)
一、引言
本文介绍运行时库实现的功能,拉起一个进程首先是把控制权交给运行时库的入口函数 _startEntryPoint 而不是main函数,你会看到在main函数执行之前都做了什么。
在编译生成elf格式文件(.o文件,动态/静态库,可执行文件都属于elf文件)的时候就已经确定好了entry point函数的地址,加载器把程序载入内存时这个函数就被放到这个地址(我猜),同理其他函数,包括静态库函数也有自己的地址,当调用这些函数式会把他们的地址和入参压到栈中。
而动态库因为在链接的时候没有链接进去,所以在调用动态库中的函数时,动态加载器动态分配一段进程地址空间,将动态库加载到该地址空间后,再修改代码段的符号地址。
至于需要修改的哪些地址,链接器在动态库的文件头中预先写好,供加载器读取修改,动态库的重定位节举例如下:
以上每一项对应着代码段中的一处重定位:在代码段的Offset处,进行Type类型的转换。这就是载入时重定位的基本概念和过程。
(但是这样实现重定位会导致各个不同的进程都在自己的内存空间中有同一份动态库的代码的copy,这样动态链接就失去了意义,为了解决这个问题,在gcc编译动态库的时候要加上 -fPIC 选项,详见上面的链接. eg: https://www.cnblogs.com/zzqcn/p/3640353.html)
先来理解以下代码:
#include <stdio.h>
void init(void) __attribute__((constructor));
void init(void){
printf(“before enter main!\n”);
}
void exit_func(void){
printf(“after leave main!\n”);
}
int main(void){
int *m = malloc(5, sizeof(int));
atexit(exit_fun);
printf(“hello world!\n”);
}
思考:
1 程序的运行结果是?
2进入main之前做了哪些操作,如果进入main?
3 如何支持printf函数?
4 退出main之后做了什么事情?
5 如何MALLOC和FREE等堆操作?
6 程序会造成内存泄露吗?
7 等等
二、运行时库概述
任何一个C/C++程序,它的背后都有一套庞大的代码来进行支撑,以使得该程序能够正常运行。这套代码至少包括入口函数,及其依赖的函数所构成的函数集合。当然,它还应该包括各种标准函数(如字符串,数学运算等)的实现。一般的程序运行过程如下:
1.操作系统创建进程后,把控制权交给程序的入口函数(gcc –e (_startEntryPoint)), 这个函数往往是运行时库的某个入口函数。 glibc 的入口函数是_start,
msvc(vc6.0)是mainCRTStartup
2. 入口函数对运行库和程序运行环境进行初始化,包括堆,I/O,线程,全局变量构造(constructor)等。
3. 调用MAIN函数,正式开始执行程序主体。
4. 执行MAIN完毕,返回入口函数,进行清理工作,包括全局变量析构,堆销毁,关闭I/O等,然后进行系统调用介绍进程
三、运行时库主要功能模块
1.启动与退出,包括入口函数及其依赖函数
2.标准函数,C语言标准规定的标准函数
3.I/O,I/O功能的封装和实现,如提供PRINT
4.堆,堆的封装和实现
5.调试支持等
四、程序详细运行过程
以下分析默认为WINDOWS静态链接过程。
1. 程序执行前装载器会把用户的参数和环境变量压入栈,接着操作系统把控制权交给mainCRTStartup入口函数。
用户的参数:对应int main(int argc,char *argv[])
环境变量:系统公用数据,系统搜索路径等等。
程序需要获取用户参数和环境变量均是 从栈上获取,需要理解栈帧的概念。
2. 初始化和OS版本相关的全局变量
3. 初始化堆,每个进程都有属于自己的堆。它是一次性从系统中申请一块比较大的虚拟空间(实际需要时(如malloc)才会映射到物理页),以后在进程中由库的堆管理算法来维护这个堆。当堆不够用时再继续申请一块大的虚拟空间继续分配。 可见,并非程序每次malloc都会调用系统API(API调用比较耗时,涉及到用户态到内核态的上下文切换),效率比较高.
堆相关操作:
HeapCreate:创建一个堆,最终会调用virtualAlloc()系统API函数去创建堆。
HeapAlloc: malloc会调用该函数
HeapFree: free会调用该函数
HeapDestroy:摧毁一个堆
4. I/O初始化,继承父进程打开文件表。可见,子进程是可以访问父进程打开的文件。如果父进程没有打开标准的输入输出,该进程会初始化标准输入输出。即初始化以下指针变量:stdin,stdout,stderr.它们都是FILE类型指针。在linux和windows中,打开文件对应于操作一个内核对象,其处于内核态,因此用户态是不能直接操作该内核对象的。用户只能操作与内核对象相关联的FILE结构指针。对应关系是:
Printf其实是调用stdout指针在屏幕上输出
#define printf(args…) fprintf(stdout, ##args)
Args…表示变长输入参数。用以下四个宏根据栈来获取。
Va_list、Va_start、Va_arg、Va_end
5. 获取命令行参数和环境变量
7. 全局变量构造,如各个全局类对象的构造函数调用和标记 __attribute__((constructor))属性的各个函数。它们都应该在进入main前进行调用。
需要运行时库和C/C++编译器、连接器的配合才能实现这个功能。
1)编译器编译某个.cpp(设为main.cpp)文件时,会将所有的构造函数实现作为一个整体放到.init段,把析构函数实现放到.finit段,然后在.ctors段放置.init段的地址(该地址即是该文件的各个构造函数的总入口)。
2)运行时库有一个库是crtbegin.o,它的.ctors段放置的内容为-1,ctrend.o,它的.ctors段放置的内容也是-1。
3)用链接器进行连接:ld crtbegin.o main.o crtend.o一定要按这种顺序,否则出错。链接后的.ELF文件是将以上各个文件的.init/.finit/.ctors等段分别合并。当然.data/.text段也会相应合并。
全局变量构造时即是遍历.ctors段的内容,从-1(crtbegin.o)开始,再到-1(crtend.o)结束,中间每四个字节即是各个文件的构造入口函数指针,如果非0,即进行调用。
8. 注册析构函数
为了支持C++类的析构函数,和标记 __attribute__((deconstructor))属性的各个函数在main之后会被调用,而且是按构造的相反顺序进行调用,同样需要编译器和链接器以及运行时库的支持,原理跟构造相仿。只是为了逆序,使用了
atexit注册各个虚构函数,注册时在链表头插入链接,main退出以后也从链表头开始获取链表函数,并进行调用。(参考:
atexit函数详解)
9. 执行函数主体。
调用main函数执行,等待返回。在这里可以用到之前已经初始化的各种资源,如I/O, 堆申请释放等等
10. 调用析构函数
11. 释放堆
12. 释放其他资源
13. 调用exit系统API退出进程
五、回答引言的问题。
1. 参考以上分析, 程序的打印结果是:
before enter main!
hello world!
after leave main!
2. 程序并不会产生系统内存泄漏。进程退出,其会摧毁整个堆。所谓内存泄露是指在进程的运行中,不恰当、不合理地申请内存,但没有释放内存。