实验一:NexOS 用户态编程与系统调用¶
实验说明¶
本实验基于 NexOS 源码,要求同学完成三个部分的功能实现,覆盖从用户态程序执行、Shell 命令解释到系统调用打通的完整链路。
实现范围
- 第一部分:实现
runscript命令执行脚本(不要求支持内置命令)。 - 第二部分:在
sh.c中实现重定向与多指令(;)。 - 第三部分:添加
ioctl系统调用并打通调用路径,同时新增gpu_stats.c,通过ioctl获取 GPU 矩阵乘次数并按指定格式输出。
总分说明
本实验总分 20 分,按比例折算计入最终成绩:
- 代码测试结果:按 70% 比例计入总分(最高分折算为 14 分)
- 线下提问检查:按 30% 比例计入总分(最高分折算为 6 分)
请严格按照“功能要求 + 评分点”完成,未通过的功能点会影响代码测试得分。
测试平台代码提交说明
本实验采用希冀平台作为测试平台,网址为https://cscourse.ustc.edu.cn。
在在线作业lab1的代码提交界面,需要粘贴你自己的gitlab代码仓库链接,由于本次实验所使用的是 lab1 分支的代码,因此需要额外指定需要测评的分支,有如下两种指定的格式:
- https://cscourse.ustc.edu.cn/vdir/Gitlab/username/yourproj.git --branch=lab1
- https://cscourse.ustc.edu.cn/vdir/Gitlab/username/yourproj.git lab1
本次实验的平台测试脚本已经经过助教团队的大量测试,但是仍不排除有测试不完善的地方。因此如果你发现线上测试结果与你本地测试的结果有出入,请将你的线上测试结果截图和线下测试截图发给助教,助教会根据情况判断是学生代码问题还是测试脚本的bug。
实验时间安排¶
注:此处为实验发布时的安排计划,请以课程主页和课程群内最新公告为准
- 4.2 晚实验课,讲解实验一,检查实验
- 4.9 晚实验课,检查实验
- 4.16 晚实验课,检查实验
- 4.23 晚及之后实验课,补检查实验
补检查分数照常给分,但会记录此次检查未按时完成,此记录在最后综合分数时作为一种参考(即:最终分数可能会低于当前分数)。
检查时间、地点:周四晚18: 30~22: 00,电三楼406/408。
获取实验代码(切换到 lab1 分支)¶
本实验假设你已经在本机 clone 过课程代码仓库。接下来你需要把远端的 lab1 分支拉取到本地,并切换到该分支进行实验。
推荐操作(已 clone 的本地仓库)
在仓库根目录执行:
常见问题
- 若提示找不到
origin/lab1:先确认远端分支名是否为lab1(可用git branch -r查看)。 - 切换分支会改变工作区文件;若你在其他分支有未提交改动,请先
git status检查并自行处理(提交或暂存)。
一、实现 runscript 命令(6 分)¶
1.1 目标¶
在用户态新增一个命令 runscript,可从脚本文件中逐行读取命令并执行。
本部分不要求支持内置命令(如 cd、exit),仅要求执行外部命令。
助教已在源码目录下 user/runscript.c 中提供了部分辅助函数,请各位同学补全main函数的主流程。
1.2 基础知识¶
本小节按 runscript 实际用到的系统调用逐个说明:做什么、参数是什么、返回值怎么判定。
| 系统调用 | 核心功能 | 参数含义 | 返回值含义 | 在本实验中的典型用途 |
|---|---|---|---|---|
open(const char *path, int omode) |
打开文件并分配一个文件描述符 fd |
path:文件路径;omode:打开模式(如 O_RDONLY) |
>=0:成功,值即 fd;-1:失败 |
打开脚本文件:open(script, O_RDONLY) |
read(int fd, void *buf, int n) |
从 fd 指向文件读取最多 n 字节到 buf |
fd:文件描述符;buf:接收缓冲区;n:最多读取字节数 |
>0:本次实际读取字节数;0:读到 EOF;-1:失败 |
循环读取脚本内容并按行拆分 |
close(int fd) |
关闭文件描述符,释放进程内的 fd 槽位 |
fd:要关闭的描述符 |
0:成功;-1:失败 |
脚本执行结束后关闭脚本文件 |
fork(void) |
创建当前进程的子进程 | 无参数 | 父进程返回子进程 pid(>0);子进程返回 0;失败返回 -1 |
每条外部命令先 fork 一个子进程 |
exec(const char *path, char *const argv[]) |
用新程序替换当前进程映像 | path:可执行文件路径;argv:参数数组, 由用户输入的命令按空格切分得到,最后一个元素必须为 0 |
成功不返回;返回则表示失败(通常 -1) |
在子进程中执行目标命令,如 /ls |
wait(int *status) |
等待任一子进程退出并回收资源 | status:用于接收退出码的用户缓冲区地址,可传指针 |
>=0:返回已退出子进程 pid;-1:失败/无子进程 |
父进程串行等待,保证脚本按顺序执行 |
exit(int status) |
立即终止当前进程并上报退出状态 | status:退出码(通常 0 表示成功,非 0 表示失败) |
不返回(noreturn) |
参数错误、打开/读取/执行失败时终止 runscript 或子进程 |
exit 在第一部分中的使用建议
- 主流程中遇到不可恢复错误(如脚本打开失败、读取失败),建议
exit(1)。 - 子进程中
exec失败时必须立即exit(1),避免子进程落回父流程代码。 - 正常执行结束使用
exit(0),便于wait(&status)在父进程判断执行结果。
1.3 功能要求¶
请按照下述要求补全 user/runscript.c 的 main 函数:
- 基本调用方式:
runscript <script_path>
- 脚本读取与逐行执行:
- 以只读方式打开脚本文件;
- 脚本不存在时,以错误码1调用exit退出;
- 按行解析并顺序执行。
- 行过滤:
- 跳过空行;
- 跳过注释行(例如以
#开头)。
- 外部命令执行:
- 将行按空白切分为
argv; - 使用
fork + exec + wait串行执行每一行命令; - 任意一行执行失败时,
runscript应立即退出并返回错误码1。
- 将行按空白切分为
- 错误处理:
- 当指令执行报错时,以错误码1调用exit退出。
功能要求样例
样例 1:基本按行执行 + 跳过注释/空行
脚本 demo1.sh:
runscript /demo1.sh预期:按顺序执行
echo start、ls;注释行和空行被忽略。
样例 2:最后一行无换行符
脚本内容(末尾没有 \n):
runscript /demo2.sh预期:最后一行依然会被执行,不应被漏掉。
样例 3:错误路径
执行:runscript /not_exist.sh
预期:打印打开失败信息并返回非零错误码。
若脚本中某行命令不存在(如 no_such_cmd),应提示执行失败并返回非零。
样例 4:中间命令失败时的返回行为
脚本 demo_err_mid.sh:
runscript /demo_err_mid.sh预期:
echo before正常执行;- 第二行执行失败后,
runscript立即exit(1); - 第三行
echo after不再执行。
1.4 评分细则(6 分)¶
2 分:runscript命令可正常编译、打包到fs.img。2 分:可按行顺序执行脚本。1 分:脚本文件路径错误时返回错误码1。1 分:命令执行错误时返回错误码1。
1.5 提示¶
- 助教在
user/runscript.c中提供了很多功能函数,注意灵活使用。 exec失败只会在子进程返回,记得在子进程中exit(1)。- 命令执行错误的处理涉及两处处理:一是
exec报错返回;二是exec执行成功,但指令运行过程中报错退出。 - 为了方便同学们测试,我们预先创建了一个测试脚本
user/test.sh,该测试脚本会打包进qemu的fsimg中,你可以自由修改这个文件中的内容来构造测试样例,然后进入qemu执行runscript test.sh来进行测试。
1.6 测试方法¶
建议测试步骤(第一部分)
- 编译与打包:确认
runscript已加入用户程序编译与fs.img打包列表(能在 shell 中直接运行runscript)。 - 基本功能:在 QEMU 内执行
runscript test.sh,确认能逐行执行、跳过注释/空行。 - 错误路径:
- 助教提供了一个辅助检查返回错误码的用户态程序
rs_status,该程序会调用你实现的runscript并获取其返回值; - 执行
rs_status /not_exist.sh:应输出RS_RC: 1; - 脚本中间插入不存在命令,然后执行
rs_status test.sh,应输出RS_RC: 1。
- 助教提供了一个辅助检查返回错误码的用户态程序
二、在 sh.c 中实现重定向与多指令(6 分)¶
2.1 目标¶
在 user/sh.c 中扩展 shell 解析能力,支持:
- 使用
;分隔的多条命令; - 输入输出重定向:
<、>、>>。
2.2 基础知识¶
下面是本实验可能用到的系统调用:
| 系统调用 | 核心功能 | 参数含义 | 返回值含义 | 在本实验中的典型用途 |
|---|---|---|---|---|
open(const char *path, int omode) |
打开文件 | path:文件路径;omode:打开模式 |
>=0:新 fd;-1:失败 |
灵活使用不同的模式打开重定向的目标 |
dup2(int oldfd, int newfd) |
让 newfd 指向 oldfd 相同的打开文件 |
oldfd:已有有效 fd;newfd:要覆盖的目标 fd(常用 0/1) |
>=0:返回 newfd;-1:失败 |
子进程中执行 dup2(infd, 0) 与 dup2(outfd, 1) 实现重定向 |
close(int fd) |
关闭不再使用的描述符 | fd:要关闭的描述符 |
0:成功;-1:失败 |
dup2 后关闭临时 fd;父进程也要关闭它持有的重定向 fd |
2.2.1 文件打开模式详解(open 的 omode)¶
这些模式常量定义在头文件 kernel/include/fcntl.h(源码中可见具体宏值);用户程序在 sh.c 中通过 #include "fcntl.h" 使用这些常量。
| 打开模式 | 宏值 | 含义 |
|---|---|---|
O_RDONLY |
0x000 |
只读打开 |
O_WRONLY |
0x001 |
只写打开 |
O_RDWR |
0x002 |
读写打开 |
O_CREATE |
0x200 |
文件不存在时创建 |
O_TRUNC |
0x400 |
打开时清空原文件内容 |
O_APPEND |
0x800 |
每次写入追加到文件末尾 |
2.2.2 多指令与三个重定向符详解¶
-
多指令分隔符
;- 含义:把一行命令切成多个“子命令”,按从左到右顺序依次执行。
- 典型输入:
echo A; echo B; echo C - 实现要点:
- 先按
;切分,再对每个子命令做参数解析与执行; - 某个子命令失败时,shell 不应退出,后续子命令继续尝试执行。
- 先按
-
输入重定向
<- 语义:把文件内容作为标准输入(
fd 0)喂给命令。若文件不存在,open失败并报错。
- 语义:把文件内容作为标准输入(
-
覆盖写重定向
>- 语义:把标准输出(
fd 1)写入目标文件,并从文件开头开始覆盖。若文件已存在,会先清空原内容再写入;若文件不存在,则会创建文件。
- 语义:把标准输出(
-
追加写重定向
>>- 语义:把标准输出追加到目标文件末尾,不覆盖已有内容。若文件不存在,则会创建文件。
2.3 功能要求¶
请按照下述要求,在 user/sh.c 中 TODO 注释标记的地方补全相关功能:
- 多指令分割(
;):- 一行输入可包含多条子命令;
- 子命令按顺序执行;
- 单个子命令的执行错误不能干扰其他子命令。
- 重定向解析:
- 在参数列表中识别
<、>、>>及其目标文件; - 从最终
argv中移除重定向符号与文件名; - 若符号后缺失文件名,应给出提示并安全返回。
- 在参数列表中识别
- 文件描述符重绑定:
<:将文件绑定到标准输入(fd 0);>:将文件绑定到标准输出(fd 1,覆盖写);>>:将文件绑定到标准输出(追加写);- 在子进程中通过
dup2实施重定向。
- 资源回收与健壮性:
- 父子进程中不再使用的 fd 要及时关闭;
open/fork/wait失败时应提示错误并不中断 shell 主循环。
功能要求样例
样例 1:多指令 ; 顺序执行
输入:
预期:按顺序输出A、B、C,中间不丢命令。输入
echo A;;echo B 时,应跳过空命令且不崩溃。
样例 2:多指令中存在出错指令
输入:
预期: -echo ok1 正常输出;
- no_such_cmd 执行失败时打印错误信息,但 shell 不退出;
- 后续 echo ok2 仍会继续执行并输出。
样例 3:输出重定向覆盖与追加
输入:
预期:cat 输出两行,依次为 hello、world。其中
> 覆盖写,>> 追加写。
2.4 评分细则(6 分)¶
2 分:多指令;能正确解析并顺序执行(含空命令边界处理)。1 分:错误子指令不能影响其他子指令运行。1 分:重定向>功能正确。1 分:重定向<功能正确。1 分:重定向>>功能正确。
2.5 提示¶
- 助教在
user/sh.c中提供了辅助函数,请注意灵活使用,比如:split_commands():处理;process_redirect():处理</>/>>
2.6 测试方法¶
建议测试步骤(第二部分)
- 多指令顺序:
echo A; echo B; echo C:应依次输出A/B/C;echo ok1; no_such_cmd; echo ok2:应报错但 shell 不退出,ok2仍会输出。
- 重定向:
echo hello > out.txt后执行cat < out.txt:应输出hello;echo world >> out.txt后再cat < out.txt:应能看到追加效果。
三、添加 ioctl 系统调用与实现 gpu_stats 程序(8 分)¶
3.1 目标¶
本部分目标包含两项:
- 在 NexOS 中新增并打通
ioctl系统调用链路:
用户程序 -> 用户态封装 ->ecall-> 内核 syscall 分发 -> 文件层分发(如 GPU 设备)。 - 新增用户态程序
gpu_stats.c:
通过ioctl获取 GPU 自启动以来矩阵乘执行次数,并按固定格式输出:gpu matmul_ops: %llu。
3.2 基础知识¶
3.2.1 背景:为什么需要 ioctl¶
在操作系统里,read/write 这类通用文件接口适合“按字节流读写”的场景,但对 GPU、网卡、显示设备这类复杂设备来说,常常需要“发送控制命令 + 传递结构化参数 + 获取状态结果”。
ioctl(input/output control)正是为这种需求设计的统一控制入口:用户态通过一个系统调用,把“命令字 + 参数地址”交给内核,再由内核转发给具体设备驱动处理。
在大模型推理场景中,这个机制尤其重要:推理程序通常需要频繁与 GPU 交互(例如查询能力、申请资源、提交计算、获取统计信息)。从 OS 视角看,这些动作本质上都属于“设备控制请求”,而 ioctl 就是用户态与 GPU 驱动之间的关键桥梁。
在本实验中,为了简化,助教在根目录虚拟了一个GPU设备,路径为 /gpu 。本实验后续与GPU交互均是和这个虚拟GPU设备进行交互。
ioctl 在本实验中的角色
本实验里,ioctl 承担的核心职责是:把用户程序的 GPU 控制请求安全、统一地送达设备驱动,并把处理结果返回用户态(例如返回矩阵乘累计次数)。
3.2.2 ioctl 端到端流程(用户态 -> 系统调用 -> 设备)¶
建议把整条链路理解为 5 步:
- 用户程序发起调用(用户态)
用户程序先拿到设备文件描述符fd(例如本实验需要使用open()打开/gpu),再调用:ioctl(fd, cmd, arg)。 - 用户态封装触发
ecall(用户库)
user/syscall.c中ioctl()会调用统一的__syscall:a7 = SYS_ioctla0 = fd, a1 = cmd, a2 = arg- 执行
ecall陷入内核。
- 内核系统调用总入口分发(
syscall())
内核根据trapframe->a7查syscalls[],命中SYS_ioctl后跳转到sys_ioctl()。 sys_ioctl()做参数校验并转给文件层
sys_ioctl()读取a0/a1/a2,检查fd与ofile[fd],拿到struct file *f后,调用:fileioctl(f, cmd, arg)。- 文件层按类型路由到具体设备驱动
fileioctl根据f->type分发(如FD_GPU -> gpu_ioctl),驱动执行后返回结果,再沿原路返回用户态。
3.2.3 关键接口说明¶
| 接口/系统调用 | 核心功能 | 参数含义 | 返回值含义 | 在本实验中的典型用途 |
|---|---|---|---|---|
ioctl(int fd, int cmd, uint64 arg)(用户态) |
向指定设备/文件发送控制命令 | fd:目标设备文件描述符;cmd:控制命令号;arg:命令参数(常为用户缓冲区地址) |
由内核具体实现决定;通常 0/非负 成功,-1 失败 |
用户程序控制设备(例如 GPU) |
fileioctl(struct file *f, int cmd, uint64 arg)(内核文件层) |
按 f->type 转发控制请求 |
f:已打开文件对象;cmd:控制命令;arg:附加参数 |
设备处理结果;不支持类型返回 -1 |
当 f->type==FD_GPU 时转发给 gpu_ioctl |
如何理解 cmd 与 arg
cmd决定“要做什么动作”(如查询信息、分配显存、执行计算)。arg是该动作的参数载体,通常是一个结构体在用户态的地址,内核按cmd解释其布局。
3.2.4 ioctl 系统调用函数功能逻辑¶
本小节描述 ioctl 系统调用内部核心处理逻辑,也是添加ioctl系统调用的核心,请认真阅读。
- 使用
myproc()获取进程结构体struct proc *。 -
sys_ioctl()读取参数寄存器- 从
struct proc *中的trapframe取三个参数:a0 -> fda1 -> cmda2 -> arg
- 从
-
sys_ioctl()做文件描述符合法性检查- 检查
fd是否落在[0, NOFILE)(NOFILE表示单进程文件描述符上限); - 检查失败立即返回
-1。
- 检查
-
sys_ioctl()获取结构体struct file *f:- 该结构体保存在
struct proc *中的ofile数组中; - 该数组中
fd下标对应的结构体,即是我们需要的; - 检查
struct proc *是否非空,空则返回-1。
- 该结构体保存在
-
调用
fileioctl()继续将指令和参数转交给下层分发- 我们实现的
sys_ioctl直接将fileioctl()的返回值作为自己的返回值。
- 我们实现的
本小节涉及的两个关键结构体
-
struct proc(进程控制块)- 作用:描述当前进程的运行上下文与资源。
- 获取方式:
myproc()的返回值。 - 在
sys_ioctl中主要用到:trapframe:保存系统调用参数寄存器(a0/a1/a2)。ofile[]:当前进程打开文件表,ofile[fd]可取到对应struct file *。
-
struct file(内核打开文件对象)- 作用:抽象“一个已打开的文件/设备”,统一承载文件类型和读写状态。
- 获取方式:通过进程控制块
struct proc的ofile[]数组获取。 - 本实验中作为参数传递给
fileioctl即可。
这一小节对应的源码定位
kernel/core/syscall.c:syscall()、sys_ioctl()、syscalls[]注册。kernel/core/file.c:fileioctl()。kernel/drivers/gpu.c:gpu_ioctl()(设备侧命令处理示例)。
3.2.5 本实验实现边界(重点)¶
只需要打通到 fileioctl
对同学来说,sys_ioctl 的核心任务是:正确取参 + 正确校验 + 调用 fileioctl(f, cmd, arg) + 返回结果。
也就是说,你只需要把系统调用层打通到 fileioctl,不需要在 sys_ioctl 里直接处理具体 GPU 命令细节。
3.2.6 ioctl用户态使用方法:获取 GPU 矩阵乘次数¶
下面内容对应参考源码中的定义:
- 指令定义位置:
kernel/include/gpu.h -
处理逻辑位置:
kernel/drivers/gpu.c的case GPU_IOC_GET_STATS -
ioctl指令(cmd)是什么- 指令名:
GPU_IOC_GET_STATS - 指令值:
5 - 语义:查询 GPU 驱动统计信息(当前实验中关注
matmul_ops)
- 指令名:
-
参数结构体是什么
- 结构体名:
struct gpu_stats - 定义:
- 字段含义:
matmul_ops表示“自系统启动以来,GPU_IOC_MATMUL成功执行的次数”。
- 结构体名:
-
ioctl(fd, cmd, arg)三个参数该怎么传fd:由open("/gpu", O_RDWR)返回的设备文件描述符;cmd:传GPU_IOC_GET_STATS;arg:传struct gpu_stats变量地址,即(uint64)&st。
-
返回值与判定规则
ioctl(...) == 0:调用成功,st.matmul_ops已由内核写回;ioctl(...) < 0:调用失败(例如fd非法、copyout失败),应打印错误并返回非零退出码。
需要包含的头文件
#include "user.h":提供open/close/ioctl/printf/exit等系统调用的用户态封装#include "fcntl.h":提供O_RDWR等文件打开方式描述符#include "gpu.h":提供指令GPU_IOC_GET_STATS与指令对应参数结构体struct gpu_stats
3.3 功能要求¶
本部分由两个连续任务组成:先完成添加 ioctl 系统调用,再编写用户态统计程序。
- 系统调用号注册:
- 在
kernel/include/syscall.h中新增SYS_ioctl编号。
- 在
- 内核态实现与分发:
- 在
kernel/core/syscall.c中实现sys_ioctl; - 从
trapframe读取参数(fd、cmd、arg); - 校验
fd合法性并获取struct file *; - 调用
fileioctl(f, cmd, arg)返回结果; - 在
syscalls[]分发表中注册SYS_ioctl -> sys_ioctl。
- 在
- 用户态封装:
- 在
user/syscall.c添加ioctl封装; - 在
user/user.h声明int ioctl(int fd, int cmd, uint64 arg);。
- 在
- 新增用户态程序
gpu_stats.c:- 在
user/目录新增gpu_stats.c,通过open("/gpu", O_RDWR)打开 GPU 设备; - 准备
struct gpu_stats st,调用ioctl(fd, GPU_IOC_GET_STATS, (uint64)&st)获取统计信息; - 调用成功后,按固定格式输出:
gpu matmul_ops: %llu
- 程序结束前关闭
fd,异常路径返回非零退出码。
- 在
- 修改
Makefile,将用户态程序gpu_stats.c加入编译目标,并打包进fsimg。
程序样例(gpu_stats)
一个最小流程如下:
1. 先运行若干次矩阵乘(例如运行 gpudemo)。
2. 再执行 gpu_stats。
3. 预期输出形如:
GPU_IOC_MATMUL 调用次数增加而变化。
3.4 评分细则(8 分)¶
4 分:ioctl系统调用链路完整(编号、分发、sys_ioctl、用户态封装均正确)。4 分:gpu_stats.c实现正确,能通过ioctl获取矩阵乘次数并按gpu matmul_ops: %llu格式输出。
自动测试命名与输出格式强约束
为确保通过自动测试,以下内容必须与要求完全一致,不得自行改名或改格式:
- 系统调用名:
ioctl - 用户程序文件名:
gpu_stats.c - 输出格式:
gpu matmul_ops: %llu
若上述任意一项被修改(包括大小写、下划线、空格、冒号位置等),将导致自动测试匹配失败,无法通过测试。
3.5 提示¶
- 参数寄存器约定:系统调用号在
a7,前三个参数在a0/a1/a2。 sys_ioctl中常见错误是未检查fd越界或ofile[fd] == 0。gpu_stats.c建议复用gpu.h中的struct gpu_stats与GPU_IOC_GET_STATS常量,避免魔法数字。- 输出格式请严格对齐题目要求:
gpu matmul_ops: %llu(包含空格与冒号位置)。
3.6 测试方法¶
建议测试步骤(第三部分)
- 先验证
ioctl是否成功:- 修改
Makefile,将用户态程序gpudemo.c添加进UPROGS和fsimg(参考NexOS 用户态程序创建指南); - 启动
qemu以后, 在命令行中输入并执行指令gpudemo 2 2 2 1 2 3 4 5 6 7 8,检查是否得到正确结果19 22 43 50。
- 修改
- 联动验证 GPU 统计:
- 先运行一次或多次
gpudemo 2 2 2 1 2 3 4 5 6 7 8; - 再运行
gpu_stats,应输出gpu matmul_ops: %llu,且数值等于最近一次执行gpudemo输出的STATS:后的结果。
- 先运行一次或多次
- 格式核对:输出必须严格匹配
gpu matmul_ops: %llu,否则自动测试无法通过。