C语言指针与内存实战:字符串逆序、结构体文件读写与排错技巧
最近在坚持更新“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换到开头输出直接变成空串。这就是典型的“差一错误”画内存图的时候你会看到数组长度为6h 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);这个版本逻辑上很漂亮但有一个隐患递归深度是字符串长度的一半。字符串比较长比如几万字符时每一次递归都要在栈上保存返回地址和局部变量极易造成栈溢出。所以实际工程中迭代法更稳妥。递归的价值在于帮助你理解“函数调用栈”本身。我用它来练习指针对子串边界的控制第二次调用传入s1就相当于把原数组第二个字符的地址当成新串的起始地址。没有地址概念的人很难一次写对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分配的大小信息循环条件写死不超过边界使用AddressSanitizergcc -fsanitizeaddress编译运行它能立即报告越界的具体位置使用valgrind检测内存泄漏和非法访问。实测用gcc -fsanitizeaddress编译刚才的越界代码运行时会直接输出类似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 / freev.释放内存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 faultn.段错误非法访问内存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[j1].score) { swap_stu(arr[j], arr[j1]); } } } }swap_stu已经定义好了传入两个结构体指针函数内使用临时变量交换。这个版本逻辑没问题但我在第一次运行为什么会崩溃我故意在调用排序前少做了一件事忘记了初始化数组元素导致成绩是随机值然后排序会交换包含垃圾数据的结构体如果垃圾值恰好是NaN非数值比较结果不确定但这不会直接导致段错误。真正导致段错误的原因是我在排序时传入的arr是NULL——因为前面的malloc失败了而我没有判断返回值。5.2 完整排查链路从崩溃到根因我把排查过程完整记录下来这正是“怎么检验非法地址c语言”的实战答案。复现步骤运行程序立即闪退没有输出。使用./sort_stu运行输出“Segmentation fault (core dumped)”。改用gdb启动gdb ./sort_stu输入run崩溃后输入bt查看调用栈。栈顶显示sort_students (arr0x0, n5)arr是0x0也就是NULL。检查代码发现arr是malloc的返回值而malloc前面有一行没写完的printf占用了太多内存实际上是我故意用一个特大的分配抢走内存这里做了艺术加工。总之malloc返回了NULL。根因没有判断malloc返回值就把NULL传给了sort_studentssort内部对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[j1].score) { swap_stu(arr[j], arr[j1]); } } } } 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语言练习不妨试试在注释里写英文、在调错时拼读报错单词坚持十期回头看看效果一定比闷头写代码好不少。