同學你會hello world嗎? 給我講清楚點

少點程式碼,多點頭髮

本文已經收錄至我的GitHub,歡迎大家踴躍star 和 issues。

//github.com/midou-tech/articles

面試官超級喜歡問hello world問題 特別是校招,我校招碰到過3次

其實很多看起來順其自然簡單的東西,背後是一套複雜的學問

記得很清楚第一次面試阿里巴巴的時候,面試官上來讓我寫一個hello world程式

當時我真的一面黑人問號的確認了三遍,面試官依舊淡定的說 是的

寫完就讓我聊hello world,一個hello world聊了一個小時

那時候面試是校招實習,聊完我真的懷疑人生了

這個問題非常考驗應試者的電腦基礎自學能力以及對問題鑽研的能力

要回答好這個問題,必須掌握電腦基礎作業系統編譯原理等知識才能給出一個完美的答案

來了,開聊了,還沒關注我的記得關注我,一鍵三連

程式碼如上,現在看來很簡單 怎麼也不會想到這樣的程式還會出錯

不丟人的說,龍叔第一次在寫這段程式碼的時候,這個簡單的程式大概寫了三四遍

好不容易倒騰完了,點擊運行後 發現少了頭文件

加上之後再運行,發現少了結尾的 ;

加上之後,發現少了return 0

就這樣倒騰了好幾遍,終於在控制台輸出了hello world!!! ,那一刻我激動得笑出了聲

於是驕傲的我趕緊趁熱打鐵,寫了下面的版本

這兩個版本的程式碼都是C語言寫的,C語言課程應該是大學的通識課了,用這個語言講,大家都能看的明白

運行結果:

外甥非常好奇,這hello world到底是怎麼輸出到螢幕的

龍叔也好奇這個問題,只不過是在C語言學完之後才開始好奇

·諾依曼的結構我們可以知道,電腦的基本組成部分如下:

馮·諾依曼結構
馮·諾依曼結構
  1. 程式,首先是通過輸入設備,滑鼠、鍵盤輸入的

  2. 寫好的程式碼在文本文件中,是需要存儲的,此時就用到存儲器,程式碼是存儲在磁碟中的

  3. 當你點擊運行時,你的程式碼會被讀到記憶體中,在記憶體中的程式碼會經過編譯器進行編譯為可執行文件

  4. 編譯後的文件經作業系統的進程去啟動一個用戶進程執行用戶的可執行程式

  5. 中央處理器會去處理程式邏輯,將執行結果輸出到輸出設備即顯示器

每個部分都有自己的工作,恪盡職守,這個在系統設計上叫模組清晰、功能完整

接下來就從幾個方面好好說說這個 hello world,讓面試官目瞪口呆下

文章大綱
文章大綱

程式碼輸入過程

  1. 啟動IDE軟體
  2. 用鍵盤飛速敲打著程式碼
  3. 檢查程式碼無誤後,點擊運行完事

程式碼輸入這麼簡單的問題,還用龍叔講??

如上圖首先說下輸入過程,此圖做了一個濃縮,主要部件 鍵盤、主機(CPU、記憶體、磁碟)、顯示器

程式碼輸入過程看起來是蠻簡單的,打開一個編輯器或者IDE,即可開始程式碼輸入

剛開始學習推薦使用IDE,當然不是沒有IDE就不能寫程式碼

任何一個文本編輯器都可以進行程式碼輸入

IDE(Integrated Development Environment) 集成開發環境,一般包括程式碼編輯器編譯器調試器和圖形用戶介面等工具

比如寫C&C with class 會下載 vc++、devC++、VS、Clion等等軟體,很棒,工具能提高生產力

我習慣用Clion,IDE都是根據自己的需要來選擇,用著就行

啟動一個IDE,這意味著什麼?

IDE是一個軟體,集成度很高的軟體 ,啟動IDE意味著作業系統必須啟動一個進程 該進程叫IDE進程

既然是集成 內部還有很多執行緒負責集成模組的工作

關於進程、執行緒 深層次的內容,後面文章會詳細講出 這裡就先不展開了

IDE進程會被作業系統管理調度

鍵盤飛速敲打程式碼,程式碼如何跑到IDE中的?

要明白這個問題得先說說鍵盤工作原理

鍵盤的基本原理就是實時監控按鍵,將按鍵資訊送入電腦

在鍵盤的內部設計中有定位按鍵位置的鍵位掃描電路,當任何鍵被按下是 編碼電路就會產生程式碼,這些程式碼會被送入介面電路,這些電路被稱為鍵盤控制電路

根據鍵盤工作原理,分為編碼鍵盤非編碼鍵盤

編碼鍵盤:鍵盤控制電路的功能完全依靠硬體來自動完成 ,根據按鍵自動識別編碼資訊

非編碼鍵盤:鍵盤控制電路的功能依靠 硬體軟體 共同完成

監控鍵盤的原理就是電位掃描,電位掃描分為逐行掃描法行列掃描法

原來如此,原來鍵盤是這樣工作的,從此我在飛速敲擊鍵盤時 會更有力量了

這僅僅是鍵盤驅動進程拿到鍵盤輸入的結果,應用程式是如何獲得輸入數據的呢?

輸入過程
輸入過程

鍵盤後台進程拿到結果後會放在自己的共享記憶體中,應用程式通過共享記憶體獲取到鍵盤輸入結果

上圖中很明顯看到鍵盤輸入是會發生IO操作的,IO整體內容這裡不展開,後面文章會更新

一頓操作,此時IDE會拿到鍵盤輸入的程式碼,你的hello world程式碼終於在顯示器中讓你看到了

接下來說說躺在IDE中程式碼是如何運行出結果的

程式碼編譯為可執行程式

程式碼終於是敲好了,激動的你一般會想著要運行一手,迫不及待看到結果

別急再等等,我們書寫的程式碼程式被稱為源程式碼,CPU執行的是機器碼,這個包含機器碼的程式被稱為可執行程式

先來看看源程式碼是如何變為可執行程式的

源程式碼是如何變為可執行程式

IDE是集成環境,很容易讓初學者以為源程式碼直接被CPU執行了

其實不然

源程式碼必須經過編譯器編譯 才能成為二進位的可執行程式

IDE裡面集成了 編譯器 調試器 ,C語言的編譯器 主要有GNU編譯器套件中的GCC、Microsoft C 或稱 MS C、Borland Turbo C 或稱 Turbo C

編譯過程是一個複雜的過程,接下來聊聊這個複雜的過程

編譯是個過程的總稱,其中還包括不同的階段,源程式碼預處理階段、編譯優化階段、彙編階段、鏈接階段

編譯過程
編譯過程
預處理階段

