比Printf高效1000倍!如何精準(zhǔn)捕捉C/C++野指針
大家好,我是島主小風(fēng)哥。
內(nèi)存是C/C++程序員的好幫手,我們通常說(shuō)C/C++程序性能更高其原因之一就在于可以自己來(lái)管理內(nèi)存,然而計(jì)算機(jī)科學(xué)中沒(méi)有任何一項(xiàng)技術(shù)可以包治百病,內(nèi)存問(wèn)題也給C/C++程序員帶來(lái)無(wú)盡的煩惱。
野指針、數(shù)組越界、錯(cuò)誤的內(nèi)存分配或者釋放、多線程讀寫(xiě)導(dǎo)致內(nèi)存被破壞等等,這些都會(huì)導(dǎo)致某段內(nèi)存中的數(shù)據(jù)被”無(wú)意“的破壞掉,這類(lèi)bug通常很難定位,因?yàn)楫?dāng)程序開(kāi)始表現(xiàn)異常時(shí)通常已經(jīng)距離真正出問(wèn)題的地方很遠(yuǎn)了,常用的程序調(diào)試方法往往很難排查此類(lèi)問(wèn)題。
既然這類(lèi)問(wèn)題通常是由于內(nèi)存的讀寫(xiě)造成,那么如果要是某一段內(nèi)存被修改或者讀取時(shí)我們能觀察到此事件就好了,幸運(yùn)的是這類(lèi)技術(shù)已經(jīng)實(shí)現(xiàn)了。
圖片
一段示例
在GDB中你可以通過(guò)添加watchpoint來(lái)觀察一段內(nèi)存,這段內(nèi)存被修改時(shí)程序?qū)?huì)停止,此時(shí)我們就能知道到底是哪行代碼對(duì)該內(nèi)存進(jìn)行了修改,這功能是不是很強(qiáng)大。
接下來(lái)我們用示例來(lái)講解一下,有這樣一段代碼:
#include <iostream>
#include <thread>
using namespace std;
// 線程修改變量值
void memory_write(int* value) {
*value = 1;
}
int main()
{
int a = 10;
// 獲取局部變量a的地址
int* c = &a;
for (int i = 0; i < 100; i++) {
a += i;
}
cout << a << endl;
// 將變量a的地址傳遞到線程
thread t(memory_write, c);
t.join();
return 0;
}
這段代碼非常簡(jiǎn)單,創(chuàng)建局部變量a,然后獲取變量a的地址并賦值給指針c,此后對(duì)變量a進(jìn)行累加和,然后輸出a的值,此時(shí)a的值為4960。
假設(shè)此后你發(fā)現(xiàn)變量a的值竟然變?yōu)榱?,然而由于代碼非常復(fù)雜你并不知道到底是哪段代碼對(duì)變量a進(jìn)行修改,在上述代碼中我們利用線程a來(lái)模擬這個(gè)場(chǎng)景,線程獲取變量a的地址后對(duì)其進(jìn)行了修改,將其變?yōu)榱?,接下來(lái)我們利用調(diào)試工具gdb來(lái)定位到底是誰(shuí)修改了變量a。
開(kāi)始捕捉“肇事者”
對(duì)上述代碼進(jìn)行編譯,接下來(lái)利用gdb進(jìn)行調(diào)試,假設(shè)源文件的名稱是a.cc,編譯后的可執(zhí)行程序名字為a:
$ gdb a.out
(gdb) b a.cc:20
Breakpoint 1 at 0x400f23: file a.cc, line 20.
(gdb) r
Starting program: /bin/a
Breakpoint 1, main () at a.cc:20
20 cout << a << endl;
上述調(diào)試命令(b a.cc:20)表示我們?cè)诖a的第20行加斷點(diǎn),當(dāng)程序運(yùn)行到這里后暫停,調(diào)試命令r表示開(kāi)始運(yùn)行程序,可以看到運(yùn)行到第20行后暫停,此時(shí)我們查看一下變量a的地址:
(gdb) p &a
$1 = (int *) 0x7fffffffe508
可以看到,變量a位于內(nèi)存地址0x7fffffffe508,接下來(lái)重點(diǎn)來(lái)了,我們?cè)撛鯓痈嬖Vgdb讓它幫我們時(shí)刻監(jiān)測(cè)0x7fffffffe508這個(gè)內(nèi)存地址中的值有沒(méi)有被修改呢?很簡(jiǎn)單:
(gdb) watch *(int*)0x7fffffffe508
Hardware watchpoint 2: *(int*)0x7fffffffe508
我們利用watch命令,讓gdb幫我們時(shí)刻監(jiān)測(cè)一段從0x7fffffffe508開(kāi)始大小為4字節(jié)的內(nèi)存區(qū)域(假設(shè)int占據(jù)4字節(jié)),這就是watch *(int*)0x7fffffffe508這行指令的含義:
圖片
除此之外上面gdb的輸出中還有一段值得注意:
Hardware watchpoint 2: *(int*)0x7fffffffe508
注意看,什么是Hardware watchpoint呢?先賣(mài)個(gè)關(guān)子,我們稍后聊,接下來(lái)我們運(yùn)行g(shù)db中的c命令,意思是continue,讓程序繼續(xù)運(yùn)行:
(gdb) c
Continuing.
4960
此時(shí)第20行執(zhí)行完畢并打印出了變量a的值4960,我們接著往下看:
[New Thread 0x7ffff6f5c700 (LWP 531823)]
[Switching to Thread 0x7ffff6f5c700 (LWP 531823)]
Hardware watchpoint 2: *(int*)0x7fffffffe508
Old value = 4960
New value = 1
memory_write (value=0x7fffffffe508) at a.cc:8
8 }
(gdb)
哈哈,gdb成功的捕捉到了是哪一行代碼修改了0x7fffffffe508這塊內(nèi)存,而且詳細(xì)的告訴我們所有信息,可以看到gdb打印出了這塊內(nèi)存之前保存的數(shù)據(jù)是數(shù)字4960,修改后的值為1,并且是在a.cc:8這里被修改的,而這里正是我們創(chuàng)建的線程對(duì)變量a進(jìn)行修改的地方,gdb成功的捕捉到了”肇事者“,原來(lái)是這個(gè)線程”無(wú)意“修改了變量a的值。
圖片
是不是很神奇,那么這一切都是怎樣實(shí)現(xiàn)的呢?
watchpoint是怎樣實(shí)現(xiàn)的?
原來(lái)這一切都是CPU的功勞。
現(xiàn)代處理器中具有特殊的debug寄存器,x86處理器中是DR0到DR7寄存器,利用這些寄存器硬件可以持續(xù)檢測(cè)處理器發(fā)出的用于讀寫(xiě)內(nèi)存的地址,更強(qiáng)大的是,不但硬件watchpoint可以檢查內(nèi)存地址,而且還是可以監(jiān)測(cè)到底是在讀內(nèi)存還是在寫(xiě)內(nèi)存。
利用gdb中的rwatch命令你可以來(lái)監(jiān)測(cè)是否有代碼讀取了某段內(nèi)存;利用gdb中的awatch命令你可以來(lái)檢查是否有代碼修改了某段內(nèi)存;利用gdb中的watch命令你可以檢查對(duì)某段內(nèi)存是否有讀或者寫(xiě)這兩種情況。
一旦硬件監(jiān)測(cè)到相應(yīng)事件,就會(huì)暫停程序的運(yùn)行并把控制權(quán)交給debugger,也就是這里的gdb,此時(shí)我們就可以對(duì)程序的狀態(tài)進(jìn)行詳細(xì)的查看了,這種硬件本身支持的調(diào)試能力就是剛才提到的Hardware watchpoint。
有hardware watchpoint就會(huì)有software watchpoint,當(dāng)硬件不支持hardware watchpoint時(shí)gdb會(huì)自動(dòng)切換到software watchpoint,此時(shí)你的程序每被執(zhí)行一條機(jī)器指令gdb就會(huì)查看相應(yīng)的事件是否發(fā)生,因此software watchpoint要遠(yuǎn)比hardware watchpoint慢,你可以利用gdb中的”set can-use-hw-watchpoints“命令來(lái)控制gdb該使用哪類(lèi)watchpoint。
值得注意的是,在多線程程序中software watchpoint作用有限,因?yàn)槿绻粰z測(cè)的一段內(nèi)存被其它線程修改(就像本文中的示例)那么gdb可能捕捉不到該事件。