与C语言交互

现代化的大型项目往往是由多种语言混合编写而成的。我第一次了解到这个知识的时候,觉得这件事是那么的理所当然,又那么的匪夷所思。不同的语言之间,是怎么互相调用的呢?

事实上,现在最优雅的方法,是多进程模型。也就是说,一个语言编写一个独立的程序,然后使用进程间通信、HTTP通信等方案,通过数据交流实现函数的调用。

但是,这种方案在进程之间进行数据交换,必然会带来不小的性能开销。如果追求极致的性能,我们可以考虑将高级语言编译出的目标文件链接为同一个可执行文件,也就是本章讲的方案。

目标文件链接

我们知道,无论是使用什么编译型语言,最终生成的永远是一个含有机器指令的可执行程序。在二进制层面,这些语言之间是没有本质区别的。因此,我们也可以使用在上一章所讲的方法,将不同语言生成的目标文件(一般来说,高级语言会支持生成动态链接库或者静态链接库)进行链接,最终生成一个二进制程序。在本章中,就以C语言文件和汇编语言文件互相调用为例。

通过这种方式的链接,我们需要注意什么呢?

ABI

首先,是ABI兼容性。我们之前提到过,不同的编程语言的ABI不同。因此,为了实现正确的调用,应当统一ABI。也就是说,两个语言互相调用的函数应该是ABI一致的。现在大部分编程语言的做法,就是将这些函数的ABI统一为平台上C语言的ABI。

在我们的例子中,C语言与汇编语言互相调用,其ABI是一致的,所以不需要额外的操作。对于C++来说,extern "C"就是表明后面的函数是C的ABI。

尽管ABI一致,这里还需要指出的是,在汇编语言层面,我们操作数据的时候需要知道数据宽度,也就是4字节、8字节等。在C语言中,我们用intchar等来表示变量的类型,间接地规定了数据宽度。在Apple Silicon平台的C语言实现中,我们常见的变量类型的宽度为:

  • bool:1字节
  • char:1字节
  • short:2字节
  • int:4字节
  • long:8字节(这里注意需要和汇编语言层面的.long区别,后者表示4字节宽度)
  • long long:8字节

命名修饰

除了ABI需要保持一致,我们之前提到,在链接的过程中,链接器是通过符号名来解析函数的。我们知道,在汇编语言中,符号名就是函数名。那么,在高级语言中,符号名是不是就是函数名呢?答案是否定的。为什么呢?

在高级语言中,往往会有很多特性。例如,函数的名称可以包含Unicode字符;又比如说,C++的函数支持重载。也就是说,同一个函数名可以有多个函数实现,通过函数签名的不同来区分函数。但是在二进制层面,一个符号只能对应一个函数。因此,在高级语言生成二进制程序的过程中,存在一个「命名修饰」(Name Mangling)阶段。

所谓的命名修饰,就是将函数名通过不同的规则转变为相应的符号名。在macOS平台上,C语言的函数名通过在前面加上_生成符号名。例如,C语言中的foo函数,其对应的符号名为_foo。我们在汇编语言中调用C语言中定义的foo函数时,就需要使用bl _foo

同理,在C语言中调用汇编语言时,需要去除前面的_。例如,我们在汇编语言中编写了一个函数供C语言调用,因此,我们需要将其命名为_开头的标签,如_bar。在C语言中,我们就可以直接使用bar作为函数名调用函数。

内联汇编

除了将不同语言生成的目标文件链接为一个可执行程序之外,大部分的高级语言都支持「内联汇编」(Inline Assembly)功能。以C语言为例,C语言的标准中并没有内联汇编功能,而大部分C编译器,如GCC(How to Use Inline Assembly Language in C Code)、Clang(Inline assembly)等,都有内联汇编的功能扩展。

最简单的,我们可以用asm关键字标记一条汇编语句:

asm("mov    x0, #0");

如果需要和C语言中的变量进行交互,我们就需要使用更复杂的语句。以swap函数为例(完整代码位于codes/15-swap.c):

int swap(int *a, int *b) {
    int sum;
    asm(
        "ldr    w9, [%1]\n\t"
        "ldr    w10, [%2]\n\t"
        "str    w9, [%2]\n\t"
        "str    w10, [%1]\n\t"
        "add    w9, w9, w10\n\t"
        "str    w9, %0"
        : "=m" (sum)
        : "r" (a), "r" (b)
        : "w9", "w10"
    );
    return sum;
}

这个函数做了两件事:首先,将ab指向的值互换;其次,将两者值相加作为函数的返回值。

我们可以看到,在asm开头的内联汇编代码有四个部分,分别用:分隔。这四个部分分别被称作:

  1. 汇编模板
  2. 输出操作数
  3. 输入操作数
  4. 保留寄存器

第一个部分,被称作汇编模板。其可以包含多条汇编语句,每条汇编语句之间用\n\t分隔。我们可以注意到,除了我们正常使用到的汇编指令、寄存器之外,还有%0%1这样的符号。这种符号会按顺序索引输出操作数和输入操作数。例如,我们例子中有1个输出操作数,2个输入操作数,那么%0就索引第一个输出操作数,%1索引第一个输入操作数,%2索引第二个输入操作数。在操作数前的rm分别表示用寄存器还是内存替代。例如,"r" (a)替代%1后,会在%1的区域直接使用某些寄存器,如ldr w9, [w10]"m" (sum)则会使用一个内存地址替代%0,例如在%0的位置被替换为[x12]

第二个部分为输出操作数,第三个部分为输入操作数。以rm表示变量是存储在寄存器中还是存储在内存中,在输出操作数中,如果以=为前缀,则说明这个变量只被写入,不被读出;以+为前缀,则说明这个变量在内联汇编中既被写入也被读出。

第四个部分为保留寄存器。由于我们在内联汇编中使用了w9w10作为临时存储值的寄存器,所以我们不希望在这个函数内部有别的地方使用这个寄存器,因此我们在这个部分声明了这两个寄存器,这样的话编译器就不会在这个函数内使用这两个寄存器来存储别的变量的值,就不会发生意外。