不可否认,这次的标题有点长。之所以把标题写得这么详细,主要是为了搜索引擎能够准确地把确实需要了解GCC生成16位实模式代码方法的朋友带到我的博客。先说一下背景,编写能在x86实模式下运行的16位代码,这个话题确实有点复古,所以能找到的资料也相应较少。要运行x86实模式的程序,目前我知道的只有两种方式,一种是使用DOS系统,另一种是把它写成引导扇区的代码,在系统启动时直接运行。很显然,许多讲自己实现操作系统的书籍都会讲到x86实模式,也只有自己实现操作系统引导的朋友需要用到x86实模式,所以我这篇文章的阅读用户数肯定很少,虽然我自认为它填补了网上关于该话题相关资料缺乏的空白。因此,凡是逛到我这篇文章的朋友,请点一下推荐,谢谢。
为什么说我这篇博客填补了相关话题的空白呢?那是因为不管是那些写书的,还是网上写文章的,一旦需要编写16位的实模式代码,都喜欢拿NASM说事儿,一点也不顾GNUAS的感受。当然,这是有历史原因的,因为Linux自从其诞生起就是32位,就是多用户多任务操作系统,所以GCC和GnuAS一移植到Linux上就是用来编写32位保护模式的代码的。而且,ELF可执行文件格式也只有ELF32和ELF64,没听说过有ELF16的。即使是Linux自己,刚诞生的时候(1991年),也只有使用as86汇编器来编写自己的16位启动代码,直到1995年以后,GNUAS才逐步加入编写16位代码的能力。
下面开始我的GCC和GNUBinutils的16位代码之旅。我决定使用DOS作为我的测试环境,所以最后生成的可执行文件都把它制作成DOS系统中可运行的PlainBinary格式。第一步安装一个qemu虚拟机来运行FreeDOS,安装虚拟机在Ubuntu中只需要一个sudoapt-getinstallqemu命令就可以完成,所以我就不截图了。但是FreeDOS的软盘映像文件需要到Qemu的官网上面去下载,下载地址如下图:
使用qemu-system-i386-fdafreedos.img可以运行Qemu虚拟机和FreeDOS系统,如下图:
因为汇编语言更接近底层,而C语言更高级,所以先从汇编语言开始,逐步过渡到C语言。先写一个简单的、能在DOS中显示一个Hello,world!的汇编语言程序,考虑到我之后会使用该程序调用C语言的main函数,并且该程序负责让程序运行结束后顺利返回DOS系统,所以我把这个程序命名为test_code16_startup.s。其代码如下:
下面对以上代码进行简单解释:
1.GNUAS汇编器使用的汇编语言采用的是AT&T语法,该语法和Intel语法不同。我更喜欢AT&T的语法,原因有两个,一是AT&T语法是Linux世界中通用的标准,二是AT&T语法在某些概念方面确实理解起来更简单(比如内存寻址模式)。有汇编语言基础的人,AT&T语法学起来也很快,主要有以下几条:①汇编指令后面跟有操作数长度的后缀,比如mov指令,如果操作数是8位,则用movb,如果操作数是16位,则用movw,如果操作数是32位,则用movl,如果操作数是64位,则用movq,其余指令依此类推;②操作数的顺序是源操作数在前,目标操作数在后,比如movw%cs,%ax表示把cs寄存器中的数据移动到ax寄存器中,这个顺序和Intel汇编语法正好相反;③所有的寄存器使用%前缀,如%ax,%di,%esp等;④对于立即数,需要使用$前缀,比如$4,$0x0c,而且如果一个数字是以0开头,则是8进制,以其它数字开头,是10进制,以0x开头则是16进制,标号当立即数使用时,需要$前缀,比如上面的pushw$message,而标号当函数名使用时,不需要$前缀,比如上面的callwdisplay_str;⑤内存寻址方式,众所周知,x86寻址方式众多,什么直接寻址、间接寻址、基址寻址、基址变址寻址等等让人眼花缭乱,而AT&T语法对内存寻址方式做了一个很好的统一,其格式为section:displacement(base,index,scale),其中section是段地址,displacement是位移,base是基址寄存器,index是索引,scale是缩放因子,其计算方式为线性地址=section+displacement+base+index*scale,最重要的是,可以省略以上格式中的一个或多个部分,比如movw4,%ax就是把内存地址4中的值移动到ax寄存器中,movw4(%esp),%ax就是把esp+4指向的地址中的值移动到ax寄存器中,依此类推。我上面的介绍是不是全网络最简明的AT&T汇编语法教程?
2.在以上代码中我全部使用的都是16位的指令,如movw、pushw、callw等,并且直接在代码中定义了字符串Hello,world!。
3.在以上代码中使用了函数display_str,在调用display_str之前,我使用pushw$15和pushw$message将参数从右向左依次压栈,然后使用callw指令调用函数,这和C语言的函数调用约定是一样的。调用callw指令会自动将%ip寄存器压栈,而在函数开始时,我又用pushw%bp将%bp寄存器压栈,所以%esp又向下移动了4个字节,所以在函数中使用0x4(%esp)和0x6(%esp)可以访问到这两个参数。在32位代码中,由于调用函数时压栈的是%eip和%ebp,所以需要使用0x8(%esp)和0xc(%esp)来依次访问压栈的参数。关于汇编语言函数调用的细节,我这里有一本好书Linux汇编编程指南.pdf。这是一本免费的英文版电子书,其原名为《Programmingfromthegroundup》。
4.以上代码使用BIOS中断int0x10来输出字符串,使用DOS中断int0x21来返回DOS系统。
5.最重要的是,需要使用.code16指令让汇编器将程序汇编成16位的代码。
代码完成后,使用下面一串命令就可以把它进行汇编、链接,然后转换成DOS下的纯二进制格式(PlainBinary),最后复制到FreeDOS.img中,使用Qemu虚拟机执行FreeDOS,然后运行该16位实模式程序。这一串命令及其运行效果如下图:
这些命令中比较重要的选项我都特意标出来了。由于我用的是64位的环境,所以调用as命令的时候需要指定--32选项,调用ld命令的时候需要指定-melf_i386选项。指定以上选项后,生成的是32位的ELF目标文件,否则默认会生成64位的ELF目标文件,如果目标文件是64位,以后和C语言生成的目标文件连接时会出问题。使用32位环境的朋友们不用特意指定这两个选项。由于DOS系统总是把PlainBinary文件载入到0x100地址处执行,所以调用ld命令时,需要指定-Ttext0x100选项。ld命令执行完成后,生成的是ELF格式的可执行文件test.elf,最后需要调用objcopy生成纯二进制文件,-j.text选项的意思是只需要代码段,因为我把Hello,world!也是定义在代码段中的,-Obinary选项指定输出格式为纯二进制文件,输出文件为test.com。最后,将freedos.img镜像文件mount到Ubuntu中,将test.com拷贝到其中,然后umount,然后运行虚拟机,在DOS中运行test,就可以看到效果了。
除了as和ld,GNUBinutils中的其它程序也是写程序和分析程序时的好帮手。可以使用readelf-S查看test.elf文件中的所有段,也可以使用objdump-s命令将test.elf中的数据以16进制形式输入,如下图:
当然,也可以使用objdump-d或者objdump-D将程序进行反汇编,查看是否真正生成了16位代码,如下图:(反汇编时一定要指定-mi8086选项)
也可以对纯二进制格式的文件进行反汇编,必须指定-bbinary选项,如下图,对test.com进行反汇编:
反汇编时,一定要指定-mi8086选项,否则objdump不知道反汇编的是16位代码。(前面提到过Linux从诞生起就是32位,所以ELF只有32位和64位两种,没有16位的ELF格式。)如下图,如果使用-mi386选项进行反汇编,反汇编结果将不知所云:
下面进入C语言的世界。为了搞清楚C语言生成的16位代码的汇编指令有哪些特别之处,先写一个简单的C语言程序进行调研,如下图:
该程序有以下特点:
1.程序的开头使用了__asm__(".code16\n")嵌入汇编指令,以指示as生成16位代码;
2.display_str函数的签名和之前汇编语言中的相同,可以使用它来观察C语言生成的代码如何传递参数。
使用下面的命令对程序进行编译和反汇编,如下图:
从上图可以看出,C语言生成的代码虽然是16位,但是它有如下特点:①从生成的display_str函数中可以看出,函数一开始是push%ebp,而不是push%bp;②在display_str函数中获取参数的位置分别为0x8(%ebp)和0xc(%ebp),而不是我在汇编语言中写的0x4(%ebp)和0x6(%ebp);③从生成的main函数可以看出,调用diaplay_str之前,没有使用push命令把参数压栈,而是直接通过sub$0x18,%esp调整%esp的位置,然后使用mov指令将参数放到指定位置,和使用push指令的效果相同;④虽然我在display_str函数的定义中故意将长度参数定义为short,但是从生成的代码中可以看到依然是每隔4个字节放一个参数。
另外需要说明的是,调用gcc时除了指定-c选项指示它只编译不连接外,还要指定-m32选项,这样才会生成32位的汇编代码,而只有在32位的汇编代码中使用.code16指令,才能编译成16位的机器码。如果没有指定-m32选项,则生成的是64位汇编代码,然后汇编时会出错。使用-m32选项后,生成的目标文件是ELF32格式。ELF32格式的目标文件只能和ELF32格式的目标文件连接,这也是为什么前面的as和ld需要指定--32和-melf_i386选项的原因。
通过以上分析,似乎可以得出以下结论:只需要将汇编代码中的pushw%bp更改为pushl%ebp,然后将获取参数的位置调整为0x8(%ebp)和0xc(%ebp),就可以从C语言里面成功调用到汇编语言中的函数了。而事实上,还有一点点小差距。从上面的反汇编代码中可以看到,函数调用时使用的是16位的call指令,该指令压栈的是%ip,而不是%eip,而C语言生成的函数框架中获取的参数位置是按照将%eip压栈计算出来的,它们之间差了两个字节。
为了证明我以上判断的准确性,我将上面的C语言程序和汇编程序修改后,编译连接成一个完整的程序,看看它究竟能否正确运行。如下图:
C语言程序修改很简单,就是去掉了display_str函数的实现,只保留声明。汇编代码如下图:
汇编语言的更改包含以下几个地方:将display_str函数导出,将pushw%bp改为pushl%ebp,同时修改获取参数的位置。编译、连接、运行程序的指令如下:
可以看到HelloworldfromClanguage没有正确显示出来。上面的命令都是前面用过的,不需要多解释,唯一不同的是使用C语言写的程序多了一个.rodata段,所以在objcopy的时候需要把这个段也包含进来。
由于C语言生成的函数框架都是从0x8(%ebp)开始取参数,它认为0x0(%ebp)是oldebp,0x4(%ebp)是%eip,而事实上使用16位的call指令调用函数后,0x4(%ebp)中是%ip而不是%eip,所以要从0x6(%ebp)开始取参数。我们不可能修改C语言生成的函数框架,只能看看能否将16位的call改成32位的call。
办法当然是有的,那就是不使用.code16,而使用.code16gcc。.code16gcc和.code16不同的地方就在于它生成的汇编代码在使用到call、ret、jump等指令时,都生成32位的机器码,相当于calll,retl,jumpl。这也是.code16gcc叫.code16gcc的原因,因为它就是配合GCC生成的函数框架使用的。
下面再来修改代码,C语言代码修改很简单,只需要将.code16改成.code16gcc即可,如下图:
通过反汇编,可以看到它使用了32位的calll和retl,如下图:
汇编程序的修改主要是将.code16改为.code16gcc,然后手动将callw改成calll,将retw改成retl,如下图:
最后,编译连接,拷贝到freedos.img,运行虚拟机,查看运行效果,如下图:
大功告成,运行效果如上图。
总结: 编写运行于x86实模式下的16位代码是一个很复古的话题,编写能在DOS下运行的PlainBinary可执行文件是一个更复古的话题。以往,凡是需要使用x86的16位实模式的时候,作者都喜欢用NASM来编程。比如《30天自制操作系统》、《Orange's一个操作系统的实现》、《x86汇编语言——从实模式到保护模式》等书籍都以NASM汇编器和Intel汇编语法作为示例。而且他们都是在进入32位保护模式后,才让汇编语言和C语言共同工作。
我用Linux操作系统,所以我就是想不管是写32位代码,还是16位代码,都能使用GCC和GNUAS。我还想即使是在16位模式下,也能尽量少用汇编语言,多用C语言。经过努力,有了上面的文章。使用GCC和GNUBinutils编写运行于x86实模式的16位代码的过程如下:
1.如果只用汇编语言编写16位程序,请使用.code16指令,并保证只使用16位的指令和寄存器;如果要和C语言一起工作,请使用.code16gcc指令,并且在函数框架中使用pushl,calll,retl,leavel,jmpl,使用0x8(%ebp)开始访问函数的参数;很显然,使用C语言和汇编语言混编的程序可以在实模式下运行,但是不能在286之前的真实CPU上运行,因为286之前的CPU还没有pushl、calll、retl、leavel、jmpl等指令。
2.使用as时,请指定--32选项,使用gcc时,请指定-m32选项,使用ld时,请指定-melf_i386选项。如果是反汇编16位代码,在使用objdump时,请使用-mi8086选项。
3.在DOS中运行的.com文件会被加载到0x100处执行,所以使用ld连接时需指定-Ttext0x100选项;引导扇区的代码会被加载到0x7c00处执行,所以使用ld连接时需指定-Ttext0x7c00选项。
4.使用gcc、as、ld生成的程序默认都是ELF格式,而在DOS下运行的.com程序是PlainBinary的,在引导扇区运行的代码也是PlainBinary的,所以需要使用objcopy将ELF文件中的代码段和数据段拷贝到一个PlainBinary文件中,使用-Obinary选项;PlainBinary文件也可以反汇编,在使用objdump时需指定-bbinary选项。