☰
16×16点阵汉字显示:从动态扫描到字模提取的完整实现
2026/10/3 1:21:37 网站建设 项目流程

说实话,“16×16点阵汉字显示”这个题目,我在电子技术设计课、课程设计、以及毕业后帮学弟学妹看毕设的各个阶段里,来来回回碰过不下十次。它几乎是各类电子类课设清单里的常青树:不算难,但信息量巨大——从单片机I/O扩展、动态扫描显示、汉字字模数据结构,到硬件驱动电路设计与实物调试,一道题把数字电路和单片机应用的核心知识点全串了起来。这篇文章把我自己完成这个项目的完整思路、选型理由、代码结构和调试过程整理出来,适合正在准备电子技术设计课程、课程设计或电子竞赛基础训练的同学参考,也适合想真正弄明白点阵屏底层原理的嵌入式入门爱好者。尤其建议先想清楚一件事:我们常说的“16×16点阵汉字显示”,到底难在哪里?难度不在“点亮LED”,而在“用最少的I/O口、最少的芯片、最稳的刷新率,点亮任意想要亮的256个点”。

1. 为什么16×16能成为汉字显示的“入门标准”

1.1 汉字笔画复杂度与点阵分辨率的关系

很多人第一次拿到题目会问:为什么是16×16,而不是8×8或更小?答案其实很朴素——汉字的笔画结构太复杂了。

8×8点阵一共64个点,显示英文字母和数字绰绰有余,5×7点阵就够拼出所有ASCII字符。但汉字里随便挑一个“繁”字、一个“赢”字,笔画一多,8×8根本放不下,强行放上去会糊成一团,别说人眼识别,连机器都难认。16×16点阵提供256个点,正好能覆盖常用汉字的笔画密度。横、竖、撇、捺、折这些基本笔画,在16×16的网格里至少能画出两到三像素宽的线条,人眼看起来清晰、方正、不缺笔画。

从应用角度看也有讲究。市面上LED点阵屏按点距和分辨率分成很多规格,16×16是“能完整显示一个汉字”的最小规格,也是单位面积成本最低的规格。往上还有24×24、32×32,清晰度确实更好,但点阵数据量几乎翻倍,扫描刷新周期变长,对主控频率和驱动电路的要求都上了一个台阶。课程设计用16×16,性能冗余和工程量之间正好处于最佳平衡点。

1.2 动态扫描:用16根线控制256个LED的核心思路

明确了分辨率,下一个问题就是:256个LED,怎么控制?

先算一笔账。如果每个LED单独占一个单片机I/O口,控制256个LED就需要256个I/O。STC89C52这种经典51单片机一共才32个I/O口,STM32F103C8T6也只有37个可用I/O。即便强行够用,也完全没有扩展空间了,更别提还要接按键、接传感器。所以直接“一对一”控制这条路走不通。

动态扫描(也叫动态刷新、分时复用)就是为解决这个问题而来的。人眼有视觉暂留效应,大约0.1秒内的光信号会在大脑中残留,只要一帧画面的刷新时间小于人眼能感知的临界频率(一般取50Hz以上),人眼就会把连续出现的离散画面看成连续的稳定图像。具体到16×16点阵,我做的是逐行扫描:

  • 每次只点亮一行(16个LED);
  • 按顺序依次扫描第0行、第1行……第15行;
  • 一行持续点亮约1ms,扫完16行需要16ms;
  • 刷新率约1/16ms ≈ 62.5Hz,高于50Hz,人眼看起来就是整屏稳定显示。

也就是说,任一时刻点阵屏上最多只有一行LED在亮,但切换速度足够快,眼睛被骗了过去。这样我们控制16行只需要16根行选线,控制每行16列只需要16根列数据线,总共32根线,再配合译码器和移位寄存器,最终单片机实际只占用几个I/O口。

2. 硬件架构拆解:四块8×8模块如何拼出16×16

2.1 拼接方式与行、列线归并

