STC89C52 多功能时钟:LCD12864 中文显示 + DS1302 时钟 + DS18B20 温度 + 4x4 键盘闹钟

一个基于 STC89C52(11.0592MHz)的多功能时钟项目:LCD12864 中文界面显示时间/日期/星期/温度/闹钟,DS1302 提供实时时钟,DS18B20 测温,4x4 矩阵键盘完成全部设置操作,带闹钟蜂鸣提醒。全文代码、接线、踩坑记录均来自实际烧录运行的项目。

一、功能简介

  • 时间显示:时:分:秒,8x 放大数字(16x16 像素)
  • 日期与星期:年月日 + 中文星期(一~日),星期由日期自动推算
  • 温度显示:DS18B20 实时测温(约 60 秒刷新一次),异常时显示"温度错误",未接传感器显示"温度无设备"
  • 闹钟功能:可设置时/分,到点蜂鸣响 30 秒自动停止,可开关("闹钟开/闹钟关")
  • 全中文界面:年/月/日/时/分/秒/闹钟/温度等均为 16x16 中文点阵显示
  • 人性化编辑:A 键退格、C 键下一项、D 键确定、\* 键取消、# 键进入闹钟设置,输入自动跳格,当前字段带下划线高亮
  • 局部刷新机制:四个显示区域各自独立刷新,每秒只刷时间行,不整屏重绘(详见第六节)

二、硬件清单

器件型号/规格数量用途
单片机STC89C52(11.0592MHz 晶振)1主控
液晶屏LCD12864(ST7565 控制器,128x64)1显示
时钟芯片DS1302 + 3.6V 纽扣电池(可选)1实时时钟
温度传感器DS18B20(需 4.7k 上拉电阻)1测温
键盘4x4 矩阵键盘1输入
蜂鸣器5V 有源蜂鸣器1闹钟提醒

三、接线

接线表

外设引脚接到单片机
LCD12864D0-D7P0.0-P0.7
LCD12864RSP2.6
LCD12864WRP2.7
LCD12864CS/RST固定电平(按模块要求)
DS1302CLKP3.6
DS1302IOP3.4
DS1302RSTP3.5
DS1302VCC2 / GND5V / GND
DS18B20DQP3.7(4.7k 上拉到 VCC)
DS18B20VCC / GND5V / GND
蜂鸣器- 极P2.5(低电平响)
蜂鸣器+ 极5V
4x4 键盘行 0-3P1.0-P1.3
4x4 键盘列 0-3P1.4-P1.7

接线示意图

接线示意图

/* 引脚定义(main.c 开头) */
#define LCD_DATAPORT P0
sbit LCD_RS = P2^6;
sbit LCD_WR = P2^7;
sbit DS18B20_PORT = P3^7;
sbit DS1302_RST = P3^5;
sbit DS1302_CLK = P3^6;
sbit DS1302_IO  = P3^4;
sbit BUZZER     = P2^5;

四、界面与按键操作

主界面共 4 行(每行 16 像素高,对应 2 个页):

  • 第 1 行(页 0-1):时间 __CODE(8x 放大数字)
  • 第 2 行(页 2-3):日期 __CODE + __CODE
  • 第 3 行(页 4-5):温度 __CODE
  • 第 4 行(页 6-7):闹钟状态 __CODE

键盘布局(4x4):

