
简介面向C语言课程设计学习者的教务信息管理系统实验报告以高校《C语言程序设计》课程设计报告形式呈现围绕模拟辅导员管理年级成绩表格的场景展开适合需要完成课程设计、理解综合程序设计流程的学生参考。压缩包内为1个doc文档约361KB集中收录实验报告正文包含源代码、流程图及较详细的设计说明。正文以十字交叉链表组织班级、学生、课程与成绩四重链讲解结构体定义、模块化编译、文件存取、排序查找、统计班级与课程均分、不及格信息处理以及模拟打印等实现思路并附菜单操作与数据输入规则说明。已有1121人学习可帮助读者梳理从需求分析、数据结构设计到功能模块划分和报告撰写的完整脉络也便于对照源码与流程图理解链表嵌套、工程文件组织及TC环境下的编译运行方式。1. 一份 2006 年的课设四重十字交叉链表撑起的教务信息管理系统很多人对 C 语言课程设计的印象是“一个结构体数组加六个 switch 菜单”。眼前这份教务信息管理系统走了另一条路班级、学生、课程、成绩各成一条链再按从属关系逐层挂接形成四重十字交叉链表九个.c文件按功能域拆开、用.prj工程串起来编译运行环境是 Turbo C 2.0。全中文菜单、方向键操作、模拟打印、加权排名、按学号或姓名断点查找全部在这套结构里跑通。它适合两类人正在做课设、需要一个能直接跑通增删改查、排序、统计的完整骨架以及写惯了现代框架、想回头看看在没有 STL、没有动态数组、只有 64KB 数据段的年代链表是怎么被“逼”出工程化的。下面按建模、编译、交互、排查、验证的顺序拆开讲。2. 四重十字交叉链表怎么建四个结构体、三个从属字段和一次排序2.1 四个结构体与三级从属关系fall1、fall2、fall3 到底存了什么这套数据模型的关键不在“四层”而在每一层都用冗余的从属字段记录自己属于谁。班级链只有班号和一个学生链头指针学生链里挂课程链头指针同时用fall1[2]记下自己属于哪个班课程链里挂成绩链头指针用fall2[3]记下属于哪个班、哪个学生成绩链用fall3[4]记下属于哪个班、哪个学生、哪门课。冗余的好处是给定任意一个成绩节点不需要回溯父节点就能定位它在整张网里的坐标删除、修改、打印都不用二次遍历。/* 班级链整张网的入口只有一个头指针在全局挂着 */ typedef struct classes { char class_no[5]; /* 班号如 05104 位 \0 */ struct classes *next1; /* 下一个班级 */ struct students *sthead; /* 本班学生链头指针 */ } CLASS; /* 学生链基本信息 课程链入口 班级从属 */ struct students { char fall1[2]; /* fall1[0] 所属班级序号字符 1 起 */ char student_no[13]; /* 学号12 位 \0 */ char name[17]; /* 姓名最长 16 字节够存一个中文名或拼音 */ char sex[2]; /* 0 男 / 1 女 */ char age[3]; /* 年龄2 位 \0 */ char phone[12]; /* 寝室电话 */ char com_phone[12]; /* 手机11 位 \0 */ char jia_quan[7]; /* 加权分买菜式写法 85.32 */ char all_credit[4]; /* 已得总学分 */ struct students *next2; /* 同班下一个学生 */ struct subjects *suhead; /* 本人课程链头指针 */ }; /* 课程链挂在学生下面课程信息 成绩链入口 */ struct subjects { char fall2[3]; /* fall2[0]班级序号 fall2[1]学生序号 */ char course_no[11]; /* 课程号10 位 \0 */ char course_name[17]; /* 课程名 */ char credit[4]; /* 学分如 2.5 */ struct subjects *next3; /* 下一个学生节点 */ struct scores *chead; /* 本门课的成绩链头指针 */ }; /* 成绩链挂在课程下面一次考试一条 */ struct scores { char fall3[4]; /* [0]班级 [1]学生 [2]课程三个字符定位 */ char number[2]; /* 第几次考试1 起 —— 只能到 9这就是边界 */ char exam_score[4]; /* 卷面分 */ char usual_score[4]; /* 平时分 */ char coll_score[4]; /* 综合分录入时算好写进来 */ char date[11]; /* 考试学期如 2005-20069 字符 \0 */ struct scores *next4; /* 同门课的下一次考试重修就挂在这条链上 */ };节点字段长度是按照实际数据长度确定的学号 12 位对应char[13]手机 11 位对应char[12]姓名留 16 字节。这种做法在小内存环境下是必要的但每个字段的长度都是不能超限多输入一位就会覆盖相邻字段。number[2]只能存一位数字同一门课考到第 10 次就会溢出jia_quan[7]存不下 “100.00” 这种三位整数分。如果要改第一件事就是把这些字段放宽到 8 或 16 字节代价是每个学生节点多占十几字节后面 2.5 节会计算这笔账。2.2 全字段定长字符串能用记事本修改的数据长什么样这套设计里没有一个int或float字段所有数值都以字符串形式保存写文件时直接fprintf输出。这意味着保存出来的.txt打开后能直接看懂成绩在文件里就是53不是二进制读写后出现的乱码。修改数据只要保证字段长度不变、分隔符位置不变程序再次打开文件就能正确读回来调试时直接用记事本改两行数据看程序反应省去了反复重新录入。代价是所有比较和计算都要现场转换。排序按加权分比得atof统计均分要累加得atof判断不及格得atoi。这里有个隐蔽的坑atof对空串返回 0对abc也返回 0手改文件时不小心多敲一个空格程序不会报错只会安静地把这个学生算成 0 分。我一般会在读文件的入口加一道校验字段非空且atof结果在合理区间内才接受否则打印行号并跳过。/* 读一个学生节点时的最小校验空字段和非法数字都要挡在外面 */ static int valid_score(const char *s) { double v; if (s NULL || s[0] \0) return 0; v atof(s); if (v 0.0 || v 100.0) return 0; return 1; } /* 调用侧任一分项非法就把整条成绩链丢弃并记一行日志 */ if (!valid_score(sc-exam_score) || !valid_score(sc-usual_score)) { fprintf(log_fp, line %d: score out of range, skipped\n, line_no); continue; }valid_score只做区间判断不判断格式字符串里是否混入了字母atof(5a3)会返回 5程序会认为是合法分。要严格得自己逐字符检查isdigit或小数点这对课设来说通常够用但交给实际使用之前必须补上否则用户在键盘上误按一下数据就会被静默丢弃。2.3 建链顺序与 “00” 哨兵四级联动的退出规则建链的顺序是固定的先建班级在班级里建学生在学生里建课程在课程里建成绩。每层用00作为退出信号返回上一级。这套规则的好处是不用记“现在在第几层”只要看到00就return天然支持任意深度回退坏处是学号、课程号里只要真出现00开头比如以 00 开头的两位编号就会被当成退出信号吞掉。/* 建课程链输入 00 结束本学生的课程录入返回建学生链的循环 */ struct subjects *build_subjects(const char *class_ord, const char *stu_ord) { struct subjects *head NULL, *tail NULL, *node; char buf[32]; while (1) { printf(课程号00 结束); read_line(buf, sizeof buf); if (strcmp(buf, 00) 0) break; /* 哨兵退回学生层 */ node (struct subjects *)malloc(sizeof(struct subjects)); if (node NULL) { printf(内存不足\n); break; } memset(node, 0, sizeof *node); node-fall2[0] class_ord[0]; node-fall2[1] stu_ord[0]; strncpy(node-course_no, buf, sizeof node-course_no - 1); printf(课程名); read_line(node-course_name, sizeof node-course_name); printf(学分); read_line(node-credit, sizeof node-credit); node-chead build_scores(node-fall3, node-fall2, node-course_no); node-next3 NULL; if (head NULL) head node; else tail-next3 node; tail node; } return head; }strncpy的第三个参数写成sizeof - 1是为了给\0留位置strncpy在源串长度不够时不会自动补结束符后面printf(%s)就会一直打印到相邻字段这是这套代码里最常见的翻车方式第 5 章还会遇到。哨兵判断放在strncpy之前是为了避免把00写进节点后又继续往下走。memset(node, 0, ...)不能省malloc回来的内存是脏的字段没清零时后面所有strcmp都可能读到垃圾。成绩链的创建多一步重修处理首次录入如果综合分不及格程序会问要不要录重修要就在chead后面再挂一个节点number递增直到及格或用户主动停。这就是next4存在的意义同一门课可以有多条成绩记录统计均分时按需取“最高一次”还是“全部计入”取决于业务口径课设里通常是全部计入。2.4 班级内排序交换数据域而不是交换节点排序的需求是“在班级内部按学号或加权分排”。链表的物理排序要改指针涉及前驱、后继、头指针三种情况写错一个就是断链或死循环。更稳妥的做法是只交换数据域节点位置不动/* 只交换数据字段next2 保持在原位suhead 必须跟着走 */ static void swap_stu_data(struct students *a, struct students *b) { struct students t *a; /* 先整份留底后面逐字段搬 */ struct subjects *tp; memcpy(a-student_no, b-student_no, sizeof a-student_no); memcpy(a-name, b-name, sizeof a-name); memcpy(a-sex, b-sex, sizeof a-sex); memcpy(a-age, b-age, sizeof a-age); memcpy(a-phone, b-phone, sizeof a-phone); memcpy(a-com_phone, b-com_phone, sizeof a-com_phone); memcpy(a-jia_quan, b-jia_quan, sizeof a-jia_quan); memcpy(a-all_credit, b-all_credit, sizeof a-all_credit); memcpy(a-fall1, b-fall1, sizeof a-fall1); memcpy(b-student_no, t.student_no, sizeof b-student_no); memcpy(b-name, t.name, sizeof b-name); memcpy(b-sex, t.sex, sizeof b-sex); memcpy(b-age, t.age, sizeof b-age); memcpy(b-phone, t.phone, sizeof b-phone); memcpy(b-com_phone, t.com_phone, sizeof b-com_phone); memcpy(b-jia_quan, t.jia_quan, sizeof b-jia_quan); memcpy(b-all_credit, t.all_credit, sizeof b-all_credit); memcpy(b-fall1, t.fall1, sizeof b-fall1); /* 课程链属于学生本人必须一起换否则名字和成绩对不上 */ tp a-suhead; a-suhead b-suhead; b-suhead tp; } /* 选择排序班级几十人O(n^2) 完全够用 */ static void sort_by_weight(struct students *head) { struct students *i, *j; for (i head; i ! NULL; i i-next2) for (j i-next2; j ! NULL; j j-next2) if (atof(j-jia_quan) atof(i-jia_quan)) swap_stu_data(i, j); }这段代码最容易漏的就是最后那三行suhead交换。漏了之后程序不崩排序结果看着也对但一旦去统计学生均分就会发现“张三的名字配着李四的课程”统计出来的均分和人对不上。这类错误没有报错信息只能靠比对原始数据反查我一般会在排序后立刻调一次打印函数人工看头两行是否对得上当作一道检查。按学号排序把比较换成strcmp(j-student_no, i-student_no) 0即可但学号是字符串0120050137和120050137长度不同时strcmp结果和数值比较不一致这点在混入学号位数不同的数据时要特别注意。2.5 内存上限small 模式下的 64KB 与节点数把节点大小算一遍学生节点约 2131723121274 72 字节加两个近指针 4 字节约 76课程节点约 31117422 39 字节成绩节点约 42444112 31 字节。按 500 名学生、每人 2 门课、每门课 1.5 次考试计光数据就约 125KB。Turbo C 2.0 的默认编译模式是 small数据段 64KB所有near指针只能寻址这一段的当前段malloc也从这一段里分配空间。125KB 必然请求失败典型现象是录入到第二十几个学生时程序突然返回主菜单或者直接malloc返回 NULL 后没有判断接着-解引用就崩溃。解法不是优化代码而是改编译模式把Options → Compiler → Model改成 Large同时把Far数据指针打开。Large 模式下数据指针是 far堆挪到 far 空间能撑到几百 KB代价是每次寻址慢一点对课设完全无感。提示改模式之后记得把工程里所有.obj删掉重新编混用不同模式的目标文件链接时会报一堆Segment ... exceeds 64K看着像代码问题其实只是残留。3. 把九个 .c 编成一个工程模块拆分、全局头指针和 jiaowu.prj3.1 九个源文件按功能域切开每个文件只对外暴露一组函数原始工程拆成main2.c、files.c、in_out.c、edit.c、tongji.c、da_yin.c、search.c、help.c、qita.c。拆分的依据不是数据类型而是用户能看到的那几个菜单项文件相关的归files.c编辑相关归edit.c统计归tongji.c打印归da_yin.c查找归search.c帮助归help.c杂项归qita.c输入输出这类被到处调用的底层函数放in_out.cmain2.c只留main()和全局状态。为什么一定要拆原文的经历很典型单文件写到 2000 行 TC2.0 编不动改 Win-TC 撑到 4000 行再换 TC3.0 到 5000 行又不行。这不是玄学TC 系列的编译器对单个编译单元有上限代码段和数据段都受 64KB 约束函数一多跳转表、字符串常量表都会爆。拆成九个文件后每个.c单独编译成.obj各自不超过 64KB链接器再把它们拼起来问题自然消失。拆的时候有个原则edit.c里修改完链表数据后要立即把全局的“未保存”标记置位这个动作不能漏否则退出时不会提示保存。各模块之间尽量不互相调用共享的东西一律走全局变量和in_out.c里的公共函数比如read_line、print_one_student、ask_yes_no。3.2 全局链表头的定义与 extern一个 main2.c 说了算九个文件都要访问班级链头只能有一处定义、其余处声明。C 的规则是定义分配内存只能出现一次声明可以出现多次。工程里最稳的写法是在main2.c里定义其他文件通过一个公共头文件data.h拿到extern。/* data.h —— 只放类型声明和全局 extern不放函数体 */ #ifndef DATA_H #define DATA_H #include stdio.h #include stdlib.h #include string.h #include alloc.h /* TC 下 farmalloc 在这里 */ typedef struct classes CLASS; typedef struct students STU; typedef struct subjects SUB; typedef struct scores SCO; /* 结构体定义写在这里顺序不能乱先 CLASS 才能声明 sthead */ struct classes { char class_no[5]; struct classes *next1; struct students *sthead; }; /* students / subjects / scores 的定义同 2.1 节此处省略 */ extern CLASS *cls_head; /* 全局班级链头定义在 main2.c */ extern char cur_base[64]; /* 当前文件的基名不带后缀 */ extern int dirty; /* 0 无改动1 有未保存修改 */ extern int file_open; /* 0 未建立/打开任何数据 */ int read_line(char *buf, int cap); int ask_yes_no(const char *prompt); void mark_dirty(void); #endif/* main2.c —— 全局量在这里落地全程序只此一份 */ #include data.h CLASS *cls_head NULL; char cur_base[64] ; int dirty 0; int file_open 0; void mark_dirty(void) { dirty 1; } int main(void) { int rc; init_screen(); /* 进入图形/文本模式画欢迎页 */ rc menu_loop(); /* 主事件循环返回 -1 表示退出 */ if (dirty ask_yes_no(当前数据未保存保存后退出)) file_save(cur_base); restore_screen(); return rc; }data.h里把结构体定义和extern放一起是为了避免“每个.c各写一份结构体定义某天改了字段忘了同步”。但要注意TC2.0 对typedef struct xxx CLASS;这种前置声明支持不全稳妥写法是把typedef写在结构体定义之后或者干脆用struct classes *全称引用不用CLASS简写。这是老编译器和现代编译器差异最大的地方之一。mark_dirty()包一层函数而不是让各模块直接写dirty 1是为了以后想加日志或改成计数时只改一处。小工程也会因此受益因为漏埋脏标记是这类程序最常见的问题改完学生信息直接关窗口数据全丢用户只会觉得“这软件吃了我的数据”。3.3 jiaowu.prj 的写法与 CtrlF9 的构建链路Turbo C 的工程文件其实就是一个纯文本清单一行一个源文件顺序就是编译和链接顺序main2.c files.c in_out.c edit.c tongji.c da_yin.c search.c help.c qita.c把上面九行存成jiaowu.prj后缀必须是.prj在 TC 里走Project → Open Project打开它然后按CtrlF9就是 Make Run。TC 的 Make 是增量式的哪个.c的修改时间比.obj新就重编哪个其余直接复用.obj。这也解释了为什么改完data.h后必须手动Project → Remove或者删掉所有.objTC2.0 不追踪头文件依赖改了头文件它不会自动重编。不方便进 IDE 的场合可以直接命令行等价# -ml Large 模式与 IDE 里 Options→Compiler→Model 选 Large 等价 tcc -ml main2.c files.c in_out.c edit.c tongji.c da_yin.c search.c help.c qita.c-ml是必须的沿用默认 small 模式会在运行期挂掉而且挂的位置随机看着像代码 bug。命令行编出来的是main2.exe想叫jiaowu.exe得改main()所在的文件名或者在 TC 的Options → Application → Output里指定。还有一条工程文件里的路径是相对当前工程目录的如果把.prj放在D:\WORK而源文件在D:\WORK\SRC那清单里得写成SRC\main2.c。路径写错时 TC 的报错是Unable to open include file或者干脆找不到源文件跟头文件缺失的报错长得很像别一上来就怀疑data.h。3.4 从 TC2.0 一路搬到 TC3.0编译容量撞墙后的迁移路径原文那条迁移曲线值得复述一遍因为它就是一份现实中的容量测试报告2000 行时 TC2.0 编不动换 Win-TC4000 行时 Win-TC 编不动换 TC3.05000 行时 TC3.0 也编不动最后靠模块化解决。这里每一道坎都不是语法问题而是编译单元规模问题。迁移时有三件事必须一起做缺一件就白折腾第一注释风格。TC2.0 只认/* *///会被当成除号报出一堆莫名其妙的语法错误。从现代编辑器粘贴代码进来时先把//全部替换掉。第二函数原型。TC2.0 对 ANSI 原型的支持很有限像int foo(void)这种写法在较老版本里要退化成int foo()参数类型写在函数定义处。工程里统一用 KR 风格声明最省事。第三库函数差异。alloc.h提供farmalloc/farfreeconio.h提供getch/textcolor/gotoxygraphics.h提供图形模式初始化这些在 Win-TC 上基本兼容在 TC3.0 上更完整但要注意BGI驱动路径。程序启动时如果initgraph找不到EGAVGA.BGI屏幕会黑一下然后退回文本模式看着像“程序没反应”其实只是图形驱动路径没配。注意换编译器之后一定要把旧的.obj和.exe全删。不同版本编出的目标文件混链报错信息会指向完全无关的行号白花半天。4. 没有鼠标的菜单系统方向键扫描码、三级事件循环和脏标记4.1 方向键扫描码解析0x00 与 0xE0 两种前缀都要接DOS 下按方向键getch()不会一次返回一个字符而是返回两个字节第一个是扩展前缀第二个才是真正的扫描码。前缀在不同环境下不一样传统 DOS/BIOS 是0x00部分 Win-TC 版本和某些兼容层给的是0xE0。只判断0x00程序在 Win-TC 里方向键就完全没反应用户以为键盘坏了。/* 统一键值普通键直接返回 ASCII扩展键映射到 0x100 以上避免冲突 */ #define K_UP 0x148 #define K_DOWN 0x150 #define K_LEFT 0x14B #define K_RIGHT 0x14D #define K_PGUP 0x149 #define K_PGDN 0x151 #define K_HOME 0x147 #define K_END 0x14F int get_key(void) { int c getch(); if (c 0x00 || c 0xE0) { /* 扩展键前缀两种都要接 */ c getch(); /* 第二字节是扫描码 */ switch (c) { case 72: return K_UP; /* 0x48 */ case 80: return K_DOWN; /* 0x50 */ case 75: return K_LEFT; /* 0x4B */ case 77: return K_RIGHT; /* 0x4D */ case 73: return K_PGUP; /* 0x49 */ case 81: return K_PGDN; /* 0x51 */ case 71: return K_HOME; /* 0x47 */ case 79: return K_END; /* 0x4F */ } return 0; /* 其它扩展键吞掉不污染菜单 */ } return c; /* 普通键回车 13、Esc 27、退格 8 */ }返回统一键值的好处是菜单层完全不用关心扫描码只认K_UP这类常量。case 72这些数字是扫描码的十进制写法跟0x48是一回事写成十进制纯粹是因为当年查的资料表就这么列。return 0那行不能省某些扩展键比如Alt字母也会走前缀分支不吞掉就会变成菜单里的“非法输入”触发一次多余的重绘。4.2 三级菜单怎么共用一个事件循环菜单是三级结构主菜单文件、编辑、打印、统计、查找、帮助、退出→ 子菜单比如编辑下分插入、删除、修改、排序→ 具体操作。如果把每级菜单都写成独立的while循环Esc返回上层的逻辑就得在每层重复一遍而且“左右键跳到别的菜单”这种跨级移动几乎没法实现。实际有效的写法是一个循环加一个深度变量/* 深度 1 主菜单2 子菜单3 操作执行返回 -1 表示退出程序 */ int menu_loop(void) { int depth 1, top 0, sub 0, key; while (1) { draw_menu(depth, top, sub); /* 只负责画不改状态 */ key get_key(); if (key 27) { /* Esc逐级回退 */ if (depth 1) { if (confirm_exit()) return -1; } else depth--; continue; } if (key 13) { /* 回车进入下一级或执行 */ if (depth 1) { depth 2; sub 0; } else if (depth 2) { depth 3; } else { run_action(top, sub); depth 2; } continue; } if (key K_UP || key K_DOWN) { if (depth 1) top step(top, key K_UP ? -1 : 1, TOP_N); else sub step(sub, key K_UP ? -1 : 1, sub_count(top)); continue; } if (key K_LEFT || key K_RIGHT) { if (depth 2) { /* 左右切换主菜单子菜单清零 */ top step(top, key K_LEFT ? -1 : 1, TOP_N); sub 0; } continue; } } }step()负责环形取模保证上下键到底后回到第一项。draw_menu纯绘制、run_action纯执行状态全部留在menu_loop的局部变量里这样加一个菜单项只需要改TOP_N和对应的字符串表不用动控制流。depth 3时执行完把深度退回 2是为了让用户执行完一个操作后还停在同一级子菜单可以连续做第二次插入这种小体验决定了课设演示时顺不顺手。4.3 链表输出的分屏查看屏幕缓冲与坐标裁剪一个班几十号人屏幕上放不下所以输出链表信息时用方向键上下左右平移视图。实现上不是真的滚动终端而是维护一个视口左上角坐标(vx, vy)绘制时只画落在视口内的行每行的第vx个字符之后才输出。/* 视口vx/vy 是视口左上角在逻辑画布中的坐标 */ static int vx 0, vy 0; void draw_students(CLASS *cls) { STU *p cls-sthead; int row 0, col; while (p ! NULL) { if (row vy row vy VIEW_H) { /* 只画视口内的行 */ gotoxy(1, row - vy 2); col printf(%-4s%-13s%-17s%s, p-fall1, p-student_no, p-name, p-sex); (void)col; } p p-next2; row; } /* 底部提示当前是第几行到第几行 */ gotoxy(1, VIEW_H 3); printf(第 %d - %d 行 / 共 %d 行方向键移动Esc 返回, vy 1, vy VIEW_H row ? row : vy VIEW_H, row); }gotoxy(1, row - vy 2)里的2是给标题行和边框留位置这类硬编码坐标在换分辨率时全要重算。VIEW_H是常量等于屏幕行数减去固定头尾。移动视口时的边界处理极容易翻车vy允许的最大值是row - VIEW_H如果row VIEW_H比如一个班只有三个学生这个值就是负数此时必须把vy固定为 0否则画面会整屏空白用户以为数据丢了。本文还有配套的精品资源点击获取