armv8 汇编入门

摘要:
准备环境aarch64linuxgnu-gcc:您可以通过下载linaro交叉编译工具链获得qemu-system-arch64aarch64-linux-gnu-gdb:您可以下载linaro跨编译工具链来获得一个简单的汇编程序。首先,创建一个空目录,例如aarch64_assemblyStart是一个默认函数名,就像C语言中的main一样。qemu-system-arch64-machinevirt,虚拟化=true,gic版本=3-ographic-msize=1024M-s-kernel。/simpe_Prog-cpuortex-a57-smp1$@现在可以调试了。首先,在当前界面/Run.sh-S中输入。此时,qemu将卡住并等待gdb的输入。调试后,可以按Ctrl+a,然后按x结束qemu执行。通过汇编调用C语言函数需要注意ABI,C语言可以通过内联汇编调用汇编。
准备环境
  • 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_FUNCEND_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中,可以基于函数调用将通用寄存器分为四组:

  1. 参数寄存器(X0X7
    这些寄存器用于传递参数和保存返回值。当然,对于结构体这种空间较大的参数显然它们也是保存不了的。
  2. 调用者保存的寄存器(x9x15
  3. 被调用者保存的寄存器(x19x29
  4. 有特殊目的的寄存器(x8x16x18x29x30
    其中,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表明我们使用变量的内存地址,其他的类型还有rI等。m前面的=号表明这个变量是可写的,但是不可读。其他可选的符号还有+&

就像其他被调用的函数,我们要避免破坏调用函数的上下文,所以在使用x19这样的寄存器时需要先将其最初的值压入栈中。上文的内联汇编中,在clobber list中我们表明使用了x0寄存器,希望gcc在使用前保存一下, 防止破坏了函数上下文。

最后volatile关键字是为了防止编译器做代码优化,就像我们这里,变量a是一个局部变量,也没有返回,那么gcc可能就直接把相关语句优化掉了。

参考资料

免责声明:文章转载自《armv8 汇编入门》仅用于学习参考。如对内容有疑问,请及时联系本站处理。

上篇小程序-video/视频播放---part1:属性及部分函数STM32使用FFT变换计算THD(20年四川省电子设计大赛E题软件部分)下篇

宿迁高防,2C2G15M,22元/月;香港BGP,2C5G5M,25元/月 雨云优惠码:MjYwNzM=

相关文章

ES6中的export以及import的使用多样性

模块功能主要由两个命令构成:export和import。export命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。 一、export导出模块使用部分的几种方式 一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果你希望外部能够读取模块内部的某个变量,就必须使用export关键字输出该变量。下面是一个 JS 文件,里...

函数中参数传递的5种方式

1、必须参数(位置参数)      必需参数:先用形式参数定义,然后在调用时对应位置使用实参(具体的数值)调用,定义的形式参数和调用的实际参数必需一一对应(顺序、数量)。       def sum(a, b):           return a + b       sum(5, 6) 2、关键字参数      关键字参数:先使用形式参数定义,然后调用...

Linux等待队列原理与实现

当进程要获取某些资源(例如从网卡读取数据)的时候,但资源并没有准备好(例如网卡还没接收到数据),这时候内核必须切换到其他进程运行,直到资源准备好再唤醒进程。 waitqueue (等待队列) 就是内核用于管理等待资源的进程,当某个进程获取的资源没有准备好的时候,可以通过调用  add_wait_queue() 函数把进程添加到  waitqueue 中,然...

MFC浅析(7) CWnd类虚函数的调用时机、缺省实现 .

1. Create2. PreCreateWindow3. PreSubclassWindow4. PreTranslateMessage5. WindowProc6. OnCommand7. OnNotify8. OnChildNotify9. DefWindowProc10. DestroyWindow11. PostNcDestroyCWnd作为MF...

Java-修饰符

Java修饰符 修饰符用来定义类、方法或者变量,通常放在语句的最前端 访问控制修饰符 访问控制符可以保护对类、变量、方法和构造方法的访问 四中不同的访问权限: default,在同一包内可见,不使用任何修饰符 private,在同一类内可见 public,对所有类可见 protected,对同一包内的类和所有子类可见 默认访问修饰符-不使用...

Python的魔法函数

概要 如何定义一个类 类里通常包含什么 各个部分解释 类是怎么来的 type和object的关系 判断对象的类型 上下文管理器 类结构 #!/usr/bin/env python #-*- coding: utf-8 -*- #Author: rex.cheny #E-mail: rex.cheny@outlook.com #类名后面写(object...