编译器设计 - 运行时环境

  • 简述

    作为源代码的程序只是文本(代码、语句等)的集合,要使其具有活力,它需要在目标机器上执行操作。程序需要内存资源来执行指令。程序包含过程名称、标识符等,这些名称需要在运行时与实际内存位置进行映射。
    运行时,我们指的是正在执行的程序。运行时环境是目标机器的一种状态,它可能包括软件库、环境变量等,为系统中运行的进程提供服务。
    运行时支持系统是一个包,主要由可执行程序本身生成,便于进程与运行时环境之间的进程通信。它在程序执行时负责内存分配和解除分配。
  • 激活树

    程序是组合成多个过程的指令序列。过程中的指令是按顺序执行的。一个过程有一个开始和一个结束分隔符,其中的所有内容都称为过程体。过程标识符和其中的有限指令序列构成了过程的主体。
    一个过程的执行称为它的激活。激活记录包含调用过程所需的所有必要信息。激活记录可能包含以下单元(取决于使用的源语言)。
    临时工 存储表达式的临时值和中间值。
    本地数据 存储被调用过程的本地数据。
    机器状态 在调用过程之前存储机器状态,例如寄存器、程序计数器等。
    控制链路 存储调用者过程的激活记录地址。
    访问链接 存储本地范围之外的数据信息。
    实际参数 存储实际参数,即用于将输入发送到被调用过程的参数。
    返回值 存储返回值。
    每当一个过程被执行时,它的激活记录就存储在栈中,也称为控制栈。当一个过程调用另一个过程时,调用者的执行将暂停,直到被调用过程完成执行。此时,被调用过程的激活记录存储在堆栈中。
    我们假设程序控制以顺序方式流动,当一个过程被调用时,它的控制被转移到被调用的过程。当一个被调用的过程被执行时,它将控制权返回给调用者。这种类型的控制流更容易以树的形式表示一系列激活,称为activation tree.
    为了理解这个概念,我们以一段代码为例:
    
    . . .
    printf(“Enter Your Name: “);
    scanf(“%s”, username);
    show_data(username);
    printf(“Press any key to continue…”);
    . . .
    int show_data(char *user)
       {
       printf(“Your name is %s”, username);
       return 0;
       }
    . . . 
    
    下面是给定代码的激活树。
    激活树
    现在我们明白了程序是以深度优先的方式执行的,因此堆栈分配是最适合程序激活的存储形式。
  • 存储分配

    运行时环境管理以下实体的运行时内存要求:
    • Code:它被称为程序的文本部分,在运行时不会更改。它的内存需求在编译时是已知的。
    • Procedures:它们的文本部分是静态的,但它们以随机方式调用。这就是为什么堆栈存储用于管理过程调用和激活的原因。
    • Variables:变量仅在运行时已知,除非它们是全局的或常量。堆内存分配方案用于管理运行时变量的内存分配和解除分配。
  • 静态分配

    在这种分配方案中,编译数据被绑定到内存中的一个固定位置,并且在程序执行时不会改变。由于预先知道内存要求和存储位置,因此不需要用于内存分配和解除分配的运行时支持包。
  • 堆栈分配

    过程调用及其激活通过堆栈内存分配进行管理。它采用后进先出 (LIFO) 方法,这种分配策略对于递归过程调用非常有用。
  • 堆分配

    仅在运行时分配和取消分配过程的局部变量。堆分配用于为变量动态分配内存,并在不再需要变量时将其收回。
    除了静态分配的内存区域外,堆栈和堆内存都可以动态和意外地增长和收缩。因此,它们无法在系统中提供固定数量的内存。
    堆分配
    如上图所示,代码的文本部分被分配了固定数量的内存。堆栈和堆内存被安排在分配给程序的总内存的极端位置。两者相互收缩和增长。
  • 参数传递

    过程之间的通信媒介称为参数传递。来自调用过程的变量值通过某种机制传递给被调用过程。在继续之前,首先要了解一些与程序中的值有关的基本术语。

    右值

    表达式的值称为它的 r 值。如果单个变量中包含的值出现在赋值运算符的右侧,则它也将成为 r 值。r 值总是可以分配给其他一些变量。

    左值

    存储表达式的内存位置(地址)称为该表达式的左值。它总是出现在赋值运算符的左侧。
    例如:
    
    day = 1;
    week = day * 7;
    month = 1;
    year = month * 12;
    
    从这个例子中,我们了解到像 1、7、12 这样的常量值,以及像日、周、月和年这样的变量,都有 r 值。只有变量具有左值,因为它们也代表分配给它们的内存位置。
    例如:
    
    7 = x + y;
    
    是一个左值错误,因为常数 7 不代表任何内存位置。
  • 形式参数

    接受调用者过程传递的信息的变量称为形参。这些变量在被调用函数的定义中声明。
  • 实际参数

    其值或地址被传递给被调用过程的变量称为实际参数。这些变量在函数调用中指定为参数。
    Example:
    
    fun_one()
    {
       int actual_parameter = 10;
       call fun_two(int actual_parameter);
    }
       fun_two(int formal_parameter)
    {
       print formal_parameter;
    }
    
    形式参数保存实际参数的信息,具体取决于所使用的参数传递技术。它可以是一个值或一个地址。
  • 按值传递

    在传值机制中,调用过程传递实际参数的右值,编译器将其放入被调用过程的激活记录中。然后形式参数保存调用过程传递的值。如果形参持有的值发生变化,应该对实参没有影响。
  • 通过引用传递

    在按引用传递机制中,实际参数的左值被复制到被调用过程的激活记录中。这样,被调用的过程现在有了实参的地址(内存位置),而形参指向同一个内存位置。因此,如果形参指向的值发生变化,应该看到对实参的影响,因为它们也应该指向相同的值。
  • 通过复制恢复

    这种参数传递机制的工作方式类似于“按引用传递”,不同之处在于对实际参数的更改是在被调用过程结束时进行的。在函数调用时,实际参数的值被复制到被调用过程的激活记录中。形式参数如果被操纵,对实际参数没有实时影响(因为传递了左值),但是当被调用的过程结束时,形式参数的左值被复制到实际参数的左值中。
    Example:
    
    int y; 
    calling_procedure() 
    {
       y = 10;     
       copy_restore(y); //l-value of y is passed
       printf y; //prints 99 
    }
    copy_restore(int x) 
    {     
       x = 99; // y still has value 10 (unaffected)
       y = 0; // y is now 0 
    }
    
    当这个函数结束时,形参 x 的左值被复制到实参 y 中。即使 y 的值在过程结束之前更改,x 的左值也会被复制到 y 的左值,使其表现得像引用调用一样。
  • 按名称传递

    像 Algol 这样的语言提供了一种新的参数传递机制,其工作方式类似于 C 语言中的预处理器。在按名称传递机制中,被调用的过程的名称被它的实际主体替换。Pass-by-name 以文本方式将过程调用中的参数表达式替换为过程主体中的相应参数,以便它现在可以处理实际参数,就像通过引用传递一样。