跳转至

系统调用

系统调用的必要性(再强化)

原因有三: 1. 特权级僭越(物理限制):AM API 包含对底层硬件的直接操作(如读写特定端口、配置异常入口)。执行这些操作在物理上要求 CPU 处于最高特权级(Ring 0 / M-Mode)。如果允许用户程序调用,等于把系统的最高控制权交给了不可信的第三方代码。 2. 全局状态污染(状态机破坏):批处理系统是一个连续流转的状态机。如果用户程序 A(即使无恶意但有 Bug)通过 AM API 破坏了全局硬件状态(如覆写了显存边界、改错了中断向量表),程序 A 结束后,系统加载程序 B 时,面对的是一个已经崩溃的硬件上下文。系统调用是保证任务切换时,系统状态绝对干净的唯一屏障。 3. 抽象语义错位(架构设计):AM API 是对“裸机硬件”的抽象,而用户程序需要的是对“系统资源”的抽象。强迫用户程序处理物理硬件细节,彻底破坏了软件的可移植性。


权限越界后,CPU与OS的状态转移

当用户程序(U-Mode)强行执行了带有 M-Mode 标签的指令(如 csrrw 修改 mtvec),硬件安检门报警后,CPU 的微观状态会发生以下原子性(Atomic)的转移:

异常控制流(ECF)的微观状态转移:

  1. 触发异常:硬件译码器发现指令特权级要求(M) > 当前特权级(U),指令执行被强行中止,绝不会修改目标 CSR。
  2. 硬件上下文保存(状态机切换)
    • mepc $\leftarrow$ 当前触发非法指令的 PC 值(记录案发现场)。
    • mcause $\leftarrow$ 异常号(例如 RISC-V 中 Illegal Instruction 的 Exception Code 通常为 2)。
    • mtval $\leftarrow$ 触发异常的具体指令机器码(可选,用于辅助 OS 诊断)。
  3. 特权级与中断状态切换
    • mstatus.mpp $\leftarrow$ U(记录陷入前的特权级为 User Mode)。
    • 当前特权级 $\leftarrow$ M-Mode(硬件强制提权,进入内核态)。
    • mstatus.mie $\leftarrow$ 0(全局关闭中断,防止异常处理被嵌套打断)。
  4. PC 劫持(控制流转移)
    • PC $\leftarrow$ mtvec(强制跳转到操作系统在启动时注册好的异常处理入口)。

操作系统的接管: 此时,控制权已经安全地交还给处于 M-Mode 的操作系统。OS 的 Trap Handler 会读取 mcause,发现是 2 号异常(非法指令),再读取 mstatus.mpp 确认来自 U-Mode。 此时,OS 作为一个冷酷的资源管理者,通常会直接向该用户进程发送 SIGILL 信号,将其强制杀死,并调度下一个批处理任务,从而保护了整个系统的纯洁性。

ISA相关:riscv系统调用号的传递

如果你选择的是RISC-V, 你会发现它并不是通过a0来传递系统调用号. 事实上, 我们参考了RISC-V Linux的系统调用参数传递的约定: 即在RISC-V Linux上也是通过这个寄存器来传递系统调用号的. 你觉得RISC-V Linux为什么没有使用a0来传递系统调用号呢?

A: 我做对的地方 思考方向完全正确,精准命中了系统设计的两个核心痛点: 1. 锁定 ABI 约束:你立刻意识到这与“函数调用约定(ABI)”强相关,没有去硬件微架构(如流水线)里瞎找原因,大方向毫无偏差。 2. 发现“老幺”的特殊生态位:你观察到 a7 是参数寄存器中的最后一个。绝大多数系统调用参数极少(通常 1~4 个),a7 长期处于闲置状态。 3. 直击“冲突与破坏”的本质:你推测是为了避免“参数”和“系统调用号”对寄存器的争夺。你敏锐地意识到,控制信息(调用号)和数据流(参数)如果混用高频寄存器,必然会导致数据被破坏。

