ARM 拇指 GCC 已拆解 C. 调用方保存的寄存器未保存,并立即加载和存储相同的寄存器



上下文: STM32F469 Cortex-M4 (ARMv7-M Thumb-2), Win 10, GCC, STM32CubeIDE;学习/尝试内联汇编和读取反汇编,堆栈管理等,写入核心寄存器,观察寄存器的内容,检查堆栈指针周围的RAM以了解事物的工作原理。

我注意到,在某些时候,当我调用一个函数时,在一个被调用函数的开头,它收到一个参数,为 C 函数生成的指令会立即"将 R3 存储在 RAM 地址 X 上"紧随其后的是"读取 RAM 地址 X 并存储在 RAM 中"。所以它写回和读回相同的值,R3 没有改变。如果它只想将 R3 的值保存到堆栈上,为什么要加载它呢?

C代码,调用函数(主),我的代码:

asm volatile("      LDR R0,=#0x00000000n"
"       LDR R1,=#0x11111111n"
"       LDR R2,=#0x22222222n"
"       LDR R3,=#0x33333333n"
"       LDR R4,=#0x44444444n"
"       LDR R5,=#0x55555555n"
"       LDR R6,=#0x66666666n"
"       MOV R7,R7n" //Stack pointer value is here, used for stack data access
"       LDR R8,=#0x88888888n"
"       LDR R9,=#0x99999999n"
"       LDR R10,=#0xAAAAAAAAn"
"       LDR R11,=#0xBBBBBBBBn"
"       LDR R12,=#0xCCCCCCCCn"
);
testInt = addFifteen(testInt); //testInt=0x03; returns uint8_t, argument uint8_t

函数调用生成将函数参数加载到 R3 中的指令,然后将其移动到 R0,然后使用指向 add15 的链接进行分支。因此,当我输入 add15 时,R0 和 R3 的值0x03 (testInt)。目前为止,一切都好。下面是函数调用的样子:

