家里老人每天要吃三种药,有的饭前、有的饭后,我上班时总担心他们记不住;自己偶尔生病吃药,忙起来也经常忘了下一顿是几点。这恐怕是很多人做智能药盒的初衷——把一个“到点提醒你吃药”的小系统做出来。我用STM32F103加上一块LCD显示屏,在Proteus里完整仿真了整个流程:定时到点蜂鸣器响、屏幕显示药名和剂量、按键翻页查看全天服药计划。这篇博文把整个项目的需求拆解、仿真搭建、代码逻辑、调试经验和报告写法全部摊开讲,适合正在做课程设计、毕业设计,或者想在Proteus里跑通一个STM32完整小项目的同学参考。
先说结论:这个项目最大的价值不在于“药盒”本身,而在于它把STM32开发里最常用的几个模块全部串起来了——定时器做时间基准、GPIO控制外设、外部中断或轮询识别按键、LCD显示屏驱动、以及完整的软件状态机设计。把这套逻辑吃透,后面做任何带人机交互的嵌入式小项目,思路都是通的。
1. 智能药盒的项目定位:需求拆解比写代码更费脑子
很多同学拿到题目就急着写代码,这是本末倒置。嵌入式项目最忌讳需求不清就动手,等你写完了才发现“医生开药方案是动态的”“一天要吃五次”这类需求没考虑到,返工成本非常高。所以第一步,先搞清楚这个药盒到底要干什么。
1.1 功能需求:一个药盒最少得具备这几个能力
从日常吃药的场景推理,一个能用的智能药盒至少包含四件事:
第一,时间管理。系统必须知道“现在是几点几分”,这是所有提醒逻辑的地基。最简单可靠的方案是维护一个软时钟:用定时器产生1秒中断,软件里对秒、分、时进行累加,并换算星期。为什么不用RTC芯片?因为Proteus仿真里挂DS1302会增加I2C或自定义时序的调试量,而课程设计/毕设阶段重点考察的是“定时提醒”本身,软时钟完全够用,还省成本。
第二,提醒触发。到点之后要同时做三件事:蜂鸣器响、LED灯闪、LCD显示最近的用药信息。这里的关键是“触发后怎么办”——是一直响到按键确认,还是响30秒自动停?我推荐做成“手动确认停止”:按键按下后蜂鸣器停、屏幕回到主界面,这是最接近真实药盒交互习惯的做法,答辩时也更容易讲清楚状态转换逻辑。
第三,人机交互。至少需要两个按键:一个用来消音/确认,一个用来翻页查看“今天还有哪几顿药、什么时间吃”。如果有余力可以加“设置时间”和“加/减”键,但核心逻辑千万别做复杂,否则状态机容易把自己绕晕。
第四,显示功能。LCD屏幕上要能显示当前时间、下一次吃药倒计时、当前药名和用量。这里面有一个隐藏考点:LCD显示中文。原生LCD1602的字库里只有ASCII字符,要显示“降压药”这种中文,必须自己做字模,这块我会在第三章具体讲。
1.2 为什么选STM32F103 + LCD1602 + Proteus这套组合
方案选型不是拍脑袋,每选一个器件都要能回答“为什么是它”。
主控芯片选了STM32F103C8T6,原因有三:一是Cortex-M3内核性能足够,72MHz主频对这种量级的任务是杀鸡用牛刀,但胜在资料多、网上例程铺天盖地,遇到问题好查;二是Proteus库里对这颗芯片的支持比较成熟,仿真行为接近实物;三是这个型号的封装好画——LQFP48,引脚不算密,画原理图时不容易连错线。
LCD选了1602而不是12864,主要考虑点在于:1602是字符型液晶,驱动时序简单、字模制作容易、Proteus仿真稳定;12864能显示图形和更大字体的中文,但对新手来说字模取模、行列扫描很容易出问题。如果学校答辩时明确要求中文显示,1602做16×16点阵字模也完全够用,一次显示两行、每行可显示8个汉字,对于“药名+剂量”这个场景已经没压力。至于TFT彩屏,除非题目明确要求,不然不建议在仿真项目里选,Proteus对TFT的仿真速度慢,而且ST7789这类驱动芯片在Proteus里的支持不完善,容易劝退。
仿真平台选Proteus是这类项目的标配。它的价值在于:不需要买实物硬件,电脑上就能验证逻辑是否通;可以把断点、变量监视、虚拟示波器全用上,排查问题效率远高于拿万用表戳实物;而且Proteus工程文件导出方便,写报告时直接截图,图文并茂特别加分。缺点也很明显——对时序的仿真和实物有差异,后面我会专门聊怎么规避。
1.3 系统的总体架构:把“输入—处理—输出”画清楚
我给这个系统画的架构图是一个典型的嵌入式闭环:
- 输入:按键(至少2个,我用的是SW1确认/消音,SW2翻页/切换)
- 处理核心:STM32F103C8T6,跑一个基于SysTick的1ms时基,软件里维护时钟和状态机
- 输出:LCD1602显示、有源蜂鸣器报警、LED指示灯闪烁
- 设定存储:用结构体数组把一天多组用药时间预置在Flash里,代码里写好一个默认服药计划,比如08:00吃降压药、12:30吃降糖药、19:00吃维生素
Proteus里对应的连接关系是:PB0-PB7接LCD数据口、PA8/PA9/PA10接LCD的RS/RW/E、PC13接蜂鸣器(通过一个NPN三极管驱动,仿真里直接连也能响但逻辑上不严谨)、PA0接LED、PA1和PA2接两个按键。后面全部按这个引脚分配写代码。
2. Proteus仿真环境的搭建:芯片包、元件选型与LCD不显示的真相
说句实在话,这个项目里卡住新手最久的地方并不是代码逻辑,而是Proteus仿真环境本身。我从元件找不到、芯片包装不上,到LCD屏幕死活不显示,踩了整整两天坑。这章把关键问题一次说透。
2.1 装STM32芯片包:Proteus里找不到STM32F103的解决办法
很多同学打开Proteus的元件库搜STM32F103C8,直接搜不到,或者搜出来的是带“Virtual”字样的无效器件。原因很简单:Proteus默认安装包里没有STM32的模型,需要单独安装芯片包。
我在Proteus 8 Professional上用的方法是:进入菜单System → Check for Updates或者直接去官网下载对应版本的STM32F103C8T6.lib。但官网下载需要注册账号,部分网络环境还很慢。更务实的方式是,在软件里点击左侧“P”打开元件库,先搜索“STM32F103R6”或“STM32F103C6”——这两个型号在旧版本的Proteus里可能自带模型。如果确定没有,去国内嵌入式论坛搜“Proteus STM32芯片包”,下载后放到C:\Program Files (x86)\Labcenter Electronics\Proteus 8 Professional\LIBRARY目录下,重启Proteus就能在元件库搜到。
芯片包装好之后有个重大注意点:STM32在Proteus里默认不会自己跑。元器件拖到图纸上之后,需要右键单片机选择“Edit Properties”,在“Program File”一栏把编译好的HEX文件路径填进去。这个动作等同于把程序烧录进芯片,忘了这一步,仿真的时候板上什么反应都没有——这不是电路问题,是压根没烧程序。
2.2 元件选型对照表:别在这个地方浪费时间
用Proteus画原理图最痛苦的就是元件搜不到。我以前找无源蜂鸣器时在库里面翻了几十页,后来整理了这张对照表,做STM32相关仿真时直接照着抄:
| 功能 | Proteus里的搜索关键词 | 该选哪个 | 踩坑提示 |
|---|---|---|---|
| STM32主控 | STM32F103 | STM32F103C8/C6 | 选带蓝色芯片图标的,Virtual的不参与仿真 |
| LCD1602 | LM016L / LCD1602 | LM016L | 字母全大写,小写搜不到 |
| 有源蜂鸣器 | BUZZER | BUZZER(带电路符号的) | 无源蜂鸣器要PWM驱动,仿真里声音逻辑难验证 |
| LED | LED | LED-RED / LED-GREEN | 注意限流电阻,仿真里也可以不加但尽量规范 |
| 按键 | BUTTON | BUTTON(默认那个) | 记得接上拉电阻到VCC |
| 电阻 | RES | RES / RESISTOR | 仿真里用RES-VARIABLE做电位器调LCD对比度 |
| 电容 | CAP | CAP(陶瓷电容) | 晶振起振电路用18-22pF |
| 晶振 | CRYSTAL | CRYSTAL | 直接搜XS1或CRYSTAL都行 |
| 三极管 | NPN | 2N2222 / BC547 | 驱动蜂鸣器时用,仿真里也可以直接用逻辑门 |
有个细节很多教程不提:LCD的对比度调节。LM016L的V0引脚通常要接一个电位器到地,把输出电压调到1V左右,屏幕才有清晰的字迹。很多人仿真时LCD黑块一块或者白屏没字,检查了半天程序,其实只是V0悬空。
2.3 LCD仿真不显示的五大常见原因与排查顺序
“LCD仿真不显示”是这个项目出现频率最高的问题。我把遇到过的所有“不显示”归类,按排查优先级排列:
- 没烧HEX文件(占三成)。STM32芯片右键属性里Program File是空的。症状:屏幕什么都不显示、蜂鸣器也不响。
- 时序初始化失败(占两成)。程序里LCD的初始化时序不对,比如延时不够、RS/RW/E引脚电平配置错误。症状:LCD有背光但第一行全是方块。
- 对比度问题(占两成)。V0引脚没接地或电位器调到0,屏幕有背光但没有字迹。症状:有背光但纯空白。
- 引脚接线错误(占一成半)。数据线D4-D7和PB0-PB7的对应关系接错,或者RS/RW/E接错了引脚。这种问题Proteus不报错,只能一个一个检查网络标号。
- 复位电路问题(占半成)。STM32的NRST引脚没上拉,仿真刚开始芯片一直处于复位状态。
排查顺序建议:先看屏幕有没有背光;有背光就看V0电压;电压正常就看HEX有没有烧进去;烧了就检查引脚连接。Proteus里还提供一个小技巧——用电压探针点LCD的E引脚和RS引脚,看时序波形是否活动。如果E引脚稳定不动,那就是程序压根没执行到LCD驱动函数。
另外,仿真EEPROM的问题也顺带一提:如果你希望在仿真时预置一组服药计划,可以在Proteus里给STM32挂一片24C02,但让软件在PC内部用结构体数组存储已经足够,写EEPROM操作反而增加代码量。
3. 代码实现:时间基准、按键扫描、LCD显示与提醒触发
这一章是全文的硬核部分,我按照实际代码的模块顺序来讲,每个模块给关键代码片段,并解释为什么这么写。
3.1 基于SysTick的时间基准:1毫秒的脉搏
STM32开发里有个常见的坑:HAL_Delay()只能在主循环里用,不能在里面做累加计时,否则会在中断里重新进入。我做时间基准用的是一个很朴素的方案——SysTick定时器产生1ms中断,在中断里累加计数。
volatile uint32_t g_1ms_counter = 0; volatile uint32_t g_sys_tick = 0; // 系统运行毫秒计数 volatile uint8_t g_1s_flag = 0; // 每秒置1,主循环处理 void SysTick_Handler(void) { g_1ms_counter++; if (g_1ms_counter >= 1000) { g_1ms_counter = 0; g_sys_tick++; g_1s_flag = 1; } }主循环的骨架是:
while (1) { if (g_1s_flag) { g_1s_flag = 0; RTC_Soft_Update(); // 更新时分秒和星期 Check_Med_Reminder(); // 检查当前时间是否匹配药时刻 LCD_Display_Update(); // 刷新显示 } Key_Scan(); // 按键扫描 }注意这里我把LCD刷新也放在1秒一次里。LCD1602是字符屏,刷新频率不需要太高,1秒一次既能显示倒计时变化,又避免主循环里频繁占用总线。
3.2 软时钟设计:时分秒、星期、用药计划的存储结构
软时钟的更新逻辑不多说,就是标准的进位。值得展开的是用药计划的数据结构——这个设计会直接决定提醒逻辑的复杂度。
typedef struct { uint8_t hour; // 服药时刻,小时 uint8_t min; // 服药时刻,分钟 char med_name[17]; // 药名,用ASCII或中文显示 char dose[9]; // 剂量描述,如“一片” uint8_t enabled; // 这个时段是否启用 uint8_t alerted; // 本次是否已经提醒过,防止重复响 } MedTime_T; const MedTime_T Med_Plan[] = { {8, 0, "Metformin", "1 tablet", 1, 0}, {12, 30, "Gliben", "0.5 tablet", 1, 0}, {19, 0, "Vitamins", "1 tablet", 1, 0}, }; #define MED_PLAN_COUNT (sizeof(Med_Plan) / sizeof(Med_Plan[0]))alerted这个字段特别关键。如果不加它,到达设定时间的那一分钟里,主循环每秒都会匹配一次,蜂鸣器会响个不停。加上它之后逻辑变成:第一次匹配到时间时把alerted置1,蜂鸣器响并等待按键确认;按键确认后清零,或者到第二天零点统一清零。这样既不会漏提醒也不会重复响。
3.3 提醒触发逻辑:状态机比一堆if-else高级得多
提醒模块我用了一个简单的三态状态机:IDLE → RINGING → CONFIRMED。
typedef enum { REMIND_IDLE, REMIND_RINGING, REMIND_CONFIRMED } Remind_State_T; Remind_State_T g_remind_state = REMIND_IDLE; void Check_Med_Reminder(void) { // 获取当前时间,读变量 current_hour / current_min for (uint8_t i = 0; i < MED_PLAN_COUNT; i++) { if (Med_Plan[i].enabled && !Med_Plan[i].alerted && current_hour == Med_Plan[i].hour && current_min == Med_Plan[i].min) { g_remind_state = REMIND_RINGING; g_cur_med_index = i; } } }在REMIND_RINGING状态下:蜂鸣器响、LED闪烁、LCD显示当前药名和剂量。只有按下确认按键才会切到CONFIRMED,此时关闭蜂鸣器,把当前用药项的alerted置1,LCD切回主界面。这个状态转换逻辑用switch配合按键扫描函数很容易实现。
3.4 LCD1602驱动:时序里的那些微妙细节
LCD1602的驱动网上满天飞,我在这里只说导致本项目失败频率最高的三个细节。
第一个细节:初始化时的延时必须充足。LCD上电后需要等待40ms以上,然后再送初始化指令。Proteus仿真里时序通常比实物宽松,但代码里还是要保证DelayMs(50)起步,否则会出现“第一次仿真黑块,暂停重跑就正常”的怪象。
第二个细节:RW引脚的处理。只写不读的话,可以把RW直接接地,省一个引脚的GPIO控制。但更稳妥的做法还是用GPIO控制,因为排查问题时可能需要读忙标志。我建议把RW接PA9,写0即可,方便以后扩展。
第三个细节:写数据前必须拉高E引脚再拉低,形成下降沿锁存。很多人的代码卡在这里——数据口的值变化了,但E没有产生下降沿,LCD什么也不做。
void LCD_WriteCmd(uint8_t cmd) { LCD_RS_GPIO(0); LCD_Data_GPIO(cmd); LCD_E_GPIO(1); DelayUs(10); LCD_E_GPIO(0); // 下降沿锁存 DelayMs(2); // 指令执行时间,至少1.5ms }3.5 LCD显示中文的原理与实现:别直接用汉字赋值
LCD1602原生字模库里没有汉字,怎么显示“降压药”这类中文?答案是自己做字模,然后通过写自定义字符的方式显示。
LCD1602内置的CGROM里,字符码0x00到0x07对应8个可以由用户自定义的5×8点阵图案,也就是CGRAM区域。一个16×16的中文汉字需要拆成上下两半,每一半用两个5×8的空间(8个字节)来拼。实际操作时,我的做法是:
- 用取模软件(我用的是PCtoLCD2002)把“降”“压”“药”这样的汉字按16×16点阵取模,得到32字节的数组。
- 每次上电时把字模写入CGRAM(函数
LCD_CreateChar),一共可以写8个自定义字符。 - 显示时,把“降压药”拆成6个半字,对应6个自定义字符码(0x00~0x05)。
取模软件里必须设置“逐行式”还是“逐列式”,这要和写入CGRAM的顺序完全一致。我当时就是这里出了问题,写出来一半字是颠倒的。网上有很多现成的中文取模教程,关键字“PCtoLCD2002 汉字取模 LCD1602”,按步骤操作即可。
如果项目要求不只是字符屏,用带中文字库的LCD12864会更省事,直接靠硬件字库显示中文,但那种屏Proteus里仿真的速度会慢一截,且引脚占得多,适合有余力的同学扩展。
3.6 按键扫描与消抖:无延时也能做到稳定识别
按键消抖最常见的教材写法是DelayMs(10)再确认,但延时会让主循环卡顿,在配合LCD刷新时稍微有点尴尬。所以我用的是带时间戳的消抖方案:检测到按键电平变化后,记录当前系统毫秒数,超过20ms后再读一次,确认电平一致才判定有效。
void Key_Scan(void) { static uint8_t key_buf = 0xFF; static uint32_t last_time = 0; uint8_t key_now; key_now = (GPIOA->IDR & 0x06) >> 1; // PA1, PA2 if (key_now != key_buf) { last_time = g_sys_tick; key_buf = key_now; } if (key_now != 0x03 && (g_sys_tick - last_time) > 20) { if (!(key_now & 0x01)) { /* 按键1被按下 */ } if (!(key_now & 0x02)) { /* 按键2被按下 */ } } }这个扫描函数可以放在主循环里每毫秒轮询,也可以开一个1ms的定时器中断做。如果按键数超过4个,建议用矩阵扫描或者把每个按键独立接外部中断,但对药盒这种两个键的产品,这种方式轻量、稳定、易读。
4. 仿真调试、代码诊断与实验报告撰写
很多人代码写完、仿真跑通就收工了,结果答辩时被老师问三个问题就卡壳:你的时间基准是多少?精度如何保证?提醒失败你会怎么查?这些问题在调试阶段就应该想清楚。这一章聊聊仿真调试的工具用法,以及实验报告怎么组织才能拿高分。
4.1 Keil MDK配置:从源码到Proteus能跑的HEX
代码写完要先烧录才能在Proteus里看效果,这里涉及三个配置文件必须设对。
第一是Output选项卡里勾选Create HEX File。不勾选的话,编译后不会生成HEX文件,Proteus右侧属性栏里你根本选不到程序。第二是Device里选对芯片,STM32F103C8对应的是STM32F103C8,千万别选成STM32F103RB,寄存器地址一样但Flash容量不同,有些编译器宏定义会不一样。第三是Debug选项卡里选Use Simulator,如果你用ST-Link的调试设置去跟Proteus对接,编译出的代码在仿真环境里不一定正常。
编译通过后,回到Proteus,右键STM32芯片,在“Program File”里选择刚才生成的.hex文件,点击左下角运行按钮,仿真就开始跑了。这一步如果提示“Cannot load file”,多半是路径中包含中文名或空格,把工程挪到英文路径下就能解决。
4.2 仿真调试三板斧:虚拟终端、电压探针、计时器
在Proteus里排查程序问题,我基本只用三个工具:
- 电压探针:直接放在LCD的E引脚、RS引脚上,运行后看波形是否在跳动。如果E引脚一直不动,说明主循环根本没进入LCD刷新函数,问题定位到代码逻辑;如果E引脚有波形但屏幕不显示,问题在时序本身或对比度。
- 虚拟终端:放在UART引脚上,通过串口把调试信息打印出来。STM32在Proteus里可以用USART1虚拟串口,程序里加个
printf("current hour = %d\n", hour),比盯着LCD猜快多了。注意要重定向fputc到USART1。 - 模拟计时器:Proteus左下角有一个
Run simulation的时间控制。默认仿真是实时速度,但如果你想验证“跑1小时才响的闹钟”,可以把仿真速度调到“Faster Than Real Time”。也可以直接修改运行时间——在Debug菜单里设置Simulation Time,快速跳到某一天的某个时刻来验证提醒逻辑。
还有一个经常被忽视的功能:实时变量监视。仿真暂停时,在调试菜单里把current_hour、g_remind_state这些变量加入Watch窗口,可以看到它们的实时值。排查“时间到了为什么不响”这类问题时,直接看状态机当前的枚举值,一秒钟就能判断是触发条件不满足还是按键确认逻辑写错了。
4.3 STM32芯片包与Keil工程的版本匹配问题
这个坑我遇到过一次。手头的Keil工程是标准库写的,但Proteus的STM32模型是HAL库风格的底层封装,两者对GPIO的初始化方式不同,但HEX文件层面其实完全兼容,问题不大。真正的问题是编译优化等级和Proteus仿真速度的联调——在Keil的优化等级选Level 3时,某些循环可能被优化掉,导致Proteus里LCD显示异常。遇到这个情况就把优化等级改为Level 0,或单步调试看一下循环是否正常执行,通常能解决。
另外,ST-Link Utility和Proteus仿真是两个概念:ST-Link是给实物板子烧录用的,仿真环境里用不到。如果题目只要求Proteus仿真,就不需要买ST-Link硬件,也不需要在工程里配置ST-Link调试器。这点很容易被初学者混淆,答辩时老师也可能故意问“你用什么下载程序”,要能分清两种烧录路径的区别。
4.4 功能测试用例与常见仿真异常
仿真跑通后,别急着截图写报告,先做一轮功能测试。我常用的用例清单是这样的:
| 编号 | 测试步骤 | 预期结果 | 实测记录 |
|---|---|---|---|
| T01 | 运行仿真,不按任何键 | LCD显示当前时间和下一次服药倒计时 | 通过 |
| T02 | 将仿真时间设为08:00前10秒 | 08:00时蜂鸣器响、LED闪、LCD显示药名 | 通过 |
| T03 | 按确认键 | 蜂鸣器停、LED灭、LCD回到主界面 | 通过 |
| T04 | 按翻页键 | LCD切换显示第二组服药时间 | 通过 |
| T05 | 再按翻页键 | 回到第一组显示 | 通过 |
| T06 | 用Proteus快速跳过3小时 | 到12:30时第二组提醒生效 | 通过 |
如果能在这个表里做到全绿,项目的主体功能就稳了。下面列几个常见异常和对应的解决方向:
- 时间走得比真实时间快:SysTick配置的时钟源和系统主频不匹配。检查RCC时钟配置是否把SYSCLK配成了72MHz,SysTick重装载值是否写成了72000而不是72000000/1000=72000。
- LCD闪烁或刷新残留:刷新函数里写完一行后没有做清屏或光标归位。注意写第二行之前要设置DDRAM地址为第二行首地址0xC0。
- 按键无响应:检查按键引脚有没有配置上拉输入。STM32内部上拉电阻在Proteus里默认不启用,如果程序里没有写
GPIO_Init的Push-Pull或InputPullUp,按键就会一直处于悬空状态。 - 蜂鸣器一直响停不下来:状态机在
CONFIRMED分支里没有把alerted置1,导致每跑一秒就重新匹配一次。
4.5 课程设计报告的骨架:让老师一眼看出工作量
报告是拿分的重点,但很多人的报告写得像产品说明书,一节“系统概述”一节“硬件设计”一节“代码”堆完就没了,老师看着都费劲。我建议按“问题—方案—实现—验证”的逻辑组织,章节结构可以是:
- 项目背景与需求分析(核心是写清楚“为什么需要智能药盒”和“功能需求点”)
- 系统总体设计(架构图、模块职责划分、器件选型对比表)
- 硬件设计(Proteus电路图分模块截图,逐个解释引脚连接理由)
- 软件设计(工程结构图、状态机图、关键函数的逻辑描述和代码片段选段)
- 系统测试(功能测试用例表、故障问题及解决过程)
- 总结与展望
关键技巧是在报告中加入自己在调试中遇到的真实问题和解决过程。比如写“LCD显示中文时出现上下半字颠倒,通过调整取模软件的扫描方式解决”,这种内容比任何冗余的文字都更能体现工作量,答辩也更有话讲。报告里截图多用带标注的图,把Proteus原理图和运行截图都配上。
答辩准备时,被问到频率最高的问题无非几个:时间基准是怎么获得的?怎么保证定时精确?如果药盒没电了怎么办?你的系统能管几种药?这些问题其实都指向同一个核心——你有没有真正理解这个系统的运行逻辑,而不是只会复制粘贴代码。
最后再分享一个实际经验。我在调这个项目的时候,LCD中文显示卡了整整一个晚上,第二天早上发现是取模软件的“逐列式”和“逐行式”选错了,导致字模数据写入顺序错位,屏幕上的汉字就像是被人从中间劈了一刀再拼回去。后来我把取模参数、CGRAM写入函数、显示调用这三部分的代码整理成一张注释表贴在工程里,之后再做别的项目遇到LCD自定义字符,一分钟就能对上了。这种边做边沉淀的习惯,比多做十个仿真项目都有用。这个STM32智能药盒做完之后,你会发现再看任何带“定时”“显示”“按键”字眼的题目,心里都有底了。