讀《程序員的自我修養——裝載、鏈接與庫》

《程序員的自我修養》這本書是我看過《深入理解計算機系統》之後看的一本書。在中國人寫的書中,它可以算是相當不錯的一本書了。但總覺得比《深入理解計算機系統》這樣的國外經典書還差那麼一點,具體差在哪裏,我也說不出來。但如果給它打個分的話,我會毫不猶豫地給五星。

這本書裏,首先給出了幾個鮮明的觀念很不錯。知其然更要知其所以然;CPU體系結構、彙編、c語言(C++)和操作系統,永遠是編程大師們的護身法寶,猶如少林寺的《易筋經》,是最爲上乘的武功,學會了《易筋經》,你將無所不能,任你創造武功,學會了“易筋經”,大師們可以任意開發操作系統、編譯器、甚至開發一種新的程序設計語言;萬變不離其宗;計算機科學領域的任何問題都可以通過增加一箇中間層來解決;真正了不起的程序員是對自己的程序的每一個字節都瞭如指掌。

從書名可知,本書主要講述的是三大部分:鏈接、裝載和庫,最核心的內容已經在上一篇中說過了。本篇就主要來解決本書開篇從Hello World說起所提的問題

對於c語言編寫的HelloWorld程序,

問題:  

程序爲什麼要被編譯器編譯了之後纔可以運行?

計算機不能直接理解高級語言,只能直接理解機器語言,所以必須要把高級語言翻譯成機器語言,計算機才能執行高級語言編寫的程序。 

  翻譯的方式有兩種,一個是編譯,一個是解釋。兩種方式只是翻譯的時間不同。編譯型語言寫的程序執行之前,需要一個專門的編譯過程,把程序編譯成爲機器語言的文件,比如exe文件,以後要運行的話就不用重新翻譯了,直接使用編譯的結果就行了(exe文件),因爲翻譯只做了一次,運行時不需要翻譯,所以編譯型語言的程序執行效率高。 如c語言就屬於這種類型。所以要對C源程序進行編譯、鏈接。

  解釋則不同,解釋性語言的程序不需要編譯,省了道工序,解釋性語言在運行程序的時候才翻譯,比如解釋性basic語言,專門有一個解釋器能夠直接執行basic程序,每個語句都是執行的時候才翻譯。這樣解釋性語言每執行一次就要翻譯一次,效率比較低。 

糾正:java很特殊,java程序也需要編譯,但是沒有直接編譯稱爲機器語言,而是編譯稱爲字節碼,然後用解釋方式執行字節碼。  

 

編譯器在把c語言程序轉換成可執行的機器碼的過程中做了什麼,怎麼做的?

 

直觀上來講,編譯器就是把便於編寫、閱讀的高級語言翻譯成計算機能夠識別、運行的低級機器語言。在我看來,編譯器分爲傳統編譯器和現代編譯器。傳統編譯器經過詞法分析、語法分析、語義分析、中間語言生成以及目標代碼的生成和優化。而一個現代編譯器的主要工作流程如下:

源代碼 (source code) → 預處理器 (preprocessor) → 編譯器 (compiler) → 彙編程序 (assembler) → 目標代碼 (object code) → 連接器 (Linker) → 可執行程序 (executables) 。

而對每一個步驟的詳細展開,內容就非常豐富了。

 

最後編譯出來的可執行文件裏面是什麼?除了機器碼還有什麼?它們怎麼存放的、怎麼組織的?

首先,必須搞清什麼是“機器碼”,它當然不是指唯一爲計算機編的序列號。彙編語言或 C 語言等高級語言編譯後的最終結果:含有可被微處理器(CPU)加載並執行的由 0 和 1 組成的序列,這就是 機器碼

可執行文件中包含兩部分內容:

程序(從原程序中的彙編指令翻譯過來的機器碼)和數據(源程序中定義的數據)

用Linux下的可執行文件elf的結構來看一下這些內容是怎麼存放、組織的。

 

 

 

#include <stdio.h>是什麼意思?把stdio.h包含進來是什麼意思?C語言庫又是什麼?它怎麼實現的?

#include <stdio.h>的意思是將stdio.h包含進來,Stdio.h是標準輸入輸出頭文件,裏面包含了標準輸入輸出函數的聲明, printf就是其中的一個。通過#include預編譯指令將需要的庫函數調入,這樣就可以實現一些基本的功能,例如字符串到標準輸入輸出設備的輸入和輸出等等。而具體的鏈接就不詳述了。

任何一個C程序,它的背後都有一套龐大的代碼來進行支撐,以使得該程序能夠正常運行。這套代碼至少包括入口函數,及其所依賴的函數所構成的函數集合。當然,它還理應包括各種標準庫函數的實現。