testInt = addFifteen(testInt);
08000272:   ldrb    r3, [r7, #11]
08000274:   mov     r0, r3
08000276:   bl      0x80001f0 <addFifteen>

所以我进入 add15,我的 C 代码用于 add15:

uint8_t addFifteen(uint8_t input){
return (input + 15U);
}

及其拆卸:

addFifteen:
080001f0:   push    {r7}
080001f2:   sub     sp, #12
080001f4:   add     r7, sp, #0
080001f6:   mov     r3, r0
080001f8:   strb    r3, [r7, #7]
080001fa:   ldrb    r3, [r7, #7]
080001fc:   adds    r3, #15
080001fe:   uxtb    r3, r3
08000200:   mov     r0, r3
08000202:   adds    r7, #12
08000204:   mov     sp, r7
08000206:   ldr.w   r7, [sp], #4
0800020a:   bx      lr

我的主要兴趣是1f8和1fa线。它将 R3 存储在堆栈上,然后将新写入的值加载回仍然保存该值的寄存器中。

问题是:

  1. 这个"将寄存器 A 存储到 RAM X,下一个从 RAM X 读取值到寄存器 A 中"的目的是什么?阅读说明似乎没有任何用处。确保 RAM 写入已完成?

  2. Push{r7} 指令使堆栈 4 字节对齐,而不是 8 字节对齐。但是在该指令之后,我们将 SP 减少 12(字节),因此它再次变为 8 字节对齐。因此,此行为是可以的。这种说法正确吗?如果在这两个指令之间发生中断怎么办?在 ISR 期间,在 ISR 堆叠期间,对齐方式是否会固定?

  3. 从我读到的关于调用方/被叫方保存寄存器的内容(很难找到任何组织良好的信息,如果你有好的材料,请分享一个链接),当我调用函数时,至少 R0-R3 必须放在堆栈上。但是,在这种情况下很容易注意到没有一个寄存器被推送到堆栈上,我通过检查堆栈指针周围的内存来验证它,很容易注意到0x11111111和0x22222222,但它们不存在,也没有任何东西将它们推到那里。在调用函数之前,R0 和 R3 中的值将永远消失。为什么在函数调用之前没有在堆栈上推送任何寄存器?我希望在 add15 返回时0x33333333 R3,因为这就是函数调用之前的情况,但该值甚至在分支到 add15 之前就被随意覆盖。为什么 GCC 没有生成指令将 R0-R3 推送到堆栈上,并且只在该分支之后才链接到 add15?

如果您需要一些编译器设置,请告诉我在Eclipse(STM32CubeIDE)中在哪里可以找到它们以及您在那里需要什么,我很乐意提供它们并将它们添加到这里的问题中。

uint8_t addFifteen(uint8_t input){
return (input + 15U);
}

你在这里看到的是未优化的,至少在 gnu 中,输入和局部变量在堆栈上获得内存位置。

00000000 <addFifteen>:
0:   b480        push    {r7}
2:   b083        sub sp, #12
4:   af00        add r7, sp, #0
6:   4603        mov r3, r0
8:   71fb        strb    r3, [r7, #7]
a:   79fb        ldrb    r3, [r7, #7]
c:   330f        adds    r3, #15
e:   b2db        uxtb    r3, r3
10:   4618        mov r0, r3
12:   370c        adds    r7, #12
14:   46bd        mov sp, r7
16:   bc80        pop {r7}
18:   4770        bx  lr

你在 r3 中看到的是输入变量 input 在 r0 中。 由于某种原因,代码没有优化,它进入 r3,然后保存在堆栈上的内存位置。

设置堆栈

00000000 <addFifteen>:
0:   b480        push    {r7}
2:   b083        sub sp, #12
4:   af00        add r7, sp, #0

将输入保存到堆栈

6:   4603        mov r3, r0
8:   71fb        strb    r3, [r7, #7]

所以现在我们可以开始在函数中实现代码,该函数想要对输入函数进行数学运算,因此进行数学运算

a:   79fb        ldrb    r3, [r7, #7]
c:   330f        adds    r3, #15

将结果转换为无符号字符。

e:   b2db        uxtb    r3, r3

现在准备返回值

10:   4618        mov r0, r3

并清理并返回

12:   370c        adds    r7, #12
14:   46bd        mov sp, r7
16:   bc80        pop {r7}
18:   4770        bx  lr

现在,如果我告诉它不要使用帧指针(只是浪费寄存器)。

00000000 <addFifteen>:
0:   b082        sub sp, #8
2:   4603        mov r3, r0
4:   f88d 3007   strb.w  r3, [sp, #7]
8:   f89d 3007   ldrb.w  r3, [sp, #7]
c:   330f        adds    r3, #15
e:   b2db        uxtb    r3, r3
10:   4618        mov r0, r3
12:   b002        add sp, #8
14:   4770        bx  lr

您仍然可以看到实现该功能的每个基本步骤。 未优化。

现在,如果您优化

00000000 <addFifteen>:
0:   300f        adds    r0, #15
2:   b2c0        uxtb    r0, r0
4:   4770        bx  lr

它消除了所有多余的部分。

弐。

是的,我同意这看起来不对,但是 gnu 肯定不会始终保持堆栈对齐,所以这看起来不对。 但我还没有阅读有关手臂呼叫公约的详细信息。 我也没有阅读过GCC的解释。 当然,他们可以声明规范,但在一天结束时,编译器作者为他们的编译器选择调用约定,他们没有义务武装或英特尔或其他人遵守任何规范。 他们的选择,就像C语言本身一样,有很多地方定义了它的实现,GNU以一种方式实现C语言,而其他方式则以另一种方式实现。 也许这是相同的。 将传入变量保存到堆栈也是如此。 我们将看到llvm/clang没有。

第三。

R0-R3和另一个或两个寄存器可能称为调用方保存,但更好的考虑方法是易失性。 被调用方可以自由修改它们而不保存它们。 与其说是保存 r0 寄存器,不如说 r0 表示一个变量,您在功能上实现高级代码时管理该变量。

例如

unsigned int fun1 ( void );
unsigned int fun0 ( unsigned int x )
{
return(fun1()+x);
}
00000000 <fun0>:
0:   b510        push    {r4, lr}
2:   4604        mov r4, r0
4:   f7ff fffe   bl  0 <fun1>
8:   4420        add r0, r4
a:   bd10        pop {r4, pc}

x 出现在 R0 中,我们需要保留该值,直到调用 fun1() 之后。 R0 可以被 fun1() 销毁/修改。 所以在这种情况下,他们保存 r4,而不是 r0,并将 x 保留在 r4 中。

叮当也这样做

00000000 <fun0>:
0:   b5d0        push    {r4, r6, r7, lr}
2:   af02        add r7, sp, #8
4:   4604        mov r4, r0
6:   f7ff fffe   bl  0 <fun1>
a:   1900        adds    r0, r0, r4
c:   bdd0        pop {r4, r6, r7, pc}

回到你的函数。

未优化的 CLANG 也会将输入变量保留在内存(堆栈)中。

00000000 <addFifteen>:
0:   b081        sub sp, #4
2:   f88d 0003   strb.w  r0, [sp, #3]
6:   f89d 0003   ldrb.w  r0, [sp, #3]
a:   300f        adds    r0, #15
c:   b2c0        uxtb    r0, r0
e:   b001        add sp, #4
10:   4770        bx  lr

您可以看到相同的步骤,准备堆栈,存储输入变量。以输入变量进行数学运算。 准备返回值。 清理,返回。

Clang/llvm optimized:

00000000 <addFifteen>:
0:   300f        adds    r0, #15
2:   b2c0        uxtb    r0, r0
4:   4770        bx  lr

恰好与 gnu 相同。 不期望任何两个不同的编译器生成相同的代码,也不期望同一编译器的任何两个版本生成相同的代码。

  1. 未优化后,输入和局部变量(在本例中为无)在堆栈上获得主页。 因此,您看到的是输入变量作为函数设置的一部分放在堆栈上的主页中。 然后函数本身想要对该变量进行操作,因此,未经优化,它需要从内存中获取该值以创建一个中间变量(在这种情况下,该变量在堆栈上没有主页)等等。 您也可以在易失变量中看到这一点。 它们将被写入内存,然后读回然后修改然后写入内存并读回,等等......

  2. 是的,我同意,但我还没有阅读规格。 归根结底,这是 gcc 的调用约定或对他们选择使用的某些规范的解释。 他们已经这样做了很长时间(不是 100% 对齐),而且不会失败。 对于所有被调用的函数,它们在调用函数时对齐。 gcc 生成的 arm 代码中的中断并非始终对齐。 自从他们采用该规范以来一直如此。

  3. 根据定义,R0-R3等是易失性的。 被叫方可以随意修改它们。 被调用方只需在 IT 需要时保存/保留它们。 在未优化和优化的情况下,只有 r0 对函数很重要,它是输入变量,用于返回值。 您在我创建的函数中看到,即使优化了输入变量,输入变量也会保留以供以后使用。 但是,根据定义,调用方假定这些寄存器被调用函数销毁,并且被调用函数可以销毁这些寄存器的内容,而无需保存它们。

就内联汇编而言,这是一种与"真正的"汇编语言不同的汇编语言。 我认为在为此做好准备之前,您还有很长的路要走,但也许没有。 经过几十年不断的裸机工作,我发现内联汇编的实际用例为零,我看到的情况是懒惰避免允许真正的组装进入 make 系统或避免编写真实汇编语言的方法。 我认为它是人们使用的酥油奇才功能,例如联合和位字段。

在 gnu 中,对于 arm,你至少有四种不兼容的 arm 汇编语言。 不统一语法实程序集,统一语法实程序集。 使用 gcc 进行汇编而不是 as 然后对 gcc 进行内联汇编时看到的汇编语言。 尽管声称兼容 clang arm 汇编语言与 gnu 汇编语言并非 100% 兼容,并且 llvm/clang 没有单独的汇编器,但您可以将其提供给编译器。 多年来,Arms 的各种工具链与 GNU for arm 的汇编语言完全不兼容。 这都是意料之中的,也是正常的。 汇编语言特定于工具而不是目标。

在进入内联汇编语言之前,先学习一些真正的汇编语言。 公平地说,也许你会这样做,也许很好,这个问题是关于编译器如何生成代码的发现,以及当你发现它不是一对一的事情时看起来有多奇怪(所有情况下的所有工具都从相同的输入生成相同的输出)。

对于内联 asm,虽然您可以指定寄存器,但根据您正在执行的操作,您通常希望让编译器选择寄存器,内联汇编的大部分工作不是程序集,而是特定编译器用于接口它的语言......这是特定于编译器的,移动到另一个编译器,期望是一种全新的语言来学习。 虽然在汇编程序之间移动也是一种全新的语言,但至少指令本身的语法往往是相同的,语言差异在于其他所有内容,标签和指令等。 如果幸运的话,它是一个工具链,而不仅仅是一个汇编程序,您可以查看编译器的输出以开始理解该语言并将其与可以找到的任何文档进行比较。 在这种情况下,Gnus 文档非常糟糕,因此需要进行大量的逆向工程。 同时,你更有可能使用GNU工具比其他任何工具都成功,不是因为它们更好,在许多情况下它们不是,而是因为纯粹的用户群和跨目标和几十年历史的共同特性。

我会非常擅长通过创建模拟 C 函数来查看使用了哪些寄存器等,从而将 asm 与 C 接口。 和/甚至更好,用 C 实现它,编译它,然后手动修改/改进/编译器的任何输出(你不需要成为大师来击败编译器,也许保持一致,但相当经常你可以很容易地看到可以在 gcc 输出上进行的改进,并且 gcc 在过去的几个版本中变得越来越糟糕,它并没有变得更好, 正如您在本网站上不时看到的那样)。 在这个工具链和目标以及编译器如何工作的 asm 中变得强大,然后也许学习 GNU 内联汇编语言。

  1. 我不确定这样做是否有特定目的。 这只是编译器找到的一种解决方案。

例如代码:

unsigned int f(unsigned int a)
{ 
return sqrt(a + 1);
}

使用优化级别为 -O0 的 ARM GCC 9 NONE 编译为:

push    {r7, lr}
sub     sp, sp, #8
add     r7, sp, #0
str     r0, [r7, #4]
ldr     r3, [r7, #4]
adds    r3, r3, #1
mov     r0, r3
bl      __aeabi_ui2d
mov     r2, r0
mov     r3, r1
mov     r0, r2
mov     r1, r3
bl      sqrt
...

在级别 -O1 中:

push    {r3, lr}
adds    r0, r0, #1
bl      __aeabi_ui2d
bl      sqrt
...

如您所见,asm 在 -O1 中更容易理解:将参数存储在 R0 中,添加 1,调用函数。

  1. 硬件在异常期间支持未对齐堆栈。看这里

  2. "调用方
  3. 保存"寄存器不一定需要存储在堆栈上,由调用方知道是否需要存储它们。 在这里,您混合(如果我理解正确的话)C 和汇编:因此,您必须在切换回 C 之前完成编译器作业:要么将值存储在被调用方保存的寄存器中(然后您按照惯例知道编译器将在函数调用期间存储它们)或者您自己将它们存储在堆栈上。

最新更新