内联汇编的限制符
问题背景
最近,在编写代码时,因为限制用到了内联汇编(inline assembly)。之前对这种在C代码里嵌入汇编的方式了解的并不多,只知道可以通过asm()
来实现。但是,编写的代码经过编译器-O2
优化后的代码却出现了问题。
简单的示例代码如下:
这里是编写了一个函数add()
,里面通过内联汇编,把参数相加。我还按照之前的经验,以为函数的参数会通过rdi
和rsi
传入,rax
的值作为函数的返回结果被使用。
在没有开启编译优化的情况下,上述代码是可以正常运行的:
但是,当开启了编译优化后,运行的结果就出错了:
此时,查看编译得到的二进制代码,发现确实存在问题:
可以看到,108a
处就是被内联优化的方法add()
,包含了内联汇编的两条指令。但是其参数rdi
和rsi
,原本应该是两次调用strtol()
的返回结果,却并没有看到相关设置;而且add()
的返回结果rax
,也没有被最后的printf()
所使用。因此,造成了计算结果的错误。
问题原因
在网上搜索了相关内容,大致理解了编译器这样优化出错的原因。
一般来说,编译器对asm()
中的汇编代码具体内容,并不会去关注。因为这些汇编代码是用户所编写,编译器会直接保留。因此,像开始的那种写法:
对于编译器而言,它看到的是一个接受了2个参数的函数add()
,但这个函数并没有对传入的参数做任何使用,也不知道函数的返回结果是在哪里。这种没有使用传参的函数,按理来说,对传入的任何参数值,其运行的结果应该都是相同的。因此,当编译器在做优化时,就不会再使用额外的指令,把rdi
和rsi
设置为所需的参数值。返回结果也是同样的原因,被编译器优化掉了。
因此,我们需要通过某种方式告诉编译器,这个函数所接收的参数在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()
方法再视作纯函数优化了: