《程序员的自我修养》第 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] 一句话抓住本章
你写的 mainprintfmalloc 和 C++ 全局对象,并不是凭空就能工作。运行库(Runtime Library)负责把操作系统交给程序的入口,准备成语言能使用的环境,再负责 I/O、内存、线程、构造/析构和退出清理。

章节导航 🧭

进度:[██████████] 已整理
难度:⭐⭐⭐⭐ 预计阅读:35 分钟 预计理解与练习:60 分钟(均为估计)

  • 先理解:main 前后发生什么
  • 再理解:C/C++ 运行库由哪些部分组成
  • 重点掌握:多线程安全、TLS、全局构造/析构
  • 最后串起来:fread 如何从缓冲走到操作系统

1. 本章学习目标 🎯

学完后,应该能用自己的话回答:

  1. 为什么程序的真实入口通常不是 main?入口函数在 main 前后分别做什么?
  2. C 运行库(CRT)和 C 语言标准库是什么关系?glibc、MSVC CRT 又是什么?
  3. 为什么 errnostrtokmallocprintf 在多线程中需要额外保护?TLS 和加锁各解决什么问题?
  4. C++ 全局对象为什么能在 main 前构造、在退出时析构?glibc/GCC 和 MSVC 分别怎样组织这些函数?
  5. fread 为什么不一定每次都调用操作系统?缓冲区、fread_s_readReadFile 如何串起来?

前置背景是第 1—10 章涉及的进程、内存、目标文件、链接和动态链接;本章之后,[[第12章 系统调用与API]] 会把“运行库如何落到系统调用”讲得更直接,[[第13章 运行库实现]] 则进一步动手实现简化运行库。

2. 整章知识地图 🗺️

运行库

程序启动与退出

入口函数

main 前初始化

main 后清理

C/C++ 能力

标准库

I/O

堆管理

语言支持

多线程

TLS

加锁

线程安全接口

C++ 对象生命周期

全局构造

全局析构

atexit

文件读取

FILE 缓冲

fread 调用链

文本换行转换

这张图的中心不是某一个库文件,而是“应用代码如何获得一套可运行环境”。启动、库函数、多线程、对象生命周期和文件读取,都由运行库把语言规则与操作系统能力接起来。

3. 知识依赖与学习路径 🔗

进程、栈、堆、目标文件

运行库入口

C/C++ 标准能力

多线程安全与 TLS

全局构造与析构

stdio 与 fread

系统调用或平台 API

先把“程序如何出生”看懂,再看运行库提供的日常能力,最后跟一条 fread 调用链观察它如何落到平台 API。这样不会把 printfmalloc 当成操作系统直接提供的魔法函数。

4. 核心知识点 👩‍🏫

⭐⭐⭐⭐⭐ 4.1 入口函数:main 不是第一行代码

一句话: 操作系统把进程控制权交给入口点(Entry Point),入口点先准备运行环境,随后才调用 main

大白话解释: 把程序想成一家刚开门的店。main 是正式接待顾客的柜台,但在开门前,店员要先通电、摆好货、准备钥匙和账本;关门后还要清点、收银、关灯。入口函数就是这套“开店和关店流程”。

为什么会有它:main 第一行执行时,全局变量已经有初值,argc/argv 已经准备好,堆、栈、I/O 也已初始化;C++ 全局对象甚至已经开始构造。如果没有入口函数,应用就无法安全使用 mallocprintf 或 C++ 对象。

核心流程:

退出清理 main 运行库初始化 入口函数 操作系统 退出清理 main 运行库初始化 入口函数 操作系统 创建进程并跳到入口点 准备栈、参数、堆、I/O、线程环境 调用 main(argc, argv, envp) 返回状态码 执行 atexit、析构、刷新 I/O 结束进程

图示说明: 读图时先看 OS → 入口函数 → main,再看 main → 清理 → OSmain 只是中间的应用阶段,不是整个进程生命周期。

