为什么在裸机编程中,Rust 比 C 复杂:从 Runtime 到 Frame Pointer 的深度理解

一、引子:C 和 Rust 的两种世界观

C 和 Rust 都可以用于操作系统内核、驱动或嵌入式系统的开发,但它们的出发点完全不同:

  • C 追求的是“最小集合”,给你几乎纯净的语言层工具,不假设任何运行环境。 用 C 写 OS,就像在一块空白画布上作画或者用 LEGO 搭积木, 你想要什么功能都要自己实现。

  • Rust 追求的是“安全性 + 工具完备”,默认假设你在一个有标准库、操作系统、运行时支持的环境里。 用 Rust 写 OS,就像要把一辆配备空调、导航、自动驾驶的豪华汽车拆成越野车: 你必须先拆掉所有“高级功能”,再自己安装一套更基础的系统。

这就是为什么在裸机(bare-metal)环境下用 Rust 写 OS 时,我们常常要先写一堆看似“奇怪”的指令,比如:

#![no_std]
#![no_main]

还要手动提供:

  • #[panic_handler]
  • 手写 _start 汇编入口
  • 自定义内存分配器

这些都是 Rust 在“脱离运行时”后必须补回的最基础支撑结构。


二、什么是 Runtime(运行时)

在深入之前,我们要弄清楚一个经常被提到但容易模糊的概念:Runtime

想象一场舞台剧:

  • 演员上台表演,就是你的 main() 函数;
  • 而“后台准备灯光、布景、收尾清理”的部分,就是 Runtime

也就是说,Runtime 是在 main() 运行前后自动执行的那一部分幕后代码。

1. C 语言的 Runtime:极简的舞台监督

C 语言的 runtime(通常由 crt0.o 提供)非常薄。

它的职责仅仅是:

  1. 从操作系统那里接收命令行参数(argc, argv);
  2. 初始化一些基础设施(如标准 I/O);
  3. 调用 main()
  4. 处理 main() 返回值后退出。

当我们写裸机 OS 时,可以完全不需要这个 runtime,只需几行汇编即可取代它的功能。

这也是为什么我们可以轻松定义自己的 _start 并直接跳到 main

2. Rust 的 Runtime:全功能的系统后台

Rust 的 runtime 则庞大得多。

它要支持:

  • 栈溢出保护(stack guard);
  • panic 错误处理机制;
  • 多线程调度;
  • 文件系统、I/O;
  • 全局堆内存分配器;
  • std 中所有高级抽象(如 Vec, Box, String 等)。

因此,Rust 的 Runtime 在调用 main() 前,会做以下事情:

  1. 设置 panic 钩子;
  2. 初始化线程、本地存储、全局内存分配;
  3. 准备异常处理机制;
  4. 最后才调用用户定义的 main()

这意味着,Rust 的 main() 不是程序的入口点,而是 runtime 调用的“用户逻辑入口”。

真正的程序入口点是 runtime 内部定义的 _start


三、Rust 在裸机编程中的三个核心指令

在裸机上(比如写内核),这个“庞大的 runtime”是不存在的。

所以我们必须手动移除它,并替换成自己的最小环境。

1. #![no_std]

告诉编译器不要链接标准库 std

标准库 std 依赖操作系统系统调用(线程、文件、堆内存分配),而在裸机上这些都没有。

但我们仍然可以使用核心库 core,因为它不依赖 OS,只提供基本的语言支持(算术、Option、Result、Slice 等)。


2. #![no_main]

告诉编译器:“我不使用默认的 runtime,不要自动生成入口。”

Rust 默认会自动生成一个隐藏入口 _start,并在里面调用 main()

但在裸机上我们必须自己定义 _start

例如:

entry.asm:

.section .text.entry
.globl _start
_start:
    la sp, boot_stack_top
    call main

main.rs:

#![no_std]
#![no_main]

#[no_mangle]
pub extern "C" fn main() -> ! {
    println!("[kernel] Hello from main!");
    loop {}
}

这里的 #[no_mangle] 告诉编译器不要修改函数名,否则编译后函数名会变成 _ZN4mainE 之类的符号,汇编的 call main 就找不到它。


3. #[panic_handler]

Rust 的设计理念是不允许“未定义行为(UB)”。

当遇到错误(如数组越界、unwrap None)时,它必须以一种定义良好的方式处理——这就是 panic

panic 的默认实现依赖 std

当我们禁用 std 后,编译器会强制要求我们提供自己的 panic 处理函数,例如:

use core::panic::PanicInfo;

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

这是语言层面的要求,编译器不会帮你“忽略” panic,你必须自己定义。


四、Rust 和 C 的对比总结

方面 C Rust
标准库依赖 libc,非常薄,可完全去掉 std,高度依赖 OS
Runtime 几乎没有,仅做 main() 调用 含 panic、堆、线程、I/O 等初始化
错误机制 未定义行为(UB) panic,必须定义行为
入口函数 main()_start 直接调用 main() 被 runtime 包裹的 start 调用
裸机适配 直接去掉 runtime 即可 必须用 #![no_std]#![no_main] 并手动提供替代项
默认假设 有 CPU、内存即可 有操作系统环境

