C语言课程设计ATM模拟系统:源码解析与二次开发指南

发布时间:2026/10/23 23:06:05
C语言课程设计ATM模拟系统:源码解析与二次开发指南

简介:这份C语言课程设计ATM资源面向高校计算机专业学生及C语言初学者,提供一套完整的ATM模拟系统实现方案,涵盖存款、取款、转账、修改密码等核心业务功能。压缩包共51个文件,约468KB,包含10个cpp源文件与2个c文件承载各功能模块逻辑,11个obj及2个exe为编译产物,另有dsp、dsw、opt等VC6工程配置文件和pdb、ilk调试信息,方便直接打开工程运行调试。资源已有317人学习下载,说明其作为课程设计参考具有一定认可度。读者可从中获得结构体存储用户信息、文件操作持久化数据、密码校验与异常处理等具体实现思路,并借助分模块的源码组织理解函数划分与流程控制,适合作为期末作业参考或C语言综合练习素材,帮助快速搭建可运行的ATM系统并掌握软件工程中的安全性与交互设计要点。

1. 从一份 C 语言课程设计 ATM 说起:它到底能跑出什么

如果你正在找一份能直接编译、能演示、还能顺手改的 C 语言课程设计,那这份 ATM 模拟系统大概率就是你要的东西。它不依赖图形界面,也没有复杂的第三方库,核心就是控制台交互加文件存储,把开户、登录、查询、存取款、转账、改密、销户这一整套银行业务流程用纯 C 语言串起来。很多同学做课程设计最怕的就是“功能写了一半,文件读写崩了”,而这个项目的价值恰恰在于它把账户数据和交易流水都落到了本地文件里,程序重启后数据还在,答辩演示时不会出现“重启即失忆”的尴尬。它适合刚学完 C 语言、需要交课程设计的学生,也适合想拿一个完整小项目练手文件操作和结构体数组的开发者。下面我就按“能干什么、怎么跑、怎么改”的顺序,把这份源码拆开讲清楚。

2. 项目架构与数据设计:结构体、文件存储和主循环怎么搭

2.1 账户结构体与全局数据布局

这份 ATM 的骨架其实就三样东西:账户结构体、账户数组、交易记录文件。先看账户结构体,它决定了后面所有功能的字段边界。

// account.h #ifndef ACCOUNT_H #define ACCOUNT_H #define MAX_ACCOUNTS 1000 #define NAME_LEN 32 #define ID_LEN 20 typedef struct { char id[ID_LEN]; // 卡号,唯一标识 char name[NAME_LEN]; // 户名 char password[16]; // 密码,明文存储仅用于课程设计演示 double balance; // 余额,单位元 int status; // 0-正常 1-冻结 2-销户 } Account; // 全局账户数组和当前数量 extern Account g_accounts[MAX_ACCOUNTS]; extern int g_account_count; #endif

这里有几个参数需要留意。MAX_ACCOUNTS设成 1000 是课程设计的常见做法,够用且不会让数组太大导致栈溢出;如果你要改成动态链表,这个宏就可以去掉。password用明文存储,这在真实项目里是绝对不行的,但课程设计为了演示方便通常就这么写,后面二次开发章节我会讲怎么改成哈希。status字段是很多简易 ATM 容易漏掉的,有了它才能做冻结和销户,答辩时多一个可演示的状态。

2.2 文件存储:账户文件和流水文件的分工

数据持久化用的是两个文本文件:accounts.dat存账户快照,transactions.log追加交易流水。账户文件在程序启动时全量加载进内存,退出或每次变更后整体回写;流水文件则是只追加不覆盖。

