2.2. 汇编语言的使用场景

很多长期使用高级语言开发的程序员都会有类似疑问:既然绝大多数应用程序都可以用高级语言完成,为什么还要学习汇编语言?从实际工作看,汇编语言仍然有不少不可替代的价值。下面列出几个典型场景。

2.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。那么这个地址对应哪条指令?可以继续使用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,用于保存当前待执行指令地址)所在位置,也就是程序停止的位置。指令div.w $r14,$r13,$r12执行除法,含义是用寄存器$r13除以$r12,并把结果写入$r14。指令bne $r12,$r0,8(0x8) #0x1200006f0是条件跳转,用于判断除数$r12是否等于0。寄存器$r0是特殊寄存器,值恒为0。如果$r12不等于$r0,程序跳转到0x1200006f0继续执行;如果相等,则顺序执行下一条break 0x7break会无条件触发断点例外,参数0x7对应SIGFPE。由此可以确定,异常原因是除法指令的除数为0,对应的C语句是return a/b;,其中b的值为0。

大型软件的问题定位也常采用类似思路。许多大型系统会内置异常处理机制,在故障发生时自动记录异常原因、异常进程、异常位置和栈回溯等信息,例如Java虚拟机的异常捕获机制、Android系统的tombstone机制。不过,仍然存在异常处理机制无法覆盖的问题。遇到这类情况时,熟练使用调试工具并具备必要的汇编知识,往往能显著提高定位效率。

GDB工具的具体使用方法将在后续章节进一步介绍。

2.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数组。假设数组元素类型为32位整型int,循环次数为10000。

for(int i = 0;i < 10000;i++)
	c[i] = a[i] + b[i];

经GCC编译后,最终由CPU执行的指令可能如下:

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 	a2,	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指令完成同样功能时,代码可写为:

L:
xvld 	x1, a1, 0 	#	加载数组a[]中的8组整数值到向量寄存器x1
xvld 	x2, a2, 0 	#	加载数组b[]中的8组整数值到向量寄存器x2
xvadd.w x3, x1, x2	#	a[i...i+7] + b[i...i+7],结果存入向量寄存器x3
xvst 	x3, t4, 0 	#	把x3数据写回数组c[i...i+7]
addi.d 	a1,	a1, 32 	#	数组a[]累加32,即指向a[i+8]
addi.d 	a2,	a2, 32 	#	数组b[]累加32
addi.d 	t4,	t4, 32 	# 	数组c[]累加32
bne 	a5, a6, L 	#	判断若for()没有结束,跳转到L,继续执行

LASX指令位宽为256位,即向量寄存器长度为256位。因此一次循环可同时完成8个32位整型数(8×32位)的加法。每次循环仍执行8条指令,但循环次数降为1250次(10000/8),总指令数约为10000条(1250×8)。理论上,它的执行效率可达到普通标量指令版本的8倍。本书第10章会专门介绍与指令架构相关的性能优化思路和方法。

2.2.3. 场景3——完成高级语言无法实现的功能

在数据库、GCC编译器、OpenJDK等基础软件源码中,经常可以看到汇编语言。这类软件作为应用软件的支撑环境或开发工具,运行逻辑比普通应用更接近CPU,因此更容易涉及体系架构相关需求。例如,GCC负责把C/C++代码翻译成与目标体系架构对应的汇编语言;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库的程序示例。