第1章:数组的存储与访问

数组这东西,C语言里最基础的数据结构之一。但说实话,很多人用了好几年,对它在内存里到底怎么待着的,还是一知半解。今天咱们就把它彻底聊透。

1.1 数组在内存中的连续存储

先问个问题:定义一个数组 int arr[5],内存里到底发生了什么?

答案是:系统会一口气分配5个连续的int大小的空间。注意这个“连续”,这是数组最核心的特征。

我个人习惯把数组想象成一排连在一起的快递柜。每个格子大小一样,编号从0开始。你告诉系统“我要5个int”,它就给你找一排连续的空位,每个空位4字节(32位系统下)。

关键点:数组名arr代表的是整个数组的首地址,也就是第一个元素的地址。arr和&arr[0]的值是一样的,但类型不同——这个细节坑过不少人。

来看个例子:

#include <stdio.h>

int main() {
    int arr[5] = {10, 20, 30, 40, 50};
    
    printf("数组首地址: %p\n", arr);
    printf("第一个元素地址: %p\n", &arr[0]);
    printf("第二个元素地址: %p\n", &arr[1]);
    
    // 看看地址差了多少
    printf("地址差: %ld 字节\n", 
           (long)&arr[1] - (long)&arr[0]);
    
    return 0;
}

输出结果会告诉你:arr和&arr[0]打印出来一模一样,&arr[1]比&arr[0]大4个字节。这就是连续存储的直接证据。

我的经验:我在调试嵌入式通信协议时,经常需要把数据打包成字节流发送。这时候数组的连续存储特性就特别有用——直接用memcpy把整个数组拷进缓冲区,省时省力。但要注意字节序问题,那是另一个话题了。

1.2 数组元素的随机访问原理

为什么数组访问那么快?arr[3]为什么能瞬间拿到值?

说白了,就是一条公式的事:

第i个元素的地址 = 数组首地址 + i × 每个元素的大小

你想想看,这只是一个加法和一个乘法。CPU做这个快得跟闪电似的。这就是所谓的“随机访问”——不管你访问第0个还是第9999个,速度都一样。

用代码验证一下:

#include <stdio.h>

int main() {
    int arr[5] = {10, 20, 30, 40, 50};
    int i = 3;
    
    // 这两种写法等价
    printf("arr[%d] = %d\n", i, arr[i]);
    printf("*(arr + %d) = %d\n", i, *(arr + i));
    
    // 甚至你可以这么写(虽然没人这么干)
    printf("%d[arr] = %d\n", i, i[arr]);
    
    return 0;
}

看到没?arr[i] 本质上就是 *(arr + i)。编译器会把中括号语法直接翻译成指针运算。所以 i[arr] 也能编译通过——因为 i[arr] 被解释成 *(i + arr),和 *(arr + i) 完全一样。

注意:虽然 i[arr] 语法上合法,但千万别这么写。代码是给人看的,不是用来炫技的。我曾经在一个遗留代码里看到这种写法,排查了半天才反应过来——嗯,从那以后我写代码就更“老实”了。

下面这张图展示了数组在内存中的布局和访问方式:

数组在内存中的连续存储与随机访问 内存地址: 0x1000 0x1004 0x1008 0x100C 0x1010 10 20 30 40 50 arr[0] arr[1] arr[2] arr[3] arr[4] ← 连续存储,每个元素占4字节 → 随机访问公式:arr[i] 的地址 = 首地址 + i × sizeof(元素类型) 例:arr[3] 的地址 = 0x1000 + 3 × 4 = 0x100C arr[i] 等价于 *(arr + i) 也等价于 i[arr]

1.3 数组的边界与越界

这是C语言里最危险的地方之一。C不会帮你检查数组下标是否合法——它信任你,或者说它懒得管你。

你声明了 int arr[5],然后写了 arr[5] = 100。编译器不会报错,程序可能正常运行,也可能当场崩溃,更可怕的是——它可能偶尔崩溃,让你抓狂。

#include <stdio.h>

int main() {
    int arr[3] = {1, 2, 3};
    int secret = 0xDEAD;
    
    // 越界写入
    arr[3] = 0xBEAF;  // 危险!
    arr[4] = 0xCAFE;  // 更危险!
    arr[-1] = 0;      // 负下标?也能编译!
    
    printf("secret = 0x%X\n", secret);
    // 猜猜secret变成了什么?
    
    return 0;
}

这段代码在栈上,arr和secret可能挨着。arr[3]越界写入,恰好改写了secret的值。这就是典型的“缓冲区溢出”——安全漏洞的温床。

血的教训:我曾经在一个嵌入式项目里,因为数组越界写了一个字节,导致系统运行3小时后随机死机。排查了整整两天,最后用内存监视工具才发现是某个循环的边界条件多算了1。从那以后,我写循环都养成了一个习惯:for(i = 0; i < N; i++),永远用小于号,绝不用小于等于。

越界的后果分三种:

越界类型 可能后果 发生概率
读越界 读到垃圾值,程序行为异常
写越界(轻微) 覆盖相邻变量,逻辑错误
写越界(严重) 覆盖函数返回地址,程序崩溃或被攻击 低但致命

避坑指南:

  • 用宏或常量定义数组大小,别写魔法数字
  • 循环边界用 i < N 而不是 i <= N-1
  • 敏感数据(如密码)用完立即清零,防止被越界读到
  • 嵌入式开发中,开启编译器的数组边界检查选项(如果有的话)

最后说一句:数组越界是C语言的“特色”,也是它的“痛点”。用好它,你能写出高效的底层代码;用不好,它就是定时炸弹。我个人建议:在性能允许的前提下,多写几个断言(assert)检查下标范围,这习惯能救你一命。


公众号:蓝海资料掘金营,微信deep3321