简介这是一份基于DevC开发的C语言单项选择题题库程序面向K12阶段及初学C语言的学生、教师用于完成单选题的录入、浏览、增删改查、章节排序、随机抽题、在线作答与自动判卷等完整练习流程。资源压缩包约54KB共7个文件包括C语言源文件、DevC工程文件、可直接运行的exe程序、CSV题库数据及Makefile编译配置等解压后即可查看代码或运行演示。题库记录涵盖试题编号、题干、四个选项、章节、标准答案与分值并支持从文件读入和保存便于批量维护与扩展统计功能。该资源已有110人学习可帮助初学者快速理解C语言文件操作、结构体应用及菜单交互设计也可作为课程设计或上机练习的参考模板。1. 在 Dev-C 里做完一个选择题题库C 语言的主干语法才算真正合拢C 语言学到指针和结构体之后很多人会卡在“语法到底落在什么场景里”。用 Dev-C 做一个单项选择刷题程序是性价比很高的补课方式你需要用结构体存题、用数组做随机下标、用字符串函数处理选项、用文件接口读外部题库最后再统计正确率。做完它函数、数组、指针、结构体和文件读写就真连成一条线了。这篇文章按实际搭建这类项目的顺序展开先讲内存里怎么组织题目再讲随机抽题与选项乱序然后把题库放到外部文本文件最后加一个错题重练的小扩展。每段都给能在 Dev-C 直接编译验证的代码说清为什么这么写、参数怎么调。正在复习 C 语言基础知识、备考计算机二级 C 语言或想补文件读写短板的读者跟着走一遍会有实打实的收获。2. 用结构体数组承载题库从字段设计到内存布局2.1 为什么用结构体数组而不是七个平行数组存一批选择题最直觉的写法是建几个平行数组题号一个、题干一个、四个选项各一个、答案一个。题目只有五道时没问题一旦扩张到三十题每一次新增题目都要在这五六个数组末尾同步赋值漏掉一个就会出现选项和答案错位查错查半天。把一道题的所有字段收进struct再用一个结构体数组存整批题目增删、交换、随机抽取都以“一道题”为最小单位这是工程里最常见的数据组织方式。结构体数组还有一个隐藏优势函数传参清晰。出题函数只需要接收一个const Question *按结构体字段读取即可对答案时比较Question.answer和用户输入整道题的语义是完整的。选项字段用char options[4][256]而不是四个独立的char option_a[256]这类写法核心理由是“可遍历”。后续输出选项、洗牌、判断答案都是四个元素周期表操作用一个 for 循环就能走完拆成四个独立字段后代码重复量会明显增加改一处漏三处。代价是二维数组每个内层长度必须一致单条选项超过 255 个字符会被截断对选择题来说足够用。2.2 最小可运行的结构体与数组定义#include stdio.h #include string.h #define MAX_QUESTION 100 #define MAX_TEXT 256 typedef struct { int id; /* 题号 */ char question[MAX_TEXT]; /* 题干 */ char options[4][MAX_TEXT]; /* options[0]~options[3] 对应 A~D */ char answer; /* 正确答案字符 A~D */ } Question; Question bank[MAX_QUESTION]; int question_count 0;MAX_QUESTION决定题库上限MAX_TEXT决定题干和选项最大长度。typedef把struct定义成Question后面声明函数参数时可以少写struct前缀代码更简洁。bank是全局数组生命周期贯穿整个程序不需要在函数间传来传去作返回值question_count记录实际题目数初始为 0加载函数执行完后更新。注意一个大坑这段代码里没有维护\0结尾的循环。strncpy虽然能限制拷贝长度但目标数组长度不足时不会自动补\0这会导致后面的strlen、printf读到数组边界之外的脏数据。安全的写法是每次strncpy后手动在目标数组最后一个位置写\0。在 Dev-C 里这种越界不会立刻报错而是表现为“输出题干时末尾多了一串乱码”非常迷惑。2.3 初始化题库的三种写法与选用场景初始化方式代码量可维护性适用场景全局数组花括号直接赋值最少差改题要翻整个数组10 道以内的验证程序init_question函数逐条填充中等好字段顺序固定50 道左右的学生作业从文本文件读取较多最好题目与代码分离上百道题或多人共用题库前两种都属于“题目嵌在代码里”第 4 章会专门讲文件方案这里先给出函数填充的写法void init_question(int index, int id, const char *q, const char *opt_a, const char *opt_b, const char *opt_c, const char *opt_d, char ans) { bank[index].id id; strncpy(bank[index].question, q, MAX_TEXT - 1); bank[index].question[MAX_TEXT - 1] \0; strncpy(bank[index].options[0], opt_a, MAX_TEXT - 1); strncpy(bank[index].options[1], opt_b, MAX_TEXT - 1); strncpy(bank[index].options[2], opt_c, MAX_TEXT - 1); strncpy(bank[index].options[3], opt_d, MAX_TEXT - 1); for (int i 0; i 4; i) { bank[index].options[i][MAX_TEXT - 1] \0; } bank[index].answer ans; }每个选项执行strncpy后再统一补一次\0这是防止“脏尾巴”的常规做法。参数里的const char *是为了避免调用端误改传入字符串C 语言里把字符串常量传给char *虽然不报错但专业代码会主动加const表明只读意图。循环内声明for (int i ...)是 C99 的语法Dev-C 较新版本默认支持如果你的编译器报错把int i提到函数开头声明即可。3. 随机抽题与选项乱序去重靠洗牌不靠运气3.1 rand() 与 srand(time(NULL)) 之间的关系rand()生成的是伪随机整数序列种子相同则序列相同。程序不调用srand()种子默认是 1每次启动跑的随机序列完全一样做刷题程序时第一轮和第二轮抽到的题都一样练习效果大打折扣。标准做法是在main开头只调用一次srand((unsigned)time(NULL))#include stdio.h #include stdlib.h #include time.h int main(void) { srand((unsigned)time(NULL)); /* 后续 rand() 调用不再重新设种子 */ }time(NULL)返回从 1970 年到当前的秒数(unsigned)是常规类型转换保证传给srand的参数类型匹配。这里最常见的错误是把srand写进抽题循环里循环内多次调用time(NULL)时只要两次调用没跨界到下一秒得到的秒数相同srand就会被重复设置成同一个种子之后取到的“随机序号”其实是一样的题目反而批量重复。对于刷题程序整个进程只设一次种子就够。如果碰到两个同学同时启动、种子恰好相同的情况生成的次序也一样这在单机练习场景里可以忽略。3.2 用 Fisher–Yates 洗牌生成抽题顺序从question_count道题里抽m道最容易想到的是“每次生成随机下标重复就重新抽”。但当m逼近question_count越往后越难抽到没被抽过的下标重试次数会指数增长。更稳的方法是把全部下标放进数组整个数组做一次 Fisher–Yates 洗牌然后只取前m个int order[MAX_QUESTION]; for (int i 0; i question_count; i) { order[i] i; } for (int i question_count - 1; i 0; i--) { int j rand() % (i 1); int temp order[i]; order[i] order[j]; order[j] temp; } /* 取 order[0] ~ order[m-1] 作为本次抽题的下标 */order数组里存的是bank数组的下标不是题号id。这样做的好处是即使题库里的id不连续比如删除过几道题洗牌逻辑也不需要额外映射。rand() % (i 1)把随机数限制在0到i的范围内每次从无序部分抽一个位置交换到末尾从尾部反向扫描是 Fisher–Yates 的标准形式。如果题目总数超过MAX_QUESTIONorder数组会越界所以question_count必须严格小于等于MAX_QUESTION。加载函数里加一个if (question_count MAX_QUESTION) { break; }这是几乎每个题库程序都会漏掉的保护。3.3 选项洗牌时正确答案要跟着内容一起动固定选项顺序会有一个副作用练习几次后用户会记住“第三题选 C”而不是真正记住知识点。更完善的刷题程序会把 A、B、C、D 四个选项的位置随机打乱同时保证无论怎么换位置判分逻辑能正确识别哪个是正确答案。打乱选项时不能用“正确答案变成随机的新位置”这种简单映射因为程序一旦重启映射就失效。正确做法是做一层指针映射用一个char *数组指向四个选项字符串洗牌只交换同址指针再用一个整型数组同步记录“每个位置原本在options里的下标”。char *tmp_opts[4]; int idx_map[4] {0, 1, 2, 3}; for (int i 0; i 4; i) { tmp_opts[i] bank[question_index].options[i]; } for (int i 3; i 0; i--) { int j rand() % (i 1); char *temp_ptr tmp_opts[i]; tmp_opts[i] tmp_opts[j]; tmp_opts[j] temp_ptr; int temp_idx idx_map[i]; idx_map[i] idx_map[j]; idx_map[j] temp_idx; }tmp_opts里每个元素是指向原始选项字符串的指针洗牌时只交换char *指针不拷贝整段字符串成本极低。idx_map[i]表示洗牌后第i个位置原本是bank[question_index].options中的第几个元素。判断答案时先用idx_map找到当前位置原本的内容再跟bank[question_index].answer对应的正确内容比对保证答完题就算选项顺序被推翻也能判对。如果这里只用指针数组洗牌、不同步维护idx_map那么“哪个选项是正确答案”在洗牌后就丢失了这也是做选项乱序时最容易被忽略的一层。4. 把题库外置到文本文件文件读写的三个落地细节4.1 一行一题的题库格式题目多了以后把所有题干写进代码里既不好维护也不方便换题。把题库抽成独立文本文件是实际项目里最常用的方案而文本格式的设计直接影响解析代码的复杂程度。常见做法是“一行一题竖线分隔”。竖线|在正常题干里几乎不会出现不需要像逗号、分号那样担心和自然语言冲突这是选它的关键。示例bank.txt1|C语言中下面哪个是合法的变量名|2abc|_name|a-b|int|B 2|以下哪个关键字用来定义结构体|struct|typedef|union|enum|A 3|下列哪个不是C语言的基本数据类型|int|char|float|string|D每行七个字段固定顺序编号、题干、选项A、选项B、选项C、选项D、答案字母。解析时用fgets按行读取再用strtok按|拆字段。这种格式通用性很好换个语言写解析器也简单缺点是没有转义机制题干里一旦出现|就无法区分但选择题题库基本遇不到。4.2 用 fgets 和 strtok 解析题目行int load_bank(const char *file_path) { FILE *fp fopen(file_path, r); if (fp NULL) { return -1; } char line[1024]; while (fgets(line, sizeof(line), fp) ! NULL) { line[strcspn(line, \n)] \0; char *fields[7]; int count 0; char *token strtok(line, |); while (token ! NULL count 7) { fields[count] token; token strtok(NULL, |); } if (count 7) { continue; } Question q; q.id atoi(fields[0]); strncpy(q.question, fields[1], MAX_TEXT - 1); q.question[MAX_TEXT - 1] \0; for (int i 0; i 4; i) { strncpy(q.options[i], fields[2 i], MAX_TEXT - 1); q.options[i][MAX_TEXT - 1] \0; } q.answer fields[6][0]; if (question_count MAX_QUESTION) { bank[question_count] q; } } fclose(fp); return question_count; }fgets会把行尾换行符读进缓冲区所以先用strcspn(line, \n)找到换行符位置并替换成\0。strtok按竖线把一行拆成最多 7 个子串返回的fields[i]是指向行内位置的指针不是独立副本strtok会修改原始line把分隔符临时替换成\0所以如果想同时保留原始行文本必须在调用前先拷贝一份。atoi把字符串转成intfields[6][0]取出答案字符串的第一个字符。如果正确答案格式多打了空格比如Dfields[6][0]取到的就不是D而是空格这是很隐蔽的解析 bug稳妥做法是在解析后对被调answer字段做一次去空格。文件里如果混入空行count 7会让空行被跳过不会污染题库。4.3 编码与换行符的坑先从乱码排查什么在 Windows 上用 Dev-C 加载中文题库最崩溃的不是语法错而是运行后题干显示成乱码。乱码里相当一部分不是代码问题而是编码不统一现象常见原因处理方式题干全是?题库文件是 UTF-8源文件和编译器按 GBK 处理把题库文件转成 ANSI/GBK或统一改成 UTF-8最后一个选项后面多出%之类字符行尾没有正确去掉\rstrcspn去完\n后再检查末尾\r并覆盖编译能过、但题面输出顺序错乱数组越界污染了相邻变量检查strncpy长度和question_count是否超过上限Windows 文本文件的换行是\r\n第 4.2 节只用strcspn去掉了\n最后一个字段可能仍保留\r。这种情况要在切行后判断int len (int)strlen(line); if (len 0 line[len - 1] \r) { line[len - 1] \0; }库文件编码与源文件不一致时Dev-C 的控制台窗口默认按当前系统代码页显示通常就是 GBK如果源文件和题库都统一成 UTF-8反而可能需要额外处理控制台代码页。最简单的项目约定是题库文件和源代码都用 ANSI 编码保存代码里不要用宽字符中文处理就不会出问题。5. 加一个错题本功能只抽做错的题再练一轮数据结构已经搭好之后加错题本其实非常便宜不需要重写出题逻辑只需要增加一个“标记”数组。int wrong_mark[MAX_QUESTION]; int wrong_count 0; void record_wrong(int bank_index) { if (!wrong_mark[bank_index]) { wrong_mark[bank_index] 1; wrong_count; } }用户答错时把bank下标传给record_wrong。为什么传bank_index而不是题号id因为出题时用的是第 3.2 节洗牌后的order数组下标order[k]本身才是bank数组的第几个元素如果按题号id记录遇到id编号不连续的题库就需要额外映射记下标最省事。第二轮“只练错题”的抽题循环只需要在构建order时过滤条件int n 0; for (int i 0; i question_count; i) { if (wrong_mark[i]) { order[n] i; } } if (n 0) { printf(没有错题可以开始新的一轮练习\n); return; } for (int i n - 1; i 0; i--) { int j rand() % (i 1); int temp order[i]; order[i] order[j]; order[j] temp; }这里把洗牌总数从question_count换成n后面的输出和判分逻辑完全复用不需要动原来的代码。正确率统计时也别忘整数计算的坑correct / total两个 int 相除结果还是 int0 到 0 之间的真值直接丢成 0。先转float再计算输出用%.2f%%格式化成百分比。题库文件写好之后还有一个值得固定下来的验证技巧写一段不到 20 行的独立校验函数把每题的id、题干前 20 个字符和answer逐行打印出来单独跑一次人工扫一遍。别直接跳进主程序跑随机逻辑否则出题的随机顺序会干扰你对文件正确性的判断。这个小函数放进#ifdef DEBUG里想用时打开宏发布时关掉干净利落。本文还有配套的精品资源点击获取