程序是怎么跑起来的

前言

本篇文章从整体流程上描述一下一个程序是怎么在计算机中运行的,整个流程分为三大块:

  • 程序的创造
  • 程序的编译
  • 程序的运行

程序的创造

一般来说,创造一个程序是代码工程师的责任,虽然现在有很多工具可以不经过编码就能创造一个可运行的程序甚至游戏。但是归根揭底,最终的程序都回归到一门编程语言上。

一般的情况,这是一门高级编程语言,可以是C/C++,可以是Python,可以是Object-C/Swift,也可以是Java等等。

经过前期的调研,立项,产品设计,UI设计,代码编写,产品测试等等一系列复杂的程序,多个人的集体合作,经过一定的时间以后,我们这个产品可能已经算是完成。

但是一个程序的创造从代码编写完成就已经结束了,测试使用的一般是经过编译后的产品,也就是已经经过了第二阶段

程序的编译

程序的编译普遍来说是编译器的责任,几乎没有人会手动进行代码的编译工作。并且绝大部分的开发其实是使用IDE进行编译的。从源代码到可运行的程序需要经过四步的工作,具体的流程可以参考文章代码到可执行文件的流程概述:
我们在整个过程中以下面的代码作为例子进行描述:

#include 
unsigned long sum(int* src, int size);
int main(int argv, char** argc)
{	
	int a[]={1,2,3,4,5,6,7,8,9,10};
	printf("a=%lu\n",sum(a,sizeof(a)/sizeof(int)));
 	return 0;
}

unsigned long sum(int* src, int size)
{
	unsigned long result = 0;
	for(int i = 0;i < size;i++)
	{
		result += *(src++);
	}
	return result;
}
  • 源码阶段~预处理阶段
    这一步是由编译器的预处理器部分进行处理的,基本就是宏的替换,生成的文件为.i文件,一般这个文件比较大,因为包含了嵌套很多层的头文件信息,就像上面的代码,包含了头文件stdio.h,而stdio.h头文件又包含了别的头文件,只要是没有包含过的头文件都会按顺序依次添加进来。因为预处理后的文件太大,就不粘贴代码了,但是我看了一下,预处理后的文件有18Kb之多。
  • 预处理阶段~编译阶段
    这一阶段是将预处理后的文件编码成汇编语言显示的汇编文件,也就是.s文件,汇编文件是我们最后能操作的文件了(应该没有人还在敲机器指令),汇编文件由一系列的段组成,不同的段包含不同的数据,代码数据,全局数据,符号表都包含在段中。
    汇编文件也是我们在进行代码优化能够对比的文件,比如对于switch的使用,对于递归的优化等,下面的代码是前面的例子生成的汇编代码
    注意:汇编代码已经去掉了没有使用到的头文件的信息,包括变量和函数的声明
    	.file	"test1.c"
    	.text
    	.globl	sum
    	.type	sum, @function
    sum:
    .LFB14:
    	.cfi_startproc
    	movl	$0, %edx
    	movl	$0, %eax
    	jmp	.L2
    .L3:
    	movslq	(%rdi), %rcx
    	addq	%rcx, %rax
    	addl	$1, %edx
    	leaq	4(%rdi), %rdi
    .L2:
    	cmpl	%esi, %edx
    	jl	.L3
    	rep ret
    	.cfi_endproc
    .LFE14:
    	.size	sum, .-sum
    	.section	.rodata.str1.1,"aMS",@progbits,1
    .LC0:
    	.string	"a=%lu\n"
    	.text
    	.globl	main
    	.type	main, @function
    main:
    .LFB13:
    	.cfi_startproc
    	subq	$56, %rsp
    	.cfi_def_cfa_offset 64
    	movl	$1, (%rsp)
    	movl	$2, 4(%rsp)
    	movl	$3, 8(%rsp)
    	movl	$4, 12(%rsp)
    	movl	$5, 16(%rsp)
    	movl	$6, 20(%rsp)
    	movl	$7, 24(%rsp)
    	movl	$8, 28(%rsp)
    	movl	$9, 32(%rsp)
    	movl	$10, 36(%rsp)
    	movl	$10, %esi
    	movq	%rsp, %rdi
    	call	sum
    	movq	%rax, %rdx
    	movl	$.LC0, %esi
    	movl	$1, %edi
    	movl	$0, %eax
    	call	__printf_chk
    	movl	$0, %eax
    	addq	$56, %rsp
    	.cfi_def_cfa_offset 8
    	ret
    	.cfi_endproc
    .LFE13:
    	.size	main, .-main
    	.ident	"GCC: (Ubuntu 4.8.4-2ubuntu1~14.04.4) 4.8.4"
    	.section	.note.GNU-stack,"",@progbits
    
  • 汇编代码~机器代码
    这一步编译器会把我们上面的汇编代码编译成机器代码,也就是.o文件,.o文件的文件组成没什么变化,还是由段组成,就是汇编指令被替换成了机器指令,也就是一条条的二进制数据,为什么是一条条呢,因为所有的汇编指令都有编码规则,就是一条汇编指令对应指定字节数的二进制序列,这个对应关系是固定的,但是不同的指令可以有不同的长度,这一步是编码,后面计算机执行程序的时候还有译码阶段。
    下面是.o文件的部分代码,也就是我们的代码数据,这只是一小部分,因为还有系统函数的调用,以及需要和别的库链接的符号表等信息。
    000000000040055d :
      40055d:	ba 00 00 00 00       	mov    $0x0,%edx
      400562:	b8 00 00 00 00       	mov    $0x0,%eax
      400567:	eb 0d                	jmp    400576 
      400569:	48 63 0f             	movslq (%rdi),%rcx
      40056c:	48 01 c8             	add    %rcx,%rax
      40056f:	83 c2 01             	add    $0x1,%edx
      400572:	48 8d 7f 04          	lea    0x4(%rdi),%rdi
      400576:	39 f2                	cmp    %esi,%edx
      400578:	7c ef                	jl     400569 
      40057a:	f3 c3                	repz retq 
    
    000000000040057c 
    : 40057c: 48 83 ec 38 sub $0x38,%rsp 400580: c7 04 24 01 00 00 00 movl $0x1,(%rsp) 400587: c7 44 24 04 02 00 00 movl $0x2,0x4(%rsp) 40058e: 00 40058f: c7 44 24 08 03 00 00 movl $0x3,0x8(%rsp) 400596: 00 400597: c7 44 24 0c 04 00 00 movl $0x4,0xc(%rsp) 40059e: 00 40059f: c7 44 24 10 05 00 00 movl $0x5,0x10(%rsp) 4005a6: 00 4005a7: c7 44 24 14 06 00 00 movl $0x6,0x14(%rsp) 4005ae: 00 4005af: c7 44 24 18 07 00 00 movl $0x7,0x18(%rsp) 4005b6: 00 4005b7: c7 44 24 1c 08 00 00 movl $0x8,0x1c(%rsp) 4005be: 00 4005bf: c7 44 24 20 09 00 00 movl $0x9,0x20(%rsp) 4005c6: 00 4005c7: c7 44 24 24 0a 00 00 movl $0xa,0x24(%rsp) 4005ce: 00 4005cf: be 0a 00 00 00 mov $0xa,%esi 4005d4: 48 89 e7 mov %rsp,%rdi 4005d7: e8 81 ff ff ff callq 40055d 4005dc: 48 89 c2 mov %rax,%rdx 4005df: be 84 06 40 00 mov $0x400684,%esi 4005e4: bf 01 00 00 00 mov $0x1,%edi 4005e9: b8 00 00 00 00 mov $0x0,%eax 4005ee: e8 6d fe ff ff callq 400460 <__printf_chk@plt> 4005f3: b8 00 00 00 00 mov $0x0,%eax 4005f8: 48 83 c4 38 add $0x38,%rsp 4005fc: c3 retq 4005fd: 0f 1f 00 nopl (%rax)
  • 机器代码~链接
    这是编译器工作的最后一步,将不同的.o文件链接起来,生成完整的可执行文件,在linux系统就是.out文件,在windows系统就是.exe文件。上面的代码经过一系列编译以后,使用命令./test运行程序,显示:
    a=55
    

至此,一个完整的程序结束了,接下来,我们介绍一下计算机是怎么执行程序的

程序的运行

程序的启动是操作系统的责任,但是先别着急双击程序,考虑程序在计算机中是怎么执行的,先必须了解几个概念:

存储器

存储器是能够保存数据的设备,我们熟悉的内存就是一种存储器,我们的程序代码就是保存到内存中的,另外一类存储器叫做通用寄存器,通常保存我们的机器指令执行的临时数据。还有一类存储器叫做状态寄存器,保存我们程序执行的状态

程序计数器

也是一个存储器,保存当前要执行的机器指令在内存中的地址

流程

因为我们只是介绍计算机执行程序的过程,很多涉及组合电路或者逻辑电路的问题我们先不介绍,后面一系列文章我会详细说明,下面看一下程序的运行过程:

  1. 我们双击一个应用程序或者使用命令行运行一个程序
  2. 操作系统的加载器会执行一系列操作
    • 在内存中开辟空间,将我们的程序数据加载到内存
    • 备份之前程序的数据,包括寄存器数据和程序计数器数据
    • 初始化程序运行的环境,比如分配缓冲区等
    • 找到程序的入口点,也就是我们的main函数,把入口位置赋值给程序计数器
  3. 经过操作系统的一系列操作,现在我们程序运行的环境已经具备了,程序计数器已就位,所有寄存器都可用。
  4. 取指:计算机根据程序计数器的值从内存中取出一条指令,然后根据这条指令最前面的操作码来判断当前是一条什么指令,可能是movq指令,或者是addq指令,或者是pushq指令都有可能,但是不同的操作码会决定计算机接下来的操作,也决定了程序计数器距离下一条指令的位置,除了操作码以外,指令还可能包含用到的寄存器编码或者内存位置编码或者常数编码
  5. 译码:我们取指完成后,如果当前指令有寄存器的操作,我们需要通过寄存器的编码拿到寄存器的数值,译码阶段,计算机会根据寄存器编码从寄存器中获取到数值,然后输出到下一阶段。
  6. 执行:执行阶段会执行算术操作或者逻辑操作,这些都是在中央处理器的逻辑/算数处理单元进行的。该操作可能输出一个计算后的数值,也可能修改状态寄存器的值
  7. 访存:该阶段会根据指令类别进行内存的读取或者写入
  8. 写回:该阶段会根据指令的类别修改寄存器的值
  9. 更新PC:该阶段会根据执行结果修改下一条指令的位置,写入程序计数器,至此,一步指令的执行就完成了
  10. 然后再次执行4

需要注意的问题

  1. 上面讲的流程只是最简单的执行,现在处理器使用了很多设计来加速指令的执行,但是上面的流程确实是一个程序在计算机执行的过程
  2. 程序执行过程中可能会需要操作系统的参与,这在指令中叫做中断,比如文件的读写,我们必须通过中断执行操作系统的代码才可以

你可能感兴趣的:(程序设计-计算机原理,汇编)