汇编语言:看懂高级代码背后发生了什么

为什么会有汇编语言

学习汇编语言之前,看看他为什么诞生

我们知道,CPU只能执行机器码,那看下面的机器码

text 复制代码
8B 45 F8
03 45 F4
89 45 EC

谁能看懂?

那么 汇编语言 它出现了, 汇编语言就是给机器码起的人类可读的名字。

一般可以这样理解:在同一套 CPU 架构下,一条机器指令通常对应一条汇编指令

  • c语言: a = b + c ->通常对应 3 - 10 条机器指令
  • 汇编语言: 一条汇编 -> 一条机器指令

汇编器和反汇编器

汇编器和反汇编器

正向:

text 复制代码
汇编代码 → [汇编器] → 机器码(本机代码)

逆向:

text 复制代码
机器码 → [反汇编器] → 汇编代码

注意: 机器码 -> 汇编代码 可以反汇编出对应指令,但变量名、注释、函数结构这些信息通常回不来;而 汇编代码 -> c 代码 更无法完全还原

为什么?

用编译器生成汇编

现在我们可以不需要手写汇编,用编译器就可以直接把 C 代码翻译给你看

比如:参数: -S,它的作用是: 生成一个 hello.s文件,里面是汇编代码

text 复制代码
gcc -S hello.c

举个例子:

c 复制代码
int AddNum(int a, int b){
	return a + b
}

void MyFunc(){
	int c;
	c = AddNum(123,456);
}

通过 -S 编译后

text 复制代码
 AddNum:
      pushl   %ebp
      movl    %esp, %ebp
      movl    8(%ebp), %eax    
      addl    12(%ebp), %eax   
      popl    %ebp
      ret               
      
 MyFunc:
      pushl   %ebp
      movl    %esp, %ebp
      subl    $8, %esp
      movl  $456, 4(%esp) # 压入第二个参数
      movl  $123, (%esp) # 压入第一个参数
      call    AddNum  
      movl    %eax, -4(%ebp)
      leave
      ret

这里再次说明了,一行 c 代码,由多条汇编指令构成

参数为什么从右往左压栈

在上面我们可以看到

text 复制代码
先 movl    $456, 4(%esp)
再 movl    $123, (%esp)

正常来说,应该是 123 去加上 456,为什么会是这个顺序?

这里先知道两概念:压栈弹栈

简单来说:

  • 压栈 = esp 下移 + 写数据
  • 弹栈 = 读数据 + esp 上移

那么,如果我们先写入 123

text 复制代码
栈的位置:
456 ->后压 栈顶
123 ->先压

那么我们读取第一个参数,也就是 123 的时候,他并不在栈顶,需要我们去找

先写入 456

text 复制代码
栈的位置: 
123 ->后压 栈顶
456 ->先压

这样能保证第一个参数 永远固定 在 栈顶,函数可以用相同的偏移量直接取

printf 这类函数,参数数量不固定:

c 复制代码
printf("%d %d %d", a, b, c);
printf("%s", str);

右到左压栈后,格式字符串(第一个参数)永远在栈顶。printf 先读格式字符串,再根据里面有几个 %d决定往后读几个参数。

如果顺序反了,printf 不知道格式字符串在哪,也就无法决定读多少个参数。

伪指令和注释

伪指令:给汇编器看的

比如:

text 复制代码
.section .text   
# 告诉汇编器:下面的内容放进代码段

.section .data   
# 下面的内容放进数据段
 
 myNum: .long 100  
 # 数据段中声明一个变量,值为 100

注释:给人看的

比如:

text 复制代码
  movl  8(%ebp), %eax    # 取第一个参数 a,放进 eax
  addl  12(%ebp), %eax   # 加上第二个参数 b

当我们用编译器参数 -S 生成汇编时,有些说明性注释可能来自编译器输出,也可能是我们自己为了阅读方便加上的

在最终生成的可执行文件中,注释会在编译时直接丢弃,伪指令只是给汇编器看的说明,处理完之后也不会作为普通指令存在

所以,在最终的可执行文件中,通常看不到源码里的注释、变量名、说明和伪指令,这也说明了为什么逆向工程这么难

汇编语言的语法

汇编语言的语法:操作码 操作数

操作码:

  • movl -> 搬移数据(move)
  • addl -> 加法(add)
  • subl -> 减法(sub)
  • cmpl -> 比较(compare)
  • jmp -> 跳转(jump)
  • call -> 调用函数
  • ret -> 函数返回

末尾的 l 表示操作 32位数据(long)

操作数:

  1. 立即数(直接写入的数据, 开头)开头) 开头)123 ->数字 123
  2. 寄存器(CPU内部的临时格子,% 开头) %eax -> eax 寄存器里的值
  3. 内存地址(括号表示去该地址取值) (%eax) -> 去 eax 里存的地址,取那里的值 4(%eax) -> eax 的值加 4,去那个地址取值

movl 指令

movl 是用得最多的指令,意思是搬移 32 位的数据

格式: movl 源, 目标

比如:

text 复制代码
#1.把立即数放进寄存器
movl $123, %eax   #eax= 123

#2.寄存器之间复制
movl %ebx, %eax  # eax = ebx

#3.把立即数写入内存
movl $456, 4(%esp) #内存地址[esp+4]= 456

#4.从内存读进寄存器
movl 8(%ebp), %eax #eax=内存地址[ebp+8]的值

