尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C语言函数从入门到实战:定义、指针传递、递归与回调全解析

C语言函数从入门到实战:定义、指针传递、递归与回调全解析 C语言学到“函数”这一章很多人会有一种“好像懂了但又不太会用”的感觉知道函数能封装代码知道 main 函数会先执行但一写就露馅——函数声明不知道放哪个文件写个 swap 换不了两个变量的值字符串逆序的函数返回后就乱码稍微复杂点的题目只会往 main 里堆代码。我自己在带新人、看校招笔试代码时这类问题见得太多。这篇文章就以“C语言函数”这条线把从定义、调用、参数传递到声明、作用域、递归、回调这些点挨个拆开该上代码的上代码该讲原理的讲原理最后再把我踩过的真实报错和排查思路分享出来希望能帮正在学这一章的你把函数真正用明白。1. 为什么必须学会用函数从复制粘贴到模块化思维1.1 没有函数的代码长什么样先看一个真实的练习场景。PTA 上有一类题给三个数组分别做逆序输出、求和、找最大值。如果你刚学完循环大概率会写三份几乎相同的 for 循环代码for (int i n - 1; i 0; i--) { printf(%d , arr1[i]); } for (int i n2 - 1; i 0; i--) { printf(%d , arr2[i]); } for (int i n3 - 1; i 0; i--) { printf(%d , arr3[i]); }这还只是逆序输出再想想求和、找最大值、排序。你会发现代码量翻倍增长改一个逻辑就要在所有复制粘贴过的地方重新改一遍。我帮人调试代码时看过最夸张的一次一个 main 函数里堆了三百多行全是这种复制粘贴的循环块改一个输出格式得改四处漏改一处就答案错误。这不是你不聪明而是你还没有把“重复”抽象出来。C语言函数就是干这个的把一段需要重复执行、且有明确输入输出的逻辑打包成一个独立单元。1.2 函数的本质是“接口”不是“实现”很多教材喜欢说函数是“子程序”这没错但更贴近工程的说法是函数是接口与实现的分离。调用函数的人只需要知道三件事函数叫什么、需要传什么参数、返回什么结果。至于函数内部是遍历数组还是推导公式调用者不需要关心。就像你点外卖只需要下订单参数和收餐返回值不需要知道后厨是怎么做菜的。后厨今天换了个厨师只要菜的味道和送餐方式不变你的“调用”就不受影响。这个思想放到项目里特别重要。比如你写了一个sort_ints排序函数今天用冒泡排序实现明天性能不够想换成快速排序只要函数签名不变所有调用它的代码都不用动。这就是“接口稳定、实现可替换”。1.3 函数化之后的三个直接收益拆开讲函数至少能给你带来三样东西任何一样都足够有说服力可复用同一个函数一次编写任意多次调用参数不同、结果不同但逻辑复用同一份。可读main 函数变成一串“动作清单”比如read_data(); process(); output();读代码的人一眼就能看出程序在做什么而不是陷进每层循环的细节里。可测试函数体小、职责单一出了 bug 可以单独写一个小程序测试这个函数不用整个项目跑一遍才知道哪里错。所以函数这一章表面上在教语法实际上在教你“拆解问题”的思维。这种思维会在你后面学数据结构、做项目、写嵌入式固件时反复用到。2. 函数的基本骨架定义、调用、返回值逐层拆解2.1 定义一个函数五要素拆解一个最简单的函数长这样int add(int a, int b) { return a b; }拆开看一个函数定义包含五部分返回类型int表示函数结束后返回一个整数。如果没有返回值写void。函数名addC语言要求函数名是合法的标识符一般建议“动词名词”能看出功能。参数列表(int a, int b)调用者需要传入两个整数。参数可以为空写作(void)更规范。函数体大括号内的代码是真正执行逻辑的地方。返回值通过return语句返回。return a b;既返回了结果又结束了函数的执行。调用它的代码也很直观#include stdio.h int add(int a, int b) { return a b; } int main(void) { int result add(3, 5); printf(%d\n, result); // 输出 8 return 0; }这里你可能会想为什么add要写在main上面如果写在下面还能不能调用这个问题的答案涉及“声明”和“定义”我在第 4 章会专门讲现在你先记住C语言是顺序编译的调用一个函数之前编译器必须至少见过它的声明或定义。2.2 main 函数也是函数系统调用与返回值的约定很多人觉得 main 函数很特殊特殊到不像一个函数。其实它也是函数只是它被操作系统调用而不是被你写的其他代码调用。int main(void)中int是返回类型return 0;表示向操作系统报告“程序正常退出”。非 0 的返回值通常表示异常或者某种错误码。有些程序会写return -1;示出错脚本里判断程序是否执行成功靠的就是这个返回值。另外main 函数也可以接收参数int main(int argc, char *argv[]) { for (int i 0; i argc; i) { printf(%s\n, argv[i]); } return 0; }argc是命令行参数个数argv是参数字符串数组。argv[0]一般是程序本身的路径。这个机制在你以后用 C 写命令行工具时会经常用到理解 main 也是函数就不难理解这些参数为什么能传进来。2.3 return 的两个作用返回结果与提前退出return在函数里有两个作用不只是“返回一个值”。第一是返回结果。函数计算完通过return把结果交给调用者类型必须和函数声明里的返回类型匹配。第二是提前结束函数。哪怕返回类型是void也可以写return;来终止函数继续执行void print_if_positive(int n) { if (n 0) { return; // 不满足条件直接结束 } printf(%d\n, n); }这种写法在条件分支里很常用。它避免了一层层嵌套 if-else让代码更扁平。有人把这种写法叫“卫语句”本质上就是利用 return 提前打断流程。注意void函数里写return;是合法的但写成return 0;就是编译错误。返回类型是 void 时return 后面不能跟值。2.4 函数调用时的“跳转记账本”机制函数调用在底层是怎么发生的可以用一个生活类比你正在写作业突然快递电话来了你记下当前写到第几题相当于保存现场然后下楼取快递去执行函数取完上来接着写恢复现场。计算机里负责这个“记账本”的是一块叫栈的内存区域。每次调用函数系统会把函数的参数、局部变量、返回地址压入栈中形成一个“栈帧”。函数返回时栈帧弹出程序跳回到调用位置继续执行。这解释了为什么函数里的局部变量在函数结束后就“消失”了——它们存在栈帧里栈帧销毁这些变量的内存空间就交还了。这个模型在后面理解递归和“返回局部变量地址”的坑时非常关键建议你此刻先把这个图景记在心里。3. 参数传递的深层机制值传递、地址传递与交换函数失效之谜3.1 值传递形参是实参的“复印件”几乎每个初学者都会写一个“失败”的交换函数void swap(int a, int b) { int temp a; a b; b temp; } int main(void) { int x 3, y 5; swap(x, y); printf(%d %d\n, x, y); // 输出3 5没变 return 0; }运行结果让你怀疑是不是编译器坏了。事实上函数写得很“正确”问题出在参数传递机制上。C语言默认的参数传递是值传递调用函数时实参x和y的值被复制一份赋值给形参a和b。也就是说a是x的“复印件”b是y的“复印件”。你在函数里交换a和b只是在交换两份复印件原件x、y纹丝不动。每个变量在内存里都有独立的地址a和x的内存地址不同操作系统并不会因为你给它们取了相似的名字就让它们共用一块内存。3.2 指针参数传地址才能改原件的唯一办法要想改原件必须拿到原件所在的地址。C语言提供指针来保存地址于是正确的交换函数长这样void swap(int *a, int *b) { int temp *a; *a *b; *b temp; } int main(void) { int x 3, y 5; swap(x, y); printf(%d %d\n, x, y); // 输出5 3 return 0; }调用时传的是x、y也就是变量的地址。形参a和b是指针它们保存的是地址这个“值”。通过*a解引用就能定位到x本身的内存空间修改*a就是修改x。这里有个容易绕晕的点指针本身也是值传递。也就是说形参a会复制一张“地址纸条”的复印件但纸条上写的地址指向的是同一个屋子通过这个地址去改屋子里的东西修改是生效的。如果试图在函数里改指针本身比如a b;那改的也只是一张纸条不会影响外部的指针变量。理解了这一层很多指针相关的困惑都能解开。3.3 数组参数退化成指针sizeof 测长度的经典翻车数组作为函数参数时机制又特殊一些。看这段代码int get_len(int arr[]) { return sizeof(arr) / sizeof(arr[0]); } int main(void) { int numbers[] {1, 2, 3, 4, 5}; int len1 sizeof(numbers) / sizeof(numbers[0]); // 5 int len2 get_len(numbers); // ? printf(%d %d\n, len1, len2); return 0; }答案会出乎意料len1是 5len2却是 1在 64 位系统上可能是 2。原因在于函数参数列表里的int arr[]只是语法糖它会退化成指针等价于int *arr。sizeof(arr)计算的是指针的大小不是整个数组的大小。所以写“接收数组的函数”时标准做法是额外传一个长度参数void print_array(int arr[], int len) { for (int i 0; i len; i) { printf(%d , arr[i]); } printf(\n); }很多 PTA 的字符串逆序题卡住本质就是这个原因字符串数组传进去之后你只能靠字符串结尾的\0来感知长度而不是靠sizeof。所以在函数里处理字符串习惯用strlen或者逐字符扫到\0跟“数组长度由调用者传入”是同一个道理。经验遇到数组形参先问自己“函数内部知不知道数组长度”。知道就正常用不知道就需要通过参数传进来或者依赖数组数据中的结束标记如字符串的\0。3.4 const 限定符给只读参数上一道保险如果你的函数只需要读取数组不需要修改它参数可以用const修饰int sum_array(const int arr[], int len) { int sum 0; for (int i 0; i len; i) { sum arr[i]; } return sum; }const的意思是“承诺不通过这个指针修改数据”。好处有两个一是编译器会检查如果你不小心写了arr[0] 1;编译阶段就报错二是读代码的人一眼就知道这个函数不会改动传入的数组接口语义更清晰。我见过不少企业级代码规范里明确规定“只读参数必须加 const”这不是强迫症而是降低函数之间的耦合度。函数之间互相传指针谁改了数据很难追踪加 const 等于立下规矩。4. 声明与定义为什么编译器总说“隐式声明”4.1 声明是“预告”定义是“实体”回到第 2 章遗留的问题add函数如果写在main后面能不能调用试一下#include stdio.h int main(void) { int result add(3, 5); printf(%d\n, result); return 0; } int add(int a, int b) { return a b; }在较新的编译器里这通常会出现一个错误或警告implicit declaration of function add。意思是在main调用add的那一行编译器还没见过add这个名字。解决办法是在调用前写上函数的声明也叫函数原型int add(int a, int b); // 声明告诉编译器有这么一个函数 int main(void) { int result add(3, 5); ... } int add(int a, int b) { // 定义函数的具体实现 return a b; }声明和定义的区别可以这么记声明只给出“签名”——返回类型、函数名、参数列表后面有分号定义在签名之后还有一对大括号包着函数体。声明是预告定义是实拍。只要声明出现在调用之前函数定义放在文件的任何位置都可以。编译器先看到声明就知道add是一个int(int, int)的函数调用方式合法等链接阶段再去找到add的真正代码块。4.2 头文件放声明、源文件放定义工程上的标准姿势单文件练习看不出问题一到多文件项目声明和定义放哪就很讲究。标准姿势是头文件.h放声明源文件.c放定义。拿两个文件举例// add.h #ifndef ADD_H #define ADD_H int add(int a, int b); #endif// add.c #include add.h int add(int a, int b) { return a b; }// main.c #include stdio.h #include add.h int main(void) { printf(%d\n, add(2, 3)); return 0; }main.c只包含add.h就能调用add函数因为它的声明在头文件里。等编译链接时add.c里的定义会被链进来整个程序完整可运行。头文件里的#ifndef ADD_H到#endif叫头文件守卫防止同一个头文件被重复包含导致重复声明。写多文件程序时每个头文件都建议加。这种“接口和实现分离”的方式和工程上团队分工是对应的一个人负责add.c的实现另一个人只需要知道add.h里的接口就能写调用代码互不干扰。4.3 老代码能“先调用后定义”现代C规范早已禁止有些老教材或者老代码里会出现“不声明也直接调用函数”的写法因为 C89 标准允许一种隐式声明编译器遇到没见过的函数名默认它返回int参数类型也不检查。所以老代码能跑不代表这样写是对的。从 C99 开始隐式声明已经是非法的编译器给出警告很多工程还直接开启-Werror把警告当错误处理。换句话说现在学 C 语言应当直接养成“调用前写声明”的好习惯尤其配合 VSCode 或较新的 GCC 版本看到 implicit declaration 就应该明白要么函数没声明要么头文件没包含。4.4 顺带回答“fun函数”的疑惑函数名是你自己的命名权搜索热词里有个“fun函数的作用”估计不少初学者被教材里fun这个函数名误导了以为它是关键字。其实fun就是 function 的缩写是作者随手起的名字。你把它改成my_func、calculate、anything都行功能不变。这件事背后的启示是函数名由你自己决定但命名会影响代码的可读性。我个人的习惯是“动词 名词”比如print_array、get_average、is_prime看到名字就知道函数想干什么。命名规范不是考试重点但它是从“学生代码”走向“工程代码”的第一步。5. 作用域、生命周期与 static 的三重身份5.1 花括号就是“墙”局部变量与全局变量的边界函数内部的变量是局部变量它的可见范围被限定在所在的花括号内void example(void) { int x 10; for (int i 0; i 3; i) { int y i * 2; // 这里能用 x、i、y printf(%d\n, x y); } // 这里能用 x但不能用 i 和 y }for循环的i和循环体里的y离开花括号就失效。这种机制叫作用域。作用域的好处是不同函数里的变量可以重名互不干扰——swap里有int temp别的函数里也可以有int temp它们活在各自的“墙”里。定义在函数外、花括号外的变量叫全局变量它的作用域从定义处开始一直到文件结束。任何函数都能访问它int global_count 0; void increment() { global_count; } int main(void) { increment(); increment(); printf(%d\n, global_count); // 2 return 0; }5.2 全局变量的方便与代价全局变量用起来很爽多个函数共享数据不用来回传参。但它有个大问题谁都可能改它出了问题很难定位是谁改的。比如程序某个时段global_count的值错了你得翻遍所有函数找出是哪一个在什么条件下改了它。函数之间的耦合因此变得很深这叫“隐式依赖”。在单线程小练习里问题不大但到了多线程嵌入式或者大型项目里全局变量是竞态条件的重灾区。工程上的普遍建议是尽量用局部变量和参数传递数据全局变量只在确有必要时使用并且集中管理。你可以把全局变量理解为“公共资源”——大家都在用但你要确认大家用之前都先洗手。5.3 static 修饰局部变量生命周期变长可见范围不变static在 C语言里是个多面手。当它修饰局部变量时改变的不是作用域而是生命周期void counter() { static int count 0; count; printf(%d\n, count); } int main(void) { counter(); // 1 counter(); // 2 counter(); // 3 return 0; }普通局部变量每次进入函数都会重新创建函数结束就销毁所以计数器永远从 1 开始。但static int count只初始化一次之后每次函数调用都沿用上次的值。它的生命周期从程序启动到程序结束但作用域依然是函数内部外面的代码访问不到它。这种特性很适合做“函数内部的记忆”。比如嵌入式开发里在一个函数里统计中断触发的次数不希望别的函数随便改这个计数器用 static 局部变量就恰到好处。5.4 static 修饰函数与全局变量把东西“关在文件里”static 还有两种用法都涉及“文件内部可见性”// file1.c static int helper(void) { return 42; } int public_function(void) { return helper() 1; }static修饰全局变量或函数时表示“仅本文件可见”。链接时其他文件无法直接使用这个函数或变量。这相当于给文件之间的接口画了一条边界对外只暴露非 static 的函数内部实现细节私有化。这在多文件工程里是常用的信息隐藏手段。一个模块对外只开放几个接口函数内部辅助函数统统用 static既避免命名冲突也防止别人误用你内部的实现。工程上这叫“模块化设计”在嵌入式固件里尤其常见。static 的位置作用域生命周期典型用途函数内局部变量函数内整个程序运行期计数器、缓存、单次初始化全局变量本文件内整个程序运行期文件私有状态函数本文件内程序运行期文件私有辅助函数6. 递归让函数调用自己但别把自己玩崩6.1 递归的思考方式分解成“一个操作 一个更小的同类问题”递归本质上是一种“函数调用自己”的写法但它背后是一种解决问题的方式把一个规模为 n 的问题拆成一个“小操作”加上一个规模为 n-1 的同类问题直到规模小到可以直接给出答案。以阶乘为例n! n * (n-1)!这就是递推关系。把它直接翻译成代码int factorial(int n) { if (n 1) { return 1; // 终止条件0! 和 1! 都是 1 } return n * factorial(n - 1); // 小操作 更小的同类问题 }写递归只需要盯住两件事终止条件要明确递归调用要让问题规模变小。两个条件缺一不可。没有终止条件就是无限递归最终栈溢出规模不变就会原地打转同样是死循环。6.2 两个经典案例阶乘和字符串逆序刚才是阶乘再来看和练习题直接相关的字符串逆序。平时用循环写很简单但用递归能更直观地体现“先递进再回归”的过程void reverse_print(const char *s) { if (*s \0) { return; } reverse_print(s 1); // 先处理后面的字符 putchar(*s); // 回来之后才输出当前字符 }调用reverse_print(abc)输出结果是cba。原理是递归先把字符串的首字符暂时“挂起”一路递归到字符串结尾然后在回归阶段一个字符一个字符地输出。后进先出自然就逆序了。画个调用关系就清楚了reverse_print(abc)调用reverse_print(bc)reverse_print(bc)调用reverse_print(c)reverse_print(c)调用reverse_print()reverse_print()发现\0返回回到reverse_print(c)输出c返回回到reverse_print(bc)输出b返回回到reverse_print(abc)输出a如果你要在 PTA 上做字符串逆序的题递归写法的核心思路就是这个。不过也要注意题目如果要求逆序后存储到新数组里而不是直接打印递归写法会更绕这时候用双指针循环反而更好写。递归适合“过程展示”循环适合“结果构造”做题时先想清楚题目要什么。6.3 斐波那契的重复计算陷阱递归不是万能的很多人学递归都会写斐波那契数列int fib(int n) { if (n 1) { return n; } return fib(n - 1) fib(n - 2); }代码很简洁但性能惨不忍睹。当n 5时fib(3)会被重复计算两次当n 50时重复计算的规模是指数级增长程序可能几分钟都算不完。这是因为朴素递归没有“记忆”每个子问题都要从头算一遍。实际上很多递归问题都带着这种重复计算优化思路是加入缓存也就是“记忆化搜索”long long memo[100] {0}; long long fib_memo(int n) { if (n 1) { return n; } if (memo[n] ! 0) { return memo[n]; } memo[n] fib_memo(n - 1) fib_memo(n - 2); return memo[n]; }或者干脆改用循环递推自底向上算long long fib_loop(int n) { long long a 0, b 1; for (int i 2; i n; i) { long long temp a b; a b; b temp; } return n 1 ? n : b; }这就是一个教训递归只是表达方式不是性能保障。选递归还是循环要看问题本身适不适合分解成同构子问题以及重复计算能不能接受。简单总结树形结构遍历、分治算法适合递归大数值递推、性能敏感场景优先循环或带记忆的递归。6.4 栈溢出与段错误递归的代价和排查思路递归的每一次调用都会创建新的栈帧消耗栈空间。如果递归深度过大比如递归 10 万层栈空间被耗光程序就会崩溃常见的表现是“段错误Segmentation fault”。在 PTA 或者本地终端上你可能会看到程序运行直接异常退出没有明确的报错位置。排查思路一般是这样检查递归终止条件是否合理比如n 1写成了n 1当 n 为 0 时就会无限递归。检查递归调用的参数是否向终止条件靠拢factorial(n)里写成了factorial(n)规模不变死循环。如果递归深度确实很大考虑改成循环或显式栈模拟。用printf在函数入口打印参数值观察调用序列是否符合预期。递归优雅但不是免费的。理解它背后的栈帧机制你就知道为什么“递归过深会出错”也就知道出错后该往哪个方向排查。7. 函数指针与回调把函数当作数据传递7.1 为什么要函数指针C语言里函数名本身代表函数的入口地址所以函数也可以有指针。用函数指针你就能把“一个函数”作为参数传给另一个函数让它们之间形成“框架 回调”的协作模式。最经典的场景是排序。C标准库的qsort支持对任意类型的数组排序但怎么比较两个元素qsort不知道需要你提供一个“比较函数”。这个比较函数的指针传进去qsort内部在需要比较时就调用你给的函数。这个机制叫回调。7.2 声明语法辨析int (*p)(int, int) 和 int *p(int, int) 的区别函数指针的声明语法是初学者最容易卡的地方。这两行看起来差不多但含义完全不同int *p(int a, int b); // 这是一个函数声明p 是函数名返回 int* int (*p)(int a, int b); // 这是一个指针声明p 是指针指向一个返回 int 的函数关键是括号(*p)表示先取p再解引用说明p是一个指针后面跟着(int a, int b)说明这个指针指向的函数接受两个 int 参数最前面的int是函数的返回类型。用 typedef 包装一下可读性会好很多typedef int (*BinaryOp)(int, int); int add(int a, int b) { return a b; } int main(void) { BinaryOp op add; // 让函数指针 op 指向 add printf(%d\n, op(3, 4)); // 7 return 0; }op(3, 4)等价于(*op)(3, 4)也等价于add(3, 4)。C语言允许简写成op(3, 4)很多入门教材不细讲这点导致很多人看到int (*p)(int, int)就头疼。7.3 qsort 排序里的回调比较函数这样写才稳看一个完整例子用qsort对 int 数组升序排序#include stdio.h #include stdlib.h int cmp_int(const void *a, const void *b) { int x *(const int *)a; int y *(const int *)b; return (x y) - (x y); } int main(void) { int arr[] {5, 2, 9, 1, 7}; int len sizeof(arr) / sizeof(arr[0]); qsort(arr, len, sizeof(int), cmp_int); for (int i 0; i len; i) { printf(%d , arr[i]); } printf(\n); return 0; }qsort的第四个参数就是函数指针它的类型是int (*compar)(const void *, const void *)。比较函数返回负数表示 a 排在 b 前返回正数表示 a 排在 b 后返回 0 表示相等。注意我写的比较逻辑是(x y) - (x y)而不是很多人习惯的x - y。原因很简单如果x是INT_MAXy是负数x - y会整数溢出结果是未定义行为。这在排序大数时是真实存在的 bug用大小比较相减的方式可以稳妥避免溢出。这也是面试和笔试里常考的一个细节。7.4 函数指针数组与嵌入式里的“模拟面向对象”函数指针更深一层的用法是把多个函数放进数组用一个下标决定调用哪个函数。这在菜单系统、状态机、按键处理里很常见void action_open(void) { printf(open\n); } void action_close(void) { printf(close\n); } void action_save(void) { printf(save\n); } int main(void) { void (*actions[])(void) {action_open, action_close, action_save}; int cmd 1; actions[cmd](); // 调用第二项输出 close return 0; }这种写法避免了超长的 switch-case 分支。以后收到网络命令、串口指令直接按命令码查表调用对应函数扩展起来只加数组元素就行。嵌入式里还有一种风格很常见用“结构体 函数指针”模拟面向对象。比如定义一个LED结构体里面存了on、off函数指针再给不同型号的 LED 芯片赋不同的实现调用者只面向结构体操作。这种思想在“C语言面向对象编程嵌入式实战”这类书里大量出现本质就是利用函数指针实现“多态”。你不需要一开始就学那么深但把函数指针练熟之后再去看这些内容会顺很多。8. 我在函数上踩过的坑真实报错与排查记录8.1 “无法将xxx项识别为 cmdlet、函数”的报错和 C 语言的关联在 Windows 的 PowerShell 里很多人执行npm -v、git --version之类的命令时会看到一串红字无法将“git”项识别为 cmdlet、函数、脚本文件或可运行程序的名称。请检查名称的拼写如果包括路径请确保路径正确然后再试一次。这个报错看起来跟 C 语言函数没关系但本质可以类比一个名字没有被注册到系统能找到的位置系统就无法调用它。PowerShell 找命令时会去 PATH 环境变量指定的目录里找可执行文件找不到就报错。对应到 C 语言里就是“函数声明不可见、定义不存在或链接时找不到”的问题。函数名写错了、头文件没包含、函数没定义编译器或链接器同样会报“未定义标识符或未定义引用”。排查这类问题我的建议是先确认程序是否安装比如where git看能不能找到。如果没找到检查 PATH 环境变量把安装目录加进去然后重开一个终端。如果找到了但还报错可能是终端缓存重开终端通常能解决。这个坑不是 C 语言的坑但很多人在配置 VSCode 的 C/C 环境时同时踩到过顺手一并记录下来。记住这个“名字必须在它该在的地方”的思维对理解函数声明和链接也有帮助。8.2 conflicting types声明与定义不一致的编译错误这是一个很典型的编译错误int get_max(int a, int b); int main(void) { printf(%d\n, get_max(3, 5)); return 0; } double get_max(int a, int b) { // 这里返回类型和声明不一致 return a b ? a : b; }编译器会报conflicting types for get_max。意思是前面声明说get_max返回 int后面定义却返回 double冲突了。这种错误多发生在你修改了函数定义但忘了更新声明或者在不同文件里复制粘贴了旧声明。修改方式很直接让定义和声明的返回类型、参数列表完全一致。多文件项目里尽量让声明源文件包含自己的头文件这样编译器在编译定义时也能对照检查能提前暴露不一致。8.3 returning address of local variable返回局部变量的野指针陷阱下面这段代码编译器可能只给警告但运行起来可能“一会儿对一会儿错”int *get_local_array(void) { int arr[3] {1, 2, 3}; return arr; // 返回局部数组首地址 } int main(void) { int *p get_local_array(); printf(%d\n, p[0]); // 危险 return 0; }第 2 章讲过局部变量存在栈帧里函数返回时栈帧销毁那块的地址虽然还在但内容已经不受保护随时可能被别的调用覆盖。打印 p[0] 可能恰好是 1也可能是乱码行为不确定。三种标准修法调用者提供数组函数往里填数据void fill_array(int arr[], int len) { for (int i 0; i len; i) { arr[i] i * 2; } }用 static 局部数组生命周期延长到程序结束int *get_fixed_array(void) { static int arr[3] {1, 2, 3}; return arr; }用malloc动态分配由调用者负责freeint *get_heap_array(void) { int *arr malloc(3 * sizeof(int)); arr[0] 1; arr[1] 2; arr[2] 3; return arr; }第三种最灵活但记得用完要释放内存否则会内存泄漏。判断“能不能返回局部变量”的核心就是一句话返回的地址所指的内存在函数返回后还合法吗合法就能用不合法就是野指针。8.4 PTA 段错误的排查递归过深与数组越界在线判题系统比如 PTA上段错误非常常见。可执行文件已经正常编译但一运行就异常终止。我见过不少和函数相关的段错误递归函数没有终止条件或者终止条件判断逻辑有漏洞导致栈被压爆。函数内部数组越界写入比如for (int i 0; i len; i)多写了一个元素破坏了栈上其他数据。返回局部数组地址后调用者继续使用访问了已失效的内存。排查段错误的流程我习惯这样来先用最小数据测试比如数组长度传 1 或 0看程序是否正常。如果递归有问题在递归函数入口打印当前参数观察参数是否异常跳动。如果数组越界把循环条件打出来逐一核对重点检查边界是还是。本地用调试器gdb跑一下程序崩溃时输入bt查看调用栈能直接定位到出错的函数和行号。函数用得好不好到排查错误的环节最能体现。那些能快速定位问题的人往往是对“变量存在哪、生命周期多长、参数怎么传”这些底子概念掌握得扎实的人。每次教学生写 C 语言我都会强调函数这章是整个 C 语言的分水岭。指针、递归、回调、模块化这些后面的硬骨头全建立在“函数到底是什么”这个基础上。最值得花时间的不是把语法背熟而是拿几个乱糟糟的 main 函数试着把它们拆成漂亮的小函数。等你能把一个三百行的函数拆成十个各司其职的小函数时你会明显感觉到写代码的状态不一样了——思路清晰测试方便bug 也好找了。
返回列表