计算机原理学习(4)-- 操作系统发展和程序编译

前言

 


前面的文章主要都是计算机硬件相关的一些工作原理。而前一篇文章介绍了内存的工作原理,编址方式,逐步过渡到软件上面来了。前面也说过,内存是一个非常重要的部件,因为CPU所需的指令和数据都在内存中。所以从这一篇开始我们主要看看程序运行时在内存中的布局。

 

我们知道对于计算机系统来说,最底层的是硬件,硬件之上是操作系统,而我们的程序都是基于操作系统来运行的,而不是基于硬件,这样操作系统为我们提供了一层抽象,所以对于程序员来说,不需要特别的关注计算机硬件。所以正常来说介绍完硬件后,应该来介绍操作系统。但是我们知道在计算机在不断的发展,硬件,操作系统都在不断发展。而无论怎么发展,程序运行都离不开内存,所以我决定以内存和出发点,来看硬件,操作系统以及应用程序的发展过程。

 

 


1. 操作系统和内存布局

在第一代计算机时期,构成计算机的主要元器件是电子管,计算机运行速度慢(只有几千次/秒),当时没有操作系统,甚至没有任何软件。程序员首先编写程序,然后直接在计算机的控制台上操纵程序运行。

 


1.1 人工操作方式


当时的计算机有一个控制面板,上面有一些开关,当要启动计算机的使用,就是要把启动程序用手工输入到机器里去,其方法就是利用机柜面板上的一排开关,用二进制代码把指令一条一条拨进去。但是指令有限,干不了太多事情。


所以当时程序员使用机器码编写程序,然后通过打孔机,将程序转换到打孔的纸袋上。纸袋每排有八个孔的位置,穿了孔的代表1,没穿孔的代表0。然后通过纸带机等设备手工将程序装入计算机的内存,按动控制台开关和按扭确定程序的起始地址并启动程序执行。程序员只能通过控制台上的显示灯来观察程序执行情况。当程序运行出错时,程序员直接通过控制台开关来停止程序运行,检查内存及寄存器内容并调试程序。程序运行结果可以通过打印机或穿孔机输出。

 

上图就是纸袋和纸袋读入机器。但是存储程序的介质是纸袋,而加载程序是通过纸带机。其实这里我也有个疑问:

  1. 纸带机是如何把纸袋上的程序加载到内存的呢?
  2. 程序被加载到内存的地址是如何确定的呢?


我没有找到答案,不过这个并不影响我们对早期程序内存模型运行的理解。在当时,整个计算机资源(CPU,内存)是由当前运行才程序独占的。所以我们可以想象当时内存的模型。程序的第一条指令被加载到内存的最低地址,比如0x00000000。通过控制面板指定程序首地址,这样CPU就能够按照我们前面介绍的方式来一条条运行。当一个程序运行完成后,要运行下一个程序,需要在使用更换纸袋。清空当前内存,在加载新的程序。


这种方式对于程序来说:

  1. 内存范围,可以读写可用的所有内存空间。
  2. 独占资源,在更换纸袋时CPU和内存资源是空闲的。

 


1.2 脱机输入/输出方式


人工操作方式的主要问题在于,从纸袋读取或写入时浪费了CPU时间,于是就出现了脱机方式。就是首先把要执行的程序通过外围机控制纸带机输入到磁带上。相对于纸带机,从磁带上加载数据到内存要快的多。这个也符合我们前面介绍的存储器分层。脱机方式的有点在于:

  1. 减少了CPU空闲的时间,解决人机速度差距的矛盾
  2. 通过磁带,提高了I/O速度,进一步减少了CPU的空闲时间

 


1.3 单道批处理系统


我们知道那个时候的CPU是非常宝贵的资源。所以必须充分利用,尽量使多个程序能连续的运行。所以一般都会把一批程序以脱机方式输入到磁带上。然后当一个程序执行完成之后,马上运行下一个程序。但是这个过程不是由人来调度的,所以我们需要一个监控程序来控制这些程序一个一个的执行。于是除了我们要运行的程序之外,在内存中还存在这样一个监控程序,我们可以把他看看做早期操作系统的雏形,也就是单道批处理系统。

上图是单道批处理系统的流程图,看起来很简单。这个小程序需要常驻内存,也就是在开始执行其他程序之前,需要把他加载到内存中。当然加载的方式和其他程序相同。然后CPU开始执行这个监控程序。然后这个程序就开始进行批处理操作。于是在内存中,同一时间需要存放两个程序。下图是此时的内存布局.


 


