第42回 | 用鍵盤輸入一條命令
新讀者看這里,老讀者直接跳過。
本系列會以一個讀小說的心態(tài),從開機啟動后的代碼執(zhí)行順序,帶著大家閱讀和賞析 Linux 0.11 全部核心代碼,了解操作系統(tǒng)的技術細節(jié)和設計思想。
本系列的 GitHub 地址如下,希望給個 star 以示鼓勵(文末閱讀原文可直接跳轉(zhuǎn),也可以將下面的鏈接復制到瀏覽器里打開)
https://github.com/sunym1993/flash-linux0.11-talk
本回的內(nèi)容屬于第五部分。

你會跟著我一起,看著一個操作系統(tǒng)從啥都沒有開始,一步一步最終實現(xiàn)它復雜又精巧的設計,讀完這個系列后希望你能發(fā)出感嘆,原來操作系統(tǒng)源碼就是這破玩意。
以下是已發(fā)布文章的列表,詳細了解本系列可以先從開篇詞看起。
第一部分 進入內(nèi)核前的苦力活
第9回 | Intel 內(nèi)存管理兩板斧:分段與分頁
第二部分 大戰(zhàn)前期的初始化工作
第18回 | 進程調(diào)度初始化 sched_init
第三部分 一個新進程的誕生
第22回 | 從內(nèi)核態(tài)切換到用戶態(tài)
第25回 | 通過 fork 看一次系統(tǒng)調(diào)用
第27回 | 透過 fork 來看進程的內(nèi)存規(guī)劃
第42回 | 用鍵盤輸入一條命令(本文)
------- 正文開始 -------
新建一個非常簡單的 info.txt 文件。
name:flash
age:28
language:java在命令行輸入一條十分簡單的命令。
[[email protected]] cat info.txt | wc -l
3
這條命令的意思是讀取剛剛的 info.txt 文件,輸出它的行數(shù)。
我們先從最初始的狀態(tài)開始說起。
最初始的狀態(tài),電腦屏幕前只有這么一段話。
[[email protected]]
然后,我們按下按鍵 'c',將會變成這樣。
[[email protected]] c
我們再按下 'a'
[[email protected]] ca
接下來,我們再依次按下 't'、空格、'i' 等等,才變成了這樣。
[[email protected]] cat info.txt | wc -l
我們今天就要解釋這個看起來十分"正常"的過程。
憑什么我們按下鍵盤后,屏幕上就會出現(xiàn)如此的變化呢?老天爺規(guī)定的么?
我們就從按下鍵盤上的 'c' 鍵開始說起。
首先,得益于 第16回 | 控制臺初始化 tty_init 中講述的一行代碼。
// console.c
void con_init(void) {
...
set_trap_gate(0x21,&keyboard_interrupt);
...
}
我們成功將鍵盤中斷綁定在了 keyboard_interrupt 這個中斷處理函數(shù)上,也就是說當我們按下鍵盤 'c' 時,CPU 的中斷機制將會被觸發(fā),最終執(zhí)行到這個 keyboard_interrupt 函數(shù)中。
我們來到 keyboard_interrupt 函數(shù)一探究竟。
// keyboard.s
keyboard_interrupt:
...
// 讀取鍵盤掃描碼
inb $0x60,%al
...
// 調(diào)用對應按鍵的處理函數(shù)
call *key_table(,%eax,4)
...
// 0 作為參數(shù),調(diào)用 do_tty_interrupt
pushl $0
call do_tty_interrupt
...
很簡單,首先通過 IO 端口操作,從鍵盤中讀取了剛剛產(chǎn)生的鍵盤掃描碼,就是剛剛按下 'c' 的時候產(chǎn)生的鍵盤掃描碼。
隨后,在 key_table 中尋找不同按鍵對應的不同處理函數(shù),比如普通的一個字母對應的字符 'c' 的處理函數(shù)為 do_self,該函數(shù)會將掃描碼轉(zhuǎn)換為 ASCII 字符碼,并將自己放入一個隊列里,我們稍后再說這部分的細節(jié)。
接下來,就是調(diào)用 do_tty_interrupt 函數(shù),見名知意就是處理終端的中斷處理函數(shù),注意這里傳遞了一個參數(shù) 0。
我們接著探索,打開 do_tty_interrupt 函數(shù)。
// tty_io.c
void do_tty_interrupt(int tty) {
copy_to_cooked(tty_table+tty);
}
void copy_to_cooked(struct tty_struct * tty) {
...
}
這個函數(shù)幾乎什么都沒做,將 keyboard_interrupt 時傳入的參數(shù) 0,作為 tty_table 的索引,找到 tty_table 中的第 0 項作為下一個函數(shù)的入?yún)ⅲ瑑H此而已。
tty_table 是終端設備表,在 Linux 0.11 中定義了三項,分別是控制臺、串行終端 1 和串行終端 2。
// tty.h
struct tty_struct tty_table[] = {
{
{...},
0, /* initial pgrp */
0, /* initial stopped */
con_write,
{0,0,0,0,""}, /* console read-queue */
{0,0,0,0,""}, /* console write-queue */
{0,0,0,0,""} /* console secondary queue */
},
{...},
{...}
};
我們用的往屏幕上輸出內(nèi)容的終端,就是 0 號索引位置處的控制臺終端,所以我將另外兩個終端定義的代碼省略掉了。
tty_table 終端設備表中的每一項結(jié)構,是 tty_struct,用來描述一個終端的屬性。
struct tty_struct {
struct termios termios;
int pgrp;
int stopped;
void (*write)(struct tty_struct * tty);
struct tty_queue read_q;
struct tty_queue write_q;
struct tty_queue secondary;
};
struct tty_queue {
unsigned long data;
unsigned long head;
unsigned long tail;
struct task_struct * proc_list;
char buf[TTY_BUF_SIZE];
};
說說其中較為關鍵的幾個。
termios 是定義了終端的各種模式,包括讀模式、寫模式、控制模式等,這個之后再說。
void (*write)(struct tty_struct * tty) 是一個接口函數(shù),在剛剛的 tty_table 中我們也可以看出被定義為了 con_write,也就是說今后我們調(diào)用這個 0 號終端的寫操作時,將會調(diào)用的是這個 con_write 函數(shù),這不就是接口思想么。
還有三個隊列分別為讀隊列 read_q,寫隊列 write_q 以及一個輔助隊列 secondary。
這些有什么用,我們通通之后再說,跟著我接著看。
// tty_io.c
void do_tty_interrupt(int tty) {
copy_to_cooked(tty_table+tty);
}
void copy_to_cooked(struct tty_struct * tty) {
signed char c;
while (!EMPTY(tty->read_q) && !FULL(tty->secondary)) {
// 從 read_q 中取出字符
GETCH(tty->read_q,c);
...
// 這里省略了一大坨行規(guī)則處理代碼
...
// 將處理過后的字符放入 secondary
PUTCH(c,tty->secondary);
}
wake_up(&tty->secondary.proc_list);
}
展開 copy_to_cooked 函數(shù)我們發(fā)現(xiàn),一個大體的框架已經(jīng)有了。
在 copy_to_cooked 函數(shù)里就是個大循環(huán),只要讀隊列 read_q 不為空,且輔助隊列 secondary 沒有滿,就不斷從 read_q 中取出字符,經(jīng)過一大坨的處理,寫入 secondary 隊列里。

