在阅读该文章之前,你起码有点操作系统的知识,了解实模式与保护模式的概念,了解分段机制,如果不懂得建议去阅读《操纵系统真象还原》这本书😁
如果你不想看下面的分析,那么我就用这段话来简单描述启动main函数之前都需要做什么:
init/main.c
中得main函数启动前,我们需要加载内核,划分内存,启用分页,把实模式转变为保护模式等一系列操作。先加载bootsect,利用bootsect中得代码读取磁盘加载setup和system,然后跳转到setup运行,setup获取硬盘等关键数据,关闭中断,开启保护模式,setup没有开启保护模式前,一直都是实模式,段寄存器中得值也都是段地址,然后跳转到system运行,head就在system内存开始的位置,运行system就要先运行head,head就要为main准备好分页等操作准备,等这些做完后,main函数就开始运行了。
如果你感兴趣,就继续往下看,坚持住~😄
加电后,BIOS准备好中断向量表以及中断服务程序,在加载bootsect前,目前内存情情况
image
加载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准备了一个数据栈。
image image
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/PS0 | 1.44MB A驱动器,major = 2; minor = 7 * 4 + 0 = 28 |
0x0208 | /dev/at0 | 1.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就表示每个磁道的扇区数。
image
在屏幕上显示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
image
执行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 ! 关闭中断
! first we move the system to it's rightful place
mov ax,#0x0000
cld ! 'direction'=0, movs moves forward
这里补充一点关于
movsb、movsw
的知识,因为后面的代码会用到相关知识,
这两个指令通常用于把数据从内存中的一个地方批量地传送(复制)到另一个地方,处理器把它们看成数据串。但是,movsb
的传送是以字节为单位的,而movsw
的传送是以字为单位的。
movsb
和movsw
指令执行时,原始数据串的段地址由DS
指定,偏移地址由SI
指定,简写DS:SI
;要传送到的目的地址由ES:DI
指定;传送的字节数(movsb)或者字数(movsw)由CX
指定。除此之外,还要指定是正向传送还是反向传送,正向传送是指传送操作的方向是从内存区域的低地址端到高地址端;反向传送则正好相反。正向传送时,每传送一个字节(movsb)或者一个字(movsw),SI
和DI
加1或者加2;反向传送时,每传送一个字节(movsb)或者一个字(movsw)时,SI
和DI
减去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(即在本程序段中的偏移地址)
image
图片来源英特尔® 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 的含义是请求特权级0(位0-1=0)、选择全局描述符表(位2=0)、选择表中第2 项(位3-15=2)。它正好指向GDT表中的数据段描述符项
[图片上传失败...(image-6c7038-1608866635403)]
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数据结构
image
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》
image
但是我觉的这么画可能更容易理解
image
网友评论