操作系统装载程序之后,首先运行的代码并不是main
的第一行,而是某些别的代码,这些代码负责准备好main
函数执行所需要的环境,并且负责调用main
函数,这时候你才可以在main
函数里放心大胆地写各种代码:申请内存、使用系统调用、触发异常、访问I/O。在main
返回之后,它会记录main
函数的返回值,调用atexit
注册的函数,然后结束进程。
运行这些代码的函数称为入口函数或入口点(Entry Point),视平台的不同而有不同的名字。程序的入口点实际上是一个程序的初始化和结束部分,它往往是运行库的一部分。一个典型的程序运行步骤大致如下:
main
函数,正式开始执行程序主体部分。main
函数执行完毕以后,返回到入口函数,入口函数进行清理工作,包括全局变量析构、堆销毁、关闭I/O等,然后进行系统调用结束进程。GLIBC入口函数
glibc是GNU发布的libc库,即c运行库,是linux系统中最底层的api,几乎其它任何运行库都会依赖于glibc。glibc的启动过程在不同的情况下差别很大,比如静态的glibc和动态的glibc的差别,glibc用于可执行文件和用于共享库的差别,这样的差别可以组合出4种情况,这里只选取最简单的静态glibc用于可执行文件的时候作为例子。
可以在这里下载glibc源码,源码的glibc/sysdeps
目录下有各个架构对入口函数的实现。glibc的程序入口为_start
(这个入口是由ld链接器默认的链接脚本所指定的,也可以通过相关参数设定自己的入口)。_start
由汇编实现,并且和平台相关,比如i386的_start
在glibc/sysdeps/i386/start.S
中,以下是选取其中的部分代码:
ENTRY (_start)
/* %ebp清零,表明这是尊贵的最外层函数 */
xorl %ebp, %ebp
/* 调用_start之前装载器会把用户的参数和环境变量压入栈中,栈中结构存放如下:
|bot|...| 0 |envn|...|env0| 0 |argn|...|arg0|argc;top|
----------------环境变量-------------参数-----------stack growth----> */
popl %esi /* %esi指向argc. */
movl %esp, %ecx /* %ecx执行argv数组和evn数组的首地址.*/
/* 将调用__libc_start_main所需的参数压入栈 */
andl $0xfffffff0, %esp
pushl %eax
pushl %esp
pushl %edx
/* This used to be the addresses of .fini and .init. */
pushl $0
pushl $0
pushl %ecx /* Push second argument: argv. */
pushl %esi /* Push first argument: argc. */
pushl $main
call __libc_start_main
END (_start)
环境变量:是存在于系统中的一些公用数据,任何程序都可以访问。通常来说,环境变量存储的都是一些系统的公共信息,例如系统搜索路径、当前OS版本等。环境变量的格式为key=value
的字符串,C语言里可以使用getenv
这个函数来获取环境变量信息。
现在可以看到_start
只是对__libc_start_main
函数做了一个调用,__libc_start_main
函数实现在glibc/csu/libc-start.c
中,以下选取部分代码:
define LIBC_START_MAIN __libc_start_main
STATIC int
LIBC_START_MAIN (int (*main) (int, char **, char ** MAIN_AUXVEC_DECL),
int argc, char **argv,
__typeof (main) init,
void (*fini) (void),
void (*rtld_fini) (void), void *stack_end){
/* 让__environ指针指向环境变量数组的首地址 */
char **ev = &argv[argc + 1];
__environ = ev;
/* 一些关键函数调用的罗列 */
__pthread_initialize_minimal ();
__cxa_atexit ((void (*) (void *)) rtld_fini, NULL, NULL);
__libc_init_first (argc, argv, __environ);
__cxa_atexit (call_fini, NULL, NULL);
(*init) (argc, argv, __environ MAIN_AUXVEC_PARAM);
__libc_start_call_main (main, argc, argv MAIN_AUXVEC_PARAM);
}
共有7个参数(现在的版本不止有7个,这里都是按照原书的内容介绍的):
argv
也包括了环境变量数组的指针init
:main函数调用前的初始化工作fini
:main函数调用后收尾工作rtld_fini
:和动态加载有关的收尾工作,runtime loader。stack_end
:标明了栈低的地址,即最高栈地址函数调用中,__cxa_atexit
函数用于将指定参数的函数(rtld_fini
和call_fini
)在main结束之后调用,末尾__libc_start_call_main
开始对main函数的调用,最终会通过_exit
调用系统调用exit()
,退出进程。
一个程序的I/O指代了程序与外界的交互,包括文件、管道、网络、命令行、信号等。更广义地讲,I/O指代任何操作系统理解为”文件”的事务。许多操作系统,包括Linux和Windows,都将各种具有输入和输出概念的实体----包括设备、磁盘文件、命令行等----统称为文件,因此这里所说的文件是一个广义的概念。
对于一个任意类型的文件,操作系统会提供一组操作函数,这包括打开文件、读文件、写文件、移动文件指针等。C语言文件操作是通过一个FILE
结构的指针来进行的,fopen
返回一个FILE
指针,其他函数使用这个指针操作。
#include
int main()
{
FILE *fp = NULL;
fp = fopen("/tmp/test.txt", "w+");
fprintf(fp, "This is testing for fprintf...\n");
fputs("This is testing for fputs...\n", fp);
fclose(fp);
}
在操作系统层面上,文件操作也有类似于FILE
的一个概念,在Linux里,这叫做文件描述符(File Descriptor),而在Windows里,叫做句柄(Handle)(以下在没有歧义的时候统称为句柄)。用户通过某个函数打开文件以获得句柄,此后用户操作文件皆通过该句柄进行。
设计这么一个句柄的原因在于句柄可以防止用户随意读写操作系统内核的文件对象。无论是Linux还是Windows,文件句柄总是和内核的文件对象相关联的,但如何关联细节用户并不可见。内核可以通过句柄来计算出内核里文件对象的地址,但此能力并不对用户开放。
举一个实际例子,Linux中,fd值为0、1、2时分别代表标准输入、标准输出和标准错误输出,所以在程序中打开文件得到的fd是从3开始增长。fd是什么呢?在内核中,每个进程都有一个私有的“打开文件表”,这个表是一个指针数组,每一个元素都指向一个内核的打开文件对象。而fd,就是这个表的下表。当用户打开一个文件时,内核会在内部生成一个打开文件对象,并在这个表里找到一个空项,让这一项指向生成的打开文件对象,并返回这一项的下标作为fd。由于这个表处于内核,用户无法访问到,因此用户就算有fd也不能直接得到打开文件对象的地址,只能通过系统提供的函数来操作。
FILE、fd、打开文件表和打开文件对象的关系如下图所示,内核指针p指向该进程的打开文件表,所以只要有fd,就可以通过p + fd
来得到打开文件表的某一项地址。stdin、stdout、stderr均是FILE的指针。
所以了解了IO机制之后,再来看I/O初始化的职责:首先I/O初始化函数需要在用户空间中建立stdin、stdout、stderr及其对应的FILE结构,使得程序进入main之后可以直接使用printf、scanf等函数。
C语言运行库:任何一个C程序,它的背后都有一套庞大的代码来进行支撑,以使得该程序能够正常运行。这套代码至少包括入口函数,及其所依赖的函数所构成的函数集合。当然,它还理应包括各种标准库函数的实现。这样的一个代码集合称之为运行时库(Runtime Library)。而C语言的运行库,即被称为C运行库(CRT)。一个C语言运行库大致包含了如下功能:
美国国家标准协会(American National Standards Institute, ANSI)在1983年成立了一个委员会,旨在对C语言进行标准化,此委员会所建立的C语言标准被称为ANSI C。
第一个完整的C语言标准建立于1989年,此版本的C语言标准称为C89。在C89标准中,包含了C语言基础函数库,由C89指定的C语言基础函数库就称为ANSI C标准运行库(简称标准库)。其后在1995年C语言标准委员会对C89标准进行了一次修订,在此次修订中,ANSI C标准库得到了第一次扩充,头文件iso646.h、wchar.h和wctype.h加入了标准库的大家庭。
在1999年,C99标准诞生,C语言标准库得到了进一步的扩充,头文件complex.h、fenv.h、inttypes.h、stdbool.h、stdint.h和tgmath.h进入标准库。
C11标准是C语言标准的第三版,前一个标准版本是C99标准。C11标准中又新增了5个头文件stdalign.h、stdatomic.h、stdnoreturn.h、threads.h、uchar.h。至此,C标准函数库共29个头文件。除了之前的14个头文件,剩下的15个头文件(C89标准)为:assert.h、ctype.h、errno.h、float.h、limits.h、locale.h、math.h、setjmp.h、signal.h、stdarg.h、stddef.h、stdio.h、stdlib.h、string.h、time.h。
关于每个头文件的介绍可以参考:C library或C库函数手册
运行库是平台相关的,因为它与操作系统结合得非常紧密。C语言的运行库从某种程度上来讲是C语言的程序和不同操作系统平台之间的抽象层,它将不同的操作系统API抽象成相同的库函数。比如我们可以在不同的操作系统平台下使用fread
来读取文件,而事实上fread
在不同的操作系统平台下的实现是不同的,但作为运行库的使用者我们不需要关心这一点。Linux和Windows平台下的两个主要C语言运行库分别为glibc(GNU C Library)和MSVCRT(Microsoft Visual C Run-time)。
值得注意的是,像线程操作这样的功能并不是标准的C语言运行库的一部分,但是glibc和MSVCRT都包含了线程操作的库函数。比如glibc有一个可选的pthread库中的pthread_create()
函数可以用来创建线程;而MSVCRT中可以使用_beginthread()
函数来创建线程。所以glibc和MSVCRT事实上是标准C语言运行库的超集,它们各自对C标准库进行了一些扩展。
glibc
glibc即GNU C Library,是GNU旗下的C标准库。最初由自由软件基金会FSF(Free Software Foundation)发起开发,目的是为GNU操作系统开发一个C标准库。
glibc的发布版本主要由两部分组成,一部分是头文件,比如stdio.h、stdlib.h等,它们往往位于/usr/include
;另外一部分则是库的二进制文件部分。二进制部分主要的就是C语言标准库,它有静态和动态两个版本,动态的标准库是/lib/xxx/libc.so.6
,静态标准库为/usr/lib/xxxlibc.a
。事实上glibc除了C标准库之外,还有几个辅助程序运行的运行库,这几个文件可以称得上是真正的”运行库”,它们就是/usr/lib/xxx/crt1.o
、/usr/lib/xxx/crti.o
、/usr/lib/xxx/crtn.o
,虽然它们都很小,但这几个文件都是程序运行的最关键的文件。
glibc 启动文件
crt1.o
里面包含的就是程序的入口函数_start
,由它负责调用__libc_start_main
初始化libc并且调用main函数进入真正的程序主体。
C++必须要在main()
函数之前执行全局/静态对象构造和必须要在main()
函数之后执行全局/静态对象析构,为了满足此类需求,运行库在每个目标文件(ELF)中引入两个初始化相关的段”.init
”和”.finit
”,运行库会保证所有位于这两个段的代码先于/后于main()
函数执行。链接器在进行链接时,会把所有输入目标文件中的”.init
”和”.finit
”按照顺序收集起来,然后将他们合并成输出文件的”.init
”和”.finit
”。但是这两个输出段中所包含的指令,还需要一些辅助的代码帮助他们启动,于是引入了两个目标文件crti.o
和crtn.o
。
crti.o
和crtn.o
两个目标文件中包含的代码实际上是_init()
函数和_finit()
函数的开始和结尾部分,当这两个文件和其它目标文件按照顺序链接起来以后,刚好形成两个完整的函数_init()
和_finit()
,所以最终输出文件中的”.init
”和”.finit
”两个段实际上分别包含的是_init()
和_finit()
函数。可以用objdump查看这两个文件的反汇编代码:
$ objdump -dr /usr/lib/mips64el-linux-gnuabi64/crti.o
/usr/lib/mips64el-linux-gnuabi64/crti.o: 文件格式 elf64-tradlittlemips
Disassembly of section .init:
0000000000000000 <_init>:
0: 67bdfff0 daddiu sp,sp,-16
4: ffbc0000 sd gp,0(sp)
8: 3c1c0000 lui gp,0x0
8: R_MIPS_GPREL16 _init
8: R_MIPS_SUB *ABS*
8: R_MIPS_HI16 *ABS*
c: 0399e02d daddu gp,gp,t9
10: ffbf0008 sd ra,8(sp)
14: 679c0000 daddiu gp,gp,0
14: R_MIPS_GPREL16 _init
14: R_MIPS_SUB *ABS*
14: R_MIPS_LO16 *ABS*
18: df820000 ld v0,0(gp)
18: R_MIPS_GOT_DISP __gmon_start__
18: R_MIPS_NONE *ABS*
18: R_MIPS_NONE *ABS*
1c: 10400004 beqz v0,30 <_init+0x30>
20: 00000000 nop
24: df990000 ld t9,0(gp)
24: R_MIPS_CALL16 __gmon_start__
24: R_MIPS_NONE *ABS*
24: R_MIPS_NONE *ABS*
28: 0320f809 jalr t9
28: R_MIPS_JALR __gmon_start__
28: R_MIPS_NONE *ABS*
28: R_MIPS_NONE *ABS*
2c: 00000000 nop
Disassembly of section .fini:
0000000000000000 <_fini>:
0: 67bdfff0 daddiu sp,sp,-16
4: ffbc0000 sd gp,0(sp)
8: 3c1c0000 lui gp,0x0
8: R_MIPS_GPREL16 _fini
8: R_MIPS_SUB *ABS*
8: R_MIPS_HI16 *ABS*
c: 0399e02d daddu gp,gp,t9
10: ffbf0008 sd ra,8(sp)
14: 679c0000 daddiu gp,gp,0
14: R_MIPS_GPREL16 _fini
14: R_MIPS_SUB *ABS*
14: R_MIPS_LO16 *ABS*
$ objdump -dr /usr/lib/mips64el-linux-gnuabi64/crtn.o
/usr/lib/mips64el-linux-gnuabi64/crtn.o: 文件格式 elf64-tradlittlemips
Disassembly of section .init:
0000000000000000 <.init>:
0: dfbf0008 ld ra,8(sp)
4: dfbc0000 ld gp,0(sp)
8: 03e00008 jr ra
c: 67bd0010 daddiu sp,sp,16
Disassembly of section .fini:
0000000000000000 <.fini>:
0: dfbf0008 ld ra,8(sp)
4: dfbc0000 ld gp,0(sp)
8: 03e00008 jr ra
c: 67bd0010 daddiu sp,sp,16
于是在最终链接完成之后,输出的目标文件中的”.init”
段只包含了一个函数_init()
,这个函数的开始部分来自于crti.o
的”.init
”段,结束部分来自于crtn.o
的”.init
”段。为了保证最终输出文件中的”.init
”和”.finit
”的正确性,我们必须保证在链接时,crti.o
必须在用户目标文件和系统库之前,而crtn.o
必须在用户目标文件和系统库之后。链接器的输入文件顺序一般是:
ld crt1.o crti.o [user_objects] [system_libraries] crtn.o
由于crt1.o
不包含”.init
”段和”.finit
”段,所以不会影响最终生成”.init
”和”.finit
”段时的顺序。
在默认情况下,ld链接器会将libc、crt1.o
等这些CRT和启动文件与程序的模块链接起来,但是有些时候,我们可能不需要这些文件,或者希望使用自己的libc和crt1.o
等启动文件,以替代系统默认的文件,这种情况在嵌入式系统或操作系统内核编译的时候很常见。GCC提供了两个参数”-nostartfile
”和”-nostdlib
”,分别用来取消默认的启动文件和C语言运行库。
其余部分感觉暂时用不到,以后如果用到在补充