十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于STM32的智能药盒设计:状态机、RTC与OLED驱动实践

基于STM32的智能药盒设计:状态机、RTC与OLED驱动实践 1. 项目概述与设计思路拆解1.1 智能药盒到底解决什么问题家里有老人的朋友应该都有过这种体验药盒里一堆药有的是饭前吃有的是饭后吃有的是一天三次有的隔天一次。年轻人尚且会忘老人更不用说了。我自己家里长辈就曾经因为记错服药时间把降糖药和降压药混在一起吃幸好发现得早没有出大事。从那以后我就一直在想能不能做一个东西到点提醒漏吃能记录家人也能远程心里有数。这个STM32智能药盒/老人用药管理系统本质上就是把“按时吃药”这件事从“靠人脑记”变成“靠设备管”。它做的事情说白了就三件第一到点提醒通过蜂鸣器、指示灯、屏幕显示告诉老人该吃哪一格药第二记录状态按了按键就是吃了没按就是漏服状态都存在芯片里第三数据可查家人可以通过屏幕或者上位机查看用药记录知道老人到底有没有按时吃。我选择用STM32做这个项目不是因为它花哨而是因为这个场景对稳定性、成本、开发效率的要求都卡得比较死。STM32F103系列主频72MHzFlash和RAM足够跑完整的逻辑加显示驱动价格在国产替代之后已经压到几块钱一片而且资料满天飞遇到问题基本都能搜到解决方案。相比之下用Arduino虽然上手快但严肃一点的毕设或者实际落地项目性能和存储总有点捉襟见肘用树莓派又成本太高、功耗太大给老人做一个药盒没必要上操作系统。1.2 适合谁来做能学到什么如果你是电子类、嵌入式方向的学生这个项目拿来当毕业设计非常合适。它有传感器按键输入、有执行器蜂鸣器、LED、有人机交互OLED显示、有实时时钟RTC、有数据存储EEPROM或者Flash模拟、有通信接口串口几乎把一款单片机产品的所有外围模块都串起来了。如果你是想给家里长辈做一个实用工具的DIY爱好者这个项目同样值得上手。成品方案在某宝上搜“智能药盒”也有但要么贵要么功能不匹配自己做一个的好处是可以根据老人的用药习惯定制比如老人习惯早上六点半起床就把第一顿提醒设在六点二十比如有的药需要冷藏可以外接一个温度传感器报警这些都是商品方案给不了的灵活度。如果你正在准备嵌入式方向的面试或者研究生复试这个项目写在简历上的含金量也相当能打。它不是那种流水账式的“基于STM32的某某系统”而是真的包含状态机设计、外设驱动、低功耗考量、容错处理这些企业里看重的点。面试官问到“你这个药盒如果按键被老人反复按怎么办”“RTC电池没电了怎么处理”“蜂鸣器会不会吵到邻居”你都能从架构层面给出回答而不是背课本。我这次开源的完整内容包括三块底层驱动代码、电路原理图AD格式可以直接打板、Proteus仿真工程没买实物板子也能完整跑通逻辑。下面我把每一个部分的设计思路、关键实现、踩坑记录都摊开讲。2. 硬件选型与原理图设计要点2.1 主控选型为什么是STM32F103C8T6主控这块我最后定了STM32F103C8T6也就是大家常说的“C8T6蓝色 pill”板子常用的那颗芯片。选它的理由排在第一位的是性价比。C8T6是64引脚LQFP封装20KB RAM、64KB Flash对于药盒这个量级的代码来说非常宽裕。我写的完整工程包含OLED驱动、RTC驱动、按键扫描、蜂鸣器控制、EEPROM读写、串口协议解析编译完ROM占用大概也就30KB左右RAM峰值不到5KB余量很大。其次是封装友好。LQFP48的封装手工焊接虽然要一点耐心但比起BGA封装比如STM32F4系列部分型号只能用回流焊C8T6拿一把烙铁就能搞定非常适合学生打样和DIY。如果你实在不想焊直接买一块最小系统板也行晶振、复位、下载电路都帮你做好了只需要把外设模块的杜邦线插上去。第三是生态成熟。从标准外设库到HAL库到LL库从Keil到IAR到VSCodeCMake各种开发方式都有大量教程。网上搜“STM32F103C8T6”能出来的资料够你从入门到画板子到调驱动全程闭环。Proteus仿真库里这颗芯片的模型也做得很完善我后面在仿真环节没遇到什么模型缺失的问题这一点对验证逻辑特别重要。主控的供电、复位、启动模式这三个部分我在原理图里都做了处理供电部分用AMS1117-3.3把USB的5V转成3.3V输入输出各加10uF和100nF电容滤波复位电路用的是经典的10K上拉电阻加100nF电容到地低电平复位BOOT0通过10K电阻下拉到地从主Flash启动BOOT1悬空。这里有个细节值得注意BOOT0一定不能浮空或者直接接高电平否则芯片上电后会进入ISP模式你下载程序会发现下不进去或者运行的是系统存储器里的固件而不是你自己写的代码。2.2 RTC时钟方案DS1302与DS3231的取舍药盒的核心是“按时提醒”所以时间来源必须可靠。市面上常见的方案有两个DS1302和DS3231。DS1302是DIP8封装的老将价格低到一两块钱一片三线串行接口CE、SCLK、IO自带一个充电电路可以给备份电池充电。缺点也很明显走时精度一般一个月下来可能偏差几十秒到几分钟温度影响也比较大而且三线接口是软件模拟时序对实时性要求苛刻主程序稍微有点卡顿就容易读取出错。DS3231是DS1302的升级版内置温补晶振TCXO精度能做到±2ppm也就是一年偏差不超过一分钟接口是标准I2C硬件时序稳定还自带温度传感器和两个闹钟。缺点就是贵模块价格在十块钱左右但考虑到药盒是医疗相关场景时间错了就可能误事我觉得这笔钱不能省。我做原理图的时候最终选了DS3231但为了兼容灵活性板上预留了DS1302的焊盘位置两个芯片封装不同通过跳线选择用哪颗。如果你的预算紧张用DS1302也能跑只要在代码里加一个定时校时逻辑比如每天通过网络或者按键校时一次日常使用问题也不大。这个设计思路也分享给各位硬件设计时多留一种备选方案成本几乎没有增加但调试和后续迭代的时候会舒服很多。DS3231的接线其实很简单SDA接PB7SCL接PB6STM32F103的I2C1VCC接3.3VGND接地。这里要特别提醒一点DS3231的SDA和SCL是开漏输出必须在总线上接上拉电阻一般用4.7K到3.3V。很多模块板上已经自带上拉电阻了但如果自己画板子用裸芯片这个上拉忘了加I2C通讯会偶尔卡死现象就是读出来的时间每隔一会儿就跳一个乱值排查起来特别费劲。2.3 显示与交互OLED、按键、蜂鸣器的搭配显示部分我选了0.96寸I2C接口的OLEDSSD1306驱动。选OLED而不是LCD1602原因很简单一是I2C只需要两根线LCD1602要8根数据线加控制线布线麻烦二是OLED自发光对比度高老人看起来不费劲三是LCD1602在Proteus里的仿真模型虽然存在但显示效果模拟得比较粗糙OLED的仿真效果和实物几乎一致。OLED屏幕上我规划了三块区域顶部一行显示当前日期和时间中间区域显示当前药格编号和药品名称备注底部一行显示最近一次服药是OK还是MISS。字体用的是6x8和8x16两种大小的ASCII字库中文字库因为Flash占用太大没有上药品名称就用拼音首字母或者药名缩写替代比如“降压药”写成“JYY”“降糖药”写成“JTY”。如果你一定要显示中文可以考虑用取模软件把需要的那几个汉字转成16x16点阵放进一个const数组里几十个字也就占2KB左右的Flash完全可行。按键我设计了三个一个“服药确认键”按下去表示“我这个周期的药已经吃了”一个“翻页键”切换查看不同药格的服药记录一个“消音键”老人如果正在忙不方便马上吃药可以暂时关闭蜂鸣器十分钟十分钟后如果还没按键确认会再次响铃。这个消音功能是访谈了社区里几位老人的家属之后才决定加的因为不少老人反映“有时候响铃的时候正在做饭走不开但又不想让铃声一直吵”加一个临时消音能显著提升使用体验。蜂鸣器用有源蜂鸣器还是无源蜂鸣器这里也有讲究。有源蜂鸣器内部自带振荡源通电就响驱动简单但音色单一只能发出固定频率的“嘀嘀嘀”无源蜂鸣器需要外部提供一定频率的方波才能发声音色更丰富可以做出“滴滴滴”和“滴——”的长短音区别。药盒报警场景我用的是无源蜂鸣器通过TIM定时器输出PWM控制频率紧急提醒用2.7kHz连续音普通提醒用1kHz短促音老人光靠听声音就能区分“必须马上吃药”和“十分钟内吃都行”。这里有个细节无源蜂鸣器直接接GPIO推挽输出虽然能响但声音偏小我加了一个S8050三极管做驱动GPIO输出高电平时三极管导通蜂鸣器电源接通声音明显洪亮很多老年人听力下降也能听清。2.4 电源与PCB布局的几个细节电源方案我设计的是USB 5V输入为主电源同时支持两节5号电池作为备用电源。正常情况下USB供电电池不耗电USB断开后自动切换到电池供电保证RTC时钟继续走字。这个切换我用的是两个肖特基二极管MSS1P3做“或”逻辑压降只有0.3V左右简单可靠不需要专门的电源管理芯片。PCB布局上有几个坑我替大家踩过了。第一晶振要紧挨芯片的OSC_IN和OSC_OUT引脚走线尽量短并且周围用地铜皮包裹否则容易停振或者频率不稳。DS3231的32.768kHz晶振和STM32主晶振8MHz两者离得远一点避免互相干扰。第二模拟地和数字地要单点连接我把按键、蜂鸣器、电源部分的地通过0欧电阻和主控的数字地单点汇接避免地环路形成噪声。第三OLED排线走线不要穿过蜂鸣器下面蜂鸣器工作时的电磁辐射会让OLED出现水波纹我第一版样机就遇到过这个问题后来把OLED的接线绕到板边才解决。原理图里还有一个细节是SWD下载接口标准的4线SWDSWDIO、SWCLK、GND、3.3V加一个复位引脚。现在调试基本都走SWD不用JTAG那么多线。很多新手画板子会漏了给SWD接口加上拉电阻导致下载器偶尔识别不到芯片我在SWDIO和SWCLK上各加了一个10K上拉到3.3V这个问题就彻底没有了。3. 软件架构与核心代码实现3.1 为什么用状态机代替顺序逻辑药盒的软件逻辑如果用“顺序执行”的思路写其实也能跑但代码会变得特别乱。比如主循环可能是读按键→处理按键→检查时间→决定要不要响铃→更新屏幕这个流程一旦加了“消音状态”“翻页查看状态”“设置时间状态”各个状态之间互相穿插代码很快就成了一锅粥。我采用的是**有限状态机FSM**架构把整个设备抽象成五个状态STANDBY待机、ALERTING闹铃提醒中、CONFIRMED已确认服药、VIEWING翻页查看记录、SETTING时间设置。每个状态下按键的响应逻辑完全不同STANDBY状态下按确认键无效因为没到吃药时间ALERTING状态下按确认键表示“我吃了”切换到CONFIRMED蜂鸣器停止状态记录写入EEPROMALERTING状态下按消音键蜂鸣器停但状态仍然是ALERTING只是声音关了十分钟后如果还没按确认键重新响铃VIEWING状态下按翻页键切换显示的药格编号和记录SETTING状态下按确认键是确认当前设置的参数而不是表示“吃药了”。状态机的核心就是一个switch-case加上一个状态转移表。我用一个二维数组定义状态转移关系当前状态、触发事件、下一状态、动作函数指针。这种方式的好处是逻辑一目了然加新功能的时候不会破坏原有逻辑而且调试时只要打印当前状态立刻能知道设备卡在哪一环。代码结构大致是这样的typedef enum { ST_STANDBY, ST_ALERTING, ST_CONFIRMED, ST_VIEWING, ST_SETTING } SystemState; typedef enum { EV_KEY_CONFIRM, EV_KEY_SNOOZE, EV_KEY_PAGE, EV_ALARM_TRIGGER, EV_TIMEOUT } EventType; typedef struct { SystemState currentState; EventType event; SystemState nextState; void (*action)(void); } StateTrans; // 状态转移表 const StateTrans stateTable[] { {ST_STANDBY, EV_ALARM_TRIGGER, ST_ALERTING, ActionStartAlarm}, {ST_ALERTING, EV_KEY_CONFIRM, ST_CONFIRMED, ActionConfirmMed}, {ST_ALERTING, EV_KEY_SNOOZE, ST_ALERTING, ActionSnoozeAlarm}, {ST_ALERTING, EV_TIMEOUT, ST_ALERTING, ActionReAlarm}, {ST_CONFIRMED, EV_TIMEOUT, ST_STANDBY, ActionBackToStandby}, {ST_STANDBY, EV_KEY_PAGE, ST_VIEWING, ActionEnterView}, {ST_VIEWING, EV_KEY_PAGE, ST_VIEWING, ActionNextRecord}, // ... };实际运行的时候主循环里不断轮询“是否有事件发生”有的话查表执行动作函数。主循环的代码非常简洁基本上就是读按键、读闹钟标志、查表、执行这为后续扩展带来了巨大便利。比如我后来想加一个“用药统计”功能只需要新增一个状态和对应的动作函数插入状态转移表即可完全不需要改动原有的核心逻辑。3.2 定时器与按键消抖的工程化处理按键消抖这个话题很多教程还在用delay(10ms)这种方式但这在药盒场景里是绝对不行的。因为delay会阻塞整个主循环阻塞期间如果恰好到了闹铃时间蜂鸣器会延迟响这在医疗场景里是不可接受的。我用的方案是10ms定时中断扫描 状态机滤波。TIM2配置为10ms中断中断服务函数里做三件事扫描三个按键的电平记录当前电平状态每个按键维护一个5次采样值的数组最近5次都是高电平才算按下连续5次都是低电平才算松开检测到一次完整的“按下-松开”过程就触发对应的事件置位标志位。这样做的效果是有效过滤了机械按键的抖动毛刺同时主循环几乎不会被拖慢。中断服务函数里不处理任何业务逻辑只更新按键状态和标志位业务逻辑全部放在主循环里执行这符合中断处理“快进快出”的原则。此外我还给按键加了一个长按和短按的区分短按翻页键是切换药格记录长按翻页键超过2秒进入SETTING状态可以调整当前时间。长按的检测逻辑是在中断里记录按下时间戳主循环每次检查如果发现按键仍处于按下状态且持续时间超过阈值就触发长按事件。这里注意一点长按没有“松开”事件是在按下过程中检测到达阈值就触发的不需要等用户松手手感会更跟手。TIM定时器的使用还有一个妙用——闹钟检查。我不用在主循环里反复比较“当前时间是否等于某一时刻”而是这样做的DS3231设置闹钟时间为下一次服药时刻当闹钟时间到的瞬间DS3231的INT引脚会被拉低STM32把INT引脚配置为外部中断输入下降沿触发外部中断服务函数里置位闹钟标志主循环发现闹钟标志后执行状态机的EV_ALARM_TRIGGER事件。这套闭环的好处是STM32大部分时间可以处于低功耗睡眠状态不需要频繁查询时间只有闹钟到来时才会被“叫醒”。虽然药盒是市电供电功耗不是最敏感的问题但这种事件驱动的架构代码风格更加干净也为以后改成电池长续航版本留好了余地。3.3 I2C驱动OLED和DS3231的底层细节OLEDSSD1306和DS3231都是I2C设备地址不同我分别写了软件模拟I2C和硬件I2C两套驱动默认用的是软件模拟。为什么因为STM32F103的硬件I2C模块出了名的“娇气”状态机复杂、errata多稍不注意就卡在busy状态尤其是用标准外设库的时候遇到一次I2C卡死就要复位整颗芯片。软件模拟I2C虽然多占一点CPU时间但逻辑透明、调试方便出问题用逻辑分析仪一看就知道是哪根线的时序不对对于这种不追求极限吞吐的场景完全够用。I2C通信的两个关键点是起始信号和停止信号的正确生成。起始信号是SCL为高时SDA从高变低停止信号是SCL为高时SDA从低变高这个时序必须严格遵守差一个微秒都可能被从设备忽略。我的软件I2C驱动里SDA和SCL都配置为开漏输出带上拉这样不需要切换输入输出方向可以避免一些电平竞争的问题。DS3231的读写流程是这样的写时间时先把要写的小时、分钟、秒、星期、日、月、年打包成一个BCD数组然后通过I2C向设备地址0xD0写入起始寄存器地址0x00再连续发送7个字节数据。读时间时先写入寄存器地址0x00然后发送一个restart信号不是stop再start是一次restart从设备地址改为0xD1连续读7个字节最后发送NACK和停止信号。读出来的BCD码需要转换成十进制主循环里再根据当前的秒值变化来更新时间显示。OLED驱动这块SSD1306内部有1KB的GRAM对应128x64像素每8个像素点为一页共8页。写显示数据时要先设置页地址和列地址然后连续写入数据。我封装了一个OLED_ShowString(x, y, str, font_size)函数底层通过查ASCII码表获取字模数据再算好坐标批量写入GRAM。这部分代码网上资源很多但要注意适配自己的屏幕型号有的OLED是128x32有的是128x64初始化指令和滚屏配置不一样直接用别人的驱动可能显示错位。3.4 服药记录如何掉电不丢失用药记录如果只是一份内存里的结构体掉电就清零了那这个药盒的意义就少了一大半。家人要看的正是“过去七天有没有按时吃药”的历史数据所以数据必须持久化存储。我用的方式是STM32内部Flash模拟EEPROM在Flash的最后两页每页1KB一共2KB开辟存储空间。为什么不用外部EEPROM芯片比如AT24C02因为AT24C02只有2Kbit也就是256字节存不了多少条记录用大容量EEPROM又要加芯片和布线成本上去了。内部Flash虽然擦写寿命只有1万次左右但药盒一天最多存4条记录一年也就1460次擦写用个五六年完全没问题。遇到Flash写满的情况代码会自动把旧的记录整体搬移到另一页然后擦除旧页采用双页轮换机制这个逻辑在flash_emulation.c文件里有完整实现简单来说就是“写满就换页、换页前先搬数据”。每条用药记录的结构体是这样的typedef struct { uint8_t year; // 年份-2000 uint8_t month; // 月份 uint8_t day; // 日期 uint8_t hour; // 小时 uint8_t minute; // 分钟 uint8_t slotId; // 药格编号 0~3 uint8_t status; // 0MISS(漏服) 1OK(已服) 2DELAY(迟服) } MedRecord;一条记录12字节我把两条记录打包成24字节作为一个写入单元配合一个标记校验用的Magic Number写入Flash时先写数据区再写校验区读取时先校验再使用。如果校验失败说明这页数据已经损坏会跳过这页继续读宁可丢一条记录也不能让程序崩溃。这里涉及到一个时序问题Flash写入的时候整个芯片的取指会被阻塞因为Flash控制器正在忙。我的Flash写入函数会短暂关闭所有中断防止写入期间发生中断处理导致程序指针混乱。同时在写Flash之前我会先备份当前时间到DS3231的通用RAM区万一写入过程被异常打断上电恢复后还能拿到一个最近的时间戳做参考。3.5 串口协议与上位机联动药盒不能只是个孤立的硬件还得能让家人“远程”看到情况。我这里说的远程不是走云平台那种高大上的方案而是更务实的串口通信药盒通过CH340串口芯片连接电脑或者以后接一个ESP8266模块转WiFi按照自定义的帧协议上报数据上位机负责解析和展示。帧协议我设计成固定帧头数据长度数据区校验的格式// 帧格式0xAA 0x55 LEN CMD DATA... CHECKSUM // CHECKSUM LEN ^ CMD ^ DATA[0] ^ DATA[1] ... (异或) typedef struct { uint8_t head[2]; // 0xAA 0x55 uint8_t len; // 数据区长度 uint8_t cmd; // 命令字 uint8_t data[16]; // 数据区 uint8_t checksum; // 校验和 } ProtocolFrame;命令字定义了几类0x01表示查询当前状态0x02表示上传服药记录0x03表示校时0x10表示设置下一个服药时间。上位机发送0x01药盒返回一帧当前时间、当前状态、最近一次服药结果的组合数据上位机发送0x02药盒把Flash里的历史记录逐条打包上传一次传不完就分包每包最多5条记录。我还用Python写了一个简单的上位机界面基于PyQt5没有用太重的图形库界面就三个区域设备连接区选择串口号和波特率、实时状态区显示当前时间和设备状态、历史记录表格区显示近七天的服药记录OK显示绿色、MISS显示红色。这套上位机代码同样打包在开源工程里懂一点Python的人可以自己改界面和功能。串口波特率我设的是1152009600太慢传历史记录要等很久115200对CH340来说非常稳定。4. Proteus仿真环境配置与实操4.1 仿真工程怎么搭实物板子不是每个人都有尤其是还没打样的时候用Proteus先把逻辑跑通是最省钱的验证方式。Proteus 8以上的版本内置了STM32F103系列芯片模型可以直接添加STM32F103C6或者STM32F103C8。我开源工程里的simulation.pdsprj文件就是完整的仿真工程用Proteus 8.9以上版本打开即可直接运行。如果你的Proteus版本打不开我的工程也没关系按下面的步骤自己搭也很快新建工程在Device Search里搜索STM32F103C8双击添加到元件列表。从元件库添加DS3231、SSD1306OLED、BUTTON、BUZZER、LED-RED、LED-GREEN、RESISTOR。按照原理图的连接关系连线。Proteus里器件引脚名和实物一致SSD1306的SDA、SCL分别接PB7、PB6DS3231同样接这两个引脚因为I2C总线是共享的注意在总线上加两个上拉电阻到3.3V这个在仿真里也不能省。双击STM32芯片在Program File一栏选择编译生成的hex文件要在keil里勾选Output选项卡的Create HEX File。点击左下角的运行按钮仿真开始。几个容易出问题的地方第一Proteus里STM32的仿真模型上电后默认是不执行程序的必须加载hex文件才能运行很多人忘记加载就点运行屏幕一片黑以为代码有问题第二仿真模型对未连接的引脚处理方式和实物不完全一致一些不用的引脚最好在工程配置里设置为Analog模式或者直接接地避免浮空引脚引入噪声第三Proteus的DS3231模型默认时间是系统时间如果想模拟“到点闹铃”的场景在仿真过程中可以点击DS3231组件手动修改当前时间和闹钟时间然后观察蜂鸣器和状态机是否按预期切换。4.2 仿真里能验证什么、不能验证什么Proteus仿真的最大价值在于逻辑验证状态机转移对不对、闹钟触发时蜂鸣器有没有响、按下确认键后记录有没有写进去、历史记录查询显示是否正确。这些逻辑层面的东西仿真和实物行为基本一致尤其是我用了软件模拟I2C仿真模型对I2C时序的模拟也比较接近真实情况DS3231的读写在仿真里跑得很顺畅。但有几样东西仿真验证不了或者验证效果和实物差距很大你得心里有数蜂鸣器实际音量Proteus里蜂鸣器“响”的表现只是图形上闪烁几下没有真实声音输出所以音量大小、音质好坏只能在实物上验证。电源噪声和电磁干扰仿真是理想化的不存在电源纹波、地弹、信号串扰这些概念我前面提到的OLED水波纹问题在仿真里根本不会出现这只能在实物调试中解决。按键的实际手感仿真里按键就是一瞬间的高低电平切换没有机械抖动。我的按键消抖逻辑在仿真里可能一次都不触发显得形同虚设但实物按键按下时会有ms级别的抖动消抖逻辑必不可少。I2C总线的时序细节Proteus模型对从设备的响应时序模拟得比较“宽容”实际硬件上如果上拉电阻阻值选得太大总线上升沿变慢可能在高波特率下通信失败这种时序margin问题仿真里看不到。我给的建议是仿真用来验证“逻辑对不对”实物用来验证“能不能稳定工作”。先用仿真把状态机、协议、显示流程全部调通再打板做实物能节省大量排查时间。我做这个项目的实际经验是仿真跑通之后去打样第一批板子焊接完成后上电除了OLED排线导致的显示水波纹需要飞线解决之外其他功能一夜之间全部调通这就是仿真调逻辑的价值所在。4.3 仿真中的双晶振问题STM32F103C8的主晶振是8MHzDS3231的晶振是32.768kHz两类晶振在仿真里都要注意。Proteus中STM32芯片模型默认使用内部RC振荡器作为时钟源如果你在原理图里配置了外部晶振但仿真模型没有正确识别程序里又用了HSE外部高速时钟启动会导致芯片跑不起来或者跑飞。解决方法是在STM32的配置属性里把Clock Frequency设置为8MHz同时在代码的SystemInit()里明确使用HSE。更稳妥的做法是在仿真工程属性里打开Model Options设置External Crystal Frequency为8000000。这样仿真里的时钟频率就和实物一致了HAL_GetTick()、定时器定时、串口波特率这些依赖时钟的计算才会准确。DS3231的32.768kHz晶振在Proteus模型里是内置的不需要额外添加外部晶振元件只要确保DS3231的VCC和GND接好模型就能自动产生正确的时间基准。如果你在仿真里发现DS3231的时间不跳秒多半是模型没跑起来检查一下是不是把DS3231的INT引脚闹钟中断输出接到STM32的外部中断引脚上了但忘记启用该引脚的外部中断导致仿真模型认为闹钟功能没有被使用在某些版本里会出现时钟走字但闹钟不触发的问题。4.4 如何模拟“漏服”和“迟服”场景用药管理系统的核心逻辑不只是“到点响铃”还有“响铃了但没人按确认键”的漏服处理。仿真里怎么模拟这个场景我是这样做的设置服药时间比如为10:00:00仿真运行到10点整DS3231触发闹钟中断STM32进入ALERTING状态蜂鸣器鸣叫OLED显示“药格1 该吃药了”。此时不做任何按键操作等10分钟仿真里可以加速时钟运行10:10时状态机会自动把这一次标记为MISS漏服写入Flash记录并退出闹铃状态回到STANDBY。这里用到了一个我前面提过的“迟到判定”逻辑闹钟触发后如果确认键一直没按下超时时间设为600秒超时后按漏服处理。为了让这个超时逻辑在仿真里能快速验证我把超时时间的宏定义写成了可配置项#define ALARM_CONFIRM_TIMEOUT_SEC 600 // 实际使用600秒 #define SIM_CONFIRM_TIMEOUT_SEC 5 // 仿真时改成5秒方便验证仿真验证时把ALARM_CONFIRM_TIMEOUT_SEC临时改成5秒跑一遍漏服流程只需要几秒。确认逻辑无误后再改回600秒重新编译烧录。这种“用宏控制行为差异”的做法在生产调试中也很实用方便在不改业务逻辑的前提下快速切换测试模式和正式模式。迟服DELAY状态的模拟则是闹钟响后过5秒再按确认键仿真里我缩短了超时窗口状态机记录这次服药的响应时间超过了理想窗口标记为DELAY而非OK。这样做的好处是家人查看历史记录时能区分“按时吃了”“晚了一会儿才吃”“完全没吃”三种情况比单纯记录“吃了/没吃”要更有参考价值。5. 常见问题与排查技巧实录5.1 编译报错与目标芯片识别我用Keil MDK做开发环境从标准外设库迁移到HAL库的时候踩了不少坑这里挑几个典型问题分享。“Error: Flash Download failed - Cortex-M3”这是最常见的下载失败问题。出现这个提示先别慌着怀疑代码检查顺序第一确认SWD下载器连接的是SWDIO、SWCLK、GND三根线不要接反第二确认目标板供电正常3.3V指示灯亮第三在Keil的Settings里看是否能识别到芯片ID如果显示“No target found”检查BOOT0是否接低电平以及复位电路是否正常第四如果以上都正常可能是下载器固件问题我把DAP-Link的固件重新刷了一遍就好了。另外很多人搜过的那个错误信息“Error: No STM32 target found! If your product embeds debug authentication, please...”也值得专门说一句。这个提示常见于使用较新的ARM调试探头连接老芯片或者连接线过长、阻抗不匹配的场景。我的排查经验是首先换一根短一点的杜邦线把下载速度从10MHz降到1MHz再试其次在MDK的Flash Download选项卡里勾选Reset and Run让程序下载完成后自动复位运行。降速这招能解决大部分识别问题因为SWD接口在高速模式对线材和接触质量比较敏感尤其杜邦线插在洞洞板上那种松垮的连接方式高速握手很容易失败。如果降速后还不能解决再考虑是不是芯片锁死了——如果之前写入过读保护选项字节需要用ST-Link Utility执行全片擦除解除保护。编译期报错“fatal error: stm32f1xx_hal_conf.h: No such file or directory”这通常是Keil工程里的HAL库头文件路径配置问题。检查Utils对话框里的C/C选项卡的Include Paths确保把STM32F1xx_HAL_Driver/Inc、Drivers/CMSIS/Device/ST/STM32F1xx/Include等目录都加进去了。另外工程里必须定义一个STM32F103xB宏在C/C选项卡的Preprocessor Symbols里这个宏决定了HAL库编译时选用的芯片型号如果没定义会导致编译错误或者某些外设驱动没有编进去。工程能编译但串口输出乱码这个大概率是串口波特率不对。我程序里用了HSE外部晶振8MHz串口初始化时由SystemCoreClock自动计算分频系数如果芯片实际跑在内部RC而不是外部晶振时钟频率偏差会导致串口波特率完全不准。排查方法是先用示波器或者用HAL的SystemCoreClockUpdate()打印时钟值确认CPU主频是不是72MHz。如果主频不对回头看RCC配置里HSE是否启动成功很多时候是外部晶振虚焊或者负载电容不对导致HSE启动失败芯片自动回退到HSI内部8MHz此时SystemCoreClock只有8MHz串口、定时器全部乱套。5.2 仿真不动的常见原因仿真比实物省事但也有自己的坑而且这些坑往往更隐秘。我整理了三个仿真里最高频的问题。**现象一点运行OLED不显示。**很多人的第一反应是驱动代码有问题但绝大多数原因是OLED仿真模型的I2C地址不对。实物SSD1306的7位I2C地址通常是0x3C写地址0x78但Proteus里SSD1306模型的默认地址有可能不同需要双击OLED元件在属性里查看和修改。这个型号的模型还挺挑地址的地址对不上初始化序列发过去石沉大海屏幕自然没反应。**现象二DS3231每秒读出来的时间都是同一个值。**这个我排查了半天最后发现是在Proteus里DS3231的时钟没有开启。双击DS3231元件在属性列表里找到Clock或者Enable选项设置为Enabled/Yes。这个选项在实物上不存在是Proteus模型特有的很容易被忽略。**现象三仿真运行速度极慢一秒要跳好几秒模拟时间。**这个是Proteus的CPU占用问题不是因为代码效率低。解决办法有三个暂停仿真在菜单System → Set Animation Options里把Simulation Speed改成No Limit在Debug菜单里关闭Enable Remote Debugging最重要的一条把仿真里不必要的显示器件减少比如多个LED相邻闪烁的动画会严重拖慢仿真进度。我STM32工程里如果需要跑完整的历史记录写入流程涉及Flash擦写在Proteus里能明显感觉到卡顿这是正常现象不是死机。开机黑屏但似乎程序在跑用Virtual Terminal能看到输出这多半是OLED初始化时序和仿真的差异我通常会在初始化函数末尾加一个简单的自检向OLED写一行测试字符然后用示波器量SDA/SCL是否有波形。仿真里用逻辑分析仪模式抓I2C总线波形看起始信号、地址字节、ACK/NACK是否正常。其实Proteus自带的虚拟仪器里有一个I2C Debugger可以挂在总线上实时打印通信内容比在STM32代码里加串口打印要方便很多。5.3 实物调试中的三个真实翻车案例说完了仿真讲讲实物调试时我亲身踩过的三个大坑给打算打板的小伙伴提前打预防针。**案例一蜂鸣器响个不停按键完全失灵。**这个现象出现得莫名其妙后来排查发现是我把蜂鸣器的驱动引脚和按键的IO口在原理图里画反了蜂鸣器接到了上拉输入的引脚按键接到了推挽输出的引脚。按按键变成了改变蜂鸣器状态推挽输出引脚被按键强制拉低后芯片IO口处于过流状态电压直接拉垮整个板子都半死不活。这个教训的核心是画完原理图之后一定要逐个引脚核对一遍外设连接关系最好把每一类外设所用的引脚列个清单。**案例二OLED显示一段时间后花屏。**花屏花纹没有规律有时候重启又好看起来特别像驱动bug。最后用示波器测了SDA和SCL波形发现SCL的低电平在噪声大的时候会出现毛刺有时候一个时钟周期内出现两个下降沿导致OLED的显存指针错乱。根治办法是SCL和SDA的线上串联33欧姆电阻做阻抗匹配、在OLED电源引脚对地加一个10uF电容吸收瞬态电流、把I2C速率从400kbps降到100kbps。OLED这种器件本来就不追求高速低速换稳定非常划算。**案例三断电重上电后RTC时间回到默认值。**这个问题一看就是DS3231的后备电池没接好。DS3231的VBAT引脚接3V纽扣电池正极电池负极接地如果VBAT浮空芯片内部的主电源掉电后寄存器的内容就丢失了。我第一版样机为了省事没有焊电池座焊了个跳线直接把VBAT接到了3.3V结果断电后3.3V也没了DS3231照样丢时间。后来加上了电池座和一颗CR1220纽扣电池断电测试时间能保持走字这个问题才真正解决。这里补充一个细节DS3231的VBAT电压范围是2.3V到5.5V用3V纽扣电池没问题如果你的系统里有3.6V的锂电池也可以直接接但不能超过5.5V否则会损坏芯片的电源管理部分。5.4 常见问题速查表我把调试中最高频的问题整理成了一张速查表建议保存下来遇到问题对号入座能少走很多弯路问题现象可能原因排查/解决思路程序下载不进芯片SWD接线错误、BOOT0不为低、下载器固件异常检查SWDIO/SWCLK/GND接线确认BOOT0下拉降速至1MHz重试串口输出乱码主时钟频率不对HSE启动失败打印SystemCoreClock确认72MHz检查外部晶振及负载电容OLED屏不亮I2C地址不匹配、I2C总线无上拉、初始化时序错误用I2C扫描程序确认设备地址检查SDA/SCL上拉电阻OLED花屏电源纹波大、I2C速率过高、排线受干扰电源加10uF电容串33欧姆电阻I2C降至100kRTC时间断电丢失VBAT未接电池、电池电压过低确认VBAT接纽扣电池正极万用表测量电池电压闹钟不响RTC闹钟寄存器未使能、INT引脚未接/未配置外部中断检查DS3231闹钟使能位确认INT引脚下降沿触发配置按键偶尔不灵敏消抖参数不合适、按键抖动时间过短/过长调整消抖采样次数或用逻辑分析仪抓按键波形确认抖动区间设备运行一段时间死机看门狗未喂、Flash写入期间中断冲突开启IWDG并定期喂狗Flash写入期间禁用可屏蔽中断Flash记录数据错乱双页轮换逻辑有bug、地址覆盖检查页状态标记写入顺序确认读取时先校验再使用6. 开源工程结构与二次开发建议6.1 目录结构说明我把开源工程压缩包解压之后目录结构是这样的SmartMedicineBox/ ├── README.md # 项目说明与使用指南 ├── Documentation/ │ ├── 原理图_V1.2.pdf # 原理图导出的PDF方便查看 │ └── 使用说明书.docx # 面向用户的简易说明书 ├── Hardware/ │ ├── SmartMedicineBox.SchDoc # Altium Designer原理图 │ ├── SmartMedicineBox.PcbDoc # Altium Designer PCB文件 │ └── BOM表.xlsx # 物料清单含参考价格 ├── Firmware/ │ ├── MDK-ARM/ # Keil工程目录 │ ├── Core/ # 主程序、中断服务函数 │ ├── Drivers/ # HAL库与CMSIS │ ├── App/ # 应用层状态机、业务逻辑 │ ├── Hardware/ # 外设驱动OLED、DS3231、蜂鸣器等 │ └── ThirdParty/ # 第三方库printf重定向等 ├── Simulation/ │ └── SmartMedicineBox.pdsprj # Proteus仿真工程 └── PC_Software/ ├── MedicineBox_Host.py # PyQt5上位机源码 └── requirements.txt # 上位机依赖列表Firmware目录下App和Hardware是重点App里的state_machine.c是状态机核心med_record.c是记录管理模块Hardware里oled.c、ds3231.c、buzzer.c、key.c各自独立接口函数都做了统一的头文件导出。这样的分层结构是为了让不同模块之间的耦合尽可能低改任何一个外设驱动不会影响其他模块。6.2 想改成8个药格或者更多怎么扩展我目前开源的版本是4个药格每条提醒对应一格药一天最多设置4个服药时段。如果你家里老人的药更多需要8格甚至12格扩展思路是这样的硬件层面药格本身只是物理容器和电路无关关键是提醒逻辑要支持多路。我的代码里已经用了一个数组来管理服药计划typedef struct { uint8_t enable; // 是否启用 uint8_t hour; // 小时 uint8_t minute; // 分钟 uint8_t slotId; // 药格编号 uint8_t medicineName[8]; // 药品名缩写 } ScheduleItem; ScheduleItem schedule[4] { {1, 7, 0, 0, JYY}, // 07:00 药格0 降压药 {1, 12, 30, 1, JTY}, // 12:30 药格1 降糖药 {1, 18, 0, 2, JYY}, // 18:00 药格2 降压药 {0, 21, 0, 3, SJQ}, // 21:00 药格3 睡觉前当前禁用 };要扩展成8个药格把schedule数组长度改成8重新规划DS3231闹钟匹配逻辑即可。这里有个硬件相关的注意点DS3231只有两个闹钟寄存器Alarm1和Alarm2但我的设计只用Alarm2做“下一次提醒”的触发当Alarm2触发后软件立即从schedule数组中查找“下一个启用的提醒时间点”写入Alarm2的寄存器作为新的闹钟。这样就不用为每条计划单独分配闹钟硬件一个硬件闹钟通过软件调度可以服务任意数量的计划项。这个思路如果你能理解透往后做任何“定时任务列表”类的功能都会很受用。6.3 增加无线通知模块的思路很多读者问我能不能加一个“家属远程收到提醒”的功能。完全可以在现有硬件基础上增加一个ESP8266 WiFi模块通过串口和STM32连接。当药盒的状态机进入CONFIRMED用户已服药或者MISS漏服状态时STM32向ESP8266发送一条AT指令ESP8266以HTTP POST请求把状态数据推送到云平台比如巴法云、OneNET或者自己服务器上的简单API家属的手机端通过微信公众号或者App接收推送通知。不过要提醒一句增加网络模块会带来供电、稳定性、安全性等一系列新问题。药盒断网的时候要能正常提醒、恢复网络后要能补传漏报的数据需要加一个简单的离线消息队列。这部分我没有合入主工程但代码里串口协议已经是完整的ESP8266只需要按协议解析串口数据帧再转发到网络即可工作量其实不大。如果你感兴趣可以在我的串口协议基础上自己写一个ESP8266的透传固件这是很好的练手方向。6.4 低功耗改进的可能性如果想把药盒改成电池供电、几个月换一次电池的版本有三个方向可以优化第一STM32大部分时间进入STOP模式闹钟通过RTC闹钟中断唤醒芯片第二OLED不是常亮只在闹钟触发和按键操作时点亮30秒30秒无操作自动关闭显示第三蜂鸣器只在闹铃期间工作用PWM驱动而非直流常通。这三个优化改下来整机待机电流可以从50mA级别降到2mA级别配合一节18650电池2000mAh以上能撑很久。我在代码里已经做了低功耗的雏形system_sleep.c里封装了进入STOP模式、配置RTC闹钟唤醒等函数只不过默认编译没开启。你把宏POWER_SAVE_ENABLE定义为1重新编译程序就会在每次操作完成后自动进入低功耗状态唤醒后再执行状态机逻辑。这部分代码建议配合数据手册仔细看STOP模式下的时钟源选择和唤醒延迟处理要仔细验证否则容易出现“能睡但醒不过来”的尴尬局面。7. 实操心得与更多扩展思路最后分享几个我做完这个项目之后最深的体会。第一个体会是嵌入式项目的复杂度不在于单点技术而在于模块之间的交互。每一个外设驱动单独拿出来都不难OLED的I2C时序、DS3231的寄存器读写、按键消抖、Flash读写单独调都能跑通真正考验人的是它们同时工作时的资源竞争和时序配合。比如DS3231的I2C读操作和OLED的I2C写操作共用总线如果不做互斥偶尔会出现写了一半被打断、总线状态错乱的情况。我在所有I2C访问的外围加了全局的临界区保护同一时刻只允许一个模块操作I2C总线这个设计可能占用二三十行代码但避免了绝大多数偶发性的奇怪问题。第二个体会是给真实用户做的设备要考虑的远比技术文档多。药盒写给老人用按键要大、字要清楚、声音要够响、操作逻辑要少还要考虑老人手抖导致误按、听力下降听不清铃声、记性差忘记吃过没吃。这些需求在产品经理嘴里叫“用户体验”在嵌入式工程师这里就是状态机里的超时重响、就是按键的长按短按区分、就是记录里的MISS/DELAY状态区分。顺便说一句药盒的OLED屏幕显示字体我用的是16x16的大字号白底黑字屏幕亮度调到最低因为老人晚上起来吃药屏幕太亮会刺眼。第三个体会是开源不是把代码丢上去就完事了。我在README里写了环境搭建的全部步骤、硬件焊接的注意事项、仿真运行的截图、代码里每个模块的注释甚至包括DS3231备份电池的购买链接。做这些的原因是开源项目的使用门槛决定它的影响力如果别人下载下来两天跑不通项目再好也不会有人用。这个项目的代码和文档前后打磨了近两个月比我自己比赛用的项目还用心。后续如果还有精力我计划再扩展两个方向一个是增加语音播报模块用SYN6288芯片把“该吃降压药了”这句话直接读出来对视力不好的老人来说比视觉显示更友好另一个是做一个简单的手机小程序通过蓝牙BLE接收药盒数据家属打开微信就能看到当天几位老人各自的服药情况。这两个方向都是在现有系统上的增量式扩展不会推翻现有架构说明当初分层设计的余量留得还算够用。做这个项目的初衷其实挺朴素家里有老人出门在外总担心他们忘记吃药。做出来之后送给长辈用了两个星期反馈是“这个东西比设手机闹钟方便字大声音响不会再搞混药了”。那一刻觉得所有熬夜调代码、焊板子、跑仿真都是值得的。如果你也在做类似的项目欢迎对照我的工程跑一遍遇到任何问题都可以把报错日志和现象发给我我们一起把细节抠明白。万一你家里也有老人需要这样一个小盒子希望这份开源工程能帮上一点忙。
返回列表