操作系统学习笔记1 | 初识操作系统

本部分主要记录了计算机开机过程中操作系统的工作流程,并以此理解操作系统的代码结构。

参考资料:


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 和教材解释一下

  1. 为什么要从07c0 转移到90000:因为如果不转移,system 数据从10000-90000转移到00000的时候会覆盖07c0的程序,司令命令部队把自己司令部给趟平了,所以提前搬走

    这一点看了视频L3以及下面setup.s的代码理解就可以明白。

  2. 为什么system 数据要开始放在10000,因为bios 中断程序在00000开始存放,把system从磁盘读到内存之后的命令还是要用bios的中断的,所以要移动

  3. 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 是第一个。

    这就达到了目的。

  1. 每个子节点的依赖关系在下方会像 tools/system这样写出来,通过这种书写方式建立一整个树。

  2. 数据结构的后根遍历。

  3. 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位的汇编代码。

    image.png

  1. as86汇编:16位的Intel 8086汇编
  2. GNU as汇编:产生32位代码,采用 AT&T系统V语法。
  3. 另外在c代码中,可以内嵌汇编,达到精细控制的目的,这又是另外一种汇编

接下来会跳转到 main.c。从汇编跳到C,如何做到?

image.png

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功能:

  1. 初始化idt以及gdt,表示用各种数据结构管理硬件参数
  2. 向后交接给main.c

8. main.c 代码理解

下面就开始C语言程序了。

image.png

  • 传给main函数的三个形参(上一部分的p1~p3),分别是envp、argv、argc,但是main函数并没有使用;main如果返回,就会跳转到L6,但操作系统正常情况下一直在工作,永远不会退出;
  • main的工作就是xx_init:内存、中断、设备、时钟、CPU等内容的初始化,这里就可以看到熟悉的chr_dev_init()tty_init()等等

init 函数举例mem_init()函数:

  • mem_init()就是内存的初始化,如下图

image.png

  • 按照 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 初始化硬件管理器。

  • 笼统的说,这些步骤,就是做了两件事情

    1. 把操作系统代码读到内存中,读到内存中,CPU才可以取指执行
    2. 初始化工作,准备一些用于管理硬件设备的数据结构
  • 后面我们还会回过头来看这里准备的这些数据结构