第七章: 编写16位代码 (DOS, Windows 3/3.1)

来源:互联网 发布:课工场java培训 编辑:程序博客网 时间:2024/05/16 19:56

【转】http://blog.csdn.net/hitop0609/archive/2009/07/07/4329512.aspx

本章将介绍一些在编写运行在'MS-DOS'和'Windows 3.x'下的16位代码的时候需要
用到的一些常见的知识.涵兽了如果连接程序以生成.exe或.com文件,如果编写
.sys设备驱动程序,以及16位的汇编语言代码与C编译器和Borland Pascal编译器
之间的编程接口.

  7.1 产生'.EXE'文件.
 
  DOS下的任何大的程序都必须被构建成'.EXE'文件,因为只有'.EXE'文件拥有一种
内部结构可以突破64K的段限制.Windows程序也需要被构建成'.EXE'文件,因为
Windows不支持'.COM'格式.
 
  一般的,你是通过使用一个或多个'obj'格式的'.OBJ'目标文件来产生'.EXE'文件
的,用连接器把它们连接到一起.但是,NASM也支持通过'bin'输出格式直接产生一
个简单的DOS '.EXE'文件(通过使用'DB'和'DW'来构建exe文件头),并提供了一组
宏帮助做到这一点.多谢Yann Guidon贡献了这一部分代码.

在NASM的未来版本中,可能会完全支持'.EXE'文件.

 7.1.1 使用'obj'格式来产生'.EXE'文件.
 
 本章选描述常见的产生'.EXE'文件的方法:把'.OBJ'文件连接到一起.
 
 大多数16位的程序语言包都附带有一个配套的连接器,如果你没有,有一个免费的
叫做VAL的连接器,在`x2ftp.oulu.fi'上可以以'LZH'包的格式得到.也可以在
 `ftp.simtel.net'上得到. 另一个免费的LZH包(尽管这个包是没有源代码的),叫做
 FREELINK,可以在`www.pcorner.com'上得到. 第三个是'djlink',是由DJ Delorie写
 的,可以在`www.delorie.com'上得到. 第四个 'ALINK', 是由Anthony A.J. Williams
 写的,可以在`alink.sourceforge.net'上得到.
 
 当把多个'.OBJ'连接进一个'.EXE'文件中的时候,你需要保证它们当中有且仅有一
个含有程序入口点(使用'obj'格式定义的特殊符号'..start'参阅6.2.6).如果没有
模块定义入口点,连接器就不知道在输出文件的文件头中为入口点域赋什么值,如
果有多个入口被定义,连接器就不知道到底该用哪一个.

一个关于把NASM源文件汇编成'.OBJ'文件,并把它连接成一个'.EXE'文件的例子在
这里给出.它演示了定义栈,初始化段寄存器,声明入口点的基本做法.这个文件也
在NASM的'test'子目录中有提供,名字是'objexe.asm'.

      segment code
    
      ..start:
              mov     ax,data
              mov     ds,ax
              mov     ax,stack
              mov     ss,ax
              mov     sp,stacktop
    
这是一段初始化代码,先把DS寄存器设置成指定数据段,然后把‘SS’和‘SP’寄存器
设置成指定提供的栈。注意,这种情况下,在'mov ss,ax'后,有一条指令隐式地把
中断关闭掉了,这样抗敌,在载入 'SS'和‘SP’的过程中就不会有中断发生,并且没
有可执行的栈可用。

还有,一个特殊的符号'..start'在这段代码的开头被定义,它表示最终可执行代
码的入口点。

              mov     dx,hello
              mov     ah,9
              int     0x21

上面是主程序:在'DS:DX'中载入一个指向欢迎信息的指针('hello'隐式的跟段
‘data'相关联,’data'在设置代码中已经被载入到‘DS‘寄存器中,所以整个指针是
有效的),然后调用DOS的打印字符串功能调用。

              mov     ax,0x4c00
              int     0x21

这两句使用另一个DOS功能调用结束程序。

      segment data
    
      hello:  db      'hello, world', 13, 10, '$'

      数据段中含有我们想要显示的字符串。

      segment stack stack
              resb 64
      stacktop:

      上面的代码声明一个含有64bytes的未初始化栈空间的堆栈段,然后把指针
      ’stacktop'指向它的顶端。操作符'segment stack stack'定义了一个叫做
      ‘stack'的段,同时它的类型也是'STACK'.后者并不一定需要,但是连接串可
      能会因为你的程序中没有段的类型为'STACK'而发出警告。

      上面的文件在被编译为'.OBJ'文件中,会自动连接成为一个有效的'.EXE'文
      件,当运行它时会打印出'hello world',然后退出。

 7.1.2 使用’bin'格式来产生`.EXE'文件。
 
 '.EXE'文件是相当简单的,所以可以通过编写一个纯二进制文件然后在前面
