极术小能手 · 2022年03月12日

【课程实验 LAB1】“施法”让CPU动起来:函数调用

实验前请点击获取配套资料

关于LAB1:在本实验 LAB1 中, 你将动手学习 Keil 开发环境的搭建, 基础汇编程序的编写, 编译和仿真调试方法, 并通过几个简单程序理解函数调用的 ATPCS 规则.
在进行本实验之前, 你应该提前掌握了一些基础的 Arm 指令和伪指令语法, 并阅读过一些简单的汇编程序.

栈 - 函数调用的指挥官

程序运行栈是内存中一块很重要的区域, 在我们之前写汇编程序的时候, 并没有使用到这块区域, 但当我们写c程序时, 栈就开始发挥作用了.

这是一条为强者节省时间的提示

如果你觉得自己已经掌握了栈的工作原理与 ATPCS, 你可以跳过本小节.

我需要首先掌握哪些知识呢?

如果你不确定自己的理论储备是否合格, 那么请试着回答下面几个问题来检验自己:

  • 对一个包含多个c函数的c文件进行编译时, 编译出来的结果是每个函数各成一段还是根据函数的嵌套关系将所有程序 "合并" 到一起?
  • 在 Cortex-M0 体系结构中, 栈被映射在哪个存储区? 地址是如何增长的?
  • 栈指针是什么? 它被存放在哪个寄存器中? 能否通过指令直接修改这个寄存器的值?
  • 栈帧是一个什么样的概念?
  • 为什么需要 ATPCS ? ATPCS 主要规定了哪几个方面?

如果你对这些问题感觉到有些吃力, 不要着急, 可以先稍回顾一下教科书, 然后看一看下面的内容...

编译器是怎样处理函数调用的?

我们知道, 编译器对一个工程中的多个c文件是独立编译的, 每个文件编译得到的目标文件最终由链接器链接生成可执行文件. 其实, 编译器对一个c文件中的多个c函数也是独立编译处理的, 每个c函数经过编译会得到一个程序段, 每个程序段拥有一个基地址, 作为该程序的入口地址, 所有的程序段共同构成了目标文件中完整的代码段(.text). 当在一个函数中调用其他函数时, 调用语句会被编译成一个跳转指令, 跳转到被调用函数的入口地址.

这意味着, 编译器保留了多个c函数之间的调用关系, 所以当你是一个 "嵌套狂魔" 时, CPU 需要在函数间 "反复横跳", 这会在一定程度上引入性能开销.

怎样理解栈帧?

程序运行栈运用了 "栈" 的 "先入后出" 的调度思想, 用于管理c函数调用. 函数的调用有个重要的特点: 当多个函数嵌套运行时, 每个函数只和调用它的函数(直接上层)和它调用的函数(直接下层)之间有直接的信息传递, 这是一种分层的设计思想, 而栈就是一种实现分层设计的有效手段. 每嵌套一个函数, 就为它开辟一个栈帧用于存放这个函数的运行数据, 每个栈帧维护一个函数"层", 栈帧在栈中向上"堆积". 注意, 这里的 "先入后出" 是不严格的, 它并不意味着我不能去访问那些被 "压在下面" 的栈单元, 汇编指令允许我可以通过在 SP 上增减任意值作为地址来 LDR 或 STR 任意一个相对寻址的栈单元.

我们为什么要学栈?

你是否有这样的问题: 既然当我写汇编程序时用不到栈, 而当我我写c程序时编译器已经帮我把一切都做好了, 我完全不需要关心我的程序是怎么在栈中运行的, 那我为什么还要学习栈呢?

其实, 学习栈的工作原理能够帮助我们更好地理解c语言编程中的一些"忌讳", 让我们从本质上搞明白一些"玄学"问题, 还能指导我们对程序作出底层的优化. 比如:

  • 为什么在c语言中不允许返回局部变量的指针, 但允许返回静态局部变量的指针?
  • 为什么函数里不建议定义大型局部数组?
  • 怎样提升函数调用时参数传递的效率?

我们为什么需要 ATPCS?

试想, 既然编译器是对每个c函数独立进行编译的, 那它是怎么知道函数传入的参数应该从哪里获取, 返回的数据应该存储在哪里, 以便其他函数使用呢? 另外, 如果这个函数被调用运行, 那么在当前函数中的寄存器操作必然会覆盖之前的寄存器值, 是否需要先将寄存器的原始值保存起来呢? 当这个函数运行结束时, 怎样正确返回到调用它的函数里继续运行呢? 怎样为函数内定义的局部变量分配存储空间呢? ...

对于这些问题, 目前几乎所有的计算机体系都采用了类似的解决方式:

  • 规定优先使用寄存器传参和保存局部变量, 寄存器不够用时就用栈.
  • 规定使用入栈的方式来保存寄存器值和返回地址, 这一步叫做"保护现场".

