UB 如何讓最佳化器刪掉檢查(時間旅行)
假設你在程式裡加了一道謹慎的安全檢查:「如果這個指標是空的,就在使用它之前先退出。」你覺得安全了。然後你開了最佳化重新建構,那道檢查卻從程式裡「消失」了——編譯器移除了正在保護你的那一行。這不是編譯器的臭蟲,而是未定義行為最令人意外、也最危險的後果,一旦你看懂為什麼,就永遠忘不了。
以下是最佳化器一步步遵循的推理。它看到你解參考了一個指標 p,再過幾行又看到你測試 if (p == NULL)。解參考空指標是未定義行為,而最佳化器被允許假設未定義行為永不發生。於是它推論:「既然 p 已經被解參考、而程式被假定為正確,那麼 p 在這裡『不可能』是空的,所以 if (p == NULL) 必為假,裡面的分支就是死碼」——於是它刪掉了那道檢查。這個錯誤讓你付出雙重代價:保護沒了,「而且」原本那次對壞指標的解參考照樣執行。這效果在表面時間上甚至會「往回」跑:因為整支程式被假定無 UB,編譯器可以搬動或移除排在那個出問題操作「之前」的程式碼,這就是為什麼人們形容 UB 讓最佳化器「時間旅行」。一個操作的後果,可能比操作本身更早出現。
為什麼重要:這正是把一個「看起來無害」的錯誤變成真正安全漏洞的機制。寫成 if (x + 1 < x) 的有號數溢位檢查,會被最佳化成 if (false),因為有號數溢位是未定義、因此「永不發生」;長度檢查也會以同樣方式蒸發。實務上的教訓不是「不要信任最佳化器」,而是「一開始就別寫出含未定義行為的程式碼」,並且把檢查寫成在危險操作「之前」測試輸入、而非之後——同時使用適當的良好定義作法(無號型別、__builtin_*_overflow、明確的範圍測試),而不要倚賴硬體碰巧會做的事。
void use(int *p) { int v = *p; /* 若 p 為空,這是 UB */ if (p == NULL) /* 最佳化器:『p 已被解參考,所以它不是空的』 */ return; /* -> 這道檢查可能被整個刪掉 */ do_work(v); } /* 安全版:在解參考『之前』先測試。 */ void use_safe(int *p) { if (p == NULL) return; do_work(*p); }
因為當 p 為空時前面的解參考是 UB,編譯器可以假設 p 非空,並移除後面那道空指標檢查。
這裡的最佳化器既非惡意也非有臭蟲——它只是正確地利用了標準「UB 永不發生」的承諾。修正之道永遠是移除 UB,而不是去和最佳化器對抗。請在危險操作「之前」驗證輸入。