第 7 章 · Characters and Strings
来源: Effective C (Seacord, 2020) — Chapter 7, pp. 119–145 笔记日期: 2026-08-27
一、章节概述
- 字符编码的演化史:从 7-bit ASCII (1968) → Extended ASCII → ISO 8859-1 → Shift-JIS → EBCDIC → Unicode (1991)。ASCII 0x00-0x7F 是 Unicode U+0000-U+007F 的子集。
- Unicode 编码空间:U+0000 to U+10FFFF(21-bit),17 个 plane(每 plane 64K),基本多语言平面(BMP)= plane 0。UTF-8/16/32 是三种 Unicode 转换格式。
- UTF-8 是 POSIX 主流:1
4 字节变长,与 ASCII 兼容(0x00-0x7F 区间字节相同)。Windows UTF-16 是变长(12 个 16-bit code unit);UTF-32 是定长(4 字节),索引 O(1) 但空间 4 倍。 - BMP 之外的字符 = supplementary characters,用 surrogate pair(high U+D800-DBFF + low U+DC00-DFFF)编码。
- Source charset vs execution charset:C 实现定义了两种——源代码用什么编码写、字符串字面量用什么编码编译。Java 直接规定编码;C 不规定——这是历史包袱。
char是 signed 还是 unsigned 是 implementation-defined——可移植代码不能假设。永远用char表示字符,不用char表示小整数。char c = 'ÿ'; if (c == EOF)的陷阱:char是 signed 时ÿ(0xFF) sign-extend 到int= -1 = EOF;isdigit((unsigned char)c)是正确写法。wchar_t是大字符集类型:Linux 32-bit、Windows 16-bit。16-bit 不够代表所有 Unicode——所以 Windowswchar_t无法满足__STDC_ISO_10646__(除 Unicode 3.1 前)。char16_t/char32_t(C11<uchar.h>):专门 UTF-16 / UTF-32 数据类型;__STDC_UTF_16__/__STDC_UTF_32__环境宏标识。VS 不定义这两个宏。- 字符常量是
int而不是char(K&R 历史):'a'类型是int——和 C++ 不。前缀LuU分别对应wchar_tchar16_tchar32_t;C2x 加u8。 - Escape sequence 八进制陷阱:
'\10'是 backspace(8),不是字符'1'+'0'。'\x8'是 hex 8。同一个字符三种写法:‘\b’ / ‘\10’ / ‘\x8’。 'ab'多字符常量 implementation-defined——通常实现成 int = ‘a’<<8 | ‘b’,但不要用。- GCC 字符集 flag:
fexec-charset=/fwide-exec-charset=/finput-charset=。Clang 只允许 UTF-8——其他拒绝。 - Windows dual entry point:
main(narrow, ANSI code page) vswmain(wide, UTF-16)。argv encoding 由 shell 决定——Win process 启动时是 UTF-16,shell 转 codepage 给你。 - Win32 A/W 后缀:
MessageBoxA/MessageBoxW、CreateWindowExA/CreateWindowExW。显式选 A 或 W——不要靠自动转换。 - C 标准库字符转换函数:单字符
mbtowc/wctomb/mbrtowc/wcrtomb;批量mbstowcs/wcstombs/mbsrtowcs/wcsrtombs;C11 加mbrtoc16/c16rtomb/mbrtoc32/c32rtomb。 mbstate_t是 restartable 转换的状态对象——保存中间状态以便跨调用恢复。nonrestartable 版本内部存状态(不线程安全)。- libiconv 是 GNU 跨平台转换库——
iconv_open+iconv。Windows 有MultiByteToWideChar/WideCharToMultiByte。 - C 字符串 = 数组:
char[](narrow)或wchar_t[](wide)。没有 primitive string 类型——size≠length:size 是 backing array 字节数,length 是首个 null 之前的 code unit 数。 - 字符串字面量是只读:修改 string literal = STR30-C 违规——可能被合并到只读段或多 literal 共享存储。
- 字符串字面量前缀:
"ABC"(char) /L"ABC"(wchar_t) /u8"ABC"(UTF-8) /u"ABC"(char16_t) /U"ABC"(char32_t)。C2x 起u8""强制 UTF-8。 const char s[4] = "abcd"的陷阱:s 是 4 字节数组但 literal 是 5 字节(“abcd\0”)——s没 null 终止!编译器不报错(设计如此)。const char s[] = S_INIT(省略长度)永远安全。<string.h>函数不安全(buffer overflow 高发):strcpy/strcat/strncpy/strncat/strlen——假设 caller 已校验。- C11 Annex K bounds-checking interfaces:
strcpy_s/strcat_s/strncpy_s/strncat_s/gets_s——显式 size 参数。Microsoft 是发起者但不完全合规(VS 用旧 API)。Linux/macOS 默认不实现 Annex K。 gets是反例代表:C99 deprecated、C11 删除。没有 size 参数 = 永远不要用。gets_s是替代——但需要__STDC_WANT_LIB_EXT1__。<string.h>vs<wchar.h>对应:str→wcs(strcpy→wcscpy、strlen→wcslen);mem→wmem(memcpy→wmemcpy)。别混用。- string length 的三种”长度”:bytes / code units / code points / extended grapheme clusters(用户感知字符)。只有 bytes / code units 能用于分配存储。
strlen的实现与陷阱:for (s=str; *s; ++s); return s-str;——没有 bound 检查,传给未 null 终止的字符串 = UB;传 NULL = UB。strncpy的陷阱:不保证 null 终止!strncpy(dest, src, n)当strlen(src) >= n时 dest 没终止符。用strncpy_s或自己dest[n-1] = '\0'。memcpyvsstrcpy:strcpy 假设 src 是字符串、拷贝直到 null;memcpy 拷贝固定字节。strcpy 用于字符串,memcpy 用于 raw memory——不要互换。strcat(strcat(strcpy(...)))嵌套的低效:每次重新扫描——C2x 引入memccpy(POSIX 已存在)返回末尾指针。- POSIX
strdup/strndup:自动 malloc 返回新字符串——caller 必须 free。Windows 用_strdup。 getenv返回的字符串可能被后续调用覆盖——getenv后立刻 strdup 拷贝是 idiom。- Runtime constraints:Annex K 函数违反约束时调用 handler(默认 abort,可设
ignore_handler_s)。MSVC 用_set_invalid_parameter_handler替代。
二、核心 Takeaways
Takeaway 1: char c = 'ÿ'; if (c == EOF) 是隐藏的”字符==EOF”陷阱
- 是什么:
char是 signed 时,'ÿ'(0xFF) sign-extend 到int= -1 = EOF。循环里检查 EOF 会永远 false。 - 为什么重要:K&R 时代
getchar()返回int就是为了这个区分——char永远装不下 EOF。 - 解决什么问题:所有
<ctype.h>调用前 cast:isdigit((unsigned char)c)。 - 适用场景:所有读字符循环;所有
<ctype.h>调用。
Takeaway 2: UTF-8/32 的 O(1) 索引 vs UTF-16/UTF-8 的变长
- 是什么:UTF-32 是定长 4 字节,第 N 个 code point 直接
buf[N];UTF-8/16 变长需要扫描。 - 为什么重要:固定长度 = 索引简单、内存大;变长 = 压缩、省空间、需要边界检测。
- 解决什么问题:选择编码 = 平衡空间/性能/兼容性。Linux/macOS 选 UTF-8(兼容 ASCII);Windows 历史选 UTF-16。
- 适用场景:所有跨平台 C 代码、协议字段、序列化。
Takeaway 3: wchar_t 跨平台宽度差异是隐式 bug 源
- 是什么:Linux
wchar_t = 32-bit、Windowswchar_t = 16-bit。wcslen(L"中文")在 Windows = 2(surrogate 对)、在 Linux = 2(BMP code points)。 - 为什么重要:跨平台代码用
wchar_t几乎必出错。 - 解决什么问题:跨平台字符串用
char+ UTF-8(POSIX 标准);或char16_t/char32_t显式。 - 适用场景:所有跨平台 C/C++ 代码。
Takeaway 4: const char s[4] = "abcd" —— 数组太小导致无 null 终止
- 是什么:literal “abcd\0” 是 5 字节,s 是 4 字节——
s没 null 终止。后续strlen(s)/strcpy(s, ...)/puts(s)= 读越界 UB。 - 为什么重要:编译器不报错(设计如此:允许初始化 char 数组不是 string)。
- 解决什么问题:省略数组长度
const char s[] = S_INIT;永远安全;或用strncpy/memcpy显式管理。 - 适用场景:所有用
#define写字符串字面量的代码——GCC 会警告-Wstringop-truncation。
Takeaway 5: strncpy 不保证 null 终止——比 strcpy 还危险
- 是什么:
strncpy(dest, src, n)当strlen(src) >= n时 dest 没 null 字节。后续把它当字符串用 = UB。 - 为什么重要:名字像 “safe strcpy” 但完全不是——CERT STR32-C 明确警告。
- 解决什么问题:用
strncpy_s(C11 Annex K),或dest[n-1] = '\0'手动补 null,或snprintf(dest, n, "%s", src)。 - 适用场景:所有固定 buffer 拷贝场景。
Takeaway 6: gets 已从 C11 删除——但遗留代码到处都是
- 是什么:
gets(s)没有 size 参数,无法防止 buffer overflow——C99 deprecated,C11 删除。 - 为什么重要:任何 GitHub 老仓库里几乎都能找到
gets——永远要替换。 - 解决什么问题:用
fgets(buf, sizeof(buf), stdin),永远传 sizeof,永远不用 gets。 - 适用场景:所有 C 代码扫描;CVE 数据库里
gets相关漏洞持续 20+ 年。
Takeaway 7: Annex K strcpy_s 等安全函数——MSVC 起源但 Linux/macOS 不实现
- 是什么:C11 Annex K 提供
strcpy_s/strcat_s/strncpy_s/strncat_s/gets_s等显式带 size 参数版本。 - 为什么重要:MSVC 是发起者(90 年代安全事件推动),但Microsoft 不完全合规(用旧
_set_invalid_parameter_handler)。Linux/macOS 默认不实现——__STDC_WANT_LIB_EXT1__不工作。 - 解决什么问题:跨平台安全字符串处理仍要自己写 wrapper;或依赖 POSIX
strdup/strndup。 - 适用场景:MSVC-only 项目用
_s系列;跨平台项目自己写 size-aware 包装。
Takeaway 8: string length 的四种”长度”——只有 bytes/code units 可用于分配
- 是什么:
strlen/wcslen数 code units;code points 数需要 mbtowc 等;extended grapheme clusters(用户感知字符,如 emoji)数更复杂。 - 为什么重要:
malloc(wcslen(s) + 1) * sizeof(wchar_t)在 Linux 是 OK;在 Windows 上 BMP 外字符需要 surrogate 对,长度计算可能错。 - 解决什么问题:分配存储用 code units;截断字符串用 extended grapheme clusters(避免切坏 emoji)。
- 适用场景:所有 i18n 字符串处理。
三、工程实践视角
嵌入式开发
- 嵌入式几乎没有 i18n 需求——ASCII 够用。但 escape sequence 八进制陷阱要小心:
'\10'是 backspace,不是'1' '0'。 char永远是 unsigned 还是 signed 看 MCU 编译器手册——avr-gcc默认 unsigned;ARM GCC 默认 signed。别假设。wchar_t在嵌入式几乎不用——资源不够。gets在 MCU 上常被误用:很多 legacy 嵌入式代码用gets(uart_buf)——C11 删除后编译器报警告但代码还在。strncpy是 fixed-buffer copy 标准——但要手动加buf[n-1] = '\0'。
Linux 系统开发
- Linux 默认 UTF-8——POSIX locale 1。C 字符串字面量默认 UTF-8(执行字符集)。
- 跨平台字符处理:永远用
char+ UTF-8——不要用wchar_t。libuv / libxml2 / glib 都用 UTF-8char*。 char16_t/char32_t在 Linux 几乎不用——直接用charUTF-8 + iconv 转。strdup/strndup是 POSIX 标准——glibc 提供。getenv后立刻strdup——避免后续getenv覆盖。- C23 起 Annex K 被 deprecated——标准社区承认它没成功。
机器人软件(ROS / ROS2)
- ROS message 编码:ROS1 默认 ASCII;ROS2 默认 UTF-8。
std_msgs/String=string data——UTF-8。 - ROS 跨语言桥接:Python str ↔ C
char*都是 UTF-8——直接strdup(s)即可。 - 机器人 UI/语音:可能涉及 emoji——必须 extended grapheme clusters 计数,不能用
strlen。 - micro-ROS 嵌入式:MCU 资源有限,字符串字面量尽量 ASCII,UTF-8 处理会增加代码大小。
汽车电子软件(AUTOSAR / ISO 26262)
- AUTOSAR 编码规范禁用
gets、禁用strcpy、禁用strcat——只允许 size-bounded 版本(strncpy/snprintf),且要求 size 在编译期可验证。 strncpy在汽车代码里常用——但必须配合buf[n-1] = '\0'(MISRA 规则)。- 字符集:车机/仪表盘可能需要本地化(中日韩);RTE 配置统一 UTF-8。
- OEM 自定义字符集:某些 OEM 用专属 codepage——必须用
iconv或 codepage-aware API。 - AUTOSAR SWS_LogAndTrace:所有 log API 是变参——但 format string 必须是 literal(防 format string 漏洞——ch01 已讲)。
初级 vs 高级工程师的关注差
| 维度 | 初级 | 高级 |
|---|---|---|
char 假设 | 不知道 signed/unsigned 区别 | 知道 implementation-defined;永远 cast (unsigned char) |
gets | 看到 gets(s) 就用 | 永远不——fgets 替代 |
strncpy | 当作”安全 strcpy” | 知道不保证 null,手动补 |
| 跨平台字符串 | 用 wchar_t | 用 char + UTF-8 + iconv |
char 数组大小 | const char s[4] = "abcd" 不察觉 | const char s[] = S_INIT 永远安全 |
wchar_t 跨平台 | 不觉差异 | 知道 Win/Linux 宽度差,避免 |
strdup 后 | 忘 free | 立刻 free 或 RAII |
escape \10 | 觉得是 ‘1’+‘0’ | 知道是 backspace |
四、AI 时代视角
这个知识今天是否仍然重要?
重要性级别:极高——i18n 是 AI 写代码最容易出错的领域。
- AI 经常写错:
- 把
gets直接生成(即使 C11 已删) - 用
wchar_t当 cross-platform string - 假设
char总是 unsigned 或总是 signed strncpy后不补 nullgetenv后不用strdup- 字符串字面量假设 ASCII
- 把
AI 能帮助完成什么
- ✅ 把
gets(s)自动改fgets(s, sizeof(s), stdin) - ✅ 把
strncpy后加buf[n-1] = '\0' - ✅ 写 UTF-8 / UTF-16 转换的 wrapper
- ✅ 解释
'\10'vs'\b'vs'\x8'等价 - ✅ 检查 MSVC vs GCC vs Clang 的字符集差异
AI 无法替代什么
- ❌ 决定公司/产品的 target encoding——涉及整个技术栈
- ❌ 跨平台字符串协议设计——涉及 wire format 兼容性
- ❌ AUTOSAR / DO-178C 字符集合规——需要 cert 工具
- ❌ extended grapheme cluster 的复杂规则——需要 Unicode 标准查阅
工程师必须掌握的核心能力
- 区分 source charset / execution charset
- 理解 UTF-8/16/32 的 trade-off
- 永远不会用
gets strncpy后永远补 null(或用_s)- 跨平台字符串用 UTF-8
char*,不要wchar_t
五、实践行动项
行动 1: 验证 char 在你机器上是 signed 还是 unsigned
cat > /tmp/charsign.c <<'EOF'
#include <stdio.h>
#include <limits.h>
int main(void) {
char c = (char)0xFF;
if (c == EOF) puts("char is signed and 0xFF == EOF (UB trap!)");
else puts("char is unsigned (safer)");
printf("CHAR_MIN=%d, CHAR_MAX=%d\n", CHAR_MIN, CHAR_MAX);
return 0;
}
EOF
cc -std=c17 -o /tmp/charsign /tmp/charsign.c && /tmp/charsign预期:在 x86 Linux GCC char 默认 signed — 0xFF sign-extend = -1 = EOF(UB 比较成立但你看到陷阱)。
行动 2: 演示 strncpy 不保证 null 终止
cat > /tmp/strncpy.c <<'EOF'
#include <stdio.h>
#include <string.h>
int main(void) {
char buf[10];
strncpy(buf, "hello world", sizeof(buf));
printf("buf = '%s'\n", buf); // UB! 可能读到 buffer 外的 garbage
printf("strlen(buf) = %zu (可能 > 10)\n", strlen(buf));
// 安全做法:
strncpy(buf, "hello world", sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0'; // 手动补 null
printf("safe buf = '%s'\n", buf);
return 0;
}
EOF
cc -std=c17 -Wall -Wextra -o /tmp/strncpy /tmp/strncpy.c && /tmp/strncpy预期:第一段 strlen 可能 > 10(读到 null 之前的所有字节);第二段是安全的。
行动 3: 演示 const char s[4] = "abcd" 的陷阱
cat > /tmp/strconst.c <<'EOF'
#include <stdio.h>
#include <string.h>
#define S_INIT "abcd"
int main(void) {
const char s[4] = S_INIT;
printf("sizeof(s) = %zu\n", sizeof(s));
// strlen(s) 是 UB! 因为 s 没 null 终止
// printf("strlen = %zu\n", strlen(s)); // 取消注释看 UB
// 安全做法:
const char safe[] = S_INIT;
printf("safe sizeof = %zu, strlen = %zu\n", sizeof(safe), strlen(safe));
return 0;
}
EOF
cc -std=c17 -Wall -Wextra -Wstringop-truncation -o /tmp/strconst /tmp/strconst.c 2>&1
/tmp/strconst预期:GCC 警告 -Wstringop-truncation;safe 版本正常。
行动 4: 演示 escape sequence 等价
cat > /tmp/esc.c <<'EOF'
#include <stdio.h>
int main(void) {
printf("'\\b' = %d (decimal)\n", '\b');
printf("'\\10' = %d (octal = 8)\n", '\10');
printf("'\\x8' = %d (hex)\n", '\x8');
printf("Are they equal? %s\n", ('\b' == '\10' && '\10' == '\x8') ? "yes" : "no");
// '\10' 是 octal!不是 '1'+'0'!
printf("'\\10' as int = 0x%x\n", '\10');
return 0;
}
EOF
cc -std=c17 -Wall -Wextra -o /tmp/esc /tmp/esc.c && /tmp/esc预期:三个写法都 = 8(同一字符)。注意:'\10' 是 1 个字符,值 8(octal)。
行动 5: 演示 wchar_t 在 Linux 是 32-bit
cat > /tmp/wch.c <<'EOF'
#include <stdio.h>
#include <wchar.h>
int main(void) {
printf("sizeof(wchar_t) = %zu bytes\n", sizeof(wchar_t));
wchar_t w = L'中'; // BMP character
printf("L'中' = 0x%x\n", (unsigned)w);
printf("wcslen(L\"中文\") = %zu\n", wcslen(L"中文"));
return 0;
}
EOF
cc -std=c17 -Wall -Wextra -o /tmp/wch /tmp/wch.c && /tmp/wch预期:Linux sizeof(wchar_t) = 4;L’中’ = 0x4e2d;在 Windows 上 wchar_t 是 2 字节,行为不同。
六、值得深入思考的问题
Q1: C 没有 primitive string——这是个**“feature”还是bug?
支持无 primitive 的论点:效率(直接字节访问)、ABI 稳定、灵活(用户可定义 string_t)。
反对:所有错误都是 C 字符串处理(CVE 占比巨大)。
问题:如果 C89 当时引入 string_t,今天的世界会怎样?为什么 Java / Python 选择 primitive string 而 C 不?
Q2: wchar_t 在 Windows 16-bit / Linux 32-bit——这种差异是设计失误吗?
提示:C 标准允许 implementation-defined 宽度;POSIX 要求 32-bit。问题:C 标准为什么不强制 wchar_t 宽度?统一到 32-bit 会让 Windows 性能损失 50%(UTF-32 → UTF-16)——这种”自由”是不是正确决策?
Q3: Annex K(C11 边界检查接口)失败的根本原因是什么?
事实:MS 发起、但 MS 不合规;Linux/macOS 不实现;C23 起 deprecated。
问题:如果当年 MS 把 strcpy_s 直接做进 ABI 而不是 TR 化、再开源,命运会不同吗?Annex K 失败说明了”标准推动者必须真用它”的什么教训?
Q4: '\10' 是 backspace——为什么 C 解析器用 octal 而不是 decimal 当 escape?
提示:octal 3 位 max(\777 = 511),decimal 解析会有歧义(\123 是 char(123) 还是字符串’1’+‘2’+‘3’?)。问题:如果当年改用 \d{N}(decimal)、\o{N}(octal)、\x{N}(hex)——是不是更易读?为什么没改?
Q5: gets 在 C99 deprecated、C11 删除——为什么 glibc 至今仍提供 gets 实现?
原因:ABI 兼容 + 遗留链接器符号。问题:编译器警告 + 标准删除 = 仍可编译运行的”僵尸 API”——**这种”软删除”足够吗?**还是需要硬破坏(链接器直接拒绝)?
下一章预告: Chapter 8 — Input/Output —— Standard I/O Streams(FILE*、stdin/stdout/stderr)、text vs binary mode、buffering(full / line / unbuffered)、POSIX file descriptor(open/read/write/close vs fopen/fread/fwrite/fclose)、random access(fseek/ftell)、temporary files、fprintf/sscanf 的 format string、binary I/O 的 endianness 处理。这是所有”程序与外界通信”的基础。