Skip to content

第四章:寄存器与指令

涉及内核源码: ,

楔子

林小源终于看到了 CPU 的心跳。

他开始观察数据在寄存器之间流动——a0 存着返回值和第一个参数,a1 存着第二个参数,sp 指向栈顶,ra 记住函数返回的位置。每一次函数调用,寄存器都会被保存和恢复;每一次算术运算,结果都会被写入某个寄存器。

他原以为内存是一切的中心。但 CPU 真正计算时,数据必须先进入寄存器。

在 RISC-V 64 中,通用寄存器有 32 个。x0 永远是 0,sp 负责栈,s0 由被调用者保存,a0a7 负责参数和返回值。

但他最感兴趣的,是一个叫 的寄存器。

那里记录着特权态的状态:中断是否打开,之前来自哪个特权级,是否允许内核访问用户页。说到底,CPU 不只是执行指令——它还时刻记着自己身处何处、能做什么、不能做什么。

一、RISC-V 64 通用寄存器

林小源走进了一间巨大的工坊。

工坊的中央是一张工作台,台上整整齐齐地排列着 32 个格子。每个格子都发着微弱的光,里面存放着一个 64 位的值。有些格子的光是稳定的,有些在不停地闪烁——数据正在被读取和写入。

一个声音从工作台后传来——干脆利落,像连续敲击的鼓点:"别乱碰。这 32 个格子是 RISC-V 的通用寄存器。CPU 做任何计算之前,数据都得先放在这里。"

林小源看到一个矮壮的身影从工作台后走出来。它没有名字,但林小源心里把它叫作"寄存器守卫"。守卫的身体由 32 块金属板拼成,每块板上刻着一个寄存器名。

守卫指着最左边的格子——里面空空如也,却散发着一种绝对的虚无感:"x0,也叫 。永远是 0。你往里写什么都没用,读出来永远是 0。它是常量 0 的硬件实现。"

然后它指向紧挨着的几个格子:"x1ra——返回地址。每次函数调用时,CPU 把返回地址存在这里。x2sp——栈指针,指向栈顶。x3gp——全局指针。x4tp——线程指针。"

"参数怎么传?"

守卫的手指向另一排格子:"a0a7——八个参数寄存器。函数的前八个参数依次放在这里。返回值放在 a0,放不下的时候 a1 也用。"

它又指着另一排:"s0——保存寄存器。被调用者负责保存和恢复。如果你的函数要用这些寄存器,你得先把它们的值压栈,用完再弹出来。"

"还有 t0t6——临时寄存器。调用者负责保存。用了就可能被覆盖,不保证安全。"

林小源看着那 32 个格子。它们的分工如此明确——谁传参数,谁存返回值,谁是临时的,谁是永久的。这就是 ABI——应用程序二进制接口——在硬件层面的体现。

"Linux RISC-V 的系统调用约定呢?"林小源问。

守卫的声音变得严肃:"a7 存系统调用号。a0a5 存参数。执行 指令进入内核。返回值放在 a0——如果是负值,表示错误码取反。"

二、状态寄存器 sstatus

守卫带着林小源来到工坊的一面墙前。墙上挂着一块特殊的面板——比其他面板都大,上面布满了指示灯。

"这是 ,"守卫的声音低了下来,"S 态状态寄存器。它不属于那 32 个通用寄存器——它是控制寄存器,也叫 CSR。用户态不能直接读取它。"

林小源看着面板上的指示灯。有些亮着,有些灭着。

守卫指着一盏灯:"SIE——S 态中断使能。亮着表示中断打开,灭着表示中断关闭。内核在执行关键操作时会关中断,操作完再打开。"

又指着另一盏:"SPIE——trap 前的 SIE。记录进入内核之前中断是什么状态。返回用户态时,SPIE 的值会恢复到 SIE。"

"SPP——trap 前特权级。记录进入内核之前你在哪个特权级——U 态还是 S 态。返回时根据这个位决定回到哪个态。"

