外观
第 19 章 高级魔法:FFI 与宏
小螃蟹说
高级卷的倒数第二章,我们打开魔法书最后两页——两大禁术:
unsafe:暂时跳出 Rust 的安全圈,亲手碰内存、调 C 语言的函数- 宏(macro):让代码"写代码"——你定义一条规则,编译器替你造出一堆代码
魔法书上有醒目的警告:禁术不是不能用,而是要明白自己在干什么。 Rust 的安全圈保护了你 18 章;今天,我们掀开帘子看一看到帘子后面是什么——然后好好把帘子拉上。
这两样本领,合起来正好凑成程序员的日常:世界上的代码有一大半是 C 写的(操作系统、数据库、浏览器……),Rust 要和它们打交道,靠 unsafe + FFI;而"重复的代码写到想吐"时,宏就是你的复印机。
19.1 预览:我们要做什么
开一个"魔法实验室",亲手演示四大魔法:
- 裸指针:绕过编译器,直接改内存里的值
- 可变静态变量:全程序共享的"喊话计数器"
- FFI:调用 C 语言的
puts函数(让 C 替你喊话) - 自制宏:自己造一个
cheer!(欢呼)宏
运行结果
text
== 魔法一:裸指针 ==
number 变成了 99
指针地址:0xc6b1dff40c
== 魔法二:可变静态变量 ==
喊了 1 次。
== 魔法三:调用 C 语言的函数 ==
Hello from C!
== 魔法四:自制宏 ==
小螃蟹 加油!加油!
小乌龟 加油!加油!
小海马!小海马!
小刺猬!小刺猬!
小兔子!小兔子!运行结果仅供参考
指针地址(0xc6b1dff40c)每次运行都不一样——那是内存里的真实位置,每次开机都不同。另外"Hello from C!"在终端里会出现在"魔法三"的位置;如果它跑到最后才出现,别慌——那是 C 和 Rust 的"喇叭"缓存不同步,内容没错。
这一章你会学到:
| 知识 | 是什么 | 会用在哪儿 |
|---|---|---|
unsafe | 暂时跳出安全圈 | 裸指针、外部函数 |
| 裸指针 | 没有保障的"裸地址" | 魔法一 |
static mut | 全程序共享的可变变量 | 魔法二 |
| FFI | 调用别的语言的函数 | 魔法三(调 C) |
macro_rules! | 代码写代码 | 魔法四(自制宏) |
19.2 动手做
步骤一:unsafe 是什么?
前 18 章,Rust 一直把你护在安全圈里:数组越界?编译期拦住;悬垂引用?编译器盯着;数据竞争?门都不让进。这套保护,叫内存安全。
但现实世界总有安全圈管不到的地方:调用 C 语言的函数、直接操作内存地址、和操作系统底层打交道……这时候,就要 unsafe——主动跳出去,对自己负责。
rust
unsafe {
// 里面的代码,编译器不做安全检查,全靠你自觉
}unsafe 不是"让程序不安全"的开关,而是**"这里我自己负责"的签名**。它一共放开四种魔法:
- 解引用裸指针(今天魔法一)
- 读写可变静态变量(今天魔法二)
- 调用外部函数(今天魔法三,FFI)
- 实现不安全的 trait(高级卷之后再说)
记住!
unsafe 块里的代码,编译器不再帮你检查——写错了,后果自负。真正的 Rust 程序员把 unsafe 包得严严实实:外面是安全函数,里面才是一小块 unsafe。魔法可以用,但要关在笼子里。
步骤二:魔法一:裸指针
建项目,加 libc 帮手:
bash
cargo new magic_lab
cd magic_labtoml
[dependencies]
libc = "0.2"libc 是"C 语言的工具包":里面都是 C 标准库的函数(魔法三要用)。
第一个魔法——裸指针(raw pointer)。第 3 章的引用(&)有编译器担保;裸指针是"裸的地址",没有任何担保,所以只能在 unsafe 里解引用:
rust
fn main() {
println!("== 魔法一:裸指针 ==");
let mut number = 42;
let address = &mut number as *mut i32;
unsafe {
*address = 99;
}
println!("number 变成了 {}", number);
println!("指针地址:{:?}", address);
}&mut number as *mut i32:把"可变引用"变身成裸指针(as第 9 章变身术)。*mut i32是"指向 i32 的可变裸指针"unsafe { *address = 99; }:解引用裸指针,把 99 写进 number 的内存。这一步必须 unsafe——裸指针可能指向任何地方,编译器不敢替你担保{:?}打印地址:指针本身就是"内存里的门牌号",打印出来是一串十六进制(0x...)
运行:
运行结果
text
== 魔法一:裸指针 ==
number 变成了 99
指针地址:0xc6b1dff40cnumber 从 42 变成了 99——我们绕过编译器,直接操作了内存。 指针地址每次跑都不一样(内存位置随缘分)。
步骤三:魔法二:可变静态变量
静态变量(static)是"全程序共享"的变量(第 1 章 const 的亲戚,但 static 是活的、有内存位置的)。第 15 章的多线程程序里,所有线程都能看到它。
static mut 是"可变的静态变量"——全程序共享、还能改。问题来了:多线程里大家都改它,会不会打架?(第 15 章的数据竞争!)Rust 的回答:读写它,必须 unsafe——"你自己保证别打架"。
rust
static mut SHOUT_COUNT: u32 = 0;
fn main() {
// ……魔法一……
println!();
println!("== 魔法二:可变静态变量 ==");
unsafe {
SHOUT_COUNT += 1;
let count = SHOUT_COUNT;
println!("喊了 {} 次。", count);
}
}static mut SHOUT_COUNT: u32 = 0; 定义在 main 外面(全局共享)。在 unsafe 块里 +1、读出来。注意我们把值拷出来(let count = SHOUT_COUNT)再打印——2024 版的 Rust 禁止直接拿着静态变量的引用去打印(那可能有隐患),拷一份就安全了。
运行:
运行结果
text
== 魔法二:可变静态变量 ==
喊了 1 次。小贴士
静态变量是"全程序唯一"的:不管函数被调用多少次,它只有一份。第 8 章我们用的 const SAVE_FILE 是常量(编译期写死);static mut 是活的、能改的。能不改就别用 static mut——它天生是线程安全的雷区。
步骤四:魔法三:FFI 调 C 语言
现在请 C 语言亲自出场!FFI(Foreign Function Interface,外部函数接口)是"调用别的语言的函数"的技术。我们调用 C 的 puts 函数——它的工作就是"把一句话喊到屏幕上":
rust
use std::ffi::CString;
fn main() {
// ……魔法一、二……
println!();
println!("== 魔法三:调用 C 语言的函数 ==");
let message = CString::new("Hello from C!").expect("CString 失败");
unsafe {
libc::puts(message.as_ptr());
}
}三件事:
libc::puts——libc 帮手提供的 C 函数。puts 是 C 的"println!"(C 里最古老的打印函数)。
CString::new(...)——C 语言不认识 Rust 的 String(它要"以 \0 结尾的文字",\0 是 C 的"句子结束标记")。CString 是"C 能吃的文字",专门用来过 FFI 的关卡。
message.as_ptr()——把 CString 变成裸指针(魔法一的亲戚),递给 C 函数。调用外部函数必须 unsafe:编译器不知道 C 那边会干什么。
运行:
运行结果
text
== 魔法三:调用 C 语言的函数 ==
Hello from C!C 语言替我们把话说出来了。 这一刻,Rust 和 C 完成了握手——Rust 世界里最古老也最重要的外交。
为什么要和 C 打交道?
世界上所有操作系统、绝大多数数据库、浏览器内核,都是 C(和 C++)写的。Rust 想用它们的能力(比如操作系统接口、老的库),就得走 FFI。Rust 的野心不是取代 C,而是和 C 握手。 很多 Rust 程序,内核是 Rust,外壳上还挂着 C 的老朋友。
步骤五:魔法四:自制宏
最后一项魔法——代码写代码。我们见过无数 println!、vec!(带 ! 的都是宏),现在自己造一个:
rust
macro_rules! cheer {
($name:expr) => {
println!("{} 加油!加油!", $name);
};
}在 main 里用它:
rust
println!();
println!("== 魔法四:自制宏 ==");
cheer!("小螃蟹");
cheer!("小乌龟");运行:
运行结果
text
== 魔法四:自制宏 ==
小螃蟹 加油!加油!
小乌龟 加油!加油!解剖 macro_rules! cheer:
macro_rules!:声明"我要定义一个宏"(这是"声明式宏"的标准写法)cheer:宏的名字(和println一样,用的时候带!)($name:expr) => { ... }:一条规则——"如果调用是cheer!(某个表达式),就把$name换成那个表达式,展开成{ ... }里的代码"
$name:expr 里的 $name 是占位符(第 9 章泛型 T 的表亲),:expr 是"接受表达式"。cheer!("小螃蟹") 展开后变成:
rust
println!("{} 加油!加油!", "小螃蟹");宏是"编译时复印机":写一次规则,编译器替你复印出无数行代码。 这不是函数调用(运行时),是代码展开(编译时)——所以宏能做的事,函数做不了(比如"写 Rust 代码的代码")。
步骤六:重复魔法:shout_list!
宏最强的本事是重复:一次处理好多参数。再造一个宏,让一串名字都喊两遍:
rust
macro_rules! shout_list {
($($name:expr),*) => {
$(
println!("{}!{}!", $name, $name);
)*
};
}新语法 $($name:expr),*:$() 是"这一组可以重复",, 是分隔符,* 是"重复任意次"。$( ... )* 包住的代码也会跟着重复——一个名字重复一遍。
rust
shout_list!("小海马", "小刺猬", "小兔子");展开后:
rust
println!("小海马!小海马!");
println!("小刺猬!小刺猬!");
println!("小兔子!小兔子!");运行:
运行结果
text
小海马!小海马!
小刺猬!小刺猬!
小兔子!小兔子!一个宏,三个名字,自动复印成三行。 想加第四个名字?调用处加一个就行——这就是宏的威力:规则写一次,重复交给编译器。
19.3 知识深挖
19.3.1 unsafe 的四种魔法
unsafe 一共放开四件事,记个清单:
| 魔法 | 干什么 | 危险在哪 |
|---|---|---|
| 解引用裸指针 | 直接操作内存地址 | 指针可能是坏的 |
读写 static mut | 全程序共享可变数据 | 多线程会打架 |
| 调用外部函数 | FFI,和 C 握手 | C 那边不可控 |
| 实现不安全 trait | 声明"我自己负责" | 编译器不管了 |
规则:unsafe 要尽量小、尽量包起来。 正确的姿势:
rust
pub fn set_number(address: *mut i32, value: i32) {
unsafe {
*address = value;
}
}外面是安全的函数(谁都能调),里面一小块 unsafe(自己负责)。把魔法关进笼子,别让它满屋子乱跑。
19.3.2 裸指针 vs 引用
第 3 章的引用(&)和今天的裸指针(*const / *mut),区别一句话:
引用 & | 裸指针 *mut | |
|---|---|---|
| 编译器检查 | 检查(借用规则) | 不检查 |
| 能指向空/坏地址 | 不能 | 能(所以危险) |
| 自动清理 | 会(离开作用域) | 不会 |
| 解引用 | 随便 | 必须 unsafe |
裸指针比引用"自由",但自由意味着自己负责。日常写代码,99% 用引用;裸指针只在和 C 打交道、或者写底层库时才出现。 认识它,是为了知道安全圈外面长什么样。
19.3.3 FFI:和 C 握手
FFI(Foreign Function Interface)是"跨语言调用"的总称。和 C 握手的三件套:
libccrate:C 标准库的 Rust 门卫(还有extern "C"语法可以直接声明 C 函数,libc 是现成的)CString:C 能吃的文字(\0结尾)unsafe:调用外部函数必须签名"自己负责"
FFI 的经典场景:调操作系统 API、用 C 写的老库、调 GPU/底层硬件。Rust 生态的很多库,内核是 Rust,外面挂着一层 C 的老朋友——FFI 就是那层胶水。
19.3.4 声明式宏:代码写代码
macro_rules! 是"声明式宏":你声明一条"展开规则",编译器照规则复印代码。
rust
macro_rules! name {
(模式) => { 展开的代码 };
}核心概念:
- 模式匹配:宏的"参数"也是用模式匹配的(第 18 章!)——
$name:expr匹配一个表达式,$($x:expr),*匹配一串 - 展开:匹配成功,占位符换成真值,代码复印出来
- 递归:宏可以调用自己(练习一!),但小心——宏的"递归"是按字面量一步一退的,不能算算术(那个"3-1"永远不会变成"2"!)
宏还有一个"亲戚":过程宏(proc macro,#[derive(Debug)] 就是),它能读代码、生成代码,是 serde 这类库的魔法核心。我们现在学的是"声明式宏",已经够用 90% 的场景了。
19.3.5 宏 vs 函数:什么时候用谁?
| 函数 | 宏 | |
|---|---|---|
| 什么时候"干活" | 运行时 | 编译时 |
| 参数 | 必须是值 | 可以是代码片段 |
| 展开 | 调用一次执行一次 | 每次使用都复印一份 |
| 重复代码 | 调用多次 | 少写多印 |
| 调试 | 方便 | 展开后难看清 |
选择题:能写函数就别写宏(函数好调试、省编译时间);宏的用武之地是:函数做不到的事(接代码片段)、或者真的想"少写多印"(比如 println! 的参数能变来变去)。
19.4 动脑筋练习
练习一:倒计时宏 count_down!
写一个宏 count_down!(3),打印:
text
3……
2……
1……发射!提示:宏的"递归"要靠字面量一步一步退:定义 (3) => { ... count_down!(2); }、(2) => { ... count_down!(1); }、(1) => { ... } 三条规则。宏的 (3) 是匹配字面量 3,不是算算术。
点开看答案
rust
macro_rules! count_down {
(3) => {
println!("3……");
count_down!(2);
};
(2) => {
println!("2……");
count_down!(1);
};
(1) => {
println!("1……发射!");
};
}调用:
rust
count_down!(3);运行:
text
3……
2……
1……发射!先试一个"想当然"的版本——用 $n:expr 匹配、count_down!($n - 1) 递归——编译会报"recursion limit reached"(递归太深)。为什么?因为 $n - 1 展开后是 3 - 1,再展开是 (3 - 1) - 1……表达式越滚越大,永远不等于字面量 1。宏的匹配是按字面量对号入座,不是算数。这就是练习的意义:亲手踩一次坑,才懂宏的脾气。
练习二:欢呼多次 cheer_many!
给宏加一个"次数"参数:cheer_many!("小乌龟", 3) 打印三遍"小乌龟 加油!"。提示:宏的展开代码里可以放 for 循环(0..$times,第 1 章)——宏复印的是代码,代码里可以有循环。
点开看答案
rust
macro_rules! cheer_many {
($name:expr, $times:expr) => {
for _ in 0..$times {
println!("{} 加油!", $name);
}
};
}调用:
rust
cheer_many!("小乌龟", 3);运行:
text
小乌龟 加油!
小乌龟 加油!
小乌龟 加油!cheer_many!("小乌龟", 3) 展开成:
rust
for _ in 0..3 {
println!("{} 加油!", "小乌龟");
}宏复印的是"代码模板",模板里什么都能放——循环、match、甚至别的宏。 这就是"代码写代码"的真正含义:你写的不是一句句代码,而是一台"代码复印机"。
练习三:和 C 数数字 strlen
libc 里还有一个函数 strlen——C 语言用来数"文字有多长"的。用它数 "Hello!" 有几个字符,和 Rust 自己的 message.as_bytes().len() 对比一下。
提示:strlen 接收裸指针(和 puts 一样传 as_ptr()),返回 usize。
点开看答案
rust
println!("== 和 C 数数字 ==");
let message = CString::new("Hello!").expect("CString 失败");
let length = unsafe { libc::strlen(message.as_ptr()) };
println!("C 函数 strlen 数出来 {} 个字符。", length);
println!("Rust 自己数出来 {} 个字节。", message.as_bytes().len());运行:
text
C 函数 strlen 数出来 6 个字符。
Rust 自己数出来 6 个字节。都是 6——"Hello!" 恰好 6 个字母、6 个字节。换成中文试试(CString::new("你好!")):C 数出来 7 个字节(一个汉字 3 字节 + 感叹号 1 字节),Rust 数出来也是 7——它们数的都是"字节"不是"字"(第 3 章说过,汉字在 UTF-8 里占 3 个字节)。和 C 打交道,永远记住:C 只认字节,不认汉字。
19.5 完整代码清单
项目结构:
text
magic_lab/
├── Cargo.toml
└── src/
└── main.rs文件:Cargo.toml
toml
[package]
name = "magic_lab"
version = "0.1.0"
edition = "2024"
[dependencies]
libc = "0.2"文件:src/main.rs
rust
use std::ffi::CString;
macro_rules! cheer {
($name:expr) => {
println!("{} 加油!加油!", $name);
};
}
macro_rules! shout_list {
($($name:expr),*) => {
$(
println!("{}!{}!", $name, $name);
)*
};
}
static mut SHOUT_COUNT: u32 = 0;
fn main() {
println!("== 魔法一:裸指针 ==");
let mut number = 42;
let address = &mut number as *mut i32;
unsafe {
*address = 99;
}
println!("number 变成了 {}", number);
println!("指针地址:{:?}", address);
println!();
println!("== 魔法二:可变静态变量 ==");
unsafe {
SHOUT_COUNT += 1;
let count = SHOUT_COUNT;
println!("喊了 {} 次。", count);
}
println!();
println!("== 魔法三:调用 C 语言的函数 ==");
let message = CString::new("Hello from C!").expect("CString 失败");
unsafe {
libc::puts(message.as_ptr());
}
println!();
println!("== 魔法四:自制宏 ==");
cheer!("小螃蟹");
cheer!("小乌龟");
shout_list!("小海马", "小刺猬", "小兔子");
}怎么运行:
bash
cd magic_lab
cargo run运行检查单:
number 从 42 变成 99,指针地址是一串 0x 开头
喊话计数器显示 1
屏幕上出现 "Hello from C!"cheer! 和 shout_list! 都正常打印
把 unsafe 块删掉,看编译器怎么拦你(看完加回来!)
试试 count_down!(3) 的"想当然递归版",看 recursion limit 报错(练习一)