2023年6月22日发(作者:)
程序员的⾃我修养--链接、装载与库笔记:运⾏库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由汇编实现,并且和平台相关。环境变量:是存在于系统中的⼀些公⽤数据,任何程序都可以访问。通常来说,环境变量存储的都是⼀些系统的公共信息,例如系统搜索路径、当前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程序,它的背后都有⼀套庞⼤的代码来进⾏⽀撑,以使得该程序能够正常运⾏。这套代码⾄少包括⼊⼝函数,及其所依赖的函数所构成的函数集合。当然,它还理应包括各种标准库函数的实现。这样的⼀个代码集合称之为运⾏时库(RuntimeLibrary)。⽽C语⾔的运⾏库,即被称为C运⾏库(CRT)。⼀个C语⾔运⾏库⼤致包含了如下功能:(1). 启动与退出:包括⼊⼝函数及⼊⼝函数所依赖的其它函数等。(2). 标准函数:由C语⾔标准规定的C语⾔标准库所拥有的函数实现。(3). I/O:I/O功能的封装和实现。(4). 堆:堆的封装和实现。(5). 语⾔实现:语⾔中⼀些特殊功能的实现。(6). 调试:实现调试功能的代码。变长参数:是C语⾔的特殊参数形式,例如printf函数,其声明如下:int printf(const char * format, ...);变长参数宏:在很多时候我们希望在定义宏的时候也能够像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标准库进⾏了⼀些扩展。 glibc:即GNU C Library,是GNU旗下的C标准库。最初由⾃由软件基⾦会FSF(Free Software Foundation)发起开发,⽬的是为GNU操作系统开发⼀个C标准库。glibc的发布版本主要由两部分组成,⼀部分是头⽂件,⽐如stdio.h、stdlib.h等,它们往往位于/usr/include;另外⼀部分则是库的⼆进制⽂件部分。⼆进制部分主要的就是C语⾔标准库,它有静态和动态两个版本。动态的标准库为/lib/x86_64-linux-gnu/.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_。MSVC CRT:同⼀个版本的MSVC CRT根据不同的属性提供了多种⼦版本,以供不同需求的开发者使⽤。按照静态/动态链接,可以分为静态版和动态版;按照单线程/多线程,可以分为单线程版和多线程版;按照调试/发布,可分为调试版和发布版;按照是否⽀持C++分为纯C运⾏库版和⽀持C++版;按照是否⽀持托管代码分为⽀持本地代码/托管代码和纯托管代码版。这些属性很多时候是相互正交的,也就是说它们之间可以相互组合。⽐如可以有静态单线程纯C纯本地代码调试版;也可以有动态的多线程纯C纯本地代码发布版等。但有些组合是没有的,⽐如动态链接版本的CRT是没有单线程的,所有的动态链接CRT都是多线程安全的。这样的不同组合将会出现⾮常多的⼦版本,于是微软提供了⼀套运⾏库的命名⽅法。这个命名⽅法是这样的,静态版和动态版完全不同。静态版的CRT位于MSVC安装⽬录下的C:Program Files (x86)Microsoft Visual Studio 12.0VClib,它们的命名规则为:p表⽰C Plusplus,即C++标准库;mt表⽰Multi-Thread,即表⽰⽀持多线程;d表⽰Debug,即表⽰调试版本。libc [p] [mt] [d] .lib动态版的CRT的每个版本⼀般有两个相对应的⽂件,⼀个⽤于链接的.lib⽂件,⼀个⽤于运⾏时⽤的.dll动态链接库。它们的命名⽅式与静态版的CRT⾮常类似,稍微有所不同的是,CRT的动态链接库DLL⽂件名中会包含版本号。⽐如Visual C++ 2013的多线程、动态链接版的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语⾔字符串表⽰则是”rn”。⽽在其它的⼀些操作系统中,回车的表⽰却有区别。例如,Linux/Unix,回车⽤n表⽰;Mac OS,回车⽤r表⽰;Windows,回车⽤rn表⽰。⽽在C语⾔中,回车始终⽤n来表⽰,因此在以⽂本模式读取⽂件的时候,不同的操作需要将各⾃的回车符表⽰转换为C语⾔的形式,也就是,Linux/Unix,不做改变;Mac OS,每遇到r就将其改为n;Windows,将rn改为n。
发布者:admin,转转请注明出处:http://www.yc00.com/web/1687425805a9143.html
评论列表(0条)