A-程序员的自我修养:链接、装载与库之第 11 章:运行库
《程序员的自我修养》第 11 章:运行库 📖
推荐博主个人使用的中转站,新用户首充500及以下,充值多少送充值一半的额度。注册送免费的额度,可以免费试用。
博主测试了将近30多家,就这家各方面都挺不错的。
点我跳转进行注册
或者使用下面的链接
https://kakouai.com/register?aff=YNTYF6Q4S8PF
下面是测试绝不掺水。


[!note] 阅读范围
本笔记依据本地 EPUB 中的第 11 章(11.1 入口函数和程序初始化、11.2 C/C++运行库、11.3 运行库与多线程、11.4 C++全局构造与析构、11.5 fread实现、11.6 本章小结)整理。EPUB 未确认版次和页码,因此不编造页码;书中以旧版 glibc、GCC、MSVC CRT 源码为例,涉及现代工具链时要以实际环境核对。
[!tip] 一句话抓住本章
你写的main、printf、malloc和 C++ 全局对象,并不是凭空就能工作。运行库(Runtime Library)负责把操作系统交给程序的入口,准备成语言能使用的环境,再负责 I/O、内存、线程、构造/析构和退出清理。
章节导航 🧭
进度:[██████████] 已整理
难度:⭐⭐⭐⭐ 预计阅读:35 分钟 预计理解与练习:60 分钟(均为估计)
- 先理解:
main前后发生什么 - 再理解:C/C++ 运行库由哪些部分组成
- 重点掌握:多线程安全、TLS、全局构造/析构
- 最后串起来:
fread如何从缓冲走到操作系统
1. 本章学习目标 🎯
学完后,应该能用自己的话回答:
- 为什么程序的真实入口通常不是
main?入口函数在main前后分别做什么? - C 运行库(CRT)和 C 语言标准库是什么关系?glibc、MSVC CRT 又是什么?
- 为什么
errno、strtok、malloc、printf在多线程中需要额外保护?TLS 和加锁各解决什么问题? - C++ 全局对象为什么能在
main前构造、在退出时析构?glibc/GCC 和 MSVC 分别怎样组织这些函数? fread为什么不一定每次都调用操作系统?缓冲区、fread_s、_read和ReadFile如何串起来?
前置背景是第 1—10 章涉及的进程、内存、目标文件、链接和动态链接;本章之后,[[第12章 系统调用与API]] 会把“运行库如何落到系统调用”讲得更直接,[[第13章 运行库实现]] 则进一步动手实现简化运行库。
2. 整章知识地图 🗺️
这张图的中心不是某一个库文件,而是“应用代码如何获得一套可运行环境”。启动、库函数、多线程、对象生命周期和文件读取,都由运行库把语言规则与操作系统能力接起来。
3. 知识依赖与学习路径 🔗
先把“程序如何出生”看懂,再看运行库提供的日常能力,最后跟一条 fread 调用链观察它如何落到平台 API。这样不会把 printf、malloc 当成操作系统直接提供的魔法函数。
4. 核心知识点 👩🏫
⭐⭐⭐⭐⭐ 4.1 入口函数:main 不是第一行代码
一句话: 操作系统把进程控制权交给入口点(Entry Point),入口点先准备运行环境,随后才调用 main。
大白话解释: 把程序想成一家刚开门的店。main 是正式接待顾客的柜台,但在开门前,店员要先通电、摆好货、准备钥匙和账本;关门后还要清点、收银、关灯。入口函数就是这套“开店和关店流程”。
为什么会有它: 当 main 第一行执行时,全局变量已经有初值,argc/argv 已经准备好,堆、栈、I/O 也已初始化;C++ 全局对象甚至已经开始构造。如果没有入口函数,应用就无法安全使用 malloc、printf 或 C++ 对象。
核心流程:
图示说明: 读图时先看 OS → 入口函数 → main,再看 main → 清理 → OS。main 只是中间的应用阶段,不是整个进程生命周期。
glibc 与 MSVC 的两个例子:
- Linux/glibc 的入口通常是
_start。它从栈中取出argc、argv,再调用__libc_start_main;后者注册收尾函数、执行初始化,最后调用main。 - Windows/MSVC CRT 的入口通常是
mainCRTStartup。它会初始化操作系统信息、堆、I/O、命令行参数和环境变量,再调用main,异常时进入 SEH(结构化异常处理)分支。
main 返回之后: 入口函数记录返回值并调用 exit。exit 会遍历 atexit/__cxa_atexit 注册的函数,随后通过 _exit(平台相关的系统调用封装)结束进程。不要把 exit 和 _exit 混为一谈:前者负责运行库清理,后者更接近“直接请求内核结束”。
[!abstract] 📌 一句话卡片|入口函数
真实顺序是“操作系统 → 运行库入口 → 初始化 →main→ 清理 → 进程结束”,而不是“操作系统 →main”。
三个生活类比:
| 生活例子 | 对应关系 | 它帮助理解什么 | 类比边界 |
|---|---|---|---|
| 餐厅开门 | 入口函数准备桌椅、收银和菜单,main 接待顾客 | 初始化必须先于业务 | 程序的初始化由代码和系统共同完成,不是人工操作 |
| 演出开场/谢幕 | 开场前布景,演出后清场;main 是正式演出 | main 前后都有工作 | 退出清理不代表所有资源都能自动修复 |
| 新员工入职 | 先分配工位和账号,再开始工作,离职时交接 | argc/argv、I/O 等像工作环境 | 参数和堆不是“永久归个人所有”的账号 |
面试/表达题:
问题: 为什么说程序不从
main开始?
结论: 因为入口点要先完成运行库初始化。
理由:main执行时,参数、堆、I/O 和部分全局状态已经被准备好。
例子: glibc 中可沿_start → __libc_start_main → main观察这条链。
一句话总结: main 是业务入口,不是进程入口;运行库负责把“刚创建的进程”变成“可执行 C/C++ 程序”。
⭐⭐⭐⭐⭐ 4.2 C/C++ 运行库:应用与操作系统之间的适配层
一句话: 运行库是一组支撑程序启动、标准函数、I/O、堆、语言特性和调试的代码集合;C 语言这部分通常称为 CRT(C Runtime)。
为什么需要它: 操作系统的 API 与不同平台有关,而 C/C++ 程序希望用统一的函数名工作。例如,程序都可以写 fread,但 Linux 可能落到 POSIX/系统调用,Windows 则可能落到 ReadFile。运行库把平台差异藏在函数实现内部。
C 运行库大致包含:
| 能力 | 例子 | 作用 |
|---|---|---|
| 启动与退出 | _start、mainCRTStartup、exit | 连接进程生命周期和 main |
| 标准函数 | printf、strlen、memcpy | 提供 C 标准规定的常用能力 |
| I/O | FILE、fopen、fread、fwrite | 在文件/设备和程序之间传数据 |
| 堆管理 | malloc、free | 管理动态内存 |
| 语言支持 | va_list、setjmp、异常辅助代码 | 支持 C/C++ 语义 |
| 调试与平台适配 | 错误转换、启动文件 | 与编译器和操作系统衔接 |
4.2.1 标准库、CRT、glibc、MSVC CRT 的区别
- C 标准库: 标准规定的一组接口和行为,例如
stdio.h、string.h、stdlib.h、math.h。它强调可移植性。 - CRT: 为了让 C 程序真正运行而加入的更大集合,除了标准库,还包括入口、堆、I/O 初始化、线程适配等。
- glibc: Linux 世界常见的 GNU C Library,是标准 C 能力加上平台扩展的实现。
- MSVC CRT: Visual C++ 配套的 Microsoft C Runtime,按静态/动态、调试/发布、C/C++、多线程等组合提供不同版本。
MSVC 中常见的静态库命名会用 p 表示 C++、mt 表示多线程、d 表示调试;动态 CRT 还会以 DLL 形式存在。工程里不同目标文件或 DLL 混用不同 CRT,可能造成符号冲突,更危险的是跨模块传递堆内存、FILE* 或其他 CRT 资源。
[!warning] 版本边界
书中讨论的是特定年代的 glibc、GCC 和 Visual C++。crt1.o、.ctors、.finit、MSVC CRT 文件名等细节会随工具链变化;学习原理时抓住“启动文件、特殊段、函数指针数组、平台适配”,实际排查时以目标环境的文档和反汇编为准。
4.2.2 两组特殊 C 接口
变长参数(stdarg.h): printf 的 ... 表示参数数量和类型可以变化。调用者按约定把参数压栈,函数内部用 va_start、va_arg、va_end 逐个读取。格式字符串写错类型会导致错误读取,甚至连后续参数也错位。
int sum(unsigned count, ...);
/* 只在调用者确实传入 count 个 int 时,才可安全读取。 */
这里的关键不是记住宏的实现,而是理解“变长参数没有自带类型和数量信息,调用者与被调用者必须遵守同一约定”。
非局部跳转(setjmp/longjmp): setjmp 记录一个跳转现场;longjmp 可以让执行流回到这个现场,并让 setjmp 看起来返回另一个值。它像“回到存档点”,但会跳过中间函数的正常返回路径,容易绕过资源释放,因此应谨慎使用。
三个生活类比:
| 生活例子 | 对应关系 | 它帮助理解什么 | 类比边界 |
|---|---|---|---|
| 翻译员 | 运行库把同一句“读文件”翻译成不同系统的 API | 平台适配层 | 适配不是完全无差异,边界行为仍可能不同 |
| 万能工具箱 | 标准函数、堆、I/O、启动代码是不同工具 | CRT 不是一个单一函数 | 工具箱本身不等于操作系统内核 |
| 快递面单 | 变长参数依赖格式说明和顺序 | 调用双方必须共享约定 | 快递不会自动知道错误的收件地址,格式写错也不会被安全纠正 |
一句话总结: 标准库给出“应该有什么”,CRT 负责让这些能力在某个平台上真正跑起来。
⭐⭐⭐⭐⭐ 4.3 运行库与多线程:共享数据必须有边界
一句话: 多线程 CRT 要解决两件事:提供创建/退出线程的接口,并让原本按单线程假设写的库函数在并发环境下仍然正确。
为什么会出问题: 线程可以共享同一进程的全局数据、堆和文件,但每个线程又需要自己的错误码、临时状态和寄存器。共享和私有没有分清,就会出现“一个线程改了,另一个线程突然变了”的问题。
典型风险:
| 对象/函数 | 单线程假设 | 多线程风险 |
|---|---|---|
errno | 用一个全局变量保存最近错误 | 线程 A 还没读取就被线程 B 覆盖 |
strtok | 用函数内部静态变量记住上次位置 | 不同线程互相覆盖解析位置 |
malloc/new 与 free/delete | 分配器数据结构只有一个执行流访问 | 并发修改空闲链表导致损坏 |
printf/fprintf | 输出目标可按顺序写入 | 多线程输出交错或缓冲状态混乱 |
| 早期异常处理 | 假设只有一个异常流 | 不同线程的异常信息冲突 |
三种改进手段:
- TLS(Thread Local Storage,线程局部存储): 让
errno、CRT 的线程状态等每个线程各有一份。 - 加锁: 在
malloc、输出等共享数据结构周围自动加锁,避免同时修改。 - 提供线程安全变体:
strtok_s/strtok_r由调用者传入context,不再依赖函数内部静态状态。
TLS 的两种使用方式:
- 隐式 TLS: 用 GCC 的
__thread或 MSVC 的__declspec(thread)声明变量;编译器、运行库和系统负责为每个线程准备副本。 - 显式 TLS: 程序员调用 Windows 的
TlsAlloc/TlsGetValue/TlsSetValue/TlsFree,或 pthread 的对应函数,手工申请、取值、赋值和释放。
Windows 会把隐式 TLS 变量放入 PE 的 .tls 段;线程启动时复制出自己的副本。线程通过 TEB(线程环境块)里的 TLS 数组找到这份副本。显式 TLS 本质上是把数据指针放进 TLS 数组,使用更麻烦且有容量限制。
CreateThread 与 _beginthread 的坑: MSVC CRT 会为线程维护 _tiddata,里面包含 errno、strtok 位置、随机数种子等 CRT 私有状态。_beginthread/_beginthreadex 会先准备它,并在合适的退出路径释放;仅使用 CreateThread 再配合静态 CRT,历史上可能在线程结束时遗留 _tiddata。使用 CRT 的 Windows 程序应优先遵循对应 CRT 的线程创建/退出接口。具体行为与静态/动态 CRT、编译器版本有关,不能把书中的老版本现象直接当成所有现代环境的结论。
三个生活类比:
| 生活例子 | 对应关系 | 它帮助理解什么 | 类比边界 |
|---|---|---|---|
| 每人一本工作笔记 | TLS 让每个线程保存自己的 errno、解析位置 | 线程私有状态 | 笔记本不代表共享资源自动安全 |
| 只有一把会议室钥匙 | 锁让线程排队访问共享堆/输出 | 互斥访问 | 锁过多会降低并发,甚至造成死锁 |
| 每位快递员有独立扫描上下文 | strtok_s 把 context 交给调用者 | 安全变体把状态显式化 | 调用者要负责 context 生命周期 |
[!abstract] 📌 一句话卡片|多线程 CRT
线程安全的核心是“共享状态要保护,私有状态要隔离”;TLS 解决隔离,锁和安全变体解决共享。
一句话总结: 选择多线程 CRT 不只是勾一个编译选项,还要确认线程创建函数、CRT 资源边界和模块之间的内存所有权。
⭐⭐⭐⭐ 4.4 C++ 全局构造与析构:把对象函数放进启动/退出通道
一句话: 编译器为每个编译单元生成初始化函数,链接器把这些函数指针集中到特殊段,运行库在 main 前遍历调用;析构函数则注册到退出路径。
glibc/GCC 的思路:
- C++ 编译器为每个
.cpp生成类似GLOBAL__I_xxx的函数,负责本编译单元的全局对象构造。 - 每个目标文件把这个函数指针放到
.ctors段;链接器把同名段合并成函数指针数组。 crtbegin.o和crtend.o提供数组边界(书中称__CTOR_LIST__、__CTOR_END__)。.init中的代码调用 GCC 提供的__do_global_ctors_aux,依次遍历构造函数。- 早期实现用
.dtors/.fini做析构;后来常见做法是在每个编译单元的初始化函数里通过__cxa_atexit注册析构回调,让exit逆序调用。
MSVC CRT 的思路:
mainCRTStartup调用_initterm(__xc_a, __xc_z)。__xc_a、__xc_z是函数指针数组的边界,分别放在.CRT$XCA和.CRT$XCZ。- 各编译单元的初始化函数放进
.CRT$XCU;链接器按段名的字母顺序合并,形成连续数组。 - 全局对象构造函数在初始化阶段执行;编译器生成一个析构辅助函数,再通过
atexit注册到退出列表。
图示说明: 重点不是死记 .ctors 和 .CRT$XCU 的名字,而是抓住“编译器生成函数 → 特殊段收集 → 入口函数遍历 → 退出回调”。不同新工具链可能使用 .init_array/.fini_array 等名字,但机制仍可按这条线理解。
三个生活类比:
| 生活例子 | 对应关系 | 它帮助理解什么 | 类比边界 |
|---|---|---|---|
| 婚礼宾客名单 | 每个编译单元登记自己的构造函数,入口按名单执行 | 函数指针数组 | 名单顺序由链接规则决定,不一定是源码出现顺序 |
| 开店/关店清单 | 开店清单先执行,关店清单退出时逆序执行 | 构造与析构成对 | 复杂依赖仍可能出现初始化顺序问题 |
| 快递分拣格 | 特殊段像按标签排好的格子,链接器把同类格子拼起来 | 段合并和边界 | 段名规则是工具链约定,不是 C++ 语法本身 |
[!warning] 启动文件选项
使用-nostartfiles或-nostdlib会取消默认启动文件或标准运行库。若程序含有 C++ 全局对象,除非你自己补齐构造/析构路径,否则对象生命周期可能失效。
一句话总结: C++ 全局对象不是“编译器偷偷插入到 main 第一行”,而是通过特殊段和运行库的启动/退出通道组织起来的。
⭐⭐⭐⭐ 4.5 fread:从用户缓冲到平台 API 的一条链
一句话: fread 先尽量从 CRT 的 FILE 缓冲复制数据,缓冲不够时才向下调用 _read,最终由 Windows 的 ReadFile 等平台 API 读取。
为什么要缓冲: 如果每读一个字节就做一次系统调用,用户态/内核态切换会带来明显开销。CRT 会一次读入较大的块,之后的小片读取直接从缓冲复制;写文件也有写缓冲,所以程序异常退出时,尚未 flush 的内容可能丢失。
调用链:
关键对象:
FILE保存文件句柄、缓冲起点_base、缓冲大小_bufsiz、当前指针_ptr、剩余字节数_cnt和状态标志。fread_s比fread多一个bufferSize,可检查输出缓冲区大小;它在内部加锁后调用_fread_nolock_s。_fread_nolock_s用data指向用户输出区的当前位置,用count记录还需读取的字节数。
_fread_nolock_s 的三种分支:
FILE缓冲非空:取count和_cnt中较小值,用memcpy_s复制,并同步移动_ptr、_cnt、data、dataSize。- 要读的数据不少于缓冲大小:尽量按整数个缓冲块直接调用
_read,避免多余复制。 - 要读的数据小于缓冲大小:调用
_filbuf填充缓冲,再取出一个字符。
文本模式换行: Windows 文件常见 CRLF(\r\n),C 程序看到的换行通常是 \n。_read 在文本模式下会把 CRLF 转成 LF,还要处理 CR 恰好落在缓冲末尾的情况:普通磁盘文件可回退文件指针,管道/设备不能回退,就用 pipech 保存预读的字符。
错误与结束: _read 将平台错误翻译成 CRT 的错误码;读到 0 字节表示结束,错误则设置 _IOERR 等标志。fread 返回的是实际读取的元素数,不一定等于请求的 count。
三个生活类比:
| 生活例子 | 对应关系 | 它帮助理解什么 | 类比边界 |
|---|---|---|---|
| 仓库备货 | 先从柜台小仓库取货,空了再去大仓库 | FILE 缓冲减少系统调用 | 缓冲会占内存,也带来 flush/一致性问题 |
| 搬家纸箱 | 大批量整箱搬,小批量从箱内取 | 三种读取分支 | 文件读取还有 EOF、错误和文本转换等细节 |
| 翻译员 | 把 Windows CRLF 转成 C 看到的 LF | 平台格式适配 | 二进制模式不会按文本规则随意转换 |
[!abstract] 📌 一句话卡片|
fread
先看 CRT 缓冲,只有数据不够时才向操作系统读取;“读到数据”与“发生系统调用”不是一回事。
一句话总结: fread 是“缓冲管理 + 线程保护 + 平台适配”的综合案例,不只是一个简单的文件读取函数。
5. 本章完整核心过程 / 论证链 🔄
5.1 程序启动到退出
- 装载器/操作系统: 创建进程,把参数和环境信息放入约定位置,并跳到入口点。
- 入口函数: 取出
argc/argv/envp,保存栈边界,初始化运行库、堆、I/O 和线程相关状态。 - 语言初始化: 调用 C++ 全局构造路径,注册退出函数。
- 应用阶段: 入口函数调用
main,应用使用printf、malloc、文件和线程接口。 - 退出阶段:
main返回或显式调用exit,运行库按退出列表执行清理,刷新 I/O,最后请求操作系统结束进程。
动画脑补: 先看“进程刚出生”停在入口点;镜头依次点亮参数、堆、I/O 和构造函数;再把控制权交给 main;main 返回后,退出列表像倒放的清单一样逐项执行,最后画面交回操作系统。
| 镜头 | 画面 | 旁白/对白 | 对应图中关系 |
|---|---|---|---|
| 1 | 进程停在入口点 | “我还不能直接执行 main,环境尚未准备好。” | OS → 入口函数 |
| 2 | 参数、堆、I/O 和构造函数依次亮起 | “运行库先把语言需要的基础设施装好。” | 入口函数 → 初始化 |
| 3 | main 执行业务 | “现在应用可以安全使用常见库函数。” | 初始化 → main |
| 4 | 退出回调、析构、flush 依次执行 | “返回值记录好,清理完成后再结束进程。” | main → 清理 → OS |
5.2 fread 的数据流
fread 的决策顺序可以压缩成一句话:先查已有缓冲;不够就按数据量选择直接读大块或填充小块;必要时做文本换行转换;最后返回实际元素数。
动画脑补: 把 count 想成一个倒计时数字。每从缓冲复制一段就减少,缓冲空且剩余量大就整块向下读,剩余量小就补满缓冲再取;遇到 EOF 或错误,倒计时停止并返回已完成的元素数。
6. 重点概念关系图 ✨
沿着“应用 → 运行库 → 操作系统”看层次;再分别追 C、D、E 三条线,就能把本章各节放回同一张地图,而不会把 fread、TLS 和全局构造误认为互不相关的技巧。
7. 易混淆概念与常见误区 ⚠️
| 错误理解 | 正确理解 | 为什么易错 | 如何判断 |
|---|---|---|---|
程序从 main 开始 | 先到入口函数,再由入口调用 main | 初学者只看到自己写的代码 | 看可执行文件入口和启动代码 |
| CRT 就是 C 标准库 | CRT 包含标准库,还包含启动、堆、I/O、线程适配等 | 名称常被混用 | 看是否涉及 _start、堆初始化或启动文件 |
printf 每次都立刻写终端 | 可能先写入 stdio 缓冲,flush 时才下沉 | 输出看起来像同步操作 | 检查缓冲模式、fflush 和退出方式 |
| 线程能访问全局变量,所以状态天然安全 | 可访问不等于并发安全;需要 TLS、锁或安全变体 | “能读写”被误当成“不会冲突” | 找共享状态和保护边界 |
fread_s 只是名字更长的 fread | 它多了输出缓冲区大小检查,并在内部加锁 | 忽略了 bufferSize 的作用 | 对照参数列表和调用链 |
exit 与 _exit 完全一样 | exit 负责运行库退出处理,_exit 更接近直接结束进程 | 都含有“exit” | 观察是否执行 atexit、析构和缓冲刷新 |
全局对象构造就是编译器把代码塞进 main | 通常由特殊段中的函数指针和入口函数遍历完成 | 只从源码视角看不到链接阶段 | 检查 .ctors、.CRT$XCU 或现代等价段 |
| 不同 DLL 使用不同 CRT 只影响链接 | 还可能造成跨模块释放内存、使用 FILE* 等运行时错误 | 链接成功掩盖了所有权问题 | 明确资源由哪个 CRT 分配、释放 |
8. 题目与实践 🧪
基础题(5 题)
- 运行库入口在
main前至少准备哪三类环境? atexit注册的函数大致在什么时候执行?- CRT 和 C 标准库是什么关系?
- TLS 的全称是什么?它给线程带来什么性质?
fread读小块数据时,为什么可能不需要系统调用?
理解题(5 题)
- 为什么
errno需要线程私有化? strtok与strtok_s/strtok_r的核心区别是什么?- 为什么 C++ 全局析构常通过
atexit或__cxa_atexit注册? fread_s为什么既要检查bufferSize,又要对文件加锁?- 为什么使用
-nostartfiles或-nostdlib可能破坏全局对象生命周期?
思考题(3 题)
- 如果程序
main返回后立刻崩溃,哪些退出清理可能还没完成?请按运行库流程推测。 - 如果两个 DLL 之间传递一个由 A 分配、由 B 释放的指针,应该先检查哪些运行库边界?
- 如果
fread请求的数据小于文件缓冲大小,什么时候仍可能发生平台 API 调用?
面试 / 表达题(5 题)
- 请用 30 秒说明“程序为什么不从
main开始”。 - 请比较 glibc/GCC 与 MSVC CRT 实现全局构造的共同点和不同点。
- 请解释 TLS、锁、线程安全函数三者的分工。
- 请画出
fread → _read → ReadFile的调用链,并说明缓冲在哪一步生效。 - 请说明静态/动态 CRT 混用为什么可能在运行时出问题。
-
基础题答案:
1)参数/环境、堆和 I/O 等运行环境;还可能包括线程和全局对象初始化。
2)main正常返回或调用exit后的退出阶段。
3)标准库是标准规定的接口集合,CRT 是让 C 程序运行起来的更大支撑集合。
4)Thread Local Storage,线程局部存储;每个线程有自己的副本。
5)因为数据可能已经在FILE缓冲里,CRT 可直接复制。 -
理解题答案:
1)共享errno会被其他线程覆盖;TLS 可让每个线程读取自己的错误状态。
2)strtok依赖内部静态位置,安全变体把上下文交给调用者或使用可重入实现。
3)退出列表能统一安排清理,并按注册顺序的逆向关系完成析构。
4)bufferSize防止写出用户输出区;加锁防止共享FILE状态被并发破坏。
5)启动文件和 CRT 参与构造、析构、入口和退出;去掉它们后,必须自己补齐这些路径。 -
思考题评分点:
1)atexit回调、全局析构、stdio flush 和其他 CRT 收尾都可能未完成;不能把“返回main”等同于“所有清理完成”。
2)检查分配/释放是否来自同一 CRT、是否跨模块传递FILE*/C++ 对象,以及静态还是动态链接。
3)缓冲为空、请求量超过缓冲、文本转换需要继续读取,或发生 EOF/错误时,都可能触发向下读取。 -
面试题评分点: 每题都应先给结论,再给运行库流程,最后举
_start、_initterm、TLS 或fread的具体例子;不要只背文件名。
实践 1|观察 main 前后的 atexit 顺序
- 目标: 验证入口函数会在
main返回后执行退出回调。 - 前提/环境: 任意可用的 C 编译器;只在临时目录创建源文件和可执行文件。
- 预计时间: 15 分钟。
- 风险与提醒: 只编译和运行自己的小程序,不修改系统文件;不要把未 flush 的输出当成已经落盘的证据。
步骤
- 写一个注册
atexit函数、在main中打印两行文字的最小 C 程序。 - 编译并运行,记录
main内输出和退出回调输出的先后顺序。 - 将
main的正常返回改成显式exit,再次比较。
预期观察: 退出回调出现在 main 主体输出之后;两种退出方式都会经过运行库的正常退出路径,但不要把它们与 _exit 的行为混为一谈。
复盘问题
- 如果改用直接结束进程的接口,哪些运行库清理可能被跳过?
- 这个实验能证明“入口函数就是
_start的全部实现细节”吗?不能,还需要看目标平台的启动代码。
实践 2|观察可执行文件中的启动/初始化相关段
- 目标: 把“全局构造通过特殊段收集”的概念和实际目标文件联系起来。
- 前提/环境: Linux/类 Unix 环境、GCC/Clang 和
objdump或readelf;工具链可能显示现代等价段名。 - 预计时间: 20 分钟。
- 风险与提醒: 只对自己编译的测试程序执行只读查看命令,不修改或删除系统二进制文件。
步骤
- 编译一个含有
atexit或 C++ 全局对象的最小程序。 - 使用
objdump -h 程序或readelf -S 程序查看段列表。 - 搜索与初始化/析构相关的段名,并把它们和“函数指针数组—入口遍历—退出回调”流程对应起来。
预期观察: 不同编译器可能显示 .init/.fini、.ctors/.dtors 或 .init_array/.fini_array 等名称;名称差异不等于机制完全不同。
复盘问题
- 为什么仅看源码中的
main看不到这些初始化函数? - 如果链接时去掉默认启动文件,观察到的段和行为可能发生什么变化?
实践 3|手算一次 fread 缓冲决策
- 目标: 理解
count、_cnt、data和缓冲大小如何决定读取路径。 - 前提/环境: 纸笔或表格,不需要真实文件和管理员权限。
- 预计时间: 15 分钟。
- 风险与提醒: 这是概念模拟,不要把手算结果写成真实 CRT 的运行日志。
步骤
- 假设用户请求 100 字节,
FILE缓冲中已有 40 字节,记录第一次复制后的count和_cnt。 - 假设缓冲变空且剩余数据大于缓冲大小,判断应走“整块
_read”还是“填充缓冲”。 - 假设读到 EOF,说明
fread应返回什么类型的结果。
预期观察: 缓冲非空时先复制;缓冲空且请求量大时倾向整块读取;EOF/错误会让实际返回元素数小于请求值。
复盘问题
- 为什么缓冲可以减少系统调用,但会引入 flush 和一致性问题?
- 文本模式下
CRLF转换应发生在用户看到数据之前还是之后?
9. 本章速查表 📌
| 概念 | 一句话解释 | 为什么重要 | 容易混淆什么 |
|---|---|---|---|
| 入口函数 | 运行库真正接到控制权的启动代码 | 准备环境并调用 main | main 不是进程第一行 |
| CRT | 支撑 C 程序运行的代码集合 | 连接语言、平台和系统 | 不等于只包含标准库 |
atexit | 注册正常退出时要调用的函数 | 支持清理和析构 | 不保证异常/强制结束时一定执行 |
| TLS | 每线程一份的私有存储 | 隔离 errno 等状态 | 与共享全局变量不同 |
strtok_s/strtok_r | 显式传入上下文的线程安全变体 | 避免静态状态冲突 | 不是所有标准库都原生同名 |
.ctors/.CRT$XCU | 收集初始化函数指针的特殊段 | 支持全局构造 | 新工具链可能改用其他段名 |
FILE 缓冲 | CRT 为文件维护的中间数据区 | 减少系统调用 | 缓冲存在不等于已写入磁盘 |
fread_s | 带输出缓冲区大小参数的读取接口 | 便于边界检查并保护并发状态 | 不代表任何错误都自动修复 |
_read | CRT 到平台读取 API 的适配层 | 翻译错误、处理文本模式 | 不等于直接等同于 ReadFile |
exit / _exit | 前者做运行库清理,后者更接近直接结束 | 解释退出行为差异 | 两个名字不能互换理解 |
10. 本章总结与下一步 🚀
今天真正学会了什么?
- 程序的真实生命周期是“入口初始化 →
main→ 退出清理”,main只是中间阶段。 - CRT 把 C/C++ 语言能力适配到具体操作系统,标准库只是其中一部分。
- 多线程安全依赖两个方向:共享状态加锁,线程私有状态使用 TLS;跨 CRT 模块传递资源还要遵守所有权边界。
- C++ 全局构造/析构依赖编译器生成函数、链接器特殊段和运行库遍历/退出回调。
fread的核心优化是“先查缓冲,必要时才触发平台读取”,并在 Windows 文本模式下处理换行转换。
下一步
- 复习进程入口、栈、堆、ELF/PE 和链接相关笔记;当前目录中第 10 章只有旧版笔记,关联笔记可在后续补齐。
- 阅读 [[第12章 系统调用与API]],把运行库函数与系统调用/API 的边界接起来。
- 阅读 [[第13章 运行库实现]],动手实现一个简化的 C 运行库入口和基础函数。
[!tip] 30 秒复述挑战
不用术语讲清:程序先由运行库准备环境,才进入main;运行库还提供标准函数、I/O、堆和线程支持。main返回后,运行库负责回调、析构、刷新缓冲并结束进程;fread则先用自己的缓冲,必要时才向操作系统读数据。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)