6.1 一维数组与越界的代价


6.1 一维数组与越界的代价

本节摘要:数组 = 一块连续内存 + 元素宽度,下标访问编译成"首地址 + 下标乘宽度"一条指令,O(1) 且缓存友好——代价是机器完全不设防:越界访问的指令与正常访问一模一样,踩到谁全看隔壁住着谁。本节看数组的字节形态与初始化规则,然后制造几起真实的越界事故,学习从废墟里读出线索。

数组就是连续内存

#include <stdio.h> int main(void) { int a[5] = {10, 20, 30, 40, 50}; printf("sizeof a = %zu\n", sizeof a); /* 20 */ printf("a 的地址 %p\n", (void *)a); printf("&a[1] 与 a 相差 %td 字节\n", (char *)&a[1] - (char *)a); /* 4 */ return 0; }

五个 int 紧挨着,共 20 字节,地址步进 4。第 5 章已经推过公式:元素地址 = 首地址 + 下标 × 4,编译器把它直接嵌进访存指令的缩放因子。初始化规则补全:

int a[5] = {1, 2, 3}; /* 剩余元素自动清零:4、5 号是 0 */ int b[] = {1, 2, 3}; /* 长度由初始化列表推得:3 */ int c[5] = {0}; /* 全零数组的惯用写法 */ /* int d[2] = {1,2,3}; 编译错误:塞不下 */

注意"部分初始化则剩余清零"这条规则只对数组与全局变量成立——普通局部变量不初始化仍是垃圾值(第 4 章)。数组没有内置长度信息,sizeof 只在未退化的场合可用,跨函数必须显式传长度——这条铁律值得再背一遍。

越界现场:踩邻居

#include <stdio.h> int main(void) { int guard1 = 111; int a[2] = {0, 0}; int guard2 = 222; a[2] = 999; /* 越界写:只有 0 和 1 合法 */ a[-1] = 888; /* 反向越界 */ printf("guard1 = %d\n", guard1); /* 888:被反向越界踩了 */ printf("guard2 = %d\n", guard2); /* 999:被正向越界踩了 */ return 0; }

这段代码在很多编译器上能"正常"跑完并打印出被踩后的值——没有报错、没有崩溃、数据悄悄换掉了。编译器可能把三个变量排成相邻(也可能调整顺序或加填充,所以结果因编译器而异),越界写直接落在邻居变量上。这就是越界最可怕的一面:它是无声的。等到崩溃时,案发现场早已被踩乱。

图 1 越界踩踏邻居的现场

图 1 越界踩踏邻居的现场

三类越界与各自的下场

越界类型 典型肇因 常见下场
栈上数组越界 循环边界差一、scanf %s 超长 踩局部变量或返回地址;踩返回地址可被利用执行任意代码
堆块越界 malloc 大小算错、strcpy 不限长 踩 malloc 元数据,堆损坏,崩在很远处的 free
全局数组越界 下标来自外部输入未检查 踩其他全局变量,或掉出数据段段错误

栈上越界踩返回地址值得单独强调:精心构造的超长输入可以把返回地址覆盖成攻击者指定的位置——缓冲区溢出攻击的原理至此已经完整可见(第 2 章 scanf 一节说的 %19s 限长,防的就是它)。

防御纪律

  1. 长度显式化:数组跨函数必传长度参数,别依赖任何"约定俗成";
  2. 检查再访问:下标来自外部输入、文件、网络时,访问前判界;
  3. 用 snprintf 替换 sprintf、strncpy 家族替换 strcpy:所有"不带长度的写函数"都是隐患;
  4. 编译期检查工具:地址消毒器在越界发生的当场报错并给出堆栈,是排查越界的最强武器,测试阶段常开。

一个体现"长度显式化"的规范函数骨架:

int sum_array(const int *a, size_t n) /* 长度是参数,责任清晰 */ { int s = 0; for (size_t i = 0; i < n; i++) /* 循环上界与长度绑定 */ s += a[i]; return s; }

⚠️ 常见坑:差一错误(off-by-one)。for (i = 0; i <= n; i++) 对长度 n 的数组多访问一个元素;边界条件写 <= 前多看一眼。另一个高频坑是"长度传了元素个数,循环里当下标用还减一"——长度与最后下标差一,这条要形成肌肉记忆。

💡 关键直觉:数组是效率与安全的交易——用"不检查边界"换来 O(1) 访问与缓存友好。C 把检查的责任完全交给你,所以纪律必须自己建:长度永远显式传,下标永远来自受控来源。

本节要点回顾

  • 数组 = 连续内存 + 元素宽度:下标编译成"首地址加下标乘宽度",与指针运算同源。
  • 部分初始化剩余清零,但跨函数长度必须显式传——数组没有自带元数据。
  • 越界访问的指令与合法访问完全一样:后果取决于踩到谁,且常常无声。
  • 栈越界踩返回地址是溢出攻击的原理,堆越界踩块头导致远处崩溃。
  • 防御三件套:长度显式、判界再访问、编译期检查工具常开。

下一节把一维数组卷成网格:二维数组的内存真相与遍历顺序的性能差。

常见疑问

问:数组越界为什么有时不崩溃? 因为访问指令本身不检查边界,后果取决于踩到谁。踩到普通变量只是数据错乱,踩到只读页或未映射页才崩溃。无声的越界是最危险的越界,排查时不能依赖"崩了才说明有问题"。

问:长度为什么不能存进数组里? C 数组只是内存与首地址,没有头部元数据可存长度。语言设计者把检查权交给程序员,换来的是零管理开销。要安全就自己传长度,或封装带长度的结构。

问:越界检测工具会拖慢多少? 地址消毒器大约拖慢两倍并占用更多内存,只在测试构建开启。它是发现越界性价比最高的手段,测试跑得慢一点换事故当场现形,完全值得。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U