Linux内核阅读1·启动与初始化(1)

博客主要为《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章,写得很棒,对各种细节抠得非常好,可惜只有第一章。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值