简介:这份C语言课程设计ATM机源代码面向高校计算机相关专业学生及C语言初学者,用于完成课程设计作业或巩固基础语法与逻辑训练。资源包共3个文件,以cpp源文件为核心,搭配2个txt文本文件分别存放账户信息与密码数据,压缩包仅2KB,轻量易读,便于直接编译运行和逐行研读。代码完整实现了登录验证、余额查询、取款、转账与存款五大核心功能,其中取款环节设有余额上限校验,转账与存款涉及账户数据的读写操作,适合用来理解结构体、文件读写、循环与条件判断等知识点的综合运用。目前已有579人学习下载,可作为课程设计参考模板,帮助读者快速理清ATM系统的模块划分与业务流程,并在此基础上进行功能扩展与代码优化。
1. 从一份能跑起来的 C 语言课程设计说起
很多同学做 C 语言课程设计时,最头疼的不是写不出代码,而是不知道一个“完整项目”到底该长什么样。课本上的例子往往只有几十行,变量命名随意,功能单一,离真正能交上去的课程设计差着十万八千里。这份 ATM 机源代码就是来解决这个问题的——它是一个结构完整、功能闭环的控制台程序,涵盖账户管理、登录验证、存取款、转账、查询流水等核心模块,代码量适中,逻辑清晰,非常适合拿来当课程设计的底稿,或者作为 C 语言综合练习的参考实现。如果你正在为课程设计发愁,或者想找一个能跑通、能改、能讲清楚的项目来练手,这份代码值得你花时间拆一遍。
2. 项目定位与技术选型:为什么用纯 C 写一个控制台 ATM
2.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++ 或者 MinGW,Linux 和 macOS 直接用 GCC 即可。如果你用 Visual Studio,需要把源文件后缀改成.c而不是.cpp,否则编译器会按 C++ 规则处理,可能导致一些 C 语言特性报错。
# Linux/macOS 下编译 gcc -o atm main.c account.c transaction.c -Wall -std=c99 # 如果所有代码都在一个文件里 gcc -o atm atm.c -Wall -std=c99 # Windows MinGW 下编译 gcc -o atm.exe atm.c -Wall -std=c99-Wall开启所有警告,能帮你发现潜在的未初始化变量、类型不匹配等问题。-std=c99指定使用 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-charset=GBK。
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 个字节的余量,宁可浪费一点空间,也不给内存越界留机会。希望这个习惯也能帮到你。
本文还有配套的精品资源,点击获取