连接上一个32bytes的头就可以产生一个'.exe'的文件了。这个文件头也是
相当简单,它可以通过使用NASM自己的'DB'和'DW'命令来产生,所以你可以使
用'bin'输出格式直接产生'.EXE'文件。

在NASM的包中,有一个'misc'子目录,这是一个宏文件'exebin.mac'。它定义
了三个宏`EXE_begin',`EXE_stack'和`EXE_end'.

要通过这种方法产生一个'.EXE'文件,你应当开始的时候先使用'%include'载
入'exebin.mac'宏包到你的源文件中。然后,你应当使用'EXE_begin'宏(不带
任何参数)来产生文件头数据。然后像平常一样写二进制格式的代码-你可以
使用三种标准的段'.text','.data','.bss'.在文件的最后,你应当调用
'EXE_end'宏(还是不带任何参数),它定义了一些标识段size的符号,而这些宏
会由'EXE_begin'产生的文件头代码引用。

在这个模块中,你最后的代码是写在'0x100'开始的地址处的,就像是'.COM'文
件-实际上,如果你剥去那个32bytes的文件头,你就会得到一个有效的'.COM'程
序。所有的段基址是相同的,所以程序的大小被限制在64K的范围内,这还是跟
一个'.COM'文件相同。'ORG'操作符是被'EXE_begin'宏使用的,所以你不必自己
显式的使用它

你可以直接使用你的段基址,但不幸的是,因为这需要在文件头中有一个重定
位,事情就会变得更复杂。所以你应当从'CS'中拷贝出一个段基址。

进入你的'.EXE'文件后,'SS:SP'已经被正确的指向一个2Kb的栈顶。你可以通过
调用'EXE_stack'宏来调整缺省的2KB的栈大小。比如,把你的栈size改变到
64bytes,你可以调用'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:
              ; put your code here
    
      section .data
    
              ; put data items here
    
      section .bss
    
              ; put uninitialised data here

'bin'格式会把'.text'段放在文件的最开始处,所以如果你需要,你可以在开始
编写代码前先声明data和bss元素,代码段最终还是会放到文件的最开始处。

BSS(未初始化过的数据)段本身在'.COM'文件中并不占据空间:BSS中的元素的地
址是一个指向文件外面的一个空间的一个指针,这样做的依据是在程序运行中,
这样可以节省空间。所以你不应当相信当你运行程序时,你的BSS段已经被初始
化为零了。

为了汇编上面的程序,你应当象下面这样使用命令行:

      nasm myprog.asm -fbin -o myprog.com

      如果没有显式的指定输出文件名,这个'bin'格式会产生一个叫做'myprog'的文
      件,所以你必须重新给它指定一个文件名。

7.2.2 使用`obj'格式产生`.COM'文件

如果你在写一个'.COM'文件的时候,产生了多于一个的模块,你可能希望汇编成
多个'.OBJ'文件,然后把它们连接成一个'.COM'程序。如果你拥有一个能够输出
'.COM'文件的连接器,你可以做到这一点。或者拥有一个转化程序(比如,
'EXE2BIN')把一个'.EXE'输出文件转化为一个'.COM'文件也可。

