bug诞生记——临时变量、栈变量导致的双杀
? ? ? ? 這是《bug誕生記》的第一篇文章。本來想起個文藝點的名字,比如《Satan(撒旦)來了》,但是最后還是想讓這系列的重心放在“bug的產生過程”和“缺失的知識點”上,于是就有了本系列這個稍微中性的名稱。(轉載請指明出于breaksoftware的csdn博客)
? ? ? ? 本系列博文的案例將都秉承一個原則——“因知識缺失,而非粗心大意導致”。在實際工作中,粗心大意產生的bug太多了。但這是工作態度或者工作狀態導致的,要去解決這個問題,可能會從機制、管理等角度來講述。但是這不是本系列想討論的。本系列更想講述的是一種“無心之過”,即因為相應知識點的缺乏,程序員想當然的做法導致的bug。
? ? ? ? 這篇我將講述一個真實發生在工作中的例子。當然實際的代碼和邏輯遠比下文例子要復雜很多,我只是抽出比較核心的點來分析。至于為什么要這么做?為什么要這么設計?為什么要這種風格?為什么代碼不嚴謹?……等與問題核心無關的疑問,我都將不做辯論。這個“免責聲明”也將貫穿本系列所有例子。
? ? ? ?下面這段代碼用于拼裝出一個絕對路徑
std::string get_name(const std::string& index) {char name[16] = "Movie";std::string full_name = std::string(name) + index;return full_name;
}int main() {char full_path[32] = "/home/work/";std::string name = get_name("1");const char* ptr_name = name.c_str();strcat(full_path + strlen(full_path), ptr_name);printf(full_path);return 0;
}
? ? ? ? 它將輸出/home/work/Movie1。
? ? ? ? 目前它還沒什么問題,后來發生的邏輯變更:只要返回固定的/home/work/Movie路徑就行了。于是程序員的改法是
std::string get_name(/*const std::string& index*/) {char name[16] = "Movie";std::string full_name = std::string(name);// + index;return full_name;
}int main() {char full_path[32] = "/home/work/";std::string name = get_name(/*"1"*/);const char* ptr_name = name.c_str();strcat(full_path + strlen(full_path), ptr_name);printf(full_path);return 0;
}
? ? ? ? 程序員將之前的代碼注釋掉了,當然這個行為是非常不好的。
? ? ? ? 此時一個具有“良知”但是“缺乏知識點”的同事將走上“犯罪”的道路,因為他將抱著“優化代碼”的目的去產生了第一個bug。他是這么改的
std::string get_name() {char name[16] = "Movie";return std::string(name);
}int main() {char full_path[32] = "/home/work/";const char* ptr_name = get_name().c_str();strcat(full_path + strlen(full_path), ptr_name);printf(full_path);return 0;
}
- 把注釋掉的廢棄代碼給刪掉了。這是值得肯定的。
- 精簡了get_name函數,不再以full_name作為返回值,減少了一次std::string類型的構造和釋放。這個也是值得肯定的。
- 精簡了main函數,刪除了std::string name局部變量,試圖直接從get_name()獲取const char*指針。他的想法是好的,但是這步將導致bug。
? ? ? ? 這段代碼沒有經過測試就上線了。因為他和QA都覺得這點小的改動不需要測試。很可惜,自以為是往往要付出代價。因為這段代碼的最終執行結果是/home/work,而不是約定的/home/work/Movie。那Movie去哪里了?
? ? ? ? 問題就出在第8行代碼。一般情況下我們認為它可以翻譯為下面兩行
std::string temp = get_name();
const char* ptr_name = temp.c_str();
? ? ? ? 如果的確是上面這么翻譯的,那也沒問題。但是實際上,temp是個行內的臨時變量,它脫離了該行就被釋放了。我們看下反匯編
const char* ptr_name = get_name().c_str();lea eax,[ebp-148h] push eax call get_name (0851672h) add esp,4 mov dword ptr [ebp-15Ch],eax mov ecx,dword ptr [ebp-15Ch] call std::basic_string<char,std::char_traits<char>,std::allocator<char> >::c_str (085166Dh) mov dword ptr [ptr_name],eax lea ecx,[ebp-148h] call std::basic_string<char,std::char_traits<char>,std::allocator<char> >::~basic_string<char,std::char_traits<char>,std::allocator<char> > (08513D9h)
? ? ? ? 第4行調用了get_name方法,返回的std::string對象指針放在eax中。
? ? ? ? 第6行將該對象指針放到當前函數棧幀內——即一個臨時對象。
? ? ? ? 第7行又將臨時對象地址放到ecx中。ecx在C++編譯中,一般用于傳遞this指針。
? ? ? ? 第8行對ecx中保存的std::string臨時對象的this指針調用了c_str成員方法,得到的const char*地址保存在eax中。
? ? ? ? 第9行將上一指令返回的const char*地址保存到ptr_name局部變量中,此時ptr_name指向的是std::string臨時對象的字符空間地址。它也就在這一刻是該程序員所期望的樣子。
? ? ? ? 第10行和第11行,又通過ecx調用std::string的析構函數。這樣保存在[ebp-148h]中的std::string對象指針指向的臨時對象被析構,也就意味著第9步得到的指針數據被刪除了。
? ? ? ? 所以const char* ptr_name = get_name().c_str();正確的翻譯是
const char* ptr_name = NULL;
{std::string temp = get_name();ptr_name = temp.c_str();
}
? ? ? ? 最終這個程序員老老實實的把main中那行“精簡代碼”變成了兩行
std::string get_name() {char name[16] = "Movie";return std::string(name);
}int main() {char full_path[32] = "/home/work/"; std::string name = get_name();const char* ptr_name = name.c_str();strcat(full_path + strlen(full_path), ptr_name);printf(full_path);return 0;
}
? ? ? ? 我想經過這次驚魂實踐,這個程序員應該再也不想碰這塊代碼了。
? ? ? ? 但是肯定還有其他“有良知”的程序員,他會覺得這代碼很不爽——搞什么std::string?轉來轉去,最終還是用了const char*。
? ? ? ? 于是他修改如下
const char* get_name() {char name[16] = "Movie";return name;
}int main() {char full_path[32] = "/home/work/"; const char* ptr_name = get_name();strcat(full_path + strlen(full_path), ptr_name);printf(full_path);return 0;
}
- 將get_name方法的返回類型改成了const char*,省去了中間std::string的轉換。
- 將main中的std::string全干掉了。
? ? ? ? 這段代碼修改的足夠簡單了。有人可能會覺得get_name可能可以干掉,直接在main函數中寫死路徑就行了。我想當時這個程序員保留get_name的原因可能是他預測該函數可能還是存在被定制的可能性。
? ? ? ? 拋開其他問題,這段程序的執行結果是正確的。相信這個版本的修改者此時內心是澎湃的,因為他覺得他不費吹灰之力,就干了一件好事。可是實際他卻挖了一個坑。
? ? ? ? 這段代碼在線上穩定運行了很久,后來隨著業務邏輯的增長。一個同學不小心在第8和第9行代碼之間插入了一個函數調用,然后這個程序就崩掉了。相信這個同學一定很郁悶,因為他可能僅僅修改了一下函數調用順序,或者寫了一個足夠簡單到不太可能出錯的代碼。結果進程崩潰,他要背鍋!
? ? ? ? 為了把問題簡單化,我讓新插入的代碼只干一件事——初始化一個棧上空間。
const char* get_name() {char name[16] = "Movie";return name;
}void satan() {char name[16] = "I am Satan";
}int main() {char full_path[32] = "/home/work/"; const char* ptr_name = get_name();satan();strcat(full_path + strlen(full_path), ptr_name);printf(full_path);return 0;
}
? ? ? ? 這個程序的輸出是/home/work/I am Satan。僅僅加了一個satan函數,就改變了結果?是的。這是由于之前那個做代碼修改的同學對棧變量和棧幀不熟悉導致的。
? ? ? ? 如果要介紹棧變量和棧幀,這個就需要從計算機基礎知識講起。本文當然不會講的很細,大家可以通過其他渠道獲取這些知識。
? ? ? ? 下面的圖只是表意,不是準確表達棧空間清空。但是核心的意思是一致的。
? ? ? ? 進入main函數時,棧結構如下
? ? ? ? 紅色表示當前活動的棧空間;綠色表示“未知區域”,該區域的數據我們可以認為是“野”的,不可保障的。
? ? ? ? 進入get_name函數后,棧結構變化如下
? ? ? ? get_name的name數組將保存“Movie”。
? ? ? ? get_name調用結束,發生退棧行為。于是程序又回到main函數中,其棧結構如下
? ? ? ? 注意一下,第12行代碼已經讓ptr_name指向了“野”空間。此時“野”空間數據還沒被污染,所以執行結果還是正確的。
? ? ? ? 然后我們調用了satan函數。進入satan函數后棧結構如下
? ? ? ? 注意一下,之前get_name的name空間已經被satan的name空間覆蓋了。此時它的數據是“I am Satan”。
? ? ? ? satan函數結束后,又回到main函數空間,其棧結構如下
? ? ? ? 我們看到,此時ptr_name還是指向“野”空間。只是此時空間不再是調用get_name時的情況,棧上數據已經被satan函數“污染”了。
? ? ? ? 所以出現錯誤的結果是必然的。
? ? ? ? 這段不到15行的代碼經過多個程序員的修改后,仿佛成了一個江湖。其中發生了各種因為“知識點缺乏”而導致的bug。所以如果我們不知道代碼最終精確的表達,可能就會寫出各種“不經意”的問題。
總結
以上是生活随笔為你收集整理的bug诞生记——临时变量、栈变量导致的双杀的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: C++拾趣——使用多态减少泛型带来的代码
- 下一篇: bug诞生记——const_cast引发