Linux0.11内核源码分析1-main函数运行之前的准备

528 阅读22分钟

在阅读该文章之前,你起码有点操作系统的知识,了解实模式与保护模式的概念,了解分段机制,还需要一点汇编语法知识,了解intel汇编和AT&T汇编,如果不懂得建议去阅读《操纵系统真象还原》这本书😁

如果你不想看下面的分析,那么我就用这段话来简单描述启动main函数之前都需要做什么:

init/main.c中得main函数启动前,我们需要加载内核,划分内存,启用分页,把实模式转变为保护模式等一系列操作。先加载bootsect,利用bootsect中得代码读取磁盘加载setup和system,然后跳转到setup运行,setup获取硬盘等关键数据,关闭中断,开启保护模式,setup没有开启保护模式前,一直都是实模式,段寄存器中得值也都是段地址,然后跳转到system运行,head就在system内存开始的位置,运行system就要先运行head,head就要为main准备好分页等操作准备,等这些做完后,main函数就开始运行了。

如果你感兴趣,就继续往下看,坚持住~😄

加电后,BIOS准备好中断向量表以及中断服务程序,在加载bootsect前,目前内存情情况

加载bootsect

CPU复位后CS:IP = 0xF000:0xFFF0,这样CPU开始执行的第一条指令就在0xFFFF0这个物理地址。这个地址就在BIOS的范围,此时DRAM中还没有操作系统的代码。我们理解的内存条中的地址只占内存地址中的一部分,还有一部分需要给bios等其他硬件,所以你在电脑上看到的内存就是没有你算出来的大。

接下来CPU会收到一个19号的中断,找到中断服务程序后就会把硬盘的0盘面0磁道1扇区的内容加载到0x07C00,然后跳转到该地址执行代码,此时DRAM中有了引导程序,为什么叫引导程序,因为需要它加载操作系统的内核文件。

开始执行bootsect.s中的代码,起点就是entry _start

boot/bootsect.s

SETUPLEN = 4				! 加载setup时需要的扇区数量
BOOTSEG  = 0x07c0			! bootsect被加载的地址
INITSEG  = 0x9000			! bootsect被复制到的新地址
SETUPSEG = 0x9020			! setup 程序执行的地址
SYSSEG   = 0x1000			! system 模块被加载的地址
ENDSEG   = SYSSEG + SYSSIZE		! system 模块内存中的末尾地址

ROOT_DEV = 0x306

entry _start
_start:
	mov	ax,#BOOTSEG  !寄存器 ax = 0x07c0
	mov	ds,ax        !寄存器 dx = 0x07c0
	mov	ax,#INITSEG  !寄存器 ax = 0x9000
	mov	es,ax        !寄存器 es = 0x9000
	mov	cx,#256      !寄存器 cx = 256,cx一般控制循环次数,因为后面执行的时rep指令和movw指令,所以这里的256是字,也就是512字节,也就是一个扇区
	sub	si,si        !寄存器si中的值减去si后再放到si寄存器中,自己减去自己,就是si清零
	sub	di,di        !寄存器di中的值减去di后再放到di寄存器中,di清零
	rep                  ! 重复执行一条指令,也就是后面的movw,一直到cx==0,这里循环了256次
	movw                 !把ds:si指向的字复制到es:di所指向的位置
	jmpi	go,INITSEG   !成功将bootsect在0x07c00的数据复制到0x90000物理地址,cs:ip =0x9000:go,go这是偏移地址,复制成功后就会跳转到该地址继续执行。
go:	mov	ax,cs       !此时cs就是0x9000,所以寄存器ax的值就会是0x9000
	mov	ds,ax       !寄存器 dx = 0x9000
	mov	es,ax       !寄存器 es = 0x9000
! put stack at 0x9ff00.
	mov	ss,ax       !寄存器 ss = 0x9000,ss与sp组合就表示栈
	mov	sp,#0xFF00  !现在栈就在 ss:sp =  0x9000:0xFF00 = 0x9FF00

上面的程序就是把bootsect的数据复制到0x9000这个物理地址,并在地址 0x9FF00准备了一个数据栈。

ROOT_DEV = 0x306 在bootsect.s中设置的根文件系统设备号其实只是初始值,不起作用,仅仅为保存根文件系统设备号的值在bootsect.s的编译后文件的508,509处预留了空间。而在最后用工具程序Build将所有内核有效部分组合起来时,还要对根文件系统设备号进行最后的处理。

Makefile中定义了ROOT_DEV= #FLOPPY 。 因为Linux当初是在Minix1.5.10操作系统的扩展版本Minix-i386上交叉编译开发的。并且当初Linus是将Linux的原始根文件系统放在第2块硬盘的第一个分区上的,所以在主Makefile文件中ROOT_DEV = /dev/hd6。并且在bootsect.s中,给定ROOT_DEV = 0x306;在Build.c中给定的缺省的主设备号(DEFAULT_MAJOR_ROOT)为3,缺省的次设备号(DEFAULT_MINOR_ROOT)为6。

