标准库常用设施
环境同前:Windows 11 x64,MinGW-w64 GCC 13.1.0。本节包含一个
-O2下memcpy与memmove结果真实不同的实测。
一、string.h:长度语义各不相同
拷贝类
char buf[16];
strcpy(buf, "0123456789");
strcpy 后 strlen=10 sizeof(buf)=16
strcpy 完全不看目标多大,源串多长就写多长(含 \0)。它的”安全版”strncpy 语义反而更别扭:
char dst[8];
strncpy(dst, "1234567890", sizeof dst);
strncpy 截断后 dst = [12345678] 最后一字节 = 56('8')→ 没有 \0
- 源串 ≥ n 时不补
\0(上一节实测过); - 源串 < n 时把剩余空间全部填 0,大缓冲区上是白白的开销;
- 编译器会警告:
[-Wstringop-truncation]。
统一改用 snprintf:它保证截断安全且一定留 \0,返回值还能判断是否被截断:
int need = snprintf(dst, sizeof dst, "%s", src);
if (need >= (int)sizeof dst) { /* 被截断了 */ }
追加类:strncat 的数字含义和你想的不一样
char d2[8] = "ab";
strncat(d2, "XYZWVU", 4);
strncat 第 3 个参数是"最多追加几个字符"而不是缓冲区大小,还会补 \0:d2=[abXYZW] 长度 6 占 7 字节
strncat 的 n 是追加字符数,而它还会再写一个 \0,所以实际最多写 n + 1 字节,且起点是已有字符串的末尾。缓冲区剩余空间必须按 剩余 >= n + 1 来算,否则溢出。这是 strncpy/strncat 混用时最常见的 off-by-one。
memcpy 与 memmove:重叠时真的不一样
标准说 memcpy 在区域重叠时行为未定义。很多人以为”反正实现都是逐字节拷,重叠也没事”。-O2 下 60 字节自移位实测:
memcpy(b, big, 64);
memmove(b + 1, b, 60); /* 或 memcpy(b + 1, b, 60) */
原始序列: ABCDEFGHIJKL
memmove: AABCDEFGHIJK ← 正确:整体右移一位
memcpy : AABCDDFGHHJK ← 错乱:SIMD 前向拷贝把源数据覆盖了
memmove 会判断方向(必要时从后往前拷),memcpy 在 -O2 下用向量化前向拷贝,重叠区域读到的是刚被自己写坏的数据。规则很干脆:源和目标可能重叠,就必须用 memmove;确认不重叠才用 memcpy(性能略好,且向量化更充分)。
查找类返回的是指针,不是下标
const char *hay = "path/to/file.c";
printf("%s\n", strchr(hay, '/') + 1); /* to/file.c */
printf("%td\n", strstr(hay, "file") - hay); /* 8 */
strchr 返回指针:to/file.c;两次出现间隔 7 字节
strstr("file") 命中偏移 8
要下标就自己减:p - hay(单位是元素,ptrdiff_t)。strchr 找字符、strrchr 找最后一次、strstr 找子串、strpbrk 找”任一字符”、strtok 分词。注意 strchr(s, c) 传的 c 是 int,但它匹配的是 \0 当 c 为 0 时的终止符,这个特例常被用来定位结尾。
二、stdlib.h:atoi 是陷阱,strtol 才是工具
printf("%d %d %d\n", atoi("abc"), atoi("12x"), atoi("99999999999999999999"));
atoi("abc")=0 atoi("12x")=12 atoi("99999999999999999999")=1661992959 ← 无法区分错误与 0
三个结果暴露 atoi 的全部问题:非法输入返回 0(你分不清是”0”还是”垃圾”)、尾部垃圾被静默忽略、溢出返回一个错得离谱的数(1661992959),而且它不设置 errno。解析用户输入或协议字段时,这三条任意一条都能变成安全缺陷。
正确姿势是 strtol + endptr + errno:
errno = 0;
char *end;
long v = strtol(" -42abc", &end, 10);
strtol(" -42abc") = -42,end 指向 "abc",errno=0
超范围:返回 2147483647,errno=34(ERANGE=34)→ 检出溢出
非数字:返回 0,end 未被推进(end==起点 ⇒ 1)errno=0
一个可用的解析函数要检查三件事:
int parse_int(const char *s, int *out) {
errno = 0;
char *end;
long v = strtol(s, &end, 10);
if (end == s) return -1; /* 一个数字都没吃到 */
if (*end != '\0') return -2; /* 尾部有非法字符 */
if (errno == ERANGE) return -3; /* 溢出,v 被钳到 LONG_MAX/MIN */
if (v < INT_MIN || v > INT_MAX) return -4;
*out = (int)v;
return 0;
}
注意调用前必须 errno = 0(见第四节),以及 strtol 溢出时返回的是钳位值而不是错误值。进制参数固定写 10,写 0 会让 "010" 变成 8——解析配置文件时这是个真实 bug 源。
sscanf 的返回值是”成功转换的项数”
int a = 0, b = 0;
int n = sscanf("7 8", "%d %d", &a, &b);
int m = sscanf("7", "%d %d", &a, &b);
sscanf 返回成功转换的项数 2,a=7 b=8
输入不足时返回 1(≠ 期望的 2)← 必须检查返回值
不检查返回值的 sscanf 会在输入不足时留下未初始化的变量继续用。sscanf 解析文本很顺手,但它对”整行必须完全匹配”没有表达能力,严格的协议解析还是手写状态机更可靠。
三、rand():RAND_MAX 只有 32767
RAND_MAX = 32767
srand(42) 两次取三个值:175 400 17869 / 175 400 17869 → 完全相同 1
rand() % 100 三个样本:75 0 69
三个结论:
- 本机
RAND_MAX是 32767(15 位),不是 21 亿。要 32 位随机数得拼两次;要密码学强度必须用平台 CSPRNG(WindowsBCryptGenRandom、Linuxgetrandom),rand()永远不行。 - 同种子必然同序列——这是特性不是 bug,测试里正好用它做可复现随机。
rand() % N有模偏差(RAND_MAX+1不是N的倍数时,小数字出现概率略高)。要均匀分布就用拒绝采样:先算出能被N整除的最大阈值,抽到超阈值的值就丢弃重抽。
四、errno:只能在失败之后读
fopen 失败返回 0000000000000000,errno=22,strerror="Invalid argument"
成功调用不会清 errno(手动设 12345 后仍读到 12345)→ 只能在失败后看
标准规定:库函数成功时不会把 errno 清零(只有失败时才设)。所以 printf("errno=%d\n", errno) 这种”顺便看看”的写法读到的往往是上一次失败的残留值。正确模式固定三步:
errno = 0; /* 1. 调用前清零 */
FILE *f = fopen(path, "rb"); /* 2. 用返回值判断成败 */
if (!f) { /* 3. 只有失败时才读 errno */
fprintf(stderr, "打开失败: %s\n", strerror(errno));
}
两个补充:
strerror的返回值指向静态缓冲区,多线程下不安全(POSIX 有strerror_r)。日志里直接用也行,但别跨线程传。- 少数函数(如
read/recv)用 errno 区分”可重试”,EINTR必须写重试循环,否则信号一来系统调用就”莫名失败”。
五、ctype.h:参数必须是 unsigned char 或 EOF
isdigit('7')=1 isdigit(0x37)=1;但 isdigit(200) 传 int 越界是 UB(应先转 unsigned char)
isalpha/isdigit 这些函数的参数类型是 int,但合法取值只有 unsigned char 的 0–255 和 EOF。char 在本机是有符号的,*p 读到 0xC8 会传成 -56,越界索引查表 → UB(常见表现是崩溃)。从 char 取值一律先转:
while (*p) { if (isdigit((unsigned char)*p)) count++; p++; }
另外这些函数的结果依赖 locale,setlocale(LC_ALL, "") 之后行为可能变。要 ASCII 语义就自己写 (c >= '0' && c <= '9')。
六、这一节的自查清单
- 全项目搜不到
strcpy/strcat/裸sprintf,统一snprintf - 不依赖
strncpy补终止符;用snprintf或手工memcpy+ 显式\0 - 算
strncat的剩余空间时按n + 1算 - 区域可能重叠就用
memmove - 查找函数返回指针,转下标用减法,判空再用
- 数字解析一律
strtol/strtod+endptr+errno,进制写死 10 -
sscanf的返回值必须等于期望项数 - 随机数:可复现测试用
srand,生产用平台 CSPRNG,注意RAND_MAX只有 32767 -
errno只在函数明确失败后读,调用前置 0 -
ctype函数参数先转unsigned char
下一节讲动态内存与对象生命周期:栈/堆/静态区各放什么、内存池什么时候值得做、碎片是怎么形成的,以及怎么用 -fsanitize=address 之外的手段在没有 sanitizer 的工具链上定位泄漏。