读书笔记:高效C/C++调试

高效C/C++调试 Effective Debugging (美)严琦、卢宪廷

目 录

第1章 调试符号和调试器 1

1.1 调试符号 1

1.1.1 调试符号概览 2

  • 全局变量
  • 文件行号
  • 数据类型
    1.1.2 DWARF格式 3
    1.2 实战故事1:数据类型的不一致 14
    1.3 调试器的内部结构 16
    1.3.1 用户界面 16
    1.3.2 符号管理模块 16
    1.3.3 目标管理模块 17

1.4 技巧和注意事项 21

1.4.1 特殊的调试符号 21

库的符号可能被部分剥离,重新编译进符号表

1.4.2 改变执行及其副作用 24

set var gFlags = 5;

1.4.3 符号匹配的自动化 25
1.4.4 后期分析 26
1.4.5 内存保护 27

1.4.6 断点不工作 27

断点不工作场景:
(1)调试符号不匹配
(2)共享库未加载
(3)启用了优化
1.5 本章小结 28

第2章 堆数据结构 29

2.1 理解内存管理器 30

2.1.1 ptmalloc 31

边界标签和盒子

2.1.2 TCMalloc 34

google开发
2.1.3 多个堆 38
2.2 利用堆元数据 39
2.3 本章小结 42

第3章 内存损坏 43

3.1 内存是怎么损坏的 44

3.1.1 内存溢出与下溢 44

用户代码写入超过分盘内存块,会覆写下一个内存块的标签

3.1.2 访问释放的内存 45

用户代码持有指向已释放指针

3.1.3 使用未初始化的值 46

info symbol 0x002ab

3.2 调试内存损坏 47
3.2.1 初始调查 49
3.2.2 内存调试工具 53
3.2.3 堆与栈内存损坏对比 53
3.2.4 工具箱 54
3.3 实战故事2:神秘的字节序转换 55
3.3.1 症状 55
3.3.2 分析和调试 56
3.3.3 错误和有价值的点 64
3.4 实战故事3:覆写栈变量 65
3.4.1 症状 65
3.4.2 分析和调试 65
3.5 本章小结 68

第4章 C++对象布局 69

第2章讨论了内存管理器如何进行内存管理。当内存管理器分配一块内存后, 其所有权便转移到了请求该内存的应用程序代码上。内存管理器会将这块内存标记为正在使用,直至应用程序释放它为止,期间不会对它进行任何操作。在内存使用过程中,内存管理器并不知道也不关心应用程序如何使用它,只要应用程序不越过内存块用户空间的边界即可。
本章将讨论应用程序或编译器如何使用分配好的内存,即如何布局数据结构以及对象如何被创建、更新和销毁。那么,这与调试有何关系呢?一个内存块的内容可以反映存储在该内存块中的对象的逻辑状态。因此,理解内存块中的每个比特和字节以及它们与对象之间的关联是有益的。当一个对象处于损坏或不一致的状态时, 这里的知识可以帮助工程师我出可能的原因。
木章首先介绍对齐和大小端(Aligment and Endian)的概念,然后详细解析C++对象的布局方式。

4.1 对齐和大小端 69

对齐和大小端都是与计算机内部如何表示和存储数据相关的概念,但它们涉及的方面是不同的。

4.1.1 对齐 69

字节:Byte,字:word

各种处理器架构支持类似的原始数据类型,例如字节(Byte)、半字(HalfWord)、字(Word)和双字(Double Word)等。不同的指令被设计用于处理特定的数据类型,例如加教1字节的指令与加载一个字的指令是不同的。
一些架构 (如SPARC)要求内存索引的地址正确对齐。例如,一个字(在C++中是整数)必须是4字节对齐的,这意味着对应的地址必须能被4整除。如果地址没有按照要求对齐,用访问字的指令访问该地址将会抛出一一个硬件异常。这通常会导致应用程序产生一个总线错误信号。 而其他一些架构(如x86系列)没有如此严格的要求,但是我们通常是对齐的,否则在某些情况下会有性能损失。因此,所有编译器默认会将数据放在合适的对齐位置,即使在那些不强制要求对齐的架构中。
C/C++数据类型,如字符、短整数、整数、 长整数、浮点数双精度浮点数等,在目标架构中都有相应的数据类型。因此,编译器会相应地对齐这些数据类型。对于复合数据类型(如结构体和数组),编译器必须确保所有数据字段在任何嵌套层次上都是对齐的。
结构体的对齐要求是所有单个字段中的最大要求。数组的对齐要求与数组中每个元素的要求相同。如果复合类型具有多个层级,那么这些规则适用于所有层级。例如,以下这个C语言的结构体:

