contributed by < Kuanch >
參閱 "The Linux Kernel Module Programming Guide",一個核心模組 (kernel module) 最重要的特色是能夠被動態載入、在不重新開機的狀況下擴展核心功能:
A Linux kernel module is precisely defined as a code segment capable of dynamic loading and unloading within the kernel as needed. These modules enhance kernel capabilities without necessitating a system reboot.
更完備的理解是,在不影響當前核心運作的狀況下掛載新的模組、動態擴充核心;另外,如果每一次功能的新增都需要被加入到 kernel image,不可避免的會導致擁腫的核心、增加重新編譯核心以及重新佈署系統的成本。
另外,模組是運行在 kernel space 中的,故它具有調動 kernel mode 下各項功能的權力。
在我們分析 ksort 及 simrupt 之前,我認為有必要透過編寫一個最簡單的模組了解撰寫模組的必要部分,考慮以下程式碼:
並考慮以下 Makefile
經過編譯後得到 hello_mod.ko
,可以透過 modinfo hello_mod.ko
得知其模組資訊,並透過 sudo insmod hello-1.ko
載入模組,在此之後 dmesg
可以看到訊息
若以 sudo rmmod hello_mod.ko
移除該模組後則會得到
ksort 是一個 character device,可理解是個能夠循序存取檔案,或說是可存取的 "stream of bytes",像是檔案一樣,故我們至少需要定義它的 open
, close
, read
, write
, ioctl
, mmap
等 system calls,這些呼叫會透過 OS 轉送到裝置驅動:
Text Console (/dev/console) and the Serial Ports (/dev/ttyS0),都是 Streaming 結構。
與之相對的是 block device 和 network device,此處我們僅先討論前者;與 stream 的概念不同,block device 是 chunks(blocks),更適合用在存取大型資料的時候:
Usage: Character devices are often used for devices that need to communicate small amounts of data with low latency, while block devices are used for storing files where the capacity and speed of reading/writing large blocks of data are paramount.
/dev/sda
for the first SCSI disk 和 /dev/nvme0n1
for NVMe drives 是常見的 block device;此外通常並沒有 read()
, write()
,而是透過 block I/O layer 管理存取請求。
由 Hello World Module 一節可知,我們至少需要定義 init_module()
和 cleanup_module()
,或者如 ksort 使用 module_init()
,其為一定義於 linux/module.h
之巨集,這種能夠客制函式名的方式似乎也是較為常見的;此外,由於 ksort 為 character device,一些存取的 system calls 也必不可少,我們從最基礎的 sort_read()
開始理解。
我們知道透過需要提供 Linux Virtual File System 介面給後續使用,當呼叫 read()
的時候就會對應到 sort_read()
,我們嘗試簡單修改 user.c
:
現在我們可以自行輸入排序,且也確定,該 ssize_t r_sz = read(fd, inbuf, size);
即呼叫 sort_read()
;注意,此為系統呼叫而非單純的使用者層級的函式呼叫,所以在某個時候,這個行程事實上轉換為 kernel mode,且要求 OS 協助存取某一個檔案或 I/O 裝置。
上圖幾乎詮釋了 read()
之後的流程,由於 sort_read()
明顯是一 blocking operation,因為排序的 sort_main()
需要時間,故呼叫的 user thread 很可能會 sleep,由另一個 thread 執行。
Kuanch
使用
printk(KERN_INFO "sort_read() called by process %d\n", current->pid);
判斷執行的 PID,雖然 PID 前後皆一致,但是否 sleep 卻無法判斷;嘗試透過
讀取 process state,但測試失敗,問題應該在如何正確讀取
/proc/.../stat
。
後來思考這個做法較困難,若是要觀察該行程是否曾經休眠,應該有其他方法,如計時。
在 lab 介紹了 ktime 的使用方法,透過在 sort_read()
頭尾插入 kt = ktime_get();
及 kt = ktime_sub(ktime_get(), kt);
,再修改 sort_write()
,我們即可透過在 user.c
呼叫 long long time = write(fd, inbuf, size);
得到核心模組的執行時間。
如同 lab 所說,此 Timer 機制用於 1. 安排「在某個時間點做某件事情」 或 2. 用來作為逾時的通知,但我們此處僅止於計算 time elapsed;那我們就使用 ktime 試試看是否能夠得到足以推論行程是否有休眠的證據。
sort_read | sort_main | copy_from_user | copy_to_user |
---|---|---|---|
16174 (ns) | 15916 (ns) | 126 (ns) | 73 (ns) |
在去除掉 void *sort_buffer = kmalloc(size, GFP_KERNEL);
及 kfree(sort_buffer);
等操作後發現,其餘 overhead 十分的少,僅有大約 60 ns,考慮到 ktime 本身和呼叫 sort_write()
等開銷,我們幾乎可以篤定該行程並未休眠。 (但仍覺得不是好方法,若能直接觀測 task_struct 本身狀態是很有幫助的,需要再研究)
首先先釐清 simrupt 的呼叫邏輯,大致同 simrupt 流程圖,但 timer_setup(&timer, timer_handler, 0);
後仍需要透過 mod_timer()
或 add_timer()
設定 timer
過期時間,才能夠觸發 timer_hanlder()
;事實上,simrupt_open()
先被呼叫,觸發了 mod_timer(&timer, jiffies + msecs_to_jiffies(delay));
,才有後續的 timer_handler()
運作。
當我們執行 cat /dev/simrupt
時,便觸發 .read
也就是 simrupt_read()
,而當我們 ctrl + C 退出 cat
時,simrupt_release()
被呼叫。
雖然我們已知清晰的呼叫邏輯,但仍未理解為什麼如此實作;我們需要將各個部件分開說明。
process_data()
與 simrupt_tasklet_func()
實際上的工作單位 work
是 simrupt_work_func()
,也就是實際被執行的部分,simrupt_tasklet
即 simrupt_tasklet_func()
作為管理並運作 work
的函式,也是實際被 tasklet_schedule()
排程的單位;可以注意到 simrupt_tasklet_func()
的主體事實上是:
這是 Linux 核心中 interrupt handle 的實作,又可明顯地分出 top half 及 bottom half 或稱 First-Level Interrupt Handler (FLIH) 及 the Second-Level Interrupt Handlers (SLIH),稱作 Divided handler。
interrupt 通常與硬體相關,相較軟體,硬體的速度極快 (by cycles),故與硬體互動時,效率十分重要;將 interrupt handle 分成兩部分的作用在於降低回應延遲、避免 nested interrpt 以及享受排程機制帶來的效率與便利性,根據 "Linux Device Drivers, Chapter 10. Interrupt Handling":
One of the main problems with interrupt handling is how to perform lengthy tasks within a handler. Often a substantial amount of workmust be done in response to a device interrupt, but interrupt handlers need to finish up quickly and not keep interrupts blocked for long. These two needs (work and speed) conflict with each other, leaving the driver writer in a bit of a bind.
Linux (along with many other systems) resolves this problem by splitting the interrupt handler into two halves. The so-called top half is the routine that actually responds to the interrupt—the one you register with request_irq. The bottom half is a routine that is scheduled by the top half to be executed later, at a safer time.
一般而言應包含
特別注意的事情是,此處的 context saving 並非我們在兩個行程交換時所進行的 context switch,因為要快速回應中斷;我猜測由於中斷無法被預期,且通常是被不同的行程執行,如果進行完整的 context switch 將會大幅影響 top half 的效率,故僅儲存必要的 register 等資訊。
Kuanch (minimal) context saving 並不是一個正式的詞彙,但稱 "context switch" 可能會和 scheduling 中的混淆,暫時找不到相關資料稱呼該過程。
top half (process_data)
需要快速的回應發起中斷的硬體,而且必然伴隨 disable interrupt 避免 nested interrupt;依據這一條件,process_data()
顯然是 top half:
可以見到前後透過 local_irq_disable()
和 local_irq_enable()
避免 nested interrupt,且 process_data()
內亦透過 tasklet_schedule(&simrupt_tasklet);
排程實際任務;且 fast_buf_put(update_simrupt_data());
亦模擬了從發起中斷的硬體取得資料的動作,如從網卡取得隨著時間不斷增加的封包編號(Packet Identifier)。
bottom half (simrupt_tasklet_func)
被排程的 simrupt_tasklet
就可認為是 bottom half 了,故我們也可觀察到,simrupt_work_func
是發生在其他 CPU 上的,即是因為 bottom half 是在排程在 simrupt_workqueue
之後被 kernel thread 取出執行的。
workqueue_struct
, work_struct
, struct worker
and task_struct
我們在其他排程器的教材中,理解每一個行程(線程)事實上就是一個 task_struct
,而排程器透過排程其成員 sched_entity
管理排程行為;而無論從 workqueue_struct
或 work_struct
,我們並沒有看到與排程直接相關的成員或屬性,那麼它們是怎麼被排程的呢?
首先我們可以在 kernel/workqueue_internal.h
找到 struct worker
,其中帶有我們熟悉的 task_struct
:
此處有趣的地方是註解紀載了每個成員的 locking annotation,也就是與鎖的關係。
我們藉由觀察定義在 kernel/workqueue.c
的 create_worker()
了解如何創建一個 woker thread:
上述函式可以看到
worker = alloc_worker(pool->node);
worker->task = kthread_create_on_node(worker_thread, worker, pool->node, "kworker/%s", id_buf);
set_user_nice(worker->task, pool->attrs->nice);
kthread_bind_mask(worker->task, pool_allowed_cpus(pool));
kick_pool(pool);
和 wake_up_process(worker->task);
raw_spin_unlock_irq(&pool->lock);
另外,在作業說明中提到:
實作上,只要該 CPU 上有一個或多個 runnable worker thread,worker-pools 就暫時不會執行新的 work。一直到最後一個正在運行的 worker thread 進入睡眠狀態時,才立即排程一個新的 worker thread,這樣 CPU 就不會在仍有尚未處理的 work item 時無所事事,但也不至於過度的建立大量 worker thread。
我們可觀察到檢查是否需要喚醒 worker thread 的一系列操作:
並可以觀察到 need_to_create_worker()
常在創建新的 worker thread 前被檢查,如 maybe_create_worker()
,而 pool_mayday_timeout()
用於當存在 work
,但卻沒有 idle worker 的狀況,透過 send_mayday()
緊急喚醒 worker thread。
追蹤 worker_enter_idle()
亦可發現符合作業描述的程式碼,此處暫不展開討論。
rx_fifo
與 rx_wait
另一個部件是 rx_fifo
與 rx_wait
,事實上它涉及了數個保護和同步機制:
read_lock
確保 simrupt_read()
是 thread-safeconsumer_lock
producer_lock
負責確保由裝置讀取、存入 rx_fifo
這兩個動作是 thread-safe除此之外,wait_event_interruptible()
和 wake_up_interruptible()
是重點操作:
當 kfifo_len(&rx_fifo)
== 0 時,任務被不斷加入到 rx_wait
,並進入等待;追蹤程式碼後會見到定義在 kernel/sched/wait.c
的 prepare_to_wait_event()
,看起來是一 spin waiting 機制。
而當 kfifo_len(&rx_fifo)
> 0,除了該行程直接傳回值使 ret == 0
成立外,其餘所有在 rx_wait
的行程因為 wake_up_interruptible(&rx_wait);
被呼叫而重新成為 runnable。
Kuanch 此處十分不直觀,嘗試是否能夠在 Use QEMU + remote gdb 觀察到狀態的改變
以下程式碼可接續追尋到 kernel/sched/wait.c
的 prepare_to_wait_event()
strace
to trace the callback and signalstrace
是一種診斷工具,它可以追蹤程式運行中的系統呼叫,有助於我們了解一部分 Linux 模組在核心中的運作;透過 strace -f cat /dev/simrupt
透過對照 LINUX SYSTEM CALL TABLE FOR X86 64,可以很好的理解每個呼叫以及其參數的意義,輕易地可以將上述訊息與 simrupt.c
的行為對應:
execve
執行 cat /dev/simrupt
之呼叫brk(NULL)
動態分配記憶體。記得我們曾在 Demystifying the Linux CPU Scheduler 閱讀筆記 2024 討論過 "Where is heap?" 的 mm_struct.brk
,brk(NULL)
用於查詢目前 heap 的位置。mmap(NULL, 82854, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7fb162844000
上述指令用於讀取檔案的一部分進入記憶體,82854
表示需要配置的記憶體空間 PROT_READ
表示僅讀不寫,回傳值為此新配置的空間的起點。請參考 Kernel Analysis with QEMU + remote GDB + Buildroot 設定;但我們需要針對 kernel_module
的部分設定,將其改為 simrupt
:
strace
比較strace
屬於靜態分析,也就是
modpost: ... undefined!
Solution1: License problem
作如下更改
見 linD026 ERROR: modpost: "kallsyms_lookup_name" [hideproc.ko] undefined!
Solution2: Linux Kernel Compiling Config
make sure you
a. disable CONFIG_TRIM_UNUSED_KSYMS
b. enable CONFIG_MODULES
Driver (驅動) [Linux Kernel慢慢學]Linux modules載入及載入順序 Booting a Custom Linux Kernel in QEMU and Debugging It With GDB 用 gdb debug 在 QEMU 上跑的 Linux Kernel LINUX SYSTEM CALL TABLE FOR X86 64