STM32智能婴儿床系统毕设:传感器采集、PWM控制与调试避坑
2026/9/18 7:37:03 网站建设 项目流程

做单片机毕设的同学里,十个有六个最后会落到 STM32 这个平台上,而智能婴儿床系统几乎是每年都会被翻出来的经典题目。原因很实际:它同时压中了"传感器采集 + 电机执行 + 人机交互 + 安全告警"四类考点,老师一眼就能看出工作量,答辩时也有的聊。但真正动手的时候,多数人卡在同一个地方——功能列表写得漂亮,落到引脚、定时器和电源上就全乱套,最后做出来一个能亮灯、能响铃、温度还会乱跳的半成品。这篇就把智能婴儿床系统从选题收敛、硬件选型、引脚分配、固件架构到调试排坑的整条链路讲清楚,偏向工程落地,适合正在做单片机毕设或者准备自己复刻一套的朋友参考。

1. 为什么我建议先砍功能再画原理图

1.1 从三个"没人愿意做"的现实痛点说起

先想清楚这个题目到底在解决什么。我在帮人看方案的时候,最常听到的表述是"监测温湿度、检测哭声、自动摇床、手机远程查看"。这话没错,但它更像一份产品描述,不是一份毕设描述。真正有说服力的切入点是三个具体的、可被观测的痛点。

第一个痛点是夜间的信息盲区。婴儿床放在主卧或者次卧,父母在另一个房间,或者人在旁边但睡着了,这时候被褥内温湿度变化、孩子踢被子、翻身频繁这些信息没人知道。传统的做法是父母后半夜起来摸一把孩子的后颈,再看一眼被子。这个动作可以被传感器替代,而且这件事是能量化的——温度和湿度都是能出数字的。

第二个痛点是哭声响应的延迟。从孩子开始哭到有人到床边,中间往往隔了几十秒到几分钟。这段时间里如果能有一个短促的、分级的提示(比如先在床头闪灯,超过阈值再蜂鸣),体验上会完全不同。这里就有一个很适合在答辩里讲的点:为什么不做"一哭就响",因为那会成为新的噪声源,做分级告警才是合理的工程判断。

第三个痛点是安抚动作的机械重复。抱着走、轻轻晃,本质上是一个低频、小幅度的往复运动。这件事用电机加一个曲柄或者用舵机做摆臂,都能做出来,而且正好是 PWM 和定时器的绝佳练手场景。

把这三个痛点拆开,功能的优先级就清楚了。温湿度监测和哭声检测是"雪中送炭",摇床是"锦上添花"但也最有观赏性,联网属于"毕设加分项"。我见过太多人一开始就把联网、APP、云端全排进计划,结果硬件还没焊完就到答辩周了。

提示:功能列表的第一版永远要写三列——必做、可做、想做。必做只有三项,其余全部砍到可做和想做里,最后按剩余时间按顺序捡回来。

1.2 一版能跑通的功能清单收敛过程

给一个我自己用过的收敛结果,你可以直接对着改。

优先级功能模块判定标准实现难度
必做床内温湿度采集与显示每 2 秒刷新一次,误差可接受
必做哭声/异常声音检测与分级告警三级提示,误报率可控
必做OLED 本地显示与四按键设置阈值可在线改并掉电保存
必做摇篮电机 PWM 驱动与定时停机有超时互锁保护
可做尿湿检测电极式或湿度传感器
可做体动/离床检测压力或称重方式中高
想做无线数据上报上位机看曲线中高
想做语音模块播报增加成本

这张表的关键不在于列了什么,而在于每一行的"判定标准"是可验证的。答辩老师最烦的一句话是"实现了温度检测功能",他最想听的是"采样周期 2 秒,滑动平均 8 次,阈值迟滞 0.5 摄氏度"。

我还想强调一件事:尿湿检测这一项,很多人做得非常敷衍。拿两根铜箔贴布上,靠水的导电性检测,理论上可行,实际上遇到几个问题——布是湿的但没形成桥接、长时间电解导致铜箔腐蚀发绿、不同尿液的导电率差异大。真想做得像样一点,用湿敏电阻或者电容式湿度传感器(比如贴着床垫的柔性湿度探头)更靠谱。它的原理是吸水后介质常数变化导致电容变化,跟纯导电检测完全不是一个层次。

1.3 一份实际能买到的物料清单与成本估算

下面这份清单是我按常见散件价格整理的,实际下单会有浮动,但量级不会差太多。你可以直接拿去填进论文的"硬件方案"章节。

类别型号/规格数量参考单价
主控STM32F103C8T6 最小系统板112 元
温湿度DHT22 或 SHT30115 / 25 元
声音驻极体麦克风 + LM393 比较器模块14 元
显示0.96 寸 SSD1306 OLED,I2C 版110 元
电机驱动TB6612FNG 双路驱动板18 元
电机12V 减速直流电机或 6V 减速电机115 元
舵机SG90 或 MG996R(选配)18 / 35 元
按键6×6 直插轻触按键40.3 元
蜂鸣器无源蜂鸣器 5V11 元
灯光WS2812B 灯珠或普通 LED 灯条15 元
无线ESP-01S112 元
电源12V/2A 适配器 + MP1584 降压 + AMS11171 套20 元
结构亚克力板/木板、曲柄连杆、螺丝若干40 元

