L03 Compiling Code, Procedures, and Stacks


MIT 6.004 Spring 2019 L03 Compiling Code, Procedures, and Stacks,由Silvina Hanono Wachman讲述。

本次讲座继续深入讨论了如何将高级编程语言转换成汇编语言,并开始探索如何处理程序(Procedures)。

主要内容

  • 指令集回顾

    到目前为止,学习了几种RISC-V指令集架构中的指令类型:

    1. 计算指令(ALU操作),包括寄存器-寄存器操作和寄存器-立即数操作
    2. 控制流指令,包括无条件跳转和条件分支
    3. 加载和存储指令,用于在内存和寄存器之间传输信息
    4. 新引入了加载上半部分立即数(Load Upper Immediate)指令,以解决常量大小的限制问题。
  • C语言到汇编语言转换

    详细讲解了将C语言代码转换成汇编语言的步骤,包括变量到寄存器的分配、将复杂操作分解为简单操作等。

  • 编译条件和循环语句

    提供了方法论,解释了如何将if-then、if-then-else和while循环等高级语言构造转换为汇编语言。

  • 程序(Procedures)

    讨论了程序实现的关键要素,包括入口点、参数传递、局部存储和退出点。特别强调了使用栈和激活记录来管理程序调用所需的信息,以及如何处理嵌套程序调用。

  • 调用约定

    引入了调用约定,详细规定了哪些寄存器由调用者保存,哪些由被调用者保存。定义了专门的寄存器用于参数传递和返回值。

  • 程序调用和返回示例

    通过计算最大公约数(GCD)的程序示例,展示了如何实现程序的调用和返回,以及如何通过跳转和链接指令(Jump and Link)来保存和恢复程序执行的上下文。


分页知识点

RISC-V回顾

  • 计算指令由算术逻辑单元(ALU)执行。
    • 寄存器-寄存器指令格式为 op dest, src1, src2,其中 op 是操作码,dest 是目标寄存器,src1src2 是源寄存器。
    • 寄存器-立即数指令格式为 op dest, src1, const,其中 const 是立即数常量。
  • 控制流指令分为无条件跳转(jaljalr)和有条件比较跳转(comp src1, src2, label)。
  • 加载和存储指令包括 lw dest, offset(base)sw src, offset(base),这里的 base 是基址寄存器,offset 是一个小的常数。
  • 伪指令是其他指令的简写形式。

处理常量

  • 执行 a = b + 3。假设 a 在寄存器 x1 中,bx2 中。对于12位的小常数,可以通过寄存器-立即数ALU操作来处理,例如 addi x1, x2, 3
  • 执行 a = b + 0x123456。最大的12位2进制补码常数是 20470x7FF)。使用 li 伪指令来将寄存器设置为较大的常数,例如通过两条指令 lui x4, 0x123addi x4, x4, 0x456 来生成 0x123456lui 指令将 0x123 放置到寄存器 x4 的高位,而 addi 则将 0x456 加到 x4 中。
  • li 伪指令也可以用来处理小常数。

编译简单表达式

  • 将变量分配给寄存器。

  • 将运算符翻译为计算指令。

  • 使用寄存器-立即数指令来处理含有小常数的操作,如 addi

  • 使用 li 伪指令来处理大常数。

  • 例如,C代码

    int x, y, z; 
    y = (x + 3) | (y + 123456); z = (x * 4) ^ y;
    

    对应的RISC-V汇编代码为:

    // x: x10, y: x11, z: x12
    // x13, x14 used for temporaries
    addi x13, x10, 3
    li x14, 123456
    add x14, x11, x14
    or x11, x13, x14
    slli x13, x10, 2
    xor x12, x13, x11
    
    • 这段汇编代码首先将 x 加 3 然后存储在 x13 中,将 y 加上 123456 并与 x13 进行或操作后存储回 y(即 x11)。接着,它将 x 左移2位(相当于乘以4)存储在 x13 中,然后将结果与 y 进行异或操作后存储到 z(即 x12)。

image-20240424115023898

编译条件语句

