contributed by < SmallHanley
>
1
解釋上述程式碼運作原理
延伸第 3 週測驗題的測驗 7
,已知輸入必為大於 0
的數值 (即 x > 0
),以下程式碼可計算 ,也就是 ceil 和 log2 的組合並轉型為整數:
一開始的 x--
和最後 return 時的 +1
,是用來計算 cell 的。若將上述兩者去掉,則整段程式可以計算 (即 floor 和 log2 的組合並轉型為整數)。剩餘的程式則是用 binary search,每次比較一半的位元數 (假設為 n,即與 比較),若比較大,則將 r 的第 () bit set。
改進程式碼,使其得以處理 x = 0
的狀況,並仍是 branchless
一種方法是在編譯時期就找出使用 constant 0 傳入,定義以下巨集 (參考 bit-field):
另一種方式是雖然數學上 , 需要傳入大於 0 的數,我們另外定義當函式傳入 0 時,回傳 0。觀察原程式傳入 0 的回傳值是 1,因此我們只要在回傳時改成加 !!x
,便可以處理參數為 0 的特例。
2
解釋上述程式碼運作原理,並迴避 while, for, goto 等關鍵字以改寫出功能等價的實作
複習〈你所不知道的 C 語言: bitwise 操作〉,改寫第 3 週測驗題的測驗 11
裡頭的 fls 函式 (fls 意謂 “find last set”),使其得以計算 Find first set:
上述程式碼是尋找一個 unsigned long 的資料中,第一個 set 的 bit 的位址 (由 LSB 開始從 0 開始算)。t
的作用是 bitmask,一開始從 16 個 bits 開始 (之後是依序為 8、4、2、1),每次檢查右半邊是否全為 0,類似於 linux2022-quiz4#測驗-1 的作法,因此,我們可 loop unrolling,迴避 while, for, goto 等操作:
或是使用 gcc built in function __builtin_ctzl
:
在 Linux 核心原始程式碼的 include/asm-generic/bitops 目錄找出相關程式碼,並探討應用案例
在 include/asm-generic/bitops 有 4 個相關的實作,分別為 ffs.h
, builtin-ffs.h
, __ffs.h
, builtin-__ffs.h
,含有 builtin
前綴的代表使用 gcc built in function。另外,沒有底線的是 32 bits 的實作,含有底線的是有考量 unsigned long 在不同平台的資料寬度,可能為 32 或是 64 bits。
在 linux2022-quiz2#測驗-4 中有介紹到 bitmap,Linux 核心原始程式碼就有許多地方用到 bitmap,像是 RT 排程器就是使用 bitmap 來表示哪一個 priority 的 queue 非空,相關程式在 kernel/sched/sched.h:
我們可以用 ffs 找到最高 priority 且非空的 queue,如下:
3
4
解釋上述程式碼運作原理,舉出上述 coroutine 實作的限制 (注意 stack)
struct task
有兩個 member,分別為用於 setjmp()
的 jmp_buf
env
以及用於排程的 list
。main
函式中,registered_task
是 function pointer 陣列,其中 function 的傳入參數是 generic type void pointer。在 schedule
函式中,利用 ASLR 設定亂數種子。接著呼叫 setjmp(sched)
,每當新的任務加進排程,會呼叫 longjmp(sched, 1)
,讓 schedule()
可以繼續將新任務加進排程。
而 task0
和 task1
的結構一樣,有三大部份,第一部份根據 schedule()
設定的參數決定迴圈次數,將 task 加入排程後呼叫 longjmp(sched, 1)
,讓 schedule()
可以繼續將新任務加進排程。當所有任務被加入排程後,schedule()
會呼叫 task_join(&tasklist)
,其中,會根據 list 的 first entry longjmp
回該 task 的 setjmp
位置:
第二部份是兩個 task 交互排程,每次執行一個 loop 後,呼叫 task_add()
重新將 task 加入 list 的尾端,並呼叫 task_switch
指定 list 的 first task 執行:
第三部份完成該 task,會呼叫 longjmp(sched, 1)
跳到 schedule()
,接著會執行 task_join(&tasklist)
執行尚未執行完的 task:
我根據老師的提示將 fibdrv 的 fib_sequence()
引入,讓兩個 task 交錯執行,一個執行奇數項,一個執行偶數項,並在 schedule()
中指定 task0
和 task1
的參數為 90,改完後 task 的第二部份實作如下:
預期上述改寫可以讓 task0
和 task1
交錯呼叫 fib_sequence()
,並且輸出 0 ~ 89 的結果,實際結果如下:
可以發現執行結果和預期不同,第 2, 4, 6, … 項結果被跳過,使用 GDB 追蹤發生錯誤的地方:
其中上面 GDB 輸出結果片段的第 1 行為 task0
的第 2 次 for 迴圈,可以發現 longjmp 回來後 i
的值是 1,且位址為 0x7fffffffdca4
,恰好與 task1
的 i
位址相同。根據 setjmp(3) :
The setjmp() function saves various information about the calling environment (typically, the stack pointer, the instruction pointer, possibly the values of other registers and the signal mask) in the buffer env for later use by longjmp().
這裡 task0
的 i
存取到的剛好是之前 task1
的 i
的位址,如果將 task0()
動一點手腳,改成:
可以發現行為完全不一樣,GDB 片段如下:
此時 task1
的 i
存取到的是上一次 task0
的 r
,而 task0
的 i
也是存取到 task1
上一次 function stack 中的某一位址。我們可以這樣理解,task0
呼叫 task_switch
的 longjmp()
後,該 stack 被 pop 掉,但是原本存放在該 stack 空間的值沒有被更改到。longjmp
到 task1
後,只有當初 setjmp
保存的 sp
、pc
和某一些暫存器以及 signal mask 被 restored,其餘 local variable 存取為未定義行為。
不過這裡我有地方沒搞懂,man page 中提到 possibly the values of other registers
會包含哪些暫存器,不確定怎麼做可以保證特定暫存器在呼叫 setjmp()
時也被保存起來。
既然 local variable 會產生以上結果,我們可以將 i
改成 static data,使得 i
不是存在 stack 上,longjmp
後得以保存原來的值。
還有一種方式是將必要的資料保存在 struct task
中 (類似於 task control block)。缺點是這個 struct tack
要不宣告成 global,要不就宣告成 static data。因此,每個 task 都需要一個對應的函式,函式設計上沒辦法設計成可重入 (Reentrancy)。
另一個嘗試同上,也是將必要的資料保存在 struct task
,不過利用 setjmp
的回傳值和 longjmp
的參數傳遞 struct task
指標指向的位址 (此方法只限於用 32 位元來編譯,因為上述兩函式的參數傳遞 type 是 int):
但是此方法沒有成功,會在呼叫 setjmp()
時出現 segfault,似乎會跳到奇怪的地方,尚未解決:
還有一種方法 (參考 FreeRTOS 的 pxCurrentTCB
),將 cur_task
宣告在全域,每當重新決定新的要執行的 task 時,就更新指標指向的內容,longjmp 回去後將內容寫回 stack。以下為參考程式,有兩個 task 函式,分別執行 fib_sequence() 和輸出 0 ~ 99。並且 create 三個 task,前兩個 task 分別輸出費氏數列的偶數項和奇數項,第三個 task 負責執行 task1()
。
對比 concurrent-programs 裡頭的 coroutine 程式碼,如 tinync 和 preempt_sched,嘗試引入 UNIX Signal 來做到 preemptive scheduling
研讀 preempt_sched 的實作方式,先嘗試將上述程式碼改為 signal trigger (由 caller raise SIGSEGV
signal) 的方式,方便使用 GDB 追蹤程式。
不過使用 GDB 追蹤程式會遇到一個問題,就是沒辦法單步執行到 task 所在的 context,以下擷取 GDB 片段,當執行到 main 函式的 raise(SIGSEGV)
時,會跳至之前註冊的 signal handler (即 timer_handler()
):
繼續執行,會發現 info frame 是由以下四個 frame 輪流出現 (0x7fffffffd580, 0x7ffff7dbd7c0, 0x7ffff7cbc7c0, 0x7ffff7bbb7c0)。且根據 Frame-Info 的描述,info frame 命令缺少 caller of this frame
的資訊 (這是 callback function,caller 不在此程式中,GDB 沒有相關資訊)。至於為什是四個 frame,推測是 main 函式加上三個 task 的 signal handler 部份。上述 GDB 片段屬於 main 函式的,若是其餘三個 task 的 backtrace 結果如下:
如預期的,GDB 的輸出結果會對應到三個不同的 task,概念上是 main 函式和三個 task 在執行時,觸發 signal,跳到 signal handler,而 handler 會呼叫 schedule()
將當前 context 保存並切換至下個欲執行的 context,形成四個不同的 context 輪流切換。
這裡不清楚 GDB 有無辦法 step into 或是 step over 到 task 函式內部,每次呼叫 task_switch_to()
之後都會直接跳到下一個 task 接收到 signal 之後:
若要將程式碼改成 preemptive scheduling,需要借助 timer。參考 alarm(2) 以及 ualarm(3),使得 timer 在指定時間間隔以後送出 SIGALRM
signal。若沒有進行訊號處理,預設是會使得程式中止,我們需要將上述程式碼 SIGSEGV
改為 SIGALRM
,做對應的訊號處理。
preempt_sched 實作缺陷
對比〈A brief history of the Linux Kernel’s process scheduler: The very first scheduler, v0.01〉,解說早期 Linux 核心 CPU 排程器的行為,並利用上述程式碼來模擬
在 linux 核心早期 (v0.01~v2.4.x) 在這篇文章中稱作 "The very first scheduler",只使用了 20 行左右的程式碼。在 v0.01 中,所有 task 用一個陣列表示,同時作為 run queue (這裡 run queue 應該是指陣列中所有可執行的 task 的集合) 使用,陣列長度為 64 (即最大容許的 task 數量為 64)。若陣列的某個 entry 為空,則表示成 NULL。
排程器的 time slice 為 150 ms,而 interval timer 每 10 ms 會發出一次中斷,對應的 handler function 會減少目前執行的 task Current
的 time slice,當 time slice 歸零時,排程器會從 run queue 中找出下一個 task。
在後續的版本,time slice 和 timer 的 interval 都有做一些調整,這篇文章不會深入討論。
以下是 Linux v0.01 的排程器策略:
假設某一時間陣列的狀態如下,time slice 的單位是 10 ms:
排程器從 run queue 中以倒序找出剩餘 time slice 最大的 task,挑選出 t1
:
t1
一直執行直到 time slice 用完:
同理,排程器從 run queue 中以倒序找出剩餘 time slice 最大的 task。如果 run queue 中有多個 tasks 同時擁有最大的剩餘 time slice,則選取第一個找到的 task,因此挑選出 t2
:
最後,所有可執行的 task 都用盡自己的 time slice:
則 reset 所有 task 的 time slice,runnable 的 task 增加 150 ms 的 time slice,sleep 的 task 增加 150 ms 加上原本剩餘 time slice 一半的 time slice (t4
為 sleep,reset 後的 time slice 為 150 + 120 / 2 = 210):
相關程式碼可以在 v0.01/include/linux/sched.h 以及 v0.01/kernel/sched.c 找到:
task
是一個指向 struct task_struct
的指標的陣列,大小為 64,FIRST_TASK
和 LAST_TASK
巨集分別代表陣列的頭和尾。
程式碼第 17 ~ 25 行將所有 interruptible 並且有收到 signal 的 task 喚醒,從 waiting 狀態變成 running 的狀態 (注意,這裡的 running 狀態不代表執行的狀態,而是 runnable 的狀態,即 ready to run)。
這裡不清楚 19 行的 jiffies
在這裡的用途 ((*p)->alarm < jiffies
)。jiffies
直覺想到是跟 timer 有關,如果 grep 原始程式碼,會發現 jiffies
的修改是在 kernel/system_call.s:158。該檔案除了 system-call low-level handling routines,也包括 timer-interrupt handler。其中有使用到 incl _jiffies
指令,用來計算 timer interrupt 發生的次數。
程式碼第 27 行以後是排程器本身。程式碼第 32 ~ 37 對應到上面排程器策略的第 1 點,以倒序找出 run queue 中剩餘 time slice 最大的 task,並用 next
代表該 task 的編號 (慣用 nr)。如果所有 runnable 的 task 的 time slice 用光,會執行第 40 ~ 42 行的程式,對應到排程器策略的第 2 點。這裡是增加 (*p)->priority
的 time slice,似乎不是固定 150 ms time slice,而是可以做調整,參考以下程式碼:
參考 v0.01/include/linux/sched.h 以及 v0.01/kernel/sched.c