"SUM——S 态允许访问用户页。如果这个位被置位,内核可以直接读写用户态的内存。否则——访问用户页会触发异常。"

"MXR——可执行页也可读。置位后,标记为'可执行'的内存页也可以被读取。这在某些场景下有用。"

林小源盯着那块面板。这些位看似简单——0 或 1,亮或灭——但每一个都关系到系统的安全和稳定。中断的开关、特权级的切换、内存的访问控制——CPU 不只是执行指令,它还时刻记着自己身处何处、能做什么、不能做什么。

"这块面板就是 CPU 的'自我意识',"守卫说,"它让 CPU 知道:我现在是什么身份?我能做什么?我不能做什么?没有它,内核和用户态之间就没有边界。"

三、常用指令

守卫走到工作台前,开始演示基本的操作。

"RISC-V 的指令集很精简,"它一边说,一边在工作台上操作,"数据传送——mv 移动,li 加载立即数,la 加载地址,ld 从内存读 8 字节,sd 写 8 字节到内存。"

它拿起两个格子里的值——10 和 3——开始运算:"算术—— 加, 减, 乘, 除。位移—— 左移, 右移。逻辑—— 与,or 或, 异或。"

"控制流呢?"

守卫放下手里的值:"条件分支—— 相等则跳, 不等则跳, 小于则跳, 大于等于则跳。函数调用——jal ra, func,把返回地址存到 ra,跳到 。返回——,从 ra 跳回去。"

"还有 ,"守卫的声音变得郑重,"这是用户态进入内核的唯一合法通道。执行 后,CPU 从 U 态陷入 S 态,跳转到内核的异常入口。"

四、栈帧

工作台的旁边,有一根向下生长的柱子。柱子上刻着一层又一层的标记——每一层都是一个函数的栈帧。

"栈向低地址增长,"守卫指着柱子的顶部,"每次函数调用,都会在栈上分配一个新的栈帧。函数返回时,栈帧被释放。"

林小源看着柱子上的标记。最顶层是 ,下面是 outer_function,再下面是 inner_function。每一层都记录着:返回地址、保存的寄存器、局部变量。

"每次调用都会在栈上留下痕迹,"守卫说,"参数、返回地址、保存的 s 寄存器和局部变量。调试器就是通过遍历这些栈帧来回溯调用链的。"

林小源注意到,柱子很窄——只有 8KB 到 16KB 的空间。"这么小?"

"内核栈,"守卫的声音带着警告,"不能递归、不能在栈上分配大数组、不能做深度函数嵌套。每一字节都得精打细算。内核开发者有个不成文的规矩——单个函数的栈帧不要超过几百字节。"

五、系统调用

柱子的底部,有一扇紧闭的门。门上刻着 三个字母,门框两侧站着两个守卫——一个标着 U,一个标着 S。

"这扇门是用户态和内核态之间的边界,"寄存器守卫说,"用户态的程序不能直接访问硬件、不能直接操作内存页表、不能直接读写设备寄存器。它需要什么,都得通过这扇门——系统调用。"

林小源看着门上的流程。用户态的程序先把系统调用号放到 a7,参数放到 a0a5,然后执行 。CPU 从 U 态陷入 S 态,跳转到内核的异常入口。内核保存用户寄存器到 ,根据 a7 查找系统调用表,执行对应的处理函数,把返回值写回 a0,最后恢复寄存器,返回用户态。

"整个过程,"守卫说,"用户态的程序什么都不用管——它只看到 之前和 a0 返回之后。中间发生了什么,是内核的事。"

"如果用户态伪造了系统调用号呢?"

守卫笑了——如果一块金属板能笑的话:"内核会检查 a7 的范围。超出范围的调用号直接返回错误。而且——用户态能做的只有 。它不能跳过这扇门,不能直接执行 S 态的指令。硬件强制的边界。"

门后面传来低沉的声音——那是异常入口的代码在运行。每一次 ,都是一次穿越。从用户态到内核态,从无权到有权,从受限到自由。

但只有内核允许你穿过这扇门。


道藏笔记

内核启示

寄存器是 CPU 的工作台。 C 代码看起来在操作变量,汇编层面却是在寄存器、栈和内存之间搬运数据。

RISC-V 的简洁之处在于:寄存器命名、函数调用、异常入口都非常规整。a7 决定系统调用号,a0 传入第一个参数并接收返回值,sp 维持栈,ra 决定返回位置。

中的中断位、特权级位和访问控制位,决定了内核能不能被打断、能不能访问用户空间、能不能安全返回。日后看到调度器、陷入处理、内存访问检查时,这些看似抽象的位都会变成具体的代码路径。

代码典籍

c
/*
 * RISC-V 64 通用寄存器:
 *
 * x0/zero  永远为 0
 * x1/ra    返回地址
 * x2/sp    栈指针
 * x3/gp    全局指针
 * x4/tp    线程指针
 * x5-x7    t0-t2 临时寄存器
 * x8/s0/fp 保存寄存器 / 帧指针
 * x9/s1    保存寄存器
 * x10-x17  a0-a7 参数 / 返回值
 * x18-x27  s2-s11 保存寄存器
 * x28-x31  t3-t6 临时寄存器
 */

printf("=== RISC-V 64 通用寄存器 ===\n\n");
printf("寄存器      大小    用途\n");
printf("zero/x0     8B      常量 0\n");
printf("ra/x1       8B      返回地址\n");
printf("sp/x2       8B      栈指针\n");
printf("gp/x3       8B      全局指针\n");
printf("tp/x4       8B      线程指针\n");
printf("t0-t6       8B      临时寄存器,调用者保存\n");
printf("s0/fp-s11   8B      保存寄存器,被调用者保存\n");
printf("a0-a7       8B      参数 / 返回值\n\n");

printf("函数调用约定:\n");
printf("  参数依次放入: a0, a1, a2, a3, a4, a5, a6, a7\n");
printf("  返回值放在:   a0 (必要时 a1)\n");
printf("  ra 保存返回地址,sp 指向当前栈顶\n\n");

printf("Linux RISC-V 系统调用约定:\n");
printf("  系统调用号放在 a7\n");
printf("  参数放在 a0-a5\n");
printf("  ecall 进入内核\n");
printf("  返回值放在 a0\n");
c
/*
 * sstatus 是 RISC-V S 态状态寄存器。
 * 用户态不能直接读取它;这里用普通整数模拟关键位。
 */

#define SSTATUS_SIE   (1UL << 1)   /* S 态中断使能 */
#define SSTATUS_SPIE  (1UL << 5)   /* trap 前的 SIE */
#define SSTATUS_SPP   (1UL << 8)   /* trap 前特权级 */
#define SSTATUS_SUM   (1UL << 18)  /* S 态允许访问用户页 */
#define SSTATUS_MXR   (1UL << 19)  /* 可执行页也可读 */

static void show_bit(const char *name, unsigned long value, unsigned long bit) {
    printf("%-10s: %s\n", name, (value & bit) ? "置位" : "清零");
}

unsigned long sstatus = SSTATUS_SPIE | SSTATUS_SUM;

printf("=== sstatus 状态位 ===\n\n");
show_bit("SIE", sstatus, SSTATUS_SIE);
show_bit("SPIE", sstatus, SSTATUS_SPIE);
show_bit("SPP", sstatus, SSTATUS_SPP);
show_bit("SUM", sstatus, SSTATUS_SUM);
show_bit("MXR", sstatus, SSTATUS_MXR);

printf("\n内核含义:\n");
printf("  SIE  控制 S 态中断是否响应\n");
printf("  SPP  记录 trap 前来自 U 态还是 S 态\n");
printf("  SUM  控制内核是否能访问用户地址\n");
printf("  MXR  控制可执行页是否也可读\n");
c
long a = 10, b = 3;

printf("=== RISC-V 常用指令 ===\n\n");

