🎯 什麼情境該想到我
當程式「還沒進 main 就掛了」、全域/靜態物件的初始化順序出問題、你想在 main 之前注入一段初始化、或用了 -nostdlib/-nostartfiles 之後冒出一堆莫名其妙的未定義符號時。
⚙️ 怎麼用
一、程式不是從 main 開始的
「正如基督徒认为世界的诞生起于 7 天创世一样,任何一个合格的 C/C++ 程序员都应该知道一个事实:程序从 main 函数开始。但是事情的真相真是如此吗?」(p343)
書中給三個「鐵證」(p343–344):進 main 時全域變數已初始化完、argc/argv 已備好、堆與 I/O 已初始化(所以能直接 malloc/printf);C++ 更多,全域物件的建構子已跑完;而 atexit 註冊的函式在 main 結束之後才跑。
標準流程四步(p344):
- OS 建立行程後,把控制權交給執行庫的入口函式(不是
main)。 - 入口函式初始化堆、I/O、執行緒、全域變數建構。
- 呼叫
main。 main返回後回到入口函式,做全域解構、銷毀堆、關閉 I/O,再用系統呼叫結束行程。
二、兩條真實路徑
glibc(p345–349)
_start ← 由 ld 的預設連結腳本指定,可用參數改
├ xor %ebp,%ebp ← 清零表示這是最外層函式
├ 從棧上取 argc / argv / envp
└ __libc_start_main(main, argc, argv, __libc_csu_init, __libc_csu_fini, ...)
├ __libc_csu_init ← main 之前的初始化
├ main()
└ exit(result)
├ 遍歷 __exit_funcs 鏈表(atexit 註冊的)
└ _exit() ← 真正的 exit 系統呼叫
MSVC(p349–352,作者評語是思路更清晰)
mainCRTStartup
→ 取 OS 版本 → _heap_init → _ioinit
→ __setargv / _setenvp → _cinit
→ 在 __try/__except 中呼叫 main → _cexit
一道好題目:入口函式一開頭為什麼用 _alloca 而不是 malloc?
「因为在程序的一开始堆还没有被初始化,而 alloca 是唯一可以不使用堆的动态分配机制。」(p350)
三、crt*.o 是什麼、為什麼連結順序不能亂
glibc 在每個目標檔引入 .init 與 .fini 兩個段,連結器把所有輸入檔的這兩段按順序收集合併,形成 _init() 與 _fini(),執行庫保證它們先於/後於 main 執行(p367)。
這兩個函式的頭尾分別來自 crti.o 與 crtn.o,所以連結順序被寫死(p368–369):
ld crt1.o crti.o [你的 .o] [系統庫] crtn.o
crti.o 必須在使用者檔案之前、crtn.o 必須在之後——順序錯了拼出來的函式就是壞的。
分工也要記清楚(p370):
| 檔案 | 屬於 | 作用 |
|---|---|---|
crt1.o / crti.o / crtn.o | glibc | _start、.init/.fini 的頭尾 |
crtbeginT.o / crtend.o | GCC | C++ 全域建構解構(.ctors 的邊界) |
libgcc.a | GCC | 平台差異補丁(如 32 位元平台上的 64 位元運算) |
libgcc_eh.a | GCC | C++ 例外處理支援 |
(C++ 全域建構之所以歸 GCC 而非 glibc,是因為那是編譯器的事,glibc 只是 C 執行庫。)
四、C++ 全域物件是怎麼被建構的
不是魔法,是**「段合併形成函式指標陣列」**(p384–388):
- 編譯器為每個編譯單元(.cpp)生成一個特殊函式(例
_GLOBAL__I_Hw),負責建構本單元所有全域物件。 - 在該目標檔的
.ctors段放一個指向它的指標。 - 連結時所有
.ctors合併成一個函式指標陣列;crtbegin.o提供起始邊界__CTOR_LIST__、crtend.o提供__CTOR_END__。 _do_global_ctors_aux從頭到尾逐一呼叫。
解構早期用對稱的 .dtors,但要求合併順序必須是 .ctors 的嚴格反序、增加連結器負擔;後來改成在每個編譯單元的建構函式裡用 __cxa_atexit() 註冊解構函式,靠「先註冊後呼叫」自然得到反序(p388)。
MSVC 機制完全同構,只是換名字:_initterm(__xc_a, __xc_z) 遍歷函式指標陣列,__xc_a/__xc_z 被 #pragma section 放進 .CRT$XCA 與 .CRT$XCZ,各編譯單元放 .CRT$XCU——連結器把相同屬性的段按段名字母序合併,A < U < Z,自然夾成陣列(p389–391)。
「这再一次证明了虽然各个操作系统、运行库、编译器在细节上大相径庭,但是在基本实现的机制上其实是完全相通的。」(p391)
實用推論:想在 main 之前跑一段程式碼,有兩招(p387)——
__attribute__((constructor)) void my_init(void) { ... }
// 或直接把函式指標塞進 .ctorsMSVC 上則可用 .CRT$XCG(G 在 U 之前)之類的段名控制自己的初始化次序(p391–392)。
五、-nostdlib 之後的那串未定義符號
第 13 章手寫 Mini CRT 時列出了完整的逃生開關(p455–456、472):
| 症狀 | 開關 |
|---|---|
GCC 內建把 strlen/strcmp 展開掉 | -fno-builtin |
缺 __stack_chk_fail(GCC)/__security_cookie、__security_check_cookie(MSVC) | -fno-stack-protector / /GS- |
缺 type_info::vftable | -fno-rtti / /GR- |
| 例外支援符號未定義 | -fno-exceptions |
缺 __dso_handle | 是 __cxa_atexit 的第三個參數,標示解構函式屬於哪個共享物件 |
連結時 -e mini_crt_entry 指定入口,crtbegin.o 必須在最前、crtend.o 必須在最後(p456、473)。
成果:Linux 上靜態連結 5083 bytes(同樣程式靜態連結 glibc 約 538KB);Windows 上 5120 bytes 且 dumpbin /IMPORTS 只依賴 KERNEL32.dll(p456–457)。
🧪 我實際套用的紀錄
- 2026-08-02:(待填)
⚠️ 注意
- 用了
-nostartfiles或-nostdlib,全域物件的建構與解構就不會執行。(p369、p388)這是「明明加了__attribute__((constructor))卻沒跑」最常見的原因。 - 多個建構函式之間預設沒有順序保證。 可用
__attribute__((constructor(5)))指定優先級:建構時數字小的先跑、解構時相反,剛好配對成「先申請的後釋放」(p272–273)。 - 這一整套是 GCC/MSVC 的擴充與實作細節,不是語言標準。 換編譯器、換版本都可能變。跨平台專案不要把架構壓在上面。
- 靜態初始化順序問題(static initialization order fiasco)在這個模型裡就很好理解了:跨編譯單元的
.ctors順序由連結器的輸入順序決定,語言層面根本沒定義。 - 動態庫的情況不同:
.so的.init由動態連結器執行,dlopen時則在dlopen回傳前執行;而可執行檔本身的.init不由動態連結器執行,由程式初始化程式碼負責(p244)。
🔗 相關工具
- 工具-編譯連結與裝載 —— 上游:這張卡接在四階段的「裝載」之後,補上「跳到入口 → 進 main」這一段
- 工具-虛擬記憶體與行程位址空間 —— 前一步:映射建好、CPU 跳到入口位址,然後才輪到這張卡
- 工具-靜態庫與動態庫 —— 相關:
crt*.o、libgcc.a、CRT 版本選擇都是連結決策的一部分 - 工具-符號決議與重定位 —— 這裡出現的一串未定義符號,用那張卡的診斷樹去分類