0x306指定根文件系统设备是第2个硬盘的第1个分区。这是Linux老式的硬盘命名方式,具体值的含义如下:

设备号=主设备号*256 +次设备号(也即dev_no = (major<<8) + minor),其中主设备号:

1-内存,2-磁盘,3-硬盘,4-ttyx,5-tty,6-并行口,7-非命名管道。

硬盘的逻辑设备号(主设备号为3)

逻辑设备号对应设备文件说明
0x300/dev/hd0代表整个第1个硬盘
0x301/dev/hd1表示第1个硬盘的第1个分区
0x302/dev/hd2表示第1个硬盘的第2个分区
0x303/dev/hd3表示第1个硬盘的第3个分区
0x304/dev/hd4表示第1个硬盘的第4个分区
0x305/dev/hd5代表整个第2个硬盘
0x306/dev/hd6表示第2个硬盘的第1个分区
0x307/dev/hd7表示第2个硬盘的第2个分区
0x308/dev/hd8表示第2个硬盘的第3个分区
0x309/dev/hd9表示第2个硬盘的第4个分区

软盘的逻辑设备号:

(主设备号为2,次设备号=type * 4 + nr,其中nr为0-3分别对应软驱A、B、C、D;type是软驱的类型2:1.2MB,7:1.44MB等)

逻辑设备号对应设备文件说明
0x021C/dev/PS01.44MB A驱动器,major = 2; minor = 7 * 4 + 0 = 28
0x0208/dev/at01.2MB A驱动器,major = 2;minor = 2 * 4 + 0 = 8

bootsect.s文件被编译以后,产生的bin文件为512字节,其中508,509字节保存的为根设备号

.org 508
root_dev:
	.word ROOT_DEV

bootsect被加载到0x90000处,所以从0x90000 + 508 = 0x901FC处即可获得根文件系统设备号的值。 这个值在后面讲到的mian.c中有用到。

加载setup

load_setup:
	mov	dx,#0x0000		! drive 0, head 0
	mov	cx,#0x0002		! sector 2, track 0
	mov	bx,#0x0200		! 读取的扇区数据放在es:bx里面,es=0x9000,bx=512,紧贴在bootsect后面
	mov	ax,#0x0200+SETUPLEN	! ah = 2表示读取扇区,al= 4表示需要读取的扇区数量
	int	0x13			!13号中断,ah里表示功能
	jnc	ok_load_setup		! ok - continue
	mov	dx,#0x0000 
	mov	ax,#0x0000		! reset the diskette
	int	0x13            !ah =0 表示重置磁盘
	j	load_setup      !再次加载

这里用到13号中断,关于13号中断的信息wiki 或者int_13,上面代码就是把0盘面0磁道2扇区的数据加载到bootsect的后面。

ok_load_setup:

! 获取磁盘参数,尤其是每个磁道的扇区数

	mov	dl,#0x00
	mov	ax,#0x0800     ! AH=8 is get drive parameters
	int	0x13           ! 获取磁盘参数  
	mov	ch,#0x00       ! ch清0,不要柱面数
	seg cs
	mov	sectors,cx     !保存cx的数据到cs:[sectors]位置,ch=0,此时的cx 就是每个磁道的扇区数
	mov	ax,#INITSEG
	mov	es,ax          !把es设置为 0x9000
seg cs
mov sectors,cx

就如同

mov cs:[sectors],cx

上面的代码把磁盘的扇区数柱面数ch和每个磁道的扇区数cl保存在cx中,由于ch=0,所以cx就表示每个磁道的扇区数。

在屏幕上显示Loading system ...

! Print some inane message

	
	mov	ah,#0x03   ! int 0x10中断 功能号ah=0x03,读取光标位置
	xor	bh,bh      ! bh 置为0,作为int 0x10中断的输入:bh=页号
	int	0x10       ! 发出中断, 返回:ch=扫描开始线;cl=扫描结束线;dh=行号; dl=列号
	mov	cx,#24 	   ! 显示24个字符
	mov	bx,#0x0007 ! bh=0,页=0;bl=7,字符属性=7
	mov	bp,#msg1   ! es:bp寄存器指向要显示的字符串

	! BIOS中断0x10功能号ah=0x13,功能:显示字符串
	! 输入:al=放置光标方式及规定属性。0x01表示使用bl中属性值,光标停在字符串结尾处;
	! es:bp 指向要显示的字符串起始位置。 cx=显示字符串个数; bh=显示页面号
	! bl=字符属性; dh=行号; dl=页号
	mov	ax,#0x1301		! write string, move cursor
	int	0x10
    
    	........
   
   msg1:
	
	! \r\n
	.byte 13,10

	! ascii码"Loading system ..."占据18字节 
	.ascii "Loading system ..."

	! \r\n\r\n
	.byte 13,10,13,10 
    

