(注意:输出内容为博文正文,从 ## 1. 开始,无前置说明,无结尾元信息)
1. 数组不只是C语言的一道坎,更是理解指针和内存的钥匙
很多学C语言的朋友都有这种感觉:数组的语法规则一节课就讲完了,定义数组、填下标、取元素,看起来比结构体简单得多。可真到了写程序的时候,各种问题就冒出来了——数组越界不报错、字符串拷贝乱码、二维数组传参编译直接拒了、数组名到底是不是指针争了半天。我在带A同学做模拟练手项目时就发现,能把数组真正玩明白的人,指针和内存这块基本不会差到哪去;反过来,指针老是绕晕的人,十有八九是数组底层的存储模型没过关。
这篇梳理不打算按教科书目录一项项念,而是按我实际排查代码时用到的思路来组织:先弄清楚数组在内存里到底长什么样,再把指针和数组的暧昧关系说明白,接着把二维数组、函数传参、字符串应用这几个高频场景逐个拆开,最后聊一聊越界访问和边界安全。它适合刚学完C语言基本语法、想系统理一遍数组知识的人,也适合考前突击、准备复试或者转行做嵌入式方向的朋友做参考。我的原则是:不堆晦涩术语,每个知识点都给到能直接落地的理解和写法。
2. 从内存地址视角拆解一维数组:声明、初始化、下标的底层逻辑
2.1 数组声明到底做了什么
先看一段最常见不过的代码:
int a[5];这行声明背后的信息量其实很大。它做了三件事:在栈上(或者静态存储区,取决于位置)申请了一段连续的内存空间,大小是5个int;这段内存的首地址叫做a,类型是int[5];此刻a的内容是不确定的,局部数组里的值可能是上一次遗留的随机数,这一点第一次学的人很容易忽略。
很多人以为int a[5]只是“造了5个变量”,不是的,它造的是“一块连续内存”。连续这两个字,是数组最重要的性质,也是后面所有指针运算的基础。假设int占4个字节,那么a[0]的地址、a[1]的地址、a[2]的地址依次差4个字节,机器可以通过“首地址 + 下标 × 元素大小”直接算出目标地址,不需要像链表那样一个个跳过去找。这也是数组随机访问是O(1)的原因。
我习惯给初学者画这样一个等价关系:数组下标访问本质上是地址计算的语法糖。编译器看到a[3]时,它实际做的事是算出这段内存基地址偏移3个元素大小的位置,然后对这个地址做解引用。所以后面会讲到a[i]和*(a+i)完全等价,道理就在这里。
2.2 初始化规则:偷懒和踩坑的边界
数组初始化有几种常见写法,规则本身不难,但细节值得记牢:
int b[5] = {1, 2, 3, 4, 5}; // 完全初始化 int c[5] = {1, 2, 3}; // 部分初始化,剩下自动补0 int d[5] = {0}; // 全部元素初始化为0 int e[] = {1, 2, 3, 4, 5}; // 编译器根据元素个数推断长度为5 int f[5]; // 局部数组中元素值不确定最值得记的一条是:部分初始化时,未被列出的元素会被自动置为0。这意味着int d[5] = {0}可以把整个数组清零,这是C语言里最朴实、最常用的清零手法。相比之下,int f[5]这种只在局部作用域里不初始化的情况就是陷阱了,如果你直接遍历去用f[i]做累加或者判断,读出来的可能是任何值。
另外还有一条C99之后逐渐普及的语法:指定初始化器。
int g[10] = {[0] = 1, [5] = 2, [9] = 3};指定下标初始化在某些场景下挺好用,比如配置表里想单独改某一项时,不用把前面所有值都列出来。但在团队项目里,这种写法对可读性是双刃剑,代码评审时总要多解释一句,我建议用在常量表场景就好,日常循序初始化的代码还是老老实实写完整列表。
2.3 下标越界为什么“不报错”
C语言的数组不做边界检查,这是设计使然——语言追求效率和底层控制力,把检查的责任交给了开发者。当你写下a[5]时(上面声明的是int a[5]),编译器大概率不会提醒你,程序运行到这一步也不会主动崩溃,它只是把你写的偏移量算出来,然后去读写那块内存地址。至于那块内存是谁的、能不能碰,那是另一回事。
真正跑起来,越界读写的结果通常有几种:读到了相邻变量或栈上的其他数据,程序逻辑莫名其妙错了;写坏了相邻变量,或者把函数返回地址覆盖掉,程序在运行到某个角落时突然崩溃;运气“好”的,那块内存是没被占用的空洞,程序一直不崩,但留下了隐蔽的雷。最麻烦的就是第三种,它不会立刻报错,等到项目大了、数据变了才炸,排查成本极高。后面专门开一节讲边界安全,这里先记住一个结论:C语言信任你,但你别太相信自己。
3. 指针与数组的纠缠:数组名、地址运算和那个经典的“数组名不是指针”
3.1 到底怎么理解“数组名退化为指针”
“数组名是不是指针”这道题,几乎是C语言面试/考试里出现频率最高的问题之一。标准答案是:数组名不是指针,但在大多数表达式中,数组名会自动“退化”为指向首元素的指针。
先看两组实测对比:
int a[5]; printf("%zu\n", sizeof(a)); // 输出5 * sizeof(int),是整个数组的大小 printf("%zu\n", sizeof(a + 0)); // 输出sizeof(int*),退化成指针了 int *p = a; // 合法,a退化为指向a[0]的指针 int (*q)[5] = &a; // 合法,&a的类型是“指向整个数组的指针”sizeo