struct aggr_type{
char c;//1字节
int i;//4字节
short s;//2字节
double d;//8字节
};

在所有的字段中,字段d的对齐要求最大,为8字节。因此,这个结构体aggr_type需要按8字节对齐。它同时也需要一些填充,从而确保每个字段满足对齐要求。
图4-1描绘了以上结构体对应的填充(灰色的方框)。字段c一共有3字节的填充,字段s有6字节的填充。这些填充使得紧接的字段i和字段d相应地对齐在需要的4字节和8字节上。

0     1       2       3       4       5       6       7       8
1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| char|         padding       |            int                |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|     short   |                  padding                      |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                        double                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

有些面试题喜欢询问如何排列相应的字段以便更省空间。当编译器为栈分配变量时,会确保每个变量,无论是原始类型还是复合类型,都满足其对齐要求。此外,ABI还会指出整个栈帧需要满足某个最小对齐值,以确保栈上的局部变量和系统数据都能正确对齐。因此,栈变量之间存在许多对齐填充。
堆中分配的数据对象也需要满足相同的要求。然而内存管理器只知道请求的内存块大小,并不了解背后的数据对象的数据类型,也不知道它的对齐要求。为了正常工作,内存管理器会确保返回的内存块满足目标架构的最大可能对齐要求,尽管这意味着些空间浪费, 因为实际的数据对象的对齐可能并不需要这么严格。举个例子:大多数CPU架构最大的对齐要求是16字节,如果应用程序申请12字节的内存,那么内存管理器返回的内存块的地址需对齐在16字节上,也就是说必须能被16整除。如果内在管理器的可分配内存的起始地址不在16字节边界上,那么它需要浪费几字节,从下一个16的整数倍的地址处开始分配。

4.1.2 大小端 70

具体的处理器架构还需要确定内存中数据的字节顺序,即采用大端还是小端方案。在小端序(Little Endian)中,一个多字节数据的最低位字节 (最不重要的字节)放在最低地址处,而最高位字节(最重要的字节)放在最高地址处。在大端序(Big Endian)中,一个多字节数据的最高位字节(最重要的字节)存储在最低的内存地址处,而最低位字节(最不重要的字节)存储在最高的内存地址处。
x86处理器采用的是小端字节序,而PowerPC和SPARC采用大端字节序。有趣的是,Itanium芯片可以通过一个开关设置为大端或小端字节序。

举个例子,让我们看看下面的变量:

unsignened 1ong var = 0x0123456789abcdef;

在小端架构中(x86_64),调试器显示的内存布加局如下,

(gdb) x/8x &var
0x7fbfff4a8: 0xef 0xcd 0xab 0x89 0x67 0x45 0x23 0x01 

最低字节0xef被放在了低地址0x7fbff4a8,同时最高字节0x01被放在了高地址0x7fbff4af。同样的变量在大端的机器上(UtraSPARC)显示相反的内存布局:

0xffffffffffa50: 0x01 0x23 0x45 0x67 0x89 0xab 0xcd 0xef

笔者在一开始接触大端小端的时候,总是忘记它们具体的布局方式, 这里说一说电者的记忆方法:
小端对应的英文是Litte-Endian,也就是小地址存放尾部的数字(即低位的数字)。同理大端对应的英文是Big-Endian,即大地址存放尾部的数字(即低位的数字)。
另外值得指出的一点是,在调试器里面,一般的输出都是从低地址到高地址.
了解目标处理器架构的字节顺序非常重要,因为在涉及跨平台数据传输或在不同架构之间共享数据时,字节顺序的差异可能导致问题。在这些情况下,通常需要进行字节序转换以确保数据的正确性。

4.2 C++对象布局 71
4.3 实战故事4:访问已经释放的数据 94
4.3.1 症状 94
4.3.2 分析和调试 94
4.4 搜索引用树 95
4.5 本章小结 101

第5章 优化后的二进制 102