預處理器將對其中的偽指令(以# 開頭的指令)和特殊符號進行處理,刪除所有的注釋,最後生成 .i文件

偽指令包括:

  • 宏定義指令,如# define Name TokenString,# undef等
  • 條件編譯指令,如# ifdef,# ifndef,# else,# elif,# endif等
  • 頭文件包含指令,如# include “FileName” 或者# include < FileName> 等
  • 特殊符號,預編譯程式可以識別一些特殊的符號

使用gcc命令可以輸出.i文件

gcc -E helloWorld.cpp -o helloWorld.i

此時.i文件是刪除了注釋、宏替換、頭文件也載入進來了,該文件比源程式碼文件大

內容太多,程式碼就不粘貼了,大家自行試驗下

編譯優化階段

編譯程式所要作的工作就是通過詞法分析語法分析語義分析,在確認所有的指令都符合語法規則之後,將其翻譯成等價的中間程式碼或彙編程式碼

詞法分析和語法分析千萬不要混淆了,校招面試的時候被面試官給繞了半天

  1. 詞法分析

詞法分析器識別出Token,把字元串轉換成一個個Token

Token包括關鍵字、標識符、字面量、操作符、界符等

為什麼要這樣做呢,把程式碼里的單詞進行分類,編譯器後面的階段不就更好處理理解程式碼了嘛

  1. 語法分析

語法分析階段把Token串,轉換成一個體現語法規則的樹狀數據結構,即抽象語法樹AST

AST樹反映了程式的語法結構

比如hello world程式碼經過語法分析之後會得到一個AST樹

hello world語法分析
hello world語法分析

很多人疑惑為什麼要把程式轉換成AST這麼一顆樹呢?

因為編譯器不像人能直接理解語句的含義,AST樹更有結構性,後續階段可以針對這顆樹做各種分析

  1. 語義分析

語義分析顧名思義就是理解語義,也就是理解程式要做什麼

比如理解 “+” 符號是執行加法、”=”號是執行賦值操作、”for”結構就是去執行循環等等

那到底怎麼理解呢?

這個階段要做的就是進行上下文分析,上下文分析包括引用消解、類型分析以及檢查等等

引用消解:找到變數所在的作用域,一個變數作用範圍屬於全局還是局部作用域

類型識別:比如執行a=3,需要識別出變數a的類型,因為浮點數和整型執行不一樣,要執行不同的運算方式

類型檢查:比如 int b = 3,是否可以進行定義賦值,等號右邊的表達式必須返回一個整型的數據或者能夠自動轉換成整型的數據,才能夠對類型為整型的變數b進行賦值

經過語義分析後獲得的資訊(引用消解資訊、類型資訊),會在AST上進行標註,形成 帶有標註的語法樹,讓編譯器更好的理解程式的語義

在語法分析後有了程式的抽象語法樹,在語義分析後有了 帶有標註的AST 和符號表後,就可以深度優先遍歷AST,並且一邊遍歷一邊執行結點的語義規則

對於解釋性語言整個遍歷的過程就是執行程式碼的過程

解釋性語言如Python 等,在遍歷帶有標註和符號表的抽象語法樹即可開始執行

編譯性語言需要生成目標程式碼,如C、C++

編譯型語言需要生成目標程式碼,而解釋性語言只需要解釋器去執行語義就可以了

之前校招面試的時候,面試官看我把hello world講的這麼好,順手問了句Java、Python 執行hello world的過程一樣么?

當時愣了下,知道不一樣 但是沒解釋的很清晰

  1. 程式碼優化

對於不同架構的CPU,生成的彙編程式碼不同,如果優化是針對每一種彙編程式碼,那這個過程就相當複雜了

所以在生成目標程式碼之前增加一個過程,先生成一個 中間程式碼IR,統一優化後再生成目標程式碼

優化程式碼主要從分為本地優化、全局優化、過程間優化

本地優化:可用表達式分析、活躍性分析

全局優化:基於控制流圖CFG作優化

過程間優化:跨越函數的優化,多個函數間作優化

說了一些乾的,舉個例子讓大家理解下到底如何優化

活躍性分析就是將一些沒有用到的程式碼刪除,比如一些沒有用到的變數

  1. 目標程式碼生成

目標程式碼生成就是將優化後的IR程式碼翻譯為彙編程式碼

翻譯為彙編程式碼主要步驟是

  • 選擇合適指令,生成性能最高的程式碼
  • 優化暫存器分配,讓一些頻繁被用到的變數存放在暫存器中
  • 在不改變運行結果的前提下,對指令做重排序優化 ,重排序優化是為了充分利用CPU內部的並行能力

編譯階段使用的指令

gcc -S helloWorld.cpp -o helloWorld.s

生成的彙編程式碼:

用的GCC版本資訊如下

彙編階段

上面的編譯階段的生成的彙編程式碼還是人能看懂的,不是給機器直接執行的,機器執行的叫做機器碼

機器碼放在可執行文件

unix環境中存在好幾種目標文件:

  • 可重定位文件,包含有適合於其它目標文件鏈接來創建一個可執行的或者共享的目標文件的程式碼和數據
  • 共享的目標文件,這種文件存放了適合於在兩種上下文里鏈接的程式碼和數據
  • 可執行文件,包含了一個可以被作業系統創建一個進程來執行之的文件

不同的作業系統的可執行文件格式不同

  • Windows的PE文件
  • Linux的elf文件
  • Mac的macho文件

彙編程式生成的實際上是第一種類型的目標文件,鏈接完成之後才能生成可執行文件

鏈接階段

將彙編階段生成的一個個的目標文件鏈接在一起生成可執行文件

其實很多人不理解為什麼需要鏈接這個過程,明明彙編階段已經生成目標程式碼

舉個例子大家就明白了,日常做系統開發的時候,我們講究系統功能模組化 現在都是微服務

一個複雜系統,往往會分成多個不同的子系統 子系統在拆分為不同的功能模組

鏈接的過程也和這個類似 一個複雜的軟體需要拆分為多個不同的模組,每個模組獨立編譯

根據需要在 “組合” 起來,這個組裝模組的過程就是 鏈接

鏈接過程
鏈接過程

比如main函數中調用了printf函數,mian函數在編譯時並不知道printf函數的地址(每個模組都是單獨編譯的)

但是調用又必須知道函數地址才能發生調用關係

編譯時暫時把這個地址擱置,鏈接時在進行地址修正

鏈接完成之後會形成一個可執行文件 ,可執行文件也叫ELF文件

這個ELF文件以及其他文件也夠喝一壺,放在後面講聊文件系統 一起聊

編譯全過程
編譯全過程

)

