你管这破玩意儿叫操作系统源码(三)
本文为学习操作系统源码 (低并发编程)所作笔记,仅供学习参考,不做任何商业用途,若有侵权,请联系删除。
第九回 | Intel 内存管理两板斧:分段与分页
上回head.s代码重新设置了gdt与idt。
1 |
|
开启分页机制,并且跳转到main函数。
如何跳转到之后用 c 语言写的 main.c 里的 main 函数,是个有趣的事,也包含在这段代码里。不过我们先瞧瞧这分页机制是如何开启的,也就是 setup_paging 这个标签处的代码。
1 |
|
也就是说,在没有开启分页机制时,由程序员给出的逻辑地址,需要先通过分段机制转换成物理地址。但在开启分页机制后,逻辑地址仍然要先通过分段机制进行转换,只不过转换后不再是最终的物理地址,而是线性地址,然后再通过一次分页机制转换,得到最终的物理地址。
比如我们的线性地址(已经经过了分段机制的转换)是
15M
二进制表示就是
0000000011_0100000000_000000000000
而这一切的操作,都由计算机的一个硬件叫 MMU,中文名字叫内存管理单元,有时也叫 PMMU,分页内存管理单元。由这个部件来负责将虚拟地址转换为物理地址。
所以整个过程我们不用操心,作为操作系统这个软件层,只需要提供好页目录表和页表即可,这种页表方案叫做二级页表,第一级叫页目录表 PDE,第二级叫页表 PTE。他们的结构如下。
之后再开启分页机制的开关。其实就是更改 cr0 寄存器中的一位即可(31 位),还记得我们开启保护模式么,也是改这个寄存器中的一位的值。
所以这段代码,就是帮我们把页表和页目录表在内存中写好,之后开启 cr0 寄存器的分页开关,仅此而已,我们再把代码贴上来。
1 |
|
当时 linux-0.11 认为,总共可以使用的内存不会超过 16M,也即最大地址空间为 0xFFFFFF。
而按照当前的页目录表和页表这种机制,1 个页目录表最多包含 1024 个页目录项(也就是 1024 个页表),1 个页表最多包含 1024 个页表项(也就是 1024 个页),1 页为 4KB(因为有 12 位偏移地址),因此,16M 的地址空间可以用 1 个页目录表 + 4 个页表搞定。
所以,上面这段代码就是,将页目录表放在内存地址的最开头,还记得上一讲开头让你留意的 _pg_dir 这个标签吧?
1 |
|
之后紧挨着这个页目录表,放置 4 个页表,代码里也有这四个页表的标签项。
1 |
|
最终将页目录表和页表填写好数值,来覆盖整个 16MB 的内存。随后,开启分页机制。此时内存中的页表相关的布局如下。
同时,如 idt 和 gdt 一样,我们也需要通过一个寄存器告诉 CPU 我们把这些页表放在了哪里,就是这段代码。
1 |
|
你看,我们相当于告诉 cr3 寄存器,0 地址处就是页目录表,再通过页目录表可以找到所有的页表,也就相当于 CPU 知道了分页机制的全貌了。
1 |
|
很简单,对照刚刚的页目录表与页表结构看。
前五行表示,页目录表的前 4 个页目录项,分别指向 4 个页表。比如页目录项中的第一项 [eax] 被赋值为 pg0+7,也就是 0x00001007,根据页目录项的格式,表示页表地址为 0x1000,页属性为 0x07 表示改页存在、用户可读写。
后面几行表示,填充 4 个页表的每一项,一共 4*1024=4096 项,依次映射到内存的前 16MB 空间。
逻辑地址:我们程序员写代码时给出的地址叫逻辑地址,其中包含段选择子和偏移地址两部分。
线性地址:通过分段机制,将逻辑地址转换后的地址,叫做线性地址。而这个线性地址是有个范围的,这个范围就叫做线性地址空间,32 位模式下,线性地址空间就是 4G。
物理地址:就是真正在内存中的地址,它也是有范围的,叫做物理地址空间。那这个范围的大小,就取决于你的内存有多大了。
虚拟地址:如果没有开启分页机制,那么线性地址就和物理地址是一一对应的,可以理解为相等。如果开启了分页机制,那么线性地址将被视为虚拟地址,这个虚拟地址将会通过分页机制的转换,最终转换成物理地址。
第十回 | 进入main函数前的最后一跃
这仍然要回到上一讲我们跳转到设置分页代码的那个地方(head.s 里),这里有个骚操作帮我们跳转到 main.c。
1 |
|
push 指令就是压栈,五个 push 指令过去后,栈会变成这个样子。
然后注意,setup_paging 最后一个指令是 ret,也就是我们上一回讲的设置分页的代码的最后一个指令,形象地说它叫返回指令,但 CPU 可没有那么聪明,它并不知道该返回到哪里执行,只是很机械地把栈顶的元素值当做返回地址,跳转去那里执行。
再具体说是,把 esp 寄存器(栈顶地址)所指向的内存处的值,赋值给 eip 寄存器,而 cs:eip 就是 CPU 要执行的下一条指令的地址。而此时栈顶刚好是 main.c 里写的 main 函数的内存地址,是我们刚刚特意压入栈的,所以 CPU 就理所应当跳过来了。
至于其他压入栈的 L6 是用作当 main 函数返回时的跳转地址,但由于在操作系统层面的设计上,main 是绝对不会返回的,所以也就没用了。而其他的三个压栈的 0,本意是作为 main 函数的参数,但实际上似乎也没有用到,所以也不必关心。
第一部分总结
第十一回 | 整个操作系统就20几行代码
mian.c文件中的main方法。
1 |
|
- 第一部分是一些参数的取值和计算
1 |
|
包括根设备 ROOT_DEV,之前在汇编语言中获取的各个设备的参数信息 drive_info,以及通过计算得到的内存边界
main_memory_start
main_memory_end
buffer_memory_start
buffer_memory_end
从哪获得之前的设备参数信息呢?如果你前面看了,那一定还记得这个表,都是由 setup.s 这个汇编程序调用 BIOS 中断获取的各个设备的信息,并保存在约定好的内存地址 0x90000 处。
内存地址 | 长度(字节) | 名称 |
---|---|---|
0x90000 | 2 | 光标位置 |
0x90002 | 2 | 扩展内存数 |
0x90004 | 2 | 显示页面 |
0x90006 | 1 | 显示模式 |
0x90007 | 1 | 字符列数 |
0x90008 | 2 | 未知 |
0x9000A | 1 | 显示内存 |
0x9000B | 1 | 显示状态 |
0x9000C | 2 | 显卡特性参数 |
0x9000E | 1 | 屏幕行数 |
0x9000F | 1 | 屏幕列数 |
0x90080 | 16 | 硬盘1参数表 |
0x90090 | 16 | 硬盘2参数表 |
0x901FC | 2 | 根设备号 |
- 第二部分是各种初始化init操作
1 |
|
- 切换到用户态模式,并在新的进程中做一个最终的初始化init
1 |
|
这里init函数里会创建一个进程,设置终端的标准IO,并且再创建一个执行shell程序的进程用来接收用户的命令,到这里其实就出现了我们熟悉的画面。
第四部分是个死循环,如果没有任何任务可以运行,操作系统会一直陷入这个死循环无法自拔。
1
2
3
4void main(void) {
...
for(;;) pause();
}
目前的内存布局图
第十二回 | 管理内存前先划分出三个边界值
1 |
|
判断的标准都是 memory_end 也就是内存最大值的大小,而这个内存最大值由第一行代码可以看出,是等于 1M + 扩展内存大小。
其实就只是针对不同的内存大小,设置不同的边界值罢了
假设总内存一共就 8M 大小吧。
那么如果内存为 8M 大小,memory_end 就是
8 * 1024 * 1024
也就只会走倒数第二个分支,那么 buffer_memory_end 就为
2 * 1024 * 1024
那么 main_memory_start 也为
2 * 1024 * 1024
该段代码就是决定了三个边界地址。
主内存区的管理和分配是以下代码,
1 |
|
缓冲区的管理和分配是以下代码,
1 |
|
第十三回 | 操作系统就用一张大表管理内存?
该篇文章主要简述主内存是如何进行管理的。
mem_init() function
1 |
|
仔细一看这个方法,其实折腾来折腾去,就是给一个 mem_map 数组的各个位置上赋了值,而且显示全部赋值为 USED 也就是 100,然后对其中一部分又赋值为了 0。
就是准备了一个表,记录了哪些内存被占用了,哪些内存没被占用。
可以看出,初始化完成后,其实就是 mem_map 这个数组的每个元素都代表一个 4K 内存是否空闲(准确说是使用次数)。
4K 内存通常叫做 1 页内存,而这种管理方式叫分页管理,就是把内存分成一页一页(4K)的单位去管理。
1M 以下的内存这个数组干脆没有记录,这里的内存是无需管理的,或者换个说法是无权管理的,也就是没有权利申请和释放,因为这个区域是内核代码所在的地方,不能被“污染”。
1M 到 2M 这个区间是缓冲区,2M 是缓冲区的末端,缓冲区的开始在哪里之后再说,这些地方不是主内存区域,因此直接标记为 USED,产生的效果就是无法再被分配了。
2M 以上的空间是主内存区域,而主内存目前没有任何程序申请,所以初始化时统统都是零,未来等着应用程序去申请和释放这里的内存资源。
应用程序申请内存
在 memory.c 文件中有个函数 get_free_page(),用于在主内存区中申请一页空闲内存页,并返回物理内存页的起始地址。
比如我们在 fork 子进程的时候,会调用 copy_process 函数来复制进程的结构信息,其中有一个步骤就是要申请一页内存,用于存放进程结构信息 task_struct。
1 |
|
我们看 get_free_page 的具体实现,是内联汇编代码,看不懂不要紧,注意它里面就有 mem_map 结构的使用。就是选择 mem_map 中首个空闲页面,并标记为已使用。
1 |
|