Lab3 - 进程管理

实验目的

理解操作系统进程管理核心机制

  • 通过实现完整的进程管理子系统,深入理解:

  • 进程的创建、执行、等待和终止全生命周期

  • 经典的fork-exec进程创建模式

  • 进程调度与上下文切换机制

  • 系统调用接口的实现原理

掌握进程调度核心算法

具体实现并理解:

  • 多进程环境下的CPU时间片轮转调度

  • 进程状态转换(就绪、运行、阻塞等)

  • 空闲进程(idle)的作用与实现

  • 非阻塞式sleep的系统支持

实验要求

本实验主要完成自制简单操作系统的进程管理功能,通过实现一个简单的任务调度,介绍基于时间中断进行进程切换完成任务调度的全过程

  1. idle进程实现

    • 将OS初始化代码的最后部分包装为idle进程(PID=0

    • 当没有其他用户进程就绪时,调度器应选择idle进程运行

  2. 进程创建机制
    • 实现fork系统调用,完整复制父进程资源

    • 实现exec系统调用,支持加载并执行新程序

    • 形成UNIX风格的fork-exec进程创建模式:

      • idle进程首先fork出子进程

      • 子进程通过exec加载应用程序替换内存镜像

  3. 系统调用增强
    • 实现非阻塞式sleep系统调用:

      • 调用进程进入等待队列

      • 内核切换其他就绪进程执行

      • 若无用户进程就绪则执行idle进程

    • 实现getpid系统调用,返回当前进程PID

选做

本次实验有一项选做内容:完成一个类似Unix/Linux的系统调用wait

  • 我们需要封装一个wait()库函数,使得测试脚本的wait测试运行
  • 具体的实现方法,将完全由你自由发挥。实验说明并不会指导你

面向测试用例编程(TDD)

先看一看这次用户程序代码lab3/app/main.c是什么

#include "lib.h"
#include "types.h"

int data = 0;

int uEntry(void)
{
    printf("==============TEST FOR BASIC==============\n");
    int ret = fork();
    int i = 8;
    int pid = getpid();

    if (ret == 0)
    {
        data = 2;
        while (i != 0)
        {
            i--;
            printf("Child Process (pid:%d): Pong %d, %d;\n", pid, data, i);
            sleep(128);
        }
        exit();	
        printf("If exit() worked, you should not get here.\n");
    }
    else if (ret != -1)
    {
        data = 1;
        while (i != 0)
        {
            i--;
            printf("Parent Process (pid:%d): Ping %d, %d;\n", pid, data, i);
            sleep(128);
        }
    }
    printf("===========TEST FOR BASIC OVER===========\n\n");
    exit(); 	// if you wants to test you wait function, remove this line.
    /*
    ......
    */
    while (1)
        ;
    return 0;
}

其中forkexitsleepgetpid等都是以前没有涉及的函数,所以,第一步就是在lab3/lib/syscall.c中补上相应的库函数

怎么补全?
  • 所有的库函数都需要系统调用,进入内核态
  • 你需要进入内核kvm.c看看内核是如何被加载和操作的

实现对应的系统调用

通过实验2printf()的锻炼,相信大家都对系统调用的流程有了一定的了解,所以接下来为了让这些库函数起作用,需要实现对应的系统调用的处理例程

相关资料

实验2回顾

实验2的内容中,kernel启动之后会按lab2/kernel/main.c写到的进行一系列的初始化操作,然后调用load_UMain加载用户程序,并通过iret进入用户态,执行用户程序

详细看一下

uint32_t loadUMain(void) {
    int i = 0;
    int phoff = 0x34; // program header offset
    int offset = 0x1000; // .text section offset
    uint32_t elf = 0x200000; // physical memory addr to load
    uint32_t uMainEntry = 0x200000;
    Inode inode;
    int inodeOffset = 0;
    readInode(&sBlock, &inode, &inodeOffset, "/boot/initrd");
    for (i = 0; i < inode.blockCount; i++) {
        readBlock(&sBlock, &inode, i, (uint8_t *)(elf + i * sBlock.blockSize));
    }
    uMainEntry = ((struct ELFHeader *)elf)->entry; // entry address of the program
    phoff = ((struct ELFHeader *)elf)->phoff;
    offset = ((struct ProgramHeader *)(elf + phoff))->off;
    for (i = 0; i < 200 * 512; i++) {
        *(uint8_t *)(elf + i) = *(uint8_t *)(elf + i + offset);
    }
    enterUserSpace(uMainEntry);
}

实际上了解elf文件结构的同学很容易能发现上面这一段代码有很大的问题,这一段代码应该对应elf文件的加载,实际上现代操作系统的加载过程更加复杂

加载ELF文件

ELF(Executable and Linking Format)是一种对象文件格式(Object files), 而这里的对象文件格式又包括三种

  • 可重定位的对象文件(Relocatable file)——.o 文件
  • 可执行的对象文件(Executable file)
  • 可被共享的对象文件(Shared object file)——.so 动态库文件

在本课程的实验中不涉及.so动态库文件,我们就拿lab2 make 所产生的文件举例,在app目录下,main.o和uMain.elf都是 ELF 对象文件, 如果想要知道都属于哪类文件, 可以使用 file 命令查看

$ file main.o uMain.elf
main.o:    ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped
uMain.elf: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), statically linked, not stripped

