GDB 源码分析 01:函数前导代码分析(上)

代码:gdb/prologue-value.hgdb/prologue-value.c

函数前导代码其实很简单也很固定,它主要就是进入一个函数时进行的代码操作,用来建立栈帧,并为临时变量开好空间。这里展示一个 x86 汇编的最简单情形:

1
2
3
push ebp
mov ebp, esp
sub esp, N

很简单,很友好,不是吗?

那么为什么要对这块代码进行重点分析呢?一方面,这是一个函数开头的部分,可以用来分析这个函数调用栈的情况,并分析这个函数所使用的临时变量地址;另一方面,虽然看起来这段代码很可爱,但是随着编译器的复杂化和在调度指令时日益激进的策略,它往往会变得面目全非。但无论如何,这段代码总归是相对简单的部分。

事实上,在现代的 gcc 编译器中往往会给出调用栈信息(call frame information, CFI),其中描述了如何寻找栈的基地址和存储的寄存器等信息,但是这些信息并不总是存在。如果它们不存在,我们就必须采用一些策略来试图解读这些信息。为了解读这些信息,我们就采用一种对指令“抽象解读”的策略,也就是下面将介绍的模糊计算策略。

值的模糊计算

prologue-value.h 中,定义了如下结构:

1
2
3
4
5
enum prologue_value_kind {
pvk_unknown,
pvk_constant,
pvk_register
};

这里所描述的是一个 prologue 值的类型,分别表示未知、常数和寄存器,它决定了在下面的结构体中,后面两个参数应该如何解读。

1
2
3
4
5
6
struct prologue_value {
enum prologue_value_kind kind;
int reg;
CORE_ADDR k;
};
typedef struct prologue_value pv_t;
  • 如果类型是 pvk_unknown,那么后面两个参数都没有意义,毕竟我们对它一无所知;
  • 如果类型是 pvk_constant,这表明这个值就是常数,记录在 k 中。注意,所谓的 CORE_ADDR 实际上就是一个 uint64_t
  • 如果类型是 pvk_register,这表明寄存器 reg 所对应的原始值为 初始值 + k ,其中 reg 为 GDB 标定的寄存器编号,是为了防止不同架构带来的跨平台问题。在开始分析之前,所有寄存器都会被标为 {pvk_register, reg, 0}

每个类型都有相关的初始化函数,它们实现起来并不困难:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pv_t pv_unknown(void) {
pv_t v = {pvk_unknown, 0, 0};
return v;
}

pv_t pv_constant(CORE_ADDR k) {
pv_t v;
v.kind = pvk_constant;
v.reg = -1;
v.k = k;
return v;
}

pv_t pv_register(int reg, CORE_ADDR k) {
pv_t v;
v.kind = pvk_register;
v.reg = reg;
v.k = k;
return v;
}

随后我们要对这些值进行“保守估计”,保守估计的意思是说,我们会把一个值正确估计或者设为未知,但不能出现错误的估计。对应于这种估计方式的逻辑变量是这样的:

1
2
3
4
5
enum pv_boolean {
pv_maybe,
pv_definite_yes,
pv_definite_no
};

然后通过看相关的函数来理解其原理:

1
2
3
4
5
6
7
static void constant_last(pv_t *a, pv_t *b) {
if (a->kind == pvk_constant && b->kind != pvk_constant) {
pv_t temp = *a;
*a = *b;
*b = temp;
}
}

这个函数就是个辅助函数。如果前者是常数而后者不是常数,对两者做个交换。这个函数在后面只是用来减少需要处理的情况总数的。接下来是对一大批寄存器运算的模拟。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pv_t pv_add(pv_t a, pv_t b) {
constant_last(&a, &b);
if (a.kind == pvk_register && b.kind == pvk_constant) {
return pv_register(a.reg, a.k + b.k);
} else if (a.kind == pvk_constant && b.kind == pvk_constant) {
return pv_constant(a.k + b.k);
} else {
return pv_unknown();
}
}

pv_t pv_add_constant(pv_t v, CORE_ADDR k) {
return pv_add(v, pv_constant(k));
}

首先实现加法的计算。这里的过程看起来很简单:两个常数可以加,寄存器可以加上常数,但其他的加法都返回未知。如果要处理常数加法,那就将常数转换成对应的类型再相加。这里不妨停一下思考两个问题:

  • 为什么寄存器和寄存器不能相加?
  • 为什么常数加法的情况在两个函数中都进行了处理?

这里我们需要注意,我们并不会实际执行程序,只是取其中的一个片段进行分析。在分析这个片段的时候,寄存器的初始值是未知的。因此,我们总共知道的就是,某个寄存器相比其进入这个片段之前多了或者少了多少。如果将寄存器与寄存器相加,那么势必要用到初始值,也就是说,它的结果对我们来说是未知的。

