C语言课程设计ATM机源代码:从能跑到能讲,完整拆解与改造指南

发布时间:2026/10/30 8:41:37
C语言课程设计ATM机源代码:从能跑到能讲,完整拆解与改造指南

简介:这份C语言课程设计ATM机源代码面向正在学习C语言、需要完成课程设计或练手项目的学生,围绕一个可运行的银行ATM模拟程序展开,帮助读者理解如何用C语言组织一个具备完整业务闭环的小型系统。压缩包共3个文件,以cpp源代码为主,另附2个txt文本文件,分别用于存放账户名与密码、客户账户数据,整体仅2KB,体量轻巧,便于直接编译运行和阅读改造。程序覆盖登录验证、余额查询、取款、转账与存款五项核心功能,其中取款环节对金额与余额关系做了校验,转账则涉及多账户之间的数据读写,适合用来练习结构体、文件读写、循环与分支控制以及基本的输入校验逻辑。目前已有579人学习下载,说明其在同类课程设计素材中具有一定参考价值。读者可据此梳理ATM项目的功能划分与数据组织方式,并在此基础上扩展修改密码、交易记录等模块。

1. 从一份“能跑起来”的C语言课设说起

很多同学做课程设计,最怕的不是写不出代码,而是交上去之后老师问一句“你这个程序怎么跑”,自己却支支吾吾说不清楚。这份C语言课程设计ATM机源代码,解决的正是这个尴尬:它把银行ATM的取款、存款、转账、查询余额、修改密码这些核心业务,用纯C语言完整实现了一遍,从登录验证到菜单交互再到文件持久化,一条链路走通。适合谁?正在做C语言课程设计、想找一个结构清晰能照着改的参考项目,或者刚学完指针和文件操作、想找个综合案例练手的开发者。它不依赖任何第三方库,一个编译器加一个终端就能跑,属于那种“下载下来改改就能交作业、拆开来看能学架构”的类型。下面我按自己拆项目的习惯,从目录结构讲到核心模块,再讲到怎么改,尽量把每个关键点都落到能抄作业的程度。

2. 项目结构与技术选型:一个单文件程序是怎么组织起来的

2.1 目录与文件构成

这类课程设计项目通常不会搞复杂的工程结构,常见做法是一个主源文件加一个数据文件,顶多再拆一个头文件出来。我拿到手的这份代码,结构大致是这样:

atm/ ├── main.c # 主程序入口,菜单循环与业务调度 ├── account.h # 账户结构体定义与函数声明 ├── account.c # 账户相关业务逻辑实现 └── accounts.dat # 账户数据文件,程序运行时读写

如果原始代码是单文件版本,那所有逻辑都挤在main.c里,函数按业务分块排列。两种组织方式各有取舍:单文件版本对新手友好,复制粘贴就能编译;多文件版本更接近真实工程,方便后期扩展。我一般建议在课设阶段先用单文件跑通,再拆成多文件,这样调试成本最低。

编译命令也很朴素,用 GCC 的话一条命令搞定:

gcc main.c account.c -o atm ./atm

这里-o atm指定输出可执行文件名,Windows 下会生成atm.exe。如果只用了单文件,把account.c去掉即可。注意编译时如果报“未定义引用”,多半是函数声明和定义对不上,检查头文件里有没有漏掉函数原型。

2.2 技术选型背后的取舍

这个项目没有用任何数据库,账户信息全部存在一个二进制文件accounts.dat里,用fread/fwrite整块读写。为什么这么选?课程设计的场景下,引入 SQLite 反而增加环境配置负担,老师评阅时也不一定认。二进制文件的好处是读写快、结构体直接落盘,缺点是可读性差、字段一改就全乱。常见做法是定义一个固定长度的结构体数组,程序启动时一次性读进内存,退出时整体写回。

// account.h #define MAX_ACCOUNTS 100 #define NAME_LEN 20 typedef struct { char cardNo[20]; // 卡号 char name[NAME_LEN]; // 户名 char password[7]; // 6位密码 + 结束符 double balance; // 余额 int status; // 0正常 1挂失 2冻结 } Account; extern Account accounts[MAX_ACCOUNTS]; extern int accountCount; int loadAccounts(void); int saveAccounts(void); int findAccount(const char *cardNo);

这段结构体定义是整个项目的数据基石。cardNo用字符数组而不是整数,是因为卡号可能有前导零,用int会丢位。password长度给 7 是留一个\0的位置,这是 C 字符串的老规矩,新手最容易在这里翻车。status字段给挂失和冻结留了扩展口,虽然课设里不一定用得上,但结构上先占位,后期加功能不用改文件格式。loadAccounts和saveAccounts成对出现,一个负责启动时灌数据,一个负责退出时落盘,中间所有业务操作都只改内存,这样设计的好处是业务逻辑和存储逻辑解耦,改存储方式时不用动业务代码。

2.3 主循环的调度逻辑

程序的主干是一个while(1)循环,先登录再进菜单,登录失败给三次机会。这种“先认证后操作”的流程是所有管理系统的通用骨架,ATM 也不例外。

int main(void) { if (loadAccounts() != 0) { printf("账户数据加载失败\n"); return 1; } while (1) { printf("\n===== 欢迎使用ATM =====\n"); printf("1. 登录\n2. 退出\n"); int choice; scanf("%d", &choice); if (choice == 2) break; if (choice != 1) { printf("无效输入\n"); continue; } char card[20], pwd[7]; printf("请输入卡号:"); scanf("%19s", card); printf("请输入密码:"); scanf("%6s", pwd); int idx = findAccount(card); if (idx < 0 || strcmp(accounts[idx].password, pwd) != 0) { printf("卡号或密码错误\n"); continue; } if (accounts[idx].status != 0) { printf("该账户状态异常,请联系柜台\n"); continue; } operateMenu(idx); // 进入业务菜单 } saveAccounts(); return 0; }

scanf("%19s", card)里的19是宽度限制,防止输入超长把栈写穿,这是 C 语言里血泪经验级别的细节,很多课设代码直接写%s,输入一长就崩。findAccount返回数组下标,找不到返回 -1,调用方用idx < 0判断,这种“下标即句柄”的写法在课设里够用,但要注意数组容量上限MAX_ACCOUNTS,账户数超了要提示而不是越界写。operateMenu是业务入口,登录成功后把下标传进去,后续所有操作都基于这个下标定位账户,避免反复查找。退出时统一saveAccounts(),保证数据不丢。

3. 核心业务模块拆解:取款、存款、转账的完整链路

3.1 取款与存款:金额校验是重灾区

取款逻辑看着简单,实际坑最多。要校验金额是否为正、是否超过余额、是否超过单笔限额,还要考虑余额扣减后的精度问题。用double存金额在课设里可以接受,但真实场景一般用“分”为单位的整数,避免浮点误差。这里按课设常见做法用double,但校验顺序不能乱。

void withdraw(int idx) { double amount; printf("请输入取款金额:"); if (scanf("%lf", &amount) != 1) { printf("输入格式错误\n"); while (getchar() != '\n'); // 清空输入缓冲 return; } if (amount <= 0) { printf("金额必须大于0\n"); return; } if (amount > 5000) { printf("单笔取款不能超过5000\n"); return; } if (amount > accounts[idx].balance) { printf("余额不足,当前余额:%.2f\n", accounts[idx].balance); return; } accounts[idx].balance -= amount; printf("取款成功,余额:%.2f\n", accounts[idx].balance); // 真实场景这里会写交易流水,课设可省略 }

scanf返回值判断是很多人忽略的点,输入字母时scanf返回 0,不处理就会死循环。while (getchar() != '\n')这行是清缓冲的经典写法,把错误输入剩下的字符吃掉,否则下一次scanf直接读到脏数据。校验顺序我一般按“格式 → 正负 → 限额 → 余额”来,先挡掉明显非法的,再查业务规则,这样错误提示更精准。存款逻辑类似,但要多一个上限校验,比如单笔不超过 20000,防止输入天文数字把double搞溢出。

3.2 转账:两个账户的原子性怎么保证

转账是取款加存款的组合,但难点在于“要么都成功,要么都不动”。课设里没有事务机制,常见做法是先做全部校验,校验通过后再改两个账户的余额,中间不插入任何可能失败的 IO 操作。

void transfer(int idx) { char target[20]; double amount; printf("请输入对方卡号:"); scanf("%19s", target); int tidx = findAccount(target); if (tidx < 0) { printf("对方账户不存在\n"); return; } if (tidx == idx) { printf("不能给自己转账\n"); return; } printf("请输入转账金额:"); if (scanf("%lf", &amount) != 1 || amount <= 0) { printf("金额无效\n"); while (getchar() != '\n'); return; } if (amount > accounts[idx].balance) { printf("余额不足\n"); return; } // 全部校验通过,再执行扣减与增加 accounts[idx].balance -= amount; accounts[tidx].balance += amount; printf("转账成功,当前余额:%.2f\n", accounts[idx].balance); }

关键点在于“先校验后修改”,所有return都发生在余额变动之前,这样就不会出现扣了钱没到账的半截状态。tidx == idx的判断防止自己转自己导致余额不变但流水混乱。真实系统里转账还要考虑对方账户状态是否正常,课设里可以加一行if (accounts[tidx].status != 0)的判断,逻辑上更完整。这里没有写交易记录,如果老师要求流水,可以在结构体里加一个定长数组存最近十笔,或者单独开一个transactions.dat,按行追加文本,实现成本很低。

3.3 密码修改与查询:边界条件别漏

查询余额就是打印balance,没什么好说的,但密码修改有几个边界要处理:新密码长度必须正好 6 位、不能和旧密码相同、两次输入要一致。

void changePassword(int idx) { char oldPwd[7], newPwd[7], confirm[7]; printf("请输入原密码:"); scanf("%6s", oldPwd); if (strcmp(accounts[idx].password, oldPwd) != 0) { printf("原密码错误\n"); return; } printf("请输入新密码(6位):"); scanf("%6s", newPwd); if (strlen(newPwd) != 6) { printf("密码必须为6位\n"); return; } if (strcmp(newPwd, oldPwd) == 0) { printf("新密码不能与原密码相同\n"); return; } printf("请再次输入新密码:"); scanf("%6s", confirm); if (strcmp(newPwd, confirm) != 0) { printf("两次输入不一致\n"); return; } strcpy(accounts[idx].password, newPwd); printf("密码修改成功\n"); }

strlen(newPwd) != 6这个校验依赖scanf("%6s")的宽度限制,如果用户输入超过 6 位,scanf只读前 6 位,剩下的留在缓冲区里,下一次输入会直接读到脏数据。所以严格来说,改完密码后应该清一次缓冲,或者用fgets读整行再处理。课设阶段用scanf加宽度限制是常见做法,但要知道它的局限。strcpy的目标缓冲区是结构体里的password[7],源串最长 6 位加结束符正好 7,不会溢出,这个长度匹配要在定义结构体时就对齐好。

4. 数据持久化与文件读写:程序关了数据不能丢

4.1 二进制文件的整块读写

loadAccounts和saveAccounts是数据不丢的关键。二进制读写的好处是代码短,一个fread就能把整个数组读进来。

int loadAccounts(void) { FILE *fp = fopen("accounts.dat", "rb"); if (fp == NULL) { // 文件不存在视为首次运行,初始化一个默认账户 accountCount = 1; strcpy(accounts[0].cardNo, "6222001"); strcpy(accounts[0].name, "测试用户"); strcpy(accounts[0].password, "123456"); accounts[0].balance = 1000.0; accounts[0].status = 0; return 0; } accountCount = fread(accounts, sizeof(Account), MAX_ACCOUNTS, fp); fclose(fp); return 0; } int saveAccounts(void) { FILE *fp = fopen("accounts.dat", "wb"); if (fp == NULL) return -1; fwrite(accounts, sizeof(Account), accountCount, fp); fclose(fp); return 0; }

fread的返回值是实际读到的元素个数,直接赋给accountCount,这样文件里有多少条就读多少条,不用额外存计数。fopen失败时初始化默认账户,保证程序第一次跑也有数据可登录,这个“首次运行兜底”是课设演示时的救命设计,否则老师一运行发现没账户,直接扣分。fwrite写回时用accountCount控制条数,不会把数组里未使用的空槽也写进去。注意sizeof(Account)在不同编译器下可能因为对齐不同而变化,同一份数据文件换编译器可能读不出来,这是二进制存储的固有缺陷,课设场景下可以接受,但要知道这个限制。

4.2 文本文件方案的备选思路

如果担心二进制文件不可读、调试时看不到内容,可以换成文本文件,每行一个账户,字段用逗号分隔。读写用fprintf和fgets加sscanf解析。好处是能用记事本直接改数据,坏处是解析代码变多、字段里不能出现分隔符。我一般建议课设用二进制跑通主流程,如果老师明确要求“数据可读”,再换文本方案,改动集中在loadAccounts和saveAccounts两个函数里,业务代码一行不用动,这就是前面说的存储与业务解耦的好处。

提示:无论用哪种方案,saveAccounts都要在程序正常退出路径上调用。如果程序中途崩溃,内存里的修改就丢了。课设里可以在每次业务操作成功后立即落盘,牺牲一点性能换数据安全。

5. 避坑与排查:课设现场最容易翻车的几个点

5.1 输入缓冲区残留导致菜单乱跳

现象:输入一次非法字符后,程序疯狂打印菜单,停不下来。原因:scanf读失败后字符留在缓冲区,循环里反复读到同一个脏字符。解决:每次scanf后判断返回值,失败时用while (getchar() != '\n')清空缓冲,再continue。这个坑几乎每份课设代码都会踩一次,提前加上清缓冲的封装函数能省很多调试时间。

5.2 结构体直接落盘后改字段读不出来

现象:给Account加了一个字段,重新编译后读旧数据文件,余额变成乱码。原因:二进制文件按旧结构体的字节布局存储,字段一变,偏移全乱。解决:改结构体后删掉旧的accounts.dat重新生成,或者加一个版本号字段做兼容判断。课设阶段最省事的做法就是删文件重来,但要在提交前确认演示数据是重新初始化过的。

5.3 密码用明文存储被老师追问

现象:答辩时老师问“密码怎么存的”,答“直接存字符串”,场面尴尬。原因:课设代码普遍明文存密码,功能上没问题,但安全意识上扣分。解决:加一个简单的哈希函数,比如把每个字符乘一个系数累加,存哈希值而不是原文,登录时对输入做同样运算再比对。不用上真正的加密库,一个十几行的自定义哈希就能体现设计意识,成本极低。

5.4 数组越界写导致程序随机崩溃

现象:账户加到第 101 个时程序崩溃,或者余额显示异常。原因:accounts[MAX_ACCOUNTS]容量固定,开户时没判断accountCount是否已达上限。解决:开户函数入口加if (accountCount >= MAX_ACCOUNTS) { printf("账户数已满\n"); return; },所有写数组的地方都先确认下标在[0, accountCount)范围内。这类越界在 C 里不会报错,只会默默踩坏相邻内存,排查时用-fsanitize=address编译能快速定位。

5.5 浮点比较导致余额判断失误

现象:取款时余额明明够,却提示余额不足。原因:double累加多次后产生微小误差,amount > balance在边界上判断不准。解决:比较时加一个极小值容差,比如if (amount - balance > 1e-6),或者干脆把金额换成以分为单位的long,从根上避开浮点。课设里用容差写法改动最小,真实项目里我一般直接用整数分。

6. 二次开发与改造思路:从课设到能讲出设计

拿到这份代码之后,最值得做的改造不是加花哨功能,而是把“能跑”变成“能讲”。我一般会先做三件事:把单文件拆成main.c加account.c加account.h,让模块边界清晰;把明文密码换成自定义哈希,答辩时能多说两句安全设计;把每次业务操作追加一行到transactions.log文本文件,形成交易流水。这三步改动量都不大,但能让项目从“作业”变成“有设计痕迹的作品”。

具体到代码层面,拆文件时把结构体定义和函数原型放头文件,业务实现放account.c,main.c只留菜单和调度。哈希函数可以这样写:

// 简单哈希,仅用于课设演示,不可用于真实场景 void hashPassword(const char *plain, char *out) { unsigned int h = 5381; while (*plain) { h = ((h << 5) + h) + (unsigned char)(*plain); plain++; } sprintf(out, "%06u", h % 1000000); // 固定6位输出 }

这个djb2变种几行代码就能把密码变成 6 位数字串,存进原来的password[7]字段刚好合适,结构体都不用改。登录时对输入做同样运算再strcmp,旧数据文件删掉重新初始化即可。交易流水更简单,在取款、存款、转账成功后各加一行fprintf,格式用“时间 卡号 类型 金额 余额”,时间用time()加localtime格式化,十行以内搞定。

验证改造是否成功,我习惯走一遍固定用例:用默认账户登录、取款 100、查余额、转账 50 给第二个账户、改密码、退出重进确认数据还在、故意输错密码三次确认锁定逻辑。这套用例跑通,基本能覆盖所有主路径。最后说个我自己的教训:早些年做课设,总想着最后一天再统一编译测试,结果改结构体字段后忘了删旧数据文件,演示时余额全是乱码,当场冒汗。从那以后我每次改完结构体或存储格式,都强制先删数据文件再跑一遍初始化流程,这个习惯帮我省了不止一次后悔药。希望这份拆解能帮到你,把课设跑通的同时,也能在答辩时讲清楚每一行代码为什么这么写。

本文还有配套的精品资源,点击获取