程序员的自我修养--链接、装载与库笔记:运行库

1. 入口函数和程序初始化

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

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

(1). 操作系统在创建进程后,把控制权交到了程序的入口,这个入口往往是运行库中的某个入口函数。

(2). 入口函数对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量构造,等等。

(3). 入口函数在完成初始化之后,调用main函数,正式开始执行程序主体部分。

(4). main函数执行完毕以后,返回到入口函数,入口函数进行清理工作,包括全局变量析构、堆销毁、关闭I/O等,然后进行系统调用结束进程。

入口函数如何实现:

GLIBC入口函数:glibc的启动过程在不同的情况下差别很大,比如静态的glibc和动态的glibc的差别,glibc用于可执行文件和用于共享库的差别,这样的差别可以组合出4种情况,这里只选取最简单的静态glibc用于可执行文件的时候作为例子。

glibc的程序入口为_start(这个入口是由ld链接器默认的链接脚本所指定的,也可以通过相关参数设定自己的入口)。_start由汇编实现,并且和平台相关。

可以从https://www.gnu.org/software/libc/sources.html 下载glibc的源码,最新的发布版本为2.30。

环境变量:是存在于系统中的一些公用数据,任何程序都可以访问。通常来说,环境变量存储的都是一些系统的公共信息,例如系统搜索路径、当前OS版本等。环境变量的格式为key=value的字符串,C语言里可以使用getenv这个函数来获取环境变量信息

MSVC CRT入口函数:MSVC的CRT默认的入口函数名为mainCRTStartup。

运行库与I/O:IO(或I/O)的全称是Input/Output,即输入和输出。对于计算机来说,I/O代表了计算机与外界的交互,交互的对象可以是人或其它设备。而对于程序来说,I/O覆盖的范围还要宽广一些。一个程序的I/O指代了程序与外界的交互,包括文件、管道、网络、命令行、信号等。更广义地讲,I/O指代任何操作系统理解为”文件”的事务。许多操作系统,包括Linux和Windows,都将各种具有输入和输出概念的实体----包括设备、磁盘文件、命令行等----统称为文件,因此这里所说的文件是一个广义的概念。对于一个任意类型的文件,操作系统会提供一组操作函数,这包括打开文件、读文件、写文件、移动文件指针等。C语言文件操作是通过一个FILE结构的指针来进行的。在操作系统层面上,文件操作也有类似于FILE的一个概念,在Linux里,这叫做文件描述符(File Descriptor),而在Windows里,叫做句柄(Handle)(以下在没有歧义的时候统称为句柄)。用户通过某个函数打开文件以获得句柄,此后用户操作文件皆通过该句柄进行。设计这么一个句柄的原因在于句柄可以防止用户随意读写操作系统内核的文件对象。无论是Linux还是Windows,文件句柄总是和内核的文件对象相关联的,但如何关联细节用户并不可见。内核可以通过句柄来计算出内核里文件对象的地址,但此能力并不对用户开放。I/O初始化的职责:首先I/O初始化函数需要在用户空间中建立stdin、stdout、stderr及其对应的FILE结构,使得程序进入main之后可以直接使用printf、scanf等函数。

MSVC CRT的入口函数初始化:MSVC的入口函数初始化主要包含两个部分,堆初始化和I/O初始化。

系统堆初始化:MSVC的堆初始化由函数_heap_init完成,它调用HeapCreate创建一个系统堆。

MSVC的I/O初始化:主要进行了如下几个工作:建立打开文件表;如果能够继承自父进程,那么从父进程获取继承的句柄;初始化标准输入输出。

2. C/C++运行库

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

一个C语言运行库大致包含了如下功能

(1). 启动与退出:包括入口函数及入口函数所依赖的其它函数等。

(2). 标准函数:由C语言标准规定的C语言标准库所拥有的函数实现。

(3). I/O:I/O功能的封装和实现。

(4). 堆:堆的封装和实现。

(5). 语言实现:语言中一些特殊功能的实现。

(6). 调试:实现调试功能的代码。

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语言的标准库非常轻量,它仅仅包含了数学函数、字符/字符串处理、I/O等基本方面。关于每个头文件的介绍可以参考:http://www.cplusplus.com/reference/clibrary/

