bash
作者: 一了 <1liao3@funlang.org>
日期: 2026-10-07
一句话总结: 从汇编起步的几条线:病毒样本、结对爆破、字节码还原,最后落到 Fun 的内联汇编与内联 C。`NewJit` 一套接口,既跑 `#!asm` 也跑 `#!C`。
从汇编到Fun语言
1999 年,我自学的第一门语言是汇编。那时候没什么教材,靠的是 DOS 自带的 debug:敲一段十六进制,反汇编出指令,看着寄存器一个个变。后来写过一篇复盘,放在《自学编程难不难》系列里。
汇编统共几十条指令,不多,但它给了你一个坐标系:指令流、寄存器、堆栈、内存地址。从此看任何软件,我都在这个坐标系里看。
一、病毒样本(2000 年前后)
有一段时间,我在网易和新浪的病毒论坛上,收别人寄来的病毒样本。用 C 写的已经算高层了,五花八门,有汇编的,甚至有 vbs 的。一行一行跟,跟完几十个样本,机器怎么执行代码就不再是书上的一句话,变成一种直觉。
(吐槽:那会儿胆子大,样本在真机上跑,现在不敢了。)
二、2003:指挥一个不会汇编的人
某款日本高速扫描仪,SCSI 接口,文档故意写错:照文档写代码,后台跑不通,非得人工去点一下按钮。可需求是全自动票箱,只能后台跑。
方案是替换通信 DLL,加一层中转日志。写 DLL 的下属不懂汇编。我就站他身后,盯着 Delphi 的 CPU 窗口,他按键,我来判断:跟进去,还是跳过去;哪一处可以改成 JMP,把那个校验爆掉。
这段经历,后来被业界称作结对编程"Pair Programming (Human-Human)"。
三、2015:还原一套字节码
有一套股票公式系统,资源是加密的,执行前二次解密,得到字节码,再放到一个自定义虚拟机上跑,是个堆栈机。没有文档,也没有符号表。
反编译器分几步:解资源,多层解密,逆向解释器映射操作码,从堆栈字节码重构 AST,再把 AST 转回公式源码。
做完这一趟,底层的认知反而简单了:所有软件都是一层包装纸。高级语言也好,加密也好,虚拟机保护也好,撕开了还是那几样东西,寄存器、堆栈、内存地址、跳转。
四、Fun:从业务层一路到机器码
语言设计的起点是 2005 年的 TemplateScript,之后是 2006 年的 Nuva,2010 年 Fun 定型。目标只有一句话:一门语言,从业务层一路覆盖到机器码,中间没有断层。
市面上的定位大致这样:
| 语言 | 定位 |
|---|---|
| Python / JS | 方便,碰不了底层 |
| C / C++ | 底层强,业务啰嗦 |
| Java / C# | 中间派,受 VM 限制 |
Fun 不是设计出来的,是从这些经历里长出来的。
五、内联汇编和内联 C:两个对等的入口
抽象是好东西,但我们得能拿到零成本或低成本的抽象。自修改代码、CRC 校验、VM 跳转表,这些高级特性最后都坍缩成机器码,Fun 得留一个直通 CPU 的后门。
先定义回调:
bash
use 'lib-jit.fun';
var ii = 0;
fun cb(a, b)
ii = a * 2^32 + b;
?, 'cb'; ?. ii;
result = ii;
end fun;
写一段内联汇编,算 1 到 1 亿的和,把结果拆成高低 32 位传给 cb:
bash
var s = `#!asm i:i
mov ecx, dword ptr [esp+04]
@sum1ton
push eax
push edx
mov eax, <test>
call eax
`;
var jit = NewJit(s, names: [test: cb.@toCallback(nil, 'ii:i', true)]);
?. jit.Run(100000000);
jit.Delete();
?. 'Ok';
#!asm 里是 x86 汇编,<test> 映射到 Fun 函数 cb,@toCallback 转成标准函数指针。JIT 编译成机器码,写进可执行内存,Run 调用。这套汇编引擎核心大约 200 行,完全由 Fun 自己实现,没有外部依赖。
真实世界的底子都是 C。Windows API 是 C,逆向过的 DLL 是 C,那台股票公式的 VM 解释器也是 C。不能直接调 C,就得写胶水层:ctypes、JNI、P/Invoke。
Fun 的做法是加载 libtcc,运行时编译 C 代码,取出函数地址直接调用。同一个例子:
bash
s = `#!C ii:i
int sum(int n, int (*test)(int, int)) {
long long s = 0;
for(int i = 1; i <= n; i++) s += i;
test((int)(s >> 32), (int)(s & 0xFFFFFFFF));
return 1;
}
`;
jit = NewJit(s);
?. jit.call(100000000, cb.@toCallback(nil, 'ii:C', true));
jit.del();
?. 'Ok';
C 代码由 TCC 运行时编译成机器码,cb 以 cdecl 方式传进去,jit.call 执行。
两个版本的接口是一致的。汇编和 C 在 Fun 里是两种对等的底层表达,共用一套入口:
bash
fun NewJit(code, args, names, name)
if code =~ /^#!c\b/i then
result = ccompile(code, flag: args, name: name);
else
result = Assembly(code: code, args: args, names: names).Load();
end if;
end fun;
六、FD 格式:lex/yacc 用不上的那种词法
FD 是 Fun 自己的配置格式。
- 缩进有三种写法:空格、
>>>、3>,优先级可以混着用 - 字符串有六种风格,包括按十六进制长度指定的无定界符字符串
-既可以是简单值,也可以是复杂对象的根节点- 键带不带引号,会改变值的解析边界
lex/yacc 假设词法是局部的、无状态的。FD 的词法是全局的、有状态的,传统工具基本派不上用场。
我的做法:用 BNF 文法定义 FD,lib-bnf 编译成正则表达式,语义动作维护一个缩进栈。核心代码大约 200 行,其中两个细节:
正则匹配结果不复制,直接从内部缓冲区按偏移取子串(语义动作里的一段):
bash
Length: (o,m,p) {
p.val = m.@(o.t.toNum(), 2);
p.@ = nil;
},
栈的实现直接拿内部缓冲区的地址,用 move 做内存搬移:
bash
var p = list.@count('ptr') + prev * SizeOfPtr;
(p + SizeOfPtr).move(p, (curr - prev) * SizeOfPtr);
语言层直接暴露指针和 memmove,中间没有夹层。
结语:有时候像 C,有时候像 Lisp
Fun 的定位,大概是这样:
mov ecx, dword ptr [esp+04]:像 C1+2|f+3|g:像 Lisp$ .# 3自定义运算符:像 Lisp(p + SizeOfPtr).move(p, len):像 C
它不在中间折中。两端都能去,中间没有鸿沟:从 Lisp 式的管道一路降到 x86 机器码,是同一门语言、同一个进程、同一个堆栈里的事。
1999 年自学汇编的时候,我不知道会走到哪里。回头看,它定下了很多底层的思维方式。
2026 年,Fun 9.0 正式开源,仓库在 github.com/Funlang/fun。不是玩具:开源之前,Fun 和它的前身在生产环境里跑了多年,支撑的商业软件卖到 130 多个国家,客户里有微软、英特尔、IBM 这些世界 500 强。如果你也想从底层开始捏一门自己的语言,或许能从这里捡到点东西。