glibc 与 MSVC 的两个例子:

  • Linux/glibc 的入口通常是 _start。它从栈中取出 argcargv,再调用 __libc_start_main;后者注册收尾函数、执行初始化,最后调用 main
  • Windows/MSVC CRT 的入口通常是 mainCRTStartup。它会初始化操作系统信息、堆、I/O、命令行参数和环境变量,再调用 main,异常时进入 SEH(结构化异常处理)分支。

main 返回之后: 入口函数记录返回值并调用 exitexit 会遍历 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 运行库大致包含:

能力例子作用
启动与退出_startmainCRTStartupexit连接进程生命周期和 main
标准函数printfstrlenmemcpy提供 C 标准规定的常用能力
I/OFILEfopenfreadfwrite在文件/设备和程序之间传数据
堆管理mallocfree管理动态内存
语言支持va_listsetjmp、异常辅助代码支持 C/C++ 语义
调试与平台适配错误转换、启动文件与编译器和操作系统衔接
4.2.1 标准库、CRT、glibc、MSVC CRT 的区别
  • C 标准库: 标准规定的一组接口和行为,例如 stdio.hstring.hstdlib.hmath.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_startva_argva_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/newfree/delete分配器数据结构只有一个执行流访问并发修改空闲链表导致损坏
printf/fprintf输出目标可按顺序写入多线程输出交错或缓冲状态混乱
早期异常处理假设只有一个异常流不同线程的异常信息冲突

三种改进手段:

  1. TLS(Thread Local Storage,线程局部存储):errno、CRT 的线程状态等每个线程各有一份。
  2. 加锁:malloc、输出等共享数据结构周围自动加锁,避免同时修改。
  3. 提供线程安全变体: strtok_s/strtok_r 由调用者传入 context,不再依赖函数内部静态状态。

线程调用 CRT 函数

是否共享状态?

直接执行

适合线程私有化?

TLS 保存每线程副本

加锁或使用安全变体

得到线程隔离的结果

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,里面包含 errnostrtok 位置、随机数种子等 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 的思路:

  1. C++ 编译器为每个 .cpp 生成类似 GLOBAL__I_xxx 的函数,负责本编译单元的全局对象构造。
  2. 每个目标文件把这个函数指针放到 .ctors 段;链接器把同名段合并成函数指针数组。
  3. crtbegin.ocrtend.o 提供数组边界(书中称 __CTOR_LIST____CTOR_END__)。
  4. .init 中的代码调用 GCC 提供的 __do_global_ctors_aux,依次遍历构造函数。
  5. 早期实现用 .dtors/.fini 做析构;后来常见做法是在每个编译单元的初始化函数里通过 __cxa_atexit 注册析构回调,让 exit 逆序调用。

MSVC CRT 的思路:

  • mainCRTStartup 调用 _initterm(__xc_a, __xc_z)
  • __xc_a__xc_z 是函数指针数组的边界,分别放在 .CRT$XCA.CRT$XCZ
  • 各编译单元的初始化函数放进 .CRT$XCU;链接器按段名的字母顺序合并,形成连续数组。
  • 全局对象构造函数在初始化阶段执行;编译器生成一个析构辅助函数,再通过 atexit 注册到退出列表。

每个 .cpp 生成初始化函数

函数指针进入特殊段

链接器合并并确定边界

入口函数遍历指针数组

全局对象构造

main

atexit / __cxa_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 的内容可能丢失。

调用链:

ReadFile 或系统 API _read FILE 缓冲 fread/fread_s 应用 ReadFile 或系统 API _read FILE 缓冲 fread/fread_s 应用 alt [缓冲够用] [缓冲不足] fread(buffer, size, count, stream) 加锁并检查参数 缓冲有数据? memcpy_s 复制 _fread_nolock_s → _read 读取一块数据 返回字节数或错误 填充/转换缓冲 复制到用户 buffer 返回实际读取的元素数

