第 2 章 环境搭建及基础知识
本章介绍编写本书操作系统所需的基础知识、系统环境及环境搭建方法,大家不必在这方面耗费太多精力,本着够用就好的原则即可。
本书操作系统开发使用的系统环境是Windows 7系统,编译环境是Linux的开源发行版CentOS 6。因此,作者将借助VMware虚拟机软件在Windows 7系统下创建了一个虚拟平台,再由此平台搭载CentOS 6操作系统。虽然我们的意图是在物理平台上运行操作系统,但如果在操作系统开发初期就使用物理平台的话,代码调试工作将变得十分艰难。故此,在开发初期使用Bochs虚拟机来调试我们的操作系统是个不错的选择。
在基础知识方面,不管你是精通C语言和汇编语言、能够写出高效且晦涩的代码的大神,还是初学编程语言抱着谭浩强的《C语言程序设计》乱啃的菜鸟,都请你们静下心来读完这一章再上路。本章可以作为复习章,亦可作为提高自己知识技能的学习章,其中涉及的知识点都很重要,如果不了解这些知识,往后的内容你会学得很吃力。不经一番寒彻骨,怎得梅花扑鼻香。我们今天的止步不前,是为了明天大踏步的前进。
2.1 虚拟机及开发系统平台介绍
研发任何一款软件都需要有完整的开发环境,研发操作系统也不例外。
开发操作系统主要使用汇编语言和C语言,再加上些许灵活多变的设计思想即可。开发应用程序可以借助丰富的调试工具和系统开发库的支持,而开发操作系统一切皆需要从零做起。
随着开源免费软件大军逐渐壮大,为了避免版权问题和收费软件的麻烦,Linux家族的操作系统已成为开发环境的首选。VMware虚拟机软件以稳定、方便、灵活、功能强大等特点深受开发者们的喜爱。Windows、Linux或Mac OS系统平台都能创建出一个表现出众的虚拟平台。Linux开源操作系统与VMware虚拟机软件经常会组合在一起使用。
开源的轻量级虚拟机Bochs,不仅可以运行虚拟平台,还能够在平台运行期间对平台进行调试,从而帮助我们度过一个个难关。当然,如果你手头有其他的可调试虚拟机,只要它具有设置断点、查看内存、查看寄存器状态、反汇编内存代码等基本功能,也可以使用。希望读者能够根据自己的喜好,搭建出一个顺手的开发环境。
2.1.1 VMware的安装
VMware这款虚拟机软件想必大家并不陌生,它基本上属于开发必备软件之一。如果你正在使用Linux的某个发行版,可以选择跳过这部分内容,直接从2.1.3节看起。这部分内容主要针对Windows用户介绍虚拟机软件和编译环境。
作者使用的操作系统是Windows 7 SP1,编译环境选定为Linux的某个发行版。因此,使用VMware软件来为编译环境虚拟硬件平台是个理想的选择。VMware旗下的VMwareWorkstation和VMwarePlayer均可满足本书开发需求。对于软件版本也无过多要求,只要能顺利安装一款Linux发行版操作系统,并支持动态挂载USB设备就可以了。
注意事项
- VMware安装完毕后,读者很可能会使用优化软件对电脑进行清理和优化,此时要特别注意,在优化过程中,优化软件可能会关闭VMware的某些自动开启的系统服务,以至于虚拟机软件有时无法连接网络和挂载USB设备。解决办法是,在运行栏内输入
services.msc
开启服务管理窗口,开启相关服务。如果不知道该开启哪个服务的话,就索性开启VMware软件的全部服务。- 在Windows 7操作系统下运行VMware软件时,尽量以管理员权限运行,否则容易报错。
2.1.2 编译环境CentOS 6
VMware软件安装后,我们将使用该软件建立虚拟硬件平台,并在虚拟平台上安装操作系统。CentOS 6是本书编译环境选用的操作系统。
系统安装
对于操作系统,可根据个人习惯自由选定,只要是Linux的发行版皆可。作者选择CentOS操作系统,主要是由于长期的使用习惯所使。虽然CentOS系统的大部分软件不是最新的,但是对于企业来说,系统稳定更重要。而且CentOS是Red Hat的免费版,提供的维护和更新时间更长,操作界面相对简单、易使用。
开发过程中涉及的一些命令
操作Linux类系统主要依靠终端命令实现,这点与Windows操作系统有所不同。这也是Linux类操作系统的精髓所在,不同功能的命令可以组合使用,进而实现更强大的功能。以下命令及工具大致涵盖了开发本操作系统所需。
- 编译器和编译工具
gcc
:GUN C语言编译器,支持C99标准并拥有独特的扩展。as
:GAS汇编语言编译器,用于编译AT&T格式的汇编语言。ld
:链接器,用于将编译文件链接成可执行文件。nasm
:NASM汇编语言编译器,用于编译Intel格式的汇编语言。make
:编译工具,根据编译脚本文件记录的内容编译程序。
- 系统工具与命令
dd
:复制指定大小的数据块,并在复制过程中转换数据格式。mount
:挂载命令,用于将U盘、光驱、软盘等存储设备挂载到指定路径上。umount
:卸载命令,与mount
命令功能相反。cp
:复制命令,复制指定文件或目录。sync
:数据同步命令,将已缓存的数据回写到存储设备上。rm
:删除命令,删除指定文件或目录。objdump
:反汇编命令,负责将可执行文件反编译成汇编语言。objcopy
:文件提取命令,将源文件中的内容提取出来,再转存到目标文件中。
以上命令和工具通常会默认安装到Linux发行版系统中。如果操作系统里没有相关命令,也无需担心,使用操作系统自带的软件更新工具(
yum
、apt-get
等),就能安装(或更新)最新版本的命令到系统中。注意事项
- 在使用VMware软件创建虚拟平台时,不必为内存和硬盘分配过大的存储空间,而且硬盘可以配置成动态增长型,这样可以节省虚拟机的磁盘存储空间。
- 由于本次开发不会使用到swap分区,那么系统就没有必要创建该分区。如果读者还打算在本系统中进行其他开发,还是创建swap分区为妙。
- 编译器和编译工具
2.1.3 Bochs虚拟机
Bochs是一款开源的可调试虚拟机软件,在开发操作系统的初期阶段,通过它的调试功能可以为系统内核的正常运行保驾护航。
Bochs环境安装
由于这款软件仍处于完善中,新版本将会解决不少bug,对于开发操作系统的内核级软件来说,这点会比较重要。因此,在选择Bochs的软件版本时,还是相对新一些比较好,作者选择的是最新的bochs-2.6.8。请读者自行下载和安装Bochs虚拟机,这里分享一下
configure
工具的配置信息,仅供参考:./configure --with-x11 --with-wx --enable-debugger --enable-disasm --enable-all-optimizations --enable-readline --enable-long-phy-address --enable-ltdl-install --enable-idle-hack --enable-plugins --enable-a20-pin --enable-x86-64 --enable-smp --enable-cpu-level=6 --enable-large-ramfile --enable-repeat-speedups --enable-fast-function-calls --enable-handlers-chaining --enable-trace-linking --enable-configurable-msrs --enable-show-ips --enable-cpp --enable-debugger-gui --enable-iodebug --enable-logging --enable-assert-checks --enable-fpu --enable-vmx=2 --enable-svm --enable-3dnow --enable-alignment-check --enable-monitor-mwait --enable-avx --enable-evex --enable-x86-debugger --enable-pci --enable-usb --enable-voodoo
因为不清楚调试内核到底会使用多少功能,索性就将它们全部添加上去。在编译时可能会出现“文件不存在”错误,这时只需将后缀名为.cpp的文件克隆出一个后缀名为.cc的副本即可通过编译,请参考以下几行复制命令:
cp misc/bximage.cpp misc/bximage.cc cp iodev/hdimage/hdimage.cpp iodev/hdimage/hdimage.cc cp iodev/hdimage/vmware3.cpp iodev/hdimage/vmware3.cc cp iodev/hdimage/vmware4.cpp iodev/hdimage/vmware4.cc cp iodev/hdimage/vpc-img.cpp iodev/hdimage/vpc-img.cc cp iodev/hdimage/vbox.cpp iodev/hdimage/vbox.cc
Bochs运行环境配置
编译安装Bochs虚拟机软件后,还需要为即将实现的操作系统创建虚拟硬件环境。这个环境是通过配置文件描述的,在Bochs文件夹内已为用户准备了一个默认的系统环境配置文件.bochsrc,里面有配置选项的说明和实例可供用户参考使用。读者可以在.bochsrc文件的基础上稍作修改,配置出一个自己的虚拟平台环境。以下内容是本系统虚拟平台环境的配置信息:
# configuration file generated by Bochs plugin_ctrl: unmapped=1, biosdev=1, speaker=1, extfpuirq=1, parallel=1, serial=1, iodebug=1 config_interface: textconfig display_library: x #memory: host=2048, guest=2048 romimage: file="/usr/local/share/bochs/BIOS-bochs-latest" vgaromimage: file="/usr/local/share/bochs/VGABIOS-lgpl-latest" boot: floppy floppy_bootsig_check: disabled=0 floppya: type=1_44, 1_44="boot.img", status=inserted, write_protected=0 # no floppyb ata0: enabled=1, ioaddr1=0x1f0, ioaddr2=0x3f0, irq=14 ata0-master: type=none ata0-slave: type=none ata1: enabled=1, ioaddr1=0x170, ioaddr2=0x370, irq=15 ata1-master: type=none ata1-slave: type=none ata2: enabled=0 ata3: enabled=0 pci: enabled=1, chipset=i440fx vga: extension=vbe, update_freq=5 cpu: count=1:1:1, ips=4000000, quantum=16, model=corei7_haswell_4770,reset_on_triple_fault=1, cpuid_limit_winnt=0, ignore_bad_msrs=1, mwait_is_nop=0, msrs="msrs.def" cpuid: x86_64=1,level=6, mmx=1, sep=1, simd=avx512, aes=1, movbe=1, xsave=1,apic=x2apic,sha=1,movbe=1,adx=1,xsaveopt=1,avx_f16c=1,avx_fma=1,bmi=bmi2,1g_pages=1,pcid=1,fsgsbase=1,smep=1,smap=1,mwait=1,vmx=1 cpuid: family=6, model=0x1a, stepping=5, vendor_string="GenuineIntel", brand_string="Intel(R) Core(TM) i7-4770 CPU (Haswell)" print_timestamps: enabled=0 debugger_log: - magic_break: enabled=0 port_e9_hack: enabled=0 private_colormap: enabled=0 clock: sync=none, time0=local, rtc_sync=0 # no cmosimage # no loader log: - logprefix: %t%e%d debug: action=ignore info: action=report error: action=report panic: action=ask keyboard: type=mf, serial_delay=250, paste_delay=100000, user_shortcut=none mouse: type=ps2, enabled=0, toggle=ctrl+mbutton speaker: enabled=1, mode=system parport1: enabled=1, file=none parport2: enabled=0 com1: enabled=1, mode=null com2: enabled=0 com3: enabled=0 com4: enabled=0 megs: 2048
在这段虚拟平台配置信息中,大部分内容依然使用默认设置信息。需要特殊说明的有以下几项。
boot:floppy
:相当于设置BIOS的启动项,此处为软盘启动。floppya:type=1_44,1_44="boot.img",status=inserted,write_protected=0
:设置插入软盘的类型为容量1.44 MB的软盘,软盘镜像文件的文件名为boot.img,状态是已经插入,写保护开关置于关闭状态。cpu
与cpuid
:这两个选项描述了处理器的相关信息,可以根据个人需求自行设定,在.bochsrc文件中也有详细说明可供参考。megs:2048
:设置虚拟平台的可用物理内存容量,以MB为单位。目前,Bochs虚拟软件可用的内存上限是2048 MB(2 GB),如果操作系统没有足够内存,Bochs会运行失败,失败时的提示信息大致如下所示:terminate called after throwing an instance of 'std::bad_alloc' what(): std::bad_alloc Aborted (core dumped)
补充说明 如果把配置项
display_library: x
修改为display_library: x,options="gui_debug"
,将开启图形界面的调试窗口。Bochs相关的调试命令
Bochs虚拟机软件最大的优点是,在虚拟平台运行时可以通过命令对其进行调试,表2-1罗列出了经常使用的调试命令。
表2-1 Bochs调试命令
指令 说明 举例 b address
在某物理地址上设置断点 b 0x7c00
c
继续执行,直到遇到断点 c
s
单步执行 s
info cpu
查看寄存器信息 info cpu
r
查看寄存器信息 r
sreg
查看寄存器信息 sreg
creg
查看寄存器信息 creg
xp /nuf addr
查看内存物理地址内容 xp /10bx 0x100000
x /nuf addr
查看线性地址内容 x /40wd 0x90000
u start end
反汇编一段内存 u 0x100000 0x100010
注:
n
代表显示单元个数;u
代表显示单元大小[b
:Byte
、h
:Word
、w
:DWord
、g
:QWord
(四字节)];f
代表显示格式(x
:十六进制、d
:十进制、t
:二进制、c
:字符)。以上这些命令都会在今后的系统开发中使用到。如果一开始就让代码运行在物理平台上,一旦出现问题,错误分析工作会变得举步维艰,甚至连查看寄存器状态和内存区数据这类小事,都会变得茫然失措、无从下手,濒临绝望。考虑到这些原因,就先让我们的程序在Bochs虚拟机里运行一段时间,待到时机成熟后再把它移植到物理平台上运行。
2.2 汇编语言
汇编语言的书写格式大体分为两种,一种是AT&T汇编语言格式,另一种是Intel汇编语言格式。这两种书写格式并不会影响汇编指令的功能,而且它们都有相应的编译器支持。
Intel汇编语言格式书写简洁,使用起来会比较舒服,支持它的编译器有MASM编译器、NASM编译器和YASM编译器。而AT&T汇编语言格式相对来说会复杂一些,支持它的编译器是GNU的GAS编译器。
对本书操作系统而言,BootLoader部分将采用Intel格式的汇编语言编写,使用NASM编译器进行编译;操作系统的内核与应用程序将采用AT&T格式的汇编语言编写,使用GNU的GAS编译器进行编译。同时使用这两种汇编语言书写格式是有原因的,可以概括为以下两点。
- 由于BootLoader全部使用汇编语言编写,代码量大,如果采用Intel格式的汇编语言,可以保证既书写简单又便于阅读。
- 内核和应用程序只有一小部分关键代码必须使用汇编语言编写,绝大部分代码会使用GNU C语言编写,那么为GNU C语言搭配上AT&T格式的GNU汇编语言,可使两者更加自然流畅地相互调用,进而提高两者的互相兼容性。
C语言和汇编语言经常会出现互相调用的情况,其中汇编语言调用C语言的过程最为复杂。稍后将通过一节篇幅专门对其进行讲解。
2.2.1 AT&T汇编语言格式与Intel汇编语言格式
AT&T汇编语言格式与Intel汇编语言格式在指令的功能上并无太大区别,但在书写格式、赋值方向、前缀等方面却各有各的特点。表2-2对这两种汇编语言格式进行了对比。
表2-2 AT&T汇编语言格式与Intel汇编语言格式对比表
|
Intel汇编语言格式 |
AT&T汇编语言格式 |
---|---|---|
书写格式 |
大多数编译器要求关键字必须使用大写字母书写,如: |
编译器要求关键字必须使用小写字母书写,如: |
赋值方向 |
指令通常带有两个操作数,一个是目的操作数,另一个是源操作数,赋值方向从右向左。 |
与Intel汇编语言格式恰恰相反,第一个是源操作数,第二个是目的操作数,赋值方向从左向右 |
操作数前缀 |
使用寄存器和立即数无需额外添加前缀。例如: |
使用寄存器必须在前面添加指令前缀 |
跳转和调用指令 |
远跳转指令 |
对于远跳转指令和远调用指令必须使用前缀l加以修饰,与 |
内存间接寻址格式 |
Intel使用 |
AT&T使用 |
指令的后缀 |
使用内存操作数时应该对操作数的位宽加以限定,借助修饰符 |
AT&T语法中大部分指令在访问数据时都需要指明操作数的位宽,通常一个字节用 |
2.2.2 NASM编译器
想必,许多读者在学习汇编语言时都是从Intel处理器的i386汇编语言开始,使用的编译器很可能是MASM(Microsoft Macro Assembler)。本节介绍的NASM编译器在语法和书写格式上,与MASM编译器比较相似,值得说明的有以下几点。
符号[ ]
在NASM编译器中,如果直接引用变量名或者标识符,则被编译器认为正在引用该变量的地址。如果希望访问变量里的数据,则必须使用符号[]。如果这样不太容易记忆,那么可以把它想象成C语言里的数组,数组名代表数组的起始地址,当为数组名加上符号[ ]后,就表示正在引用数组的元素。
符号
$
符号
$
在NASM编译器中代表当前行被编译后的地址。这么说好像不太容易理解,那么请看下面这行代码:jmp $
这条汇编指令的功能是死循环,将它翻译成十六进制机器码是E9 FD FF。其中,机器码E9的意思是跳转,而机器码FD FF用于确定跳转的目标地址,由于x86处理器是以小端模式保存数据的,所以机器码转换为地址偏移值是0xfffd,即十进制数-3。从机器码E9可知,这个
JMP
指令完成的动作是相对跳转,跳转的目标地址是在当前指令地址减3处,这条指令的长度为3个字节,所以处理器又回到这条指令处重新执行。符号$
在上述过程中指的是机器码E9之前的位置。符号
$$
明白了符号
$
,那么,符号$$
又是什么意思呢?其实,它代表一个Section(节)起始处被编译后的地址,也就是这个节的起始地址。编写小段的汇编程序时,通常使用一个Section即可,只有在编写复杂程序时,才会用到多个Section。Section既可以是数据段,也可以是代码段。不能把Section比喻成函数,这是不恰当的。提示 在编写代码的过程中,时常使用代码
$-$$
,它表示本行程序距离Section起始处的偏移。如果只有一个节,它便表示本行程序距离程序起始处的距离。在第3章中,我们会把它与关键字times
联合使用,来计算将要填充的数据长度,示例代码如下:times 512 - ($ - $$) db 0
2.2.3 使用汇编语言调用C语言的函数
在开发操作系统时,常常会从汇编程序跳转至C语言的函数中执行。比如,从系统引导程序(汇编程序)跳转到系统内核主函数中,或者从中断处理入口程序(汇编程序)跳转到中断处理函数(属于中断上半部)中等。这些汇编语言调用C语言的过程都会涉及函数的调用约定、参数的传递方式、函数的调用方式等技术细节,下面就来逐一讲解这些知识点。
函数的调用方式
汇编语言调用函数的方式并没有想象中的那么复杂,通过汇编指令
JMP
、CALL
、RET
及其变种指令就可实现。为了更好理解整个调用过程,请先看下面这段代码:int test() { int i = 0; i = 1 + 2; return i; } int main() { test(); return 0; }
这段程序非常简单,唯一要注意的地方是主函数
main
的返回值,此处建议主函数的返回值使用int
类型,而不要使用void
或者其他类型。虽然主函数执行到return 0
以后就跟我们没有关系了,但在回收进程的过程中可能要求主函数要有返回值,或者某些场合会用到主函数的返回值。考虑到上述原因,请读者尽量使用int
类型,如果处于某种特殊的、可预测的环境,则无需遵照此条建议。接下来,反汇编这段代码编译出的程序,让我们从汇编语言的角度去看看函数
test
的调用过程。使用objdump
命令可以把目标程序反编译成汇编语言,该命令提供了诸多参数,通过这些参数可以从目标程序中反编译出各类想要的数据信息。读者可以参考以下命令对test
程序进行反汇编:objdump -d test
经过
objdump
命令的反编译工作后,程序代码段内的数据将会以汇编语言形式显示出来。过滤掉多余的代码后,以下是test
函数和main
函数的反汇编代码片段:0000000000400474 <test>: 400474: 55 push %rbp 400475: 48 89 e5 mov %rsp,%rbp 400478: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp) 40047f: c7 45 fc 03 00 00 00 movl $0x3,-0x4(%rbp) 400486: 8b 45 fc mov -0x4(%rbp),%eax 400489: c9 leaveq 40048a: c3 retq 000000000040048b <main>: 40048b: 55 push %rbp 40048c: 48 89 e5 mov %rsp,%rbp 40048f: b8 00 00 00 00 mov $0x0,%eax 400494: e8 db ff ff ff callq 400474<test> 400499: b8 00 00 00 00 mov $0x0,%eax 40049e: c9 leaveq 40049f: c3 retq
这段代码中的
000000000040048b<main> :
是程序的主函数main
,函数名前面的十六进制数000000000040048b
是函数的起始地址,每个数字占4位宽度共16个数字,这也间接说明该程序运行在16×4 = 64位地址宽度下。乍一看,有好多个
%
符号。还记得2.2.1节里讲的AT&T汇编语法格式吗?这就是引用寄存器时必须在前面添加的符号前缀。还有一些汇编指令加入了后缀字母l
和q
,字母l
表示操作数的位宽是32位(一个双字),字母q
表示操作数的位宽是64位(一个四字)。此段中的代码
leaveq
等效于movq %rbp, %rsp; popq %rbp;
,其中的rsp
表示64位寄存器,它是32位寄存器ESP的扩展,其他通用寄存器同理。代码callq 400474
的意思是跳转到test
函数里执行,由此看来,汇编语言调用C语言的函数还是非常简单的。如果使用JMP
汇编指令替换CALL
指令,依然可以获得同样的效果。从它们的区别来看,CALL
指令会把其后的那条指令的地址压入栈中,作为调用的返回地址,也就是代码中的0000000000400499
地址处,随后再跳转至test
函数里执行,而JMP
指令却不会把返回地址0000000000400499
压入栈中。一旦test
函数执行完毕,便会执行代码retq
把栈中的返回地址弹出到RIP
寄存器中,进而返回到主函数main
中继续执行。由于JMP
指令没有返回地址入栈的操作,通过以下伪代码即可替代CALL
指令:pushq $0x0000000000400499 jmpq 400474 <test>
CALL
指令还可以被RET
指令所取代,在执行RET
指令时,该指令会弹出栈中保存的返回地址,并从返回地址处继续执行。根据RET
指令的执行动作,可先将返回地址0000000000400499
压入栈中,再把test
函数的入口地址0000000000400474
压入栈中,此时跳转地址和返回地址均已存入栈中,紧接着执行RET
指令,以调用返回的形式从主函数main
“返回”到test
函数。以下是RET
指令取代CALL
指令的伪代码:pushq $0x0000000000400499 pushq $0x0000000000400474 retq
整个实现过程是不是没有想象中的那么困难?当掌握了汇编指令的原理后,任何指令皆可灵活运用,希望本节内容可以启发读者的设计灵感!
函数的调用约定
函数的调用约定描述了执行函数时返回地址和参数的出入栈规律。不同公司开发的C语言编译器都有各自的函数调用约定,而且这些调用约定的差异性很大。随着IBM兼容机对市场进行洗牌后,微软操作系统和编程工具占据了统治地位。除微软之外,仍有零星的几家公司和开源项目GNU C在维护自己的调用约定。下面将介绍几款比较流行的函数调用约定。
stdcall调用约定
在调用函数时,参数将按照从右向左的顺序依次压入栈中,例如下面的
function
函数,其参数入栈顺序依次是second
、first
:int function(int first,int second)
函数的栈平衡操作(参数出栈操作)是由被调用函数完成的。通过代码
retn x
可在函数返回时从栈中弹出x
字节的数据。当CPU执行RET
指令时,处理器会自动将栈指针寄存器ESP向上移动x
个字节,来模拟栈的弹出操作。例如上面的function
函数,当function
函数返回时,它会执行该指令把参数second
和first
从栈中弹出来,再到返回地址处继续执行。- 在函数的编译过程中,编译器会在函数名前用下划线修饰,其后用符号
@
修饰,并加上入栈的字节数,因此函数function
最终会被编译为_function@8
。
cdecl调用约定
- cdecl调用约定的参数压栈顺序与stdcall相同,皆是按照从右向左的顺序将参数压入栈中。
- 函数的栈平衡操作是由调用函数完成的,这点与stdcall恰恰相反。stdcall调用约定使用代码
retn x
平衡栈,而cdecl调用约定则通常会借助代码leave
、pop
或向上移动栈指针等方法来平衡栈。 每个函数调用者都含有平衡栈的代码,因此编译生成的可执行文件会较stdcall调用约定生成的文件大。
cdecl是GNU C编译器的默认调用约定。但GNU C在64位系统环境下,却使用寄存器作为函数参数的传递方式。函数调用者按照从左向右的顺序依次将前6个整型参数放在通用寄存器RDI、RSI、RDX、RCX、R8和R9中;同时,寄存器XMM0~XMM7用来保存浮点变量,而RAX寄存器则用于保存函数的返回值,函数调用者负责平衡栈。
- fastcall调用约定
- fastcall调用约定要求函数参数尽可能使用通用寄存器ECX和EDX来传递参数,通常是前两个
int
类型的参数或较小的参数,剩余参数再按照从右向左的顺序逐个压入栈中。 - 函数的栈平衡操作由被调用函数负责完成。
- fastcall调用约定要求函数参数尽可能使用通用寄存器ECX和EDX来传递参数,通常是前两个
除此之外,还有很多调用约定,如thiscall、nakedcall、pascal等,有兴趣的读者可以自行研究。
参数传递方式
在知晓函数的调用约定后不难发现,参数的传递方式无外乎两种,一种是寄存器传递方式,另一种是内存传递方式。由于这两种参数传递方式在通常情况下都可以满足开发要求,所以参数的传递方式并不会被特殊关注。但在编写操作系统的过程中存在许多要求苛刻的场景,使得我们不得不掌握这两种参数传递方式的特点。
寄存器传递方式。寄存器传递方式就是通过寄存器来传递函数的参数。此种传递方式的优点是执行速度快,只有少数调用约定默认使用寄存器来传递参数的,而绝大部分编译器需要特殊指定传递参数的寄存器 。
在基于x86体系结构的Linux内核中,系统调用API一般会使用寄存器传递方式。因为,应用层空间与内核层空间是相隔离的,若想从应用层把参数传递至内核层,最便捷的方法是通过寄存器来携带参数,否则就只能大费周折地在两个层之间搬运数据。更详细的解释会在第4章中给出。
内存传递方式。在大多数情况下,函数参数都是以压栈方式传递到目标函数中的。
同在x86体系结构的Linux内核中,中断处理过程和异常处理过程都会使用内存传参方式。(从Linux 2.6开始逐渐改为寄存器传递方式。)因为从中断/异常产生到调用相应的处理,这期间的过渡代码全部由汇编语言编写。在汇编语言跳转至C语言函数的过程中,C语言函数使用栈来传递参数,为了保证两种开发语言的无缝衔接,在汇编代码中必须把参数压入栈中,然后再跳转到C语言实现的中断处理函数中执行。
以上内容均是基于x86体系结构的参数传递方式。而在x64体系结构下,大多数编译器选择寄存器传参方式。
2.3 C语言
我想绝大部分读者对C语言并不陌生,但由于它的灵活性仅次于变幻莫测的汇编语言,即使作者本人也不敢说熟练掌握或精通C语言。由于个人能力有限,下面仅对本书操作系统的主要开发语言(GNU C语言)进行讲解,整个讲解过程侧重于内嵌汇编语言和标准C语言扩展两个方面。
2.3.1 GNU C内嵌汇编语言
在很多操作系统开发场景中,C语言依然无法完全代替汇编语言。例如,操作某些特殊的CPU寄存器、操作主板上的某些IO端口或者对性能要求极为苛刻的场景等,此时我们必须在C语言内嵌入汇编语言来满足上述要求。
GNU C语言提供了关键字asm
来声明代码是内嵌的汇编语句,如下面这行代码:
#define nop() __asm__ __volatile__ ("nop \n\t")
这条内嵌汇编语句的作用可从函数名中知晓,它正是nop
函数(空操作函数)的实现,同时该函数也是本书系统内核支持的一个库函数。那就让我们从nop
函数入手,开启GNU C内嵌汇编语言的学习之旅。
从nop
函数中可知,C语言使用关键字__asm__
和__volatile__
对汇编语句加以修饰,这两个关键字在C语言内嵌汇编语句时经常使用。
__asm__
关键字:用于声明这行代码是一个内嵌汇编表达式,它是关键字asm
的宏定义(#define __asm__ asm
)。故此,它是内嵌汇编语言必不可少的关键字,任何内嵌的汇编表达式都以此关键字作为开头;如果希望编写符合ANSI C标准的代码(即与ANSI C标准相兼容),那么建议使用关键字__asm__
。__volatile__
关键字:其作用是告诉编译器此行代码不能被编译器优化,编译时保持代码原状。由此看来,它也是内嵌汇编语言不可或缺的关键字,否则经过编译器优化后,汇编语句很可能被修改以至于无法达到预期的执行效果。如果期望编写处符合ANSI C标准的程序(即与ANSI C标准兼容),那么建议使用关键字__volatile__
。
GNU C语言的内嵌汇编表达式并非像nop
函数一般简单,它有着极为复杂的书写格式。接下来将书写格式分为内嵌汇编表达式、操作约束和修饰符、序号占位符三部分进行讲解。
内嵌汇编表达式
尽管C语言经过汇编阶段后会被解释成汇编语言,但两者毕竟是不同的开发语言,为了在C语言内融入一段汇编代码片段,那就必须在每次嵌入汇编代码前做一番准备工作,因此在C语言里嵌入汇编代码要比纯粹使用汇编代码复杂得多。嵌入前的准备工作主要负责确定寄存器的分配情况、与C程序的融合情况等细节,这些内容大部分需要在内嵌的汇编表达式中显式标明出来。
GNU C语言的内嵌汇编表达式由4部分构成,它们之间使用“:”号分隔,其完整格式为:
指令部分:输出部分:输入部分:损坏部分
如果将内嵌汇编表达式当作函数,指令部分是函数中的代码,输入部分用于向函数传入参数,而输出部分则可以理解为函数的返回值。以下是这4部分功能的详细解释。
指令部分是汇编代码本身,其书写格式与AT&T汇编语言程序的书写格式基本相同,但也存在些许不同之处。指令部分是内嵌汇编表达式的必填项,而其他部分视具体情况而定,如果不需要的话则可以直接忽略。在最简单的情况下,指令部分与常规汇编语句基本相同,如
nop
函数。指令部分的编写规则要求是:当指令表达式中存在多条汇编代码时,可全部书写在一对双引号中;亦可将汇编代码放在多对双引号中。如果将所有指令编写在同一双引号中,那么相邻两条指令间必须使用分号(
;
)或换行符(\n
)分隔。如果使用换行符,通常在其后还会紧跟一个制表符(\t
)。当汇编代码引用寄存器时,必须在寄存器名前再添加一个%
符,以表示对寄存器的引用,例如代码"movl $0x10,%%eax"
。输出部分紧接在指令部分之后,这部分记录着指令部分的输出信息,其格式为:“输出操作约束”(输出表达式),“输出操作约束”(输出表达式),……。格式中的输出操作约束和输出表达式成对出现,整个输出部分可包含多条输出信息,每条信息之间必须使用逗号“,”分隔开。
- 括号内的输出表达式部分主要负责保存指令部分的执行结果。通常情况下,输出表达式是一个变量。
- 双引号内的部分,被称为“输出操作约束”,也可简称为“输出约束”。输出约束部分必须使用等号“
=
”或加号“+
”进行修饰。这两个符号的区别是,等号“=
”意味着输出表达式是一个纯粹的输出操作,加号“+
”意味着输出表达式既用于输出操作,又用于输入操作。不论是等号“=
”还是加号“+
”,它们只能用在输出部分,不能出现在输入部分,而且是可读写的。关于输出约束的更多内容,将在“操作约束和修饰符”中进行补充。
- 输入部分记录着指令部分的输入信息,其格式为:“输入操作约束”(输入表达式),“输入操作约束”(输入表达式),……。格式中的输入操作约束与输入表达式同样要求成对出现,整个输入部分亦可包含多条输入信息,并用逗号“,”分隔开。在输入操作约束中不允许使用等号“
=
”和加号“+
”,因此输入部分是只读的。 损坏部分描述了在指令部分执行的过程中,将被修改的寄存器、内存空间或标志寄存器,并且这些修改部分并未在输出部分和输入部分出现过,格式为:“损坏描述”,“损坏描述”,……。如果需要声明多个寄存器,则必须使用逗号“,”将它们分隔开,这点与输入/输出部分一致。
寄存器修改通知。这种情况一般发生在寄存器出现于指令部分,又不是输入/输出操作表达式指定的寄存器,更不是编译器为
r
或g
约束选择的寄存器。如果该寄存器被指令部分所修改,那么就应该在损坏部分加以描述,比如下面这行代码:__asm__ __volatile__ ("movl %0,%%ecx"::"a"(__tmp):"cx");
这段汇编表达式的指令部分修改了寄存器ECX的值,却未被任何输入/输出部分所记录,那么必须在损坏部分加以描述,一旦编译器发现后续代码还要使用它,便会在内嵌汇编语句的过程中做好数据保存与恢复工作。如果未在损坏部分描述,则很可能会影响后续程序的执行结果。
注意,已在损坏部分声明的寄存器,不能作为输入/输出操作表达式的寄存器约束,也不会被指派为
q
、r
、g
约束的寄存器。如果在输入/输出操作表达式中已明确选定寄存器,或者使用q
、r
、g
约束让编译器指派寄存器时,编译器对这些寄存器的状态非常清楚,它知道哪些寄存器将会被修改。除此之外,编译器对指令部分修改的寄存器却一无所知。内存修改通知。除了寄存器的内容会被篡改外,内存中的数据同样会被修改。如果一个内嵌汇编语句的指令部分修改了内存数据,或者在内嵌汇编表达式出现的地方,内存数据可能发生改变,并且被修改的内存未使用
m
约束。此时,应该在损坏部分使用字符串memory
,向编译器声明内存会发生改变。如果损坏部分已经使用
memory
对内存加以约束,那么编译器会保证在执行汇编表达式之后,重新向寄存器装载已引用过的内存空间,而非使用寄存器中的副本,以防止内存与副本中的数据不一致。标志寄存器修改通知。当内嵌汇编表达式中包含影响标志寄存器
R|EFLAGS
的指令时,必须在损坏部分使用cc
来向编译器声明这一点。
操作约束和修饰符
每个输入/输出表达式都必须指定自身的操作约束。操作约束的类型可以细分为寄存器约束、内存约束和立即数约束。在输出表达式中,还有限定寄存器操作的修饰符。
寄存器约束限定了表达式的载体是一个寄存器,这个寄存器可以明确指派,亦可模糊指派再由编译器自行分配。寄存器约束可使用寄存器的全名,也可以使用寄存器的缩写名称,如下所示:
__asm__ __volatile__("movl %0,%%cr0"::"eax"(cr0)); __asm__ __volatile__("movl %0,%%cr0"::"a"(cr0));
如果使用寄存器的缩写名称,那么编译器会根据指令部分的汇编代码来确定寄存器的实际位宽。表2-3记录了常用的约束缩写名称。
表2-3 常用约束缩写名称表
缩写 描述 r 任何输入/输出型的寄存器 q 从EAX/EBX/ECX/EDX中指派一个寄存器 g 寄存器或内存空间 m 内存空间-f-选用浮点寄存器 a 使用RAX/EAX/AX/AL寄存器 b 使用RBX/EBX/BX/BL寄存器 c 使用RCX/ECX/CX/CL寄存器 d 使用RDX/EDX/DX/DL寄存器 D 使用RDI/EDI/DI寄存器 S 使用RSI/ESI/SI寄存器 i 一个整数类型的立即数 F 一个浮点类型的立即数 内存约束限定了表达式的载体是一个内存空间,使用约束名
m
表示。例如以下内嵌汇编表达式:__asm__ __volatile__ ("sgdt %0":"=m"(__gdt_addr)::); __asm__ __volatile__ ("lgdt %0"::"m"(__gdt_addr));
立即数约束只能用于输入部分,它限定了表达式的载体是一个数值,如果不想借助任何寄存器或内存,那么可以使用立即数约束,比如下面这行代码:
__asm__ __volatile__("movl %0,%%ebx"::"i"(50));
使用约束名
i
限定输入表达式是一个整数类型的立即数,如果希望限定输入表达式是一个浮点数类型的立即数,则使用约束名F
。立即数约束只能使用在输入部分。修饰符只可用在输出部分,除了等号
=
和加号+
外,还有&
符。符号&
只能写在输出约束部分的第二个字符位置上,即只能位于=
和+
之后,它告诉编译器不得为任何输入操作表达式分配该寄存器。因为编译器会在输入部分赋值前,先对&
符号修饰的寄存器进行赋值,一旦后面的输入操作表达式向该寄存器赋值,将会造成输入和输出数据混乱。
补充说明 只有在输入约束中使用过模糊约束(使用
q
、r
或g
等约束缩写)时,在输出约束中使用符号&
修饰才有意义!如果所有输入操作表达式都明确指派了寄存器,那么输出约束再使用符号&
就没有任何意义。如果没有使用修饰符&
,那就意味着编译器将先对输入部分进行赋值,当指令部分执行结束后,再对输出部分进行操作。序号占位符
序号占位符是输入/输出操作约束的数值映射,每个内嵌汇编表达式最多只有10条输入/输出约束,这些约束按照书写顺序依次被映射为序号0~9。如果指令部分想引用序号占位符,必须使用百分号
%
前缀加以修饰,例如序号占位符%0
对应第1个操作约束,序号占位符%1
对应第2个操作约束,依次类推。指令部分为了区分序号占位符和寄存器,特使用两个百分号(%%)
对寄存器加以修饰。在编译时,编译器会将每个占位符代表的表达式替换到相应的寄存器或内存中。指令部分在引用序号占位符时,可以根据需要指定操作位宽是字节或者字,也可以指定操作的字节位置,即在
%
与序号占位符之间插入字母b
表示操作最低字节,或插入字母h
表示操作次低字节。
2.3.2 GNU C语言对标准C语言的扩展
为了提高C语言的易用性和开发效率,GNU C语言在标准C语言的基础上引入了诸多人性化的扩展。下面主要讲解今后开发操作系统将会涉及的技巧,和平时研发过程中使用频率比较高的内容。
柔性数组成员(或称零长数组、变长数组)
GNU C 语言允许使用长度为0的数组来增强结构体的灵活性,其在动态创建结构体时有着非常明显的优势,例如下面这几行代码:
struct s {int n;long d[0];}; int m = 数值; struct s *p = malloc(sizeof (struct s) + sizeof (long [m]));
struct s
结构体中的数组成员变量d
在作用上与指针极为相似,但是在为指针p
开辟存储空间时却仅需执行一次malloc
函数。由此可见,柔性数组成员不仅能够减少内存空间的分配次数提高程序执行效率,还能有效保持结构体空间的连续性。case
关键字支持范围匹配GNU C语言允许
case
关键字匹配一个数值范围,由此可以取代多级的if
条件检测语句。以下这段代码的执行条件是待匹配字符为小写字母:case 'a'...'z': /*from 'a' to 'z'*/ break;
typeof
关键字获取变量类型借助关键字
typeof(x)
可以取得变量x
的数据类型,在编写宏定义时,关键字typeof
经常会派上用场。可变参数宏
在GNU C语言中宏函数允许使用可变参数类型,例如:
#define pr_debug(fmt,arg...) \ printk(fmt,##arg)
在这段代码中,当可变参数
arg
被忽略或为空时,printk
函数中的##
操作将迫使预处理器去掉它前面的那个逗号。如果在调用宏函数时,确实提供了若干个可变参数,那么GNU C会把这些可变参数放到逗号后面,使其能够正常工作。元素编号
标准C语言规定数组和结构体必须按照固定顺序对成员(或元素)进行初始化赋值。GNU C语言为使数组和结构体初始化更加自由,特意放宽了此限制,使得数组可以在初始化期间借助下标对某些元素(元素可以是连续的或者不连续的)进行赋值,并在结构体初始化过程中允许使用成员名直接对成员进行赋值。与此同时,GNU C语言还允许数组和结构体按照任意顺序对成员(或元素)进行初始化赋值。以下是两者的初始化实例:
unsigned char data[MAX] = { [0]=10, [10 ... 50]=100, [55]=55, }; struct file_operations ext2_file_operations= { open:ext2_open, close:ext2_close, };
Linux 2.6以后的内核源码已经开始使用上述初始化扩展。读者在编写Linux驱动时,推荐采用以下初始化方式:
struct file_operations ext2_file_operations= { .read=ext2_read, .write=ext2_write, };
当前函数名
GNU C语言为当前函数的名字准备了两个标识符,它们分别是
__PRETTY__FUNCTION__
和__FUNCTION__
,其中__FUNCTION__
标识符保存着函数在源码中的名字,__PRETTY__FUNCTION__
标识符则保存着带有语言特色的名字。在C函数中,这两个标识符代表的函数名字相同,参考代码如下所示:void func_example() { printf("the function name is %s",__FUNCTION__); }
在C99标准中,只规定标识符
__func__
能够代表函数的名字,而__FUNCTION__
虽被各类编译器广泛支持,但只是__func__
标识符的宏别名。特殊属性声明
GNU C语言还允许使用特殊属性对函数、变量和类型加以修饰,以便对它们进行手工代码优化和定制。在声明处加入关键字
__attribute__((ATTRIBUTE))
即可指定特殊属性,关键字中的ATTRIBUTE
是属性说明,如果存在多个属性,必须使用逗号隔开。目前GNU C语言支持的属性说明有noreturn
、noinline
、always_inline
、pure
、const
、nothrow
、format
、format_arg
、no_instrument_function
、section
、constructor
、destructor
、used
、unused
、deprecated
、weak
、malloc
、aliaswarn_unused_result nonnull
等。noreturn
属性用来修饰函数,表示该函数从不返回。这会使编译器在优化代码时剔除不必要的警告信息。例如:#define ATTRIB_NORET __attribute__((noreturn)) .... asmlinkage NORET_TYPE void do_exit(long error_code) ATTRIB_NORET;
packed
属性的作用是取消结构在编译时的对齐优化,使其按照实际占用字节数对齐。这个属性经常出现在协议包的定义中,如果在定义协议包结构体时加入了packed
属性,那么编译器会取消各个成员变量间的对齐填充,按照实际占用字节数进行对齐。例如下面这个结构体,它的实际内存占用量为1 B+4 B+8 B=13 B:struct example_struct { char a; int b; long c; } __attribute__((packed));
regparm(n)
属性用于以指定寄存器传递参数的个数,该属性只能用在函数定义和声明里,寄存器参数的上限值为3(使用顺序为EAX、EDX、ECX)。如果函数的参数个数超过3,那么剩余参数将使用内存传递方式。值得注意的一点是,
regparm
属性只在x86处理器体系结构下有效,而在x64体系结构下,GUN C语言使用寄存器传参方式作为函数的默认调用约定。无论是否采用regparm
属性加以修饰,函数都会使用寄存器来传递参数,即使参数个数超过3,依然使用寄存器来传递参数,具体细节遵照cdecl调用约定。请看下面这个例子:int q = 0x5a; int t1 = 1; int t2 = 2; int t3 = 3; int t4 = 4; #define REGPARM3 __attribute((regparm(3))) #define REGPARM0 __attribute((regparm(0))) void REGPARM0 p1(int a) { q = a + 1; } void REGPARM3 p2(int a, int b, int c, int d) { q = a + b + c + d + 1; } int main() { p1(t1); p2(t1,t2,t3,t4); return 0; }
使用下面这条
objdump
命令将这段程序反汇编,让我们从汇编级来看看regparm
属性对函数调用约定的影响:objdump -D 可执行程序
此条命令中的选择
-D
用于反汇编程序中的所有段,包括代码段、数据段、只读数据段以及其他辅助段等。而此前使用过的选项-d
只能反汇编出程序的代码段。以下是反汇编出的部分程序片段:Disassembly of section .text: 0000000000400474 <p1>: 400474: 55 push %rbp 400475: 48 89 e5 mov %rsp,%rbp 400478: 89 7d fc mov %edi,-0x4(%rbp) 40047b: 8b 45 fc mov -0x4(%rbp),%eax 40047e: 83 c0 01 add $0x1,%eax 400481: 89 05 3d 04 20 00 mov %eax,0x20043d(%rip) #6008c4 <q> 400487: c9 leaveq 400488: c3 retq 0000000000400489 <p2>: 400489: 55 push %rbp 40048a: 48 89 e5 mov %rsp,%rbp 40048d: 89 7d fc mov %edi,-0x4(%rbp) 400490: 89 75 f8 mov %esi,-0x8(%rbp) 400493: 89 55 f4 mov %edx,-0xc(%rbp) 400496: 89 4d f0 mov %ecx,-0x10(%rbp) 400499: 8b 45 f8 mov -0x8(%rbp),%eax 40049c: 8b 55 fc mov -0x4(%rbp),%edx 40049f: 8d 04 02 lea (%rdx,%rax,1),%eax 4004a2: 03 45 f4 add -0xc(%rbp),%eax 4004a5: 03 45 f0 add -0x10(%rbp),%eax 4004a8: 83 c0 01 add $0x1,%eax 4004ab: 89 05 13 04 20 00 mov %eax,0x200413(%rip) # 6008c4 <q> 4004b1: c9 leaveq 4004b2: c3 retq 00000000004004b3 <main>: 4004b3: 55 push %rbp 4004b4: 48 89 e5 mov %rsp,%rbp 4004b7: 53 push %rbx 4004b8: 8b 05 0a 04 20 00 mov 0x20040a(%rip),%eax #6008c8 <t1> 4004be: 89 c7 mov %eax,%edi 4004c0: e8 af ff ff ff callq 400474<p1> 4004c5: 8b 0d 09 04 20 00 mov 0x200409(%rip),%ecx #6008d4 <t4> 4004cb: 8b 15 ff 03 20 00 mov 0x2003ff(%rip),%edx #6008d0 <t3> 4004d1: 8b 1d f5 03 20 00 mov 0x2003f5(%rip),%ebx #6008cc <t2> 4004d7: 8b 05 eb 03 20 00 mov 0x2003eb(%rip),%eax #6008c8 <t1> 4004dd: 89 de mov %ebx,%esi 4004df: 89 c7 mov %eax,%edi 4004e1: e8 a3 ff ff ff callq 400489<p2> 4004e6: b8 00 00 00 00 mov $0x0,%eax 4004eb: 5b pop %rbx 4004ec: c9 leaveq 4004ed: c3 retq 4004ee: 90 nop 4004ef: 90 nop Disassembly of section .data: 00000000006008c0 <__data_start>: 6008c0: 00 00 add %al,(%rax) ... 00000000006008c4 <q>: 6008c4: 5a pop %rdx 6008c5: 00 00 add %al,(%rax) ... 00000000006008c8 <t1>: 6008c8: 01 00 add %eax,(%rax) ... 00000000006008cc <t2>: 6008cc: 02 00 add (%rax),%al ... 00000000006008d0 <t3>: 6008d0: 03 00 add (%rax),%eax ... 00000000006008d4 <t4>: 6008d4: 04 00 add $0x0,%al ...
如果读者参照2.2.3节中描述的cdecl调用约定可知,在x64体系结构下,函数采用寄存器传参方式。而此段代码也确实通过寄存器向函数
p1
和p2
传递参数,按照从左至右的顺序依次使用RDI、RSI、RDX、RCX这4个寄存器,这却与regparm
属性的规定完全不一致。由此看来,在基于x64体系结构的GNU C语言环境中,属性regparm
已经不再起作用了。