总计在 150 到 200 元之间。这个预算对毕设来说非常友好,也方便在论文里做成本对比表格。有个细节值得注意:TB6612 比 L298N 更值得选。L298N 的导通压降能到 1.5V 到 2V 以上,6V 电机供下去只剩 4V,转速直接掉一截;TB6612 的导通电阻小得多,压降在 0.5V 以内,而且待机电流更低,做电池方案时差距很明显。

2. 主控和外设选型:每一颗芯片背后的取舍

2.1 STM32F103C8T6 与 51 单片机之间的真实差距

每年答辩都有人问:"为什么不用 51 单片机?"这个问题你得准备一个有技术含量的回答,而不是"因为老师要求"。

真正的差距体现在三个地方。第一是定时器资源。这个系统里至少要同时存在三路时间行为:1ms 的系统节拍、20ms 的按键扫描节拍、50Hz 的舵机 PWM。51 单片机普遍只有两个定时器,一旦其中一路被 PWM 占用,系统节拍就得靠软件延时凑,整个程序的实时性立刻崩掉。STM32F103C8T6 有四个通用定时器加两个高级定时器,随便分配。

第二是中断优先级管理。51 的中断只有两级优先级,串口接收和传感器采集之间很容易互相拖累。STM32 的 NVIC 支持多级抢占和子优先级,可以把串口中断放低,把系统节拍放高,让实时任务永远不被通信任务阻塞。

第三是调试能力。STM32 带 SWD 接口,配合 ST-Link 可以单步、断点、看变量、看外设寄存器,调试效率跟串口打印完全不是一个量级。我在第 5 节会讲的那些坑,有一大半是靠 SWD 在线调试才定位下来的。

至于 STC 系列或者国产增强型 51,确实比经典 51 强,引脚和频率也够用,但生态上外设库、示例代码、社区资料的密度还是差一截。做毕设时间紧张,资料密度几乎等于开发速度。

2.2 温湿度、声音、尿湿、体动四类传感器的选型对比

这四个传感器我都在实物上踩过,直接给结论。

温湿度:DHT22(也叫 AM2302)单总线,精度 0.5 摄氏度、2% 相对湿度,价格便宜,缺点是采样周期最快 2 秒,而且时序对微秒级延时敏感,中断期间容易被干扰。SHT30 走 I2C,精度更高,采样快,但贵 10 块钱左右。如果你要频繁刷新,选 SHT30;如果只是2秒一次,DHT22 完全够。不要用 DHT11,湿度精度只有 ±5%,量程还到不了高湿区,测被褥内湿度会很难看。

声音:市面上两种常见模块。一种是 LM393 比较器模块,输出数字高低电平,本质上就是"超过某个音量就翻转",旋钮调阈值;另一种是麦克风加放大,输出模拟包络,需要你自己做 ADC 采样和算法。前者便宜好用,适合做"有声音/没声音"的粗判断;后者灵活,但需要写算法。我的建议是先上 LM393 把流程跑通,有余力再换成模拟采集做能量加过零率的双条件判断,答辩时更容易讲出深度。

尿湿:前面说过,电极式方案问题多。用湿敏电阻模块的话,接法简单,但要注意它是电阻分压输出,得做滤波,不然一滴水下去数值剧烈抖动。

体动/离床:最靠谱的是 HX711 加称重传感器,但 HX711 是 24 位 ADC,需要模拟时序驱动,且读数漂移需要做零点校准和去极值平均,工作量中等偏上。想做省事的就用薄膜压力传感器加运放,只判断"有人/没人"。

传感器接口优点主要坑点
DHT22单总线便宜、精度够用2 秒周期、时序怕中断
SHT30I2C快、精度高价格翻倍
LM393 声音模块GPIO/模拟便宜、接线简单阈值靠电位器,易受环境噪声影响
湿敏电阻ADC接线简单数值抖动大,需滤波
HX711 称重模拟时序精度高、能做体动漂移需校准,代码量大

2.3 执行机构:直流电机、舵机和灯光怎么与"婴儿安全"妥协

这是整个项目里最需要动脑子的一块。电动婴儿床天然带着安全争议,绕不开,所以你的方案里必须要有明确的安全设计,而且要能在答辩的时候讲出来,这反而是加分项。

第一条原则是运动幅度和速度必须被限制在低频小幅度范围。我实际操作里的做法是把 PWM 占空比上限锁死在 60%,运行时间上限设为 10 分钟,超时无条件停止。这两条限制写在固件里,不通过按键开放给用户,属于硬约束。

第二条原则是必须有物理限位或者行程检测。曲柄连杆驱动的摆动本身有机械死点,只要连杆长度和摆臂角度设计好,就不会出现无限旋转。但如果你用的是舵机,就一定要注意舵机的角度范围设置,别让它顶到结构上死磕,时间长了会烧齿轮。

