iT邦幫忙

2021 iThome 鐵人賽

DAY 26
2

本節是以 Golang 上游 7ee4c1665477c6cf574cb9128deaf9d00906c69f 為基準做的實驗

予焦啦!針對外部中斷的機制,我們從 RISC-V CPU 內部的控制暫存器看起,到達 PLIC 作為中斷控制器的用法,昨天也進行了若干實驗,因此回頭參考 PLIC 的規格書的話能夠有更加具體的了解。今天則是要再往更外部裝置的方向走去;以我們現在的目標而言,如果能夠看到輸出是還不錯,但如果能夠有個輸入輸出的命令列可用,那就更好了不是嗎?

本日重點概念

  • Golang
    • fmt.Println 函數的基本流程
  • Hoddarla/ethanol
    • Hello World!
  • UART
    • 基本操作
    • 基本初始化設定
    • 中斷過程展示

筆者沒有打算詳細介紹 UART 裝置的原理和概念。一來是筆者其實對此不熟,二來是網路上已經有太多優秀的解說,如 12、這次鐵人賽也有的3之類的。筆者猜測,隨著 ARM 也加入今年的主題,應該也會有其它相關的解說。

除了上面引用的幾篇 UART 機制教學文之外,筆者用來當作參考的是上面的 wikibook 連結。裡面有詳細的暫存器資訊,雖然和我們經手的 OpenSBI 使用的名稱有些微出入,但仍是極佳的參照。

打通 Hello World

我們先前已經實作過 exit 等執行緒收尾的函數,所以我們知道 ethanol/main.goHello Wrold! 訊息只是被跳過了;實際上,fmt.Println 函數本身有被執行到,否則 Golang 怎麼能夠結束 main 組件的 main 函數,擅自回到 runtime.main 函數之中收尾這個執行緒?所以現在的目標就是,我們現在必須追究,為什麼 fmt.Println 沒有產生效果。這對筆者來說不太直覺,因為我們在第二章當中已經打通 throw 之類的錯誤函數的輸出;當時是透過自行建立 runtime.write2 函數呼叫,連結到 OpenSBI 提供的字元輸出功能。為什麼 fmt.Println 沒有辦法直接接上那個部份呢?

要理解背後的原因,請讀者回顧一下筆者前年的鐵人賽文章:

這兩篇應該視作一個整體。作為一個 fmt 組件的函數,Println 只是指定了輸出管道為控制台(console)標準輸出(standard output)、在 Fprintln 函數之上又包裝一層的函數fmt 組件裡面提供給格式化輸出需求的真正核心函數,其實是 Fprintln

對於 Golang 的標準輸出的初始設定有興趣的讀者,可以往前回溯一篇,閱讀追蹤 os.Stdout 之篇章。

於是我們可以理解到這兩者(runtime.writefmt.Fprintln)之間的差異。前者是,runtime 組件在執行期的時候遇到輸出需求時使用的函數,類似的需求有我們已經走訪過的 throw 函數。也就是說,其實 runtime.write 不需要很複雜,只要能夠從標準錯誤(standard error)當中顯示出有意義的資訊即可。相對的,後者則是格式化輸出的核心函數,它需考量到很多不同的輸出場景(比方說控制台、I/O 裝置、檔案等等)的各式需求,然後特定情況還需要處理資料格式化。前者不能使用後者,因為 runtime 組件是 Golang 生命週期之始,所有後續的組件,當然包含 fmt,都需仰賴它完成的執行期初始化。但為何後者不能使用前者呢?這是筆者感到比較不直覺的部份,不過也許是考量到跨組件的相依性吧?

完全不知道標準輸出或標準錯誤、或是對於相關名詞感到懵懂的讀者,請參考維基百科

懶人包:從 fmt.Println 到印出訊息的部份

雖然前段已經提供詳盡的參考,但如果讀者想要懶人包的話,這裡直接提供。謎底是我們可以在 src/os/file_opensbi.gowrite 函數裡面埋個 panic 呼叫,

diff --git a/src/os/file_opensbi.go b/src/os/file_opensbi.go
index 2c9a194ec1..090adcd269 100644
--- a/src/os/file_opensbi.go
+++ b/src/os/file_opensbi.go
@@ -77,7 +77,9 @@ func (f *File) pread(b []byte, off int64) (n int, err error) {
 }
 
 func (f *File) write(b []byte) (n int, err error) {
-       return 0, nil
+       print(string(b))
+       panic("??")
+       return 13, nil
 }

編譯並執行,可以取得回溯訊息:

...
Hello World!
panic: ??

goroutine 1 [running]:
os.(*File).write(...)
        /home/noner/FOSS/hoddarla/ithome/go/src/os/file_opensbi.go:81
os.(*File).Write(0x0, {0xffffffcf04014020, 0xd, 0x10})
        /home/noner/FOSS/hoddarla/ithome/go/src/os/file.go:176 +0x98
