LCD12864显示错位、多出一个空格?ST7920的连续ASCII必须是偶数个(51单片机)

0 阅读7分钟

万年历屏幕上星期那一栏,字总是比其他行往外偏半格。我改了三次偏移量,三次都没用。

最后发现根子不在地址算错了 —— 是 ST7920 的一条隐藏规则:连续写进去的半角字符(ASCII)必须是偶数个。

这条规则数据手册上不写在显眼的地方,网上教程也很少单独讲。但它一旦犯事,症状特别像"地址算错了",于是你会一遍遍去核对地址表,越核对越确信自己对,然后怀疑屏幕坏了。

一、病根:DDRAM 是按 16 位一个单元管的

先把硬件机制说清楚,后面所有案例都能自己推导出来。

ST7920 的显示 RAM 不是按字节编址的,是按 16 位一个显示单元编址的。一个 DDRAM 地址对应一个 16 位单元,而一个 16 位单元刚好装:

  • 1 个汉字(16×16 点阵,内码 2 个字节)

  • 或者 2 个半角 ASCII(8×16 点阵,每个 1 个字节)

关键点在于写入的过程:数据是 8 位总线送进去的,ST7920 内部有个写数据寄存器,你送的第一个字节先落在高字节位上,屏幕什么都不显示;送第二个字节才填满整个 16 位单元,这时内容被真正推入 DDRAM,地址指针 +1。

也就是说:两个字节才走一格。

那么如果连续写的 ASCII 是奇数个,最后一个字节就只有它自己,孤零零挂在寄存器里。它不会消失 —— 它会等着,跟你下一次写进去的第一个字节拼成一对。

于是从那一次开始,后面所有内容整体错半格。表现出来就是:"明明对每个字都单独写了地址,怎么还是歪的。"

二、我的代码里,五个真实的坑

这条规则不讲的时候看不出来,讲完之后回头翻代码,会发现到处都是它的痕迹。下面五个全是我这个项目里的真事。

坑 1:"星期3" 为什么会偏半格

```c
void print_weekday(unsigned int day)
{
    const char *weekdays[] =
    {
        0,
        "星期一",
        "星期二",
        "星期三",   // 2026-10-13 改:原为"星期3",ASCII奇数会错位一格
        "星期四",
        "星期五",
        "星期六",
        "星期日"
    };
    hzkdis(weekdays[day]);
}
```

原来是"星期3"。算一下:

| 内容 | 字节数 | | --- | --- | | 星 期(2 个汉字) | 2 × 2 = 4 | | 3(1 个半角) | 1 | | 合计 | 5 字节 = 2.5 个单元 ✗ |

半个字节单元,错位。

改成"星期三":4 + 2 = 6 字节 = 3 个单元,整格闭合,星期这一栏立刻归位。

当时我为了省两个字节用了半角数字,结果对着一张错位半格的屏幕查了一下午。这笔账亏到姥姥家。

坑 2:"%RH" 后面那个诡异的空格

第四行湿度单位这么写的:

```c
DHT11_receive();
write_com(0x9d);
Num(humidity);
write_com(0x9e);
hzkdis("%RH ");   // 2026-10-13 改:补空格,ST7920 连续ASCII须偶数个
```

%、R、H 三个半角字符 = 3 字节 = 1.5 个单元,奇数,毛病同上。

末尾补一个空格变成"%RH ":4 字节 = 2 个单元,一次占掉 0x9E、0x9F 两格,第四行刚好写满。

这个空格不是笔误,也不是为了排版好看 —— 它是凑数的。屏幕上完全看不出来,因为第四行本来就没有别的内容了。


坑 3:为什么代码里冒号写成 " :"

这一条最有意思,因为它是"作者意图"的化石。

时间显示的冒号,我写的是:

```c
write_com(0x91);
hzkdis(" :");      // 注意冒号前面有个空格
write_com(0x92);
Num(DS1302_Time[4]);
```

一个冒号而已,为什么要带个空格?

答案就在这篇讲的东西上 —— 单个 : 只有 1 个字节,半格。写成" :"是 2 个字节,正好一个单元。

屏幕上显示效果是"空格加冒号",但因为这一格本来就要被占,那个空格不占额外地方。你看到的是排版需要,实际是凑偶数。

