ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C语言函数参数与返回值:值传递、指针、错误处理全解析

C语言函数参数与返回值:值传递、指针、错误处理全解析 函数、参数、返回值——这三个词几乎是每个学C语言的人每天都要打照面的东西。学到这一章很多人其实已经能写出带参数的函数、用return返回结果但真到了项目里还是会被各种“灵异现象”卡住为什么传进函数的数组被改了为什么函数返回的指针一用就崩为什么我明明return了却拿到一个乱值这一篇就是专门把“参数的传递机制、返回值的坑、错误处理的约定”一次说透的适合刚学完函数语法、想进阶到“能写出健壮代码”阶段的读者也适合写过一阵C但总觉得哪里差点意思的开发者。我在工作里排查过好几回跟参数和返回值相关的崩溃说句实话C语言的语法不难难的是把“数据到底怎么流动、生命周期到底归谁管、错了到底怎么通知调用方”这三件事想明白。这篇文章就沿着这三条线往后展开每一条都会有可运行的代码示例和踩坑记录。1. 学函数之前先搞懂这两个词到底在说什么1.1 函数是什么为什么C程序离不开它函数在C语言里就是一段“有名字的、可以反复调用的代码块”。从汇编层面看函数调用对应着call指令、参数压栈、返回值传递、栈帧回收这一整套机制从工程层面看函数是C语言唯一的代码组织单元。没有函数你写一万行的程序就是一万行顺序执行的语句变量名称空间全挤在一起改一处崩三处。但我想强调的是另一个视角函数是“把复杂问题拆成小块”的产物。我看到很多初学者写函数纯粹是为了让main函数短一点把原来写在main里的几十行代码往自定义函数里一贴就算完事。这种叫“搬运”不叫“设计”。真正合理的拆法是一个函数只做一件事并且通过参数去描述“输入”通过返回值去描述“输出”。1.2 参数与返回值函数与外界通信的两个接口参数和返回值是函数跟外部世界打交道的两个通道。参数负责把外部数据“送进去”返回值负责把处理结果“带出来”。这个说法听起来很简单但仔细想会引出一个关键问题函数内部对参数做修改外部那个变量会不会跟着变这个问题的答案直接决定了对C语言数据流模型的理解。C语言里函数参数传递是“值传递”——传给函数的是实参的拷贝而不是实参本身。所以函数内部无论怎么改形参外面的变量一般都毫发无伤。返回值也是这样return语句把某个表达式的值拷贝到调用方。你可能觉得这很基础可正是这个“拷贝”模型引出了后面一大串的坑。1.3 一个函数该拆多细从设计视角看参数和返回值顺着“接口”这个思路往下走其实参数和返回值还承担着一个更重要的责任定义这个函数的使用契约。调用方看到函数签名就应该能回答三个问题我要准备什么东西给它它会还给我什么除了返回值它还会不会改变我的其他东西正因为如此我在实际工作中判断一个函数设计得好不好会看几点参数数量是否超过3到4个超过就要考虑用结构体打包参数指针是不是该加const的地方都加了返回值到底是“计算结果”还是“错误状态”两者有没有混在一起。这些问题看起来是“风格问题”但恰恰是这些风格在决定代码的可读性和可维护性。2. 参数传递C语言只有值传递这一条路2.1 形参实参的关系以及那个经典的swap问题先做个小测试。下面这段代码运行之后a和b的值会交换吗#include stdio.h void swap(int x, int y) { int tmp x; x y; y tmp; } int main(void) { int a 2, b 5; swap(a, b); printf(a%d, b%d\n, a, b); return 0; }运行结果一定是a2, b5交换根本不会发生。原因就是值传递swap拿到的x、y是a、b的拷贝swap内部交换的是拷贝a和b本尊从头到尾没被动过。这是C语言入门最经典的认知门槛。很多人在这一步会卡住其实可以换个比喻来理解值传递就好比你给工厂寄了一张照片工厂按照片样子加工了一个复制品最后把复制品处理完就扔了你手里的原件上没有任何变化。要想真正交换必须把“原件的位置”告诉函数。C语言里没有引用类型所以只能把地址传进去——也就是指针。2.2 用指针“伪造”引用传递的底层原理把swap的参数改成指针之后事情就对了void swap(int *x, int *y) { int tmp *x; *x *y; *y tmp; } int main(void) { int a 2, b 5; swap(a, b); printf(a%d, b%d\n, a, b); return 0; }现在输出的就是a5, b2。这里注意一个细节传递给swap的仍然是“值”——传给x的是a这个地址值本身传给y的是b这个地址值。函数内部通过*x这样的解引用操作间接地访问到了外面那个变量所在的内存空间所以能修改它。很多资料会把这种方式叫“址传递”或者“引用传递”严格说C语言没有引用只有值传递。指针变量传进去的也是一个值只不过这个值恰好是地址。理解到这一层后面遇到“指针的指针”“二维数组参数”这些内容时才不会晕。我自己带过的一些新手在写“需要修改调用方变量”的函数时经常忘记给形参加星号。如果你在函数里写了x ...而本意是想让外面的变量变那多半是漏了一层解引用。这种问题不会报错结果就是变量没变——排查起来特别费劲建议写这类函数时先把形参的“类型”和“层级”写清楚再用解引用去操作目标。2.3 数组参数为什么传着传着就成了指针数组作为函数参数时情况更有意思。看这个例子#include stdio.h void print_size(int arr[]) { printf(inside: %zu\n, sizeof(arr)); } int main(void) { int numbers[10]; printf(outside: %zu\n, sizeof(numbers)); print_size(numbers); return 0; }在常见的64位平台下main里输出的sizeof(numbers)是4010个int乘4字节而函数内部输出的sizeof(arr)是8——因为arr其实已经退化成指针了。C语言规定数组名在表达式里会隐式转换成指向首元素的指针。所以函数参数里写int arr[]编译器和int *arr没有本质区别。这个规则带来的影响很直接在函数内你无法通过sizeof(arr) / sizeof(arr[0])算出元素个数必须额外传一个长度参数。另外要注意数组退化之后函数通过arr[i] x修改数组元素外面的数组是能感知的。因为arr指向的是调用方数组的首元素它不像普通int变量那样被拷贝。也就是说“值传递”传的是那个指针变量本身但指针指向的内存是共享的。二维数组参数就更容易踩坑了。比如int matrix[][4]你写函数参数时第二维必须写上第一维可以省略。原因是编译器需要知道每一行有多少个元素才能计算matrix[i][j]的偏移量。你在函数参数里写int matrix[][]是编译不过的因为编译器根本不知道“一行”有多长。2.4 给参数加const接口契约的第一步指针传参天然能修改外部数据这既是能力也是风险。一个设计良好的函数应该在签名里就把“我只读你不改你”声明清楚——用的就是const。size_t str_len(const char *s);这个const的意思是在函数体内通过s不能修改它指向的字符。一旦你写了s[0] a或者*s a编译器会直接报错。这相当于把接口契约从“文档约定”变成了“编译期强制”防呆效果很好。const还有一个容易忽略的细节实参是char *时可以传给const char *形参反过来却不行。也就是说从const char *到char *是一个“危险”的隐式转换编译器会警告或者报错。这提醒我们如果一个函数只读数据就声明成const char *只有真的要修改才去掉const。这个习惯能帮你在早期就发现很多“试图越权修改只读数据”的问题。3. 返回值比return更值得关心的三件事3.1 return的两层含义与调用堆栈return语句在C语言里干两件事一是立刻结束当前函数的执行把控制权交还给调用方二是有表达式时把表达式的值作为函数结果传回去。这两件事可以在同一个return里发生也可以只做第一件——void函数里的return; 就是纯结束。这里有必要提一下返回值是怎么“传出来”的。在x86-64等主流平台上函数返回整型、指针等值通常直接用寄存器传递比如eax/rax。所以返回值的过程比参数压栈简单得多。但不管用寄存器还是用栈return的都是“值”是拷贝。所以返回一个局部变量的值没问题但返回一个局部变量的地址就有问题了。3.2 返回局部变量地址新手最常见的悬垂指针这是我在代码评审里见过最多、也最容易引起线上崩溃的问题。看这个反面教材int *get_value(void) { int x 42; return x; }函数一返回x所在的那块栈空间就不属于这个函数了。这块空间之后会被其他函数调用覆盖。你拿到的x是一个“悬垂指针”——地址还在内容随时可能变成别的。第一次打印可能还侥幸是42可一旦中间再调用几个函数再回头看这块内存鬼知道里面是什么。正确做法之一是返回静态变量或全局变量的地址int *get_value(void) { static int x 42; return x; }static让x的生命周期变成整个程序运行期间所以返回它的地址是安全的。另一种做法是由调用方提供缓冲区void get_value(int *out) { *out 42; }这个风格叫“输出参数”——通过指针参数把结果写回调用方提供的变量。我的建议是在嵌入式、底层系统这类对生命周期要求严格的场景里优先用输出参数返回值和错误码分开职责代码会清晰很多。3.3 bool返回值、错误码约定以及非法参数的处理很多场景下函数返回的不是“计算结果”而是“状态”。比如判断一个字符串是否为空返回布尔值最自然。C99标准里引入stdbool.h后C语言终于有了正规的bool类型和true/false写法简洁多了#include stdbool.h bool is_empty(const char *s) { return s NULL || s[0] \0; }bool类型函数的命名也有一点习惯在里面通常用is_、has_、contains_这类前缀让调用方一看到函数名就知道返回值表达的是一种判断。这一点在代码可读性上帮助很大。但bool返回值并不能解决所有问题。当函数可能以多种原因失败时只返回true/false就太单薄了。比如一个“把字符串转成整数”的函数可能是输入为NULL也可能是内容根本不是数字也可能是数字太大溢出。这时候函数需要区分“成功”和各种“失败原因”常见的做法是返回错误码用0表示成功非0表示各种错误。这就要说到“非法参数异常”这个话题了。C语言没有异常机制函数拿到非法参数时没法“抛异常”只能靠返回值约定告诉调用方我没处理你给的参数不对。所以我在写库函数时习惯先做一遍参数检查把NULL指针、越界长度、非法索引这些情况都拦在入口处然后返回对应的错误码。虽然多写几行判断但能把大量“崩溃在内部、甩锅给调用方”的问题扼杀在入口。3.4 main函数的参数与返回值程序与操作系统的握手很多初学者以为main函数就是int main(void)其实完整的入口签名往往是int main(int argc, char *argv[])argc表示命令行参数个数argv是参数数组。这俩参数就是程序跟操作系统之间的“函数接口”。你运行./app test.txt时argc就是2argv[0]是./appargv[1]是test.txt。要写一个支持命令行传参的工具靠的就是这两个参数。main的返回值也值得说。传统约定是返回0表示程序正常结束返回非0表示出错。这个值最终会被操作系统拿到在shell里你可以用echo $?去检查上一个程序的退出码。所以如果你的程序有错误分支别全都return 0把不同错误返回成不同的非0值脚本里才能精确判断失败原因。我工作里看过不少人在main尾部就写一个return 0;了事错误分支全走exit(1)——这本身没错但如果你想把错误类型细化一点不同分支返回不同退出码外面自动化脚本的排障效率会高很多。4. 函数声明、函数指针与回调参数的高级玩法4.1 声明和定义为什么总是需要原型C语言要求“先声明后使用”。函数的定义和声明可以分开定义是完整写出函数体声明是只写函数签名结尾加分号。如果你在调用一个还没声明的函数老式编译器会默认它返回int并且不对参数做任何检查——这个“隐式声明”在C99标准里已经明确不合法了可还有不少老代码或老教程在这么搞。隐式声明的危害在参数类型不一致时尤其明显。比如你写了一个返回char *的函数没声明就直接调用编译器可能当成返回int然后从返回值寄存器里只取部分数据拿到的指针就残缺了。这种问题错得非常隐蔽查起来特别浪费时间。所以我的习惯是所有自定义函数要么定义在使用之前要么在文件头部写清楚原型声明。如果函数要被多个文件共用就把原型放进头文件源文件里#include头文件即可。不要嫌麻烦这一行声明能帮你拦住一大批“调用方猜错签名”的毛病。4.2 函数指针做参数把“行为”当作参数传递C语言的函数参数可以是值、是指针、是结构体甚至还可以是“指向函数的指针”。函数指针作为参数常用于实现回调机制——你告诉我“在某种情况下帮我干某件事”我把“干某事”的具体函数传过去。先看怎么定义一个函数指针类型。比如要定义一个“接收一个int、返回一个int的函数指针”可以写int (*handler)(int);这行代码有点反直觉建议拆开看(*handler)说明handler先是一个指针再往外看int ... (int)说明指向的函数返回int、接收一个int参数。用typedef包一层会更清爽typedef int (*mapper_t)(int);这样mapper_t就成了一种“函数指针类型”后面用它声明变量跟声明int变量一样直观。4.3 回调函数的一个实战场景给一个数组里的每个元素都应用某个变换是函数指针做参数最经典的例子。下面这个map函数把传入的mapper作用到每个元素上#include stdio.h typedef int (*mapper_t)(int); void map(int *arr, size_t n, mapper_t fn) { if (arr NULL || fn NULL) { return; } for (size_t i 0; i n; i) { arr[i] fn(arr[i]); } } int double_it(int x) { return x * 2; } int main(void) { int a[] {1, 2, 3}; map(a, 3, double_it); for (int i 0; i 3; i) { printf(%d , a[i]); } return 0; }输出结果是1 2 3变成2 4 6。这里map函数完全不知道“double_it内部怎么实现”它只依赖“传入的函数能满足int (int)这个签名”。代码的灵活性和复用性一下子就上来了。回调机制在真实工程里到处都是定时器注册超时回调、按键驱动注册中断回调、信号处理、事件循环……所有“框架等着你的代码来执行”的场景底层几乎都是函数指针。学到这里参数就不仅仅是数据了还可以是“逻辑”。这也是我眼里C语言函数参数最有魅力的一层。回调参数里还有一个常见坑如果回调要在稍后被调用而它需要的“环境数据”无法通过参数传过去怎么办通用的解法是给回调加一个void *ctx参数用来携带自定义上下文。框架调用回调时原样把ctx传回去这就绕开了“函数指针只能传一个函数、不能夹带私人数据”的限制。我写嵌入式驱动时几乎每个回调都会留一个ctx算是经验之谈。5. 综合案例设计一个带错误处理的安全字符串工具5.1 需求与接口设计前面把参数和返回值的知识点铺完了这里把它们合起来做一个“能直接抄进项目”的小工具安全的字符串拼接。原生strcat不给目标缓冲区容量函数没法知道边界拼接时内存越界防不胜防。我们要写的函数要解决三件事输入参数加const只读源串校验目标缓冲区是否足够大不够就返回错误码通过输出参数把“实际需要多少空间”告诉调用方。先定义错误码和函数原型#define STR_OK 0 #define STR_ERR_NULL -1 #define STR_ERR_SPACE -2 int str_safe_append(char *dst, size_t dst_size, const char *src, size_t *need_size);这个函数签名里每个参数都有自己的明确角色dst是可修改的目标缓冲dst_size是目标缓冲容量src是只读源串need_size是可选输出参数——当空间不足时函数把真实需要的字节数通过它告诉调用方。一个函数同时用上了const保护、显式长度参数、错误码返回值和输出参数。5.2 完整实现与关键点拆解#include stdio.h #include string.h #define STR_OK 0 #define STR_ERR_NULL -1 #define STR_ERR_SPACE -2 int str_safe_append(char *dst, size_t dst_size, const char *src, size_t *need_size) { size_t dst_len; size_t src_len; size_t total; if (dst NULL || src NULL || dst_size 0) { return STR_ERR_NULL; } dst_len strnlen(dst, dst_size); if (dst_len dst_size) { return STR_ERR_NULL; } src_len strlen(src); if (src_len (size_t)-1 - dst_len - 1) { return STR_ERR_SPACE; } total dst_len src_len 1; if (total dst_size) { if (need_size ! NULL) { *need_size total; } return STR_ERR_SPACE; } memcpy(dst dst_len, src, src_len 1); return STR_OK; }代码里这几个点值得细说。strnlen限制了读取dst的长度不超过dst_size防止dst本身没有终止符时发生越界读取。if (src_len (size_t)-1 - dst_len - 1)是在做整数溢出防护防止加法溢出后绕过空间判断。这些细节看起来啰嗦但恰恰是C语言写“安全工具”和写“玩具代码”的分水岭。再看调用方的体验int main(void) { char buf[8] Hello; size_t need 0; int ret; ret str_safe_append(buf, sizeof(buf), , world!, need); if (ret STR_ERR_SPACE) { printf(space not enough, need %zu bytes\n, need); return 1; } if (ret STR_ERR_NULL) { printf(invalid argument\n); return 1; } printf(%s\n, buf); return 0; }如果目标缓冲区只有8字节而追加内容需要一个更大空间这个程序会打印space not enough, need 14 bytes而不是悄悄把内存写穿。这就是错误处理的现实意义问题发生时不至于崩溃还给调用方留了补救信息。5.3 调用方怎么写才不容易出错设计完一个安全函数调用时的习惯同样重要。我在实际评审代码时发现调用方常见的毛病是不检查返回值直接往下走。尤其很多项目里的字符串处理函数失败率不算高于是大家习惯“凭感觉认为它不会失败”。一旦漏了检查出错之后程序状态已经坏掉后续很难追。所以调用方的铁律有两条第一返回值必须检查零值代表成功非零代表失败别跳过第二输出参数使用前先想清楚它可能不会被写——比如函数在成功路径上不写need_size调用方就不要提前使用这个值。如果拿不准就把局部变量先初始化为0再加判断。6. 常见问题与排查技巧实录6.1 编译期报错速查编译期冒出来的错误和警告大部分跟参数、返回值有关这里整理一张速查现象常见原因处理方式编译报implicit declaration of function函数没有声明或定义就使用补原型声明或调整定义位置报conflicting types for xxx多次声明的函数签名不一致检查头文件与源文件的函数原型报assignment to ‘char *’ from incompatible pointer type把const char *赋给char *变量确认类型归属需要修改时才去掉const数组参数里写int a[][]报错二维数组第二维缺失写成int a[][N]N为列数函数体内用sizeof(arr)算数组长度失败数组参数已退化为指针额外传入长度参数编译警告里有一条特别值得留意如果代码里出现了“function returns address of local variable”那不是警告是灾难预告。GCC在较高警告等级下能帮你查出“返回局部变量地址”的问题一定要当错误处理。6.2 运行期崩溃与逻辑错误的排查思路运行时的问题往往比编译期更头疼因为现象和原因经常隔得很远。按我排查经验顺序应该是“先定位错误类型再缩小范围”。如果程序一跑就段错误Segmentation fault优先怀疑三类情况指针为NULL还被解引用指针悬垂指向已释放或已失效的内存数组越界写坏了关键数据。排查时先在gdb里跑一遍bt看调用栈再print可疑指针——很多问题一眼就能看出来。如果函数返回值“看起来奇怪”比如该返回正整数却返回了负数或者反复无常先检查是不是没有写return。C语言对非void函数漏写return的行为是未定义编译器不一定报错但运行结果不可预期。给编译器加上-Wall -Wextra这类问题能提示一部分。如果“传进去的数组在函数里变了但我没想让它变”那多半是数组退化导致的。仔细检查函数体里有没有对形参的操作。如果你在函数里只是打算读取最好把参数声明成const int arr[]编译器会帮你盯着。排查逻辑类问题我还有一个笨但极有效的方法在每个函数入口和出口打印关键参数与返回值。这不是什么高深技巧但在C这种“什么都要自己管”的语言里打印是成本最低的探针。确认了入口数据没问题、出口数据没问题问题就锁定在函数体中间那几行再配合单步调试基本都能水落石出。最后再分享一个小技巧。写函数时我习惯先在纸上或者注释里写好“函数签名 三行说明”包括参数含义、返回值含义、谁来分配内存然后再填函数体。这样写出来的函数参数和返回值从一开始就边界清晰后面不管是调优还是改bug都比边写边想舒服得多。这套方法我在嵌入式、服务端、命令行工具里都验证过算是最朴素也最耐用的C语言设计经验。
返回列表