欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

系统级程序设计笔记(unit2——程序的机器级表示)

程序员文章站 2022-06-11 20:15:34
...

这个专题的所有学习笔记来自于对武汉大学计算机学院软件工程专业大三上学期的专业必修课《系统级程序设计》的学习(教材为深入理解计算机系统CSAPP),涉及的编程语言全部为C语言和C++语言。

该博客为第2单元的学习笔记,这一单元的主要内容是堆栈原理的介绍、指针和数组、变量和地址等,部分内容来自《深入理解计算机系统》的第三章的部分内容,部分内容来自《C专家编程》。对应ssd6课程的lecture3。


C语言程序执行中的奇迹(The Wonder of Program Execution)

(1)观察下面的代码

#define ARRAY_SIZE 10

void natural_numbers (void) {
  int i;
  int array[ARRAY_SIZE];

  i = 1;

  while (i <= ARRAY_SIZE) {
       array[i] = i - 1;
       i = i + 1;
  }
}

该代码会陷入一种死循环的状态,这是因为在内存中,局部变量的位置。数组a[9]结束后,是变量i,因此,对a[10]进行操作时,实际上是对变量i进行操作,所以变量i的值被修改为9,再次满足进入循环的条件。i的值一直在9和10之间摆动,while条件总成立,造成了死循环。

内存 具体值
a[0]
a[1] 0
a[2] 1
a[3] 2
a[4] 3
a[5] 4
a[6] 5
a[7] 6
a[8] 7
a[9] 8
i(a[10]) 9

(2)C语言中的抽象层次
C编程模型本身就是抽象的,体现在以下的方面:
使用变量名,而不是直接通过地址值进行访问变量。
使用array[i]的形式访问数组元素,而不需要自己计算元素的地址。
使用c=a+b,这样的代码实现加法,而不需要直接向CPU下达命令编译器负责将C程序翻译成机器代码。

我们考虑变量和数据类型,而不是内存芯片。
我们考虑实现的算法,而不是在这些芯片之间移动数据。
我们考虑程序语句,而不是存储这些语句的位置和方式。
我们不太考虑执行过程,因为没有必要 。

计算机系统使用了多种不同形式的抽象,利用更简单的抽象模型来隐藏实现的细节。对于机器级编程来说,两种抽象很重要,第一种是由指令集体系结构(Instruction Set Architecture,ISA)来定义机器级程序的格式和行为,第二种抽象是机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组。

(3)对以下代码的解释:

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

#define MAXLINE_LENGTH 80

char Buffer[MAXLINE_LENGTH];

char * readString(void)
{
    int nextInChar;
    int nextLocation;

    printf("Input> ");
    nextLocation = 0;
    while ((nextInChar = getchar()) != '\n' &&
           nextInChar != EOF) {
        Buffer[nextLocation++] = nextInChar;
    }
    return Buffer;
}

int main(int argc, char * argv[])
{
  char * newString;

  do {
      newString = readString();
      printf("%s\n", newString);
  } while (strncmp(newString, "exit", 4));
  return 0;
}

这段代码并不能达到预期的效果,这是因为Buffer变量在这里是全局的。在接连读取的情况下。第二次读取并不会清空第一次读取中的数据。如果第二次读取的数据比第一次短,那么第一次比第二次多的那部分仍然会留在Buffer之中。例如

Input>Hello
Hello
Input>Hello world
Hello world
Input>Hello
Hello world

可以看到第三次输入的是hello,但是依然保留了第二次输出的world

变量和地址

(1)程序计数器(Program Counter)指示要执行的下一个指令的内存中的地址。
整数寄存器文件包含16个命名的位置,分别存储64位的值。这些寄存器可以存储地址(对应于C语言的指针)或整数数据。有些寄存器被用来记录某些重要的程序状态,而其他的寄存器用来保存临时数据,例如过程的参数和局部变量,以及函数的返回值。
条件码寄存器保存着最近执行的算术或逻辑指令的状态信息。它们用来实现控制或数据流中的条件变化,比如if和while语句
一组向量寄存器可以存放一个或多个整数或浮点数值。

