Rust OS 前置:从 C 到 Rust——变量、类型与 struct

学 Rust OS 时会密集碰到 struct、引用、裸指针、unsafe、生命周期这些概念。如果此前没有 Rust 基础,直接读 OS 代码会很吃力。这篇先用 C/C++ 作对照,把你已经会的东西在 Rust 里怎么写过一遍,建立一张 Rust 地图。

本文是 Rust OS 学习系列的前置篇。后续会接着讲引用与借用、属性与内存布局,并把它们综合到《Rust OS 入门:VGA 字符模式》的 Writer 代码上。

📚 基本概念速读

名称 含义 省流
let 声明变量 默认不可变
let mut 声明可变变量 想改就加 mut
: T 变量名后写类型 let x: u8 = 10;
struct 把多个数据组织在一起 类似 C 的 struct
impl 给 struct 添加方法 类似 C++ 的 class 方法
pub 允许模块外访问 默认模块内私有
[T; N] 定长数组类型 [[ScreenChar; 80]; 25]
match 模式匹配 更强大的 switch
Option<T> 有值/无值 Some(v) / None
Result<T, E> 成功/失败 Ok(v) / Err(e)
trait 约定一组方法,类型去实现它 类似 interface
<T> 泛型:类型当参数 Volatile<ScreenChar>
enum 一组固定的选项,变体还能带数据 Option / Result 就是它
for / 迭代器 遍历一列值 for x in 0..4
mod / use 分组代码、给路径起短名字 对应 C 的 #include + 命名空间

🗺️ 先建一张 Rust 地图

如果熟悉 C,Rust 可以这样对照着看:

1
2
3
4
5
6
7
8
C
│
├── 变量
├── 指针
├── struct
├── 函数
├── 数组
└── malloc / free

Rust 也有这些东西,只是在它们之上加了一套所有权与借用的思想:

1
2
3
4
5
6
7
8
9
10
11
12
                 Rust
│
┌──────────────┴──────────────┐
│ │
C/C++ 熟悉的东西 Rust 核心思想
│ │
变量 所有权
struct 借用 &
指针 可变借用 &mut
数组 生命周期 'a
函数 Option / Result
类型 unsafe

学习 OS 时的最大难点往往不是 Rust 语法本身,而是所有权 + 借用 + 引用 + 生命周期这套思想。本篇先讲 C 里也有、只是写法不同的部分。

📦 let:变量与不可变性

Rust 声明变量用 let:

1
2
3
4
let x = 10;        // 类型自动推断为 i32
let x = 10u8; // 显式字面量后缀:u8
let x = 10usize; // usize
let x: u8 = 10; // 或写在冒号后

可以对照 C 的 int x = 10;。语法上类型写在变量名后:

1
2
3
4
let x: u8 = 10;
│ │
│ └── 类型
└────── 变量名

Rust 默认变量不可变——这是和 C 的一个关键差异:

1
2
let x = 10;
x = 20; // ❌ 编译错误:cannot assign to immutable variable

要修改必须显式声明可变:

1
2
let mut x = 10;
x = 20; // ✅

简单记:let 是创建不可变变量,let mut 是创建可变变量。VGA 的 Writer 里会看到:

1
2
let mut column_position = 0;
column_position += 1; // 需要 mut

🏷️ 类型:写在变量名后

OS 开发里最常见的整数类型:

1
2
3
4
5
6
7
8
9
10
u8    无符号 8 位
u16 无符号 16 位
u32 无符号 32 位
u64 无符号 64 位
usize 与机器地址大小相关(64 位机上就是 u64 宽度)

i8 / i16 / i32 / i64 有符号版本
bool / char 布尔 / 字符
&str 字符串切片
String 动态字符串(需要分配器)

裸机代码里出现频率最高的是 usize、u8、u16、u32、u64——它们直接对应寄存器、地址和 MMIO 宽度。

🧱 struct:和 C 很像

1
2
3
4
struct ScreenChar {
ascii_character: u8,
color_code: ColorCode,
}

基本对应 C:

1
2
3
4
struct ScreenChar {
uint8_t ascii_character;
ColorCode color_code;
};

创建与访问:

1
2
3
4
5
6
let c = ScreenChar {
ascii_character: b'A',
color_code: color,
};
c.ascii_character; // 读字段
c.color_code;

还有更简短的写法——元组结构体,字段不写名字,只按位置:

1
2
3
4
struct ColorCode(u8);      // 只包一个 u8

let c = ColorCode(0x0e); // 造一个
c.0; // 取出里面的值:用 .0 访问

这种”只包一层”的写法也叫 newtype:给某个类型换个有语义的名字,同时内存里仍然只是那一个字节(后面 VGA 那篇的 ColorCode 就是它)。

🏷️ enum:一组固定的选项

C 的 enum 只给一组整数取名字;Rust 的 enum 是”一个值,只能是这几种之一”,而且每个变体还能自带数据。

1
2
3
4
5
6
pub enum Color {
Black = 0,
Blue = 1,
// ... 共 16 个变体
White = 15,
}

使用时必须带上枚举名:

