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

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

第六回 | 先解决段寄存器的历史包袱问题

第六回 | 先解决段寄存器的历史包袱问题 (qq.com)

当前的内存布局如下,

0地址开始处存放着操作系统的全部代码吗,也就是 system 模块,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 根设备号

接下来就要进行真正的第一项大工程了,那就是模式的转换,需要从现在的16位的实模式转变为之后的32位的保护模式

这是 x86 的历史包袱问题,现在的 CPU 几乎都是支持 32 位模式甚至 64 位模式了,很少有还仅仅停留在 16 位的实模式下的 CPU。所以我们要为了这个历史包袱,写一段模式转换的代码,如果 Intel CPU 被重新设计而不用考虑兼容性,那么今天的代码将会减少很多甚至不复存在。

并不直接介绍实模式和保护模式的区别,而是跟着代码慢慢品味。

setup.s

1
2
3
4
5
6
lidt  idt_48      ; load idt with 0,0
lgdt gdt_48 ; load gdt with whatever appropriate

idt_48:
.word 0 ; idt limit=0
.word 0,0 ; idt base=0L

CPU计算物理地址的方式,段基址左移四位,再加上偏移地址。

当 CPU 切换到保护模式后,同样的代码,内存地址的计算方式不一样。

刚刚那个 ds 寄存器里存储的值,在实模式下叫做段基址,在保护模式下叫段选择子。段选择子里存储着段描述符的索引。

通过段描述符索引,可以从全局描述符表 gdt 中找到一个段描述符,段描述符里存储着段基址。

段基址取出来,再和偏移地址相加,就得到了物理地址,整个过程如下。

段寄存器(比如 ds、ss、cs)里存储的是段选择子,段选择子去全局描述符表中寻找段描述符,从中取出段基址

全局描述符表(gdt)长什么样?它在哪?怎么让 CPU 知道它在哪?

操作系统把这个位置信息存储在一个叫 gdtr 的寄存器中。

怎么存呢?就是刚刚那条指令。

1
lgdt    gdt_48

其中 lgdt 就表示把后面的值(gdt_48)放在 gdtr 寄存器中,gdt_48 标签,我们看看它长什么样。

1
2
3
gdt_48:
.word 0x800 ; gdt limit=2048, 256 GDT entries
.word 512+gdt,0x9 ; gdt base = 0X9xxxx

可以看到这个标签位置处表示一个 48 位的数据,其中高 32 位存储着的正是全局描述符表 gdt 的内存地址

0x90200 + gdt

gdt 是个标签,表示在本文件内的偏移量,而本文件是 setup.s,编译后是放在 0x90200 这个内存地址的,还记得吧?所以要加上 0x90200 这个值。

那 gdt 这个标签处,就是全局描述符表在内存中的真正数据了。

1
2
3
4
5
6
7
8
9
10
11
12
gdt:
.word 0,0,0,0 ; dummy

.word 0x07FF ; 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 ; base address=0
.word 0x9A00 ; code read/exec
.word 0x00C0 ; granularity=4096, 386

.word 0x07FF ; 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 ; base address=0
.word 0x9200 ; data read/write
.word 0x00C0 ; granularity=4096, 386

可以看出目前全局描述符表有三个段描述符,第一个为,第二个是代码段描述符(type=code),第三个是数据段描述符(type=data).

第二个和第三个段描述符的段基址都是 0,也就是之后在逻辑地址转换物理地址的时候,通过段选择子查找到无论是代码段还是数据段,取出的段基址都是 0,那么物理地址将直接等于程序员给出的逻辑地址(准确说是逻辑地址中的偏移地址)。

接下来我们看看目前的内存布局,还是别管比例。

这里我把 idtr 寄存器也画出来了,这个是中断描述符表,其原理和全局描述符表一样。全局描述符表是让段选择子去里面寻找段描述符用的,而中断描述符表是用来在发生中断时,CPU 拿着中断号去中断描述符表中寻找中断处理程序的地址,找到后就跳到相应的中断程序中去执行,具体我们后面遇到了再说。

好了,今天我们就讲,操作系统设置了个全局描述符表 gdt,为后面切换到保护模式后,能去那里寻找到段描述符,然后拼凑成最终的物理地址,就这个作用。当然,还有很多段描述符,作用不仅仅是转换成最终的物理地址,不过这是后话了。

第七回 | 六行代码就进入了保护模式

第七回 | 六行代码就进入了保护模式 (qq.com)

操作系统设置了个全局描述符表 gdt。

为后面切换到保护模式后,能去那里寻找到段描述符,然后拼凑成最终的物理地址。

而此时,我们的内存布局变成了这个样子。

setup.s

1
2
3
4
mov al,#0xD1        ; command write
out #0x64,al
mov al,#0xDF ; A20 on
out #0x60,al

