contributed by < Appmedia06 >
queue.[ch]
內的函式,並通過 make test
的自動評分系統,其中包括:
q_new
q_free
q_insert_head
q_insert_tail
q_remove_head
q_remove_tail
q_size
q_delete_mid
q_delete_dup
q_swap
q_reverse
q_reverseK
q_sort
q_descend
q_merge
qtest
提供新的命令 shuffle,參考 Fisher–Yates shuffle 演算法在開始作業之前,先了解 lab0-c 的組成。
console.[ch]
: 實作 ./qtest
的命令界面list.h
: 實做 Linux 雙向鍊結串列的實作qtest.c
: 測試程式queue.[ch]
: 佇列實作接著來看要實作的佇列的架構,在 list.h
中的 struct list_head
結構,可以看到佇列為雙向的鍊結串列。
佇列節點裡的的內容為定義在 queue.h
的 element_t
,其內容包含一個字串 value
,以及一個鍊結串列 list
。
實際上的佇列就如下圖所示,是一個雙向環狀的鍊結串列。
q_new
用 malloc 配置記憶體給新建立的 list_head
,若 malloc 配置記憶體失敗會回傳 NULL
,因此判斷若是成功配置的話,使用 INIT_LIST_HEAD
這個 API 來初始化佇列,使其變成一個環狀的鍊結串列,反之則回傳 NULL
。
q_free
要釋放掉整個佇列的記憶體空間,考慮了以下事項:
list_for_each_entry_safe
,它比 list_for_each_entry
多提供了一個safe指標,來儲存下一個要走訪的節點,來安全的走訪佇列。remove
,接著再釋放掉其元素的資料及指標。list_del
將走訪到的節點先 remove
。q_release_element
將移除的節點裡的資料及指標釋放。head
釋放。q_insert_head
要插入一個節點到佇列的最前面
strcpy
和 strncpy
差異,C99 7.21.2.4 The strncpy function:The strncpy function copies not more than n characters (characters that follow a null
character are not copied) from the array pointed to by s2 to the array pointed to by s1. If copying takes place between objects that overlap, the behavior is undefined.
strcpy
會直接將整個字串複製過去,而它判斷字串的大小即為尋訪字串直到找到字元 '\0'
。但當要被複製過去的目的的記憶體大小不足夠時,就可能發生 buffer overflow 的問題,因此使用 strncpy
讓我們先判斷來源字串的大小用於配置,若配置失敗則表示記憶體大小不足,進而中止插入。list_add
將 new_node
的list和head連在一起,下圖顯示了list_add的流程q_insert_tail
要插入一個節點到佇列的最後面,操作和 q_insert_head
相似,差別差在:
list_add
變成 list_add_tail
我們發現 q_insert_head
和 q_insert_tail
這兩個函式只差了一行,也就是決定要加入的位置。因此我們可以利用 q_insert_head
來實作 q_insert_tail
。原本插入最前端的只要改成插入最後一個節點即可,利用 list_head
的雙向鍊結,引入最後一個節點,即可插入最後一個節點。且提供了比較簡潔的程式碼。
q_remove_head
& q_remove_tail
要從佇列中移除一個節點,而非刪除一個節點。
首先注意到了 q_remove_head
和 q_remove_tail
類似,主要差別和上述一樣,我們可以呼叫 list.h
中方便的 API : list_first_entry
和 list_last_entry
即可找出佇列的頭或尾了。
q_remove_element
list_del_init
移除,並重新初始化這個被移除的節點sp
和 bufsize
皆不為0,則使用 strncpy
安全的將被移除的節點的值複製給 sp
這邊列出 q_remove_head
的函式。
和插入一樣, q_remove_head
和 q_remove_tail
只差一行程式碼而已。 list_first_entry
巨集的功能是取出引入的 head 的下一個節點,因此只要將輸入改為 head->prev->prev
,也就是最後一個節點的前一個,就會取出最後一個節點。
q_size
使用 list_for_each
尋訪整個 list
來計算 list
中有幾個元素。
q_delete_mid
「龜兔賽跑」理論( Floyd's Cycle detection ),利用快慢指標來判斷何時到 list
的中間了。
演算步驟:
fast
以及 slow
指標為head
(開頭)fast
走兩步fast
是否走到最後一個節點,若有則結束迴圈,若無則讓 slow
走一步。fast
是否走到最後一個節點fast
走到最後一個節點時, slow
的下一個節點即為中間值list_entry
取得中間節點,再使用 list_del
移除此節點,最後使用 q_release_element
將此節點的完整釋放針對 fast
以2的幂在移動是否走到最後一個節點,基於我們使用的是環狀鍊結串列,有兩種可能:
list
長度為偶數的:
因為最後一個節點為2的幂,所以 fast
會走到最後一個節點,也就是 head->prev
list
長度為奇數的:
因為最後一個節點不為2的幂,所以 fast
會跳過最後一個節點,直接走到第一個節點,也就是 head
,這也是用 do_while 的理由
q_delete_dup
要將佇列裡重複的節點刪除
list_for_each_entry_safe
尋訪每個節點,定義 next_str
為下一個節點的字串,stmcmp
來判斷目前節點的字串和下一個節點的字串是否相等,並且目前節點不是最後一個節點,若成立,則刪除目前節點dup_flag
來判斷是否為重複節點中的最後一個節點但當我執行 ./qtest
的 dedup
命令時,得到了以下錯誤:
Segmentation fault occurred. You dereferenced a NULL or invalid pointerAborted (core dumped)
錯誤的原因是當 node
走到最後一個節點時,list_entry(node->list.next, element_t, list)->value;
,其 value
是沒有值的,這就導致 strcmp
函式發出 Segmentation fault
。因此我讓判斷條件先檢查是否為最後一個節點,這樣它就可以發現是最後一個節點而不會去執行 strcmp
。
q_swap
先理解 list_move
的功用,實際上 list_move
就是先做 list_del
(移除)再做 list_add
(插入),所以我想要做 swap
節點兩兩交換就可以利用 list_move(node, node->next)
,原理如下:
list_for_each
從 node0
開始尋訪list_move(node0, node1)
,透過這樣的操作後, node0
就在 node1
和 node2
之間了node2
繼續,重複剛剛的步驟即可做到交換了透過上面示意圖思考後,發現 q_swap
再做的事其實跟 q_reverseK
函式很像。若是將 K 設置為 2,那其實就會兩個節點為一組去做反轉了。
q_reverse
基於這邊佇列是一個雙向鍊結串列,所以要把每個節點做反轉,只要將每個節點的 next
和 prev
交換即可。
list_for_each_safe
進行尋訪佇列,每個節點做反轉list_for_each_safe
的中止條件為當尋訪的 node
已經是 head
。也就是說最後要將 head
本身的 next
和 prev
對調q_reverseK
要將佇列k個節點為一組做反轉,若剩餘的節點數目小於k則不需在反轉。
cnt
: 計數node
: 尋訪的節點safe
: 指向 node
的下一個節點head_from
: 紀錄切割範圍的開頭new_head
: 暫時存放切割下來的佇列list_from
設成 head
作為起點,並使用 LIST_HEAD
宣告一個 list_head
作為暫時存放切割下來的佇列list_for_each_safe
尋訪佇列list_from
為起點, node
為終點,使用 list_cut_position
將其切割下來放入 new_head
new_head
反轉new_head
以 head_from
為開頭插回原本的佇列head_from
設為 safe
的前一個,這樣下次才會從 node
的下一個開始切割q_sort
以小到大順序來排序鏈結串列的節點,使用 merge sort 的遞迴方式來實做。
q_sort
next
賦值為 NULL
,來斷開環狀鍊結q_divide
將鍊結串列做 divide & conquer ,回傳即為排序好的鍊結串列prev
串起,最後再將步驟2斷開的頭尾連接起來prev
會亂掉,因此需要將其串起q_divide
head
或其下個節點以為空,代表切到只剩一個,就可以回傳回去了fast
走到最後一個節點的下一個(奇)或走到最後一個節點(偶),此刻的 slow
即為中間節點merge_Two_list
,將左鍊結串列和右鍊結串列合併merge_Two_list
new_head
為合併 left
和 right
的新鍊結串列indirect
(指標的指標)指向 new_head
iter_node
(指標的指標)用來走訪 left
和 right
left
和 right
有一方走完了,即跳出迴圈到步驟9,反之則執行步驟5left
和 right
的值誰比較小,就將 iter_node
指向該指標iter_node
指派給 indirect
,也就等同於將 left
或 right
指派給 new_head
indirect
指向它所指向的指標的下一個節點iter_node
走到下一個節點indirect
indirect
利用到了 <你所不知道的 C 語言: linked list 和非連續記憶體> 說明的間接指標的概念,在第4行將它指向 new_head
指標,而在第11行 iter_node
指派給 indirect
,意思即為將 left
或 right
的節點賦給 new_head
, 接著 indirect
再去指向 new_head
的下一個節點,這一套下來,雖然都是對 new_head
和 left
或 right
操作,但透過操作間接指標,我們就不用直接對這些指標做更動iter_node
也是這樣的操作,透過間接指標對 left
和 right
走訪q_ascend
& q_descend
q_ascend
: 刪除左邊有比較大的節點,也就是說處理後的鍊結串列是升序的
q_descend
: 刪除右邊有比較大的節點,也就是說處理後的鍊結串列是降序的
q_ascend
和 q_descend
兩者是非常相似,可以走訪佇列,比較前後兩個節點的大小來去判斷是否要刪除。差別就差在 q_ascend
是透過從頭到尾,而 q_descend
則是反過來的從尾到頭。
q_ascend
ele
和 ele_next
分別代表前一個和下一個節點的元素head
時,代表結束,跳出迴圈node_num
即為用於計數,當不需刪除節點時,自加一,而最後 ++node_num
則是要加上 head
max
來紀錄目前最大值,但發現在使用 list_del
移除節點時會把原本的佇列串起來,這也就可以讓我繼續走訪節點去比較前後兩個節點了q_descend
其想法和演算步驟都和 q_ascend
一樣,差別僅在它是從尾開始走到頭的,因此需要修改一開始的 ele
和 ele_prev
為最後一個以及倒數第二個,並把 next
都改為 prev
。
當我使用 ./qtest
測試時,帶入<2487. Remove Nodes From Linked List> 給的範例[5, 2, 13, 3, 8]時,使用 descend
會發現 13 這個 字串 被刪除了,透過實驗發現只有雙位數以上的值才有可能會發生這種情況,原來是因為 strcmp
函式的實作是從兩個字串的第一個字元開始比較,所以當字串 "3" 和 "13" 做比較時會先拿 "3" 和 "1" 做比較,因為 "3" 比 "1" 的 ASCII code 還大,所以會直接回傳正數,而導致第13行會判斷 "3" 比 "13" 還大,進而把 "13" 給刪除了。
q_merge
要合併數個長度不同且已排序的佇列,並回傳總節點數量,先理解 q_merge
的參數是佇列,卻說是要合併多個佇列:
queue_contex_t
: 管理一連串佇列的結構也就是說, q_merge
傳入的 head
即為 queue_contex_t
的 chain
的 head
,以下圖解說明:
我把它簡單理解為一個鍊結串列每個節點都是一個獨立的鍊結串列,而我們要做的就是把每個節點的鏈結串列合併。
演算步驟
first
為第一個子佇列first
節點數量並斷開頭尾連結sub
、累加佇列數量、斷開頭尾連結,使用 merge_Two_list
將 first->next
和 sub->next
合併回 first->next
,接著初始化 sub
佇列,繼續走訪直到走完佇列prev
串起,最後再將步驟2斷開的頭尾連接起來實做細節
first
佇列時,要抓的是 head->next
,而非 head
merge_Two_list
來合併兩個佇列時,也都是從它們的 next
開始合併,因為佇列的 head
是沒有東西的first
和 sub
名稱時蠻猶豫的,有考慮過使用 major
和 minor
,但考慮到它們並無主從關係,因此還是使用上述名稱q_sort
一樣,再做合併時會導致 prev
順序亂掉以及需要先斷開頭尾,因此最後需要補上我的筆記:研讀 Linux 核心原始程式碼的 lib/list_sort.c 筆記
研讀 list_sort.c
裡的 merge sort 之後,要將其引入自己的專案裡。以下是流程。
lib/list_sort.c
加進 lab0-c 目錄下EXPORT_SYMBOL(list_sort);
list_sort.h
,將使用到的巨集加入qtest.c
新增 list_sort.h
標頭檔
新增 cmp
函式,用於 list_sort
定義變數 use_list_sort
, 為 1 時,用 list_sort
,反之則用 q_sort
在 do_sort
函式,根據 use_list_sort
決定排序函式
在 console_init
函式內新增
因為安裝 perf 會有版本的問題,因此先看自己的 Linux 版本。
根據自己的版本修改以下安裝命令。
接著,要得到 perf 的權限,可以先透過以下指令來設置 perf 的最高權限。
在 traces
目錄下創建 trace-sort.cmd
,來用 qtest
提供的 time 來計算排序所花的時間。
回到 lab0-c 目錄下執行 ./qtest -f traces/trace-sort.cmd
,
根據命令可以看到,先創建了一個佇列,在其佇列尾端隨機插入 100,000 筆資料,並獲取現在執行的時間,接著就執行排序,排序結束後再次獲取執行時間,因此倒數第二行的 Delta time 就是執行排序的時間。
q_sort
和 list_sort
之間效能落差以下分別統計q_sort
和 list_sort
之間效能落差,資料數從 10 萬筆到 80 萬筆,共 8 個,每次都做 3 次來平均。
q_sort
資料數 | q_sort_1st | q_sort_2nd | q_sort_3rd | Average |
---|---|---|---|---|
100,000 | 0.094 | 0.092 | 0.102 | 0.096 |
200,000 | 0.239 | 0.259 | 0.257 | 0.251 |
300,000 | 0.391 | 0.482 | 0.459 | 0.444 |
400,000 | 0.572 | 0.594 | 0.579 | 0.581 |
500,000 | 0.738 | 0.793 | 0.750 | 0.760 |
600,000 | 0.942 | 0.987 | 0.978 | 0.969 |
700,000 | 1.098 | 1.165 | 1.169 | 1.144 |
800,000 | 1.344 | 1.321 | 1.322 | 1.329 |
list_sort
資料數 | list_sort_1st | list_sort_2nd | list_sort_3rd | Average |
---|---|---|---|---|
100,000 | 0.070 | 0.056 | 0.064 | 0.063 |
200,000 | 0.151 | 0.147 | 0.169 | 0.156 |
300,000 | 0.253 | 0.258 | 0.252 | 0.254 |
400,000 | 0.349 | 0.361 | 0.360 | 0.356 |
500,000 | 0.462 | 0.463 | 0.451 | 0.459 |
600,000 | 0.570 | 0.582 | 0.564 | 0.572 |
700,000 | 0.692 | 0.699 | 0.676 | 0.689 |
800,000 | 0.789 | 0.813 | 0.806 | 0.803 |
排序效能比較圖
可以看到當資料量越大時,效能差的越多。
perf 提供了很多事件的觀測,現在要來看兩個函式分別在機器上跑的 cache-misses 、 instruction 、 cycle 。
q_sort
list_sort
將上面結果整理成表格
q_sort | list_sort | |
---|---|---|
cache-references | 49,269,944 | 40,668,846 |
cache-misses | 33,195,862 | 28,224,650 |
instructions | 3,308,935,634 | 3,291,921,745 |
cycles | 3,504,986,169 | 2,727,824,372 |
insn per cycle | 0.32 | 0.64 |
從上面表格可以很明顯看出來, Linux 的 list_sort 因為其設計對 cache 友善的演算法,因此比 q_sort 的 cache-misses 少了 17% ,也因為這樣,使其 cycle 的數量少非常多, inst per cycle 也比 q_sort 好了 2 倍。
根據第二周作業,實作了 Timsort,現在需要將其整合到 qtest 命令中。具體步驟如下:
timsort.c
和 timsort.h
(sort_impl.h
) 添加到 lab0-c 目錄下。qtest.c
中新增 do_timsort 函式,該函式基本上就是將 do_sort
中的排序演算法改為 timsort
函式。console_init
函式中新增 timsort
的指令。另外,在上面提到的 list_sort
部分有個疏忽,直接編譯會產生以下錯誤訊息。需要先在 Makefile 裡新增輸出檔,才能解決這個問題。
在提交 commit 時, Cppcheck 發出了錯誤訊息:
原來是需要將指標做初始的賦值。
詳細修改: commit
make test
實作完佇列後便可以測試,輸入 make test
,會去用 qtest
執行 scripts/driver.py ,得到結果為 100 分,代表通過自動評分系統的所有項目。
參考 以 Valgrind 分析記憶體問題 ,得知 Valgrind 的用途簡單來說就是讓使用者可以觀察程式在執行時的一些狀況,例如使用未初始化的記憶體、不當的記憶體配置、或取消配置記憶體等等。
現在要開始測試,先輸入
就會得到動態分析後的結果
結果得到了 100 分,代表並無出現有記憶體的問題,這邊我順便列出 Valgrind 可以偵測出的記憶體相關的問題。
Memory Leak
當程式配置了記憶體,但在釋放前就已經沒有指標指向此塊記憶體,依據情況又可以分成以下四種。
Invalid Memory Access
有些對記憶體有危險的操作並不會馬上發生 Segmentation fault ,但是在之後執行的程式很有可能就出現問題了。可能會有以下幾種狀況。
Segmentation Fault: 它會出現在當程式企圖存取CPU無法定址的記憶體區段時。當錯誤發生時,硬體會通知作業系統產生了記憶體存取權限衝突的狀況。作業系統通常會產生核心轉儲(core dump)以方便開發者進行除錯。
下列幾種情況可能會導致 Segmentation Fault :
其他案例
使用工具 massif 來視覺化 qtest 使用時的記憶體消耗量。
可以先將 harness.c
裡面的 time_limit
調整大一點,因為 Valgrind 會使程式的速度大幅下降,就很可能發生 timeout ,所以暫時調高 time_limit
可以讓我們先得到其數據。
輸入命令。
就可以得到 massif.out.xxxx
的檔案,接著到 Massif-Visualizer 用 Ubuntu Software 安裝 Massif 。
安裝完成後,輸入命令,其中 xxxx 為生成的數字。
就可以得到記憶體消耗量的視覺圖了。
shuffle
命令tail
反向的走訪佇列,每次都和前面隨機一個節點 rand_node
交換將實作佇列洗牌的函式 q_shuffle
寫在 qtest.c
中。
接著要把 shuffle 加入 ./qtest
的命令中,根據前面 do_what
函式,寫 do_shuffle
函式,最後顯示佇列前 3 個元素。
繼續在 qtest.c
中的 console_init
函式中新增 ADD_COMMAND
。
執行 ./qtest
,使用命令 it RAND 10
先插入隨機 10 個字串,接著執行 shuffle
命令後發現出現以下錯誤。
檢查後發現是在找隨機節點時可能會存取到無效的指標,因此修改以下程式。
根據老師給的 測試程式 來檢驗 shuffle
的亂度,創建一個測試用的 python 檔,安裝必要的套件包,執行命令 python3 test_shuffle.py
,便可以得到洗牌亂度的直方圖了。
但這裡發現亂度非常不均勻,看完老師的 M06: integration 後發現拿掉時間種子就可以解決了。
會導致這樣的原因,要先從 rand()
函數說起,根據 C99 (§ 7.20.2.2) 說明了 rand()
函式的作法,它會將 next 進行一次 Linear congruential generator ,接著再將其限縮在 ,也就是 之間。
而 srand(time(NULL))
會初始化當下時間一次,但初始化 next
變數後,它便會去做 Linear congruential generator,就和時間無關了,而移除掉它的功能是每次 shuffle 都會將 Linear congruential generator 的 seed 初始化為當下的時間,這樣就符合 Uniform Distribution 。
但實際上這樣並沒有達成真正的洗牌,每次shuffle 都是一樣的操作,根本沒有 shuffle 到,因為每次 seed 都被 static unsigned long int next = 1;
這樣初始化。為其實 shuffle 是有規律的,只是不知道它從週期的哪個起點開始。那有規律不就不是獨立事件。
這篇論文介紹了 dudect 工具,用來檢測特定平台上的程式碼是否以 constant time 運行。作者們的方法基於 TIMING LEAKAGE DETECTION ,提供了一個簡潔且易於使用的方案。
該工具不依賴於靜態分析,而是基於對使用平台上執行時間測量的統計分析。這樣一來,就避免了對底層硬體進行建模的問題,也就是說可以不用在特定的硬體環境下測試。該工具的設計使得在黑盒測試情況下使用時具有極大的優勢,並且可以通過實施細節來提高檢測定時攻擊的效果。
根據論文中的 II. OUR APPROACH : TIMING LEAKAGE DETECTION 說明實作方法。
Step 1: Measure execution time
Step 2: Apply post-processing
描述了在執行時間測量後,實踐者可以對每個單獨的測量進行後處理,以進行統計分析。
Step 3: Apply statistical test
第三步是應用統計的測試,以試圖推翻"兩個定時分佈相等"的虛無假設。可以選擇不同的統計測試方法,以確定兩個定時分佈是否存在差異。
在 lab0-c 中有實作論文中的 dudect 程式,只是當我們將 lab0-c push 到 github 上時會發現在 trace-17-complexity 0/5
是沒有分數的。因為實作程式碼和論文上說明的實作方法有出入,因此現在要來修改它。
看到 fixture.c
,透過註解可以理解這個程式是多次測量給定函數的執行時間,使用 Welch's t-test 檢測函式是否為常數時間 。而 doit
就實作的函式。
doit
函式的實作流程
prepare_inputs
來準備輸入資料。constant.c
中有 measure
函式,會檢測的函式執行前後的 cpucycles
,而 mode
則是選擇要檢測哪個函式(有 ih
, it
, rh
, rt
)。differentiate
將前後時間相減獲得執行時間。update_statistics
去除掉 CPU cycle counter 中非正常的值。report
計算 Welford method 的 t 值, t 值用於統計數據確定數據之間的平均值是否存在顯著差異。如果 t 值大於 threshold 及判定不是恆定時間。回傳布林代數。而和論文的實作 dudect 比較後發現缺少了 Cropping 的動作。因此需要在 fixture.c
裡增加可以將測量結果進行預處理,丟掉超過特定百分比的測量結果。
update_statistics
中新增判斷是否超過特定百分比percentiles
陣列,當第一次執行的時候,先呼叫 prepare_percentiles
用來初始化 percentiles
的值。接著就和原本程式一樣呼叫 update_statistics
和 report
。只是 update_statistics
多了上述檢測功能。詳細差異: commit
經過修改後,最終 push 到 github 上,成功得到 100 分。
這篇文章首先提到了人們在程式設計領域往往過於急躁,只求結果而不願深入學習。這種表面涉獵卻缺乏深度的方式,往往造就了技能不足的程式設計師。
這讓我回想起大一大二時期,當我學會一項技能後,便急於開始專案開發。然而,現在回顧當初的作品,發現其中許多不成熟之處。若當初能夠充分補充知識,或許專案會更加完善。
目前我正在學習 Linux 核心設計課程,這門課程要求的知識量相當龐大。因此,我決定調整自己的節奏,深入吸收各個知識點,包括你所未接觸過的 C 語言系列以及與 Linux 系統相關的知識,並且努力完成所有作業。雖然我尚未修習過這門課程,但我期望通過自己的努力學習,成為浩瀚的程式碼中一個微小的齒輪。