按下鍵盤后為什么屏幕上就會有輸出
書接上回,上回書咱們說到,繼內存管理結構 mem_map 和中斷描述符表 idt 建立好之后,我們又在內存中倒騰出一個新的數據結構 request。
并且把它們都放在了一個數組中。
這是塊設備驅動程序與內存緩沖區的橋梁,通過它可以完整地表示一個塊設備讀寫操作要做的事。
我們繼續往下看,tty_init。
- void main(void) {
- ...
- mem_init(main_memory_start,memory_end);
- trap_init();
- blk_dev_init();
- chr_dev_init();
- tty_init();
- time_init();
- sched_init();
- buffer_init(buffer_memory_end);
- hd_init();
- floppy_init();
- sti();
- move_to_user_mode();
- if (!fork()) {init();}
- for(;;) pause();
- }
這個方法執行完成之后,我們將會具備鍵盤輸入到顯示器輸出字符這個最常用的功能。
打開這個函數后我有點慌。
- void tty_init(void)
- {
- rs_init();
- con_init();
- }
看來這個方法已經多到需要拆成兩個子方法了。
打開第一個方法,還好。
- void rs_init(void)
- {
- set_intr_gate(0x24,rs1_interrupt);
- set_intr_gate(0x23,rs2_interrupt);
- init(tty_table[1].read_q.data);
- init(tty_table[2].read_q.data);
- outb(inb_p(0x21)&0xE7,0x21);
- }
這個方法是串口中斷的開啟,以及設置對應的中斷處理程序,串口在我們現在的 PC 機上已經很少用到了,所以這個直接忽略,要講我也不懂。
看第二個方法,這是重點。代碼非常長,有點嚇人,我先把大體框架寫出。
- void con_init(void) {
- ...
- if (ORIG_VIDEO_MODE == 7) {
- ...
- if ((ORIG_VIDEO_EGA_BX & 0xff) != 0x10) {...}
- else {...}
- } else {
- ...
- if ((ORIG_VIDEO_EGA_BX & 0xff) != 0x10) {...}
- else {...}
- }
- ...
- }
可以看出,非常多的 if else。
這是為了應對不同的顯示模式,來分配不同的變量值,那如果我們僅僅找出一個顯示模式,這些分支就可以只看一個了。 啥是顯示模式呢?那我們得簡單說說顯示,一個字符是如何顯示在屏幕上的呢?換句話說,如果你可以隨意操作內存和 CPU 等設備,你如何操作才能使得你的顯示器上,顯示一個字符‘a’呢?
我們先看一張圖。
內存中有這樣一部分區域,是和顯存映射的。啥意思,就是你往上圖的這些內存區域中寫數據,相當于寫在了顯存中。而往顯存中寫數據,就相當于在屏幕上輸出文本了。
沒錯,就是這么簡單。 如果我們寫這一行匯編語句。
- mov [0xB8000],'h'
后面那個 h 相當于匯編編輯器幫我們轉換成 ASCII 碼的二進制數值,當然我們也可以直接寫。
- mov [0xB8000],0x68
其實就是往內存中 0xB8000 這個位置寫了一個值,只要一寫,屏幕上就會是這樣。
簡單吧,具體說來,這片內存是每兩個字節表示一個顯示在屏幕上的字符,第一個是字符的編碼,第二個是字符的顏色,那我們先不管顏色,如果多寫幾個字符就像這樣。
- mov [0xB8000],'h'
- mov [0xB8002],'e'
- mov [0xB8004],'l'
- mov [0xB8006],'l'
- mov [0xB8008],'o'
此時屏幕上就會是這樣。
是不是賊簡單?那我們回過頭看剛剛的代碼,我們就假設顯示模式是我們現在的這種文本模式,那條件分支就可以去掉好多。 代碼可以簡化成這個樣子。
- #define ORIG_X (*(unsigned char *)0x90000)
- #define ORIG_Y (*(unsigned char *)0x90001)
- void con_init(void) {
- register unsigned char a;
- // 第一部分 獲取顯示模式相關信息
- video_num_columns = (((*(unsigned short *)0x90006) & 0xff00) >> 8);
- video_size_row = video_num_columns * 2;
- video_num_lines = 25;
- video_page = (*(unsigned short *)0x90004);
- video_erase_char = 0x0720;
- // 第二部分 顯存映射的內存區域
- video_mem_start = 0xb8000;
- video_port_reg = 0x3d4;
- video_port_val = 0x3d5;
- video_mem_end = 0xba000;
- // 第三部分 滾動屏幕操作時的信息
- origin = video_mem_start;
- scr_end = video_mem_start + video_num_lines * video_size_row;
- top = 0;
- bottom = video_num_lines;
- // 第四部分 定位光標并開啟鍵盤中斷
- gotoxy(ORIG_X, ORIG_Y);
- set_trap_gate(0x21,&keyboard_interrupt);
- outb_p(inb_p(0x21)&0xfd,0x21);
- a=inb_p(0x61);
- outb_p(a|0x80,0x61);
- outb(a,0x61);
- }
別看這么多,一點都不難。
首先還記不記得之前匯編語言的時候做的工作,存了好多以后要用的數據在內存中。
內存地址 | 長度(字節) | 名稱 |
---|---|---|
0x90000 | 2 | 光標位置 |
0x90002 | 2 |
擴展內存數 |
0x90004 | 2 | 顯示頁面 |
0x90006 | 1 |
顯示模式 |
0x90007 | 1 | 字符列數 |
0x90008 | 2 | 未知 |
0x9000A | 1 |
顯示內存 |
0x9000B | 1 |
顯示狀態 |
0x9000C | 2 | 顯卡特性參數 |
0x9000E | 1 |
屏幕行數 |
0x9000F | 1 | 屏幕列數 |
0x90080 | 16 |
硬盤1參數表 |
0x90090 | 16 | 硬盤2參數表 |
0x901FC | 2 |
根設備號 |
所以,第一部分獲取 0x90006 地址處的數據,就是獲取顯示模式等相關信息。
第二部分就是顯存映射的內存地址范圍,我們現在假設是 CGA 類型的文本模式,所以映射的內存是從 0xB8000 到 0xBA000。
第三部分是設置一些滾動屏幕時需要的參數,定義頂行和底行是哪里,這里頂行就是第一行,底行就是最后一行,很合理。
第四部分是把光標定位到之前保存的光標位置處(取內存地址 0x90000 處的數據),然后設置并開啟鍵盤中斷。
開啟鍵盤中斷后,鍵盤上敲擊一個按鍵后就會觸發中斷,中斷程序就會讀鍵盤碼轉換成 ASCII 碼,然后寫到光標處的內存地址,也就相當于往顯存寫,于是這個鍵盤敲擊的字符就顯示在了屏幕上。
這一切具體是怎么做到的呢?我們先看看我們干了什么。
1. 我們現在根據已有信息已經可以實現往屏幕上的任意位置寫字符了,而且還能指定顏色。
2. 并且,我們也能接受鍵盤中斷,根據鍵盤碼中斷處理程序就可以得知哪個鍵按下了。
有了這倆功能,那我們想干嘛還不是為所欲為?
好,接下來我們看看代碼是怎么處理的,很簡單。一切的起點,就是第四步的 gotoxy 函數,定位當前光標。
- #define ORIG_X (*(unsigned char *)0x90000)
- #define ORIG_Y (*(unsigned char *)0x90001)
- void con_init(void) {
- ...
- // 第四部分 定位光標并開啟鍵盤中斷
- gotoxy(ORIG_X, ORIG_Y);
- ...
- }
這里面干嘛了呢?
- static inline void gotoxy(unsigned int new_x,unsigned int new_y) {
- ...
- x = new_x;
- y = new_y;
- pos = origin + y*video_size_row + (x<<1);
- }
就是給 x y pos 這三個參數附上了值。
其中 x 表示光標在哪一列,y 表示光標在哪一行,pos 表示根據列號和行號計算出來的內存指針,也就是往這個 pos 指向的地址處寫數據,就相當于往控制臺的 x 列 y 行處寫入字符了,簡單吧?
然后,當你按下鍵盤后,觸發鍵盤中斷,之后的程序調用鏈是這樣的。
- _keyboard_interrupt:
- ...
- call _do_tty_interrupt
- ...
- void do_tty_interrupt(int tty) {
- copy_to_cooked(tty_table+tty);
- }
- void copy_to_cooked(struct tty_struct * tty) {
- ...
- tty->write(tty);
- ...
- }
- // 控制臺時 tty 的 write 為 con_write 函數
- void con_write(struct tty_struct * tty) {
- ...
- __asm__("movb _attr,%%ah\n\t"
- "movw %%ax,%1\n\t"
- ::"a" (c),"m" (*(short *)pos)
- :"ax");
- pos += 2;
- x++;
- ...
- }
前面的過程不用管,我們看最后一個函數 con_write 中的關鍵代碼。
__asm__ 內聯匯編,就是把鍵盤輸入的字符 c 寫入pos 指針指向的內存,相當于往屏幕輸出了。
之后兩行 pos+=2 和 x++,就是調整所謂的光標。
你看,寫入一個字符,最底層,其實就是往內存的某處寫個數據,然后順便調整一下光標。
由此我們也可以看出,光標的本質,其實就是這里的 x y pos 這仨變量而已。
我們還可以做換行效果,當發現光標位置處于某一行的結尾時(這個應該很好算吧,我們都知道屏幕上一共有幾行幾列了),就把光標計算出一個新值,讓其處于下一行的開頭。
就一個小計算公式即可搞定,仍然在 con_write 源碼處有體現,就是判斷列號 x 是否大于了總列數。
- void con_write(struct tty_struct * tty) {
- ...
- if (x>=video_num_columns) {
- x -= video_num_columns;
- pos -= video_size_row;
- lf();
- }
- ...
- }
- static void lf(void) {
- if (y+1<bottom) {
- y++;
- pos += video_size_row;
- return;
- }
- ...
- }
相似的,我們還可以實現滾屏的效果,無非就是當檢測到光標已經出現在最后一行最后一列了,那就把每一行的字符,都復制到它上一行,其實就是算好哪些內存地址上的值,拷貝到哪些內存地址,就好了。
這里大家自己看源碼尋找。 所以,有了這個初始化工作,我們就可以利用這些信息,弄幾個小算法,實現各種我們常見控制臺的操作。
或者換句話說,我們見慣不怪的控制臺,回車、換行、刪除、滾屏、清屏等操作,其實底層都要實現相應的代碼的。 所以 console.c 中的其他方法就是做這個事的,我們就不展開每一個功能的方法體了,簡單看看有哪些方法。
- // 定位光標的
- static inline void gotoxy(unsigned int new_x, unsigned int new_y){}
- // 滾屏,即內容向上滾動一行
- static void scrup(void){}
- // 光標同列位置下移一行
- static void lf(int currcons){}
- // 光標回到第一列
- static void cr(void){}
- ...
- // 刪除一行
- static void delete_line(void){}
內容繁多,但沒什么難度,只要理解了基本原理即可了。
OK,整個 console.c 就講完了,要知道這個文件可是整個內核中代碼量最大的文件,可是功能特別單一,也都很簡單,主要是處理鍵盤各種不同的按鍵,需要寫好多 switch case 等語句,十分麻煩,我們這里就完全沒必要去展開了,就是個苦力活。 到這里,我們就正式講完了 tty_init 的作用。
在此之后,內核代碼就可以用它來方便地在控制臺輸出字符啦!這在之后內核想要在啟動過程中告訴用戶一些信息,以及后面內核完全建立起來之后,由用戶用 shell 進行操作時手動輸入命令,都是可以用到這里的代碼的! 讓我們繼續向前進發,看下一個被初始化的倒霉鬼是什么東東。 欲知后事如何,且聽下回分解。
本文轉載自微信公眾號「低并發編程」,可以通過以下二維碼關注。轉載本文請聯系低并發編程公眾號。本網站已獲得低并發編程的授權。