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指令、全局变量赋值的反汇编、大端机/小端机,对指令建立了形象的认知,并直接构建了指令。
全局变量赋值引发的学习过程就是我们探索式学习很好的缩影,分为由浅及深的三阶段:深入剖析 → 部分修改 → 全新构建。我们要善于将自己的探索过程分解为递进的阶段,而这些阶段又由诸多设问和实证组成、推进。通过这样的锻炼,我们能渐渐磨炼出自己的学习和探索能力。记住,设问和实证为心灵的自由插上了一对翱翔的翅膀。