注
博客主要为《Linux内核设计的艺术》(以下简称《设计艺术》)和《Linux内核完全注释》(以下简称《完全注释》),以及非常好的Linux内核视频 - Linux内核精讲内容的搬运和阅读笔记,以及相关博客链接的整理。代码来源于《完全注释》配套代码。
写着玩儿的,如有错误,欢迎指正。
BIOS
在电脑启动阶段,通过硬件电路强行将PC寄存器值设为0XF000,IP寄存器值设为0XFFF0,这样CS:IP会指向0XFFFF0这个位置,即BIOS的地址范围。
0xFFFF0存储的是一条jmp指令,指向0XFE000。BIOS启动块的代码虽然较短,但涉及大量硬件知识,故而不做赘述。
BIOS操作如上,实在看不懂在讲什么。。。
关于为什么操作系统运行后,不使用BIOS的功能。
boot操作
终于,电脑开始执行Linux操作了,这部分主要实现将内核代码从磁盘移动到内存。
具体实现细节见《设计艺术》P5 。
以上为关于汇编版本的问题。
关于为什么不在第一步直接把bootset.s移动到0X90000:貌似由于一些历史原因,必须以0X7C00作为入口。
关于为什么不在bootsect.s中直接把系统模块加载到物理地址0X0000:
bootsect.s
终于到手撕源码的阶段了~~(兴奋地搓手手)~~
SYSSIZE = 3000h ;// 指编译连接后system模块的大小。
;// 这里给出了一个最大默认值。
SETUPLEN = 4 ;// setup程序的扇区数(setup-sectors)值
BOOTSEG = 07c0h ;// bootsect的原始地址(是段地址,以下同)
INITSEG = 9000h ;// 将bootsect移到这里
SETUPSEG = 9020h ;// setup程序从这里开始
SYSSEG = 1000h ;// system模块加载到10000(64kB)处.
ENDSEG = SYSSEG + SYSSIZE ;// 停止加载的段地址
首先是一些变量的定义
start: ;// 以下10行作用是将自身(bootsect)从目前段位置07c0h(31k)
;// 移动到9000h(576k)处,共256字(512字节),然后跳转到
;// 移动后代码的 go 标号处,也即本程序的下一语句处。
mov ax,BYTE PTR BOOTSEG ;// 将ds段寄存器置为7C0h
mov ds,ax
mov ax,BYTE PTR INITSEG ;// 将es段寄存器置为9000h
mov es,ax
mov cx,256 ;// 移动计数值 = 256字 = 512 字节
sub si,si ;// 源地址 ds:si = 07C0h:0000h
sub di,di ;// 目的地址 es:di = 9000h:0000h
rep movsw ;// 重复执行,直到cx = 0;移动1个字
jmpi INITSEG:[go] ;// 间接跳转。这里INITSEG指出跳转到的段地址。
go: mov ax,cs ;// 将ds、es和ss都置成移动后代码所在的段处(9000h)。
mov ds,ax ;// 由于程序中有堆栈操作(push,pop,call),因此必须设置堆栈。
mov es,ax
;// put stack at 9ff00. 将堆栈指针sp指向9ff00h(即9000h:0ff00h)处
mov ss,ax
mov sp,0FF00h ;/* 由于代码段移动过了,所以要重新设置堆栈段的位置。
; sp只要指向远大于512偏移(即地址90200h)处
; 都可以。因为从90200h地址开始处还要放置setup程序,
; 而此时setup程序大约为4个扇区,因此sp要指向大
; 于(200h + 200h*4 + 堆栈大小)处。 */
上面在这段就是实现了第一步,将bootsect自己移动到了0X90000处,比较好看懂。
值得注意的细节是,一个扇区为512B,bootsect.s占第1个扇区(0盘面0磁道1扇区),setup.s占据了第2-5个扇区。
load_setup:
;// 以下10行的用途是利用BIOS中断INT 13h将setup模块从磁盘第2个扇区
;// 开始读到90200h开始处,共读4个扇区。如果读出错,则复位驱动器,并
;// 重试,没有退路。
;// INT 13h 的使用方法如下:
;// ah = 02h - 读磁盘扇区到内存;al = 需要读出的扇区数量;
;// ch = 磁道(柱面)号的低8位; cl = 开始扇区(0-5位),磁道号高2位(6-7);
;// dh = 磁头号; dl = 驱动器号(如果是硬盘则要置为7);
;// es:bx ->指向数据缓冲区; 如果出错则CF标志置位。
mov dx,0000h ;// drive 0, head 0
mov cx,0002h ;// sector 2, track 0
mov bx,0200h ;// address = 512, in INITSEG
mov ax,0200h+SETUPLEN ;// service 2, nr of sectors
int 13h ;// read it
jnc ok_load_setup ;// ok - continue
mov dx,0000h
mov ax,0000h ;// reset the diskette
int 13h
jmp load_setup
从这段开始,开始执行将setup.s移入内存的过程,关于int 13的细节可以参考参考博客1的“坑7”(这个作者写得比我更细致一些)。
从jmp load_setup 到《设计艺术》的system模块之前,还有数十行代码,被一笔带过了,但《完全注释》里讲的比较详尽,但主要都是一些磁盘操作,与操作系统本身貌似关联不大,这个有空回来填坑。
关于system模块,还是推荐阅读参考博客1的“坑八”和“坑九”,写得太赞了!
setup.s
《完全注释》P203 有更详尽的说明。
本章一开始,和于渊大神的《自己动手写操作系统》一样,直接引入了保护模式与实模式的概念。如果你与博主一样,度过了三年一言难尽的混子本科的话,就需要补充大量前置知识。
首先为什么要引入保护模式?说白了,因为地址线只有16条,我们却希望它能表示32位的内存地址(4GB)。为什么不弄32条地址线?我猜测是为了简化硬件设计的目的。相应的,虽然通用寄存器扩展到了32位,但是段寄存器依然只有16位(参考博客2)。于是,便借用了表的概念。当然,保护模式也使得操作系统给任务分配内存时更具有灵活性,实现了内存保护功能。这个具体咋实现的,还需要继续阅读。
上图为段描述符格式
在操作系统中,通常采用段页式存储来实现虚拟内存,段基址为32位,由于一个页长度为4K,规定一个段的长度不能超过4GB(内存实际大小?),所以一个段的段限长不会超过1M(20位)。
在弄明白保护模式的寻址原理前,需要先弄清楚逻辑地址、线性地址和物理地址。这里就体现了本科学习的拉跨,由于没有引入线性地址的概念,一开始把它和逻辑地址弄混了。三个地址之间的关系在《完全注释》的P82有详尽解释,在此不作赘述。
上图可能会造成误解,以为选择符是直接与偏移值相加的,但实际上选择符只是指明偏移量,从中我们也可看出一个GDT或LDT内最多有2^13=8092个段描述符。
值得注意的是,段选择符是保存在段寄存器里的(上文提到过,段寄存器依然是16位)。另外,一个段描述符为8字节,如此算来,一个GDT或LDT的大小最大为64KB(16位),而GDTR或LDTR的前32位存储基地址,后16位存储限长,这说明是被精确规划过的。
图片来源参考博客3
GDT中并非全部存着LDT的段描述符(LDT也被视为一个段),还有一些其他的段描述符,如系统段描述符(见参考博客4)。
具体分页和分段的实现机制见《完全注释》P92。下面继续来读setup.s。、
代码一开始先是一大堆硬件操作,调用了好几次BIOS中断及读取各种硬件设备信息。这段是上文的步骤4,执行的时候依然需要调用BIOS中断,所以还不能覆盖BIOS区。这段代码的具体实现原理网上讲解不多,暂时略去,以后填坑。
读取完硬件设备信息后,首先进行cli关中断,关于关中断是屏蔽了哪些中断,见参考博客5,大概意思是开机的时候用户按键盘或者开打印机不会对OS产生任何影响。
do_move:
mov es,ax ;// es:di -> 目的地址(初始为0000:0)
add ax,1000h
cmp ax,9000h ;// 已经把从8000 段开始的64k 代码移动完?
jz end_move
mov ds,ax ;// ds:si -> 源地址(初始为1000:0)
sub di,di
sub si,si
mov cx,8000h ;// 移动8000 字(64k 字节)。
rep movsw
jmp do_move
移动内核代码段,即上面那个图6-2的第5步,总共512KB,即如果以后的内核代码段不超过这个长度,就还能用这段代码。
剩下的代码,都是一些寄存器设置,还有8259A的中断向量表设置(当时上微机接口的时候就没听明白8259A咋用的,现在更看不懂了,以后填坑)。一些参数的具体细节见参考博客6。
A20地址线的作用《设计艺术》P22讲得比较明白,大致是从1MB内存进化到4MB内存时一些历史问题所导致的。总之,开A20是进入保护模式的标志。
在文件的最后,gdt段的设置值得注意。
;// 全局描述符表开始处。描述符表由多个8 字节长的描述符项组成。
;// 这里给出了3 个描述符项。第1 项无用,但须存在。第2 项是系统代码段
;// 描述符(208-211 行),第3 项是系统数据段描述符(213-216 行)。每个描述符的具体
;// 含义参见列表后说明。
gdt:
dw 0,0,0,0 ;// 第1 个描述符,不用。
;// 这里在gdt 表中的偏移量为08,当加载代码段寄存器(段选择符)时,使用的是这个偏移值。
dw 07FFh ;// 8Mb - limit=2047 (2048*4096=8Mb)
dw 0000h ;// base address=0
dw 9A00h ;// code read/exec
dw 00C0h ;// granularity=4096, 386
;// 这里在gdt 表中的偏移量是10,当加载数据段寄存器(如ds 等)时,使用的是这个偏移值。
dw 07FFh ;// 8Mb - limit=2047 (2048*4096=8Mb)
dw 0000h ;// base address=0
dw 9200h ;// data read/write
dw 00C0h ;// granularity=4096, 386
这里初始化了三个gdt描述符,第一个为空,第二个是代码段描述符,对照上面的格式,发现基址为0,限长为8MB(为什么是这个数我没找到相关资料,但在head.s中它会被改成16MB)。
另外还有GDTR的设置
gdt_48:
dw 800h ;// 全局表长度为2k 字节,因为每8 字节组成一个段描述符项
;// 所以表中共可有256 项。
dw 512+gdt,9h ;// 4 个字节构成的内存线性地址:0009<<16 + 0200+gdt
;// 也即90200 + gdt(即在本程序段中的偏移地址,205 行)。
限长2KB,也就是256*8,基址90200+gdt,在head.s中会被更新。这里还体现了小端存储的特性。
head.s
head.s是在保护模式下运行的,是mian.c前的最后一个汇编文件。
head.s主要做了四件小事儿。
_startup_32: ;// 以下5行设置各个数据段寄存器。指向gdt数据段描述符项
mov eax,10h
;// 再次注意!!! 这里已经处于32 位运行模式,因此这里的$0x10 并不是把地址0x10 装入各
;// 个段寄存器,它现在其实是全局段描述符表中的偏移值,或者更正确地说是一个描述符表
;// 项的选择符。有关选择符的说明请参见setup.s 中的说明。这里$0x10 的含义是请求特权
;// 级0(位0-1=0)、选择全局描述符表(位2=0)、选择表中第2 项(位3-15=2)。它正好指向表中
;// 的数据段描述符项。(描述符的具体数值参见前面setup.s )。下面代码的含义是:
;// 置ds,es,fs,gs 中的选择符为setup.s 中构造的数据段(全局段描述符表的第2 项)=0x10,
;// 并将堆栈放置在数据段中的_stack_start 数组内,然后使用新的中断描述符表和全局段
;// 描述表.新的全局段描述表中初始内容与setup.s 中的完全一样。
mov ds,ax
mov es,ax
mov fs,ax
mov gs,ax
lss esp,_stack_start ;// 表示_stack_start -> ss:esp,设置系统堆栈。
;// stack_start 定义在kernel/sched.c,69 行。
call setup_idt ;// 调用设置中断描述符表子程序。
call setup_gdt ;// 调用设置全局描述符表子程序。
mov eax,10h ;// reload all the segment registers
mov ds,ax ;// after changing gdt. CS was already
mov es,ax ;// reloaded in 'setup_gdt'
mov fs,ax ;// 因为修改了gdt,所以需要重新装载所有的段寄存器。
mov gs,ax ;// CS 代码段寄存器已经在setup_gdt 中重新加载过了。
lss esp,_stack_start
第一件就是重新设置了idt和gdt(至于为什么不在setup.s里直接设置,我猜测是覆盖的问题)。
首先看setup_idt子程序
;/*
; * 下面这段是设置中断描述符表子程序setup_idt
; *
; * 将中断描述符表idt 设置成具有256 个项,并都指向ignore_int 中断门。然后加载
; * 中断描述符表寄存器(用lidt 指令)。真正实用的中断门以后再安装。当我们在其它
; * 地方认为一切都正常时再开启中断。该子程序将会被页表覆盖掉。
; */
setup_idt:
lea edx,ignore_int ;// 将ignore_int 的有效地址(偏移值)值 edx 寄存器
mov eax,00080000h ;// 将选择符0x0008 置入eax 的高16 位中。
mov ax,dx ;/* selector = 0x0008 = cs */
;// 偏移值的低16 位置入eax 的低16 位中。此时eax 含
;// 有门描述符低4 字节的值。
mov dx,8E00h ;/* interrupt gate - dpl=0, present */
;// 此时edx 含有门描述符高4 字节的值。
lea edi,_idt
mov ecx,256
rp_sidt:
mov [edi],eax ;// 将哑中断门描述符存入表中。
mov [edi+4],edx
add edi,8 ;// edi 指向表中下一项。
dec ecx
jne rp_sidt
lidt fword ptr idt_descr ;// 加载中断描述符表寄存器值。
ret
ignore_int为哑中断处理程序(函数体也在head.s里,只执行了一个printk)。
eax存储ignore_int的基地址,edi则指向中断向量表的表项。
gdt的设置则比较直白:
;// 全局表。前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:
DQ 0000000000000000h ;/* NULL descriptor */
DQ 00c09a0000000fffh ;/* 16Mb */ // 代码段最大长度16M。
DQ 00c0920000000fffh ;/* 16Mb */ // 数据段最大长度16M。
DQ 0000000000000000h ;/* TEMPORARY - don't use */
DQ 252 dup(0) ;/* space for LDT's and TSS's etc */
end
然后就是检查A20和数学协处理器有没有打开(涉及硬件的代码我都跳了,以后要用到的话再来填坑)。
最后就是设置页表,这段也很简单。
页表项格式如上。
;/*
; * Setup_paging
; *
; * 这个子程序通过设置控制寄存器cr0 的标志(PG 位31)来启动对内存的分页处理
; * 功能,并设置各个页表项的内容,以恒等映射前16 MB 的物理内存。分页器假定
; * 不会产生非法的地址映射(也即在只有4Mb 的机器上设置出大于4Mb 的内存地址)。
; *
; * 注意!尽管所有的物理地址都应该由这个子程序进行恒等映射,但只有内核页面管
; * 理函数能直接使用>1Mb 的地址。所有“一般”函数仅使用低于1Mb 的地址空间,或
; * 者是使用局部数据空间,地址空间将被映射到其它一些地方去-- mm(内存管理程序)
; * 会管理这些事的。
; *
; * 对于那些有多于16Mb 内存的家伙- 太幸运了,我还没有,为什么你会有:-)。代码就
; * 在这里,对它进行修改吧。(实际上,这并不太困难的。通常只需修改一些常数等。
; * 我把它设置为16Mb,因为我的机器再怎么扩充甚至不能超过这个界限(当然,我的机
; * 器很便宜的:-))。我已经通过设置某类标志来给出需要改动的地方(搜索“16Mb”),
; * 但我不能保证作这些改动就行了 :-( )
; */
align 2 ;// 按4 字节方式对齐内存地址边界。
setup_paging: ;// 首先对5 页内存(1 页目录+ 4 页页表)清零
mov ecx,1024*5 ;/* 5 pages - pg_dir+4 page tables */
xor eax,eax
xor edi,edi ;/* pg_dir is at 0x000 */
;// 页目录从0x000 地址开始。
pushf ;// VC内汇编使用cld和std后,需要自己恢复DF的值
cld
rep stosd
;// 下面4 句设置页目录中的项,我们共有4 个页表所以只需设置4 项。
;// 页目录项的结构与页表中项的结构一样,4 个字节为1 项。参见上面的说明。
;// "$pg0+7"表示:0x00001007,是页目录表中的第1 项。
;// 则第1 个页表所在的地址= 0x00001007 & 0xfffff000 = 0x1000;第1 个页表
;// 的属性标志= 0x00001007 & 0x00000fff = 0x07,表示该页存在、用户可读写。
mov eax,_pg_dir
mov [eax],pg0+7 ;/* set present bit/user r/w */
mov [eax+4],pg1+7 ;/* --------- " " --------- */
mov [eax+8],pg2+7 ;/* --------- " " --------- */
mov [eax+12],pg3+7 ;/* --------- " " --------- */
;// 下面6 行填写4 个页表中所有项的内容,共有:4(页表)*1024(项/页表)=4096 项(0 - 0xfff),
;// 也即能映射物理内存4096*4Kb = 16Mb。
;// 每项的内容是:当前项所映射的物理内存地址+ 该页的标志(这里均为7)。
;// 使用的方法是从最后一个页表的最后一项开始按倒退顺序填写。一个页表的最后一项
;// 在页表中的位置是1023*4 = 4092。因此最后一页的最后一项的位置就是$pg3+4092。
mov edi,pg3+4092 ;// edi -> 最后一页的最后一项。
mov eax,00fff007h ;/* 16Mb - 4096 + 7 (r/w user,p) */
;// 最后1 项对应物理内存页面的地址是0xfff000,
;// 加上属性标志7,即为0xfff007.
std ;// 方向位置位,edi 值递减(4 字节)。
L3: stosd ;/* fill pages backwards - more efficient :-) */
sub eax,00001000h ;// 每填写好一项,物理地址值减0x1000。
jge L3 ;// 如果小于0 则说明全添写好了。
popf
;// 设置页目录基址寄存器cr3 的值,指向页目录表。
xor eax,eax ;/* 页目录表(pg_dir)在0x0000 处。 */
mov cr3,eax ;/* cr3 - page directory start */
;// 设置启动使用分页处理(cr0 的PG 标志,位31)
mov eax,cr0
or eax,80000000h ;// 添上PG 标志。
mov cr0,eax ;/* set paging (PG) bit */
ret ;/* this also flushes prefetch-queue */
;// 在改变分页处理标志后要求使用转移指令刷新预取指令队列,这里用的是返回指令ret。
;// 该返回指令的另一个作用是将堆栈中的main 程序的地址弹出,并开始运行/init/main.c
;// 程序。本程序到此真正结束了。
分页的代码如上,将页目录的前四项填满了,然后第三个页表的最后一项指向0xfff000为开始的页,换算一下,这正好是16MB内存的最后一页。然后就是一个循环,把所有的页表项都填满了。
当然,这些是内核的页表,对于每个用户进程,还有他们自己的专属页表,这些是后话了。
总而言之,在head.s运行完之后,内存的分配情况如下图:
值得注意的是,一个页表大小4K,即保护模式下可访问的内存为16MB。
关于idt的起始地址,应当与head.s的文件大小有关。《设计艺术》说head.s占据25K+184B的空间,我并没有在上下文中找到这个数据的来源。
下面就开始调用main.c函数了,因为这里的main不同于用户程序的main,是最底层的main,所以调用方式自然有所不同。
;// 下面这几个入栈操作(pushl)用于为调用/init/main.c 程序和返回作准备。
;// 前面3 个入栈指令不知道作什么用的,也许是Linus 用于在调试时能看清机器码用的.。
;// 139 行的入栈操作是模拟调用main.c 程序时首先将返回地址入栈的操作,所以如果
;// main.c 程序真的退出时,就会返回到这里的标号L6 处继续执行下去,也即死循环。
;// 140 行将main.c 的地址压入堆栈,这样,在设置分页处理(setup_paging)结束后
;// 执行'ret'返回指令时就会将main.c 程序的地址弹出堆栈,并去执行main.c 程序去了。
after_page_tables:
push 0 ;// These are the parameters to main :-)
push 0 ;// 这些是调用main 程序的参数(指init/main.c)。
push 0
push L6 ;// return address for main, if it decides to.
push _main_rename ;// '_main'是编译程序对main 的内部表示方法。
jmp setup_paging
L6:
jmp L6 ;// main should never return here, but
;// just in case, we know what happens.
这里《设计艺术》P42讲得比较细,但看起来有点玄乎,大概理解到main.c返回之后会进入死循环就可以了吧。
整理完了系统启动时前三个.s文件的一些笔记,由于不想深入硬件,所以跳了很多,只关心了一些数据结构在内存里的位置,下面终于到了mian.c文件。当然,这部分网上我目前能找到最好的博客是跟我一起玩《linux内核设计的艺术》第1章,写得很棒,对各种细节抠得非常好,可惜只有第一章。