操作系统内核Hack:(二)底层编程基础
在《操作系统内核Hack:(一)实验环境搭建》中,我们看到了一个迷你操作系统引导程序。尽管只有不到二十行,然而要完全看懂还是需要不少底层软硬件知识的。本文的目的就是跟大家一起学习这一部分知识,本着够用就行的原则,不会完全铺开来,只要能让我们顺利走完未来的操作系统内核Hack之旅就可以了。
1.开篇:“古怪”的80386
如果大家跳过这一部分直接看本文后面的部分,或者您之前接触过操作系统内核的学习,一定会觉得80386的行为很古怪。为什么开机之后要设置一大堆东西然后跳到一个叫做什么保护模式的东西?为什么内存管理要分段还要分页这么麻烦?这种种令人费解的现象背后其实是有一段不同寻常的历史的,了解了其来龙去脉,一切就变得理所当然了!
关于Intel的x86处理器整个家族史就不详谈了,否则可能要说上个几天几夜。我们主要关心具有里程碑意义的两款CPU:8086和80386。为什么只关注这两款CPU呢?首先通过一篇不错的资料简单了解一下这两款CPU的历史,再细说缘由。
80x86段式寻址的原因
Intel 8086是一个由Intel于1978年所设计的16位微处理器芯片,是x86架构的鼻祖。8086 CPU是Intel系列的16位微处理器,也就是说“算术逻辑运算单元(ALU)”的宽度,即数据总线是16位,可直接运算长度是16位的数据。但它却有20条地址线,可直接寻址1MB的存储空间(注:当时认为20根线寻址1MB已经绰绰有余了,详见流传的盖茨大叔语录)。于是Intel设计了一种在当时看来不失为巧妙的方法,即分段的方法。同时配合新引入的四个段寄存器:CS、DS、SS和ES,通过移位相加的方法实现了16位ALU产生20位地址的目的(注:别着急,后面我们会详说)。除此以外,还带来了程序地址不用硬编码(逻辑地址)等好处。
从80286开始,Intel引入了更为先进的保护模式。这种模式下内存段的访问受到了限制,访问内存时不能直接访问段式寻址计算出的地址了,而需要经过额外转换和检查。于是老式的8086方式被成为实模式。终于说到了我们的主角80386 CPU,它是一个32位的CPU,也就是它的ALU数据总线是32位的,同时它的地址总线与数据总线宽度一致也是32位,因此其寻址能力达到4GB。理论上说,当数据总线与地址总线宽度一致时,其CPU结构应该简洁明了。但是,80386无法做到这一点。作为X86产品系列的一员,80386必须维持那些段寄存器的存在,还必须支持实模式,同时又要能支持保护模式。这给Intel的设计人员带来很大的挑战,Intel选择了在段寄存器的基础上构筑保护模式,并且保留段寄存器16位。
从8086的16位到80386的32位处理器,这看起来是处理器位数的变化,但实质上是处理器体系结构的变化。从80386以后,Intel的CPU经历了80486、Pentium、PentiumII、PentiumIII等型号,虽然它们在速度上提高了好几个数量级,功能上也有不少改进,但基本上属于同一种系统结构的改进与加强,而无本质的变化,所以我们把80386以后的处理器统称为IA32(32 Bit Intel Architecture)。
说到这里各位应该明白了,之所以不能跳过8086直接介绍80386或之后更新的CPU,是因为兼容的原因。也就是说,80386中一些看似奇怪的行为,例如之上面提到的16位寄存器、段式寻址和保护模式,其实都是“历史遗留问题”造成的。Intel为了向前兼容,必须继续兼容过去的设计。
2.寄存器简介
了解了整个的大背景后,我们首先学习一下最基础知识-寄存器。寄存器可以分为很多种,这里主要学习我们最常用到的两种:通用寄存器和段寄存器。
2.1 通用寄存器
通用寄存器是最常用的一类寄存器,16位CPU的通用寄存器共有8个:AX,BX,CX,DX,BP,SP,SI,DI。它们可以用来参与算术运算和逻辑运算,可以保存运算结果,也可以用来传输数据。在特定用途中,某些通用寄存器是有特殊用处的,例如拷贝数据时,CX为计数器,SI和DI为源和目的地址;BP为栈基地址,而SP为栈顶指针。
2.2 段寄存器
8086有四个段寄存器:代码段寄存器CS、数据段寄存器DS、堆栈段寄存器SS、附加段ES。每当需要产生物理地址时,BIU(总线接口单元)会自动引用一个段寄存器并左移4位再与一个16位的偏移相加。若一个程序的代码长度、堆栈长度和数据长度均不超过64K字节,则可在程序开始时给DS、SS等赋值,这样在程序的其他地方就不用考虑这些段寄存器,程序就能正常运行了。
这样看来,我们之前再熟悉不过的五种寻址方式,其实只是确定那个16位偏移的方式,即有效地址。而决定基地址的值其实在段寄存器里。后面在“分段管理机制”中我们会详细介绍。
3.NASM汇编
因为Orange’s使用NASM(Netwide Assembler),一种非常流行的支持从16位到64位及Linux和Windows平台的汇编器,所以为了能够顺利进行下去,我们有必要简要了解一下汇编语言和NASM的基础知识。此部分内容不用死记硬背,可以留作后续编写汇编代码时的参考手册,可以先关注加粗的重点内容。学会一种汇编语言也是一门手艺,你不知道什么时候就有可能要读或写汇编。
汇编基础知识温习
《六星经典CSAPP-笔记(7)加载与链接》中学习过编译过程。当我们编译高级语言的源代码时,通常包括预处理、编译、汇编、链接四个阶段。实际上driver(GCC)在背后帮我们调度着预处理器cpp、编译器cc1、汇编器as、链接器ld来完成这些工作。现在我们直接手写汇编程序而不是高级语言程序,所以预处理和编译阶段自然就省了。开发测试也就变得简单了,只要写好汇编程序后,如果所有代码都放在一个文件中,则直接用汇编器产生可执行文件。如果放在多个文件,则产生可重定位的.obj目标文件后再链接就可以了。
3.1 AT&T语法 vs. Intel语法
在Linux下,默认的汇编语言编译器(汇编器)是GNU Assembler(GAS)。GAS采用的是一种叫做AT&T的古老的汇编语言语法,只有GAS和一些老式汇编器在使用。而NASM和TASM、MASM(Microsoft Assembler),以及DOS汇编器都采用的是Intel语法。大多数汇编器都支持它,就连新版的GAS也允许在GAS中使用Intel语法了。
之前在学习《深入理解计算机系统》(简称CSAPP)时曾接触过这两种语法,“倔强的”CSAPP坚持全书采用AT&T语法。当时学习时重点不是这两种语法,所以只在读书笔记《六星经典CSAPP-笔记(3)程序的机器级表示》中说了一下四个区别。在此重新整理一下两者的主要差别:
- 操作数前缀:Intel语法的寄存器和立即数都没有前缀%和$,例如esp而非AT&T语法的%esp,
push 4
而非AT&T语法的pushl $4
- 操作数顺序:Intel语法指令的操作数顺序与ATT语法的完全相反,例如
mov eax, 4
而非AT&T语法的movl $4, %eax
- 操作数位宽:Intel语法通过在内存操作数(而不是操作码本身)前面加 byte ptr、word ptr和dword ptr来指定大小,例如
mov al, byte ptr foo
而非AT&T语法的movb foo, %al
- 间接寻址格式:Intel语法用不同的方式描述内存位置,例如
DWORD PTR [ebp+8]
而非AT&T语法的8(%ebp)
- 长跳转指令:Intel语法是call/jmp far section:offset,而AT&T语法是 lcall/ljmp section, offset
还有种更高级的汇编HLA(High Level Assembly),在《The Art of Assembly Language》中通篇都是,“高级”得简直不像汇编。因为跟我们的主题关系不大,所以这里提一句就不细说了。
3.2 二进制文件
这里简要介绍一下常见的二进制文件格式:
- HEX:Intel标准的十六进制文件,它每行以冒号开头,用十六进制的ASCII码保存机器指令。它常用来保存单片机或其他处理器的目标程序代码。
- ELF:诸多*nix使用的既可执行(Executable)又可重定位链接(Linkable)的文件格式。类似的还有古老的a.out、COFF以及Windows下的PE,这些都是需要跟操作系统的加载器配合才能正常运行的文件。关于ELF文件格式详见《程序员的自我修养:(1)目标文件 》。
- BIN:最纯粹的二进制文件,输出文件中除了你编写的汇编对应的机器码外不会附加任何东西,没有固定的文件扩展名。常见的应用场景有:
- MS-DOS中的.COM文件
- 设备驱动中的.SYS文件
- 操作系统开发
- 引导程序开发(Boot Loader)
- AXF:ARM的调试文件,除了包含bin的内容之外,还附加了其他的调试信息。
我们要开发的是操作系统内核,是由硬件而非操作系统直接加载的,所以当然要用Bin文件格式。NASM默认产生BIN格式的可执行文件,可以用-f参数指定其他格式。这里单独指出一个NASM为BIN文件提供的指令ORG,ORG指令指明了当前程序要被加载到内存中的哪里,影响就是:所有内部的地址引用都会加上ORG指明的偏移量。
3.3 NASM编程入门
相比MASM、DOS汇编器等Intel语法的汇编器,NASM对汇编语法做了很多简化,写出来的汇编代码比较简洁、优雅,所以受到了诸多开发者的追捧。可惜不知道什么原因,使用NASM的图书却非常少,权威的参考资料只能是官方网站上的教程了。这一部分可以作为参考手册,只有真正动手写NASM汇编代码时才能真正掌握这些语法。
3.3.1 排版
与其他汇编器类似,NASM的每行源代码都包含四部分:标签、指令、操作数、注释:
- 标签和注释是可选的,是否有操作数根据指令语法来定
- 可以在行尾用”\”将下一行也作为当前行的一部分
- 空格多少没有限制
- 有效字符是字母、数字、”_”、”$”、”#”、”@”、”~”、”.”、”?”
- 只能以字母、”.”、”_”、”?”开头(”.”开头是有特殊含义的)
- 以” "为前