if语句

  • 在C语言中的if语句可以通过使用分支指令编译到RISC-V汇编语言中。

  • C语言示例和对应的RISC-V汇编示例如下:

    if (expr) {
        if-body
    }
    

    编译为:

    // 将表达式expr编译到xN寄存器中
    beqz xN, endif
    // 编译if-body部分
    endif:
    
    • 这里beqz是分支指令,它检查寄存器xN中的值是否为0,如果为0,则跳转到endif标签。
    • endif是一个标签,用于在编译if-body之后标记代码的结束位置。

if-else语句

  • if/else语句的编译与if语句类似,但需要处理两种情况(if-bodyelse-body)。

  • C语言示例和对应的RISC-V汇编示例如下:

    if (expr) {
        if-body
    } else {
        else-body
    }
    

    编译为:

    // 将表达式expr编译到xN寄存器中
    beqz xN, else
    // 编译if-body部分
    j endif
    else:
    // 编译else-body部分
    endif:
    
    • 如果表达式expr计算结果为假(即xN为0),程序会跳转到else标签,执行else-body部分;否则执行if-body部分后跳过else-body

示例if编译

int x, y;  // x: x10, y: x11

...

if (x < y) {
    y = y - x;
}

解释: 这是一个简单的C代码示例,声明了两个整型变量xy,然后在一个if语句中检查x是否小于y,如果是,则从y中减去x的值。

接下来,我们将看到如何将这段C代码转换成汇编代码:

左侧的汇编代码:

slt x12, x10, x11   // 将x和y比较,如果x < y,则x12 = 1,否则x12 = 0
beqz x12, endif     // 如果x12 = 0(x不小于y),则跳转到endif
sub x11, x11, x10   // x小于y,计算y - x,并将结果存回x11
endif:              // if语句的结束标记

解释:

  • slt x12, x10, x11:设置条件寄存器x12的值,如果x10(代表x)小于x11(代表y),则寄存器x12将被置为1,否则为0。
  • beqz x12, endif:条件跳转指令,如果x12的值为0(意味着x不小于y),则跳转到endif标签处,跳过减法操作。
  • sub x11, x11, x10:如果x小于y,则执行这条减法指令,将x的值从y中减去,并将结果存回到x11中。

右侧的汇编代码:

bge x10, x11, endif  // 如果x >= y,则跳转到endif
sub x11, x11, x10    // x小于y,计算y - x,并将结果存回x11
endif:               // if语句的结束标记

解释:

  • bge x10, x11, endif:是一个分支指令,它直接检查x是否不小于y,如果这个条件为真,则跳转到endif标签。
  • sub x11, x11, x10:和左边的代码一样,这行执行减法操作。

在右侧的汇编代码中,我们可以看到一个优化的写法,直接使用bge(分支如果大于等于)指令来判断和跳转,减少了一条指令的使用,这显示了在汇编语言级别的优化技巧。

while循环

  • while循环可以使用反向分支来编译。

  • C语言示例和对应的RISC-V汇编示例如下:

    while (expr) {
        while-body
    }
    

    编译为:

    while:
    // 将表达式expr编译到xN寄存器中
    beqz xN, endwhile
    // 编译while-body部分
    j while
    endwhile:
    
    • 在此示例中,beqz指令检查xN是否为0,如果为0则跳转到endwhile标签,表示循环结束。如果不为0,则继续执行while-body部分,然后跳回到while标签处,重新评估循环条件。

整合所有内容

综合示例

  • 上述的循环和条件语句可以结合起来编译更复杂的结构。

  • C语言示例和对应的RISC-V汇编示例如下:

    while (x != y) {
        if (x > y) {
            x = x - y;
        } else {
            y = y - x;
        }
    }
    

    编译为:

    // x: x10, y: x11
    j compare
    loop:
    // 编译while-body部分
    compare:
    bne x10, x11, loop
    
    • 在这个例子中,bne指令用来比较x10x11,如果它们不相等(即x != y),则跳回loop标签继续执行循环体。

下面部分展示了如何将一个包含while循环和if-else条件语句的复杂C语言代码块转换为RISC-V汇编代码。

C代码示例:

while (x != y) {
    if (x > y) {
        x = x - y;
    } else {
        y = y - x;
    }
}

对应的RISC-V汇编代码:

// x: x10, y: x11
j compare
loop:
    ble x10, x11, else
    sub x10, x10, x11
    j endif
else:
    sub x11, x11, x10
endif:
compare:
    bne x10, x11, loop

这段汇编代码首先跳转到compare标签来检查循环条件。如果x <= yble指令),则跳转到else分支。在if分支中,x减去y的结果存回xsub x10, x10, x11),否则else分支中y减去x的结果存回ysub x11, x11, x10)。结束ifelse分支后,跳回到compare以重新检查循环条件。

过程(Procedures)

过程定义:

  • 过程(Procedures)(也称为函数或子程序)是执行特定任务的可重用代码片段,具有单一的命名入口点和零个或多个形式参数。当过程执行完毕后,它会返回到调用者。
  • 使用过程可以实现抽象和重用,允许从简单过程的集合中组成大型程序。

C代码示例:

int gcd(int a, int b) {
    int x = a;
    int y = b;
    while (x != y) {
        if (x > y) {
            x = x - y;
        } else {
            y = y - x;
        }
    }
    return x;
}

对应的RISC-V汇编代码:

// x: x10, y: x11
j compare
loop:
    ble x10, x11, else
    sub x10, x10, x11
    j endif
else:
    sub x11, x11, x10
endif:
compare:
    bne x10, x11, loop

这段代码定义了一个计算两个整数最大公约数(GCD)的函数。函数体的汇编代码与前面的循环几乎相同,因为核心逻辑是类似的。函数最终返回x,在汇编中通常使用a0(或x10)寄存器来返回值。

管理过程的寄存器空间

当一个过程(调用者)调用另一个过程(被调用者)时,它们共享同一套寄存器集。为了避免复杂和稀缺的寄存器管理,调用者和被调用者之间应该有明确的约定:

  1. 调用者保存(Caller-Saved)和被调用者保存(Callee-Saved)
    • 共享寄存器集的划分非常复杂,并且寄存器本身也非常稀缺。
    • 为了简化寄存器管理,调用者或被调用者应该将需要的寄存器保存在内存中,并在过程调用完成后恢复这些寄存器的值。

通过这种方式,RISC-V汇编确保每个过程可以在不干扰其他过程的情况下运行。

实现过程

  1. 传递参数和获取结果
    • 调用者通过寄存器向被调用者传递参数,并从被调用者获取结果。
    • 一个过程可以从多个不同位置被调用,因此需要有一致的方法来管理参数和结果。
  2. 无条件跳转和返回地址
    • 调用者通过执行无条件跳转指令(如 jal ra, label)到达被调用者的过程代码。
    • 为了返回到正确的位置,被调用者需要知道返回地址,并将其存储在 ra 寄存器中。

过程链接与控制权传递

使用 jal ra, label 指令可以实现控制权在调用者和被调用者之间的传递:

  1. 存储返回地址:在返回地址寄存器 ra 中存储当前指令后面第四个字节的地址。
  2. 跳转到标签:跳转到标签 label 处的指令位置执行过程代码。
  3. 返回调用者:在执行完过程后,使用 jr ra 指令跳转回 ra 寄存器中的地址,继续执行调用者的代码。

image-20240424115810587

过程调用的复杂性

当一个过程A调用了过程B,过程B又调用了过程C时,会出现如下复杂性:

  • 单一的返回地址寄存器 ra 不足以保存所有返回地址。
  • 过程C的返回地址将覆盖过程A的返回地址。
  • 存储过程A寄存器的内存空间必须与存储过程B寄存器的空间不同。

过程的存储需求

每次过程调用的基本要求包括:

  1. 输入参数、返回地址和结果
    • 输入参数:通过寄存器传递。
    • 返回地址:通过 ra 寄存器存储。
    • 结果:通过寄存器返回给调用者。
  2. 局部存储需求
    • 需要保存编译器无法放在寄存器中的变量。
    • 需要保存我们将要覆写的调用者寄存器值的空间。

每次过程调用都会有自己所有这些数据的实例,这被称为过程的激活记录(activation record)。

栈的洞察

  • 栈是一种数据结构,用于存储激活记录(activation records)。
  • 激活记录按照后进先出(LIFO)的顺序被分配和释放。
  • 栈的操作包括push(压入)、pop(弹出)以及访问栈顶元素。
  • 我们只需要访问当前正在执行的过程的激活记录。

image-20240424115922189

RISC-V栈

  • 栈位于内存中,需要一个寄存器来指向它。在RISC-V中,栈指针sp是寄存器x2
  • 栈的增长方向是向下的,即从高地址向低地址增长。
    • 执行push操作会减小栈指针sp的值。
    • 执行pop操作会增加栈指针sp的值。
  • sp寄存器指向栈顶,即最后一个压入栈的元素。
  • 使用栈的规则是:可以在任何时候使用栈,但在使用完毕后要恢复到原始状态。

image-20240424115948914

使用栈

  • 示例的入栈序列:

    addi sp, sp, -N
    sw ra, 0(sp)
    sw a0, 4(sp)
    
  • 对应的出栈序列:

    lw ra, 0(sp)
    lw a0, 4(sp)
    addi sp, sp, N
    

    这些操作分别表示在进入和退出过程时保存和恢复寄存器,以及分配和释放栈空间。

    入栈序列好像有些混淆?先sw a0, 4(sp)才更符合LIFO吧,又或者属于同一次的入栈不用在意顺序。

调用约定

  • 调用约定指定了过程间寄存器使用的规则。

  • RISC-V调用约定为寄存器x0-x31提供了符号名称以指明它们的作用:

    • a0a7 (x10x17):用于函数参数。
    • a0a1 (x10x11):用于函数返回值。
    • ra (x1):用于返回地址。
    • t0t6 (x5-x7, x28-x31):用作临时寄存器。
    • s0s11 (x8-x9, x18-x27):被保存的寄存器。
    • sp (x2):栈指针。
    • gp (x3):全局指针。
    • tp (x4):线程指针。
    • zero (x0):硬连线零。

每个寄存器的保存责任分为调用者(Caller)和被调用者(Callee),这决定了在过程调用过程中哪些寄存器需要被保存和恢复。

通过这样的约定,不同的过程可以预知哪些寄存器在调用后会被保留其值,哪些寄存器可能会被修改,从而在编写代码时可以做出适当的寄存器使用决策。

image-20240424120025656

调用者保存与被调用者保存寄存器

  • 调用者保存寄存器:在函数调用时不保留。如果调用者希望保持其值不变,它必须在将控制权交给被调用者之前将其保存在栈上。通常,这包括参数寄存器(aN),返回地址(ra),和临时寄存器(tN)。
  • 被调用者保存寄存器:在函数调用时必须保留。如果被调用者希望使用这些寄存器,它必须在使用前将其原始值保存在栈上,并在返回控制权给调用者之前恢复它们。这通常包括已保存的寄存器(sN)和栈指针(sp)。

使用被调用者保存寄存器 示例

在这个例子中,函数f用到了两个被调用者保存寄存器s0s1来存储临时值。在函数调用的开始,这些寄存器的值被保存到了栈上。函数完成后,这些值从栈上恢复,以保证这些寄存器的值在函数调用前后保持不变。

实现函数f,使用s0s1来存储临时值。

f:
  addi sp, sp, -8   // 在栈上分配两个字(8字节)
  sw s0, 4(sp)      // 保存s0
  sw s1, 0(sp)      // 保存s1
  addi s0, a0, 3    // 将参数x加3,存入s0
  li s1, 123456     // 将常量123456加载到s1
  add s1, a1, s1    // 将参数y加上123456,存入s1
  or a0, s0, s1     // 对s0和s1进行逻辑或操作,结果存入a0
  lw s1, 0(sp)      // 恢复s1
  lw s0, 4(sp)      // 恢复s0
  addi sp, sp, 8    // 释放栈上的两个字
  ret               // 返回

这段代码是一个函数f的汇编实现,它接受两个整数参数xy,然后返回(x + 3) | (y + 123456)的结果。

解释:

  • addi sp, sp, -8:减小栈指针sp,在栈上为局部变量分配空间。
  • sw s0, 4(sp)sw s1, 0(sp):将寄存器s0s1的当前值保存在栈上。这是因为s0s1是被调用者保存(callee-saved)寄存器,函数负责保留和恢复它们的值。
  • addi s0, a0, 3:将输入参数x(存在a0)加3,结果存储在s0
  • li s1, 123456:将常数123456加载到寄存器s1
  • add s1, a1, s1:将输入参数y(存在a1)加上123456,结果存储在s1
  • or a0, s0, s1:执行逻辑或操作,将s0s1的结果合并,存储在返回值寄存器a0
  • lw s1, 0(sp)lw s0, 4(sp):从栈上恢复寄存器s0s1的值。
  • addi sp, sp, 8:调整栈指针sp,释放之前分配的空间。
  • ret:返回到调用函数的位置。

这里要注意,被调用者保存寄存器的使用是函数调用约定的一部分,这些约定规定了哪些寄存器的值在函数调用前后应当保持不变。当函数调用另一个函数时,它可能需要使用某些寄存器来存储临时值,但如果这些寄存器在被调用者的函数调用约定中需要保持值不变,那么它们就必须在函数开始时保存,结束前恢复原值。

image-20240424120400809

使用调用者保存的寄存器 示例

调用者(Caller)

int x = 1;
int y = 2;
int z = sum(x, y);
int w = sum(z, y);
li a0, 1        // 将1加载到寄存器a0,即设置x=1
li a1, 2        // 将2加载到寄存器a1,即设置y=2
addi sp, sp, -8 // 在栈上分配空间
sw ra, 0(sp)    // 保存返回地址ra到栈上
sw a1, 4(sp)    // 保存y的值(在a1寄存器)到栈上 // 保存y
jal ra, sum     // 跳转到sum函数,并设置返回地址为ra
// a0 = sum(x, y)
lw a1, 4(sp)    // 从栈上恢复y的值到a1 // 恢复y
jal ra, sum     // 再次调用sum函数
// a0 = sum(z, y)
lw ra, 0(sp)    // 从栈上恢复返回地址ra
addi sp, sp, 8  // 释放栈空间

调用者代码示例中,xy被赋值后用于调用函数sum。在调用函数前,调用者负责保存那些在函数调用后可能需要重新使用的值,这包括返回地址和y的值,因为在sum函数调用结束后会再次使用到y

被调用者(Callee)

int sum(int a, int b) {
    return a + b;
}
sum:
  add a0, a0, a1 // 将寄存器a0和a1的值相加,结果存回a0
  ret            // 返回调用者

被调用者代码示例是函数sum的实现,它简单地将两个参数相加并返回结果。

解释: 在汇编语言中,li指令用于将立即数加载到寄存器中,addi用于执行加法操作,sw用于将寄存器的值存储到栈上,lw用于从栈上加载值到寄存器中,jal是跳转并链接指令,用于函数调用,它同时设置返回地址ra。调用者保存的寄存器(如raa1)在函数调用前后由调用者自己管理。

为什么我们需要保存并恢复a1? 因为在调用sum函数的过程中,a1寄存器中的值可能会被改变,而调用者在之后的操作中还需要使用到原来a1中的值。在这个例子中,即便sum函数本身并没有修改a1的值,调用者并不知道sum的实现细节,所以它必须假设a1可能会被修改,遵循调用约定来保证程序的正确性。

注意:在一些架构或调用约定中,某些寄存器(如a0-a3)是被设计为在函数调用时用于传递参数的,它们的值在函数调用结束后不保证保持不变,这就是为什么调用者在调用其他函数之前需要保存这些寄存器的值。这种设计可以使得函数调用更高效,因为不需要所有的寄存器在每次函数调用时都被保存和恢复。

在这个例子中,栈被用作保存和恢复寄存器值的临时存储,这是管理寄存器和保证调用者和被调用者之间约定的常见方式。这种机制是理解和编写底层代码,特别是涉及函数调用和寄存器管理时的重要概念。