明确了扫描原理,硬件方案就顺理成章了。市面上很难直接买到现成的16×16单块点阵屏,常用方案是买四块8×8点阵模块,按2×2的方式拼成一个大方块。

8×8模块内部是共阳或共阴接法,对外引出8根行线和8根列线。把四块拼起来后,需要做线网归并:

  • 上面两块的行线对应并联,形成屏的第0~7行;
  • 下面两块的行线对应并联,形成屏的第8~15行;
  • 左侧两块(上下各一块)的8根列线并联,形成屏的第0~7列;
  • 右侧两块(上下各一块)的8根列线并联,形成屏的第8~15列。

归并之后,从外部看起来就是一块标准的16行×16列点阵屏,行线16根、列线16根。这里有个容易踩的坑:四块模块买到手后,先要确认模块的行列引脚定义。不同厂家、不同颜色的模块,引脚顺序可能完全不同。我习惯先用万用表二极管档逐个测出每个引脚对应哪个行、哪个列,再画接线图,千万别直接按网上的引脚图硬接。

2.2 驱动芯片选型:74HC154负责行、74HC595负责列

16行16列,如果全部由单片机直接驱动,I/O口仍然不够。常规做法是引入两类芯片:

  • 2片74HC595级联,负责16位列数据输出;
  • 1片74HC154(4-16译码器),负责16行选通。

74HC595是串行输入、并行输出的移位寄存器。单片机只需要3根线(数据线DS、移位时钟SHCP、锁存时钟STCP),就能把16位数据串行移入两片595,再一次性并行输出到16根列线上。这就像一条窄传送带,一件一件地把货物送进仓库,等全部到齐后一起上架。

74HC154则是4线输入、16线输出的译码器。单片机用4个I/O口组成4位地址(0~15),就能唯一选中16根输出线中的一根,把它拉低(或拉高,取决于具体型号的极性)。用4根线选通16行,比直接用16根线省了12个I/O。

两者配合,单片机实际占用的I/O是:595的3根 + 154的4根 = 7根。7根I/O控制256个LED,性价比非常突出。如果你用STM32这类I/O更多的芯片,也可以直接用GPIO模拟行选和列数据,省掉芯片,但连线会爆炸,而且实时性未必更好,所以我还是推荐按经典方案来。

2.3 电流驱动能力:为什么必须加三极管或达林顿管

芯片选好了,还有一个隐藏难题:驱动电流不够。

74HC154每个输出口的灌电流/拉电流能力一般在4~6mA左右,而一行要同时点亮16个LED。以普通红色LED工作电流10mA计算,16个LED需要160mA,154根本带不动。所以154只能做“译码选通”,不能直接当行驱动器。

我常用的方案是共阳模块+PNP三极管行驱动+595列灌电流:

  • 每根行线接一个PNP三极管(如8550)的集电极,三极管发射极接VCC;
  • 154的译码输出控制三极管基极;
  • 选中某一行时,对应三极管导通,把VCC加到该行所有LED的阳极;
  • 列的阴极由595输出低电平灌电流,点亮对应的LED。

如果选共阴模块,则行线接NPN三极管或达林顿管到GND,列线由595输出高电平驱动。两种接法都可以,但一定要保持“行选通极性”和“列数据极性”匹配,不匹配的典型症状就是整屏乱亮、按行闪烁或者干脆全灭。

列驱动这边,595的输出灌电流能力大约20mA,理论上直接驱动一列(对应一行中的一个LED)够用,但为了留余量、防止595过热,很多设计会在595输出后接ULN2803达林顿管阵列再驱动LED阴极。ULN2803每个通道最大灌电流500mA,还内置续流二极管,非常皮实。实际做课程设计时,用2片595直驱也能跑,但接到大尺寸点阵(比如16×64)或者高亮LED模块时,强烈建议加ULN2803。

3. 汉字字模的提取与存储:32字节背后的数据结构

3.1 从字形到点阵:取模软件的正确配置

