比Printf高效1000倍!如何精準捕捉C/C++野指針
大家好,我是島主小風哥。
內存是C/C++程序員的好幫手,我們通常說C/C++程序性能更高其原因之一就在于可以自己來管理內存,然而計算機科學中沒有任何一項技術可以包治百病,內存問題也給C/C++程序員帶來無盡的煩惱。
野指針、數組越界、錯誤的內存分配或者釋放、多線程讀寫導致內存被破壞等等,這些都會導致某段內存中的數據被”無意“的破壞掉,這類bug通常很難定位,因為當程序開始表現異常時通常已經距離真正出問題的地方很遠了,常用的程序調試方法往往很難排查此類問題。
既然這類問題通常是由于內存的讀寫造成,那么如果要是某一段內存被修改或者讀取時我們能觀察到此事件就好了,幸運的是這類技術已經實現了。
圖片
一段示例
在GDB中你可以通過添加watchpoint來觀察一段內存,這段內存被修改時程序將會停止,此時我們就能知道到底是哪行代碼對該內存進行了修改,這功能是不是很強大。
接下來我們用示例來講解一下,有這樣一段代碼:
#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;
}
這段代碼非常簡單,創建局部變量a,然后獲取變量a的地址并賦值給指針c,此后對變量a進行累加和,然后輸出a的值,此時a的值為4960。
假設此后你發現變量a的值竟然變為了1,然而由于代碼非常復雜你并不知道到底是哪段代碼對變量a進行修改,在上述代碼中我們利用線程a來模擬這個場景,線程獲取變量a的地址后對其進行了修改,將其變為了1,接下來我們利用調試工具gdb來定位到底是誰修改了變量a。
開始捕捉“肇事者”
對上述代碼進行編譯,接下來利用gdb進行調試,假設源文件的名稱是a.cc,編譯后的可執行程序名字為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;
上述調試命令(b a.cc:20)表示我們在代碼的第20行加斷點,當程序運行到這里后暫停,調試命令r表示開始運行程序,可以看到運行到第20行后暫停,此時我們查看一下變量a的地址:
(gdb) p &a
$1 = (int *) 0x7fffffffe508
可以看到,變量a位于內存地址0x7fffffffe508,接下來重點來了,我們該怎樣告訴gdb讓它幫我們時刻監測0x7fffffffe508這個內存地址中的值有沒有被修改呢?很簡單:
(gdb) watch *(int*)0x7fffffffe508
Hardware watchpoint 2: *(int*)0x7fffffffe508
我們利用watch命令,讓gdb幫我們時刻監測一段從0x7fffffffe508開始大小為4字節的內存區域(假設int占據4字節),這就是watch *(int*)0x7fffffffe508這行指令的含義:
圖片
除此之外上面gdb的輸出中還有一段值得注意:
Hardware watchpoint 2: *(int*)0x7fffffffe508
注意看,什么是Hardware watchpoint呢?先賣個關子,我們稍后聊,接下來我們運行gdb中的c命令,意思是continue,讓程序繼續運行:
(gdb) c
Continuing.
4960
此時第20行執行完畢并打印出了變量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這塊內存,而且詳細的告訴我們所有信息,可以看到gdb打印出了這塊內存之前保存的數據是數字4960,修改后的值為1,并且是在a.cc:8這里被修改的,而這里正是我們創建的線程對變量a進行修改的地方,gdb成功的捕捉到了”肇事者“,原來是這個線程”無意“修改了變量a的值。
圖片
是不是很神奇,那么這一切都是怎樣實現的呢?
watchpoint是怎樣實現的?
原來這一切都是CPU的功勞。
現代處理器中具有特殊的debug寄存器,x86處理器中是DR0到DR7寄存器,利用這些寄存器硬件可以持續檢測處理器發出的用于讀寫內存的地址,更強大的是,不但硬件watchpoint可以檢查內存地址,而且還是可以監測到底是在讀內存還是在寫內存。
利用gdb中的rwatch命令你可以來監測是否有代碼讀取了某段內存;利用gdb中的awatch命令你可以來檢查是否有代碼修改了某段內存;利用gdb中的watch命令你可以檢查對某段內存是否有讀或者寫這兩種情況。
一旦硬件監測到相應事件,就會暫停程序的運行并把控制權交給debugger,也就是這里的gdb,此時我們就可以對程序的狀態進行詳細的查看了,這種硬件本身支持的調試能力就是剛才提到的Hardware watchpoint。
有hardware watchpoint就會有software watchpoint,當硬件不支持hardware watchpoint時gdb會自動切換到software watchpoint,此時你的程序每被執行一條機器指令gdb就會查看相應的事件是否發生,因此software watchpoint要遠比hardware watchpoint慢,你可以利用gdb中的”set can-use-hw-watchpoints“命令來控制gdb該使用哪類watchpoint。
值得注意的是,在多線程程序中software watchpoint作用有限,因為如果被檢測的一段內存被其它線程修改(就像本文中的示例)那么gdb可能捕捉不到該事件。