Windows、Linux等现代操作系统都运行于CPU的保护模式下。学习保护模式的汇编语言编程,要选用合适的编译、调试工具,编译工具决定了汇编程序的语法、结构,而调试工具则能够帮助我们迅速查找程序中的错误,提高调试效率。
本实验指导书采用Microsoft公司的MASM 6.14作为编译工具,Microsoft Visual C/C++作为开发调试环境。
1.1 汇编程序结构
和其他语言一样,汇编语言的源程序也要符合一定的格式,才能被编译程序所识别和处理。学习和掌握这些格式,是进行汇编编程的第一步。
1.1.1 一个显示字符串的汇编程序
下面是一个简单的汇编程序。
;程序清单:test.asm(在控制台上显示一个字符串)
.386
.model flat, stdcall
option casemap:none
; 说明程序中用到的库、函数原型和常量
includelib msvcrt.lib
printf PROTO C :ptr sbyte, :vararg
; 数据区
.data
szMsg byte “Hello World!”, 0ah, 0
; 代码区
.code
start:
mov eax, OFFSET szMsg
invoke printf, eax
ret
end start
1.1.2 程序格式
在源程序test.asm中,以分号(;)开始的行是注释行。注释行对程序的编译和执行没有影响。
一、模式定义
程序的第一部分是有关模式定义的3条语句:
.386
.model flat, stdcall
option casemap:none
这些语句定义了程序使用的指令集、工作模式。
(1)指令集
.386语句是汇编语言的伪指令,说明本程序使用的指令集是哪一种CPU的。还可以使用:.8086、.186、.286、.386、.386p、.486、.486p、.586、.586p等。
后面带p的伪指令则表示程序中可以使用特权指令。
(2)工作模式
.model语句用来定义程序工作的模式,它的格式是:
.model 内存模式[, 调用规则][, 其他模式]
内存模式的定义影响最后生成的可执行文件,可执行文件的规模可以有很多种类型,在Windows环境下,内存模式为flat,可执行文件最大可以用4 GB内存。
在test.asm中,.model语句指明使用stdcall调用规则。调用规则就是子程序的调用方式,即调用子程序时参数传递的次序和堆栈平衡的方法。
(3). option语句
option语句有许多选项,这里介绍一种:
option casemap:none
这条语句说明程序中的变量和子程序名是否对大小写敏感。对大小写敏感表示区分大写、小写形式,例如变量XYZ和xyz是两个不同的变量。对大小写不敏感则不区分大写、小写形式,变量XYZ和xyz是同一个变量。
由于Windows API函数中的函数名称是区分大小写的,所以应该指定这个选项“casemap:none”,否则在调用函数的时候会出现问题。
二、includelib语句
和C程序一样,在汇编程序中也需要调用一些外部模块(子程序/函数)来完成部分功能。例如,在hello.asm中,就需要调用printf函数将字符串显示在屏幕上。
printf函数属于C语言的库函数。它的执行代码放在一个动态链接库DLL(dynamic-load library)中,这个动态库的名字叫msvcrt.dll。
在汇编源程序中,需要用includelib语句指出库文件的名称,链接时LINK就从库文件中找出了函数的位置,避免出现上面的错误提示。这种库文件也叫导入库(import library)。例如:
includelib msvcrt.lib
一个DLL文件对应一个导入库,如msvcrt.dll的导入库是msvcrt.lib;kernel32.dll的导入库是kernel32.lib;user32.dll的导入库是user32.lib等。导入库文件在Visual C/C++的库文件目录中,在链接生成可执行文件时使用。
可执行文件执行时,只需要DLL文件,不需要导入库。
三、函数声明语句
对于所有要用到的库函数(或Windows API函数),在程序的开始部分必须预先声明。包括函数的名称、参数的类型等,如:
在汇编语言程序中,函数声明为:
函数名称 PROTO [调用规则] :[第一个参数类型] [,:后续参数类型]
其中,PROTO后的调用规则是可选项。如果不写,则使用model语句中指定的调用规则。
如果函数使用C调用规则,则PROTO后跟一个C。接下来是参数的说明。如果参数个数、类型不定,则用VARARG说明(varible argument)。
先看在C语言头文件stdio.h中printf的函数声明:
_CRTIMP int __cdecl printf(const char *, ...);
可知printf函数的调用规则为C调用规则(__cdecl, 即c declare),第一个参数是字符串指针,后面的参数数量及类型不定。
这里,用ptr sbyte代表const char *。
printf PROTO C :ptr sbyte,:vararg
四、数据和代码部分
程序中的数据部分和代码部分是分开定义的,数据部分从这一行开始:
.data
代码部分从这一行开始:
.code
遇到end语句时,代码部分结束。
end语句一般是整个程序的最后一条语句。end语句后面跟的是起始标号。它指出了程序执行的第一条指令的位置。在例子中,使用start作为起始标号,程序从start处开始执行。注意,程序并不一定要从代码部分的第一行开始执行。例如,start前面可以写一些子程序等。
end 起始标号
如果要定义堆栈部分,可以使用堆栈定义语句:
.stack [堆栈大小]
1.2 Visual C/C++环境
Microsoft Visual C/C++(简称VC)是一个典型的集成开发环境(IDE,integrated development environment),在国内外十分流行。集成开发环境大大地提高了程序开发过程的效率,而且它还能够动态地调试程序。除了可以编写调试C/C++程序外,VC还可以用来编辑、修改、编译、调试汇编程序。本书使用的版本是Microsoft Visual C/C++ 6.0。
1.2.1 建立工程
首先,按照以下步骤建立一个能编译、调试汇编程序的工程:
(1) 启动VC后,从菜单中选择“File”→“New”。
(2) 如图1-1所示,在打开的“New”对话框顶部,单击“Projects”,再选中“Win32 Console Application”。在Location编辑框中输入“c:"asm”,再在“Project name”中输入“test”。输入“test”时,它自动地添加到Location编辑框中“c:"asm”的后面。
图1-1 建立汇编程序工程之一
(3) 单击“OK”键后,出现一个新的对话框,单击“Finish”。
(4) 接下来,VC的窗口的左边显示出“test classes”,下面有“ClassView”和“FileView”两种视图,如图1-2所示。
(5) 这时,可将hellow.asm(或其他的一个.asm源程序文件)复制到c:"asm"test中,并改名为test.asm;也可以将其他的汇编程序源文件复制到c:"asm"test"test.asm。
图1-2 建立汇编程序工程之二
(6) 接下来,再从菜单中选择“Project”→“Add to Projects”→“Files”,在该对话框中的文件名处输入“c:"asm"test"test.asm”,如图1-3所示。
图1-3 建立汇编程序工程之三
(7) 在VC窗口左边的视图中,展开“FileView”中的“Source Files”,显示出“test.asm”。在“test.asm”上,单击鼠标右键,出现如图1-4所示的菜单。
图1-4 建立汇编程序工程之四
(8) 在菜单中选择“Setting”。弹出另一个对话框,如图1-5所示。在“Commands”编辑框中输入“ml /c /coff /Zi test.asm”,在“Outputs”编辑框中输入“test.obj”。再单击“OK”。
图1-5 建立汇编程序工程之五
(9) 最后,再将“ML.EXE”和“ML.ERR”两个文件复制到“c:"windows”。如果Windows安装到其他目录,则需要把这两个文件复制到相应的目录。可用“set windir”命令显示出Windows的安装目录。
(10)最后,验证是否能在VC中编译test.asm。在VC中按F7键,应该自动编译生成test.exe。如果源程序中有错误,编译后将错误信息显示在“Output”的“Build”视图中。点击该错误信息,光标自动定位到出现错误的程序行(也可以按F4键定位到错误的程序行)。
为了使VC适合于汇编语言的调试,可对它进行如下设置,如图1-6所示。
(11)从“Tools”菜单中选择“Options…”,再选择“Debug”页,选中“Disassembly window”中的“Code bytes”(前面打上对勾)。
(12)在“Memory window”中,选中“Fixed width”,在后面填入数字16。
(13)在“General”中,选中“Hexdecimal display”。
(14)不选“View floating point registers”。
图1-6 VC的调试设置选项
程序编译成功后,按Ctrl+F5可以运行已编译好的程序。
1.2.2 汇编程序的调试
一、设置断点
如果程序运行的结果不正确,可以在VC中调试。单击“FileView”视图中的test.asm,这个源程序就会自动地进入VC的编辑窗口。
将光标移动到程序入口所在的程序行上,按F9键。就在该行设置了一个断点。断点的程序行前有一个红色的小圆点,如图1-7所示。
按F5键在Debug状态下执行程序,或者从菜单中选择“Build”→“Start Debug”→“Go”。
这时,当前窗口显示出程序中的指令序列,有一个黄色箭头,它就是程序要执行的下一条指令,如图1-8所示。
二、内存窗口
从菜单中选择“View”→“Debug Windows”→“Memory”,打开内存窗口,在地址“Address:”后面的编辑框中可以输入内存变量的名称,这里输入szTitle。内存窗口中就显示出该变量所在内存单元的值。前面的部分是以十六进制的形式显示出来的,后面是以ASCII字符的形式显示出来的。
在内存窗口上单击鼠标右键,可以选择:按字节、字、双字显示内存单元的值。
三、寄存器窗口
从菜单中选择“View”→“Debug Windows”→“Registers”,打开寄存器窗口。在寄存器窗口中,显示了各个32位寄存器和段寄存器的值。
在调试程序时,如果某一个寄存器或内存单元的值被改变,则它的值用红色显示出来。
在寄存器窗口中的最后一行,显示的内存单元就是当前指令要读或写的操作数。
EFLAGS状态寄存器的值是按位显示的。但是,VC并没有使用我们所熟悉的OF、DF、IF、SF、ZF、AF、PF、CF名称,而是用它自己的一套名称,如表1-1所示。
表1-1 VC中的EFLAGS标志位
VC格式 |
OV |
UP |
EI |
PL |
ZR |
AC |
PE |
CY |
FLAGS位 |
OF |
DF |
IF |
SF |
ZF |
AF |
PF |
CF |
含义 |
溢出 |
方向 |
中断允许 |
符号 |
为零 |
辅助进位 |
奇偶 |
进位 |
例如UP=0表示DF=0。
四、监视窗口
从菜单中选择“View”→“Debug Windows”→“Watch”,打开监视窗口。在“Name”一栏下面,可以输入想要监视的变量或寄存器名称。监视窗口会随时将这些变量的值显示出来。
要在调试过程中改动寄存器或内存变量的值,可以在Watch窗口的该寄存器或变量的内容(在Value列)用鼠标左键单击,修改其值后,按回车键即可。
也可以在内存窗口中修改变量的值。在要修改的内存单元上点击,直接输入新的内容即可。
另外一种方法是按Shift+F9。弹出对话框后,在“Expression”处输入寄存器或内存变量的名称,再在下面的Value一列处修改其内容。最后,按“OK”。
图1-7 编辑、编译汇编源程序并设置断点
图1-8中为打开内存窗口、监视窗口和寄存器窗口后的屏幕显示。
调试过程中,编辑窗口中显示出汇编源程序。如果要查看程序的实际执行代码,从菜单中选择“View”→“Debug Windows”→“Disassmebly”。在运行过程中,实际上运行的是机器代码,而不是汇编源程序。机器代码及其反汇编的指令和源程序混合显示在编辑窗口中。反汇编中的程序地址和指令中的数据都是用十六进制显示的。在调试过程中,使用十六进制来表示地址和(变量或寄存器的)数值更方便。
按F10键可一步一步地执行程序。执行过程中,可以在内存窗口中观察变量的变化;在寄存器窗口中可以看到寄存器的变化;更加方便的是,可以把鼠标移动到编辑窗口中的寄存器或变量上,停留几秒钟后,VC会自动地显示它们的值。
按Shift+F5键,可结束调试。
图1-8 VC调试环境:编辑窗口、内存窗口、监视窗口和寄存器窗口
1.2.3 常用调试命令
常用的调试命令如表1-2所示。
表1-2 VC的常用调试命令
功能键 |
作 用 |
描 述 |
F11 |
单步执行 |
Step Into |
F10 |
执行 |
Step Over |
Ctrl+F10 |
执行到当前光标的位置的指令 |
Run to Cursor |
F9 |
在当前光标的位置的指令上设置/清除断点 |
Set/Clear Breakpoint at Cursor |
F5 |
执行程序 |
Go |
Shift+F5 |
终止程序,退出程序 |
Stop Debugging |
设置当前指令 |
将光标处的指令设为当前指令 |
Set Next Statement |
Shift+F11 |
当前子程序执行结束 |
Step Out |
l F11:单步执行当前指令。当前指令在反汇编窗口中用一个黄色箭头指示,CS:EIP指向当前指令。按F11键后,当前指令执行,黄色箭头和EIP随之变化,指向新的当前指令。
l F10:执行当前进程指令。F10和F11在执行一般指令时没有区别。在当前指令是一条CALL、INT指令的情况下有所区别。如果当前指令是CALL指令,按F11后,进入到子程序的第一条指令,子程序执行前就进入调试状态,可调试子程序的执行过程;按F10后,子程序执行完毕后才回到调试状态,不需要调试子程序的执行过程。
l Ctrl+F10:先把光标移动到一条指令上,可以用键盘上的上、下箭头移动光标,或者在某一行上点击。再按Ctrl+F10,程序就从当前指令处开始执行,一直到光标处的指令再停下来。
l F9:先把光标移动到一条指令上,按F9,就在该指令上设置了一个断点。再按F9,这个断点就清除了。设置断点后,指令的前面标有一个红色的圆点。程序运行到断点时,会停下来,这时就可以检查各个变量、寄存器的内容以及程序的执行流程是否正确,以查找程序中的错误。
l F5:从当前指令开始执行程序,直到遇到断点或程序结束时为止。
l Shift+F5:终止程序,不再执行后面的程序。终止后,可以再按F11键(或Ctrl+Shift+F5)重新开始调试过程。
l 设置当前指令:在调试时,可能希望跳过一部分程序不执行,也可能想将已执行过的一段程序再执行一遍。这可以通过改变当前指令来实现。在新的当前指令上按下鼠标右键,弹出一个菜单,在其中选择“Set Next Statement”。这时,黄色箭头就指到新设置的当前指令上。
l Shift+F11:先按住Shift键,再按下F11。当前指令在子程序中时,如果想使整个子程序执行完毕,返回到主程序,则使用Shift+F11。
某些功能也可以从“Debug”菜单中选择。如图1-9所示。
图1-9 VC的部分Debug菜单项
1.3 字符串输入、输出
在C语言中,常用printf、scanf、sprintf等函数来实现字符串的的输入输出,在汇编语言中,可以调用这些函数。
1. printf
在前面的程序例子中已经用到过printf。在程序中,要指明printf的调用规则,以及它的参数类型。
printf PROTO C :dword,:vararg
printf使用C调用规则(参数自右至左入栈,由主程序平衡堆栈)。第1个参数是一个双字(:dword),即字符串的地址,后面的其他参数个数可变,可以1个没有,也可以跟多个参数。
以下C语句输出3个整数A、B、R和一个字符Op:
printf ("%d %c %d = %d"n" , A, Op, B, R);
在汇编语言中,在数据区中要定义szOutputFmtStr:
szOutputFmtStr byte '%d %c %d = %d', 0ah, 0
使用invoke语句调用printf。Printf后面跟的第1个参数是格式字符串的地址;第2、3、4个参数分别是要输出的整数。
invoke printf, offset szOutputFmtStr, A, Op, B, R
在程序中,还需要包括以下语句,指示链接程序在msvcrt.lib库文件寻找链接信息。
includelib msvcrt.lib
2. sprintf
sprintf与printf相似,它将输出保存在第1个字符串szStr中。
invoke sprintf, offset szStr, offset szOutputFmtStr, A, Op, B, R
3. scanf
scanf是从控制台将用户的输入读入到程序的变量中,变量的类型可以是整数、字符、字符串等。
scanf的调用规则和参数类型说明为:
scanf PROTO C :dword,:vararg
scanf的链接信息也包括在msvcrt.lib库文件中。
程序中需要输入A、Op、B时,A、B是整数,OP是字符。它的第1个参数是格式字符串的地址;第2、3、4个参数分别是A、Op、B的地址。
szInputFmtStr byte '%d %c %d', 0
invoke scanf,offset szInputFmtStr,offset A,offset Op,offset B
其效果等价于:
scanf("%d %c %d", &A, &Op, &B);
下面的程序首先调用printf显示字符串,提示用户输入要计算的表达式,再调用scanf接收用户的输入。根据输入的运算符Op,通过条件跳转指令实现对加、减、乘、除的判断和处理。最后,调用printf输出计算结果。其执行结果为:
input (a Op b, Op=+-/*, ex: 28-2): 4*5
4 * 5 = 20
;程序清单:equation.asm(四则运算)
.386
.model flat,stdcall
Option casemap:none
includelib msvcrt.lib
scanf PROTO C :dword,:vararg
printf PROTO C :dword,:vararg
.data
szInPmt byte 'input (a Op b, Op=+-/*, ex: 28-2): ', 0 ;
szInputFmtStr byte '%d %c %d', 0
szOutputFmtStr byte '%d %c %d = %d', 0ah, 0
szErrMsg byte 'invalid Operation. ', 0ah, 0
A sdword ?
B sdword ?
Op dword ?
R sdword ?
.code
start:
invoke printf, offset szInPmt
invoke scanf, offset szInputFmtStr,
offset A,
offset Op,
offset B
mov eax, A
mov ebx, B
cmp Op, '+'
jz lab_add
cmp Op, '-'
jz lab_sub
cmp Op, '*'
jz lab_mul
cmp Op, '/'
jz lab_div
lab_err:
invoke printf, offset szErrMsg
jmp lab_exit
lab_add:
add eax, ebx
mov R, eax
jmp lab_output
lab_sub:
sub eax, ebx
mov R, eax
jmp lab_output
lab_mul:
imul eax, ebx
mov R, eax
jmp lab_output
lab_div:
cdq
idiv ebx
mov R, eax
jmp lab_output
lab_output:
invoke printf, offset szOutputFmtStr, A, Op, B, R
lab_exit:
ret
end start
equation.asm在C:"asm"sample"chap-01目录中,编译连接的步骤为:
(1) 进入C:"asm"sample"chap-01目录
cd C:"asm"sample"chap-01
(2) 设置编译环境
c:"asm"bin"asmvars.bat
(3) 编译连接
ml /coff equation.asm /link /subsystem:console
1.4 常用Windows API调用
API是application programming interface的缩写,代表应用程序编程接口。API一般使用stdcall调用规则。
1. MessageBox
printf和scanf适用于控制台程序(链接选项为/subsystem:console),而带窗口的Windows程序(链接选项为/subsystem:windows)不能使用printf和scanf。这里介绍一个输出信息用的API-MessageBox。
它的调用规则和参数类型说明为:
MessageBoxA PROTO :DWORD,:DWORD,:DWORD,:DWORD
MessageBoxA的链接信息包括在user32.lib库文件中。
MessageBoxA的C语言原型在VC附带的"winuser.h"中提供。
2. 确定函数的声明语句和库文件
在编程中,应尽可能地利用已有的C的库函数和Windows API函数,以减少编程的工作量。
通过查阅在MSDN(或VC等工具)资料和帮助文件、阅读示例程序等方法,弄清函数的功能以及入口、出口参数,以及每一个参数的用法。根据函数的名称、参数的个数、类型、调用规则等,写出这样的声明语句:
printf PROTO C :dword,:vararg
再确定它属于哪一个库文件。常用的库文件有:msvcrt.lib、kernel32.lib、user32.lib等。
函数可能返回的是一个整数、指针或其他类型。无论如何,返回值都在EAX中。要注意有些函数是通过传递地址指针的方式来改变参数的值,如scanf。
1.5 读取CPU标识
CPUID指令是获得CPU信息的汇编指令,它可以返回CPU类型、型号、制造商信息、商标信息、序列号、缓存等CPU相关信息。
CPUID指令使用eax作为输入参数,eax、ebx、ecx、edx作为输出参数。例如:
mov eax, 0
cpuid
执行结果为:EAX = 00000002 EBX = 756E6547 ECX = 6C65746E EDX = 49656E69。结果以十六进制显示。EBX、ECX、EDX中各存储4个字符,全部12个字符为:GenuineIntel。
使用eax=3作为输入参数时,在ECX、EDX中返回CPU序列号的第0~31位、第32~63位。
mov eax, 0
cpuid
;程序清单:cpuid.asm(读取CPU标识)
.586
.model flat,stdcall
Option casemap:none
includelib msvcrt.lib
printf PROTO C :dword,:vararg
.data
szVendorID byte 13 dup (0)
szFormatStr byte 'VendorID = %s; Processor SN = %08X%08X', 0ah
.code
start:
mov eax, 0
cpuid
mov dword ptr szVendorID, ebx
mov dword ptr szVendorID+4, edx
mov dword ptr szVendorID+8, ecx
mov eax, 3
cpuid
invoke printf, offset szFormatStr,
offset szVendorID, ecx, edx
ret
end start
程序运行结果如下所示:
VendorID = GenuineIntel; Processor SN = 00000000007B7040
1.6 WinDbg调试工具
WinDbg是微软公司发布的免费源码级调试工具。Windbg可以用于Kernel模式调试和用户模式调试,还可以调试Dump文件。
使用WinDbg调试汇编程序的主要步骤为:
1.首先,使用下面的命令编译、链接,产生.EXE文件。/coff选项要求MASM生成链接器所需要的COFF格式的.obj文件,/Zi则指定编译生成的目标文件中含有调试信息,/link表示要生成.EXE文件,而/subsystem:console则表示生成控制台程序。
ml /coff /Zi cpuid.asm /link /subsystem:console
2.启动WinDbg,从菜单File→Open Executable装入.EXE文件。
3.在WinDbg命令窗口的最下方,可输入命令,按回车键执行。如:
x cpuid!
在窗口内显示出cpuid.exe内的所有数据变量:
0:000> x cpuid!
*** WARNING: Unable to verify checksum for cpuid.exe
00404000 cpuid!szVendorID = 0x00 ''
0040400d cpuid!szFormatStr = 0x56 'V'
4.输入u命令,可以查看汇编指令:
0:000> u start l e
cpuid!start [cpuid.asm @ 11]:
00401010 b800000000 mov eax,0
00401015 0fa2 cpuid
00401017 891d00404000 mov dword ptr [cpuid!szVendorID (00404000)],ebx
0040101d 891504404000 mov dword ptr [cpuid!szVendorID+0x4 (00404004)],edx
00401023 890d08404000 mov dword ptr [cpuid!szVendorID+0x8 (00404008)],ecx
00401029 b803000000 mov eax,3
0040102e 0fa2 cpuid
00401030 52 push edx
00401031 51 push ecx
00401032 6800404000 push offset cpuid!szVendorID (00404000)
00401037 680d404000 push offset cpuid!szFormatStr (0040400d)
0040103c e811000000 call cpuid!printf (00401052)
00401041 83c410 add esp,10h
00401044 c3 ret
5.输入bp命令,可以设置断点,例如:
bp start
6.从菜单中选择Debug→Go,或者按F5键,可以执行到断点处。
7.从菜单中选择View→Disassembly,可以看到断点处的指令以高亮方式显示。
8.从菜单中选择View→Register,打开一个窗口,显示寄存器的当前值。
9.在WinDbg命令窗口输入r命令,也可以显示寄存器的当前值。还可以修改寄存器的值,如“r eax=5”。
10.在WinDbg命令窗口输入输入d命令,可以查看内存内容,如:“d szVendorID L c”。将d换为db、dw、dd,则指定用字节、字、双字的格式查看。L后面跟的数字则表示查看的单元个数。
11.按F11、F10、Shift+F11、Ctrl+F10分别表示单步执行当前指令、执行完毕当前指令、执行完当前函数、执行到光标处,其用法与表1-2相同。
1.7 实验题:用MessageBox函数显示CPU信息
cpuid.asm采用的是控制台模式(链接选项为/subsystem:console),调用printf输出信息。
要求:
1. 使用VC 6.0建立工程文件;
2. 采用带窗口的Windows程序(链接选项为/subsystem:windows);
3. 使用sprintf 、MessageBoxA函数;
4. 输出制造商信息、序列号;
5. 输出CPU商标信息,执行CPUID的输入参数为80000002H、80000003H、80000004H,具体格式可查阅CPU指令手册。
运行结果如图1-10所示: