第 6 章 · 指针与字符串

C 没有「字符串类型」,这个事实是本章所有故事的起点。所谓字符串,就是一段以 '\0' 结尾的 char 数组;而「指针与字符串」的组合,贡献了 C 语言史上最多的「能编译、能运行、会崩」三连。本章从一个我带新生时每年都要救一次的事故开始:改一个字符串字面量。原文小节编号错成 5.x,一并修正。

6.1 事故现场:改只读内存

char *s = "hello";
s[0] = 'H';        // 编译通过,运行段错误

学生最懵的点:编译器一个警告都不给(老版本),运行就崩。真相:"hello" 是字符串字面量,编译器把它放进只读段(.rodata,第 2 章的五段地图),s 只是个指向它的指针;写只读段,MMU 拦下,SIGSEGV(第 2 章亲手触发过)。修法两步走:语义上用 const 把「别改」写进类型,让编译器当场拦截:

const char *s = "hello";     // s[0] = 'H' 直接编译报错

这个坑的姐妹版是 6.2 的对子,每年面试必考。

6.2 char* 和 char[]:两个名字,两种人生

char *s = "hello";      // s:指针,指向只读段里的字面量
char  t[] = "hello";    // t:数组,把字面量拷贝进栈上 6 字节(含 '\0')
char *s = "hello"char t[] = "hello"
内存s(栈上 8 字节)→ .rodata 里的 6 字节栈上实打实 6 字节副本
能改吗不能(写只读段,UB)能,随便改
sizeof8(指针大小)6(整个数组)
生命周期字面量全程活着跟随所在函数

一行分辨法:右边有方括号是「复制一份给自己」,没有是「只记个地址」。要修改就用 char t[];只读引用(传参、存常量)用 const char *。两个都合法,场景不同,混用的代价就是 6.1 的段错误。

6.3 '\0' 终结符:便捷与危险的共同根源

<string.h> 全家桶没有长度参数,全靠扫到 '\0' 才知道结束:

strlen(s)      // 数到 '\0' 为止,O(n) 而非 O(1)!长度信息不存在,每次都现数
strcpy(d, s)   // 连 '\0' 一起拷;前提:d 足够大,它不检查
strcat(d, s)   // 从 d 的 '\0' 处接着写;同样的前提
strcmp(a, b)   // 字典序,返回负/零/正(判断相等用 == 0,别拿返回值当布尔)

危险集中在 strcpy/strcat:不检查目标大小,源一长就写穿缓冲区。这不是纯学术话题,是史上最著名的安全漏洞家族(缓冲区溢出,第 14 章有专门一节,当年 Morris 蠕虫、无数 CVE 的主角)。安全姿势:

char buf[16];
snprintf(buf, sizeof buf, "%s-%s", a, b);   // 永远按 sizeof 写,截断但不越界

(strncpy 也在名单上但有个自己的坑:源太长时不补 '\0',后续 strlen 找不到终点照样跑飞,不如 snprintf 干净。)

6.4 strtok:两个陷阱的自带教材

按分隔符切串,行为有两个反直觉点,都值得单独说:

char buf[] = "a,b,c";              // 必须是可写数组(理由见陷阱一)
char *tok = strtok(buf, ",");      // 第一次传原串
while (tok) {
    puts(tok);
    tok = strtok(NULL, ",");       // 后续传 NULL:继续上次的进度
}
// 输出三行:a b c

陷阱一:它改写原串。找到分隔符时,直接把那字节替换成 '\0'(就地切分)。所以传 char *p = "a,b,c" 进去等于往只读段写,当场崩。陷阱二:不可重入。上次切到哪,靠函数内部一个 static 变量记着(所以第二次调用传 NULL),嵌套解析两个串、或者多线程同时用,位置互相踩。需要时可重入版 strtok_r(状态由调用者自己保管)。这个函数是「static 变量的方便与代价」的活教材。

6.5 手写一遍 strlen:指针式代码的入门仪式

每个 C 学习者都该写一次自己的 strlen,它是「指针遍历」的最小完备样本:

size_t my_strlen(const char *s) {
    const char *p = s;
    while (*p) p++;          // *p 非 0 就前进;'\0' 停
    return (size_t)(p - s);  // 指针相减 = 元素个数(第 3 章)
}

四行代码覆盖了本章三个知识点:*p 解引用判断、指针自增(步长 1)、指针相减。参数用 const char * 承诺「我只读」,这个习惯能让你免于 6.1 那类事故,也让调用者放心传字面量进来。

常见错误

  • strcmp 返回值当布尔用。if (strcmp(a, b)) 是「不相等」(返回非零),写成「相等才执行」逻辑就反了;明确写 == 0。
  • strlen 的返回值做乘法前忘转。它返回 size_t(无符号),strlen(s) - 5 在短串时不会得负数,而是回绕成天文数字,循环直接越界。减法前先比大小。
  • 局部 char[] 返回给调用者。和第 2 章的悬空指针同案:栈上数组随函数消失。要返回字符串,堆上分配(调用者 free)、调用者传缓冲区进来、或 static(线程不安全)三选一。

练习

  1. 动手:跑 6.1 的事故代码(记录真实报错),改成 char t[] 版再跑,验证能改;最后打印 sizeof(s)、sizeof(t)、strlen(s) 三个值,对齐 6.2 的表。
  2. 实现:手写 my_strcat(char *d, const char *s)(先把 d 走到 '\0',再逐字拷),再写一个带长度上限的安全版 my_strncat(d, s, n),保证不写穿。
  3. 手算:char buf[8] = "hello" 里 sizeof(buf) 和 strlen(buf) 各是多少?若执行 strcat(buf, "world") 会发生什么(数一数需要几个字节)?
  4. 思考:Java 的 String 不可变、长度缓存 O(1),C 的字符串可变、长度 O(n)。从「字符串拼接 s = a + b」这个操作出发,比较两种设计各自的时间/空间账,再想想 C 语言字符串设计是「偷懒」还是「当时内存太贵」。