如果你要这样做,你必须注意几件事情:

      (*) 第一个含有代码的目标文件在它的代码段中,第一句必须是:'RESB 100h'。
          这是为了保证代码在代码段基址的偏移'100h'处开始,这样,连接器和转化
  程序在产生.com文件时,就不必调整地址引用了。其他的汇编器是使用'ORG'
  操作符来达到此目的的,但是'ORG'在NASM中对于'bin'格式来说是一个格式相
  关的操作符,会表达不同的含义。

      (*) 你不必定义一个堆栈段。

      (*) 你的所有段必须放在一个组中,这样每次你的代码或数据引用一个符号偏移
          时,所有的偏移值都是相对于同一个段基址的。这是因为,当一个'.COM'文件
  载入时,所有的段寄存器含有同一个值。

  7.3 产生`.SYS'文件

  MS-DOS设备驱动-'SYS'文件-是一些纯二进制文件,跟.com文件相似,但有一点,
它们的起始地址是0,而不是'100h'。因此,如果你用'bin'格式写一个设备程序
,你不必使用'ORG'操作符,因为'bin'的缺省起始地址就是零。相似的,如果你
使用'obj',你不必在代码段的起始处使用'RESB 100h'

'.SYS'文件拥有一个文件头,包含一些指针,这些指针指向设备中完成实际工
作的不同的子过程。这个结构必须在代码段的起始处被定义,尽管它并不是
实际的代码。

要得到关于'.SYS'文件的更多信息,头结构中必须包含的数据,有一本以FAQ列
表的形式给出的书可以在`comp.os.msdos.programmer'得到。

  7.4 与16位C程序之间的接口。

      本章介绍编写调用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'宏以相似的方式工作。

  7.4.2 内存模式。

  NASM没有提供支持各种C的内存模式的直接机制;你必须自己记住你在何
种模式下工作。这意味着你自己必须跟踪以下事情:

      (*) 在使用单个代码段的模式中(tiny small和compact)函数都是near的,
          这表示函数指针在作为一个函数参数存入数据段或压栈时,有16位
  长并只包含一个偏移域(CS寄存器中的值从来不改变,总是给出函数
  地址的段地真正部分),函数调用就使用普通的near'CALL'指令,返回
  使用'RETN'(在NASM中,它跟'RET'同义)。这意味着你在编写你自己的
  过程时,应当使用'RETN'返回,你调用外部C过程时,可以使用near的
  'CALL'指令。

      (*) 在使用多于一个代码段的模块中(medium, large和huge)函数是far的,
          这表示函数指针是32位长的(包含 16位的偏移值和紧跟着的16位段
  地址),这种函数使用'CALL FAR'进行调用(或者'CALL seg:offset')
  而返回使用'RETF'。同样的,你编写自己的过程时,应当使用'RETF',
  调用外部C过程应当使用'CALL FAR'。
    
      (*) 在使用单个数据段的模块中(tiny, small和medium),数据指针是16位
          长的,只包含一个偏移域(’DS‘寄存器的值不改变,总是给出数据元素
  的地址的段地址部分)。

      (*) 在使用多于一个数据段的模块中(compact, large和huge),数据指针
          是32位长的,包含一个16位的偏移跟上一佧16位的段地址。你还是应
  当小心,不要随便改变了ds的值而没有恢复它,但是ES可以被随便用
  来存取32位数据指针的内容。

7.4.3 函数定义和函数调用。

