T1. Valgrind and Helgrind

T1. Tools for Analyzing


本节课,我们将会学习两个工具—— Valgrind 和 Helgrind 。这两个工具是用来分析 C/C++ 程序中的漏洞,分析之后,你可以更好的修正你漏洞百出的程序。

T1.0

这里我们只简单的引入这么一个工具。无论是 Valgrind 还是 Helgrind,其作用都是找到程序中存在或潜在的一些资源问题。Valgrind 用于检查程序中存在的内存错误的问题,Helgrind 用于检查多线程程序中存在或可能存在的竞争问题和同步错误。

T1.0.1 Building Mode

这里引入一些代码构建的知识。我们有两种构建代码的模式——debug mode 和 release mode。在 debug mode 中,为了,我们需要尽可能地保留如变量名、函数名、源代码行号的调试符号信息。因而,我们要尽可能地避免编译器的优化(-O0),让编译器直接告诉我们潜在的问题(-Wall, -Wextra),并在编译时添加 -g 参数保留调试信息。

而在 release mode 下,我们的任务就变为让程序在机器上运行的时间尽可能地快。我们希望大多数的计算任务都在编译时期完成。所以往往在外面开启 -O3 编译器优化后,你会发现许多函数都会被内联掉(没有了栈帧的创建和销毁,程序当然会变快)。所以 release mode 下,我们会添加编译器优化选项,并去掉 -g 参数。

T1.0.2 Our Choice

由于 Valgrind 和 Helgrind 都是检查我们程序中潜在的问题,因而我们选择 debug mode,保留调试信息,以便出现问题后第一时间找到出现问题的地方(哪个文件,哪一行代码)。

T1.1 Valgrind: The Gateway to Valhalla

在北欧神话中,死后灵魂可能会前往不同的领域 (realm) 。分别是由爱与战争女神 Freyja 掌管的 Fólkvangr 、由冥界女神 Hel 掌管的 Hel 、由海洋女神 Rán 掌管的海洋领域、Burial Mound 和由主神 Odin 掌管的 Valhöll (Valhalla)

通往 Valhalla 的门被称为 Valgrind。据传说,Valgrind 是一个巨大的大门,通往战死英灵 (einherjar) 最终安息的地方——Valhalla。女武神 (valkyries) 会带领这些英勇牺牲的战士通过这扇大门,进入英灵殿 Valhalla 享受盛宴和荣耀,为最终的末日决战——诸神黄昏(Ragnarök)做准备。

valhalla.jpg

Valgrind,通往英灵殿的大门,象征着筛选和守护。只有英勇牺牲的战士才能跨过此门,进入奥丁的殿堂,为世界末日筛选战士。

而 Valgrind,通往内存安全的大门,也象征着筛选和守护。它筛选出内存错误,引导程序员修复问题,为程序的崩溃排除隐患。(Memcheck模块)

T1.1.1 First Peek Valgrind

在检查内存错误前,我们需要现有一段程序。在 C/C++ 中,常见的内存错误有以下几种:

  1. 申请了内存但没有释放。(内存泄漏)
  2. 申请了一次内存,但是释放了两次。(双重释放)
  3. 使用没有经过初始化的内存。(未定义行为)
  4. 访问内存时超出合法范围。(访问越界)
  5. 向申请的缓冲区中写入超过其容量的数据。(内存溢出)

对每种错误,我们都会写一小段代码查看 Valgrind 可能给我们输出的信息。此前,我们先来了解一下对于那些没有问题的程序, Valgrind 会给我们提示什么。

int main(){
	return 0;
}
du@DVM:~/Valgrind$ gcc -O0 -g  main.c -o proc
du@DVM:~/Valgrind$ valgrind ./proc 
==38947== Memcheck, a memory error detector
==38947== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==38947== Using Valgrind-3.18.1 and LibVEX; rerun with -h for copyright info
==38947== Command: ./proc
==38947== 
==38947== 
==38947== HEAP SUMMARY:
==38947==     in use at exit: 0 bytes in 0 blocks
==38947==   total heap usage: 0 allocs, 0 frees, 0 bytes allocated
==38947== 
==38947== All heap blocks were freed -- no leaks are possible
==38947== 
==38947== For lists of detected and suppressed errors, rerun with: -s
==38947== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

观察输出,我们的输出好像没有什么有用的信息。我们重新一段内存泄漏的程序。这次,我们同时满足以上五种不同的内存错误。

#include <stdlib.h>
#include <stdio.h>
#include <string.h>

int main() {
    // ================= Memory Leak =================

	int* freed = (int*)malloc(1024 * sizeof(int));
	free(freed);
	int* not_freed = (int*)malloc(1024 * sizeof(int)); // No free
	// Having an array of pointer, Let's say size = 10
    int** ptr_array = (int**)malloc(10 * sizeof(int*));
    for(int i = 0; i < 10; i++){
	    ptr_array[i] = (int*)malloc(1024 * sizeof(int));
    }
    // Only five of them got freed.
    for(int i = 0; i < 5; i++){
	    free(ptr_array[i]);
    }
    // ================== Double Free ==================
    free(freed); // freed has been freed

    // ============ Use Initialized Memory =============
    int* uninit_ptr = (int*)malloc(1024 * sizeof(int));
    printf("Undefined Access: %d\n", *uninit_ptr);
    free(uninit_ptr);

    // ================= Out of Bound ==================
    int* out_of_bound_ptr = (int*)malloc(1024 * sizeof(int));
    out_of_bound_ptr[1024] = 42; // Out of bound 0 - 1023
    free(out_of_bound_ptr);

    // ================ Buffer Overflow ================
    char* buffer = (char*)malloc(5 * sizeof(char));;
    strcpy(buffer, "Hello, Overflow!");
    printf("Overflowed: %s\n", buffer);
	free(buffer);
    return 0;
}

编译一下,我们加入 -Wall 参数查看所有警告:

du@DVM:~/Valgrind$ gcc -O0 -g -Wall  main.c -o proc
main.c: In function ‘main’:
main.c:10:14: warning: unused variable ‘not_freed’ [-Wunused-variable]
   10 |         int* not_freed = (int*)malloc(1000 * sizeof(int)); // No free
      |              ^~~~~~~~~
main.c:35:5: warning: ‘__builtin_memcpy’ writing 17 bytes into a region of size 5 overflows the destination [-Wstringop-overflow=]
   35 |     strcpy(buffer, "Hello, Overflow!");
      |     ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
main.c:34:27: note: destination object of size 5 allocated by ‘malloc’
   34 |     char* buffer = (char*)malloc(5 * sizeof(char));;
      |     

通过这些警告,你实际上可以排除错误五了,编译器提醒你 buffer[5] 的大小是 5 ,但是写进了17个字节的数据。这里我们不处理,看看 Valgrind 会怎么处理。我们先来分析分析 Valgrind 的总结信息。你可以用下面的命令使用 Valgrind:

valgrind ./proc
valgrind -tool=valgrind ./porc
...
==41224== HEAP SUMMARY:
==41224==     in use at exit: 24,656 bytes in 7 blocks
==41224==   total heap usage: 17 allocs, 11 frees, 58,453 bytes allocated
==41224== 
==41224== LEAK SUMMARY:
==41224==    definitely lost: 4,176 bytes in 2 blocks
==41224==    indirectly lost: 20,480 bytes in 5 blocks
==41224==      possibly lost: 0 bytes in 0 blocks
==41224==    still reachable: 0 bytes in 0 blocks
==41224==         suppressed: 0 bytes in 0 blocks
==41224== Rerun with --leak-check=full to see details of leaked memory
==41224== 
==41224== Use --track-origins=yes to see where uninitialised values come from
==41224== For lists of detected and suppressed errors, rerun with: -s
==41224== ERROR SUMMARY: 44 errors from 14 contexts (suppressed: 0 from 0)

Valgrind 给我们说它发现了 44 个内存错误。然后上面还有对堆内存泄漏的总结信息,我们来分析一下。Valgrind 说我们申请了 17 个内存块(一个内存块 4096 字节),其中 11 个已经释放掉了,但还剩下 7 个内存块被泄露了(因为双重释放)。我们先分析一下之前的代码:

    // ================== Memory Leak ==================

	int* freed = (int*)malloc(1024 * sizeof(int));
	free(freed);
	int* not_freed = (int*)malloc(1024 * sizeof(int)); // No free
	// Having an array of pointer, Let's say size = 10
    int** ptr_array = (int**)malloc(10 * sizeof(int*));
    for(int i = 0; i < 10; i++){
	    ptr_array[i] = (int*)malloc(1024 * sizeof(int));
    }
    // Only five of them got freed.
    for(int i = 0; i < 5; i++){
	    free(ptr_array[i]);
    }

这里,not_freed 申请了一块内存(4096)没有释放,在一个指向堆内存的 10 个指针序列中,我们只删除了 5 个块,所以还有 5 个块没有被释放掉。此外,存放指针序列的 10*8 = 80 个字节也没有释放掉,这里额外占了一块内存。 Valgrind 的结论是正确的。

