说个前段时间被反复问到的场景:有人想看中文能不能写底层一点的东西,比如一个驱动、一段启动代码,而不是整天在业务层做中文变量名。这句话点醒我一个一直没落地的想法——与其纠结“怎么把Java关键字翻译成中文”,不如直接下沉到指令集这一层,把助记符本身做成中文。我花了大概三个月的业余时间,搞出来一套叫“伏羲-128”的全中文字义指令集:128条指令,每条指令由一个或两个有明确动作含义的汉字承担,配套一个汇编器、一个虚拟机,以及一套能映射到常见CPU指令的翻译模板。这东西不是什么玩具,它能跑斐波那契、能写一小段排序、能给脚本语言做后端,也可以拿来教学生理解寄存器、栈、程序计数器这些概念。
这篇文章我会把这套指令集从立项到落地的完整过程拆开讲,包括128这个数字怎么定的、字表怎么建、汇编器怎么处理中文分词、为什么先做VM而不是直接怼机器码、踩过哪些全角符号和输入法相关的大坑。适合对编译器、汇编、指令集设计感兴趣的开发者,也适合一直在思考“中文到底能不能做底层技术”的朋友参考。
1. 立项前纠结了很久的问题:助记符中文化的本质是什么
1.1 中文编程做了几十年,为什么没人把汇编中文化到底
很多中文编程项目做的事情其实是用中文关键字替换英文关键字,比如if变成“如果”,else变成“否则”,class变成“类”。这种方案本质上是给现有语言的语法树披了一层中文皮,底层仍然是英文关键字驱动的解析逻辑。它当然有它的价值,降低了部分初学者的入门门槛,但说实话,换皮替换对理解“程序到底怎么跑”帮助不大。
真正让我觉得有空间的地方在汇编层。你看指令集设计里有一件很有趣的事:像MOV、ADD、JMP、PUSH这些助记符,它们本质上只是操作码给人看的一个名字。操作码0x01到底代表“把数据搬进寄存器”还是“把数据存回内存”,完全是芯片设计者说了算。既然助记符只是一个“给操作码起的可读名字”,那它换成“取”“存”“跳”“入”这些汉字,从原理上完全说得通。
但“把助记符翻译成汉字”和“设计一套字义指令集”是两码事。前者只是做了一张英文到中文的映射表,后者要求整个编码体系、查表逻辑、操作数规则都围绕汉字来组织。这也是我给伏羲-128定下的基本原则:不是MOV加个注释叫“移动”,而是从根上让“取”这个字负责取数语义,“入”这个字负责压栈语义,字和指令之间是真正的绑定关系。
1.2 从“句法翻译”到“字义编码”:我对指令集的理解
中文编程社区里有个争论一直没停过:如果中文名可以出现在代码里,那算不算中文编程?我的看法是,普通应用层代码里出现中文名当然方便,但那只是标识符层面的本地化。而“字义指令集”是把指令系统中的每一个符号都当成有意义的中文单元来参与编码,包括操作码、寄存器编号、甚至标志位,这些在传统体系里往往都是英文缩写。
举个具体例子。传统汇编里你会写:
asm复制MOV AX, 5
ADD AX, 1
如果只是中文化助记符,会变成:
asm复制移动 寄存器甲, 5
增加 寄存器甲, 1
但“字义指令集”的思路不一样。我不需要有一个英文助记符作为源头,指令表本身就是一套中文体系。例如伏羲-128里同样的操作写作:
text复制取 甲, 五
加 甲, 一
“取”这个字既承担了记忆作用,也承担了查表作用。汇编器看到“取”就知道操作码是0x01,看到“加”就知道操作码是0x0A,不再经过任何英文中转。这就是字义编码和翻译替换之间的本质区别。
1.3 128这个数字是怎么定下来的,以及项目名来源
很多人看到“伏羲-128”第一反应是问:128是不是代表128位?其实不是,128代表的是指令条数。我初版整理需求的时候,只统计最常用的数据传送、算术逻辑、栈操作和控制流指令,大约六七十条就够用了。但接着我意识到一个问题:如果这套指令集将来要承接函数调用、中断处理、浮点运算,甚至给一个简单脚本语言做后端,那六七十条会非常紧张。
后来我按三个层次做了词表规划。第一层是核心指令,包括常见的载入、存储、算术、逻辑、比较、跳转,大约五十条;第二层是栈帧与过程调用相关的指令,包括压栈、出栈、调用、返回等,约三十条;第三层是输入输出、系统控制、调试辅助这类“环境相关”指令,约四十条。加起来一百二左右,再加上若干预留空位组成了128。选择128还有一个工程上的理由:7位二进制就能编下,最高位留作扩展位,未来就算要增加指令也没必要改编码格式。
“伏羲”这个名字取的是八卦意象。八卦本质上是一套用阴阳爻组合表达万物的编码系统,用极少的符号演化出无穷变化,这和我对指令集的理解高度一致。伏羲在中国文化里是“画卦造字”的象征,用来命名一套以汉字为核心的指令集,挺贴切。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 128个指令字的词表怎么建:动作分组、同音查重、编码规则
2.1 按动作分组:数据传送、算术、控制流、I/O、系统
字表是整个项目的命脉。128个汉字看着不多,但真要一个个选定并且保证不重音、不歧义、不打起来,比想象中麻烦。我最终把指令字分成了六个动作组,每组有明确的语义边界:
| 分组 | 语义范围 | 代表指令字 |
|---|---|---|
| 数据传送 | 寄存器与内存之间的搬数 | 取、存、移、拷、赋 |
| 算术运算 | 整数/浮点的加减乘除等 | 加、减、乘、除、余、增、减 |
| 逻辑与移位 | 位运算和位移 | 与、或、非、异、左、右 |
| 栈与调用 | 函数调用过程 | 入、出、调、返、递 |
| 控制流 | 跳转与比较 | 跳、比、等、大、小、停 |
| I/O与系统 | 输入输出和控制 | 印、读、写、启、清 |
这份表看起来简单,但它解决了一个核心问题:每个汉字都对应一个明确的动作场景。比如“印”代表输出,选它是因为“打印、印刷”的本意里有“向外表现”的含义,而且字形简单,不容易和别的指令字混淆。“余”代表取模运算,因为它和“除”同属算术组,字义上天然有“剩下”的意思。
当时我坚持所有指令尽量单字,只有极少数语义复合的才允许用双字。比如“比较”就是一个双字指令,因为“比”和“较”单拆开使用反而容易迷惑人。双字指令的加入给后面做词法分析带来了一点复杂度,这个我放到下一章细说。
2.2 读音编码查重的坑:“压”和“淹”、“入”和“与”差点撞车
选定汉字后,我遇到的第一件头疼事情是拼音查重。最初我想用拼音首字母做一个快速检索索引,结果一查就发现问题了。比如我原本打算用“压”来表示压栈操作,理由是和“入”放在一起能组成“压入”,听起来很形象。但在索引表里,“压”读yā,“淹”也读yān的韵母部分,如果未来要做语音输入或语音调试,这两个字在识别结果里很容易被互相替换,整个调试体验会很糟糕。
类似的问题还有“入”和“与”,前者读rù,后者读yǔ,拼音差异其实挺大,但在某些方言口音里会含糊。更危险的其实是“移”和“异”,“移”在算术组里代表移位操作,“异”在逻辑组里代表异或操作,两个都读yí/yì,键盘输入时几乎完全同音。我后来统一做了一张“同音冲突表”,规则是:如果两个字拼音声母韵母完全相同,且两个指令出现在同一操作场景里的概率较高,就必须替换其中一个。
最终“压栈”这组指令定成了“入”和“出”,没有用“压”。“移”和“异”保留了“异”,把“左移、右移”合并成“左”和“右”两个单字指令,彻底绕开了“移”和“异”的同音问题。这个过程让我意识到,做一套中文指令集不能只看字面意思,一定得把读音、输入法、甚至语音识别场景都考虑进去。
2.3 为什么最终没有用“拼音简写”,而是回到字义本身上
项目做到一半的时候,有朋友建议我放弃完整汉字,改用拼音首字母缩写,比如“取数”缩写为QS,“加一”缩写为JY。理由是写起来快,也避免了中文输入法切换的麻烦。这个建议我试了两天,最后还是放弃了。
原因很简单:拼音缩写会产生大量同码。128条指令,很多指令的拼音首字母组合是重复的,比如“出栈”和“存栈”对应的首字母都是CZ,“加法”和“减法”都是JF。为了解决这些冲突,我得人为引入一套消歧规则,那等于又设计了一套新的编码语言,学习成本反而更高。而一个汉字本身就是一个完整的视觉符号,直接看到“出”就明白是出栈,看到“取”就明白是取数,不需要先把它还原成拼音再理解。
另外还有一个容易被忽略的理由:字义指令集的“字义”二字,决定了它的核心卖点就是汉字的意义携带能力。如果退回拼音缩写,那不过是换了一身中文衣服的英文缩写体系。“义”不是靠首字母能表达的。
3. 从“取 甲, 五”到0x01 0x00 0x05:汇编器的分词和编码实操
3.1 两条腿走路:字表驱动汇编器的工作流程
汇编器的实现语言我选了Python,理由很实在:生态成熟、字符串处理能力强、开发速度快,做原型验证再合适不过。整个汇编器分三条核心管线:文本清洗、词法分析、编码输出。
文本清洗这一步在中文场景下尤其重要,因为输入法会带来很多“隐形垃圾”。我遇到的情况包括全角空格、中文逗号、分号后面多了一个全角分号、行末多出的中文句号等等。清洗阶段会先把所有全角标点统一转成半角,再把全角空格替换成普通空格,最后把连续空白压缩成单空格。这里的处理原则是“宽容输入”:用户写的是中文指令,但标点符号没必要严格限制成半角。
词法分析基于一张字表驱动。字表的每一行定义一个指令字、它属于哪个分组、操作码是多少、允许的操作数类型是什么。读取一张字表生成两个核心数据结构:一个是从指令字到操作码的映射字典,一个是从指令字到操作数规则的映射字典。
编码输出阶段则根据每条指令的操作数规则生成字节序列。比如“取 甲, 五”这样的指令,汇编器会先查“取”的操作码0x01,再查寄存器名“甲”得到0x00,最后把立即数“五”转成整数0x05,拼接成三个字节:0x01 0x00 0x05。整个过程完全是查表循环,没有用到任何复杂的解析算法,但正因为简单,稳定性非常高。
3.2 单字、双字指令的匹配策略,以及“印甲”为什么不会歧义
双字指令的存在让词法分析不能简单地逐字匹配。比如“比较”是一个指令,“比”单独也是一个合法标识符的含义之一,那么输入“比较 甲, 乙”时,如果程序只做单字扫描,就会先匹配出“比”,然后继续扫描“较”,最终报错。
解决问题的思路是最大匹配法。对于每个位置,先尝试在双字指令表里查当前字和下一位字组成的双字序列,查询命中就整体合并成一个token,查询没命中就退回单字识别。这个策略在伏羲-128的指令集上运行得很好,因为双字指令数量很少,总共就“比较”“打印”“跳转”“清零”这几个,匹配开销可以忽略。
“印甲”不会歧义的原因在于操作数定义里做了明确的类型约束。“印”这个指令后面只接受寄存器或字符串字面量,“甲”是一个合法的寄存器名,所以“印甲”会被解析成“印”加寄存器参数“甲”,中间不需要空格。相比之下,“印 甲”和“印甲”都会被正确处理,这种设计极大方便了手写指令时的输入灵活性。
3.3 指令编码的bit分配:操作码、模式、寄存器编号
伏羲-128的每一条指令编码遵循统一格式,方便后续做VM执行和机器码翻译。字节码格式如下:
| 字段 | 长度 | 说明 |
|---|---|---|
| 操作码 | 1字节 | 指令字对应的编号,0x00到0x7F |
| 模式位 | 高2位 | 标识操作数类型:寄存器、立即数、内存、无操作数 |
| 操作数信息 | 剩余3位 | 标识具体寄存器编号或寻址方式 |
| 扩展操作数字节 | 按需 | 立即数、内存偏移等 |
看到这里你可能会问:操作码占了1字节,模式又复用同一字节的高位,那操作码岂不是要压缩到低6位?实际我把最高位留给了扩展标志,如果该位为1,说明后面还有扩展操作码字节,这样未来扩展指令完全不会破坏已有编码。
一个具体例子:“加 甲, 五”的编码如下。操作码0x0A代表“加”。模式位标识第二个操作数是立即数,操作数信息里包含寄存器甲对应编号0和立即数0x05。最终字节序列是0x0A 0x05 0x05,其中0x05既是寄存器甲的编号叠加模式位的结果,而第二个0x05是立即数本身。为了这条编码规则,我在汇编器的测试用例里专门写了一组“编码快照测试”,确保以后改代码不会破坏既有程序的二进制产物。
4. “汇编器只是前半场”:VM执行器与向真实CPU靠拢的翻译层
4.1 为什么先做VM而不是直接输出机器码
不少朋友看到“指令集”三个字就默认它会直接编译成机器码。我一开始也确实想过直接输出x86-64的汇编,但写了两天后发现这个方向在原型阶段是个坑。原因有三:格式差异、跨平台、以及调试困难。
先说不做直接输出的第一个原因:不同CPU的机器码格式差别太大了,x86-64的指令是变长的,一条指令可能占用1到15个字节;ARM64的指令则是定长32位。一套面向“中文表达逻辑”的指令集如果绑定到某种特定CPU的编码上,那等于自废武功,换一个平台就得重写汇编器后端。
第二个原因是跨平台调试。如果输出的是机器码,我得在汇编器里同时维护反汇编器、调试器、断点机制,工作量会翻好几倍。而做VM之后,调试只需观察指令执行状态即可,方便很多。
第三个原因是设计确认顺序问题。字义指令集的语义还在逐步打磨阶段,先做VM可以快速验证每条指令的行为是否符合预期。等指令集语义稳定下来了,再考虑输出机器码才合理。我的方案是先做一个轻量VM,同时做一层能映射到x86-64和ARM64的“翻译模板”,既保证能跑起来,也给未来接LLVM留了冗余。
4.2 case分发、内存模型与栈帧设计
伏羲-128的VM写得很朴素,本质是一个循环加一堆if-elif分发。每个时钟周期里,VM从内存中取出当前PC指向的字节作为操作码,查一张操作码到执行函数的映射表,然后执行对应的动作,最后更新PC。内存模型上,我用了三个独立空间:
- 寄存器区:8个通用寄存器,命名分别为甲、乙、丙、丁、戊、己、庚、辛。
- 栈区:一块独立的内存区域,栈指针用一个专用寄存器“栈”表示。
- 数据区:用于存放变量、数组和字符串数据。
栈帧设计上,函数调用会依次压入返回地址、旧栈帧基址、寄存器快照。这样做的好处是,将来接入真实CPU翻译层时,栈帧布局可以尽量向ABI靠拢。VM源码里我甚至加了一个“栈深度检查”,当栈指针越界时直接报错,避免递归失控导致程序崩溃到看不出原因。
性能方面,用纯Python写这个VM,不做任何优化时大约每秒钟能执行三四十万条指令。对教学和DSL场景绰绰有余,但要做真正的系统编程确实不够。所以我又加了一个“循环缓存”的优化,把最近常用的指令分发结果缓存起来,简单场景下能再快个百分之三十左右。
4.3 把伏羲字节码“翻译”到x86-64和ARM64的两套模板
VM方案能解决运行问题,但很多人在意的是“能不能直接编译到本机代码”。我设计了一套翻译层,核心思路不是逐条指令做汇编翻译,而是构建“函数级模板映射”。举几个常用对应关系:
| 伏羲指令 | 语义 | x86-64翻译示例 | ARM64翻译示例 |
|---|---|---|---|
| 取 甲, 立即数 | 加载立即数 | MOV EAX, imm | MOV X0, imm |
| 加 甲, 乙 | 寄存器相加 | ADD EAX, EBX | ADD X0, X0, X1 |
| 入 甲 | 压栈 | PUSH RAX | STR X0, [SP, #-16]! |
| 跳 目标标签 | 无条件跳转 | JMP label | B label |
| 比较 甲, 乙 | 比较两个寄存器 | CMP EAX, EBX | CMP X0, X1 |
这套翻译模板大概覆盖了128条指令中的七成,其余三成要么涉及复杂寻址,要么依赖运行环境,暂时用软件例程模拟。比如浮点操作,x86-64上我直接借助SSE指令集翻译,ARM64上则用对应的SIMD指令。真正驱动中断、模拟外设的部分还是留在VM层处理。
很多人以为从一套指令集翻译到另一套指令集需要特别高深的算法,实际上对于我这种只做“语义等价翻译”的场景,模板匹配加少量重写就够了。真要做到寄存器分配优化、指令调度、窥孔优化,那就要上LLVM了。这也是我下一步准备接LLVM后端的原因,因为可以免费获得大量成熟的优化能力。
5. 在真实使用中踩出的坑:全角符号、输入法联想、调试器显示
5.1 全角字符和全角空格:一个肉眼几乎看不到的坑
整个项目里最让人抓狂的bug不是指令编码错误,而是全角空格。很多人在中文输入法状态下编写伏羲-128程序,缩进时输入法自动补全的是全角空格,那个空格和普通半角空格在视觉上几乎一模一样,但在代码里却是完全不同的字符。
我最早一版汇编器在处理指令时,会在操作数和操作符之间按空格切分。当用户写了“取 甲, 五”但中间混入了一个全角空格时,解析器会把它当成第三个操作数的一部分,然后一脸懵地报错。为了解决这个问题,我在文本清洗阶段加入了一个强制规则:所有全角空格统一替换成半角空格。从那以后,这个坑就没了。
类似的坑还有中文标点。用户写“取甲,五”时用了中文逗号“,”而不是半角逗号“,”,如果清洗阶段没有处理,分词器会把“五,”当成一个完整的操作数,编码时自然就崩了。我现在的策略是:在分词前把常见的中文标点全部映射成半角标点,包括逗号、分号、括号、句号。这样用户在写代码时基本不需要刻意切换输入法。
5.2 中文寄存器名在表格中对不齐:等宽字体也救不了
VM里打印寄存器快照时,我最初写成英文表格对齐方式:用固定宽度打印字段名。结果发现中文寄存器名在终端里根本对不齐。原因在于终端渲染汉字时,一个汉字通常占两个英文等宽字符的宽度,但某些终端里的“窄字体”和“宽字体”渲染规则又不一样,导致看似等宽的表格出现错位。
解决这个问题我写了一个显示宽度计算函数,判断每个字符的Unicode码点,落在CJK区间的视为宽度2,其他按宽度1算。打印表格时用这个函数计算实际占用宽度,再补空格对齐。实测下来,在绝大多数现代终端上都能对齐。
这个坑本质是“中文排版宽度”问题,不只是伏羲-128会遇到。如果你想给自己的中文调试工具做一个好看的表格输出,一定不要用简易的字符串长度去补空格,要按显示宽度来算。
5.3 输入法联想的“魔改”:用户敲错同音字怎么给提示
伏羲-128的社区试玩阶段,很多用户反馈“我明明想写‘加’,但输入法联想出来‘家’,汇编器直接报错了”。这个问题让我意识到,中文指令集不能只接受精确字符,还需要对输入法造成的同音错字给出宽容提示。
我做了一个“拼音容错表”:将128条指令字对应的拼音全部记录在案。当汇编器在一个该出现指令字的位置遇到一个不认识的字时,会先查这个字是否在指令字表里,不在的话就查它的拼音,把拼音和指令字的拼音做匹配,如果匹配成功,就生成一条警告:“这里是不是想写‘加’?你已经输入了‘家’。”
这个功能上线后,很多试玩用户表示“终于知道自己打错哪个字了”。它虽然不影响核心编译流程,却大大提升了使用体验。中文输入法和英文键盘不同,用户打错同音字是一个非常常见的场景,作为面向中文用户的工具,这种容错设计应该被优先考虑。
6. “伏羲-128”现在能跑什么,哪些场景我真的建议你试
6.1 教学场景:中文汇编反而是更好的思维训练
我在给几位新人讲程序执行原理的时候,用伏羲-128做过一次实验。传统方式是用寄存器、栈、PC这些英文术语教学,新人往往需要先跨过术语理解障碍。改用伏羲-128后,指令本身就说明白了动作:看到“入”就知道是压入栈,看到“跳”就知道是跳到别处,理解门槛一下就低了。
举一个经典的教学例子:用伏羲-128写一个计算斐波那契数列的程序。程序里用寄存器甲作为计数器,寄存器乙保存当前值,寄存器丙保存前一个值,主循环体内三次加法、一次比较、一次跳转。等学生把这段程序跑通,再去看传统汇编实现,大部分人能很快理解对应的英文助记符在做什么。
我并不是主张用中文指令集替代真正的汇编教学,而是建议把它作为“从零到一”的启蒙工具,等学生对寄存器、栈、PC这些概念有了感觉,再切到x86或ARM汇编,效果会好很多。
6.2 脚本化小工具的DSL与配置语言
伏羲-128另一个比较契合的场景是给特定领域的小工具做DSL或配置语言。比如你想写一个按规则整理文件的脚本,用传统Shell写虽然能用,但可读性全看注释。用伏羲-128写,每一步动作都像在读中文说明书:
text复制读 文件列表
对于 每一行:
比较 大小, 阈值
等 跳转到 小文件处理
写 归档目录
小文件处理:
写 临时目录
停止
这个例子里的“对于”“每一行”等实际上还是借用了一点高级控制流能力,我单独扩展成了一条“循环伪指令”,在汇编器里被展开成多条基础跳转指令。这种“基于伏羲-128核心指令集扩展出的流程控制糖衣”,让书写意图变得非常清晰。我自己给几个内部工具写的自动化脚本,后来都改成了这种风格,不仅好写,更好维护。
6.3 后续路线与一句最想分享的体会
目前伏羲-128的配套工具链包括汇编器、反汇编器、VM执行器、指令规范CSV表和一个基础调试器。下一步计划做三件事:第一是写VSCode插件,提供语法高亮、悬停提示和单步调试界面;第二是做一个在线Playground,让用户在浏览器里直接写中文指令并看到寄存器变化;第三是把LLVM后端接上,让伏羲-128程序可以正式编译到真实硬件平台。
最后说一句在整个折腾过程中体会最深的东西。做任何中文系列的项目,最重要的不是“把界面文字换成中文”,而是想清楚“中文在这个过程中到底充当了什么角色”。在伏羲-128里,汉字不只是显示层的名字,它直接参与编码、查表、分词、调试、输入容错,是整套系统的语义基石。只要把这一层想明白,中文能做底层这件事就没有什么好争论的。如果你也想做一个类似的中文指令集,我的建议是先花时间建词表、做同音查重、写编码快照测试,把编码边界夯得死死的,再往下推进。