从此图我们可以发现,这个程序或许并不如我们想象的简单:

  1. 控制卡解释程序: 对于监控程序来说,他必须知道当前是那个程序在运行,这就需要每个程序能唯一的标识自己,所以需要在作业程序中加入一段控制卡代码,来唯一标识自己。所以监控程序也需要一个控制卡解释程序来识别这些标识。
  2. 设备驱动程序: 我们知道驱动程序是用来控制硬件控制芯片的,而批处理系统在一个程序运行完成时需要通过I/O设备加载下一个程序,所以需要驱动程序完成这些操作。
  3. 作业队列:监控程序能够根据控制卡提供的信息自动形成作业队列,所以当程序解释时监控程序可以加载下一个程序。
  4. 中端自陷向量表: 关于中端我们前面有介绍过,这里当系统执行I/O操作后,会以中端的方式通知,而这里的向量表则定义了不同中端对应的处理方式。


在这里我们可以发现,对于单道批处理程序来说,内存不再是独享,而是作业程序和监控程序共享,共享就会有一些问题:

  1. 作业程序加载的地址如何确定?
  2. 如何切换程序的控制权?


所以,我个人觉得,决定程序加载的位置应该是监控程序来确定。比如0x0000~0x050的内存区域是监控程序的区域,这样作业程序的起始地址就是0x0051,而监控程序通过修改PC计数器,来修改CPU要执行的下一条代码来切换程序控制权。

 

 

1.4 多道批处理程序


对于单道操作系统来说,他可以连续的运行多个程序,减少了程序切换时的CPU等待时间。但是它的问题在于,当执行I/O操作加时,CPU是空闲的。于是出现了多道批处理程序。多道批处理程序,把多个程序同时加载到内存中,当其中正在运行的程序执行I/O操作时,CPU可以继续执行其他的程序,而当I/O操作结束后,程序可以继续被执行。

上图是多道批处中会存在2个以上的程序,所以需要一个内存分区表来标识每个程序占用的内存范围,以保证每个程序内存空间的独立。

  1. 后备队列: 虽然内存中存在多个程序,但是一个时间内只有一个程序会被执行,而其他没有被执行的程序如果单道程序的作业一样也有一个队列,我们叫后备队列。
  2. 调度程序:对于多道批处理程序来说,它除了要监控之外,还需要一个调度程序,来决定在I/O操作时,后备队列中的那一个程序获得CPU时间。

多道批处理系统的优点在于资源利用率高、系统吞吐量大、平均运行时间长。同样也存在物交互能力,而且需要内存管理,作业调度,设备管理等功能,这就增加了程序的负责的度。

 


1.5 现代操作系统


可以说批处理操作系统是现代操作系统的雏形。从DOS到Windows3.1,到Win95,WinXP,Win8,Unix,Linux。现代操作系统可以说是一个庞大的程序,多CPU,多进程,多线程,虚拟内存等等,相比多道批处理应用程序复杂了千万倍。而内存布局也随着硬件和操作系统的发展而发生了变化。但是主要的目的都是提高系统效率,提高系统稳定性安全性。提供更好的交互体验。

 

 


2 程序的编译和链接

到目前为止,我们都只是一直在谈论,程序被加载到内存然后执行的过程,而没有提及到程序是如何从我们编写的代码变为可执行文件的。在我们开始介绍现代操作系统中程序内存布局之前,先看看程序是如何被编译成可执行文件的。因为计算机的发展是和硬件,操作系统,编译器等共同发展分不开的。

 

 

2.1 编译过程

 

现在我们基本都是在可视环境下进行开发,比如Eclipse,VS等开发工具。这些工具功能相当的强大,我们只需专注代码的编写,点几下鼠标,一个可执行文件就被生成出来了。但是在这背后,开发工具到底做了什么呢? 下面一个简单的C程序是如何被编译成可执行文件的呢?

[cpp]  view plain  copy
 print ? 在CODE上查看代码片 派生到我的代码片
  1. #include <stdio.h>  
  2. int main()  
  3. {  
  4.     printf("Hello, world.\n");  
  5.     return 0;  
  6. }  

一般来说,一个程序从源代码到可执行文件是通过编译器来完成的,简单的说,编译器的工作就是把高级语言转换为机器码,一个现代的编译器工作流程是:(源代码)--预处理--编译---汇编---链接--(可执行文件)。在Linux下一般使用GCC来编译C语言程序, 而VS中使用cl.exe。下图就是上面的代码在GCC中编译的过程。我们后面讨论的都以C语言为例。编译器

 

