
1. 为什么STM32F1至今仍是嵌入式入门的首选平台如果你在电子论坛或者技术群里问一句新手学单片机选什么十个人里至少有六个会告诉你从STM32F1系列开始。这个答案从十年前延续到今天不是因为大家懒得换推荐而是因为F1系列确实踩中了一个非常微妙的平衡点性能够用、资料够多、价格够低、坑够浅。STM32F1是意法半导体基于ARM Cortex-M3内核推出的32位微控制器家族最经典的型号就是STM32F103C8T6也就是大家常说的蓝板或最小系统板。它跑在72MHz主频下拥有64KB到128KB的Flash、20KB的SRAM外设涵盖GPIO、USART、SPI、I2C、ADC、定时器、CAN等几乎你能想到的嵌入式入门外设它都有。关键是这样一颗芯片的核心板在市场上的价格长期维持在十元上下对于学生党和刚入行的工程师来说试错成本极低。但便宜和资料多只是表面原因。真正让F1系列经久不衰的是它的技术生态成熟度。标准外设库、HAL库、LL库三代开发方式并存你既可以用寄存器直接操作感受底层也可以用HAL库快速搭建项目原型。网上关于F1的教程、例程、开源项目数量庞大几乎你遇到的每一个问题都能找到别人踩过的坑和解决方案。这种遇到问题一定能搜到答案的安全感对初学者来说比任何参数都重要。再说说这次热搜里出现的DHT11温湿度传感器。DHT11和STM32F1的组合几乎是嵌入式入门项目里的番茄炒蛋——最基础、最经典、也最能说明问题。DHT11是一款低成本数字温湿度传感器单总线通信只需要一根数据线就能读取温度和湿度。它的通信协议不复杂但也不简单涉及微秒级的时序控制非常适合用来练习GPIO的输入输出切换、定时器延时、以及单总线协议的解析。很多人第一次真正理解时序这个概念就是从驱动DHT11开始的。这篇文章会围绕STM32F1系列展开重点落在DHT11温湿度采集这个具体场景上。我会从芯片选型、开发环境搭建、DHT11驱动原理、代码实现、常见问题排查几个维度把整个链路讲透。不管你是刚拿到第一块开发板的新手还是想复习一下底层时序的老手应该都能从中找到有用的东西。2. STM32F1的型号迷宫与选型逻辑2.1 从F103C8T6说起为什么它成了事实标准STM32F1系列下面有F100、F101、F102、F103、F105、F107等多个子系列每个子系列又有不同的引脚数、Flash容量和封装形式。面对选型表上密密麻麻的型号新手很容易懵。但如果你只是做入门学习和一般项目STM32F103C8T6几乎是不需要犹豫的选择。这颗芯片的规格是这样的LQFP48封装48个引脚其中可用GPIO约37个Flash 64KBSRAM 20KB主频72MHz供电范围2.0V到3.6V。它之所以成为事实标准有几个很实际的原因。第一它的引脚数刚好够用又不浪费面包板和洞洞板都能轻松焊接。第二它的Flash和SRAM容量对于跑RTOS、驱动常见传感器、做简单控制逻辑来说绰绰有余。第三它的价格在批量采购时可以压到极低做小批量产品也不心疼。相比之下F103RCT6有64个引脚、256KB Flash适合需要更多IO和更大存储的项目F103ZET6有144个引脚通常用在开发板上做教学演示。如果你不确定选哪个我的建议是先买一块C8T6最小系统板把外设都玩一遍等真正遇到IO不够或存储不够的时候再换型号。提前为可能用到的引脚买单往往是浪费。2.2 核心板、开发板、最小系统板的区别市面上卖STM32F1的板子大致分三类价格和用途差别很大很多人第一次买容易搞混。最小系统板通常只包含芯片、晶振、复位电路、BOOT选择跳线、电源稳压和排针。它把芯片的所有引脚引出来体积小价格低适合已经有一定基础、自己会接外设的人。你拿到手就是一块光板需要自己配USB转TTL模块来下载程序。核心板在最小系统板的基础上增加了USB接口、MicroSD卡槽、Flash芯片等常用外设有的还带了CAN收发器。它比最小系统板方便一些但本质上还是裸板需要你自己接传感器和执行器。开发板则是完整的学习平台板载了LED、按键、OLED屏、蜂鸣器、温湿度传感器、电机驱动接口等一大堆外设配套的例程和教程也很丰富。价格从几十到几百不等。对于完全零基础的人来说买一块开发板跟着教程走比买最小系统板自己摸索要省心得多。我的建议是如果你连下载程序需要USB转TTL这件事都不知道先买开发板如果你已经玩过Arduino或者51单片机直接买C8T6最小系统板加一个USB转TTL模块总成本不到二十块。2.3 开发工具链的选择Keil、CubeIDE还是PlatformIOSTM32F1的开发环境主要有三条路线各有各的适用场景。Keil MDK是国内最流行的STM32开发工具资料最多教程最全几乎所有卖开发板的商家都会提供Keil工程。它的优势是编译速度快、调试功能强、芯片支持包安装简单。缺点是正版授权费用高虽然很多人用评估版但代码大小限制在32KB对于F103C8T6的64KB Flash来说稍微大一点的项目就会超限。STM32CubeIDE是意法半导体官方推出的免费IDE基于Eclipse和GCC。它集成了CubeMX配置工具可以图形化配置引脚、时钟、外设自动生成初始化代码。对于新手来说CubeMX的图形化配置能大幅降低入门门槛你不需要手动查手册算时钟树点几下鼠标就能把系统时钟配到72MHz。缺点是Eclipse系的IDE用起来比较臃肿编译速度不如Keil而且生成的HAL库代码比较冗长。PlatformIO是嵌入在VS Code里的开发平台支持Arduino框架和STM32原生开发。它的优势是跨平台、插件生态丰富、库管理方便适合习惯用VS Code的开发者。缺点是对国内用户来说首次安装平台包和库的时候网络可能不太顺畅需要一些耐心。我个人的习惯是快速验证想法用CubeIDE加CubeMX正式项目用Keil跨平台协作或者开源项目用PlatformIO。对于这篇文章要讲的DHT11驱动三种环境都能跑代码逻辑是一样的区别只在于工程配置方式。3. DHT11的通信协议拆解与STM32F1的适配要点3.1 单总线协议的本质一根线怎么传数据DHT11最让人困惑的地方是它只用一根数据线就完成了双向通信。没有时钟线没有片选线主机和从机怎么知道什么时候该谁说话答案藏在时序里。单总线协议的核心思想是用信号持续的时间长短来编码0和1。DHT11的数据格式是40位包括8位湿度整数、8位湿度小数、8位温度整数、8位温度小数、8位校验和。每一位数据都以一个低电平开始然后拉高高电平持续26到28微秒表示0持续70微秒左右表示1。主机通过测量高电平的持续时间来判断这一位是0还是1。整个通信过程分三个阶段。第一阶段是主机发送起始信号主机把数据线拉低至少18毫秒然后拉高20到40微秒接着释放总线。第二阶段是DHT11响应DHT11检测到起始信号后会拉低总线80微秒再拉高80微秒表示我准备好了。第三阶段是数据传输DHT11连续送出40位数据每位之间有一个50微秒的低电平间隔。这里的关键点是主机在数据传输阶段必须把数据线切换为输入模式并且要能精确测量微秒级的高电平持续时间。STM32F1的72MHz主频意味着一个时钟周期约13.9纳秒用简单的循环延时就能达到微秒级精度但要注意编译器优化可能会打乱延时循环的节奏。3.2 为什么DHT11不适合用硬件I2C或SPI有人会问STM32F1有硬件I2C和SPI外设为什么不用它们来读DHT11答案是协议不匹配。I2C和SPI都是同步串行协议有独立的时钟线来同步数据。DHT11是异步单总线协议没有时钟线数据位的区分完全依赖时间长度。STM32F1的硬件I2C和SPI外设是为标准协议设计的无法直接生成DHT11所需的任意时序波形。虽然理论上可以用SPI的MOSI线模拟单总线但配置起来比直接用GPIO复杂得多而且SPI外设的最小时间粒度不一定能满足DHT11的微秒级要求。所以驱动DHT11的正确姿势是用普通GPIO软件模拟时序。具体来说需要把连接DHT11数据引脚的GPIO配置为开漏输出或推挽输出发送起始信号时然后在读取数据时切换为浮空输入或上拉输入。STM32F1的GPIO支持在运行时动态切换模式通过修改CRL或CRH寄存器的对应位就能实现用HAL库的话调用HAL_GPIO_Init重新初始化即可。3.3 上拉电阻与线缆长度被忽视的硬件细节DHT11的数据线需要接一个上拉电阻典型值是4.7kΩ到10kΩ。这个电阻的作用是在总线空闲时把电平拉高确保信号稳定。很多模块已经把上拉电阻集成在板子上了如果你买的是裸传感器就必须自己加。上拉电阻的阻值选择有讲究。阻值太小总线被拉低时电流大可能超过DHT11的灌电流能力阻值太大总线电容充电慢高电平上升沿变缓可能导致时序判断错误。4.7kΩ是最常用的值线缆长度在20厘米以内时基本没问题。如果你需要把传感器放在一米以外的地方建议降到2.2kΩ甚至1kΩ同时考虑用屏蔽线减少干扰。还有一个容易被忽略的点DHT11的供电电压是3.3V到5.5V但STM32F1的GPIO是3.3V电平。如果你用5V给DHT11供电数据线的高电平也会接近5V直接接到STM32的GPIO上可能损坏引脚。正确的做法是要么用3.3V给DHT11供电要么在数据线上加电平转换电路。我见过不少人因为这个问题烧了芯片其实只要把DHT11的VCC接到3.3V就能避免。4. 从零实现DHT11驱动的完整代码链路4.1 工程搭建与GPIO初始化假设你用的是STM32F103C8T6和CubeIDE第一步是在CubeMX里配置系统时钟为72MHz然后选择一个GPIO引脚连接DHT11的数据线。我习惯用PA0因为它靠近板子边缘接线方便。在CubeMX里把PA0配置为GPIO_Output初始电平设为高输出模式选Open Drain开漏上拉电阻选Pull-up。开漏模式的好处是当引脚输出高电平时实际是由外部上拉电阻把线拉高这样在切换为输入模式时不会出现驱动冲突。生成代码后你会得到MX_GPIO_Init函数里面已经配置好了PA0。接下来需要自己写几个宏定义来操作引脚#define DHT11_PIN GPIO_PIN_0 #define DHT11_PORT GPIOA #define DHT11_OUT_H() HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET) #define DHT11_OUT_L() HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_RESET) #define DHT11_IN() HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN)然后写一个微秒级延时函数。STM32F1的SysTick默认配置为1毫秒中断直接用它做微秒延时精度不够。可以用定时器也可以用简单的循环延时。下面这个循环延时函数在72MHz、无优化的情况下大约延时1微秒void delay_us(uint32_t us) { uint32_t i; for(i 0; i us; i) { __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); } }注意这个延时函数的精度依赖于编译器优化等级。如果开了-O2优化循环可能被优化掉。稳妥的做法是用定时器做微秒延时或者把延时函数放在单独的源文件里并关闭该文件的优化。4.2 起始信号与响应检测起始信号的时序是主机拉低总线至少18毫秒然后拉高20到40微秒接着切换为输入模式等待DHT11响应。uint8_t DHT11_Start(void) { uint8_t retry 0; DHT11_OUT_H(); delay_us(30); DHT11_OUT_L(); HAL_Delay(20); // 拉低至少18ms DHT11_OUT_H(); delay_us(30); // 拉高20-40us // 切换为输入模式等待DHT11响应 GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin DHT11_PIN; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_PULLUP; HAL_GPIO_Init(DHT11_PORT, GPIO_InitStruct); // 等待DHT11拉低总线 while(DHT11_IN() retry 100) { retry; delay_us(1); } if(retry 100) return 1; // 超时DHT11未响应 // 等待DHT11拉高总线 retry 0; while(!DHT11_IN() retry 100) { retry; delay_us(1); } if(retry 100) return 1; // 等待DHT11再次拉低准备传输数据 retry 0; while(DHT11_IN() retry 100) { retry; delay_us(1); } if(retry 100) return 1; return 0; // 响应成功 }这段代码里有几个细节值得说。第一HAL_Delay(20)用的是毫秒级延时精度足够覆盖18毫秒的要求。第二切换GPIO模式时重新调用了HAL_GPIO_Init这会覆盖之前的输出配置所以下次发送起始信号前需要重新配置为输出模式。第三每个等待循环都有超时计数防止DHT11没接好时程序卡死。4.3 40位数据的读取与校验响应成功后DHT11会连续送出40位数据。每一位都以50微秒低电平开始然后高电平持续26到28微秒表示0持续70微秒表示1。uint8_t DHT11_ReadByte(void) { uint8_t i, byte 0; for(i 0; i 8; i) { // 等待50us低电平结束 while(!DHT11_IN()); // 延时30us后采样如果还是高电平说明是1 delay_us(30); byte 1; if(DHT11_IN()) { byte | 1; // 等待高电平结束 while(DHT11_IN()); } } return byte; } uint8_t DHT11_ReadData(uint8_t *temp, uint8_t *humi) { uint8_t buf[5]; uint8_t i; if(DHT11_Start() 0) { 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 0; } } return 1; }读取逻辑的核心是在正确的时间点采样。每一位开始时DHT11先拉低50微秒然后拉高。如果是0高电平持续26到28微秒如果是1持续70微秒。代码里先等待低电平结束然后延时30微秒再采样。如果此时还是高电平说明高电平持续时间超过了30微秒判定为1如果已经是低电平说明高电平在26到28微秒就结束了判定为0。这个30微秒的采样点选择是有讲究的。它必须大于0信号的最大高电平时间28微秒小于1信号的最小高电平时间70微秒。30微秒刚好落在中间偏0的位置留出了足够的容错空间。4.4 主循环与数据展示把上面的函数串起来在主循环里每隔两秒读一次数据通过串口打印出来int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); uint8_t temp, humi; char msg[64]; while(1) { if(DHT11_ReadData(temp, humi) 0) { sprintf(msg, Temp: %d C, Humi: %d %%\r\n, temp, humi); } else { sprintf(msg, DHT11 read error\r\n); } HAL_UART_Transmit(huart1, (uint8_t*)msg, strlen(msg), 100); HAL_Delay(2000); } }DHT11的采样率有限制两次读取之间至少间隔1秒否则传感器可能来不及完成内部转换返回上一次的数据或者直接通信失败。我一般用2秒间隔既满足实时性要求又不会给传感器太大压力。5. 调试DHT11时最容易踩的五个坑5.1 读出来全是0或者全是1这是最常见的问题通常有三个原因。第一GPIO模式切换没做对。发送起始信号后必须把引脚切为输入模式如果忘了切换引脚还在输出状态读到的永远是输出寄存器的值。第二上拉电阻没接或阻值不对。如果数据线没有上拉空闲时电平不确定DHT11的响应信号可能被误判。第三延时函数不准。如果延时函数因为编译器优化变得太快或太慢采样点就会偏离导致所有位都判错。排查方法用逻辑分析仪或者示波器抓一下数据线的波形看起始信号、响应信号和数据位的时间是否符合手册要求。没有仪器的话可以用另一个GPIO接LED在关键时间点翻转LED用肉眼观察大致时序。5.2 第一次读成功后面一直失败这种情况通常是采样间隔太短。DHT11完成一次测量后需要时间恢复如果连续快速读取传感器可能还没准备好就收到了下一次起始信号。解决办法很简单在两次读取之间加至少1秒的延时。另外如果DHT11刚从休眠状态唤醒第一次读取也可能失败可以连续读两次丢弃第一次的结果。还有一个可能的原因是电源不稳定。DHT11在传输数据时电流会有波动如果电源滤波不好电压跌落可能导致传感器复位。在DHT11的VCC和GND之间并一个100nF的陶瓷电容能明显改善这个问题。5.3 温度湿度数值跳变严重DHT11的精度是±2℃和±5%RH本身就不高但如果数值跳变超过这个范围说明有干扰。常见的干扰源包括电机、继电器等感性负载在开关时产生的电磁干扰长线缆引入的噪声电源纹波过大。应对措施数据线尽量短必要时用屏蔽线在DHT11的电源脚加滤波电容软件上做滑动平均滤波连续读5次去掉最大最小值再取平均。不过要注意DHT11本身响应就慢过度滤波会让数据更迟钝一般3到5次平均就够了。5.4 用HAL_Delay做微秒延时导致时序错乱HAL_Delay的最小单位是毫秒用它做微秒延时只能靠循环空转但循环次数很难精确控制。更麻烦的是HAL_Delay依赖SysTick中断如果在中断里调用或者中断被关闭延时就会失效。正确的做法是用一个硬件定时器做微秒延时。比如配置TIM4为1MHz计数频率每计数一次就是1微秒延时函数里读取当前计数值等到差值达到要求再返回。这样不依赖中断精度也高。如果不想占用定时器可以用DWTData Watchpoint and Trace单元Cortex-M3内核自带这个功能通过几个寄存器就能实现精确的微秒延时。5.5 多任务环境下DHT11读取被中断打断如果你在跑RTOSDHT11的读取过程绝对不能被打断。40位数据的传输时间大约4毫秒如果在这期间发生了任务切换或者中断时序就会错乱。解决办法是在读取DHT11的代码段前后关闭中断或者把DHT11读取放在最高优先级的任务里并且用互斥锁保护。关闭中断的时间要尽量短4毫秒对于大多数系统来说是可以接受的但如果你的系统对中断响应要求很高就需要考虑用硬件方式比如定时器输入捕获来读取DHT11把CPU解放出来。不过对于F103C8T6这种资源有限的芯片软件模拟加关中断是最简单可靠的方案。6. 从DHT11延伸出去的几个练手方向DHT11驱动跑通之后你可以沿着几个方向继续深入把STM32F1的外设一个个吃透。第一个方向是OLED显示。把温湿度数据显示在0.96寸OLED屏上需要驱动I2C或SPI接口的SSD1306控制器。这个过程会让你理解I2C的时序、显存的布局、字库的取模是很好的综合练习。第二个方向是数据上传。通过ESP8266或ESP32模块把温湿度数据发到手机或云平台。这里涉及AT指令解析、串口通信、JSON格式封装是从单机走向物联网的必经之路。第三个方向是低功耗优化。DHT11和STM32F1都可以在不需要的时候进入休眠用定时器唤醒。你可以测量不同休眠模式下的电流消耗计算电池续航学习PWR寄存器的配置和唤醒源的设置。第四个方向是替换传感器。DHT11的精度和响应速度有限可以试试DHT22、SHT30、BME280等更高级的传感器。SHT30用I2C接口精度更高BME280还能测气压。对比不同传感器的驱动方式你会对通信协议有更深的理解。第五个方向是RTOS移植。把裸机程序改造成FreeRTOS或RT-Thread下的多任务程序DHT11读取放一个任务OLED刷新放一个任务串口打印放一个任务。这会让你理解任务调度、优先级、信号量、队列这些RTOS核心概念。我在实际项目里最深的体会是DHT11虽然简单但它把嵌入式开发中最核心的几个能力都串起来了——GPIO操作、时序控制、协议解析、错误处理、硬件调试。把这一个传感器吃透再去看其他传感器你会发现套路都是相通的。先看手册的时序图再确定通信方式然后写驱动、调波形、加校验最后做滤波和容错。这套方法论比任何具体的代码都值钱。最后分享一个小技巧如果你手头没有逻辑分析仪可以用STM32F1的另一个定时器做输入捕获把DHT11的数据线接到定时器的捕获引脚上通过测量高电平持续时间来判断数据位。这样既能验证你的软件驱动是否正确又能顺便学会输入捕获的用法一举两得。