操作系统的状态机模型

操作系统也是程序。操作系统可以启动其他程序,那么又是哪个程序加载了操作系统?这是个鸡生蛋,蛋生鸡的问题。

计算机系统基础,模拟器实验。操作系统上的程序是由加载器加载的,操作系统也是程序,如果操作系统也是个 elf 文件,那也得有个加载器加载。这个加载器本身也是个程序,谁来加载呢?

动手写操作系统

自己写操作系统?

专业和爱好的区别。

软件和硬件的桥梁

最小的 c 程序

#include <sys/syscall.h>

.globl _start
_start:
  movq $SYS_write, %rax   # write(
  movq $1,         %rdi   #   fd=1,
  movq $st,        %rsi   #   buf=st,
  movq $(ed - st), %rdx   #   count=ed-st
  syscall                 # );

  movq $SYS_exit,  %rax   # exit(
  movq $1,         %rdi   #   status=1
  syscall                 # );

st:
  .ascii "\033[01;31mHello, OS World\033[0m\n"
ed:

这个最小程序可以用。现在有个问题,如何让他在没有操作系统的硬件上运行起来。

这就有两个问题

  • 加载到计算机上

  • 硬件上提供一些功能,类似 syscall

这个问题也很简单。为了让计算机能运行一些程序,那么软件的设计者和硬件的设计者坐在一起,达成一些约定,“程序就这样写”,就可以了。

回忆数字电路模拟器,数字电路是有 reset 的,这个 reset 是让整个状态机回到初始状态,这个状态是人设计好的,是确定的。

这就是 Bare-metal 与程序员的约定。硬件和软件的约定。计算机硬件也是状态机,CPU reset 后,处理器(里的寄存器)处于一个确定的状态。这个状态可以去 datasheet 上去查询。

这个状态,厂商制造出来就是这样的。更具体地讲,x86 情况加,cs、ip的指针值,这些东西会写在手册里。CPU 是无情的执行指令的机器(计算机系统基础)。

想要把硬件和软件连起来,就是让 reset 后的 pc 可以读到一条有效的指令。即启动后执行的第一条指令。

看intel的手册,x86 启动后

  • 寄存器的初始状态

    • EIP = 0x0000fff0

    • CS = 0xffff0000

实际上这些东西是看得见的。可以用 qemu 来调试,所有的状态都可以看得到。

CPU reset 后 主板厂商的代码就要开始执行了。根据x86手册,pc = ffff0h,一般来讲这里是个跳转指令,这时候状态机已经跑起来了。跑的还不是linux、windows,他们是灵活的,计算机可以启动u盘里的linux,u盘是主板厂商不知道的。这时候跑的是主板厂商的代码,厂商的代码是在 ROM 里写死的代码,不能随便改。这段代码会扫描计算机上所有的设备,可以配置一些东西比如启动项,从U盘启动还是硬盘启动,也可以配置CPU的东西比如关闭超线程、硬件虚拟化、大小核调频率。这个软件就叫 Firmware 固件。

CPU 执行的第一个软件是主板厂商提供的,因为主板厂商知道自己的主板用了什么芯片。

老的计算机 Legacy BIOS (Basic I/O System) ,现在系统更复杂了,是 UEFI (Unified Extensible Firmware Interface),我们甚至可以直接在 UEFI 上写操作系统,也挺好玩。

从学习的角度讲,学 BIOS 稍微简单一点,当然还是比 ARM RISC-V 复杂。CPU reset 程序就跑了,自己可以定义一块 ROM ,没有 firmware 我们写的程序就是 firmware。

Legacy BIOS,一些约定的东西。

Firmware 是计算机运行的第一个软件,必须提供机制,将用户数据载入内存

主要需要做的事情

  • 扫描系统里的硬件

  • 找到一个有操作系统的硬件

  • 加载操作系统

