# 2020q1 Homework4 (kcalc) contributed by < `OscarShiang` > ###### tags: `linux2020` ## 測試環境 ```shell $ cat /etc/os-release NAME="Ubuntu" VERSION="18.04.4 LTS (Bionic Beaver)" $ cat /proc/version Linux version 5.3.0-40-generic (buildd@lcy01-amd64-024) (gcc version 7.4.0 (Ubuntu 7.4.0-1ubuntu1~18.04.1)) ``` ## 作業要求 * [ ] 找出至少 2 個 Linux 核心中使用定點數的案例,搭配程式碼解說 * [ ] `MathEx` 如何解析給定字串,從而分離出變數和對應數學表示法呢? * [ ] 在 `MathEx` 中,`nop` 一類使用者自訂的函數如何實作呢? * [ ] 是否發現 `MathEx` 對於負數的除法結果不正確、乘法和加法的 overflow 沒有做處理,且缺乏對減法的 underflow 處理機制呢?你預計如何克服? * [ ] 將 [fibdrv 作業](https://hackmd.io/@sysprog/linux2020-fibdrv)的成果透過 livepatch,整合進 kcalc * [ ] 觀察原始程式碼的 `expression.[ch]` 裡頭 `vec` 相關的程式碼,將 [quiz4](https://hackmd.io/@sysprog/linux2020-quiz4) 使用到的技巧予以應用,提出對空間效率更好的實作 * [ ] 指出原本 fixed-point 實作的限制,觀察 Linux 核心所用的 fixed-point 運算程式碼,提出針對 kcalc 的改進方案 * [ ] 原有的測試程式不會檢驗輸出數值,應予以擴充,得以自動和預先輸入的期望數值進行比對 ## Linux 核心使用定點數案例 ### 計算 Load Average 在 Linux 的核心中為了計算 load average 的數值,使用到了定點數的概念,首先看到 [`include/linux/sched.h`](https://elixir.bootlin.com/linux/v4.0/source/include/linux/sched.h#L154) 中相關的 macro : ```cpp ... extern unsigned long avenrun[]; /* Load averages */ extern void get_avenrun(unsigned long *loads, unsigned long offset, int shift); #define FSHIFT 11 /* nr of bits of precision */ #define FIXED_1 (1<<FSHIFT) /* 1.0 as fixed-point */ #define LOAD_FREQ (5*HZ+1) /* 5 sec intervals */ #define EXP_1 1884 /* 1/exp(5sec/1min) as fixed-point */ #define EXP_5 2014 /* 1/exp(5sec/5min) */ #define EXP_15 2037 /* 1/exp(5sec/15min) */ #define CALC_LOAD(load,exp,n) \ load *= exp; \ load += n*(FIXED_1-exp); \ load >>= FSHIFT; ... ``` 可以看到在計算時會使用到的精度 `FSHIFT` 與在使用 [Exponential smoothing](https://en.wikipedia.org/wiki/Exponential_smoothing) 會用到的常數 `EXP_1`, `EXP_5` 與 `EXP_15` 而在實作的部分在 [`kernel/sched/proc.c`](https://elixir.bootlin.com/linux/v4.0/source/kernel/sched/proc.c) 中可以看到在 `calc_global_load` 函式中 ```cpp void calc_global_load(unsigned long ticks) { long active, delta; if (time_before(jiffies, calc_load_update + 10)) return; /* * Fold the 'old' idle-delta to include all NO_HZ cpus. */ delta = calc_load_fold_idle(); if (delta) atomic_long_add(delta, &calc_load_tasks); active = atomic_long_read(&calc_load_tasks); active = active > 0 ? active * FIXED_1 : 0; avenrun[0] = calc_load(avenrun[0], EXP_1, active); avenrun[1] = calc_load(avenrun[1], EXP_5, active); avenrun[2] = calc_load(avenrun[2], EXP_15, active); calc_load_update += LOAD_FREQ; /* * In case we idled for multiple LOAD_FREQ intervals, catch up in bulk. */ calc_global_nohz(); } ``` 為了分別計算 1, 5, 15 秒的 load average,在實作上對 `avenrun` 三個元素進行運算,利用 `calc_load` 計算數值 而 `calc_load` 的函式如下: ```cpp /* * a1 = a0 * e + a * (1 - e) */ static unsigned long calc_load(unsigned long load, unsigned long exp, unsigned long active) { load *= exp; load += active * (FIXED_1 - exp); load += 1UL << (FSHIFT - 1); return load >> FSHIFT; } ``` 可以看到數值使用是 `unsigned long` 來實作,而為了維持定點數計算的正確性,在結束乘法運算後需要將數值向右 shift 才會是計算的結果 當 load average 被算出來之後,接著就是讓其可以正確地顯示出來。 在 `fs/proc/loadavg.c` 的實作中 ```cpp static int loadavg_proc_show(struct seq_file *m, void *v) { unsigned long avnrun[3]; get_avenrun(avnrun, FIXED_1/200, 0); seq_printf(m, "%lu.%02lu %lu.%02lu %lu.%02lu %ld/%d %d\n", LOAD_INT(avnrun[0]), LOAD_FRAC(avnrun[0]), LOAD_INT(avnrun[1]), LOAD_FRAC(avnrun[1]), LOAD_INT(avnrun[2]), LOAD_FRAC(avnrun[2]), nr_running(), nr_threads, task_active_pid_ns(current)->last_pid); return 0; } ``` `LOAD_INT` 與 `LOAD_FRAC` 的定義如下 ```cpp #define LOAD_INT(x) ((x) >> FSHIFT) #define LOAD_FRAC(x) LOAD_INT(((x) & (FIXED_1-1)) * 100) ``` `LOAD_INT` 的實作就是將小數部分捨棄,就可以直接使用。 而 `LOAD_FRAC` 的話,首先將 `x` 與 `FIXED_1-1` 進行 AND 運算,因為 `FIXED_1 = 1 << FSHIFT` 也就是 $100000000000_2$,減 1 後就變成 $011111111111_2$ 所以透過遮罩的方式就可以將 `x` 的小數部分取出。 接著將其乘以 100 再使用 `LOAD_INT` 就可以取出小數部分的前兩位了 ### time 命令的 real time 計算 在 `Linux Programmer's Manual` 的 `time(7)` 有提到 > Real time is defined as time **measured from some fixed point**, either from a standard point in the past (see the description of the Epoch and calendar time below), or from some point (e.g., the start) in the life of a process (elapsed time). ## MathEx 解析字串與變數原理 在 `test_simple.c` 中可以看到,解析字串的函式是 `expr_create`,該函式中首先利用 `expr_next_token` 尋找 token 在字串的位置。 `expr_next_token` 最主要的功能如下: 1. 遇到 `#` 標注的註解時忽略整行內容 2. 遇到 空白 或是 `\n` 時向後尋找其他內容 最後將 token 所在的 index 回傳給 `n`。 回到 `expr_create` 函式後,依據 token 的類型(數字、運算子、變數等等),分別存入不同的 vec 之中。因為 `vec` 的 `push` 與 `pop` 都是處理 vec 尾端的元素,所以可以知道在 vec 是作為 stack 使用 在遇到 `'('` 與 `'{'` 等等字元時,就會把儲存在 `os` 與 `as` 中的運算子與參數進行包裝,利用迴圈的方式重複這樣的過程,讓 `result` 在傳入 `expr_eval` 時可以用遞迴的方式將傳入的表示式算出數值。 在分析完所有的字元後,將 `es` 這個 vec 的元素 pop 到 `result` 中,並將 `result` 回傳,結束字串的解析。 ## 如何在 MathEx 中實作自定義函式 在 `MathEx/test-simple.c` 中有示範使用 `user_funcs` 陣列包裝函式並在使用 `expr_create` 時一併傳入。而 `struct expr_func` 需要的 field data 有二個: 1. 函式名稱 2. 函式指標 而在測試程式的實作如下: ```cpp static struct expr_func user_funcs[] = { { "add", add, 0 }, { NULL, NULL, 0 }, }; ``` 上述實作中,在填入完函式的名稱與指標後,第三個參數 `0` 是為了讓編譯器可以將後面的資料初始化為 0。而最後一個 field data 全部都是 0 的元素是用來標記結尾的。 在 `expr_create` 的分析中,如下列的程式碼: ```cpp struct expr *expr_create(const char *s, size_t len, struct expr_var_list *vars, struct expr_func *funcs) { ... } else { struct expr_func *f = expr_func(funcs, str.s, str.n); struct expr bound_func = expr_init(); bound_func.type = OP_FUNC; bound_func.param.func.f = f; bound_func.param.func.args = arg.args; if (f->ctxsz > 0) { void *p = calloc(1, f->ctxsz); if (!p) { goto cleanup; /* allocation failed */ } bound_func.param.func.context = p; } vec_push(&es, bound_func); } ... } ``` 就會去比對傳入的函式的列表,如果與輸入的字串吻合的話,就會將函式的指標與參數一同 compile 成 `bound_func`,讓 `expr_eval` 可以呼叫對應的函式來進行計算。 ## 使用 livepatch 整合 fibdrv 為了讓 livepatch 能夠找到對應的函式進行替換,我在 `main.c` 中加入 `user_func_fib` 作為 MathEx 的自定義函式。 ```cpp int user_func_fib(struct expr_func *f, vec_expr_t args, void *c) { int n = expr_eval(&vec_nth(&args, 0)); return kfib(n); } ``` 在使用 `user_func_fib` 分析完參數後,將參數傳入 `kfib` 函式進行運算 ```cpp noinline int kfib(int n) { return 0; } ``` 在 `livepatch_calc.c` 中新增 `livepatch_fib` 函式作為 `kfib` 的替代函式 ```cpp int livepatch_fib(int n) { if (unlikely(!n)) return 0; int a = 0, b = 1; for (int i = 32 - __builtin_clz(n); i >= 0; i--) { int t1 = a * (2 * b - a); int t2 = b * b + a * a; a = t1; b = t2; if (n & (1 << i)) { t1 = a + b; a = b; b = t1; } } return a; } ``` 掛載 `livepatch_calc` 檢查函式是否被替換: ```shell $ sudo dmesg ... [25658.424649] calc: Initializing the module [25658.424652] calc: registered correctly with major number 241 [25658.424665] calc: device class registered correctly [25658.426054] calc: device class created correctly [25689.644432] calc: Received 7 -> fib(3) [25689.644438] calc: Result: 0 [25689.644444] calc: Device successfully closed [25714.831552] livepatch: enabling patch 'livepatch_calc' [25714.845684] livepatch: 'livepatch_calc': starting patching transition [25714.846236] livepatch: 'livepatch_calc': patching complete [25721.820212] calc: Received 7 -> fib(3) [25721.820223] calc: Result: 512559680 [25721.820226] calc: Device successfully closed ``` 從 `fromfixed` 指令檢查計算結果,發現因為 `livepatch_fib` 未按照 fixed-point 的格式計算而產生錯誤 接著著手修改 `livepatch_fib` 以符合 fixed-point 的格式 ## 現行的 fixed-point 實作原理 目前的 fixed-point 所採取的作法是與 floating-point 類似的計算方式,就是將 32 bits 寬度的數值拆成三個部分: exponent 用以表示次方, mantissa 用以表示尾數以及 sign 來表示正負號 $$ {mantissa} \times 10 ^ {exponent} $$ 因為在 exponent 的表示方法是以 10 為底數,所以不需要像 IEEE 754 所規範的 32 位元浮點數一樣使用 8 個位元來表示。而在這樣的表示法中可以表示的最大數值為 `1342177270000000`,最小的數值為 `-1342177280000000` 但是在數值較大時會產生精度的問題,像是現行的表示法可以表示 `1342177270`,但卻不能表示 `1342177271`,因為 `1342177271` 已經超過 mantissa 所能表示的大小,但卻不到 exponent 可以表示的範圍,就會導致 $1342177270 + 1 = -9$ 的情況產生 ## 更改 fixed-point 結構 參考 [`include/linux/sched.h`](https://elixir.bootlin.com/linux/v4.0/source/include/linux/sched.h#L154) 中所使用的定點數,並設計以下結構: ```cpp typedef union __fixedp { struct { uint32_t frac; uint32_t inte; }; uint64_t data; } fixedp; ``` 也就是使用 32 bit 記錄 「整數」 部分,以及另外 32 bit 記錄 「小數」 部分。 除了擴充可記錄的數值範圍外,更減少在存取整數部分時的計算步驟:在 [`include/linux/sched.h`](https://elixir.bootlin.com/linux/v4.0/source/include/linux/sched.h#L154) 中所使用的結構在存取整數部分時需要做位移,但在此結構中只需取出 `inte` 的 field member 即可; 而在進行加減運算時,不需要考慮 exponent 不同的議題,直接運算即可。在進行乘除時,則可以利用 `data` 的 field member 來做位移修正。 將字串轉換為 fixed-point 的實作如下: ```cpp static uint64_t expr_parse_number(const char *s, size_t len) { fixedp num = {0}; int frac = 0; int dot = 0; unsigned int digits = 0; for (unsigned int i = 0; i < len; i++) { if (s[i] == '.' && dot == 0) { dot = i + 1; continue; } if (isdigit(s[i])) { if (dot) frac++; else digits++; } else return NAN_INT; } static int pow10[] = { 1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000, 1000000000, }; if (dot) { uint32_t ipt = 0, tmp; uint32_t mask = pow10[frac]; int i = 31; sscanf(s, "%u.%u", &tmp, &ipt); while (ipt && i) { ipt <<= 1; if (ipt >= mask) { num.frac |= 1 << i; ipt %= mask; } i--; } } if (digits) sscanf(s, "%u", &num.inte); return (digits > 0 ? num.data : NAN_INT); } ``` ### 設計特殊值的表示法 因為在計算時可能會碰到 `NaN`, `INF` 等情況發生,所以需要定義這些特殊值的表示方法: 1. `INF`: 調用 `fraction` 全為 1 的情況記錄 2. `NaN`: 最小的數值來表示 `NaN` ```cpp const uint64_t INF_INT = 9223372036854775808; const uint64_t NAN_INT = 1; ``` 將 `main.c` 中,為了符合新的 fixed-point 的實作,將 `user_func_fib` 進行修改: ```cpp uint64_t user_func_fib(struct expr_func *f, vec_expr_t args, void *c) { uint64_t n = expr_eval(&vec_nth(&args, 0)); return kfib(n >> 32) << 32; } ``` 因為在新的 fixed-point 的定義下,整數部分實際上能運算到的最大範圍還是 $2^{31} - 1$,所以在這樣的實作下 `fib` 實際能正確計算出的最大數值是 `fib(75)`。 ## 實作 fixed-point 轉換程式 因為更改了 fixed-point 的實作,所以為了能檢驗運算的結果,需要一個新的 fixed-point 轉換的程式: ```cpp #include <stdio.h> #include <stdlib.h> #include <string.h> #include "fixed-point.h" int main(int argc, char *argv[]) { if (argc < 2) return -1; fixedp ipt = {0}; ipt.data = strtoul(argv[1], NULL, 10); if (ipt.data == NAN_INT) puts("NAN_INT"); else if (ipt.data == INF_INT) puts("INF_INT"); else { double result = (int) ipt.inte + ((double) ipt.frac / UINT32_MAX); printf("%lf\n", result); } return 0; } ``` 使用定義在 `<stdint.h>` 中的 `UINT32_MAX` 將小數部分轉換為浮點數形式,並印出對應的浮點數數值。 接著修改 `scripts/test.sh` 的轉換機制,將 `fromfixed` 改成執行編譯好的 `scripts/eval`: ```shell #!/usr/bin/env bash CALC_DEV=/dev/calc CALC_MOD=calc.ko LIVEPATCH_CALC_MOD=livepatch-calc.ko EVAL=scripts/eval test_op() { local expression=$1 echo "Testing " ${expression} "..." echo -ne ${expression}'\0' > $CALC_DEV $EVAL $(cat $CALC_DEV) } ... ``` 執行 `make check` 檢查結果: ```shell $ make check ... Testing x=5, x = x+1 ... 6.000000 Testing six=6, seven=7, six*seven ... 42.000000 Testing 小熊=6, 維尼=7, 小熊*維尼 ... 42.000000 Testing τ=1.618, 3*τ ... 4.854000 Testing $(τ, 1.618), 3*τ() ... 4.854000 Testing $(zero), zero() ... 0.000000 Testing $(one, 1), one()+one(1)+one(1, 2, 4) ... 3.000000 Testing $(number, 1), $(number, 2+3), number() ... 5.000000 Testing nop() ... 0.000000 livepatch was applied Testing nop() ... 0.000000 [99614.133466] calc: Received 6 -> nop() [99614.133476] livepatch_calc: function nop is now patched [99614.133477] calc: Result: 0 [99614.133481] calc: Device successfully closed [99614.139096] calc: size: 2 result: 0 [99614.139134] calc: Device successfully closed Disabling livepatch... Complete ``` 從上面的結果可以看到完成 fixed-point 的轉換。 ## 參考資料 1. [Exponential smoothing](https://en.wikipedia.org/wiki/Exponential_smoothing) 2. [kernel/sched/proc.c](https://elixir.bootlin.com/linux/v4.0/source/kernel/sched/proc.c) 3. [include/linux/sched.h](https://elixir.bootlin.com/linux/v4.0/source/include/linux/sched.h#L154) 4. [IEEE 754 - Wikipedia](https://en.wikipedia.org/wiki/IEEE_754)