(2)在硬件中,所有的数据都存储在内存中。内存是一个从0开始的字节序列。CPU执行的机器代码在内存位置上运行,仅由它们的地址标识。编译器负责将我们的程序在变量上执行的操作转换为在地址上执行的操作,但无论是变量名称还是变量的类型都不存在于此转化中。
在大多数情况下,程序员不关心处理变量的地址。
&运算符返回存储变量或表达式的地址。而*操作符做相反的操作,它返回存储在它的地址中的值。这就引出了一个棘手的问题:C程序员可以使两个变量名指向内存中相同的位置,而硬件无法区分。上述问题在源代码中表现并不明显。
(3)
系统级程序设计笔记(unit2——程序的机器级表示)
这幅图我们在unit1中已经见过了,现在详细解释一下:
从低地址向上:
程序代码和数据:对所有的进程来说,代码是从一个固定的地址开始的,紧接着是和C全局变量对应的数据位置。代码和数据区是直接按照可执行目标文件的内容初始化的。
。代码和数据区后紧随着的是运行时堆。代码和数据区在进程一开始运行时就被指定了大小,与此不同,调用像malloc和free这样的C标准库函数时,堆可以在运行时动态地扩展和收缩。
共享库。大约在地址空间的中间部分是一块用来存放像C标准库和数学库这样的共享库的代码和数据的区域。
。位于用户虚拟地址空间顶部的是用户栈,编译器用它来实现函数调用。和堆一样,用户栈在程序执行期间可以动态地扩展和收缩。调用函数时栈增长,从一个函数返回时栈收缩。
内核虚拟内存。地址空间顶部是为内核保留的。不允许应用程序读写这个区域的内容或者直接调用内核代码定义的函数。
地址分为三组:所有局部变量都聚集在一起。在全局变量中,声明时初始化的变量在一个集群中,而未初始化的则在另一个集群中。

我们看到所有的数据都有一个地址,而地址只是一个整数。由于内存能够存储整数,我们不仅可以将数据存储在内存中,还可以将数据地址存储在内存中。在后一种情况下,我们存储地址的位置本身就有一个地址。C允许我们给出这些存储地址名称的位置,也就是说,C允许我们声明保存数据地址的变量,而不是直接保存数据。您可能已经熟悉这种变量类型:它们通常称为指针或引用。

可执行程序包括BSS段、数据段、代码段(也称文本段)。
BSS(Block Started by Symbol)通常是指用来存放程序中未初始化的全局变量和静态变量的一块内存区域。特点是:可读写的,在程序执行之前BSS段会自动清0。所以,未初始的全局变量在程序执行之前已经成0了。
注意和数据段的区别,BSS段存放的是未初始化的全局变量和静态变量,数据段存放的是初始化后的全局变量和静态变量。

数组和指针

(1)带下标的数组和指针引用之间的等效性:

subscript call reference memory location
[0] a[0] M
[1] a[1] M+1
[2] a[2] M+2
[3] a[3] M+3
[4] a[4] M+4
[5] a[5] M+5

下面表格中每一行的两者都是等效的。

在数组中取值/取地址 利用指针取值/取地址
A[i] *(A + i)
&A[i] A+ i
A[i + j] *(A + i + j)
&A[i + j] A+ i + j

(2)需要注意的是,虽然一个数组可以用指针引用来进行操作,但是我们也不能认为任何一个指针也可以被想象成为数组,观察下面这个例子。

#include <stdio.h>
void Initialize (char * a, char * b)
{
        a[0] = 'T'; a[1] = 'h'; a[2] = 'i';
        a[3] = 's'; a[4] = ' '; a[5] = 'i';
        a[6] = 's'; a[7] = ' '; a[8] = 'A';
        a[9] = '\0';
        b = a;
        b[8] = 'B';
}
#define ARRAY_SIZE 10
char a[ARRAY_SIZE];
char b[ARRAY_SIZE];
int main(int argc, char * argv[])
{
        Initialize(a, b);
        printf("%s\n%s\n", a, b);
        return 0;
}

输出结果

This is B

我们在前一行中指定了b=a。然而,与数组中的内容不一样的是,两个数组在赋值b=a之后具有相同的地址。修改一个也会修改另一个。赋值B(8)=“B”将修改包含“8”的内存位,并且原始B的内容将保持不变。这两个原始数组仍然被打印,但第一个数组现在包含“This is b”,而第二个数组将是空的,因为它从未被触摸过。
这个例子说明了我们说“C语言没有真正的数组”这句话的含义。具有真正数组支持的语言会把b = a这行代码解释为把a的内容复制给b,就像a和b是整数一样(而不是把a的地址分配给b)。
真正的数组支持将使编译器生成目标代码,将数组A的内容复制到数组B中。生成的目标代码将执行下面C代码中所示的操作。它需要创建一个循环,一个接一个地将每个字符的内容从数组A复制到数组B:

void Initialize (char * a, char * b)
{
        int i;
        a[0] = 'T'; a[1] = 'h'; a[2] = 'i';
        a[3] = 's'; a[4] = ' '; a[5] = 'i';
        a[6] = 's'; a[7] = ' '; a[8] = 'A';
        a[9] = '\0';
        for (i = 0; i < ARRAY_SIZE; i++) 
        {
            b[i] = a[i];
        }
        b[8] = 'B';
}

(3)值传递和引用传递
值传递的例子:

#include <stdio.h>

int first;
int second;

void callee (int first)
{
        int second;

        second = 1;
        first = 2;
        printf("callee: first = %d second = %d\n", first, second);
}

int main (int argc, char *argv[])
{
        first = 1;
        second = 2;
        callee(first);
        printf("caller: first = %d second = %d\n", first, second);
        return 0;
}

输出结果:

callee:first=2 second=1
caller:first=1 second=2

系统级程序设计笔记(unit2——程序的机器级表示)
系统级程序设计笔记(unit2——程序的机器级表示)
在callee中的first是形参,在值传递的情况下也被当做局部变量进行处理(可以看到它和局部变量second的地址非常接近)

函数的参数也可以通过引用传递。在这种情况下,编译器不会为正式参数分配一个新地址,而是使用调用者指定的参数的地址。这样,一个分配的形式参数在函数(调用者)中最终会改变调用方传递的变量的值。

引用传递的例子:

#include <stdio.h>

int first;
int second;

void callee (int * first)
{
        int second;

        second = 1;
        *first = 2;
        printf("callee: first = %d second = %d\n", *first, second);
}

int main (int argc, char *argv[])
{
        first = 1;
        second = 2;
        callee(&first);
        printf("caller: first = %d second = %d\n", first, second);
        return 0;
}

在这里传递了地址,first不再被当做是局部变量来处理,此处的first不是形参,而是用指针的操作传递地址。
输出结果:

callee:first=2 second=1
caller:first=2 second=2

(4)递归:
编译器如何决定在何处分配变量?
为什么局部变量和全局变量分配在不同的地方?
当我们检查函数的递归调用时,运行如下代码:

#include <stdio.h>
#include <stdlib.h>

void callee (int n)
{
    if (n == 0) return;
    printf("%d (0x%08x)\n", n, &n);
    callee (n - 1);
    printf("%d (0x%08x)\n", n, &n);
}

int main (int argc, char * argv[])
{
    int n;
    if (argc < 2){
        printf("USAGE: %s <integer>\n", argv[0]);
        return 1;
    }
    n = atoi(argv[1]);

    callee(n);
    return 0;
}

输出结果:

10 (0x0065fda4)
9 (0x0065fd4c)
8 (0x0065fcf4)
7 (0x0065fc9c)
6 (0x0065fc44)
5 (0x0065fbec)
4 (0x0065fb94)
3 (0x0065fb3c)
2 (0x0065fae4)
1 (0x0065fa8c)
1 (0x0065fa8c)
2 (0x0065fae4)
3 (0x0065fb3c)
4 (0x0065fb94)
5 (0x0065fbec)
6 (0x0065fc44)
7 (0x0065fc9c)
8 (0x0065fcf4)
9 (0x0065fd4c)
10 (0x0065fda4)

这表明编译器为每一个对callee的调用分配一个地址,但是编译器是如何知道它必须分配10个n的实例呢?编译器将为每一个函数调用和每个函数返回添加额外的代码。这段代码分配任何调用者所需要调用的局部变量。
全局变量可以静态分配。这意味着编译器可以在程序执行之前修复全局变量的特定地址。但是,由于函数可以递归调用,并且每个递归调用都需要自己实例化局部变量,编译器必须动态地分配局部变量。这样的动态行为会使程序运行得更慢,因此不可取。但对于局部变量是必要的。因为编译器不知道需要动态分配多少变量,所以它保留了大量的扩展空间。这就是为什么本地变量被分配到远离全局变量的地址的地址。

(5)系统级程序设计笔记(unit2——程序的机器级表示)
extern char a[]与extern char a[100]都提示a是一个数组,也就是一个内存地址,数组内的字符可以从这个地址找到。编译器并不需要知道数组总共有多长,因为它只需要产生偏离起始地址的偏移地址。从数组中提取一个字符只要简单地从符号表显示的a的地址加上下标,需要的字符就位于这个地址中。
相反,如果声明extern char *p,它将告诉编译器p是一个指针(四字节的对象),它指向的对象是一个字符。为了取得这个字符,必须得到地址p的内容,把它作为字符的地址并从地址中取得这个字符。指针的访问要灵活得多,但需要一次额外的提取。
系统级程序设计笔记(unit2——程序的机器级表示)
在上面的mango中,如果把mango声明为指针,那么不管p原先是定义为指针还是数组,编译器都会按照以下三步进行操作:1.取得符号表中mango的地址,提取存储于此处的指针。2.把下标所表示的偏移量与指针的值相加,产生一个地址。3.访问地址,取得字符。
系统级程序设计笔记(unit2——程序的机器级表示)
但只有当p原来定义为指针时这个方法才是正确的。所以extern int*mango;在这里用mango[i]这种形式提取声明的内容时实际上得到的是一个字符,但是按照上面的三步编译器把它当成指针,把ASCII字符解释为地址显然是错的。
所以我们需要换一种方式去表达,如下的两种方式都是可取的。
方式一:
File1:int mango[100];
File2:extern int mango[];
方式二:
File1:int *mango;
File2:extern int*mango;

