當前位置: 妍妍網 > 碼農

第一行程式碼 Hello World 的背後?開發者:「一言難盡……」

2024-05-06碼農

【CSDN 編者按】相信每位程式設計師都對「 Hello World 」程式非常熟悉,但你是否了解其背後的抽象世界呢?

原文連結:https://thecoder08.github.io/hello-world.html

未經允許,禁止轉載!

作者 | Lennon McLean 責編 | 夏萌

譯者 | 彎月

出品 | CSDN(ID:CSDNnews)

在本文中,我們來深入探討現代Hello Worl d程式背後的抽象世界。

背景介紹

本文主要探討用C語言編寫的Hello World程式。不考慮具體的程式語言在Hello World正式執行之前直譯器/編譯器/JIT等工作的話,C語言就是高級語言所能達到的最高層次了。

原本我寫這篇文章的目的是讓所有具備一些編程背景的人都能理解,但現在我認為具備一些C語言或組合語言的知識會更有幫助。

Hello World程式碼

每個人都應該很熟悉Hello World程式。 學習Python時,你編寫的第一個程式可能像下面這樣:

非常簡單,就是在螢幕上輸 出文本 「Hello W orld!」。

在本文中,我們來看一看用 C語言編寫的Hello World程式。 你能看懂下面的程式碼嗎?

這個程式執行的操作與上述 Python程式碼完全一樣。 但與Python不同,你不能直接呼叫直譯器執行這個程式。 你必須先執行編譯器,將這段程式碼轉換成機器碼,然後才能在電腦的處理器上直接執行。 所有現代大型程式都是這樣編寫的。

因此,我們必須執行以下命令:

這個命 令可以將檔 hello.c中的C程式碼轉換成機器碼,並生成一個名為hello的程式。 然後,我們就可以透過如下命令運行程式了:

結果是:

我們的程式

那麽,我們的程式是如何 輸出這個文本的呢?首先,我們來看看我們的程式,看看裏面究竟是什麽。

你不用擔心看不懂,我會慢慢解 釋。 重點是下面這幾個欄位:

這幾個欄位告訴我們,這個程式是x86_64指令集架構上的ELF可執行檔。 什麽意思?

ELF可執行檔是Linux檔,相當於Windows下的.exe檔,就是一種電腦可以執行的程式。 其余資訊告訴我們,這是一個在 64位元 x86 處理器上執行的機器碼程式,64位元 x86 處理器是自1981年以來IBM電腦一直在使用的CPU架構。 當然,當時還不是64位元的,但現代處理器也可以執行為IBM PC編寫的程式碼。 這又是另一個話題了。

我們的程式檔包含的是機 器程式碼,一種語言,也是CPU能理解的唯一語言。那麽,CPU從何處開始執行程式碼呢?

此處的重點是:Entry point address,其 值為 0x1060。 這是一個十六進制數位,代表了程式加 載到電腦記憶體後,程式中的一個位置。那麽,這個位置上究竟有什麽呢?

程式碼

這條命令完整的輸出 太長了,此處就不貼了。下面是截取的一部份,請註意 1060: 開頭的一行:

什麽意思?冒號前面的數位是後面的字節的地址,也就是它們在檔中的位置。後面的數位是程式檔中的數據字節,此 處表示機器碼。 後面的文本是機器碼的反組譯。 組譯語 言是人類 可讀的機器碼的表示。 請註意,即便左側的字節不表示程式碼,反組譯器仍會嘗試對它們進行反組譯。 由此會產生一些垃圾和毫無意義的組譯程式碼。

如上,我們找到了一些程式碼! 但不是我們編寫的程式碼。 這些程式碼是編譯器(嚴格來說是連結器)自動 添加到程式中的。 本質上,這些程式碼會執行一些初始化,然後執行一個重要的指令:

這條指令告訴電腦去執行其他地方的一些程式碼,此處即為地址0x2f53,當動態連結器載入我們的程式時,這個地址會被改為0x3fd8。 關於這一點,此處不做詳細探討。

但無論你怎麽努力尋找,我們的檔中都找不到這兩個地址。準確來說,0x3fd8在全域偏移表中,同樣相關內容也超出了本文的範圍,但此刻它是空的。這是因為這段程式碼不是在我們的程式中定義的,而是在其他地方。

C 庫

那麽究竟在哪 裏?

我們的程式碼依賴的庫有很多,上面只是其中一部份。 我們可以看到下面這行:

main函式當 然就在我們的程式 中。 看看反組譯,你會看到:

終於看到我們的程式碼了! 那它究竟幹了什麽呢?

  1. 設定了一個棧幀。

  2. 設定了我們的函式呼叫的參數。

  3. 呼叫了我們的Hello World函式。

  4. 清理了棧幀。

  5. 從函式中返回,結束程式碼為0。

這就是我們在原始碼中看到的內容。 但什麽是棧幀呢? 它是電腦記憶體的一部份,我們的程式用棧幀來儲存局部變量,即在main函式內聲明的變量。 幸運的是,我們沒有聲明任何變量,所以不需要在意。 重點是下面這部份:

具體操作為:

  1. 設定 Hello World 字串的記憶體地址,將其作為函式呼叫的第一個參數(間接呼叫)。

  2. 呼叫 puts() 函式。

等等,puts()? 我們呼叫的不是 printf() 嗎?

錯。 但是,編譯器進行了一種最佳化。 printf 函式很復雜,因為它能夠打印「格式化輸出」,這意味著我們可以在輸出中嵌入變量。 這個函式負責將它們轉換為字串並輸出,但我們沒有用到這些功能。 因此,編譯器將 printf() 替換為更為簡單的 puts(),後者僅負責打印一串未經格式化的文本。 那麽,我們的文本在哪裏?

字串

根據反組譯器的顯示,我 們的字串位於地址 0x0eac,載入後會轉換為地址 0x2004。那麽,字串裏面是什麽呢?

前面,我說過即使不是程式碼,反組譯器 也會嘗試進行反組譯,這就是一個很好的例子。 忽略上面這些組合語言,因為它們毫無意義。 我們來看看 0x2004,後面是一串十六進制字節 48 65 6c 6c 6f 20 57 6f 72 6c 64 21 00,轉譯過來就是字串「Hello World!」,最後是一個NULL終止符。

但是我們的字串中不是還包含一個換行符 \n 嗎,不是應該被轉譯為 ASCII 0x0a 嗎?沒錯,但這也是編譯器最佳化後的結果。puts() 函式會在字串後面添加換行符,而 printf() 不會。因此,我們的換行符被移除了,這樣輸出就只包含一個換行符。

我們還看到了一個NULL字節 0x00,又稱作NULL終止符。所有 C 字串的末尾都有這個字節。在 C 中,字串不包含任何長度資訊。因此,接受任何長度的字串作為參數的函式會逐字節地對其進行操作,直到遇到NULL終止符。如果記憶體中有多個字串,並且它們之間沒有NULL終止符,那麽 C 函式將一次性操作所有字串。最終,函式將來到字串末尾,並開始讀取不允許讀取的記憶體,而你的程式將崩潰並顯示「Segmentation Fault」錯誤。

puts()

puts()的地址是0x 1050。

又一次呼叫標準庫(嚴格來說是全域 偏移表,但最終是標準庫)。

此處,我們還是不想閱讀標準庫的反組譯程式碼,但幸運的是 Glibc(我們的 C 標準庫)是開源的。我們能從中發現什麽呢?

在標準庫中,puts() 的別 名為 _IO_puts。

可以看到,這個函式獲取了字串的 長度,獲得了輸出流鎖,進行了一些檢查,並呼叫了 _IO_sputn。 然後,釋放鎖,並返回打印字元的數量。

我搜尋了一下這個函式,但沒有找到。 很明顯,它透過一個名為 _IO_file_jumps 的函式執行了一些操作,並 呼叫了 _IO_new_file_xsputn。

好長的一段程式碼,我可不打算去分析這段程式碼究竟在幹什麽。 我知道使用 Glibc 來解釋這段程式碼會很麻煩。 因此,此處我決定檢視 musl libc,我知道它應該很小。

musl

在 musl 中,puts() 定義如下:

首先,獲取輸出流 鎖; 然後,呼叫fputs; 最後,釋放輸出流鎖。

那麽,fputs又是怎樣定義的呢?

獲取字串的長 度,然後呼叫fwrite(),參數為輸出流、字串及其長度。

那麽,fwrite()的定義又是什麽呢?

獲取另一個輸出流鎖,然後呼叫__fwritex(),然後釋放輸出流鎖。

那麽,__fwritex()的定義又是什麽呢?

這段程式碼有點多, 但主要操作是使用輸出流的FILE物件呼叫write()。 我們的流被定義為標準輸出(stdout),這又是在哪裏 定義的呢?

此處,write函式被 定義為__stdout_write(),那麽後者的定義又是什麽呢?

針對我們的輸出流 執行了一次 TIOCGWINSZ ioctl,然後又呼叫了 __stdio_write(),那麽後者的定義 又是什麽呢?

