从汇编到Fun语言

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]:像 C
  • 1+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 强。如果你也想从底层开始捏一门自己的语言,或许能从这里捡到点东西。

相关推荐
l1t5 小时前
DeepSeek总结的有符号、无符号、未对齐:从汇编到 SQL 的教训
汇编·数据库·sql
Hi202402176 天前
同一个 NMS 算子,两种语言实现:CUDA C 与 Rust 生成 LLVM IR 和 Vortex 汇编的差异
c语言·汇编·rust·gpgpu·vortex·cudac
lhbweilai6 天前
深挖FreeRTOS内核实现(二)
c语言·汇编·stm32·单片机
水饺编程6 天前
第1章:开发环境搭建,在 Windows 中安装 VS 2019
linux·c语言·汇编·visual studio
水饺编程7 天前
第1章:下载 Linux 0.12 内核
linux·c语言·汇编·ubuntu
水饺编程7 天前
第1章:开发环境搭建,在 Windows 中安装 Git
linux·c语言·汇编·git·ubuntu
水饺编程8 天前
第1章:在 Windows 中安装 GCC 套件
c语言·汇编
水饺编程9 天前
第1章,了解乌班图环境,使用浏览器和邮箱
c语言·汇编·ubuntu
水饺编程9 天前
第1章:开发环境搭建,在 Windows 中安装 QEMU
c语言·汇编