操作系统学习笔记1 | 初识操作系统
本部分主要记录了计算机开机过程中操作系统的工作流程,并以此理解操作系统的代码结构。
参考资料:
- 课程:哈工大操作系统(本笔记对应前三讲)
- 实验:操作系统原理与实践_Linux – 蓝桥云课 (lanqiao.cn)
- 笔记:操作系统学习导引 · 语雀 (yuque.com)
1. 什么是操作系统
操作系统是计算机硬件和应用软件之间的一层软件,方便我们使用硬件(比如显存)、高效地使用硬件(如打开多个终端和窗口):
管理的硬件:
- CPU管理、内存管理
- 终端管理、磁盘管理
- 文件管理、网络管理
- 电源管理、多核管理
而组成一个操作系统最基本的是前五个。
学习操作系统可以有很多层次:
大部分人停留在第一层,即使用操作系统的接口。而计算机专业学生应当能够掌控计算机系统,真正理解操作系统的工作原理。
2. 计算机工作原理
探讨一个问题。打开计算机电源后,计算机的开机过程中发生了什么?
这也是实验一的内容。
要了解这个问题,首先要了解计算机的工作原理。
计算机是如何工作的?
首先是 图灵机。之前做过记录:计算机系统3-> 现代计算机基石 | 图灵机理论 – climerecho – 博客园 (cnblogs.com)
但是这样的图灵机还是太菜啦,一个图灵机只能做特定的一件事(因为控制逻辑是写死了的)
而 通用图灵机 可以看碟下菜,成为大厨。纸带上对控制器的控制逻辑进行编码,而控制器识别这样的编码,就能够完成我们需要执行的操作。
通用图灵机的功能就已经很像一个应用程序(程序)了。
接下来的冯诺依曼 存储程序 思想,将程序存入内存,按照需求将程序载入CPU(上图中的控制器)进行解释执行。
经典的 “取值执行”。
这样一个计算机就算搭建完成了,就像是大厨能够按照客人需求选择菜谱进行烹饪。
3. 开机过程理解
再回到开机过程的理解,计算机的工作归结于 “取指执行”,而所有的程序(包括操作系统),在开机前都放在磁盘上,如何取指执行呢?
- 打开电源,计算机执行的第一句指令是什么?即第一条指令对应的PC寄存器里的地址是多少?
以×86 PC 为例,
-
刚开机,会执行 BIOS 中的代码
BIOS,ROM BIOS映射区,是Basic Input Output System 的缩写。意思是计算机的内存里总要有一个基本的输入输出程序,否则内存空白一片,就无法开启冯诺依曼的”取值执行”。
-
而这段代码固化在 0xFFFF0 处
-
开机时,CS = 0xFFFF, IP= 0x0000
和保护模式对应,实模式的寻址CS:IP(CS左移4位+IP),这样 CS << 4 + IP 就正好是 0xFFFF0,正是内存刚上电时唯一有代码的地方,接着进行取值执行。
-
这段代码主要用于 检查 RAM,键盘,显示器,软硬磁盘
如果这段代码过不去,表示硬件出问题了。
-
将磁盘0磁道0扇区读入内存0x7c00处
1扇区 512字节
0磁道0扇区就正是操作系统的引导扇区,这个扇区中存放操作系统的第一段代码
开机时按住相关热键(不同设备不同)即可进入启动设备设置界面(俗称BIOS界面),可以设置为光盘启动,也可以从U盘等设备进入某个操作系统。
启动时设备信息被设置在 CMOS 中,CMOS是互补金属氧化物半导体64~128B,用来存储实时钟和硬件配置信息。
-
设置 CS=0x07c0, ip=0x0000,进行实模式寻址后跳到引导扇区的代码执行。
引导扇区的代码做了什么事情呢?
4. bootsect.s 代码理解
bootsect.s 就是上面所说的引导扇区的代码,是汇编代码。
因为高级语言(如C)无法具体指定硬件,特别是内存位置;而汇编则可以对硬件进行完整的控制。
这段代码经过汇编后得到机器代码,放在引导扇区。
划重点:所以bootsect.s的起始位置是0x7c00。后续理解会用到。
首先是固化的bootsect,需要把后续的代码引导出来。
mov ax, #BOOTSEG
mov ds, ax
# 将ds置为 07c0
mov ax, #INITSEG
mov es, ax
# 将es置为 9000
# 这是两个段寄存器,还需要偏移才能寻址
mov cx, #256
sub si, si
sub di, di
# 加入偏移,偏移通过自减产生,为0
# 根据上面提到过的实模式的CS:IP寻址
# 此时ds:si=7c00,es:di= 90000
rep movw
# rep:重复执行,直到cx=0
# 意思是移动字,一共移动256个字(cx处有说明,也正好是512字节)
# movw: 将DS:SI内容复制到ES:DI中即从7c00
# DS和ES一个是源数据段寄存器,另一个是目的数据段寄存器
jmpi go, INITSEG
# 段间跳转指令,cs=INITSEG,IP=go
# go是一个标记,替代一个具体的地址,编译后就会分配到我们指定的地址
# 这一点具体计算机组成原理有提到过,但我还没整理出来,就是从汇编代码起始的地址,到这个标号处,标号标记了此处的地址。比如说到go这个标签处,go是200地址
# INITSEG 上面提到过,是0x9000
# 这样根据寻址,90000+200
# bootsect.s现在就挪到了 900200,在这里相当于顺序向下执行。
# 但是必须写这句话,因为代码在那个地方。
这段代码要决定接下来setup的读入情况。
# 接下来的代码略讲,看一些重点的
#
# 这段代码是用于读入setup区的(分区图见上图)
go:mov ax,cs
#cs是0x9000
mov es,ax
mov ss,ax
mov sp, #0xff00
# 为call准备(具体后面会使用这一块的内容)
load_setup:
mov dx,#0x0000
mov cx,#0x0002
mov bx,#0x0200
mov ax,#0x0200+SETUPLEN
# 0x13是BIOS读磁盘扇区的中断:ah=0x02-读磁盘,al=扇区数量(SETUPLEN=4) ch=柱面号,dh=磁头号,dl=驱动器号,es:bx=内存地址
int 0x13
# 现在只是读入了引导扇区用十三号中断读入操作系统其他的内容
# 需要知道从哪里读:cl开始扇区,即mov cx,#0x0002,读取cx的低8位是2。
# 理解一下,boot扇区占了第1个扇区,所以从第2个扇区读。
# 需要知道读多少扇区:ax,0x0200,高八位作为ah,低八位作为al
# 所以是从第二个扇区开始读4个扇区
# 需要直到读到哪里,es:bx告知读到哪里
# 从go标签处得知,cs赋值给了ax,ax赋值给了es,cs是0x9000,而bx是0200
# 所以基址是0x9000,偏移是0x0200.意思就是把setup的四个扇区读进来
jnc ok_load_setup
mov dx,#0x0000
mov ax,#0x0000
int 0x13
j load_setup
ok_load_setup:
mov dl,#0x00
mov ax,#0x0800
int 0x13
mov ch,#0x00
mov sectors,cx
mov ah,#0x03
xor bh,bh
int ox10
# 这句是这段代码的关键,进行BIOS的10号中断,是一个显示字符的BIOS中断,用于在屏幕上输出。
# 具体参数不再介绍,回头单独介绍吧。
mov cx,#24
# 显示的字符数为24
mov bx,#0x0007
# 7是显示属性
mov bp,#msg1
#msg1是用于显示的内容所在的内存地址,见下面的data段
# 意思就是把下面的msg1段显示到光标位置
# Windows开机时的logo就是这一段代码的作用(好看了一些)
mov ax,#1301
int 0x10
mov ax,#SYSSEG
mov es,ax
call read_it
# 读入 system 模块
jmpi 0,SETUPSEG
# 转入0x9020:0x0000,接下来执行setup.s
bootsect.s 中的data段/数据:
sectors: .word 0
msg1:.byte 13,10
.asscii "Loading system"
.byte 13,10,13,10
# 根据这一段就可以修改开机显示的内容,比如改为:"CliviaOS is loading"
# 不过要记得修改显示的字符长度,在上面的mov cx,#24的地方修改一下
# 修改之后这个系统重新编译,再开机就可以看到更改效果
这也是实验二的内容,回头会把实验二整理出来。
下面读入 system 模块。
read_it: mov ax,es
cmp ax,#ENDSEG
#ENDSEG=SYSSEG+SYSSIZE,
#SYSSIZE=0x8000,这个变量可以根据image的大小设定(编译操作系统的时候)
jb ok1_read
ret
ok1_read:
mov ax,sectors
sub ax,sread
# sread是当前磁道已读扇区数,ax是未读扇区数
call read_track
-
值得注意的是,除了函数
read_it
,读入 system 模块为什么还要定义一个函数ok1_read
?因为 system 模块可能很大,要跨越磁道,所以要处理这个问题。
-
在引导扇区的末尾,BIOS需要这段代码识别引导扇区
.org 510 .word oxAA55 #扇区的最后两个字节,否则会打出非引导设备
-
接下来需要将控制权交给 setup.s,怎么交接呢?
使用跳转,即修改PC;
setup模块放在 0x90200 处,所以cs=9020,ip=0
SETUPSEG在上面的图中就正是9020
这样就实现了跳转
jmpi 0,SETUPSEG # ip=0,cs=SETUPSEG,
综上,开机的图标背后做了什么事情,大概上理解一下:
- 打出Logo
- 把 setup 和system 区的代码读进来。
- 一些别的事情…
5. bootsect.s 补充解释
其实不同系统不同版本的bootsect.s都会有差别,所以上面的代码不必死记,但是这是我接触到的与操作系统相关的第一段代码,所以认真整理了下。
摘自一些我觉得有用的弹幕。
可以参考《Linux内核完全注释V3.0》203页,有详细注释。
老师没讲但很重要的几个点结合linux0.11 和教材解释一下
-
为什么要从07c0 转移到90000:因为如果不转移,system 数据从10000-90000转移到00000的时候会覆盖07c0的程序,司令命令部队把自己司令部给趟平了,所以提前搬走
这一点看了视频L3以及下面setup.s的代码理解就可以明白。
-
为什么system 数据要开始放在10000,因为bios 中断程序在00000开始存放,把system从磁盘读到内存之后的命令还是要用bios的中断的,所以要移动
-
bios的终端每次启动都会又BIO rom的程序初始化一次,不用担心下次启动的时候没有
6. setup.s 代码理解
setup模块依然是setup.s汇编代码,依然对启动过程进行精细控制,相当于编程中的初始化,底层硬件的参数初始化操作系统。
6.1 初始化操作系统并移动
start:mov ax,#INITSEG
mov ds,ax
mov ah,#0x03
xor bh,bh
int 0x10
mov [0],dx
mov ah,#0x88
int 0x15
# 本段代码重点,是一个BIOS中断,获取物理内存的大小
# 使用#0x88作为参数,获取的值放入ax中,ax 赋给 [2]
mov [2],ax
# 这是间接寻址,段寄存器左移 4 位再 +2,即0x90002
# 而段寄存器现在指向9000
# 将 ax 中内容传递至内存地址 ds:[2] 处 即 0x90002 处,
# ax 中保存的值为调用 int15 中断后获取的扩展内存大小
# 操作系统是要管理内存的,所以有必要知道内存的大小。
# 这就是setup的意义,要让操作系统知道计算机底层的模样。
# 操作系统会形成很多数据结构来管理上图表格中这些参数。
cli
# 不允许中断
mov ax,#0x0000
cld
#########################重点提醒###########################
# 下面还是做一个移动
# 移动system模块到0x0000的位置,共计0x8000的地址空间,将来操作系统的代码将一直放在这个位置
# 此前 5.bootsect.s 补充解释中提到的也是这里
# 回顾前一小节中,bootsect代码首先会将自身从0x07c0:0x0000处移动到0x9000:0x0000处,接下来读入的setup模块也紧跟在移动后的bootsect代码后,这么做就是为了给此时将system放在0x0000~0x8000腾出空间
#########################重点提醒###########################
do_move:mov es,ax
#ax=0,赋值给了es
add ax,#0x1000
cmp ax,#0x9000
jz end_move
mov ds,ax
sub di,di
sub si,si
mov cx,#0x8000
rep
movsw
jmp do_move
# 此后,内存从0开始的地址存放的都是操作系统,在此之上的是应用程序。
扩展内存:
扩展内存是 ram 中高于 1MB 的部分。Intel 刚出来的时候是1MB,后来把大于这部分的内存都称为扩展内存。
拓展阅读:《Linux0.11内核剖析》,能够对操作系统的全貌有所了解。
setup.s 到此做了两件事,1 是把操作系统进行挪动,2 是初始化操作系统,使其能够管理底层硬件。
6.2 进入保护模式
接下来,操作系统应当继续向下执行,setup 还做了一件重要的事:
- 进入保护模式(此前是实模式)
call empty_8042
mov al,#0xD1
out #0x64,a1
call empty_8042
mov a1,#0xDF
out #ox60,a1
mov ax,#0x0001
mov cr0,ax
jmpi 0,8
empty_8042:
.word 0x00eb,0x00eb
in al,#0x64
test al,#2
jnz empty_8042
jnz empty_8042
ret
来看这一句:
jmpi 0,8
- 这句代码是重点,也是操作系统中的理解重点
- 如果还按照上面的寻址模式,则会跳转到0x80,属于system模块,会死机
- 所以实际上并不是上面的寻址模式,cs<<4+ip,cs和ip都是16位寄存器,这种寻址最多只能达到20位地址 = 1M,不适用动辄内存4G的计算机。
- 所以需要从16/20位(1M)切换到32位(4G),后者就是保护模式
- 那么如何做到这种切换呢?即切换寻址模式,切换CPU的解释方式,切换为另一条电路?
这两句:
mov ax,#0x0001
mov cr0,ax
-
提到了一个寄存器cr0,这个寄存器的最后一位PE,如果是0,则为实模式;如果是1,则为保护模式。
-
可见,这两句代码就是把末位赋1,走了另一条电路
-
而这个电路如何解释执行,涉及一个著名概念 gdt.
这个功能由硬件实现,目的就是快。
-
cs此时被称为 选择子(selector) ,存放查表的索引,真正的地址放在表项里。
cs=8是要选择表中的项,再从表中取出基址,与ip相加得到地址,这时得到的就是32位地址。
这个表就是著名的 gdt表(global description table)
-
表中的内容从何而来呢?是由setup.s来做的。
#这段代码简单讲
end_move:mov ax,#SETUPSEG
mov ds,ax
lidt idt_48
lgdt gdt 48
idt_48:.word 0
.word 0,0
gdt_48:.word 0x800
.word 512+gdt,0x9
## 这一段就是初始化表
gdt:.word 0,0,0,0
# 注意,一个word16位,所以一行作为一个表项就是64位
.word 0x07FF,0x0000,0x9A00,0x00C0
# 寻址的时候以字节做索引,1个字节8位,所以cs=8就是这个第二行起始处
.word ox07FF,0x0000,0x9200,0x00C0
通过上面代码几个指令的组合(相对固定),就能得到这个gdt表,再结合上面那一段更改寻址方式的指令,就能够实现jmpi 0,8
现在来谈中断,中断处理也与上面类似。
也是以上面的方式去寻找中断函数的入口地址。
这一点再下一部分操作系统接口会再提到。
6.3 gdt 查表方式理解
上面我们得知,jmpi 0,8
使用gdt 查表,查到的是下面代码中的第2行word,而怎么解释这个表项则是由硬件规定的。
.word的四个地址是如何体现在GDT表项中的呢?
- 注意看GDT表项的四个段标注
- 段基址31…24对应0x00C0的高位00
- 段基址23…16对应0x9A00的低位00
- 段基址15…0对应0x0000
- 段限长15…0对应0x07FF
- 合起来,段基址就是全零。
- 所以这个表项的意思是,jmp 到内存0x0000处,接下来去0地址处执行,也就是前面移动过的system模块。
也就是大端寻址。
这里存放的不连续是因为硬件设计的历史原因。
前面提到过的bootsect模块和setup模块都是由其相应的.s文件编译过来的,而system模块一定有很多文件,我们要保证接下来进行的是system的第一段代码;也就是head.s,
- 操作系统的代码最后必须是:boot、setup、system这样的过程
- 这些过程的严丝合缝,才能保证操作系统顺利开机,否则就死机了。
所以我们要编写编译操作系统的控制代码——Makefile。
6.4 Makefile
Makefile可以控制最终生成的代码的组织结构,然后按照前述的顺序放在硬盘的前面几个扇区中。
我们通常把操作系统编译后的样子称为 image,image 中就是上面所说的代码结构,指定放在0磁道0扇区。
-
Makefile是一种树状结构
-
image 是依赖于上图中的 boot/bootsect tools/system tools/build … 产生
相当于父节点依赖于子节点,每个子结点完成了,最终整个树才能建立。
-
而image 的这些子节点还会依赖于它的子节点,比如tools/system依赖于boot/head.o init/main,o $(DRIVERS) 等等
-
上面提到过的这些子节点还会依赖于 head.s等文件。
-
当所有子节点完成后,通过
(LD)boot/head.o init/main.o $DRIVERS ... -o tools/system
链接起来,来构建父节点 -
再向上创建 image
使用build,具体参见 Linux 源码
-
而在整个树中,head.s 是第一个。
这就达到了目的。
每个子节点的依赖关系在下方会像
tools/system
这样写出来,通过这种书写方式建立一整个树。数据结构的后根遍历。
system模块的第一部分代码是head.s,head.s执行完后再执行main.c
7. head.s 代码理解
-
再次初始化IDT和GDT表
之前的IDT 和 GDT 被建立起来只是为了临时完成jmpi 0,8 这条指令,而之后操作系统要开始真正工作。
-
其他如开启20号地址线 ,就不再探讨细节。
-
上图中,head.s的代码和之前看到的bootsect.s和setup的代码不太一样,多了很多%eax,而不再是ax。
这是因为head.s是运行在保护模式(32位模式)下的,是32位的汇编代码,而bootsect.s和setup的代码是16位的汇编代码。
- as86汇编:16位的Intel 8086汇编
- GNU as汇编:产生32位代码,采用 AT&T系统V语法。
- 另外在c代码中,可以内嵌汇编,达到精细控制的目的,这又是另外一种汇编
接下来会跳转到 main.c。从汇编跳到C,如何做到?
-
与C语言之间的跳转没有区别,C语言本身还是汇编,通过栈来完成。
复习C语言压栈
运行时栈是从高地址拓展到低地址的,是从上(顶)到下(底)压栈。
下图中的左上角栈图中下面是栈顶,并不冲突,倒着看就可以了,下图代码就是:先压入p3,p2,p1(3个0),返回地址(L6),main
after_page_tables:
#压栈
push1 $0
push1 $0
push1 $0
push1 $L6
push1 $_main
#压栈结束后跳转到set_paging
jmp set_paging
L6: jmp L6
setup_paging:#设置页表代码#
ret
## 设置页面setup_paging的具体代码这里省略,后面再讲
- 可见head.s也是在压栈,所以从head.s跳转到main.c实际上很简单,就是把参数和main.c的地址压入栈中
- 在设置页表的代码(即setup_paging模块)执行完后,会执行 ret返回指令,那么就把栈中 main.c 的地址作为返回地址,达到了跳转到main.c的效果
- 接下来就是执行上面压栈的内容,即执行main.c。
- 如果main.c执行结束,会跳转到L6,L6是一个死循环;实际上,正常情况下,main.c就会一直运行下去,不会执行结束,如果main.c结束了,就会跳到L6,表现的结果就是计算机死机了
总结一下head.s功能:
- 初始化idt以及gdt,表示用各种数据结构管理硬件参数
- 向后交接给main.c
8. main.c 代码理解
下面就开始C语言程序了。
- 传给main函数的三个形参(上一部分的p1~p3),分别是envp、argv、argc,但是main函数并没有使用;main如果返回,就会跳转到L6,但操作系统正常情况下一直在工作,永远不会退出;
- main的工作就是xx_init:内存、中断、设备、时钟、CPU等内容的初始化,这里就可以看到熟悉的
chr_dev_init()
,tty_init()
等等
init 函数举例mem_init()
函数:
- mem_init()就是内存的初始化,如下图
-
按照 4K 为单位对内存进行划分区域(页),mem_map数组是表示内存区域是否被使用的一个表格;
2的12次方也就是4K,这就是 页 的初始化
-
end_mem其实就是总内存大小,那这个参数是从哪里来的呢?我们之前讲setup的时候,说了会读取内存大小放在0x90002的位置,就是从这里来的。
妙蛙。
-
下面的代码,首先将mem_map全部初始化为USED,然后将start_mem到end_mem之间的内存区域设置为0,即未被使用
-
mem_map前面的部分是USED,这是操作系统代码和一些管理硬件的数据结构所占用的内存
9. 总结
-
前面代码部分,分析了bootsect.s、setup、head.s、main.c、mem_init()
bootsect.s 将操作系统从磁盘读入,setup.s 获得参数,启动保护模式,head.s 初始化页表,main.c 初始化硬件管理器。
-
笼统的说,这些步骤,就是做了两件事情
- 把操作系统代码读到内存中,读到内存中,CPU才可以取指执行
- 初始化工作,准备一些用于管理硬件设备的数据结构
-
后面我们还会回过头来看这里准备的这些数据结构