实现32位简单版Windows和Linux双平台的C++运行库
2021-01-16 18:58:00 Author: mp.weixin.qq.com(查看原文) 阅读量:136 收藏

本文为看雪论坛优秀文章

看雪论坛作者ID:genliese

一、背景

为了总结在《程序员的自我修养--链接、装载与库》这本书中的”运行库实现“章节学到的知识,现编写”运行库实现“一文。
因为要讲清楚实现运行库的细节是比较复杂的,同时也限于篇幅和避免复杂繁琐,所以本文只会提及关键实现处并给出源代码,同时给出相关知识点在《程序员的自我修养--链接、装载与库》中的相关章节和相关参考资料,如有不理解的地方请回帖或私信我,鉴于本人才疏学浅,如有错误,欢迎指正。

二、声明

1. 为了便于读者理解,本文大量复制《程序员的自我修养--链接、装载与库》中的文字。

2. 《程序员的自我修养--链接、装载与库》已经给出了“运行库实现”的完整源代码,且本文中的绝大多数源代码都是直接复制其中的代码,但是因为书籍年代久远(09年出版的)和实验环境的改变的原因,我修改了一部分源代码以顺利运行。

三、目的


实现32位简单版Windows和Linux双平台的C++运行库
  • CRT库功能

    • 入口函数

    • 初始化

    • 堆管理

    • 基本IO

  • C++库功能

    • new/delete

    • 类:stream、string

四、意义

帮助理解CRT和C++运行库的结构,从而举一反三。

五、实现

我们先实现CRT,然后再添加C++运行库的功能

5.1 注意点

(1)Linux部分的实现代码中的内联汇编使用的是AT&T汇编,和x86汇编相似,但是区别也很大
(2)规定源代码中的函数执行失败返回-1
(3)规定《程序员的自我修养--链接、装载与库》简称《修养》
(4)实验环境:
  • Linux实验环境:18.04.1-Ubuntu 64位,gcc version 7.5.0 (Ubuntu 7.5.0-3ubuntu1~18.04)

  • Windows实验环境:Win10 2004 64位,cl 19.26.28805

5.2 C语言运行库实现

在开始实现Mini CRT之前,首先要对它进行基本的规划。“麻雀虽小五脏俱全”,虽然Mini CRT很小,但它应该具备CRT的基本功能以及遵循几个基本设计原则,这些我们归结为如下几个方面:
  • 首先Mini CRT应该以ANIS C的标准库为目标,尽量做到与其接口相一致。

  • 具有自己的入口函数(mini_crt_entry

  • 基本的进程相关操作(exit)

  • 支持堆操作(malloc、free

  • 支持基本的文件操作(fopen、fread、fwrite、fclose、fseek

  • 支持基本的字符串操作(strcpy、strlen、strcmp

  • 支持格式化字符串和输出操作(printf、sprintf)

  • 支持atexit()函数

  • 最后,Mini CRT应该是跨平台的。我们计划让Mini CRT能够同时支持Windows和 Linux两个操作系统

  • Mini CRT的实现应该尽量简单,以展示CRT的实现为目的,并不追求功能和性能,基本上是“点到为止”

为了使CRT能够同时支持Linux和 Windows两个平台,必须针对这两个操作系统环境的不同进行条件编译。
在Mini CRT中,我们使用宏WIN32为标准来决定是Windows还是Linux。因为实际的代码常呈现这样的结构:
#ifdef WIN32//Windows部分代码#else//Linux部分实现代码#endif

通常我们会把CRT的各个函数的声明放在不同的头文件中,比如 IO相关的位于stdio.h;字符串和堆相关的放在stdlib.h中。为了简单起见,将Mini CRT中所有函数的声明都放在minicrt.h中。

5.2.1 入口函数

5.2.1.1 入口函数实现流程图
5.2.1.2 命令行解析
因为我们的main函数需要两个参数:参数个数和参数字符串指针数组,所以我们得进行命令行解析。
 
Windows实现部分
 
Windows用GetCommandLineA获取输入字符串,然后手动提取参数,不过该提取算法有缺陷,如果有多余的空格,会得到"\0"的字符串。
 
实现代码如下:
int flag = 0;int argc = 0;char* argv[16];char* cl = GetCommandLineA();//解析命令行//算法缺陷:有多余的空格,会得到"\0"的字符串argv[0] = cl;argc++;while (*cl){    if (*cl == '\"')    {        if (flag == 0)        {            flag = 1;        }else{            flag = 0;        }    }    else if (*cl == ' ' && flag == 0)    {        if (*(cl+1))        {            argv[argc] = cl + 1;            argc++;        }        *cl = '\0';    }    cl++;}

Linux实现部分
 
当进入入口函数的时候,堆栈依次保存着参数个数、参数字符串指针、环境变量字符串指针和其它信息(注意保存没有返回地址,因为这是入口函数),此时ESP指向argc,堆栈信息如下:
 
然而因为我们的入口函数不是裸函数,所以会在函数开头生成如下指令:
...push ebpmov ebp,esp...

所以执行完上述指令后,此时EBP指向旧的EBP,新的堆栈信息如下:
 
所以此时可以通过EBP加上偏移的方式获取我们所需的参数。
 
实现代码如下:
int argc = 0;char** argv = 0;char* ebp_reg = 0;// ebp_reg = %ebp;asm("movl %%ebp,%0      \n"    :"=r"(ebp_reg));argc = *(int*)(ebp_reg + 4);argv = (char**)(ebp_reg + 8);

5.2.1.3 堆初始化
参考本文—“5.2.2 堆的实现”中的mini_crt_heap_init函数
if (mini_crt_heap_init() == -1){    crt_fatal_error("heap initialize failed");}

5.2.1.4 IO初始化
参考本文—“5.2.3 IO与文件操作”中的mini_crt_io_init函数。
if (mini_crt_io_init() == -1){    crt_fatal_error("IO initialize failed");}

5.2.1.5 调用初始化函数
调用需要在main函数之前执行的函数,如全局对象的构造函数,在C++运行库部分进行实现
//do_global_ctors();

5.2.1.6 调用main
也就是调用我们程序的入口函数
ret = main(argc,argv);

5.2.1.7 退出
Mini CRT结束部分很简单,它要完成两项任务:一个就是调用由atexit()注册的退出回调函数;另外一个就是实现结束进程。这两项任务都由exit()函数完成,这个函数在Linux中的实现是调用Linux的1号系统调用实现进程结束,ebx表示进程退出码;而Windows则提供了一个叫做ExitProcess的API,直接调用该API即可结束进程。
 
不过在进行系统调用或API之前,exit()还有一个任务就是调用由atexit()注册的退出回调函数,这个任务通过调用mini_crt_exit_routine()实现。atexit()注册回调函数的机制主要是用来实现全局对象的析构的,在这一节中暂时不打算让MiniCRT支持C++,所以暂时将调用mini_crt_exit_routine()这个函数的那行代码去掉。
exit(ret); void exit(int exitCode){    //mini_crt_call_exit_routine();#ifdef WIN32    ExitProcess(exitCode);#else    asm("movl %0,%%ebx \n"        "movl $1,%%eax \n"        "int $0x80     \n"        "hlt           \n"        :        :"m"(exitCode)        :"%ebx");#endif

5.2.2 堆的实现

实现malloc()函数和free()函数。当然堆的实现方法有很多,在不同的操作系统平台上也有很多可以选择的方案,在遵循Mini CRT的原则下,我们将Mini CRT堆的实现归纳为下面几条:
- 实现一个以空闲链表算法为基础的堆空间分配算法。
为了简单起见,堆空间大小固定为32MB,初始化之后空间不再扩展或缩小。
在Windows平台下不使用HeapAlloc等堆分配算法,采用VirtualAlloc向系统直接申请32MB空间,由我们自己的堆分配算法实现malloc。
在 Linux平台下,使用mmap2函数向系统直接申请32MB空间。
堆分配算法的原理参考《修养》10.3.4章节,整个堆空间按照是否被占用而被分割成了若干个空闲(Free)块和占用(Used)块,它们之间由双向链表链接起来。
 
当用户要申请一块内存时,堆分配算法将遍历整个链表,直到找到一块足够大的空闲块,如果这个空闲块大小刚好等于所申请的大小,那么直接将这个空闲块标记为占用块,然后将它的地址返回给用户;如果空闲块大小大于所申请的大小,那么这个空闲块将被分割成两块,其中一块大小为申请的大小,标记为占用,另外一块为空闲块。
 
当用户释放某一块空间时,堆分配算法会判别被释放块前后两个块是否为空闲块,如果是,则将它们合并成一个大的空闲块。
 
实现代码如下:
//malloc.c#include "minicrt.h" typedef struct __heap_header{    enum{        HEAP_BLOCK_FREE = 0xABABABAB,        HEAP_BLOCK_USED = 0xCDCDCDCD,    } type;    unsigned size;  //block size including header    struct __heap_header* next;    struct __heap_header* prev;} heap_header; #define ADDR_ADD(a,o) (((char*)(a)) + o)#define HEADER_SIZE (sizeof(heap_header)) static heap_header* list_head = NULL; void free(void* ptr){    heap_header* header = (heap_header*)ADDR_ADD(ptr,-HEADER_SIZE);    if (header->type != HEAP_BLOCK_USED)    {        return;    }    //合并前一块    ...完整代码请点击阅读原文

我们在malloc.c中实现了3个对外的接口函数,分别是: mini_crt_init_heap、malloc和free。不过这个堆的实现还比较简陋:它的搜索算法是O(n)的(n是堆中分配的块的数量);堆的空间固定为32MB,没有办法扩张;它没有实现realloc、calloc函数;它没有很好的堆溢出防范机制;它不支持多线程同时访问等等。
 
虽然它很简陋,但是它体现出了堆分配算法的最本质的几个特征,其他的诸如改进搜索速度、扩展堆空间、多线程支持等都可以在此基础上进行改进,由于篇幅有限,我们也不打算一一实现它们,读者如果有兴趣,可以自己考虑动手改进Mini CRT,为它增加上述特性。

5.2.3 IO与文件操作

IO部分在任何软件中都是最为复杂的,在CRT中也不例外。在传统的C语言和UNIX里面,IO和文件是同一个概念,所有的IO都是通过对文件的操作来实现的。因此,只要实现了文件的基本操作(fopen、fread、fwrite、fclose和 fseek),即是完成了Mini CRT的IO部分。与堆的实现一样,我们需要为Mini CRT的IO部分设计一些实现的基本原则:
- 仅实现基本的文件操作,包括 fopen、fread、fwrite、fclose及 fseek。
- 为了简单起见,不实现缓冲(Buffer)机制。
- 不对Windows下的换行机制进行转换,即“\r\n”与“\n”之间不进行转换。
- 支持三个标准的输入输出stdin、stdout和 stderr。
- 在Windows下,文件基本操作可以使用API:CreateFile、ReadFile、WriteFile、CloseHandle和SetFilePointer实现。
- Linux 不像Windows那样有API接口,我们必须使用内联汇.编实现open、read、write、close和seek这几个系统调用。
- fopen时仅区分“r”、“w”和“+”这几种模式及它们的组合,不对文本模式和二进制模式进行区分,不支持追加模式(“a”)。
实现代码如下:
//stdio.c#include "minicrt.h" int mini_crt_io_init(){    return 0;} #ifdef WIN32#include <Windows.h> FILE *fopen(const char *filename, const char *mode){    HANDLE hFile = 0;    int access = 0;    int creation = 0;     if (strcmp(mode, "w") == 0)    {        access |= GENERIC_WRITE;        creation |= CREATE_ALWAYS;    }    if (strcmp(mode, "w+") == 0)    {        access |= GENERIC_WRITE | GENERIC_READ;        creation |= CREATE_ALWAYS;    }    if (strcmp(mode, "r") == 0)    {        access |= GENERIC_READ;        creation |= OPEN_EXISTING;    }    if (strcmp(mode, "r+") == 0)    {        access |= GENERIC_WRITE | GENERIC_READ;        creation |= OPEN_EXISTING;    }     hFile = CreateFileA(filename, access, 0, 0, creation, 0, 0);    if (hFile == INVALID_HANDLE_VALUE)    {        return (FILE*)-1;    }    return (FILE*)hFile;}...完整代码请点击阅读原文

另外还有―段与文件操作相关的声明须放在minicrt.h里面:
typedef int FILE;#define EOF (-1) #ifdef WIN32#define stdin   ((FILE*)(GetStdHandle(STD_INPUT_HANDLE)))#define stdout  ((FILE*)(GetStdHandle(STD_OUTPUT_HANDLE)))#define stderr  ((FILE*)(GetStdHandle(STD_ERROR_HANDLE)))#else#define stdin   ((FILE*)0)#define stdout  ((FILE*)1)#define stderr  ((FILE*)2)#endif
由于省略了诸多实现内容,所以CRT IO部分甚至可以不要做任何初始化,于是IO的初始化函数mini_crt_init_io也形同虚设,仅仅是一个空函数而已。

5.2.4 字符串相关操作

字符串相关的操作也是CRT的一部分,包括计算字符串长度、比较两个字符串、整数与字符串之间的转换等。由于这部分功能无须涉及任何与内核交互,是纯粹的用户态的计算,所以它们的实现相对比较简单。
 
实现代码如下:
//string.c//int n:只支持十进制//不支持radix不是十进制且n<0的情况char* itoa(int n,char* str,int radix){    char digit[]="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";    char* p = str;    char* head = str;    if (!p || radix < 2 || radix > 36)    {        return p;    }    //我觉得是可以行的    if (radix != 10 && n < 0)    {        return p;    }    if (n == 0)    {        *p++ = '0';        *p = 0;        return p;    }    if (radix == 10 && n < 0)    {        *p++ = '-';        n = -n;    }    while (n)    {        *p++ = digit[n % radix];        n /= radix;    }    ...完整代码请点击阅读原文

5.2.5 格式化字符串

现在的Mini CRT已经初具雏形了,它拥有了堆管理、文件操作、基本字符串操作。接下来将要实现的是CRT中一个如雷贯耳的函数,那就是printf。
printf是一个典型的变长参数函数,即参数数量不确定,如何使用和实现变长参数的函数参考《修养》11.2.2章节。本节实现的相关内容列举如下:
  • printf实现仅支持%d、%s,且不支持格式控制(比如%08d)。

  • 实现fprintf和vfprintf,实际上printf是fprintf的特殊形式,即目标文件为标准输出的fprintf。

  • 实现与文件字符串操作相关的几个函数,fputc和fputs。

实现代码如下:
//printf.c#include "minicrt.h" //返回字符int fputc(int c,FILE* stream){    if (fwrite(&c,1,1,stream) == -1)    {        return EOF;    }else{        return c;    }}//返回字符串长度int fputs(const char* str,FILE* stream){    int len = strlen(str);    if (fwrite(str,len,1,stream) == -1)    {        return EOF;    }else{        return len;    }} #ifndef WIN32#define va_list char*#define va_start(ap,arg)    (ap=(va_list)&arg+sizeof(arg))#define va_arg(ap,t)    (*(t*)((ap+=sizeof(t)) - sizeof(t)))#define va_end(ap)  (ap=(va_list)0)#else#include <Windows.h>#endif...点击阅读原文查看完整代码

1. 定义模式:翻译模式/普通模式。
2. 循环整个格式字符串。
如果遇到%
普通模式:进入翻译模式;
翻译模式:输出%,退出翻译模式。
如果遇到%后面允许出现的特殊字符(如d和s)
翻译模式:从不定参数中取出一个参数输出,退出翻译模式;
普通模式:直接输出该字符。
- 如果遇到其他字符:无条件退出翻译模式并输出字符。
在Mini CRT的vfprintf实现中,并不支持特殊的格式控制符,例如位数、进度控制等,仅支持%d与%s 这样的简单转换。真正的vfprintf格式化字符串实现比较复杂,因为它支持诸如%f、%x已有各种格式、位数、精度控制等,在这里并没有将它们一一实现,也没有这个必要,Mini CRT的printf已经能够充分展示printf的实现原理和它的关键技巧,读者也可以根据Mini CRT printf的实现去更加深入地分析glibc或MSVC CRT的相关代码。

5.2.6 如何使用Mini CRT

Mini CRT将以库文件和头文件的形式提供给用户。首先我们建立一个minicrt.h的头文件,然后将所有相关的常数定义、宏定义,以及Mini CRT所实现的函数声明等放在该头文件里。当用户程序使用Mini CRT 时,仅需要#include "minicrt.h"即可,而无须像标准的CRT一样,需要独立的包含相关文件,比如stdio.h、stdlib.h等。
 
minicrt.h的代码如下:
//minicrt.h#ifndef __MINI_CRT_H__#define __MINI_CRT_H__ #ifdef __cplusplusextern "C"{#endif //malloc#ifndef NULL#define NULL (0)#endif#ifndef _SIZE_T_DEFINEDtypedef unsigned int        size_t;#define _SIZE_T_DEFINED#endif  /* _SIZE_T_DEFINED */ void free(void* ptr);void* malloc(unsigned size);// static int brk(void* end_data_segment);static void* mmap2(void *addr, unsigned len, int prot,           int flags, int fd, int offset);int mini_crt_heap_init(); //字符串...点击阅读原文查看w完整代码

接下来的问题是如何编译得到库文件了。由于动态库的实现比静态库要复杂,所以MiniCRT仅仅以静态库的形式提供给最终用户,在 Windows下它是minicrt.lib;在Linux下它是minicrt.a。在不同平台下编译和制作库文件的步骤如下所示,Linux下的命令行为:
gcc -c -fno-builtin -nostdlib -fno-stack-protector -m32 entry.c malloc.c stdio.c string.c printf.car -rs minicrt.a malloc.o printf.o stdio.o string.o

这里的-fno-builtin参数是指关闭GCC的内置函数功能,默认情况下GCC会把strlen、strcmp等这些常用函数展开成它内部的实现。
- nostdlib表示不使用任何来自glibc、GCC的库文件和启动文件,它包含了-nostartfiles这个参数。
- fno-stack-protector是指关闭堆栈保护功能,最近版本的GCC会在 vfprintf这样的变长参数函数中插入堆栈保护函数,如果不关闭,我们在使用Mini CRT时会发生“stack chk fail”函数未定义的错误。
- m32是指生成32位的中间目标文件,因为我的实验环境是64位的,而实现的运行库是32位的。
在Windows 下, Mini CRT的编译方法如下:
cl /c /DWIN32 /utf-8 /GS- entry.c malloc.c printf.c stdio.c string.clib entry.obj malloc.obj printf.obj stdio.obj string.obj /OUT:minicrt.lib

/DWIN32表示定义WIN32这个宏,这也正是在代码中用于区分平台的宏。
/GS-表示关闭堆栈保护功能,MSVC和 GCC一样也会在不定参数中插入堆栈保护功能。不管这个功能会不会在最后链接时发生"security_cookie"和"security_check_cookie"符号未定义错误。
如果出现了编码问题,则可以使用/utf-8来解决
为了测试Mini CRT是否能够正常运行,我们专门编写了一段测试代码,用于测试MiniCRT的功能:
//test.c#include "minicrt.h" int main(int argc,char* argv[]){    int i;    FILE* fp;    //1.拷贝参数到堆    char** v = malloc(argc*sizeof(char*));    for ( i = 0; i < argc; i++)    {        v[i] = malloc(strlen(argv[i])+1);        strcpy(v[i],argv[i]);    }    //2.写入文件    fp = fopen("test.txt","w");    for ( i = 0; i < argc; i++)    {        int len = strlen(v[i]);        fwrite(&len,sizeof(int),1,fp);        fwrite(v[i],strlen(v[i])+1,1,fp);    }    fclose(fp);    //3.读取文件    fp = fopen("test.txt","r");    for ( i = 0; i < argc; i++)    {        int len;        char* buf;        fread(&len,sizeof(int),1,fp);        buf = malloc(len+1);        fread(buf,len+1,1,fp);        printf("%d %s\n",len,buf);        free(buf);        free(v[i]);    }    fclose(fp);    return 0;}

这段代码用到了Mini CRT中绝大部分函数,包括malloc、free、fopen、fclose、fread、fwrite、printf,并且测试了main参数。它的作用就是将main的参数字符串都保存到文件中,然后再读取出来,由printf显示出来。在Linux 下,可以用下面的方法编译和运行test.c:
gcc -c -ggdb -fno-builtin -nostdlib -fno-stack-protector -m32 test.cld -static -m elf_i386 -e mini_crt_entry entry.o test.o minicrt.a -o test

-e mini_crt_entry用于指定入口函数。
可以看到静态链接Mini CRT最后输出的可执行文件只有10964字节,这正体现出了Mini CRT的“迷你”之处,而如果静态链接glibc时,最后可执行文件则约为645KB。在Windows下,编译和运行test.c的步骤如下:
cl /c /DWIN32 /utf-8 test.clink test.obj minicrt.lib kernel32.lib /NODEFAULTLIB /DEBUG /entry:mini_crt_entry

 
与Linux类似,Windows下使用Mini CRT链接的可执行文件也非常小,只有12800字节。如果我们使用dumpbin查看它的导入函数可以发现,它仅依赖于Kernel32.DLL中我们使用到的函数。

5.3 C++运行库实现

现在Mini CRT已经能够支持最基本的C语言程序运行了。C++作为兼容C语言的扩展语言,它的运行库的实现其实并不复杂,在这一节中将介绍如何为Mini CRT添加对C++语言的一些常用的操作支持。
 
通常C++的运行库都是独立于C语言运行库的,比如Linux 下C语言运行库为libc.so/libc.a,而C++运行库为(libstdc++.so/libstdc++.a );Windows 的C语言运行库为libcmt.lib/msvcr90.dll,而C++运行库为libcpmt.Jib/msvcp90.dll。
一般这些C++的运行库都是依赖于C运行库的,它们仅包含对C++的一些特性的支持,比如new/delete、STL、异常处理、流( stream)等。但是它们并不包含诸如入口函数、堆管理、基本文件操作等这些特性,而这些也是C++运行库所必需的,比如C++的流和文件操作依赖于C运行库的基本文件操作,所以它必须依赖于C运行库。

本节中我们将在 Mini CRT的基础上实现一个支持C++的运行库,当然出于简单起见,将这个C++运行库的实现与Mini CRT合并到一起,而不是单独成为一个库文件,也就是说经过这一节对Mini CRT的功能改进,最终编译出来的minicrt.a/minicrt.lib将支持C++的诸多特性。

当然,要完整实现一个C++的运行库是很费事的一件事,C++标准模板库STL包含了诸如流、容器、算法、字符串等,规模较为庞大。
出于演示的目的,我们将对C++的标准库进行简化,最终目标是实现一个能够成功运行如下C++程序代码的运行库:
//test.cpp#include "iostream"#include "string" using namespace std; string abc("abcd"); class Person{private:    int age;    char* name;public:    Person(){        printf("Person\n");    }    ~Person(){        printf("~Person\n");    }}; int main(int argc,char* argv[]){    Person* arr = new Person[2];    string* msg = new string("Hello World");    cout << *msg << endl;    cout << abc << endl;    delete msg;    delete[] arr;    return 0;}

上面这段程序看似简单,实际上它用到了C++运行库的诸多功能。我们将所用到的特性列举如下:
  • string类的实现。

  • stream类的实现,包括操纵符(Manupilator) (endl)。

  • 全局对象构造和析构( cout、abc)。

  • new/delete。

在开始本节之前,还是按照前面Mini CRT实现时的做法:在进入具体主题之前先列举一些实现的原则。在实现 Mini CRT对C++的支持时,我们遵循如下原则:

* HelloWorld程序无须用到的功能就不实现,比如异常。

尽量简化设计,尽量符合C++标准库的规范。
对于可以直接在头文件实现的模块尽量在头文件中实现,以免诸多的类、函数的声明和定义造成代码量膨胀,不便于演示。
与前面的Mini CRT实现一样,运行库代码要做到可以在Windows和Linux 上同时运行,因此对于平台相关部分要使用条件编译分别实现。虽然C++运行库几乎没有与系统相关的部分(全局构造和析构除外),C运行库已经将大部分系统相关部分封装成C标准库接口,C++运行库只须要调用这些接口即可。
另外值得一提的是,模板是不需要运行库支持的,它的实现依赖于编译器和链接器,对运行库基本上没有要求。

5.3.1 new与delete

5.3.1.1 有了malloc/free为什么还要new/delete
malloc/free是C/C++语言的标准库函数,new/delete是C++的运算符,它们都用于申请动态内存和释放。对于类类型的对象而言,光用malloc/free无法满足动态申请对象的要求,因为对象在创建的同时要自动执行构造函数,对象在消亡时要自动执行析构函数。
由于malloc/free是库函数,编译器在编译时判定库函数是已编译的代码,编译器不会进行编译和检查。而new/delete是C++的运算符,编译器会进行编译和检查,如果是类对象则不仅会调用new/delete,而且会调用类对象的构造函数和析构函数。我们来看一个例子:
 
malloc/free生成的代码
#include <stdlib.h> class C{public:    C(){     }    ~C(){     }}; int main(int argc,char** argv){    C* c = (C*)malloc(sizeof(C));    free(c);    return 0;}
用GCC编译并反汇编这段代码,将会看到malloc/free的实现:
g++ -c test.cobjdump -dr test.o

 
可以看到,malloc仅仅申请了内存,并没有调用构造函数;同样free也仅仅释放内存,也没有调用析构函数。
 
new/delete生成的代码
#include <stdlib.h> class C{public:    C(){     }    ~C(){     }}; int main(int argc,char** argv){    C* c = new C;    delete c;    return 0;}

用GCC编译并反汇编这段代码,将会看到new/delete的实现:
g++ -c test.cobjdump -dr test.o

可以看到,new操作的实现实际上是先调用了_Znwm函数,再调用_ZN1CC1Ev函数;delete操作的实现实际上是先调用了_ZN1CD1Ev函数,再调用_ZdlPvm函数,如果用c++filt将这四个符号反修饰,可以看到它的真面目:
可以看到,new不仅申请了内存,而且还调用了构造函数;同样delete也不仅释放了内存,而且还调用了析构函数。注意调用构造和析构函数的代码是编译器帮我们生成并进行调用的。
5.3.1.2 实现new与delete
从上节的实验可以知道new的真面目是一个叫operator new的函数,这也是我们在C++中熟悉的运算符函数。在C++中,运算符实际上是一种特殊的函数,叫做运算符函数,一般new运算符被定义为(上面是64位程序,所以是long):
 
void* operator new(unsigned int size)
 
除了new、delete这样的运算符以外,+、-、*、%等都可以被认为是运算符,这些运算符都有相对应的运算符函数。对于operator new函数来说,它的参数size是指须要申请的空间大小,一般是指new对象的大小,而返回值是申请的堆地址。delete运算符函数的第一个参数是对象的地址,第二个参数是对象的大小,它没有返回值。
 
既然new/delete的实现是相应的运算符函数,那么,如果要实现new/delete,就只须要实现这两个函数就可以了。而这两个函数的主要功能是申请和释放堆空间,这再容易不过了,因为在 Mini CRT中已经实现了堆空间的申请和释放函数:malloc和free。于是new/delete的实现变得尤为简单,它们的实现源代码如下:
//new_delete.cpp#include "minicrt.h" extern "C" void* malloc(unsigned int);extern "C" void free(void*); void* operator new(size_t size){    return malloc(size);}//第二个参数必须存在void operator delete(void* p,size_t size){    free(p);} void* operator new[](size_t size){    return malloc(size);}//删除不是对象的数组void operator delete[](void* p){    free(p);}//删除对象数组void operator delete[](void* p,size_t size){    free(p);}

在上面代码中除了new/delete之外,我们还看到了new[]和 delete[],它们分别是用来申请和释放数组的,在这里一并予以实现。另外除了申请和释放堆空间之外,没有看到任何对象构造和析构的调用,其实对象的构造和析构是在new/delete之前/之后由编译器负责产生相应的代码进行调用的,new/delete仅仅负责堆空间的申请和释放,不负责构造和析构。
 
在真实的C++运行库中,new/delete的实现要比上面的复杂一些,它们除了使用malloc/free申请释放空间之外,还支持new_handler在申请失败时给予程序进行补救的机会、还可能会抛出bad_alloc异常等,由于Mini CRT并不支持异常,所以就省略了这些内容。
 
另外值得一提的是,在使用真实的C++运行库时,也可以使用上面这段代码自己实现new/delete,这样就会将原先C++运行库的new/delete覆盖,使得有机会在new/delete时记录对象的空间分配和释放,可以实现一些特殊的功能,比如检查程序是否有内存泄露。
这种做法往往被称为全局new/delete运算符重载(Global new/delete operator overloading)。除了重载全局new/delete运算符之外,也可以重载某个类的new/delete,这样可以实现一些特殊的需求,比如指定对象申请地址(Replacement new),或者使用自己实现的堆算法对某个对象的申请/释放进行优化,从而提高程序的性能等,这方面的讨论在C++领域已经非常深入了,在此我们不一一展开了。

5.3.2 C++全局构造与析构

5.3.2.1 glibc全局构造和析构的原理
全局对象的构造函数要在main函数之前被执行,全局对象的析构函数要在main函数之后被执行。

要明白全局对象的构造函数是如何在main函数之前被执行,全局对象的析构函数是如何在main函数之后被执行的?我们可以从《修养》11.4章节中找到答案,以下内容根据《修养》得出:
 
注意:因为完整的原理比较复杂,我就粗略说一下,详细内容请参考《修养》
 
结论:对于一个文件来说,全局对象/静态全局对象无论有多少个,都只会生成一个初始化函数_GLOBAL__sub_XXX。

如下面的test.cpp文件会生成_GLOBAL__sub_I__ZN10HelloWorldC2Ei,xixi.cpp会生成_GLOBAL__sub_I__ZN6PersonC2Ei。
//test.cpp#include <stdio.h>#include "xixi.h" class HelloWorld{int m_a;public:    HelloWorld(int a);    ~HelloWorld();   }; HelloWorld::HelloWorld(int a){    m_a = a;    printf("HelloWorld %d\n",a);} HelloWorld::~HelloWorld(){    printf("~HelloWorld %d\n",m_a);} HelloWorld Hw1(1);HelloWorld Hw2(2); int main(int argc,char** argv){    printf("main\n");    xixishow();    return 0;}
//xixi.h#ifndef XIXI_H#define XIXI_H class Person{int m_age;public:    Person(int age);    ~Person();       void showage();}; extern Person xixip;extern void xixishow();#endif
//xixi.cpp#include "xixi.h"#include <stdio.h> Person::Person(int age){    m_age = age;    printf("Person %d\n",age);} Person::~Person(){    printf("~Person %d\n",m_age);} void Person::showage(){        printf("age: %d\n",m_age);    } Person xixip1(1);Person xixip2(2);  void xixishow(){    xixip1.showage();}
g++ -c test.cpp -o test.oobjdump -d test.o

g++ -c xixi.cpp -o xixi.oobjdump -d xixi.o

 
不用担心两个_GLOBAL__sub_XXX函数中都调用_Z41__static_initialization_and_destruction_0ii,而可能出现重定义的问题,因为最后在链接成ELF文件的时候,会将其中的一个_Z41__static_initialization_and_destruction_0ii的末尾添加数字避免重定义。
 
_Z41__static_initialization_and_destruction_0ii函数的内容:
我们可以发现,在_GLOBAL__sub_XXX函数中会调用全局对象/静态全局对象的构造函数,并用__cxa_atexit注册其析构函数。下面是__cxa_atexit函数的原型:
int __cxa_atexit(void (*func) (void *), void * arg, void * dso_handle);
第一个参数是要注册的函数,第二个参数是要给注册的函数用的参数,第三个参数是要注册的函数所在模块的标识句柄。用__cxa_atexit注册的函数会被添加到一个链表中,然后会在exit函数(exit函数会在main函数之后被执行)中遍历该链表,循环调用其中的函数,且__cxa_atexit函数采用头插法,先注册后调用,刚好满足先构造后析构的特性。

总结一下,通过上述分析得到:如果_GLOBAL__sub_XXX初始化函数被调用,那么全局对象/静态全局对象的构造函数和析构函数都会被正确调用。现在的问题是怎么调用_GLOBAL__sub_XXX初始化函数呢?
 
glibc是这样做的,它会把_GLOBAL__sub_XXX函数放在.ctors段(当前新版本的glibc,如2.27版本,会放在.init_array,同时兼容.ctors段),然后在链接阶段,,把各个文件的.ctors段拼接在一起,形成一个新的.ctors段,这样就形成了一个函数指针数组。为了遍历这个数组,我们一般需要知道数组的首地址和数组元素个数,glibc会在链接阶段,链接crtbeginT.o和crtend.o文件,且链接顺序是这样的:
 
链接顺序:
ld crt1.o crti.o crtbeginT.o [user_objects] [system_libraries] crtend.o crtn.o
 
crtbeginT.o:也有一个ctors段,里面存储的是一个4字节的-1(0xFFFFFFFF),由链接器将这个数字改成全局构造函数的数量。根据上面的链接顺序可知,该段是所有.ctors段的开头部分,这个段还将起始地址定义为符号CTOR_LIST,这样CTOR_LIST代表的就是所有.ctors段最后合并后的起始地址了,这样就解决了初始化数组的遍历问题。
 
crtend.o:也有一个.ctors段,它的内容就是一个0,然后定义一个符号CTOREND__,根据链接顺序可知,CTOREND__代表的就是所有.ctors段最后合并后的结束地址了,我们也可以用CTOR_LIST和CTOREND__对初始化数组进行遍历。
 
GCC遍历初始化数组的代码:
void __do_global_ctors_aux(void){    /* Call constructor functions. */    unsigned long nptrs = (unsigned long) __CTOR_LIST__[0];    unsigned i;    for ( i = nptrs; i >= 1; i--)    {        __CTOR_LIST__[i]();    }}

5.3.2.2 glibc全局构造和析构的实现
根据上面glibc全局构造和析构的原理可知,编译器会帮我们生成初始化函数并放在.ctors段,链接器会帮我们把所有文件的.cotrs段拼接在一起,形成一个初始化函数数组,那么我们只需要做以下几点即可:
 
1. 实现类似crtbeginT.o和crtend.o的文件,用来遍历初始化数组。

2. 实现__cxa_atexit函数,因为初始化函数中会用该函数注册析构函数(在下一节atexit实现中实现)。

3. 遍历初始化数组,循环调用其中的初始化函数。
 
实现代码如下:
//crtbegin.cpp#ifndef WIN32typedef void (*ctor_func)(void);//当前新版本的glibc,如2.27版本,会放在`.init_array`,同时兼容`.ctors`段ctor_func ctors_begin[1] __attribute__ ((section(".init_array"))) = {    (ctor_func)-1}; void run_hooks(){    const ctor_func* list = ctors_begin;    while ((int)*++list != -1){        (**list)();    }}#endif
//crtend.cpp#ifndef WIN32typedef void (*ctor_func)(void);//当前新版本的glibc,如2.27版本,会放在`.init_array`,同时兼容`.ctors`段ctor_func crt_end[1] __attribute__ ((section(".init_array"))) = {    (ctor_func)-1}; #endif
//ctor.cpptypedef void (*init_func)(void);#ifdef WIN32#pragma section(".CRT$XCA",long,read)#pragma section(".CRT$XCZ",long,read) __declspec(allocate(".CRT$XCA")) init_func ctors_begin[] = { 0 };__declspec(allocate(".CRT$XCZ")) init_func ctors_end[] = { 0 }; extern "C" void do_global_ctors(){    init_func* p = ctors_begin;    while (p < ctors_end)    {        if (*p != 0)        {            (**p)();        }        ++p;    }} #else void run_hooks();extern "C" void do_global_ctors(){    run_hooks();} #endif
5.3.2.3 msvc全局构造和析构的原理
msvc全局构造和析构的原理和glibc差不多,都是通过段拼接形成数组,然后遍历数组即可。我们以下面的代码为例来进行讲解。
 
结论:对于一个文件来说,全局对象/静态全局对象有n个,那么会生成n个初始化函数(这和glibc有区别),初始化函数的符号名像这样??EHw1@@YAXXZ (void cdecl `dynamic initializer for 'Hw1''(void))。
我们以下面的代码为例来进行讲解。
#include <iostream> class HelloWorld{int m_a;public:    HelloWorld(int a);    ~HelloWorld();   }; HelloWorld::HelloWorld(int a){    m_a = a;    printf("HelloWorld %d\n",a);} HelloWorld::~HelloWorld(){    printf("~HelloWorld %d\n",m_a);} HelloWorld Hw1(1);HelloWorld Hw2(2); int main(int argc,char** argv){    printf("main\n");    return 0;}

我们用Visual Studio在全局对象定义处下断点,查看反汇编:


我们可以发现在初始化函数中会调用全局对象的构造函数,并用atexit注册一个会调用析构函数的函数。下面是atexit函数的原型:
int atexit(void (*function)(void));

参数是要注册的函数,atexit函数的功能和__cxa_atexit一模一样,只是少两个参数罢了,这里就不重复了。

总结一下,通过上述分析得到:如果初始化函数被调用,那么全局对象的构造函数和析构函数都会被正确调用。现在的问题是怎么调用初始化函数呢?
 
msvc是这样做的,它会把初始化函数放在.CRT$XCU段(如上图,上面代码生成的两个初始化函数都放入.CRT$XCU段),然后在链接阶段,把各个文件的.CRT$XCU段拼接在一起,然后放在.rdata段中,这样就形成了一个函数指针数组。为了遍历这个数组,我们一般需要知道数组的首地址和数组元素个数。msvc在运行库中定义两个全局变量,如下:
typedef void (*init_func)(void); #pragma section(".CRT$XCA",long,read) #pragma section(".CRT$XCZ",long,read) __declspec(allocate(".CRT$XCA")) init_func ctors_begin[] = { 0 }; __declspec(allocate(".CRT$XCZ")) init_func ctors_end[] = { 0 };

其中pragma指令是创建名为.CRT$XCA和.CRT$XCZ的段,__declspec指令表示ctors_begin变量会被分配到.CRT$XCA段,ctors_end变量会被分配到.CRT$XCZ段,链接器会把所有相同属性的段合并,即所有段名为.CRT$XC?按照字母顺序依次拼接在一起,然后放入.rdata段。所以实际的段拼接顺序如下:
 
.CRT$XCA ... .CRT$XCU .CRT$XCU .CRT$XCU ... .CRT$XCZ
 
这样,初始化数组的第一个元素为0,ctors_begin指向数组的第一个元素,初始化数组的最后一个元素为0,ctors_end指向数组的最后一个元素。这样我们就可以循环遍历数组,调用其中的初始化函数了。
void do_global_ctors(){    init_func * p = ctors_begin;    while (p < ctors_end)    {        if (*p != 0)         {            (**p)();        }        ++p;    }}
5.3.2.4 msvc全局构造和析构的实现
根据上面msvc全局构造和析构的原理可知,编译器会帮我们生成初始化函数并放在.CRT$XCU段,链接器会把所有相同属性的段合并,即所有段名为.CRT$XC?按照字母顺序依次拼接在一起,然后放入.rdata段,形成一个初始化函数数组,那么我们只需要做以下几点即可:
 
1. 添加属性为long,read,名为.CRT$XCA的段,添加属性为long,read,名为.CRT$XCZ的段

2. 在.CRT$XCA段中分配一个4个字节值为0的变量,在.CRT$XCZ段中分配一个4个字节值为0的变量

3. 实现atexit函数,因为初始化函数会用该函数注册一个会调用析构函数的函数(在下一节atexit实现中实现)

4. 遍历初始化数组,循环调用其中的初始化函数
//ctor.cpptypedef void (*init_func)(void);#ifdef WIN32#pragma section(".CRT$XCA",long,read)#pragma section(".CRT$XCZ",long,read) __declspec(allocate(".CRT$XCA")) init_func ctors_begin[] = { 0 };__declspec(allocate(".CRT$XCZ")) init_func ctors_end[] = { 0 }; extern "C" void do_global_ctors(){    init_func* p = ctors_begin;    while (p < ctors_end)    {        if (*p != 0)        {            (**p)();        }        ++p;    }} #else void run_hooks();extern "C" void do_global_ctors(){    run_hooks();} #endif

5.3.3 atexit实现

atexit()的用法十分简单,即由它注册的函数会在进程退出前,在exit()函数中被调用。atexit()和exit()函数实际上并不属于C++运行库的一部分,它们是C语言运行库的一部分。在前面实现Mini CRT时我们在exit()函数的实现中预留了对atexit()的支持(//mini_crt_call_exit_routine();)。
 
本来可以不实现atexit()的,毕竟它不是非常重要的CRT函数,但是在这里不得不实现atexit的原因是:所有全局对象的析构函数——不管是Linux还是 Windows——都是通过atexit或其类似函数来注册的,以达到在程序退出时执行的目的。
 
实现它的基本思路也很简单,就是使用一个链表把所有注册的函数存储起来,到exit()时将链表遍历一遍,执行其中所有的回调函数,Windows版的atexit的确可以按照这个思路实现。
 
Linux 版的atexit 要复杂一些,导致这个的问题的原因是GCC实现全局对象的析构不是调用的 atexit,而是调用的__cxa_atexit。它不是C语言标准库函数,它是GCC实现的一部分。为了兼容GCC,Mini CRT不得不实现它。它的定义与atexit()有所不同的是,__cxa_atexit所接受的参数类型和atexit不同:
typedef void(*cxa_func_t)(void*);typedef void (*atexit_func_t) (void);int __cxa_atexit(cxa_func_t func,void* arg,void* unused);int atexit(atexit_func_t);
__cxa_atexit所接受的函数指针必须有一个void*型指针作为参数,并且调用__cxa_atexit的时候,这个参数(void* arg)也要随着记录下来,等到要执行的时候再传递进去。也就是说,__cxa_atexit()注册的回调函数是带一个参数的,我们必须把这个参数也记下来。
 
__cxa_atexit的最后一个参数可以忽略,在这里不会用到。
 
于是在设计链表时要考虑到这一点,链表的节点必须能够区分是否是atexit()函数还是__cxa_atexit()注册的函数,如果是__cxa_atexi()注册的函数,还要把回调函数的参数保存下来。我们定义链表节点的结构如下:
typedef struct __func_node{    atexit_func_t func;    void* arg;    int is_cxa;    struct __func_node* next;}func_node;
其中is_cxa成员如果不为0,则表示这个节点是由__cxa_atexit()注册的回调函数,arg成员表示相应的参数。atexit的实现代码如下:
//atexit.c#include "minicrt.h" typedef struct __func_node{    atexit_func_t func;    void* arg;    int is_cxa;    struct __func_node* next;}func_node; static func_node* atexit_list = 0; int register_atexit(atexit_func_t func,void* arg,int is_cxa){    func_node* node;    if (!func)    {        return -1;    }    node = (func_node*)malloc(sizeof(func_node));    if (node == 0)    {        return -1;    }    node->func = func;    node->arg = arg;    node->is_cxa = is_cxa;    node->next = atexit_list;    atexit_list = node;    return 0;} #ifndef WIN32int __cxa_atexit(cxa_func_t func,void* arg,void* unused){    return register_atexit((atexit_func_t)func,arg,1);}#endif int atexit(atexit_func_t func){    return register_atexit(func,0,0);}...完整代码请点击阅读原文查看
值得一提的是,在注册函数时,被注册的函数是插入到列表头部的,而最后mini_crt_call_exit_routine()是从头部开始遍历的,于是由atexit()或__cxa_atexit()注册的函数是按照先注册后调用的顺序,这符合析构函数的规则,因为先构造的全局对象应该后析构。

5.3.4 入口函数修改

由于增加了全局构造和析构的支持,那么需要对Mini CRT的入口函数和exit()函数进行修改,把对do_global_ctors()和 mini_crt_call_exit_routine()的调用加入到entry()和exit()函数中去。修改后的entry.c如下(省略一部分未修改的内容):
//entry.c...void mini_crt_entry(void){...    if (mini_crt_heap_init() == -1)    {        crt_fatal_error("heap initialize failed");    }    if (mini_crt_io_init() == -1)    {        crt_fatal_error("IO initialize failed");    }    do_global_ctors();    ret = main(argc,argv);    exit(ret);} void exit(int exitCode){    mini_crt_call_exit_routine();#ifdef WIN32    ExitProcess(exitCode);#else    asm("movl %0,%%ebx \n"        "movl $1,%%eax \n"        "int $0x80     \n"        "hlt           \n"        :        :"m"(exitCode)        :"%ebx");#endif}

5.3.5 stream与string

C++的Hello World里面一般都会用到cout和string,以展示C++的特性。流和字符串是C++ STL的最基本的两个部分,我们在这一节中为MiniCRT增加string和stream的实现,在有了流和字符串之后,Mini CRT将最终宣告完成,可以考虑将它重命名为Mini CRT++。
 
当然,在真正的STL实现中,string和stream的实现十分复杂,不仅有强大的模板定制功能、缓冲,庞大的继承体系及一系列辅助类。我们在实现时还是以展示和剖析为最基本的目的,简化一切能够简化的内容。string和 stream的实现将遵循下列原则。
  • 不支持模板定制,即这两个类仅支持char字符串类型,不支持自定义分配器等,没有basic_string模板类。
  • 流对象仅实现ofstream,且没有继承体系,即没有ios_base、stream、ostream、fstream等类似的相关类。
  • 流对象没有内置的缓冲功能,即没有stream_buffer类支持。
  • cout作为ofstream的一个实例,它的输出文件是标准输出。

stream和string类的实现用到了不少C++语言的特性,已经一定程度上偏离了本文的意义,因此在此仅将它们的实现源代码列出,而不做更多的详细分析。有兴趣的读者可以参考C++STL的相关实现的资料,如果对C++语言本身不熟悉,也可以跳过这一节,这并不影响对Mini CRT 整体实现的理解。string和iostream的实现如下:
//string.cpp#include "minicrt.h" namespace std{    class string{        unsigned len;        char* pbuf;    public:        explicit string(const char* str);        string(const string&);        ~string();        string& operator=(const string&);        string& operator=(const char* s);        const char& operator[](unsigned idx) const;        char& operator[](unsigned idx);        const char* c_str() const;        unsigned length() const;        unsigned size() const;    };     string::string(const char* str) :        len(0),pbuf(0){            *this = str;        }      string::string(const string& s) :        len(0),pbuf(0){            *this = s;        }    string::~string(){        if (pbuf != 0)        {            delete[] pbuf;            pbuf = 0;        }    }    ...完整代码请点击阅读原文查看

5.3.6 如何使用Mini CRT++

我们的Mini CRT终于完成了对C++的支持,同时它也升级为了Mini CRT++。在这一节中将介绍如何编译并且在自己的程序中使用它。首先展示在 Windows下编译的方法:
cl /c /DWIN32 /GS- /utf-8 entry.c malloc.c printf.c stdio.c string.c atexit.ccl /c /DWIN32 /GS- /GR- /utf-8 crtbegin.cpp crtend.cpp ctor.cpp new_delete.cpp iostream.cpplib entry.obj malloc.obj printf.obj stdio.obj string.obj ctor.obj new_delete.obj atexit.obj iostream.obj /OUT:minicrt.lib

这里新增的一个编译参数为/GR-,它的意思是关闭RTTI功能,否则编译器会为有虚函数的类产生RTTI相关代码,在最终链接时会看到const type_info::vftable符号未定义的错误。
 
而Mini CRT++为了能够在Linux下正常运行,还须要建立一个新的源代码文件叫做sysdep.cpp,用于定义Linux平台相关的一个变量:
//sysdep.cppextern "C"{    void* __dso_handle = 0;}

这个变量是用于处理共享库的全局对象析构的。我们知道共享库也可以拥有全局对象,这些对象在共享库被卸载时必须被正确地析构。而共享库有可能在进程退出之前被卸载,比如使用dlopen/dlclose就可能导致这种情况。
那么一个问题就产生了,如何使得属于某个共享库的全局对象析构函数在共享库被卸载时运行呢? GCC的做法是向__cxa_atexit()传递一个参数,这个参数用于标示这个析构函数属于哪个共享对象。
我们在前面实现__cxa_atexit()时忽略了第三个参数,实际上这第三个参数就是用于标示共享对象的,它就是__dso_handle这个符号。由于在Mini CRT++中并不考虑对共享库的支持,于是我们就仅仅定义这个符号为0,以防止链接时出现符号未定义错误。
 
Mini CRT++在Linux平台下编译的方法如下:
gcc -c -m32 -fno-builtin -nostdlib -fno-stack-protector entry.c malloc.c stdio.c string.c printf.c atexit.cg++ -c -m32 -nostdinc++ -fno-rtti -fno-exceptions -fno-builtin -nostdlib -fno-stack-protector crtbegin.cpp crtend.cpp ctor.cpp new_delete.cpp sysdep.cpp iostream.cppar -rs minicrt.a malloc.o printf.o stdio.o string.o ctor.o atexit.o iostream.o new_delete.o sysdep.o
  • -fno-rtti的作用与cl的/GR-作用一样,用于关闭RTTI。

  • -fno-exceptions的作用用于关闭异常支持,否则GCC会产生异常支持代码,可能导致链接错误。

  • -m32是指生成32位的中间目标文件,因为我的实验环境是64位的,而实现的运行库是32位的

在Windows下使用Mini CRT++的方法如下:
cl /c /DWIN32 /GR- /utf-8 test.cpplink test.obj minicrt.lib kernel32.lib /NODEFAULTLIB /DEBUG /entry:mini_crt_entry

在Linux下使用Mini CRT++的方法如下:
g++ -c -m32 -nostdinc++ -fno-rtti -fno-exceptions -fno-builtin -nostdlib -fno-stack-protector test.cppld -static -m elf_i386 -e mini_crt_entry entry.o crtbegin.o test.o minicrt.a crtend.o -o test

crtbegin.o和crtend.o在ld链接时位于用户目标文件的最开始和最后端,以保证链接的正确性。

六、小结

在本文中,我们首先尝试实现了一个支持C运行的简易CRT:Mini CRT。接着又为它加上了一些C++语言特性的支持,并且将它称为Mini CRT++。
在实现C语言运行库的时候,介绍了入口函数entry、堆分配算法malloc/free、IO和文件操作fopen/fread/fwrite/fclose,字符串函数strlen/strcmp/atoi和格式化字符串printf/fprintf。
在实现C++运行库时,着眼于实现C++的几个特性:new/delete、全局构造和析构、stream和string类。

因此在实现Mini CRT++的过程中,我们得以详细了解并且亲自动手实现运行库的各个细节,得到一个可编译运行的瘦身运行库版本。
当然,Mini CRT++所包含的仅仅是真正的运行库的一个很小子集,它并不追求完整,也不在运行性能上做优化,它仅仅是一个CRT的雏形,虽说很小,但能够通过Mini CRT++窥视真正的CRT和C++运行库的全貌,抛砖引玉、举一反三正是Mini CRT++的目的。

七、问题解答

7.1 为什么在windows平台运行C++运行库的测试程序时,endl函数好像不起作用?

这是msvc编译器的问题。

比如下面的代码:
//newtest.cpp#include "iostream"#include "string" using namespace std; int main(int argc,char* argv[]){    string* msg = new string("Hello World");    cout << *msg << endl;    delete msg;    return 0;}

用IDA查看main函数的反汇编
我们发现,并不是直接使用cout这个全局变量,而是进行拷贝构造,而且是强转cout为父类型的指针后进行的拷贝构造,所以调用的是父类的拷贝构造,这还不关键。

关键的是在endl函数调用之前,拷贝构造出来的对象就被析构了,不要紧,因为我写的析构函数不会改变属性,还是fp存储的还是stdout,但是存储this指针的是esp。

那么就导致在开栈的时候,就把属性fp给改变了,就导致endl函数的调用失败,我搞不懂msvc编译器怎么不用ebp-xxx来存储局部变量
 
相反g++编译器就很正常
 
局部变量ebp-xxx作为this对全局变量cout进行拷贝构造,然后打印字符串,调用endl函数,然后才析构

7.2 minicrt.lib(ctor.obj) : warning LNK4210: 存在 .CRT 节;可能有未处理的静态初始值设定项或结束符

这是在编译运行C++库时出现的警告,但不影响运行库的使用

7.3 new和delete的小心得

new对象数组的话,会多分配一个int,用于在开头存储元素的个数,用于循环析构和传递delete[]第二个参数,而对于不是new对象数组,那么没必要分配一个int,因为不需要循环析构,如下代码:
//test.cpp#include "iostream"#include "string" class Person{private:    int age;    char* name;public:    Person(){        printf("Person\n");    }    ~Person(){         printf("~Person\n");    }}; int main(int argc,char* argv[]){    Person* arr = new Person[2];    return 0;}

 
对于delete[]的使用,不是对象数组,那么delete和delete[]没有区别,但是对于对象数组,只有delete[]才会析构每个对象和正确释放内存,但如果用delete对象数组的话,只会调用第一个对象的析构函数,并且释放的地址是原地址+sizeof(int),这是不对的,就是简单当成一个对象进行析构和释放内存了(以为当前地址就是申请的地址,当然对于一个对象来说就是如此)

7.4 附完整库编译命令


C运行库库编译命令 gcc -c -fno-builtin -nostdlib -fno-stack-protector -m32 entry.c malloc.c stdio.c string.c printf.c && ar -rs minicrt.a malloc.o printf.o stdio.o string.o && gcc -c -ggdb -fno-builtin -nostdlib -fno-stack-protector -m32 test.c && ld -static -m elf_i386 -e mini_crt_entry entry.o test.o minicrt.a -o test  cl /c /DWIN32 /utf-8 /GS- entry.c malloc.c printf.c stdio.c string.c && lib entry.obj malloc.obj printf.obj stdio.obj string.obj /OUT:minicrt.lib && cl /c /DWIN32 /utf-8 test.c && link test.obj minicrt.lib kernel32.lib /NODEFAULTLIB /DEBUG /entry:mini_crt_entry ——————————————————————————————————————————————————————— C++运行库库编译命令 cl /c /DWIN32 /GS- /utf-8 entry.c malloc.c printf.c stdio.c string.c atexit.c && cl /c /DWIN32 /GS- /GR- /utf-8 crtbegin.cpp crtend.cpp ctor.cpp new_delete.cpp iostream.cpp && lib entry.obj malloc.obj printf.obj stdio.obj string.obj ctor.obj new_delete.obj atexit.obj iostream.obj /OUT:minicrt.lib && cl /c /DWIN32 /GR- /utf-8 test.cpp && link test.obj minicrt.lib kernel32.lib /NODEFAULTLIB /DEBUG /entry:mini_crt_entry  gcc -c -m32 -fno-builtin -nostdlib -fno-stack-protector entry.c malloc.c stdio.c string.c printf.c atexit.c && g++ -c -m32 -nostdinc++ -fno-rtti -fno-exceptions -fno-builtin -nostdlib -fno-stack-protector crtbegin.cpp crtend.cpp ctor.cpp new_delete.cpp sysdep.cpp iostream.cpp && ar -rs minicrt.a malloc.o printf.o stdio.o string.o ctor.o atexit.o iostream.o new_delete.o sysdep.o && g++ -c -m32 -nostdinc++ -fno-rtti -fno-exceptions -fno-builtin -nostdlib -fno-stack-protector test.cpp && ld -static -m elf_i386 -e mini_crt_entry entry.o crtbegin.o test.o minicrt.a crtend.o -o test


- End -

看雪ID:genliese

https://bbs.pediy.com/user-home-825187.htm

  *本文由看雪论坛 genliese 原创,转载请注明来自看雪社区。

# 往期推荐

公众号ID:ikanxue
官方微博:看雪安全
商务合作:[email protected]

球分享

球点赞

球在看

点击“阅读原文”,了解更多!


文章来源: http://mp.weixin.qq.com/s?__biz=MjM5NTc2MDYxMw==&mid=2458379621&idx=1&sn=f1877c13bc3e1ebe4b73898b276cf661&chksm=b180d5ef86f75cf985c4a7ef5cb7f96c768f0f2d783272097e0abc8f37231b46ca29f051d36e#rd
如有侵权请联系:admin#unsafe.sh