简介:面向C语言课程设计学习者的教务信息管理系统实验报告文档,适合计算机相关专业学生对照完成课程设计、梳理系统实现思路与撰写报告。文档围绕模拟辅导员年级成绩表处理数据的场景展开,重点讲解以十字交叉链表组织班级、学生、课程与成绩四重链的方法,并覆盖新建、保存、打开文件及链表修改、插入、删除、排序等管理操作,同时包含班级与课程均分统计、不及格学生信息筛选、模拟打印输出等功能说明和流程图。压缩包内仅1个doc文件,约361KB,篇幅集中,便于按实验报告结构查阅设计目标、数据结构定义、模块划分与运行效果。已有1121人学习,可作为C语言综合项目从需求分析到链表落地实现的参考样本,帮助读者理解模块化编译、图形界面菜单交互和文件持久化在课程设计中的组织方式。
1. C语言课程设计里的教务管理系统:一份能跑通的十字交叉链表源码包
C语言课程设计 教务管理系统这个题目,很多人第一反应是“学生成绩表增删改查”,真拿到一份 TC 时代的完整课程设计源码,才发现难点不在菜单,而在四层十字交叉链表怎么建、怎么存、怎么删。这份资源给出的是一套可编译的模块化工程:班级链挂学生链,学生链挂课程链,课程链挂成绩链,配合新建/打开/保存、插入/删除/修改/排序、班级均分、学生加权、不及格统计和模拟打印。适合正在做课程设计、需要一份能讲清结构又能改参数的学生,也适合想复盘早期 C 工程组织方式的开发者。下面按“结构→工程→功能→避坑→验证”拆开。
2. 先看数据结构:四层链表怎么把班级、学生、课程、成绩串起来
2.1 四个结构体的字段取舍与从属关系编码
这套程序的核心不是二维数组,而是四层链:CLASS里挂STUDENT,STUDENT里挂SUBJECT,SUBJECT里挂SCORE。每个父节点只保存子链头指针,子节点用next串起来,所以插入一个学生不需要搬动整个数组,删除一个班级也不用重排后面的班号。代价是每一层都要自己管理malloc/free,指针一断就是空指针翻车。
先看结构体定义,字段长度基本按“业务上限 + 1 个结束符”卡死:
#include <stdio.h> #include <stdlib.h> #include <string.h> typedef struct scores { char fall3[4]; /* fall3[0]班级序号 fall3[1]学生序号 fall3[2]课程序号 */ char number[2]; /* 考试次数,如 "1" "2" */ char exam_score[4]; /* 考试成绩,100 分也放得下 */ char usual_score[4]; /* 平时成绩 */ char coll_score[4]; /* 综合成绩 */ char date[11]; /* 考试时间,如 2005-2006 */ struct scores *next4; } SCORE; typedef 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; SCORE *chead; } SUBJECT; typedef struct students { char fall1[2]; /* fall1[0]班级序号 */ char student_no[13]; char name[17]; char sex[2]; /* 0 男 1 女 */ char age[3]; char phone[12]; char com_phone[12]; char jia_quan[7]; /* 加权分 */ char all_credit[4]; /* 已得总学分 */ struct students *next2; SUBJECT *suhead; } STUDENT; typedef struct classes { char class_no[5]; struct classes *next1; STUDENT *sthead; } CLASS;这里有个容易看漏的点:fall3[4]不是存完整的班级号、学号、课程号,而是存“第几个”的序号字符。原程序用fall3[0]指从属班级、fall3[1]指从属学生、fall3[2]指从属课程,本质是给成绩节点留一条回查父节点的短标记。班号超过一位时,不要拿它当完整班号用,否则查询会对不上。固定长度字符数组的好处是保存时直接写字符串,浮点分数也当字符串存,用文本编辑器打开没有乱码;坏处是输入必须截断,保存后手工改文件不能改字段长度,否则下一次读取会错位。
2.2 用固定长度字符数组保存数据:为什么不用 float 和 int
这份源码把分数、学分、加权都放进char数组,不是偷懒,而是早期课程设计里很常见的取舍:文件保存用fprintf直接写字符串,打开时按行拆字段,不需要处理浮点精度和二进制字节序。比如credit[4]能放"2.5",exam_score[4]能放"100",jia_quan[7]能放"85.25"。统计时再用atof转回浮点,排序时用strcmp比学号,逻辑简单,调试时肉眼可读。
代价也要说清楚:字符串比较不是数值比较,"100"和"99"按字典序比会出错;分数参与均分前必须atof;输入"2.50"和"2.5"会被当成不同字符串。我的习惯是“存储用字符串,计算前统一转数值,显示时再格式化”,中间不混着比。保存函数里把浮点转字符串时固定两位小数,能减少后续解析的歧义。
2.3 手工建一条最小样例链:从班级到成绩的插入顺序
建链顺序不能乱:先有班级,才能挂学生;先有学生,才能挂课程;先有课程,才能挂成绩。原程序用输入"00"逐层返回,实际上是在递归式建链时给一个“结束当前层”的哨兵。下面给三个最小构造函数,参数都是字符串,长度由结构体字段卡住:
CLASS *new_class(const char *no) { CLASS *c = (CLASS *)malloc(sizeof(CLASS)); if (!c) return NULL; memset(c, 0, sizeof(CLASS)); strncpy(c->class_no, no, sizeof(c->class_no) - 1); c->next1 = NULL; c->sthead = NULL; return c; } STUDENT *new_student(const char *no, const char *name, const char *sex, const char *age) { STUDENT *s = (STUDENT *)malloc(sizeof(STUDENT)); if (!s) return NULL; memset(s, 0, sizeof(STUDENT)); strncpy(s->student_no, no, sizeof(s->student_no) - 1); strncpy(s->name, name, sizeof(s->name) - 1); strncpy(s->sex, sex, sizeof(s->sex) - 1); strncpy(s->age, age, sizeof(s->age) - 1); s->next2 = NULL; s->suhead = NULL; return s; } SUBJECT *new_subject(const char *no, const char *name, const char *credit) { SUBJECT *sub = (SUBJECT *)malloc(sizeof(SUBJECT)); if (!sub) return NULL; memset(sub, 0, sizeof(SUBJECT)); strncpy(sub->course_no, no, sizeof(sub->course_no) - 1); strncpy(sub->course_name, name, sizeof(sub->course_name) - 1); strncpy(sub->credit, credit, sizeof(sub->credit) - 1); sub->next3 = NULL; sub->chead = NULL; return sub; }new_class的no传 4 位班号,new_student的name不要超过 16 字节,new_subject的credit传"2.5"这种短字符串。memset先把整块内存清零,避免strncpy没补\0时打印出脏字符。真实建链时还要把这些节点挂到父链上:班级头插或尾插都行,学生必须挂到指定CLASS->sthead,课程挂到指定STUDENT->suhead,成绩挂到指定SUBJECT->chead。头插法写起来短,但输出顺序会反;课程设计里为了和输入顺序一致,我一般用尾插,多一个tail指针换可读性。
3. 工程拆分与编译:main2.c、tongji.c 等模块怎么在TC里组织成 .prj
3.1 模块职责划分与头文件边界
这份工程把菜单和功能拆成多个.c:main2.c放主菜单和全局头指针,in_out.c管新建/打开/保存/退出,files.c做底层文件读写,edit.c管插入/删除/修改/排序,search.c管查找,tongji.c管统计,da_yin.c管模拟打印,help.c管帮助,qita.c放公共小工具。拆模块不是为了好看,而是 TC 单文件大到一定程度后编译和调试都会变脆,血泪经验是“先按菜单功能切,再按数据操作切”,不要一个文件从界面写到文件系统。
| 模块 | 主要职责 | 接口注意点 |
|---|---|---|
main2.c | 主菜单、登录界面、全局头指针 | g_class_head只在这里定义 |
in_out.c | 新建、打开、保存、退出 | 四个文件命名规则统一 |
files.c | fopen/fgets/fprintf封装 | 失败要关掉已打开文件 |
edit.c | 插入、删除、修改、排序 | 删除必须释放子链 |
search.c | 班级、学生、课程查找 | 学生支持子串匹配 |
tongji.c | 班级均分、学生加权、不及格 | 重修成绩取最新或最高 |
da_yin.c | 屏幕预览 + txt 输出 | 路径可配置 |
help.c | 帮助界面 | 不依赖全局链 |
qita.c | 输入清缓冲、字符串工具 | 避免scanf残留 |
头文件jiao_wu.h只放结构体、宏和函数原型,不放变量定义。全局链头在main2.c里定义一次,其他文件用extern CLASS *g_class_head;引用。这样不会出现multiple definition,也不会每个.c都复制一份结构体导致类型不一致。
#ifndef JIAO_WU_H #define JIAO_WU_H typedef struct scores { /* 字段同上,略 */ } SCORE; typedef struct subjects { /* 字段同上,略 */ } SUBJECT; typedef struct students { /* 字段同上,略 */ } STUDENT; typedef struct classes { /* 字段同上,略 */ } CLASS; extern CLASS *g_class_head; CLASS *find_class(CLASS *head, const char *class_no); STUDENT *find_student(CLASS *cls, const char *student_no); SUBJECT *find_subject(STUDENT *stu, const char *course_no); void save_all(CLASS *head, const char *base); void free_class(CLASS *p); #endif#ifndef/#define/#endif是头文件保护,防止重复包含。原型里只写类型,不写具体实现,链接时由各.c提供。
3.2 在TC里建工程并设置输出目录
TC 的 Project 菜单里新建jiaowu.prj,把.c文件逐个 Add item 进去,后缀必须是.prj。工程文件本身是文本清单,每行一个源文件,不要把.h加进去:
main2.c tongji.c da_yin.c in_out.c files.c edit.c help.c search.c qita.c编译时按Ctrl+F9,TC 会按工程清单编译并链接成jiaowu.exe。输出目录建议单独设一个bin或out,不要把.obj、.exe和源码混在一起,后面清理方便。工程名改了,输出文件名也跟着改,运行时找的默认文件路径也要同步改,否则会出现“编译成功但一保存就提示打不开”的假故障。
如果不想每次点菜单,可以用 TC 的命令行编译器写一个批处理。-ml选大内存模型,指针多、链表节点多时比小模型稳;-I和-L指向 TC 的INCLUDE、LIB:
@echo off set TC=C:\TC %TC%\TCC.EXE -ml -I%TC%\INCLUDE -L%TC%\LIB main2.c in_out.c files.c edit.c help.c search.c qita.c tongji.c da_yin.c if errorlevel 1 goto fail echo build ok goto end :fail echo build failed :endTC变量按自己机器上的安装目录改,-ml不是万能药,但它能缓解全局数据段吃紧。if errorlevel 1判断上一条命令返回值,非零就跳到失败分支。这个批处理适合在 DOS 窗口里重复编译,比每次进 IDE 点菜单快。
3.3 用宏隔离图形界面,先让逻辑能跑
原程序大量用gotoxy、textcolor、clrscr这类 TC 专有函数,直接拿到别的编译器上会一片红。常见做法是加一层平台宏,把清屏和光标移动包起来,逻辑代码不直接调conio.h:
#ifndef PLATFORM_H #define PLATFORM_H #ifdef __TURBOC__ #include <conio.h> #define CLEAR_SCREEN() clrscr() #define GOTO_XY(x, y) gotoxy((x), (y)) #else #include <stdio.h> #define CLEAR_SCREEN() printf("\033[2J\033[H") #define GOTO_XY(x, y) printf("\033[%d;%dH", (y), (x)) #endif #endif__TURBOC__是 TC 预定义宏,走conio.h;其他环境用 ANSI 转义做临时清屏和定位,只用于逻辑调试,不追求界面还原。这样链表插入、删除、保存、统计可以先在普通控制台跑通,再回到 TC 调颜色和菜单。参数x是列,y是行,和gotoxy顺序一致,别写反。
4. 核心功能落地:新建、保存、查询、统计、模拟打印的代码骨架
4.1 新建链表与“00”结束输入的递归返回
新建时最怕两层循环写死:班级没输完就进学生,学生没输完就进课程,最后"00"到底返回哪一层全靠全局变量。原程序用"00"做哨兵,在班级层输入"00"结束建链,在学生层输入"00"返回班级层,在课程层输入"00"返回学生层。实现上可以写成三个函数互相调用,每层只负责自己的节点,返回值表示“是否继续”:
int input_students(CLASS *cls) { char no[13], name[17], sex[2], age[3]; while (1) { printf("输入学号(00结束):"); scanf("%12s", no); if (strcmp(no, "00") == 0) return 0; printf("姓名 性别 年龄:"); scanf("%16s %1s %2s", name, sex, age); STUDENT *s = new_student(no, name, sex, age); if (!s) return -1; s->next2 = cls->sthead; cls->sthead = s; } }no最多读 12 个字符,留一个结束符;name最多 16 个;sex只读 1 个;age最多 2 个。strcmp(no, "00") == 0是哨兵判断,返回 0 表示这一层结束。这里用头插法,学生输出顺序会和输入相反;要顺序一致就把s->next2挂到尾部,多维护一个tail。注意scanf("%12s")不读空格,姓名里不要带空格,否则会把后半截当成性别。
4.2 保存成四个txt文件:文件名生成规则与字符串化写入
保存时输入一个基名,程序自动拼四个文件名:base1.txt存班级,base2.txt存学生,base3.txt存课程,base4.txt存成绩。基名不要带.txt,否则会拼成abc.txt1.txt这种打不开的名字。写入时用|分隔字段,既方便程序解析,也方便人眼检查:
void save_all(CLASS *head, const char *base) { char path[64]; FILE *f1, *f2, *f3, *f4; CLASS *c; STUDENT *s; SUBJECT *sub; SCORE *sc; sprintf(path, "%s1.txt", base); f1 = fopen(path, "w"); sprintf(path, "%s2.txt", base); f2 = fopen(path, "w"); sprintf(path, "%s3.txt", base); f3 = fopen(path, "w"); sprintf(path, "%s4.txt", base); f4 = fopen(path, "w"); if (!f1 || !f2 || !f3 || !f4) { if (f1) fclose(f1); if (f2) fclose(f2); if (f3) fclose(f3); if (f4) fclose(f4); return; } for (c = head; c; c = c->next1) { fprintf(f1, "%s\n", c->class_no); for (s = c->sthead; s; s = s->next2) { fprintf(f2, "%s|%s|%s|%s|%s|%s|%s|%s\n", c->class_no, s->student_no, s->name, s->sex, s->age, s->phone, s->com_phone, s->jia_quan); for (sub = s->suhead; sub; sub = sub->next3) { fprintf(f3, "%s|%s|%s|%s|%s\n", c->class_no, s->student_no, sub->course_no, sub->course_name, sub->credit); for (sc = sub->chead; sc; sc = sc->next4) { fprintf(f4, "%s|%s|%s|%s|%s|%s|%s\n", c->class_no, s->student_no, sub->course_no, sc->date, sc->usual_score, sc->exam_score, sc->coll_score); } } } } fclose(f1); fclose(f2); fclose(f3); fclose(f4); }base传e:\abc或abc,不要带后缀。path[64]对课程设计够用,路径太长要加。四个fopen任意一个失败都要把已打开的关掉再返回,否则文件句柄泄漏。保存顺序是班级→学生→课程→成绩,打开时按同样顺序读,先建父链再挂子链。文本里全是字符串,手工改分数时只改值、不改字段长度,否则下一次strtok拆出来的列会错位。
4.3 查找的断点匹配:按学号/姓名子串扫描
原程序的学生查找有个“断点查找”设计:输入"zhang"能把姓名里含zhang的都列出来,输入"012005"能把学号里含这串数字的都列出来。实现就是strstr子串匹配,遍历班级和学生两层:
int contains(const char *text, const char *key) { return strstr(text, key) != NULL; } void search_student(CLASS *head, const char *key) { CLASS *c; STUDENT *s; for (c = head; c; c = c->next1) { for (s = c->sthead; s; s = s->next2) { if (contains(s->student_no, key) || contains(s->name, key)) { printf("%s %s %s\n", c->class_no, s->student_no, s->name); } } } }text是学号或姓名字段,key是用户输入的子串。strstr返回第一次出现的位置,非空就算命中。缺点是不区分大小写,"Zhang"找不到"zhang";TC 里可以用strlwr把两边统一转小写再比。另一个细节是空字符串""会匹配所有节点,输入前要拦掉。
4.4 统计班级均分、学生加权与不及格名单
统计最容易翻车的地方是“重修成绩怎么算”。成绩链里一个课程可能挂多条成绩,number是考试次数。简单平均会把不及格和重修都算进去,班级均分被拉低。我的做法是:先按课程找该学生的最新一次成绩,或者取最高一次,再参与均分。下面给综合分计算和班级均分的骨架:
float score_value(const char *usual, const char *exam) { float u = (float)atof(usual); float e = (float)atof(exam); return u * 0.3f + e * 0.7f; } void class_average(CLASS *head) { CLASS *c; STUDENT *s; SUBJECT *sub; SCORE *sc; float sum; int cnt; for (c = head; c; c = c->next1) { sum = 0.0f; cnt = 0; for (s = c->sthead; s; s = s->next2) { for (sub = s->suhead; sub; sub = sub->next3) { for (sc = sub->chead; sc; sc = sc->next4) { sum += score_value(sc->usual_score, sc->exam_score); cnt++; } } } if (cnt > 0) printf("班级 %s 均分 %.2f\n", c->class_no, sum / cnt); } }score_value里平时 30%、考试 70% 是示例权重,改参数就改这两个系数。class_average遍历到成绩层,cnt记录参与统计的成绩条数,cnt > 0才除,避免空链除零。不及格名单同理,score_value(...) < 60.0f就输出学生和课程,注意去重:同一课程重修后及格,不应再出现在不及格名单里。
模拟打印是把整条链按层级缩进输出到屏幕,同时写一份到student.txt:
void print_all(CLASS *head, const char *path) { FILE *fp = fopen(path, "w"); CLASS *c; STUDENT *s; SUBJECT *sub; SCORE *sc; if (!fp) return; for (c = head; c; c = c->next1) { printf("班级 %s\n", c->class_no); fprintf(fp, "班级 %s\n", c->class_no); for (s = c->sthead; s; s = s->next2) { printf(" %s %s\n", s->student_no, s->name); fprintf(fp, " %s %s\n", s->student_no, s->name); for (sub = s->suhead; sub; sub = sub->next3) { printf(" %s %s\n", sub->course_no, sub->course_name); fprintf(fp, " %s %s\n", sub->course_no, sub->course_name); for (sc = sub->chead; sc; sc = sc->next4) { printf(" %s 平时%s 考试%s\n", sc->date, sc->usual_score, sc->exam_score); fprintf(fp, " %s 平时%s 考试%s\n", sc->date, sc->usual_score, sc->exam_score); } } } } fclose(fp); }path传student.txt或带目录的路径,fopen失败直接返回,不要让fprintf往空指针写。屏幕预览和文件输出用同一套遍历顺序,保证看到的和打印出来的一致。
5. 避坑与排查:TC工程、链表越界、文件路径、图形模式下的五个翻车点
5.1 编译到两千行就崩:模块拆分与内存模型
现象:单文件写到两千行左右,TC 开始报奇怪的编译错误,或者编译通过但运行到建链就崩。原因不是代码逻辑突然变差,而是早期编译器对单个翻译单元的大小、全局数据段和内存模型有上限,全局大数组、重复字符串常量、过长的函数都会把编译器内部表吃满。解决:按功能拆 `.
本文还有配套的精品资源,点击获取