否則,就喚醒等待這個輔助隊列 secondary 的進程,之后怎么做就由進程自己決定。
我們接著看,中間的一大坨處理過程做了什么事情呢?
這一大坨有太多太多的 if 判斷,但都是圍繞著同一個目的,我們舉其中一個簡單的例子。
#define IUCLC 0001000
#define _I_FLAG(tty,f) ((tty)->termios.c_iflag & f)
#define I_UCLC(tty) _I_FLAG((tty),IUCLC)
void copy_to_cooked(struct tty_struct * tty) {
...
// 這里省略了一大坨行規(guī)則處理代碼
if (I_UCLC(tty))
c=tolower(c);
...
}
簡單說,就是通過判斷 tty 中的 termios,來決定對讀出的字符 c 做一些處理。
在這里,就是判斷 termios 中的 c_iflag 中的第 4 位是否為 1,來決定是否要將讀出的字符 c 由大寫變?yōu)樾憽?/span>
這個 termios 就是定義了終端的模式。
struct termios {
unsigned long c_iflag; /* input mode flags */
unsigned long c_oflag; /* output mode flags */
unsigned long c_cflag; /* control mode flags */
unsigned long c_lflag; /* local mode flags */
unsigned char c_line; /* line discipline */
unsigned char c_cc[NCCS]; /* control characters */
};
比如剛剛的是否要將大寫變?yōu)樾?,是否將回車字符替換成換行字符,是否接受鍵盤控制字符信號如 ctrl + c 等。
這些模式不是 Linux 0.11 自己亂想出來的,而是實現(xiàn)了 POSIX.1 中規(guī)定的 termios 標準,具體可以參見:
https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap11.html#tag_11

好了,我們目前可以總結(jié)出,按下鍵盤后做了什么事情。

