自制一個 Linux 作業系統(5) — Output (Memory-mapped I/O)

YC
9 min readMar 19, 2020

--

這系列的文章會記錄我嘗試做出一個 Linux OS kernel的過程,要主是依照 https://littleosbook.github.io/ 這系列的文章,加上自已踩下的坑,希望能幫助同樣有做一個作業系統夢想的各位。

來到第四章,我們開始學習如何讓 OS 跟硬體溝通。

通常 kernel 會以兩種方法來跟硬體作互動 :
1. memory-mapped I/O (記憶體對映輸入輸出)
2. I/O ports( 又叫 port-mapped I/O )

如果硬體使用的是 memory-mapped I/O 的話,我們可以把 data 寫到一個特別的記憶體位置,那硬體將會使用這個記憶體位置的資料,其中一個我們會用到的例子是使用 framebuffer
如果硬體使用的是I/O ports 的話,那我們可以以 assembly 的 outin 來跟硬體溝通。out 會需要到兩個參數 : 分別為 I/O port 的位置與要傳送的 data。in 則只需要用到 I/O port 位置這個參數,並回傳從硬體得到的 data。( 在螢幕上閃爍的游標就是硬體通過 I/O ports 控制 PC 的一個例子。)

接著,我們會實作在螢幕上顯示文字。

在 Linux 上的顯示事件主要是依賴於 framebuffer 來完成。framebuffer 是一個硬體裝置,它主要用於顯示記憶體中的視訊資料到螢幕上。( 詳細可參考 wiki: framebuffer )

在電腦上通過 framebuffer 顯示文字是以 memory-mapped I/O 方式完成的。它的 memory-mapped I/O 起始記憶體位置為 0x000B8000

這個記憶體以16位元方式儲存,不同位元分別儲存不同資訊 :

Bit:     | 15 14 13 12 11 10 9 8 | 7 6 5 4 | 3 2 1 0 |
Content: | ASCII | FG | BG |

1. bit 0-3 : 以 4 個 bit 定義背景顏色
2. bit 4-7: 以 4 個 bit 定義前景顏色
3. bit 8-15: 以 8 個 bit 定義 ASCII 的字符。

這是原文中提供的色彩表與其對應的值。

假設我們現在要於 (0,0) 坐標中顯示 “A” 這個字符 (65 / 0x41),並有綠色前景 (2) 與深灰色背景 (8), 在 16 bits 中的表示為 0x4128。而 Assembly 是 :

mov [0x000B8000], 0x4128

那如果是 (0,1) 坐標的話,就是 :

0x000B8000 + 16 = 0x000B8010

我們可以用 C 來對 framebuffer 進行操作,對 0x000B8000 的記憶體加上我們需要的值 :

char *fb = (char *) 0x000B8000; // 給字符指標指到0x000B8000
fb[0] = 'A'; // 把A(0x41)放到左邊 8 bit
fb[1] = 0x28; // 把綠色與深灰色放到右邊 8 bit

以下是把整個寫入 framebuffer 包起來的 function :

/** fb_write_cell:
* Writes a character with the given foreground and background to
* position i

* in the framebuffer.
*
* @param i The location in the framebuffer
* @param c The character
* @param fg The foreground color
* @param bg The background color
*/
void fb_write_cell(unsigned int i, char c, unsigned char fg, unsigned char bg)
{
char *fb = (char *) 0x000B8000;
fb[i] = c;
fb[i + 1] = ((fg & 0x0F) << 4) | (bg & 0x0F);
}
#define FB_GREEN 2
#define FB_DARK_GREY 8
fb_write_cell(0, 'A', FB_GREEN, FB_DARK_GREY);

((fg & 0x0F) << 4) 是對 fg0x0F 的值進行比較,再往後移 4 bit。即 :

  1. 0000 0010 & 0000 1111 = 0000 0010 ( 這樣可以清理左 4 bit 的值,且保留右 4bit 的值。)
  2. 0000 0010 << 4 = 0010 0000 ( 把值往左移 4 bit)
  3. (bg & 0x0F) = 0000 1000 & 0000 1111 = 0000 1000
  4. ((fg & 0x0F) << 4) | (bg & 0x0F) = 0010 0000 | 0000 1000 (即保留所有有值的 bit )
  5. 0010 0000 | 0000 1000 = 0010 1000 = 0x28 (hex)= 40(dec)

所以,當左邊 8 bit 的 0x41 跟右邊 8 bit 的 0x28合並起來,就會是 0x4128,也就是我們想要顯示於 framebuffer 的 前綠後黑的 “A”。

接著,我們會了解一下如何移動游標。