fmt.Fprintln({0xffffffc0000b0620, 0x0}, {0xffffffcf04056f70, 0x1, 0x1})
        /home/noner/FOSS/hoddarla/ithome/go/src/fmt/print.go:265 +0x7c
fmt.Println(...)
        /home/noner/FOSS/hoddarla/ithome/go/src/fmt/print.go:274
main.main()
        /home/noner/FOSS/hoddarla/ithome/ethanol/main.go:6 +0x70

自從第一章解消了編譯過程找不到符號定義的問題之後,我們就沒有再重訪過 os 組件裡面的東西了。都在處理執行期的事情。

為什麼是埋 panic 而不是 throw?因為 panicprint 都是 Golang 讓開發者可以到處使用的函數,但 throwruntime 組件特定的。

也就是說,只要我們拔掉 panic,Hoddarla/ethanol 就算是正式 Hello World 了!這當然是一個里程碑。最後的細節是,回傳所印出的字元不能總是寫死 13 個。這個部分我們可以使用 len(string(b)),因為 Golang 的這些功能都接上了。相關部分已經更新在今天上傳的 Hoddarla repo

這裡引用了 print 函數搭配字元陣列到字串的處理,我們就成功地看到了 Hello World! 的輸出了。只是,這個透過 print 函數的輸出,其實還是透過 runtime.write,也就是利用 OpenSBI 使之輸出。至於到底是怎麼輸出的?答案就是昨日我們在觀察 PLIC 時看到的 UART 裝置。

UART:簡單模式

之所以說簡單模式,是因為顯然 OpenSBI 有一套方法可以駕馭 UART 裝置,但不是透過外部中斷的方法;否則的話,我們就不需要在先前幾天的實驗當中,使用 Ctrl+A X 組合鍵去強制關閉 QEMU,且隨意敲擊按鍵也沒有任何反應。所以我們先看看 OpenSBI 如何使用以及如何初始化 UART 裝置吧。

使用 UART

回顧一下我們在 runtime.write2 的作為,

// func write2(p uintptr)
TEXT runtime·write2(SB),NOSPLIT|NOFRAME,$0-8
        MOV     p+0(FP), A0
        LB      0(A0), A0
        MOV     $1, A7
        MOV     $0, A6
        ECALL
        RET

對於相關規格有興趣的讀者可以參考 SBI 規格書

A6=0A7=1 的組合,構成 legacy_console_putchar 的環境呼叫(evironment call,RISC-V 系統中的較低權限等級向較高權限等級索取服務時的呼叫)。當然,這會進到 OpenSBI 的陷阱向量,經過一些手續之後,最終會導到 lib/sbi/sbi_ecall_legacy.c(OpenSBI 的原始碼資料夾下)裡面的 sbi_ecall_legacy_handler。在主要的 switch 區塊內,進入

        case SBI_EXT_0_1_CONSOLE_PUTCHAR:
                sbi_putc(regs->a0);
                break;

sbi_putc 位在 lib/sbi/sbi_console.c 之中,

void sbi_putc(char ch)
{
        if (console_dev && console_dev->console_putc) {
                if (ch == '\n')
                        console_dev->console_putc('\r');
                console_dev->console_putc(ch);
        }
}

搜尋一下這個 console_putc 函數指標的話,可以看到有 5 組串列裝置(serial device)。其中 3 組看起來和特定的平臺有關,剩下兩個分別是 uart8250_putc,另一個則是 htif_putc。雖然這裡省略了具體的執行步驟,但筆者在 GDB 裡面設了這兩個斷點並觀察之後可以斷定,這裡的呼叫使用到的是前者。在 lib/utils/serial/uart8250.c 之中:

72      static void uart8250_putc(char ch)
73      {
74              while ((get_reg(UART_LSR_OFFSET) & UART_LSR_THRE) == 0)
75                      ;
76
77              set_reg(UART_THR_OFFSET, ch);
78      }

其中,LSRTHR 都是 UART 的暫存器。這個函數希望在 LSR 暫存器的 THRE 位元是 0 的時候卡住;通過了之後,才去將傳進來的字元寫到 THR 暫存器去。

LSR 是線路狀態暫存器(Line Status Register),它的 THRE 位元是發射器保留暫存器(Transmitter Holding Register)狀態是否餘有空間(Empty)的意思。如果這個位元真的是 0,就表示這裡我們就算想要寫入字元給 UART(正確來說是 THR,也就是發射器保留暫存器)請它輸出,它也沒有辦法做到。

set_reg 也有值得一提的部分:

static void set_reg(u32 num, u32 val)
{
        u32 offset = num << uart8250_reg_shift;

        if (uart8250_reg_width == 1)
                writeb(val, uart8250_base + offset);
        else if (uart8250_reg_width == 2)
                writew(val, uart8250_base + offset);
        else
                writel(val, uart8250_base + offset);
}