這里我們應該產(chǎn)生幾個疑問。
一、讀隊列 read_q 里的字符是什么時候放進去的?
還記不記得最開始講的 keyboard_interrupt 函數(shù),我們有一個方法沒有展開講。
// keyboard.s
keyboard_interrupt:
...
// 讀取鍵盤掃描碼
inb $0x60,%al
...
// 調(diào)用對應按鍵的處理函數(shù)
call *key_table(,%eax,4)
...
// 0 作為參數(shù),調(diào)用 do_tty_interrupt
pushl $0
call do_tty_interrupt
...
就是這個 key_table,我們將其展開。
// keyboard.s
key_table:
.long none,do_self,do_self,do_self /* 00-03 s0 esc 1 2 */
.long do_self,do_self,do_self,do_self /* 04-07 3 4 5 6 */
...
.long do_self,do_self,do_self,do_self /* 20-23 d f g h */
...
可以看出,普通的字符 abcd 這種,對應的處理函數(shù)是 do_self,我們再繼續(xù)展開。
// keyboard.s
do_self:
...
// 掃描碼轉(zhuǎn)換為 ASCII 碼
lea key_map,%ebx
1: movb (%ebx,%eax),%al
...
// 放入隊列
call put_queue
可以看到最后調(diào)用了 put_queue 函數(shù),顧名思義放入隊列,看來我們要找到答案了,繼續(xù)展開。
// tty_io.c
struct tty_queue * table_list[]={
&tty_table[0].read_q, &tty_table[0].write_q,
&tty_table[1].read_q, &tty_table[1].write_q,
&tty_table[2].read_q, &tty_table[2].write_q
};
// keyboard.s
put_queue:
...
movl table_list,%edx # read-queue for console
movl head(%edx),%ecx
...
可以看出,put_queue 正是操作了我們 tty_table 數(shù)組中的零號位置,也就是控制臺終端 tty 的 read_q 隊列,進行入隊操作。
答案揭曉了,那我們的整體流程圖也可以再豐富起來。

二、放入 secondary 隊列之后呢?
按下鍵盤后,一系列代碼將我們的字符放入了 secondary 隊列中,然后呢?
這就涉及到上層進程調(diào)用終端的讀函數(shù),將這個字符取走了。
上層經(jīng)過庫函數(shù)、文件系統(tǒng)函數(shù)等,最終會調(diào)用到 tty_read 函數(shù),將字符從 secondary 隊列里取走。
// tty_io.c
int tty_read(unsigned channel, char * buf, int nr) {
...
GETCH(tty->secondary,c);
...
}
取走后要干嘛,那就是上層應用程序去決定的事情了。
假如要寫到控制臺終端,那上層應用程序又會經(jīng)過庫函數(shù)、文件系統(tǒng)函數(shù)等層層調(diào)用,最終調(diào)用到 tty_write 函數(shù)。
// tty_io.
int tty_write(unsigned channel, char * buf, int nr) {
...
PUTCH(c,tty->write_q);
...
tty->write(tty);
...
}
這個函數(shù)首先會將字符 c 放入 write_q 這個隊列,然后調(diào)用 tty 里設定的 write 函數(shù)。
終端控制臺這個 tty 我們之前說了,初始化的 write 函數(shù)是 con_write,也就是 console 的寫函數(shù)。
// console.c
void con_write(struct tty_struct * tty) {
...
}
這個函數(shù)在 第16回 | 控制臺初始化 tty_init 提到了,最終會配合顯卡,在我們的屏幕上輸出我們給出的字符。

那我們的圖又可以補充了。

核心點就是三個隊列 read_q,secondary 以及 write_q。
其中 read_q 是鍵盤按下按鍵后,進入到鍵盤中斷處理程序 keyboard_interrupt 里,最終通過 put_queue 函數(shù)字符放入 read_q 這個隊列。
secondary 是 read_q 隊列里的未處理字符,通過 copy_to_cooked 函數(shù),經(jīng)過一定的 termios 規(guī)范處理后,將處理過后的字符放入 secondary。(處理過后的字符就是成"熟"的字符,所以叫 cooked,是不是很形象?)
然后,進程通過 tty_read 從 secondary 里讀字符,通過 tty_write 將字符寫入 write_q,最終 write_q 中的字符可以通過 con_write 這個控制臺寫函數(shù),將字符打印在顯示器上。
這就完成了從鍵盤輸入到顯示器輸出的一個循環(huán),也就是本回所講述的內(nèi)容。
好了,現(xiàn)在我們已經(jīng)成功做到可以把這樣一個字符串輸入并回顯在顯示器上了。
[root@linux0.11] cat info.txt | wc -l
那么接下來,shell 程序具體是如何讀入這個字符串,讀入后又是怎么處理的呢?
欲知后事如何,且聽下回分解。
------- 關于本系列 -------
本系列的開篇詞看這,開篇詞
本系列的番外故事看這,讓我們一起來寫本書?也可以直接無腦加入星球,共同參與這場旅行。
最后,本系列完全免費,希望大家能多多傳播給同樣喜歡的人,同時給我的 GitHub 項目點個 star,就在閱讀原文處,這些就足夠讓我堅持寫下去了!我們下回見。
