# Linux 核心專題: 重作 kxo
> 執行人: mincch
> [解說影片](https://youtu.be/JZnuofbJINE?si=SmNTC1LKuuLJw6qU)
### Reviewed by `Mike1117`
- 很多程式碼區塊未註記程式語言,導致可讀性下降。
- >接這去記錄每一步移動 record_move 當有新的棋盤狀態傳輸過來時,我們需要檢查舊狀態和新狀態的差異,可以判斷出在哪個格子進行了移動,並將其記錄下來。
- 「接著」
- 選擇在 user space 比對新舊棋盤推斷下棋的位置,而不是由 kernel space 傳送的理由是什麼?
### Reviewed by `alexc-313`
> 但是修改後的 code 雖然減少了每次傳輸的成本,但卻增加了大量的呼叫次數
這可能是因為修改後的程式碼多字呼叫 `smp_wmb()` ,是否能夠在保存並行性的前提下減少呼叫次數? 處理完一行再呼叫 `smp_wmb()` 是否可行? 另外,建議使用 c 註記程式語言,而非 clike 。
## 任務描述
以[第三份作業](https://hackmd.io/@sysprog/linux2025-kxo)為基礎、搭配課堂討論和觀摩學員的成果,重新投入 kxo 開發。過程中可參照其他學員的成果 (但要實驗和指出其缺失),務必清楚標示。
## TODO: 重作 kxo
## TODO: 觀摩其他學員的成果
> 紀錄你的啟發、你進行的驗證,以及後續改進
## 開發環境
```shell
$ uname -rs
Linux 6.11.0-28-generic
$ gcc --version
gcc (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0
$ lscpu
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Address sizes: 39 bits physical, 48 bits virtual
Byte Order: Little Endian
CPU(s): 4
On-line CPU(s) list: 0-3
Vendor ID: GenuineIntel
Model name: Intel(R) Core(TM) i5-4200U CPU @ 1.60GHz
CPU family: 6
Model: 69
Thread(s) per core: 2
Core(s) per socket: 2
Socket(s): 1
Stepping: 1
CPU(s) scaling MHz: 80%
CPU max MHz: 2600.0000
CPU min MHz: 800.0000
BogoMIPS: 4589.34
Virtualization features:
Virtualization: VT-x
Caches (sum of all):
L1d: 64 KiB (2 instances)
L1i: 64 KiB (2 instances)
L2: 512 KiB (2 instances)
L3: 3 MiB (1 instance)
NUMA:
NUMA node(s): 1
NUMA node0 CPU(s): 0-3
```
## 處理 ctrl+Q 無法使用
自 GitHub kxo 進行 fork 專案後,閱讀 README 後發現 Ctrl + Q: Terminate all tic-tac-toe games running in kernel space,但是當我實際在操作時卻沒有反應,接著我去檢查了 `xo-user.c` 看到了以下這段 code
```clike
static void raw_mode_enable(void)
{
tcgetattr(STDIN_FILENO, &orig_termios);
atexit(raw_mode_disable);
struct termios raw = orig_termios;
raw.c_lflag &= ~(ECHO | ICANON);
tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
}
```
`c_lflag` : 主要是在影響字元的處理方式,處理使用者互動體驗,例如:打字的時候,螢幕上是否立刻顯示出來。
`ECHO` :控制輸入字符是否顯示出來。當設為 true 時,輸入的字元會立即顯示。當清除時 `raw.c_lflag &= ~ECHO` ,輸入的字元不會顯示,這常用於密碼輸入或遊戲等需要隱藏輸入的場景。
`ICANON`:當設為 true 時,不會直接送出,直到接收到換行符 `\n` 或 EOF ,再將整個傳遞給程式。所以這代表在按下 Enter 鍵之前,可以編輯或修改輸入的內容。
`c_iflag` : 處理鍵盤輸入的原始資料,例如:終端機是不是要處理 Ctrl+S 和 Ctrl+Q 這種特殊輸入
`IXON` :用來攔截 Ctrl+S / Ctrl+Q
`ICANON`:將 CR(0x0D) 轉成 LF(0x0A),若設為 false 可保留 `\r`
```diff
static void raw_mode_enable(void)
{
tcgetattr(STDIN_FILENO, &orig_termios);
atexit(raw_mode_disable);
struct termios raw = orig_termios;
raw.c_lflag &= ~(ECHO | ICANON);
+ raw.c_iflag &= ~(IXON | ICRNL);
tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
}
```
這邊將 `IXON` 與 `ICRNL` 設為 false,可以使我們鍵盤的輸入 Ctrl+Q 不會再被攔截導致沒有反應,這樣只要按下 Ctrl+Q 就可以正常終止遊戲。
## 使畫面呈現的部分在使用者層級
在將畫面呈現在使用者層級實作前我先研讀了 kxo 的原始設計中,遊戲盤面的繪製是透過模組中的 draw_board 完成的。這個函式使用draw_buffer,把 ASCII 畫面字元放到 kfifo;使用者程式只負責把字串整段讀出來再 printf()。這做法雖然直觀,但每次畫面更新都要在核心與 user space 之間處理會有額外更多的開銷。在最後印出 `DRAWBUFFER_SIZE`,可以發現為 `DRAWBUFFER_SIZE = 66` ,而修改之後只需要傳送 board 自己的大小 size
運用 bpftrace 測量:
```
write_bytes : 1058 B
read_bytes : 5984 B
write_calls : 15
read_calls : 18
ioctl_calls : 4
@ioctl_calls: 4
@rd_bytes: 5984
@rd_calls: 18
@wr_bytes: 1058
@wr_calls: 15
```
1058B / 15 = 70 byte per call
```
write_bytes : 3506722 B
read_bytes : 714248 B
write_calls : 350764
read_calls : 175286
ioctl_calls : 4
@ioctl_calls: 4
@rd_bytes: 714248
@rd_calls: 175286
@wr_bytes: 3506722
@wr_calls: 350764
```
3506722B / 350764 = 9.9 byte per call
用同樣30秒的時間讓他 timeout ,但是修改後的 code 雖然減少了每次傳輸的成本,但卻增加了大量的呼叫次數
```c
static int draw_board(char *table)
{
int i = 0, k = 0;
draw_buffer[i++] = '\n';
smp_wmb();
draw_buffer[i++] = '\n';
smp_wmb();
while (i < DRAWBUFFER_SIZE) {
for (int j = 0; j < (BOARD_SIZE << 1) - 1 && k < N_GRIDS; j++) {
draw_buffer[i++] = j & 1 ? '|' : table[k++];
smp_wmb();
}
draw_buffer[i++] = '\n';
smp_wmb();
for (int j = 0; j < (BOARD_SIZE << 1) - 1; j++) {
draw_buffer[i++] = '-';
smp_wmb();
}
draw_buffer[i++] = '\n';
smp_wmb();
}
return 0;
}
```
這段函式負責根據遊戲介面 (char *table) 的狀態,把它變成一個 draw_buffer。將井字遊戲盤面的文字表示形式 ( 'X', 'O', ' ', '|', '-') 放到一個記憶體緩衝區中。
`draw_buffer` : 用於暫存要輸出的盤面字串 ( static char draw_buffer[DRAWBUFFER_SIZE] )。
`table` : 傳入的參數,代表了當前遊戲的介面狀態,通常是一個包含 'X', 'O', ' ' 等字元的陣列。
根據 table 的內容,將井字遊戲盤面的視覺化表示(包含格子內容、分隔符號 | 和 -、以及換行符號 \n)寫入 draw_buffer 中。
`smp_wmb()` : 在多核心處理器系統中,處理器可能會為了效能而重新排序記憶體寫入操作。`smp_wmb()` 確保在此之前的所有寫入操作(即對 draw_buffer 的寫入)都已完成並對其他處理器可見,這對於並行操作和資料一致性非常重要。
```c
static void produce_board(void)
{
unsigned int len = kfifo_in(&rx_fifo, draw_buffer, sizeof(draw_buffer));
if (unlikely(len < sizeof(draw_buffer)))
pr_warn_ratelimited("%s: %zu bytes dropped\n", __func__,
sizeof(draw_buffer) - len);
pr_debug("kxo: %s: in %u/%u bytes\n", __func__, len, kfifo_len(&rx_fifo));
}
```
這段函式負責將 draw_board 準備好的 draw_buffer 中的資料,透過 KFIFO (Kernel FIFO) 機制寫入 rx_fifo。
`kfifo_in(&rx_fifo, draw_buffer, sizeof(draw_buffer))` : 將資料寫入 KFIFO 。
`draw_buffer` : 包含要寫入資料的源緩衝區 (也就是 draw_board 準備好的盤面字串)。
`if (unlikely(len < sizeof(draw_buffer)))` 這是一個錯誤檢查。如果實際寫入的資料量少於預期,表示 KFIFO 可能滿了,會導致部分資料被丟棄。
將 produce_board 以及 draw_board 移除,並在 `xo-user.c` 加入
```c
static void draw_board(uint32_t bits)
{
printf("\x1b[H");
for (int r = 0; r < 4; ++r) {
printf("|");
for (int c = 0; c < 4; ++c) {
int idx = r * 4 + c;
uint32_t v = (bits >> (idx * 2)) & 3;
char ch = (v == 1) ? 'X' : (v == 2) ? 'O' : ' ';
printf(" %c |", ch);
}
printf("\n-----------------\n");
}
fflush(stdout);
}
```
這段函式負責從 KFIFO 讀取到的壓縮盤面資料 (bits) ,然後將他輸出在終端機上繪製出遊戲盤面。
用 00 , 01 , 10 分別來代表 ' ','O', 'X'
`uint32_t v = (bits >> (idx * 2)) & 3` : 將 bits 右移 idx * 2 位,這樣就把當前格子 idx 的 2 位元狀態移到了 bits 的最低兩位。接著再 & 3:可以找出最低兩位元的值。這就是當前格子的狀態 (0, 1, 或 2)。
## 對弈的過程中,要在螢幕顯示當下的時間 (含秒數),並持續更新
為了在使用者層獲取和顯示時間,我使用了時間處理函式庫,並調整 select 系統呼叫,使其能夠在沒有新的遊戲盤面資料或使用者輸入時,也能周期性地執行,以便更新時間顯示。
引入 <time.h> 標頭檔在 xo-user.c 中,新增對 <time.h> 的引用。這個函式庫提供了獲取和格式化時間的相關函式,例如 time() 和 localtime()。
遇到的困難
1. 時間與棋盤共享 stdout 造成的衝突:終端機的所有輸出都導向同一個標準輸出 (stdout)。當時間列和遊戲棋盤都需要向終端機寫入資料時,同時多個 printf() 操作都可能改變終端機滑鼠的當前位置。會造成棋盤被時間覆蓋掉,並且畫面會有閃爍的感覺
2. 游標位置干擾問題:在前面的處理中,我的 draw_board() 函式內部可能直接使用了 `\x1b[H` (將游標移動到左上角)來清除畫面或是重新顯示畫面。這導致了一些問題。時鐘已經寫好的字會立即被棋盤重畫的內容覆蓋掉,導致時間無法穩定顯示。
為了解決這些問題我將時間顯示與棋盤繪製分離,我創建了一個專門顯示時間的函式 `draw_time()` 來去單獨處理顯示時間的功能
```c
static void draw_time(void)
{
time_t now = time(NULL);
const struct tm *tm = localtime(&now);
printf("\x1b[H");
printf("Time: %02d:%02d:%02d\x1b[K", tm->tm_hour, tm->tm_min, tm->tm_sec);
fflush(stdout);
}
```
`time_t now = time(NULL);` 和 `const struct tm *tm = localtime(&now);`:利用 time() 獲取時間戳,再用 localtime() 轉換為 tm 結構,來提取時、分、秒。
`fflush(stdout);` 獨立的 fflush 調用確保了 `draw_time()` 輸出的時間能夠立即顯示在螢幕上,不會因為 stdout 的緩衝機制而延遲,這對於時間的即時性較重要。
為了解決游標會在畫面左上角一直閃爍的問題,我直接在 `main` 的開頭和結尾加上 `printf("\x1b[?25l");` 和 `printf("\x1b[?25h");` 分別代表了隱藏游標和顯示游標,在開始進入棋局前將游標隱藏起來,結束時再把他顯示,這樣就可以解決一直在畫面閃爍的問題。
## 當離開對弈時,顯示多場對弈的過程
在 `xo-user.c` 中,新增 moves 陣列用於儲存每次移動的文字表示,n_moves 記錄移動次數,game_lines 儲存多個遊戲的歷史記錄,n_games 記錄遊戲局數。接著去記錄每一步移動 `record_move` 當有新的棋盤狀態傳輸過來時,我們需要檢查舊狀態和新狀態的差異,可以判斷出在哪個格子進行了移動,並將其記錄下來。比較 oldb 和 newb。找出哪個格子從 0 (空) 變成了 1 (X) 或 2 (O)。
```c
static void record_move(uint32_t oldb, uint32_t newb)
{
if (n_moves >= 128)
return;
for (int i = 0; i < 16; ++i) {
uint32_t o = (oldb >> (i * 2)) & 3;
uint32_t n = (newb >> (i * 2)) & 3;
if (o == 0 && n != 0) {
moves[n_moves][0] = 'A' + (i % 4);
moves[n_moves][1] = '1' + (i / 4);
moves[n_moves][2] = '\0';
++n_moves;
break;
}
}
}
```
接下來是當遊戲結束(分出勝負或平局)時,需要將當前遊戲的移動過程儲存到歷史記錄中,並為下一局遊戲重置相關計數器。實作 `game_over_flush` :這個函式在遊戲結束時被呼叫,負責將當前局的移動歷史 (moves) 拼接成一個字串,儲存到 game_lines 中,並重置 n_moves。
```c
static void game_over_flush(void)Add commentMore actions
{
if (!n_moves)
return;
int len = 0;
len += snprintf(game_lines[n_games] + len, sizeof(game_lines[0]) - len,
"Moves:");
for (int i = 0; i < n_moves; ++i)
len += snprintf(game_lines[n_games] + len, sizeof(game_lines[0]) - len,
" %s%s", moves[i], (i + 1 == n_moves) ? "" : " -> ");
n_games++;
n_moves = 0;
}
```
最後將 game_history 在 main 要結束前輸出,即可得到各對局的對弈過程。
在實作過程中遇到了問題:
開始棋局後沒有經過暫停直接 Ctrl+Q :就會缺少 Moves 輸出
但如果有 Ctrl+P → 恢復 → Ctrl+Q : Moves 就可以正常列印
在原本設計中,Ctrl+Q 在 `listen_keyboard_handler` 中會直接設定 `end_attr = true;` 並呼叫 `game_over_flush()`。當 Ctrl+Q 被按下時, device_fd 還沒有讀取到最後的棋盤更新(或者 main 迴圈中的 read_attr 處理還沒觸發),導致 record_move 沒有記錄最後一步, game_over_flush 在 moves 陣列被正確輸入前就被呼叫。導致來不及讀取最後被遺漏。
listen_keyboard_handler() 內對 Ctrl‑Q 的處理:
```c
read_attr = false;
end_attr = true;
game_over_flush();
```
就會導致:提早關閉讀取 (read_attr = false) 主迴圈的 select() 之後就 不再監聽 /dev/kxo,這一步永遠不會進到 record_move() → moves[]。
```diff
static void listen_keyboard_handler(void)
case 17:
read(attr_fd, buf, 6);
buf[4] = '1';
- read_attr = false;
end_attr = true;
- game_over_flush();
write(attr_fd, buf, 6);
break;
```
將 `game_over_flush()` 移動到 `main` 函式結束前再呼叫,並且保持 `read_attr = true`: 確保在 Ctrl+Q 時,主迴圈仍然能讀取到最後的棋盤狀態更新。即可修正這些問題。