C 里的 char * 很强,也很模糊。
它可能表示一段可变字节,可能表示 NUL 结尾字符串,可能表示 ASCII 文本,也可能只是某个 C API 要求的参数。调用者还要自己知道:这段内存归谁释放,里面有没有 \0,编码是什么,长度从哪里来。
Rust 不把这些都塞进一个类型。它把常见情况拆开:
一个字节:u8
一段字节:&[u8] / Vec<u8>
UTF-8 文本视图:&str
拥有的 UTF-8 文本:String
C 字符串视图:&CStr
拥有的 C 字符串:CString
这一篇只建立第一层边界:什么时候是字节,什么时候是文本,什么时候是 C 边界。
C 的 char * 同时承担太多角色
看一个常见 C 函数:
void log_name(const char *name)
{
printf("name=%s\n", name);
}
这个签名隐含了很多约定:
name不能是NULLname指向 NUL 结尾字符串- 中间不能提前出现
\0 %s会一直读到第一个\0- 字节内容按某种文本编码解释
- 函数不拥有这段内存
这些规则不在 const char * 里。它只是一个指针。
如果同样的 char * 被拿来表示二进制数据,规则又完全不同。二进制数据中间可以有 0 字节,必须额外传长度,不能用 %s 打印。
Rust 的字符串类型首先要解决的就是这件事:不要把字节和文本混成一个默认类型。
字节还是用 u8
上一篇已经讲过,Rust 的 char 不是 C 的 char。Rust 的 char 是一个 Unicode 标量值,不是一个字节。
如果你处理的是原始字节,用 u8:
let byte: u8 = b'A';
一段二进制数据用 &[u8] 或 Vec<u8>:
fn parse_frame(input: &[u8]) -> Result<(), &'static str> {
if input.len() < 2 {
return Err("frame too short");
}
Ok(())
}
如果数据来自设备、文件、网络报文、压缩数据、加密数据,默认先按字节看,不要急着转字符串。
这和 C 里区分 uint8_t *buf, size_t len 与 char *text 是同一个工程判断,只是 Rust 把类型分得更明确。
&str 是 UTF-8 文本视图
Rust 的 &str 表示一段 UTF-8 文本视图:
fn greet(name: &str) {
println!("hello, {name}");
}
它不是 NUL 结尾字符串,也不是裸指针。可以先理解成:
&str = 指向 UTF-8 文本的视图 = 指针 + 长度 + UTF-8 有效性
因为它带长度,所以文本中可以包含 \0:
let text = "abc\0def";
println!("{}", text.len());
这和 C 的 %s 完全不同。C 字符串遇到第一个 NUL 就结束;Rust 的 &str 按长度表示文本,NUL 只是其中一个字节。
但 &str 有一个前提:内容必须是合法 UTF-8。如果你拿到的是任意字节,就不能直接当作 &str。
String 是拥有的 UTF-8 文本
String 表示拥有所有权的、可增长的 UTF-8 文本:
let mut name = String::from("device");
name.push_str("-01");
可以把它和 Vec<u8> 类比:
Vec<u8> 拥有一段可增长字节 buffer
String 拥有一段可增长 UTF-8 文本
String 可以借用成 &str:
fn print_name(name: &str) {
println!("{name}");
}
let name = String::from("sensor");
print_name(&name);
如果函数只是读取文本,优先接收 &str,不要强迫调用方传 String:
fn is_valid_topic(topic: &str) -> bool {
!topic.is_empty() && !topic.contains(' ')
}
这样调用方可以传字符串字面量,也可以传 String 的借用。函数不拿走文本所有权。
字节转文本要处理失败
C 里很多代码会直接把一段 char * 当文本用。Rust 会要求你面对编码边界。
从字节转 &str 时,要检查 UTF-8:
fn parse_name(input: &[u8]) -> Result<&str, std::str::Utf8Error> {
std::str::from_utf8(input)
}
如果输入不是合法 UTF-8,这个函数会返回错误。
这对设备和协议开发很重要。不是所有字节都是文本:
- 串口帧可能是二进制协议
- 文件名可能来自系统编码边界
- 网络 payload 可能是压缩或加密数据
- C API 返回的字符串可能不是 UTF-8
Rust 不是让字符串变复杂,而是把原来容易被忽略的编码假设暴露出来。
C 边界用 CStr 和 CString
如果要和 C API 交互,Rust 也有专门的 C 字符串类型。
从 C 收到 NUL 结尾字符串时,通常用 CStr 视图:
use std::ffi::CStr;
use std::os::raw::c_char;
unsafe fn print_from_c(ptr: *const c_char) {
let c_str = CStr::from_ptr(ptr);
println!("{:?}", c_str);
}
如果要把 Rust 字符串传给 C,通常用 CString:
use std::ffi::CString;
let name = CString::new("device-01").expect("no interior nul");
let ptr = name.as_ptr();
CString::new 会检查字符串内部不能有 NUL。原因很直接:C 字符串用 NUL 作为结束符,中间出现 NUL 会让 C 提前截断。
这里还有所有权问题:ptr 只是 CString 内部数据的指针。CString 还活着时指针才有效。把指针交给 C 保存,必须单独设计释放和生命周期规则。
这些属于 FFI 边界,后面会单独讲。第一层先记住:C 字符串不要直接用 String 硬传,用 CStr / CString 表达边界。
API 设计时先问数据是什么
写 Rust 函数时,不要先问“C 里这是 char *,Rust 里对应哪个类型”。先问它到底是什么数据。
如果是二进制数据:
fn parse_payload(payload: &[u8]) {}
如果是只读 UTF-8 文本:
fn parse_topic(topic: &str) {}
如果函数要创建并返回文本:
fn make_topic(device: &str) -> String {
format!("devices/{device}/state")
}
如果是 C API 的 NUL 结尾字符串:
// &CStr / CString
这样的 API 边界比所有地方都用 String 或 Vec<u8> 更清楚。
这一篇先记住什么
C 的 char * 在 Rust 里不是一个统一替代品:
char byte / unsigned char byte -> u8
char * + len as binary data -> &[u8] / Vec<u8>
valid UTF-8 text view -> &str
owned UTF-8 text -> String
const char * from C -> &CStr
owned C string for C API -> CString
核心区别是:
字节不一定是文本
文本不等于 C 字符串
C 字符串不等于 Rust String
Rust 把这些边界拆开,是为了让编码、长度、NUL 结尾和所有权不再混在一个 char * 里。
下一篇可以进入结构体和内存布局。C 里的 struct、padding、对齐和 ABI 直觉,会对应到 Rust 的普通 struct 与 #[repr(C)]。