你管这叫操作系统源码(三)

文章目录

重新设置idt和gdt

正式进入 c 语言写的 main.c 之前的 head.s 究竟写了点啥?head.s 文件很短:

_pg_dir:
_startup_32:
    mov eax,0x10
    mov ds,ax
    mov es,ax
    mov fs,ax
    mov gs,ax
    lss esp,_stack_start

注意到开头有个标号 _pg_dir。先留个心眼,这个表示页目录,之后在设置分页机制时,页目录会存放在这里,也会覆盖这里的代码。

再往下连续五个 mov 操作,分别给 ds、es、fs、gs 这几个段寄存器赋值为 0x10,根据段描述符结构解析,表示这几个段寄存器的值为指向全局描述符表中的第二个段描述符,也就是数据段描述符。

最后 lss 指令相当于让 ss:esp 这个栈顶指针指向了 _stack_start 这个标号的位置。还记得图里的那个原来的栈顶指针在哪里吧?往上翻一下,0x9FF00,现在要变咯。

这个 stack_start 标号定义在了很久之后才会讲到的 sched.c 里,我们这里拿出来分析一波。

long user_stack[4096 >> 2];

struct
{
  long *a;
  short b;
}
stack_start = {&user_stack[4096 >> 2], 0x10};

这啥意思呢?首先,stack_start 结构中的高位 8 字节是 0x10,将会赋值给 ss 栈段寄存器,低位 16 字节是 user_stack 这个数组的最后一个元素的地址值,将其赋值给 esp 寄存器。

赋值给 ss 的 0x10 仍然按照保护模式下的段选择子去解读,其指向的是全局描述符表中的第二个段描述符(数据段描述符),段基址是 0。

赋值给 esp 寄存器的就是 user_stack 数组的最后一个元素的内存地址值,那最终的栈顶地址,也指向了这里(user_stack + 0),后面的压栈操作,就是往这个新的栈顶地址处压咯。

继续往下看:

call setup_idt ;设置中断描述符表
call setup_gdt ;设置全局描述符表
mov eax,10h
mov ds,ax
mov es,ax
mov fs,ax
mov gs,ax
lss esp,_stack_start

先设置了 idtgdt,然后又重新执行了一遍刚刚执行过的代码。

为什么要重新设置这些段寄存器呢?因为上面修改了 gdt,所以要重新设置一遍以刷新才能生效。那我们接下来就把目光放到设置 idt 和 gdt 上。

中断描述符表 idt 我们之前没设置过,所以这里设置具体的值,理所应当。

setup_idt:
    lea edx,ignore_int
    mov eax,00080000h
    mov ax,dx
    mov dx,8E00h
    lea edi,_idt
    mov ecx,256
rp_sidt:
    mov [edi],eax
    mov [edi+4],edx
    add edi,8
    dec ecx
    jne rp_sidt
    lidt fword ptr idt_descr
    ret

idt_descr:
    dw 256*8-1
    dd _idt

_idt:
    DQ 256 dup(0)

最终效果:中断描述符表 idt 里面存储着一个个中断描述符,每一个中断号就对应着一个中断描述符,而中断描述符里面存储着主要是中断程序的地址,这样一个中断号过来后,CPU 就会自动寻找相应的中断程序,然后去执行它。

那这段程序的作用就是,设置了 256 个中断描述符,并且让每一个中断描述符中的中断程序例程都指向一个 ignore_int 的函数地址,这个是个默认的中断处理程序,之后会逐渐被各个具体的中断程序所覆盖。比如之后键盘模块会将自己的键盘中断处理程序,覆盖过去。

那现在,产生任何中断都会指向这个默认的函数 ignore_int,也就是说现在这个阶段你按键盘还不好使

设置中断描述符表 setup_idt 说完了,那接下来 setup_gdt 就同理了。我们就直接看设置好后的新的全局描述符表长什么样吧?

