发新帖本帖赏金 50.00元(功能说明)我要提问
返回列表
打印
[开发工具]

探讨编译优化等级与内存占用的关系

[复制链接]
165|1
手机看帖
扫描二维码
随时随地手机跟帖
跳转到指定楼层
楼主
DKENNY|  楼主 | 2024-6-27 17:43 | 只看该作者 |只看大图 回帖奖励 |倒序浏览 |阅读模式
本帖最后由 DKENNY 于 2024-6-27 17:54 编辑

#申请原创# @21小跑堂

前言
    上文简单的介绍了优化等级与代码执行效率的一些关系,这篇文章我们聊聊当我们选择不同的编译优化等级时,这段代码到底做了什么样的优化,优化的点到底在哪,并且当优化等级的提高时,代码的内存占用有什么变化。

## Keil的编译内存
  在 Keil 中,编译内存是指编译过程中涉及的不同类型的内存区域,这些区域对于程序的性能和效率至关重要。以下是一些主要的内存区域:
  - 代码区(Code Memory):通常是只读的,存储程序的机器代码。在微控制器中,这通常对应于内部或外部的闪存。
  - 数据区(Data Memory):存储全局变量和静态变量。这部分内存在程序执行期间保持不变。
  - 堆栈(Stack):用于存储局部变量和函数调用的上下文。堆栈的大小和使用效率直接影响程序的稳定性和性能。
  - 堆(Heap):用于动态内存分配。在嵌入式系统中,由于资源有限,通常会尽量避免使用动态内存分配。

  一般代码编译后,Keil会生成一个map文件,我们查看这个文件,里面记录了代码内存使用与映射关系。
  例如,如下是keil的某工程编译生成的map文件,其记录了该工程占用的RAM和ROM区。

