予焦啦!Hello World 与 Uart 机制观察

本节是以 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 技术部份在这次铁人赛的结尾。各位读者,我们明天再会!


<<:  初探网路安全(一):密码大小事,存在服务器的密码安全吗?

>>:  【Day20】电子商务与行销篇-UTMs

DAY7:Kaggle-San Francisco Crime Classification(下)

资料视觉化 这边我们会用到seaborn来做一下简单的资料视觉化。 import seaborn ...

Day-2 Excel出现#字号!难道是中毒了吗!?

你是不是常常在编辑试算表时遇到”####”呢?别紧张,其实出现这个符号并不是你输入错误喔,而是你的栏...

利用大数据分析预测MLB胜负(中)

在上一篇文章中,我们介绍作者如何分析MLB赛事,并找出影响比赛胜负较为重要的因子,而今天我们就来看看...

【程序】我使用 Git 的心态转变 转生成恶役菜鸟工程师避免 Bad End 的 30 件事 - 3

吼气鼠,萤幕没录到我打的文字哈哈~ 补充在这边~ When I was young. -> ...

Day_14 Router/Switch/Gateway/NAT

前面几天连续介绍有线与无线的应用,多数家中或单位的网路就都从这些应用做拓展。让透天每层楼都有网路、w...