Try   HackMD

2025q1 Homework (lab0)

contributed by <Andrewtangtang>

作業書寫規範:

  • 無論標題和內文中,中文和英文字元之間要有空白字元 (對排版和文字搜尋有利)
  • 文字訊息 (尤其是程式執行結果) 請避免用圖片來表示,否則不好搜尋和分類
  • 共筆書寫請考慮到日後協作,避免過多的個人色彩,用詞儘量中性
  • 不要在筆記內加入 [TOC] : 筆記左上方已有 Table of Contents (TOC) 功能,不需要畫蛇添足
  • 不要變更預設的 CSS 也不要加入任何佈景主題: 這是「開發紀錄」,用於評分和接受同儕的檢閱
  • 在筆記中貼入程式碼時,避免非必要的行號,也就是該手動將 c=cpp= 變更為 ccpp。行號只在後續討論明確需要行號時,才要出現,否則維持精簡的展現。可留意「你所不知道的 C 語言: linked list 和非連續記憶體」裡頭程式碼展現的方式
  • HackMD 不是讓你張貼完整程式碼的地方,GitHub 才是!因此你在開發紀錄只該列出關鍵程式碼 (善用 diff 標示),可附上對應 GitHub commit 的超連結,列出程式碼是為了「檢討」和「便於他人參與討論」
  • 留意科技詞彙的使用,請參見「資訊科技詞彙翻譯」及「詞彙對照表
  • 不要濫用 :::info, :::success, :::warning 等標示,儘量用清晰的文字書寫。:::danger 則僅限授課教師作為批注使用
  • 避免過多的中英文混用,已有明確翻譯詞彙者,例如「鏈結串列」(linked list) 和「佇列」(queue),就使用該中文詞彙,英文則留給變數名稱、人名,或者缺乏通用翻譯詞彙的場景
  • 在中文敘述中,使用全形標點符號,例如該用「,」,而非 ","。注意書名號的使用,即 ,非「小於」和「大於」符號
  • 避免使用不必要的 emoji 字元

開發環境