第三条原则是电机供电与逻辑供电分离。这一点我在第 5 节会展开讲,因为它直接决定你的温度数据会不会乱跳。

灯光我倾向于用 WS2812B 或者普通 LED 灯条做氛围提示,因为它的功耗低、无机械动作、响应快。三级告警的视觉部分就交给它:绿色常亮是正常,黄色呼吸是提示,红色快闪是紧急。用 PWM 调亮度的时候记得频率别落在人眼可感知的范围内,一般 1kHz 以上就看不出来闪了。

2.4 OLED、按键、蜂鸣器与无线模块的取舍

OLED 选 I2C 版还是 SPI 版,取决于你的引脚余量。I2C 只占两根线,但刷新率受限,全屏刷新在 400kHz 下大概几十毫秒,做局部刷新完全够。SPI 版快,但要占 5 到 6 根线。我一般选 I2C 版,因为省下来的引脚能留给别的外设。

按键用四个就够:设置、加、减、确认。别小看按键,它是人机交互里最容易出 bug 的地方,消抖、长按、连按三种行为要分开处理。我的做法是按键扫描统一放在 20ms 的节拍里,读到低电平后连续确认 3 次才算按下,长按计时到 800ms 触发长按事件。

蜂鸣器一定要选无源的。有源蜂鸣器只能发一个固定频率的声音,做不出分级告警的音调差异。无源蜂鸣器用定时器输出 PWM,改频率就能改音调,做三档提示音非常方便,而且这几行代码在论文里很好看。

无线模块如果时间充裕,ESP-01S 是最省事的选择,AT 指令发数据上门户,工作量可控。不用花精力去折腾以太网方案,用不上,还多出一堆网络配置和硬件开销。

3. 引脚、定时器和 ADC 的分配表

3.1 先定分配表再画原理图的一条硬规则

我见过太多人先画原理图、先焊板子,等到写代码才发现两个外设抢同一个引脚,或者 PWM 通道冲突。正确的顺序是:先列引脚分配表,确认没有冲突和外设复用错误,再去画原理图。

STM32F103C8T6 的引脚复用是有硬约束的。比如 USART1 固定在 PA9/PA10,I2C1 固定在 PB6/PB7 或重映射到 PB8/PB9,SPI1 在 PA5/PA6/PA7。如果你随口把 OLED 的 SCL 定在 PA6,那就跟 SPI1 的 MISO 撞了,虽然不一定报错,但后面加外设的时候会很难受。

下面是我用的分配表,可以直接参考。

功能引脚外设备注
系统调试PA13/PA14SWD保留,不要占用
OLED SCL/SDAPB6/PB7I2C1硬件 I2C 或 GPIO 模拟
温湿度数据PB12GPIO 单总线配 4.7k 上拉
声音数字量PB0GPIO/EXTI也可以接 ADC
尿湿模拟量PA0ADC1_IN0分压后输入
电机 PWM A/BPA6/PA7TIM3_CH1/CH2双路驱动
电机方向PB1/PB2GPIO控制正反转
舵机 PWMPB8TIM4_CH350Hz
蜂鸣器PB9TIM4_CH4无源
按键 K1-K4PB3/PB4/PB5/PB10GPIO需注意 PB3/PB4 是 JTAG 脚
无线串口PA2/PA3USART2接 ESP-01S

注意:PB3、PB4 默认是 JTAG 的 JTDO 和 NJTRST,要用作普通 GPIO 必须先关闭 JTAG 只保留 SWD,否则这两个键永远读不到。这个坑我在第一版上踩了整整一晚上。

3.2 定时器资源分配:一个 1ms 节拍加两路 PWM

定时器是这个系统的心脏,分配思路是"一个管时间、一个管 PWM"。

TIM2 做 1ms 的系统节拍。配置成向上计数,预分频 71(72MHz/72 = 1MHz),自动重装载值 999,这样每 1000 个计数就是 1ms,开更新中断。所有需要周期执行的任务都挂在这个节拍上,用软件计数器分频出 20ms、100ms、500ms 等不同的时间片。

// TIM2 初始化:72MHz -> 1kHz 计数 -> 1ms 中断 void TIM2_Init_1ms(void) { RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_TimeBaseInitTypeDef tb; tb.TIM_Prescaler = 71; // 72MHz / (71+1) = 1MHz tb.TIM_Period = 999; // 1MHz / 1000 = 1kHz -> 1ms tb.TIM_ClockDivision = TIM_CKD_DIV1; tb.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &tb); TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); TIM_Cmd(TIM2, ENABLE); }

TIM3 出两路 PWM 驱动电机。电机的 PWM 频率不要太高,太高了驱动芯片的开关损耗和噪声都会上来,我一般取 10kHz 到 20kHz 之间。取 20kHz 的好处是正好在人耳听觉上限之上,电机不会有啸叫。计算方式:72MHz 分频到 1MHz,周期 49 就是 1MHz/50 = 20kHz。

