在探索Hello World程序的执行过程中,我们将深入了解操作系统的各个方面,从创建进程开始,一直到程序的执行结束。这个故事以操作系统的视角展开,记录了整个过程中涉及的关键知识点。
hello要做什么要做的效果就是,把你写的操作系统代码,也就是一堆 0101…组成的二进制数,写到硬盘上的某个位置,把这块硬盘插在电脑上,按下电脑的开机键,最终在黑黑的屏幕上输出一个 hello world 字符串。
创建进程
故事开始于一个用户决定运行Hello World程序的瞬间。用户敲击键盘,输入启动命令。这个命令被Shell捕获,Shell负责解释用户的命令并与操作系统进行交互。
当运行./hello
时,操作系统会执行以下一系列步骤,从进程的角度来看:
加载程序: 操作系统会在内存中加载hello
可执行文件。这涉及将可执行文件的代码段、数据段和其他必要的部分加载到进程的地址空间。
分配资源: 操作系统会为hello
进程分配一些资源,包括内存空间、文件描述符、寄存器等。这确保了进程有足够的资源来执行。
建立环境: 操作系统会为hello
进程设置运行环境,包括设置命令行参数、环境变量等。这些信息将对程序的执行产生影响。
创建进程控制块(PCB): 操作系统会为hello
创建一个进程控制块,其中包含了进程的状态信息、程序计数器、寄存器的值等。PCB用于跟踪和管理进程的执行。
加载动态链接库(如果有的话): 如果hello
依赖于某些动态链接库,操作系统将加载这些库,以便在运行时解析程序中的外部符号。
设置执行环境: 操作系统会为hello
设置执行环境,包括清除寄存器、设置堆栈指针等,以确保程序执行的正确性。
执行程序: 操作系统将控制权转交给hello
的入口点,开始执行程序。程序开始运行,执行其中的指令,可能涉及到对系统调用的调用,文件的读写等操作。
处理系统调用: 如果hello
程序需要访问系统资源(如文件、网络等),它将通过系统调用请求操作系统提供相应的服务。操作系统会根据请求执行相应的内核功能。
处理中断: 在程序执行过程中,可能会发生中断事件(如硬件中断、时钟中断等)。操作系统会响应这些中断,执行相应的中断处理程序。
终止进程: 当hello
程序执行完成或者出现错误时,操作系统会终止该进程。这可能涉及到释放分配给进程的资源、更新进程状态等操作。
总的来说,操作系统在执行可执行文件时扮演了很多角色,包括资源管理、环境设置、程序加载、中断处理等。这些步骤确保了程序能够在系统上正确运行并与其他进程协同工作。
# 伪代码示例
# 用户在Shell中输入字符,例如 ./hello,并按下Enter键
user_input = read_input_from_keyboard()
# 处理键盘输入中断
handle_keyboard_interrupt()
# Shell 解析用户输入
command = parse_user_input(user_input)
# 创建新进程
pid = fork()
if pid == 0:
# 子进程执行 hello 程序
exec("hello")
# 父进程等待子进程结束
wait(pid)
# 中断处理函数
def handle_keyboard_interrupt():
# 从键盘缓冲区中读取字符
char = read_char_from_keyboard_buffer()
# 处理特殊字符
if char == '\n':
# 用户按下Enter键
execute_command(command)
# 执行命令函数
def execute_command(command):
if command == "./hello":
# 加载并执行 hello 程序
exec("hello")
以下是详细步骤
这个函数的主要任务是执行一个可执行文件,以下是该函数主要步骤的另一种表达方式:
获取可执行文件的inode: 从文件系统中获取可执行文件的inode,以便后续的文件操作。
检查文件权限: 检查可执行文件的访问权限,确保当前用户有权执行该文件。
读取文件头部信息: 读取可执行文件的头部信息,以获取执行所需的关键信息。
处理脚本文件: 如果可执行文件是脚本文件,则执行相应的处理操作。
验证文件格式和大小: 检查可执行文件是否符合ZMAGIC格式,并确保文件大小正确,以避免错误执行。
复制参数和环境变量: 将命令行参数和环境变量复制到新进程的地址空间中,以便新进程能够正确访问这些信息。
更新文件描述符: 释放当前进程的可执行文件描述符,并为当前进程分配新的描述符。
信号处理程序重置: 清空当前进程的信号处理程序,确保新进程不会继承旧的信号处理状态。
文件关闭: 关闭当前进程打开的文件,以释放相关的系统资源。
页表释放: 释放当前进程的页表,以便为新进程创建新的地址映射。
协处理器状态重置: 重置当前进程的数学协处理器状态,确保新进程不会继承旧的数学协处理器状态。
LDT创建: 创建新的局部描述符表(LDT),以确保新进程有自己独立的段描述符表。
堆栈和用户ID设置: 设置新进程的堆栈指针和用户ID,以确保正确的执行环境。
加载代码和数据: 从磁盘加载可执行文件的代码和数据到新进程的地址空间中。
设置入口点: 将新进程的入口点设置为可执行文件的入口点。
设置堆栈指针: 将新进程的堆栈指针设置为新进程的堆栈顶部。
成功返回: 返回0以表示执行成功。
内存管理
随着进程的创建,操作系统负责为Hello World程序分配内存空间。这包括代码段、数据段和堆栈。代码段存储程序的指令,数据段存储程序的静态数据,而堆栈则用于存储函数调用的信息。
在这个过程中,操作系统还会创建页表,用于将虚拟地址空间映射到物理内存。这有助于保护不同进程的内存空间,防止它们相互干扰。
结合考试的一个页表翻译题
给了一个内存表如下图所示,采用二级页表 让你翻译0x00467FA H写出具体翻译过程。 一级页表的起始地址是从0开始(4分)
0x00H 十六个字节的数字
0x10H 十六个字节的数字
0X20H 我记得都是0
0x30H 我记得都是0
0x40H 一大堆数字
0xFF0 也是数字 到0x2040一大堆数字
PS: 这个题应该是按照10 、10、12翻译的,如果没算错的话 一级页表那里是1,二级页表是6,地址偏移自己算(之前说的都是十进制),如果一级页表那里是4的话,应该是从0+4 * 4字节(页表项长度是4B),也就是从第二行开始读四个字节,我记得当时应该是小端法读,就比如是34 57 85 A4 B3,转过来应该是B3 A4 85 57 34,之后应该是可以在0FF0到2040找到一个数的作为二级页表的起始地址,之后再找到64B的位置 也就是从起始地址+64B的位置 读四个字节,这样获得了三十二位数字之后将后边的12位制成0之后+一开始算的偏移就是最后的物理地址。
在Linux内核中,处理缺页异常的核心函数是 do_page_fault
。当发生缺页异常时,内核会调用 do_page_fault
函数来处理。
以下是一个简化的代码示例,演示了当发生缺页异常时,内核可能调用的处理函数:
#include
#include
void handle_page_fault(struct pt_regs *regs)
{
unsigned long address = read_cr2(); // 读取引发缺页异常的线性地址
struct mm_struct *mm = current->mm;
// 检查是否有空页面
if (handle_mm_fault(mm, NULL, address, 0) == 0) {
// 页面分配成功
return;
}
// 处理无法分配页面的情况
printk("Page fault at address: %lx\n", address);
// 其他处理逻辑...
}
这里使用 handle_mm_fault
函数来尝试处理缺页异常,如果页面成功分配,函数返回0。如果分配失败,可能由于内存不足等原因,就需要进行其他处理逻辑。
IO与文件系统
Hello World程序在执行过程中可能需要进行输入输出操作。当程序尝试从标准输入读取数据或将数据写入标准输出时,操作系统介入并负责将数据传递给正确的设备。
同时,操作系统还会检索Hello World程序的可执行文件。这个文件可能存储在硬盘上的文件系统中。操作系统会根据文件系统的结构,将程序加载到内存中,并确保所有依赖的库和资源也被正确加载。
第四章:进程的调度、释放和回收
hello进程的调度日志由下所示:
4 J 30793
4 R 30793
6 N 30793
6 J 30794
4 W 30794
6 R 30795
4 J 30797
6 E 30798
4 R 30798
当hello进程新建并就绪后,进程4也就是sh进入等待状态,之后hello进程开始运行,并在结束前唤醒
sh,之后hello进程退出sh继续运行。
do_exit
函数实现了在进程退出时的一系列操作,以下是该函数主要功能的另一种表达方式:
int do_exit(long code)
{
int i;
free_page_tables(get_base(current->ldt[1]),get_limit(0x0f));
free_page_tables(get_base(current->ldt[2]),get_limit(0x17));
for (i=0 ; i<NR_TASKS ; i++)
if (task[i] && task[i]->father == current->pid) {
task[i]->father = 1;
if (task[i]->state == TASK_ZOMBIE)
/*assumption task[1] is always init */
(void) send_sig(SIGCHLD, task[1], 1);
}
for (i=0 ; i<NR_OPEN ; i++)
if (current->filp[i])
sys_close(i);
iput(current->pwd);
current->pwd=NULL;
iput(current->root);
current->root=NULL;
iput(current->executable);
current->executable=NULL;
if (current->leader && current->tty >= 0)
tty_table[current->tty].pgrp = 0;
if (last_task_used_math == current)
last_task_used_math = NULL;
if (current->leader)
kill_session();
current->state = TASK_ZOMBIE;
current->exit_code = code;
fprintk(3,"%d\tE\t%d\n",current->pid,jiffies);
tell_father(current->father);
schedule();
return (-1); /* just to suppress warnings*/
}
释放局部描述符表(LDT)页帧: 释放进程的局部描述符表页帧,确保释放了相关的系统资源。
查找子进程并更新父子关系: 遍历任务数组,查找当前进程的子进程,并将其父进程设置为init进程。同时,如果找到僵尸进程,向init进程发送 SIGCHLD 信号。
关闭所有打开的文件描述符: 遍历文件描述符数组,关闭当前进程打开的所有文件描述符。
释放进程相关计数: 释放当前进程的工作目录、根目录、可执行程序等引用计数,确保相关资源得到释放。
修改控制终端的组: 如果当前进程是会话领导进程,并且控制终端有效,则将控制终端的进程组设置为0。
清除数学协处理器标记: 如果当前进程是最后使用数学协处理器的进程,将全局变量 last_task_used_math
设置为 NULL。
清除会话(如果是会话领导进程): 如果当前进程是会话领导进程,清除整个会话,确保相关进程的关联关系被解除。
设置进程状态为僵尸状态: 将当前进程状态设置为僵尸状态,表示该进程已经退出。
记录退出代码和时间: 将退出代码和当前时间记录,以供后续查询或分析使用。
通知父进程: 向父进程发送通知,告知其子进程已经退出。
执行度量调度: 执行调度算法,将 CPU 的控制权切换到其他就绪进程上运行。
返回错误码-1: 函数最后返回错误码 -1,实际上,该返回语句可能永远不会被执行,因为函数中的 schedule()
调用将导致当前进程切换到其他进程。
通过这些操作,do_exit
函数确保了进程退出时释放了相关资源、更新了进程间关系,并通知了相关进程。这有助于系统的正常运作和资源管理。
第五章:执行Hello World
最终,Hello World程序准备好在其独立的进程中执行。操作系统通过调度算法决定何时运行这个进程。程序的指令被解释器加载到CPU中执行,而CPU的寄存器则保存了程序的状态和上下文信息。
在程序执行的过程中,操作系统会监视进程的状态,处理中断并确保进程能够安全地完成。当Hello World程序输出"Hello, World!"时,这一漫长而复杂的过程就算是结束了。
通过这个故事,我们深入了解了操作系统在Hello World程序执行过程中所扮演的关键角色,涉及到的进程管理、内存管理、IO、文件系统、缓冲、缺页中断等方方面面。这也让我们更加欣赏操作系统在计算机系统中的不可或缺的地位。