老的 Legacy BIOS 的启动方式:对于一块磁盘,flash、ssd、hhd。操作系统和firmware之间的约定是:启动磁盘的第一个 512 Byte 叫主引导扇区(MBR),这 512 Byte 要由 firmware 搬动到内存的一个确定的物理地址 7c00。这就是firmware和操作系统第一次也是唯一一次握手。

可以直接看看物理磁盘的前 512 Byte,最后一个字节是 aa55 ,表示可以启动。这也是 IBM 时代留下来的约定。当时是按顺序°磁盘的前512,是aa55代表可执行,不是就读下一个。如果全部没找到,那么就启动失败了,没找到可启动的设备(press any key to continue)。

今天的机器,即插即用,usb键盘插上就可以了。

所以 firmware 做的事情(bios的话),内存的 7c00 是磁盘的前512 字节,PC也指向了 7c00

  • Legacy BIOS 把第一个可引导设备的第一个扇区加载到物理内存的 7c00 位置

    • 此时处理器处于 16-bit 模式

    • 规定 CS:IP = 0x7c00, (R[CS] << 4) | R[IP] == 0x7c00

      • 可能性1:CS = 0x07c0, IP = 0

      • 可能性2:CS = 0, IP = 0x7c00

    • 其他没有约束

这 512 B 的代码就可以做更多事情了,再加载 1M 代码啥的,启动保护模式。最后把操作系统加载上。

所有的这些东西,理论,能不能看看代码?

如何去看这个代码呢?这也是专业人士该考虑的事情。

qume,调试从操作系统上电后的每一步操作。如今的好的工具链。VirtualBox 背后是这个,ffmpeg也是他的作品(格式工厂)

当然也有真机方案:JTAG debugger

这就解决了鸡和蛋的问题。

  • 主板上有个芯片,firmware代码存在里面

  • CPU reset 后加载 firmware

还有个很重要的用途,这个firmware还要给512Byte代码提供一些支持,比如说打印字符,有异常要有东西显示出来。也即提供一些api,帮助512读磁盘之类的。

以前 IBM PC 成功,因为五专利,每个人都可以造自己的兼容计算机。BIOS也很成功。

后来系统越来越复杂,需要把启动变得更安全。

以前设备接口是确定的,比如鼠标的PS/2接口。后来,有了USB鼠标,甚至USB接个蓝牙适配器,用蓝牙鼠标。这都不是标准设备。再比如USB指纹锁,需要在系统启动时就要能用。不能说操作系统能用再验证,这就晚了。

所以,有了 UEFI。

UEFI 上的操作系统加载,也变复杂了。机器先从UEFI启动,有额外要求

  • 有个 FAT32 (指令格式) 的分区

  • 盘必须按 GPT (GUID Partition Table) 方式格式化

  • Firmware 加载任意大小的 PE 可执行文件 .efi

    • 没有 legacy boot 512 字节限制

    • EFI 应用可以返回 firmware

有了前面的系统启动后,操作系统就可以来了。

操作系统的状态机模型

启动加载器(boot loader)会把操作系统代码放到内存里,然后设置pc值,这就跑起来了。这就解释了操作系统也是个C程序。Firmware 和 boot loader 共同完成 “操作系统的加载”

  • 初始化全局变量和栈;分配堆区 (heap)

  • 为 main 函数传递参数

    • 谁给操作系统传递了参数?

    • 如何实现参数传递?

进入C代码之后,完全就是C语言的事情了。

程序就是状态机,操作系统也是状态机。操作系统如果不使用AbstractMachine API的话,就是个普通的 C 程序。剩下的写操作系统,就是要理解api。

单核多线程和多核多线程没有什么本质上的不同。每个线程都有自己的栈。

一个单线程的 C 程序,执行了 mpe_init() 后,变成了什么样子?

多线程的api还是可以容易的去理解的。比较难处理的是 CTE 和 VME。

CPU1 终端,就是把当前执行的所有的线程,保存到 context 里。

VME 虚拟内存。就是一个“需要经过地址翻译”的模式。

读源码

最后更新于