操作系统真相还原笔记

inode_sync  # bochs 使用

编译汇编代码。

nasm -I include/ -o mbr.bin mbr.S 

将生成的 mbr.bin 写入我们的虚拟硬盘

dd if=./mbr.bin of=/your_path/bochs/hd60M.img  bs=512 count=1 conv=notrunc 

同理将loader.bin写入硬盘

nasm -I include/ -o loader.bin loader.S && dd if=./loader.bin  of=./hd60M.img bs=512 count=3 seek=2 conv=notrunc

修改配置文件bochs.disk

megs: 32
# 第二步,设置对应真实机器的 BIOS 和 VGA BIOS。
# 对应两个关键字为: romimage 和 vgaromimage
romimage: file=/bochs/bochs/share/bochs/BIOS-bochs-latest
vgaromimage: file=/bochs/bochs/share/bochs/VGABIOS-lgpl-latest
# 第三步,设置 Bochs 所使用的磁盘,软盘的关键字为 floppy。
# 若只有一个软盘,则使用 floppya 即可,若有多个,则为 floppya, floppyb…
#floppya: 1_44=a.img, status=inserted
# 第四步,选择启动盘符。
#boot: floppy #默认从软盘启动,将其注释
boot: disk #改为从硬盘启动。我们的任何代码都将直接写在硬盘上,所以不会再有读写软盘的操作。
# 第五步,设置日志文件的输出。
log: bochs.out
# 第六步,开启或关闭某些功能。
# 下面是关闭鼠标,并打开键盘。
mouse: enabled=0
keyboard: type=mf, serial_delay=250
# map=/bochs/bochs/share/bochs/keymaps/x11-pc-us.map
# 硬盘设置
ata0: enabled=1, ioaddr1=0x1f0, ioaddr2=0x3f0, irq=14
# 下面的是增加的 bochs 对 gdb 的支持,这样 gdb 便可以远程连接到此机器的 1234 端口调试了
# gdbstub: enabled=0, port=1234, text_base=0, data_base=0, bss_base=0
ata0-master: type=disk, path="hd60M.img", mode=flat

运行bochs

sudo ./bochs -f bochs.disk

调试

实模式下查看从0xffff0开始3个字节的内存

xp/3b 0xffff0

查看从0xffff0开始一个命令的反汇编

u/1 0xffff0

实模式寄存器

在这里插入图片描述

在这里插入图片描述

进入保护模式

jmp dword SELECTOR_CODE:p_mode_start
[bits 32]
82 p_mode_start:
83 mov ax, SELECTOR_DATA
84 mov ds, ax
85 mov es, ax
86 mov ss, ax
87 mov esp,LOADER_STACK_TOP
88 mov ax, SELECTOR_VIDEO
89 mov gs, ax
90
91 mov byte [gs:160], 'P'
92
93 jmp $

先利用跳转指令清空流水线,更新代码段寄存器和代码段段描述符缓冲寄存器
然后更新数据段,显存段寄存器

启动过程

  • bios 在0xFFFF0自动运行,从第一个扇区加载mbr程序到0x7c00,然后跳转到此处执行mbr
  • mbr 负责从磁盘读取loader.bin 程序加载到LOADER_BASE_ADDR, 然后跳转到此处执行loader
  • loader负责创建段描述符,并把选择子放入对应的段寄存器中,打开保护模式。创建页目录表,页表并打开分页模式。把由c程序编译的二进制kernel文件先从磁盘中读到缓冲区,然后根据其中的elf把kernel中的程序段segment加载到对应的内存地址中,然后跳转到elf中记录的kernel入口地址执行kernel. 入口地址需要在链接时指定,且要求和loader.S中的跳转地址保持一致。
    所有内核线程公用内核物理内存池和内核虚拟内存池,用户进程共享用户用例内存池。

指令

vstart,$,$$

vstart指明段内汇编地址起始位置 该段内的汇编的地址都是从vstart开始算的
$代表当前行行首的标号 $$代表当前段的起始汇编地址
$-$$ 代表当前位置在段内的偏移

NOP

指令即空指令
运行该指令时单片机什么都不做,但是会占用一个指令的时间

shr,shrl

shl和shr是逻辑移位指令。

shl是逻辑左移指令,它的功能为:

(1)将一个寄存器或内存单元中的数据向左移位;

(2)将最后移出的一位写入CF中;

(3)最低位用0补充。

cmp

cmp oprd1,oprd2

为第一个操作减去第二个操作数,但不影响第两个操作数的值,它影响flag的CF,ZF,OF,SF.
CF 无符号操作溢出
ZF 零标志
SF 符号标志,最近的操作得到的结果为负数
OF 最近操作导致一个补码溢出。

若执行指令后:ZF=1,则说明两个数相等,因为zero为1说明结果为0.

  • 当无符号时:

若CF=1,则说明了有进位或借位,cmp是进行的减操作,故可以看出为借位,所以,此时oprd1<oprd2;

CF=0,则说明了无借位,但此时要注意ZF是否为0,若为0,则说明结果不为0,故此时oprd1>oprd2.

  • 当有符号时:

若SF=0,OF=0 则说明了此时的值为正数,没有溢出,可以直观的看出,oprd1>oprd2;

若SF=1,OF=0 则说明了此时的值为负数,没有溢出,则为oprd1<oprd2;

若SF=0,OF=1 则说明了此时的值为正数,有溢出,可以看出oprd1<oprd2,因为此时oprd1为正数,oprd2为负数;

若SF=1,OF=1则说明了此时的值为负数,有溢出,可以看出oprd1>oprd2,因为此时oprd1为负数,oprd2为正数;

最后两个可以作出这种判断的原因是,溢出的本质问题:

两数同为正,相加,值为负,则说明溢出

两数同为负,相加,值为正,则说明溢出

故有,正正得负则溢出,负负得正则溢出

loop

循环cx寄存器值次

db dd dd

db定义字节类型变量,一个字节数据占1个字节单元,读完一个,偏移量加1

dw定义字类型变量,一个字数据占2个字节单元,读完一个,偏移量加2

dd定义双字类型变量,一个双字数据占4个字节单元,读完一个,偏移量加4

db, dw, dd在编译期间已经执行完了,因为是数据段,所以cpu执行期间需要跳过他们所在的行

byte word dword

byte是字节,也就是8位。用来储存char或者char类型指针。
word是字,也就是16位。用来储存16位整数或者16位地BGVVVVVVVVV址。
dword是双字,也就是32位。可以用来储存32位整数或者32位内存地址。

movsb movsw movsd cld std

在这里插入图片描述

CPU 只能和一个 IO 接口通信 , 南桥芯片 ICH 负责仲裁
南桥用于连接 pci、pci-express、AGP 等低速设备,
北桥用于连接高速设备,如内存。

b,w,d 分别代表一次移动1字节,2字节,4字节
rep movsb 的意思是把ESI 处的1字节复制到EDI处,并重复 ecx次,
若eflags寄存器的df位为0 则每次复制结束esi和edi的地址加1(对应movsb,movsw则加2),df位为1则减1。 cld可以另df位置0,std置1.

