Lxr-《Hb》2015.02.19-03.03
《汇编语言·第三版》--王爽
操作系统:win7 x86 开发环境:TC++3.0(下成C++的IDE了) |
虽然依本人功力还不能将计算机的物理实体及抽象层次结构笔记到清楚层次,但还是笔记一下。
规定(注意这个词)只有0和1两个元素(注意这一点),再创建(注意这个词)一些(运算)法则(如“与”,“或”,“非”,“加”,“减”,“乘”,“除”等)构成与十进制系统有对应关系(注意这一点)的“二进制系统”(这个“系统”非计算机操作系统含义)。
由于本人不是计算机的发明或设计者,实在很难从计算机的本质或者根源处说起。就从计算机硬件电路的电压笔记吧。因为对计算机内硬件电路电压的抽象---- 电平,是软件的最底层。计算机中呈现就是电平。
硬件电路(由电阻/电容/CMOS之类的“基本”元件组成)在通电后会有电流和电压。在硬件电路中,不同地方拥有不同的电压值,这些不同的电压看起来没什么可被加以利用的潜力(它能够帮我们完成复杂计算么?他能够给我们看我是歌手么?……)
电平是电压的上一层概念规定(抽象)。
将处于某电压范围的电压称为高电平,处于另一范围的电压称为低电平。如由CMOS规定的电平:[2V,3.3V]范围的电压被称为高电平;[0V, 0.8V]范围的电压被称为低电平。如此,就可以将硬件电路中的电压分为三类:高电平,低电平,其它电平。将电压设计在电平内的硬件电路看起来也没有什么可被加以利用的潜力。
我们可以规定各种范围下的电平范围,我们可以通过选取不同值的元件使得硬件电路中存在指定数量的电平(电压)。
比如我设计了一个硬件电路C1,包含10个不同的电压值[1V,2.1V, 3.3V,3.5V,…, 9.1V,9.2V,C1似乎有很大的被利用的潜力,因为10进制也刚好有10个元素。用这10个电压值来分别表示(对应)0 ~ 9这10个数字]。用2.1V和3.3V作为我设计的另一个硬件电路C2的输入,C2在这两个输入下刚好能够输出一个3.5V的电压值。因为电压和数字的对应,那么这个电路就能够表示1+ 2 = 3这个过程[不要被在计算器中看到的“1+ 1 = 2这个现象所困扰”(能够显示每个符号实在是做了很多的后台工作:计算器中显示的符号也是根据电平“转换”(这两个字包含的东西有点多)来的)。眼睛看到的是真的,但不一定是本质(有时也不是非要了解事物的本质,熟悉到合适的层次即可。看个人性格)]。如果每个电路中都有10电平状态,那么就可以用十进制系统来指导硬件电路的设计(跳跃太大)。
假设我又设计了一个只有高/低电平两个状态的硬件电路C3,将高/低电平和二进制中的元素1/0相对应。当C3的输入为“低高高低”电平时,C3输出“高高”电平。由电平和二进制数字之间的对应,C3就能够表示(01)2+ (10)2 = (11)2这个过程。如果每个电路中的电压都只有高/低电平两种状态,那么就可以用二进制系统来指导硬件电路的设计。
现在(2015.02.23)二进制系统是(不仅是)高/低电平的上一层抽象。二进制系统成为了硬件电路设计的指导思想,而非十进制。这是为什么?
[1] 十进制能够描述的,二进制也能够(间接)描述
[2] 二进制的基本元素只有1和0,硬件电路更容易实现
[3] 最初设计高/低电平者的初衷
就这样,硬件电路(数字)在二进制理论的指导下被设计了几十年,硬件电路里的高/低电平序列就可以用1/0序列来对应表示了[注意这一跳跃:一个物理存在量(可能还电人)被折腾到纸上(逻辑上)比划]。二进制的地位也越来越高(虽然还比不上十进制),在二进制理论的指导下,可谓成果累累。在计算机领域内,基本已经将二进制视为计算机的最底层。在大多数场合里二进制都直接代替电平(常能听/看到这样的话:计算机只能识别10序列。虽然这话隐含的意思是计算机只能传输高/低电平,虽然说这话的人还可能没有意识到这话的隐含意思……)。
对于计算机来说,其内部只有电平在线上传输。而我们在电脑中看到的是“字符”,“汉字”,“图”,“视频”等玩意。跟标题“二进制和电平”丝毫不沾边。其实我也笔记不清楚每个显示领域中的许多细节(愧知识贫乏),只笔记一个简单的“现象”(不知能否打通学计算机的第一二脉):
[1] 打开一个编辑器,就word吧。
[2] 在word中按下“1”(双引号起强调作用)这个键(刚刚才按过)。
[3] (“1”立马显示在word中,不过我还不想说这个现象,因为在word中显示“1”之前还发生了一些我们没有看见的事情)键盘上的每一个按键相当于一个开关,键盘中有一个芯片对键盘上的每一个键的开关状态进行扫描。“1”这个按键被按下时,开关接通,键盘中的芯片检测到“1”按键被按下,于是就产生一个扫描码,这个扫描码(通码)说明了按下的键在键盘上的位置。扫描码被送入主板上的相关芯片的寄存器中,该寄存器有一个地址如60h(这个寄存器还有一个别名:端口。CPU可以直接读端口)。
[4] 当60h端口中有数据到达时,相关的芯片会向CPU发出中断信息。CPU检测到该中断信息后,如果此中断未被屏蔽,则引发中断过程,转去执行对应的中断程序(中断程序被前辈写好,整个中断机制都是前辈们提早完善好了的,这个过程可以参看《汇编语言》一书)。中断程序将完成诸如以下功能:
[5] 松开按下的“1”键时,键盘芯片也将产生一个扫描码(断码),这个扫描码说明了松开的键在键盘上的位置。这个扫描码也被送入[3]中提到的那个寄存器端口中。再次引发中断历程,并作类似的处理。
[6] 当关闭word时,计算机内的电平Y将传递给硬盘,让硬盘以它的方式保存着Y的信息。每当打开这个word时,计算机从硬盘将Y在硬盘对应的信息读入计算机内部(先不管它在哪里)再形成Y。Y再被传输到显存中,再由word做处理显示“1”于word中。Word呈现“1”的速度之快,让我以为word显示“1”是最先发生的。
其它的“字符”,“汉字”,“图”,“视频”等的显示步骤和过程可能有所不同,但都离不开这样的一个机制:高/低电平与二进制元素(1/0)相对应。先在计算机内部形成高/低电平[高/低电平的形成过程遵循二进制理论(用高/低电平编码)],再将这些高/低电平解释(解释过程也按照二进制理论进行),最后将解释的结果转换为人类熟悉的符号。
[猜:计算机内高/低电平最终能够有所显示,取决于屏幕的特殊材料。一端接高电平,另一端接低电平的LED灯能以蓝或者红色的状态亮起来。屏幕中有高电平使其以一定颜色“亮”起来的很灵小的材料。]
计算机硬件由众多独立的硬件电路模块相互连接而成。硬件电路根据电平输入作对应的电平输出,从而表现出不同的功能。CPU可以指定一些硬件电路的电平输入,从而得到这些硬件电路的期望输出。我们可以指挥CPU给哪个硬件电路给什么样的电平输入。那么怎么指挥CPU呢?
指挥CPU的也将是电平信号串(很多个电平)。指挥CPU这个过程的机密细节我也不得而知,只能做一个简要抽象的笔记。CPU提供专门的寄存器X(如8086提供CS和IP寄存器分别保存指挥CPU电平的段地址和偏移地址)来保存指挥CPU的电平串的地址。我们需要做两件事情来达到指挥CPU的目的:
[1] 确定好指挥CPU的电平串Y
[2] 修改X的值为Y的首地址
能说明[1]这个过程的需要借助二进制编辑器(ASCII码编辑器内的1和0是字符‘1’和‘0’)。在这个编辑器中只接受二进制的输入。往二进制编辑器内输入“1”,计算机内就会产生与“1”对应的电平;往二进制编辑器内输入“0”,计算机内部就会产生与“0”对应的电平。只要我们知道“1”,“0”在计算机内对应的电平以及什么电平能够让CPU去指挥哪一个部件并给以这个部件什么样的输入,我们就可以在二进制编辑器中用二进制将这一串电平写下来。然后保存在二进制编辑器写的内容,将二进制编辑器输出的文件命名为bfile。
然后写一个名为loader的程序,它可以将bfile中二进制对应的电平传输到内存(就当内存是计算机内部)地址为ad的内存处,然后修改X的值为ad。那么CPU就会来读ad中的电平串(ad中的电平就传输给了CPU),这串电平作为CPU的输入(CPU也是一个硬件电路),CPU将会输出一串电平去驱动某个硬件电路工作。
以下是二进制编程的一个例子,打开二进制编辑器BZ,新建一个文件,往新建文件内输入以下内容:
Figure1. 二进制程序
然后将此文件保存为b.exe。用DosBox执行b.exe,执行结果如下(此二进制代码是在屏幕的第2行第二列显示一个红色字母’a’):
Figure2. 二进制程序的运行效果
如果我们将这样能够指挥CPU去驱动其它部件的很多电平集合在一起(对于用户来说就是将二进制码集合在二进制编辑器中),并通过loader载入。就可以让CPU发出不同的“号令”,让被选中的硬件电路表现出二进制码中指定的功能,从而确定了计算机的功能。这就是用二进制码(机器码)编程的过程吧……(我的功力,只能这样笔记了)
在二进制编辑器内书写的10序列所对应的高低电平可以直接作为CPU的输入,将这样的二进制码称为机器码(它在计算机内对应的高低电平可以直接作为CPU的输入)。
当打开记事本或者notepad++等编辑器写汇编代码时,编辑器中汇编代码在计算机中所对应的电平是每个字符的编码。如在编辑器中显示的add指令(二进制编码为0110000101100100 01100100),如果在计算机内的采用电平编码与二进制对应,那么它在计算机内部所对应的高低电平序列为“低高高低低低低高低高高低低高低低 低高高低低高低低”(或许还有附加信息的电平编码信息)。
CPU很可能(“add”的编码有可能和某机器码相同)不认识字符串“add”在计算机内对应的高低电平串,它只认识前辈们规定的那些电平串(即机器码)。由于汇编指令与机器码有一一对应的关系,前辈们就编写了汇编程序的编译器(包括汇编编译器和链接器,二者的作用或实现看专业书籍),编译器将根据汇编程序输出一个与汇编程序相对应的机器码的文件,这个机器码文件在计算机内对应的高低电平是CPU能够识别的,就可以用loader程序将其载入计算机内去指挥CPU去指挥指定的硬件电路模块。
汇编之所以出现,是为了给想指挥CPU工作的人提供方便,“0110000101100100 01100100”和“add”对编程者来说,谁更方便一些?但如果要用汇编程序来指挥CPU发号施令,就必须要使用汇编程序的编译器来编译汇编程序,将汇编程序转变为机器码。
如果计算机内的电平与字符的二进制编码成对应关系,在C语言集成开发环境中的编辑器中用C语言编写程序时,计算机内部的电平序列与C语句中每个字符的二进制编码相对应。如编辑器中的if(i的ASCII为105,f的ASCII为102)语句在计算机内对应的电平序列为“低高高低高低低高低高高低低高高低”。
当然这样的电平序列CPU很可能是不认识的,所以要将这个电平序列转换为机器码对应的电平序列(CPU只能识别机器码对应的电平序列),将C语句转换为机器码的任务由编译器(包括C预处理器,C编译器,汇编编译器,链接器)完成。
同汇编语言相比,C语言的表达方式更接近人的逻辑思维,同时也提供了许多函数供编程者调用。对于一个用C语言编程的人来说,需要将C程序转换为机器代码的编译器,否则C程序是不起丝毫作用的,计算机对C源程序是不感冒的。
由于C语句与汇编语言非一一对应的关系,对于一个相同的C程序,不同的编译器可能会生成不同的汇编代码。好一点的编译器将依据C源程序生成效率高一点的机器码,差一点的编译器将依据C源程序生成效率低的机器码。对于实现同一个功能,分别用汇编程序Afile.asm和C程序的Cfile.c来实现。Afile.asm和Cfile.c经过编译器后分别生成Afile和Cfile可执行文件。一般来说,Cfile比Afile要复杂且执行效率要低(C编译器将C语言转换成汇编语言的策略不够“十足优秀”),这就是人们常说的C比汇编语言执行效率要低的原因(如果汇编程序写的太挫,本来用1条汇编指令就可以解决的事情偏被绕了弯用100条汇编指令才完成,那么很可能此时的C程序比汇编程序的效率要高)。C++程序的效率(执行时间和存储空间)之所以又要比C程序要低,是因为C++的规则比C复杂得多,对于相同功能的C程序和C++程序,C++程序经C++编译器处理得到的机器码比C程序经C编译器得到的机器码更复杂。
首先编写一个使用寄存器的程序例子,由于使用的是TC++3.0编译器,使用寄存器得遵循TC++3.0编译器规定的形式。
Table 1. url.c
int main(void) { printf(“%xh\n”, main); _AX = 1; _BX = 1; _CX = 2; _AX = _BX + _CX; _AH = _BL + _CL; _AL = _BH + _CH;
getchar(); return 0; } |
打开TC++3.0(可按照书中“研究实验1”操作一把,体验一下编译和链接的文件),打开url.c。
编译:Compile〉〉Compile,经此步骤TC++3.0的C编译器会在Options>> Directories >> Output Directory设定的目录下输出url.obj文件。
链接:Compile〉〉 Link,经此步骤TC++3.0的C编译器会在Options>> Directories >> Output Directory设定的目录下输出TCDEF.EXE文件。
运行:Run >> Run。运行结果如下:Figure 3. url.c运行结果
是TC++3.0下的编译器将使用寄存器的C代码转换成为的机器码。
开始>> 在搜索中输入cmd并打开搜索到的cmd应用程序。进入到TCDEF应用程序所在目录:
Figure 4. 启动cmd并设置其所在目录
在TC++3.0编译器(C编译器+ 汇编编译器 + 链接器)输出可执行程序TCDEF.EXE所在的目录下用debug调试工具加载TCDEF.EXE应用程序:
Figure 5. 用debug工具加载TCDEF.EXE
在cmd下依次使用以下命令就可以得到使用寄存器的C代码url.c的机器指令(与汇编代码一一对应,读汇编代码即可):
Figure 6. url.c 在debug下的main函数的机器码和汇编代码
这段汇编代码体现出来的函数栈帧可以参考《LinuxC编程一站式学习》和《CSAPP》的学习笔记。CALL和RET指令可以参考《汇编语言》– 王爽之前的章节。
在C语言中,TC++3.0支持使用寄存器的形式为“_寄存器名”,如_AX就能够访问到AX寄存器。
使用寄存器的C代码经TC++3.0编译后除寄存器名得以转换后无其它变化。
使用寄存器的C函数拥有自己的函数栈帧。
“printf(“%xh\n”, main);”语句以16进制形式输出main程序在代码段中的偏移地址,得到main函数的偏移地址为291h。
为什么“printf(“%xh\n”,main);”输出的是main函数在代码段中的偏移地址?
[1] 在之前的汇编练习中,汇编编译器将汇编程序的“end 标号”的标号处当成程序的代码段的首地址,并将CS:IP的值设置为“标号”在内存中的地址,让CPU从这个地址开始执行代码。
[2] 在链接C的各目标文件时还会链接C环境自带的目标文件,将C环境自带的目标文件中的启动和结束代码加到用户的目标中而得到一个可执行文件。CPU执行启动代码后,将调用名为main的函数。这样,main函数就加入了代码段,main这个符号就是main函数在代码段中的偏移地址。(main符号代表的含义由TC++3.0编译器解析)
所以“printf(“%xh\n”,main);”能够将main函数在代码段中的偏移地址打印在屏幕上。
[ “getchar();”函数从标准输入接收一个输入,在这里是为了让运行窗口停留,方便我们观看结果。]
计算机前辈将物理内存抽象成为一个以字节为单元的连续的空间。所有的物理存储器被看作由若干存储单元组成的逻辑存储器,没个物理存储器在这个逻辑存储器中占有一段地址空间。CPU在这段地址空间中读写数据,实际上就是在相对应的物理存储器中读写数据。与程序内存相关的许多概念都是在这个逻辑基础上来讲解的。(“内存对齐”少数概念需要从内存的物理结构理解)
Figure7. 对物理内存的抽象 – 以字节为单元的连续数组
系统自动(不究是编译器还是操作系统)会为程序中的变量分配内存(在用户程序空间中分配)。对于内存来说,需要给出内存空间首地址和空间存储数据的类型(对于程序中变量,只需要指明变量的存储类型;如果直使用内存,那么需要指定内存的地址及存储大小)。以下是直接使用内存的一个例子。
Table 2. uml.c
1. int main(void) 2. { 3. printf("%xh\n", main); 4. *(char *)0x2000 = 'a'; 5. *(int *) 0x2000 = 0xf; 6. *(char far *)0x20001000 = 'a'; 7. 8. _AX = 0x200; 9. *(char *)_AX = 'b'; 10. 11. _BX = 0x1000; 12. *(char *)(_BX + _BX) = 'a'; 13. *(char far *)(0x20001000 + _BX) = *(char *)_AX; 14. 15. getchar(); 16. return 0; 17. } |
在TC++3.0中编译,链接并运行以上程序(main函数在代码段中的偏移地址仍旧为291h)。就能在指定内存中存入指定的内容。
“*(char*)0x2000 = 'a';”语句表示向偏移地址(段寄存器DS)为2000h,存储一个字节的内存空间写入字符’a’。
“*(char far*)0x20001000 = 'a';”表示向地址2000:1000,存储一个字节的内存空间写入字符’a’。
uml.c程序对应的汇编代码
在tc++3.0编译器下得到uml.c对应的汇编代码如下:
Figure8 uml.c在debug调试器下的汇编代码
已经在图示中标明C代码和汇编代码的对应关系。黄色框里的BX的值应该为2000,经debug单步调试,TC++ 3.0将其编译为3000,我们视为是编译器的错误哈。
在TC++3.0环境下编写一个用堆内存的程序mlc.c。
Table 3. mlc.c
1. #define Buffer ((char *)*(int far*)0x200) 2. 3. int main(void) 4. { 5. Buffer = (char *)malloc(20); 6. //if (Buffer == NULL) return 1; 7. 8. Buffer[10] = 0; 9. 10. while(Buffer[10] !=8){ 11. Buffer[Buffer[10] = 'a' + Buffer[10]; 12. Buffer[10]++; 13. } 14. free(Buffer); 15. return 0; 16. } |
Buffer是地址为0x200内存中的内容,这个内容是“用来存储字节数据内存(块)”的首地址。用TC++3.0编译,链接mlc.c,再用debug调试器调试,看经malloc()函数分配的堆内存位于何处。
mlc.c的汇编代码
用debug调试,得到mlc.c对应的汇编代码为:
Figure9. mlc.c的汇编代码(TC++ 3.0)
[1] 将要malloc()函数分配堆空间的字节数传递给AX。malloc()将分配成功的堆首地址(是一个偏移地址,其段地址被自动分配在诸如DS的寄存器中)返回给AX保存。再将堆首地址放在Buffer(0x200)中。
[2] 将保存在0x0200内存中的偏移地址给BX,再加上偏移量(10个字节)得到在堆中的总偏移量便能够访问到Buffer[10]。(此次段地址保存在DS寄存器中)
[3] 将Buffer[10]和8相比,如果Buffer[10]不等于8则执行[4]中的代码。
[4] 将保存在0x200的堆首地址赋值给BX,再将Buffer[10]的内容赋值给AL;再让BX的值加上AL的值(AL作为了BX的下标值);再将保存在0x200的堆地址分配给SI,再将Buffer[10]的值赋值给AL,再给AL加上’a’的值;最后将AL的值赋值给当前数组元素,再将Buffer[10]的值增1。再去执行[3]。
用一条C语句在屏幕中间显示一个绿色的字符。
Table 4. c_show_green_a_in_one_line.c
1. int main(void) 2. { 3. *(int short far *)(0xb8000000 + 0xa0 * 11/*The 12th Line*/ \ 4. + 39 * 2/*The 40th coloumn*/) = 0x0261; 5. getchar(); 6. return 0; 7. } |
在TC++3.0环境中编写一个含有全局,局部变量及函数调用的C例子ukl.c。
Table 5. ukl.c
1. int a1, a2, a3; 2. 3. void f(void); 4. 5. int main() 6. { 7. int b1, b2, b3; 8. 9. a1 = 0xa1; 10. a2 = 0xa2; 11. a3 = 0xa3; 12. b1 = 0xb1; 13. b2 = 0xb2; 14. b3 = 0xb3; 15. return 0; 16. } 17. 18. void f(void) 19. { 20. int c1, c2, c3; 21. a1 = 0x0fa1; 22. a2 = 0x0fa2; 23. a3 = 0x0fa3; 24. c1 = 0xc1; 25. c2 = 0xc2; 26. c3 = 0xc3; 27. } |
在TC++3.0下编译,链接ukl.c,用debug调试器观看其汇编代码。
在TC++3.0环境下编写一个返回值为整型的函数,并用main调用的程序r_value.c。
Table 6. r_value.c
1. int fi(void); 2. 3. 4. int a, b. ab; 5. 6. int main(void) 7. { 8. int c; 9. 10. c = fi(); 11. return 0; 12. } 13. 14. int fi(void) 15. { 16. ab = a + b; 17. return ab; 18. } |
在TC++3.0下编译,链接r_value.c。用debug调试器查看fi()和ff()函数返回值放在了哪里。
在C语言中直接使用内存要借助指针来实现。在C程序中一般不用直接使用内存的形式,而是使用用变量。因为有的内存用户程序不能访问。
用debug调试器可以看出未初始化的全局变量保存在以DS寄存器中的值为段地址的内存块中,这段内存被前辈们称为.bss段(附加段,管它叫什么)。DS的值由启动代码分配,由结束代码恢复(链接时发生)。
用debug调试器可以看出局部变量被保存在以SS寄存器中的值为段地址的内存块中,这段内存被前辈们称为栈。SS值的分配和恢复跟DS一样。
bp指向函数栈内存的栈底(地址大的一端),sp指向函数栈内存的栈顶(地址小的一端)。“pushbp”将bp的值压入栈中保存(sp指向栈内容为bp的栈内存),“movbp, sp”语句让bp指向原栈顶(新栈底),“subsp, +06”让sp指向新栈顶,一个运行的函数的栈空间就是sp和bp所包含的栈内存。bp和sp都以ss为段地址。
在C程序中用代码申请和释放的内存被前辈们称之为堆,它的段地址由某个寄存器(mlc.c中用DS保存)保存。其实不管是堆,栈,.bss段等都是内存,前辈们根据使用内存方式的不同而将它们如此命名。如此而已。
通过mlc.c的汇编代码可以知道,数组访问元素a[i]的过程有:计算数组名代表的地址Add,计算数组下标p,然后得到下标为i的数组元素的地址为Add+ p。再通过Add + p去访问数组元素a[i]。
fi()函数将整型的返回值放在了AX寄存器中。
1. void f() 2. { 3. *(char far *)(0xb8000000 + 160 * 10 + 80) = 'a'; 4. *(char far *)(0xb8000000 + 160 * 10 + 81) = 2; 5. } |
在TC++3.0下编译,链接f.c。
TC++3.0编译f.c成功,生成f.obj文件。在链接阶段会出现“LinkerError:Undefined symbol _MAIN in module C0.ASM”错误提示。这个错误是说在C0.ASM这个文件中所引用的_MAIN符号没有被定义。
用汇编语言的链接器link链接f.obj
Figure10. 汇编链接器链接f.obj(由TC++ 3.0输出)
用debug加载f.exe,得出:
[1] f.exe共0x1b个字节
[2] f.exe不能正确返回(f函数内RET指令,但没有CALL指令调用f函数)
[3] f函数的偏移地址为0
Table 7. m.c
1. int main(void) 2. { 3. *(char far *)(0xb8000000 + 160 * 10 + 80) = 'a'; 4. *(char far *)(0xb8000000 + 160 * 10 + 81) = 2; 5. return 0; 6. } |
[1] m.c的可执行程序供占0x15C0个字节
[2] 程序能够正确返回
[3] m.c main()函数中的汇编代码和f函数的汇编代码一样
调用main()函数的指令的地址 整个程序返回的指令
main()函数的偏移地址为0x291h。用debug调试器找到“call291”指令即可。
Figure11. 调用main函数指令的地址
调用main()函数的地址为13E6:0155(物理内存机器级抽象层的地址)。
根据以前的汇编程序知识得整个程序返回的指令为“movax, 4c00h和int21h”。
Figure12. m.c的退出程序的指令
调用main()函数等代码从何而来?
Figure13. 链接C0s.obj
虽然用link.exe链接C0s.obj时有警告有错误但还是生成了C0s.exe(c0.ASM文件出现)。
用debug查看C0s.exe和m.c的可执行文件的汇编代码。
C0s.exe开始处汇编代码
Figure14. 汇编代码开始处
m.exe汇编代码开始处
Figure15. m.exe开始处汇编代码
C0s.exe和m.exe(m.c的可执行程序)的汇编代码比较
由Figure12, 13可知,二者开始处的汇编除个别参数不同外(由用户程序main引起),其余都相同。
在m.exe中,调用main函数的指令的偏移地址为0x0155。二者在0x155后面的汇编代码比较如下:
Figure16. m.exe和C0S.exe在0x155偏移地址后汇编指令比较
二者指令一致,但指令的参数不一样。比如m.exe中0155h处的“CALL0291”是调用main()函数。
我们只要改写c0s.obj,让它不调用main函数,编写C语言程序时就可以不写main函数了(链接阶段不会再有“无main符号定义的错误”)。
Table 8. C0S.ASM
1. assume cs:code 2. 3. data segment 4. db 128 dup(0) 5. data ends 6. 7. code segment 8. start: mov ax, data 9. mov ds,ax 10. mov ss, ax 11. mov sp, 128 12. 13. call my_flag 14. 15. mov ax, 4c00h 16. int 21h 17. 18. my_flag: 19. 20. code ends 21. end start |
用masm.exe编译C0S.ASM得到C0S.OBJ文件。将C0S.OBJ放在f:\mytc\minic目录下(将原来的C0S.OBJ剪切到其它目录)。
再编译 链接 f.c
用TC++3.0打开f.c,对其编译,链接,皆通过,并在tc指定的输出目录下生成TCDEF.EXE文件。在cmd中运行TCDEF.EXE,并没有出现绿色字符’a’的运行结果(原因在于call指令后偏移地址值0xFF92非my_flag所在的偏移地址值0x0012)。在debug调试期下观看TCDEF.EXE的汇编代码:
Figure17. f.c可执行文件的汇编代码
f.c对应的汇编代码紧挨在C0S.ASM之后。但f.c对应的指令得不到执行。
编译 链接ac.c
Table 9. ac.c
1. #define Buffer ((char *)*(int far*)0x200) 2. 3. void ac(void) 4. { 5. Buffer = 0; 6. 7. Buffer[10] = 0; 8. 9. while(Buffer[10] !=8){ 10. Buffer[Buffer[10]] = 'a' + Buffer[10]; 11. Buffer[10]++; 12. } 13. } |
Buffer是地址为0x200内存中的内容,这个内容是“用来存储字节数据内存(块)”的首地址。“Buffer= 0;”语句将首地址为0x200的那几个字节的内存初始化为0。然后就是用0~ 10这几个字节的内存了。
对ac.c编译,链接,再用debug调试器观看其可执行程序的汇编代码。
Figure18. ac.c的汇编代码
ac.c中Buffer代表的内存是从0开始的;mlc.c中Buffer代表的内存是malloc函数从“堆”那里申请来的,需要用free函数释放。
TC在链接C程序时把C0s.obj和用户.obj文件一同进行链接,生成.exe文件。按照这个方式生成的.exe文件的程序的运行过程如下。
[1] C0S.obj里的程序先运行,进行相关的初始化,比如,申请资源,设置DS/SS等寄存器等;
[2] C0s.obj内的程序调用main()函数,开始执行用户程序;
[3] 用户程序从main()返回到C0S.obj的程序中;
[4] C0s.obj的程序接着运行,进行相关的资源释放,环境恢复等工作;
[5] C0s.obj的程序调用DOS的int21h例程的4ch号功能,程序返回。
[1] C开发系统提供了用户写的应用程序正确运行所必须的初始化和程序返回等相关程序,这些程序被保存在相关的文件中(如TC的C0S.OBJ)。在C程序的链接阶段,这些程序会被添加到用户程序中。
[2] 需要将这些文件和用户.obj程序进行链接,才能生成可正确运行的.exe文件。
[3] 链接在用户.obj文件前面的由C语言开发系统提供的.obj文件的程序要对main函数进行调用。
故而C程序需要包含一个main函数。
在TC++3.0下新建文件,输入以下内容,将其另存为a.c
Table 10. a.c
1. void showchar(char a, char b); 2. 3. int main(void) 4. { 5. showchar('a', 2); 6. return 0; 7. } 8. 9. void showchar(char a, char b) 10. { 11. *(char far *)(0xb8000000 + 160 * 10 + 80) = a; 12. *(char far *)(0xb8000000 + 160 * 10 + 81) = b; 13. } |
在TC++3.0 下编译,链接a.c,在指定目录下生成TCDEF.EXE。
用debug调试器查看TCDEF.EXE的汇编代码,找出以下两个问题的答案:main函数如何给showchar传递参数?showchar是如何接收参数的?用debug跟踪TCDEF.EXE程序,得到main和showchar的函数栈帧如下:
Figure19. a.c的函数栈帧
分析一个不定参数的程序b.c:
Table 11. b.c
1. void showchar(int, int, ...); 2. 3. int main(void) 4. { 5. showchar(4, 2, 'a', 'b', 'c', 'd'); 6. return 0; 7. } 8. 9. void showchar(int n, int color, ...) 10. { 11. int a; 12. 13. for(a = 0; a != n; a++) 14. { 15. *(char far *)(0xb8000000 + 160 * 10 + 80 + a + a) = *(int *)(_BP + 8 + a + a); 16. *(char far *)(0xb8000000 + 160 * 10 + 80 + a + a) = color; 17. } 18. } |
showchar函数是如何知道要显示多少个字符的?在TC++3.0中,支持不定参数的表达方式为….。showchar根据第一个参数n的值来判断不定参数的个数,然后准确的将存储在父函数栈内的实参取出使用。
根据函数栈帧可以看出:
(1) main函数通过自己的栈空间给showchar传递参数。并且TC++3.0遵循从右到左的顺序传递参数。
(2)showchar通过bp寄存器到main函数的栈空间内取参数。并且TC++3.0先取左边的参数。
那么printf是如何知道有多少个参数的?如果按照b.c思维方式,那么在printf函数内部的代码会先数printf函数第一个指针参数所指的字符串内像“%c,%d”之类的类型匹配符的个数,再按照b.c中使用_BP+ sizeof(int *) + 2 + 2起取参数。
[3] 实现一个简单的printf
实现一个简单的printf函数,只需要支持“%c”即可。十分粗鲁的实现simple_printf如下:
Table 12. simple_printf.c
1. int simple_printf(const char *, ...); 2. 3. int main(void) 4. { simple_printf("%c %c %c %c %c %c", 'a', 'b', 'c', 'd', 'e', 'f'); 5. getchar(); 6. return 0; 7. } 8. 9. int simple_printf(const char *s, ...) 10. { 11. int i, n; 12. const char *pt; 13. 14. if (0 == s) return 1; 15. 16. i = 0; 17. n = 0; 18. pt = s; 19. 20. for ( ; pt[i + 1] != 0; i++) { 21. if (pt[i] == '%' && (pt[i + 1] == 'c' || pt[i + 1] == 'd')) 22. ++n; 23. } 24. 25. for (i = 0; i < n; ++i) { 26. *(char far *)(0xb8000000 + i + i) = *(int *)(_BP + 2 + 2 + 2 + i + i); 27. *(char far *)(0xb8000000 + 1 + i + i) = 4; 28. } 29. 30. return 0; 31. } |
其中“*(int*)(_BP + 2 + 2 + 2 + i + i);”三个2的含义分别为:CALL指令引起的IP入栈所占的2字节栈内存;BP入栈所占的2字节栈内存;simple_printf第一个指针参数所占的2字节栈内存。
此simple_printf只能输出多个“%c”类型匹配符对应的字符(红色,无回车等其它功能)。以上程序的运行结果为:
Figure20. 简单printf运行结果
Intel自80386及后续处理器都可以在以下3个模式下工作。
(1) 实模式:工作方式相当于一个8086。
(2) 保护模式:提供支持多任务环境的工作方式,建立保护机制
(3) 虚拟8086模式:可从保护模式切换至其中的一种8086工作方式。这种方式的提供使用可以方便地在保护模式下运行一个或多个原8086程序。
任何一台使用Intel系列CPU的PC只要一开机,CPU就工作在实模式下。如果你的机器装的是DOS,那么在DOS加载后CPU仍以实模式工作。如果你的机器装的是Windows,那么Windows加载后,将由Windows将CPU切换到保护模式下工作,因为Windows是多任务系统,它必须在保护模式下运行。如果你在Windows中运行一个DOS下的程序,那么Windows将CPU切换到虚拟8086下运行该程序。或者是这样,你点击开始菜单在程序项中进入MS-DOS方式,这时,Windows也将CPU切换到虚拟8086模式下运行。《汇编语言》第三版– 王爽。