2.1.1 预处理

 

预处理是程序编译的第一步,以C语言为例, 预编译会把源文件预编译成一个 .I 文件。而C++则是编译成 .ii。 GCC中预编译命令如下

[plain]  view plain  copy
 print ? 在CODE上查看代码片 派生到我的代码片
  1. #gcc -E hello.c -o hello. I  

当我们打开hello.i 文件是会发现这个文件变的好大,因为其中包含的<stdio.h> 文件被插入到了hello.i 文件中,一下是截取的一部分内容

[cpp]  view plain  copy
 print ? 在CODE上查看代码片 派生到我的代码片
  1. # 1 "hello.c"  
  2. # 1 "<built-in>"  
  3. # 1 "<命令行>"  
  4. # 1 "hello.c"  
  5. # 1 "/usr/include/stdio.h" 1 3 4  
  6. # 28 "/usr/include/stdio.h" 3 4  
  7. # 1 "/usr/include/features.h" 1 3 4  
  8. # 324 "/usr/include/features.h" 3 4  
  9. # 1 "/usr/include/i386-linux-gnu/bits/predefs.h" 1 3 4  
  10. # 325 "/usr/include/features.h" 2 3 4  
  11. # 357 "/usr/include/features.h" 3 4  
  12. # 1 "/usr/include/i386-linux-gnu/sys/cdefs.h" 1 3 4  
  13. # 378 "/usr/include/i386-linux-gnu/sys/cdefs.h" 3 4  
  14. # 1 "/usr/include/i386-linux-gnu/bits/wordsize.h" 1 3 4  
  15. # 379 "/usr/include/i386-linux-gnu/sys/cdefs.h" 2 3 4  
  16. # 358 "/usr/include/features.h" 2 3 4  
  17. # 389 "/usr/include/features.h" 3 4  
  18. # 1 "/usr/include/i386-linux-gnu/gnu/stubs.h" 1 3 4# 940 "/usr/include/stdio.h" 3 4  
  19.   
  20.   
  21.   
  22. # 2 "hello.c" 2  
  23. int main()  
  24. {  
  25.     printf("Hello, world.\n");  
  26.     return 0;  
  27. }  

总结下来预处理有一下作用:

  • 所有的#define删除,并且展开所有的宏定义
  • 处理所有的条件预编译指令,比如我们经常使用#if #ifdef #elif #else #endif等来控制程序
  • 处理#include 预编译指令,将被包含的文件插入到该预编译指令的位置。这也就是为什么我们要防止头文件被多次包含。
  • 删除所有注释 “//”和”/* */”.
  • 添加行号和文件标识,以便编译时产生调试用的行号及编译错误警告行号。比如上面的 # 2 "hello.c" 2
  • 保留所有的#pragma编译器指令,因为编译器需要使用它们。

 

2.1.2 编译

 

编译是一个比较复杂的过程。编译后产生的是汇编文件,其中经过了词法分析、语法分析、语义分析、中间代码生成、目标代码生成、目标代码优化等六个步骤。大学时有一门《编译原理》的课程就是讲这个的,只可惜当时学的并不好,感觉太枯燥太难懂了。所以当我们语法有错误、变量没有定义等问题是,就会出现编译错误。

[plain]  view plain  copy
 print ? 在CODE上查看代码片 派生到我的代码片
  1. #gcc -S hello.i -o hello.s  

通过上面的命令,可以从预编译文件生成汇编文件,当然也可以之际从源文件编译成汇编文件。实际上是通过一个叫做ccl的编译程序来完成的。

[plain]  view plain  copy
 print ? 在CODE上查看代码片 派生到我的代码片
  1.     .file   "hello.c"  
  2.     .section    .rodata  
  3. .LC0:  
  4.     .string "Hello, world."  
  5.     .text  
  6.     .globl  main  
  7.     .type   main, @function  
  8. main:  
  9. .LFB0:  
  10.     .cfi_startproc  
  11.     pushl   %ebp  
  12.     .cfi_def_cfa_offset 8  
  13.     .cfi_offset 5, -8  
  14.     movl    %esp, %ebp  
  15.     .cfi_def_cfa_register 5  
  16.     andl    $-16, %esp  
  17.     subl    $16, %esp  
  18.     movl    $.LC0, (%esp)  
  19.     call    puts  
  20.     movl    $0, %eax  
  21.     leave  
  22.     .cfi_restore 5  
  23.     .cfi_def_cfa 4, 4  
  24.     ret  
  25.     .cfi_endproc  
  26. .LFE0:  
  27.     .size   main, .-main  
  28.     .ident  "GCC: (Ubuntu/Linaro 4.6.3-1ubuntu5) 4.6.3"  
  29.     .section    .note.GNU-stack,"",@progbits  

