找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

456

积分

0

好友

58

主题
发表于 昨天 12:56 | 查看: 7| 回复: 0

我们平时编写的C、C++、Java等代码属于高级语言,虽然我们人类能轻松理解,但计算机却需要通过“翻译”才能读懂。这些代码必须经过编译器转换成二进制指令,CPU才能执行。

反之,我们学会了高级语言,并不意味着理解了计算机实际的运行步骤。

编程语言层次对比图

计算机真正能够理解的是低级语言,它专门用来控制硬件。汇编语言就是低级语言,它直接描述并控制CPU的运行。如果你想了解CPU到底执行了哪些操作,以及代码的精确运行步骤,学习汇编语言是必经之路。

汇编语言本身并不易学,甚至连清晰简明的入门资料都难以寻觅。本文尝试用最易懂的方式,为你解释CPU是如何执行代码的。

汇编语言代码示例

一、汇编语言是什么?

我们知道,CPU只负责计算,本身不具备智能。你输入一条指令,它就运行一次,然后停下来,等待下一条指令。

这些指令都是二进制的,称为操作码,例如加法指令可能就是00000011编译器的作用,正是将高级语言编写的程序,翻译成一条条这样的操作码。

对人类而言,二进制程序是不可读的,我们根本无法直观看出机器做了什么。为了解决可读性问题,以及满足偶尔的编辑需求,汇编语言便应运而生。

汇编代码调试界面

“汇编语言是二进制指令的文本形式”,它与机器指令是一一对应的关系。例如,加法指令00000011写成汇编语言就是ADD。只要将其还原成二进制,汇编语言就可以被CPU直接执行,因此它是最底层的低级语言。

二、来历

最早的时候,编写程序就是手写二进制指令,然后通过各种开关输入计算机。后来,发明了纸带打孔机,通过在纸带上打孔来输入指令。

为了解决二进制指令的可读性问题,工程师曾将指令写成八进制,但可读性依然不佳。很自然地,最后发展为用文字表达,比如加法指令写成ADD,内存地址也用标签表示。

这样一来,就多出一个将文字指令翻译回二进制的步骤,这个步骤称为assembling,完成该步骤的程序叫做assembler。它处理的文本自然就叫做assembly code,标准化后称为assembly language,缩写为asm,中文译为汇编语言。

计算机汇编代码示例

每一种CPU的机器指令集都是不同的,因此对应的汇编语言也不一样。 本文介绍的是目前最常见的x86汇编语言,即Intel公司CPU使用的那一种。

三、寄存器

学习汇编语言,首先必须了解两个核心概念:寄存器内存模型

先来看寄存器。CPU本身只负责运算,不负责储存数据。数据通常储存在内存中,CPU需要时再去读写。

但是,CPU的运算速度远高于内存的读写速度。为了避免被拖慢,CPU都自带一级和二级缓存,可以看作是速度较快的内存。

然而,CPU缓存仍然不够快,且数据在缓存中的地址不固定,每次寻址也会影响速度。

因此,除了缓存,CPU还自带了寄存器,用来储存最频繁使用的数据。 也就是说,那些被高频读写的数据(比如循环变量),会放在寄存器里。CPU优先读写寄存器,再由寄存器与内存交换数据,这样效率最高。

计算机存储层次结构

寄存器不依靠地址区分数据,而是依靠名称。每个寄存器都有自己的名字,我们告诉CPU去哪个具体的寄存器拿数据,速度是最快的。有人将寄存器比喻为CPU的“零级缓存”。

四、寄存器的种类

早期的x86 CPU只有8个寄存器,且各有专用。现在的寄存器数量已过百,大多变成了通用寄存器,但早期寄存器的名字被保留了下来。

  • EAX
  • EBX
  • ECX
  • EDX
  • EDI
  • ESI
  • EBP
  • ESP

