计算机如何使用栈内存

值和地址


程序和数据必须保存在存储器内.
存储器可分为 易失的 和 非易失的.
易失存储器需要供电, 只在计算机开机时才能保存数据, 如 内存.
非易失存储器可以在关机或重启时, 数据也不丢失, 如 闪存, 硬盘.
内存被编组成 地址-值 这样的关系对, 每个地址存储的不是 0 就是 1.
操作系统保证任何内存地址都是唯一的, 且不为 0 或 负数.
标志符 NULL 被定义为零地址, 表明一个无效地址.

地址
first0
second1
third1
fourth0

想记住所有程序操作的内存地址是不可能的, 计算机先驱们选择创建标志符指代相关的内存单元, 如 counter, sum.
如果某个标志符指代的内存单元所存储的值在程序运行期间会变化, 就称这个标志符为变量.

 标志符地址
int a = 8;a1008
double pi = 3.14;pi2003.14
char c = 'A';c300'A'

编译器会把标志符转化为地址, 计算机并不会看到这些标志符, 计算机只看到内存地址和值.

现代计算机把内存编成三类:

  • 栈内存
  • 堆内存
  • 程序内存

栈内存和堆内存用来存储数据, 程序内存用来存储程序的机器码.
栈, stack, 一种后入先出的结构, 放入一个数据项叫压入 (push), 移出一个数据项叫弹出 (pop).
栈内存严格遵守后入先出的原则, 新入数据从顶部进入, 且数据总是从顶部移出.

函数的返回位置


  1. void f1(void)
  2. {
  3. /* some code */
  4. }
  5.  
  6. void f2(void)
  7. {
  8. /* some code */
  9. f1();
  10. /* f1 结束后, 程序从这里继续 */
  11. /* some code */
  12. }

程序执行时, 一个标记插在 f1 被调用处的正下方, 该标记告诉程序在 f1 结束后应从哪里继续, 这叫做返回位置.

  1. void f1(void)
  2. {
  3. /* some code */
  4. }
  5.  
  6. void f2(void)
  7. {
  8. /* some code */
  9. f1();
  10. /* 返回位置 B */
  11. /* some code */
  12. }
  13.  
  14. void f3(void)
  15. {
  16. /* some code */
  17. f2();
  18. /* 返回位置 A */
  19. /* some code */
  20. }

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 之后的行号 18, 即返回位置 A

  • f2 调用 f1, f1 之后的行号被压入栈内存, 调用栈现有

  • f1 之后的行号 10, 即返回位置 B
    f2 之后的行号 18, 即返回位置 A

  • f1 结束后, 行号 10 出栈, 程序从第 10 行继续, 调用栈现有

  • f2 之后的行号 18, 即返回位置 A

  • f2 结束后, 行号 18 被弹出, 程序从第 18 行继续, 调用栈空

调用栈规则:

  • 当一个函数被调用, 这条调用之后的行号被压入调用栈, 这个行号就是返回位置, 这是被调函数结束(即返回), 程序继续执行的地方
  • 如相同的函数在不同的行被调用, 每个调用都有各自相对应的返回位置
  • 一个函数结束后, 程序从存储在调用栈顶部的行号处继续, 调用栈顶部的内容会被弹出

函数的 argument


  1. void f1(int a, char b, double c)
  2. {
  3. /* some code */
  4. }
  5.  
  6. void f2(void)
  7. {
  8. /* some code */
  9. f1(8, 'X', 3.14);
  10. /* 返回位置 A */
  11. /* some code */
  12. }

f1 被调用时, f2 必须为 f1 提供 3 个相应类型的 argument, 而且这些信息会被压入调用栈.
返回位置 和 argument 构成函数 f1 的栈帧, 一个栈帧占据栈内存内一个连续的块.
栈帧和标志符只对人类有意义, 计算机只处理地址和值.

f1 的栈帧

栈帧标志符地址
f1c1033.14
b102'X'
a1018
返回位置 A100第 10 行
  1. void f1(int u, int v)
  2. {
  3. /* some code */
  4. }
  5.  
  6. void f2(int a, int b)
  7. {
  8. /* some code */
  9. f1(a+b, a-b);
  10. /* 返回位置 B */
  11. /* some code */
  12. }
  13.  
  14. void f3(void)
  15. {
  16. /* some code */
  17. f2(8, 16);
  18. /* 返回位置 A */
  19. /* some code */
  20. }

f3 调用 f2, f2 的栈帧被压入调用栈.

栈帧标志符地址
f2b10216
a1018
返回位置 A100第 18 行

f2 调用 f1, f1 的栈帧被压入调用栈.

栈帧标志符地址
f1v105-8
u10424
返回位置 B103第 10 行
f2b10216
a1018
返回位置 A100第 18 行

合并后的调用栈规则:

  • 如果函数有 argument, 那么 argument 被存储在返回位置的上方
  • argument 和 返回位置共同构成被调函数的栈帧
  • 当一个函数被调用, 这条调用之后的行号被压入调用栈, 这个行号就是返回位置, 这是被调函数结束(即返回), 程序继续执行的地方
  • 如相同的函数在不同的行被调用, 每个调用都有各自相对应的返回位置
  • 一个函数结束后, 程序从存储在调用栈顶部的行号处继续, 调用栈顶部的内容会被弹出

函数的局部变量


  1. void f1(int x, int y, int z)
  2. {
  3. int u = x + y + z;
  4. int v = x - y - z;
  5. }
  6.  
  7. void f2(void)
  8. {
  9. f1(8, 16, 24);
  10. /* 返回位置 A */
  11. }

一个函数有局部变量, 这些局部变量将被保存到调用栈中.

f1 的栈帧

栈帧标志符地址
f1v105-32
u10448
z10324
y10216
x1018
返回位置 A100第 10 行

局部变量总是存储在栈上, 函数调用期间, 他们一直在那里.
全局变量则在函数调用之间存在.
使用全局变量的问题在于变量的值可以在程序的任意位置发生改变.
随着程序越来越大, 越来越复杂, 追踪这些全局变量发生改变的位置就越来越困难, 如果失去了对改变的追踪, 经常导致程序出现异常行为.
全局常量经常被使用, 也可以被广泛接受, 因为他们不变.

合并后的调用栈规则:

  • 如果函数有局部变量, 局部变量被存储在 argument 的上方
  • 如果函数有 argument, 那么 argument 被存储在返回位置的上方
  • argument 和 返回位置共同构成被调函数的栈帧
  • 当一个函数被调用, 这条调用之后的行号被压入调用栈, 这个行号就是返回位置, 这是被调函数结束(即返回), 程序继续执行的地方
  • 如相同的函数在不同的行被调用, 每个调用都有各自相对应的返回位置
  • 一个函数结束后, 程序从存储在调用栈顶部的行号处继续, 调用栈顶部的内容会被弹出

函数的返回值


  1. int f1(int a, int b)
  2. {
  3. return a + b;
  4. }
  5.  
  6. void f2(void)
  7. {
  8. int u;
  9. u = f1(8, 16);
  10. /* 返回位置 A */
  11. }

局部变量 u 在 f2 中, 所以 u 在 f2 的栈帧中.
u 未被初始化, 未被初始化的变量的值称为未占用.
虽然 u 没有被初始化, 但 u 是有值的, u 的值是 u 被分配到的内存单元内原本就存储着的那些垃圾值.

f2 的栈帧

栈帧标志符地址
f2u100未占用

u 的地址是在 f1 被调用之前就存入调用栈的, 这个地址称为值地址, 是用来存储 f1 返回值的.
f1 栈帧建立时, 要为值地址加上一行, 值是 u 的地址, 然后被压入调用栈.

栈帧标志符地址
f1b10416
a1038
值地址102100
返回位置 A101第 10 行
f2u100未占用

f1 执行, 8 加 16 产生值 24, 24 被写入地址 100 处(此处原来的值是未占用), f1 栈帧出栈.

栈帧标志符地址
f2u10024

合并后的调用栈规则:

  • 如果一个函数有返回值, 这个值会被写入主调函数的一个局部变量中, 该变量的地址(值地址)存储在被调函数栈帧以后再被压入调用栈中
  • 如果函数有局部变量, 局部变量被存储在 argument 的上方
  • 如果函数有 argument, 那么 argument 被存储在返回位置的上方
  • argument 和 返回位置共同构成被调函数的栈帧
  • 当一个函数被调用, 这条调用之后的行号被压入调用栈, 这个行号就是返回位置, 这是被调函数结束(即返回), 程序继续执行的地方
  • 如相同的函数在不同的行被调用, 每个调用都有各自相对应的返回位置
  • 一个函数结束后, 程序从存储在调用栈顶部的行号处继续, 调用栈顶部的内容会被弹出

注意


主调函数 f2 没有被强制一定要存储被调函数 f1 的返回值.
如果第 9 行改写为

  1. f1(8, 16);

f1 被调用, 但返回值被丢弃, 这时, 因为没有必要存储返回值, 值地址也就没有被写入调用栈.

关于 return


对于 void 型函数, 函数无返回值, return 使函数停止, 程序从返回位置继续.
对于非 void 型函数, 函数有返回值, return 为调用栈值地址给出的变量赋值.
一个函数执行了 return, return 之后的内容都会被忽略.
执行 return, 会使函数停止, 他的栈帧会出栈, 程序会从返回位置继续执行.

可见度


一个函数被调用, 一个新的栈帧就被压入调用栈, 函数只能看到自己的栈帧.

  1. int f1(int m, int n)
  2. {
  3. return (m + n);
  4. }
  5.  
  6. void f2(void)
  7. {
  8. int a = 8;
  9. int b = 16;
  10. int u;
  11. u = f1(a, b);
  12. /* 返回位置 */
  13. }
  1. int f1(int a, int b)
  2. {
  3. return (a + b);
  4. }
  5.  
  6. void f2(void)
  7. {
  8. int a = 8;
  9. int b = 16;
  10. int u;
  11. u = f1(a, b);
  12. /* 返回位置 */
  13. }

两个程序完全相同, f1 的 argument 被重命名为 a 和 b 不会有任何实际作用.
两个程序的调用栈是相同的, 只不过 f1 的 argument 有不同的标志符而已, 而标志符对计算机没有意义.
需要强调的是, 第二段程序, f1 中的变量 a 与变量 b, 和 f2 中的变量 a 与变量 b 所指代的是不同的 地址-值 对.

栈帧标志符地址   栈帧标志符地址
f1n10616   f1b10616
m1058 a1058
值地址104102 值地址104102
返回位置103第 12 行 返回位置103第 12 行
f2u102未占用 f2u102未占用
b10116 b10116
a1008 a1008

《 C 语言程序设计进阶教程》