简介:这份教务信息管理系统C语言课程设计资料面向高校计算机及相关专业学生,适用于C语言课程设计选题、实验报告撰写与源码研读参考。压缩包内仅含1个doc文档,约361KB,以实验报告形式集中呈现程序简介、功能说明、数据结构设计、流程图及源代码,省去在零散文件中反复查找的麻烦。文档围绕教务信息管理场景展开,涉及图形界面菜单操作、模块化编译、班级—学生—课程—成绩四重十字交叉链表、信息增删改查排序、班级与课程均分统计、不及格信息统计、文件保存及模拟打印等内容,能帮助读者理解链表组织方式与课程设计写作框架。目前已有1121人学习下载,适合需要完成同类课程设计、梳理实验报告结构或准备答辩演示的读者参考。
1. 一份 2006 年的课程设计,骨架比想象中硬
翻到一份 C 语言课程设计,题目是教务信息管理系统,我原本以为又是三层菜单套一个结构体数组,结果打开一看是四重十字交叉链表:班级链挂学生链,学生链挂课程链,课程链再挂成绩链,九个源文件按模块编译,在 TC 里按 Ctrl+F9 吐出 jiaowu.exe,数据落盘成四个定长 txt,直接能拿去打印。这份教务信息管理系统 C 语言课程设计真正值钱的地方不在功能清单,而在于它把一个正经的数据建模问题硬塞进了 64KB 内存和 DOS 字符界面里:没有 STL,没有 vector,指针写错一个字节就是几天调不出来。适合两类人看,一类是想搞明白嵌套链表到底怎么串起来的初学者,另一类是手上有老 C 代码要维护、想知道模块化工程在 TC 时代怎么组织的人。
2. 四重链表建模:班级、学生、课程、成绩怎么用指针咬合
这套系统的数据模型不是一张平表,而是四层嵌套。理解它的关键是先想清楚一个问题:一个成绩节点,怎么知道自己属于哪个班、哪个学生、哪门课?答案藏在每个结构体的fall字段里。
2.1 四个结构体的字段设计:从 CLASS 一路拆到 scores
先看最外层的班级链,它是整条数据链的入口,本身很轻,只有班号和一个学生链头指针:
/* 班级链:整条十字交叉链的入口 */ typedef struct classes { char class_no[5]; /* 班号,4 位有效字符 + 1 个 '\0' */ struct classes *next1; /* 同级下一个班级 */ struct students *sthead; /* 本班学生链的头指针 */ } CLASS;class_no[5]这个长度不是随手写的,4 位班号是那个年代高校班级编号的常见宽度,留一位给字符串结束符。sthead是把纵向链路接起来的关键,班级节点本身不存学生数据,只存一个入口地址,这样插入删除学生时班级节点完全不用改动。
往下一层是学生链,字段明显变多,因为学生是业务主体:
/* 学生链:挂在某个班级下面 */ struct students { char fall1[2]; /* 从属关系:fall1[0] 是所属班号 */ char student_no[13]; /* 学号,12 位 + '\0' */ char name[17]; /* 姓名,16 字节足够存中文拼音或汉字 */ char sex[2]; /* 性别:'0' 男,'1' 女 */ char age[3]; /* 年龄,两位数字 */ char phone[12]; /* 寝室电话 */ char com_phone[12]; /* 手机,'0' 表示没填 */ char jia_quan[7]; /* 加权分,如 "87.35" */ char all_credit[4]; /* 已得总学分 */ struct students *next2; /* 同级下一个学生 */ struct subjects *suhead; /* 该学生的课程链头指针 */ };fall1[2]是个看起来冗余的字段,学生明明已经挂在某个班级的链表里了,为什么还要记一遍班号?因为这套结构没有父指针。从学生节点往上找不到班级,一旦要在打印或查找时显示「某班某学生」,只能靠这个冗余字段。用一点空间换一次回溯,在 2006 年的机器上是划算的买卖。
课程链和成绩链同理,一层比一层多一个从属字段:
/* 课程链:挂在某个学生下面 */ struct subjects { char fall2[3]; /* fall2[0] 班号,fall2[1] 学号中的序号 */ char course_no[11]; /* 课程编号 */ char course_name[17]; /* 课程名称 */ char credit[4]; /* 学分,如 "2.5" */ struct subjects *next3; /* 同级下一门课 */ struct scores *chead; /* 该课程的成绩链头指针 */ }; /* 成绩链:一门课可以有多条考试记录(正常考 + 重修) */ struct scores { char fall3[4]; /* fall3[0] 班,fall3[1] 学生,fall3[2] 课程 */ char number[2]; /* 第几次考试,'1' 正常,'2' 起是重修 */ char exam_score[4]; /* 考试成绩 */ char usual_score[4]; /* 平时成绩 */ char coll_score[4]; /* 综合成绩 */ char date[11]; /* 考试时间,如 "2005-2006" */ struct scores *next4; /* 同一门课的下一次考试 */ };成绩链的next4不是指向下一门课,而是指向同一门课的下一次考试记录。这就是为什么不及格的学生可以反复录入重修成绩——链表节点可以一直往后加,直到及格或者用户主动停手。这个设计在课设级别的程序里算是有想法的,它把「一次考试」当成一条记录,而不是把成绩当成课程的一个属性。
2.2 三级头指针的遍历路径:一条统计语句要走多少层循环
结构建好之后,所有统计功能本质上都是四层嵌套遍历的变体。以「统计各班所有课程均分」为例,骨架长这样:
/* 统计每个班级、每门课程的平均综合成绩 */ void class_average(CLASS *head) { CLASS *pc; struct students *ps; struct subjects *psu; struct scores *psc; double sum; int cnt; for (pc = head; pc != NULL; pc = pc->next1) { /* 第一层:班级 */ printf("班级 %s\n", pc->class_no); /* 以第一个学生的课程链为模板,因为默认所有人课程相同 */ for (psu = pc->sthead->suhead; psu != NULL; psu = psu->next3) { sum = 0.0; cnt = 0; for (ps = pc->sthead; ps != NULL; ps = ps->next2) { /* 第二层:学生 */ for (psc = ps->suhead; psc != NULL; psc = psc->next4) { /* 找到课号一致的记录才累加 */ if (strcmp(ps->suhead->course_no, psu->course_no) == 0) { sum += atof(psc->coll_score); cnt++; } } } if (cnt > 0) printf(" %s 均分 %.2f\n", psu->course_name, sum / cnt); } } }这段代码暴露了这套结构的一个真实代价:课程链挂在学生下面,意味着「同一门课」在不同学生节点里是四个独立的节点,统计时必须按课程号去对齐。原程序默认所有学生课程一样,所以能拿第一个学生的课程链当模板循环,换个场景就得改成先收集全部课程号去重再统计。参数head是班级链头指针,传入 NULL 时整个循环直接跳过,这就是「请先新建文件」提示的来源。
2.3 定长字符数组的取舍:为什么成绩不用 float
四个结构体里几乎全是char[],连成绩、学分、加权分都没用数值类型。第一次看会觉得别扭,但这套选择在这个项目里是自洽的。
第一个原因是落盘。保存文件是把链表写成 txt,如果用float存成绩再fprintf("%f"),会遇到 53.0 写成 53.000000、32.5 写成 32.500000 这种尾巴,而且浮点误差会让 60 分变成 59.999998 之类的东西。全用字符串,存进去什么样,文件里就什么样,用记事本打开能直接看懂,甚至能手工改。
第二个原因是输入容错。scanf("%s", buf)到字符数组最省事,用户输错了也能原样存下来,不会像scanf("%f")那样把输入缓冲区搞脏。
代价在后面每一处比较和计算里。排序要atof,算加权要atof,判及格也要atof。这些转换散落在各模块里,一旦某个字段忘加结束符,atof就会一路读到隔壁字段去,算出来的加权分是个天文数字。这类 bug 不会崩,只会让统计结果看起来很玄学。
提示:定长字段的宽度一旦定下来就是文件格式的一部分,改宽度等于改协议,老文件全部读不进来。
3. 模块化编译:九个 .c 怎么拼出一个 jiaowu.exe
单文件写到两千行的时候,TC 就已经开始罢工了。这份课设的进化路径很典型:先一个 .c 一路写到 TC 2.0 编不动,换 Win-TC 撑到四千行,再换 TC 3.0 撑到五千行,最后靠拆模块才彻底解决。拆模块不只是为了绕过编译器限制,它顺带把功能边界也理清了。
3.1 九个源文件的职责边界与函数清单
按文件名基本能看出分工,这也是那个年代课设的命名习惯:文件名就是模块名。
| 源文件 | 承担的职责 | 典型导出函数 |
|---|---|---|
| main2.c | 程序入口、主菜单绘制、按键分发 | main()、main_menu() |
| files.c | 新建、打开、保存、退出四件事 | create_all()、save_all()、load_all() |
| edit.c | 插入、删除、修改、排序 | insert_class()、del_student()、sort_by_weight() |
| in_out.c | 各类信息的屏幕输出、分页滚动 | print_student()、scroll_view() |
| search.c | 班级、学生、课程的查找 | search_student()、search_course() |
| tongji.c | 班级均分、学生均分、不及格名单 | class_average()、student_rank() |
| da_yin.c | 报表生成与写文件 | print_report() |
| help.c | 帮助界面 | show_help() |
| qita.c | 杂项,如日期、字符串工具 | trim()、read_key() |
这种拆法有一个隐含约定:files.c是唯一负责磁盘 IO 的模块,其他模块只碰内存里的链表。这让「未保存提示」只需要在一处判断——每次新建、打开、退出之前,先问一句链表是否脏。老代码里靠一个全局标志位实现,虽然土,但在这种规模下够用。
我一般会在这九个 .c 之上再抽一个公共头文件,把所有结构体定义和函数原型放进去:
/* jiaowu.h:所有模块共享的声明,避免各 .c 里重复写 extern */ #ifndef JIAOWU_H #define JIAOWU_H typedef struct classes CLASS; struct students; struct subjects; struct scores; /* 文件模块 */ CLASS *create_all(void); int save_all(CLASS *head, const char *base); CLASS *load_all(const char *base); int is_dirty(void); void set_dirty(int flag); #endif原报告里没有提到头文件,那个年代的课设常见做法是在每个 .c 顶部手写一遍extern声明,一旦函数签名改了就得改九处。抽头文件是多花十分钟、少踩一堆链接错误的事。
3.2 在 TC 里建 .prj 工程:从源码到 jiaowu.exe 的完整链路
TC 的工程文件本质就是一个纯文本清单,每行一个源文件名。后缀必须是.prj,内容长这样:
main2.c files.c edit.c in_out.c search.c tongji.c da_yin.c help.c qita.c操作顺序是固定的,少一步都出不来 exe:
- 在 TC 里逐个打开 .c 文件确认能单独编译通过,语法错误要在这步清掉。
- 菜单
Project→Project name,输入jiaowu.prj,回车。 - 回到编辑器,按
F9执行 Make,或者Compile→Build all强制全量重编。 - 按
Ctrl+F9运行,TC 会自动先编译再执行。 - 编译产物是
jiaowu.exe,退出 TC 后在 DOS 下直接敲jiaowu也能跑。
这里有个容易翻车的点:Make 和 Build all 不是一回事。Make 只重编比 .obj 新的源文件,如果只改了结构体定义却没让所有依赖它的模块重编,链接出来的 exe 里就会出现「同一个结构体两套布局」的诡异现象——A 模块按旧布局写next2,B 模块按新布局读,指针指到隔壁字段上去。改过公共头文件之后,我一定是 Build all,不省那几秒。
编译目标要选对。TC 2.0 默认是 Small 模式,代码段和数据段各 64KB,这套程序的数据全在堆上(malloc),但全局数组和静态缓冲区仍然占数据段。如果编译报Segment overflow或者运行到一半莫名重启,先看Options→Compiler→Model,改成 Medium 或 Large,指针会变成 far 指针,能访问超过 64KB 的空间。
3.3 菜单主循环与键盘扫描:方向键、Esc 和回车怎么被接住
整套交互不用鼠标,全靠键盘。方向键在 BIOS 里是扩展键,bioskey读回来是一个 int,低字节为 0,高字节是扫描码。0x4800是上,0x5000是下,0x4B00是左,0x4D00是右,Esc 是0x011B。
#include <bios.h> #define KEY_UP 0x4800 #define KEY_DOWN 0x5000 #define KEY_LEFT 0x4B00 #define KEY_RIGHT 0x4D00 #define KEY_ESC 0x011B /* 阻塞读一个键,普通键返回 ASCII,扩展键返回完整扫描码 */ int read_key(void) { int k = bioskey(0); if ((k & 0xFF) == 0) /* 低字节为 0 说明是方向键这类扩展键 */ return k; return k & 0xFF; /* 普通字符键只留 ASCII 部分 */ }主循环是一个状态机,只做三件事:重绘、读键、分发。
/* 主菜单循环:上下选菜单,回车进入,Esc 返回上层 */ void main_menu(void) { int cur = 0, key; draw_menu(cur); /* 高亮当前项,用 textbackground 反色 */ while (1) { key = read_key(); if (key == KEY_UP) { cur = (cur + MENU_N - 1) % MENU_N; /* 环形上移,到头绕回 */ draw_menu(cur); } else if (key == KEY_DOWN) { cur = (cur + 1) % MENU_N; draw_menu(cur); } else if (key == KEY_ESC) { return; /* 回上层,由调用者决定去哪 */ } else if (key == 13) { dispatch(cur); /* 执行当前项,子菜单递归调用自身 */ } else { shortcut(key); /* 直接按首字母跳转,如 'f' 进文件菜单 */ } } }draw_menu只重绘菜单区域,不clrscr整个屏幕,否则光标闪烁明显。子菜单的实现方式是递归调用同一个循环,只是把菜单项数组换成子菜单的数组,Esc 时return自然回到父层。这种写法代码量小,代价是层级深了以后栈会涨,但这套程序最多三层,没问题。
左右键在子菜单里的语义是「横向切换到其他主菜单的子项」,实现上是在子菜单循环里额外判断KEY_LEFT/KEY_RIGHT,直接跳出当前子菜单并带上目标索引。这就是原报告里说的「按左右键就可以进入其它主菜单选项的子选项」。
4. 功能实现:新建、存盘、统计三条主链路
菜单只是壳,真正决定这套系统能不能用的是三条链路:怎么把数据建起来、怎么存下去、怎么算出来。
4.1 新建链表:三层输入循环与 "00" 哨兵
新建是最容易写崩的地方,因为它要一次性把四层结构按顺序建完。这里的核心技巧是用"00"当哨兵,让用户在任意一层输入"00"就结束当前层、返回上一层。
/* 新建班级链,每个班级内部递归建学生链,输入 "00" 结束当前层 */ CLASS *create_classes(void) { CLASS *head = NULL, *tail = NULL, *p; char buf[8]; while (1) { printf("请输入班号(输入 00 结束):"); scanf("%4s", buf); if (strcmp(buf, "00") == 0) break; /* 哨兵:结束班级层 */ p = (CLASS *)malloc(sizeof(CLASS)); if (p == NULL) { printf("内存不足,已建好的部分保留\n"); break; /* 不崩,保留已建数据 */ } strncpy(p->class_no, buf, 4); p->class_no[4] = '\0'; /* 手工补结束符,strncpy 不保证 */ p->next1 = NULL; p->sthead = create_students(p->class_no); /* 往下建学生链 */ if (tail == NULL) head = p; /* 第一个节点 */ else tail->next1 = p; /* 尾插,保持输入顺序 */ tail = p; } return head; }三个细节值得说。strncpy(p->class_no, buf, 4)之后必须手工写p->class_no[4] = '\0',因为strncpy在源串长度大于等于 n 时不会补结束符,少了这一行,后面strcmp就会读到结构体里next1的低字节上去,指针被当成字符串的一部分,查班号时永远查不到。第二,malloc失败时选择break而不是exit,让用户至少能把已经录入的部分存下来,这在机房的老机器上很实用。第三,尾插法保持了录入顺序,如果改成头插,打印出来的班级顺序是反的,用户会觉得程序有 bug。
学生链、课程链、成绩链是同样的套路,只是哨兵判断的位置不同。成绩链多一层逻辑:录完综合成绩后判断是否及格,不及格就追问要不要录重修,要就继续在next4上加节点,直到及格或用户放弃。
4.2 存盘与读盘:一个文件名裂变成四个 txt
保存的交互只让用户输一个文件名,程序自己派生出四个。这是原报告里特意强调的设计,也是这套系统最实用的部分——四个文件分别对应四层链表,任何一个都能单独打开检查。
/* 保存全部链表:base 是用户输入的文件名(不含后缀), 函数内部派生 base1.txt ~ base4.txt 分别存班级/学生/课程/成绩 */ int save_all(CLASS *head, const char *base) { char n1[64], n2[64], n3[64], n4[64]; FILE *f1, *f2, *f3, *f4; CLASS *pc; struct students *ps; struct subjects *psu; struct scores *psc; sprintf(n1, "%s1.txt", base); sprintf(n2, "%s2.txt", base); sprintf(n3, "%s3.txt", base); sprintf(n4, "%s4.txt", base); /* 四个文件一次性全开,任何一个失败就整体失败,避免写一半 */ f1 = fopen(n1, "w"); f2 = fopen(n2, "w"); f3 = fopen(n3, "w"); f4 = fopen(n4, "w"); if (!f1 || !f2 || !f3 || !f4) { if (f1) fclose(f1); if (f2) fclose(f2); if (f3) fclose(f3); if (f4) fclose(f4); return 0; } /* 四层嵌套遍历,边遍历边往对应文件写 */ for (pc = head; pc; pc = pc->next1) { fprintf(f1, "%-4s\n", pc->class_no); /* 定宽 4,对应 class_no[5] */ for (ps = pc->sthead; ps; ps = ps->next2) { fprintf(f2, "%-2s%-12s%-16s%-1s%-2s\n", ps->fall1, ps->student_no, ps->name, ps->sex, ps->age); for (psu = ps->suhead; psu; psu = psu->next3) { fprintf(f3, "%-2s%-10s%-16s%-3s\n", psu->fall2, psu->course_no, psu->course_name, psu->credit); for (psc = psu->chead; psc; psc = psc->next4) fprintf(f4, "%-3s%-1s%-3s%-3s%-3s%-10s\n", psc->fall3, psc->number, psc->exam_score, psc->usual_score, psc->coll_score, psc->date); } } } fclose(f1); fclose(f2); fclose(f3); fclose(f4); return 1; }%-4s里的负号表示左对齐,宽度必须和结构体里的数组长度一一对应,这是定长记录的命门。读盘时按同样宽度fscanf(fp, "%4s", buf)切回来,两边宽度对不上就全乱。base传入"e:\\abc"时,反斜杠要转义,或者在调用fopen前统一拼好盘符路径。
原报告里那句「可以在保存的文件中修改数据,只要注意数据的长度不要被改变」,说的就是这个定宽格式。改一个学号,删掉中间两位,后面所有字段整体左移,读回来时班号会变成学号的前四位——这是文件格式设计上的血泪经验,定长记录对人友好,对编辑的容错是零。
注意:读盘前先检查四个文件是否齐全,缺一个就提示「文件不完整」,不要读到一半返回半个链表。
4.3 统计链路:班级均分、加权排名与不及格名单
统计模块是这套程序唯一有「计算价值」的地方,三个功能分别对应三种遍历方式。
班级均分在上面已经写过,是四层遍历加课程号对齐。学生均分本质上是加权计算再排序,加权公式是Σ(综合成绩 × 学分) / Σ学分,综合成绩又由平时和考试按比例合成。这套公式在录入时就已经算成字符串存进coll_score,统计时只做读取。
排序这里有个取舍:是重排链表指针,还是只排显示顺序?重排节点很容易翻车,一旦交换时漏改了suhead,学生的课程链就挂到别人身上去了。我倾向的做法是不动链表,只把节点指针收进一个数组排序:
/* 按加权分从高到低输出全班排名,不改变链表本身的结构 */ void student_rank(CLASS *pc) { struct students *arr[128]; struct students *tmp; int n = 0, i, j; if (pc == NULL || pc->sthead == NULL) { printf("请先新建文件\n"); return; } /* 收指针进数组,最多 128 人,够一个班用 */ for (tmp = pc->sthead; tmp && n < 128; tmp = tmp->next2) arr[n++] = tmp; /* 冒泡,比较的是字符串转出来的浮点数 */ for (i = 0; i < n - 1; i++) for (j = 0; j < n - 1 - i; j++) if (atof(arr[j]->jia_quan) < atof(arr[j + 1]->jia_quan)) { tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; } for (i = 0; i < n; i++) { printf("%2d %-12s %-16s 加权 %s\n", i + 1, arr[i]->student_no, arr[i]->name, arr[i]->jia_quan); } }数组大小 128 是个硬上限,超过就会静默截断。这个数字在课设场景下够用,但在实际教务场景里一个年级很容易过千,这是二次开发必须改掉的地方之一。等加权相同时按链表顺序排,冒泡是稳定排序,正好满足原报告里那句「加权相同就按他们在链表中的顺序来排」。
不及格名单的判定用atof(psc->coll_score) < 60,注意要遍历next4上所有考试记录,取最后一次的成绩判断,否则重修及格的学生还会出现在名单里。
4.4 断点查找:用 strstr 做学号与姓名的模糊匹配
查找模块有一个很实用的设计:输入00就切换成按姓名查,而且支持子串匹配,输zhang能把所有姓张的找出来。实现上就是strstr:
/* 子串匹配查找:学号和姓名任意一个命中就输出 */ void search_student(CLASS *head, const char *key) { CLASS *pc; struct students *ps; int found = 0; for (pc = head; pc; pc = pc->next1) for (ps = pc->sthead; ps; ps = ps->next2) /* strstr 返回首次出现位置的指针,NULL 表示没找到 */ if (strstr(ps->student_no, key) || strstr(ps->name, key)) { printf("班级 %s ", pc->class_no); print_student(ps); found++; } if (found == 0) printf("没有找到相关信息\n"); }strstr是区分大小写的,录姓名时如果大小写混用,查zhang就查不到Zhang。那个年代没有strcasestr,常见做法是先统一转成小写再比。另外key传空串时strstr会返回原串,等于全部命中,输入校验要在调用前挡住空输入。
5. 避坑与排查:这些坑我一个个踩过
这套程序最消耗时间的从来不是写功能,而是排查那些不报错、只表现得「怪怪的」问题。下面五条是我见过的、也是这套代码结构最容易复现的。
现象一:单文件写到两千行,TC 编译直接卡死或报段溢出。原因:TC 2.0 默认 Small 内存模型下代码段只有 64KB,函数一多就装不下,编译器不是报错而是直接失去响应。解决:拆模块,每个 .c 控制在几百行,用.prj工程统一编译;同时把Options里的内存模型改成 Medium 或 Large,让代码段和数据段都能突破 64KB。
现象二:程序能编过,运行到建链表那一步就黑屏重启,改几行无关代码又好了。原因:局部数组开得太大,把栈撑爆了。DOS 下默认栈只有几 KB,一个char buf[4096]的局部变量就足够让main回不去。这种 bug 的表现和代码逻辑完全无关,加一句printf就可能改变栈布局从而「治好」,特别迷惑。解决:大缓冲区一律放全局或者malloc,局部变量控制在几十字节;排查时把可疑函数里的数组临时改成静态的,如果问题消失就基本确认是栈。
现象三:遍历链表到某个节点时崩溃,打印出来发现next2的值是个看不懂的地址。原因:字符数组越界写,把相邻的指针字段覆盖了。比如strcpy(ps->name, input)而input超过 16 字节,多出来的字节正好落在next2上。这类问题在录入长姓名时最容易触发,而且当时不崩,等下次遍历才崩,现场早就没了。解决:所有字符串写入用strncpy并手工补'\0',录入前先校验长度;结构体定义时把指针字段放在数组前面,能让越界先撞到别的数组,至少不会立刻毁掉链路。
现象四:图形界面一运行就提示找不到EGAVGA.BGI,换台机器就黑屏。原因:BGI 驱动是外部文件,程序启动时按路径去找,路径写死成开发机上的目录,换机器自然找不到。解决:把驱动文件拷到 exe 同目录,代码里用相对路径;更彻底的办法是用registerbgidriver把驱动编进 exe,彻底摆脱外部文件依赖。这一条对现在做嵌入式或者单文件发布的人同样成立。
现象五:打开文件提示「文件打不开」,但文件明明在。原因:保存时生成的是abc1.txt到abc4.txt四个文件,打开时只输abc,程序去拼四个名字,缺一个就整体失败;或者用户输入时带了.txt后缀,程序又拼了一次,变成abc.txt1.txt。解决:文件名一律不带后缀,输入后先检查四个文件是否都存在,缺哪个就明确报哪个,不要笼统说「打不开」;输入框里过滤掉.字符。
这五条的共同点是:它们都不会给你一个清晰的错误信息。老 C 代码的调试基本靠二分和打印,把可疑函数的入口出口都打上标记,一层层缩小范围,比盯着代码看有效得多。
6. 二次开发:把课程设计推到能用的边界
这套代码要真拿去当工具用,绕不开三件事:把定长字符数组换成真正的数据类型、把四文件存储换成单文件或数据库、把 BGI 图形界面从业务逻辑里剥出来。
先看数据层。字符数组的问题不在存,而在算。每次统计都要atof,精度和容错全靠运气。改造时我会保留结构体骨架,但把字段类型换掉,同时保留一个兼容读取函数,让老数据还能导进来:
/* 新结构:数值字段用真正的类型,只保留从属关系为字符串 */ struct student_v2 { char class_no[5]; char student_no[13]; char name[17]; int sex; /* 0 男 1 女,不再是字符 '0'/'1' */ int age; float jia_quan; /* 加权分直接存浮点 */ float all_credit; struct student_v2 *next; struct subject_v2 *sub_head; }; /* 兼容老文件:读到字符串后转成数值,转换失败给默认值而不是崩 */ struct student_v2 *from_old_line(const char *line) { struct student_v2 *p = (struct student_v2 *)malloc(sizeof(*p)); char buf[20]; if (p == NULL) return NULL; sscanf(line, "%4s%12s%16s", p->class_no, p->student_no, p->name); /* 加权分单独切出来转换,失败就当 0 处理 */ if (sscanf(line + 32, "%6s", buf) == 1) p->jia_quan = (float)atof(buf); else p->jia_quan = 0.0f; p->next = NULL; p->sub_head = NULL; return p; }这段代码里的line + 32是定长记录的字段偏移,看着很脏,但它就是老格式的真实样子,改造时必须有这么一层适配,否则历史数据全废。
存储层的改造优先级最高。四个 txt 的问题不是不漂亮,而是没有事务性:写第三个文件时断电,前两个已经是新数据,后两个还是旧的,读回来数据直接错位。我的做法是先写临时文件,四个都写完再统一rename,用文件系统的最小原子性兜底。改造清单大致是这样:
| 改造点 | 现状 | 建议做法 |
|---|---|---|
| 数据字段 | 全部char[],比较靠atof | 数值字段换int/float,保留一层旧格式适配 |
| 存储 | 四个定长 txt,无事务 | 临时文件 + 统一 rename,或直接换单文件 CSV / SQLite |
| 界面 | BGI 图形 +bioskey轮询 | 抽出ui_*接口,控制台版用 ncurses,桌面版换 Qt |
| 人数上限 | 指针数组写死 128 | 动态扩容或改用链表原地归并排序 |
| 查找 | strstr子串匹配 | 加索引或至少做一次小写归一 |
| 测试 | 没有 | 给每条链表写构造/销毁函数,配最小用例 |
界面这层的剥离是收益最大的。现在这套代码里,printf和gotoxy混在统计函数里,想换个前端等于重写。做法是把所有输出收敛成void ui_show_student(const struct student_v2 *)这类函数,业务函数只算不画,前端随便换。
验证方法也要跟着改。老代码没测试,改一行全靠手工跑一遍菜单。我的习惯是给每层链表写一对build_demo_*/free_*函数,用固定数据造三班五学生,然后把加权计算和均分统计的期望值写死在断言里。改结构体、改排序、改存储,跑一遍就知道有没有破功。
最后说个我自己的教训。早年改这类老代码,我总想一步到位重构,结果改到一半发现某个函数被九个地方调用,每个地方的参数含义还略有不同,最后既没改完也没法回退。从那以后我每次动这种四层嵌套链表,都强制走一遍「先加新函数、旧函数转调新函数、跑通、再删旧函数」的流程,一次只动一层。慢是慢,但不会在某个深夜对着一个被覆盖的next指针怀疑人生。希望帮到你。
本文还有配套的精品资源,点击获取