AT89C52 + ST7565 图形液晶计算器开发全记录
一、项目概述
用一片 AT89C52 单片机,搭配一块 128x64 像素的 ST7565 图形液晶屏(无字库)和 1x4 矩阵键盘,实现一个四则运算计算器。全部代码约 635 行 C 语言,写在一个 main.c 文件中,用 Keil C51 编译。
功能:
- 四则运算(加减乘除)
- 支持负数输入
- 链式运算(直接按运算符继续计算)
- 2 条历史记录
- 溢出和除零检测(显示 Err: over / Err: /0)
- 8 位数字范围(-99999999 ~ 99999999)
最终 LCD 显示效果:
页0: 123 + 456 = 表达式
页1: 579 结果(右对齐)
页2: 10 + 20 = 30 历史记录1
页3: 5 x 6 = 30 历史记录2
页4: 空白
页5: 空白
页6: zhangtim.cn 静态文字
页7: liu xing yu 静态文字
二、硬件平台
2.1 核心器件
| 器件 | 型号 | 说明 |
| MCU | AT89C52 | 8051 内核,12MHz,256B RAM,8KB Flash |
| LCD | ST7565 128x64 | 图形液晶,无字库,8080 并行接口 |
| 键盘 | 1x4 轻触按键 | 行扫描法,接 P1.0~P1.7 |
2.2 接线图
AT89C52
+------------+
P1.0 -| |
P1.1 -| 键盘行扫描 |
P1.2 -| P1.0~P1.3 |
P1.3 -| |
P1.4 -| 键盘列输入 |
P1.5 -| P1.4~P1.7 |
P1.6 -| |
P1.7 -| |
| |
P0.0~P0.7 ---- LCD D0~D7 (8位数据总线)
P2.6 ------- LCD RS (数据/命令选择)
P2.7 ------- LCD WR (写使能)
| |
+------------+
ST7565 LCD 模块接线:
RD -> VCC (不读,只写)
CS -> GND (始终选中)
RST -> VCC (不复位)
A0/RS -> P2.6
WR -> P2.7
D0~D7 -> P0.0~P0.7
VDD -> 5V
VSS -> GND
VO -> 电位器(调节对比度)
关键设计决策: ST7565 的 RD 引脚直接接 VCC、CS 接 GND,意味着整个项目只写不读。这大大简化了驱动——不需要三态总线控制,不需要读时序,所有操作都是单向写入。
2.3 键盘布局
物理按键从下到上排列:C 0 = +
C 清零,回到初始状态
0 数字 0
= 等号,计算结果
+ 加法
注意: 加减乘除四个运算符中,+ 在键盘最上方物理位置,但软件里 - 键还有一个特殊功能:在还没输入数字时按 - 可以切换正负号。
三、ST7565 LCD 驱动详解(核心章节)
这是整个项目中最有技术含量、也是踩坑最多的部分。
3.1 ST7565 是什么
ST7565 是 Sitronix 公司出品的 128x64 单色图形液晶控制器芯片。它不是"智能屏"——你不能直接发"画个圆"这样的命令。它只做一件事:维护一块显示 RAM(DDRAM),你往里写什么,屏上就显示什么。
关键参数:
| 参数 | 值 |
| 显示分辨率 | 128 x 64 像素 |
| 显存大小 | 65 x 132 x 1 bit(实际只用 128x64) |
| 接口 | 8080 并行 / SPI(本项目用 8080 并行) |
| 驱动电压 | 3.3V~5V(本项目 5V) |
| 对比度 | 可软件调节(0~63) |
3.2 显存组织结构(理解驱动的关键)
ST7565 的显存不是像电脑显卡那样"一个地址对应一个像素"。它的组织方式是页(Page)+ 列(Column):
列地址 0 1 2 3 ... 127
+----+----+----+----+----+---...+----+
Page 0 (行0~7)| D0 | D1 | D2 | D3 | D4 | | | 每个字节是 8 个纵向像素
+----+----+----+----+----+---...+----+
Page 1 (行8~15)| | | | | | | |
+----+----+----+----+----+---...+----+
Page 2 | | | | | | | |
+----+----+----+----+----+---...+----+
... ... ... ...
+----+----+----+----+----+---...+----+
Page 7 (行56~63)| | | | | | | |
+----+----+----+----+----+---...+----+
每个字节 8 个像素(纵向排列):
bit 7 = 该列最上面的像素
bit 6 = 往下一个像素
...
bit 0 = 该列最下面的像素
举例: 要点亮第 2 页、第 10 列、从上往下第 3 个像素(bit5),需要:
1. 发命令 0xB2(设置页地址=2)
2. 发命令 0x10, 0x0A(设置列地址=10,高4位+低4位)
3. 发数据,其中 bit5 = 1
为什么要理解这个? 因为画一个 8x8 字符的过程是:先定位到某个页+列,然后连续写 8 个字节(覆盖 8 列),每字节控制该列的 8 个纵向像素。
3.3 硬件物理旋转——一切坑的根源
这个项目的 LCD 模块被物理旋转了 90 度安装(竖屏模式)。
正常安装(横屏)时:
原始: 128 列 x 64 行
Page 从上到下: 0,1,2,...,7 (64行 / 8 = 8页)
列从左到右: 0,1,2,...,127
字模直接用: 没问题
旋转 90 度后(竖屏):
旋转后: 64 列 x 128 行(但控制器仍然认为自己在管 128x64)
原来的"列"变成了竖屏的"行"
原来的"页"变成了竖屏的"列"
如果不处理,字符会歪掉
具体表现: 如果直接把标准 8x8 字模往 ST7565 写,屏幕上看到的字符是:左右镜像 + 上下颠倒 + 横竖互换。完全没法看。
3.4 软件转置算法——解决问题的核心
为了补偿硬件旋转,需要对字模做矩阵转置 + 垂直翻转。
原始字模(以数字 '0' 为例):
行0: 00111100 = 0x3C
行1: 01100110 = 0x66
行2: 01100110 = 0x66
行3: 01100110 = 0x66
行4: 01100110 = 0x66
行5: 01100110 = 0x66
行6: 00111100 = 0x3C
行7: 00000000 = 0x00
转置后(行变列,列变行)+ 垂直翻转(行号反转):
输出列0: 取原始 行7 的 bit7, 行6 的 bit7, ..., 行0 的 bit7
输出列1: 取原始 行7 的 bit6, 行6 的 bit6, ..., 行0 的 bit6
...
输出列7: 取原始 行7 的 bit0, 行6 的 bit0, ..., 行0 的 bit0
实际代码:
static void lcd_draw_char(u8 x, u8 page, u8 ch)
{
u8 idx, i, j, b;
idx = font_index(ch);
// 1. 定位到目标位置
lcd_write_cmd(0xB0 | page); // 设置页地址
lcd_write_cmd(0x10 | (x >> 4)); // 列地址高4位
lcd_write_cmd(0x00 | (x & 0x0F)); // 列地址低4位
// 2. 写 8 字节(8列 x 8像素 = 64像素)
for (i = 0; i < 8; i++) {
b = 0;
for (j = 0; j < 8; j++) {
// 核心转置公式:
// g_font8x8[idx][7-j] -> 7-j 做垂直翻转
// & (0x80 >> i) -> 取原行第 i 位
// |= (0x80 >> j) -> 放到输出的第 j 位
if (g_font8x8[idx][7 - j] & (0x80 >> i))
b |= (0x80 >> j);
}
lcd_write_data(b); // 写入一列像素
}
}
逐行解析这个算法:
外层 i 从 0 到 7:遍历输出的 8 列(对应 LCD 显存的连续 8 列)
内层 j 从 0 到 7:遍历原始字模的 8 行(7-j 做垂直翻转)
// 第 i 列的输出值 b:
j=0: 取 g_font8x8[idx][7] 的第 i 位 -> b 的第 0 位
j=1: 取 g_font8x8[idx][6] 的第 i 位 -> b 的第 1 位
j=2: 取 g_font8x8[idx][5] 的第 i 位 -> b 的第 2 位
...
j=7: 取 g_font8x8[idx][0] 的第 i 位 -> b 的第 7 位
这个公式的本质: 一个乘法表大小的查找,用位运算在 O(64) 时间内完成整个 8x8 矩阵的转置+翻转。在 12MHz 8051 上约 200us 完成一个字符。
3.5 ST7565 初始化序列——10 条命令
static void lcd_init(void)
{
lcd_write_cmd(0xE2); // 1. 软件复位
delay_ms(50); // 等 50ms 让内部电路稳定
lcd_write_cmd(0xA2); // 2. 偏压设置: 1/9 bias
lcd_write_cmd(0xA0); // 3. SEG 扫描方向: 正向
lcd_write_cmd(0xC8); // 4. COM 扫描方向: 反向 <- 竖屏关键
lcd_write_cmd(0x2F); // 5. 电源控制: 启动 Booster + Regulator + Follower
lcd_write_cmd(0x24); // 6. 内部电阻比率: ratio = 4
lcd_write_cmd(0x81); // 7. 电子体积命令前缀
lcd_write_cmd(0x28); // 8. V0 对比度值: 0x28 (40/63)
lcd_write_cmd(0xAF); // 9. 开显示
lcd_write_cmd(0x40); // 10. 起始行: 0
}
每条命令的作用:
| 序号 | 命令 | 含义 | 为什么选这个值 |
| 1 | 0xE2 | 软件复位 | 每次上电必须执行,初始化内部状态机 |
| 2 | 0xA2 | 偏压 1/9 | Booster ON,驱动能力好 |
| 3 | 0xA0 | SEG 正向 | 配合 0xC8 实现旋转补偿 |
| 4 | 0xC8 | COM 反向 | **竖屏核心**——让行扫描从上到下 |
| 5 | 0x2F | 电源全开 | 升压器+稳压器+电压跟随器全部启动 |
| 6 | 0x24 | V0 ratio | 内部电阻分压比=4,影响对比度范围 |
| 7-8 | 0x81+0x28 | 对比度 | 0x28=40,可调范围 0~63 |
| 9 | 0xAF | 开显示 | LCD 开始输出画面 |
| 10 | 0x40 | 起始行=0 | 显存第 0 行对应屏幕最上面 |
0xA0 和 0xC8 的组合决定了屏幕方向。0xC8 让 COM 从上到下扫描,0xA0 让 SEG 从左到右——但因为模块被物理旋转了 90 度,软件里还需要做字模转置才能正确显示。
3.6 8080 并行写时序
ST7565 的 8080 接口只有两个控制信号:
RS (A0): 0=命令 1=数据
WR: 下降沿写入,上升沿锁存
写命令时序:
RS=0, 数据放总线, WR=0, 延时, WR=1, 延时
|___|~~~~~~~~~|___|~~~~~~~~
RS 数据建立 WR 数据锁存
写数据时序:
RS=1, 数据放总线, WR=0, 延时, WR=1, 延时
代码:
static void lcd_write_cmd(u8 cmd)
{
LCD_RS = 0; // RS=0: 命令
LCD_DATAPORT = cmd; // 数据放总线
LCD_WR = 0; // WR 拉低
delay_short(); // ~10us 建立时间
LCD_WR = 1; // WR 拉高,数据锁存
delay_short(); // ~10us 保持时间
}
static void lcd_write_data(u8 dat)
{
LCD_RS = 1; // RS=1: 数据
LCD_DATAPORT = dat;
LCD_WR = 0;
delay_short();
LCD_WR = 1;
delay_short();
}
为什么不用读时序? 因为 RD 接 VCC,CS 接 GND——硬件上已经把读功能禁用了。这是简化设计的常见做法:如果不需要读状态/显存,可以省掉三态总线控制。
3.7 局部清屏——避免闪烁
static void lcd_clear_area(u8 start_page, u8 end_page)
{
u8 page, col;
for (page = start_page; page <= end_page; page++) {
lcd_write_cmd(0xB0 | page);
lcd_write_cmd(0x10);
lcd_write_cmd(0x00);
for (col = 0; col < 128; col++)
lcd_write_data(0x00);
}
}
为什么不用全屏清屏? 因为页 6~7 有静态文字(zhangtim.cn / liu xing yu),只在初始化时画一次。如果每次刷新都清全屏,这两行文字会先闪没再画回来,造成可感知的闪烁。只清页 0~3 就完美解决了。
3.8 完整渲染流程
static void lcd_render(void)
{
u8 i, pos;
// 1. 清空 4 行文本缓冲
for (i = 0; i < 4; i++)
for (pos = 0; pos < 16; pos++)
g_line[i][pos] = ' ';
// 2. 根据状态机填充缓冲
// 行0: 表达式 "123 + 456 ="
// 行1: 结果 "579"
// 行2: 历史1 "10 + 20 = 30"
// 行3: 历史2 "5 x 6 = 30"
// ...根据当前状态有不同填充逻辑...
// 3. 只清页 0~3
lcd_clear_area(0, 3);
// 4. 逐字符绘制(每个字符 8x8 像素)
for (i = 0; i < 4; i++)
for (pos = 0; pos < 16; pos++)
lcd_draw_char(pos * 8, i, g_line[i][pos]);
}
每行 16 个字符 x 8 像素/字符 = 128 像素,刚好铺满一行。
四、字模表
4.1 什么是字模
字模就是字符的像素点阵数据。8x8 字模 = 8 字节,每字节 8 个像素纵向排列。
以数字 '1' 为例 (8x8):
列: 0 1 2 3 4 5 6 7
行0: . . . 1 . . . . = 0x08
行1: . . 1 1 . . . . = 0x18
行2: . . . 1 . . . . = 0x08
行3: . . . 1 . . . . = 0x08
行4: . . . 1 . . . . = 0x08
行5: . . . 1 . . . . = 0x08
行6: . . 1 1 1 . . . = 0x1C
行7: . . . . . . . . = 0x00
实际存储: {0x08, 0x18, 0x08, 0x08, 0x08, 0x08, 0x1C, 0x00}
4.2 本项目字模表(37 个字符)
包括:空格、0-9、+ - * / =、E、C、a c e l o r v(用于 "Error" 等提示)、. g h i m n s t u x y z(用于 "zhangtim.cn" 和 "liu xing yu")
static u8 code g_font8x8[37][8] = {
{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // [0] 空格
{0x3C,0x66,0x6E,0x76,0x66,0x66,0x3C,0x00}, // [1] 0
{0x18,0x38,0x18,0x18,0x18,0x18,0x7E,0x00}, // [2] 1
{0x3C,0x66,0x06,0x0C,0x18,0x30,0x7E,0x00}, // [3] 2
{0x3C,0x66,0x06,0x1C,0x06,0x66,0x3C,0x00}, // [4] 3
{0x0C,0x1C,0x2C,0x4C,0x7E,0x0C,0x0C,0x00}, // [5] 4
{0x7E,0x60,0x7C,0x06,0x06,0x66,0x3C,0x00}, // [6] 5
{0x1C,0x30,0x60,0x7C,0x66,0x66,0x3C,0x00}, // [7] 6
{0x7E,0x06,0x0C,0x18,0x18,0x18,0x18,0x00}, // [8] 7
{0x3C,0x66,0x66,0x3C,0x66,0x66,0x3C,0x00}, // [9] 8
{0x3C,0x66,0x66,0x3E,0x06,0x0C,0x38,0x00}, // [10] 9
{0x00,0x18,0x18,0x7E,0x18,0x18,0x00,0x00}, // [11] +
{0x00,0x00,0x00,0x7E,0x00,0x00,0x00,0x00}, // [12] -
{0x00,0x66,0x3C,0xFF,0x3C,0x66,0x00,0x00}, // [13] *
{0x02,0x06,0x0C,0x18,0x30,0x60,0x40,0x00}, // [14] /
{0x00,0x00,0x7E,0x00,0x7E,0x00,0x00,0x00}, // [15] =
{0x7E,0x60,0x60,0x7C,0x60,0x60,0x7E,0x00}, // [16] E
{0x3C,0x66,0x60,0x60,0x60,0x66,0x3C,0x00}, // [17] C
{0x00,0x00,0x36,0x4C,0x5C,0x4C,0x36,0x00}, // [18] a
{0x00,0x00,0x3C,0x60,0x60,0x60,0x3C,0x00}, // [19] c
{0x00,0x00,0x3C,0x66,0x7E,0x60,0x3C,0x00}, // [20] e
{0x18,0x18,0x18,0x18,0x18,0x18,0x18,0x00}, // [21] l
{0x00,0x00,0x3C,0x66,0x66,0x66,0x3C,0x00}, // [22] o
{0x00,0x00,0x6C,0x76,0x60,0x60,0x60,0x00}, // [23] r
{0x00,0x00,0x00,0x66,0x66,0x3C,0x18,0x00}, // [24] v
{0x00,0x00,0x00,0x00,0x00,0x18,0x18,0x00}, // [25] .
{0x00,0x00,0x3E,0x66,0x66,0x3E,0x06,0x7C}, // [26] g
{0x00,0x60,0x60,0x7C,0x66,0x66,0x66,0x00}, // [27] h
{0x00,0x18,0x00,0x38,0x18,0x18,0x3C,0x00}, // [28] i
{0x00,0x00,0x6C,0x76,0x76,0x6A,0x62,0x00}, // [29] m
{0x00,0x00,0x6C,0x76,0x66,0x66,0x66,0x00}, // [30] n
{0x00,0x00,0x3C,0x60,0x3C,0x06,0x7C,0x00}, // [31] s
{0x00,0x30,0x30,0x7C,0x30,0x30,0x1C,0x00}, // [32] t
{0x00,0x00,0x66,0x66,0x66,0x6E,0x36,0x00}, // [33] u
{0x00,0x00,0x66,0x3C,0x18,0x3C,0x66,0x00}, // [34] x
{0x00,0x00,0x66,0x66,0x66,0x3E,0x06,0x7C}, // [35] y
{0x00,0x00,0x7E,0x0C,0x18,0x30,0x7E,0x00}, // [36] z
};
code 关键字: Keil C51 中 code 表示数据存储在 Flash/ROM 中,不占用 256 字节 RAM。37 x 8 = 296 字节全部放在程序存储器里。
五、计算器逻辑
5.1 状态机
按数字键
ST_INPUT ──────────────> ST_INPUT
| |
| 按运算符 | 按等号
v v
ST_OP ──────────────> ST_RESULT
| |
| 除零/溢出 | 按数字键(新计算)
v v
ST_ERROR ST_INPUT
按 C 键: 任何状态 -> ST_INPUT (初始化)
5.2 核心运算
static u8 calc_result(s32 a, s32 b, u8 op, s32 *out)
{
switch (op) {
case OP_ADD: *out = a + b; break;
case OP_SUB: *out = a - b; break;
case OP_MUL:
if (b != 0) {
// 乘法溢出预检:|a| > MAX_NUM/|b| 必溢出
a1 = a < 0 ? -a : a;
a2 = b < 0 ? -b : b;
if (a1 > (s32)MAX_NUM / a2) return 2;
}
*out = a * b;
break;
case OP_DIV:
if (b == 0) return 1; // 除零错误
*out = a / b;
break;
}
// 结果范围检查
if (*out > (s32)MAX_NUM || *out < -(s32)MAX_NUM) return 2;
return 0; // 正常
}
值域: -99999999 ~ 99999999(8 位十进制数字)。
5.3 历史记录
最多保存 2 条历史。每次等号按下时:
历史2 = 历史1 (旧的1推到2)
历史1 = 当前计算 (新的存入1)
显示逻辑:如果历史2有值,行2显示历史1、行3显示历史2;否则行3显示静态文字。
5.4 负数输入
在没有输入数字时按 - 键,切换正负号标志(s_input_neg = !s_input_neg),不消耗输入。
六、键盘扫描
6.1 行扫描法
for (row = 0; row < 4; row++) {
P1 = ~(1 << row); // 当前行输出 0,其余输出 1
val = P1 & 0xF0; // 读高 4 位(列输入,内部上拉)
if (val != 0xF0) { // 有列被拉低 = 有键按下
for (col = 0; col < 4; col++) {
if (!(val & (0x10 << col))) {
key = g_key_map[row][col];
break;
}
}
}
}
6.2 软件消抖
连续采样 15 次,键值不变才确认:
s_same_cnt < 15: 等待稳定
s_same_cnt == 15: 确认按键,产生一次有效事件
s_release_cnt >= 15: 确认释放,清除 last_key 防止重复触发
七、LCD 缓冲架构
7.1 脏标记模式
void main(void) {
disp_init();
calc_init();
while (1) {
key_scan();
calc_process(s_key_val);
if (g_dirty) {
lcd_render();
g_dirty = 0;
}
delay_ms(1);
}
}
只有状态变化时才重绘,避免每帧刷满屏导致闪烁和 CPU 浪费。
7.2 双缓冲思想
- `g_line[4][17]` 是**文本缓冲**(RAM 中),先把要显示的文字填进去
- `lcd_render()` 把文本缓冲转成像素写到 LCD 显存
这种分离让显示逻辑和业务逻辑解耦——calc_process 只管改 g_line,lcd_render 只管画。
八、踩过的坑
8.1 RAM 溢出(反复修了 3 次)
AT89C52 只有 256 字节 IDATA,非常紧张:
| 次数 | 问题 | 原因 | 解决方案 |
| 第1次 | DATA 段 256.2 字节 | 局部数组太大 | 缩减 tmp 数组 |
| 第2次 | IDATA 溢出 | 4 条历史记录变量太多 | 砍掉历史3/4,只保留 2 条 |
| 第3次 | g_line[6][17] 溢出 | 6行缓冲太占空间 | 改回 [4][17] |
教训: 8051 编程,每一字节 RAM 都要精打细算。能放 code(ROM) 的绝不放 idata(RAM),能用 u8 的绝不用 u16。
8.2 ST7565 字模方向
最初直接用标准字模,字符全是歪的。排查路径:
1. 先怀疑接线 -> 检查后正常
2. 再怀疑初始化 -> 对照数据手册逐条检查 -> 正常
3. 最后才想到物理旋转 -> 写转置测试程序验证 -> 确认
8.3 Keil C51 的 code 指针限制
// 错误写法!Keil C51 不允许
code u8 *s = "hello";
s = "world"; // error C183: unmodifiable lvalue
// 正确写法:直接内联或用 typedef
typedef u8 code *codeptr;
8.4 全屏刷新闪烁
最初每帧 lcd_clear() 清全屏再重画,底部静态文字也被清掉。改成 lcd_clear_area(0, 3) 只清计算器区域后问题解决。
九、完整代码结构
main.c (635 行)
|
+-- 类型定义、宏、常量 (1-45)
+-- 键盘映射表 (47-80)
+-- 全局变量 (82-105)
+-- delay_ms, input_to_num, num_to_str (107-143)
+-- line_right, line_put, line_append (145-175)
+-- ST7565 底层驱动 (177-213)
| +-- delay_short() ~10us 延时
| +-- lcd_write_cmd() 写命令 (RS=0)
| +-- lcd_write_data() 写数据 (RS=1)
| +-- lcd_clear() 全屏清零
|
+-- lcd_init() (215-238) ST7565 初始化 + 静态文字
+-- 字模表 g_font8x8[37][8] (240-278)
+-- font_index() (280-313) ASCII -> 字模索引
+-- lcd_draw_char() (315-330) 转置渲染核心
+-- 渲染辅助函数 (332-379)
| +-- line_right() 右对齐数字
| +-- line_put() 逐字填充
| +-- line_append() 追加数字
| +-- line_hist() 格式化历史记录
| +-- line_str() 填充字符串
| +-- lcd_clear_area() 局部清屏
|
+-- lcd_render() (381-436) 总渲染入口
+-- key_scan() (444-495) 键盘扫描 + 消抖
+-- calc_init() (497-506) 计算器初始化
+-- calc_result() (508-531) 四则运算
+-- calc_process() (533-619) 按键处理 + 状态机
+-- main() (621-635) 主循环
十、总结
这个项目展示了在资源极其有限的 8051 平台上驱动图形 LCD 的完整方案:
1. ST7565 驱动:8080 并行接口,10 条命令完成初始化,写命令/写数据两函数搞定底层
2. 字模转置:用位运算矩阵转置补偿硬件旋转,一个 8x8 块约 200us
3. 脏标记渲染:只在状态变化时重绘,避免闪烁
4. RAM 节省:code 存字模、idata 存状态、局部变量及时释放
5. 状态机:清晰管理 输入 -> 运算 -> 结果 -> 错误 的状态流转
6. 局部清屏:只刷计算器区域,静态文字不受影响
整个计算器最终约 635 行 C 代码,编译后占 AT89C52 的 8KB Flash 约一半空间,256 字节 RAM 几乎用尽。