
做了这么多年计算机组成原理相关的实验我一直觉得“汉字显示”是比“乘法器”“ALU”更让人头疼的东西。英文和数字有ASCII一张7位编码的表就能搞定可一到中文编码方式、点阵字库、区位号、内码这些概念全搅在一起不少人在Logisim里折腾一晚上最后连个“汉”字都点不亮。这篇就打算用Logisim把GB2312汉字字库的完整搭建过程讲透从编码原理到点阵存储从生成字库文件到Logisim连线显示全部手把手走一遍。标题里提到的“完整电路图”我这次不会直接甩一张大图了事而是把每一段电路的连接逻辑拆开写清楚。因为根据我的经验字库电路的核心不是“图好看”而是“地址算得对”。只要理解了内码到ROM地址的换算剩下全是连线活。这篇文章适合正在做单周期CPU、数字逻辑课设或者对GB2312编码和点阵字库感兴趣的读者——哪怕你之前完全没碰过Logisim跟着走也能把字库跑起来。1. 为什么要自己搭一个GB2312字库先说说我为什么非要折腾这个。前两年带学生做单周期CPU课程设计很多同学做到最后一步“在屏幕上显示自己的名字缩写”时卡住了。英文字母好办做一个8x8或者16x8的ASCII字库ROM就完事中文就麻烦了Windows里那些字体文件是矢量字库Logisim根本读不了更别说还要处理GB2312编码的区位换算。其实中文显示在硬件层面特别“笨”——它不需要你认识汉字只需要你按照编号去查一张大表查到这个字的“点阵图”然后一行一行点亮LED就够了。这和现实生活里查字典很像字典里的字是按拼音或部首排序的每个字有个固定的页码和位置计算机里的汉字是按GB2312的区位码排序的每个字有个固定的“区”和“位”根据这个坐标就能在字库里找到它对应的16x16点阵。那为什么非要用Logisim搭一遍我觉得有三个实际价值彻底搞懂GB2312编码。很多人背过“GB2312内码等于区位码加0xA0A0”但完全不知道为什么要加、加完怎么用。搭一次电路这个公式就再也忘不掉了。给自制CPU/显示器模块提供可复用的汉字输出单元。Logisim里做CPU实验最后多半要接一个显示设备。有了字库ROM你就可以用16位内码作为输入让电路输出对应的点阵数据等于给你的CPU装了一个“中文显卡”。为真实的单片机/OLED项目打基础。你要是以后玩STM32、ESP32驱动OLED屏原理一模一样往字库里查点阵往屏幕上刷像素。区别只是平台不同逻辑完全通用。所以这篇文章虽然用的是Logisim但学会的东西放到真实硬件上一样成立。这恐怕是很多“纯软件”教程给不了你的收获。2. 核心原理从GB2312编码到点阵存储2.1 GB2312到底是怎么给汉字编号的GB2312是一个双字节编码方案把可显示的字符放在一个94行乘94列的大表格里。行叫作“区”列叫作“位”。每个字符用“区号位号”定位比如“汉”字的区位码是2626意思就是第26区第26位。但是计算机里不能直接传十进制区位码所以GB2312规定了一种“内码”表示把区号和位号分别加上0xA0。为什么要加0xA0因为要避开ASCII码里0x00到0x7F的可显示区域和控制字符保证中英文混排时不会冲突。这样一来“汉”字的区位码2626加上0xA0A0之后内码就是0xBABA。你可以验证一下0x26 0xA0 0xBA两个字节都是BA所以很多老程序员一看到BABA就知道是“汉”字。GB2312共收录了6763个汉字分为两级一级汉字3755个放在16区到55区按拼音排序。比如16区01位是“啊”内码0xB0A1这也是绝大多数人验证GB2312时用的第一个字。二级汉字3008个放在56区到87区按部首排序。1区到9区是各种符号、数字、拉丁字母10区到15区暂时空着。这个分布非常关键因为后面算ROM地址全靠它。我们只需要关心16区到87区这72个区的汉字部分。2.2 16x16点阵字库的存储格式一个16x16的汉字点阵就是用一个16行乘16列的网格来描述字形有笔画的格子记为1没笔画的格子记为0。这样一个字就需要256个二进制位按8位一个字节算正好是32个字节。这32个字节怎么排列最经典的HZK16字库采用“逐行存放”的方式每个汉字占32字节分成16行每行2字节第r行的两个字节存放在缓冲区偏移r2和r21的位置。其中第r2字节表示这一行左半部分8个像素第r21字节表示右半部分8个像素。每个字节里最高位bit7对应最左边的像素点。举个例子如果某一行左半字节是0xFF说明这一行左边8个点全亮如果右半字节是0x80说明右边第1个点亮后面7个点灭。这里的“左边第1个”就是该行第9列。这里有个容易踩坑的地方网上有些取模软件生成的点阵是“纵向取模”或者“先右后左”和HZK16的排列方式不一样。如果你拿到的字库显示出来左右颠倒或者上下颠倒通常不是电路问题而是字节序或者位序的问题。我在第7章会专门讲怎么处理。2.3 从内码到ROM地址的换算现在关键来了。既然字库文件里每个汉字从16区开始按顺序存放那么给定一个汉字的区号qu和位号wei它在字库文件中的字节偏移就是offset ((qu - 16) * 94 (wei - 1)) * 32公式的含义是先算出这个汉字排在所有汉字里的第几个从0开始再乘以每个汉字占用的32字节。因为区号从16开始一级汉字和二级汉字连续排列56区会无缝接在55区后面这个公式对全部汉字都适用。但是在Logisim电路里我建议ROM的数据位宽设成16位而不是8位。为什么呢因为16x16点阵的每一行正好是16个像素用一个16位的字来表示一行再合适不过。这样每个汉字在ROM里占用的就不是32个字节而是16个地址——每个地址存一行。换算关系就变成了rom_addr ((qu - 16) * 94 (wei - 1)) * 16这个乘法看起来复杂但拆开一点都不难。我们的电路实际上要做这么几件事输入内码高字节减0xA0得到区号qu输入内码低字节减0xA0得到位号wei计算(qu - 16)计算(wei - 1)前者乘94再加后者得到汉字序号序号乘16得到ROM地址。这样一共需要两个减法器、一个乘法器、一个加法器再加一个乘法器或者左移4位电路结构非常清晰。先把这段话记住后面第4章就是照着这个思路连线。3. 数据准备用HZK16或Pillow生成Logisim字库文件3.1 方案一读取现成HZK16字库推荐我自己最推荐的方法是直接找一个现成的HZK16字库文件。很多嵌入式开源项目里都附带这个文件像U8g2、各种LCD例程包里基本都有。它的好处是点阵数据经过长期验证笔画清晰不用自己调渲染参数。拿到HZK16之后我们只需要用Python写一个小脚本按区位码定位并读取点阵再输出成Logisim的.data格式即可。下面这个脚本我实测过逻辑很直接def read_hzk16(qu, wei, filepathHZK16): # 计算该汉字在文件中的字节偏移每个汉字32字节 offset ((qu - 16) * 94 (wei - 1)) * 32 with open(filepath, rb) as f: f.seek(offset) buf f.read(32) rows [] for i in range(16): left buf[i * 2] # 行左半8个像素 right buf[i * 2 1] # 行右半8个像素 row (left 8) | right rows.append(row) return rows # 生成整个GB2312汉字区16区~87区的Logisim字库文件 with open(gb2312_16.data, w) as out: for qu in range(16, 88): for wei in range(1, 95): rows read_hzk16(qu, wei) for row in rows: out.write(str(row) \n)这段代码输出的gb2312_16.data每行是一个十进制数范围在0到65535之间。整个文件有72个区乘94个位乘16行约10.8万行正好对应ROM地址从0到108287。需要注意55区最后几个空位在标准HZK16里也占存储空间所以不用跳过否则地址会错位。这个细节我一开始没注意结果后面所有的字都偏移了排查了大半天。脚本里(left 8) | right这一行的含义要理解清楚left是这一行左边8位把它放到16位数字的高8位right是右边8位放到低8位。如果某个HZK16变体是先右后左存储就把left和right换一下。3.2 方案二用Pillow从系统字体渲染备选如果你一时找不到HZK16或者想用自己电脑里的字体生成字库那可以用Pillow库把汉字渲染成位图再转成点阵。这个方法比较灵活但需要调整的参数多一点。核心思路是用TrueType字体把汉字画到一张大图上然后缩小到16x16并二值化。from PIL import Image, ImageDraw, ImageFont def render_char(ch, font_pathC:/Windows/Fonts/simsun.ttc): # 先渲染到64x64的大图避免字号太小时笔画粘连 big Image.new(L, (64, 64), 255) draw ImageDraw.Draw(big) font ImageFont.truetype(font_path, 48) draw.text((6, 6), ch, fontfont, fill0) # 适当偏移让字形尽量居中 small big.resize((16, 16), Image.LANCZOS) px small.load() rows [] for y in range(16): row 0 for x in range(16): if px[x, y] 128: row | (1 (15 - x)) rows.append(row) return rows这里有几个经验点。第一不要直接用16号字体渲染再取点那样笔画很容易糊成一片而且字形位置不好控制。先渲染到64x64再缩放到16x16相当于做了个简单的抗锯齿最后二值化出来的点阵边缘会干净很多。第二阈值128的意思就是灰度小于128的像素都算作“有笔画”如果你用的字体笔画比较细可以适当把这个阈值调小。渲染方案的好处是你可以随意换字体。缺点是TrueType字体矢量轮廓和真正的16x16像素字体在笔画布局上不完全一样个别字可能会有1到2像素的偏移。如果只是做Logisim实验完全够用但如果你追求那种老式宋体点阵字的规整感我还是建议用HZK16。3.3 生成文件格式的几个注意事项Logisim的ROM可以直接加载两种格式Intel HEX.hex和Logisim自己的.data格式。我们这里生成的就是.data格式。关于这个格式我踩过几个坑提醒一下每行只能有一个数值不能有逗号、空格、注释。十进制、十六进制、带0b前缀的二进制都可以识别但为了保险我建议统一用十进制。文件最后一行之后不要留多余的空行Windows记事本保存时如果自动加了换行符一般没问题但如果保存成UTF-8带BOMLogisim某些版本会报错。所以我推荐用VS Code或Notepad来生成和检查文件。另外如果你只是想先做一个通关测试不着急生成全部6763个字可以只生成“啊”、“汉”、“中”三个字或者干脆只生成前10个汉字。这样ROM地址位宽设置成6位就够看起来更清爽。等全部逻辑通了再加载完整字库。4. Logisim电路搭建把内码翻译成ROM地址4.1 版本选择与工程准备Logisim有几个常见版本传统Logisim 2.7.1功能稳定但组件偏旧Logisim-evolution是目前维护最活跃的分支界面更友好还自带中文翻译和LED矩阵组件。我的建议是直接用Logisim-evolution后面第6章讲到动态扫描显示时它的LED Matrix组件会省很多事。新建工程后打开“项目”菜单里的“电路”面板默认有一个main电路。如果要保持思路清晰我习惯把整个字库电路分成三个子电路addr_calc地址计算、rom_storageROM存储、display显示。不过为了这篇教程好理解我下面还是以单电路的方式来讲你只要按模块分区布线效果一样。4.2 组件清单先把你需要的组件摆好在画布上下面是完整清单组件位置/库位宽或参数用途输入引脚 Pin x2输入/输出库8位输入内码高字节和低字节常量 Constant x2导线库8位值1600xA0参与减法减法器 Subtractor x2算术库数据位8位内码减0xA0得到区号、位号常量 Constant x2导线库8位值16 和 1计算偏移减法器 Subtractor算术库数据位8位区号减16位号减1乘法器 Multiplier算术库数据位8位区偏移乘94加法器 Adder算术库数据位16位区偏移和位偏移相加乘法器 Multiplier算术库数据位16位汉字序号乘16ROM存储器库地址位宽17数据位宽16存放点阵探针 Probe导线库16位调试用LED x16输入/输出库无查看输出这个组件清单其实不复杂核心就是“减法、乘法、加法”这几件事。但正因为组件少所以每一根线的连接都值得仔细核对。4.3 地址转换电路一步步把内码变成ROM地址下面把地址计算电路拆成4个阶段每一阶段我都给出明确的接法。第1步内码拆分与减0xA0把内码高字节引脚和高字节常量0xA0分别送入第一个减法器的A端和B端输出就是区号qu。内码低字节同理输出就是位号wei。这里要特别检查减法器的输出位宽是否够用。8位减法器的输入是8位、输出也是8位时遇到负数会显示成补码但我们这里的情况是输入内码最小0xA1对应区号1减0xA0之后最小为1不会出现负数所以8位输出完全够。第2步减去汉字区起点把区号qu接到减法器A端常量16接到B端输出就是(qu - 16)。注意这一步只对16区及以后的汉字有意义如果你输入了符号区的内码比如0xA1A1结果会是负数最终查出来的点阵就不对。这是正常现象GB2312符号区我们没有做进去。位号wei同理接减法器减去常量1得到(wei - 1)。第3步乘94加偏移把(qu - 16)送入乘法器A端常量94送入B端输出就是(qu - 16) * 94。然后把结果送到加法器A端(wei - 1)送到B端输出就是汉字序号index。index的范围是0到6762理论需要13位二进制加法器输出设成16位足够。这里有个实战技巧乘法器在Logisim里的输出位宽默认是两个输入位宽之和。比如8位乘8位输出就是16位。这个默认其实很好不用手动改。只要记得加法器输出也设成16位以上就不会出现数据被截断的问题。第4步汉字序号乘以16得到ROM地址最后一步把index送入第二个乘法器A端常量16送入B端输出就是ROM地址。这一步等价于把index左移4位。有的同学为了省乘法器喜欢用移位器或者直接把index的低位拼接4个0效果一样但我还是建议用乘法器因为含义最直白出了问题也好排查。ROM地址理论上最大是6762乘16等于108192需要17位二进制。所以乘法器输出、以及后面ROM的地址位宽都必须设置成17位。这个地方我见过太多人翻车乘法器输出默认16位结果一超过65535就溢出汉字全乱套。4.4 完整连线表为了让你照着搭不出错我干脆把这个电路画成一张“文字版连线表”每一行是一根关键连线的去向源目标说明内码高字节Pin减法器1的A端高字节常量160减法器1的B端0xA0内码低字节Pin减法器2的A端低字节常量160减法器2的B端0xA0减法器1输出减法器3的A端区号qu常量16减法器3的B端减去16减法器2输出减法器4的A端位号wei常量1减法器4的B端减去1减法器3输出乘法器1的A端qu-16常量94乘法器1的B端乘94乘法器1输出加法器1的A端区偏移减法器4输出加法器1的B端位偏移加法器1输出乘法器2的A端汉字序号常量16乘法器2的B端乘16乘法器2输出ROM的A端17位地址ROM的D端16个LED或LED Matrix16位点阵数据把这8行线连完整个字库电路的“骨干”就已经完成了。下一步就是给ROM加载数据文件然后把输出接上显示设备验证。5. ROM配置与单行验证5.1 设置ROM参数并加载字库双击画布上的ROM组件在属性面板里设置两个关键参数Address Bit Width设为17Data Bit Width设为16。然后找到“Image”这一项点击后面的“Load Image…”按钮选择我们刚才生成的gb2312_16.data文件。加载成功后你可以点一下菜单栏的“仿真”按钮把输入引脚设置成想要的汉字内码然后用探针看ROM的地址输出。我先拿“啊”字做测试因为它是16区01位算出来的地址应该是((16-16)*94 0) * 16 0文件里的第一个字就是它非常好验证。“汉”字的内码是0xBABA所以高位引脚设成0xBA低位引脚设成0xBA。算一下地址qu26wei26(26-16)*94 25 965再乘16等于15440。你用探针看到这个数就说明前面电路算对了。5.2 用16个LED验证一行点阵ROM加载后从D端引出一根16位总线接一排16个LED。因为有16个LED需要把总线拆开。方法是在总线和LED之间放一个Splitter组件把16位总线拆成16根单线再分别连到LED的输入端。现在ROM输出的永远是“当前地址对应的那一行”的数据。一开始地址不是0而是“汉”字算出来的15440这样LED显示的是“汉”字第0行的16个像素。你可以来回切换高字节引脚看LED亮灭的变化。如果没有LED矩阵这种“一次看一行”的方法也足够验证数据对不对了。我个人习惯在ROM的D端再接一个Probe探针这样不用看LED直接看16位数值就能和脚本输出的数据核对。比如脚本里“汉”字第一行如果算出来是十进制12345Probe显示12345说明ROM加载和地址计算都正确对不上就先查地址再查文件。5.3 常见验证误判用LED验证时最容易产生一个误解当你输入“汉”字内码16个LED只亮了一行看起来不像汉字就以为电路错了。其实这不是错而是ROM本来就只输出了一行。你要做的是循环改变“第几行”这个信息让ROM依次输出第0行、第1行……第15行。这就引出了第6章的动态扫描。还有一种情况是LED亮的位置明显反了比如左右镜像。这通常是字库数据生成时bit位顺序和LED电路的实际接法不一致。解决办法是把输出总线的某几位交换或者在脚本里对每一行的16位数据做一次位反转。用Python处理的话就是int({:016b}.format(row)[::-1], 2)非常简单。6. 动态扫描一行一行把字刷出来6.1 为什么需要动态扫描现在ROM能输出任意一行的数据了但我们要让人眼“看到”一个完整的汉字就得在一瞬间把16行数据全部显示出来。最直接的想法是用16个16位的寄存器把16行全部存下来再同时输出到16x16的LED阵列。但这样电路会非常庞大而且Logisim里操作起来也麻烦。工程上更常用的做法是动态扫描让所有行的LED共用一个数据端口每次只点亮一行对应的LED然后快速循环从第0行扫到第15行。只要循环速度够快人眼的视觉暂留效应就会把16次闪烁“合成”成一个完整的汉字。你手机屏幕其实也是这么刷新的只是刷新率更高而已。6.2 用计数器驱动行号动态扫描的电路只需要在原电路上增加三样东西一个4位计数器、一个加法器、一个时钟源。计数器从0计数到15它的输出就代表当前要显示的行号。加法器的两个输入分别是“汉字起始地址”也就是第4章算出来的ROM地址和“计数器行号”输出作为ROM的新地址。这样一来随着计数器的增长ROM会依次输出汉字第0行的数据、第1行的数据……第15行的数据周而复始。在Logisim里添加Counter组件后记得把数据位宽设为4位然后把“Maximum Value”设为15确保它数到15之后回到0。时钟可以选择“Clock”组件然后接计数器的CLK端。仿真时如果发现灯闪得太快看不清就把时钟频率调低一点如果看到图像在闪烁、有断层感就把频率调高一些。Logisim的时钟频率在“仿真”菜单的“Tick Frequency”里设置一般300到500Hz是一个比较舒服的区间。6.3 显示设备LED Matrix还是手动LED阵列显示部分有两种方案取决于你用的Logisim版本。如果你是Logisim-evolution画布上直接添加一个LED Matrix组件行数设16、列数设16。然后需要把ROM输出的16位数据按位拆分接到LED Matrix对应的行数据引脚上。这个组件还支持颜色设置我当时把默认的红色改成绿色看起来更有“老式点阵屏”的味道。如果你是传统Logisim 2.7.1没有LED Matrix那就只能手动摆16x16个LED。摆放的时候有个技巧每行16个LED排成一条16行之间留一点间距然后每一行内部用Splitter把总线拆开16行的输入分别接一个多路选择器的输出。这样比把256个LED全部单独连线要简洁得多。不过说实话手动摆256个LED很容易头大所以我更建议用Logisim-evolution。6.4 动态扫描的验证效果扫描电路搭好后输入“汉”字的内码0xBABA你会看到LED矩阵上稳定地显示出一个“汉”字。这时候可以再试试“啊”字的内码0xB0A1显示的是“啊”字。一个字一个字的切换内码就相当于在做一台“单字显示器”。有个细节值得注意动态扫描时如果计数器频率太低会看到明显的“扫描条”——同一时刻只有一行在亮然后这一行在屏幕上往下跑。这不是电路接错而是刷新率不够。把Tick Frequency调高之后扫描条就会消失。如果你觉得一行一行扫过去还是太闪烁还有一个更“豪华”的方案用16个16位寄存器把整个汉字的16行数据全部锁存住然后再用16x16的LED矩阵同时输出。这样就不需要动态扫描静态就能稳定显示。代价是电路规模会大不少我觉得做实验没必要但如果你想挑战一下自己完全可以试试。7. 常见问题与排查表这个电路算不上复杂但我在带学生做实验的过程中几乎每次都会遇到下面这些问题。我整理成一个排查表你照着对号入座就行现象可能原因解决办法ROM加载后所有输出都是0文件路径错、格式错、ROM地址位宽不对先看Probe显示的地址值确认文件是纯数字文本且ROM的Image选项确实加载成功输入“汉”内码LED完全没反应内码输入引脚位宽不是8位或者高位低位接反检查两个Pin的位宽交换高低字节试试显示出来的字左右颠倒点阵字节左右顺序反了在脚本里对16位数据做位反转或者交换每行两个字节的位置显示出来的字上下颠倒字库文件的行存储顺序是“下半部分在前”让计数器从15倒计数到0或者调整ROM数据的行顺序所有字都对但整体偏移一个区块55区空位没有保留或HZK16版本不标准确认字库文件是标准全区位版如果是精简版需要改偏移公式ROM地址永远不对数值特别大乘法器或加法器输出位宽不够高位被截断ROM地址相关连线全部统一为17位乘法器输出位宽要大于16位动态扫描时画面闪烁严重时钟频率太低在Tick Frequency里把频率提高到300Hz以上扫描条很明显一明一暗往下滚计数器没有设置最大值数到15之后继续往上加把Counter的Maximum Value设为15或者用比较器让它到15自动复位除了表格里的问题我还想分享一个排查思路。不要一上来就盯着整块电路看把电路拆成“地址计算”和“数据输出”两段分别验证。地址计算段你用Probe看最终送进ROM的地址值数据输出段你手动用一个Constant作为ROM地址比如固定成0看看输出的是不是“啊”字的第一行。这样二分定位比从头到尾查线快得多。另一个我很推荐的调试习惯是先把ROM地址位宽改小加载一个迷你字库文件只含几个字确保小范围逻辑正确再换全量字库。很多人一上来就要显示“中华人民共和国”结果全量文件10万多行ROM加载慢不说出了问题也很难定位。先用1到2个字跑通后面换全量就是水到渠成的事。8. 实操心得与扩展方向我在实际使用中发现这套电路最大的价值不只是显示汉字而是它把“编码”和“硬件查表”这两个概念打通了。你写完这个字库电路之后再去看GB2312、甚至看UTF-16的编码规则会感觉它们都只是“查表地址计算方式不同”而已。GB2312需要先减0xA0再乘94UTF-16如果按线性排列可能连减法器都省了。如果你做完这个实验还想继续扩展我建议往这几个方向走加入符号区支持。GB2312的1到9区还有各种标点符号扩展电路只需要在地址计算前加一个区号判断如果区号小于16走符号区地址计算否则走汉字区。ROM里也对应补充这些符号的点阵数据。支持24x24或32x32点阵。做大字号显示时每个汉字占的字节数变多ROM地址的乘法系数也要跟着变。这个改起来不复杂但会让你对“字号和存储容量”的关系有更深的理解。把字库接到你自制的CPU上。如果你的Logisim单周期CPU已经能执行指令试着加一条“输出汉字内码”的指令让CPU把内码写到某个寄存器然后由字库电路自动查表送显示。到了这一步你就等于做了一个最小化的“中文显示终端”很多东西都可以在这个基础上玩了。我个人的体会是像GB2312字库这种东西看起来知识点很杂又是编码又是点阵又是ROM但真把它拆开每一步逻辑都很朴素。只要把“内码到地址”这条主线抓在手里剩下的都是水到渠成的事。希望这篇教程能帮你少走几步弯路有没讲透的地方欢迎按你自己的实验环境再调一调。