简介:基于STM32的自动售货机完整工程项目,包含源代码与文档说明,适合电子信息、自动化、计算机等专业学生用于课程设计、毕业设计或项目初期演示。项目基于STM32标准库开发,覆盖定时器、Flash、RCC、ADC、I2C、CAN、USART等常用外设的驱动配置,代码按功能模块拆分,结构清晰、可读性好,便于二次扩展。压缩包内共有229个文件,以C源文件、头文件、编译生成的o/hex目标文件、Keil工程文件以及Markdown说明文档为主,整体约8.07MB,下载后可直接使用Keil打开工程完成编译、烧录和验证,省去环境搭建与驱动移植的重复工作。目前已有293人学习下载,代码均经过实际运行测试,既适合嵌入式初学者对照源码理解外设初始化和售货机控制逻辑,也可作为课程设计、毕业设计的基础框架,进一步扩展支付模块、联网功能或更多货道联动控制,提升项目的完整度与创新性。
1. 基于STM32的自动售货机:先立好主控、电机与状态机的三角关系
很多人以为自动售货机就是把货道电机转一圈,货掉下来,收钱走人。真正动手之后才发现,电机转起来只占整个工程的一小部分,最难的是回答三个问题:电机有没有把货真正推出去、支付结果怎么可靠地接进来、整个售货流程在异常情况下怎么回到安全状态。基于STM32的自动售货机项目,本质上是用一块F103级别的芯片,把PWM驱动、ADC电流采样、串口通信和有限状态机串成一个完整产品。对于刚做完流水灯和温湿度传感器的开发者,这是一个能摸到产品边界的进阶项目;对课程设计和毕业设计,这套东西的源码量、文档量和可展示性也都非常合适。下面这套方案从硬件选型开始讲,逐步落到代码结构和调试方法,最后给出参数整定和文档组织的具体建议。
2. STM32自动售货机的硬件选型与出货电路设计
2.1 从最小系统到货道驱动:STM32自动售货机需要哪些功能模块
先明确这台机器的功能边界。常见的弹簨货道自动售货机有十几个货道,每个货道一个直流减速电机,电机旋转带动螺旋弹簧把货品推前,掉落到取货口。主控需要做四件事:接收按键选货信号、与支付模块完成金额确认、控制对应货道电机转动、判断货是否掉出。
主控芯片一般选STM32F103C8T6。理由不复杂:72MHz主频跑状态机和通信绰绰有余,LQFP48封装手工焊接可行,网上参考资料最多,做毕业设计还有现成的开发板和仿真器生态。源码组织上遵循“驱动层、业务层、应用层”三层结构,驱动层只操作寄存器或HAL库,业务层跑状态机和超时逻辑,应用层管菜单和日志。
货道驱动是硬件的关键点。单路电机工作电流约0.3A到1A,直接用IO口驱动不可能,需要H桥或电机驱动芯片。常见做法是用TB6612FNG,单颗芯片内置两路H桥,峰值输出1.2A,逻辑电压和电机电压分离,比L298N的总线压降小、发热低,也不必在原理图上画一堆分立MOS管。一个四路货道的小机器用两颗TB6612就够了。
2.2 直流电机不会自己报告状态,出货检测要靠采样电阻
电机驱动本身不复杂,难的是“出货成功”怎么判断。低成本方案中扛大梁的是电流检测:直流有刷电机运转时电流大致能反映负载大小。弹簧空转、推着货走、货卡住三种状态,电流特征分得开。
具体做法是在电机低端串一支采样电阻。电机电流流过分流电阻产生压降,经过运放放大后送入STM32的ADC。采样电阻用0.1欧姆/1W的贴片电阻,放大倍数10倍,那么ADC输入电压与电流的关系是1A对应1V。STM32的ADC参考电压为3.3V,折算下来可测最大电流约3.3A,留足裕量。
需要特别注意的是电源分区。电机瞬态启动电流会拉低母线电压,如果主控和电机共用一组电源,ADC采样和Flash写入会出现偶发异常。正确做法是外部适配器12V输入后分两路:一路经过DC-DC降压到5V给逻辑电路,另一路直接给电机驱动。两路地在电源入口处单点相连,避免地环流。
2.3 IO分配表,出货前先把引脚表定死
引脚规划决定了后面布线和代码的改动量,建议在画原理图之前就把引脚功能表定死。以下是一份四货道加扫码支付的最小分配方案:
| 功能模块 | 引脚 | 配置说明 |
|---|---|---|
| 电机1方向/使能 | PA0、PA1 | 推挽输出,控制TB6612的AIN1/AIN2 |
| 电机1 PWM | PA8 | 定时器1通道1,输出10kHz PWM |
| 电机2方向/使能 | PA2、PA3 | 推挽输出 |
| 电机2 PWM | PA9 | 定时器1通道2 |
| 电流采样ADC | PA4、PA5 | ADC1通道4/通道5,采样两路电机电流 |
| 4x4矩阵按键 | PB0~PB7 | 输入带上拉,行列扫描 |
| OLED显示屏 | PB8、PB9 | I2C1,地址0x3C |
| 支付模块串口 | PA9、PA10 | USART1,115200 8N1 |
| 调试日志串口 | PA2、PA3 | USART2,115200 |
| EEPROM存储 | PB10、PB11 | I2C2,AT24C02地址0xA0 |
注意USART1的PA9与定时器1通道2复用冲突,所以实际项目里PWM和串口要错开引脚,上面表格只是一种示意。定引脚时第一件事是打开CubeMX的引脚冲突检查,第二件事是把所有复用功能列成一张表贴在工位前面。
2.4 初始化代码:让定时器、ADC和GPIO先跑起来
引脚和时钟配置用CubeMX生成骨架,但关键参数要自己心里有数。PWM频率选择10kHz,理由是两个:一是避开人耳敏感频段,电机轴和弹簧不会发出刺耳啸叫;二是H桥开关损耗在10kHz级别还可以接受。占空比初始给80%,电机在12V下转速约等于额定值的八成,既能保证推力又不会让弹簧高速空转导致货品飞出。
void Motor_Init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_TIM1_CLK_ENABLE(); // 电机方向引脚 PA0/PA1 配置为推挽输出 gpio.Pin = GPIO_PIN_0 | GPIO_PIN_1; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOA, &gpio); // PWM 引脚 PA8 配置为复用功能 gpio.Pin = GPIO_PIN_8; gpio.Mode = GPIO_MODE_AF_PP; gpio.Alternate = GPIO_AF1_TIM1; HAL_GPIO_Init(GPIOA, &gpio); // TIM1 输出 10kHz PWM,默认占空比 80% // 时钟 72MHz,PSC=7,ARR=899,得到频率 = 72M/(8*900) = 10kHz htim1.Init.Prescaler = 7; htim1.Init.Period = 899; htim1.Init.CounterMode = TIM_COUNTERMODE_UP; HAL_TIM_PWM_Init(&htim1); TIM_OC_InitTypeDef ocp = {0}; ocp.OCMode = TIM_OCMODE_PWM1; ocp.Pulse = 720; // 900*80% ocp.OCPolarity = TIM_OCPOLARITY_HIGH; HAL_TIM_PWM_ConfigChannel(&htim1, &ocp, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); }代码里设置的预分频和自动重装值决定了PWM频率,修改时按公式Fpwm = 72MHz / ((PSC+1) * (ARR+1))自行换算。Pulse字段就是比较寄存器值,改变它可以动态调占空比。经验是占空比不要给到100%,堵转时满占空比会让电流直接顶到电源上限,给控制逻辑留不出区分空间。
3. 用电流特征判定出货结果:电机驱动与掉货检测的实现
3.1 弹簧货道的负载特征与电机选型
弹簧货道属于典型的LIFO出货结构,最前排的货品被螺旋弹簧向前推进,货品落下后弹簧继续空转。一个出货循环里,电机负载经历了“启动浪涌、推货带载、空转”三个阶段。启动浪涌电流可达工作电流的3到5倍,持续几十毫秒;推货过程电流比空转高出20%到50%;如果货品卡住,电流会持续保持在高位不回落。
电机选型方面,货道电机是带减速箱的直流永磁电机,减速比约1:50到1:100,输出轴转速30到60转/分钟,这样弹簧才能缓慢推动货品。工作电压是12V还是24V取决于机器规格,这里以12V电机为例,24V方案只需改电源电压和PWM占空比标定,判定逻辑完全一致。
3.2 电流采样与中值滤波:别让启动浪涌骗了你
ADC采样的难点不在硬件,在数据处理。电机刚启动时的浪涌电流和堵转电流在数值上很像,如果以“电流超过阈值”作为卡货判据,点一下启动就会误判。处理办法是加时间窗:电机启动后一段时间内不判定,等浪涌过去再开始采样。
#define MOTOR_START_DELAY_MS 90 // 跳过启动浪涌 #define MOTOR_OVER_CURRENT_MS 300 // 持续超流判定时间 #define MOTOR_MAX_RUN_MS 8000 // 最大运行时间 #define CURRENT_THRESHOLD_MA 450 // 堵转阈值,实测后标定 void Motor_CurrentTask(void) { static uint32_t overCurrentCount = 0; // 电机启动前90ms不采样,避免浪涌误判 if (HAL_GetTick() - motorStartTick < MOTOR_START_DELAY_MS) { return; } uint16_t adcValue = ADC_GetAverage(ADC_CH_CURRENT); // 已做中值滤波 uint16_t currentMa = (uint16_t)(adcValue * 1000 / adc1maFactor); if (currentMa > CURRENT_THRESHOLD_MA) { overCurrentCount++; } else { overCurrentCount = 0; } // 连续300ms超流判定为卡货 if (overCurrentCount * 5 >= MOTOR_OVER_CURRENT_MS) { Motor_Stop(); Machine_GoToState(STATE_FAULT); } else if (HAL_GetTick() - motorStartTick > MOTOR_MAX_RUN_MS) { // 超时强制停止,按出货失败处理 Motor_Stop(); Machine_SetError(ERR_VEND_TIMEOUT); } }采样采用滑动中值滤波:连续采17个点排序取中间值,能滤掉换向器火花和电源纹波带来的尖峰,同时不引入相位延迟。上电后先做一次ADC零点标定,把零电流对应的ADC值存到EEPROM,防止运放偏置电压造成温漂误判。
3.3 出货判定参数表:空转、带载、堵转的区别
做完硬件后第一步不是写业务逻辑,而是把每个货道空转和带载的电流数据测出来。下面是一张12V减速电机的典型记录表,实际数值以你的减速比和弹簧间距为准:
| 状态 | 电流范围 | 持续时间 | 判定策略 |
|---|---|---|---|
| 空转 | 120~180mA | 整个循环 | 正常出货,可接受 |
| 推货带载 | 220~350mA | 约1~3秒 | 正常出货,不需要额外处理 |
| 启动浪涌 | 600~900mA | 30~80ms | 前90ms不采样 |
| 堵转 | 400~600mA | 持续 | 判定卡货,停止并退币 |
这里的核心思想是阈值取“带载最大值”和“堵转最小值”的中间值,两层都留出余量。如果两个区间重叠,优先调低减速比或更换电机型号,而不是靠软件硬切。弹簧间距过大、货品过重都会造成电流区间漂移,这是机械和电气相互妥协的过程。
3.4 堵转后的反向抖落:提高出货成功率的一个小技巧
直流电机堵转并不总是真卡死,有时候只是货品卡在弹簧和挡板之间的临界点。碰到这种情况,一个有效手段是让电机反方向转300毫秒再正转。反向转动会让弹簧稍微松退,给货品重新调整位置的空间。
void Motor_Unblock(void) { // 反向后正转,每次300ms Motor_SetDirection(MOTOR_REVERSE); Motor_SetDuty(60); HAL_Delay(300); Motor_SetDirection(MOTOR_FORWARD); Motor_SetDuty(80); motorStartTick = HAL_GetTick(); // 重置启动计时,重新走电流判定 }注意反向时间不能太长,否则弹簧会把后面的货往回退,打乱货道排列。这个功能做成可配置项,通过参数表决定启用还是关闭,有些货道弹簧卡得紧,反向反而会把出货逻辑搞乱。
4. 状态机与支付协议:让整个售货流程可维护
4.1 状态划分与转移表,先把流程画清楚再动代码
售货流程看起来是一个线性过程:选货、付钱、出货、结束。但异常情况会把线性流程变成一团乱麻,比如用户选货后取消、支付超时、出货失败后要退款。不用状态机直接写if嵌套,三四个异常分支之后代码就没法改动了。
这里把系统划分为五个状态:IDLE空闲、SELECT已选货、PAYING支付中、VEND出货中、FAULT故障。状态转移的关键在于事件驱动,按键事件、支付事件、定时器事件都通过一个事件队列进入状态机,状态机内部根据当前状态和事件决定动作。
| 当前状态 | 触发事件 | 下一状态 | 执行动作 |
|---|---|---|---|
| IDLE | 按键选货 | SELECT | 点亮对应货道指示灯,记录所选货道 |
| SELECT | 支付成功 | VEND | 启动电机,开始出货流程 |
| SELECT | 支付超时 | IDLE | 清零所选货道,指示灯熄灭 |
| VEND | 电流判定正常 | IDLE | 扣减库存,发放找零 |
| VEND | 卡货/超时 | FAULT | 停止电机,触发退款流程 |
| FAULT | 维护人员复位 | IDLE | 清理错误码,恢复可用 |
4.2 状态机代码骨架,注意防重入和事件清空
状态机写在主循环里而不是用RTOS,省掉任务切换开销,逻辑也更直观。关键点是一个状态对应一个入口函数,状态切换只发生在状态机的Run函数内部,外部代码不能直接调状态入口,否则会出现一个状态被重复进入的竞态问题。
void Machine_Run(void) { Event_t evt = Queue_Dequeue(); switch (machineState) { case STATE_IDLE: if (evt == EVENT_KEY_SELECT) { machineState = STATE_SELECT; Machine_SelectEnter(); } break; case STATE_SELECT: if (evt == EVENT_PAY_OK) { machineState = STATE_VEND; Machine_VendEnter(); // 启动电机、记录时间戳 } else if (evt == EVENT_PAY_TIMEOUT) { machineState = STATE_IDLE; Machine_IdleEnter(); } break; case STATE_VEND: if (evt == EVENT_VEND_DONE) { Storage_DecrementStock(curSlot); machineState = STATE_IDLE; Machine_IdleEnter(); } else if (evt == EVENT_VEND_FAULT) { machineState = STATE_FAULT; Refund_Start(); // 进入退款流程 } break; default: break; } }这里有两个容易踩的坑。第一个是事件队列出队后如果没有匹配到当前状态,事件直接丢弃,不允许缓存到下一轮,否则出货过程中的残留按键会把状态机打乱。第二个是进入VEND状态时立刻把当前出货时间戳存下来,超时判断统一以这个时间戳为基准,不要在定时器回调里改状态机状态。
4.3 与支付模块的串口协议:帧头加长度加校验
支付模块通过串口和主控通信,常见的有投币器和扫码模块。串口通信最大的坑是粘包和半包,必须定义帧协议。下面是一个极简但够用的帧格式,适合跟大部分扫码模块对接。
// 帧格式:AA 55 LEN CMD DATA... CS // 其中LEN表示CMD+DATA的总字节数,CS为前面所有字节的异或校验 // 支付成功示例:AA 55 06 0A 01 00 00 00 01 0B // 第1字节CMD=0x0A表示支付通知,后4字节为订单金额,单位分 #define FRAME_HEAD1 0xAA #define FRAME_HEAD2 0x55 #define FRAME_CMD_PAY 0x0A uint8_t Uart_GetFrame(uint8_t *cmd, uint8_t *data) { static uint8_t buf[64]; static uint8_t state = 0; static uint8_t len = 0; static uint8_t idx = 0; while (HAL_UART_Receive(&huart1, &rxByte, 1, 0) == HAL_OK) { buf[idx++] = rxByte; switch (state) { case 0: if (rxByte == FRAME_HEAD1) state = 1; else idx = 0; break; case 1: state = (rxByte == FRAME_HEAD2) ? 2 : 0; break; case 2: len = rxByte; if (len == 0 || len > 16) state = 0; // 异常帧头丢弃 else state = 3; break; case 3: if (idx >= len + 4) { // 长度域后实际字节数 if (Uart_CheckXor(buf, idx)) { *cmd = buf[3]; memcpy(data, buf + 4, len - 1); state = 0; idx = 0; return 1; } state = 0; idx = 0; } break; } } return 0; }上面的接收函数用状态机逐字节解析,不依赖串口空闲中断,任何波特率下都能稳定工作。异或校验虽然不是强校验,但对售货机这种短帧、噪声可控的场景足够。实际上更稳的做法是支付成功后主控和支付模块再做一次金额确认查询,双重保险避免计费争议。
4.4 EEPROM库存落盘,掉电不丢账
库存和销售记录必须掉电保存,不能依赖SRAM。使用板载AT24C02,按货道顺序存放库存值。最简单可靠的方案是双备份写入:同一个地址写两份数据,读取时比对,不一致则恢复为较小值。
AT24C02的理论擦写寿命是100万次,按每天售出200次计算,寿命超过十年,磨损均衡不是刚需。但如果做毕设或者长期测试,频繁改写同一地址仍可能在调试期就写坏一片芯片。稳妥起见,把库存区切分为A/B两个扇区,写库时将数据写入非当前扇区,同时维护usage字段区分当前活跃扇区。这是一个典型的“备区写入、读主校验”模式,代码量不大但可靠性提升明显。
5. 源码调试、参数整定与文档组织:从能跑到不怕改
5.1 串口日志分级,把状态跳转打出来看
拿到源码后第一件事不是改逻辑,而是把日志系统打开。日志分四级:ERR、WARN、INFO、DBG,通过一个宏统一控制开关。DBG级别打印ADC原始值、PWM占空比和电流计算结果,INF级别打印状态机跳转,每次跳转都带时间戳和触发事件。
状态跳转日志的格式建议这样设计:[tick][状态1]->[状态2], event=xx。整理日志时用脚本把跳转序列拉出来,一条售货流程应该是IDLE->SELECT->VEND->IDLE的闭环。如果看到VEND->FAULT,直接去查电流采样曲线。
5.2 电流阈值的标定流程:空转、带载、堵转各测一组
阈值不能拍脑袋定。标定流程是:空货道连续运行20次,记录电流最大值;放标准货品运行30次,记录电流最大值;人为卡住货品运行10次,记录电流最小值。最终阈值取卡货最小值和带载最大值的中间点。下表是一份记录模板,建议打印出来贴在调试台上:
| 测试工况 | 测试次数 | 最大电流(mA) | 最小电流(mA) | 备注 |
|---|---|---|---|---|
| 空转 | 20 | 180 | 120 | 弹簧无货 |
| 带载 | 30 | 350 | 220 | 标准可乐罐 |
| 堵转 | 10 | 600 | 400 | 卡死状态 |
5.3 常见故障现象与排查方向
出货完成后商品没掉出来、扫码付款后不出货、电机转了但反馈失败,这几类问题在调试期反复出现。对症下药的方向如下:
| 现象 | 优先排查项 |
|---|---|
| 电机不转 | 方向引脚电平、PWM是否启动、电源是否被拉低 |
| 电机转了但报卡货 | 电流阈值偏低、采样电阻接触不良、弹簧和货道摩擦大 |
| 出货成功但系统认为失败 | 电流判定时间窗太短、带载电流和空转电流区间重叠 |
| 串口收不到支付结果 | 帧长度计算错误、波特率不匹配、没有共地 |
| 按键偶尔失灵 | 矩阵扫描没做去抖、引脚内部上拉未开启 |
5.4 文档说明:配一份决策记录,而不是复制手册
“源代码+文档说明”这个标题里的文档,最有价值的部分不是原理图和寄存器表,而是决策记录。每个硬件选型和软件方案旁边的注释,回答“为什么这么选而不那样做”。例如电流检测为什么选采样电阻而不是红外对射传感器,PWM频率为什么定10kHz而不是100Hz,文档里写清楚这些,拿到你代码的人才能快速理解和接手。
配套文档建议包含六份:原理图PDF、PCB源文件、引脚分配表、BOM清单、接线表、调试记录。其中接线表必须和源码里的引脚宏定义一一对应,调试记录保留每次阈值标定的原始数据。文档和源码同版本号管理,改代码当天必须同步更新文档,否则一个月后再看自己都认不出当初为什么这样写。
本文还有配套的精品资源,点击获取