esp 和栈

esp 是栈的核心

esp 永远指向栈顶,栈向低地址方向增长

压入数据的两个步骤

text 复制代码
1. esp 下移,腾出空间
    subl  $8, %esp       
    # esp = esp - 8(腾出 8 字节)
2. 用 movl 写入数据
    movl  $456, 4(%esp)  # 写入第一个数据
    movl  $123, (%esp)   # 写入第二个数据

完整的函数调用过程

看完前面的,来一个完整的函数调用过程

text 复制代码
c = AddNum(123, 456);

  subl  $8,    %esp        # 栈上腾出空间
  movl  $456,  4(%esp)     # 右到左:先压 456
  movl  $123,  (%esp)      # 再压 123
  call  AddNum             # 调用函数

call 做了什么?

  1. 把 下一条指令的地址 压入栈
  2. 跳转到 AddNum 的入口

相当于

text 复制代码
返回地址
123
456

进入函数后:建立 栈帧

text 复制代码
AddNum:
    pushl %ebp           
    # 保存调用者的 ebp
    
    movl  %esp, %ebp     
    # ebp = esp,标记当前函数的栈底

执行完之后:

text 复制代码
旧 ebp
返回地址
123
456

ret 指令:返回

  1. 从栈顶弹出返回地址
  2. 跳转到那个地址
  3. 回到 call 的下一条指令继续执行

上面的图说的是 调用者(MyFunc)的流程,接下来是 被调用者(AddNum) 内部 具体做了什么

在 MyFunc 里

text 复制代码
call AddNum
movl %eax, -4(%ebp)

函数结束后

text 复制代码
popl %ebp 
ret

全局变量和局部变量

全局变量:定义在 .data段中

  • 有固定的内存地址
  • 程序运行期间一直存在
  • 任何函数都能访问
c 复制代码
int num = 100;
void funcA(){ num = 200 }

局部变量:定义在 栈 中

c 复制代码
void funcA(){
	int x = 10
}

但是,栈上的数据并不会清零

当 esp 上移时,局部变量的空间还在那, 数据依旧在那

这就是为什么局部变量不初始化就使用,会读到垃圾值

c 复制代码
void func(){
	int x;
	printf("%d",x);
  //可能打印出上一个函数留下的数据
}

可以看出,全局变量很方便,那我们全用全局变量不好吗?

全用全局变量会出现一个问题: 追踪困难

c 复制代码
int count = 0;

void funcA() { count++; }
void funcB() { count = 0; }
void funcC() { count *= 2; }

当 count 的值出错了,你需要调查所有用过 count 的函数

而 局部变量 没有这个问题,因为只有一个函数能改他,对应的也能容易排查

还可能出现更严重的问题: 多线程

  1. 线程 A 读 count = 10
  2. 线程 B 同时把 count 改成 0
  3. 线程 A 继续用它以为是 10 的 count 做计算

if 和 for 的底层

for循环,在汇编中其实就是 比较 + 跳转

if 判断也是一样的,也是 比较 + 跳转

乍一看,if 和 for 都差不多,都是 比较 + 跳转,但还是有区别的

而我们的 CPU 从头到尾只做一件事:

text 复制代码
取指令 → 执行 → PC移动 → 取下一条指令 → 执行 →  ...

所以,所有的高级语言结构,其本质都是控制 PC 跳到哪里去

text 复制代码
顺序执行  → PC 每次 +1,往前走
if/else   → 条件不满足,PC 跳过一段
for/while → 条件满足,PC 跳回去
函数调用  → PC 跳到函数入口,返回时跳回来

总结

最后,如果只用一句话去理解 汇编语言 ,那就是: 它不是为了让你每天手写机器指令,而是为了让你看懂高级语言背后到底发生了什么。

像平时我们写 C 语言、Java 或者其他高级语言时,看到的是变量、函数、if、for 这些结构。但到了 CPU 真正执行的时候,这些东西都会被拆成更底层的指令,比如搬移数据、比较、跳转、调用函数、返回地址

相关推荐
小宇子2B17 天前
一个 7 行的 C 函数,是怎么一路变成 CPU 上的电信号
c·汇编语言
YangWeiminPHD1 个月前
从零开始构建你的第一个8051汇编程序:掌握A51汇编语言核心知识
51单片机·汇编语言·金水32051编译器
Sss_Ass2 个月前
跟着老师不迷路系列---跟着李述铜老师学习汇编语言之基本汇编程序符号绑定语句
学习·嵌入式·汇编语言·李述铜·符号绑定语句
Sss_Ass2 个月前
跟着老师不迷路系列---跟着李述铜老师学习汇编语言之内核寄存器简介
学习·学习方法·汇编语言·李述铜
Sss_Ass2 个月前
跟着老师不迷路系列——跟着李述铜老师学习汇编语言之基本汇编程序section指令
学习·学习方法·汇编语言·李述铜·section指令
Sss_Ass2 个月前
跟着老师不迷路系列---跟着李述铜老师学习汇编语言之基本汇编程序指令集分类
开发语言·学习·学习方法·汇编语言·李述铜
alanesnape4 个月前
CPU眼中的i++ 与 ++i
cpu·汇编语言
肆忆_4 个月前
刨根问底:从反汇编看 C++ 对象的生与死
汇编语言
zhongvv5 个月前
对单片机C语言指针的一些理解
c语言·数据结构·单片机·指针·汇编语言