• 链接装载与库:第十一章——运行库


    一、入口函数和程序初始化

    1.1 程序从main函数开始吗

    操作系统装载程序之后,首先运行的代码并不是main的第一行,而是某些别的代码,这些代码负责准备好main函数执行所需要的环境,并且负责调用main函数,这时候你才可以在main函数里放心大胆地写各种代码:申请内存、使用系统调用、触发异常、访问I/O。在main返回之后,它会记录main函数的返回值,调用atexit注册的函数,然后结束进程。

    运行这些代码的函数称为入口函数入口点(Entry Point),视平台的不同而有不同的名字。程序的入口点实际上是一个程序的初始化和结束部分,它往往是运行库的一部分。一个典型的程序运行步骤大致如下:

    • 操作系统在创建进程后,把控制权交到了程序的入口,这个入口往往是运行库中的某个入口函数。
    • 入口函数对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量构造,等等。
    • 入口函数在完成初始化之后,调用main函数,正式开始执行程序主体部分。
    • main函数执行完毕以后,返回到入口函数,入口函数进行清理工作,包括全局变量析构、堆销毁、关闭I/O等,然后进行系统调用结束进程。

    1.2 入口函数如何实现

    GLIBC入口函数

    glibc是GNU发布的libc库,即c运行库,是linux系统中最底层的api,几乎其它任何运行库都会依赖于glibc。glibc的启动过程在不同的情况下差别很大,比如静态的glibc和动态的glibc的差别,glibc用于可执行文件和用于共享库的差别,这样的差别可以组合出4种情况,这里只选取最简单的静态glibc用于可执行文件的时候作为例子。

    可以在这里下载glibc源码,源码的glibc/sysdeps目录下有各个架构对入口函数的实现。glibc的程序入口为_start(这个入口是由ld链接器默认的链接脚本所指定的,也可以通过相关参数设定自己的入口)。_start由汇编实现,并且和平台相关,比如i386的_startglibc/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)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    环境变量:是存在于系统中的一些公用数据,任何程序都可以访问。通常来说,环境变量存储的都是一些系统的公共信息,例如系统搜索路径、当前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);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    共有7个参数(现在的版本不止有7个,这里都是按照原书的内容介绍的):

    • 第一个参数是main的函数指针,argv也包括了环境变量数组的指针
    • init:main函数调用前的初始化工作
    • fini:main函数调用后收尾工作
    • rtld_fini:和动态加载有关的收尾工作,runtime loader。
    • stack_end:标明了栈低的地址,即最高栈地址

    函数调用中,__cxa_atexit函数用于将指定参数的函数(rtld_finicall_fini)在main结束之后调用,末尾__libc_start_call_main开始对main函数的调用,最终会通过_exit调用系统调用exit(),退出进程。

    1.3 运行库与IO

    一个程序的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);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    在操作系统层面上,文件操作也有类似于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++运行库

    2.1 C语言运行库

    C语言运行库:任何一个C程序,它的背后都有一套庞大的代码来进行支撑,以使得该程序能够正常运行。这套代码至少包括入口函数,及其所依赖的函数所构成的函数集合。当然,它还理应包括各种标准库函数的实现。这样的一个代码集合称之为运行时库(Runtime Library)。而C语言的运行库,即被称为C运行库(CRT)。一个C语言运行库大致包含了如下功能:

    • 启动与退出:包括入口函数及入口函数所依赖的其它函数等。
    • 标准函数:由C语言标准规定的C语言标准库所拥有的函数实现。
    • I/O:I/O功能的封装和实现。
    • 堆:堆的封装和实现。
    • 语言实现:语言中一些特殊功能的实现。
    • 调试:实现调试功能的代码。

    2.2 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 libraryC库函数手册

    2.3 glibc

    运行库是平台相关的,因为它与操作系统结合得非常紧密。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.ocrtn.o

    crti.ocrtn.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*
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    $ 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
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    于是在最终链接完成之后,输出的目标文件中的”.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
    
    • 1

    由于crt1.o不包含”.init”段和”.finit”段,所以不会影响最终生成”.init”和”.finit”段时的顺序。

    在默认情况下,ld链接器会将libc、crt1.o等这些CRT和启动文件与程序的模块链接起来,但是有些时候,我们可能不需要这些文件,或者希望使用自己的libc和crt1.o等启动文件,以替代系统默认的文件,这种情况在嵌入式系统或操作系统内核编译的时候很常见。GCC提供了两个参数”-nostartfile”和”-nostdlib”,分别用来取消默认的启动文件和C语言运行库。

    其余部分感觉暂时用不到,以后如果用到在补充

  • 相关阅读:
    NFS性能瓶颈分析
    app测试流程和重点,怎么避免Bug漏测?
    【SpringBoot】浅谈向容器注入Bean有几种方式。
    使用SuperMap iDesktopX数据迁移工具迁移ArcGIS数据
    c++_0基础_讲解7 练习
    华为机试真题 Java 实现【无向图染色】【2022.11 Q4新题】
    2022南京邮电大学-计软网安学院-电子信息-应届生-考研分享
    Node + Express 后台开发 —— 起步
    【VS Code插件开发】常见自定义命令(七)
    职场人该看懂的几个点
  • 原文地址:https://blog.csdn.net/qq_42570601/article/details/126655636