[1] [2] [3] [A]
[4] [5] [6] [B]
[7] [8] [9] [C]
[*] [0] [#] [D]

按键功能:

按键主界面编辑界面
1-9、0进入时间设置输入数字(强制 2 位,输满自动跳下一项)
A进入时间设置退格:删最后一位;已删空则回退到上一项
B无(预留)
C进入时间设置下一项;最后一项则保存退出
D进入时间设置确定并保存退出
\*闹钟开关切换取消,不保存退出
#进入闹钟设置

编辑采用强制两位输入 + 自动跳格:例如设置分钟,必须输入两个数字,输满自动跳到下一字段;输错按 A 退格重输,无需长按。年份输入两位(如 __CODE 表示 2025 年),当前编辑字段下方有 32 像素宽的下划线高亮,不会输错位置。

五、核心代码

1. LCD12864 驱动与中文 16x16 绘制

ST7565 控制器按页(8 行一页)寻址,128x64 共 8 页。写命令:__CODE 选页、__CODE 和 __CODE 选列。中文点阵是 16x16,一个字符占上下两页:

static void lcd_draw_ch16(u8 x, u8 page, u8 idx)
{
    u8 i;
    lcd_write_cmd(0xB0 | page);
    lcd_write_cmd(0x10 | (x >> 4));
    lcd_write_cmd(0x00 | (x & 0x0F));
    for (i = 0; i < 16; i++)
        lcd_write_data(g_ch16[idx][i]);
    lcd_write_cmd(0xB0 | (page + 1));
    lcd_write_cmd(0x10 | (x >> 4));
    lcd_write_cmd(0x00 | (x & 0x0F));
    for (i = 0; i < 16; i++)
        lcd_write_data(g_ch16[idx][16 + i]);
}

__CODE 是 25 个常用字的 16x16 点阵(列向排列,前 16 字节为上半页、后 16 字节为下半页),"年/月/日/时/分/秒/闹/钟/温/度/开/关/星/期/一~六/无/设/备/错/误"。字库来源与生成过程见第七节,全是血泪。

2. DS1302 读写(BCD 码)

以 0x8E 寄存器控制写保护(0x00 关、0x80 开),时间寄存器按"秒 分 时 日 月 星期 年"顺序连续读:

static void ds1302_write_byte(u8 addr, u8 dat)
{
    u8 i;
    DS1302_RST = 0; _nop_();
    DS1302_CLK = 0; _nop_();
    DS1302_RST = 1; _nop_();
    for (i = 0; i < 8; i++) {          /* 命令字节,LSB 先行 */
        DS1302_IO = addr & 0x01;
        addr >>= 1;
        DS1302_CLK = 1; _nop_();
        DS1302_CLK = 0; _nop_();
    }
    for (i = 0; i < 8; i++) {          /* 数据字节 */
        DS1302_IO = dat & 0x01;
        dat >>= 1;
        DS1302_CLK = 1; _nop_();
        DS1302_CLK = 0; _nop_();
    }
    DS1302_RST = 0; _nop_();
}

3. DS18B20 时序

单总线协议,严格按时序拉低/采样。复位时检测存在脉冲,异常状态置标志,显示层据此显示"温度错误/温度无设备":

static u8 ds18b20_reset(void)
{
    u8 t;
    DS18B20_PORT = 0;
    { u16 i = 300; while (i--); }      /* 拉低 >480us */
    DS18B20_PORT = 1;
    t = 0;
    while (DS18B20_PORT && t < 500) { _nop_(); t++; }
    if (t >= 500) return 1;            /* 无器件应答 */
    t = 0;
    while (!DS18B20_PORT && t < 240) { _nop_(); t++; }
    if (t >= 240) return 1;
    return 0;
}

注意:温度转换一次要 750ms 左右(12 位精度),本项目把转换放在每秒的 tick 里异步推进:发转换命令后置 __CODE,下一次转换周期再读结果,避免主循环被长时间阻塞(这也是刷新策略的一部分,见第六节)。

4. 键盘扫描与去抖

行扫描法:逐行拉低,读列值判断按键;软件去抖采用"连续 15 次相同才确认 + 释放计数":

static void key_scan(void)
{
    u8 row, col, val;
    u8 key = KEY_NONE;

    for (row = 0; row < 4; row++) {
        P1 = ~(1 << row);              /* 逐行拉低 */
        val = P1 & 0xF0;
        if (val != 0xF0) {
            for (col = 0; col < 4; col++) {
                if (!(val & (0x10 << col))) {
                    key = g_key_map[row][col];
                    break;
                }
            }
            break;
        }
    }
    /* 去抖:连续 DEBOUNCE 次相同才输出,松开后同样计数 */
    ...
}

5. 编辑状态机(时间/闹钟共用)

时间设置 6 个字段(年月日时分秒)、闹钟设置 2 个字段(时分),共用一套逻辑,通过指针切换。强制 2 位输入、超范围钳位、退格/跳格:

static void edit_key(u8 key)
{
    u8 *edit;
    u8 code *max;
    u8 n;
    if (s_alarm_setting) {             /* 闹钟:2 个字段 */
        edit = s_alarm_edit;  max = s_alarm_field_max;  n = 2;
    } else {                           /* 时间:6 个字段 */
        edit = s_edit;  max = s_field_max;  n = 6;
    }
    if (key <= 9) {                    /* 数字输入 */
        if (s_input_len < 2) {
            s_input = (u8)(s_input * 10 + key);
            s_input_len++;
            if (s_input > max[s_field]) s_input = max[s_field];
            edit[s_field] = s_input;
        }
        if (s_input_len == 2) {        /* 输满自动跳下一项 */
            s_input = 0;  s_input_len = 0;
            s_field++;
            if (s_field >= n) {
                if (s_alarm_setting) alarm_setting_save();
                else exit_setting_save();
            } else {
                g_dirty = 1;
            }
        } else {
            g_dirty = 1;
        }
        return;
    }
    if (key == KEY_BS) {               /* A:退格 */
        if (s_input_len == 2) {
            s_input /= 10;  s_input_len = 1;
            edit[s_field] = s_input;  g_dirty = 1;
        } else if (s_input_len == 1) {
            s_input = 0;  s_input_len = 0;
            edit[s_field] = 0;  g_dirty = 1;
        } else if (s_field > 0) {      /* 已删空则回退上一项 */
            s_field--;  s_input = 0;  s_input_len = 0;  g_dirty = 1;
        }
        return;
    }
    if (key == KEY_NEXT) {             /* C:下一项 */
        s_input = 0;  s_input_len = 0;
        s_field++;
        if (s_field >= n) {
            if (s_alarm_setting) alarm_setting_save();
            else exit_setting_save();
        } else {
            g_dirty = 1;
        }
        return;
    }
    if (key == KEY_OK) {               /* D:保存退出 */
        if (s_alarm_setting) alarm_setting_save();
        else exit_setting_save();
    }
}

六、LCD 刷新策略:哪些要刷新、哪些不要刷新

128x64 屏幕按页寻址,共 8 页(页 0-7),每页 8 行像素。四个显示区域各占 2 页:时间(0-1)、日期星期(2-3)、温度(4-5)、闹钟(6-7)。

初版实现最偷懒的做法是"每秒全屏重绘"——直接把整屏 8 页全清掉再全部重画。实际使用中会发现两个问题:

  1. 闪烁:每秒整屏清除+重绘,屏幕肉眼可见地闪;
  2. 卡顿:DS18B20 转换期间(约 750ms 阻塞)和全屏重绘叠加,刷新周期紊乱,时间显示都不连贯了。

所以改为分区域独立刷新:每个区域一个脏标志(dirty flag),谁变了才清谁、画谁,其余区域保持原样不动。

各区域的刷新时机

区域所在页刷新时机触发条件
时间 HH:MM:SS页 0-1每秒__CODE,由 clock_tick 每秒置位
日期 + 星期页 2-3每整点(跨天检测)__CODE,clock_tick 每 3600 秒置位
温度页 4-5每 60 秒__CODE,DS18B20 转换周期结束置位
闹钟状态行页 6-7仅状态变化时__CODE,开关切换/闹钟设置保存时置位
编辑界面(时间/闹钟设置)全屏仅进入/退出/字段切换直接清全屏重绘

关键设计:日期、温度、闹钟行都不要每秒刷

  • 日期星期只在整点检测一次跨天(__CODE),一天最多刷 24 次,平时完全不动——反正内容没变,刷了也是白刷,还引入闪烁;
  • 温度 60 秒才变一次,而且 DS18B20 转换慢,没必要每秒去读;
  • 闹钟行只有用户操作时才变,平时就俩字加时间,动它干嘛。

调度核心:clock_tick

static void clock_tick(void)
{
    if (s_setting) return;             /* 设置界面暂停自动刷新 */
    s_tick_ms++;
    if (s_use_sw_clk && (s_tick_ms % 1000) == 0) {
        sw_clock_tick();               /* 软件时钟后备计时 */
    }
    if (s_tick_ms < 1000) return;
    s_tick_ms = 0;
    if (!s_use_sw_clk)
        ds1302_read_time();            /* 每秒读一次 RTC */
    alarm_check();
    if (s_alarm_setting) return;
    s_time_dirty = 1;                  /* 只标记时间行要刷 */
    g_dirty = 1;
    s_sec_cnt++;
    if (s_sec_cnt % 60 == 0) {         /* 每 60 秒处理温度 */
        if (s_temp_converting) {
            if (ds18b20_read_scratchpad())
                s_temp_converting = 0; /* 上一次转换已就绪,读结果 */
            s_temp_dirty = 1;          /* 温度行要刷 */
        } else {
            ds18b20_start_conv();      /* 发转换命令,不阻塞等待 */
            s_temp_converting = 1;
        }
    }
    if (s_sec_cnt % 3600 == 0) {       /* 每整点检查跨天 */
        s_date_dirty = 1;              /* 日期行要刷 */
    }
    if (s_sec_cnt >= 3600) s_sec_cnt = 0;
}

渲染核心:只清脏区域

static void clock_render(void)
{
    ...
    if (s_time_dirty) {                /* 时间:只清页 0-1 重绘 */
        s_time_dirty = 0;
        lcd_clear_area(0, 1);
        lcd_str2x(0, 0, "HH:MM:SS");   /* 实际为拼好的数字串 */
    }
    if (s_date_dirty) {                /* 日期:只清页 2-3 */
        s_date_dirty = 0;
        lcd_clear_area(2, 3);
        lcd_str(0, 2, "20YY-MM-DD");   /* + 中文星期 */
    }
    if (s_temp_dirty) {                /* 温度:只清页 4-5 */
        s_temp_dirty = 0;
        lcd_clear_area(4, 5);
        ...
    }
    if (s_alarm_dirty) {               /* 闹钟行:只清页 6-7 */
        s_alarm_dirty = 0;
        lcd_clear_area(6, 7);
        ...
    }
}

__CODE 只向指定页写 __CODE(清成空白),不清的区域一点不动,屏幕就不会闪。

编辑界面的刷新

编辑状态和主界面不同:内容要即时反馈(数字变化、下划线位置变化),所以编辑界面全屏重绘(__CODE 后重画标签、数字、下划线),此时自动计时刷新暂停(__CODE 里 __CODE),避免编辑时背景时间还在跳,干扰视野。下划线是单独画的 32 像素宽实心条(页 4 或对应页写 0xFF),字段切到哪里就擦掉旧的重画新的,这就是"要刷新"的部分。

七、字体开发全过程:三个字体 Bug 详解

ST7565 是无字库屏——屏幕上只有像素点,没有任何字符 ROM,所有字(包括数字、字母、中文)都得自己提供点阵数据。项目里 ASCII 用自带的 8x8 点阵(__CODE),中文 16x16 则需要专门的字库。这一节记录中文显示踩过的全部坑,按时间线还原。

阶段一:TrueType 渲染方案(失败)

最初打算用 Windows 自带的 SimSun(宋体)把每个汉字渲染成 16x16 点阵,脚本把字形像素转成数组生成 __CODE。

Bug 1:字形边缘被截断

16x16 的画布刚好等于一个汉字的点阵尺寸,但 SimSun 渲染出来的字形(尤其带撇捺、笔画出头的字)会超出画布边界,导致字的边缘被一刀切掉。典型受害者:

  • "错""误""备"这类笔画多、结构复杂的字,右下角/左下角的笔画出界,看起来像被啃了一口;
  • "年"字的撇、竖钩正好顶到边界,渲染结果笔画不全。

尝试加大渲染画布再缩放也无济于事(缩放后仍有细节丢失),而且 Windows 渲染器的边缘抗锯齿会让点阵带灰阶,转二值化后笔画粗细不均,显示效果很"脏"。这条路最终放弃。

阶段二:位序颠倒 Bug(上下翻转)

按常规思维,字模字节应该是 bit7 在顶部、bit0 在底部(和普通汉字点阵库一致)。第一版字模按这个位序生成,烧进去一看——所有中文上下颠倒

排查过程:

  1. 首先怀疑字库生成错,但用 Windows 画图把点阵按同样规则画出来,字形方向是正确的,说明生成端没错;
  2. 再看屏幕控制器:本屏初始化序列里有 __CODE(列反向扫描),这是关键线索——屏幕的页内字节顺序和常见屏是反的;
  3. 翻项目里一直在用的英文 8x8 字库代码找到铁证:
/* 8x8 英文字符绘制:j 从 0 到 7,读的是 [7 - j] 行! */
if (g_font8x8[idx][7 - j] & (0x80 >> i))

英文显示一直是正常的,而它反着读字模行,说明这块屏的页内字节是 bit0 在顶部、bit7 在底部。我们之前按 bit7=顶部生成字模,等于把每个字上下镜像了。

修复:字模生成时把位序反过来——列向存储时,字节的 bit0 对应字形第 0 行。改完方向正常,中文不再倒立。

阶段三:换标准 HZK16 点阵(最终方案)

SimSun 方案截断问题无法根治,决定换标准 HZK16 字库(UCDOS 的点阵字库,印刷级质量,笔画规整),每个字 32 字节,直接提取,不经过任何渲染。

Bug 3:整体错位(偏移公式坑)

HZK16 按 GB2312 区位码定位,常规认知是"汉字从 16 区开始",网上流传的偏移公式:

offset = ((区-16) * 94 + (位-1)) * 32

按这个公式提取,结果是所有字整体错位:"一"不是一条横线,"二"直接全空,"三"只剩两条横——隔几个字串位。典型症状就是:某个字看着"像"要显示的字又不像,或者是空白、花屏。

这次没急着改,先写了个诊断脚本,把"一""二""三""年"这几个笔画特征极其明显的字用不同偏移公式都提取一遍,打印成 __CODE 组成的点阵预览。结果一目了然:

  • 公式 A(从 16 区开始):一 不是横线、二 全空 —— 错;
  • 公式 B(从 1 区开始):一 是中部一条横线,二 是两条横,三 是三条横,年 是撇+横+竖+底横 —— 全对。

这个 GitHub 下载的 HZK16 文件(267,616 字节)包含 GB2312 全 87 区(从 1 区开始,含符号区),不是常见的"只含汉字 16-87 区"的裁剪版,所以必须用:

offset = ((区-1) * 94 + (位-1)) * 32

教训:从网上下载的字库文件,先写脚本把几个已知字形打印出来验证,再批量生成,否则错位的数据会直接污染整个字库头文件。

阶段四:ROM 不够,字库装不下

这是最后一个坑,也是让"乱码"问题真正死心的原因。

先算账

  • STC89C52 的 Flash 只有 8KB(8192 字节);
  • 完整 GB2312 字库 6763 个汉字 × 32 字节 ≈ 216KB,连完整 HZK16 文件(267KB)的零头都装不下,想都别想;
  • 就算只取常用 100 字也要 3.2KB,加上程序本体(约 6.8KB)必然超限。

实际表现

  1. 一开始贪心,界面文案里想到的字都放进去(四五十个字),Keil 编译直接报错——代码段空间不足(CODE 段溢出)。这就是"屏幕 ROM 不够";
  2. 强行压缩(比如把字库数组声明改小)更危险:数组越界后,字模读取会越过字库数组边界,读到相邻代码段里的机器码字节当字模显示,屏幕出现毫无规律的"花屏/乱码"——这种乱码不是编码问题,是数据越界。

解决方案:先统计界面全部文案,去重后只有 25 个字(年/月/日/时/分/秒/闹/钟/温/度/开/关/星/期/一~六/无/设/备/错/误),25 × 32 = 800 字节,是唯一装得下的规模。最终编译结果:

Program Size: data=79.3 xdata=4 code=6776

code 6776 字节已占 8KB 的 82.7%,只剩约 1.4KB 余量。以后要加新功能、新汉字,都得先看这个数字。

经验总结:51 上做中文界面,顺序是:先列文案 → 去重统计用字 → 按 32 字节/字算 ROM 预算 → 再生成字库。想显示"所有汉字"请换大容量单片机或外挂 Flash/字库芯片,8KB 的 89C52 没这个命。

提取脚本核心(位序转换)

HZK16 行存储(每行 2 字节,bit7 为最左),LCD 要列存储(页内字节 bit0 为顶部),转换时两处位序都要处理:

# 按 GB2312 码点定位:qu=首字节-0xA0, wei=次字节-0xA0
$offset = (($qu - 1) * 94 + ($wei - 1)) * 32   # 本文件从 1 区开始
$hzk = [IO.File]::ReadAllBytes('HZK16')        # 267,616 字节
# 转列向:bit r = 行 r(上半页 16 字节 / 下半页 16 字节)
for ($c = 0; $c -lt 16; $c++) {
    $tb = 0; $bb = 0
    for ($r = 0; $r -lt 8; $r++) {
        if (($hzk[$off + 2*$r + ($c -shr 3)]) -band (0x80 -shr ($c -band 7))) { $tb = $tb -bor (1 -shl $r) }
        if (($hzk[$off + 2*($r+8) + ($c -shr 3)]) -band (0x80 -shr ($c -band 7))) { $bb = $bb -bor (1 -shl $r) }
    }
    $bytes[$c] = $tb; $bytes[$c + 16] = $bb
}

生成的 25 字点阵按 32 字节/字写入 __CODE,编译后仅占 800 字节代码空间。

八、踩坑记录汇总

  1. TrueType 渲染字形被截断:16x16 画布装不下复杂汉字笔画,"错/误/备"边缘被切。改用标准 HZK16 点阵解决。
  2. 中文上下颠倒:本屏初始化含 __CODE(反向扫描),页内字节 bit0 在顶部;证据是 8x8 英文库代码 __CODE 反读字模行而显示正常。字模按 bit0=顶部生成后修复。
  3. HZK16 整体错位:文件从 1 区开始(含符号区),不能套用"从 16 区开始"的公式 __CODE,应为 __CODE。用"一/二/三/年"诊断预览验证后修复。
  4. ROM 不够导致乱码:STC89C52 只有 8KB Flash,全字库 216KB 装不下;字库数组越界会读出代码段机器码当字模 → 花屏乱码。界面文案去重后精选 25 字(800 字节),code=6776 占 82.7%。
  5. RTC 每次上电被覆盖:原工程每次上电都用编译时间写 DS1302,导致断电后时钟被"重置"。合理做法是首次上电或检测到无效时间才初始化(VCC1 备用电池保证走时)。
  6. 主循环计时漂移:用主循环计数 + 重绘判断时间,长时间运行会有累计漂移,且 DS18B20 转换(约 750ms 阻塞等待)期间界面刷新变慢。已通过异步转换(发命令→下周期读结果)缓解;进一步改进可换定时器中断计时。
  7. 刷新闪烁:最初每秒全屏重绘导致闪烁,改为四区域独立脏标志 + 局部清屏(__CODE)后解决。

九、项目文件

文件说明
main.c全部逻辑(约 1120 行):LCD 驱动、DS1302、DS18B20、键盘、闹钟、编辑状态机、刷新调度
ch16_font.h25 个汉字的 16x16 点阵(HZK16 提取,800 字节)
main.uvprojKeil C51 工程

编译结果:__CODE,0 Error 0 Warning。