从实模式到保护模式笔记笔记12分页机制和动态页面分配
从实模式到保护模式笔记笔记12分页机制和动态页面分配
0x00 代码
本章代码比较复杂,对照代码看完书后,手写了本章变动部分,加入个人理解后的详细注释。
内核主体代码:
;以下是常量部分,和引导程序约定,方便引导
core_code_seg_sel equ 0x38
core_data_seg_sel equ 0x30
sys_routine_seg_sel equ 0x28
video_ram_seg_sel equ 0x20
core_stack_seg_sel equ 0x18
mem_0_4_gb_seg_sel equ 0x08
;以下是系统header段,用于引导程序加载内核
;==========================================================
core_length dd core_end
sys_routine_seg dd section.sys_routine.start
core_data_seg dd section.core_data.start
core_code_seg dd section.core_code.start
core_entry dd start
dw core_code_seg_sel
[bits 32]
;以下是公共例程段
;==========================================================
SECTION sys_routine vstart=0
;----------------------------------------------------------
;字符串显示例程,以0结尾
;输入 ds:ebx 串起始线性地址
put_string:
push ecx
.getc:
mov cl,[ebx]
or cl,cl
jz .exit
call put_char
inc ebx
jmp .getc
.exit:
pop ecx
retf ;段间返回
;----------------------------------------------------------
;字符显示例程,并推进光标
;输入 cl 字符ascii
put_char:
pushad
;以下取当前光标位置
mov dx,0x3d4
mov al,0x0e
out dx,al
inc dx ;0x3d5
in al,dx ;高字
mov ah,al
dec dx ;0x3d4
mov al,0x0f
out dx,al
inc dx ;0x3d5
in al,dx ;低字
mov bx,ax ;BX=代表光标位置的16位数
cmp cl,0x0d ;回车符?
jnz .put_0a
mov ax,bx
mov bl,80
div bl
mul bl
mov bx,ax
jmp .set_cursor
.put_0a:
cmp cl,0x0a ;换行符?
jnz .put_other
add bx,80
jmp .roll_screen
.put_other: ;正常显示字符
push es
mov eax,video_ram_seg_sel ;0x800b8000段的选择子
mov es,eax
shl bx,1
mov [es:bx],cl
pop es
;以下将光标位置推进一个字符
shr bx,1
inc bx
.roll_screen:
cmp bx,2000 ;光标超出屏幕?滚屏
jl .set_cursor
push ds
push es
mov eax,video_ram_seg_sel
mov ds,eax
mov es,eax
cld
mov esi,0xa0 ;小心!32位模式下movsb/w/d
mov edi,0x00 ;使用的是esi/edi/ecx
mov ecx,1920
rep movsd
mov bx,3840 ;清除屏幕最底一行
mov ecx,80 ;32位程序应该使用ECX
.cls:
mov word[es:bx],0x0720
add bx,2
loop .cls
pop es
pop ds
mov bx,1920
.set_cursor:
mov dx,0x3d4
mov al,0x0e
out dx,al
inc dx ;0x3d5
mov al,bh
out dx,al
dec dx ;0x3d4
mov al,0x0f
out dx,al
inc dx ;0x3d5
mov al,bl
out dx,al
popad
ret
;----------------------------------------------------------
;读磁盘
;EAX=逻辑扇区号
;DS:EBX=目标缓冲区地址
;返回:EBX=EBX+512
read_hard_disk_0:
push eax
push ecx
push edx
push eax
mov dx,0x1f2
mov al,1
out dx,al ;读取的扇区数
inc dx ;0x1f3
pop eax
out dx,al ;LBA地址7~0
inc dx ;0x1f4
mov cl,8
shr eax,cl
out dx,al ;LBA地址15~8
inc dx ;0x1f5
shr eax,cl
out dx,al ;LBA地址23~16
inc dx ;0x1f6
shr eax,cl
or al,0xe0 ;第一硬盘 LBA地址27~24
out dx,al
inc dx ;0x1f7
mov al,0x20 ;读命令
out dx,al
.waits:
in al,dx
and al,0x88
cmp al,0x08
jnz .waits ;不忙,且硬盘已准备好数据传输
mov ecx,256 ;总共要读取的字数
mov dx,0x1f0
.readw:
in ax,dx
mov [ebx],ax
add ebx,2
loop .readw
pop edx
pop ecx
pop eax
retf ;段间返回
;----------------------------------------------------------
;以16进制显示一个双字
;输入 edx 待显示的双字
put_hex_dword:
pushad
push ds
mov ax,core_data_seg_sel
mov ds,ax
mov ebx,bin_hex;指向字符表
mov ecx,8;一共8位16进制
.xlt:
rol edx,4;循环左移
mov eax,edx
and eax,0x0000000f;拿到最低4位
xlat;以al 为偏移从 ds:ebx处查表 结果返回 al
push ecx
mov cl,al
call put_char
pop ecx
loop .xlt
pop ds
popad
retf;段间返回
;----------------------------------------------------------
;GDT内安装描述符
;输入 edx:eax 描述符
;输出 cx 选择子
set_up_gdt_descriptor:
push eax
push ebx
push edx
push ds
push es
mov ebx,core_data_seg_sel
mov ds,ebx
sgdt [pgdt];获取GDTR内容
mov ebx,mem_0_4_gb_seg_sel
mov es,ebx
movzx ebx,word [pgdt];界限
inc bx
add ebx,[pgdt+2];新描述符地址
mov [es:ebx],eax
mov [es:ebx+4],edx
add word [pgdt],8
lgdt [pgdt]
mov ax,[pgdt];新界限
xor dx,dx
mov bx,8
div bx
mov cx,ax;索引
shl cx,3;索引左移3位 设置ti rpl
pop es
pop ds
pop edx
pop ebx
pop eax
retf
;----------------------------------------------------------
;构造描述符
;输入 eax 基地址 ebx 界限 ecx 属性
;输出 edx:eax 描述符
make_seg_descriptor:
mov edx,eax
shl eax,16
or ax,bx; eax 描述符低32位
and edx,0xffff0000;基地址高16位
rol edx,8;循环左移8
bswap edx;交换24-31于0-7 8-15与16-23 基地址安装完成
xor bx,bx;ebx中只剩下 16-19位
or edx,ebx;装配界限 高4位
or edx,ecx;装配属性
retf
;----------------------------------------------------------
;构造门描述符
;输入 eax 段内偏移 bx 目标代码段选择子 cx 属性
;返回 edx:eax 描述符
make_gate_descriptor:
push ebx
push ecx
mov edx,eax
and edx,0xffff0000;偏移地址高16位
or dx,cx;装配属性
and eax,0x0000ffff;低16位偏移
shl ebx,16;选择子左移16位
or eax,ebx
pop ecx
pop ebx
retf;段间返回
;----------------------------------------------------------
;从页映射位串中找到未分配的页,并置1
;输入 无
;输出 eax 物理页地址
allocate_a_4k_page:
push ebx
push ecx
push edx
push ds
mov eax,core_data_seg_sel
mov ds,eax
xor eax,eax
.b1:
bts [page_bit_map],eax;将page_bit_map处的位串的第eax位(从0开始算,二进制位)的值传到CF标志位,并将第eax位置1
jnc .b2;cf位为0时,即位串的第eax位为0时
inc eax
cmp eax,page_map_len*8;page_map_len是位串所占字节
jl .b1
mov ebx,message_3
call sys_routine_seg_sel:put_string
hlt;至此 位串全部为1,无可分配物理页,直接停机了。。。
.b2:
shl eax,12; * 4096即是页的物理地址
pop ds
pop edx
pop ecx
pop ebx
ret;段间返回
;----------------------------------------------------------
;分配一个页,并安装在对应线性地址的页结构上
;输入 ebx 页的线性地址
alloc_inst_a_page:
push eax
push ebx
push esi
push ds
mov eax,mem_0_4_gb_seg_sel
mov ds,eax
;先检查该线性地址所对应的页表是否存在(也就是页目录中对应项是否写入了页表的物理地址)
mov esi,ebx
and esi,0xffc00000;高10位
shr esi,20; 将原来的高10位作为新的低12位页内偏移,右移22 左移2 *4
or esi,0xfffff000;0xfffff000 对应的物理地址是页目录的物理地址
test dword [esi],0x00000001;P位是否为1,即页表是否存在(登记在页目录)
jnz .b1
;不存在 创建
call allocate_a_4k_page
or eax,0x00000007
mov [esi],eax;不存在就申请一个物理页,并将该页的起始物理地址登记在页目录对应项
;至此,输入的线性地址肯定有对应页表了,要申请页,将页的物理地址写
;到页表的对应项。
.b1:
mov esi,ebx
shr esi,10;
and esi,0x003ff000;至此 将输入的线性地址的高10位 转移到esi的中10位
or esi,0xffc00000;esi高10位全1
;至此 esi 是输入的线性地址对应页表的物理地址 所对应的线性地址
and ebx,0x003ff000
shr ebx,10
or esi,ebx;将原来的中10位 作为低12位偏移地址 左移12 右移2 *4
;至此 esi是输入线性地址对应页表项的物理地址 所对应的线性地址
call allocate_a_4k_page;分配一个物理页
or eax,0x00000007
mov [esi],eax;登记分配的物理页,即完成线性地址的页分配
pop ds
pop esi
pop ebx
pop eax
retf
;----------------------------------------------------------
;创建新页目录,并复制当前页目录的内容(内核先把自己页目录搞成用户任务
;需要的结构,然后把自己页目录复制一份,给程序当做页目录)
;新的页目录的所有目录项的值和旧目录一样,所以当切换任务后cr3指向
;新的页目录后,用原来的线性地址仍然可以访问到对应的物理地址
;输入:无
;输出 eax 新页目录的物理地址
create_copy_cur_pdir:
push ds
push es
push esi
push edi
push ebx
push ecx
mov ebx,mem_0_4_gb_seg_sel
mov ds,ebx
mov es,ebx
call allocate_a_4k_page
mov ebx,eax
or ebx,0x00000007
mov [0xfffffff8],ebx
;0xfffffff8对应的物理地址是页目录倒数第二项的物理地址
;将新的页目录暂时作为就页目录的一个页表,不然无法用线性地址访问到
;新的页目录,就没法复制了
mov esi,0xfffff000;ds:si->当前页目录的线性地址
mov edi,0xffffe000;es:edi->新页目录的线性地址
mov ecx,1024
cld
repe movsd;页表完成复制
pop ecx
pop ebx
pop edi
pop esi
pop es
pop ds
retf;段间返回
;----------------------------------------------------------
;终止当前任务,注意执行此例程时,仍是任务的全局空间,执行完毕
;后才是离开任务
terminate_current_task:
mov eax,core_data_seg_sel
mov ds,eax
pushfd
pop edx
test dx,0100_0000_0000_0000B;测试eflags的nt位
jnz .b1
jmp far [program_man_tss]
retf
.b1:
iretd
retf;作者给的代码没有retf,这里写上,是因为万一任务又被执行了,
;会恢复到这里,用retf才可以返回用户局部空间
sys_routine_end:
;系统核心的数据段
;==========================================================
SECTION core_data vstart=0
pgdt dw 0 ;用于设置和修改GDT
dd 0
page_bit_map db 0xff,0xff,0xff,0xff,0xff,0x55,0x55,0xff
db 0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff
db 0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff
db 0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff
db 0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55
db 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
db 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
db 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
page_map_len equ $-page_bit_map
;符号地址检索表
salt:
salt_1 db '@PrintString'
times 256-($-salt_1) db 0
dd put_string
dw sys_routine_seg_sel
salt_2 db '@ReadDiskData'
times 256-($-salt_2) db 0
dd read_hard_disk_0
dw sys_routine_seg_sel
salt_3 db '@PrintDwordAsHexString'
times 256-($-salt_3) db 0
dd put_hex_dword
dw sys_routine_seg_sel
salt_4 db '@TerminateProgram'
times 256-($-salt_4) db 0
dd terminate_current_task
dw sys_routine_seg_sel
salt_item_len equ $-salt_4
salt_items equ ($-salt)/salt_item_len
message_0 db ' Working in system core,protect mode.'
db 0x0d,0x0a,0
message_1 db ' Paging is enabled.System core is mapped to'
db ' address 0x80000000.',0x0d,0x0a,0
message_2 db 0x0d,0x0a
db ' System wide CALL-GATE mounted.',0x0d,0x0a,0
message_3 db '********No more pages********',0
message_4 db 0x0d,0x0a,' Task switching...@_@',0x0d,0x0a,0
message_5 db 0x0d,0x0a,' Processor HALT.',0
bin_hex db '0123456789ABCDEF'
;put_hex_dword子过程用的查找表
core_buf times 512 db 0 ;内核用的缓冲区
cpu_brnd0 db 0x0d,0x0a,' ',0
cpu_brand times 52 db 0
cpu_brnd1 db 0x0d,0x0a,0x0d,0x0a,0
;任务控制块链
tcb_chain dd 0
;内核信息
core_next_laddr dd 0x80100000 ;内核空间中下一个可分配的线性地址
program_man_tss dd 0 ;程序管理器的TSS描述符选择子
dw 0
core_data_end:
;内核代码段
;==========================================================
SECTION core_code vstart=0
;----------------------------------------------------------
;在ldt内安装描述符
;输入 edx:eax 描述符 ebx tcb线性基地址
;输出 cx 描述符选择子
fill_descriptor_in_ldt:
push eax
push edx
push edi
push ds
mov ecx,mem_0_4_gb_seg_sel
mov ds,ecx
mov edi,[ebx+0x0c];从tcb获得ldt基地址
xor ecx,ecx
mov cx,[ebx+0x0a];获得ldt界限
inc cx;大小
mov [edi+ecx+0x00],eax
mov [edi+ecx+0x04],edx
add cx,8
dec cx
mov [ebx+0x0a],cx;重新写入界限
mov ax,cx
xor dx,dx
mov cx,8
div cx
mov cx,ax;索引号
shl cx,3
or cx,0000_0000_0000_0100B
;ti位置1 表示在ldt中,rpl暂时为0
pop ds
pop edi
pop edx
pop eax
ret
;----------------------------------------------------------
;加载并重定位程序
;输入 push 逻辑扇区 push tcb基地址
;输出 无
load_relocate_program:
pushad
push ds
push es
mov ebp,esp;通过ebp访问栈内参数
mov ecx,mem_0_4_gb_seg_sel
mov es,ecx
;清空当前页目录前半部分(低2gb空间,暂时将系统任务地址空间的低
;2gb来加载程序,反正系统任务又不上,加载好后就可以把系统的页目
;录复制一份给,这样加载不同程序,便生成了不同的页目录,因为每次
;都将低2gb对应的页目录项清空,再利用alloc_inst_a_page重建了)
mov ebx,0xfffff000
xor esi,esi
.b1:
mov dword [es:ebx+esi*4],0x00000000;ebx对应的物理地址是页目录表
inc esi
cmp esi,512
jl .b1
;以下开始分配内存并加载用户程序
mov eax,core_data_seg_sel
mov ds,eax
mov eax,[ebp+12*4];起始扇区号
mov ebx,core_buf
call sys_routine_seg_sel:read_hard_disk_0
;判断程序大小
mov eax,[core_buf];大小
mov ebx,eax
and ebx,0xfffff000
add ebx,0x1000;4kb对齐
test eax,0x00000fff
cmovnz eax,ebx;eax不4kb对齐 就用对齐后的ebx
mov ecx,eax
shr ecx,12;ecx即对应的页数,大循环
mov eax,mem_0_4_gb_seg_sel
mov ds,eax
mov eax,[ebp+12*4];扇区号
mov esi,[ebp+11*4];tcb基地址
.b2:
mov ebx,[es:esi+0x06];tcb内记载的可分配线性空间的起始地址
add dword [es:esi+0x06],0x1000;分配4KB,所以新分配地址+0x1000
call sys_routine_seg_sel:alloc_inst_a_page;将该线性页和对应物理页对应
push ecx
mov ecx,8;一个页大小要8个扇区填满
.b3:
call sys_routine_seg_sel:read_hard_disk_0
inc eax
loop .b3
pop ecx;下一个4096字节
loop .b2
;在内核地址空间创建用户任务的TSS,因为内核要随时都能访问所有任务的TSS
;所以TSS必然要放在内核地址空间里
mov eax,core_data_seg_sel
mov ds,eax
mov ebx,[core_next_laddr];内核可分配的线性空间
call sys_routine_seg_sel:alloc_inst_a_page
add dword [core_next_laddr],0x1000;新的可分配的线性地址
mov [es:esi+0x14],ebx;tcb中登记tss线性地址
mov word [es:esi+0x12],103;登记界限
;在用户任务的局部地址空间创建LDT,因为ldt只有在切换成前台任务时,才会
;被内核访问,因此卸载局部地址空间也行。
mov ebx,[es:esi+0x06];从tcb中取得可分配的线性地址
add dword [es:esi+0x06],0x1000;新的可分配的
call sys_routine_seg_sel:alloc_inst_a_page
mov [es:esi+0x0c],ebx;tcb中登记ldt线性基地址
;建立程序代码段描述符
mov eax,0x00000000
mov ebx,0x000fffff
mov ecx,0x00c0f800;4Kb粒度,结合基地址和界限,0-4GB的平坦模型
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi;tcb基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0011B;RPL置3
mov ebx,[es:esi+0x14];tcb中获取tss
mov [es:ebx+76],cx;填写tss 的 cs域
;再不必往tcb中登记cs选择子
;程序数据段描述符
mov eax,0x00000000
mov ebx,0x000fffff
mov ecx,0x00c0f200 ;4KB粒度的数据段描述符,特权级3
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi ;TCB的基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0011B ;设置选择子的特权级为3
;由于平坦模型 所有段寄存器的选择子对应的描述符都能访问0-4GB
mov ebx,[es:esi+0x14] ;从TCB中获取TSS的线性地址
mov [es:ebx+84],cx ;填写TSS的DS域
mov [es:ebx+72],cx ;填写TSS的ES域
mov [es:ebx+88],cx ;填写TSS的FS域
mov [es:ebx+92],cx ;填写TSS的GS域
;将数据段作为用户任务的固有堆栈
mov ebx,[es:esi+0x06];从tcb中取得可分配线性地址
add dword [es:esi+0x06],0x1000;新可分配线性地址
call sys_routine_seg_sel:alloc_inst_a_page
mov ebx,[es:esi+0x14];tcb中获取tss基地址
mov [es:ebx+80],cx;填写ss域
mov edx,[es:esi+0x06]
mov [es:ebx+56],edx;将指针设置为堆栈的上限,刚好为可分配的新地址
;在用户任务局部空间创建0特权级堆栈
mov ebx,[es:esi+0x06]
add dword [es:esi+0x06],0x1000;分配线性地址
call sys_routine_seg_sel:alloc_inst_a_page
mov eax,0x00000000
mov ebx,0x000fffff
mov ecx,0x00c09200;4Kb粒度 向上扩展(普通数据段用作栈段) dpl0
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi;tcb基地址
call fill_descriptor_in_ldt
or cx,00000000_00000000B;rpl 0
mov ebx,[es:esi+0x14];tcb中获取tss基地址
mov [es:ebx+8],cx;填写tss中 ss0
mov edx,[es:esi+0x06]
mov [es:ebx+4],edx;tss 的esp0 (上界刚好是新可分配的线性地址)
;在用户任务的局部地址空间内创建1特权级堆栈
mov ebx,[es:esi+0x06] ;从TCB中取得可用的线性地址
add dword [es:esi+0x06],0x1000
call sys_routine_seg_sel:alloc_inst_a_page
mov eax,0x00000000
mov ebx,0x000fffff
mov ecx,0x00c0b200 ;4KB粒度的堆栈段描述符,特权级1
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi ;TCB的基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0001B ;设置选择子的特权级为1
mov ebx,[es:esi+0x14] ;从TCB中获取TSS的线性地址
mov [es:ebx+16],cx ;填写TSS的SS1域
mov edx,[es:esi+0x06] ;堆栈的高端线性地址
mov [es:ebx+12],edx ;填写TSS的ESP1域
;在用户任务的局部地址空间内创建2特权级堆栈
mov ebx,[es:esi+0x06] ;从TCB中取得可用的线性地址
add dword [es:esi+0x06],0x1000
call sys_routine_seg_sel:alloc_inst_a_page
mov eax,0x00000000
mov ebx,0x000fffff
mov ecx,0x00c0d200 ;4KB粒度的堆栈段描述符,特权级2
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi ;TCB的基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0010B ;设置选择子的特权级为2
mov ebx,[es:esi+0x14] ;从TCB中获取TSS的线性地址
mov [es:ebx+24],cx ;填写TSS的SS2域
mov edx,[es:esi+0x06] ;堆栈的高端线性地址
mov [es:ebx+20],edx ;填写TSS的ESP2域
;重定位salt
mov eax,mem_0_4_gb_seg_sel
mov es,eax
mov eax,core_data_seg_sel
mov ds,eax
cld;esi edi 正向增大
mov ecx,[es:0x0c];由于程序被从线性地址0处加载 所以0x0c处是
;u-salt条目数
mov edi,[es:0x08];u_salt的偏移地址(由于从0开始加载,偏移地址即是线性地址)
.b4:
push ecx
push edi
mov ecx,salt_items;内循环 常量
mov esi,salt
.b5:
push edi
push esi
push ecx
mov ecx,64
repe cmpsd;一次比4字节比64次
jnz .b6
mov eax,[esi];如果匹配,那么esi 恰好是c_salt对应条目的偏移地址
mov [es:edi-256],eax;
mov ax,[esi+4];选择子
or ax,0000_0000_0000_0011B;RPL置3
mov [es:edi-252],ax;回写调用门选择子 到 u_salt对应项
.b6:
pop ecx
pop esi
add esi,salt_item_len;下一个c_salt
pop edi;u_salt 也要回到对应条目偏移0处重新比较
loop .b5
pop edi
add edi,256;下一个u_salt条目
pop ecx
loop .b4
;在gdt中登记ldt
mov esi,[ebp+11*4];tcb基地址
mov eax,[es:esi+0x0c];ldt起始地址
movzx ebx,word [es:esi+0x0a];段界限
mov ecx,0x00408200;ldt描述符属性 dpl 0
call sys_routine_seg_sel:make_seg_descriptor
call sys_routine_seg_sel:set_up_gdt_descriptor
mov [es:esi+0x10],cx;将ldt选择子登记到tcb
mov ebx,[es:esi+0x14];tcb中获取tss线性基地址
mov [es:ebx+96],cx;tss填上ldt选择子
mov word [es:ebx+0],0;反向链(上一级任务tss选择子)位0
mov dx,[es:esi+0x12];tss段长度(界限)
mov [es:ebx+102],dx
mov word [es:ebx+100],0;T=0 不开启任务调试
mov eax,[es:0x04];从任务的4GB地址空间获取入口地址
mov [es:ebx+32],eax;填写tss的eip
pushfd
pop edx
mov [es:ebx+36],edx;填写tss的eflags
;在gdt中登记tss
mov eax,[es:esi+0x14];tcb中获取tss线性基地址
movzx ebx,word [es:esi+0x12];段界限
mov ecx,0x00408900;tss描述符
call sys_routine_seg_sel:make_seg_descriptor
call sys_routine_seg_sel:set_up_gdt_descriptor
mov [es:esi+0x18],cx;tcb中登记tss选择子
;创建用户任务页目录
call sys_routine_seg_sel:create_copy_cur_pdir
mov ebx,[es:esi+0x14];tcb中获取tss线性基地址
mov dword [es:ebx+28],eax;填写tss的cr3(page director base
;address register)
pop es
pop ds
popad
ret 8;跳过栈中参数
;----------------------------------------------------------
;在tcb链上追加tcb (现阶段没luan用 下一章抢占式任务切换才有用)
;输入 ecx=tcb线性基地址
append_to_tcb_link:
push eax
push edx
push ds
push es
mov eax,core_data_seg_sel
mov ds,eax
mov eax,mem_0_4_gb_seg_sel
mov es,eax
mov dword [es:ecx+0x00],0;当前tcb指针域清0(因为是链上最后一个)
mov eax,[tcb_chain]
or eax,eax;为0为空?
jz .notcb
.search:
mov edx,eax
mov eax,[es:edx+0x00];访问指针域
or eax,eax
jnz .search;如果有下一级tcb的话 去访问
mov [es:edx+0x00],ecx;没有的话就把当前tcb的地址写上
jmp .retpc
.notcb:
mov [tcb_chain],ecx;如空,则直接写在tcb_chain对应空间
.retpc:
pop es
pop ds
pop edx
pop eax
ret
;----------------------------------------------------------
;内核代码入口
;无输入 无输出
start:
mov ecx,core_data_seg_sel
mov ds,ecx
mov ecx,mem_0_4_gb_seg_sel
mov es,ecx
mov ebx,message_0
call sys_routine_seg_sel:put_string
;显示处理器信息,代码被省略了,不写了
;准备打开分页机制
;创建系统内核页目录表pdt 并清0
mov ecx,1024
mov ebx,0x00020000;页目录物理地址
xor esi,esi
.b1:
mov dword [es:ebx+esi],0x00000000;清零
add esi,4
loop .b1
;页目录最后一项位向自己,值为页目录自身的物理地址
;不这么做不方便拿到页目录的线性地址来访问它。
mov dword [es:ebx+4092],0x00020003
;在页目录内创建低1MB(0x00000000开始的)线性地址对应的目录项
mov dword [es:ebx+0],0x00021003;高20位才是该目录项对应页表的物理地址
;低12位 是属性
;创建该目录项的页表,并初始化页表项
mov ebx,0x00021000
xor eax,eax
xor esi,esi
.b2:
mov edx,eax
or edx,0x00000003
mov [es:ebx+esi*4],edx;登记页表里每一个项对应页的物理地址
add eax,0x1000
inc esi
cmp esi,256;(仅仅先登记低1MB线性地址对应的物理地址)
jl .b2
.b3:
mov dword [es:ebx+esi*4],0x00000000
inc esi
cmp esi,1024
jl .b3
;设置cr3寄存器 ,并开启页功能
mov eax,0x00020000 ;pcd pwt =0
mov cr3,eax
mov eax,cr0
or eax,0x80000000
mov cr0,eax;最高位pg置1 开启分页机制,从此再也不能直接用物理地址了
;在页目录内创建线性地址0x80000000对应的目录项
mov ebx,0xfffff000;0xfffff000是页目录的线性地址
mov esi,0x80000000;
shr esi,22
shl esi,2;右移22位将高10位当做最低12位,再*4作页内偏移
mov dword [es:ebx+esi],0x00021003;写入目录项(页表物理地址和属性)
;将gdt中段描述符 的线性基地址 都迁移到0x80000000 上(高2gb)
sgdt [pgdt]
mov ebx,[pgdt+2]
or dword [es:ebx+0x10+4],0x80000000
or dword [es:ebx+0x18+4],0x80000000
or dword [es:ebx+0x20+4],0x80000000
or dword [es:ebx+0x28+4],0x80000000
or dword [es:ebx+0x30+4],0x80000000
or dword [es:ebx+0x38+4],0x80000000
;除了0_4_gb这个段描述符的线性基地址没改,因为它是用来从0-4gb的段
;可以表示线性空间
add dword [pgdt+2],0x80000000;gdtr也要用迁移后的线性地址
lgdt [pgdt]
jmp core_code_seg_sel:flush ;刷新段寄存器(因为选择子对应的描述符
;的线性基地址改了)
flush:
mov eax,core_stack_seg_sel
mov ss,eax
mov eax,core_data_seg_sel
mov ds,eax
mov ebx,message_1
call sys_routine_seg_sel:put_string
;以下开始安装调用门,特权级之间的转移必须用门
mov edi,salt
mov ecx,salt_items
.b4:
push ecx
mov eax,[edi+256];
mov bx,[edi+260]
mov cx,1_11_0_1100_000_00000B;属性
call sys_routine_seg_sel:make_gate_descriptor
call sys_routine_seg_sel:set_up_gdt_descriptor
mov [edi+260],cx;回写门描述符
add edi,salt_item_len
pop ecx
loop .b4
;测试门
mov ebx,message_2
call far [salt_1+256]
;为程序管理器prgman(内核任务)TSS分配内存空间
mov ebx,[core_next_laddr]
call sys_routine_seg_sel:alloc_inst_a_page
add dword [core_next_laddr],0x1000;新的可分配的线性地址
;填写tss必要项目
mov word [es:ebx+0],0;反向链0
mov eax,cr3
mov dword [es:ebx+28],eax;登记cr3
mov word [es:ebx+96],0;没有ldt
mov word [es:ebx+100],0;关闭任务调试 T=0
mov word [es:ebx+102],103;不使用I/O位图
;创建prgman TSS 描述符 并安装到gdt
mov eax,ebx;tss线性基地址
mov ebx,103;界限
mov ecx,0x00408900;tss 描述符 特权级0
call sys_routine_seg_sel:make_seg_descriptor
call sys_routine_seg_sel:set_up_gdt_descriptor
mov [program_man_tss+4],cx;将prgman tss选择子保存在内核数据段
;tr是任务进行的标志,我们的prgman实际执行了,但是不算任务
ltr cx
;现在处理器认为 prgman任务执行了
;在内核空间 创建用户任务的tcb,这章的tcb较简洁
mov ebx,[core_next_laddr]
call sys_routine_seg_sel:alloc_inst_a_page
add dword [core_next_laddr],0x1000;新的可分配的线性地址
mov dword [es:ebx+0x06],0;用户任务局部地址空间的起始线性地址
mov word [es:ebx+0x0a],0xffff;登记ldt初始界限(0-1=0xffff)
mov ecx,ebx
call append_to_tcb_link;tcb添加到tcb链
push dword 50
push ecx;tcb基地址
call load_relocate_program
mov ebx,message_4
call sys_routine_seg_sel:put_string
call far [es:ecx+0x14]; tcb内偏移0x14是tss选择子
mov ebx,message_5
call sys_routine_seg_sel:put_string
hlt
core_code_end:
;===================================
SECTION core_trail
core_end:;注意之所以单独搞个core_trail段 是为了让core_end 长度为
;真正的core代码的长度,不然因为core_code段里有vstart=0,放在
;core_code段,core_end的偏移地址就只能表示core_code段的长度了
用户程序代码:
program_length dd program_end
entry_point dd start
salt_position dd salt_begin
salt_items dd (salt_end-salt_begin)/256;salt条目数
salt_begin:
PrintString db '@PrintString'
times 256-($-PrintString) db 0
TerminateProgram db '@TerminateProgram'
times 256-($-TerminateProgram) db 0
reserved times 256*500 db 0;保留一个空白区,演示分页
ReadDiskData db '@ReadDiskData'
times 256-($-ReadDiskData) db 0
PrintDwordAsHex db '@PrintDwordAsHex'
times 256-($-PrintDwordAsHex) db 0
salt_end:
message_0 db 0x0d,0x0a,
db ' .....User task is running with'
db 'paging enabled.....',0x0d,0x0a,0
space db 0x20,0x20,0
[bits 32]
start:
mov ebx,message_0
call far [PrintString]
xor esi,esi
mov ecx,88
.b1:
mov ebx,space
call far [PrintString]
mov edx,[esi*4]
call far [PrintDwordAsHex]
inc esi
loop .b1
call far [TerminateProgram]
program_end:
上述代码编译通过,运行正确
0x01 代码详解
操作系统负责对内存进行分配和回收,一段时间后,内存空间里可用部分会变得很分散,没办法拿出一块足够大小的连续空间来装入新的程序,这让内存空间的利用效率很低。
为此,处理器引入了分页机制,将物理内存按照最小单位-页(连续的4096KB),进行分配,并且允许不连续的页映射成连续的线性空间。而这个复杂的映射过程是由处理器自动完成的,操作系统只需要指定一张映射表即可。
分页机制概述
之前一直是内存的分段管理,处理器的段部件将段地址+偏移量,得到一个地址,这个地址我们成为线性地址,在不开启分页模式时,这个线性地址就是最终访问的物理地址。
而开启分页机制后,处理器将物理内存按照4KB一个页的方式,均匀的分成很多页(具体页数量由实际内存大小决定,如果是4GB则是2^20个页),第一个页的起始物理地址是0x00000000,结束地址是0x00000fff;第二个页起始物理地址是0x00001000,结束地址是0x00001fff;以此类推。
对于页来说,其地址指的是页的起始地址,故其低12位始终为0。分段机制一旦进入保护模式就无法关闭,因此,启用了页机制时,分段机制也在工作。也就是说,程序仍然需要用段地址+偏移地址的方式访问内存,仍然有段的保护机制。一个大的段,占用的线性空间可以远远大于4KB,因此,操作系统将段的线性空间映射到多个物理页,段的线性空间仍然是连续的,但是物理页之前可以不必连续。
分配物理页时,操作系统会搜索空闲的页,并分配个程序使用,而分配的页的总长度会大于等于段长度(可以多给但是不能少给)
下图举例了一个段如何映射物理页,段的大小为8200字节,但是分配了3个页。
段部件可以产生4GB的线性地址,因此我们说有4GB虚拟内存空间,说它是虚拟的,是因为线性地址必须要对应物理页才可以使用,不然无法读写。段部件输出的是虚拟地址或者线性地址,为了根据线性地址找到物理地址,操作系统必须维护一张把线性地址转换成物理地址的表。
由于每个4KB的线性空间要映射到一个物理页,所以一共有2^20个待映射项,或者说转换表有2^20项,每项保存着分配到的页的物理地址,大小为4字节。这个转换表的用法是:把线性地址的高20位当做索引,索引 * 4字节后作为偏移,去表中拿到对应项,读取其中的物理地址,然后+线性地址低12位作为偏移(因为一个页4KB,这个偏移是页内偏移),从而拿到对应的线性地址的真正物理地址。
例如,指令 mov edx,[0x2002],如果ds里的段地址是0x00200000,那么段部件给出的线性地址是0x00202002,而线性地址的高20为是0x00202,将其作为索引 * 4得到转换表内的偏移0x00808,从表中(如上图)拿到一个双字,0x00007000,即是分配的页的物理地址,而线性地址的低12位是页内偏移地址,用物理页地址加上页内偏移,最终物理地址0x00007002。
为什么表内偏移量为0x00808的地方,会恰好是页地址0x00007000,而不是其他的页地址呢?
程序加载时,操作系统会首先在虚拟内存空间中分配段,然后根据段需要分配多少页,来搜索空闲物理页(可以应该有个东西来记录物理页的分配情况),当段较大,需要按照页的尺寸分成多个地址区段时,操作系统用每个区段的首地址,取高20位,* 4 ,作为偏移量访问表格,并将分配给该区段的页的物理地址写入对应表项。最终,对该段的读写都读写到对应页中。
由于线性地址到物理地址(页)的映射是任意的,那么线性地址/虚拟地址空间,与页的分配完全无关。因此,为了充分挖掘分页内存管理的威力,每个任务都可以拥有4GB的虚拟内存空间(每个任务都有对应的映射表,这样在不同人物中对同一个线性地址的访问,被映射到不同的物理页,数据也不干扰)。但是物理页是统一调配的,因为最终访问的都是物理地址。
例如A任务有个段 基地址0x00050000,段长度3000字节,操作系统给其分配了一个物理地址为0x08001000的页,一会儿后,另一个任务B加载了,它的一个段,基地址也是0x00050000,段长度4096,此时操作系统给其分配另一个不同的、物理地址为0x00700000的页。这种情况下A任务访问线性地址0x00050006时,访问的是物理地址0x08001006;而在任务B内访问相同的线性地址,则访问的是物理地址0x00700006。
另一个问题是,每个任务都有4GB虚拟内存空间,而物理内存空间最大只有4GB,根本不够分? 首先,加载一个任务时,只根据任务对线性空间的使用情况,来给线性地址分配页,另外,如果物理页真的不够分了,会检查所有任务的页转换表,将使用得较少的页里的数据写到磁盘,并在该任务(假设是A)的转换表上做记录,这样就空闲了一个页,分配给B使用。如果下次任务A访问到了该页,处理器发现了该记录,就又把这个分配出去的页按照前面的方式(将分配给B的数据写入磁盘,并记录在B的转换表上)空闲回来给A,并从磁盘恢复数据。
页目录、页表和页
分页机制已经发展了10多年了,最新的分页机制已经变得十分复杂,本章将介绍最经典的,兼容性最强的分页机制。
为了完成从虚拟地址/线性地址到物理地址的转换,操作系统应当为每个任务正准备一张页映射表,线性地址为0-4GB,每个页4KB,因此映射表需要2^20项,没项保存一个页物理地址,4字节,每个任务的映射表大小为2^22字节,4MB,相对于80386时期的物理内存的主流大小,4MB实在太大了。
由于某些原因(这个原因就是低2GB被作为任务的局部空间、高2GB用做全局空间),线性地址的最高部分和最低部分都会被用到,所以根据实际使用动态扩展这个映射表也是比较困难的。
因此,处理器设计了层次化的分页结构,并不是用单一的映射表,而是页目录和页表,如下图:
因为4GB虚拟内存对应2^20个页表,可以随机地抽取这些页,将它们组织在1024个页表呢,每个页表可以容纳1024个页的物理地址。页表内的每个项目叫做页表项,占4字节,存放的是页的物理地址,所以每个页表的大小是4KB,正好是一个标准页的长度。
将2^20个页表归拢到1024个页表后,再用一个表来登记/指向这1024个页表,这就是页目录(Page Directory Table,PDT),和页表一样,页目录项的长度是4字节,填写的是页表的物理地址,指向1024个页表,页目录的大小是4KB,也是一个标准页的长度。
这种层次化分页结构的页目录和页表是每个任务都拥有的。在处理器内部有cr3寄存器,存放着当前任务页目录的物理地址,故又叫做页目录基址寄存器(Page Directory Base Register,PDBR)。
每个人物都有自己的TSS(Task Status Segement),它是任务的标志性结构,存放了和任务有关的各种数据,其中就包括cr3,存放任务自己的页目录物理地址。当任务切换时,处理器切换到新任务开始执行,相应的CR3寄存器的内容也会被更新,以指向新任务的页目录位置。相应的,页目录又指向页表,使得每个人物都有自己的4GB线性/虚拟地址空间。
页目录和页表也是4KB,最终也要写到物理页中,他们和普通页只是作用不同。当任务撤销后,它们和任务所占用的普通页一样会被回收。
通过这样的页目录、页表层次结构,对于没有使用得线性空间,就不必为其创建页表,也就是对应的页目录项为空,从而节省空间,等到需要使用时,再创建对应的页表,并登记在对应的页目录项里
地址变换的具体过程
对于上述层次分页机制最简单的描述是,CR3寄存器给出页目录的物理基地址;页目录又给出了页表的物理地址;每个页表给出了它所包含页的物理地址。
具体的地址变换过程如下:
对于某任务加载后,操作系统根据任务的需要,在其4GB线性空间内创建了一个段,段起始地址0x00800000,段界限值0x5000,粒度为字节,当任务执行时,段寄存器ds指向该段,那么对于指令:mov edx,[0x1050]
段部件输出的线性地址是0x00801050,如果没有开启分页机制,该地址同时也是最终的物理地址。现在开启了分页机制,仍需页部件转换后,才能得到物理地址。
页部件将线性地址分成3段,高10位,中间10位,低12位。高10位是页目录的索引,中10位是对应页表的索引,低12位是页内偏移。
当前任务页目录的物理地址在CR3中,假设内容为0x00005000。那么0x00801050,高10位是0000000010,也就是0x02,是页目录内的索引,处理器将其* 4,作为偏移量访问页目录,处理器从物理地址0x00005008处取得页表的物理地址0x08001000。
而线性地址的中间10位为0000000001,即0x01,是页表内的索引,处理器将其 * 4,作为偏移量访问页表,处理器从物理地址0x08001004处取得页的物理地址0x0000c000。
页的物理地址是0x0000c000,而线性地址低12位是0x50,所以将其作为页内偏移相加,最终得到物理地址是0x0000c050,这就是线性地址0x00801050所对应的物理地址,要访问的数据就在这儿。
这种转换时由处理器的页部件自动完成的,但是转换的结果却是操作系统事先安排好的。当任务加载时,处理器先创建虚拟的段,并根据段地址的高20位分析它要用到哪些页目录项和页表项。然后寻找空闲的物理页,将原本应该写到段中的数据写到一个或者多个页中,并将页的物理地址填写到任务相应的页表项中,只有这样做了,当程序运行时,才能以段+偏移地址的线性地址,找到正确的数据。
注意,这句话里,操作系统加载任务时,先按照自己的页目录,页表,或者说先按照自己的线性地址空间,去访问某个物理空间(这个物理空间的地址操作系统自己知道),先将程序段内的数据写到物理空间,然后根据这个物理地址,和程序里提供的线性地址,来构造程序任务的页目录项和页表项。比如,操作系统在加载任务程序时,有一个段线性基地址0x00005000,这个线性地址是在任务自己的线性地址空间里的安排,对于操作系统,可以任意提供一个线性地址将其写入到物理空间(只要操作系统知道自己最终写入的物理地址即可),然后根据任务内的段线性地址,推算出应该在任务页目录的哪一个项和对应页表里的哪一个项,然后算出这个项的物理地址,再算出对应的操作系统线性空间内对应的线性地址,去访问,修改这个项,将其指向前面写入数据的页物理地址。这样就构造了任务的线性地址空间了。
检测题
线性地址0x0c005032 高10位 0000_1100_00,即0x30,中间10位 00_0000_1001,即0x05,低12位0x32,所以将该线性地址转换成物理地址时,应该是访问了页目录0x30项,对应页目录的偏移地址为0xc0,并且该项内容为0x00003000,是项表的物理地址。然后再访问页表的0x05项,也就是页表内偏移地址0x14处,这里的内容是0x0000a000,是页的物理地址,最后加上页内偏移0x032,即使最终物理地址。
使内核在分页机制下工作
创建内核的页目录表和页表:
本章的代码没有提供引导代码,也就是说我们的内核总体结构和内存布局和前几章类似,如下图:
需要注意的是,分页机制同样只能在保护模式下开启。内核入口仍然在core__code段的start标号处。同样先是显示处理器信息。接下来就是开启分页机制。
开启分页机制后,就只能通过线性地址访问物理地址了,对于未开启分页机制之前的代码,其线性地址=物理地址,为了开启后仍然能够正常访问,所以我们即使开启分页机制后,也要让原来的线性地址访问到原来的物理地址,因此对于已经使用的线性地址,务必要在页目录和页表里使其对应的物理地址和其相同。
由于我们的内核非常小,从上图可以看出,只占用了最低端的1MB,因此一个页表对应1024个页,一共1024 * 4096 字节,也就是4MB,因此 只需要将页目录的最低一项对应的页表的低256项(0-255)改成和线性地址相同即可。同时我们把页目录放在物理地址0x00020000处,其占0x1000字节空间,并将页目录最低一项的对应的页表放在物理地址0x00021000处,那么最终的物理内存空间布局如下:
所以我们先将0x00020000为起点后的1024 * 4字节的内存空间全部清零,也就是页目录先全部清零,然后将0x00020000+4092修改为0x0002003,这句是将页目录的最后一项指向自己的物理地址,也就是页目录将自己作为一个页表,并将页目录的最后一项指向自己,目的是为了开启分页后,能够用线性地址访问页目录自己。然后是0x00020000+0修改为0x00021003,是将页目录的最后一项指向我们建立的页表。为什么写入的物理地址以3结尾,实际上,写入页目录项和页表项的,只有高20位是物理地址的高20位,低12位是相关属性的设置(因为分页后,低12位都是页内偏移,所以只用高20位就能表示所有页的物理地址),如下图:
P(present)是存在位,为1时,表示页表或者页 存在物理内存中,否则不存在,必须先予以创建,或者从磁盘调用内存方可使用。
RW(read/write)是读/写位,为0是只能读,为1时可读可写。
US(user/supervisor)是用户/管理 位,为0时只允许特权级别为0、1、2的程序访问,为1时,允许所有特权级的程序访问。
PWT和PCD是和缓存有关,本章置0,暂时不用理解。
A(accessed)是访问位,由处理器固件置1,可被操作系统周期性监视置0,用于统计页的使用情况。当内存空间紧张时,用于将较少使用的页换出磁盘,同时将其P位置0,然后将释放的页分配给马上要运行的程序,实现虚拟内存
D(dirty)位,由处理器置1,用于指示对应的页是否被写过数据。
PAT(page attribute table)用于更复杂的分页机制,本章置0,不用理解。
G(global)位,用于表示该表项所指向的页是否为全局的页,如果是,那么将在高速缓存中一直存在,本章置0,暂时不用深入了解。
AVL位无用途,由操作系统自己定义。
所以前面页目录里填写的数,最后低12位是3,也就是表示存在内存里,可读可写,且只允许特权级0、1、2的任务访问
同时由于将页目录的最后一项指向页目录自己,因此对于内核来说,线性空间的最高端4MB无法访问(因为对应的少了一个页表1024个页,每页4KB)
线性地址的最低1MB空间为0x0000000-0x000fffff,因此对应的目录项是最低项,并且对应该项页表里的低256(0-255)项写入从0x0000000-0x000ff000即可,而页表的256-1023项则写入0。
至此,我们的页目录和页表都已经准备好了,然后令cr3指向我们的页目录,再修改cr0的最高位来开启分页机制(最低位用来开启保护模式)
如果PE位为0,置1PG位处理器会产生异常,也就是不能在实模式开启分页机制。可以在bochs里使用creg指令查看控制寄存器
现在处理器已经工作在分页机制下,例如GDTR里记录的是GDT的线性基地址0x00007e00,第二个描述符的线性地址是0x00007e08,如果访问这个线性地址,处理器将其送入页部件,高10位取出0x00,用来作为索引 * 4后是页目录内的偏移,处理器从cr3处读取页目录的物理地址0x00020000,加上偏移0x00,拿到页表的物理地址0x00021000(注意不是0x00021003,低12位是属性);中10位是0x07,作为索引访问页表,*4是页表内偏移 0x1c,拿到的页的物理地址是0x00007000;最低12位0xe08是页内偏移,因此最终访问的物理地址还是0x00007e08。
任务的全局空间和局部空间的页面映射:
开启分页机制后,每个任务都有自己独立的4GB虚拟地址空间,那么任务调用内核例程呢?如果任务的页目录表和页表只包含任务自己的物理页的话,那么其永远无法访问内核数据,永远无法进入特权级0的全局地址空间执行了,因为任务的页目录表和页表里没有登记内核所占用的那些物理页面。
我们之前说过,任务的4GB地址空间包括两个部分,局部空间和全局空间,全局空间是所有任务共用的。明显,内核就是所有任务共用的,它应当属于每个任务的全局空间,一般来说,全局空间占据着任务4GB地址空间的高2GB,即线性地址0x80000000-0xffffffff,地址空间的分配要体现在每个人物的页目录中,页目录的前半部分指向任务自己的页表;后半部分则指向内核的页表。否则的话,当转到内核执行时,无法完成地址转换,因为找不到对应的页目录项和页表项。
在任何任务的任何时候,如果段部件发出的线性地址高于0x8000000,则访问的是全局地址空间/内核。
为此,即使我们的任务是内核任务,也要讲全局空间挪到0x80000000开始的一段连续空间,也就是我们要修改页目录表,使得0x800000000开始的1MB线性地址空间映射到物理地址0x00000000-0x000fffff,而我们原来的线性空间到物理空间0x00000000-0x000fffff到0x00000000-0x000fffff的映射仍然有效。
为了实现这个新的映射,我们只需要修改页目录表中对应0x800000000的项,也就是1000_0000_00, 0x200项,将该项指向在0x00021000处的页表即可,而不是新建一个页表,然后再写上同样的页表项,再将新建的页表的物理地址写到页目录的0x200项。这也是分层结构的好处,可以实现页目录表的多个项对应一个页表,
为了修改页目录表PDT,必须要知道PDT的物理地址,然后加上0x200项的偏移,就可以知道要修改的物理地址了。我们知道pdt物理地址是0x00020000,0x200项的偏移是0x800,要访问的物理地址是0x00020800,然而,我们现在在分页机制下,不能直接访问这个物理地址,需要给出其线性地址。我们可以把页目录看做一个普通页,那么他在页表是最后一项(因为页目录自己指向自己,所以永远可以吧一个页目录看做是自己的页表或者看做是自己的页目录),而且他所在的页表又是对应页目录的最后一项,那么页目录的物理地址,对应的线性地址高10位全1,中间10位全1,是0xfffff000,而该项页内偏移是0x800,所以最终我们要修该的PDT的项的线性地址是0xfffff800,将其赋值为0x00021000,就完成了新的映射。
现在我们已经将0x80000000-0x800fffff线性地址空间映射到了物理空间0x00000000-0x000fffff了,但是我们的段描述符里的基地址和GDTR里有关GDT的基地址都用的是原来的老映射,我们需要用新的线性地址访问,因此我们要修改GDTR里的线性基地址和段描述符里的线性基地址,都给他们加上0x80000000。并用lgdt重新装载。
注意0_4_gb这个段没有修改,因为他对应的本就是4GB线性地址,其线性基地址恒为0x00000000
我们修改时,各个段寄存器里的高速缓存部分仍然保存着原来的段描述符的线性基地址,因此仍然可以正常工作(原映射还在),但是我们要将他们刷新成新的,所以我们使用了jmp+代码段选择子:偏移地址,来刷新cs段寄存器:
jmp core_code_seg_sel:flushflush:
mov eax,core_stack_seg_sel
mov ss,eax
mov eax,core_data_seg_sel
mov ds,eax
这样就刷新了cs段寄存器里的高速缓存部分里的,然后重新给ss ds赋值,写入新的描述符缓存。
接下来是安装并测试调用门描述符,方法和前两章一样,不再多说。
创建内核任务
内核的虚拟内存分配:
接下来的工作是要让内核的一部分成为内核任务,并为创建用户任务和实施任务切换做准备。所以要创建内核任务的TSS,内核的主体部分占据线性空间的0x8000000-0x800fffff的1MB空间,从0x80100000-0xffffffff是内核自由分配的空间,为了连续、动态的分配内核的空间,在内核数据段声明了标号core_next_laddr来指示一个双字0x80100000,是内核初始可以分配的线性地址,每次分配新的线性空间后,该标号将更新为下一个可分配的线性地址。
所以每次需要分配线性空间时,可以从core_next_laddr读一个双字,作为线性空间的起始地址,然后确定需要分配的空间的大小后,将该大小+读的双字作为新的可分配的线性空间首地址写回core_next_laddr处。(注意要使用0x00000000为段基地址的段超越前缀)。
在分页急之下,内存的分配既要在虚拟内存空间中进行,还要在页目录表和页表中进行,因为线性地址最终要通过页目录表和页表转换成物理地址,如果没有分配物理页,那么对内存的访问是无效的,会引发处理器异常中断。
所以代码在获取内核任务的TSS的4KB线性空间后,还需要给该4KB线性空间分配一个页。为什么是4KB,页的大小是4KB,动态分配最低分配一个页(当然如果实现了页共用那又是另一回事)调用过程alloc_inst_a__page申请一个物理页。
;检查该线性地址所对应的页表是否存在
mov esi,ebx
and esi,0xffc00000
shr esi,20 ;得到页目录索引,并乘以4
or esi,0xfffff000 ;页目录自身的线性地址+表内偏移
test dword [esi],0x00000001 ;P位是否为“1”。检查该线性地址是
jnz .b1 ;否已经有对应的页表
;创建该线性地址所对应的页表
call allocate_a_4k_page ;分配一个页做为页表
or eax,0x00000007
mov [esi],eax ;在页目录中登记该页表
.b1:
;开始访问该线性地址所对应的页表
mov esi,ebx
shr esi,10
and esi,0x003ff000 ;或者0xfffff000,因高10位是零
or esi,0xffc00000 ;得到该页表的线性地址
;得到该线性地址在页表内的对应条目(页表项)
and ebx,0x003ff000
shr ebx,10 ;相当于右移12位,再乘以4
or esi,ebx ;页表项的线性地址
call allocate_a_4k_page ;分配一个页,这才是要安装的页
or eax,0x00000007
mov [esi],eax
alloc_inst_a_page例程的关键代码如上,首先要检查要分配的线性地址在页目录表里有没有对应的表项,也就是有没有对应的页表。为什么回出现没有页表的情况呢?因为开启分页机制,只需要有一个页目录表就行了,然后对所有要访问线性地址,在页目录中表中给它们创建页表,并在对应项填写分配的物理页的地址。所以刚分配的线性地址很可能在页目录表中找不到对应的项(高10位作为索引时找不到),那我们就得访问页目录表中高10位作为索引时对应的项,看该项对应的双字,最低位P为是否为1,为0就不存在。
所以要访问该项,我们需要知道访问该项的线性地址,之前说过,访问页目录表自身的线性地址是0xfffff000,而该项的索引 * 4 即是在页目录表内的偏移地址。因此,我们将传入的ebx的副本esi最高10位提取出来 * 4后取出,放到最低12位,然后 or 0xfffff000,即得到对应的页目录项的线性地址,用test 该地址 和 0x00000001,即可判断该项是不是存在。
如果不存就要创建对应页表,并将页表的物理地址写入该项。页表本身大小刚好对应一个物理页,因此,创建一个页表实际是分配一个物理页。所以我们调用了allocate_a_4k_page例程:
mov eax,core_data_seg_sel
mov ds,eax
xor eax,eax
.b1:
bts [page_bit_map],eax
jnc .b2
inc eax
cmp eax,page_map_len*8
jl .b1
mov ebx,message_3
call sys_routine_seg_sel:put_string
hlt ;没有可以分配的页,停机
.b2:
shl eax,12 ;乘以4096(0x1000)
我们分配线性地址空间,只需要记录可以分配线性地址空间的首地址,因为线性地址空间的分配必然是连续的一整块。而物理页则不同,物理页的分配可以是不连续的,所以我们要记录每一个物理页的分配情况。如果有4GB的物理内存,那么有2^20个页,如果花一个字节来记录每个页,那就需要1MB空间来记录,开销太大,由于页的使用与否只有两种情况,所以我们选择用一个位数为2^20的比特串来记录,第N位的0/1表示第N个页空闲或者占用,这样仅需128K即可。这个比特串称为页映射位串。
实际上,这个页映射位串由操作系统维护,操作系统在初始阶段会检测物理内存大小,然后确定页映射位串的长度,并将已经占用的页对应的比特位置1。
对于我们本章的代码,并没有检测物理内存,而是直接假定是2MB物理内存,所以一共512个页,512位比特位,需要64字节来表示,我们也在core_data段定义了一个标号page_bit_map,对应64字节,我们可以发现前32字节几乎都是0xff,因为前32字节对应的低256个页,已经被使用,被映射到了线性空间。(实际上page_bit_map页映射串里有一部分是0x55,对应着01010101,只是为了表示页的分配可以是不连续的,但是我们之前实际的分配是将低256个页连续分配的,作者想这么写,他说那几个虽然已经被分配给内核1MB空间,但比特位置0的页,不会被内核使用,再分配出去也没关系,他开心就好:)
已经定义了页映射位串了,那怎么从中找到空闲页呢?bts [page_bit_map],eax,指令会将page_bit_map处开始的位串的第eax位传送给CF标志位,并将位串的第eax位置1。如果第eax是0,说明对应页空闲,我们将0传给CF标志位,通过CF标志位有关的跳转,我们就知道该对应页是空闲的,将eax * 4096即使对应页的物理地址,分配出去,而且bts指令最后还会将第eax位置1,也符合被分配的情况。所以我们的代码用中,用循环从0开始不断增加eax的值,然后用jnc 来判断bts后cf是不是0,是0就可以跳出循环,eax的值 * 4096即是对应页的物理地址,并且位串里的该位置也被bts指令置1;是1那么位串里的该位置再被置1也不影响,继续循环下一位。
如果eax一直循环到512还没有跳出循环,说明所有物理页全部被分配出去了,没有办法再分配页了,本章中我们用一个简单粗暴的方式hlt 停机指令 来处理这种情况。
至此我们已经分配了一个物理页,并将其物理地址放在eax里返回到调用处了,继续回到alloc_inst_a_page例程,我们刚刚是没有对应页表,现在已经分配了一个页作为页表了,就将该页的高20位和低12位属性合成4个字写入我们算出的页目录项的线性地址esi。所以我们先把 or eax,0x00000007,表明US位为1,所有特权级的程序都能访问该页表(因为该页被作为页表填入页目录项);RW位为1,该页表可读可写;P位为1,存在于内存中。然后mov [esi],eax 即完成了新页表的创建和安装。
至此,对应ebx线性地址的页表页有了,再就是给该线性地址对应的页表项分配一个页,并填写页的物理地址高20位和低12位属性了。同样的,我们要修改该页表项,也需要知道其线性地址。
该页表本身可以看做一个页,那么该页表的页表是页目录,在页目录里的偏移是ebx的高10位;而页目录的页目录是页目录自己,并且在页目录里的偏移是最后一项0x3ff。
也就是说该页表的线性地址高10位是0x3ff,中10位是ebx的高10位,因此我们用ebx的副本esi 将其右移10位 并and 0x003ff000将其高10位移动中10位,其余位置0,并再or 0xffc0000,也就是将0x3ff放在高10位,至此得到ebx对应的页表的线性地址,而其在页表中对应的项的偏移地址,是ebx中10位作为索引并 * 4,因此我们又 and ebx,0x003ff000 ,shr ebx,10 将ebx中10位取出 放到低12位(右移12位),并 * 4 又左移2位,得到在页表内偏移,最后or 之前得到的页表线性地址esi,最终就得到了访问ebx对应的页表项的线性地址。
同样的我们需要在该项上登记一个分配的页的,因此再次调用allocate_a_4k_page例程,并将返回的分配的页的物理地址or 0x0000007,给低12位装配属性,最终写入[esi]对应页表项的线性地址。
至此 完成了对已经分配的线性地址的物理页分配。retf回到调用处。
创建内核任务的TSS:
在为内核任务TSS分配了虚拟地址空间和页之后,要将core_next_laddr处的双字表示的可分配空间的起始地址修改为新的,因此要加上0x1000,也就是4096字节一个页的大小(对虚拟地址空间和物理地址空间的分配最好都以4KB为单位)。
对于尚未执行过的任务,在其切换到其之前,需要填几乎全部TSS项,而我们的内核任务已经在实际运行了,但在使用ltr指向其TSS选择子之前,仍然需要填写一些必要的项目:任务的上一级TSS选择子链,CR3(页目录表的物理地址0x00020000),LDT,T(调试,默认填0),IO位图映射(如果不用就直接填TSS界限)
在填完TSS后,即可在GDT中安装TSS描述符,TSS基地址即是之前分配给TSS用的线性空间的起始地址,段界限仍然是103而不是0xfff。利用make_seg_descriptor和set_up_gdt_descriptor例程安装后,将内核任务TSS选择子写到core_data段里对应的标号处,以方便后面使用切回内核任务,然后用ltr cx,将内核任务tss选择子写入tr寄存器,标志着内核任务正在执行。
用户任务的创建和切换
多段模型和段页式内存管理
保护模式下未开启分页机制时,首先按照程序的机构分段,创建各段描述符,用描述符指向物理内存的各个段。描述符中的基地址给出了段的起始物理地址,段界限则给出了段的长度,属性值指示了段的类型和特权级别。
而开启分页机制时,多段模型仍然能够运行的很好。但是程序的分段是在线性地址空间中进行的,而不是物理内存,描述符中的基地址是段在线性空间的起始地址,段界限和属性仍然发挥其保护作用。因为开启了分页机制,虚拟地址空间上的段会被映射到物理内存中的一个或者多个页,段是连续的,但是所占用的页可以不连续。开启分页机制后 段基地址+偏移地址 得到的是线性地址,还要经过页部件的转换才能得到物理地址。
为什么要分段?为什么要使用多段模型?①一开始,只是因为8086处理器的16根地址线无法表达2^20的全部内存空间,因此分段。②并且带来了一个好处,是分段模型下很容易实现程序的浮动运行和重定位,但是显然,不使用分段,也有麻烦一点的方法实现浮动运行和重定位。③80386的保护模式,给段添加了界限和属性,实现了内存保护,数据隔离。
“分页兴,平坦王”,分页机制的出现使得分段机制有点多余了,为什么这么说呢?①32位处理器的EIP能够访问全部的4GB内存。②程序的浮动运行和重定位不用分段也可以实现。③由于分页机制实现了线性空间和物理内存的独立,因此每个任务都可以拥有的4GB线性地址空间,而不发生物理内存中数据的干扰与泄露。
平坦模型和用户程序结构
但是很遗憾,分段是Intel处理器的固有机制,处理器总是按照“段地址+偏移量”来形成线性地址,不可能绕开这种工作机制。
而平坦模型则是将全部4GB线性空间整体当做一个大段来处理,所有的段都是4GB,每个段的描述符都指向4GB的段,段的基地址都是0x00000000,段界限都是0xfffff,粒度是4KB
在平坦模型下,程序编写时不分段,只保留一个段,代码和数据都在这个段内,互相相邻,但一般不交叉,段界限和数据访问的检查仍然进行,但是从不会发生违例(因为对线性地址的任何位置访问都是合法的)
平坦模型中,指令和数据的偏移地址即是其线性地址,所有的内容都是按类型组织,先是和整个程序有关的用于加载的头部,再是程序用到的数据,再是执行代码部分,不按照段划分。
在用户代码的数据部分保留了一个很大的空白区域,标号reserved对应的12800字节,将u_salt分成了两部分,由于我们在page_bit_map里写了好几个0x55,因此在此程序被加载时,分配的物理页必然不连续,这里这么做就是为了验证处理器和当前程序能否在分页机制下正常工作。注意的是u_salt的每项都是256字节对齐的,所以中间空白的数据12800也是256的整数倍,否则后面的u_salt项将不能正确的被读取。
用户任务的虚拟地址空间分配
继续回到内核主体代码,我们已经让内核代码处于执行状态,接下来就该创建用户任务并执行了。
首先是创建用户任务的TCB,同样的在本章TCB并没有发挥什么作用,但是还是创建它,作记录之用,TCB也被简化了:
TCB是用来登记任务的,而内核负责任务的管理和调度,所以TCB必须要放在内核任务的地址空间里,如果放在用户任务的地址空间里,那么内核的页目录和页表中,没有指向TCB所在页的表项,内核不可能访问到。
mov ebx,[core_next_laddr]
call sys_routine_seg_sel:alloc_inst_a_page
add dword [core_next_laddr],4096
因此我们分配空间时,用的仍然是[core_next_laddr]处保存的内核任务的可分配的线性地址空间。同样是分配0x1000,4KB给TCB,并将新的可分配线性地址写回[core_next_laddr],调用alloc_inst_a_page,来给分配的线性地址空间分配物理页。至此,TCB内存分配完毕,然后往其中写入必要的项:ldt当前界限和用户任务可分配的线性地址。
ldt初始化长度为0,因此16位的段界限是0-1=0xffff,而用户任务的虚拟内存空间大小是4GB,从0x00000000开始分配,前2GB可以任意使用分配,后2GB被映射到内核的页表,所以一般任务初始可分配的线性地址是0x00000000。
一样的,创建TCB后,要将TCB追加到TCB链上,调用append_to_tcb_chain完成,然后通过栈传入两个参数,调用load_relocate_program来加载和重定位用户程序,并创建为一个任务。
用户程序的加载
我们首先要做就是为用户程序分配内存,装载从磁盘读入的数据。分页机制下,每个任务都有自己的4GB的虚拟地址空间,装载用户程序需要的内存,应该在任务自己的线性地址空间里进行,而不是从内核任务分配,也就是说我们分配的物理页不能是登记在内核任务的页目录表和页表中,而应该是用户任务的页目录表页和页表。
现在用户任务没有页目录表和页表,内核能做的就是为用户任务创建页目录和页表,然后看看用户程序大小,需要多少内存空间,根据需要分配物理页。
我们要在用户任务的线性空间分配内存,并分配对应的页,就需要在内核任务分配一个物理页,作为用户任务的PDT,然后先将用户程序装载到内核自己的线性地址空间的低2GB里(因为用户任务的局部空间只能使用低2GB),这样内核的PDT和页表里就登记了包含着用户程序的页,然后再将内核任务PDT复制给用户任务PDT,这样他们的目录项指向同样页表,所以线性空间也会映射到同样的物理页了(刚好内核部分也被作为高2GB的全局空间复制到了用户任务的线性地址空间)。最后再将内核PDT的低512项(对应低2GB空间)清空即可。
作者在本章使用的方法和我的类似,每次创建一个新任务,都清空内核PDT的前512个目录项,而后半部分内核的代码数据不受影响。然后将加载用户程序,在内核任务上给其分配空间,分配到0x00000000开始的低2GB线性地址空间里,最后把内核任务的PDT复制一份作为用户任务的页目录表。
用户程序必须被加载起始为0x00000000的线性地址空间里,这是为什么呢?因为平坦模型下,段的基地址都是0x00000000,因此为了让程序被加载后 段+偏移量 能够输出想访问的正确地址,就必须对齐基地址。(本质是本章代码没有对用户程序进行重定位)
平坦模型下段描述符的创建
每个用户任务的TSS,内核任务在任何时刻都要能够访问得到,以便于任务的管理,而任务自己几乎不访问自己的TSS,因此TSS应该创建在内核的虚拟地址空间,也就是应该用[core_next_laddr]里分配的线性地址作为任务TSS的地址空间,而不是登记在任务TCB里的任务下一个可用线性地址空间。
而LDT则不一样,内核除了在任务加载时会往里面安装任务的段描述符,之后再也不访问LDT。因此,LDT应该安装在任务的虚拟地址空间,方便任务访问。因此对LDT分配内存代码如下:
mov ebx,[es:esi+0x06] ;从TCB中取得可用的线性地址
add dword [es:esi+0x06],0x1000
call sys_routine_seg_sel:alloc_inst_a_page
mov [es:esi+0x0c],ebx ;填写LDT线性地址到TCB中
然后是创建用户任务的代码段和数据段的描述符,并安装到GDT里,注意由于用的是平坦模型,所以段基地址都是0x0000000,段界限都是0xfffff,粒度都是4KB,特权级3,代码段的属性是0x00c0f800->00000000_1100_0000_1111_1000_00000000 对应 G位1 粒度4KB D/B位1 32位 L位0 非64位 AVL位0 P位1 存在内存里 DPL 11 特权级3 S位1 非系统段 TYPE 1000 可执行 非特权级已从,只能相同特权级调用或者调用门 R 位0 不可读出 A位0 尚未访问(处理器负责置1)。
数据段安装完毕后,TSS里的DS ES FS GS都填写数据段的选择子,代码段安装完毕后,TSS里的CS填写代码段选择子。
在平坦模型下,栈段和其他段共享4GB的虚拟内存空间,用户任务的数据段可以拿到当栈段用,但是需要在用户任务里分配线性地址空间,并分配物理页。然后将分配的线性地址空间的上界作为TSS里的 ESP初始地址填入(因为push是向低地址扩展的)。但是由于我们用的是数据段作为栈段,填入TSS里ss的是数据段的端选择自,因此对于栈段的界限检查,是数据段的0_4GB,实际不起作用,因此我们自己要在程序里小心,防止越界。
因为用户任务是特权级3,需要额外创建特权级0、1、2的栈,这些栈也在用户任务的线性地址空间,也需要分配物理页,并安装到ldt里。这些栈段的段基地址也是0x00000000,界限是0xfffff,粒度4KB,但是特权级不一样(向上扩展的段),安装完后将选择子CX的RPL变得和DPL一致,然后再填写到TSS对应的SS域,同样的EIP域还是分配的线性地址空间的上界。
当所有的段都安装到LDT后,再就是重定位SALT了。我们从线性地址空间里,访问任务的头部,拿出u-salt的条目数和在平坦模型下的偏移(实际就是线性地址了)
mov ecx,[es:0x0c] ;U-SALT条目数
mov edi,[es:0x08] ;U-SALT在4GB空间内的偏移
然后和前两章一样,用cld 和 repe cmpsd 这两个指令进行比较(ds:esi和es:edi开始的比较,每次double word,ecx次),将调用门的偏移地址(可忽略)和门选择子回写到用户任务的头部。
再就是创建并安装LDT描述符,从TCB中取出LDT的线性基地址和段界限,并添加属性,创建描述符并安装到GDT,最后将LDT描述符选择子登记在TCB。
同样填写TSS,再将TSS描述符创建并安装到GDT,然后将TSS描述符选择子登记在TCB中。
最后利用例程create_copy_cur_pdir来创建用户任务的页目录PDT,然后将内核任务的PDT项复制到用户任务PDT项里。最后将用户任务PDT的物理地址写入TSS的CR3域。
至此 用户任务的加载完成。回到start处,注意例程load_relocate_program用的是栈传递参数,因此段内返回是ret 8,跳过栈中的参数,保持栈平衡。
最后执行call far tss选择子:偏移地址实行任务切换。
切换到用户任务执行
任务切换时,内核任务的状态被保存到当前的TSS中,接着找到用户任务的TSS,从中取出各种参数,加载到处理器的各个寄存器中,包括CR3寄存器,LDTR,段寄存器,EIP ESP通用寄存器等,于是用户任务就开始执行了。
0x02 调试
可以用creg命令来查看各个控制寄存器的值,用 page 线性地址 命令查看线性地址到物理地址的映射信息。
使用info tab,可以查看当前任务的全部线性地址和物理地址的映射关系。
可以看出页表本身对应的线性地址是0xffc00000-0xffc00fff,而页目录对应的是0xfffff000-0xffffffff。
0x03 检测题
1.call far [PrintString],是一个通过调用门特权级转换,到特权级为0的全局空间去执行。首先,通过调用门选择子要去访问GDT里的调用门描述符,而GDTR里记载的是GDT的线性地址,这个线性地址是在内核地址空间里设定了,为了在用户任务的线性地址空间同样能够访问,所以必须要将内核映射到任务的地址空间内。
2.要显示当前任务前50个页面的物理地址,就要访问页目录和页表项,由于线性空间的分配是连续的,且我们是从0x00000000开始分配的,因此前50个页面必然在页目录对应的第0项的页表上,我们需要遍历该页表,打印该页表的低50项的高20位(因为低12位不是物理地址,是属性),而该页表的线性地址怎么算呢?
该页表作为页,其页表是页目录,它是页表中的第0项,因此线性地址中10位是偏移0,而页目录是自己的页目录,在页目录中是倒数第二项(这里不是倒数第一项,倒数第一项是指向内核PDT,倒数第二项才指向任务PDT,我们是任务PDT每一项都是从内核PDT复制过来的,所以指向也是倒数第二项),因此线性地址的高10位是偏移0x3fe,因此最终访问该页表的线性地址是0xff800000。所以用户程序代码如下:
start:
mov ebx,message_0
call far [PrintString]
xor esi,esi
mov ecx,50
.b1:
mov ebx,space
call far [PrintString]
mov edx,[0xff800000+esi*4]
and edx,0xfffff000;将最低12位置0
call far [PrintDwordAsHex]
inc esi
loop .b1
call far [TerminateProgram]
上述代码编译通过,运行正确。并且从结果可以看到,任务的前50个页大部分在1MB以上,因为内核占据了1MB以下的页,少部分有1MB以下的,是因为当时在page_bit_map里低32字节除了写0xff外还写了0x55。
page_bit_map db 0xff,0xff,0xff,0xff,0xff,0x55,0x55,0xff
db 0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff
db 0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff
db 0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff
db 0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55
db 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
db 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
db 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
3.内核也工作在平坦模式下,那就要修改描述符了,改线性基地址和段界限,还有粒度,并且由于是0_4GB的数据段,那么很多其他的段都可以不用了,直接用数据段就行了,比如栈段,都需要在内核引导程序中修改。
这个题目要修改引导代码 内核主体代码,代码比较长,答案另开一篇博客