关键对象:

  • FILE 保存文件句柄、缓冲起点 _base、缓冲大小 _bufsiz、当前指针 _ptr、剩余字节数 _cnt 和状态标志。
  • fread_sfread 多一个 bufferSize,可检查输出缓冲区大小;它在内部加锁后调用 _fread_nolock_s
  • _fread_nolock_sdata 指向用户输出区的当前位置,用 count 记录还需读取的字节数。

_fread_nolock_s 的三种分支:

  1. FILE 缓冲非空:取 count_cnt 中较小值,用 memcpy_s 复制,并同步移动 _ptr_cntdatadataSize
  2. 要读的数据不少于缓冲大小:尽量按整数个缓冲块直接调用 _read,避免多余复制。
  3. 要读的数据小于缓冲大小:调用 _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 程序启动到退出

  1. 装载器/操作系统: 创建进程,把参数和环境信息放入约定位置,并跳到入口点。
  2. 入口函数: 取出 argc/argv/envp,保存栈边界,初始化运行库、堆、I/O 和线程相关状态。
  3. 语言初始化: 调用 C++ 全局构造路径,注册退出函数。
  4. 应用阶段: 入口函数调用 main,应用使用 printfmalloc、文件和线程接口。
  5. 退出阶段: main 返回或显式调用 exit,运行库按退出列表执行清理,刷新 I/O,最后请求操作系统结束进程。

动画脑补: 先看“进程刚出生”停在入口点;镜头依次点亮参数、堆、I/O 和构造函数;再把控制权交给 mainmain 返回后,退出列表像倒放的清单一样逐项执行,最后画面交回操作系统。

镜头画面旁白/对白对应图中关系
1进程停在入口点“我还不能直接执行 main,环境尚未准备好。”OS → 入口函数
2参数、堆、I/O 和构造函数依次亮起“运行库先把语言需要的基础设施装好。”入口函数 → 初始化
3main 执行业务“现在应用可以安全使用常见库函数。”初始化 → main
4退出回调、析构、flush 依次执行“返回值记录好,清理完成后再结束进程。”main → 清理 → OS

5.2 fread 的数据流

fread 的决策顺序可以压缩成一句话:先查已有缓冲;不够就按数据量选择直接读大块或填充小块;必要时做文本换行转换;最后返回实际元素数。

动画脑补:count 想成一个倒计时数字。每从缓冲复制一段就减少,缓冲空且剩余量大就整块向下读,剩余量小就补满缓冲再取;遇到 EOF 或错误,倒计时停止并返回已完成的元素数。

6. 重点概念关系图 ✨

应用代码

C/C++ 运行库

启动/退出

标准函数与 I/O

堆与线程状态

操作系统入口与退出系统调用

FILE 缓冲

平台读文件 API

TLS 与锁

沿着“应用 → 运行库 → 操作系统”看层次;再分别追 CDE 三条线,就能把本章各节放回同一张地图,而不会把 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 题)

  1. 运行库入口在 main 前至少准备哪三类环境?
  2. atexit 注册的函数大致在什么时候执行?
  3. CRT 和 C 标准库是什么关系?
  4. TLS 的全称是什么?它给线程带来什么性质?
  5. fread 读小块数据时,为什么可能不需要系统调用?

理解题(5 题)

  1. 为什么 errno 需要线程私有化?
  2. strtokstrtok_s/strtok_r 的核心区别是什么?
  3. 为什么 C++ 全局析构常通过 atexit__cxa_atexit 注册?
  4. fread_s 为什么既要检查 bufferSize,又要对文件加锁?
  5. 为什么使用 -nostartfiles-nostdlib 可能破坏全局对象生命周期?

思考题(3 题)

  1. 如果程序 main 返回后立刻崩溃,哪些退出清理可能还没完成?请按运行库流程推测。
  2. 如果两个 DLL 之间传递一个由 A 分配、由 B 释放的指针,应该先检查哪些运行库边界?
  3. 如果 fread 请求的数据小于文件缓冲大小,什么时候仍可能发生平台 API 调用?

