
简介这份C语言课程设计ATM机源代码面向高校计算机相关专业学生及C语言初学者用于完成课程设计作业或巩固基础语法与逻辑训练。资源包共3个文件以cpp源文件为核心搭配2个txt文本文件分别存放账户信息与密码数据压缩包仅2KB轻量易读便于直接编译运行和逐行研读。代码完整实现了登录验证、余额查询、取款、转账与存款五大核心功能其中取款环节设有余额上限校验转账与存款涉及账户数据的读写操作适合用来理解结构体、文件读写、循环与条件判断等知识点的综合运用。目前已有579人学习下载可作为课程设计参考模板帮助读者快速理清ATM系统的模块划分与业务流程并在此基础上进行功能扩展与代码优化。1. 从一份能跑起来的 C 语言课程设计说起很多同学做 C 语言课程设计时最头疼的不是写不出代码而是不知道一个“完整项目”到底该长什么样。课本上的例子往往只有几十行变量命名随意功能单一离真正能交上去的课程设计差着十万八千里。这份 ATM 机源代码就是来解决这个问题的——它是一个结构完整、功能闭环的控制台程序涵盖账户管理、登录验证、存取款、转账、查询流水等核心模块代码量适中逻辑清晰非常适合拿来当课程设计的底稿或者作为 C 语言综合练习的参考实现。如果你正在为课程设计发愁或者想找一个能跑通、能改、能讲清楚的项目来练手这份代码值得你花时间拆一遍。2. 项目定位与技术选型为什么用纯 C 写一个控制台 ATM2.1 这个项目到底解决了什么问题课程设计的本质是考察你对一门语言综合运用能力而不是让你去造一个生产级系统。这份 ATM 机源代码的定位非常明确用标准 C 语言在不依赖任何第三方库的前提下实现一个具备完整业务逻辑的控制台程序。它要解决的核心问题是——让你有一个“能讲清楚每一行代码为什么这么写”的项目。从功能角度看它覆盖了 ATM 的基本业务闭环开户、登录、查询余额、存款、取款、转账、修改密码、查询交易记录、退出。这些功能之间不是孤立的而是通过账户数据和交易流水串联起来的。比如转账操作会同时修改两个账户的余额并生成两条交易记录取款操作会校验余额是否充足并更新流水。这种业务关联性正是课程设计需要体现的“系统思维”。从技术选型角度看纯 C 语言 控制台交互是最稳妥的方案。不需要图形界面库不需要数据库不需要网络编程所有数据用文件存储所有交互用scanf和printf完成。这样做的好处是代码可移植性强在 Windows 上用 Dev-C 能跑在 Linux 上用 GCC 也能跑调试方便断点打在哪儿都行答辩时老师问的任何一行代码你都能解释清楚不会因为框架封装太深而说不明白。2.2 数据结构设计账户与交易流水怎么存任何管理类系统的核心都是数据结构设计。这个项目里有两个关键结构体账户和交易记录。账户结构体承载了用户身份信息和资金信息交易记录结构体则用于追溯每一笔资金变动。// 账户结构体存储单个用户的完整信息 typedef struct { char cardNo[20]; // 卡号唯一标识 char password[20]; // 密码明文存储课程设计简化处理 char name[30]; // 户名 double balance; // 余额 int status; // 账户状态0-正常 1-冻结 2-销户 } Account; // 交易记录结构体存储每一笔资金变动 typedef struct { char cardNo[20]; // 关联卡号 int type; // 交易类型1-存款 2-取款 3-转账转出 4-转账转入 double amount; // 交易金额 double balanceAfter; // 交易后余额 char timeStr[30]; // 交易时间字符串 } Transaction;这两个结构体是整个系统的数据基石。账户信息通常用一个固定长度的数组来管理比如Account accounts[100]最多支持 100 个账户。交易记录同理用Transaction transactions[1000]存储最近 1000 条流水。数组方案虽然简单但对于课程设计来说完全够用而且避免了链表操作带来的指针复杂性降低了调试难度。常见做法是程序启动时从accounts.dat和transactions.dat两个二进制文件中读取数据到内存数组程序运行期间所有增删改查都在内存中完成退出时再整体写回文件。这种“全量加载、全量保存”的策略在数据量小的时候非常高效也避免了频繁文件 I/O 带来的性能问题。2.3 文件存储方案二进制还是文本数据持久化是课程设计必须解决的问题。这个项目采用二进制文件存储原因有三第一二进制读写可以直接用fread和fwrite整块操作结构体数组代码量极少第二不需要手动解析字符串避免了格式错误第三读写速度快适合频繁保存的场景。// 保存账户数据到文件 void saveAccounts() { FILE *fp fopen(accounts.dat, wb); if (fp NULL) { printf(文件打开失败\n); return; } // 一次性写入整个账户数组 fwrite(accounts, sizeof(Account), accountCount, fp); fclose(fp); } // 从文件加载账户数据 void loadAccounts() { FILE *fp fopen(accounts.dat, rb); if (fp NULL) { // 文件不存在时初始化为空 accountCount 0; return; } accountCount fread(accounts, sizeof(Account), MAX_ACCOUNTS, fp); fclose(fp); }这里有个细节值得注意fread的返回值是实际读取到的元素个数直接赋值给accountCount就能知道当前系统里有多少个账户。写入时用accountCount控制写入数量避免把数组中未使用的空位也写进文件。这种写法比文本文件逐行解析要简洁得多也不容易出错。提示二进制文件在不同编译器或平台之间可能存在兼容性问题比如结构体对齐方式不同。如果换编译器后读不出数据优先检查结构体成员顺序和类型是否一致。3. 核心模块拆解从登录验证到转账的完整链路3.1 登录模块三次机会与账户锁定登录是 ATM 系统的入口也是安全性最直观的体现。这个项目的登录逻辑并不复杂但有几个关键点需要处理好卡号是否存在、密码是否正确、账户是否被冻结、连续输错是否锁定。// 登录验证函数返回登录成功的账户下标失败返回 -1 int login() { char inputCard[20]; char inputPwd[20]; int attempts 0; // 密码尝试次数 printf(请输入卡号); scanf(%s, inputCard); // 查找账户 int index findAccountByCardNo(inputCard); if (index -1) { printf(卡号不存在\n); return -1; } // 检查账户状态 if (accounts[index].status 1) { printf(该账户已被冻结请联系管理员\n); return -1; } if (accounts[index].status 2) { printf(该账户已销户\n); return -1; } // 密码验证最多三次机会 while (attempts 3) { printf(请输入密码); scanf(%s, inputPwd); if (strcmp(inputPwd, accounts[index].password) 0) { printf(登录成功\n); return index; } else { attempts; printf(密码错误还剩 %d 次机会\n, 3 - attempts); } } // 三次错误后冻结账户 accounts[index].status 1; saveAccounts(); printf(密码连续错误三次账户已冻结\n); return -1; }这段代码的逻辑链条很清晰先验证卡号再验证状态最后验证密码。密码错误三次后直接修改账户状态为冻结并立即调用saveAccounts()持久化。这里有个容易忽略的点——冻结操作必须立刻保存否则程序崩溃或强制退出后冻结状态就丢了下次启动又能继续试密码安全机制形同虚设。findAccountByCardNo是一个简单的线性查找函数遍历accounts数组比较卡号字符串。虽然效率不高但对于最多 100 个账户的场景来说完全够用而且代码直观答辩时容易讲清楚。3.2 存取款模块余额校验与流水记录存款和取款是 ATM 最核心的资金操作。存款相对简单只需要增加余额并记录流水取款则需要校验余额是否充足还要考虑 ATM 机自身的现金储备课程设计中通常简化为不限制。// 取款函数 void withdraw(int index) { double amount; printf(请输入取款金额); scanf(%lf, amount); // 校验金额合法性 if (amount 0) { printf(取款金额必须大于零\n); return; } if (amount accounts[index].balance) { printf(余额不足当前余额%.2f 元\n, accounts[index].balance); return; } // 更新余额 accounts[index].balance - amount; // 记录交易流水 addTransaction(accounts[index].cardNo, 2, amount, accounts[index].balance); // 保存数据 saveAccounts(); saveTransactions(); printf(取款成功当前余额%.2f 元\n, accounts[index].balance); }取款逻辑中有两个关键校验金额必须大于零金额不能超过余额。这两个校验缺一不可否则会出现负数取款或者透支的情况。交易流水通过addTransaction函数追加到全局transactions数组并自动记录当前时间。// 添加交易记录 void addTransaction(char *cardNo, int type, double amount, double balanceAfter) { if (transactionCount MAX_TRANSACTIONS) { printf(交易记录已满无法继续记录\n); return; } strcpy(transactions[transactionCount].cardNo, cardNo); transactions[transactionCount].type type; transactions[transactionCount].amount amount; transactions[transactionCount].balanceAfter balanceAfter; // 获取当前时间并格式化 time_t now time(NULL); struct tm *t localtime(now); strftime(transactions[transactionCount].timeStr, 30, %Y-%m-%d %H:%M:%S, t); transactionCount; }addTransaction函数用time和strftime生成可读的时间字符串方便后续查询流水时展示。交易类型用整数枚举表示1 代表存款2 代表取款3 代表转账转出4 代表转账转入。这种设计在查询流水时可以通过switch语句转换成中文描述。3.3 转账模块两个账户的原子操作转账是课程设计里最能体现“业务完整性”的功能因为它涉及两个账户的同时修改。如果只扣了转出方余额而没有增加转入方余额系统就会出现资金凭空消失的严重 bug。// 转账函数 void transfer(int index) { char targetCard[20]; double amount; printf(请输入对方卡号); scanf(%s, targetCard); // 不能给自己转账 if (strcmp(targetCard, accounts[index].cardNo) 0) { printf(不能给自己转账\n); return; } // 查找对方账户 int targetIndex findAccountByCardNo(targetCard); if (targetIndex -1) { printf(对方卡号不存在\n); return; } if (accounts[targetIndex].status ! 0) { printf(对方账户状态异常无法转账\n); return; } printf(请输入转账金额); scanf(%lf, amount); if (amount 0) { printf(转账金额必须大于零\n); return; } if (amount accounts[index].balance) { printf(余额不足当前余额%.2f 元\n, accounts[index].balance); return; } // 执行转账先扣后加 accounts[index].balance - amount; accounts[targetIndex].balance amount; // 记录双方流水 addTransaction(accounts[index].cardNo, 3, amount, accounts[index].balance); addTransaction(accounts[targetIndex].cardNo, 4, amount, accounts[targetIndex].balance); // 统一保存 saveAccounts(); saveTransactions(); printf(转账成功当前余额%.2f 元\n, accounts[index].balance); }转账模块的校验链条比取款更长对方卡号不能是自己、对方账户必须存在、对方账户状态必须正常、金额必须合法、余额必须充足。所有校验通过后才执行“先扣后加”操作然后分别记录两条流水最后统一保存。这里“统一保存”很重要——如果扣款后保存一次、加款后再保存一次中间程序崩溃就会导致数据不一致。把两次修改都放在内存中完成最后一次性写文件能最大程度保证数据一致性。注意课程设计中通常不涉及事务回滚机制但你可以通过“先备份再修改”的思路来增强健壮性。比如在转账前把两个账户的原始数据存到临时变量如果保存文件失败就恢复回去。3.4 流水查询按时间倒序展示交易记录查询流水是用户体验的重要环节。用户需要看到自己最近的交易记录包括交易类型、金额、交易后余额和时间。实现思路是遍历transactions数组筛选出当前卡号的记录然后按时间倒序输出。// 查询当前账户的交易流水 void queryTransactions(int index) { char *cardNo accounts[index].cardNo; int found 0; printf(\n 交易流水 \n); // 从后往前遍历实现时间倒序 for (int i transactionCount - 1; i 0; i--) { if (strcmp(transactions[i].cardNo, cardNo) 0) { printf(时间%s\n, transactions[i].timeStr); printf(类型%s\n, getTypeName(transactions[i].type)); printf(金额%.2f 元\n, transactions[i].amount); printf(交易后余额%.2f 元\n, transactions[i].balanceAfter); printf(------------------------------\n); found 1; } } if (!found) { printf(暂无交易记录。\n); } } // 交易类型转中文描述 char* getTypeName(int type) { switch (type) { case 1: return 存款; case 2: return 取款; case 3: return 转账转出; case 4: return 转账转入; default: return 未知; } }倒序遍历数组是实现时间倒序最简单的方法因为addTransaction是顺序追加的数组下标越大时间越晚。getTypeName函数把整数类型转换成中文让输出更友好。这种“存储用整数、展示用字符串”的做法在课程设计中很常见既节省存储空间又方便扩展新的交易类型。4. 编译运行与调试从源码到可执行文件的完整步骤4.1 环境准备与编译命令这份代码不依赖任何第三方库只需要一个标准的 C 编译器。Windows 上推荐 Dev-C 或者 MinGWLinux 和 macOS 直接用 GCC 即可。如果你用 Visual Studio需要把源文件后缀改成.c而不是.cpp否则编译器会按 C 规则处理可能导致一些 C 语言特性报错。# Linux/macOS 下编译 gcc -o atm main.c account.c transaction.c -Wall -stdc99 # 如果所有代码都在一个文件里 gcc -o atm atm.c -Wall -stdc99 # Windows MinGW 下编译 gcc -o atm.exe atm.c -Wall -stdc99-Wall开启所有警告能帮你发现潜在的未初始化变量、类型不匹配等问题。-stdc99指定使用 C99 标准确保for循环内声明变量、//注释等特性可用。如果你的代码用了strcpy、scanf等函数某些编译器可能会提示“不安全”这是正常的课程设计阶段可以忽略或者用#define _CRT_SECURE_NO_WARNINGS屏蔽 Visual Studio 的警告。编译成功后会在当前目录生成atm或atm.exe可执行文件。首次运行时会自动创建accounts.dat和transactions.dat两个数据文件。如果目录没有写权限文件创建会失败程序可能直接崩溃。所以建议把可执行文件放在一个有写权限的目录下运行比如用户主目录或者桌面。4.2 首次运行与初始化数据程序启动后首先加载数据文件。如果文件不存在accountCount会被初始化为 0此时系统里没有任何账户你需要先注册一个账户才能登录。注册功能通常在主菜单里输入卡号、密码、户名后系统会分配一个账户下标并保存。// 注册新账户 void registerAccount() { if (accountCount MAX_ACCOUNTS) { printf(账户数量已达上限\n); return; } Account newAcc; printf(请输入卡号); scanf(%s, newAcc.cardNo); // 检查卡号是否重复 if (findAccountByCardNo(newAcc.cardNo) ! -1) { printf(该卡号已存在\n); return; } printf(请输入密码); scanf(%s, newAcc.password); printf(请输入户名); scanf(%s, newAcc.name); newAcc.balance 0.0; // 初始余额为零 newAcc.status 0; // 正常状态 accounts[accountCount] newAcc; accountCount; saveAccounts(); printf(注册成功请登录。\n); }注册时初始余额设为 0状态设为正常。卡号重复检查是必须的否则会出现两个相同卡号的账户登录时findAccountByCardNo只会返回第一个匹配项第二个账户永远无法登录。注册完成后立即保存确保数据落盘。4.3 常见编译错误与修复方法课程设计代码在编译时最容易遇到三类错误头文件缺失、函数未声明、类型不匹配。头文件缺失通常是因为用了strcmp、strcpy却没包含string.h用了time、localtime却没包含time.h。函数未声明是因为函数定义在调用之后需要在文件开头加函数原型声明。类型不匹配常见于scanf读double时用了%f而不是%lf导致读入的数据错误。// 正确的函数声明放在文件开头 int login(); void withdraw(int index); void transfer(int index); void queryTransactions(int index); int findAccountByCardNo(char *cardNo); void saveAccounts(); void loadAccounts(); void addTransaction(char *cardNo, int type, double amount, double balanceAfter);把函数原型统一放在main函数之前能避免“隐式声明”警告。scanf读double必须用%lf读float才用%f这是 C 语言里非常容易翻车的地方。如果发现取款金额读进来是 0 或者乱码优先检查这里。提示如果程序运行后菜单显示乱码检查源文件编码是否为 UTF-8以及终端是否支持中文。Windows 控制台默认 GBK 编码用 UTF-8 源文件时可能需要在编译时加-fexec-charsetGBK。5. 避坑与常见问题排查5.1 数据文件读写失败导致程序崩溃现象程序启动后直接闪退或者登录时提示“卡号不存在”但明明注册过。原因fopen返回NULL时没有做判空处理后续fread或fwrite操作空指针导致段错误。另一种情况是文件路径不对程序在 A 目录运行数据文件在 B 目录读写的是不同文件。解决所有fopen之后必须检查返回值。如果文件打开失败给出明确提示并决定是初始化空数据还是退出程序。数据文件建议用相对路径并确保程序运行目录和文件目录一致。FILE *fp fopen(accounts.dat, rb); if (fp NULL) { // 文件不存在时初始化为空而不是崩溃 accountCount 0; return; }5.2 字符串输入缓冲区溢出现象输入很长的卡号或密码后程序行为异常甚至修改了其他变量的值。原因scanf(%s, buf)不限制输入长度如果输入超过buf大小会覆盖相邻内存。这是 C 语言最经典的安全漏洞。解决用scanf(%19s, buf)限制最大读取长度或者改用fgets。对于卡号字段char cardNo[20]最多读 19 个字符留一个位置给字符串结束符\0。// 限制输入长度防止溢出 scanf(%19s, newAcc.cardNo); scanf(%19s, newAcc.password);5.3 转账后余额不一致现象A 转账给 B 后A 的余额减少了但 B 的余额没有增加或者增加了但流水里查不到。原因转账逻辑中只修改了一个账户就保存了文件或者保存文件时只保存了账户数据没保存流水数据。另一种可能是findAccountByCardNo返回的下标错误导致加款加到了别的账户上。解决转账操作必须“先完成所有内存修改再统一保存”。保存时账户文件和流水文件都要写。调试时可以在修改前后打印两个账户的余额确认数值变化符合预期。5.4 密码明文存储被质疑现象答辩时老师问“密码为什么是明文存的”不知道怎么回答。原因课程设计为了简化通常直接存明文。但这确实是一个安全缺陷。解决可以加一个简单的哈希函数比如把密码每个字符加上一个固定偏移量再存储验证时同样偏移后比较。虽然这不是真正的加密但能体现你有安全意识。更好的做法是用一个简单的异或加密代码量不大效果立竿见影。// 简单异或加密密钥为 0x5A void encrypt(char *str) { for (int i 0; str[i] ! \0; i) { str[i] ^ 0x5A; } }存储前调用encrypt读取后再次调用encrypt还原。注意加密后的字符串可能包含不可打印字符用%s输出会乱码所以只在存储和比较时使用不要直接打印。5.5 交易记录数组越界现象程序运行一段时间后查询流水时输出乱码或者新增流水后程序崩溃。原因transactionCount达到MAX_TRANSACTIONS后没有做边界检查继续写入导致数组越界。解决addTransaction函数开头必须检查transactionCount MAX_TRANSACTIONS如果满了就拒绝新增或者用循环队列覆盖最旧的记录。课程设计中建议直接拒绝并提示“交易记录已满”简单可靠。6. 二次开发与改造思路从课程设计到个人项目6.1 把数组换成链表支持动态账户数量固定数组最大的问题是容量写死。如果你想把这份代码改成一个能实际用的小工具第一步就是把Account accounts[100]换成链表。每个节点动态分配注册时malloc一个新节点插入链表销户时从链表删除并free。这样账户数量只受内存限制不再有 100 个的上限。改造时注意findAccountByCardNo要改成遍历链表返回节点指针而不是返回数组下标。所有通过下标访问accounts[index]的地方都要改成通过指针访问node-balance。文件存储也要相应调整不能再用fwrite整块写入需要遍历链表逐个写入读取时逐个malloc并串联。6.2 增加管理员模式与账户管理功能课程设计通常只有用户视角但一个完整的 ATM 系统应该有管理员角色。你可以增加一个隐藏的管理员登录入口登录后可以查看所有账户、冻结/解冻账户、重置密码、查询全局流水。管理员数据可以单独存一个admin.dat或者直接在代码里硬编码一个管理员账号。// 管理员菜单 void adminMenu() { int choice; while (1) { printf(\n 管理员菜单 \n); printf(1. 查看所有账户\n); printf(2. 冻结账户\n); printf(3. 解冻账户\n); printf(4. 重置密码\n); printf(5. 返回\n); printf(请选择); scanf(%d, choice); switch (choice) { case 1: listAllAccounts(); break; case 2: freezeAccount(); break; case 3: unfreezeAccount(); break; case 4: resetPassword(); break; case 5: return; default: printf(无效选择\n); } } }管理员功能不需要太复杂重点是体现你对“角色权限”的理解。冻结账户就是把status改成 1解冻改成 0重置密码就是让管理员输入新密码覆盖旧的。这些操作都要立即保存文件。6.3 用文件锁避免多进程数据覆盖如果你把程序编译成可执行文件后同时开两个终端运行两个进程会各自加载一份数据到内存退出时各自保存后保存的会覆盖先保存的导致数据丢失。解决方法是加文件锁或者在程序启动时检查是否已有实例在运行。简单做法是创建一个lock文件程序启动时尝试以独占方式打开如果失败就提示“程序已在运行”。退出时删除lock文件。这个技巧在课程设计答辩时是个加分项能体现你对并发问题的思考。// 简单的单实例检查 FILE *lock fopen(atm.lock, r); if (lock ! NULL) { printf(程序已在运行中\n); fclose(lock); return 1; } lock fopen(atm.lock, w); fclose(lock); // 程序退出前删除 lock 文件 remove(atm.lock);6.4 我踩过的一个坑时间字符串的缓冲区大小最后说一个我实际改代码时翻车的地方。strftime格式化时间字符串时我一开始给timeStr分配了 20 个字节格式是%Y-%m-%d %H:%M:%S算下来正好 19 个字符加结束符理论上够。但实际运行时发现偶尔会多出乱码排查后发现是strftime在某些平台会写入超出预期的字符或者结构体对齐导致相邻字段被覆盖。后来我把timeStr改成char timeStr[30]问题消失。从那以后我每次定义字符串缓冲区都强制多留 10 个字节的余量宁可浪费一点空间也不给内存越界留机会。希望这个习惯也能帮到你。本文还有配套的精品资源点击获取