最小C基础库

本文探讨了如何构建一个最小的C基础库,仅包含_start()和_exit()函数,使得helloworld程序可以脱离glibc独立运行。通过逐步添加printf()、strlen()、main()参数处理和atexit()函数,展示了C基础库的基本构建过程,并解释了在不使用glibc时可能出现的问题及解决方法。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

还是从我们最熟悉的程序说起,我们学编程时接触到的第一个程序就是helloworld,代码如下:

#include <stdio.h>

int main()
{
        printf("hello world\n");
        return 0;
}

我们使用gcc静态编译这个程序  gcc -static -o helloworld  hellworld.c就可以生成可执行文件helloworld,执行这个程序就会在屏幕上打印出一行字符:hello world。确实很简单。但是你有没有想过编译过程中gcc做了什么呢?我不是指从c代码到汇编代码到机器码的编译过程,因为我在讨论C基础库,我的意思是gcc会向这个程序中添加大量其他函数。我们可以通过readelf -s helloworld |grep FUNC查看helloworld中包含的函数,我就不贴输出的信息了,因为的确很恐怖,最后输出了1353行信息,也就是说helloworld这个可执行程序需要调用1353个函数,是不是很恐怖?我只想说:这TM都是什么函数?你们都跟hello world有关系吗?为了弄清楚helloworld执行的过程,我决定自己写一个最小的C基础库,让helloworld脱离glibc独立运行。

一个最简单的C基础库只需要包含两个函数就可以了:_start()和_exit()。_start()是ld设置的可执行程序的入口函数,_exit()的作用是结束一个进程。我们编写这两个函数:

# _start.S

        .text
        .align 4
        .type _start, @function
        .globl _start

_start:
        call main
        call _exit

_start.S中实现了一个函数_start(),_start()依次调用了两个函数main()和_exit(),就这么简单。这里为什么用汇编实现呢?因为后面我们会扩充这个函数,扩充的内容需要用汇编实现。

# _exit.S

        .text
        .align 4
        .type _exit, @function
        .globl _exit

_exit:
        pushl   %ebx
        mov     %eax, %ebx
        movl    $1, %eax
        int     $0x80
        popl    %ebx
        ret
_exit.S中实现了一个函数_exit(),_exit()直接发起系统调用结束了一个进程。

有了这两个函数,一个C程序就可以正常运行了。但是,由于我们还没有实现printf(),因此需要先注释掉helloworld.c中的printf()语句。

// helloworld.c

int main()
{
//      printf("hello world\n");
        return 0;
}
为了避免引入glibc中的函数,我们需要用下面的方法编译这个程序

[root@localhost libc]# gcc -c -fno-builtin -o _start.o _start.S
[root@localhost libc]# gcc -c -fno-builtin -o _exit.o _exit.S 
[root@localhost libc]# gcc -c -fno-builtin -o helloworld.o helloworld.c 
[root@localhost libc]# ld -static -s -o helloworld _exit.o _start.o helloworld.o

现在就可以运行这个程序了,直接在终端中输入./helloworld,这个程序绝对可以正常执行,当然终端中不会有任何输出信息。那么怎么验证程序真的执行了呢?可以通过在终端中执行echo $?,$?表示上一条语句(也就是./helloworld)的返回值,结果是0。你还可以修改main()函数的return语句,让main()返回2,重新编译运行,再次执行echo $?,这时输出的值就是2了,说明main()函数的确执行了。

为了让终端中打印出hello world,我们来实现printf()函数,由于标准的printf()函数太复杂了(变参、各种不同的格式化方式),为了简单起见我们实现一个简化版本的printf(),代码如下:

# _start.S
# void printf(char *str, int size);

        .text
        .align 4
        .type printf, @function
        .globl printf

printf:
    pushl   %ebx
    pushl   %ecx
    pushl   %edx
    mov     $1, %ebx            # 向标准输出中写数据
    mov     16(%esp), %ecx      # 这是printf()中第一个参数,需要打印的字符串.
    mov     20(%esp), %edx      # 这是printf()中第二个参数,字符串的长度.
    movl    $4, %eax            # 这是write(2)系统调用的编号
    int     $0x80               # 发起系统调用
    popl    %edx
    popl    %ecx
    popl    %ebx
    ret
这个函数也不难,printf()直接利用write(2)系统调用将信息打印在屏幕中。helloworld.c代码如下:

// helloworld.c

int main()
{
        printf("hello world\n", 12);
        return 0;
}
再次编译helloworld,

[root@localhost libc]# gcc -c -fno-builtin -o _start.o _start.S
[root@localhost libc]# gcc -c -fno-builtin -o _exit.o _exit.S
[root@localhost libc]# gcc -c -fno-builtin -o helloworld.o helloworld.c
[root@localhost libc]# gcc -c -fno-builtin -o printf.o printf.S
[root@localhost libc]# ld -static -s -o helloworld helloworld.o _start.o _exit.o printf.o

现在执行./helloworld,就可以在屏幕中打印出hello world了。为了在屏幕上打印出hello world,只需要实现_start()、_exit()、printf()三个函数就可以了,够简单吧。那么为什么利用glibc打印hello world时会关联那么多的函数呢?因为glibc在执行main()前做了很多初始化工作,main()之后还做了一些清理工作,另外我们实现的是一个简化版本的printf(),glibc中的_start()、exit()、printf()比我们这里的复杂多了。但是不管怎么说,我们毕竟用几行代码就在屏幕上打印出了hello world,这就可以看作是一个最小的C基础库。