我們距離終點已經很近了。 這個函式執行了很多操作,呼叫了 syscall(),第一個參數為 SYS_writev。 那麽,syscall() 是如 何定義的呢?

syscall()的第一個參數為系統呼叫編號,還接受數量可變的額外參數。va_arg()呼叫將這些參數讀入變量a、b、c、d、e和f中。然後,我們使用 這些參數呼叫__syscall(),並將結果放入__syscall_ret()。

不幸的是,我找不到__syscall()的定義。 但我覺得這是因為這部份屬於平台範疇。 Musl是一個多架構的C庫,因此從這個深度開始執行的程式碼取決於我們使用的是什麽架構。 在深入研究之前,我看了一眼__syscall_ret():

檢查__syscall()的返回值是否有效,如果無效,則系統呼叫失敗,因此返回-1。

系統呼叫

我們的Hello World呼叫的最後幾個階段涉及到了系統呼叫。什麽是系統呼叫?無論我們的C庫有多大,都無法完成底層的一些工作。其中之一就是與硬體通訊。這部份工作預留給了內核,是作業系統的一部份,負責控制並共享IO裝置、記憶體和CPU的存取。在這個例子中,這部份工作由Linux內核負責。在Windows中是ntoskrnl.exe,也就是工作管理員顯示的System。

這意味著,向作業系統傳達了後面的工作後,puts()呼叫就功成身退了。在這個例子中,我們要求作業系統向輸出流寫入一些文本。寫入流的工作是系統呼叫write完成的。Musl使用了一個類似的系統呼叫,叫做writev,它可以在陣列中寫入多個緩沖區。下面,我們來看看m usl如何進行系統呼叫。

我們已經追蹤到最底層了。在x86_64平台上,musl可以使用7個不同的函式進行系統呼叫。每個函式接受不同數量的參數。

每個函式都有一個__asm__指令,它可以將行內組譯程式碼嵌入到編譯器的機器語言輸出中。我們在向作業系統發出系統呼叫時設定了一些CPU寄存器並執行了syscall指令。然後,控制權轉移到了內核,由後者讀取我們的參數並執行系統呼叫。

內核

接下來,由Linux內核執行系統呼叫請求的操作。系統呼叫write告訴內核寫入檔案系統中的一個已開啟的檔,或者寫入一個流,而此處我們的操作屬於後者。

系統呼叫write有3個參數:檔描述符、寫入的緩沖區以及寫入的字節數。musl使用的系統呼叫write略有不同,但此處我們只討論write。

那麽,我們到底寫入到哪裏呢?

視具體情況而定。

在這個例子中,我在GNOME終端模擬器中執行了hello程式。這款模擬器是一個圖形應用程式,對於內核來說,它是一個偽終端(pty)。所以,內核將我們的訊息Hello World保存在緩沖區中,模擬器執行時,讀取緩沖區,然後再顯示。終於講完了。

當然,整個旅程還沒有完全結束。模擬器必須將文本渲染成一幀(可以使用GPU來渲染),將此幀發送到X伺服器/合成器,然後由後者將其與其他正在執行的應用程式組合在一起(也使用GPU),例如我當前用來撰寫這篇文章的文字編輯器,然後將其發送回內核,最後顯示出來。

長呼一口氣。我略過了很多不太重要的細節,而且你的環境可能完全不同。比如你選擇遠端登入,那麽內核會將的文本發送到sshd,然後將透過互聯網發送的封包(加密)發送回內核。或者,你使用的是物理終端,連線到串口轉USB介面卡,那麽內核必須將文本放入USB封包,並將其發送到下一級。再或者,你使用了幀緩沖控制台,這是在沒有安裝GUI的情況下與作業系統互動的預設方式。在這種情況下,內核必須將文本渲染成一幀,並將其輸出到顯視器。

重點在於,接下來的操作有很多可能性,而且具體的細節並不重要。因為你的Hello World消 息只是一個系統呼叫,來自一個程式,此時此刻你的電腦上有數百萬個系統呼叫和成千上萬個程式在執行,而Hello World只是其中最微不足道的一個。

總結

現如今,硬體上的現代軟體系統是如此錯綜復雜,想方設法完整地理解電腦上的一個小操作完全沒有意義。顯然,為了解釋這個小程式所做的一切操作,我略過了很多內容。我沒有提到所有邊緣情況、附加資訊以及電腦執行的其他任務。我也沒有解釋內核是如何工作的。

問:那麽,Hello World 程式究竟是如何工作的呢?

答:一言難盡……

推薦閱讀: