编写简单的c运行库

来源:互联网 发布:终结者 知乎 编辑:程序博客网 时间:2024/06/09 16:45

看了《程序员自我修养》这本书后,对目标文件、可执行文件的结构有了比较清晰的了解,对目标文件链接成可执行文件的过程和程序运行库有了大致的认识。不过正如“纸上得来终觉浅,绝知此事需恭行”,很多东西看似容易,但实践的时候却往往不是这样,在实践中往往能发现很多的问题。《程序员自我修养》这本书我觉得是理论与实践很好的结合了,它在最后一章给出了一个c和c++运行库的简单版的实现,通过实现这个可以更为深刻地理解可执行文件的结构、程序的执行、运行库的实现。参考这边书,我在linux下实现的一个简单的c运行库,这个运行库主要实现了文件操作、字符串操作、动态内存分配三个方面。

1 程序的入口函数实现

  当被问到程序的入口函数是什么的时候,很多人都会回答是main函数。其实这是不准确的,因为如果main是第一个开始执行的函数,那么对于在main函数外面定义的变量,特别是c++中的对象,由谁来初始化它们呢?还有就是我们用atexit函数注册的清理函数在main函数结束之后才被调用。种种都说明在main函数之外还有函数,它负责建立程序执行所需的环境,包括变量的初始化和堆的初始化,清理函数的调用,构造函数和析构函数调用(如果是c++的话)。这个函数在为程序准备好了运行环境之后才开始调用main函数。我实现的入口函数很简单,它主要负责对堆进行初始化、调用main函数和退出程序。

复制代码
 1     /*初始化堆*/ 2     if (CrtInitHeap() == -1) 3         CrtFataError("init heap error\n"); 4     /*获取argc和argv*/ 5     __asm__ volatile( 6         "movl 0x4(%%ebp), %0\n\t" 7         "lea 0x8(%%ebp), %1"     8          :"=r"(argc),"=r"(argv) 9          );10     environ = &argv[argc + 1];11     ret = main(argc, argv);12     exit(ret);
复制代码

  当程序被加载到内存中,在运行上面的入口函数前,操作系统就将程序的命令行参数和环境变量放到了该程序的栈中,这时候栈的分布为:

此时,栈顶指针指向ebp,正如我在程序的栈结构中说的那样,当进入函数时,通常做的第一件事就是把ebp保存到栈中。从上图可以知道argc在esp+4中,argv在esp+8中,程序中用了嵌入汇编来获得它们的值,程序中第10行获得环境变量,然后就是调用main函数了,向main中传递了argc和argv(现在可以明白为什么我们可以再main中直接用argc和argv了吧!!),最后根据main的返回值调用exit结束程序。

  这里最主要的还是初始化堆和管理堆比较麻烦些,为什么要初始化堆呢?因为我们程序经常要用到malloc函数来动态申请内存,而malloc函数是从堆中获取内存的。你可能会说堆不是在程序开始运行前就由操作系统分配好了堆给程序吗?其实我们这个初始化堆的意思是说把分配给堆的地址空间变大,并不是真正的申请内存,只有当程序用malloc申请并用到时才真正分配内存(用时才分配,这个是内存管理的时,我们就不深究了)。因为程序运行时给堆的地址空间是比较小的,所以需要初始化堆。管理堆的工作主要是负责堆的分配和回收。

2 堆的初始化

  正如上面讲的,我们的堆初始化就是扩充堆的地址空间。我们可以用系统调用brk来完成这个,不过现在我们不能用glibc的库,所以只能用汇编来调用了brk系统调用了。

复制代码
 1 int brk(void *addr) 2 { 3     int ret; 4  5     __asm__ volatile( 6         "movl %1, %%ebx\n\t"     7         "movl $45, %%eax\n\t" 8         "int $0x80 \n\t" 9         :"=a"(ret)10         :"m"(addr)11     );12     return ret;13 }
复制代码

  brk的系统调用号时45,当传递的参数是NULL时,brk会返当前堆的起始地址,brk的参数是堆的结束地址,所以要扩充堆地址空间,必须先获取堆的起始地址,然后根据起始地址来扩充堆地址空间。 

复制代码
1     void *heapBase, *heapEnd;2     int heapSize = 1024 * 1024 * 32;3 4     /*扩展堆的大小到32MB*/5     heapBase = (void *)brk((void *)0);6     heapEnd = (void *)brk(ADDR_ADD(heapBase, heapSize));7     if (heapEnd == heapBase)8         return -1;
复制代码

