运行库:程序进入main()之前发生了什么

本文深入解析了Linux和Windows环境下程序启动的内部流程,从_glibc和MSVC-CRT运行库的角度详细介绍了程序如何从_start到达main函数的过程,包括参数入栈、环境变量设置、I/O及堆初始化等关键步骤。

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

1、linux下glibc运行库运行逻辑

正常可执行文件用glibc运行的函数调用顺序
_start -> __libc_start_main -> exit -> _exit;

_start:
    xorl  %ebp, %ebp //异或操作,将结果赋值到第一个参数上, ebp设置为0正好可以体现出_start作为最外围函数的地位
    popl  %esi //将当前堆栈中argc弹出赋值给esi
    movl  %esp, %ecx //将argv和env数组指针赋值给ecx
    pushl %esp
    pushl %edx
    pushl $__libc_csu_fini
    pushl $__libc_csu_init
    pushl %ecx
    pushl %esi
    pushl main
    call __libc_start_main

    hlt
int __libc_start_main(
      int (*main) (int, char **, char **),
      int argc, //main输入形参个数
      char * __unbounded *__unbounded ubp_av, //输入形参数组和环境变量数组联合指针
      __typeof (main) init, //main函数调用前的初始化函数指针
      void (*fini) (void), //main函数调用后的收尾函数指针
      void (*rtld_fini) (void),//和动态加载有关的收尾工作,rtld是runtime loader的缩写
      void * __bounded stack_end) //栈底
{
    #if __BOUNDED_POINTERS__ 
        char **argv;
    #else
        #define argv ubp_av
    #endif
        int result;

    char** ubp_ev = &ubp_av[argc+1]; //定位到env环境变量数组
    INIT_ARGV_and_ENVIRON; //宏定义,展开为_environ = ubp_ev;但之所以分成两步看着略显多余的间接赋值,是为了兼容bounded机制,这种情况是默认不支持bounded的版本
    __libc_stack_end = stack_end;//将栈底地址存储在一个全局变量,留待后续使用

    DL_SYSDEP_OSCHECK (__libc_fata1); //检查操作系统版本的宏,不进行展开了

    /*接下来代码繁杂,省去大量信息,只保留核心的函数调用*/
    __pthread_initialize_minimal();
    __cxa_atexit(rtld_fini, NULL, NULL); //等同于atexit()函数,用于将参数指定的函数在main结束之后调用,所以参数传入的rtld_fini和fini均会在main结束之后调用
    __libc_init_first(argc, argv, __environ);
    __cxa_atexit(fini, NULL, NULL);
    (*init) (argc, argv, __environ);

    result = main (argc, argv, __environ);
    exit(result);
}

看下exit的实现

void exit(int status)
{
    while(__exit_funcs != NULL)
    {
        ...
        __exit_funcs = __exit_funcs -> next; //__exit_funcs是存储着__cxa_atexit和atexit注册的善后函数的指针链表,这个while循环便是用来逐个调用这些注册的函数。
    }
    ...
    _exit(status);//最后由系统函数_exit完成输出返回,该函数由汇编实现,且与平台无关
}
_exit:
    movl  4(%esp),  %ebx
    movl  $__NR_exit, %eax
    int   $0x80
    hlt

脚注
可以看到_start和_exit末尾都有一个hlt指令,这个指令是起到冗余保护作用。一般一旦exit被调用,即意味着程序的运行被终止(eix程序寄存器换到了别的函数了),因此_exit末尾的hlt不会执行,从而__libc_start_main永远不会再返回了,也就意味着_start末尾的hlt也不会运行,而_exit末尾的hlt指令则是为了检测exit系统调用是否成功,如果失败,程序将永远不会停止,所以hlt强制终止命令可以强行把程序停下来,_start中的hlt指令也是这种冗余保护机制的一部分。

2、Windows下MSVC-CRT运行库的运行逻辑

MSVC-CRT默认的入口函数名为mainCRTStartup,这个mainCRTStartup的总体流程是:
1.初始化和OS版本有关的全局变量
2.初始化堆
3.初始化I/O
4.获取命令行参数和环境变量
5.初始化C库的一些数据
6.调用main并记录返回值
7.检查错误并将main的返回值返回

