本章介绍编写本书操作系统所需的基础知识、系统环境及环境搭建方法,大家不必在这方面耗费太多精力,本着够用就好的原则即可。
本书操作系统开发使用的系统环境是Windows 7系统,编译环境是Linux的开源发行版CentOS 6。因此,作者将借助VMware虚拟机软件在Windows 7系统下创建了一个虚拟平台,再由此平台搭载CentOS 6操作系统。虽然我们的意图是在物理平台上运行操作系统,但如果在操作系统开发初期就使用物理平台的话,代码调试工作将变得十分艰难。故此,在开发初期使用Bochs虚拟机来调试我们的操作系统是个不错的选择。
在基础知识方面,不管你是精通C语言和汇编语言、能够写出高效且晦涩的代码的大神,还是初学编程语言抱着谭浩强的《C语言程序设计》乱啃的菜鸟,都请你们静下心来读完这一章再上路。本章可以作为复习章,亦可作为提高自己知识技能的学习章,其中涉及的知识点都很重要,如果不了解这些知识,往后的内容你会学得很吃力。不经一番寒彻骨,怎得梅花扑鼻香。我们今天的止步不前,是为了明天大踏步的前进。
研发任何一款软件都需要有完整的开发环境,研发操作系统也不例外。
开发操作系统主要使用汇编语言和C语言,再加上些许灵活多变的设计思想即可。开发应用程序可以借助丰富的调试工具和系统开发库的支持,而开发操作系统一切皆需要从零做起。
随着开源免费软件大军逐渐壮大,为了避免版权问题和收费软件的麻烦,Linux家族的操作系统已成为开发环境的首选。VMware虚拟机软件以稳定、方便、灵活、功能强大等特点深受开发者们的喜爱。Windows、Linux或Mac OS系统平台都能创建出一个表现出众的虚拟平台。Linux开源操作系统与VMware虚拟机软件经常会组合在一起使用。
开源的轻量级虚拟机Bochs,不仅可以运行虚拟平台,还能够在平台运行期间对平台进行调试,从而帮助我们度过一个个难关。当然,如果你手头有其他的可调试虚拟机,只要它具有设置断点、查看内存、查看寄存器状态、反汇编内存代码等基本功能,也可以使用。希望读者能够根据自己的喜好,搭建出一个顺手的开发环境。
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软件时,尽量以管理员权限运行,否则容易报错。
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分区为妙。
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文件夹内已为用户准备了一个默认的系统环境配置文件.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虚拟机软件最大的优点是,在虚拟平台运行时可以通过命令对其进行调试,表2-1罗列出了经常使用的调试命令。
表2-1 Bochs调试命令
注
:
n
代表显示单元个数;
u
代表显示单元大小[
b
:
Byte
、
h
:
Word
、
w
:
DWord
、
g
:
QWord
(四字节)];
f
代表显示格式(
x
:十六进制、
d
:十进制、
t
:二进制、
c
:字符)。
以上这些命令都会在今后的系统开发中使用到。如果一开始就让代码运行在物理平台上,一旦出现问题,错误分析工作会变得举步维艰,甚至连查看寄存器状态和内存区数据这类小事,都会变得茫然失措、无从下手,濒临绝望。考虑到这些原因,就先让我们的程序在Bochs虚拟机里运行一段时间,待到时机成熟后再把它移植到物理平台上运行。
汇编语言的书写格式大体分为两种,一种是AT&T汇编语言格式,另一种是Intel汇编语言格式。这两种书写格式并不会影响汇编指令的功能,而且它们都有相应的编译器支持。
Intel汇编语言格式书写简洁,使用起来会比较舒服,支持它的编译器有MASM编译器、NASM编译器和YASM编译器。而AT&T汇编语言格式相对来说会复杂一些,支持它的编译器是GNU的GAS编译器。
对本书操作系统而言,BootLoader部分将采用Intel格式的汇编语言编写,使用NASM编译器进行编译;操作系统的内核与应用程序将采用AT&T格式的汇编语言编写,使用GNU的GAS编译器进行编译。同时使用这两种汇编语言书写格式是有原因的,可以概括为以下两点。
C语言和汇编语言经常会出现互相调用的情况,其中汇编语言调用C语言的过程最为复杂。稍后将通过一节篇幅专门对其进行讲解。
AT&T汇编语言格式与Intel汇编语言格式在指令的功能上并无太大区别,但在书写格式、赋值方向、前缀等方面却各有各的特点。表2-2对这两种汇编语言格式进行了对比。
表2-2 AT&T汇编语言格式与Intel汇编语言格式对比表
想必,许多读者在学习汇编语言时都是从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
在开发操作系统时,常常会从汇编程序跳转至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调用约定
retn x
平衡栈,而cdecl调用约定则通常会借助代码
leave
、
pop
或向上移动栈指针等方法来平衡栈。
每个函数调用者都含有平衡栈的代码,因此编译生成的可执行文件会较stdcall调用约定生成的文件大。
cdecl是GNU C编译器的默认调用约定。但GNU C在64位系统环境下,却使用寄存器作为函数参数的传递方式。函数调用者按照从左向右的顺序依次将前6个整型参数放在通用寄存器RDI、RSI、RDX、RCX、R8和R9中;同时,寄存器XMM0~XMM7用来保存浮点变量,而RAX寄存器则用于保存函数的返回值,函数调用者负责平衡栈。
int
类型的参数或较小的参数,剩余参数再按照从右向左的顺序逐个压入栈中。
除此之外,还有很多调用约定,如thiscall、nakedcall、pascal等,有兴趣的读者可以自行研究。
在知晓函数的调用约定后不难发现,参数的传递方式无外乎两种,一种是寄存器传递方式,另一种是内存传递方式。由于这两种参数传递方式在通常情况下都可以满足开发要求,所以参数的传递方式并不会被特殊关注。但在编写操作系统的过程中存在许多要求苛刻的场景,使得我们不得不掌握这两种参数传递方式的特点。
寄存器传递方式 。寄存器传递方式就是通过寄存器来传递函数的参数。此种传递方式的优点是执行速度快,只有少数调用约定默认使用寄存器来传递参数的,而绝大部分编译器需要特殊指定传递参数的寄存器。
在基于x86体系结构的Linux内核中,系统调用API一般会使用寄存器传递方式。因为,应用层空间与内核层空间是相隔离的,若想从应用层把参数传递至内核层,最便捷的方法是通过寄存器来携带参数,否则就只能大费周折地在两个层之间搬运数据。更详细的解释会在第4章中给出。
内存传递方式 。在大多数情况下,函数参数都是以压栈方式传递到目标函数中的。
同在x86体系结构的Linux内核中,中断处理过程和异常处理过程都会使用内存传参方式。(从Linux 2.6开始逐渐改为寄存器传递方式。)因为从中断/异常产生到调用相应的处理,这期间的过渡代码全部由汇编语言编写。在汇编语言跳转至C语言函数的过程中,C语言函数使用栈来传递参数,为了保证两种开发语言的无缝衔接,在汇编代码中必须把参数压入栈中,然后再跳转到C语言实现的中断处理函数中执行。
以上内容均是基于x86体系结构的参数传递方式。而在x64体系结构下,大多数编译器选择寄存器传参方式。
我想绝大部分读者对C语言并不陌生,但由于它的灵活性仅次于变幻莫测的汇编语言,即使作者本人也不敢说熟练掌握或精通C语言。由于个人能力有限,下面仅对本书操作系统的主要开发语言(GNU C语言)进行讲解,整个讲解过程侧重于内嵌汇编语言和标准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 常用约束缩写名称表
内存约束
限定了表达式的载体是一个内存空间,使用约束名
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
表示操作次低字节。
为了提高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
已经不再起作用了。