用比喻来说:

  • C 就像一堆基础 LEGO,你要什么自己搭;
  • Rust 是一辆高级汽车,你得先拆掉自动驾驶、导航系统,才能手动驾驶。

五、main 函数与真正的入口点

在带标准库的 Rust 程序中:

_start → runtime → main

在裸机 OS 中:

_start (汇编) → rust_main (或 main)

Rust 的 fn main() 并不特殊,当 #![no_main] 启用后,它就只是一个普通函数。

你完全可以把 rust_main 命名为 main,只要汇编中调用的名称一致。


六、Frame Pointer 与 Stack Pointer 的区别(延伸理解)

在调试(例如用 GDB)时,我们经常看到 “stack trace(调用栈回溯)”。

理解这一点有助于明白为什么在裸机环境中,Rust 比 C 更复杂地处理调用栈和调试信息

1. Stack Pointer (SP)

sp(x86 上是 rsp,RISC-V 上是 sp)永远指向当前栈顶

函数调用时,局部变量和返回地址会不断压入栈中,sp 会随之向下移动。

例如 RISC-V:

addi sp, sp, -64      # 为局部变量腾出空间
sd   ra, 56(sp)       # 保存返回地址
sd   s0, 48(sp)       # 保存旧的帧指针
addi s0, sp, 64       # 更新当前帧指针

此时:

  • sp 指向当前栈帧底部(最低地址);
  • s0(frame pointer)指向上一层函数栈的顶部(栈帧锚点)。

2. Frame Pointer (FP)

fp(x86 上是 rbp,RISC-V 上是 s0)是每个函数栈帧的固定锚点

有了它,编译器和调试器就能:

  • 通过固定偏移访问局部变量;
  • 沿着保存的旧 fp 一层层回溯函数调用链;
  • 实现 GDB 的 backtrace、内核 panic trace。

七、为什么在 Rust OS 开发中必须理解 Frame Pointer

Rust 与 C 的差异,不仅在语法层面,还在编译器如何生成调用栈这一底层机制。

1. C:天然保留 frame pointer,调试简单

在 C 语言中:

  • 默认会使用 rbp / ebp(x86)或 s0(RISC-V)作为帧指针;
  • 栈帧结构固定且可预测;
  • 编译器不会轻易省略 frame pointer;
  • GDB 可以完整打印调用链;
  • 对内核 panic trace、context switch、异常恢复都很友好。

2. Rust:LLVM 优化可能移除 frame pointer

Rust 默认开启 LLVM 优化,会:

  • 省略 frame pointer(-fomit-frame-pointer);
  • 内联函数;
  • 重排局部变量布局。

结果就是:

  • gdb bt 只能看到一两层函数;
  • 或直接显示 <optimized out>
  • panic trace 可能无法恢复完整调用链。

而在 OS 或裸机环境中,没有 runtime 提供 unwind 信息时,这种情况会导致你彻底失去调试能力


3. 启用 frame pointer 的解决方法

要让调试恢复正常,需要强制保存 frame pointer:

C 语言:

gcc -g -O0 -fno-omit-frame-pointer

Rust:

RUSTFLAGS="-C force-frame-pointers=yes" cargo build

这样编译器会保留每层栈帧的 fp 链,方便 GDB 回溯。


八、Frame Pointer 与 Runtime 的联系:为什么它出现在 Rust vs C 的比较中

表面上讲,frame pointer 似乎只是个汇编层细节,

但它其实与 “Rust vs C 在 OS 编程中的本质差异” 密切相关:

层面 C 的方式 Rust 的方式 关联
语言层 手工管理内存和栈 编译器/Runtime 自动处理 去掉 runtime 后必须手动恢复底层控制
编译层 栈结构固定 LLVM 可重排、可优化 必须理解并保留 frame pointer
调试层 GDB 能完整回溯 无 frame pointer 时难调试 panic trace、异常恢复都依赖帧链

在有 runtime 的 Rust 中,panic 展开依靠 unwinder 和调试信息自动完成;

但在裸机 OS 环境下,我们禁用了 runtime,因此frame pointer 成了唯一能追溯调用关系的线索

这也是为什么很多 Rust 内核教程(如 Writing an OS in Rust)都会建议在 Cargo.toml 中强制开启:

[profile.dev]
debug = true
force-frame-pointers = true

九、总结:Rust 的“复杂”,是因为它更完整

C 是一块原始石料,你从零雕刻;

Rust 是一座完整的机械系统,要拆解后重新组装成能在裸机上运行的形态。

Rust 默认提供了强大的 runtime、安全的 panic 机制和优化的栈管理系统;

而当你脱离这些机制时,你必须自己重新接管它们。

从定义 _start 到实现 panic_handler,再到启用 frame pointer,

你实际上是在说:

“别替我做决定,这次我来控制舞台的每一盏灯。”