_gdt:
    DQ 0000000000000000h    ;/* NULL descriptor */
    DQ 00c09a0000000fffh    ;/* 16Mb */
    DQ 00c0920000000fffh    ;/* 16Mb */
    DQ 0000000000000000h    ;/* TEMPORARY - don't use */
    DQ 252 dup(0)

其实和我们原先设置好的 gdt 一模一样。

也是有代码段描述符数据段描述符,然后第四项系统段描述符并没有用到,不用管。最后还留了 252 项的空间,这些空间后面会用来放置任务状态段描述符 TSS局部描述符 LDT,这个后面再说。
你管这叫操作系统源码(三)
为什么原来已经设置过一遍了,这里又要重新设置一遍,你可千万别想有什么复杂的原因,就是因为原来设置的 gdt 是在 setup 程序中,之后这个地方要被缓冲区覆盖掉,所以这里重新设置在 head 程序中,这块内存区域之后就不会被其他程序用到并且覆盖了,就这么个事。

说的口干舌燥,还是来张图吧:
你管这叫操作系统源码(三)
如果你本节的内容完全不能理解,那就记住最后这张图就好了,本节代码就是完成了这个图中所示的一个指向转换而已,并且给所有中断设置了一个默认的中断处理程序 ignore_int,然后全局描述符表仍然只有代码段描述符和数据段描述符。

好了,本节就是两个描述符表位置的变化以及重新设置,再后面一行代码就是又一个令人兴奋的功能了!

jmp after_page_tables
...
after_page_tables:
    push 0
    push 0
    push 0
    push L6
    push _main
    jmp setup_paging
L6:
    jmp L6

那就是开启分页机制,并且跳转到 main 函数!

这可太令人兴奋了!开启分页后,配合着之前讲的分段,就构成了内存管理的最最底层的机制。而跳转到 main 函数,标志着我们正式进入 c 语言写的操作系统核心代码!

Intel内存管理:分段与分页

先瞧瞧这分页机制是如何开启的,也就是 setup_paging 这个标签处的代码:

setup_paging:
    mov ecx,1024*5
    xor eax,eax
    xor edi,edi
    pushf
    cld
    rep stosd
    mov eax,_pg_dir
    mov [eax],pg0+7
    mov [eax+4],pg1+7
    mov [eax+8],pg2+7
    mov [eax+12],pg3+7
    mov edi,pg3+4092
    mov eax,00fff007h
    std
L3: stosd
    sub eax,00001000h
    jge L3
    popf
    xor eax,eax
    mov cr3,eax
    mov eax,cr0
    or  eax,80000000h
    mov cr0,eax
    ret

首先要了解的就是,啥是分页机制?

还记不记得之前我们在代码中给出一个内存地址,在保护模式下要先经过分段机制的转换,才能最终变成物理地址,就是这样:
你管这叫操作系统源码(三)
这是在没有开启分页机制的时候,只需要经过这一步转换即可得到最终的物理地址了,但是在开启了分页机制后,又会多一步转换
你管这叫操作系统源码(三)
也就是说,在没有开启分页机制时,由程序员给出的逻辑地址,需要先通过分段机制转换成物理地址。但在开启分页机制后,逻辑地址仍然要先通过分段机制进行转换,只不过转换后不再是最终的物理地址,而是线性地址,然后再通过一次分页机制转换,得到最终的物理地址。

分段机制我们已经清楚如何对地址进行变换了,那分页机制又是如何变换的呢?我们直接以一个例子来学习过程。

比如我们的线性地址(已经经过了分段机制的转换)是

15M

二进制表示就是

0000000011_0100000000_000000000000

我们看一下它的转换过程:
你管这叫操作系统源码(三)
也就是说,CPU 在看到我们给出的内存地址后,首先把线性地址被拆分成

高 10 位:中间 10 位:后 12 位

高 10 位负责在页目录表中找到一个页目录项,这个页目录项的值加上中间 10 位拼接后的地址去页表中去寻找一个页表项,这个页表项的值,再加上后 12 位偏移地址,就是最终的物理地址。

而这一切的操作,都由计算机的一个硬件叫 MMU,中文名字叫内存管理单元,有时也叫 PMMU,分页内存管理单元。由这个部件来负责将虚拟地址转换为物理地址。

所以整个过程我们不用操心,作为操作系统这个软件层,只需要提供好页目录表和页表即可,这种页表方案叫做二级页表,第一级叫页目录表 PDE,第二级叫页表 PTE。他们的结构如下:
你管这叫操作系统源码(三)
之后再开启分页机制的开关。其实就是更改 cr0 寄存器中的一位即可(31 位),还记得我们开启保护模式么,也是改这个寄存器中的一位的值。
你管这叫操作系统源码(三)
然后,MMU 就可以帮我们进行分页的转换了。此后指令中的内存地址(就是程序员提供的逻辑地址),就统统要先经过分段机制的转换,再通过分页机制的转换,才能最终变成物理地址。

所以这段代码,就是帮我们把页表和页目录表在内存中写好,之后开启 cr0 寄存器的分页开关,仅此而已,我们再把代码贴上来。

setup_paging:
    mov ecx,1024*5
    xor eax,eax
    xor edi,edi
    pushf
    cld
    rep stosd
    mov eax,_pg_dir
    mov [eax],pg0+7
    mov [eax+4],pg1+7
    mov [eax+8],pg2+7
    mov [eax+12],pg3+7
    mov edi,pg3+4092
    mov eax,00fff007h
    std
L3: stosd
    sub eax,00001000h
    jge L3
    popf
    xor eax,eax
    mov cr3,eax
    mov eax,cr0
    or  eax,80000000h
    mov cr0,eax
    ret

我们先说这段代码最终产生的效果吧。

当时 linux-0.11 认为,总共可以使用的内存不会超过 16M,也即最大地址空间为 0xFFFFFF

而按照当前的页目录表和页表这种机制,1 个页目录表最多包含 1024 个页目录项(也就是 1024 个页表),1 个页表最多包含 1024 个页表项(也就是 1024 个页),1 页为 4KB(因为有 12 位偏移地址),因此,16M 的地址空间可以用 1 个页目录表 + 4 个页表搞定。

4(页表数)* 1024(页表项数) * 4KB(一页大小)= 16MB

所以,上面这段代码就是,将页目录表放在内存地址的最开头,还记得上一讲开头让你留意的 _pg_dir 这个标签吧?

_pg_dir:
_startup_32:
    mov eax,0x10
    mov ds,ax
    ...

之后紧挨着这个页目录表,放置 4 个页表,代码里也有这四个页表的标签项。

.org 0x1000 pg0:
.org 0x2000 pg1:
.org 0x3000 pg2:
.org 0x4000 pg3:
.org 0x5000

最终将页目录表和页表填写好数值,来覆盖整个 16MB 的内存。随后,开启分页机制。此时内存中的页表相关的布局如下。
你管这叫操作系统源码(三)
这些页目录表和页表放到了整个内存布局中最开头的位置,就是覆盖了开头的 system 代码了,不过被覆盖的 system 代码已经执行过了,所以无所谓。

同时,如 idt 和 gdt 一样,我们也需要通过一个寄存器告诉 CPU 我们把这些页表放在了哪里,就是这段代码。

xor eax,eax
mov cr3,eax

你看,我们相当于告诉 cr3 寄存器,0 地址处就是页目录表,再通过页目录表可以找到所有的页表,也就相当于 CPU 知道了分页机制的全貌了。

至此后,整个内存布局如下:
你管这叫操作系统源码(三)
那么具体页表设置好后,映射的内存是怎样的情况呢?那就要看页表的具体数据了,就是这一坨代码:

setup_paging:
    ...
    mov eax,_pg_dir
    mov [eax],pg0+7
    mov [eax+4],pg1+7
    mov [eax+8],pg2+7
    mov [eax+12],pg3+7
    mov edi,pg3+4092
    mov eax,00fff007h
    std
L3: stosd
    sub eax, 1000h
    jpe L3
    ...

很简单,对照刚刚的页目录表与页表结构看:
你管这叫操作系统源码(三)
前五行表示,页目录表的前 4 个页目录项,分别指向 4 个页表。比如页目录项中的第一项 [eax] 被赋值为 pg0+7,也就是 0x00001007,根据页目录项的格式,表示页表地址为 0x1000,页属性为 0x07 表示改页存在、用户可读写。

后面几行表示,填充 4 个页表的每一项,一共 4*1024=4096 项,依次映射到内存的前 16MB 空间。

画出图就是这个样子,其实刚刚的图就是:
你管这叫操作系统源码(三)

看,最终的效果就是,经过这套分页机制,线性地址将恰好和最终转换的物理地址一样

现在只有四个页目录项,也就是将前 16M 的线性地址空间,与 16M 的物理地址空间一一对应起来了:
你管这叫操作系统源码(三)
好了,我知道你目前可能有点晕头转向,关于地址,我们已经出现了好多词了,包括逻辑地址线性地址物理地址,以及本文中没出现的,你可能在很多地方看到过的虚拟地址

而这些地址后面加上空间两个字,似乎又成为了一个新词,比如线性地址空间物理地址空间虚拟地址空间等。

那就是时候展开一波讨论,将这块的内容梳理一番了。

Intel 体系结构的内存管理可以分成两大部分,也就是标题中的分段分页

分段机制在之前几节已经讨论过多次了,其目的是为了为每个程序或任务提供单独的代码段(cs)、数据段(ds)、栈段(ss),使其不会相互干扰。

分页机制是本节讲的内容,开机后分页机制默认是关闭状态,需要我们手动开启,并且设置好页目录表(PDE)和页表(PTE)。其目的在于可以按需使用物理内存,同时也可以在多任务时起到隔离的作用,这个在后面将多任务时将会有所体会。

在 Intel 的保护模式下,分段机制是没有开启和关闭一说的,它必须存在,而分页机制是可以选择开启或关闭的。所以如果有人和你说,它实现了一个没有分段机制的操作系统,那一定是个外行。

再说说那些地址:

逻辑地址:我们程序员写代码时给出的地址叫逻辑地址,其中包含段选择子和偏移地址两部分。

线性地址:通过分段机制,将逻辑地址转换后的地址,叫做线性地址。而这个线性地址是有个范围的,这个范围就叫做线性地址空间,32 位模式下,线性地址空间就是 4G。

物理地址:就是真正在内存中的地址,它也是有范围的,叫做物理地址空间。那这个范围的大小,就取决于你的内存有多大了。

虚拟地址:如果没有开启分页机制,那么线性地址就和物理地址是一一对应的,可以理解为相等。如果开启了分页机制,那么线性地址将被视为虚拟地址,这个虚拟地址将会通过分页机制的转换,最终转换成物理地址。
你管这叫操作系统源码(三)
但实际上,我是不喜欢虚拟地址这个叫法的,因为它在 Intel 标准手册上出现的次数很少,我觉得知道逻辑地址、线性地址、物理地址这三个概念就够了,逻辑地址是程序员给出的,经过分段机制转换后变成线性地址,然后再经过分页机制转换后变成物理地址,就这么简单。

好了,我们终于把这些杂七杂八的,idt、gdt、页表都设置好了,并且也开启了保护模式,之后我们就要做好进入main.c 的准备了,那里是个新世界!

不过进入 main.c 之前还差最后一哆嗦,就是 head.s 最后的代码,也就是上节最后的那段代码。

有个 push _main,把 main 函数的地址压栈了,那最终跳转到这个 main.c 里的 main 函数,一定和这个压栈有关。压栈为什么和跳转到这里还能联系上呢?下一篇将揭秘这个过程,你会发现仍然简单得要死。

进入main前的最后一跃

上节我们跳转到设置分页代码的那个地方(head.s 里),这里有个骚操作帮我们跳转到 main.c

after_page_tables:
    push 0
    push 0
    push 0
    push L6
    push _main
    jmp setup_paging
...
setup_paging:
    ...
    ret


push 指令就是压栈,五个 push 指令过去后,栈会变成这个样子。
你管这叫操作系统源码(三)
然后注意,setup_paging 最后一个指令是 ret,也就是我们上一回讲的设置分页的代码的最后一个指令,形象地说它叫返回指令,但 CPU 可没有那么聪明,它并不知道该返回到哪里执行,只是很机械地把栈顶的元素值当做返回地址,跳转去那里执行。

再具体说是,把 esp 寄存器(栈顶地址)所指向的内存处的值,赋值给 eip 寄存器,而 cs:eip 就是 CPU 要执行的下一条指令的地址。而此时栈顶刚好是 main.c 里写的 main 函数的内存地址,是我们刚刚特意压入栈的,所以 CPU 就理所应当跳过来了。

当然 Intel CPU 是设计了 call 和 ret 这一配对儿的指令,意为调用函数和返回,具体可以看后面扩展资料里的内容。

至于其他压入栈的 L6 是用作当 main 函数返回时的跳转地址,但由于在操作系统层面的设计上,main 是绝对不会返回的,所以也就没用了。而其他的三个压栈的 0,本意是作为 main 函数的参数,但实际上似乎也没有用到,所以也不必关心。

总之,经过这一个小小的骚操作,程序终于跳转到 main.c 这个由 c 语言写的主函数 main 里了!至此,整个第一部分就圆满结束了,为了跳进 main 函数的准备工作,我称之为进入内核前的苦力活,就完成了!我们看看我们做了什么:
你管这叫操作系统源码(三)
我把这些称为进入内核前的苦力活,经过这样的流程,内存被搞成了这个样子。
你管这叫操作系统源码(三)
之后,main 方法就开始执行了,靠着我们辛辛苦苦建立起来的内存布局,向崭新的未来前进!

资料

[1] 关于逻辑地址-线性地址-物理地址的转换,可以参考 Intel 手册:Intel 3A Chapter 3 Protected-Mode Memory Management
你管这叫操作系统源码(三)
而有关这些地址的定义和说明,在本小节中也做了详细的说明,看这里的介绍是最权威也是最透彻的:
你管这叫操作系统源码(三)
[2] 页目录表和页表的具体结构,可以看 Intel 3A Chapter 4.3 32-bit paging
你管这叫操作系统源码(三)
你管这叫操作系统源码(三)
[3] 关于 ret 指令,其实 Intel CPU 是配合 call 设计的,有关 call 和 ret 指令,即调用和返回指令,可以参考 Intel 手册:Intel 1 Chapter 6.4 CALLING PROCEDURES USING CALL AND RET

可以看到还分为不改变段基址的 near call 和 near ret
你管这叫操作系统源码(三)
以及改变段基址的 far call 和 far ret
你管这叫操作系统源码(三)
压栈和出栈的具体过程,上面文字写的清清楚楚,下面 Intel 手册还非常友好地放了张图
你管这叫操作系统源码(三)
可以看到,我们本文就是左边的那一套,把 main 函数地址值当做 Calling EIP 压入栈,仿佛是执行了 call 指令调用了一个函数一样,但实际上这是我们通过骚操作代码伪造的假象,骗了 CPU。

然后 ret 的时候就把栈顶的那个 Calling EIP 也就是 main 函数地址弹出栈,存入 EIP 寄存器,这样 CPU 就相当于“返回”到了 main 函数开始执行

上一篇:SQL


下一篇:基于HTML5打造的一款别踩白板小游戏