内存竟被”无意“破坏,真相究竟如何?

在GDB中你可以通过添加watchpoint来观察一段内存,这段内存被修改时程序将会停止,此时我们就能知道到底是哪行代码对该内存进行了修改,这功能是不是很强大。

大家好,我是小风哥。

内存是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可能捕捉不到该事件。

好啦,这个话题就到这里,希望对大家理解内存、程序调试有所帮助。