// file_io.c #include <stdio.h> #include <string.h> #include "account.h" // 从账户文件加载到全局数组 int load_accounts(const char *path) { FILE *fp = fopen(path, "r"); if (!fp) { // 文件不存在视为首次运行,不算错误 g_account_count = 0; return 0; } g_account_count = 0; while (g_account_count < MAX_ACCOUNTS && fscanf(fp, "%s %s %s %lf %d", g_accounts[g_account_count].id, g_accounts[g_account_count].name, g_accounts[g_account_count].password, &g_accounts[g_account_count].balance, &g_accounts[g_account_count].status) == 5) { g_account_count++; } fclose(fp); return g_account_count; } // 把全局数组整体写回账户文件 int save_accounts(const char *path) { FILE *fp = fopen(path, "w"); if (!fp) return -1; for (int i = 0; i < g_account_count; i++) { fprintf(fp, "%s %s %s %.2f %d\n", g_accounts[i].id, g_accounts[i].name, g_accounts[i].password, g_accounts[i].balance, g_accounts[i].status); } fclose(fp); return 0; }

fscanf的返回值判断是这里的关键。很多人写文件读取时直接while(!feof(fp)),结果最后一条记录被读两次,这是 C 语言文件操作里最经典的坑之一。用fscanf返回成功匹配的字段数来判断,等于 5 才说明五个字段都读到了,这样能干净地停在文件末尾。save_accounts用"w"模式整体覆盖,适合账户数量不大的场景;如果账户上千,每次改一个余额就全量回写会有点慢,但课程设计规模下完全没问题。

2.3 主循环与菜单分发

主程序是一个典型的“登录前菜单 + 登录后菜单”双层循环。登录前只能开户、登录、退出;登录成功后进入业务菜单,所有操作都围绕当前登录账户的指针展开。

// main.c #include <stdio.h> #include "account.h" #include "auth.h" #include "transaction.h" int main(void) { load_accounts("accounts.dat"); while (1) { printf("\n===== ATM 模拟系统 =====\n"); printf("1. 开户\n"); printf("2. 登录\n"); printf("0. 退出\n"); printf("请选择: "); int choice; scanf("%d", &choice); if (choice == 0) { save_accounts("accounts.dat"); printf("已退出,数据已保存。\n"); break; } else if (choice == 1) { do_register(); } else if (choice == 2) { Account *cur = do_login(); if (cur) { business_loop(cur); // 进入登录后业务循环 } } else { printf("无效选择,请重新输入。\n"); } } return 0; }

business_loop接收的是Account *,这样查询余额、存取款都直接改内存里的结构体,退出业务循环时再统一保存。这种“内存操作为主、文件读写为辅”的模式,比每次操作都打开文件要清晰得多,也避免了频繁 IO 带来的文件损坏风险。菜单分发用if-else而不是switch,是因为登录分支需要拿到返回值再决定是否进入业务循环,switch里做这件事反而绕。

3. 核心业务模块拆解:开户、登录、存取款和转账怎么落地

3.1 开户与登录:卡号生成和密码校验

开户要做三件事:生成不重复卡号、检查容量、写入数组。卡号生成常见做法是用时间戳加随机数,或者直接递增。

// auth.c #include <stdio.h> #include <string.h> #include <stdlib.h> #include <time.h> #include "account.h" static int find_account(const char *id) { for (int i = 0; i < g_account_count; i++) { if (strcmp(g_accounts[i].id, id) == 0) return i; } return -1; } void do_register(void) { if (g_account_count >= MAX_ACCOUNTS) { printf("账户数量已达上限,无法开户。\n"); return; } Account *a = &g_accounts[g_account_count]; // 用时间戳后 8 位加序号生成卡号,降低重复概率 srand((unsigned)time(NULL)); snprintf(a->id, ID_LEN, "62%08d", rand() % 100000000); printf("请输入户名: "); scanf("%s", a->name); printf("请设置密码(6位): "); scanf("%s", a->password); a->balance = 0.0; a->status = 0; g_account_count++; save_accounts("accounts.dat"); printf("开户成功,卡号: %s\n", a->id); } Account *do_login(void) { char id[ID_LEN], pwd[16]; printf("请输入卡号: "); scanf("%s", id); printf("请输入密码: "); scanf("%s", pwd); int idx = find_account(id); if (idx < 0) { printf("卡号不存在。\n"); return NULL; } if (g_accounts[idx].status != 0) { printf("账户已冻结或销户,无法登录。\n"); return NULL; } if (strcmp(g_accounts[idx].password, pwd) != 0) { printf("密码错误。\n"); return NULL; } printf("登录成功,欢迎 %s。\n", g_accounts[idx].name); return &g_accounts[idx]; }