另外, 为了实现函数调用, 我们还必须规定好使用哪几个寄存器传参, 使用哪几个寄存器保存局部变量, 还有寄存器的传参顺序, 局部变量在寄存器中的定义顺序, 以及入栈出栈和寄存器间的对接顺序 .... 然而, 由于不同指令集架构中寄存器的数量和功能不同, 所以不同指令集架构中的规定也各不相同, 这些规定被称为指令集架构的函数调用标准, 比如 x86 的 cdel, x86-64 的 fastcall 和我们学到的 ARM 的 ATPCS 等等.

你手上的编译器才是最好的老师

文字的表达有时是无力的, 理论的灌输可能是抽象的. 如果你很有兴趣, 很想弄明白函数调用和栈的深层原理, 为何不去自己动手写个程序编译一下, 向你的编译器学习学习? 在动手的过程中不妨思考这样几个问题:

  • SP 的值是怎么发生变化的? 为什么要发生变化?
  • LP 是怎么一步步被加载, 保护和重加载的? 它是怎样指导嵌套的程序找到返回地址的?
  • "保护现场" 这一步是调用者完成的还是被调用者完成的?
  • 栈帧是如何被 "开辟" 出来的?
  • 为什么程序只会去访问当前栈帧和相邻栈帧中的数据, 不会去访问更底层的栈帧中的数据?
  • 编译器做了哪些 "无用功"? 为什么会做这些 "无用功"?

自己动手, 理解函数调用

汇编程序之间原则上不需要通过栈进行相互调用, 而 c 程序之间基于栈的相互调用在编译器的处理下是对编程者屏蔽的, 所以, 要动手理解 ARM 程序的调用方式, 最好的选择是进行 c 程序与汇编程序的相互调用.

c 程序调用汇编程序

在 c 程序的 main 函数中调用一段汇编程序, 实现字符串的复制.

首先, 在之前创建的 Keil 工程的基础上, 移除 Target 中的所有源文件.

image.png
移除源文件的方法
打开文件夹 "./Task1/call", 发现这里已经为你准备好了本实验需要的启动文件, 直接将其添加至工程 Target 中即可. 打开启动文件, 你会发现这里多了几行用于栈初始化的代码 (记住这句话, 待会要考).

然后在 "./Task1/call" 中新建文件 "main.c" 和 "strcopy.s" 并添加至工程 Target 中.

在 "main.c" 中添加以下代码:

#include <string.h>
#include <stdint.h>
#include <stdio.h>

extern void strcopy(char *d, const char *s);

int main()
{
    char srcstr[] = "First string - soure";         /* 定义源字符串数组并初始化 */
    char dststr[] = "Second string - destination";  /* 定义目的字符串数组并初始化 */
    strcopy(dststr,srcstr);                         /* 调用字符串复制汇编函数 */
    return 0;
}

在 "strcopy.s" 中添加以下代码:

            AREA SCopy, CODE, READONLY
            EXPORT  strcopy
strcopy
            LDRB     r2,     [r1]    ; r1对应源字符串首地址,利用寄存器间接寻址读取字符
            ADDS     r1,     #1
            STRB     r2,     [r0]    ; r0对应目的字符串首地址,利用寄存器间接寻址保存字符
            ADDS     r0,     #1
            CMP      r2,     #0      ; 判断字符串是否结束
            BNE      strcopy         ; 循环执行字符复制,直到字符串结束
            BX       lr              ; 汇编子程序返回
            END

保存文件后, 进行编译 (Build).

What? 我编译失败了

  • 根据错误提示检查是否存在语法错误.
  • 尝试在 Optiins for Target -> Target 右侧菜单的 ARM Compiler 处选择 V5 版本的编译器.
  • 尝试在 Optiins for Target -> Linker 里取消勾选 Don't Search Standard Libraries.
  • 编译成功后, 运行仿真调试.

别慌, 一步一步来. 请一边单步运行, 一遍观察源代码窗口中程序的运行情况, 当程序进入 strcopy 程序后, 观察寄存器窗口中 r0 和 r1 的值, 此时的 r0 便是字符串拷贝的目的地址, r1 是字符串拷贝的源地址.

image.png
寄存器窗口
为了在接下来更直观地观察字符串拷贝的过程, 将当前 r0 的值拷贝到页面底部的 Memory 窗口中, 这时便可以看到该地址周围的所有数据.

image.png
Memory 窗口

内存映射, Debug 的利器
Memory 窗口的内存映射功能允许我们去查看任意地址处存储的数据, 这里的内存是抽象的内存, 因为 ARM 架构采用统一编址方式, 所有的存储设备以及外设空间共用同一个地址, 这意味着通过内存映射, Flash, ROM, 外设寄存器等所有的一切都能够尽收眼底. 它的作用不仅限于在线仿真, 在基于调试器的实际调试中同样能够发挥作用.

如果 Memory 窗口不小心被关闭, 可以从菜单栏 -> View -> Memory Windows 打开.

字符串是采用 ASCII 标准编码的, 所以需要右键 Memory 区域选择 Ascii, 这样数据就以字符串的形式呈现出来了.

image.png
以字符串解码 Memory 窗口内容
可以看到, 我们在 "main.c" 中定义的 dststr 出现在了字符串的目的地址处, 这时, 你可以单步运行, 观察 dststr 一步步被 srcstr 拷贝覆盖的过程, 也可以点击02-23直接运行, 观察字符串拷贝的最终结果.

image.png
最终运行结果

思考

启动文件中是怎样进行栈初始化的? 我们知道 RAM 的起始地址是 0x20000000, 在启动文件里我们分配了 0x400 个字节的栈空间, 然后将栈顶地址 __initial_sp 传给中断向量表的第一个表项, 也就是将初始的 SP 值存储在 0x00000000 地址处, 以便程序启动时将其读入 R13 中.

Stack_Size      EQU     0x00000400
                AREA    STACK, NOINIT, READWRITE, ALIGN=4
Stack_Mem       SPACE   Stack_Size
__initial_sp

                AREA    RESET, DATA, READONLY
                EXPORT  __Vectors
__Vectors       DCD     __initial_sp              ; Top of Stack

在本实验中, main 函数中定义的字符串作为局部变量被存放在栈里. 另外, 注意我们在 main 函数中 strcopy 函数的声明方式:

extern void strcopy(char *d, const char *s);

d 和 s 是两个 32 位地址参数, 所以根据 ATPCS 标准, 在调用该函数前, 程序会把事先把准备好的实参放在 r0 和 r1 中, 这是程序成功实现传参的关键. 注意 strcopy 程序中的最后一条指令 BX lr, 它能够指导该程序返回 main 函数, 这是因为在 main 函数中调用 strcopy 时, 会执行一条 BL 跳转指令, 跳转到 strcopy 的同时会把返回地址保存在 lr 中.

汇编程序调用c程序

接下来的实验中, 我们将进行一个 "c调用汇编, 汇编再调用c" 的两层嵌套, 实现一个简单的数学运算功能: i+2i+3i+4i. 将 "main.c" 的内容更改为:

#include <string.h>
#include <stdint.h>
#include <stdio.h>
extern void f(void);
int main()
{
    f();
    return 0;
}

/*a+b+c+d+e*/
int g(int a, int b, int c, int d)
{
    return a + b + c + d;
}

在 "./Task1/call" 中创建汇编文件 "f.s" 并添加以下内容:

   EXPORT f
        PRESERVE8
        AREA f,CODE,READONLY
        ENTRY                        
        IMPORT g                 ; 声明g为外部引用符号
        PUSH   {lr}
        MOVS   r0, #2            ; i=2
        ADDS   r1, r0, r0        ; (R1)=i*2
        ADDS   r2, r1, r0        ; (R2)=i*3
        ADDS   r3, r1, r1        ; (R3)=i*4
        BL     g                 ; 调用C函数g(),返回值在R0中
        POP    {pc}

保持启动文件不变, 确保更改后的 "main.c" 和 "f.s" 已经添加至 Target, 编译并仿真调试. 仍然单步运行, 观察程序一步步从 main 函数进入 f 程序, 在 f 程序中, 根据 ATPCS 标准, 我们要将 g 函数需要的四个参数一一准备好, 分别放在 r0-r3 中. 单步运行程序, 直到程序进入 g 函数之前, 我们会看到参数 2, 4, 6, 8 已经被存放到了 r0-r3 中:

image.png
在进入 g 函数之前, r0-r3 中已经备好了四个参数
继续单步运行, 直到运行到 g 函数返回之前, 可以看到计算结果 2+4+6+8=20=14H 被存放到了 r0 中, 这是因为根据 ATPCS 标准, 函数返回的第一个数据应该存放到 r0 中.

image.png
在 g 函数返回之前, 其运算结果已经被存放到了 r0 中
继续单步运行, 观察程序一步步从 g 函数返回至 f 程序, 最后返回至 main 函数中.

思考
  • 请思考, "f.s" 中的 PUSH 和 POP 指令有什么作用呢? 如果去掉这两条指令, 程序还能顺利返回到 main 函数中吗?
  • 其实, ARM 架构中的保护现场不仅保护了通用寄存器, 有时还需要保护 lr. 那么请思考, 究竟什么时候需要保护 lr, 什么时候不需要呢?

END

文章来源:

推荐内容

更多内容请关注微处理器系统结构与嵌入式系统设计专栏
推荐阅读
关注数
117
内容数
20
电子科技大学示范性微电子学院开设的「微处理器系统结构与嵌入式系统设计」课程配套实验,原链接:[链接]
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息