内联汇编的限制符

问题背景

最近,在编写代码时,因为限制用到了内联汇编(inline assembly)。之前对这种在C代码里嵌入汇编的方式了解的并不多,只知道可以通过asm()来实现。但是,编写的代码经过编译器-O2优化后的代码却出现了问题。

简单的示例代码如下:

这里是编写了一个函数add(),里面通过内联汇编,把参数相加。我还按照之前的经验,以为函数的参数会通过rdirsi传入,rax的值作为函数的返回结果被使用。

在没有开启编译优化的情况下,上述代码是可以正常运行的:

但是,当开启了编译优化后,运行的结果就出错了:

此时,查看编译得到的二进制代码,发现确实存在问题:

可以看到,108a处就是被内联优化的方法add(),包含了内联汇编的两条指令。但是其参数rdirsi,原本应该是两次调用strtol()的返回结果,却并没有看到相关设置;而且add()的返回结果rax,也没有被最后的printf()所使用。因此,造成了计算结果的错误。

问题原因

在网上搜索了相关内容,大致理解了编译器这样优化出错的原因。

一般来说,编译器对asm()中的汇编代码具体内容,并不会去关注。因为这些汇编代码是用户所编写,编译器会直接保留。因此,像开始的那种写法:

对于编译器而言,它看到的是一个接受了2个参数的函数add(),但这个函数并没有对传入的参数做任何使用,也不知道函数的返回结果是在哪里。这种没有使用传参的函数,按理来说,对传入的任何参数值,其运行的结果应该都是相同的。因此,当编译器在做优化时,就不会再使用额外的指令,把rdirsi设置为所需的参数值。返回结果也是同样的原因,被编译器优化掉了。

因此,我们需要通过某种方式告诉编译器,这个函数所接收的参数在asm()的内联汇编中是有读取使用的,而且函数的返回值也在内联汇编中设置完成。这样,即使开启了编译优化选项,也可以得到正确运行的结果。而这种告诉编译器哪些变量、寄存器被使用、返回的方式,就是设置asm()中的内联汇编限制符(inline assembly constraints)。

内联汇编的限制符

关于限制符的使用,可以阅读这篇文章详细了解,这里就只做简单的介绍。

完整的内联汇编格式是 asm(汇编代码 : 输出表 : 输入表 : 破坏的内容)。汇编代码后面的内容就是限制符,它告诉了寄存器,前面的汇编代码中,哪些是输出,那些是输入,哪些是运行时被修改破坏(clobbered)的内容。

对于输出表和输入表来说,可以包含多项,用逗号隔开。每项使用"x"(y)的形式,其中双引号中的字符串x是这一项的类型,括号中的y是这一项的符号名称。常见的类型字符串有:

例如,"b"(var)限制变量var应该保存在寄存器rbx中。对于输出来说,通常还需要在类型字符串前面加上=或者+,前者说明是只写的,后者说明是读写的。

对于破坏的内容,则可以直接通过"%rax", "%rbx"这种形式告诉编译器哪些寄存器的值被修改了,如果要使用这些寄存器,需要备份其值。除了寄存器,还可以通过"cc"说明条件码被修改了(通常是内联汇编代码中有比较大小的指令),通过"memory"说明内存被修改了。所有这些项,同样被逗号分隔。

如果设置了输出表或者输入表的内容,在内联汇编代码中,还可以通过%0, %1这种方式来获取对应的输出、输入项。对应规则是:将输出表中的各项依次排列,再跟上输入表中的各项目,按照从0开始依次编号。例如:

上述代码中,限制符指定了输出是寄存器中的变量j,输入是寄存器中的变量i。按照规则,%0就是输出的第一项,%1就是接下来的输入的第一项。而相应的汇编代码所操作的内容,就是把输入寄存器的值,mov到了输出寄存器中。而为了和这种记录方式区别开来,汇编代码中正常的寄存器引用,需要用连续两个%符号,例如%%rax

回到最开始的add()函数,我们可以为其中的内联汇编添加限制符如下:

上面这段汇编代码,是从输入的寄存器rdi中读取变量i、寄存器rsi中读取变量j,运算后将结果输出到寄存器rax中的变量res。除了rdi, rsi, rax这三个寄存器,其他寄存器并没有被修改,所以限制符最后破坏的内容为空。

经过这样的修改,我们就可以告诉编译器:add()函数的参数是有被内联汇编使用的,不能被优化掉;内联汇编代码的结果会保存在变量res中,返回这个变量即可。完整的代码如下:

此时,开启-O2优化选项,得到的运行结果也是正确的:

查看二进制代码,可以确认此时的指令是正确的:

可以看到,1085处将第一次strtol()的结果rax保存到r12中,在108d处将r12的值设置给edi,作为add()的第一个参数。类似地,第二次strtol()的结果在1094处被设置给esi,作为add()的第二个参数。这两个参数的设置都没有问题。而add()的返回结果,也在10a3处设置到ecx,正确地作为printf()的第4个参数。

另一个例子

接下来,我们再看一个例子。开始之前,我们先介绍下纯函数(pure function)和对其的优化。

如果一个函数的运算结果只依赖于传入的参数,而且除了运算结果没有其他的副作用,那么这样的函数就可以称为“纯函数”。如果熟悉Haskell这类函数式编程,那么应该对纯函数不陌生。上文中的方法add()就是一个纯函数,用户也可以给函数显式地添加__attribute__ ((pure)),告诉编译器某个函数是纯函数。

当编译器遇到一个纯函数,而且这个纯函数有被连续多次以同样的参数调用,那么根据纯函数的性质,编译器认为这多次调用的返回结果都是相同的,而且没有副作用。此时,往往会把多次调用优化为一次。例如:

使用-O2优化选项,编译得到的结果是:

可以看到,在10b8处是一个无条件跳转,回到10a0处,构成了while循环。而纯函数add()只在108f处有调用过一次,并没有在while循环的内部每次调用。这正是编译器把add()作为纯函数来优化的结果。

但是,如果add()函数中的内联汇编代码并没有构成纯函数,此时就需要小心设置限制符了。例如:

此时的add()就不是纯函数了,因为其除了从int *i中读取并和j相加,还会把int *i的内存内容加一,这就是它的副作用。按理来说,由于这个副作用的存在,main()函数中的while循环不会一直运行下去,但优化编译后的结果却并不是这样:

可以看到,在10cd处的跳转回10b0,对应的就是while循环。但是109b处被内联优化的add()函数仍然在while循环外,只执行了一次。这是因为编译器错误地认为add()仍然是一个纯函数,而且每次执行add()的参数不变,所以优化后的结果就出错了。

我们回到add()中的内联汇编限制符来分析原因。"D"(i)说明i是保存在寄存器rdi中,但这里的i实际上是一个int指针,指向了一片内存区域,所以用"D"这种类型字符串,实际上是把i是指针这一信息丢失了的。为了解决这一问题,我们可以修改限制符,把i的类型用"m"(内存)来表示。修改后的完整代码如下:

可以看到,现在的i是在内存中,而且汇编代码中使用其对应的%1指代。此时,编译器知道了有内存地址被传入了内联汇编代码,可能产生副作用,就不会把add()方法再视作纯函数优化了:

本文由 Galaxy Lab 作者:刘瑞恺 发表,其版权均为 Galaxy Lab 所有,文章内容系作者个人观点,不代表 Galaxy Lab 对观点赞同或支持。如需转载,请注明文章来源。
2

发表评论

*