计算机如何使用栈内存
值和地址
程序和数据必须保存在存储器内.
存储器可分为 易失的 和 非易失的.
易失存储器需要供电, 只在计算机开机时才能保存数据, 如 内存.
非易失存储器可以在关机或重启时, 数据也不丢失, 如 闪存, 硬盘.
内存被编组成 地址-值 这样的关系对, 每个地址存储的不是 0 就是 1.
操作系统保证任何内存地址都是唯一的, 且不为 0 或 负数.
标志符 NULL 被定义为零地址, 表明一个无效地址.
地址 | 值 |
---|---|
first | 0 |
second | 1 |
third | 1 |
fourth | 0 |
想记住所有程序操作的内存地址是不可能的, 计算机先驱们选择创建标志符指代相关的内存单元, 如 counter, sum.
如果某个标志符指代的内存单元所存储的值在程序运行期间会变化, 就称这个标志符为变量.
标志符 | 地址 | 值 | |
---|---|---|---|
int a = 8; | a | 100 | 8 |
double pi = 3.14; | pi | 200 | 3.14 |
char c = 'A'; | c | 300 | 'A' |
编译器会把标志符转化为地址, 计算机并不会看到这些标志符, 计算机只看到内存地址和值.
现代计算机把内存编成三类:
- 栈内存
- 堆内存
- 程序内存
栈内存和堆内存用来存储数据, 程序内存用来存储程序的机器码.
栈, stack, 一种后入先出的结构, 放入一个数据项叫压入 (push), 移出一个数据项叫弹出 (pop).
栈内存严格遵守后入先出的原则, 新入数据从顶部进入, 且数据总是从顶部移出.
函数的返回位置
- void f1(void)
- {
- /* some code */
- }
- void f2(void)
- {
- /* some code */
- f1();
- /* f1 结束后, 程序从这里继续 */
- /* some code */
- }
程序执行时, 一个标记插在 f1 被调用处的正下方, 该标记告诉程序在 f1 结束后应从哪里继续, 这叫做返回位置.
- void f1(void)
- {
- /* some code */
- }
- void f2(void)
- {
- /* some code */
- f1();
- /* 返回位置 B */
- /* some code */
- }
- void f3(void)
- {
- /* some code */
- f2();
- /* 返回位置 A */
- /* some code */
- }
f3 在第 17 行调用 f2, f2 在第 9 行调用 f1.
f1 结束后, 从调用 f1 之后的那行继续, 第 10 行.
f2 结束后, 从调用 f2 之后的那行继续, 第 18 行.
程序怎么知道一个函数结束后, 该从哪里继续?
当 f3 调用 f2 时, 与 第 18 行等价的机器码被压入栈内存, 记住了这个返回位置 (返回位置 A).
当 f2 调用 f1 时, 与 第 10 行等价的机器码被压入栈内存, 记住了这个返回位置 (返回位置 B).
后入先出原则保证栈内存存储着函数调用的倒序, 因此, 程序知道 f1 结束后, 应从返回位置 B 继续, f2 结束后, 应从返回位置 A 继续.
栈内存也叫调用栈, 每个 C 程序都由他控制函数执行的流程.
用行号作为返回位置是一种概念抽象, 不反映具体的处理器, 真正的处理器使用程序计数器.
程序执行时, 调用栈内, 信息的变化情况:
- f3 调用 f2, f2 之后的行号被压入栈内存, 调用栈现有
- f2 调用 f1, f1 之后的行号被压入栈内存, 调用栈现有
- f1 结束后, 行号 10 出栈, 程序从第 10 行继续, 调用栈现有
- f2 结束后, 行号 18 被弹出, 程序从第 18 行继续, 调用栈空
f2 之后的行号 18, 即返回位置 A |
f1 之后的行号 10, 即返回位置 B |
f2 之后的行号 18, 即返回位置 A |
f2 之后的行号 18, 即返回位置 A |
调用栈规则:
- 当一个函数被调用, 这条调用之后的行号被压入调用栈, 这个行号就是返回位置, 这是被调函数结束(即返回), 程序继续执行的地方
- 如相同的函数在不同的行被调用, 每个调用都有各自相对应的返回位置
- 一个函数结束后, 程序从存储在调用栈顶部的行号处继续, 调用栈顶部的内容会被弹出
函数的 argument
- void f1(int a, char b, double c)
- {
- /* some code */
- }
- void f2(void)
- {
- /* some code */
- f1(8, 'X', 3.14);
- /* 返回位置 A */
- /* some code */
- }
f1 被调用时, f2 必须为 f1 提供 3 个相应类型的 argument, 而且这些信息会被压入调用栈.
返回位置 和 argument 构成函数 f1 的栈帧, 一个栈帧占据栈内存内一个连续的块.
栈帧和标志符只对人类有意义, 计算机只处理地址和值.
f1 的栈帧
栈帧 | 标志符 | 地址 | 值 |
---|---|---|---|
f1 | c | 103 | 3.14 |
b | 102 | 'X' | |
a | 101 | 8 | |
返回位置 A | 100 | 第 10 行 |
- void f1(int u, int v)
- {
- /* some code */
- }
- void f2(int a, int b)
- {
- /* some code */
- f1(a+b, a-b);
- /* 返回位置 B */
- /* some code */
- }
- void f3(void)
- {
- /* some code */
- f2(8, 16);
- /* 返回位置 A */
- /* some code */
- }
f3 调用 f2, f2 的栈帧被压入调用栈.
栈帧 | 标志符 | 地址 | 值 |
---|---|---|---|
f2 | b | 102 | 16 |
a | 101 | 8 | |
返回位置 A | 100 | 第 18 行 |
f2 调用 f1, f1 的栈帧被压入调用栈.
栈帧 | 标志符 | 地址 | 值 |
---|---|---|---|
f1 | v | 105 | -8 |
u | 104 | 24 | |
返回位置 B | 103 | 第 10 行 | |
f2 | b | 102 | 16 |
a | 101 | 8 | |
返回位置 A | 100 | 第 18 行 |
合并后的调用栈规则:
- 如果函数有 argument, 那么 argument 被存储在返回位置的上方
- argument 和 返回位置共同构成被调函数的栈帧
- 当一个函数被调用, 这条调用之后的行号被压入调用栈, 这个行号就是返回位置, 这是被调函数结束(即返回), 程序继续执行的地方
- 如相同的函数在不同的行被调用, 每个调用都有各自相对应的返回位置
- 一个函数结束后, 程序从存储在调用栈顶部的行号处继续, 调用栈顶部的内容会被弹出
函数的局部变量
- void f1(int x, int y, int z)
- {
- int u = x + y + z;
- int v = x - y - z;
- }
- void f2(void)
- {
- f1(8, 16, 24);
- /* 返回位置 A */
- }
一个函数有局部变量, 这些局部变量将被保存到调用栈中.
f1 的栈帧
栈帧 | 标志符 | 地址 | 值 |
---|---|---|---|
f1 | v | 105 | -32 |
u | 104 | 48 | |
z | 103 | 24 | |
y | 102 | 16 | |
x | 101 | 8 | |
返回位置 A | 100 | 第 10 行 |
局部变量总是存储在栈上, 函数调用期间, 他们一直在那里.
全局变量则在函数调用之间存在.
使用全局变量的问题在于变量的值可以在程序的任意位置发生改变.
随着程序越来越大, 越来越复杂, 追踪这些全局变量发生改变的位置就越来越困难, 如果失去了对改变的追踪, 经常导致程序出现异常行为.
全局常量经常被使用, 也可以被广泛接受, 因为他们不变.
合并后的调用栈规则:
- 如果函数有局部变量, 局部变量被存储在 argument 的上方
- 如果函数有 argument, 那么 argument 被存储在返回位置的上方
- argument 和 返回位置共同构成被调函数的栈帧
- 当一个函数被调用, 这条调用之后的行号被压入调用栈, 这个行号就是返回位置, 这是被调函数结束(即返回), 程序继续执行的地方
- 如相同的函数在不同的行被调用, 每个调用都有各自相对应的返回位置
- 一个函数结束后, 程序从存储在调用栈顶部的行号处继续, 调用栈顶部的内容会被弹出
函数的返回值
- int f1(int a, int b)
- {
- return a + b;
- }
- void f2(void)
- {
- int u;
- u = f1(8, 16);
- /* 返回位置 A */
- }
局部变量 u 在 f2 中, 所以 u 在 f2 的栈帧中.
u 未被初始化, 未被初始化的变量的值称为未占用.
虽然 u 没有被初始化, 但 u 是有值的, u 的值是 u 被分配到的内存单元内原本就存储着的那些垃圾值.
f2 的栈帧
栈帧 | 标志符 | 地址 | 值 |
---|---|---|---|
f2 | u | 100 | 未占用 |
u 的地址是在 f1 被调用之前就存入调用栈的, 这个地址称为值地址, 是用来存储 f1 返回值的.
f1 栈帧建立时, 要为值地址加上一行, 值是 u 的地址, 然后被压入调用栈.
栈帧 | 标志符 | 地址 | 值 |
---|---|---|---|
f1 | b | 104 | 16 |
a | 103 | 8 | |
值地址 | 102 | 100 | |
返回位置 A | 101 | 第 10 行 | |
f2 | u | 100 | 未占用 |
f1 执行, 8 加 16 产生值 24, 24 被写入地址 100 处(此处原来的值是未占用), f1 栈帧出栈.
栈帧 | 标志符 | 地址 | 值 |
---|---|---|---|
f2 | u | 100 | 24 |
合并后的调用栈规则:
- 如果一个函数有返回值, 这个值会被写入主调函数的一个局部变量中, 该变量的地址(值地址)存储在被调函数栈帧以后再被压入调用栈中
- 如果函数有局部变量, 局部变量被存储在 argument 的上方
- 如果函数有 argument, 那么 argument 被存储在返回位置的上方
- argument 和 返回位置共同构成被调函数的栈帧
- 当一个函数被调用, 这条调用之后的行号被压入调用栈, 这个行号就是返回位置, 这是被调函数结束(即返回), 程序继续执行的地方
- 如相同的函数在不同的行被调用, 每个调用都有各自相对应的返回位置
- 一个函数结束后, 程序从存储在调用栈顶部的行号处继续, 调用栈顶部的内容会被弹出
注意
主调函数 f2 没有被强制一定要存储被调函数 f1 的返回值.
如果第 9 行改写为
- f1(8, 16);
f1 被调用, 但返回值被丢弃, 这时, 因为没有必要存储返回值, 值地址也就没有被写入调用栈.
关于 return
对于 void 型函数, 函数无返回值, return 使函数停止, 程序从返回位置继续.
对于非 void 型函数, 函数有返回值, return 为调用栈值地址给出的变量赋值.
一个函数执行了 return, return 之后的内容都会被忽略.
执行 return, 会使函数停止, 他的栈帧会出栈, 程序会从返回位置继续执行.
可见度
一个函数被调用, 一个新的栈帧就被压入调用栈, 函数只能看到自己的栈帧.
- int f1(int m, int n)
- {
- return (m + n);
- }
- void f2(void)
- {
- int a = 8;
- int b = 16;
- int u;
- u = f1(a, b);
- /* 返回位置 */
- }
- int f1(int a, int b)
- {
- return (a + b);
- }
- void f2(void)
- {
- int a = 8;
- int b = 16;
- int u;
- u = f1(a, b);
- /* 返回位置 */
- }
两个程序完全相同, f1 的 argument 被重命名为 a 和 b 不会有任何实际作用.
两个程序的调用栈是相同的, 只不过 f1 的 argument 有不同的标志符而已, 而标志符对计算机没有意义.
需要强调的是, 第二段程序, f1 中的变量 a 与变量 b, 和 f2 中的变量 a 与变量 b 所指代的是不同的 地址-值 对.
栈帧 | 标志符 | 地址 | 值 | 栈帧 | 标志符 | 地址 | 值 | |
---|---|---|---|---|---|---|---|---|
f1 | n | 106 | 16 | f1 | b | 106 | 16 | |
m | 105 | 8 | a | 105 | 8 | |||
值地址 | 104 | 102 | 值地址 | 104 | 102 | |||
返回位置 | 103 | 第 12 行 | 返回位置 | 103 | 第 12 行 | |||
f2 | u | 102 | 未占用 | f2 | u | 102 | 未占用 | |
b | 101 | 16 | b | 101 | 16 | |||
a | 100 | 8 | a | 100 | 8 |