作業系統學習筆記10 | I/O、顯示器與鍵盤

2022-09-11 18:03:36

從這一部分開始介紹作業系統的裝置驅動,作業系統通過檔案系統的抽象驅動裝置讓使用者能夠使用顯示器、鍵盤等互動工具。並講解printf和scanf是如何實現敲下鍵盤將字元顯示到螢幕上的。


參考資料:


1. 外設工作原理的主幹理解

記憶體管理 的理解過程相似,對於 IO裝置(也叫外設)的理解,我們回到計算機的工作原理-- 馮·諾依曼的儲存程式、取指執行思想。

IO裝置分為兩類:

  1. 鍵盤和顯示器,本文先聚焦這部分;
  2. 磁碟,這部分下一篇會詳解;

後續會在磁碟驅動的基礎上抽象出檔案,最後所以會講檔案系統。

img

計算機如何讓外設工作的呢?

  • 根據生活經驗,每個外設都會有對應的控制裝置,

    比如顯示器對應顯示卡;磁碟對應磁碟控制器...

    這些裝置內部是暫存器。

  • 核心原理向對應的 外設控制器 / 裝置控制器 發指令,外設控制器根據其中的 暫存器(或是 memory)的值來操控對應硬體

    如 顯示卡控制視訊記憶體輸出到顯示器、或是在其內部的計算電路執行一些計算(GPU是高效的平行計算硬體);

    具體表現為 out 指令:out xx,al

    外設部分 所有的程式碼落實到最後都是這個 out 指令。

  • CPU 向 外設控制器 傳送指令(通過 PCI 匯流排)後,進入阻塞切換到其他程序。

  • 外設工作完後會向CPU傳送中斷,CPU再接著執行相關的中斷處理程式

核心程式碼思路就是上面 發指令、外設工作、中斷處理這幾步;但為了讓不同用途、不同廠家、不同型號的外設使用起來簡單,還需要虛擬化、抽象化為統一的檢視:檔案檢視。所以又加上了很多程式碼來進行包裝。

抽象為檔案檢視是一個很重要的概念,這讓上層使用者只要想輸出到顯示器上,都可以統一使用 printf 函數,而不需要考慮顯示器是什麼型號。

總結:

  • 抽象化為統一的檔案檢視;
  • CPU發外設控制指令;
  • 外設工作後返回中斷處理;
img

2. 顯示器的工作理解

我們採用自頂向下的方式看看一段控制顯示器輸出的高階語言程式是如何被外設執行的。

2.1 檔案檢視

學習筆記2-系統呼叫 得知(當時的配圖如下圖1),printf 遠遠不是 」輸出" 的真相,它的C語言函數庫中是如下程式碼。下面的open、write、close 都是什麼意思呢?

//開啟顯示器對應的檔案,此時顯示器已經被抽象為檔案
int fd = open("/dev/xxx");
for(int i = 0; i < 10; i++){
    write(fd,i,sizeof(int));
}
close(fd);
img

由於外設被抽象為檔案,所以講解 printf 的顯示機制之前,還需要了解一下整個檔案檢視的全貌。

如上面程式碼所示,作業系統為使用者操作各種外設提供了統一的檔案操作介面

  • 操作函數:如上面程式碼中提到的 open、write、close,此外還有 read;
  • 操作物件:即"/dev/xxx",不同的裝置名對應的就是不同的裝置。根據這裡的不同來區分裝置,進而據此決定後續控制哪種硬體進行操作;
img

接下來,檔案介面的操作函數,根據操作物件(C語言中的檔名)的不同,進行相應的控制硬體的處理。這就是下圖的第二層:

如這裡我們這裡的程式碼是 printf 的展開,檔名對應的是顯示器,對應就控制顯示器。

在向下寫控制器的指令就是 out 指令,向顯示器控制器寫入相應內容,控制器經過處理將指令作用到硬體上。這就是下圖的最底一層。

當某些外設控制器處理完畢,就會向CPU返回中斷,進行一些中斷處理,再返回到 檔案系統的介面層(write、close)這裡。

比如鍵盤,按下鍵位後返回中斷。

img

