9.1. 内嵌汇编基本格式
内嵌汇编的基本格式如下:
asm asm-qualifiers (
"Assembler Template" //汇编模板
: OutputOperands //输出操作数
: InputOperands //输入操作数
: Clobbers //破坏描述
);
内嵌汇编使用asm()形式表示,括号内最多包含4个部分:Assembler Template,即汇编模板,可包含0条或多条内嵌汇编指令;OutputOperands,即输出操作数,可以没有,也可以有多个;InputOperands,即输入操作数,可以没有,也可以有多个;Clobbers,即破坏描述,可以为空,也可以列出多个条目。各部分之间用“:”分隔。asm-qualifiers是asm()的限定符,可以为空,也可以是volatile、inline、goto中的一种。
其中,汇编模板部分必须存在,但内容可以为空字符串""。汇编模板也可以包含一条或多条内嵌汇编指令,每条指令通常以双引号""为单位,并以\n\t结尾,或通过换行分隔。下面两种写法都是合法的:
asm("");
asm("add.d %0,%1\n\t"
"add.d %0,%2\n\t"
:"=r"(ret)
:"r"(a),"r"(b)
);
asm("")中没有任何内嵌汇编指令,因此编译器不会为它生成汇编指令。第二段内嵌汇编包含两条汇编指令,但没有显式使用固定寄存器,所以破坏描述部分可以省略。
如果内嵌汇编中只有汇编指令,不需要输出操作数、输入操作数和破坏描述,后面的“:”都可以省略。例如:
asm("break 0");
这里仅向程序中插入一条中断指令,不需要任何变量参与,因此只需执行LoongArch中断指令break 0。
还需要说明,asm是__asm__的别名,因此上面的语句也可以写成:
__asm__("break 0");
注意:如果内嵌汇编只使用后面的部分,而前面的部分为空,那么空缺部分仍需要用“:”占位。下面的示例有输入操作数,但没有输出操作数和破坏描述,因此输出操作数前的“:”不能省略,后面的破坏描述可以省略。这样写是为了让编译器正确识别各个部分。
asm("move $4,%0\n\t"
: //输出操作数
:"r"(a) //输入操作数
);
在asm模板中,可以使用/*...*/或//添加注释。通常,/*...*/用于块注释,//用于行注释。
从上面示例还可以看出,指令操作数既可以使用占位符,也可以直接使用通用寄存器或浮点寄存器。使用寄存器作为操作数时,写法与汇编源程序一致,即寄存器名前需要加$,$后可以跟寄存器编号(如$4),也可以跟寄存器别名(如$a0)。上面这条指令把C语言变量a的值写入通用寄存器r4。
9.1.1. 输入操作数和输出操作数
在内嵌汇编格式中,输入操作数和输出操作数中的每个操作数,都由一个带双引号""的约束字符串,以及一个放在括号中的C语言表达式或变量组成。例如,本章开头内嵌汇编中的"r"(src),其中r是约束符,src是C语言变量。当存在多个输入或输出操作数时,操作数之间用“,”分隔。内嵌汇编会对输入操作数和输出操作数统一编号,并用%num形式引用,num为从0开始的非负整数。例如,一个输出操作数和两个输入操作数的内嵌汇编如下:
asm("add.d %0,%1,%2\n\t"
:"=r"(ret) //输出操作数,也是第0个操作数
:"r"(a),"r"(b) /*输入操作数,也是第1个操作数和第2个操作数*/
);
这里使用LoongArch指令add.d实现C语言中的ret = a + b。两个输入操作数"r"(a)和"r"(b)之间用“,”分隔。%0表示操作数"=r"(ret),%1表示"r"(a),%2表示"r"(b)。
每个操作数前的约束字符或字符串,用于限制后面C语言表达式或变量的处理方式。编译器会根据这些约束决定如何分配资源。例如,"=r"(ret)中的“=r”包含两个约束:“=”表示该操作数是输出操作数,因此不会出现在输入操作数列表中;“r”表示需要为该操作数分配一个寄存器,也就是把它与某个寄存器关联。约束字符还有很多,其中一部分与具体体系架构相关,8.2节会详细列举。
输入操作数通常是C语言变量,也可以是C语言表达式。例如:
asm("move %0,%1\n\t"
:"=r"(ret)
:"r"(&src+4)
);
这里的输入操作数&src + 4就是C语言表达式。编译器会先把地址&src + 4分配给一个通用寄存器,再参与move指令操作,最终赋值给变量ret。
输出操作数必须是左值,编译器会进行检查。简单来说,以赋值符号“=”为界,“=”左边是左值,右边是右值。因此,输出操作数必须用“=r”标识。输入操作数既可以是左值,也可以是右值。输出操作数也可以有多个;当存在多个输出操作数时,每个输出操作数都需要使用“=”标识。例如,LoongArch读取当前处理器核计时器的指令rdtime.d rd, rj中,rd和rj都是输出:rd用于读取counter值,rj用于读取counterid。使用该指令的内嵌汇编可写为:
unsigned long count = 0;
int count_id = 0;
asm( "rdtime.d %0,%1\n\t"
:"=r"(count),"=r"(count_id)
);
默认情况下,输出操作数权限是只写(Write-Only),但编译器不会额外检查这一点。这个特性有时会引发问题。若在内嵌汇编指令中误把输出操作数当作右值使用,编译器编译时不会报错,但程序运行结果可能不符合预期。此时可以使用限制符“+”把输出操作数权限改为可读可写。例如:
asm("add.d %0,%0,%1\n\t"
:"+r"(ret)
:"r"(a)
);
这实现了变量ret的累加,即ret = ret + a。也可以使用数字限制符“0”实现类似效果。
asm("add.d %0,%1,%2\n\t"
:"=r"(ret)
:"0"(ret),"r"(a)
);
这里数字限制符“0”表示输入操作数ret与第0个输出操作数使用同一地址空间。数字限制符只能用于输入操作数,并且必须指向某个输出操作数。它在日常编程中并不常见,了解即可。
9.1.2. 破坏描述
内嵌汇编中的破坏描述用于声明汇编指令部分会写入哪些寄存器或内存,从而通知编译器这些寄存器或内存在该内嵌汇编中会被破坏,需要提前保存,并在内嵌汇编执行后恢复旧值。破坏描述主要有两种形式:声明寄存器和声明memory。
破坏描述寄存器
通常,内嵌汇编更多使用C语言变量,而变量对应的寄存器由编译器根据整个函数上下文分配。编译器通常会选择未使用寄存器,或已经完成保存处理的寄存器。但是,汇编指令部分也可以直接使用LoongArch寄存器,并对其执行写操作。如果没有在破坏描述中声明这些寄存器,编译器就不会进行相应检查和保护,可能导致某个寄存器旧值未保存就被修改,引发程序错误甚至严重异常。例如,下面的内嵌汇编直接使用寄存器,却没有破坏描述:
asm("li.w $ra, 0\n\t");
这段内嵌汇编把0写入通用寄存器ra。在LoongArch ABI中,ra用于保存函数返回地址。这里ra被改写为0,却没有通知编译器,会导致该内嵌汇编所在函数无法找到原返回地址,从而不能正常返回。正确做法是在破坏描述中声明ra会被修改:
asm("li.w $ra, 0\n\t" :::"$ra");
如果内嵌汇编中有多个固定寄存器会被破坏,建议在破坏描述中全部声明。声明多个寄存器时,用“,”分隔:
asm(
"add.d $a3, $a1, $a2\n\t"
"move $v0,%0\n\t"
:"=g"(ret)
:"r"(a),"r"(b)
:"$a3", "$v0"
);
该示例中,a3和v0都是目的寄存器,存在写操作,因此需要通过破坏描述通知编译器保存并恢复它们的旧值。
破坏描述memory
GCC编译器通常会对生成指令进行优化,例如使用-O2、-O3时。在保证程序正确性的前提下,编译器会尽量使用寄存器缓存数据,减少访存指令。先看下面的C语言语句:
a += 1;
b += a;
实现a += 1通常需要3条汇编指令:从内存加载变量a到寄存器,与立即数1相加,再写回内存。实现b += a通常需要4条汇编指令:分别从内存加载b和a,执行加法,再写回b。没有优化时,两条语句共需7条汇编指令。但执行b += a时,可以复用a += 1后a所在寄存器的值,省去再次从内存加载a的操作,最终只需6条指令。GCC生成的结果也确实如此。
ldptr.w $r12,$r4,0 #从内存加载变量a的值
addi.w $r12,$r12,1(0x1) #加法运算
stptr.w $r12,$r4,0 #将结果写回变量a所在内存
ldptr.w $r13,$r5,0 #从内存加载变量b的值
add.w $r12,$r13,$r12 #加法运算(复用r12的结果)
stptr.w $r12,$r5,0 #将结果写回变量b所在内存
如果在这两条C语句之间插入一段内嵌汇编:
a += 1;
asm volatile("":::"memory");
b += a;
编译器生成的汇编指令会发生变化:
ldptr.w $r12,$r4,0 #从内存加载变量a的值
addi.w $r12,$r12,1(0x1)
stptr.w $r12,$r4,0
ldptr.w $r12,$r5,0
ldptr.w $r13,$r4,0 #再次从内存加载变量a的值
add.w $r12,$r12,$r13
stptr.w $r12,$r5,0
可以看到,插入带memory破坏描述的内嵌汇编后,b += a没有复用a += 1的寄存器结果,而是重新从内存加载a到r13后再计算。这里的memory相当于告诉编译器,不要继续依赖之前缓存到寄存器中的内存值。
memory破坏描述的作用可以概括为:通知编译器asm中可能修改了内存,因此在asm前后不要对访存相关语句做值假设或相关优化,而应及时刷新内存,即把寄存器数据写回内存,或从内存重新读取最新数据。下面再看一个更实际的例子。考虑如下代码的输出结果:
int dest = 1;
int add_value = 2;
int old_value;
asm volatile ( "amadd.w %0, %1, %2 \n\t"
: "=&r" (old_value)
: "r" (add_value), "r" (&dest)
:
);
printf("old_value=%d dest=%d\n", old_value, dest);
这段代码使用原子指令amadd.w实现加法操作:把变量dest与add_value相加,将结果写回dest,同时把dest旧值写到old_value。期望结果是dest = 3、old_value = 1。但实际结果可能是dest = 1、old_value = 1。问题出在哪里?先看对应汇编指令:
addi.w $r12,$r0,2(0x2) //add_value
addi.d $r13,$r3,12(0xc) //加载dest地址到r13
amadd.w $r5,$r12,$r13 //取dest地址中的旧值加2后写回dest地址
addi.w $r6,$r0,1(0x1) // 加载dest数据1到r6
bl -352(0xffffea0) # 1200005a0 <printf@plt> //输出数据
从addi.w $r6,$r0,1(0x1)可以看出,编译器并未发现这段内嵌汇编对dest执行了写操作。因此,在调用printf之前,编译器没有重新读取dest所在内存,而是直接用addi.w把参数寄存器r6设为1,导致输出的dest仍为1。要让dest + 2的结果可被观察到,需要使用memory描述符。正确写法如下:
asm volatile ( "amadd.w %0, %1, %2 \n\t"
: "=&r" (old_value)
: "r" (add_value), "r" (&dest)
: "memory"
);
此时再查看生成的汇编指令:
addi.w $r12,$r0,2(0x2) //add_value
addi.d $r13,$r3,12(0xc) //加载dest地址到r13
amadd.w $r5,$r12,$r13 //取dest地址中的旧值加2后写回dest地址
ldptr.w $r6,$r3,12(0xc) //加载dest地址中的数据到r6
bl -352(0xffffea0) # 1200005a0 <printf@plt> //输出数据
可以看到,调用printf前,参数寄存器r6的值来自对dest内存地址的重新读取。amadd.w已经把dest + 2的结果3写回dest所在内存,因此ldptr.w读到的r6值为3。
9.1.3. 有名操作数
从GCC 3.1开始,内嵌汇编支持有名操作数,也就是可以为输入操作数和输出操作数命名。名称形式为[name],其中name可由大小写字母、数字、下划线等组成,并放在每个操作数前。在汇编指令中引用有名操作数时,写作%[name],区别于前文使用的操作数序号%num。下面是一段使用有名操作数的内嵌汇编:
asm("add.d %[out],%[in1],%[in2]\n\t"
:[out]"=g"(ret)
:[in1]"r"(a), [in2]"r"(b)
);
这段内嵌汇编为每个操作数定义了名称,分别为out、in1和in2,对应变量ret、a和b。汇编指令中的操作数既可以使用前文介绍的add.d %0, %1, %2形式,也可以使用操作数名称,即add.d %[out], %[in1], %[in2]。当然,也可以只给部分输出或输入操作数命名。例如:
asm("add.d %[out],%1,%2\n\t"
:[out]"=g"(ret)
:"r"(a),"r"(b)
);
这里只给第0个操作数ret命名。第1个和第2个操作数仍使用序号形式%1、%2。