int mainCRTStartup(void)
{
    ...
    posvi = (OSVERSIONINFOA *) _alloca(sizeof(OSVERSIONINFOA));//这里不用malloc,是因为堆还没有初始化,这时只有alloca是唯一可以不使用堆的动态分配机制,alloca可以在栈上分配任意大小的空间(栈允许就可以),并且在函数返回的时候自动释放,就像局部变量一样。
    posvi -> dwOSVersionInforSize = sizeof(OSVERSIONINFOA);

    GetVersionExA(posvi);
    _osplatform = posvi -> dwPlatformId;
    _winmajor = posvi -> dwMajorVersion;
    _winminor = posvi -> dwMinorVersion;
    _osver = (posvi -> dwBuildNumber) & 0x07fff;

    if ( _osplatform != VER_PLATFORM_WIN32_NT )
        _osver |= 0x08000;

    _winver = (_winmajor << 8) + _winminor;
    /*被赋值的这些变量是VC7预定义的一些全局变量,其中_osver和_winver表示操作系统的版本,_winmajor是主版本号,这段代码通过调用GetVersionExA()来获取当前操作系统的版本信息,并且赋值给各个全局变量。 */

        /*赶紧初始化堆空间,不然很多事情没法做*/
    if (!_heap_init(0))
        fast_error_exit(RT_HEAPINIT);//使用_heap_init()函数对堆进行初始化,如果初始化失败,则通过fast_error_exit()直接退出

    __try {
        if(_ioinit() < 0) //_ioinit函数初始化I/O
            _amsg_exit(_RT_LOWIOINIT);

        _acmdln = (char *)GetCommandLineA();
        _aenvptr = (char *)_crtGetEnvironmentStringsA();

        if(_setargv() < 0) //初始化main函数的argv参数
            _amsg_exit(_RT_SPACEARG);
        if(_setenvp() < 0)//设置环境变量数组指针
            _amsg_exit(_RT_SPACEENV);

        initret = _cinit(TRUE);//其他C库的这只

        if(initret != 0)
            _amsg_exit(initret);
        __initenv = _environ;

        mainret = main(__argc, __argv, _environ);

        _cexit();
    }
    __except(_XcptFilter(GetExceptionCode(), GetExceptionInformation()) )
    {
        mainret = GetExceptionCode();
        _c_exit();
    }/*end of try-except*/
    return mainret;
}

MSVC _heap_init()堆初始化代码

HANDLE  _crtheap = NULL;

int _heap_init(int mtflag)
{
    if( (_crtheap = HeapCreate(mtflag ? 0: HEAP_NO_SERIALIZE,
         BYTES_PER_PAGE, 0)) == NULL)
        return 0;

    return 1;
}
/*32位编译环境的MSVC堆初始化异常简单,仅是调用了HeapCreate这个API来创建一个系统堆,因此不难想象,MSVC的malloc函数必然是调用了HeapAlloca这个API,将堆管理的过程直接交给了操作系统。*/

I/O初始化代码
I/O初始化主要是用户控件中建立stdin\stdout\stderr及其对应的FILE结构,使得程序进入main之后可以直接使用printf、scanf等函数。FILE结构实际定义在C语言标准中并没有明确指出,故而不同的版本可能有不同的实现:

struct _iobuf {
    char *_ptr;
    int _cnt;
    char *_base;
    int _flag;
    int _file; //每个进程都有一个打开文件列表数组用来管理该进程当前使用的文件,_file值其实便是fd(file descriptor)项,即打开文件列表中的下标,用来指向某个进程打开的文件
    int _charbuf;
    int _bufsiz;
    char *_tmpfname;
    };
typedef struct _iobuf FILE;

typedef struct{
    intptr_t osfhnd;//打开文件的句柄,使用8字节整数类型intptr_t来存储
    char osfile;//文件的打开属性
/*osfile的值可以通过一系列值按位或的方式得出:
FOPEN(0x01)句柄被打开
FEOFLAG(0x02)已到达文件末尾
FCRLF(0x04)在文本模式中,行缓冲已遇到回车符
FPIPE(0x08)管道文件
FNOINHERIT(0x10)句柄打开时具有属性_O_NOINHERIT(不遗传给子进程)
FAPPEND(0x20)句柄打开时具有属性O_APPEND(在文件末尾追加数据)
FDEV(0x40)设备文件
FTEXT(0x80)文件以文本模式打开
*/
    char pipch;//用于管道的单字符缓冲
}ioinfo;//MSVC CRT中,已经打开的文件句柄信息使用数据结构ioinfo来表示

在crt/src/ioinit.c中有一个数组

int _nhandle;
ioinfo * _pioinfo[64]; //等效于ioinfo _pioinfo[64][32]

这个二维数组便是进程用户态的打开文件表,所以进程可容纳的句柄是64*32 = 2048,验证了句柄资源的有限性,而n_handle则记录了该表的实际元素个数。只所以使用而不是二维数组的原因是使用指针数组更加节省空间,而如果使用二维数组,则不论程序里打开了几个文件都必须始终消耗2048个ioinfo的空间。

总结:无论是Linux还是Windows,用户程序的入口都绝非惯常所认为的main()函数。任何程序的运行是建立在多层抽象封装的基础上才能得以进行逻辑操作:1、操作系统的软硬件提供的虚拟空间、CPU调度等机制;2、运行库实现的,包括参数入栈、环境参数入栈、I/O初始化、堆初始化等操作。这几层背后的逻辑完成后,搭好了舞台,终于可以进入应用层,即程序员眼中所有逻辑操作入口–main()函数。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值