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?”。这里直接给一个对比表,方便你按项目体量做判断:
| 型号 | 内核 | 主频 | Flash | RAM | 封装/引脚 | 典型应用场景 |
|---|---|---|---|---|---|---|
| STM32F103C8T6 | Cortex-M3 | 72MHz | 64KB | 20KB | LQFP48 | 温湿度采集、I/O控制、小车 |
| STM32F103CBT6 | Cortex-M3 | 72MHz | 128KB | 20KB | LQFP48 | 程序稍大的采集节点、简单UI |
| STM32F103RCT6 | Cortex-M3 | 72MHz | 256KB | 48KB | LQFP64 | 带屏显记录仪、多传感器采集 |
| STM32F103ZET6 | Cortex-M3 | 72MHz | 512KB | 64KB | LQFP144 | 综合仪表、电机控制、学习板 |
| STM32F105RBT6 | Cortex-M3 | 72MHz | 128KB | 32KB | LQFP64 | 需要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,最终SYSCLK=8MHz×9=72MHz,这是F1系列的标准满速配置。
很多人忽略的总线分频在这里也要说清楚:APB1最高36MHz,APB2最高72MHz。如果APB1不分频,那挂在APB1上的USART、I2C、TIM2~7的时钟就是72MHz,但其实很多外设的时钟树设计只允许36MHz或72MHz,配错了会导致串口波特率计算偏差。典型症状是波特率设成115200,实际却是半个字节乱码。正确做法是APB1设2分频变成36MHz,APB2设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用的是类单总线协议,只有一根数据线,既当输入又当输出,数据帧结构是40bit:8bit湿度整数部分、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模拟单总线读取DHT11
4.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(i=0;i<72;i++);大概是1us,但不同编译器优化下会变化。为了稳妥,我一般直接用DWT(Core Debug Unit)做一个精确延时,或者用SysTick做us延时函数。这里给一个简单的SysTick方案:
void delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * 72; // 72MHz主频下1us=72个周期 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又初始化了I2C1,GPIO配置就会被覆盖,导致数据脚不复位。排查引脚冲突时,重点检查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和SWCLK,PA15、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代码调试接近尾声的时候,我会故意把数据线断开或者把传感器电源拔掉,看程序会不会卡死。好的驱动必须能在异常时优雅退出并报告错误,而不是让整个设备挂住。加上超时判断和错误标志,这套驱动才算真的能用。往后你接任何传感器,都应该坚持这个测试习惯——硬件永远会出问题,它不出问题的时候,往往只是还没到时候。