# 2021q1 Homework1 (lab0) contributed by < [`toastcheng`](https://github.com/toastcheng) > ###### tags: `linux2021` ## TODO - [x] 函式實作 - [x] 開啟 [Address Sanitizer](https://github.com/google/sanitizers/wiki/AddressSanitizer),修正 `qtest` 執行過程中的錯誤 * 先執行 `qtest` 再於命令提示列輸入 `help` 命令,會使 開啟 [Address Sanitizer](https://github.com/google/sanitizers/wiki/AddressSanitizer) 觸發錯誤,應予以排除 - [x] 運用 Valgrind 排除 `qtest` 實作的記憶體錯誤 - [ ] 並透過 Massif 視覺化 "simulation" 過程中的記憶體使用量,需要設計對應的實驗 - [x] 在 `qtest` 中實作 [coroutine](https://en.wikipedia.org/wiki/Coroutine),並提供新的命令 `web`,提供 web 伺服器功能,注意: web 伺服器運作過程中,`qtest` 仍可接受其他命令 * 可嘗試整合 [tiny-web-server](https://github.com/7890/tiny-web-server),將 `FORK_COUNT` 變更為 `0`,並以 [coroutine](https://en.wikipedia.org/wiki/Coroutine) 取代原本的 fork 系統呼叫 - [ ] 解釋 [select](http://man7.org/linux/man-pages/man2/select.2.html) 系統呼叫在本程式的使用方式,並分析 [console.c](https://github.com/sysprog21/lab0-c/blob/master/console.c) 的實作,說明其中運用 CS:APP [RIO 套件](http://csapp.cs.cmu.edu/2e/ch10-preview.pdf) 的原理和考量點。可對照參考 [CS:APP 第 10 章重點提示](https://hackmd.io/@sysprog/H1TtmVTTz) :arrow_forward: 為避免「舉燭」,請比照 `qtest` 實作,撰寫獨立的終端機命令解譯器 (可建立新的 GitHub repository) - [ ] 說明 [antirez/linenoise](https://github.com/antirez/linenoise) 的運作原理,注意到 [termios](http://man7.org/linux/man-pages/man3/termios.3.html) 的運用 - [ ] 研讀論文 [Dude, is my code constant time?](https://eprint.iacr.org/2016/1123.pdf),解釋本程式的 "simulation" 模式是如何透過以實驗而非理論分析,達到驗證時間複雜度,需要解釋 [Student's t-distribution](https://en.wikipedia.org/wiki/Student%27s_t-distribution) 及程式實作的原理。注意:現有實作存在若干致命缺陷,請討論並提出解決方案; - [ ] 指出現有程式的缺陷 (提示: 和 [RIO 套件](http://csapp.cs.cmu.edu/2e/ch10-preview.pdf) 有關),嘗試強化並提交 pull request ## 0. 環境 首先來設置作為開發的環境,手邊較為方便的電腦有一台 Macbook Air,雖和 Linux 同是 Unix-like 的作業系統,但許多相關套件都略有出入,因此最初想使用 Virtualbox 來使用虛擬機,後來考量電腦負擔太重,轉而嘗試以容器化的方式可以更為輕量,因此用 docker 來準備需要的 linux 環境。 ```dockerfile FROM ubuntu:20.04 RUN apt update RUN apt install \ -y \ --no-install-recommends \ build-essential \ git \ clang-format \ cppcheck \ aspell \ colordiff \ valgrind ``` build 完 docker image 後將需要的檔案掛載到容器中 (`-v <host path>/<container path>`) 執行 bash 便能開始進行開發。 ```bash $ docker build . -t lab0 $ docker run -v /Users/tushicheng/development/:/data -it lab0 bash # uname -a Linux 988da3fcde51 4.19.121-linuxkit #1 SMP Tue Dec 1 17:50:32 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux ``` ## 1. 函式實作 首先嘗試將尚未實作的函式完成,讓這個專案的基本功能可以運作。觀察 `queue.h` 可以一覽我們期望這個佇列 (queue) 資料結構 `queue_t` 的長相(此將註解省略): ```c= typedef struct { list_ele_t *head; } queue_t; typedef struct ELE { char *value; struct ELE *next; } list_ele_t; ``` `queue_t` 為一個含有連結串列 `list_ele_t` 的結構體,每個節點的值是字元指標,或俗稱的 C-style string,`queue_t` 應該支援的函式如下: 1. `q_new` 初始化,建構函式 2. `q_free` 解構函式 3. `q_insert_head` 在連結串列的開頭插入一個節點 4. `q_insert_tail` 在連結串列的尾巴插入一個節點 5. `q_remove_head` 移除連結串列的開頭節點 6. `q_size` 回傳連結串列的節點個數 (大小) 7. `q_reverse` 將連結串列的順序倒排 8. `q_sort` 將連結串列的節點依值的順序排序 ### `q_size` commit: [Add members to queue_t and impl insert head/tail](https://github.com/ToastCheng/lab0-c/commit/6f9288c63c8839785dd0b3c2c265914953bb98c7) 先從較為簡單的函式開始,`q_size` 需要回傳此佇列的大小,在最單純的情況,若我們只有連結串列的開頭 `head`,則必定需要花 $O(n)$ 的時間走訪各個節點直到最後一個節點的 `next` 為 `NULL`,方能知道佇列的大小。 但每一次花費線性時間來取得佇列大小並不合理,最直接的方式是用一個額外的整數變數 `size` 來將佇列的大小資訊暫存起來,若佇列大小因為操作而改變(如 `q_insert_head`、`q_remove_head`),則我們一併修改 `size` 即可。 將 `size` 加入 `queue_t` : ```c= typedef struct { list_ele_t *head; int size; } queue_t; ``` 則 `q_size` 只需要將佇列的 `size` 回傳即可。 ```c= int q_size(queue_t *q) { return q->size; } ``` 但若是佇列 `q` 為空,硬是去對其取值會引發 segmentation fault。 ``` cmd> free q = NULL cmd> size Warning: Calling size on null queue Segmentation fault occurred. You dereferenced a NULL or invalid pointer Aborted ``` 因此需要做一次額外的檢查,若 `q` 根本沒有初始化,也回傳 0。 ```c= int q_size(queue_t *q) { return q ? q->size : 0; } ``` `// TODO inconsistence` ### `q_insert_tail` `q_insert_tail` 將字串 `s` 加進佇列`q` 的尾巴。最單純的實作是先從佇列開頭一路走訪到最後一個節點,再將新節點加入,但這會跟 `q_size` 有一樣的問題,僅僅是加入一個節點卻要 $O(n)$ 的時間複雜度。用相同的概念,我們可以再次在 `queue_t` 加入新變數 `tail` 來紀錄最後一個節點的位址,如此便省略從頭走訪的時間。 ```c= typedef struct { list_ele_t *head; list_ele_t *tail; int size; } queue_t; ``` `q_insert_tail` 的主要實作很單純:將新的節點 `newt` (new tail) 指派給目前佇列的最後一個節點 `q->tail->next = newt`。 但其中有許多要注意的狀況: 1. `q` 若為空值需要回傳 `false`。 2. 新節點 `newt` 若因為 malloc 失敗而為空值要回傳 `false`。 3. 複製字串 `s` 時若失敗要回傳 `false`,並將本來已分配給 `newt` 的記憶體回收。 4. 若佇列 `q` 尚無任何節點,`q->head` 和 `q->tail` 同為空值,則要將兩者都指派為 `newt`。 5. 更新佇列的大小 `q->size++`。 ```c= bool q_insert_tail(queue_t *q, char *s) { // case 1. if (q) { list_ele_t *newt = malloc(sizeof(list_ele_t)); // case 2. if (newt) { newt->next = NULL; size_t size = strlen(s) + 1; newt->value = malloc(sizeof(char) * size); // case 3. if (newt->value) { snprintf(newt->value, size, "%s", s); // case 4. if (!q->tail) { q->head = newt; q->tail = newt; } else { q->tail->next = newt; q->tail = newt; } // case 5. q->size++; return true; } free(newt); } } return false; } ``` 一開始並沒有顧及 `malloc` 回傳值為空的狀況,閱讀 man page [malloc (3)](https://man7.org/linux/man-pages/man3/malloc.3.html) 可以知道若是想分配的記憶體超過 `RLIMIT_AS` 或 `RLIMIT_DATA` 便會引發 `ENOMEM` 記憶體不足的錯誤,雖然在現代的電腦開發環境,很難觸碰到這個極限,但許多[嵌入式系統的開發](https://stackoverflow.com/questions/9101597/under-what-circumstances-can-malloc-return-null#:~:text=1-,Yes.,you%20might%20write%20in%20it.)中,因為資源較為有限,因此這個限制會低得許多。 ``` calloc(), malloc(), realloc(), and reallocarray() can fail with the following error: ENOMEM Out of memory. Possibly, the application hit the RLIMIT_AS or RLIMIT_DATA limit described in getrlimit(2). ``` 那在我的機器上需要跟 `malloc` 要求多少記憶體才會引發 `ENOMEM` 呢?按照 man page的說明,繼續去找 [getrlimit (2)](https://man7.org/linux/man-pages/man2/getrlimit.2.html) 發現可以用 `getrlimit` 來使用系統呼叫: ```c= #include <sys/resource.h> int main() { struct rlimit rlp; getrlimit(RLIMIT_NPROC, &rlp); printf("max: %lu\n", rlp.rlim_max); printf("cur: %lu\n", rlp.rlim_cur); ... } ``` `rlimit` 是一個帶有 soft limit、hard limit 的結構體。使用 `getrlimit` 時第一個參數傳入不同的 flag 便能得到 kernel 對不同資源的限制為何(結果會放在 `rlimit` 中)。 例如在這個作業中我想知道一個 `process` 允許被分配多少記憶體,就帶入 `RLIMIT_NPPROC` 這個 flag 即可。結果為: ```shell max: 18446744073709551615 cur: 18446744073709551615 ``` 為一個非常大的值,這個值也剛好是 $2^{64}-1$。 但就算 `malloc` 成功,並不代表記憶體真的夠用,繼續閱讀 [malloc (3)](https://man7.org/linux/man-pages/man3/malloc.3.html) 會知道,kernel 有 overcommit 的機制,當要讀寫時發現記憶體真的不夠用時,process 便會被 OOM killer (Out of memory killer) 終止。 另外還有幾點實作的考量: 1. `strlen` 本來的實作是一個一個 byte 讀取,直到讀到 `\0` 終止。 ```c= size_t size = 1; for (char *it = s; *it != '\0'; it++) size++; ``` 起初很確信必然得這樣才能知道字串的長度(畢竟也沒有別的資訊了),但在 [你所不知道的 C 語言:數值系統篇](https://hackmd.io/@sysprog/c-numerics) 中發現 `strlen` 還是比較快的實作方式,因為我們確實不必一次只讀一個 byte,可以一次讀 4 byte 再檢查其中有無 `\0`! 2. `strcpy` `strcpy` 已被指出是不安全的函式,[Common vulnerabilities guide for C programmers](https://security.web.cern.ch/recommendations/en/codetools/c.shtml) 中說明 `strcpy` 的問題在於沒有指出 buffer size 大小,如果字串複製的目的地太小,那複製後可能會蓋到其他的記憶體區段,也就是 Buffer overflow。 ```c= char str1[10]; char str2[]="abcdefghijklmn"; // size: 15 strcpy(str1,str2); printf("str1: %s\n", str1); printf("%s\n", str2); printf("%lu\n", strlen(str1)); printf("%lu\n", strlen(str2)); ``` ```shell str1: abcdefghijklmn str2: klmn size 1: 14 size 2: 4 ``` 這裡的問題在於 `str1` 的長度只有 10,`str2` 多出來的 5 字元寫到了自己所在的空間。 ```graphviz digraph G { rankdir=LR; node [shape=record] N [label="{<s1>||||||||||<s2>a|b|c|d|e|f|...|n|\\0}"] s1 [label="str1" shape=plaintext] s2 [label="str2" shape=plaintext] s1 -> N:s1; s2 -> N:s2; } ``` `strcpy` 後 `str2` 的記憶體空間被竄改了。 ```graphviz digraph G { rankdir=LR; node [shape=record] N [label="{<s1>a|b|c|d|e|f|g|h|i|j|<s2>k|l|m|n|\\0|f|...|n|\\0}"] s1 [label="str1" shape=plaintext] s2 [label="str2" shape=plaintext] s1 -> N:s1; s2 -> N:s2; } ``` 這也是為什麼 `strlen(str2)` 會變成 4,因為 `\0` 被寫到 `str2` 的第五個字元,後面的字元便被截掉了。 按照 CERN 的建議,換成 `snprintf` 即可有較為安全可預測的行為,第二個參數明顯的確保不會寫超過 10 bytes: ```c= char str1[10]; char str2[]="abcdefghijklmn"; // size: 15 snprintf(str1, 10, "%s", str2); printf("str1: %s\n", str1); printf("%s\n", str2); printf("%lu\n", strlen(str1)); printf("%lu\n", strlen(str2)); ``` ```shell str1: abcdefghi str2: abcdefghijklmn size 1: 9 size 2: 14 ``` 當然如果故意把 buffer size 設超過 `str1` 的大小也是會有同樣 buffer overflow 問題,。 ```c= snprintf(str1, 15, "%s", str2); ``` ### `q_insert_head` `q_insert_head` 將字串 `s` 加進佇列`q` 的開頭,跟 `q_insert_tail` 的實作相似 。函式的邏輯:將新的節點 `newh` (new head) 的 `next` 指向佇列當前的 `head`,並將佇列的 `head` 更新為 `newh`。 須要注意的地方跟 `q_insert_tail` 相似,因此不再贅述。 ```c= bool q_insert_head(queue_t *q, char *s) { if (q) { list_ele_t *newh = malloc(sizeof(list_ele_t)); if (newh) { size_t size = strlen(s) + 1; newh->value = malloc(sizeof(char) * size); if (newh->value) { snprintf(newh->value, size, "%s", s); newh->next = q->head; q->head = newh; if (!q->tail) q->tail = newh; q->size++; return true; } free(newh); } } return false; } ``` ### `q_new` 這個函式將初始化一個佇列 `queue_t`,並將其變數也一併初始化並回傳,若沒有將指標 `head` 和 `tail` 設為空值,預設會是不確定 (indeterminate),可以見這篇[討論](https://stackoverflow.com/questions/12253191/is-it-necessary-to-call-pointer-null-when-initializing)。因此後續確認節點存不存在的操作如 `if (q->head) { ... }` 實際上是會執行的,也就是說會認為 `q->head` 是有值的,而嘗試去 dereference 而發生錯誤。 ```c= queue_t *q_new() { queue_t *q = malloc(sizeof(queue_t)); if (q) { q->head = NULL; q->tail = NULL; q->size = 0; return q; } return NULL; } ``` ### `q_free` 將 `q` 所使用的記憶體釋放掉,也要將其管理的連結串列一併釋放,包括每個節點的字串。 ```c= void q_free(queue_t *q) { if (q) { list_ele_t *it = q->head; while (it) { list_ele_t *tmp = it; it = it->next; free(tmp->value); free(tmp); } free(q); } } ``` ### `q_remove_head` `q_remove_head` 這個函式將第一個節點移除、釋放其記憶體空間,並將該節點的字串複製到給定的字元指標 `sp`。最一開始我沒有想太多,利用 `memcpy` 來處理 `bufsize` 空間不夠的狀況,為了滿足 `sp` 為一個大小最大為 `bufsize - 1` 的字串,將前 `bufsize - 1` 個字元複製過去,最後再補上終止字符 `\0`。 ```c= size_t size = strlen(q->head->value) + 1; if (size > bufsize) { memcpy(sp, q->head->value, bufsize - 1); sp[bufsize - 1] = '\0'; } else { memcpy(sp, q->head->value, size); } ``` 但後來參考了前面提及的 `snprintf`,可以將其改寫成較為簡潔的形式: ```c= size_t size = strlen(q->head->value) + 1; snprintf(sp, size > bufsize ? bufsize : size, "%s", q->head->value); ``` 最後將開頭的節點釋放記憶體,並減少佇列的大小 `q->size--`。 ```c= bool q_remove_head(queue_t *q, char *sp, size_t bufsize) { if (q && q->size > 0) { if (sp) { size_t size = strlen(q->head->value) + 1; snprintf(sp, size > bufsize ? bufsize : size, "%s", q->head->value); } list_ele_t *tmp = q->head; q->head = q->head->next; free(tmp->value); free(tmp); q->size--; return true; } return false; } ``` ### `q_reverse` `q_reverse` 將連結串列的順序顛倒。首先將原本的 `head` 及 `tail` 保存下來,另外宣告兩個指標 `prev` 和 `cur`。用 `cur` 從 `head` 開始走訪各個節點, `prev` 則是如其名「跟在」 `cur` 後面,`cur` 將每個走訪到的節點的 `next` 都指向 `prev`,以此達到顛倒順序的效果,最後再將 `q->head` 及 `q->tail` 對調。 ```c= void q_reverse(queue_t *q) { if (q) { list_ele_t *h = q->head; list_ele_t *t = q->tail; list_ele_t *prev = NULL; list_ele_t *cur = q->head; while (cur) { list_ele_t *next = cur->next; cur->next = prev; prev = cur; cur = next; } q->head = t; q->tail = h; } } ``` ### `q_sort` `q_sort` 對連結串列做遞增排序。一開始我使用 quicksort 實作,但發現測試的條件有隨機的輸入以及「遞減」的輸入,對於 quicksort 來說如果 pivot 選擇不當,會讓 divide and conquer 效果大大降低,最壞情況是 $O(n^2)$ 的時間複雜度,並不適合這個情境,因此我考量使用 merge sort 來實作,無論是平均或是最壞情況都有 $O(n \log n)$ 的複雜度。 `q_sort` 將連結串列交由 `mergesort` 進行排序,排序完後再從新的 `head` 開始找到最後一個節點,將其指派給 `tail`。而對於 `q` 為空值或是大小小於 2 不會做任何操作。 ```c= void q_sort(queue_t *q) { if (q && q->size > 1) { mergesort(&q->head); list_ele_t *tmp = q->head; while (tmp->next) { tmp = tmp->next; } q->tail = tmp; } } ``` `mergesort` 利用快慢指標,找到連結串列位於中間的節點,將其一分為二遞迴地執行,最後再將兩個連結串列用 `merge` 組合成排序完成的新串列。 ```c= void mergesort(list_ele_t **head) { if ((*head)->next) { list_ele_t *fast = *head; list_ele_t *slow = *head; list_ele_t *prev = NULL; while (fast && fast->next) { fast = fast->next->next; prev = slow; slow = slow->next; } if (prev) prev->next = NULL; mergesort(head); mergesort(&slow); list_ele_t *result = NULL; merge(&result, *head, slow); *head = result; } } ``` `merge` 則是真正進行合併的地方,將兩個連結串列從頭一一比對,`*result` 指向值較小的節點,並前進到下一個節點,較大的節點將繼續做下一輪的比較。直到其中一個連結串列走到盡頭,將連結串列`*result` 依序指向另一個連結串列剩下的節點。 ```c= void merge(list_ele_t **result, list_ele_t *left, list_ele_t *right) { while (left && right) { if (strcmp(left->value, right->value) > 0) { *result = right; right = right->next; } else { *result = left; left = left->next; } result = &((*result)->next); } while (left) { *result = left; left = left->next; result = &((*result)->next); } while (right) { *result = right; right = right->next; result = &((*result)->next); } } ``` ### 改進 merge sort 一開始我用了陣列的方式去思考,很習慣地在 `merge` 函式中、用 ```c= while (left) { *result = left; left = left->next; result = &((*result)->next); } while (right) { *result = right; right = right->next; result = &((*result)->next); } ``` 去處理剩餘的節點,但驚覺這裡可以優化,多虧連結串列的彈性,我能直接將剩餘的連結串列直接接在 `*result` 之後: ```c= *result = right ? right : left; ``` 短短一行便能完成,簡潔多了。 實作完以上函式,跑 `make test` 便能通過所有測試! ```shell $ make test scripts/driver.py -c --- Trace Points +++ TESTING trace trace-01-ops: # Test of insert_head and remove_head --- trace-01-ops 6/6 +++ TESTING trace trace-02-ops: # Test of insert_head, insert_tail, and remove_head --- trace-02-ops 6/6 +++ TESTING trace trace-03-ops: # Test of insert_head, insert_tail, reverse, and remove_head --- trace-03-ops 6/6 +++ TESTING trace trace-04-ops: # Test of insert_head, insert_tail, size, and sort --- trace-04-ops 6/6 +++ TESTING trace trace-05-ops: # Test of insert_head, insert_tail, remove_head, reverse, size, and sort --- trace-05-ops 5/5 +++ TESTING trace trace-06-string: # Test of truncated strings --- trace-06-string 6/6 +++ TESTING trace trace-07-robust: # Test operations on NULL queue --- trace-07-robust 6/6 +++ TESTING trace trace-08-robust: # Test operations on empty queue --- trace-08-robust 6/6 +++ TESTING trace trace-09-robust: # Test remove_head with NULL argument --- trace-09-robust 6/6 +++ TESTING trace trace-10-malloc: # Test of malloc failure on new --- trace-10-malloc 6/6 +++ TESTING trace trace-11-malloc: # Test of malloc failure on insert_head --- trace-11-malloc 6/6 +++ TESTING trace trace-12-malloc: # Test of malloc failure on insert_tail --- trace-12-malloc 6/6 +++ TESTING trace trace-13-perf: # Test performance of insert_tail --- trace-13-perf 6/6 +++ TESTING trace trace-14-perf: # Test performance of size --- trace-14-perf 6/6 +++ TESTING trace trace-15-perf: # Test performance of insert_tail, size, reverse, and sort --- trace-15-perf 6/6 +++ TESTING trace trace-16-perf: # Test performance of sort with random and descending orders # 10000: all correct sorting algorithms are expected pass # 50000: sorting algorithms with O(n^2) time complexity are expected failed # 100000: sorting algorithms with O(nlogn) time complexity are expected pass --- trace-16-perf 6/6 +++ TESTING trace trace-17-complexity: # Test if q_insert_tail and q_size is constant time complexity Probably constant time Probably constant time --- trace-17-complexity 5/5 --- TOTAL 100/100 ``` ## 2. 透過 AddressSanitizer 分析 加入 `SANITIZER=1` 的 flag 重新 `make` 一次,在 `qtest` 裡下 `help` 後給出錯誤: ```shell ==574==ERROR: AddressSanitizer: global-buffer-overflow on address 0x55e284086400 at pc 0x55e28406f7bd bp 0x7ffdc7839ca0 sp 0x7ffdc7839c90 READ of size 4 at 0x55e284086400 thread T0 #0 0x55e28406f7bc in do_help_cmd /data/week1/lab0-c/console.c:307 #1 0x55e28406f8d0 in interpret_cmda /data/week1/lab0-c/console.c:221 #2 0x55e2840700b5 in interpret_cmd /data/week1/lab0-c/console.c:244 #3 0x55e2840717f8 in run_console /data/week1/lab0-c/console.c:660 #4 0x55e28406e3e5 in main /data/week1/lab0-c/qtest.c:780 #5 0x7f94ca40b0b2 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x270b2) #6 0x55e28406bb8d in _start (/data/week1/lab0-c/qtest+0x8b8d) 0x55e284086401 is located 0 bytes to the right of global variable 'echo' defined in 'console.c:59:13' (0x55e284086400) of size 1 SUMMARY: AddressSanitizer: global-buffer-overflow /data/week1/lab0-c/console.c:307 in do_help_cmd ``` 錯誤訊息中寫到 `echo` 這個全域變數似乎跟某個位在 `0x55e284086401` 的局域變數碰撞上了,很可能是 buffer overflow 所致,在某個 buffer 寫入超過其大小的 byte。 觀察 `echo`,宣告在 59 行: ```c= static bool echo = 0; ``` 其他被使用的方式只有正常的讀寫,除了 108 行: ```c= add_param("echo", (int *) &echo, "Do/don't echo commands", NULL); ``` 將布林指標轉型成整數指標,兩種指標指向的記憶體位址大小不同,很可能是在寫入 `echo` 時溢出了,考量到 `echo` 接受來自命令列的輸入,將之設成 `int` 也不會影響其使用,因此便將宣告改成: ```c= static int echo = 0; ``` 重跑 `make SANITIZER=1`,得到新的錯誤訊息: ```shell ==2691==ERROR: AddressSanitizer: global-buffer-overflow on address 0x55b42e086620 at pc 0x55b42e06d7b7 bp 0x7fff80b42ca0 sp 0x7fff80b42c90 READ of size 4 at 0x55b42e086620 thread T0 #0 0x55b42e06d7b6 in do_help_cmd /data/week1/lab0-c/console.c:307 #1 0x55b42e06d8ca in interpret_cmda /data/week1/lab0-c/console.c:221 #2 0x55b42e06e0af in interpret_cmd /data/week1/lab0-c/console.c:244 #3 0x55b42e06f7f5 in run_console /data/week1/lab0-c/console.c:660 #4 0x55b42e06c3e5 in main /data/week1/lab0-c/qtest.c:780 #5 0x7fce78de30b2 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x270b2) #6 0x55b42e069b8d in _start (/data/week1/lab0-c/qtest+0x8b8d) 0x55b42e086621 is located 0 bytes to the right of global variable 'simulation' defined in 'console.c:21:6' (0x55b42e086620) of size 1 'simulation' is ascii string '' ``` 這次將焦點放在 `simulation` 上,同樣為一個布林變數,但 `simulation` 並非靜態變數,意思是在其他目標檔中這個變數也是可見的,所以需要連同 `console.h` 中的宣告一併修改,改成 `int simulation` 之後再 `make SANITIZER=1` 一次,問題便解決了。 [AddressSanitizer Algorithm](https://github.com/google/sanitizers/wiki/AddressSanitizerAlgorithm) 上針對 AddressSanitizer 的幾個核心功能做的簡短說明 ## 3. 透過 Valgrind 分析 用 `make valgrind` 來使用 Valgrind 分析,可以發現到很多 still reachable 的記憶體區塊。 ```shell scripts/driver.py -p /tmp/qtest.Uu8zU3 --valgrind --- Trace Points +++ TESTING trace trace-01-ops: # Test of insert_head and remove_head ==2786== 5 bytes in 1 blocks are still reachable in loss record 1 of 3 ==2786== at 0x483B7F3: malloc (in /usr/lib/x86_64-linux-gnu/valgrind/vgpreload_memcheck-amd64-linux.so) ==2786== by 0x4A3E50E: strdup (strdup.c:42) ==2786== by 0x1100D3: linenoiseHistoryAdd (linenoise.c:1236) ==2786== by 0x110C66: linenoiseHistoryLoad (linenoise.c:1325) ==2786== by 0x10C22C: main (qtest.c:769) ==2786== ==2786== 80 bytes in 19 blocks are still reachable in loss record 2 of 3 ==2786== at 0x483B7F3: malloc (in /usr/lib/x86_64-linux-gnu/valgrind/vgpreload_memcheck-amd64-linux.so) ==2786== by 0x4A3E50E: strdup (strdup.c:42) ==2786== by 0x110047: linenoiseHistoryAdd (linenoise.c:1236) ==2786== by 0x110C66: linenoiseHistoryLoad (linenoise.c:1325) ==2786== by 0x10C22C: main (qtest.c:769) ==2786== ==2786== 160 bytes in 1 blocks are still reachable in loss record 3 of 3 ==2786== at 0x483B7F3: malloc (in /usr/lib/x86_64-linux-gnu/valgrind/vgpreload_memcheck-amd64-linux.so) ==2786== by 0x110093: linenoiseHistoryAdd (linenoise.c:1224) ==2786== by 0x110C66: linenoiseHistoryLoad (linenoise.c:1325) ==2786== by 0x10C22C: main (qtest.c:769) ==2786== --- trace-01-ops 6/6 ... ``` 為避免被輸出訊息淹沒,可以分別跑不同的測試: ```shell scripts/driver.py --valgrind -t 01 ``` 使用不同的測試資料,都是出現 `still reachable` 的訊息,可能意味著記憶體並沒有釋放。可以感受到 Valgrind 非常強大,在執行時期檢查記憶體使用情形,還能精確給出發生問題的行數。根據輸出資訊發現每一個測試結果是類似的,都是 1. `linenoiseHistoryAdd` ```c= history = malloc(sizeof(char *) * history_max_len); ``` 2. `strdup` ```c= /* Add an heap allocated copy of the line in the history. * If we reached the max length, remove the older line. */ linecopy = strdup(line); ``` 看起來都是跟 `history` 有關,所以先從 `history` 被釋放的地方找起,可以發現只有 `freeHistory` 這個函式會釋放記憶體,所幸釋放的路徑沒有太複雜,呼叫 `freeHistory` 的也只有 `linenoiseAtExit` 一個函式。 繼續觀察會發現整個函式呼叫的順序如下: ``` linenoise --> linenoiseRow --> enableRawMode --> atexit(linenoiseAtExit) ``` 而 [atexit(3)](https://man7.org/linux/man-pages/man3/atexit.3.html) 是用來註冊當程式結束時要執行的函式。因此只要這個函式被呼叫到,`history` 應該要能被釋放。 ```c= static int enableRawMode(int fd) { struct termios raw; if (!isatty(STDIN_FILENO)) goto fatal; if (!atexit_registered) { atexit(linenoiseAtExit); ... } static void linenoiseAtExit(void) { disableRawMode(STDIN_FILENO); freeHistory(); } ``` 直到看到 `run_console` 便豁然開朗,在 `has_infile = true` 的情況下,是不會去呼叫到 `linenoise` 的。 ```c= bool run_console(char *infile_name) { ... if (!has_infile) { char *cmdline; while ((cmdline = linenoise(prompt)) != NULL) { interpret_cmd(cmdline); linenoiseHistoryAdd(cmdline); /* Add to the history. */ linenoiseHistorySave(HISTORY_FILE); /* Save the history on disk. */ linenoiseFree(cmdline); } } else { while (!cmd_done()) cmd_select(0, NULL, NULL, NULL, NULL); } return err_cnt == 0; } ``` 解決的方式有二,一是將 `history` 正確釋放,二是不要去分配記憶體給他。前者因為 `history` 及對應的釋放函式都是 `static` 屬性,要改動 `linenoise.c` 及 `linenoise.h`,相對牽動的檔案比較多,而且跑檔案並沒有明顯紀錄歷史操作的需求,因此採取後者:若是跑檔案的模式 (`-f`),則不分配記憶體給 `history`。 ```c= if (!infile_name) { linenoiseSetCompletionCallback(completion); linenoiseHistorySetMaxLen(HISTORY_LEN); linenoiseHistoryLoad(HISTORY_FILE); /* Load the history at startup */ } ``` 這樣便能順利通過 `make valgrind`。 ```shell (... 上略) --- trace-15-perf 6/6 +++ TESTING trace trace-16-perf: # Test performance of sort with random and descending orders # 10000: all correct sorting algorithms are expected pass # 50000: sorting algorithms with O(n^2) time complexity are expected failed # 100000: sorting algorithms with O(nlogn) time complexity are expected pass --- trace-16-perf 6/6 +++ TESTING trace trace-17-complexity: # Test if q_insert_tail and q_size is constant time complexity Probably constant time Probably constant time --- trace-17-complexity 5/5 --- TOTAL 100/100 Test with specific case by running command: scripts/driver.py -p /tmp/qtest.Nv2TGq --valgrind -t <tid> ``` ### 使用 `massif` 視覺化觀察 `valgrind --tool=massif ./qtest -f traces/trace-01-ops.cmd` 在 simulation 模式下,只會執行 `is_insert_tail_const` 及 `is_size_const` 這兩個函式,搭配 `trace-17-complexity.cmd` 來觀察: ![](https://i.imgur.com/dHCafxB.jpg) `// TODO` ## 4. 實作 web server 及 coroutine 先試著玩玩 `tiny-web-server`。 ```shell root@35b88cfa2929:/data/week1/tiny-web-server# make cc -Wall -O2 -o tiny tiny.c root@35b88cfa2929:/data/week1/tiny-web-server# ./tiny serve directory '/data/week1/tiny-web-server' listen on port 9999, fd is 3 child pid is 25 child pid is 26 child pid is 27 child pid is 28 ``` 看看 localhost:9999,可以發現伺服器正常運作! ![](https://i.imgur.com/XuqHQCp.png) 首先找出 `tiny-web-server` 監聽 port 9999 上流量以及對應 handler 的部分。 很快可以把範圍縮小到 `process`、`handle_directory_request` 函式。 觀賞一下 `process` 函式: ```c= void process(int fd, struct sockaddr_in *clientaddr) { #ifdef LOG_ACCESS printf("accept request, fd is %d, pid is %d\n", fd, getpid()); #endif http_request req; parse_request(fd, &req); struct stat sbuf; int status = 200, ffd = open(req.filename, O_RDONLY, 0); if(ffd <= 0) { status = 404; char *msg = "File not found"; client_error(fd, status, "Not found", msg); } else { fstat(ffd, &sbuf); if(S_ISREG(sbuf.st_mode)) { if (req.end == 0) { req.end = sbuf.st_size; } if (req.offset > 0) { status = 206; } serve_static(fd, ffd, &req, sbuf.st_size); } else if(S_ISDIR(sbuf.st_mode)) { status = 200; handle_directory_request(fd, ffd, req.filename); } else { status = 400; char *msg = "Unknow Error"; client_error(fd, status, "Error", msg); } close(ffd); } #ifdef LOG_ACCESS log_access(status, clientaddr, &req); #endif } ``` 開頭 3 行是寫入 log,第 4 行將請求剖析成可用的結構體。第 8 行之後是將使用者的請求路徑用 `open` 打開,`S_ISREG` 查詢 man page [inode (7)](https://man7.org/linux/man-pages/man7/inode.7.html) 中意思是 `is it a regular file?`。 可以知道這裡是判斷該檔案合不合法的邏輯,若是合法檔案則由 `serve_static` 來處理,若是目錄,則由 `handle_directory_request` 處理。 接著看一下 `serve_static` 是如何處理客戶端的請求: ```c= void serve_static(int out_fd, int in_fd, http_request *req, size_t total_size) { char buf[256]; if (req->offset > 0) { sprintf(buf, "HTTP/1.1 206 Partial\r\n"); sprintf(buf + strlen(buf), "Content-Range: bytes %lu-%lu/%lu\r\n", req->offset, req->end, total_size); } else { sprintf(buf, "HTTP/1.1 200 OK\r\nAccept-Ranges: bytes\r\n"); } sprintf(buf + strlen(buf), "Cache-Control: no-cache\r\n"); // sprintf(buf + strlen(buf), "Cache-Control: public, max-age=315360000\r\nExpires: Thu, 31 Dec 2037 23:55:55 GMT\r\n"); sprintf(buf + strlen(buf), "Content-length: %lu\r\n", req->end - req->offset); sprintf(buf + strlen(buf), "Content-type: %s\r\n\r\n", get_mime_type(req->filename)); writen(out_fd, buf, strlen(buf)); off_t offset = req->offset; /* copy */ while(offset < req->end) { if(sendfile(out_fd, in_fd, &offset, req->end - req->offset) <= 0) { break; } #ifdef LOG_ACCESS printf("offset: %d \n\n", (unsigned int)offset); #endif close(out_fd); break; } } ``` 簡單來說,`serve_static` 會拿到一個 file descriptor `out_fd`,這是給客戶端的回應所使用的 file descriptor,先是在 `buf` 寫入回應的 header 以及內容,最後再一次用 `writen` 寫進 `out_fd` 並關檔。 這裡的情境並不需要開關檔,只需專注在如何在伺服器端讀取客戶端的請求,因此將 `process` 改寫並加上 `handle_command` 函式: ```c= void process(int fd, struct sockaddr_in *clientaddr) { #ifdef LOG_ACCESS printf("accept request, fd is %d, pid is %d\n", fd, getpid()); #endif http_request req; parse_request(fd, &req); handle_command(fd, req.filename); } ``` 而 `handle_command` 先把它做成很簡單的 echo server 來試試: ```c= void handle_command(int out_fd, char *cmd) { printf("receiving command: %s\n", cmd); char buf[256]; sprintf(buf, "HTTP/1.1 200 OK\r\n"); sprintf(buf + strlen(buf), "Content-Type: text/plain\r\n\r\n"); sprintf(buf + strlen(buf), "%s\r\n", cmd); writen(out_fd, buf, strlen(buf)); close(out_fd); } ``` 這樣一來便能成功從客戶端接收指令! ```shell accept request, fd is 4, pid is 69 receiving command: new ``` ![](https://i.imgur.com/Uw3FkhC.png) 請求和回應可以順利改動之後,把焦點轉移到程式如何同時服務多個來自客戶的請求,在 `main` 中可以看到 `fork` 的使用: ```c= int i=0; for(; i < FORK_COUNT; i++) { int pid = fork(); if (pid == 0) { // child while(1) { connfd = accept(listenfd, (SA *)&clientaddr, &clientlen); process(connfd, &clientaddr); close(connfd); } } else if (pid > 0) { // parent printf("child pid is %d\n", pid); } else { perror("fork"); } } while(1) { connfd = accept(listenfd, (SA *)&clientaddr, &clientlen); process(connfd, &clientaddr); close(connfd); } ``` 在 for 迴圈中 parent 會產出 `FORK_COUNT` 個 child,而他們會陷進 while 迴圈接受請求,而 parent 自己在產完 child 之後也會進入最下方的 while 迴圈開始等待請求。 而在進入 for 迴圈之前有個有趣的地方: ```c= // Ignore SIGPIPE signal, so if browser cancels the request, it // won't kill the whole process. signal(SIGPIPE, SIG_IGN); ``` 查詢了 man page [pipe (7)](https://man7.org/linux/man-pages/man7/pipe.7.html): ``` If all file descriptors referring to the read end of a pipe have been closed, then a write(2) will cause a SIGPIPE signal to be generated for the calling process. If the calling process is ignoring this signal, then write(2) fails with the error EPIPE. ``` 如果客戶端在伺服器寫完之前就關掉瀏覽器或是關閉 socket 連線,那會導致 `write` 發生錯誤而收到 `SIGPIPE` signal。預設會讓 process 直接終止,這邊把他忽略掉。 接著將之整合進 `qtest`,首先將 `tiny.c` 整理出需要的函式,並新增 header 檔 `tiny.h`,在這我一併將 `main()` 更名成 `start_server()` 以供 `qtest` 使用。 `qtest.c` 中,加入命令 `web`: ```c= #include "tiny.h" static bool do_web(int argc, char *argv[]); void start_server() { struct sockaddr_in clientaddr; int default_port = DEFAULT_PORT, listenfd, connfd; socklen_t clientlen = sizeof clientaddr; listenfd = open_listenfd(default_port); if (listenfd > 0) { printf("listen on port %d, fd is %d\n", default_port, listenfd); } else { perror("ERROR"); exit(listenfd); } int i=0; for(; i < FORK_COUNT; i++) { int pid = fork(); if (pid == 0) { // child while(1) { connfd = accept(listenfd, (SA *)&clientaddr, &clientlen); process(connfd, &clientaddr); close(connfd); } } else if (pid > 0) { // parent printf("child pid is %d\n", pid); } else { perror("fork"); } } while(1) { connfd = accept(listenfd, (SA *)&clientaddr, &clientlen); process(connfd, &clientaddr); close(connfd); } } static void console_init() { ... add_cmd("web", do_web, " | Start a web server"); ... } static bool do_web(int argc, char *argv[]) { report(3, "start server.."); start_server(); return true; } ``` 也要在 `Makefile` 中加入 `tiny.o` 才能連結到目標: `Makefile` 中新增 `tiny.o`: ```makefile OBJS := qtest.o report.o console.o harness.o queue.o \ random.o dudect/constant.o dudect/fixture.o dudect/ttest.o \ linenoise.o \ tiny.o ``` 重新 `make` 過後就成功加入新功能! ```shell root@35b88cfa2929:/data/week1/lab0-c# make CC tiny.o LD qtest root@35b88cfa2929:/data/week1/lab0-c# ./qtest cmd> web start server.. listen on port 9999, fd is 3 child pid is 379 child pid is 380 child pid is 381 child pid is 382 ``` 接下來的問題是,如何將收到的請求轉換成命令執行。目前的程式碼依靠 `linenoise` 將命令列的命令讀取出來,再交由 `interpret_cmd` 執行,以及存入歷史等,詳細可以參見 `run_console` 函式: ```c= char *cmdline; while ((cmdline = linenoise(prompt)) != NULL) { interpret_cmd(cmdline); linenoiseHistoryAdd(cmdline); /* Add to the history. */ linenoiseHistorySave(HISTORY_FILE); /* Save the history on disk. */ linenoiseFree(cmdline); } ``` 我希望可以重複使用 `interpret_cmd`,讓它也能處理來自使用者的請求,因此將其加入 `console.h` ,從 static 函式改為一般的函式供其他檔案 (`qtest.c`) 使用。而我希望使用者可以從伺服器的回應看見佇列的資訊,但所有的命令都是透過 `report` 函式將訊息呈現在命令列,一時間難以全面支援伺服器版本,因此新增函式 `void write_queue_info(char *buf)` 來將 `show_queue` 的資訊寫入 buffer,再由 `handle_command` 將該資訊回應給使用者。 ```c= void handle_command(int out_fd, char *cmd) { printf("receiving command: %s\n", cmd); interpret_cmd(cmd); char info[QUEUE_INFO_LEN]; write_queue_info(info); char buf[RESP_BUF_LEN]; snprintf(buf, RESP_BUF_LEN - strlen(buf) - 1, "HTTP/1.1 200 OK\r\n"); snprintf(buf + strlen(buf), RESP_BUF_LEN - strlen(buf) - 1, "Content-Type: text/plain\r\n\r\n"); snprintf(buf + strlen(buf), RESP_BUF_LEN - strlen(buf) - 1, "%s\r\n", info); writen(out_fd, buf, strlen(buf)); close(out_fd); } ``` 在將 `tiny.c` 加入的同時發現它並不符合 commit hook 的規範,所以也順便將其改寫,避免使用 `sprintf` 而用 `snprintf` 加上 buffer size,以防 buffer overflow。 對應的 Github commit: [Add tiny-web-server to qtest](https://github.com/ToastCheng/lab0-c/commit/185c250362133bf3ecfa904ed5b9df9d34722292)。 ![](https://i.imgur.com/bLYuMFE.png) ![](https://i.imgur.com/N77gvi2.png) ```shell root@35b88cfa2929:/data/week1/lab0-c# ./qtest cmd> web start server.. listen on port 9999, fd is 3 receiving command: new q = [] receiving command: ih hello q = [hello] ``` 最後試著用 coroutine 的方式改寫 `web` 命令。先 `#include <setjmp.h>` 來使用 `setjmp`、`longjmp`。想了一些方式實作 coroutine,但先從簡單的命令列和伺服器切換開始,coroutine 跟 multithread 的差異之一是,切換工作時是「自發的」,也就是說一個 coroutine 在讓出 cpu 的時間點是事先設計好的,例如 Golang 中的 goroutine (coroutine) 設計在引發 function call 的時候讓出 cpu。 因為我的實作中伺服器和命令列執行命令會是執行相同的函式,所以我使用了一個變數 `is_cmd` 來決定現在要換成誰執行,然後用兩個 `jmp_buf` 來管理伺服器和命令列兩邊的 context。 ```c= static bool is_cmd = true; static jmp_buf cmd_buf; static jmp_buf web_buf; ``` 在 server 接受連線前 `setjmp`: ```c= if (setjmp(web_buf) > 0) report(3, "back to server, serving.."); connfd = accept(listenfd, (SA *) &clientaddr, &clientlen); handle_command(connfd); close(connfd); ``` 然後讓出 cpu 的時間點,簡單起見我只在 `insert_head` 和 `insert_tail` 執行完時設定 `longjmp`: ```c= is_cmd = !is_cmd; longjmp(is_cmd ? cmd_buf : web_buf, 1); ``` 有一個須要注意的點,在 `longjmp` 回到當初 `setjmp` 處時,局域變數的值會改變!這一點會造成不可預期的錯誤,以此為例:`listenfd` 是伺服器創造的 socket descriptor,伺服器會重複使用它來接受一個又一個的連線,但當從 `longjmp` 跳躍回來時,它的值卻 ```shell cmd> web start server.. listen on port 9999, fd is 3 listen fd: 3 receiving command: new q = [] cmd> it 9 q = [9] back to server, serving.. listen fd: 80923806 ``` 因此將 `listenfd` 放置在全域讓其在 coroutine 切換時不會改變,更標準一點的做法應該是可以有一個 context 結構,保存這些重要資訊,其中包含 `jmp_buf`。 最後重新 `make` 便能得到支援 coroutine 切換的版本。對應的 Github commit: [Implement coroutine to execute both cmd and server](https://github.com/ToastCheng/lab0-c/commit/84dd11678bf0c8b6b0cd6cf64440467e40465c49) ```shell root@35b88cfa2929:/data/week1/lab0-c# ./qtest cmd> web start server.. listen on port 9999, fd is 3 receiving command: new q = [] cmd> ih 9 q = [9] back to server, serving.. receiving command: ih 8 q = [8 9] back to command line.. cmd> ``` ## 5. 分析 `console.c` 實作及 select 系統呼叫 `// TODO` ## 6. 分析 `antirez/linenoise` `// TODO` ## 7. 分析 simulation 模式 `// TODO` ## 8. 分析現有程式缺陷 `// TODO`