檔案的讀寫的資料來源來自記憶體,如果是 printf,就是將資料從記憶體裡的某段緩衝區取出字元打到顯示器上,而如果是 fprintf,就是記憶體該區域的相關字元放到磁碟的相應塊上。

2.2 從高階程式到檔案介面

高階語言中,如果要輸出一段字元,我們通常使用:

printf("Host Name:%s",name);

學習筆記2-系統呼叫中我們知道 printf 並非事情真相,它會繼續展開為一段包含 write 的 函數庫程式碼:write(1,buf,...); 意思是將 buf 這裡的字串 寫到 1 這個地方。

至於這裡的 1 是什麼意思,見下面 2.3 sys_write。不過顯而易見,write 再向下就會變成一段含有 out 指令的程式碼。

根據上面的檔案系統,write 這個檔案介面會根據操作物件不同進行分支,選擇不同的第二層操作,比如操作顯示器時就執行顯示器分支。

2.3 核心層介面實現

上面進行到了檔案介面,介面通過 int 0x80 指令中斷進入核心,這在 <學習筆記2 | 作業系統介面> 詳細講過,這裡就是繼續向下完成核心中介面的實現,也就是核心層介面實現。

這部分綜合知識太多,彙集了很多前面學到的知識,並且還有一些東西需要搜尋,因此我給出了很多外連。

2.3.1 sys_write

wirte 函數呼叫了 int 0x80 中斷,進入作業系統核心態,根據 IDT 呼叫 sys_write 執行具體的功能,核心程式碼如下:

注意,這裡之前學習筆記2介面呼叫 講到 sys_wirte 就沒有繼續向下了,這裡算是續上了。

  • fd 就是上面的 1,buf 是存放格式化輸出的快取,count描述應當從這段記憶體向檔案寫入幾個位元組。可見,後兩者都不能決定 write 向哪一個分支繼續向下操作,所以提前猜測是 fd / 1 的作用;

  • file=current->filp[fd]; 如果對多程序影象還有點印象,current 就是當前程序的PCB,這裡的意思就是PCB中的一個陣列 flip 的1號位置處儲存了一個檔案。

  • 下一句 inode 就是獲取 檔案的資訊。由於所有的外設都被抽象為檔案,所以檔案中一定有描述外設特徵的資訊。

    這樣我們就拿到了分支依據。

img

2.3.2 sys_fork

有一個問題:flip 以及 flip[1]是哪裡來的?

  • 答:既然在程序的PCB中,那麼就是從程序建立(一般指從父程序建立子程序)時建立的,也就是從父程序拷貝來的。

    不要問父程序的是從哪裡來的,作業系統本身算是0號程序,類似套娃。

  • 回憶 學習筆記5 的 ThreadCreate,程序建立是 fork => sys_fork 最後落實到 copy_process;

    下圖程式碼中:

    • NR_OPEN 是一個程序可以開啟的最大檔案數。一個程序不能使用超過 NR_OPEN 個 檔案描述符。

      詳見:NR_OPEN 與 NR_FILE 的區別

    • 因為父子程序檔案標識 fd 是資料,不發生寫更改時是不需要 」寫時複製「 的。這個的 ++ 操作的物件是被操作的檔案,目的是檔案資訊更新(標誌著使用者加一)。

  • flip[1]實際上是 開啟檔案的 指標。最開始是誰建立這個開啟檔案的指標的?

    • 回憶學習筆記4-開機過程中,所有的程序是從 0號程序開啟 Shell 後續逐漸建立子程序開始的。(對於Linux 0.11 而言)

    • 程式碼如下圖下側,可見是開啟了檔案(dev/tty0)並拷貝了兩份。而其中的 tty0 我們很熟悉,正是顯示器。

      兩個dup(0)的意思就是拷貝兩份,具體參見:dup( )和dup2( )函數詳解,此時陣列 0,1,2 位置上都是這個檔案,因此上文的 1 就決定要操作 tty0.

img

2.3.3 sys_open

