
1. 先搞清楚一件事数组进函数到底传了什么很多初学者包括当年的我第一次接触数组传参时都默认函数收到的是整个数组的“拷贝”。听上去很合理int 变量传参就是复制一份整数float 传参就是复制一份小数那数组传参当然应该把整块数据复制一遍。但一试就露馅了在函数里sizeof(arr)完全不是数组的大小在函数里修改元素外面的数组也跟着变。原因一句话就能说清C/C 里数组传参传的不是“数组”而是数组首元素的地址。拿生活类比你请朋友来家里吃饭你不是把整个厨房搬到朋友家而是把门牌号告诉他。朋友凭地址进厨房自己拿菜。这个“地址”就是数组名退化后的指针。在 C 语言里void foo(int a[])和void foo(int *a)是完完全全等价的两种写法编译器会把前者自动改写为后者。这个机制叫“数组退化为指针”。所以不管你在函数形参里写[10]、[]还是*a最终收到的都是一个int *类型的指针变量。为什么要这么设计两个原因。一是效率数组可能很大如果每次传参都整体复制一份内存和耗时会成倍增长。尤其嵌入式、实时系统里数组长度几万字节时一行函数调用就可能拖垮性能。二是 C 语言设计哲学里“信任程序员”直接操作内存地址把控制权交给你不额外做安全检查。理解这个本质后后面所有细节——为什么传长度、为什么二维数组必须标列数、为什么函数里不能 sizeof——全都一目了然。2. 一维数组传参写法不止一种坑也不止一个2.1 三种等价写法与实际效果一维数组作为函数参数官方认可的写法有三种// 写法一数组形式 void func1(int arr[], int len); // 写法二指针形式 void func2(int *arr, int len); // 写法三指定长度的数组形式 void func3(int arr[10], int len);三种写法在编译器中完全等价都编译成void func(int *arr, int len)。写法三里写的10会被直接忽略编译器不会帮你检查传入的数组是否真的有 10 个元素。你以为写了个约束实际上是给后来的维护者看的“文档”——仅此而已。调用方式保持一致直接写数组名。func1(arr, 5);即可不用加也千万不要写arr。arr本身就是地址arr是整个数组的地址类型是int (*)[5]跟int *不是一回事强传进去编译也会报警告。2.2 sizeof 为什么在函数里“失灵”这是问得最多的面试题。先说现象void print_len(int arr[]) { printf(%zu\n, sizeof(arr)); // 输出 864位系统指针大小 }表面看arr看起来是个数组但前面说过它实际上已经被调整为int *arr一个指针变量。sizeof作用于指针得到的是指针本身占用的字节数64 位系统固定 8 字节。如果数组有 40 个int元素160 字节你在主调函数里sizeof(arr)能算出 160可一旦进了函数就变成 8。所以 C 语言里必须显式传递数组长度没有第二条路。标准库的做法是像qsort一样把“长度”和“单个元素大小”都作为参数传进去。这也是为什么memcpy(arr, brr, sizeof(arr))只能在主调函数里用——一旦封装成函数就必须用sizeof(arr) / sizeof(arr[0])这类公式算出长度后再传进去。实际编码中我的习惯是第一参数传数组名、第二参数传长度长度用size_t类型void print_all(int arr[], size_t n) { for (size_t i 0; i n; i) { printf(%d , arr[i]); } printf(\n); }不要偷懒在函数内部用“哨兵值”或“约定长度”去推断数组终点除非是字符串\0结尾否则一定会出边界越界的问题。2.3 不想丢失长度信息C 里还有一招如果是 C可以用模板引用参数来“保留”数组长度template size_t N void print_all(int (arr)[N]) { for (size_t i 0; i N; i) { cout arr[i] endl; } }这里的int (arr)[N]是“对长度为 N 的整型数组的引用”。这样调用时编译器会推导出 N函数内部直接用 N 即可甚至可以对数组用for (auto x : arr)。代价是每个不同长度的数组都会实例化一份模板代码代码体积会略增大但正确性和可读性提升明显。如果你还在用纯 C就只能老老实实传长度。这不是语言的缺陷而是 C 的哲学一切控制权交给程序员错误越早暴露越好。3. 二维数组与多维数组传参列数是一个硬约束3.1 为什么二维数组必须写列数一维数组传参时长度可以不写但二维数组传参时列数必须显式写出行数可以省略// 正确 void process(int matrix[][5], int rows); // 也正确把二维数组看成“指向数组的指针” void process(int (*matrix)[5], int rows); // 错误缺少列数 void process(int matrix[][], int rows);原因要从内存布局讲起。C 语言里的二维数组是“一维数组的数组”int matrix[3][5]本质上是 3 个长度为 5 的int[5]数组连续排列。访问matrix[i][j]时编译器要算出偏移量i * 列数 j再去从首地址偏移(i * 列数 j) * sizeof(int)的位置取值。如果不知道列数编译器根本算不出第二行从哪里开始。这就是为什么列数必须写它不是语法装饰而是内存寻址的必要参数。行数倒是可以省略因为matrix[1]的偏移只依赖列数和行索引不需要知道总共有多少行。3.2 一维指针模拟二维数组的常见做法当行数和列数在运行时才确定时固定列数的写法就不够用了。常见方案有两种。方案一一维数组模拟。开一块连续内存用row * cols col手动算下标void process_flat(int *data, int rows, int cols) { for (int i 0; i rows; i) { for (int j 0; j cols; j) { int v data[i * cols j]; printf(%d , v); } } }这种内存布局是严格连续的cache 命中率高适合图像处理、矩阵计算。缺点就是下标计算要自己写代码可读性稍差。方案二指针数组。先分配一个int *row_ptr[rows]每个元素指向一行数据int **board malloc(rows * sizeof(int *)); for (int i 0; i rows; i) { board[i] malloc(cols * sizeof(int)); }然后就能像二维数组一样写board[i][j]。但注意每一行 malloc 出来的内存不保证连续整体访问性能略差而且释放时需要逐行 free忘记释放就是内存泄漏。这两种方式我都在实际项目里用过。如果是图像数据、矩阵运算这种要大量连续访问的场景推荐一维数组模拟如果只是做一个行数未知的表结构且行与行之间逻辑独立可以用指针数组。3.3 数组指针和指针数组别再把名字搞混了这两个词混淆是 C 语言入门高频问题。写一段代码马上分清int *p1[5]; // 指针数组5 个 int 指针组成的数组 int (*p2)[5]; // 数组指针指向“5 个 int 组成的数组”的指针区别就在括号。int *p1[5]中[]优先级高p1先和[5]结合说明p1是数组元素类型是int *。int (*p2)[5]用括号强行让p2先和*结合所以p2是指针指向的类型是int [5]。函数传参时二维数组名matrix会退化成指向第一行的指针也就是int (*)[5]。所以void process(int matrix[][5], int rows)和void process(int (*matrix)[5], int rows)完全等价后者更接近本质。如果你把参数写成int **matrix那跟int (*matrix)[5]完全不兼容——int **是指向指针的指针而不是指向数组的指针。很多人拿int **接收二维数组编译报警告或运行崩溃原因就在类型不匹配。4. 指针数组传参与 main 函数的隐藏逻辑4.1 指针数组的传参写法指针数组本身也是数组传参规则和整数数组一致——退化成“指向指针的指针”。比如要写一个函数接收一组字符串地址void print_strings(char *strs[], int count) { for (int i 0; i count; i) { printf(%s\n, strs[i]); } }这里的char *strs[]等价于char **strs。调用时传入char *arr[]类型的变量即可。在函数内部strs[i]取到的是一个char *printf 的%s格式参数正好就是char *所以可以直接打印。注意区分字符串数组可以用char arr[][20]这种二维字符数组也可以用char *arr[]这种指针数组。前者每个字符串都固定占 20 字节内存浪费但数据连续后者每个字符串长度自由内存紧凑但需要用 malloc 或静态字符串常量初始化。传参时前者退化为char (*)[20]后者退化为char **两者也不兼容。4.2 main 函数的 argc 和 argv 到底长什么样每个写 C 程序的人都会用 main但很多人没意识到int main(int argc, char *argv[])本身就是“指针数组传参”的活例子。argv是一个指针数组每个元素指向一个以\0结尾的字符串。argc表示这个数组的元素个数。命令行输入./program hello world时系统会把参数拆成字符串构造出这样的数组argv[0] ./program argv[1] hello argv[2] world argc 3所以argv[0]永远是程序名真正传入的参数从argv[1]开始。很多初学者想当然以为argc是“参数个数”其实它包含了程序名本身。写命令行工具时判断参数个数要写if (argc 2)说明至少需要一个额外参数不然argv[1]可能非法访问。main函数里要遍历参数标准写法就是根据argc作为循环上限逐个处理argv[i]。因为argv是char **你也可以在函数里把它当作二级指针来写argv就能跳向下一个字符串地址。但日常我更建议用数组下标语义清晰不容易写出错。4.3 跨语言视角其他语言为什么不按指针传如果你还接触其他语言会发现数组传参的语义不太一样。Python 里函数参数传递实际上是“对象引用传参”你传一个 list 进函数函数内部append或修改元素外面的 list 也会变但如果你给参数重新赋值lst []外面的引用不受影响。Java 的数组是对象传数组传入的是“引用值”函数内改arr[0]会影响到外部但把参数重新new一个新数组外部也不感知。这些语言的共同点是数组本身不是 C 的那种“连续内存首地址”而是带有长度信息的对象。这也是 C 的数组没有“长度属性”的直观对比。理解 C 里的退化规则后再回头看其他语言的设计反倒能更明白“为什么 Python 的len(arr)随处可用C 却不行”。5. 实战中常见的坑与排查技巧5.1 函数内修改数组外面会跟着变这个特性常被新人当 bug 报明明只是传进去处理为什么调用方的数组值变了。先说结论这是正常现象。因为传的是地址函数内通过指针操作的就是原来的内存。如果你确实需要“副本”有两种方案。一是调用方自己拷贝一份再传int backup[10]; memcpy(backup, data, sizeof(data)); func(backup, 10); // 函数改 backup不影响 data二是函数内部拷贝进来再处理void func(const int src[], size_t n) { int *tmp (int *)malloc(n * sizeof(int)); memcpy(tmp, src, n * sizeof(int)); // 修改 tmp不碰 src free(tmp); }但在工程实践中更常见的需求反而是“函数内改外面要看到”比如排序、数组反转、原地去重。这种场景就是设计成传地址的没必要拷贝。真正要注意的是如果你不希望调用方数据被意外修改形参一定加const。5.2 const 加在参数上的正确姿势void func(const int arr[], size_t n);const int arr[]表示这里的“数组”元素只读函数内不能通过arr[i] x来赋值。编译器会帮你检查。如果调用方传入的是一个非 const 数组允许实参传给 const 形参这是安全的反过来就不行——你不能把const int arr[]传给声明为int *的参数编译器会报错。实际项目中我建议所有“只读访问”的数组参数都加const。这不仅是健壮性更是自文档化的手段调用方一眼就知道这个函数会不会改动数据。5.3 数组越界不报错但这才是最危险的C 不会检查数组越界函数传参后更不会检查。arr[i]中 i 超出范围程序会读/写到数组边界外的内存。有时候它运行起来毫无异常因为没踩到敏感地址有时候栈被破坏函数返回时崩溃有时候是堆内存被改写过了很久在另一个看似无关的地方才出错。排查越界问题最常见的办法是编译期和运行期工具结合。启用编译器的边界检查gcc 可以用-fsanitizeaddressMSVC 可以用/fsanitizeaddress运行时会准确报告越界读写的行号。临时的插入printf打点排查也有效但大型项目还是上 ASan 省事。这些工具应该作为日常开发的一部分而不是发现问题才临时开。5.4 数组作为函数返回值别返回局部数组函数返回“数组”是另一个常见错误场景。C 不允许直接返回数组类型常见绕过方式是把局部数组名返回出去——但那个数组在函数返回时已经销毁返回的指针变成悬空指针。int *get_array(void) { int tmp[10] {0}; return tmp; // 危险tmp 是局部变量函数结束即失效 }正确做法有三种调用方传入一个缓冲区由调用方管理内存、用malloc在函数内分配内存由调用方负责 free、或者定义成static局部变量不推荐因为下一次调用会覆盖。开发中我用得最多的是第一种让调用方提供数组和长度函数负责填充——也是最符合“数组传参”这个主题模式的做法。6. 一个完整的综合示例试试把这些知识点串起来为了把前面所有机制拉到一起看我给出一个极端一点但非常实用的例子一个函数接收二维数组形式的棋盘和一组字符串命令判断并修改棋盘内容。这里同时用到了二维数组形参、指针数组、长度参数和 const 修饰。#include stdio.h #include string.h // 二维字符数组传参列数必须是常量行数是运行时参数 void init_board(char board[][8], size_t rows) { for (size_t i 0; i rows; i) { for (size_t j 0; j 8; j) { board[i][j] .; } } } // 打印函数也是二维数组参数因为只读所以加 const void print_board(const char board[][8], size_t rows) { for (size_t i 0; i rows; i) { for (size_t j 0; j 8; j) { printf(%c , board[i][j]); } printf(\n); } } // 指针数组传参commands 是 char * 数组退化为 char ** void apply_commands(char board[][8], size_t rows, const char *commands[], size_t cmd_count) { for (size_t k 0; k cmd_count; k) { size_t r commands[k][0] - 0; // 假设命令格式是 3 5 P size_t c commands[k][2] - 0; char ch commands[k][4]; if (r rows c 8) { board[r][c] ch; } } } int main(void) { char board[4][8]; init_board(board, 4); const char *cmds[] { 1 2 X, 0 0 O, 3 7 # }; apply_commands(board, 4, cmds, 3); print_board(board, 4); return 0; }这个例子里有几个值得注意的细节。char board[][8]的列数 8 必须写因为board[r][c]要计算偏移。apply_commands的第三参数是const char *commands[]它和const char **commands等价而cmds这个变量本身是const char *[3]传进去正好匹配。如果我把形参写成char *commands[]cmds因为带 const 会报类型不兼容——这就是 const 的好处编译器强迫你意识到“函数不会修改字符串内容”。这几个函数一组合一维指针退化、二维数组列数约束、指针数组传参、const 修饰全部知识点都落地了。最后再分享一点我在实际项目里养成的习惯每写一个接收数组的函数第一件事就是确认自己是“读”还是“写”。只读就加 const要写就检查边界需要长度就把长度参数放到数组参数后面。按这个纪律去写数组传参相关的 bug 能减少一半以上。C 语言给了你完全的内存访问能力同时也要求你自己守住底线。数组传参这件事看起来只是语法细节实际上是你对内存模型理解程度的直接检验。