// TIM3 CH1/CH2,20kHz PWM void TIM3_PWM_Init(void) { RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitTypeDef gpio; gpio.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); TIM_TimeBaseInitTypeDef tb; tb.TIM_Prescaler = 71; tb.TIM_Period = 49; // 1MHz / 50 = 20kHz tb.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &tb); TIM_OCInitTypeDef oc; oc.TIM_OCMode = TIM_OCMode_PWM1; oc.TIM_OutputState = TIM_OutputState_Enable; oc.TIM_Pulse = 0; // 初始占空比 0,安全第一 oc.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM3, &oc); TIM_OC2Init(TIM3, &oc); TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_OC2PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_Cmd(TIM3, ENABLE); }

TIM4 出舵机 PWM 和蜂鸣器音调。舵机要 50Hz,也就是 20ms 周期,脉宽 0.5ms 到 2.5ms 对应 0 到 180 度。同一个定时器做两件事要注意周期不同的问题:舵机要 20ms 周期,蜂鸣器需要几百 Hz 到几 kHz。这时候要么把蜂鸣器挪到另一个定时器,要么蜂鸣器用软件翻转 GPIO——我一般把蜂鸣器放在 TIM4_CH4 上,周期设置成 100us(10kHz),然后靠"响的时候改占空比、不响的时候设成 0"来控制,这样舵机那一路用同一个定时器的另一通道就行不通了,因为周期被固定成 100us。

所以更干净的做法是:舵机单独占用一个定时器,蜂鸣器用另一个。F103C8T6 有 TIM1、TIM2、TIM3、TIM4,用户任务里 TIM2 做节拍、TIM3 做电机、TIM4 做舵机、蜂鸣器再想办法。我的实际方案是蜂鸣器用 TIM4_CH4 的 PWM,但在不需要舵机的时候才启用,或者在需要同时工作时把蜂鸣器改成 GPIO 软件翻转,用一个 100us 的节拍计数器自己生成方波。这是个很典型的资源冲突,写方案时就要想清楚,不要等做到一半才发现。

3.3 ADC 采样、基准电压与滤波的关系

STM32F103 的 ADC 是 12 位,参考电压默认就是 VDDA,一般就是 3.3V。所以最小分辨力是 3.3V/4096,大约 0.8mV。这个精度对尿湿检测够用,但前提是你的模拟前端不要太离谱。

尿湿检测的传感器本质是个可变电阻,接法是跟一个固定电阻分压,中间点接 ADC。固定电阻选 10k 比较通用。这里有个容易忽略的细节:分压点的阻抗不能太高。STM32 的 ADC 采样保持电路要求源阻抗在几十 k 以内,否则采样电容充不满,读出来的值会偏低而且不稳定。如果你发现读数总是比万用表量出来的低一截,八成就是源阻抗太高,加个电压跟随器或者减小分压电阻就行。

再一个就是滤波。我一般用"中值滤波 + 一阶低通"的组合:先取 9 个点排序取中间值去掉脉冲干扰,再做低通平滑。这两步的代码不长,效果立竿见影。

// 中值滤波 + 一阶低通 #define MED_N 9 static uint16_t med_buf[MED_N]; static uint16_t MedianFilter(uint16_t new_val) { uint16_t tmp[MED_N]; uint8_t i, j; for (i = MED_N - 1; i > 0; i--) med_buf[i] = med_buf[i - 1]; med_buf[0] = new_val; for (i = 0; i < MED_N; i++) tmp[i] = med_buf[i]; for (i = 0; i < MED_N - 1; i++) { for (j = 0; j < MED_N - 1 - i; j++) { if (tmp[j] > tmp[j + 1]) { uint16_t t = tmp[j]; tmp[j] = tmp[j + 1]; tmp[j + 1] = t; } } } return tmp[MED_N / 2]; } static float LowPass(float old_val, float new_val, float alpha) { return old_val + alpha * (new_val - old_val); // alpha 取 0.1~0.3 }

这里要解释清楚alpha 为什么取 0.1 到 0.3。alpha 越小,滤波越平滑但响应越慢;alpha 越大,响应快但噪声大。温度这种慢变量用 0.1 就行,尿湿这种需要一定响应速度的用 0.25 左右。答辩时被问"为什么是这个值",你可以说这是实测几组后的折中,同时把响应时间的测试数据甩出来,效果比空谈理论好得多。

3.4 电源这块,别让电机把逻辑电路一起带走

电源方案是新手最容易忽略、老手最重视的一块。整个系统里有两个截然不同的负载:一个是逻辑电路,3.3V,电流一两百毫安,非常怕电压波动;另一个是电机,6V 或者 12V,启动瞬间电流能冲到 1A 以上,换向时还会产生强烈的反向电动势。

如果用一套电源直接供电,电机启动的瞬间 3.3V 会被拽下去,STM32 可能出现复位,ADC 读数会跳,严重的时候 OLED 直接白屏。我第一版就是这么干的,调了三天才反应过来。

