简介基于STM32F103C8T6最小系统板的DHT11温湿度显示工程是一份面向嵌入式入门的完整项目案例。工程通过IIC通信读取DHT11温湿度数据并在0.96寸OLED屏上实时显示覆盖GPIO初始化、通信时序、数据校验、显示驱动等核心环节可帮助学习者将理论知识点串联成可直接运行的硬件方案。资源包共227个文件包括C源码、H头文件、Keil工程文件、编译生成的.o与.crf中间文件、可烧录的hex文件以及清理脚本等整体压缩后仅8.33MB目录按工程、库、输出等模块划分便于快速定位源码、库函数与编译产物还附带.map、.lst等辅助文件供调试分析。已有1523人学习下载。借助库函数和驱动代码不仅能深入理解STM32的IIC外设配置、DHT11的时序要求和OLED的显示控制方法还能在现有工程上修改引脚或显示布局直接迁移到宿舍环境监测、实验室看板或课程设计演示等场景实用性很强。 这两年玩STM32的朋友越来越多尤其是STM32F103C8T6这颗“神芯”价格便宜、资料多、生态成熟堪称入门首选。很多人拿到手做的第一个有点意思的小项目就是用最小系统板读取DHT11温湿度传感器再把数据丢到OLED屏上显示。听起来简单实际做起来却有不少坑DHT11的时序读不出来、OLED不亮、数据乱码、校验失败……我当初也是一路踩过来。这篇就把我自己的完整做法和踩坑记录整理出来从硬件接线到CubeMX配置再到手写DHT11驱动和OLED显示中文全部是实战经验希望能帮你一次跑通。1. 项目整体设计与硬件选型思路1.1 为什么选这套方案STM32F103C8T6是ARM Cortex-M3内核、主频最高72MHz、Flash 64KB、SRAM 20KB在小资源MCU里属于非常均衡的选择。它不像F103ZET6那样引脚多到用不完也不像8位单片机那样性能捉襟见肘做温湿度采集显示这种任务简直是“杀鸡用牛刀”预算和资源都刚好。DHT11是数字温湿度传感器单总线通信只占一个IO口读取范围0-50℃、5%-95%RH精度±2℃、±5%RH。虽然精度不如DHT22/SHT30但胜在便宜、驱动简单、资料满天飞非常适合教学和原型验证。OLED选择的是0.96寸、I2C接口的SSD1306驱动屏幕4根线搞定VCC、GND、SCL、SDA比SPI屏少两根线接线简洁。这套组合的特点是便宜整套成本不超过15元、接线简单总共6根杜邦线、知识密度高涉及GPIO操作、时序协议、I2C通信、定时器延时。做完这个项目你对STM32的底层外设基本就有感觉了。1.2 硬件接线与电源注意点接线表我直接贴出来模块引脚连接目标DHT11VCC3.3VDHT11GNDGNDDHT11DATAPA6任意GPIO均可OLEDVCC3.3VOLEDGNDGNDOLEDSCLPB8I2C1_SCLOLEDSDAPB9I2C1_SDA有两个细节要注意。第一DHT11的VCC接3.3V就行虽然它的逻辑电平兼容3.3V/5V但接5V时数据线电平就变成5V对STM32的GPIO是不安全的。部分模块板载了上拉电阻如果没有建议在DATA引脚外部加一个4.7K-10K上拉电阻到VCC否则数据线可能不稳定。第二OLED的VCC只接3.3V接5V大概率直接烧屏——别问我怎么知道的。2. 开发环境搭建与STM32CubeMX初始化2.1 工具链的选择我用的开发环境是STM32CubeMX 6.x Keil MDK 5的组合。CubeMX负责图形化配置引脚和生成初始化代码Keil负责写逻辑和编译烧录。如果你用STM32CubeIDE也行步骤类似只是编译和调试集成在一起。有一点我强烈建议开启SYS-Debug Serial Wire。如果不选这个选项第一次烧录后第二次连接下载器时会提示找不到芯片因为SWD引脚被默认当成了普通GPIO。新手最容易在这里卡住只能按住复位键抢时间烧录非常狼狈。2.2 CubeMX配置步骤实录打开CubeMX新建工程选择芯片型号STM32F103C8T6然后按下面的顺序配置SYSDebug选Serial Wire。RCCHSE选Crystal/Ceramic Resonator也就是外部8MHz晶振。如果你买的最小系统板没有焊晶振就选内部时钟主频最高也只能跑到64MHz左右但DHT11读取不受影响。时钟树设置HCLK为72MHz直接输入72回车软件会自动算好分频倍频系数。GPIO配置PA6设为GPIO_Output初始电平High这是因为DHT11数据线空闲状态为高电平。注意这里的模式其实后面手写驱动时会改先这么配置主要为了生成基础代码。I2C1配置PB8设为I2C1_SCLPB9设为I2C1_SDAI2C1模式下频率选100KHz即可SSD1306完全够用。生成工程Project Manager里设置好工程名和路径Toolchain选MDK-ARM其他默认生成代码。2.3 最小系统板上的资源盘点F103C8T6带着48个引脚QFN或LQFP封装。最小系统板一般把全部GPIO引出来外加一个USB转串口芯片通常为CH340或CP2102、一个AMS1117-3.3稳压芯片、一个8MHz晶振和一个复位按键。这意味着你不需要自己搭复杂的外围电路直接用排针引出的引脚就能快速验证。资源层面PA、PB、PC各有一堆可用引脚ADC、USART、SPI、I2C、定时器全都有。要注意的是PC13、PC14、PC15这三个引脚只能用普通推挽输出不能用作外部中断已经踩过坑绕开比较好。3. DHT11驱动从零手写3.1 单总线协议的核心时序DHT11用的单总线协议本质上是通过拉低/拉高电平的时间长短来传数据。一次完整通信分三阶段主机发送起始信号主机把数据线拉低至少18ms典型值20ms然后释放并拉高20-40us等待DHT11响应。这个低电平时间必须足够长DHT11才“醒”得过来。DHT11应答DHT11检测到起始信号后会拉低80us再拉高80us表示“我准备好了”。这两段80us是判断应答成功的依据。数据传输应答完成后DHT11连续输出40位数据8位湿度整数、8位湿度小数、8位温度整数、8位温度小数、8位校验和。每一位数据都先拉低50us然后拉高。拉高持续时间26-28us表示“0”拉高持续70us左右表示“1”。判断方法就是测量电平从拉高开始到变回低电平的时间以50us为界限区分0和1。校验和必须等于湿度整数湿度小数温度整数温度小数之和的低8位否则本次读取作废。这一点务必要写进代码里不要偷懒跳过去不然数据错了你根本不知道。3.2 HAL库下的延时和GPIO操作HAL库自带的HAL_Delay()只能做到毫秒级延时DHT11需要微秒级的延时控制。常用的思路是使用DWT-CYCCNT做微秒延时因为这个寄存器是Cortex-M3内核自带的不用额外配置定时器精确度又很高。核心代码是这样void DWT_Delay_Init(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; } void DWT_Delay_us(uint32_t us) { uint32_t start DWT-CYCCNT; uint32_t ticks us * 72; // 72MHz主频1us72个时钟周期 while ((DWT-CYCCNT - start) ticks); }GPIO读写我用的是HAL库的HAL_GPIO_WritePin()和HAL_GPIO_ReadPin()但如果你的系统时钟跑在72MHz这两个函数的调用开销比较大可能影响微秒级精度。实测下来DHT11时序要求没那么苛刻HAL库调用也能正常读。不过如果你追求极限稳定可以直接操作寄存器把PA6配置成推挽输出时用GPIOA-BSRR GPIO_PIN_6拉高用GPIOA-BRR GPIO_PIN_6拉低读电平用(GPIOA-IDR GPIO_PIN_6)速度会快很多。3.3 DHT11完整驱动代码我自己封装的读取函数如下uint8_t DHT11_ReadData(uint8_t *humidity, uint8_t *temperature) { uint8_t buf[5] {0, 0, 0, 0, 0}; uint8_t i, j; uint32_t timeout; // 主机拉低起始信号至少18ms HAL_GPIO_WritePin(DHT11_GPIO_Port, DHT11_Pin, GPIO_PIN_RESET); HAL_Delay(20); HAL_GPIO_WritePin(DHT11_GPIO_Port, DHT11_Pin, GPIO_PIN_SET); // 等待DHT11拉低应答 timeout 100000; while (HAL_GPIO_ReadPin(DHT11_GPIO_Port, DHT11_Pin) GPIO_PIN_SET timeout--); // 应答信号低80us再高80us timeout 100000; while (HAL_GPIO_ReadPin(DHT11_GPIO_Port, DHT11_Pin) GPIO_PIN_RESET timeout--); timeout 100000; while (HAL_GPIO_ReadPin(DHT11_GPIO_Port, DHT11_Pin) GPIO_PIN_SET timeout--); // 读取40位数据 for (i 0; i 5; i) { for (j 0; j 8; j) { // 每位数据起始低50us timeout 100000; while (HAL_GPIO_ReadPin(DHT11_GPIO_Port, DHT11_Pin) GPIO_PIN_RESET timeout--); // 高电平持续时间决定0/1 DWT_Delay_us(40); if (HAL_GPIO_ReadPin(DHT11_GPIO_Port, DHT11_Pin) GPIO_PIN_SET) { buf[i] | (0x80 j); timeout 100000; while (HAL_GPIO_ReadPin(DHT11_GPIO_Port, DHT11_Pin) GPIO_PIN_SET timeout--); } } } // 校验和判断 if ((uint8_t)(buf[0] buf[1] buf[2] buf[3]) buf[4]) { *humidity buf[0]; *temperature buf[2]; return 0; } return 1; }这里我用了一个取巧的方式在电平拉高后延时40us再检测电平。如果此时还是高说明高电平持续超过40us大概率是“1”如果是低则是“0”。这个思路省去了精确计时实际操作验证很稳定。3.4 为什么数据偶尔读不出来DHT11的时序上有一个经典问题在DHT11应答后的高电平响应期间GPIO方向必须保持输入状态。如果你把PA6配置成了推挽输出释放总线时写高电平数据线确实是被强行拉高的但DHT11拉低时需要和你的推挽输出“打架”轻则时序乱掉重则可能损坏传感器。最稳妥的做法是在读取数据前把PA6配置为开漏输出或输入模式读取结束后再恢复为推挽输出。如果你用CubeMX默认配置的GPIO_Output模式也能偶尔读出结果但大概率会出现“读10次成功6次”的怪现象。我自己是用开漏输出加上拉电阻解决的没有再去切换模式代码清爽很多。4. OLED显示SSD1306驱动与中文字模4.1 OLED屏的I2C驱动核心0.96寸OLED用的SSD1306控制芯片是一个128x64分辨率的图形点阵屏。驱动它最少需要几条基本命令关闭/开启显示、设置显示起始行、设置列地址、设置页地址。I2C从机地址通常是0x788位写地址或0x3C7位地址部分模块是0x7A/0x3D如果屏幕不响应先检查地址对不对。初始化序列网上有很多版本我用的是一份经过多次验证的static void OLED_Init_Commands(void) { uint8_t init_cmds[] { 0xAE, 0x00, 0x10, 0x40, 0x81, 0xCF, 0xA1, 0xA6, 0xA8, 0x3F, 0xC8, 0xD3, 0x00, 0xD5, 0x80, 0xD9, 0xF1, 0xDA, 0x12, 0xDB, 0x40, 0x8D, 0x14, 0xAF }; for (int i 0; i sizeof(init_cmds); i) OLED_WriteCmd(init_cmds[i]); }这段初始化序列把显示从关闭到开启、列地址自增方向、电荷泵开关、显示时钟分频等都设置好了。对多数SSD1306驱动的0.96寸屏都通用不需要深究每一句含义但建议把0xAE和0xAF对应的开关显示命令记下来排查屏幕不亮时经常用到。4.2 显示汉字字模的生成与使用SSD1306的显存是按8页划分的每页8个像素高度。也就是说显示8x16的汉字最方便先写字的左半部分页N两字节再写右半部分页N1两字节。取的模格式要高8位在下还是在上要和你代码里的取模方向保持一致。我用的是PCtoLCD2002软件设置如下取模方式阴码1对应点亮取模走向逐行式每行显示数16进制自定义格式C51格式例如“温”字的字模大概长这样const uint8_t hanzi_wen[] { 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x08, 0x08, 0x28, 0x18, 0x28, 0x08, 0x08, 0x08, 0x08, 0x08, 0x20, 0x10, 0x08, 0x04, 0xFE, 0x02, 0x01, 0x00, 0x00, 0x00, 0x00, 0x80, 0x80, 0x80, 0x80, 0x80 };显示函数的核心思路是指定起始坐标x, y把字模数据逐字节写入对应页和列。我封装了一个显示字符串的函数支持ASCII和汉字混排遇到ASCII小于0x80时显示8x16英文/数字字体否则按16x16的汉字字模表查表显示。4.3 在OLED上格式化输出温湿度DHT11读出来的是整数型数据比如湿度60%RH温度25℃。DHT11的“小数”位实际上大部分时间都是0只有个别场合会给出小数精度所以不推荐在小数位上费太多功夫。我显示的时候拼两个数字就行char line1[16], line2[16]; snprintf(line1, sizeof(line1), Hum:%d%%RH, humidity); snprintf(line2, sizeof(line2), Temp:%dC, temperature); OLED_ShowString(8, 2, line1); OLED_ShowString(8, 4, line2);sprintf在STM32上偶尔会遇到重定向问题记得在工程设置里勾选Use MicroLIB否则浮点格式会导致链接体积暴涨。5. 常见问题与排查技巧实录5.1 OLED不亮或亮但没有内容先分清两种情况完全不亮和亮但无内容。完全不亮大概率是电源问题用万用表测模块VCC和GND之间电压必须是3.3V左右。亮但无内容优先确认I2C地址再用逻辑分析仪抓一下SDA/SCL波形看主机有没有发数据。如果波形正常但没有画面检查初始化序列的结束命令是否是0xAF开启显示。有一个非常容易忽略的坑是OLED模块自带的I2C地址选择电阻。有些模块背面有A0/A1的电阻位置焊接不同电阻决定地址。如果你拿到的是A0焊好的模块地址就是0x3C如果A0悬空可能是0x3D。代码里统一用7位地址HAL库的I2C_HandleTypeDef里写的也是7位地址千万别把0x78直接填进去否则数据传输时地址会变成0xF0完全错乱。5.2 DHT11读不到数据或读出来全是0这类问题的排查思路我整理成了一个速查表现象可能原因解决办法一直超时无应答接线错误或使能了推挽输出核对接线改用开漏输出上拉读到数值全为0起始信号时间过短拉低时间改成20ms以上数据跳变严重供电不稳或GPIO电平不匹配用独立3.3V电源供电/检查杜邦线接触校验和总是失败数据线干扰或延时函数不准确DWT延时替换HAL_DelayDHT11两次读取之间至少间隔1秒我实测1.5到2秒刷新一次比较稳妥。刷太快会让传感器内部状态机错乱表现为偶尔报校验错误。所以主循环里记得让系统睡一下别一直发启动信号。5.3 程序卡死在读取函数中如果DHT11拔掉或者接触不良读取函数里的while循环可能一直等待电平变化程序就卡死了。我写的代码里加了一个超时计数超过一定次数直接跳出返回失败。这里要注意的是超时计数值不要设太短不同环境下DHT11响应时间有一定差异我一般设100000次。5.4 显示乱码或者字符叠加OLED显示乱码和字符叠加通常是清屏函数没调用。在写入新数据前先执行OLED_Clear()否则旧数据残留在显存里就出现了“叠影”。另外汉字如果显示成乱码形状问题几乎都在取模设置和显示函数的取模方向不一致上检查取模走向是逐行式还是逐列式两者区别非常大。6. 实测效果与个人心得代码全部完成后把程序通过ST-Link下载到F103C8T6最小系统板。上电瞬间OLED会先亮起然后约2秒后显示第一组温湿度数据。我让板子连续运行了一个下午期间用手捂住传感器湿度数值会缓慢上升松开后又会跌回去整个过程比较稳定没有出现死机和花屏。有一点经验值得单独说DHT11的引脚模式一定要想清楚再写。我最初图省事直接用CubeMX把PA6配成推挽输出读出来的数据时好时坏。后来改成开漏输出、加上拉电阻一次通信成功率几乎100%。这个坑很隐蔽排查成本却很高写代码前先花10秒把引脚方向想明白比自己后面找BUG省太多时间了。如果你手头已经有F103C8T6和OLED强烈建议先跑通这个项目再往上加东西——比如DHT11换成DHT22提升精度、OLED加上历史曲线、通过蓝牙模块把数据发到手机、或者用ESP8266接入物联网平台。每一步都是在熟悉的外设基础上扩展新的通信方式踩坑面更小成就感也来得快。本文还有配套的精品资源点击获取