STM32F1嵌入式入门实战:DHT11温湿度采集与串口通信 1. 为什么STM32F1至今仍是嵌入式入门的首选如果你在电子论坛或者技术群里问一句“新手该从哪块单片机入手”十个人里至少有六个会告诉你STM32F1系列。这个答案从十年前一直延续到现在哪怕Cortex-M4、M33甚至更高性能的芯片已经铺天盖地STM32F103C8T6这颗“蓝色药丸”依然活跃在无数项目里。原因并不复杂——它的资料存量、社区生态、外设覆盖面和价格区间恰好卡在了一个极难被替代的位置上。STM32F1系列基于ARM Cortex-M3内核主频最高72MHz内置Flash从16KB到1MB不等SRAM从6KB到96KB封装形式覆盖LQFP、QFN、BGA等多种规格。以最常见的STM32F103C8T6为例64KB Flash、20KB SRAM、37个GPIO、3个USART、2个SPI、2个I2C、2个12位ADC、1个CAN控制器这套配置放在今天做温湿度采集、电机控制、串口屏驱动、小型数据记录仪依然绰绰有余。更关键的是它的参考手册、固件库、HAL库、标准外设库、LL库全部开源且文档齐全任何一个外设的配置流程都能在官方手册里找到逐位说明。我见过太多人一上来就追新芯片结果卡在某个外设的时钟树配置上三天跑不起来最后换回STM32F1两个小时点灯成功。这不是说新芯片不好而是STM32F1的“容错率”极高——它的时钟树结构清晰外设寄存器映射规整中断向量表简单即使你配置错了某个位大概率只是外设不工作不会直接把芯片锁死。这种“温和的反馈”对建立初学者的信心至关重要。从成本角度看STM32F103C8T6的核心板在市场上长期维持在十元上下配套的ST-Link下载器二十元以内就能买到DHT11温湿度传感器模块三到五元OLED显示屏十元左右。整套入门硬件成本不超过五十元却能覆盖GPIO、定时器、串口、I2C、单总线、ADC、中断、DMA等几乎所有嵌入式核心知识点。这种性价比是其他任何平台难以复制的。还有一个容易被忽略的点STM32F1的“社区记忆”极其深厚。你在开发中遇到的任何问题——比如串口乱码、ADC采样跳动、I2C死锁、定时器PWM占空比不对——几乎都能在中文论坛里找到三篇以上的详细分析帖。这些帖子往往附带示波器截图、寄存器值对比和最终解决方案这种“前人踩坑后人乘凉”的生态是STM32F1最坚固的护城河。2. 从零搭建STM32F1开发环境的几个关键决策2.1 工具链选择Keil、IAR还是STM32CubeIDE这个问题没有标准答案但有一条经验法则如果你打算长期做嵌入式开发并且需要对接大量国产芯片Keil MDK的兼容性最好如果你追求跨平台和免费STM32CubeIDE是首选如果你在汽车电子或工业控制领域IAR的编译优化和调试体验更胜一筹。我个人的选择是Keil MDK配合STM32CubeMX。原因很实际Keil的调试器对ST-Link支持极好断点响应快外设寄存器查看窗口直观而且网上绝大多数STM32F1的例程都是Keil工程直接打开就能编译下载省去了移植的麻烦。STM32CubeMX则负责生成初始化代码把时钟树配置、引脚分配、外设参数这些容易出错的部分可视化处理生成HAL库代码后再导入Keil进行业务逻辑开发。注意Keil MDK从5.37版本开始不再预装ARM Compiler 5而很多老工程用的是AC5编译的。如果你打开旧工程提示缺少编译器需要单独安装ARM Compiler 5并手动添加到Keil的编译器路径中。这个坑几乎每个STM32F1新手都会踩一次。2.2 ST-Link驱动与下载配置的常见问题ST-Link是STM32F1最常用的调试下载工具价格便宜且支持SWD接口。但在实际使用中有几个高频问题需要提前知道。第一驱动安装。Windows 10和Windows 11对ST-Link的驱动签名要求较严如果你买到的是山寨ST-Link可能会遇到“设备描述符请求失败”或“未知USB设备”的提示。解决办法是安装ST官方提供的STM32 ST-LINK Utility或者STM32CubeProgrammer这两个软件会自动安装经过签名的驱动。如果仍然不行需要在设备管理器里手动更新驱动指向CubeProgrammer安装目录下的Drivers文件夹。第二SWD接口连接。STM32F1的SWD接口需要连接四根线3.3V、GND、SWDIO、SWCLK。很多人只接三根线不接3.3V用目标板自身供电这本身没问题但前提是目标板和ST-Link必须共地。如果忘记接GND会出现“No target connected”的错误。另外SWDIO和SWCLK的线长最好不要超过15厘米否则在高频下载时容易出现通信失败。第三下载配置。在Keil的Options for Target → Debug选项卡中选择ST-Link Debugger然后点击Settings在Flash Download选项卡中勾选“Reset and Run”。这样每次下载完成后芯片会自动复位运行省去手动按复位键的步骤。如果你用的是STM32CubeProgrammer需要在“Mode”中选择“Under reset”否则当程序禁用了SWD引脚或进入了低功耗模式时会无法连接。2.3 时钟树配置72MHz不是随便设的STM32F1的最高主频是72MHz但这个72MHz不是凭空来的。它需要外部晶振通常8MHz经过PLL倍频得到。具体路径是8MHz外部晶振 → HSE → PLL输入 → PLL倍频9倍 → 72MHz系统时钟。同时APB1总线最高36MHzAPB2总线最高72MHzADC时钟最高14MHz。在STM32CubeMX中配置时钟树时你只需要在HSE选择Crystal/Ceramic Resonator然后在PLL Source选择HSEPLL Mul选择9倍系统时钟源选择PLLCLKCubeMX会自动计算并分配各总线分频系数。但如果你手动配置寄存器就需要特别注意APB1的分频系数必须设置为2否则APB1外设如USART2、I2C1、SPI2会超频工作表现为通信不稳定或直接不工作。提示如果你用的是STM32F103C8T6最小系统板板载的8MHz晶振通常质量一般负载电容匹配可能不精确。如果发现串口波特率有偏差可以先检查晶振是否起振用示波器测量OSC_OUT引脚是否有8MHz正弦波。如果没有起振尝试将负载电容从20pF调整到10pF或30pF。3. DHT11温湿度传感器在STM32F1上的驱动实现3.1 DHT11的单总线协议本质DHT11是一款含有已校准数字信号输出的温湿度复合传感器内部集成了一个电阻式感湿元件和一个NTC测温元件通过单根数据线与MCU通信。它的通信协议属于“单总线”类型但与DS18B20的单总线不同DHT11的时序更简单但对时间精度要求更苛刻。一次完整的DHT11通信过程分为四个阶段主机发送起始信号、DHT11响应信号、DHT11传输40位数据、总线释放。起始信号是主机拉低数据线至少18ms然后拉高20-40us。DHT11检测到起始信号后会拉低总线80us作为响应再拉高80us通知主机准备接收数据。随后DHT11依次发送40位数据每一位数据都以50us低电平开始紧接着的高电平持续时间决定数据是0还是1——高电平持续26-28us表示0持续70us表示1。40位数据的排列顺序是8位湿度整数 8位湿度小数 8位温度整数 8位温度小数 8位校验和。校验和等于前四个字节相加的低8位。对于DHT11来说湿度小数和温度小数通常为0因为它的分辨率只有1%RH和1℃。3.2 GPIO模拟时序的代码实现与微秒级延时在STM32F1上驱动DHT11最稳妥的方式是用GPIO模拟单总线时序。虽然可以用定时器输入捕获来解码但对于DHT11这种低速传感器GPIO模拟更简单直接。首先需要配置一个GPIO引脚比如PA0工作模式在输出和输入之间动态切换。输出时配置为推挽输出输入时配置为浮空输入或上拉输入。DHT11的数据线需要接一个4.7kΩ到10kΩ的上拉电阻到3.3V因为DHT11的数据线是开漏输出没有上拉电阻无法输出高电平。微秒级延时是DHT11驱动的核心。STM32F1的SysTick定时器可以产生1us的中断但在中断里做延时会影响其他任务。更常用的方法是使用__NOP()空指令或者简单的for循环来实现微秒延时。但for循环延时的精度受编译器优化等级影响很大建议将延时函数放在单独的.c文件中并且该文件不参与高级优化。// 微秒级延时函数基于72MHz系统时钟 void delay_us(uint32_t us) { uint32_t i; for(i 0; i us * 8; i) { __NOP(); } }这个8的系数是通过示波器实测校准出来的。在72MHz主频下每次循环大约消耗9个时钟周期8次循环约72个周期正好接近1us。但如果你改了编译器优化等级或者主频这个系数需要重新校准。3.3 读取流程的完整代码与校验处理#define DHT11_GPIO_PORT GPIOA #define DHT11_GPIO_PIN GPIO_PIN_0 uint8_t dht11_data[5]; uint8_t dht11_read(void) { uint8_t i, j; uint8_t temp; // 主机发送起始信号 DHT11_SET_OUTPUT(); DHT11_LOW(); HAL_Delay(20); // 拉低至少18ms DHT11_HIGH(); delay_us(30); DHT11_SET_INPUT(); // 等待DHT11响应 if(DHT11_READ() 1) return 1; // 超时 while(DHT11_READ() 0); // 等待80us低电平结束 while(DHT11_READ() 1); // 等待80us高电平结束 // 读取40位数据 for(i 0; i 5; i) { temp 0; for(j 0; j 8; j) { while(DHT11_READ() 0); // 等待50us低电平结束 delay_us(40); // 延时40us后采样 temp 1; if(DHT11_READ() 1) { temp | 1; while(DHT11_READ() 1); // 等待剩余高电平结束 } } dht11_data[i] temp; } // 校验 if(dht11_data[4] (dht11_data[0] dht11_data[1] dht11_data[2] dht11_data[3])) { return 0; // 成功 } return 2; // 校验失败 }这段代码的关键在于采样点的选择。在等待50us低电平结束后延时40us再采样此时如果数据是0高电平已经结束26-28us 40us读到低电平如果数据是1高电平还在持续70us 40us读到高电平。这个40us的延时是经过计算的取在0和1的高电平持续时间之间。注意DHT11的采样周期不能低于1秒。如果你连续快速读取DHT11可能不响应或者返回上一次的数据。在实际项目中建议用定时器每2秒触发一次读取读取成功后更新全局变量其他任务直接读取全局变量即可。3.4 实际调试中遇到的三个典型问题第一个问题是“首次读取失败后续正常”。这通常是因为DHT11上电后需要1秒左右的稳定时间。如果你在main函数初始化后立刻读取大概率失败。解决办法是在初始化后加一个HAL_Delay(1000)或者将第一次读取放在定时器中断里延迟触发。第二个问题是“数据偶尔校验失败”。DHT11的时序对中断非常敏感如果在读取过程中发生了其他中断比如SysTick中断、串口接收中断时序会被打断导致数据位错位。解决办法是在dht11_read函数执行期间关闭全局中断__disable_irq()读取完成后再__enable_irq()。但关闭中断的时间不能太长DHT11一次读取大约需要4-5ms这个时间关闭中断是可以接受的。第三个问题是“温度值始终为0”。这通常是因为DHT11的供电电压不足。DHT11的工作电压是3.3V到5.5V如果你用3.3V供电而数据线的上拉电阻又接到了5V会导致电平不匹配。正确的做法是供电和上拉电阻都接3.3V或者都接5V但此时STM32的GPIO需要设置为开漏模式并外接上拉。4. 串口通信与数据上报的工程化处理4.1 USART的初始化与中断接收配置STM32F1的USART1挂在APB2总线上最高时钟72MHzUSART2和USART3挂在APB1总线上最高时钟36MHz。在配置波特率时需要根据总线时钟计算BRR寄存器的值。以USART1、72MHz时钟、115200波特率为例BRR 72,000,000 / (16 × 115200) 39.0625整数部分39小数部分0.0625×161所以BRR寄存器值为0x271。用STM32CubeMX配置USART1时选择Asynchronous模式波特率1152008位数据位1位停止位无校验。然后使能USART1全局中断在NVIC中设置合适的优先级。生成代码后HAL库会自动生成MX_USART1_UART_Init函数。中断接收的典型用法是每次接收一个字节在中断回调函数中处理。但这种方式在接收不定长数据时效率很低。更实用的做法是使用HAL_UART_Receive_IT接收固定长度或者使用IDLE中断配合DMA接收不定长数据。// 在main函数中启动IDLE中断接收 __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUFFER_SIZE); // 在stm32f1xx_it.c中添加IDLE中断处理 void USART1_IRQHandler(void) { if(__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); HAL_UART_DMAStop(huart1); uint8_t data_length RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); // 处理接收到的data_length个字节 // ... HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUFFER_SIZE); } HAL_UART_IRQHandler(huart1); }这种IDLE中断加DMA的方式可以接收任意长度的数据包而且CPU占用率极低。IDLE中断在总线空闲时触发此时DMA已经自动把数据搬运到了缓冲区你只需要计算接收长度并处理即可。4.2 温湿度数据的格式化与上报协议在实际项目中温湿度数据通常需要上报到上位机或者云平台。最简单的格式是ASCII字符串比如“T:25.0,H:60.0\r\n”。这种格式可读性好调试方便但传输效率低。如果数据量较大可以用二进制格式比如两个字节表示温度高字节整数低字节小数两个字节表示湿度再加一个字节的校验。我通常会在两者之间取一个折中方案用JSON格式。虽然JSON的解析需要额外的库但它的可读性和扩展性最好。比如{temp:25.0,humi:60.0,ts:123456}在STM32F1上拼接JSON字符串可以用sprintf函数。但sprintf会占用较多Flash空间如果Flash紧张可以自己写一个简单的格式化函数。void format_sensor_data(char *buf, float temp, float humi) { int temp_int (int)temp; int temp_dec (int)((temp - temp_int) * 10); int humi_int (int)humi; int humi_dec (int)((humi - humi_int) * 10); sprintf(buf, {\temp\:%d.%d,\humi\:%d.%d}\r\n, temp_int, temp_dec, humi_int, humi_dec); }提示在Keil中使用sprintf时需要在Target选项中勾选“Use MicroLIB”否则会报错或者链接失败。MicroLIB是ARM专门为嵌入式优化的精简C库虽然功能有所裁剪但sprintf、printf这些常用函数都支持。4.3 串口乱码的排查思路串口乱码是STM32F1新手遇到最多的问题之一。排查思路可以按照以下顺序进行第一步检查波特率。确认代码中设置的波特率和串口调试助手的波特率一致。如果用的是外部晶振确认晶振频率和代码中HSE_VALUE宏定义一致。如果HSE_VALUE定义为8000000但实际焊的是12000000的晶振波特率会偏差50%必然乱码。第二步检查时钟配置。用STM32CubeMX生成的代码时钟配置通常是正确的。但如果你手动修改了SystemClock_Config函数需要确认SystemCoreClock变量的值是否与实际主频一致。这个变量影响HAL_Delay和串口波特率计算。第三步检查硬件连接。TX接RXRX接TXGND接GND。如果只接了TX和RX没有接GND串口调试助手可能收到乱码或者什么都收不到。第四步检查电平匹配。STM32F1的串口是3.3V电平如果你用的是5V的USB转串口模块需要在TX和RX线上加电平转换电路或者选择支持3.3V的模块。直接连接可能会损坏STM32的引脚。第五步检查中断优先级。如果串口接收中断的优先级低于其他中断而其他中断处理时间较长会导致串口接收数据丢失表现为乱码。解决办法是提高串口中断的优先级或者使用DMA接收。5. 系统整合与低功耗优化的实际考量5.1 主循环的任务调度设计在一个典型的STM32F1温湿度采集项目中主循环通常需要处理多个任务定时读取DHT11、格式化数据、通过串口发送、可能还有按键扫描和LED指示。如果全部放在主循环里顺序执行DHT11的读取会阻塞其他任务因为DHT11的读取需要4-5ms的微秒级延时。更好的做法是用定时器产生时间片在主循环中轮询执行。比如用TIM2产生1ms中断在中断中设置标志位主循环检测标志位执行对应任务。volatile uint8_t flag_1s 0; volatile uint8_t flag_2s 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { static uint16_t cnt 0; cnt; if(cnt % 1000 0) flag_1s 1; if(cnt % 2000 0) flag_2s 1; } int main(void) { // 初始化... while(1) { if(flag_2s) { flag_2s 0; if(dht11_read() 0) { format_sensor_data(tx_buffer, dht11_data[2], dht11_data[0]); HAL_UART_Transmit(huart1, (uint8_t*)tx_buffer, strlen(tx_buffer), 100); } } if(flag_1s) { flag_1s 0; HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); } } }这种前后台架构简单可靠适合大多数中小型项目。如果任务更多更复杂可以考虑引入FreeRTOS但对于DHT11这种时序敏感的传感器RTOS的任务切换可能会影响时序需要谨慎处理。5.2 低功耗模式与DHT11的配合如果项目是电池供电低功耗设计就很重要。STM32F1支持睡眠、停止和待机三种低功耗模式。睡眠模式功耗约10mA停止模式约20uA待机模式约2uA。但DHT11本身的工作电流约1mA而且需要持续供电才能工作。如果系统大部分时间在低功耗模式DHT11的供电也需要控制。可以用一个GPIO控制DHT11的VCC读取前拉高供电等待1秒稳定后读取读取完成后拉低断电。// DHT11供电控制 #define DHT11_PWR_ON() HAL_GPIO_WritePin(DHT11_PWR_GPIO_Port, DHT11_PWR_Pin, GPIO_PIN_SET) #define DHT11_PWR_OFF() HAL_GPIO_WritePin(DHT11_PWR_GPIO_Port, DHT11_PWR_Pin, GPIO_PIN_RESET) // 读取流程 DHT11_PWR_ON(); HAL_Delay(1000); // 等待DHT11稳定 dht11_read(); DHT11_PWR_OFF();在两次读取之间STM32可以进入停止模式用RTC闹钟或者外部中断唤醒。停止模式下SRAM和寄存器内容保留唤醒后继续执行。但需要注意的是停止模式下SysTick定时器会停止HAL_Delay函数会失效需要用RTC或者独立看门狗来计时。注意进入停止模式前需要将GPIO配置为模拟输入或者保持状态避免悬空的引脚产生漏电流。特别是DHT11的数据线如果上拉电阻一直供电而DHT11断电数据线会被上拉电阻拉高产生不必要的功耗。解决办法是在DHT11断电时将数据线引脚配置为模拟输入。5.3 看门狗与异常恢复机制在工业现场或者无人值守的场景中看门狗是必不可少的。STM32F1内置独立看门狗和窗口看门狗。独立看门狗由内部40kHz的LSI时钟驱动不受主时钟影响即使主时钟失效也能正常工作。独立看门狗的配置很简单设置预分频系数和重装载值确定超时时间。比如预分频64重装载值625超时时间 64 × 625 / 40000 1秒。在主循环中定期喂狗如果程序跑飞或者死循环看门狗超时后会复位芯片。// 独立看门狗初始化 IWDG_HandleTypeDef hiwdg; hiwdg.Instance IWDG; hiwdg.Init.Prescaler IWDG_PRESCALER_64; hiwdg.Init.Reload 625; HAL_IWDG_Init(hiwdg); // 主循环中喂狗 HAL_IWDG_Refresh(hiwdg);但看门狗只能复位芯片不能记录故障原因。更好的做法是在复位后检查RCC_CSR寄存器的IWDGRSTF标志判断是否是看门狗复位然后将故障信息写入Flash或者通过串口上报。if(__HAL_RCC_GET_FLAG(RCC_FLAG_IWDGRST)) { __HAL_RCC_CLEAR_RESET_FLAGS(); // 记录看门狗复位事件 printf(Watchdog reset detected!\r\n); }这种异常恢复机制在实际项目中非常有用可以帮助你定位偶发的死机问题。如果发现设备频繁看门狗复位就需要检查是否有中断优先级配置不当、堆栈溢出或者硬件干扰等问题。6. 从DHT11项目延伸出的STM32F1学习路径DHT11温湿度采集看起来简单但它串联起了STM32F1开发的几乎所有核心知识点GPIO输入输出、微秒级延时、单总线协议、串口通信、中断处理、定时器、DMA、低功耗、看门狗。把这个项目吃透再去看其他外设如SPI Flash、I2C OLED、ADC采样、PWM输出会发现底层逻辑是相通的。我个人的建议是不要急着上RTOS或者复杂的协议栈。先用裸机把DHT11、OLED、串口这三个外设玩熟理解轮询、中断、DMA三种数据传输方式的区别和适用场景。然后再去碰FreeRTOS理解任务调度和信号量。最后再考虑联网模块把数据上传到服务器。STM32F1的Flash和RAM资源有限这反而是一种优势——它逼着你去优化代码去理解每一个字节的用途。当你习惯了在64KB Flash里精打细算再去用STM32F4或者H7会有一种“海阔凭鱼跃”的畅快感。但如果没有在F1上打过基础直接上高端芯片很容易写出臃肿低效的代码遇到问题也不知道从何查起。最后分享一个我在实际项目中总结的小技巧在开发阶段把串口1作为调试口重定向printf所有关键变量的值、函数执行状态、错误码都通过串口打印出来。同时把串口2作为数据口专门用于和上位机通信。这样调试信息和业务数据分开互不干扰。等产品定型后再把调试口的printf全部注释掉或者用宏开关控制既不影响性能又保留了调试能力。// 调试宏开关 #define DEBUG_ENABLE 1 #if DEBUG_ENABLE #define DEBUG_PRINTF(...) printf(__VA_ARGS__) #else #define DEBUG_PRINTF(...) #endif这个习惯让我在排查现场问题时省下了大量时间。很多偶发故障靠在线调试器是抓不到的但串口日志可以一直开着等故障复现时直接看日志就能定位。STM32F1的USART资源足够不用白不用。