
去年夏天接了个不太起眼的活儿帮学校图书馆做一套书架区和自习区的环境监测。需求听起来很简单测测温湿度、光照、噪声把数据汇总起来看看趋势就完事。真做下去才发现图书馆这个场景跟网上那些室内气象站完全不是一回事——书架区的温湿度要按天甚至按周评估波动自习区的噪声是瞬时尖峰、平均值毫无意义光照还得区分靠窗的自然光衰减和人造补光。前后折腾了两个多月最后整理成了一套完整开源方案STM32主控板、Altium Designer画的原理图、Keil工程源码外加一套Proteus仿真工程。这篇就把里面的器件选型逻辑、电路细节、固件架构和踩过的坑全部摊开讲一遍代码和图纸的骨架都在文章里照着复现完全没问题。1. 图书馆环境监测这个选题跟通用气象站到底差在哪1.1 场景决定了要测什么而不是反过来很多人做STM32环境监测的第一反应是把能买的传感器都挂上去DHT11、MQ-135、雨滴、火焰、人体红外一股脑堆满。我一开始也差点走这条路画到第二版原理图的时候停下来了问了自己一个问题图书馆管理员拿到这些数据能做出什么决策如果答案是做不了任何决策那这个传感器的存在就是浪费引脚和代码空间。图书馆这个场景的真实痛点其实很具体。第一是纸质文献的保存环境书库和密集架区域长期温湿度失衡会导致纸张发霉、胶装开裂、书页起翘管理员需要的是缓慢趋势和越限报警不是每秒刷新的数字。第二是读者自习区的舒适度夏天靠窗座位晒得发烫、冬天靠走廊的位置阴冷这两类座位的体感差异能到三四度需要的是区域对比而不是单点读数。第三是照明阅览桌面照度不足会直接引起视觉疲劳但书库区域照度反而是越低越好因为光照会加速纸张纤维氧化和油墨褪色这两个区域的阈值是反过来的。第四是噪声这是最容易被做烂的一项。自习区一整天大部分时间都是安静的平均声压级可能只有40dB上下但偶尔有人拖椅子、接电话、小声聊天瞬间就能冲到70dB。如果你只上报一个平均分贝那这个数据完全没有信息量必须保留短周期峰值才有意义。把这些想清楚之后传感器清单就自然收敛了根本不需要我去选。1.2 五个物理量的器件选型与精度分级我最终确定的监测项是五项空气温度、相对湿度、照度、噪声声压级、颗粒物浓度。前四项是核心第五项属于加分项因为图书馆临街一侧的窗户密封性一般粉尘浓度确实值得关注。选型的时候我把每一项都拆成了入门档和稳妥档两个价位最后混搭使用——核心项上稳妥档边缘项上入门档。监测项入门档器件稳妥档器件接口说明温度/湿度DHT11SHT30-DIS单总线 / I2CDHT11便宜但精度和一致性差SHT30带出厂校准照度光敏电阻GL5528BH1750FVIADC / I2C光敏电阻非线性严重标定麻烦噪声驻极体麦克风运放MAX9814模块ADCMAX9814带AGC动态范围更宽颗粒物无PMS5003UART只能上激光散射方案显示0.96寸OLED0.96寸OLEDI2CSSD1306128x64分辨率这里我要专门说一下DHT11和SHT30的取舍因为这是新手最容易纠结的地方。DHT11的价格大概是SHT30的十分之一但它有几个硬伤湿度精度标称±5%RH实际批次之间能差到±8%温度精度±2℃而且是整数输出小数点后面那一位是补零凑的最要命的是它的单总线协议对时序极其敏感如果主循环里有中断或者被其他任务拖住读出来就是校验和错误。如果你只是做个课设演示DHT11完全够用但如果是真往图书馆部署、要长期跑几个月我建议直接上SHT30。多花的那点钱换来的是一整年不用去现场重新插拔传感器这笔账很好算。我在最终版本里做成了双传感器兼容固件里通过一个编译宏切换驱动实物用的是SHT30仿真里用DHT11因为Proteus的DHT11模型现成好找。照度这一项光敏电阻的方案我劝你别碰。GL5528的阻值-照度曲线本身就是对数关系而且个体差异巨大、温漂明显你想把它标定到误差±50lux以内得写一套拟合公式再逐板校准工作量远超器件本身的价格。BH1750是数字输出、直接给lux值I2C两线搞定50Hz/60Hz光噪声抑制还是内置的没有任何犹豫的必要。1.3 从能测到测得准的那一步这里有个很多人会忽略的点传感器精度不等于系统精度。SHT30本身精度很好但如果你把它贴在STM32芯片旁边3毫米的位置测出来的温度会被MCU自身发热抬高2到3度BH1750的感光窗口如果被OLED屏的亚克力盖板挡住一部分读数会系统性偏低噪声通道如果走的是开关电源的地纹波会直接混进ADC采样里。我在PCB布局上专门做了三件事把温湿度传感器放到板子边缘、远离所有发热器件并且在它周围挖了一圈隔离用的开槽把BH1750的感光面朝向机壳开窗的正前方中间不加任何透明介质模拟部分的走线单独铺一块地用0欧电阻在电源入口处和数字地单点连接。这三条的代价只是布局时多花半小时但换来的是数据直接可用、不需要后期做补偿。我见过不少项目在软件里写了一堆温度补偿系数本质就是在补布局的坑属于本末倒置。2. 硬件方案定型STM32F103C8T6之外的那些电路细节2.1 主控能力核算与引脚分配主控选的是STM32F103C8T6也就是常说的蓝板同款。选它不是因为便宜——虽然确实便宜——而是因为这套系统的资源需求恰好卡在它的能力区间内往上换到F4纯属浪费。把需求算一下64KB Flash装固件绰绰有余这套代码最终编译出来大概28KB20KB RAM里OLED显存占1KB1024字节128×64÷8各传感器的缓冲区加起来不到2KB剩下的全给协议栈和堆栈。外设方面需要I2C×2一路给OLED和BH1750一路给SHT30、UART×2一路给PMS5003一路给WiFi模块、ADC×1噪声通道双I2C加双UART刚好是它的上限一个不多一个不少。时钟配置上外部8MHz晶振经PLL九倍频到72MHz作为系统时钟ADC时钟单独分频到12MHzADC最大不能超过14MHz这个限制很多人不知道超了之后采样值会莫名其妙地漂。RTC用外部32.768kHz晶振用于给数据打时间戳。引脚分配我整理成了表这张表在画原理图和写固件的时候要反复对照最好一开始就定死引脚功能连接对象备注PA5SPI1_SCK预留调试用PA6/PA7ADC12_IN6/IN7噪声通道复用为模拟输入PA9/PA10USART1_TX/RX调试串口接USB转TTLPA11/PA12USB DM/DP预留未使用PB6/PB7I2C1_SCL/SDAOLED BH1750挂同一条总线PB10/PB11I2C2_SCL/SDASHT30独立总线隔离干扰PB8GPIO输出蜂鸣器越限报警PB13SPI2_SCK预留备用PC13GPIO输出状态LED板载LEDPA2/PA3USART2_TX/RXPMS50039600波特率I2C1上挂两个从机、I2C2上挂一个从机这个分配不是随意的。OLED是持续刷新的设备波形刷新频率高会带来较大的总线翻转噪声BH1750对时序不敏感可以共存。而SHT30对电源纹波和总线噪声比较敏感单独给它一条总线更保险。如果片上有第三个I2C口我甚至会把BH1750也隔离出去。2.2 电源树与ADC基准两处最容易埋雷的地方整套系统的供电方案是DC 5V输入经一颗低压差线性稳压器降到3.3V给数字部分模拟部分噪声运放和ADC参考再从3.3V经一级LC滤波单独供电。这里我要强调一个原则——凡是模拟量测量只要条件允许就不要和数字负载共用一路电源。具体做法是在电源入口放一个10uF的钽电容做低频储能后面每颗芯片的电源引脚旁边贴一颗100nF的陶瓷电容做高频退耦。这两个电容的作用不一样不能互相替代钽电容响应的是毫秒级的负载变化陶瓷电容响应的是纳秒级的高频尖峰。我见过有人只在板子上放了几颗100nF跑起来MCU一动ADC就读数异常就是因为缺了低频储能。ADC参考电压这一块更关键。STM32F103的VDDA和VREF在64脚封装里是合并的一般直接接3.3V。但你要清楚这意味着什么ADC的绝对精度完全取决于3.3V这一路的稳定度。12位ADC、3.3V参考一个LSB大约是0.806mV换算成相对误差是0.024%。如果你的3.3V电源上叠加了20mV的纹波那ADC的有效位数直接掉到9位左右后面再怎么滤波也补不回来。我在VDDA前面串了一颗磁珠后面接了一颗1uF加一颗100nF实测噪声通道的底噪从±8个LSB降到了±3个LSB。这个改动成本不到两毛钱效果立竿见影。提示如果你要用内部参考电压或者外部基准芯片一定要在初始化代码里把ADC的采样时间设够。STM32F103的ADC输入阻抗模型对长采样时间更友好我一般把噪声通道设成239.5个周期的采样时间虽然慢但读数稳。2.3 三个传感器接口的电路差异DHT11的单总线接口看似简单其实有讲究。数据线需要一颗4.7kΩ到10kΩ的上拉电阻我选的是4.7kΩ。为什么不是10kΩ因为总线电容大概在100pF量级4.7k的上升时间常数约0.47微秒足够快10k的话上升沿会变缓在某些批次芯片上会导致时序判断出错。但也不能太小太小的话当总线被拉低时灌电流会超过芯片IO的承受能力。4.7k是经过大量验证的稳妥值。SHT30和BH1750都是标准I2C从机上拉电阻同样推荐4.7kΩ100kHz速率下。如果你把I2C跑到400kHz上拉要减小到2.2kΩ左右否则上升沿变缓会导致通信失败。我在这套系统里I2C统一跑100kHz一是够用二是抗干扰余量大三是省电。SHT30的地址选择引脚ADDR接地时地址是0x44接VDD时是0x45。BH1750的ADDR引脚接地是0x23接VDD是0x5C。这两个器件的地址在初始化代码里要写死别指望自动扫描——扫描出来的地址如果和预期不符说明焊接有问题早点发现比晚发现好。噪声通道用的是MAX9814模块它内部集成了驻极体偏置、可调增益放大器和自动增益控制。输出直接进STM32的ADC引脚。这里需要注意MAX9814的输出是围绕VDD/2偏置的交流信号所以ADC读到的原始值大概在2048附近上下摆动你要算的是这个摆动量的均方根而不是绝对值。模块上有个GAIN引脚可以设置40dB、50dB、60dB三档增益。图书馆场景噪声范围大概在35dB到80dB我选的是50dB档配合软件里的RMS计算刚好能覆盖。如果增益设太高安静时候的底噪会被放得很大测出来的本底噪声有45dB纯粹是电路自己在那响。3. 固件架构不上RTOS也能把任务调度做干净3.1 SysTick软定时器加任务表比裸写while(1)强太多先说结论这套系统的实时性要求其实很低最紧的时序约束是DHT11的单总线读取微秒级但那个用阻塞延时加关中断就能处理。除此之外传感器采样周期都在百毫秒到秒级完全没必要上FreeRTOS多出来的几KB RAM和调度开销纯属浪费。但不上RTOS不等于要写成一坨while(1)里塞满delay_ms(1000)。我用的是SysTick软定时器任务表的方案核心思路是让SysTick每1毫秒产生一次中断中断里只做一件事给每个任务的时间计数器加一。主循环里轮询这些计数器谁的计数到了就执行谁。typedef struct { void (*task)(void); uint16_t period_ms; uint16_t counter; uint8_t enable; } soft_timer_t; static soft_timer_t timer_tbl[] { { task_read_sht30, 2000, 0, 1 }, { task_read_bh1750, 1000, 0, 1 }, { task_sample_noise, 500, 0, 1 }, { task_read_pms, 3000, 0, 1 }, { task_refresh_oled, 200, 0, 1 }, { task_upload_data, 10000, 0, 1 }, }; void SysTick_Handler(void) { for (uint8_t i 0; i sizeof(timer_tbl)/sizeof(timer_tbl[0]); i) { if (timer_tbl[i].counter 0) { timer_tbl[i].counter--; } } } void app_poll(void) { for (uint8_t i 0; i sizeof(timer_tbl)/sizeof(timer_tbl[0]); i) { if (timer_tbl[i].enable timer_tbl[i].counter 0) { timer_tbl[i].counter timer_tbl[i].period_ms; timer_tbl[i].task(); } } }这段代码逻辑非常简单但它解决了裸机开发里最常见的问题任务之间的时间耦合。如果你用delay_ms串行执行读取SHT30要等100毫秒、等DHT11要等20毫秒、刷新OLED又要等最后每个任务的真实周期都是所有这些延时之和你设的2秒读一次实际变成3秒数据时间戳就全乱了。用任务表之后每个任务的周期是独立的、可预测的而且新增任务只需要在表里加一行不用改任何调度代码。这里有个细节要注意SysTick中断里绝对不能做耗时操作。我见过有人在中断里直接调传感器读取函数结果DHT11的20毫秒阻塞把整个系统拖垮主循环里的OLED刷新变成卡顿。中断只做计数这是铁律。3.2 驱动分层HAL库之上再包一层才能换传感器不改业务代码HAL库的定位是硬件抽象但它抽象得不够彻底。你调HAL_I2C_Mem_Read读SHT30这个调用里带着具体的从机地址、寄存器地址、数据长度业务代码就被绑死在SHT30上了。哪天要换成DHT11或者别的型号业务层要跟着改一遍。我的做法是在HAL之上再包一层统一接口定义成这个样子typedef struct { uint8_t (*init)(void); uint8_t (*read_temp)(float *temp); uint8_t (*read_humi)(float *humi); } temp_humi_drv_t; extern const temp_humi_drv_t drv_sht30; extern const temp_humi_drv_t drv_dht11;业务层只认temp_humi_drv_t这个结构体具体用哪个驱动由编译宏控制#ifdef USE_SHT30 const temp_humi_drv_t *th_drv drv_sht30; #else const temp_humi_drv_t *th_drv drv_dht11; #endif这样一来仿真工程用DHT11驱动、实物工程用SHT30驱动业务代码一个字都不用改。这套写法在多传感器项目里价值极大我后来做别的项目也一直在用。驱动的具体实现里SHT30的部分比较简单发一条0x2C 0x06命令触发单次测量等15毫秒读6个字节前3个是温度和CRC后3个是湿度和CRC。温度换算公式是-45 175 * raw / 65535湿度是100 * raw / 65535。这里必须做CRC校验因为I2C总线一旦受干扰读到错数据的概率不低而错数据如果不校验就直接显示会出现湿度120%这种离谱值。DHT11的驱动麻烦得多。它的单总线协议是主机拉低总线至少18毫秒然后拉高20到40微秒接着释放总线从机响应时先拉低80微秒、再拉高80微秒然后开始逐位传输40位数据。每一位的0和1靠高电平持续时长区分——26到28微秒是070微秒是1。这个时序必须用微秒级延时而且整个读取过程要关中断否则SysTick中断进来一打断时序就崩了。我的实现里是先把40位数据全部读进一个缓冲区再开中断然后再做解析和校验。关中断的时间大概是4毫秒对这套系统来说完全可以接受。3.3 数据滤波三种算法各管一摊原始ADC和传感器读数从来都不是干净的。温度会有0.1度的抖动照度会有几十lux的跳变噪声通道的原始采样点数以千计。如果直接把原始值显示出来OLED上的数字会跳得让人眼花上报到服务器的曲线全是毛刺。我用了三种不同的滤波策略对应三类不同的数据特性滑动平均用于温度和湿度。温湿度是慢变量物理上不会突变用窗口长度为8的滑动平均就够了。窗口太大会导致响应迟滞比如空调刚开的时候要过好几分钟才反映出来窗口太小则滤波效果不明显。8这个数是在实际数据上试出来的平衡点。#define TEMP_WIN 8 static float temp_buf[TEMP_WIN]; static uint8_t temp_idx 0; float filter_moving_avg(float new_val) { temp_buf[temp_idx] new_val; temp_idx (temp_idx 1) % TEMP_WIN; float sum 0; for (uint8_t i 0; i TEMP_WIN; i) { sum temp_buf[i]; } return sum / TEMP_WIN; }中值滤波用于照度。BH1750偶尔会因为I2C通信干扰返回异常值比如室内明明只有300lux突然报一个3000lux用滑动平均的话这个错值会被平均进去污染后面好几秒的数据。中值滤波的做法是取最近5个值排序取中间那个单个错值直接被剔除而且不会像滑动平均那样把阶跃信号弄圆滑。峰值保持分位数用于噪声。我在500毫秒的采样窗口里以8kHz采样4000个点先算RMS得到这段时间的平均声压级同时记录窗口内的最大瞬时值。上报的时候两个都传平均级反映整体安静程度峰值反映突发事件。这个设计后来证明非常有用管理员一看曲线就知道哪个时间段有人在自习区聊天。数据项滤波算法窗口/参数剔除异常值能力响应延迟温度、湿度滑动平均窗口8弱中照度中值滤波窗口5强低噪声平均值RMS窗口4000点中低噪声峰值峰值保持窗口4000点无本就保留无注意滤波参数一定要拿真实数据去调不要凭感觉设。我的做法是先把原始数据打到串口存成CSV导入表格软件画图然后拿不同窗口长度反复试看哪一组能把毛刺压下去同时不损失真实变化。这一步花半小时能省掉后面无数次返工。4. Proteus仿真哪些能跑通哪些注定白费功夫4.1 仿真在这个项目里的真实定位先摆明观点Proteus仿真不能替代实物调试但它有两个不可替代的价值。第一是验证程序逻辑尤其是任务调度、状态机、显示刷新这些纯软件的部分仿真里跑通了实物大概率没问题。第二是给没有硬件的人一个验证方案的机会——很多学生做课设的时候手头没有传感器仿真能让他们把代码跑起来看到效果。但如果你指望用仿真验证传感器的测量精度那是南辕北辙。Proteus里的传感器模型都是理想化的DHT11模型永远返回你设定的温湿度值不会给你随机误差BH1750模型输出的lux值是常数不会有波动噪声通道在仿真里根本没有模拟前端你只能用一个信号发生器去喂一个正弦波进去。所以我给这套仿真工程的定位是逻辑验证平台。它验证的是我的代码在给定的输入下能不能正确运算、正确显示、正确组包上报不是我的系统在真实环境下测得准不准。仿真工程里的元件替换清单是这样的实际器件仿真替代方案仿真可行性STM32F103C8T6STM32F103R6完全可行注意Flash只有32KSHT30DHT11模型可行模型会返回固定值BH1750电位器接ADC只能模拟光照变化趋势MAX9814信号发生器可行用正弦波模拟声音PMS5003虚拟串口终端可行手动输入数据帧OLED SSD1306图形LCD模型可行能显示文字和图形这里有个坑要提醒Proteus里的STM32F103C8T6模型不一定有很多版本的元件库只有R6或者C6。R6的封装引脚更多Flash只有32KB如果你的代码编译出来超过32KB就装不进去。解决办法是把调试相关的功能比如全量串口日志在仿真版本里用宏关掉代码能压到25KB左右刚好放得下。还有一点仿真里的主频设置必须和代码里的系统时钟配置对得上。Proteus的MCU属性里有个Crystal Frequency字段你要填80000008MHz因为它只认外部晶振频率内部的PLL倍频是芯片自己算的。如果你填成72000000仿真跑起来会快九倍串口输出的波特率会全乱。4.2 在Proteus里跑I2C和单总线的实操要点I2C器件在Proteus里有个很好用的调试工具I2C Debugger。把它挂在总线上仿真运行的时候能实时看到主机发出的地址、读写位、数据字节以及从机有没有回ACK。这个工具在我排查SSD1306初始化失败的时候救过命——一眼就能看出是地址发错了还是从机没响应。操作上要注意I2C Debugger本身也会占用一个从机地址如果你把它挂在已经有多个从机的总线上要确认它的地址不冲突。默认情况下它不参与寻址只是监听所以一般不会有问题。DHT11的单总线在仿真里更容易出问题。Proteus的DHT11模型对时序的容忍度和真实芯片不一样有时候你的代码在实物上跑得好好的在仿真里就是读不出来。我遇到的具体现象是主机拉低18毫秒之后模型响应的高电平持续时间比真实芯片略长导致我的位判断阈值卡在边界上。解决办法是把判断阈值放宽一点比如把高电平超过40微秒算1改成超过35微秒算1这样两边都能兼容。不过这种放宽是有代价的会牺牲一点抗干扰能力。所以我的建议是仿真版本和实物版本用不同的编译宏阈值分开设置。别为了图省事用同一套参数最后两边都不讨好。串口部分仿真里用Virtual Terminal来接收UART输出把波特率设成和代码一致115200就能看到打印信息。如果你要看PMS5003的数据帧可以另外接一个虚拟串口手动往里输入42 4D 00 1C ...这种格式的数据帧验证解析逻辑。4.3 仿真跑通之后实物上还要重新验证什么从仿真到实物有几件事必须重新来一遍一个都不能省。第一是时钟树验证。仿真里的主频是你手动填的实物上的主频取决于HSE起振和PLL锁定。烧录进去第一件事是用调试串口打印SystemCoreClock的值确认是72000000。如果不是多半是晶振没起振或者负载电容不匹配一般选20pF但如果晶振手册要求12pF就得改。第二是I2C总线的实际速率。仿真里不考虑上拉电阻和总线电容实物上这两者直接决定通信可靠性。用示波器看SCL的上升沿如果波形明显变圆、上升时间超过1微秒就要把上拉电阻减小。第三是ADC的实际噪声水平。仿真里ADC输入是理想的实物上你短接ADC输入到地读出来的值应该在0到3之间跳动如果跳到±20说明电源有问题要去查退耦电容和地线布局。第四是传感器的实际响应速度。SHT30从触发测量到数据就绪仿真里你可以随便设实物上是15毫秒高重复性模式下最大15ms。如果你在代码里只等10毫秒就去读读回来的是上一次的旧数据而且不会报错这种静默错误最难查。5. 联网上传从串口透传到MQTT的三条路径5.1 ESP-01S的AT指令方式最省事也最脆最省事的联网方案是用ESP-01S模块通过AT指令和STM32的UART对接。STM32这边只需要发字符串模块自己处理WiFi连接和TCP。初始化流程大概是这样的先发AT确认模块在线然后ATCWMODE1设为Station模式ATCWJAPSSID,PASSWORD连接热点接着ATCIPSTARTTCP,192.168.1.100,1883建立到Broker的TCP连接最后ATCIPSEND长度发数据。static void esp_send_cmd(const char *cmd, const char *expect, uint16_t timeout_ms) { uart2_send_string(cmd); uart2_send_string(\r\n); uint32_t start get_tick(); while ((get_tick() - start) timeout_ms) { if (uart2_rx_contains(expect)) { return; } } /* 超时处理这里不要直接复位先记录再重试一次 */ }这套方案的问题在于模块状态不可控。ESP-01S在信号弱的时候会自己断开重连但这个过程它不一定通知STM32结果就是STM32还在傻乎乎地发数据其实早就发不出去了。而且AT指令的响应是靠字符串匹配的网络延迟一大匹配就会超时误判成失败。我的处理方式是加两层保护一是在每次发送前先发AT探测不回OK就走重连流程二是设置一个发送失败计数器连续失败超过5次就硬复位模块拉低使能引脚200毫秒。这样做虽然土但可靠性比单纯依赖AT响应好得多。另外提醒一句ESP-01S的默认波特率是115200但有些批次出厂是9600接上去发现全是乱码就是这个原因。可以用ATUART_DEF115200,8,1,0,0统一设一下。5.2 换成ESP32跑MQTT客户端把复杂度从前端挪到后端如果你的板子空间和预算允许我更推荐直接换成ESP32做联网部分让STM32只管采集、ESP32只管通信两者用UART或者SPI交换数据。这样做的好处是ESP32上可以跑完整的MQTT客户端库断线重连、QoS等级、遗嘱消息LWT全都是库函数调用不用自己拼AT指令。ESP32还支持OTA升级固件改一次不用重新拆机壳插烧录器。代价是多一颗芯片、多一套工具链、多一份功耗。所以这属于典型的复杂度权衡如果你的项目要长期部署、要远程维护那这点复杂度花得值如果只是做几次演示AT指令方案更快出结果。MQTT的报文格式我可以给个简单的组包示例用JSON最直观char payload[256]; snprintf(payload, sizeof(payload), {\dev\:\%s\,\ts\:%lu,\t\:%.1f,\h\:%.1f,\lux\:%d,\db\:%.1f,\db_pk\:%.1f}, DEV_ID, rtc_get_unix(), sht_temp, sht_humi, bh_lux, noise_avg, noise_peak);主题设计上建议按library/区域编号/数据类型分层比如library/zone_a/env。这样订阅的时候可以用通配符一次订阅所有区域也可以在Broker侧做规则转发分别存储。加上设备ID、时间戳和区域编号之后数据到了后端就能直接入库画图不需要再做复杂的解析。5.3 上报频率和断网续传这两件事必须提前想清楚上报频率是个需要认真算的参数。如果你2秒上报一次一天就是43200条记录一年一千五百万条。如果每个区域一台设备图书馆分五个区域那就是七千多万条——单机数据库根本扛不住。我的做法是分级上报正常状态下60秒上报一次原始数据当某个指标越限比如湿度超过65%RH或者噪声峰值超过65dB时自动切到5秒一次的加密上报把这个事件段的细节完整记录下来事件结束后回到60秒。这样总量能压到原来的十分之一但关键信息一点不丢。断网续传是另一个必须考虑的。网络总会有断的时候如果断网期间数据直接丢弃那这段时间的曲线就是空白的而恰恰是网络异常时段可能对应着某些真实的环境变化比如空调故障导致的温度上升。我在STM32的Flash里划了一块区域做环形缓冲区每条记录大约32字节划出4KB就能存128条。断网的时候数据写进缓冲区网络恢复后按时间顺序补发。这里要注意Flash的写入寿命STM32F103的Flash擦写次数标称是1万次所以不能每条数据都擦一次扇区——要攒够一整页1KB或2KB再写一次或者用外挂的SPI Flash可以到10万次擦写。6. 调试过程中踩过的坑与排查链路6.1 I2C总线死锁导致OLED白屏的完整定位过程这套系统里最让我头疼的一个问题是OLED偶尔白屏。现象是上电之后一切正常跑几个小时或者几天之后屏幕突然全白复位之后又好了但过一阵子再来一次。排查过程我按步骤走了一遍第一步确认是软件问题还是硬件问题。把OLED换到另一块同型号的开发板上跑了三天没复现说明不是屏本身的问题。第二步在代码里加计数器记录OLED刷新函数的调用次数和返回码。发现白屏发生的时候刷新函数的返回码是HAL_ERROR也就是I2C传输失败。第三步抓波形。用逻辑分析仪挂在I2C1的SCL和SDA上一直录到白屏发生。抓到的波形很说明问题在某次通信中SCL被从机拉低持续了很长时间主机发完第9个时钟后准备发停止条件但SDA一直是低电平总线被卡住了。这就锁定了根因从机在应答过程中把SDA拉住不放主机检测不到总线空闲后续所有通信都失败。这种问题在I2C协议里叫总线死锁常见诱因是从机在进行内部操作比如BH1750在积分转换期间时被主机强行读取导致从机状态机错乱。解决办法有两层。软件层是加超时检测每次I2C传输前先检查总线状态如果SCL或SDA被长时间拉低就手动发送9个时钟脉冲把从机状态机推完再补一个停止条件。硬件层是把上拉电阻从10kΩ改成4.7kΩ让上升沿更快、总线状态判定更可靠。改完之后跑了半个月白屏再没出现过。这个排查过程的核心经验是遇到偶发问题不要靠猜要加计数器、要抓波形。我一开始也怀疑过是OLED的驱动芯片SSD1306过热白折腾了两天。6.2 DHT11校验和错误的三种不同根因仿真版本用DHT11的时候我遇到过读取成功但校验和错误的现象发生率大概百分之几。这个错误看起来很随机但其实背后是三个不同的原因我逐个排除过。第一个原因是关中断时间不够。DHT11的读取过程需要精确的微秒延时如果读取过程中SysTick中断进来时序就会被拉长。我最初的代码只在读取开始时关中断读完40位就开中断但实际上解析过程尤其是那个40位的循环也会被中断打断。后来把整个读取加解析过程全部放进临界区错误率从百分之几降到了千分之几。第二个原因是两次读取间隔太短。DHT11手册里写着采样周期不低于1秒但实际上如果环境湿度变化快内部转换还没完成你就去读它会返回上一次的数据甚至错数据。我后来把读取周期设成2秒千分之几的错误率降到了万分之一以下。第三个原因最隐蔽是电源扰动。我一开始用USB口给板子供电电脑上插拔U盘的时候DHT11就会读错。换成独立的5V电源适配器之后这个问题彻底消失了。所以如果你的系统里有电机、继电器这类大电流负载一定要给传感器单独滤波。现象根因解决方式验证方法校验和错误偶发中断打断时序扩大临界区范围关闭所有中断跑24小时校验和错误周期性读取间隔过短周期改为2秒对比不同周期的错误率校验和错误与外部事件相关电源扰动独立供电LC滤波示波器看电源纹波6.3 ADC噪声通道读数乱跳的接地问题噪声通道是最难搞的一项因为它是纯模拟量。我最开始的读数在安静环境下本底噪声就有±30个LSB的跳动换算下来相当于凭空多了几dB的底噪测出来的安静值根本不安静。排查的时候我用示波器看了ADC输入引脚上的波形。发现信号上叠加了一个大概20kHz、幅度约15mV的周期性纹波。这个频率特征很典型来自开关电源。顺着这条线索往下查发现MAX9814模块的地线是接在数字地平面上的而数字地上的开关电源回流电流在走线上产生了压降这个压降被运放的输入参考到了。这就是典型的共地干扰。解决方式是把模拟地和数字地在电源入口处单点连接中间用一颗0欧电阻或者磁珠。另外在MAX9814的电源引脚旁边加了一颗10uF电容和一颗100nF陶瓷电容把高频纹波旁路掉。改完之后底噪降到±4个LSB对应大约2dB的误差在图书馆这种场景下完全可以接受。这个经验我想强调一下模拟电路的调试八成的问题都能靠看波形查地线解决剩下的两成才是元件参数问题。7. 从单点监测到区域组网这套方案还能怎么长单台设备跑通之后我陆续做了几项扩展这里分享给想继续往下走的人。第一项扩展是区域组网。图书馆有阅览区、书库、走廊、自习室四类空间每类空间的环境要求不一样。我给每台设备编了一个区域ID通过LoRa或者有线RS485组网到一台网关网关再通过以太网上报。这样做的价值在于可以做区域对比分析比如同样是靠窗位置东侧和西侧的温差能到2.5度这个结论单点数据是得不出来的。第二项扩展是照度与人员占用的联动。这个需要对人体存在检测做一点投入——用红外阵列传感器或者毫米波雷达检测某个座位区域是否有人。有人且照度低于300lux就触发补光提醒无人区域照度超过200lux就建议关灯。这套逻辑在自习室这种人走灯不关的场景下节电效果相当明显。第三项扩展是数据趋势报警而不是阈值报警。阈值报警的问题是容易误报也容易漏报湿度瞬时到66%RH可能只是有人开了窗但如果连续6小时湿度缓慢上升到65%RH就是真的有问题了。我在后端加了一个简单的线性回归拟合最近两小时的温湿度斜率斜率超过某个值就报警效果比单纯看阈值好很多。第四项扩展是低功耗改造。如果设备和网关之间走无线那就需要考虑电池供电或者PoE。低功耗的做法是让STM32在两次采样之间进Stop模式用RTC闹钟唤醒传感器只在采样瞬间上电其余时间切断电源。这样平均电流能从20mA降到1mA以下配合一节18650电池能撑一个月以上。最后说一个我自己踩过的坑。做扩展的时候千万别一次全加上我就是一口气把LoRa、人体检测、低功耗三块同时改结果出了问题根本分不清是哪个改动引起的。后来老老实实退回单点版本一次只加一项、每加一项跑够48小时问题才好定位。嵌入式项目里小步快跑、单变量验证这条原则比任何技术选型都重要这是我做了这么多个项目之后最实在的一条体会。整套代码、原理图和Proteus工程我都放在开源仓库里了结构和上面的章节一一对应/Hardware下是原理图和PCB/Firmware下是Keil工程/Simulation下是Proteus工程/Docs里放了引脚分配表和协议说明。想改传感器或者改上报方式的重点看driver和app两个目录业务逻辑和驱动是分开的换起来不用动其他地方。