简单理解,这一步就是为了突破地址信号线 20 位的宽度,变成 32 位可用。这是由于 8086 CPU 只有 20 位的地址线,所以如果程序给出 21 位的内存地址数据,那多出的一位就被忽略了,比如如果经过计算得出一个内存地址为

1 0000 00000000 00000000

那实际上内存地址相当于 0,因为高位的那个 1 被忽略了,地方不够。

当 CPU 到了 32 位时代之后,由于要考虑兼容性,还必须保持一个只能用 20 位地址线的模式,所以如果你不手动开启的话,即使地址线已经有 32 位了,仍然会限制只能使用其中的 20 位。

好了,接下来的一步,就是真正切换模式的一步了,从代码上看就两行。

1
2
3
mov ax,#0x0001  ; protected mode (PE) bit
lmsw ax ; This is it;
jmpi 0,8 ; jmp offset 0 of segment 8 (cs)

前两行,将 cr0 这个寄存器的位 0 置 1,模式就从实模式切换到保护模式了。

所以真正的模式切换十分简单,重要的是之前做的准备工作。

再往后,又是一个段间跳转指令 jmpi,后面的 8 表示 cs(代码段寄存器)的值,0 表示偏移地址。请注意,此时已经是保护模式了,之前也说过,保护模式下内存寻址方式变了,段寄存器里的值被当做段选择子。

回顾下段选择子的模样。

对照上面段选择子的结构,可以知道描述符索引值是 1,也就是要去全局描述符表(gdt)中找第一项段描述符。

1
2
3
4
5
6
7
8
9
10
11
12
gdt:
.word 0,0,0,0 ; dummy

.word 0x07FF ; 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 ; base address=0
.word 0x9A00 ; code read/exec
.word 0x00C0 ; granularity=4096, 386

.word 0x07FF ; 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 ; base address=0
.word 0x9200 ; data read/write
.word 0x00C0 ; granularity=4096, 386

我们说了,第 0 项是空值,第一项被表示为代码段描述符,是个可读可执行的段,第二项为数据段描述符,是个可读可写段,不过他们的段基址都是 0。

所以,这里取的就是这个代码段描述符,段基址是 0,偏移也是 0,那加一块就还是 0 咯,所以最终这个跳转指令,就是跳转到内存地址的 0 地址处,开始执行。

零地址处是什么呢?还是回顾之前的内存布局图。

就是操作系统全部代码的 system 这个大模块,system 模块怎么生成的呢?由 Makefile 文件可知,是由 head.s 和 main.c 以及其余各模块的操作系统代码合并来的,可以理解为操作系统的全部核心代码编译后的结果。

1
2
3
4
5
6
7
8
tools/system: boot/head.o init/main.o \
$(ARCHIVES) $(DRIVERS) $(MATH) $(LIBS)
$(LD) $(LDFLAGS) boot/head.o init/main.o \
$(ARCHIVES) \
$(DRIVERS) \
$(MATH) \
$(LIBS) \
-o tools/system > System.map

第八回 | 烦死了又要重新设置一边idt和gdt

第八回 | 烦死了又要重新设置一遍 idt 和 gdt (qq.com)

上回书咱们说到,CPU进入了32位保护模式。

首先配置了全局描述符表gdt和中断描述符表idt

1
2
lidt  idt_48
lgdt gdt_48

然后打开了A20地址线

1
2
3
4
mov al,#0xD1        ; command write
out #0x64,al
mov al,#0xDF ; A20 on
out #0x60,al

然后更改cr0寄存器开启保护模式

1
2
mov ax,#0x0001
lmsw ax

最后,一个干脆利落的跳转指令,跳到了内存地址 0 处开始执行代码。

1
jmpi 0,8

0 位置处存储着操作系统全部核心代码,是由 head.s 和 main.c 以及后面的无数源代码文件编译并链接在一起而成的 system 模块。

那接下来,我们就品品,正式进入 c 语言写的 main.c 之前的 head.s 究竟写了点啥?

head.s 文件很短,我们一点点品。

1
2
3
4
5
6
7
8
_pg_dir:
_startup_32:
mov eax,0x10
mov ds,ax
mov es,ax
mov fs,ax
mov gs,ax
lss esp,_stack_start

**_pg_dit,表示页目录**,之后在设置分页机制时,页目录会存放在这里,也会覆盖这里的代码。

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

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

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

1
2
3
4
5
6
7
8
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),后面的压栈操作,就是往这个新的栈顶地址处压咯。

继续往下看

1
2
3
4
5
6
7
8
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 上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 就同理了。我们就直接看设置好后的新的全局描述符表长什么样吧?

1
2
3
4
5
6
_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 程序中,这块内存区域之后就不会被其他程序用到并且覆盖了。


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