简介:基于STM32F103的多传感器护理监测项目,面向嵌入式开发者和物联网爱好者,集成DHT11温湿度、微波生命雷达、红外体温、一氧化碳、液晶屏及WiFi等模块,可实时监测老人或病人的环境与生理状态,适用于智慧养老、病房监护等场景。压缩包共184个文件、约3.8MB,以15个C源文件和15个头文件为核心,完整实现各传感器驱动与主控逻辑;uvprojx/uvoptx为Keil工程配置文件,hex/axf为编译输出程序,lst/map辅助编译调试,另有bat脚本等工具文件,整体结构清晰,便于按模块对照学习。当前已有46人学习下载。代码基于KEIL标准库编写,注释详细,各模块接线均在代码中通过宏定义给出,配合J-Link或ST-Link即可下载调试;若更换STM32F103其它型号,只需调整芯片型号与Flash容量。对于需要快速完成多传感器融合项目或开发健康监测原型的开发者,可显著节省驱动编写与联调时间。
1. 老人护理监测仪:五路传感器在 STM32F103 上的一次完整拼装
深夜值班室屏幕上弹出这样一条记录:室内一氧化碳浓度 982ppm,微波雷达判定目标处于连续微动状态,红外体温读到 36.8℃。这说明老人已经醒来、燃气泄漏正在逼近危险阈值,而传感器给出的这一组数据足以让护理人员提前介入。这台基于 STM32F103 的护理监测仪,把 DHT11 温湿度、微波生命雷达、MLX90614 红外体温、MQ-7 一氧化碳、TFT 液晶屏和 WiFi 模块拼成了一条完整的信号采集链路,代码全部跑在 KEIL 标准库上,不依赖 RTOS,也不需要 HAL。对正在做医疗电子原型、养老设备 DEMO 或毕设进阶的开发者来说,这套工程的价值在于把最容易出错的传感器时序和通信协议先替你走通了一遍,剩下的事情就是对照接线改参数。
2. DHT11 与 MQ-7:从单总线时序到 ADC 浓度换算
2.1 DHT11 为什么坚持用 GPIO 模拟单总线,而不走 IIC
DHT11 的通信协议是单总线,一根数据线既做输入又做输出。市面上有带 IIC 接口的温湿度传感器,但 DHT11 的出货量决定了它便宜、替换容易,而且它要求的时序窗口相对宽松,只要主机拉低时间、释放后的采样点落在合理区间,读数就稳定。真正的坑在于很多人用系统滴答做延时,中断一多时序就被打断,所以我在这类项目里一般直接用 GPIO 模拟时序,代码里也是这么处理的。
uint8_t DHT11_Read_Data(uint8_t *hum, uint8_t *hum_dec, uint8_t *temp, uint8_t *temp_dec) { uint8_t buf[5] = {0}; uint8_t i; DHT11_Output_Mode(); // 切换为推挽输出 DHT11_DQ_0(); delay_us(20); // 主机拉低至少 18ms,这里取 20ms 余量 DHT11_DQ_1(); delay_us(30); // 拉高 20~40us 后释放总线 DHT11_Input_Mode(); // 转为上拉输入,等待从机响应 if (!DHT11_DQ_READ()) // 从机响应:先拉低 80us 再拉高 80us { while (!DHT11_DQ_READ()); // 等待响应低电平结束 while (DHT11_DQ_READ()); // 等待响应高电平结束 for (i = 0; i < 40; i++) // 40 位数据:湿度整数/小数、温度整数/小数、校验 { while (!DHT11_DQ_READ()); // 每个 bit 前的低电平 delay_us(40); // 在 40us 处采样判断 0 或 1 buf[i / 8] <<= 1; if (DHT11_DQ_READ()) buf[i / 8] |= 0x01; while (DHT11_DQ_READ()); // 等待该 bit 高电平结束 } if (buf[0] + buf[1] + buf[2] + buf[3] == buf[4]) { *hum = buf[0]; *hum_dec = buf[1]; *temp = buf[2]; *temp_dec = buf[3]; return 0; } } return 1; // 超时或校验失败 }这段代码的关键点在两个while循环和delay_us(40)的采样点。DHT11 数据位用高电平宽度区分 0 和 1,35us 以下算 0,70us 左右算 1,所以在 40us 处读一次电平就能把两类位区分开。注意delay_us(30)必须在切换到输入模式前完成,否则上拉还没稳定,读到的第一个响应电平会抖动。这里的buf[0] + buf[1] + buf[2] + buf[3] == buf[4]是低八位校验,加出来的值超过 255 时只取低 8 位参与比较,所以直接用单字节变量相加即可,不用人为做& 0xFF。
提示:DHT11 的读取周期建议不小于 1 秒,连续读会把从机拉入忙状态,返回的数据全是 0xFF。
2.2 MQ-7 的 ADC 采样与预热校准
MQ-7 是一氧化碳传感器,输出是模拟电压,接 STM32F103 的 ADC 引脚即可。它内部有一个加热电阻,上电初期需要预热,冷启动头几分钟读数会漂,这是正常现象,项目里一般会做 60 秒的启动抑制:前 60 秒只显示---,不参与报警判断。
uint16_t MQ7_Read_ADC(void) { uint16_t sum = 0; uint8_t i; for (i = 0; i < 8; i++) // 连续采样 8 次 { ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC)); // 等待转换完成 sum += ADC_GetConversionValue(ADC1); } return sum / 8; // 滑动平均,MQ-7 输出本身有波动 }换算浓度时不要直接拿 ADC 值当 ppm,常见做法是采集洁净空气中的基准电压V0,再用Rs/R0曲线反查。STM32F103 的 ADC 是 12 位,参考电压 3.3V,所以Vout = adc * 3.3 / 4096。我一般会在代码里留两个宏:MQ7_BASE_VOLTAGE和MQ7_ALARM_PPM,校准环境不同,改宏比改逻辑快得多。如果手头没有标准气源,至少做两级阈值:300ppm 预警、1000ppm 报警,避免单阈值在预热期误触发。
MQ-7 的加热电压建议 5V 供电,而 STM32F103 的 ADC 引脚耐压是 3.3V。很多新手直接把 MQ-7 的 AOUT 接到 PA1,结果分压不对,读数一直满量程。正确接法是加一个 10k 对地分压电阻,或者用运放做电平搬移。代码里的接线定义其实已经把这个分压电阻当成默认配置了,硬件不一致时先量 AOUT 引脚电压,确认在 0~3.3V 范围内再接。
2.3 项目接线约束与代码对照
这套工程的接线定义全部写在代码头部,按模块分块注释。下表是这套案例中较常规的一组对应关系,实际以代码中#define的引脚为准:
| 模块 | 信号线 | STM32F103 引脚 | 说明 |
|---|---|---|---|
| DHT11 | DAT | PA0 | 单总线数据,需 4.7k 上拉 |
| MQ-7 | AOUT | PA1 | ADC1_IN1,带分压电阻 |
| 微波雷达 | RX | PA2 | 串口 2 接收 |
| 微波雷达 | TX | PA3 | 串口 2 发送 |
| MLX90614 | SCL | PB6 | IIC 时钟 |
| MLX90614 | SDA | PB7 | IIC 数据 |
| TFT 液晶屏 | FSMC | PD0~PD15 | 16 位并口数据线 |
| WiFi 模块 | TX/RX | PB10/PB11 | 串口 3 |
从上表能看出一个设计原则:把低速、时序敏感的传感器放在 GPIO 或 IIC 引脚上,把需要连续数据流的雷达和 WiFi 放到独立串口上。这样既不会让 DHT11 的延时被雷达中断干扰,也不会让 WiFi 的 AT 响应和传感器数据在同一个串口里互相污染。
DHT11 的上拉电阻是必须的,GPIO 内部上拉阻值在 30~50k,对单总线来说偏弱,数据线长一点就会出现偶发读错。4.7k 外置上拉可以保证总线释放时电平爬升够快,这是排查“数据每隔几个周期跳一次”时最先要检查的地方。
3. 生命体征的感知:雷达存在检测与红外体温补偿
3.1 微波雷达选型:为什么不做简单人体感应,而是存在检测
传统 PIR 人体感应只能输出“有没有动”,老人静卧不动就判断为无人,这是护理场景不能接受的。微波生命雷达工作在 24GHz 频段,能区分“目标存在”“目标运动”“目标静止”三种状态,静止的呼吸起伏也会被微多普勒效应捕获。实际项目中常用带串口输出的 24GHz 雷达模块,接线少,数据帧里直接给出目标状态、距离和能量值,STM32F103 只需要在串口中断里收帧。
这类雷达模块的典型帧格式如下,不同固件字节偏移可能略有差异,但帧头、校验机制基本一致:
| 字段 | 字节数 | 含义 |
|---|---|---|
| 帧头 | 2 | 0xF4 0xF3 |
| 数据字段 | 1 | 固定数据标识 |
| 目标状态 | 1 | 0 无人 1 有人 2 有人且运动 |
| 移动距离 | 1 | 单位 cm |
| 移动能量 | 1 | 0~100 |
| 静止距离 | 1 | 单位 cm |
| 静止能量 | 1 | 0~100 |
| 校验 | 1 | 前面所有字节求和取低 8 位 |
3.2 雷达帧解析与目标状态机
接收端用串口 2 的中断把字节攒进环形缓冲区,然后逐帧校验。这里的关键是不要让校验逻辑在中断里执行,中断只做存字节,主循环里再解析,否则一帧数据里任何一个字节的间隔抖动都会导致丢帧。
// frame: 完整一帧, len: 帧长, 通过指针回传目标状态和两个距离值 uint8_t Radar_Parse_Frame(uint8_t *frame, uint8_t len, uint8_t *state, uint8_t *move_d, uint8_t *static_d) { uint8_t sum = 0, i; if (len < 12 || frame[0] != 0xF4 || frame[1] != 0xF3) return 0; for (i = 2; i < len - 1; i++) // 从数据字段到静止能量,全部参与校验 sum += frame[i]; if ((sum & 0xFF) != frame[len - 1]) // 校验错误直接丢弃整帧 return 0; *state = frame[6]; // 官方数据手册中目标状态所在字节 *move_d = frame[7]; // 移动目标距离 *static_d = frame[9]; // 静止目标距离 return 1; }解析完成后不要直接拿单帧状态去报警,雷达在目标由动转静时会有一段时间输出不稳定。我一般会维护一个 5 帧的状态窗口:连续 3 帧为无人,才把人员状态置为无人;连续 2 帧为有人,就立刻置为有人。这样既滤掉了瞬时误判,又不会在老人跌倒时延迟太久。
目标状态机和报警阈值放在一起处理:state==2且持续 10 秒以上是“活动异常”;static_d小于雷达死区(通常是 20cm)且能量持续走低,则可能是跌倒后无动作。这比单一阈值判断可靠得多。
3.3 MLX90614 红外体温的读取与距离补偿
MLX90614 是非接触红外测温模块,SMBus 接口,地址固定为 0x5A,读取 RAM 地址 0x07 得到物体温度,0x06 得到环境温度。标准库的硬件 IIC 在 STM32F103 上使用时要处理总线错误和仲裁,项目里用模拟 IIC 反而稳定,因为 MLX90614 的时钟频率要求并不高。
// 读 MLX90614 的 RAM 寄存器,addr 传 0x07 表示物体温度 // 返回值需要乘以 0.02 再减去 273.15 才是摄氏度 uint16_t MLX90614_Read_RAM(uint8_t addr) { uint16_t data; I2C_Start(); I2C_WriteByte(0x5A << 1); // 设备地址 + 写位 I2C_ReadAck(); I2C_WriteByte(addr); // 写入 RAM 地址 0x06 / 0x07 I2C_ReadAck(); I2C_Start(); // 重复起始,切换为读方向 I2C_WriteByte((0x5A << 1) | 1); I2C_ReadAck(); data = I2C_ReadByte(1); // 读低字节,主机回 NACK data |= (I2C_ReadByte(0) << 8); // 读高字节,回 ACK 以便继续读 PEC I2C_ReadByte(0); // PEC 校验字节,本实现中不验证 I2C_Stop(); return data; } // 调用示例 // float tobj = MLX90614_Read_RAM(0x07) * 0.02f - 273.15f;data是 14 位数据,最高两位是标志位,所以读完要立即转成温度。0x07 * 0.02 - 273.15的结果单位是摄氏度,这在数据手册的公式表里是固定换算系数。代码里最后那个I2C_ReadByte(0)是在读 PEC 校验字节,这里没有做 CRC8 验证,工业环境里可以把 PEC 也解析出来,计算方式是对地址、命令和数据做 CRC8,初值为 0。
3.4 体温数据修正的现场方法
MLX90614 测的是额头或手腕表面温度,和腋下体温有 1~2℃ 的偏差。更麻烦的是环境温度和测量距离对这个偏差影响很大:距离越远,红外能量衰减越多,读数越低。常见的补偿公式是T_est = T_obj + (T_amb - T_obj) * k + offset,其中k取 0.05~0.1,offset是校准偏差,可以在设备固定安装后用体温计实测一次标定。
这个工程里,雷达的静止距离正好可以喂给补偿逻辑:雷达给出距离值,体温补偿系数按距离分段,小于 30cm 用近距离系数,大于 80cm 直接显示--,因为超过 1 米后 MLX90614 的读数已经失去参考意义。这就是前面为什么要坚持用串口雷达而不是简单人体感应模块的原因,距离信息本身就参与了传感器融合。
4. TFT 液晶屏与 WiFi 上报:现场交互与链路调度
4.1 显示通道:FSMC 刷屏与局部刷新策略
这块工程的 TFT 液晶屏走的是 FSMC 接口,16 位数据线,一次写一个像素点。很多人在 F103 上刷屏慢,原因是把整屏当画布一次重绘,刷一帧要几百毫秒。正确做法是开启视窗,只更新变化区域,比如温湿度数值只占屏幕右上角一个 120x40 的区域,那就在这个区域内重绘数字,其余部分不动。
// 设置可视区域,只允许写入 x0~x1, y0~y1 范围内的像素 void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { LCD_WriteReg(0x2A, x0 >> 8); // 列地址高字节 LCD_WriteReg(0x2A + 1, x0 & 0xFF); // 列地址低字节 LCD_WriteReg(0x2A + 2, x1 >> 8); LCD_WriteReg(0x2A + 3, x1 & 0xFF); LCD_WriteReg(0x2B, y0 >> 8); // 行地址 LCD_WriteReg(0x2B + 1, y0 & 0xFF); LCD_WriteReg(0x2B + 2, y1 >> 8); LCD_WriteReg(0x2B + 3, y1 & 0xFF); LCD_WriteReg(0x2C, 0); // 开始写入显存 }0x2A是列地址寄存器,0x2B是行地址寄存器,0x2C是显存写入命令。设置完窗口后,连续写入像素点数据会自动换行,不需要再发地址命令。配合一个“脏矩形”结构体,把数据变化的区域记录下来,每次循环只刷脏矩形,刷新率可以从 5fps 提到 30fps 以上。刷屏时如果发现花屏,先检查 FSMC 时序里的地址建立时间和数据建立时间,典型值是0x00003034这一档,时序过快会让 TFT 控制器来不及采样。
对于用 0.96 寸 OLED 的方案,IIC 屏则简单得多,SSD1306 驱动只需要初始化序列和显存整包上传,但它的刷新率瓶颈在 IIC 时钟上,所以这类屏适合只显示小字量的场景,大数字动态变化还是 TFT 更合适。
4.2 无线通道:ESP8266 的 AT 指令任务划分
WiFi 模块用 ESP8266 的 AT 固件是常见做法,STM32F103 通过串口发指令,模块返回OK、ERROR或者+IPD数据。AT 指令最坑的是等待时间:连接路由器可能要 3 秒以上,TCP 建链也要 1 秒左右,如果在主循环里阻塞等这些响应,DHT11 的读时序会被拖垮,雷达的帧也会丢。
所以任务划分方式应该是:WiFi 的连接和上报放在一个独立的状态机中,每 10ms 检查一次串口接收,不为任何一条 AT 指令死等。
// 上报一帧 JSON 数据到 TCP 服务器 // 注意:AT+CIPSEND 的字节数必须和实际发送长度一致,否则数据会被截断 void ESP8266_Report(float temp, uint8_t hum, uint8_t gas, uint8_t person) { char msg[72]; char cmd[24]; uint16_t len; len = sprintf(msg, "{\"t\":%.1f,\"h\":%d,\"co\":%d,\"p\":%d}\r\n", temp, hum, gas, person); sprintf(cmd, "AT+CIPSEND=%d\r\n", len); // 先告之发送长度 UART3_Send_String("AT+CWJAP=\"mynet\",\"12345678\"\r\n"); // 接入 AP UART3_Send_String(cmd); // 再告知数据长度 delay_ms(20); UART3_Send_String(msg); // 最后发送数据体 }AT+CIPSEND=<len>是告诉模块“接下来要发多少字节”,模块收到后回>提示符,此时才能发数据。很多排错案例都是长度算错,strlen直接算中文会多算字节,或者漏掉\r\n导致服务器端收到半个 JSON。数据体里用\r\n结尾,是为了配合 TCP 服务器按行解析的惯例,如果用 TCP 调试工具测试,注意不要被工具的显示格式干扰。
4.3 串口 1 与串口 3 的分工依据
这套工程里串口资源分配很有代表性:串口 1 留给调试下载和雷达以外的日志输出,串口 3 单独给 WiFi 模块。理由是 STM32F103 的串口 1 挂在 APB2 总线上,串口 3 挂在 APB1 总线上,两者的时钟源不同。用标准库时,串口 1 的时钟使能是RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE),串口 3 则是RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART3, ENABLE),写错总线就会导致波特率配置失效,串口完全不出数据。115200 波特率下两个总线的分频系数不同,如果从串口 1 往串口 3 搬家,直接改USART_InitStructure.USART_BaudRate是不够的,还要检查RCC_PCLK1Config的时钟树配置。
另外,调试信息如果和 AT 指令响应用同一个串口,主循环里printf一多,WiFi 模块的响应就会被冲掉。所以串口 1 接调试器、串口 3 接模块的分工不能混。
4.4 完整数据流的调度顺序
系统初始化完成后,主循环按一个固定节奏跑任务:
- 每 2 秒读一次 DHT11,更新温湿度缓存;
- 每 1 秒读一次 MQ-7 ADC,做平均滤波;
- 串口 2 中断持续收雷达帧,主循环每 100ms 解析一次;
- 每 500ms 刷新 TFT 的脏矩形区域;
- 每 5 秒触发一次 WiFi 上报状态机。
这个调度顺序的核心是:耗时操作全部放主循环,中断只做“收字节”和“置标志”。雷达数据每 100ms 解析一次,刚好和模块输出频率匹配;DHT11 读一次要 30ms,放在 2 秒周期里不影响其他任务。如果以后要在这套代码上移植 FreeRTOS,可以直接把上面 5 个周期任务拆成 5 个任务,信号量用xSemaphoreGiveFromISR从串口中断里释放,任务优先级按“雷达 > 显示 > WiFi > 传感器”排。
5. 长期运行的稳定性处理:电源、看门狗与死机恢复
护理设备最怕的就是“悄无声息地不动了”。传感器偶尔读错可以容忍,但主控死机后没人知道,系统的可信度就归零。这里有几个实际验证过的问题点。
第一个是供电。DHT11、雷达、ESP8266 同时工作时的峰值电流远超 STM32F103 的 LDO 承受能力,常见做法是雷达和 WiFi 模块单独用 3.3V 稳压芯片供电,传感器侧再加 10uF 和 0.1uF 的去耦电容。ESP8266 在 Wi-Fi 发射瞬间电流会到 300mA,如果和主控共用一颗 AMS1117,电压跌落会让 ADC 参考电压漂移,MQ-7 的浓度读数会周期性跳高。把模块供电和模拟采样供电分开,是这类项目里最值得先做的改动。
第二个是独立看门狗。STM32F103 的 IWDG 使用内部 40kHz LSI 时钟,不依赖主时钟,主时钟挂了它照样能复位。配置代码如下:
void IWDG_Init(void) { IWDG_WriteAccessCmd(IWDG_WriteAccess_Enable); // 取消写保护 IWDG_SetPrescaler(IWDG_Prescaler_64); // 40kHz / 64 = 625Hz IWDG_SetReload(625); // 625 次计数,约 1 秒溢出 IWDG_ReloadCounter(); IWDG_Enable(); // 使能后不可关闭 }喂狗点放在主循环末尾,每轮循环喂一次。注意不要在 DHT11 读取前喂狗,因为单总线时序最长可能阻塞 30ms,如果喂狗点选在阻塞之前,一旦传感器对地短路,主循环就死在while等待里,看门狗失去意义。正确位置是主循环所有阻塞操作完成之后。
第三个问题是 SWD 下载失败后的恢复手段。项目里 DHT11 和雷达的 GPIO 初始化如果和 SWD 引脚冲突,会导致第二次下载连不上,这是很常见的事故。处理办法是把 BOOT0 拉高、BOOT1 拉低,让芯片进入系统存储器模式,用串口 ISP 擦除 Flash,再复位回正常模式。所以硬件设计时 BOOT0 要留跳线帽或 0 欧电阻的位置,不要直接接地。
最后一个小细节是 PA11 引脚。PA11 同时映射到 USB_DM 和 TIM1_CH4,如果代码里初始化了 GPIO 和定时器,又没有关闭复用功能,引脚会出现电平异常,和传感器通信时会偶发数据错乱。排查顺序一般是:先用万用表量引脚电压,再查引脚复用表,最后看时钟树。这套工程里 TFT 的数据线占了 PD0~PD15,PA11 虽然空闲,但如果有扩展板要新增外设,优先避让 PA11,能省掉不少排查时间。
本文还有配套的精品资源,点击获取