# ARM 汇编基础知识
# 汇编
大部分做嵌入式开发时候,使用的都是 C 语言,通过编译器生成二进制文件,然后烧录到 MCU 中.
其实,更底层还有汇编语言机器码,例如.start_up.s 文件就是汇编文件,用于设置堆栈,中断向量表以及中断向量函等
不同的芯片指令集可能不同,所对应的机器码也会不同,汇编的出现就是为了帮助记忆
同一条汇编代码,由汇编器来实现转译
# 反汇编
反汇编是将难以辨认的机器转化为汇编语言,反汇编出来的汇编大概率和原本的汇编代码不一样
例如,原汇编内容如下
反汇编后,引导文件对应的代码如下
可以看到有着很大的差别,但是,毫无疑问二者都可以实现最终效果
# 二进制文件生成流程
正常来说,一个 C 语言程序,会先经过预处理生成 x.i
文件,这时候还是 C 语言文件,仅仅是完成了将头文件内容放入 x.c
的文件夹中
接下去就是编译,将 x.i
文件编译为汇编文件 x.s
, 这时候得出来的文件就是汇编文件了
再接下去就是将各个 x.s
文件汇编为 x.o
文件,这时候文件就是二进制格式了,无法打开查看了
最后通过链接器将 x.o
文件,合并成 x.elf
文件用于下载
这时候如果想看汇编的话就需要通过反汇编器来实现反汇编 x.dis
, 一般来说各种编译器自带了反汇编器
# KEIL 下怎么反汇编
在 KEIL 的 User 选项中,如下图添加这两项 (filename 自行替换)
fromelf --bin --output=led.bin Objects\filename.axf
fromelf --text -a -c --output=filename.dis Objects\filename.axf
然后重新编译,即可得到二进制文件 filename.bin、反汇编文件 filename.dis。
如下图操作:
# GCC 下反汇编
使用 GCC 工具链编译程序时,可以在 Makefile 中添加一句:
$(OBJDUMP) -D -m arm filename.elf > filename.dis # OBJDUMP = arm-linux-gnueabihf-objdump
``
就可以把可执行程序 filename.elf,反汇编,得到 filename.dis
# PC 寄存器
PC 寄存器默认指向当前指令的下一条指令位置
但是,不同架构的处理步长会不同,Thumb 指令集中 PC 步长为 4, ARM 指令集中 PC 步长为 8, 而 Thumb2 指令集为了兼容 Thumb 指令集就使用了 4 作为步长
# 汇编如何调用 C 语言函数
# 直接调用
如果函数调用不涉及参数传递,那么可以直接调用
例如,调用 void SystemReset(void)
, 那么就可以直接调用
bl SystemReset
如果函数具有返回值,例如 uint8_t is_empty(void);
, 当调用的时候可以直接调用
返回值会存入 r0~r3
bl is_empty
and r0, #0xff
# 含有参数的函数
如果函数带有参数,那就涉及到了传参的问题,怎么传,用什么传?
其实 ARM 中有一个 ATPCS 规则 ARM-THUMB procedure call standard (ARM-Thumb 过程调用标准)
这个标准的第 10 页约定 r0~r15 寄存器的用途
- R0~r3: 用于参数传递和数值返回
- r4~r11: 用于保存当前函数的局部变量
例如,调下面这个函数
int delay(uint32_t n){ | |
while (n--); | |
return 0; | |
} |
在汇编中的调用代码为
ldr r0, =0x7fffff ; 传递参数, 存放到r0
bl delay
cmp r0, #0 ; 校验返回值
思考一个问题当参数超级多的时候会怎么样?
当函数参数过多的时候,会使用栈来保存
r4~r11 作为局部变量的保存点,可能在调用时会发生改变
这是因为这些寄存器时共享的,CPU 的任何运算操作都需要借助寄存器来实现
所以在调用函数时,需要将 r4~r15 以及 psr 寄存器都保存下来 (当然因地制宜,没用到就不存了,要不然浪费空间)
# C 函数的汇编代码
这里是一段 STM32MP157 的 led 点亮代码,由于 main
函数太长这里只分析一下 delay 的调用和执行
#define USE_A7 | |
static void delay(volatile long ntime) | |
{ | |
while (ntime--) | |
{ | |
; | |
} | |
} | |
int main(void) | |
{ | |
volatile unsigned int *p_reg; | |
/* 使能 PLL4 */ | |
p_reg = (volatile unsigned int *)(0x50000000 + 0x894); | |
*p_reg |= (1 << 0); | |
// 等待时钟稳定 | |
while (!(*p_reg & (1 << 1))) | |
{ | |
; | |
} | |
/* 使能 GPIOA */ | |
#ifdef USE_A7 | |
p_reg = (volatile unsigned int *)(0x50000000 + 0xA28); | |
*p_reg |= (1 << 0); | |
#endif | |
#ifdef USE_M4 | |
p_reg = (volatile unsigned int *)(0x50000000 + 0xAA8); | |
*p_reg |= (1 << 0); | |
#endif | |
/* 设置 GPIO10 为输出模式 */ | |
p_reg = (volatile unsigned int *)(0x50002000 + 0x00); | |
*p_reg &= ~(3 << 20); | |
*p_reg |= (1 << 20); | |
// 指向 (GPIOA_ODR) | |
p_reg = (volatile unsigned int *)(0x50002000 + 0x14); | |
/* 执行任务 */ | |
while (1) | |
{ | |
/* 设置 LED 电平变化 */ | |
*p_reg ^= (1 << 10); | |
delay(1000000); | |
} | |
return 0; | |
} |
经过反汇编后,这里就只给出调用和 delay
的代码,简单进行一下分析
c01000bc: f244 2040 movw r0, #16960 ; 0x4240 ; 将0x4240存入r0的低16位
c01000c0: f2c0 000f movt r0, #15 ; 将0x0f存入r0 的搞16位
; 上面的出来的就是100000
c01000c4: f7ff ffa2 bl c010000c <delay> ; 调用 delay 函数
c010000c <delay>:
c010000c: b480 push {r7} ; 保存共享过程寄存器的原有的值
c010000e: b083 sub sp, #12 ; sp向后偏移12Byte
c0100010: af00 add r7, sp, #0 ; 将sp的值读取如r7
c0100012: 6078 str r0, [r7, #4] ; r0 存入到 sp+4 的地址
c0100014: bf00 nop
c0100016: 687b ldr r3, [r7, #4] ; 将sp+4 地址上的数据读入到r3
c0100018: 1e5a subs r2, r3, #1 ; 将 r3 的值-1 ntime-1
c010001a: 607a str r2, [r7, #4] ; 更新SP+3的数值
c010001c: 2b00 cmp r3, #0 ; 比较是否减到0
c010001e: d1fa bne.n c0100016 <delay+0xa> ; 是则返回
c0100020: bf00 nop
c0100022: 370c adds r7, #12 ; 出栈
c0100024: 46bd mov sp, r7 ; 回复 sp 数值
c0100026: f85d 7b04 ldr.w r7, [sp], #4 ; 回复 r7
c010002a: 4770 bx lr
图片简单讲解一下,更详细的可以参考韦东山的视频
# 使用汇编实现点灯
分析和了解了这么多,总得实战一下,其实本质上是一个 读 - 改 - 存 的过程
一般来说,当出现清零,或者亦或之类的运算在 改 这个步骤上会多一些;
不是注释,这里的注释符号和 C 语言一致
.text
.global _start
_start:
/* 1. 使能GPIO */
/* 使能 RCC */
ldr r0, =(0x50000000 + 0x894)
ldr r1, [r0]
orr r1, r1, #(1<<0)
str r1, [r0]
NotReady:
ldr R1, [R0]
tst r1, #(1 << 1)
beq NotReady /* 不为0则等待 */
/* 使能 GPIOA 模块 */
ldr r0, =(0x50000000 + 0xA28)
ldr r1, [r0]
orr r1, r1, #(1<<0)
str r1, [r0]
/* 配置GPIO引脚 */
/* 设置GPIO10为输出模式 */
ldr r0, = (0x50002000 + 0x00)
ldr r1, [r0]
mov r2, #(3<<20) /* 用于生成清除位 */
bic r1, r1, r2 /* r1 = r1&~(R2) */
orr r1, r1, #(1<<20) /* 配置为 输出 */
str r1, [r0]
/* r0 指向 (GPIOA_ODR) */
ldr r2, = (0x50002000 + 0x14)
Loop:
/* 设置引脚高电平 */
ldr r1, [r2]
orr r1, r1, #(1<<10)
str r1, [r2]
ldr r0, =1000000
bl Delay
/* 设置引脚低电平 */
ldr r1, [r2]
bic r1, r1, #(1<<10)
str r1, [r2]
ldr r0, =1000000
bl Delay
b Loop
Delay:
subs r0, r0, #1
bne Delay
mov pc, lr
能明显的感觉到,咱们的代码要比反汇编的短,会更加高效一些
这是因为编译器会在保证稳妥的情况下再选择高效的指令
当然,还会有更加高效的算法,但这是我能力内目前能想到的一个方案
如果有更好的方法,望各位大佬斧正
# 参考文献
[1] ARM ArchitectureReference Manual ARMv7-A and ARMv7-R edition
[2] DDI0403E_B_armv7m_arm
[3] PM0056
[4] ATPCS (ATM-Thumb 指令调用标准)
大道五十,天衍四十九,人遁其一!