硬件通路打通之后,软件要解决的是“数据从哪来”。一个16×16汉字在内存里是32个字节,这个数字很多同学背过,但未必真正理解。展开算就是:16行×16列 = 256个点,每个点用一个二进制位表示(1亮、0灭),256个bit = 32字节。

要把汉字变成这32字节,常用的工具是取模软件。我用过PCtoLCD2002和字模精灵,国产软件,上手快,关键是要把参数设置对。以PCtoLCD2002为例,我固定的配置是:

  • 点阵格式:汉字(16×16);
  • 取模方式:逐行式(也叫横向取模,先取第0行的16个点,再取第1行……);
  • 阴码还是阳码:阴码(1表示亮);
  • 低位在前还是高位在前:看你的595接线和移位顺序,下面会细说;
  • 每行显示字节数:2字节。

逐行式的意思很好理解:把16×16的网格按行切开,每一行有16个点,正好分成两个字节,高字节对应左8列,低字节对应右8列(或者反过来,取决于“高位在前/低位在前”的设置)。整个汉字数据就是32字节,按行顺序排列。

3.2 字模寻址:GB2312区位码与字库偏移计算

如果你做的是固定显示几个汉字(比如“电子技术设计”),最省事的做法是提前用取模软件把这些字逐一导出来,生成一个C语言数组。代码里不需要任何字库,直接查数组下标就能获取字模。

但如果想实现“任意汉字动态显示”,比如串口发来一个汉字编码,屏幕立刻显示出来,那就需要外挂字库文件,并懂得从字库中定位字模。以GB2312编码为例,一个汉字在电脑里占两个字节:高字节是区码+0xA0,低字节是位码+0xA0。标准16×16点阵字库文件(HZK16)按区位码顺序排列,每区94个汉字,每个汉字32字节。要读某个汉字的字模,C语言计算偏移量的公式是:

// code_h为机内码高字节,code_l为机内码低字节 unsigned long offset = ((code_h - 0xA1) * 94 + (code_l - 0xA1)) * 32;

这个公式不需要死记,理解一次:区码从0xA1开始对应第1区,减0xA1后得到“第几个区”(从0开始),一区有94个汉字,乘94得到区偏移,再加上位偏移,最后乘以32字节就是字模在文件里的位置。拿着这个偏移去读字库文件,读出32字节就是完整的字模。我当时做串口显示功能时,就是把HZK16文件烧录到外部Flash里,程序启动时把它读取到缓冲区,再按偏移寻址。

3.3 取模顺序和硬件接线的对应关系

这是整个设计里最容易翻车的一环,单独拿出来说。

取模软件生成了32字节,但这32字节怎么跟硬件“对上号”,取决于三件事:取模软件的取模方向、595的数据移位顺序、屏幕模块的实际物理排列。三者只要错一个,显示出来的汉字就是镜像、旋转90度或者完全乱码。

我以自己最常用的一套组合为例:

  • 取模:逐行式,阴码,高位在前;
  • 硬件:2片595级联,先移入的那片接屏幕左侧8列,后移入的接右侧8列;
  • 数据发送:先发送左8列对应字节的bit7~bit0(bit7对应最左列),再发送右8列对应字节的bit7~bit0。

这样第一个字节的最高位正好落在屏幕左上角的LED上。按照这个对应关系,字符数据和屏幕位置就绑死了。如果你改了取模设置或者换了屏幕接线,先修改代码里的移位顺序,而不是把32字节在数组里手动重排——手动重排又慢又容易出错。

验证方法建议自底向上:先用代码固定点亮第一行的第0列,确认坐标原点位置,再点亮第0行第15列,确定横向范围;确认无误后再去显示汉字字模。坐标都没对准就上汉字,出了问题根本分不清是取模错还是扫描错。

4. 扫描刷新与显示逻辑:核心代码实现

4.1 显示缓冲区的设计:数组为什么是char[2][16]还是char[32]