加载system

	mov	ax,#SYSSEG     ! ax = 0x1000
	mov	es,ax	       ! es = 0x1000
	call	read_it        !下一条指令call	kill_motor 的IP压栈,等待ret指令返回后弹出地址给IP
	call	kill_motor
    
       .....
       
read_it:
	mov ax,es        ! ax = 0x1000
	test ax,#0x0fff  ! 这里确保ax是在64KB的边界,因为后面加载数据都是用64KB计算,正常情况下:test ax,#0x0fff结果为0,则ZF=1。不满足JNE跳转条件(ZF=0)
die:	jne die		 ! es must be at 64kB boundary
	xor bx,bx        ! 清bx 寄存器,用于表示当前段内存放数据的开始位置,为读取磁盘数据做好准备

rp_read:
	mov ax,es       ! ax = 0x1000

	
	cmp ax,#ENDSEG		! ax - ENDSEG的结果会修改ZF标志,如果结果为0,则ZF=1,否则ZF=0 用此判断有没有加载完毕
	
	! jb指令当进位CF标志位为1时跳转到ok1_read标号处
	! cmp是减法,如果CF = 1,说明有借位,此时ax的值比#ENDSEG的值小
	! 说明没有读完,跳转ok1_read处执行
	jb ok1_read

	ret  ! 返回到call kill_motor出执行

因为这里是段间转移,所以call指令就会把程序下一条指令的位置的IP压入堆栈中,然后转移到调用的子程序,ret就会把堆栈中的数据弹出给IP。

! 定义局部变量,已读扇区数,由于前面加载了bootsect(1个扇区数据)和setup(4个扇区数据),
! 所以这里1+SETUPLEN
sread:	.word 1+SETUPLEN

!磁头号
head:	.word 0			! current head

! 磁道号
track:	.word 0			! current track
ok1_read:
	seg cs
	mov ax,sectors ! 把cs:[sectors]的值赋给ax
	sub ax,sread   ! ax = ax - sread , ax 表示磁道剩余扇区数
	mov cx,ax      ! cx 扇区数
	shl cx,#9      ! 乘以512,cx表示剩余字节数
	add cx,bx      ! bx已经被初始化为0,因为还是实模式,段寄存器只有16位,最大到64KB,这里cx表示读取了多少字节数
    
	jnc ok2_read   ! 若没有超过64KB 字节,则跳转至ok2_read 处执行
	je ok2_read    
	
        xor ax,ax      ! 超出最大段64KB,将ax清0
	sub ax,bx      ! 由于寄存器是16位无符号的,所以0 - bx = 65536 - bx,結果为段內剩余字节数
	shr ax,#9      ! ax>>9 转换成扇区数
ok2_read:
	call read_track  ! 注意ax中的al存着我们需要读取的扇区数
	mov cx,ax        ! 读取后,al中保存着实际读取的扇区数数量,cx就表示这次已经读了多少扇区 
	add ax,sread     ! 该磁道上已经读取的扇区总数
	seg cs
	cmp ax,sectors   ! 如果当前磁道上的还有扇区未读,则跳转到ok3_read 处
	jne ok3_read
	mov ax,#1        ! 读该磁道的下一磁头面(1 号磁头)上的数据。如果已经完成,则去读下一磁道
	sub ax,head      ! 判断当前磁头号
	jne ok4_read     ! 如果是0 磁头,则再去读1 磁头面上的扇区数据
	inc track        ! 否则去读下一磁道
ok4_read:
	mov head,ax      ! 保存当前磁头号
	xor ax,ax        ! 清0当前磁道已读扇区
ok3_read:
	mov sread,ax     ! 保存当前磁道已读扇区数
	shl cx,#9
	add bx,cx
	jnc rp_read      ! 若小于64KB 边界值,则跳转到rp_read处,继续读数据
	mov ax,es        ! 否则调整当前段,为读下一段数据作准备
	add ax,#0x1000   ! 将段基址调整为指向下一个64KB 内存开始处
	mov es,ax
	xor bx,bx        ! 清段内数据开始偏移值
	jmp rp_read      ! 跳转至rp_read 继续读取
    
