老“码”识途
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

1.1 全局变量引发的故事

1.1.1 剖析赋值语句机器码

我们从下面这段简单地为全局变量赋值语句的反汇编开始:

int gi;
void main(int argc, char* argv[])
{
  gi = 12;
}

使用VS 2008作为调试环境。将光标放在“gi=12; ”语句行上,然后按F9键,在该行上设置断点(如果继续按F9键,将消除该断点),出现如图1.1所示的标志

图1.1

断点(break point):当程序运行时,经过该点所在语句,程序将暂停运行。可在此时观察程序的各种状态,如变量值、内存值、寄存器,甚至可以修改这些相关状态。可以在程序运行前设置断点,也可以在运行中设置断点。

然后保证程序为Debug模式(默认模式),如图1.2中椭圆圈定部分。因为在Debug模式下可进行代码的调试跟踪。VS 2008的Release版本可调试,因为生成了调试信息,而VC 6.0必须手动配置,否则无法调试跟踪。

按F5键,进入调试运行方式(按Ctrl+F5组合键,为非调试运行方式,设置的断点无效)。程序在断点处暂停,见图1.3。

图1.2

图1.3

选择菜单的“调试→窗口→反汇编”命令(见图1.4),进行反汇编(disassemble)(快捷键Ctrl+Alt+D),反汇编的结果见DM1-1。

图1.4

                                  DM1-1
1  gi = 12;
2  0041138E    mov  dword ptr [gi (417140h)],0ch
3  }

第2行是第1行对应的汇编码,左边的数字0041138E是十六进制数表示的赋值指令mov的存放地址,右边是该条指令的汇编表示形式。(说明:内存地址和其中的值均采用十六进制数表示,在反汇编中一般省略末尾的H或h。全书同。)学习过汇编的读者会奇怪,全局变量的名字gi怎么会出现在汇编指令中呢?汇编语言没有变量名。因为在VC环境中,为了易理解,开发环境特意将操作对应的符号名也显示在语句中。为了看到纯正的汇编语句,我们选择暂时将符号名从反汇编语句中去除。在代码窗口中单击右键,在弹出的快捷菜单中选择“显示符号名”,取消其选中状态(如果需要显示符号名,则再次单击),见图1.5。此时,反汇编结果见DM1-2,mov指令中没有C语言的变量名gi了,是把0ch(h代表十六进制,0c即十进制数12)赋值给内存。该内存地址为指令方括号中的十六进制数,即00417140h。

图1.5

                                  DM1-2
  gi = 12;
  0041138E    mov  dword ptr ds:[00417140h],0ch
}

从这里开始,我们就开始探索式学习了。“问题”是探索式学习的关键。我们必须学会存疑发问,然后寻找解决方法求证。让我们来发问吧:

计算机的信息存储在内存中,mov指令将数据存放在指定地址的内存中。C语言的全局变量本质上是一块内存,所以对它的存取也是通过地址进行的。

“mov DS:[地址值],存储值”指令将存储值存入括号中的地址指向的内存中。DS是数据段寄存器,在x86寻址中是段寄存器和段内偏移合在一起定位的(这有点像用街道名和门牌号一起定位的方式)。在Windows和Linux系统中,所有段寄存器都指向一个位置,所以相当于只有一个段,段寄存器可以不用考虑。

① 内存00417140h真的是gi的地址吗?

② mov指令真的放在地址为0041138eh内存中吗?

先来解决第一个问题。打印是我们最熟悉的基本方法。修改源代码如下:

gi=12;
printf(“gi address=%x\n”, &gi);

这样太麻烦了,如果每次想看不同值,岂非要不断修改程序?这是不能容忍的!因此我们利用调试环境的一个能力,用监视窗口来观察程序状态,如变量和表达式。选择如图1.6所示的菜单命令,激活一个监视(watch)窗口。

图1.6

选择一个空行,双击“名称”列,输入“&gi”,回车,则在“值”列中显示其值 0x00417140 (与尾部加h一样,头部加0x也是十六进制数的一种表示方法),见图1.7。证明前面mov指令中的地址确实是gi的地址。

图1.7

我们暂时将第二个问题放一放,进一步探索mov指令,来看它的机器码。在代码窗体中单击右键,在弹出的快捷菜单中选择“显示代码字节”(见图1.8),则反汇编的结果如下:

gi = 12;
0041138E  c7 05 40 71 41 00 0c 00 00 00      mov  dword ptr ds:[00417140h], 0ch

图1.8

