编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • 进程虚拟地址空间
      • 栈与堆底层对决
      • 指针本质与多级解引
      • 指针运算底层真相
      • 函数指针与回调机制
      • 限定符与指针语义
      • 补码与位运算原理
      • IEEE754浮点本质
      • 数组与指针的纠葛
      • 结构体对齐与优化
      • 字符串存储与安全
        • 目录
        • 1. 案例引入
          • 1.1 日志系统离奇崩溃
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 字符串的三种住所
          • 2.2 C字符串软肋
        • 3. \0 结尾的设计代价
          • 3.1 哨兵字节的哲学起源
          • 3.2 以空间换简洁
          • 3.3 字节性能
          • 3.4 二进制安全之痛
        • 4. 字面量栈数组
          • 4.1 两条写法天壤之别
          • 4.2 rodata去重池化揭秘
          • 4.3 修改字面量为何崩
          • 4.4 编译期常量折叠
        • 5. 缓冲区溢出根因分析
          • 5.1 strcpy的死亡证明
          • 5.2 strcat叠加灾难
          • 5.3 sprintf格式炸弹
          • 5.4 gets为什么被开除
          • 5.5 溢出后的栈帧惨状
        • 6. strncpy 的陷阱
          • 6.1 名为安全实为陷阱
          • 6.2 不写\0的五种场景
          • 6.3 零填充的性能灾难
          • 6.4 源码级行为解剖
        • 7. 安全字符串操作方案
          • 7.1 snprintf一把梭
          • 7.2 strlcpy与strlcat
          • 7.3 动态字符串与sds
          • 7.4 编译器内置防护
          • 7.5 静态分析与Sanitizer
        • 8. ASCII到Unicode编码演进
          • 8.1 ASCII的128个格子
          • 8.2 多字节编码战国时代
          • 8.3 Unicode统一字符集
          • 8.4 UTF-8的变长编码艺术
          • 8.5 C语言的wchart与char16t
        • 9. 综合案例串讲
          • 9.1 案例真相揭晓
          • 9.2 一份字符串的一生
          • 9.3 面试高频问题清单
          • 9.4 安全编码速查卡
      • 预处理器宏与条件编译
      • 编译到汇编全流程
      • 链接器符号与重定位
      • 静态库与动态库对比
      • Make与CMake构建
      • 文件IO与系统调用
      • 动态内存管理揭秘
      • 未定义行为与防御
      • C工程化与设计哲学
    • 标准集库

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • C语言入门精通
  • 专栏博客
杨充
2026-06-10
目录

字符串存储与安全

# 11.字符串存储与安全

C 字符串以 '\0' 结尾的设计代价与收益、字符串字面量在 .rodata 的去重池化、char arr[] 在栈 vs char *ptr 指向 .rodata 的本质区别、strcpy/strcat/sprintf 缓冲区溢出根因、strncpy 的 '\0' 不保证写入陷阱、snprintf/strlcpy 安全替代方案、ASCII → Unicode → UTF-8 编码演进与 C 的宽字符 wchar_t

# 目录

  • 1. 案例引入
    • 1.1 日志系统离奇崩溃
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 字符串的三种住所
    • 2.2 C字符串软肋
  • 3. \0 结尾的设计代价
    • 3.1 哨兵字节的哲学起源
    • 3.2 以空间换简洁
    • 3.3 字节性能
    • 3.4 二进制安全之痛
  • 4. 字面量栈数组
    • 4.1 两条写法天壤之别
    • 4.2 rodata去重池化揭秘
    • 4.3 修改字面量为何崩
    • 4.4 编译期常量折叠
  • 5. 缓冲区溢出根因分析
    • 5.1 strcpy的死亡证明
    • 5.2 strcat叠加灾难
    • 5.3 sprintf格式炸弹
    • 5.4 gets为什么被开除
    • 5.5 溢出后的栈帧惨状
  • 6. strncpy 的陷阱
    • 6.1 名为安全实为陷阱
    • 6.2 不写\0的五种场景
    • 6.3 零填充的性能灾难
    • 6.4 源码级行为解剖
  • 7. 安全字符串操作方案
    • 7.1 snprintf一把梭
    • 7.2 strlcpy与strlcat
    • 7.3 动态字符串与sds
    • 7.4 编译器内置防护
    • 7.5 静态分析与Sanitizer
  • 8. ASCII到Unicode编码演进
    • 8.1 ASCII的128个格子
    • 8.2 多字节编码战国时代
    • 8.3 Unicode统一字符集
    • 8.4 UTF-8的变长编码艺术
    • 8.5 C语言的wchar_t与char16_t
  • 9. 综合案例串讲
    • 9.1 案例真相揭晓
    • 9.2 一份字符串的一生
    • 9.3 面试高频问题清单
    • 9.4 安全编码速查卡

# 1. 案例引入

# 1.1 日志系统离奇崩溃

先看一段在某金融后台跑了两年没出过事的代码,上线新业务后 第 3 天凌晨 收到告警——进程 SIGABRT,堆栈指向一个不可能出错的地方:

// log_router.c —— 日志路由分发模块
#include <stdio.h>
#include <string.h>

#define LOG_PATH_MAX  128
#define MSG_MAX       512

void route_log(const char* service, const char* msg) {
    char filepath[LOG_PATH_MAX];
    char content[MSG_MAX];

    /* 拼接日志文件路径 */
    strcpy(filepath, "/var/log/bank/");
    strcat(filepath, service);           // ← 假设 service 不会太长
    strcat(filepath, ".log");

    /* 拼接日志内容 */
    snprintf(content, sizeof(content),
             "[%s] %s\n", service, msg);

    /* 写文件... */
    FILE* fp = fopen(filepath, "a");
    if (fp) {
        fputs(content, fp);
        fclose(fp);
    }
}

int main() {
    /* 新业务上线:service 名从上游透传,不再是我们内部定义的 */
    route_log(
        "payment-gateway-v3-asia-pacific-southeast-region-extended",
        "transaction completed"
    );
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

现象:

  • 测试环境跑了一个月:零崩溃
  • 生产环境新业务上线第 3 天:SIGABRT — stack smashing detected
  • 事发时的调用参数:service 长度 58 字节,LOG_PATH_MAX 只有 128

直觉怀疑:是不是 snprintf 的长度算错了?打开 core 一看,__stack_chk_fail 触发——说明 canary 被踩了。filepath 只有 128 字节,"/var/log/bank/" 已经占了 14 字节,".log" 占 4 字节,剩下的 110 字节看似够用……但 service 这次传了 58 字节过来,加上前面的 14 + .log 的 4 + 中间 strcat 累积的 \0 覆盖 = 14 + 58 + 4 + 1(\0) = 77 字节,理论上是安全的。

那为什么还会 stack smashing?别急——这里至少藏着 5 个字符安全相关的坑,每一个单独看都不致命,叠在一起就出事了。

# 1.2 顺藤摸到根因

带着 core dump 往深处走:

  • 假设 1:是不是 strcpy + strcat 把 \0 弄丢了?—— strcat 从目标字符串的 \0 开始追加,如果目标里没有 \0,它就一路找下去,可能找十几 KB 才找到,最终写的地址远超 filepath 的 128 字节。
  • 假设 2:filepath 在栈上的初始值是未定义的—— char filepath[128] 没有初始化,栈上残留的是之前函数调用的垃圾数据。如果垃圾数据恰好一个 \0 都没有,那 strcpy 找 \0 的行为是未定义的——但这里 strcpy 是往 filepath 写,不会被读影响。真正危险的是后续的 strcat——它从 filepath 的末尾 \0 开始追加,但如果 strcpy 后 filepath 的 \0 在哪是确定的(就在拷贝内容的末尾),所以这条排除。
  • 假设 3:等等——strcpy(filepath, "/var/log/bank/") 写入 15 字节(含 \0)。strcat(filepath, service) 从第 14 字节覆盖 \0,追加 service 的 58 字节 + 新 \0,此时 filepath 用了 14 + 58 + 1 = 73 字节。strcat(filepath, ".log") 追加 5 字节(含 \0),总计 73 + 5 = 78 字节。78 < 128,没溢出。
  • 假设 4:那为什么还有 stack smashing?—— 重新审查:content 是 512 字节,snprintf(content, sizeof(content), "[%s] %s\n", service, msg)——这里 service 58 字节,加上前缀 [ (1) + ] (2) + msg + \n (1) + \0 (1)……如果 msg 来自某个超长日志呢?但日志显示 msg 只有 22 字节。
  • 假设 5:真正的凶手——案发时 service 的实际值是 62 字节(前面的 58 是我简化了描述),而且生产环境那个版本里有一个隐藏的"血崩点":上游通过 sprintf 拼了一个超长的 service 名再传给 route_log,而中间层有一个 char buf[64] 的临时缓冲区……上游的溢出踩坏了调用者的栈帧,使得 route_log 的 canary 校验值在 main 还没进 route_log 之前就已经被破坏了。

这个案例的根因链条:

上游 sprintf 溢出 (64 字节 buf 装不下 70 字节的拼接结果)
   → 溢出覆盖 main 的栈帧 → canary 被改
     → 进入 route_log → 函数入口处 canary 被保存
       → 函数返回时检查 canary → 发现不一致 → __stack_chk_fail → SIGABRT
1
2
3
4

真正的凶手不是 route_log 内部,而是调用链上游的一个 64 字节临时缓冲区。

这个事故里藏着至少 8 个原理点:

① "hello" 写在进程地址空间的哪里?为什么不能改?        → 第 4 章
② char arr[] 和 char *ptr 的存储位置区别是什么?       → 第 4 章
③ strcpy/strcat 为什么是定时炸弹?                     → 第 5 章
④ snprintf 以为安全,坑在哪?                           → 第 7 章
⑤ stack canary 是怎么检测溢出的?                      → 第 5.5 节
⑥ strncpy 加了长度参数就安全了吗?                      → 第 6 章
⑦ \0 结尾的设计到底给 C 带来了什么?                     → 第 3 章
⑧ 中文、emoji 在 C 里怎么处理?为什么 strlen("你好") ≠ 2? → 第 8 章
1
2
3
4
5
6
7
8

# 1.3 我们要回答什么

这个事故就是本篇的主线案例。我们带着上面 8 个问号往下走,每讲完一段原理就解开一两个;最后在第 9 章把案例彻底剖开,并给出生产级安全方案。

本篇路线:

架构总图 (第 2 章)
   ↓
\0 结尾设计 (第 3 章) ─→ 解开"为什么 C 字符串这么危险但又改不得"
   ↓
字面量 vs 栈数组 (第 4 章) ─→ 解开""写在哪、能不能改"
   ↓
缓冲区溢出根因 (第 5 章) ─→ 解开"strcpy/get 为什么是代码炸弹"
   ↓
strncpy 陷阱 (第 6 章) ─→ 解开"号称安全的函数为何更坑"
   ↓
安全方案 (第 7 章) ─→ 武器库
   ↓
编码演进 (第 8 章) ─→ 解开"ASCII/Unicode/UTF-8 在 C 里的全貌"
   ↓
综合案例 (第 9 章) ─→ 案例彻底剖开 + 速查卡
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

📌 本篇定位:字符串是 C 语言的"阿喀琉斯之踵"——大约 70% 的 C 程序安全漏洞(NVD 统计)源于字符串处理不当。本篇从内存布局、API 语义、编码演进三个维度,把 C 字符串的每一个暗坑剖到字节级。读完本篇后,对 strcpy/strncpy/snprintf 的选择题、char* 与 char[] 的辨析题、以及 UTF-8 在 C 中的长度谜题,都能从原理出发给出准确回答。

# 2. 架构概览

# 2.1 字符串的三种住所

C 语言中,字符串可以住在进程地址空间的三个不同位置:

高地址
  ┌────────────────────────────────────────────────┐
  │              栈区 (stack)                       │  ← char buf[64] 住这
  │         函数局部数组,随函数进入分配、退出释放      │     rw-
  │         char arr[] = "hello" 把 "hello" 拷贝进来 │
  ├────────────────────────────────────────────────┤
  │            共享库 / mmap 区                      │
  │         动态分配的字符串 (malloc)                 │  ← strdup 的产物住这
  │         char* p = malloc(64)  → heap            │     rw-
  ├────────────────────────────────────────────────┤
  │   堆区 (heap)                                    │
  │   malloc / realloc / strdup 的字符串             │
  ├────────────────────────────────────────────────┤
  │   bss   未初始化全局 char arr[100] = "" 住这     │
  ├────────────────────────────────────────────────┤
  │   data  已初始化全局 char arr[] = "init" 住这    │
  ├────────────────────────────────────────────────┤
  │   rodata  常量只读段                             │  ← "hello" 的真正老家
  │   字符串字面量被编译器合并去重                     │     r--
  │   char *p = "hello" → p 指向这里!               │
  ├────────────────────────────────────────────────┤
  │   text   机器指令                                │
  ├────────────────────────────────────────────────┤
  │   保留区                                        │
  └────────────────────────────────────────────────┘
低地址
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

核心结论:char *p = "hello" 和 char arr[] = "hello" 的区别不是语法糖——指针指向 rodata(不可写),数组在栈/data(可写)。这个区别是本章最重要的地基。

三种住所速查:

写法 字符串内容在哪 指针/数组名在哪 可修改? 生命周期
char *p = "hello" .rodata (只读) 栈 (局部) 或 data (全局) ❌ 写就崩 程序全周期
char arr[] = "hello" 栈 (局部) 或 data (全局) 栈/数据段 ✅ 随作用域
char *p = malloc(64) 堆 栈 ✅ 直到 free
static char arr[] = "hi" .data .data ✅ 程序全周期
const char* p = "hello" .rodata 栈/data ❌ 程序全周期

# 2.2 C字符串软肋

疑惑:Java/Python/Go 的字符串都"安全",为什么 C 的字符串会成为"漏洞之王"?

论证:

  1. C 没有真正的字符串类型——C 的"字符串"是 char*,一个指针而已。编译器不知道它指向的内存有多大,运行时也不知道。长度信息不在类型系统里,存在程序员的脑子里。
  2. 长度的唯一信息来源是 \0——要遍历字符串找到 \0 才知道长度。这意味着 strlen 是 O(n),更重要的是:如果 \0 丢了,所有基于它的操作全部失控。
  3. 库函数设计于"信任时代"——1970 年代的 Unix 程序员默认"调用者知道自己在干什么",strcpy 不检查目标缓冲区大小,gets 甚至不检查源长度。这些函数在"局域网互信"时代没问题,在互联网时代是定时炸弹。
  4. 缓冲区的生命周期管理完全靠人工——没有 GC、没有 RAII、没有引用计数。char* 可以指向已释放的栈、野指针、未初始化的垃圾——编译器不帮忙。
  5. 对比验证:
语言 字符串长度信息 边界检查 内存管理 编码感知
C \0 结尾,O(n) 扫描 无(全靠程序员) 手动 无(字节流)
C++ std::string 存 O(1) size at() 抛异常 RAII 无(需外部库)
Java 对象头存 length 数组越界抛异常 GC UTF-16 原生
Go string 结构体存 len 切片越界 panic GC UTF-8 原生
Rust &str 编译期 + 运行时 编译期拒绝 所有权系统 UTF-8 校验

结论:C 语言在"不做类型编码"与"零成本抽象"之间做了极端选择——字符串不是一种类型,只是一个字节序列的约定。这种设计在 1970 年是一种"最小化实现"的智慧,但在 2026 年仍然是 C 安全漏洞的头号土壤。后续所有章节,本质上都在回答一个问题:在 C 的类型系统不帮忙的情况下,我们怎么把字符串管住。

# 3. \0 结尾的设计代价

# 3.1 哨兵字节的哲学起源

疑惑:为什么 C 选择用 \0 而不是像 Pascal 那样在前面存长度?

论证:

  1. 历史事实:C 语言 1972 年诞生于 PDP-11 小型机,内存只有 16 KB。Dennis Ritchie 在设计 B 语言(C 的前身)时就面临一个选择——每个字符串额外存一个长度字段,还是用一个特殊字符标记结尾。

  2. Pascal 的方案(存长度):

(* Pascal:字符串第一个字节存长度 *)
var s: string[255];  (* 实际分配 256 字节,s[0] 存长度 *)
// 优点:O(1) 取长度,字符串可以包含任意字节
// 缺点:最大长度被长度字段的字节数锁死(Pascal 用 1 字节存长度 → 最长 255)
1
2
3
4
  1. C 的方案(哨兵字节 \0):
char s[] = "hello";   // 实际 6 字节: 'h','e','l','l','o','\0'
// 优点:字符串长度无上限(理论),指针就是首地址,极简
// 缺点:O(n) 取长度,不能包含 \0 字节
1
2
3
  1. 关键洞察:Ritchie 选择 \0 不是随机的——\0 在 ASCII 里是 NUL 字符,天然是"什么都不是"的意思;而且 PDP-11 的字符串指令(如 MOVC)天然支持 NUL 终止的字符串遍历,硬件层本身就是这么设计的。

  2. 连锁反应:一旦 C 选择了 \0 结尾,整个 Unix 生态就绑定了这一决定——strlen、strcpy、strcmp、printf("%s")……所有标准库函数都基于这个假设。50 年后,这一决定的阴影依然笼罩着每一个 char buf[64]。

结论:\0 结尾不是设计缺陷,而是 1972 年约束下的最优解——Pascal 的长度前缀法同样有自己的天花板(固定位宽限制长度)。真正的问题是后来的 C 程序员把这一设计用在 2026 年的互联网场景中,而库函数没有与时俱进。

# 3.2 以空间换简洁

\0 结尾的核心代价用一个例子说清楚:

#include <string.h>
size_t my_strlen(const char* s) {
    const char* p = s;
    while (*p) p++;     // ← 每次比较一个字节,直到碰到 \0
    return p - s;
}
1
2
3
4
5
6

假设 s 指向一个 1 MB 的字符串,这个循环跑 100 万次。但这不是最糟的——最糟的是:

char* p = malloc(1024 * 1024);
memset(p, 'A', 1024 * 1024);  // 全填 'A',末尾没有 \0
size_t len = strlen(p);        // 💀 读越界!一直读到碰巧遇到 \0 或 SIGSEGV
1
2
3

strlen、strcpy、strcat、strcmp……它们的共同特征是:不知道目标缓冲区的真实大小,只认 \0。

更隐蔽的代价:C 字符串不能包含 \0 字节——这是"非二进制安全"的根源:

char data[] = {0x48, 0x00, 0x4F};  // H\0O
printf("%s\n", data);              // 输出只有 "H"——\0 截断了!
size_t len = strlen(data);         // len = 1,不是 3
1
2
3

任何需要存储二进制数据(图片、加密结果、网络包)的场景,都不能直接用 C 字符串——必须用 memcpy/memcmp 系列 + 显式长度。这就是 Redis 选择自定义 SDS 字符串的根本原因(第 7.3 节详述)。

# 3.3 字节性能

疑惑:只是一个字节的 \0 而已,真的有性能问题?

论证:

\0 最阴险的性能陷阱是 O(n²) 级联:

char buf[4096] = "";                    // buf = "" (1 个 \0)
for (int i = 0; i < 1000; i++) {
    strcat(buf, "a");                   // 每次 strcat 都要 O(n) 找到末尾 \0
}
1
2
3
4

这段代码的总时间复杂度:

第 1 次:strlen(buf) = 0   → 找 \0 0 步
第 2 次:strlen(buf) = 1   → 找 \0 1 步
第 3 次:strlen(buf) = 2   → 找 \0 2 步
...
第 1000 次:strlen(buf) = 999 → 找 \0 999 步

总计:0+1+2+...+999 ≈ 500,000 步
1
2
3
4
5
6
7

而如果 C 字符串存了长度字段,这个循环是 O(n) 总时间。这就是为什么"循环中用 strcat 拼接字符串"是 C 性能面试题的经典陷阱。

实测对比(10000 次拼接一个字符):

# strcat 逐次拼接
$ time ./strcat_naive
real    0m2.847s         ← O(n²),逐次 O(n) 找末尾

# 手动维护尾指针
$ time ./strcat_fast
real    0m0.003s         ← O(n),始终知道末尾在哪
1
2
3
4
5
6
7

修复办法——手动维护写指针:

char buf[4096];
char* p = buf;
for (int i = 0; i < 1000; i++) {
    *p++ = 'a';
}
*p = '\0';               // 只写一次 \0
1
2
3
4
5
6

或者用 snprintf 的返回值(返回"如果 buf 够大会写入多少字节")来追踪末尾位置(第 7.1 节详述)。

# 3.4 二进制安全之痛

因为 \0 被当作字符串终结符,包含 \0 的任何数据都不能用 C 字符串 API 处理:

// 场景:网络包中取一个 32 字节的 HMAC 摘要
uint8_t hmac[32];                // 二进制数据,很可能包含 0x00 字节
recv(sock, hmac, 32, 0);

// ❌ 错误做法
printf("hmac: %s\n", hmac);      // 输出在第一个 0x00 处截断
size_t len = strlen(hmac);        // len 不一定是 32!

// ✅ 正确做法 —— 用显式长度的 API
fwrite(hmac, 1, 32, stdout);     // 写入正好 32 字节
print_hex(hmac, 32);             // 转成 hex 字符串再打印
1
2
3
4
5
6
7
8
9
10
11

二进制安全(binary-safe) 的定义:字符串操作函数不依赖 \0 终止符,而是显式传入长度。C 标准库的 mem* 系列(memcpy、memset、memcmp、memchr)就是二进制安全的。

这直接催生了 Redis 的核心数据结构 SDS(Simple Dynamic String),我们将在第 7.3 节深入剖析。

# 4. 字面量栈数组

# 4.1 两条写法天壤之别

这是 C 语言最容易被误解的一道选择题:

char *p = "hello";      // ← 写法 A:指针指向字面量
char a[] = "hello";     // ← 写法 B:数组初始化为字面量的副本
1
2

它们在内存里的样子:

写法 A: char *p = "hello";

 栈/数据段                 .rodata 段 (只读)
┌─────────┐              ┌──┬──┬──┬──┬──┬──┐
│   p     │──────────────│ h│ e│ l│ l│ o│ \0│
│ (8字节) │              └──┴──┴──┴──┴──┴──┘
└─────────┘              如果你写 p[0]='H' → SIGSEGV

写法 B: char a[] = "hello";

 栈 (局部变量 a)
┌──┬──┬──┬──┬──┬──┐
│ h│ e│ l│ l│ o│ \0│    ← 编译时把 rodata 的 "hello" 拷贝到栈上
└──┴──┴──┴──┴──┴──┘    如果写 a[0]='H' → 安全,a 在栈上可写
1
2
3
4
5
6
7
8
9
10
11
12
13
14

汇编视角最能说明问题:

// test.c
void f() {
    char *p = "hello";
    char a[] = "hello";
}
1
2
3
4
5
# gcc -S -O2 test.c
f:
    # 写法 A:把 rodata 的地址赋给 p
    lea     rax, .LC0          # .LC0 是 rodata 中 "hello" 的地址
    mov     QWORD PTR [rsp+8], rax   # p = .LC0 的地址

    # 写法 B:把 rodata 的 "hello" 拷贝到栈上
    mov     eax, DWORD PTR .LC0[rip]       # 拷贝 "hell" (4 字节)
    mov     DWORD PTR [rsp], eax
    movzx   eax, WORD PTR .LC0[rip+4]      # 拷贝 "o\0" (2 字节)
    mov     WORD PTR [rsp+4], ax
    ret

.section .rodata
.LC0:
    .string "hello"             # "hello\0" 在只读段
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

关键洞察:

  • 写法 A 的 p 是一个指针变量(8 字节在栈),其值是 .rodata 段的地址
  • 写法 B 的 a 直接就是栈上的 6 字节数组,编译期把 .rodata 的 6 字节 memcpy 到栈上
  • 写法 A 的 sizeof(p) = 8(指针大小),写法 B 的 sizeof(a) = 6(数组大小含 \0)

# 4.2 rodata去重池化揭秘

疑惑:同一个程序中多次写 "hello",会生成多份副本吗?

论证:

const char* s1 = "hello";
const char* s2 = "hello";
const char* s3 = "hello world" + 6;  // 指向 "world"
1
2
3

编译器(GCC/Clang 默认开启 -fmerge-constants)会做字符串池化:

$ gcc -S test.c && grep LC test.s
    leaq    .LC0(%rip), %rax    # s1 → .LC0
    leaq    .LC0(%rip), %rax    # s2 → 同一个 .LC0!
    leaq    .LC0+6(%rip), %rax  # s3 → .LC0 偏移 6 = "world"
# 只有一份 .LC0: "hello\0world\0"(合并 + 去重)
1
2
3
4
5

GCC 的字符串池化策略(gcc/varasm.c 源码逻辑):

所有字符串字面量 → 去重(相同字符串只存一份)
                   ↓
  如果 -fmerge-constants 开启 → 尝试前缀合并
  例如 "hello" 和 "hello world" → 合并为 "hello world\0"
                                "hello" 指向偏移 0
                                "world" 指向偏移 6
1
2
3
4
5
6

陷阱:

char* p = "hello";
// 如果整个程序的字符串都被合并成一个巨大的 rodata 块,
// p 的"邻居"可能是一个完全不相关的字符串。
// 这时候如果 p 被当成可变 string 写了(UB),
// 破坏的不只是 "hello",还可能是其他字符串。
1
2
3
4
5

-fno-merge-constants 可以关闭去重,但一般不需要——你不应该写 rodata,所以去重对正常程序是纯收益。

# 4.3 修改字面量为何崩

char* p = "hello";
p[0] = 'H';          // 💀 未定义行为 → 大概率 SIGSEGV
1
2

这个崩溃的完整链路:

1. 编译期:"hello" 落在 ELF 的 .rodata 节
2. 加载期:.rodata 被 mmap 到进程地址空间,权限为 r-- (只读)
3. 运行时:p[0] = 'H' 翻译成 mov BYTE PTR [rax], 'H'
4. MMU 查页表:发现该页权限位没有 W(写)标志
5. CPU 触发 #PF (页错误),内核检查 VMA → 写只读页 → 非法
6. 内核发 SIGSEGV → 进程崩溃
1
2
3
4
5
6

为什么有的平台不崩?

在某些嵌入式平台(没有 MMU)上,.rodata 和 .text 没有页级保护,写字面量可能不崩,但仍然是 UB——另一个变量如果恰好被分配到同一物理地址,就被静默破坏了。

历史遗留:C 语言标准将字符串字面量的类型定义为 char[N](不是 const char[N]),这是为了兼容 1970 年代的 K&R 代码——当时连 const 关键字都没发明。C++ 修正了这一点:字面量类型是 const char[N],写它连编译都过不了。

// C++:
char* p = "hello";   // ❌ 编译错误:const char[6] 不能转 char*
const char* p = "hello"; // ✅
1
2
3

# 4.4 编译期常量折叠

更高级的优化:

const char* s = "hello" " " "world";   // 编译期拼接 → "hello world"
// ISO C 规定:相邻的字符串字面量自动拼接
1
2

这常用于跨行字符串:

const char* sql =
    "SELECT id, name, amount "
    "FROM transactions "
    "WHERE date > '2024-01-01' "
    "ORDER BY id DESC;";
// 等价于一个长字符串,没有运行时拼接开销
1
2
3
4
5
6

编译器还会做常量传播:

if ("hello"[0] == 'h') {   // 编译期求值,"hello"[0] 直接换成 'h'
    do_something();         // if ('h' == 'h') → 编译期优化成无条件调用
}
1
2
3

# 5. 缓冲区溢出根因分析

# 5.1 strcpy的死亡证明

strcpy 是我们将详细解剖的第一个"危险函数":

char* strcpy(char* dest, const char* src);
// 把 src 的字节(包括 \0)拷贝到 dest
// 前提:dest 有足够的空间
// 问题:谁保证了 dest 有足够空间?——没有人
1
2
3
4

溢出示例:

void handle_packet(const char* user_input) {
    char filename[32];
    strcpy(filename, user_input);  // ← user_input 可能 1000 字节
    open_file(filename);
}
1
2
3
4
5

溢出发生时栈帧的样子:

高地址
┌────────────────────────────────┐
│  调用者的栈帧                    │
│  ... 返回地址 ...               │  ← 溢出可能覆盖这里 → 控制流劫持
├────────────────────────────────┤
│  保存的 RBP                     │  ← 溢出可能覆盖这里
├────────────────────────────────┤  ← RBP
│  local int x                    │
├────────────────────────────────┤
│  filename[32]                   │  ← strcpy 从低地址向高地址写
│  ┌──┬──┬──┬──┬──┬──┬──┬──...──┐│
│  │ h │ e │ l │...│ 超 │ 出 │ 部 ││ ← 溢出!继续往上写
│  └──┴──┴──┴──┴──┴──┴──┴──...──┘│    踩到栈上的其他数据
├────────────────────────────────┤  ← filename 起始地址
│  canary (8B)                    │  ← 编译器插入的金丝雀值
└────────────────────────────────┘
低地址
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

为什么 strcpy 必须被"开除":

属性 strcpy 现代替代
长度检查 ❌ 无 strlcpy (长度参数)
截断行为 ❌ 不截断 snprintf (截断)
\0 保证 ✅ 会写(如果 dest 够大) strlcpy (始终保证)
C11 标准 保留但标注危险 strcpy_s (边界检查接口)
编译器警告 -Wstringop-overflow 大多数安全替代免警告

# 5.2 strcat叠加灾难

strcat 的问题比 strcpy 更隐蔽——它需要先找到目标字符串的 \0:

char* strcat(char* dest, const char* src);
// 1. 扫描 dest 找到 \0
// 2. 从那个位置开始,把 src 拷贝过去(包括 \0)
1
2
3

双重风险:

char buf[16] = "hello";           // buf = "hello\0" + 10 字节未定义
strcat(buf, " world of pain");   // 写入 17 字节(含 \0)
// "hello" = 5," world of pain" = 17 → 总计 22 字节 > 16
// 溢出 6 字节
1
2
3
4

更阴险的场景——未初始化缓冲区:

void log_it(const char* msg) {
    char buf[256];                    // ⚠️ 没有初始化!
    strcpy(buf, "[LOG] ");           // 写入 "[LOG] \0"
    strcat(buf, msg);                // 从 \0 继续追加,安全
    // ... 但如果 buf 没初始化且后续代码跳过了 strcpy ...
}
1
2
3
4
5
6

组合灾难:

char buf[64];
strcpy(buf, argv[1]);                // 危险1:无长度检查
strcat(buf, argv[2]);                // 危险2:从 \0 追加,继续无检查
strcat(buf, ".config");              // 危险3:累积溢出
// argv[1] = 50 字节,argv[2] = 20 字节,总计 50 + 20 + 7 = 77 > 64
1
2
3
4
5

修复:用 snprintf 一次性构造,或者用显式传递当前长度的方式(第 7 章)。

# 5.3 sprintf格式炸弹

sprintf 在三个维度上都不安全:

int sprintf(char* str, const char* format, ...);
// 1. 不检查 str 的缓冲区大小
// 2. format 如果是用户可控的 → 格式字符串漏洞
// 3. 返回值是"写入的字节数",但溢出时返回值同样没有意义
1
2
3
4

经典格式字符串攻击:

void unsafe_log(const char* user) {
    char buf[256];
    sprintf(buf, user);   // 💀 如果 user 是 "%x%x%x%x",会泄露栈内容
                          //    如果 user 是 "%n",会写任意地址
}
1
2
3
4
5

正确做法:

snprintf(buf, sizeof(buf), "%s", user);  // user 只是一条数据,不是格式
1

sprintf 的返回值陷阱:

int written = sprintf(buf, "%s", some_string);
// 如果 some_string 太长导致溢出,written 的值是"如果 buf 够大会写入的字节数"
// 但这个值可能 > sizeof(buf) —— 你拿到的是"已写作废的数字"
1
2
3

对比 snprintf:

int needed = snprintf(buf, sizeof(buf), "%s", some_string);
// needed = 如果 buf 无限大会写入的字节数(不含 \0)
// 你可以用这个值判断是否发生了截断:
if (needed >= (int)sizeof(buf)) {
    // 发生截断!some_string 的尾部没写进去
}
1
2
3
4
5
6

# 5.4 gets为什么被开除

gets 在 C11 标准中被正式移除——这在标准的演进中非常罕见:

// C99: gets 还在,但手册上写"永远别用"
// C11: gets 被删除,彻底不能用

char* gets(char* s);  // ← 已从标准中移除
// 从 stdin 读一行,存到 s
// 问题:没有传入 s 的大小 → 无论多长的输入都往里写
1
2
3
4
5
6

传奇漏洞——Morris Worm (1988): 世界上第一个互联网蠕虫正是利用了 gets——fingerd 守护进程用 gets 从网络读取输入,攻击者发送超长字符串覆盖返回地址,跳转到 shellcode。结果:1988 年 11 月 2 日,互联网 10% 的机器瘫痪。

现代替代:

char buf[256];
fgets(buf, sizeof(buf), stdin);   // ✅ 长度限制,永远只读 sizeof(buf)-1
// 注意:fgets 会把 \n 也读进去(如果缓冲区够大),你可能需要手动 strip
1
2
3

# 5.5 溢出后的栈帧惨状

回看第 1 章的案例,我们用一个"溢出攻击模拟"来展示溢出到底做了什么:

#include <string.h>
#include <stdio.h>

void innocent() {
    printf("innocent: I should never be called!\n");
}

void victim(char* input) {
    char buf[16];
    strcpy(buf, input);           // ← 溢出在此发生
    printf("victim: buf = %s\n", buf);
}

int main() {
    char payload[64];
    memset(payload, 'A', 16);     // 填满 buf
    *(void**)(payload + 24) = innocent;  // 覆盖返回地址 → 指向 innocent
    victim(payload);
    // 输出:victim: buf = AAAAAAAAAAAAAAAA
    //      innocent: I should never be called!
    // ← 控制流被劫持了!
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

栈帧在 strcpy 前后的变化:

strcpy 前:                      strcpy 后(溢出):
┌──────────────────────┐         ┌──────────────────────┐
│  main 的栈帧          │         │  main 的栈帧          │
├──────────────────────┤         ├──────────────────────┤
│  返回地址 = main+8    │         │  返回地址 = &innocent │ ← 被覆盖!
├──────────────────────┤         ├──────────────────────┤
│  旧 RBP              │         │  'AAAA'              │ ← 被覆盖!
├──────────────────────┤         ├──────────────────────┤
│  buf[12..15]         │         │  'AAAA'              │
│  buf[8..11]          │         │  'AAAA'              │
│  buf[4..7]           │         │  'AAAA'              │
│  buf[0..3]           │         │  'AAAA'              │
└──────────────────────┘         └──────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13

现代防护 (GCC 默认开启):

  • -fstack-protector-strong → canary 检测
  • -Wstack-protector → 警告哪些函数被插了 canary
  • ASLR → 攻击者不知道 innocent 的真实地址

但 canary 不是万能的——它只能检测"覆盖返回值的溢出"。如果溢出只覆盖了本地变量(没越过 canary),canary 检测不到。

# 6. strncpy 的陷阱

# 6.1 名为安全实为陷阱

strncpy 的名字让很多人误以为它是 strcpy 的"安全版",但真相是——strncpy 最初是为固定宽度的文件记录设计的,不是为 C 字符串安全设计的。

char* strncpy(char* dest, const char* src, size_t n);
// 从 src 拷贝最多 n 个字节到 dest
// 行为(关键!):
//   如果 strlen(src) < n:dest 的剩余字节全部填充 \0
//   如果 strlen(src) >= n:dest 的前 n 字节被覆盖,但 dest[n] 不写入 \0!
1
2
3
4
5

核心陷阱:strncpy 不保证 dest 是一个合法的 C 字符串(以 \0 结尾)。

# 6.2 不写\0的五种场景

char small[5];

/* 场景 1:src 正好等于 n */
strncpy(small, "hello", 5);   // small = "hello" —— 没有 \0!
small[4] = 'o';               // strlen(small) 会读越界

/* 场景 2:src 大于 n */
strncpy(small, "hello world", 5);  // small = "hello" —— 没有 \0!

/* 场景 3:忘加 \0 的惯用错误 */
char name[32];
strncpy(name, "John", 31);    // 正确:strlen("John")=4 < 31,name 余下 27 字节都被填 \0
strncpy(name, "Doe", 3);      // 错误:只覆盖前 3 字节,第 4 字节仍是 'n'!
                               // name = "Doe\0n\0\0..." → "Doen"??

/* 场景 4:你以为安全其实不安全 */
char username[64];
strncpy(username, user_input, sizeof(username));
// 还在心存侥幸 sizeof(username) = 64,如果 user_input 是 64 字节 → username 最后没有 \0!
// strlen(username) 会跑到 username[64] 之后,读越界

/* 场景 5:和 strlen 配合的灾难 */
char path[256];
strncpy(path, base, sizeof(path) - 1);
path[sizeof(path) - 1] = '\0';    // 正确写法——手动保证末尾 \0
size_t len = strlen(path);        // 现在安全了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

正确使用 strncpy 的完整咒语:

strncpy(dest, src, dest_size - 1);
dest[dest_size - 1] = '\0';
1
2

但这样一来你已经在做 strlcpy 做的事了——多了两次操作且更易错。下面会讲 strlcpy 是最佳选择。

# 6.3 零填充的性能灾难

strncpy 最坑人的性能问题:

char buf[4096];
strncpy(buf, "hi", sizeof(buf)); // buf 的前 2 字节 = "hi",后 4094 字节全部填 \0
1
2

这意味着:每次拷贝一个短字符串,strncpy 都会把目标缓冲区的剩余空间全部置零。如果你在一个 4 KB 的缓冲区内频繁拷贝短字符串,性能会暴跌:

# strcpy 拷贝 4 字节
$ time ./strcpy_test           # ~0.01s

# strncpy 拷贝 4 字节到 4KB 缓冲区
$ time ./strncpy_test          # ~0.15s —— 慢了 15 倍!因为写了 4096 字节
1
2
3
4
5

什么时候用 strncpy:

  • 写入固定宽度的二进制记录(这正是它的原始用途)
  • 你已经明确知道 dest 是一个固定长度的结构体字段

什么时候绝不用 strncpy:

  • 任何以 \0 结尾的 C 字符串操作
  • 不确定 dest 大小的情况

# 6.4 源码级行为解剖

strncpy 在 glibc 中的实现逻辑(简化):

char* strncpy(char* dest, const char* src, size_t n) {
    size_t i;
    for (i = 0; i < n && src[i] != '\0'; i++)
        dest[i] = src[i];
    for ( ; i < n; i++)
        dest[i] = '\0';          // ← 零填充的根源
    return dest;
}
1
2
3
4
5
6
7
8

注意这两个独立的 for 循环——第一个循环在 src 有 \0 时终止,第二个循环把剩余的 dest 全部置零。这就是为什么它不适合通用字符串拷贝。

与 strlcpy(BSD 风格)对比:

size_t strlcpy(char* dest, const char* src, size_t size) {
    size_t srclen = strlen(src);
    if (size > 0) {
        size_t copy_len = (srclen >= size) ? size - 1 : srclen;
        memcpy(dest, src, copy_len);
        dest[copy_len] = '\0';    // ← 始终保证 \0 结尾
    }
    return srclen;                // ← 返回 src 的完整长度,便于检测截断
}
1
2
3
4
5
6
7
8
9

关键区别:

  • strlcpy 始终保证 \0 结尾(如果 size > 0)
  • strlcpy 不填充剩余空间——性能友好
  • strlcpy 返回值是 src 的完整长度,方便调用者检测是否发生截断

# 7. 安全字符串操作方案

# 7.1 snprintf一把梭

sprintf 是地雷,snprintf 是排雷工具——但你自己还得把它用对:

int snprintf(char* str, size_t size, const char* format, ...);
// 核心承诺:
//   - 最多写入 size-1 个有效字符到 str
//   - 始终以 \0 结尾(即使被截断)
//   - 返回"如果 str 无限大会写入的字符数"(不含 \0)
1
2
3
4
5

snprintf 的"瑞士军刀"用法:

char buf[128];
int needed;

/* 1. 安全拼接路径 */
needed = snprintf(buf, sizeof(buf), "%s/%s/%s", base, dir, file);
if (needed >= (int)sizeof(buf)) {
    // 发生了截断!路径 > 127 字节被丢弃
    fprintf(stderr, "path too long: needed %d bytes\n", needed);
}

/* 2. 两次调用的经典模式:先预测大小,再分配 */
needed = snprintf(NULL, 0, "%s/%s", base, file) + 1;  // +1 for \0
char* dynamic = malloc(needed);
snprintf(dynamic, needed, "%s/%s", base, file);        // 确保不截断

/* 3. 连续追加(利用返回值追踪当前长度) */
char* p = buf;
size_t remain = sizeof(buf);
int n;

n = snprintf(p, remain, "[%d] ", level);
p += n; remain -= n;

n = snprintf(p, remain, "%s: ", module);
p += n; remain -= n;

n = snprintf(p, remain, "%s", msg);
// p += n;  // 可选
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

snprintf 的暗坑:

  1. glibc 2.0 之前的 bug:旧版本 snprintf 不保证 \0 结尾。生产环境确保 glibc ≥ 2.1。

  2. Windows 的 _snprintf 不兼容:_snprintf 在截断时不保证 \0 结尾!始终用 _snprintf_s 或 StringCchPrintf。

  3. 性能:snprintf 要解析格式字符串,比 memcpy 慢——不要在高频路径用它拷贝简单字符串。

  4. 格式字符串中的 %s 如果参数是 NULL:行为未定义。始终保证传给 %s 的指针非空。

# 7.2 strlcpy与strlcat

BSD 系的 strlcpy/strlcat 是 C 语言社区公认的"最接近正确答案的字符串操作函数":

size_t strlcpy(char* dst, const char* src, size_t dstsize);
size_t strlcat(char* dst, const char* src, size_t dstsize);

// 语义:
//  strlcpy:最多拷贝 dstsize-1 个字符到 dst,始终 \0 结尾
//  strlcat:最多追加 dstsize-strlen(dst)-1 个字符,始终 \0 结尾
//  返回值:src 的完整长度(用于检测截断)
1
2
3
4
5
6
7

对比一图胜千言:

函数 \0 保证 零填充 截断检测 性能 标准
strcpy ✅(假设够大) ❌ ❌ 快 C89
strncpy ❌(不保证) ✅(严重) ❌ 慢 C89
snprintf ✅ ❌ ✅(返回值) 中 C99
strlcpy ✅ ❌ ✅(返回值) 快 BSD
strcpy_s ✅ ❌ ✅(errno) 快 C11 Annex K

strlcpy 使用示例:

char buf[64];
size_t needed = strlcpy(buf, user_input, sizeof(buf));
if (needed >= sizeof(buf)) {
    // 截断发生!user_input 的后面部分被丢弃了
    log_truncation(user_input, sizeof(buf));
}
1
2
3
4
5
6

但 strlcpy 不在 POSIX 标准中——它在 BSD 和 macOS 上原生支持,在 Linux 上需要自行实现或链接 libbsd:

# Linux 上使用 strlcpy
$ sudo apt install libbsd-dev
$ gcc -o prog prog.c -lbsd
1
2
3

或嵌入自己的实现(只有 4 行):

size_t my_strlcpy(char* dst, const char* src, size_t size) {
    size_t srclen = strlen(src);
    if (size) {
        size_t len = srclen >= size ? size - 1 : srclen;
        memcpy(dst, src, len);
        dst[len] = '\0';
    }
    return srclen;
}
1
2
3
4
5
6
7
8
9

# 7.3 动态字符串与sds

Redis 的作者 antirez 设计了一套二进制安全的动态字符串 SDS(Simple Dynamic String),直接解决了 C 字符串的所有痛点:

// SDS 的内存布局(sdshdr5/8/16/32/64,根据长度自适应)
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len;        // 已用长度(不含 \0)
    uint64_t alloc;      // 分配的总容量(不含 \0 和 header)
    unsigned char flags; // 头部类型标记
    char buf[];          // 柔性数组,存放实际字符串 + \0
};
// sds 字符串 = buf 的首地址(兼容所有 C 字符串 API!)
1
2
3
4
5
6
7
8

SDS 如何根除 C 字符串的七大罪状:

C 字符串问题 SDS 如何解决
O(n) 取长度 len 字段 → O(1)
缓冲区溢出 自动扩容,sdscat 之前检查容量
非二进制安全 len 存实际长度,不依赖 \0
内存泄漏 与 Redis 的内存分配器配合,显式 sdsfree
频繁分配 预分配策略:扩容时多申请一倍,减少 realloc
碎片 惰性空间释放(sdstrim 只改 len 不改 alloc)
与 C API 互操作 buf 末尾始终有额外 \0,可以传给 printf

SDS 的分配策略(核心性能优化):

// sds.c 中的扩容逻辑(简化)
sds sdsMakeRoomFor(sds s, size_t addlen) {
    size_t avail = sdsavail(s);
    if (avail >= addlen) return s;       // 已有空间够用

    size_t newlen = sdslen(s) + addlen;
    if (newlen < SDS_MAX_PREALLOC)       // 小于 1 MB
        newlen *= 2;                      // 翻倍扩容
    else
        newlen += SDS_MAX_PREALLOC;      // 大于 1 MB,每次加 1 MB

    // realloc...
    // 更新 alloc 字段...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

启示:如果你在 C 项目中大量操作字符串(解析协议、构建 JSON、日志拼接),实现一个简化版 SDS 是投资回报率最高的重构——50 行代码换来缓冲区溢出的零风险。

# 7.4 编译器内置防护

现代编译器提供了多层字符串安全检查,把它们全部打开:

GCC/Clang 编译选项:

# 基础防护(生产必须)
-O2
-Wall -Wextra
-Wformat=2                        # 格式字符串漏洞检测
-Wformat-security                 # 格式字符串安全
-Wstringop-overflow=4             # 字符串操作溢出检测
-Warray-bounds=2                  # 数组越界
-fstack-protector-strong          # Stack canary
-D_FORTIFY_SOURCE=2               # 运行时检测(注入 __memcpy_chk 等)
-fsanitize=address                # ASan(开发/测试环境)
1
2
3
4
5
6
7
8
9
10

_FORTIFY_SOURCE 是最强编译器级防线:

// 当编译时加上 -D_FORTIFY_SOURCE=2 时:
// strcpy(dest, src) 被替换为:

char* __strcpy_chk(char* dest, const char* src, size_t destlen) {
    // 如果编译器能推断 dest 的大小(如 char dest[32]),
    // destlen 在编译期就是 32
    if (strlen(src) >= destlen) {
        __chk_fail();   // ← 直接 abort,而不是溢出后让程序继续跑
    }
    return strcpy(dest, src);  // 长度已检查,安全
}
1
2
3
4
5
6
7
8
9
10
11

效果对比:

#include <string.h>
void test(char* user) {
    char buf[16];
    strcpy(buf, user);         // 不加 FORTIFY: 静默溢出
}
1
2
3
4
5
$ gcc -O2 -D_FORTIFY_SOURCE=2 -c test.c
# 编译器判断 buf 大小为 16,注入 __strcpy_chk(buf, user, 16)
# 运行时:如果 strlen(user) >= 16 → 直接 abort,不溢出
# 效果:溢出变崩溃,崩溃比静默错误好
1
2
3
4

# 7.5 静态分析与Sanitizer

AddressSanitizer (ASan):运行时在每次字符串操作前后插入"影子内存"检查:

$ gcc -fsanitize=address -g -o test test.c
$ ./test
=================================================================
==12345==ERROR: AddressSanitizer: stack-buffer-overflow
    #0 0x... in strcpy
    #1 0x... in handle_input test.c:42
Address 0x7fff... is located in stack of thread T0
    This frame has 1 object(s):
    [32, 48) 'buf' (line 40) <== Memory access at offset 48 overflows
                                       this variable
1
2
3
4
5
6
7
8
9
10

ASan 的报告精确到哪个变量的哪个字节被溢出覆盖了。

静态分析工具:

# Clang Static Analyzer
$ scan-build gcc -c file.c

# Coverity / CodeQL (CI 集成)
# 自动扫描 strcpy/strcat/sprintf/gets 并标记为缺陷
1
2
3
4
5

生产环境检查清单:

# 1. 确保编译器防护全开
$ gcc -O2 -Wall -Wextra -Wformat=2 -D_FORTIFY_SOURCE=2 \
      -fstack-protector-strong -Warray-bounds=2 ...

# 2. CI 中跑静态分析
$ scan-build make

# 3. 测试环境跑 ASan
$ gcc -fsanitize=address -g ...

# 4. 检查二进制安全特性
$ checksec --file=./binary
    STACK CANARY   : yes
    FORTIFY        : yes
    NX             : yes
    PIE            : yes
    RELRO          : full
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 8. ASCII到Unicode编码演进

# 8.1 ASCII的128个格子

ASCII (American Standard Code for Information Interchange, 1963) 用 7 bits 表示 128 个字符:

0x00-0x1F:  控制字符 (NUL, LF, CR, TAB, ESC...)
0x20:       SPACE (空格)
0x30-0x39:  '0'-'9' (十进制 48-57)
0x41-0x5A:  'A'-'Z' (十进制 65-90)
0x61-0x7A:  'a'-'z' (十进制 97-122)
0x7F:       DEL (删除)
1
2
3
4
5
6

关键事实:

  • 'A' 和 'a' 的二进制差异只有 第 5 位(0x20)
  • '0' 不是数字 0,是 0x30(48)——'0' - 48 = 0
  • C 语言的 \0 就是 ASCII 的第一个字符 NUL (0x00)

ASCII 时代的 C 代码:

char c = 'A';          // c = 65
c += 32;               // c = 97 = 'a'(大小写转换)
if (c >= '0' && c <= '9') {  // 判断数字
    int digit = c - '0';      // '5' - '0' = 5
}
1
2
3
4
5

ASCII 对英语完美,但对 é(法语)、ß(德语)、ñ(西班牙语)、ø(挪威语)……128 个格子根本不够。

# 8.2 多字节编码战国时代

ASCII 不够用,各国各自扩展:

Latin-1 (ISO 8859-1):用满 8 bits→256 个字符,覆盖西欧拉丁字母。

GB2312 (1980, 中国):用 2 字节表示一个汉字:

字节 1: 0xA1-0xF7(高字节)
字节 2: 0xA1-0xFE(低字节)
例: "中" = 0xD6 0xD0
1
2
3

Shift_JIS (日本):另一种双字节编码。

EUC-KR (韩国):另一种双字节编码。

问题:同一个字节序列,在不同编码下是完全不同的字符:

// 一个字节序列:0xD6 0xD0
// GB2312 解释为: "中"
// Latin-1 解释为: "ÖÐ" (两个西欧字符)
// 程序怎么知道该用哪个编码?——不知道,除非你在"带外"传编码名
1
2
3
4

这就是"乱码"的根本原因——字节序列没有自描述编码的能力。后来又出现了 BOM (Byte Order Mark) 等带外标记,但始终治标不治本。

# 8.3 Unicode统一字符集

Unicode 的目标:给全世界每一个字符一个唯一的数字(码点,code point)。

U+0000 - U+007F:  ASCII (完全兼容)
U+0080 - U+00FF:  Latin-1 补充
U+4E00 - U+9FFF:  中日韩统一表意文字 (CJK,最常用的 ~20000 汉字)
U+1F600 - U+1F64F: Emoji 😀😁😂...
1
2
3
4

Unicode 码点范围:

范围 名称 容量
U+0000 - U+FFFF BMP(基本多语言平面) 65536 个
U+10000 - U+10FFFF 辅助平面(16 个) ~1,000,000 个

码点 ≠ 编码:Unicode 规定了"哪个数字代表哪个字符",但没有规定"怎么把数字写成字节"。这需要具体的编码方案——UTF-8、UTF-16、UTF-32。

# 8.4 UTF-8的变长编码艺术

UTF-8 是 Ken Thompson 在 1992 年设计的——对,就是那个写 Unix、B 语言的 Ken Thompson。它是 Unicode 最成功的编码方案:

码点范围 UTF-8 字节数 字节模板
U+0000 - U+007F 1 0xxxxxxx
U+0080 - U+07FF 2 110xxxxx 10xxxxxx
U+0800 - U+FFFF 3 1110xxxx 10xxxxxx 10xxxxxx
U+10000 - U+10FFFF 4 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

UTF-8 编码实例:

"A"  (U+0041) → 0x41                        (1 字节,与 ASCII 完全一样)
"é"  (U+00E9) → 0xC3 0xA9                   (2 字节)
"中" (U+4E2D) → 0xE4 0xB8 0xAD             (3 字节)
"😀" (U+1F600)→ 0xF0 0x9F 0x98 0x80       (4 字节)
1
2
3
4

UTF-8 设计的精妙之处:

  1. ASCII 完全兼容:任何 ASCII 字符串都是合法的 UTF-8。strcmp、strstr、strchr 在绝大多数场景下对 UTF-8 字符串同样工作。

  2. 自同步:从任意字节开始,最多往前找 3 字节就能定位到一个字符的起始。数据损坏不会导致无限前向错误。

  3. 字节序无关:不需要 BOM 来标记大小端——UTF-8 天然没有字节序问题。

  4. C 语言的适配性:char* 天然就是 UTF-8 字符串的载体——strlen 返回的是字节数,不是字符数。

UTF-8 在 C 中的陷阱:

char* s = "你好";         // UTF-8: 0xE4 0xBD 0xA0 0xE5 0xA5 0xBD

strlen(s);               // = 6 —— 2 个汉字 × 3 字节 = 6
                         // ☠️ 不是 2 个"字符"!

s[1];                    // = 0xBD —— 这是"你"的中间字节
                         // ☠️ 不是一个完整的"字符"!

// 截断"你好"到 4 字节:
char buf[5];
memcpy(buf, s, 4);
buf[4] = '\0';
printf("%s\n", buf);     // 输出"你"后面跟一个乱码
                         // 因为"好"被截成两半了
1
2
3
4
5
6
7
8
9
10
11
12
13
14

UTF-8 安全截断:

size_t utf8_truncate(char* s, size_t max_bytes) {
    if (strlen(s) <= max_bytes) return strlen(s);
    size_t len = max_bytes;
    // 从截断点往前找,确保不在多字节字符的中间
    while (len > 0 && (s[len] & 0xC0) == 0x80) {
        len--;  // 跳过连续字节(10xxxxxx)
    }
    s[len] = '\0';
    return len;
}
1
2
3
4
5
6
7
8
9
10

# 8.5 C语言的wchar_t与char16_t

C 语言的宽字符类型试图在类型系统层面支持多字节字符:

#include <wchar.h>

wchar_t wc = L'中';       // 宽字符字面量,前缀 L
wchar_t ws[] = L"你好";   // 宽字符串字面量

size_t len = wcslen(ws);  // = 2(在大多数平台上)
1
2
3
4
5
6

wchar_t 的可移植性噩梦:

平台 wchar_t 大小 编码 wcslen(L"中")
Linux/x86-64 4 字节 UTF-32 1
Windows/x86-64 2 字节 UTF-16 1
macOS/x86-64 4 字节 UTF-32 1
某些嵌入式 2 字节 UTF-16 1

结论:wchar_t 不能写可移植的代码。如果需要遍历 Unicode 字符,用第三方库(如 ICU、utf8proc)或 C11 的 char16_t/char32_t:

#include <uchar.h>        // C11

char16_t u16 = u'中';     // UTF-16,始终 2 字节(在 BMP 中)
char32_t u32 = U'😀';     // UTF-32,始终 4 字节

char16_t s16[] = u"你好"; // UTF-16 字符串
char32_t s32[] = U"你好"; // UTF-32 字符串
1
2
3
4
5
6
7

生产建议:除非你在写 Windows API(必须 UTF-16),否则统一使用 UTF-8 + char*。这是 Linux、macOS、Web 的事实标准,也是 C 语言生态中路径阻力最小的选择。

# 9. 综合案例串讲

# 9.1 案例真相揭晓

回到第 1 章 route_log 崩溃,八个疑问现在能逐条作答:

疑问 答案
① "hello" 写在进程地址空间的哪里?为什么不能改? 第 4.1/4.3:在 .rodata 段,权限 r--,写即 SIGSEGV
② char arr[] 和 char *ptr 的区别? 第 4.1:数组在栈/data 可写,指针指向 rodata 不可写
③ strcpy/strcat 为什么是定时炸弹? 第 5.1/5.2:不检查边界,长输入直接溢出
④ snprintf 以为安全,坑在哪? 第 7.1:Windows 的 _snprintf 不保证 \0;格式字符串漏洞
⑤ stack canary 怎么检测溢出? 第 5.5:函数入口存随机值,返回前校验,不一致即 abort
⑥ strncpy 加了长度参数就安全了吗? 第 6.2:不保证 \0 结尾,零填充性能灾难
⑦ \0 结尾的设计给了 C 什么? 第 3:极简实现、零成本抽象;代价是 O(n) 取长度、非二进制安全
⑧ 中文/emoji 在 C 里怎么处理? 第 8:UTF-8 变长编码,strlen("你好")=6 不是 2

第 1 章案例的真正根因:

上游 sprintf 向 64 字节 buf 写入 70 字节
  → 溢出覆盖 main 的栈帧 → canary 被篡改
    → route_log 进入时 canary 被保存
      → 返回前 canary 校验失败 → __stack_chk_fail → SIGABRT
1
2
3
4

修复方案(按代价从小到大):

方案 A:消除上游溢出(治本)

// 上游代码改造
char buf[64];
snprintf(buf, sizeof(buf), "%s_%s_%s_%s", a, b, c, d);
// 即使部分截断,也不会溢出到相邻变量
1
2
3
4

方案 B:route_log 内部全面防御

void route_log(const char* service, const char* msg) {
    char filepath[LOG_PATH_MAX];
    // 用 snprintf 一次构造,避免多次 strcat 级联
    int n = snprintf(filepath, sizeof(filepath),
                     "/var/log/bank/%s.log", service);
    if (n >= (int)sizeof(filepath)) {
        // 截断处理:记录告警,使用截断后的路径
        syslog(LOG_WARNING, "filepath truncated: service=%s (len=%zu)",
               service, strlen(service));
    }
    // ...
}
1
2
3
4
5
6
7
8
9
10
11
12

方案 C:生产级防御——FORTIFY + Stack Protector

# 编译时开启所有防护
gcc -O2 -D_FORTIFY_SOURCE=2 -fstack-protector-strong \
    -Wformat=2 -Wstringop-overflow=4 \
    -o log_router log_router.c
1
2
3
4

方案 D:动态字符串替代静态缓冲区

// 不用固定长度缓冲区,而是按需分配
char* filepath;
asprintf(&filepath, "/var/log/bank/%s.log", service);
// asprintf 是 GNU 扩展,自动分配所需大小的内存
// ...
free(filepath);
1
2
3
4
5
6

# 9.2 一份字符串的一生

把 "hello" 从一个字节序列到最终生命结束的全过程串起来:

编译期
  ├─ gcc 把 "hello" 放进 ELF 的 .rodata 节
  ├─ 去重:"hello" 如果出现多次,合并为一个副本
  └─ 常量传播:"hello"[0] 在编译期求值为 'h'

加载期 (execve)
  ├─ 内核 mmap .rodata 进进程地址空间,权限 r--
  ├─ 如果有 wchar_t 字面量,映射到对应的只读段
  └─ 此时"hello"就在内存里了,但程序还没跑到它

运行期
  ├─ char *p = "hello";
  │   └─ 栈上分配 8 字节指针,值 = rodata 中 "hello" 的地址
  ├─ char a[] = "hello";
  │   └─ 栈上分配 6 字节数组,编译期把 rodata 的 6 字节 memcpy 到栈
  ├─ p[0] = 'H' → CPU 写只读页 → MMU 触发 #PF → SIGSEGV
  └─ a[0] = 'H' → 正常写入栈,a = "Hello"

安全链(如果发生溢出)
  ├─ FORTIFY_SOURCE 在编译期注入 __strcpy_chk → 运行时长度检查
  ├─ ASan 在每次字符串操作前后检查影子内存
  ├─ Stack canary 在返回前校验栈完整性
  ├─ NX bit 阻止跳到栈上执行 shellcode
  └─ ASLR 让攻击者无法预测函数/gadget 地址

退出期
  ├─ 栈上的 char a[] 随函数返回自动释放
  ├─ rodata 中的 "hello" 随进程退出一起被内核回收
  └─ 堆上的字符串在 free 之后归还给 ptmalloc/内核
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

# 9.3 面试高频问题清单

1. char *p = "hello" 和 char a[] = "hello" 的区别?

指针 p 指向 .rodata 只读段,不可修改;数组 a 在栈(局部)或数据段(全局),是字面量的副本,可以修改。sizeof(p) = 8(指针大小),sizeof(a) = 6(数组大小含 \0)。

2. strcpy 和 strncpy 的区别?strncpy 安全吗?

strcpy 不检查目标大小,溢出即灾难。strncpy 加了长度参数但有两个致命陷阱:① 如果 src ≥ n,不保证 \0 结尾;② 如果 src < n,剩余空间全部填 \0(性能灾难)。strncpy 是为固定宽度记录设计的,不是通用的安全字符串拷贝工具。

3. 为什么 strcat 在循环里拼接字符串是 O(n²)?

每次 strcat 都要从 dest 头部扫描找 \0,循环 n 次拼接的总时间约 n(n-1)/2 步。修复:手动维护尾指针,或使用 snprintf 的返回值累积当前位置。

4. sprintf vs snprintf vs strlcpy 怎么选?

永远不用 sprintf。需要格式化的用 snprintf(标准 C99,跨平台最好)。简单字符串拷贝用 strlcpy(BSD 风格,始终 \0 结尾,返回截断信息)。绝对不用 strncpy 做字符串拷贝。

5. C 字符串为什么不能存 \0 字节?

\0 被设计为字符串终止符。printf("%s")、strlen、strcpy 等所有标准库函数都在第一次看到 \0 时停止。要存二进制数据(含 \0),必须用 mem* 系列 + 显式长度,或 SDS 等二进制安全方案。

6. strlen("你好") 在 UTF-8 下返回多少?为什么?

返回 6。"你" = 3 字节 (0xE4 0xBD 0xA0),"好" = 3 字节 (0xE5 0xA5 0xBD)。strlen 统计的是字节数,不是字符数——因为 C 语言没有"字符"的概念,只有 char。

7. \0 结尾和 Pascal 的前缀长度,各有什么优劣?

\0 结尾:长度无上限、实现极简、指针就是字符串;代价是 O(n) 取长度、不能存 \0。前缀长度:O(1) 取长度、二进制安全;代价是长度上限被前缀字段位宽限制(如 Pascal 的 1 字节前缀 → 最长 255 字符)。

8. 为什么 gets 被 C11 标准删除了?

gets 不接收缓冲区大小参数,无法阻止溢出——这是 1988 年 Morris 蠕虫的直接原因。C11 正式移除,替代为 fgets(buf, size, stdin)。

9. snprintf(NULL, 0, ...) 有什么用途?

用于先计算所需缓冲区大小:返回值是"如果 buf 无限大会写入的字节数"(不含 \0)。常见模式:snprintf(NULL, 0, fmt, ...) → 拿到长度 → malloc(len+1) → 第二次 snprintf 实际写入。

10. 如何检测 UTF-8 字符串是否被非法截断?

从截断位置往前检查每个字节的高位——如果碰到 10xxxxxx 模式,说明当前字节是多字节字符的后续字节,需要继续往前找起始字节(11xxxxxx 或 0xxxxxxx),直到找到后再截断。详见第 8.4 节的 utf8_truncate 实现。

# 9.4 安全编码速查卡

永远别用(CVE 制造机):

函数 为什么 替代
gets 无缓冲区大小 fgets
sprintf 无大小 + 格式漏洞 snprintf

谨慎使用(手写防御代码):

函数 风险 防御
strcpy 溢出 确保 strlen(src) < sizeof(dest)
strcat 溢出 + O(n²) 维护尾指针 / 用 snprintf
scanf("%s") 溢出 用 %Ns 限制宽度

推荐使用(现代安全实践):

函数 适用场景 注意
snprintf 格式化拼接 Windows 用 _snprintf_s
strlcpy 字符串拷贝 Linux 需 libbsd 或自行实现
strlcat 字符串追加 同上
fgets 读取用户输入 会保留 \n,需要 strip
memcpy 二进制数据 显式传长度,最安全的操作
asprintf 动态分配 + 格式化 GNU 扩展,需手动 free

编译器防御编译选项:

gcc -O2 \
    -Wall -Wextra \
    -Wformat=2 \
    -Wformat-security \
    -Wstringop-overflow=4 \
    -Warray-bounds=2 \
    -D_FORTIFY_SOURCE=2 \
    -fstack-protector-strong \
    -fPIE -pie \
    -o prog prog.c
1
2
3
4
5
6
7
8
9
10

60 秒诊断命令:

# 检查二进制内置了哪些安全特性
checksec --file=./binary

# 静态分析:扫描危险函数调用
grep -rn 'gets\|sprintf\|strcpy\|strcat' src/
grep -rn 'scanf("%[^n]' src/       # 潜在的无限 scanf

# ASan 跑测试
gcc -fsanitize=address -g -o test test.c
ASAN_OPTIONS=detect_stack_use_after_return=1 ./test

# 检查字符串字面量是否在只读段
readelf -x .rodata a.out | head -20

# 查看字符串池化的效果
objdump -s -j .rodata a.out | grep -A5 'hello'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

下一篇:12.文件IO缓冲与系统调用 —— 我们已经知道"字符串怎么写才安全",下一步进入 IO 层:printf 到底经过了几层缓冲才到磁盘?write 和 fwrite 的效率差在哪?mmap 零拷贝是怎么绕过内核缓冲区的?

上次更新: 2026/06/11, 09:01:44
结构体对齐与优化
预处理器宏与条件编译

← 结构体对齐与优化 预处理器宏与条件编译→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式