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 核心器件

器件型号说明
MCUAT89C528051 内核,12MHz,256B RAM,8KB Flash
LCDST7565 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
}

每条命令的作用:

序号命令含义为什么选这个值
10xE2软件复位每次上电必须执行,初始化内部状态机
20xA2偏压 1/9Booster ON,驱动能力好
30xA0SEG 正向配合 0xC8 实现旋转补偿
40xC8COM 反向**竖屏核心**——让行扫描从上到下
50x2F电源全开升压器+稳压器+电压跟随器全部启动
60x24V0 ratio内部电阻分压比=4,影响对比度范围
7-80x81+0x28对比度0x28=40,可调范围 0~63
90xAF开显示LCD 开始输出画面
100x40起始行=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 几乎用尽。