ins 和outs 端口号保存在dx中
另外,ins[bwd]从端口读入数据到内存的目的地址,故只涉及到 edi 的自增自减。 outs[bwd]把内存中的源数据写入端口,故只涉及到 esi 的自增自减。
lods[bwd]把内存中的源数据加载到寄存器 al、 ax 或 eax,自增自减操作也只涉及 esi。
而 stos[bwd]将 al、
ax、 eax 中的值写入到内存中的目的地址,故也只涉及 edi 的自增自减。

 41 /* 将从端口 port 读入的 word_cnt 个字写入 addr */ 
 42 static inline void insw(uint16_t port, void* addr, uint32_t word_cnt) {
    
     
 43 /****************************************************** 
 44 insw 是将从端口 port 处读入的 16 位内容写入 es:edi 指向的内存, 
 45 我们在设置段描述符时,已经将 ds,es,ss 段的选择子都设置为相同的值了, 
 46 此时不用担心数据错乱。 */ 
 47 asm volatile ("cld; rep insw" : "+D" (addr), "+c" (word_cnt) 
: "d" (port) : "memory"); 
 48 /************************

call,ret,retf,iret

ret( return)指令的功能是在栈顶(寄存器 ss: sp 所指向的地址)弹出 2 字节的内容来替换 IP 寄存器,

retf( return far)是从栈顶取得 4 字节,栈顶处的 2 字节用来替换 IP 寄存器,另外的 2 字节用来替换
CS 寄存器。
ret 和 call 指令是需要配合使用的,注意了,是 ret 要以 call 为主,根据 call 的种类来选择 ret 或 retf。
call 的种类从大方向上分也就两种,一是近调用,另一个是远调用。

iret
iret 指令因此有了两个功能,一是从中断返回,另外一个就是返回到调用自己执行的那个旧任务,这也相当于执行一个任务。那么问题来了,对同一条 iret 指令, CPU 是如何知道该从中断返回呢,还是返回到旧任务继续执行呢?
这就用到 NT 位了,当 CPU 执行 iret 时,它会去检查 NT 位的值,如果 NT 位为 1,这说明当前任务是被嵌套执行的,因此会从自己 TSS 中“上一个任务 TSS 的指针”字段中获取旧任务,然后去执行该任务。如果 NT 位的值为 0,这表示当前是在中断处理环境下,于是就执行正常的中断退出流程

in out

IN AL,21H;表示从21H端口读取一字节数据到AL
  IN AX,21H;表示从端口地址21H读取1字节数据到AL,从端口地址22H读取1字节到AH
  MOV DX,379H
  IN AL,DX ;从端口379H读取1字节到AL
  OUT 21H,AL;将AL的值写入21H端口
  OUT 21H,AX;将AX的值写入端口地址21H开始的连续两个字节。(port[21H]=AL,port[22h]=AH)
  MOV DX,378H
  OUT DX,AX ;将AH和AL分别写入端口379H和378H

panic assert

assert内部调用panic , panic 内部关中断,输出字符串,无限while循环

esp

在这里插入图片描述

1 int a = 02 function(int b, int c) {
    
    
3 int d;
4 }
5 a++;

1)调用 function(1,2);按照 C 语言调用规范,参数入栈的顺序从右到左:会先压入 2,再压入 1。
每个参数在栈中各占 4 字节。
( 2)栈中再压入 function 的返回地址,此时栈顶的值是执行“ a++”相关指令的地址。
下面是堆栈框架的指令。
( 3) push ebp;将 ebp 压入栈,栈中备份 ebp 的值,占用 4 字节。
( 4) mov ebp, esp;将 esp 的值复制到 ebp, ebp 作为堆栈框架的基址,可用于对栈中的局部变量和其
他参数寻址。
此时的 ebp 便是栈中局部变量的分界线。在这之后, esp 将自减一定的值为局部变量在栈中分配空间,
该值取决于所有局部变量空间大小的总和。
( 5) sub esp,4; 由于函数 function 中有局部变量 d, 局部变量是在栈中存放的, 故 esp 要预留出 4 字节,
专门给变量 d 使用。
终于到了应用 ebp 指针的时候,以 ebp 为基址对栈中数据寻址。
[ebp-4]是局部变量 d,对应上面的第( 5)步。
[ebp]是 ebp 在栈中的备份,对应上面的第( 3)步。
[ebp+4]是函数的返回地址,对应上面的第( 2)步。
函数中的参数 b 是用[ebp+8]访问,参数 c 用[ebp+12]访问,对
应上面的第( 1)步。
栈中数据的布局如图 3-8 所示。
( 6)函数结束后跳过局部变量的空间: mov esp, ebp。
( 7)恢复 ebp 的值: pop ebp。
至此函数中堆栈框架的指令结束了,然后是返回指令 ret,接着主调函数中执行“add esp,8”来回收参
数 b 和 c 的空间。

保护模式下
在这里插入图片描述

符号溢出

在有符号数中,首位决定是正数还是负数,由8位扩展到16位时,要自动扩展首位。
溢出符号假定使用的是有符号数,在最高位有进位时of=1

16进制

0xC0000000 3G
0x100000 1M
0X1000 4K
0xffc00000 高10位为0x3ff在二进制下全为1
2^10=1000=0x400
实模式下 地址 0~0x9FFFF 处是 DRAM

在这里插入图片描述CR 是 0x0d, LF 是 0x0a
CR(Carriage Return)表示回车
LF(Line Feed)表示换行
BS(backspace)的 asc 码是 8

elf

Executable and Linkable Format,可执行链接格式 linux下二进制文件格式标准
elf 可以是待重定向的文件,共享目标文件和可执行文件
在NASM汇编语言中SECTION和SEGMENT是同义词,也就是完全相同。
汇编过程负责把汇编语言中具有相同关键字的section编译成目标文件elf中的section
链接过程负责把属性相同的节合并成段(segment),方便保护模式下的特权检查。
c语言中的节有三种属性:
(1)可读写的数据,如数据节.data 和未初始化节.bss。
(2)只读可执行的代码,如代码节.text 和初始化代码节.init。
(3)只读数据,如只读数据节.rodata,一般情况下字符串就存储在此节。
程序头表的条目是用来描述段(segment)的 待重定位文件没有程序头表
在这里插入图片描述ELF header 结构
在这里插入图片描述
e_entry 占用 4 字节,用来指明操作系统运行该程序时,将控制权转交到的虚拟地址。
e_phoff 占用 4 字节,用来指明程序头表( program header table)在文件内的字节偏移量。如果没有程序头表,该值为 0。
e_ehsize 占用 2 字节,用来指明 elf header 的字节大小。
e_phentsize 占用 2 字节,用来指明程序头表( program header table)中每个条目( entry)的字节大小,
即每个用来描述段信息的数据结构的字节大小,该结构是后面要介绍的 struct Elf32_Phdr。
e_phnum 占用 2 字节,用来指明程序头表中条目的数量。实际上就是段的个数。

program header :
在这里插入图片描述
p_offset 占用 4 字节,用来指明本段在文件内的起始偏移字节。
p_vaddr 占用 4 字节,用来指明本段在内存中的起始虚拟地址。
p_filesz 占用 4 字节,用来指明本段在文件中的大小。
p_memsz 占用 4 字节,用来指明本段在内存中的大小。

elf 加载程序:
把elf文件加载到内存BIN_BASE_ADDR,
根据e_phoff+BIN_BASE_ADDR拿到程序头表的地址
依次根据BIN_BASE_ADD+p_offset加载程序段到内存中的虚拟地址p_vaddr

跳转到e_entry 开始执行二进制文件。

gcc -c -o kernel/main.o kernel/main.c,的作用是编译、汇编到目标代码,不进行链接,也就是直接生成目标文件。
ld kernel/main.o -Ttext 0xc0001500 -e main -o kernel/kernel.bin 用于链接成二进制可执行文件elf, -Ttext指定起始虚拟地址 -e 来指定起始的函数名
链接器规定,当多个文件链接成一个可执行文件时,默认只把名为_start 的函数作为程序的入口地址。
readelf –e /bin/find 查看 find 程序的 elf 头

特权级

Task State Segment(TTS)可以记录特权级从低到高转换时对应的目标栈, 所以只用记录0,1,2特权级的栈就行了。
CPL(Current Privilege Level) 处理器当前的特权级
记录在CS寄存器的低两位中(RPL)

DPL 是段描述符记录的特权级
RPL 是段选择子中记录的特权级
在这里插入图片描述
门结构是一段描述符,记录了另一段目标代码段的选择子或TSS, 只要CPL<=门的DPL,那么进入目标代码段后,处理器将以目标代码段 DPL 为当前特权级 CPL,实现特权级的提升。
只能通过中断和调用门的返回从高特权级进入地特权级。

调用门由 call 指令或 jmp 指令后接门描述符选择子来调用,偏移量被忽略。Call 调用门时,会自动将转移前的SS和ESP,调用们的参数,以及转移前的CS和EIP压入TSS指向的高特权级栈。转移到新栈和新特权级后,操作系统会根据栈中保存的CS_old的rpl修改所有新提交的数据段选择子的rpl,防止用户修改内核数据段,这里利用了arpl 指令。
retf时,先弹出CS和EIP并加载,然后根据retf的参数跳过栈中参数,加载旧的SS和ESP

在这里插入图片描述

用户可以给目标代码段提交自己的选择子用于数据的修改,为了防止用户提交内核数据段的选择子,特权检查还要加上RPL, 即CPL≤DPL 并且 RPL≤DPL

在一般情况下,如果低特权级不向高特权级程序提供自己特权级下的选择子,也就是不涉及向高特权级程序“委托、代理”办事的话, CPL 和 RPL 都来自同一程序。但凡涉及“委托、代理”,进入 0 特权级后, CPL 是指代理人,即内核, RPL 则有可能是委托者,即用户程序,也有可能是内核自己。

处理器的特权检查,都是只发生在往段寄存器中加载选择子访问描述符的那一瞬间.

  • 不通过调用们直接访问代码和数据时的特权检查:

    受访者为非一致性代码段,只能平级访问,唯一从高特权降到低特权运行的情况:处理器从中断处理程序中返回到用户态的时候。

    受访者为一致性(Conforming)代码段,CPL≥DPL & RPL≥DPL,用来实现从低特权级的代码向高特权级的代码转移,但是CPL并不会被DPL替换,而是和之前保持一致。

    受访者为数据段时CPL ≤目标数据段 DPL && RPL ≤ 目标数据段 DPL

    往段寄存器 SS 中赋予数据段选择子时,处理器要求 CPL 等于栈段选择子对应的数据段的 DPL,即数值上 CPL = RPL = 用作栈的目标数据段 DPL。

在平坦模型下,整个 4GB 内存是一个段,操作系统
为所有用户进程构建了两个用户级的 RPL 为 3 的选择子, 分别指向 4GB 的用户数据段和 4GB 的用户代码
段。因为用户程序在自己的虚拟地址空间中运行,各用户进程的虚拟地址不冲突,所以各用户程序共用这
两个选择子就够了
,也就是说用户进程在申请系统服务时无需提供选择子

MOV AX, [123456H] ;默认段寄存器DS
MOV EAX, [EBX+EBP] ;默认段寄存器DS
MOV EBX, [EAX+100H] ;默认段寄存器DS
MOV EBX, [EBP+EBX] ;默认段寄存器SS
MOV [ESP+EDX*2], AX ;默认段寄存器SS
MOV AX, [ESP] ;默认段寄存器SS

扩展内联汇编

  • r: 自动分配寄存器

  • m: 内存约束是要求 gcc 直接将位于 input 和 output 中的 C 变量的内存地址作为内联汇编代码的操作数,
    不需要寄存器做中转,直接进行内存读写,也就是汇编代码的操作数是 C 变量的指针。 AT&T 语法的汇编语言把内存寻址放在最高级,任何数字都被看成是内存地址

  • &:表示此 output 中的操作数要独占所约束(分配)的寄存器,只供 output 使用,任何 input 中所分
    配的寄存器不能与此相同。注意,当表达式中有多个修饰符时, &要与约束名挨着,不能分隔。

1 #include <stdio.h>
2 void main() {
    
    
3 int ret_cnt = 0, test = 0;
4 char* fmt = "hello,world\n"; // 共 12 个字符
5 asm(" pushl %1; \
6 call printf; \
7 addl $4,%%esp; \
8 movl $6, %2" \
9 :"=&a"(ret_cnt) \
10 :"m"(fmt),"r"(test) \
11 );
12 printf("the number of bytes written is %d\n", ret_cnt);
13 }

这里%1记录的是保存了fmt指针的地址
pushl是把指针压入了栈

  • =:寄存器约束,用于output,表示只可写,内嵌的汇编指令运行结束后,如果想将
    运行结果存储到 c 变量中,就把变量写到output当中,编译器背后通过 mov 操作把 寄存器的值传给 c变量。
1 #include<stdio.h>
2 void main() {
    
    
3 int in_a = 1, in_b = 2, out_sum;
4 asm("addl %%ebx, %%eax":"=a"(out_sum):"a"(in_a),"b"(in_b));
5 printf("sum is %d\n",out_sum);
6 }
  • +:寄存器约束,+也只用在 output 中,但它具备读、写的属性,也就是它既可作为输入,同时也可以作为输出
1 #include <stdio.h>
2 void main() {
    
    
3 int in_a = 1, in_b = 2;
4 asm("addl %%ebx, %%eax;":"+a"(in_a):"b"(in_b));
5 printf("in_a is %d\n", in_a);
6 }
  • volatile 修饰的变量,表示每次都从内存中读取它的值,而不是从寄存器中 。 在扩展汇编中可以用"memory"修饰所有变量,都从内存读取

中断

BIOS 中断是实模式下的方法,只能在进入保护模式前调用。可以用来获取硬件信息,比如内存大小。
CPU 外部的中断就称为外部中断,来自 CPU 内部的中断
称为内部中断。其实还可以再细分,外部中断按是否导致宕机来划分,可分为可屏蔽中断和不可屏蔽中断
两种,而内部中断按中断是否正常来划分,可分为软中断和异常。
中断描述符表( Interrupt Descriptor Table, IDT)是保护模式下用于存储中断处理程序入口的表,CPU 内部有个中断描述符表寄存器( Interrupt Descriptor Table Register, IDTR)
当 CPU 接收一个中断时,需要用中断向量在此表中检索对应的描述符,在该描述符中找到中断处理程序的
起始地址,然后执行中断处理程序。
CPU 外:外部设备的中断由中断代理芯片接收,处理后将该中断的中断向量号发送到 CPU。
CPU 内: CPU 执行该中断向量号对应的中断处理程序。例如int 8 指令
由外部设备引起的中断,特权级检查的时候只需要满足当前特权级低于目标段特权级,如果是软中断则当前特权级还必须大于中断描述符的特权级。
通过中断从低特权级转移到高特权级时,处理器会从 TSS 中查找并获得和目标段dpl一样的
新栈,然后在新栈中依次压入旧的ss,esp,eflags,cs,eip 。 从中断返回时iret会自动弹出它们
IDT中的描述符:
在这里插入图片描述
在这里插入图片描述

  • 初始化过程:
    初始化中断描述符表IDT, 每个描述符记录中断处理函数的选择子和偏移量。系统可能会自动压入错误码。
    可以通过设置起始中断号的方式改变引脚IR对应的中断号,而IR对应的设备是固定。

  • 编程定时计数器
    设置计数器的工作方式,使得计数器为0时产生一个上升沿,然后循环反复可以实现控制时钟中断信号的频率。
    193180/计数器 0 的初始计数值=中断信号的频率

  • 中断处理函数
    中断描述符中记录的是汇编程序kernel.S中VECTOR宏展开后里面intr%1entry的地址(%1是宏的参数,代表中断号),然后在宏中调用C的处理函数,这样做的原因是汇编中方便保存上下文和控制IO。
    处理函数最后会跳转到intr_exit,用于弹出之前压入的各寄存器回复中断之前的上下文,最后调用iretd

    cdecl(C declaration,即 C 声明),起源于 C 语言的一种调用约定,在 C 语言中,函数参数是从右到左的顺序入栈的。 GNU/Linux GCC,把这一约定作为事实上的标准, x86 架构上的许 多 C 编译器也都使用这个约定。在 cdecl 中,参数是在栈中传递的。 EAX、 ECX 和 EDX 寄存 器是由调用者保存的,其余的寄存器由被调用者保存。函数的返回值存储在EAX 寄存器。由 调用者清理栈空间
    C和Delphi函数的返回值同样是存储在EAX中 这也是方便了在汇编中调其他语言写的函数

    编译器会将属性相同的 section 合并到同一个大的 segment 中,例如编译器会自动把所有.text和.data放到各自的一个segment当中

    1 [bits 32]
    2 %define ERROR_CODE nop ;若在相关的异常中 CPU 已经自动压入了
    ;错误码,为保持栈中格式统一,这里不做操作
    3 %define ZERO push 0 ;若在相关的异常中 CPU 没有压入错误码
    ;为了统一栈中格式,就手工压入一个 0
    4
    5 extern idt_table ;idt_table 是 C 中注册的中断处理程序数组
    6
    7 section .data
    8 global intr_entry_table
    9 intr_entry_table:
    10
    11 %macro VECTOR 2
    12 section .text
    13 intr%1entry: ; 每个中断处理程序都要压入中断向量号
    ; 所以一个中断类型一个中断处理程序
    ; 自己知道自己的中断向量号是多少
    14
    15 %2 ; 中断若有错误码会压在 eip 后面
    16 ; 以下是保存上下文环境
    17 push ds
    18 push es
    19 push fs
    20 push gs
    21 pushad ; PUSHAD 指令压入 32 位寄存器,其入栈顺序是:
    ; EAX,ECX,EDX,EBX,ESP,EBP,ESI,EDI,EAX 最先入栈
    22
    23 ;如果是从片上进入的中断
    ;除了往从片上发送 EOI 外,还要往主片上发送 EOI
    24 mov al,0x20 ; 中断结束命令 EOI
    25 out 0xa0,al ; 向从片发送
    26 out 0x20,al ; 向主片发送
    27
    28 push %1 ;不管 idt_table 中的目标程序是否需要参数
    ;都一律压入中断向量号,调试时很方便
    29 call [idt_table + %1*4] ; 调用 idt_table 中的 C 版本中断处理函数
    30 jmp intr_exit
    31
    32 section .data
    33 dd intr%1entry ; 存储各个中断入口程序的地址
    ; 形成 intr_entry_table 数组
    34 %endmacro
    35
    36 section .text
    37 global intr_exit
    38 intr_exit:
    39 ; 以下是恢复上下文环境
    40 add esp, 4 ; 跳过中断号
    41 popad
    42 pop gs
    43 pop fs
    44 pop es
    45 pop ds
    46 add esp, 4 ; 跳过 error_code
    47 iretd
    48
    49 VECTOR 0x00,ZERO
    …略
    81 VECTOR 0x20,ZERO
    

内存管理

在开启页表之前,把描述符表里的显存段基地址改成虚拟地址,代码段和数据段基地址还是0,开启页表后,用虚拟地址重新加载描述附表。
不管是页目录表还是页表的每一项都是32位,并且只有高20位用来记录地址,因为地址都是4kb的整数倍。
一个页表可以表示的内存容量为 4mb
保证所有用户进程的3gb-4gb的虚拟地址指向同一片物理页地址

在这里插入图片描述内存地址0xc0000000 = 11b<<30 而高10位11b<<8() 为768, 所以它对应第768个页表(按顺序排列)。
把页目录表的最后一项的记录页目录表本身的地址

我们在访问页目录表中的页目录项时,可以通过虚拟地址 0xfffffxxx 的方式,其中的 xxx
是页目录表内的偏移地址,0xfffff000 的高 10 位是 0x3ff=1023是最后一个表项,中间 10 位依然是 0x3ff,所以可以用xxx直接访问页目录表内4kb的空间,也就是并不会被页部件自动乘以 4
同理,若想访问某页表4kb空间,要使虚拟地址高 10 位为 0x3ff,目的是获取页目录表物理地址。中间 10 位
为页目录表中32的索引,因为是 10 位的索引值,所以这里不用乘以 4。低 12 位为页表内的偏移地址。

  • 书中的内存布局
    在这里插入图片描述
    栈指针 esp 已经在 loader.S 中被设置成
    0xc0000900

  • 向下扩展的栈段的保护
    在这里插入图片描述每次向栈中压入数据时就是CPU 检查栈段的时机,它要求必须满足以下条件。
    实际段界限+1≤esp-操作数大小≤ 0xFFFFFFFF

  • 内存池

    5 struct bitmap {
          
          
    6 uint32_t btmp_bytes_len;
    7 /* 在遍历位图时,整体上以字节为单位,细节上是以位为单位,
    所以此处位图的指针必须是单字节 */
    8 uint8_t* bits;
    9 };
    6 /* 虚拟地址池,用于虚拟地址管理 */
    7 struct virtual_addr {
          
          
    8 struct bitmap vaddr_bitmap; // 虚拟地址用到的位图结构
    9 uint32_t vaddr_start; // 虚拟地址起始地址
    10 };
    11
    12 extern struct pool kernel_pool, user_pool;
    /
    18 struct pool {
          
          
    19 struct bitmap pool_bitmap; //本内存池用到的位图结构,用于管理物理内存
    20 uint32_t phy_addr_start; // 本内存池所管理物理内存的起始地址
    21 uint32_t pool_size; // 本内存池字节容量
    22 };
    23
    24 struct pool kernel_pool, user_pool; // 生成内核内存池和用户内存池
    25 struct virtual_addr kernel_vaddr; // 此结构用来给内核分配虚拟地址
    
    

    PF_KERNEL代表内核内存。 PF_USER 值为 2,它代表用户内存
    内核物理内存池管理的起始内存(物理地址):
    初始状态: 0-1mb物理内存对应的第一张页表,已经被分配给页目录的第一项和第768项,第一张页表之后紧接着就是空的第769-1022张页表,页目录表的第769-1022也分配给它们。所以在链接内核程序生成elf文件时,要指定入口地址在虚拟地址0xC0000000以上。

    内核物理内存池管理的起始地址:
    kp_start=0x100000 + page_size * 256 (256个页分别对应—页目录表,第一张页表,3G以上虚拟空内存对应的第769-1022张页表)
    用户物理内存池管理起始内存:
    up_start=kp_start+kernel_free_page * PG_SIZE
    内核物理内存池用到的位图的起始地址:
    kp_bit=0xc009a000 (在页表中对应物理地址0x9a000)
    用户物理内存池用到的位图的起始地址:
    up_bit=kp_bit+kpb_length(位图长度)
    内核虚拟内存池管理的起始内存:
    kvp_start=0xc0100000(3g+1mb)
    内核虚拟内存池用到的位图的起始地址:
    kvp_bit=up_bit+upb_length

  • 虚拟内存分配过程
    从虚拟内存池里一次申请k个page,
    因为物理内存不一定连续,所以在物理内存池里申请k次, 然后分别把每个虚拟页和物理页用页表关联起来.
    对于一个虚拟页t,可以利用页目录表的最后一项保存着页目录表起始物理地址的特性,计算出到能访问到t对应的pde和pte的虚拟地址。若此时pde的p属性值为0,即pde对应的页表不在内存中,则在物理内存池中申请一页,并把物理地址回填到pde中。之后,把新建页的内容清零,然后把t的物理地址填到对应的pte中。
    函数static void page_table_add(void* _vaddr, void* _page_phyaddr)中vaddr为任意地址,而_page_phyaddr必须是物理叶开头地址。

