🎯 什麼情境該想到我

當程式「還沒進 main 就掛了」、全域/靜態物件的初始化順序出問題、你想在 main 之前注入一段初始化、或用了 -nostdlib-nostartfiles 之後冒出一堆莫名其妙的未定義符號時。

⚙️ 怎麼用

一、程式不是從 main 開始的

「正如基督徒认为世界的诞生起于 7 天创世一样,任何一个合格的 C/C++ 程序员都应该知道一个事实:程序从 main 函数开始。但是事情的真相真是如此吗?」(p343)

書中給三個「鐵證」(p343–344):進 main 時全域變數已初始化完、argc/argv 已備好、堆與 I/O 已初始化(所以能直接 mallocprintf);C++ 更多,全域物件的建構子已跑完;而 atexit 註冊的函式在 main 結束之後才跑。

標準流程四步(p344):

  1. OS 建立行程後,把控制權交給執行庫的入口函式(不是 main)。
  2. 入口函式初始化堆、I/O、執行緒、全域變數建構。
  3. 呼叫 main
  4. 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.ocrtn.o,所以連結順序被寫死(p368–369):

ld crt1.o crti.o [你的 .o] [系統庫] crtn.o

crti.o 必須在使用者檔案之前crtn.o 必須在之後——順序錯了拼出來的函式就是壞的。

分工也要記清楚(p370):

檔案屬於作用
crt1.o / crti.o / crtn.oglibc_start.init/.fini 的頭尾
crtbeginT.o / crtend.oGCCC++ 全域建構解構(.ctors 的邊界)
libgcc.aGCC平台差異補丁(如 32 位元平台上的 64 位元運算)
libgcc_eh.aGCCC++ 例外處理支援

(C++ 全域建構之所以歸 GCC 而非 glibc,是因為那是編譯器的事,glibc 只是 C 執行庫。)

四、C++ 全域物件是怎麼被建構的

不是魔法,是**「段合併形成函式指標陣列」**(p384–388):

  1. 編譯器為每個編譯單元(.cpp)生成一個特殊函式(例 _GLOBAL__I_Hw),負責建構本單元所有全域物件。
  2. 在該目標檔的 .ctors放一個指向它的指標。
  3. 連結時所有 .ctors 合併成一個函式指標陣列;crtbegin.o 提供起始邊界 __CTOR_LIST__crtend.o 提供 __CTOR_END__
  4. _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) { ... }
// 或直接把函式指標塞進 .ctors

MSVC 上則可用 .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)。

🔗 相關工具