read_track:
	push ax        ! 压栈保存数据
	push bx
	push cx
	push dx
	mov dx,track   ! dx存储磁道号
	mov cx,sread   ! cx表示已读扇区数(扇区号)
	inc cx         ! cx加1 ,表示读取下一个扇区
	mov ch,dl      ! dl=0,ch=0,表示0柱面
	mov dx,head   
	mov dh,dl      ! dh表示磁头号
	mov dl,#0      ! dl=0表示驱动号
	and dx,#0x0100 ! 保证磁头号不大于1
	mov ah,#2      ! ah表示功能号,2表示读取磁盘扇区,al中保存需要读取的扇区数量
	int 0x13       ! 发出中断,读取失败cf=1,ah会保存返回码,al保存实际读取扇区数,数据读取到es:bx指向的内存中
	jc bad_rt      ! jump carry,即cf=1时跳转,cf=1表示读取失败
	pop dx         ! 出栈
	pop cx
	pop bx
	pop ax
	ret
bad_rt:	mov ax,#0      ! 磁盘系统复位,跳转到read_track重新读取
	mov dx,#0
	int 0x13
	pop dx
	pop cx
	pop bx
	pop ax
	jmp read_track    

INT 13h AH=02h的功能是读取扇区

参数

al:需要读取的扇区数
ch:哪个柱面
cl:哪个扇区
dh:哪个磁头
dl:哪个驱动

输出ES:BX缓冲区,读取后,ah保存返回码,al保存实际读取扇区数量,如果读取失败CF置位1。

!关闭软驱的马达

kill_motor:
	push dx
	mov dx,#0x3f2
	mov al,#0
	outb
	pop dx
	ret

获取root_dev跟设备号。,然后jmpi 0,SETUPSEG地址处执行,这个地方就是setup程序的地址。

        seg cs
	mov	ax,root_dev     ! 定义了根设备号,就用该设备号,没有定义的跳转到root_defined
	cmp	ax,#0
	jne	root_defined
	seg cs
	mov	bx,sectors
	mov	ax,#0x0208		! /dev/ps0 - 1.2Mb
	cmp	bx,#15                  ! 如果sectors=15 则说明是1.2Mb 的驱动器;
	je	root_defined
	mov	ax,#0x021c		! /dev/PS0 - 1.44Mb
	cmp	bx,#18                  ! 如果sectors=18 则说明是1.44Mb 的驱动器;
	je	root_defined
undef_root:
	jmp undef_root
root_defined:
	seg cs
	mov	root_dev,ax

! after that (everyting loaded), we jump to
! the setup-routine loaded directly after
! the bootblock:

	jmpi	0,SETUPSEG

执行setup程序

start:
	mov	ax,#INITSEG	! ax =  0x9000
	mov	ds,ax           ! ds =  0x9000
	mov	ah,#0x03	! read cursor pos
	xor	bh,bh
	int	0x10		! save it in known place, con_init fetches
	mov	[0],dx		! 把光标位置保存到ds:[0],也就是0x90000

int 0x10中断在这里查看INT_10H

BIOS 中断0x10 的读光标功能号 ah = 0x03

输入:bh = 页号

返回:ch = 扫描开始线,cl = 扫描结束线, dh = 行号(0x00 是顶端),dl = 列号(0x00 是左边)。

! Get memory size (extended mem, kB)

	mov	ah,#0x88
	int	0x15
	mov	[2],ax

获取扩展内存的大小值(KB)。

调用中断0x15,功能号ah = 0x88 返回:ax = 从0x100000(1M)处开始的扩展内存大小(KB)。 若出错则CF 置位,ax = 出错码。

! Get video-card data:

	mov	ah,#0x0f
	int	0x10
	mov	[4],bx		! bh = display page
	mov	[6],ax		! al = video mode, ah = window width

获取显示卡当前显示模式。

调用BIOS 中断0x10,功能号 ah = 0x0f

返回:ah = 字符列数,al = 显示模式,bh = 当前显示页。 0x90004(1 字)存放当前页,0x90006 显示模式,0x90007 字符列数。

! check for EGA/VGA and some config parameters

	mov	ah,#0x12
	mov	bl,#0x10
	int	0x10
	mov	[8],ax
	mov	[10],bx
	mov	[12],cx 

检查显示方式(EGA/VGA)并取参数

调用BIOS 中断0x10

功能号:ah = 0x12,bl = 0x10

返回:

bh = 显示状态 (0x00 - 彩色模式,I/O 端口=0x3dX) 、(0x01 - 单色模式,I/O 端口=0x3bX)

bl = 安装的显示内存(0x00 - 64k, 0x01 - 128k, 0x02 - 192k, 0x03 = 256k)

cx = 显示卡特性参数

! Get hd0 data

	mov	ax,#0x0000
	mov	ds,ax
	lds	si,[4*0x41] ! 取中断向量0x41 的值,也即hd0 参数表的地址ds:si
	mov	ax,#INITSEG
	mov	es,ax
	mov	di,#0x0080
	mov	cx,#0x10
	rep
	movsb