到上面其實還不夠,因為還有一個 open 這個系統呼叫 在被呼叫,不妨再看看 sys_open 是如何實現的。見下圖程式碼:

  • filename 檔名,flag 檔案解析目錄,&inode 存放在磁碟上的檔案資訊;

  • 根據檔案名字把檔案讀入進來,最核心是讀入檔案的inode(檔案的相關資訊,其中有比如裝置型別和編號的資訊等等)

  • open 函數建立了 如下圖所示的 鏈:

    • 右側鏈:f->f_inode = inode
    • 左側鏈:current->flip[fd]=f;

    這樣就完成了 檔案 向 PCB 的新增,回答了2.3.1 sys_write 中的疑問。

    此處的 fd 為 1,是拷貝產生的,所以也對應 tty0,顯示器;我們順著 引數1這條鏈,最後找到的就是顯示器。

勘誤:下圖PPT中的f應當都為i,或者把第二行的i改為f。

img

2.3.4 回到 sys_write 向螢幕輸出

通過2.3.2 sys_fork 和 2.3.3 sys_open,檔案資訊 inode 從何而來以及如何開啟檔案 flip 就已經比較清楚了,下面回到 sys_write 看看如何外設分支的選擇以及 sys_write 向下如何引出 out 指令的。

  • 計算機的裝置分為 字元裝置(char device)和塊裝置(block device),首先分支確定是否哪個大類的裝置。

    • 塊裝置將資訊儲存在固定大小的塊中,每個塊都有自己的地址。資料塊的大小通常在512位元組到32768位元組之間。塊裝置的基本特徵是每個塊都能獨立於其它塊而讀寫。磁碟是最常見的塊裝置。
    • 另一種基本的裝置型別是字元裝置。字元裝置按照字元流的方式被有序存取,像串列埠和鍵盤就都屬於字元裝置。如果一個硬體裝置是以字元流的方式被存取的話,那就應該將它歸於字元裝置;反過來,如果一個裝置是隨機(無序的)存取的,那麼它就屬於塊裝置。

    參考資料:塊裝置與字元裝置 - 青山牧雲人

    • 先從檔案中讀取資訊 file->f_inode,然後再判定inode是不是字元裝置 if(S_ISCHR(inode->i_mode))
  • 分好了大類,這裡拿到的 tty 是 顯示器,屬於 字元裝置;下面要選擇是字元裝置中的第幾個裝置:

    • 字元裝置向下執行 rw_char(),讀寫字元裝置。根據引數可知,這裡的操作是 WRITE 寫。

    • 選擇是字元裝置中的第幾個裝置(裝置號): inode->i_zone[0]

      使用 ls -l 可以列出裝置及其主裝置號、從裝置號,這也是 inode 中儲存的裝置資訊。

      這裡假設我們的顯示器主裝置號為4,從裝置號為0。

  • 找到裝置後,我們需要選擇處理常式。

    • rw_char()向字元裝置輸入資訊;
    • 根據主裝置號 MAJOR(dev)crw_table 裡查表;
    • 得到表裡存放的函數指標,根據這個函數指標以及裝置號,就可以找到對應的處理常式,接下來就是對應的處理常式。
img
  • 很顯然,這裡通過 函數指標陣列 又實現了一層分支,看看這個 crw_table 陣列的組成和工作:

    • crw_table 裡第 4 個函數(主裝置號為4)是 rw_ttyx。而 rw_ttyx 對應的正是向終端裝置(顯示器)上進行寫操作,這是根據 上面的 write 一層層傳下來的。

      終端裝置包括 鍵盤和顯示器,其中鍵盤為讀操作,顯示器為寫操作。

  • rw_ttyx 呼叫 tty_write,在tty_write裡實現輸出:

    • 如下圖1,根據 tty_table 和 channel 找到 tty,相當於找到對應的資料流,上面提到過字元裝置按照字元流的形式讀寫。

    • 在往顯示器裡寫之前,為了彌補CPU計算與顯示器寫時兩種速度的不平衡,會將資料先寫在緩衝區,再從緩衝區向顯示器寫。

    • 下面程式碼中的:sleep_if_full 對佇列是否滿進行判斷,如果滿了,則休眠等待。

      佇列是 tty->write_q,類似於生產者消費者模型中的共用緩衝區。另一邊顯示器裝置會有對應的消費者函數,當一份工作執行完畢,緩衝區中還有內容,則從緩衝區中讀取字元。

    • 如果緩衝區沒有滿,則向緩衝區寫,如下圖2,緩衝區是在使用者態記憶體,根據get_fs_byte 從使用者態緩衝區讀出,放在 tty->write_q 這個佇列中。接下來就可以呼叫函數提取緩衝區內容進行螢幕輸出了:tty->write(tty);

