# 2020q1 Homework1 (lab0)
contributed by < [`KYG-yaya573142`](https://github.com/KYG-yaya573142/lab0-c) >
> [H01: lab0](https://hackmd.io/JSFDnHn0Rpe0V7d-AqLAXA?view)
## 預期目標
* [C 語言程式設計](https://hackmd.io/@sysprog/c-programming) 議題,如[不定個數參數的處理](https://en.wikibooks.org/wiki/C_Programming/stdarg.h), [signal](https://en.wikibooks.org/wiki/C_Programming/signal.h), [setjmp/longjmp](https://en.wikibooks.org/wiki/C_Programming/setjmp.h) 和記憶體管理
* 學習 [GNU/Linux 開發工具](https://hackmd.io/@sysprog/gnu-linux-dev)
- [Cppcheck](http://cppcheck.sourceforge.net/): 靜態程式碼分析工具
- [Valgrind](https://valgrind.org/): 動態程式分析工具
* 學習使用 Git 與 GitHub
* 樹立一致且易於協作的程式開發規範
* 研究自動測試機制
* 接觸 [Linux Programming Interface](http://man7.org/tlpi/)
## 作業要求
* `q_new`: 建立新的「空」佇列
* `q_free`: 釋放佇列所佔用的記憶體
* `q_insert_head`: 在佇列開頭 (head) 加入 (insert) 給定的新元素 (以 LIFO 準則)
* `q_insert_tail`: 在佇列尾端 (tail) 加入 (insert) 給定的新元素 (以 FIFO 準則),時間複雜度須為 $O(1)$
* `q_remove_head`: 在佇列開頭 (head) 移除 (remove) 給定的元素
* `q_size`: 計算佇列中的元素數量,時間複雜度須為 $O(1)$
* `q_reverse`: 以反向順序重新排列鏈結串列 (此函式不該配置或釋放任何鏈結串列元素)
* `q_sort`: 以遞增順序來排序鏈結串列的元素 (此函式不該配置或釋放任何鏈結串列元素)
## 開發流程
### queue.h
題目要求 `q_insert_tail` 和 `q_size` 的時間複雜度須為 $O(1)$,因此先修改 queue.h 中的 `struct queue_t`,新增 `list_ele_t *tail` 及 `int size`
```cpp
/* Queue structure */
typedef struct {
list_ele_t *head; /* Linked list of elements */
list_ele_t *tail;
int size;
} queue_t;
```
### `q_size`
直接回傳 `q->size` 即可
```cpp=
int q_size(queue_t *q)
{
if (!q)
return 0;
return q->size;
}
```
### `q_new`
先確認是否成功 malloc,再初始化 queue
```cpp=
queue_t *q_new()
{
queue_t *q = malloc(sizeof(queue_t));
if (!q)
return NULL;
q->head = NULL;
q->tail = NULL;
q->size = 0;
return q;
}
```
### `q_free`
須注意在 `q_insert_head` 與 `q_insert_tail` 中,除了配置 elements 的記憶體之外,還會再配置記憶體來儲存字串,因此釋放記憶體的順序應該是
1. 用來儲存字串的空間
2. linked list elements
3. queue
```cpp=
void q_free(queue_t *q)
{
if (!q) /* ignore NULL queue */
return;
/* Free queue structure */
list_ele_t *tmp = q->head;
while (tmp) {
q->head = tmp->next;
if (tmp->value) /* free the string if existed */
free(tmp->value);
free(tmp);
tmp = q->head;
}
free(q);
}
```
### `q_insert_head` & `q_insert_tail`
這兩個 function 寫起來非常相似,先配置 element 的記憶體空間,再配置 element 中用來儲存字串的空間 (記得確認 malloc 是否成功),儲存字串至 element 內,最後將 element 插入 queue 的頭/尾並把 `q->size` 加 1
實作上有幾點需特別注意:
* `strlen` 回傳的長度**不包含** null terminator,因此 length 記得要加 1 回去
```shell
$ man strlen
...
DESCRIPTION
The strlen() function calculates the length of the string pointed to by s, excluding the terminating null byte ('\0').
...
```
* 若在 malloc 字串所需空間時才失敗,記得要釋放 element 的空間再 return
* 若為 empty queue 時,需特別處理 `q->head` 或 `q->tail` (使兩者皆指向目前新增的唯一 element)
```cpp=
bool q_insert_head(queue_t *q, char *s)
{
if (!q) /* ignore NULL queue */
return false;
list_ele_t *newh = malloc(sizeof(list_ele_t));
if (!newh)
return false;
/* allocate space for the string and copy it */
int length = strlen(s) + 1;
newh->value = malloc(length * sizeof(char));
if (!newh->value) {
free(newh);
return false;
}
strncpy(newh->value, s, length);
/* insert element at head of queue */
if (q->head == NULL) /* queue is empty */
q->tail = newh;
newh->next = q->head;
q->head = newh;
(q->size)++;
return true;
}
bool q_insert_tail(queue_t *q, char *s)
{
if (!q) /* ignore NULL queue */
return false;
list_ele_t *newh = malloc(sizeof(list_ele_t));
if (!newh)
return false;
/* allocate space for the string and copy it */
int length = strlen(s) + 1;
newh->value = malloc(length * sizeof(char));
if (!newh->value) {
free(newh);
return false;
}
strncpy(newh->value, s, length);
/* insert element at tail of queue */
if (q->head == NULL) { /* no element in the queue */
q->head = newh;
q->tail = newh;
} else {
q->tail->next = newh;
q->tail = q->tail->next;
}
q->tail->next = NULL;
(q->size)++;
return true;
}
```
### `q_remove_head`
釋放記憶體的邏輯類似 `q_free`,釋放 elements 配置的記憶體前,需先處裡並釋放 element 中字串使用的空間
實作上需注意複製字串至 sp 的這個步驟,由於有最大空間為 bufsize 的限制,應該使用 `strncpy` 而非 `strcpy`,使用前先查閱說明 `man strncpy`
> The strcpy() function copies the string pointed to by src, **including** the terminating null byte ('\0'), to the buffer pointed to by dest. The strings may not overlap, and the destination string dest must be large enough to receive the copy. **Beware of buffer overruns!**
> The strncpy() function is similar, except that at most n bytes of src are copied. **Warning: If there is no null byte among the first n bytes of src, the string placed in dest will not be null-terminated.**
> If the length of src is less than n, strncpy() writes additional null bytes to dest to ensure that a total of n bytes are written.
因此當 bufsize 小於字串長度時,將不會複製到 null terminator,要記得手動將其補上
```cpp=
bool q_remove_head(queue_t *q, char *sp, size_t bufsize)
{
if (!q || !q->head) /* ignore NULL and empty queue */
return false;
list_ele_t *tmp = q->head;
q->head = q->head->next;
if (tmp->value) {
if (sp != NULL) {
strncpy(sp, tmp->value, (bufsize - 1));
sp[bufsize - 1] = '\0';
}
free(tmp->value);
}
free(tmp);
(q->size)--;
return true;
}
```
### `q_reverse`
實作上可以分為以下步驟
1. 建立 `new` 指標,用來指向已經分類完成的 elements
2. `q->tail` 可以先指向 `q->head`,因為最後順序會反轉
3. 依序將 element 從原先的 queue 取出
4. 總是將取出的 element 放到 `new` 的**頭**,來達到反轉順序的目的
5. 最後將 `new` 指標接回去 `q->head` 即可
```c=
void q_reverse(queue_t *q)
{
if (!q || !q->head) /* ignore NULL and empty queue */
return;
if (!q->head->next)
return;
list_ele_t *tmp;
list_ele_t *new = NULL;
q->tail = q->head;
while (q->head) {
/* detach element from the head */
tmp = q->head;
q->head = q->head->next;
/* reverse the queue */
tmp->next = new;
new = tmp;
}
q->head = new;
}
```
### `q_sort`
根據作業說明的共筆中提供的資料,可以使用 bubble sort、insertion sort、selection sort、merge sort 等方式來排序 (將 `sort_method` 的部分替換成欲使用的演算法名稱即可,例如 `merge_sort`)
```c=
void q_sort(queue_t *q)
{
if (!q || !q->head) /* ignore NULL and empty queue */
return;
if (!q->head->next)
return;
q->head = sort_method(q->head);
while (q->tail->next) { /* update the tail pointer */
q->tail = q->tail->next;
}
}
```
至於題目中所謂的*以遞增順序來排序鏈結串列的元素*要怎麼做呢? 其實直接觀察 qtest.c 就可以知道答案了,其中 `do_sort` 這個方程式中很明確的使用 `strcasecmp` 來檢驗 `q_sort` 的結果,因此在我們的 `q_sort` 中一樣使用 `strcasecmp` 來排序就可以了
#### merge sort
概念上可以分為拆分 (`merge_sort`) 及合併 (`merge`) 兩個部分
拆分 `merge_sort`:
1. 將原先的 list 從中間分為兩半
2. 將分出來的兩個 list 再各自從中間分為兩半
3. 重複步驟 2 直到整個 list 都被拆成單一 element
```c=
list_ele_t *merge_sort(list_ele_t *head)
{
/* merge sort */
if (!head || !head->next)
return head;
list_ele_t *slow = head;
list_ele_t *fast = head->next;
/* split list */
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
}
fast = slow->next;
slow->next = NULL;
/* sort each list */
list_ele_t *l1 = merge_sort(head);
list_ele_t *l2 = merge_sort(fast);
/* merge sorted l1 and sorted l2 */
return merge(l1, l2);
}
```
合併 `merge`
1. 排序兩個單一 element 並合併為一個 list
2. 把兩個上述步驟產生的 list 排序並接合為一個 list
3. 重複步驟 2 直到接合所有 list
以下我分別使用不同的方式來實作 `merge` 的部分
**方式 1 - 使用遞迴的方式 merge**
這個方式的優點是程式碼很簡潔,但遞迴呼叫的方式導致記憶體使用量更龐大。
最明顯的例子就是當測試 traces-15-perf.cmd 時,因為配置的 elements 數非常龐大 (size = 2,000,000),會出現 segmentation fault (使用 AddressSanitizer 可以知道問題是 stack overflow)
註:後續會再使用 valgrind 來分析此問題
```shell
$ make SANITIZER=1
$ make test
...
+++ TESTING trace trace-15-perf:
# Test performance of insert_tail, size, reverse, and sort
ASAN:DEADLYSIGNAL
=================================================================
==9205==ERROR: AddressSanitizer: stack-overflow on address 0x7ffcd0a27fd8 (pc 0x7f703599965e bp 0x7ffcd0a28840 sp 0x7ffcd0a27fa0 T0)
...
```
```c=
list_ele_t *merge(list_ele_t *l1, list_ele_t *l2)
{
if (!l1)
return l2;
if (!l2)
return l1;
if (strcasecmp(l1->value, l2->value) < 0) {
l1->next = merge(l1->next, l2);
return l1;
} else {
l2->next = merge(l1, l2->next);
return l2;
}
}
```
**方式2 - pseudo head**
常見的 pseudo head 是先 malloc 一個空間來連接已經排序完成的 element,但在本作業中不允許在 `q_sort` 內呼叫 malloc,因此我改為使用指標的方式實作,如此一來在 traces-15-perf.cmd 時就不會錯誤
```c=
list_ele_t *merge(list_ele_t *l1, list_ele_t *l2)
{
if (!l1)
return l2;
if (!l2)
return l1;
list_ele_t *head = NULL; /* pseudo head */
list_ele_t *tmp = NULL;
/* decide the first element and use it as pseudo head */
if (strcasecmp(l1->value, l2->value) < 0) {
head = l1;
l1 = l1->next;
} else {
head = l2;
l2 = l2->next;
}
/* merge remaining elements to pseudo head */
tmp = head;
while (l1 && l2) {
if (strcasecmp(l1->value, l2->value) < 0) {
tmp->next = l1;
l1 = l1->next;
} else {
tmp->next = l2;
l2 = l2->next;
}
tmp = tmp->next;
}
if (l1)
tmp->next = l1;
if (l2)
tmp->next = l2;
return head;
}
```
## 變更排序所用的函式為 natural sort
為了使用 [natural sort](https://github.com/sourcefrog/natsort),需先修改 Makefile 來將 strnatcmp.[ch] 加入
(相關的 dependency 會放在 .natsort 內)
```
...
NAT_DIR := natsort
...
OBJS := qtest.o report.o console.o harness.o queue.o \
random.o dudect/constant.o dudect/fixture.o dudect/ttest.o \
natsort/strnatcmp.o
...
%.o: %.c
@mkdir -p .$(DUT_DIR)
@mkdir -p .$(NAT_DIR)
$(VECHO) " CC\t$@\n"
$(Q)$(CC) -o $@ $(CFLAGS) -c -MMD -MF .$@.d $<
...
clean:
rm -f $(OBJS) $(deps) *~ qtest /tmp/qtest.*
rm -rf .$(DUT_DIR)
rm -rf .$(NAT_DIR)
rm -rf *.dSYM
(cd traces; rm -f *~)
```
接著需要修改 queue.c 中使用的排序方式及 `make test` 時驗證 `q_sort` 結果的部分,也就是 queue.c 與 qtest.c 這兩個檔案的部分內容
* queue.c:
實際需要修改的地方是 merge sort 使用的 `merge`,將原先使用的 `strcasecmp` 修改為 `strnatcmp` 即可
* qtest.c:
字串排序結果是在 `do_sort` 中驗證的,將原先使用的 `strcasecmp` 修改為 `strnatcmp` 即可
至此已將排序函式變更為 natural sort,然而編譯時 Cppcheck 會報錯,如下
```shell
natsort/strnatcmp.c:112:14: style: The scope of the variable 'ca' can be reduced. [variableScope]
nat_char ca, cb;
^
natsort/strnatcmp.c:112:18: style: The scope of the variable 'cb' can be reduced. [variableScope]
nat_char ca, cb;
^
natsort/strnatcmp.c:170:0: style: The function 'strnatcasecmp' is never used. [unusedFunction]
^
Fail to pass static analysis.
```
直接觀察 strnatcmp.c 相關的部分,區域變數 ca 和 cb 的用途是用來儲存接下來要比較的字元,但由於整個比較的過程都不會 (也不該) 修改到原本的字串,因此其實不用多宣告這兩個區域變數,也就是捨棄原本先宣告 `ca = a[ai]` 再使用 ca 的作法,改為直接使用 a[ai] 即可,修改完成後就可以正常編譯了
```c=
static int strnatcmp0(nat_char const *a, nat_char const *b, int fold_case)
{
int ai = 0;
int bi = 0;
int fractional, result;
while (1) {
/* skip over leading spaces or zeros */
while (nat_isspace(a[ai]))
++ai;
while (nat_isspace(b[bi]))
++bi;
/* process run of digits */
if (nat_isdigit(a[ai]) && nat_isdigit(b[bi])) {
fractional = (a[ai] == '0' || b[bi] == '0');
if (fractional) {
if ((result = compare_left(a + ai, b + bi)) != 0)
return result;
} else {
if ((result = compare_right(a + ai, b + bi)) != 0)
return result;
}
}
if (!a[ai] && !b[bi]) {
/* The strings compare the same. Perhaps the caller
will want to call strcmp to break the tie. */
return 0;
}
if (fold_case) {
if (nat_toupper(a[ai]) < nat_toupper(b[bi]))
return -1;
if (nat_toupper(a[ai]) > nat_toupper(b[bi]))
return +1;
} else {
if (a[ai] < b[bi])
return -1;
if (a[ai] > b[bi])
return +1;
}
++ai;
++bi;
}
}
```
最後是驗證的部分,原先在測試 `q_sort` 正確性的 trace files 中,無論是使用 `strcasecmp` 或是 `strnatcmp` 都會給出一樣的結果,因此根據 natural sort 提供的測試排序文件 [test-word](https://github.com/sourcefrog/natsort/blob/master/test-words) 來寫一個專門測試 natural sort 是否正確運作的 [traces/trace-18-natsort.cmd](https://github.com/KYG-yaya573142/lab0-c/blob/master/traces/trace-18-natsort.cmd),實測結果也顯示正確
```
strcasecmp strnatcmp
1-02 | 1-02
1-2 | 1-2
1-20 | 1-20
10-20 | 10-20
fred | fred
jane | jane
pic01 | pic01
pic02 | pic02
pic02000 | pic02a
pic02a | pic02000
pic05 | pic05
pic100 | pic2
pic100a | pic3
pic120 | pic4
pic121 | pic100
pic2 | pic100a
pic3 | pic120
pic4 | pic121
tom | tom
x2-g8 | x2-g8
x2-y08 | x2-y08
x2-y7 | x2-y7
x8-y8 | x8-y8
```
## 以 Valgrind 分析記憶體問題
先前使用遞迴的方式 merge 時遇到的問題一樣可以用 valgrind 分析,錯誤訊息明確的指出 stack overflow
```
+++ TESTING trace trace-15-perf:
# Test performance of insert_tail, size, reverse, and sort
==27612== Stack overflow in thread #1: can't grow stack to 0x1ffe801000
==27612== Stack overflow in thread #1: can't grow stack to 0x1ffe801000
==27612== Can't extend stack to 0x1ffe8010a8 during signal delivery for thread 1:
==27612== no stack segment
==27612==
==27612== Process terminating with default action of signal 11 (SIGSEGV)
```
## Valgrind - Massif
### 使用介紹
[Massif](https://valgrind.org/docs/manual/ms-manual.html) 可以分析程式執行過程 heap 的使用狀態,使用的方法為
```shell
$ valgrind --tool=massif ./qtest
```
以本作業來說,也可以選擇製作或使用已有的 trace file 來進行分析
```shell
$ valgrind --tool=massif ./qtest -f trace
```
註:最初使用時會報錯 `Unknown option: --show-leak-kinds=all`,將 .valgrindrc 裡面 `--show-leak-kinds=all` 刪掉即可 (.valgrindrc 裡面紀錄的是預設使用的參數)
分析結果預設存於同目錄下的 massif.out.\<pid\> 檔案中 (可以使用參數 `--massif-out-file` 指定不同的檔案),得到分析結果後,可以進一步將其圖像化
```shell
$ ms_print massif.out.<pid>
```
圖像中的 `:` 代表一般記錄點、`@` 代表詳細記錄點、`#` 代表峰值位置,y 軸為 heap 使用量,x 軸預設為 instruction 的執行數量,可使用參數 `--time-unit=<i|ms|B>` 更改
```
--time-unit=<i|ms|B> [default: i]
The time unit used for the profiling.
There are three possibilities:
1. instructions executed (i), which is good for most cases;
2. real (wallclock) time (ms, i.e. milliseconds), which is sometimes useful;
3. bytes allocated/deallocated on the heap and/or stack (B), which is useful for very short-run programs, and for testing purposes, because it is the most reproducible across different machines.
```
Massif 可配合不同的參數調整分析的方式,詳見 [Manual](https://valgrind.org/docs/manual/valgrind_manual.pdf)
### 實測 heap 使用量
接下來分別以 `--time-unit=i`(預設) 與 `--time-unit=B` 兩種參數來分析以下自訂指令
```
new
ih head 100000
it tail 100000
reverse
sort
size
quit
```
觀察 `--time-unit=i` 的分析結果,可以看到程式的過程一共約執行了 429.1 Mi,然而峰值在位置 83.6 Mi 時就出現了,這是因為我們執行的指令實際上只有 `ih head 100000` 和 `it tail 100000` 會大量增加 heap 的使用量,其餘的 `reverse` `sort` `quit` 主要改變的是 stack 而不是 heap,這也是為何到達峰值後,heap 的使用量幾乎都維持一致直到程式結束
```
$ ms_print massif.out.32595
--------------------------------------------------------------------------------
Command: ./qtest
Massif arguments: (none)
ms_print arguments: massif.out.32595
--------------------------------------------------------------------------------
MB
24.42^ #
| :#:::::::::::::::::::::::::::::::::::::::::::::::::::::
| ::#: ::
| ::#: :::
| :::#: :::
| @:::#: :::
| @:::#: :::
| :@:::#: ::::
| :@:::#: ::::
| ::@:::#: :::::
| @::@:::#: :::::
| :@::@:::#: :::::
| :@::@:::#: :::::
| ::@::@:::#: :::::
| :::@::@:::#: ::::::
| @::@::@:::#: ::::::
| :@::@::@:::#: ::::::
| :@::@::@:::#: :::::::
| ::@::@::@:::#: :::::::
|:::@::@::@:::#: :::::::
0 +----------------------------------------------------------------------->Mi
0 429.1
Number of snapshots: 53
Detailed snapshots: [8, 9, 17, 26, 34 (peak)]
```
觀察 `--time-unit=B` 的分析結果,可以發現 heap 先線性成長到峰值,接著再線性下降直到程式結束,這是因為 `ih head 100000` 和 `it tail 100000` 實質上就是重複 100000 次相同指令,所以是線性成長,接著 `quit` 時會使用 `q_free` 將記憶體逐一釋放,所以是線性下降
另外,原先 `--time-unit=i` 的分析結果在峰值後會出現一段 heap 用量幾乎沒有變動的區域,這個區域在改用 `--time-unit=B` 分析後會直接消失,這是因為此時 x 座標是 heap 的使用/釋放量,既然 heap 用量沒有改變,x 座標就不會移動
```
$ ms_print massif.out.32584
--------------------------------------------------------------------------------
Command: ./qtest
Massif arguments: --time-unit=B
ms_print arguments: massif.out.32584
--------------------------------------------------------------------------------
MB
24.42^ #
| ::#::
| :::#: :
| :::::#: :::
| :@: :::#: :: :
| :::@: :::#: :: ::::
| :: :@: :::#: :: ::::::
| :::: :@: :::#: :: :::::@:
| ::::: :@: :::#: :: :::::@:::
| :::::::: :@: :::#: :: :::::@:::::
| :::: ::::: :@: :::#: :: :::::@::::::
| :: :: ::::: :@: :::#: :: :::::@:::::::::
| ::::: :: ::::: :@: :::#: :: :::::@:::::::::::
| :: ::: :: ::::: :@: :::#: :: :::::@::::::::::::
| :::: ::: :: ::::: :@: :::#: :: :::::@:::::::::::::@
| :: :: ::: :: ::::: :@: :::#: :: :::::@:::::::::::::@:
| ::::: :: ::: :: ::::: :@: :::#: :: :::::@:::::::::::::@::::
| :::: :: :: ::: :: ::::: :@: :::#: :: :::::@:::::::::::::@::::::
| :: :: :: :: ::: :: ::::: :@: :::#: :: :::::@:::::::::::::@::::::@
| :::: :: :: :: ::: :: ::::: :@: :::#: :: :::::@:::::::::::::@::::::@::
0 +----------------------------------------------------------------------->MB
0 48.47
Number of snapshots: 71
Detailed snapshots: [1, 23, 28 (peak), 38, 55, 65]
```
Massif 的結果會顯示峰值所在的 snapshot,分別是兩種分析結果的 34 及 28 這兩筆,挑選出來後直接比對,可以發現 heap 的使用量確實是一模一樣的,差別僅在 x 軸的不同
```
--time-unit=i
--------------------------------------------------------------------------------
n time(i) total(B) useful-heap(B) extra-heap(B) stacks(B)
--------------------------------------------------------------------------------
34 83,560,433 25,610,648 20,210,167 5,400,481 0
--time-unit=B
--------------------------------------------------------------------------------
n time(B) total(B) useful-heap(B) extra-heap(B) stacks(B)
--------------------------------------------------------------------------------
28 25,611,112 25,610,648 20,210,167 5,400,481 0
```
Massif 在特定的 snapshot 會做詳細的紀錄 (同時也是圖像上的 `@` 與 `#` 所在的位置),其中 heap 的總量可再被分為兩種
* useful-heap
程式執行時向系統請求的 heap 量
* extra-heap
heap 用來記錄管理資訊的空間 (header 與 footer)、以及為了alignment 而增加的空間,這兩個也就是所謂的 internal fragmentation
挑選峰值的紀錄觀察,可以發現只有 78.91% 的 heap 使用量是 useful-heap,這代表 internal fragmentation 相對明顯
接著看下方的樹狀圖,先觀察前兩階的數據,可以發現 `q_insert_head` 與 `q_insert_tail` 都使用了 21.87% 與 17.57% 的用量,而它們的用量加起來幾乎就是 useful-heap 的總量,這符合前面觀察到的結果,因為整個測試過程中只有 `ih` 和 `it` 會新增 elements
```
--------------------------------------------------------------------------------
n time(B) total(B) useful-heap(B) extra-heap(B) stacks(B)
--------------------------------------------------------------------------------
26 24,457,704 24,457,240 19,300,056 5,157,184 0
27 24,951,528 24,951,064 19,689,714 5,261,350 0
28 25,611,112 25,610,648 20,210,167 5,400,481 0
78.91% (20,210,167B) (heap allocation functions) malloc/new/new[], --alloc-fns, etc.
->78.87% (20,200,064B) 0x10C6E9: test_malloc (harness.c:137)
| ->21.87% (5,600,000B) 0x10CC56: q_insert_head (queue.c:58)
| | ->21.87% (5,600,000B) 0x10A77F: do_insert_head (qtest.c:213)
| | ->21.87% (5,600,000B) 0x10B8E8: interpret_cmda (console.c:220)
| | ->21.87% (5,600,000B) 0x10BE5C: interpret_cmd (console.c:243)
| | ->21.87% (5,600,000B) 0x10C5CB: cmd_select (console.c:609)
| | ->21.87% (5,600,000B) 0x10C672: run_console (console.c:630)
| | ->21.87% (5,600,000B) 0x10AD97: main (qtest.c:771)
| |
| ->21.87% (5,600,000B) 0x10CCF5: q_insert_tail (queue.c:89)
| | ->21.87% (5,600,000B) 0x10A4F8: do_insert_tail (qtest.c:298)
| | ->21.87% (5,600,000B) 0x10B8E8: interpret_cmda (console.c:220)
| | ->21.87% (5,600,000B) 0x10BE5C: interpret_cmd (console.c:243)
| | ->21.87% (5,600,000B) 0x10C5CB: cmd_select (console.c:609)
| | ->21.87% (5,600,000B) 0x10C672: run_console (console.c:630)
| | ->21.87% (5,600,000B) 0x10AD97: main (qtest.c:771)
| |
| ->17.57% (4,500,000B) 0x10CC7C: q_insert_head (queue.c:63)
| | ->17.57% (4,500,000B) 0x10A77F: do_insert_head (qtest.c:213)
| | ->17.57% (4,500,000B) 0x10B8E8: interpret_cmda (console.c:220)
| | ->17.57% (4,500,000B) 0x10BE5C: interpret_cmd (console.c:243)
| | ->17.57% (4,500,000B) 0x10C5CB: cmd_select (console.c:609)
| | ->17.57% (4,500,000B) 0x10C672: run_console (console.c:630)
| | ->17.57% (4,500,000B) 0x10AD97: main (qtest.c:771)
| |
| ->17.57% (4,500,000B) 0x10CD1B: q_insert_tail (queue.c:94)
| | ->17.57% (4,500,000B) 0x10A4F8: do_insert_tail (qtest.c:298)
| | ->17.57% (4,500,000B) 0x10B8E8: interpret_cmda (console.c:220)
| | ->17.57% (4,500,000B) 0x10BE5C: interpret_cmd (console.c:243)
| | ->17.57% (4,500,000B) 0x10C5CB: cmd_select (console.c:609)
| | ->17.57% (4,500,000B) 0x10C672: run_console (console.c:630)
| | ->17.57% (4,500,000B) 0x10AD97: main (qtest.c:771)
| |
| ->00.00% (64B) in 1+ places, all below ms_print's threshold (01.00%)
|
->00.04% (10,103B) in 1+ places, all below ms_print's threshold (01.00%)
```
### 記憶體對齊
從 [你所不知道的 C 語言:記憶體管理、對齊及硬體特性](https://hackmd.io/@sysprog/c-memory?type=view) 可以知道在 64-bit 的系統下,malloc 會以 16 bytes 進行對齊,而原先 heap block 用來記錄管理資訊的空間 header 與 footer 會共佔 8 bytes,也就是說如果 elements 中儲存的字串低於 8 個字 (含 null terminator),一律會因為 alignment 要求而實際 malloc 8 bytes,同理當字串長度為 9~24 個字時皆會 malloc 24 bytes
這個現象也可以用 massif 觀察,使用以下的指令進行實驗,主要目的是驗證使用字串為 "h" 與 "t" 時的 heap 使用量其實與字串為 "head" 與 "tail" 時一樣
```
new
ih h 100000
it t 100000
free
new
ih head 100000
it tail 100000
quit
```
從結果可以看到 heap 總使用量根本一樣,而字串為 "head" 與 "tail" 時,因為程式實際索取的記憶體大小較大,產生的 useful-heap 也確實比較高
```
string: "h", "t"
--------------------------------------------------------------------------------
n time(B) total(B) useful-heap(B) extra-heap(B) stacks(B)
--------------------------------------------------------------------------------
18 25,611,112 25,610,648 19,610,164 6,000,484 0
76.57% (19,610,164B) (heap allocation functions) malloc/new/new[], --alloc-fns, etc.
string: "head", "tail"
--------------------------------------------------------------------------------
n time(B) total(B) useful-heap(B) extra-heap(B) stacks(B)
--------------------------------------------------------------------------------
28 25,611,112 25,610,648 20,210,167 5,400,481 0
78.91% (20,210,167B) (heap allocation functions) malloc/new/new[], --alloc-fns, etc.
```
最後再追加一個比較,目的是呈現當字串長度為 9~24 個字時會 malloc 24 bytes
註:massif 沒辦法同時抓取複數峰值,因此只呈現圖像結果
```
new
ih h 100000
it t 100000
free
new
ih head 100000
it tail 100000
free
new
ih headhead 100000
it tailtail 100000
quit
```
結果很明顯,字串長度 8 以內 heap 的使用量都一樣,字串長度一到 9 bytes 就能觀察到 heap 使用量提升
```
MB
27.48^ #
| #
| :#
| @ :: ::#:
| :@: :: ::#::
| :@: :: : :::#::
| @:@::: :: : @:::#:::
| @:@:: :::: :: @:::#::::
| :@:@:: @ : :: ::: :@:::#::::
| :@:@:: @ :: :: ::: ::@:::#:::::
| :::@:@:: @: :: :: :::: ::@:::#::::::
| : :@:@:: @:: ::: :: ::::: :::@:::#:::::@
| :: :@:@:: @:: ::: :: :::::@ ::::@:::#:::::@:
| :: :@:@:: @::: :::: :: :::::@ :::::@:::#:::::@::
| ::: :@:@:: @::: ::::: :: :::::@: :::::@:::#:::::@::
| :::: :@:@:: @::::: :::::: :: :::::@ @:::::@:::#:::::@:::
| :::: :@:@:: @:::: ::::::: :: :::::@ : @:::::@:::#:::::@::::
| :::: :@:@:: @:::: ::::::: :: :::::@ : @:::::@:::#:::::@::::
| :::::: :@:@:: @:::: : :::::::: :: :::::@ :: :@:::::@:::#:::::@:::::
|:: :::: :@:@:: @:::: :: @::::::: :: :::::@ ::@ ::@:::::@:::#:::::@::::::
0 +----------------------------------------------------------------------->MB
0 152.0
```
## 分析 Makefile
閱覽 Makefile 有助於了解整個程式的架構,然而點開 Makefile 後發現~~根本看不懂~~需要花點時間來理解內容,以下會分段逐步理解 Makefile 的內容,許多用法在 [Makefile 語法和示範](https://hackmd.io/@sysprog/SySTMXPvl) 中已經有詳細示範,將不再重複說明
[完整的 Makefile 請參照 Github](https://github.com/KYG-yaya573142/lab0-c/blob/master/Makefile)
* 首次執行 `make` 會自動安裝 git hook:
直接 `make` 預設會執行第一個 target,也就是 `all` 這個項目,所以除了編譯 `qtest` 之外,還會執行安裝 git hook 的腳本
```
all: $(GIT_HOOKS) qtest
$(GIT_HOOKS):
@scripts/install-git-hooks
@echo
```
* `make VERBOSE=1` 可以顯示詳細編譯訊息
* `@` 的作用是該行的指令不會被顯示
* `@true` 則追加該行指令直接被視為成功的作用
* 因此 `" LD\t$@\n"` 這行如果前面從 `@printf` 改為 `@` 會出錯,需要追加 `true` 讓這行直接視為執行成功 (這個例來說就是該行直接無效但不出錯)
```
# Control the build verbosity
ifeq ("$(VERBOSE)","1")
Q :=
VECHO = @true
else
Q := @
VECHO = @printf
endif
qtest: $(OBJS)
$(VECHO) " LD\t$@\n"
$(Q)$(CC) $(LDFLAGS) -o $@ $^ -lm
```
* Dependency 的部分相當有趣,查資料後發現本作業中使用類似 [Auto-Dependency Generation](http://make.mad-scientist.net/papers/advanced-auto-dependency-generation/) 的方法來處理 dependency 的問題
* `deps := $(OBJS:%.o=.%.o.d)` 使用自動變數替換,也就是每個 dependency 檔案名稱都是物件檔結尾加上 .d
* `-MMD` 代表僅考慮 user header files 不包含 system head files,`-MF` 是指定輸出 dependency 檔案的名稱
* `-include $(deps)` 須放置在檔案的最後,告訴 `make` 需要 dependency 檔案的名稱,前置的 `-` 代表就算執行失敗也不強制結束 `make` 的執行
```
OBJS := qtest.o report.o console.o harness.o queue.o \
random.o dudect/constant.o dudect/fixture.o dudect/ttest.o \
deps := $(OBJS:%.o=.%.o.d)
%.o: %.c
@mkdir -p .$(DUT_DIR)
$(VECHO) " CC\t$@\n"
$(Q)$(CC) -o $@ $(CFLAGS) -c -MMD -MF .$@.d $<
-include $(deps)
```
* sanitizer 的支援
* 很單純的加入相關的編譯參數
```
# Enable sanitizer(s) or not
ifeq ("$(SANITIZER)","1")
# https://github.com/google/sanitizers/wiki/AddressSanitizerFlags
CFLAGS += -fsanitize=address -fno-omit-frame-pointer -fno-common
LDFLAGS += -fsanitize=address
endif
```
* valgrind 的支援
* `2>&1` 指的是將 stderr 的輸出重新導向到 stdout
* `/dev/null` 概念類似黑洞,進去的東西都會消失
* `||` 代表若左邊執行出錯,就執行右邊的內容
* 因此 `valgrind_existence` 執行的意思就是 "檢查 valgrind 版本 (但無論成功失敗都不列出結果),如果檢查失敗就顯示右邊的錯誤訊息"
* `$(MAKE)` 代表將這行當作 make 來執行,詳見 [Manual](https://www.gnu.org/software/make/manual/html_node/MAKE-Variable.html)
* `$(eval expression)` 會先將 expression 展開,接著再將 expression 的內容視為 Makefile 的一部分並執行,與直接將 expression 寫在 Makefile 的差別為,後面的 shell 指令 `mktemp` 只有在 `make valgrind` 時才會執行並取得檔案名稱,詳見 [Manual](https://www.gnu.org/software/make/manual/html_node/Eval-Function.html)
* `sed -i "s/alarm/isnan/g" $(patched_file)`,其中 `s/alarm/isnan/g` 不是路徑,是 `sed` 用來替換內容的表示法,這裡的意思是 "將 `$(patched_file)` 中的所有的 alarm 替換成 iasnan",詳見 [Manual](https://www.gnu.org/software/sed/manual/html_node/The-_0022s_0022-Command.html)
* 由於 valgrind 會大幅度的增加程式的執行時間,將 `alarm` 去除可以避免在 `qtest` 中設置 timer,去除原先對執行時間的限制
* 相關的語法可以參閱 [sed - stream editor](http://manpages.ubuntu.com/manpages/precise/en/man1/plan9-sed.1.html) 以及 [the s command](https://www.gnu.org/software/sed/manual/html_node/The-_0022s_0022-Command.html) (感謝 [Randy870819](https://hackmd.io/@randy870819/system-prog-lab0) 提供)
```
valgrind_existence:
@which valgrind 2>&1 > /dev/null || (echo "FATAL: valgrind not found"; exit 1)
valgrind: valgrind_existence
# Explicitly disable sanitizer(s)
$(MAKE) clean SANITIZER=0 qtest
$(eval patched_file := $(shell mktemp /tmp/qtest.XXXXXX))
cp qtest $(patched_file)
chmod u+x $(patched_file)
sed -i "s/alarm/isnan/g" $(patched_file)
scripts/driver.py -p $(patched_file) --valgrind
@echo
@echo "Test with specific case by running command:"
@echo "scripts/driver.py -p $(patched_file) --valgrind -t <tid>"
```
## [dudect](https://github.com/oreparaz/dudect)
dudect 以兩種不同類別的資料來測試程式的執行時間,再以統計的方式分析兩者的執行時間的平均值是否有差異,若無法證明平均值有差異,則程式的執行時間可能為常數 (constant time)
dudect 的優點是不需要執行平台的硬體資訊 (這也是本題可以使用虛擬機跑的原因之一)。執行的過程可以分為三個步驟
1. 量測執行時間
使用的資料分為固定資料及隨機資料兩種 (fix-vs-random test),同時為了降低環境因素的影響,每次量測選定的資料種類也是隨機的,執行時間則是根據 CPU cycle 來計算
3. 後處理
每次量測時間後,可以在統計分析前先進行後處理,例如去除一定數值以外的資料 (cropping),來避免因程式執行過程中被系統插斷造成的正偏差。另外,可以根據後面想執行的統計分析,先在此步驟先進行資料處理
5. 統計分析
dudect 使用 [Welch's t-test](https://en.wikipedia.org/wiki/Welch's_t-test) 來分析兩種資料的結果,如果檢定結果無法否定 "兩種資料具有相同的平均數" 這個零假設 (null hypothesis),就代表兩種資料很有可能具有一樣的平均數,也就是程式執行時間很可能是常數
#### t-test
* t-test 是一種假說檢定 (statistical hypothesis test),指零假設 (null hypothesis) 成立時的任一檢定統計會具有 Student’s t-distribution
* Student's t-test 和 Welch's t-test 皆為雙樣本的 t-test 應用,Student's t-test 同時假設兩個母體 (population) 具有相同的變異數,Welch's t-test 則無此假設
* 本作業中使用的是 Welch's t-test,因為我們沒辦法得知母體的變異數
#### [Student’s t-distribution](https://zh.wikipedia.org/wiki/%E5%AD%A6%E7%94%9Ft-%E5%88%86%E5%B8%83#%E5%AD%A6%E7%94%9Ft-%E5%88%86%E5%B8%83%E7%BD%AE%E4%BF%A1%E5%8C%BA%E9%97%B4%E7%9A%84%E6%8E%A8%E5%AF%BC)
* 用於根據小樣本來估計 "呈常態分布且變異數未知的總體" 的平均值
* 自由度 $\nu$ 越大,t-distribution 越接近常態分佈
* 也簡稱為 t-distribution
### 分析本作業中如何實作 dudect
從 qtest.c 裡面可以看到使用 `is_insert_tail_const` 與 `is_size_const` 來判斷,其函數的定義在 dudect/fixture.c 內,由於整個程式的架構非常複雜,現階段先釐清整體實作的架構,許多輔助函數的實作之後再探討
#### `is_size_const`
```c=
bool is_size_const(void)
{
bool result = false;
t = malloc(sizeof(t_ctx));
for (int cnt = 0; cnt < test_tries; ++cnt) {
printf("Testing size...(%d/%d)\n\n", cnt, test_tries);
init_once();
for (int i = 0;
i <
enough_measurements / (number_measurements - drop_size * 2) + 1;
++i)
result = doit(1);
printf("\033[A\033[2K\033[A\033[2K");
if (result == true)
break;
}
free(t);
return result;
}
```
* `t_ctx` 是一個儲存統計用資料的資料結構,相關的意義可以在後續的 `t-push` 中得知
* mean 是平均值
* m2 是離均差之合 (aggregates of squared distance from the mean)
* n 是資料數量
```
typedef struct {
double mean[2];
double m2[2];
double n[2];
} t_ctx;
```
* 第一個迴圈 `for (int cnt = 0; cnt < test_tries; ++cnt)` 代表最多會進行 10 次測試,如果結果為真,會直接跳離迴圈 (也就是結果顯示為 constant time)
* `init_once` 會將 `t` 的所有資料成員初始化為 `0.0`,`q` 初始化為 `NULL`
* 實際的計算與量測在 `doit` 中實施
* `doit` 一次會進行 `(number_measurements - drop_size * 2)` 次的測試,而統計需求共需 `enough_measurements = 10000` 次測試數據
#### `doit`
```c=
static bool doit(int mode)
{
int64_t *before_ticks = calloc(number_measurements + 1, sizeof(int64_t));
int64_t *after_ticks = calloc(number_measurements + 1, sizeof(int64_t));
int64_t *exec_times = calloc(number_measurements, sizeof(int64_t));
uint8_t *classes = calloc(number_measurements, sizeof(uint8_t));
uint8_t *input_data =
calloc(number_measurements * chunk_size, sizeof(uint8_t));
if (!before_ticks || !after_ticks || !exec_times || !classes ||
!input_data) {
die();
}
prepare_inputs(input_data, classes);
measure(before_ticks, after_ticks, input_data, mode);
differentiate(exec_times, before_ticks, after_ticks);
update_statistics(exec_times, classes);
bool ret = report();
free(before_ticks);
free(after_ticks);
free(exec_times);
free(classes);
free(input_data);
return ret;
}
```
* `prepare_inputs(input_data, classes)`
* 總共準備 `number_measurements = 150` 個資料
* classes 的內容只會是 0 和 1 ,用來區分兩種資料類別
* `input_data` 的每個成員大小為 `chunk_size * sizeof(uint8_t)`,也就是 16 bytes
* class 0 的 `input_data` 的前兩個 bytes 會被設置為 0
* 在此函數內會一同隨機設置用來測試的字串 `static char random_string[100][8]`
* 產生隨機數值的方法應該是從[這裡](https://bench.cr.yp.to/tips.html)偷過來的
* `measure`
* `assert` 是一個保護函數 [(man assert)](http://man7.org/linux/man-pages/man3/assert.3.html)
* 根據 `mode` 的數值來判斷是要分析 `q_size` 還是 `q_insert_tail` ,另外這裡有用到 anonymous enum 的技巧 `enum { test_insert_tail, test_size }`
* 從 `for (size_t i = drop_size; i < number_measurements - drop_size; i++)` 可以看出實際使用的資料會從頭尾再扣除 `drop_size = 20`,查閱[原始 Github 專案](https://github.com/oreparaz/dudect/tree/master/src)並比對內容,這麼做的目的就是 dudect 步驟二的 cropping,只是本作業中有對 cropping 的時間點稍作修改
* 原始 Github 專案共使用 first order uncropped、cropped、second order uncropped 三種方式來計算最大 t 值,本作業中大幅度省略至只使用單一閥值來 cropping
* 為了避免和主測試函數的內容混淆,同時維持測試環境的一致性,這邊會先使用 `dut_new` 與 `dut_insert_head` 做出測試用的 linked-list
* 實際測試時間的函數被包在 `before_ticks[i] = cpucycles()` 與 `after_ticks[i] = cpucycles()` 之間,執行時間以 CPU clock cycles 當基準計算
* 由 `*(uint16_t *) (input_data + i * chunk_size) % 10000)` 可以看出字串插入的隨機數量是根據 `input_data` 的前兩個 bytes 決定,而且最多不超過 10000。同時由此可以看出 class 0 的數據實際上根本不會插入字串
* `differentiate`
* 計算執行時間 `exec_times[i] = after_ticks[i] - before_ticks[i]`
* `update_statistics`
* 選定納入使用的數據,並呼叫 `t-push` 對其進行前處理
* CPU cycle counter overflowed 的資料與 `drop_size` 涵蓋的資料會在此被過濾掉
* `t-push`
* 使用 [Welford's online algorithm](https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Welford's_online_algorithm) 計算實行 Welch's t-test 所需的資料,計算結果儲存於 `t` 指向的資料結構中
* `report`
* 使用 `t_compute` 計算出 t 值,判斷是否在閥值範圍內並回傳判斷結果
#### `cpucycles`
```cpp=
static inline int64_t cpucycles(void)
{
#if defined(__i386__) || defined(__x86_64__)
unsigned int hi, lo;
__asm__ volatile("rdtsc\n\t" : "=a"(lo), "=d"(hi));
return ((int64_t) lo) | (((int64_t) hi) << 32);
#else
#error Unsupported Architecture
#endif
}
```
* 執行時間以 CPU clock cycles 當基準計算
* 詳細方法可參閱 [Code Execution Times: IA-32/IA-64 Instruction Set Architecture](https://www.intel.com/content/www/us/en/embedded/training/ia-32-ia-64-benchmark-code-execution-paper.html) (現階段我還看不太懂 [asm](https://linux.die.net/man/3/inline_asm) 的用法)
#### `t_compute`
```c=
double t_compute(t_ctx *ctx)
{
double var[2] = {0.0, 0.0};
var[0] = ctx->m2[0] / (ctx->n[0] - 1);
var[1] = ctx->m2[1] / (ctx->n[1] - 1);
double num = (ctx->mean[0] - ctx->mean[1]);
double den = sqrt(var[0] / ctx->n[0] + var[1] / ctx->n[1]);
double t_value = num / den;
return t_value;
}
```
* 實際計算 [Welch’s t-test](https://en.wikipedia.org/wiki/Welch%27s_t-test) 的函式
* 計算檢定量 $t \quad = \quad {\; \overline{X}_1 - \overline{X}_2 \; \over \sqrt{ \; {s_1^2 \over N_1} \; + \; {s_2^2 \over N_2} \quad }}$
* $\bar{X}$ 為平均值,也就是 `ctx->mean`
* $s^2$ 為標準差平方,也就是 `ctx->m2`
* $N$ 為資料數量,也就是 `ctx->n`
* 本作業直接以固定的 t 值 `t_threshold_moderate = 10` 進行雙尾檢定 (two-tail test),也就是判斷 t 的絕對值是否在接受範圍內,來判斷是否為 constant time
* 閥值越大,[I 型錯誤](https://zh.wikipedia.org/wiki/%E7%AC%AC%E4%B8%80%E5%9E%8B%E5%8F%8A%E7%AC%AC%E4%BA%8C%E5%9E%8B%E9%8C%AF%E8%AA%A4) (執行時間被誤認為非常數) 的機率會降低,但 [II 型錯誤](https://zh.wikipedia.org/wiki/%E7%AC%AC%E4%B8%80%E5%9E%8B%E5%8F%8A%E7%AC%AC%E4%BA%8C%E5%9E%8B%E9%8C%AF%E8%AA%A4) (執行時間被誤認為常數) 的機率會增加
* 原先的 Welch’s t-test 需再算出自由度 $\nu$ 來獲得對應的 t-distribution,根據設定的信賴區間及 t-distribution 決定 t 的閥值,再進行雙尾檢定
$\nu \quad \approx \quad {{\left( \; {s_1^2 \over N_1} \; + \; {s_2^2 \over N_2} \; \right)^2 } \over{ \quad {s_1^4 \over N_1^2 \nu_1} \; + \; {s_2^4 \over N_2^2 \nu_2 } \quad }},\quad\nu_1 = N_1-1,\quad\nu_2 = N_2-1$
## RIO 套件 (Robust I/O)
[RIO 套件](http://csapp.cs.cmu.edu/2e/ch10-preview.pdf)是 CS:APP 中提供的一種 system I/O [wrapper](https://en.wikipedia.org/wiki/Wrapper_library) 套件,其內容建立於原本的 `read` 與 `write` 之上,原始內容可詳見 [csapp.c](http://csapp.cs.cmu.edu/3e/ics3/code/src/csapp.c) 與 [csapp.h](http://csapp.cs.cmu.edu/2e/ics2/code/include/csapp.h)
RIO 套件具有以下特點
* 被 signal handler 中斷後 (會導致 [EINTR](http://man7.org/linux/man-pages/man2/read.2.html) 錯誤),會自動嘗試重新執行,直到遇到 EOF 或是達到指定的讀取大小
* 具有 application-level buffer,可大幅降低實際呼叫 system call 的次數,進而提升效能
* 可以方便的用於 socket
* thread-safe (之後研讀 CS:APP 第 12 章會再探討並補充進來)
RIO 套件的精隨在於 `rio_read` 以及對應的 rio 結構體 `rio_t`
```cpp
#define RIO_BUFSIZE 8192
typedef struct {
int rio_fd; /* Descriptor for this internal buf */
int rio_cnt; /* Unread bytes in internal buf */
char *rio_bufptr; /* Next unread byte in internal buf */
char rio_buf[RIO_BUFSIZE]; /* Internal buffer */
} rio_t;
/*
* rio_read - This is a wrapper for the Unix read() function that
* transfers min(n, rio_cnt) bytes from an internal buffer to a user
* buffer, where n is the number of bytes requested by the user and
* rio_cnt is the number of unread bytes in the internal buffer. On
* entry, rio_read() refills the internal buffer via a call to
* read() if the internal buffer is empty.
*/
static ssize_t rio_read(rio_t *rp, char *usrbuf, size_t n)
{
int cnt;
while (rp->rio_cnt <= 0) { /* Refill if buf is empty */
rp->rio_cnt = read(rp->rio_fd, rp->rio_buf,
sizeof(rp->rio_buf));
if (rp->rio_cnt < 0) {
if (errno != EINTR) /* Interrupted by sig handler return */
return -1;
}
else if (rp->rio_cnt == 0) /* EOF */
return 0;
else
rp->rio_bufptr = rp->rio_buf; /* Reset buffer ptr */
}
/* Copy min(n, rp->rio_cnt) bytes from internal buf to user buf */
cnt = n;
if (rp->rio_cnt < n)
cnt = rp->rio_cnt;
memcpy(usrbuf, rp->rio_bufptr, cnt);
rp->rio_bufptr += cnt;
rp->rio_cnt -= cnt;
return cnt;
}
```
* 在使用 RIO 套件前,會先建立並初始化一個 `rio_t` 物件
* `rio_read` 在被呼叫後,會先檢查 `rio_t` 物件中的 buffer 是否還有內容可以被使用
* 若有 - 直接讀取 `rio_buf`
* 若無 - 呼叫 `read` 讀取最多至 8192 bytes 的內容至 `rio_buf`
* 也就是說使用者呼叫 `rio_read` 取得的內容總是會從 `rio_buf` 取得,`rio_read` 同時會負責更新 `rio_buf` 的內容
### `qtest` 使用 RIO 套件的考量點
`qtest` 使用 RIO 套件的優勢會體現於 trace files 的讀取,若使用無緩衝的 `read` 來讀取指令,需要重複以 `read` 讀取單個字元直到遇到 `\n` 為止,如此一來會因為頻繁的 system call 造成效能低落
改用 RIO 套件的方法後,會將整個 trace file 的內容存入 `rio_buf`,接著 `readline` 會從 `rio_buf` 內讀出指令來執行,只有當 `rio_buf` 內容都使用完畢 (也就是跑完整個 trace file 的內容),才會再次將下一個 trace file 的內容完整存入 `rio_buf`
### `qtest` 實際使用 RIO 套件的方式
觀察 qtest.c 及 console.c 可歸納出整體的呼叫順序,`qtest` 首先會在 `run_console` 初始化 `rio_t` 物件的空間,接著 `cmd_select` 會負責從 `rio_buf` 取得輸入的字串並交給後續的 `interpret_cmd` 執行指令,`cmd_select` 中的 `readline` 則是實際讀取與更新 buffer 的函數 (等同於 RIO 套件的 `rio_read`)
```
main → run_console → cmd_select → interpret_cmd → parse_args
```
#### `run_console`
```cpp
bool run_console(char *infile_name)
{
if (!push_file(infile_name)) {
report(1, "ERROR: Could not open source file '%s'", infile_name);
return false;
}
while (!cmd_done())
cmd_select(0, NULL, NULL, NULL, NULL);
return err_cnt == 0;
}
static bool push_file(char *fname)
{
int fd = fname ? open(fname, O_RDONLY) : STDIN_FILENO;
if (fd < 0)
return false;
if (fd > fd_max)
fd_max = fd;
rio_ptr rnew = malloc_or_fail(sizeof(rio_t), "push_file");
rnew->fd = fd;
rnew->cnt = 0;
rnew->bufptr = rnew->buf;
rnew->prev = buf_stack;
buf_stack = rnew;
return true;
}
```
* `rio_t` 物件在 `push_file` 內產生,但此時 `rio_buf` 還沒有內容
* 最後將產生的物件以 linked list 的方式插入 `buf_stack` 所指向 list
* 觀察 console.c 中定義的 `rio_t` 資料結構以及 `buf_stack` 指標的使用方式,可以發現 console 將每個 `rio_t` 物件以 stack 的邏輯在管理,這也是為何新增及刪除 `rio_t` 物件的函式名稱分別叫做 `push_file` 及 `pop_file`
```cpp
#define RIO_BUFSIZE 8192
typedef struct RIO_ELE rio_t, *rio_ptr;
struct RIO_ELE {
int fd; /* File descriptor */
int cnt; /* Unread bytes in internal buffer */
char *bufptr; /* Next unread byte in internal buffer */
char buf[RIO_BUFSIZE]; /* Internal buffer */
rio_ptr prev; /* Next element in stack */
};
static rio_ptr buf_stack;
```
#### `cmd_select`
流程略為複雜,直接以 flowchart 呈現其運作邏輯,`select` 的部分可參閱[下個章節的討論](#解釋-select-系統呼叫在本程式的使用方式)
```flow
st=>start: cmd_select
cond1=>condition: read_ready:
complete line
in rio_buf?
cond2=>condition: cmd_done:
buf_stack?
sub1=>subroutine: readline
(from rio_buf)
sub2=>subroutine: readline
(from file)
op1=>operation: select
op2=>operation: interpret_cmd
op3=>operation: interpret_cmd
op4=>operation: interpret_cmd
e=>end: end
st->cond1
cond1(yes)->sub1->op4(left)->cond1
cond1(no)->cond2(yes)->op1->sub2->op2->e
cond2(no)->e
```
* `interpret_cmd` 會呼叫 `parse_args` 來將 `readline` 回傳的內容解析為指令及參數,最後根據指令及參數呼叫 `interpret_cmda` 來執行該指令內容
* 執行命令的部分,[作業導讀](https://hackmd.io/@sysprog/linux2020-lab0#qtest-%E5%91%BD%E4%BB%A4%E7%9B%B4%E8%AD%AF%E5%99%A8%E7%9A%84%E5%AF%A6%E4%BD%9C)的部分已經說明得很清楚了
#### `readline`
```flow
st=>start: readline
cond1=>condition: rio_buf?
cond2=>condition: rio_buf?
cond3=>condition: \n?
op1=>operation: read:
update rio_buf
op2=>operation: read a char
from rio_buf
op3=>operation: test
op4=>operation: EOF:
pop_file
e=>end: end
st(right)->cond1
cond1(no, bottom)->op1->cond2
cond1(yes, right)->op2(right)->cond3
cond3(no)->cond1
cond3(yes)->e
cond2(no)->op4->e
cond2(yes)->op2
```
* 邏輯類似 RIO 套件的 `rio_read`
* 與 RIO 套件不同的地方是,被 signal 中斷而未讀取任何資料時 (`EINTR`),**不會**自動重新執行 `read`,而會被判斷成 `EOF`
## 解釋 `select` 系統呼叫在本程式的使用方式
```cpp
int select(int nfds,
fd_set *readfds, fd_set *writefds, fd_set *exceptfds,
struct timeval *timeout);
```
`fd_set` 是一種儲存 file descriptor (以下簡稱為 fd) 編號的集合,例如 fd 集合為 {0, 2, 7} 的 `fd_set` 以 binary 顯示會是 10000101。`fd_set` 須配合下面幾種 macro 來管理
* `FD_ZERO(fd_set *set)` - 清空 set 內容
* `FD_SET(int fd, fd_set *set)` - 新增 fd 至 set
* `FD_CLR(int fd, fd_set *set)` - 將 fd 從 set 移除
* `FD_ISSET(int fd, fd_set *set)` - 確認 fd 是否可用 (在 `select` 返回後使用)
`select` 會暫停程式並監測 `fd_set` 清單中紀錄的 fd,直到其中至少有一個 fd 變成可使用的狀態,返回 "可用 fd 的數量",並將相對應的 `fd_set` 清單內容修改為 "可用 fd 清單" (因此每次呼叫 `select` 前都須 `FD_ZERO` 初始化清單)
* 監測至最大編號為 nfds 的 fd
* 監測 readfds 中紀錄的 fd 是否可讀
* 監測 writefds 中紀錄的 fd 是否可寫
* 監測 exceptfds 中紀錄的 fd 是否有特殊狀態
* timeout 可以設定 `select` 要等待多久 [(詳見 man select)](http://man7.org/linux/man-pages/man2/select.2.html)
本作業中 `select` 使用在 `cmd_select` 內,當 `read_ready` 確認 `rio_buf` 無內容可用後,`select` 會暫停程式直到以下兩種情況發生
* 使用者輸入指令 (具體來說是按下的 ENTER 鍵)
* trace file 可被讀取 (**想不到哪種情況會產生這個情況**)
最後 `readline` 才會根據 `select` 的結果決定是否能從 fd 讀取資料
### `select` 於本作業中的必要性
以下列程式碼進行實驗
```cpp
int main(int argc, char *argv[])
{
int fds = STDIN_FILENO;
int nfds = fds + 1;
int result = 0;
fd_set readfds;
char line[10];
memset(line, 0x0, 10);
while (1) {
FD_ZERO(&readfds);
FD_SET(fds, &readfds);
select(nfds, &readfds, NULL, NULL, NULL);
if (FD_ISSET(fds, &readfds)) {
result = read(fds, line, 10);
line[result] = '\0';
printf("input: %s", line);
}
}
}
```
執行結果如下
```shell
test
input: test
hello
input: hello
```
但其實如果把 `select` 的部份去掉,輸出結果是一樣的,差別是
* 使用 `select` 時,程式會在 `select` 等待輸入,輸入後 `read` 會直接讀取剛剛輸入的數值
* 單獨使用 `read` 時,程式會在 `read` 等待,直到鍵入 ENTER 才會結束
同時我還進行了另一個實驗,目的是想要知道如果 `fork` 後 parent 和 child 都使用 `read` 來讀取輸入,會不會造成 race condition。測試的方式是執行程式後,等待一秒以上再輸入文字 (因為 parent 會先 `sleep(1)`),此時 parent 與 child 皆執行到 `read` 的位置,且 child 比 parent 先執行 `read`
```cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/select.h>
#include <sys/types.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
int fds = STDIN_FILENO;
int nfds = fds + 1;
int result = 0;
pid_t pid;
fd_set readfds;
char line[10];
memset(line, 0x0, 10);
if ((pid = fork()) == 0) { /* child runs the job */
FD_ZERO(&readfds);
FD_SET(fds, &readfds);
result = read(fds, line, 10);
line[result] = '\0';
printf("child input: %s", line);
} else { /* parent */
sleep(1);
FD_ZERO(&readfds);
FD_SET(fds, &readfds);
result = read(fds, line, 10);
line[result] = '\0';
printf("parent input: %s", line);
}
}
```
執行結果如下
```
child
child input: child
parent
parent input: parent
```
因此就算同時有兩個程式對同個 file descriptor 進行 `read`,不使用 `select` 仍不會產生混淆的狀態
根據上面的結果,我認為 `qtest` 中無需使用 `select`,單獨使用 `read` 也能達到一樣的結果
### 移除 qtest 中的 `select`
直接修改 console.c:598 將 `select` 的部分移除,測試結果發現無論是 `make test` 的分數,或是手動輸入指令測試,程式執行結果都找不到差別
```cpp=598
int result = 1; /* remove the usage of select() */
if (result <= 0)
return result;
infd = buf_stack->fd;
if (1) {
/* Commandline input available */
result--;
cmdline = readline();
if (cmdline)
interpret_cmd(cmdline);
}
return result;
```
### 使用 `select` 的情境
在 [GNU - Waiting for Input or Output](https://www.gnu.org/software/libc/manual/html_node/Waiting-for-I_002fO.html) 中有提到 `select` 可以使用的情境,這同時可以說明為何 qtest 中不須使用 `select`,因為輸入端只有一個鍵盤,如果有額外的輸入端才必須使用 `select`
> **Sometimes a program needs to accept input on multiple input channels** whenever input arrives. For example, some workstations may have devices such as a digitizing tablet, function button box, or dial box that are connected via normal asynchronous serial interfaces.
> You cannot normally use read for this purpose, because this blocks the program until input is available on one particular file descriptor; input on other channels won’t wake it up. You could set nonblocking mode and poll each file descriptor in turn, but this is very inefficient.
> A better solution is to use the select function. This blocks the program until input or output is ready on a specified set of file descriptors, or until a timer expires, whichever comes first.
## 使用 [antirez/linenoise](https://github.com/antirez/linenoise) 強化 `qtest` 命令列功能
### linenoise 使用說明
linenoise 的 API 非常簡單,閱覽 [Github 的範例](https://github.com/antirez/linenoise/blob/master/example.c)就可以非常快上手
* 主要的 API,會將 `prompt` 的內容列出在 terminal,並把處理過後的輸入以字串的型態回傳
* 回傳的字串使用過後需 `free`
```cpp
char *linenoise(const char *prompt);
```
* 多行編輯功能
* `linenoiseSetMultiLine(0)` 為單行編輯
* `linenoiseSetMultiLine(1)` 為多行編輯
```cpp
linenoiseSetMultiLine(1);
```
* 歷史紀錄功能,讓使用者可以用上下鍵使用曾經輸入過的內容
* `linenoiseHistoryAdd` 將字串紀錄到歷史紀錄中
* `linenoiseHistorySetMaxLen` 可以設置歷史紀錄最大的長度
* `linenoiseHistoryLoad` 開啟儲存歷史紀錄的檔案,儲存歷史紀錄於檔案前須先開啟該檔案
* `linenoiseHistorySave` 儲存歷史紀錄於檔案
```cpp
int linenoiseHistoryAdd(const char *line);
int linenoiseHistorySetMaxLen(int len);
int linenoiseHistoryLoad(const char *filename);
int linenoiseHistorySave(const char *filename);
```
* 命令自動補完,使用者可以按下 `<TAB>` 鍵來根據已輸入的部分自動補完命令
* 在使用前需先以 `linenoiseSetCompletionCallback(completion)` 註冊 callback funtion
* 被註冊為 callback 的函式有規定的格式,同時內容包含自動補完的判斷依據,如下
```cpp
void completion(const char *buf, linenoiseCompletions *lc) {
if (buf[0] == 'h') {
linenoiseAddCompletion(lc,"hello");
linenoiseAddCompletion(lc,"hello there");
}
}
```
### 整合並應用 linenoise
#### 將 linenoise.[ch] 納入lab0
會特別提到這點是因為 git hook 的所有設定也會套用到 linenoise,也就是說執行 `git commit` 前會先對 linenoise.[ch] 做 clang-format 和 cppcheck,前者沒什麼問題,直接 `clang-format -i linenoise.[ch]` 就可以解決,後者則會需要對原始碼進行修改,為了節省時間我選擇直接修改腳本讓 cppcheck 忽略 linenoise.[ch],scripts/pre-commit.hook 的修改內容如下
```
...
CPPCHECK_IGNORE="-ilinenoise/"
...
# static analysis
$CPPCHECK $CPPCHECK_IGNORE $CPPCHECK_OPTS >/dev/null
if [ $? -ne 0 ]; then
...
```
#### 在 qtest 中使用 linenoise
原本的 `cmd_select` 會先 `select` 確認 fd 是否可用,然後才使用 `readline` 讀取指令。如果直接改成使用 `select` + `linenoise` 會導致指令需要輸入兩次 (準確來說是第一次的輸入會被 `linenoise` 捨棄),因此若要維持 `select` 的作用並同時使用 `linenoise`,勢必要修改 `linenoise` 的原始碼。根據[前面的討論結果](#select-於本作業中的必要性),我決定當 fd 為 stdin 時,不使用 `select` 並直接 `linenoise` 來讀取輸入的指令 (雖然這樣就失去 `cmd_select` 本來名稱所代表的用途)
實作時有幾點注意事項
* 需將 linenoise 回傳的字串結尾改成 `\n\0` (因為原本的 lab0 會根據 `\n` 來判斷是否為完整的輸入,而 linenose 預設結尾不含 `\n`)
* linenoise 回傳的字串是使用 [`strdup`](http://man7.org/linux/man-pages/man3/strdup.3p.html) 產生的,記得 `free`
* 也可以不使用 `linenoiseHistorySave`,只是這樣每次執行 qtest 時不會有上次的歷史紀錄
```cpp
int cmd_select(int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout)
{
...
/* need to read command from fd */
infd = buf_stack->fd;
if (infd != STDIN_FILENO) { /* use trace files */
/* prepare necessary data for select */
if (!readfds)
readfds = &local_readset;
FD_SET(infd, readfds);
if (infd >= nfds)
nfds = infd + 1;
if (nfds == 0)
return 0;
int result = select(nfds, readfds, writefds, exceptfds, timeout);
if (result <= 0)
return result;
if (readfds && FD_ISSET(infd, readfds)) {
/* Commandline input available */
FD_CLR(infd, readfds);
result--;
cmdline = readline();
if (cmdline)
interpret_cmd(cmdline);
}
return result;
} else { /* use linenoise (stdin) */
cmdline = linenoise(prompt);
linenoiseHistoryAdd(cmdline); /* Add to the history. */
linenoiseHistorySave(
"linenoise/history.txt"); /* Save the history on disk. */
int len = strlen(cmdline);
if (len >= (RIO_BUFSIZE - 2)) /* prevent overflow */
len = RIO_BUFSIZE - 2;
memcpy(linebuf, cmdline, len + 1); /* copy content to inter buffer */
/* the original string got from linenoise is ended with \0,
* change it to \n\0. */
linebuf[len] = '\n';
linebuf[len + 1] = '\0';
free(cmdline); /* free the memory allocated by linenoise */
cmdline = len ? linebuf : NULL;
if (cmdline)
interpret_cmd(cmdline);
}
return 0;
}
```
自動補完與歷史紀錄的功能需在使用前先設定好 callback,這個部分寫在 qtest.c 中,於 `run_console` 前執行
```cpp
void completion(const char *buf, linenoiseCompletions *lc)
{
if (buf[0] == 'f') {
linenoiseAddCompletion(lc, "free");
}
if (buf[0] == 'h') {
linenoiseAddCompletion(lc, "help");
}
...
if (buf[0] == 't') {
linenoiseAddCompletion(lc, "time");
}
}
static void linenoise_init()
{
/* Set the completion callback. This will be called every time the
* user uses the <tab> key. */
linenoiseSetCompletionCallback(completion);
/* Load history from file. The history file is just a plain text file
* where entries are separated by newlines. */
linenoiseHistoryLoad(
"linenoise/history.txt"); /* Load the history at startup */
}
int main(int argc, char *argv[])
{
...
queue_init();
init_cmd();
console_init();
linenoise_init();
...
ok = ok && run_console(infile_name);
...
}
```
## 現有程式的缺陷
在繪製 `readline` 的流程圖的時候,我發現 `readline` 不像 RIO 套件有考慮到被 signal 中斷的狀況 (可參考 [RIO 套件](#RIO-套件-Robust-IO)中的 `rio_read`),這會造成一個邏輯問題
* 被 signal 中斷而**未讀取任何資料**時 (`EINTR`),**不會**自動重新執行 `read`,而會被判斷成 `EOF`
## 實作小型終端機命令解譯器 (待補)
我認為可以將此次作業的經驗用於改善以前寫的 [CS:APP shell lab](https://hackmd.io/1sOTAV6DRVm5E7UpYdBk1g),但短時間內沒辦法完成
TODO list:
* 改善 git commit 的使用方式 (也應該考量如何加入 [git-good-commit](https://github.com/tommarshall/git-good-commit))
* 改善 signal handler 的使用方式 (例如考量使用 `siglongjmp`),我覺得我原先寫的 signal handler 肯定爛透了,整體的流程也該重新考慮
* 應該更詳細理解 [Asynchronous Signal Safety](http://man7.org/linux/man-pages/man7/signal-safety.7.html) 如何達成
* 重新編輯筆記,使之具有更高的可讀性
## 參考資料 (待整理)
[flowchart.js](https://github.com/adrai/flowchart.js)
###### tags: `linux 2020` `csapp`