一開始,真正的偏移量(offset)計算是將傳入的暫存器號碼(num)做左移 uart8250_reg_shift 的運算。之後,再針對這個 UART 裝置的暫存器寬度(uart8250_reg_width)來決定,這個 I/O 的寫入動作是針對一個位元組(b 後綴)、雙字元(w 後綴)、還是一個字組(l 後綴)。

這些 write* 呼叫,就是 MMIO 暫存器寫入呼叫。除了最後對應到 sbsh 之類的寫入指令之外,還需要有前置的寫入屏障(write barrier),由 RISC-V 指令 fence w,o 確保,代表在真正進行這個輸出(o)之前,所有的記憶體寫入(w)行為都必須已經完成了。

uart8250_reg_* 這些量,又是怎麼決定的呢?這就不得不提 UART 的初始化。

初始化過程

同樣在 lib/utils/serial/fdt_serial_uart8250.c 裡面,函數 serial_uart8250_init

static int serial_uart8250_init(void *fdt, int nodeoff,
                                const struct fdt_match *match)
{
        int rc;
        struct platform_uart_data uart;

        rc = fdt_parse_uart8250_node(fdt, nodeoff, &uart);
        if (rc)
                return rc;

        return uart8250_init(uart.addr, uart.freq, uart.baud,
                             uart.reg_shift, uart.reg_io_width);
}

這裡有裝置樹剖析(parse),透過 fdt_parse_uart8250_node 來完成,這當然會比筆者在第二章當中只負責剖析記憶體節點與斷章中剖析核心參數的部份要正式許多。它透過傳入 platform_uart_data 結構指標,在該函數內完成大部份成員的賦值。然後將其中五個值傳入 uart8250_init 函數。最後兩個是我們在上一小節已經看過的暫存器偏移量與暫存器寬度,所以這裡先略過。第一個是則是 UART 本身在裝置樹中顯示的基底位址,QEMU 所使用的 UART 的話是在 0x10000000,可見:

                uart@10000000 {
                        interrupts = <0x0a>;
                        interrupt-parent = <0x09>;
                        clock-frequency = "\08@";
                        reg = <0x00 0x10000000 0x00 0x100>;
                        compatible = "ns16550a";
                };

無論是節點本身的示意或是 reg 屬性的第一組數字,都顯示了這個事實。至於第二與第三個值,分別代表頻率與鮑率(baud rate);這兩個值與 UART 運作時的時間特性有關,但筆者這裡不打算深入研究。使用 GDB 設法斷在 uart8250_init 呼叫之前,可以觀察到這幾個數字的值:

(gdb) p/x $a0
$1 = 0x10000000
(gdb) p/x $a1
$2 = 0x384000
(gdb) p/x $a2
$3 = 0x1c200
(gdb) p/x $a3
$4 = 0x0
(gdb) p/x $a4
$5 = 0x1

其中,uart.freq 就來自 clock-frequency 的怪字串 \08@,表示成四個位元組(加上結尾的 \0)的數字之後,恰好就是 0x00(第一個字元 \0)、0x38(字元 8)、0x40(字元 @)、結尾字元。

uart.bauduart.reg_shiftuart.reg_io_width 三個值都是來自 lib/utils/fdt/fdt_helper.c 裡面的定義,

 19 #define DEFAULT_UART_BAUD               115200
 20 #define DEFAULT_UART_REG_SHIFT          0
 21 #define DEFAULT_UART_REG_IO_WIDTH       1

uart8250_init 的初始化內容

這段顯得平鋪直述、與作業系統模式的設定較無關,且又尚未設置我們最感興趣的中斷。沒有興趣的讀者跳過亦無所謂。

這個函數在 lib/utils/serial/uart8250.c 當中。我們可以據此觀察 OpenSBI 所進行的初始化,順便對照先前的 UART 8250 詳解,學習 MMIO 暫存器名稱與功能。

        /* 停用所有的中斷 */
        set_reg(UART_IER_OFFSET, 0x00);

