数组与字符串
环境同前两节:Windows 11 x64,MinGW-w64 GCC 13.1.0,
-std=c17 -Wall -Wextra -O0。文中所有地址、字节数、退出码都是实测输出。
一、数组 = 一段连续内存
这句话不是比喻,是可以逐字节验证的:
int a[5] = {10, 20, 30, 40, 50};
for (size_t i = 1; i < 5; i++) printf("%td ", (char *)&a[i] - (char *)&a[i - 1]);
sizeof(a)=20 a 与 &a 数值相同:a=0000009716bffde0 &a=0000009716bffde0
相邻元素地址差:4 4 4 4 (字节)
每个元素间隔恰好 sizeof(int),没有间隙、没有头部信息。由此直接推出四件事:
-
a[i]就是*(a + i)的语法糖,所以下标可以是负数,只要指针往回指:int *mid = &a[2]; printf("mid[-1]=%d mid[-2]=%d\n", mid[-1], mid[-2]); /* 20 10 */ -
随机访问是 O(1):地址算出来就行,不需要遍历。
-
插入/删除是 O(n):后面所有元素都得搬。
-
长度不在数组里,内存里没有任何地方记着”我有 5 个”。这是后面所有退化和越界问题的根源。
多维数组是行优先的一维内存
int m[2][3] = {{1, 2, 3}, {4, 5, 6}};
printf("sizeof(m)=%zu sizeof(m[0])=%zu sizeof(m[0][0])=%zu\n", sizeof m, sizeof m[0], sizeof m[0][0]);
printf("&m[0][2] 与 &m[1][0] 差 %td 字节\n", (char *)&m[1][0] - (char *)&m[0][2]);
int flat[6];
memcpy(flat, m, sizeof m);
sizeof(m)=24 sizeof(m[0])=12 sizeof(m[0][0])=4
&m[0][2]=0000009716bffdf8 &m[1][0]=0000009716bffdfc 差 4 字节 ⇒ 行与行首尾相接(行优先)
把整个二维数组当一维 memcpy 后:1 2 3 4 5 6
m[0][2] 和 m[1][0] 只差 4 字节——上一行的结尾紧接着下一行的开头,整个 m 就是一块 24 字节的连续内存,可以一次 memcpy 搬走。行优先的直接后果:遍历二维数组时内层循环要沿着行走。按列遍历会让每一步都跳过一整行,缓存全部失效,这在数值计算里是几倍的差距。
二、数组名退化:以及三个不退化的例外
绝大多数表达式里,数组名会转换成”指向首元素的指针”,这叫退化(decay)。但有三处不退化,忘了就会算错 sizeof:
| 场景 | 表达式的类型 | 实测结果 |
|---|---|---|
a 用在表达式里 | int * | a + 1 前进 4 字节 |
sizeof a | 整个数组 | sizeof a = 20 |
&a | int (*)[5] | &a + 1 前进 20 字节 |
char s[] = "abc" 的右侧 | 用字面量初始化数组 | 拷贝 4 字节进数组,不是取指针 |
a+1 前进 4 字节,&a+1 前进 20 字节
a 和 &a 数值相同、类型不同:都指向同一块内存开头,但 a + 1 走一个元素,&a + 1 走整个数组。这个区别在指针算术里会真实改变结果。
退化之后 sizeof 就废了
static void array_param(int arr[5]) { /* 这里的 [5] 只是注释 */
printf("sizeof(arr)=%zu ⇒ \"元素个数\"=%zu\n", sizeof arr, sizeof arr / sizeof arr[0]);
}
main 里 sizeof(arr)/sizeof(arr[0]) = 5
函数内 sizeof(arr)=8 sizeof(arr[0])=4 ⇒ 算出的"元素个数"=2
编译器会警告,但警告不等于错误,代码照样编译通过:
warning: 'sizeof' on array function parameter 'arr' will return size of 'int *' [-Wsizeof-array-argument]
所以规矩只有一条:数组进函数,指针和长度成对传。形参写 int arr[5]、int arr[]、int *arr 三者完全等价,[5] 不产生任何约束。
二维数组的形参必须保留”列宽”
int f(int (*m)[3], int rows); /* 合法:行宽 3 必须写进类型 */
int g(int m[][3]); /* 同上,只是写法不同 */
int h(int m[][4]); /* 列宽不同就是不同类型 */
因为编译器要算 m[i][j] 的地址:base + (i * 列宽 + j) * sizeof(int)——列宽是地址计算的一部分,行数是参数所以可以不写。把 int a[2][3] 传给 h(int m[][4]),本机只给警告不报错:
warning: passing argument 1 of 'h' from incompatible pointer type [-Wincompatible-pointer-types]
然后一切照跑,读到的全是错位的数据。这类”警告后继续运行”的分支,就是必须开 -Werror 的理由。
三、初始化规则
int partial[5] = {1, 2};
int all_zero[5] = {0};
int designated[4] = {[3] = 9, [1] = 7};
部分初始化补齐:partial = 1 2 0 0 0
= {0} 清零:all_zero[4] = 0
指定初始化(乱序写,按下标落位):designated = 0 7 0 9
- 写了初始化列表但没写满,剩余元素自动补 0(不是随机值)。这是把结构体/数组清零最省事的办法。
- 没初始化的局部数组是未定义内容,别猜它是不是 0。静态存储期的数组才保证为 0。
[3] = 9这种指定初始化器(designated initializer)在 C 里可以乱序写,落位按下标来——上面实测0 7 0 9。做”枚举 → 配置表”时非常好用。
四、字符数组与字符串字面量:差一个 \0 就是两种东西
char withNul[] = "abc";
char *p = "abc";
char noNul[3] = {'a', 'b', 'c'};
char guard = 'Z';
char a[]="abc": sizeof=4 strlen=3(自动补 \0)
char *p="abc": sizeof(p)=8(只是个指针,指向只读段)
char a[3]={'a','b','c'}: strlen=6 ← 没有终止符,读到了相邻的 'Z'
三行代码三个教训:
-
char a[] = "abc"会拷贝字面量(含\0)进数组,sizeof是 4,数组可写。 -
char *p = "abc"只是让p指向只读段里的字面量,sizeof(p)是指针宽度 8。改它会当场段错误:char *p = "abc"; p[0] = 'X';Segmentation fault 退出码 = 139要可写就写
char p[] = "abc";。 -
第三个数组没有终止符,
strlen一路读过数组末尾,读到 6——它把相邻的'Z'和后面的字节都当成了字符串内容。这是 UB,本机给出 6 只是运气。
内嵌 \0 会劈成两种”相等”
char buf[8] = {'a', 'b', 0, 'c', 0, 0, 0, 0};
strlen(buf); memcmp(buf, "ab\0cd", 5); strcmp(buf, "ab\0cd");
含内嵌 \0 的 5 字节数据:strlen=2 memcmp(5 字节)=-1 strcmp=0
同样两个缓冲区:strlen 认为只有 2 字节,strcmp 认为两者相等(都在第 3 字节看到 \0 就停了),memcmp 认为不等(老老实实比满 5 字节)。结论:文本用 str* 系列,二进制数据必须显式带长度并用 mem* 系列。 拿 strlen 去量图片、序列化的协议帧,就是这个坑。
五、字符串函数怎么选
strcpy 永远不要用
它不检查目标缓冲区大小,源串多长就写多长。历史上大半的字符串溢出漏洞都来自它和它的兄弟 sprintf、strcat。
strncpy 也不是安全版
char dst[8];
strncpy(dst, "1234567890", sizeof dst);
strncpy 截断后 dst = [12345678] 最后一字节 = 56('8')→ 没有 \0
它的语义是”最多拷 n 个字节”,源串长度 ≥ n 时不补 \0。紧接着 printf("%s", dst) 就会越界读。而且当源串短时它会把剩余空间全部填 0,在大缓冲区上是白白的性能开销。编译器对此有专门警告:
warning: 'strncpy' output truncated copying 8 bytes from a string of length 10 [-Wstringop-truncation]
用 snprintf,并读懂它的返回值
char dst[8];
int need = snprintf(dst, sizeof dst, "%s", "1234567890");
snprintf 返回 10(本该写入的长度),dst = [1234567] 长度 7,一定有终止符
snprintf 保证在缓冲区内留下 \0(除非 size 传 0),截断时安全。返回值是”如果缓冲区够大本该写入的字符数”(不含 \0),所以 need >= sizeof dst 就说明被截断了——这是判断截断的标准写法。
别忘了 strlen 是 O(n)
for (size_t i = 0; i < strlen(s); i++) /* 每轮都从头再数一遍,O(n²) */
编译器不一定能把它移出循环。要么先存进变量,要么直接遍历到 \0。
六、越界访问为什么”看起来能用”
这是 C 和带边界检查的语言最大的差别:数组访问没有任何运行时检查。a[5] 只是”从 a 起第 20 个字节开始读”,地址算得出来,指令就能执行。
volatile int guard = 111;
int arr[3] = {1, 2, 3};
volatile int *raw = arr;
raw[3] = 999; /* 越界:arr 只有 3 个元素 */
写之前 guard=111,arr 与 guard 地址差 12 字节
写 arr[3]=999 之后 guard=999 ← 越界写踩到了相邻对象
guard 恰好排在 arr 后面 12 字节,也就是 arr[3] 的位置,于是它被无声地改成了 999。程序没有崩,没有报错,只是某个变量的值从此不对了——这就是越界最典型的破坏形式:症状出现在离病因很远的地方。
那编译器能帮忙抓吗?本机实测的结论是部分能抓,但不能指望。
抓不到的例子(写越界后再读回同一个位置):
gcc -std=c17 -Wall -Wextra -O2 p9a.c → 无输出
gcc -std=c17 -Wall -Wextra -O2 -Warray-bounds=2 p9a.c → 无输出
gcc -std=c17 -O1 -fanalyzer p9a.c → 无输出
抓得到的例子(直接读越界下标):
warning: array subscript 2 is above array bounds of 'int[2]' [-Warray-bounds=]
同一个 -Warray-bounds,一个沉默一个报错——差别在于常量折叠后编译器”看不看穿”那次写。所以:编译器没报,不等于没有越界。 想真正确认,只有 sanitizer 或人工审查。
工具链层面还有 ASAN,但本机这个 MinGW 构建不带 sanitizer 运行时:
ld.exe: cannot find -lasan: No such file or directory
(上一节提到的 -fsanitize=undefined 同理,libubsan 也没有。)要在 Windows 上用这些工具,得换 MSVC(/fsanitize=address 已支持)或 WSL/Linux 侧的 GCC/Clang。
所以越界的防线按有效性排序是:
- 写的时候就别越界:长度和指针成对传,循环条件用
i < n而不是i <= n,unsigned长度先判 0。 -Wall -Wextra -Werror:至少把能报的都报出来,并且不让警告积累成噪音。- 换有 sanitizer 的工具链跑测试(Linux/WSL 上的
-fsanitize=address,undefined),CI 里挂上。 - 评审时专门看数组下标和
memcpy/snprintf的长度参数。
七、这一节的自查清单
- 数组进函数一律”指针 + 长度”成对传,不用
sizeof推算元素个数 - 清楚
a、&a、a+1、&a+1各自的类型和步长 - 二维数组按行遍历;形参写清列宽;不把
int (*)[3]传给int (*)[4] - 可写的字符串用
char buf[N],绝不改写字符串字面量 - 手工构造的字符数组确认有
\0,或者根本不用\0结尾而是显式传长度 - 二进制数据用
mem*+ 长度,不用str*+strlen - 全项目搜不到
strcpy/strcat/裸sprintf;字符串拼接统一snprintf -
snprintf的返回值拿来做截断判断 - 循环条件里的
strlen提出去 - 越界不能指望编译器,靠写法 +
-Werror+ sanitizer 三层
下一节进入结构体与联合体:内存对齐到底怎么算、offsetof 与柔性数组、联合体做类型双关的边界,以及为什么 sizeof 常常比你算的大。