在 framebuffer 中,移動游標需要用到它的分別二個 I/O ports。
游標的位置是以 16 bit 的 int 來決定的,如 0 代表 (0,0)、1 代表 (0,1)、80 代表 (1,0)。( 因為 framebuffer 的總大小是 25 個 row,80 個 column,兩者都以0開始算,所以每81個 int 就會用完一個 column ,並進位一個 row。)
因為 16 bit 對 assembly 的 out 的位置參數來說超出大小,所以我們要分開兩次傳送位置,分別是頭 8 bit 再到後 8 bit。

framebuffer 的兩個 I/O ports,0x3D5一個是接收 data ,0x3D4一個是描述接收的 data 。

以下是 assembly 以位置 80 為例 :

out 0x3D4, 14      ; 14 告知 framebuffer 接下來要用頭 8 bit 的位置
out 0x3D5, 0x00 ; 傳 0x0050 到頭 8 bit 到 0x3D5
out 0x3D4, 15 ; 15 告知 framebuffer 接下來要用後 8 bit 的位置
out 0x3D5, 0x50 ; 傳 0x0050 到後 8 bit 到 0x3D5

因為 assembly 的 out 不能直接由 C 來使用,所以我們最好把整個 assembly 包成一個 function,並開放出來 label 給 C 來 call (使用 cdecl)。

global outb          ; make the label outb visible outside this file; outb - send a byte to an I/O port
; stack: [esp + 8] the data byte
; [esp + 4] the I/O port
; [esp ] return address
outb:
mov al, [esp + 8] ; move the data to be sent into the al
register

mov dx, [esp + 4] ; move the address of the I/O port into the
dx register

out dx, al ; send the data to the I/O port
ret ; return to the calling function
  1. global outb : 把outb 這 function 開放給外部使用。
  2. mov al, [esp + 8] : 把 data 傳到 AL register,而 AL register 是 AX register 的一個細分,用於存儲後 8 bit 的資訊。相對的,還有 AH register,用於存儲前 8 bit 的資訊。AX register 是一個累加器 (accumulator) ,作用是註冊大部分的操作。
  3. [esp + 8] : 因為 esp 指向記憶體堆疊的最上層 ( the top of memory address’s stack),而堆疊是由上往下成長的,所以 [esp + 8]是指 esp 的指標往下移 8 byte。
  4. mov dx, [esp + 4] : 把 I/O port 的位置傳到 DX register。DX register 是一個 data register,主要用於乘除。
  5. 這邊會用到 [esp + 8][esp + 4] 是因為我們在 subprogram 時會帶入多個參數,而在參數的傳遞上,cdecl (calling convention) 規定 function 中的參數是以反向傳入 stack 中,又因為我們將會傳入的參數都只有 4 byte ,因此,第一個參數位於 [esp + 4],第二個參數位於[esp + 8]
  6. out dx, al : 最後把 AL 的數據傳到 I/O port。

我們先把上面的 function 存到 io.s,並再創建一個 header io.h :

#ifndef INCLUDE_IO_H
#define INCLUDE_IO_H
/** outb:
* Sends the given data to the given I/O port. Defined in io.s
*
* @param port The I/O port to send the data to
* @param data The data to send to the I/O port
*/
void outb(unsigned short port, unsigned char data);
#endif /* INCLUDE_IO_H */

以下是把移動游標包成 C function :

#include "io.h"/* The I/O ports */
#define FB_COMMAND_PORT 0x3D4
#define FB_DATA_PORT 0x3D5
/* The I/O port commands */
#define FB_HIGH_BYTE_COMMAND 14
#define FB_LOW_BYTE_COMMAND 15
/** fb_move_cursor:
* Moves the cursor of the framebuffer to the given position
*
* @param pos The new position of the cursor
*/

void fb_move_cursor(unsigned short pos)
{
outb(FB_COMMAND_PORT, FB_HIGH_BYTE_COMMAND);
outb(FB_DATA_PORT, ((pos >> 8) & 0x00FF));
outb(FB_COMMAND_PORT, FB_LOW_BYTE_COMMAND);
outb(FB_DATA_PORT, pos & 0x00FF);

到這邊我們先已經把移動游標從 Assembly 轉到 C 來操作。

下一章,我們繼續來了解第四章剩下的部分 — Serial Port :
自制一個 Linux 作業系統(6) — Output (Serial Port)

如果你覺得我的文章幫助到你,希望你也可以化讚為賞,加入 Liker ,再按下方的綠色拍手按鈕,為文章點讚!為作者增加收益,再回饋更多好文章!

--

--

YC
YC

Written by YC

提供更精確的技術內容為目標,另創立「程式愛好者」專頁。首席軟體工程師,專研後端技術、物件導向、軟體架構。

No responses yet