阅读本文章需要的基础:
计算机组成原理:针对8086,80386CPU架构的计算机硬件体系要有清楚的认知,我们都知道操作系统是用来管理硬件的,那我们就要对本版本的操作系统所依赖的硬件体系有系统的了解,有了系统的了解后才能全面的管理它,我们对8086,80386CPU架构的计算机硬件体系如果有非常深刻的认识,我们看源代码内核的时候,就可以更可能的以一种开发者的角度去思考代码的作用,先从全局的角度去思考问题,而不是采用一种众人摸象的思维从头看到末尾。
计算机编程C语言基础:linux内核基本都是用C编写的,只有一些对硬件操作要求特别高的地方,我们使用汇编直接对硬件操作,C语言建议去看完K&R的《The C Programming Language 》。
计算机汇编语言:linux0.11内核中使用了3中代码,分别是Intel汇编(16位汇编),GNU as汇编(32位汇编),内嵌汇编(c语言里面嵌套的汇编)。此三种汇编我不做介绍,建议学习顺序,先学Intel汇编语法,在学GNU as汇编语法,再学内嵌汇编。
回顾之前bootsect.s和setup.s建立起来的系统环境:
setup.s设置了局部全局描述符表(GDT):
gdt:
dw 0,0,0,0 # 第1 个描述符,不用。
#第2个描述符, 这里在gdt 表中的偏移量为08h,当加载代码段寄存器(段选择符)时,使用的是这个偏移值。
dw 07FFh
dw 0000h
dw 9A00h
dw 00C0h
# 第三个描述符,这里在gdt 表中的偏移量是10h,当加载数据段寄存器(如ds 等)时,使用的是这个偏移值。
dw 07FFh
dw 0000h
dw 9200h
dw 00C0h
setup.s打开了A20地址线,实现了32位寻址:
打开了A20地址线,意味着CPU可以进行32位寻址,物理寻址范围可以达到4GB,但是我们的linux0.11操作系统对内核的管理还显得比较稚嫩了,
linux0.11只能支持16MB的物理内存管理。实模式下,CS+IP最大寻址范围为:FFFFFh,在保护模式下,最大寻址范围为:FFFFFFh。
setup.s将控制寄存器(CR0)0号控制位开启:
开启保护模式,仅开启段保护。
setup.s程序结束后内存中程序示意图:
setup.s最后一句转移指令解析:
jmpi 0,8 #将0h加载到EIP段内偏移地址寄存器,将8h加载到CS段选择子中。
#执行段选择指令的时候,系统硬件会从GDTR全局描述符表寄存器定位到Linus在setup.s里面设置的GDT临时全局描述符表的位置。
#然后用CS段选择子的段值去定位GDT表表项(linus在setup.s里面设置的GDT临时全局描述符表)。
#08h对应到的GDT描述符表项从高到低为:(0C00 9A00 0000 07FF)h
#取到GDT全局描述符表项后,将表项加载到CS段的描述符高速缓存寄存器中,如图:
#描述符高速缓存寄存器根据里面的值开始分析,GDT全局描述符格式如图:
#对照上表,我们来分析一下现在存在CS段寄存器的描述符高速缓存寄存器的那一段二进制数据代表了什么:
(0C00 9A00 0000 07FF)h=(0000 1100 0000 0000 1001 1010 0000 0000 0000 0000 0000 0000 0000 0111 1111 1111)2
我们在linus写的GDT表项中知道,当CS段选择子选择08h编号的全局描述符表项的时候,系统自动完成一系列操作。
此过程对程序员不可见,程序员可以在内存中通过设置GDT类型的数据结构,然后让GDTR指向这个数据结构。
就可以实现系统保护模式下的段选择操作。本次08h表示:
段限长为:8M,段基址为:00000000h,该段的特权级为:00(内核级),本段为代码段:可以执行。
至于为什么段限长为8M(0h—90000h),我觉得是要保护地址区为90000h以上的区域。
因为在90000h以上的区域,在setup.s代码段的时候在那里构造了一系列的数据结构。
系统硬件在CS代码段寄存器中的描述符高速缓存寄存器中去出段基址和EIP中的0h相加,得到真正的线性地址。
# 如果系统开启了分页机制的话,线性地址在通过分页机制映射出真正的内存物理地址。
head.s执行完之后,内存的分配情况,注意看head.s被数据结构填充完毕后的内存分配,最终head.s执行完就是这样的:
head.s开始执行,进入正文:
/******************************************head.s代码段的总体执行步骤***********************************************/
/***********************************startup_32:初始化各个扩展数据段寄存器******************************************/
/******************************************_stack_start:设置系统堆栈**************************************************/
/************************************setup_idt:重新设置中断描述符表IDT*********************************************/
/************************************setup_gdt:重新设置全局描述符表GDT *******************************************/
/***************************************1:检测a20地址线是否真的开启*************************************************/
/*****************************************设置管理内存分页的处理机制 ***********************************************/
/***********************check_x87:检测现在的计算机硬件体系是否含有数学协处理器*******************************/
/***********************after_page_tables:将页目录表和页表放置在内存地址0处占位********************************/
/********************************ignore_int:这就是那个统一的中断服务程序*******************************************/
/*****************************Setup_paging:开始设置页目录表和页表的值********************************************/
/****************利用ret指令弹出预先压入的/init/main.c程序的入口地址,去运行main.c程序************************/
/******************************************页目录表和页表的占位位置************************************************/
/*************************************************head.s END*********************************************************/
/*
* linux/boot/head.s
*
* (C) 1991 Linus Torvalds
*/
/*
*linux内核源代码的结构是树形的,本段head.s汇编程序代码存放在 linux/boot目录中
*
* 原著Linus Torvalds于1991年发表
*/
/*
* head.s contains the 32-bit startup code.
*
* NOTE!!! Startup happens at absolute address 0x00000000, which is also where
* the page directory will exist. The startup code will be overwritten by
* the page directory.
*/
/*
* head.s 含有32 位启动代码。(前面说到setup.s开启了A20地址线后,系统地址线扩展到了32根,分段机制开启后,进入32位保护模式,使用32位GNU as汇编。)
* 注意!!! 32 位启动代码是从绝对地址0x00000000 开始的,这里也同样是页目录将存在的地方,
* 因此这里的启动代码将被页目录覆盖掉。(这也是head,s代码段的难点,head.s代码段在执行的时候,边执行,边创建数据结构覆盖掉自己的代码段。)
*/
.text #伪代码,告诉编译器,后续编译出来的内容放在代码段(程序从这里执行)。
.globl _idt,_gdt,_pg_dir,_tmp_floppy_area //伪代码,告诉编译器后续跟的是一个全局可见的标记(可能是变量名,也可以是过程段名)。
_pg_dir: #用来标识内核分页机制完成后的内核起始位置,也就是物理内存的起始位置0X00000000h
/***********************************startup_32:初始化各个扩展数据段寄存器******************************************/
startup_32: #32 位启动代码从这里正式开始
movl $0x10,%eax #将setup.s程序设置的全局描述符表GDT里面的第10h描述符加载到EAX寄存器
#我们把第10h全局描述符从高位到低位拿出来,然后去对照存储器的段描述符格式(图上),解析出其中的描述和基地址。.
#(00C0 9200 0000 07FF)h=(0000 0000 1100 0000 1001 0010 0000 0000 0000 0000 0000 0000 0000 0111 1111 1111)2
#对照完上图后发现:段限长为:8M,段基址为:00000000h,该段的特权级为:00(内核级),本段为数据段:可读。
mov %ax,%ds #将EAX的值加载到EDS段选择子中(EAX的低位为AX)
mov %ax,%es #将EAX的值加载到EES段选择子中(EAX的低位为AX)
mov %ax,%fs #将EAX的值加载到EFS段选择子中(EAX的低位为AX)
mov %ax,%gs #将EAX的值加载到EGS段选择子中(EAX的低位为AX)
#我们来看下80386CPU体系架构的计算机硬件体系里面,寄存器是上面样的布局,我们先看下通用寄存器,再来看下段寄存器。
#注意实模式寄存器的位数和保护模式寄存器的位数差别,扩展的位数有多少,之前实模式寄存器在扩展寄存器中的位置。
/******************************************_stack_start:设置系统堆栈**************************************************/
lss _stack_start,%esp #将_stack_start结构体对象里面的属性在物理内存的高字加载到ESP寄存器中,将低字加载到SS段寄存器中。
# stack_start 定义在kernel/sched.c,69 行。
/*kernel/sched.c设置堆栈ss:esp代码段
*long user_stack[PAGE_SIZE >> 2]; #定义系统堆栈,物理长度4K,一共1024项。
* #我们有必要说一下这个系统堆栈在内存的位置,它的位置紧跟操作系统内核SYSTEM代码后面。
*struct #该结构用于设置堆栈ss:esp
*{
* long *a; #长整型指针,在32位的保护模式下,它就是32位数据大小。
*short b; #短整型
*}
*stack_start =
*{
*&user_stack[PAGE_SIZE >> 2], 0x10}; #定义系统堆栈结构体对象,第一个属性值是user_stack系统堆栈数组的最后一项的地址,第二个属性自己想。
*/
call setup_idt # 调用设置中断描述符表子过程
call setup_gdt # 调用设置全局描述符表子过程
movl $0x10,%eax #因为上一步更新了全局描述符表,全新的全局描述符每个项的段限长都发生了变化
#段限长从8MB(90000h,之前使用段机制的时候怕把90000h处的临时数据结构冲掉)
#变成了16MB,所以我们要重新的加载数据段寄存器的值。
mov %ax,%ds #将AX通用寄存器的值加载到DS段选择子中(EAX的低位为AX)
mov %ax,%es #将AX通用寄存器的值加载到ES段选择子中(EAX的低位为AX)
mov %ax,%fs #将AX通用寄存器的值加载到FS段选择子中(EAX的低位为AX)
mov %ax,%gs #将AX通用寄存器的值加载到GS段选择子中(EAX的低位为AX)
lss _stack_start,%esp #将_stack_start结构体对象里面的属性在物理内存的高字加载到ESP寄存器中,将低字加载到SS段寄存器中。
/***************************************1:检测a20地址线是否真的开启*************************************************/
# 用于测试A20 地址线是否已经开启。采用的方法是向内存地址0x000000 处写入任意
# 一个数值,然后看内存地址0x100000(1M)处是否也是这个数值。如果一直相同的话,就一直
# 比较下去,也即死循环、死机。表示地址A20 线没有选通,结果内核就不能使用1M(FFFFFh) 以上内存。
xorl %eax,%eax
1: incl %eax # check that A20 really IS enabled
movl %eax,0x000000 # loop forever if it isn‘t
cmpl %eax,0x100000
je 1b
# ‘1b‘表示向后(backward)跳转到标号1 去(33 行)。
# 若是‘5f‘则表示向前(forward)跳转到标号5 去。
/***************************************1:检测a20地址线是否真的开启*************************************************/
/*****************************************设置管理内存分页的处理机制 ***********************************************/
/*
* NOTE! 486 should set bit 16, to check for write-protect in supervisor
* mode. Then it would be unnecessary with the "verify_area()"-calls.
* 486 users probably want to set the NE (#5) bit also, so as to use
* int 16 for math errors.
*/
/*
* 注意! 在下面这段程序中,486 应该将位16 置位,以检查在超级用户模式下的写保护,
* 此后"verify_area()"调用中就不需要了。486 的用户通常也会想将NE(#5)置位,以便
* 对数学协处理器的出错使用int 16。
*/
# 下面这段程序(43-65)用于检查数学协处理器芯片是否存在。方法是修改控制寄存器CR0,在
# 假设存在协处理器的情况下执行一个协处理器指令,如果出错的话则说明协处理器芯片不存在,
# 需要设置CR0 中的协处理器仿真位EM(位2),并复位协处理器存在标志MP(位1)。
movl %cr0,%eax #将CR0控制寄存器的内容加载到EAX寄存器中
andl $0x80000011,%eax #源操作码:0X80000011=(1000 0000 0000 0000 0000 0000 0001 0001)2
#andl指令的作用主要是对目标操作数相应位置零
#linux0.11操作系统是架构在80386中
#本段代码的作用主要是对CR0控制寄存器的0-4位进行控制
#我来解释一下源操作码为什么要这样设置
#首先,我们看到源操作码对应的二进制数控制码的第4位(从0开始计)
#第4位为ET扩展类型位,我们要将之置为1,意义为我们先假设80387协处理器的存在
#在其假设存在的情况下执行一个协处理其指令,如果出错则说明80387协处理器不存在。
#CR0控制寄存器中的第1-3位是指令类型控制位
#CR0控制寄存器的1-3位从低位到高位分别为MP监控协处理器标志,EM仿真标志位,TS任务已切换标志位
#3个位组合可以形成指令类型控制,现在先对其初始化,置为0,方便下面代码的使用。
orl $2,%eax #2=(0000 0000 0000 0000 0000 0000 0000 0010)2
#orl指令的作用是对目标操作数相应位置一
#这个操作是针对CR0控制寄存器的1-3位(控制指令类型位),将MP监控协处理器标志位置为1,其他2位为0.
#我们对应看看下面的图表,看看产生了什么效果
#它的作用是可以执行浮点运算的指令和执行WAIT/FWAIT指令类型
#好的,下面我们就来开始测试,我们看看到底计算机硬件体系里面到底有没有包含80387协处理器
movl %eax,%cr0 #将刚才EAX寄存器里面设置好的值,加载到CR0,此时CR0开始执行上面我们预设的效果
call check_x87
jmp after_page_tables
CR0控制寄存器图:
/***********************check_x87:检测现在的计算机硬件体系是否含有数学协处理器*******************************/
#80387协处理器小科普:为了弥补X86系列架构的计算机在浮点运算上的不足,Intel在1980年推出了X87系列协处理器
#那时候还是一个外置的,可选的芯片,(linus当时他就没有安装那种外部的协处理器)1989年,Intel发布了487协处理器
#至此之后CPU都内置了协处理器,这样对于486以前的计算机而言,操作系统检验X87协处理器的存在就非常有必要了。
check_x87:
fninit #finit 向协处理器发出初始化命令,如果系统中存在协处理器的话,那么在执行了fninit指令后其状态字低字节(8位)肯定为0。
fstsw %ax #fstsw(store status register)这个指令的功能是把协处理器的状态寄存器中的值取出并存入内存变量里.
cmpb $0,%al #我们的AX已经接收到了协处理器的状态寄存器中的值,如果这个值的低字节位(8位)是0的话,也就是在AL中的值
#如果AL是0的话,那么由这个操作系统管理的系统中存在协处理器
#我们很容易想到用CMP这种汇编指令去处理这种问题,
#CMP汇编指令的特点是,通过比较(compare)目标操作数于源操作数的差,不影响两个操作数的值,只影响FLAGS标志寄存器的值。
#我们来看看,目标操作数和源操作数是不是相等的,也就是立即数0和AL寄存器中的值。
#如果是相等的,两操作数相减为0,这个为0的结果就会影响FLAGS标志寄存器中的ZF零标志位置零。
je 1f #我们要把这一瞬间的比较结果保存下来。je汇编指令执行的时候看FLAGS标志寄存器里面的ZF零标志,
#如果其ZF标志位置零了,我们跳转到后面(forward)的1标号
#EFLAGS标志寄存器图:
movl %cr0,%eax # 如果存在的则向前跳转到标号1 处,否则改写cr0。
#将CR0扩展寄存器中的值加载到EAX中保存,我们把EAX在本过程的作用看作一个中间作用的寄存器。
xorl $6,%eax #xorl汇编指令的作用是将目标操作数的相应位,对应源操作数的相应位。
#源操作数位1的位对应到目标操作数的位将会置反。
#我们来说说这句汇编指令干了什么
#6=(0000 0000 0000 0000 0000 0000 0000 0110)2
#作用是把CR0寄存器的1-2位(从0开始计数)置反。
#也就是CR0中的MP监控协处理器标志位置反,由1变成0.
#EM仿真标志位置为1,EM标志位置为1后,其中的意思是表明处理器内部或外部没有协处理器。
#我们在来看看这样的话,由TS,EM,MP控制的协处理器指令类型置成了什么效果。
#协处理器类型被置为,当计算机执行浮点运算的指令类型的时候,机器发出DNA异常。
#WAIT/FWAIT被置为可执行的。
#CR0控制寄存器中的TS,EM,MP位控制的协处理器指令类型图:
movl %eax,%cr0 #将预设的一串控制CR0控制寄存器的值从EAX寄存器加载到CR0控制寄存器中。
ret #程序返回到调用本check_x87代码段的指令的下一句指令,并执行它。
.align 2 # 这里".align 2"的含义是指存储边界对齐调整。"2"表示调整到地址最后2 位为零。
# 即按4 字节方式对齐内存地址。
#对齐伪指令ALIGN
#对齐伪指令格式:
#ALIGN Num
#其中:Num必须是2的幂,如:2、4、8和16等。
#伪指令的作用是:告诉汇编程序,本伪指令下面的内存变量必须从下一个能被Num整除的地址开始分配。
1: .byte 0xDB,0xE4 # 如果存在协处理器,程序就跳转到了这里,再把协处理器的处理码保存。
ret #程序返回到调用本check_x87代码段的指令的下一句指令,并执行它。
/************************************setup_idt:重新设置中断描述符表IDT*********************************************/
/*
* setup_idt
*
* sets up a idt with 256 entries pointing to
* ignore_int, interrupt gates. It then loads
* idt. Everything that wants to install itself
* in the idt-table may do so themselves. Interrupts
* are enabled elsewhere, when we can be relatively
* sure everything is ok. This routine will be over-
* written by the page tables.
*/
/*
* 下面这段是设置中断描述符表子程序 setup_idt
*
* 将中断描述符表idt 设置成具有256 个项,并都指向ignore_int 中断门。然后加载中断
* 描述符表寄存器(用lidt 指令)。真正实用的中断门以后再安装。当我们在其它地方认为一切
* 都正常时再开启中断。该子程序将会被页表覆盖掉。
*/
#head.s在一系列的检查所依赖的系统硬件环境里面,设备能否正常使用之后。
#head.s继续建立新的真正的数据结构提供main这个SYSTEM模块下的主代码段的执行。
#head.s的存在就是为了main这个主代码块可以正常运行。
#程序运行到了这里开始建立新的真正的数据结构。
#现在我们一起来建立IDT中断描述符表这个数据结构。
#我们现在想想我们要把IDT这个中断描述符表做成什么样子呢?
#首先,IDT中断描述符表在内存中整体分布是这样的。
#中断描述符表在整个内存中的分布位置如下图,可以结合这篇文章的第一张图构造一个整体的印象,我们先看下图:
#IDT中断描述符表中的中断描述符项的格式如下图:
#IDT中断描述符表在线性内存中的位置:在ignore_int汇编程序段的上面,在GDT全局描述符表的下面。
#由于808386CPU只能识别256种中断,并我看中断描述符的格式,我们知道,一个中断门描述符的大小是8个BYTE(64BIT)。
#也就是说明IDT中断描述符表跨越了256*8B的内存大小,也就是2KB的物理内存大小。
#也就是说跨越了2K的线性寻址大小。
#本程序先让所有的中断描述符默认指向ignore_int这个位置,ignore_int这个代码段的作用是调用/kernel/printk.c 中的printk函数,打印“Unknown interrupt”。
#将来main函数里面还要让中断描述符对应具体的中断服务程序。
#我们会设置中断描述符表寄存器的值(IDTR),让中断描述符表寄存器指向中断描述符表的最低位。
#我们这样做的目的是先将中断机制的整体架构搭建起来,给中断描述符表的每个项都赋予一个固定的值(值向ignore_int过程),避免野指针。
setup_idt:
lea ignore_int,%edx #取ignore_int标号的基址加载到EDX扩展寄存器中,EDX要将其低位的中断服务程序基址传给EAX的低位。
movl $0x00080000,%eax #下面我来讲一下怎么去设置中断描述符项
#我们先看一个中断描述符项里面有什么,我们把中断描述符设置完后,要和我们预想的一样指向ignore_int程序段。
#所以我们要把ignore_int程序段的信息按照中断描述符项的格式加载进去。
#中断描述符64位,包含了其对应的中断服务程序的段内偏移地址(OFFSET),所在段段选择符(SELECTOR),
#段特权级(DPL),段存在标志(P),段描述符类型(TYPE)等信息。
#中断描述符的长度为64位,分高32位和低32位。我们用EDX保存低32位的描述格式,用EDX保存高32位的描述格式。
#然后在将EAX的值加载到中断描述符的低位,将EDX的值加载到中断描述符的高位。
#循环这个加载过程,把剩下的中断描述符表项全部设置完毕。
#用ECX控制循环次数,当然我们的循环次数为256次。
#我们来看看IDT表项要被设置成什么样的格式。
#首先ignore_int中断服务程序的偏移地址(OFFSET)就可以使用lea指令取出ignore_int标号的基地址
#这个基地址要被拆分成第0-15位和第48-63位,分别放在EAX和EDX寄存器中
#段选择符,我们要选用代码段的段选择符,所以我们使用0008h号GDT段选择符。
#中断描述符的高位0-15位是中断服务程序的描述信息,我们设为(8E00)h=(1000 1110 0000 0000)2
#第47位为段存在标志(P),此位被置位,说明此中断服务程序存在于内存中。
#第45-46位为特权级标志(DPL),表示本中断服务程序是内核特权级
#第40-43位为段描述符类型标志(TYPE),我们设置的是1110.即将此段描述符标记为“386中断门”。
#本句汇编指令的作用是将立即数00080000h加载到EAX中,EAX保存其加载来的高位0008,那是本中断服务程序的段选择。
movw %dx,%ax #将EDX的低位,也就是DX(里面保存了ignore_inr程序的基址)加载到EAX的低位(AX)。
movw $0x8E00,%dx #将8E00中断服务程序描述序列号加载到EDX的低位(DX)中。
#开始将EAX,EDX寄存器的值加载到IDT中断描述符表的位置,从IDT中断描述符表的低位开始加载,循环完毕后,IDT中断描述符表就设置好了。
lea _idt,%edi #将IDT服务程序的基地址加载到EDI目标索引寄存器中。
#解释一下_idt这个标号,如图:
#这个标号是指向head.s代码段为IDT中断描述符表预留的空间的
#这个标号在head,s代码段的后部分。
#如上图,就在标号_idt位置处,linus在写head.s程序的时候预留了256*8BYTE的位置。
#linus在预留的时候将标号_idt以后的256*8BYTE的内存空间都置为了零。
#所以我们现在要做的就是将这段以_idt开头的空间里的描述符全部指向ignore_int中断服务程序。
mov $256,%ecx #设置循环次数256次。
rp_sidt: #开始进入循环体
movl %eax,(%edi) #将EAX中的内容加载到EDI所指向的内存空间中
movl %edx,4(%edi) #将EDX中的内容加载到EDI+4所指向的内存空间中
addl $8,%edi #将EDI目标引索寄存器中的值加8,其效果就是将EDI指向下一个IDT中断描述符项
dec %ecx #ECX自减(C语言语法的自减效果一样)
jne rp_sidt #如果上面的自减运算为零,触动了ZF(EFLAGS标志寄存器零标志位)复位,就跳出循环,要不然,继续循环。
lidt idt_descr #解释一下idt_descr这个标号,如图:
# 这个标号指向的是给IDTR中断描述符表基址寄存器赋值的内容。
#也可以说它指向的是IDT中断描述符表的限长和IDT中断描述符表的入口地址。
#通过这段内容给IDTR赋值
#下面是IDTR寄存器的格式,你想想为什么这段idt_descr后的内容要这样写?
ret #函数返回
/************************************setup_gdt:重新设置全局描述符表GDT *******************************************/
/*
* setup_gdt
*
* This routines sets up a new gdt and loads it.
* Only two entries are currently built, the same
* ones that were built in init.s. The routine
* is VERY complicated at two whole lines, so this
* rather long comment is certainly needed :-).
* This routine will beoverwritten by the page tables.
*/
/*
* 设置全局描述符表项 setup_gdt
* 这个子程序设置一个新的全局描述符表gdt,并加载。此时仅创建了两个表项,与前
* 面的一样。该子程序只有两行,“非常的”复杂,所以当然需要这么长的注释了?。
setup_gdt:
lgdt gdt_descr #根据gdt_descr标号后的内容,加载全局描述符表寄存器。
#gdt_descr标号在head.s代码段的后面部分。
#GDT全局描述符表也设置在head.s代码段的后面。
#GDT全局描述符表在head.s代码段中占位的时候就已经直接初始化了,内容如下:
ret #函数返回
/***********************after_page_tables:将页目录表和页表放置在内存地址0处占位********************************/
#计算机寻址从段机制到页机制都是由计算机硬件系统完成的。
#首先,计算机的寻址是通过段寄存器中的描述符高速缓存寄存器中获取段地址后,在用段地址和存在通用寄存器中的偏移地址相加获取完全的线性地址。
#采用分页机制。此时32位的线性地址分为3个部分,(10位)页目录索引 (10位)页表索引 (12位)
#偏移地址首先高10位的页目录索引部分和CR3寄存器(它存放着页目录地址)结合,找到相应的页表的地址
#然后根据中间的10位的页表索引,找到相应的页的起始位置
#然后,根据低12位的偏移地址,在这个页中就可以找到对应的地址了——而这就是物理地址!
#如图:
#页目录表项和页表项的格式:
#页目录表和页表在内存中的位置:
#CR3页目录表寄存器的格式:
#我们从页目录项和页表项的图中看到,页目录项和页表项都是4BYTE长度。
#页目录表有1024(1K)个页目录项,每个页目录项4BYTE,所以页目录表的长度是4KB,地址跨度是4K。
#每个页表有1024(1K)个页表项,每个页表项有4BYTE,所以每个页表项的长度是4KB,地址跨度是4K。
/*
* I put the kernel page tables right after the page directory,
* using 4 of them to span 16 Mb of physical memory. People with
* more than 16MB will have to expand this.
*/
/* Linus 将内核的内存页表直接放在页目录表之后,使用了4 个页目录表项来寻址16 Mb 的物理内存。
* 如果你有多于16 Mb 的内存,就需要在页目录表这里进行扩充修改。
*/
#所以页目录表和页表在内存中的位置和长度是这样的:
#页目录和页表是计算机系统寻址的索引,就像我们书的目录一样,我们都习惯把它放在开头。
#CPU只会根据CR3页目录表基地址寄存器找到页目录的起始地址,然后开始页目录寻址机制,根据这个规则你也可以把页目录表放在内存的其他地方。
#.org 起始地址,是汇编伪指令,表示后面的代码从.org指定的起始地址开始存放。
.org 0x1000 //从0X1000开始存放内存页表pg0,内存页表pg0长度4KB,寻址跨度4K(FFFh)
pg0: //页表pg0开始出添加一个标号pg0,方便后面的赋值操作。
.org 0x2000 //从0X2000开始存放内存页表pg1,内存页表pg1长度4KB,寻址跨度4K(FFFh)
pg1: //页表pg0开始出添加一个标号pg1,方便后面的赋值操作。
.org 0x3000 //从0X3000开始存放内存页表pg2,内存页表pg2长度4KB,寻址跨度4K(FFFh)
pg2: //页表pg2开始出添加一个标号pg2,方便后面的赋值操作。
.org 0x4000 //从0X4000开始存放内存页表pg3,内存页表pg3长度4KB,寻址跨度4K(FFFh)
pg3: //页表pg3开始出添加一个标号pg3,方便后面的赋值操作。
.org 0x5000 # 定义下面的内存数据块从偏移0x5000 处开始。
/*
* tmp_floppy_area is used by the floppy-driver when DMA cannot
* reach to a buffer-block. It needs to be aligned, so that it isnt
* on a 64kB border.
*/
/* 当DMA(直接存储器访问)不能访问缓冲块时,下面的tmp_floppy_area 内存块
* 就可供软盘驱动程序使用。其地址需要对齐调整,这样就不会跨越64kB 边界。
*/
_tmp_floppy_area:
.fill 1024,1,0 # 共保留1024 项,每项1 字节,填充数值0。
#下面的几个入栈操作(pushl)将数据压入系统堆栈user_stack中,在前面_stack_start:设置系统堆栈那一章中我们说过。
#压入的数据是为了调用/init/main.c 程序和返回作准备的,前面3 个入栈指令不知道作什么用的,也许是Linus 用于在调试时能看清机器码用的。
#程序跳转到setup_paging后,setup_paging程序段执行到最后一条语句ret,从系统堆栈中取出_main(main.c程序的入口地址),设置完CS:IP.
#程序就开始跳转到main函数中执行,当然我们的操作系统肯定是是设置为无限循环的,操作系统不可能停止的,它只要不关机,都是无限执行下去的。
#如果main.c 程序真的退出时,就会返回到这里的标号L6 处继续执行下去,也即死循环。
# 下面这几个入栈操作(pushl)用于为调用/init/main.c 程序和返回作准备。
after_page_tables:
pushl $0
pushl $0
pushl $0
pushl $L6
pushl $_main
#如图,这张图是在刚进入main.c函数中系统堆栈的情况。
jmp setup_paging
L6:
jmp L6 //无条件跳转指令,这里做了一个无条件的无限循环
// 如果main.c 程序真的退出时, 通过系统堆栈的L6,我们跳转到这里无限循环。。。。。。。
/* This is the default interrupt "handler" */
/* 下面是默认的中断“向量句柄” */
int_msg:
.asciz "Unknown interrupt\n\r" # 定义字符串“未知中断(回车换行)”。
/********************************ignore_int:这就是那个统一的中断服务程序*******************************************/
#我们程序员在编写中断服务程序的时候,我们要注意一些什么事情呢?
#首先保护现场是我们汇编程序员应该要有的素质,我们要将中断汇编程序中会改变的寄存器的值压入系统堆栈中。
#其次在我们写完中断服务程序的核心代码段的时候,我们要将现场恢复。
#将中断服务程序开头的那些被压入系统堆栈的寄存器的值反入栈顺序加载到相应寄存器中。
ignore_int:
#开始保存现场
pushl %eax
pushl %ecx
pushl %edx
push %ds # 这里请注意!!ds,es,fs,gs 等虽然是16 位的寄存器,但入栈后
# 仍然会以32 位的形式入栈,也即需要占用4 个字节的堆栈空间。
push %es
push %fs
#保存现场完毕,下面开始对寄存器操作。
movl $0x10,%eax # 置段选择符(使ds,es,fs 指向gdt 表中的数据段)。
mov %ax,%ds
mov %ax,%es
mov %ax,%fs
pushl $int_msg # 把调用printk 函数的参数指针(地址)入栈。
call _printk # 该函数在/kernel/printk.c 中。调用printk.c打印出"Unknown interrupt\n\r" 向量句柄。
# ‘_printk‘是printk 编译后模块中的内部表示法。
//开始恢复现场。
popl %eax
pop %fs
pop %es
pop %ds
popl %edx
popl %ecx
popl %eax
//恢复现场完毕。
iret # 中断返回(把中断调用时压入栈的CPU 标志寄存器(32 位)值也弹出)。
/*****************************Setup_paging:开始设置页目录表和页表的值********************************************/
/*
* Setup_paging
*
* This routine sets up paging by setting the page bit
* in cr0. The page tables are set up, identity-mapping
* the first 16MB. The pager assumes that no illegal
* addresses are produced (ie >4Mb on a 4Mb machine).
*
* NOTE! Although all physical memory should be identity
* mapped by this routine, only the kernel page functions
* use the >1Mb addresses directly. All "normal" functions
* use just the lower 1Mb, or the local data space, which
* will be mapped to some other place - mm keeps track of
* that.
*
* For those with more memory than 16 Mb - tough luck. I‘ve
* not got it, why should you :-) The source is here. Change
* it. (Seriously - it shouldn‘t be too difficult. Mostly
* change some constants etc. I left it at 16Mb, as my machine
* even cannot be extended past that (ok, but it was cheap :-)
* I‘ve tried to show which constants to change by having
* some kind of marker at them (search for "16Mb"), but I
* won‘t guarantee that‘s all :-( )
*/
/*
* 这个子程序通过设置控制寄存器cr0 的标志(PG 位31)来启动对内存的分页处理功能,
* 并设置各个页表项的内容,以恒等映射前16 MB 的物理内存。分页器假定不会产生非法的
* 地址映射(也即在只有4Mb 的机器上设置出大于4Mb 的内存地址)。
* 注意!尽管所有的物理地址都应该由这个子程序进行恒等映射,但只有内核页面管理函数能
* 直接使用>1Mb 的地址。所有“一般”函数仅使用低于1Mb 的地址空间,或者是使用局部数据
* 空间,地址空间将被映射到其它一些地方去 -- mm(内存管理程序)会管理这些事的。
* 对于那些有多于16Mb 内存的家伙 - 太幸运了,我还没有,为什么你会有?。代码就在这里,
* 对它进行修改吧。(实际上,这并不太困难的。通常只需修改一些常数等。我把它设置为
* 16Mb,因为我的机器再怎么扩充甚至不能超过这个界限(当然,我的机器很便宜的?)。
* 我已经通过设置某类标志来给出需要改动的地方(搜索“16Mb”),但我不能保证作这些
* 改动就行了??)。
*/
.align 2 # 这里".align 2"的含义后面的代码是指存储边界对齐调整。"2"表示调整到地址最后2 位为零。
# 即按4 字节方式对齐内存地址。
setup_paging: #开始设置刚才占位的在0X0000地址开始的页目录表和页表。
movl $1024*5,%ecx #我们做一个循环先把从0X0000地址开始的页目录表和页表初始化为零。
#再做一个开始真正的设置寻址映射的数据,这个setup_paging代码段是这样分两段来设置页目录和页表的值的。
#现在我们来做第一步,我们做一个循环先把从0X0000地址开始的页目录表和页表初始化为零。
#现在我们要想清楚两个东西,一是循环体要做什么,二是循环次数要多少次。
#我们现在是将页目录和页表这一打段的值全部设置成为0。
#我们的循环体里面就把一个固定长度的0赋值给目标地址,就这样一直循环下去,这个问题就解决了。
#我们现在保护模式的通用寄存器的长度是4BYTE的,我们把一个通用寄存器的值全部置为零。
#再在循环体里面把通用寄存器里面的4个字节的0加载到目标地址中。循环下去就搞定了。
#那我们的循环次数设置为多少呢?我们知道我们的那块页目录表和页表的地址是连续的,大小是20KB的。
#所以,循环次数是20KB除以4B,是5K(1024*5)。
xorl %eax,%eax #将通用寄存器里面所有的BIT都置为零。
xorl %edi,%edi #页目录表和页表的偏移地址是从0XOOOO开始的。所以我们把目标偏移地址设置为零。
cld;rep;stosl #CLD指令的解释:与cld相对应的指令是std,二者均是用来操作方向标志位DF(Direction Flag)。
#cld使DF 复位,即是让DF=0,std使DF置位,即DF=1.这两个指令用于串操作指令中。
#通过执行cld或std指令可以控制方向标志DF,决定内存地址是增大(DF=0,向高地址增加)还是减小(DF=1,向地地址减小)。
#CLD指令即告诉程序si,di向内存地址增大的方向走。
#rep指令表示紧跟着下面的一条指令重复执行,直到ECX的值是零。
#STOSL指令相当于将EAX中的值保存到ES:EDI指向的地址中。
#页目录表和页表这段内存的值都是零后,我们来真正设置页目录和页表的内容。
# 下面4 句设置页目录中的项,我们共有4 个页表所以只需设置4 项。
#_pg_dir标记是在head.s代码段的开头就设置好的地址标记,head.s开头地址=内存的开始地址=目录表的开头地址=_pg_dir标记的地址
# 页目录项的结构与页表中项的结构一样,4 个字节为1 项。
# "$pg0+7"这个立即数是要作为0号页表的地址加载到_pg_dir处的(也就是页目录表的第0项)。
#pg0这个标号是页表0的地址,放到页目录表的第0项很正常,这个7是什么呢?
#7不用说都是0号页表的描述符,你还记得吗?
#则第1 个页表所在的地址 = 0x00001007 & 0xfffff000 = 0x1000;
# 第1 个页表的描述标志 = 0x00001007 & 0x00000fff = 0x07,表示该页存在、用户可读写。
#后面的以此类推
movl $pg0+7,_pg_dir
movl $pg1+7,_pg_dir+4
movl $pg2+7,_pg_dir+8
movl $pg3+7,_pg_dir+12
#下面我们就要填写页表的内容了,我们再来回顾下页表的格式,如图:
#物理寻址内存是这样分配的,在保护模式下,最大寻址为4GB=4BYTE(一个页表项的大小)*G=1024(页目录项数量)*1024(页表项数量)*4BYTE(页表项大小)
#我们linux0.11只能做到管理16MB大小的物理内存。我们是16MB= 4(页目录项数量)*1024(页表项数量)*4BYTE(页表项大小)
#下面6 行填写4 个页表中所有项的内容,共有:4(页表)*1024(项/页表)=4096 项(0 - 0xfff),
# 页表的每项可以寻址4KB的大小,也即能映射物理内存 4096*4Kb = 16Mb。
# 每项的内容是:当前项所映射的物理内存地址 + 该页的标志(这里均为7)。
# 使用的方法是从最后一个页表的最后一项开始按倒退顺序填写。一个页表的最后一项在页表中的
# 位置是1023*4 = 4092。因此最后一页的最后一项的位置就是$pg3+4092。
movl $pg3+4092,%edi # 3号页表的最后一项的偏移基地址。
movl $0xfff007,%eax # 最后1 项对应物理内存页面的地址是0xfff000,
# 加上属性标志7,即为0xfff007.
std # 方向位置位,edi 值递减(4 字节)。
1: stosl
subl $0x1000,%eax # 每填写好一项,物理地址值减0x1000。
jge 1b # 如果小于0 则说明全添写好了。
#我们现在设置好了页目录表和页表的值,我们要开启页表寻址机制了。
#打开方式:先设置CR3页目录表基地址寄存器的值,将里面的页目录表基地址寄存器指向内存的页目录。
#再开启CR0控制寄存器中的第32位,用来开启分页处理。
#CR0和CR3控制寄存器的图如下:
# 设置页目录基址寄存器cr3 的值,指向页目录表。
xorl %eax,%eax # 页目录表在0x0000 处。
movl %eax,%cr3
# 设置启动使用分页处理(cr0 的PG 标志,位31)
movl %cr0,%eax
orl $0x80000000,%eax # 添上PG 标志。
movl %eax,%cr0
ret
# 在改变分页处理标志后要求使用转移指令刷新预取指令队列,这里用的是返回指令ret。
# 该返回指令的另一个作用是将堆栈中的main 程序的地址弹出,并开始运行/init/main.c 程序。
# 本程序到此真正结束了。
/******************************************页目录表和页表的占位位置************************************************/
.align 2 # 按4 字节方式对齐内存地址边界。
.word 0
idt_descr : #下面两行是lidt 指令的6 字节操作数:长度,基址。
.word 256*8-1 # idt contains 256 entries
.long _idt
.align 2
.word 0
gdt_descr: # 下面两行是lgdt 指令的6 字节操作数:长度,基址。
.word 256*8-1 # so does gdt (not that that‘s any
.long _gdt # magic number, but it works for me :^)
.align 3 # 按8 字节方式对齐内存地址边界。
_idt: .fill 256,8,0 # idt is uninitialized # 256 项,每项8 字节,填0。
# 全局表。前4 项分别是空项(不用)、代码段描述符、数据段描述符、系统段描述符,其中
# 系统段描述符linux 没有派用处。后面还预留了252 项的空间,用于放置所创建任务的
# 局部描述符(LDT)和对应的任务状态段TSS 的描述符。
# (0-nul, 1-cs, 2-ds, 3-sys, 4-TSS0, 5-LDT0, 6-TSS1, 7-LDT1, 8-TSS2 etc...)
_gdt: .quad 0x0000000000000000 /* NULL descriptor */
.quad 0x00c09a0000000fff /* 16Mb */ # 代码段最大长度16M。
.quad 0x00c0920000000fff /* 16Mb */ # 数据段最大长度16M。
.quad 0x0000000000000000 /* TEMPORARY - don‘t use */
.fill 252,8,0 /* space for LDT‘s and TSS‘s etc */