调试作为一种日常中常见的工作,提高调试水平是非常有必要的。
调试的过程是收集足够多的信息来判断出错误信息。
本文介绍调试过程中所需要的的内存相关知识、底层知识、调试技巧,通过这些知识加强信息收集,减少无用的调试工作,使调试更有目的性。
程序挂掉大多表现为内存异常,通过分析core文件中的内存信息来推断程序异常原因是一种常见的方法,而在分析的过程中需要确保收集到的信息是正确的,因此glibc内存管理原理与程序中内存使用方法是调试的基础知识。
代码段、数据段、bss段,由内核在启动程序时分配。
代码段、数据段,BSS段处于较低位置,在地址上能明显区分出来。
栈是随线程的创建而产生的,是由内核分配的,不会调用用户态内存分配函数(查内存泄漏时切记)。栈不能无限扩展,栈被创建时就已经分配好了虚拟内存。
heap和mmap区域,就是常说的动态内存了。
1.1、glibc在软件中的地位
应用几乎都是通过glibc或stl向操作系统申请内存,而目前stl中的默认内存管理器最终也是调用glibc的。
用户程序不应该直接调用系统接口来管理heap区和mmap区域。
1.2、glibc它在管理什么?
首先给出最基本也是最重要的概念:内存块。
内存块是glibc中可管理的最小单元,glibc向操作系统申请的内存到glibc这一层被划分成了内存块。
各个内存块大小是不一样的,glibc通过算法不断的对内存块切割、组合。
内存块都是自描述的。
内存块的自描述数据结构
struct malloc_chunk {
INTERNAL_SIZE_T prev_size; / Size of previous chunk (if free). /
INTERNAL_SIZE_T size; / Size in bytes, including overhead. /
struct malloc_chunk fd; / double links -- used only if free. */
struct malloc_chunk* bk;
/ Only used for large blocks: pointer to next larger size. /
struct malloc_chunk fd_nextsize; / double links -- used only if free. */
struct malloc_chunk* bk_nextsize;
};
注:这里暂时只关注前两个域。
内存块开始位置是两个域(32位机器上,一个域是4字节;64位机器上,一个域是8字节),第一个域中记录的是上一个内存块的大小,如果上一个内存块在使用中,那么这个字段是无效的;第二个域记录的是当前内存块的大小。第2个域的低3位有其他用处,分别是表示当前块空闲、当前块是mmap分配、当前块是主分配区。但是,对于A标志位,如果当前内存块比较小时,即使它被释放了,该标志位还是使用中,这和管理机制有关(fastbin机制,一定大小的内存块释放时是由fastbin进行管理,32位机器上是72字节)。由于低3位不用,所以内存块是8字节对齐的。
1.3、glibc中管理的内存块是从哪里来的?
操作系统提供了两种虚拟内存分配接口供用户态调用,brk和mmap。Brk分配到的内存就是我们常说的堆。mmap分配到的内存其实是另一个区域,只是它被glibc的内存分配接口封装起来了。
每次mmap所分配的内存都会有一个vm_area来描述,因此mmap获得的内存释放后再访问就会出现段错误。在glibc中,对于用户请求,超过一定大小的请求是通过mmap分配的(这个值在32位机器上是128K)
brk分配到的区域是连续的,在操作系统看来就是一个大块,它在内核中只由一个vm_area来描述,所以访问通过free释放但glibc并没有调用brk归还给操作系统的brk内存是不会出错的。
在glibc中,对于用户请求,小于一定大小的请求是从brk所获取到的内存切出来的的。
每次调用brk时,只是简单的增大或减小该vm_area。
大多数情况下,glibc一直都是对一个可伸缩的内存块根据应用的请求进行不断分割、合并、再分割、再合并。
1.4、它如何与应用交互,返回给请求者的是什么?
Glibc提供了一系列内存分配接口,(malloc,realloc,等等)事实上,尽量只用简单的接口比如malloc,这样方便检测工具检测。
当用户请求一个特定大小的内存时,glibc总会从内部选取一个内存块,该内存块可能是已存在的,或者是从一个大的内存块中切出来的。该内存块返回给请求者时,总是返回该内存块的起始地址加上malloc_chunk结构体的前两个域的偏移。对于用户来讲,前2个域是不可见的。所以,使用gdb调试时,当获取到一个内存地址,可以通过往前移2个域来确认该内存块的具体信息,常用于确认内存块边界和分配状态,从而做出关键性的判断。
1.5、glibc内存管理异常打印分析
对于内存错误,如果遇到malloc或free错误时往往无从下手,根本不知道哪里出了问题。因此,对该打印进行分析从而排查错误还是很有必要的。该方法的研究正在进行中。
参考:
《glibc内存管理ptmalloc源代码分析》
调试过程中了解C/C++中的对象或内存的布局是非常重要的。
(1)虚函数表
对于拥有虚函数的类的对象,起始位置都有一个虚函数表。
虚函数表在编译期间就已确定,它在代码段上,它是在对象被创建的时候写入到一个地址上的。可以通过info symbol addr来确认这是代码段上的,或者通过查看这个虚表中一系列函数地址来确认这是个什么类。
(2)静态变量
类中的静态变量不占用类定义对象的内存,因此在计算对象大小时不需要考虑进去。
(3)继承
会因为引入基类而导致成员变量的起始位置与想象中的不同。对于单继承,虚表位置不会变。
(4)模板
模板类和模板函数,如果在不同在位置使用相同的模板参数定义,那么编译后,代码中只存有一份该参数的类或函数。
由于代码中使用的STL模板对象较多,因此也经常会在这些对象中出错,调试时需要根据这些结构的内部布局来查找问题。
(1) List
list的节点结构
Prev和Next是两个指针,分别指向前一个节点和后一个节点(头节点和尾节点会有不同)。
(2) map
map的节点结构
JSON
一个Json对象有4个域,
union ValueHolder
{
Int int_;
UInt uint_;
double real_;
bool bool_;
char *string_;
# ifdef JSON_VALUE_USE_INTERNAL_MAP
ValueInternalArray *array_;
ValueInternalMap *map_;
#else
ObjectValues *map_;
# endif
} value_;
ValueType type_ : 8;
int allocated_ : 1; // Notes: if declared as bool, bitfield is useless.
#ifdef JSON_VALUE_USE_INTERNAL_MAP
unsigned int itemIsUsed_ : 1; // used by the ValueInternalMap container.
int memberNameIsStatic_ : 1; // used by the ValueInternalMap container.
#endif
CommentInfo *comments_;
value_是一个联合体,当该级Json是arrayValue或objectValue时,map_才是有效的。
通过gdb可以查看一个Json的结构
$1={
static null={
static null=,
static minInt=-2147483648,
static maxInt=2147483647,
static maxUInt=4294967295,
value_={
int_=0,
uint_=0,
real_=0,
bool_=false,
string_=0x0,
map_=0x0
},
type_=Json::nullValue,
allocated_=0,
comments_=0x0
},
static minInt=-2147483648,
static maxInt=2147483647,
static maxUInt=4294967295,
value_={
int_=6332528,
uint_=6332528,
real_=3.1286845361277773e-317,
bool_=112,
string_=0x60a070 "",
map_=0x60a070
},
type_=Json::arrayValue,
allocated_=0,
comments_=0x0
}
可以看到其中有个对象是ObjectValues *map_,这是一个map,它的类型是
typedef std::map<czstring, value=""> ObjectValues;
class CZString
{
public:
enum DuplicationPolicy
{
noDuplication=0,
duplicate,
duplicateOnCopy
};
CZString( int index );
CZString( const char *cstr, DuplicationPolicy allocate );
CZString( const CZString &other );
~CZString();
CZString &operator=( const CZString &other );
bool operator<( const CZString &other ) const;
bool operator==( const CZString &other ) const;
int index() const;
const char *c_str() const;
bool isStaticString() const;
private:
void swap( CZString &other );
const char *cstr_;
int index_;
};
也就是说一个Json可以由很多有层次的节点组成,每个节点都是一个Json,上下层的Json通过以CString为Key的map联系起来。
CZString的大小是8字节或16字节。
Json的大小是12或24字节。
在调试过程中,假如完全站在内存布局的角度看待内存,当发生错误时,根据现场以及内存管理与使用规则来找出是哪里不符合要求,这样才能处理错误。
反汇编基础
在调试过程中,有时候无法精确定位行,或者debug版本代码和实际代码不一致,或者出错位置看不到源代码,或者错误的行为超出理解,感到不可思议,或者打包后出现问题,而产品线人员并没有意识到错误。以上的种种问题都可以通过汇编代码获取到自己想要的内容。汇编代码几乎和可执行文件一一对应,它代表了最真实的执行流程,无论选择了合适的方法还是错误的方法生成它。
以下内容针对NVR常用平台(arm-none,hisiv200,armv7,X86,X64),以及这些平台上的常见编译方式。
栈与寄存器
(1) X86
l 上个栈帧的EBP,顶层栈这个值为0
l 可能包括栈破坏检查需要填充的信息。本次调用会被修改的寄存器
l 当前函数的所有局部变量
l 所有被调函数的最大形参大小,与局部变量一起一次性分配的。
l 返回地址,调用函数时会自动压栈
EIP(Instruction Pointer)是指令寄存器,当停止在某条指令上时,EIP就指向那条指令。
EBP(Base Pointer)是栈帧基址寄存器,存放执行函数对应栈帧的栈底地址。
ESP(Stack Pointer)是栈寄存器,存放执行函数对应栈帧的栈顶地址,且始终指向栈顶。
(2) X64
l 上个栈帧的RBP,顶层栈这个值为0;
l 存储栈破坏参数需要填充的信息,本次调用可能会被修改的寄存器
l 当前函数的所有局部变量
l X64的传参方式和X86不一样,当参数少于7个时,参数从左到右放入寄存器: rdi, rsi, rdx, rcx, r8, r9。当参数为7个以上时,前6个与前面一样,但后面的依次从 "右向左" 入栈。
l 返回地址,调用函数时会自动压栈
x86_64架构用字母“r”作名称前缀,指示各寄存器大小为64位。
指令寄存器(RIP), 栈帧基址寄存器(RBP),栈寄存器(RSP)
(3) ARM
l 返回地址与本次函数中会被修改的寄存器,函数开头的指令会把这些成教信息入栈,地址最高处是返回地址。(少数情况下不是)
l 当前函数需要的所有局部变量的空间。当前函数的所有局部变量是一次性分配。
l ARM的传参是前5个参数通过寄存器传参,多余的参数通过栈传递。
注:公司的ARM平台的运行方式是没有用到栈帧基址寄存器的。
引用:
《C语言函数调用栈》
顺序、跳转、循环,在高级语言中形成了所有逻辑的基本组成方式。
结构化的高级语言,经过编译器编译链接后,生成的低级语言的排列顺序也是结构化的。理解汇编时,需要认清楚其中的结构。
以下流程图中的流程块之间的位置关系,就是它们在代码段上的位置关系。
循环语句最明显的特点是往前跳转,实际上,这些循环都可以认为是do..while的变形,for和while都是先跳到条件判断处执行。
引用:
《Linux基本反汇编结构与GDB入门》
在C++中,引用是相对安全的指针。
第一次传参
(gdb) disassemble
Dump of assembler code for function func1():
0x08048481 <+0>: push %ebp
0x08048482 <+1>: mov %esp,%ebp
0x08048484 <+3>: sub $0x18,%esp
=> 0x08048487 <+6>: movl $0x1,-0x4(%ebp) //a赋值为1
0x0804848e <+13>: lea -0x4(%ebp),%eax //取a的地址,放入到eax
0x08048491 <+16>: mov %eax,0x4(%esp) //参数b入栈
0x08048495 <+20>: mov %eax,(%esp) //参数a的地址入栈,因为优化的缘故,所以这里使用了已经获取到的地址
0x08048498 <+23>: call 0x804845b<func2(int&, int*)="">
0x0804849d <+28>: leave
0x0804849e <+29>: ret
第二次传参
(gdb) disassemble
Dump of assembler code for function func2(int&, int*):
0x0804845b <+0>: push %ebp
0x0804845c <+1>: mov %esp,%ebp
0x0804845e <+3>: sub $0x8,%esp
0x08048461 <+6>: mov 0x8(%ebp),%eax //获取到参数a
0x08048464 <+9>: mov 0xc(%ebp),%edx //获取到参数b
=> 0x08048467 <+12>: movl $0x1,(%eax) //a赋为1
0x0804846d <+18>: movl $0x2,(%edx) //*b赋为2
0x08048473 <+24>: mov %edx,0x4(%esp) //参数b入栈
0x08048477 <+28>: mov %eax,(%esp) //参数a入栈
0x0804847a <+31>: call 0x8048444<func3(int&, int*)="">
0x0804847f <+36>: leave
0x08048480 <+37>: ret
通过以上的验证,了解到引用和指针其实是同样的东西,但是引用可以保证第一次获取到的地址绝对是可用的。
此外,还可以看到,无论是引用还是指针,第二次传参的时候,是从一个地方把这个地址取过来,然后入栈(X86)或传给寄存器(ARM)来作为参数。因此,在某些极端情况下,引用也是不安全的。比如X86上的栈破坏,ARM上参数过多时参数保存在栈上时因栈破坏而被修改。
虚函数与函数指针
这两种方式因为需要执行的函数地址是在运行时赋值的,因此调用时需要一个目标寄存器存储获得的函数地址。
X86和arm的函数动态调用方式,
call *eax (X86)
ldr ip, [寄存器] (ARM)
blx 寄存器 (ARM)
Infra中信号回调函数(类成员函数,普通函数)都是以函数指针的方式调用的。
嵌套结构
嵌套结构包括嵌套的结构体,类的继承。
多个结构体嵌套时,当通过最外层的结构体访问内层的变量时,到该变量的偏移是在编译期间就计算好了,看上去就像是直接的成员变量一样。派生类的对象访问基类的成员变量也是如此。
编译时就定好的函数地址,debug版本会显示该符号的名称,通过它即使程序不运行也能知道调用的是什么。
call 0x8048000 (X86)
callq 0x572b30 (X64)
jmp 0x8048000 (X86)
jmpq 0x572b30 (X64)
bl 0x68f9f8 (ARM)
ldr ip, 0x68f9f8 (ARM)
但是像虚函数调用与函数指针调用,只能在运行时以某种方式获取到目标函数地址,再执行该函数。
call *%eax (X86,X64)
jmp *%eax (X86)
jmpq *%eax (X64)
blx 寄存器 (ARM)
ldr ip, [寄存器] (ARM)
编译时有很多东西是已经计算好的,比如对一个配置根据索引进行获取某个元素,
T array[num],实际应该是num*sizeof(T),那么sizeof(T)就是编译期间计算出来的不变的数值。
此外,编译时因为语法的限定会无法执行某些操作,而运行时是没有这些语法检查机制的。
比如,引用在编译时是不会出错的,但是运行时在某些极端情况下(栈破坏)也是会导致传参错误。
可作标记的指令
了解具有特定功能的指令,结合C/C++代码,可以容易的在茫茫的汇编代码中识别出当前执行的语句。
Ldr ip,[寄存器] 虚函数或函数指针(ARM)
blx 寄存器 虚函数或函数指针(ARM)
Call 寄存器 虚函数或函数指针(X86,X64)
rep一般表示memset,strcpy (X86)
call 地址 普通函数调用(X86,X64)
ldr ip, 地址 普通函数调用(ARM)
bl 地址 普通函数调用(ARM)
从汇编的角度去看待问题,有时候比直接看代码了解问题更有针对性。如果与代码有出入,要相信自己看到汇编是对的,通过判断,找出与代码的出入在哪里。
参考:
《Debug Hacks》
《Binary Hacks》
《深入理解计算机系统》
设置指令地址断点
在指令地址位置设断点,比在代码行数位置设置断点要正确。
遇到过一个问题,
m_indexBuf=m_indexPacket->GetBuffer();
assert(m_indexBuf);
在assert上设置断点,当停止在断点上后,打印m_indexBuf,但是计算出的这个值和实际内存中保存的值怎么也对不上。仔细检查后发现,是因为assert的条件判断操作在前,交易赋值操作在后,导致在断点位置处,还没有赋值给m_indexBuf。
使用输出结果
这里,$1就是a。当使用一整串复杂的命令输出了某个结构时,可以用这种方法打印这个结构中的其他域,而不需要一条更加复杂的命令。此外,使用这种方法还能减少对地址所在的类型进行错误转换。
gdb打印stl容器
需要gdbinit中的pmap命令
gdb打印Json
因为Json就是不同层次的Json+CZString通过map组成,因此也可以通过打印map的方式打印出部分Json。
当该级Json是arrayValue或objectValue时,map_才是可以打印的,其他类型下,map_是无效的。
打印方法和pmap类似,但是需要自己手动逐级打印,前提是需要有gdbinit。
7 void func()
8 {
9 Json::Value x=Json::nullValue;
10 x"Y"="item1";
11 x"Y"="item2";
12
13 printf("%s
", x.toStyledString().c_str());
14 }
打印第一级
(gdb) pmap x.value_.map_ 'Json::Value::CZString' 'Json::Value'
elem[0].left: $1={
cstr_=0x60a120 "Y",
index_=1
}
elem[0].right: $2={
static null={
static null=,
static minInt=-2147483648,
static maxInt=2147483647,
static maxUInt=4294967295,
value_={
int_=0,
uint_=0,
real_=0,
bool_=false,
string_=0x0,
map_=0x0
},
type_=Json::nullValue,
allocated_=0,
comments_=0x0
},
static minInt=-2147483648,
static maxInt=2147483647,
static maxUInt=4294967295,
value_={
int_=6332736,
uint_=6332736,
real_=3.1287873017821123e-317,
bool_=64,
string_=0x60a140 "",
map_=0x60a140
},
type_=Json::arrayValue,
allocated_=0,
comments_=0x0
}
Map size=1
打印第二级
(gdb) pmap $2.value_.map_ 'Json::Value::CZString' 'Json::Value'
elem[0].left: $3={
cstr_=0x0,
index_=0
}
elem[0].right: $4={
static null={
static null=,
static minInt=-2147483648,
static maxInt=2147483647,
static maxUInt=4294967295,
value_={
int_=0,
uint_=0,
real_=0,
bool_=false,
string_=0x0,
map_=0x0
},
type_=Json::nullValue,
allocated_=0,
comments_=0x0
},
static minInt=-2147483648,
static maxInt=2147483647,
static maxUInt=4294967295,
value_={
int_=6332592,
uint_=6332592,
real_=3.1287161563291111e-317,
bool_=176,
string_=0x60a0b0 "item1",
map_=0x60a0b0
},
type_=Json::stringValue,
allocated_=-1,
comments_=0x0
}
elem[1].left: $5={
cstr_=0x0,
index_=1
}
elem[1].right: $6={
static null={
static null=,
static minInt=-2147483648,
static maxInt=2147483647,
static maxUInt=4294967295,
value_={
int_=0,
uint_=0,
real_=0,
bool_=false,
string_=0x0,
map_=0x0
},
type_=Json::nullValue,
allocated_=0,
comments_=0x0
},
static minInt=-2147483648,
static maxInt=2147483647,
static maxUInt=4294967295,
value_={
int_=6332960,
uint_=6332960,
real_=3.1288979724867807e-317,
bool_=32,
string_=0x60a220 "item2",
map_=0x60a220
},
type_=Json::stringValue,
allocated_=-1,
comments_=0x0
}
Map size=2
此外,当类型是arrayValue,cstr_无效,index_有效。对于objectValue,index_无效,cstr_有效。
当出现栈破坏时,会导致gdb打出的栈信息不足或有错误。如果其中的堆栈有非常关键的信息,那只能依靠栈回溯来恢复未被破坏的栈。
对于x86,对于编译方式是不加-fomit-frame-pointer,也就是说,栈上总会保存上一层栈的栈帧基址,因此X86的栈回溯是根据栈帧基址不断的获取到上一个栈的栈帧基址,而返回地址总是在栈帧基址上方。由于该过程需要使用栈基址,所以如果其中的栈基址出现问题,就不能顺利的回溯栈了。
X64使用和X86相同的方法进行栈回溯。
相比X86的栈回溯,ARM由于没有使用栈基址寄存器,所以栈回溯会麻烦一些。
先获取当前栈的SP,根据当前函数的入栈、扩展栈的指令,计算出返回地址在栈上的位置,以及上层栈的SP,再以返回地址所在的函数作为新的需要回溯的函数,不断重复这一过程。由于该操作需要依赖每个栈对应函数的具体汇编细节,所以如果其中一个栈的返回地址被破坏,就不能顺利的回溯栈了。
对于X86、X64,栈帧基址、返回地址信息都是保存在栈上;对于ARM,返回地址信息保存在栈上。所以,即使其中一个栈帧的关键信息被破坏,不能顺利回溯栈,也还是可以根据栈上的其他未被破坏的栈基址以及返回地址恢复出后续的栈。具体方法是,以16进制,地址大小长的方式打印出当前sp后面栈上的一些数据,把这些数据通过addr2line进行翻译。
使用gdb时,切换到不同的栈时就会获得该层栈的参数与寄存器信息。
但是,对于寄存器传参的平台(X64和ARM),如果堆栈层次稍微深一点,出错时bt显示出的参数和寄存器很有可能是不正确的(栈相关的寄存器除外)。这种情况下如果盲目依赖gdb打出来的参数信息,很有可能得出错误结论或无法调试。对于一个看上去不那么正确的变量或者被优化的参数可以通过参数回溯来获得正确的内容。由于X86是栈传参,gdb打出来的参数总是对的,因此这里只考虑X64和ARM。
(1) 把上层过滤出的寄存器或当前变量对应的寄存器设定被观察寄存器
(2) 根据汇编分析如果经过当前栈后,还会剩余哪些寄存器能够恢复出被观察寄存器的值;如果某个寄存器的值记录在栈上,根据该参数反向恢复,结束。
(3) 切换到下个栈,依次步骤1到3重复
比如这里的目标变量是d
栈3:b->d,d->a,0->d
栈2:a- >c,1->a,2->b
栈1:push c
栈0:b->c
在栈1时就可以获取到c
向下跟踪,直接使用过滤法。
向上跟踪,找到该参数的来源,如果来源能从栈上获得,那么直接结束。如果不能,对最顶层的来源使用过滤法。
切记获取完毕后还需要结合各方面数据进行验证,保证没有出现逻辑矛盾。
此外,要注意返回值对寄存器的修改,主要是小于等于寄存器大小的返回值。不同平台的而返回值放在不同的寄存器中,X86是EAX,ARM是R0,X64是RAX。
当内存块中的数据出错时,检查当前内存块的内存边界是非常重要的。需要判定自身内存块边界与附近的内存块边界,这是收集的信息之一。
出错的地址可能不是一个正确的内存块地址,通过边界检查可以了解到这种情况。如果发生了内存溢出,明确内存块边界后,可以知道溢出了多少字节。甚至可能这块内存块已经被释放了,被挪作它用,导致不是期望数据,通过内存头上的信息可以了解当前内存块的状况。
运行时,有可能会因为头文件不对、虚函数参数不对导致调用的并不是期望的函数,有可能是调用了其他虚函数,或者是调用了头文件中默认的虚函数,还有可能调用了其他库中头文件的默认虚函数。对于以上问题,在函数执行位置的行数上设断点,再查看后续执行的指令,逐条执行,获得目标函数地址并进入该函数,就能知道执行的是什么函数了。
比如:某个虚函数调用 blx 寄存器
可以设置断点在“blx 寄存器”这条指令上,断点发生时输入si(执行单条指令),就能进入到具体的函数中了。
对于因为虚函数错而导致的死机,可以在死机时检查调用的虚函数以及实际应该调用的虚函数来明确错误原因。
相当多的死机是因为字符串拷贝溢出,当感觉像是字符串溢出时,以字符的形式打印内存数据进行识别。字符的十六进制值是在0x20 到07e之间。
根据地址,快速识别出该地址所属的内存区域(代码段、数据段、BSS段,栈,堆),以及该地址是否是非法的地址。
对申请的内存块进行封装,增加特定的信息字段,在内存释放时进行溢出检测,以及在core文件中对内存块进行完整性检查以及边界确认。
利用操作系统虚拟页到物理页的映射机制,实现操作系统级的实时边界溢出检查。该方法需要大量的物理内存以及虚拟内存支持,只适用于X86、X64上运行,以及嵌入式平台上小模块的测试,相比valgrind优势是在内存足够的情况下性能不受影响。
本文只讲了方法,具体的诀窍需要平时摸索。
此外,它不能取代对代码的熟悉程度。
由于调试是一种高时间成本的工作,往往开发过程中的一个只要稍微注意一下就能避免的问题,如果引入了bug,最终在调试过程中解决是非常消耗人力的。所以,根本还是提高开发过程中的质量,做到少调试、不调试。