最近给一位朋友讲《C语言第19章 函数的参数与返回值》时我又双叒叕撞见了那道所有人都写过的经典代码swap交换函数。他老老实实写了void swap(int a, int b)函数体里交换得有模有样结果回到main里一printf两个变量纹丝不动。朋友很认真地问“我明明在函数里把值换过来了为什么外面还是老样子”这个问题看起来只是“形参实参”四个字的背诵题但真正把参数和返回值搞透彻恰恰要从这个翻车现场开始。这一章我不想堆定义也不打算机械地罗列“传值调用”“传址调用”的教科书表述。我想把函数调用时底层的参数复制、返回值寄存、指针参数的真实用途、数组和结构体参数的奇怪行为以及做题和实际编码里最容易被低估的几个细节从头到尾捋一遍。顺便把一些和本章高度相关的练习题比如5×5鞍点、字符串逆序、冒泡排序、PAT里的“霍格沃茨找零钱”都放进来做例子。看完之后你再回去翻C语言教材第19章应该会有完全不一样的感觉。1. 经典翻车现场swap函数为什么“交换了个寂寞”1.1 值传递的底层逻辑形参只是实参的复印件很多初学者会把函数理解成“把变量丢进去函数加工一下变量就自己变了”。这个直觉在部分高级语言里勉强成立但在C语言里完全不成立。C语言中函数参数只有一种传递姿势值传递。调用发生时实参表达式的值会被完整复制一份然后塞给形参变量。你在函数里对形参做任何修改改的都是那份复制品和原来的实参变量半毛钱关系都没有。打个比方你拿着身份证原件去办事窗口递进去的是窗口复印的一份复印件工作人员在复印件上写写画画你手里的原件不会多出一道划痕。实参x把值1复制给形参a实参y把值2复制给形参b然后你在swap内部交换a和b交换的是两个独立的新变量。main函数里的x和y从头到尾就没参与过交换。如果再往下挖一层就涉及函数调用时的栈帧概念。每次调用函数系统都会在当前调用栈上为它的局部变量和形参开辟一块独立空间这块空间在函数返回后自动释放。所以形参a和b是函数私有的“临时工”函数一结束它们就被清退。这也是为什么很多人调试的时候看到函数内部打印的值是对的回到调用方一看又变回去了原因就在这一层复制的隔阂。1.2 想让函数改变外面的世界必须传地址要解决swap问题正确的写法几乎人尽皆知把参数改成指针。void swap(int *a, int *b) { int temp *a; *a *b; *b temp; } int main(void) { int x 1, y 2; swap(x, y); printf(x%d y%d\n, x, y); return 0; }这里传进去的其实是x和y的地址。形参a、b的类型是“指向int的指针”。从本质上看这仍然是一次值传递——只不过这次复制的东西是x的地址而不是x的值。函数内部使用*a去读写a所指向的内存读写的对象就直接命中main函数里的x。所以C语言里那句经典论断“一切传参都是值传递”依然成立只不过当你传的是地址时这个“值”本身携带了定位能力。这也解释了scanf为什么总要加。当你写scanf(%d, num)实际上是在把num的地址交给scanf让scanf从键盘读一个整数直接写到num所在的那块内存。如果你不小心漏掉scanf会把num的值当作地址轻则写进一个非法区域导致程序崩溃重则悄悄覆盖一块不该动的内存。这里我特别提醒初学者指针参数和普通整数参数在写法上都很像“传一个数字”但两者的意图完全不同。指针搞清楚的是“你住在哪”普通变量关心的只是“你现在值多少”。有一次我帮一个朋友调试链表翻转函数他在函数内部创建了新的链表头返回值却声明成了void。他以为改到了外部指针变量就算成功实际上他那个函数只是在局部变量上挂了个新节点函数一退出整条链表的头就找不到了。那一刻他真正理解了参数与返回值的分工参数负责把数据送进门返回值负责把结果带出门而指针参数相当于“能在你家里拆墙的施工队”。2. 返回值你以为“return一下”很简单背后是寄存器与存储期限的权衡2.1 return的底层它带回来的值存在哪里当函数执行return 42;时处理器一般会把结果放进一个约定好的寄存器比如x86-64环境下的RAX然后执行ret指令。函数自己的栈帧随后销毁但寄存器的值仍然保留调用者从寄存器里把结果取走。这就是为什么函数返回一个局部变量是完全安全的局部变量的内存虽然销毁了但它的值在返回之前已经被复制到了寄存器里相当于人走之前把钱转走了。所以int、double这种基础类型的返回值几乎没什么悬念。真正有意思的是结构体。现代编译器在函数返回较大的结构体时往往会在调用者的栈上预留一块隐藏缓冲区函数内部把结构体内容拷进缓冲区调用者再从缓冲区取。语法上你只写了一个return底层其实搬了好几次家。因此“按值返回结构体”在C语言中完全合法但如果结构体很大每次返回都是一次内存搬运性能损耗就很实在了。新手容易犯的一个错误是以为函数可以像Python那样一次性返回多个值。C语言没有这种语法。你只能返回一个东西想要多个数据就得靠结构体或者靠后面要讲到的指针出参。2.2 返回指针最容易踩的悬空指针陷阱返回值的真正坑藏在返回指针里。最典型的翻车代码长这样char *get_message(void) { char buf[64] hello; return buf; // 危险 }buf是函数内的局部数组函数一结束栈空间就被回收。这之后返回的地址就成了悬空指针。编译器不一定报错程序运行也可能碰巧输出hello因为那块内存还没被立刻覆盖。但只要后续再调用几个别的函数栈上内容被写乱输出就会变成乱码甚至直接段错误。这种问题比立刻崩溃更折磨人因为它有时候能跑有时候不能跑毫无规律可循。我实际写项目时遇到需要从函数返回一块内存的情况一般有三条稳妥路线方案写法要点代价返回字符串字面量return hello;字面量有静态存储期限但不能修改返回static局部变量在函数内声明static char buf[64];所有调用方共享同一块内存会被后续调用覆盖返回堆内存用malloc分配返回指针调用方必须记得free否则泄漏三种方案各有适用场景。临时打印个只读消息用字面量最省心函数只维护一个内部缓冲可以用static想要每次调用都有独立、可修改的数据就老老实实走malloc。很多教程只强调“不能返回局部变量”却不解释替代方案导致初学者彻底不敢返回指针了。其实返回指针并不可怕可怕的是没想清楚这块内存在函数退出之后到底归谁、谁负责释放。2.3 返回值不够用时把“状态码”和“业务结果”分开实际项目里一个函数经常需要同时告诉调用者两件事操作有没有成功以及成功的时候结果是多少。C语言里最常见的约定是“返回值承载状态码指针参数承载业务结果”。int divide_int(int a, int b, int *result) { if (b 0) { return -1; // 失败 } *result a / b; return 0; // 成功 }调用方先判断返回值再使用result。这种模式在标准库和Linux系统编程里四处可见比如scanf返回成功匹配的个数strtol用尾指针传出解析停下的位置。养成这种习惯能规避一个特别隐蔽的问题如果函数既要用返回值传数据又想在出错时返回一个特殊值最后就得用魔法值硬扛。我在代码评审里见过有人用-1表示“没找到”结果某天业务数据恰好算出个-1整个程序就开始安静地错乱排查起来非常痛苦。所以我一贯的主张是返回值优先表达函数执行的“结果状态”而不是业务数值。业务数据用出参、用结构体、用指针来传递都比用返回值硬塞更清晰。3. 参数设计里那些容易被忽略的细节3.1 数组参数为什么默默变成指针以及为什么sizeof会骗你把数组传给函数是C语言里最举重若轻的一个操作也是误解最多的地方。你写void print_array(int arr[]) { int n sizeof(arr) / sizeof(arr[0]); // 你一定以为这就是数组长度 }然后你发现sizeof(arr)的值是8而不是40。原因在于C语言规定数组作为函数参数时会发生“数组退化为指针”。函数签名里的int arr[]本质上会被编译器改写成int *arr。也就是说数组传参时根本没有把整个数组复制进函数只复制了数组首元素的地址。这既节省了内存也带来了一个铁律在函数内部光靠数组参数无法知道数组有多长。解决方案很朴素把长度一并传进去。void print_array(int arr[], int n) { for (int i 0; i n; i) { printf(%d , arr[i]); } }这个“数组长度”参数几乎是所有C函数设计里最不起眼但最重要的一环。我看到不少初学者写的排序、查找函数只传数组不传长度然后靠着某个魔法宏或者全局变量去猜长度一旦数组规模变化程序就开始越界读写。越是老练的人越会在函数签名里老老实实写上int n这不算啰嗦这是让函数可以脱离上下文独立运行的底线。3.2 结构体传值还是传地址这笔账要会算结构体作为函数参数有两种选择直接传结构体副本或者传结构体指针。前者会把整个结构体逐字节复制进函数后者只传一个地址过去。我见过有人写一个保存学生信息的函数结构体里有姓名、学号、几十门课成绩每次调用都把整个结构体传进去代码是能跑编译也通过但程序性能被拖得一塌糊涂。对于这种体积较大的结构体正确姿势是传指针typedef struct { char name[32]; int scores[30]; int id; } Student; void print_student(const Student *stu) { printf(%s %d\n, stu-name, stu-id); }这里的const也很有讲究。const Student *stu的意思是“你不能通过stu修改这个结构体”函数只能读取它。这等于在函数签名里写下一份承诺我只读你的数据不碰你的数据。编译器会在你试图stu-id 100;时报错把这个可能破坏数据的低级失误从运行时提前到编译期。我在团队里一直强调C语言不是只有“能跑”和“不能跑”两种状态还有很多“跑得歪”的状态比如在大型项目里一个函数不该改的数据被悄悄改了造成的连锁故障远比直接崩溃难查。那什么时候适合直接传结构体值呢结构体本身很小比如就两三个int字段而且你的确需要一份副本去修改它不希望影响调用方原来的结构体。这时传值简单直观甚至因为ABI会把部分小结构体塞进寄存器效率也不差。原则一句话需要读不改传const指针需要改传普通指针要副本才传值。3.3 const放在哪里含义完全不同谈到指针参数const就是绕不开的细节。三行代码三种语义const int *p; // p指向一个const int不能通过p修改目标 int *const p; // p本身不能指向别的地址但可以修改目标 const int *const p; // 两者都不能改许多刚学指针的同学看到这几种写法的第一反应是这有什么意义区别可太大了。拿函数签名举例int find_max(const int *a, int n)非常明确地告诉调用方a所指向的数据我不会改你放心传。而int *const p更多用在函数内部你希望p始终指向同一个内存区域避免手误让它飘到别处去。我自己的编码习惯是函数参数里的指针凡是只读用途的一律加const。这其实是一种“接口契约”。写出来既是给自己看的也是给编译器一个把关的机会。等函数代码量大了你很难记住哪个参数是只读、哪个是要被修改的。const一标注别人读你的代码就能少猜很多。3.4 可变参数高回报高风险能不用就别用C语言里还有一类参数个数不固定的函数典型代表就是printf。它靠格式字符串里的%d、%f告诉函数参数的类型和数量然后函数内部通过va_list、va_start、va_arg、va_end逐个取出参数。有个段子说C语言的printf能编译通过但运行起来能给你表演一场“内存读杂技”。比如printf(%d, 3.14)它会把double的二进制位的一部分当作int来读取输出一个莫名其妙的值。在某些编译选项下编译器连警告都不给。这种机制完全没有类型安全可言因为你把“参数有哪些、是什么类型”这件事全都寄托在格式字符串上而格式字符串和后面实际传入的参数是否匹配编译器帮不了多少忙。自己写可变参数函数时比printf更麻烦你必须有一个办法知道参数个数否则va_arg会一路读越界。所以我的原则很简单能不用可变参数就不用。如果只是为了打印调试信息写几个固定参数的格式化函数已经绰绰有余。可变参数适合标准库级、充分验证过的场景不适合日常业务代码里图省事。4. main函数和经典练习题里的“参数-返回值”实战4.1 main不是普通的“函数”它在和操作系统对话main函数的参数和返回值其实是整个程序与操作系统的一场对话。标准写法是int main(int argc, char *argv[])。argc是命令行参数的个数argv是每个参数的字符串数组argv[0]通常是程序名。返回值传给操作系统约定返回0表示正常结束非0表示出错。shell脚本、CI工具、以及系统服务里的启动脚本都是靠这个值来判定程序到底跑没跑成功。C语言还存在一个历史悠久的“void main”争议。很多老编译器和某些嵌入式IDE里能见到void main()但标准C并不认可这种写法。不要小看这一点我之前帮一个项目排查过链接问题启动代码里把main声明成了void换一个工具链之后链接器对返回值的使用出现了预期差排查了半天才定位到就是这个签名惹的祸。所以无论你的程序多小请老老实实写int main(int argc, char *argv[])或者参数不用时可以写int main(void)。4.2 5×5鞍点问题二维数组做参数时最容易漏掉“列数”搜索热门列表里有一道高频题用stdio.h和limits.h求5×5矩阵的鞍点。所谓鞍点是指这个元素在它所在的那一行最大同时在它所在的那一列最小。这类题目特别适合检验参数与返回值的理解因为二维数组作为参数时对“列数”有硬性要求。int find_saddle_point(int matrix[5][5], int *row, int *col) { for (int i 0; i 5; i) { int max_col 0; for (int j 1; j 5; j) { if (matrix[i][j] matrix[i][max_col]) { max_col j; } } int min_row 0; for (int k 1; k 5; k) { if (matrix[k][max_col] matrix[min_row][max_col]) { min_row k; } } if (min_row i) { *row i; *col max_col; return 1; } } return 0; }这段函数分离了两件事先锁定第i行的最大值所在列再在这个列上找出最小值所在行如果那一行正好是第i行说明当前这个元素就是鞍点。返回1表示找到row和col带回坐标返回0表示没有。这里就体现了“返回值装状态指针出参装结果”的设计思路。如果硬要一个返回值同时表示“找到没有”和“坐标是多少”最后要么拼出一个魔法值要么把坐标编码进返回值怎么都别扭。我在讲二维数组的时候还发现很多同学会在函数形参里写int matrix[][5]却漏掉了列数5导致编译报错或无法正确索引。二维数组传参时第一维长度可以不写但第二维长度必须明确因为编译器需要用列数来计算数组寻址的偏移量。这一点是本章概念在实际解题里的最直接延伸。4.3 字符串逆序和冒泡排序应该返回指针还是原地修改字符串逆序是PTA里出现频率很高的题也是初学者最容易纠结“我到底该返回什么”的题。常见的写法有两种void reverse_str(char s[]) { int len strlen(s); for (int i 0, j len - 1; i j; i, j--) { char t s[i]; s[i] s[j]; s[j] t; } }这种“原地反转”版本修改的是调用者传入的字符数组本身函数不需要返回值。调用方执行完这个函数字符串已经被倒过来了干净利落。另一种写法是char *reverse_str(char *s)在函数内部malloc一块新内存返回反转后的新字符串。代价是调用方得记得free。到底选哪个我自己的习惯是优先原地修改。原因很简单资源所有权清晰。函数没有产生新内存调用者不需要记着释放而且字符串逆序这个操作本身没有“保留原串”的必要。反过来如果你的函数需要返回一个全新的、独立于输入的字符串那就老老实实malloc并在注释里写明“返回值需要调用者free”。以后你回头看代码少猜一次内存泄漏。冒泡排序也一样。我写排序函数时几乎总是定义成void bubble_sort(int a[], int n) { for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (a[j] a[j 1]) { int temp a[j]; a[j] a[j 1]; a[j 1] temp; } } } }void返回值并不是“没设计”而是一种设计决策。排序本身是对原数据的重排函数不做新数据生产自然不需要返回新数组首地址。如果你让排序函数返回int*调用方反而容易误以为这份数据是函数新创建的后续处理时出现重复free或者忘记free的问题。函数签名应该努力做到让人一眼看出这个函数会在哪块数据上动手以及它的输出到底放在哪里。4.4 从PAT“霍格沃茨找零钱”看返回类型设计PAT乙级里有一道题叫《在霍格沃茨找零钱》要求处理类似“加隆-西可-纳特”的三级进制找零。很多人一上来就设计一个函数返回一个int表示“应找的钱数”。问题是钱有三种单位返回哪种都丢信息。这道题特别适合当成参数与返回值设计的综合案例。更好的做法是用结构体配合指针出参typedef struct { int galleon; int sickle; int knut; } Money; int change(Money paid, Money cost, Money *result) { long paid_total paid.galleon * 17 * 29 paid.sickle * 29 paid.knut; long cost_total cost.galleon * 17 * 29 cost.sickle * 29 cost.knut; if (paid_total cost_total) return -1; long diff paid_total - cost_total; result-galleon diff / (17 * 29); result-sickle diff % (17 * 29) / 29; result-knut diff % 29; return 0; }这里既有结构体做入参又有结构体指针做出参返回值专门用来表示成功失败。三种都在一个函数里出现。正的金额差异被统一换算成最小单位再折算回去避免逐位借位换算的麻烦。这题做下来参数传值、传地址、返回值状态码这些概念全都能用上比背十遍定义都管用。平时我遇到同学纠结“该用返回值还是出参”都会让他先画一张数据流向图这个函数处理完结果数据到底应该出现在调用者的哪一块内存里如果结果本来就该住在调用者的结构体里那就是出参如果结果需要脱离函数独立存在、有独立生命周期那就要考虑返回值或堆内存。5. 我在函数边界上坚持的几个小习惯踩坑多年攒下来的经验5.1 打开-Wall -Wextra认真对待隐式声明C语言函数原型声明有一个古老的坑如果编译器在调用点之前没有看到函数原型C89标准下会默认假定函数返回int参数类型也按照“提升后的类型”处理。这和你实际定义的类型一旦不一致程序运行就非常魔幻。我调试过一位同学写的double返回函数因为漏了头文件声明编译器以int来读取寄存器结果只拿走低32位输出全是一堆乱码。用gcc或clang时从第一天就打开-Wall -Wextra不要等到程序莫名其妙才想起来开。编译器给出的隐式声明警告十有八九都指向真实存在的接口设计问题。自己写头文件时也要养成习惯所有对外函数原型集中列在一起实现文件里包含自己的头文件这样编译器会自动帮你校验函数定义和原型声明是否一致。这一下就能挡掉大量“函数签名改了一处、调用点忘了改”造成的错位。5.2 命名与注释把函数签名当成一份合同C语言参数没有默认值没有重载函数签名一旦定下来就是一份很硬的合同。我见过太多类似int f(int a, int b, int c)的代码不翻实现根本猜不到参数含义。现在我写函数凡是参数超过三个的几乎都会在头文件里写明每个参数是输入、输出还是输入输出用in/out/inout来标。函数名本身也要尽量带动作语义比如copy_file(const char *src, const char *dst, int overwrite)一眼就能看出源、目标和覆盖策略。这些不是花架子是给三个月后的自己省时间。你永远会高估自己对旧代码的记忆力也永远会低估别人的注释需求。5.3 边界值和哨兵值每写完一个函数都值得多跑几遍说到参数与返回值最实用的测试习惯就是盯边界。空数组、长度0、只有一个元素、最大负数、溢出边界这些才是暴露真相的好地方。字符串逆序如果只有5个字符中间元素刚好不动如果6个字符中间两个要互换。你如果只测奇数长度很可能把一个错误的循环边界带进项目。我改别人代码时会先拿几组边界值快速验证而不是只在正常路径上跑一把。很多“碰巧能跑”的函数都是在边界条件下露出马脚。调试返回值问题还有一个笨办法但非常有效在函数入口和出口各打印一组关键值先定位问题是参数传进来就错了还是函数内部算错了还是返回后处理错了。真正常见的坑仍然集中在“参数复制”和“返回值生命周期”这两类定位到这两个层面问题基本就清楚了一大半。C语言的函数参数与返回值说到底就四个关键词值传递、地址传递、返回状态、生命周期。把swap为什么失效、返回局部数组为什么崩、数组参数为什么得不到数组长度这三件事想明白这一章的核心就算真正嚼透了。剩下的都是在无数个练习题和真实项目中一遍遍强化出来的手感。