image-20240424120757756


补充

这里是一些关于在RISC-V架构中进行函数调用和栈操作的关键知识点总结:

  1. 调用约定:在RISC-V及许多其他体系结构中,调用约定定义了函数参数、返回值、和临时变量应如何在寄存器和栈之间传递。对于RISC-V,默认的调用约定规定了a0a7用于前八个整数函数参数,a0a1用于前两个返回值。
  2. 寄存器使用:RISC-V的调用约定还规定了哪些寄存器需要在函数调用前保存(caller-saved)和哪些寄存器需要在函数返回前保存(callee-saved)。
  3. 栈的后进先出(LIFO)原则:在函数调用时,最后压入栈的数据最先被弹出。保存寄存器值时,栈指针(sp)下移以分配空间,然后寄存器值依序压入。恢复寄存器值时,栈中的数据依序弹出,并且栈指针上移以释放空间。
  4. 寄存器的保存与恢复:通常,ra(返回地址寄存器)和函数参数(如果后续函数调用会更改它们)需要被保存在栈上。如果一个函数调用另一个函数,ra需要首先保存,以确保能够返回到正确的位置。
  5. 寄存器保存的必要性:如果一个函数(比如multiply)不会调用任何会更改a0a1的函数,则在调用该函数之前不需要在栈上为这些寄存器分配空间。
  6. 栈指针的管理:函数调用前后,栈指针的位置应该保持一致。这意味着分配给栈帧的空间在函数返回前必须完全释放。

在嵌套函数调用的情况下,每一层函数都必须确保它调用的任何函数不会意外地改变它依赖的寄存器值。如果存在这种可能性,函数必须在调用之前将这些寄存器值保存在栈上,并在调用结束后从栈上恢复它们。

函数调用时保存寄存器的必须遵循LIFO原则的情况

  1. 当一个函数可能再调用另一个函数(即有嵌套函数调用)时,必须保存当前函数的返回地址ra。在这种情况下,LIFO原则非常重要,因为每个函数都需要在完成后能够返回到正确的位置。
  2. 如果sum函数或任何被调用的函数内部可能会导致ra寄存器被修改(比如通过另一个函数调用),那么在恢复寄存器时就必须严格遵守LIFO原则,首先恢复最后保存的寄存器,这通常是ra

在这种情况下,如果不遵守LIFO原则,可能会导致返回到错误的地址,从而导致程序运行错误或崩溃。

在没有嵌套调用的情况下,为何可以灵活恢复寄存器

  1. sum函数调用完成后,ra没有被更改,因此可以安全地先恢复其他寄存器,例如a1。这是因为ra的值在栈上保持不变,没有被sum函数或其他任何操作修改。
  2. sp(栈指针)保持不变,因此恢复寄存器的顺序可以灵活安排。只要所有必须的寄存器最终都被正确恢复,并且栈指针也被正确调整到函数调用前的状态,程序的执行流就不会受到影响。

总结来说,是否需要遵守严格的LIFO原则取决于函数调用的上下文。如果存在修改ra的操作,那么必须遵守LIFO。如果保证ra在整个调用期间不会被修改,恢复寄存器的顺序就可以有所灵活性。在编写汇编代码时,通常建议遵循一致的模式,以减少混淆和潜在的错误。


补充2

激活帧

激活帧(Activation Frame),也称为栈帧(Stack Frame),是在栈上为每次函数调用所分配的内存块。它包含了函数执行所需的所有信息,如:

  • 局部变量:函数内声明的变量。
  • 参数:传递给函数的参数。
  • 返回地址:函数执行完毕后应返回到的代码位置。
  • 保存的寄存器:调用前需要保存的寄存器,以便函数执行后能够恢复。

栈帧

栈帧是栈上分配给单个函数调用的内存段。每次函数调用时,一个新的栈帧会被推入栈顶,每次函数返回时,其对应的栈帧会被弹出。栈帧的结构通常由编译器自动管理,确保了函数可以正确地访问它的变量和参数,以及正确返回到调用位置。

栈指针(sp)