内核线程

  • 线程PCB的数据结构

    struct task_struct {
          
          
    uint32_t* self_kstack; // 内核栈栈顶
    enum task_status status;
    char name[16];
    uint8_t priority;
    uint8_t ticks; // 每次在处理器上执行的时间嘀嗒数
    
    /* 此任务自上 cpu 运行后至今占用了多少 cpu 嘀嗒数,
    也就是此任务执行了多久*/
    uint32_t elapsed_ticks;
     /* general_tag 的作用是用于线程在一般的队列中的结点 */
     struct list_elem general_tag;
    
    /* all_list_tag 的作用是用于线程队列 thread_all_list 中的结点 */
    struct list_elem all_list_tag;
    uint32_t* pgdir; // 进程自己页表的虚拟地址
     uint32_t stack_magic; // 用这串数字做栈的边界标记
    // 用于检测栈的溢出
    };
    

    栈中需要预留中断栈struct intr_stack 的空间,这有两个目的。
    (1)将来线程进入中断后,位于 kernel.S 中的中断代码会通过此栈来保存上下文。注意intr_stack 的栈底不一定在pcb顶部,只要保持和self_kstack的相对距离即可。
    (2)将来实现用户进程时,会将用户进程的初始信息放在中断栈中。

    初始化线程的时候,在内核空间申请一页内存给pcb,并让线程的内核栈指向页的顶端。

  • 线程内核栈的数据结构

    struct thread_stack {
          
          
    uint32_t ebp;
    uint32_t ebx;
    uint32_t edi;
    uint32_t esi;
     /* 线程第一次执行时, eip 指向待调用的函数 kernel_thread
    其他时候, eip 是指向 switch_to 的返回地址*/
    void (*eip) (thread_func* func, void* func_arg);
    /***** 以下仅供第一次被调度上 cpu 时使用 ****/
    /* 参数 unused_ret 只为占位置充数为返回地址 */
    void (*unused_retaddr);
    thread_func* function; // 由 kernel_thread 所调用的函数名
    void* func_arg; // 由 kernel_thread 所调用的函数所需的参数
    };
    

    ebp,ebx,edi,esi的值由线程初始化函数填入线程内核栈,线程start的时候把esp指向内核栈顶,依次弹出栈ebp,ebx,edi,esi,然后用ret弹出并跳转到内核栈中的eip,执行其指向的函数kernel_thread,由于是用ret跳转的,没有自动压入返回地址,所以这时unused_retaddr相当于kernel_thread的返回地址。kernel_thread的参数是真正要执行的函数func和其参数func_arg。在kernel_thread中执行func的原因是kernel_thread会自动清理func的栈。

  • 多线程
    running_thread()可以通过当前栈顶地址推算出,当前线程pcb的起始地址,原理是pcb对应一整页内存,所以其首地址是4k的整数倍。所以把栈顶地址低低三位置0就得到pcb首地址。

    线程之间的切换由时钟中断驱动,线程由中断陷入内核态,但是此时使用的还是线程自己的栈。时钟中断处理函数会检查线程的运行时间(ticks),如果超过priority就调用schedule函数。在schedule中如果当前线程cur的状态是正在运行,就把当前线程cur的tag压入就绪队列。tag是线程pcb的一个属性,从队列中取出一个tag,可以根据tag属性在pcb中偏移,还原出pcb的起始地址。接着从就绪队列弹出下一个待运行的线程next,调用swich_to(cur,next)完成线程切换:

    1 [bits 32]
    2 section .text
    3 global switch_to
    4 switch_to:
    5 ;栈中此处是返回地址
    6 push esi //abi规则,c函数调用汇编时,汇编需要为c函数保存上下文 这里是schedule调用switch_to
    7 push edi
    8 push ebx
    9 push ebp
    10
    11 mov eax, [esp + 20]; 得到栈中的参数 cur, cur = [esp+20]
    12 mov [eax], esp ; 保存栈顶指针 esp. task_struct 的 self_kstack 字段
    13 ; self_kstack 在 task_struct 中的偏移为 0
    14 ; 所以直接往 thread 开头处存 4 字节便可
    15 ;------- 以上是备份当前线程的环境,下面是恢复下一个线程的环境 ---------
    16 mov eax, [esp + 24] ; 得到栈中的参数 next, next = [esp+24]
    17 mov esp, [eax] ; pcb 的第一个成员是 self_kstack 成员
    ; 它用来记录 0 级栈顶指针,被换上 cpu 时用来恢复 0 级栈
    18 ; 0 级栈中保存了进程或线程所有信息,包括 3 级栈指针
    19 pop ebp
    20 pop ebx
    21 pop edi
    22 pop esi
    23 ret ; 返回到上面 switch_to 下面的那句注释的返回地址,
    24 ; 未由中断进入,第一次执行时会返回到 kernel_thread
    

    进入switch_to后,此时使用的还是cur的内核栈,栈顶往下依次是switch_to返回地址,cur,next, schedule返回地址,时钟中断处理函数返回值和其保存的上下文。 然后还需要压入esi,edi,ebx,ebp四个寄存器。由于中断处理函数和schedule已经运行到结尾,所以只需要保存由这四个寄存器组成的上下文,然后把cur栈顶保存到pcb中。切换到next线程相当于恢复被中断的next线程,先恢复next的内核栈顶,弹出四个寄存器,然后按照switch_to返回地址,cur,next, schedule返回地址,时钟中断处理函数返回值的顺序逐步跳转,然后恢复中断时保存的上下文。
    switch_to新线程时,由于之前没有经历过中断和schedule,所以switch_to返回地址是我们初始化线程时设置的kernel_thread的地址。而且新线程的esi,edi,ebx,ebp也都是0。
    thread_start只是把pcb加入就绪队列中。

  •  /* 信号量结构 */
    struct semaphore {
          
          
    uint8_t value;
    struct list waiters;
    };
    
    /* 锁结构 */
    struct lock {
          
          
    struct task_struct* holder; // 锁的持有者
    struct semaphore semaphore; // 用二元信号量实现锁
    uint32_t holder_repeat_nr; // 锁的持有者重复申请锁的次数
    };
    

    锁是由只有0和1两个值的信号量实现的,信号量的waiters属性包含所有因为这个信号量阻塞的线程的tag. 信号量的down操作:

    20 /* 信号量 down 操作 */
    21 void sema_down(struct semaphore* psema) {
          
          
    22 /* 关中断来保证原子操作 */
    23 enum intr_status old_status = intr_disable();
    24 while(psema->value == 0) {
          
           // 若 value 为 0,表示已经被别人持有
    25 ASSERT(!elem_find(&psema->waiters, \
         &running_thread()->general_tag));
    26 /* 当前线程不应该已在信号量的 waiters 队列中 */
    27 if (elem_find(&psema->waiters, &running_thread()->general_tag)) {
          
          
    28 PANIC("sema_down: thread blocked has been in waiters_list\n");
    29 }
    30 /* 若信号量的值等于 0,则当前线程把自己加入该锁的等待队列,
    然后阻塞自己 */
    31 list_append(&psema->waiters, &running_thread()->general_tag);
    32 thread_block(TASK_BLOCKED); // 阻塞线程,直到被唤醒
    33 }
    34 /* 若 value 为 1 或被唤醒后,会执行下面的代码,也就是获得了锁*/
    35 psema->value--;
    36 ASSERT(psema->value == 0);
    37 /* 恢复之前的中断状态 */
    38 intr_set_status(old_status);
    39 }
    

    用关中断保证down操作的原子性,用while来判断信号量的值可以确保线程在唤醒的时候信号量已近恢复成1(这里信号量只能为0或1)。阻塞当前线程会把其状态置为blocking,这时调用schedule,当前线程不会被加入就绪队列。up操作会从waiters列表里弹出第一个元素,并把它对应线程的状态设置为ready,放入ready_list队首,下次schedule的时候该线程会被优先调度。
    一旦一个线程获得了锁,它可以多次申请该锁并不会造成阻塞,原因是用holder_repeat_nr记录了持有者的申请次数

    140 /* 将线程 pthread 解除阻塞 */ 
    141 void thread_unblock(struct task_struct* pthread) {
          
           
    142 enum intr_status old_status = intr_disable(); 
    143 ASSERT(((pthread->status == TASK_BLOCKED) || (pthread->status == TASK_WAITING) || 
    (pthread->status == TASK_HANGING))); 
    144 if (pthread->status != TASK_READY) {
          
           
    145 ASSERT(!elem_find(&thread_ready_list, &pthread->general_tag)); 
    146 if (elem_find(&thread_ready_list, &pthread->general_tag)) {
          
           
    147 PANIC("thread_unblock: blocked thread in ready_list\n"); 
    148 } 
    149 list_push(&thread_ready_list, &pthread->general_tag); 
     // 放到队列的最前面,使其尽快得到调度
    150 pthread->status = TASK_READY; 
    151 } 
    152 intr_set_status(old_status);
    
    42 void sema_up(struct semaphore* psema) {
          
          
    43 /* 关中断,保证原子操作 */
    44 enum intr_status old_status = intr_disable();
    45 ASSERT(psema->value == 0);
    46 if (!list_empty(&psema->waiters)) {
          
          
    47 struct task_struct* thread_blocked = elem2entry(struct task_struct, \
    general_tag, list_pop(&psema->waiters));
    48 thread_unblock(thread_blocked);
    49 }
    50 psema->value++;
    51 ASSERT(psema->value == 1);
    52 /* 恢复之前的中断状态 */
    53 intr_set_status(old_status);
    /* 获取锁 plock */
    57 void lock_acquire(struct lock* plock) {
          
          
    58 /* 排除曾经自己已经持有锁但还未将其释放的情况 */
    59 if (plock->holder != running_thread()) {
          
          
    60 sema_down(&plock->semaphore); // 对信号量 P 操作,原子操作
    61 plock->holder = running_thread();
    62 ASSERT(plock->holder_repeat_nr == 0);
    63 plock->holder_repeat_nr = 1;
    64 } else {
          
          
    65 plock->holder_repeat_nr++;
    66 }
    67 }
    68
    69 /* 释放锁 plock */
    70 void lock_release(struct lock* plock) {
          
          
    71 ASSERT(plock->holder == running_thread());
    72 if (plock->holder_repeat_nr > 1) {
          
          
    73 plock->holder_repeat_nr--;
    74 return;
    75 }
    76 ASSERT(plock->holder_repeat_nr == 1);
    77
    78 plock->holder = NULL; // 把锁的持有者置空放在 V 操作之前
    79 plock->holder_repeat_nr = 0;
    80 sema_up(&plock->semaphore); // 信号量的 V 操作,也是原子操作
    81 }
    
  • 输入输出

    • put_char:
      在默认的 80*25 模式下,每行 80 个字符共 25 行,屏幕上可以容纳 2000 个字符
      BS(backspace)的 asc 码是 8 ,动作是光标值回退并用空格0x20代替光标处字符
      回车符的 ASCII 码是 0xd,换行符的 ASCII 码是 0xa,书中不管参数是回车符,还是换行符,一律按我们平时所理解的回车换行符(CRLF)处理(Linux 中就把换行符处理成回车+换行),即这两个动作的合成:光标回撤到行首+换到下一行。
      backspace 的原理就是将光标向回移动 1 位,将该处的字符用空格覆盖。
      put_char处理流程:先判断输入符是否是bs或者是回车符和换行符中的一个,然后跳转到相应的处理函数,否则跳转到普通字符处理函数.put_other。bs处理函数不用处理换行和滚屏,所以最后的操作是固定的跳转到光标设置函数.set_cursor。.put_other最后要进行判断,若光标值小于 2000,表示未写到显存的最后,则跳转到.set_cursor,否则顺序运行到回车符和换行符处理函数.is_line_feed和.is_carriage_return(两者地址相同)。换行后如果光标值仍然小于2000,还是跳转.set_cursor,否组顺序运行到滚屏函数.roll_screen。
      滚屏的步骤:
      (1)将第 1~24 行的内容整块搬到第 0~23 行,也就是把第 0 行的数据覆盖。
      (2)再将第 24 行,也就是最后一行的字符用空格覆盖,这样它看上去是一个新的空行。
      (3)把光标移到第 24 行也就是最后一行行首。

    • 键盘驱动(键盘中断处理函数):
      键盘上某一个按键按下的瞬间8042芯片会产生通码,不松开的话通码会一直产生。松开的一瞬间会产生断码。一般的字符的通码和断码为一个字节,且断码=通码+0x80。 最长的PrintScreen SysRq的通码和断码分别为 e0,2a,e0,37和 e0,b7,e0,aa。产生通码或断码后,8042会把数据保存在寄存器0x60上并通过外部中断的形式通知cpu来取数据。因此按键时所发的中断次数,取决于该键扫描码中包含的字节数,键盘中断处理程序中必须要用 in 指令不断读取“输出缓冲寄存器”,否则 8042 无法继续响应键盘操作。
      在这里插入图片描述
      转义符的作用是方便c语言把字符转成asc码。在键盘驱动中,用keymap[][2]保存0到0x3A通码对应的字符以及shift转义后的字符。
      用全局静态变量static bool ctrl_status, shift_status, alt_status, caps_lock_status, ext_scancode保存控制字符的状态,其中ext_scancode用于通符由两个字节组成的情况,例如r-ctrl 和r-alt的通符分别为e0,1d和e,38。如果中断程序识别到e0,则ext_scancode为true,并且马上返回。caps_lock和其它控制字符不同,它只考虑通码,即按下一次状态取反。其它控制符则通码为开,断码为关。注意 shift和caps_lock同时打开的时候,对双字符键例如数字键来说和单按shift无区别,但是对子母键老说相当于两者的作用相抵消,所以子母为小写。对于那些通码在 0x3b 以上的按键, 在 else代码块中执行put_str(“unknownkey\n”)提示未知键。

    • 环形输入缓存
      数据结构

       /* 环形队列 */
      struct ioqueue {
              
              
      // 生产者消费者问题
      struct lock lock;
      /* 生产者,缓冲区不满时就继续往里面放数据,
      * 否则就睡眠,此项记录哪个生产者在此缓冲区上睡眠 */
      struct task_struct* producer;
      
      /* 消费者,缓冲区不空时就继续从里面拿数据,
      * 否则就睡眠,此项记录哪个消费者在此缓冲区上睡眠 */
      struct task_struct* consumer;
      char buf[bufsize]; // 缓冲区大小
      int32_t head; // 队首,数据往队首处写入
      int32_t tail; // 队尾,数据从队尾处读出
      };
      

      从head处写入,从tail处取出。每次更新head和tail值时都要对bufsize取余,如果head==tail说明缓冲区为空,如果head+1后取余等于tail则说明缓冲区已满。

      }
      29
      30 /* 使当前生产者或消费者在此缓冲区上等待 */
      31 static void ioq_wait(struct task_struct** waiter) {
              
              
      32 ASSERT(*waiter == NULL && waiter != NULL);
      33 *waiter = running_thread();
      34 thread_block(TASK_BLOCKED);
      35 }
      37 /* 唤醒 waiter */
      38 static void wakeup(struct task_struct** waiter) {
              
              
      39 ASSERT(*waiter != NULL);
      40 thread_unblock(*waiter);
      41 *waiter = NULL;
      42 }
      /* 消费者从 ioq 队列中获取一个字符 */
      char ioq_getchar(struct ioqueue* ioq) {
              
              
      46 ASSERT(intr_get_status() == INTR_OFF);
      47
      48 /* 若缓冲区(队列)为空,把消费者 ioq->consumer 记为当前线程自己,
      49 * 目的是将来生产者往缓冲区里装商品后,生产者知道唤醒哪个消费者,
      50 * 也就是唤醒当前线程自己*/
      51 while (ioq_empty(ioq)) {
              
              
      52 lock_acquire(&ioq->lock);
      53 ioq_wait(&ioq->consumer);
      54 lock_release(&ioq->lock);
      55 }
      56
      57 char byte = ioq->buf[ioq->tail]; // 从缓冲区中取出
      58 ioq->tail = next_pos(ioq->tail); // 把读游标移到下一位置
      59
      60 if (ioq->producer != NULL) {
              
              
      61 wakeup(&ioq->producer); // 唤醒生产者
      62 }
      63
      64 return byte;
      65 }
      66
      67 /* 生产者往 ioq 队列中写入一个字符 byte */
      68 void ioq_putchar(struct ioqueue* ioq, char byte) {
              
              
      69 ASSERT(intr_get_status() == INTR_OFF);
      70
      71 /* 若缓冲区(队列)已经满了,把生产者 ioq->producer 记为自己,
      72 * 为的是当缓冲区里的东西被消费者取完后让消费者知道唤醒哪个生产者,
      73 * 也就是唤醒当前线程自己*/
      74 while (ioq_full(ioq)) {
              
              
      75 lock_acquire(&ioq->lock);
      76 ioq_wait(&ioq->producer);
      77 lock_release(&ioq->lock);
      78 }
      79 ioq->buf[ioq->head] = byte; // 把字节放入缓冲区中
      80 ioq->head = next_pos(ioq->head); // 把写游标移到下一位置
      81
      82 if (ioq->consumer != NULL) {
              
              
      83 wakeup(&ioq->consumer); // 唤醒消费者
      84 }
      85 }
      
      

      当前线程调用 ioq_getchar从缓存区获取一个字符时,如果缓存区为空则竞争锁,竞争到锁后挂起当前线程并使当前线程为consumer,此前consumer应该为空,因为当前线程要么是第一次运行,要么是挂起后被其它线程的wakeup(&ioq->consumer)唤醒,而wakeup(&ioq->consumer)唤醒线程后会把consumer置空,所以此时需要把consumer的设置为当前线程。如果当前缓存区不为空则取出一个字符并更新tail的值,如果此时producer不为空则唤醒producer对应的线程。 ioq_putchar和 ioq_getchar的情况类似。总之只有缓冲区满或者空的时候需要用consumer和producer来记录和唤醒挂起的线程,并且锁机制确保一个时刻只能有一个挂起的consumer和一个挂起的producer。producer运行完wakeup,其它被锁住的线程或新线程可以来竞争成为consumer。可以把lock_acquiere和lock_release包围的区域看成是一个 槽,每一时刻只允许一个线程在 槽中,一个线程出槽后,马上就会有下一个线程重新占用它。对于槽中的consumer线程,只有producer线程能使它出槽。 以上的缓冲区只能允许一个消费者和一个生产者同时运行。

用户进程

  • LDT和TSS
    在这里插入图片描述
    LDT是一个描述符“表”,在GDT中对应一个段描述符。使用时用指令lldt+16位选择子把LDT对应的段描述符加载到LDTR 寄存器中。对比之下,加载gdt时是lgdt“16 位内存单元” &“32 位内存单元”。
    如果想要访问LDT中的内存段,用TI位为1的选择子来检索。一个任务状态段TSS对应一个段描述符,直接用 ltr+16位选择子检索GDT,并把描述符保存在TR寄存器。
    intel任务切换的默认方法是:将新任务对应的 LDT 信息加载到 LDTR 寄存器,对应的 TSS 信息加载到 TR 寄存器。G更新LDTR不是必须的。

    TSS描述符中的TYPE段中有一个B位,值为1时表示任务繁忙,当前TSS不能当做新任务调用。任务门描述符中保存了TSS的选择子。eflags中的NT位为1表示当前任务是其它任务调用的,执行iretd时需要从当前TSS 的“上一个任务的 TSS 指针”字段中获取旧任务的 TSS,转而去执行旧任务。切换到新任务之前需要把8 个通用寄存器, 6 个段寄存器,指令指针 eip,栈指针寄存器 esp,页表寄存器 cr3 和标志寄存器 eflags 等保存到当前TSS。切换到新任务之后需要将旧任务的 TSS 选择子写入新任务 TSS 中“上一个任务的 TSS 指针”字段中。调用新任务时处理器自动将nt位置1,返回旧任务后新任务的nt位自动置0;
    有两种操作可以切换任务:第一种是中断发生时,通过任务门进行任务切换。另一种通过call或者jmp+任务门选择子或TSS 选择子切换任务。区别是jmp调用后,新任务 TSS 描述符中的 B 位会被 CPU 置为 1 以表示任务忙, 旧任务 TSS 描述符中的 B 位会被 CPU 清 0。但是无论哪种情况,从新任务返回时,新任务的B位都会清零。
    任务门可以用 call 和 jmp 指令直接调用也可以通过中断调用,原因是这两个门既可以在GDT也可以在中断描述符表IDT中
    任务们中保存的是TSS段描述符的选择子。

  • 中断和TSS
    若中断门从低特权级转移到高特权级时,处理器会从 TSS 中查找并获得和目标段dpl一样的
    新栈,然后在新栈中依次压入旧的ss,esp,eflags,cs,eip 。 从中断返回时iret会自动弹出它们。在过程中只用到了当前tss,并没有发生任务切换。从新TSS返回旧TSS时,只要恢复旧TSS中的快照就行了,不用考虑特权级的转换。

  • linux中任务切换
    linux中TSS只负责记录向更高特权级转移时提供相应特权的栈地址。虽然TSS可以记录三个栈,但是当处理器进入不同的特权级时,它会自动在 TSS 中找同特权级的栈,然后把当前任务的状态保存在TSS指向的新栈里。

  • 用户空间
    需要利用PCB中pgdir 和 userprog_vaddr两个属性
    get_a_page(enum pool_flags pf, uint32_t vaddr) 将地址 vaddr 与 pf 池中的物理地址关联,仅支持一页空间分配。内部调用page_table_add((void*)vaddr, page_phyaddr) 同时把vaddr对应的虚拟内存位图置一。get_user_pages(uint32_t pg_cnt)在用户空间中申请 内存,并返回其虚拟地址
    得到虚拟地址映射到的物理地址addr_v2p(uint32_t vaddr)
    在init_all中加入tss的初始化函数,其中初始化tss,tss.ss0设置为SELECTOR_K_STACK。在 gdt 中添加 dpl 为 3 的数据段和代码段描述符,更新lgdt并把tss选择子加载到ltr。

  • 创建用户进程

    86
    87 /* 创建用户进程虚拟地址位图 */
    88 void create_user_vaddr_bitmap(struct task_struct* user_prog) {
          
          
    89 user_prog->userprog_vaddr.vaddr_start = USER_VADDR_START;
    90 uint32_t bitmap_pg_cnt = DIV_ROUND_UP(\
    (0xc0000000 - USER_VADDR_START) / PG_SIZE / 8 , PG_SIZE);
    91 user_prog->userprog_vaddr.vaddr_bitmap.bits =get_kernel_pages(bitmap_pg_cnt); //这里和默认的位图不同,使用的是申请的内存
    92 user_prog->userprog_vaddr.vaddr_bitmap.btmp_bytes_len =\
    (0xc0000000 - USER_VADDR_START) / PG_SIZE / 8;
    93 bitmap_init(&user_prog->userprog_vaddr.vaddr_bitmap);
     97 void process_execute(void* filename, char* name) {
          
          
    98 /* pcb 内核的数据结构,由内核来维护进程信息,
    因此要在内核内存池中申请 */
    99 struct task_struct* thread = get_kernel_pages(1);
    100 init_thread(thread, name, default_prio);
    101 create_user_vaddr_bitmap(thread);
    102 thread_create(thread, start_process, filename);
    103 thread->pgdir = create_page_dir();
    104
    105 enum intr_status old_status = intr_disable();
    106 ASSERT(!elem_find(&thread_ready_list, &thread->general_tag));
    107 list_append(&thread_ready_list, &thread->general_tag);
    108
    109 ASSERT(!elem_find(&thread_all_list, &thread->all_list_tag));
    110 list_append(&thread_all_list, &thread->all_list_tag);
    111 intr_set_status(old_status);
    112 }
    

    在当前进程中调用process_execute创建用户进程, 在 process_execute中, 先调用函数 get_kernel_pages 申请 1 页内核内存创建进程的 pcb,这里的 pcb 就是 thread,接下来调用函数init_thread 对 thread 进行初始化。随后调用函数 create_user_vaddr_bitmap 为用户进程创建管理虚拟地址空间的位图,并保存在其PCB中。接着调用 thread_create 创建线程,此函数的作用是将函数 start_process 和用户进程 user_prog 作为kernel_thread 的参数, 以使 kernel_thread 能够调用start_proces(user_prog)。 接下来是调用函数 create_page_dir为进程创建页表,随后通过函数 list_append 将进程 pcb,也就是 thread 加入就绪队列和全部队列,至此用户进程的创建部分完成,现在就等着进程运行了。
    create_user_vaddr_bitmap是在当前进程的内核空间申请内存作为新进程虚拟内存的位图。
    create_page_dir 在当前进程的内核空间申请一页作为新目录表,并且把当前页目录表的内核部分复制过来,新页目录表的最后一项改成自己的物理地址,这样新页目录表在当前页表对应的页目录项也被复制过来,只要不被删除就会一直存在于共享内核空间。把新页目录表的虚拟地址赋值给pcb的pgdir变量
    开始一个新的内核线程和用户进程的区别:内核线程在switch_to调用kernel_thread函数的过程中就直接开始运行了。而用户进程的kernel_thread函数只是做一些准备工作,真正运行时在中断返回之后。

  • 通过iretd进入用户进程
    pcb预留的中断栈 intr_stack

    24 struct intr_stack {
          
          
    25 uint32_t vec_no; // kernel.S 宏 VECTOR 中 push %1 压入的中断号
    26 uint32_t edi;
    27 uint32_t esi;
    28 uint32_t ebp;
    29 uint32_t esp_dummy;
    // 虽然 pushad 把 esp 也压入,但 esp 是不断变化的,所以会被 popad 忽略
    30 uint32_t ebx;
    31 uint32_t edx;
    32 uint32_t ecx;
    33 uint32_t eax;
    34 uint32_t gs;
    35 uint32_t fs;
    36 uint32_t es;
    37 uint32_t ds;
    38
    39 /* 以下由 cpu 从低特权级进入高特权级时压入 */
    40 uint32_t err_code; // err_code 会被压入在 eip 之后
    41 void (*eip) (void);
    42 uint32_t cs;
    43 uint32_t eflags;
    44 void* esp;
    45 uint32_t ss;
    46 };
    
    15 void start_process(void* filename_) {
          
          
    16 void* function = filename_;
    17 struct task_struct* cur = running_thread();
    18 cur->self_kstack += sizeof(struct thread_stack);
    19 struct intr_stack* proc_stack = (struct intr_stack*)cur->self_kstack;
    20 proc_stack->edi = proc_stack->esi =\
    proc_stack->ebp =proc_stack->esp_dummy = 0;
    21 proc_stack->ebx = proc_stack->edx = \
    proc_stack->ecx = proc_stack->eax = 0;
    22 proc_stack->gs = 0; // 用户态用不上,直接初始为 0
    23 proc_stack->ds = proc_stack->es = proc_stack->fs = SELECTOR_U_DATA;
    24 proc_stack->eip = function; // 待执行的用户程序地址
    25 proc_stack->cs = SELECTOR_U_CODE;
    26 proc_stack->eflags = (EFLAGS_IOPL_0 | EFLAGS_MBS | EFLAGS_IF_1);
    27 proc_stack->esp = (void*)((uint32_t)get_a_page(PF_USER,\
    USER_STACK3_VADDR) + PG_SIZE) ;
    28 proc_stack->ss = SELECTOR_U_DATA;
    29 asm volatile ("movl %0, %%esp; jmp intr_exit" \
    : : "g" (proc_stack) : "memory");
    

    在switch_to之前调用process_activate(next)。process_activate中会更新页表 page_dir_activate(struct task_struct* p_thread)如果目标pcb里的pgdir为空,说明是内核线程,则重新把cr3寄存器设置为0x100000的物理地址,否则利用当前页目录表的内核部分(所有进程公用)把pgdir保存的6虚拟地址转成物理地址然后更新cr3. update_tss_esp(p_thread),然后把tss中esp0 改为目标pcb的栈底地址。 switch_to新进程后会调用start_process。start_process把intr_stack中 的esp改成虚拟地址USER_STACK3_VADDR (0xc0000000 - 0x1000),把eip改成用户程序的地址,把cs改成SELECTOR_U_CODE,把ds,es,fs都改为 SELECTOR_U_DATA。最后调用intr_exit
    因为这里涉及特权切换,所以进程同样也需要借用intr_exit 进入

    tss中esp0 改为目标pcb的栈底地址作用是,当目标进程进入中断时,可以把上下文从其pcb的顶端开始保存。

当从iretd返回用户态后,cpu会自动检查各段寄存器指向的描述符的dpl的权限是否小于cpl,否则就清空段寄存器。

  • c语言布局
    现代操作系统都是在平坦模型(整个4GB 空间为 1 个段)下工作,编译器也是按照平坦模型为程序布局,运行时,程序中的代码和数据都在内存中的同一个段中整齐排列(可以按照读写,执行权限分为数据段和代码段和栈段,但他们的起始地址相同)。

    elf 头中有bss 节的虚拟地址、大小等相关记录,bss 节负责给未初始化的全局变量和局部静态变量,被链接器合并到数据段。如果在某段中合并了 bss 节,elf中该段的 MemSiz 应该大于 FileSiz,原因是 bss节不占用文件系统空间,只占用内存空间。由于bss被分配到内存中的数据段,只要知道数据段的起始地址及大小,便可以确定堆的起始地址了。
    在这里插入图片描述

系统调用

一个系统调用分成两部分,一部分是用户进程调用的接口每一部分是内核实现,一般由sys_开头。
利用中断向量号0x80和eax的保存的调用号以及其它寄存器里的参数来调用不同的系统调用。由于32位的c函数用栈来传递参数,所以在进入syscall_handler后,原本在用户态中保存在寄存器的参数被压入内核态的栈中,然后传递给syscall_table数组中的函数。
单独为0x80在IDT中建立描述符

make_idt_desc(&idt[lastindex], IDT_DESC_ATTR_DPL3, syscall_handler);

特权级设置为3,是因为int 0x80是软中断。其中syscall_handler是kernel.s中的函数
在用户态通过调用不同参数个数的_syscall,触发软中断。
具体功能实现函数需要注册到syscall_table数组 中
_syscall3: 三个参数的调用不同系统实现的函数。返回值先保存在内核栈中的eax位置,然后在返回用户态的时候弹出到eax寄存器

40 #define _syscall3(NUMBER, ARG1, ARG2, ARG3) ({
    
     \
41 int retval; \
42 asm volatile ( \
43 "int $0x80" \
44 : "=a" (retval) \
45 : "a" (NUMBER), "b" (ARG1), "c" (ARG2), "d" (ARG3) \
46 : "memory" \
47 ); \
48 retval; \
49 })
  • printf

    8 #define va_start(ap, v) ap = (va_list)&v // 把 ap 指向第一个固定参数 v
    9 #define va_arg(ap, t) *((t*)(ap += 4)) // ap 指向下一个参数并返回其值
    10 #define va_end(ap) ap = NULL
    27 uint32_t vsprintf(char* str, const char* format, va_list ap) {
          
          
    28 char* buf_ptr = str;
    29 const char* index_ptr = format;
    30 char index_char = *index_ptr;
    31 int32_t arg_int;
    32 while(index_char) {
          
          
    33 if (index_char != '%') {
          
          
    34 *(buf_ptr++) = index_char;
    35 index_char = *(++index_ptr);
    36 continue;
    37 }
    38 index_char = *(++index_ptr); // 得到%后面的字符
    39 switch(index_char) {
          
          
    40 case 'x':
    41 arg_int = va_arg(ap, int);
    42 itoa(arg_int, &buf_ptr, 16);
    43 index_char = *(++index_ptr);
    // 跳过格式字符并更新 index_char
    44 break;
    45 }
    46 }
    47 return strlen(str);
    48 }
    uint32_t printf(const char* format, ...) {
          
          
    52 va_list args;
    53 va_start(args, format); // 使 args 指向 format
    54 char buf[1024] = {
          
          0}; // 用于存储拼接后的字符串
    55 vsprintf(buf, format, args);
    56 va_end(args);
    57 return write(buf);
    58 }
    

    利用… 使编译器允许可变参数,然后在vsprintf中通过va_start等三个宏来获取每个参数。在vsprintf中使用系统调用write()

  • melloc
    物理内存 0xb00 处储存着用于内存池的总内存otal_mem_bytes ,以字节为单位,此位置比较好记。

    /* 内存块 */
    29 struct mem_block {
          
          
    30 struct list_elem free_elem;
    31 };
    32
    33 /* 内存块描述符 */
    34 struct mem_block_desc {
          
          
    35 uint32_t block_size; // 内存块大小
    36 uint32_t blocks_per_arena; // 本 arena 中可容纳此 mem_block 的数量
    37 struct list free_list; // 目前可用的 mem_block 链表
    38 };
    39
    40 #define DESC_CNT 7
    
    33 struct arena {
          
          
    34 struct mem_block_desc* desc; // 此 arena 关联的 mem_block_desc
    35 /* large 为 ture 时, cnt 表示的是页框数。
    36 * 否则 cnt 表示空闲 mem_block 数量 */
    37 uint32_t cnt;
    38 bool large;
    39 };
    

    mem_block块 是内存分配的最小单位,一个arena可以分为多个相同大小的块,不同arena中相同大小的空闲块可以串成一个列表,保存在对应的块描述符mem_block_desc中

    if (list_empty(&descs[desc_idx].free_list)) {
          
          
    394 a = malloc_page(PF, 1); // 分配 1 页框作为 arena
    395 if (a == NULL) {
          
          
    396 lock_release(&mem_pool->lock);
    397 return NULL;
    398 }
    399 memset(a, 0, PG_SIZE);
    400
    401 /* 对于分配的小块内存,将 desc 置为相应内存块描述符,
    402 * cnt 置为此 arena 可用的内存块数,large 置为 false */
    403 a->desc = &descs[desc_idx];
    404 a->large = false;
    405 a->cnt = descs[desc_idx].blocks_per_arena;
    406 uint32_t block_idx;
    407
    408 enum intr_status old_status = intr_disable();
    409
    410 /* 开始将 arena 拆分成内存块,并添加到内存块描述符的 free_list 中 */
    411 for (block_idx = 0; \
    block_idx < descs[desc_idx].blocks_per_arena; block_idx++) {
          
          
    412 b = arena2block(a, block_idx);
    413 ASSERT(!elem_find(&a->desc->free_list, &b->free_elem));
    414 list_append(&a->desc->free_list, &b->free_elem);
    415 }
    416 intr_set_status(old_status);
    417 }
    418
    419 /* 开始分配内存块 */
    420 b = elem2entry(struct mem_block, \
    free_elem, list_pop(&(descs[desc_idx].free_list)));
    421 memset(b, 0, descs[desc_idx].block_size);
    422
    423 a = block2arena(b); // 获取内存块 b 所在的 arena
    424 a->cnt--; // 将此 arena 中的空闲内存块数减 1
    425 lock_release(&mem_pool->lock);
    426 return (void*)b;
    427 }
    

    在内核空间中调用void* sys_malloc(uint32_t size),根据pcb是否有页表判断是否是内核线程。如果当前是内核线程,则使用内核共享的块描述符,否则使用pcb里的块描述符。如果size超过最大内存块大小1024,则直接从内存池取出多个页框并分配给一个arena,再跨过 arena 大小,把剩下的内存返回,然后设置arena->large属性为ture,cnt属性记录页框数量。 否则,块描述符的free_list是否为空。若为空就申请一页内存作为新的arena,然后将其分块并把块加入free_list。最后从块描述符的列表当中取出一块并返回,返回块所在arena的cnt属性减一。注意,只有空闲块中有mem_block的数据结构。
    malloc中调用了malloc_page(enum pool_flags pf, uint32_t pg_cnt), malloc_page中又会调用vaddr_get(enum pool_flags pf, uint32_t pg_cnt),它会自动在当前进程自己的虚拟内存池(cur->userprog_vaddr)中申请虚拟内存。

  • sys_free
    pte的p位可以实现数据转储,我们可以在处理 pagefault 异常的中断处理程序中将之前保存到外存的页框数据再次载入到物理内存中,该物理内存可以是原来的物理页,也可以是新的物理页,这取决于实际物理内存的使用情况,然后把目标物理页地址更新到 pte 中,并将 P 位置为 1
    void mfree_page(enum pool_flags pf, void* _vaddr, uint32_t pg_cnt)(页框释放) :从_vaddr所在的页框开始,释放pg_cnt个页框。必须逐个页框进行释放,先把页框虚拟地址转成物理地址,把对应的物理内存池中的位图置零,然后页表项p位置零,最后虚拟内存池位图置零。
    sys_free(void* ptr)(内存释放统一接口):和malloc 一样也需要判断是内核还是用户内存。通过block2arena(ptr)获取ptr所在的arena,原理是arena的数据结构在ptr所在页框的起始位置。根据arena的large属性可以知道ptr是按块分配的还是直接从内存池中分配的。如果是按块分配,则把内存块回收到块描述符的列表arena->desc->free_list中。然后检查arena中的内存块是否都是空闲的,如果是就释放整个arena所在的页框,并删除free_list中相应的块。如果ptr是直接从内存池分配的,就直接调用mfree_page。