其实以上内容都是我瞎编的, 你可以自己亲自尝试一下, 到底一个 ELF 对象文件哪类.

ELF头结构

ELF 文件由4部分组成, 分别为ELF 头(ELF Header), 程序头(Program Header Table), 节(Section)和节头(Section Header Table). 虽然不是每个 ELF 文件都包含这4部分内容, 但是 ELF 头的位置是固定的, 也是由 ELF 头中的数据来决定其他部分的组成. 下面是一张通用的 ELF 文件结构图

+----------------------+
|      ELF Header      |
+----------------------+
|   Program Header 0   |
+----------------------+
|   Program Header 1   |
+----------------------+
|         ...          |
+----------------------+
|      Section 0       |
+----------------------+
|      Section 1       |
+----------------------+
|         ...          |
+----------------------+
|   Section Header 0   |
+----------------------+
|   Section Header 1   |
+----------------------+
|         ...          |
+----------------------+

可以看到 ELF 头出现在最前面, 而其他部分的长度都是可变的, 只有 ELF Header是固定的. 我们给出 ELF Header的结构

struct ELFHeader {
    unsigned int   magic;
    unsigned char  elf[12];
    unsigned short type;
    unsigned short machine;
    unsigned int   version;
    unsigned int   entry;				//程序入口
    unsigned int   phoff;				//Program Header 偏移
    unsigned int   shoff;				//Section Header 偏移
    unsigned int   flags;
    unsigned short ehsize;			//此头长度
    unsigned short phentsize;		//程序头长度
    unsigned short phnum;				//程序头数
    unsigned short shentsize;		//节头长度
    unsigned short shnum;				//节头数
    unsigned short shstrndx;
};

我们通过一个例子来理解一个结构的内容, 我们用 GNU binutils 的readelf 工具查看一个 .o 文件的ELF 头内容

$ readelf -h uMain.elf
ELF Header:
  Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF32
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Intel 80386
  Version:                           0x1
  Entry point address:               0x0
  Start of program headers:          52 (bytes into file)
  Start of section headers:          8884 (bytes into file)
  Flags:                             0x0
  Size of this header:               52 (bytes)
  Size of program headers:           32 (bytes)
  Number of program headers:         3
  Size of section headers:           40 (bytes)
  Number of section headers:         10
  Section header string table index: 9

ELF 的开头四个字节(也就是结构中的 magic 和 12比特的 elf), 是机器无关的固定内容, 包括 magic=0x7f 以及三个EFL 字符.

ELF的加载主要和Program Header有关,所以还要看一下其结构

struct ProgramHeader {
    unsigned int type;
    unsigned int off;       //这个段第一个字节在文件中的偏移
    unsigned int vaddr;     //这个段第一个字节在内存中的虚拟地址
    unsigned int paddr;     //应当加载到的物理内存地址
    unsigned int filesz;    //这个段在 elf 文件中的长度
    unsigned int memsz;     //这个段在内存中的长度
    unsigned int flags;
    unsigned int align;
};

程序头描述的是这个段在文件中的位置和大小以及在内存中的位置和大小,同样可以用readelf查看

$ readelf -l uMain.elf

Elf file type is EXEC (Executable file)
Entry point 0x0
There are 3 program headers, starting at offset 52

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  LOAD           0x001000 0x00000000 0x00000000 0x008f0 0x008f0 R E 0x1000
  LOAD           0x002000 0x00002000 0x00002000 0x0000c 0x00010 RW  0x1000
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0x10

 Section to Segment mapping:
  Segment Sections...
   00     .text .rodata .eh_frame
   01     .got.plt .bss
   02

实际操作系统需要把Program Headerstype为LOAD即0x1的段加载到对应的物理内存

