contributed by < toastcheng
>
linux2021
qtest
執行過程中的錯誤
qtest
再於命令提示列輸入 help
命令,會使 開啟 Address Sanitizer 觸發錯誤,應予以排除qtest
實作的記憶體錯誤qtest
中實作 coroutine,並提供新的命令 web
,提供 web 伺服器功能,注意: web 伺服器運作過程中,qtest
仍可接受其他命令
FORK_COUNT
變更為 0
,並以 coroutine 取代原本的 fork 系統呼叫qtest
實作,撰寫獨立的終端機命令解譯器 (可建立新的 GitHub repository)首先來設置作為開發的環境,手邊較為方便的電腦有一台 Macbook Air,雖和 Linux 同是 Unix-like 的作業系統,但許多相關套件都略有出入,因此最初想使用 Virtualbox 來使用虛擬機,後來考量電腦負擔太重,轉而嘗試以容器化的方式可以更為輕量,因此用 docker 來準備需要的 linux 環境。
build 完 docker image 後將需要的檔案掛載到容器中 (-v <host path>/<container path>
) 執行 bash 便能開始進行開發。
首先嘗試將尚未實作的函式完成,讓這個專案的基本功能可以運作。觀察 queue.h
可以一覽我們期望這個佇列 (queue) 資料結構 queue_t
的長相(此將註解省略):
queue_t
為一個含有連結串列 list_ele_t
的結構體,每個節點的值是字元指標,或俗稱的 C-style string,queue_t
應該支援的函式如下:
q_new
初始化,建構函式q_free
解構函式q_insert_head
在連結串列的開頭插入一個節點q_insert_tail
在連結串列的尾巴插入一個節點q_remove_head
移除連結串列的開頭節點q_size
回傳連結串列的節點個數 (大小)q_reverse
將連結串列的順序倒排q_sort
將連結串列的節點依值的順序排序q_size
commit: Add members to queue_t and impl insert head/tail
先從較為簡單的函式開始,q_size
需要回傳此佇列的大小,在最單純的情況,若我們只有連結串列的開頭 head
,則必定需要花 的時間走訪各個節點直到最後一個節點的 next
為 NULL
,方能知道佇列的大小。
但每一次花費線性時間來取得佇列大小並不合理,最直接的方式是用一個額外的整數變數 size
來將佇列的大小資訊暫存起來,若佇列大小因為操作而改變(如 q_insert_head
、q_remove_head
),則我們一併修改 size
即可。
將 size
加入 queue_t
:
則 q_size
只需要將佇列的 size
回傳即可。
但若是佇列 q
為空,硬是去對其取值會引發 segmentation fault。
因此需要做一次額外的檢查,若 q
根本沒有初始化,也回傳 0。
// TODO inconsistence
q_insert_tail
q_insert_tail
將字串 s
加進佇列q
的尾巴。最單純的實作是先從佇列開頭一路走訪到最後一個節點,再將新節點加入,但這會跟 q_size
有一樣的問題,僅僅是加入一個節點卻要 的時間複雜度。用相同的概念,我們可以再次在 queue_t
加入新變數 tail
來紀錄最後一個節點的位址,如此便省略從頭走訪的時間。
q_insert_tail
的主要實作很單純:將新的節點 newt
(new tail) 指派給目前佇列的最後一個節點 q->tail->next = newt
。
但其中有許多要注意的狀況:
q
若為空值需要回傳 false
。newt
若因為 malloc 失敗而為空值要回傳 false
。s
時若失敗要回傳 false
,並將本來已分配給 newt
的記憶體回收。q
尚無任何節點,q->head
和 q->tail
同為空值,則要將兩者都指派為 newt
。q->size++
。一開始並沒有顧及 malloc
回傳值為空的狀況,閱讀 man page malloc (3) 可以知道若是想分配的記憶體超過 RLIMIT_AS
或 RLIMIT_DATA
便會引發 ENOMEM
記憶體不足的錯誤,雖然在現代的電腦開發環境,很難觸碰到這個極限,但許多嵌入式系統的開發中,因為資源較為有限,因此這個限制會低得許多。
那在我的機器上需要跟 malloc
要求多少記憶體才會引發 ENOMEM
呢?按照 man page的說明,繼續去找 getrlimit (2) 發現可以用 getrlimit
來使用系統呼叫:
rlimit
是一個帶有 soft limit、hard limit 的結構體。使用 getrlimit
時第一個參數傳入不同的 flag 便能得到 kernel 對不同資源的限制為何(結果會放在 rlimit
中)。
例如在這個作業中我想知道一個 process
允許被分配多少記憶體,就帶入 RLIMIT_NPPROC
這個 flag 即可。結果為:
為一個非常大的值,這個值也剛好是 。
但就算 malloc
成功,並不代表記憶體真的夠用,繼續閱讀 malloc (3) 會知道,kernel 有 overcommit 的機制,當要讀寫時發現記憶體真的不夠用時,process 便會被 OOM killer (Out of memory killer) 終止。
另外還有幾點實作的考量:
strlen
本來的實作是一個一個 byte 讀取,直到讀到 \0
終止。
起初很確信必然得這樣才能知道字串的長度(畢竟也沒有別的資訊了),但在 你所不知道的 C 語言:數值系統篇 中發現 strlen
還是比較快的實作方式,因為我們確實不必一次只讀一個 byte,可以一次讀 4 byte 再檢查其中有無 \0
!
strcpy
strcpy
已被指出是不安全的函式,Common vulnerabilities guide for C programmers 中說明 strcpy
的問題在於沒有指出 buffer size 大小,如果字串複製的目的地太小,那複製後可能會蓋到其他的記憶體區段,也就是 Buffer overflow。
這裡的問題在於 str1
的長度只有 10,str2
多出來的 5 字元寫到了自己所在的空間。
strcpy
後 str2
的記憶體空間被竄改了。
這也是為什麼 strlen(str2)
會變成 4,因為 \0
被寫到 str2
的第五個字元,後面的字元便被截掉了。
按照 CERN 的建議,換成 snprintf
即可有較為安全可預測的行為,第二個參數明顯的確保不會寫超過 10 bytes:
當然如果故意把 buffer size 設超過 str1
的大小也是會有同樣 buffer overflow 問題,。
q_insert_head
q_insert_head
將字串 s
加進佇列q
的開頭,跟 q_insert_tail
的實作相似
。函式的邏輯:將新的節點 newh
(new head) 的 next
指向佇列當前的 head
,並將佇列的 head
更新為 newh
。
須要注意的地方跟 q_insert_tail
相似,因此不再贅述。
q_new
這個函式將初始化一個佇列 queue_t
,並將其變數也一併初始化並回傳,若沒有將指標 head
和 tail
設為空值,預設會是不確定 (indeterminate),可以見這篇討論。因此後續確認節點存不存在的操作如 if (q->head) { ... }
實際上是會執行的,也就是說會認為 q->head
是有值的,而嘗試去 dereference 而發生錯誤。
q_free
將 q
所使用的記憶體釋放掉,也要將其管理的連結串列一併釋放,包括每個節點的字串。
q_remove_head
q_remove_head
這個函式將第一個節點移除、釋放其記憶體空間,並將該節點的字串複製到給定的字元指標 sp
。最一開始我沒有想太多,利用 memcpy
來處理 bufsize
空間不夠的狀況,為了滿足 sp
為一個大小最大為 bufsize - 1
的字串,將前 bufsize - 1
個字元複製過去,最後再補上終止字符 \0
。
但後來參考了前面提及的 snprintf
,可以將其改寫成較為簡潔的形式:
最後將開頭的節點釋放記憶體,並減少佇列的大小 q->size--
。
q_reverse
q_reverse
將連結串列的順序顛倒。首先將原本的 head
及 tail
保存下來,另外宣告兩個指標 prev
和 cur
。用 cur
從 head
開始走訪各個節點, prev
則是如其名「跟在」 cur
後面,cur
將每個走訪到的節點的 next
都指向 prev
,以此達到顛倒順序的效果,最後再將 q->head
及 q->tail
對調。
q_sort
q_sort
對連結串列做遞增排序。一開始我使用 quicksort 實作,但發現測試的條件有隨機的輸入以及「遞減」的輸入,對於 quicksort 來說如果 pivot 選擇不當,會讓 divide and conquer 效果大大降低,最壞情況是 的時間複雜度,並不適合這個情境,因此我考量使用 merge sort 來實作,無論是平均或是最壞情況都有 的複雜度。
q_sort
將連結串列交由 mergesort
進行排序,排序完後再從新的 head
開始找到最後一個節點,將其指派給 tail
。而對於 q
為空值或是大小小於 2 不會做任何操作。
mergesort
利用快慢指標,找到連結串列位於中間的節點,將其一分為二遞迴地執行,最後再將兩個連結串列用 merge
組合成排序完成的新串列。
merge
則是真正進行合併的地方,將兩個連結串列從頭一一比對,*result
指向值較小的節點,並前進到下一個節點,較大的節點將繼續做下一輪的比較。直到其中一個連結串列走到盡頭,將連結串列*result
依序指向另一個連結串列剩下的節點。
一開始我用了陣列的方式去思考,很習慣地在 merge
函式中、用
去處理剩餘的節點,但驚覺這裡可以優化,多虧連結串列的彈性,我能直接將剩餘的連結串列直接接在 *result
之後:
短短一行便能完成,簡潔多了。
實作完以上函式,跑 make test
便能通過所有測試!
加入 SANITIZER=1
的 flag 重新 make
一次,在 qtest
裡下 help
後給出錯誤:
錯誤訊息中寫到 echo
這個全域變數似乎跟某個位在 0x55e284086401
的局域變數碰撞上了,很可能是 buffer overflow 所致,在某個 buffer 寫入超過其大小的 byte。
觀察 echo
,宣告在 59 行:
其他被使用的方式只有正常的讀寫,除了 108 行:
將布林指標轉型成整數指標,兩種指標指向的記憶體位址大小不同,很可能是在寫入 echo
時溢出了,考量到 echo
接受來自命令列的輸入,將之設成 int
也不會影響其使用,因此便將宣告改成:
重跑 make SANITIZER=1
,得到新的錯誤訊息:
這次將焦點放在 simulation
上,同樣為一個布林變數,但 simulation
並非靜態變數,意思是在其他目標檔中這個變數也是可見的,所以需要連同 console.h
中的宣告一併修改,改成 int simulation
之後再 make SANITIZER=1
一次,問題便解決了。
AddressSanitizer Algorithm 上針對 AddressSanitizer 的幾個核心功能做的簡短說明
用 make valgrind
來使用 Valgrind 分析,可以發現到很多 still reachable 的記憶體區塊。
為避免被輸出訊息淹沒,可以分別跑不同的測試:
使用不同的測試資料,都是出現 still reachable
的訊息,可能意味著記憶體並沒有釋放。可以感受到 Valgrind 非常強大,在執行時期檢查記憶體使用情形,還能精確給出發生問題的行數。根據輸出資訊發現每一個測試結果是類似的,都是
linenoiseHistoryAdd
strdup
看起來都是跟 history
有關,所以先從 history
被釋放的地方找起,可以發現只有 freeHistory
這個函式會釋放記憶體,所幸釋放的路徑沒有太複雜,呼叫 freeHistory
的也只有 linenoiseAtExit
一個函式。
繼續觀察會發現整個函式呼叫的順序如下:
而 atexit(3) 是用來註冊當程式結束時要執行的函式。因此只要這個函式被呼叫到,history
應該要能被釋放。
直到看到 run_console
便豁然開朗,在 has_infile = true
的情況下,是不會去呼叫到 linenoise
的。
解決的方式有二,一是將 history
正確釋放,二是不要去分配記憶體給他。前者因為 history
及對應的釋放函式都是 static
屬性,要改動 linenoise.c
及 linenoise.h
,相對牽動的檔案比較多,而且跑檔案並沒有明顯紀錄歷史操作的需求,因此採取後者:若是跑檔案的模式 (-f
),則不分配記憶體給 history
。
這樣便能順利通過 make valgrind
。
massif
視覺化觀察valgrind --tool=massif ./qtest -f traces/trace-01-ops.cmd
在 simulation 模式下,只會執行 is_insert_tail_const
及 is_size_const
這兩個函式,搭配 trace-17-complexity.cmd
來觀察:
// TODO
先試著玩玩 tiny-web-server
。
看看 localhost:9999,可以發現伺服器正常運作!
首先找出 tiny-web-server
監聽 port 9999 上流量以及對應 handler 的部分。
很快可以把範圍縮小到 process
、handle_directory_request
函式。
觀賞一下 process
函式:
開頭 3 行是寫入 log,第 4 行將請求剖析成可用的結構體。第 8 行之後是將使用者的請求路徑用 open
打開,S_ISREG
查詢 man page inode (7) 中意思是 is it a regular file?
。 可以知道這裡是判斷該檔案合不合法的邏輯,若是合法檔案則由 serve_static
來處理,若是目錄,則由 handle_directory_request
處理。
接著看一下 serve_static
是如何處理客戶端的請求:
簡單來說,serve_static
會拿到一個 file descriptor out_fd
,這是給客戶端的回應所使用的 file descriptor,先是在 buf
寫入回應的 header 以及內容,最後再一次用 writen
寫進 out_fd
並關檔。
這裡的情境並不需要開關檔,只需專注在如何在伺服器端讀取客戶端的請求,因此將 process
改寫並加上 handle_command
函式:
而 handle_command
先把它做成很簡單的 echo server 來試試:
這樣一來便能成功從客戶端接收指令!
請求和回應可以順利改動之後,把焦點轉移到程式如何同時服務多個來自客戶的請求,在 main
中可以看到 fork
的使用:
在 for 迴圈中 parent 會產出 FORK_COUNT
個 child,而他們會陷進 while 迴圈接受請求,而 parent 自己在產完 child 之後也會進入最下方的 while 迴圈開始等待請求。
而在進入 for 迴圈之前有個有趣的地方:
查詢了 man page pipe (7):
如果客戶端在伺服器寫完之前就關掉瀏覽器或是關閉 socket 連線,那會導致 write
發生錯誤而收到 SIGPIPE
signal。預設會讓 process 直接終止,這邊把他忽略掉。
接著將之整合進 qtest
,首先將 tiny.c
整理出需要的函式,並新增 header 檔 tiny.h
,在這我一併將 main()
更名成 start_server()
以供 qtest
使用。
qtest.c
中,加入命令 web
:
也要在 Makefile
中加入 tiny.o
才能連結到目標:
Makefile
中新增 tiny.o
:
重新 make
過後就成功加入新功能!
接下來的問題是,如何將收到的請求轉換成命令執行。目前的程式碼依靠 linenoise
將命令列的命令讀取出來,再交由 interpret_cmd
執行,以及存入歷史等,詳細可以參見 run_console
函式:
我希望可以重複使用 interpret_cmd
,讓它也能處理來自使用者的請求,因此將其加入 console.h
,從 static 函式改為一般的函式供其他檔案 (qtest.c
) 使用。而我希望使用者可以從伺服器的回應看見佇列的資訊,但所有的命令都是透過 report
函式將訊息呈現在命令列,一時間難以全面支援伺服器版本,因此新增函式 void write_queue_info(char *buf)
來將 show_queue
的資訊寫入 buffer,再由 handle_command
將該資訊回應給使用者。
在將 tiny.c
加入的同時發現它並不符合 commit hook 的規範,所以也順便將其改寫,避免使用 sprintf
而用 snprintf
加上 buffer size,以防 buffer overflow。
對應的 Github commit: Add tiny-web-server to qtest。
最後試著用 coroutine 的方式改寫 web
命令。先 #include <setjmp.h>
來使用 setjmp
、longjmp
。想了一些方式實作 coroutine,但先從簡單的命令列和伺服器切換開始,coroutine 跟 multithread 的差異之一是,切換工作時是「自發的」,也就是說一個 coroutine 在讓出 cpu 的時間點是事先設計好的,例如 Golang 中的 goroutine (coroutine) 設計在引發 function call 的時候讓出 cpu。
因為我的實作中伺服器和命令列執行命令會是執行相同的函式,所以我使用了一個變數 is_cmd
來決定現在要換成誰執行,然後用兩個 jmp_buf
來管理伺服器和命令列兩邊的 context。
在 server 接受連線前 setjmp
:
然後讓出 cpu 的時間點,簡單起見我只在 insert_head
和 insert_tail
執行完時設定 longjmp
:
有一個須要注意的點,在 longjmp
回到當初 setjmp
處時,局域變數的值會改變!這一點會造成不可預期的錯誤,以此為例:listenfd
是伺服器創造的 socket descriptor,伺服器會重複使用它來接受一個又一個的連線,但當從 longjmp
跳躍回來時,它的值卻
因此將 listenfd
放置在全域讓其在 coroutine 切換時不會改變,更標準一點的做法應該是可以有一個 context 結構,保存這些重要資訊,其中包含 jmp_buf
。
最後重新 make
便能得到支援 coroutine 切換的版本。對應的 Github commit: Implement coroutine to execute both cmd and server
console.c
實作及 select 系統呼叫// TODO
antirez/linenoise
// TODO
// TODO
// TODO