! Get hd1 data

	mov	ax,#0x0000
	mov	ds,ax
	lds	si,[4*0x46]  ! 取中断向量0x46 的值,也即hd1 参数表的地址
	mov	ax,#INITSEG
	mov	es,ax
	mov	di,#0x0090
	mov	cx,#0x10
	rep
	movsb
    
       ! 检查系统是否存在第2 个硬盘,如果不存在则第2 个表清零
	mov	ax,#0x01500
	mov	dl,#0x81
	int	0x13
	jc	no_disk1
	cmp	ah,#3
	je	is_disk1
no_disk1:
	mov	ax,#INITSEG  ! 第2 个硬盘不存在,则对第2 个硬盘表清零
	mov	es,ax
	mov	di,#0x0090
	mov	cx,#0x10
	mov	ax,#0x00
	rep
	stosb

取第一个硬盘的信息(复制硬盘参数表) 第1个硬盘参数表的首地址竟然是中断向量0x41 的向量值!而第2 个硬盘参数表紧接第1 个表的后面, 0x90080 处存放第1 个硬盘的表,0x90090 处存放第2 个硬盘的表。

利用BIOS 中断调用0x13 的取盘类型功能。

功能号 ah = 0x15;

输入:dl = 驱动器号(0x8X 是硬盘:0x80 指第1 个硬盘,0x81 第2 个硬盘)

输出:ah = 类型码;00 --没有这个盘,CF 置位; 01 --是软驱,没有change-line 支持; 02 --是软驱(或其它可移动设备),有change-line 支持; 03 --是硬盘。

is_disk1:

     ! 现在我们要进入保护模式中

   cli			! 关闭中断,需要等到main.c中开启中断

! first we move the system to it's rightful place

   mov	ax,#0x0000
   cld			! 'direction'=0, movs moves forward

这里补充一点关于 movsb、movsw的知识,因为后面的代码会用到相关知识, 这两个指令通常用于把数据从内存中的一个地方批量地传送(复制)到另一个地方,处理器把它们看成数据串。但是,movsb的传送是以字节为单位的,而movsw的传送是以字为单位的。 movsbmovsw指令执行时,原始数据串的段地址由DS指定,偏移地址由SI指定,简写DS:SI;要传送到的目的地址由ES:DI指定;传送的字节数(movsb)或者字数(movsw)由CX指定。除此之外,还要指定是正向传送还是反向传送,正向传送是指传送操作的方向是从内存区域的低地址端到高地址端;反向传送则正好相反。正向传送时,每传送一个字节(movsb)或者一个字(movsw),SIDI加1或者加2;反向传送时,每传送一个字节(movsb)或者一个字(movsw)时,SIDI减去1或者减去2。不管是正向传送还是反向传送,也不管每次传送的是字节还是字,每传送一次,CX的内容自动减1。 标志寄存器的第10位是方向标志DF(Direction Flag)DF=0表示正向传送,DF=1表示反向传送。 cld指令将DF标志清零,std指令将DF标志置1

bootsect 引导程序是将system 模块读入到从0x10000(64k)开始的位置。由于当时假设system 模块最大长度不会超过0x80000(512k),也即其末端不会超过内存地址0x90000,所以bootsect 会将自己移动到0x90000 开始的地方,并把setup 加载到它的后面。 下面这段程序的用途是再把整个system 模块移动到0x00000 位置,即把从0x10000 到0x8ffff 的内存数据块(512k),整块地向内存低端移动了0x10000(64k)的位置。

do_move:
	mov	es,ax		! 复制到的目的地址es:di = 0x0000:0x0
	add	ax,#0x1000
	cmp	ax,#0x9000       ! 已经把从0x8000 段开始的64k 代码移动完?
	jz	end_move
	mov	ds,ax		! 复制的源地址ds:si = 0x1000:0x0
	sub	di,di
	sub	si,si
	mov 	cx,#0x8000       ! 移动0x8000 字(64k 字节)
	rep
	movsw
	jmp	do_move
end_move:
	mov	ax,#SETUPSEG	! right, forgot this at first. didn't work :-)
	mov	ds,ax           ! ds 指向本程序(setup)段
	lidt	idt_48		! 加载中断描述符到idtr寄存器
	lgdt	gdt_48		! 加载全局描述符到gdtr寄存器

! that was painless, now we enable A20

	call	empty_8042
	mov	al,#0xD1		! command write
	out	#0x64,al
	call	empty_8042
	mov	al,#0xDF		! A20 on
	out	#0x60,al
	call	empty_8042
    
    ......
    
    	mov	ax,#0x0001	! 启用保护模式
	lmsw	ax		! This is it!
	jmpi	0,8		! 现在处于保护模式了,段寄存器里面就不是段地址了,而是段选择符了
    