3 堆的管理

  堆的工作主要是管理用brk申请来的空间,负责空间的分配和回收。我用的是双向链表实现的,链表的节点结构为heap_header结构体,当用malloc函数申请堆空间时,采用最先适应算法,只要找到一个满足申请空间大小的空闲节点,就将该节点所在的空间分配给它,如果该节点的空间比较大,则拆分这个节点为两个节点,前一个节点分配申请空间的大小,并标记为占用,后一个为空闲节点。在用free函数释放堆空间时,确定该空间所在的节点,并将该节点标记为空闲,查找该节点的相邻节点是否是空闲节点,如果是则合并。

  堆空间中节点的数据结构为: 

复制代码
typedef struct _heap_header{    enum heap_state state;/*是否空闲*/    int size;/*本节点空间大小*/    struct _heap_header *next;/*下一个节点*/    struct _heap_header *pre;/*上一个节点*/}heap_header;
复制代码
在前面编写简单的c运行库(一)中主要实现了调用main函数前的初始化、获取参数和环境变量、退出程序等工作。接下来我们真正实现c标准库中的一些函数(主要是文件操作、字符串操作函数)。不过我们对这些函数的实现力争简单,对于效率方面考虑的不是很多,因为目的主要还是学习神秘的库是怎么实现的。

1 文件操作

  c中的标准I/O库都是带有缓存的,我们在这里为了实现的简单,将缓存省略了,直接包装了有关文件操作的系统调用。现在我们直接看文件打开的函数:

复制代码
 1 static int open(const char *pathname, int flags, int mode) 2 { 3     int ret; 4  5     __asm__ volatile( 6         "int $0x80" 7         :"=a"(ret) 8         :"0"(5),"b"(pathname),"c"(flags),"d"(mode) 9     );10     if (ret >= 0)11         return ret;12     return -1;13 }
复制代码

  open函数中直接调用了嵌入汇编调用了系统调用。对于系统调用的返回值,如果是负数,直接返回-1,否则直接返回。这个函数是系统调用的一个包装,本质其实就是个系统调用。然后我们在open函数的基础上实现c标志库函数中的fopen函数。

复制代码
 1 FILE *fopen(const char *path, const char *mode) 2 { 3     int fd = -1; 4     int flags = 0; 5     int access = 00700; /*创建文件的权限*/ 6  7     if (strcmp(mode, "w") == 0) 8         flags |= O_WRONLY | O_CREAT | O_TRUNC; 9     if (strcmp(mode, "w+") == 0)10         flags |= O_RDWR | O_CREAT | O_TRUNC;11     if (strcmp(mode, "r") == 0)12         flags |= O_RDONLY;13     if (strcmp(mode, "r+") == 0)14         flags |= O_RDWR | O_CREAT;15     fd = open(path, flags, access);16     return (FILE *)fd;17 }
复制代码

  由于我没有像标志I/O库那样实现缓存,所以我直接把FILE定义为int型,这样我们用FILE就相当于用了文件描述符。从上面的代码中可以知道我设置了文件的创建权限只有文件创建者有读写执行的权限,还有就是我只实现了以只读、只写、读写方式打开文件,对于追加等方式没有实现。然后函数read、fread和write、fwrite都可以用相同的方式实现,还有fputc,fputs也是已一样的。

2 输出函数

  I/O函数中比较麻烦的要属实现printf、fprintf这些可变参数的函数,当然这些函数都是调用vfprintf函数实现的,所以只要实现了vfprintf函数,其它的函数实现就比较简单了。

  首先来看下我实现的vfprintf函数代码:

复制代码
 1 int vfprintf(FILE *stream, const char *format, va_list ap) 2 { 3     int n = 0, flag = 0, ret;     4     char str[20]; 5  6     while (*format) 7     { 8         switch (*format)     9         {10             case '%':11                 if (flag == 1)12                 {13                     fputc('%', stream);14                     flag = 0;15                     n ++;16                 }17                 else18                     flag = 1;19                 break;20             case 'd':21                 if (flag == 1)22                 {23                     itoa((int)va_arg(ap, int), str, 10);            24                     ret = fputs(str, stream);25                     n += ret;26                 }27                 else28                 {29                     fputc('d', stream);    30                     n ++;31                 }32                 flag = 0;33                 break;34             case 's':35                 if (flag == 1)36                 {37                     ret = fputs((char *)va_arg(ap, char *), stream);    38                     n += ret;39                 }40                 else41                 {42                     fputc('s', stream);    43                     n ++;44                 }45                 flag = 0;46                 break;47             case '\n':48                 /*换行*/49                 fputc(0x0d, stream);    50                 n ++;51                 fputc(0x0a, stream);52                 n ++;53                 break;54             default:55                 fputc(*format, stream);56                 n ++;57         }58         format ++;59     }60     return n;61 }
