C语言指针参数技巧:一次返回多个结果与输出参数模式 很多初学《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 (arr0x7fffffffdf00, n5, pMax0x0, pMin0x0) at demo.c:20 20 *pMax arr[i];一眼看到pMax0x0说明调用者传来的指针是空指针。再往前看调用代码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的时间。