1
2
let c = Color::Yellow;     // 不是裸写 Yellow
let n = c as u8; // 转成编号,底层类型由 #[repr(u8)] 指定(篇三)

变体后面还能挂数据——这是 C 的 enum 没有的:

1
2
3
4
5
enum Token {
Number(u32), // 带一个 u32
Word { len: u32 }, // 带一个命名字段
End, // 什么都不带
}

“每个变体带的数据还不同”这类值,配 match 就能逐个分支安全处理。Option 和 Result 本身就是这种 enum:

1
2
Option<T> = Some(T) | None
Result<T, E> = Ok(T) | Err(E)

🚪 pub:谁能看到

pub 表示 public,允许模块外访问;不加 pub 的结构体默认只在当前模块内可用:

1
2
3
pub struct Writer {          // 外部能看见 Writer
column_position: usize, // 但字段仍是私有的
}

📦 mod 与 use:代码怎么分组,路径怎么写

C 用 #include 把别的文件搬进来;Rust 不用,它的组织单位叫模块——文件本身就是一层分组,编译器按项目结构自动找到全部代码。

1
mod vga_buffer;          // 声明一个子模块,对应 vga_buffer.rs

层级关系是三层套下去:

1
2
3
4
5
6
crate(一次编译的整体,也对应一个依赖包)
│
├── mod vga_buffer ← 一个模块
│ ├── struct Writer
│ └── fn _print
└── mod serial

三个词各管一件事:

词 是什么
crate 一次编译的单位,也是依赖的单位:Cargo.toml 里 [dependencies] 的每一项就是一个 crate
mod crate 内部的模块,用来把代码分堆
use 给某个路径起短名字,省得每次写全

use 的路径怎么读——第一段永远是 crate 名,后面跟着它导出的东西:

1
2
3
4
use core::fmt;                     // crate :: 模块
use spin::Mutex; // crate :: 类型
use lazy_static::lazy_static; // crate :: 宏(用的时候名字后带 !)
use crate::vga_buffer::_print; // crate 关键字:指我自己这个 crate

两点容易踩:

  • 没在 Cargo.toml 里声明过的 crate,use 不到(core、std 这类随工具链来的除外)。
  • use 不产生任何代码,只是编译期的名字解析;它写在文件哪个位置都行,作用范围都是整个模块——后面 VGA 的 Writer 里 use core::fmt; 就写在文件中部。

🛠️ impl:给 struct 加方法

C 里要写 writer_write(writer, ...) 这类散装函数;Rust 把方法挂在类型上:

1
2
3
4
5
impl Writer {
fn write_byte(&mut self, byte: u8) {
// ...
}
}

调用:

1
writer.write_byte(b'A');

可以理解为 struct + impl = 类型 + 它的方法,类似 C++ 的 class,只不过 Rust 把数据(struct)和方法(impl)分开声明。

🧩 trait:约定一组方法,类型去实现它

impl 是”给某个类型加方法”;trait 是”约定某一类类型都必须提供哪些方法”。C 里没有对应物,最接近的是 C++ 的纯虚函数或 Java 的 interface,但 Rust 的 trait 更轻——它只描述能力,不涉及继承关系。

1
2
3
4
5
6
trait Write {
fn write_str(&mut self, s: &str) -> Result<(), ()>; // 必须实现
fn write_char(&mut self, c: char) { // 带默认实现,可以不写
// ...
}
}

某个类型要”接入”这份约定,就写 impl 契约 for 类型:

1
2
3
4
5
impl Write for Writer {
fn write_str(&mut self, s: &str) -> Result<(), ()> {
// 只补必须实现的那一个
}
}
1
2
3
4
trait Write            ← 约定:谁有资格"写出字符串"
│
├── impl for Writer (VGA 屏幕)
└── impl for SerialPort (串口)

规则两条:

  • 必须实现的方法一个都不能少;带默认实现的可以省略,省略时就用默认行为。
  • impl 一完成,这个类型就自动获得了那些带默认实现的方法。

还有一个容易忽略的前提:调用 trait 方法之前,trait 本身得先 use 进当前作用域,编译器才知道这个方法存在。

1
2
use core::fmt::Write;     // 先把它带进作用域
writer.write_fmt(args); // 现在才调得到

这套 trait 定义 + 默认实现 + 类型各自实现 的写法,就是后面 VGA 那篇第七层的全部内容。

📐 数组与索引

1
let a = [1, 2, 3, 4];   // 类型 [i32; 4]

类型里的两个数字分别表示元素类型与个数:

1
2
3
4
[i32; 4]
│ │
│ └── 4 个元素
└─────── 每个元素是 i32

VGA 里的屏幕缓冲正是二维数组:

1
2
chars: [[ScreenChar; BUFFER_WIDTH]; BUFFER_HEIGHT]
// 即 [[ScreenChar; 80]; 25]:25 行,每行 80 个字符单元
1
2
3
4
5
6
7
8
        ┌───────────────────┐
Row 0 │ . . . . . . . . . │
Row 1 │ . . . . . . . . . │
Row 2 │ . . . . . . . . . │
... │ ... │
Row 24 │ . . . . . . . . . │
└───────────────────┘
80 列 × 25 行

