学C语言有个很有意思的分水岭:前几篇笔记里的变量、分支、循环、函数,上课时大家都觉得能跟上,一到指针、字符串和文件操作,例子看得懂,作业写不出来。这篇是《C语言学习笔记》系列的第五篇,我打算把进入“能动手写点小工具”阶段最容易绊倒人的知识点集中整理一遍,包括变量和内存的关系、指针与结构体、字符串处理、基础算法、文件IO,以及我自己常用的错误自查清单。适合已经掌握C语言基本语法、正在刷PTA或者准备做第一个小项目的朋友,也适合想快速回顾核心知识点的同学。
1. 先把内存这块地基砸实:变量生命周期、栈空间与类型转换
1.1 基本数据类型的存储是“盒子”,不是数字
C语言里每个变量都要先声明类型,这个类型决定了两件事:分配多少内存,以及这块内存里的二进制怎么解读。以常见的x86-64环境为例,char是1字节,short通常是2字节,int是4字节,long在64位下是8字节,float是4字节,double是8字节。注意我说“通常”,因为C标准只规定了最小范围,具体大小由编译器目标平台决定,这也是为什么写代码时要习惯用sizeof去确认,而不是凭记忆拍脑袋。
生活化类比一下:内存像一排放快递盒的货架,变量名是贴在盒子上的标签,类型决定盒子的大小。char是小盒,int是中盒,double是大盒。你往int盒子里塞一个char的值,合法但浪费;往char盒子里塞int的值,直接装不下,轻则截断,重则把相邻盒子的内容也改掉。理解了这个“盒子模型”,后面很多内存错误都能找到根因。
1.2 局部变量活在栈上,生命周期和函数绑定
局部变量默认分配在栈上。所谓栈,就是一个后进先出的内存区域,每次函数调用会压入一个栈帧,函数里的局部变量都住在这个栈帧里,函数一返回,整个栈帧弹出,里面的变量就不存在了。这也是为什么你不可能在函数外面访问它的局部变量,就算你用指针记下了地址,函数返回之后再取那个地址的值,行为也是未定义的。
“局部变量越少,所占栈空间越小”这句话本身是对的,但更准确的理解是“局部变量总占用空间越小,越不容易栈溢出”。栈空间通常有限,Windows上默认大约1MB,Linux上默认8MB。如果函数里声明一个大数组,比如int a[1024 * 1024],直接占4MB栈空间,在某些环境下递归一深就爆了。我刷题时习惯把较大的数组放到全局区,或者用malloc放到堆上,前者缺点是全局变量不利于模块化,后者则要记得free。两种方案各有取舍,但至少要意识到栈空间是“租来的”,不是无限免费送的。
1.3 %d配char输入是个经典翻车现场
很多初学者写过类似代码:
char c; scanf("%d", &c);表面上看是“读一个整数到字符变量里”,实际上翻车翻得很隐蔽。char只有1字节,scanf按%d读取时,会把4字节的int整数直接写进c的地址,这就越界写入了相邻的3个字节。在栈上,这3个字节可能属于其他变量,也可能属于栈帧的某些控制数据,结果就是程序行为变得飘忽不定,有时打印出莫名其妙的数字,有时运行到后面直接崩溃。
正确做法有两种:一是用int做中转,读取后再赋给char;二是用%hhd告诉scanf按单字节读:
char c; int tmp; scanf("%d", &tmp); c = (char)tmp; // 或者 scanf("%hhd", &c);反过来,printf输出时有个默认参数提升:char、short在传给printf前会自动转成int,float会自动提升为double。所以printf("%d", c)输出的是字符的ASCII码,printf("%c", 65)输出的是字母A。理解这个提升规则,你就能明白“为什么printf用%f还是%lf都行,scanf里却必须严格区分”的问题——printf的可变参数会做默认提升,%f和%lf实际接收的都是double,所以表现一样;而scanf要写内存,必须知道每个参数的真实字节宽度,float和double必须用%f和%lf严格区分。
2. 指针、数组与结构体:组合在一起才叫C语言
2.1 数组名不等于指针,但处处表现得像指针
数组和指针是C语言里最容易让人产生“我懂了”错觉的一对组合。数组名在大多数表达式里会被隐式转换为指向首元素的指针,所以你可以写int arr[5]; intp = arr;,也可以写(arr + 2)来访问arr[2]。但数组名本身不是指针变量,有两个地方最能看出差别。
第一个是sizeof。sizeof(arr)得到的是整个数组占用的字节数,比如int arr[5]在常见环境下是20;而如果用int *p = arr,sizeof(p)在64位平台上是8,仅仅是指针本身的大小。第二个是&。&arr的类型是“指向5个int数组的指针”,而不是“指向int的指针”,所以int **p = &arr这个写法是错的。
数组传参时就更有意思了。函数形参写成int arr[],本质上和int *arr完全等价,编译器会把数组形式退化成指针,所以函数内部再sizeof(arr)只能得到8。想在函数里知道数组长度,必须把长度作为参数一起传进去,这是初学者最容易踩的坑之一。
2.2 结构体:数据有了“打包”概念
结构体是C语言里第一个真正让你把多个数据打包成一个整体去操作的工具,也是链表、二叉树、文件记录管理的基础。定义结构体常用typedef,省得每次写struct关键字:
typedef struct Student { char name[32]; int age; float score; } Student;访问成员时分两种情况:普通结构体变量用点号,结构体指针用箭头->。箭头其实是一个语法糖,(*p).age和p->age完全等价,但写起来少一层括号,可读性也好很多。
结构体还有一个重要知识点:内存对齐。结构体大小并不是成员大小的简单相加,编译器会按成员的对齐要求插入填充字节。比如下面这个结构体:
typedef struct { char a; int b; } T;char占1字节,int按4字节对齐,所以a后面会填充3字节,整个T的大小是8而不是5。如果成员顺序调整,把char放最后,结构体还是8,因为尾部也要对齐到最大成员的对齐边界。这在嵌入式开发里经常是个问题:你定义了一个通信协议结构体,想直接强转成字节流发送,结果发现中间多出来的填充字节把协议格式全部打乱了。解决办法是使用#pragma pack(1)或者__attribute__((packed)),让结构体按1字节对齐,但代价是访问未对齐成员时在某些硬件上会有性能损失,甚至触发总线错误,属于明确知道自己在干什么才用的手段。
2.3 动态内存分配:堆上的自由都是有代价的
结构体和数组配合使用,很快你就会遇到“数组长度写死”的烦恼。C语言没有标准的变长数组支持,学习阶段最常用的方案是malloc/calloc/realloc/free,它们在stdlib.h里声明,负责在堆上申请和释放内存。
malloc(n)申请n字节,calloc(count, size)按元素个数申请并把内存清零,realloc调整已有块大小,free把块归还给系统。听起来简单,实际用起来有三类错误最常犯。
一是忘记free,导致内存泄漏。程序跑得时间长一点,内存占用稳步上升,最后卡死。短小的练习题感觉不出来,写服务类程序时必须养成“谁申请谁释放”的习惯。
二是free之后继续用,形成悬垂指针。释放后指针本身地址还在,但那块内存已经不属于你的程序,再读写就是未定义行为。稳妥做法是free之后立刻把指针置为NULL。
三是越界修改了堆块的控制信息。malloc返回的地址前后有管理数据,你在返回地址之外越界读写,可能破坏堆结构,导致free时崩溃。这类错误最难排查,往往要配合内存调试工具才能定位。
理解栈和堆的区别之后,你才能回答“结构体指针用malloc分配和直接用结构体变量有什么区别”这种经典问题:前者生命周期由你控制,可以跨函数返回并继续使用;后者生命周期绑定在声明它的函数栈帧上,函数返回就失效。这是后续写链表的基本功。
3. 字符串处理没有捷径:fgets、分割和逆序的完整实战链路
3.1 fgets和gets的安全之争已经没什么可争的了
很多教材还残留着gets函数,但现在的主流编译环境已经把它视为危险函数,官方标准从C11开始干脆把它删掉了,现代编译器里用gets大概率直接编译失败。原因很简单:gets不检查缓冲区大小,输入多长就往目标地址写多长,缓冲区溢出几乎是注定的,恶意输入甚至能借此改变程序的返回地址。
fgets是替代方案里的正解:
char buf[128]; if (fgets(buf, sizeof(buf), stdin) != NULL) { // 成功读取一行 }fgets最多读入size-1个字符,剩下的一个位置留给字符串结尾的\0。它会保留用户输入的换行符,除非这一行刚好长到缓冲区放不下。所以处理用户输入时,去掉结尾换行是常规操作:
buf[strcspn(buf, "\n")] = '\0';strcspn返回第一个匹配到换行符的下标,如果没有换行,它会返回字符串长度,把buffer末尾那个本来就存在的\0再写一遍,没有副作用,所以这句代码可以放心用。
3.2 字符串函数全家福:知道每个函数的边界条件
C语言标准库的字符串函数集中在string.h里,表面上都很简单,但每个都有自己的边界脾气。
strlen返回\0之前的字符个数,不含\0;strcpy把源串连同结尾符一起拷贝到目标;strcmp比较两个字符串,返回0表示相等,负数表示第一个比第二个小,正数表示大,注意它不是返回1或-1,而是按字符差值来的;strcat把源串拼接到目标串末尾。
这三兄弟在安全上都有隐患:strcpy不检查目标容量,strcat同样不检查,strcmp遇到非\0结尾的“字符串”会一直比较下去,直到触发段错误。对应的安全版本是strncpy、strncat、strncmp,但strncpy也有个著名的坑:它只会拷贝最多n个字符,如果源字符串长度超过n,目标缓冲区不会自动补\0,你还得手动在buf[n - 1] = '\0',不然后面输出字符串时会接着打印缓冲区后面的垃圾数据。
3.3 按空格分割字符串:strtok方便,但有三个陷阱
“把字符串按空格分开”是个非常经典的需求,读一行英文句子,拆成单词逐行输出。标准库提供的strtok函数用起来最省事:
char line[] = "hello world test"; char *p = strtok(line, " "); while (p != NULL) { printf("%s\n", p); p = strtok(NULL, " "); }第一次调用传字符串地址,后续传NULL表示接着上次位置继续切。它有三个需要知道的陷阱。
第一,它会修改原字符串。strtok找到分隔符后,会把分隔符位置直接替换成\0,所以如果后续还要用原字符串做别的处理,得先拷贝一份再切。
第二,连续分隔符会被跳过。用空格切"a b"只能得到"a"和"b",中间空的“单词”不会返回。有些场景需要保留空字段,那strtok就不合适了,得自己写遍历逻辑。
第三,strtok不可重入。它内部用静态变量保存拆分位置,多线程环境或嵌套拆分会互相干扰,此时应使用strtok_r,或者干脆自己实现分割函数。
如果不想依赖这些行为差异,手写一版也不复杂:遍历字符串,碰到非空格就记录单词起点,碰到空格或结尾就把这一段按长度复制出来。
3.4 字符串逆序:练的是“交换”和“递归”两种思维
字符串逆序是PTA等平台上出镜率很高的基础题,比如输入"hello",输出"olleh"。拿到题先想清楚一个关键点:输入字符串可能带着换行符,如果不做处理直接逆序,结果会多出一个换行在开头。
最常见的解法是双指针交换法。两个下标,一个从0开始,一个从len-1开始,不断交换字符并向中间靠拢,直到两个下标相遇:
void reverse(char *s) { int left = 0; int right = strlen(s) - 1; while (left < right) { char tmp = s[left]; s[left] = s[right]; s[right] = tmp; left++; right--; } }交换法思路直接,代码也短。还有一种递归写法,虽然效率不一定更高,但对理解函数调用栈和知识迁移很有帮助:递归地把除首字符外的部分逆序,再把首字符放到末尾。递归解法要注意在每次进入下一层之前先存下当前首字符,回来后在末尾补上它。这个做法能帮你把“函数调用栈”和“字符串操作”串起来理解,值得动手写一遍,而不是只在纸上画。
4. 小算法实战:从冒泡排序到日期推算再到滤波
4.1 冒泡排序:最直观的排序,也是优化的入门
冒泡排序的思路是每一轮把相邻的两个元素比较,如果顺序不对就交换,这样每一轮结束时,最大的元素会像气泡一样浮到数组末尾。重复n-1轮,数组就有序了。基础写法:
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 tmp = a[j]; a[j] = a[j + 1]; a[j + 1] = tmp; } } } }内层循环的n-1-i是优化的第一步:第i轮结束后,末尾i个元素已经处于最终位置,没必要再比较。但即使是这个写法,遇到基本有序的数组仍然会傻乎乎地全部比完。加一个标志位可以提前退出:
void bubble_sort(int a[], int n) { for (int i = 0; i < n - 1; i++) { int swapped = 0; for (int j = 0; j < n - 1 - i; j++) { if (a[j] > a[j + 1]) { int tmp = a[j]; a[j] = a[j + 1]; a[j + 1] = tmp; swapped = 1; } } if (!swapped) break; } }如果某一轮没有任何交换,说明数组已经有序,直接退出。这个标志位写法在面试和笔试里经常成为加分项。要说明的是,冒泡排序的时间复杂度是O(n^2),数据量大时完全不够看。学习它的价值更多在于理解“比较-交换”这个排序基本框架,后面接触快速排序、归并排序时,你会发现很多思想都是从这种基础排序延伸出来的。
4.2 日期推算:先打表,再处理两个边界优化
输入年、月、日,计算它是这一年的第几天,是典型的“看起来简单,一写就错”的题目。核心难点有两个:闰年判断和各月天数的表示。
闰年规则是“能被4整除但不能被100整除,或者能被400整除”,C语言里写成:
int is_leap(int y) { return (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0); }月份天数用数组存是最清晰的做法:
int days[] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; if (is_leap(year)) { days[2] = 29; }然后累加month之前的所有月份天数,再加上day,就得到答案。把下标设计成从1开始,days[0]占着不用,代码读起来会舒服很多。
这个题真正的价值不在算法复杂度,而在让你养成两个习惯:一是把经常查的表提前建立,而不是在代码里写一大串switch-case;二是把所有边界条件想清楚,包括闰年2月、输入为12月时的下标边界、年份合法性校验等。我自己做题时会随手写几组测试数据,比如2024年2月29日、2023年12月31日、2024年1月1日,跑一次能发现很多隐形问题。
4.3 ADC值滤波函数:从刷题到嵌入式场景的一小步
如果你接触过单片机或者传感器开发,就会知道ADC采集到的原始值往往很“脏”,要么有毛刺,要么随机跳动。滤波函数是嵌入式C里非常实用的小部件。
最常用的是中值滤波:连续采N次,按大小排序,取中间值作为有效结果。它对消除脉冲噪声效果最好,比如一个尖峰突然冒出来,排序后会被挤到两端,中间的输出基本不受影响。用前面讲过的排序知识就能实现。
另一种是滑动平均:维护一个窗口,每来一个新值就丢弃最旧的值,对窗口内所有值取平均。它对随机噪声的平滑效果好,但反应速度会比原始值慢半拍。两种方法各有取舍,中值适合“偶尔抽风”,平均适合“一直有点噪”。
我推荐的做法是,在练习题阶段就试着把这些“刷题专用”的排序算法改造成函数接口,多想想它除了排数字还能干什么。这样一来,你以后遇到“ADC值滤波”“信号去抖”这类真实需求时,不会觉得是另一个世界的东西。
4.4 两个练手感的小玩具:九九乘法表和随机数
九九乘法表是几乎所有人写过的第一个“嵌套循环”程序。要控制得好,格式输出是关键:
for (int i = 1; i <= 9; i++) { for (int j = 1; j <= i; j++) { printf("%d*%d=%2d ", j, i, i * j); } printf("\n"); }%2d让小于10的积右对齐,输出看起来整齐。这个题的循环变量取值范围、内层循环上限、打印顺序,三个点任何一个想错输出就会乱,很适合用来练习“在纸上步进循环变量”的能力。
随机数也是新手常踩坑的地方。rand()生成的是伪随机数,如果不设置种子,每次运行程序得到的序列完全相同。标准做法是先用srand(time(NULL))设定种子,再调用rand()。要生成0到1之间的浮点数,正确写法是rand() / (RAND_MAX + 1.0)或者rand() / (double)RAND_MAX,这样能得到[0,1]区间的浮点值。如果你直接写rand() / RAND_MAX,两边都是整数,结果永远是0,因为整数除法直接把小数部分截断了。这个问题看起来小,实际调试时非常容易困惑。
5. 文件读写与IO函数的正确打开方式
5.1 scanf、fscanf、sscanf三兄弟:从键盘、文件到字符串
初学者通常先学会scanf和printf,这两个函数面向标准输入输出。等到需要读写文件时,fscanf和fprintf出现了,它们多了一个文件流参数,比如:
FILE *fp = fopen("data.txt", "r"); if (fp == NULL) { perror("fopen"); return 1; } int num; while (fscanf(fp, "%d", &num) == 1) { printf("%d\n", num); } fclose(fp);fscanf的返回值是成功赋值的参数个数,把它作为循环判断条件是比feof更稳妥的读文件方式。
还有一个不太起眼但很实用的兄弟函数sscanf,它从字符串里解析数据,比如从一行文本"2024-05-01"里拆出年、月、日:
int y, m, d; sscanf("2024-05-01", "%d-%d-%d", &y, &m, &d);这类解析技巧在写配置文件和日志处理时非常常用。
5.2 文件模式、fopen失败检查和fclose的纪律
fopen的第二个参数是打开模式,常用的有r(只读)、w(只写,覆盖旧内容)、a(追加)、r+(读写但不会清空)、w+(读写但会清空)、a+(读和追加)。在Windows上还可能有t和b的文本/二进制模式,Linux平台两者没有本质区别。处理二进制数据时建议显式加b,避免Windows上文本模式下换行符被转换导致读写不一致。
打开文件之后第一件事永远是判断返回值是不是NULL。文件不存在、权限不足、磁盘已满都可能让fopen失败,不检查就直接fscanf,程序就会在一个空指针上崩溃。
关闭文件的纪律同样重要:fclose不仅要写,还要写在所有可能提前return的路径之前。比如读文件循环里遇到坏数据想提前退出,很多人直接return,文件句柄就泄漏了。这种泄漏不会立刻报错,但程序长时间运行打开大量文件后,会耗尽系统文件描述符,后面所有fopen都开始失败,现象非常隐蔽。我的习惯是尽量用一个专门的错误处理段统一fclose,或者把代码组织成“失败goto清理段”的形式,这在C语言的老派项目里很常见,也确实是应付资源管理问题最务实的手段之一。
5.3 格式化输出时类型转换:别让格式符和类型“打架”
printf系列函数最神秘的地方在于,它并不知道你传进去的参数到底是什么类型,它只相信格式字符串里的说明。所以“格式符和实际参数类型必须匹配”是一等一的大原则。常见对应关系很简单:%d对应int,%f对应double(float会自动提升为double),%c对应int(char会提升为int),%s对应char*,%p对应指针。
最容易翻车的是用%d去打印double的内存,或者反过来用%f输出整数。有时输出看起来是一个巨大而离奇的数字,有时是0,没有个准。还有精度修饰,比如%.2f限制小数点后两位,%5d控制最小宽度,%-5d左对齐。这些修饰符在控制输出格式时非常有用,比写一堆空格拼接要省事得多。
如果你看老项目源码时遇到奇怪的输出,可以先怀疑格式符和参数类型不匹配,再用-Wall -Wextra重新编译,警告信息往往会直接告诉你哪里不对。
6. 常见错误自查清单与控制台小技巧
6.1 我总结的C语言错误自查清单
学C语言最大的痛苦不是语法记不住,而是出了运行时错误之后不知道从哪查起。我把平时自己最常犯的错误整理成一张清单,写代码效率提升非常明显:
| 错误类型 | 典型现象 | 检查方法 |
|---|---|---|
| 数组越界 | 值被莫名改写、崩溃 | 检查所有下标范围,特别关注循环边界 |
| scanf漏写& | 段错误或读到垃圾值 | 逐个参数检查是否传了地址 |
| 字符串没有\0 | 输出乱码、strlen越界 | 手动初始化char buf[100] = {0} |
| =和==混淆 | 程序逻辑混乱 | 编译时打开-Wall,看编译器警告 |
| switch漏break | 执行了不该执行的分支 | 检查每个case结尾 |
| free后继续使用 | 偶发崩溃 | free后立即置NULL |
| 指针未初始化就解引用 | 段错误 | 指针定义后先置NULL或立即赋值 |
| 数组作参数sizeof失真 | 函数内拿不到数组长度 | 形参额外传长度 |
这张表的实用价值在于,调试时不是漫无目的地看代码,而是先对着清单排除一轮高频失误。大部分“看起来很难的bug”最后定位到根因,往往都是这张表里的某一项。
6.2 让控制台程序好用的两个小技巧:隐藏光标和打印动画
如果你做的是Windows下的控制台练手项目,比如打字游戏或者节日祝福动画,有一类问题很常见:光标一闪一闪影响观感,输出刷新时闪烁明显。隐藏光标可以用Windows控制台API实现:
#include <windows.h> void hide_cursor(void) { HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE); CONSOLE_CURSOR_INFO info; info.dwSize = 1; info.bVisible = FALSE; SetConsoleCursorInfo(hOut, &info); }这只是Windows平台API的一个小例子。想要在控制台实现打字游戏,核心思路其实是“清屏+重绘”:每次循环用system("cls")或者光标定位函数回到固定位置,再输出新的画面,配合Sleep控制刷新速率,一个最简单的打字游戏雏形就出来了。节日祝福代码、动画条之类的效果,本质上都是同一套逻辑。做这些小项目最大的好处是,你会把scanf的输入缓冲问题、格式输出的对齐问题、循环和延时的配合问题一次性摸一遍,比单纯刷题更贴近真实开发的感觉。
6.3 环境配置的取舍:先把编译器跑起来再说
最后补充一点关于开发环境的经验。很多初学者卡在“代码写好了但不知道怎么跑起来”,VS Code配C语言环境经常要处理编译器路径、tasks.json和launch.json,用起来有一定门槛。我的建议是学习阶段优先选一个开箱即用的集成环境,比如Windows下用Visual Studio社区版,新建控制台项目直接F5就能跑;Linux下用gcc加文本编辑器最轻量。等对这些基础命令都熟了,再回头折腾VS Code的配置,心态会稳很多。
这一条不算什么高深知识,但我确实见过太多人因为环境问题折在入门第一步。环境只是工具,先把C语言本身这条路走通,收益会大得多。