本文为学习操作系统源码
(低并发编程)所作笔记,仅供学习参考,不做任何商业用途,若有侵权,请联系删除。
第九回 | Intel
内存管理两板斧:分段与分页
第九回
| Intel 内存管理两板斧:分段与分页 (qq.com)
上回head.s代码重新设置了gdt与idt。
1 2 3 4 5 6 7 8 9 10 11
| jmp after_page_tables ... after_page_tables: push 0 push 0 push 0 push L6 push _main jmp setup_paging L6: jmp L6
|
开启分页机制,并且跳转到main函数。
如何跳转到之后用 c 语言写的 main.c 里的 main
函数,是个有趣的事,也包含在这段代码里。不过我们先瞧瞧这分页机制是如何开启的,也就是
setup_paging 这个标签处的代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| 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
而这一切的操作,都由计算机的一个硬件叫
MMU,中文名字叫内存管理单元,有时也叫
PMMU,分页内存管理单元。由这个部件来负责将虚拟地址转换为物理地址。
所以整个过程我们不用操心,作为操作系统这个软件层,只需要提供好页目录表和页表即可,这种页表方案叫做二级页表,第一级叫页目录表
PDE,第二级叫页表 PTE。他们的结构如下。
之后再开启分页机制的开关。其实就是更改 cr0
寄存器中的一位即可(31
位),还记得我们开启保护模式么,也是改这个寄存器中的一位的值。
所以这段代码,就是帮我们把页表和页目录表在内存中写好,之后开启 cr0
寄存器的分页开关,仅此而已,我们再把代码贴上来。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| 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 个页表搞定。
所以,上面这段代码就是,将页目录表放在内存地址的最开头,还记得上一讲开头让你留意的
_pg_dir 这个标签吧?
1 2 3 4 5
| _pg_dir: _startup_32: mov eax,0x10 mov ds,ax ...
|
之后紧挨着这个页目录表,放置 4
个页表,代码里也有这四个页表的标签项。
1 2 3 4 5
| .org 0x1000 pg0: .org 0x2000 pg1: .org 0x3000 pg2: .org 0x4000 pg3: .org 0x5000
|
最终将页目录表和页表填写好数值,来覆盖整个 16MB
的内存。随后,开启分页机制。此时内存中的页表相关的布局如下。
同时,如 idt 和 gdt 一样,我们也需要通过一个寄存器告诉 CPU
我们把这些页表放在了哪里,就是这段代码。
你看,我们相当于告诉 cr3 寄存器,0
地址处就是页目录表,再通过页目录表可以找到所有的页表,也就相当于
CPU 知道了分页机制的全貌了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| 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 空间。
逻辑地址:我们程序员写代码时给出的地址叫逻辑地址,其中包含段选择子和偏移地址两部分。
线性地址:通过分段机制,将逻辑地址转换后的地址,叫做线性地址。而这个线性地址是有个范围的,这个范围就叫做线性地址空间,32
位模式下,线性地址空间就是 4G。
物理地址:就是真正在内存中的地址,它也是有范围的,叫做物理地址空间。那这个范围的大小,就取决于你的内存有多大了。
虚拟地址:如果没有开启分页机制,那么线性地址就和物理地址是一一对应的,可以理解为相等。如果开启了分页机制,那么线性地址将被视为虚拟地址,这个虚拟地址将会通过分页机制的转换,最终转换成物理地址。
第十回 |
进入main函数前的最后一跃
这仍然要回到上一讲我们跳转到设置分页代码的那个地方(head.s
里),这里有个骚操作帮我们跳转到 main.c。
1 2 3 4 5 6 7 8 9 10 11
| 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
就理所应当跳过来了。
至于其他压入栈的 L6 是用作当 main
函数返回时的跳转地址,但由于在操作系统层面的设计上,main
是绝对不会返回的,所以也就没用了。而其他的三个压栈的 0,本意是作为 main
函数的参数,但实际上似乎也没有用到,所以也不必关心。
第一部分总结
第一部分完结
进入内核前的苦力活 (qq.com)
第十一回 |
整个操作系统就20几行代码
第11回
| 整个操作系统就 20 几行代码 (qq.com)
mian.c文件中的main方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| void main(void) { ROOT_DEV = ORIG_ROOT_DEV; drive_info = DRIVE_INFO; memory_end = (1<<20) + (EXT_MEM_K<<10); memory_end &= 0xfffff000; if (memory_end > 16*1024*1024) memory_end = 16*1024*1024; if (memory_end > 12*1024*1024) buffer_memory_end = 4*1024*1024; else if (memory_end > 6*1024*1024) buffer_memory_end = 2*1024*1024; else buffer_memory_end = 1*1024*1024; main_memory_start = buffer_memory_end;
mem_init(main_memory_start,memory_end); trap_init(); blk_dev_init(); chr_dev_init(); tty_init(); time_init(); sched_init(); buffer_init(buffer_memory_end); hd_init(); floppy_init();
sti(); move_to_user_mode(); if (!fork()) { init(); }
for(;;) pause(); }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| void main(void) { ROOT_DEV = ORIG_ROOT_DEV; drive_info = DRIVE_INFO; memory_end = (1<<20) + (EXT_MEM_K<<10); memory_end &= 0xfffff000; if (memory_end > 16*1024*1024) memory_end = 16*1024*1024; if (memory_end > 12*1024*1024) buffer_memory_end = 4*1024*1024; else if (memory_end > 6*1024*1024) buffer_memory_end = 2*1024*1024; else buffer_memory_end = 1*1024*1024; main_memory_start = buffer_memory_end; }
|
包括根设备
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 |
根设备号 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| void main(void) { ... mem_init(main_memory_start,memory_end); trap_init(); blk_dev_init(); chr_dev_init(); tty_init(); time_init(); sched_init(); buffer_init(buffer_memory_end); hd_init(); floppy_init(); ... }
|
- 切换到用户态模式,并在新的进程中做一个最终的初始化init
1 2 3 4 5 6 7 8 9
| void main(void) { ... sti(); move_to_user_mode(); if (!fork()) { init(); } ... }
|
这里init函数里会创建一个进程,设置终端的标准IO,并且再创建一个执行shell程序的进程用来接收用户的命令,到这里其实就出现了我们熟悉的画面。
目前的内存布局图
第十二回 |
管理内存前先划分出三个边界值
第12回
| 管理内存前先划分出三个边界值 (qq.com)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| void main(void) { ... memory_end = (1<<20) + (EXT_MEM_K<<10); memory_end &= 0xfffff000; if (memory_end > 16*1024*1024) memory_end = 16*1024*1024; if (memory_end > 12*1024*1024) buffer_memory_end = 4*1024*1024; else if (memory_end > 6*1024*1024) buffer_memory_end = 2*1024*1024; else buffer_memory_end = 1*1024*1024; main_memory_start = buffer_memory_end; ... }
|
判断的标准都是 memory_end
也就是内存最大值的大小,而这个内存最大值由第一行代码可以看出,是等于 1M
+ 扩展内存大小。
其实就只是针对不同的内存大小,设置不同的边界值罢了
假设总内存一共就 8M 大小吧。
那么如果内存为 8M 大小,memory_end 就是
8 * 1024 * 1024
也就只会走倒数第二个分支,那么 buffer_memory_end
就为
2 * 1024 * 1024
那么 main_memory_start 也为
2 * 1024 * 1024
该段代码就是决定了三个边界地址。
主内存区的管理和分配是以下代码,
1 2 3 4 5
| void main(void) { ... mem_init(main_memory_start, memory_end); ... }
|
缓冲区的管理和分配是以下代码,
1 2 3 4 5
| void main(void) { ... buffer_init(buffer_memory_end); ... }
|
第十三回 |
操作系统就用一张大表管理内存?
操作系统就用一张大表管理内存?
(qq.com)
该篇文章主要简述主内存是如何进行管理的。
mem_init() function
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| #define LOW_MEM 0x100000 #define PAGING_MEMORY (15*1024*1024) #define PAGING_PAGES (PAGING_MEMORY>>12) #define MAP_NR(addr) (((addr)-LOW_MEM)>>12) #define USED 100
static long HIGH_MEMORY = 0; static unsigned char mem_map[PAGING_PAGES] = { 0, };
void mem_init(long start_mem, long end_mem) { int i; HIGH_MEMORY = end_mem; for (i=0 ; i<PAGING_PAGES ; i++) mem_map[i] = USED; i = MAP_NR(start_mem); end_mem -= start_mem; end_mem >>= 12; while (end_mem-- >0) mem_map[i++]=0; }
|
仔细一看这个方法,其实折腾来折腾去,就是给一个
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 2 3 4 5 6
| int copy_process(...) { struct task_struct *p; ... p = (struct task_struct *) get_free_page(); ... }
|
我们看 get_free_page
的具体实现,是内联汇编代码,看不懂不要紧,注意它里面就有
mem_map 结构的使用。就是选择 mem_map
中首个空闲页面,并标记为已使用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| unsigned long get_free_page(void) { register unsigned long __res asm("ax"); __asm__( "std ; repne ; scasb\n\t" "jne 1f\n\t" "movb $1,1(%%edi)\n\t" "sall $12,%%ecx\n\t" "addl %2,%%ecx\n\t" "movl %%ecx,%%edx\n\t" "movl $1024,%%ecx\n\t" "leal 4092(%%edx),%%edi\n\t" "rep ; stosl\n\t" "movl %%edx,%%eax\n" "1:" :"=a" (__res) :"0" (0),"i" (LOW_MEM),"c" (PAGING_PAGES), "D" (mem_map + PAGING_PAGES-1) :"di","cx","dx"); return __res; }
|