简介:这份PDF资料面向C语言初学者与需要巩固指针、数组知识的开发者,系统讲解二维数组作为函数参数传递的三种常见写法,帮助解决形参声明与实参匹配时容易混淆的问题。资源共1个PDF文件,约36KB,内容以示例代码和文字说明为主,篇幅精炼便于随时查阅。资料围绕形参给出第二维长度、形参声明为指向数组的指针、形参声明为指针的指针三种方法展开,每种方法均配有完整可运行的代码片段,并特别说明第三种方式下不能使用array[i][j]取值、需按一维数组方式访问的原因。目前已有22384人学习,适合在函数封装、字符串数组处理等场景中参考,也可作为复习指针与数组退化关系的辅助材料。
1. 二维数组传参为什么不能照搬一维数组的写法
刚写完一维数组传参的人,第一次把int a[3][4]塞进函数签名时,几乎都会撞上同一堵墙:编译器报error: expected 'int *' but argument is of type 'int (*)[4]',或者更隐蔽地,函数里a[i][j]算出来的地址完全不对。原因不在语法,而在类型系统——二维数组名在大多数表达式里会退化成指向首行的指针,类型是int (*)[4],而不是int **,更不是int *。把这两者混为一谈,是 C 语言里最经典的指针陷阱之一。
这个标题要解决的就是这件事:二维数组作为函数参数到底有哪几种传法,各自在什么场景下用,参数怎么写,边界怎么定,以及为什么有些写法在 32 位单片机上能跑、换到 64 位机器就崩。适合已经会写一维数组传参、正在啃指针与数组关系的人,也适合回头梳理main函数参数、字符串数组传参这类同源问题的老手。三种方法不是并列的三种风格,而是从「编译期已知列数」到「运行期完全动态」的一条连续谱,选错一种,后面全是补丁。
2. 三种传参方法的类型本质与最小可运行代码
2.1 方法一:形参写成int a[][4],列数必须编译期确定
这是最贴近直觉的写法,也是教材里出现频率最高的。形参声明为int a[][4],编译器会把它调整成int (*a)[4],即「指向含 4 个 int 的数组的指针」。行数可以省略,列数不能省,因为指针算术a + i每次要跳过整整一行。
#include <stdio.h> /* 列数 4 写死在形参里,编译器据此计算 a+i 的步长 */ void print_matrix(int a[][4], int rows) { for (int i = 0; i < rows; i++) { for (int j = 0; j < 4; j++) { printf("%d ", a[i][j]); /* 等价于 *(*(a+i)+j) */ } putchar('\n'); } } int main(void) { int m[3][4] = {{1,2,3,4},{5,6,7,8},{9,10,11,12}}; print_matrix(m, 3); /* 数组名退化为 int (*)[4] */ return 0; }逻辑说明:a[i][j]被编译器翻译成*(*(a + i) + j)。a + i的步长是4 * sizeof(int),这个步长完全由形参里的4决定。参数说明:第一个参数是退化后的行指针,第二个rows必须由调用方传入,因为函数内部用sizeof(a)只能得到指针大小(8 字节或 4 字节),拿不到真实行数。
提示:在函数内写
sizeof(a)/sizeof(a[0])求行数是无效的,这是新手最常见的错误之一,a在这里已经是指针。
2.2 方法二:形参写成int (*a)[4],与方法一等价但意图更清楚
int a[][4]和int (*a)[4]在形参位置完全等价,编译器对前者做类型调整后就是后者。显式写指针形式的好处是:读代码的人一眼能看出「这是个指针,不是数组」,避免误以为函数内还能用sizeof求长度。
#include <stdio.h> /* 显式行指针,列数 4 是类型的一部分 */ void sum_rows(int (*a)[4], int rows, int out[]) { for (int i = 0; i < rows; i++) { int s = 0; for (int j = 0; j < 4; j++) { s += a[i][j]; } out[i] = s; /* 每行和写回调用方数组 */ } } int main(void) { int m[2][4] = {{1,2,3,4},{10,20,30,40}}; int sums[2] = {0}; sum_rows(m, 2, sums); printf("%d %d\n", sums[0], sums[1]); /* 10 100 */ return 0; }逻辑说明:int (*a)[4]中,a先与*结合,说明是指针,再与[4]结合,说明指向的是含 4 个 int 的数组。参数说明:out[]作为输出参数,本质是int *,调用方负责保证长度不小于rows。这种写法在需要把「行指针」这个概念讲清楚的场合更合适,比如讲解main函数参数char *argv[]时,argv退化成char **,和这里的行指针是同一类退化规则的不同分支。
2.3 方法三:形参写成int **a,配合动态分配的指针数组
当前两种方法都要求列数编译期已知,遇到「行数和列数都从文件或网络读进来」的场景就无能为力。这时常见做法是用指针数组:先分配rows个int *,再为每行分配cols个 int,形参用int **接收。
#include <stdio.h> #include <stdlib.h> /* 二级指针接收,行列数都靠参数传入 */ void fill(int **a, int rows, int cols) { for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { a[i][j] = i * cols + j; } } } int main(void) { int rows = 3, cols = 5; int **m = malloc(rows * sizeof(int *)); /* 指针数组 */ for (int i = 0; i < rows; i++) { m[i] = malloc(cols * sizeof(int)); /* 每行独立分配 */ } fill(m, rows, cols); printf("%d\n", m[2][4]); /* 14 */ for (int i = 0; i < rows; i++) free(m[i]); free(m); return 0; }逻辑说明:m是int **,m[i]是int *,m[i][j]是 int。每次m[i]指向的内存块可以长度不同,这就是「锯齿数组」。参数说明:rows和cols都必须显式传入,函数内无法从int **反推任何维度信息。注意释放顺序必须与分配顺序相反,先free(m[i])再free(m),否则指针数组本身被释放后,各行的地址就丢了,造成内存泄漏。
2.4 三种方法的类型对照与选型依据
| 方法 | 形参写法 | 退化后类型 | 列数要求 | 内存布局 | 典型场景 |
|---|---|---|---|---|---|
| 一 | int a[][4] | int (*)[4] | 编译期固定 | 连续 | 矩阵运算、图像固定宽 |
| 二 | int (*a)[4] | int (*)[4] | 编译期固定 | 连续 | 同上,意图更明确 |
| 三 | int **a | int ** | 运行期可变 | 行间不连续 | 动态读入、锯齿数组 |
选型依据很直接:列数在编译期能确定,就用方法一或二,内存连续、缓存友好、释放简单;列数运行期才知道,或者各行长度不一,才用方法三,代价是多次malloc、行间不连续、释放要成对。很多性能问题就出在明明列数固定却用了int **,白白丢掉连续内存的缓存优势。
3. 传参时的边界、释放与常见编译错误排查
3.1 行数为什么必须单独传,sizeof在函数内为什么失效
数组名作为函数实参时,退化是指针,不是数组。sizeof(数组名)在定义它的作用域内得到整个数组字节数,在函数形参位置得到的是指针大小。这个规则对一维、二维、字符串数组一视同仁。
#include <stdio.h> void wrong(int a[][4]) { /* 下面这行在 64 位机器上输出 8,不是 48 */ printf("sizeof(a) = %zu\n", sizeof(a)); } int main(void) { int m[3][4]; printf("sizeof(m) = %zu\n", sizeof(m)); /* 48 */ wrong(m); return 0; }逻辑说明:main里的m是真正的数组对象,sizeof给出 48;wrong里的a已经是指针,sizeof给出 8。参数说明:行数只能由调用方通过额外参数传入,或者用哨兵值(如最后一行全 0)标记结束,后者在字符串数组里更常见。
注意:如果函数声明和定义分开写,两处的形参类型必须一致。
void f(int a[][4])和void f(int **a)是完全不同的类型,链接期不一定报错,运行期一定出问题。
3.2 方法三的释放顺序与内存泄漏定位
方法三最容易出的错是释放不完整。下面这段代码演示了正确与错误释放的差别,以及如何用工具定位。
/* 错误示范:只 free 了指针数组,各行内存泄漏 */ void bad_free(int **a, int rows) { free(a); /* 各 a[i] 指向的块全部丢失 */ } /* 正确示范:先释放每一行,再释放指针数组 */ void good_free(int **a, int rows) { for (int i = 0; i < rows; i++) { free(a[i]); } free(a); }逻辑说明:a和a[i]是两次独立分配,必须两次独立释放。参数说明:rows必须与分配时一致,否则漏掉的行就是泄漏。定位手段上,Linux 下用valgrind --leak-check=full ./a.out,能看到definitely lost的具体字节数和调用栈;Windows 下可用 Visual Studio 的 CRT 调试堆,在main开头加_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF),退出时输出泄漏块编号。
3.3 编译错误对照:int **与int (*)[4]不兼容
把二维数组直接传给int **形参,是最高频的错误。编译器给出的信息往往很长,抓住关键一行即可。
# 典型报错 error: passing argument 1 of 'fill' from incompatible pointer type note: expected 'int **' but argument is of type 'int (*)[4]'逻辑说明:int (*)[4]是「指向数组的指针」,int **是「指向指针的指针」,两者解引用一次得到的类型不同,前者得到int[4],后者得到int *。参数说明:修正方式只有两条路——要么把形参改成int (*)[4]并固定列数,要么把实参改成真正的指针数组。不存在「加个强制转换就能用」的合法场景,强转后a[i][j]会把行内的 int 当成指针解引用,直接段错误。
4. 用main参数与字符串数组验证同一套退化规则
4.1char *argv[]退化成char **的完整验证
main函数的char *argv[]是方法三在标准库层面的实例:argv是字符指针数组,退化成char **。写一个打印所有参数的小程序,能直观看到这套规则。
#include <stdio.h> /* argc 是行数,argv 是 char **,每行是一个字符串 */ int main(int argc, char *argv[]) { for (int i = 0; i < argc; i++) { printf("argv[%d] = %s\n", i, argv[i]); } return 0; }逻辑说明:argv[i]是char *,argv[i][j]是 char。参数说明:argc由运行时填入,等于参数个数加一(程序名占argv[0])。编译运行./a.out alpha beta,输出三行。这个例子说明:只要形参是「指针的指针」,长度信息就必须靠额外参数(这里是argc)或哨兵(argv[argc]保证为NULL)传递,和方法三完全一致。
4.2 字符串数组传参:char a[][N]与方法一的对应关系
二维字符数组传参是方法一的直接应用,列数N决定每个字符串的最大长度。
#include <stdio.h> #include <string.h> /* 每个字符串最多 15 字符 + '\0',列数固定为 16 */ void print_names(char names[][16], int count) { for (int i = 0; i < count; i++) { printf("%s (len=%zu)\n", names[i], strlen(names[i])); } } int main(void) { char names[3][16] = {"Alice", "Bob", "Charlie"}; print_names(names, 3); return 0; }逻辑说明:names[i]是char[16],退化成char *传给printf的%s。参数说明:列数 16 必须能容纳最长字符串加结尾的'\0',否则strcpy会越界。如果各字符串长度差异很大,用方法三的char **加动态分配更省内存,代价同样是释放要成对。
4.3 三种方法在单片机与 PC 上的差异
在资源受限环境里,方法三的多次malloc可能直接失败,因为堆空间本就有限,而且碎片化严重。常见做法是预先分配一块连续大缓冲区,再手工算出每行偏移,本质上还是方法一的内存布局,只是列数用变量表示。
/* 连续缓冲区模拟动态二维数组,避免多次 malloc */ #include <stdlib.h> int *buf = malloc(rows * cols * sizeof(int)); /* 访问第 i 行第 j 列 */ #define AT(i, j) buf[(i) * (cols) + (j)]逻辑说明:AT宏把二维下标映射到一维偏移,cols是运行期变量,但内存只有一次分配、一次释放。参数说明:传给函数时用int *加rows、cols两个参数,函数内用同样的宏访问。这种写法在 PC 上同样适用,缓存友好度优于方法三,是「列数运行期可变」场景下更稳的选择。
5. 进阶技巧:用变长数组参数把列数也变成运行期变量
C99 引入的变长数组(VLA)允许形参里的列数用变量表示,这是方法一和方法三之间的折中:内存仍然连续,列数却可以运行期决定。
#include <stdio.h> /* cols 是运行期参数,a 的类型是 int (*)[cols] */ void show(int rows, int cols, int a[rows][cols]) { for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { printf("%d ", a[i][j]); } putchar('\n'); } } int main(void) { int m[2][3] = {{1,2,3},{4,5,6}}; show(2, 3, m); /* 列数由实参推导,无需写死 */ return 0; }逻辑说明:形参int a[rows][cols]中,rows和cols是前面的参数,编译器据此生成正确的指针算术。参数说明:rows和cols必须写在数组参数之前,顺序不能反;调用时实参的列数必须与cols一致,否则步长错位。VLA 在 GCC、Clang 上默认支持,MSVC 不支持,跨平台项目要留意。
提示:VLA 只是形参层面的便利,不改变「数组名退化成指针」的事实,函数内
sizeof(a)仍然是指针大小。
如果连 VLA 都不想用,还有一种更通用的做法:把二维数组当成一维数组传,函数签名写成void f(int *a, int rows, int cols),内部用a[i * cols + j]访问。这种写法兼容所有 C 标准,在嵌入式与跨平台代码里最常见,代价是下标表达式要自己写对,cols传错就是静默的越界。三种方法加上 VLA 和一维展开,构成了二维数组传参的完整工具箱,选哪个取决于列数是否编译期已知、内存是否要求连续、以及目标平台对 VLA 的支持程度。
本文还有配套的精品资源,点击获取