Common Lisp (简称“CL”)诞生于1984年,最一开始的目标是期望把70年代末80年代初的那些 lisp 家族的语言做一番综合和统一。CL 很成功的实现了这个目标,这使得 CL 的各种设计来自于当时各种各样的 lisp 家族的语言。几年以后,CL 迎来了一次升级,核心语言基本上没有太大的变动,主要是加入了CLOS。1994年,ANSI 发布了第一个 CL 标准,这次标准化过程是对当时 CL 市场的一次概括,内容基本上和上一次升级差别不大。在这以后, CL 标准委员会工作重心转移,跑去支援 ISLISP[1] 标准的制定。2004年,由于确实没人了,CL 标准委员会宣布解散。从 1994 - 2004 年间,CL 标准文档的变化仅仅只是对语言表达不清晰、“错别字”、语法错误这种类型的修改,语言本身并没有改变。CL 界至今仍旧严格的遵循其 ANSI 标准 “ANSI INCITS 226”[2]

CL 的这种历史发展过程,使得它成为今天少见的保留着 80 年左右语言设计的一门编程语言,堪称编程语言界的“活化石”。

反汇编

可能是由于 CL 的设计都是从 80 年左右的那些 LISP 家族“抄”来的, CL 标准规定,每个 CL 实现都必须实现一个 DISASSEMBLE 函数,用于反汇编[3]。例如,对于函数 '(lambda ()) ,反汇编的结果如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CL-USER> (disassemble '(lambda ()))
(recover-fn-from-rip) ; [0]
(testl (% nargs) (% nargs)) ; [7]
(jne L29) ; [9]
(pushq (% rbp)) ; [11]
(movq (% rsp) (% rbp)) ; [12]
(movl ($ #x1300B) (% arg_z.l)) ; [15]
(movq (% rbp) (% rsp)) ; [20]
(popq (% rbp)) ; [23]
(retq) ; [24]

(:align 2)
L29 ; [@44]
(uuo-error-wrong-number-of-args) ; [29]
NIL

这是 Clozure Common Lisp (简称“CCL”)的反汇编结果。换一种实现呢?

1
2
3
4
5
6
7
8
9
10
11
12
* (disassemble '(lambda ()))
; disassembly for (LAMBDA ())
; Size: 21 bytes. Origin: #x52B3FFDC ; (LAMBDA ())
; DC: 498B4510 MOV RAX, [R13+16] ; thread.binding-stack-pointer
; E0: 488945F8 MOV [RBP-8], RAX
; E4: BA17001050 MOV EDX, #x50100017 ; NIL
; E9: 488BE5 MOV RSP, RBP
; EC: F8 CLC
; ED: 5D POP RBP
; EE: C3 RET
; EF: CC10 BREAK 16 ; Invalid argument count trap
NIL

这是 Steel Bank Common Lisp (简称“SBCL”)的反汇编结果。由于是 CL 标准规定必须存在的,所以即使是非 native 平台的 CL 实现,都必须提供对应的反汇编功能。

在 CCL 中写汇编代码

即使 CL 可以对任何编译过的函数进行反汇编,标准里却没有提供汇编的接口。但是每个主流 CL 实现都带汇编器,因而可以在 CL 中嵌入汇编语言代码。下面用 CCL 和 x86_64 linux 平台为例子,讲解怎么在 CL 中插入汇编。

LAP

CCL 中,用汇编实现的东西,称为 LAP 。至于 LAP 全称到底是什么,可能也没什么人记得了,有的说是 Lisp Assembly Program ,官方给出的文档却也有 Lisp Assembly Parser 的解释[4]

普通的CL函数,可以通过 CCL::DEFX86LAPFUNCTION 定义。例如,对于两个 FIXNUM 类型的相加,一个简单的实现如下。(注意CCL用的汇编是LISP风格的语法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(ccl::defx86lapfunction my-add-1 ((x1 arg_y) (x2 arg_z))
;; 检查参数个数是否正确。这是一个 lap 宏 。
(check-nargs 2)

;; 通过 tag 判断是不是 FIXNUM 类型。
(trap-unless-fixnum x1)
(trap-unless-fixnum x2)

;; 还原真实的 FIXNUM
(unbox-fixnum x1 imm0)
(unbox-fixnum x2 imm1)

;; 普通加法
(movq (% imm0) (% imm2))
(addq (% imm1) (% imm2))

;; 重新打包成 FIXNUM
(box-fixnum imm2 arg_z)

;; 返回一个值
(single-value-return))

这个程序首先检查参数个数是不是2,如果不是,抛出异常。接下来,检查两个参数的类型是不是都是 FIXNUM,如果不是,抛出异常。然后才是做加法。这个函数用 CCL 反汇编结果如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
CL-USER> (disassemble 'my-add-1)
(recover-fn-from-rip) ; [0]
(cmpl ($ 16) (% nargs)) ; [7]
(jne L61) ; [10]
(testb ($ 7) (% arg_y.b)) ; [12]
(je.pt L21) ; [16]
(uuo-error-reg-not-fixnum (% arg_y)) ; [19]

(:align 2)
L21 ; [@36]
(testb ($ 7) (% arg_z.b)) ; [21]
(je.pt L30) ; [25]
(uuo-error-reg-not-fixnum (% arg_z)) ; [28]
L30 ; [@45]
(movq (% arg_y) (% imm0)) ; [30]
(sarq ($ 3) (% imm0)) ; [33]
(movq (% arg_z) (% imm1)) ; [37]
(sarq ($ 3) (% imm1)) ; [40]
(movq (% imm0) (% imm2)) ; [44]
(addq (% imm1) (% imm2)) ; [47]
(imulq ($ 8) (% imm2) (% arg_z)) ; [50]
(retq) ; [54]

(:align 2)
L61 ; [@76]
(uuo-error-wrong-number-of-args) ; [61]
NIL

然而这些都是 虚假的汇编。真正的汇编,可以用 gdb 反汇编得到。如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 由于对齐等因素,这里的反汇编不是百分百正确。不过大体上和CCL反汇编结果是对得上的。
(gdb) x/30i 0x00003020004d303f
0x3020004d303f: lea -0x7(%rip),%r13 # 0x3020004d303f
0x3020004d3046: cmp $0x10,%ecx
0x3020004d3049: jne 0x3020004d307c
0x3020004d304b: test $0x7,%dil
0x3020004d304f: je,pt 0x3020004d3054
0x3020004d3052: int $0xf7
0x3020004d3054: test $0x7,%sil
0x3020004d3058: je,pt 0x3020004d305d
0x3020004d305b: int $0xf6
0x3020004d305d: mov %rdi,%rax
0x3020004d3060: sar $0x3,%rax
0x3020004d3064: mov %rsi,%rdx
0x3020004d3067: sar $0x3,%rdx
0x3020004d306b: mov %rax,%rcx
0x3020004d306e: add %rdx,%rcx
0x3020004d3071: imul $0x8,%rcx,%rsi
0x3020004d3075: retq <--- 这里基本上对应(retq)了。
0x3020004d3076: xchg %ax,%ax
0x3020004d3078: (bad)
0x3020004d3079: add %al,(%rax)
0x3020004d307b: add %cl,%ch
0x3020004d307d: retq $0x9000
0x3020004d3080: repnz add %al,(%rax)
0x3020004d3083: add %al,(%rax)
0x3020004d3085: add %al,(%rax)
0x3020004d3087: add %ch,%dh
0x3020004d3089: pushq $0x3020004d
0x3020004d308e: add %al,(%rax)
0x3020004d3090: add %dl,(%rax)
0x3020004d3092: add %al,(%rax)

把 LAP 包装成闭包

使用 LAP 方式定义出来的是一个有名有姓的 CL 函数,那么,也可以通过 CCL::X86-LAP-FUNCTION把汇编代码包装成一个CL闭包。如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
CL-USER> (funcall
(lambda ()
(ccl::x86-lap-function
;; name
nil
;; arglist
((x1 arg_y) (x2 arg_z))
;; body
(check-nargs 2)
(trap-unless-fixnum x1)
(trap-unless-fixnum x2)
(unbox-fixnum x1 imm0)
(unbox-fixnum x2 imm1)
(movq (% imm0) (% imm2))
(addq (% imm1) (% imm2))
(box-fixnum imm2 arg_z)
(single-value-return)
))
50 60)
110

Foreign Function Interface

由于 LAP 做出来的函数,是标准的 CCL 函数,遵循 CCL 内部的各种标准,必须对 CCL 的内部运作相当了解才能很好的写出来。但是,真的只有这种方法吗?也许,我们要的只是在内存中生成一段机器码,然后丢给 CCL去执行即可。这时候可以换一种思路,一方面利用 CCL 的汇编器帮我们生成一段机器码,同时,丢给 FFI 一个指针,让 CCL 把这段代码当做普通的机器码那样去执行。这样,我们只需要遵循通用的 ABI 标准,以及少数 FFI 的约定,即可写出任何汇编代码。CCL 在调用我们的汇编代码的时候,也会按照这些通用的准则为我们准备相应的数据。

下面这个例子,汇编是直接从 gcc 生成的代码抄来的。它传入一个int类型的参数,把参数加一后返回,这个程序遵循通用的 ABI 标准。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
CL-USER> (ff-call
(ccl:%address-of ;; 获取汇编代码的地址。
(ccl::x86-lap-function nil ()

;; prologue
(pushq (% rbp))
(movq (% rsp) (% rbp))

;; first argument is in EDI
(movl (% edi) (% eax))
(incl (% eax))

;; epilogue
(popq (% rbp))

(retq)))
:int 10 :int)

11

总结

本文介绍了在 CCL 中编写汇编代码的方法。在 CCL 中,可以通过 LAP 来编写汇编代码。如果是 LAP function ,则需要遵循 CCL 的各种约定。如果通过 FFI 来调用编写的汇编代码,则只需要遵循通用的 ABI 约定。


  1. ISO Standarized Lisp,ISO 推出的标准化的 LISP 家族语言。 ↩︎

  2. 有兴趣的同学可以在 ANSI 的在线商店购买,也就 30 美元,1000+ 页的一本书,平均一页纸密密麻麻的知识只要2毛钱,赚翻了! ↩︎

  3. 可能当时 CL “抄”的某些 lisp 家族的语言,就有这个功能。那是一个内存特别小机器性能特别差要非常小心计划着用的年代,反汇编可以让人更好的发现底层的性能、资源使用等方面的问题。 ↩︎

  4. 详见 https://trac.clozure.com/ccl/wiki/Internals/Compiler↩︎