在加载 ELF 文件时,操作系统需要根据每个段的相关信息(offvaddrfileszmemsz)将段正确加载到内存中。具体过程如下:

  1. 文件偏移(off:每个段的数据在 ELF 文件中的位置,从该偏移量开始读取。
  2. 虚拟内存地址(vaddr:指定该段应该加载到虚拟内存中的位置。
  3. 文件大小(filesz:段在文件中实际占用的字节数。操作系统会将这部分内容从 ELF 文件中读取到内存中。
  4. 内存大小(memsz:段在内存中占用的总字节数。若内存大小大于文件大小,则会将内存中未使用的部分([vaddr + filesz, vaddr + memsz))清零。

因此,段的内容从 ELF 文件中读取到 vaddr 开始的内存位置,长度为 filesz,然后如果 memsz 大于 filesz,则会将剩余的内存空间清零,以确保段的内存区域完整并安全。

进入用户态

实验2通过enterUserSpace()跳转到entry处的代码进行用户态执行

void enterUserSpace(uint32_t entry) {
    uint32_t EFLAGS = 0;
    asm volatile("pushl %0":: "r" (USEL(SEG_UDATA))); // push ss
    asm volatile("pushl %0":: "r" (0x200000)); //TODO push esp, further modification
    asm volatile("pushfl"); //push eflags, sti
    asm volatile("popl %0":"=r" (EFLAGS));
    asm volatile("pushl %0"::"r"(EFLAGS|0x200));
    asm volatile("pushl %0":: "r" (USEL(SEG_UCODE))); // push cs
    asm volatile("pushl %0":: "r" (entry)); //TODO push eip, further modification
    asm volatile("pushl %0":: "r" (USEL(SEG_UDATA)));
    asm volatile("pushl %0":: "r" (USEL(SEG_UDATA)));
    asm volatile("pushl %0":: "r" (USEL(SEG_UDATA)));
    asm volatile("pushl %0":: "r" (USEL(SEG_UDATA)));
    asm volatile("popl %gs");
    asm volatile("popl %fs");
    asm volatile("popl %es");
    asm volatile("popl %ds");
    asm volatile("iret");
}

这一段是先在栈中设置好iret返回需要的eipcseflagsespss,然后通过栈设置gsfsesds。因为实验2默认只有一个用户进程,所以可以省略多进程利用iret切换时的额外操作。

启动时钟源

实验2已经使用以下代码对8253可编程计时器进行设置,使得8253以频率HZ产生时间中断信号发送给8259A可编程中断控制器;若依照代码框架lab2/kernel/kernel/i8259.c中给出的配置示例对8259A进行设置,时间中断的中断向量为0x20

#define TIMER_PORT 0x40
#define FREQ_8253 1193182
#define HZ 100

void initTimer() {
    int counter = FREQ_8253 / HZ;
    outByte(TIMER_PORT + 3, 0x34);
    outByte(TIMER_PORT + 0, counter % 256);
    outByte(TIMER_PORT + 0, counter / 256);
}

在实验2中,timerHandle 只是设置定期标签。而在lab3中,timerHandle将起到关键性作用。

分段内存管理

实验2中,默认操作系统一个内核进程,一个用户进程。

内核在链接时通过-Ttext 0x100000指定程序的起始地址,同时在加载时将内核进程加载到内存的0x100000处,设置GDT中内核代码段和数据段的基址为0x0

值得一提的是内核ss也指向数据段,设置esp0x200000;用户程序则不同,链接时指定程序起始地址为0x0,却加载到内存的0x200000,这时虚拟地址到物理地址就只能通过段选择子和GDT进行转换,所以设置代码段和数据段的段基址为0x200000

在后续实验中,用户多进程的寻址方式应沿用实验2的设计,即所有用户进程在链接时默认的起始地址均为 0x0,但实际加载到不同的物理内存地址。在实验3中,默认用户进程的起始地址为 0x200000,每个进程占用 0x100000 大小的内存空间。通过为每个进程设置不同的段基址,实现用户进程在内存中的隔离。这种方式理论上可以通过切换段选择子的值来完成用户进程的切换。

+-------------------------+ 0xffffffff
|           ...           |
+-------------------------+ 0x00400000
|      app 1 stack        |
+-------------------------+
|          app 1          |
+-------------------------+ 0x00300000
|      app 0 stack        |
+-------------------------+
|          app 0          |
+-------------------------+ 0x00200000
|      kernel stack       |
+-------------------------+
|         kernel          |
+-------------------------+ 0x00100000
|           ...           |
+-------------------------+ 0x00000000

进程

进程为操作系统资源分配的单位,每个进程都有独立的地址空间(代码段、数据段),独立的堆栈,独立的进程控制块。

以下为一个广义的进程生命周期中的状态转换图

  • 进程由其父进程利用FORK系统调用创建,则该进程进入RUNNABLE状态
  • 时间中断到来,RUNNABLE状态的进程被切换到,则该进程进入RUNNING状态
  • 时间中断到来,RUNNING状态的进程处理时间片耗尽,则该进程进入RUNNABLE状态
  • RUNNING状态的进程利用SLEEP系统调用主动阻塞;或利用系统调用等待硬件I/O,则该进程进入BLOCKED状态
  • 时间中断到来,BLOCKED状态的进程的SLEEP时间片耗尽;或外部硬件中断表明I/O完成,则该进程进入RUNNABLE状态
  • RUNNING状态的进程利用EXIT系统调用主动销毁,则该进程进入DEAD状态
                                 +------------+
            SLEEP TIME EXPIRED   |            |        SLEEP SYSCALL
               I/O FINISHED  +---+  BLOCKED   +<--+  I/O REQUEST SYSCALL
                             |   |            |   |
                             |   +------------+   |
                             V                    |
+------------+ FORK    +-----+------+      +------+-----+ EXIT    +------------+
|            | SYSCALL |            +<-----+            | SYSCALL |            |
|     NEW    +-------->+  RUNNABLE  |      |  RUNNING   +-------->+    DEAD    |
|            |         |            +----->+            |         |            |
+------------+         +------------+      +------------+         +------------+

进程控制块

在本课程和其它课程中,大家应该对进程控制块有了一定的了解,实验3开始操作系统提供对多进程的支持,简单介绍一下lab3中的进程控制块

struct ProcessTable {
    uint32_t stack[MAX_STACK_SIZE];         // 内核堆栈
    struct StackFrame regs;                 // 陷阱帧,保存上下文
    uint32_t stackTop;                      // 保存内核栈顶信息
    uint32_t prevStackTop;                  // 中断嵌套时保存待恢复的栈顶信息
    int state;                              // 进程状态:STATE_RUNNABLE、STATE_RUNNING、STATE_BLOCKED、STATE_DEAD
    int timeCount;                          // 当前进程占用的时间片
    int sleepTime;                          // 当前进程需要阻塞的时间片
    uint32_t pid;                           // 进程的唯一标识
    char name[32];                          // not used
};

实验3中的 StackFrame相对实验2中的TrapFrame 也稍微有点不同

struct StackFrame {
    uint32_t gs, fs, es, ds;
    uint32_t edi, esi, ebp, xxx, ebx, edx, ecx, eax;
    uint32_t irq, error;
    uint32_t eip, cs, eflags, esp, ss;
};

简单说就是将int指令会保存的ssespeflagscseiperror number都做为陷阱帧的部分,也就是说陷阱帧保存了陷入中断时硬件保存的内容

在实验中,采用线性表的方式组织pcb,也就是将pcb以数组形式连续存放,为了简单起见,可以将pcb的pid设为其索引。内核进程(IDLE)会占据0号pcb,剩下的分配给用户进程。同样为了简单,我们默认每个pcb对应进程的内存空间固定,pcb[i]对应的内存起始地址为(i + 1) * 0x100000,大小为0x100000

struct ProcessTable pcb[MAX_PCB_NUM];

进程切换与堆栈切换

下图为产生时间中断后,时间中断处理程序为两个用户态进程P1、P2进行进程切换的过程中,堆栈切换的简单图示

  1. 进程P1在用户态执行,8253可编程计时器产生时间中断
  2. 依据TSS中记录的进程P1的SS0:EPS0,从P1的用户态堆栈切换至P1的内核堆栈,并将P1的现场信息压入内核堆栈中,跳转执行时间中断处理程序
  3. 进程P1的处理时间片耗尽,切换至就绪状态的进程P2,并从当前P1的内核堆栈切换至P2的内核堆栈
  4. 从进程P2的内核堆栈中弹出P2的现场信息,切换至P2的用户态堆栈,从时间中断处理程序返回执行P2
D  O       31            0                    31            0
I  F      +-------+-------+                  +-------+-------+
R         |#######|#######|    OLD           |#######|#######|
E  E      +-------+-------+   SS:ESP OF P1   +-------+-------+
C  X      |#######|#######|     |            |#######|#######|
T  P      +-------+-------+<----+            +-------+-------+
I  A      |               |                  |#######|#######|    NEW
O  N      |               |                  +-------+-------+   SS:ESP OF P2
N  S      |               |                  |#######|#######|     |
   I      |               |                  +-------+-------+<----+
 | O      |               |                  |               |
 | N      |               |                  |               |
 |        |               |                  |               |
 !        *               *                  *               *
          *               *                  *               *
      +-- *               *                  *               * <-+
      |   USER STACK OF P1                   USER STACK OF P2    |
      |                                                          |
      | ENTER                                                    | LEAVE
      | KERNEL                                                   | KERNEL
      | SPACE                                                    | SPACE
      |                                                          |
D  O  |    31            0                   31            0     |
I  F  +-> +-------+-------+ ---------------> +-------+-------+ --+
R         |#######|#######|      SWITCH      |#######|#######|
E  E      +-------+-------+   KERNEL STACK   +-------+-------+
C  X      |#######|#######|                  |#######|#######|
T  P      +---------------+                  +---------------+
I  A      |      PID      |                  |      PID      |
O  N      +---------------+                  +---------------+
N  S      |   SLEEPTIME   |                  |   SLEEPTIME   |
   I      +---------------+                  +---------------+
 | O      |   TIMECOUNT   |                  |   TIMECOUNT   |
 | N      +---------------+                  +---------------+
 |        |     STATE     |                  |     STATE     |
 !        +-------+-------+<----+            +-------+-------+<----+
          |#######|  SS   |     |            |#######|  SS   |     |
          +---------------+  SS0:ESP0 OF P1  +---------------+  SS0:ESP0 OF P2
          |      ESP      |  FROM TSS        |      ESP      |  FROM TSS
          +---------------+                  +---------------+
          |     EFLAGS    |                  |     EFLAGS    |
          +-------+-------+                  +-------+-------+
          |#######|  CS   |                  |#######|  CS   |
          +-------+-------+                  +-------+-------+
          |      EIP      |                  |      EIP      |
          +---------------+                  +---------------+
          |     ERROR     |                  |     ERROR     |
          +---------------+                  +---------------+
          |      IRQ      |                  |      IRQ      |
          +---------------+                  +---------------+
          |      EAX      |                  |      EAX      |
          +---------------+                  +---------------+
          |      ECX      |                  |      ECX      |
          +---------------+                  +---------------+
          |      EDX      |                  |      EDX      |
          +---------------+                  +---------------+
          |      EBX      |                  |      EBX      |
          +---------------+                  +---------------+
          |      XXX      |                  |      XXX      |
          +---------------+                  +---------------+
          |      EBP      |                  |      EBP      |
          +---------------+                  +---------------+
          |      ESI      |                  |      ESI      |
          +---------------+                  +---------------+
          |      EDI      |                  |      EDI      |
          +-------+-------+                  +-------+-------+
          |#######|  DS   |                  |#######|  DS   |
          +-------+-------+                  +-------+-------+
          |#######|  ES   |                  |#######|  ES   |
          +-------+-------+                  +-------+-------+
          |#######|  FS   |    NEW           |#######|  FS   |    OLD
          +-------+-------+   SS:ESP OF P1   +-------+-------+   SS:ESP OF P2
          |#######|  GS   |     |            |#######|  GS   |     |
          +-------+-------+<----+            +-------+-------+<----+
          |               |                  |               |
          *               *                  *               *
          *               *                  *               *
          *               *                  *               *
          KERNEL STACK OF P1                 KERNEL STACK OF P2

在 lab2里,只有一个用户进程,我们将TSS中的ss0:esp0设成了一个固定的值,因此用户进程的内核栈固定。但是在 lab3里,我们将面临堆栈切换的问题,即每个用户进程的内核堆栈是不一样的,所以每次切换进程时需要将tss的esp0设置对应用户进程的内核堆栈位置

说了那么多进程切换的细节,那要如何选择下一个就绪进程呢?这就涉及到调度策略,目前只需要采用轮转调度(Round Robin)的策略即可,即依次调度进程1, 进程2, …, 进程n, 进程1…

你也可以按照你自己设计的顺序,n->n-1->...>1 或者 n->n-2..等等,只需要能够满足操作系统设计的基本要求便可以。

lab3/kernel/kernel/irqHandle.c中,你需要完成schedule()。当执行forksleep等系统调用时,或者timeHandle()被执行到,我们可能会需要使用到schedule()

/*
 * Schedules the next process to run
 * 
 * Returns:
 *   pid_t - The process ID of the scheduled process
 *           Returns -1 if no process is available for scheduling
 */
uint32_t schedule() {
    // TODO: Select the next process and perform context switching
    return -1;
}

内核IDLE进程

若没有处于RUNNABLE状态的进程可供切换,则需要切换至以下内核IDLE进程,该进程调用waitForInterrupt()执行hlt指令,hlt会使得CPU进入暂停状态,直到外部硬件中断产生。

框架代码的IDLE初始化过程相对简单,在lab3/kernel/kernel/kvm.c中的initIdle()为大家提供了idle的初始化。我们可能需要根据具体的需求,修改IDLE的初始化

/*
#define SYS_WRITE 0
#define SYS_FORK 1
#define SYS_EXEC 2
#define SYS_SLEEP 3
#define SYS_EXIT 4
#define SYS_GETPID 5

#define STD_OUT 0

#define MAX_BUFFER_SIZE 256
*/
void idle()
{
    // (SYS_FORK, 0, 0, 0, 0, 0)
    int32_t ret = syscall(1, 0, 0, 0, 0, 0);

    if (ret == 0)
    {
        // child: exec
        // (SYS_EXEC, Begin_Sector, Sector_Num, 0, 0, 0)
        syscall(2, (uint32_t)201, (uint32_t)(200), 0, 0, 0);
    }
    else
    {
        // parent: run only when no other process is Runnable
        // LOG: enter idle
        char buffer[] = "I'm the user idle!\nFirst fork: First entering the idle process\n";
        // (SYS_WRITE, STD_OUT, Buffer, Output_Size, 0, 0)
        syscall(0, 0, (uint32_t)buffer, sizeof(buffer) - 1, 0, 0);

        while (1)
        {
            // waitForInterrupt();
            // What's the function of `waitForInterrupt`?
            // Why we commented it out ?
            // you can also try a `putChar('i');` here
        };
    }
}

void initIdle(void)
{
    // init idle and make it a user process, while it's text is the text of kernel

    // 1. idle's kernel stack
    pcb[0].stackTop = (uint32_t)&(pcb[0].stackTop) - sizeof(struct StackFrame);
    /*
    WHY `- sizeod(struct StackFrame)` ?
        : We need to fake a frame later.
        ------------------------------------------
            struct ProcessTable {
                uint32_t stack[MAX_STACK_SIZE];
                struct StackFrame regs;				<- (uint32_t) & (pcb[i].stackTop) - sizeof(struct StackFrame)
                uint32_t stackTop;					<- (uint32_t) & (pcb[i].stackTop)
                uint32_t prevStackTop;
                ...
            };
        ------------------------------------------
    */
    pcb[0].prevStackTop = (uint32_t)&(pcb[0].stackTop);

    // 2. some status and message of process 0 control
    pcb[0].state = STATE_RUNNING;
    pcb[0].timeCount = MAX_TIME_COUNT;
    pcb[0].sleepTime = 0;
    pcb[0].pid = 0;
    // DO: for wait()
    pcb[0].ppid = -1;
    pcb[0].childCount = 0;
    // DO OVER

    // 3. init segment selectors
    pcb[0].regs.ss = USEL(SEG_IDLE_DATA);
    pcb[0].regs.cs = USEL(SEG_IDLE_CODE);
    pcb[0].regs.ds = USEL(SEG_IDLE_DATA);
    pcb[0].regs.es = USEL(SEG_IDLE_DATA);
    pcb[0].regs.fs = USEL(SEG_IDLE_DATA);
    pcb[0].regs.gs = USEL(SEG_IDLE_DATA);

    // 4. init important env registers
    pcb[0].regs.esp = 0x1fffff;
    pcb[0].regs.eip = (uint32_t)idle;
    pcb[0].regs.ebp = 0x1fffff;
    pcb[0].regs.eflags = 0x202;

    // 5. fake `iret`
    // 		: pretend you had get interrupted into kernel from user idle process
    current = 0;
    uint32_t tmp = pcb[current].stackTop;
    pcb[current].stackTop = pcb[current].prevStackTop;
    tss.esp0 = pcb[current].stackTop;
    asm volatile("movl %0, %%esp" ::"m"(tmp));
    asm volatile("popl %%gs\n\t"	  // 恢复 GS
                 "popl %%fs\n\t"	  // 恢复 FS
                 "popl %%es\n\t"	  // 恢复 ES
                 "popl %%ds\n\t"	  // 恢复 DS
                 "popal\n\t"		  // 恢复通用寄存器(EAX, EBX...)
                 "addl $4, %%esp\n\t" // intr
                 "addl $4, %%esp\n\t" // error node
                 "iret"				  // 中断返回
                 ::
                     : "memory", "cc");

    while (1)
        ;
    return;
}

设置 iret 返回到用户态 在 initIdle 的最后部分,代码伪造了一个中断返回 (iret) 的场景:

asm volatile("movl %0, %%esp" ::"m"(tmp));
asm volatile("popl %%gs\n\t"      // 恢复 GS
             "popl %%fs\n\t"      // 恢复 FS
             "popl %%es\n\t"      // 恢复 ES
             "popl %%ds\n\t"      // 恢复 DS
             "popal\n\t"          // 恢复通用寄存器(EAX, EBX...)
             "addl $4, %%esp\n\t" // intr
             "addl $4, %%esp\n\t" // error node
             "iret"               // 中断返回
             ::
                 : "memory", "cc");
  • iret 指令会从栈中弹出 EIP(指令指针)、CS(代码段选择器)和 EFLAGS,并切换到指定的权限级别。
  • 在栈中,CS 被设置为 USEL(SEG_IDLE_CODE),这是一个用户态代码段选择器。
  • 因此,iret 执行后,CPU 会切换到用户态,并跳转到 idle 函数的入口地址。
Tip

为什么idle会进入用户态

static inline void waitForInterrupt() {
    asm volatile("hlt");
}
...
while(1) {
    waitForInterrupt();
}
Question?

为什么在initIdle()中,我们移除了waitForInterrupt()?

系统调用

fork 系统调用用于创建子进程。内核会为子进程分配一块独立的内存空间,并将父进程的地址空间和用户态堆栈完整复制到子进程的内存中。同时,内核会为子进程分配一个独立的进程控制块(PCB),并完成对其进程控制块的初始化设置。

若子进程创建成功,fork 系统调用在父进程中返回子进程的 pid,而在子进程中返回 0;若创建失败,则返回 -1

pid_t fork();

sleep 系统调用用于使进程主动阻塞自身。内核会将该进程的状态从 RUNNING 转换为 BLOCKED,并为其设置 SLEEP 时间片。随后,内核会调度切换到其他处于 RUNNABLE 状态的进程继续执行。

int sleep(uint32_t time);

exit 系统调用用于进程主动销毁自身。内核会将该进程由RUNNING状态转换为DEAD状态,回收分配给该进程的内存、进程控制块等资源。随后,内核会调度切换到其他处于 RUNNABLE 状态的进程继续执行。

int exit();

为了完成fork系统调用,你需要完成exec系统调用。但是本次实验并没有要求将exec系统调用封装成库函数。当然,欢迎你实现一个封装好的exec的库函数,并在用户程序中展现(实验就不要求了)

中断嵌套与临界区

由于系统调用的处理时间往往很长,为保证进程调度的公平性,需要在系统调用中开启外部硬件中断,以便当前进程的处理时间片耗尽时,进行进程切换;由于可以在系统调用中进行进程切换,因此可能会出现多个进程并发地处理系统调用,对共享资源(例如内核的数据结构,视频显存等等)进行竞争,例如以下场景

  1. 进程P1在内核态处理系统调用,处理视频显存,此时外部硬件中断开启
  2. 8253可编程计时器产生一个时间中断
  3. 在内核态处理系统调用的进程P1将现场信息压入P1的内核堆栈中,跳转执行时间中断处理程序
  4. 进程P1的处理时间片耗尽,切换至就绪状态的进程P2,并从当前P1的内核堆栈切换至P2的内核堆栈
  5. 从进程P2的内核堆栈中弹出P2的现场信息,从时间中断处理程序返回执行P2
  6. 进程P2在内核态处理系统调用,处理视频显存,与进程P1形成竞争

在以下系统调用内核处理函数中利用int $0x20指令主动陷入时间中断来模拟以上场景

void sysPrint(struct StackFrame *sf) {
    ...
    for (i = 0; i < size; i++) {
        asm volatile("movb %%es:(%1), %0":"=r"(character):"r"(str+i));
        if(character == '\n') {
            displayRow ++;
            displayCol = 0;
            if(displayRow == 25) {
                displayRow = 24;
                displayCol = 0;
                scrollScreen();
            }
        } else {
            data = character | (0x0c << 8);
            pos = (80*displayRow + displayCol) * 2;
            asm volatile("movw %0, (%1)"::"r"(data),"r"(pos+0xb8000));
            displayCol ++;
            if(displayCol == 80) {
                displayRow ++;
                displayCol = 0;
                if(displayRow == 25){
                    displayRow = 24;
                    displayCol = 0;
                    scrollScreen();
                }
            }
        }
        asm volatile("int $0x20"); // 测试系统调用嵌套时间中断
    }
    ...
}

对编译生成的内核ELF进行反汇编,得到以下代码

001005dc <sysPrint>:    ...
  100606:   or  $0xc,%ah  100609:   lea (%ecx,%ecx,4),%edx  10060c:   shl $0x4,%edx  10060f:   add 0x102404,%edx  100615:   lea 0xb8000(%edx,%edx,1),%edx  10061c:   mov %ax,(%edx)  10061f:   mov 0x102404,%eax  100624:   inc %eax  100625:   mov %eax,0x102404  10062a:   cmp $0x50,%eax  10062d:   je  10063d  10062f:   int $0x20
  100631:   inc %ebx  100632:   cmp %esi,%ebx  100634:   je  10066c  100636:   mov %es:(%ebx),%al  100639:   cmp $0xa,%al  10063b:   jne 100606  10063d:   inc %ecx  10063e:   mov %ecx,0x102408  100644:   mov $0x0,0x102404  10064e:   cmp $0x19,%ecx  100651:   jne 10062f    ...
00102404 <displayCol>:  102404:   00 00
    ...
00102408 <displayRow>:  102408:   00 00
    ...

考虑以下场景

  • P1从时钟中断返回,顺序执行 0x1006310x1006320x1006340x1006360x1006390x10063b0x10063d0x10063e0x1006440x10064e0x1006510x10062f, 再次陷入时间中断,切换至P2
  • P2从时间中断返回,顺序执行 0x1006310x1006320x1006340x1006360x1006390x10063b0x1006060x1006090x10060c0x10060f0x1006150x10061c
  • 全局变量displayRow的更新产生一致性问题
思考

如果上述的场景引发了你的思考,欢迎你在实验报告中大胆陈述。

多个进程并发地进行系统调用,对共享资源进行竞争可能会产生一致性问题,带来未知的BUG;因此,在系统调用过程中,对于临界区的代码不宜开启外部硬件中断,而对于非临界区的代码,则可以开启外部硬件中断,允许中断嵌套

解决思路

完成库函数

这一部分算是对实验2的复习,我们在lab3/lib/syscall.c中留下了4个库函数待完成,你需要调用syscall完善库函数,难度0

时钟中断处理

实验3的主要内容是进程管理。在现阶段的操作系统中,进程切换主要发生在两种情况下:

  • sleep 相关的阻塞或恢复操作;
  • 进程时间片用完时切换到下一个进程。

针对时间片用完的情况,下面讲述一下进程切换的流程

  1. 遍历pcb,将状态为STATE_BLOCKED的进程的sleepTime减一,如果进程的sleepTime变为0,重新设为STATE_RUNNABLE

  2. 递增当前进程的 timeCount。若时间片耗尽(timeCount == MAX_TIME_COUNT)且存在其他可运行进程(STATE_RUNNABLE),则触发进程切换;否则继续执行当前进程。

内核的IDLE 进程是否需要特殊处理完全取决于你的设计。以下是一份进程切换的参考代码:

    tmpStackTop = pcb[current].stackTop;
    pcb[current].stackTop = pcb[current].prevStackTop;
    tss.esp0 = (uint32_t)&(pcb[current].stackTop);
    asm volatile("movl %0, %%esp"::"m"(tmpStackTop)); // switch kernel stack
    asm volatile("popl %gs");
    asm volatile("popl %fs");
    asm volatile("popl %es");
    asm volatile("popl %ds");
    asm volatile("popal");
    asm volatile("addl $8, %esp");
    asm volatile("iret");

自行理解代码含义,并用在合适的地方,当然这个仅供参考,有自己的想法也行

说到这里得提一下irqHandle对比lab2也增加了部分保存与恢复的内容,同学们自行理解

void irqHandle(struct StackFrame *tf) { // pointer tf = esp
     asm volatile("movw %%ax, %%ds"::"a"(KSEL(SEG_KDATA)));

+	uint32_t tmpStackTop = pcb[current].stackTop;
+	pcb[current].prevStackTop = pcb[current].stackTop;
+	pcb[current].stackTop = (uint32_t)tf;

    switch(tf->irq) {
        case -1:
            break;
        case 0xd:
            GProtectFaultHandle(tf); // return
            break;
        case 0x20:
            timerHandle(tf);         // return or iret
            break;
        case 0x21:
            keyboardHandle(tf);      // return
            break;
        case 0x80:
            syscallHandle(tf);       // return
            break;
        default:assert(0);
     }
    
+	pcb[current].stackTop = tmpStackTop;
 }

timerHandle实现,难度2

进程调度

lab3/kernel/kernel/irqHandle.c中,你需要完成调度函数schedule()

Question?

什么时候会触发进程的调度?

lab3/kernel/kernel/irqHandle.c中定义了extern变量,用于访问和修改kvm.c中记录的当前运行进程的pid

下面给出一种最简单的进程调度参考代码,你可以根据具体的需求,实现调度算法

        i = (current + 1) % MAX_PCB_NUM;
        while (i != current) {
            if (i != 0 && pcb[i].state == STATE_RUNNABLE)
                break;
            i = (i + 1) % MAX_PCB_NUM;
        }
        if (pcb[i].state != STATE_RUNNABLE)
            i = 0;
        current = i;
        /* echo pid of selected process */
        pcb[current].state = STATE_RUNNING;
        pcb[current].timeCount = 1;
        ....

选择进程之后,你还需要处理一系列操作,如切换进程上下文、恢复新进程的执行空间等操作。

Tip

在调试过程中,你可以尝试将pcb打印出来。当然,gdb也能查看pcb,帮助你debug

难度2

系统调用例程

void sysFork(struct StackFrame *sf);
void sysExec(struct StackFrame *sf);
void sysSleep(struct StackFrame *sf);
void sysExit(struct StackFrame *sf);
void sysGetPid(struct StackFrame *sf);
...

sysFork

sysFork要做的是在寻找一个空闲的pcb做为子进程的进程控制块,将父进程的资源复制给子进程。如果没有空闲pcb,则fork失败,父进程返回-1,成功则子进程返回0,父进程返回子进程pid

在处理fork时有以下几点注意事项:

  1. 代码段和数据段可以按照前文的说明进行完全拷贝
  2. pcb的复制时,需要考虑哪些内容可以直接复制,哪些内容通过计算得到,哪些内容和父进程无关
  3. 返回值放在哪
Tip

initIdle()中有初始化pcb[0]的经验可供参考,难度4

sysExec

sysExec是操作系统内核中实现程序替换功能的关键系统调用,它负责将当前进程的内存映像替换为新的程序映像。下面我们将详细解析这个系统调用的实现原理和功能:

sysExec主要完成以下任务:

  • 替换当前进程的地址空间

  • 初始化新程序的执行环境

  • 开始执行新程序

难度3

sysSleep

将当前的进程的sleepTime设置为传入的参数,将当前进程的状态设置为STATE_BLOCKED,然后再调用schedule(),选择下一个进程并进行切换

TIPS

schedule()中执行进程切换,调度

建议将进程切换的操作,放在中断返回iret

需要注意的是判断中断嵌套难度1

sysExit

将当前进程的状态设置为STATE_DEAD,然后模拟时钟中断进行进程切换,难度0

sysGetPid

获取当前进程的pid难度0

选做内容:wait()库函数

lab3/app/main.c中,给出了wait()库函数的测试脚本

    printf("==============TEST 1 FOR WAIT=============\n");
    ret = fork();
    i = 4;
    pid = getpid();
    int ppid = getppid();
    if (ret == 0)
    {
        data = 2;
        while (i != 0)
        {
            i--;
            printf("Child Process (pid:%d, ppid:%d): Pong %d, %d;\n", pid, ppid, data, i);
            sleep(128);
        }
        exit();
        printf("If exit() worked, this message wouldn't appear.\n");
    }
    else if (ret != -1)
    {
        int wait_ret = 114514;
        wait_ret = wait();
        printf("first wait() returns: %d\n", wait_ret);
        wait_ret = wait();
        printf("second wait() returns: %d\n", wait_ret);
        data = 1;
        while (i != 0)
        {
            i--;
            printf("Parent Process (pid:%d, ppid:%d): Ping %d, %d;\n", pid, ppid, data, i);
            sleep(128);
        }
    }
    printf("===========TEST 1 FOR WAIT OVER===========\n\n");
提醒

wait的测试需要你完成getppid系统调用,并用库函数封装

wait()的功能与Unix中的wait相同,能够完成的同学想必不需要多说

wait()库函数的实现流程与之前实现的库函数别无不同之处。你需要实现wait系统调用,修改进程的调度流程等。。具体的实现方法请你根据自己对框架代码的理解和wait的流程,自行完成

测试程序演示

基础测试

test-basic

wait功能测试

test-wait1

test-wait2

代码框架

lab3-STUID
├── lab
│   ├── app
│   │   ├── main.c
│   │   └── Makefile
│   ├── bootloader
│   │   ├── boot.c
│   │   ├── boot.h
│   │   ├── Makefile
│   │   └── start.S
│   ├── kernel
│   │   ├── include
│   │   │   ├── common
│   │   │   │   ├── assert.h
│   │   │   │   ├── const.h
│   │   │   │   └── types.h
│   │   │   ├── common.h
│   │   │   ├── device
│   │   │   │   ├── disk.h
│   │   │   │   ├── serial.h
│   │   │   │   ├── timer.h
│   │   │   │   └── vga.h
│   │   │   ├── device.h
│   │   │   ├── x86
│   │   │   │   ├── cpu.h
│   │   │   │   ├── io.h
│   │   │   │   ├── irq.h
│   │   │   │   └── memory.h
│   │   │   └── x86.h
│   │   ├── kernel
│   │   │   ├── disk.c
│   │   │   ├── doIrq.S
│   │   │   ├── i8259.c
│   │   │   ├── idt.c
│   │   │   ├── irqHandle.c
│   │   │   ├── kvm.c
│   │   │   ├── serial.c
│   │   │   ├── timer.c
│   │   │   └── vga.c
│   │   ├── lib
│   │   │   └── abort.c
│   │   ├── main.c
│   │   └── Makefile
│   ├── lib
│   │   ├── lib.h
│   │   ├── syscall.c
│   │   └── types.h
│   ├── Makefile
│   └── utils
│       ├── genBoot.pl
│       └── genKernel.pl
└── report
    └── 231220000.pdf

作业提交

  • 本次作业需提交可通过编译的实验相关源码与报告,提交前请确保执行make clean动作.
  • 提交的最后结果应该要能完整实现四个系统调用库函数fork()exit()getpid()sleep()
  • 其他问题参看Introduction中的 作业规范与提交 一章

results matching ""

    No results matching ""