我们已经将system 模块移动到0x00000 开始的地方,所以这里的偏移地址是0,这里的段值的8 已经是保护模式下的段选择符了,用于选择描述符表和描述符表项以及所要求的特权级。所以段选择符 8(0b0000,0000,0000,1000)表示请求特权级0、使用全局描述符表中的第1 项,该项指出代码的基地址是0 因此这里的跳转指令就会去执行system 中的代码。

gdt:                            !全局描述符表开始处。描述符表由多个8 字节长的描述符项组成
	.word	0,0,0,0		! 第1 个描述符,不用
        !代码段描述符  
	.word	0x07FF		! 8Mb - limit=2047 (2048*4096=8Mb)
	.word	0x0000		! base address=0
	.word	0x9A00		! code read/exec
	.word	0x00C0		! granularity=4096, 386

        !数据段描述符  
	.word	0x07FF		! 8Mb - limit=2047 (2048*4096=8Mb)
	.word	0x0000		! base address=0
	.word	0x9200		! data read/write
	.word	0x00C0		! granularity=4096, 386
    
idt_48:
	.word	0			! idt limit=0
	.word	0,0			! idt base=0L
gdt_48:
	.word	0x800		! 全局表长度为2k 字节,因为每8 字节组成一个段描述符项,所以表中共可有256 项
	.word	512+gdt,0x9	! 4 个字节构成的内存线性地址:0x0009<<16 + 0x0200+gdt,也即0x90200 + gdt(即在本程序段中的偏移地址)
    

图片来源英特尔® 64 位和 IA-32 架构开发人员手册:卷 3A 第196页

段选择符

head.s开始执行

因为head.s是位于system开始,所以,jmpi 0,8 就是执行head.s代码,注意这里不是intel汇编格式,而是AT&T汇编。

startup_32:
	movl $0x10,%eax  # eax= 10
	mov %ax,%ds
	mov %ax,%es
	mov %ax,%fs
	mov %ax,%gs
	lss _stack_start,%esp  # ss:esp 设置堆栈,stack_start在sched.c中
	call setup_idt
	call setup_gdt
	movl $0x10,%eax		# reload all the segment registers
	mov %ax,%ds		# after changing gdt. CS was already
	mov %ax,%es		# reloaded in 'setup_gdt'
	mov %ax,%fs
	mov %ax,%gs
	lss stack_start,%esp
	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 去
    
    movl %cr0,%eax		# check math chip
	andl $0x80000011,%eax	# Save PG,PE,ET
/* "orl $0x10020,%eax" here for 486 might be good */
	orl $2,%eax		# set MP
	movl %eax,%cr0
	call check_x87
	jmp after_page_tables

这里已经处于32 位运行模式,因此这里的0x10就是段选择符,这里0x10 就是段选择符,这里0x10 的含义是请求特权级0(位0-1=0)、选择全局描述符表(位2=0)、选择表中第2 项(位3-15=2)。它正好指向GDT表中的数据段描述符项

setup_idt:
	lea ignore_int,%edx   # 将ignore_int 的有效地址(偏移值)保存到edx 寄存器
	movl $0x00080000,%eax # 将选择符0x0008 置入eax 的高16 位中
	movw %dx,%ax	      # 偏移值的低16 位置入eax 的低16 位中
	movw $0x8E00,%dx      # interrupt gate - dpl=0, p=1 此时edx 含有门描述符高4 字节的值

	lea idt,%edi          # _idt 是中断描述符表的地址
	mov $256,%ecx         # 256次循环,
rp_sidt:
	movl %eax,(%edi)     # 把32位寄存器eax的值赋给es:edi的内存位置
	movl %edx,4(%edi)    # 把32位寄存器eax的值赋给es:edi + 4的内存位置
	addl $8,%edi         # edi 指向表中下一项,描述符占据8字节
	dec %ecx
	jne rp_sidt          # 加载256个描述符
	lidt idt_descr       # 加载中断描述符表寄存器值
	ret
setup_gdt:
	lgdt gdt_descr       # 加载全局描述符表寄存器
	ret
....


ignore_int:
	pushl %eax
	pushl %ecx
	pushl %edx
	push %ds
	push %es
	push %fs
	movl $0x10,%eax
	mov %ax,%ds
	mov %ax,%es
	mov %ax,%fs
	pushl $int_msg
	call printk
	popl %eax
	pop %fs
	pop %es
	pop %ds
	popl %edx
	popl %ecx
	popl %eax
	iret
    

上面也讲过48位idtr寄存器得结构,.word表示一个字,也就是2个字节(16位),idrtd的低16位白表示的是limit,被限制多少字节,256*8-1中的乘以8是因为中断描述符也是占据8个字节,所以表示的就是256个中断描述符,现在idt地址处被清0了,等待main函数启动收重新设置中断。

