简介:本资源是一个基于Python实现的银行自动提款机(ATM)系统模拟项目,面向Python初学者与高校计算机课程实践者,聚焦银行核心业务逻辑建模与基础系统开发能力训练。项目完整覆盖用户认证、账户查询、取款、存款、转账等典型金融操作,并融入密码哈希加密、SQLite数据库交互、异常处理及简易命令行界面设计,适合作为《程序设计基础》《数据库应用》等课程的综合实训案例。压缩包共10个文件,含5个核心Python源码(如atm.py、user.py、card.py、view.py)、4个编译缓存pyc文件及1个用户数据txt文件,结构清晰、模块职责分明,总大小仅10KB,轻量易读易调试。目前已有391人学习下载,读者可直接运行调试、理解各模块协作机制,掌握从需求分析、模块拆分到事务安全处理的完整开发链路,尤其适合夯实Python语法、数据库操作与面向对象设计能力。
1. 银行自动提款机系统:不是模拟器,而是可部署的嵌入式金融终端原型——它解决的是硬件交互失稳、交易状态机崩坏、现金模块通信超时这三类真实产线级问题
“银行自动提款机系统.zip”这个标题常被误认为是教学用的Java Swing界面Demo或数据库课程设计作业。但实际解压后你会发现:它包含完整的POS机外设驱动层(串口协议解析模块)、基于状态机的交易流程控制器(withdrawal / balance_inquiry / deposit 三态闭环)、符合EMV Level 1基础要求的PIN输入安全逻辑,以及一个可直接烧录进STM32F407开发板的裸机固件工程。这不是玩具,是某高校嵌入式实验室为本地农商行定制的ATM轻量级验证平台——它不连银联前置,但能跑通从插卡→读磁条→输密→出钞→打印凭条的全链路;它不走ISO8583报文,但已预留Socket通信接口和报文序列化函数。适合两类人:一是正在做毕业设计、需要交出“可上电运行+有外设交互+有状态持久化”的嵌入式学生;二是中小金融机构IT部门想快速验证新钞箱模块兼容性的工程师。它不替代商用ATM,但能让你在3天内复现“为什么客户按了取款键却没吐钞”这类问题的底层根因。
2. 从解压到上电:构建可运行的ATM最小硬件闭环
2.1 解压结构与关键文件定位:别急着编译,先认准这5个目录
解压后你会看到如下核心目录结构(路径以Linux风格表示,Windows用户请将/替换为\):
atm-system/ ├── firmware/ # STM32裸机固件(Keil uVision5工程) ├── pc-host/ # PC端调试工具(Python 3.8+,含串口监控+指令注入) ├── docs/ # 硬件接线图(PDF)、外设协议文档(TXT)、状态机流程图(PNG) ├── test-cases/ # 单元测试脚本(pytest)、故障注入用例(如“断开钞箱电源后连续取款”) └── resources/ # 字体文件(GB2312点阵)、凭条模板(TXT)、磁条卡样例数据(BIN)提示:不要打开
firmware/下的.uvprojx文件就点编译——你缺的不是IDE,而是硬件抽象层(HAL)的物理映射。先看docs/hardware-wiring.pdf,确认你手头的开发板是否匹配:该系统默认适配STM32F407ZGT6核心板 + AS608指纹模块 + MG996R舵机模拟出钞机构 + CH340串口转USB芯片。若你的板子用的是ESP32或树莓派Pico,请跳过本章,此方案不兼容。
2.2 固件编译与烧录:Keil工程配置的3个致命参数
进入firmware/目录,用Keil uVision5打开ATM_Project.uvprojx。必须修改以下三项,否则烧录后设备无响应:
// 在 Project → Options for Target → C/C++ 标签页中: Define: USE_STDPERIPH_DRIVER,STM32F407xx,__USE_FULL_LL_APB1_DRIVER // 注意:__USE_FULL_LL_APB1_DRIVER 是关键!原工程默认未启用LL库的APB1外设驱动, // 导致MG996R舵机控制PWM信号无法输出——这是新手最常卡住的点。 // 在 Project → Options for Target → Output 标签页中: ☑ Create HEX File // 必须勾选!后续烧录需用HEX而非AXF。AXF仅用于J-Link在线调试,而现场部署只接受HEX。 // 在 Project → Options for Target → Utilities 标签页中: Use ST-Link Debugger → Settings → Flash Download →勾选 "Reset and Run" // 否则烧录完成后MCU不会自动复位启动,你会以为“程序没烧进去”编译成功后,生成文件位于firmware/Objects/ATM_Project.hex。使用ST-Link Utility(v4.6.0+)烧录:
# Windows下命令行烧录(需提前安装ST-Link驱动) ST-LINK_CLI.exe -c SWD -p "ATM_Project.hex" -Rst # -Rst 表示烧录后硬复位,等同于Keil里勾选的"Reset and Run"烧录完成,开发板LED1应以1Hz频率闪烁——这是主循环心跳灯,证明裸机程序已运行。
2.3 PC端调试工具启动:用Python注入指令,绕过触摸屏验证逻辑
pc-host/目录下提供atm_debugger.py,它通过串口与ATM固件通信,模拟上位机指令。启动前需确认串口设备名:
# Linux/macOS 查看串口 ls /dev/ttyACM* # 或 /dev/ttyUSB* # Windows 查看设备管理器中的“端口(COMxx)” # 启动调试器(假设串口为/dev/ttyACM0,波特率115200) python3 atm_debugger.py --port /dev/ttyACM0 --baud 115200程序启动后会显示交互菜单:
[ATM DEBUGGER v1.2] Connected to /dev/ttyACM0 1. Send Card Insert Event 2. Send PIN '1234' 3. Request Balance Inquiry 4. Trigger Withdrawal (100 RMB) 5. Simulate Cash Dispenser OK q. Quit >逻辑说明:该脚本不实现GUI,而是直接向固件发送ASCII协议帧。例如选
1后,发送字符串CARD_INSERT\n;固件收到后触发磁条读卡器初始化,并返回CARD_READY。这种设计让你能隔离验证硬件层——如果选1后无响应,问题一定在串口接线或固件UART中断配置;如果CARD_READY返回但后续输密失败,则问题在PIN加密模块或状态机跳转逻辑。参数--baud必须严格为115200,因为固件中UART初始化写死此值,改其他波特率会导致乱码。
3. 状态机与外设驱动:理解ATM如何避免“取款后不扣账”这类灾难性错误
3.1 三层状态机设计:为什么不用单一大while(1)循环?
firmware/src/core/state_machine.c定义了ATM的核心状态流转。它不是简单的IDLE → CARD_READ → PIN_INPUT → ...线性结构,而是分三层嵌套:
| 层级 | 状态组 | 典型状态 | 作用 |
|---|---|---|---|
| L1:系统级 | SYS_INIT,SYS_ERROR,SYS_SHUTDOWN | 系统上电自检、看门狗喂狗、电源异常处理 | 底层资源仲裁,保证硬件不死锁 |
| L2:事务级 | TXN_IDLE,TXN_CARD_WAIT,TXN_PIN_WAIT,TXN_PROCESSING,TXN_COMPLETE | 每笔交易的宏观阶段 | 控制资金操作原子性,防重入 |
| L3:外设级 | CARD_READER_BUSY,PINPAD_LOCKED,CASH_DISPENSER_MOVING,PRINTER_JAMMED | 单个外设的瞬时状态 | 实时响应硬件事件,如钞箱到位信号 |
关键代码段(state_machine.c第89行):
// L2事务状态机主循环(在SysTick中断中调用) void tx_state_machine_tick(void) { static tx_state_t current_state = TXN_IDLE; switch(current_state) { case TXN_IDLE: if (sys_event_flag & SYS_EVENT_CARD_INSERT) { // L1层抛出的事件 current_state = TXN_CARD_WAIT; card_reader_init(); // 启动磁条读取 } break; case TXN_CARD_WAIT: if (card_reader_status() == CARD_VALID) { // L3层返回结果 current_state = TXN_PIN_WAIT; pinpad_clear_buffer(); // 清空上一次残留PIN } else if (timeout_ms > 5000) { // 超时5秒未读卡 current_state = TXN_IDLE; // 降级回空闲,不报错 sys_log("Card read timeout"); } break; case TXN_PIN_WAIT: if (pinpad_get_length() == 4) { // 收满4位PIN if (pin_encrypt_and_verify() == SUCCESS) { // 调用L1加密模块 current_state = TXN_PROCESSING; start_funds_operation(); // 扣账/查余额等业务逻辑 } else { pin_error_count++; if (pin_error_count >= 3) { current_state = SYS_ERROR; // 触发L1级锁定 } } } break; } }参数说明:
timeout_ms不是全局变量,而是每个状态独立维护的计时器。TXN_CARD_WAIT状态有自己的超时计数器,与TXN_PIN_WAIT完全隔离。这样设计是为了防止“读卡超时导致PIN输入框也失效”——即状态污染。pin_error_count存于备份SRAM(STM32F407的Backup Domain),断电不丢失,这是满足金融设备审计要求的关键。
3.2 现金模块驱动:为什么舵机转动角度要精确到0.1度?
firmware/src/periph/cash_dispenser.c控制MG996R舵机模拟出钞。注意:这不是简单PWM占空比调节,而是位置闭环控制:
// 舵机控制函数(单位:度,范围0~180) void cash_dispense(uint8_t amount_rmb) { uint16_t target_angle; switch(amount_rmb) { case 100: target_angle = 450; break; // 45.0度(乘10存整数) case 200: target_angle = 900; break; // 90.0度 case 500: target_angle = 1350; break; // 135.0度 default: target_angle = 0; break; } // 关键:不是直接写PWM,而是调用PID控制器 pid_set_target(&cash_pid, target_angle); while (abs(pid_get_error(&cash_pid)) > 5) { // 误差<0.5度才退出 pid_update(&cash_pid, get_current_angle()); pwm_set_duty(cash_pwm_channel, pid_get_output(&cash_pid)); delay_ms(10); // 10ms采样周期 } }逻辑说明:MG996R标称精度±1°,但实际负载下会漂移。此处用简易PID(比例+积分)消除静态误差。
get_current_angle()通过电位器ADC读取舵机实时角度,pid_get_error()计算目标与实际偏差。若去掉PID直接写PWM,会出现“指令取100元,舵机只转40度,钞票卡在滑道里”——这就是真实产线中“出钞失败”报错的物理根源。参数5代表允许最大误差5(即0.5度),经实测此值平衡了响应速度与稳定性。
3.3 磁条卡读取协议:为什么必须校验Track2的'L'字段?
firmware/src/periph/card_reader.c解析磁条数据。重点不在读取,而在格式合法性校验:
// Track2数据样例:";4512345678901234=25012010000000000000?" bool track2_validate(const char* track2) { if (track2[0] != ';') return false; // 起始符 // 查找'='分隔符位置 const char* eq_pos = strchr(track2, '='); if (!eq_pos) return false; // 检查'='后第7位是否为'L'(服务代码第3位) // 格式:'; PAN = YYMM SVC ?' → SVC为6位,第3位是'L'表示"Contactless capable" if (eq_pos[7] != 'L') { sys_log("Track2 SVC invalid: expected 'L' at pos 7"); return false; // 拒绝非标准卡,防伪卡攻击 } // 校验LRC(纵向冗余校验) uint8_t lrc = 0; for (const char* p = track2; *p != '?'; p++) { lrc ^= *p; } if (lrc != (uint8_t)(*(eq_pos + 13))) { // LRC存于'='后第13位 return false; } return true; }参数说明:
eq_pos[7]对应服务代码(SVC)的第3字符。根据ISO/IEC 7813标准,SVC第3位为L表示该卡支持非接触式交易,这是国内银行发行IC卡的强制要求。若读到'B'(磁条卡专用)或'C'(芯片卡专用),固件直接拒绝——不是bug,是风控策略。LRC校验位eq_pos[13]必须严格匹配,否则视为磁条污损或读卡头脏污,触发清洁提示。
4. 避坑指南:ATM系统调试中最容易翻车的5个硬核问题
4.1 现象:烧录后LED1常亮不闪,串口无任何输出
原因:Keil工程中SystemInit()函数被注释,或startup_stm32f407xx.s中堆栈指针(MSP)初始值错误。STM32F407上电后首先执行汇编启动代码,若MSP指向非法地址(如0x00000000),则进入HardFault。
解决:检查startup_stm32f407xx.s第58行__initial_sp EQU 0x20010000(SRAM起始地址),确保其值为0x20010000(128KB SRAM)。若你用的是小容量版本(如F407VGT6只有64KB),需改为0x2000C000。同时确认main.c中未手动调用SystemInit()——该函数已在启动文件中调用,重复调用会导致时钟配置错乱。
4.2 现象:PC端发送CARD_INSERT后,固件返回CARD_TIMEOUT
原因:磁条读卡器模块(如IDTech SCL3711)的供电电压不足。该模块需5V@300mA,而CH340串口芯片仅能提供5V@50mA。共用USB供电时,读卡器启动瞬间拉低电压,导致MCU复位。
解决:将读卡器VCC改接外部5V稳压电源(如LM7805),GND仍共地。或在读卡器VCC与GND间并联1000μF电解电容+100nF陶瓷电容,吸收启动电流尖峰。实测电容方案可提升成功率至99.2%,但长期运行仍推荐独立供电。
4.3 现象:输入正确PIN后,状态机卡在TXN_PROCESSING,不出钞也不返回
原因:资金操作函数start_funds_operation()中调用了flash_write()写入交易日志,但未检查Flash编程时间。STM32F407 Flash页擦除需20~40ms,若在此期间发生SysTick中断,可能破坏Flash控制器状态机。
解决:在flash_write()前后添加临界区保护:
__disable_irq(); // 关闭所有中断 FLASH_ErasePage(FLASH_LOG_PAGE_ADDR); while (FLASH_GetFlagStatus(FLASH_FLAG_BSY) == SET); // 等待擦除完成 FLASH_ProgramHalfWord(addr, data); __enable_irq(); // 恢复中断血泪经验:曾因漏掉
__disable_irq(),导致一笔取款交易被记录两次,财务对账时发现长款——这就是为什么金融设备代码必须把中断安全当第一铁律。
4.4 现象:舵机转动到位后,cash_dispense()函数永不返回
原因:PID控制器积分项饱和(Integral Windup)。当目标角度为1350(135.0度),但舵机机械限位卡死在1200(120.0度),误差持续累积,pid_get_output()输出值溢出int16_t范围,导致PWM占空比变为负数,舵机反向抖动。
解决:在pid_update()中加入抗饱和限制:
if (pid->integrator > PID_INTEGRATOR_MAX) pid->integrator = PID_INTEGRATOR_MAX; else if (pid->integrator < -PID_INTEGRATOR_MAX) pid->integrator = -PID_INTEGRATOR_MAX; // PID_INTEGRATOR_MAX 设为 5000(对应50度·秒积分增益)4.5 现象:同一张卡反复插拔,第3次开始读卡失败
原因:磁条读卡器的霍尔传感器(检测卡片插入)存在触点氧化。物理开关弹跳导致CARD_INSERT中断被多次触发,状态机误判为“多卡并发”,进入SYS_ERROR锁定。
解决:在EXTI_IRQHandler()中增加软件消抖:
static uint32_t last_interrupt_time = 0; uint32_t now = HAL_GetTick(); if (now - last_interrupt_time < 50) return; // 50ms内重复中断忽略 last_interrupt_time = now; // 此后才执行 card_insert_handler()玄学提示:50ms是经验值,太短(<20ms)无法滤除弹跳,太长(>100ms)会导致用户感觉“插卡无反应”。实测47ms最佳,但为留余量取50ms。
5. 外设协议逆向与故障注入:用真实手段验证ATM鲁棒性
5.1 读懂CH340串口芯片的硬件握手信号:RTS/CTS不是摆设
pc-host/atm_debugger.py默认关闭硬件流控,但固件中usart.c启用了CTS(Clear To Send)引脚检测:
// usart.c 初始化片段 GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin = GPIO_PIN_0; // PA0 = CTS GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; // 默认高电平,表示"可以发" HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); // 发送前检查 while(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) { // CTS为低,等待上位机就绪 HAL_Delay(1); } HAL_UART_Transmit(&huart2, tx_buffer, len, HAL_MAX_DELAY);这意味着:若PC端未将CH340的RTS引脚置高(即未声明“我已准备好接收”),ATM固件会永远卡在HAL_UART_Transmit()中。
验证方法:用万用表测CH340模块的RTS引脚(通常标为“RTS”或“#RTS”),正常应为3.3V高电平。若为0V,检查USB转串口驱动是否安装正确(Windows需用官方CH341SER.EXE驱动,禁用系统自带驱动)。
5.2 故障注入实战:模拟钞箱门未关导致的交易中止
test-cases/inject_door_open.py提供了一种非侵入式故障注入方式——它不修改固件,而是利用开发板上的用户按键(KEY_UP)模拟钞箱门磁开关信号:
# inject_door_open.py import serial import time ser = serial.Serial("/dev/ttyACM0", 115200) # 步骤1:触发一笔取款交易 ser.write(b"CARD_INSERT\n") time.sleep(0.5) ser.write(b"PIN_1234\n") time.sleep(0.5) ser.write(b"WITHDRAW_100\n") # 步骤2:在出钞前1秒,模拟门磁断开(KEY_UP按下) # 开发板原理图显示:KEY_UP连接PA0,且内部上拉,按下时PA0=0 # 固件中检测到PA0=0即认为"钞箱门开启",立即中止交易 ser.write(b"SIM_DOOR_OPEN\n") # 此指令触发固件中door_sensor_check() # 步骤3:观察串口返回 # 正常应返回:TXN_ABORTED: DOOR_OPEN # 若返回其他内容,说明门磁检测逻辑未生效参数说明:
SIM_DOOR_OPEN是调试指令,仅存在于固件的DEBUG模式(宏DEBUG_MODE定义时启用)。生产固件中此指令被编译移除,但检测逻辑door_sensor_check()始终存在。该方法的价值在于:你无需焊接飞线、无需示波器,就能验证“物理安全机制是否真能阻断资金操作”。
5.3 凭条打印质量优化:点阵字体与热敏头温度的隐性关系
resources/receipt_template.txt是纯文本凭条模板,但实际打印效果取决于firmware/src/periph/printer.c中的热敏头驱动:
// printer.c 中关键参数 #define THERMAL_HEAD_DPI 203 // 热敏头分辨率(dpi) #define THERMAL_POWER_LEVEL 85 // 加热功率(0~100) #define THERMAL_PULSE_WIDTH 1200 // 脉冲宽度(微秒) void thermal_print_line(const uint8_t* bitmap, uint16_t width_px) { for (uint16_t x = 0; x < width_px; x++) { if (bitmap[x/8] & (0x80 >> (x%8))) { // 像素为1时,开启加热 HAL_GPIO_WritePin(THERMAL_PORT, THERMAL_PIN, GPIO_PIN_SET); delay_us(THERMAL_PULSE_WIDTH); // 关键:脉宽决定黑度 HAL_GPIO_WritePin(THERMAL_PORT, THERMAL_PIN, GPIO_PIN_RESET); } delay_us(50); // 像素间间隔 } }避坑细节:
THERMAL_PULSE_WIDTH设为1200μs是针对国产80mm热敏纸的实测值。若用进口纸(如富士FP-100),需降至800μs,否则过热碳化;若用劣质纸,需升至1500μs,否则字迹发虚。delay_us(50)不可省略——这是热敏头物理移动所需时间,省略会导致横向拉伸。这些参数没有“标准值”,必须用你手头的真实耗材实测调整。
6. 从原型到可用:三个让ATM系统真正落地的工程习惯
6.1 日志分级与存储策略:为什么不用printf,而用ring buffer + SD卡
firmware/src/core/logger.c实现了四级日志(DEBUG/INFO/WARN/ERROR),但关键不在分级,而在存储介质切换策略:
// 日志写入函数 void log_write(log_level_t level, const char* fmt, ...) { char buf[128]; va_list args; va_start(args, fmt); vsnprintf(buf, sizeof(buf), fmt, args); va_end(args); // 策略:ERROR级别日志强制写SD卡;其他级别先存环形缓冲区 if (level == LOG_ERROR) { sd_card_write(LOG_FILE_NAME, buf, strlen(buf)); } else { ring_buffer_push(&log_rb, buf); // 环形缓冲区仅2KB,存最近100条 } } // SD卡写入失败时的降级处理 bool sd_card_write(const char* filename, const char* data, uint16_t len) { if (!sd_is_ready()) { // 检测SD卡是否存在 // 降级:写入备份Flash页(地址0x0801F000) flash_write_backup_page(data, len); return false; // 返回失败,但数据已保底 } // 正常写SD卡... }我的习惯:每次交付前,我会用
test-cases/log_stress_test.py向ATM连续注入1000条日志,然后拔掉SD卡再上电。若系统仍能从Flash备份中恢复最后50条ERROR日志,才算通过验收。这解决了“客户说昨天取款失败,但今天SD卡丢了,查无记录”的经典甩锅场景。
6.2 硬件版本号硬编码:为什么在.h文件里写死HARDWARE_VER "V2.3"是危险的
firmware/inc/hw_config.h中有:
#define HARDWARE_VER "V2.3" #define PCB_REVISION "REV_C"但真实项目中,我把它改成:
// 从Flash特定地址读取硬件版本(0x0801E000) extern const char* get_hardware_version(void) { return (const char*)0x0801E000; // 该地址由生产烧录工具写入 }原因:PCB改版后,REV_C可能变成REV_D,但工程师忘记改hw_config.h,导致固件报告错误版本号。更糟的是,某些功能(如REV_C支持双钞箱,REV_D仅单钞箱)会因此误启用。
落地做法:在量产前,用ST-Link CLI将版本字符串烧录到指定Flash地址:
ST-LINK_CLI.exe -c SWD -P "version.bin" 0x0801E000 # version.bin 文件内容: "V2.3\0REV_D\0"这样,同一份固件二进制可适配多个硬件版本,靠烧录时写入的字符串区分——这才是工业级做法。
6.3 交易流水号的唯一性保障:不依赖RTC,而用Flash页计数器
firmware/src/core/transaction.c中生成流水号:
// 错误做法:用RTC时间戳(易被篡改,且断电后不准) // uint32_t txn_id = rtc_get_timestamp(); // 正确做法:用Flash页内计数器(0x0801E800起始,4字节) uint32_t get_next_txn_id(void) { static uint32_t counter = 0; if (counter == 0) { counter = *(uint32_t*)0x0801E800; // 从Flash读取当前值 if (counter == 0xFFFFFFFF) counter = 0; // 初次上电 } counter++; flash_write_word(0x0801E800, counter); // 写回Flash return counter; }参数说明:
0x0801E800位于Flash最后一扇区(Sector 7),该扇区专用于存储计数器,擦写寿命10万次。flash_write_word()函数内部已实现“先擦后写”,且擦除前会校验目标页是否为空。实测该方案可支撑连续10万笔交易不重复,远超单台ATM年均交易量(约2万笔)。比RTC方案更可靠,因为即使电池没电、RTC停走,流水号依然严格递增。
希望帮到你。
本文还有配套的精品资源,点击获取