1. 程式人生 > >同學你會hello world嗎? 給我講清楚點

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

少點程式碼,多點頭髮

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

https://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語法分析

很多人疑惑為什麼要把程式轉換成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藏著大學問,囊括的內容也實在是太豐富了

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

我是龍叔,我們下期見