上面就是生成的汇编文件。我们看出其中分了好几个部分。我们只需要关注,LFB0这个段中保存的就是C语言的代码对于的汇编代码.。

 


2.1.3 汇编

 

汇编的过程比较简单,就是把汇编代码转换为机器可执行的机器码,每一个汇编语句机会都对应一条机器指令。它只需要根据汇编指令和机器指令的对照表进行翻译就可以了。汇编实际是通过汇编器as来完成的,gcc只不过是这些命令的包装。

[plain]  view plain  copy
 print ? 在CODE上查看代码片 派生到我的代码片
  1. #gcc -c hello.s -o hello.o  
  2. //或者  
  3. #as hello.s -o hello.o  

汇编之后生成的文件是二进制文件,所以用文本打开是无法查看准确的内容的,用二进制文件查看器打开里面也全是二进制,我们可以用objdump工具来查看:

[plain]  view plain  copy
 print ? 在CODE上查看代码片 派生到我的代码片
  1. cc@cheng_chao-nb-vm:~$ objdump -S hello.o  
  2.   
  3. hello.o:     file format elf32-i386  
  4.   
  5.   
  6. Disassembly of section .text:  
  7.   
  8. 00000000 <main>:  
  9.    0:   55                      push   %ebp  
  10.    1:   89 e5                   mov    %esp,%ebp  
  11.    3:   83 e4 f0                and    $0xfffffff0,%esp  
  12.    6:   83 ec 10                sub    $0x10,%esp  
  13.    9:   c7 04 24 00 00 00 00    movl   $0x0,(%esp)  
  14.   10:   e8 fc ff ff ff          call   11 <main+0x11>  
  15.   15:   b8 00 00 00 00          mov    $0x0,%eax  
  16.   1a:   c9                      leave    
  17.   1b:   c3                      ret      

上面我们看到了Main函数汇编代码和机器码对应的关系。关于objdump工具后面会介绍。这里生成的.o文件我们一般称为目标文件,此时它已经和目标机器相关了。

 

 

2.1.4 链接

链接是一个比较复杂的过程,其实链接的存在是因为库文件的存在。我们知道为了代码的复用,我们可以把一些常用的代码放到一个库文件中提供给其他人使用。而我们在使用C,C++等高级语言编程时,这些高级语言也提供了一些列这样的功能库,比如我们这里调用的printf 函数就是C标准库提供的。 为了让我们程序正常运行,我们就需要把我们的程序和库文件链接起来,这样在运行时就知道printf函数到底要执行什么样的机器码。

[plain]  view plain  copy
 print ? 在CODE上查看代码片 派生到我的代码片
  1. #ld -static crt1.o crti.o crtbeginT.o hello.o -start-group -lgcc -lgcc_eh -lc-end-group crtend.o crtn.o  

 

我们看到我们使用了链接器ld程序来操作,但是为了得到最终的a.out可执行文件(默认生成a.out),我们加入了很多目标文件,而这些就是一个printf正常运行所需要依赖的库文件。

 

 

2.1.5 托管代码的编译过程

 

对于C#和Java这种运行在虚拟机上的语言,编译过程有所不同。 对于C,C++的程序,生成的可执行文件,可以在兼容的计算机上直接运行。但是C#和JAVA这些语言则不同。他们编译过程是相似的,但是他们最终生成的并不是机器码,而是中间代码,对于C#而言叫IL代码,对于JAVA是字节码。所以C#,JAVA编译出来的文件并不能被执行。


我们在使用.NET或JAVA时都需要安装.NET CLR或者JAVA虚拟机,以.NET为例,CLR实际是一个COM组件,当你点击一个.NET的EXE文件时,它和C++等不一样,不能直接被执行,而是有一个垫片程序来启动一个进程,并且初始化CLR组件。当CLR运行后,一个叫做JIT的编译器会吧EXE中的IL代码编译成对应平台的机器码,然后如同其他C++程序一样被执行。


有关C#程序的编译和运行可以参考之前写的: .Net学习笔记(一) ------ .NET平台结构



参考


《程序员的自我修养》

《计算机操作系统》

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值