栈指针是一个特殊的寄存器,用来指向栈顶当前的位置。在RISC-V架构中,这个寄存器通常是sp(x2)。栈指针在函数调用时移动,以分配新的栈帧,并在函数返回时恢复,以释放栈帧所占用的空间。

栈从高地址向低地址增长

大多数现代架构的栈都是“向下增长”的,意味着栈顶的地址会随着数据的推入而减小。例如,如果栈指针sp的当前地址是0x8000,在一个新的栈帧被推入栈后,sp可能会变为0x7FFC。这样,新的数据会放在之前数据的下方(即地址更小的方向)。这种设计通常是出于安全和效率的考虑。


裸机编程的针对补充:链接脚本与内存布局

在学习了RISC-V的栈使用、调用约定以及寄存器保存规则之后,我们现在暂时转向程序的物理内存布局和链接脚本——这些内容是编译和执行过程中不可或缺的一部分。

链接脚本扮演了一个至关重要的角色,它负责为编译后的代码片段指定物理地址。这包括确定代码段(.text)、初始化的全局变量段(.data)以及未初始化的全局变量段(.bss)的确切位置和大小。

链接脚本的重要性

当编译器处理源代码时,它生成一系列指令和数据。这些输出通常是段(segment)的形式,段是可执行文件或二进制对象中具有特定角色的代码和数据集合。但是,这些段还没有具体的物理地址。它们需要被加载到处理器可以访问的物理内存地址中。链接脚本就是定义这些映射的地方。

链接脚本指定哪些段应该被放置在内存的哪个位置。对于嵌入式系统来说,这一点尤其重要,因为它们可能有着非常特定的内存布局要求。例如,只读内存(ROM)可能包含初始化代码,而随机访问内存(RAM)用于运行时数据。链接脚本确保了每个段都位于正确的内存类型中。

内存地址空间配置

内存地址空间配置要求开发者必须了解硬件架构中不同类型内存的物理地址和特性。这涉及到诸如内存的读/写权限、是否易失(如RAM)或非易失(如ROM)等特性。在设计链接脚本时,开发者必须确保代码和数据被放置在适当的内存区域内。

例如,处理器在启动时通常会从ROM中读取代码,因为ROM在断电后内容不会丢失。这意味着,启动代码需要被放置在ROM的特定区域内。而全局变量,尤其是可写的数据,应该位于RAM中,以便在程序运行时可以修改它们。

应用在L3章节

本节课已经了解了过程调用的存储需求,包括调用约定和栈的使用。链接脚本与内存布局的知识将帮助我们理解这些存储需求是如何映射到物理内存上的,从而让我们更全面地理解整个程序的编译、链接和加载过程。理解链接脚本,以及可能的如何调整链接脚本来满足特定硬件平台的要求。这样的练习和知识对于希望在嵌入式系统开发领域或操作系统开发领域建立职业生涯的学习者来说,是非常有价值的。


Table of contents
  1. L03 Compiling Code, Procedures, and Stacks
    1. 主要内容
  2. 分页知识点
    1. RISC-V回顾
    2. 处理常量
    3. 编译简单表达式
    4. 编译条件语句
    5. 整合所有内容
    6. 过程(Procedures)
    7. 管理过程的寄存器空间
      1. 实现过程
    8. 过程链接与控制权传递
    9. 过程调用的复杂性
    10. 过程的存储需求
    11. 栈的洞察
    12. RISC-V栈
    13. 使用栈
    14. 调用约定
    15. 调用者保存与被调用者保存寄存器
    16. 使用被调用者保存寄存器 示例
      1. 使用调用者保存的寄存器 示例
        1. 调用者(Caller)
        2. 被调用者(Callee)
    17. 补充
    18. 函数调用时保存寄存器的必须遵循LIFO原则的情况
    19. 在没有嵌套调用的情况下,为何可以灵活恢复寄存器
    20. 补充2
    21. 激活帧
    22. 栈帧
    23. 栈指针(sp)
    24. 栈从高地址向低地址增长
    25. 裸机编程的针对补充:链接脚本与内存布局
      1. 链接脚本的重要性
      2. 内存地址空间配置
      3. 应用在L3章节