深度拆解:从 LC-3 汇编到 Java/Rust,数组越界与硬件中断的底层真相

在很多程序员的认知里,像数组越界(OutOfBounds)或空指针(NullPointer)这样的错误,往往会与"内核报错"、"硬件崩溃"联系在一起。

我们经常听到一个观点:

"访问了不属于你的内存,CPU 硬件就会抛出中断,操作系统就会把程序杀死。"

但这真的是事实的全部吗?

今天,我们将从最底层的 LC-3 极简 CPU 架构 出发,一路向下探寻 MMU(内存管理单元) 的硬件设计,并向上剖析 Java(JVM)与 Rust 在处理内存安全时的权衡与设计哲学。


目录

  • [一、硬件真相:在 CPU 看来,根本就没有"数组"](#一、硬件真相:在 CPU 看来,根本就没有“数组” "#%E4%B8%80%E7%A1%AC%E4%BB%B6%E7%9C%9F%E7%9B%B8%E5%9C%A8-cpu-%E7%9C%8B%E6%9D%A5%E6%A0%B9%E6%9C%AC%E5%B0%B1%E6%B2%A1%E6%9C%89%E6%95%B0%E7%BB%84")

  • [二、为什么 CPU 硬件无法精准检测数组越界](#二、为什么 CPU 硬件无法精准检测数组越界 "#%E4%BA%8C%E4%B8%BA%E4%BB%80%E4%B9%88-cpu-%E7%A1%AC%E4%BB%B6%E6%97%A0%E6%B3%95%E7%B2%BE%E5%87%86%E6%A3%80%E6%B5%8B%E6%95%B0%E7%BB%84%E8%B6%8A%E7%95%8C")

    • [1. 硬件的眼里只有页(Page),没有变量](#1. 硬件的眼里只有页(Page),没有变量 "#1-%E7%A1%AC%E4%BB%B6%E7%9A%84%E7%9C%BC%E9%87%8C%E5%8F%AA%E6%9C%89%E9%A1%B5page%E6%B2%A1%E6%9C%89%E5%8F%98%E9%87%8F")
    • [2. 为什么硬件不做"字节级"越界保护](#2. 为什么硬件不做“字节级”越界保护 "#2-%E4%B8%BA%E4%BB%80%E4%B9%88%E7%A1%AC%E4%BB%B6%E4%B8%8D%E5%81%9A%E5%AD%97%E8%8A%82%E7%BA%A7%E8%B6%8A%E7%95%8C%E4%BF%9D%E6%8A%A4")
  • [三、C、Java 与 Rust 的不同抉择](#三、C、Java 与 Rust 的不同抉择 "#%E4%B8%89cjava-%E4%B8%8E-rust-%E7%9A%84%E4%B8%8D%E5%90%8C%E6%8A%89%E6%8B%A9")

    • [1. C 语言:摆烂哲学](#1. C 语言:摆烂哲学 "#1-c-%E8%AF%AD%E8%A8%80%E6%91%86%E7%83%82%E5%93%B2%E5%AD%A6%E4%BF%A1%E4%BB%BB%E7%A8%8B%E5%BA%8F%E5%91%98")
    • [2. Java:绝对安全](#2. Java:绝对安全 "#2-java%E7%BB%9D%E5%AF%B9%E5%AE%89%E5%85%A8%E7%BA%AF%E8%BD%AF%E4%BB%B6%E6%A3%80%E6%9F%A5")
    • [3. Rust:编译期神仙打架](#3. Rust:编译期神仙打架 "#3-rust%E7%BC%96%E8%AF%91%E6%9C%9F%E7%A5%9E%E4%BB%99%E6%89%93%E6%9E%B6%E9%9B%B6%E5%BC%80%E9%94%80%E5%AE%89%E5%85%A8")
  • 四、终极对比总结

  • 结语


一、硬件真相:在 CPU 看来,根本就没有"数组"

在现代高级语言中,数组是一个包含了长度属性、数据类型的独立结构。

然而,在 CPU 和内存的硬件视角里:

计算机中根本不存在"数组"这种数据类型,有的只是连续的内存单元(Memory Address)。

以教学常用的 16 位极简 CPU 架构 LC-3 为例,我们在代码中定义一个数组:

asm 复制代码
; LC-3 汇编中的数组定义
ARRAY   .FILL #10    ; 地址 x3050:存放数据 10
        .FILL #20    ; 地址 x3051:存放数据 20
        .FILL #30    ; 地址 x3052:存放数据 30

在 LC-3 中,数组仅仅是几条连续的 .FILL 伪指令(或者用 .BLKW 预留的一块空内存)。

访问 arr[1] 的本质,就是拿数组的首地址(x3050)加上索引偏移量(#1),然后去访问对应的内存地址:

asm 复制代码
LEA R0, ARRAY    ; 1. 获取首地址 R0 = x3050 (&arr)
ADD R0, R0, #1   ; 2. 计算目标地址 R0 = x3050 + 1 = x3051
LDR R1, R0, #0   ; 3. 解引用:读取 x3051 地址的数据到 R1

可以把这个过程理解成:

  1. 找到数组的首地址;
  2. 根据索引计算目标地址;
  3. 访问目标地址中的数据。

那么问题来了:如果在 LC-3 中访问 arr[999] 会发生什么?

CPU 依然会进行地址计算:

text 复制代码
x3050 + 999 = x3437

然后尝试读取 x3437 中的数据。

对于这种没有实现数组边界概念的极简 CPU 来说,硬件本身并不知道 x3437 已经超出了所谓的"数组"范围


二、为什么 CPU 硬件无法精准检测数组越界?

有同学可能会问:

"LC-3 是极简 CPU,现代 x86 / ARM 处理器那么强大,难道不能靠 MMU(内存管理单元)硬件来监控数组越界吗?"

答案是:

MMU 可以进行内存访问权限保护,但它并不知道某个地址是否属于某个具体的数组。

这两者是完全不同的问题。


1. 硬件的眼里只有页(Page),没有变量

现代 CPU 的 MMU(Memory Management Unit)通常以**页(Page)**作为内存管理和保护的基本单位。

常见的页面大小是:

text 复制代码
4 KB = 4096 bytes

MMU 主要负责:

  • 虚拟地址到物理地址的转换;
  • 页面访问权限检查;
  • 用户态与内核态隔离;
  • 读 / 写 / 执行权限控制;
  • 未映射页面的访问检测。

但 MMU 并不知道:

text 复制代码
这个 40 字节区域是一个 int 数组

也不知道:

text 复制代码
这个 100 字节区域是一个 struct

更不知道:

text 复制代码
0x2028 已经超过了 arr[9]

举个例子

假设操作系统分配了一个 4 KB 的内存页:

text 复制代码
┌──────────────────────────────────────────────────────────────┐
│                   操作系统分配的 Page                       │
│                       4096 Bytes                            │
├───────────────────────┬──────────────────────────────────────┤
│       arr[0..9]       │ 其他变量 / 数据 / 未使用空间         │
│       40 Bytes        │                                      │
├───────────────────────┴──────────────────────────────────────┤
│                                                              │
│                    剩余空间                                  │
│                                                              │
└──────────────────────────────────────────────────────────────┘
 0x2000                 0x2028                              0x2FFF

假设:

text 复制代码
arr 起始地址 = 0x2000
arr 大小     = 40 Bytes

那么:

text 复制代码
arr[0]  -> 0x2000
arr[9]  -> 0x2024
arr[10] -> 0x2028

从数组语义上来说:

text 复制代码
arr[10]

已经越界。

但是从 MMU 的角度来看:

text 复制代码
0x2028

仍然属于同一个合法页面。

因此:

MMU 会允许这次访问。


微小越界:没有跨页

例如:

text 复制代码
arr[10]

对应地址:

text 复制代码
0x2028

MMU 检查发现:

text 复制代码
0x2028 ∈ 当前合法 Page

并且页面具有:

text 复制代码
Read = Yes
Write = Yes

于是:

text 复制代码
CPU
 ↓
MMU 检查
 ↓
页面合法
 ↓
允许访问

硬件并不知道:

text 复制代码
0x2028 已经不属于 arr

因此可能出现:

  • 读取邻近变量;
  • 覆盖其他数据;
  • 读取未初始化数据;
  • 内存破坏;
  • 更严重的安全漏洞。

巨大越界:跨到了非法页面

如果访问的地址已经超出了当前合法映射范围,例如:

text 复制代码
0x3F40

而对应页面:

  • 没有映射;
  • 或者没有相应访问权限;

那么 MMU 就会发现问题。

典型过程是:

text 复制代码
CPU
 ↓
MMU
 ↓
页表查询
 ↓
发现页面不存在 / 权限不足
 ↓
触发 Page Fault
 ↓
操作系统处理异常
 ↓
进程可能收到 SIGSEGV

这时候才会出现我们熟悉的:

text 复制代码
Segmentation Fault

因此:

硬件能够发现"你访问了一个没有权限访问的页面",但通常无法直接发现"你访问了数组之外的第一个字节"。


2. 为什么硬件不做"字节级"越界保护?

如果希望硬件直接检测:

text 复制代码
arr[10]

是否越界,那么硬件必须知道:

text 复制代码
arr 起始地址
arr 长度
当前访问地址

例如:

text 复制代码
Base = 0x2000
Length = 40
Access = 0x2028

硬件需要判断:

text 复制代码
Base <= Access < Base + Length

这实际上意味着硬件必须保存大量的:

text 复制代码
Base + Bound

信息。

方案一:为每个变量配置硬件寄存器

例如:

text 复制代码
Array 1:
    Base = 0x2000
    Length = 40

Array 2:
    Base = 0x3000
    Length = 100

Array 3:
    Base = 0x5000
    Length = 256

这样硬件就可以精确检查每一次内存访问。

但问题是:

  • 硬件成本增加;
  • 寄存器数量有限;
  • 上下文切换复杂;
  • 动态内存管理困难;
  • 无法轻松支持数量庞大的动态对象;
  • 每次内存访问都增加额外检查逻辑。

方案二:每个数组独占一个页面

例如:

text 复制代码
┌──────────────┐
│ Guard Page   │
├──────────────┤
│              │
│    Array     │
│              │
├──────────────┤
│ Guard Page   │
└──────────────┘

这样数组一旦越界,就很容易撞到保护页。

但如果:

text 复制代码
数组只有 4 Bytes

却给它:

text 复制代码
4 KB Page

那么大量内存都会被浪费。

因此,这种方式适合:

  • 特殊安全场景;
  • 调试器;
  • Sanitizer;
  • 某些安全运行时;

但不适合作为所有普通数组的默认硬件机制。


本质上的取舍

最终形成的是一个经典的工程权衡:

text 复制代码
精确的内存安全
        ↕
硬件复杂度
        ↕
运行时性能
        ↕
内存利用率

现代计算机因此通常让:

硬件负责粗粒度的内存保护,软件和编译器负责更细粒度的语言级安全。


三、C、Java 与 Rust 的不同抉择

既然硬件无法直接理解"数组"这个概念,那么高级语言就必须在软件层面解决这个问题。

C、Java 和 Rust 选择了三种不同的路线。


1. C 语言:摆烂哲学(信任程序员)

C 的设计哲学之一就是:

相信程序员。

C 数组访问通常不会自动进行运行时边界检查。

例如:

c 复制代码
int arr[3] = {10, 20, 30};

int x = arr[10];

C 并不会自动判断:

text 复制代码
10 >= 3

然后抛出异常。

它最终可能表现为:

text 复制代码
读取某个未知内存位置

或者:

text 复制代码
覆盖其他内存

甚至可能:

text 复制代码
直接崩溃

微小越界

如果访问的地址仍然位于合法页面:

text 复制代码
CPU
 ↓
MMU
 ↓
页面合法
 ↓
正常访问

结果可能是:

text 复制代码
读取垃圾数据

或者:

text 复制代码
修改邻近变量

这也是很多经典内存安全漏洞的根源。

巨大越界

如果最终访问到了:

text 复制代码
未映射页面

则可能出现:

text 复制代码
CPU
 ↓
MMU
 ↓
Page Fault
 ↓
OS
 ↓
SIGSEGV

最终:

text 复制代码
Segmentation Fault

2. Java:绝对安全(纯软件检查)

Java 选择了完全不同的路线。

Java 数组本身包含长度信息:

java 复制代码
int[] arr = {10, 20, 30};

当执行:

java 复制代码
int x = arr[i];

JVM 必须保证:

text 复制代码
0 <= i < arr.length

因此在概念上可以把它理解成:

asm 复制代码
CMP R1, R2              ; 比较索引 i 和数组长度 length
JAE  throw_exception    ; 如果 i >= length,则抛出异常
MOV R3, [R0 + R1 * 4]   ; 读取数组元素

实际 JVM 生成的机器码会因:

  • CPU 架构;
  • JVM 实现;
  • JIT 编译;
  • 优化级别;

而有所不同。

这里展示的是概念模型


Java 数组越界的处理过程

例如:

java 复制代码
int[] arr = {10, 20, 30};

System.out.println(arr[10]);

逻辑上相当于:

text 复制代码
arr[10]
   ↓
检查 10 < arr.length ?
   ↓
false
   ↓
抛出 ArrayIndexOutOfBoundsException

因此:

text 复制代码
C
 ↓
没有语言级边界检查
 ↓
可能直接访问内存

而:

text 复制代码
Java
 ↓
运行时检查
 ↓
发现越界
 ↓
抛出异常

Java 异常与硬件中断的区别

这里需要特别澄清一个容易混淆的概念:

Java 的数组越界异常,并不等于 CPU 产生了硬件中断。

对于普通的数组越界:

java 复制代码
arr[i]

JVM 通常会通过软件生成的边界检查发现问题,然后进入异常处理路径。

可以概括为:

text 复制代码
Java bytecode
      ↓
JVM / JIT
      ↓
Bounds Check
      ↓
发现越界
      ↓
Java Exception

整个过程通常不需要因为"数组越界"而真的访问一个非法页面。


那么 Java 中有没有硬件异常参与?

有些运行时异常可能与 CPU/OS 的异常机制有关。

例如:

java 复制代码
int x = 1 / 0;

在某些 JVM 和平台实现中,整数除零可能利用 CPU 的异常机制,然后由 JVM 的信号/异常处理机制将其转化为 Java 层面的异常。

类似地,一些隐式空指针访问在特定 JVM 实现中也可能通过底层异常机制被捕获,再转换成:

text 复制代码
NullPointerException

所以更准确的说法是:

Java 异常机制本质上是软件运行时机制;某些特定异常的底层实现可能借助 CPU/OS 提供的硬件异常机制。

不能简单地说:

"Java 的异常都是硬件中断。"


3. Rust:编译期神仙打架(零开销安全)

Rust 的路线介于两者之间:

默认保证安全,但尽可能让编译器消除运行时检查。

例如:

rust 复制代码
let arr = [10, 20, 30];

let x = arr[1];

Rust 默认的索引访问是安全的。

概念上需要保证:

text 复制代码
0 <= index < length

如果运行时无法证明这一点,就需要进行边界检查。

但是 Rust 的强大之处在于:

LLVM 等优化器可以利用程序中的信息证明某些边界检查是多余的,然后将其消除。


策略一:编译期边界检查消除

例如:

rust 复制代码
let arr = [10, 20, 30];

let x = arr[1];

编译器可以直接知道:

text 复制代码
arr.length = 3
index = 1

因此:

text 复制代码
1 < 3

恒成立。

那么运行时就没有必要再生成:

asm 复制代码
CMP
JAE

之类的边界检查。

最终可以生成更加直接的机器代码。

因此:

text 复制代码
Rust 安全代码
      ↓
编译器分析
      ↓
证明访问一定合法
      ↓
消除 Bounds Check
      ↓
接近手写底层代码的性能

策略二:Idiomatic 迭代器(Iterators)

Rust 中非常推荐使用迭代器:

rust 复制代码
for item in &arr {
    println!("{}", item);
}

这种写法不仅更加符合 Rust 的风格,也给编译器提供了非常明确的访问模式。

编译器可以对:

rust 复制代码
for item in &arr

进行优化,使最终代码非常高效。

但需要注意:

"迭代器绝对没有任何检查"并不是 Rust 语言层面的保证。

实际是否存在额外检查,取决于:

  • 具体迭代器;
  • 优化级别;
  • 编译器;
  • 上下文;
  • 是否能够内联;
  • 是否能够证明安全。

因此更准确的说法是:

Rust 的迭代器设计非常有利于编译器进行优化,很多情况下可以达到非常接近手写指针代码的性能。


策略三:unsafe 逃生舱

如果确实需要完全绕过 Rust 的安全边界检查,可以使用:

rust 复制代码
unsafe

例如:

rust 复制代码
unsafe {
    let val = *arr.get_unchecked(i);
}

get_unchecked 不进行常规的运行时边界检查。

这意味着:

text 复制代码
Rust Safe Code
      ↓
编译器 + 运行时安全保证

可以进入:

text 复制代码
Rust Unsafe Code
      ↓
程序员承担额外安全责任
      ↓
更接近底层 C / 指针操作

但是这里有一个非常重要的区别:

unsafe 并不是"关闭 Rust 所有安全机制",而是允许程序员使用一组需要自行维护安全不变量的底层操作。

如果违反要求,就可能产生 Undefined Behavior(未定义行为)

因此:

rust 复制代码
unsafe

应该被理解为:

"我作为程序员,接管这一部分安全责任。"

而不是:

"Rust 变成了 C。"


四、终极对比总结

语言 数组越界检查机制 微小越界(未跨页) 巨大越界(跨页) 性能开销
C 通常无语言级边界检查 可能读取垃圾数据、覆盖邻近内存,属于未定义行为风险 可能触发硬件异常并最终导致 Segmentation Fault ,但不提供相应安全保证
Java 运行时边界检查 抛出 ArrayIndexOutOfBoundsException 同样由数组边界检查发现并抛出异常 通常较低,JIT 可进行优化
Rust 默认安全索引 + 编译器优化 触发 panic!,除非使用 unsafe 等方式绕过检查 同样由 Rust 的安全机制处理 通常可接近零额外开销,编译器可消除部分检查

三种语言的核心设计哲学

可以简单概括为:

C

text 复制代码
相信程序员
     ↓
尽可能少的运行时检查
     ↓
性能 + 控制权
     ↓
安全责任交给程序员

Java

text 复制代码
相信 JVM
     ↓
运行时检查
     ↓
发现非法访问
     ↓
抛出异常
     ↓
保证内存安全

Rust

text 复制代码
相信编译器
     ↓
静态分析 + 类型系统 + 所有权系统
     ↓
尽可能在编译期证明安全
     ↓
无法证明时进行运行时检查
     ↓
优化器尽可能消除检查

结语

从底层的 LC-3 到现代的 JVM 与 Rust 编译器,我们可以看到计算机体系结构和编程语言设计之间非常有意思的一条演进路线。

最重要的认识是:

CPU 本身通常不知道什么是"数组",MMU 也不知道什么是"数组边界"。

CPU 看到的主要是:

text 复制代码
指令
+
地址
+
数据

而 MMU 看到的主要是:

text 复制代码
虚拟地址
+
页表
+
访问权限

至于:

text 复制代码
arr[0]
arr[1]
arr[2]
...
arr[9]

这是编程语言和运行时系统赋予内存的一种抽象含义

因此,数组越界实际上存在两个完全不同的概念:

text 复制代码
语言层面的越界
        ↓
"这个地址已经不属于这个数组"

以及:

text 复制代码
硬件层面的非法访问
        ↓
"这个地址所在的页面没有权限访问"

两者并不是一回事。


最终可以用一句话概括

硬件负责保护"你能不能访问这个页面",而软件负责判断"你有没有越过这个数组"。

因此:

  • C 选择相信程序员,把更多责任交给开发者;
  • Java 选择由 JVM 在运行时执行安全检查;
  • Rust 则通过类型系统、所有权模型、编译器分析和优化,尽可能把安全检查提前到编译阶段,并在无法证明安全时保留运行时检查。

最终形成了三种不同的工程哲学:

text 复制代码
             内存安全
                │
       ┌────────┼────────┐
       │        │        │
       ▼        ▼        ▼
      C       Java     Rust
       │        │        │
   相信程序员  相信 JVM  相信编译器
       │        │        │
   少检查      运行时检查  编译期证明
       │        │        │
   高自由度    高安全性    安全 + 性能

这也是为什么理解数组越界,最终会一路追溯到:

text 复制代码
高级语言
   ↓
编译器 / JVM
   ↓
机器码
   ↓
CPU
   ↓
MMU
   ↓
页表
   ↓
物理内存

当把这条链路串起来之后,就会发现:

"数组越界"从来不是一个单纯的数组问题,而是编程语言抽象、编译器优化、CPU 指令集、MMU 和操作系统共同作用的结果。

相关推荐
Gorway1 小时前
理解 Spring 依赖注入:从构造器注入到集合与条件 Bean
java·后端
颜进强1 小时前
Claude Code - 26 效率三件套:cc-switch 切模型 · codeburn 算成本 · claude-hud 看状态
前端·后端·ai编程
掘金者阿豪1 小时前
时序数据库选型被写入峰值带偏了?金仓超表让我少加了不少班
后端
大黄评测2 小时前
jQuery 遍历方法 each 实战:循环处理列表数据
后端
大勇前进2 小时前
jQuery 事件委托原理:彻底解决动态生成元素绑定失效问题
后端
苍何2 小时前
做了个微信聊天爆款视频 Skill, Codex / WorkBuddy一键复刻使用
后端·aigc
卷无止境2 小时前
用 FastAPI 撑起大文件的上传下载:从流式处理到断点续传的完整实践
后端·python·fastapi
铁皮饭盒2 小时前
网页端, 6.5MB人脸识别模型, 谷歌框架, 又快又准
前端·javascript·后端