面试 / 表达题(5 题)

  1. 请用 30 秒说明“程序为什么不从 main 开始”。
  2. 请比较 glibc/GCC 与 MSVC CRT 实现全局构造的共同点和不同点。
  3. 请解释 TLS、锁、线程安全函数三者的分工。
  4. 请画出 fread → _read → ReadFile 的调用链,并说明缓冲在哪一步生效。
  5. 请说明静态/动态 CRT 混用为什么可能在运行时出问题。
点击查看答案与评分点
  1. 基础题答案:
    1)参数/环境、堆和 I/O 等运行环境;还可能包括线程和全局对象初始化。
    2)main 正常返回或调用 exit 后的退出阶段。
    3)标准库是标准规定的接口集合,CRT 是让 C 程序运行起来的更大支撑集合。
    4)Thread Local Storage,线程局部存储;每个线程有自己的副本。
    5)因为数据可能已经在 FILE 缓冲里,CRT 可直接复制。

  2. 理解题答案:
    1)共享 errno 会被其他线程覆盖;TLS 可让每个线程读取自己的错误状态。
    2)strtok 依赖内部静态位置,安全变体把上下文交给调用者或使用可重入实现。
    3)退出列表能统一安排清理,并按注册顺序的逆向关系完成析构。
    4)bufferSize 防止写出用户输出区;加锁防止共享 FILE 状态被并发破坏。
    5)启动文件和 CRT 参与构造、析构、入口和退出;去掉它们后,必须自己补齐这些路径。

  3. 思考题评分点:
    1)atexit 回调、全局析构、stdio flush 和其他 CRT 收尾都可能未完成;不能把“返回 main”等同于“所有清理完成”。
    2)检查分配/释放是否来自同一 CRT、是否跨模块传递 FILE*/C++ 对象,以及静态还是动态链接。
    3)缓冲为空、请求量超过缓冲、文本转换需要继续读取,或发生 EOF/错误时,都可能触发向下读取。

  4. 面试题评分点: 每题都应先给结论,再给运行库流程,最后举 _start_initterm、TLS 或 fread 的具体例子;不要只背文件名。

实践 1|观察 main 前后的 atexit 顺序

  • 目标: 验证入口函数会在 main 返回后执行退出回调。
  • 前提/环境: 任意可用的 C 编译器;只在临时目录创建源文件和可执行文件。
  • 预计时间: 15 分钟。
  • 风险与提醒: 只编译和运行自己的小程序,不修改系统文件;不要把未 flush 的输出当成已经落盘的证据。

步骤

  1. 写一个注册 atexit 函数、在 main 中打印两行文字的最小 C 程序。
  2. 编译并运行,记录 main 内输出和退出回调输出的先后顺序。
  3. main 的正常返回改成显式 exit,再次比较。

预期观察: 退出回调出现在 main 主体输出之后;两种退出方式都会经过运行库的正常退出路径,但不要把它们与 _exit 的行为混为一谈。

复盘问题

  1. 如果改用直接结束进程的接口,哪些运行库清理可能被跳过?
  2. 这个实验能证明“入口函数就是 _start 的全部实现细节”吗?不能,还需要看目标平台的启动代码。

实践 2|观察可执行文件中的启动/初始化相关段

  • 目标: 把“全局构造通过特殊段收集”的概念和实际目标文件联系起来。
  • 前提/环境: Linux/类 Unix 环境、GCC/Clang 和 objdumpreadelf;工具链可能显示现代等价段名。
  • 预计时间: 20 分钟。
  • 风险与提醒: 只对自己编译的测试程序执行只读查看命令,不修改或删除系统二进制文件。

步骤

  1. 编译一个含有 atexit 或 C++ 全局对象的最小程序。
  2. 使用 objdump -h 程序readelf -S 程序 查看段列表。
  3. 搜索与初始化/析构相关的段名,并把它们和“函数指针数组—入口遍历—退出回调”流程对应起来。