变长参数:是C语言的特殊参数形式,例如printf函数,其声明如下:

int printf(const char * format, ...);

如此的声明表明,printf函数除了第一个参数类型为const char*之外,其后可以追加任意数量、任意类型的参数。在函数的实现部分,可以使用stdarg.h里的多个宏来访问各个额外的参数:假设lastarg是变长参数函数的最后一个具名参数(例如printf里的format),那么在函数内部定义类型为va_list的变量:va_list ap; 该变量以后将会依次指向各个可变参数。ap必须用宏va_start初始化一次,其中lastarg必须是函数的最后一个具名的参数。va_start(ap, lastarg); 此后可以使用va_arg宏来获得下一个不定参数(假设已知其类型为type):type next=va_arg(ap, type); 在函数结束前,还必须用宏va_end来清理现场。关于这几个宏的用法可以参考:https://blog.csdn.net/fengbingchun/article/details/78483471

变长参数宏:在很多时候我们希望在定义宏的时候也能够像printf一样可以使用变长参数,即宏的参数可以是任意个,这个功能可以由编译器的变长参数宏实现。

// 在GCC编译器下,变长参数宏可以使用”##”宏字符串连接操作实现,比如:
#define printf(args …) fprintf(stdout, ##args)
// 而在MSVC下,我们可以使用__VA_ARGS__这个编译器内置宏,比如:
#define printf(…) fprintf(stdout, __VA_ARGS__) // 它的效果与前面的GCC下使用##效果一样

glibc与MSVC CRT:运行库是平台相关的,因为它与操作系统结合得非常紧密。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标准库进行了一些扩展

  glibcGNU C Library,是GNU旗下的C标准库。最初由自由软件基金会FSF(Free Software Foundation)发起开发,目的是为GNU操作系统开发一个C标准库。glibc的发布版本主要由两部分组成,一部分是头文件,比如stdio.h、stdlib.h等,它们往往位于/usr/include;另外一部分则是库的二进制文件部分。二进制部分主要的就是C语言标准库,它有静态和动态两个版本。动态的标准库为/lib/x86_64-linux-gnu/libc.so.6,而静态标准库为/usr/lib/x86_64-linux-gnu/libc.a。事实上glibc除了C标准库之外,还有几个辅助程序运行的运行库,这几个文件可以称得上是真正的”运行库”,它们就是/usr/lib/x86_64-linux-gnu/crti.o、/usr/lib/x86_64-linux-gnu/crt1.o、/usr/lib/x86_64-linux-gnu/crtn.o,虽然它们都很小,但这几个文件都是程序运行的最关键的文件。

glibc启动文件:crt1.o里面包含的就是程序的入口函数_start,由它负责调用__libc_start_main初始化libc并且调用main函数进入真正的程序主体。crti.o和crtn.o两个目标文件中包含的代码实际上是_init()函数和_finit()函数的开始和结尾部分,当这两个文件和其它目标文件按照顺序链接起来以后,刚好形成两个完整的函数_init()和_finit()。可以用objdump查看这两个文件的反汇编代码,结果如下图所示:于是在最终链接完成之后,输出的目标文件中的”.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(crt0.o)不包含”.init”段和”.finit”段,所以不会影响最终生成”.init”和”.finit”段时的顺序。

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

其实C++全局对象的构造函数和析构函数并不是直接放在.init和.finit段里面的,而是把一个执行所有构造/析构的函数的调用放在里面,由这个函数进行真正的构造和析构。除了全局对象构造和析构之外,.init和.finit还有其它的作用。由于它们的特殊性(在main之前/之后执行),一些用户监控程序性能、调试等工具经常利用它们进行一些初始化和反初始化的工作。当然我们也可以使用”__atrribute__((section(“.init”)))”将函数放到.init段里面,但是要注意的是普通函数放在”.init”是会破坏它们的结构的,因为函数的返回指令使得__init()函数会提前返回,必须使用汇编指令,不能让编译器产生”ret”指令。

