
简介面向51单片机开发者的OLED屏幕与DS18B20温度显示实践资源以51单片机为核心通过I2C接口驱动OLED实时展示环境温度与时间信息适合正在学习嵌入式基础、准备课程设计或毕业设计的读者。压缩包共31个文件约107KB内含main.c、iic.c、temp.c等源程序配套uvproj工程文件可直接编译hex烧录文件可下载运行obj、lst等中间文件便于检查编译细节与代码生成情况。已有2343人学习。项目重点演示了DS18B20一线总线协议的读取时序与温度解析以及OLED的初始化、坐标定位和字符/数字显示方法同时包含STC15W.h等头文件帮助理解寄存器配置与模块化程序结构。对想从简单闪灯迈向综合性传感显示的开发者来说这套代码提供了清晰可仿照的框架也能辅助排查时序异常、显示不刷新等实际问题。 先说我为什么会折腾这个。前阵子翻出一个吃灰的OLED屏幕和几个DS18B20温度探头想着正好桌面缺一个既能看时间又能看室温的小东西就顺手做了个基于单片机的温度时间显示器。做完之后发现网上关于OLED和DS18B20的资料虽然一大堆但大多只讲了“怎么点亮”和“怎么读温度”等到你真正想把这两个东西放在同一个工程里、还要稳定跑起来的时候各种坑就冒出来了温度偶尔跳成85度、OLED字模取出来是反的、时间一刷新屏幕就闪得厉害。这篇博文就把我从选型、连线、驱动到整合的完整过程捋一遍重点讲那些容易卡住人的细节和排查思路。1. 这个项目到底在做什么系统构成与能力边界先说清楚这个项目不是啥高深的东西。硬件上就三大件一块0.96寸I2C接口的OLED屏幕一颗DS18B20数字温度传感器再加一块主控芯片我用了STM32F103但同样的思路搬到ESP32、Arduino上完全成立。软件上做的事也很直白DS18B20负责测环境温度OLED负责把温度和时间显示出来时间来源我选了DS3231硬件RTC芯片这样断电后时间也不会丢。很多人第一次做这种项目会犯一个规划错误——上来就写代码写完发现三个模块打架。比如DS18B20读取一次温度最长要750毫秒如果每次刷新屏幕都同步去读一次温度OLED就会明显卡顿再比如I2C总线上同时挂了OLED和RTC两个设备地址如果冲突屏幕直接就白屏。所以动手之前先把系统的能力边界和资源消耗盘清楚温度测量范围DS18B20支持-55℃到125℃精度在-10℃到85℃之间是±0.5℃家用场景完全够刷新策略时间每秒刷新一次温度每2秒读一次读温度放在后台非阻塞流程里屏幕就不会闪显示布局屏幕分成上下区域上面显示温度下面显示时间中间用一行横线分隔掉电保持时间由DS3231独立供电维持主控重启后直接从RTC读回时间不需要重新设置。这个项目适合谁来参考如果你是刚接触单片机通信协议、想搞懂单总线时序和I2C驱动的新手这篇可以当一份完整的手把手教程如果你已经能点灯了、但不知道多个传感器怎么优雅地共用一个工程文中的架构拆分和防阻塞思路也值得一看。一个常见的误区是把OLED、DS18B20、RTC各自点亮之后就以为“组合”只是一条主循环的事。实际上真正的难点在于资源的协调——时间、温度、显示刷新这三者节奏不同硬塞在一个顺序流程里要么读温度的时候屏幕没反应要么刷屏的时候CPU被占死外设中断全部丢失。后面我会一步步展开讲我最终采用的调度方案。2. 硬件选型与连线每一步都有它的道理2.1 OLED选型先分清楚I2C还是SPI、SSD1306还是SH1106市面上的小尺寸OLED模块绝大多数是SSD1306或SH1106驱动芯片接口有I2C和SPI两种。这个项目我强烈建议选I2C版本理由只有一个省引脚。STM32F103用PB8做SCL、PB9做SDA两根线就搞定了显示剩下的引脚可以留给按键、RTC和后续扩展。SPI版虽然刷新速度更快但至少要占用5个引脚对这个应用场景来说没必要。这里有个新手特别容易踩的坑SSD1306和SH1106的驱动代码并不完全通用。0.96寸的OLED基本都是SSD1306128x64分辨率1.3寸的很多用的SH1106也是128x64但显存寻址方式有差异。你在淘宝买的模块详情页如果不写芯片型号直接问客服要资料包确认是SSD1306还是SH1106再决定用哪份驱动代码。我最初直接拿SSD1306的代码去驱动一块1.3寸的SH1106屏结果画面错位加闪烁排查了半小时才反应过来是驱动库不匹配。另一个需要确认的参数是I2C地址。SSD1306的七位地址默认是0x3C但有一部分模块把地址引脚拉高了变成了0x3D。代码里如果写死了0x3C屏幕就会一直不亮。我习惯在初始化里加一个地址探测函数扫描I2C总线上的所有设备地址这样OLED和RTC分别在哪一目了然调试的时候能少走很多弯路。2.2 DS18B20的接线与供电方式DS18B20是单总线器件数据引脚DQ需要接一个4.7kΩ的上拉电阻到VCC这个电阻是必须的不是可选项。单总线协议要求总线在空闲时是高电平器件靠拉低总线来发起通信没有上拉电阻通信根本无法建立。供电方式有两种一种是外部供电给VDD引脚另一种是寄生供电——VDD和GND接在一起全靠数据线上的寄生电容供电。桌面温度计这种固定场景我建议直接外部供电稳定省心。寄生供电在长线传输和低温环境时容易出问题读出来的数据经常会跳成-0.5或85排查起来非常头疼。连线方案如下器件引脚接主控/电源OLEDVCC3.3V5V供电的模块转接板也可接5VOLEDGNDGNDOLEDSCLPB8I2C1_SCLOLEDSDAPB9I2C1_SDADS18B20VDD3.3V外部供电DS18B20GNDGNDDS18B20DQPA1并接4.7kΩ上拉至3.3VDS3231SCL与OLED共用PB8DS3231SDA与OLED共用PB9从表格能看出来I2C总线上挂了OLED和RTC两个设备它们的地址一个是0x3C或0x3D一个是0x68不冲突可以安全共用。DS18B20单独占一个GPIO用普通的推挽输出加开漏读入的模式来操作。2.3 主控平台选择STM32和ESP32该怎么选如果只是做一个桌面温度计STM32F103C8T6这种入门级MCU足够了成本低、资料多网上随便一搜就是HAL库的工程模板。但如果你是做带WiFi的桌面天气站或者嫌设置RTC时间麻烦想开机自动联网对时那就直接上ESP32——它内部自带WiFi用SNTP协议从网络获取时间把DS3231都省了。我最终选了STM32F103 DS3231的组合是因为我想做一个完全不依赖网络的离线设备让它安安静静摆在桌面上不掺和任何联网的事情。用ESP32也能做但对我来说“能联网”反而成了一种干扰而且ESP32的Deep Sleep低功耗玩法在这个项目里也用不上得不偿失。3. DS18B20驱动单总线时序从底层到HAL库实现3.1 为什么DS18B20的时序不能直接用HAL_DelayDS18B20的单总线协议本质上是用精确的延时来控制总线上的电平持续时间。初始化复位、写0、写1、读0、读1每个操作都有严格的时间窗口。早期的51单片机教程喜欢用软件延时就是空循环来凑这些时间移植到STM32的HAL库之后问题就来了——HAL_Delay的参数单位是毫秒而DS18B20需要的是微秒级延时15微秒、60微秒、480微秒毫秒级别的延时根本没法用。所以第一步要准备一个微秒级延时函数。最简单可靠的办法是用SysTick把系统时钟配置成1微秒tick一次在延时函数里循环查询计数寄存器。另一个方案是用TIM定时器做延时原理类似但多占一个外设。DWTData Watchpoint and Trace也是个好工具它有专门的CYCCNT寄存器记录CPU周期数延时精度很高且不打断中断适合对时序要求比较苛刻的场合。我直接用SysTick的微妙延时够用代码也不复杂void delay_us(uint32_t us) { uint32_t start DWT-CYCCNT; uint32_t ticks us * (SystemCoreClock / 1000000); while ((DWT-CYCCNT - start) ticks); }注意DWT要手动使能CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk;这段配置放在主函数初始化里执行一次即可。有了可靠的微秒延时后面跟DS18B20通信就顺手多了。3.2 复位时序、ROM指令与温度读取的完整流程单总线的通信流程分几步顺序不能乱主机拉低总线480微秒典型值范围480-960释放总线DS18B20检测到复位脉冲后等待60-240微秒然后拉低总线60-240微秒作为应答脉冲主机检测到应答脉冲说明器件在线可以继续通信主机发送ROM指令这里只用到跳过ROM0xCC因为是单点测温不需要寻址多个器件主机发送功能指令启动温度转换0x44然后等待转换完成转换完成后主机发送跳过ROM0xCC 读暂存器指令0xBE连续读9个字节取第1、2字节拼成16位温度数据根据分辨率移位得到实际温度值。写时序和读时序的差异在于总线拉低的时间。写0主机拉低总线60-120微秒写1主机先拉低1-15微秒然后释放总线让上拉电阻把电平拉高持续60-120微秒。读时序主机拉低总线1微秒后释放然后在15微秒内采样总线电平。采样太晚就会等到下一个读时隙数据就错了。一个很隐蔽的细节是在两个读时隙之间主机必须保证总线恢复高电平至少1微秒否则DS18B20的输出驱动会和上拉电阻打架读回来的数据全是0。3.3 温度数据解析与85度“魔数”的处理DS18B20返回的16位数据高字节在高位低位字节的低4位是小数部分中12位是整数部分。我的设置是12位分辨率所以温度值等于原始数据右移4位再乘以0.0625。代码逻辑int16_t raw (temp[1] 8) | temp[0]; float temperature raw * 0.0625f;如果读到的温度一直是85度基本可以断定是DS18B20没有完成初始化复位或者器件根本没有应答。85这个值其实是DS18B20内部寄存器上电默认值你读到的不是真实温度而是器件还没开始转换时的原始寄存器状态。排查思路我放在最后一节专门讲。4. OLED驱动SSD1306的显存机制与汉字显示4.1 先弄懂SSD1306内部是怎么“记”画面的SSD1306内部有一块128x64的GRAM本质上每个像素对应一个bit1代表点亮0代表熄灭。问题在于这个GRAM不是按“行-列”的常规思路排列的而是把64行分成了8页Page每页对应8行像素1列正好是1个字节。也就是说第0页覆盖第0到7行第1页覆盖第8到15行以此类推。向SSD1306写入数据时先要通过命令设置目标页地址和列地址然后连续发送字节每个字节的8个bit会垂直排列到当前页的当前列上。这就引出了汉字取模的关键规则一个字模的每一行数据实际上代表的是8行像素中某一行的横向点阵不是我们人眼从左往右读的“行”。我用的取模方式是“列行式、阴码”。列行式即先从左到右取16列每列取上下两个字节正好覆盖16x16点阵阴码是1表示点亮、0表示熄灭我直接把取到的字节数组拷贝进显存即可不用做任何反转。如果你用PCtoLCD2002取模设置里选“阴码 列行式 前低位在前”取出来的数组就能直接灌进SSD1306的GRAM。4.2 I2C控制字节与数据写入流程OLED在I2C总线上的写操作每次通信的第一个字节是控制字节。控制字节为0x00表示后面跟的是命令0x40表示后面跟的是数据。这个规则记住就行驱动库内部会反复用到。初始化SSD1306的序列比较固定核心几行oled_write_cmd(0xAE); // 关闭显示 oled_write_cmd(0xD5); // 设置时钟分频因子 oled_write_cmd(0x80); oled_write_cmd(0x8D); // 开启电荷泵 oled_write_cmd(0x14); oled_write_cmd(0xAF); // 开启显示注意有些模块带RES引脚上电后需要拉低再拉高复拉一下如果是不带RES的纯I2C四针模块靠0xAE和0xAF控制开关。还有热词里有人问“OLED屏连上电源就亮吗”答案是如果不发送0xAE关闭显示命令屏上电后驱动芯片默认就是开显示状态显示内容是初始化前的随机噪声这是正常现象不是坏了。4.3 中文字库与ASCII怎么共存OLED要显示汉字核心工作就两件准备好点阵数据字模准备好字模的索引表。我建了一个结构体数组每个元素包含汉字的内码用两个字节表示GBK编码和对应的字模指针typedef struct { uint16_t index; const uint8_t *font_data; } FontIndex;ASCII字符我用了两种规格8x16的用于显示时间数字12x12或16x16的用于标题栏汉字。8x16字符在取模时是逐行取模一行两个字节正好横向8像素汉字16x16则是上面说的列行式。显示函数内部先判断字符是ASCII还是汉字根据编码范围走不同的取模读取逻辑这样同一个显示接口就能统一处理英文、数字和中文。这里有一个容易翻车的地方字符串里同时出现中英文时字符宽度不一致逐字符刷新会让光标位置错乱。我的处理是先把整行要显示的内容用sprintf拼好再逐字符判断宽度并推进光标确保中英文混排不乱。4.4 局部刷新和全屏刷新的取舍SSD1306不读回显存只支持写入。如果你有一块完整的显存缓冲1KB的uint8_t数组每次要刷新时把整块缓冲推送到屏上就是“全屏刷新”。这种方式的优点是代码简单、不容易错位缺点是1KB数据走I2C在400kHz速率下也需要约20毫秒——如果每秒刷10次屏幕就会肉眼可见地闪烁。我的做法是缓冲区域刷新结合显存缓冲保留1KB全量数组但每次只把变化的那一小块区域打包发送。比如时间每秒变化只需要刷新右下角的时间区域温度每2秒变化只需要刷新左上角温度区域。这样既保证了画面稳定又减轻了I2C总线压力后续如果再挂别的I2C外设也不至于抢带宽。5. 时间从哪来RTC方案对比与校准经验5.1 三选一内部RTC、DS3231还是WiFi对时做带时间的显示项目时间来源通常有三种方案精度成本掉电保持适用场景MCU内部RTC中等受晶振和温漂影响最低需外部备份电池32.768kHz晶振对时间精度要求不高的室内设备DS3231高内置温补晶振年误差约1-2分钟中等自带电池座CR2032轻松撑一年桌面时钟、仪器仪表ESP32 SNTP极高误差取决于网络低复用WiFi断电后靠首次联网对时联网设备我最终选了DS3231核心原因是省心。STM32内部RTC的32.768kHz晶振起振问题是个老大难尤其是低成本无源晶振负载电容匹配不当的时候LSE振荡器经常不工作或者工作时快时慢排查起来特别折腾。DS3231内部集成了温补晶振和晶体一上电就稳完全不用管起振问题。5.2 I2C挂两个设备地址冲突和读写时序的仲裁OLED和DS3231都挂在I2C1上地址固定为0x68OLED是0x3C或0x3D没有冲突。但要注意的是I2C总线上的设备地址是7位读写时最低位表示方向所以读DS3231时发送的从机地址是0xD10x681 | 1写是0xD0。初学者经常在这里晕。DS3231的读写流程是先写入寄存器地址再连续读写数据。时间寄存器从0x00开始依次是秒、分、时、日、月、星期、年全部是BCD码。把读出来的BCD转成十进制uint8_t bcd_to_dec(uint8_t bcd) { return (bcd 4) * 10 (bcd 0x0F); }反过来写入也是先转成BCD。我封装了一个底层驱动上层只跟“年”“月”“日”“时”“分”“秒”这些变量打交道不用关心BCD还是I2C。5.3 时间校准小程序辅助还是手动按键DS3231出厂时内部时钟就已经在跑但默认时间和真实时间对不上必须校准。我的做法是先用USB转TTL串口连到STM32通过串口助手发送设定时间指令比如发送字符串“2025-02-15 20:30:00”MCU的串口中断解析后写入DS3231。这种方式的优点是校准过程可控缺点是必须在开发状态下进行。如果是成品设备更合理的方案是在外壳上留两个按键一个进入设置模式一个调整数值。但我不想在桌面上放一台按钮很多的设备所以只留了串口校准平时也不动它。6. 软件架构把温度、时间、显示三个“节奏”揉进一个主循环6.1 非阻塞主循环设计一个简单的调度器项目规模不大用不上RTOS但在主循环里裸奔容易出问题。我设计了一个非常轻量的时间片调度机制volatile uint32_t tick_ms; void SysTick_Handler(void) { tick_ms; } uint32_t get_tick(void) { return tick_ms; } // 主循环 while (1) { if (get_tick() - last_temp_read 2000) { last_temp_read get_tick(); request_temp_conversion(); // 启动转换不等待结果 } if (get_tick() - last_temp_result 1000) { last_temp_result get_tick(); temperature read_temp_result(); // 读取上次转换的结果 } if (get_tick() - last_display 1000) { last_display get_tick(); update_display(); // 刷新时间与温度显示区域 } }这样设计的好处是DS18B20的750毫秒转换时间完全被“吸收”了。每次我先发启动转换的命令然后继续干别的事等下一轮主循环再来的时候转换大概率已经完成了直接读结果即可。屏幕刷新也不会因为等待温度转换而被卡住。6.2 显示缓冲与I2C传输的层次划分我把代码分成了三层底层I2C读写函数直接操作HAL库的HAL_I2C_Mem_Write中间层OLED的显存缓冲区操作包括画点、画线、画字符、画汉字、清屏应用层把温度、时间、状态栏组合成具体的界面布局。应用层只关心“在哪里画什么”完全不用管I2C协议。这样如果将来换SPI接口的OLED只需要替换中间层的发送函数界面逻辑一行都不用动。6.3 内存开销与优化STM32F103C8T6只有20KB RAMSSD1306的1KB显存缓冲看着不多但加上字模数组、串口缓冲、堆栈开销还是得稍微规划一下。我的字模数组放在const区直接进Flash不占RAM全局变量尽量少用能局部就不全局堆栈大小在启动文件里调到4KB避免在深层函数调用时栈溢出。实测下来整个工程RAM占用大约8KB多一点Flash占用30KB左右对这块芯片来说非常宽裕。如果用的ESP32那内存就更不是问题了。7. 实测中的坑症状、排查链路与最终结论7.1 温度恒为85度不是没焊好就是没复位成功我在调试过程中至少遇到三次“温度直接读85度”的情况每次原因都不同整理成排查链路如下先排除硬件连接DS18B20的三个引脚有没有接反VDD和GND千万不能反DQ引脚的上拉电阻是不是4.7kΩ如果用的10kΩ在某些环境也可能不稳定用逻辑分析仪或示波器看复位时序主机拉低480微秒后总线上有没有出现DS18B20的应答低脉冲。如果没有应答说明器件没在总线上响应——检查供电电压是否在3.0-5.5V之间低于3.0V器件不工作如果应答正常但读出来还是85大概率是复位后的时序间隔有问题。DS18B20要求主机在复位后等待一段时间再发ROM命令常规做法是复位后延时240微秒确保器件从复位状态恢复过来最后检查温度转换是否真的执行了。发出0x44之后要等待转换完成12位分辨率时最长750毫秒。如果主机在转换没完成时就去读暂存器读到的还是上一次转换的旧值而旧值如果恰好是上电默认的0x0550即85.0度看起来就像“永远卡在85度”。7.2 OLED花屏、白屏、错位大多是初始化和缓存的问题花屏最常见的原因是I2C速率过高。STM32的I2C外设配置成400kHz标准快速模式但如果OLED模块的走线较长、或者用了杜邦线连接信号质量会变差导致数据传输出错。把I2C速率降到100kHz通常能解决一大半问题。白屏只亮不显示内容优先怀疑显存初始化没成功。SSD1306上电后要完整执行一遍初始化序列很多人漏掉了0x8D电荷泵命令屏就是白的。还有一种情况是I2C地址错误代码里写0x3C实际模块是0x3D数据根本没送到目标设备。用我前面说的I2C地址扫描函数一秒钟就能定位。错位、画面整体偏移往往和页地址、列地址设置有关。SSD1306的列地址是7位0-127但设置列地址的命令要分两次0x00-0x0F的低四位用0x00到0x0F直接发送高三位用0x10到0x17发送。如果驱动库把这部分搞错画面整体会往左或往右偏一段且所有内容都是乱的。7.3 汉字取模反了或者位置不对分清楚“列行式”和“行列式”汉字取模的坑太经典了。取模软件默认设置五花八门有逐行式、逐列式、列行式、行列式还有阴码阳码的区别。用错了一种显示出来的汉字就是镜像的、旋转的甚至完全看不清。最稳妥的办法是固定一套自己熟悉的取模设置写进项目注释里。我的固定配置是取模软件PCtoLCD2002点阵格式阴码取模走向列行式每行显示数16前导零自动补齐字节内低位在前。只要所有汉字和ASCII字符都按这套配置取模显示函数里统一处理基本不会出错。如果换了一台电脑、换了一个取模软件先拿一个“中”字验证设置对不对再批量取模。7.4 I2C总线上RTC读写偶发失败电气特性与通信错误处理DS3231和OLED共用I2C总线偶尔会出现读写超时或数据错误。一开始我以为是代码问题后来查了下拉电阻发现开发板上I2C已有上拉电阻但OLED模块上可能还自带上拉相当于两个上拉并联总阻值变小。阻值太小会让总线拉高的驱动能力不足导致信号上升沿变缓高频通信下出错概率大增。处理办法是总线上所有设备模块如果有板上拉就不要在MCU侧再重复外接上拉或者统一算好并联后的等效阻值一般在2.2kΩ到4.7kΩ之间都是可接受的。另外I2C通信代码里加一个简单的错误重试机制读RTC超时后延时1毫秒重试一次实际用下来很少重试成功但多这层保险心里踏实。8. 还能怎么玩这个项目的扩展余地硬件上原封不动软件层面可以做不少有趣的事情。最简单的是把温度曲线记录下来MCU内部Flash足够存几百组数据每隔十分钟存一次温度和对应的时间戳OLED上加一个简易波形页面就能看最近几小时的温度变化趋势。如果换ESP32做主控显示内容可以瞬间丰富起来——从网上拉天气数据同时显示室内温度和城市天气。DS3231都能省掉改成SNTP对时整机成本还会降一些。再进一步可以把DS18B20换成防水探头版本放到冰箱、鱼缸、温室里做远程温度监控OLED显示本机温度另一个小无线模块把数据发到手机。这个项目的核心价值就在于把单总线传感和I2C显示这两套最基础的通信路径吃透后面无论接什么传感器、换什么屏都只是换个驱动函数的事。我做这个项目最大的体会是不要一上来就想做“大而全”的系统先让一个温度准确显示在屏幕上再让时间稳定走动然后再考虑怎么让它们互不干扰地共处。每一步都验证过了后续的扩展就是搭积木了。本文还有配套的精品资源点击获取