架构级的标准解答(需要补全的底层逻辑) RISC-V Linux 做出这个决定的根本原因,可以降维概括为两个第一性原理:“零成本抽象”“状态机保护”。 1. 消除数据腾挪开销(Zero-Cost Abstraction) * 如果用 a0 传调用号:根据 C 语言 ABI,用户态调用 write(fd, buf, len) 时,fd 默认在 a0。为了把 a0 腾出来放 SYS_write,标准库必须写一堆汇编,把 a2 移到 a3a1 移到 a2a0 移到 a1。这会带来无意义的指令开销和延迟。 * 标准解法 (a7):C 编译器排布好的 a0~a2 参数原地不动。标准库只需要加一条 li a7, SYS_write,直接 ecall 进内核。完美实现了 C 语言到系统调用的“零开销映射”。 2. 保护控制流上下文,防止返回值覆写(Context Preservation) * 如果用 a0 传调用号:系统调用结束时,内核必须把返回值(如成功读取的字节数、或错误码 -1)写回 a0。此时,原本存在 a0 里的 SYS_write 就被彻底抹除了。 * 致命后果:如果系统调用执行到一半被硬件中断或信号打断,内核需要“重启”这个系统调用时,会发现系统调用号已经丢失。 * 标准解法 (a7):将控制流(a7 存调用号)与数据流(a0 存返回值)在物理寄存器上彻底隔离。无论 a0 怎么被覆写,a7 里的调用号稳如泰山,内核随时可以安全地重启状态机。

穿越时空的旅程 PRO

hello 程序的前世今生:它从哪里来,到哪里去

在 PA 的执行模型里,navy-apps/tests/hello/hello.c 先被编译、链接成 ELF 可执行文件。这个 ELF 并不会一开始就在“可执行内存”里运行,而是先作为镜像内容存放在文件系统镜像中(实验环境里可理解为 ramdisk 上的程序文件)。

系统启动后,Nanos-lite 通过 loader() 加载用户程序。加载过程的关键不是“把 ELF 头映射到内存”,而是:

  1. 从 ramdisk 读出 ELF 头;
  2. 解析 program header;
  3. 对每个 PT_LOAD 段,把 filesz 字节拷贝到该段的目标地址;
  4. memsz > filesz,对剩余区域清零(即 .bss);
  5. 取 ELF 的入口地址 e_entry 作为程序第一条指令地址。

因此,hello “为什么出现在某个内存位置”,本质上是由链接脚本与 ELF 段信息共同决定的:链接时已经确定了各段的虚拟地址,加载器按这些地址把段放入内存。


程序真正开始执行时,CPU 并不是“随便跳到某地址”,而是通过内核建立的执行上下文切换到 e_entry,从 hello 的第一条指令开始运行。
hello 在运行期间会调用库函数;当库函数需要操作系统服务(如 writebrk)时,会通过系统调用触发 ecall

  • 用户态执行 ecall
  • ISA/AM trap 框架保存现场并构造上下文(Context);
  • 进入统一异常入口(trap 汇编)后调用 C 级事件分发;
  • do_event/do_syscall 根据系统调用号执行 SYS_writeSYS_brk 等;
  • 把返回值写回约定寄存器(如 GPRx);
  • 恢复上下文,返回到原执行流继续执行下一条指令。

对“字符如何到终端”的主线可概括为:

hello -> libc封装(如printf/write) -> ecall -> SYS_write -> 串口输出例程(putch) -> 终端显示

当 hello 调用 printf()write() 时,用户态最终会执行到库里的 _write() 封装,再通过内联汇编触发 ecall,把系统调用号(SYS_write)和参数(fdbuflen)放入约定寄存器。
CPU 因 ecall 进入异常/中断处理流程,保存现场后交给 Nanos-lite 的系统调用分发逻辑(do_syscall)。内核识别到 SYS_write 后,若 fd1/2(stdout/stderr),就把 buf 指向的字符逐个交给串口输出函数(如 putch)。
putch 再通过 AM 提供的底层 I/O 机制把字符发送到模拟串口设备,最终由仿真器/终端前端显示出来。
系统调用返回写入字节数后,控制流恢复到用户程序,hello 继续执行下一条指令并重复这一过程,于是我们在终端上看到持续输出。