很多初学《C语言程序设计(第四版)》(何钦铭、颜晖主编)的读者,学到第八章指针时都会在同一个地方卡住:函数明明只能return一个值,可实际需求却经常要求它一次返回好几个结果。比如除法运算要同时拿到商和余数,解一元二次方程要拿两个根,统计数组想把最大值、最小值和平均值一次带回来。这个问题的标准答案,就是本章的重点之一:利用指针作为函数参数,把多个结果“带”出函数。它还有一个更正式的名字——输出参数模式,是C语言里极其重要的一种接口设计方式。
这篇文章我打算用尽量直白的方式,把“为什么需要这种写法”“底层原理是什么”“代码怎么写”以及“我实际调试中踩过的坑”全部讲清楚。适合正在学指针的新手,也适合准备讲这一章的老师,以及刚接触嵌入式开发、天天跟指针打交道的初学者。
1. 为什么要“返回多个结果”
在我带过的学生里,几乎每个人都会在第一次看到int *p作为函数参数时问一句:C语言为什么不能直接return a, b?因为它没有Python那类元组,也没有现代语言的解构赋值。语法层面,函数只能通过return返回一个值,这个值是整个函数表达式计算后的结果。那多个值怎么出来?只能通过参数的通道。
1.1 return的限制与C语言的多返回值诉求
C语言的函数调用规则很简单:调用者通过“实参”把数据送进函数,函数通过return把一个结果送回调用者。注意,这里的“一个”是硬性限制,不管你是返回int、double还是结构体,对函数调用机制来说都只算“一个值”。
但真实需求从来不是教科书上的“输入一个数,输出一个结果”。写程序时经常遇到这些场景:
- 算术类:计算商和余数,或者把一个数拆成整数部分和小数部分;
- 查找类:在数组里找到最大值,同时告诉你最大值在哪个下标;
- 转换类:角度转弧度,想拿到转换结果,还要知道转换是否成功;
- 统计类:处理一串成绩数据,想把最高分、最低分、平均分一次全部算出来。
这些需求有一个共同点:一次计算,多个独立的结果。如果只靠return,你只能选择其中一个输出,其他结果都要另外想办法。而C语言里最自然、最轻量的“另外想办法”,就是指针参数。
1.2 三种常见的替代方案,各有取舍
既然函数不能直接返回多个值,那绕过这个限制的办法大致有三种:全局变量、结构体打包、指针参数。我把它们放在同一张表里对比,你一眼就能看出为什么最终要落到指针上。
| 方案 | 实现方式 | 优点 | 缺点 | 典型场景 |
|---|---|---|---|---|
| 全局变量 | 函数里直接修改全局变量 | 调用方拿起来方便,不用传参 | 状态被任意代码改动、不可重入、多线程安全差、函数不纯粹 | 单片机简单demo、课堂演示 |
| 结构体返回 | 定义一个结构体,把所有结果塞进去return | 一次可以带多个数据,类型明确 | 每个场景都要定义结构体,稍显重量;旧C标准下大结构体有拷贝开销 | 需要一次性返回一批关联字段的接口 |
| 指针参数 | 函数通过形参指针写入调用者提供的变量 | 轻量、零拷贝、语言原生支持,返回值的位子还能留来表示状态 | 使用时要小心空指针、内存归属等问题 | 库函数接口、嵌入式底层驱动、工程代码 |
这三种方案没有绝对的对错,但指针参数是C语言里最通用、最接近底层的做法。你会发现很多经典C库函数都用这个模式:比如fscanf一边返回读取项数,一边通过变量指针把读到的值带出来;scanf系列函数更是把这个模式用到了极致。教材第八章把它作为重点,不是因为这道题考得多,而是因为这是读源码、写接口、做嵌入式开发的家常便饭。
1.3 为什么说这是接口设计能力的分水岭
注意一个细节:指针参数不仅能“带结果出来”,还能让函数保留return来表达“这次调用发生了什么”。比如一元二次方程求解,return 0表示无实根,return 1表示有两个相等实根,return 2表示有两个不同实根,而真正的根是通过指针参数带出去的。这种“返回值放状态,指针输出放数据”的写法,就是工程里最基本的接口设计模式之一。
很多同学一开始觉得这只是语法技巧,等真正开始写超过几百行的项目、要阅读别人封装的代码时,才发现这种模式无处不在。所以第八章这一小节,值得你多花点时间彻底弄透。
2. 指针参数的本质:从传值到传址
要理解“指针如何返回多个结果”,第一步不是背语法,而是先想清楚函数参数传递的本质:C语言默认是传值调用,形参是实参的副本。
2.1 传值调用为什么改不动实参
先看一个几乎所有初学者都写过的失败代码:
void TryModify(int x) { x = 100; } int main(void) { int a = 1; TryModify(a); printf("a = %d\n", a); // 输出 a = 1 return 0; }运行结果让很多人蒙了:函数里明明给x赋了100,为什么a还是1?因为传给函数的并不是变量a本身,而是a的值拷贝。x和a是两个不同的内存单元,函数内修改x,对a毫无影响。
打个比方:你把简历复印件交给面试官,面试官在复印件上写了“录用”,但你手里的原件一个标点都没变。要真正影响原件,你得直接把原件递过去。对应到C语言,就是把变量的地址递过去。
2.2 输出参数的正确写法
传地址版本长这样:
void TryModify(int *p) { *p = 100; } int main(void) { int a = 1; TryModify(&a); printf("a = %d\n", a); // 输出 a = 100 return 0; }这里p指向a,*p就等价于a,所以函数内写*p = 100等于直接修改了a所在的内存单元。这个代码里有三个关键点:
- 形参声明用
int *p,明确告诉编译器“我需要一个地址”; - 实参必须传
&a,把变量地址给出去; - 函数体内用
*p赋值,而不是用p赋值。
注意第3点的差异,它是一大堆bug的来源。p本身是一个“存放地址的变量”,如果写p = 100,意思是让这个指针变量重新指向地址100,这是非法操作;只有*p = 100才是往目标地址写入100。这个区别后面的错误排查部分还会再讲。
2.3 让return专注状态,让指针专注数据
把传地址和多个结果结合起来,思路就清晰了。return只负责一个“窄门”,但指针参数就像你在函数外面提前挖好的几个收货口,函数结果出来时直接往这些口里填数据。函数本身同时可以报一声“成功还是失败”“根有几个”“查找有没有找到”。
用一个简单例子来体会这种分工。比如写一个“安全除法”函数:除数为0时不能直接除,否则算出来的结果是无法确定的浮点值。
int SafeDivide(int a, int b, double *pResult) { if (b == 0) { return 0; // 除数为0,返回失败 } *pResult = a / (double)b; return 1; // 成功 } int main(void) { double result = 0.0; if (SafeDivide(1, 2, &result)) { printf("result = %.2f\n", result); } else { printf("divide by zero!\n"); } return 0; }这里pResult是典型的输出参数。返回值表示状态,方便调用者用if做判断;指针带出的result才是真正的业务数据。学完这个例子,你再看fread、read、gets_s这类库函数,会发现它们全是同一套套路。
3. 教材式例题拆解:四个可直接复用的函数
下面从教材常见的习题出发,给出四个可以直接复制运行的函数。每个函数的设计都遵循同一个原则:返回值用于状态和简单标记,指针参数用于输出多个结果。
3.1 一元二次方程求根
一元二次方程ax² + bx + c = 0是最典型的“一个输入,多个输出”案例,因为它天然有两个根。
#include <stdio.h> #include <math.h> int SolveQuadratic(double a, double b, double c, double *pX1, double *pX2) { double delta = b * b - 4 * a * c; if (delta < 0) { return 0; // 无实根 } if (delta == 0) { *pX1 = -b / (2 * a); // 两个相等实根 *pX2 = *pX1; return 1; } double sqrtDelta = sqrt(delta); *pX1 = (-b + sqrtDelta) / (2 * a); *pX2 = (-b - sqrtDelta) / (2 * a); return 2; // 两个不同实根 } int main(void) { double x1 = 0.0, x2 = 0.0; int n = SolveQuadratic(1.0, -3.0, 2.0, &x1, &x2); if (n == 2) { printf("x1 = %.2f, x2 = %.2f\n", x1, x2); } else if (n == 1) { printf("x = %.2f\n", x1); } else { printf("no real root\n"); } return 0; }这个函数里,返回值不只是用来区分几种情况的,它还告诉调用者“指针参数里到底哪些数据是有意义的”。如果返回0,说明pX1、pX2里什么都没有,强行读取就是未定义数据;如果返回1,只有pX1是有效的。这种约定在工程里非常常见:先检查返回值,再决定能否使用输出参数的值。
3.2 求商和余数
除法取得商和余数,是教材和考试里几乎必出现的题目。写法很简单:
void DivMod(int a, int b, int *pQuotient, int *pRemainder) { *pQuotient = a / b; *pRemainder = a % b; } int main(void) { int q = 0, r = 0; DivMod(17, 5, &q, &r); printf("quotient = %d, remainder = %d\n", q, r); return 0; }细想一下,这个函数其实和C标准库里的div函数思路一致,只不过div选择用结构体把商和余数一次性返回。如果你只需要两个整型结果,定义一个结构体确实有点“杀鸡用牛刀”;而指针版本零拷贝、零结构体定义,在单片机这类讲究资源的场景下更轻快。顺带提一句,单片机C程序的栈空间通常很有限,如果用返回结构体去带数据,栈拷贝开销和结构体占用的空间都要算进去;指针输出的方式是直接写调用者提供的内存,几乎不产生额外栈开销。这也是嵌入式底层代码里到处都是指针参数的原因之一。
3.3 数组最大值和它的下标
查找最大值,经常还要同时找出“它的下标”,因为后续可能要根据下标去定位相邻元素或做删除操作。下面的函数用返回值返回下标,用指针输出最大值。
#include <stdio.h> int FindMax(const int arr[], int n, int *pMaxValue) { if (arr == NULL || n <= 0) { return -1; // 错误返回 } int maxIndex = 0; for (int i = 1; i < n; i++) { if (arr[i] > arr[maxIndex]) { maxIndex = i; } } *pMaxValue = arr[maxIndex]; return maxIndex; // 下标即“成功标记”,-1为错误 } int main(void) { int data[] = {3, 9, 4, 7, 5}; int value = 0; int index = FindMax(data, 5, &value); if (index >= 0) { printf("max value = %d, index = %d\n", value, index); } return 0; }这里我用const int arr[]声明数组参数,意思是告诉编译器“这个数组在函数内只读不写”。这种细节非常重要,它让读代码的人一眼就能分辨哪些参数是输入、哪些参数是输出。本小节后面讲const的时候还会展开。
3.4 最大值、最小值和平均值批量输出
一次输出三个统计值,是“返回多个结果”最直白的体现。注意pMax、pMin刚进入函数时就要被赋值,不能空着,否则第一轮的比较就没有基准值了。
#include <stdio.h> double AnalyzeArray(const double arr[], int n, double *pMax, double *pMin) { if (arr == NULL || n <= 0) { return 0.0; // 错误,返回0并避免后续崩溃 } double sum = arr[0]; *pMax = arr[0]; *pMin = arr[0]; for (int i = 1; i < n; i++) { if (arr[i] > *pMax) { *pMax = arr[i]; } if (arr[i] < *pMin) { *pMin = arr[i]; } sum += arr[i]; } return sum / n; // 返回值继续用来送平均值 } int main(void) { double scores[] = {85.5, 92.0, 78.5, 96.0, 88.5}; double max = 0.0, min = 0.0; double avg = AnalyzeArray(scores, 5, &max, &min); printf("max = %.2f, min = %.2f, avg = %.2f\n", max, min, avg); return 0; }这个函数非常实用。注意return同时还在承担“平均值”的输出任务,等于函数用了三条通道返回数据:一条return,两条指针参数。你完全可以把average也放进指针参数,让函数变成纯void类型;但那样调用者就少了一个优雅地写在赋值语句里的机会。具体怎么取舍,取决于需求:如果状态和平均值都需要,按这个写法来就很好。
4. 接口设计与工程避坑
会写例题只是第一步。真正到了项目里,还要考虑接口可读性、内存归属和安全性。下面几条经验,我觉得比语法本身更值得记。
4.1 参数顺序:入参放前面,出参放后面
C语言函数没有命名参数的概念,传参全靠位置。如果输入参数和输出参数混在一起,调用者很难一眼看出数据流向。工程里最常见的惯例是:输入参数放在前面,输出参数放在后面。
int FetchConfig(const char *path, int *pTimeout, int *pRetryCount);看到这个签名,阅读者就能形成直觉:path是输入,后面两个指针是函数要填数据出来的位置。反过来,如果写成int FetchConfig(int *pTimeout, const char *path, int *pRetryCount),读起来就很别扭,还容易在传参时把实参顺序搞错。
参数命名上也可以再加一层信号。有人习惯在输出参数前加p或pOut,比如pOutMax、pResult;有人用out_前缀。没有绝对标准,但同一个项目里必须统一。我在代码审查时经常看到命名混乱的函数,调用的地方根本不知道哪个参数会被函数修改,这种代码后期维护成本特别高。
4.2 内存归属:谁分配,谁释放
指针输出结果时,目标内存由谁提供,这是一个必须提前定清楚的问题。最稳妥的模式是:调用者准备内存,函数只负责写入,并且函数必须清楚内存的大小边界。
看一个最常见的不安全写法:
void GetUserName(char *name) { strcpy(name, "zhang_san"); // 危险:如果name空间不够就溢出 }调用者如果只准备了一个5字节的缓冲区,字符串根本放不下。正确做法是把缓冲区长度也传进来:
void GetUserName(char *name, int nameLength) { strncpy(name, "zhang_san", nameLength - 1); name[nameLength - 1] = '\0'; }字符串输出有一堆细节要处理:长度不够时要截断,截断后要手动补'\0',函数内部绝对不能假设缓冲区足够大。这不仅是代码规范问题,更是安全底线。反过来,如果某些函数需要动态分配内存再返回,那内存到底由调用者释放还是函数内部释放,也必须写清楚;C语言里没有垃圾回收,谁分配谁释放,是最基本的规矩。
4.3 const的边界:哪些参数只读,哪些必须可写
const在指针参数里有两种含义,很多初学者分不清。const int *p表示p指向的值不能通过p修改,这叫“指向常量的指针”;int *const p表示p本身不能重新指向别处,这叫“常量指针”。
在输出参数的场景里,我们一般希望函数通过指针写数据,所以输出参数通常不写const,例如:
void GetMax(int *pMax); // 正确:pMax指向的变量会被改写如果你错误地声明成:
void GetMax(const int *pMax);然后在函数里执行*pMax = 10;,编译器会毫不留情地报错:无法向只读内存写入。这个错误其实是个“好事”,因为编译器在你写const的时候就替你发现了接口设计的问题。所以正确的习惯是:入参能加const就加,比如const int arr[]、const char *path;出参不要乱加,因为你本来就打算修改它。
4.4 迷之错误:返回局部变量的地址
和“指针参数返回多个结果”密切相关的还有一个高频错误:函数返回局部变量的地址。
int *GetNumber(void) { int value = 10; return &value; // 危险!value是函数内的局部变量 }这个函数一结束,value所在的那块内存就“归还”给系统了,但指针还留着。编译器也许会警告:函数返回局部变量的地址。哪怕程序碰巧还能打印出10,那也只是内存里的旧数据还没被覆盖,运气好而已。下一次其他函数调用把这块栈空间顶掉,你用这个指针拿到的就是垃圾值,甚至解引用时直接崩溃。
正确处理有三种:
- 用
static int让变量生命周期延续,但这样函数不可重入,多线程有风险; - 在函数内部用
malloc分配堆内存,但要由调用者负责free,接口要写清楚; - 最推荐的做法:把变量定义在调用者那边,把地址传进函数,用指针参数输出。
void GetNumber(int *pValue) { *pValue = 10; // 写入调用者提供的变量 }这个原则,就是本章“利用指针返回多个结果”的核心精神:调用者准备容器,函数负责填内容。想通这一点,指针就不再是玄学。
5. 常见错误定位与排查实录
下面的错误,我几乎每次课程答疑都会遇到。整理成一份速查表,你在调试时可以直接对照。
| 现象 | 根本原因 | 快速排查方法 |
|---|---|---|
| 编译有warning,运行时崩溃 | 调用时忘记给输出参数传地址 | 看编译警告,检查是否写成了func(x1)而不是func(&x1) |
| 函数调完,原变量还是旧值 | 函数内写了p = 值,而不是*p = 值 | 打印指针指向地址,确认是否指向原变量 |
| 指针指向的数据时灵时不灵 | 返回了局部变量的地址 | 检查返回地址是否来自函数内部局部数组 |
解引用*p时直接段错误 | 传入的是空指针或野指针 | 用调试器查看指针值,是否接近0地址 |
| 字符串输出一片乱码 | 缓冲区没补'\0',或空间不够 | 检查strncpy后有没有手动补结束符 |
5.1 忘记取地址:编译警告加运行时崩溃
最常见的一幕是学生写一元二次方程求解时这样调用:
double x1, x2; SolveQuadratic(1.0, -3.0, 2.0, x1, x2); // 漏了 &编译器会提示类似 “passing argument 4 of ‘SolveQuadratic’ makes pointer from integer without a cast” 的警告。很多人看到是warning就忽略了,结果程序一跑直接段错误。为什么?因为函数把你辛辛苦苦算出来的x1值(比如0.0)当成了地址,往0地址附近写入解的结果,立刻踩进内核保护区。
排查方法其实很简单:每次编译时盯住warning,把它当成error处理。如果程序崩溃,用gdb启动:
gdb ./program run btbacktrace会指到SolveQuadratic里的*pX1 = ...那一行,再回头看调用处,缺少&一般就一眼能发现了。
5.2 把 p = 写成 *p =
另一个经典错误是函数内部逻辑写成了:
void TrySet(int *p) { p = 100; // 错误:改变了指针变量本身,而不是它指向的变量 }这行代码编译时可能说“赋值使指针来自整数”,程序运行后实参完全不变。原因前面已经说过:p是一个指针变量,它存储的是某个内存单元的地址;赋值p = 100是把这个指针变量本身的内容改成100,和外部变量毫无关系。正确写法是:
*p = 100;我在检查代码时,第一步就全局搜“函数体内有没有对形参指针直接赋值、却没有马上解引用”的痕迹。如果你也有类似困惑,最简单的验证方法是打印指针变量里的地址:
printf("p = %p, &x = %p\n", (void *)p, (void *)&x);如果两个地址不一样,说明你手上的p并不是外部位变量的地址。
5.3 实参指针重新指向别处
有一种更隐蔽的变体。调用者传了一个指针进来,函数内部却把这个形参指针重定向到其他地方,导致后续写入全部落空。比如:
void FindFirstChar(const char *str, char *pOutput) { if (str == NULL || pOutput == NULL) { return; } pOutput = str; // 错误:只是让形参指向str,调用者的pOutput没变 }这种代码不会立即崩溃,所以排查起来更麻烦。正确的输出方式是把字符拷贝到*pOutput指向的变量里,而不是让形参指针重新指向别处。要记住:函数内部的形参指针本身就是一份拷贝,你改形参指针的值影响不了调用者那边的指针变量;但你可以通过解引用去修改它指向的内存内容。
排查这类问题时,我习惯打印调用者函数里的指针值和被调函数里的形参指针值。如果两者地址不一致,说明函数内重新赋值过,那就要仔细审视每一行xxx = ...是不是漏写了*。
5.4 传入空指针
输出参数最常见的越界行为是用户传入了NULL。比如写一个封装函数:
void GetResult(int *pResult) { *pResult = 42; // 如果pResult为NULL,这里直接段错误 }调用者如果忘了给指针变量初始化,或者某个错误分支没找到数据就传了NULL,程序一样会崩溃。所以内部函数的安全写法是:
void GetResult(int *pResult) { if (pResult == NULL) { return; } *pResult = 42; }这个模式叫防御性编程。你别觉得“别人怎么会传NULL进来”,大型项目一旦规模上去,各种调用路径都会超出你的预期,一个低成本的if (pResult == NULL)能省掉无数深夜定位问题的时间。
5.5 字符串输出缓冲区溢出的坑
“返回多个结果”并不局限于返回数值。当函数要输出字符串时,问题更加尖锐,因为字符串天然没有长度约束。常见的坏代码:
void ConcatStr(char *dst, const char *src) { sprintf(dst, "%s%s", dst, src); // 不检查长度 }一旦dst的空间不够,sprintf会无脑继续写,把相邻内存摧毁,可能触发安全的“缓冲区溢出”。正确做法是用snprintf,并且显式传入目标缓冲区的大小:
void ConcatStr(char *dst, int dstSize, const char *src) { if (dst == NULL || src == NULL || dstSize <= 0) { return; } snprintf(dst, dstSize, "%s%s", dst, src); }类似的还有strncpy不自动补'\0'的坑,我见过太多人用strncpy(dst, src, strlen(src)),结果dst后面没有结束符,打印出一串乱码。用指针做字符串输出参数时,缓冲区长度和结束符是永远要挂在心上的两件事。
5.6 一次典型段错误的排查过程
最后分享一个真实的排查场景,方便你把这些经验串起来。
一位同学写了一个数组统计函数,调用它计算成绩的最大值和平均值。代码运行到一半,终端直接输出Segmentation fault。我让他先用gdb定位:
(gdb) run Program received signal SIGSEGV, Segmentation fault. 0x0000555555555181 in AnalyzeArray (arr=0x7fffffffdf00, n=5, pMax=0x0, pMin=0x0) at demo.c:20 20 *pMax = arr[i];一眼看到pMax=0x0,说明调用者传来的指针是空指针。再往前看调用代码:
double max = 0.0, min = 0.0; double *pMax = NULL; double *pMin = NULL; AnalyzeArray(scores, 5, pMax, pMin); // 这两个传的不是&max、&min!问题找到了:调用者声明了两个变量max、min,却把指针变量pMax、pMin初始化为NULL,然后直接把NULL传进去。函数里一写*pMax就崩溃。修复很简单,要么把pMax、pMin定义为指向max、min的指针,要么调用时直接传&max, &min。这类段错误最大的迷惑点是:运行前完全没报错,逻辑看起来也对,只有运行到函数内部写入时才炸掉。记住一个规律:如果崩溃点在“往指针指向的内存写值”那一行,大概率是指针指向了非法地址或空地址。
6. 我个人的实操体会
指针做输出参数这个话题,看似只是第八章的一个知识点,但它背后是“数据流向”的思维方式。我个人的体会是:写C代码,最重要的问题不是“某一行语法怎么写”,而是“这块数据从哪来、往哪去、谁拥有它”。指针参数之所以难学,是因为它强迫你把这三个问题想清楚。
我后来写嵌入式驱动时,发现这招用得是最多的。芯片寄存器的状态要读出来,要同时返回状态值和配置是否成功;ADC采集要拿到采样结果还要知道通道号;串口收发要返回收到多少字节、能不能写入。这些API几乎清一色是“返回值放状态,指针参数放数据”的套路。可以说,把第八章这个知识点吃透,再去读HAL库、标准库和很多开源代码,会顺畅非常多。
如果你正在刷题,建议专门挑几道有“同时返回多个量”需求的题目练手,比如:
- 写一个函数,返回数组中间元素的值和下标;
- 写一个函数,把秒数拆成时、分、秒,一并输出三个整数;
- 写一个链表的插入函数,返回插入位置并输出链表节点数。
我在实际练习中还发现一个窍门:调试的时候不要吝啬打印,把“指针变量本身的地址”和“指针指向的地址”都打出来。很多看起来玄乎的问题,一旦看清了指针指向哪里,立刻就不玄了。这个习惯一直留到现在,帮我省了不少定位bug的时间。