前言:操作系统系列实验的blog比较抽象,如有需要可以直接去搬我上传的项目直接用
毕竟这几篇都是汇报检查是我打的草稿,内容很乱
链接如下:
可运行项目文件直达
参考来源:
1.操作系统课程设计geekos project1-3
总结用户态进程创建流程
在GeekOS中,用户态进程的创建涉及多个步骤,从加载可执行文件到最终将新进程加入就绪队列等待运行。下面是用户态进程创建的主要流程总结:
调用
Spawn
函数:Spawn
函数是创建用户态进程的入口点。- 该函数接收要加载的可执行文件路径 (
program
) 和命令行参数 (command
) 以及指向Kernel_Thread
的指针 (pThread
)。
读取可执行文件:
- 使用
Read_Fully
函数将可执行文件的内容读入内存缓冲区。
- 使用
解析 ELF 文件:
- 调用
Parse_ELF_Executable
函数分析 ELF 格式的可执行文件,获取段信息、入口地址等关键数据。
- 调用
初始化并加载用户程序:
- 调用
Load_User_Program
函数来处理以下任务:- 计算用户程序所需的内存空间,包括代码段、数据段和堆栈段。
- 分配内存,并将可执行文件中的各段内容复制到分配的内存空间中。
- 初始化
User_Context
结构体,设置段描述符(LDT)、内存布局、入口地址等。 - 根据命令行参数初始化用户内存空间中的参数块。
- 初始化用户打开文件列表,并添加标准输入输出文件。
- 调用
创建用户上下文:
- 在
Load_User_Program
函数内部,会调用Create_User_Context
函数来创建用户上下文对象(User_Context
)。 - 这个过程包括为用户进程设置 LDT(本地描述符表)及其描述符选择子,并将其关联到 GDT(全局描述符表)中。
- 在
初始化内核堆栈:
- 调用
Setup_User_Thread
函数为新进程初始化内核堆栈。 - 设置初始的寄存器状态,模拟中断现场,以便进程首次进入用户态时能够正确恢复运行状态。
- 调用
启动用户线程:
- 调用
Start_User_Thread
函数开始一个新进程。 - 将新进程的状态设置为就绪,并将其加入就绪队列等待调度。
- 调用
调度运行:
- 当调度器选择该进程进行运行时,通过
Switch_To_User_Context
函数切换到用户地址空间。 Switch_To_User_Context
函数会激活新的用户地址空间,并设置必要的寄存器值,以确保进程可以顺利从用户态开始执行。
- 当调度器选择该进程进行运行时,通过
用户态进程创建流程图
1 | +-----------------+ |
关键函数说明
- **
Spawn
**:创建用户态进程的入口点。 - **
Read_Fully
**:读取可执行文件到内存。 - **
Parse_ELF_Executable
**:解析 ELF 格式的可执行文件。 - **
Load_User_Program
**:计算并分配内存,加载程序段和数据段,初始化User_Context
。 - **
Create_User_Context
**:创建用户上下文对象。 - **
Setup_User_Thread
**:初始化内核堆栈,设置初始寄存器状态。 - **
Start_User_Thread
**:开始一个新进程,将其加入就绪队列。 - **
Switch_To_User_Context
**:切换到用户地址空间,激活用户上下文。
这个流程确保了用户态进程能够被正确地创建、加载和运行,同时维护了系统的稳定性和安全性。
步骤总览
本项目要求对以下/src/geekos/中的文件进行修改:
**(1) user.c
**:完成函数Spawn()
和Switch_To_User_Context()
。
创建进程,切换用户上下文
**(2) elf.c
**:完成函数Parse_ELF_Executable()
,要求与项目1相同。
分析exe文件,用于上下文(context)
**(3) userseg.c
**:完成函数Destroy_User_Context()
、Load_User_Program()
、Copy_From_User()
、Copy_To_User()
和: Switch_To_Address_Space()
。
销毁用户进程上下文,加载用户进行,切换用户地址空间,用来进出内核操作
**(4) kthread.c
**:完成函数Setup_User_Thread()
和Start_User_Thread()
。
设置,启动进程,进入等待队列
**(5) syscall.c
**:完成函数Sys_Exit()
、Sys_PrintString()
、Sys_GetKey()
、Sys_SetAttr()
、Sys_GetCursor()
、: Sys_PutCursor()
、Sys_Spawn()
、Sys_Wait()
和Sys_GetPID()
。
系统调用函数,方便用户进程执行内核操作,以及只有内核才有的权限操作,如创建进程,进行系统调用需要进入内核空间使用(3)中的函数
**(6) main.c
**:改写Spawn_Init_Process(void)
,改写时将“/c/shell.exe
”作为可执行文件传递给Spawn
函数的program
参数,创建第一个用户态进程,然后由它来创建其它进程。
1 | 开始本项目前需要阅读/src/geekos目录中的entry.c、lowlevel.asm、kthread.c、userseg.c,其中在userseg.c中主要关注Destroy_User_Context()和Load_User_Program()两个函数。 |
前言
请严格参照实验指导书进行本教程的阅读,否则可能会出现不可预知的错误。
项目2的实现:
1.生成一个新的用户级进程
“src/GeekOS/user.c
”文件中的函数Spawn()
,其功能是生成一个新的用户级进程;
(只展示需要修改的)
1 | int Spawn(const char *program, const char *command, struct Kernel_Thread **pThread) { |
2.切换用户地址空间
“src/GeekOS/user.c
”文件中的函数Switch_To_User_Context()
,调度程序在执行一个新的进程前调用该函数以切换用户地址空间;
1 | void Switch_To_User_Context(struct Kernel_Thread* kthread, struct Interrupt_State* state) |
3.解析 ELF 格式的可执行文件
“src/GeekOS/elf.c
”文件中的函数Parse_ELF_Executable()
。该函数的实现要求和项目1相同。
4.计算并分配内存,加载程序段和数据段,初始化 User_Context
“src/GeekOS/userseg.c
”文件中主要是实现一些为实现对“src/GeekOS/user.c
”中高层操作支持的函数。
Create_User_Context()
函数,用于创建并初始化一个用户上下文结构。
1 | /**需在此文件各函数前增加一个函数,此函数的功能是按给定的大小创建一个用户级进程上下文,具体实现如下: |
Destroy_User_Context()
函数的功能是释放用户态进程占用的内存资源。
1 | void Destroy_User_Context(struct User_Context* userContext) |
Load_User_Program()
函数的功能通过加载可执行文件镜像创建新进程的User_Context结构。
1 | int Load_User_Program(char *exeFileData, ulong_t exeFileLength, |
Copy_From_User()
和Copy_To_User()
函数的功能是在用户地址空间和内核地址空间之间复制数据,在分段存储器管理模式下,只要段有效,调用memcpy
函数就可以实现这两个函数的功能。
1 | bool Copy_From_User(void* destInKernel, ulong_t srcInUser, ulong_t bufSize) |
1 | bool Copy_To_User(ulong_t destInUser, void* srcInKernel, ulong_t bufSize) |
Switch_To_Address_Space()
函数的功能是通过将进程的LDT装入到LDT寄存器来激活用户的地址空间。
1 | void Switch_To_Address_Space(struct User_Context *userContext) |
5.初始化内核堆栈,设置初始寄存器状态.开始一个新进程,将其加入就绪队列
“src/GeekOS/kthread.c”文件中的Start_User_Thread函数和Setup_User_Thread函数。
修改/project2/src/geekos/kthread.c 文件,添加头文件“#include < geekos/user.h>”。
Setup_User_Thread()
函数的功能是为进程初始化内核栈,栈中是为进程首次进入用户态运行时设置处理器状态要使用的数据
1 | /* |
Start_User_Thread()
是一个高层操作,该函数使用User_Context
对象开始一个新进程。
1 | /* |
6.
“src/GeekOS//syscall.c”文件中主要是实现用户程序要求内核进行服务的一些系统调用函数定义。要求用户实现的有Sys_Exit()函数、Sys_PrintString()函数、Sys_GetKey()、Sys_SetAttr()、Sys_GetCursor()、Sys_PutCursor()、Sys_Spawn()函数、Sys_Wait()函数和Sys_GetPID( )函数。
在/project2/src/geekos/syscall.c 文件中添加函数 Copy_User_String(),用于在 Sys_PrintString 中使用
1 | /* 添加函数 Copy_User_String 以便在函数 Sys_PrintString 中使用 */ |
Sys_Exit()函数
1 | static int Sys_Exit(struct Interrupt_State* state) |
Sys_PrintString()函数
1 | static int Sys_PrintString(struct Interrupt_State* state) |
Sys_GetKey()函数
1 | static int Sys_GetKey(struct Interrupt_State* state) |
Sys_SetAttr()函数
1 | static int Sys_SetAttr(struct Interrupt_State* state) |
Sys_GetCursor()函数
1 | static int Sys_GetCursor(struct Interrupt_State* state) |
Sys_PutCursor()函数
1 | static int Sys_PutCursor(struct Interrupt_State* state) |
Sys_Spawn()函数函数
1 | static int Sys_Spawn(struct Interrupt_State* state) |
Sys_Wait()函数
1 | static int Sys_Wait(struct Interrupt_State* state) |
Sys_GetPID( )函数。
1 | static int Sys_GetPID(struct Interrupt_State* state) |
7.生成第一个用户态进程
在main.c文件中改写生成第一个用户态进程的函数调用:Spawn_Init_Process(void)
1 | static void Spawn_Init_Process(void) |
8.
更改bochsrc配置文件,同project1。(可无)
简要说明 LDT 与 GDT 的区别和联系
1. 定义
- **GDT (Global Descriptor Table)**:全局描述符表,是一个存储在内存中的数据结构,用于定义系统中所有段的描述符。GDT 中的每个条目都是一个段描述符,描述了段的基地址、界限、访问权限等信息。GDT 是全局的,对所有进程都可见。
- **LDT (Local Descriptor Table)**:局部描述符表,也是一个存储在内存中的数据结构,用于定义特定进程的段描述符。LDT 是局部的,每个进程可以有自己的 LDT,用于管理其私有段。
2. 区别
作用范围:
- GDT:全局作用,对所有进程都可见。GDT 通常包含系统级的段描述符,如代码段、数据段、系统调用门等。
- LDT:局部作用,仅对特定进程可见。LDT 通常包含用户进程的私有段描述符,如用户代码段、数据段等。
数量:
- GDT:系统中只有一个 GDT。
- LDT:每个进程可以有一个 LDT,因此系统中可以有多个 LDT。
内容:
- GDT:通常包含系统级的段描述符,如内核代码段、内核数据段、TSS(任务状态段)等。
- LDT:通常包含用户进程的私有段描述符,如用户代码段、用户数据段等。
选择子:
- GDT:使用全局描述符选择子(GDT selector)来访问 GDT 中的段描述符。
- LDT:使用局部描述符选择子(LDT selector)来访问 LDT 中的段描述符。
3. 联系
描述符选择子:
- GDT 和 LDT 都使用描述符选择子来访问段描述符。选择子是一个 16 位的值,其中高 13 位是描述符在表中的索引,低 3 位包含特权级别和表指示符(TI 位,0 表示 GDT,1 表示 LDT)。
段描述符:
- GDT 和 LDT 中的段描述符格式是相同的,都包含段的基地址、界限、访问权限等信息。
地址空间:
- GDT 和 LDT 一起工作,共同定义了系统的地址空间。GDT 提供了系统级的段描述符,而 LDT 提供了进程级的段描述符,两者结合可以实现对不同进程的地址空间管理。
示例
在GeekOS中,LDT和GDT的使用如下:
GDT:
- 在系统初始化时,设置 GDT 并加载到 GDT 寄存器中。
- GDT 包含内核代码段、内核数据段、TSS 等系统级段描述符。
LDT:
- 在创建用户进程时,为每个进程创建一个 LDT。
- LDT 包含用户代码段、用户数据段等进程私有段描述符。
- 使用
lldt
指令将 LDT 加载到 LDT 寄存器中,以便在用户态下访问 LDT 中的段描述符。
总结
GDT 和 LDT 是 x86 架构中用于段管理的两个重要数据结构。GDT 是全局的,对所有进程都可见,主要用于系统级的段管理;LDT 是局部的,每个进程可以有自己的 LDT,主要用于用户进程的私有段管理。两者通过描述符选择子和段描述符共同工作,实现了系统的地址空间管理和进程隔离。
在GeekOS系统中,用户程序的参数传递是一个多步骤的过程,涉及到从命令行参数的解析到将这些参数正确地放置在用户内存空间中,最后通过寄存器传递给用户程序。以下是详细的步骤和解释:
1. 命令行参数解析
当用户通过命令行启动一个程序时,命令行参数会被解析成一个字符串数组。例如,如果用户执行命令 ./myprogram arg1 arg2
,那么参数会被解析为:
argv[0] = "./myprogram"
argv[1] = "arg1"
argv[2] = "arg2"
2. 获取参数块大小
在Load_User_Program()
函数中,首先需要计算参数块的大小。这一步通过调用Get_Argument_Block_Size()
函数来实现:
1 | Get_Argument_Block_Size(command, &numArgs, &argBlockSize); |
这里,command
是包含所有命令行参数的字符串,numArgs
是参数的数量,argBlockSize
是参数块的总大小。
3. 分配用户内存
在计算出用户进程所需的总内存大小后,需要分配足够的内存来存放程序的代码段、数据段、堆栈段以及参数块。内存大小的计算如下:
1 | ulong_t size = Round_Up_To_Page(maxva) + DEFAULT_USER_STACK_SIZE + argBlockSize; |
其中,maxva
是程序段在内存中的最大地址,DEFAULT_USER_STACK_SIZE
是默认的用户堆栈大小,argBlockSize
是参数块的大小。
4. 格式化参数块
在分配好内存后,需要将参数块格式化并写入用户内存空间。这一步通过调用Format_Argument_Block()
函数来实现:
1 | Format_Argument_Block(userContext->memory + argBlockAddr, numArgs, argBlockAddr, command); |
这里,userContext->memory + argBlockAddr
是指向用户内存中参数块起始地址的指针,numArgs
是参数的数量,argBlockAddr
是参数块的地址,command
是包含所有命令行参数的字符串。
5. 初始化User_Context
结构
在User_Context
结构中,需要设置参数块的地址,以便用户程序可以访问到这些参数:
1 | userContext->argBlockAddr = argBlockAddr; |
6. 设置寄存器状态
在Setup_User_Thread()
函数中,需要将参数块的地址传递给esi
寄存器,以便用户程序启动时可以访问到这些参数:
1 | Push(kthread, 0); /* eax */ |
7. 切换到用户地址空间
在Switch_To_User_Context()
函数中,确保在执行用户程序前正确切换到用户地址空间:
1 | Switch_To_Address_Space(userContext); |
8. 开始用户进程
最后,通过调用Start_User_Thread()
函数开始用户进程,并将其加入就绪队列:
1 | struct Kernel_Thread *thread = Start_User_Thread(userContext, false); |
总结
通过上述步骤,用户程序的参数被正确地传递给了程序。具体来说,参数首先被解析成字符串数组,然后格式化并写入用户内存空间,最后通过寄存器传递给用户程序。这些步骤确保了用户程序在启动时能够正确访问到命令行参数。
五、所遇问题及解决方法
在完成本次实验的过程中,遇到了几个主要问题,以下是这些问题及其解决方法的详细描述:
1. 用户程序参数传递问题
问题描述:在尝试将命令行参数传递给用户程序时,发现参数没有正确传递到程序中,导致程序无法正常读取参数。
解决方法:
- 在
Load_User_Program()
函数中,通过调用Get_Argument_Block_Size()
函数获取参数块的大小和参数个数。 - 使用
Format_Argument_Block()
函数将参数块格式化并写入用户内存空间。 - 初始化
User_Context
结构中的argBlockAddr
成员,使其指向参数块在用户内存空间中的地址。 - 在
Setup_User_Thread()
函数中,将参数块地址传递给esi
寄存器,确保程序启动时可以访问到这些参数。
2. 内存分配问题
问题描述:在为用户进程分配内存时,发现内存分配失败,导致进程无法正常创建。
解决方法:
- 在
Create_User_Context()
函数中,确保内存分配的大小是页对齐的,使用Round_Up_To_Page()
函数来计算所需的内存大小。 - 在分配内存时,使用
Malloc()
函数,并检查返回值是否为NULL
,如果是,则返回错误码。 - 在分配完内存后,使用
memset()
函数将内存初始化为零,避免未初始化的内存导致的不确定行为。
3. 用户地址空间切换问题
问题描述:在切换到用户地址空间时,发现切换不成功,导致程序在内核态下运行,而不是在用户态下运行。
解决方法:
- 在
Switch_To_User_Context()
函数中,确保在切换地址空间前禁用中断,防止在切换过程中发生中断。 - 使用
Switch_To_Address_Space()
函数切换到用户的地址空间。 - 设置内核堆栈指针
esp0
,确保内核栈在用户态下也能正确使用。 - 保存当前的用户上下文到静态变量
s_currentUserContext
中,以便后续的切换操作。
4. 系统调用实现问题
问题描述:在实现系统调用函数时,发现某些系统调用(如Sys_PrintString()
)无法正确处理用户态内存中的数据。
解决方法:
- 在
syscall.c
文件中,实现Copy_User_String()
函数,用于从用户态内存中安全地复制字符串到内核态内存。 - 在
Sys_PrintString()
函数中,调用Copy_User_String()
函数获取用户态内存中的字符串,然后调用Print()
函数输出字符串。 - 在复制数据时,使用
Validate_User_Memory()
函数验证用户态内存的有效性,确保不会发生越界访问。
六、结论
通过本次实验,我们深入学习了在GeekOS系统中加载和运行用户态程序的方法。具体来说,我们实现了以下几个关键功能:
- 解析ELF文件:通过修改
Parse_ELF_Executable()
函数,成功解析了ELF文件的文件头和程序头,获取了必要的信息。 - 加载用户程序:实现了
Load_User_Program()
函数,计算并分配了用户进程所需的内存,加载了程序段和数据段,初始化了User_Context
结构。 - 创建用户上下文:实现了
Create_User_Context()
函数,按给定的大小创建并初始化了一个用户级进程上下文。 - 初始化内核堆栈:实现了
Setup_User_Thread()
函数,初始化了内核堆栈和寄存器状态,确保用户程序在启动时具有正确的初始状态。 - 切换用户地址空间:实现了
Switch_To_User_Context()
函数,确保在执行用户程序前正确切换到用户地址空间。 - 实现系统调用:在
syscall.c
文件中实现了多个系统调用函数,如Sys_PrintString()
、Sys_Exit()
等,确保用户程序可以通过系统调用来请求内核服务。
通过这些实现,我们不仅掌握了GeekOS系统的基本操作,还对操作系统的核心概念有了更深刻的理解。本次实验为我们进一步学习和研究操作系统提供了坚实的基础。