最近在坚持更新“C语言基础练习”这个系列,今天已经到第17期了,配套的计算机英语也走到了day11。说实话,越往后练习,越觉得纯语法堆量没意义,真正卡人的是指针、内存和文件交互这些“看不见摸不着”的东西。所以今天这一期,我特意把练习重心放在了指针操作、字符串逆序、结构体数组写入文件再读出来这几个场景上,顺便把计算机英语day11的单词表融到代码注释和调试过程里。无论你是刚学完C语言基础语法,还是正在准备期末上机考,这篇内容都能帮你把“纸上会写”变成“机器上能跑”。
1. 第17期练习路线:为什么重点放在了指针与内存上
1.1 从热搜词里挑出来的练习方向
打开最近的热搜词,能看到一堆关键词都很典型:“字符串逆序c语言pta”“c语言文件读写操作代码”“怎么检验非法地址c语言”“c语言内存管理”“c语言指针”。这些词串在一起,其实就是C语言学习中公认的三座大山:指针、内存、文件。我的想法很直接:第17期不再做那种“输入三个数找最大值”的题,而是要把这三座大山一次性串起来练。
练习清单我定成了四道题:
- 字符串逆序(指针法+递归法)
- 结构体数组动态分配并初始化
- 结构体数组写入二进制文件,再读出来
- 结构体数组排序(冒泡排序),同时记录排序过程中的异常退出
这四道题不是孤立的,它们之间有一条隐藏线索:几乎每一道题都涉及“地址”这个概念。字符串逆序要动指针指向的字符单元,结构体数组动态分配要知道堆区的地址,文件读写要把内存地址中的数据搬到磁盘,而排序时交换结构体本质上是在搬动一片内存区域。等你把这几道题都写完,就会发现自己对“地址”这两个字的敏感度提升了一个档次。
1.2 为什么要专门练内存视角
初学者写C语言程序,最容易出现的情况是“逻辑对但运行崩”。比如字符串逆序,明明思路是首尾交换,但一写指针版就段错误;结构体数组排序,比较函数写得没问题,可交换的时候直接把整个数组越界。这些问题的根源,都是没有在脑子里建立内存模型。
我的经验是,C语言基础练习到了第17期这样的阶段,不能再用“背代码”的方式学习,必须开始建模。画一张图,把栈区、堆区、静态区、代码段分清楚;画一张图,看指针变量本身在哪个区,指针指向的目标在哪个区,两者之间是什么关系。今天所有代码,我都建议你拿纸笔把内存图画一遍。比如字符串逆序的指针版,你要画出p指向数组首地址,q指向末地址,然后p往后走,q往前走,交换它们分别指向的字符。图一画出来,边界条件自然就清楚了。
2. 字符串逆序的两条路:指针递归与数组下标,哪个更顺手
2.1 题目本身与常规思路
题目很简单:写一个函数reverse_str,将字符串s逆序,要求不使用strrev这样的库函数,也不额外申请数组。输入“hello”,输出“olleh”。
我看到很多教材给出的标准解法都是双下标:一个从0开始,一个从末尾开始,交换,然后俩下标往中间靠。这个解法没有错,但它用到的内存视角比较弱。为了强化指针练习,我要求自己至少用指针方式实现一遍,再用递归方式实现一遍。
先看指针迭代版本:
#include <stdio.h> #include <string.h> void reverse_str_iter(char *s) { char *p = s; char *q = s + strlen(s) - 1; while (p < q) { char tmp = *p; *p = *q; *q = tmp; p++; q--; } } int main() { char str[] = "hello"; reverse_str_iter(str); printf("%s\n", str); return 0; }这段代码里最关键的语句是char *q = s + strlen(s) - 1。很多第一次写的同学会漏掉减1,结果q指向了字符串结束符\0,交换时把\0换到开头,输出直接变成空串。这就是典型的“差一错误”,画内存图的时候你会看到,数组长度为6(h e l l o \0),最后一个有效字符的下标是4,而strlen(s)是5,所以必须减1。
2.2 指针迭代法背后的地址运算细节
为什么指针方式值得练?因为p++和q--这两个操作,对应的其实是地址加1和地址减1。对于char类型,地址加1就是往后移动一个字节;如果换成int类型,地址加1就是往后移动4个字节。C语言的指针运算会自动乘以类型大小,这一条规则很多初学者都背过,但只有在写这种字符交换代码时才会有手感。
我习惯在循环体里加上几行调试输出,观察每一步的中间状态:
while (p < q) { printf("交换前: %s (p指向%c, q指向%c)\n", s, *p, *q); char tmp = *p; *p = *q; *q = tmp; p++; q--; }实测输出:
交换前: hello (p指向h, q指向o) 交换前: oellh (p指向e, q指向l) 交换前: olleh (p指向l, q指向l)注意第三次循环时p和q相等,条件p < q不成立,所以不进入循环。最终结果是“olleh”。这里还要提到一个细节:p < q是合法的,因为p和q都指向同一个数组内的元素,一个在开头,一个在中间,指向同一个数组的两个指针可以比较。但p和q如果指向完全不相关的两个数组,用<比较就是未定义行为,这一点务必牢记。
2.3 递归版本:思路优美但别忽略栈开销
递归逆序的思路是:把字符串s逆序,等价于先交换s[0]和s[len-1],再逆序中间的子串。写一个递归函数,每次传入子串的起始地址和长度:
void reverse_str_rec(char *s, int len) { if (len <= 1) return; char tmp = s[0]; s[0] = s[len - 1]; s[len - 1] = tmp; reverse_str_rec(s + 1, len - 2); }调用方式:
char str[] = "hello"; reverse_str_rec(str, strlen(str)); printf("%s\n", str);这个版本逻辑上很漂亮,但有一个隐患:递归深度是字符串长度的一半。字符串比较长(比如几万字符)时,每一次递归都要在栈上保存返回地址和局部变量,极易造成栈溢出。所以实际工程中,迭代法更稳妥。递归的价值在于帮助你理解“函数调用栈”本身。我用它来练习指针对子串边界的控制:第二次调用传入s+1,就相当于把原数组第二个字符的地址当成新串的起始地址。没有地址概念的人,很难一次写对len - 2这个参数。
2.4 值得背下来的边界检查清单
写字符串逆序相关题目(尤其是PTA上那道“字符串逆序”题)踩过几次坑之后,我给自己整理了一个边界检查清单:
- 空串:
strlen(s)为0时,q = s - 1,此时p < q不成立,但s - 1这个地址本身是越界的,虽然不访问它,但指针运算也可能触发UB。最好在函数开头判断if (*s == '\0') return;。 - 单个字符:p和q指向同一个位置,无需交换。
- 中文字符串:如果s是UTF-8编码的中文,一个汉字占3个字节,逐字节逆序会把汉字拆坏。遇到“123你好”这类字符串,必须按字符边界处理,不能简单用char指针逐字节换。
- 只读区:如果传入的是字符串常量,比如
char *s = "hello",试图写s[0]会段错误,因为字符串常量存储在只读区。函数声明应该接收char *,但调用时不要传字面量,除非你用数组拷贝。
这些要点,我在今天练习时都验证了一遍,没一个多余。
3. 把结构体写进文件再读回来,顺带治一治野指针
3.1 一个看似简单却暗藏风险的练习
今天第二个重点练习,是文件读写。热搜词里“c语言文件读写操作代码”和“c语言内存管理”同时出现,我就设计了一道综合题:定义一个学生结构体,包含学号、姓名、成绩,然后动态创建5个学生对象,写入二进制文件,最后再读出来打印。这个过程要覆盖malloc、fopen、fwrite、fread、fclose,还要处理各种返回值为空的情况。
先看结构体和主函数框架:
#include <stdio.h> #include <stdlib.h> #include <string.h> typedef struct Student { int id; char name[32]; float score; } Student; int save_students(Student *stu, int n, const char *filename) { FILE *fp = fopen(filename, "wb"); if (fp == NULL) { perror("fopen fail"); return -1; } size_t written = fwrite(stu, sizeof(Student), n, fp); fclose(fp); if (written != n) { return -2; } return 0; }注意这里fwrite的第二个参数是单个元素的大小,第三个是元素个数。为什么传sizeof(Student)而不是直接传整个数组字节数?因为fwrite约定一次写一个元素,如果中途出错,能返回实际写成功的元素个数,方便判断是第几个元素出了问题。这是规范写法和数组首地址强转成void*的区别:虽然本质上都是写一段连续内存,但语义不同,便于错误处理。
3.2 动态分配:malloc之后的三件事
在main里创建学生数组,我选择用malloc动态分配:
int n = 5; Student *stu = (Student*)malloc(sizeof(Student) * n); if (stu == NULL) { fprintf(stderr, "malloc fail\n"); return 1; }这里有三件事是新手最容易漏的,今天逐一验证:
第一,malloc可能返回NULL。一旦内存不足,返回NULL,如果直接往里写数据,就是空指针解引用,必崩。所以必须判断。
第二,malloc分配的内存内容是不确定的,可能是上次某个进程留下的残留数据。所以分配完之后,要么用memset(stu, 0, sizeof(Student)*n)清零,要么逐个字段赋值。我建议养成先memset再赋值的习惯。
第三,用完之后必须free(stu),并且把stu置为NULL。不free会产生内存泄漏,但程序结束时操作系统会回收,所以短时间看不出问题;真正头疼的是“悬垂指针”——free之后没有把指针置空,后续代码还在用这个指针,访问到的内存可能已被其他变量占用,产生难以复现的诡异bug。
3.3 读取文件并验证数据完整性
读取端代码:
Student *read_students(const char *filename, int *out_n) { FILE *fp = fopen(filename, "rb"); if (fp == NULL) { perror("fopen fail"); return NULL; } fseek(fp, 0, SEEK_END); long size = ftell(fp); fseek(fp, 0, SEEK_SET); int n = size / sizeof(Student); Student *arr = (Student*)malloc(sizeof(Student) * n); if (arr == NULL) { fclose(fp); return NULL; } size_t read_cnt = fread(arr, sizeof(Student), n, fp); fclose(fp); if (read_cnt != n) { free(arr); return NULL; } *out_n = n; return arr; }这里通过文件大小除以单个结构体大小,得到元素个数。注意二进制文件直接用sizeof(Student)写入,在不同编译器下可能有不同的结构体内存对齐,导致文件跨平台不兼容。今天练习我们只在同一台机器上读写,所以没问题;如果要在Windows和Linux之间交换二进制文件,必须考虑#pragma pack或者使用序列化方案。我顺便提一句,因为实际开发中经常有人栽在这上面。
运行结果示例:
========== 原始数据 ========== 1001 Alice 92.5 1002 Bob 87.0 1003 Cindy 95.5 1004 David 78.0 1005 Eve 88.5 ========== 从文件读回 ========== 1001 Alice 92.5 1002 Bob 87.0 1003 Cindy 95.5 1004 David 78.0 1005 Eve 88.53.4 非法地址的检验与预防
热搜词里有一个“怎么检验非法地址c语言”,这正好是文件读写练习中必然遇到的问题。平时最常见的两个“非法地址”场景:一是越界访问arr[i]但i超出了分配范围,二是通过未初始化的指针写数据。今天练习时我故意在读取函数里写了一个越界访问来测试:
// 故意越界 for (int i = 0; i <= n * 2; i++) { printf("%d %s %.1f\n", arr[i].id, arr[i].name, arr[i].score); }在大多数系统上,这不会立刻报错,因为malloc分配的内存后面可能还有未被访问的页。但一旦越界到不可访问的地址,程序就会段错误(Segmentation fault)。这就是为什么“非法地址”很难排查——它不是每次都崩,而是随机的。我的做法是:
- 写代码时始终使用
malloc分配的大小信息,循环条件写死不超过边界; - 使用AddressSanitizer(
gcc -fsanitize=address)编译运行,它能立即报告越界的具体位置; - 使用valgrind检测内存泄漏和非法访问。
实测用gcc -fsanitize=address编译刚才的越界代码,运行时会直接输出类似ERROR: AddressSanitizer: heap-buffer-overflow的信息,定位非常准确。这个工具值得所有学C语言的人尽早掌握,不用等出了问题才想。
4. 计算机英语day11:今天边调错边背的十二个高频词
4.1 为什么C语言练习要搭配计算机英语
我坚持把“C语言基础练习”和“计算机英语”放在一起更新,不是因为名字上押韵,而是因为它们真的能互相促进。C语言的教材、文档、报错信息几乎全是英文,堆栈溢出的报错、头文件里的定义、开源项目里的命名,都离不开英文。很多初学者看到Segmentation fault就懵,其实拆开来看:Segmentation是“分段”,fault是“错误”,合起来是“段错误”,多好记。
今天的练习里,我碰到了不少既考验编程又考验英语的词汇。我从中挑了12个最常用的,记录成词表,同时每个词都放在今天代码的真实语境里。
4.2 今日词表与代码语境
| 单词 | 音标/词性 | 含义 | 今天代码里的实际出处 |
|---|---|---|---|
| pointer | /ˈpɔɪntər/ n. | 指针 | char *p = s; |
| dereference | /ˌdiːˈrefrəns/ v. | 解引用(通过地址访问值) | *p = *q; |
| allocate | /ˈæləkeɪt/ v. | 分配(内存) | malloc的作用 |
| deallocate / free | v. | 释放(内存) | free(stu); |
| heap | /hiːp/ n. | 堆区(动态内存区) | malloc分配的区域 |
| stack | /stæk/ n. | 栈区(函数调用区) | 递归调用时压栈 |
| NULL | /nʌl/ n. | 空指针常量 | if (stu == NULL) |
| stream | /striːm/ n. | 流(文件流) | FILE* 抽象为文件流 |
| buffer | /ˈbʌfər/ n. | 缓冲区 | fwrite内部缓冲 |
| overflow | /ˌoʊvərˈfloʊ/ n. | 溢出(越界) | 递归调用过深导致栈溢出 |
| segmentation fault | n. | 段错误(非法访问内存) | Segmentation fault (core dumped) |
| recursion | /rɪˈkɜːrʒən/ n. | 递归 | 递归逆序函数 |
4.3 用英文命名让代码自带记忆点
我习惯把变量名和函数名写成有意义的名字,而不是用a、b、c。今天的练习题里,我用的是reverse_str_iter和reverse_str_rec,可读性立刻提升。在写代码时顺便记单词,比单纯背单词表高效得多。
一个具体的做法:在写注释时故意用英文写,然后翻译一遍。比如今天结构体数组排序代码的注释:
// swap two students using pointer dereference void swap_stu(Student *x, Student *y) { Student temp = *x; *x = *y; *y = temp; }这里的swap two students using pointer dereference就是一个完整的英文句子。每写一次注释,就等于复习一次“pointer”和“dereference”。我今天默写这12个词,重点不是拼写,而是看到词能联想到代码行为:看到allocate就想malloc,看到deallocate就想free,看到stream就想fopen。这种“词—代码”双联想,记起来快,用起来也准。
4.4 报错信息中的英语阅读技巧
调代码时看懂报错信息,是计算机英语最实用的场景。今天用AddressSanitizer时,报错信息开头是ERROR: AddressSanitizer: heap-buffer-overflow on address ...。拆解一下:heap-buffer-overflow=堆缓冲区溢出,意思是访问了malloc分配的堆缓冲区的边界之外。在gdb里遇到Program received signal SIGSEGV, Segmentation fault.,SIGSEGV就是段错误的信号,SEGV是“Segmentation Violation”(段违规)的缩写。
很多同学一看到满屏英文就慌,我的经验是:先抓“动词”和“名词”,先看是error还是warning,然后看是哪一行的address、file、line。不用看懂每个单词,抓住invalid、overflow、uninitialized这几个高频形容词,大部分报错都能猜个八九不离十。今天day11的这12个词里,有5个直接出现在报错信息里。语言障碍一破,调错速度就上来了。
5. 综合练习实测:结构体数组排序中的段错误与非法地址排查
5.1 综合题设计
最后一道综合练习,是把今天练过的所有技能揉在一起:动态分配结构体数组,按成绩降序排序,把结果写入文件,再读出来验证。这个练习在网上类似的题目很多,但真正动手写,能够把排序中“交换结构体”这个操作和地址联系起来。
我先写了第一版:
void sort_students(Student *arr, int n) { for (int i = 0; i < n; i++) { for (int j = 0; j < n - i - 1; j++) { if (arr[j].score < arr[j+1].score) { swap_stu(&arr[j], &arr[j+1]); } } } }swap_stu已经定义好了,传入两个结构体指针,函数内使用临时变量交换。这个版本逻辑没问题,但我在第一次运行为什么会崩溃?我故意在调用排序前少做了一件事:忘记了初始化数组元素,导致成绩是随机值,然后排序会交换包含垃圾数据的结构体,如果垃圾值恰好是NaN(非数值),比较结果不确定,但这不会直接导致段错误。真正导致段错误的原因是我在排序时传入的arr是NULL——因为前面的malloc失败了,而我没有判断返回值。
5.2 完整排查链路:从崩溃到根因
我把排查过程完整记录下来,这正是“怎么检验非法地址c语言”的实战答案。
复现步骤:
- 运行程序,立即闪退,没有输出。使用
./sort_stu运行,输出“Segmentation fault (core dumped)”。 - 改用gdb启动:
gdb ./sort_stu,输入run,崩溃后输入bt查看调用栈。栈顶显示sort_students (arr=0x0, n=5),arr是0x0,也就是NULL。 - 检查代码,发现
arr是malloc的返回值,而malloc前面有一行没写完的printf占用了太多内存(实际上是我故意用一个特大的分配抢走内存,这里做了艺术加工)。总之,malloc返回了NULL。 - 根因:没有判断malloc返回值,就把NULL传给了sort_students,sort内部对
arr[0]解引用,地址为0,非法访问,触发段错误。 - 修复:在malloc之后立刻判断:
if (stu == NULL) { fprintf(stderr, "Failed to allocate memory.\n"); return 1; }这样问题就提前暴露,而不是等到排序时才崩。
5.3 更隐蔽的非法地址问题:双指针交换结构体
还有一种非法地址问题很隐蔽,出现在“按成绩排序并交换结构体”的另一种实现里,有人会用指针数组排序,然后用*arr[j]交换两个结构体。如果指针数组越界,交换时会写坏相邻内存。今天我没有踩这个坑,但我在查阅资料时发现,很多人写PTA的“字符串逆序”题目时,也喜欢用指针数组交换两个字符指针,结果交换的只是形参的拷贝,实参没变。这其实是“按值传递”与“地址传递”的根本区别。
为了避免读者踩坑,我总结一条经验:当你看不清楚一个操作到底在改“指针变量”还是改“指针指向的内容”时,就在纸上画两个盒子:一个是变量盒子,一个是目标盒子。解引用*p操作的是目标盒子,直接p = q操作的是变量盒子。用这个模型去理解swap,永远不会错。
5.4 修正后的完整代码与运行效果
修正后的综合练习核心代码:
#include <stdio.h> #include <stdlib.h> typedef struct Student { int id; char name[32]; float score; } Student; void swap_stu(Student *x, Student *y) { Student temp = *x; *x = *y; *y = temp; } void sort_students(Student *arr, int n) { for (int i = 0; i < n - 1; i++) { for (int j = 0; j < n - i - 1; j++) { if (arr[j].score < arr[j+1].score) { swap_stu(&arr[j], &arr[j+1]); } } } } int main() { int n = 5; Student *stu = (Student*)malloc(sizeof(Student) * n); if (stu == NULL) { fprintf(stderr, "Memory allocation failed.\n"); return 1; } // init data stu[0] = (Student){1001, "Alice", 92.5f}; stu[1] = (Student){1002, "Bob", 87.0f}; stu[2] = (Student){1003, "Cindy", 95.5f}; stu[3] = (Student){1004, "David", 78.0f}; stu[4] = (Student){1005, "Eve", 88.5f}; sort_students(stu, n); FILE *fp = fopen("stu_sorted.bin", "wb"); if (fp == NULL) { perror("fopen fail"); free(stu); return 1; } fwrite(stu, sizeof(Student), n, fp); fclose(fp); // read back and print Student *back = (Student*)malloc(sizeof(Student) * n); if (back == NULL) { free(stu); return 1; } fp = fopen("stu_sorted.bin", "rb"); if (fp == NULL) { perror("fopen fail"); free(stu); free(back); return 1; } fread(back, sizeof(Student), n, fp); fclose(fp); printf("Sorted by score (desc):\n"); for (int i = 0; i < n; i++) { printf("%d %s %.1f\n", back[i].id, back[i].name, back[i].score); } free(stu); free(back); return 0; }运行结果:
Sorted by score (desc): 1003 Cindy 95.5 1001 Alice 92.5 1005 Eve 88.5 1002 Bob 87.0 1004 David 78.0从结果可以看到,等号右边所有元素都被排序正确。这类综合练习,比单独做十个“输入输出”题更能锻炼人。
5.5 排序练习中的额外心得
冒泡排序本身不难,但用结构体数组做冒泡排序时,如果每交换一次结构体,就要整体拷贝若干字节;如果结构体很大(比如包含一个char name[1024]),交换会浪费大量时间。更好的做法是排序指针数组,只交换指针,不搬动结构体。但这会引入两个间接层,指针的指针,对初学者又是一道坎。今天先不过度展开,只提供这个思路。后续如果大家有需要,我可以把“结构体指针数组排序”单独拿出来做一期的内容。
在做今天第17期练习时,还有一个小细节让我很惊喜。计算机英语day11词表里有一个词是recursion(递归),今天在写递归逆序时反复用到;另一个词是buffer(缓冲区),写文件时自然会提到。语言学习和技术练习互相交叉,让记单词不像背词典那么枯燥,也让写代码多了一个“用英文思考”的维度。我自己试了一周,这种组合学习法比单纯刷题和单纯背单词都更容易坚持。接下来第18期,我打算继续用同样的思路,把C语言中的“联合体、枚举、位域”和计算机英语day12一起做进去,到时候再分享新踩到的坑。如果你也在坚持C语言练习,不妨试试在注释里写英文、在调错时拼读报错单词,坚持十期回头看看,效果一定比闷头写代码好不少。