
1. 为什么STM32F1至今仍是嵌入式入门的首选平台如果你在电子论坛或者技术群里问一句新手学单片机选什么十个人里至少有六个会告诉你从STM32F1系列开始。这个答案从十年前延续到今天不是因为大家懒得换推荐而是因为F1系列确实踩中了一个非常微妙的平衡点——性能够用、资料够多、价格够低、坑也够经典。STM32F1是意法半导体基于ARM Cortex-M3内核推出的32位微控制器家族最经典的型号就是STM32F103C8T6也就是大家常说的蓝板或最小系统板。它跑在72MHz主频下拥有64KB Flash和20KB SRAM外设涵盖GPIO、USART、SPI、I2C、ADC、定时器、CAN等几乎你能想到的嵌入式入门外设它都有。关键是这样一颗芯片的市场价格长期稳定在十元上下对于学生党和个人开发者来说试错成本极低。但便宜好用只是表面原因。真正让F1系列成为教学和项目首选的核心因素是它的参考手册和固件库生态极其成熟。ST官方提供了标准外设库Standard Peripheral Library和后来的HAL库两条路线加上国内大量开发者贡献的中文教程、开源项目和论坛问答你遇到的几乎每一个问题都能在网上找到有人踩过同样的坑。这种集体经验密度是很多 newer 芯片不具备的优势。再说说这次要聊的具体场景——用STM32F1驱动DHT11温湿度传感器。这个组合堪称嵌入式入门的Hello World级项目但别看它简单里面涉及的知识点其实相当密集单总线时序控制、微秒级延时、GPIO输入输出模式切换、数据校验、串口打印调试。把这一套跑通你对STM32的GPIO操作和时序理解就能上一个台阶。很多人觉得DHT11太简单了没什么好讲的但实际动手时发现读出来的数据不是0就是255或者湿度正常温度乱跳这些问题背后都有具体的原因。这篇文章会从F1系列的选型逻辑讲起然后深入到DHT11的驱动原理、代码实现、常见问题排查最后聊一聊从F1过渡到其他系列时需要注意什么。无论你是刚拿到第一块开发板的新手还是想回顾一下底层时序操作的老手应该都能从中找到有用的东西。2. STM32F1的型号迷宫与选型决策2.1 从F103C8T6说起为什么它成了国民型号STM32F1系列下面其实有很多子型号光是F103这一条线就有C6、C8、CB、RB、RC、RE、VC、VE、ZG等多个变体。命名规则里字母代表引脚数和Flash容量数字代表封装类型。比如C8T6就是48引脚、64KB Flash、LQFP48封装。很多人第一次看这个命名规则会觉得头大但其实记住几个关键型号就够了。F103C8T6之所以成为最流行的型号原因很实际48个引脚刚好够用又不至于焊接困难64KB Flash对于大多数入门项目绰绰有余20KB SRAM跑裸机程序完全足够。更重要的是这个型号的最小系统板在市场上大量流通板载了8MHz晶振、32.768kHz RTC晶振、复位电路、BOOT选择跳线、Micro USB接口和稳压芯片你拿到手插上USB就能开始写代码不需要自己画板子。相比之下F103ZET6那种144引脚的大片子虽然外设更丰富多了FSMC、SDIO、更多串口但对于入门来说反而是负担——引脚太多接线容易出错而且很多外设你根本用不到。我个人的建议是第一块板子选C8T6最小系统板等你有了一定经验再根据项目需求升级。2.2 Flash和RAM的取舍别等到编译报错才后悔选型时最容易忽略的就是Flash和RAM的容量。很多人觉得我就点个灯读个传感器64KB肯定够了这话没错但如果你打算用HAL库加上FreeRTOS再挂个OLED显示屏的驱动库Flash占用会迅速膨胀。我实测过一个带OLED显示、DHT11读取、串口输出的HAL库工程编译出来大概占了30多KB Flash和8KB RAM。看起来还有余量但如果你再加个Bootloader或者文件系统64KB就会变得紧张。这里有个经验数据可以参考标准外设库的代码体积通常比HAL库小30%到50%。如果你对Flash容量比较敏感或者想深入理解寄存器操作标准库其实是更好的选择。HAL库的优势在于跨系列兼容性好从F1换到F4或者G0时上层逻辑几乎不用改但代价就是代码臃肿、执行效率略低。型号FlashRAM引脚数典型价格适合场景STM32F103C8T664KB20KB4810-15元入门学习、小型项目STM32F103RCT6256KB48KB6420-30元中等复杂度项目STM32F103ZET6512KB64KB14435-50元多外设、显示类项目STM32F103C6T632KB10KB488-12元极简项目、成本敏感2.3 最小系统板上的那些坑位拿到一块蓝色最小系统板有几个地方是新手容易忽略的。首先是BOOT跳线BOOT0接高电平、BOOT1接低电平时芯片从系统存储器启动也就是进入ISP下载模式BOOT0接低电平时从Flash启动也就是正常运行程序。很多新手烧录完程序忘记把跳线拨回来结果发现板子没反应折腾半天以为是代码问题。其次是USB接口的用途。最小系统板上的Micro USB通常只用于供电和串口通信通过板载的CH340或类似芯片并不直接支持SWD调试。你要真正调试程序还是得接ST-Link或者DAP-Link仿真器。板载的串口可以用来打印调试信息这个后面讲DHT11的时候会用到。还有一个细节是晶振。最小系统板一般板载8MHz外部晶振配合内部PLL倍频到72MHz。但有些廉价板子的晶振负载电容焊接不良导致起振困难或者频率偏移。如果你发现程序跑起来时序总是不对可以用示波器或者逻辑分析仪测一下晶振引脚确认起振正常。3. DHT11的单总线协议到底在干什么3.1 一根线怎么传数据时序即协议DHT11只有一根数据线既要发送命令又要接收数据这种单总线1-Wire的设计思路其实很巧妙——通过不同的高低电平持续时间来区分0和1。你可以把它想象成摩尔斯电码短信号和长信号的组合代表不同含义。具体来说STM32作为主机先拉低数据线至少18毫秒然后拉高20到40微秒这是起始信号。DHT11检测到这个信号后会拉低总线80微秒作为响应信号再拉高80微秒然后开始传输40位数据。这40位数据包括8位湿度整数、8位湿度小数、8位温度整数、8位温度小数、8位校验和。校验和的规则是前四个字节相加取低8位。每一位数据的表示方式是先拉低50微秒作为位起始然后拉高。如果高电平持续26到28微秒表示数据0如果持续70微秒左右表示数据1。所以读取每一位时你需要在低电平结束后等待一段时间然后采样总线电平——如果还是高就是1如果已经变低就是0。这个时序对时间精度要求比较高误差不能太大。STM32F1跑在72MHz下一个时钟周期约13.9纳秒用SysTick或者定时器做微秒级延时完全够用。但如果你用HAL_Delay()这种毫秒级延时函数那就完全没法读DHT11了。3.2 为什么你的DHT11总是读失败时序容错分析我见过太多人第一次读DHT11失败代码看起来没问题但串口打印出来全是0或者255。这里面有几个高频原因第一个原因是延时不准。很多人用for循环做微秒延时比如for(i0;i10;i);这种写法实际延时时间取决于编译器优化等级和主频非常不可靠。正确的做法是用SysTick定时器或者DWTData Watchpoint and Trace单元来做精确延时。DWT是Cortex-M3内核自带的一个调试组件可以用来做纳秒级计时代码也很简单。第二个原因是GPIO模式切换不及时。DHT11的通信过程中数据线需要在输出和输入模式之间切换。主机发送起始信号时是输出模式发送完毕后要立即切换为输入模式等待DHT11响应。如果你切换太慢就会错过响应信号。我建议在拉高总线后立刻切换为输入模式然后用while循环等待DHT11拉低。第三个原因是上拉电阻缺失或阻值不当。DHT11的数据线需要接一个4.7kΩ到10kΩ的上拉电阻。很多DHT11模块自带了这个电阻但如果你买的是裸传感器就必须自己加。没有上拉电阻的话总线在空闲状态下不是稳定的高电平读取数据会随机出错。第四个原因是供电电压。DHT11的工作电压是3.3V到5.5V但如果你用5V供电而STM32的GPIO是3.3V电平虽然DHT11的数据线在5V供电时输出高电平接近5V但STM32的GPIO容忍5V输入F1系列大部分引脚是5V容忍的所以通常没问题。不过为了保险建议统一用3.3V供电。3.3 微秒延时的三种实现方式对比在STM32F1上做微秒延时常见的有三种方案各有优劣SysTick方案是最常用的。SysTick是Cortex-M3内核自带的一个24位递减计数器配置成1微秒中断一次然后在中断里维护一个全局变量。需要延时的时候记录当前值然后循环等待直到差值达到目标。这种方案不占用额外定时器精度也够用缺点是中断频繁对系统实时性有一点影响。DWT方案是我个人最推荐的。DWT的CYCCNT寄存器会记录CPU运行的时钟周期数72MHz下每个周期约13.9纳秒。你可以直接读取CYCCNT的差值来计算经过了多少微秒。这种方案不需要中断不占用任何外设精度极高代码也简洁。唯一需要注意的是DWT默认是关闭的需要先使能。通用定时器方案适合对时间精度要求极高的场景。比如用TIM2配置成1MHz的计数频率直接读CNT寄存器。这种方案精度最高但占用了一个硬件定时器对于资源紧张的项目来说有点浪费。// DWT微秒延时实现示例 #define DWT_CR *(volatile uint32_t *)0xE0001000 #define DWT_CYCCNT *(volatile uint32_t *)0xE0001004 #define DEM_CR *(volatile uint32_t *)0xE000EDFC void DWT_Init(void) { DEM_CR | (1 24); // 使能DWT DWT_CYCCNT 0; // 清零计数器 DWT_CR | (1 0); // 使能CYCCNT } void delay_us(uint32_t us) { uint32_t start DWT_CYCCNT; uint32_t ticks us * (SystemCoreClock / 1000000); while ((DWT_CYCCNT - start) ticks); }这段代码里SystemCoreClock是系统主频72MHz时就是72000000。ticks计算出来就是需要等待的时钟周期数。由于CYCCNT是32位的在72MHz下大约59.6秒会溢出一次对于微秒级延时来说完全不用担心溢出问题。4. 从零搭建DHT11读取工程的关键步骤4.1 硬件连接三根线也有讲究DHT11模块通常有三个引脚VCC、DATA、GND。有些模块是四引脚多了一个NC空脚。连接方式很简单VCC接3.3VGND接GNDDATA接STM32的任意GPIO。我一般习惯接PA0或者PB12这两个引脚在最小系统板上比较容易接线。但这里有个细节DATA线一定要接上拉电阻。如果你用的是三引脚模块模块背面通常已经焊了一个贴片电阻不需要额外处理。但如果你用的是裸传感器就必须在DATA和VCC之间接一个4.7kΩ到10kΩ的电阻。我见过有人直接把裸传感器的DATA接到GPIO然后抱怨读不出数据折腾了一下午才发现是上拉电阻的问题。另外DHT11的采样频率不能太高。官方数据手册明确写了采样周期不能低于1秒也就是说你每秒最多读一次。如果你在while循环里连续读第二次读出来的数据很可能是上一次的缓存或者直接读取失败。我建议在两次读取之间至少间隔2秒给传感器足够的内部转换时间。4.2 GPIO模式切换的时机与代码实现DHT11的通信过程中GPIO需要在输出和输入模式之间切换。在标准外设库中配置GPIO的代码大概是这样// 配置为推挽输出 void DHT11_SetOutput(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin DHT11_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(DHT11_PORT, GPIO_InitStructure); } // 配置为浮空输入 void DHT11_SetInput(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin DHT11_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(DHT11_PORT, GPIO_InitStructure); }这里用浮空输入而不是上拉输入是因为外部已经有上拉电阻了。如果你用内部上拉虽然也能工作但内部上拉电阻阻值较大约40kΩ上升沿会变缓可能影响时序判断。起始信号的发送流程是先设置为输出模式拉低总线至少18ms然后拉高20-40us接着立刻切换为输入模式等待DHT11的响应。这个立刻切换很关键如果你在拉高之后还做了其他事情再切换可能就错过了DHT11拉低总线的80us响应信号。4.3 读取40位数据的完整流程与校验读取一位数据的逻辑是这样的先等待低电平结束DHT11拉低50us作为位起始然后延时约40us采样总线电平。如果此时是高电平说明这一位是1如果是低电平说明是0。为什么延时40us因为数据0的高电平持续26-28us数据1的高电平持续70us。延时40us后采样正好落在0已经变低、1还是高的区间内判断最可靠。uint8_t DHT11_ReadByte(void) { uint8_t i, byte 0; for (i 0; i 8; i) { while (DHT11_ReadPin() 0); // 等待低电平结束 delay_us(40); // 延时40us byte 1; if (DHT11_ReadPin() 1) { byte | 1; } while (DHT11_ReadPin() 1); // 等待高电平结束 } return byte; }读完整40位后进行校验if (check (hum_int hum_dec temp_int temp_dec))。如果校验通过说明数据有效如果不通过直接丢弃这次数据等下一轮再读。我建议在代码里加一个重试机制连续读三次取校验通过的那一次。4.4 串口打印调试让数据看得见读出来的温湿度数据如果不打印出来你根本不知道对不对。用STM32F1的USART1接USB转串口模块配置成115200波特率然后在代码里重定向printf函数就可以用printf直接输出数据了。标准库中重定向printf需要实现fputc函数int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TC) RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }然后在主循环里每隔2秒读一次DHT11校验通过就打印if (DHT11_ReadData(temp, humi) SUCCESS) { printf(Temperature: %d.%d C, Humidity: %d.%d %%\r\n, temp_int, temp_dec, humi_int, humi_dec); } else { printf(DHT11 read failed!\r\n); }串口打印的好处是你可以实时看到数据变化用手捏住传感器或者对着它哈气温湿度数值应该会有明显变化。如果数值纹丝不动那多半是读取失败了只是你的代码没有正确处理错误情况。5. 那些让你抓狂的典型故障与排查路径5.1 读出来全是0或255从电源到上拉的逐项检查这是最常见的问题串口打印出来的温湿度要么全是0要么全是255。排查思路应该从硬件到软件逐项进行第一步确认供电。用万用表量一下DHT11的VCC和GND之间是不是稳定的3.3V。有些最小系统板的3.3V输出能力有限如果同时挂了OLED和其他外设电压可能被拉低。DHT11的工作电流虽然不大约1mA但对电压波动比较敏感。第二步确认上拉电阻。用万用表量DATA线在空闲状态下的电压应该是3.3V左右。如果只有1V多或者飘忽不定说明上拉电阻没接好或者阻值太大。第三步检查延时精度。如果你用的是for循环延时换成DWT或者SysTick重新测试。我遇到过有人用delay_ms(1)来代替delay_us(1000)结果因为HAL_Delay的最小分辨率问题导致时序完全错乱。第四步用逻辑分析仪抓波形。这是最直接的排查手段。一个几十块钱的逻辑分析仪就能把起始信号、响应信号和40位数据的波形全部抓下来。你对照数据手册的时序图一眼就能看出是哪个环节出了问题。比如起始信号拉低时间不够18ms或者采样点位置偏了。5.2 数据偶尔正确偶尔错误时序裕量的重要性如果数据不是全错而是时对时错那说明时序处于临界状态。可能的原因包括延时函数的精度在边界上、GPIO模式切换有额外开销、中断干扰了时序。中断干扰是一个容易被忽略的因素。STM32F1默认开启了SysTick中断用于HAL库的毫秒计时如果你的DHT11读取过程中被SysTick中断打断时序就会偏移。解决办法是在读取DHT11的临界区里暂时关闭中断__disable_irq(); // DHT11读取代码 __enable_irq();但要注意关闭中断的时间不能太长否则会影响其他外设的响应。DHT11一次完整读取大约需要4-5ms这个时间关闭中断是可以接受的。另一个提高裕量的方法是多次采样取众数。连续读5次取出现次数最多的那组数据。虽然DHT11本身精度有限温度±2℃湿度±5%但至少可以避免单次读取的随机错误。5.3 从F1换到F4/G0时DHT11代码要改什么如果你在F1上跑通了DHT11想换到F4或者G0系列代码需要改的地方其实不多但有几个关键点要注意主频变了延时参数要重新计算。F1是72MHzF4可以跑到168MHzG0通常是64MHz。DWT延时函数里的SystemCoreClock会自动更新但如果你用的是循环延时就必须重新调整循环次数。GPIO配置方式可能不同。F1的标准库和F4的HAL库在GPIO初始化结构体上有差异比如HAL库多了GPIO_Pull字段。如果你从标准库迁移到HAL库GPIO模式要改成GPIO_MODE_OUTPUT_PP和GPIO_MODE_INPUT。HAL库的微秒延时需要自己实现。HAL库只提供了HAL_Delay()毫秒延时微秒延时需要自己用DWT或者定时器实现。好在DWT方案是内核级的跨系列通用直接复制过去就能用。中断优先级配置不同。F1的中断优先级分组和F4/G0有差异如果你在DHT11读取中关了中断要确保其他关键中断比如串口接收的优先级配置正确避免数据丢失。6. 把DHT11项目做成一个可复用的驱动模块6.1 驱动分层硬件抽象与业务逻辑分离一个写得好的DHT11驱动应该把硬件相关的操作GPIO配置、延时、引脚读写和协议逻辑起始信号、读位、校验分开。这样当你换平台或者换引脚时只需要改硬件抽象层协议层完全不用动。我通常会把驱动分成三个文件dht11.h放函数声明和宏定义dht11.c放协议实现dht11_port.c放硬件相关的GPIO和延时函数。在dht11.h里定义几个宏#define DHT11_PORT GPIOA #define DHT11_PIN GPIO_Pin_0 #define DHT11_RCC RCC_APB2Periph_GPIOA这样换引脚的时候只改这三个宏就行了。延时函数也用宏或者函数指针的方式抽象出来方便替换。6.2 错误码设计让调用者知道发生了什么很多人的DHT11驱动只返回一个bool值成功或者失败。但实际调试时你更想知道失败的原因是什么——是响应超时还是校验错误还是根本没检测到传感器我建议定义一个错误码枚举typedef enum { DHT11_OK 0, DHT11_ERR_NO_RESPONSE, // 没有收到响应信号 DHT11_ERR_TIMEOUT, // 等待超时 DHT11_ERR_CHECKSUM, // 校验失败 DHT11_ERR_PARAM // 参数错误 } DHT11_Status_t;然后在读取函数里根据不同的失败情况返回不同的错误码。调用者可以根据错误码决定是重试、报警还是记录日志。这个习惯在更复杂的项目里会非常有价值。6.3 非阻塞读取别让DHT11拖慢你的主循环DHT11一次读取需要4-5ms如果你在主循环里直接调用阻塞式读取函数这5ms内CPU什么都干不了。对于简单的温湿度采集项目无所谓但如果你同时要驱动显示屏、处理按键、响应串口命令这5ms的阻塞就会造成明显的卡顿。解决办法是用状态机实现非阻塞读取。把DHT11的读取过程拆分成几个状态发送起始信号、等待响应、读取数据位、校验。每个状态在主循环里执行一小步然后立刻返回下次循环再继续。这样虽然单次读取的总时间没变但CPU可以在等待的间隙处理其他任务。实现非阻塞读取需要用一个静态变量记录当前状态和已读取的位数代码会比阻塞版本复杂一些但对于多任务项目来说是值得的。如果你用FreeRTOS也可以把DHT11读取放在一个独立的任务里用vTaskDelay让出CPU时间。7. 从DHT11延伸出去F1上还能玩什么传感器DHT11只是一个起点。STM32F1的GPIO、I2C、SPI、ADC这些外设可以驱动大量常见的传感器模块。比如I2C接口的OLED显示屏SSD1306驱动可以把温湿度数据直接显示在屏幕上不需要电脑串口。SPI接口的无线模块如NRF24L01可以实现两个F1板子之间的无线数据传输。ADC采集光敏电阻或热敏电阻成本极低适合做简单的环境监测。超声波测距模块HC-SR04用定时器捕获功能测量回波时间精度可以到厘米级。这些模块的驱动思路和DHT11有相通之处理解协议时序、正确配置外设、处理错误情况。把DHT11这个项目吃透再上手其他传感器会顺利很多。我个人在实际操作中的体会是嵌入式开发最难的不是写代码而是建立时序意识和硬件思维。软件出错了可以单步调试但硬件时序出错了你只能靠示波器、逻辑分析仪和反复实验来定位。DHT11这个项目虽然简单但它强迫你去面对真实世界的时序问题这种经验是看多少教程都换不来的。最后分享一个小技巧如果你手头没有逻辑分析仪可以用另一块STM32的定时器输入捕获功能来测量DHT11数据线的高电平持续时间。虽然精度不如专业仪器但足以判断数据位是0还是1对于排查时序问题很有帮助。这个方法我在早期没有仪器的时候经常用成本几乎为零。