在C语言里,数组并不是一种自带长度属性的对象,而是同类型元素在内存中的连续排列。创建一个数组,本质上是告诉编译器或运行时环境:需要一段多大的连续空间,以及按什么类型去解读这些空间。由于C语言把内存管理权交给程序员,数组创建方式会直接影响它的作用域、生命周期以及能否在运行期改变大小。下面从栈上静态数组、堆上动态数组和函数传递三个角度展开。

一、栈上静态数组:声明与初始化
静态数组通常指在函数内部或全局区通过常量表达式声明大小、由编译器自动分配空间的数组。它的基本语法是 元素类型 数组名[常量表达式]。方括号里的长度必须是编译期能确定的整型常量表达式,不能是运行期读取的变量。比如 int scores[5]; 会在栈上留出5个int大小的连续空间,而 double matrix[3][4]; 则创建一个3行4列的二维数组,实际内存同样是一段连续区域。
初始化数组有几个容易忽略的规则。如果初始化列表给出的元素少于数组长度,剩余元素会被自动置零,例如 int a[5] = {1, 2, 3}; 中后两个元素是0。如果不写长度而使用初始化列表,编译器会根据列表项数推导长度:int b[] = {3, 5, 7}; 的长度就是3。字符数组可以用字符串字面量初始化,但要注意 char str[] = "abc"; 实际占据4个字节,因为字符串末尾会自动追加一个 \0 结束符。若写成 char str[3] = "abc";,数组只能容纳 a、b、c 三个字符,缺少结束符,后续按字符串处理时很可能越界。
#include <stdio.h>
int main(void) {
int scores[5] = {88, 92, 76};
int b[] = {1, 2, 3};
double matrix[3][4] = {0};
for (int i = 0; i < 5; i++) {
printf("%d ", scores[i]);
}
printf("\n");
char str[] = "abc";
printf("str bytes: %zu\n", sizeof(str));
return 0;
}
这段代码中,scores只初始化了前三个元素,编译器会把后两个补0,因此循环输出为 88 92 76 0 0。二维数组使用 {0} 可以把所有元素统一置零。字符数组 str 使用自动推导长度,sizeof 输出4,印证了结束符的存在。理解栈上数组的自动存储期也很关键:函数返回后,数组所在栈内存会被回收,不能把指向局部数组的指针返回给调用者。
二、堆上动态数组:malloc、calloc、realloc
当数组长度需要由用户输入、文件内容或网络包大小决定时,静态数组就不够用了。C语言提供动态内存分配函数,在堆上按运行期长度创建数组。最常用的是 malloc,它只负责分配指定字节数的空间,不初始化内容。分配n个int元素的数组可以写成 int *arr = malloc(n * sizeof(int));。这里必须乘以 sizeof(int),因为malloc的参数是字节数而不是元素个数。如果分配失败,malloc会返回NULL,后续任何解引用都会导致未定义行为。
calloc 在分配的同时把每个字节置零,适合需要干净初始状态的缓冲区。它的参数是元素数量和单个元素大小:int *arr = calloc(n, sizeof(int));。相较之下,malloc分配后数据是未定义的,读这些位置可能得到随机值。另一个常用函数 realloc 用来调整已分配内存的大小,常常用于动态数组扩容。realloc的返回值必须重新接收,因为新的内存块地址可能与原来不同。安全的做法是先保存到临时指针,确认成功后再覆盖原指针,否则一旦失败原指针也会丢失,造成内存泄漏。
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int n;
printf("请输入数组长度:");
scanf("%d", &n);
int *arr = malloc(n * sizeof(int));
if (arr == NULL) {
fprintf(stderr, "内存分配失败\n");
return 1;
}
for (int i = 0; i < n; i++) {
arr[i] = i * i;
}
int new_n = n + 5;
int *tmp = realloc(arr, new_n * sizeof(int));
if (tmp == NULL) {
free(arr);
fprintf(stderr, "扩容失败\n");
return 1;
}
arr = tmp;
for (int i = n; i < new_n; i++) {
arr[i] = 0;
}
free(arr);
arr = NULL;
return 0;
}
示例里先根据输入n分配数组,再通过realloc扩容5个元素。注意扩容后新增部分不会被calloc那样自动清零,所以循环把新元素手动置0。释放内存时使用 free(arr);,之后设置 arr = NULL; 可以降低重复释放或使用悬空指针的风险。动态数组的优势是大小灵活,缺点是必须手动管理释放,否则容易造成内存泄漏。
三、数组退化为指针:函数参数中的关键细节
数组创建之后,经常要传给函数处理。但C语言里一个关键规则是:当数组名作为函数参数传递时,它会退化为指向首元素的指针,原本的长度信息在函数内部完全丢失。也就是说,即使形参写成 int arr[10],编译器也按 int *arr 处理,10这个数字不会参与类型检查。此时在函数内部用 sizeof(arr) 得到的是指针大小,通常是4或8字节,而不是整个数组的字节数。
因此,传递数组时通常需要额外传递长度参数。比如打印函数可以设计为 void print_array(int *arr, size_t len),调用前用 sizeof(nums) / sizeof(nums[0]) 计算元素数量。这种方法只对当前作用域内可见的数组有效,对已经退化为指针的数组不能再度使用。若数组是多维的,例如 int matrix[][4],除第一维外的维度必须写清楚,因为编译器需要根据列数计算每行步长,否则无法正确索引。
#include <stdio.h>
void print_array(int *arr, size_t len) {
for (size_t i = 0; i < len; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
int main(void) {
int nums[] = {10, 20, 30, 40};
size_t len = sizeof(nums) / sizeof(nums[0]);
print_array(nums, len);
return 0;
}
这段程序说明了长度传递的标准姿势。main函数中nums还没有退化,所以能通过sizeof求出元素个数;进入print_array后,arr已经是指针,必须依靠len来控制循环边界。若忘记传递长度,函数要么写死长度,要么越界读取,这正是很多缓冲区漏洞的起点。理解数组退化为指针,也有助于理解为什么不能从函数返回局部数组,但可以返回malloc分配到堆上的数组。
四、常见创建错误与规避方法
创建数组时最常见的错误是越界访问。C语言不做边界检查,写入 arr[n] 时如果下标超出数组范围,可能覆盖相邻变量、破坏栈结构甚至被安全攻击利用。避免方式包括:循环条件使用常量长度或显式传递的len;对用户输入的上限做检查;使用静态分析工具和编译器的地址消毒器辅助定位。
另一个问题是混淆字符串数组与字符指针。比如 char *p = "hello"; 创建的是指向字符串字面量的指针,内容通常不可修改;而 char arr[] = "hello"; 创建的是可修改的字符数组。两者的sizeof、赋值能力和修改行为都不同。需要变长字符串时,优先考虑堆上分配 malloc(strlen(src) + 1),注意为 \0 留出空间。
还有一类隐蔽问题是使用变长数组,即C99允许的 int n = 10; int arr[n];。这种方式虽然创建简单,但数组存储在栈上,长度过大容易造成栈溢出,而且不是所有编译器或标准都支持得很好。在需要运行期长度时,动态内存分配通常是更通用、更可控的选择。规范地创建、传递和释放数组,是C语言程序员处理连续数据的基本功。