16位程序中的C调用转化如下所示。在下面的描述中,_caller_和_callee_
分别表示调用者和被调用者。

      (*) caller把函数的参数按相反的顺序压栈,(从右到左,所以第一个参数
          被最后一个压栈)。
 
      (*) caller然后执行一个'CALL'指令把控制权交给callee。根据所使用的
          内存模式,'CALL'可以是near或far。

      (*) callee接收控制权,然后一般会(尽管在没有带参数的函数中,这不是
          必须的)在开始的时候把’SP‘的值赋给’BP‘,然后就可以把‘BP’
  作为一个基准指针用以寻找栈中的参数。当然,这个事情也有可能由
  caller来做,所以,关于'BP'的部分调用转化工作必须由C函数来完成
  。因此callee如果要把'BP'设为框架指针,它必须把先前的BP值压栈。

      (*) 然后callee可能会以'BP'相关的方式去存取它的参数。在[BP]中存有BP
  在压栈前的那个值;下一字word,在[BP+2]处,是返回地址的偏移域,
  由'CALL'指令隐式压入。在一个small模式的函数中。在[BP+4]处是参
  数开始的地方;在large模式的函数中,返回地址的段基址部分存在
  [BP+4]的地方,而参数是从[BP+6]处开始的。最左边的参数是被后一个被
  压入栈的,所以在'BP'的这点偏移值上就可以被取到;其他参数紧随其后,偏
  移地址是连续的.这样,在一个象'printf'这样的带有一定数量的参数的函
  数中,以相反的顺序把参数压栈意味着函数可以知道从哪儿获得它的第一个
  参数,这个参数可以告诉接接下来还有多少参数,和它们的类型分别是什么.

      (*) callee可能希望减小'sp'的值,以便在栈中分配本地变量,这些变量可以用
          'BP'负偏移来进行存取.

      (*) callee如果想要返回给caller一个值,应该根据这个值的大小放在'AL','AX'
          或'DX:AX'中.如果是浮点类型返回值,有时(看编译器而定)会放在'ST0'中.

      (*) 一旦callee结束了处理,它如果分配过了本地空间,就从'BP'中恢复'SP'的
  值,然后把原来的'BP'值出栈,然后依据使用的内存模式使用'RETN'或'RETF'
  返回值.

      (*) 如果caller从callee中又重新取回了控制权,函数的参数仍旧在栈中,所以它
          需要加一个立即常数到'SP'中去,以移除这些参数(不用执行一系列的pop指令
  来达到这个目的).这样,如果一个函数因为匹配的问题偶尔被以错误的参数个
  数来调用,栈还是会返回一个正常的状态,因为caller知道有多少个参数被压
  了,它会把它们正确的移除.

这种调用转化跟Pascal程序的调用转化是没有办法比较的(在7.5.1描述).pascal
拥有一个更简单的转化机制,因为没有函数拥有可变数目的参数.所以callee知道
传递了多少参数,它也就有能力自己来通过传递一个立即数给'RET'或'RETF'指令
来移除栈中的参数,所以caller就不必做这个事情了.同样,参数也是以从左到右
的顺序被压栈的,而不是从右到左,这意味着一个编译器可以更方便地处理。
 

这样,如果你想要以C风格定义一个函数,应该以下面的方式进行:这个例子是
在small模式下的。

      global  _myfunc
    
      _myfunc:
              push    bp
              mov     bp,sp
              sub     sp,0x40         ; 64 bytes of local stack space
              mov     bx,[bp+4]       ; first parameter to function
    
              ; some more code
    
              mov     sp,bp           ; undo "sub sp,0x40" above
              pop     bp
              ret

在巨模式下,你应该把'RET'替换成'RETF',然后应该在[BP+6]的位置处寻找第
一个参数,而不是[BP+4].当然,如果某一个参数是一个指针的话,那参数序列
的偏移值会因为内存模式的改变而改变:far指针作为一个参数时在栈中占用
4bytes,而near指针只占用两个字节。

另一方面,如果从你的汇编代码中调用一个C函数,你应该做下面的一些事情:

      extern  _printf
    
            ; and then, further down...
    
            push    word [myint]        ; one of my integer variables
            push    word mystring       ; pointer into my data segment
            call    _printf
            add     sp,byte 4           ; `byte' saves space
    
            ; then those data items...
    
      segment _DATA
    
      myint         dw    1234
      mystring      db    'This number -> %d <- should be 1234',10,0

      这段代码在small内存模式下等同于下面的C代码:

          int myint = 1234;
          printf("This number -> %d <- should be 1234/n", myint);

      在large模式下,函数调用代码可能更象下面这样。在这个例子中,假设DS已经
      含有段'_DATA'的段基址,你首先必须初始化它:

            push    word [myint]
            push    word seg mystring   ; Now push the segment, and...
            push    word mystring       ; ... offset of "mystring"
            call    far _printf
            add    sp,byte 6

      这个整型值在栈中还是占用一个字的空间,因为large模式并不会影响到'int'
      数据类型的size.printf的第一个参数(最后一个压栈),是一个数据指针,所以
      含有一个段基址和一个偏移域。在内存中,段基址应该放在偏移域后面,所以,
      必须首先被压栈。(当然,'PUSH DS'是一个取代'PUSH WORD SEG mystring'的更
      短的形式,如果DS已经被正确设置的话)。然后,实际的调用变成了一个far调用,
      因为在large模式下,函数都是被far调用的;调用后,'SP'必须被加上6,而不是
      4,以释放压入栈中的参数。

  7.4.4 存取数据元素。

      要想获得一个C变量的内容,或者声明一个C语言可以存取的变量,你只需要把变
      量名声明为'GLOBAL'或'EXTERN'即可。(再次提醒,就象在7.4.1中所介绍的,变
      量名前需要加上一个下划线)这样,一个在C中声明的变量'ini i'可以在汇编语
      中以下述方式存取:

      extern _i
    
              mov ax,[_i]

      而要声明一个你自己的可以被C程序存取的整型变量如:'extern int j',你可
      以这样做(确定你下在'_DATA'段中):

      global  _j
    
      _j      dw      0

      要存取C的数组,你需要知道数组元素的size.比如,'int'变量是2byte长,所以
      如果一个C程序声明了一个数组'int a[10]',你可象这样存取'a[3]':'mov ax,
      [_a+6]'.(字节偏移6是通过数组下标3乘上数组元素的size2得到的。) 基于C的
       16位编译器的数据size如下:1 for `char',  2 for `short' and `int', 4
      for `long' and `float', and 8 for `double'.

      为了存取C的数据结构,你必须知道从结构的基地址到你所感兴趣的域的偏移地
      址。你可以通过把C结构定义转化为NASM的结构定义(使用'STRUC'),或者计算这
      个偏移地址然后进行相应操作。

      以上述任何一种方法实现,你必须得阅读你的C编译器的手册去找出他是如何组
      织数据结构的。NASM在它的宏'STRUC'中不给出任何对结构体成员的对齐操作,
      所以你可能会发现结构体类似下面的样子:

      struct {
          char c;
          int i;
      } foo;

      可能就是4字节长,而不是三个字,因为'int'域会被对齐到2byte边界。但是,这
      种排布的特性在C编译器中很可能只是一个配置选项,使用命令行选项或者
      '#pragma'行。所以你必须找出你的编译器是如何实现这个的。

7.4.5 `c16.mac': 与16位C接口的帮助宏。

      在NASM包中,在'misc'子目录下,是一个宏文件'c16.mac'。它定义了三个宏
      'proc','arg'和'endproc'。这些被用在C风格的过程定义中,它们自动完成了
      很多工作,包括对调用转化的跟踪。

      (另外一种选择是,TASM兼容模式的'arg'现在也被编译进了NASM的预处理器,
      详见4.9)

      关于在汇编函数中使用这个宏的一个例子如下:

      proc    _nearproc
    
      %$i     arg
      %$j     arg
              mov     ax,[bp + %$i]
              mov     bx,[bp + %$j]
              add     ax,[bx]
    
      endproc

      这把'_nearproc'定义为一个带有两个参数的一个过程,第一个('i')是一个整
      型数,第二个('j')是一个指向整型数的指针,它返回'i+*j'。

      注意,'arg'宏展开的第一行有一个'EQU',而且因为在宏调用的前面的那个
      label在宏展开后被加在了第一行的前面,所以'EQU'能否工作取决于'%$i'是否
      是一个关于'BP'的偏移值。同时一个对于上下文来说是本地的context-local变
      量被使用,它被'proc'宏压栈,然后被'endproc'宏出栈,所以,在后来的过程
      中,同样的参数名还是可以使用,当然,你不一定要这么做。

      宏在缺省状况下把过程代码设置为near函数(tiny,small和compact模式代码),
      你可以通过代码'%define FARCODE'产生far函数(medium, large和huge模式代
      码)。这会改变'endproc'产生的返回指令的类型,还会改变参数起始位置的偏移
      值。这个宏在设置内容时,本质上并依赖数据指针是near或far。

      'arg'可以带有一个可选参数,给出参数的size。如果没有size给出,缺省设置为
      2,因为绝大多数函数参数会是'int'类型。

      上面函数的large模式看上去应该是这个样子:

      %define FARCODE
    
      proc    _farproc
    
      %$i     arg
      %$j     arg     4
              mov     ax,[bp + %$i]
              mov     bx,[bp + %$j]
              mov     es,[bp + %$j + 2]
              add     ax,[bx]
    
      endproc

      这利用了'arg'宏的参数定义参数的size为4,因为'j'现在是一个far指针。当我们
      从'j'中载入数据时,我们必须同时载入一个段基址和一个偏移值。