## 内存占用分析--代码反汇编
    首先,我们需要明白,仅凭C语言代码本身,分析其优化过程是相当困难的。因此,我们需要将C代码转换为更底层的汇编代码,通过研究其对应的汇编代码,我们才能深入理解代码的具体优化细节。
    为了实现这个功能,我们需要用到下面这个工具,这个工具可以帮我们把c语言转化为对应的汇编代码。
   
    工具:CompilerExplorer(https://godbolt.org/




### 1.简单函数测试--add()
    我们首先实现一个特别简单的函数,接收两个整数参数并返回它们的和
int add(int a, int b)
{
    return a + b;
}

#### 1.1 反汇编代码(-00)


  接下来我们分析一下这段汇编代码,它具体做了什么操作。
    1. push {r7}: 这条指令将寄存器 `r7` 的当前值压入堆栈。
    2. sub sp, sp, #12: 这条指令将栈指针 `sp` 减去12,为局部变量分配空间。
    3. add r7, sp, #0: 将栈指针的值复制到 `r7`,这里 `r7` 可能作为新的帧指针使用,指向栈的当前位置。
    4. str r0, [r7, #4] 和 str r1,[r7]: 这两条指令将函数的参数 `a` 和 `b` 存储在帧指针 `r7` 指向的栈空间的特定位置。
    5. ldr r2, [r7, #4] 和 ldr r3,[r7]: 从刚才存放 `a` 和 `b` 的位置加载这两个值到 `r2` 和 `r3` 寄存器,准备执行加法操作。
    6. add r3, r3, r2: 执行加法操作,将 `r2` 和 `r3` 中的值相加,结果存储在 `r3` 中。
    7. mov r0, r3: 将加法的结果从 `r3` 移动到 `r0`。
    8. adds r7, r7, #12: 调整帧指针 `r7`,释放之前分配的栈空间。
    9. mov sp, r7: 恢复栈指针 `sp` 的值,这样栈就回到函数调用前的状态。
    10. ldr r7, [sp], #4:从堆栈中弹出先前保存的`r7` 的值,恢复 `r7` 的原始值。
    11. bx lr: 返回指令,跳转到链接寄存器 `lr`(Link Register) 存储的地址,即返回到调用这个函数的位置。

    总的来说,这段汇编代码展示了函数调用的标准约定,包括参数传递、本地存储分配和清理、以及返回值处理,基本符合一个正常函数的调用逻辑。

#### 1.2 反汇编代码(-01)


  我们同样分析一下这段汇编代码。
    1. `add r0, r0, r1`: 这条指令直接在寄存器内完成加法操作。它将`r0` 和 `r1` 寄存器中的值相加,将结果存储回`r0` 寄存器。由于在大多数ARM架构调用约定中,`r0` 和 `r1` 寄存器用于传递函数的前两个整数参数,这也意味着这个指令直接使用了传入函数的原始参数进行计算,并将加法操作的结果置于`r0` 中,准备作为函数返回值。这种做法省略了存储和从堆栈加载参数的需要,显著提高了代码的效率。
    2. `bx lr`: 这条指令是一个分支指令,它使用链接寄存器(LinkRegister, `lr`)中存储的地址进行跳转,通常指向调用当前函数的下一条指令。在这个上下文中,这意味着函数执行结束后,控制流会返回到调用该`add` 函数的位置。这是函数调用完成后的标准退出方式。

#### 1.3 反汇编代码(-02)


    可以看到选择O2对应的反汇编代码跟O1一样,说明O1已经是最佳的优化等级了。

#### 1.4 三种编译优化等级的分析
  
优化等级
内存占用和优化
-O0
。由于无任何优化,会执行多个操作栈的指令,导致较大的内存占用和较慢的执行速度。
-O1
。去除了所有不必要的栈操作,直接在寄存器中完成计算和函数返回,减少了内存占用。
-O2
。与 -O1 相同,由于函数简单,没有进一步的优化空间,也没有使用额外的栈空间。

    总结来说,随着优化级别的提高,编译器越来越多地利用寄存器来处理数据,减少对内存的依赖。这降低了内存占用并提高了程序的执行速度。在 -O1-O2 级别,由于 add 函数的简单性,生成的汇编代码相同,都是直接在寄存器中进行计算,没有使用栈空间。

### 2.多个函数测试--test_add()函数
    我们再看看下面这个函数段,也就是我们上次做测试用的。
typedef unsigned int u32;

void add(u32 n)
{
    u32 a = 0, b = 0;
    while (n-- > 0)
    {
        a = b + n;
    }
}

//执行100000次
void test_add(void)
{
    u32 n = 100000;

    while (n-- > 0)
    {
        add(100000);
    }

    return;
}

#### 2.1 反汇编代码(-00)


    这里显示不全,具体的代码可以自行使用 Compiler Explorer 这款工具去尝试。我们同样对这段汇编代码简单分析一下。
add(unsigned int):
        push    {r7}                ; 保存r7寄存器的当前值到栈中
        sub     sp, sp, #20         ; 栈指针sp减去20,为局部变量分配空间
        add     r7, sp, #0          ; 设置帧指针r7为当前栈指针sp的值
        str     r0, [r7, #4]        ; 将函数参数(r0寄存器的值)存储到局部变量空间
        movs    r3, #0              ; 将0移动到r3寄存器,准备初始化累加器
        str     r3, [r7, #12]       ; 初始化累加器为0
        movs    r3, #0              ; 再次将0移动到r3寄存器,这一步是多余的
        str     r3, [r7, #8]        ; 初始化循环计数器为0
        b       .L2                 ; 跳转到标签.L2,开始循环
.L3:
        ldr     r2, [r7, #8]        ; 加载循环计数器的当前值到r2寄存器
        ldr     r3, [r7, #4]        ; 加载累加器的当前值到r3寄存器
        add     r3, r3, r2          ; 将r2和r3的值相加,结果存回r3
        str     r3, [r7, #12]       ; 将累加结果存回累加器位置
.L2:
        ldr     r3, [r7, #4]        ; 重新加载函数参数到r3寄存器
        subs    r2, r3, #1          ; 函数参数减1,结果存入r2
        str     r2, [r7, #4]        ; 更新函数参数的存储值
        cmp     r3, #0              ; 比较函数参数是否为0
        ite     ne                   ; 如果不等于0,则执行接下来的movne指令,否则执行moveq指令
        movne   r3, #1              ; 如果不等于0,将1移动到r3寄存器
        moveq   r3, #0              ; 如果等于0,将0移动到r3寄存器
        uxtb    r3, r3              ; 将r3寄存器的值扩展为无符号字节
        cmp     r3, #0              ; 再次比较r3的值是否为0
        bne     .L3                 ; 如果不等于0,跳转回.L3继续循环
        nop                         ; 空操作,没有实际效果
        nop                         ; 另一个空操作
        adds    r7, r7, #20         ; 清理局部变量空间,将帧指针r7恢复到原始位置
        mov     sp, r7              ; 将栈指针sp设置为帧指针r7的值,恢复栈指针
        ldr     r7, [sp], #4        ; 从栈中弹出原始的r7值,恢复r7寄存器,并更新栈指针sp
        bx      lr                  ; 返回到调用函数的地方
test_add():
        push    {r7, lr}            ; 保存r7寄存器和链接寄存器lr到栈中
        sub     sp, sp, #8          ; 栈指针sp减去8,为局部变量分配空间
        add     r7, sp, #0          ; 设置帧指针r7为当前栈指针sp的值
        movw    r3, #34464          ; 将34464的低16位移动到r3寄存器
        movt    r3, 1               ; 将1的高16位移动到r3寄存器,与之前的低16位组合成一个32位数
        str     r3, [r7, #4]        ; 将这个32位数存储到局部变量空间
        b       .L5                 ; 跳转到标签.L5,开始测试循环
.L6:
        movw    r0, #34464          ; 将34464的低16位移动到r0寄存器
        movt    r0, 1               ; 将1的高16位移动到r0寄存器,准备作为参数传递给add函数
        bl      add(unsigned int)   ; 调用add函数
.L5:
        ldr     r3, [r7, #4]        ; 加载局部变量的值到r3寄存器
        subs    r2, r3, #1          ; 局部变量减1,结果存入r2
        str     r2, [r7, #4]        ; 更新局部变量的存储值
        cmp     r3, #0              ; 比较局部变量是否为0
        ite     ne                   ; 如果不等于0,则执行接下来的movne指令,否则执行moveq指令
        movne   r3, #1              ; 如果不等于0,将1移动到r3寄存器
        moveq   r3, #0              ; 如果等于0,将0移动到r3寄存器
        uxtb    r3, r3              ; 将r3寄存器的值扩展为无符号字节
        cmp     r3, #0              ; 再次比较r3的值是否为0
        bne     .L6                 ; 如果不等于0,跳转回.L6继续测试循环
        nop                         ; 空操作,没有实际效果
        adds    r7, r7, #8          ; 清理局部变量空间,将帧指针r7恢复到原始位置
        mov     sp, r7              ; 将栈指针sp设置为帧指针r7的值,恢复栈指针
        pop     {r7, pc}            ; 从栈中弹出原始的r7和pc值,恢复r7寄存器和跳转回调用位置

  我们可以看到,上面很多的操作都是无用且多余的。
1. 重复的初始化:

   movs    r3, #0
   str     r3, [r7, #12]
   movs    r3, #0              ; 这行是多余的,因为r3寄存器已经是0了
   str     r3, [r7, #8]

   在这里,`r3`寄存器被连续两次设置为0,但第二次实际上是不必要的,因为`r3`已经是0了。

  2. 无操作指令(NOP):
   nop                         ; 这个nop操作没有实际效果
   nop                         ; 这个也是

   `nop`指令通常用于调试或者占位,但在这段代码中它们没有实际的作用,可以移除。

  3. 条件标志的冗余检查:
   在`add(unsignedint)`函数中,有一个条件检查的序列,它检查了两次相同的条件:
   cmp     r3, #0
   ite     ne
   movne   r3, #1
   moveq   r3, #0
   uxtb    r3, r3
   cmp     r3, #0              ; 这个比较是多余的,因为r3已经被设置为1或0
   bne     .L3

#### 2.2 反汇编代码(-01)


    我们同样对这段汇编代码分析一下。
add(unsigned int):
.L2:
        subs    r0, r0, #1     ; r0寄存器的值减1
        cmp     r0, #-1        ; 将r0寄存器的值与-1比较
        bne     .L2            ; 如果r0不等于-1,跳转回.L2继续循环
        bx      lr             ; 返回到调用者的地址
test_add():
        movw    r3, #34464     ; 将立即数34464移动到r3寄存器的低16位
        movt    r3, 1          ; 将立即数1移动到r3寄存器的高16位
.L5:
        subs    r3, r3, #1     ; r3寄存器的值减1
        bne     .L5            ; 如果r3不等于0,跳转回.L5继续循环
        bx      lr             ; 返回到调用者的地址

    -O1相比于-O0的优化等级,做了以下几点优化:
    1. 简化了栈操作:-O0 有多个对栈的操作,包括保存和恢复寄存器、分配和清理局部变量空间。-O1 中这些操作被大幅简化,只有必要的寄存器保存和恢复,这减少了对栈的访问次数,提高了效率。
    2. 移除了局部变量:-O0中使用了局部变量来存储函数参数和中间结果,而-O1直接在寄存器中操作,避免了对内存的访问,从而提高了速度。
    3. 减少了指令数量:-O0 中有一些重复的指令和无操作指令(nop),这些在-O1中都被移除了,减少了程序的大小和执行时间。
    4. 优化了循环结构:-O0中的循环包含了更多的指令和条件判断,而-O1中的循环被大幅简化,只包含减法和分支指令,这使得循环更加紧凑和高效。
    5. 移除了不必要的条件执行:-O0中使用了`ite` 指令来进行条件执行,但在-O1中,这种复杂的条件执行被移除,简化了程序的流程。

    总的来说,-O1在减少指令数量、简化程序结构、提高执行效率方面做了优化。这些优化使得代码更加紧凑,执行更快,也更容易理解和维护。  

    这段代码在上次的测试下,结果发现O2的执行效率比O1还快,但我们可以看到O1的反汇编代码已经够简洁了,我们接下来看看O2等级下的反汇编代码是什么形式的。

#### 2.3 反汇编代码(-02)


    我们可以看到,-O2下的反汇编代码只有短短几行,我们同样分析一下。
add(unsigned int):
        bx      lr               ; 直接返回到调用函数
test_add():
        bx      lr               ; 直接返回到调用函数

    在C++代码中,`add`函数计算了一个累加的值,但是这个值并没有被使用或返回。同样,`test_add` 函数调用了 `add` 函数100000次,但是`add` 函数的结果也没有被使用。由于这些计算的结果没有被使用,编译器在O2优化级别下认为这些计算是无效的,因此它将这些函数体优化掉了。
    在-O2优化级别下,编译器会进行死代码消除(DeadCode Elimination),这意味着它会移除那些不影响程序最终结果的代码。由于 `add` 和 `test_add` 函数的计算结果并没有对程序的输出产生影响,所以编译器决定移除这些无用的计算,从而节省了内存和执行时间。
    在汇编代码中,`bx lr` 指令是一个分支指令,它直接返回到调用函数的地方。这意味着`add` 和 `test_add` 函数的函数体被完全优化掉了,它们只是简单地返回而不做任何操作。这种优化减少了程序的大小,因为不需要为这些函数分配额外的内存来存储局部变量或执行计算。同时,它也提高了程序的执行速度,因为不需要执行无用的计算。

#### 2.4 反汇编代码(-03)


    我们注意到,O3的反汇编代码与O2的一样,说明O2已经是最佳的优化等级了。

#### 2.5 四种编译优化等级的分析
  
优化等级
优化内容
内存占用
O0
保留了所有的指令和局部变量,没有进行任何优化。
最大,因为所有的局部变量和状态都被保存在栈上。
O1
移除了一些不必要的指令,简化了循环。
减少,因为一些局部变量和指令被优化掉了。
O2
完全移除了函数体内的计算,只保留了返回指令。
最小,因为函数体内没有任何计算和局部变量的存储。
O3
与O2类似,执行了更激进的代码优化,可能包括内联函数、移除冗余代码等,但具体表现与O2相同。
最小,同O2,因为仅保留了返回指令。

    这些优化使得程序的大小和执行时间都得到了改善,但也意味着如果函数的计算结果是需要的,那么在高优化等级下可能会出现问题,因为编译器可能会错误地移除那些看似无用但实际上有用的代码。因此,在使用高级别的优化时,开发者需要确保他们的代码逻辑是正确的,并且所有需要的计算都被正确地使用了。

## 总结
    针对上述函数在不同编译优化等级(O0O1O2O3)下为,我们可以看到,随着优化等级的提升,编译器采用了不同的策略来优化代码而影响了内存占用。
    -O0等级在这个基本的优化等级下,编译器几乎不做任何优化。所有的局部变状态和计算都被保留。因此,内存占大的,因为每个函数调用都需要在栈上分配空间以保存这些局部变量和状态。
    -O1等级在这一等级,编译器开始进行一些基本的优化,比如移除不必要令和简化循环结构。这些优化导致了内存占用的减少,主要是因为移除了一部分局部变量和指令,从而减少了栈的使用。
    -O2等级编译器在O2等级下进行了更激进的优化,包括完全移无用计算和局部变量。如果函数的输出不被外部使用或者检测到计算结果不会对程序结果产生影响,这些计算就会被移除。结果是,内存占用达到了最小,因为函数体内没有任何计算和局部变量需要存储。
    -O3等级在O3等级,编译器尝试采取了比O2更激进的优化技术,如函数内联和循环展开等。但是,由于在给出的 `add` 和 `test_add` 函数示例中,这些函数的计算结果没有被使用,导致其反汇编代码与O2等级相同保留了返回指令。因此,尽管O3可能引入了额外的优化措施,其对内存占用的影响与O2等级一致——都是最小值。


    总的来说,随着优化等级的提高,编译移除不必要的、局部变量以及整合计算步骤等手段,有效减少了程序的内存占用。
    在上一篇帖子(链接:https://bbs.21ic.com/icview-3385800-1-1.html)中,还有一个FFT函数的实现,有兴趣的可以使用 Compiler Explorer 这个工具查看对应的反汇编代码,分析一下为什么使用O3的优化等级相比与O2的优化等级执行效率反而慢了,这里就不介绍了,可以自行探索。



使用特权

评论回复

打赏榜单

21小跑堂 打赏了 50.00 元 2024-06-28
理由:恭喜通过原创审核!期待您更多的原创作品~

评论
21小跑堂 2024-6-28 09:59 回复TA
一篇探讨编译优化等级与内存占用关系的实操文,很详细的介绍了编译器的优化等级和内存占用之间的关系,并通过实际代码和分析阐述底层原理。对关键点的解读和探讨较为深入,总体较佳。 
发新帖 本帖赏金 50.00元(功能说明)我要提问
您需要登录后才可以回帖 登录 | 注册

本版积分规则

20

主题

35

帖子

4

粉丝