5.1 调试版和发行版的区别 102
5.2 调试优化代码的挑战 106
5.3 汇编代码介绍 108
5.3.1 寄存器 109
5.3.2 指令集 111
5.3.3 程序汇编的结构 113
5.3.4 函数调用习惯 116
5.4 分析优化后的代码 127
5.5 调试优化后的代码示例 130
5.6 本章小结 141

第6章 进程镜像 142

进程映射:这个视图提供了从内核角度观察进程地址空间的视角。

# cat /proc/<pid>/maps
Address                   perm  offset   device inode   pathname
55bc80a11000-55bc80a32000 r--p  00000000 fd:00  1054763 /usr/sbin/nginx

说明:

  • Address: 显示内存区域的地址范围。在linux内核中,这也被称为虚拟内存区域(VMA)
  • perm: 展示权限位。其中,rwx分别代表读、写和执行权限。“-”表示该权限被禁止。这一列的最后一个字符要么是s要么是p,分别代表该内存区域是共享的还是私有的。
  • offset: 表示该内存区域关联的磁盘文件的偏移量。
  • device: 展示以marjor:minor格式表示的设备号。
  • inode: 给出设备的inode号。
  • pathname: 显示相关文件的路径。
    6.1 二进制文件格式 144
    6.2 运行期加载和链接 148

6.3 进程映射表 153

6.3.1 可执行文件 154
6.3.2 共享库 156
6.3.3 线程栈 157
6.3.4 无名区域 157
6.3.5 拦截 158
6.3.6 链接时替换 158
6.3.7 预先加载代理函数 159
6.3.8 修改导入和导出表 159
6.3.9 对目标函数进行手术改变 164
6.3.10 核心转储文件格式 166
6.3.11 核心转储文件分析工具 169
6.4 本章小结 170

第7章 调试多线程程序 171

7.1 竞争条件 171
7.2 它是竞争条件吗 172
7.3 调试竞争条件 174
7.4 实战故事5:记录重要区域 175
7.4.1 症状 175
7.4.2 分析调试 175
7.5 死锁 177
7.6 本章小结 179

第8章 更多调试方法 180

8.1 重现错误 180

8.1.1 归因 181
8.1.2 收集环境信息 182
8.1.3 重建环境 184

8.2 防止未来的bug 184

8.2.1 知识保留和传递 185
8.2.2 增强提前检查 185
8.2.3 编写更好调试的代码 185

8.3 不要忘记这些调试规则 189

8.3.1 分治法 189
8.3.2 退一步,获取新的观点 189
8.3.3 保留调试历史 190

8.4 逆向调试 190

reverse-step: 反向逐步执行

8.4.1 rr:Record and Replay 191

8.4.2 rr注意事项 191
8.5 本章小结 192

第9章 拓展调试器能力 193

9.1 使用Python拓展GDB 193
9.1.1 美化输出 194
9.1.2 编写自己的美观打印器 195
9.1.3 将重复的工作变成一个命令 197
9.1.4 更快地调试bug 198
9.1.5 使用Python设置断点 200
9.1.6 通过命令行来启动程序和设置断点 203
9.2 GDB自定义命令 203
9.3 本章小结 206

第10章 内存调试工具 207

内存调试工具底层算法分为3种类型:
(1)填充字节方法:最常用的是在每个内存块的开头和末尾添加额外的填充字节。有缺陷的代码可能会越过分配的内存块的界限,修改这些填充字节。调试工具在内存API的入口malloc, free检查这些 填充字节。如果发现填充字节被修改,就表示内存损坏。工具报告错误的上下文。
(2)系统保护页方法:工具在可能越界的内存块前后设置一个不可访问的系统保护页。程序非法访问时,系统通过硬件检测到。这种方法可以立即捕获无效的内存访问。但是频繁的设置系统保护页会造成内存和CPU开销大。
(3)动态二进制分析:valgrind可以运行任何现有程序而无须重新编译。在内部使用影子内存跟踪程序内存使用情况,每次内存访问都会更新影子内存。缺点:细粒度和软件模式检查性能下降。google address sanitizer通过编译器在生成的二进制文件中插入诊断代码,不需要二进制检测框架。
对于复杂问题,常见的方式是根据收集到的信息尝试在受控环境中重现问题。然而,如果问题的重现具有很强的时序相关性。或者每次运行时内存块的地址可能会改变。这时我们可以通过各种工具尽早地检测到内存。
有时错误可能因为分配算法改变而掩盖。但并不意味着修复。

不同内存调试工具的对比