同样的写法在时分之间、分秒之间、闹钟时分之间各出现一次,一共三处。


坑 4:闪烁清屏为什么永远是双空格

调时间时选中位会闪烁,清掉内容的写法满篇都是:

```c
if(TimeSetSelect==0 && TimeSetFlashFlag==1)
{
    write_com(0x81);
    hzkdis("  ");        // 两个空格,不是一个
}
```

hzkdis(" ") 是 2 字节 = 1 个单元,正好盖掉一格。

写成hzkdis(" ")就该出事了 —— 1 个字节半格,一个孤立的空格字节挂在寄存器里,等你下一次写年份数字的时候,它会跟十位数字拼在一起,显示出一个鬼东西。

再看星期闪烁那一行,一次写了三组:

```c
if(TimeSetSelect==6 && TimeSetFlashFlag==1)
{
    write_com(0x95); hzkdis("  ");
    write_com(0x96); hzkdis("  ");
    write_com(0x97); hzkdis("  ");
}
```

因为星期是"星期三"三个汉字,正好占 0x95、0x96、0x97 三个单元,所以擦三格。一组双空格 = 一格,就是这么换算的。


坑 5:为什么我的数字从来不错位

最后这条是反过来看的。

```c
void Num(unsigned int Number)
{
    unsigned char i = 0;
    for(i = 2; i > 0; i--)
    {
        write_data(Number / LCD_Pow(10, i-1) % 10 + '0');
    }
}
```

Num() 固定写两位,一个不多一个不少。2 个 ASCII = 正好 1 个单元。

这就是为什么年月日时分秒、温度湿度写了满屏,一个都没错位。 不是我算得准,是这个函数的输出天生就是闭合的。

反过来说:要是哪天我把Num()改成不定长度(比如一位数就不补零),立刻全线崩。


三、把整块屏幕算一遍,你就信了

拿这套规则去核自己的布局,会发现整个界面是严密咬合的。第二行:

| 地址 | 写入内容 | 字节数 | 占单元 | | --- | --- | --- | --- | | 0x90 | Num(时) | 2 | 1 | | 0x91 | hzkdis(" :") | 2 | 1 | | 0x92 | Num(分) | 2 | 1 | | 0x93 | hzkdis(" :") | 2 | 1 | | 0x94 | Num(秒) | 2 | 1 | | 0x95–0x97 | hzkdis("星期三") | 6 | 3 |

1+1+1+1+1+3 = 8 个单元,第二行整行写满,一格不多一格不少。

第一行是 7 个单元 + 0x87 留空,第三行同理也是 8 个。每一行都自己闭合 —— 这才是它看起来整齐的真正原因。

四、通用判断法则

以后遇到"不知道哪多出来一格",按这个查:

| 内容 | 字节数 | | --- | --- | | 1 个汉字 | 2 | | 1 个半角英文/数字/半角符号 | 1 | | 1 个空格 | 1 | | 1 个全角符号(如,。) | 2 |

不是偶数的两种补法:

  1. 末尾补一个空格(最简单,"%RH" → "%RH ")

  2. 把半角改成全角("星期3" → "星期三",顺手还更好看)

顺带三条写代码时的约定,能省掉以后所有的麻烦:

  • 每个显示函数自己保证输出偶数个字节,别指望调用方帮你补

  • 别写不定长的 ASCII 输出,数字统一补零到偶数位(就是这个 Num() 的做法)

  • 清屏/擦除用两个空格,永远别写单个空格

记住一句就够:

汉字算两个,ASCII 算一个,凑成偶数才不错。

写在最后

这个坑属于典型的"知道了一秒解决,不知道能查一下午"。

它难查的原因不是技术复杂,而是症状骗人 —— 看起来跟地址错、偏移量错、乱码长得一模一样,于是你会在地址表上反复找自己的茬,而不会想到去数字符串的字节数。

下次再看到屏幕上多出一格、少半格,先别动地址:把那行字符串的字节数从头数一遍。


本文是「51 万年历」系列第 4 篇。

后面还有:DS1302 时序与 BCD 码、DHT11 单总线、农历查表算法、整合篇。

有问题评论区问,我看到就回。