
STM32F1系列这颗芯片做嵌入式的几乎没人不认识。不管你是学生做课程设计还是刚转行搞单片机几十块甚至十几块的开发板核心soC八成都是它。我入行那会儿也是从F103C8T6起步的折腾过灯、按键、中断、PWM后来做产品原型验证也拿它顶了不少场景。今天借DHT11温湿度传感器这个最经典的小项目把STM32F1系列的选型思路、开发环境搭建、GPIO模拟单总线协议、再到实际踩过的坑点一次性串起来讲透给准备入门或者想复习一遍的朋友做个参考。1. STM32F1系列整体认知与选型思路1.1 STM32F1到底是什么——内核、主频与定位STM32F1系列是意法半导体最早普及的一批32位MCU内核用的是ARM Cortex-M3没有MMU主打低成本、低功耗、高性价比。整个系列的主频基本都在72MHz左右Flash从16KB到512KB不等RAM从4KB到64KB不等。这个性能放在今天看不算猛但在绝大多数物联网传感采集、电机控制、仪器仪表场景里完全够用。有一个点特别值得注意STM32F1属于“不等宽”引脚兼容家族同样封装下不同型号的引脚定义基本一致比如LQFP48封装的F103C8T6和F103CBT6引脚是Pin-to-Pin兼容的。这意味着你在做硬件设计时可以留一手——先用小Flash版本开发量产后如果程序空间不够可以直接换同封装大容量型号PCB都不用动。这种灵活性是很多8位机给不了的。从生态角度讲F1系列吃到了ST官方标准外设库和后来HAL库两代软件栈的红利网上教程和例程覆盖量巨大。任何你想到的功能基本都能搜到现成案例这点对新手极其友好。即使现在ST主推G0、G4、H7这些新系列但F1的门槛低、资料全依然是入门教学和快速原型验证的首选。1.2 家族型号怎么选容量、封装、外设资源差异对比STM32F1的型号命名看起来复杂其实拆开就几块系列名、Flash容量、封装、温度等级。我最常被人问到的就是“用F103C8T6还是F103ZET6”。这里直接给一个对比表方便你按项目体量做判断型号内核主频FlashRAM封装/引脚典型应用场景STM32F103C8T6Cortex-M372MHz64KB20KBLQFP48温湿度采集、I/O控制、小车STM32F103CBT6Cortex-M372MHz128KB20KBLQFP48程序稍大的采集节点、简单UISTM32F103RCT6Cortex-M372MHz256KB48KBLQFP64带屏显记录仪、多传感器采集STM32F103ZET6Cortex-M372MHz512KB64KBLQFP144综合仪表、电机控制、学习板STM32F105RBT6Cortex-M372MHz128KB32KBLQFP64需要USB设备功能且不想外接芯片选型时除了看Flash和RAM还要留意定时器数量和通信外设数量。比如F103C8T6有3个USART、2个SPI、2个I2C、2个ADC模块做常规传感采集绰绰有余。但如果要做同时驱动TFT屏幕、多个UART外设、还能跑文件系统的完整产品C8T6的64KB Flash很快就撑满了这时候上RCT6或者ZET6更稳妥。我用F1系列的一个经验是初期不要盲目追求大容量先把需求列表列出来把每路外设占用的引脚、Flash预估写清楚再倒推型号。很多项目其实用C8T6就够没必要上来就搞512KB的旗舰版毕竟布线难度和PCB面积都不一样。2. 开发环境搭建与工程模板2.1 工具链选型Keil、STM32CubeMX与标准外设库的取舍做STM32F1开发主流工具链是Keil MDK STM32CubeMX或者Keil 标准外设库也有用GCC Makefile VS Code的极客流派。我个人的建议是新手认准Keil MDK STM32CubeMX这套组合原因很简单——CubeMX把时钟树、引脚复用、外设初始化这类重复劳动自动生成了你只需要专注于业务代码逻辑。这里要讲清楚一个概念STM32CubeMX生成的代码基于HAL库而很多老教程用的标准外设库SPL是ST前一代的库。两者不兼容。如果你看的是老教程但用了新工具链容易对不上。我的建议是直接上手HAL库它虽然代码层级多、函数调用开销略大但方便维护后期切到G0、G4系列也不用重新学。性能敏感场景再回头去抠寄存器或者用LL库绝对不晚。Keil MDK的破解和安装网上教程很多我只提醒三个容易踩的坑第一安装路径不要带中文和空格否则编译时会有莫名其妙的问题第二Keil版本尽量用5.x以上对HAL库支持更好第三务必安装对应的STM32F1xx设备支持包否则打开工程会提示找不到芯片。2.2 新建工程的关键动作时钟树与烧录配置用CubeMX创建一个F103C8T6工程核心动作是配置时钟和烧录引脚。F1系列内部有PLL锁相环外部晶振通常用8MHz无源晶振。配置时钟树时把HSE设为Crystal/Ceramic Resonator然后PLL倍数设为9最终SYSCLK8MHz×972MHz这是F1系列的标准满速配置。很多人忽略的总线分频在这里也要说清楚APB1最高36MHzAPB2最高72MHz。如果APB1不分频那挂在APB1上的USART、I2C、TIM2~7的时钟就是72MHz但其实很多外设的时钟树设计只允许36MHz或72MHz配错了会导致串口波特率计算偏差。典型症状是波特率设成115200实际却是半个字节乱码。正确做法是APB1设2分频变成36MHzAPB2设1分频保持72MHz。烧录配置这块F103支持JTAG、SWD和串口ISP三种方式。我现在基本只用SWD只接SWDIO、SWCLK、GND三条线外加一个3.3V电源线和复位线可选。SWD占用引脚少而且JTAG接口默认占用的PB3、PB4可以被释放成普通IO这对引脚紧张的项目帮助很大。在CubeMX里只需选择Serial Wire即可自动保留SWDIO、SWCLK两个引脚。3. DHT11温湿度传感器的工作原理与硬件接线3.1 单总线协议与时序拆解DHT11用的是类单总线协议只有一根数据线既当输入又当输出数据帧结构是40bit8bit湿度整数部分、8bit湿度小数部分、8bit温度整数部分、8bit温度小数部分、8bit校验和。检验算法很简单前四个字节相加低8位等于校验字节就认为数据有效。这块传感器最关键的其实是时序很多人第一次调DHT11就被它卡住因为它对微秒级延时要求比较严格。主机发起通信时先把数据线拉低至少18ms然后释放并拉高20~40us等待DHT11回应。DHT11响应后会先输出一个约80us低电平再拉高80us然后开始逐位发送数据。数据“0”的高电平持续26~28us数据“1”的高电平持续70us左右。读取时只要测量每个bit高电平的持续时间就能判断是0还是1。理解物理原理后就知道为什么不能随便用一个delay函数去读DHT11——不同的编译器优化等级、不同主频delay函数的实际延时差异很大。这里我把读DHT11的时序判断分开做起始信号用80~120us的延时读取每个bit时连续采样数据线电平用“高电平持续超过40us判为1”这个标准而不是死磕26us和70us的精确边界实践证明更稳。3.2 硬件接线细节上拉电阻与供电选择DHT11最多就三个引脚也有四脚的第三脚悬空VCC、GND、DATA。供电这块要注意DHT11标称3.3V~5.5V都可以但“5V供电、3.3V单片接口”这种混接最容易出问题。我的建议是直接用3.3V给DHT11供电数据脚也直接连STM32的GPIO不需要电平转换电路最简单也相对安全。数据线必须要加上拉电阻阻值4.7kΩ~10kΩ都可以。原因是DHT11的数据线是开漏结构主机和传感器都只能主动拉低释放后需要靠上拉电阻把电平拉高。如果漏了这颗电阻你会发现DHT11偶尔能读到一次但大部分时间数据线波形都是浮空的读回来的字节基本是0xFF这种异常值。引脚选择上我习惯用PC13这块板载LED旁边的引脚做演示或者用PB12、PB13也行。需要注意PB3、PB4、PA15这几个引脚默认被JTAG复用占用如果你不想重新配置复用功能就不要把DHT11接在这些脚上否则初始化后引脚一直是高阻状态读不到数据。4. 代码实现GPIO模拟单总线读取DHT114.1 初始化与引脚模式切换用STM32的GPIO模拟单总线协议核心技巧是灵活切换引脚方向。发送起始信号时引脚要设为推挽输出读数据时要切换到输入模式并配合上拉。HAL库提供了HAL_GPIO_Init()可以反复配置方向但频繁调用会比较慢。我更推荐直接用寄存器操作或者用GPIO_InitTypeDef只改Mode字段来切换速度。我自己常用的驱动代码框架是这样的定义一个DHT11_Init()完成PC13引脚初始化默认模式为推挽输出并输出高电平读取函数内先发起始信号然后把引脚模式切成输入开始采样。#define DHT11_PORT GPIOC #define DHT11_PIN GPIO_PIN_13 void DHT11_Init(void) { GPIO_InitTypeDef gpio {0}; gpio.Pin DHT11_PIN; gpio.Mode GPIO_MODE_OUTPUT_PP; gpio.Pull GPIO_NOPULL; gpio.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DHT11_PORT, gpio); HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET); }有个小技巧在发送起始信号前把引脚拉低后要保持至少18ms。如果主频已经配置为72MHz普通软件延时函数写个HAL_Delay(20)就够了但注意HAL_Delay基于SysTick精度是ms级读bit时绝对不能用它要用us级的delay。4.2 微秒延时与数据位读取微秒延时是读DHT11的重中之重。最简单的方式是裸循环空转比如72MHz主频下一个for(i0;i72;i);大概是1us但不同编译器优化下会变化。为了稳妥我一般直接用DWTCore Debug Unit做一个精确延时或者用SysTick做us延时函数。这里给一个简单的SysTick方案void delay_us(uint32_t us) { uint32_t start DWT-CYCCNT; uint32_t ticks us * 72; // 72MHz主频下1us72个周期 while ((DWT-CYCCNT - start) ticks); }使用DWT前需要先使能调试时钟追踪。这个延时函数我实测误差非常小比软件空循环可靠得多。读取单个bit的逻辑是等待数据线从高电平变为低电平然后计时低电平时间约50us释放后再计时高电平时间。如果高电平持续时间超过40us判定为1否则为0。我通常实现成一个DHT11_ReadByte()循环8次读取一个字节。uint8_t DHT11_ReadByte(void) { uint8_t data 0; for (int i 0; i 8; i) { while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) GPIO_PIN_RESET); // 等待低电平结束 delay_us(40); // 采样点放在高电平开始后的40us if (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) GPIO_PIN_SET) { data | (1 (7 - i)); } while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) GPIO_PIN_SET); // 等待高电平结束 } return data; }这里有个隐藏细节while等待电平变化时外部已经通过上拉把引脚拉高了所以不会有死锁问题。但要注意DHT11和主机之间没有时钟同步如果传感器没插好或者损坏while会一直卡死。实际产品代码里我会给每个while加超时判断比如超过100us就跳出防止程序挂住。教学演示可以简化但项目里必须加。4.3 主函数轮询逻辑与PC串口输出DHT11的采样周期建议不短于1秒官方标称是采集间隔大于2秒。我实际测试过连续快速读取偶尔也能成功但数据不稳定所以主函数里做2秒轮询即可。完整的读取流程是发起始信号、读取40bit数据、校验、解析湿度与温度。uint8_t DHT11_ReadData(uint8_t *humi_int, uint8_t *humi_dec, uint8_t *temp_int, uint8_t *temp_dec) { uint8_t buf[5] {0}; HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_RESET); delay_us(20000); // 起始信号拉低20ms HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET); delay_us(30); GPIO_InitTypeDef gpio {0}; gpio.Pin DHT11_PIN; gpio.Mode GPIO_MODE_INPUT; gpio.Pull GPIO_PULLUP; HAL_GPIO_Init(DHT11_PORT, gpio); if (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) GPIO_PIN_RESET) { while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) GPIO_PIN_RESET); while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) GPIO_PIN_SET); for (int i 0; i 5; i) { buf[i] DHT11_ReadByte(); } } // 恢复输出模式 gpio.Mode GPIO_MODE_OUTPUT_PP; HAL_GPIO_Init(DHT11_PORT, gpio); HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET); uint8_t checksum buf[0] buf[1] buf[2] buf[3]; if ((checksum 0xFF) ! buf[4]) { return 1; // 校验失败 } *humi_int buf[0]; *humi_dec buf[1]; *temp_int buf[2]; *temp_dec buf[3]; return 0; }主函数里用printf重定向到UART1把温湿度数据打到串口助手上。这里有一点要注意HAL库下重定向printf需要重写fputc函数而且如果你用的是HAL_UART_Transmit它是阻塞发送。调试用没问题但实际产品中建议用DMA方式发送避免printf拖慢主循环。新手经常在这里卡住——明明串口配置没问题就是不打印十有八九是忘了实现fputc到USART的映射。5. 实战中遇到的常见问题与排查技巧5.1 数据一直为0或返回校验错误这个问题排在DHT11调试问题第一名。常见原因有三个硬件接线不对、时序不对、上拉电阻缺失。排查思路从硬件开始先用万用表量DATA脚对地电压如果传感器正常且接线正确实际上电后DATA应该是被上拉拉高的大概3.3V左右。如果量到0V大概率是短路或者传感器损坏。然后是时序我遇到过一种典型情况起始信号拉低时间不够只有几毫秒DHT11根本没被唤醒响应信号自然没有。用示波器或逻辑分析仪抓一下波形最直观——如果看不到传感器回应的80us低电平脉冲就说明它根本没检测到起始信号。没有仪器的话把拉低时间加到25ms左右再试通常能解决。还有一个很隐蔽的坑CubeMX默认开启的I2C外设和DHT11用的引脚冲突。比如你把DHT11接在PB6、PB7上而CubeMX又初始化了I2C1GPIO配置就会被覆盖导致数据脚不复位。排查引脚冲突时重点检查CubeMX里有没有其他外设占用了同一个Pin。5.2 每次读取的温湿度偶发跳变DHT11本身精度就不高温度误差±2°C、湿度误差±5%RH所以数值偶尔跳动是正常的。但如果跳变幅度特别大比如湿度从60%突然变到10%那就要注意两点一是读取间隔是否太短DHT11两次读取之间至少要隔1秒以上否则内部正在采样的数据不完整二是程序里是否在中断函数中调用了DHT11读取函数。DHT11的时序完全依赖微秒级实时性如果你的代码在读取过程中被定时器中断打断哪怕只打断几十微秒后面采样的bit就会震掉。我踩过这个坑用一个定时器中断做按键扫描没太在意优先级结果DHT11读取成功率骤降到60%左右。解决办法是在读取DHT11期间关闭相关中断或者把读取函数放到高优先级任务里确保时序不被破坏。5.3 引线过长导致的波形畸变如果你用杜邦线把DHT11接到几十厘米外的STM32板上也会出现读数不稳定的现象。原因是DHT11数据线靠上拉电阻被动上拉走线越长寄生电容越大翻转沿变缓MCU在40us采样点读到的高电平可能已经衰减到1以下。处理办法有三个缩短杜邦线、把上拉电阻从10k换成4.7k、在DHT11的VCC和GND引脚之间就近加一个100nF去耦电容。去耦电容这块很多人忽略但DHT11响应瞬间的电流尖峰会让电源电压波动进而影响数据线电平判断。如果是量产品设计建议把DHT11和MCU放在同一块PCB上走线控制在5cm以内否则要考虑用I2C接口的SHT30这类传感器替代时序稳定性会好很多。5.4 引脚默认复用功能的坑F1系列很多引脚上电后默认不是普通IO而是JTAG调试口或者其他复用功能。PA13、PA14是SWDIO和SWCLKPA15、PB3、PB4分别是JTDI、JTDO、NJTRST。如果你不打算用JTAG调试可以在初始化里关闭JTAG功能释放这些引脚__HAL_AFIO_REMAP_SWJ_NOJTAG();这句会把JTAG引脚释放只保留SWD调试。我常用这个方法把PB3、PB4腾出来接外部设备。但注意如果你在释放前已经把DHT11接上初始化代码执行顺序很重要先关闭JTAG复用再初始化GPIO否则引脚功能已经被锁定配置不生效。6. 项目扩展把这套玩法搬到实际产品里STMF1和DHT11这个组合做完一次基本就能理解“MCU如何跟外部传感器打交道”。后面再做其他单总线协议器件、I2C传感器、SPI传感器思路都是同一个套路弄懂物理层时序再把时序转成代码最后加状态机和超时保护。区别只在于协议复杂度不同。我个人的体会是STM32F1系列很适合作为理解这些协议的载体。它没有太多硬件自动化的外设去“掩盖”底层时序GPIO模拟单总线时你必须自己控制每一个微秒这会逼你把数据手册里的时序图彻底看懂。等你在F1上把DHT11这种东西调通了再去用H7、G4这类高性能MCU会发现只是配置方式变了底层的知识是一样的。最后分享一个小技巧。DHT11代码调试接近尾声的时候我会故意把数据线断开或者把传感器电源拔掉看程序会不会卡死。好的驱动必须能在异常时优雅退出并报告错误而不是让整个设备挂住。加上超时判断和错误标志这套驱动才算真的能用。往后你接任何传感器都应该坚持这个测试习惯——硬件永远会出问题它不出问题的时候往往只是还没到时候。