正确的做法是四层防护。第一层,逻辑和电机各自独立供电或者至少独立稳压,用一个单独的降压模块给逻辑电路。第二层,电源入口加电感加电容,电感和一个电解电容构成 LC 滤波,拦住高频纹波。第三层,电机两端并一个 100nF 陶瓷电容加一个 1000uF 电解电容,吸收换向尖峰。第四层,PCB 或者洞洞板上把大电流走线和模拟地分开,最后单点汇总到电源地

成本上只多花不到十块钱,但稳定性完全不是一个量级。

4. 固件骨架:裸机也得有时间片思维

4.1 为什么要坚决放弃 delay 型写法

初学者的程序长得都差不多:初始化,然后 while(1) 里读温度、延时 500ms、读湿度、延时 500ms、检查声音、延时 500ms。这种写法在功能单一的时候没问题,但在这个系统里是灾难。

原因是这个系统里有多个必须及时响应的事件。声音检测要快,慢了就漏掉了;按键要快,慢了手感很差;OLED 刷新可以慢,200ms 一次也无所谓;温度采集最快也就 2 秒一次。用统一延时的写法,只能满足其中最慢的一个,其他全被拖死。

解决方式就是 1ms 的定时器中断 + 时间片轮询。中断里只做一件事:把各个软件计数器减一或者自增,同时处理最紧急的事情(比如喂狗、按键扫描)。主循环里根据标志位执行不同的任务。

volatile uint16_t tick_20ms = 0; volatile uint16_t tick_200ms = 0; volatile uint16_t tick_2s = 0; volatile uint8_t flag_key_scan = 0; volatile uint8_t flag_display = 0; volatile uint8_t flag_sensor = 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); if (tick_20ms) tick_20ms--; else { tick_20ms = 20; flag_key_scan = 1; } if (tick_200ms) tick_200ms--; else { tick_200ms = 200; flag_display = 1; } if (tick_2s) tick_2s--; else { tick_2s = 2000; flag_sensor = 1; } Key_Tick(); // 按键状态机推进 Alarm_Tick(); // 报警计时推进 } } int main(void) { SystemInit(); TIM2_Init_1ms(); while (1) { if (flag_key_scan) { flag_key_scan = 0; Key_Process(); } if (flag_display) { flag_display = 0; OLED_Refresh(); } if (flag_sensor) { flag_sensor = 0; Sensor_Read_All(); } Motor_Task(); // 电机状态机,内部自己判断时间 } }

这套骨架带来的最大好处是每个任务的执行周期是独立可控的,改一个任务的频率不影响其他任务。这种结构在论文里可以写成"基于时间片轮询的非阻塞调度",听起来很专业,实际上确实就是这么回事。

4.2 DHT22 的微秒级时序与中断冲突怎么处理

DHT22 是单总线协议,靠高低电平的持续时间传递数据,时间单位是微秒。问题在于你的系统每 1ms 会有一次定时器中断,如果中断恰好插在 DHT22 读数据的中间,那段时序就被拉长了,读出来的数据全是 0xFF 或者全 0。

解决方案有三个,我都试过。第一种是读传感器期间关全局中断,读完立刻打开。DHT22 一次读取大约 5ms,关 5ms 中断对系统影响不大,节拍会轻微漂移,可以接受。第二种是读之前先关,读再重试,失败就重来一次,最多三次,避免总线真的出问题时卡死。第三种是彻底换 SHT30 走 I2C,I2C 有 ACK 机制,抗干扰强得多。

