第七章 写16位代码(DOS,Windows3/3.1)
这章主要介绍写MS-DOS或Windows3.x下运行的16位代码是遇到的方法.主要说明如何将程序连接成.EXE或.COM
文件,如何写.SYS设备驱动程序,如何提供汇编语言和16位C编译器和Borland Pascal之间的接口.
7.1 生成.EXE文件
任何在DOS下运行的大型程序都在做为一个.EXE文件来构造:只有.EXE文件需要跨过64K段的内部结构.Windows
程序由于不支持.COM格式也必须构造成.EXE文件.通常你生成.EXE文件时用obj输出格式生成一个或多个obj文件.
然后用连接器将它们连到一起.然而,NASM也可以生成用于bin格式的简单的DOS.EXE文件(用DB和DW来构造.EXE
文件头).和一个支持这个的宏压缩包.感谢Yann Guidon对这个代码的描述.
NASM也支持本了的.EXE文件,做为将来版本的另一种输出格式.
7.1.1 用obj格式生成的.EXE文件
这一段描述了通过.OBJ文件连接成.EXE文件的方法.16位程序语言是用一个适合的的连接器压缩的;如果把你没有
话,这有一个叫VAL的自由连接器,来自x2ftp.oulu.,fi的LZH文档格式.一个LZH文档可以在找到.这还有
一个叫FREELINK的\'free\'连接器(虽然它不带源码),可以从www.pcorner.com上找到,另外还有DJ Delori写的djlink可
以在www.delorie.com上找到.当将几个.OBJ文件连接进一个.EXE文件中时,你应该保证这些obj文件中的一个为程序
定义了开始.(用..start指定符定义,见第6.2.6节)如果没有模块定义起始点时,则连接器将不知道输出文件头中入口
给定的值;如果定义过多的起始点,连接器也不知道会用哪一个.这里给出呈个用NASM源码编译成.OBJ的文件和连
接器连接成.EXE文件的例子.这解释了定义一个堆栈,初始化段寄存器,定义开始点的基本规则,这个文件也包含在
NASM文档中的test子目录中,名字为objexe.asm:
segment code
..start: mov ax,data
mov ds,ax
mov ss,ax
mov sp,stacktop
初始化部分将DS设为数据段,并将SS和SP初始为提供堆栈的顶部.注意将一个指定移入SS时,将禁止中断,所以在
SS和SP间取数时和没有堆栈执行时将不会生成堆栈.指定符..start定义了这个代码的开始点,这也是说这个点将为
结果执行文件的输入点.
mov dx,hello
mov ah,09
int 0x21
上面的主程序:将一个欢迎信息指针将入DS:DX(hello是一个隐式相关的段数据,将在设置代码中送入DS中,所以指
针是有效的)并调用DOS的显示字串命令.
mov ax,0x4c00
int 0x21
这将用另一个DOS系统调用终止程序.
segment data
hell db \'hello,world\',13,10,\'$\'
数据段包含了我们想要显示的字串.
segment stack stack
resb 64
stacktop:
上面的代码定义了一个包含64个字节非初始化的堆栈段,然后将指针移到它的顶端.堆栈段定向符定义了一个名
字为stack的堆栈,类型为STACK.对于正确运行程序后者不需要,但如果你的程序没有STACK类型的段则连接器将发
出一个警告信息.将上面的文件编译成.obj文件并连接成一个有效的.exe文件,运行这个文件时会显示\'hello,world\'
信息并退出.
7.1.2用bin格式生成.exe文件
.exe文件格式的简单在于:它可以通过写一个纯的二进制程序并粘在一个32字节头后面来构造一个.exe文件.这个
头可以简单的用NASM的DB,DW命令来生成,所以你可以用一个bin的输出格式来直接生成.exe文件.
在NASM的文档中的misc目录下有一个叫exebin.mac的宏文件.它定义了三个宏:EXE_begin,EXE_stack和EXE_end.
为了用上面的方法生成一个.EXE文件,你可以在你的源码文件中的开始处用%include包含一文件exebin.mac.然后
你就可以用宏EXE_begin(无参数)来生成文件头数据.为通用的bin格式写代码-你可以用三个标准段:.text,.data和
.bss.在文件尾你应该用EXE_end宏(也不带参数)来定义一些标记段尺寸的符号,这些符在用EXE_begin生成的头
代码中被引用.在这种模式下,你可以结束0x100处开始写代码,就象一个.com文件,事实上,如果你除去.exe文件32
字节的头,你将会得到一个有效的.com程序.所有段基址都是相同的,所以你可以限制到一个64k的程序就象一个
.com文件一样.注意一个ORG定向符被EXE_begin宏调用,所以你不应该用显式的调用.
你不能直接引用你的段基址,这将在头中需要一个重定位,这样事情将变得复杂.所以你将CS拷贝出来的方法得到
段基址.对于你的.EXE文件一个入口,SS:SP将会设置一个2K堆栈的顶部.你可以通过调用一个EXE_stack宏来调整
2KB的默认尺寸.例如,要将你程序的堆栈尺寸改变到64字节,则可以用EXE_stack 64.一个生成.EXE文件的例子将
NASM文档的子目录test中做为binexe.asm给出.
7.2 生成.COM文件
写一个大型的DOS程序必须写成.EXE格式的,小型的则可以写成.COM格式的..COM文件是纯二进制的,并很容易用
bin格式生成它.
7.2.1 用bin格式生成.COM文件
.COM文件通常在偏100h位置将入(虽然段可以改变).例如在程序右边的100h开始执行.所以写一个.COM程序,你应该
用下面的源码:
org 100h
section .text
start: ;将代码放到这
section .data
;将数据项放到这
section .bss
;将非初始化数据放到这
bin格式将.text段放在文件中最前面,所以你可以在要写的代码前定义数据或BSS项并代码将在它属于的文件前结
事.BSS(未初始化数据)段不在一个.COM文件中占用空间:相反BSS项的地址将用一个超过文件未尾的指针来解决.
然而你不能在你运行时相信BSS段被初始化为全0.如果编译上面的程序,你应该用这样的命令行参数:
nasm myprog.asm -fbin -o myprog.com
bin格式在没有显式输出文件名指定的情况下将生成一个叫myprog的文件,所你必须重载它并给出一个指定文件名
7.2.2 用obj格式生成.COM文件
如果你用.COM程序写了很多模块,你可以希望将几个.OBJ文件编译并连接成一个.COM程序,你可以这样做:直接提供你一个输出.COM的兼容连接器(TLINK这样做),或用一个象EXE2BIN的转换程序来使连接器将.EXE文件转成.COM
文件.如果你这样做,你需要注意以下事情:
第一个包含开始执行的代码段应该用一行RESB 100h的语句.这可以保证代码会在偏移100h开始,所以连接或转换器在生成.COM文件时将不必调整文件中的引用地址 .其它的编译器用一个ORG定向符来达到这个目的.
但在NASM中的ORG是一个指定bin输出格式的定向符,这不意味着它不能做与MSAM兼容编译所做的事.
你不必定义一个堆栈段.所有你定义的段都应该在一个相同的段中,所以你的代码或数据每次引用一个符号偏移时
,所有偏移都是对相同的段基址的偏移.这是因为一个.COM文件被取出时,所有段寄存器都包含相同的值.
7.3 生成.SYS文件
MS-DOS设备驱动程序-.SYS文件-是纯二进制文件,与.COM文件相似,除了它们是在初始0开始而不是100h.然而,如果
你用bin格式写了一个设备驱动程序,你不需要ORG定向符,因为对bin默认开始点为0.相似的,如果你用一个obj,你
不必在代码段开始处用RESB 100h..SYS文件用一个头结构开始,包含工作驱动程序里指向不同的例程.这个结构在
代码段的开始处定义,虽然这不是真正的代码.关于.SYS文件更多的信息和头结构中的数据,一本常见问题集将在
新闻组 comp.os.msdos.programmer上找到.
7.4 对于16位C程序的接口
这段说明了写关于调用或被调用 C程序的汇编程序的基本规则.为了做到这点,你应该写一个象.OBJ文件的典型汇
编模块,然后用它连接到你的C模块来生成一个混和语言程序。
7.4.1 外部符号名字
C编译器的常规为:所有定义的全部符号名字(函数或数据)用一个下划线加上名字来在C程序中出现。所以,一个
C程序员想象在汇编语言程序的printf函数将为_printf。这意味在你的汇编程序中,你可以定义一个不带下划线的
外部符号并不必和C符号冲突。如果你觉得下划线不方便,你可以定义宏来代替GLOBAL和EXTERN定向符:
%macro cglobal 1
global _%1
%define %1 _%1
%endmacro
%macro cextern 1
extern _%1
%define %1 _%1
%endmacro(这些宏格式一次只带一个参数;一个%rep构造可以解决这个。)
如果你定义一个外部符号如:
cextern printf
然后宏将扩展为:extern _printf
%define printf _printf
上面你可以象一个符号引用printf,预处理器将前导下划线符号放在需要的地方。cglobal宏也以相同的方式工作。你必须在定义一个符号前用cglobal,但你如果用GLOBAL便可以必须这样做。
7.4.2 内存模型
NASM不直接支持不同C内存模型;你必须跟踪你要写的哪一个。这意味着你必须做下面的事:
用一个单代码段的模式(tiny,small和compact),函数是near型的。这意味着将函数做为参数存入数据段或压入堆栈
时,函数指针将是一个16位并包含offset域(CS寄存器不会改变它的值,并给出函数全地址中的段部分),函数将
用序号near CALL指令调用并返回RETN(在NASM中等价与RET)。这就有两种相同的方法,你可以在代码里返回一
个RETN,也可以用near CALL指令调用外部C程序。
用多个代码段的模型(medium,large和huge),函数为far的,这就是说函数指针是一个32位长(包含一个16位的段
值加16位的偏移),函数用CALL FAR调用 (或CALL seg:offset)并返回RETF,并且用CALL FAR来调用外部程序。
在用一个单一数据段(tiny,small和medium)的模型中,数据指针为16位长的,只包含一个偏移域(DS寄存器不改变它
的值勤,并经常给出整个数据项地址的段部分。)
在用多个数据段的模型号中(compact,large和huge),数据指针是32位长,由一个16位的偏移和16位的段。你应该注
意不能修改你的程序,在后面没有存它时。但ES对你用来访问32位数据指针的内容是自由的。
大内存模式允许单数据项超过64K尺寸。在其它所有的数据模型中,你可以通过用给出的偏移值做算术运算来
访问整个数据项,一个段的值是否存在;在大模型中,你必须注意你的指针的数学运算。
在很多内存模型中,存在一个默认的数据段,整个段地址将保存在DS中在整个程序中。这个数据段做为堆栈
段保存在SS中,所以函数的本地变量(被存在堆栈中)和全局数据项存在其它段中不用改变DS可以简单被访问。
通常大数据项被存在其它段中。然而,有些内存模型(通常不为标准的)允许假定SS和DS保存了相同的值被移去。在后面例子中注意函数的本地变量。
在一个单代码段的模型中,段被定义为_TEXT,所以你的代码段必须用这个名字来连接到主代码段中相同的位置。在一个单数据段的模型中用一个默认的被叫_DATA的数据段。
7.4.3 函数定义和函数调用
下面列出16位程序中的C调用。在下面的描述中,调用和被调用 指调用函数和被调用函数。
调用将函数参数压入堆栈,在剩下的顺序中一个接一个(从右到左,所以第一个指定的参烽将最后被
压入堆栈)。
调用然后指行一个CALL指令来将控制传给出被调用者。
这个CALL为near还是far取决于内存模型。
被调用者接收控制后,(虽然这并不必须,在函中不用访问它们的参数)开始将SP存入BP中以便可以用BP做为一个基址来在堆栈中找它们的参数。然而,调用者也可以做这个,所以调用部分的常规状态是BP必须
对任何C函数为可保存的。因此对被调用者,如果它做为一个frame指针设置时,必须
首先将前一个值压入堆栈。被调用者可以访问它的参数来引用BP。在[BP]中的值保存BP压入堆栈前的值;下一
字在[BP+2],保存部分返回地址的偏移部分,并通过CALL来隐式压入堆栈。在一个小模式(near)的函数中,返回地
址的段部分将存在[BP+4]中,并参数存在[BP+6]中。函数的最左边参数,将被最后压入堆栈,在从BP开始的偏移
是可以访问的;后面的其它值将会比偏移在。在此,象printf这样的函数将带很多的参数,并将参数按相反顺序压
入堆栈意味着在那个位置找到第一个参数,然后告诉它剩下参数的数量和类型。被调用者可以减小SP的值,以便
为本地变量分配堆栈空间,将从BP开始访问负偏移.
被调用者,如果它希望返回一个值给调用者,应该将值保存在AL,AX或DX:AX中取决于值的尺寸。浮点数有时(
取决于编译)将在ST0中返回。一旦被调用者完成处理,它将从BP中恢复SP,如果它分配了本地空间,弹出BP以
前的值,并通过RETN或RETF返回,这取决于内存模型。当调用者重新从被调用者得到控制时,函数参数将依然
在堆栈上面,所以它将加一个立即常数到SP上来移除它们(取代执行一系列的POP指令)。因此如果一个函数被
意外的用参数的错误号调用会引起一个原型不匹配的错误,从被调用 者那堆栈仍会返回一个敏感的状态,使我
们知道这有多少个参数被压入堆栈并被移除。用这个调用规则与Pascal程序的相比较是有意义的(在第7.5.1节
描述)。Pascal有一个简单的规则:没有函数带有可变的参数。然而被调用知道它应传递多少个参数,并能从它
自己的堆栈中不分配它们用用一个立即参数来RET或RETF指令,所以调用者不必做这个。参数也是从左到右的
顺序压入堆栈的,而不是从右到左,这表示一个编译器可以很好的保证关于性能上的顺序。因此,你可以在后
面定义一个C形式的函数下面是一个small model的例子:
global _)myfunc
_myfunc: push bp
mov bp,sp
sub sp,0x40 ;64字节的本地堆栈空间
mov bx,[bp+4] ;函数的第一个参数
mov sp,bp
pop bp ;取消上面的"sub sp,0x40"
ret
对于large-model函数,你应该用RETF来代替RET,用[BP+6]来代替第一个参数[BP+4]。所以,如果一个参数为指
针,那么子参数的偏移将会因为内存模型来改变:far指针当做为一个参数被传递时会在堆栈上放4个字节,而near
指针则会放两个。在另一个处理的结束,将从你的汇编代码中调用一个C函数。你可以象下面所做的:
extern _printrf
;然后,更多的...
push word [myint] ;我的一个整型变量
push word mystring ;指向我的数据段
call _printf
add sp,byte 4 ;\'byte\'保存空间
;然后为数据项
segment _DATAQ
myint dw 1234
mystring db \'This number->%d<-should be 1234\',10,10
这个small-model汇编代码瑟下面的C代码等价:
int myint=1234
print("Thie number->%d<-should be 1234\\n",myint);
在large model中,函数调用代码看上去象这样,这个例子中假设DS已经为_DATA段的段基址,如果不是的话你
必须先初始化它:
push word [myint]
push word seg mystring ;现在压入段,并且...
push word mystring ;"mystring"的偏移
call far _print
add sp,byte 6
整数值将一个word放到堆栈上,这是因为large model对整数类型尺寸没有影响。第一个参数(最后压入的)将显示
,然而,如果为一个数据指针,则必须包含一个段和偏移部分。段将在内存中存在第二个位置上,但必须先压
入堆栈。(当然,PUSH DS 将为一个短型的指令而不是PUSH WORD SEG mystring,如果DS做为上面的例子被设置
时)这将变成一个far调用,因为函数将far调用 在large model;并且SP必须加6而不是4来构造另外的word参数。
7.4.4 访问数据项
为了得到C变量的内容,或定义C可以访问的变量,你只需用GLOBAL或EXTERN来定义它们.(名字需要用下划线来
引导,见第7.4.1节),因此从汇编中访问一个int i形式的C变量:
extern _i
mov ax,[_i]
为了定义一个C程序能象int j一样从外部访问,你可以这样做(确信你在一个_DATA段中汇编,如果需要):
global _j
_j dw 0
为了访问一个C的数组,你需要知道数组元素的尺寸.例如,int变量为2个字节长,所以一个C程序象int a[10]定义一个
数组,你可以用mov ax,[_a+6]来访问a[3].(字节偏移位置6由数组偏移3乘上数组元素的尺寸2得到).在16位的编译器
中C的基本类型尺寸为:char为1,short和int为2,long和float为4,double为8.为了访问一个C数据结构,你需要从知道你感
兴趣结构基位址中得到偏移.你也可以将C结构定义转换为NASM的结构定义(用STRUC)或象上面一样计算一个偏移
来做到这一步.为了做到上面这些,你应该读一个你的C编译器手册来找出它是如何组织数据结构的.NASM用它自己
STRUC宏不指定结构成员的偏移,所以如果C编译器产生偏移时,你必须在自己指定它.你可以发现如下的结构:
struct {
char c;
int i;
} foo;
为四个字节长而不是3个字节,因为int域为2字节对齐.然而这个特性在C编译器是可以配置的,或用命令行
参数#pargma,你也必须找出你自己的编译器是如何做的.
7.4.5 c16.mac:16位C接口的帮助宏
在NASM文档的misc目录下,有一个叫c16.mac的宏.它定义了三个宏:proc,arg和endproc.这些是为用C形式过程定义
的,在保存调用规则的轨迹方面它们会自动做很多工作.一个用宏来操作的汇编函数例子如下 :
proc _nearproc
%$i arg
%$j arg
mov ax,[bp+%$i]
mov bx,[bp+%$j]
add ax,[bx]
endproc
这里定义的_nearproc将为一个带2个参数的函数,第一个(i)为一个整数,第二个(j)为一个指向整数的指针.它将返回
i+*j.注意arg宏用了一个EQU做为表达式的第一行,因为在宏调用前的标号将会扩展第一行宏.EQU的工作是定义
%$i为从BP的一个偏移.一个上下文相关本地变量的使用:本地的上下文相关将审美观点proc压入堆栈,而用endproc
宏弹出堆栈 ,所以相同的参数名字可以在后面的过程中使用.当然你不必这样做.宏对于near函数设置了默认的
过程代码(tiny,small和compact-model代码),你可以用%define FARCODE来生成far函数( medium,large和huge-mode代
码).这将通过endproc改变return指令的类型,并改变参数开始点的偏移.宏设置包含在本质上不从属于数据指针是
近还是远.arg可以带一个可选的参数,给出参数的尺寸 .如果没给出尺寸便设为2,因为象许多函数参数一样为int类
型.large-model与上面相同:
%define FARCODE
proc_farpoc
%$i arg
%$j arg 4
mov ax,[bp+%$i]
mov bx,[bp+%$j]
mov es,[bp+%$j+2]
add ax,[bx]
endproc
因为j为一个远指针,所以arg宏的参数被定义成了尺寸为4的参数.当我们从j取数据时,我们必须取出一个段和一个
偏移.
7.5 对于Borland Pascal程序的接口
Borland Pascal程序的接口与16位C程序的接口相似.不同的是:对于C程序接口的前导下划线对于Pascal不需要.
内存模型经常为large:函数为far,数据指针为far,并且没有数据项能超过64K长.(事实上,一些函数是near,而且那些对
一个Pascal单元来说为本地的函数,永远不会在外部调用它所有Pascal调用的汇编函数,和所有汇编程序都能调用
Pascal函数为far型的.)然而所有在Pascal程序定义的静态数据会在一个默认的数据段中,当控制被传到你的汇编代
码时,它们的段地址将会在DS中.但当默认的数据段为本地变量时这将不成立(它们在堆栈段)并且支态的分配本地
变量.所有数据指针将是far的.函数调用规则不同-如下描述.一些数据类型如string存储也不同.
这将限定你们允许使用的段名字-Borland Pascal将忽略在一个段中的定义的代码和数据,这个段的名字不符合上面
,具体描述见下面.
7.5.1 Pascal调用规则
16位的Pascal调用规则如下 ,下面的描述中,词调用 者与被调用者是指做调用工作的函数和被调用的函数.
调用者将函数的参数一个接一个按正常的顺序压入堆栈(从左到右,所以第一个参数将被首先压入堆栈).
调用者然后执行一个far CALL指令将控制传给被调用者.
被调用者接到控制,开始(虽然事实上不需要,在函数中不必访问它们的参数)将SP入到BP中,以使BP可以做
为一个基指针来找出堆栈上它们的参数.然而,调用者也可以正确的做这个,所以调用规则的部分状态为BP必须被
一些函数保存.因此被调用者,如果它将BP做为一个frame指针设置的话,必须将前面的值压入堆栈.
被调用者可以参照BP来访问它的参数.[BP]中的值将为它压入堆栈前的值.在[BP+2]中将保存返回值的偏移部分,而
[BP+4]中则保存段值.参数值从[BP+6]开始.函数最右边的参数最后压入堆栈,可以从BP中得到这个偏移;其它后面的
值则会比偏移大.
被调用者可能希望以后减小SP的值,以便为本地变量分配堆栈空间,这些变量将从BP中得到负的偏移来被访问.
被调用者,如果它希望返回一个值给调用者,应该将值丰入AL,AX或DX:AX这取决于值的尺寸.浮点数的值存在ST0中.
Real(Borland的本身定义的浮点数据类型不能直接被FPU处理)类型的结果将存在DX:BX:AX.对于字串结果的返回值
,调用者将在压入参数前将一个临时字串的指针压入堆栈,并且被调用者将在这个位置放入返回的字串值.字串不是
一个参数,并且不应该用RETF指令从堆栈中移去.一旦被调用者完成处理,如果它分配本地堆栈空间,它将从BP中
恢复SP,然后弹出BP的值勤,并通过RETF返回.它用一个带立即参数的RET格式来给出要弹出堆栈的字节数.这使从
堆栈中移除的参数对返回指令产生一个边界反映.
当调用者重新从被调用者那里得到控制时,函数的参数可能已经从堆栈中移去,所以它不需要做什么工作.因此你
可以象下面一样定义一个Pascal形式的函数,带两个整型参数:
global myfunc
myfunc: push bp
mov bp,sp
sub sp,0x40 ;64个字节的堆栈空间
mov bx,[bp+8] ;函数的第一个参数
mov bx,[bp+6] ;函数的第二个参数
;一些代码
mov sp,bp ;取消"sub sp,0x40"
pop bp
retf 4 ;参数的尺寸为4
在一个进程结束时,从你的汇编代码中调用一个Pascal函数,可以用下面的代码:
extern SomeFunc
;后面下面
push word seg mystring ;现在将段压入堆栈,
push word mystring ;"mystring"的偏移
push word [myint] ;我的变量中的一个
call far SomeFunc
这等于Pascal代码:
procedure SomeFunc(String: PChar;Int: Integer);
SomeFunc(@mystring,myint);
7.5.2 Borland Pascal段命名限制
由于Borland Pascal内部单元文件格式与OBJ完全不同,当连接时,从一个OBJ文件读取和理解相关信息时将是一
个写生的过程。然而一个目标文件连接到一个Pascal程序时将必须遵守一些限制:
过程和函数必须为一个叫CODE,CSEG,_TEXT的段。
初始化数据必须在一个叫CONST或_DATA的段。
未初始化的数据必须在一个叫DATA,DSEG,_BSS的段。
另外一些在目标文件中的段将完全被忽略。GROUP定向符和段属性也将被忽略。
7.5.3 在Pascal程序中用c16.mac
c16.mac宏文件在第7.4.5节描述,也可以用来写一个被Pascal程序调用的函数,如果你用%define PASCAL时。这个
定义保证了函数为far(它执行FARCODE)并使过程返回一个用操作符生成的指令。定义PASCAL不会改变计算参数
偏移的那些代码;你必须用相反的顺序定义函数的参数。如:
%define PASCAL
proc_pascalproc
%$j arg 4
%$i arg
mov ax,[bp+%$i]
mov bx,[bp+%$j]
mov es,[bp+%$j+2]
add ax,[bx]
endproc
这定义了与第7.4.5节中的例子在概念上相同的例程:它定义了一个带2个参数的函数,一个整数和一个指向整数
的指针,将返回整数的和与指针的内容。与large-model C版本的唯一区别为定义PASCAL来代替FARCODE的定义
,参数将以相反的顺序定义?