GCC平台相关目标文件:crtbeginT.o、libgcc.a、libgcc_eh.a、crtend.o这几个文件实际上不属于glibc,它们是GCC的一部分,它们都位于GCC的安装目录/usr/lib/gcc/x86_64-linux-gnu/4.9/下。crtbeginT.o及crtend.o这两个文件是真正用于实现C++全局构造和析构的目标文件。C++这样的语言的实现是跟编译器密切相关的,而glibc只是一个C语言运行库,它对C++的实现并不了解。而GCC是C++的真正实现者,它对C++的全局构造和析构了如指掌。于是它提供了两个目标文件crtbeginT.o和crtend.o来配合glibc实现C++的全局构造和析构。由于GCC支持诸多平台,能够正确处理不同平台之间的差异性也是GCC的任务之一。比如有些32位平台不支持64位的long long类型的运算,编译器不能够直接产生相应的CPU指令,而是需要一些辅助的例程来帮助实现计算。libgcc.a里面包含的就是这种类似的函数,这些函数主要包括整数运算、浮点数运算(不同的CPU对浮点数的运算方法很不相同)等,而libgcc_eh.a则包含了支持C++的异常处理(Exception Handing)的平台相关函数。另外GCC的安装目录下往往还有一个动态链接版本libgcc_s.so。

MSVC CRT:同一个版本的MSVC CRT根据不同的属性提供了多种子版本,以供不同需求的开发者使用。按照静态/动态链接,可以分为静态版和动态版;按照单线程/多线程,可以分为单线程版和多线程版;按照调试/发布,可分为调试版和发布版;按照是否支持C++分为纯C运行库版和支持C++版;按照是否支持托管代码分为支持本地代码/托管代码和纯托管代码版。这些属性很多时候是相互正交的,也就是说它们之间可以相互组合。比如可以有静态单线程纯C纯本地代码调试版;也可以有动态的多线程纯C纯本地代码发布版等。但有些组合是没有的,比如动态链接版本的CRT是没有单线程的,所有的动态链接CRT都是多线程安全的。这样的不同组合将会出现非常多的子版本,于是微软提供了一套运行库的命名方法。这个命名方法是这样的,静态版和动态版完全不同。静态版的CRT位于MSVC安装目录下的C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\lib,它们的命名规则为:p表示C Plusplus,即C++标准库;mt表示Multi-Thread,即表示支持多线程;d表示Debug,即表示调试版本。

libc  [p]  [mt]  [d]  .lib

动态版的CRT的每个版本一般有两个相对应的文件,一个用于链接的.lib文件,一个用于运行时用的.dll动态链接库。它们的命名方式与静态版的CRT非常类似,稍微有所不同的是,CRT的动态链接库DLL文件名中会包含版本号。比如Visual C++ 2013的多线程、动态链接版的DLL文件名为msvcr120.dll。下表列举了一些最常见的MSVC CRT版本(以Visual C++ 2013为例):

C++ CRT:MSVC还提供了相应的C++标准库。如果你的程序是使用C++编写的,那么就需要额外链接相应的C++标准库。这里的”额外”的意思是,如下表所列的C++标准库里面包含的仅仅是C++的内容,比如iostream、string、map等,不包含C的标准库。

3. 运行库与多线程

CRT的多线程困扰:

线程的访问权限:线程的访问能力非常自由,它可以访问进程内存里的所有数据,甚至包括其它线程的堆栈(如果它知道其它线程的堆栈地址,然后这是很少见的情况),但实际运用中线程也拥有自己的私有存储空间,包括:栈(尽管并非完全无法被其它线程访问,但一般情况下仍然可以认为是私有的数据);线程局部存储(Thread Local Storage, TLS),是某些操作系统为线程单独提供的私有空间,但通常只具有很有限的尺寸;寄存器(包括PC寄存器),是执行流的基本数据,因此为线程私有。从C程序员的角度来看,数据在线程之间是否私有如下表所示:

多线程运行库:对于C/C++标准库来说,线程相关的部分是不属于标准库的内容的,它跟网络、图形图像等一样,属于标准库之外的系统相关库。这里所说的”多线程相关”主要有两个方面,一方面是提供那些多线程操作的接口,比如创建线程、退出线程、设置线程优先级等函数接口;另外一方面是C运行库本身要能够在多线程的环境下正确运行。对于第一方面,主流的CRT都会有相应的功能。比如Windows下,MSVC CRT提供了诸如_beginthread()、_endthread()等函数用于线程的创建和退出;而Linux下,glibc也提供了一个可选的线程库pthread(POSIX Thread),它提供了诸如pthread_create()、pthread_exit()等函数用于线程的创建和退出。很明显,这些函数都不属于标准的运行库,它们都是平台相关的。对于第二个方面,C语言运行库必须支持多线程的环境,实际上,最初CRT在设计的时候是没有考虑多线程环境的,因为当时根本没有多线程这样的概念。

CRT改进:(1). 使用TLS;(2). 加锁:在多线程版本的运行库中,线程不安全的函数内部都会自动地进行加锁;(3). 改进函数调用方式:C语言的运行库为了支持多线程特性,一种改进的办法就是修改所有的线程不安全的函数的参数列表,改成某种线程安全的版本。但是很多时候改变标准库函数的做法是不可行的。标准库之所以称之为”标准”,就是它具有一定的权威性和稳定性,不能随意更改。

线程局部存储实现:TLS的用法很简单,如果要定义一个全局变量为TLS类型的,只需要在它定义前加上相应的关键字即可。对于GCC来说,这个关键字就是__thread。对于MSVC来说,相应的关键字为__declspec(thread)。一旦一个全局变量被定义成TLS类型的,那么每个线程都会拥有这个变量的一个副本,任何线程对该变量的修改都不会影响其它线程中该变量的副本

Windows TLS的实现:对于Windows系统来说,正常情况下一个全局变量或静态变量会被放到”.data”或”.bss”段中,但当我们使用__declspec(thread)定义一个线程私有变量的时候,编译器会把这些变量放到PE文件的”.tls”段中。当系统启动一个新的线程时,它会从进程的堆中分配一块足够大小的空间,然后把”.tls”段中的内容复制到这块空间中,于是每个线程都有自己独立的一个”.tls”副本。所以对于用__declspec(thread)定义的同一个变量,它们在不同线程中的地址都是不一样的。对于一个TLS变量来说,它有可能是一个C++的全局对象,那么每个线程在启动时不仅仅是复制”.tls”的内容那么简单,还需要把这些TLS对象初始化,必须逐个地调用它们的全局构造函数,而且当线程退出时,还要逐个地将它们析构,正如普通的全局对象在进程启动和退出时都要构造、析构一样。

显示TLS:使用__thread或__declspec(thread)关键字定义全局变量为TLS变量的方法往往被称为隐式TLS,即程序员无须关心TLS变量的申请、分配赋值和释放,编译器、运行库还有操作系统已经将这一切悄悄处理妥当了。在程序员看来,TLS全局变量就是线程私有的全局变量。相对于隐式TLS,还有一种叫做显示TLS的方法,这种方法是程序员需要手工申请TLS变量,并且每次访问该变量时都要调用相应的函数得到变量的地址,并且在访问完成之后需要释放该变量。在Windows平台上,系统提供了TlsAlloc()、TlsGetValue()、TlsSetValue()和TlsFree()这4个API函数用于显示TLS变量的申请、取值、赋值和释放。Linux下相对应的库函数为pthread库中的pthread_key_create()、pthread_getspecific()、pthread_setspecific()和pthread_key_delete()。相对于隐式的TLS变量,显式的TLS变量的使用十分麻烦,而且有诸多限制。

在Windows下创建一线程的方法有两种,一种是调用Windows API CreateThread()来创建线程;另外一种就是调用MSVC CRT的函数_beginthread()或_beginthreadex()来创建线程。在使用静态链接CRT(/MT, /MTd)时,CreateThread()可能会导致内存泄漏。当使用CRT时(基本上所有的程序都使用CRT),尽量使用_beginthread()/_beginthreadex()/_endthread()/_endthreadex()这组函数来创建线程

4. C++全局构造与析构

