1. 为什么STM32F1至今仍是入门嵌入式的首选如果你在电子论坛或者技术群里问一句“新手学单片机选什么”十个人里至少有六个会告诉你从STM32F1系列开始。这个答案放在十年前成立放在今天依然成立而且理由比当年更充分。STM32F1是意法半导体基于ARM Cortex-M3内核推出的32位微控制器家族最经典的型号如STM32F103C8T6也就是大家常说的“蓝板”或“最小系统板”几十块钱就能买到一块配套的教程、例程、开源项目多到看不完。它解决的核心问题是让没有太多嵌入式基础的人能够用较低的成本和较短的时间理解32位MCU的完整开发流程——从时钟配置、GPIO操作、中断管理到定时器、串口、ADC、I2C、SPI等外设的驱动编写。我之所以在2025年还愿意花时间写这篇东西是因为最近帮几个朋友处理STM32F1相关的项目时发现网上资料虽然多但要么太老还在用标准外设库要么太碎只讲某个外设的配置代码不讲为什么这样配要么直接跳到HAL库但没讲清楚底层逻辑。更麻烦的是很多人拿到DHT11温湿度传感器这类常见模块后不知道该怎么和STM32F1配合使用要么时序调不通要么读出来的数据全是0或者255。所以这篇内容我会围绕STM32F1这个平台把开发环境搭建、时钟系统理解、GPIO与定时器配合、DHT11单总线驱动实现、常见调试手段这几个核心环节串起来讲既有原理层面的解释也有可以直接复现的代码和操作步骤。适合刚接触STM32F1的初学者也适合已经用过但想重新梳理底层逻辑的开发者。2. 开发环境选型与工程模板搭建2.1 标准库、HAL库与LL库的选择逻辑STM32F1系列的开发方式主要有三种标准外设库Standard Peripheral Library、HAL库Hardware Abstraction Layer和LL库Low Layer。标准库是ST早期推出的直接操作寄存器代码效率高资料最丰富但ST已经停止维护。HAL库是现在ST主推的跨系列兼容性好配合STM32CubeMX可以快速生成初始化代码但代码体积大执行效率相对低。LL库可以看作是HAL库的底层补充直接操作寄存器效率接近标准库但API没有标准库那么直观。我的建议是如果你刚入门先用标准库把STM32F1的基本外设跑一遍理解寄存器的操作逻辑等熟悉之后再转到HAL库配合CubeMX做项目提高开发效率。对于DHT11这种对时序要求比较严格的单总线设备标准库或者LL库反而更容易调通因为你可以精确控制延时。下面我以标准库为例讲一下工程模板的搭建。2.2 手动搭建标准库工程的关键步骤很多人用Keil MDK新建工程时直接选STM32F103C8器件然后添加启动文件就完事了结果编译报一堆错。问题出在缺少库文件和头文件路径配置。正确的做法是从ST官网下载STM32F10x标准外设库版本3.5.0解压后找到Libraries文件夹里面包含CMSIS和STM32F10x_StdPeriph_Driver两个目录。在工程目录下新建Startup、Library、User、CMSIS四个文件夹。把启动文件startup_stm32f10x_md.s根据芯片容量选择C8T6是中容量放到Startup把stm32f10x.h、system_stm32f10x.c/h放到CMSIS把inc和src里的外设驱动文件放到Library自己的main.c、stm32f10x_conf.h、stm32f10x_it.c/h放到User。在Keil的Options for Target里C/C选项卡的Define栏填入USE_STDPERIPH_DRIVER, STM32F10X_MDInclude Paths里添加上述四个文件夹的路径。在stm32f10x_conf.h里取消需要的外设头文件注释比如#include stm32f10x_gpio.h、#include stm32f10x_rcc.h、#include stm32f10x_tim.h等。注意启动文件的选择非常关键。STM32F103C8T6的Flash是64KB属于中容量产品必须用startup_stm32f10x_md.s。如果错选成hd大容量或ld小容量程序可能跑不起来或者中断向量表错位。2.3 用CubeMX生成HAL库工程的注意事项如果你决定用HAL库STM32CubeMX确实能省很多事。但有几个坑我踩过第一CubeMX默认把系统时钟配置成内部HSI精度不够做串口通信容易出波特率误差一定要在Clock Configuration里切换到HSE外部晶振并把PLL倍频到72MHz。第二生成工程时如果选MDK-ARM注意勾选“Copy only necessary library files”否则工程文件夹会非常大。第三HAL库的延时函数HAL_Delay()依赖SysTick中断如果在中断服务函数里调用它会导致死锁这一点在写DHT11驱动时要特别注意。3. 时钟树与GPIO的底层逻辑从寄存器到实际配置3.1 STM32F1时钟系统的核心脉络STM32F1的时钟系统比8位单片机复杂得多但理解之后会发现它的设计很灵活。核心时钟源有四个HSI内部8MHz RC振荡器、HSE外部4-16MHz晶振、LSI内部40kHz RC用于看门狗、LSE外部32.768kHz晶振用于RTC。这些时钟源经过PLL倍频、分频后分配给AHB总线、APB1总线低速最高36MHz、APB2总线高速最高72MHz以及各个外设。以最常见的STM32F103C8T6为例外部接8MHz晶振经过PLL 9倍频得到72MHz作为系统时钟SYSCLK。AHB预分频器设为1所以HCLK72MHz。APB1预分频器设为2所以PCLK136MHz挂载在APB1上的外设如TIM2、TIM3、USART2、I2C1时钟为36MHz。APB2预分频器设为1所以PCLK272MHz挂载在APB2上的外设如GPIOA、USART1、SPI1、ADC1时钟为72MHz。为什么要搞清楚这些因为后面配置定时器计算延时、配置串口计算波特率、配置DHT11的微秒级延时都依赖准确的时钟频率。如果你用CubeMX生成了代码但没注意时钟配置很可能实际运行频率和预期不一致导致延时不准、通信失败。3.2 GPIO八种工作模式的适用场景STM32F1的每个GPIO引脚都可以配置成八种模式之一输入浮空、输入上拉、输入下拉、模拟输入、开漏输出、推挽输出、开漏复用输出、推挽复用输出。很多人写代码时随便选一个推挽输出就完事了但在实际项目中模式选错会导致电平异常、功耗增加甚至损坏器件。推挽输出最常用可以输出强高低电平驱动LED、继电器等。输出速度根据需求选2MHz、10MHz或50MHz速度越高功耗和EMI越大不要无脑选50MHz。开漏输出输出端没有上拉需要外部接上拉电阻适合I2C总线、电平转换等场景。多个开漏引脚可以线与在一起实现总线仲裁。复用推挽输出当引脚被用作外设功能如USART_TX、SPI_SCK时使用由外设控制输出。模拟输入用于ADC采集此时施密特触发器关闭引脚直接连接到ADC模块。输入上拉/下拉用于按键检测省去外部电阻。但要注意STM32F1的内部上拉电阻约40kΩ如果干扰严重还是建议用外部4.7kΩ-10kΩ上拉。配置GPIO的代码通常长这样GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_12; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure);提示在配置GPIO之前必须先使能对应端口的时钟。忘记使能时钟是新手最常见的错误之一表现是引脚完全没反应但代码编译没问题。3.3 用定时器实现微秒级延时的两种方案DHT11的通信协议要求微秒级延时比如主机拉低总线至少18ms然后拉高20-40us再读取从机的响应。如果用HAL_Delay()或者循环空转精度根本不够。我试过两种方案方案一用SysTick定时器做微秒延时。SysTick是Cortex-M3内核自带的24位递减计数器配置成1us中断一次然后在中断里对一个全局变量累加。但这种方法在中断里累加如果延时期间被其他高优先级中断打断精度会受影响。方案二用通用定时器TIM做微秒延时。以TIM4为例挂载在APB1上时钟36MHz。设置预分频器PSC35则计数频率为1MHz即每计数一次1us。设置自动重装载值ARR65535然后通过读取当前计数值来计算延时。这种方法的精度取决于定时器时钟的准确性比SysTick更稳定。我最终采用的是方案二的变体不开启定时器中断直接启动定时器在循环里读取计数值达到目标延时后关闭定时器。代码片段如下void delay_us(uint16_t us) { TIM_SetCounter(TIM4, 0); TIM_Cmd(TIM4, ENABLE); while (TIM_GetCounter(TIM4) us); TIM_Cmd(TIM4, DISABLE); }这个函数在72MHz系统时钟、APB136MHz、PSC35的条件下实测延时误差在1us以内足够DHT11使用。4. DHT11单总线驱动的完整实现与调试4.1 DHT11的通信协议拆解DHT11是一款含有已校准数字信号输出的温湿度复合传感器内部由一个电阻式感湿元件和一个NTC测温元件组成通过单总线与MCU通信。一次完整的通信过程包括主机发送起始信号、从机响应、从机发送40位数据、主机读取数据。具体时序如下主机拉低总线至少18ms然后拉高20-40us等待从机响应。从机检测到起始信号后拉低总线80us再拉高80us表示准备发送数据。从机依次发送40位数据每一位以50us低电平开始随后高电平持续26-28us表示“0”持续70us表示“1”。最后一位数据发送完毕后从机拉低总线50us然后释放总线进入空闲状态。40位数据的格式是8位湿度整数 8位湿度小数 8位温度整数 8位温度小数 8位校验和。校验和等于前四个字节之和的低8位。DHT11的湿度小数和温度小数通常为0因为它的分辨率是1%RH和1℃。4.2 GPIO模式切换与代码实现DHT11的数据线需要双向通信所以GPIO要在输出和输入模式之间切换。我把它接在PB12上配置代码如下#define DHT11_GPIO_PORT GPIOB #define DHT11_GPIO_PIN GPIO_Pin_12 #define DHT11_RCC RCC_APB2Periph_GPIOB void DHT11_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(DHT11_RCC, ENABLE); GPIO_InitStructure.GPIO_Pin DHT11_GPIO_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(DHT11_GPIO_PORT, GPIO_InitStructure); GPIO_SetBits(DHT11_GPIO_PORT, DHT11_GPIO_PIN); } void DHT11_SetOutput(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin DHT11_GPIO_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(DHT11_GPIO_PORT, GPIO_InitStructure); } void DHT11_SetInput(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin DHT11_GPIO_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(DHT11_GPIO_PORT, GPIO_InitStructure); }读取数据的核心函数如下uint8_t DHT11_ReadByte(void) { uint8_t i, byte 0; for (i 0; i 8; i) { while (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) 0); delay_us(40); byte 1; if (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) 1) { byte | 1; while (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) 1); } } return byte; } uint8_t DHT11_ReadData(uint8_t *temp, uint8_t *humi) { uint8_t buf[5], i; DHT11_SetOutput(); GPIO_ResetBits(DHT11_GPIO_PORT, DHT11_GPIO_PIN); delay_ms(20); GPIO_SetBits(DHT11_GPIO_PORT, DHT11_GPIO_PIN); delay_us(30); DHT11_SetInput(); if (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) 0) { while (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) 0); while (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) 1); for (i 0; i 5; i) { buf[i] DHT11_ReadByte(); } if (buf[0] buf[1] buf[2] buf[3] buf[4]) { *humi buf[0]; *temp buf[2]; return 1; } } return 0; }4.3 调试中常见的三个问题与排查方法问题一读出来的数据全是0或者255。最常见的原因是延时不准。DHT11对时序非常敏感尤其是读取每一位数据时判断高电平持续时间的那个40us延时。如果系统时钟不是72MHz或者定时器配置有误这个延时就会偏差很大。我的排查方法是用示波器或者逻辑分析仪抓一下数据线的波形看起始信号、响应信号、数据位的高低电平时间是否符合手册要求。如果没有仪器可以临时把延时函数改成循环空转通过调整循环次数来逼近正确时序。问题二第一次读取成功第二次失败。这种情况通常是总线没有正确释放。DHT11在通信结束后会释放总线但如果你在读取完数据后没有把GPIO切回输出模式并拉高下一次通信时主机拉低总线的动作可能无效。我的做法是在每次读取失败后重新初始化GPIO为推挽输出并拉高等待至少1秒再重试。DHT11的采样周期建议不低于1秒频繁读取会导致传感器发热湿度读数偏高。问题三湿度值一直偏高或偏低。这通常不是驱动问题而是传感器放置位置的问题。DHT11对安装环境有要求不要把它放在MCU芯片旁边因为芯片发热会影响温度读数进而影响湿度计算。也不要把传感器密封在壳子里要保持空气流通。我在一个项目里把DHT11放在STM32F1板子正上方5mm处读出来的温度比实际环境高了3℃后来把传感器用排线引出10cm数据就正常了。5. 从裸机到实用数据滤波、串口输出与低功耗处理5.1 对DHT11原始数据做滑动平均滤波DHT11的精度是±2℃和±5%RH本身就不高再加上单总线通信容易受干扰读出来的数据偶尔会跳变。直接显示原始值体验很差我通常会在MCU端做一个滑动平均滤波。具体做法是定义一个长度为5的数组每次读取成功后把新数据存入然后计算平均值。如果某次读取失败就用上一次的有效值填充避免数据断层。#define FILTER_LEN 5 uint8_t temp_buf[FILTER_LEN] {0}; uint8_t humi_buf[FILTER_LEN] {0}; uint8_t buf_index 0; void Update_Filter(uint8_t temp, uint8_t humi) { temp_buf[buf_index] temp; humi_buf[buf_index] humi; buf_index (buf_index 1) % FILTER_LEN; } uint8_t Get_Filtered_Temp(void) { uint16_t sum 0; for (uint8_t i 0; i FILTER_LEN; i) sum temp_buf[i]; return sum / FILTER_LEN; }这个滤波方法简单有效代价是响应速度变慢适合环境变化不剧烈的场景。如果要做快速响应可以改用中值滤波或者卡尔曼滤波但计算量会大一些。5.2 通过串口把温湿度数据发到上位机STM32F1的USART1挂载在APB2上时钟72MHz配置成115200波特率很方便。初始化代码如下void USART1_Init(uint32_t bound) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); USART_InitStructure.USART_BaudRate bound; USART_InitStructure.USART_WordLength USART_WordLength_8b; USART_InitStructure.USART_StopBits USART_StopBits_1; USART_InitStructure.USART_Parity USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, USART_InitStructure); USART_Cmd(USART1, ENABLE); }发送数据时用printf重定向会更方便。在Keil里勾选“Use MicroLIB”然后重写fputc函数int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TC) RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }这样就能在串口助手里看到格式化的温湿度数据了。我习惯每2秒发送一次格式为“Temp: 25C, Humi: 60%”方便用串口绘图工具做趋势图。5.3 低功耗场景下的DHT11读取策略如果你的STM32F1项目是电池供电的比如无线温湿度节点那就要考虑功耗。DHT11本身工作电流约1mA不算高但STM32F1在72MHz全速运行时的电流约30-50mA这才是耗电大户。我的做法是让STM32F1在大部分时间进入Stop模式用RTC闹钟或者外部中断唤醒醒来后快速读取DHT11通过串口或无线模块发送数据然后继续休眠。具体流程是配置RTC每2秒产生一次闹钟中断在中断里退出Stop模式读取DHT11发送数据然后重新进入Stop模式。注意进入Stop模式前要把GPIO配置成模拟输入或者保持状态避免悬空引脚漏电。DHT11的数据线在空闲时应该由主机拉高所以进入Stop模式前要把PB12设置为推挽输出并输出高电平。实测下来这种策略可以把平均电流降到2mA左右用两节AA电池能撑好几个月。当然如果你对功耗要求更极致可以考虑用STM32L系列但那是另一个话题了。6. 几个容易忽略的硬件细节与个人经验6.1 电源去耦与复位电路STM32F1的每个电源引脚旁边都要放一个100nF的陶瓷电容越靠近引脚越好。我见过有人画PCB时把去耦电容放在板子另一面结果ADC采集出来的数据跳动很大。另外NRST引脚上接一个100nF电容到地可以防止复位引脚受干扰导致MCU意外复位。BOOT0和BOOT1引脚要接10kΩ下拉电阻到地确保从主Flash启动。6.2 晶振布局与负载电容外部8MHz晶振要尽量靠近MCU的OSC_IN和OSC_OUT引脚走线要短且对称。负载电容通常选20pF但具体值要根据晶振手册来定。如果晶振不起振先检查焊接有没有虚焊再检查负载电容是否匹配。我遇到过一批板子晶振能起振但频率偏了0.5%导致串口波特率误差累积通信偶尔出错后来把负载电容从20pF换成15pF就正常了。6.3 SWD调试接口的保留不管项目多小我都会把SWD接口SWDIO、SWCLK、GND、3.3V引出来用4Pin的排针。这样调试时可以直接用ST-Link下载和单步调试比串口打印高效得多。注意SWDIO和SWCLK不要被其他外设占用如果必须复用要在初始化时先配置成SWD功能否则下次下载程序时可能连不上。6.4 关于DHT11替代方案的思考DHT11便宜、简单但精度和响应速度确实一般。如果你做的是需要精确控温的项目比如孵化器、恒温箱建议换成DHT22AM2302或者SHT30。DHT22的精度是±0.5℃和±2%RH价格贵一些但值得。SHT30是I2C接口精度更高而且有工业级版本。不过对于学习STM32F1的单总线驱动来说DHT11依然是最好的练手对象因为它把时序要求推到了极致调通之后再去看其他单总线设备会发现思路完全一样。我在实际项目中用STM32F103C8T6配合DHT11做过一个温室监控节点连续运行了三个月中间只出现过两次读取失败都是因为传感器附近湿度突然变化太大导致的。后来加了滑动平均滤波和失败重试机制就再没出过问题。这个组合虽然简单但稳定性足够应付大多数非工业场景。如果你也在用STM32F1和DHT11做东西希望上面这些代码和经验能帮你少走点弯路。