跳转到内容

课前准备:Avatar OS 前置知识

"Indeed, the best way to understand how computers work is to build one from scratch." — Noam Nisan & Shimon Schocken 出自《The Elements of Computing Systems: Building a Modern Computer from First Principles》(中文译名:《计算机系统要素:从零开始构建现代计算机》)前言。

从 C 语言跳转到 OS,跨度很大,Unit 5 的实验难度较高。在开始前,请做足知识和环境上的准备。

NOTE

课程仓库opencamp/learning-nccl/Unit-5-C-Kernel 基于 Avatar OS 真实内核代码的 24 节由浅入深的课程,涵盖 AArch64 / RISC-V 64 / x86_64 三个架构。


课程总览

五个阶段 · 24 课(L97–L120)

阶段Lesson主题关键文件
一、裸机基础97Bare-Metal Hello 裸机第一声boot_hello.S
98Freestanding Types 独立类型系统include/types.h
99Kernel Logging 内核日志系统include/klog.h, lib/klog.c
100Linker Script 链接脚本boot/*/link.ld
101Boot Assembly 引导汇编boot/*/boot.S
102Architecture Abstraction 架构抽象层include/arch.h, include/barrier.h
二、内存与同步103Memory Barriers 内存屏障include/barrier.h
104Spinlocks 自旋锁include/spinlock.h
105Physical Memory Manager 物理内存管理kernel/mm/pmm.c
106Page Tables 页表结构include/aarch64/mmu.h
107MMU Enable 启用 MMUkernel/mm/*/mmu.S
108Kernel Address Space 内核地址空间include/mm_vm.h
三、中断与定时器109Exception Vectors 异常向量表boot/*/exception.S
110Exception Handling 异常处理boot/*/exception.c
111Timer Driver 定时器驱动driver/timer/timer.c
112Interrupt Controllers 中断控制器driver/irq/gicv3.c, plic.c, lapic.c
四、多任务113Task Control Block 任务控制块kernel/task/task.h
114Context Switch 上下文切换kernel/task/*/switch.S
115Round-Robin Scheduler 轮转调度kernel/task/sched.c
116Preemptive Scheduling 抢占式调度kernel/task/preempt.c
五、用户态与多核117User Mode 用户态kernel/task/task.c
118System Calls 系统调用kernel/syscall/syscall.c
119ELF Loader ELF 加载器kernel/loader/elf_loader.c
120SMP Multicore 多核启动与调度kernel/task/cpu.c

里程碑

里程碑对应 Lesson能力描述
M1L105最小内核基础设施:UART + 日志 + 栈 + 锁 + 物理内存
M2L108虚拟内存完备:页表 + MMU + 地址空间隔离
M3L116抢占式多任务:定时器驱动的上下文切换
M4L120完整操作系统:用户态 + 系统调用 + 多核

三架构支持

每课涉及的概念均在三个架构上实现,可通过 ARCH 变量切换:

bash
make ARCH=aarch64 run    # AArch64(ARM 64位)
make ARCH=riscv64 run    # RISC-V 64
make ARCH=x86_64 run     # x86_64

构建与评测

bash
# 编译运行
make -C kernel clean
make -C kernel build
make -C kernel qemu

# 评测指定 Lesson
bash grading/grade.sh -l 97

# 评测全部
bash grading/grade.sh

每课 README 中的「预期输出」描述了自动评测匹配的关键内容,通过即代表该课实验通过。


一个好用的 AI 助手

Unit 5 与之前的 C 语言单元有一个本质区别:你将面对大量陌生概念——汇编指令、MMU、页表、异常向量、原子操作、内存屏障…… 这些知识很难一次性全部记住,也不应该在还没开始实验时就试图"彻底搞懂"。

下面的前置知识章节(计算机组成原理、进阶 C 特性、汇编详解)是按需查阅的参考手册,不是要求背诵的教科书。更好的学习方式是:

  1. 从 L97 直接开始实验
  2. 遇到不懂的指令或概念 → 回到本文档对应章节查阅
  3. 查阅后仍有疑问 → 用 AI 助手即时提问

举个具体例子:你在 L100 的 boot.S 里看到 movz x19, #0x0000 后面紧跟着 movk x19, #0xFFFF, lsl #48,你可能会问"为什么不用一条 mov 写 64 位地址?"——这时把这段汇编贴给 AI,它能立刻告诉你 ARMv8 指令编码限制和 movz+movk 的工作原理,比翻手册快 10 倍。

类似的场景贯穿整个课程:

  • dsb / dmb / isb 到底有什么区别?(L107 MMU 启用)
  • ldaxr + stxr 为什么失败后要重试?(L103 自旋锁)
  • 异常向量表为什么要 .balign 0x80?(L109)
  • 上下文切换为什么只保存 x19-x30?(L114)

这些都是 AI 助手最擅长回答的问题——它不需要你写出完整的上下文,只要贴一段代码 + 一句话提问即可。

NOTE

CNB 官方为学习提供了一些 AI 额度,可在 WebIDE、VS Code 等环境中使用。安装 Tencent Cloud CodeBuddy,模型建议选 DeepSeek V4 Pro


一、计算机组成原理与操作系统核心概念

汇编语言直接操作硬件,如果不理解计算机的基本组成,看汇编指令就像看天书。本节帮你建立基本的硬件心智模型,为后续汇编学习和实验打好基础。

1.1 CPU 基础:寄存器与程序计数器

CPU 的核心组件:运算器(ALU) + 控制器 + 寄存器组

概念说明课程对应
寄存器(Register)CPU 内部最快的存储单元,直接参与 ALU 运算L97 起全程使用
通用寄存器ARMv8 有 x0-x30 共 31 个 64 位通用寄存器L97:strb w1, [x0] 输出字符
程序计数器 PC存放下一条将要执行指令的地址L100:理解 bl/ret 如何通过修改 PC 实现函数调用与返回
链接寄存器 LR(x30)函数调用时自动保存返回地址L100:bl 将返回地址写入 x30
栈指针 SP指向当前栈顶,栈用于保存局部变量和返回地址L100:Boot 阶段设置栈