$ gcc --version
gcc (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0

$ lscpu
Architecture:             x86_64
  CPU op-mode(s):         32-bit, 64-bit
  Address sizes:          40 bits physical, 48 bits virtual
  Byte Order:             Little Endian
CPU(s):                   16
  On-line CPU(s) list:    0-15
Vendor ID:                GenuineIntel
  Model name:             QEMU Virtual CPU version 2.5+
    CPU family:           15
    Model:                107
    Thread(s) per core:   1
    Core(s) per socket:   16
    Socket(s):            1
    Stepping:             1
    BogoMIPS:             5199.99
    Flags:                fpu de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ht syscall nx lm constant_tsc nopl xtopology cpuid
                           tsc_known_freq pni ssse3 cx16 sse4_1 sse4_2 x2apic popcnt aes hypervisor lahf_lm cpuid_fault pti

佇列操作的程式碼實作

q_new

目標

創建節點,當作整個鏈結串列串的開頭

實作細節

先使 malloc 分配一塊記憶體空間給該 list_head 指標,接下來使用 list.h 內部定義的 macro INIT_LIST_HEAD 初始化該節點使節點中的 nextprev 都指向自己

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

程式碼

-    return NULL;
+    struct list_head *head = malloc(sizeof(struct list_head));
+    if (head == NULL) {
+        return NULL;
+    }
+    INIT_LIST_HEAD(head);
+    return head;

q_free

commit:c4e246f

目標

釋放掉所有被佇列使用到的記憶體空間

實作細節

先檢查傳入函數的參數 head 是不是NULL指標以及該條鏈結串列是不是沒有節點的,如果是則直接釋放掉 head 指標,否則創建兩個 element 型別的指標 *entry 以及 *safe 使用 list.h 內部定義的 macro list_for_each_entry_safe traverse 並取出嵌入 list_head 的真正 struct element_t 釋放掉該結構體的記憶體空間,list_for_each_entry_safe 會檢查下一個節點是不是 head pointer 來確保安全釋放記憶體空間。

遇到的問題

commit:d8bd4e9
在初始版本中,程式碼未先檢查佇列是否為空或 head 是否為 NULL,就直接使用 list_for_each_entry traverse 並釋放記憶體,導致 Segmentation Fault。這是因為 list_for_each_entry 假設鏈結串列至少包含一個有效節點,如果 head 為 NULL 或鏈結串列為空,則會存取非法記憶體區域。在更新版本中,增加了適當的條件檢查,並改用 list_for_each_entry_safe,這個 macro 在遍歷的過程中,會先記錄下一個節點,確保當前節點釋放後不會影響鏈結串列的完整性。

程式碼

-    struct list_head *current;
-    list_for_each (current, head)
-        q_release_element(list_entry(current, element_t, list));
+    element_t *entry, *safe;
+    // cppcheck-suppress unknownMacro
+    list_for_each_entry_safe (entry, safe, head, list)
+        q_release_element(entry);
     free(head);

q_insert_head, q_insert_tail

commit:8bd3921

目標

創建一個新的佇列元素,將傳入的 string 複製一份到該元素的value 中,最後根據要求將該元素插入鏈結串列的頭部或尾部

實作細節

函式在執行時必須先做例外檢查,如果 head 是 NULL 值必須回傳 false

if (!head) {
    return false;
}

接下來要為佇列元素以及其中的 value 分配記憶體,而這個分配記憶體與檢查的操作在 q_insert_headq_insert_tail 都會使用到,因次我將這個部分的操作創建一個函數 new_element() 來完成。而這裡需要注意的是記憶體分配失敗時的處理機制。當 malloc 無法成功分配記憶體時,函式應該回傳 NULL,以通知上層調用者發生了錯誤。因此,所有透過 malloc 取得記憶體空間的指標都應該進行例外檢查,確保可以處理記憶體分配失敗的情況。

static inline element_t *new_element(char *s)
{
    element_t *e = malloc(sizeof(element_t));
    if (!e)
        return NULL;

    e->value = strdup(s);
    if (!e->value) {
        free(e);
        return NULL;
    }
    return e;
}

最後將成功被創建 element 根據要求,使用macrolist_add list_add_tail 插入 headhead->next(q_insert_head), head->prevhead (q_insert_head)中間

bool q_insert_head(struct list_head *head, char *s)
{ 
    // other code
    list_add(&element->list, head);
}
bool q_insert_tail(struct list_head *head, char *s)
{ 
    // other code
    list_add_tail(&element->list, head);
}

遇到的問題

這兩項測試在測試 trace-17-complexity 一直通過不了測試測試的結果認為我的操作時間完成不是常數時間,但目前還找不到解決的方法,或許得做更多的研究看我這兩個操作的細節可能會花超過常數時間的操作。(已在 dudect 部分修正完成)

q_remove_head, q_remove_tail

commit:4ff4b46

目標

根據函數的要求將佇列中的第一個元素或最後一個元素從鏈結串列中移除,並將element_t->value 的 string 複製到 buffer 的起始位置 sp 的地方,供外部調用函數者去做檢查與目標移除的 string 是否相同。

實作細節

首先還是必須先對佇列進行例外檢查,假設佇列是空的則必須回傳NULL讓上層調用者得知

+    if (list_empty(head)) {
+       return NULL;
+    }

接著使用 macro 中提供的 list_first_entry 取得佇列中的第一個元素的指標, list_last_entry 取得佇列中的最後一個元素的指標

element_t *q_remove_head(struct list_head *head, char *sp, size_t bufsize)
 {   
     //other code
+    element_t *entry = list_first_entry(head, element_t, 
 }
 
element_t *q_remove_head(struct list_head *head, char *sp, size_t bufsize)
 {
     //other code
+    element_t *entry = list_last_entry(head, element_t, 
 }

將該元素的 value 值使用 strncpy 最多複製 bufsize-1 個字元 到 sp 指標指向的記憶體區域,確保不會超過 bufsize 限制,並在最後補上 \0 來確保字串終止。接著,使用 macro list_del_init 將該元素從佇列中安全移除

+    if (sp && entry->value) {
+        strncpy(sp, entry->value, bufsize - 1);
+        sp[bufsize - 1] = '\0';
+    }
+    list_del_init(&entry->list);

q_size

commit:86b19d3

目標

計算出佇列元素個數

實作細節

創建一個臨時的指標變數 current 並使用 macro list_for_each(current, head) ,他會使current指向 head->next ,若 current 不等於 head 則持續 traverse 移到下一個節點上

+    int len = 0;
+
+    struct list_head *current;
+    list_for_each (current, head)
+        len++;
+    return len;

q_delete_mid

commit:3996f11

目標

假設佇列的元素個數為 n 則必須找到

n2th 的node 將其從佇列中移除移除並釋放其記憶體空間

實作細節


一開始的想法是利用已經實作的 q_size() 來計算佇列的長度,接著透過迴圈遍歷,判斷是否到達

n2th 的節點。然而,在閱讀了教材中〈分析「快慢指標」〉後,我了解到,雖然「單一指標」與「快慢指標」在時間複雜度上相同,且對空間局部性的影響也相近,但考慮到時間局部性,當佇列長度夠長時,使用「快慢指標」能夠提高快取命中率。這是因為慢指標能在較短的時間內再次訪問到快指標曾經訪問過的記憶體位置,從而提高 Cache 的效能。基於這個考量,我決定使用「快慢指標」來實作該功能。


因為題目中的要求是找到

n2th 的節點,而鏈結串列的 head 本身是不會被嵌入的,因此真正有被嵌入的第一個節點為head->next ,初始化兩個 list_head 指標 fastslow 指向 head->next 接著每一次的疊代 fast 往後走兩個 nodeslow 往後走一個 node,直到 fastfast->next 走到 head 為止,從佇列中刪除 slow 指標並使用 macro q_release_element 釋放元素結構的記憶體空間。

struct list_head *slow = head->next;    
struct list_head *fast = head->next;
while (fast != head && fast->next != head) {
        slow = slow->next;
        fast = fast->next->next;
    }

遇到的問題

在一開始的版本中我把 fast 指向 head->next->nextslow 指向 head->next 但因為此題目是以 zero-based indexing 這樣在偶數時我真正刪除的元素是 index

n21th 的節點,在 commit [cada3638fd297f64e1afdb374d1b33b5359ffe48] 已修正

-    struct list_head *fast = head->next->next;
+    struct list_head *fast = head->next;

q_delete_dup

commit:3996f11

目標

如過佇列中的元素中的字串值有連續重複的則將這些重複的節點從佇列中刪除

實作細節

這邊我嘗試使用課堂上介紹的 indirect pointer 寫法,初始化一個 pointer to pointer 變數 indirect,讓它指向每次檢查的 duplicate nodes 前一個節點的 next 指標的記憶體位置。這樣可以直接修改 next 指標來刪除節點,避免額外維護 prev 指標來記錄當前檢查的string以及與前一個節點的連接關係。

struct list_head **indirect = &head->next;
遍歷佇列,尋找重複節點
  • 使用 strcmp() 比較 cur_value 與 (*indirect)->next->value,判斷是否有連續相同的元素。
  • 若相同,則標記 dup = true,並移除所有與 cur_val 相同的節點
const char *cur_val = list_entry(*indirect, element_t, list)->value;
        bool dup = false;

        while ((*indirect)->next != head &&
               strcmp(cur_val,
                      list_entry((*indirect)->next, element_t, list)->value) ==
                   0) {
            dup = true;
            struct list_head *dup_node = (*indirect)->next;
            list_del(dup_node);
            q_release_element(list_entry(dup_node, element_t, list));
刪除重複節點並更新 indirect

若 dup == true,則刪除 *indirect,並釋放記憶體,若當前節點無重複,則移動 indirect,移動到下一個節點的 next 指標所在的記憶體位址

if (dup) {  
    struct list_head *temp = *indirect;
    list_del_init(*indirect);  
    q_release_element(list_entry(temp, element_t, list));
} else {
    indirect = &(*indirect)->next;
}

q_reverse

commit:e6792c9

目標

將鏈結串列內部元素的順序顛倒過來

實作細節

使用一個臨時時的指標 curr 來 traverse 這個鏈結串列並將下一個元素的 pointer 存下來,該指標的 nextprev pointer 互相交換,traverse 一圈後即可翻轉順序

struct list_head *curr = head;
do {
    struct list_head *tmp = curr->next;
    curr->next = curr->prev;
    curr->prev = tmp;
    curr = tmp;
} while (curr != head);

q_reverseK, q_swap

commit:4bf9dbc

目標

可以根據輸入的 argument k,k個數字組成一組進行組內的鏈結串列順序反轉。因為 q_swap 其實是 q_reverseK(K=2) 的特例,因此我先實作 q_reverseK 函數,在 q_swap 內部在呼叫他,來實現相同邏輯。

實作細節

reverse_segment(head, tail): 反轉指定區間
  • reverse_segment(head, tail) 負責反轉 [head, tail) 範圍內的節點,並確保鏈結仍然保持正確的連結。
  • 具體步驟:
    記錄 head 之前的節點 before_head,避免丟失對原鏈結的訪問。
+ struct list_head *before_head = head->prev;

traverse [head, tail),對每個節點交換 nextprev,實現區間內的順序反轉。

void reverse_segment(struct list_head *head, struct list_head *tail)
{
    struct list_head *curr = head;
    struct list_head *prev = tail;

    while (curr != tail) {
        struct list_head *next = curr->next;
        curr->next = prev;
        curr->prev = next;
        prev = curr;
        curr = next;
    }
}

重新連接反轉後的區段,使其與原鏈結串列保持一致。

    before_head->next = prev;
    prev->prev = before_head;
    head->next = tail;
    tail->prev = head;
q_reverseK(head, k): 以 k 為單位反轉鏈結串列

head->next 開始遍歷鏈結,每次尋找 k 個節點組成的區段,若長度不足 k 則保持不變

    while (count < k && tail != head) {
        count++;
        tail = tail->next;
    }
    if (count < k)
        break;

對每個符合 k 長度的區段,呼叫 reverse_segment 進行反轉,並移到下一個區段繼續檢查

    reverse_segment(curr, tail);  // reverse the segment [curr, tail)
    curr = curr->next;

q_ascend, q_descend

commit:b0790a3

目標

根據鏈結串列的排序規則,移除右側存在比當前節點更大(或更小)值的節點:

  • q_ascend:移除右側有更小值的節點,最終保留遞增(非嚴格)排序的鏈結。
  • q_descend:移除右側有更大值的節點,最終保留遞減(非嚴格)排序的鏈結。

實作細節

這兩個函式的核心邏輯相同,主要差異在於判斷條件的不同

使用 Stack 儲存目前有效的節點
  • 由於決策依賴於右側的節點值,因此需要一個 stack 來追蹤當前鏈結中仍然有效的節點
  • 透過traverse curr,我們可以檢查 stack[top] 是否仍符合 ascend/descend 條件
struct list_head *curr = head->next;
struct list_head **stack =
    malloc(sizeof(struct list_head *) * q_size(head));
int top = -1;

移除不符合條件的節點
  • stack[top] 不符合排序規則(q_ascend 需要刪除右側更小的節點、q_descend 需要刪除右側更小的節點),則透過 macro list_del_init() 將其從鏈結串列中移除,並釋放記憶體。
  • 這個過程持續 pop stack,直到 stack[top] 符合條件為止。
  • 當 curr 經過判斷後,確定它仍然是有效節點,則將其加入 Stack 以便後續比較。
while (curr != head) {
        while (top >= 0 &&
               ((is_ascend &&
                 strcmp(list_entry(stack[top], element_t, list)->value,
                        list_entry(curr, element_t, list)->value) > 0) ||
                (!is_ascend &&
                 strcmp(list_entry(stack[top], element_t, list)->value,
                        list_entry(curr, element_t, list)->value) < 0))) {
            list_del_init(stack[top]);
            q_release_element(list_entry(stack[top], element_t, list));
            top--;
        }
        stack[++top] = curr;
        curr = curr->next;
    }

因為這兩個函式的主要核心邏輯相同我將其提出來放在 q_filter(struct list_head *head, bool is_ascend) function ,q_ascend, q_descend 可以透過傳入布林值來調整 q_filter 的行為。
commit [00f16088f8929961dc9ccda94411239b3d784c1b]

+    int q_ascend(struct list_head *head)
+    {
+        return q_filter(head, true);
+    }
+    int q_descend(struct list_head *head)
+    {
+        return q_filter(head, false);
+    }

q_sort

commit:dfc0d4b

目標

將佇列可以可以按照升序或者是降序排序,並須是stable sort(Stable sort algorithms sort equal elements in the same order that they appear in the input),且必須在時間複雜度

O(NlogN) 下完成

實作細節

由於 Merge Sort 具有 穩定排序(Stable Sort)的特性,並且在各種情況下都能保證

O(NlogN) 的時間複雜度,因此選擇該演算法來實現 q_sort

基本流程如下:

  • 切割鏈結串列:使用 find_mid() 劃分鏈結串列的前半部分與後半部分,並不斷遞迴分割,直到每個子鏈結僅剩下一個節點。
  • 合併已排序的子鏈結串列:利用 merge() 函數,將排序後的左右子鏈結串列合併,保持 升序/降序 並確保穩定性。
1.切割環狀雙向鏈結串列

在進行 Merge Sort 時,遇到一個 特殊問題head 節點不應該被拆分和排序,但它仍然存在於原始環狀雙向鏈結中。因此,遞迴切割前,需要先將 head 從環狀鏈結串列中暫時移除,避免影響排序邏輯,
等到最後排序完後再接上。

void q_sort(struct list_head *head, bool descend)
{
    // temporary remove the head
    struct list_head *first = head->next;
    struct list_head *last = head->prev;
    first->prev = last;
    last->next = first;
    
     struct list_head *sorted = merge_sort_recursive(first, descend);

    // restore the head
    sorted->prev->next = head;
    head->prev = sorted->prev;
    head->next = sorted;
    sorted->prev = head;
}
2.find_mid() 找到中間節點

由於雙向鏈結串列無法直接存取長度,我們可以利用雙向鏈結串列的特性,使用兩個標從頭尾一起 traverse,找到中間節點,來將鏈結切割成左右兩部分

while (left != right) {
    right = right->prev;
    if (left == right)
        break;
    left = left->next;
}
return left;

3.merge() 合併已排序的子鏈結串列

在 merge() 過程中,為了方便比較 leftright 的節點,我們先將環狀鏈結暫時斷開

left->prev->next = NULL;
right->prev->next = NULL;

這樣 leftright 就變成了 非環狀的鏈結串列,可以透過參考教材 〈你所不知道的 C 語言: linked list 和非連續記憶體〉 中使用 indirect pointer 來 merge 兩條 sorted list的方式,但需要額外處理雙向 prev 指標維護。compare是設計好來回傳比較結果的函式:

while (L1 && L2) {
    if (compare(L1, L2, descend)) {
        *ptr = L1;
        L1 = L1->next;
    } else {
        *ptr = L2;
        L2 = L2->next;
    }
    (*ptr)->prev = prev;
    prev = *ptr;
    ptr = &(*ptr)->next;
}

最後,將 L1 或 L2 剩餘的部分直接連接到合併後的鏈結中

*ptr = L1 ? L1 : L2;
(*ptr)->prev = prev;
4.merge_sort_recursive() 遞迴處理

merge_sort_recursive() 是主要的遞迴函式,負責:

  • 分割鏈結串列
  • 遞迴排序左右兩個子鏈結
  • 合併排序結果
 struct list_head *mid = find_mid(head, head->prev);
    struct list_head *right = mid->next;

    // initialize the right list
    right->prev = head->prev;
    head->prev->next = right;

    // initialize the left list with the head
    mid->next = head;
    head->prev = mid;

    struct list_head *left_sorted = merge_sort_recursive(head, descend);
    struct list_head *right_sorted = merge_sort_recursive(right, descend);

    return merge(left_sorted, right_sorted, descend);

q_merge

commit:dfc0d4b

目標

將多條已經排序的佇列,根據指定的升序 (ascend) 或降序 (descend) 規則,合併為單一排序佇列,確保最終仍維持穩定的排序結果。

實作細節

在這個實作中,每一條待合併的佇列都有一個 head,這與一般的鏈結串列合併有所不同。因此,我特別撰寫了一個函式 merge_lists_with_sentinel_node() 來處理含 head 的雙向環狀鏈結串列的合併,使 q_merge() 可以順利進行多條佇列的合併。

merge_lists_with_sentinel_node()

這邊與上述的 merge 函數重複使用的程式碼很多,不同之處在於初始化不用將頭尾斷開,中間迴圈的判斷方法改成是不是已經走回 head 了,之後在把剩餘的部分接上去即可

+void merge_lists_with_sentinel_node(struct list_head *l1, + struct list_head *l2, + bool descend) +{ + struct list_head *curr = l1->next; + struct list_head *next = l2->next; + struct list_head *head = l1, **ptr = &(head->next), *prev = head; + while (curr != l1 && next != l2) { + // remain code + }
  • 定義一個新的 macro element_next(pos, member) 用來取出佇列中下一個元素
#define element_next(pos, member) \
    list_entry((pos)->member.next, typeof(*(pos)), member)
  • traverse queue_contex_t 清單並合併所有佇列,利用 next 有沒有回到 head 來看合併完成了沒
for (next = element_next(entry, chain); &next->chain != head;
     next = element_next(next, chain)) {
    merge_lists_with_sentinel_node(curr, next->q, descend);
}

可改進的地方

這裡的許多程式碼與 q_sort()merge 的函式有許多相同的地方,這兩者的主要區別在於 sublist

  • q_sort()merge() 處理的是沒有 head 節點的子鏈結串列,直接進行合併。
  • q_merge()merge_lists_with_sentinel_node() 處理的是每條鏈結都有 head 節點的情況,因此需要特殊處理 head 的連接與初始化。

目前的設計導致了重複的合併邏輯,可以考慮統一 merge 操作,設計一個更通用的函式提高可讀性,也能確保合併邏輯在一致

提供新的命令 shuffle

commit:f135e76

實作目標

qtest.c 中新增 shuffle 命令,並實作 Fisher–Yates shuffle 演算法來隨機打亂佇列的節點順序。

實作細節

產生隨機數

在洗牌過程中,每次都需要在 1 ~ len 範圍內產生一個隨機索引,來決定要交換的節點。我發現專案內的 random.c 提供了一個函式:

int randombytes(uint8_t *buf, size_t n);

randombytes(uint8_t *buf, size_t n) 這個函式可以產生 n 個 bytes 的隨機數,而其底層通常透過系統呼叫(system call) 來獲取安全隨機數。
因此我創建了一個大小為 8 bytes 的 uint8_t 陣列,呼叫 randombytes() 來填充這個陣列,將隨機數 byte 陣列轉換成 size_t 型態,取直後對當前長度 size 取 mod 運算,以確保索引值落在合理範圍內

uint8_t random_bytes[8];
randombytes(random_bytes, sizeof(random_bytes));
size_t j = *((size_t *) random_bytes) % size;

最後 traverse 整個佇列一次來確保每個點都有被實作隨機交換。

+    while (curr != head) {
+        //above code
+        size_t j = *((size_t *) random_bytes) % size;
+        struct list_head *target = head->next;
+        for (size_t i = 0; i < j; i++) {
+            target = target->next;
+        }
+        if (curr != target)
+            swap_nodes(curr, target);
+        curr = curr->prev;
+        size--;
+    }

驗證亂度

commit:31ff6fc
這裡使用 Pearson's chi-squared test 來檢驗我們實驗所觀察出來的結果是否與理論的亂度相符合,這次的實驗我選擇 123 三個字串來進行 shuffle洗牌,假設shuffle 是公平的,即由 123 所排列出來的 6 種排列組合出現的機率應該要相同且加起來合為 1 ,因此我們可以定義虛無假說。

  • H0
    : shuffle 的結果發生的機率相同,遵守 Uniform distribution
  • 𝐻1
    (對立假說): shuffle 的結果發生的機率至少有一個不同

接下來使用 Pearson's chi-squared test 來驗證 該 shuffle 是否符合 Normal distribution。

計算 chi-squared test statistic
(X2)

X2=i=1n(OiEi)2Ei

  • (X2)
    : Pearson's cumulative test statistic
  • (Oi)
    : the number of observations of type (i)
  • (Ei)
    : the expected count of type (i)
  • shuffle 結果:
total count of results: 100000

Chi-squared values for each permutation:
Permutation | Observation | Expected | Chi-squared
--------------------------------------------------
123        |      16699 |    16666 |     0.0653
132        |      16693 |    16666 |     0.0437
213        |      16575 |    16666 |     0.4969
231        |      16646 |    16666 |     0.0240
312        |      16653 |    16666 |     0.0101
321        |      16734 |    16666 |     0.2775
--------------------------------------------------
Total chi-square sum: 0.9176
Degrees of freedom: 5
Expectation:  16666
Chi-square sum:  0.9175567022680907

決定自由度

根據自由度所代表的意義

Degrees of freedom are the number of independent variables that can be estimated in a statistical analysis and tell you how many items can be randomly selected before constraints must be put in place.
因此在此處,自由度(Degrees of Freedom, df)為 5,表示在這 6 種可能的 shuffle 結果中,我們可以自由選擇 5 個結果的觀察頻率,而第 6 個結果的頻率則必須由前 5 個結果推導而出(因為總數固定,所有機率加總必須為 1)。

選擇顯著水準

顯著水準

α 是統計檢定中設定的錯誤拒絕虛無假設
H0
的機率,即當即當
H0
其實是對的時候,我們卻錯誤地拒絕它的機率,一般我們會選擇 5%。接著考慮
Pvalue
Pvalue
是統計檢定中用來衡量「在虛無假設(
H0
)為真的情況下,觀察到這種結果的機率」。接著查表發現,我們觀察到的結果
k=5
X2=0.9175567022680907
,所以
1>Pvalue>0.95

image

統計檢定的結果不拒絕虛無假說

因為假設要推翻虛無假設

H0 ,則觀察到的結果必須有統計顯著性,即
Pvalue
必須小於顯著水準
α=0.05
,但從結果來看
Pvalue>0.95>α
,所以統計檢定的結果不拒絕虛無假說
(H0)
,也就是說樣本資料不拒絕 shuffle 的結果遵循 Uniform distribution,因為沒有足夠證據推翻。相關測試程式碼與結果放在 /lab0-c/tests/ 中。
image

閱讀論文〈Dude, is my code constant time〉

Problem

時序攻擊(timing attack)是一種透過觀察程式執行時間,對原本加密的訊息進行反向破解的攻擊方式。然而,以往對程式碼安全性的檢查,大多只能仰賴耗時的人工檢視或使用特定套件來進行評估,而這些檢查方法通常需要針對特定的硬體 CPU 建立模擬環境。但由於 CPU 廠商很少公開其內部運作的詳細資訊,因此要為特定硬體建立一個準確的測量模型是相當困難的。有鑑於此,作者提出了一種名為 dudect 的工具,它透過量測程式碼實際的執行時間,並利用統計方法來驗證該段程式碼的執行時間是否符合「constant time」的要求。

Methods

該方法總共使用三個步驟來測量與統計來得出結論。

Step1 Measure execution time

在執行時間測試中,使用兩類資料:Fix(每次輸入固定值)和 Random(每次隨機生成)。Fix 資料通常選擇容易觸發邊緣情況的值,Random 則模擬實際多樣性。每次測量隨機選擇資料類別,並使用 cpucycle counter 或高解析度計時器記錄執行時間。資料準備在測試前完成,確保一致性。

Step2 Apply post-processing

在統計分析之前,通常需要對測量數據進行後處理。由於測量過程可能會受到作業系統或其他非相關活動的干擾,導致執行時間的分布偏向執行時間較長的的一邊(正偏向分布)。因此,我們會針對數據進行 cropping 處理,捨棄超過特定閾值的極端數據,以降低測量誤差對分析結果的影響。
image

Step3 Apply statistical test

使用統計的測試來嘗試去拒絕虛無假設(兩組時間數據的分布會相同)

(a) t-test

Welch's t-test 是用於檢測兩組資料均值是否相等的簡單統計檢定方法,若檢定失敗,則表示存在均值的資訊洩漏。當 t-test 結合裁剪(cropping)預處理時,由於裁剪屬於非線性轉換,不僅能檢測均值差異,還能捕捉更高階的統計洩漏。
{D8258594-C672-4374-9105-13F5D360A1E3}

(b) Non-parametric tests

非參數檢定(如 Kolmogorov-Smirnov 檢定)對數據分佈的假設較少,適用於分佈未知的情況,優點是靈活且適用範圍廣,但缺點是收斂較慢且需要更多樣本數才能得出穩定結果。

lab0 dudect程式碼的缺陷

未正確取樣正確範圍內的數據

commit:b3400ff
原本的程式碼想要對測量數據的前後幾筆資料做捨棄,但應該是要跑完 N_MEASURES 次後,沒有在取樣範圍內的數據再做捨棄,而不是直接跑
N_MEASURES -2*DROP_SIZE 的次數。

-        for (size_t i = DROP_SIZE; i < N_MEASURES - DROP_SIZE; i++) {
+        for (size_t i = 0; i < N_MEASURES; i++) {
-            before_ticks[i] = cpucycles();
+
+            int64_t beforeticks = cpucycles();
-            after_ticks[i] = cpucycles();
+            int64_t afterticks = cpucycles();
             int after_size = q_size(l);
             dut_free();
+            if (i < DROP_SIZE || i >= N_MEASURES - DROP_SIZE) {
+                continue;
+            }
+            before_ticks[i] = beforeticks;
+            after_ticks[i] = afterticks;}

而我認為可以直接需要取樣的資料直接從 ticks 陣列的起始開始放即可,這樣之後做 percentile 操作時我們需要去做對執行時間做排序可以從陣列的起始開始排序 N_MEASURES -2*DROP_SIZE 更方便操作,因此我更改為以下版本。
commit:b08bab1

-        for (size_t i = 0; i < N_MEASURES; i++) {
+        for (size_t i = 0, j = 0; i < N_MEASURES; i++) {
                // remain code
-            before_ticks[i] = beforeticks;
-            after_ticks[i] = afterticks;
+            before_ticks[j] = beforeticks;
+            after_ticks[j] = afterticks;
+            j++;

+static void init_percentiles(const int64_t *exec_times)
+ {
+    int64_t *sorted_exec_times =
+        malloc((N_MEASURES - 2 * DROP_SIZE) * sizeof(int64_t));
+    memcpy(sorted_exec_times, exec_times,
+           (N_MEASURES - 2 * DROP_SIZE) * sizeof(int64_t));
+    qsort(sorted_exec_times, N_MEASURES - 2 * DROP_SIZE, sizeof(int64_t),
+

並未具備 percentile 驗證方法處理

commit:b3400ff
原始 lab0 程式碼僅對未經裁剪(crop)的原始數據進行檢驗,並未具備像論文中根據不同剪裁區間的百分位數(percentile)內數據去做檢測的驗證方法。參考論文作者提供的 dudect 原始碼,我額外加入了 100 個 percentile 測試點,並透過指數轉換的方式抽取出 100 個不同百分比的裁剪區間。

+#define DUDECT_NUMBER_PERCENTILES 100
+#define DUDECT_TESTS \
+    (1 + DUDECT_NUMBER_PERCENTILES + 1)  // 102 tests  percentiles
+
+static t_context_t *ttest_ctxs[DUDECT_TESTS];
+static int64_t *percentiles;

+static void init_percentiles(int64_t *exec_times)
 {
-    for (size_t i = 0; i < N_MEASURES; i++) {
+    qsort(exec_times, N_MEASURES - 2 * DROP_SIZE, sizeof(int64_t),
+          (int (*)(const void *, const void *)) cmp);
+    for (size_t i = 0; i < DUDECT_NUMBER_PERCENTILES; i++) {
+        double p =
+            1 - (pow(0.5, 10 * (double) (i + 1) / DUDECT_NUMBER_PERCENTILES));
+        percentiles[i] = percentile(exec_times, p, N_MEASURES - 2 * DROP_SIZE);
+    }
+}

這邊我犯了一個錯,我直接對 exec_times 做排序會導致原資料順序改變,與 classes 陣列的 label 無法對應上。因此我在 commit:1cc896f 做了修正

-static void init_percentiles(int64_t *exec_times)
+static void init_percentiles(const int64_t *exec_times)
 {
-    qsort(exec_times, N_MEASURES - 2 * DROP_SIZE, sizeof(int64_t),
+    int64_t *sorted_exec_times =
+        malloc((N_MEASURES - 2 * DROP_SIZE) * sizeof(int64_t));
+    memcpy(sorted_exec_times, exec_times,
+           (N_MEASURES - 2 * DROP_SIZE) * sizeof(int64_t));
+    qsort(sorted_exec_times, N_MEASURES - 2 * DROP_SIZE, sizeof(int64_t),
-        percentiles[i] = percentile(exec_times, p, N_MEASURES - 2 * DROP_SIZE);
+        percentiles[i] =
+            percentile(sorted_exec_times, p, N_MEASURES - 2 * DROP_SIZE);
     }
+    free(sorted_exec_times);
 }

透過這項新增的 percentile 檢測方法,能夠更有效地比較分析不同裁剪區間下,測試數據的分布是否符合我們的統計假設。
image

假設有足夠多的資料,可以從這102筆測試數據中看看最大的 t 有沒有超過我們的threshold,來判斷該程式是否為 constant time。

+static t_context_t *max_test(t_context_t **ttest_ctxs)
+{
+    int max_index = 0;
+    double max_value = 0;
+    for (size_t i = 0; i < DUDECT_TESTS; i++) {
+        if (ttest_ctxs[i]->n[0] > ENOUGH_MEASURE) {
+            double x = fabs(t_compute(ttest_ctxs[i]));
+            if (x > max_value) {
+                max_value = x;
+                max_index = i;
+            }
+        }
     }
+    return ttest_ctxs[max_index];

經過這些改動後,程式碼便能通過 trace-17-complexity 的測試。