tags:
- OS
T1. Valgrind and Helgrind
本节课,我们将会学习两个工具—— Valgrind 和 Helgrind 。这两个工具是用来分析 C/C++ 程序中的漏洞,分析之后,你可以更好的修正你漏洞百出的程序。
这里我们只简单的引入这么一个工具。无论是 Valgrind 还是 Helgrind,其作用都是找到程序中存在或潜在的一些资源问题。Valgrind 用于检查程序中存在的内存错误的问题,Helgrind 用于检查多线程程序中存在或可能存在的竞争问题和同步错误。
这里引入一些代码构建的知识。我们有两种构建代码的模式——debug mode 和 release mode。在 debug mode 中,为了,我们需要尽可能地保留如变量名、函数名、源代码行号的调试符号信息。因而,我们要尽可能地避免编译器的优化(-O0
),让编译器直接告诉我们潜在的问题(-Wall
, -Wextra
),并在编译时添加 -g
参数保留调试信息。
而在 release mode 下,我们的任务就变为让程序在机器上运行的时间尽可能地快。我们希望大多数的计算任务都在编译时期完成。所以往往在外面开启 -O3
编译器优化后,你会发现许多函数都会被内联掉(没有了栈帧的创建和销毁,程序当然会变快)。所以 release mode 下,我们会添加编译器优化选项,并去掉 -g
参数。
由于 Valgrind 和 Helgrind 都是检查我们程序中潜在的问题,因而我们选择 debug mode,保留调试信息,以便出现问题后第一时间找到出现问题的地方(哪个文件,哪一行代码)。
在北欧神话中,死后灵魂可能会前往不同的领域 (realm) 。分别是由爱与战争女神 Freyja 掌管的 Fólkvangr 、由冥界女神 Hel 掌管的 Hel 、由海洋女神 Rán 掌管的海洋领域、Burial Mound 和由主神 Odin 掌管的 Valhöll (Valhalla) 。
通往 Valhalla 的门被称为 Valgrind。据传说,Valgrind 是一个巨大的大门,通往战死英灵 (einherjar) 最终安息的地方——Valhalla。女武神 (valkyries) 会带领这些英勇牺牲的战士通过这扇大门,进入英灵殿 Valhalla 享受盛宴和荣耀,为最终的末日决战——诸神黄昏(Ragnarök)做准备。
Valgrind,通往英灵殿的大门,象征着筛选和守护。只有英勇牺牲的战士才能跨过此门,进入奥丁的殿堂,为世界末日筛选战士。
而 Valgrind,通往内存安全的大门,也象征着筛选和守护。它筛选出内存错误,引导程序员修复问题,为程序的崩溃排除隐患。(Memcheck模块)
在检查内存错误前,我们需要现有一段程序。在 C/C++ 中,常见的内存错误有以下几种:
对每种错误,我们都会写一小段代码查看 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)
之后就是内存泄漏的错误。我们最开始就解释了。
valgrind --leak-check=full ./proc
valgrind --leak-check=full --show-leak-kinds=all ./proc
人脑是单线程顺序执行的,天然喜欢顺序执行有条理的东西。然而,为了提高系统的响应速度,在单核心处理器上我们引入了线程并发,使得多个任务可以交替执行。为了进一步提升性能,我们进入了多核心 CPU 的并行时代,线程之间既有并发,又可以并行运行。这种复杂性给程序设计带来了更高的挑战。
为了提高程序的运行速度,我们会把单线程的程序被分成多线程的程序,如此,就很有可能引入一些并发错误,比如 Heisenbug、死锁等。而快但是不正确的程序并不比慢但是正确的程序来得好。我们希望通过并行并发让我们的程序又快又好。这时,我们就引入另一个工具:Helgrind。(但其实 Helgrind 并不能告诉你程序是否正确,但是能检测一些常见的并发错误)
Valgrind 默认情况下是一个用于检测内存错误的工具,而其中的 Helgrind 模块专门用于检测多线程程序中的并发问题。它能够分析使用 POSIX pthread 库时可能出现的竞争条件、死锁等问题,是开发高性能且正确的多线程程序的重要工具。
pthread API 的错误使用——释放已经释放的锁等。
申请锁的顺序问题——可能导致死锁等。
数据竞争问题——数据的不一致性等。
valgrind --tool=helgrind ./proc
thread announcement 不是按顺序的,thread #1 也就是主线程可能在后面才announce
Callgrind 是另一个 Valgrind 套件中的子工具。它用于性能分析。帮助开发者了解程序中的性能瓶颈,它可以生成一个调用图来方便分析哪些函数耗费了最多的资源。
valgrind --tool=callgrind ./proc