万年历屏幕上星期那一栏,字总是比其他行往外偏半格。我改了三次偏移量,三次都没用。
最后发现根子不在地址算错了 —— 是 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
|
不是偶数的两种补法:
-
末尾补一个空格(最简单,
"%RH"→"%RH ") -
把半角改成全角(
"星期3"→"星期三",顺手还更好看)
顺带三条写代码时的约定,能省掉以后所有的麻烦:
-
每个显示函数自己保证输出偶数个字节,别指望调用方帮你补
-
别写不定长的 ASCII 输出,数字统一补零到偶数位(就是这个
Num()的做法) -
清屏/擦除用两个空格,永远别写单个空格
记住一句就够:
汉字算两个,ASCII 算一个,凑成偶数才不错。
写在最后
这个坑属于典型的"知道了一秒解决,不知道能查一下午"。
它难查的原因不是技术复杂,而是症状骗人 —— 看起来跟地址错、偏移量错、乱码长得一模一样,于是你会在地址表上反复找自己的茬,而不会想到去数字符串的字节数。
下次再看到屏幕上多出一格、少半格,先别动地址:把那行字符串的字节数从头数一遍。
本文是「51 万年历」系列第 4 篇。
-
第 1 篇:万年历完整功能演示,Proteus 跑通实录(源码和 Proteus 工程在这篇文末)
-
第 2 篇:Proteus 里 LCD12864B 黄屏不显示的完整排查(屏幕点不亮先看这篇)
-
第 3 篇:ST7920 八位并口驱动详解 + DDRAM 地址表(错位预防的下游在这)
后面还有:DS1302 时序与 BCD 码、DHT11 单总线、农历查表算法、整合篇。
有问题评论区问,我看到就回。