計算機原理學習(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程序是如何被編譯成可執行文件的呢?

  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 文件中,一下是截取的一部分內容

  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平臺結構



參考


《程序員的自我修養》

《計算機操作系統》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章