你管这破玩意儿叫操作系统源码(三)

本文为学习操作系统源码 (低并发编程)所作笔记,仅供学习参考,不做任何商业用途,若有侵权,请联系删除。

第九回 | 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 我们把这些页表放在了哪里,就是这段代码。

1
2
xor eax,eax
mov cr3,eax

你看,我们相当于告诉 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 根设备号
  • 第二部分是各种初始化init操作
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程序的进程用来接收用户的命令,到这里其实就出现了我们熟悉的画面。

  • 第四部分是个死循环,如果没有任何任务可以运行,操作系统会一直陷入这个死循环无法自拔。

    1
    2
    3
    4
    void main(void) {
    ...
    for(;;) pause();
    }

目前的内存布局图

第十二回 | 管理内存前先划分出三个边界值

第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, };

// start_mem = 2 * 1024 * 1024
// end_mem = 8 * 1024 * 1024
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;
}

你管这破玩意儿叫操作系统源码(三)
https://www.spacezxy.top/2023/04/03/OperatingSystem/Operating-system-source-code-3/
作者
Xavier ZXY
发布于
2023年4月3日
许可协议