《嵌入式工程师自我修养/C语言》系列——读懂ARM汇编程序,你只差这篇!保姆级教程(附完整示例剖析)!
快速学习嵌入式开发其他基础知识?>>>>>>>>> 返回专栏总目录 《嵌入式工程师自我修养/C语言》<<<<<<<<<
博主已开通同名公众号,通过文末或主页推广二维码关注博主,将为你推送最新、最细、最硬核的车载系统知识和嵌入式开发知识!
Tip📌:鼠标悬停双虚线关键词/句,可获得更详细的描述
第一、二、三章节都是针对ARM编译器下的汇编语言描述的,学习了这些之后再看第四章节能更好地区分它和GNU GCC下的汇编语言的区别。
一、ARM汇编程序初体验
建议阅读本文前,先阅读《万字长文带你由浅入深夯实ARM汇编基础——汇编指令及寻址方式最全梳理(附示例)!》。在上述ARM汇编基础一文中,我们详细梳理了各种寻址方式及汇编指令。对这些指令的熟练掌握需要大量阅读汇编代码,而实际上一份完整的汇编代码除了指令,还有一些伪指令或者叫伪操作,想快速读懂或者方便地编写汇编代码离不开这些伪操作。
ARM汇编程序是以段(section)为单位进行组织的。在一个汇编文件中,可以有不同的section,比如我们经常提到的代码段、数据段等,各个段之间相互独立,一个ARM汇编程序至少要有一个代码段。我们可以使用AREA伪操作来标识一个段的起始、段名、段的属性(CODE、DATA)和读写权限(READONLY、READWRITE)。下面给出一个具体的示例初步感受下:
Tip📌:在汇编程序中,使用分号;来注释代码。
AREA COPY,CODE,READONLY ;该汇编代码的第一个段:段名为COPY,是代码段,属性为只读
ENTRY ;用ENTRY伪操作来标识汇编程序的运行入口
START ;标号START,类似C语言中的函数名,在汇编语言中,标号代表的指令地址
LDR R0, =SRC ;
LDR R1, =DST
MOV R2, #10
LOOP ;标号,结合下面的BNE LOOP指令构成了一个循环程序
LDR R3, [R0], #4
STR R3, [R1], #4
SUBS R2, R2, #1
BNE LOOP
AREA COPYDATA,DATA,READWRITE ;该汇编代码的第二个段:段名为COPYDATA,是数据段,具有读写权限
SRC DCD 1,2,3,4,5,6,7,8,9,0
DST DCD 0,0,0,0,0,0,0,0,0
END ;用END伪操作来标识汇编程序的结束
二、符号、标号、伪操作详解
2.1 符号和标号
在ARM汇编程序中,我们可以使用符号来标识一个地址、变量或数字常量。当用符号来标识一个地址时,这个符号通常又被称为标号。符号的命名规则和C语言的标识符命名规则一样:由字母、数字和下画线组成,符号的开头不能使用数字,但标号命名没有那么严格,标号的开头不仅可以是数字,甚至整个标号可以是一个纯数字。
符号的命名在其作用域内必须唯一,不能与系统内部或系统预定义的符号同名,不能与指令助记符、伪指令同名。一般情况下,一个符号的作用域是整个汇编源文件。有时候我们会直接通过数字[0,99]而不是使用字符来进行地址引用,我们称这种数字为局部标号。局部标号的作用域为当前段,在汇编程序中,我们可以使用下面的格式来引用局部标号。
%{F|B|A|T} N{routename} ;大括号{}括起来的部分是可选项
- %:引用符号,对一个局部标号产生引用
- F:指示编译器只向前搜索
- B:指示编译器只向后搜索
- A:指示编译器搜索宏的所有宏命令层
- T:指示编译器搜索宏的当前层
- N:局部标号的名字
- routename:局部标号作用范围名称,使用ROUT定义
Tip📌:
若B、F没有指定,编译器将默认先向后搜索,然后向前搜索。
若A、T都没指定,则汇编程序默认搜索从当前层到最顶层的所有宏命令,但不搜索较低层的宏命令。
若在标签中或者对标签的引用中指定了routename,则汇编程序将其与最近的一个前ROUT指令的名称进行比较,不匹配则汇编失败。
基于开头的那个例子我们把loop标号改为纯数字0:
AREA COPY,CODE,READONLY
ENTRY
START
LDR R0, =SRC
LDR R1, =DST
MOV R2, #10
0 ;定义局部标号:0
LDR R3, [R0],#4
STR R3, [R1], #4
SUBS R2, R2, #1
BNE %B0 ;向后寻找(这行代码往上是后,往下是前)局部标号0并跳转,构成循环程序结构
AREA COPYDATA,DATA,READWRITE
SRC DCD 1,2,3,4,5,6,7,8,9,0
DST DCD 0,0,0,0,0,0,0,0,0
END
2.2 伪操作
我们知道C语言中有很多预处理命令(#include、#define、#if、#else、#end等)。编译器在编译C代码时,首先执行的就是预处理操作(对应:头文件包含、宏定义的嵌入展开,砍掉不参与编译的代码块等),如果在编译之前不做预处理操作,则编译器就会报错,因为实际上编译器并不认识这些预处理命令,他们不是C语言标准中的关键字。
同样,汇编器也定义了一些特殊的指令助记符,就是所说的伪操作,他们就类似于C语言中的预处理命令,是为了方便对汇编程序做各种处理而生的。除了第一节提到的用AREA来定义一个段(section)、用ENTRY来指定汇编程序的执行入口、用END伪操作来标识汇编程序的结束以外,还有如下常用的伪操作:
/******************** 基础伪操作 ********************/
ALIGN ;地址对齐
AREA ;用来定义一个代码段或数据段,常用的段属性为CODE/DATA
ENTRY ;指定汇编程序的执行入口
END ;用来告诉编译器源程序已到了结尾,停止编译
EQU ;赋值伪指令,类似宏,给常量定义一个符号名
CODE16/CODE32 ;指示编译器后面的指令为THUMB/ARM 指令
EXPORT/GLOBAL ;声明一个全局符号,可以被其他文件引用
IMPORT/EXTERN ;引用其他文件的全局符号前,要先IMPORT
GET/INCLUDE ;包含文件,并将该文件当前位置进行编译,一般包含的是程序文件
INCBIN ;包含文件,但不编译,一般包含的是数据、配置文件等
/******************** 变量定义 ********************/
//全局变量定义
GBLA a ;定义一个全局算术变量a,并初始化为0
a SETA 10 ;给算术变量a赋值为10
GBLL b ;定义一个全局逻辑变量b,并初始化为false
b SETL 20 ;给逻辑变量b赋值为20
GBLS STR ;定义一个全局字符串变量STR,并初始化为0
STR SETS "123" ;给变量STR赋值为"123"
//局部变量定义
LCLA a ;定义一个局部算术变量a,并初始化为0
LCLL b ;定义一个局部逻辑变量b,并初始化为false
LCLS name ;定义一个局部字符串变量name,并初始化为0
name SETS "123" ;给局部字符串变量赋值
/******************** 数据定义 ********************/
DATA1 DCB 10,20,30,40 ;分配一片连续的字节存储单元名为DATA1,并初始化
STR DCB "123" ;给字符串STR分配一片连续的存储单元并初始化
DATA2 DCD 10,20,30,40 ;分配一片连续的字存储单元名为DATA2,并初始化
BUF SPACE 100 ;给BUF分配100字节的存储单元并初始化为0
DATA 10,20,30,40 ;定义了一个包含四个整数的数据集合
举个例子:下面我们实现了一个汇编子程序SUM_ASM,使用EXPORT伪操作将其声明为一个全局符号(这样其他汇编程序或C程序就可以直接调用它了),SUM_ASM自身又调用了其他子程序sum(这个sum子程序可以是一个汇编子程序,也可以是一个使用C语言定义的函数),在调用之前我们要先使用IMPORT伪操作把sum子程序导入进来,然后就可以直接使用BL指令跳转过去运行了。
IMPORT sum
AREA SUM_ASM,CODE,READONLY
EXPORT SUM_ASM
SUM_ASM
STR LR, [SP, #-4] ;保存调用者的返回地址
LDR R0, =0X3 ;参数传递
LDR R1, =0X4 ;参数传递
BL sum ;调用其他文件里的子程序
LDR PC, [SP], #4 ;返回主程序,继续运行
END
三、C语言和汇编语言混合编程
3.1 C程序和汇编程序的互相调用——ATPCS规则
从上面一个例子我们了解到C程序和汇编程序其实是可以相互调用的,C代码中有些对性能要求比较高的场合会要求使用汇编语言编写,即使我们基本不写代码,遇到这些场景也要能看懂。上面的例子中sum子程序可以是一个使用C语言定义的函数,那么如何保证在汇编中调用C语言函数用的的各种寄存器操作 和 直接调用汇编代码完成的函数功能时使用的寄存器操作是一致的呢?这就是依靠所谓的ATPCS规则(ARM-Thumb Procedure Call Standard),其核心内容就是定义了子程序调用的具体规则,一些规则如下所示:
- ARM 程序要使用满递减堆栈, 入栈/出栈操作要使用STMFD/LDMFD指令
- 子程序间要通过寄存器R0~R3(可记作a0~a3)传递参数,当参数个数大于4时,剩余的参数使用堆栈来传递
- 子程序通过R0~R1返回结果
- 子程序中使用R4~R11(可记作v1~v8)来保存局部变量
- R12作为调用过程中的临时寄存器,一般用来保存函数的栈帧基址,记作FP
- R13作为堆栈指针寄存器,记作SP
- R14作为链接寄存器,用来保存函数调用者的返回地址,记作LR
- R15作为程序计数器,总是指向当前正在运行的指令,记作PC
在ARM平台下,无论是C程序,还是汇编程序,只要大家遵守ARM子程序之间的参数传递和调用规则,就可以很方便地在一个C程序中调用汇编子程序,或者在一个汇编程序中调用C程序。因为最终大家都使用一致的寄存器操作方案实现具体功能。比如我们使用交叉编译器arm-linux-gcc编译下面的main.c文件和上面例子中的汇编文件,编译可以完成并且生成的可执行文件可以在ARM平台上正常运行。
//文件:main.c
int sum{int a,int b)
{
int result=0;
printf("result=%d\n", result);
return result;
}
int main(void)
{
SUM_ASM();
return 0;
}
3.2 在C程序中内嵌汇编代码
通过ARM编译器在ANSI C标准的基础上扩展的关键字__asm,我们就可以在C程序中内嵌ARM汇编代码。
__asm
{
指令 /*注释*/
...
[指令]
}
Tip📌:在内嵌的汇编代码中添加注释,要使用C语言的/**/注释符,而不是汇编语言的分号注释符。
实例分析:
//main.c
int src[10] = {1,2,3,4,5,6,7,8,9};
int dst[10] = {0};
//数据块复制的C语言实现
int data_copy_c(void)
{
for(inti=0; i<10; i++)
{
dst[i] = src[i];
}
return 0;
}
//数据块复制的内嵌ARM汇编实现
int data_copy_asm(void)
{
__asm
{
LDR R0, =src
LDR R1, =dst
MOV R2, #10
LOOP :
LDR R3, [R0], #4
STR R3, [R1], #4
SUBS R2, R2, #1
BNE LOOP
}
}
Tip📌:
不同的编译器基于ANSI C标准扩展了不同的关键字,使用的汇编格式可能不太一样。如GNU ARM编译器提供了一个__asm__关键字,__asm__的后面还可以选择使用__volatile__关键字修饰,用来告诉编译器不要优化这段代码。
__asm__ __volatile__
{
"汇编语句;"
...
"汇编语句;"
}
3.3 在汇编程序中调用C程序
3.1节的例子其实展示的就是这种场景,要注意的一点就是在调用C程序的时候,要根据ATPCS规则来完成参数的传递,并配置好C程序传递参数和保存局部变量所依赖的堆栈环境。当要传递的参数大于4个时,除了前4个参数使用寄存器R0~R3传递,剩余的参数要使用堆栈进行传递,这时候就需要编译器通过栈指针来进行管理和维护,下面给出一个完整的多参数传递示例。
//汇编文件
IMPORT sum
AREA SUM_ASM,CODE,READONLY
EXPORT SUM_ASM
SUM_ASM
MOV RO, 0X0 ;arg1-->R0
MOV R1, 0X1 ;arg2-->R1
MOV R2, 0X2 ;arg3-->R2
MOV R3, 0X3 ;arg4-->R3
MOV R4, 0X5 ;arg6-->SP,注意这里不是第五个参数,因为栈的特点是先进后出
STR R4, [SP, #-4]
MOV R4, 0X4 ;arg5-->SP
STR R4, [SP, #-4]
BL sum
MOV PC,LR
END
//C文件
int sum(intb,intc,int c,int d,int d,int f)
{
int result=0;
printf("result=%d\n", result);
return result;
}
int main(void)
{
SUM_ASM();
return 0;
}
四、GNU ARM汇编语言
4.1 GNU ARM编译器——GCC
在ARM平台下从事嵌入式软件开发,大家会遇到各种不同的集成开发环境(IDE)和编译器,如最常见的Keil MDK、IAR、ARM交叉编译器arm-linux-gcc等。如果将这些不同的IDE归类,一般可以分为两大类:一类IDE内部集成了ARM编译器,另一类则使用开源项目GNU产出的ARM编译器 GCC。不同的ARM编译器编译同一个文件都能在ARM平台上运行是因为这些编译器开发商都遵循着同一套ARM指令集标准,但他们都根据自己的产品需求和定位,各自扩展了不同的伪操作。以常见的ARM编译器和GNU ARM编译器GCC为例,差别如下所示。
由于gcc是我们很常用的一款编译器,因此学习各种GNU ARM伪指令操作是很有必要的,在学习了前三章节的基础上,下面的内容阅读起来应该更加快速并容易,首先列举下GNU常见的一些ARM伪指令:
4.2 GNU ARM汇编语言伪操作示例详解
学会ARM汇编语言之后,GNU ARM汇编语言基本也能看懂了,但细节上还是有很多不一样的地方(比如在GNU ARM汇编程序中经常使用小圆点.表示当前指令的地址,而ARM汇编语言中使用$符号),下面罗列了GNU ARM汇编语言中最常见的一些伪指令,并给出了对应的示例解释,需要仔细阅读,防止这些不起眼的细节成为我们的阅读障碍。
/**************************** 标号 **************************/
@GNU ARM汇编语言中的标识符除了字母、数字、下画线外还可以有“.”,局部标号可以由纯数字构成,此时在引用时使用Nf或Nb的形式,分别表示向前搜索或向后搜索。
@示例如下:
.global _start @GNU ARM汇编语言使用标号_start作为汇编程序的入口,用.global伪操作声明后就可以被其他文件引用
...
1: @定义局部标号1
sub r0, r1, r2 @进行运算
beq 1b @相等则向后搜索标号1,并跳转
b 2f @向前搜标号2并无条件跳转
add r0, r0, #1
2:
add r1, r1, #2
/**************************** .section伪操作 *************************/
@用户可以使用.section伪操作自定义一个段,每个段以段名开始,以下一个段名或文件结尾作为结束标记。在定义段名时,不要和系统预留的段名冲突,如.text、.data、.bss、.rodata都是编译器系统预留的段名,分别表示代码段、数据段、BSS段、只读数据段。按照如下格式定义段名:
.section <section name> {,"<flags>"}
@示例如下:
.section .mysection "awx" @定义一个可写、可执行的段 .mysection
align 2 @段中的数据地址按照2字节对齐
/**************************** 基本数据格式用法 **************************/
@二进制数据通常以0B或0b开头,八进制数据以0开头,十六进制数据以0x开头,十进制数据则以非0数字开头。
@负数前面加“-”,取补用“~”,不相等用“<>”,其他运算符C语言语法相似。
@示例如下:
.ascii "hello\0" @字符串常量要用双引号""括起来,使用.ascii定义字符串时要自行在结尾加'\0'
.string "hello", "world!" @.string伪操作可以定义多个字符串
.asciz "hello" @使用.asciz伪操作可以定义一个以NULL字符结尾的字符串
.rept 3 .byte 0x10 .endr @.rept用于重复执行后续的指令块,3表示后续的指令块会被重复执行3次。
@.byte 0x10是一个指令,表示将一个字节的值0x10存储到当前指令的地址处。
@最后,.endr指示重复块的结束。
@最后这行代码的含义是:将值0x10存储到当前指令的地址处,重复执行这个操作3次。最终的效果是在内存中连续存储3个字节的值0x10。
/**************************** 数据定义 **************************/
.float 3.14 @用.float伪操作定义一个浮点数f,并初始化为3.14
.equ f, 3.1415 @通过.equ伪操作将这个浮点数重新赋值为3.1415
@.equ伪操作除了给数据赋值,还可以把常量定义在代码段中,然后在代码中直接引用。类似C语言宏定义。
@示例如下:
.section .data
.equ DELAY, 100
...
.section .text
...
MOV R0, $DELAY
...
五、从C代码到汇编代码完整实例分析
C程序源码如下:
//hello.c
#include <stdio.h>
int global_val = 10;
int global_uvar;
int add(int a, int b)
{
return a + b;
}
int main(void)
{
int sum;
sum=add(1, 2);
printf("hello world!\n");
return 0;
}
Linux环境下使用交叉编译器将hello.c文件编译为汇编文件:
该文件内容如下:
关于其中提到的eabi属性:EABI (Embedded Application Binary Interface) 是嵌入式应用二进制接口的缩写,是一种用于描述二进制接口的标准。EABI定义了程序在嵌入式系统中的二进制接口,包括数据类型的大小、对齐方式、函数调用约定等。通过遵循EABI标准,可以确保不同编译器生成的代码可以在同一嵌入式系统上正确地运行。
.arch armv5t @指令集版本
.eabi_attribute 20, 1 @下面是一系列的eabi属性设置,用于指定编译器和链接器的工作方式
.eabi_attribute 21, 1
.eabi_attribute 23, 3
.eabi_attribute 24, 1
.eabi_attribute 25, 1
.eabi_attribute 26, 2
.eabi_attribute 30, 6
.eabi_attribute 34, 0
.eabi_attribute 18, 4
.file "hello.c" @指定源文件名为"hello.c"
.text @标志接下来的指令是代码段
.global global_val @声明一个全局符号global_val,声明后其他文件可以引用
.data @标志接下来的指令是数据段
.align 2 @数据段对齐方式:2的2次方,即将数据段按4字节对齐
.type global_val, %object @设置全局符号global_val的类型为对象
.size global_val, 4 @设置全局符号global_val的大小为4字节
global_val:
.word 10 @为global_val分配一个字大小的存储空间,初始化为10
.comm global_uvar,4,4 @在.comm临时段中申请一段命名空间,声明符号global_uvar为未初始化的全局共享变量,大小为4字节,对齐为4字节
.text @标志接下来的指令是代码段
.align 2 @代码段对齐方式:2的2次方,即将代码段按4字节对齐
.global add @声明一个全局符号add
.syntax unified @指定使用统一语法
.arm @指定当前代码段使用ARM指令集
.fpu softvfp @指定使用软件浮点运算
.type add, %function @设置全局符号add的类型为函数
add: @标号,表示add函数的入口地址,以下是add函数的具体实现,包括参数传递、变量存储、运算等指令
@ args = 0, pretend = 0, frame = 8
@ frame_needed = 1, uses_anonymous_args = 0
@ link register save eliminated.
str fp, [sp, #-4]!
add fp, sp, #0
sub sp, sp, #12
str r0, [fp, #-8]
str r1, [fp, #-12]
ldr r2, [fp, #-8]
ldr r3, [fp, #-12]
add r3, r2, r3
mov r0, r3
add sp, fp, #0
@ sp needed
ldr fp, [sp], #4
bx lr
.size add, .-add @指定add函数的大小=当前地址(即函数结束地址)- add函数的开始地址
.section .rodata @定义一个新的section:.rodata只读数据段
.align 2 @设置.rodata只读数据段对齐方式:2的2次方,按4字节对齐
.LC0: @标号,用来表示字符串的地址
.ascii "hello world!\000" @定义一个字符串,将其存储在只读数据段
.text @一个新的代码段开始地址
.align 2 @设置对齐方式
.global main @声明全局符号main
.syntax unified
.arm
.fpu softvfp
.type main, %function @设置全局符号main的类型为函数
main: @以下是main函数的具体实现,包括函数调用、变量存储、字符串输出等指令
@ args = 0, pretend = 0, frame = 8
@ frame_needed = 1, uses_anonymous_args = 0
push {fp, lr}
add fp, sp, #4
sub sp, sp, #8
mov r1, #2
mov r0, #1
bl add
str r0, [fp, #-8]
ldr r0, .L5
bl puts
mov r3, #0
mov r0, r3
sub sp, fp, #4
@ sp needed
pop {fp, pc}
.L6:
.align 2
.L5:
.word .LC0 @分配内存,用来存放printf要打印的字符串地址:LC0
.size main, .-main @设置main函数大小=当前地址-main函数开始地址
.ident "GCC: (Ubuntu/Linaro 7.5.0-3ubuntu1~18.04) 7.5.0" @编译器标识
.section .note.GNU-stack,"",%progbits @指定GNU栈信息