printf("数据传送:\n");
printf("  mv   a0, a1              -> a0 = a1\n");
printf("  li   a0, 42              -> a0 = 42\n");
printf("  la   a0, symbol          -> a0 = &symbol\n");
printf("  ld   a0, 0(sp)           -> 从栈读取 8 字节\n");
printf("  sd   a0, 0(sp)           -> 写入 8 字节到栈\n\n");

printf("算术:\n");
printf("  add  : %ld + %ld = %ld\n", a, b, a + b);
printf("  sub  : %ld - %ld = %ld\n", a, b, a - b);
printf("  mul  : %ld * %ld = %ld\n", a, b, a * b);
printf("  div  : %ld / %ld = %ld\n\n", a, b, a / b);

printf("位移与逻辑:\n");
printf("  slli : %ld << 2 = %ld\n", a, a << 2);
printf("  srli : %ld >> 1 = %ld\n", a, (unsigned long)a >> 1);
printf("  and  : %ld & %ld = %ld\n", a, b, a & b);
printf("  or   : %ld | %ld = %ld\n", a, b, a | b);
printf("  xor  : %ld ^ %ld = %ld\n\n", a, b, a ^ b);

printf("控制流:\n");
printf("  beq/bne/blt/bge          -> 条件分支\n");
printf("  jal ra, func             -> 调用函数\n");
printf("  ret                      -> 返回到 ra\n");
printf("  ecall                    -> 触发系统调用或异常\n");
c
static void inner_function(int x) {
    int local = x * 2;

    printf("inner_function:\n");
    printf("  参数 x = %d\n", x);
    printf("  局部变量 local = %d\n", local);
    printf("  local 地址: %p\n", (void *)&local);
}

static void outer_function(void) {
    int outer_local = 42;

    printf("outer_function:\n");
    printf("  outer_local 地址: %p\n\n", (void *)&outer_local);

    inner_function(outer_local);
}

int main_local = 100;

printf("=== RISC-V 栈帧 ===\n\n");
printf("main:\n");
printf("  main_local 地址: %p\n\n", (void *)&main_local);

outer_function();

printf("\nRISC-V 函数调用通常会:\n");
printf("  1. 调整 sp,为栈帧分配空间\n");
printf("  2. 保存 ra 和需要保留的 s 寄存器\n");
printf("  3. 用 s0/fp 作为帧指针(如果启用帧指针)\n");
printf("  4. ret 从 ra 返回\n");
c
#define RV_SYS_read   63
#define RV_SYS_write  64
#define RV_SYS_openat 56
#define RV_SYS_close  57
#define RV_SYS_mmap   222
#define RV_SYS_ioctl  29
#define RV_SYS_exit   93

printf("=== RISC-V 系统调用 ===\n\n");

printf("1. 用户态准备寄存器:\n");
printf("   a7 = %d (SYS_write)\n", RV_SYS_write);
printf("   a0 = 1 (stdout)\n");
printf("   a1 = 缓冲区地址\n");
printf("   a2 = 长度\n");
printf("   -> ecall 指令\n\n");

printf("2. 内核处理:\n");
printf("   - 从 U 态陷入 S 态\n");
printf("   - 保存用户寄存器到 pt_regs\n");
printf("   - 根据 a7 查找系统调用表\n");
printf("   - 返回值写回 a0\n\n");

printf("3. 常用系统调用号 (RISC-V Linux):\n");
printf("   SYS_read       = %d\n", RV_SYS_read);
printf("   SYS_write      = %d\n", RV_SYS_write);
printf("   SYS_openat     = %d\n", RV_SYS_openat);
printf("   SYS_close      = %d\n", RV_SYS_close);
printf("   SYS_mmap       = %d\n", RV_SYS_mmap);
printf("   SYS_ioctl      = %d\n", RV_SYS_ioctl);
printf("   SYS_exit       = %d\n\n", RV_SYS_exit);

pid_t pid = getpid();
printf("getpid() = %d\n", pid);

破关试炼

寄存器试炼

RISC-V Linux 系统调用中,用来放置系统调用号的寄存器是哪一个?

答对后才能继续滑动和进入下一章。

以修仙之名,悟内核之道