硬盘驱动

LBA : 所有扇区从0开始编号,直接按28位扇区号定位
CHS : 按照柱面-磁头-扇区”来定位
一块硬盘支持两个通道,4块IDE(PATA), Primary 通道、 Secondary 通道每个支持一个master主盘和一个slave从盘。
通道1 连接8259A从片 IRQ14 第二个通道连接IRQ15
通道1的终端号为0x20 + 14;

( 1)先选择通道,往该通道的 sector count 寄存器中写入待操作的扇区数。
( 2)往该通道上的三个 LBA 寄存器写入扇区起始地址的低 24 位。
( 3)往 device 寄存器中写入 LBA 地址的 24~27 位,并置第 6 位为 1,使其为 LBA 模式,设置第 4
位,选择操作的硬盘( master 硬盘或 slave 硬盘)。
( 4)往该通道上的 command 寄存器写入操作命令。
( 5)读取该通道上的 status 寄存器,判断硬盘工作是否完成。
( 6)如果以上步骤是读硬盘,进入下一个步骤。否则,完工。
( 7)将硬盘数据读出。
硬盘工作完成后,它已经准备好了数据,咱们该怎么获取呢?一般常用的数据传送方式如下。
( 1)无条件传送方式。
( 2)查询传送方式。
( 3)中断传送方式。
( 4)直接存储器存取方式( DMA)。
( 5) I/O 处理机传送方式。
其中(4)(5)需要额外的硬件实现。

data 寄存器是16 位 其它例如device 寄存器是8位
status和command是同一个端口,在读写时作用不同
主分区又叫做引导分区,最多只能创建四个。
可以创建一个扩展分区(占用一个主分区编号)
逻辑分区在扩展分区之内可以创建无数个。
第一个逻辑分区的编号为5.
分区大小等于“每柱面上的扇区数”乘以“柱面数”,
柱面的大小等于盘面数(磁头数)乘以每磁道扇区数。
分区不能跨柱面,也就是同一个柱面不能包含两个分区
通常在写一个文件时,是由多个磁头同时写入
到不同的盘面中编号位置相同的磁道上,采用并行的方式

第一个扇区:
(1)主引导记录 MBR。
(2)磁盘分区表 DPT。
(3)结束魔数 55AA,表示此扇区为主引导扇区,里面包含控制程序。

  • 分区表
    在这里插入图片描述第8项指的就是LBA 总扩展分区和子扩展分区的文件系统类型ID都为0x5
    活动分区是指当前分区的第一个扇区是否有引导程序(内核加载器),这个扇区也称为OBR(OS BOOT RECORD)引导扇区。而 OBR 引导扇区是分区中最开始的扇区,归操作系统的文件系统管理,因此操作系统通常往 OBR 引导扇区中添加内核加载器的代码,供 MBR 调用以实现操作系统自举。
    扩展分区的第一个扇区是EBR引导扇区,其中的分区表的第一分区表项用来描述所包含的逻辑分区的元信息,第二分区表项用来描述下一个子扩展分区的地址,第三、四表项未用到。
    在这里插入图片描述在两个横向虚线间的分区属于同一个分区表。各个级别的子扩展分区的起始偏移都是针对总扩展分区的起始地址。 逻辑分区的起始偏移则是针对它所在子扩展的起始地址

  • 驱动

    7 /* 分区结构 */ 
     8 struct partition {
          
           
     9 uint32_t start_lba; // 起始扇区
    10 uint32_t sec_cnt; // 扇区数
    11 struct disk* my_disk; // 分区所属的硬盘
    12 struct list_elem part_tag; // 用于队列中的标记
    13 char name[8]; // 分区名称
    14 struct super_block* sb; // 本分区的超级块
    15 struct bitmap block_bitmap; // 块位图
    16 struct bitmap inode_bitmap; // i 结点位图
    17 struct list open_inodes; // 本分区打开的 i 结点队列
    18 }; 
    19 
    20 /* 硬盘结构 */ 
    21 struct disk {
          
           
    22 char name[8]; // 本硬盘的名称
    23 struct ide_channel* my_channel; // 此块硬盘归属于哪个 ide 通道
    24 uint8_t dev_no; // 本硬盘是主 0,还是从 1 
    25 struct partition prim_parts[4]; // 主分区顶多是 4 个
    26 struct partition logic_parts[8]; 
     // 逻辑分区数量无限,但总得有个支持的上限,那就支持 8 个
    27 }; 
    28 
    29 /* ata 通道结构 */ 
    30 struct ide_channel {
          
           
    31 char name[8]; // 本 ata 通道名称
    32 uint16_t port_base; // 本通道的起始端口号
    33 uint8_t irq_no; // 本通道所用的中断号
    34 struct lock lock; // 通道锁
    35 bool expecting_intr; //表示等待硬盘的中断
    36 struct semaphore disk_done; // 用于阻塞、唤醒驱动程序
    37 struct disk devices[2]; // 一个通道上连接两个硬盘,一主一从
    38 };
    

    每个ide_channel有一个互斥锁lock,用来保证同一时间只操作通道上的一个硬盘
    有一个信号量disk_done用来使驱动在等待硬盘时自动挂起。
    idle线程:

     22 static void idle(void* arg UNUSED) {
          
           
     23 while(1) {
          
           
     24 thread_block(TASK_BLOCKED); 
     25 //执行 hlt 时必须要保证目前处在开中断的情况下
     26 asm volatile ("sti; hlt" : : : "memory"); 
     27 } 
     28 }
    

    在初始化线程环境时,用thread_start创建idle线程。一运行idle线程就会被阻塞。修改schedule函数,使得
    就绪队列中没有元素时就从阻塞队列中唤醒idle线程,然后调用asm volatile (“sti; hlt” : : : “memory”); 作用是开中断和使cpu停止运行,直到下一次外部中断。
    thread_yield使当前 线程换下并加入就绪队列。
    thread_yield和thread_block的区别是,被换下来线程加入就绪队列(自旋锁?)
    ticks_to_sleep:

    52 static void ticks_to_sleep(uint32_t sleep_ticks) {
          
           
    53 uint32_t start_tick = ticks; 
    54 
    55 /* 若间隔的 ticks 数不够便让出 cpu */ 
    56 while (ticks - start_tick < sleep_ticks) {
          
           
    57 thread_yield(); 
    58 } 
    59 }
    

    ticks_to_sleep利用thread_yield使当前线程sleep_ticks 个滴答数,ticks是一个全局变量,记录每一次中断以来的滴答数。
    void mtime_sleep(uint32_t m_seconds): 把毫秒数转换成ticks并调用ticks_to_sleep:
    busy_wait(struct disk* hd): 对磁盘进行忙等,每隔10毫秒调用一次mtime_sleep(10); 最长等待30秒。
    void ide_read(struct disk* hd, uint32_t lba, void* buf, uint32_t sec_cnt)负责从硬盘读取数据
    首先锁住硬盘所在通道,根据硬盘数据结构中的dev_no属性选择硬盘, 写入待读入的起始扇区号和扇区数, 利用cmd_out写入读命令,在cmd_out中会令当前通道的expecting_intr为true,表示正等待硬盘中断,然后通过信号量阻塞自己。 被硬盘中断唤醒后调用 busy_wait检查硬盘是否准备好数据,没有准备好就让出cpu,最多等30秒,然后调用read_from_sector真正读取数据,最后释放锁。
    read和write的最小单位都是扇区,不管buf大小是否大于一个扇区

     208 void intr_hd_handler(uint8_t irq_no) {
          
           
    209 ASSERT(irq_no == 0x2e || irq_no == 0x2f); 
    210 uint8_t ch_no = irq_no - 0x2e; 
    211 struct ide_channel* channel = &channels[ch_no]; 
    212 ASSERT(channel->irq_no == irq_no); 
    213 /* 不必担心此中断是否对应的是这一次的 expecting_intr,
    214 * 每次读写硬盘时会申请锁,从而保证了同步一致性 */ 
    215 if (channel->expecting_intr) {
          
           
    216 channel->expecting_intr = false; 
    217 sema_up(&channel->disk_done); 
    218 
    219 /* 读取状态寄存器使硬盘控制器认为此次的中断已被处理,
     从而硬盘可以继续执行新的读写 */ 
    220 inb(reg_status(channel)); 
    221 }  
    

    硬盘中断程序会检查发出中断的通道是否在等待中断,如果是的话就通过信号量加一唤醒线程。
    有三种方式使硬盘可以产生新的中断:
    1 读取了 status 寄存器。
    2 发出了 reset 命令。
    3 或者又向 reg_cmd 写了新的命令。

  • 读取分区表
    Linux中所有的设备都在/dev/目录下,硬盘命名规则是[x]d[y][n],[x]是硬盘分类,【y】是设备号,【n】是分区号

     56 struct partition_table_entry {
          
           
     57 uint8_t bootable; // 是否可引导
     58 uint8_t start_head; // 起始磁头号
     59 uint8_t start_sec; // 起始扇区号
     60 uint8_t start_chs; // 起始柱面号
     61 uint8_t fs_type; // 分区类型
     62 uint8_t end_head; // 结束磁头号
     63 uint8_t end_sec; // 结束扇区号
     64 uint8_t end_chs; // 结束柱面号
     65 /* 更需要关注的是下面这两项 */ 
     66 uint32_t start_lba; // 本分区起始扇区的 lba 地址
     67 uint32_t sec_cnt; // 本分区的扇区数目
     68 } __attribute__ ((packed)); // 保证此结构是 16 字节大小
     69 
     70 /* 引导扇区,mbr 或 ebr 所在的扇区 */ 
     71 struct boot_sector {
          
           
     72 uint8_t other[446]; // 引导代码
     73 struct partition_table_entry partition_table[4]; 
     // 分区表中有 4 项,共 64 字节
     74 uint16_t signature; // 启动扇区的结束标志是 0x55,0xaa, 
     75 } __attribute__ ((packed));
    

    书中用fdisk对硬盘分区
    partition_scan(struct disk* hd, uint32_t ext_lba) :扫描硬盘 hd 中地址为 ext_lba 的扇区中的所有分区,通过ide_read(hd, ext_lba, bs, 1)把扇区读取到用malloc分配的内存bs中,遍历分区表,如果遇到子扩展分区就会递归调用partition_scan。 遇到主分区和逻辑分区就利用标志hd->prim_parts[p_no].part_tag和 &hd->logic_parts[l_no].part_tag把他们加入到全局的分区列表partition_list中。 分区的数据结构指向硬盘,硬盘指向通道

    在硬盘数据结初始化函数ide_init()中,根据0x475的内容获取硬盘数量。对每个通道的每个硬盘调用partition_scan(hd, 0)。最后所有的内容汇总到全局数组struct ide_channel channels[2]中。

文件系统

微软在FAT系统中利用单向链表组织、跟踪文件的所
有块。
在这里插入图片描述

在unix中,文件在磁盘中利用inode结构来索引所有的块
inode 大小固定
每个inode有15个索引项,前12个项是文件前12个块的地址
第13个索引项指向一级间接块索引表(占一块),可以索引256个块
第15和16是二级和三级块索引表,所以inode总共可以索引12+256+256*256+256*256*256个块(块为4kb时大约为70G)

目录和文件都使用inode保存
目录项中包含文件名、inode 编号和文件类型
文件类型包括目录和普通文件等

通过文件名/a/b.txt找实体数据块的过程:首先在根目录中找到a对应额目录项,通过其inode编号读取a目录,然后找到b.txt的目录项并通过inode读取b.txt.
根目录在文件系统中是固定的。
LINUX中的目录项:
在这里插入图片描述
每个分区都有一个超级块:

在这里插入图片描述

 5 /* 超级块 */ 
 6 struct super_block {
    
     
 7 uint32_t magic; 
 // 用来标识文件系统类型
 // 支持多文件系统的操作系统通过此标志来识别文件系统类型
 8 uint32_t sec_cnt; // 本分区总共的扇区数
 9 uint32_t inode_cnt; // 本分区中 inode 数量
10 uint32_t part_lba_base; // 本分区的起始 lba 地址
11 
12 uint32_t block_bitmap_lba; // 块位图本身起始扇区地址
13 uint32_t block_bitmap_sects; // 扇区位图本身占用的扇区数量
14 
15 uint32_t inode_bitmap_lba; // i 结点位图起始扇区 lba 地址
16 uint32_t inode_bitmap_sects; // i 结点位图占用的扇区数量
17 
18 uint32_t inode_table_lba; // i 结点表起始扇区 lba 地址
19 uint32_t inode_table_sects; // i 结点表占用的扇区数量
20 
21 uint32_t data_start_lba; // 数据区开始的第一个扇区号
22 uint32_t root_inode_no; // 根目录所在的 I 结点号
23 uint32_t dir_entry_size; // 目录项大小
24 
25 uint8_t pad[460]; // 加上 460 字节,凑够 512 字节 1 扇区大小
26 } __attribute__ ((packed));

分区的布局:
在这里插入图片描述

  • 创建文件系统

     6 /* inode 结构 */ 
     7 struct inode {
          
           
     8 uint32_t i_no; // inode 编号
     9 
    10 /* 当此 inode 是文件时,i_size 是指文件大小, 
    11 若此 inode 是目录,i_size 是指该目录下所有目录项大小之和*/ 
    12 uint32_t i_size; 
    13 
    14 uint32_t i_open_cnts; // 记录此文件被打开的次数
    15 bool write_deny; // 写文件不能并行,进程写文件前检查此标识
    16 
    17 /* i_sectors[0-11]是直接块,i_sectors[12]用来存储一级间接块指针 */ 
    18 uint32_t i_sectors[13]; 
    19 struct list_elem inode_tag; 
    20 }; 
    21 #endif
    
    9 #define MAX_FILE_NAME_LEN 16 // 最大文件名长度
    10 
    11 /* 目录结构 */ 
    12 struct dir {
          
           
    13 struct inode* inode; 
    14 uint32_t dir_pos; // 记录在目录内的偏移
    15 uint8_t dir_buf[512]; // 目录的数据缓存
    16 }; 
    17 
    18 /* 目录项结构 */ 
    19 struct dir_entry {
          
           
    20 char filename[MAX_FILE_NAME_LEN]; // 普通文件或目录名称
    21 uint32_t i_no; // 普通文件或目录对应的 inode 编号
    22 enum file_types f_type; // 文件类型
    23 }; 
    

    书中为了方便一块等于一扇区,分区内最大文件数为4096
    partition_format(struct partition* part)为磁盘上每一个分区创建文件系统(格式化)。
    首先定义引导块,超级块,inode位图,inode数组。剩余空间分配空闲块位图和空闲块。inode位图占用扇区数为最大文件数除以扇区比特数。因为空闲块位图和空闲块两个互相决定对方大小,所以首先根据全部剩余空间预设一个位图长度,剩余空间减去位图长度就是空闲块。然后根据剩余块修改位图长度。根目录inode编号为0。
    根据分区的布局初始化超级块,并保存到磁盘中
    利用malloc申请一个缓存区buf,用来存放各个位图的初始化数据,大小设置为三个位图中最大的。

    buf[0] |= 0x01; // 第 0 个块预留给根目录,位图中先占位
    72 uint32_t block_bitmap_last_byte = block_bitmap_bit_len / 8; 
    73 uint8_t block_bitmap_last_bit = block_bitmap_bit_len % 8; 
    74 uint32_t last_size = SECTOR_SIZE - \ 
     (block_bitmap_last_byte % SECTOR_SIZE); 
     // last_size 是位图所在最后一个扇区中,不足一扇区的其余部分
    75 
    76 /* 1 先将位图最后一字节到其所在的扇区的结束全置为 1,
     即超出实际块数的部分直接置为已占用*/ 
    77 memset(&buf[block_bitmap_last_byte], 0xff, last_size); 
    78 
    79 /* 2 再将上一步中覆盖的最后一字节内的有效位重新置 0 */ 
    80 uint8_t bit_idx = 0; 
    81 while (bit_idx <= block_bitmap_last_bit) {
          
           
    82 buf[block_bitmap_last_byte] &= ~(1 << bit_idx++); 
    83 } 
    84 ide_write(hd, sb.block_bitmap_lba, buf, sb.block_bitmap_sects);
    

    首先初始化空闲块位图,所有位置0,除了第0个块预留给根目录。而位图占用的最后一个扇区的剩余部分置1. 这里必须置1 的原因是保存在磁盘中的超级块中只有扇区级别的大小(空闲块位图大小),并没有字节或位级别的大小,所以只能在位图内部屏蔽那些不能用的位。
    初始化inode位图,清空buf。第0个inode分给根目录,写入磁盘
    初始化inode表中的第0个inode,指向根目录实体。目录大小为两个目录项(.和…),第0个索引项指向第0个空闲块。写入磁盘
    在第0个空闲块位置写入两个目录项

    filesys_init()在所有磁盘的所有分区(不管是否存在)上搜索文件系统,若没有则在磁盘上进行格式化。判断分区是否存在的依据是sec_cnt属性是否为0,判断分区是否有文件系统的依据是超级块开头的魔数是否等于特定值。

    bool mount_partition(struct list_elem* pelem, int arg) :根据分区名从分区表中找到分区,并从磁盘中读取该分区的超级块,空闲块位图,inode位图,作为分区数据结构的属性。 首先malloc一个扇区,读取超级块。然后根据超级块的记录申请空闲块位图,inode位图的需要的内存,然后从磁盘相应位置读取。最后初始化分区的open_inodes。
    在 filesys_init()中调用mount_partition:

    92 void filesys_init() {
          
           
    …略
    243 /* 确定默认操作的分区 */ 
    244 char default_part[8] = "sdb1"; 
    245 /* 挂载分区 */ 
    246 list_traversal(&partition_list, mount_partition, (int)default_part); 
    247 }
    
  • 文件描述符
    在这里插入图片描述
    open返回的文件的描述符是PCB中文件描述符数组的下标,通过该数组的元素中获取全局文件表中文件结构的下标。从文件结构中获取文件的inode。 PCB数组的前三个元素分别是0,1,2。 一个inode可以对应多个文件结构

     8 /* 文件结构 */ 
     9 struct file {
          
           
    10 uint32_t fd_pos; 
     // 记录当前文件操作的偏移地址,以 0 为起始,最大为文件大小-1 
    11 uint32_t fd_flag; 
    12 struct inode* fd_inode; 
    13 };
    
    14 struct inode_position {
          
           
    15 bool two_sec; // inode 是否跨扇区
    16 uint32_t sec_lba; // inode 所在的扇区号
    17 uint32_t off_size; // inode 在扇区内的字节偏移量
    18 };
    
  • inode相关函数
    inode_position用来临时保存inode所在扇区,扇区内偏移,以及是否跨扇区。
    void inode_locate(struct partition* part, uint32_t inode_no, struct inode_position* inode_pos),用来根据inode生成inode_position。
    inode_sync(struct partition* part, struct inode* inode, void* io_buf):把inode更新到磁盘中。函数中会调用inode_locate,如果发现inode跨区,就要首先读取两个扇区,修改后再写入两个扇区。读取时的缓区需要调用者提供。内存中inode的write_deny 写入磁盘时必须设置为false. inode_tag属性清空前后指针。

    inode_open(struct partition* part, uint32_t inode_no):根据inode_no返回inode。先在part->open_inodes中寻找,如果找不到就利用 inode_locate,然后从磁盘中读取到申请的内存中。这里必须是内核内存,实现方法是把pcb中页表置空,这样malloc就会把当前线程判断为内核线程。然后把inode加入part->open_inodes,inode的打开数+1。
    inode_close(struct inode* inode): 关闭中断,检查inode->cnt, 如果减一之后为0,从open_inodes中移除inode,同时从内核空间里释放inode内存。打开中断
    inode_init(uint32_t inode_no, struct inode* new_inode) 初始化给定的inode

  • 文件相关函数
    pcb_fd_install(int32_t globa_fd_idx) 把全局文件表中的下标注册到当前线程或进程的文件描述符数组中

  • 目录相关函数
    search_dir_entry(struct partition* part, struct dir* pdir, const char* name, struct dir_entry* dir_e) 在pdir目录中寻找名为name的文件或目录。需要依次读取inode记录的所有块中的目录项,写目录项时保证目录项不跨页。如果inode中记录的某个块地址为0,则跳过这个块。造成块为0的原因是某个块的目录项被全部删除后,会回收这个块。
    sync_dir_entry(struct dir* parent_dir, struct dir_entry* p_de, void* io_buf) 将目录项写入父目录中。同样是遍历父目录inode中的140个块,如果碰到未分配的块,就利用block_bitmap_alloc 为其分配一扇区,并从扇区开始处写入目录项。如果此时是第12个块,则分配两个块,一个作为一级间接块索引表,一个作为第0 个间接块。 如果碰到已分配的块,遍历所有可能的目录项,利用目录项的f_type属性判断其能否被分配给新目录项,若该属性为为FT_UNKNOWN,表示其可以被利用。 注意分配两个块时,如果第二个块分配失败,需要回滚操作,重置block_bitmap的相应位置。分配扇区后要把block_bitmap同步磁盘。
    int search_file(const char* pathname, struct path_search_record* searched_record) 从根目录开始查找,递归地调用search_dir_entry。search_dir_entry需要输入目录或文件名name,使用sub_path = path_parse(sub_path, name)得到,path_parse可以将最上层路径名解析出来。对每一级父目录进行搜索之前要打开目录的inode,搜索完成后关闭inode。如果某一级搜索失败,则保持目录的inode为打开状态,方便后续的文件写入操作。
    函数返回inode编号。其它的结果保存在path_search_record中。

     28 struct path_search_record {
          
           
    29 char searched_path[MAX_PATH_LEN]; // 查找过程中的父路径
    30 struct dir* parent_dir; // 文件或目录所在的直接父目录
    31 enum file_types file_type;
    
  • 创建文件
    file_create(struct dir* parent_dir, char* filename, uint8_t flag) 创建文件需要从inode位图申请inode号,从内存中申请inode数据结构所需的内存,通过inode_sync把inode写入到磁盘中的数组。找到全局文件表的空位并写入,把目录项写入父目录中,把inode加入全局的链表,把文件安装到线程的文件描述符表中。几个从位图申请的操作容易出问题,所以某一个操作失败之后要回滚之前成功的操作(goto rollback):

    169 rollback: 
    170 switch (rollback_step) {
          
           
    171 case 3: 
    172 /* 失败时,将 file_table 中的相应位清空 */ 
    173 memset(&file_table[fd_idx], 0, sizeof(struct file)); 
    174 case 2: 
    175 sys_free(new_file_inode); 
    176 case 1: 
    177 /* 如果新文件的 i 结点创建失败,
     之前位图中分配的 inode_no 也要恢复 */ 
    178 bitmap_set(&cur_part->inode_bitmap, inode_no, 0); 
    179 break; 
    180 } 
    181 sys_free(io_buf); 
    182 return -1; 
    183 }
    

    而同步位图和同步inode到硬盘的操作一般不会失败。

    int32_t sys_open(const char* pathname, uint8_t flags)
    打开文件的系统调用的内核实现,如果文件不存在且flag|O_CREAT=O_CREAT则调用file_create创建。利用 search_file判断文件是否存在,并得到parent_dir。
    打开inode的具体实现为file_open(uint32_t inode_no, uint8_t flag)。和file_create一样,需要在全局文件表找到空位,并插入新的一项,并用inode_open打开inode数据结构作为其属性。内部调用inode_open,以inode数据结构中的write_deny属性作为锁来限制多个进程写同一个文件。如果打开的文件结构需要写文件,则把writr_deny设置为true。
    关闭操作针对的是文件结构file ,函数为int32_t file_close(struct file* file),内部把文件结构清零,并调用inode_close。

  • 文件写入
    file_write(struct file* file, const void*buf, uint32_t count) 从文件末端写入。一次性申请写入操作需要的全部快。
    分成三种情况,写入后块总数和写入前块总数都大于12,写入前小于12写入后大于12,写入前后都小于12三种情况。例如,对于第一种情况,要从块位图中申请一块作为一级间接表,后续从位图中申请的块号直接写入到间接表中。每分配一个块就将位图同步到磁盘。分配完块后,利用一个块大小的缓存将内存中的数据写入到磁盘中。更新文件结构中的指针。最后再把inode同步到磁盘。
    int32_t sys_write(int32_t fd, const void* buf, uint32_t count)
    write系统调用的内核实现,fd是PCB中文件描述符。如果fd为标准输出,则直接调用console_put_str(tmp_buf)。否则把fd转换成文件结构并调用file_write.

  • 读取文件
    和写入类似,也是输入文件结构,然后分成三种情况。读取是从file->fd_pos开始,读取完成之后需要更新fd_pos指针。
    int32_t sys_lseek(int32_t fd, int32_t offset, uint8_t whence)设置文件的偏移指针。有三种参考位置,当前读写指针,文件开头,和文件末尾。

    SEEK_SET,offset 的参照物是文件开始处,也就是将读写位置指针设置为距文件开头偏移 offset 个字节处。
    SEEK_CUR,offset 的参照物是当前读写位置,也就是将读写位置指针设置为当前位置+offset。
    SEEK_END,offset 的参照物是文件尺寸大小,即文件最后一个字节的下一个字节,也就是将读写位 置指针设置为文件尺寸+offset。

  • 文件删除

    bool delete_dir_entry(struct partition* part, struct dir* pdir, uint32_t inode_no, void* io_buf) 从目录中删除inode_no对应的目录项。
    需要遍历目录inode中的140个块号,因为和文件inode不同,其中不为0的块号不一定是连续的。找到目录项后,如果对应块只有这一项就回收这一块,如回收间接块后间接表为空就回收间接表。
    void inode_release(struct partition* part, uint32_t inode_no)
    先回收inode索引的所有块,同样需要遍历140个块号,因为此inode可能对应目录。然后回收inode本身,最后调用inode_close
    int32_t sys_unlink(const char* pathname) 删除文件的系统调用,首先用search_file找到inode并打开父目录。然后根据inode查找全局文件表,如果已经打开就不允许删除。否则调用delete_dir_entry和inode_release,然后关闭父目录。

  • 目录创建
    (1)确认待创建的新目录在文件系统上不存在。
    (2)为新目录创建 inode。
    (3)为新目录分配 1 个块存储该目录中的目录项。
    (4)在新目录中创建两个目录项“.”和“…”,这是每个目录都必须存在的两个目录项。
    (5)在新目录的父目录中添加新目录的目录项。
    (6)将以上资源的变更同步到硬盘。
    和创建文件时一样,失败的步骤要回滚。

  • 遍历目录
    dir_entry* sys_readdir(struct dir* dir) ,类似于迭代器,每调用一次返回一个不同的目录项。文件结构中的fd_pos属性用来记录以及遍历过的目录项的大小之和。通过fd_pos属性避免重复遍历。
    sys_rewinddir(struct dir* dir)
    将fd_pos置零。

  • 删除目录
    int32_t sys_rmdir(const char* pathname)
    只允许删除空目录,只有.和…这两个目录项的目录为空。然后和删除文件一样,删除其在父目录中的对应项和release_inode。

  • 工作目录
    当前线程或进程的工作目录保存在PCB中,只保存工作目录的inode_no。
    char* sys_getcwd(char* buf, uint32_t size) 获取工作目录的绝对路径。由于inode中不保存文件名或目录名,所以需要从工作目录的父目录中根据inode_no检索到目录项,然后从中获取工作目录名。以此类推,从下往上获取从工作目录到根目录的所有目录名。

  • 文件属性
    int32_t sys_stat(const char* path, struct stat* buf) 获取文件大小,类型和inode_no。
    如要获取inode的类型(文件或目录),可以直接利用路径搜索函数返回结构searched_record中的文件类型属性。

系统交互

  • sys_fork 系统调用
    copy_pcb_vaddrbitmap_stack0(struct task_struct* child_thread, struct task_struct* parent_thread) 把当前进程的pcb拷贝给子进程的pcb。要修改pid,时间片和队列中的tag等属性。此外还需要把父进程的虚拟地址位图拷贝一份给子进程。
    对于那些父进程虚拟位图中已经分配出去的页(进程体,栈和堆申请),需要通过遍历位图找出来,并复制到子进程的空间里。
    复制需要用到内核空间中的一页作为缓存,用内核空间是因为复制过程中需要切换到子进程的新页表,而内核空间是所有进程公用的,父子进程都能访问到。复制过来的内容存放在从用户物理内存池中申请到的新物理地址上,同时更新新页表。
    然后是修改 intr_stack中断栈和内核线程栈thread_stack。需要把中断栈中eax设置为0,作为子进程的返回值。同时要把子进程PCB中的属性self_kstack设置为thread_stack栈顶,为的是switch_to函数能准确地跳转到中断返回函数。
    父进程PCB中的文件描述表是一个 静态的数组,所以会被完全复制到子进程的PCB中,所以子进程要做的只是更新全局inode表中文件的打开次数。

  • 初始化线程环境

    14 /* 初始化线程环境 */ 
    215 void thread_init(void) {
          
           
    696
    222 /* 先创建第一个用户进程:init */ 
    223 process_execute(init, "init"); 
     // 放在第一个初始化,这是第一个进程,init 进程的 pid 为 1 
    224 
    225 /* 将当前 main 函数创建为线程 */ 
    226 make_main_thread(); 
    227 
    228 /* 创建 idle 线程 */ 
    229 idle_thread = thread_start("idle", 10, idle, NULL); 
    230 
    

    创建用户进程,在make_main_thread()之前,目的是使得第一个进程的pid为1。
    make_main_thread()是把 将 kernel 中的 main 函数完善为主线程。

  • 完善write read
    write系统调用, 根据fd的不同,可以输出到文件和屏幕。
    如果输入到屏幕,则调用console_put_str,它是加了终端锁的put_str。
    put_str内部调用了put_char,两者都是用汇编实现
    read系统调用,也可以利用ioq_getchar从键盘的缓冲区中读取字符。键盘中断处理函数每次向缓冲区中存入一个字符。
    putchar系统调用就是console_put_str的封装。
    clear 系统调用是cls_screen汇编函数的封装,功能是清空屏幕。
    sprintf和prinf内部都调用了vprintf,其功能是按照格式将参数列表中的参数输入到字符串中。printf内部调用了 write(1, buf, strlen(buf)),写入到标准输出中。sprintf直接输出到buf中。

  • shell
    shell 的调用过程: 在kernel的main函数中有init_all,其中的线程初始化部分会调用process_execute(init, “init”):

     void init(void) {
          
          
       uint32_t ret_pid = fork();
       if(ret_pid) {
          
            // 父进程
          while(1);
       } else {
          
          	  // 子进程
          my_shell();
       }
       panic("init: should not be here");
    }
    

    shell 内部会循环调用read_line, read_line每次利用read从标准输入中读取一个字符,并输出到屏幕,同时把字符存入shell的缓冲区中,直到缓冲区满或遇到回车符。
    读取完一行后调用 cmd_parse(char* cmd_str, char** argv, char token)其功能是 分析字符串 cmd_str 中以 token 为分隔符的单词,将各单词的指针存入 argv 数组
    路径解析转换:
    先利用make_clear_abs_path(char* path, char* final_path)将相对路径和用getcwd得到的当前路径拼接起来。
    在wash_path(char* old_abs_path, char* new_abs_path)中利用path_parse逐个读取用斜杠分割的路径名,读取到一个就缓存到new_abs_path中,如果读到…则从new_abs_path中删除一个路径名。

  • 加载用户进程
    segment_load(int32_t fd, uint32_t offset, uint32_t filesz, uint32_t vaddr)
    加载elf中的段到内存中,vaddr是程序头表中的段起始虚拟地址。
    根据filesz计算出段将要占用的页数,然后判断每个页是否存在于页表中,如果不存在就利用get_a_page(PF_USER, vaddr_page)申请,如果存在就直接覆盖。最后更新文件指针,并读取文件内容到vaddr。
    load(const char* pathname) 调用segment_load加载文件中每一个段,并返回程序入口地址
    sys_execv(const char* path, const char* argv[]) 用path指向的程序替换当前进程执行,并修改进程名。将intr_stack中的ebx 用来保存参数数组地址,ecx保存参数个数,eip保存入口地址,esp改为0xc0000000,使新进程的用户栈从新开始。 最后调用asm volatile (“movl %0, %%esp; jmp intr_exit” : : “g” (intr_0_stack) : “memory”); 直接利用终中断退出函数intr_exit执行新进程。
    在这里插入图片描述

    c标准库是操作系统无关的,c运行库是和操作系统相关的,其最主要的功能是初始化运行环境,给main函数传递参数等。为了从ecx和ebx中读取参数并压入main函数的栈,实现一个简陋的crt:

    1 [bits 32] 
    2 extern main 
    3 section .text 
    4 global _start 
    5 _start: 
    6 ;下面这两个要和 execv 中 load 之后指定的寄存器一致
    7 push ebx ;压入 argv 
    8 push ecx ;压入 argc 
    9 call main
    

    其中_start是elf中的入口。

    在编译的时候string.o、syscall.o、stdio.o、assert.o 和 start.o 打包成静态库文件 simple_crt.a,并和用户程序链接。

  • exit 和 wait
    进程需要主动调用void sys_exit(int32_t status)来结束自己。
    sys_exit中需要遍历线程表,找出所有子进程并把他们过继给init进程(第一个用户进程),把status保存在pcb中,释放当前进程所占用的物理页框(通过遍历PDE和PTE找),回收用户虚拟地址池占用的物理内存,关闭进程打开的文件(删除全局文件结构,inode引用次数减一,在Linux中只是全局文件结构的引用次数减一),将正在等待状态的父进程唤醒,使自己进入task_hanging状态。
    对于调用sys_exit进程的父进程,需要找出一个处在hanging状态的子进程,读取其pcb中的status, 从就绪队列和全部队列中删除其表项,回收pcb以及其中的pgdir和pid。如果还有其他子进程,就又进入waiting状态,等待子进程唤醒。

    孤儿进程是指过继给init进程的进程
    僵尸进程是指,调用exit时,父进程还没有调用wait,此时进程的用户空间已经释放,但是pcb,pid等资源仍然被占用。
    在shell中若想运行可执行程序,要么在开机前由其它机器写入磁盘的文件系统,要么在运行主线程时读取磁盘扇区中的程序并写入文件系统。主线程main在执行完各种初始化和写入文件系统后会调用exit。
    第一个用户进程init利用fork出的子进程运行shell,并在自己的进程内不断调用wait接收僵尸进程。

  • 管道
    管道应该能支持串联,即A|B|C。但是书中的实现只能先执行完A,把结果缓存在AB管道中,然后再执行B。因为消费者和生产者不同时存在,所以传输数据的总大小不能大于缓冲区的大小。
    管道通过复用文件描述符和文件结构实现,在文件结构中加了一个标识其是否是管道。需要配套地修改write、read以及文件打开和关闭的系统调用。
    管道的使用:首先在当前进程新建两个文件描述符,然后在他们之间建立管道,最后用它们的文件结构替换第0个或第一个描述符对应的文件结构。

猜你喜欢

转载自blog.csdn.net/weixin_39849839/article/details/110383087