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

你會跟著我一起,看著一個操作系統(tǒng)從啥都沒有開始,一步一步最終實現(xiàn)它復雜又精巧的設計,讀完這個系列后希望你能發(fā)出感嘆,原來操作系統(tǒng)源碼就是這破玩意。
以下是已發(fā)布文章的列表,詳細了解本系列可以先從開篇詞看起。
第一部分 進入內核前的苦力活
第二部分 大戰(zhàn)前期的初始化工作
第三部分 一個新進程的誕生
第43回 | shell 程序讀取你的命令(本文)
------- 正文開始 -------
新建一個非常簡單的 info.txt 文件。
name:flash
age:28
language:java在命令行輸入一條十分簡單的命令。
[[email protected]] cat info.txt | wc -l
3
這條命令的意思是讀取剛剛的 info.txt 文件,輸出它的行數(shù)。
在上一回,我們詳細解讀了從鍵盤敲擊出這個命令,到屏幕上顯示出這個命令,中間發(fā)生的事情。

那今天,我們接著往下走,下一步就是,shell 程序如何讀取到你輸入的這條命令的。
這里我們需要知道兩件事情。
第一,我們鍵盤輸入的字符,此時已經到達了控制臺終端 tty 結構中的 secondary 這個隊列里。
第二,shell 程序將通過上層的 read 函數(shù)調用,來讀取這些字符。
// xv6-public sh.c
int main(void) {
static char buf[100];
// 讀取命令
while(getcmd(buf, sizeof(buf)) >= 0){
// 創(chuàng)建新進程
if(fork() == 0)
// 執(zhí)行命令
runcmd(parsecmd(buf));
// 等待進程退出
wait();
}
}
int getcmd(char *buf, int nbuf) {
...
gets(buf, nbuf);
...
}
char* gets(char *buf, int max) {
int i, cc;
char c;
for(i=0; i+1 < max; ){
cc = read(0, &c, 1);
if(cc < 1)
break;
buf[i++] = c;
if(c == '\n' || c == '\r')
break;
}
buf[i] = '\0';
return buf;
}
看,shell 程序會通過 getcmd 函數(shù)最終調用到 read 函數(shù)一個字符一個字符讀入,直到讀到了換行符(\n 或 \r)的時候,才返回。
讀入的字符在 buf 里,遇到換行符后,這些字符將作為一個完整的命令,傳入給 runcmd 函數(shù),真正執(zhí)行這個命令。
那我們接下來的任務就是,看一下這個 read 函數(shù)是怎么把之前鍵盤輸入并轉移到 secondary 這個隊列里的字符給讀出來的。
read 函數(shù)是個用戶態(tài)的庫函數(shù),最終會通過系統(tǒng)調用中斷,執(zhí)行 sys_read 函數(shù)。
// read_write.c
// fd = 0, count = 1
int sys_read(unsigned int fd,char * buf,int count) {
struct file * file = current->filp[fd];
// 校驗 buf 區(qū)域的內存限制
verify_area(buf,count);
struct m_inode * inode = file->f_inode;
// 管道文件
if (inode->i_pipe)
return (file->f_mode&1)?read_pipe(inode,buf,count):-EIO;
// 字符設備文件
if (S_ISCHR(inode->i_mode))
return rw_char(READ,inode->i_zone[0],buf,count,&file->f_pos);
// 塊設備文件
if (S_ISBLK(inode->i_mode))
return block_read(inode->i_zone[0],&file->f_pos,buf,count);
// 目錄文件或普通文件
if (S_ISDIR(inode->i_mode) || S_ISREG(inode->i_mode)) {
if (count+file->f_pos > inode->i_size)
count = inode->i_size - file->f_pos;
if (count<=0)
return 0;
return file_read(inode,file,buf,count);
}
// 不是以上幾種,就報錯
printk("(Read)inode->i_mode=%06o\n\r",inode->i_mode);
return -EINVAL;
}
關鍵地方我已經標上了注釋,整體結構不看細節(jié)的話特別清晰。
這個最上層的 sys_read,把讀取管道文件、字符設備文件、塊設備文件、目錄文件或普通文件,都放在了同一個方法里處理,這個方法作為所有讀操作的統(tǒng)一入口,由此也可以看出 linux 下一切皆文件的思想。
read 的第一個參數(shù)是 0,也就是 0 號文件描述符,之前我們在講第四部分的時候說過,shell 進程是由進程 1 通過 fork 創(chuàng)建出來的,而進程 1 在 init 的時候打開了 /dev/tty0 作為 0 號文件描述符。
// main.c
void init(void) {
setup((void *) &drive_info);
(void) open("/dev/tty0",O_RDWR,0);
(void) dup(0);
(void) dup(0);
}
而這個 /dev/tty0 的文件類型,也就是其 inode 結構中表示文件類型與屬性的 i_mode 字段,表示為字符型設備,所以最終會走到 rw_char 這個子方法下,文件系統(tǒng)的第一層劃分就走完了。
接下來我們看 rw_char 這個方法。
// char_dev.c
static crw_ptr crw_table[]={
NULL, /* nodev */
rw_memory, /* /dev/mem etc */
NULL, /* /dev/fd */
NULL, /* /dev/hd */
rw_ttyx, /* /dev/ttyx */
rw_tty, /* /dev/tty */
NULL, /* /dev/lp */
NULL}; /* unnamed pipes */
int rw_char(int rw,int dev, char * buf, int count, off_t * pos) {
crw_ptr call_addr;
if (MAJOR(dev)>=NRDEVS)
return -ENODEV;
if (!(call_addr=crw_table[MAJOR(dev)]))
return -ENODEV;
return call_addr(rw,MINOR(dev),buf,count,pos);
}
根據 dev 這個參數(shù),計算出主設備號為 4,次設備號為 0,所以將會走到 rw_ttyx 方法繼續(xù)執(zhí)行。
// char_dev.c
static int rw_ttyx(int rw,unsigned minor,char * buf,int count,off_t * pos) {
return ((rw==READ)?tty_read(minor,buf,count):
tty_write(minor,buf,count));
}
根據 rw == READ 走到讀操作分支 tty_read,這就終于快和上一講的故事接上了。
以下是 tty_read 函數(shù),我省略了一些關于信號和超時時間等非核心的代碼。
// tty_io.c
// channel=0, nr=1
int tty_read(unsigned channel, char * buf, int nr) {
struct tty_struct * tty = &tty_table[channel];
char c, * b=buf;
while (nr>0) {
...
if (EMPTY(tty->secondary) ...) {
sleep_if_empty(&tty->secondary);
continue;
}
do {
GETCH(tty->secondary,c);
...
put_fs_byte(c,b++);
if (!--nr) break;
} while (nr>0 && !EMPTY(tty->secondary));
...
}
...
return (b-buf);
}
入參有三個參數(shù),非常簡單。
channel 為 0,表示 tty_table 里的控制臺終端這個具體的設備。buf 是我們要讀取的數(shù)據拷貝到內存的位置指針,也就是用戶緩沖區(qū)指針。nr 為 1,表示我們要讀出 1 個字符。
整個方法,其實就是不斷從 secondary 隊列里取出字符,然后放入 buf 指所指向的內存。
如果要讀取的字符數(shù) nr 被減為 0,說明已經完成了讀取任務,或者說 secondary 隊列為空,說明不論你任務完沒完成我都沒有字符讓你繼續(xù)讀了,那此時調用 sleep_if_empty 將線程阻塞,等待被喚醒。
其中 GETCH 就是個宏,改變 secondary 隊列的隊頭隊尾指針,你自己寫個隊列數(shù)據結構,也是這樣的操作,不再展開講解。
#define GETCH(queue,c) \
(void)({c=(queue).buf[(queue).tail];INC((queue).tail);})
同理,判空邏輯就更為簡單了,就是隊列頭尾指針是否相撞。
#define EMPTY(a) ((a).head == (a).tail)
理解了這些小細節(jié)之后,再明白一行關鍵的代碼,整個 read 到 tty_read 這條線就完全可以想明白了。那就是隊列為空,即不滿足繼續(xù)讀取條件的時候,讓進程阻塞的 sleep_if_empty,我們看看。
sleep_if_empty(&tty->secondary);
// tty_io.c
static void sleep_if_empty(struct tty_queue * queue) {
cli();
while (!current->signal && EMPTY(*queue))
interruptible_sleep_on(&queue->proc_list);
sti();
}
// sched.c
void interruptible_sleep_on(struct task_struct **p) {
struct task_struct *tmp;
...
tmp=*p;
*p=current;
repeat: current->state = TASK_INTERRUPTIBLE;
schedule();
if (*p && *p != current) {
(**p).state=0;
goto repeat;
}
*p=tmp;
if (tmp)
tmp->state=0;
}
我們先只看一句關鍵的代碼,就是將當前進程的狀態(tài)設置為可中斷等待。
current->state = TASK_INTERRUPTIBLE;
那么執(zhí)行到進程調度程序時,當前進程將不會被調度,也就相當于阻塞了,不熟悉進程調度的同學可以復習一下 第23回 | 如果讓你來設計進程調度。
進程被調度了,什么時候被喚醒呢?
當我們再次按下鍵盤,使得 secondary 隊列中有字符時,也就打破了為空的條件,此時就應該將之前的進程喚醒了,這在上一回 第42回 | 用鍵盤輸入一條命令 一講中提到過了。
// tty_io.c
void do_tty_interrupt(int tty) {
copy_to_cooked(tty_table+tty);
}
void copy_to_cooked(struct tty_struct * tty) {
...
wake_up(&tty->secondary.proc_list);
}
可以看到,在 copy_to_cooked 里,在將 read_q 隊列中的字符處理后放入 secondary 隊列中的最后一步,就是喚醒 wake_up 這個隊列里的等待進程。
而 wake_up 函數(shù)更為簡單,就是修改一下狀態(tài),使其變成可運行的狀態(tài)。
// sched.c
void wake_up(struct task_struct **p) {
if (p && *p) {
(**p).state=0;
}
}
總體流程就是這個樣子的。

當然,進程的阻塞與喚醒是個體系,還有很多細節(jié),我們下一回再仔細展開這部分的內容。
欲知后事如何,且聽下回分解。
------- 關于本系列 -------
本系列的開篇詞看這,開篇詞
本系列的番外故事看這,讓我們一起來寫本書?也可以直接無腦加入星球,共同參與這場旅行。
最后,本系列完全免費,希望大家能多多傳播給同樣喜歡的人,同時給我的 GitHub 項目點個 star,就在閱讀原文處,這些就足夠讓我堅持寫下去了!我們下回見。