uint8_t DHT22_Read(float *temp, float *humi) { uint8_t retry = 0; while (retry++ < 3) { __disable_irq(); // 关中断保护时序 uint8_t ok = DHT22_Read_Raw(temp, humi); __enable_irq(); // 立刻恢复 if (ok && *temp > -40.0f && *temp < 80.0f && *humi <= 100.0f) { return 1; } Delay_ms(10); } return 0; // 三次都失败,上报错误 }

提示:读失败不要直接显示乱码,一定要有"传感器异常"这个状态位,并在 OLED 上展示出来。答辩时老师很可能拔掉传感器看你系统怎么反应,有异常处理逻辑的会明显加分。

4.3 状态机写电机控制与三级告警

电机控制绝对不能用"开电机然后 delay 十分钟"的写法,必须是一个状态机。状态有:空闲、启动加速、稳定运转、减速停止、超时保护。每个状态里根据当前速度和目标速度做微调,到达时间上限就无条件进入停止。

typedef enum { MOT_IDLE, MOT_ACCEL, MOT_RUN, MOT_DECEL, MOT_LOCK } MotState; static MotState mot_state = MOT_IDLE; static uint32_t mot_run_ms = 0; void Motor_Task(void) { switch (mot_state) { case MOT_IDLE: Motor_SetDuty(0); break; case MOT_ACCEL: Motor_Ramp_Up(); // 每 50ms 加 5% 占空比 if (Motor_GetDuty() >= MOT_DUTY_MAX) mot_state = MOT_RUN; break; case MOT_RUN: mot_run_ms += 20; if (mot_run_ms >= MOT_RUN_LIMIT_MS) { // 默认 10 分钟 mot_state = MOT_DECEL; } break; case MOT_DECEL: Motor_Ramp_Down(); if (Motor_GetDuty() == 0) { mot_run_ms = 0; mot_state = MOT_IDLE; } break; case MOT_LOCK: Motor_SetDuty(0); // 故障锁定,需要人工复位 break; } }

告警也做成三级状态机。第一级是灯光提示,第二级是灯光加短鸣,第三级是灯光加连续鸣叫并且停止摇床。这里的判定依据建议用迟滞而不是单阈值:比如声音强度超过阈值持续 1 秒进二级,持续 5 秒进三级,但降到阈值 70% 以下才降级。这样能避免在阈值边缘反复横跳,答辩演示的时候会稳定很多。

4.4 参数掉电保存与 Flash 读写

阈值这些参数必须能掉电保存,否则每次上电都要重设,体验很差。STM32F103C8T6 内部有 64KB Flash,程序占不了那么多,可以拿最后一页(1KB)当参数区。

关键点有三个。第一,Flash 写之前必须先擦除,擦除的最小单位是页,不是字节。第二,擦写期间不能执行同一块 Flash 里的代码,所以要么把执行代码放到其他地方,要么在擦写的时候保证不取值。保险的做法是先关中断、把参数复制到 RAM 缓冲区、擦除、写入、再开中断。第三,一定要加校验和,比如写入 10 个字节的参数再加一个字节的累加和,读出来校验不对就用默认值。

#define PARAM_ADDR 0x0800FC00UL #define PARAM_MAGIC 0x5A5A typedef struct { uint16_t magic; int16_t temp_hi; // 温度上限,单位 0.1 摄氏度 int16_t temp_lo; uint16_t sound_th; // 声音阈值 uint16_t run_limit; // 运行时长上限,单位秒 uint8_t checksum; } Param_t; void Param_Save(const Param_t *p) { Param_t tmp = *p; tmp.magic = PARAM_MAGIC; uint8_t *raw = (uint8_t *)&tmp; uint8_t sum = 0; for (int i = 0; i < (int)sizeof(Param_t) - 1; i++) sum += raw[i]; tmp.checksum = sum; FLASH_Unlock(); FLASH_ErasePage(PARAM_ADDR); uint16_t *pdata = (uint16_t *)&tmp; for (int i = 0; i < (int)(sizeof(Param_t) / 2); i++) { FLASH_ProgramHalfWord(PARAM_ADDR + i * 2, pdata[i]); } FLASH_Lock(); }

这段代码在论文里其实挺有分量,因为它体现了"参数持久化 + 完整性校验"的工程思维,比单纯讲"我实现了设置功能"要扎实。

5. 调试阶段真实踩过的六个坑

5.1 电机一转,温度就跳变三度

这是我遇到过的第一个真正棘手的 bug。现象很奇怪:电机不转的时候温度读数稳定在 25.2 摄氏度左右,电机一转,温度就往上飘,最高到 28 度多,电机停下又慢慢恢复。

排查过程我走了三步。第一步,确认传感器本身没问题:把 DHT22 拔下来接到另一块开发板上单独测,数据稳定,排除传感器。第二步,看现象跟什么相关:发现漂移幅度跟电机占空比正相关,占空比越大飘得越多,而且跟电机正反转也有关系。这就基本锁定了是电机换向产生的电气干扰。第三步,用示波器看电源纹波,果然在电机启动瞬间,3.3V 上出现了 300mV 左右的尖峰。

修复方案做了三件事:给电机供电和逻辑供电各用一路稳压;电机端子并上 100nF 陶瓷电容和 1000uF 电解电容,尽量靠近电机;PCB 上把电机的地单独走线,最后单点接到主电源地。改完之后温度漂移从 3 度降到 0.2 度以内,属于可接受范围。

这个坑的价值在于:它把"电源完整性"这个概念从课本变成了你真正见过的东西,答辩时讲出来非常有说服力。

5.2 I2C 死锁与 OLED 白屏

第二个坑是 OLED 偶尔白屏,重启就好,但过一会儿又白。一开始以为是驱动问题,换了好几个库都不行。

后来才发现是 I2C 总线死锁。硬件 I2C 在主机复位或者噪声干扰的时候,有可能从机把 SDA 一直拉低,主机无论怎么发时序都读不到 ACK,程序就在等待标志位的地方卡死。因为我的 OLED 刷新放在主循环里,卡住之后整个 while(1) 就停了,看起来就像"死机白屏"。

修复方式是两条腿走路。一是给 I2C 操作加超时,读或者写的时候加一个计数上限,超了就强制复位 I2C 外设,重新初始化。二是给整机加独立看门狗,万一软件真的卡死在某个地方,1 秒后自动复位。

// I2C 超时保护示例 static uint8_t I2C_WaitEvent(uint32_t event, uint32_t timeout) { while (!I2C_CheckEvent(I2C1, event)) { if (timeout-- == 0) { I2C_SoftwareResetCmd(I2C1, ENABLE); I2C_SoftwareResetCmd(I2C1, DISABLE); I2C_Init(I2C1, &i2c_cfg); // 重新初始化 return 0; } } return 1; }

如果你的项目对实时性要求没那么高,另一个更省心的方案是用 GPIO 模拟 I2C。软件模拟的 I2C 每一拍你都控制得住,超时逻辑自己写,死锁的概率低很多,而且引脚随便选,不占用硬件 I2C 资源。

5.3 打开看门狗之后系统反复复位

第三个坑是加看门狗之后系统一直在重启。现象是 LED 大概 1 秒闪一次,OLED 永远停留在开机画面。

原因在于喂狗的位置放错了。我一开始把喂狗放在 1ms 的定时器中断里,想当然觉得这样最稳妥。结果发现系统仍然会复位,因为真正卡死的地方在中断之外——比如 DHT22 读取超时、OLED 等 ACK。中断还在正常跑,狗一直在喂,但主循环早就停了,看门狗完全没起到作用。

正确做法是:看门狗必须在主循环里喂,而且要确认主循环跑到了预期的位置。我改成用一个"任务完成标志",主循环每遍历完一圈把所有任务都执行一遍之后才喂狗,这样任何一个任务卡死都会导致超时复位。

还有一个参数要算清楚。STM32F103 的独立看门狗用的是内部 LSI 时钟,标称 40kHz,实际可能在 30kHz 到 60kHz 之间波动。溢出时间 = (4 × 2^分频系数 × 重载值) / LSI 频率。取分频 64、重载 625,理论上是 40kHz 下 1 秒,实际可能在 0.67 秒到 1.33 秒之间。所以喂狗周期设计成 200ms 比较稳妥,别卡在临界值上。

void IWDG_Init(void) { IWDG_WriteAccessCmd(IWDG_WriteAccess_Enable); IWDG_SetPrescaler(IWDG_Prescaler_64); // 40kHz / 64 = 625Hz IWDG_SetReload(625); // 625Hz / 625 = 1Hz -> 约 1s IWDG_ReloadCounter(); IWDG_Enable(); }

5.4 声音模块白天一直报警

第四个坑是白天声音模块疯狂触发,晚上又正常。这个问题很有意思,因为它暴露的是阈值设定的场地依赖

LM393 模块上有个电位器,调的是比较阈值。我在安静的实验室调好了,搬到宿舍就炸了。原因是环境噪声水平差异巨大——宿舍里有空调、有人说话、有键盘声,整体底噪就高。

解决方案有两层。软件层面加上持续时间判定:不是超过阈值就报警,而是连续超过阈值 1 秒才报警,这样短促的偶发噪声会被过滤掉。硬件层面加一个自动阈值调整:开机或者空闲的时候先采样 5 秒环境噪声,取平均值加一个余量作为动态阈值,这样换环境也能自适应。

答辩演示的时候,这条逻辑一定要提前演示一遍——先安静,然后用手拍一下床板,看它多久触发,再拍几下,说清"持续判定"的机制。老师看到你有意处理误报,印象分立刻上去。

5.5 按键抖动、长按与连按的判断

第五个坑是按键。最初写的按键代码是"检测到低电平就算按下",结果一次按下经常被识别成三四次。加了个 10ms 延时消抖,连按又不好用了。

最后落地的是一个比较完整的按键状态机,核心是三个时间常数:消抖确认时间 20ms、长按判定时间 800ms、连按重复间隔 200ms。按键扫描放在 20ms 的时间片里,每次记录当前状态,跟上一个状态比较,稳定 3 次才认。

typedef struct { uint8_t stable; // 消抖后的稳定电平 uint8_t last; // 上一次采样 uint8_t cnt; // 连续相同次数 uint16_t hold_ms; // 按住时长 uint8_t long_fired; // 长按已触发标志 } Key_t; void Key_Tick(void) { for (int i = 0; i < KEY_NUM; i++) { uint8_t cur = KEY_Read(i); // 0 表示按下 if (cur == keys[i].last) { if (keys[i].cnt < 5) keys[i].cnt++; } else { keys[i].cnt = 0; keys[i].last = cur; } if (keys[i].cnt >= 3 && keys[i].stable != cur) { // 20ms * 3 = 60ms 稳定 keys[i].stable = cur; if (cur == 0) { // 刚按下 keys[i].hold_ms = 0; keys[i].long_fired = 0; } else { // 刚抬起 if (!keys[i].long_fired) { Key_OnClick(i); // 短按事件 } } } if (keys[i].stable == 0) { // 按住期间 keys[i].hold_ms += 20; if (keys[i].hold_ms >= 800 && !keys[i].long_fired) { keys[i].long_fired = 1; Key_OnLongPress(i); // 长按事件 } else if (keys[i].hold_ms >= 800 && keys[i].hold_ms % 200 == 0) { Key_OnRepeat(i); // 连按事件 } } } }

注意:短按事件要放在"抬起"的时刻处理,而不是"按下"的时刻。否则长按的时候会先触发一次短按,再触发长按,用户感受很怪。

5.6 串口打印一切正常、脱机跑就出问题

第六个坑最隐蔽。现象是插着 USB 转串口调试的时候一切正常,一拔掉串口线、脱机运行,系统就开始不定期抽风。

原因有两个。一是串口打印本身有阻塞性。如果你用阻塞式发送,每次打印都要等发送完成,这会拖慢主循环,而插着串口线的时候,接收端一直在读,缓冲区不会满,所以感觉正常;一旦拔掉,如果没有正确处理 TXE 标志或者缓冲区溢出,就可能卡住。二是开发板的供电方式变化。插着调试器的时候,开发板可能由调试器供电,纹波很小;脱机之后走的是自己的电源模块,纹波大得多,之前被掩盖的电源问题就暴露了。

对应的排查方法很朴素但很有效:把所有调试打印改成一个可控的宏开关,脱机测试时全部关掉;同时用万用表加示波器确认脱机状态下的电源质量。另外,输出重定向的printf最好换成自己写的轻量级发送函数,避免库函数的额外开销。

这个问题给我的教训是:"在调试器下正常"不等于"系统正常"。每次重要功能做完,一定要做一轮完全脱机的运行测试,时间不少于连续两小时。

6. 无线上报、演示脚本与资料整理

6.1 用 ESP-01S 做数据上报的最简协议

无线部分如果时间紧张,不要碰复杂的协议栈,用 ESP-01S 走串口 AT 指令发数据到自己的上位机或者门户就够了。关键是设计一个简单到不会出错的帧格式

我用的是这样的文本帧:以$开头,以#结尾,字段用逗号分隔,最后加一个累加校验。

$BED,25.3,58,1,0,42#\r\n

字段依次是:温度、湿度、告警等级、电机状态、声音强度。解析端按逗号切分,校验不过就丢掉整帧。这种格式的好处是肉眼可读、调试方便、容错简单。二进制协议看着高级,但联调的时候你根本不知道发出来的是什么,很容易浪费时间。

通信异常一定要有超时重连。ESP-01S 的 AT 指令响应时间是变化的,别写死等待,要做重试和超时。整个通信流程也最好放到一个独立的低优先级任务里,不要阻塞主循环。

6.2 答辩八分钟怎么演示才不翻车

演示脚本我给一个实际用过的版本,八分钟能讲完,节奏比较紧凑。

前三十秒讲痛点:不念 PPT,直接说"我在实际调研里发现,婴儿床最需要解决的是夜间信息盲区和哭声响应延迟这两个问题",然后一句话说清你的方案。两分钟讲硬件:把板子拿起来,指着讲主控、传感器、电机驱动、电源分区的布局,重点提电源隔离和看门狗这两处设计。三分钟做功能演示:温度用捂热或者吹气让它变化,湿度用湿棉签靠近传感器,哭声用手机放一段录音,看三级告警的递进,然后按按键改阈值并断电重启验证保存生效。两分钟讲代码结构:展示时间片调度的主循环和状态机。最后一分钟留给提问。

演示最容易翻车的三个点:一是电机不动,提前检查电池电量和驱动接线;二是传感器读不到,准备一个备用传感器;三是无线断连,演示前把上位机连好,如果断了就临时用屏幕上的数据讲,不要慌。

6.3 代码工程和论文素材怎么整理

最后说一个很实际的事:答辩结束后老师可能会翻你的代码,一个结构混乱的工程会大幅减分。建议按功能分文件:bsp_timer.cbsp_i2c.csensor_dht22.capp_motor.capp_alarm.cparam_flash.c,每个文件配一个头文件。主函数里除了初始化和主循环调度,不要放业务逻辑。

论文素材要提前攒。每次调试遇到的问题、测出来的数据、改前后的对比,随手记在一个文档里。等到写论文的时候你会发现,最难写的"实验结果与分析"章节,素材早就有了。我建议至少准备三张表:外设引脚分配表、传感器性能对比表、连续运行测试记录表。有这三张表,论文的硬件和测试部分基本就有着落了。

还有一个容易被忽略的细节:代码里所有魔法数字都要定义成宏。比如MOT_DUTY_MAXMOT_RUN_LIMIT_MSTEMP_HI_DEFAULT,答辩时老师问"你这个占空比上限怎么定的",你能直接指着宏说"这里定义的 60%,是考虑到结构摆幅和安全余量做的折中",比现场翻代码找数字要专业得多。

我自己做完这套东西之后最大的体会是:单片机项目真正难的地方从来不是"会不会写某个外设的驱动",那些都是查手册能解决的事。难的是多个模块同时工作时的相互干扰——电机拖累温度、看门狗掩盖死锁、串口打印掩盖实时性问题。这些只有在系统真正跑起来、连续运行几个小时之后才会暴露。所以如果你现在还在方案阶段,建议早点把硬件搭起来跑通最小功能,把剩下的时间全留给调试和稳定性验证,这部分投入产出比最高。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询