1. 为什么STM32F1到今天还值得花时间学
1.1 一颗“老芯片”的生存逻辑
如果你最近在选型或者准备入门嵌入式,大概率会刷到一堆推荐:什么国产替代、什么Cortex-M4、什么RTOS加WiFi6。但只要你翻一翻淘宝销量、看看各大论坛的新手提问区,就会发现一个反常识的现象——STM32F1系列依然是出货量最大、教程最多、踩坑记录最全的32位MCU之一。尤其是搭配DHT11温湿度传感器做环境监测这个组合,几乎成了电子类专业学生和转行嵌入式的人绕不开的“新手村任务”。
我自己第一次接触STM32F1是在一块最小系统板上,芯片型号是STM32F103C8T6,48个引脚,64KB Flash,20KB RAM,主频72MHz。当时觉得这参数放到今天简直寒酸,但真正用起来才发现,对于绝大多数控制类、采集类、显示类的项目,它完全够用,而且便宜、好买、资料多到令人发指。一块核心板不到十块钱,DHT11模块两三块,加起来一顿早餐钱就能跑通一个完整的“采集-处理-显示”链路。
这篇文章不打算写成数据手册的复读机,而是想从一个实际做过多个F1项目的人的角度,把STM32F1的核心架构、开发方式选择、DHT11驱动细节、常见翻车现场这几个事情讲透。不管你是刚学完51单片机想往上走一步,还是已经工作但一直用库函数“糊代码”想补补底层,应该都能找到对你有用的东西。
1.2 谁适合看这篇,能解决什么问题
先说清楚受众。如果你是完全零基础,连C语言指针和结构体都还没搞明白,那建议先补一下C语言,否则看寄存器操作会很痛苦。但如果你满足下面任意一条,这篇内容就是为你准备的:
- 学过51或者Arduino,想系统性地掌握一款32位MCU;
- 工作中需要用到STM32F1做产品,但一直用CubeMX生成代码,想搞清楚底层到底发生了什么;
- 做课设或者项目要用DHT11采集温湿度,结果时序调不通、数据一直显示85或者0;
- 想从标准库迁移到HAL库,或者反过来,不知道两者到底差在哪。
我会从芯片选型、开发环境搭建、GPIO与时钟体系、DHT11单总线时序、串口打印调试、常见故障排查这几个维度展开。每个部分都会给出可以直接复现的操作步骤和参数计算过程,而不是只丢一句“配置一下就行”。尤其是DHT11的时序部分,我会把微秒级延时的计算方法、中断干扰的处理、校验失败的排查思路全部写出来,这些都是我在实际项目里踩过坑之后总结的。
2. STM32F1的芯片家族与选型思路
2.1 型号命名规则拆解
STM32F1不是一个芯片,而是一个系列。拿最常见的型号STM32F103C8T6来拆:
| 字段 | 含义 | 具体到本例 |
|---|---|---|
| STM32 | 产品家族 | 意法半导体32位MCU |
| F | 产品类型 | 基础型 |
| 103 | 子系列 | 增强型,带USB和CAN |
| C | 引脚数 | 48引脚 |
| 8 | Flash容量 | 64KB |
| T | 封装 | LQFP |
| 6 | 温度范围 | -40到85摄氏度 |
同系列里还有STM32F103RCT6(64引脚、256KB Flash)、STM32F103ZET6(144引脚、512KB Flash)等等。对于DHT11这种单总线传感器项目,C8T6完全足够,甚至用STM32F101这种基础型也行,只是101没有USB和CAN,主频也低一些。
选型的时候有个经验:先看引脚数够不够,再看Flash和RAM,最后看外设。很多人一上来就买ZET6这种大板子,结果焊起来麻烦、体积大、成本高,实际项目里根本用不到那么多IO。我个人的建议是,新手入门买C8T6最小系统板,做产品再根据实际外设需求往上选。
2.2 核心资源与时钟树
STM32F103C8T6的核心资源如下:
- 内核:ARM Cortex-M3,最高72MHz;
- Flash:64KB;
- SRAM:20KB;
- GPIO:37个可用IO;
- 定时器:3个通用定时器(TIM2/3/4)、1个高级定时器(TIM1);
- 通信接口:2个SPI、2个I2C、3个USART、1个USB、1个CAN;
- ADC:2个12位ADC,共10个通道。
时钟树是F1的精华也是难点。简单说,外部晶振一般是8MHz,经过PLL倍频到72MHz作为系统时钟。具体路径是:8MHz → 除以1 → 乘以9 → 72MHz。这个72MHz再分频给AHB、APB1、APB2。APB1最高36MHz,APB2最高72MHz,所以挂载在APB1上的外设(如TIM2/3/4、USART2/3)时钟频率要注意,配置波特率的时候如果算错了,串口就会出乱码。
我见过太多人串口打印乱码,最后发现是把APB1的时钟当成72MHz去算波特率了。记住一句话:USART1挂在APB2上,USART2和USART3挂在APB1上,前者72MHz,后者36MHz。
3. 开发方式的选择:标准库、HAL库还是寄存器
3.1 三种方式的真实对比
这是每个学F1的人都会纠结的问题。我用一个表格把三种方式说清楚:
| 对比项 | 寄存器 | 标准库 | HAL库 |
|---|---|---|---|
| 代码量 | 最少 | 中等 | 最多 |
| 可读性 | 差 | 好 | 好 |
| 移植性 | 差 | 差 | 好 |
| 学习曲线 | 陡 | 平缓 | 平缓 |
| 调试难度 | 高 | 中 | 低 |
| 官方支持 | 无 | 已停止 | 持续更新 |
标准库(SPL)是意法半导体早期推出的,代码直观,比如GPIO_SetBits(GPIOA, GPIO_Pin_0)一眼就能看懂。但官方从2015年左右就停止更新了,新芯片不再支持。HAL库是现在的主流,配合CubeMX可以图形化配置,生成代码快,但封装层次多,出了问题不好查。
我的建议是:新手先用标准库把GPIO、定时器、串口、中断这几个基础外设摸一遍,理解寄存器是怎么被操作的,然后再转HAL库做项目。如果一上来就用CubeMX点几下生成代码,很容易变成“只会点鼠标”的状态,遇到时序问题就懵了。
3.2 开发环境搭建的实操步骤
以Keil MDK为例,搭建一个F1工程需要这几步:
- 安装Keil MDK,版本建议5.30以上,太老的版本对Win11兼容性差;
- 安装器件支持包,去Keil官网下载
Keil.STM32F1xx_DFP,双击安装; - 下载标准库,从ST官网找
STM32F10x_StdPeriph_Lib_V3.5.0,这是最后一个标准库版本; - 新建工程,选择芯片型号
STM32F103C8; - 添加启动文件,从库里的
startup/arm目录复制startup_stm32f10x_md.s(md表示中等容量); - 配置头文件路径,把
Library和User目录加进去; - 定义宏,在C/C++选项卡里加上
USE_STDPERIPH_DRIVER, STM32F10X_MD。
这七步里最容易出错的是第5步和第7步。启动文件选错了(比如选了hd高容量版本),编译能过但运行会跑飞;宏定义没加,会报一堆assert_param相关的错误。我建议把这几步截图保存,下次建工程直接照着做,能省很多时间。
提示:如果你用的是CubeMX加HAL库,步骤会简化很多,但生成的工程默认用内部晶振,需要手动在Clock Configuration里改成外部晶振并配置PLL,否则主频只有8MHz,DHT11的微秒延时算出来会差很多。
4. DHT11温湿度传感器的驱动细节
4.1 单总线协议的时序本质
DHT11用的是单根数据线完成双向通信,这种协议叫单总线。它的核心难点在于所有时序都是微秒级的,而且对时间精度要求比较高。一次完整的读取流程是这样的:
- 主机拉低总线至少18ms,然后拉高20-40us,作为起始信号;
- DHT11检测到起始信号后,拉低总线80us,再拉高80us,作为响应;
- 之后DHT11连续发送40位数据,每一位以50us低电平开始,高电平的持续时间决定是0还是1;
- 高电平持续26-28us表示0,持续70us表示1;
- 最后一位数据发完后,DHT11拉低总线50us,然后释放。
这里的关键是区分0和1的阈值。理论上26-28us是0,70us是1,但实际测量会有波动。我一般用40us作为分界:高电平持续时间大于40us判为1,小于40us判为0。这个阈值在大多数DHT11上都能稳定工作。
4.2 微秒延时的实现与误差控制
在72MHz的F1上,用SysTick或者定时器做微秒延时都可以。用SysTick的话,配置成1us中断一次,但中断本身有开销,实际延时会有偏差。更可靠的做法是用定时器计数或者空循环。
空循环的写法需要根据主频计算循环次数。比如72MHz下,一个指令周期约13.9ns,但C语言一条语句编译后可能对应多条指令。我一般用示波器实测校准,先写一个大概的循环,然后测量实际延时,再调整循环次数。下面是一个经过校准的微秒延时函数示例:
void delay_us(uint32_t us) { uint32_t i; while(us--) { for(i = 0; i < 8; i++) // 72MHz下约1us,需实测校准 { __NOP(); } } }注意这个8不是固定的,不同编译器优化等级下结果不同。建议用示波器或者逻辑分析仪测一下实际波形,把循环次数调到延时准确为止。如果没有仪器,可以用串口打印时间戳来粗略验证。
4.3 完整的读取函数与校验逻辑
DHT11一次返回40位数据,格式是:湿度整数8位 + 湿度小数8位 + 温度整数8位 + 温度小数8位 + 校验和8位。校验和等于前四个字节相加的低8位。下面是一个完整的读取函数框架:
uint8_t DHT11_Read(uint8_t *temp, uint8_t *humi) { uint8_t buf[5]; uint8_t i, j; // 主机发送起始信号 DHT11_Out(); DHT11_Low(); delay_ms(20); DHT11_High(); delay_us(30); DHT11_In(); // 等待DHT11响应 if(DHT11_WaitLow(100) != 0) return 1; if(DHT11_WaitHigh(100) != 0) return 2; // 读取40位数据 for(i = 0; i < 5; i++) { buf[i] = 0; for(j = 0; j < 8; j++) { if(DHT11_WaitHigh(100) != 0) return 3; delay_us(40); buf[i] <<= 1; if(DHT11_ReadPin() == 1) { buf[i] |= 1; if(DHT11_WaitLow(100) != 0) return 4; } } } // 校验 if(buf[4] != (buf[0] + buf[1] + buf[2] + buf[3])) return 5; *humi = buf[0]; *temp = buf[2]; return 0; }这个函数里每个return对应一种错误:1是响应超时,2是响应高电平超时,3是数据位高电平超时,4是数据位低电平超时,5是校验失败。实际调试时把返回值通过串口打印出来,能快速定位问题出在哪一步。
5. 串口调试与数据可视化
5.1 串口配置的关键参数
用USART1做调试输出,配置如下:
- 波特率:115200;
- 数据位:8;
- 停止位:1;
- 校验位:无;
- 硬件流控:无。
在标准库里的配置代码:
USART_InitTypeDef USART_InitStructure; USART_InitStructure.USART_BaudRate = 115200; 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);波特率的计算依赖于APB2的时钟频率。如果系统时钟是72MHz,USART1挂在APB2上,那么USARTDIV = 72000000 / (16 * 115200) = 39.0625,实际寄存器值取39,小数部分0.0625乘以16等于1,所以BRR寄存器写入0x271。这些计算标准库的USART_Init函数会自动完成,但你要确保RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE)被调用了,否则串口没时钟,什么也发不出来。
5.2 用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; }然后在Keil的Target选项里勾选Use MicroLIB。如果不勾选,需要自己实现_sys_exit等底层函数,比较麻烦。MicroLIB是Keil提供的一个精简C库,专门用于嵌入式,勾上就行。
重定向之后,就可以用printf("Temp: %d C, Humi: %d %%\r\n", temp, humi)这样的语句输出数据了。注意DHT11的湿度是整数,温度也是整数,小数部分在DHT11上通常是0,所以直接用%d就行。
5.3 数据上云与本地显示的扩展思路
串口打印只是最基础的调试手段。实际项目里,你可能需要把数据传到上位机或者显示在屏幕上。几个常见的扩展方向:
- OLED显示:用I2C接口的0.96寸OLED,把温湿度实时显示出来,适合做桌面气象站;
- 上位机曲线:用Python的
pyserial库读取串口数据,配合matplotlib画实时曲线; - 数据记录:加一个SD卡模块,用SPI接口把数据按时间戳写入CSV文件;
- 无线传输:加一个蓝牙或者WiFi模块,把数据发到手机或者云平台。
这些扩展里,OLED显示是最适合练手的,因为I2C协议本身也是F1的必修课。我建议先把DHT11读通,再加OLED,一步一步来,不要一上来就搞无线传输,否则出了问题不知道是传感器的问题还是通信模块的问题。
6. 常见问题与排查技巧实录
6.1 DHT11数据一直显示85或0
这是最经典的问题。85这个值其实是DHT11上电后的默认值,说明根本没有读到有效数据。排查顺序如下:
- 检查供电:DHT11的工作电压是3.3V到5.5V,但很多模块是5V供电的,如果MCU是3.3V,数据线电平可能不匹配。建议用3.3V给DHT11供电,或者在数据线上加电平转换;
- 检查上拉电阻:DHT11的数据线需要4.7K到10K的上拉电阻,很多模块自带,但如果自己接线,一定要加上;
- 检查延时精度:用示波器看起始信号的低电平时间是不是18ms以上,响应信号的80us低和80us高是不是准确;
- 检查引脚配置:读取数据前要把引脚从输出模式切换到输入模式,而且输入模式要配置成浮空输入或者上拉输入,不能是模拟输入。
我遇到过最坑的一次是上拉电阻没接,数据线一直悬空,读出来全是0。后来加了一个4.7K电阻到3.3V,立刻正常。
6.2 串口打印乱码
乱码的原因基本只有一个:波特率不对。排查步骤:
- 确认系统时钟是不是72MHz,用
RCC_GetClocksFreq函数打印出来看看; - 确认USART挂在哪个总线上,USART1是APB2,USART2/3是APB1;
- 确认外部晶振是不是8MHz,有些板子用的是12MHz晶振,PLL倍频参数要改;
- 确认串口助手的波特率和代码里一致。
还有一种情况是晶振没起振,MCU自动切换到内部8MHz RC振荡器,这时候系统时钟不是72MHz,波特率自然就错了。用示波器测一下晶振引脚有没有波形,或者读RCC_GetFlagStatus(RCC_FLAG_HSERDY)看看高速外部晶振有没有就绪。
6.3 程序跑飞或进入HardFault
HardFault是Cortex-M3的异常,通常由以下几种原因引起:
| 原因 | 排查方法 |
|---|---|
| 数组越界 | 检查数组下标,尤其是字符串操作 |
| 空指针解引用 | 检查指针是否初始化 |
| 堆栈溢出 | 增大启动文件里的Stack_Size |
| 中断优先级配置错误 | 检查NVIC_PriorityGroupConfig |
| 访问了未使能时钟的外设 | 检查RCC_APBxPeriphClockCmd |
我调试HardFault的常用方法是在HardFault_Handler里加一个死循环,然后看LR寄存器的值,通过LR判断是从哪个函数跳进来的。Keil的调试模式下,可以在Registers窗口看到LR和PC的值,结合反汇编定位问题代码。
6.4 常见问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| DHT11返回85 | 未读到数据 | 检查上拉电阻和延时 |
| DHT11返回0 | 时序错误 | 用示波器校准延时 |
| 校验失败 | 数据位读取错误 | 调整0/1判定阈值 |
| 串口乱码 | 波特率错误 | 检查时钟配置 |
| 程序不运行 | 启动文件选错 | 换成md版本 |
| 编译报错assert_param | 宏定义缺失 | 加USE_STDPERIPH_DRIVER |
| 中断不触发 | NVIC未配置 | 检查优先级和使能 |
| 定时器不准 | 时钟频率算错 | 确认APB1/APB2频率 |
提示:DHT11的采样率不能太高,官方建议间隔不小于1秒。如果连续快速读取,传感器可能不响应或者返回上一次的数据。我在代码里一般加一个
delay_ms(1500),确保两次读取之间有足够的间隔。
7. 从F1出发的进阶路线
7.1 什么时候该换芯片
STM32F1虽然够用,但有些场景确实力不从心:
- 需要浮点运算:F1没有FPU,做PID或者滤波算法时浮点计算很慢,这时候可以考虑F4系列;
- 需要更大RAM:F1最多20KB RAM,跑RTOS加网络协议栈会很吃力;
- 需要高速ADC或DAC:F1的ADC采样率1MHz,F4可以到2.4MHz;
- 需要以太网:F1没有内置MAC,F4和F7有。
但如果你只是做温湿度采集、继电器控制、串口通信这类任务,F1完全够用,而且成本优势明显。不要为了“先进”而换芯片,要根据实际需求来。
7.2 从裸机到RTOS的过渡
裸机程序写多了,会遇到一个问题:多个任务互相阻塞。比如DHT11读取需要20ms,这期间如果还要处理按键和显示,就会卡顿。这时候可以引入RTOS,比如FreeRTOS。
在F1上跑FreeRTOS是可行的,20KB RAM够创建几个任务。移植步骤:
- 下载FreeRTOS源码,把
Source目录下的文件加入工程; - 复制
FreeRTOSConfig.h到User目录,根据F1的资源修改配置; - 实现
vApplicationStackOverflowHook和vApplicationMallocFailedHook; - 配置SysTick和PendSV中断优先级为最低;
- 创建任务,启动调度器。
我建议先把裸机程序写稳定,再上RTOS。如果裸机都调不通,上RTOS只会让问题更复杂。而且F1的资源有限,任务不要建太多,一般3到5个就够了。
7.3 项目实战:桌面温湿度监测站
最后给一个完整的项目思路,把前面讲的东西串起来:
- 硬件:STM32F103C8T6最小系统板 + DHT11 + 0.96寸OLED + 蜂鸣器;
- 功能:每2秒读取一次温湿度,显示在OLED上,温度超过30度蜂鸣器报警;
- 软件架构:主循环轮询DHT11,定时器中断刷新OLED,蜂鸣器用GPIO控制;
- 调试手段:串口打印每次读取的原始数据和校验结果。
这个项目麻雀虽小五脏俱全,涵盖了GPIO、定时器、中断、I2C、单总线、串口这几个核心外设。做完之后,你对F1的掌握程度会有一个质的飞跃。我在带新人的时候,基本都用这个项目作为考核,能独立做出来并且稳定运行一周不出问题的,就可以放手去做更复杂的任务了。
我个人在实际操作中的体会是,STM32F1就像一把用了很多年的螺丝刀,看起来不起眼,但顺手、可靠、坏了不心疼。DHT11虽然精度一般,但胜在便宜和简单,适合用来验证想法。真正把这两个东西吃透,比盲目追新芯片要有价值得多。