C++ 程序启动过程、内存分配和释放原理

推荐阅读《程序员的自我修养》

CRT启动代码

CRT即C-Run-Time

main函数并不是真正的入口点,在windows平台下,找到Visual Studio安装目录:VS2019\VC\Tools\MSVC\14.29.30037\crt\src\vcruntime

首先是exe_main.cpp,英文注释已经说得很清楚了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//
// exe_main.cpp
//
// Copyright (c) Microsoft Corporation. All rights reserved.
//
// The mainCRTStartup() entry point, linked into client executables that
// uses main().
//
#define _SCRT_STARTUP_MAIN
#include "exe_common.inl"

extern "C" DWORD mainCRTStartup(LPVOID) // 这时真正的入口点
{
return __scrt_common_main();
}

然后在exe_common.inl最底下323行

1
2
3
4
5
6
7
8
9
10
11
// This is the common main implementation to which all of the CRT main functions
// delegate (for executables; DLLs are handled separately).
static __forceinline int __cdecl __scrt_common_main()
{
// The /GS security cookie must be initialized before any exception handling
// targeting the current image is registered. No function using exception
// handling can be called in the current image until after this call:
__security_init_cookie();

return __scrt_common_main_seh();
}

在这个函数的上面235行就是__scrt_common_main_seh的定义

1
2
3
4
5
6
7
8
9
10
11
12
static __declspec(noinline) int __cdecl __scrt_common_main_seh()
{
...
//
// Initialization is complete; invoke main...
//
int const main_result = invoke_main();
//
// main has returned; exit somehow...
//
...
}

invoke_main的定义在76行

1
2
3
4
static int __cdecl invoke_main()
{
return main(__argc, __argv, _get_initial_narrow_environment());
}

就是它调用了main函数

main函数启动之前做了什么

这部分大都已经过时了,了解大致思想就好

启动过程可以简化为

1
2
3
4
5
6
7
8
9
10
mainCRTStartup      // windows下的入口
_heap_init(0) // 初始化堆内存,调用__sbh_heap_init small block heap旧版有sbh管理
_ioinit() // 初始化io
GetCommandLineA // 获取命令行参数
__crtGetEnvironmentStringA // 获取环境变量 A是Ascii
_setargv() // 保存启动的命令行参数字符串(如"xxx.exe")
_setenvp() // 保存环境变量字符串,分配内存次数是环境变量数+1,多一次是分配指针数组
_cinit() // C数据初始化
mainret = main(__args,__argv,_evniron)
exit(mainret)

其中_heap_init会为crt创建专用的内存,_ioinit会创建32个8字节的ioinfo结构体,共256字节,用于初始化io,还会打开三个重要文件:stdinstdoutstderr

关于保存打开的文件:使用一个FILE数组_iob保存,这个数组使用的是二级索引,最大可以索引2048。在一个FILE结构体里有int _file,这其中就保存了两级索引。stdinstdoutstderr分别就是这个数组的前三个元素。

_environ是指针,指向指针数组,保存了一系列环境变量字符串,最后一个数组元素NULL代表结束。

main函数参数个数为什么可以改变

vcstartup_internal.h中找到main函数声明

1
2
3
4
5
extern "C" int __CRTDECL main(
_In_ int argc,
_In_reads_(argc) _Pre_z_ char** argv,
_In_z_ char** envp
);
  1. main函数声明为extern "C",因此会按照C风格进行编译,编译后不会带有参数类型名来区分重载(这也是为什么main函数只能写一个)
  2. main函数的Calling convention采用__cdecl,参数从右向左压入栈中,栈的清空由主调函数完成,所以被调函数可以使用变长参数。而且生成汇编代码里函数名以下划线开头,并没有指明参数个数,所以可以正常调用。相比之下,__stdcall的栈由被调函数处理,汇编函数名有@参数字节数限制。

因此不论main函数定义中使用了多少形参,形参类型是什么,最后编译成的名字都类似于_main,可以被找到定义并调用。只不过形参定义为规定的形式才可以main函数里正常使用压栈的参数。

表达式delete[]如何为每个数组元素调用dtor

在使用new [n]开辟空间时,会额外分配一块空间,比如4字节,用来记录n的值,也就是数组的长度,而且dtor是逆序调用的,也就是从数组的最后一个元素开始调用

测试后发现,在VS2019上对数组用delete删除会出现断言失败:_CrtIsValidHeapPointer,说明那块计数空间也需要delete[]来删除。但是当对象所属类型测试is_trivially_destructible_vtrue时,就不会出错。猜想是new[]采用了优化策略,没有设置额外的计数值,从而delete也可以正确释放内存

delete数组一定会导致内存泄露吗

不一定,delete调用的是operator delete,其中调用了free,会直接释放整个数组的内存空间,如果数组元素没有指针,或者析构函数本就是trivial(不重要)的,那就不会有内存泄露问题。

free如何知道要释放的内存大小

首先要知道malloc实际分配的内存情况:请求的内存大小+调试信息(比如内存用途、大小、文件名、行号,共32+4字节)+4*2的cookie+填充(补齐16字节的倍数)

这两个cookie就是实际分配内存的上下界限,其中记录了实际的内存大小,因为内存大小为16的倍数,所以最后一个16进制位一定是0,于是可以设置为1来标记内存已经被分配出去。于是在调用free时,如果不是调试模式,没有调试信息,只要把指针上移4字节就可以找到上cookie,从而得知当初分配的内存大小。

关于把n对齐到16的倍数

1
(n + (16-1)) & ~(16-1)

free回收内存过程

回收内存时会对相邻的内存块进行合并

内存合并:当回收一块内存时查看内存块上面和下面是否有已经回收的,已回收(cookie最后一位为0)则合并(并更新cookie),因为每次都会与上下合并,所以只需要看自己的上下即可,而不用再走太远

查看上下内存的状态:当前指针指向自己内存开头

  • 查看下面的:指针上移,查看cookie,得知自己内存大小,然后就可以下移到下面的内存的上cookie
  • 查看上面的:指针上移再上移到上面一块的下cookie,所以如果没有下cookie就不知道上面内存的状态

当然也可以只有上cookie,只需要在一个cookie里同时保存本区块和上一个区块的长度即可(这时一个cookie占8字节)

最开始有几块内存不合并,它们的cookie全为1,从而作为特殊的分隔符