程序是怎么执行的
计算机的设计目标是让程序高效、稳定、安全的运行,因此一个程序的运行涉及操作系统和几乎所有计算机核心硬件。对于计算机初学者而言,简单了解一个程序的运行过程可以更好的理解操作系统、计算机组成等专业课程,惭愧的是工作已经这么多年,竟然在 GPT 的帮助下才第一次理解程序从源代码到结束调用的一生
从源代码到机器指令
在了解程序是怎么执行之前,首先来了解一下程序是怎么来的
汇编语言的诞生
我们肯定听说过计算机世界只有0与1,确实如此,一段计算机可以执行的程序是这样的
11010000000000000000001111111111
10111001000000000000001111111111
01010010000000000000000000101000
10111001000000000000001011101000
01010010000000000000000001001000
10111001000000000000001111101000
10111001010000000000001011101000
10111001010000000000001111101001
00001011000010010000000100001000
10111001000000000000001111101000
10111001010000000000001100000000
10010001000000000000001111111111
11010110010111110000001111000000
一条可执行计算机指令通常由操作码和操作数,显然这样的二进制数字人类是无法处理的,于是计算机的先贤发明了人类与计算机的中间语言——编程语言,最开始是汇编语言,为了让程序可被人类理解,汇编语言对计算机指令表达做了几个改善
使用助记符表示机器指令,例如
MOV
、ADD
等,这些词汇比直接使用二进制或十六进制代码更容易理解和记忆使用标签和符号来表示内存地址、寄存器名、变量和函数名,使得程序的结构更加清晰
比如上面程序的第一条指令11010000000000000000001111111111
使用汇编语言表达变成
sub
是指令的操作码,表示“减法”操作第一个
sp
表示目标寄存器第二个
sp
表示要从中减去的寄存器#16
表示要减去的值是16
这条指令的意思是将当前 sp
(堆栈指针)的值减去 16
,并将结果存储回 sp
寄存器,换句话说它是在堆栈上“为后续操作分配空间”。上面的二进制代码用 ARM 汇编大概是这样
.section .data // 数据段, 如果需要的话可以在这里定义常量数据
.section .text // 代码段
.globl _start // 定义全局入口点
_start: // 程序入口
// 初始化变量
mov w0, #1 // a = 1, 将 1 存储到寄存器 w0
mov w1, #2 // b = 2, 将 2 存储到寄存器 w1
// 计算 c = a + b
add w2, w0, w1 // 将 w0 和 w1 相加,结果存储到寄存器 w2 (c)
// 返回 c 作为程序的退出代码
mov x0, w2 // 将 c (在 w2 中) 存储到 x0 中
mov x8, #93 // 系统调用号 93 对应于 exit
svc 0 // 调用内核执行退出
讲的很清晰,但还是不懂,因为汇编语言本质上是对机器语言的低级抽象,使用助记符(通常是英文单词或词根)来表示机器指令,试图指令更易于人类理解和编写
面向普通人的编程语言 C
汇编语言做到了人类可理解,但只限于对计算机硬件和操作系统很了解的专业人员,大部分人还是难以理解,于是出现了高级语言,也就是屏蔽了计算机底层硬件,更贴近于人类自然语言的编程语言,其中有划时代意义的就是 C 语言,上面的汇编语言用 C 语言表示如下
即使没有学过编程语言,也能大概看懂上面的程序原来是是在计算 1+2
现代的高级语言 Java
C 语言已经奠定了现代编程语言的基础,为了保证执行效率,C 语言允许直接访问底层硬件和操作系统的资源,比如通过指针操作内存、直接操作文件系统。但这种特性也使得 C 代码通常由两个问题
程序员手动管理内存,增加了内存泄漏、非法内存访问等风险
依赖于具体硬件的实现,从而降低了代码的可移植性
随着计算机从业人口变多,和计算机硬件提升,对编程语言本身执行效率的诉求在下降,而对编程语言容易理解、内存安全、编写效率(跨平台、内置库等)诉求在上升,而 Java 很好的满足了这些方面的诉求,迅速成为多数企业的第一选择
自然语言转为机器指令
C、Java 等满足了人类简单、高效编写程序的诉求,唯一的缺点是计算机不懂 C 或 Java,而编译器、链接器等充当了其中翻译官的角色
编译器的主要任务是将源代码转换为目标代码(机器代码或中间代码,如字节码),中间有几个子过程
词法分析、语法分析、语义分析,最终生成语法树
将语法树转换为中间代码,这种代码较为接近源代码,但不依赖于最终目标机器的具体实现
代码优化,以提高程序的效率,常见的优化有常量折叠、死代码消除等
目标代码生成将中间代码转换为特定机器的目标代码或机器代码
链接器的主要任务是将一个或多个目标文件组合在一起,形成一个可执行文件,也有几个子过程
确定符号(如函数和变量)的地址,解决不同对象文件之间对同一符号的引用
为目标文件中的所有符号分配地址,并更新这些符号的引用,以确保指向正确的内存地址
将所有目标文件和资源整合成一个完整的可执行文件
我们可以使用 objdump
工具反编译链接器生成的可执行文件
这样可以看到经过编译器、链接器处理的代码
sum: file format mach-o arm64
Disassembly of section __TEXT,__text:
0000000100003f74 <_main>:
100003f74: d10043ff sub sp, sp, #16
100003f78: b9000fff str wzr, [sp, #12]
100003f7c: 52800028 mov w8, #1
100003f80: b9000be8 str w8, [sp, #8]
100003f84: 52800048 mov w8, #2
100003f88: b90007e8 str w8, [sp, #4]
100003f8c: b9400be8 ldr w8, [sp, #8]
100003f90: b94007e9 ldr w9, [sp, #4]
100003f94: 0b090108 add w8, w8, w9
100003f98: b90003e8 str w8, [sp]
100003f9c: b94003e0 ldr w0, [sp]
100003fa0: 910043ff add sp, sp, #16
100003fa4: d65f03c0 ret
反编译后使用了 内存地址: 机器指令 汇编指令
的格式供人阅读,数字部分使用十六进制缩短长度,比如第一条指令可以这样分解
100003f74:表示这条指令存储在内存地址
100003f74
d10043ff:是这条指令的机器码表示
sub sp, sp, #16:是这条指令的汇编语言形式的表示,objdump 显示出来是为了方便人理解
综上所述,100003f74: d10043ff sub sp, sp, #16
这一行代码表示在内存地址 100003f74
处存储了一条指令,该指令的功能是将当前的堆栈指针 sp
减去 16
看完这一小节就知道需要知道几点
计算机可执行的机器指令是由编译器和链接器给翻译的
链接器不仅仅是把多个目标文件合并成一个可执行文件,还为指令确定了相对的内存地址
指令的执行需要地址 + 操作码 + 操作数
将可执行文件加载到内存
当用户或系统请求运行程序时,操作系统响应请求,创建进程,将可执行文件加载到内存中准备运行该程序,过程分为几步
创建进程控制块(PCB)
操作系统会为即将创建的新进程生成一个进程控制块,PCB 是操作系统用来管理进程的核心数据结构,包含了关于该进程的关键信息:
进程ID(PID)
进程状态(如就绪、运行、阻塞)
程序计数器
堆栈指针
内存管理信息(如页表或段表)
资源使用情况(如打开的文件描述符)
在生成 PCB 时,操作系统会进行一些简单的初始化,但并不会立即为程序分配内存,此时 PCB 只是处于一种准备状态,等待后续的步骤
读取可执行文件头信息
接下来操作系统将根据指定路径找到可执行文件,并读取其头部信息,程序头部信息包含了比如机器架构、程序入口、内存分配、动态链接等基本信息,这些信息对于操作系统的加载器(loader)可以正确地加载和执行程序至关重要
readelf 是一个用于查看 ELF (Executable and Linkable Format) 格式文件的命令行工具,广泛用于 Unix/Linux 系统。Mac 系统使用 Mach-O 文件格式,可以使用 otool 查看程序头信息
Mach header:
-------------------------------------------
magic : 0xfeedfacf
cputype : 16777228 (x86_64)
cpusubtype : 0 (no specific subtype)
caps : 0x00
filetype : 2 (Executable)
ncmds : 16 (Number of Load Commands)
sizeofcmds : 744 (Size of Load Commands in bytes)
flags : 0x00200085 (Flags)
-------------------------------------------
每个可执行文件都包含一个入口点信息(main 函数),这个入口点是程序的第一条指令地址,操作系统会将其保存在 PCB 中
分配内存空间
根据文件头信息,操作系统为进程的逻辑地址空间分配必要的信息,通常包括代码段、数据段、堆和栈等
代码段:也称 .text 段,存储程序的可执行代码(机器指令),通常是只读的,防止程序运行时修改自身的代码,代码中函数体、循环、条件判断等,都被编译为指令并存储在代码段中
数据段:存储已初始化的全局和静态变量,是可读可写的,程序运行时可以修改其中的变量值,
int a = 5;
这样的全局变量会被存储在数据段中堆:用于动态内存分配,堆的大小在程序运行时动态变化,在 C 语言中程序员使用
malloc()
和free()
手动管理栈:用于管理函数调用及局部变量,每当函数被调用时,相应的局部变量会被压入栈,函数返回时这些变量会被弹出。栈的大小通常是固定的,由系统预设,在函数调用嵌套太深时可能会导致栈溢出,这就是我们常见的 stackoverflow 报错
如果有足够的内存空间,操作系统将进行物理内存分配,没有的话要用进行内存回收或者使用虚拟内存等技术腾挪出可用内存空间
为程序创建一个新的进程逻辑内存空间,分配必要的代码段、数据段、堆和栈区域
在分页系统中更新进程的页表,以便将物理内存页面映射到进程的逻辑地址空间
物理内存地址是计算机硬件中实际存在的内存地址,这些地址直接映射到物理内存芯片上的特定位置。
逻辑内存地址(也称虚拟地址)是程序生成的地址,这些地址是相对于进程的虚拟地址空间的,这样每个进程可以认为自己拥有独立的、连续的内存地址空间,进而简化了编程模型
在程序实际执行访问内存时候,逻辑地址通过内存管理单元(MMU)被转换为物理地址
进一步初始化,等待调度
一旦内存被分配,操作系统会进行更复杂的初始化,例如:
设置权限标志(可读、可写、可执行等)
初始化未初始化的变量
准备堆和栈
然后进程的 PCB 会被更新以反映新的状态,例如进程现在处于就绪状态,并且已经设置了程序的入口点、页表等信息,PCB 加入到就绪队列等待 CPU 调度
指令执行
指令的执行需要多个硬件合作配合,大体可以分为几个流程
取指(Fetch):从内存中获取指令并传输到指令寄存器(IR)
解码(Decode):解析指令的操作码和操作数
执行(Execute):执行操作,比如算术计算或逻辑操作
访存(Memory Access):对于需要访问内存的指令,执行读/写操作
写回(Write Back):将结果写回寄存器
1. 取指(Fetch)
取指需要程序计数器(Program Counter, PC)和指令寄存器(Instruction Register, IR)的配合:
程序计数器指向当前执行指令的地址(初始化的是 PCB 存储的程序入口指令地址)
CPU 从内存中读取这条指令并将其存入指令寄存器中
PC 始终保存下一条要执行的机器指令的地址。每次 CPU 执行完一条指令后,PC 都会更新,以指向下一条指令
在每个指令周期开始时,CPU 使用 PC 中存储的地址从内存中读取指令,将指令加载到 IR,以便 CPU 解码和执行,因此 IR 中也只有一条指令
2. 解码(Decode)
CPU 包含一个指令解码单元,用以解析指令的具体操作,指令通常由操作码(Opcode)和操作数组成:
操作码表示要执行的操作类型(加法、减法、跳转等)
操作数是指参与操作的数据或者内存地址
3. 执行(Execute)
根据指令类型,CPU 启动相应的执行单元:
算术逻辑单元(ALU):用于算术和逻辑运算,如加法、减法、逻辑与、逻辑或等
寄存器:可以直接在寄存器中进行操作,加速数据处理
控制单元(Control Unit):根据解码结果控制各个部件执行相应的操作
4. 存储结果(Store)
执行完指令后,结果可能需要存放:
寄存器:直接存放在 CPU 内部的寄存器中
内存:将结果存回内存中的特定地址
5. 更新程序计数器(PC)
在执行完一条指令后,程序计数器自动更新,指向下一条将要执行的指令。这通常是通过加一实现的,除非是执行了跳转指令,这时会直接修改程序计数器
6. 重复循环
CPU 重复进行取指、解码、执行、存储结果和更新程序计数器的过程,直到程序结束或被外部中断
CPU 内部有一个时钟生成器(Timer、定时器),用于提供稳定的时钟信号,这个时钟信号以固定的频率振荡,控制着 CPU 的操作节奏。每个时钟信号的脉冲称为一个时钟周期,指令的各个阶段(取指令、解码、执行等)通常与时钟信号的脉冲同步进行
机器周期是指 CPU 完成一基本操作所需要的时间,这些基本操作可以包括取指令、执行指令、读写内存等,一个机器周期可以包括多个时钟周期
7. 外部设备和中断处理
在多任务操作系统中,时钟生成器可以生成定期中断,通知 CPU 某项任务需要处理。这使得操作系统能够暂停当前任务,切换到其他任务或处理系统事件,让操作系统实现时间片轮转调度
如果程序需要与外部设备交互(例如 I/O 操作),发出系统调用,CPU 会暂停当前执行的任务,转而处理中断请求,执行相应的中断服务程序,这时系统会从用户模式切换到内核模式,以执行安全的操作
当外部交互处理完成时候,利用时钟生成器生成的定期中断,操作系统恢复之前保存的上下文信息,将进程切换回原来的程序继续执行
8. 结束和清理
程序完成后,控制权通过系统调用返回操作系统
释放资源:操作系统释放为程序分配的内存和其他资源
退出状态:程序可以返回一个状态码,以指示成功或失败
小结
程序的生命周期涵盖了从编写到结束的多个阶段,包括编写、编译、加载、链接、准备运行、执行、暂停与切换、结束和资源回收等,包含了程序在操作系统中如何执行和管理以及计算机的各个核心硬件是怎么配合的各种知识,回想起来大学阶段如果围绕如何让程序运行来展开计算机组成和操作系统的教学,这两门课也许会有趣很
评论