contributed by < Max042004 >
Denny0097
commits 格式不一致
只有 commit 915f77 的 title 在最後加上 "in queue.c",
而在 c0bc6e8 ~ a372995 的 commits 都有另外加上 "functions",建議統一。
整體很完整,已收藏,感謝。
rota1001
q_new
的實作中,根據 CONTRIBUTTING.md,使用 early return 技巧把 if
中的程式碼搬出來可以讓程式碼更簡潔。q_delete_mid
中,可以發現要移除的東西永遠和 backward
相同,所以可以把兩者合併。目前總分95,trace17未通過
以 malloc
分配記憶體,用 INIT_LIST_HEAD
初始化雙向鏈結串列的 head ,使其指標均指向自己
然而另一個巨集 LIST_HEAD
也能建立並初始化一個 struct list_head
的物件,那為什麼不能使用巨集 LIST_HEAD
來建立一個 list_head
呢? 從 Leorium 的解釋得知了答案,因為 LIST_HEAD
所建立的物件屬於 q_new
的 stack memory,所以一旦離開 q_new
後,所屬的 stack memory 被釋放後就無法再存取。
走訪每一個節點並一一釋放記憶體的方法,要使用 list_for_each_entry_safe
而非 list_for_each_entry
,因為後者會在走訪上一個節點之後才存取下一個節點的地址,因此當我釋放上一個節點後,就無法存取下一個節點的地址。而前者會在走訪上一個節點的時候就先存取下一個節點的地址,避免了存取不到地址的問題。
走訪的過程用 q_release_element
釋放記憶體,因為 element_t
的成員包含指向佔有記憶體空間的字串指標,因此需要先釋放字串的記憶體才可以釋放 element_t
的記憶體,否則會造成字串的記憶體洩漏。此外 q_release_element
使用 test_free
對記憶體做更多必要的檢查和防護機制,防止不當的釋放行為,也能在發現記憶體被修改時發出警告,並透過一個雙向鏈結串列紀錄使用的記憶體。這樣的設計在除錯和檢測記憶體洩漏或損壞時非常有用。
因為 list_for_each_entry_safe
並不會走訪 head,所以最後需要特別釋放 head ,在 commit 7833122 引入 harness.h
, free
會自動替換成 test_free
。
藉由實作 q_insert
減少重複的程式碼,參考楊志璿的作法,使用函式指標減少多餘的if-else判斷。而後來 commit 的時候出現 cppcheck memleak 的警告, cppcheck 以為這塊記憶體之後無法再被存取,但實際上透過 list_add
將節點連接上雙向鏈結串列以後,可以經由前後節點的指標存取到,所以並不是記憶體洩漏。參考陳麒升的作法加上 cppcheck-suppress memleak
引入 harness.h
以後, strdup
會被 test_strdup
取代,差別是後者在實作中分配記憶體時呼叫 test_malloc
後來在做第二周作業的時候,回過頭來改善程式碼時,從陳麒升提示,發現 harness 是用來在測試階段引入用來快速偵錯的,不能把include harness push 到上去。
後續跟 Leorium 的討論中,嘗試將 queue.h 中 include harness.h 移除,測試看看 harness 裡面的 test_malloc 和 test_test 對於效能的影響,於是使用 Valgrind Massif 工具測量記憶體用量與執行時間。我同樣測量之前測過 trace 14
可以看到與之前記憶體用量相比,用量少了大概 1/3 ,但時間也有減少
然而 commit hook 限制不得更改 .h 的檔案,所以我不確定 harness 的使用範圍是?
Commit 7dd8042
先使用 snprintf 複製字串 。
之後要比較 snprintf 和 strncpy, memcpy 的差異性
將 list_first_entry
改為 list_last_entry
,其他部分一樣。
commit b458c98
用 list_for_each
走訪每一個節點的同時用 len
紀錄節點數目
用兩個指標,forward
從head
的下一個節點為起點順著走訪每一個節點,backward
從head
的上一個節點為起點逆著走訪每一個節點,forward
使用list_for_each_safe
走訪,用 n_forward
儲存 forward
的下一個節點。當 forward
和 backward
指向同一個節點時,此節點為佇列最中間的節點,用 list_entry
取得容器結構體的地址,先將節點移除再釋放記憶體;當節點個數為偶數的時候,處理的是 backward
和 n_forward
同時指向的節點。
後續使用三元運算子改善了 if-else 中重複的程式碼
Commit 77a1c13
起初作法用 i
記錄目前走訪的是第幾個節點,然後對以 k
為模對 i
取餘數
後來參考komark06的作法,發現上述作法在累加 i
的過程可能造成溢位,所以改以 i==k
比對,並在每一次比對後將 i
設為0,以避免整數潛在的溢位風險
Commit 77a1c13
採用老師上課講解的作法, q_swap
為 reverseK
在 k = 2
的特例
起初參照你所不知道的 C 語言: linked list 和非連續記憶體中遞迴版的 Quick sort 實作,但後來發現測試要求是 stable sort ,所以改為 merge sort
實作一個 helper function _merge
依照升序或降序合併兩個鏈結串列。
然而卻無法通過 trace14 ,從 ./qtest 的執行結果推測是 q_sort 時間複雜度不通過。
參考巫冠君的實作,推測也可能也是因為遞迴造成stack overflow,但在massif的檢查中,stack 用量顯示 0 ,記憶體用量均來自 heap ,所以跟 stack overflow 無關。
所以比較有可能是時間複雜度不通過的問題
後來發現因為以下的操作過於沒效率,可以用 list_cut_position
改善,改善後就通過 trace14了
題目要求為: Remove every node which has a node with a strictly less value anywhere to the right side of it
從環狀雙向鏈結串列尾部、也就是 head->prev
作為起點逆向走訪,用 strcmp
比對當前節點和其 prev
節點的 value
,如果回傳小於0,代表當前的節點比較小,因此前一個節點必須移除。藉由環狀的特性,不斷的走訪並比對。
一樣的實作,只更改判斷 strcmp
的回傳值是否大於0。
Commit 61ad05e
在 queue.h
可以看到 queue_contex_t
的定義,以雙向鏈結串列指標 q
指向佇列的 head
,並且使用 chain
與其他佇列串連在一起,用 size
紀錄這個佇列的長度
作法參考komark06
不過目前 qtest
並未提供以降序合併所有佇列
在這裡,我參考 I-HSIN Cheng 使用 massif 分析 trace14 的記憶體用量,分析的過程中產生了許多不符合預期的結果,後續發現只是因為一小部份的程式碼不夠有效率。
用 massif 追蹤改善前後的執行時間差別
改善前
改善後
雖然改善後記憶體用量增加,不過我最主要追蹤的是執行時間
在兩次 screen shot 之間的時間差對應 trace 14 執行 reverse 和 sort 的過程,在我改善 sort 的實作後,這段執行時間從大約 670,000,000 下降到 580,000,000。
signal 是一種處理程式例外情況的功能,當引發 signal (比如按 ctrl-c ) ,行程一定要停下手上的工作,優先處理 signal 。 引發 signal 後有預設行為,比如終止行程,也可以自行定義:
使用 signal ,我用很簡單的程式練習如何使用 signal
閱讀到 report_event 時,注意到以前沒看過的語法
上述程式碼使用兩層三元運算子
在 ISO/IEC 9899 6.5.15寫到:
代表三元運算子允許巢狀結構
命令列直譯器:
我一開始以為 add_cmd
用單向鏈結串列儲存命令,並且將以字典順序排列,是確保不會出現 strcmp
沒有比對完全,僅尋找到前面 n 個字元相符就回傳 0 的情況。
但後來查看 man page 和實驗後,發現 strcmp 只會在兩個字串完全相同才會回傳 0 。
是 strncmp
會比對給定的前 n 個字元,一樣就回傳 0 ,不管剩下的尚未比對的部份。
檢測非預期的記憶體操作或程式執行逾時:
exception_setup
會在首次呼叫走 else,設定 alarm(time_limit) ,一旦超過 time_limit
但該行程還沒結束時,會送出 SIGALRM
,然後立刻跳回 signal handler
。signal handler
會印出錯誤訊息,並進行 siglongjmp
跳到 exception_setup
並且走 if ,然後回傳 false ,結束該測試。
exception_setup
透過 sigsetjmp
的設計而執行兩次。
我原先用陣列儲存佇列的地址以減少迴圈次數,並且透過更改字串的指標實現節點更改順序的效果,但struct的記憶體配置把成員相鄰地放在一起,依據連續的存放位置進行讀取,所以我誤認為讀取該struct時依舊是讀取原本的成員。不過後來在寫自我評量的過程中查詢規格書學習到:假如結構體的成員是指標,則指標所指向的記憶體並不是與結構體排列在一起,所以我原本的想法是可行的,我又回過頭來除錯本來的程式,並且把宣告陣列改成動態記憶體配置,然後成功實現 shuffle
接下來發現程式碼涉及兩塊記憶體的值的原地交換,於是嘗試練習老師上課教的 bitop 並根據你所不知道的 C 語言:bitwise 操作以及 ISO/IEC 9899 6.5.11:
Each of the operands shall have integer type.
必須先將原本的 char * 型態轉換成 integer ,考慮到有號整數容易發生意外,所以選擇將型態轉換為 unsigned int 。也實現 shuffle
接下來利用作業說明提供的python程式碼驗證 shuffle
的亂度,在實際執行時,程式碼在重複執行
input += "shuffle\n"
時會造成程式碼卡住,所以我將程式碼改成可以一次性輸入洗牌次數:
python腳本改成
執行的結果如下
顯然並沒有達成真正的洗牌,非常多的結果一次都沒有出現,全部集中在少數結果,接下來我要細查原因
於是我更改python腳本,參考陳麒升的作法,使用字串乘法的方式,使得執行 shuffle
的命令彼此分開
執行結果如下:
測試結果雖然較上次平均,但結果依舊不理想,我猜測這與亂數種子的設定有關,於是我將 srand(time(NULL))
移動到 qtest.c
的 int main()
裡頭,然後再做一次測試:
終於得到較為理想的亂數分佈,而這時我好奇一開始在 qtest
提供的一次性輸入洗牌次數的命令是否為影響結果的變因,於是我把程式碼更改後再做一次測試,得到的結果:
發現第一次測試的兩份結果在 chi square sum
有顯著差異,於是後續我繼續分別測試三次兩種不同方式的結果
採用字串乘法:
一次行輸入洗牌次數:
根據結果推測兩種不同的執行方式對於亂數分佈的影響並不顯著
shuffle
接下來使用統計方法驗證洗牌結果,在開始之前我想先了解何謂統計方法驗證結果
這份教材第五頁解釋 hypothesis testing 的目的:
The objective of hypothesis testing is to decide, based on
sample information, if the alternative hypotheses is actually
supported by the data.
這份教材則說明統計驗證方法的流程
在統計驗證中,比如檢測新藥的實驗,要證明新推出的藥物對於降低血壓有效,會將虛無假說定為「新藥對於降低血壓無效」,對立假說為「新藥能使血壓降低」,當統計檢定發現數據出現的情況在虛無假說下發生的可能性非常低(例如,p值低於預先設定的顯著水準),我們便會拒絕虛無假說,從而支持對立假說。
這兩個假設構成了假設檢定的基礎,目的是用來判斷數據提供的證據是否足以說明研究主張(即對立假說)比隨機變異更可能解釋觀察到的現象。而在本實驗,我們期望的目標則是不拒絕虛無假說。
顯著水準是在假設檢定中預先設定的臨界值(常見的有 0.05 或 0.01)。它代表當虛無假說(H₀)為真時,我們願意接受的「犯型一錯誤」的最大機率,也就是說,如果觀察到的數據出現的機率低於這個 α 值,就認為數據與虛無假說不符,因此有足夠的證據拒絕虛無假說。
p 值是在假設虛無假說為真時,觀察到「與實際結果一樣極端或更極端」數據的機率。簡單來說,p 值量化了數據與虛無假說相容的程度;當 p 值很小時(通常小於事前設定的顯著水準 α),意味著在虛無假說下觀察到這樣的數據的可能性極低,因而我們傾向於拒絕虛無假說。
在此份檢驗:
shuffle
命令採用一次輸入洗牌次數,但是根據python腳本計算出來的 chi square value ,在23自由度下的 p 值,四次結果有三次無法拒絕虛無假說。python腳本在 commit 340c789
從結果可以看到 p 值大部分集中於0.9到0.1的區間,並且平均數為0.4698,明顯大於0.05,所以沒有足夠證據推翻虛無假說,因此不拒絕虛無假說
而在實作過程,發現 q_test.c
的 int main
函式已定義亂數種子:
srand(os_random(getpid() ^ getppid()));
所以上面的測試不是以 srand(time(NULL))
生成的。
於是我重新用 srand(time(NULL))
再生成一次結果:
平均數為0.3711,與先前的測試並沒有很大的差距,結論依然能得到不拒絕虛無假說
- 那我好奇使用
srand(os_random(getpid() ^ getppid()));
的必要性在哪,有更好的測試能看出差距嗎?- 目前的統計方式檢驗是否足夠好?
在 qtest
提供顯示熵的指令,我把它打開來看看
根據 lab0(D)
Entropy在資訊理論的定義是某個隨機事件平均每個結果發生時所能傳達出的資訊量,當我們預期某個事件發生的機率很高,而他真的發生的時候,我們獲得的資訊其實沒有變多
從上述結果可以看到,當字串重複率很高,entropy 就小,當字串長度越長並且重複性低, entropy 就越高
對於一分佈而言,因
,Entropy 的極大值發生在 時 ,因此分佈即爲 uniform distribution
所以將熵用於評估亂度在於計算當前訊息的熵與其最大可能熵的差距有多大
比如1 byte char 的大小為 ,則最大的 entropy 為 ,而如果我們蒐集一個亂數產生器產生的100000個8 bits 字串的,計算出每一種結果出現的機率,就能使用熵公式計算出熵(熵只需要就能計算)
熵的用途除了計算亂度以外,另一個用途是根據訊息的機率分布去計算訊息編碼所需要的平均編碼長度。
比如單純由英文字母組成的字串,每一個字母出現的次數乘以其編碼所需的 bits 數,再除以總長度,就是對訊息編碼所需的平均長度(就是熵的公式)。這是資訊壓縮的原理
而檢驗亂數的方法也包含平均值檢定、蒙地卡羅法計算 Pi 值、及序列相關係數 (可檢驗序列上每個值前後的相關性)
為什麼需要這麼多不同的方式檢驗亂度
再來我好奇為什麼 Linux 對亂度如此要求,並且亂數被用在哪裡,如何實作的?
在這兩篇 LWN.net ,LWN.net 文章有解釋:
對於作業系統而言,產生足夠亂的亂數對於系統安全至關重要
要產生足夠亂的亂數,必須確保熵足夠,那麼 Linux 究竟如何得到足夠的熵呢?
對於某些情形不需要非常亂的亂數,可以由 /dev/urandom
提供,而某些需要非常亂的亂數,比如加密金鑰等,熵要由環境中隨機事件蒐集,比如敲鍵盤的頻率,硬碟中斷的時間點等等,這些實作來自 /dev/random。
然而從環境蒐集熵就碰到一些問題,比如一些嵌入式設備, headless device ,或是才剛初始化的作業系統,要如何從環境蒐集足夠的熵?
在作業中的檔案 random.c
的 linux_getrandom
函式會系統呼叫 getrandom ,將一個 buffer 填滿隨機字串 ,並且透過 flags 控制熵的來源,在 random.c
實作中 flags 是0,代表從預設的 /dev/urandom
取得熵,而非 /dev/random
(flags 是 bit mask)。
假如不存在系統呼叫 getrandom
,則呼叫 linux_urandom
,使用 /dev/urandom
生成隨機數,並且當 /dev/urandom
的熵不夠時,回傳 -1
可以找到 flags bitmask 的定義的位置,在我的電腦 /usr/include/x86_64-linux-gnu/sys ,但我更想知道系統呼叫 getrandom
的實作,從 random.c
可知其屬於 glibc
,但接下來我無法從 glibc
找到 getrandom
確切實作的位置,僅僅在這個網站看到相關的實作
研讀的時候搭配 Chatgpt Deep research 。
常數時間是一種時間複雜度,它表示某個演算法求出解答的時間在固定範圍內,而不依照問題輸入資料大小變化。比如用索引值存取陣列元素
時序攻擊是從執行時間推敲金鑰或密碼,攻破許多 variable time implementation
有的防範方式是從組合語言,編譯過的高階語言檢查是否有 memory indices 或 branches 是否 secret dependent。但這種方法對於檢查稍微大型的程式會非常費時。
而有些工具可以分析程式碼和 execution traces (a complete record of the computation) ,這些工具仰賴對於硬體的建模,但電腦實際執行過程很複雜,並且許多處理器公司並不會公佈微架構的細節,因此硬體建模難度很高。
這篇論文避免了硬體建模,也不仰賴靜態分析,而是從 side-channel evaluation 下手,收集多次測量時間的結果,並用統計方法分析執行時間是否是常數時間。
分析方式很簡單,檢查執行時間在兩個不同的輸入是否有統計上的差異。
輸入資料採取 fix-vs-random ,即一份是常數資料(可能用來引發 corner case),另一份是隨機資料。
corner case 與 edge case 定義上不同,前者含多個變數,後者僅代表某個極端變量
測量時間的方式使用 CPU 提供的 Cycle counters, x86 環境使用 RDTSC instruction (Read Time-Stamp Counter)
為了減少像作業系統中斷的環境干擾,常數資料和隨機資料會在隨機排序下進行測量。
處理測量資料:
在真實電腦上執行的結果,執行時間通常呈現長尾分佈,也就是執行時間較長的極端值會比較多,比如因為 系統中斷, scheduler noise, or cache effects 。這些極端值會造成統計分佈向較長執行時間偏移。所以傾向切除執行時間較長的極端數據(不過在 Dudect 的實作中也會考慮原始分佈)
Welch's t-test defines the statistic t by the following formula:
從公式可以看到:
t 越接近 0 代表兩組數據差距越小
樣本數越多,t 越大
分佈的平均數差距越大,t 越大
分佈的變異數越大,t 越小
可以設想這樣的情況幫助理解:假設平均數有一定程度的差距,此時如果變異數越小,代表分佈越集中,那麼兩組數據的差距會很明顯,但是如果變異數很大,分佈很分散,那麼差距就會不明顯。
常見的標準是 t 大於 4.5 代表有統計上的顯著差異
Student's t-test assumes that the sample means being compared for two populations are normally distributed, and that the populations have equal variances. Welch's t-test is designed for unequal population variances, but the assumption of normality is maintained.
接下來我看不懂 Higher-order preprocessing 在做什麼,也看不懂檢定方法
第一個案例:記憶體比對,比對輸入的密碼是否正確
這邊不懂smoke test
分析的時候,視角分為 white-box :得知實作細節,和 black-box 。
虛無假說為兩種分佈是一樣的,對立假說為兩種分佈不一樣。
首先測試 memcmp ,理論上是非常數時間的函數,因為它會在比對到第一個不同的值後回傳。
可以看到 Fig. 1 在 fix 和 random 顯示出明顯的 timing leakage ,對於 fix 而言,特別設定成必須比對到最後的 corner case ,而 random 則有機率會提早比對到不符合然後回傳,所以 random 的累積分佈函數會上升的比 fix 還要快。
t 值遠大於 4.5 ,即使只有數千次的樣本也超過。
接下來換作 black box ,也就是在不知道哪樣的 fix 可以比對到最後一位的情況下,將 fix 隨便設定,論文裡設定成 0 。結果在累積分佈函數無法看到顯著差異。
然而在 t 值就能看到,當樣本數夠大,大部分的結果超過 4.5 的門檻,顯示有統計上的差異。
接著進行理論上常數時間的實作
累積函數看起來一樣
t 值均小於 4.5 門檻,並且沒有樣本數多而變大的趨勢,因此統計結果不拒絕虛無假說,符合理論預期。
lab0 的實作,使用 cpucycles() 獲得時間
,計算的時候,使用了 double , sqrt ,fabs()。
追溯程式碼,從 differentiate
得知 N_MEASURES
等於 150,再找到 constant.c
中 measure
的 after_ticks[]
的指派:
for 迴圈的執行減去兩個 DropSize 後總共執行 110 次 insert_head
,也就是每次 t 檢定的分佈樣本每一批是 110 個執行 insert 的執行時間,每一批新的數據藉由 t_push
更新分佈的平均數和變異數。
所以會在執行 measure
的時候,把 110 筆執行時間儲存下來。然後 differentiate
會把每一筆 execution times 計算出來。
classes
代表順序,其為 110 個 insert 的輸入資料的 fix 和 random 的隨機排列
t_push
的用意在於更新樣本分佈的平均數和變異數。
t_compute
每次會計算當前兩個分佈的 t 值。又因為 t_push
會在每次執行 insert
後,把新一筆執行時間長度的資料納入分佈,算出新的平均值和變異數並更新回分佈的數據中。所以每次 t_compute
的執行都代表更大樣本數下的 t 值。不過 t_compute
並不是在每次 t_push
執行一次就跟著執行一次,t_compute
位於 report 中,所以當 t_compute
執行一次之前, t_push
在 update_statistics
已經執行 N_MEASURES
次。所以 t_compute
每隔一次計算,樣本分佈的平均值和變異數已經更新了 N_MEASURES
次,也就是樣本數一次增加 N_MEASURES
個。將程式執行速度放慢也可以看到 still to go 是以每次減少 110 的速度下降。
report 中會確認樣本數是否足夠,如果不夠就會回傳 false ,繼續執行下去。
所以如果把每一次 t_compute 計算出來的 t 值繪製出來,可以得到一個統計圖,如果這個圖的 t 值在測試進行而樣本數越來越大時會顯著超過設定的門檻 ,那就表示待測的程式不是常數時間。
在修改前執行的話,會有這行
表示整個測試會重複執行 10 次,如果這 10 次沒有一次 t 值小於 10 的話(lab0選擇10作為門檻),也就是當樣本數達到 ENOUGH_MEASURE 後的兩個分佈有顯著差異,就斷定待測程式不是常數時間。
發現在 lab0 的實作,並沒有採取如論文中,用 p 百分位數切除較大的極端值,反而只計算未切除的數據
論文的程式碼有做切除:
但問題是要切除多少百分位。參考 rota1001的實驗 ,他選擇切除大於 50 百分位數的數據。
所以如果要把極端數值移除,就必須將所有的 execution times 排序,然後取百分位數。然後再將執行時間超過設定的百分位數的值跳過 t_push
這是一個小型的 server 實作,於是我好奇 server 的底層運作原理是什麼。我請 Chatgpt Deep Research 給我快速的講解
我在測試的時候,發現程式碼已具備同時接受命令列輸入以及網頁伺服器輸入的功能。
追溯程式碼發現,在 console.c
的 do_web
中,會將 eventmux_callback
設為 web_eventmux
,因此在 ./qtest
輸入 web
命令,就能同時接受命令列和網頁伺服器的輸入。
以下解釋 web_eventmux
如何運作的
首先將命令列和伺服器兩個 file descriptor 加入 listenset
再來使用 select() 等待 set 中 fd 的輸入
在 linenoise.c
的 line_edit
可以看到:
event_callback
初始化為 NULL
,只有當呼叫 do_web
時才會設為 web_eventmux
。所以未呼叫 do_web
時,linenoise
使用 read
來接受輸入。
如果輸入未包含特定字元,比如 ctrl-c, ESC 等,line_edit
會直接回傳輸入的字串。當包含上述之類的特定字元,會繼續執行 line_edit
後續程式碼將特定字元移除。
然而在 do_web
的程式碼,包含將 use_linenoise
設為 false ,但在 run_console
中, 當 use_linenoise
為 false 時,會呼叫 cmd_select
,但在 cmd_select
中,依然透過呼叫 linenoise
來接受輸入,而運作依然符合預期的原因是 linenoise
會呼叫 line_raw
而後者會再呼叫 line_edit
,同時因為 eventmux_callback
被設為 web_eventmux
,後者程式碼有使用 select
因此 cmd_select
似乎是非必要的程式碼,我將 use_linenoise = false;
註解掉依然運作正常。