在写扫描代码之前,先想清楚数据组织方式。最直接的做法是把字模数组当作显示源,扫描第row行时,从32字节中取第row行对应的2个字节,送给595。

但这样做有一个问题:如果屏幕要显示的内容不是固定的一个汉字,而是“欢迎光临”四个字轮播、或者左右滚动、甚至后续要做贪吃蛇游戏,直接操作字模数组会让逻辑很混乱。我更推荐引入显示缓冲区:

unsigned char display_buf[16][2]; // 当前帧的16行,每行2字节

主程序(或游戏逻辑)负责往display_buf里写数据,而扫描中断只负责把display_buf的内容刷到屏幕上。两者解耦之后,显示逻辑和业务逻辑互不干扰。想显示汉字,就把某个汉字的32字节copy到display_buf;想让汉字左移,就重新计算display_buf;想让屏幕显示贪吃蛇,就按游戏状态填充display_buf。扫描代码永远不变。

4.2 定时器中断驱动的逐行扫描流程

扫描过程强烈建议放在定时器中断里。如果放在主循环里,一旦主程序执行较长的耗时操作(比如串口发送、按键消抖延时),扫描就会卡顿,屏幕立刻闪烁。用定时器中断保证扫描周期固定,是显示稳定的前提。

以STC89C52为例,我使用定时器T0,设置为1ms中断一次,每次中断扫描一行。核心代码如下:

sbit DS = P1^0; // 595数据线 sbit SHCP = P1^1; // 595移位时钟 sbit STCP = P1^2; // 595锁存时钟 unsigned char code hanzi[][32] = { // 这里放置取模软件生成的字模数据 }; unsigned char display_buf[16][2]; unsigned char row = 0; void Hc595SendByte(unsigned char dat) { unsigned char i; for (i = 0; i < 8; i++) { DS = (dat & 0x80) ? 1 : 0; dat <<= 1; SHCP = 0; SHCP = 1; // 上升沿移入一位 } } void Timer0_ISR(void) interrupt 1 { unsigned char dat_h, dat_l; TH0 = 0xFC; // 重装初值,1ms定时 TL0 = 0x18; // 1、消隐:先将154的所有输出关闭,避免行切换瞬间旧数据继续亮 P2 = 0x00; // 假设P2低4位接154地址,先把地址清掉 // 更稳妥的做法是给154输出加一个OE控制脚,这里用地址切换临时消隐 // 如果不做消隐,行切换瞬间会产生“拖尾残影”,下文会细说 // 2、送列数据:先送右8列还是左8列,取决于你的接线 // 假设先移入的是左侧8列对应的字节 Hc595SendByte(display_buf[row][0]); // 左8列 Hc595SendByte(display_buf[row][1]); // 右8列 // 3、595锁存输出 STCP = 0; STCP = 1; // 4、154选通当前行 P2 = row; // P2低4位作为154的地址输入 // 5、指向下一行 row++; if (row >= 16) { row = 0; } }

这段代码的注释我写得很详细,因为每一步都有明确作用。消隐放在最前面:先把154地址清掉,让上一行先熄灭,再送新行数据,最后才选通新行。顺序搞反的话,换行瞬间屏幕会出现一条淡淡的斜向亮带,俗称“鬼影”。

4.3 刷新率的计算与消隐处理

上面代码的定时器是1ms中断一次,一次扫一行,16行扫完需要16ms,刷新率62.5Hz。这是稳定不闪烁的底线值。

如果把定时器改成2ms中断一次,刷新率就掉到31Hz,人眼会明显感觉到整屏在闪,尤其用手机拍屏幕时会有黑色横纹滚动。所以我的经验是:定时器中断周期不能大于1.5ms;如果MCU速度足够,用750µs甚至500µs刷新,亮度均匀性和稳定性会更好。

