在 Common Lisp (SBCL) 里进行面向机器的 Low-level 编程

在本文开始之前,先祝贺 Emacs China 的各位道友 2023 新年快乐!

前言

我们知道,在很多系统级编程语言,如 C/C++ 、 Rust 里,编译器往往会提供一些语法与机制用于内联汇编,能够允许程序员直接操作寄存器或执行机器码,达到更高的执行效率或进行更细粒度的底层控制。想象一下,如果动态语言能够做到这一点会是什么样呢? SBCL 作为 Common Lisp 直接到机器码的编译器实现,可以说十分擅长在 Lisp 程序里插入汇编程序段,这样除了可以获得上述的好处之外,个人认为最大好处莫过于能在进行底层编程的同时,享受交互式的编程编程体验,也就是说修改代码后不再需要等待漫长的编译过程(这里点名批评 Rust)与重复地进行数据输入,即可得到代码的运行结果并检验其正确性。

国外也有一些文章介绍了在 SBCL 里进行内联汇编的方法,但我认为其中的讲解过于复杂且脱离实际用途,并且用到的 SBCL 内部 API 很多已经被弃用了,我这里以实际的开发案例,演示一下在 SBCL 里面向机器编程的方法。

SBCL 的优化机制

这里以 Common Lisp 中 float-sign 这个函数为例,为什么以它为例呢?因为本人之前文章 记录一次将 C++ 程序移植到 Common Lisp 上的经历 - #12,来自 coco24 提到的工程里,通过内联汇编优化 float-sign 这个热点函数,实现了显著的性能提升。

这个函数做的事情很简单,如果传入一个浮点数,返回这个浮点数的符号,如果传入两个参数,则返回第一个参数的符号赋给第二个参数后的结果:

(float-sign -3.14)                                     ; => -1.0
(float-sign -3.14 1.592)                               ; => -1.592
(float-sign 1.0 sb-ext:single-float-negative-infinity) ; => #.SINGLE-FLOAT-POSITIVE-INFINITY

在 C++ 里,该函数对应于 std::copysignf

首先,我们试着通过 Emacs 的 Sly 查找这个函数的定义,SBCL 给出了以下结果:

/usr/share/sbcl-source/src/code/float.lisp
 (DEFUN FLOAT-SIGN)
/usr/share/sbcl-source/src/compiler/float-tran.lisp
 (:DEFTRANSFORM FLOAT-SIGN (DOUBLE-FLOAT &OPTIONAL DOUBLE-FLOAT) "optimize")
 (:DEFTRANSFORM FLOAT-SIGN (SINGLE-FLOAT &OPTIONAL SINGLE-FLOAT) "optimize")
/usr/share/sbcl-source/src/compiler/fndb.lisp
 (:DEFOPTIMIZER FLOAT-SIGN DERIVE-TYPE)
 (DECLAIM FLOAT-SIGN DEFKNOWN)