对于第二个问题,需要注意,加常数的情况并不仅仅存在于显式的指令编码中。例如,在下面将读到的 logical_and 函数中,如果出现与常数 0and 操作,这个寄存器的值就会变成常数 0 ,于是,当它与另一个寄存器相加时,就会进入寄存器与常数相加的情形。

接下来按照这个思路,可以很简单的实现减法和逻辑与运算,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
pv_t pv_subtract(pv_t a, pv_t b) {
constant_last(&a, &b);
if (a.kind == pvk_constant && b.kind == pvk_constant) {
return pv_constant(a.k - b.k);
} else if (a.kind == pvk_register && b.kind == pvk_constant) {
return pv_register(a.reg, a.k - b.k);
} else if (a.kind == pvk_register && b.kind == pvk_register
&& a.reg == b.reg) {
return pv_constant (a.k - b.k);
} else {
return pv_unknown();
}
}

pv_t pv_logical_and(pv_t a, pv_t b) {
constant_last(&a, &b);
if (a.kind == pvk_constant && b.kind == pvk_constant) {
return pv_constant(a.k & b.k);
} else if (b.kind == pvk_constant && b.k == 0) {
return pv_constant(0);
} else if (b.kind == pvk_constant && b.k == ~(CORE_ADDR) 0) {
return a;
} else if (a.kind == pvk_register && b.kind == pvk_register
&& a.reg == b.reg && a.k == b.k) {
return a;
} else {
return pv_unknown();
}
}

这两段的代码逻辑也相当清晰,如果理解了什么叫做保守估计,那么应该也能很轻松地读懂。主要的难点在于理解什么情况下可以对这个操作做出准确的估计,什么时候不能。基于这些计算方式,我们就可以基本上知道某段代码结束之后,能得到怎样的结果了。

这里需要注意,如果出现了除算术指令之外的其他指令,其结果就基本上全是 pvk_unknown 了。所以,这里所做的只是一个前导代码分析,因为函数的前导代码往往不会包含太复杂的东西。

对模糊值做出判断

当我们知道一段代码运行之后的结果之后,我们就可以尝试确定一些东西了,比如某个对象是否落在一个数组里。但在进行这些判断之前,先要有三个最普通的判断函数:判断某个值是否是常数、是否是某个寄存器、是否是某寄存器 初始值 + k 的结果,这三个函数将成为后面应用的基石。

1
2
3
4
5
6
7
8
9
10
11
int pv_is_constant(pv_t a) {
return (a.kind == pvk_constant);
}

int pv_is_register(pv_t a, int r) {
return (a.kind == pvk_register && a.reg == r);
}

int pv_is_register_k(pv_t a, int r, CORE_ADDR k) {
return (a.kind == pvk_register && a.reg == r && a.k == k);
}

接下来我们要开始考虑,如何判断一个对象是否落在某个数组里。很显然,我们需要考虑如何描述一个对象和如何描述一个数组。我们需要的参数有:

  • 描述一个对象在内存中的位置和长度,pv_t addrCORE_ADDR size
  • 描述一个数组在内存里的位置和长度,pv_t array_addrarray_len (数组长度),以及 CORE_ADDR elt_size (数组内每个元素的大小);
  • 返回值,是不是指向某个完整元素,以及如果指向某个完成元素,其对应的指标 int *i

在开始考虑如何写这个函数之前,我们需要明确地指出这个函数的功能以及它的返回类型。它的返回类型是 enum pv_boolean 型:

  • 如果这个对象一定落在数组中,那么返回 pv_definite_yes ,并把 I
  • 如果这个对象一定不落在数组中,那么返回 pv_definite_no
  • 如果不能判断、或者不指向完整的数组元素,那么返回 pv_maybe

好,接下来可以开始写这个函数了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
enum pv_boolean pv_is_array_ref(pv_t addr, CORE_ADDR size, 
pv_t array_addr, CORE_ADDR array_len,
CORE_ADDR elt_size, int *i) {
pv_t offset = pv_subtract(addr, array_addr);
if (offset.kind == pvk_constant) {
if (offset.k <= -size && offset.k >= array_len * elt_size) {
return pv_definite_no;
} else if (offset.k % elt_size != 0 || size != elt_size) {
return pv_maybe;
} else {
*i = offset.k / elt_size;
return pv_definite_yes;
}
} else {
return pv_maybe;
}
}

这里的逻辑其实很简单:如果对象的起始地址和数组起始地址不能相减或相减不为常数,我们都不能严格做出判断。然后我们考虑这样的情形:

这里就是第一个比较的来源:只有当 offset <= -size 时,才能明确判断说这个元素不在数组内,同样的,第二个比较发生在右边。然后检查是否对齐。如果不对齐,那只能是 pv_maybe。只有当对齐且元素大小等于数组元素大小,且偏移量落在数组之内时,才会返回 pv_definite_yes

我们上面的这些工作都只是完成了这个模块的第一部分。为了真正让这个模块得以被使用,还需要具备分析一个内存块的内容的能力。这就是第二部分将要讨论的内容。