预期观察: 不同编译器可能显示 .init/.fini.ctors/.dtors.init_array/.fini_array 等名称;名称差异不等于机制完全不同。

复盘问题

  1. 为什么仅看源码中的 main 看不到这些初始化函数?
  2. 如果链接时去掉默认启动文件,观察到的段和行为可能发生什么变化?

实践 3|手算一次 fread 缓冲决策

  • 目标: 理解 count_cntdata 和缓冲大小如何决定读取路径。
  • 前提/环境: 纸笔或表格,不需要真实文件和管理员权限。
  • 预计时间: 15 分钟。
  • 风险与提醒: 这是概念模拟,不要把手算结果写成真实 CRT 的运行日志。

步骤

  1. 假设用户请求 100 字节,FILE 缓冲中已有 40 字节,记录第一次复制后的 count_cnt
  2. 假设缓冲变空且剩余数据大于缓冲大小,判断应走“整块 _read”还是“填充缓冲”。
  3. 假设读到 EOF,说明 fread 应返回什么类型的结果。

预期观察: 缓冲非空时先复制;缓冲空且请求量大时倾向整块读取;EOF/错误会让实际返回元素数小于请求值。

复盘问题

  1. 为什么缓冲可以减少系统调用,但会引入 flush 和一致性问题?
  2. 文本模式下 CRLF 转换应发生在用户看到数据之前还是之后?

9. 本章速查表 📌

概念一句话解释为什么重要容易混淆什么
入口函数运行库真正接到控制权的启动代码准备环境并调用 mainmain 不是进程第一行
CRT支撑 C 程序运行的代码集合连接语言、平台和系统不等于只包含标准库
atexit注册正常退出时要调用的函数支持清理和析构不保证异常/强制结束时一定执行
TLS每线程一份的私有存储隔离 errno 等状态与共享全局变量不同
strtok_s/strtok_r显式传入上下文的线程安全变体避免静态状态冲突不是所有标准库都原生同名
.ctors/.CRT$XCU收集初始化函数指针的特殊段支持全局构造新工具链可能改用其他段名
FILE 缓冲CRT 为文件维护的中间数据区减少系统调用缓冲存在不等于已写入磁盘
fread_s带输出缓冲区大小参数的读取接口便于边界检查并保护并发状态不代表任何错误都自动修复
_readCRT 到平台读取 API 的适配层翻译错误、处理文本模式不等于直接等同于 ReadFile
exit / _exit前者做运行库清理,后者更接近直接结束解释退出行为差异两个名字不能互换理解

10. 本章总结与下一步 🚀

今天真正学会了什么?

  1. 程序的真实生命周期是“入口初始化 → main → 退出清理”,main 只是中间阶段。
  2. CRT 把 C/C++ 语言能力适配到具体操作系统,标准库只是其中一部分。
  3. 多线程安全依赖两个方向:共享状态加锁,线程私有状态使用 TLS;跨 CRT 模块传递资源还要遵守所有权边界。
  4. C++ 全局构造/析构依赖编译器生成函数、链接器特殊段和运行库遍历/退出回调。
  5. fread 的核心优化是“先查缓冲,必要时才触发平台读取”,并在 Windows 文本模式下处理换行转换。

下一步

  • 复习进程入口、栈、堆、ELF/PE 和链接相关笔记;当前目录中第 10 章只有旧版笔记,关联笔记可在后续补齐。
  • 阅读 [[第12章 系统调用与API]],把运行库函数与系统调用/API 的边界接起来。
  • 阅读 [[第13章 运行库实现]],动手实现一个简化的 C 运行库入口和基础函数。

[!tip] 30 秒复述挑战
不用术语讲清:程序先由运行库准备环境,才进入 main;运行库还提供标准函数、I/O、堆和线程支持。main 返回后,运行库负责回调、析构、刷新缓冲并结束进程;fread 则先用自己的缓冲,必要时才向操作系统读数据。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