索引从 0 开始,和 C 一样:buffer.chars[24][0] 表示第 24 行第 0 列。

🔁 for 循环与迭代器

C 的 for 是”初始化 → 判断 → 自增”三段式;Rust 的 for 是遍历一列值:

1
2
3
4
5
6
for i in 0..4 { /* ... */ }            // 0, 1, 2, 3(不含右端)
for i in 0..=3 { /* ... */ } // 0, 1, 2, 3(含右端)

for row in 1..BUFFER_HEIGHT { // 1 到 24
// ...
}

0..4 这种写法叫区间。for 右边要的是”一列值”,能给出这一列值的就叫迭代器:

1
2
for byte in s.bytes() { /* ... */ }     // &str 按 UTF-8 字节逐个给出
for ch in "abc".chars() { /* ... */ } // 按字符逐个给出

同一个区间还能当 match 的模式用,两种写法经常一起出现:

1
2
3
4
match byte {
0x20..=0x7e | b'\n' => { /* 可打印 ASCII 与换行 */ }
_ => { /* 其余 */ }
}

一句话:for ... in ... 右边产出一列值,左边接住每一个。产出规则由迭代器决定,不用自己写下标和自增。

🧬 泛型 <T>:类型也能当参数

C 里想写个”装什么都行”的容器,只能用 void *,用的时候再强转;Rust 用泛型:把类型本身当成参数,写进尖括号。

1
2
3
4
5
struct Volatile<T> {          // T 是类型参数,先不指定具体是什么
value: T,
}

let v: Volatile<u8> = ...; // 到这里才把 T 填成 u8

读法:Volatile<u8> 读作”装着 u8 的 Volatile”,尖括号里就是填进去的那个类型。

函数和 trait 也能带类型参数,而且常常给它加要求:

1
fn largest<T: PartialOrd>(a: T, b: T) -> T    // T 必须能比较大小

T: PartialOrd 里的 PartialOrd 是一个 trait——意思是”填进来的类型必须实现这项能力”。所以上一节讲的 trait,除了自己定义自己实现,也用来给泛型参数立规矩。

OS 代码里泛型几乎无处不在,后面会反复见到:

1
2
3
Volatile<ScreenChar>          一个屏幕格子的 volatile 包装
Mutex<Writer> 一把锁,里面装着 Writer
Option<T> / Result<T, E> 速读表里那两个

和 C 的 void * 比,泛型的关键差别是:填进去什么类型,编译器就为它生成对应的一份代码,运行时不必转型,也不损失类型检查。

🔒 const

1
2
const BUFFER_HEIGHT: usize = 25;
const BUFFER_WIDTH: usize = 80;

类似 C 的 #define,但 Rust 的 const 带类型、参与类型检查。

🧮 函数与最后一行是返回值

1
2
3
fn add(a: i32, b: i32) -> i32 {
a + b // 没有分号:这是返回值
}

Rust 函数体的最后一个表达式就是返回值。如果写成 a + b;,加了分号就变成语句,返回 ()。这引出了 Rust 和 C 一个很大的思维差异——Rust 喜欢表达式:

1
2
3
4
5
let x = if condition {
10
} else {
20
};

这里的 if 本身产生一个值并赋给 x。这也是 Rust 代码看起来和 C 不一样的原因之一。

🔀 match:更强大的 switch

1
2
3
4
5
match number {
0 => println!("zero"),
1 => println!("one"),
_ => println!("other"),
}

match 是模式匹配,后面学 Option/Result/枚举时它是核心工具,可暂时理解为加强版 switch。

🟢 Option 与 Result:不靠 NULL 和 -1

C 里常用 NULL 表示没有值、用 return -1 表示失败。Rust 不喜欢这种方式,改用两个显式类型:

1
2
Option<T>  =  Some(T) 有值  |  None 没有值
Result<T,E> = Ok(T) 成功 | Err(E) 失败
1
2
3
4
5
6
7
8
9
10
let x: Option<i32> = Some(10);
let y: Option<i32> = None;

fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
Err("除数不能为 0".into())
} else {
Ok(a / b)
}
}

Some/None/Ok/Err 加上 match,就能强制调用方处理可能没有值或可能失败的情况——这是 Rust 错误处理的基本形态,OS 代码里读取 MMIO 寄存器、解析结构时常会遇到。

✅ 总结

从 C 到 Rust,变量、类型、struct、数组、函数这些零件大多能一一对应,差异主要在三点:变量默认不可变(mut)、类型写在变量名后、最后一个表达式即返回值。真正的分水岭是下一篇要讲的引用与所有权。

这一篇里还有几样值得单独记住的东西:enum(可带数据的固定选项)、trait(约定一组方法)、泛型 <T>(类型当参数)、for 与迭代器(遍历一列值)、mod / use(代码分组与路径)。后面每一篇 OS 文章都会反复用到它们。

下一篇接着讲这条核心主线:&(借用)→ &mut(可变借用)→ 裸指针 → unsafe → 生命周期,把内存到底归谁管讲清楚。

Happy Hacking! 🎉