简介:面向51单片机学习者的时钟温度计综合实例,基于DS1302与DS18B20,以LCD12864显示日期、时间与温度。资源包内含完整C语言工程、Proteus仿真电路及可烧录Hex文件,并配有实验指导文档,适合课程设计、毕业设计或自学练手。压缩包共23个文件,以.c/.h源码、.uv2/opt工程配置、.hex烧录文件、.bak备份及.doc实验说明等类型为主,整体仅267KB,轻量易下载。目前已有383人学习浏览。源码采用模块化编写,便于理解DS1302时钟时序和18B20单总线协议;仿真工程可直接运行,观察LCD12864显示效果;通过实验文档可对照调试步骤,快速掌握从仿真到实物的移植方法。尤其适合需要同时掌握时钟芯片和温度传感器驱动,并具备Proteus仿真调试能力的读者,也是单片机入门后的进阶实战素材。
1. 这套“双传感器 + 大屏”组合,为什么值得拆开看
DS1302 和 18B20 是 51 单片机入门到进阶之间最常被同时提起的两个芯片,一个是 SPI 类三线实时时钟,一个是单总线数字温度传感器,通信协议完全不同。这个 206-12864+DS1302+18B20 项目把它们放在同一块 12864 液晶上显示,本质上是一个「两种时序协议 + 一种大屏驱动」的综合训练。比起流水灯和数码管,它能让你同时练到移位时钟、单总线时序、指令型液晶的初始化流程,以及如何在查询结构下分配刷新节拍。适合正在做课程设计、或者想把手里的开发板从“能亮”推进到“有完整功能”的人。这个资源里包含 Keil 工程、Proteus 仿真文件和实验指导文档,硬件成本几乎为零,但信息量并不低。值得说明的是,12864 在这里不只是显示,它的 DDRAM 地址管理和并行时序,反而是整个项目里最容易写错的部分。下面按驱动层、显示层、应用层三个顺序拆。
2. 时序驱动层:DS1302 的三线协议和 18B20 的单总线协议
这两个芯片放在一起,最大的价值不是“都能用到”,而是它们的时序模型可以作为以后读任何 datasheet 的参考模板。DS1302 使用 SCLK、I/O、RST 三线,有命令字节、地址字节、数据字节的概念;18B20 完全相反,只有一根数据线,靠严格的时间窗口区分 0 和 1。先把这两个的驱动架构建起来,后续换成 DS3231 或 DHT11 都只是换寄存器映射,不用改架构。
2.1 DS1302 的寄存器映射和读写时序
DS1302 内部有一组时钟寄存器,地址从 0x80 开始,按“秒、分、时、日、月、星期、年”排列。注意它存的不是二进制,而是 BCD 码,比如 0x23 代表 23 秒,而不是十进制 23。这个细节直接影响读回来之后要不要转换、写进去之前要不要处理。控制寄存器是 0x8E,它的 bit7 是写保护位,上电默认是 1,必须先写 0 才能改时间。
写时序的经典三步是先拉低 RST,再拉高 RST 使芯片进入通信状态,然后逐个字节写入,每 bit 在 SCLK 上升沿被采样。读操作多一步:命令字节的 bit0 置 1,发送完命令后,数据在 SCLK 下降沿输出,主机必须在下降沿之后、下一个上升沿之前把数据读走。主要延时来自 SCLK 高低电平的保持时间,一般用_nop_()或者一个短延时函数就能覆盖。下面给出常用的一个字节读写函数:
unsigned char DS1302_ReadByte(void) { unsigned char i, dat = 0; for (i = 0; i < 8; i++) { dat >>= 1; // 先右移,让低位对齐 if (DS1302_IO) dat |= 0x80; // 下降沿后读数据线 DS1302_SCLK = 0; _nop_(); DS1302_SCLK = 1; // 上升沿,芯片准备输出下一位 _nop_(); } return dat; }这段代码的关键在于dat >>= 1和dat |= 0x80的顺序。DS1302 是低位先出,所以每次移位后把当前 IO 电平放到最高位,循环 8 次后正好还原成完整字节。如果反过来先读再移位,整个字节的位序就会错乱。SCLK 的两个_nop_()是给芯片留出数据建立时间,在 12MHz 晶振下足够,如果换成 24MHz 最好加一个空循环。
读时间的整体逻辑是先向 0x81 发命令(秒寄存器地址 + 读标志),连续读 7 个字节代表秒到年;写时间时先向 0x8E 写 0,再逐个写寄存器,最后把写保护置 1。这个项目里 DS1302.c 基本就是这个结构。
2.2 从文件清单反推驱动结构
解压后能看到 DS1302.c、DS1302.h、DS1302.OBJ、DS1302.LST 等文件。.OBJ和.LST是 Keil 编译过程产物,.PLG是编译日志。这些文件存在说明资源里带的是完整的可编译工程,不是只贴了源码片段。由于没有单独列出 DS18B20.c,温度部分很可能写在主文件里,或者以头文件形式内联。这意味着移植时要把温度转换代码从主循环里剥出来,单独整理成模块。
这个工程的模块边界大致是:DS1302.c 只负责时间读写,12864 驱动负责显示,主文件做数据拼接。这样分的好处是,后面如果要改成 DS3231,只需要把 DS1302.c 换成 DS3231.c,接口保持统一即可。文件清单里还出现了 LCD1602.h,一个常见的坑是工程里既有 1602 的驱动头文件又有 12864 的驱动,实际用的是 12864,但 1602 头文件没有被移除。如果在 Keil 里同时编译,会出现重复定义引脚的问题,编译工程时要检查实际被包含的头文件。
2.3 18B20 的初始化时序和温度读取
18B20 的初始化是所有操作的前置条件。主机拉低总线至少 480us,然后释放,等待 18B20 拉低总线 60~240us,表示存在脉冲。检测到低电平才算复位成功。这个时序必须严格,因为单总线协议没有时钟线,唯一的时间参考就是主机自己。延时偏短,初始化失败;偏长,超过 960us 会让芯片进入别的状态。
温度读取的流程是:复位 → 发 0xCC(跳过 ROM) → 发 0x44(启动温度转换) → 等待转换完成 → 复位 → 发 0xCC → 发 0xBE(读暂存器) → 连续读两个字节。低字节是温度低 8 位,高字节是符号位和高 3 位。12 位分辨率下,LSB 代表 0.0625℃,读回两个字节后要先拼成 16 位整数,再判断正负。
// 假设 temp_l 和 temp_h 分别是从 18B20 读到的低字节和高字节 int temp_raw = (temp_h << 8) | temp_l; if (temp_raw & 0x8000) { // 负温度:取反加一,得到绝对值 temp_raw = ~temp_raw + 1; negative = 1; } float temp = temp_raw * 0.0625; // 乘以分辨率系数temp_raw * 0.0625这一步可以用整数运算替代,因为 0.0625 正好是 1/16。对 51 这种没有 FPU 的芯片,用temp_raw >> 4得到整数部分,(temp_raw & 0x0F) * 6 / 10可以得到一位小数,精度稍低但计算开销小很多。Proteus 仿真中两者都能正常工作,差异体现在代码体积上。
3. 12864 液晶的驱动与显示地址规划
12864 是这个系统的输出窗口。它的驱动核心是指令集操作:初始化、清屏、设置地址、写数据。这个项目里的 12864 大概率是带中文字库的 ST7920 控制器,命令字 0x30 切换基本指令集,0x80 + 地址位用于定位。与 1602 相比,12864 的难点在于 DDRAM 地址不连续,换行要计算偏移量,四行地址分别是 0x80、0x90、0x88、0x98。
3.1 初始化序列和常见失败点
ST7920 的初始化时序比 1602 更挑剔。上电后要给一个较长的等待,让内部控制器完成复位,然后依次发送 0x30、0x30、0x30 三次基本指令,再发送 0x0C(显示开、光标关)、0x01(清屏)。清屏指令之后,模块需要几毫秒处理,不能立即写数据。
void LCD12864_Init(void) { delay_ms(50); // 上电等待,ST7920 内部复位 WriteCmd(0x30); delay_ms(5); // 8 位模式,基本指令集 WriteCmd(0x30); delay_ms(1); WriteCmd(0x30); delay_ms(1); WriteCmd(0x0C); delay_ms(1); // 显示开,光标关 WriteCmd(0x01); delay_ms(5); // 清屏,需较长时间 }失败最多的就是第一次上电后马上发指令。ST7920 的复位时间可能长达 40ms,在 Proteus 里延时不足偶尔能跑,但实际硬件上经常表现为花屏或完全不显示。另一个常见坑是 0x30 之后直接发送 0x0C 而不加延时,模块可能还在处理前一条指令。这个初始化序列建议作为独立函数保留,不要在主循环里重复调用。
3.2 字符显示地址的偏移计算
ST7920 的 DDRAM 地址和屏幕坐标不是线性的。第 1 行从 0x80 开始,第 2 行从 0x90 开始,第 3 行从 0x88 开始,第 4 行从 0x98 开始。第 2 行和第 3 行的地址错开 8 个字节,这是最常见的显示错位原因。如果直接用“行号 × 16 + 列号”计算地址,第 3 行就会跑到第 1 行中间去。
建议用查表替代计算:
code unsigned char LineAddr[4] = {0x80, 0x90, 0x88, 0x98}; void LCD12864_DisplayString(unsigned char line, unsigned char col, char *str) { WriteCmd(LineAddr[line] + col); // 定位 while (*str) { WriteData(*str++); } }LineAddr数组直接映射硬件地址,省去每次调用都做地址换算。col超过 7 时要注意,12864 每行只显示 8 个汉字(16×16 点阵),超出部分会绕到下一行。
4. 主循环的刷新策略:时间、温度、显示三者如何共存
主程序的核心不是“分别写完”就结束,而是要处理三个外设的刷新频率差异。DS1302 走时自己会持续更新,但寄存器值不是自动推到屏幕上的;18B20 每次温度转换需要最多 750ms;12864 液晶的写入速度又远慢于单片机执行一条指令的速度。如果每轮循环都做三件事,温度转换还没完成就再读,拿到的会是上一次的旧值,而液晶频繁刷新会闪烁。
4.1 查询式架构和节拍分配
这个工程没有用中断,也没有实时操作系统,所以需要一个刷新节拍。常见做法是主循环里顺序执行:读 DS1302 → 格式化字符串 → 刷新时间区域 → 周期性读 18B20 → 刷新温度区域。温度读取放一个标志位,每 10 次循环才执行一次,这样既保证显示变化指挥流畅,又不会让 18B20 的转换等待拖慢整个循环。
while (1) { // 每次循环都刷新时间 DS1302_GetTime(&time); // 读取当前时间到结构体 sprintf(buf, "%02d-%02d-%02d", time.year, time.month, time.date); LCD12864_DisplayString(0, 0, buf); sprintf(buf, "%02d:%02d:%02d", time.hour, time.min, time.sec); LCD12864_DisplayString(1, 0, buf); // 每 N 次循环刷新一次温度 if (++tick >= 10) { tick = 0; if (DS18B20_ReadTemp(&temp) == 0) { sprintf(buf, "Temp: %d.%d C", temp.int_part, temp.dec_part); LCD12864_DisplayString(2, 0, buf); } } }DS18B20_ReadTemp内部包含启动转换和读暂存器两步,如果转换未完直接读,会拿到上一次的结果。所以要么像上面这样每 10 次循环读一次,要么在函数内部加 750ms 延时。前者适合仿真和大多数实时性要求不高的场景,后者会让按键响应变慢。时间格式用%02d补齐,确保个位数前面有 0。
4.2 日期与星期的联动处理
DS1302 的星期寄存器单独存一个 1~7 的数值,不会根据日期自动计算。对于课程设计来说,手动设置即可;但如果想让它自动更新,可以在写入日期后通过蔡勒公式推算。蔡勒公式在 51 上计算量不大,唯一需要注意的是公式中把 1、2 月视为上一年的 13、14 月,否则结果会出错。不过,除非展示要求,否则一般直接读取 DS1302 的星期寄存器更稳妥。
4.3 设置模式的实现思路
如果要做按键校时,注意一个边界:按键扫描放在温度转换的 750ms 等待期间时,响应会迟钝。正确做法是把温度转换改成非阻塞式——启动转换后不等待,直接返回;主循环继续处理按键和显示,等时间差不多到了再读取结果。代码上就是拆成两个函数:一个发启动转换命令,一个读转换结果,中间用时间戳判断是否超过 750ms。下面是一个非阻塞读取的骨架:
unsigned char temp_ready = 0; unsigned int temp_timer = 0; void Temp_StartConvert(void) { DS18B20_Reset(); DS18B20_WriteByte(0xCC); DS18B20_WriteByte(0x44); temp_timer = 0; temp_ready = 0; } void Temp_CheckResult(void) { if (++temp_timer > 750) { // 大约 750ms 后尝试读取 if (DS18B20_ReadTemp(&temp) == 0) { // 显示温度 } temp_ready = 1; Temp_StartConvert(); // 立即启动下一次转换 } }这个做法在真实硬件上比延时等待稳定得多。延时等待期间如果用户按下校时按键,整个循环被拖住,轻则按键无响应,重则产生重复触发的误判。非阻塞方案只在主循环中多判断一次计数器,收益是按键响应和温度刷新互不干扰。
5. Proteus 仿真中的时序边界与硬件差异
Proteus 仿真和真实硬件有一个关键差异:仿真器里的单片机执行指令是一个周期一个周期模拟的,_nop_()的延时效果和真实芯片几乎一致,但 Proteus 对电气特性的模拟是理想化的——DS1302 的 SCLK 保持时间、18B20 的上拉电阻波形,在仿真里可能怎么调都对,板子上却死活不行。所以仿真通过只是第一步,以下这些差异要提前知道。
5.1 18B20 的上拉电阻必须显式画出来
Proteus 里直接拉一个 DS18B20 元件到原理图,默认引脚有内部上拉模型,程序基本能跑通。真实硬件上,单片机的 I/O 口虽然有弱上拉(P0 口除外),但驱动能力有限,18B20 的数据线必须外接一个 4.7kΩ 上拉电阻到 VCC。没有这个电阻,单总线的时序波形会失真,通信表现为时好时坏——初始化偶尔成功,偶尔失败。在 Proteus 里养成画上拉电阻的习惯,可以避免带着这个问题去做实物。
5.2 DS1302 晶振参数不要照抄默认值
DS1302 需要外接 32.768kHz 晶振,Proteus 里通常用CRYSTAL元件替代。很多教程的仿真文件里直接放置了一个默认频率的晶振,但 DS1302 对晶振的负载电容敏感,真实硬件上需要并联两个 6~10pF 电容到地。Proteus 里不加电容能跑通是因为仿真模型不计算负载电容的起振条件。更关键的是,如果仿真文件中晶振是 12MHz 的(有些人从单片机原理图直接复制过来),DS1302 的时间走速会快几百倍,1 秒钟当 1 分钟过。遇到走时异常快的,先检查晶振频率是不是 32.768kHz。
5.3 Keil 编译优化级别对时序的影响
很多人把程序从 Proteus 搬到实物后发现 18B20 读不到温度,第一反应是检查代码逻辑,其实更常见的原因是 Keil 的优化级别改变了延时函数的执行时间。工程默认可能开了 Level 0(不优化),但为了减小代码体积,有人会改成 Level 8,这会让_nop_()被直接吞掉,18B20 的 15us 读时隙变成 2us,必然失败。建议涉及 18B20、DS1302 时序的工程固定使用 Level 0,延时函数内部加volatile修饰变量防止被编译器优化。
void delay_us(unsigned int us) { volatile unsigned int i; // 防止被优化掉 for (i = 0; i < us; i++) { _nop_(); } }volatile告诉编译器这个变量每次都要从内存读取,不能优化为常量。具体一个_nop_()在 12MHz 下约 1us,24MHz 下约 0.5us,所以delay_us(15)在不同晶振下实际延时不同。仿真通过后做实物,第一件事就是确认晶振频率和代码里的延时假设一致。
5.4 用示波器或逻辑分析仪验证时序
Proteus 自带虚拟示波器,可以在 DS1302 的 SCLK 引脚和 18B20 的数据线上挂探针。初始化代码跑完一轮后,暂停仿真,看波形是否符合 datasheet:DS1302 的 SCLK 应该是一串规则的方波,18B20 在复位后应该有明显的低电平存在脉冲。如果示波器上看到 18B20 的波形持续低电平,说明芯片没有应答——通常是上拉电阻没接,或者时序中拉低时间超过了 960us。这一招在排查未知 bug 时特别好用,因为单总线协议里“波形能看”比“代码能读”更直接。
5.5 HEX 文件路径和仿真加载
Proteus 中的单片机元件需要手动加载 HEX 文件,路径在“Edit Component”对话框里设置。当工程在 Keil 中重新编译后,Proteus 不会自动刷新,需要重新选择或关闭仿真后再次打开才能使用最新的 HEX。最常见的尴尬是改完代码跑仿真,发现行为没变——因为 Proteus 加载的还是旧文件。将 HEX 输出路径设置为 Keil 工程目录下的默认路径Objects或Listings,可以减少这类低级失误。
本文还有配套的精品资源,点击获取