跳转到主内容字符串不是 char 指针 | 物联网民工

字符串不是 char 指针

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 不能是 NULL
  • name 指向 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)]。