6.3 使用ebp或esp寻址

在前面的内容中,我们接触到很多高级语言中的变量访问。将高级语言转换成汇编代码后,就变成了对ebp或esp的加减法操作(寄存器相对间接寻址方式)来获取变量在内存中的数据,比如以下代码:


//变量nInt所在地址为ebp-4,对这个变量进行访问其实就是按dword方式读写这个地址

int nInt=1;

0040B7C8 mov dword ptr[ebp-4],1

//变量cChar所在地址为ebp-8,对这个变量进行访问其实就是按byte方式读写这个地址

char cChar=2;

0040B7CF mov byte ptr[ebp-8],2


由此可见,局部变量是通过栈空间来保存的。根据这两个变量以ebp寻址方式可以看出,在内存中,局部变量是以连续排列的方式存储在栈内的。

由于局部变量使用栈空间进行存储,因此进入函数后的第一件事就是开辟函数中局部变量所需的栈空间大小。这时函数中的局部变量就有了各自的内存空间。在函数结尾处执行释放栈空间的操作。因此局部变量是有生命周期的,它的生命周期在进入函数体的时候开始,在函数执行结束的时候结束。

在大多数情况下,使用ebp寻址局部变量只能在非O2选项中产生,这样做是为了方便调试和检测栈平衡,使目标代码可读性更高。从代码清单6-1中可以看出,使用ebp保存函数作用域的栈地址,这样在函数退出前,用于esp的还原,以及栈平衡的检查。而在O2编译选项中,为了提升程序的效率,省去了这些检测工作,在用户编写的代码中,只要栈顶是稳定的,就可以不再使用ebp,利用esp直接访问局部变量,可以节省一个寄存器资源。为了防止变量被编译器优化掉,需要对变量执行一些输入输出操作,示例如代码清单6-6所示。

代码清单6-6 使用esp访问局部变量—Release版


//C++源码说明:通过esp访问局部变量

void InNumber(){

int nInt=1;

scanf("%d",&nInt);

char cChar=2;

scanf("%c",&cChar);

printf("%d%c\r\n",nInt, cChar);

}

//函数在main函数中被调用

void main(){

InNumber();

}

;在Release版下,反汇编代码信息

;函数定义,由于在main函数中只有一句函数调用代码,

;因此IDA为其取名_main_0,而不是使用地址做标号

_main_0 proc near

var_5=byte ptr-5;IDA定义的局部变量标号,IDA环境下局部变量用var_开头

var_4=dword ptr-4;IDA定义的局部变量标号

;为局部变量开辟8字节栈空间,这里在没有了那些烦琐的操作

sub esp,8

;这句指令等价于:esp+8-4,标号var_4等于-4,IDA自动识别出访问的变量地址,并调整显示方式,省

去了计算偏移量这个过程,类似于高级语言中为变量命名,使代码显示起来更具可读性

lea eax,[esp+8+var_4]

mov[esp+8+var_4],1;初始化var_4变量为1

push eax;eax中保存[esp+8-4]的值,将eax作为参数入栈

push offset aD;"%d"

call_scanf;调用函数_scanf

;在分析指令的时候,IDA会根据代码上下文归纳出影响栈顶的指令,以确定esp相对寻址所访问的目标。

;于是IDA识别出以下相对寻址指令的目标是该函数中的局部变量var_5,之前执行了两次push指令,

;所以esp指向的栈顶地址存在-8的差值,而且本函数第一条指令sub esp,8也影响栈顶。综合以

;上信息,IDA为了表达出此时访问的局部变量为var_5,并且将var_5定义为-5,需要对esp相对寻

;址进行调整,先求解[esp+X+var_5]中的X,此处求解的X值为10h,然后就可以表达为

;[esp+10h+var_5],以加强代码的可读性。笔者建议读者可以在调试器环境下观察栈窗口,自

;己计算一下,加深对以后讲解的体会和理解

lea ecx,[esp+10h+var_5]

mov[esp+10h+var_5],2;为var_5处的局部变量赋值2

push ecx;功能同上,esp-=4

push offset aC;"%c",esp-=4

call_scanf;

;由于又执行了两次push指令,并且没有平衡栈,所以需要再次调整esp的相对偏移值,这里的调整值为18h。注意,在这里的movsx指令处点一下Q键,可以得到movsx edx, byte ptr[esp+13h],按K键可还原名称。这里的movsx指令显示var_5的类型为有符号类型,byte ptr说明长度为单字节,对应C语言中的定义应该是char。当然读者也可以考察使用变量作参数的函数,如果函数功能是已知的,那么参数类型也就已知了,进而推导出变量的类型。如果遇到本例这类格式化函数,那么鉴定变量类型就更简单了