复制代码

  vfprintf主要麻烦的是对格式化字符串的分析,我们在这里使用一种比较简单的算法:

  (1)定义模式:翻译模式/普通模式

  (2)循环整个格式字符串

    a) 如果遇到%

      i 普通模式:进入翻译模式

      ii 翻译模式: 输出%, 退出翻译模式

    b) 如果遇到%后面允许出现的特殊字符(如d和s)

      i 翻译模式:从不定参数中取出一个参数输出,退出翻译模式

      ii 普通模式:直接输出该字符串

    c) 如果遇到其它字符(除\n):无条件退出翻译模式并输出字符

    d) 如果遇到'\n'字符,如果直接输出是不能达到换行的效果的,必须要同时输出回车换行才行

  从上面的实现vfprintf的代码中可以看出,并不支持特殊的格式控制符,例如位数、进度控制等,仅支持%d与%s这样的简单转换。真正的vfprintf格式化字符串实现比较复杂,因为它支持诸如“%f”、“%x”已有的各种格式、位数、精度控制等。我觉得上面实现的代码已经充分的展示了vfprintf的实现原理和它的关键技巧,所以没有必要一个一个的都实现。现在来实现printf的就简单多了,下面是printf的实现代码:

复制代码
 1 int printf(const char *format, ...) 2 { 3     int n; 4     va_list ap; 5  6     va_start(ap, format); 7     n = vfprintf(stdout, format, ap); 8     va_end(ap); 9     return n;10 }
复制代码

  对于可变参数的编程,我已经在c语言中的可变参数编程中详细的讲过了,包括它的实现原理。所以只要了解了可变参数的编程,对于实现printf函数来说就真的没什么难度了,纯粹就是调用vfprintf函数而已。如果实现了printf函数,那么对于实现scanf、fscanf也是同样的原理。


在编写简单的c运行库(二)中主要实现了对有关文件操作函数的实现,接下来主要实现有关字符串的函数,如itoa,strcmp,strcpy,strlen函数,这些函数并没有用到系统调用,所以也就不用向实现文件操作的函数那样使用内嵌汇编,这些函数的定义都放在string.h中。实现了字符串函数之后,就大概实现了一个小型的c运行库,虽然很简略,但对于理解c库函数运行原理、所用的关键技术有了比较深刻的认识。最后用这个小的c运行库来编译运行一个简单的测试程序,用以测试我们的库能否正常的工作。

1 字符串函数

  字符串函数中主要是实现itoa函数有点难度,其它的都还比较的简单,所以这里主要讲下itoa函数的实现。

复制代码
 1 char *itoa(int n, char *str, int radix) 2 { 3     char digit[] = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; 4     char *ptr = str, *base; 5  6     if (!str || radix < 2 || radix > 36) 7         return str; 8     if (radix != 10 && n < 0) 9         return str;10     if (!n)11     {12         *ptr++ = '0';13         *ptr = 0;14     }15     if (radix == 10 && n < 0)16     {17         *ptr++ = '-';18         n = -n;19     }20     base = ptr;21     while (n)22     {23         *ptr++ = digit[n % radix];    24         n /= radix;25     }26     *ptr = 0;27     for (-- ptr; base < ptr; base ++, ptr --)28     {29         *ptr ^= *base;30         *base ^= *ptr;31         *ptr ^= *base;32     }33     return str;34 }
复制代码

  itoa函数功能是把一个整数转换为字符串,我们在编写前面vfprintf函数的时候其实就已经用到过,它在c编程中也是经常用到的。从上面的代码中可以看到itoa支持2-36进制的整数转换为字符串。在这个函数中只认为十进制的数才能带有"-"号,所以在代码的第15行判断该整数是否满足是十进制的负数,如果满足在数的最前面加个"-"号,其它进制的负数默认不带"-"号。21-25行根据数的进制把数的低位到高位一个一个的分离并保存到ptr字符数组中,但是输出字符串中高位应该放在前面,所以27-32行主要是对ptr字符数组做一个倒置操作。

2 测试库

  接下来用一个简单的程序来测试编写的运行库,测试程序如下:

复制代码
 1 #include "minicrt.h" 2  3  4 extern char **environ; 5  6 int main ( int argc, char *argv[] ) 7 { 8     int i; 9     FILE *fp;10     char **v = malloc(argc * sizeof(char *));11     for (i = 0; i < argc; i ++)12     {13         v[i] = malloc(strlen(argv[i]) + 1);14         strcpy(v[i], argv[i]);15     }16 17     fp = fopen("text.txt", "w");18     for (i = 0; i < argc; i ++)19     {20         int len = strlen(v[i]);    21         printf("%d %s\n", len, v[i]);22         fwrite(&len, 1, sizeof(int), fp);23         fwrite(v[i], 1, len, fp);24     }25     fclose(fp);26 27     fp = fopen("text.txt", "r");28     for (i = 0; i < argc; i ++)29     {30         int len;31         char *buf;32 33         fread(&len, 1, sizeof(int), fp);34         buf = malloc(len + 1);35         fread(buf, 1, len, fp);36         buf[len] = 0;37         printf("%d %s\n", len, buf);38         free(buf);39         free(v[i]);40     }41     free(v);42     fclose(fp);43 44     while (*environ)45         printf("%s\n", *environ ++);    46 47     return 0;48 }
复制代码

  所有库中函数的声明、类型的声明都放在了头文件minicrt.h中,没有像标准的库那样对每类库函数的声明放在单独的头文件中,如文件操作放在stdio.h中。测试程序中基本上都用到了我们前面编写过的函数,所以对于测试我们的库是最适合不过了。

  要使用库,首先我们先要用前面编写的代码文件建立一个库,怎么建立呢?我们可以用linux下的ar命令来建立一个静态库,具体的可以见下面的命令。之所以用静态库,因为这样可以省略很多不必要的工作,我们的目的仅仅为了了解库的原理和关键技术。而动态库还有很多其它方面的知识,包括装载、运行时链接等,不过了解这些工作原理正是下面要做的工作了。

cc -c -g -fno-builtin -nostdlib -fno-stack-protector entry.c malloc.c stdio.c string.c test.car -rs minicrt.a malloc.o stdio.o string.o

  “-fno-builtin”指关闭GCC内置函数功能,默认情况下GCC会把strlen、strcmp等这些常用函数展开成它内部的实现。

  "-nostdlib"不使用任何来自Glibc、GCC的库文件和启动文件,它包含了-nostartfiles这个参数。

  "-fno-stack-protector"是指关闭堆栈保护功能,最近版本的GCC会在vfprintf这样的变长参数中插入堆栈保护函数,如果不关闭,使用自己写的库时会报“__stack_chk_fail”函数未定义错误。

  其中entry.c是在编写简单的c运行库(一)中说的入口函数实现,malloc.c中是有关堆的初始化和申请释放堆的函数,stdio.c包含编写简单的c运行库(二)中有关文件操作的函数,string.c包含本文中说的字符串函数的实现,test.c中则是我们的测试代码。

  链接测试程序时不能使用c的标准库,要用自己写的minicrt.a库,具体命令为:

ld -static -g -e MiniCrtEntry entry.o test.o minicrt.a -o test

  "-e"参数是指定入口函数,我们使用自己实现的入口函数MiniCrtEntry。

  运行的结果如下:

复制代码
cc@localhostmimicrt]$./test6 ./test6 ./testXDG_SESSION_ID=248HOSTNAME=localhost.localdomainTERM=xtermSHELL=/bin/bashHISTSIZE=1000SSH_CLIENT=192.168.1.161 62555 22SSH_TTY=/dev/pts/0USER=ccLD_LIBRARY_PATH=/usr/local/lib...
复制代码

  正如测试程序所希望的那样,程序打印出了命令行参数的总字节数,命令行参数,环境变量。可以说这个库基本上是正确的。

3 总结

  编写简单的c运行库到这里基本就结束了,虽然只是实现了一个很小的库,不过麻雀虽小,五脏俱全,虽然没有真实c标准库那么的高效、完全,但至少这个库实现了c标准库的核心部分,有了这个小型库,对于扩展它的其它功能还是比较容易的。实现这个库还是比较的简单,因为有《程序员自我修养》这本书作为参考,不过这边书中所实现的linux中c++运行库的全局构造和析构机制,我在linux中按它说的实现,却发现结果和它说的不太一样,test.o中的.ctors节并没有合并到crtbegin.o和crtend.o的.ctors节之间,而是合并到crtbegin.o和crtend.o的.ctors节的下面去了,至于为什么会这样,我依然没有找到这个答案,希望有人按《程序员自我修养》实现过linux下的c++库的人帮忙解惑或者讨论下。

原文:http://www.cnblogs.com/chengxuyuancc/archive/2013/06/07/3123550.html


0 0
原创粉丝点击