资讯动态

基于CubeMX与HAL库的STM32F103温湿度采集与OLED显示方案

发布时间:2026/9/15 11:35:37 来源:尧图企业网站定制
简介基于STM32CubeMX与HAL库的STM32F103RCT6单片机温湿度采集显示工程面向嵌入式初学者与STM32开发者解决DHT11数据读取、7针OLED模拟SPI驱动以及温湿度实时显示的核心问题。工程中PC2连接DHT11数据引脚并采用3.3V供电OLED的RST、CS、DC、SDIN、SCLK分别接PB11至PB15第一行显示温度temp、第二行显示湿度humi接线和逻辑清晰。压缩包共1037个文件以C源码、头文件、链接脚本、工程配置和编译中间文件为主并包含uvprojx、ioc、hex等工程相关文件整体约24.61MB便于直接打开查看或烧录验证。当前已有854人学习下载适合对照CubeMX图形化配置和HAL库代码理解外设初始化、时序读取、数据解析与OLED显示更新流程是入门温湿度采集显示项目的实用参考。1. 基于 CubeMX 的 HAL 库 STM32F103RCT6 温湿度采集显示方案从哪里入手调试桌上最不缺的就是“差一点就跑起来”的外设DHT11 要盯着微秒级单总线时序7 针 OLED 的 DC/CS/RES 三根控制线又决定了你到底是在 SPI 模式还是 I2C 模式下工作。CubeMX 加 HAL 库的优势是在工程初始化阶段就把引脚、时钟和外设参数固定住剩下的事情集中在传感器读取和显示缓冲这两块而 STM32F103RCT6 与最常见的 C8T6 引脚兼容Flash 却大得多后面要加 DMA 双缓冲、字模表也不会捉襟见肘。这套方案适合课设、竞赛样机和一般仪器仪表预研主线是 SPI1 驱动 SSD1306加上 GPIO 模拟 DHT11 单总线。下面按工程生成顺序把每一环讲清楚。2. 用 CubeMX 配置 STM32F103RCT6 的时钟、GPIO 与 SPI12.1 新建 STM32F103RCTx 工程时先确定 RCC、SYS 和引脚分配在 CubeMX 首页选择 MCU 时输入 STM32F103RCTxLQFP64 封装确认 Flash 是 256KB、SRAM 48KB。新建工程后先把 RCC 和 SYS 改掉这两个选项决定了外部晶振和调试口是否可用配置项路径推荐值HSESystem Core RCCCrystal/Ceramic ResonatorSYS DebugSystem Core SYSSerial WireSPI1Connectivity SPI1Full-Duplex MasterPA4System Core GPIOGPIO_Output标签 OLED_CSPB0System Core GPIOGPIO_Output标签 OLED_DCPB1System Core GPIOGPIO_Output标签 OLED_RESPA6System Core GPIOGPIO_Output标签 DHT11_DATA这里把 PA5/PA7 留给 SPI1 的 SCK 和 MOSIPA4 做软件片选PB0、PB1 做 OLED 的 DC 和复位。PA6 作为 DHT11 数据线先设置成输出高电平后面驱动里会动态切换成输入。SYS 选 Serial Wire 是为了保留 SWD 调试口否则生成代码后第一轮烧录可能直接被关闭的 JTAG 挡住。2.2 DHT11 数据引脚的 GPIO 模式规划DHT11 使用的是单总线协议同一根线上既要输出起始信号又要读取传感器回送的 40 位数据。CubeMX 里只能先配置成一种初始状态我一般会把 PA6 设为推挽输出、上拉、高速并在初始化代码里让这一脚输出高电平。/* main.c 中 MX_GPIO_Init 之后手动补一段 */ GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin DHT11_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull GPIO_PULLUP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DHT11_GPIO_PORT, GPIO_InitStruct); HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_PIN, GPIO_PIN_SET);这段配置的含义是总线空闲时保持高电平符合 DHT11 的等待状态推挽输出能保证拉低 20ms 的起始信号有足够驱动能力。真正读取数据前驱动函数会把同一引脚重新初始化成输入模式不能靠库里的某个函数一键完成所以 DHT11 驱动里要同时准备两套 GPIO 初始化结构体。2.3 SPI1 主机参数与 7 针 OLED 的适配7 针 OLED 的 D0/D1 在 SPI 模式下分别对应 SCK 和 MOSI因此 SPI1 不需要 MISO只开 Full-Duplex Master 也能正常工作。CubeMX 里把 Hardware NSS 关掉片选完全由 PA4 这个普通 GPIO 控制换引脚时不用改 SPI 配置。参数设置为什么这么选ModeFull-Duplex MasterSSD1306 只接收不需要读取返回数据Hardware NSSDisable软件 CS 更灵活PA4 随时可控BaudRate Prescaler872MHz / 8 9Mbps低于 SSD1306 上限Clock PolarityLowSSD1306 的 SPI 模式 0空闲低电平Clock Phase1 Edge第一个边沿采样与多数例程一致First BitMSB FirstSSD1306 要求高位先传如果 PCB 走线较长或者杜邦线超过 10cm我会把 Prescaler 改成 16也就是 4.5Mbps换来波形更干净。对 128x64 屏幕来说4.5M 和 9M 在肉眼刷新效果上没有明显区别。2.4 时钟树只改两处PLL 和 APB 分频外部 8MHz 晶振接在 OSC_IN/OSC_OUT 上Clock Configuration 页面里把 Input frequency 设为 8再点 PLL Source 为 HSE倍频到 72MHz。APB1 Prescaler 选 /2APB2 保持 /1。这样 SPI1 所在的总线时钟是 72MHz定时器也是 72MHzDHT11 的微秒延时计算可以按SystemCoreClock / 1000000直接推不需要处理系数。2.5 工程生成后 main.c 的四个自检点生成工程后先不要急着写应用代码按顺序检查SystemClock_Config()、MX_GPIO_Init()、MX_SPI1_Init()三处是否与计划一致。SYS 选的是 Serial Wiremain.c开头会有MPU_Config()和CPU_CACHE_Enable()这类函数要留意。具体检查点HAL_RCC_ClockConfig()之后SystemCoreClock是否为 72000000。MX_SPI1_Init()中BaudRatePrescaler是否为SPI_BAUDRATEPRESCALER_8。MX_GPIO_Init()是否把 PA4/PB0/PB1 初始化为推挽输出且 PA6 是输出高电平。检查hspi1.Init.Mode是SPI_MODE_MASTERNSS 设为SPI_NSS_SOFT。这四处只要有一处偏了后边 OLED 要么不亮要么显示乱码而 DHT11 则表现为一直读取超时。工程配置部分到这里就固定住了后面所有代码都依赖这套初始化结果。3. DHT11 单总线时序与 HAL 库标准驱动写法3.1 DHT11 模块供电和上拉电阻的工程判断买来的 DHT11 模块一般已经焊好 4.7kΩ 上拉电阻和滤波电容数据线直接连接 PA6 即可。如果是裸传感器必须在 DATA 与 VCC 之间外接一颗 4.7kΩ 电阻否则引脚释放后无法快速回到高电平位判决就会出错。供电电压建议用 3.3V。虽然 DHT11 标称 3.3V 到 5.5V 都能工作但 5V 供电时数据线高电平接近 5V直接进 STM32 的 PA6 虽然有保护二极管长期可靠性仍然不如 3.3V 供电。用 3.3V 供电时数据线逻辑电平与 MCU 完全一致不需要电平转换电路。3.2 单总线应答时序和 bit0/bit1 的判定表读取一次 DHT11 的完整时序可以分为三段主机发起起始信号、DHT11 应答、40 位数据输出。阶段电平变化判定要点起始信号主机拉低 20ms再释放释放后等待 20~40us然后切输入应答信号DHT11 拉低 80us再拉高 80us若超时没有低电平说明上拉或接线有问题数据位 050us 低电平 26~28us 高电平高电平短数据位 150us 低电平 70us 高电平高电平长40 位数据顺序是湿度整数、湿度小数、温度整数、温度小数、校验和。标准 DHT11 的小数位常为 0但代码不能假设它恒为 0要原样保留并显示。3.3 用 DWT 计数器做微秒级延时替代 HAL_DelayHAL_Delay()最小单位是毫秒无法完成 40us 的采样等待。常见的替代方案是 DWT 内核计数器它的精度取决于内核时钟在 72MHz 下 1us 正好是 72 个时钟周期不需要额外占用定时器。/* dht11.c 顶部增加 DWT 延时 */ static void DelayUs_DWT(uint32_t us) { DWT-CYCCNT 0; while (DWT-CYCCNT (SystemCoreClock / 1000000U) * us); }3.3.1 DWT 延时初始化只执行一次第一次使用 DWT 前要解锁并开启周期计数这段代码放到main()外设初始化之后调用一次即可CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; DWT-CYCCNT 0;使用 DWT 时要注意两个坑不要在DelayUs_DWT执行期间被高优先级中断打断太久ISR 里的 HAL 库调用会让计数值变慢如果使用了 FreeRTOS任务切换会延长这几十微秒因此 DHT11 的读取最好放在任务里但采样点仍然以 DWT 为基准。另一种方案是用 TIM2 做输入捕获计数器代码复杂度高DWT 对 F103 来说足够。3.4 完整的 DHT11 读取函数与 checksum 校验下面这段代码可以直接复制到工程中驱动占用的是 PA6已经带超时保护不会因为传感器掉线而死在某个while里。/* dht11.c 核心读取代码 */ static void DHT11_PinOutput(void) { GPIO_InitTypeDef g {0}; g.Pin DHT11_PIN; g.Mode GPIO_MODE_OUTPUT_PP; g.Pull GPIO_NOPULL; g.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DHT11_GPIO_PORT, g); } static void DHT11_PinInput(void) { GPIO_InitTypeDef g {0}; g.Pin DHT11_PIN; g.Mode GPIO_MODE_INPUT; g.Pull GPIO_PULLUP; HAL_GPIO_Init(DHT11_GPIO_PORT, g); } static uint8_t WaitLevel(GPIO_PinState level, uint32_t timeout_us) { while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_PIN) ! level) { if (--timeout_us 0) return 1; DelayUs_DWT(1); } return 0; } static uint8_t DHT11_ReadByte(void) { uint8_t val 0; for (uint8_t i 0; i 8; i) { if (WaitLevel(GPIO_PIN_SET, 100)) return 0xFF; DelayUs_DWT(40); val 1; if (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_PIN) GPIO_PIN_SET) val | 0x01; if (WaitLevel(GPIO_PIN_RESET, 100)) return 0xFF; } return val; } uint8_t DHT11_Read(uint8_t out[4]) { uint8_t raw[5]; DHT11_PinOutput(); 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); DelayUs_DWT(30); DHT11_PinInput(); if (WaitLevel(GPIO_PIN_RESET, 200)) return 1; if (WaitLevel(GPIO_PIN_SET, 200)) return 2; if (WaitLevel(GPIO_PIN_RESET, 200)) return 3; for (uint8_t i 0; i 5; i) raw[i] DHT11_ReadByte(); if ((raw[0] raw[1] raw[2] raw[3]) ! raw[4]) return 4; out[0] raw[0]; out[1] raw[1]; out[2] raw[2]; out[3] raw[3]; return 0; }代码里的WaitLevel每次只等 1us超时上限 200us。DHT11 应答和每一位的低电平最长也只有 80us 左右200us 足够且不会误判。DHT11_ReadByte的采样点放在 40us是因为数据位从低电平跳高后0 与 1 的区分点在 28us 到 70us 之间40us 取中间值最稳定。校验和把前四个字节相加与第五字节比较不等则丢弃本次结果。3.5 读取间隔与连续调用时的防掉线处理DHT11 数据手册建议读取间隔不小于 1 秒实际工程中我会在while(1)主循环里间隔 2 秒读一次。若读取失败返回码 1/2/3 分别表示应答低位超时、应答高位超时、数据起始位超时返回 4 表示校验和错误。失败后不要立即重试先把引脚恢复成输出高电平保持 10ms再执行下一次读取否则总线可能处于被 DHT11 拉低的半死状态。4. 7 针 OLED 的 SSD1306 SPI 驱动与温湿度两行显示4.1 7 针 OLED 引脚定义与 SPI 模式确认7 针OLED 常见为 0.96 寸 128x64 SSD1306引脚分别为 GND、VCC、D0、D1、RES、DC、CS。部分模块把 D0/D1 标成 SCL/SDA容易误以为是 I2C其实在 SPI 模式下 D0 是 SCKD1 是 MOSIRES 是复位DC 是数据命令选择CS 是片选。模块引脚功能连接到 STM32F103RCT6GND电源地GNDVCC3.3V 电源3.3VD0 / SCLSPI 时钟PA5D1 / SDASPI 数据PA7RES复位PB1DC数据/命令选择PB0CS片选PA4模块背面可能有 0Ω 电阻选择 SPI 或 I2C 模式默认情况下 7 针版本就是 SPI。如果屏幕接了 SPI 却全屏花点先看模块背面的电阻是跳到 I2C 还是 SPI这是最多人忽略的一步。4.2 命令通道与数据通道的 SPI 传输封装SSD1306 通过 DC 引脚区分命令和数据DC 为低时发送的是命令DC 为高时发送的是显存数据。CS 低有效每次传输前拉低传输完拉高。static void OLED_WriteCmd(uint8_t cmd) { HAL_GPIO_WritePin(OLED_DC_PORT, OLED_DC_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(OLED_CS_PORT, OLED_CS_PIN, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, cmd, 1, HAL_MAX_DELAY); HAL_GPIO_WritePin(OLED_CS_PORT, OLED_CS_PIN, GPIO_PIN_SET); } static void OLED_WriteData(const uint8_t *data, uint16_t len) { HAL_GPIO_WritePin(OLED_DC_PORT, OLED_DC_PIN, GPIO_PIN_SET); HAL_GPIO_WritePin(OLED_CS_PORT, OLED_CS_PIN, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, (uint8_t *)data, len, HAL_MAX_DELAY); HAL_GPIO_WritePin(OLED_CS_PORT, OLED_CS_PIN, GPIO_PIN_SET); }这里每次传输都重新配置 DC 和 CS代码直观代价是开销略高。在意性能时可以只在片选拉低前设置 DC因为 SSD1306 在 CS 拉低期间连续收到的字节会按同一类型写入。HAL_MAX_DELAY表示阻塞等待传输完成SPI 速率 9Mbps 时一帧 1024 字节大约 1ms对 2 秒刷新一次的场景没有压力。4.3 SSD1306 初始化序列与关键寄存器说明复位和初始化顺序固定先拉低 RES 20ms再拉高然后按数组顺序写入命令。static const uint8_t oled_init_cmd[] { 0xAE, /* 关闭显示 */ 0xD5, 0x80, /* 时钟分频 */ 0xA8, 0x3F, /* 多路复用比64 行 */ 0xD3, 0x00, /* 显示偏移 0 */ 0x40, /* 起始行 0 */ 0x8D, 0x14, /* 开启电荷泵 */ 0x20, 0x00, /* 水平地址模式 */ 0xA1, /* 段重映射左右反调 */ 0xC8, /* COM 扫描方向上下反调 */ 0xDA, 0x12, /* COM 引脚硬件配置 */ 0x81, 0x7F, /* 对比度 */ 0xD9, 0xF1, /* 预充电周期 */ 0xDB, 0x40, /* VCOMH 电平 */ 0xA4, /* 显示 RAM 内容 */ 0xA6, /* 正常显示 */ 0xAF /* 开启显示 */ }; void OLED_Init(void) { HAL_GPIO_WritePin(OLED_RES_PORT, OLED_RES_PIN, GPIO_PIN_RESET); HAL_Delay(20); HAL_GPIO_WritePin(OLED_RES_PORT, OLED_RES_PIN, GPIO_PIN_SET); HAL_Delay(20); for (uint8_t i 0; i sizeof(oled_init_cmd); i) OLED_WriteCmd(oled_init_cmd[i]); memset(oled_buf, 0, sizeof(oled_buf)); OLED_Refresh(); }0xA8 后面的 0x3F 是针对 64 行的配置如果手里是 0.91 寸 128x32 屏这里要改成 0x1F并把 0xDA 后面的 0x12 改成 0x02。1.3 寸 SH1106 屏幕的列地址命令与 SSD1306 不同不能用这套初始化直接驱动。4.4 显存缓存、ASCII 与 16x16 汉字混合显示OLED 屏没有写点指令所有像素都先画到内存数组再整帧刷新到屏幕。128x64 屏幕的显存是 1024 字节每个字节对应一列中的 8 个纵向像素点。uint8_t oled_buf[1024]; void OLED_SetPixel(uint8_t x, uint8_t y, uint8_t on) { if (x 128 || y 64) return; uint16_t idx (y / 8) * 128 x; if (on) oled_buf[idx] | 1 (y % 8); else oled_buf[idx] ~(1 (y % 8)); } void OLED_Refresh(void) { OLED_WriteData(oled_buf, sizeof(oled_buf)); }8x16 的 ASCII 字符占两页上半 8 行写入当前页下半 8 行写入下一页。下面对应 SSD1306 水平地址模式的按页绘制函数void OLED_ShowChar8x16(uint8_t x, uint8_t page, char ch) { const uint8_t *p font8x16[(ch - ) * 16]; for (uint8_t i 0; i 8; i) oled_buf[page * 128 x i] | p[i]; for (uint8_t i 0; i 8; i) oled_buf[(page 1) * 128 x i] | p[i 8]; }font8x16是 128 个字符、每个 16 字节的 ASCII 字模表来自于常见 OLED 例程的字体数组工程里单独放到font.c即可。中文字模用类似的 16x16 点阵每个汉字占 32 字节取模软件选择“列行式、阴码、顺向”生成的数组可以直接对应上面的页写入逻辑。RCT6 有 256KB Flash放几十个汉字没有压力。4.5 把 DHT11 数据格式化成两行显示主循环里读取 DHT11 成功后用snprintf把整数和小数拼成字符串写入显存。char line[20]; uint8_t dht[4]; if (DHT11_Read(dht) 0) { OLED_Clear(); snprintf(line, sizeof(line), Temp: %2d.%d C, dht[2], dht[3]); OLED_ShowString8x16(0, 0, line); snprintf(line, sizeof(line), Humi: %2d.%d %%, dht[0], dht[1]); OLED_ShowString8x16(0, 2, line); OLED_Refresh(); } HAL_Delay(2000);OLED_ShowString8x16内部逐个调用OLED_ShowChar8x16绘制完再统一刷新一次避免屏幕出现半刷状态。显示行用 page 而不是像素坐标第 0 页和第 2 页分别对应屏幕顶部 16 行和中部的 16 行两行之间自然留出间隙。5. 用 DMA 刷新 OLED 并处理 DHT11 掉线和显示异常5.1 DMA 传输显存时的双缓冲写法当刷屏频率提高后阻塞式HAL_SPI_Transmit会占用 CPU。改用 DMA 可以让 SPI 外设在后台搬运显存CPU 继续处理下一次 DHT11 读取。uint8_t frame[2][1024]; uint8_t frame_active 0; volatile uint8_t spi_tx_done 1; void OLED_RefreshDMA(uint8_t *src) { while (!spi_tx_done); HAL_GPIO_WritePin(OLED_DC_PORT, OLED_DC_PIN, GPIO_PIN_SET); HAL_GPIO_WritePin(OLED_CS_PORT, OLED_CS_PIN, GPIO_PIN_RESET); spi_tx_done 0; HAL_SPI_Transmit_DMA(hspi1, src, 1024); } void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { HAL_GPIO_WritePin(OLED_CS_PORT, OLED_CS_PIN, GPIO_PIN_SET); spi_tx_done 1; } }调用时每次在frame[0]和frame[1]之间切换先画完当前帧再启动 DMA 传输然后立刻切换画下一帧。双缓冲在 48KB SRAM 中占用 2KB完全可接受。注意传输完成回调里要把 CS 拉高否则下一次 DMA 启动前片选状态错误。5.2 校验和失败后的自动重试策略DHT11 在供电不稳或连续快速读取时会出现校验和错误。错误处理可以保留旧数据显示而不是立刻清屏。连续失败三次后把显示内容替换成Temp: --.- C和Humi: --.- %这样现场人员能区分“传感器未接”和“偶发错误”。uint8_t fail_count 0; if (DHT11_Read(dht) 0) { fail_count 0; OLED_Clear(); OLED_ShowDHT(dht); OLED_Refresh(); } else { fail_count; if (fail_count 3) { OLED_Clear(); OLED_ShowString8x16(0, 0, Temp: --.- C); OLED_ShowString8x16(0, 2, Humi: --.- %%); OLED_Refresh(); } } HAL_Delay(2000);每次失败等待 2 秒再重试避免总线被占用过密。如果长期报错优先检查模块供电和上拉电阻而不是修改时序参数。5.3 常见故障排查表与定位手段现象最可能原因处理方向OLED 白屏但背光亮模块处于 I2C 模式检查模块背面 0Ω 电阻切到 SPIOLED 显示乱码或左右反初始化中 A1/C8 与屏方向不符交换 0xA1/0xA0、0xC8/0xC0DHT11 每次返回 1应答信号没收到检查 DATA 线是否漏接上拉DHT11 返回 4校验和不匹配采样点 40us 改到 45us 再测温度恒为 0 而湿度正常传感器损坏或数据线接触不良换传感器或重新焊线排查 DHT11 时序问题时把逻辑分析仪夹在 DATA 引脚上观察起始信号后的应答低电平宽度和每个数据位的高电平时长能直接区分 0 和 1 的判决边界。OLED 初始化异常则用示波器看 D0 波形幅度如果不到 2.4VSPI 模式配置多半被切换到了 I2C。我排查这类组合项目时会先让 OLED 单独滚动显示 0 到 9再单独读 DHT11 用串口打印最后才合并到一起调主循环这个顺序能省掉一半的联调时间。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价