准备环境
aarch64-linux-gnu-gcc
: 可以通过下载 linaro 交叉编译工具链获得qemu-system-aarch64
aarch64-linux-gnu-gdb
: 可以通过下载 linaro 交叉编译工具链获得
一个简单的汇编程序
首先,创建一个空目录,例如,名为aarch64_assembly
。然后,创建一个名为entry.S
的汇编程序,其内容为:
#define BEGIN_FUNC(name)
.global name;
.type name, %function;
name:
#define END_FUNC(name)
.size name, . - name
BEGIN_FUNC(_start)
.align 7
nop
mov x0, 0x1
mov x1, 0x2
add x0, x0, x1
loop:
b loop
END_FUNC(_start)
这里暂时不管BEGIN_FUNC
和END_FUNC
宏,就当它是用来定义函数的。_start
是一个默认的函数名,就像C
语言中的main
。
然后我们写个简单的Makefile
进行构建,避免每次都要输入一长串的内容进行编译。其内容如下:
CC = aarch64-linux-gnu-gcc
LD = aarch64-linux-gnu-ld
CFLAGS = -g -O0 -nostdlib -nodefaultlibs
simple_prog: entry.o
$(LD) -o $@ $^
%.o: %.S
$(CC) $(CFLAGS) -c $< -o $@
.PHONY: clean
clean:
-rm entry.o
-rm simple_prog
OK,现在我们可以进行运行调试了。因为还没有打印输出,所以我们暂时通过qemu
内的gdb server
进行调试。同样地,为了避免重复输入,搞一个run.sh
脚本。
qemu-system-aarch64
-machine virt,virtualization=true,gic-version=3
-nographic
-m size=1024M
-s
-kernel ./simpe_prog
-cpu cortex-a57 -smp 1
$@
现在就可以调试了。首先在当前界面输入./run.sh -S
,这个时候qemu
会卡住,等待gdb
的输入。
然后新开一个shell
窗口,输入如下指令
root@ubuntu:~/xxx# aarch64-linux-gnu-gdb ./simple_prog
(gdb) target remote localhost:1234
(gdb) ...
好了,可以愉快地使用gdb
进行调试了。特别提醒,si
单步执行每条指令,print $reg
打印寄存器内容。调试结束之后,可以Ctrl+a
,然后x
结束qemu
执行。
函数调用
我们知道,对于ARM
来说,CPU
根据PC
寄存器中的地址值取指令,然后进行译码和执行。当我们通过bl
指令进行函数调用的时候,显然,PC
寄存器中的地址值会变为跳转地址。此外,lr
(即x30
)中会保存bl
指令后下一条指令的地址。
我们实际通过程序来看一下。将加法功能抽出来单独成为一个函数,即修改entry.S
为
#define BEGIN_FUNC(name)
.global name;
.type name, %function;
name:
#define END_FUNC(name)
.size name, . - name
BEGIN_FUNC(add)
add x0, x0, x1
ret
END_FUNC(add)
BEGIN_FUNC(_start)
.align 7
nop
mov x0, 0x1
mov x1, 0x2
bl add
loop:
b loop
END_FUNC(_start)
类似前一节的方法进行调试,在bl
指令和ret
指令前后,注意看lr
寄存器值的变化。
调用C函数
显然, 用汇编开发的效率是极低的。因此,在一些C语言也可能解决问题且对性能没有极高要求的场景,我们可以通过C语言进行编写。通过汇编调用C语言函数需要关注ABI
(即 Application Binary Interface),C语言调用汇编可以通过内联汇编(Inline Assembly)。我们首先关注ABI
。
ABI
主要用于制定一致的规则,从而让各个可执行的代码模块之间可以相互调用。其内容包括ELF
(Executable and Linkable Format)标准、PCS
(Procedure Call Standard)标准、DWARF
(Debugging With Attributed Record Formats)标准等。
这里我们主要关注PCS
。每个函数都会有自己的栈帧,栈帧中保存着传入的参数、函数内部声明的局部变量、传给其他函数的参数等。其一般结构如下图所示:
其中,传入的参数从寄存器或者Stack args area
区域获得,和函数内部声明的局部变量一起,存放于Local variables
区域。
在aarch64
中,可以基于函数调用将通用寄存器分为四组:
- 参数寄存器(
X0
至X7
)
这些寄存器用于传递参数和保存返回值。当然,对于结构体这种空间较大的参数显然它们也是保存不了的。 - 调用者保存的寄存器(
x9
至x15
) - 被调用者保存的寄存器(
x19
至x29
) - 有特殊目的的寄存器(
x8
、x16
至x18
、x29
和x30
)
其中,x8
是间接结果寄存器。它被用来保存一个间接结果的地址,比如,当函数返回一个很大的结构体时。x30
是链接寄存器,用来保存函数的返回地址。
首先,我们创建一个main.c
,里面定义一个sum
求和函数。其内容如下:
int add(int a, int b)
{
return a + b;
}
这个函数参数能够通过一个通用寄存器传递,因此是通过寄存器x0 ~ x7
传递的。返回值也能够通过通用寄存器传递,因此是通过x0
返回的。因此,我们只需要修改entry.S
,去掉其中的sum
函数,即其内容改为
#define BEGIN_FUNC(name)
.global name;
.type name, %function;
name:
#define END_FUNC(name)
.size name, . - name
BEGIN_FUNC(_start)
.align 7
nop
adr x0, stack_top
mov sp, x0
mov x0, 0x1
mov x1, 0x2
bl add
loop:
b loop
END_FUNC(_start)
在函数调用的过程中,我们需要调用栈空间。我们通过链接脚本创建,即创建simple_prog.lds
内容如下:
OUTPUT_FORMAT("elf64-littleaarch64", "elf64-bigaarch64", "elf64-littleaarch64")
OUTPUT_ARCH(aarch64)
ENTRY(_start)
SECTIONS
{
. = 0x40000000;
.startup . : { entry.o(.text) }
.text : { *(.text) }
.data : { *(.data) }
.bss : { *(.bss COMMON) }
. = ALIGN(8);
. = . + 0x1000; /* 4kB of stack memory */
stack_top = .;
}
因为添加了新的.c
文件和链接脚本,所以我们需要同时修改Makefile
内容如下:
CC = aarch64-linux-gnu-gcc
LD = aarch64-linux-gnu-ld
CFLAGS = -g -O0 -nostdlib -nodefaultlibs
LDFLAGS := -static -L ./ -T ./simple_prog.lds
simple_prog: entry.o main.o
$(LD) $(LDFLAGS) -o $@ $^
%.o: %.S
$(CC) $(CFLAGS) -c $< -o $@
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
.PHONY: clean
clean:
-rm entry.o
-rm main.o
-rm simple_prog
OK,编译链接,然后调试。
那对于比较大的,通用寄存器放不下的结构体之类的参数是如何传递的呢?首先,如果结构体的大小小于等于16
字节,那么会尝试通过通用寄存器传递。如果结构体太大了,那么就会尝试通过通用寄存器传递结构体的基地址。看个简单的例子,修改main.c
为
#define MAX 10
struct score {
int arr[MAX];
};
struct score score_add(int delta, int which, struct score s_id)
{
if (which >= 0 && which < MAX) {
s_id.arr[which] += delta;
}
return s_id;
}
然后修改entry.S
调用该函数
#define BEGIN_FUNC(name)
.global name;
.type name, %function;
name:
#define END_FUNC(name)
.size name, . - name
BEGIN_FUNC(_start)
.align 7
nop
adr x0, stack_top
mov sp, x0
sub sp, sp, 0x28 # store structure score
mov x0, 0x50
str x0, [sp]
mov x2, sp # parameter s_id
mov x8, sp # return value base address
mov x0, 0x5 # delta = 5
mov x1, 0x0 # which = 0
bl score_add
loop:
b loop
END_FUNC(_start)
编译调试之后,我们可以看到这样的汇编指令:
40000030: str x19, [sp, #-32]!
40000034: mov x3, x8
40000038: str w0, [sp, #28] # delta
4000003c: str w1, [sp, #24] # which
40000040: mov x19, x2 # s_id
40000044: ldr w0, [sp, #24]
40000048: cmp w0, #0x0
4000004c: b.lt 40000074 <score_add+0x44> // b.tstop
40000050: ldr w0, [sp, #24]
40000054: cmp w0, #0x9
40000058: b.gt 40000074 <score_add+0x44>
4000005c: ldrsw x0, [sp, #24]
40000060: ldr w1, [x19, x0, lsl #2]
40000064: ldr w0, [sp, #28]
40000068: add w1, w1, w0
4000006c: ldrsw x0, [sp, #24]
40000070: str w1, [x19, x0, lsl #2]
40000074: mov x0, x3
40000078: mov x1, x19
4000007c: ldp x2, x3, [x1]
40000080: stp x2, x3, [x0]
40000084: ldp x2, x3, [x1, #16]
40000088: stp x2, x3, [x0, #16]
4000008c: ldr x1, [x1, #32]
40000090: str x1, [x0, #32]
40000094: ldr x19, [sp], #32
40000098: ret
至于当通用寄存器个数不够的情况下,参数是如何传递的,大家可以自行查看相关手册标准并试验之。
内联汇编
有时候,我们在C代码中需要使用汇编代码,比如我们想知道当前用的是SP_EL0
还是SP_ELx
,显然是没有C
语句能直接查询到的,这时我们可以使用内联汇编。我们添加一个新的函数check_el
,检查当前的EL
级别,即修改main.c
内容如下:
#define MAX 10
struct score {
int arr[MAX];
};
struct score score_add(int delta, int which, struct score s_id)
{
if (which >= 0 && which < MAX) {
s_id.arr[which] += delta;
}
return s_id;
}
void check_el(void)
{
int a;
asm volatile("mrs x0, CurrentEL
"
"lsr x0, x0, #2
"
"str x0, %[result]
"
: [result]"=m" (a)
:
: "x0");
}
并修改entry.S
以调用它:
#define BEGIN_FUNC(name)
.global name;
.type name, %function;
name:
#define END_FUNC(name)
.size name, . - name
BEGIN_FUNC(_start)
.align 7
nop
adr x0, stack_top
mov sp, x0
sub sp, sp, 0x28 # store structure score
mov x0, 0x50
str x0, [sp]
mov x2, sp # parameter s_id
mov x8, sp # return value base address
mov x0, 0x5 # delta = 5
mov x1, 0x0 # which = 0
bl score_add
bl check_el
loop:
b loop
END_FUNC(_start)
然后可以编译调试。这里我们提一嘴内联汇编的格式。其通用格式为
asm(code : output operand list : input operand list : clobber list);
也就是说,各个部分是通过冒号分隔的。我们使用内联汇编的时候,总是不可避免的需要将变量牵扯进去,就像上文中check_el
中的变量a
。这里我们通过符号名result
对该变量进行引用。即通过[result] "=m" (a)
将符号名和变量a
关联起来,并在代码部分通过%[result]
引用该变量。(不过在GCC
3.1之前是通过数字编号引用变量的。也就是说,它通过"=m" (a)
建立变量和汇编代码的关联,通过%0
这种类似的数字编号引用变量。)
那么[result] "=m" (a)
中的=m
是什么意思呢?我们在汇编指令中一般使用三种不同类型操作数:寄存器、内存地址、立即数。例如,mov
操作的第一个操作数必然是寄存器,而str
操作的第二个操作数必然是内存地址。这里的m
表明我们使用变量的内存地址,其他的类型还有r
、I
等。m
前面的=
号表明这个变量是可写的,但是不可读。其他可选的符号还有+
、&
。
就像其他被调用的函数,我们要避免破坏调用函数的上下文,所以在使用x19
这样的寄存器时需要先将其最初的值压入栈中。上文的内联汇编中,在clobber list
中我们表明使用了x0
寄存器,希望gcc
在使用前保存一下, 防止破坏了函数上下文。
最后volatile
关键字是为了防止编译器做代码优化,就像我们这里,变量a
是一个局部变量,也没有返回,那么gcc
可能就直接把相关语句优化掉了。