ARM架构基础知识点总结: 从零建立认知ARM 底层架构到底在干什么


📺 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.LEB.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 语言里的 ifforwhile,落到汇编层面全部都是
"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/ 目录下,欢迎对照阅读。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值