glibc全局构造与析构: 对于每个编译单元(.cpp),GCC编译器会遍历其中所有的全局对象,生成一个特殊的函数,这个特殊函数的作用就是对本编译单元里的所有全局对象进行初始化。GCC在目录代码中生成了一个名为_GLOBAL_I_Hw的函数,由这个函数负责本编译单元所有的全局/静态对象的构造和析构。由于全局对象的构建和析构都是由运行库完成的,于是在程序或共享库中有全局对象时,记得不能使用”-nonstartfiles”或”-nostdlib”选项,否则,构建与析构函数将不能正确执行

MSVC CRT的全局构造和析构:MSVC CRT的全局构造实现在机制上与Glibc基本是一样的,只不过它们的名字略有不同。Glibc下通过__cxa_exit()向exit()函数注册全局析构函数,MSVC CRT也通过atexit()实现全局析构,它们除了函数命名不同之外几乎没有区别。

5. fread实现

缓冲(Buffer):缓冲最为常见于IO系统中,设想一下,当希望向屏幕输出数据的时候,由于程序逻辑的关系,可能要多次调用printf函数,并且每次写入的数据只有几个字符,如果每次写数据都要进行一次系统调用,让内核向屏幕写数据,就明显过于低效,因为系统调用的开销是很大的,它要进行上下文切换、内核参数检查、复制等,如果频繁进行系统调用,将会严重影响程序和系统的性能。一个显而易见的可行方案是将对控制台连续的多次写入放在一个数组里,等到数组被填满之后再一次性完成系统调用写入,实际上这就是缓冲最基本的想法。当读文件的时候,缓冲同样存在。我们可以在CRT中为文件建立一个缓冲,当要读取数据的时候,首先看看这个文件的缓冲里有没有数据,如果有数据就直接从缓冲中取。如果缓冲是空的,那么CRT就通过操作系统一次性读取文件一块较大的内容填充缓冲。这样,如果每次读取文件都是一些尺寸很小的数据,那么这些读取操作大多都直接从缓冲中获得,可以避免大量的实际文件访问。除了读文件有缓冲以外,写文件也存在着同样的情况,而且写文件比读文件要更加复杂。因为当我们通过fwrite向文件写入一段数据时,此时这些数据不一定被真正地写入到文件中,而是有可能还存在于文件的写缓冲里面,那么此时如果系统崩溃或进程意外退出时,有可能导致数据丢失,于是CRT还提供了一系列与缓冲相关的操作用于弥补缓冲所带来的问题。C语言标准库提供与缓冲相关的几个基本函数,如下表所示:所谓flush一个缓冲,是指对写缓冲而言,将缓冲内的数据全部写入实际的文件,并将缓冲清空,这样可以保证文件处于最新的状态。之所以需要flush,是因为写缓冲使得文件处于一种不同步的状态,逻辑上一些数据已经写入了文件,但实际上这些数据仍然在缓冲中,如果此时程序意外地退出(发生异常或断电等),那么缓冲里的数据将没有机会写入文件,flush可以在一定程度上避免这样的情况发生。C语言支持两种缓冲,即行缓冲(Line Buffer)和全缓冲(Full Buffer)。全缓冲是经典的缓冲形式,除了用户手动调用fflush外,仅当缓冲满的时候,缓冲才会被自动flush掉。而行缓冲则比较特殊,这种缓冲仅用于文本文件,在输入输出遇到一个换行符时,缓冲就会被自动flush,因此叫行缓冲。

文本换行:在Windows的文本文件中,回车(换行)的存储方式是0x0D(用CR表示),0x0A(用LF表示)这两个字节,以C语言字符串表示则是”\r\n”。而在其它的一些操作系统中,回车的表示却有区别。例如,Linux/Unix,回车用\n表示;Mac OS,回车用\r表示;Windows,回车用\r\n表示。而在C语言中,回车始终用\n来表示,因此在以文本模式读取文件的时候,不同的操作需要将各自的回车符表示转换为C语言的形式,也就是,Linux/Unix,不做改变;Mac OS,每遇到\r就将其改为\n;Windows,将\r\n改为\n。

GitHubhttps://github.com/fengbingchun/Messy_Test

发布了718 篇原创文章 · 获赞 1131 · 访问量 609万+

猜你喜欢

转载自blog.csdn.net/fengbingchun/article/details/102142691
今日推荐