调试特性 ptmalloc MALLOC_CHECK _ Asan AccuTrak Valgrind/Memcheck
实现原理 软件填充 软件影子内存 硬件和软件填充 软件影子内存
检测上溢出 Yes Yes Yes Yes
检测下溢出 No Yes Yes Yes
检测重复释放 Yes Yes Yes Yes
检测释放后使用 No Yes Yes Yes
检测使用未初始化内存 No No No Yes
粒度 字节 字节 字节
变慢程度
空间开销(每个用户块) 1-16字节 1字节 8字节 or 1系统页 与块大小相同
配置 No No Yes No
代码开源 Yes Yes Yes Yes
重新编译 No Yes No No

10.1 ptmalloc’s MALLOC_CHECK_ 208
10.2 Google Address Sanitizer 212
10.3 AccuTrak 213
10.4 有效地调试内存损坏 225
10.5 实战故事6:内存管理器的崩溃问题 228
10.5.1 症状 229
10.5.2 分析和调试 229
10.6 本章小结 235

第11章 Core Analyzer 236

解析进程的核心转储文件或内存映像
11.1 使用示例 237
11.2 主要功能 239
11.2.1 搜索引用的对象(水平搜索) 239
11.2.2 查询地址及其底层对象(垂直搜索) 240
11.2.3 内存模式分析 241
11.2.4 查询堆内存块 242
11.2.5 堆遍历(检查整个堆以发现损坏并获取内存使用统计) 242
11.3 本章小结 246

第12章 更多调试工具 247

12.1 strace 247

作用:程序和操作系统如何交流
12.1.1 常用功能 247
12.1.2 常用附加选项 248

12.2 实战故事7:僵尸进程 248

strace -o debug.txt -f -e trace=signal <program>

12.2.1 遇到难题 248
12.2.2 揭示bug的真相 249

12.3 Perf 249

作用:分析系统性能瓶颈
收集性能数据
(1)CPU周期
(2)指令计数
(3)缓存未命中
(4)分支预测错误
(5)内存访问

12.4 eBPF 250

作用:高度定制和细致分析
12.4.1 准备环境 251
12.4.2 编写代码 251
12.4.3 编译程序 252
12.4.4 加载和运行程序 254
12.5 实战故事8:链接问题 255
12.5.1 切入 255
12.5.2 更奇怪的事情 258
12.5.3 柳暗花明 259
12.5.4 补充 260
12.5.5 结论 261
12.6 实战故事9:临时变量的生命周期 261
12.7 本章小结 264

第13章 崩溃发送机制 265

崩溃报告
13.1 客户端 266
13.2 远程报告收集服务器 267
13.3 终端集成器 268
13.4 本章小结 268

第14章 内存泄漏 269

g++ -fsanitize=address

14.1 为什么RAII是基石 269
14.2 分析 270
14.3 调试内存泄漏 273
14.4 本章小结 275
第15章 协程 276
15.1 C++协程 277
15.2 协程的切分点 279
15.3 协程之诺 281
15.4 本章小结 283
第16章 远程调试 284
16.1 GDB远程调试 285
16.2 Visual Studio远程调试 286
16.3 本章小结 287
第17章 容器世界 288
17.1 容器示例 288
17.2 容器应用 289
17.3 C/C++容器调试 291
17.4 实战故事10:CrashLoopBackOff 292
17.5 实战故事11:liveness failure 292
17.6 本章小结 294

第18章 尽量不要调试程序 295

18.1 借助编译器来提前发现错误 295
18.2 编写简短的实验代码 295

18.3 日志和监控 296

18.3.1 日志 296

要素:

  • 日志级别
  • 时间戳
  • Json格式化,方便日志解析

18.3.2 监控 297

  • metrics
  • alerts
  • dashboards
    18.4 遵循最佳编码实践 297
    18.5 本章小结 298
    附录A 调试混合语言 299
    附录B 在Windows/x86环境下进行程序调试 301
    B.1 PE文件格式 301
    B.2 Windows Minidump格式 306
    附录C 一个简单的C++ coroutine程序 309

资料:
core analyzer https://cloud.tencent.com/developer/article/2408828
作者博客:高效C/C++调试 - CrackingOysters的文章 - 知乎
https://zhuanlan.zhihu.com/p/675726977

posted @ 2024-09-03 09:22  liqinglucky  阅读(126)  评论(0编辑  收藏  举报