消隐环节再强调一次。595是锁存输出,锁存后的数据不会自动变化;154选通某一行后,该行会一直亮着,直到下次切换。如果下次切换时先送新数据、再关旧行,那么在新数据移位过程中,旧行一直处于点亮状态,595输出端的数据又在新旧之间跳变,反映到屏幕上就是上一行的内容短暂残留、边缘发虚。正确做法永远是“先灭、再送数、最后点亮”,三步顺序不能乱。有些成熟方案会给154加一个使能引脚,直接用一个全局开关控制消隐,效果更干净。

5. 实物调试踩坑实录:这些坑我当年都踩过

5.1 装配后整屏不亮或全亮的排查链路

焊接完电路、烧录程序,第一次上电的瞬间往往是问题最多的时候。整屏不亮是最常见的情况,按我自己的排查顺序,基本可以定位九成问题:

  1. 先量电源:确认VCC和GND之间电压正常(5V),用万用表在电源入口处测,不要只信电源适配器上的指示灯;
  2. 再量行驱动:用示波器或万用表量154的输出脚,看有没有行选通波形。如果没有波形,检查154的4个地址输入是否真的来自单片机,地址线有没有虚焊;
  3. 再看595:用示波器量595的SHCP移位时钟和STCP锁存时钟,确认有脉冲。没有脉冲就检查3根控制线是否接对;
  4. 最后查限流电阻:很多同学把电阻阻值选得过大(比如10kΩ),整行电流被限制到不足1mA,LED几乎不亮。16×16点阵的列限流电阻,我一般用220Ω~470Ω,具体按LED压降和工作电流算。

全亮的情况则多半是行选通极性反了。154输出高电平有效,你却用了NPN三极管做行驱动,导致所有行同时导通;或者把列数据取反了,595输出全0时本应全灭,结果因为共阴共阳接错变成了全亮。这种问题用万用表逐行测行线电压很快就能定位。

5.2 亮度不均与闪烁:刷新率、驱动能力和电源纹波

显示能出来了,但某些行明显更亮或者全局闪烁,这是第二阶段的典型问题。

亮度不均最常见的原因是每行点亮时间不一致。如果扫描周期不均匀,比如主循环里有人用了延时函数,或者定时器中断里处理了太多耗时指令,某行的点亮时间比其他行长,这一行就会特别亮。解决思路是把扫描代码精简,尽量少在中断里做复杂运算;字模指针的取用、位运算都要提前算好,中断里只做赋值和移位。

另一个容易被忽略的原因是电源。点阵屏在扫描瞬间的峰值电流变化很快,如果电源线太细、太长,或者用电脑USB口供电,线路压降和电源纹波会让LED供电电压在每行切换时波动,表现也是闪烁。我调试时习惯把电源换成5V/2A以上的独立适配器,电源线尽量短粗,必要时在电源入口并联一个100µF电解电容和0.1µF陶瓷电容。

595驱动能力不足也会导致亮度问题。一片595一共8个输出口,每个口灌电流约20mA,但如果16列同时点亮,2片595的总灌电流可能超过300mA,接近芯片极限。这时候就会出现整屏暗、或者只有少数LED亮的情况。增加ULN2803做第二级驱动后,亮度和稳定性立刻改善。

5.3 残影和拖尾:行切换瞬间没消隐导致的鬼影

残影问题往往在显示动态内容时暴露得最明显。比如让汉字向左滚动,屏幕边缘会出现一层薄薄的“影子”,像近视眼没戴眼镜看霓虹灯。

根本原因是行切换时没有消隐。具体机制前面说过:595的输出是锁存的,上一行的数据在切换到下一行之前如果没有被清除,就会在极短的时间内继续显示在旧行上。虽然这个时间很短,但由于LED对电压变化是即时响应的,人眼对高速变化的边沿很敏感,残影就产生了。

解决手段有两个层面。硬件上,给154的OE(输出使能)引脚接一个单片机I/O,在切换行之前把OE拉高关闭输出,切换完成后再拉低恢复,这是最彻底的消隐。软件上,至少要在切换前先把154地址清掉,或者把595输出清零,让屏幕黑一下再刷新下一行。我用过最省事的方法是加一个全局变量disp_enable,每次进入中断先禁止输出,送完数据再允许输出,效果和无额外I/O的方案相比干净得多。