mov指令的机器码处于指令存放地址和汇编指令中间,即黑体显示部分。

现在来分析这个机器码中包含的与mov指令相关的信息。首先还是猜测,该指令应该包含了3方面的信息:要赋的值12,要赋值的内存地址00417140h,该指令是mov指令。按照猜测,我们在给出的机器码c7 05 40 71 41 00 0c 00 00 00中来实证。

我们需要观察、分析和猜测。赋值的12,即0ch,就被包含其中,从右边数第4字节。赋值的地址00417140h好像没有,但是其中包含每个单独的字节,00、41、71、40。从右往左看,则右数第5~8字节分别是00 41 71 40。我们因此猜测上面的机器码分成如下3块:

| c7 05 (代表mov指令) | 40 71 41 00 (地址00417140h)| 0c 00 00 00(代表值12)|

套用前面对地址的分析,第3块从右往左看就是0000000c,即12。而int类型在32位机上就是4字节,所以这条指令中用4字节表示了12。

这样我们似乎发现了一个规律,计算机上表示的整数在内存中是按字节倒序存储的。0000000c在内存中就是0c 00 00 00,00417140h在内存中就是40 71 41 00。

下面来验证。将“gi = 12;”改成“gi=0x12345678;”,再反汇编:

gi = 0x12345678;
0041138E c7 05 40 71 41 00 78 56 34 12   mov  dword ptr ds:[00417140h],12345678h

其中,机器码c7 05 40 71 41 00 78 56 34 12的后4字节“78 56 34 12”应该是被赋的值,倒序排列为“12345678”。可知,我们的猜测是正确的。

这里引出“整数在计算机上的表示方式”的知识。在内存中如何存储整数只是一种规范,分为大端机和小端机两种方式。

小端机 大端机

小端机:整数逻辑上的最低字节放在内存的最低地址,次低字节放在内存的次低地址,依次存放。比如, 0x12345678放到内存中就是78 56 34 12。Intel的x86系列CPU是小端机。

大端机:与小端机刚好相反。比如,0x12345678放到内存中就是12 34 56 78。PowerPC、SUN的SPARC、Motorola 6800是大端机。

下面来看mov指令的效果。要看赋值效果,我们需要用另一个调试利器——内存观察窗体。在VC 2008中选择如图1.9所示的菜单命令,激活内存窗体,见图1.10。

图1.9

在“地址”栏中输入地址,其值为十六进制数表示(以0x开头),然后回车。下方为内存面板区,其中显示内存的值,左边是内存地址,地址从左到右、从上到下增加,显示单位为字节。

首先执行程序,在语句“gi = 12;”断点处停下。在“地址”栏中输入要观察的gi的地址0x00417140,回车,结果见图1.11。

图1.10

单步执行“gi = 12;”,再观察gi所在内存的值,见图1.12。

图1.11

图1.12

单步执行

单步执行:只执行当前语句。根据单步执行函数的方式,单步执行分为两种:step into(快捷键F11),跟踪执行进当前函数;step over(快捷键F10),不跟踪执行进入当前函数,直接到下一语句。

gi所在内存0x00417140中的值从00变为0c。可知,赋值12产生了效果。但到底是修改了1字节还是4字节呢?我们需要证明。(虽然我们知道int是4字节。)怎么办?我们运用内存窗体的另外一个能力——修改内存的值。

修改内存值

修改内存值:单击要修改的字节,然后直接输入要修改的新值。

重新执行程序,当断点中断时,将0x00417140指向的4字节全部修改为11,见图1.13。然后单步执行“gi = 12;”语句,我们发现,总共修改了4字节:0c 00 00 00。至此验证了整数gi是4字节。最后我们还要看小端机的效果,将赋值语句修改为“gi = 0x12345678;”,单步执行,并在内存窗体中查看,其值确实是“78 56 34 12”。

图1.13

1.1.2 修改赋值语句机器码

现在来看之前提出的问题:

② mov指令真的放在内存0041138eh地址中吗?

怎样来设计这个实验?既然指令依然存放在内存中,本质也是内存中的一些数据,那么我们是否可以通过内存窗体来观察?对于“gi=12;”,该地址的值是c7 05 40 71 41 00 0c 00 00 00。在内存窗体的“地址”栏中输入0x0041138E,回车,结果见图1.14,确实该内存段存放的是mov指令的机器码。

图1.14