上述8个寄存器中,前七个都是通用的。ESP寄存器有特定用途,用于保存当前栈的地址(详见下一节)。

CPU寄存器示意图

我们常听到32位CPU、64位CPU的说法,其实指的就是寄存器的大小。32位CPU的寄存器大小就是4字节。

五、内存模型:Heap

寄存器只能存放少量数据,大多数时候,CPU需要指挥寄存器直接与内存交换数据。因此,除了寄存器,还必须理解内存如何储存数据。

程序运行时,操作系统会分配一段内存,用于储存程序代码和运行产生的数据。这段内存有起始地址和结束地址。

内存起始与结束地址示意图

程序运行中,对于动态的内存请求(如新建对象,或使用malloc命令),系统会从预先分配的内存中划出一部分。规则是从起始地址开始分配。

例如,用户请求10字节内存,则从起始地址0x1000分配到0x100A;再请求22字节,则接着分配到0x1020

Heap内存分配示意图

这种因用户主动请求而划分出的内存区域,叫做 Heap(堆) 。它从起始地址开始,由低位地址向高位地址增长。Heap的一个重要特点是不会自动消失,必须手动释放,或由垃圾回收机制回收。

六、内存模型:Stack

除了Heap,其他内存占用称为 Stack(栈) 。简单说,Stack是由于函数运行而临时占用的内存区域。

Stack与Heap内存布局图

请看下面的例子。

int main()
{
    int a = 2;
    int b = 3;
}

上面代码中,系统开始执行main函数时,会为它在内存中建立一个,所有main的内部变量(ab)都保存在这个帧里。main函数执行结束后,该帧被回收,内部变量占用的空间也随之释放。

main函数帧示意图

如果函数内部调用了其他函数呢?

int main()
{
   int a = 2;
   int b = 3;
   return add_a_and_b(a, b);
}

上面代码中,main函数调用了add_a_and_b函数。执行到这一行时,系统也会为add_a_and_b新建一个帧,储存它的内部变量。此时,同时存在两个帧:mainadd_a_and_b一般来说,调用栈有多少层,就有多少帧。

main与add_a_and_b函数帧示意图

等到add_a_and_b运行结束,它的帧被回收,系统回到main函数刚才中断的地方继续执行。通过这种机制,实现了函数的层层调用,且每一层都能使用自己的本地变量。

所有的帧都存放在Stack中。由于帧是一层层叠加的,所以叫“栈”。生成新的帧叫“入栈”(push);栈的回收叫“出栈”(pop)。Stack的特点是,最晚入栈的帧最早出栈(最内层的函数调用最先结束),这就是“后进先出”的数据结构。

每一次函数执行结束,就自动释放一个帧;所有函数执行完毕,整个Stack就都释放了。

栈的Push操作示意图

栈的Pop操作示意图

Stack是从内存区域的结束地址开始,从高位地址向低位地址分配。例如,内存结束地址是0x8000,第一帧占16字节,则下一帧从0x7FF0开始;第二帧占64字节,则地址移动到0x7FB0

Stack地址分配示意图

七、CPU 指令

7.1 一个实例

了解了寄存器和内存模型,现在可以看看汇编语言的具体模样了。下面是一个简单程序example.c

int add_a_and_b(int a, int b) {
   return a + b;
}

int main() {
   return add_a_and_b(2, 3);
}

使用gcc将其转换为汇编语言。

$ gcc -S example.c

该命令会生成一个文本文件example.s,里面就是汇编语言,包含几十行指令。一个高级语言的简单操作,底层可能由几个甚至几十个CPU指令构成。CPU依次执行这些指令,完成该操作。

example.s简化后大致如下:

_add_a_and_b:
   push   %ebx
   mov    %eax, [%esp+8]
   mov    %ebx, [%esp+12]
   add    %eax, %ebx
   pop    %ebx
   ret

_main:
   push   3
   push   2
   call   _add_a_and_b
   add    %esp, 8
   ret

可以看到,原程序的两个函数对应两个标签_add_a_and_b_main。每个标签内是该函数转换成的CPU运行流程。

每一行是CPU执行的一次操作,又分为两部分。以其中一行为例:

push   %ebx

这里,push是CPU指令,%ebx是该指令用到的运算子。一个CPU指令可以有零到多个运算子。

下面我们逐行讲解这个汇编程序。

7.2 push 指令

根据约定,程序从_main标签开始执行。这时会在Stack上为main建立一个帧,并将Stack栈顶地址写入ESP寄存器。之后要写入main帧的数据,都会放在ESP寄存器保存的地址。

然后,开始执行第一行代码。

push   3

push指令用于将运算子放入Stack,这里就是将3写入main帧。

看似简单,push指令有一个前置操作:它会先取出ESP寄存器里的地址,将其减去4个字节,再将新地址写回ESP寄存器。

使用减法是因为Stack从高地址向低地址发展;减4字节是因为3int类型,占4字节。得到新地址后,3会被写入这个地址开始的四个字节。

push   2

第二行类似,push指令将2写入main帧,紧挨着前面写入的3。这时,ESP寄存器的值会再减去4字节(累计减去8)。

栈帧与esp寄存器位置图

7.3 call 指令

第三行的call指令用来调用函数。

call   _add_a_and_b

上面的代码表示调用add_a_and_b函数。程序会去找_add_a_and_b标签,并为该函数建立一个新的帧。

接着开始执行_add_a_and_b的代码。

push   %ebx

这行表示将EBX寄存器里的值,写入_add_a_and_b帧。这是因为后面要用到这个寄存器,所以先把当前值保存起来,用完后再恢复。push指令会再将ESP寄存器的地址减去4字节(累计减去12)。

7.4 mov 指令

mov指令用于将一个值写入某个寄存器。

mov    %eax, [%esp+8]

这行代码表示:先将ESP寄存器里的地址加上8字节,得到一个新地址,然后按照这个地址从Stack取出数据。根据前面的步骤,可以推算出这里取出的是2,再将2写入EAX寄存器。

下一行代码做类似操作。

mov    %ebx, [%esp+12]

上面的代码将ESP寄存器的值加12字节,然后按此地址从Stack取出数据3,将其写入EBX寄存器。

7.5 add 指令

add指令用于将两个运算子相加,并将结果写入第一个运算子。

add    %eax, %ebx

上面的代码将EAX寄存器的值(2)加上EBX寄存器的值(3),得到结果5,再将这个结果写入第一个运算子EAX寄存器。

7.6 pop 指令

pop指令用于取出Stack最近一个写入的值(即最低地址的值),并将这个值写入运算子指定的位置。

pop    %ebx

上面的代码表示,取出Stack最近写入的值(即EBX寄存器原始的原始值),再将这个值写回EBX寄存器(因为加法已完成,EBX寄存器用不到了)。

注意,pop指令还会将ESP寄存器里的地址加4,即回收4个字节。

7.7 ret 指令

ret指令用于终止当前函数的执行,将运行权交还给上层函数,即回收当前函数的帧。

ret

该指令没有运算子。

随着add_a_and_b函数终止,系统回到main函数中断的地方继续执行。

add    %esp, 8

上面的代码表示,将ESP寄存器里的地址,手动加上8字节,再写回ESP寄存器。因为前面的pop操作已回收4字节,这里再回收8字节,等于全部回收。

ret

最后,main函数运行结束,ret指令退出程序执行。

希望这篇汇编语言入门指南,能帮助你更好地理解代码在计算机底层的运行逻辑。如果你想深入探讨更多计算机基础知识,欢迎来云栈社区交流分享。




上一篇:Python面向对象编程详解:类、继承与封装的核心概念与实例
下一篇:Cursor提效:前端需求开发工作流实践
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-18 11:24 , Processed in 0.350079 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表