find_account返回下标而不是指针,是为了让调用方能判断-1。登录时先查卡号再验密码,顺序不能反,否则会泄露“卡号存在但密码错”和“卡号不存在”的区别,虽然课程设计不讲究这个,但养成习惯没坏处。snprintf比sprintf安全,能防止卡号字符串溢出,这个细节答辩时容易被问到。

3.2 存取款与转账:余额校验和流水记录

存取款的核心是金额校验和流水追加。转账则多了一步“对方账户是否存在且正常”。

// transaction.c #include <stdio.h> #include <string.h> #include "account.h" static void append_log(const char *id, const char *type, double amount, double after) { FILE *fp = fopen("transactions.log", "a"); if (!fp) return; fprintf(fp, "%s %s %.2f %.2f\n", id, type, amount, after); fclose(fp); } void do_deposit(Account *a) { double amt; printf("请输入存款金额: "); scanf("%lf", &amt); if (amt <= 0) { printf("金额必须大于 0。\n"); return; } a->balance += amt; append_log(a->id, "DEPOSIT", amt, a->balance); save_accounts("accounts.dat"); printf("存款成功,当前余额: %.2f\n", a->balance); } void do_withdraw(Account *a) { double amt; printf("请输入取款金额: "); scanf("%lf", &amt); if (amt <= 0) { printf("金额必须大于 0。\n"); return; } if (amt > a->balance) { printf("余额不足,当前余额: %.2f\n", a->balance); return; } a->balance -= amt; append_log(a->id, "WITHDRAW", amt, a->balance); save_accounts("accounts.dat"); printf("取款成功,当前余额: %.2f\n", a->balance); } void do_transfer(Account *from) { char to_id[ID_LEN]; double amt; printf("请输入对方卡号: "); scanf("%s", to_id); printf("请输入转账金额: "); scanf("%lf", &amt); if (amt <= 0 || amt > from->balance) { printf("金额非法或余额不足。\n"); return; } int idx = -1; for (int i = 0; i < g_account_count; i++) { if (strcmp(g_accounts[i].id, to_id) == 0) { idx = i; break; } } if (idx < 0 || g_accounts[idx].status != 0) { printf("对方账户不存在或状态异常。\n"); return; } from->balance -= amt; g_accounts[idx].balance += amt; append_log(from->id, "TRANSFER_OUT", amt, from->balance); append_log(g_accounts[idx].id, "TRANSFER_IN", amt, g_accounts[idx].balance); save_accounts("accounts.dat"); printf("转账成功,当前余额: %.2f\n", from->balance); }

append_log用"a"模式追加,每次操作写一行,格式是“卡号 类型 金额 操作后余额”。这个流水文件在答辩时很有用,能证明交易确实发生了。转账里先扣款再加款,顺序不能反,否则如果中间出现异常,钱会凭空多出来。虽然单机程序不会真的“中间异常”,但逻辑上先扣后加是更稳妥的写法。每次操作后都调save_accounts,保证内存和文件一致,避免程序崩溃丢数据。

3.3 查询、改密与销户:状态字段的实际用法

查询就是打印当前账户信息;改密要验证旧密码;销户不是真删,而是把status置为 2。