既然指令存储在内存中,并且可以修改内存中的值,那么我们是否可以修改其值,从而达到修改指令的效果?还记得刚才验证,最后4字节代表要赋的值,那我们来看修改其内容之后的效果,如修改为“gi = 894567;”。首先,计算894567的十六进制值。可采用Windows系统自带的“计算器”来求取,其十六进制数表示为0xda667。

十六进制数、二进制数与十进制数之间的转换

打开Windows系统自带的“计算器”,选择“查看→科学型”菜单命令(Windows 7下是“查看→程序员”),就可以选择十进制、二进制和十六进制了。比如,在十进制下输入12,然后选择十六进制,就变成了c。反向转换也如此。

如果我们要修改内存窗体中代表mov指令的字节串“c7 05 40 71 41 00 0c 00 00 00”的后4字节,应该怎样将0xda667填进去?是“c7 05 40 71 41 00 00 0d a6 67”吗?(想不清楚?没关系,实验一下。)调试运行并在断点“gi = 12;”处停止,修改内存窗体,见图1.15。再按F10键,单步执行,在监视窗体中查看gi的值,见图1.16。gi的值变为“1738935552”,不是12,也不是我们希望的894567。那么,说明我们修改还是有部分正确,它确实修改了mov指令所赋值,只是值不对。可将该值输入到计算器中看看其十六进制表示,也可用一点小技巧更方便观察其十六进制值。

图1.15

图1.16

十六进制显示

在监视窗体中用十六进制查看结果:因为调试器用十六进制数显示指针变量值,我们只需骗骗它,将被查看的变量强制转换为void *。比如,我们要查看变量gi,它本身是int类型,输入“(void *) gi”,就可用十六进制查看了。

用十六进制查看gi的结果,见图1.17。

图1.17

前面计算出的十六进制数应该是0xda667,与0x67a60d00有什么关联?把“0xda667”写为“0x000da667”,并将这两个数按字节分开,即

我们发现,这两个值刚好按字节倒序排列了。(前面提到过Intel计算机是小端机。)因此,修改后的机器指令应该是“c7 05 40 71 41 00 67 a6 0d 00”,而不是“c7 05 40 71 41 00 00 0d a6 67”。经过这次修改且如前执行并观察,gi的结果真的变成了我们希望的值。

练习:再定义一个全局变量,然后修改mov指令,将其地址部分修改为该变量地址,观察执行结果。

这样我们就全面测试了前面关于该指令各部分含义的猜测。这是一个有意思的关于基础知识学习的游戏,它的名字叫“猜测和实证”。希望大家从中感悟,找到自己的游戏方法,并运用到之后的学习中。在实证后是什么?就是“构建”,见1.1.3节。

1.1.3 直接构建新的赋值语句

既然我们发现了指令不过就是一些字节的组合,能否可以抛开C语言,自己构造指令来执行?我们完全可以分配一段内存,然后将mov指令的机器码填入。但是,如何让CPU执行我们的代码?

选择如图1.18所示的菜单命令,可在VC 2008中激活寄存器窗体,见图1.19,从中可以查看当前寄存器的值。

关于代码的执行

CPU中有一组寄存器,其中IP(Instruction Pointer)寄存器(又叫指令寄存器)与代码执行相关,其大小为16位(bit)。CPU从该寄存器指向的内存读入指令,并执行。执行后,IP自动加上所执行指令的长度,于是计算机就不停地重复取指和执行过程。后来内存变大,用16位存储地址不够了,于是将IP寄存器变成了32位的EIP(Extended Instruction Pointer)寄存器。总之,在Intel系统中,EIP寄存器指向哪里,CPU就将该地址作为将执行指令的入口,即使错误地指向了数据区。

jmp指令可设定EIP寄存器,使CPU跳转到设定地址执行。JMP指令有多种形式,如“jmp [地址]”。

图1.18

图1.19

寄存器窗体:在VC中可以激活寄存器窗体,它显示所有寄存器的当前值。如果单步执行,寄存器的值与上次不同,将用红色标注。

在“gi = 12;”语句的断点处停下,查看其反汇编显示的指令地址是否与EIP寄存器中的值相同。从图1.20中两个箭头指示的地址可知,EIP的值与当前执行的指令的地址相同,都是0041138e(十六进制数,数字末尾省略了“h”)。按F10键,单步执行,发现下一条指令的地址也是EIP寄存器中的值。

图1.20