6. 从汉字显示到贪吃蛇:同一个硬件的进阶玩法

6.1 为什么点阵屏适合做贪吃蛇

课程设计做完汉字显示,很多同学会被问到一个加分的扩展问题:能不能在16×16点阵上做个贪吃蛇小游戏?这个问题看似跳出了“汉字显示”,实际上是对整个系统能力的一次综合考验。

16×16的分辨率做贪吃蛇非常合适:游戏区域一共256个格子,蛇身、食物、边界都天然映射到256个点位上。每一帧游戏画面本质上就是一个16×16的位图,和汉字字模的数据格式完全一致——都是32字节,都是按行排列。所以底层扫描代码、595驱动、154行选通全部不用改,只需要改游戏逻辑里的缓冲区内容。

6.2 游戏状态与显示的分离:缓冲区思维

贪吃蛇的框架,我建议严格遵循“逻辑更新”和“画面渲染”分离的模式。游戏维护一张16×16的状态表,可以用一个16字节数组表示256个点,每个点用2位表示状态:0空格、1蛇身、2食物。每帧逻辑更新后,把状态表翻译到display_buf,扫描中断照常工作。

伪代码如下:

unsigned char game_map[16]; // 每个bit表示一个格子,简化版用16字节表示256点 unsigned char snake[100]; // 存蛇身每个点的坐标,最多99节 unsigned char food; void GameUpdate(void) { // 1、根据方向键计算蛇头新位置 // 2、判断是否撞墙、撞自己,是则游戏结束 // 3、判断是否吃到食物,吃到则蛇身加长、重新生成食物 // 4、移动蛇身(数组移位) // 5、把整个game_map重绘到display_buf }

这个结构的好处是,游戏逻辑完全不关心硬件细节,即使以后换用OLED屏或者串口屏幕,只需要改“渲染”那一步的代码。很多同学贪吃蛇做出来一卡一卡的,就是因为把游戏逻辑直接写在了显示中断里,每个1ms中断都在做蛇移动判断,导致刷新周期被拉长到几十毫秒。分开之后,游戏逻辑放在主循环里,用50~100ms间隔作为移动节奏,显示中断保持1ms刷新,系统就稳了。

6.3 按键、刷新与游戏逻辑的时间片分配

16×16贪吃蛇的按键一般用4个独立按键或者一个摇杆。这里有一个时间片的分配问题:

  • 1ms任务:扫描显示(定时器中断);
  • 10ms任务:按键扫描和消抖;
  • 100ms任务:蛇移动一次、碰撞检测、食物生成。

在51单片机上实现时,我会用几个软件计数器:定时器中断每1ms进一次,维护一个tick变量累加;主循环里判断tick,tick达到10执行按键扫描,达到100执行游戏更新。这套“时间片轮询”的写法虽然看起来多花了几行代码,但它让每个任务都有确定的时间槽,不会互相干扰。

如果用STM32版本,思路完全一样,只是可以用硬件定时器做1ms基准,按键可以用外部中断,游戏逻辑放在主循环或RTOS任务里。STM32主频更高,还能顺带把LED亮度用PWM调一调,让蛇身和食物用不同亮度区分,观感完全不一样。

很多初学者把点阵项目做完就扔一边了,我个人觉得有点可惜。这个题目真正的价值在于它把“数据结构”“硬件驱动”“时间调度”三样东西揉在了一起。你先把单点点亮,再扫描出行,然后叠加字模数据,最后跑起贪吃蛇,每一步都能看到上一层的积累在起作用。我做这类项目最大的体会是:别急着炫技,先把每一层的“为什么”弄明白,后面所有扩展都是水到渠成的事。如果你正在做这个题目,遇到显示乱码先检查字节顺序,遇到闪烁先量刷新率,遇到乱亮先查共阳共阴——这三板斧下去,八成问题当场就能解决。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询