idt_descr

idt_descr:
	.word 256*8-1		# idt contains 256 entries
	.long idt
.align 2  # 2的2次方内存对其,也就是4字节对齐
.word 0
gdt_descr:
	.word 256*8-1		# so does gdt (not that that's any
	.long gdt		# magic number, but it works for me :^)

	.align 8  # 2的8次方内存对齐,64字节对齐
idt:	.fill 256,8,0		# idt 清0,等待main函数启动后重新填值

gdt:	.quad 0x0000000000000000	/* NULL描述符,要有,但是不用,防止哪个傻蛋用了这个 */
	.quad 0x00c09a0000000fff	/* 16Mb */
	.quad 0x00c0920000000fff	/* 16Mb */
	.quad 0x0000000000000000	/* TEMPORARY - don't use */
	.fill 252,8,0			/* space for LDT's and TSS's etc */

after_page_tables:
	pushl $0		# These are the parameters to main :-)
	pushl $0
	pushl $0
	pushl $L6		# 如果main函数退出,就会到L6处死循环,main函数是内核运行的,一般不断电不会退出
	pushl $_main    # 把main函数的地址压栈,等待分页完成后就会执行
	jmp setup_paging
L6:
	jmp L6
   

开始分页,为什么分页以及分页的原理,建议大家可以看看《操作系统真象还原》这本书。 普及一下概念,页表中的每一行(只有一个单元格)称为页表项(Page Table Entry, PTE),其大小是4 字节,页表项的作用是存储内存物理地址。

由于页大小是4KB,所以页表项中的物理地址都是4K 的整数倍,故用十六进制表示的地址,低3 位都是0,比如0x1000,0x2000,0x3000,为什么我在这里说这个,因为后面分析PTE时会有用。

以为寄存去是32位的了,内存寻址可以达到4GB,也就是说每个应用都可以访问4GB的内存,可是不止我们一个应用,还有其他的应用也要用内存,如果都要占用4GB,肯定会有冲突,分页后,应用按需加载,每个应用都会有4GB的虚拟内存,注意这里是虚拟内存,并不是真正的内存,想要PTE中有物理地址,经过MMU转换后就可以得到真实的物理地址了,现在1页是4KB,虚拟内存是4GB,所以可以划出来 4GB/4KB=1M 个页,也就是4GB 空间中可以容纳1048576 个页,页表中自然也要有1048576 个页表项。

下面分析如何通过一级页表得到物理地址的:

在 32 位保护模式下任何地址都是用32 位二进制表示的,包括虚拟地址也是。虚拟地址的高20 位可用来定位一个物理页,低12 位可用来在该物理页内寻址(偏移地址)。

一个页表项对应一个页,所以,用线性地址的高20 位作为页表项的索引,每个页表项要占用4 字节 大小,所以这高20 位的索引乘以4 后才是该页表项相对于页表物理地址的字节偏移量。用cr3 寄存器中 的页表物理地址加上此偏移量便是该页表项的物理地址,从该页表项中得到映射的物理页地址,然后用线 性地址的低12 位与该物理页地址相加,所得的地址之和便是最终要访问的物理地址。 一级页表将线性地址转换成物理地址过程

说一级页表就是为了说明原理。但我们这里用的是二级页表,下面分析二级页表 每个页表都有自己的页表,这样就光页表就占据不少内存,一级页表在线代操作系统也不会用。 一级页表是将这1M 个标准页放置到一张页表中,二级页表是将这1M 个标准页平均放置1K 个 页表中。每个页表中包含有1K 个页表项。页表项是4 字节大小,页表包含1K 个页表项,故页表大小为 4KB,为了管理二级页表,就要有页目录,每个页表的物 理地址在页目录表中都以页目录项(Page Directory Entry, PDE)的形式存储,页目录项大小同页表项一 样,都用来描述一个物理页的物理地址,其大小都是4 字节,而且最多有1024 个页表,所以页目录表也 是4KB 大小,同样也是标准页的大小,也就是说页目录只占用1页。经过页目录,程序可以访问的物理地址为 1024(1024个页目录) * 1024(每个PDE指向1024个PTE)*4KB(每个PTE指向4KB物理内存) = 4GB,虽然可以理论可以访问这么多地址,实际上不会的,就光页表还要占据内存,为了和操作系统交互,这4GB还要给内核分配一点占据高地址的1GB,自己的程序还剩下3GB了。呵呵呵~~~

二级页表

二级页表与一级页表在原理上相同,但结构上已经有了很大不同,它们在虚拟地址到物理地址转换方 法上也有很大不同。