假定我们构造了一段mov指令,需要用jmp语句跳转到这段指令。这段指令执行完毕后,EIP将指向下面的地址,即箭头指向的内存地址。如果不加处理,EIP所指内存的值是不确定的。CPU就会将该不确定值解释为对应指令,这将导致不可预料的行为。因此,我们在mov指令后面应该放一条指令,让程序回到正常流程。jmp指令正好可达成该目的,见图1.21。正常代码用jmp指令跳到我们构造的新代码,在新代码执行了mov指令后,用jmp指令跳回正常代码中jmp指令之后的代码,即xxxx代表的代码。

图1.21

因为在构造的新代码中需要构造jmp指令,所以先来看jmp指令的机器码。到网上查找吗?其实很多时候,使用反汇编调试,我们不需求助他人。只需构造一段包含jmp指令的代码,然后通过反汇编跟踪查看机器码即可,见DM1-3。(这就是基础的力量,它可以生化万物,支撑我们的学习探索。)

                                  DM1-3
  int i, gi;  //工程见“code\第1章\asmjmp”
  void * address;
  {
1     _asm {
2          mov  address, offset _lb1
3          jmp  address
4     }
5     i = 2;
6  _lb1:
7     gi = 12;
  }

第2行将标签_lb1即第6行语句的地址设定给变量address。(C语言中嵌入的汇编能够识别高级语言中的符号,如变量address,这与纯粹的汇编不同。)

第3行跳转到address变量指向的地址,即执行第6行语句。

下面来看关键代码的反汇编:

                            jmp  address
004113C8 ff 25 cc 74 41 00  jmp  dword ptr ds:[004174cch]

在C语言程序中嵌入汇编

嵌入汇编有两种方式:① _asm{ …. },在花括号中填写多条汇编语句;② _asm一条汇编语句,如“_asm mov eax, 1”。例如,C语言中获取标签的地址值:

void * address;
_lb1:
gi = 12;
_asm mov address, offset _lb1;

变量address中保存的是标签_lb1所在的地址,即“gi=12;”语句的地址。

机器码后4字节是cc 74 41 00,正好是jmp指令中地址004174cch的小端机表示。因此,我们能够推断出代表jmp指令的操作码是ff 25。现在构建机器码(见DM1-4)并调用之,工程见“code\第1章\自己构造mov和jmp代码”。

                                  DM1-4
   void main()
   {
1    void * code = buildCode();
2    _asm {
3        mov  address, offset _lb1
4    }
5    gi = 12;
6    printf("gi=%d\n", gi);
7    _asm  jmp code          //执行我们自己构建的代码
8    gi = 13;
9  _lb1:
10   printf("gi=%d\n", gi);  //打印的结果为18,而不是13
11   getchar();
   }

第1行,主函数调用buildCode获取新构建代码的首地址。第2~4行将第10行代码的地址赋值给address。第5~6行对gi赋值为12并打印。第7行跳转到指针code指向的地址,即第1行分配的新代码首址。新代码将对gi赋值为18,并跳转到address指向的地址,即第10行。第10行打印gi的新值18。

我们来分析buildCode函数,它要构建的汇编指令如下:

mov gi, 18;
jmp address

而这两条指令的机器码如下:

mov指令

jmp指令

这两条指令共16字节,因此需要用malloc分配16字节,然后将相关数据填入到其中,见DM1-5。

第1行,分配16字节存储这两条指令。第2行,pMov指向mov指令的开始。第3行,pJmp指向jmp指令头,因为mov指令为10字节,所以pJmp=code+10。第6~7行,将mov的机器码填入,即c7 05。第8~9行,将gi地址的4字节赋值给机器码第3字节开始。

第10行,将“18”这个要被赋值的4字节数设定给机器码第7字节开始的内存。第12~13行,设定jmp指令的机器码,即ff 25。第14行,填写address的地址,从jmp指令的第3字节开始,填写4字节。第15行,将构造出的代码首址,即code,返回。

第9、10、14行填写整数时,因为赋值指令采用小端机赋值,所以不需我们控制。

1.1.4 小结

通过本节,我们了解了调试环境的反汇编、监视窗口、内存窗口、单步、断点、mov指令、全局变量赋值的反汇编、大端机/小端机,对指令建立了形象的认知,并直接构建了指令。

全局变量赋值引发的学习过程就是我们探索式学习很好的缩影,分为由浅及深的三阶段:深入剖析 → 部分修改 → 全新构建。我们要善于将自己的探索过程分解为递进的阶段,而这些阶段又由诸多设问和实证组成、推进。通过这样的锻炼,我们能渐渐磨炼出自己的学习和探索能力。记住,设问和实证为心灵的自由插上了一对翱翔的翅膀。