img img

2.4 真正的輸出:out

  • 留意,tty 之所以 能夠指向 write_q 緩衝佇列,以及這裡的 write 函數,是因為定義它是一個指向 tty_struct 結構體的指標。

  • 在 tty_struct 中查到 con_write,使用 con_write 向顯示器寫,這也是上面提到的那個 消費者函數。

    • con_write() 中使用 GETCH(tty->write_q, c) 從緩衝佇列中取出字元 c,使用內嵌組合編寫將字元寫在顯示器上的指令,即寫出out指令

    • 內嵌組合講解:

      • _attr 屬性賦給 ah,將字元 c 賦給 ax,因為是字元實際上是放在 al 當中。

      • 現在的 ax 裡低位元組是字元,高位元組是屬性。

      • 然後,將 ax 賦給 1,1是 pos 顯示卡暫存器,最後得到的語句正是 mov ax, pos將 ax 中的值放到視訊記憶體上。

        • 補充一點計算機基礎知識:外部裝置儲存,有一部分可以和記憶體統一編址,此時使用 mov;另一部分獨立編址,使用 out。

        • Intel x86平臺普通使用了名為記憶體對映(MMIO)的技術,該技術是PCI規範的一部分,IO裝置埠被對映到記憶體空間,對映後,CPU存取IO埠就如同存取記憶體一樣。

        • 所以這裡用 mov 和 out 本質上是一樣的。

        參考資料:理解「統一編址與獨立編址、I/O埠與I/O記憶體」 - 板牙

        後續學習組合和介面的時候看看會補充這部分內容。

      • while() 每回圈一次在顯示器上輸出一個字元,直至while() 結束為止。

img

到這裡,就完成了從高階程式到最終顯示器輸出的全部過程,上面的程式碼流程也就是平時赫赫有名的 裝置驅動。開發裝置驅動的過程 就是編寫函數並註冊到分支的表上,建立對應 dev 檔案,建立 flip 鏈條。

注意,這裡 console 就是終端的意思,這個檔案裡書寫了鍵盤和顯示器兩方面的驅動。

2.5 視訊記憶體工作過程概述

上面while迴圈中,每寫一個字元,pos + 2。pos 的初始值在哪裡?

  • 控制檯的初始化在 作業系統 main.c 中的 con_init(),這裡設定了遊標的行號和列號
  • 注意這裡的 0x90000,在 學習筆記1開機過程 中,bootsect.s 把自己和 setup.s 移動到記憶體 0x90000 處,setup.s 根據 bios 中斷取出硬體引數,也包括了啟動時遊標在視訊記憶體中的位置。
  • 而在 main.c 中 初始化 視訊記憶體時,得到 0x90000 中儲存的視訊記憶體中游標的位置並賦值給 pos,後續用 pos 操縱視訊記憶體,進行字元顯示。
img

至於為什麼是 pos + 2 而不是 pos + 1 呢?

  • 因為螢幕字元在視訊記憶體中除了字元本身外還有字元屬性(如顏色),如下圖的視訊記憶體字元格式所示:
  • 通過 console.c 中的設定,可以呈現如黑底白字的效果。
img

2.6 簡單總結

高階程式 printf => 檔案介面 write => 字元裝置介面 crw_table 函數陣列 => 生產者:tty 裝置寫(tty_write)=> 緩衝佇列 write_q、同步機制 => 消費者:顯示器寫(con_write).

img

實驗7 中按下 F12 ,此後螢幕上輸出都會是 * 號,這一點在上述顯示過程中不難理解,只需要在 tty_write 中的 c 字元 替換為 * 即可。下面來看看如何用鍵盤啟動這個過程。

3. 鍵盤的工作理解

鍵盤也歸屬於上面 2.1的檔案檢視,也可以按照 第 1 部分外設工作原理的主幹來進行理解,不過此處與顯示器不同,鍵盤是典型的輸出裝置,可以向 CPU 發中斷處理請求

