📺 B站 嵌入式孙老师:博主个人介绍
📘 博主书籍-京东购买链接:Yocto项目实战教程
📘 加博主微信,进技术交流群:jerrydev
ARM架构基础知识点总结: 从零建立认知ARM 底层架构到底在干什么
很多人一提到"ARM 架构"就会想到一堆缩写:AArch64、EL0-EL3、TLB、MMU、TTBR……
名词一多,"底层"这两个字就自带了神秘感。

但如果把这些名词都先放到一边,回到最朴素的问题:
CPU 上电之后,到底在干什么?
答案其实很简单:不断地从内存里取一条指令,看懂它,执行它,然后取下一条。
ARM 架构的所有"高级概念"——寄存器、指令集、异常等级、MMU——最终都是在回答
"CPU 怎么把这件事做得更快、更安全、更灵活"这一个问题的不同侧面。
这篇文章的目标不是讲全 ARM 手册,而是先把这张"认知地图"画出来:
搞清楚 CPU 执行代码的基本模型、寄存器怎么用、指令集长什么样、
特权级为什么存在、内存体系为什么分层、程序从上电到跑起来经过了哪几层。
每一节都配代码和图,读完这篇之后再去看 TRM(Technical Reference Manual)
或者更细节的 MMU/异常处理文章,就不会觉得那些名词凭空冒出来。
后面涉及到 MMU 页表翻译的细节,本文只点到为止,
完整拆解放在同仓库的 12_内存管理单元_MMU/ 系列笔记里,感兴趣可以对照着看。
一、ARM 是什么:先纠正一个误区
第一个要去掉的"神秘感":ARM 不是一颗芯片,是一套指令集架构(ISA)标准。
ARM Holdings(公司)
-> 设计并授权 "ARM 架构"(一套指令集 + 编程模型规范)
-> 高通、苹果、联发科、瑞芯微... 各自设计自己的 CPU 核心
-> 只要遵守这套规范,就能跑同一套 ARM 二进制指令
这和 x86 的"Intel/AMD 自己设计芯片、自己实现指令集"是不同的商业模式,
但对写代码/看汇编的人来说,真正要关心的是架构版本:
ARMv7-A 及更早 -> 只有 32 位模式(AArch32)
ARMv8-A 开始 -> 新增 64 位模式(AArch64),同时兼容 AArch32
ARMv9-A -> 在 v8 基础上增加安全/向量增强特性
本文以主流的 AArch64(ARMv8-A 64 位模式) 为例展开,
这也是目前手机、服务器、嵌入式高端芯片的主流形态。
RISC 设计哲学:少即是多
ARM 是典型的 RISC(精简指令集),对比 x86 这种 CISC(复杂指令集),
核心差异可以用一句话讲清楚:
CISC(x86): 一条指令可以做很多事,指令长度不固定
例:ADD [rax], 5 // 直接在内存地址上加 5,一条指令内存读+改+写都做了
RISC(ARM): 一条指令只做一件简单的事,指令长度固定(AArch64 下每条 4 字节)
例:要实现同样的效果,需要拆成多条:
LDR X1, [X0] // 从内存读到寄存器
ADD X1, X1, #5 // 在寄存器里加 5
STR X1, [X0] // 写回内存
看起来 RISC “更啰嗦”,但换来的是:每条指令执行时间可预测、译码简单、
更容易做流水线和乱序执行——这一点会在下一节的流水线模型里看到具体好处。
二、CPU 怎么执行代码:取指-译码-执行
去掉神秘感的第二步:CPU 执行代码本质上是一个简单的循环。
┌───────────────────────────────┐
│ │
v │
┌───────────────┐ │
│ 1. 取指 (IF) │ 按 PC 指向的地址取一条指令 │
└───────┬────────┘ │
v │
┌───────────────┐ │
│ 2. 译码 (ID) │ 看懂这是加法还是访存还是跳转│
└───────┬────────┘ │
v │
┌───────────────┐ │
│ 3. 执行 (EX) │ ALU 做运算 / 算出访存地址 │
└───────┬────────┘ │
v │
┌───────────────┐ │
│ 4. 访存 (MEM) │ 需要的话读写内存 │
└───────┬────────┘ │
v │
┌───────────────┐ │
│ 5. 写回 (WB) │ 结果写回寄存器 │
└───────┬────────┘ │
└────────── PC 自增,回到第 1 步 ──┘
真实的 ARM 核心会把这 5 步做成流水线(pipeline):
第 1 条指令还在"执行"阶段时,第 2 条已经在"译码",第 3 条已经开始"取指",
这样吞吐率就不再是"一条指令 5 个周期",而是接近"每个周期完成一条":
周期: 1 2 3 4 5 6 7
指令1(ADD): IF ID EX MEM WB
指令2(SUB): IF ID EX MEM WB
指令3(LDR): IF ID EX MEM WB
指令4(STR): IF ID EX MEM WB
RISC 指令"定长、简单"的设计,正是为了让这条流水线更容易搭建:
每条指令译码规则一样,硬件不需要先猜"这条指令到底多长"。
三、寄存器模型:CPU 用什么在"思考"
CPU 执行指令时,真正参与运算的不是内存,而是一小撮寄存器——
可以理解成 CPU 内部速度最快、数量最少的"随身工作台"。
1. 通用寄存器
X0 ~ X30 64 位通用寄存器(共 31 个)
W0 ~ W30 对应 X0~X30 的低 32 位视图(同一个寄存器,两种"观察方式")
XZR / WZR 零寄存器:读它永远得到 0,写它相当于丢弃结果
SP 栈指针(Stack Pointer)
PC 程序计数器(不能被普通指令当作目标寄存器直接写)
其中两个寄存器有约定俗成的特殊角色:
X29 (FP) 帧指针,标记当前函数栈帧的起始位置
X30 (LR) 链接寄存器,BL 调用函数时自动把"返回地址"存进这里
2. 状态寄存器 PSTATE / NZCV
除了通用寄存器,CPU 还维护一组"状态标志位",记录上一次运算的特征:
N (Negative) 结果是负数
Z (Zero) 结果是 0
C (Carry) 产生了进位/借位
V (Overflow) 有符号运算溢出
条件跳转指令(比如 B.LE、B.GT)本质上就是在读这几个标志位做判断,
下一节的代码示例会直接看到。
3. 函数调用怎么用寄存器:从一段 C 代码说起
int add(int a, int b) {
return a + b;
}
用 aarch64-linux-gnu-gcc -O0 -S 编译出的汇编大致是:
add:
sub sp, sp, #16 ; 在栈上开一块局部变量空间
str w0, [sp, #12] ; 参数 a(第1个参数用 w0 传入)先存到栈上
str w1, [sp, #8] ; 参数 b(第2个参数用 w1 传入)也存到栈上
ldr w0, [sp, #12] ; 再读回来(-O0 不优化,比较啰嗦)
ldr w1, [sp, #8]
add w0, w0, w1 ; 真正的加法,结果放回 w0
add sp, sp, #16 ; 归还栈空间
ret ; 跳回 X30(LR) 记录的返回地址
加 -O2 优化后,编译器会发现完全不需要落栈,直接:
add:
add w0, w0, w1
ret
这里体现了 AArch64 的调用约定(AAPCS64)里最关键的两条规则:
参数传递: 前 8 个整数/指针参数依次放进 X0~X7
返回值: 放进 X0(128 位及以下的返回值)
这也是为什么很多人说"看汇编先看 X0"——函数的输入输出基本都压在这个寄存器上。
四、指令集:CPU 能听懂的"语言"
ARM 指令大致分三类,理解了这三类,看懂大部分汇编就没问题了。
1. 数据处理指令(只碰寄存器)
ADD X0, X1, X2 ; X0 = X1 + X2
SUB X0, X1, #1 ; X0 = X1 - 1
AND X0, X1, X2 ; 按位与
MOV X0, X1 ; 寄存器搬运
CMP X0, X1 ; 本质是 X0 - X1,只更新 NZCV,不保存结果
2. 访存指令(唯一能碰内存的指令)
LDR X0, [X1] ; 把 X1 指向的内存读到 X0
STR X0, [X1] ; 把 X0 写到 X1 指向的内存
LDR X0, [X1, #8] ; 基址+偏移寻址
LDP X0, X1, [SP] ; 一次读两个寄存器(Load Pair),函数序言/尾声常用
这是 ARM 区别于 x86 的关键设计:ARM 是 Load-Store 架构。
x86(寄存器-内存架构):
ADD [内存地址], 寄存器 // 数据处理指令可以直接操作内存
ARM(Load-Store 架构):
数据处理指令(ADD/SUB/AND...) 只能操作寄存器
唯一碰内存的指令 只有 LDR / STR 这一类
CPU <---LDR---> 寄存器 <---运算---> 寄存器 <---STR---> 内存
好处:运算指令的行为永远是"纯寄存器操作",不会因为操作数在内存里而
产生不确定的执行时间,这也是流水线能做得又深又快的原因之一。
3. 控制流指令(跳转)
B label ; 无条件跳转
BL func ; 跳转并把返回地址存进 X30(Branch with Link,用于函数调用)
RET ; 跳回 X30 记录的地址(用于函数返回)
CBZ X0, label ; X0 == 0 就跳(Compare and Branch on Zero)
B.LE label ; 根据上条 CMP 留下的 NZCV 标志位,"小于等于"就跳
用一段带分支的 C 代码看条件跳转怎么用:
int max(int a, int b) {
if (a > b) return a;
return b;
}
max:
cmp w0, w1 ; 计算 a - b,结果不保留,只更新 NZCV
b.le .L2 ; 如果 a <= b,跳到 .L2
ret ; 否则直接返回,此时 w0 里已经是 a
.L2:
mov w0, w1 ; a <= b,把 b 挪进返回值寄存器
ret
再看一个循环,体会"跳转指令 + 标志位"是怎么拼出 for 循环的:
int sum(int n) {
int s = 0;
for (int i = 0; i < n; i++) s += i;
return s;
}
sum:
mov w1, #0 ; s = 0
mov w2, #0 ; i = 0
.L_loop:
cmp w2, w0 ; 比较 i 和 n
b.ge .L_end ; i >= n 就跳出循环
add w1, w1, w2 ; s += i
add w2, w2, #1 ; i++
b .L_loop ; 回到循环开头
.L_end:
mov w0, w1 ; 返回值 = s
ret
到这里可以发现:C 语言里的 if、for、while,落到汇编层面全部都是
"CMP 更新标志位 + 条件跳转"的组合,没有任何魔法。
五、特权级:谁有权力做什么
一台跑着完整操作系统的机器,代码分成好几个"信任等级":
应用程序、内核、Hypervisor、安全监控程序,权限依次升高。
ARMv8 用 Exception Level(异常等级,EL0~EL3) 描述这件事:
┌─────────────────────────────────────────┐
│ EL3 Secure Monitor(安全监控固件) │ 权限最高
├─────────────────────────────────────────┤
│ EL2 Hypervisor(虚拟机管理器) │
├─────────────────────────────────────────┤
│ EL1 Kernel(操作系统内核) │
├─────────────────────────────────────────┤
│ EL0 Application(普通应用程序) │ 权限最低,日常写的代码都跑在这
└─────────────────────────────────────────┘
日常写的 C/C++ 应用代码运行在 EL0,没有权限直接操作硬件、修改页表。
想让内核帮忙做点"特权操作"(比如 write()、open() 这些系统调用),
应用程序要通过一条专门的指令主动"举手":
SVC #0 ; Supervisor Call,从 EL0 陷入 EL1
一次系统调用的完整流程:
EL0 应用代码
mov x8, #64 ; 系统调用号:64 = write(AArch64 Linux)
mov x0, #1 ; 参数1: fd = 1 (stdout)
ldr x1, =msg ; 参数2: 缓冲区地址
mov x2, #13 ; 参数3: 长度
svc #0 ; 主动触发异常,权限从 EL0 -> EL1
│
v
硬件自动完成:
- 保存当前 PC 到 ELR_EL1(异常返回地址)
- 保存当前 PSTATE 到 SPSR_EL1
- 跳到 EL1 的异常向量表(vector table)里 "SVC" 对应的入口
│
v
EL1 内核代码
- 读 X8 拿到系统调用号,读 X0~X2 拿到参数
- 执行真正的 write 逻辑(这里才有权限碰硬件/驱动)
- 把返回值放进 X0
- 执行 ERET 指令
│
v
硬件自动完成:
- 从 ELR_EL1 恢复 PC,从 SPSR_EL1 恢复 PSTATE
- 权限从 EL1 -> EL0,回到应用代码 svc 指令的下一条
理解这个流程之后,"用户态"和"内核态"就不再是抽象概念,
而是"CPU 当前处于哪个 EL,以及能不能执行某些指令/访问某些内存"这么具体。
关于 EL2/EL3 更细节的两阶段地址翻译、安全世界隔离,
本文不展开,留到虚拟化/安全相关的专题文章里讲。
六、内存体系一览:为什么会有 Cache、为什么会有 MMU
CPU 访问内存不是"寄存器之后直接连着一大块 RAM",中间隔着好几层,
一层比一层慢、但一层比一层大:
速度快 ↑ 容量小 速度慢 ↓ 容量大
┌──────────────┐
│ 寄存器 │ 几十个,接近 CPU 主频,容量按字节算
├──────────────┤
│ L1 Cache │ 几十 KB,几个周期就能命中
├──────────────┤
│ L2 Cache │ 几百 KB ~ 几 MB
├──────────────┤
│ L3 Cache │ 几 MB ~ 几十 MB(多核共享)
├──────────────┤
│ DRAM(内存) │ 几 GB ~ 上百 GB,慢上百倍
├──────────────┤
│ Flash/磁盘 │ 慢几个数量级,但断电不丢数据
└──────────────┘
Cache 解决的是"访存太慢"的问题:把最近/即将用到的数据放到离 CPU 更近的地方。
而 MMU(内存管理单元) 解决的是另一个问题:每个进程看到的地址,
和真实的物理内存地址是两回事。应用程序里看到的指针(虚拟地址 VA),
要经过 MMU 按照页表翻译成真正的物理地址(PA),这样才能做到:
- 每个进程都以为自己独占一整块地址空间,互相看不见对方的内存
- 同一段物理内存可以被多个进程共享(比如动态库)
- 内存不够时可以把不常用的页换到磁盘上(swap),对进程透明
这一整套"虚拟地址怎么变成物理地址"的机制(TLB、TTBR0/TTBR1、多级页表、
访问权限位……)内容很多,本文只点出"为什么需要它",
完整拆解在同仓库 12_内存管理单元_MMU/ 系列笔记里,建议接着往下看。
七、从上电到跑起来:你的代码经过了哪几层
最后串一下整体链路,回答"这一切到底是怎么开始的":
1. 上电
-> CPU 从固定地址开始执行,运行芯片内置的 BootROM
(只做最基础的硬件检测,决定从哪个存储介质启动)
2. BootROM
-> 加载一个更完整一点的引导程序(如 SPL / Bootloader)到内存
负责初始化 DDR 等复杂外设
3. Bootloader(如 U-Boot)
-> 完成硬件初始化,加载操作系统内核到内存,跳转过去
4. Kernel(如 Linux)
-> 建立页表,开启 MMU(对应第六节),
建立异常向量表(对应第五节),
最终启动第一个用户态进程
5. 应用程序(你写的 C/C++ 代码)
-> 跑在 EL0,需要特权操作时用 SVC 陷入内核(对应第五节)
而"你写的代码"本身,从源码到能被 CPU 执行,也经过了明确的几步,
并不是一步"编译"就完事:
C 源码 (add.c)
-> 编译器 (gcc -S) 生成汇编 (add.s),也就是第三、四节看到的那些指令
-> 汇编器 (as) 生成机器码 (add.o),指令变成真正的二进制编码
-> 链接器 (ld) 把多个 .o 和库拼成一个可执行文件
-> 加载到内存,PC 指向入口地址,CPU 按第二节的取指-译码-执行循环开始跑
至此,从"CPU 是什么"到"一段 C 代码怎么变成 CPU 真正执行的指令",
这条链路里的每一环都有了具体的画面,不再是几个抽象名词。
总结
回顾一下这篇文章建立的认知框架:
CPU 执行模型 取指-译码-执行-访存-写回,流水线让它更快
寄存器模型 X0~X30 是工作台,X0~X7 传参、X0 返回值、X30 是返回地址
指令集 数据处理只碰寄存器,LDR/STR 才碰内存(Load-Store 架构)
特权级 EL0~EL3 权限递增,SVC 是应用向内核"举手"的方式
内存体系 Cache 解决"慢",MMU 解决"地址不是真的地址"
启动链路 BootROM -> Bootloader -> Kernel -> App,一层交接一层
有了这张地图,再去看 ARM Architecture Reference Manual 或者更细节的
MMU、异常处理、Cache 一致性、GIC 中断这些专题,就是在这张地图上
往具体的某个格子里"填内容",而不是从零开始面对一堆陌生名词。
下一篇会顺着"内存体系"这一节继续往下拆:MMU 到底怎么把虚拟地址
翻译成物理地址、TLB 怎么加速这个过程——对应内容已经整理在
12_内存管理单元_MMU/ 目录下,欢迎对照阅读。
4936

被折叠的 条评论
为什么被折叠?



