简介这是用C实现的背单词小程序面向希望掌握控制台项目开发流程的C初学者也适合需要本地单词复习工具的英语学习者。程序围绕单词添加、背诵记忆、复习、生词本、错题本等模块展开覆盖背词软件的基础使用逻辑帮助使用者理解如何用文件持久化保存单词数据并实现简单的统计与分类功能。资源包为zip压缩格式内含1个cpp源文件整体约2KB代码量小、逻辑集中便于逐行阅读和二次修改。借助这份代码可以学习C标准输入输出、字符串处理、数组或链表操作、文件读写等常见知识点同时可将其扩展为图形界面版本或加入词库导入导出功能。目前已有726人学习下载适合作为课程设计、期末作业或自学C的配套练习项目。1. 未命名没关系先确定「背单词小程序」的 C 形态做「背单词小程序」之前先别被名字带偏标题里的「小程序」不是微信生态里的那套东西而是一个用 C 写的小软件跑在命令行窗口里。没有账号、没有云同步词库和复习记录都放在本地文件里这种形态特别适合刚结束 C 语法学习、想用标准库做真东西的人。它比图书管理系统多一点算法记忆调度比小游戏少一点图形依赖核心难点就是「词库怎么读、间隔怎么算、成绩怎么存」三条链路。下面按可编译的路径展开从数据层开始一步步把命令行版的背单词工具跑起来。2. 背单词小程序的数据层词库字段与复习记录编写一个 C 软件最容易出问题的不是 sort 或 vector而是「数据半路丢了」。背单词小程序的核心资产是词库和背诵记录它们需要长期维护、频繁读写因此先把这两类文件的设计讲清楚后面所有逻辑才能稳定跑在它们之上。2.1 词库不用数据库CSV 字段怎么设计词库是唯一要人工长期维护的文件选择 CSV 作为唯一数据源有几个现实理由能用 Excel、WPS 直接编辑能在 git 里看到单词变动差异也能被 Python 脚本批量处理。背单词词库通常从四列起步字段含义示例备注word单词abandon主键不允许重复phonetic音标/əˈbændən/可为空meaning释义v. 放弃抛弃中文释义放最后example例句He abandoned his car.内容较长group分组CET4可选用于分册设计时注意一点例句里经常出现英文逗号和双引号这意味着不能简单地用getline后按逗号split。C 标准库没有现成的split函数需要写一个能识别引号的状态解析器常见做法是逐个字符扫描维护一个inQuotes标志。2.2 读取词库时的 UTF-8 BOM 与 GBK 编码陷阱在 Windows 上Excel 另存为 CSV 时默认编码是 GBK/ANSI而现代 C 源码和大多数编辑器默认 UTF-8两者碰撞后控制台会出现乱码。我一般用两个方案之一要么固定词库存 UTF-8读取时检测并剥离 BOM要么把词库转成 GBK保持传统终端编码。方案一更通用尤其当词库还要交给 git 或跨平台使用时。下面这段代码展示了如何用std::ifstream按字节方式读取并跳过 UTF-8 BOM#include fstream #include string #include vector // 手动解析一行 CSV支持双引号包裹的字段 std::vectorstd::string parseCSVLine(const std::string line) { std::vectorstd::string fields; std::string current; bool inQuotes false; for (size_t i 0; i line.size(); i) { char c line[i]; if (inQuotes) { if (c ) { // 连续两个引号是转义引号 if (i 1 line.size() line[i 1] ) { current.push_back(); i; } else { inQuotes false; } } else { current.push_back(c); } } else { if (c ) { inQuotes true; } else if (c ,) { fields.push_back(current); current.clear(); } else { current.push_back(c); } } } fields.push_back(current); // 最后一段别忘了 return fields; } // 读取首行并去掉 UTF-8 BOM std::string stripBOM(const std::string line) { const unsigned char b0 0xEF, b1 0xBB, b2 0xBF; if (line.size() 3 static_castunsigned char(line[0]) b0 static_castunsigned char(line[1]) b1 static_castunsigned char(line[2]) b2) { return line.substr(3); } return line; }逻辑说明parseCSVLine里inQuotes控制逗号是否被当作分隔符遇到成对引号则把它还原为普通引号。stripBOM把 EF BB BF 三个字节从首行剥离否则词库第一列的单词会带上不可见字符后续查重、索引时全部错位。读取时建议用std::ios::binary打开文件避免文本模式下\r\n被提前转换导致解析混乱。Windows 下如果控制台输出 UTF-8 中文仍然乱码可以在main开头调用SetConsoleOutputCP(CP_UTF8)这是 Windows 特有的 API其他平台不需要。2.3 复习记录保存零依赖 KV 与原子写入背单词的进度记录与词库不同词库可以一次性读入背诵记录则每次背完都要写入。常见做法是使用 JSON但 C 标准库没有 JSON 解析器为避免额外引入第三方库可以用简单的 KV 文本格式。每个单词一行abandon last2026-05-20 interval5 ease2.50 wrong0字段含义如下字段含义类型last上次背诵日期YYYY-MM-DDinterval下次复习间隔天intease熟练度系数doublewrong累计错误次数int写入时不要直接覆写原文件。如果程序在写入过程中崩溃或断电原文件会只剩半行。我一般会先写一个临时文件progress.tmp全部写好后再用std::filesystem::rename替换旧文件。该操作在 Windows 和 Linux 上对同一目录内的文件基本是原子的可以防止数据损坏。3. 背单词小程序的调度核心简化 SM-2 间隔重复背单词不靠「今天有劲就多背」的随机性而是靠记忆调度算法。社区里免费、稳定的算法模型是 SM-2 间隔重复它只要求三个数字上次复习时间、间隔天数、熟练度系数。用这个模型做出来的小程序词库规模再大也不会手忙脚乱。3.1 间隔不靠感觉三个状态字段决定复习时机SM-2 的核心思路是让单词在即将遗忘的时间点再次出现。每次用户评分后系统根据评分和当前间隔计算下一次复习时间。评分规则常用五档制评分含义调度结果5立即想起interval interval * ease4稍作反应interval interval * easeease 微增3犹豫后答对interval 不变2答错但记住了interval 重置为 11完全忘记interval 1wrong 1这里的关键是ease系数每个单词独立维护答得越好系数越高间隔增长越快答错后系数回落间隔缩短。SM-2 原版还会限制ease不低于 1.3避免系数降到负数后间隔越来越短。3.2 到期判定与成绩更新代码实现在 C 中实现调度器核心是两张表一张是单词本身一张是复习状态。可以定义一个结构体把两者合并#include chrono #include string #include vector struct Card { std::string word; std::vectorstd::string meanings; std::chrono::system_clock::time_point lastReview; int intervalDays 1; double ease 2.5; int wrongCount 0; }; // 判断当前时间是否到期 bool isDue(const Card card, std::chrono::system_clock::time_point now) { auto elapsed std::chrono::duration_caststd::chrono::hours( now - card.lastReview).count(); return elapsed card.intervalDays * 24LL; } // 更新卡片状态, score 取值为 1-5 void updateCard(Card card, int score) { if (score 4) { card.intervalDays static_castint(card.intervalDays * card.ease); card.ease 0.1; // 答得好加速增长 } else if (score 3) { // 间隔保持系数不动 } else { card.intervalDays 1; // 答错就回到最初 card.ease std::max(1.3, card.ease - 0.2); card.wrongCount (score 2) ? 1 : 0; } card.lastReview std::chrono::system_clock::now(); }逻辑说明isDue把时间差统一转为小时再比较避免duration_castdays在不同编译器下的行为差异。updateCard里三个分支对应上一小节的五档评分同时把ease钳制在 1.3 以上。注意这里用的是std::chrono::system_clock::now()返回的系统时间不要与内存中的计时器混淆。C17 下建议用duration_casthours而不是hours days混合运算因为std::chrono的days类型在 C20 才完整支持老工具链会导致头文件编译错误。3.3 复习队列与三个必调参数当词库积累到上千词后「先背哪个」成为一个队列问题。常见做法是第一优先取isDue返回 true 的旧词第二优先取从未背过的新词最后才是未到期的词。这样做既能保证旧单词不遗忘又能逐步引入新内容避免某天到期数量爆炸。void buildQueue(std::vectorCard cards, std::vectorCard* dueQueue, std::vectorCard* newQueue, int maxNewCount) { for (auto card : cards) { if (card.lastReview std::chrono::system_clock::time_point{}) { if (newQueue.size() static_castsize_t(maxNewCount)) { newQueue.push_back(card); } } else if (isDue(card, std::chrono::system_clock::now())) { dueQueue.push_back(card); } } }三个必调参数分别是参数推荐值说明初始间隔1 天首轮背完立即复习间隔太短没有意义最小 ease1.3低于此值强制重置防止系数衰减失控每轮新词上限10-20控制每日新词量避免到期队列无限膨胀buildQueue中lastReview为零点表示从未背过这个判断依赖time_point默认构造的初始值千万不能用浮点时间戳 0 混用。如果需要调试间隔逻辑可以临时把初始间隔改成 0.04 天约 1 小时验证通过后再调回 1 天。4. 背单词小程序的 C CLI 交互参数与输入输出流命令行工具好不好用一半看参数设计一半看输入输出流的处理。背单词小程序要在终端里连续显示新词、等待用户评分、判断对错这个循环由三个部分组成参数解析、会话循环、流状态处理。4.1 用参数决定这轮背什么简易参数解析不需要引入 getopt 库手动解析即可同时在 Windows 和 Linux 下表现一致。定义一个Options结构体支持的参数如下参数默认值作用-n / --new10本轮新词数量--no-priorityfalse不优先复习到期词--filewords.csv指定词库路径-h / --help-打印帮助信息struct Options { int newCount 10; bool reviewFirst true; std::string wordFile words.csv; }; Options parseArgs(int argc, char** argv) { Options opt; for (int i 1; i argc; i) { std::string arg argv[i]; if (arg -n i 1 argc) { opt.newCount std::stoi(argv[i]); } else if (arg --no-priority) { opt.reviewFirst false; } else if (arg --file i 1 argc) { opt.wordFile argv[i]; } } return opt; }std::stoi遇到非数字参数会抛出std::invalid_argument解析参数时需要把这一行包进try-catch输出错误信息后返回默认值。参数解析程序写在单独的单元方便之后扩展--export、--reset等子命令。4.2 背词循环显示、评分、写回记录核心循环逻辑如下先取出一个待背单词显示单词和音标让用户输入评分然后调用updateCard更新状态最后把整批数据统一保存。注意这里不能用std::cin score后再读getline否则残留的换行符会直接吞掉下一次输入。void runSession(std::vectorCard cards, const Options opt) { for (size_t i 0; i static_castsize_t(opt.newCount); i) { if (cards.empty()) break; Card card cards[i % cards.size()]; // 简化轮询取卡 std::cout [单词] card.word \n; std::cout [释义] ; for (const auto m : card.meanings) std::cout m ; std::cout \n评分(5-1, q退出): ; std::string line; if (!std::getline(std::cin, line)) { std::cout 输入流已结束本轮结束\n; break; } if (line q) break; try { int score std::stoi(line); if (score 1 score 5) { updateCard(card, score); } else { std::cout 请输 1-5\n; --i; // 分数无效不消耗本轮配额 } } catch (const std::exception) { std::cout 无法识别的评分\n; --i; } } }逻辑说明每轮至少读取一整个文本行保证输入状态干净q直接退出非数字输入用异常捕获避免程序崩溃。把--i回退配额的目的是让无效输入不占本轮新词名额。这里展示的是简化队列实际使用时可从 3.3 节的dueQueue和newQueue按顺序取卡。评分后建议不要每次写文件而是在整个会话结束后统一保存。频繁打开文件不仅慢还会加剧文件损坏风险但注意updateCard修改的是内存中的对象必须在程序退出前保存到进度文件。4.3 处理 stdin 意外结束与残余换行C 的std::cin状态机是新手最容易踩的坑。当从管道或文件重定向输入时输入流可能提前读到 EOF此时std::cin的eofbit被置位后续所有getline都会立即失败。因此背词循环中应该像上面代码一样先检查getline返回值再处理内容。另一种常见问题出现在std::cin x与std::getline混用时operator不会消耗末尾换行符紧随其后的getline会读到空行。如果确实需要先用再读文本应在中间调用std::cin.ignore(std::numeric_limitsstd::streamsize::max(), \n)。注意在流对象上调用.clear()可以重置错误标志但已经丢失的数据无法恢复。背词循环里最稳妥的处理方式是只用getline读取所有内容再手工解析字符串避免混用两种读取方式。5. 背单词小程序的构建与发布CMake、VSCode 与 VC 运行库写代码和把程序交到别人手上运行是两件事。命令行小程序虽然结构简单但在 Windows 上构建和分发时工程配置和运行库问题往往比算法更耗时。这一章讲清楚从 VSCode 配置到可执行文件发布的最小链路。5.1 最小 CMake 配置与 VSCode 调试环境无论使用 MSVC、GCC 还是 Clang用 CMake 统一管理工程都是最常见的做法。一个背单词小程序的最小配置如下cmake_minimum_required(VERSION 3.16) project(word_trainer LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(word_trainer main.cpp word_parser.cpp scheduler.cpp session.cpp ) if(MSVC) target_compile_options(word_trainer PRIVATE /W4 /utf-8) else() target_compile_options(word_trainer PRIVATE -Wall -Wextra) endif()/utf-8是 MSVC 下的关键参数它让编译器把源码视为 UTF-8否则含中文字符串的字面量有可能出现乱码。在 VSCode 里配置 C/C 环境时只需安装 C/C 扩展和 CMake Tools 扩展然后在.vscode/settings.json里指定cmake.generator为 Ninja打开工程后按 F7 构建、F5 调试。CMake Tools 会自动生成launch.json的调试配置不需要手工编写太多底层文件。5.2 链接模式选择静态链接还是依赖 Visual C Redistributable在 Windows 上MSVC 编译的程序默认依赖动态运行时库例如VCRUNTIME140.dll。目标机器如果没有安装对应版本的 Visual C Redistributable程序会直接报「缺少 VCRUNTIME140.dll」。这个问题在给同事、朋友分发小程序时非常常见。两种解决方案的取舍如下方案优点缺点动态链接/MDexe 体积小目标机器需装运行库静态链接/MT单文件直接运行exe 体积增大调试符号复杂在 CMake 中切换静态链接的写法是set(CMAKE_MSVC_RUNTIME_LIBRARY MultiThreaded$$CONFIG:Debug:Debug)Debug 版本保留Debug后缀Release 版本则完全静态链接libcmt.lib。静态链接后dumpbin /dependents word_trainer.exe输出里不会再出现VCRUNTIME140.dll这就是发布前最直接的验证手段。5.3 双击没反应时的排查顺序命令行程序双击运行后窗口一闪而过这不是程序崩溃而是控制台窗口运行完毕后立即关闭。排查步骤如下先在现有终端里手动执行word_trainer.exe观察是否有输出区分「程序未启动」和「启动后立即退出」。在main入口临时加一行std::cerr started std::endl;如果这行都没打印说明 DLL 加载阶段就失败了。用dumpbin /dependents查看依赖项确保所有 DLL 都在系统路径中。检查是否被杀毒软件静默拦截。本地编译的小工具经常被启发式查杀把它加入信任区再运行。如果程序包含std::filesystem相关操作还要确认编译选项里是否链接了对应库。GCC 9 之前需要手动加-lstdcfsMSVC 下通常不需要额外链接。6. 背单词小程序上线前的健壮性检查自测与容错背单词工具要长期使用最怕两件事算法改坏了没人发现、进度文件损坏无法恢复。上线前做两件小事能省掉很多麻烦。6.1 用 assert 把核心算法锁进回归解析函数和调度函数都是纯逻辑适合单元测试。不需要测试框架用cassert就能覆盖关键路径。把下面一组断言放在程序编译选项的 Debug 版本里#include cassert void runSelfTests() { auto fields parseCSVLine(\abandon, v.\,例句中的,逗号); assert(fields.size() 3); assert(fields[0] abandon, v.); Card c; c.intervalDays 3; c.ease 2.5; updateCard(c, 5); // 评分5间隔应增大 assert(c.intervalDays 3); assert(!isDue(c, c.lastReview)); // 刚复习完不应到期 }这段自测不依赖外部文件和时间每次构建后运行一遍能快速暴露因重构引入的解析偏移或调度边界错误。Release 构建时assert会被编译器优化掉不会影响性能。6.2 文件校验与导入导出的兜底进度文件损坏通常不是磁盘故障而是程序异常退出导致写入不完整。除了 2.3 节的原子写入外加载进度文件时建议做基础校验每行必须能用空格拆出 word、last、interval、ease 四个字段interval必须大于 0last必须能解析成日期。任何一行不合法就跳过该行并在启动时提示「发现 1 条损坏记录已忽略」。导入词库同理不要相信用户手工编辑的 CSV 一定规范。逐行解析时检查word和meaning字段非空单词重复时保留第一次出现的结果并输出警告。先把错误行统计出来全部解析完再报告而不是遇到一行错误就终止程序。这样背单词小程序即使喂错了文件也不会崩溃下次重新导出词库时就能恢复正常。本文还有配套的精品资源点击获取