为什么一个函数会有多处定义呢?这里其实可以类比一下 CLOS 的 DEFGENERICDEFMETHOD 的关系,前者定义声明,后者定义实现,并且一条声明根据规则可以实际调用不同的实现。在 SBCL 里,对于 Common Lisp 的内置函数,一旦被声明为 DEFKNOWN , SBCL 会认为这个函数可以被优化,并可能会提供多种编译方案:

  • 如果对应的函数有使用 DEFINE-VOP 定义的虚拟操作符存在且参数与返回值满足条件,则在函数的调用处进行寄存器分配并生成汇编代码段,将其嵌入到程序中。在 SBCL 里,这种生成汇编代码段的对象被称为虚拟操作符 (Virtual OPerator) ,它可以被看作是一种底层的函数,其主体是带有寄存器、参数/返回值类型的约束与分配规则的汇编代码生成器。如 + 在 C/C++ 里的运算符,在 Common Lisp 中虽然是函数,但它们对于不同的 Lisp 类型(如 fixnum, single-float ), SBCL 也中具有不同的 VOP 与之对应,使得两个 fixnum 相加就是一条 ADD 指令,提高了效率。对于 float-sign 而言,如果传入了两个参数均为 single-float , SBCL 实际上会调用这个 VOP :
    (define-vop (single-float-copysign)
      (:translate single-float-copysign)
      (:args (x :scs (descriptor-reg))
             (y :scs (descriptor-reg)))
      (:arg-types single-float single-float)
      (:results (res :scs (descriptor-reg)))
      (:temporary (:sc unsigned-reg :from (:argument 0)) temp)
      (:policy :fast-safe)
      (:generator 3
        (move temp x)
        (move res y)
        (inst shl res 1)   ; discard result's sign bit
        (inst shl temp 1)  ; copy X's sign bit into the carry flag
        (inst rcr res 1)))
    
    DEFINE-VOP 的基本语法稍后会提到,这里大家可以将其看作一个黑箱,它能够将第一个参数的浮点数符号复制给传入的第二个浮点数并返回。
  • 如果对应的函数有使用 DEFTRANSFORM 定义的变换规则存在且满足变换条件(通常包括参数类型、是否可折叠为常量、是否为指定的平台等), SBCL 会使用这个变换规则,将函数的调用变换为一个或多个 VOP 的组合。还是以 float-sign 为例, SBCL 里存在这样一个 DEFTRANSFORM
    (deftransform float-sign ((float &optional float2)
                              (single-float &optional single-float) *)
      (if (vop-existsp :translate single-float-copysign)
          (if float2
              `(single-float-copysign float float2)
              `(single-float-sign float))
          (if float2
              (let ((temp (gensym)))
                `(let ((,temp (abs float2)))
                   (if (minusp (single-float-bits float)) (- ,temp) ,temp)))
              '(if (minusp (single-float-bits float)) $-1f0 $1f0))))
    
    其实这里稍微熟悉 Lisp 语法的大概都能看出这里是什么意思了,如果传入了两个参数均为 single-float 类型 ,并且有为平台定义 single-float-copysign 这个 VOP ,那么将函数调用变换为上面提到的 single-float-copysign
  • 如果对应的函数参数类型是未知的、或者不满足所有 VOP 与变换规则的条件,此时 SBCL 就会使用 DEFUN 定义的备用 (Fallback) 函数,它们的地位与用户定义的函数是相当的,调用时不会进行寄存器级别的优化,也就是在函数的调用位置会真的产生了一次函数的调用,效率相对是比较低的。此时,在大部分情况下 SBCL 会给出优化提示,使得对应的 VOP 或变换规则能被应用。
    (defun float-sign (float1 &optional (float2 (float 1 float1)))
      "Return a floating-point number that has the same sign as
       FLOAT1 and, if FLOAT2 is given, has the same absolute value
       as FLOAT2."
      (declare (float float1 float2) (explicit-check))
      (* (if (etypecase float1
               (single-float (minusp (single-float-bits float1)))
               ;; If 64-bits words, use all the bits. No need to right-shift them.
               (double-float (minusp #+64-bit (double-float-bits float1)
                                     #-64-bit (double-float-high-bits float1)))
               #+long-float
               (long-float (minusp (long-float-exp-bits float1))))
             (float -1 float1)
             (float 1 float1))
         (abs float2)))
    

SBCL 引入这样优化机制的好处也是显而易见的:

  • 不同指令集的 CPU 显然不可能原生提供相同的功能集合,因此定义的 VOP 的数量与内容一定是与 CPU 架构相关的。如在 x86 上, POPCNT 指令可以直接计算一个二进制数中 1 的数量,使得 Common Lisp 的 logcount 函数在 SBCL 上可以被编译为一条汇编指令,但这个在 ARM 上是无法做到的。
  • 在移植 SBCL 到不同指令集的 CPU 时,只需要定义少量的 VOP (基本上就是 C/C++ 里的那些原生运算符对应的汇编表示),即可使得 SBCL 以较低的效率(其余未被定义的 VOP 与变换规则全部变成了函数的调用)运行在这个新的平台上。

所以这里大家应该就能明白为什么给 SBCL 加上类型声明能够显著提高程序的执行效率了,添加的类型声明配合 SBCL 类型推断器的辅助,可以使得更多的 VOP 或变换规则被应用,使得用户定义的函数编译后输出高效的机器码。

DEFKNOWNDEFINE-VOP

回到 float-sign 中来, 通过其 single-float 版本的变换规则我们可以得知,传入两个参数均为 single-float 时,调用的实际上是 single-float-copysign 这个函数,那我们先来看一下 single-float-copysignDEFKNOWN 定义:

(defknown single-float-copysign (single-float single-float)
  single-float (movable foldable flushable))

其中按照顺序:

  • single-float-copysign 就是其对应的函数名称,在 x86-64 平台上, SBCL 没有为其定义备用函数,因为 float-sign 的变换规则保证了这个函数传入的一定是单精度浮点,其备用函数是这样定义的:
    (defun single-float-copysign (float float2)
      (single-float-copysign float float2))
    
    有人可能会说这不是无限递归了吗?其实不然, single-float-copysign 这个函数在编译时,函数体内的 single-float-copysign 就会被定义好的 VOP 取代,因此该函数的反编译结果如下:
    ; disassembly for SINGLE-FLOAT-COPYSIGN
    ; Size: 23 bytes. Origin: #x53449882                          ; SINGLE-FLOAT-COPYSIGN
    ; 82:       488BC6           MOV RAX, RSI
    ; 85:       488BD7           MOV RDX, RDI
    ; 88:       48D1E2           SHL RDX, 1
    ; 8B:       48D1E0           SHL RAX, 1
    ; 8E:       48D1DA           RCR RDX, 1
    ; 91:       488BE5           MOV RSP, RBP
    ; 94:       F8               CLC
    ; 95:       5D               POP RBP
    ; 96:       C3               RET
    ; 97:       CC10             INT3 16                          ; Invalid argument count trap
    
  • (single-float single-float) 定义了传入参数的类型,只有实际(断言的)类型符合这个类型要求,为这个 DEFKNOWN 定义的 VOP 与变换规则才可能被应用。
  • single-float 定义了为 DEFKNOWN 定义的 VOP 与规则的返回值类型。
  • (movable foldable flushable) 定义了允许编译器应用的优化,其中:
    • movable : 允许指令重排。
    • foldable : 允许常量折叠以减少相同的指令执行。
    • flushable : 允许删除不必要的指令以减小代码的体积并提高其效率。

我们继续看一下对应的 VOP 定义:

(define-vop (single-float-copysign)
      (:translate single-float-copysign)
      (:args (x :scs (descriptor-reg))
             (y :scs (descriptor-reg)))
      (:arg-types single-float single-float)
      (:results (res :scs (descriptor-reg)))
      (:temporary (:sc unsigned-reg :from (:argument 0)) temp)
      (:policy :fast-safe)
      (:generator 3
        (move temp x)
        (move res y)
        (inst shl res 1)   ; discard result's sign bit
        (inst shl temp 1)  ; copy X's sign bit into the carry flag
        (inst rcr res 1)))

其中按照顺序:

  • single-float-copysign 是这个 VOP 的名称,不同的 VOP 可以继承,但必须具有不同名称,否则会被视为 VOP 重定义。

  • :translate 指定要转换的目标函数,也就是之前 DEFKNOWN 的名称。

  • :args 指定传入的参数与需要分配的寄存器,使用 :scs 参数指定一个或多个寄存器类型,使用 :target 指定目标寄存器。

  • :arg-types 指定参数类型,这里的类型可以是 DEFKNOWN 参数里的子类型。

  • :results 指定为返回值分配寄存器,参数与 :args 类似。注意由于 Common Lisp 支持多值返回,这里与 :args 一样也可以分配多个寄存器。

  • :result-types 这里没出现,但还是提一下,与 :arg-types 是类似的。

  • :temporary 用于指定中间寄存器,可以使用 :sc 指定寄存器的类型,使用 :from:to 指定寄存器中的值来源与去向。

  • :generator 的第一个参数指定开销,有多个 VOP 满足条件时, SBCL 选择开销最小的 VOP 使用,其余参数是汇编代码的生成器:

    (move temp x)
    (move res y)
    (inst shl res 1)
    (inst shl temp 1)
    (inst rcr res 1)
    

    inst 后的汇编代码将被原封不动地释放,而 move 可以让 SBCL 根据寄存器类型自动选用合适的移动指令,这里等价于 (inst mov ...)

    根据 IEEE 754 浮点数标准:

     1位           8位                                       23位
    .---.-----------------------.--------------------------------------------------------------.
    +---+-----------------------+--------------------------------------------------------------+
    | S |           e           |                               f                              |
    +---+-----------------------+--------------------------------------------------------------+
    

    这一段汇编代码的作用是将第一个参数的符号位复制给第二个参数并返回。

示例

优化 SBCL 的 float-sign

我们可以尝试优化一下 SBCL 的 single-float-copysign 。这里先给出在一般场景下调用 float-sign 的测试函数:

(defun test-float-sign (x y)
  (declare (optimize (speed 3)
                     (debug 0)
                     (safety 0))
           (type single-float x y)
           (values single-float))
  (+ (float-sign x y) (float-sign y x)))

然后我们对其汇编代码进行分析:

; disassembly for TEST-FLOAT-SIGN
; Size: 69 bytes. Origin: #x535D7AE6                          ; TEST-FLOAT-SIGN
; AE6:       488BC2           MOV RAX, RDX
; AE9:       488BCF           MOV RCX, RDI
; AEC:       48D1E1           SHL RCX, 1
; AEF:       48D1E0           SHL RAX, 1
; AF2:       48D1D9           RCR RCX, 1
; AF5:       66480F6EC9       MOVQ XMM1, RCX
; AFA:       0FC6C9FD         SHUFPS XMM1, XMM1, #4r3331
; AFE:       488BC7           MOV RAX, RDI
; B01:       488BCA           MOV RCX, RDX
; B04:       48D1E1           SHL RCX, 1
; B07:       48D1E0           SHL RAX, 1
; B0A:       48D1D9           RCR RCX, 1
; B0D:       66480F6ED1       MOVQ XMM2, RCX
; B12:       0FC6D2FD         SHUFPS XMM2, XMM2, #4r3331
; B16:       F30F58D1         ADDSS XMM2, XMM1
; B1A:       660F7ED2         MOVD EDX, XMM2
; B1E:       48C1E220         SHL RDX, 32
; B22:       80CA19           OR DL, 25
; B25:       488BE5           MOV RSP, RBP
; B28:       F8               CLC
; B29:       5D               POP RBP
; B2A:       C3               RET

很显然,这里调用的就是 single-float-copysign 这个 VOP ,不过大家发现问题了吗?由于 SBCL 的 single-float-copysign 这个 VOP 是针对描述符寄存器的,使用的是整数的位运算,而浮点运算使用的是 SSE 浮点数寄存器 XMM ,所以会产生不必要的寄存器移动与 CPU 状态切换,这里显然是比较低效的。我们可以为 SBCL 的 single-float-copysign 定义一个基于 SSE 指令集的高效版本:

#+(and sbcl x86-64)
(sb-c:define-vop (single-float-copysign-sse)
  (:translate sb-kernel:single-float-copysign)
  (:args (xmm3 :scs (sb-vm::single-reg))
         (xmm1 :scs (sb-vm::single-reg)))
  (:arg-types single-float single-float)
  (:temporary (:sc sb-vm::single-reg :target r :to :result) xmm0)
  (:temporary (:sc sb-vm::single-reg) xmm2)
  (:results (r :scs (sb-vm::single-reg)))
  (:result-types single-float)
  (:policy :fast-safe)
  (:generator 2
              (sb-vm::inst movss xmm2 (sb-vm::constantize #x80000000))
              (sb-vm::inst movaps xmm0 xmm2)
              (sb-vm::inst andnps xmm0 xmm1)
              (sb-vm::inst andps xmm2 xmm3)
              (sb-vm::inst orps xmm0 xmm2)
              (unless (sb-c:location= r xmm0)
                (sb-vm::inst movss r xmm0))))

这里通过一些逻辑运算可以将 xmm3 寄存器中符号位与 xmm1 中的非符号位结合,结果存入 xmm0 寄存器中,其中用到的还有两个其他的 SBCL 内置函数:

  • sb-vm::constantize 用于插入一个常量,这里的 #x80000000 是二进制下最高位为 1 的掩码,用于处理符号位。
  • sb-c:location= 用于判断两个分配后的寄存器的相等性,在这里,如果没法将寄存器 xmm0 与返回值寄存器 r 分配为同一个寄存器,则需要将 xmm0 的内容移动至返回值寄存器 r 中。

注意我们定义的 single-float-copysign-sse 中, :generator 后的开销需要小于原来 SBCL 定义的 single-float-copysign 的开销,否则编译器不会选用我们定义的 VOP 。

对这段代码进行编译后,下面还是编译相同函数进行测试:

(defun test-float-sign (x y)
    (declare (optimize (speed 3)
                       (debug 0)
                       (safety 0))
             (type single-float x y)
             (values single-float))
    (+ (float-sign x y) (float-sign y x)))

反编译后的结果:

; disassembly for TEST-FLOAT-SIGN
; Size: 69 bytes. Origin: #x536BE968                          ; TEST-FLOAT-SIGN
; 68:       F30F100DC8FFFFFF MOVSS XMM1, [RIP-56]             ; [#x536BE938]
; 70:       0F28C1           MOVAPS XMM0, XMM1
; 73:       0F55C3           ANDNPS XMM0, XMM3
; 76:       0F54CA           ANDPS XMM1, XMM2
; 79:       0F56C1           ORPS XMM0, XMM1
; 7C:       F30F10E0         MOVSS XMM4, XMM0
; 80:       F30F100DB0FFFFFF MOVSS XMM1, [RIP-80]             ; [#x536BE938]
; 88:       0F28C1           MOVAPS XMM0, XMM1
; 8B:       0F55C2           ANDNPS XMM0, XMM2
; 8E:       0F54CB           ANDPS XMM1, XMM3
; 91:       0F56C1           ORPS XMM0, XMM1
; 94:       F30F10D0         MOVSS XMM2, XMM0
; 98:       F30F58D4         ADDSS XMM2, XMM4
; 9C:       660F7ED2         MOVD EDX, XMM2
; A0:       48C1E220         SHL RDX, 32
; A4:       80CA19           OR DL, 25
; A7:       488BE5           MOV RSP, RBP
; AA:       F8               CLC
; AB:       5D               POP RBP
; AC:       C3               RET

我们来测试一下 float-sign 的正确性:

(float-sign -1.0 22.0)                  ; => -22.0
(float-sign 3.0 22.0)                   ; => 22.0
(float-sign -1.0 sb-ext:single-float-positive-infinity) ; => #.SINGLE-FLOAT-NEGATIVE-INFINITY

结果都是正确的。优化前后汇编代码减少了两行,但实际考虑到 CPU 的状态切换,产生的性能差距应该会更大一些。下面我们将这个函数内联,在循环里实测一下性能:

(defun performance-test ()
  (declare (optimize (speed 3)
                     (debug 0)
                     (safety 0)))
  (trivial-benchmark:with-timing (10)
    (loop :for x :of-type single-float := 1.0 :then (+ x (test-float-sign -1.0 x))
          :repeat 100000000
          :finally (return x))))

(performance-test)

使用 trivial-benchmark 对循环进行 10 次采样的结果如下:

  • SBCL 原始版本
    ​-                SAMPLES  TOTAL     MINIMUM   MAXIMUM   MEDIAN    AVERAGE   DEVIATION
    REAL-TIME        10       4.873373  0.486669  0.493337  0.486671  0.487337  0.002
    RUN-TIME         10       4.86349   0.484148  0.490104  0.485853  0.486349  0.001473
    USER-RUN-TIME    10       4.856902  0.480859  0.490097  0.485855  0.48569   0.0024
    SYSTEM-RUN-TIME  10       0.006596  0         0.003302  0         0.00066   0.001318
    PAGE-FAULTS      10       0         0         0         0         0         0.0
    GC-RUN-TIME      10       0         0         0         0         0         0.0
    BYTES-CONSED     10       0         0         0         0         0         0.0
    EVAL-CALLS       10       0         0         0         0         0         0.0
    
  • 自定义的 SSE 指令集版本
    ​-                SAMPLES  TOTAL     MINIMUM   MAXIMUM   MEDIAN    AVERAGE   DEVIATION
    REAL-TIME        10       3.166692  0.313335  0.326669  0.316669  0.316669  0.003651
    RUN-TIME         10       3.163733  0.31455   0.327258  0.314947  0.316373  0.003683
    USER-RUN-TIME    10       3.163736  0.314551  0.327247  0.314949  0.316374  0.003679
    SYSTEM-RUN-TIME  10       0.000009  0         0.000009  0         0.000001  0.000003
    PAGE-FAULTS      10       0         0         0         0         0         0.0
    GC-RUN-TIME      10       0         0         0         0         0         0.0
    BYTES-CONSED     10       0         0         0         0         0         0.0
    EVAL-CALLS       10       0         0         0         0         0         0.0
    

使用 SSE 指令集的版本快了 35% ,是个比较可观的性能提升。

实现 CPUID

CPUID 是 x86 架构中的一条指令,它允许软件访问 CPU 的信息和功能。该指令可用于检索有关 CPU 的各种信息,例如型号、功能、支持的指令集以及操作系统和应用程序可以使用的特性。

要使用 CPUID 指令,我们需要将所需信息的编号放入 EAX 寄存器,然后执行 CPUID 指令。执行后, EAXEBXECXEDX 寄存器将包含所请求的信息,这在高级语言里不通过内联汇编或一些内置函数 (Intrinsics Function) 是无法做到的。下面我们就在 SBCL 里实现一下获取 CPUID 的返回的信息,为了方便起见,我们定义一个包用于导入 SBCL 的一些内部符号:

(defpackage sb-asm-test
  (:use #:cl #:sb-c #:sb-vm)
  (:nicknames #:asm-bench)
  (:import-from #:sb-vm #:inst #:function-raw-address
                #:any-reg #:unsigned-reg #:unsigned-num #:positive-fixnum #:tagged-num .
                #+x86-64
                (#:rax-offset #:rcx-offset #:rdx-offset #:rbx-offset #:rsp-offset #:rbp-offset #:rsi-offset #:rdi-offset
                              #:r8-offset  #:r9-offset #:r10-offset #:r11-offset #:r12-offset #:r13-offset #:r14-offset #:r15-offset))
  (:import-from #:sb-x86-64-asm #:ea))

先给出 DEFKNOWN 定义:

(in-package #:sb-asm-test)

(defknown cpuid ((unsigned-byte 32))
    (values (unsigned-byte 32) (unsigned-byte 32) (unsigned-byte 32) (unsigned-byte 32))
    (flushable movable))             ; 不能将这个函数标注为 `foldable'

这里需要注意一下,由于 CPUID 在不同的机器上的结果可能不一样,所以我们不应该让编译器在传入常量时优化掉这条指令。下面是 DEFINE-VOP 的定义:

(in-package #:sb-asm-test)

(define-vop (cpuid)
  (:policy :fast-safe)
  (:translate cpuid)
  (:args (arg-rax :scs (unsigned-reg) :target rax))
  (:arg-types unsigned-num)
  (:temporary (:sc unsigned-reg :offset rax-offset) rax)
  (:temporary (:sc unsigned-reg :offset rbx-offset) rbx)
  (:temporary (:sc unsigned-reg :offset rcx-offset) rcx)
  (:temporary (:sc unsigned-reg :offset rdx-offset) rdx)
  (:results (res-1 :scs (unsigned-reg)) (res-2 :scs (unsigned-reg))
            (res-3 :scs (unsigned-reg)) (res-4 :scs (unsigned-reg)))
  (:result-types unsigned-num unsigned-num unsigned-num unsigned-num)
  (:generator
   4
   (unless (location= arg-rax rax)
     (move rax arg-rax))
   (inst cpuid)
   (loop :for reg :in (list rax rbx rcx rdx)
         :for res :in (list res-1 res-2 res-3 res-4)
         :unless (location= reg res)
           :do (move res reg))))

最后我们需要定义一个同名函数作为入口。前面也提到了,函数体在编译的过程中会展开为我们上面编写的 cpuid 这个 VOP ,所以不用担心无限递归的问题,但需要注意类型声明,使得这个 VOP 能被应用:

(in-package #:sb-asm-test)

(defun cpuid (eax)
  (declare (type (unsigned-byte 32) eax)
           (optimize (speed 3)
                     (safety 0)
                     (debug 0)))
  (cpuid eax))

下面我们反编译这个函数看看:

; disassembly for CPUID
; Size: 61 bytes. Origin: #x536F14FA                          ; CPUID
; 4FA:       48D1FE           SAR RSI, 1
; 4FD:       488BC6           MOV RAX, RSI
; 500:       0FA2             CPUID
; 502:       4C8BC0           MOV R8, RAX
; 505:       488BFB           MOV RDI, RBX
; 508:       488BF1           MOV RSI, RCX
; 50B:       4C8BCA           MOV R9, RDX
; 50E:       49D1E0           SHL R8, 1
; 511:       48D1E7           SHL RDI, 1
; 514:       48D1E6           SHL RSI, 1
; 517:       49D1E1           SHL R9, 1
; 51A:       498BD0           MOV RDX, R8
; 51D:       4C894DF0         MOV [RBP-16], R9
; 521:       488D5D10         LEA RBX, [RBP+16]
; 525:       B908000000       MOV ECX, 8
; 52A:       F9               STC
; 52B:       488D65F0         LEA RSP, [RBP-16]
; 52F:       488B6D00         MOV RBP, [RBP]
; 533:       FF73F8           PUSH QWORD PTR [RBX-8]
; 536:       C3               RET

可以看到, CPUID 这条汇编指令成功地出现在我们的 cpuid 这个函数体里。值得一提的是由于 cpuid 的参数与返回值是 32 位无符号整数,可以被 fixnum 容纳,因此 SBCL 自动将参数与返回值均视为 fixnum 而进行了移位操作,这意味着对于整数、浮点数这些,我们通常不需要考虑 VOP 的参数、返回值与 Lisp 对象的转换问题,像 (unsigned-byte 64) 这样无法被 fixnum 容纳的类型, SBCL 也会进行自动的拆装箱操作。

下面我们可以实际地运用一下我们编写的这个 cpuid 函数,获取 CPU 的制造商信息:

(in-package #:sb-asm-test)

(defun cpu-vendor ()
  (declare (optimize (speed 3)
                     (debug 0)
                     (safety 0)))
  (multiple-value-bind (eax ebx ecx edx) (cpuid 0)
    (declare (ignore eax))
    (loop :for reg :of-type (unsigned-byte 32) :in (list ebx edx ecx)
          :nconc (loop :for i :below 4
                       :collect (code-char (ldb (byte 8 (* i 8)) reg)))
            :into result
          :finally (return (coerce result 'string)))))

(cpu-vendor)                            ; => "GenuineIntel"

总结

SBCL 中的 VOP 可以看作是一般的 Lisp 程序与汇编程序的桥梁,当 Lisp 程序中函数的调用类型能够确定且满足条件时, SBCL 会尝试使用 VOP 或一系列变换规则对其进行优化,利用这一点,我们可以比较容易地在 SBCL 里内联汇编,实现提高性能或进行一些硬件操作。作为动态语言实现的 SBCL (前身为 CMUCL ),其具有极高的上限(指动态性),也具有极高的下限(指底层编程的能力),加上 Lisp 的表达能力与交互性, 世界上很难有第二门语言能在这几个方面 同时 与之媲美的,但可惜的是 SBCL 没有为其底层编程的能力对外开放一套较为稳定的 API 并且缺乏对应的文档,因此本文中的所有代码仅保证在 x86-64 的 SBCL 2.2.11 上能够正常运行,希望以后 SBCL 开发者能在这方面下一点功夫。这里也安利给学习汇编或做底层开发的道友,不妨可以试一下 SBCL ,秒编译秒运行的体验确实相当地棒!

15 个赞

之前看 luajit 用汇编实现 luajit interpreter,用的是 dynasm,看来以后自己写一个 Lisp 可以考虑用 SBCL 的 assembler 做汇编器了:smiley:(事实上,你 post 的那个外国网友写的帖子就是用 SBCL 的 assembler 实现一个栈虚拟机的解释器)

另外我觉得 CCL(Clozure) 的 LAP 也挺有意思的,看着比 SBCL 用 inst 宏更 declarative

https://xfy.vercel.app/posts/write-assembly-code-in-ccl

https://xfy.vercel.app/posts/debug-ccl-using-gdb/

CCL 的编译速度在大部分情况下可以吊打 SBCL ,可惜原作者走了以后就没有什么实质性的更新了,而且现在几乎不怎么维护了,而 SBCL 还是保持着月更的传统, 前几天 2.3.0 都出了。

CCL 的看上去更简洁一点,但表达能力 SBCL 应该更好,因为 SBCL 的 DEFINE-VOP 主体部分是代码生成器而不是代码模板,其中可以执行任意的 Lisp 表达式,包括自定义的函数、宏以及各种流程控制语句,而不仅仅是定义几条汇编指令那么简单,我给出的 cpuid 例子里就使用了 loop 宏来一次完成多条指令的生成, inst 宏执行顺序,代表着最终生成的汇编代码顺序,要实现 CCL 那种不用写 inst 的风格在 SBCL 里自己写一个宏就可以做到。

楼主用过 FASM G 吗

我用它来给 HP Saturn 写过汇编,不过主要还是支持 x86 和 ARM

没有,不过看上去是一个带有比较完善宏机制的汇编生成器?

主打的就是宏和交叉汇编,指令集可以完全用宏定制的。不过本身是用 x86 汇编自举的,所以非 x86/amd64 的电脑得用 DOS 模拟器或者虚拟机运行。

看起来有点意思,我有空去深入了解一下