侯捷 C++ Startup:main 之前,谁在准备运行环境
一个只写了 main 的程序,为什么一进来就能申请内存、读取参数、使用标准输出?如果全局对象的构造函数还会打印一行文字,谁又在 main 之前调用了它?侯捷从这些问题进入 CRT Startup code:研究用户代码开始工作之前,运行库已经做好了哪些准备,以及用户代码结束后谁接手善后。
来源档案:讲者为侯捷;片头原课名为《C++程序的生前死後:揭密 CRT Startup code》,画面带 Boolan 博览标识。本次使用 B 站账号 DetachmentSy 上传的《侯捷 - C++ Startup 揭密:C++ 程序的生前和死后》,BV1jVhjzqEEu,上传日期为 2025-08-04(Asia/Shanghai),原始录制年份未核实。上传者与原讲者分别署名。
本篇覆盖 P1《01 前言,如何自定 Startup code》、P2《02 默认的 Startup code 在哪儿,main 生前和死后的 Call》、P3《03 Startup code 源码摘要》。核对日期为 2026-09-12。当前上传共有 11 个分 P,标题编号为 01–10、12;这只能证明目录跳号,不能判断缺课、误编号或剪辑合并,也不能承诺原课程完整。
这是 AI 辅助初加工的学习笔记,依据完整音轨转录和关键课件整理;现代补充另行标明。回看时间对应这个上传版本,保留可读时间与关键词;播放器参数的实际自动跳转未在本机验证。
本门阅读目录
- main 之前,谁在准备运行环境:入口符号、CRT 启动骨架与读图方法。
先修:把“程序开始”拆成不同层次
建议先熟悉函数调用、构造与析构、局部和全局变量,以及编译与链接的区别。这里不要求自己实现运行库,但需要愿意追问:某个可观察行为来自语言规则,还是工具链安排?
main 是 C++ 程序熟悉的用户入口;可执行文件的入口地址则是装载和工具链层面的问题。CRT 是 C runtime library,即 C 运行库。课程关心其中的启动部分怎样把运行环境准备好,随后把控制交给用户入口。因此,“main 是入口”和“main 之前还有代码执行”并不矛盾,二者描述的层次不同。
还需要给“最早”加上范围。P1 讲者为了突出 CRT,会称启动代码最早执行;本文只将它理解为早于用户 main 的运行时启动流程。操作系统装载等更早过程并没有被这句话排除,C++ 标准也没有规定所有实现必须存在一个叫 mainCRTStartup 的函数。
课程选 Windows 下的 VC6 和 VC10 作对照。P1 的关键提示是:某层源码里的机制不见了,可能只是换到另一层。阅读旧 CRT 的收获应是学会追踪职责,而不是记住旧文件名后要求新版逐一对应。课堂还列出《程序员的自我修养——链接、装载与库》作为进一步理解链接、装载和库的线索,并明确该书的主要观察环境与本课的 Windows 环境不同。
先换掉入口:证明 main 需要有人调用
P1 的第一步并没有实现一个完整运行库。讲者先找到 VC6 的项目设置,在 Link 的 Output 分类中填写 Entry-point symbol,项目选项随后出现 /entry:MyStartup。这个动作告诉链接器选择哪个符号作为入口。
Windows 演示定义了 MyStartup,借助 Windows API 建堆、分配内存并显示消息,没有定义 main。Linux 对照例同时定义了 blabla 和 main,再以课件中的 gcc entrypoint.c -e blabla 选择前者;展示结果只有 blabla 的输出。两个例子从相反方向说明同一件事:定义一个名叫 main 的函数,并不意味着脱离正常启动约定后仍会自动执行它。默认运行时之所以能进入用户代码,是因为启动路径安排了调用。
这一步只证明入口选择能够改变,没有证明已经建立可供一般 C++ 程序使用的运行环境。讲者随后强调,通用启动代码远比这个例子困难;微软帮助中的建议也是让链接器选择默认入口,以正确初始化 CRT 并执行静态对象构造。Microsoft /ENTRY 文档仍保留这一建议。
课件修正:Windows 页的 HeapAlloc 申请量为 0x10 字节,随后双层循环持续写入并递增 int*,还把移动后的指针用于消息显示。这个容量和指针用法不能作为正确内存操作复制运行。本文只保留入口设置的教学意图。Linux 页也只是特定环境的演示,直接从自定入口调用库函数涉及 ABI 与运行时前提,不能改一个链接选项就当成跨平台的“无 CRT 程序模板”。
再找回默认入口:源码用条件编译复用启动流程
P1 的帮助页按应用种类给出默认入口:控制台的 mainCRTStartup/wmainCRTStartup,Windows 子系统的 WinMainCRTStartup/wWinMainCRTStartup,以及 DLL 的 _DllMainCRTStartup。这些是微软工具链名称;宽字符版本和应用子系统的区别,也不应混为同一维度。
P2 随后定位 VC6 源码。课件的四文件关系是 crt0.c、wcrt0.c、wincrt0.c、wwincrt0.c:后三者设置相关宏后包含 crt0.c,通过条件编译复用主体。这四个文件是此处可执行程序启动的窄/宽字符、控制台/窗口变体,不能拿它们的数量去替换前面含 DLL 的应用分类。
这个组织方式解释了一个源码阅读陷阱:搜索到 mainCRTStartup 后,还需要确定本次编译采用哪些宏、走哪个分支,才能知道实际调用的是 main、wmain 还是其他用户入口。课件里的文件名是追踪线索,完整的实现结论还依赖版本与构建条件。
课件中的 call stack 应该怎样读
P2 的长图把 mainCRTStartup 放在靠下位置,向上列出后续动作,缩进表示调用层次。它把九个主要步骤整理在同一级,main 是第八项,exit 是第九项。
最值得保留的是讲者自己的限定:这不是某个时刻的真实调用栈截图,而是经过整理的调用轨迹。 _heap_init 返回后可以进行 _ioinit;图把它们都画出来,并不意味着两者同时仍在调用栈上。同样,只画出 _ioinit 深入内存分配的某条链,并不表示它只调用这些函数。
读这种图时,先用编号理解阶段先后,再用缩进理解某条调用链的深入关系。若要判断某一时刻还有哪些栈帧,必须停在具体断点观察,不能把整张教学图当成一份瞬时栈快照。
九个步骤连起来,才看得见初始化的依赖
P3 用删节后的 crt0.c 对照前一张图。以下顺序属于课堂分析的 VC6 启动路径,是九个重点步骤,不是所有源码语句的完整清单:
| 阶段 | 课件中的名称 | 读源码时要追问的职责 |
|---|---|---|
| 1 | _heap_init |
建立后续分配所需的堆管理基础 |
| 2 | _ioinit |
初始化底层 I/O 状态,过程中也可能需要内存 |
| 3–4 | GetCommandLineA、__crtGetEnvironmentStringsA |
取得命令行与环境字符串来源 |
| 5–6 | _setargv、_setenvp |
将字符串整理成运行库需要的参数与环境表示 |
| 7 | _cinit |
执行后续要展开的 C/C++ 初始化工作 |
| 8 | main |
交给应用程序员的代码,得到返回值 |
| 9 | exit |
用返回值进入正常终止路径 |
堆初始化之所以靠前,可以从后续消费者反推:I/O 状态、参数数组、环境字符串都可能需要存储空间。P3 没有急着逐字符分析命令行,而是提出一种验证方法:知道要保存哪些字符串后,推算请求的内存量,再用这些真实请求检查前面建立的堆模型。后面的内存分配章节因此有了明确的输入来源,不只是抽象地画空闲块。
这里还要区分“整个 I/O 环境需要准备”与“所有标准流都由 _ioinit 单独初始化”。课件将 _ioinit 注释为 initialize lowio;它不能独自代表全部 C 标准流和 C++ iostream 的初始化。相邻的 _cinit 是另一项工程,名称相近也不表示可以合并成一步。
P3 的源码页让控制流更具体:先检查 _heap_init 的结果,失败走错误终止;正常路径在调用用户 main 后,把返回值交给 exit。摘要还展示了 Windows 的 __try/__except 分支。因此,即便只读这一页,也不能把整个程序画成“一定走完九步”的无条件流水线。整理者说明:这些是课件可见的分支线索,本文不将它展开成一份可移植的异常处理模板。
源码里的 main(__argc, __argv, _environ) 还暴露了平台扩展:标准可移植程序通常使用 int main() 或 int main(int argc, char* argv[]),不能因为 VC6 启动代码传入环境参数,就认为三参数形式是所有 C++ 实现必须支持的形式。C++ 工作草案 basic.start.main列出了实现必须接受的两种形式。
P3 最后回到条件编译,并补充 _tmain、_tWinMain 等通用文本宏。课件左栏的 _UNICODE 条件将 _tmain 映射到 wmain 或 main;右栏的启动源码还结合 _WINMAIN_、WPRFLAG 选择窗口/控制台与宽字符路径。这里看到的是预处理阶段对名称的选择,不能再凭 _tmain 这个拼写想象出一种新的语言级入口。
现代补充:保留职责模型,更新实现坐标
今天阅读 MSVC,仍可用“准备运行环境→调用用户入口→终止清理”的顺序提问,但不要要求每一项仍位于 VC6 的那个源文件。微软文档说明,Visual Studio 2015 对 CRT 作了拆分,涉及 UCRT、VCRuntime 及负责初始化和终止的启动库;启动代码仍与编译器版本有关。把现代环境的源码、符号和编译选项记录下来,再追踪实际调用链,才是延续本课的方法。Microsoft CRT 库组成
初始化和终止的语言规则,也不能从一张 VC6 调用图无限推广。非局部静态存储期变量的动态初始化存在允许延后的情形,所以“所有全局对象构造都必须在 main 第一条语句前发生”过强;本课观察的是具体启动实现。C++ 工作草案 basic.start.dynamic
main 正常返回时,先离开其作用域并销毁其中的自动对象,再进入 std::exit 的效果;直接在深层函数调用 std::exit 不会通过正常退栈销毁沿途自动对象。abort、_Exit 等终止方式也不是课件正常退出路径的同义词。后续分析“死后”的清理时,必须先说清是哪种退出方式。basic.start.main、support.start.term
这些边界与本站shared_ptr 引用计数源码阅读有关:智能指针在析构时释放拥有的资源,是一条依赖析构确实发生的路径。理解运行时怎样离开程序,才能判断某种退出方式是否仍走过这条路径,而不是把“用了智能指针”当成所有终止情形都会清理资源的保证。
用调试器验证“谁调用谁”
以下是整理者补充的 Windows/MSVC 观察任务,不是本机已经完成的复现实验。本次没有在 Windows 上执行 VC6 或现代 MSVC,未把 macOS 下的输出替代成课件验证。
保留默认入口,创建一个带调试信息的控制台程序,在 main 第一条语句设断点。检查调用栈,记录其下方出现的启动相关符号、工具链版本、目标架构和运行库链接选项;如果符号没有加载,先解决符号可见性,再判断是否存在某个阶段。现代符号可能与 mainCRTStartup 的课件路径存在多层包装。
随后给一个非局部对象的构造与析构设断点,记录三个时刻各自的调用栈,而不是把三张快照拼成一个“同时存在的栈”。实验目标是回答控制从哪里来、返回哪里去;它不要求手工覆盖 /ENTRY,也不以观察到某种初始化顺序证明所有平台都保证该顺序。
重点回看
| 讲次与时间 | 画面或讨论线索 | 回看时关注什么 |
|---|---|---|
| P1 · 10:27–17:52 | 自定 Startup code、Entry-point symbol | 从设置入口推导默认 CRT 仍需履行的职责 |
| P1 · 18:12–23:53 | Windows MyStartup、Linux blabla |
两个例子如何说明 main 需要启动路径调用;注意原代码限制 |
| P2 · 00:56–02:20 | “Startup code 在哪兒”、四个 .c 文件 |
条件编译如何复用 crt0.c 主体 |
| P2 · 02:23–06:56 | 整理后的 call stack | 从下往上的阶段顺序,与缩进表示的调用关系 |
| P3 · 02:15–05:06 | 九个动作、框出的 3–6 项 | 字符串处理如何成为验证内存模型的输入 |
| P3 · 08:11–09:21 | _UNICODE、_tmain |
名称映射与窗口/控制台分支不是新入口机制 |
思考与扩展
- 在一张瞬时调用栈中看不到已经返回的
_heap_init,能否据此断言程序没有初始化堆?先回答,再把断点前移到准备阶段观察。检验的关键是区分“没有执行”和“执行后已经退栈”。 - 若参数与环境整理需要分配内存,把它们移到堆准备之前会依赖什么额外前提?在课件的九步表上为每一步标注它消费的资源,检查所需资源由哪一步提供;不要仅凭编号背诵先后。
- 将局部智能指针所在函数的正常
return改为std::exit,它的析构路径是否仍会发生?先按标准条款预测,再在独立小程序里给析构加输出,比较两条路径。这个实验检验终止语义,不能检验 VC6 内部函数名。