(6)可修改的左值:

X=Y;

这行代码的上下文环境里,X的含义是X所代表的地址,这被称为左值。左值在编译时可知,左值表示存储结果的地方。Y的含义是Y所代表的地址的内容,这被称为右值。右值直到运行时才知。
C语言引入了“可修改的左值”这个术语。它表示左值允许出现在赋值语句的左边。这个奇怪的术语是为与数组名区分,数组名也用于确定对象在内存中的位置,也是左值,但它不能作为赋值的对象。因此,数组名是个左值但不是可修改的左值。标准规定赋值符必须用可修改的左值作为它左边一侧的操作数。用通俗的话说,只能给可以修改的东西赋值。

活动记录和堆栈

(1)活动记录Activation Records:过程的一次执行所需要的信息用一块连续的存储区来管理,这块存储区叫做活动记录,是内存中的一个区域,在这个区域中包含了一个函数中所有参数,局部变量,临时变量,返回地址等信息。
函数调用的活动记录是在调用函数时创建的,当函数返回时就会被销毁。活动记录被组织在一个堆栈中。由于这个原因,它们通常也称为栈帧(stack frame)。main函数的活动记录在栈的底部,作为一个函数去调用另一个。被调用的活动记录被推向调用者的上面。只有堆栈顶部(TOS,top of stack)的活动记录可以被访问。这是因为: 函数在其所有活动子程序返回之前都不会返回,也就是说,我们永远不需要删除一个不在堆栈顶部的活动记录。
作用域的规则意味着一个函数无法访问它的母变量的局部变量——也就是说,我们从来都不需要访问一个来自不在栈顶的活动记录的数据。
实际上,大多数计算机从更高的地址到更低的地址来分配活动记录堆栈(通常直接称为堆栈),即堆栈向下增长。
系统级程序设计笔记(unit2——程序的机器级表示)
(2)在执行过程中的任何时刻,编译器和硬件内部都维护两个重要的值,这些值用于以简单而优雅的方式分隔和操纵活动记录:
栈指针(stack pointer)保存堆栈结束的地址,这里将会分配一个新的活动记录。
帧指针(frame pointer)保存之前的活动记录结束的地址,这个值是当当前的函数返回时栈指针将会返回的地址。
当函数被调用时,编译器和硬件会做的事情:
将帧指针压入堆栈。
将帧指针设置为指向栈指针所指向的地方。
按照被调用函数所需要存储局部状态的内存大小来递减栈指针指向的地址。
系统级程序设计笔记(unit2——程序的机器级表示)
当函数返回时,编译器和硬件会做的事情:
将栈指针设置为指向帧指针所指向的地方。
从堆栈中弹出旧的的帧指针的值。
系统级程序设计笔记(unit2——程序的机器级表示)
我们将会根据(3)中的问题完善这些步骤,完整版的步骤在(4)中。
(3)问题:在程序执行的每一步中,是什么决定了是哪个数据项或者哪个CPU指令将要被取出?
回答:CPU保存着一个特殊的值,被叫做程序计数器(PC,program counter),其中包含了将要执行的指令的地址(比如堆栈指针和帧指针)。
一个读取-译码-执行的周期:
从程序计数器中包含的地址中获取内存,解读操作码代表的信息,获取操作数,执行指令,并存储结果。
函数调用和程序计数器:
问题:为了能在被调用者返回的时候老的程序计数器恢复,老的程序计数器应该被存储在哪里?
回答:堆栈中的每一个活动记录都包含着一个帧指针的副本。帧指针被存储在堆栈中,以便为每个函数返回还原不同的值,每个函数调用需要返回到调用它的地址。
(4)完整版本(加入了PC和帧指针的副本之后对(2)的补充):
当进行一个函数调用的时候,编译器:
①将返回地址(当前程序计数器)压入堆栈中。
②将帧指针压入堆栈中。
③将帧指针设置为指向栈指针所指向的地方。
④按照被调用函数所需要存储局部状态的内存大小来递减栈指针指向的地址
函数返回时,编译器:
①将栈指针设置为指向帧指针所指向的地方。
②从堆栈中弹出旧的的帧指针的值
③从堆栈中弹出返回地址的值
④跳回返回地址
注意:帧指针里面存的是上一个栈帧(调用者)的帧指针的值。

我们将会在unit4中对堆栈、栈帧和函数调用过程有更深入的学习和认识。