程式如何裝載

裝載就是把可執行程式載入到記憶體中,供後續的CPU執行

在linux命令行中我們經常這樣執行一個可執行程式

./a.out

這樣一下就把程式載入到記憶體中,載入完成之後直接執行了

其實你可以使用

strace ./a.out

這個命令可以看到所有的系統調用

可以看到 第一個執行的系統調用是 execve

通過 man execve 可以看到這個函數的描述

execve() executes the program pointed to by filename. filename must be either a binary executable, or a script starting with a line of the form:

​ #! interpreter [optional-arg]

execve()執行文件指定的程式 文件必須是二進位可執行文件,或者執行一個以 shebang開頭的腳本

Shebang 就是 #! 開頭

通過查看Linux的execve源碼如下

主要執行工作落在了 do_execve 上,繼續看看 do_execve 源碼

前面就是計算一些參數如argv、env 拷貝相關數據,最終裝載程式執行search_binary_handler

list_for_each_entry 函數非常重要,這個函數遍歷所有formats列表,找到當前系統合適的可裝載格式

前面已經說過,linux 下可執行文件格式是ELF文件

retval = fmt->load_binary(bprm) 就是load可執行程式

load_binary是載入二進位文件啊,我們的程式明明是ELF文件

仔細看看load_binary的源碼會發現裡面有一個初始化,初始化的時候會做一個賦值替換為

或許到這裡大家基本已經了解了,但還是疑惑怎麼才能判斷載入的ELF文件

可以去看看源碼怎麼寫的 (源碼太長,這裡就不粘貼了 告訴你位置有興趣的自己去看看)

源碼位置:

有個函數叫 static int load_elf_binary(struct linux_binprm *bprm);

在 /fs/binfmt_elf.c Line 820

再看看我們的可執行程式頭上長啥樣 readelf -l a.out 即可查看可執行文件頭部資訊

解釋器通過判斷 Program Headers 中的 INTERP 的值得到該可執行程式的文件類型

cpu執行程式

我們的CPU執行程式的步驟是:

  1. CPU讀取PC指針指向的指令,簡稱取指(fetch)
  2. CPU 分析指令暫存器中的指令,確定指令的類型和參數,簡稱 解碼(decode)
  3. 如果是計算類型的指令,那麼就交給邏輯運算單元計算;如果是存儲類型的指令,那麼由控制單元執行 ,簡稱執行(execute)
  4. 將執行結果進行返回給暫存器或者將暫存器數據存入記憶體,簡稱 存儲(store)
  5. PC 指針自增,並準備獲取下一條指令

上面步驟是一個循環也稱為CPU指令周期,CPU 的工作就是一個周期接著一個周期,周而復始。

指令周期
指令周期

更多關於CPU執行的問題,可以看看好朋友小林的 你不好奇 CPU 是如何執行任務的?

或者持續關注,後面我會更新關於CPU執行調度的文章

結果輸出

在Unix系統中,每個進程都會默認打開三種標準I/O 分別是STDIN、STDOUT和STDERR

printf源碼

這只是第一次源碼,願意了解的可以看看vfprintf實現,你會發現底層使用了 緩衝輸出

輸出是一次output,也就是會經歷一次從記憶體外部文件系統的數據轉移

總結

到這裡基本就講完了了hello world全部內容,講完了不一定是講透徹了

比如 關於文件系統的知識、IO知識、CPU調度知識、進程管理、記憶體管理等等知識都沒法通過一篇文章說透徹

說實話一個小小的hello world藏著大學問,囊括的內容也實在是太豐富了

今天只是從整體上把控了一下,細節內容後面寫作業系統會一一更新

我是龍叔,我們下期見