C语言二维数组传参:三种方法、类型退化与选型指南
简介这份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 写死在形参里编译器据此计算 ai 的步长 */ 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]); /* 等价于 *(*(ai)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 **aint **运行期可变行间不连续动态读入、锯齿数组选型依据很直接列数在编译期能确定就用方法一或二内存连续、缓存友好、释放简单列数运行期才知道或者各行长度不一才用方法三代价是多次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给出 48wrong里的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-checkfull ./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 的支持程度。本文还有配套的精品资源点击获取