對於終端裝置鍵盤和顯示器而言,有兩個明線:

  • CPU 向對應的 外設控制器 / 裝置控制器 發指令;
  • 外設控制器 向 CPU 返回中斷請求。
img

3.1 21號中斷與中斷處理

對於鍵盤來說,敲下鍵盤就會發出中斷,所以鍵盤的工作應當從鍵盤中斷開始。

在作業系統初始化時( main.c 中的 con_init() ),將鍵盤中斷 / 21號中斷的處理程式設定為:keyboard_interrupt。當敲鍵盤產生中斷時,就會呼叫這個中斷處理常式。

當然,這裡的21號中斷也是硬體手冊中查到的。

img
  • inb $0x60,%al是最核心的指令

    • inb 讀入一個位元組,會將 60 埠中的資料讀入到 al 當中。

      60 埠:掃描碼,每一個按鍵都對應一個掃描碼

    • call key_table(,%eax,4):根據不同的碼,呼叫key_table 來執行相應的工作,這也開始向上分支了。

3.2 處理掃描碼

在 key_table 中,根據前面得到的不同的掃描碼,做不同的指令,其中 do_self 為用組合語言寫的顯示字元函數。do_self 會將 key_map 載入 ebx。

對於一般的 敲下a,b,c這樣的鍵,就是呼叫 do_self 顯示字元本身。

img

key_map 中是一堆 ASCII 碼:

  • 將 key_map 載入 ebx 的意思是,將這個表的起始地址賦給 ebx;
  • 掃描碼是 key_map 表的偏移,存放在 eax 中;
  • movb (%ebx,%eax),%al,就找到了按下的鍵所對應的 ASCII 碼;

同理,如果是 shift 鍵,如下面程式碼所示,對應的是一些按下shift 才能顯示的字元。

img

3.3 放入緩衝佇列

拿到 ASCII 碼後,放入緩衝佇列,當上層程序執行如 scanf 這樣的函數時,就從緩衝佇列拿出字元。

  • 如下圖程式碼;ASCII碼 放到了緩衝佇列當中;call put_queue,等上層程序來拿;
    • put_queue 中得到終端裝置的列表和 read_q 的 head;
    • 將 ASCII 碼輸出到這個緩衝佇列的頭部。

然後,再將其回顯到螢幕。

這裡一個生產者就完成了,後續消費者(即上層取佇列元素的程式,同樣經過檔案檢視的封裝)與第2部分 寫入 write_q 佇列的程式很相似,只不過上文是寫,這裡應當是 讀取 read_q 佇列。這部分不再細講。

img上。

3.4 回顯

可顯示的字元通常需要回顯到顯示器上,這其實就跟第 2 部分的開頭會師了:

  • 從 read_q 中得到一個字元 c;
  • 將字元 c 放入 write_q 佇列中;
  • 呼叫 tty->wirte 將其顯示到螢幕上。

3.5 簡單總結

  • 鍵盤中斷的核心就是 取出ASCII碼放到read_q裡面 ;

  • 再從 read_q 裡面放入 secondary(進行跳脫等中間處理)等佇列中

    一些優化技術。

  • scanf 再從 secondary 佇列中取出ASCII碼。

  • 回顯,將這個碼再放到write_q佇列中,從佇列中取出碼回寫輸出到螢幕上。

img

4. 總結

第3部分的鍵盤 scanf 與第 2 部分 printf 顯示器綜合起來,就得到了從鍵盤輸入,到顯示器輸出的全過程:

  • 使用 scanf 輸入時,OS 掃描鍵盤上是否有所輸入;
  • 如果有,呼叫中斷處理,查詢到對應的 ASCII 碼;
  • 將 掃描碼 放入 read_q 佇列,經過一些優化技術(如放入secondary佇列),此時佇列中的元素可以被 scanf 正確讀入。
  • 讀入後,呼叫 回寫指令,把 ASCII 碼再放入 write_q 佇列中,向螢幕發出 out 指令,讓字元可在顯示器上輸出。

本部分對應實驗7. 與2.6部分對應,如果要讓F12按下後輸出 *,則需在 3.2 部分處理掃描碼的時候不呼叫原 func,而是重寫一個函數使其得到的字元是 *

img