代码:gdb/prologue-value.h
,gdb/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
函数中,如果出现与常数 0
做 and
操作,这个寄存器的值就会变成常数 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 addr
和 CORE_ADDR size
;
- 描述一个数组在内存里的位置和长度,
pv_t array_addr
和 array_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
。
我们上面的这些工作都只是完成了这个模块的第一部分。为了真正让这个模块得以被使用,还需要具备分析一个内存块的内容的能力。这就是第二部分将要讨论的内容。