1. 背景
为了总结在《程序员的自我修养--链接、装载与库》这本书中的”运行库实现“章节学到的知识,现编写”运行库实现“一文。因为要讲清楚实现运行库的细节是比较复杂的,同时也限于篇幅和避免复杂繁琐,所以本文只会提及关键实现处并给出源代码,同时给出相关知识点在《程序员的自我修养--链接、装载与库》中的相关章节和相关参考资料,如有不理解的地方请回帖或私信我,鉴于本人才疏学浅,如有错误,欢迎指正。
2. 声明
1.为了便于读者理解,本文大量复制《程序员的自我修养--链接、装载与库》中的文字,如有侵权,请管理员删帖或联系我删帖。
2.《程序员的自我修养--链接、装载与库》已经给出了“运行库实现”的完整源代码,且本文中的绝大多数源代码都是直接复制其中的代码(如有侵权,请管理员删帖或联系我删帖),但是因为书籍年代久远(09年出版的)和实验环境的改变的原因,我修改了一部分源代码以顺利运行。
3. 目的
实现32位简单版Windows和Linux双平台的C++运行库
- CRT库功能
- C++库功能
- new/delete
- 类:stream、string
4. 意义
帮助理解CRT和C++运行库的结构,从而举一反三
5. 实现
我们先实现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 ebp
mov 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;
}
//合并前一块
if (header->prev != NULL && header->prev->type == HEAP_BLOCK_USED)
{
header->prev->next = header->next;
if (header->next != NULL)
{
header->next->prev = header->prev;
}
header->prev->size += header->size;
header = header->prev;
}
//合并后一块
if (header->next != NULL && header->next->type == HEAP_BLOCK_USED)
{
header->size += header->next->size;
header->next = header->next->next;
if (header->next != NULL)
{
header->next->prev = header;
}
}
}
void* malloc(unsigned size){
heap_header* header = 0;
if (size == 0)
{
return NULL;
}
header = list_head;
while (header != 0)
{
if (header->type == HEAP_BLOCK_USED)
{
header = header->next;
continue;
}
//标记为USED && 没有空闲块
if (header->size >= size + HEADER_SIZE &&
header->size <= size + HEADER_SIZE * 2)
{
header->type = HEAP_BLOCK_USED;
}
//标记为USED && 有空闲块
if (header->size > size + HEADER_SIZE * 2)
{
//split
heap_header* next = (heap_header*)ADDR_ADD(header,size+HEADER_SIZE);
next->next = header->next;
next->prev = header;
next->type = HEAP_BLOCK_FREE;
next->size = header->size - HEADER_SIZE - size;
if (header->next != NULL)
{
header->next->prev = next;
}
header->next = next;
header->type = HEAP_BLOCK_USED;
header->size = size + HEADER_SIZE;
return ADDR_ADD(header,HEADER_SIZE);
}
header = header->next;
}
return (void*)-1;
}
#ifndef WIN32
//brk成功返回0,失败返回-1,所以不能用brk
//linux brk system call
// static int brk(void* end_data_segment){
// int ret = 0;
// //brk system call number: 45
// //int /usr/include/asm-i386//unistd.h
// //#define __NR_brk 45
// asm(
// "movl $45,%%eax \n"
// "movl %1,%%ebx \n"
// "int $0x80 \n"
// "movl %%eax,%0 \n"
// :"=r"(ret): "m"(end_data_segment));
// }
static void* mmap2(void* addr, unsigned len, int prot,
int flags, int fd, int offset){
void* ret = 0;
asm(
"pushl %%ebp \n"
"movl $192,%%eax \n"
"movl %1,%%ebx \n"
"movl %2,%%ecx \n"
"movl %3,%%edx \n"
"movl %4,%%esi \n"
"movl %5,%%edi \n"
"movl %6,%%ebp \n"
"int $0x80 \n"
"movl %%eax,%0 \n"
"popl %%ebp \n"
:"=r"(ret)
: "m"(addr),"m"(len),"m"(prot),"m"(flags),"m"(fd),"m"(offset)
:"%ebx","%ecx","%edx","%esi","%edi");
return ret;
}
#endif
#ifdef WIN32
#include <Windows.h>
#endif
int mini_crt_heap_init(){
void* base = NULL;
heap_header* header = NULL;
//32MB heap size
unsigned heap_size = 1024 * 1024 * 32;
#ifdef WIN32
base = VirtualAlloc(0,heap_size,MEM_COMMIT | MEM_RESERVE,PAGE_READWRITE);
if(base == NULL){
return -1;
}
#else
#define MAP_PRIVATE 0x02 /* Changes are private. */
#define MAP_ANONYMOUS 0x20
#define PROT_READ 0x1 /* Page can be read. */
#define PROT_WRITE 0x2 /* Page can be written. */
base = mmap2(0,heap_size,PROT_READ | PROT_WRITE,MAP_PRIVATE | MAP_ANONYMOUS,-1,0);
if(base == (void*)-1){
return -1;
}
#endif
header = (heap_header*)base;
header->size = heap_size;
header->type = HEAP_BLOCK_FREE;
header->next = NULL;
header->prev = NULL;
list_head = header;
return 0;
}
我们在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;
}
//返回读取的item
int fread(void *buffer, int size, int count, FILE *stream)
{
int read = 0;
if (!ReadFile((HANDLE)stream, buffer, size * count, &read, 0))
{
return -1;
}
if (size != 0)
{
return read / size;
}else{
return 0;
}
}
//返回写入的item
int fwrite(const void *buffer, int size, int count, FILE *stream)
{
int written = 0;
if (!WriteFile((HANDLE)stream, buffer, size * count, &written, 0))
{
return -1;
}
if (size != 0)
{
return written / size;
}else{
return 0;
}
}
int fclose(FILE *fp)
{
return CloseHandle((HANDLE)fp);
}
//返回新指针
int fseek(FILE *fp, int offset, int set)
{
return SetFilePointer((HANDLE)fp, offset, 0, set);
}
#else //ifndef WIN32
static int open(const char* pathname,int flags,int mode){
int fd = 0;
asm("movl $5,%%eax \n"
"int $0x80 \n"
:"=a"(fd)
:"b"(pathname),"c"(flags),"d"(mode));
return fd;
}
static int read(int fd,void* buffer,unsigned size){
int ret = 0;
asm("movl $3,%%eax \n"
"int $0x80 \n"
:"=a"(ret)
:"b"(fd),"c"(buffer),"d"(size));
return ret;
}
static int write(int fd,const void* buffer,unsigned size){
int ret = 0;
asm("movl $4,%%eax \n"
"int $0x80 \n"
:"=a"(ret)
:"b"(fd),"c"(buffer),"d"(size));
return ret;
}
static int close(int fd){
int ret = 0;
asm("movl $6,%%eax \n"
"int $0x80 \n"
:"=a"(ret)
:"b"(fd));
return ret;
}
static int seek(int fd,int offset,int mode){
int ret = 0;
asm("movl $19,%%eax \n"
"int $0x80 \n"
:"=a"(ret)
:"b"(fd),"c"(offset),"d"(mode));
return ret;
}
FILE* fopen(const char* filename,const char* mode){
int fd = -1;
int flags = 0;
int access = 00700; //创建文件的权限
//来自于/usr/include/asm-generic/fcntl.h
//注意:以0开头的数字是八进制的
#define O_RDONLY 00000000
#define O_WRONLY 00000001
#define O_RDWR 00000002
#ifndef O_CREAT
#define O_CREAT 00000100 /* not fcntl */
#endif
#ifndef O_TRUNC
#define O_TRUNC 00001000 /* not fcntl */
#endif
#ifndef O_APPEND
#define O_APPEND 00002000
#endif
if (strcmp(mode,"w") == 0)
{
flags |= O_WRONLY | O_CREAT | O_TRUNC;
}
if (strcmp(mode,"w+") == 0)
{
flags |= O_RDWR | O_CREAT | O_TRUNC;
}
if (strcmp(mode,"r") == 0)
{
flags |= O_RDONLY;
}
if (strcmp(mode,"r+") == 0)
{
flags |= O_RDWR;
}
fd = open(filename,flags,access);
return (FILE*)fd;
}
int fread(void* buffer,int size,int count,FILE* stream){
int ret = 0;
ret = read((int)stream,buffer,size * count);
if (ret == -1)
{
return -1;
}else{
if (size != 0)
{
return ret / size;
}else{
return 0;
}
}
}
int fwrite(const void* buffer,int size,int count,FILE* stream){
int ret = 0;
ret = write((int)stream,buffer,size * count);
if (ret == -1)
{
return -1;
}else{
if (size != 0)
{
return ret / size;
}else{
return 0;
}
}
}
int fclose(FILE* fp){
return close((int)fp);
}
int fseek(FILE* fp,int offset,int set){
return seek((int)fp,offset,set);
}
#endif
另外还有―段与文件操作相关的声明须放在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;
}
*p = 0;
for (--p; head < p; ++head,--p)
{
char temp = *head;
*head = *p;
*p = temp;
}
return str;
}
//有缺陷:没有检查空指针
int strcmp(const char* src,const char* dst){
int ret = 0;
unsigned char* p1 = (unsigned char*)src;
unsigned char* p2 = (unsigned char*)dst;
//这里有缺陷,应该手动触发异常
while (!(ret = *p1 - *p2) && *p2)
{
++p1,++p2;
}
return ret;
}
char* strcpy(char* dest,const char*src){
char* ret = dest;
if (!dest || !src)
{
return dest;
}
while (*src)
{
*dest++ = *src++;
}
*dest = '\0';
return ret;
}
unsigned strlen(const char* str){
int cnt = 0;
if (!str)
{
return 0;
}
for (; *str != '\0'; ++str)
{
++cnt;
}
return cnt;
}
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
int vfprintf(FILE* stream,const char* format,va_list arglist){
int translating = 0;
int ret = 0;
const char* p = 0;
for (p = format; *p != '\0'; ++p)
{
switch (*p)
{
case '%':
if (!translating)
{
translating = 1;
}else{
if (fputc('%',stream) == -1)
{
return EOF;
}else{
++ret;
translating = 0;
}
}
break;
case 'd':
if (translating) //%d
{
char buf[16];
translating = 0;
itoa(va_arg(arglist,int),buf,10);
if (fputs(buf,stream) == -1)
{
return EOF;
}else{
ret += strlen(buf);
}
}else{
if (fputc('d',stream) == -1)
{
return EOF;
}else{
++ret;
}
}
break;
case 's':
if (translating)
{
const char* str = va_arg(arglist,const char*);
translating = 0;
if (fputs(str,stream) == -1)
{
return EOF;
}else{
ret += strlen(str);
}
}else{
if (fputc('s',stream) == -1)
{
return EOF;
}else{
++ret;
}
}
break;
default:
if (translating)
{
translating = 0;
}
if (fputc(*p,stream) == -1)
{
return EOF;
}else{
++ret;
}
break;
}
}
return ret;
}
int printf(const char* format,...){
va_list(arglist);
va_start(arglist,format);
return vfprintf(stdout,format,arglist);
}
int fprintf(FILE* stream,const char* format,...){
va_list(arglist);
va_start(arglist,format);
return vfprintf(stream,format,arglist);
}
- 定义模式:翻译模式/普通模式。
- 循环整个格式字符串。
- 如果遇到
%
- 普通模式:进入翻译模式;
- 翻译模式:输出
%
,退出翻译模式。
- 如果遇到
%
后面允许出现的特殊字符(如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 __cplusplus
extern "C"{
#endif
//malloc
#ifndef NULL
#define NULL (0)
#endif
#ifndef _SIZE_T_DEFINED
typedef 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();
//字符串
char* itoa(int n,char* str,int radix);
int strcmp(const char* src,const char* dst);
char* strcpy(char* dest,const char* src);
unsigned strlen(const char* str);
//文件与IO
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
int mini_crt_io_init();
FILE* fopen(const char* filename,const char* mode);
int fread(void* buffer,int size,int count,FILE* stream);
int fwrite(const void* buffer,int size,int count,FILE* stream);
int fclose(FILE* fp);
int fseek(FILE* fp,int offset,int set);
//printf
int fputc(int c,FILE* stream);
int fputs(const char* str,FILE* stream);
int printf(const char* format,...);
int fprintf(FILE* stream,const char* format,...);
//internal
void do_global_ctors();
void mini_crt_call_exit_routine();
//atexit
typedef void (*atexit_func_t) (void);
int atexit(atexit_func_t);
//下面两个是C++库用到的
typedef void(*cxa_func_t)(void*);
int __cxa_atexit(cxa_func_t func,void* arg,void*);
#ifdef __cplusplus
}
#endif
#endif //__MINI_CRT_H__
接下来的问题是如何编译得到库文件了。由于动态库的实现比静态库要复杂,所以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.c
ar -rs minicrt.a malloc.o printf.o stdio.o string.o
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
/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.c
ld -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.c
link 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.c
objdump -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.c
objdump -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.o
objdump -d test.o
g++ -c xixi.cpp -o xixi.o
objdump -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 WIN32
typedef 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 WIN32
typedef void (*ctor_func)(void);
//当前新版本的glibc,如2.27版本,会放在`.init_array`,同时兼容`.ctors`段
ctor_func crt_end[1] __attribute__ ((section(".init_array"))) = {
(ctor_func)-1
};
#endif
//ctor.cpp
typedef 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.cpp
typedef 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 WIN32
int __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);
}
void mini_crt_call_exit_routine(){
func_node* p = atexit_list;
for(;p != 0;p = p->next){
#ifdef WIN32
p->func();
#else
if (p->is_cxa)
{
((cxa_func_t)p->func)(p->arg);
}else{
p->func();
}
#endif
free(p);
}
atexit_list = 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;
}
}
string& string::operator=(const string& s){
if (&s == this)
{
return *this;
}
this->~string();
len = s.len;
pbuf = strcpy(new char[len + 1],s.pbuf);
return *this;
}
string& string::operator=(const char* s){
this->~string();
len = strlen(s);
pbuf = strcpy(new char[len + 1],s);
return *this;
}
const char& string::operator[](unsigned idx) const{
return pbuf[idx];
}
char& string::operator[](unsigned idx){
return pbuf[idx];
}
const char* string::c_str() const{
return pbuf;
}
unsigned string::length() const{
return len;
}
unsigned string::size() const{
return len;
}
ofstream& operator<<(ofstream o,const string& s){
return o << s.c_str();
}
}
//iostream
#include "minicrt.h"
namespace std{
class ofstream{
public:
enum openmode{in = 1,out = 2,binary = 4,trunc = 8};
protected:
const char* filename;
ofstream::openmode md;
FILE* fp;
public:
ofstream(const ofstream& of);
ofstream();
explicit ofstream(const char* filename,ofstream::openmode md = ofstream::out);
~ofstream();
ofstream& operator<<(char c);
ofstream& operator<<(int n);
ofstream& operator<<(const char* str);
ofstream& operator<<(ofstream& (*)(ofstream&));
void open(const char* filename,ofstream::openmode md = ofstream::out);
void close();
ofstream& write(const char* buf,unsigned size);
};
inline ofstream& endl(ofstream& o){
return o << '\n';
}
class stdout_stream : public ofstream{
public:
stdout_stream();
stdout_stream(const stdout_stream& std);
};
extern stdout_stream cout;
}
//iostream.cpp
#include "minicrt.h"
#include "iostream"
#ifdef WIN32
#include <Windows.h>
#endif
namespace std{
stdout_stream::stdout_stream() : ofstream(){
fp = stdout;
}
stdout_stream::stdout_stream(const stdout_stream& std){
fp = std.fp;
}
stdout_stream cout;
ofstream::ofstream(const ofstream& of){
if (of.fp == 0)
{
return;
}else if(of.fp == stdout){
this->fp = stdout;
return;
}
this->filename = (new char[strlen(filename) + 1],of.filename);
this->md = md;
open(this->filename,this->md);
}
ofstream::ofstream() : fp(0){
}
ofstream::ofstream(const char* filename,ofstream::openmode md) : fp(0){
this->filename = (new char[strlen(filename) + 1],filename);
this->md = md;
open(this->filename,md);
}
ofstream::~ofstream(){
close();
}
ofstream& ofstream::operator<<(char c){
fputc(c,fp);
return *this;
}
ofstream& ofstream::operator<<(int n){
fprintf(fp,"%d",n);
return *this;
}
ofstream& ofstream::operator<<(const char* str){
fprintf(fp,"%s",str);
return *this;
}
ofstream& ofstream::operator<<(ofstream& (*manip)(ofstream&)){
return manip(*this);
}
void ofstream::open(const char* filename,ofstream::openmode md){
char mode[4];
close();
switch (md)
{
case out | trunc:
strcpy(mode,"w");
break;
case out | in | trunc:
strcpy(mode,"w+");
break;
case out | trunc | binary:
strcpy(mode,"wb");
break;
case out | in | trunc | binary:
strcpy(mode,"wb+");
break;
default:
break;
}
fp = fopen(filename,mode);
}
void ofstream::close(){
if (fp == stdout)
{
return;
}
if (fp)
{
delete[] filename;
fclose(fp);
}
fp = 0;
}
ofstream& ofstream::write(const char* buf,unsigned size){
fwrite(buf,size,1,fp);
return *this;
}
}
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.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
这里新增的一个编译参数为/GR-
,它的意思是关闭RTTI
功能,否则编译器会为有虚函数的类产生RTTI
相关代码,在最终链接时会看到const type_info::vftable
符号未定义的错误。
而Mini CRT++
为了能够在Linux下正常运行,还须要建立一个新的源代码文件叫做sysdep.cpp
,用于定义Linux平台相关的一个变量:
//sysdep.cpp
extern "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.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
-fno-rtti
的作用与cl
的/GR-
作用一样,用于关闭RTTI
。
-fno-exceptions
的作用用于关闭异常支持,否则GCC会产生异常支持代码,可能导致链接错误。
-m32
是指生成32位的中间目标文件,因为我的实验环境是64位的,而实现的运行库是32位的
在Windows下使用Mini CRT++
的方法如下:
cl /c /DWIN32 /GR- /utf-8 test.cpp
link 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.cpp
ld -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链接时位于用户目标文件的最开始和最后端,以保证链接的正确性。
6. 小结
在本文中,我们首先尝试实现了一个支持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. 问题解答
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
8. 完整源码下载
见附件 密码均为:52pojie
C_minicrt.zip:C运行库源码
CPP_minicrt.zip:C++运行库源码