NOTE

寄存器 vs 内存:寄存器的访问速度是内存的上百倍,但数量极少(AArch64 只有 31 个通用寄存器)。这正是汇编编程的核心挑战——如何高效地使用有限的寄存器。

1.2 内存层级:从寄存器到外存

层级速度容量典型大小
寄存器最快最小~31 个 × 64-bit
L1 Cache32–64 KB
L2 Cache256–512 KB
L3 Cache2–32 MB
主存(RAM)4–64 GB
外存(Flash/磁盘)最慢最大128 GB–数 TB
概念说明课程对应
Cache(缓存)CPU 与主存之间的高速缓冲,最小单位是 Cache Line(通常 64 字节)L103:理解为什么 Store 不立即对其他核可见
Cache Line缓存与主存交换数据的最小单位L103-L104:ldaxr/stxr 以 Cache Line 为独占监视粒度
TLB(地址翻译缓存)MMU 的"缓存",缓存最近用过的虚拟地址→物理地址映射L107:页表修改后必须用 tlbi 刷新 TLB
MMIO 不走 Cache外设寄存器映射到物理地址,每次读写必须直达硬件L97:UART 寄存器必须标记 volatile

1.3 地址空间:物理地址与虚拟地址

概念说明课程对应
物理地址(PA)硬件总线上的真实地址,直接对应内存芯片的某个位置L97:UART MMIO 基址 0x09000000
虚拟地址(VA)程序看到的地址,由 MMU 翻译成物理地址L99:链接脚本中的 VMA
MMU 地址翻译VA → 查多级页表 → PAL107:MMU 启用流程
内核高半区 vs 用户低半区内核映射到高地址(0xFFFF...),用户程序在低地址(0x0000...L108:地址空间隔离

1.4 特权级:CPU 的安全分层

概念说明课程对应
EL0(用户态)最低特权级,不能访问系统寄存器L117:用户程序运行在 EL0
EL1(内核态)OS 内核运行级别,可访问所有系统资源L97 起大部分代码运行在 EL1
EL2(Hypervisor)虚拟机监控器级别本课程不涉及
EL3(Secure Monitor)安全监控级别本课程不涉及
svc/eret 切换特权级svc 从 EL0 进入 EL1,eret 从 EL1 返回 EL0L117-L118:系统调用实现

TIP

为什么需要特权级? 如果用户程序能随意修改页表、关闭中断,整个系统就会崩溃。特权级确保了只有可信的内核代码才能操作关键硬件资源。

1.5 中断与异常:CPU 如何响应外部事件

概念说明课程对应
同步异常由指令执行直接触发(如系统调用 svc、缺页异常)L109:svc 触发同步异常
异步中断由外部硬件触发(如定时器、键盘、网卡)L111-L112:定时器 + GIC 中断控制器
异常向量表一张固定布局的跳转表,CPU 根据异常类型自动跳转到对应入口L109:VBAR_EL1 指向 2KB 的向量表
中断屏蔽通过 DAIF 寄存器控制哪些中断被屏蔽L100:msr daifset 屏蔽中断

二、进阶 C 特性

以下 C 语言特性在裸机/内核开发中高频出现。如果对某个概念不熟,建议先回顾 Unit 1-4 的相关实验。

2.1 指针进阶

知识点说明课程对应
void* 泛型指针通用指针类型,需要时强制转换L105:PMM 分配器返回 void*
多级指针 void**指针的指针L113:TCB 的 sp 字段保存"栈指针的地址"
函数指针指向函数的指针,用于回调/分发表L117:系统调用分发表 syscall_table[]
container_of从结构体成员指针反推结构体首地址L115:从链表节点获取包含它的 task_struct
成员对齐 & 首地址相等结构体第一个成员偏移为 0L113:sp 必须在 TCB 偏移 0

2.2 宏编程

c
// container_of — 内核中最经典的宏之一
#define container_of(ptr, type, member) \
    ((type *)((char *)(ptr) - offsetof(type, member)))
知识点说明课程对应
#define 函数宏用宏模拟内联函数L108 phys_to_virt() / L115 container_of()
do { } while(0) 包装让多语句宏在 if/else 中安全展开L98:kprintf
##__VA_ARGS__可变参数宏L98:kprintf 格式化字符串
__typeof__编译期类型推导L115:container_of 类型安全检查
# 字符串化 / ## 拼接宏参数转字符串 / 标识符拼接L98:日志级别宏模板
#if/#ifdef 条件编译架构相关代码选择性编译L102:ARCH_AARCH64 vs ARCH_RISCV64

TIP

为什么宏要用 do { ... } while(0) 包装? 考虑这个场景:

c
// 错误写法:只有 stmt1 受 if 控制
#define BAD_MACRO  stmt1; stmt2;
if (cond) BAD_MACRO   // → if (cond) stmt1; stmt2;

// 正确写法:整个宏作为一条语句
#define GOOD_MACRO  do { stmt1; stmt2; } while(0)
if (cond) GOOD_MACRO  // → if (cond) do { stmt1; stmt2; } while(0);

2.3 static / inline / volatile / extern

知识点说明课程对应
static 文件级作用域函数/变量不对外暴露,避免符号冲突L97:static void uart_putc() 驱动函数内部可见
static inline零开销内联 + 文件级作用域L103:Spinlock 函数——频繁调用,必须内联
extern 外部符号引用其他文件或链接脚本定义的符号L100:引用 __bss_start[]_stack_top
volatile告诉编译器"每次都从内存读取,不要优化"L97:UART MMIO 寄存器 / L103:Spinlock

TIP

volatile 为什么在 MMIO 中必不可少? 编译器看到 *p = 'A'; *p = 'B'; 时,可能会优化成只做 *p = 'B'。但对于 UART 设备寄存器,每次写入都是一次真实的硬件操作——不加 volatile,你的字符就丢了。

2.4 位运算与内存模型

知识点说明课程对应
位运算 << >> & | ~ ^对二进制位直接操作贯穿全程
Bitmap 位图用 1 个 bit 表示资源是否占用L105:物理内存分配器
位字段组装与提取从 64 位值中提取/设置特定字段L107:页表描述符构建 / 虚拟地址分解
sizeof / offsetof / 对齐类型大小 / 成员偏移 / 地址对齐要求L113 TCB 布局 / L106 页表对齐 / L109 异常向量表对齐
-nostdlib -ffreestanding无 libc 的裸机环境编译参数L99:freestanding 环境,一切从零构建

2.5 工具链知识

知识说明课程对应
make / make clean构建系统L97 起:每日构建
objdump -d / nm / readelf反汇编 / 符号表 / ELF 解析L100:检查 ELF 结构 / L119:解析 ELF
gdb + QEMU 远程调试源码级调试裸机程序L100 起
链接脚本 VMA vs LMA虚拟地址 vs 加载地址L99-L100:段布局
段:.text .rodata .data .bss代码段 / 只读数据 / 已初始化数据 / 零初始化数据L99:段分类 / L100:BSS 清零

2.6 链接器(Linker)是什么

编译四阶段中,前三个阶段(预处理 → 编译 → 汇编)将每个 .c / .S 文件变成独立的 .o(目标文件),而 链接器 负责最后一步:把多个 .o 文件合并成一个可执行文件。

阶段输入工具输出
预处理hello.ccpphello.i
编译hello.icc1hello.s
汇编hello.sashello.o
...world.c → ... → world.o(同上)
链接hello.o + world.old(链接器)kernel.elf

链接器做了三件关键的事:

  1. 符号解析(Symbol Resolution):每个 .o 文件里有"我提供什么符号"和"我需要什么符号"。链接器把所有 .o 的符号表合并,把"需要"匹配到"提供"。例如 boot.S 里声明了 .globl _start(提供),link.ld 里用 ENTRY(_start) 找到它(需要)。

  2. 段合并(Section Merging):所有 .o.text 段合并成一个大的 .text,所有 .data 合并成一个大的 .data,依次类推。最终一个可执行文件内部是整齐排列的段,而不是散落的碎片。

  3. 地址分配(Address Assignment):链接脚本(linker script,*.ld)告诉链接器每个段应该放在哪个虚拟地址(VMA)。例如".text 放在 0xFFFF000000000000.bss 放在 .data 之后"。这一步决定了程序运行时的内存布局。

TIP

和裸机开发有什么关系? 在普通 C 程序中,链接器用的是系统默认脚本,你感受不到它的存在。但在裸机/内核开发中,你必须自己写链接脚本——因为没有操作系统帮你布局内存。ENTRY(_start) 告诉 CPU 从哪里开始执行;.bss 段需要手动清零(因为没有 C 运行时帮你做)。

这正是 L99(链接脚本)和 L100(BSS 清零) 的核心内容。


三、前置汇编知识:ARMv8/AArch64 GAS 汇编详解

看到汇编不用怕——在这个阶段,能看懂就可以了。

以下是 Avatar OS 课程中使用的所有汇编指令的详细讲解,按类别组织。这份参考手册 + AI 助手,就是你攻克 Unit 5 的操作系统之旅的"左膀右臂"。

NOTE

课程基于 AArch64 执行态(64 位 ARM 架构)。不要与 AArch32(32 位 ARM 模式)混淆——两者的寄存器宽度、指令集和系统寄存器命名都不同。


0. 必须先掌握的核心概念

在看具体指令之前,请先理解下面这些概念。它们在整个汇编部分会反复出现,花 5 分钟读一遍,后面学指令会顺畅 10 倍


0.1 三种操作数:汇编的数据来源

一条汇编指令需要"对什么数据做什么操作"。汇编中有且仅有三种数据来源:

add x0, x1, #8 为例,三个操作数分别来自三种来源:

操作数来源说明速度
x0(目标)寄存器CPU 内部的存储,极快极快
x1(源1)寄存器同上极快
#8(源2)立即数常数直接编码在指令里最快
类型写法C 语言等价速度
立即数#42#0xFF常量 420xFF最快(在指令编码里)
寄存器值x0w1sp变量 int a极快(在 CPU 内部)
内存值[x0][sp, #8]指针解引用 *ptr较慢(需访问 RAM)

NOTE

立即数前缀 #:ARM 汇编中,立即数必须带 #mov x0, 42 是错的,必须是 mov x0, #42

间接寻址 []:方括号意为"把这个寄存器的值当成内存地址"。[x1] 不是访问 x1 本身,而是访问 x1 指向的那块内存。等价于 C 的 *ptr


0.2 指令格式:目标操作数永远在最前面

ARM GAS 汇编的指令格式固定为:

text
指令  目标操作数,  源操作数1,  源操作数2
asm
add x0, x1, x2    @ x0 = x1 + x2  (目标 x0,源 x1、x2)
ldr x0, [x1]      @ x0 = *x1       (目标 x0,源 [x1])
str x2, [x0]      @ *x0 = x2       (目标 [x0],源 x2)

永远记这条规则:第一个操作数是被写入的目标,后面的是只读的源。

WARNING

不要和 x86 的 AT&T 语法混淆! x86 AT&T 的 add %eax, %ebxebx += eax——源在前。ARM 正好相反:add x0, x1, x2x0 = x1 + x2——目标在前。如果你在网上看到不同的操作数顺序,先确认它针对的是 ARM 还是 x86。


0.3 寄存器一览

AArch64(ARM 64 位)有 31 个通用寄存器 + 若干特殊寄存器:

寄存器宽度用途
x0 – x3064 位通用寄存器,随便用
w0 – w3032 位x 寄存器的低 32 位视图,写入 w 时高 32 位自动清零
sp64 位Stack Pointer,栈指针,始终指向栈顶
lr(x30)64 位Link Register,bl 调用时自动存放返回地址
xzr / wzr64/32 位Zero Register,读永远返回 0,写丢弃(比 mov x0, #0 更高效)
pc64 位Program Counter,存放下条指令地址(不能直接读写,由跳转指令间接修改)

NOTE

x 与 w 的关系w0x0 的低 32 位视图,两者共享同一个物理寄存器。写入 w0 时,x0 的高 32 位自动清零。

寄存器前缀物理宽度读写行为
x0x3064 位完整读写 64 位
w0w3032 位读写低 32 位,写入时高 32 位自动清零

TIP

xzr 是神器:想给寄存器清零,直接 mov x0, xzr,不需要 mov x0, #0(省了一条编码立即数的空间)。


0.4 栈指针 sp 与函数调用

是内存中一块按"后进先出"规则使用的区域。sp 始终指向栈顶,栈向低地址方向增长:

地址方向内容说明
高地址旧数据已使用的栈空间
sp 初始位置栈从高地址开始
可用空间sp 指向当前栈顶
低地址(栈底)栈向低地址增长

栈用来保存:局部变量、返回地址、临时寄存器值。

IMPORTANT

ARM 没有 x86 那样的 push/pop 指令。所有栈操作由 stp/ldpStore/Load Pair)配合手动地址偏移完成。


0.5 标签、符号与 .globl

汇编中,label: 形式的文本叫标签(Label),代表某个内存地址。bl label 就是"跳转到 label 对应的地址"。

.globl(Global) 声明该标签对链接器可见。不加 .globl,标签只在当前 .S 文件内有效。

asm
.globl _start          @ 导出 _start,让链接器能找到入口
_start:                @ _start 标签,代表一个内存地址
    mov x0, #42

my_helper:             @ 没有 .globl,外部文件看不到这个标签
    ret

0.6 伪指令(Directives):给汇编器的指令

伪指令以 . 开头,不是 CPU 指令——它们是告诉汇编器"怎么布局代码和数据"的元信息。

伪指令作用示例
.globl / .global声明全局符号.globl _start
.section指定代码/数据段.section .text
.align N2^N 字节对齐.align 4 → 16 字节
.balign N按 N 字节对齐.balign 2048
.ascii定义字符串(无 \0.ascii "hello\n"
.macro / .endm定义宏.macro save_all_regs ... .endm
.space N保留 N 字节.space 65536
.equ定义符号常量.equ MY_CONST, 42
asm
.section .text          @ 以下内容放代码段
.globl _start           @ 导出入口供链接器使用
_start:
    // 代码写这里

.section .rodata        @ 以下内容放只读数据段
.align 4                @ 16 字节边界对齐
msg:
    .ascii "hello, OK\n"

TIP

.ascii vs .asciz.ascii 自动加 \0(NUL 终止符),.asciz 会。本课程用 .ascii 精确控制字符串长度——裸机环境没有 C 标准库替你处理字符串终止。


1. 数据移动指令

这是最核心、最高频的指令族,相当于 C 语言中的赋值运算符 = 和解引用 *ptr

1.1 立即数加载 → 寄存器

mov

全称 Move(移动)。

asm
mov  x0, #1          @ x0 = 1(加载立即数)
mov  x2, #10         @ x2 = 10
mov  sp, x0          @ sp = x0(寄存器到寄存器)
mov  x2, #0          @ x2 = 0

WARNING

mov 只能加载 16 位立即数(支持移位扩展)。这是因为 ARMv8 的指令编码中,立即数字段只有约 16 位宽。想要加载 64 位地址怎么办?往下看。

TIP

为什么指令编码只给 16 位? ARMv8 每条指令固定 32 位宽。扣掉操作码、寄存器编号等字段后,留给立即数的空间只有约 16 位。这是固定长度指令集的经典权衡——指令长度统一,但立即数空间有限。

movz + movk

全称 Move with Zero(清零移动)/ Move with Keep(保留移动)。

asm
@ 构建 64 位立即数:0xFFFF000000000000
movz x19, #0x0000              @ x19 = 0x0000,低16位清零
movk x19, #0xFFFF, lsl #48    @ x19[63:48] = 0xFFFF,保持其他位不变

这条指令组合在启动代码中用于构建内核高半区地址偏移。

指令含义行为
movzMove with Zero设目标位段,其余位填 0
movkMove with Keep设目标位段,其余位 保持不变

以构建 0xFFFF000000000000 为例,64 位寄存器 x19 的变化过程:

步骤指令x19[63:48]x19[47:16]x19[15:0]说明
初始???任意值
1movz x19, #0x0000000x0000全部清零,低 16 位设为目标值
2movk x19, #0xFFFF, lsl #480xFFFF0(保持)0x0000(保持)仅替换 bits[63:48],其余位不变

TIP

为什么不直接写 mov x0, #0xFFFF000000000000 因为 ARMv8 的 mov 指令编码无法容纳 64 位立即数。movz + movk 组合是构建大常量的标准方式,最多需要 4 条指令填满 64 位。

ldr x, =label

全称 Load Register(伪指令)。

asm
ldr x0, =_stack_top    @ x0 = _stack_top 的链接地址
ldr x0, =__bss_start   @ x0 = BSS 段起始地址
ldr x0, =exception_vector_base

IMPORTANT

这是伪指令(pseudo-instruction),不是真实的 CPU 指令。汇编器会在后台自动将其转换为 PC 相对加载或通过文字池(literal pool)访问。它加载的是编译时链接地址(VMA),而非运行时地址。

adr

全称 Address of label at PC-relative offset(PC 相对地址加载)。

asm
adr  x1, msg           @ x1 = msg 标签的当前运行时地址(±1MB 范围)
adr  x0, high_half     @ x0 = high_half 的地址

NOTE

adr真实指令,通过 PC + 有符号偏移 计算地址,范围 ±1MB。适合短距离地址加载——它返回的是运行时地址,而非链接时地址。

adr vs ldr = 核心对比
维度adrldr =
本质真实 CPU 指令伪指令(由汇编器展开)
范围±1MB(PC 相对)64 位全范围(字面池)
返回地址运行时刻 PC 相对地址链接时绝对地址(VMA)
代码密度1 条指令1 条指令 + 1 个字面池条目
适用场景附近标签的运行时地址任意位置的链接地址

TIP

什么时候用哪个? 在位置无关代码(PIC)和 Trampoline 中,必须用 adr 获取运行时地址,因为链接时的 VMA 和实际运行的物理地址不同。在静态链接的启动代码中,两者通常等价,ldr = 更直观。


1.2 内存读写

ldr / str

全称 Load Register(加载)/ Store Register(存储),64 位操作。

asm
ldr x0, [x1]          @ x0 = *(x1 指向的内存),加载 8 字节
str x2, [x0]          @ *(x0 指向的内存) = x2,存储 8 字节
后索引寻址
asm
str x2, [x0], #8      @ *(x0) = x2;  x0 += 8;   (写后地址递增)

这在 BSS 清零循环中使用:

asm
clear_bss:
    cmp x0, x1
    b.ge clear_bss_done
    str x2, [x0], #8      @ 写入 8 字节零,然后 x0 += 8(相当于 *ptr++ = 0
    b clear_bss
前索引寻址

在上下文切换中经常看到这种形式:

asm
stp x29, x30, [sp, #-16]!  @ sp -= 16; *(sp+0)=x29; *(sp+8)=x30

! 表示先修改再操作

  1. sp = sp - 16(栈向下生长,先分配空间)
  2. 写入 x29sp+0
  3. 写入 x30sp+8
ldrb / strb

全称 Load Register Byte(加载字节)/ Store Register Byte(存储字节)。

asm
strb w1, [x0]         @ *(x0 指向的内存) = w1 的低 8

UART 输出使用字节写入:

asm
movz x0, #0x0900, lsl #16   @ UART MMIO 基址
mov  w1, #'B'
strb w1, [x0]               @ 向 UART 数据寄存器写入 'B'

TIP

为什么用 w1 而不是 x1 w1x1 的低 32 位视图。字节写入只需要 8 位数据,用 w1 可以避免汇编器报类型不匹配。strb 会自动取 w1 的最低 8 位。

ldp / stp

全称 Load Pair(加载寄存器对)/ Store Pair(存储寄存器对)。

asm
stp x29, x30, [sp, #-16]!  @ 前索引推栈:sp-=16; *(sp)=x29; *(sp+8)=x30
ldp x29, x30, [sp], #16    @ 后索引弹栈:x29=*(sp); x30=*(sp+8); sp+=16

IMPORTANT

AArch64 不提供 push/pop 指令(这点和 x86 不同),所有栈操作都通过 stp/ldpStore/Load Pair)+ 手动偏移实现。


1.3 寄存器宽度约定

AArch64 的寄存器有"别名"——同一个物理寄存器可以用不同宽度来访问:

寄存器前缀宽度说明
x0-x3064 位完整 64 位寄存器
w0-w3032 位对应 x 寄存器的低 32 位,写入 w 时高 32 位自动清零
xzr64 位零寄存器(读永远返回 0,写丢弃)
wzr32 位32 位零寄存器
asm
mov x0, xzr     @ x0 = 064 位清零的标准方式)
mov w0, wzr     @ w0 = 032 位清零,同时清零 x0 高 32 位)

TIP

神器 xzr/wzr:这是 ARM 架构的精妙设计——不需要浪费一条指令加载 #0,直接"读"零寄存器即可。


2. 系统寄存器访问指令

在 ARMv8 中,CPU 的控制和状态寄存器(如页表基址、异常配置)不能通过普通 ldr/str 访问——它们不在统一的内存地址空间中,必须用专用指令。

TIP

为什么系统寄存器不能当普通内存读写? 系统寄存器(System Registers)位于 CPU 内部的一个独立地址空间,与普通内存地址空间完全隔离。这种设计有三重好处:

  1. 性能:访问走专用数据通路,不需经过 Cache/TLB/总线
  2. 安全:访问受异常级别(EL)控制,普通 ldr/str 无法绕开权限检查
  3. 原子性msr 可以原子地修改控制位,避免竞态

2.1 mrs

全称 Move from System Register(读系统寄存器)。

asm
mrs x0, sctlr_el1       @ x0 = SCTLR_EL1(系统控制寄存器)
mrs x0, esr_el1         @ x0 = ESR_EL1(异常综合寄存器)
mrs x0, far_el1         @ x0 = FAR_EL1(故障地址寄存器)
mrs x0, currentel       @ x0 = CurrentEL(当前异常级别)
mrs x0, cntfrq_el0      @ x0 = 系统定时器频率
mrs x0, cntpct_el0      @ x0 = 物理计数器值

2.2 msr

全称 Move to System Register(写系统寄存器)。

asm
msr ttbr0_el1, x0       @ TTBR0_EL1 = x0(用户页表基址)
msr ttbr1_el1, x0       @ TTBR1_EL1 = x0(内核页表基址)
msr vbar_el1, x0        @ VBAR_EL1 = x0(异常向量基址)
msr spsr_el1, x0        @ SPSR_EL1 = x0(保存的处理器状态)
msr elr_el1, x0         @ ELR_EL1 = x0(异常链接寄存器)
msr sp_el0, x0          @ SP_EL0 = x0(EL0 栈指针)
msr cntv_tval_el0, x0   @ CNTV_TVAL_EL0 = x0(定时器倒计时值)
msr cnthctl_el2, x0     @ CNTHCTL_EL2(Hypervisor 层定时器控制)

NOTE

系统寄存器命名遵循 name_ELx 约定,其中 ELx 指该寄存器归属的异常级别

  • _EL0:用户态可用
  • _EL1:内核态(OS 内核运行在此级别)
  • _EL2:Hypervisor 层
  • _EL3:Secure Monitor 层

本课程主要操作 EL1 寄存器。

2.3 msr daifset / daifclr — 中断屏蔽控制

asm
msr daifset, #0xf       @ 屏蔽 D / A / I / F 四种异常
msr daifclr, #0xf       @ 打开所有中断
含义全称
DDebug exceptions调试异常
AAsynchronous (SError)异步错误(如 ECC 错误)
IIRQ普通中断请求
FFIQ快速中断请求

TIP

#0xf = 二进制 0b1111,每一位对应一种异常类型。写入 0xf 屏蔽全部四种,写入 0x0 打开全部。


3. 原子操作与自旋锁

在多核系统中,多个 CPU 核心可能同时访问同一块内存。原子操作保证"读-改-写"操作不会被其他核心打断。

TIP

为什么需要原子操作? 考虑两个核心同时尝试获取锁:

Core 0: lock 看到 0(空闲)
Core 1: lock 看到 0(空闲)
Core 0: lock = 1
Core 1: lock = 1 两个核心都认为拿到了锁!

这就是竞态条件(race condition)ldaxr/stxr 通过硬件级的"独占监视"机制解决了这个问题。

3.1 ldaxr

全称 Load-Acquire eXclusive Register(获取独占加载)。

asm
ldaxr w1, [x0]          @ w1 = *(x0 指向的内存);  标记该地址为「独占监视」
  • Acquire 语义(后续读写不能重排到此指令之前)
  • 设置硬件对 [x0] 所在 cache line 的「独占监视」标志

3.2 stxr

全称 Store eXclusive Register(独占存储)。

asm
stxr w2, w3, [x0]      @ if (独占监视有效) *(x0) = w3; w2 = 0;
                        @ else w2 = 1(失败); 不写入
  • Release 语义(之前的读写不能重排到此指令之后)
  • 如果 ldaxrstxr 之间该地址被其他核修改,独占监视自动失效,stxr 不写入

IMPORTANT

ldaxr + stxr 不是 CAS! 它和 x86 的 CMPXCHG 不同:

  • CMPXCHG 比较值是否匹配,匹配才写
  • stxr 不比较值——它只检查独占监视是否还有效
  • ARM 的原子操作依赖"试写"而非"比较",失败后必须重新 ldaxr 读取

3.3 stlr

全称 Store-Release Register(释放存储)。

asm
stlr w0, [x1]           @ *(x1) = w0;  Release 语义(之前操作不可后延)

3.4 自旋锁原理

自旋锁的核心思想:用 ldaxr + stxr 实现一条原子的"读-检查-写"链路。

加锁流程

  1. ldaxr 读取锁值 → 如果是 0(空闲),继续;否则自旋等待
  2. stxr 尝试写入 1 → 如果独占监视有效(没被抢),成功;否则回到步骤 1 重试

解锁流程

  • stlr 写入 0 → Release 语义确保锁释放前的所有操作对其他核可见

NOTE

具体的 C 内联汇编实现在 L103-L104 实验中展开。这里只需要理解:自旋锁 = ldaxr(读 + 声明独占)+ 判断 + stxr(试写,失败就重试) + stlr(写 0 释放)。


4. 内存屏障指令

ARMv8 采用弱内存序模型(Weakly-Ordered Memory Model):CPU 可以按不同于程序顺序的实际顺序执行内存访问。这对单核程序透明,但多核场景下可能出问题。

TIP

弱内存序的直观理解:假设 Core 0 执行:

data = 42;      // Store A
flag = 1;       // Store B

Core 1 执行:

while (flag == 0);    // Load B
print(data);          // Load A

在弱内存序下,Core 1 可能在 flag == 1 后读到 data == 0(旧值)——因为 Store B 先于 Store A 到达 Core 1 的缓存!内存屏障用来防止这类重排。

4.1 dmb

全称 Data Memory Barrier(数据内存屏障)。

asm
dmb sy       @ 全系统数据屏障 — 前后内存操作不可交叉
dmb ish      @ 可共享域内屏障 — 对同一 Inner Shareable 域可见
dmb ishst    @ 仅 Store 屏障
dmb ishld    @ 仅 Load 屏障

4.2 dsb

全称 Data Synchronization Barrier(数据同步屏障)。

asm
dsb sy       @ 严格同步 — 之前所有内存访问完成后才开始后面指令
dsb ishst    @ Store 同步(写 MMU 寄存器前必须用)

4.3 isb

全称 Instruction Synchronization Barrier(指令同步屏障)。

asm
isb          @ 冲刷流水线 — 之后的指令重新取指

NOTE

dmb vs dsb vs isb 的区别,直观理解:

  • dmb:禁止屏障前后的内存操作交叉。相当于一个"请排队"的提示。
  • dsb:屏障前的所有内存操作必须完成,才能执行屏障后的指令。相当于"等前面全部做完"。
  • isb:冲刷 CPU 流水线,从新上下文重新取指。相当于"之前的状态立刻生效,之后的指令重新来过"。

4.4 课程中的应用场景

场景需要的屏障原因
修改页表dsb ishst + isb页表写入必须对其他核可见,且 TLB 需要看到新条目
启用/禁用 MMUdsb ishst + isbMMU 开启后所有地址翻译立即生效
修改异常向量基址isb异常处理路径必须立即使用新向量表
跨核同步数据结构dmb ish保证共享数据的写入顺序

5. 分支与控制流指令

分支指令修改 PC(程序计数器),控制程序的执行路径。

5.1 无条件跳转

ARM 的分支指令由一个"根指令" b 加上不同后缀组合而成。理解后缀的含义,比死记硬背所有变体更高效:

指令全称行为关键变化
bBranch直接跳转到 label(±128MB)最基础:只改 PC
blBranch with Link跳转 + 自动将返回地址写入 x30(lr)加了 l:多了"记下回家的路"
brBranch to Register跳转到寄存器中的地址r 替换 label:目标地址来自寄存器而非固定标签
blrBranch with Link to Register跳转到寄存器中的地址 + 自动保存返回地址到 x30l + r 叠加:既有动态目标,又记返回地址
retReturn等价于 br lr,从函数返回bbr lr 的语义封装

记忆口诀

  • l = Link(链接):加 l 意味着"自动把返回地址写入 x30",这样被调函数执行完可以 ret 回来
  • r = Register(寄存器):加 r 意味着"跳转目标来自寄存器",而不是硬编码的 label,适用于函数指针、虚表分发等动态场景
  • blr = bl + br:既有 link(保存返回地址),又从寄存器取目标——是 C 中函数指针调用的底层实现
asm
b   label         @ 无条件跳转(不保存返回地址)
bl  function      @ 函数调用(x30 = 下一条指令的地址)
blr x0            @ 通过函数指针调用(x30 = 返回地址,目标 = x0)
br  x0            @ 跳转到 x0,不保存返回地址(常用于 switch-case 跳转表)
ret               @ 等价于 br lr,函数返回

NOTE

b vs bl 的本质区别b 是"一去不回"的跳转(如循环末尾跳回循环头),bl 是"去了还要回来"的调用(如函数调用)。bl 在跳转前悄悄把下一条指令的地址写入 x30(Link Register),配合 ret(=br lr)就构成了完整的函数调用/返回框架。这相当于 x86 的 CALL + RET,但更透明:你可以直接看到 x30 就是返回地址。

5.2 条件跳转

asm
cmp  x0, x1       @ **C**o**m**p**are:比较 x0 和 x1,设置条件标志(NZCV)
b.ge label        @ Branch if Greater or Equal(有符号)
b.lt label        @ Branch if Less Than
b.eq label        @ Branch if EQual
b.ne label        @ Branch if Not Equal
b.gt label        @ Branch if Greater Than
b.le label        @ Branch if Less or Equal

BSS 清零中的条件跳转:

asm
    cmp x0, x1
    b.ge clear_bss_done    @ x0 >= x1 时结束循环
    str x2, [x0], #8
    b clear_bss

TIP

条件后缀速记:eq = equal, ne = not equal, ge = greater or equal, lt = less than, gt = greater than, le = less or equal。注意 ARM 的条件跳转区分有符号比较无符号比较(无符号用 hs/lo/hi/ls)。

5.3 cbnz / cbz

全称 Compare and Branch if Not Zero(非零则跳转)/ if Zero(为零则跳转)。

asm
cbnz x0, label    @ 若 x0 != 0,跳转到 label
cbz  x0, label    @ 若 x0 == 0,跳转到 label

无需先 cmp,直接判断寄存器是否为 0——这比 cmp + b.eq 少一条指令。

5.4 wfe / wfi

全称 Wait For Event(等待事件)/ Wait For Interrupt(等待中断),用于低功耗等待。

asm
wfe   @ 低功耗等待,直到被 SEV 唤醒
wfi   @ 低功耗等待,直到中断唤醒

hang:
    wfe
    b hang          @ 原地等待,不消耗 CPU(进入低功耗态)

NOTE

wfe vs wfi 的区别wfe(Wait For Event)可用 SEV 指令唤醒,常用于多核间的轻量级同步;wfi(Wait For Interrupt)只能被硬件中断唤醒,用于真正的空闲等待。


6. 异常处理机制

异常是 CPU 处理"意外事件"的机制——同步异常(如系统调用 svc、缺页)和异步中断(如定时器、外设)都通过异常向量表分发。

6.1 svc

全称 Supervisor Call(系统调用)。

asm
svc #0              @ 触发系统调用,从 EL0 切换到 EL1
  • 从 EL0 用户态切换到 EL1 内核态
  • #0 是 24 位立即数参数,可通过 ESR_EL1 读取
  • 系统调用约定x8 = 系统调用号,x0-x5 = 参数,x0 = 返回值

用户程序示例:

asm
mov  x8, #64      @ syscall number: SYS_WRITE
svc  #0            @ 触发系统调用

6.2 eret

全称 Exception Return(异常返回)。

asm
eret               @ PC = ELR_EL1, PSTATE = SPSR_EL1
  • ELR_EL1 加载到 PC(返回到中断前的下一条指令)
  • SPSR_EL1 恢复 CPU 状态(中断前的特权级等)
  • 用于从内核态返回用户态或从异常处理返回

NOTE

eret 做了两件事:恢复 PC(从哪里中断的)和恢复 CPU 状态(中断前是什么特权级)。这两个信息在异常发生时分别被硬件自动保存到 ELR_EL1SPSR_EL1

6.3 异常向量表原理

ARMv8 异常向量表有 16 个入口(4 组 × 4 种异常类型),共 2KB16 × 0x80),由 VBAR_EL1 寄存器指向基址。

偏移组 0:同 EL,SP0组 1:同 EL,SPx组 2:低 EL,AArch64组 3:低 EL,AArch32
+0x000SyncSyncSyncSync
+0x080IRQIRQIRQIRQ
+0x100FIQFIQFIQFIQ
+0x180SErrorSErrorSErrorSError

四组分别对应

  • 组 0:同 EL、使用 SP_EL0(栈指针 0)
  • 组 1:同 EL、使用 SP_ELx(当前级别的栈指针)
  • 组 2:从低 EL 进入,AArch64 模式(本课程最常用)
  • 组 3:从低 EL 进入,AArch32 模式

每组 4 种异常类型:Sync(同步异常)、IRQ(普通中断)、FIQ(快速中断)、SError(系统错误)

每个入口间隔 0x80 字节(128 字节),足够放一条分支指令跳转到实际的 C 处理函数。

IMPORTANT

设置异常向量表的关键步骤:

  1. 定义 2KB 对齐的向量表(.balign 0x800
  2. 在每个入口填入跳转指令(b handler
  3. msr vbar_el1, x0 设置 VBAR_EL1
  4. 调用 isb 确保新向量表立即生效

具体的向量表代码实现和异常现场保存/恢复宏在 L109-L110 实验中展开


7. MMU / 页表相关操作

MMU(Memory Management Unit)将虚拟地址翻译为物理地址,是实现虚拟内存、进程隔离和保护的基础。

7.1 页表项结构(原理)

每条页表项是 64 位描述符,以 Block Descriptor 为例:

位域名称含义
[63]保留
[54]UXNEL0 不可执行
[53:48]保留
[47:30]输出物理地址block descriptor 输出物理地址 / table descriptor 下级页表基址
[29:11]保留
[10]AFAccess Flag:硬件访问后自动置 1
[9:7]保留
[6:4]AttrIndx内存属性索引(普通内存 / 设备内存 / 不可缓存等)
[3:2]保留
[1:0]描述符类型0b01 = block, 0b11 = table

7.2 tlbi

全称 TLB Invalidate(TLB 刷新)。

asm
tlbi vmalle1is        @ 清空 EL1 所有 TLB 条目(跨核广播)

页表修改后必须刷新 TLB,否则 MMU 可能继续使用旧的地址映射。

7.3 MMU 启用原理

MMU 的启用通过设置 SCTLR_EL1 寄存器的 bit 0 来完成:

1. 配置 TCR_EL1(页表粒度、地址宽度)
2. 设置 TTBR0_EL1 / TTBR1_EL1(页表基址)
3. tlbi + dsb + isb(刷新 TLB、同步)
4. 设置 SCTLR_EL1[0] = 1(启用 MMU)
5. isb(立即同步——后续所有取指经过 MMU 翻译)

WARNING

启用 MMU 后,地址立即改变! 如果 SCTLR_EL1[0] 从 0 变为 1,下一条指令的 PC 就会经过 MMU 翻译。如果你的页表没有为当前代码段建立正确映射,CPU 会立刻触发 Translation Fault。这就是为什么需要 isb——确保流水线中的指令使用新的 MMU 状态。

具体的 MMU 启用代码在 L107 实验中展开


8. 上下文切换

上下文切换是操作系统任务调度的核心——保存当前任务的所有寄存器状态,恢复下一个任务的状态。

核心原理

上下文切换只保存 callee-saved 寄存器(x19-x30)

  • x0-x18 是 caller-saved(调用者保存),编译器在调用函数时自动推栈
  • x19-x30 是 callee-saved(被调用者保存),必须手动保存和恢复

关键设计:sp(栈指针)保存在 TCB 结构体偏移 0 处,即 &task->sp == &task。因此 TCB 指针直接指向栈指针的存储位置,无需额外偏移计算。

切换流程概要

1. 将当前任务的 x19-x30 压入其栈中
2. 将当前 sp 存入 TCB
3. 加载新任务的 sp
4. 从新任务栈中恢复 x19-x30
5. ret 自然返回到新任务的上下文

NOTE

具体的上下文切换汇编代码在 L114 实验中展开


9. GCC 内联汇编(Inline Assembly)

GCC 内联汇编让你在 C 代码中嵌入汇编指令,是内核开发中访问 CPU 特性不可或缺的工具。

基本格式

c
asm volatile(
    "汇编指令模板"
    : 输出操作数列表    // output operands
    : 输入操作数列表    // input operands
    : 破坏列表          // clobber list
);

TIP

每一部分的含义

  • 模板:汇编指令字符串,%0%1 等是操作数的占位符
  • 输出操作数:C 变量 ← 汇编结果,格式 "约束"(变量名)
  • 输入操作数:汇编 ← C 变量值,格式 "约束"(表达式)
  • 破坏列表:告诉编译器哪些寄存器/内存被修改,防止编译器误优化

约束符速查

约束含义示例
r任意通用寄存器"r"(lock)
+r可读写的寄存器"+r"(tmp)
=r只写的寄存器"=r"(val)
%w0操作数的 W(32 位)视图%w0
%0操作数的 X(64 位)视图%0
memory声明内存会被修改(clobber 专用): "memory"

IMPORTANT

"+r" vs "=r" vs "r" 的细微区别

  • "=" 表示纯输出(只写),编译器不会把旧值传给你
  • "+" 表示读写,编译器会把变量值加载到寄存器,执行完后写回
  • 不加前缀表示纯输入(只读)

实战示例

c
// 读取系统寄存器
static inline uint64_t read_sctlr_el1(void) {
    uint64_t val;
    asm volatile("mrs %0, sctlr_el1" : "=r"(val));
    return val;
}

// 内存同步屏障
static inline void dsb_sy(void) {
    asm volatile("dsb sy" ::: "memory");
}

// 读取故障地址
static inline uintptr_t read_far(void) {
    uintptr_t val;
    asm volatile("mrs %0, far_el1" : "=r"(val));
    return val;
}

WARNING

"memory" clobber 很重要!它告诉编译器"汇编代码可能读写你不认识的任意内存位置"。不加 "memory",编译器可能会把内存访问重排到内联汇编之前或之后,在多核/中断场景下导致难以排查的 bug。


10. 汇编指令速查卡

类别指令
数据移动mov, movz, movk, ldr, str
字节操作ldrb, strb, ldrsw
寄存器对stp, ldp
地址生成adr, ldr =
系统寄存器mrs(读), msr(写)
中断控制msr daifset / daifclr
异常控制svc, eret
原子操作ldaxr, stxr, stlr
内存屏障dmb, dsb, isb
分支b, bl, blr, br, ret, cbnz, cbz
比较cmp, b.eq/ne/ge/lt/gt/le
等待wfe, wfi
TLB 管理tlbi vmalle1is

这套汇编体系是课程的核心底盘。建议从数据移动指令开始理解,然后逐步深入系统寄存器原子操作异常处理,这些会在课程各阶段反复使用并加深理解。


参考链接

Released under the MIT License.