這樣的一個代碼集合稱之爲運行庫(Runtime Library)。而C語言的運行庫,即被稱爲C運行庫(CRT)。

一個C語言運行庫大致包含了如下功能:

啓動與退出:包括入口函數及入口函數所依賴的其他函數等。

標準函數:由C語言標準規定的C語言標準庫所擁有的函數實現。

I/O:I/O功能的封裝和實現,參見上一節中I/O初始化部分。

堆:堆的封裝和實現,參見上一節中堆初始化部分。

語言實現:語言中一些特殊功能的實現。

調試:實現調試功能的代碼。

C運行庫的具體實現源碼就先不考慮了。  

不同的編譯器、不同的硬件平臺以及不同的操作系統,最終編譯出來的結果一樣嗎?爲什麼?

不一樣。

對於不同的編譯器,整個流程(預處理——編譯器(詞法分析、語法分析,語義分析...)——彙編器——鏈接器)之中只要有稍微一點的不同,我想編譯後的結果——可執行文件都是不同的。

對於不同的硬件平臺,比如x86、SPARC、MIPS、ARM等,它們的尋址方式、地址格式、指令格式等等等等都不相同,那麼編譯的過程必然也會有所不同,結果自然不同。

對於不同的操作系統,答案是一目瞭然的。不同的操作系統下,它的可執行文件格式的要求都不相同,共享庫以及動態鏈接方式都不一樣,那麼結果肯定也就不一樣的啦。

Hello World程序是怎麼運行起來的?操作系統是怎麼裝載它的?他從哪兒開始執行的,到哪兒結束?main函數前發生了什麼?main函數後發生了什麼?

上一篇《程序的流程——鏈接、裝載與運行》很詳細地描述了它

如果沒有操作系統,Hello World可以運行嗎?如果要在一臺沒有操作系統的機器上運行Hello World需要什麼?應該怎麼實現?

首先,答案是肯定的,試想一下沒有操作系統前,程序不是照樣跑?!但同時也是需要一些條件的。Hello World在有操作系統時需要操作系統需要進行裝載鏈接(c運行庫),那麼沒有操作系統的情況下,肯定需要自己的裝載器和鏈接器吧,然後需不需要一些什麼內存管理器、要不要實現自己的c運行庫就不得而知了。下面是從csdn論壇上發帖得到的幾個答案,在這裏分享一下。

Waiting4you

用匯編,直接調用BIOS中斷來輸出字符,把編譯好的東東(應該不會大於512字節)寫到磁盤的第一扇區。
注意,整個程序必須只有一個代碼段,彙編的起始地址要改成0x7C00(好像是,不是很確定)。編譯好的512字節最後兩個字節要改成0x55,0xAA
有個叫《自己動手寫操作系統》的書,開篇就有一個類似的代碼

bluewanderer

1. printf是C庫中的IO部分
2. IO部分包含文件系統
3. 文件系統是操作系統的內容
4. printf對操作系統有依賴
所以,沒操作系統休想printf("Hello World/n")
C庫這類東西術語上就叫操作系統抽象層,沒操作系統你抽象誰去

janneliu

你可以將PC指針指向你要執行的代碼段起始,或者想辦法開機的時候直接從你的代碼段起始執行,當然你編譯鏈接的時候不能用類似libc的庫了,像printf都的自己封裝,這肯定要彙編的東西了

vcprg

可以實現的。
就現在來講的話,總的來說需要軟件和硬件。
硬件的話需要馮諾依曼或哈佛結構或還是其他別的什麼體系結構的計算機。
軟件的話需要就是可以編寫和編譯Hello world程序的宿主機的操作系統。

例如:你可以在C51單片機上運行,用單片機相應的編譯器把你的hello world程序編譯成二進制代碼,然後將程序燒進單片機的存儲器(ROW),最後上電就可以運行了。注意:如果你想顯示hello world,你就需要一個顯示屏或陣列二極管或數碼管或是其他什麼的,並寫出相應的程序顯示出“hello world”。

 

printf是怎麼實現的? 爲什麼可以有不定數量的參數?爲什麼它能夠在終端上輸出字符串?

我們將以printf的實現源代碼爲例,講述printf是怎麼實現可變參數的,怎麼在終端輸出字符串!

首先看printf函數的定義:

 

 

參數中明顯採用了可變參數的定義,而在main.c函數的後面直接調用了printf函數,我們可以看下printf函數的參數是如何使用的。

 

先來分析第一個printf調用:

printf("%d buffers = %d bytes buffer space/n/r",NR_BUFFERS, NR_BUFFERS*BLOCK_SIZE);

可以看到*fmt等於"%d buffers = %d bytes buffer space/n/r”,是一個char 類型的指針,指向字符串的啓始位置。而可變的參數在這裏是NR_BUFFERS和NR_BUFFERS*BLOCK_SIZE。

其中NR_BUFFERS在buffer.c中定義爲緩衝區的頁面大小,類型爲int;BLOCK_SIZE在fs.h中的定義爲

#define BLOCK_SIZE 1024

因此兩個可變參數NR_BUFFERS和NR_BUFFERS*BLOCK_SIZE都爲int類型;

而對於 可變參數 一系列va( variable-argument 函數

va_list arg_ptr

void va_start( va_list arg_ptr, prev_param ); 

type va_arg( va_list arg_ptr, type ); 

void va_end( va_list arg_ptr );

首先在函數裏定義一個va_list型的變量,這裏是arg_ptr,這個變量是指向參數的指針。然後使用va_start使arg_ptr指針指向prev_param的下一位,然後使用va_args取出從arg_ptr開始的type類型長度的數據,並返回這個數據,最後使用va_end結束可變參數的獲取。

在printf("%d buffers = %d bytes buffer space/n/r",NR_BUFFERS, R_BUFFERS*BLOCK_SIZE)中,根據以上的分析fmt指向字符串,args首先指向第一個可變參數,也就是NR_BUFFERS(args在經過一次type va_arg( va_list arg_ptr, type )調用後,會根據type的長度自動增加,從而指向第二個可變參數NR_BUFFERS*BLOCK_SIZE)。

我們先不管write函數的實現,首先來看vsprintf。

 

這樣我們就實現了根據fmt中的格式轉換符將可變參數轉換到相應的格式,利用write函數 達到 輸出的目的。然而,write函數過於複雜,甚至有不少內嵌彙編語言。下面僅僅描述一下printf輸出的一般步驟:

1、當printf被調用後,首先會經過C函數庫的處理,也就是字符串解析,得到要輸出的字符串。

2、調用WriteChars(),它會調用WriteFile()這個API,所謂的File其實是控制檯輸出緩衝區的句柄。WriteFile判斷句柄類型(如是文件句柄將調用ntdll.dll中的NtWriteFile函數),因爲這裏是控制檯句柄所以將調用WriteConsoleA函數。

3、WriteConsoleA函數將調用ntdll.dll中的csrClientCallServer函數,這個函數的目的是通知csrss.exe要輸出字符了。

4、csrClientCallServer最終會調用NtRequestWaitReplyPort,此時系統進入內核態,內核會通知csrss.exe

5、csrss.exe中一個叫CsrApiRequestThread的線程已經用一個叫NtReplyWaitReceivePort(這個函數被調用後,線程就會被阻斷,直到上面的NtRequestWaitReplyPort被調用才繼續執行)的函數等很久了,此時接到指令欣喜若狂的csrss.exe就會根據發來的內容經過一番糾結判斷是要輸出字符,於是找到自己的winsrv.dll

6、winsrv.dll有個叫SrvWriteConsole的函數被調用,這個函數會對發來的信息進行一番安全檢查、處理,然後給一個叫DoSrvWriteConsole的函數

7、這個DoSrvWriteConsole會做一些單字節、多字節等編碼的檢查、轉換,然後調用FE_DoSrvWriteConsole函數

8、然後調用FE_DoWriteConsole,這個函數調用FE_WriteChars。

9、FE_WriteChars會進行兩步工作

(1)更新控制檯緩衝區,這個使用叫做FE_StreamWriteToScreenBuffer和BisectWrite函數完成的

(2)更新屏幕緩衝區,這個使用叫做FE_WriteToScreen和FE_WriteRegionToScreen函數完成的,主要過程包括將文本用一個叫FE_PolyTextOutCandidate的函數放到待輸出隊列裏,然後等這一批文本都放進去後調用GdiFlush函數刷到屏幕上。

10、終於快大功告成了,SrvWriteConsole返回,csrss.exe這個時候用一個叫NtReplyPort的函數告訴我們的Hello.exe:嗯,我寫完了,於是我們的Hello.exe繼續運行,然後你就會看到屏幕上出現可愛的:Hello World!

Hello World程序在運行時,它在內存中是什麼樣子?

Hello World程序在運行的過程中,是CPU、內存與磁盤三者之間進行的交互。內存中只提供一塊有限的區域,稱之爲“活動區域”。它裏面的存放是磁盤上加載進來運行的頁。此時內存裏面只有固定數量的頁,也叫做頁幀大小。

穩定後就是 0101010101011111111111100000000000000000000000

 

 

 

 

發佈了259 篇原創文章 · 獲贊 7 · 訪問量 51萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章