少點代碼,多點頭發
本文已經收錄至我的GitHub,歡迎大家踴躍star 和 issues。
面試官超級喜歡問hello world問題 特別是校招,我校招碰到過3次
其實很多看起來順其自然簡單的東西,背后是一套復雜的學問
記得很清楚第一次面試阿里巴巴的時候,面試官上來讓我寫一個hello world程序

當時我真的一面黑人問號的確認了三遍,面試官依舊淡定的說 是的
寫完就讓我聊hello world,一個hello world聊了一個小時
那時候面試是校招實習,聊完我真的懷疑人生了
這個問題非常考驗應試者的計算機基礎、自學能力以及對問題鑽研的能力
要回答好這個問題,必須掌握計算機基礎、操作系統、編譯原理等知識才能給出一個完美的答案
來了,開聊了,還沒關注我的記得關注我,一鍵三連

代碼如上,現在看來很簡單 怎么也不會想到這樣的程序還會出錯
不丟人的說,龍叔第一次在寫這段代碼的時候,這個簡單的程序大概寫了三四遍
好不容易倒騰完了,點擊運行后 發現少了頭文件
加上之后再運行,發現少了結尾的 ; 號
加上之后,發現少了return 0
就這樣倒騰了好幾遍,終於在控制台輸出了hello world!!! ,那一刻我激動得笑出了聲
於是驕傲的我趕緊趁熱打鐵,寫了下面的版本

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

外甥非常好奇,這hello world到底是怎么輸出到屏幕的
龍叔也好奇過這個問題,只不過是在C語言學完之后才開始好奇
從馮·諾依曼的結構我們可以知道,計算機的基本組成部分如下:

程序,首先是通過輸入設備,鼠標、鍵盤輸入的
寫好的代碼在文本文件中,是需要存儲的,此時就用到存儲器,代碼是存儲在磁盤中的
當你點擊運行時,你的代碼會被讀到內存中,在內存中的代碼會經過編譯器進行編譯為可執行文件
編譯后的文件經操作系統的進程去啟動一個用戶進程執行用戶的可執行程序
中央處理器會去處理程序邏輯,將執行結果輸出到輸出設備即顯示器
每個部分都有自己的工作,恪盡職守,這個在系統設計上叫模塊清晰、功能完整
接下來就從幾個方面好好說說這個 hello world,讓面試官目瞪口呆下

代碼輸入過程

- 啟動IDE軟件
- 用鍵盤飛速敲打着代碼
- 檢查代碼無誤后,點擊運行完事

代碼輸入這么簡單的問題,還用龍叔講??
如上圖首先說下輸入過程,此圖做了一個濃縮,主要部件 鍵盤、主機(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文件是刪除了注釋、宏替換、頭文件也加載進來了,該文件比源代碼文件大
內容太多,代碼就不粘貼了,大家自行試驗下
編譯優化階段
編譯程序所要作的工作就是通過詞法分析、語法分析、 語義分析,在確認所有的指令都符合語法規則之后,將其翻譯成等價的中間代碼或匯編代碼
詞法分析和語法分析千萬不要混淆了,校招面試的時候被面試官給繞了半天
- 詞法分析
詞法分析器識別出Token,把字符串轉換成一個個Token
Token包括關鍵字、標識符、字面量、操作符、界符等
為什么要這樣做呢,把代碼里的單詞進行分類,編譯器后面的階段不就更好處理理解代碼了嘛
- 語法分析
語法分析階段把Token串,轉換成一個體現語法規則的樹狀數據結構,即抽象語法樹AST
AST樹反映了程序的語法結構
比如hello world代碼經過語法分析之后會得到一個AST樹

很多人疑惑為什么要把程序轉換成AST這么一顆樹呢?
因為編譯器不像人能直接理解語句的含義,AST樹更有結構性,后續階段可以針對這顆樹做各種分析
- 語義分析
語義分析顧名思義就是理解語義,也就是理解程序要做什么
比如理解 "+" 符號是執行加法、"="號是執行賦值操作、"for"結構就是去執行循環等等
那到底怎么理解呢?
這個階段要做的就是進行上下文分析,上下文分析包括引用消解、類型分析以及檢查等等
引用消解:找到變量所在的作用域,一個變量作用范圍屬於全局還是局部作用域
類型識別:比如執行a=3,需要識別出變量a的類型,因為浮點數和整型執行不一樣,要執行不同的運算方式
類型檢查:比如 int b = 3,是否可以進行定義賦值,等號右邊的表達式必須返回一個整型的數據或者能夠自動轉換成整型的數據,才能夠對類型為整型的變量b進行賦值
經過語義分析后獲得的信息(引用消解信息、類型信息),會在AST上進行標注,形成 帶有標注的語法樹,讓編譯器更好的理解程序的語義
在語法分析后有了程序的抽象語法樹,在語義分析后有了 帶有標注的AST 和符號表后,就可以深度優先遍歷AST,並且一邊遍歷一邊執行結點的語義規則
對於解釋性語言整個遍歷的過程就是執行代碼的過程
解釋性語言如Python 等,在遍歷帶有標注和符號表的抽象語法樹即可開始執行
編譯性語言需要生成目標代碼,如C、C++
編譯型語言需要生成目標代碼,而解釋性語言只需要解釋器去執行語義就可以了
之前校招面試的時候,面試官看我把hello world講的這么好,順手問了句Java、Python 執行hello world的過程一樣么?
當時愣了下,知道不一樣 但是沒解釋的很清晰
- 代碼優化
對於不同架構的CPU,生成的匯編代碼不同,如果優化是針對每一種匯編代碼,那這個過程就相當復雜了
所以在生成目標代碼之前增加一個過程,先生成一個 中間代碼IR,統一優化后再生成目標代碼
優化代碼主要從分為本地優化、全局優化、過程間優化
本地優化:可用表達式分析、活躍性分析
全局優化:基於控制流圖CFG作優化
過程間優化:跨越函數的優化,多個函數間作優化
說了一些干的,舉個例子讓大家理解下到底如何優化

活躍性分析就是將一些沒有用到的代碼刪除,比如一些沒有用到的變量
- 目標代碼生成
目標代碼生成就是將優化后的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執行程序的步驟是:
- CPU讀取PC指針指向的指令,簡稱取指(fetch)
- CPU 分析指令寄存器中的指令,確定指令的類型和參數,簡稱 解碼(decode)
- 如果是計算類型的指令,那么就交給邏輯運算單元計算;如果是存儲類型的指令,那么由控制單元執行 ,簡稱執行(execute)
- 將執行結果進行返回給寄存器或者將寄存器數據存入內存,簡稱 存儲(store)
- PC 指針自增,並准備獲取下一條指令
上面步驟是一個循環也稱為CPU指令周期,CPU 的工作就是一個周期接着一個周期,周而復始。

更多關於CPU執行的問題,可以看看好朋友小林的 你不好奇 CPU 是如何執行任務的?
或者持續關注,后面我會更新關於CPU執行調度的文章
結果輸出
在Unix系統中,每個進程都會默認打開三種標准I/O 分別是STDIN、STDOUT和STDERR
printf源碼

這只是第一次源碼,願意了解的可以看看vfprintf實現,你會發現底層使用了 緩沖輸出
輸出是一次output,也就是會經歷一次從內存外部文件系統的數據轉移
總結
到這里基本就講完了了hello world全部內容,講完了不一定是講透徹了
比如 關於文件系統的知識、IO知識、CPU調度知識、進程管理、內存管理等等知識都沒法通過一篇文章說透徹
說實話一個小小的hello world藏着大學問,囊括的內容也實在是太豐富了
今天只是從整體上把控了一下,細節內容后面寫操作系統會一一更新
我是龍叔,我們下期見