movsx edx,[esp+18h+var_5]

mov eax,[esp+18h+var_4]

push edx;esp-=4

push eax;esp-=4

push offset Format;"%d%c\r\n",esp-=4

call_printf

;经过优化后的代码,一次性平衡了栈顶esp。在此函数中,共执行了7次push操作,而函数scanf和printf函数使用相同的调用方式,即__cdecl调用方式,因此函数内没有平衡栈,需要调用者来平衡栈顶指针esp,又因为在退出函数前,还需释放局部变量的8个字节(见函数入口指令)空间,所以esp需要加(7*4+8=)36转换成十六进制后为24h

add esp,24h

retn;执行ret指令结束函数调用

_main_0 endp;函数调用结束


在代码清单6-6中,通过IDA的标识,可以轻松地知道函数实现中的两个局部变量。图6-3为变量在栈中占用的地址空间,假设进入函数后未分配栈空间的esp为0x0012FFF0。

图 6-3 使用esp寻址栈空间

使用了esp寻址后,不必在每次进入函数后都调整栈底ebp,这样既减少了ebp的使用,又省去了维护ebp的相关指令,因此可以有效提升程序的执行效率。但是,缺少了ebp就无法保存进入函数后的栈底指针,也就无法进行栈平衡检测。由于已经是Release版,在程序发布前经过Debug下的调试检测,因此这项检测工作有些画蛇添足,可以省去。

每次访问变量都需要计算,如果在函数执行过程中esp发生了改变,再次访问变量就需要重新计算偏移,这真是个令人头疼的问题。为了省去对偏移量的计算,方便分析,IDA在分析过程中事先将函数中的每个变量的偏移值计算出来,得出了一个固定偏移值,使用标号将其记录。IDA是如何计算出这个固定偏移值的呢?这个偏移值可以为正,也可以为负,因此有两种计算偏移值的方案:正数标号法和负数标号法。

1)正数标号法:在进入函数后,执行申请变量栈空间的相关指令,调整esp,然后以调整后的esp作为基址来计算局部变量的偏移值,即图6-3中的地址0x0012FFE8。在函数的执行过程中,如果存在对栈顶操作的相关指令,则调整esp相对间接寻址中的相对值,再加上标号值寻址到变量所在的地址。这样一来,由于使用调整后的esp作为基址,而栈顶的生长方向是向0增长,因此变量的偏移值必然为0或者正数。

假设esp进入函数前的地址为0x0012FFF0,示例中使用两个整型变量:


var_0=4;定义第一个变量偏移量,所在地址为0x0012FFEC

var_1=0;定义第二个变量偏移量,所在地址为0x0012FFE8

sub esp,8;申请变量栈空间,esp保存地址变为0x0012FFE8

lea eax,[esp+var_0];寻址第一个变量地址为0x0012FFE8+4=0x0012FFEC

push eax;执行push指令,esp被减4,esp地址变为0x0012FFE4

lea eax[esp+4+var_1];由于esp被减4,需要对基址esp进行加4,调整后再加上标号


2)负数标号法:在进入函数后,执行申请变量栈空间的相关指令,调整esp,然后以调整前的esp作为基址来计算局部变量的偏移值,即图6-3中的地址0x0012FFF0。在函数的执行过程中,如果存在对栈顶操作的相关指令,则调整esp相对间接寻址中的相对值,再加上标号值寻址到变量所在的地址。被调整的esp的相对值是负数,这样一来,由于使用调整前的esp作为基址,而栈顶的生长方向是向0增长,所以变量的偏移值必然为负数。

假设esp进入函数前地址为0x0012FFF0,示例中使用两个整型变量:


var_0=-4;定义第一个变量偏移量,所在地址为0x0012FFEC

var_1=-8;定义第二个变量偏移量,所在地址为0x0012FFE8

sub esp,8;申请变量栈空间,esp保存地址变为0x0012FFE8

;使用申请变量栈空间前的esp作为基址,就需要调整esp,将其加8

lea eax,[esp+8+var_0]

push eax;执行push指令,esp被减4,esp地址变为0x0012FFE4

;由于esp被减4,需要对基址esp进行二次调整,加8后再加4,因此得到数值0x0C

lea eax[esp+0Ch+var_1]


显然,IDA选择了后者,用负数作为偏移值,将其作为标号,参与变量寻址计算。但是这两种标号最后转换得到的esp偏移是相同的。IDA为什么要选择后者呢?下一节的内容将帮我们解决这个疑问。