1.2 汇编语言的使用场景
很多使用高级语言编程的人员从业之初甚至从业多年都会有这样的疑问:现在大多数应用程序都是使用高级语言进行编程,学习汇编语言有什么用处?下面列举几个汇编语言的使用场景。
1.2.1 场景1——快速定位问题和分析问题
举一个不太“烧脑”的小例子:浮点数例外。多数程序开发者在工作中都会遇到异常信号SIGFPE,当程序执行除法运算语句时,如果被除数为0,那么系统就会毫不留情地发送给你一个信号SIGFPE(中文或许显示“浮点数例外”)。这样一个异常的C语言代码如下:
int test(int a, int b){
return a/b;
}
当我们调用函数test时 ,故意给参数b传入0,那么就会收到“浮点数例外”。使用调试工具GDB(GNU Debugger ,Linux系统下的调试工具)在龙芯平台上调试这段代码时,可以获取如下信息:
Program received signal SIGFPE, Arithmetic exception.
0x00000001200006ec in test ()
(gdb) bt
#0 0x00000001200006ec in test ()
#1 0x0000000120000734 in main ()
这里GDB已经列出了函数调用栈,即函数main调用了函数test,在执行函数test中地址为0x00000001200006ec 处的指令时,触发异常SIGFPE。那么0x00000001200006ec处的指令是什么呢?我们可以使用GDB进一步确认。
(gdb) x/5i $pc-12
0x1200006e0 <test+40>: ld.w $r12,$r22,-24(0xfe8)
0x1200006e4 <test+44>: div.w $r14,$r13,$r12
0x1200006e8 <test+48>: bne $r12,$r0,8(0x8) # 0x1200006f0 <test+56>
=> 0x1200006ec <test+52>: break 0x7
0x1200006f0 <test+56>: move $r12,$r14
其中 => 标识了当前PC(Program Counter,PC用来存放当前欲执行指令的地址)位置,即当前程序停在的位置。上面的汇编指令div.w $r14,$r13,$r12 为除法指令,实现用寄存器 $r13除以$r12,将结果写入$r14 。汇编指令bne $r12,$r0,8(0x8) # 0x1200006f0 是条件跳转指令,判断被除数$r12是否等于0(寄存器$r0 为特殊寄存器,其值永远为0),如果不相等则跳转到地址0x1200006f0 处继续执行,否则就不跳转,执行接下来的汇编指令break 0x7。break 指令将无条件触发断点例外,其参数0x7对应SIGFPE。至此,我们就知道了当前程序异常是由除法指令中的被除数为0引起的,对应的C语言代码就是return a/b;,语句中的b为0。
其实对大型软件的异常问题定位,基本都是这个思路。不过好在很多大型软件都会内置一套完整的异常处理机制,在异常发生时,可自动收集异常原因、异常进程、异常位置、栈回溯等信息,比如Java虚拟机中提供的捕获异常、Android系统中的tombstone。尽管如此,我们还是有可能遇到异常处理机制无法捕获的异常(漏网之鱼),这时掌握一些调试工具的使用方法和汇编语言的知识是很有必要的。
对于GDB工具的使用,在后面章节还会有更详细的介绍。
1.2.2 场景2——性能分析和优化
了解计算机体系架构和汇编语言有助于我们深入分析软件性能瓶颈。虽然编译器已经做了大部分的性能优化工作,比如C/C++语言的编译器GCC(GNU Compiler Collection,GNU编译器组件)编译时使用-O3比-O1可以带来更进一步的性能优化;支持Java虚拟机根据函数大小及函数被使用的次数来动态调整优化策略。但是在特定场景中,这些还是不够用,比如游戏引擎、音视频的编解码等领域,会经常遇到和算法相关的大数据量数学运算。这时如果我们会使用汇编语言,就可以更进一步做针对特定处理器的优化工作。比如多数处理器中都实现了单指令流多数据流(Single-Instruction stream Multiple-Data stream, SIMD)功能的汇编指令,亦称为向量指令,其可实现一条指令操作多组数据。龙芯架构LoongArch中也实现了SIMD,包括向量扩展(Loongson SIMD Extension,LSX)和高级向量扩展(Loongson Advanced SIMD Extension,LASX),其中LSX为128位向量位宽,LASX为256位向量位宽。
举一个使用龙芯LASX实现程序优化的小例子。下面的代码(使用C语言实现)实现a数组与b数组中的各项数据相加,将结果写入c数组的加法运算。这里假设数组类型为整型int(32位),循环长度为10000。
for(int i = 0; i < 10000; i++)
c[i] = a[i] + b[i];
使用GCC编译后,生成的最终可供CPU执行的指令如下:
//LoongArch汇编指令
L:
ld.w t1, a1, 0 # 加载数组a[i]值到寄存器t1
add.w t3, t1, t2 # 实现a[i]+b[i],将结果存入寄存器t3
st.w t3, t4, 0 # t3数据写回c[i]
addi.d a1, a1, 4 # 数组a[]累加4,即指向a[i+1]
addi.d a1, a2, 4 # 数组b[]累加 4
addi.d t4, t4, 4 # 数组c[]累加 4
bne a5, a6, L # 判断若for()没有结束,跳转到L,继续执行
上面这段指令实现了循环操作c[i] = a[i] + b[i],相关指令格式在后面章节还会有详细介绍。在这里可以看出要实现两个长度为10000的整型数组加法运算,CPU要循环执行10000次,每次循环要执行8条指令,那么完成整个功能要执行80000条指令。
同样的功能,用龙芯LASX指令实现如下:
//LoongArch 汇编指令
L:
xvld x1, a1, 0 # 加载数组a[]中的8组整型值到向量寄存器x1
xvld x2, a2, 0 # 加载数组b[]中的8组整型值到向量寄存器x2
xvadd.w x3, x1, x2 # a[i…i+8]+b[i…i+8],将结果存入向量寄存器x3
xvst x3, t4, 0 # 把x3数据写回数组c[i…i+8]
addi.d a1, a1, 32 # 数组a[]地址累加32,即指向a[i+9]
addi.d a1, a2, 32 # 数组b[]地址累加32
addi.d t4, t4, 32 # 数组c[]地址累加32
bne a5, a6, L # 判断若for()没有结束,跳转到L,继续执行
龙芯LASX指令是256位宽(即向量寄存器的长度),故循环一次可以完成8组整型值(8×32位)的加法运算。循环一次也是执行8条指令,但总的循环次数仅为1250次(10000/8),那么完成整个功能执行10000(1250×8)条指令即可,在理论上是GCC编译器生成的普通指令执行性能的8倍。在本书的第10章将专门介绍和指令架构相关的性能优化基本思路和方法。
1.2.3 场景3——完成高级语言无法实现的功能
在一些基础软件的源代码中,比如数据库、GCC编译器、OpenJDK等,我们能频繁看到汇编语言的身影。因为它们作为应用软件的支撑或工具,相对于应用软件在运行逻辑上更靠近CPU,也就更可能出现和计算机体系架构相关的功能要求。例如,GCC编译器负责将C/C++语言翻译成和计算机体系架构相关的汇编语言;Java语言开发者熟知的OpenJDK负责Java语言到机器指令的动态翻译和执行。这方面的软件从业者就不仅要熟知某种高级语言,还要熟知特定处理器支持的汇编语言。
例如有这样一个问题:在C语言中如何获取程序运行的当前PC值?不同架构有不同的方式,在龙芯平台上可以通过如下内嵌汇编来实现。
static long * get_PC(void){
unsigned long *val;
__asm__ volatile ("move %0, $r1" : "=r"(val));
return val;
}
这里__asm__是内嵌汇编指令,用来实现汇编语言和C语言的混合编程(后面会有专门章节来详细介绍其语法规范)。这里只需关注核心汇编指令move %0, $r1 。在龙芯架构寄存器使用约定里,寄存器$r1存放了函数的返回地址,%0代表val,所以move %0, $r1就完成了把当前函数的返回地址存到变量val中。而当前函数的返回地址就是调用该函数时的PC,所以你就可以通过调用这个函数来获取当前位置的PC。
汇编语言也是编写嵌入式设备上程序的理想工具。和通用计算机处理器相比,嵌入式设备(比如电话、打印机、门禁设备等)的典型特征是没有大容量内存,这就要求其上的程序尽量短小。如果使用高级语言编写,经过编译器翻译后的机器指令可能会有一些冗余,例如大量的函数调用开销、动态库加载(尽管程序中仅用了某个动态库的几个函数)等。如果直接使用汇编语言进行针对性的编写,那么内存占用肯定最少,因此汇编语言特别适合编写嵌入式程序。后面章节会专门介绍如何编写一个脱离libc库的程序示例。