天天看點

讀《程式員的自我修養——裝載、連結與庫》

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

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

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

對于c語言編寫的HelloWorld程式,

#include<stdio.h>

main()

{

printf("hello, world/n");

return 0;

}

問題:  

l  程式為什麼要被編譯器編譯了之後才可以運作?

計算機不能直接了解進階語言,隻能直接了解機器語言,是以必須要把進階語言翻譯成機器語言,計算機才能執行進階語言編寫的程式。 

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

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

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

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

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

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

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

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

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

可執行檔案中包含兩部分内容:

程式(從原程式中的彙編指令翻譯過來的機器碼)和資料(源程式中定義的資料) 。

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

l  #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運作庫的具體實作源碼就先不考慮了。  

l  不同的編譯器、不同的硬體平台以及不同的作業系統,最終編譯出來的結果一樣嗎?為什麼?

不一樣。

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

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

對于不同的作業系統,答案是一目了然的。不同的作業系統下,它的可執行檔案格式的要求都不相同,共享庫以及動态連結方式都不一樣,那麼結果肯定也就不一樣的啦。

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

上一篇《程式的流程——連結、裝載與運作》很詳細地描述了它 。

l  如果沒有作業系統,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”。

l  printf是怎麼實作的? 它 為什麼可以有不定數量的參數?為什麼它能夠在終端上輸出字元串?

我們将以printf的實作源代碼為例,講述printf是怎麼實作可變參數的,怎麼在終端輸出字元串!

首先看printf函數的定義:

int printf(char *fmt, ...)

{

  va_list args;

  int n;

  va_start(args, fmt);

  n = vsprintf(sprint_buf, fmt, args);

  va_end(args);

  write(stdout, sprint_buf, n);

  return n;

}

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

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

printf("Free mem: %d bytes/n/r",memory_end-main_memory_start);

先來分析第一個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。

int vsprintf(char *buf, const char *fmt, va_list args)

{

int len;

unsigned long num;

int i, base;

char *str;

char *s;

int flags; // Flags to number()

int field_width; // Width of output field

int precision; // Min. # of digits for integers; max number of chars for from string

int qualifier; // 'h', 'l', or 'L' for integer fields

//str為最終存放字元串的位置但是他随着字元串增長而增長,buf始終指向最終字元串的啟始位置。fmt為格式字元串

for (str = buf; *fmt; fmt++)

{

if (*fmt != '%')

{

*str++ = *fmt;//如果不是%則表示這是需要原樣列印的字元串,直接複制即可

continue;

}

// Process flags

flags = 0;

repeat:

fmt++; // This also skips first '%' //fmt指向%的後一位

switch (*fmt)

{

case '-': flags |= LEFT; goto repeat;

case '+': flags |= PLUS; goto repeat;

case ' ': flags |= SPACE; goto repeat;

case '#': flags |= SPECIAL; goto repeat;

case '0': flags |= ZEROPAD; goto repeat;

}

// Get field width

field_width = -1;

if (is_digit(*fmt))

field_width = skip_atoi(&fmt);

else if (*fmt == '*')

{

fmt++;

field_width = va_arg(args, int);

if (field_width < 0)

{

field_width = -field_width;

flags |= LEFT;

}

}

// Get the precision

precision = -1;

if (*fmt == '.')

{

++fmt;

if (is_digit(*fmt))

precision = skip_atoi(&fmt);

else if (*fmt == '*')

{

++fmt;

precision = va_arg(args, int);

}

if (precision < 0) precision = 0;

}

// Get the conversion qualifier

qualifier = -1;

if (*fmt == 'h' || *fmt == 'l' || *fmt == 'L')

{

qualifier = *fmt;

fmt++;

}

// Default base

base = 10;

switch (*fmt) //如果沒有上面奇怪的标志位(*/./h/l/L)則fmt仍然指向%的後一位,下面判斷這個标志位

{

case 'c':

if (!(flags & LEFT)) while (--field_width > 0) *str++ = ' ';

*str++ = (unsigned char) va_arg(args, int);

while (--field_width > 0) *str++ = ' ';

continue;

case 's':

s = va_arg(args, char *);

if (!s) s = "<NULL>";

len = strnlen(s, precision);

if (!(flags & LEFT)) while (len < field_width--) *str++ = ' ';

for (i = 0; i < len; ++i) *str++ = *s++;

while (len < field_width--) *str++ = ' ';

continue;

case 'p':

if (field_width == -1)

{

field_width = 2 * sizeof(void *);

flags |= ZEROPAD;

}

str = number(str, (unsigned long) va_arg(args, void *), 16, field_width, precision, flags);

continue;

case 'n':

if (qualifier == 'l')

{

long *ip = va_arg(args, long *);

*ip = (str - buf);

}

else

{

int *ip = va_arg(args, int *);

*ip = (str - buf);

}

continue;

case 'A':

flags |= LARGE;

case 'a':

if (qualifier == 'l')

str = eaddr(str, va_arg(args, unsigned char *), field_width, precision, flags);

else

str = iaddr(str, va_arg(args, unsigned char *), field_width, precision, flags);

continue;

// Integer number formats - set up the flags and "break"

case 'o':

base = 8;

break;

case 'X':

flags |= LARGE;

case 'x':

base = 16;

break;

case 'd':

case 'i':

flags |= SIGN;

case 'u':

break;

default:

if (*fmt != '%') *str++ = '%';)//如果格式轉換符不是%,則表示出錯,直接列印一個%。如果是%,那麼格式轉換符就是%%,就由下面if(*fmt)隻輸出一個%

if (*fmt)

*str++ = *fmt;//如果格式轉換符不正确則輸出%+不正确的格式轉換符。如果是%%,則隻輸出一個%

else

--fmt;;//如果轉換格式符不是上面這些正确的,也不是空,那麼直接輸出,并傳回到判斷fmt的for語句;否則就指向末尾了,fmt後退一位,這樣在for循環自動再加1進行判斷時*fmt的條件就不滿足,退出for循環

continue;

}

if (qualifier == 'l')

num = va_arg(args, unsigned long);

else if (qualifier == 'h')

{

if (flags & SIGN)

num = va_arg(args, short);

else

num = va_arg(args, unsigned short);

}

else if (flags & SIGN)

num = va_arg(args, int);

else

num = va_arg(args, unsigned int);

str = number(str, num, base, field_width, precision, flags);

}

*str = '/0';; //設定str字元串的最後一位為'/0'

return str - buf; //傳回值為字元串的長度

}

這樣我們就實作了根據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!

l  Hello World程式在運作時,它在記憶體中是什麼樣子?

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

穩定後就是 0101010101011111111111100000000000000000000000

繼續閱讀