// account_ops.c #include <stdio.h> #include <string.h> #include "account.h" void do_query(Account *a) { printf("\n--- 账户信息 ---\n"); printf("卡号: %s\n", a->id); printf("户名: %s\n", a->name); printf("余额: %.2f\n", a->balance); printf("状态: %s\n", a->status == 0 ? "正常" : (a->status == 1 ? "冻结" : "销户")); } void do_change_pwd(Account *a) { char old[16], new1[16], new2[16]; printf("请输入原密码: "); scanf("%s", old); if (strcmp(old, a->password) != 0) { printf("原密码错误。\n"); return; } printf("请输入新密码: "); scanf("%s", new1); printf("请再次输入新密码: "); scanf("%s", new2); if (strcmp(new1, new2) != 0) { printf("两次输入不一致。\n"); return; } strcpy(a->password, new1); save_accounts("accounts.dat"); printf("密码修改成功。\n"); } void do_close(Account *a) { printf("确认销户?销户后无法登录。输入 YES 确认: "); char confirm[8]; scanf("%s", confirm); if (strcmp(confirm, "YES") != 0) { printf("已取消。\n"); return; } a->status = 2; save_accounts("accounts.dat"); printf("销户成功。\n"); }

销户用状态标记而不是从数组里删除,好处是流水记录还能关联到卡号,也避免了数组删除带来的下标移动问题。改密要求两次输入一致,这是最基本的防误操作设计。查询功能虽然简单,但它是答辩时最常被要求演示的,所以输出格式要清晰,别只打印一个数字。

4. 编译运行与调试:从源码到可执行文件的完整步骤

4.1 目录结构与编译命令

拿到源码后,先确认文件布局。常见做法是按功能拆成几个.c和.h,放在同一目录下。

atm_project/ ├── main.c ├── account.h ├── auth.c ├── auth.h ├── transaction.c ├── transaction.h ├── account_ops.c ├── account_ops.h └── Makefile

如果没有 Makefile,直接用 gcc 编译:

gcc -Wall -g -o atm main.c auth.c transaction.c account_ops.c file_io.c

-Wall打开所有警告,-g保留调试信息。课程设计代码里最常见的问题是隐式声明,比如忘了包含头文件,-Wall会直接告诉你。编译通过后运行:

./atm

首次运行没有accounts.dat,load_accounts会返回 0,程序正常进入菜单。先选 1 开户,再选 2 登录,就能走完整个流程。

4.2 用 Makefile 管理多文件编译

文件一多,手敲 gcc 容易漏文件。写个简单 Makefile 更省事:

CC = gcc CFLAGS = -Wall -g OBJS = main.o auth.o transaction.o account_ops.o file_io.o TARGET = atm $(TARGET): $(OBJS) $(CC) -o $@ $^ %.o: %.c $(CC) $(CFLAGS) -c $< -o $@ clean: rm -f $(OBJS) $(TARGET)

make只编译改过的文件,比每次全量编译快。make clean清掉中间文件。这个 Makefile 是通用模板,后面加新模块只要往OBJS里加对应的.o就行。

4.3 调试:用 gdb 看登录后的账户指针

如果登录后操作没反应,或者余额改错了账户,可以用 gdb 跟一下。

gcc -Wall -g -o atm main.c auth.c transaction.c account_ops.c file_io.c gdb ./atm

在 gdb 里:

(gdb) break do_login (gdb) run (gdb) next (gdb) print g_accounts[0] (gdb) print g_account_count

print g_accounts[0]能直接看到第一个账户的字段值,确认status和balance是不是你预期的。如果g_account_count一直是 0,说明load_accounts没读到文件,检查accounts.dat是否存在、格式是否匹配fscanf的五个字段。调试文件读写问题时,先看文件内容再看代码,往往比反过来快。

5. 避坑与常见问题:文件读写、输入缓冲和状态判断的翻车现场

5.1 现象:程序启动后账户全没了,accounts.dat 变成空文件

原因通常是save_accounts在load_accounts之前被调用,或者fopen用"w"模式打开后没写任何数据就关闭了。还有一种情况是load_accounts读文件失败,g_account_count被置 0,然后某个操作触发保存,直接把空数组写回去,覆盖了原文件。解决方法是:启动时先加载,加载失败不要立刻保存;保存前先判断g_account_count是否大于 0,或者把保存做成“先写临时文件再重命名”的原子操作。

5.2 现象:输入菜单选项时程序疯狂循环,停不下来

这是scanf遇到非法输入后的经典问题。比如菜单要数字,你输入了字母,scanf("%d")匹配失败,字符留在缓冲区里,下一次循环又读到同一个字符,于是无限循环。解决办法是在scanf失败后清空缓冲区:

int choice; if (scanf("%d", &choice) != 1) { while (getchar() != '\n'); // 清掉当前行剩余字符 printf("请输入数字。\n"); continue; }

这个坑几乎每个 C 语言课程设计都会遇到,答辩前一定要测一遍“输入字母会怎样”。

5.3 现象:转账后对方余额没变,或者钱转到了自己头上

先检查find_account的循环边界,i < g_account_count别写成i <= g_account_count,否则会读到未初始化的数组元素。再检查转账时有没有排除“转给自己”的情况,虽然逻辑上自己转自己余额不变,但流水会多两条,演示时显得奇怪。最后确认save_accounts是在两边的余额都改完之后才调用的,如果只改了一边就保存,文件里就是错的。

5.4 现象:密码修改后旧密码还能登录

多半是strcpy的目标写错了,比如复制到了局部变量而不是a->password。还有一种可能是do_change_pwd里比较旧密码时用了=而不是strcmp,if (old = a->password)这种写法在 C 里是合法的,但比较的是指针不是内容,编译器可能只给个警告。打开-Wall能帮你抓到大部分这类问题。

5.5 现象:交易流水文件越来越大,查询时卡顿

transactions.log只追加不清理,跑久了会很大。课程设计阶段一般不会遇到,但如果要长期演示,可以在每次启动时检查文件行数,超过一定数量就归档或截断。更简单的做法是给流水文件按日期分文件,比如transactions_20250101.log,这样单个文件不会无限增长。

6. 二次开发与改造:从课程设计到能拿得出手的小项目

如果你想让这份 ATM 在答辩时更有亮点,或者想拿它当跳板练更真实的开发技能,下面几个改造方向按难度从低到高排。

第一个改造是密码哈希。现在密码明文存在accounts.dat里,任何人打开文件都能看到。你可以引入一个简单的哈希函数,比如把密码每个字符乘一个质数再累加,存哈希值而不是原文。登录时对输入做同样计算再比较。这不需要第三方库,几十行代码就能搞定,但能让答辩老师看到你有安全意识。

第二个改造是账户数据改用二进制文件。文本文件可读性好,但fscanf对格式敏感,多一个空格就可能读错。改成fwrite/fread直接写结构体,读写更快,也不怕格式漂移。注意二进制文件里存的是内存布局,跨平台可能有问题,但课程设计都在同一台机器上跑,影响不大。

第三个改造是把固定数组换成动态链表或动态数组。现在MAX_ACCOUNTS是 1000,开户超过就失败。改成malloc按需扩容,或者直接用链表,能支持任意数量账户。链表的插入删除也顺便练了指针操作,答辩时能讲的东西更多。

第四个改造是加一个管理员模式。普通用户只能操作自己的账户,管理员可以冻结/解冻任意账户、查看所有流水、按卡号查询。这需要在登录时区分角色,可以在Account里加一个role字段,或者单独存一个管理员密码。管理员模式能让你的项目从“单用户 ATM”变成“带后台的简易银行系统”,演示效果提升明显。

第五个改造是加单元测试。C 语言没有现成的测试框架,但你可以写一个test.c,里面直接调用do_deposit、do_withdraw这些函数,用断言检查余额变化。编译时把业务代码和测试代码一起编,跑一遍看断言有没有失败。这能让你在改代码时快速发现回归问题,也是从“写完能跑”到“改完不崩”的关键一步。

我自己的习惯是,每次改完核心逻辑,先不急着跑完整菜单,而是写个几行的临时main直接调那个函数,打印关键变量。比如改完转账逻辑,就手动构造两个账户,调一次do_transfer,看两边余额和流水对不对。这个习惯帮我省了很多“菜单点半天才发现逻辑错”的时间。从那以后我每次动文件读写相关的代码,都强制先备份一份accounts.dat再跑,免得调试过程中把演示数据覆盖了。希望这份拆解能帮你把课程设计跑起来,也顺手改出点自己的东西。

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