linux2020
主講人: jserv / 課程討論區: 2020 年系統軟體課程
返回「Linux 核心設計」課程進度表Image Not Showing Possible ReasonsLearn More →
- The image file may be corrupted
- The server hosting the image is unavailable
- The image path is incorrect
- The image format is not supported
CMU (位於美國賓州匹茲堡的卡內基美隆大學) 電腦科學系的 Introduction to Computer Systems (ICS) 課程準備了 C Programming Lab 作為檢閱學生 C 語言程式設計的認知:
在給定的程式碼中,queue.h 包含以下鏈結串列 (linked list) 結構定義:
接著用鏈結串列來實作佇列 (queue)
這過程可用下圖來表示:佇列的上層建構於 queue_t
類型的物件。一開始,此資料結構僅包含單一成員 head
,之後陸續以鏈結串列增添給定的字串,如 "cab"
, "bead"
, "cab"
等等。佇列內的每個元素由單向的鏈結串列構成,每個元素由 list_ele_t
類型的物件來表示,後者包含真正保存字串的 value
和指向下個鏈結串列元素開頭地址的 next
。
圖 1: 透過鏈結串列實作的佇列。每個鏈結串列元素都有個
value
指標,指向一段連續的記憶體 (即 C-style string,以 NULL 字元結尾的字串),字元依據 ASCII 編碼 (以十六進位顯示)。
C 語言中,字串以連續的char
型態物件的排列來表示,對多數的硬體來說,char
型態表示為 1 個 byte。,而為了儲存長度 l
的字串,需要至少 l + 1
個 char
元素,並在最後設定為 \0
字元。上圖的 [cab, bead, cab]
,其中字元 a
和 e
以十六進位表示為 ASCII 編碼的 61
和 65
。觀察上圖,字串 "cab"
和 "bead"
在執行時期可能對應到兩段不相鄰的記憶體。
在給定的 C Programming Lab 程式碼中,佇列透過 queue_t *
指標型態來操作,程式碼需要考慮以下二種特別狀況,作出對應的處置:
NULL
佇列:是指標值為 NULL
;head
) 的值為 NULL
;另一種解讀方式:
若你有空的紅包袋,裡面的東西是 empty;
若你連紅包袋都沒有,就可以說 NULL;
queue.c 僅提供介面 (interface) 但尚未有完整程式實作,需要由學員補完並逐步精進,包含以下:
q_new
: 建立新的「空」佇列;q_free
: 釋放佇列所佔用的記憶體;q_insert_head
: 在佇列開頭 (head) 加入 (insert) 給定的新元素 (以 LIFO 準則);q_insert_tail
: 在佇列尾端 (tail) 加入 (insert) 給定的新元素 (以 FIFO 準則);q_remove_head
: 在佇列開頭 (head) 移除 (remove) 給定的元素。q_size
: 計算佇列中的元素數量。q_reverse
: 以反向順序重新排列鏈結串列,該函式不該配置或釋放任何鏈結串列元素,換言之,它只能重排已經存在的元素;q_sort
: 「Linux 核心設計」課程所新增,以遞增順序來排序鏈結串列的元素,可參閱 Linked List Sort 得知實作手法;在檔案 queue.c 的程式碼註解可見到更詳盡的資訊,包括如何處理無效的操作 (如,從「空」佇列或 NULL
佇列中刪除),及應具有的副作用和返回值。
其中 q_insert_tail
和 q_size
需要將原本 時間複雜度的實作改寫為 時間複雜度,亦即無論佇列空間為何,該操作僅需要固定數量的步驟。你的程式碼將透過 1,000,000
個以上元素的佇列進行測試。
「Linux 核心設計」課程延伸 C Programming Lab 並追加以下機制:
It should operate in O(1) time
卻未有自動評分,本課程參照 dudect 工具及對應的論文 Dude, is my code constant time?,提出以實驗而非理論驗證時間複雜度的方法上述的素養對於接觸有一定規模的開放原始碼專案 (特別是 Linux 核心),是非常關鍵的前期訓練。
給定的程式碼在編譯後,會得到名為 qtest
的執行檔,後者提供許多測試這次作業可用的工具,執行 qtest
後,輸入 help
即有各命令的解說。參考執行過程: (以 $
開頭的字串表示應當在命令列執行的命令)
本程式內建自動評分程式,以下是參考執行輸出。一開始什麼都沒做,當然是零分:
v1.90
,可用 $ cppcheck --version
檢查版本,若小於 1.90
則需要額外準備:
$PATH
環境變數的更新,也可寫在 ~/.bashrc
檔案最底端,下次重開終端機就生效; 或是$ source ~/.bashrc
也可$ sudo apt remove cppcheck
,再依據 Cppcheck Developer Info 網頁指示,編譯並安裝 Cppcheck;"If I had eight hours to chop down a tree, I’d spend six hours sharpening my axe." – Abraham Lincoln
「如果我有 8 小時可以砍 1 棵樹,我會花 6 小時把斧頭磨利。」 – 亞伯拉罕.林肯
(類似漢語「工欲善其事,必先利其器」)
首先,在 Ubuntu Linux 環境建立適合開發的目錄 (directory),例如 /home/ubuntu/linux2020
:
在剛才建立的新目錄,自 GitHub 取得 lab0-c 程式碼:
一旦你 fork 後,可將上面 sysprog21
換為你的 GitHub 帳號,對應的命令列會是:
切換到 lab0-c
目錄並嘗試編譯程式碼:
預期會看到以下輸出:
也可清除編譯輸出的檔案:
若你想知道編譯過程的細節,可執行以下命令:
預期輸出:
不難發現,名為 qtest
的執行檔正是編譯過程最終的輸出。請不要被上述一堆看似嚇人的參數給迷惑了。UNIX 風格 (當然 GNU/Linux 也繼承這優良傳統) 是儘量揭露系統資訊給使用者,這也是「Linux 核心設計」課程特別想讓學員體會到的精神。
-o
一類的參數使用,可參見 你所不知道的 C 語言:編譯器和最佳化原理篇
參閱 lab0-c 的文件 (即 README.md
檔案) 後,我們得知能用以下命令測試 qtest
:
對應的程式輸出如下:
參閱 qtest
的 help
命令輸出,我們可得知以下:
qtest comand | function |
---|---|
new | Create new queue |
free | Delete queue |
ih str [n] | Insert string str at head of queue n times (default: n == 1) |
it str [n] | Insert string str at tail of queue n times (default: n == 1) |
也就是說,$ make check
將 traces/trace-eg.cmd 檔案內容指派給 qtest
,進行以下操作: (僅列出部分)
trace-eg
嗎?顯然這是個示範的檔案,但為何叫作 eg
呢?為何不用 "example" 開頭兩個字母來簡稱 "ex" 呢?這可有學問!"e.g." 是拉丁文 "exempli gratia" 的縮寫,意思是「例如」、「舉例來說」,即英文的 "for example" 或 "for instance"。ex.
放到句子裡當「例如」的用途!
初步理解 qtest
運作機制後,就可前進到 lab0-c 的自動評分系統。操作命令如下:
實際就是執行 scripts/driver.py 這個 Python 程式。同樣地,請不要因為自己沒學過 Python 程式語言就退堂 (或「退選」),你可以快速瀏覽,應能發現,這個自動評分程式就是將 traces/trace-XX-CAT.cmd 這樣形式的命令序列提供給 qtest
內建的命令直譯器,然後依據給定的檢驗標準,逐一判定該給測試者多少分數。
或許你會糾結於 driver.py
這個檔名,在你的印象中,"driver" 的翻譯是「驅動程式」,不就是該用低階程式語言撰寫嗎?怎麼會跟 Python 和自動評分系統有關?
查閱 dictionary.com 的 "driver" 詞目,我們會發現:
Computers. software or hardware that controls the interface between a computer and a peripheral device.
Electronics. a circuit whose output provides the input of another circuit
在電子電機電腦科學 (即 EECS) 領域中,"driver" 可以是軟體,也能是硬體,顯然上述的 "driver.py" 是軟體,其作用是控制電腦程式 (這裡指給定的命令序列,如 ih
及 it
) 和特定的單元,後者就是由 qtest
所提供的命令直譯器。平常我們看到「驅動程式」的翻譯方式,其實是 device driver 的簡稱,在程式設計中,我們可見到類似的 "driver" 案例,如:
另外,lab0-c 專案支援透過 $ make SANITIZER=1
開啟 Address Sanitizer 從而強化執行時期的記憶體檢測,隨後 $ make test
執行過程中會見到類似以下輸出:
這是符合預期的結果,請不要慌張。你可依循上述訊息,追查程式碼檔名及行號,推測為何會觸發 SIGSEGV (在 UNIX 及其相容系統上,意味著 Segmentation fault)。請搭配閱讀以下簡報:
使用一致的 programming style 很重要,我們可透過 clang-format 這個工具來調整作業程式要求的風格,使用方式如下:
課程要求的 C 程式撰寫風格簡述:
{ }
英語: curly brackets, 又稱花括號) 與空格
{
放在行末,把右大括號 }
放在行首,如:sizeof
, typeof
, alignof
, __attribute__
(註: 這是 gcc 延展的功能),在 Linux 核心中,這些關鍵字的使用都會帶上一對括號,儘管在 C 語言的使用上並不需帶上括號
sizeof
不是函式,而是 operator!if
, switch
, case
, for
, do
, while
sizeof
, typeof
, alignof
, __attribute__
之後*
符號) 的位置應該緊靠著變數名稱或函式名稱,而非型別名稱,例如:
= + - < > * / % | & ^ <= >= == != ? :
&*+-~!
sizeof
typeof
alignof
__attribute__
defined
++
--
運算子之前新增空格 (即 i++
)++
--
之後新增空格 (即 ++i
).
和 ->
) 周圍新增空格ThisVariableIsATemporaryCounter
這種「可愛」的名字,相反地,一個 C 程式設計師會把這種變數命名為 tmp
,如此簡潔易寫。static
宣告的函式) 需要使用描述性的名稱。若你有個計算活躍使用者數量的函式,你應該用 count_active_users() 一類的名稱,避免用 cntusr()
這樣不易望文生義的名稱。i
。反過來說,命名為 loop_counter
是生產力很低的行為。同樣地,tmp
可以是任何型別的區域變數。首次執行 make
後,Git pre-commit / pre-push hook 將自動安裝到現行的工作區 (workspace),之後每次執行 git commit
時,Git hook 會檢查 C/C++ 原始程式碼的風格是否一致,並透過 Cppcheck 進行靜態程式碼檢查。
下圖展示 Git pre-commit hook 偵測到開發者的修改並未遵守一致的 coding style,主動回報並提醒開發者:
紅色標注的二行程式碼 (即 int member1;
和 int member2;
) 不符合指定的 4 個空白縮排方式,在 git pre-commit 階段就成功阻擋這樣風格不一致的程式碼變更。
Git commit message 是什麼呢?在取得 lab0-c 程式碼後,執行 $ git log
的輸出就是了。你或許會納悶,commit message 又不是程式碼,充其量只能算是「程式開發的軌跡」,為何要特別探討?
tig
套件,更便利地瀏覽 git repository 資訊。$ sudo apt install tig
Peter Hutterer 在 On commit messages 說得很精闢:
「重新了解一段程式碼更動的脈絡很浪費腦力。雖然這件事情沒辦法完全避免,但是我們可以盡量降低這件事情的複雜度。Commit messages 正可以做到這點,而我們可以從 commit message 看出一個開發者是否為一位好的合作對象。」
一個專案是否能長期且成功地運作 (撇除其他影響的因素),取決於它的可維護性,而在這件事上沒有其他工具比專案本身的開發軌跡更為強大。因此花時間學習如何撰寫與維護專案的開發紀錄,是件值得投資的事。一開始你可能會覺得麻煩,但它很快就會變成一種習慣,甚至能成為你之所以感到自豪及具備生產力的因素。
Chris Beams 在 How to Write a Git Commit Message 一文 (繁體中文翻譯: 如何寫一個 Git Commit Message) 提出,好的 Git commit message 應符合七條規則:
lab0-c 內建 Git pre-commit hook 就包含依循上述七條規則的自動檢查,可阻擋不符合規則的 git commit message,此外還透過 GNU Aspell 對 commit message 的標題進行拼字檢查。
以下實際操作 Git。
事先編輯檔案 queue.h
後,執行 $ git commit
會發現:
原來要指定變更的檔案,用命令 $ git add
:
$ git commit -a
這道命令,讓 Git 自動追蹤變更的檔案並提交
重新 git commit
,這時可發現安裝的 Git hooks 發揮作用:
Git pre-commit hook 提示我們:
e
,可編輯 git commit message;y
,因為 y 不是有效選項, 所以出現 help;e
, 再次編輯訊息,因訊息有限制標題開頭要大寫;注意:請避免用 $ git commit -m
,而是透過編輯器調整 git commit message。許多網路教學為了行文便利,用 $ git commit -m
示範,但這樣很容易讓人留下語焉不詳的訊息,未能提升為 好的 Git Commit Message。因此,從今以後,不要用 git commit -m
, 改用 git commit -a
(或其他參數) 並詳細查驗變更的檔案。
在 Ubuntu Linux 預設組態中,執行 $ git commit -a
會呼叫 GNU nano 來編輯訊息,可透過變更 EDITOR
環境變數,讓 git 找到你偏好的編輯器,例如指定 vim 就可以這樣做:
你也可把上述變更加到 bash 設定檔案中,例如:
一番折騰後,終於順利提交 (commit) 我們的變更:
需要注意的是,目前提交的變更僅存在於這台電腦中,還未發佈到 GitHub 上,於是我們需要執行 git push
下方的
jserv
是示範帳號,請換為你自己的 GitHub 帳號名稱
不難發現上面訊息提到 Why is Git always asking for my password? 這個超連結,如果你厭倦每次 $ git push
都需要輸入帳號及密碼,可參考超連結內容去變更設定。
又因 pre-push hook 使然,每次發布變更到 GitHub 服務時,只要是 master
branch (主要分支) 就會觸發編譯要求,只有通過編譯的程式碼才能發布,但 master
以外的 branch 不受此限,換言之,我們鼓勵學員運用 git branch
進行各項實驗,請參照線上中文教材 《為你自己學 Git》。
若你很有耐心地讀到這裡,恭喜你,終於可著手修改 lab0-c 程式碼。
這裡我們嘗試實作函式 q_size
,首先找出 queue.c
檔案中對應的註解:
Return number of elements in queue.
Return 0 if q is NULL or empty
Remember: It should operate in time
q_size(queue_t *)
由於要在 的常數時間內執行完成,所以也不可能每次都走訪整個鏈結串列,以取得佇列的容積。又在 queue.h
的註解,我們得知:
You will need to add more fields to this structure to efficiently implement
q_size
andq_insert_tail
因此,我們嘗試新增 int size 這個新成員到 struct queue_t
:
接著修改 q_size
的傳回值,改成傳回 q->size
。一切就緒後,提交修改:
Git pre-commit hook 偵測到程式縮排不符合前述的風格,因而擋住我們的提交,我們要執行 $ clang-format -i queue.c
去調整程式縮排,才得以繼續提交。
Valgrind 是個在使用者層級 (user space) 對程式在執行時期的行為提供動態分析的系統軟體框架,具備多種工具,可以用來追蹤及分析程式效能,最為人們所熟知的特性就是幫助使用者偵測記憶體錯誤,諸如使用未初始化的記憶體、不當的記憶體配置、或取消配置記憶體,並加以分析。所有工具都包含在 valgrind 套件中,可以透過以下命令執行:
以記憶體追蹤工具來說,上述 toolname 可以是 memcheck, massif, 或 cachegrind。請注意,使用 Valgrind 時,會讓程式執行速度比平常更慢。
依據 Valgrind is NOT a leak checker 的描述:
Valgrind is an undefined behavior checking tool first, a function and memory profiler second, a data-race detection tool third, and a leak checking tool last.
dynamic Binary Instrumentation (DBI) 著重於二進位執行檔的追蹤與資訊彙整,而 dynamic Binary Analysis (DBA) 則強調對收集資訊的分析。上述 Valgrind 是個 DBI 系統框架,可建構一系列 DBA 工具,藉由 shadow values 技術來實作,後者要求對所有的暫存器和使用到的主記憶體做 shadow (即自行維護的副本),這也使得 Valgrind 相較其他分析方法會較慢。為了實作 shadow values,需要以下 4 個部分:
9 個具體功能:
也就是說,Valgrind 主要的手法是將暫存器和主記憶體的內容自行維護副本,並在任何情況下都可以安全正確地使用,同時記錄程式的所有操作,在不影響程式執行結果前提下,輸出有用的資訊。為了實作功能,Valgrind 利用 dynamic binary re-compilation 把測試程式 (稱為 client 程式)的機械碼解析到 VEX 中間表示法 (intermediate ,簡稱 IR,是種虛擬的指令集架構,規範在原始程式碼 VEX/pub/libvex_ir.h)。VEX IR 在 Valgrind 採用執行導向的方式,以 just-in-time (JIT) 編譯技術動態地把機械碼轉為 IR,倘若觸發特定工具感興趣的事件 (如記憶體配置),就會跳躍到對應的處理工具,後者會插入一些分析程式碼,再把這些程式碼轉換為機械碼,儲存到 code cache 中,以利後續需要時執行。
整個流程如下圖:
Valgrind 啟動後,會開始轉換 client 程式,Valgrind 執行的都是加工後的 client 程式。運作細節可參見:
由於 Valgrind 是動態追蹤,會從給定的程式一路追蹤到 glibc (GNU C library),為了更好的分析效果,需要安裝對應包含除錯訊息的套件:
常見的記憶體錯誤,就是配置的記憶體 (一般透過呼叫 malloc 所得) 在一系列操作後,忘記釋放 (即呼叫 free),且已無任何指標指著該記憶體空間。
我們舉個簡單的例子,並使用 Valgrind 來協助偵測: (檔名 case1.c
)
編譯:
Valgrind 使用方法很簡單,如果有參數就加在後面,程式執行結束就會產生報告:
預期輸出:
報告中指明有一個 10 bytes 的 block 被偵測為 "definitely lost",位置在 case1.c 的第 3 行:
可看到 memory lost 分成幾種類型:
Invalid memory access 有時不會立即造成 segmentation fault,所以不會有 core dump 可分析,於是需要借助像 Valgrind 這類的工具來偵測。一般情況可能是在配置記憶體合法範圍之外的地方進行記憶體操作,或用了已釋放的記憶體 (use-after-free)。
以下是範例程式: (檔名: case2.c
)
比照前一個案例編譯並啟動 Valgrind:
預期可見以下報告輸出:
不要被報告的篇幅嚇到,我們可歸納錯誤如下:
Invalid write of size 2
和 Invalid write of size 1
: 試圖寫入一個非法的區域,Valgrind 精準地提示問題在 case2.c
這檔案的第 6 行所配置記憶體 4 bytes。通常遇到這種情況都是忘記檢查 buffer 的 size 就去用。
Invalid read of size 4
: 試圖讀取一個非法的區域。
Invalid read of size 4
: 讀取的區域已被釋放,該釋放的操作也被 Valgrind 指出,在 case2.c
檔案的第 13 行
Invalid free
: 即釋放一個不存在的區域,或雙重釋放 (double free)若遇到 Conditional jump or move depends on uninitialised value(s)
的錯誤,可能是用到沒有結束字元 (null-terminated string) 的字串。
另外,若函式 A 用到用到函式 B 配置出來的記憶體空間 (特別在 stack 空間 中),但因而造成程式出現非預期的行為。
下圖為典型的 C 語言程式在執行時的記憶體配置圖,記憶體的使用可分為 text, data, bss, stack, heap 這幾個主要部分:
堆疊區段 (stack segment) 用以儲存函式的區域變數,及各種函式呼叫時需要儲存的資訊 (例如函式返回的記憶體位址,和呼叫者函式的狀態一類資訊),每次的函式呼叫就會在堆疊區段建立一個 stack frame,儲存該次呼叫的所有變數與狀態,這樣一來,同一個函式重複被呼叫時就會有不同的 stack frame,不會互相干擾,遞迴函式就是透過這樣的機制執行。
對於區域變數的存取若超過範圍,可能造成 stack corrupt,致使出現以下錯誤訊息:
這訊息是由 gcc 內建的 Stack Smashing Protector (ssp) 所產生。
之前提到 Valgrind 可支援多種工具,其中分析記憶體使用狀況的工具叫做 Massif 可分析以下:
可搭配視覺化工具展現給定程式在執行時期的記憶體行為,類似以下:
若不想看到 possibly lost
,可追加 --show-possibly-lost=no
參數到 Valgrind 的命令列。若 file descriptor 開了沒關也可偵測,只要加上 --track-fds=yes
看到這裡,相信你感受到 Valgrind 的強大,趕快從第一手材料汲取更多資訊: Valgrind User Manual
lab0-c 已整合 Valgrind 來協助學員細緻地找出記憶體相關的問題,諸如 memory leak, buffer overflow, Dangling pointer 等等。使用方式如下:
參考的程式輸出:
在最後一行訊息,我們見到類似以下:
其中 /tmp/qtest.cjLJHC
是隨機產生的檔名,請依據實際程式輸出調整,而 <tid>
則是 traces
目錄裡頭個別 trace 檔案的編號,例如 01
, 02
, 03
, …, 15
。
自動「測試」的議題會比自動「評分」多,至少需要涵蓋以下:
qtest
本身就是個命令直譯器,該如何設計可擴充的架構呢?前兩者涉及 Linux 的 signal,後兩者則需要額外的程式設計技巧。探討細部議題之前,請先參閱 你所不知道的C語言: Stream I/O, EOF 和例外處理。
在 harness.h 和 harness.c 檔案中,我們可見對記憶體管理進行相關的調整。說明如下:
將原先的 malloc
與 free
使用 test_malloc
與 test_free
取代。該部份可見 harness.h 檔案內容最後方:
因此,一旦像是 queue.c
這樣的 C 程式引入 (即透過 #include
前置處理) harness.h 後,原本呼叫 malloc
與 free
就被替換為在 harness.c 內所實作的 test_malloc
與 test_free
這兩個函式。為了區隔,以下用 test_malloc
與 test_free
稱呼該二函式,而非 malloc
與 free
。這樣的程式開發技巧稱為 function hooking。
至於為何這檔名叫做 harness
呢?這是有學問的,請見 Test harness。
所有由於呼叫 test_malloc
得來的記憶體,紀錄於一個名為 allocated
的一個雙向鏈結串列中,後者對應的結構為 block_ele_t
,定義如下:
本結構體的最後一個成員 payload[0]
是實際呼叫者使用的記憶體開頭。該成員使用到的 GCC 中 Array of Length Zero 特徵,後者給予開發的便利:若結構體中的最後一個成員是大小為 0 的陣列,那可透過 malloc(sizeof(block_ele_t) + 希望 payload 後面接著多少記憶體)
來達成「最後一個成員大小可任意指定」的效果(另外一個好處是 malloc
跟 free
一次就可以完成,不用替 payload
開頭的記憶體再 malloc
一次)。
圖解上述結構體在記憶體中的抽象呈現:
test_malloc
函式實作如下:
這技巧體現於程式碼的 malloc(size + sizeof(block_ele_t) + sizeof(size_t))
當中。除了 sizeof(block_ele_t)
與 size
, 還多了 sizeof(size_t)
的空間。這是因為使用者實際可使用的記憶體,前後被兩個 size_t
整數包著,裡面各自紀錄著兩個 Magic Number,作為驗證這塊記憶體是否是由 test_malloc
分配的依據,以及作為記憶體是否有產生越界等不佳使用的依據。
test_malloc
配置的空間,示意如下圖:
gcc 的擴充功能 "Arrays of Length Zero" 在 Linux 核心大量使用,可參照中文解說 C Struct Hack - Structure with variable length array
位於配置的記憶區塊之前的 magic number,實際上就是結構體中 magic_header
這個成員,其值會在 test_malloc
中被指定為 MAGICHEADER
,即 0xdeadbeef
; 在記憶體尾端的 size_t
整數,數值必定為 MAGICFOOTER
,也就是 0xbeefdead
。
像 0xdeadbeef
這樣本身是十六進位數值但卻有英語上的意義 (dead beef),稱為 hexspeak,也大量存在於 Linux 核心原始程式碼中。
因為要回傳給使用者的指標實際上是 payload
的位置,所以程式中另外有 find_footer
跟 find_header
這兩個函式作為輔助。前者傳入 block_ele_t*
,回傳位於 payload
尾端 magic number 的位址。如 test_malloc
中有段指定尾端 magic number 的程式:
而後者則是傳入使用者呼叫後得到的記憶體開頭,反推該記憶體所屬的 block_ele_t
的開頭位置。這兩個函式除了用在 test_malloc
當中,也會用再 test_free
當中。在呼叫 test_free
時,使用者傳入的,實際上是 payload
的位置,但釋放記憶體時,除了該記憶體之外,該記憶體所屬的結構體,也要一併釋放。因此需要有一個尋找該記憶體所屬開頭的方法。
這兩個函式的原理不難理解:給定 block_ele_t
,尾端的 magic number,由 payload_size
進行推算即可; 對於反推記憶體所屬的結構體,則是利用 sizeof(block_ele_t)
反推,並且尋找反推過後的記憶體是否在 allocated
的清單中。若沒有,則推論為錯誤的配置。
而在 find_header
中有檢測傳入指標是否為空的設定。
test_free
中用的原理類似:首先用 find_header
找出使用者準備釋放的記憶體,其所屬的結構體有沒有再 allocated
裡面。若傳入的指標為空指標,或是該記憶體不屬於任何一個節點,find_header
內部會分別傳出「試圖釋放空指標」或「記憶體損壞」等警告。
若順利找到該記憶體所屬的節點,接著檢驗尾端的 magic number 是否仍為 MAGICFOOTER
,作為記憶體有沒有被越界存取等狀況的判斷依據。若比對結果發現不合,也會發出該區段記憶體損壞的警告。
若確認結果無誤,則將記憶體區段前後的 magic number 都設成 MAGICFREE
。並且將記憶體內容都用 FHILLCHAR
填充。最後移出 allocated
這個雙向鏈結串列。
不難發現,串連方式其實就是佇列的 insert_head
,只是 block_ele_t
為雙向鏈結串列。配置空間的主要區塊,實際拿來存放資料的空間,也會被填上 FILLCHAR
(即 0x55
) 做初始化:
在分配記憶體時,每呼叫一次 test_malloc
函式,allocate_count
就會遞增;而每呼叫一次 test_free
函式,該數值就會遞減。因此,只要在程式即將結束時,判斷該數值,即可判斷是否妥當地釋放記憶體。
qtest
命令直譯器的實作在 qtest
執行後,會見到 cmd>
命令提示訊息,當輸入一則命令後,會經過以下函式呼叫,一路到 parse_arg
,後者解析使用者的輸入,呼叫流程是:
main → run_console → cmd_select → interpret_cmd → parse_args
之後,interpret_cmda
會在 cmd_list
這個 struct CELE
為元素的單向鏈結串列中找對應的命令。cmd_list
會在 main
裡面的 cmd_init
跟 console_init
呼叫時初始化。
找到後,struct CELE
這個結構體中有個 operation
成員,儲存每個命令的結構體中的 operation
包裝著待測試的函式:
程式執行到此,會將之前解析的輸入傳遞過去。以 it
命令為例:
parse_arg
執行完畢,將跳躍到對應的測試程式,其呼叫堆疊如下:
接著我們可推知,一旦事先提供 do_*
函式,並在 console_init
中呼叫 add_cmd("<instruction>", <do_*>, "<documentation>")
,即可增加新命令。以下示範在console.c
檔案新增名為 hello
的命令。先準備 do_hello
函式:
並在 console_init
中新增以下:
重新編譯後,在 qtest
的命令列中輸入 hello
,可發現該命令已可被命令直譯器所識別:
再輸入 help
,則會發現對應的說明也新增:
我們在 queue_init
函式的實作發現一些特別的東西:
注意到 signal 的使用,註冊了 SIGSEGV
和 SIGALRM
對應的處理常式 (handler)。在 UNIX 或者符合 POSIX 規範的作業系統 (例如 GNU/Linux) 中具備 signal 的機制,可傳送 signal (中文翻譯為「訊號」,不過為了行文彰顯該術語,後面一律用原文 "signal") 給其他程式。當我們在終端機中,透過鍵盤按下 Ctrl+C 組合鍵,作業系統會傳一個 SIGINT 給給定的行程 (process,可理解為「執行中的程式」),而行程收到 signal 後就一定要放下當下的工作,優先處理 signal。
至於如何「處理」,程式會有預設行為,根據不同 signal 往往有有不同處理行為,比如:
Signal | 描述 | 預設的動作 (action) |
---|---|---|
SIGSEGV | 無效的記憶體參照 | 終止行程的運作 |
SIGALRM | 由 alarm 傳遞來的 Timer signal | 終止行程的運作 |
SIGWINCH | 終端機視窗寬度或高度變更 | 忽略該 signal |
這裡需要特別提 alarm,後續會運用到:當呼叫 alarm(n) 後,等待 n 秒後,會觸發 SIGALRM
這個 signal,若 alarm 的參數為 0
,則之前設置的 Timer 會被取消,並將剩下的時間返回。
不過使用者可更改 signal 對應的動作,而且在許多程式會做這樣的操作,例如收到 SIGINT 後,將目前行程的資訊記錄於某個日誌檔案 (軟體的「黑盒子」),待善後舉措完畢後再真正終止該行程。
SIGSEV
(segmentattion fault 會發出的 signal)的處理常式如下:
另一個 SIGALARM
的處理函式是:
可以發現兩者均使用 trigger_exception
,後者的實作如下:
除了設定相關的變數、全域變數 error_message
指向給錯誤訊息外,還有一個 siglongjmp
。
這個 siglongjmp
會跳到哪裡去呢?觀察 lab0-c 裡頭用到 sigsetjmp
的程式碼,會發現在 exception_setup
函式裡頭:
再根據手冊對 sigsetjmp 的描述:
setjmp() and sigsetjmp() return 0 if returning directly, and nonzero when returning from longjmp(3) or siglongjmp(3) using the saved context.
亦即,上述 exception_setup
函式列表的第 3 行會是關鍵 —— 此部分將被執行 2 次:
0
,接著跳到第 15 行執行 else{ ... }
;1
,繼續執行第 5 行之後的 if{ ... }
;我們也可發現除了暫停 SIGALARM
的計時外,若從 setlongjmp() 返回,會呼叫 report_event()
把錯誤訊息輸出於終端機,但因傳入的參數只是 MSG_ERROR
,所以不會終止程式(要是 MSG_FATAL
才會終止)。report_event
函式的實作程式如下:
會發現這是一個接受不定個數參數的函式,主要的功能是輸出錯誤訊息。這也說明為什麼 qtest
收到 SIGSEGV 後,還會繼續執行。
翻閱《The Linux Programming Interface》,得知 signal 運用上的議題:
sigsetjmp
跟 siglongjmp
是能在 signal handler 內部使用的非區域跳躍。不使用 setjmp
或longjmp
的考量點是:
The sa_mask field allows us to specify a set of signals that aren’t permitted to interrupt execution of this handler. In addition, the signal that caused the handler to be invoked is automatically added to the process signal mask. This means that a signal handler won’t recursively interrupt itself if a second instance of the same signal arrives while the handler is executing.
以及:
However, there is a problem with using the standard longjmp() function to exit from a signal handler. We noted earlier that, upon entry to the signal handler, the kernel automatically adds the invoking signal, as well as any signals specified in the act.sa_mask field, to the process signal mask, and then removes these signals from the mask when the handler does a normal return.
What happens to the signal mask if we exit the signal handler using longjmp()? The answer depends on the genealogy of the particular UNIX implementation.
簡言之,當某個 signal handler 被觸發時,該 signal 會在執行 signal handler 時會被遮罩住,並在 signal handler 回傳時恢復。而,在裡面使用 longjmp
時,解除訊號遮照的行為有可能不會發生(是否解除則依照實作決定)。為了保證在非區域跳躍後能夠恢復,所以 POSIX 另行規範規範得以在 signal handler 中呼叫的 sigsetjmp
跟 siglongjmp
。
jmp_ready
的技巧:
Because a signal can be generated at any time, it may actually occur before the target of the goto has been set up by sigsetjmp() (or setjmp()). To prevent this possibility (which would cause the handler to perform a nonlocal goto using an uninitialized env buffer), we employ a guard variable, canJump, to indicate whether the env buffer has been initialized. If canJump is false, then instead of doing a nonlocal goto, the handler simply returns.
因為 signal 有可能在任何時候發生,包含 sigsetjmp
處理中,但未處理完 sigjmp_buf
之際。倘若這時 signal handler 又進行 siglongjmp
,那麼將產生錯誤。改進方式是引入一個 jmp_ready
變數,表示「sigjmp_buf
是否準備好」,並在可能進行 siglongjmp
的 signal handler 中先檢查這個變數,以確保 sigjmp_buf
有正確地初始化。
觀察以下程式碼:
前面是在初始化系列全域變數,姑且先略過這些變數代表的功能,但後面的 add_cmd
顯然是新增命令,其對應實作如下:
再參照 console.h 中的結構體:
於是可發現,在 qtest
命令直譯器所支援的命令是透過一個單向鏈結串列來儲存,而每個結構是用一個名稱 (name
)、一個指向對應功能的函式的函式指標,及一個字串的說明 (documentation
) 所構成。而插入的手法類似 insertion sort,將所有命令以字典順序排列。另外,add_param
運作方式也類似:
對應的結構體為 param_ele
和 param_ptr
:
在 interpret_cmda
中,如果有找到命令,會呼叫對應的 struct CELE
中的 operation
成員。operation
每個函式的型別都是 bool (*do_something) (int, char**)
,對應的命名就是 do_<operation name>
的那些函式。呼叫 operation
時,採用以下手法:
如果收到 SIGSEGV
或 SIGALRM
,那會跳進 signal handler 裡面,而 signal handler 會執行 siglongjmp
,至於接著會跳躍到何處呢?我們繼續分析可發現,每個形如 do_<operation_name>
的函式,都有類似下面這段程式:
當執行流程第一次 (或說「不因 signal handler 而跳來這邊」) 時,會呼叫 exception_setup
,實作如下:
於是可推知,首次呼叫上述函式時,exception_setup
的功能是建立 sigjmp_buf
,並回傳 true
。
回到稍早提及的 if (exception_setup(true))
敘述中,若是第一次回傳,那麼會開始測試函式。若測試函式的過程中,發生任何錯誤 (亦即觸發 SIGSEGV
或 SIGALRM
一類的 signal),就會立刻跳回 signal handler。signal handler 會印出錯誤訊息,並進行 siglongjmp
。由 exception_setup
的程式可以知道又是跳到 exception_setup(true)
裡面,但這時會回傳 false
,因而跳過測試函式,直接結束測試並回傳 ok
內含值。換言之,exception_cancel()
後就算再發生 SIGALRM
或 SIGSEGV
,也不會再有機會回到 exception_setup()
裡面。
將上述討論繪製為流程圖:
綜合上述,bool exception_setup(bool limit_time)
用來判別程式於執行時期是否超過指定時間範圍 (此處 time_limit
設定為有效),探討以下兩種狀況:
jmp_ready == true
→ 呼叫 trigger_exception
保存錯誤訊息 (即 error_message
) 並將 error_occurred
設成 true → 由 siglongjmp
返回 exception_setup
進入 if 迴圈,重新計時並顯示錯誤訊息,最後回傳 false
;time_limited == true
→ 呼叫 exception_cancel
重新計時並將 jmp_ready
設回 false
,此時 error_message
也會設定成空白字元,使其不顯示;若在執行作業要求的函式中,沒有發出任何訊號,就接著檢測個別位於 queue.c 實作的函式回傳結果是否正確。以流程圖總結例外處理和驗證機制:
queue.[ch]
和連帶的檔案,測試後用 Git 管理各項修改,記得也該實作 q_sort
函式。
qtest
實作的記憶體錯誤,並透過 Massif 視覺化 "simulation" 過程中的記憶體使用量,需要設計對應的實驗qtest
實作,撰寫獨立的終端機命令解譯器 (可建立新的 GitHub repository)qtest
命令列功能,使其支援單行或多行編輯、歷史命令處理、命令自動補完 (例如輸入 h
之後按下 Tab 按鍵,自動接續補上 elp
,成為完整的 help
命令)。除了整合程式碼外,應當說明 antirez/linenoise 的運作原理,注意到 termios 的運用