我们可以在这个库的基础上进行扩充实现更多的功能。每次调用printf()前我们都要自己计算出要打印的字符串的长度,是不是很烦?我们可以实现strlen(),自动计算字符串长度。

int strlen(const char *str)
{
        const char *s;

        for (s = str; *s; ++s)
                ;
        return (s - str);       
}
现在修改printf(),去掉printf()中第二个参数

# _start.S
# void printf(char *str);

        .text
        .align 4
        .type printf, @function
        .globl printf

printf:
    pushl   %ebp
    movl    %esp,  %ebp
    pushl   %ebx
    pushl   %ecx
    pushl   %edx

    pushl   8(%ebp)
    call    strlen
    addl    $4,  %esp
    mov     %eax, %edx          # 这是printf()中第二个参数,字符串的长度.

    mov     $1, %ebx            # 向标准输出中写数据
    mov     8(%ebp), %ecx       # 这是printf()中第一个参数,需要打印的字符串.
    movl    $4, %eax            # 这是write(2)系统调用的编号
    int     $0x80               # 发起系统调用

    popl    %edx
    popl    %ecx
    popl    %ebx
    popl    %ebp
    ret
修改后的代码中,printf()首先调用strlen()计算字符串的长度,然后再发起write()系统调用,我们修改helloworld.c

// helloworld.c

int main()
{
        printf("hello world\n");
        return 0;
}
现在打印hello world时就不需要指定字符串的长度了。

我们继续扩充这个C基础库,现在扩充什么呢?我们向扩充main()函数的参数。前面的例子中main()函数一直没有参数,但是我们知道main()函数有两个参数argc和argv[],我们可以将main()的参数打印出来。

int main(int argc, char *argv[])
{
        int i;
        for (i =  0; i < argc; i++)
                printf(argv[i]);
        return 0;
}

很可惜,如果不使用glibc而是使用上面我们自己写的C基础库的话,这段程序无法执行,终端会出现“段错误(吐核)”的提示信息。为什么会出现这种情况呢?因为在_start()函数中我们没有处理好main()函数的参数就直接调用main()函数了,为了让这段程序正常运行,我们需要扩充_start()函数。首先我们看看可执行程序加载完毕后main()函数的参数在栈中是如何存放的


上图是可执行程序加载完毕后进程栈的示意图,进程栈中保存了下列信息:

argc:这是传递给main()函数的参数个数,也就是main()函数的第一个参数。

argv[]:这是传递给main()函数的参数,也就是main()函数的第二个参数。argv只是一个指针,参数保存在这个指针指向的位置。

envp[]:这其实是传递给main()函数的第三个参数,保存的是环境变量的信息。我们不考虑这个参数了。

根据可执行文件链接方式的不同,进程栈中还有其他一些信息。如果可执行程序是动态链接的,进程栈中还会保存动态链接器的一些信息。进程栈中的”返回地址“就是动态链接器的地址。可执行程序加载完毕后首先执行动态链接器的程序,动态链接器负责将动态库加载到进程中,然后跳转到_start()函数执行。如果可执行程序是静态链接的,进程栈中就不保存动态链接器的信息。进程栈中的“返回地址”就是_start()函数的地址。可执行程序加载完毕后直接跳转到_start()开始执行。为了简单起见,我们就不考虑动态链接了。另外需要说明的一点是:无论是动态链接还是静态链接,可执行程序加载完毕后寄存器esp中保存的都是argc在进程栈中的地址,通过寄存器esp我们就可以找到main()函数的参数。我们对前面的_start.S修改如下:

# _start.S

        .text
        .align 4
        .type _start, @function
        .globl _start

_start:

        mov     %esp, %eax
        mov     $0f, %edx
        pushl   %edx
        pushl   %eax
        call    __libc_init

0:  jmp   main


// init.c

void __libc_init(int *elfdata, int (*main)(int, char**))
{
    int     argc = *elfdata;
    char**  argv = (char**)(elfdata + 1);
    _exit(main(argc, argv));
}

最后我们向这个基础C库中增加atexit()函数,应用程序可以调用atexit()注册一些函数,这些函数在main()函数之后运行,一个应用程序可以通过atexit()注册任意多个函数。由于我们没有实现malloc(),无法动态分配内存,因此我们规定函数数量的最大值(规定为10个函数),静态分配内存。代码如下:

// exit.c

int index = 0;
void (*func[10])(void) = {};

int atexit(void (*function)(void))
{
        if (index >= 10)
                return 1;
        func[index] = function;
        index++;
        return 0;
}

void exit(int status)
{
        int i;
        for (i = index - 1; i >= 0; i--)
                func[i]();

        _exit(status);
}

我们修改helloworld.c如下:

// helloworld.c

void atexit_func1(void)
{
        printf("I am in atexit_func1()\n");
}

void atexit_func2(void)
{
        printf("I am in atexit_func2()\n");
}

int main(int argc, char *argv[])
{
        int i;

        for (i =  0; i < argc; i++) {
                printf(argv[i]);
                printf("\n");
        }

        atexit(atexit_func1);
        atexit(atexit_func2);

        printf("I am in main()\n");
        return 0;
}

我们重新编译后运行,结果如下

[root@mail libc]# ./helloworld argv1 argv2
./helloworld
argv1
argv2
I am in main()
I am in atexit_func2()
I am in atexit_func1()

可见通过atexit()注册的函数的确在main()函数之后运行。


完整代码可以从这里下载。(我本来想将代码打包上传到博客中,但是不知道怎么上传文件,因此就创建了一个项目,我不会继续维护这个项目。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值