IER MMIO 暫存器全稱為 `Interrupt Enable Register,它的 4 個低位元都代表不同的中斷事件。OpenSBI 並沒有要使用中斷。

        bdiv = uart8250_in_freq / (16 * uart8250_baudrate);

        ...
        /* 啟用 DLAB(Divisor Latch Access Bit,除數鎖存器存取位元) */
        set_reg(UART_LCR_OFFSET, 0x80);

        if (bdiv) {
                /* Set divisor low byte */
                set_reg(UART_DLL_OFFSET, bdiv & 0xff);
                /* Set divisor high byte */
                set_reg(UART_DLM_OFFSET, (bdiv >> 8) & 0xff);
        }

先從 bdiv 說起,這個值是頻率除以鮑率再除以 16 的一個比值。要讓 UART 正確運作,需要將這個比值拆分為低位元組與高位元組,分別存入 DLL(代表 Divisor Latch Low Byte,除數鎖存器的低位元組)與 DLH(不知道為什麼 OpenSBI 這裡使用 DLM 來命名?總之是代表除數鎖存器的高位元組)。

這兩個 MMIO 暫存器的偏移量實際上與其它 MMIO 暫存器重疊,所以為了精確的控制,需要額外的一個開關,DLAB。這個控制位元位在 LCR(Line Control Register,線路控制暫存器)MMIO 暫存器裡面,僅有在它啟用時,才能夠寫入上述的 DLLDLH。至於為什麼除數與比值如此設計,筆者目前沒有興趣追究。

        /* 資料位元數為 8,沒有奇偶值檢查,1 個停止位元 */
        set_reg(UART_LCR_OFFSET, 0x03);

LCR 線路控制暫存器設置這些其他的 UART 屬性。資料位元可以設置 5 到 8 的範圍,奇偶值也有不同模式可以設定。停止位元則是可以指定 1 或 2 個。

DLAB 位元也因此被清空了,所以重疊範圍的 MMIO 暫存器因此可以正常使用了。

        /* 啟用 FIFO */
        set_reg(UART_FCR_OFFSET, 0x01);

FCR 是 FIFO 控制暫存器。FIFO 是 8250 之後的 UART 較常採用的內部機制,它可以容納多個位元組存在,並以先進先出的模式讀寫。當然,這個暫存器有其它位元可以控制 FIFO 行為,在此先略過了。

        /* 無須控制數據機 DTR RTS */
        set_reg(UART_MCR_OFFSET, 0x00);

MCR 是數據機控制暫存器,大致上的場合應該都是不需要的狀態,筆者就略過了。

        /* 清除線路狀態 */
        get_reg(UART_LSR_OFFSET);
        /* 讀取接收緩衝區 */
        get_reg(UART_RBR_OFFSET);
        /* 設置暫用空間 */
        set_reg(UART_SCR_OFFSET, 0x00);

LSR 在前面的印出字元函數內觀察過了,是代表線路狀態暫存器;不確定為什麼這裡使用 get_reg 函數?前述的 UART 使用指南裡面並沒有提到這個暫存器有讀取附帶清空效果的說明,而且每個位元展示的行為也不同。

RBR 是接收緩衝區暫存器(Recieve Buffer Register)。SCR 是暫用空間暫存器(SCratch Register),同我們前幾天介紹的 RISC-V scratch 系列控制暫存器一樣,可用作暫存空間。

如此的設置之後,OpenSBI 的部份就算結束了。但這不可能就是 UART 正常使用的方式的全部。目前為止,Hoddarla 都還沒有辦法接收 UART 的輸入,但 debian 虛擬機卻可以做到。至少輸入,應該要給我們一些中斷來用吧?不然將它接到 PLIC 去又有什麼意義呢?

UART:中斷與輸入

由於筆者不知道怎麼除錯先前使用的 debian 映像,所以決定自己簡單的編 Linux 來用。懶人包(拉取 Linux 核心會非常久):

git clone git://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git
cd linux
make ARCH=riscv defconfig
make ARCH=riscv menuconfig # 將 Kernel Hacking 裡面的 Debug Info 勾選起來,儲存
make ARCH=riscv CROSS_COMPILE=riscv64-buildroot-linux-musl- -j12 # 假設先前的工具鏈路徑已經在環境變數 PATH 中
...
Kernel: arch/riscv/boot/Image.gz is ready # 成功之後會顯示這個

直接拿來試跑的指令:

qemu-system-riscv64 \
    -smp 4 \
    -M virt \
    -m 256M \
    -nographic \
    -kernel ./arch/riscv/boot/Image
...
[    0.317606] Serial: 8250/16550 driver, 4 ports, IRQ sharing disabled
[    0.329343] 10000000.uart: ttyS0 at MMIO 0x10000000 (irq = 2, base_baud = 230400) is a 16550A
...

當然最後會遇到錯誤,因為我們沒有給它能用的使用者空間,但這不是重點。重點是,我們看到 UART 相關的部份出現在 Linux 的啟動訊息內了。檢索一番之後,可以發現這些訊息在 tty/serial/8250/8250_core.c 裡的 serial8250_init 函數。不如就開個 GDB,從那裡開始觀察吧:

... # 鎖定虛擬記憶體開啟之時刻
   0x8020103c:  sfence.vma
   0x80201040:  csrw    satp,a0
...
Continuing.
[Switching to Thread 1.2]

Thread 2 hit Breakpoint 1, 0x0000000080201040 in ?? ()
(gdb) d 1
(gdb) si
0x0000000080201044 in ?? ()
(gdb) set $pc=0xffffffff80001044
(gdb) si
0xffffffff80001048 in ?? ()
(gdb) add-symbol-file ../../aur/linux/vmlinux
add symbol table from file "../../aur/linux/vmlinux"
(y or n) y
(gdb) b serial8250_init
Cannot access memory at address 0xffffffff8081a2d0
(gdb)

但是,Linux 的早期分頁做得比較多層次一點,這時候還是沒有辦法直接設置斷點於我們想要觀察的函數。無奈,只能多推進一點,

_start_kernel () at arch/riscv/kernel/head.S:327
327             call soc_early_init
(gdb) 
328             tail start_kernel
(gdb) b serial8250_init
Breakpoint 2 at 0xffffffff8081a2d0: file drivers/tty/serial/8250/8250_core.c, line 1161.

這時候也可以簡單確認一下 PLIC 的狀態,完全沒有啟用任何來自其它裝置的中斷:

(gdb) p/x $satp
$1 = 0x8000000000080c04
(gdb) set $satp=0x0
(gdb) x/wx 0xc002080
0xc002080:      0x00000000
(gdb) x/wx 0xc002180
0xc002180:      0x00000000
(gdb) x/wx 0xc002280
0xc002280:      0x00000000
(gdb) x/wx 0xc002380
0xc002380:      0x00000000
(gdb) set $satp=$1

幸好,n 個幾次之後,Linux 應該就已經設置完成了對應的頁表,所以我們可以斷在這裡。但就算整個 serial8250_init 走完,也看不到 UART 的狀態有被設置得與 OpenSBI 有何不同。話說回來,筆者還沒有展示過怎麼從 GDB 觀察 UART 的這些 MMIO 暫存器,但概念與上述偷看 PLIC 類似:

(gdb) p/x $satp
$1 = 0x80000000000814ee
(gdb) set $satp=0x0
(gdb) x/8bx 0x10000000
0x10000000:     0x00    0x00    0xc1    0x03    0x00    0x60    0xb0    0x00

一樣是沒有中斷設置的狀態:偏移量 1 的值(第二組)是 0x00。那又該去哪裡找 UART 中斷開啟的地方呢?

這尋寶的過程是最沒有頭緒的部份,但有一種開放世界的探索感。總之得用上各種線索,也需善用模式比對工具如 grep

用中斷啟用暫存器,也就是 IER 當作關鍵字去 driver/serial 底下亂搜一通,在 ./drivers/tty/serial/8250/8250.h 看到了這個函數:

135 static inline bool serial8250_set_THRI(struct uart_8250_port *up)
136 {
137         if (up->ier & UART_IER_THRI)
138                 return false;
139         up->ier |= UART_IER_THRI;                                                                                                   
140         serial_out(up, UART_IER, up->ier);                                                                                          
141         return true;                                                                                                                
142 }

字面上的意思就像是要設置傳輸暫存器(Transmition Holder Register)的中斷,並且透過 serial_out 去寫入 MMIO 暫存器的感覺。確實,UART_IER 是定義為 1 的巨集,代表偏移量;UART_IER_THRI 代表 2,對照一下也沒錯,就是 IER 內對應到傳輸暫存器的中斷位元。所以我們往 serial_out 這個裡面看去,

118 static inline void serial_out(struct uart_8250_port *up, int offset, int value)
119 {
120         up->port.serial_out(&up->port, offset, value);
121 }

出現函數指標時最懶人的追蹤方法,果然還是回到 GDB 然後設斷點下去看會跳到哪裡。直接公佈答案,是 mem_serial_out 函數:

 407 static void mem_serial_out(struct uart_port *p, int offset, int value)
 408 {
 409         offset = offset << p->regshift;
 410         writeb(value, p->membase + offset);
 411 }

如果以 mem_serial_out 為斷點觀察,會發現 Linux 這裡在做串列裝置探測(probe)的時候,還是會重設 DLLDLH

這個 writeb 函數是一個 I/O 界面。在 RISC-V 的實作的話,真正執行寫入單一位元組指令(sb)之前,有一個屏障指令 fence w,o,代表所有的記憶體寫入效應必須先於之後的任何輸出效應完成。總之,只要持續觀察這個 mem_serial_out 函數,就可以監控到 Linux 對 UART 的控制了。當然也可以從回溯堆疊觀察是怎麼呼叫進來的,但訊息量不大,所以筆者也是將之略過。

設法收斂的除錯招式

在 QEMU 端使用 -append earlycon=sbi console=ttyS0 參數的話,有大半 Linux 的早期開機訊息就都會是經由 OpenSBI 去印出的。但是接下來,如果斷點設在 mem_serial_out,會因為兩種狀況而觸發太多次:

  1. 直接印出,也就是對於偏移量 0 位置寫入一個位元組的資料的作法。
  2. 換行,每次換行都會對偏移量 1 做清 0 的動作。

所以最後筆者在 GDB 的除錯條件是這樣下的:

riscv64-elf-gdb -ex 'target remote :1234'
    -ex 'b *0x0000000080201040'       # 斷在啟用虛擬記憶體之前
    -ex "c" -ex "d 1" -ex "si"        # 刪除實體記憶體斷點
    -ex "set \$pc=0xffffffff80001044" # 推進之後直接移動程式指標
    -ex "add-symbol-file ../../aur/linux/vmlinux" 
    -ex "si 6" -ex "n 6"              # 需要等到正式的頁表建立,龜毛的 Linux ...
    -ex 'b mem_serial_out if $a1 != 0 && ($a1 != 1 || $a2 != 0)'
                                      # 迴避上述兩種狀況

這麼一來,會停下來的條件大部份是對偏移量 4 的控制(MCR,數據機控制暫存器),也終於可以抓到第一次中斷設置。

第一次中斷設置時的狀況

Thread 4 hit Breakpoint 2, mem_serial_out (p=0xffffffff81324010 <serial8250_ports>, offset=1, value=2)
    at drivers/tty/serial/8250/8250_port.c:409
409             offset = offset << p->regshift;
(gdb) bt
#0  mem_serial_out (p=0xffffffff81324010 <serial8250_ports>, offset=1, value=2) at drivers/tty/serial/8250/8250_port.c:409
#1  0xffffffff80356248 in serial_port_out_sync (offset=1, value=2, p=0xffffffff81324010 <serial8250_ports>)
    at drivers/tty/serial/8250/8250_port.c:524
#2  serial_port_out_sync (p=p@entry=0xffffffff81324010 <serial8250_ports>, value=2, offset=1)
    at drivers/tty/serial/8250/8250_port.c:516
#3  0xffffffff803576e0 in serial8250_do_startup (port=0xffffffff81324010 <serial8250_ports>)
    at drivers/tty/serial/8250/8250_port.c:2307

value 的 2 代表設置傳輸暫存器變成空的的時候的中斷。但追蹤 serial8250_do_startup 的相關內容,又似乎是在測試 UART 而已。之後偶爾設成 5(兩個位元都與接收暫存器有關),但筆者發現都沒有真正觸發外部中斷。

設置使用者空間

先前我們都看不到外部中斷,可能是因為 Linux 自己印出系統訊息的時候,都會希望不要干擾到串列裝置的狀態,大部分都是以直接寫到 THR 的方式去輸出。這個直接輸出的前後會存取 IER,也就是中斷啟用暫存器的狀態,並做相對應的儲存與回復動作。所以就算我們觀測到一直有針對 IER 的寫入,可是實際上還是都沒有任何觸發的中斷。

這只是筆者的猜測,至少從實驗看起來是如此。也許實際上理解有誤,但不影響接下來的實驗的正確性。

所以,還是乖乖把使用者空間創起來吧。參考以前參賽時留下的紀錄,busybox 部份還是完全可以參照使用(連附上的 inittab 也完全可以續用),然後在 Linux 資料夾下調整一下 .config 檔案,新增組態:

CONFIG_INITRAMFS_SOURCE="rootfs"

之後再回到 Linux 資料夾下 make ARCH=riscv 重編,就可以拿來開進 busybox 的使用者空間了。我們可以使用 GDB 先無條件放行整個系統,等到進入命令列之後,再試著下斷點在 mem_serial_out/in 等兩個函數,觀察看看 UART 有哪些動態。

閒置時,對於 UART MMIO 暫存器,完全沒有讀寫的動態。正好可以試一個實驗,在 QEMU 端隨意敲擊一次輸入鍵,我們來分析之後發生的每一件事情。記得,這時候的斷點只有上述兩個函數。

讀取中斷識別暫存器 IIR

這時候的回溯堆疊為

Thread 2 hit Breakpoint 1, 0xffffffff803554ae in __raw_readb (addr=<optimized out>) at ./arch/riscv/include/asm/mmio.h:49
49              asm volatile("lb %0, 0(%1)" : "=r" (val) : "r" (addr));
(gdb) bt
#0  0xffffffff803554ae in __raw_readb (addr=<optimized out>) at ./arch/riscv/include/asm/mmio.h:49
#1  mem_serial_in (p=<optimized out>, offset=<optimized out>) at drivers/tty/serial/8250/8250_port.c:404
#2  0xffffffff80357cd0 in serial_port_in (offset=2, up=0xffffffff81324010 <serial8250_ports>) at ./include/linux/serial_core.h:263
#3  serial8250_default_handle_irq (port=0xffffffff81324010 <serial8250_ports>) at drivers/tty/serial/8250/8250_port.c:1948
#4  0xffffffff80354456 in serial8250_interrupt (irq=<optimized out>, dev_id=0xffffffe00185bbc0)
    at drivers/tty/serial/8250/8250_core.c:126
#5  0xffffffff8005309c in __handle_irq_event_percpu (desc=desc@entry=0xffffffe00164a600, flags=flags@entry=0xffffffff81203cc4)
    at kernel/irq/handle.c:156
#6  0xffffffff80053230 in handle_irq_event_percpu (desc=0xffffffe00164a600) at kernel/irq/handle.c:196
#7  handle_irq_event (desc=desc@entry=0xffffffe00164a600) at kernel/irq/handle.c:213
#8  0xffffffff80056c2e in handle_fasteoi_irq (desc=0xffffffe00164a600) at kernel/irq/chip.c:717
#9  0xffffffff80052684 in generic_handle_irq_desc (desc=<optimized out>) at ./include/linux/irqdesc.h:158
#10 handle_irq_desc (desc=<optimized out>) at kernel/irq/irqdesc.c:646
#11 generic_handle_domain_irq (domain=<optimized out>, hwirq=<optimized out>) at kernel/irq/irqdesc.c:675
#12 0xffffffff802f1114 in plic_handle_irq (desc=0xffffffe00160b200) at drivers/irqchip/irq-sifive-plic.c:236
#13 0xffffffff80052a10 in generic_handle_irq_desc (desc=<optimized out>) at ./include/linux/irqdesc.h:158
#14 handle_irq_desc (desc=<optimized out>) at kernel/irq/irqdesc.c:646
#15 handle_domain_irq (domain=<optimized out>, hwirq=<optimized out>, regs=<optimized out>) at kernel/irq/irqdesc.c:701
#16 0xffffffff802f0f0a in riscv_intc_irq (regs=<optimized out>) at drivers/irqchip/irq-riscv-intc.c:40
#17 0xffffffff8000304c in handle_exception () at arch/riscv/kernel/entry.S:232

可見我們從 Linux 的陷阱向量進入 PLIC 的處理函數,然後又導到 serial8250_interrupt 去。然後我們讀取偏移為 2 的中斷識別暫存器,這會顯示哪個中斷是這次觸發的原因。

這個讀取相關的資訊為

(gdb) p/x $a0
$1 = 0xffffffd000245002
(gdb) si
0xffffffff803554b2      49              asm volatile("lb %0, 0(%1)" : "=r" (val) : "r" (addr));
(gdb) p/x $a0
$2 = 0xffffffffffffffcc

第一個位址之所以是一個看起來很正常的虛擬位址,而不是 0x10000002 這個 UART MMIO 真正的物理位址,是因為在作業系統模式運行的 Linux 也沒辦法直接存取物理位址。在稍早初始化時,就已經建立好這個虛擬位址對應到 UART 去了。

讀取回的值這裡為 0xcc,對應到維基的解釋,是一個過期的中斷擱置(Time-out Interrupt Pending)。

讀取線路狀態暫存器 LSR

讀回來的值顯示 0x61,分別代表

  • 空閒的接收緩衝區
  • 空閒的傳輸緩衝區
  • 有資料已就緒:這一項會促使 UART 中斷處理進行讀取。

讀取接收暫存器 RBR

這裡就真的將剛才的輸入鍵讀出來了,得到的值是 0xd,也就是 \r 字元。

讀取線路狀態暫存器 LSR,再來一次

這次就觀察不到資料就緒了,顯示 0x60

讀取數據機控制暫存器 MCR

讀取得 0xb0,代表

  • Carrier Detect
  • Data Set Ready
  • Clear to Send

這其實與前面小節看到的狀態一樣。

讀取中斷識別暫存器 IIR,再讀一次

這次得到的是 0xc1。最低位元代表中斷擱置位元,但語意上是前述 0xcc 為 0 時代表有中斷擱置,而現在為 1 代表中斷已處理。

寫入中斷識別暫存器 IER:寫入值為 7

值得一提的是,這裡已經不是在前述的回溯堆疊,也就是 PLIC 中斷處理或是 UART 中斷處理函數之內了。因為後續處理的行為,不應該佔用被中斷的系統太久時間,所以是將相關資訊存下來,等到系統有空之後再慢慢去處理就好。

重新啟用中斷。

讀取中斷識別暫存器 IIR,再讀一次

這次得到的是 0xc2,代表的是傳輸暫存器空了所發生的中斷。

讀取線路狀態暫存器 LSR,再來一次

這次也還是沒有資料就緒,顯示 0x60

讀取數據機控制暫存器 MCR

還是一樣 0xb0

寫入中斷識別暫存器 IER:寫入值為 5

重新啟用中斷。

讀取中斷識別暫存器 IIR,再讀一次

這次得到的是 0xc1,也是沒事。

寫入中斷識別暫存器 IER:寫入值為 7

重新啟用中斷。

讀取中斷識別暫存器 IIR,再讀一次

這次得到的是 0xc2,代表的是傳輸暫存器空了所發生的中斷。

讀取線路狀態暫存器 LSR,再來一次

這次也還是沒有資料就緒,顯示 0x60

讀取數據機控制暫存器 MCR

還是一樣 0xb0

寫入輸出暫存器 THR:寫入值為 \r,終於!

紀錄一下這時的回溯堆疊:

#0  0xffffffff8035641a in __raw_writeb (addr=<optimized out>, val=13 '\r') at ./arch/riscv/include/asm/mmio.h:21
#1  mem_serial_out (p=0xffffffff81324010 <serial8250_ports>, offset=<optimized out>, value=13)
    at drivers/tty/serial/8250/8250_port.c:410
#2  0xffffffff80357a76 in serial_out (value=<optimized out>, offset=0, up=0xffffffff81324010 <serial8250_ports>)
    at drivers/tty/serial/8250/8250.h:120
#3  serial8250_tx_chars (up=up@entry=0xffffffff81324010 <serial8250_ports>) at drivers/tty/serial/8250/8250_port.c:1818
#4  0xffffffff80357c9e in serial8250_handle_irq (port=port@entry=0xffffffff81324010 <serial8250_ports>, iir=<optimized out>)
    at drivers/tty/serial/8250/8250_port.c:1932
#5  0xffffffff80357cec in serial8250_handle_irq (iir=<optimized out>, port=0xffffffff81324010 <serial8250_ports>)
    at drivers/tty/serial/8250/8250_port.c:1952
#6  serial8250_default_handle_irq (port=0xffffffff81324010 <serial8250_ports>) at drivers/tty/serial/8250/8250_port.c:1949
#7  0xffffffff80354456 in serial8250_interrupt (irq=<optimized out>, dev_id=0xffffffe00185bbc0)
    at drivers/tty/serial/8250/8250_core.c:126
#8  0xffffffff8005309c in __handle_irq_event_percpu (desc=desc@entry=0xffffffe00164a600, flags=flags@entry=0xffffffff81203cc4)
    at kernel/irq/handle.c:156
#9  0xffffffff80053230 in handle_irq_event_percpu (desc=0xffffffe00164a600) at kernel/irq/handle.c:196
#10 handle_irq_event (desc=desc@entry=0xffffffe00164a600) at kernel/irq/handle.c:213
#11 0xffffffff80056c2e in handle_fasteoi_irq (desc=0xffffffe00164a600) at kernel/irq/chip.c:717
#12 0xffffffff80052684 in generic_handle_irq_desc (desc=<optimized out>) at ./include/linux/irqdesc.h:158
#13 handle_irq_desc (desc=<optimized out>) at kernel/irq/irqdesc.c:646
#14 generic_handle_domain_irq (domain=<optimized out>, hwirq=<optimized out>) at kernel/irq/irqdesc.c:675
#15 0xffffffff802f1114 in plic_handle_irq (desc=0xffffffe00160b200) at drivers/irqchip/irq-sifive-plic.c:236
#16 0xffffffff80052a10 in generic_handle_irq_desc (desc=<optimized out>) at ./include/linux/irqdesc.h:158
#17 handle_irq_desc (desc=<optimized out>) at kernel/irq/irqdesc.c:646
#18 handle_domain_irq (domain=<optimized out>, hwirq=<optimized out>, regs=<optimized out>) at kernel/irq/irqdesc.c:701
#19 0xffffffff802f0f0a in riscv_intc_irq (regs=<optimized out>) at drivers/irqchip/irq-riscv-intc.c:40
#20 0xffffffff8000304c in handle_exception () at arch/riscv/kernel/entry.S:232

為何說終於呢?因為先前的所有 UART 處理,都還只是作業系統在維護 UART 的正確狀態,但這個輸入的字元的效應,卻一直沒有真正出現,直到此時。至此,我們已經有足夠的範本可以臨摹了。

小結

予焦啦!今天輕描淡寫的讓我們先前一直惦記的 Hello World 訊息印出來了,但這相比於 UART 的學習歷程,實在是輕如鴻毛。我們有著更遠大的目標,所以才從 Linux 控制 Serial/UART 串列裝置的驅動程式裡面偷學幾招。過程中一度陷入沒辦法做實驗的窘境,但也只是印證了筆者對於 Linux 系統的瞭解仍然不夠透徹。

明日,我們就將迎來 Hoddarla 技術部份在這次鐵人賽的結尾。各位讀者,我們明天再會!


上一篇
予焦啦!RISC-V 外部中斷機制
下一篇
予焦啦!基本的命令列
系列文
予焦啦!Hoddarla 專案起步:使用 Golang 撰寫 RISC-V 作業系統的初步探索33
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言