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 | 闹钟提醒 |
三、接线
接线表
| 外设 | 引脚 | 接到单片机 |
|---|---|---|
| LCD12864 | D0-D7 | P0.0-P0.7 |
| LCD12864 | RS | P2.6 |
| LCD12864 | WR | P2.7 |
| LCD12864 | CS/RST | 固定电平(按模块要求) |
| DS1302 | CLK | P3.6 |
| DS1302 | IO | P3.4 |
| DS1302 | RST | P3.5 |
| DS1302 | VCC2 / GND | 5V / GND |
| DS18B20 | DQ | P3.7(4.7k 上拉到 VCC) |
| DS18B20 | VCC / GND | 5V / GND |
| 蜂鸣器 | - 极 | P2.5(低电平响) |
| 蜂鸣器 | + 极 | 5V |
| 4x4 键盘 | 行 0-3 | P1.0-P1.3 |
| 4x4 键盘 | 列 0-3 | P1.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 页全清掉再全部重画。实际使用中会发现两个问题:
- 闪烁:每秒整屏清除+重绘,屏幕肉眼可见地闪;
- 卡顿: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 在底部(和普通汉字点阵库一致)。第一版字模按这个位序生成,烧进去一看——所有中文上下颠倒!
排查过程:
- 首先怀疑字库生成错,但用 Windows 画图把点阵按同样规则画出来,字形方向是正确的,说明生成端没错;
- 再看屏幕控制器:本屏初始化序列里有 __CODE(列反向扫描),这是关键线索——屏幕的页内字节顺序和常见屏是反的;
- 翻项目里一直在用的英文 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)必然超限。
实际表现:
- 一开始贪心,界面文案里想到的字都放进去(四五十个字),Keil 编译直接报错——代码段空间不足(CODE 段溢出)。这就是"屏幕 ROM 不够";
- 强行压缩(比如把字库数组声明改小)更危险:数组越界后,字模读取会越过字库数组边界,读到相邻代码段里的机器码字节当字模显示,屏幕出现毫无规律的"花屏/乱码"——这种乱码不是编码问题,是数据越界。
解决方案:先统计界面全部文案,去重后只有 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 字节代码空间。
八、踩坑记录汇总
- TrueType 渲染字形被截断:16x16 画布装不下复杂汉字笔画,"错/误/备"边缘被切。改用标准 HZK16 点阵解决。
- 中文上下颠倒:本屏初始化含 __CODE(反向扫描),页内字节 bit0 在顶部;证据是 8x8 英文库代码 __CODE 反读字模行而显示正常。字模按 bit0=顶部生成后修复。
- HZK16 整体错位:文件从 1 区开始(含符号区),不能套用"从 16 区开始"的公式 __CODE,应为 __CODE。用"一/二/三/年"诊断预览验证后修复。
- ROM 不够导致乱码:STC89C52 只有 8KB Flash,全字库 216KB 装不下;字库数组越界会读出代码段机器码当字模 → 花屏乱码。界面文案去重后精选 25 字(800 字节),code=6776 占 82.7%。
- RTC 每次上电被覆盖:原工程每次上电都用编译时间写 DS1302,导致断电后时钟被"重置"。合理做法是首次上电或检测到无效时间才初始化(VCC1 备用电池保证走时)。
- 主循环计时漂移:用主循环计数 + 重绘判断时间,长时间运行会有累计漂移,且 DS18B20 转换(约 750ms 阻塞等待)期间界面刷新变慢。已通过异步转换(发命令→下周期读结果)缓解;进一步改进可换定时器中断计时。
- 刷新闪烁:最初每秒全屏重绘导致闪烁,改为四区域独立脏标志 + 局部清屏(__CODE)后解决。
九、项目文件
| 文件 | 说明 |
|---|---|
| main.c | 全部逻辑(约 1120 行):LCD 驱动、DS1302、DS18B20、键盘、闹钟、编辑状态机、刷新调度 |
| ch16_font.h | 25 个汉字的 16x16 点阵(HZK16 提取,800 字节) |
| main.uvproj | Keil C51 工程 |
编译结果:__CODE,0 Error 0 Warning。