这里你可能注意到有好几种不同的 lost 。最有意思的就是 possibly lost,意思是 Valgrind 也不知道有没有内存泄漏。Still reachable 就是在程序退出时,仍然有指针指向为泄漏的内存。

现在,我们按照顺序注意分析所有有用的内存错误:

第一个内存错误,Valgrind 告诉我们文件的第 21 行有一个无效释放的错误。(双重释放)

==41224== Invalid free() / delete / delete[] / realloc()
==41224==    at 0x484B27F: free (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
==41224==    by 0x10923E: main (main.c:21)

第二个内存错误,Valgrind 告诉我们在代码的第 25 行打印了一个未初始化的变量。(未定义行为)

==41224== Conditional jump or move depends on uninitialised value(s)
==41224==    at 0x48E0AD6: __vfprintf_internal (vfprintf-internal.c:1516)
==41224==    by 0x48CA79E: printf (printf.c:33)
==41224==    by 0x109268: main (main.c:25)

第三个错误,Valgrind 告诉我们在代码的第 30 行进行了一个大小 4 字节的数据的写入。这里即是数组的访问越界。(访问越界)

==41224== Invalid write of size 4
==41224==    at 0x10928D: main (main.c:30)
==41224==  Address 0x4aa4850 is 0 bytes after a block of size 4,096 alloc'd

第四个错误和第三个错误差不多,Valgrind 告诉我们在 35 行有一个无效的 8 字节写入。5 字节的 Buffer 我们写入了 16+1 = 17 字节,溢出 12 字节。这里是每 8 字节进行写入的。(堆内存溢出)

==41224== Invalid write of size 8
==41224==    at 0x1092C5: main (main.c:35)
==41224==  Address 0x4aa4890 is 0 bytes inside a block of size 5 alloc'd

第五个错误,第二个 8 字节写入:

==41224== Invalid write of size 8
==41224==    at 0x1092C8: main (main.c:35)
==41224==  Address 0x4aa4898 is 3 bytes after a block of size 5 alloc'd

第六个错误,最后 17-16 = 1 字节的内存写入:

==41224== Invalid write of size 1
==41224==    at 0x1092CC: main (main.c:35)
==41224==  Address 0x4aa48a0 is 11 bytes after a block of size 5 alloc'd

第七个错误,由于我们对越界的数组进行了读取,Valgrind 告诉我们堆内存读取越界了。

==41224== Invalid read of size 1
==41224==    at 0x484ED24: strlen (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
==41224==    by 0x48E0D30: __vfprintf_internal (vfprintf-internal.c:1517)

之后就是内存泄漏的错误。我们最开始就解释了。

T1.1.2 More Info

valgrind --leak-check=full ./proc
valgrind --leak-check=full --show-leak-kinds=all ./proc

T1.2 Helgrind: The Gateway to Hell

人脑是单线程顺序执行的,天然喜欢顺序执行有条理的东西。然而,为了提高系统的响应速度,在单核心处理器上我们引入了线程并发,使得多个任务可以交替执行。为了进一步提升性能,我们进入了多核心 CPU 的并行时代,线程之间既有并发,又可以并行运行。这种复杂性给程序设计带来了更高的挑战。

为了提高程序的运行速度,我们会把单线程的程序被分成多线程的程序,如此,就很有可能引入一些并发错误,比如 Heisenbug、死锁等。而快但是不正确的程序并不比慢但是正确的程序来得好。我们希望通过并行并发让我们的程序又快又好。这时,我们就引入另一个工具:Helgrind。(但其实 Helgrind 并不能告诉你程序是否正确,但是能检测一些常见的并发错误)

Valgrind 默认情况下是一个用于检测内存错误的工具,而其中的 Helgrind 模块专门用于检测多线程程序中的并发问题。它能够分析使用 POSIX pthread 库时可能出现的竞争条件、死锁等问题,是开发高性能且正确的多线程程序的重要工具。

T1.2.1 Basic Categories of Concurrency Errors

pthread API 的错误使用——释放已经释放的锁等。
申请锁的顺序问题——可能导致死锁等。
数据竞争问题——数据的不一致性等。

T1.2.2 Helgrind Tool

valgrind --tool=helgrind ./proc

thread announcement 不是按顺序的,thread #1 也就是主线程可能在后面才announce

T1.3 Callgrind: The Gate to Function Call?!

Callgrind 是另一个 Valgrind 套件中的子工具。它用于性能分析。帮助开发者了解程序中的性能瓶颈,它可以生成一个调用图来方便分析哪些函数耗费了最多的资源。

valgrind --tool=callgrind ./proc