一级页表转换方法,是将32 位虚拟地址拆分成两部分,高20 位用于定位一个物理页,低 12 位用于物理页内的偏移量。

在二级页表是这样的: 页目录中1024 个页表,只需要10 位二进制就能够表示了,所以,虚拟地址的高 10 位(第31~22 位)用来在页目录中定位一个页表,也就是这高10 位用于定位页目录中的页目录项PDE, PDE 中有页表物理页地址。找到页表后,到底是页表中哪一个物理页呢?由于页表中可容纳1024 个物理页, 故只需要10 位二进制就能够表示了。所以虚拟地址的中间10 位(第21~12 位)用来在页表中定位具体的 物理页,也就是在页表中定位一个页表项PTE,PTE 中有分配的物理页地址。由于标准页都是4KB,12 位 二进制便可以表达4KB 之内的任意地址,故线性地址中余下的12 位(第11~0 位)用于页内偏移量。 无论是PDE还是PTE,地址中的都是索引,每个PDE或者PTE都占据4字节,所以会乘以4用来表示访问地址

二级页表虚拟地址到物理地址转换

下面看看PDE或者PTE数据结构 4 字节大小,但其内容并不全是物理地址,只有第12~31 位才是物理地址,这才20 位,因该是32位啊~, 因为页目录项和页表项中的都是物理页地址,标准页大小是4KB,故地址都是4K 的倍数,也就是地址的低12 位是0,所以只需要记录物理地址高20 位就可以啦。这样省出来的12 位。这里的每一项具体是什么含义,这里说不下,书中自有黄金屋~~~。感觉就分页都能讲一章了😂

最后在说下控制寄存器cr3,它是用来保存页目录的 启动分页机制的开关是将控制寄存器cr0 的PG 位置1

好了回到我们的linux分析,绕了一大圈回来了,有没有看懂分页,有没有懵逼~🤣

setup_paging一共分了5页,1个页目录,4个页表, stosl 指令相当于将 eax 中的值保存到 ES:EDI 指向的地址中,若设置了标志寄存器EFLAGS中的方向位置位(即在 stosl指令前使用STD指令)则EDI自减4,否则(使用CLD指令)EDI自增4。所以这里 ecx的值就是10245,每次移动4个字节,10245就是移动了1024* 5 * 4个字节,也就是5KB(5页)。pg_dir的初始位置在0x0000这个位置。

movl $pg0+7,pg_dir表示将pg0+7的值放到pg_dir的位置,此时pg_dir的位置在0x0000, $pg0+7的值是0x00001007,则第1 个页表所在的地址 = 0x00001007 & 0xfffff000 = 0x1000;第1 个页表的属性标志 = 0x00001007 & 0x00000fff = 0x07=0b0111,P=1,RW=1,US=1 表示该页存在、用户可读写。

setup_paging:
	movl $1024*5,%ecx		/* 5 pages - pg_dir+4 page tables */
	xorl %eax,%eax                  /* eax 清0 */
	xorl %edi,%edi			/* edi 清0  pg_dir is at 0x000  */
	cld;rep;stosl                   /* cld将df置位0,表示正向传送 ,重复执行stosl */
       
       /* 初始化页目录 */
	movl $pg0+7,pg_dir		/*  第1个PDE,$pg0+7表示0x00001007 */
	movl $pg1+7,pg_dir+4		/*  第2个PDE,每个PTE占据4字节,所以加4 */
	movl $pg2+7,pg_dir+8		/*  第3个PDE,前面已经有2个PDE了,所以加8 */
	movl $pg3+7,pg_dir+12		/*  第4个PDE,前面已经有3个PDE了,所以加12 */
	
    	movl $pg3+4092,%edi             /*一个页表的最后一项在页表中的位置是1023*4 = 4092。 因此最后一页的最后一项的位置就是$pg3+4092*/
	movl $0xfff007,%eax		/*  16Mb - 4096 + 7 (r/w user,p) */
	std                             /*  方向位置位,edi 值递减(4 字节) */
1:	stosl			/* fill pages backwards - more efficient :-) */
	subl $0x1000,%eax
	jge 1b
	xorl %eax,%eax		/* 页目录表在0x0000 处 */
	movl %eax,%cr3		/* 设置页目录基址寄存器cr3 的值,指向页目录表 */
	movl %cr0,%eax
	orl $0x80000000,%eax   
	movl %eax,%cr0		/* 添上PG 标志,启用分页 */
	ret			/* this also flushes prefetch-queue */
.org 0x1000
pg0:

.org 0x2000
pg1:

.org 0x3000
pg2:

.org 0x4000
pg3:

.org 0x5000

这是head执行完后,内存分布,图片来源《Linux_内核完全注释_V11》 但是我觉的这么画可能更容易理解