C/C++程序如何翻译为汇编代码

本文内容总结自:《深入理解计算机系统》第三版

历史

Intel 处理器系列俗称 x86,经历了一个长期的发展过程。

8086:第一代单芯片,16位微处理器。

80286:增加了更多的寻址模式,现已废弃。

i386:将体系结构扩展到32位,增加了平坦寻址模式。

i486:改善了性能,将浮点单元集成到处理器芯片。

Pentium:改善了性能,对指令集进行了小的扩展。

Pentium 4E:增加了超线程,可以在一个处理器上同时运行两个程序。

Core i7:支持超线程,多核。

每个后继处理器的设计都是向后兼容的。Intel 处理器系列有好多名字,包括IA32(英特尔32位体系结构,Intel Architecture 32-bit),以及最新的 Intel 64,常称为,x86-64。

数据格式

由于从 16 位体系扩展成 32 位,Intel 用 “字” (word)表示16位数据类型,及 2 个字节。

我们常说 int 类型为双字,即,4 字节。long 类型为四字,即,8 字节。然而并非总是如此,我们应该根据系统使用的数据模型(Data Model),来判断不同类型的数据占据多少字节。

现今所有 64 位的类 Unix 平台均使用 LP64 数据模型,而 64 位 Windows 使用 LLP64 数据模型(摘自:https://www.cnblogs.com/lsgxeva/p/7614856.html)。如下图所示,其中数字的单位是 bit。

Data Model LP64 LLP64
平台 Unix 和 Unix 类的系统 (Linux,Mac OS X) Win64
char 8 8
short 16 16
int 32 32
long 64 32
long long 64 64
pointer 64 64

顾名思义,LP64 指 long 与 Pointer 类型都是 64 bit;LLP64 指 long long 与 Pointer 类型都是 64 bit。

访问信息

一个 x86-64 的中央处理单元(cpu),包含一组 16 个存储 64 位值的通用目的寄存器。这些寄存器用来存储整数和指针

 注:所有 16 个寄存器的低位部分都可以作为字节、字、双字和四字来访问。

当生成不足 8 字节的指令到这些寄存器时,有两条写入规则:

  • 生成 1、2 字节数字的指令保持寄存器中剩下的字节不变
  • 生成 4 字节数字的指令会把高位 4 个字节置零

寻址方式

一条指令一般由操作码、地址码组成,地址码由源操作数、目的操作数、下条指令地址组成。源操作数和目的操作数,分为三种类型:

  • 立即数(immediate):表示常数值,一般用 ‘$’ + integer 表示
  • 寄存器(register):表示寄存器中的内容,用 ra 表示任意寄存器 a,用引用 R[ra] 表示寄存器 a 中的值
  • 内存引用:根据有效地址,访问对应的内存位置,用 M[addr] 表示存储在 addr 中的值
类型 格式 操作数值 名称 用途
立即数 $imm imm 立即数寻址 提供常量、设定初始值
寄存器 ra R[ra] 寄存器寻址 局部变量变量赋值
存储器 imm M[imm] 绝对寻址  
存储器 (ra) M[R[ra]] 间接寻址 指针解引用
存储器 imm(ra) M[imm+R[ra]] 基址(+偏移量)寻址  
存储器 (ra, rb) M[R[ra]+R[rb]] 变址寻址  
存储器 imm(ra, rb) M[imm+R[ra]+R[rb]] 变址寻址  
存储器 imm(ra, rb, s) M[imm+R[ra]+R[rb]*s] 比例变址寻址  

数据传送指令

指令 效果 描述
MOV  S, D S→D 把数据从源位置复制到目的位置
movb   复制1个字节
movw   复制字
movl   复制双字
movq   复制四字
movabsq   复制绝对的四字
long exchange(long *xp, long y)
{
    long x = *xp;
    *xp = y;
    return x;          
}
exchange:
    movq    (%rdi), %rax;  
    movq    %rsi, (%rdi); 
    ret    

解释汇编代码:

  1. xp 指针作为第一参数存储在寄存器 %rdi;y 作为第二参数存储在寄存器 %rsi;%rax 存储返回值
  2. 第一行:读 xp 中存储的值 *xp,存储到返回值
  3. 第二行:读 y 中的值,放入 xp 指向的内存地
  4. 第三行:返回函数调用点

注意:指针就是操作数在内存中的地址,存储在一个寄存器中,通过读内存地址得到其对应的值。而 x 这样的局部变量一般保存在寄存器中,而不是内存中,访问寄存器要比访问内存快得多。

压入和弹出栈数据

指令 效果 描述
pushq  S

R[%rsp]←R[%rsp]-8;

M[R[%rsp]]←S

将四字压入栈
popq    D

D←M[R[%rsp]];

R[%rsp]←R[%rsp]+8

将四字弹出栈

注意:栈的地址是从高地址到低地址增长的,也就是说,压栈需要对栈顶指针 %rsp 做减操作,弹栈需要做加操作。

算数和逻辑操作

指令 效果 描述
leaq  S, D D←&S 将有效地址写入目的操作数
INC  D   +1
DEC  D   -1
NEG  D   取负
NOT  D   按位取反
ADD  S, D D←D+S +
SUB  S, D D←D-S -
IMUL S, D   *
XOR  S, D   异或
OR    S, D  
AND  S, D  
SAL  k, D   左移 k 位(右边填0)
SHL  k, D   左移 k 位(右边填0)
SAR  k, D   算术右移(左边填符号位)
SHR  k, D   逻辑右移(左边填0)

控制

通常,C 语言中的语句和机器代码中的指令都是按照它们在程序中出现的次序,顺序执行的。用 jump 指令可以改变一组机器代码指令的执行顺序,jump 指令指定控制应该被传递到程序的哪个部分。C 语言编译器正是依靠这些指令序列,来实现自己的控制结构。

1 条件码

除了整数寄存器,cpu 还维护一组单个位的条件码(condition code)寄存器,它们描述了最近的算数或者逻辑操作的属性。可以检测这些寄存器来执行条件分支指令。常用的有:

  • CF:进位标志,最近的操作使最高位产生了进位。可用来检查无符号操作的溢出
  • ZF:零标志,最近的操作得出结果为 0
  • SF:符号标志,最近的操作得到的结果为负数
  • OF:溢出标志,最近的操作导致一个补码溢出——有符号数的正负溢出

2 跳转指令

jmp 指令是无条件跳转,可以是直接跳转,如:jmp  .getNum,跳转到 .getNum 执行一组指令;也可以是间接跳转,如:jmp  *%rax,用 %rax 寄存器中的值作为跳转目的。下表中其他的跳转都是有条件的:

指令 同义名 跳转条件 描述

jmp  Label

  1 直接跳转
jmp  *op   1 间接跳转
je     Label jz ZF 相等
jne    Label jnz ~ZF 不相等
js      Label   SF 负数
jns    Label   ~SF 非负数
jg      Label jnle   大于(有符号)
jge    Label jnl   大于等于(有符号)
jl       Label jnge   小于(有符号)
jle     Label jng   小于等于(有符号)
ja      Label jnbe   大于(无符号)
jae    Label jnb   大于等于(无符号)
jb      Label jnae   小于(无符号)
jbe     Label jna   小于等于(无符号)

3 条件分支

 将条件表达式和语句从 C 语言翻译成机器代码,最常用的方式是结合有有条件和无条件跳转。(需要区分数据的条件转移和控制的条件转移)

(1)控制的条件转移

C 语言中的 if-else 语句(基于控制的条件转移)的形式:

if (test-expr)
  then-statement
else
  else-statement

汇编通常这样实现上面的形式:

汇编器为 then-statement 和 then-statement 产生各自的代码块,它会插入条件和无条件分支,以保证能执行正确的代码块。

t = test-expr
;条件转移
if (!t)
  goto  false
then-statement
;无条件转移
goto  done

false:
  else-statement

done:
...

(2)数据的条件转移

注意:数据的条件转移只在一些受限的情况中才可行。

控制的条件转移实现的函数:

long diff(long x, long y)
{
  long result;
  if (x < y)
    result = y-x;
  else
    result = x-y;
  return result;
}

数据的条件转移实现的函数:

long diff(long x, long y)
{
  // 计算每个分支
  long rval = y-x;
  long eval = x-y;
  bool test = (x>=y);
  if (test)
    rval = eval;
  return rval;
}

为何基于数据的条件传送会优于基于控制的条件转移?

cpu 通过使用流水线来提高性能,在流水线中,一条指令的处理要经过一系列阶段,每个阶段执行所需操作的一小部分,如:从内存取指令、从内存读数据、执行算术运算、向内存写数据、更新程序计数器等。流水线的方法可能这么做:在取一条指令的同时,执行它前面一条指令的算术运算。要做到这一点,必须要明确指令的执行序列,这样才能保证流水线中充满待执行的指令。

当机器遇到条件分支时,只有当分支条件求值完成后,才能决定分支往哪边走。处理器使用分支预测逻辑来猜测每条跳转指令是否会执行。如果猜对了的话,流水线中充满指令;如果猜错的话,cpu 要丢掉它在跳转指令后所作的工作,再从正确的指令位置开始重新填充流水线,这会导致程序性能严重下降。

同控制的条件转移不同,处理器无需预测测试结果就可以执行条件传送,仅仅检查条件码就可以,然后要么更新目的寄存器,要么保持不变。

基于数据的条件转移形式如下:

v = then-expr;
ve = else-expr;
t = test-expr;
if (!t)
  v = ve;

这个 if 语句使用数据的条件传送来实现。

缺陷

无论如何,条件分支的正误两条分支都会被计算求值,如果计算量非常大,那造成的性能损失堪比预测错误。

注意:GCC 在多数情况下,使用控制的条件转移,即使分支预测错误造成的性能损失大于复杂的计算。

4 循环

(1)do-while

C 形式:

do 
  body-statement
while (test-expt);

汇编形式:

loop:
  body-statement
  t = test-expr
  if (t)
    goto loop

(2) while 循环

while (test-expr)
  body-statement

汇编形式1(跳转到中间):

goto test
loop:
  body-statement
test:
  t = test-expr
  if (t)
    goto loop

汇编形式2(guarded-do,当使用较高级别的优化编译时,采用这种方法):

t = test-expr
if (!t)
  goto done
loop:
  body-statement
  t = test-expr
  if (t)
    goto loop
done:
...

(3)for 循环

C 语言形式:

for (init-expr; test-expr; update-expr)

  body-statement

C 语言标准使用等同 while 的汇编方法来实现它。等同于:

init-expr;
while (test-expr){
  update-expr;
}

但是有一个特殊情况,存在 continue 的情况:

int sum = 0;
for (int i = 0;i < 10;++i){
  if (i & 1)
    continue;
  sum += i;
}

如果使用 while 规则:

int sum = 0;
int i = 0;
while (i < 10) {
  if (i & 1)
    continue;
  sum += i;
  ++i;
}

很明显,如果 (i&1) 成立,那么,while 循环将跳过 i 更新语句,造成无限循环,但是 for 循环每轮迭代都正常更新。

所以,当遇到 continue 时,编译器会做额外的操作保证循环正确,使用 goto 代替 continue:

i = 0;
while (i < 10) {
  if (i & 1)
    goto update;
  sum += i;
  update:
    ++i;
}

(4)switch 语句

注意:开关语句仅仅可以根据整数索引值进行多重分支,不能使用一个例如:字符串作为判断条件。

如果有多种条件跳转分支(例如 4 个以上),且索引值的范围跨度较小,开关语句使用跳转表(jump table)使得实现更加高效。

跳转表是一个数组,数组元素 i 存储代码段的入口地址,该代码段实现当索引值等于 i 时程序的动作。和使用一组长的 if-else 语句相比,使用跳转表的优点是执行开关语句的时间与条件分支的数量无关

 可以看到,索引值有 100,102,103,104,106,编译器需要把它们映射到一个较小的连续空间,通过 -100,它们的取值区间就映射到了区间[0, 6]。那么,根据 index 的值,如果 index < 0 或者 index > 6,显然它属于 default 情况,如果在范围内,就可以跳转到正确的代码段。

过程

过程即封装的函数。它包括以下三个机制(以 P 调用 Q,随后再返回 P 中执行为例):

  1. 传递控制:进入过程 Q 的时候,程序计数器的值被设置为 Q 的代码起始地址;返回到 P 时,PC 设置为调用 Q 语句后面的那条语句
  2. 传递数据:P 必须能够向 Q 提供若干个参数,Q 必须能向 P 返回一个值(也可能不返回)
  3. 分配和释放空间:开始时,Q 可能需要为局部变量分配空间;返回时,必须释放这些空间

 运行时栈

C 语言过程调用机制的关键特性,在于使用了栈数据结构提供的后进先出的内存管理原则。

栈帧:当 x84-64 过程需要的存储空间超出寄存器能够存放的大小时,就会在栈上分配空间,这部分空间叫做栈帧(stack frame)。

 栈顶:当前正在执行的过程总是在栈顶。

返回地址:当 P 调用 Q 时,会把返回地址压入栈中,指明当 Q 返回时,要从 P 程序的哪个位置继续执行。这个返回地址位于 P 的栈帧中。

Q 的代码分配自己的栈帧的空间,它可以保存寄存器的值,分配局部变量空间,为自己的调用设置参数等。

通过寄存器,P 最多可以传递 6 个整数值(指针和整数),如果超过了这个需求,P 在调用 Q 之前在自己的栈帧里存储好这些参数。

注意:许多参数少于 6 个的函数是不需要栈帧的,当这个函数所有的局部变量都存储在寄存器,且该函数不调用其他任何函数时,就可以不为它分配栈帧。

转移控制

P 调用 Q:只需要把 Q 的代码的起始地址设置为 PC 的值即可。

Q 执行完毕返回 P:CPU 需要记录继续 P 的执行的代码位置。

指令 描述
call  Label 把调用指令的下一条指令的地址作为返回地址压栈,把 PC 设置为 Q 的起始地址
call  *Op 同上
ret 从栈中弹出返回地址,并把它赋值给 PC

数据传送

当 P 调用 Q 时,P 的代码必须先把参数复制到适当的寄存器中。类似的,当 Q 返回到 P 时,P 的代码可以发访问寄存器 %rax 中的返回值。

如果一个函数有大于 6 个整型参数,超过 6 个的部分就要通过栈来传递。假设有 n 个参数,那么 P 的栈帧为第 7-n 在栈上分配空间,参数 7 位于栈顶的位置。

这也就意味着,如果函数参数在栈上分配,那么压栈的顺序是从后向前,读参数的顺序则是从前向后

注意:以上是整型参数(包括指针)的情况,浮点类型也有对应的寄存器。 

注意:通过栈传递参数时,所有的数据大小(字节,而不是 bit)都必须向 8 的倍数对齐。

(1)栈帧中的局部存储空间

例子:

void proc (
      long  a1, long*  a1p,
      int   a2, int*   a2p,
      short a3, short* a3p,
      char  a4, char*  a4p)
{
  *a1p += a1;
  ...
}

栈帧:

 局部数据必须存放在内存中的情况

  • 寄存器不足以存放所有的数据
  • 对一个局部变量使用 '&' 取地址,必须要为它产生一个地址
  • 某些局部变量是数组或者结构体,必须能通过数组或结构体引用被访问到

例子:

long call_proc ()
{
  long x1 = 1;
  int x2 = 2;
  short x3 = 3;
  char x4 = 4;
  proc(x1, &x1, x2, &x2, x3, &x3, x4, &x4);
  return (x1+x2)*(x3-x4);
}

对应的汇编代码:

 由上图可以看到,在栈上分配局部变量 x1-x4,它们具有不同的大小。使用 leaq 指令生成指向这些位置的指针。这是为其分配栈帧,做调用前准备。

注意:需要区分栈帧上的局部变量区(给局部变量分配内存),以及参数构造区(用于传参)。

我们看到,传递参数的时候,仍然使用 6 个寄存器存储前 6 个参数,使用栈帧传递后 2 个参数

注意:上图是 call_proc 函数的栈帧,而不是 proc 的栈帧。 

存储顺序:我们可以看到,局部变量的存储是按照顺序,从高地址到低地址顺序进行存储的。

而参数在栈帧上的存储恰好是反过来的,这会在实际执行过程中按参数顺序来读取。

对齐:由于局部变量从高地址到低地址顺序进行存储,那么局部变量的对齐需要向低地址对齐,我们看到 16 这个地址空间是用于 padding 的,而不是 23 这个地址用于 padding。

而参数在栈帧上,(起始地址)必须以 8 的倍数(字节)进行分配。占内存不满 8 字节,就存储在低位。

(2)寄存器中的局部存储空间

寄存器组是唯一被所有过程共享的资源(从上面我们看到栈帧是独立分配的)。

必须确保一个函数(调用者),调用另一个函数(被调用者)时,被调用者不会覆盖调用者随后会使用的寄存器值。

被调用者保存寄存器:寄存器 %r12-%r15, %rbx, %rbp 是被调用者保存寄存器(除了 %rsp,所有其他的寄存器都是调用者保存寄存器)。当 P 调用 Q 时,P 必须保存这些寄存器的值,保证它们的值在 Q 返回到 P 时,与 Q 被调用时是一样的。

关于被调用者保存寄存器和调用者保存寄存器的理解:

我们知道,任何函数都可以修改调用者寄存器,当函数 P 在某个此类寄存器中存储了局部数据,然后调用函数 Q,而函数 Q 也可以修改这个寄存器,所以,在调用之前保存好这个可能被修改的数据,是 P(调用者)的责任,调用者 P 通过被调用者保存寄存器来保存这些值。

那么 Q 为什么要保存被调用者保存寄存器的值,而不用保存调用者保存寄存器的值呢?

我们看到,调用者 P 通过被调用者保存寄存器来保证自己的正确性,因此,在有了安全保障下,Q 可以随心所欲的操作这些共享的调用者寄存器,而不必担心造成问题;但是,Q 可能也要调用其他函数,因此,Q 也可能需要保存自己当前的调用者保存寄存器中的值,这就要覆盖 P 的被调用者保存寄存器中的值了,如果覆盖了,那 P 谈何恢复自己的调用者保存寄存器的值呢?因此,Q 在调用其他函数前,先帮助 P 保存被调用者保存寄存器中的值,将之写在自己的栈帧上,Q 调用完成后,通过栈帧恢复 P 被调用者保存寄存器的值。也就是说,保证 P 能正常恢复,是 Q 的责任;保证 Q 能随意使用调用者保存寄存器,是 P 的责任。

例子:

 第一次调用,必须保存 x 的值,因为 Q 的第一参数使用 %rdi 传递,P 的第一参数也通过 %rdi 传递,P 先保存此时的 %rdi,然后 Q 就可以使用 %rdi,并且不会影响后续。通过 movq %rdi, %rbp。

第二次调用,使用 %rbx 保存 Q(y) 的返回值,随后,通过 movq %rbp, %rdi 指令,又将 P 的一参寄存器中的值设置为 x。

在函数的结尾,对 %rbp 和 %rbx 进行弹栈操作,恢复这两个被调用者保存寄存器的值。它们的弹出顺序与压栈顺序相反。

(3)递归调用

递归调用一个函数本身与调用其他函数是一样的。栈规则提供了一种机制,每次函数调用都有自己私有的状态信息存储空间(保存的返回地址和被调用者保存寄存器的值)。如果有需要,它还可以在自己的栈帧上为局部变量分配存储空间。而栈分配和释放的顺序自然地与函数调用-返回顺序匹配。

数组分配与访问

对于数据类型 T 和整型常数 N:

T array[N];
  • 在内存中分配一个 size*N 字节的连续区域(size 代表数据类型 T 所占字节大小)
  • 引入标识符 array,可以用 A 作为指向数组开头的指针

数组元素 i 放在内存地址为 addr+size*i 的地方(设数组首地址 A 的值为 addr)

1 下标运算

内存引用指令可以简化数组访问,假设 array 是 int 型数组,我们要得到 array[i],而 array 的首地址放在寄存器 %rdx 中,i 放在寄存器 %rcx 中,那么,取对应元素的指令为:

movl (%rdx, %rcx, 4), %eax

这条指令将 array[i] 的值放在寄存器 %eax 中。

等同于计算:

addr + 4*i

2 指针运算

C 语言允许对指针进行运算,计算出来的值会根据指针引用的数据类型的大小进行伸缩。

以上述数组为例,假如 p 是int* 类型指针,且指向 array 首地址,那么:

p+i = addr+i*4

3 多维数组

首先介绍一下 typedef 声明对于数组如何使用:

int a[5][3];

等同于:

typedef int ary_dec[3]; // ary_dec 被定义为含有 3 个 int 元素的整型数组
ary_dec[5]; // 5 个 int[3],等同于,int a[5][3]

对于多维数组:

T D[R][C];

它的数组元素 D[i][j] 的内存地址为(设 D 首地址为 addr,T 的数据类型大小为 size):

&D[i][j] = addr+(i*C+j)*size;

关于效率:

想想对于一个多维数组 int a[N][N] 来说,遍历数组取 a[i][j] 的值,每次 a[i][j] 都对应着一次 addr+(i*N+j)*4 的运算,而你如果使用 int* p = &a[0][0],那么,*p++ 同样可以遍历数组,每次你需要计算的是 p+4,只使用了一次加法操作,显然这样做效率更高。

GCC 对于定长多维数组的优化(级别 O1)正是使用这种取消索引改用指针的方式。对于动态数组,如果允许优化,GCC 同样能识别指针的步长,进行类似于上述的优化。

结构体

使用 struct 声明,将所有成员都存放在内存中一段连续的区域内,而指向结构体的指针就是结构体第一个成员第一个字节的地址。编译器负责维护每个结构体类型信息,指示每个字段(field)的字节便宜,从而对成员进行正确的内存引用。

例子:

struct rec {
  int i;
  int j;
  int a[2];
  int* p;
};

它的内存布局为:

 为了访问每个字段,编译器产生的代码要将结构的地址加上适当偏移,例如:对于一个 struct rec* 类型的变量 r,我们要把 r->i 复制到 r->j(假设 r 放在寄存器 %rdi 中):

movl (%rdi), %eax
movl %eax, 4(%rdi)

字段 i 的偏移为 0,字段 j 的偏移为 4,我们对它们的取值方式分别为:(%rdi) 和 4(%rdi)。

联合体

联合允许多种类型引用一个对象,用不同的字段来引用相同的内存块。

例子:

struct U {
  char c;
  int i[2];
  double v;
};
union U {
  char c;
  int i[2];
  double v;
};

在 x86-64 机器上,字段的偏移量,完整大小如下:

 如何应用?

我们实现知道一个数据结构中的不同字段的使用时互斥的,那么将这两个字段声明为联合,而不是结构体,将会减少空间的分配。

例如:对于一棵特殊的二叉树,它们的值都存储在叶子节点上,对于非叶节点,它们不含值,只含指向左右孩子的指针。那么,对于值的使用和对于左右孩子的使用显然可以互斥,因为,是叶子节点就不会有左右孩子,是非叶节点,就不会有值。

enum NodeType{
  LEAF,
  NO_LEAF
};

strcut Node {
  NodeType type;
  union info {
    struct internal {
      struct node* left;
      struct node* right;
    };
    double data[2];
  };
};

占用空间为 24 字节(包括了 padding)。

相较于 struct 声明:

struct node {
    struct node* left;
    struct node* right;
     double data[2];
};  

占用空间为 32 字节。

识别字节顺序:

union U {
  int a;  // 地址 0-3
  char c[2];  // 地址 0-1
};

我们声明一个 U 类型的变量,并对 c 字段进行赋值:

U u;
u.a = 0x00000001

U 类型的对象占 4 字节,如果使用小端存储,那么,此时 u 内容为:

00 00 00 01

 如果使用大端存储,那么,此时 u 内容为:

01 00 00 00

因此,我们检测 u.c[0] 和 u.c[1] 的值,就可以知道机器使用什么字节顺序。

int res = u.c[0] | u.c[1]; // 若为小端存储,结果为 1,若为大端存储,结果为 0
cout << res << endl;

注意:不论大小端存储,c 字符数组是从低地址到高地址的。

内存越界引用与缓冲区溢出

内存越界引用:C 对于数组引用不进行任何边界检查,而且局部变量和状态信息(例如返回地址和保存的寄存器的值)都存放在栈中。对越界数组元素的写操作会破坏存储在栈中的状态信息,造成严重的错误。

缓冲区溢出:通常,在栈中分配某个字符数组来保存一个字符串,但是字符串的长度超出了为数组分配的空间。

考虑下面这个程序:

 如果我们通过 gets 输入字符串的长度超过了 7,就会产生一些未定义的结果。

echo() 的汇编代码:

 可以看出,程序在栈上分配了 24 字节,buf 位于栈顶的位置,那么到返回地址之前,还有 16 字节是未被使用的,因此,可能产生的破坏如下:

 如果字符串赋值超过 23 个字符,占用了起始地址 24,那么返回地址就会被破坏,导致程序跳转到意想不到的位置。(实际上你使用这个程序运行是不会报错的,GCC 有栈保护机制) 

 对抗缓冲区溢出攻击:

栈随机化:程序开始时,在栈上分配一段 0-n 字节之间的随机大小空间。程序不使用这段空间,但是它会导致程序每次执行时后续的栈的位置发生了变化。分配的范围必须足够大,这样获得足够多的地址变化。但是又要足够小,以节省栈空间。在 Linux 系统,这种技术称为地址空间布局随机化(Address-Space Layout Randomization,ASLR),每次运行时,程序的不同部分,包括程序代码,库代码,栈,全局变量和堆数据,都会被加载到内存的不同区域。

栈破坏检测:在栈帧中任何局部缓冲区与状态之间存储一个特殊的金丝雀(canary)值,这个值是在程序每次运行时随机产生的。在恢复寄存器状态和从函数返回之前,程序检查这个金丝雀值是否被更改,如果是的,那么程序异常终止。

原文地址:https://www.cnblogs.com/icky1024/p/C-to-assemblers.html