iT邦幫忙

2021 iThome 鐵人賽

DAY 20
1

予焦啦!正如 Golang 自己維護了記憶體管理機制(競技場、記憶體抽象層、垃圾回收、...)般,讓 ethanol 核心取用 RISC-V 硬體功能的部分可以借殼上市。現在我們在探討中斷的話,就很難不聯想到使用者空間裡面,能夠類比於系統中斷這種非同步行為的訊號(POSIX signal)機制了。

所以今天,我們來觀察看看 Golang 的訊號處理機制,作為之後的參考用。

本節重點概念

  • Golang
    • 訊號使用範例
  • 雜項
    • Debian/riscv64 環境架設
    • 訊號簡介

Debian/riscv64 環境架設

為了觀察 Golang 的訊號機制,當然可以在 x86 主機上面進行一樣的實驗,但有鑑於本系列主打 RISC-V,且系列行進至此還沒有一個穩定的 Linux 可以參照,這裡就順便架設一個。

以 Debian 發行版為例,並沒有什麼特別理由,只是先前筆者個人經驗已經玩過 Fedora,這次體驗個不一樣的。若要按部就班從頭 bootstrap 當然還是得費一番工夫,但是都有現成的便宜可以撿,待後詳述。

下載 DQIB(Debian Quick Image Backer)預編映像

若是讀者有興趣瀏覽 Debian 對 RISC-V 的支援的話,可以連結到官方頁面去。但這裡我們直接下載預先編譯完成、可用於 QEMU 的映像檔於 DQIB 頁面的 Images for riscv64-virt。

下載完成後的檔案名稱是 artifacts.zip,可以在命令列解壓縮:

$ unzip artifacts.zip
Archive:  artifacts.zip
   creating: artifacts/
  inflating: artifacts/image.qcow2 
  inflating: artifacts/initrd 
  inflating: artifacts/kernel
  inflating: artifacts/readme.txt    
  inflating: artifacts/ssh_user_ecdsa_key  
  inflating: artifacts/ssh_user_ed25519_key  
  inflating: artifacts/ssh_user_rsa_key

readme.txt 當中介紹了用法:

qemu-system-riscv64 \
    -machine virt \
    -cpu rv64 -m 1G \
    -device virtio-blk-device,drive=hd \
    -drive file=image.qcow2,if=none,id=hd \
    -device virtio-net-device,netdev=net \
    -netdev user,id=net,hostfwd=tcp::2222-:22 \
    -bios /usr/lib/riscv64-linux-gnu/opensbi/generic/fw_jump.elf \
    -kernel /usr/lib/u-boot/qemu-riscv64_smode/uboot.elf \
    -object rng-random,filename=/dev/urandom,id=rng -device virtio-rng-device,rng=rng \
    -nographic \
    -append "root=LABEL=rootfs console=ttyS0"

可是,bios 參數所需的 OpenSBI 韌體檔案路徑與 kernel 參數許虛的 U-Boot 執行檔路徑都不在筆者的開發主機上,顯然是需要特別調度了。

使用 Docker 的 Debian 環境

是的,那些路徑的執行檔,其實是假設開發者有 Debian 環境,那麼就可以簡單透過 apt 軟體包管理員直接下載。幸好有 Docker 這種容器工具,我們可以因此省掉另外準備 Debian 開發機的工夫。

以下假設讀者有操作 Docker 的經驗。若無,也不難學,甚至鐵人賽的過去系列也是很多的,不妨一尋。

$ docker run -it --privileged debian
root@aee2fdf7d438:/# apt update
...
root@aee2fdf7d438:/# apt install u-boot-qemu opensbi
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done            
The following NEW packages will be installed: 
  opensbi u-boot-qemu 
0 upgraded, 2 newly installed, 0 to remove and 0 not upgraded.
...
Setting up u-boot-qemu (2021.01+dfsg-5) ...
Setting up opensbi (0.9-1) ...
root@aee2fdf7d438:/# ls /usr/lib/u-boot/qemu-riscv64_smode/uboot.elf 
/usr/lib/u-boot/qemu-riscv64_smode/uboot.elf
root@aee2fdf7d438:/#

筆者這裡下載了 DQIB 推薦的 OpenSBI 是先遵守官方的建議。後來經過實測,無論是 hoddarla 專案裡面的 misc/opensbi 版本,或是 QEMU 6.1.0 本身內建的版本(也就是移除掉 -bios 參數),都能夠使用這個預編的 Debian。

從容器內部將檔案複製出來:

$ docker cp aee:/usr/lib/u-boot/qemu-riscv64_smode/uboot.elf ./

重新執行 readme.txt 當中的指令

一開始經過 OpenSBI 進入到 U-Boot 的倒數計時,會有一個選單:

Hit any key to stop autoboot:  0 

Device 0: QEMU VirtIO Block Device
            Type: Hard Disk
            Capacity: 10240.0 MB = 10.0 GB (20971520 x 512)
... is now current device
Scanning virtio 0:1...
Found /boot/extlinux/extlinux.conf
Retrieving file: /boot/extlinux/extlinux.conf
671 bytes read in 1 ms (655.3 KiB/s)
U-Boot menu
1:	Debian GNU/Linux 11 (bullseye) 5.10.0-8-riscv64
2:	Debian GNU/Linux 11 (bullseye) 5.10.0-8-riscv64 (rescue target)
Enter choice:

選一即可,繼續開下去,完全沒有問題!帳號密碼用 root:root 或是 debian:debian 皆可登入。

Enter choice: 1
1:	Debian GNU/Linux 11 (bullseye) 5.10.0-8-riscv64
Retrieving file: /boot/initrd.img-5.10.0-8-riscv64
57240645 bytes read in 172 ms (317.4 MiB/s)
Retrieving file: /boot/vmlinux-5.10.0-8-riscv64
18105856 bytes read in 15 ms (1.1 GiB/s)
append: root=LABEL=rootfs rw noquiet root=LABEL=rootfs
Moving Image from 0x84000000 to 0x80200000, end=813bd000
## Flattened Device Tree blob at bf748af0
   Booting using the fdt blob at 0xbf748af0
   Using Device Tree in place at 00000000bf748af0, end 00000000bf74ce2d

Starting kernel ...

[    0.000000] Linux version 5.10.0-8-riscv64 (debian-kernel@lists.debian.org) (gcc-10 (Debian 10.2.1-6) 10.2.1 20210110, GNU ld (GNU Binutils for Debian) 2.35.2) #1 SMP Debian 5.10.46-4 (2021-08-03)
[    0.000000] OF: fdt: Ignoring memory range 0x80000000 - 0x80200000
...
Welcome to Debian GNU/Linux 11 (bullseye)!

[    8.610897] systemd[1]: Set hostname to <debian>.
[   10.511555] systemd[1]: Queued start job for default target Graphical Interface.
[   10.518599] random: systemd: uninitialized urandom read (16 bytes read)
[   10.541828] systemd[1]: Created slice system-getty.slice.
[  OK  ] Created slice system-getty.slice.
[   10.545311] random: systemd: uninitialized urandom read (16 bytes read)
[   10.550545] systemd[1]: Created slice system-modprobe.slice.
[  OK  ] Created slice system-modprobe.slice.
[   10.551673] random: systemd: uninitialized urandom read (16 bytes read)
[   10.556772] systemd[1]: Created slice system-serial\x2dgetty.slice.
[  OK  ] Created slice system-serial\x2dgetty.slice.
...
Debian GNU/Linux 11 debian ttyS0

debian login: root
Password: 
Linux debian 5.10.0-8-riscv64 #1 SMP Debian 5.10.46-4 (2021-08-03) riscv64

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Sat Sep  4 03:25:09 UTC 2021 on ttyS0
root@debian:~#

訊號機制

這個機制是一般 Unix 作業系統的機制,並非 hoddarla 專案現階段想要實作的功能。我們是為了參考非同步行為的處理,才來研究這個部分。

kill 指令

一般 Linux 系統下,可以使用 kill 指令傳遞訊號給予其他的行程。比方說,kill -2 <pid> 指令,能夠給予 pid 號碼的行程一個中斷訊號,相當於是在該行程的執行控制台(console)前景按下 Ctrl+C。簡單的操作:

root@debian:~# sleep 10 &
[1] 243
root@debian:~# kill -2 243
[1]+  Interrupt               sleep 10
root@debian:~#

使用 kill -h 可以觀察到所有支援的訊號號碼。

Golang 範例

參考 Go by examples 網站的範例,來試試上述的中斷訊號的效果:

package main

import (
    "fmt"
    "os"
    "os/signal"
    "syscall"
)

func main() {

    sigs := make(chan os.Signal, 1)
    done := make(chan bool, 1)

    signal.Notify(sigs, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM)

第一部分我們看到兩個頻道(channel)的初始化,分別是屬於 os 組件的訊號型別,以及布林型別。這些都會再稍後的部分使用到。接下來是 os/signal 組件的 Notify 函數。

這個函數使用不定長度參數,除了第一個參數必須指定一個訊號頻道之外,後面可以指定多個訊號。當這個行程真的收到訊號之時,Golang 的各種機制(後續小節簡述)會把該訊號傳遞出來,到給定的頻道去。且看這個程式的後半邏輯:

    go func() {
        sig := <-sigs
        fmt.Println()
        fmt.Println(sig)
        done<- true
    }()

    fmt.Println("awaiting signal")
    <-done
    fmt.Println("exiting")
}

令開一個併發(concurrent)的共常式,用以接收訊號;印出訊號之後,透過另外一個傳遞布林值的頻道,知會主函數當中的 <-done 之一行。進而結束整個範例。

這支程式跑起來像是這樣:

$ ./test 
awaiting signal
^C   # 使用者按下 Ctrl + C
interrupt
exiting

訊號之初始化

如果我們省略一切使用者空間的設置,只看訊號是怎麼透過作業系統服務的話,Linux 裡面最重要的呼叫是 rt_sigaction。使用 strace 工具觀察這支程式可以看到中間有一段連續的 rt_sigaction

rt_sigaction(SIGINT, NULL, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=0}, 8) = 0                 
rt_sigaction(SIGINT, {sa_handler=0x7aa30, sa_mask=[], sa_flags=SA_ONSTACK|SA_RESTART|SA_SIGINFO}
, NULL, 8) = 0                                                                                  
rt_sigaction(SIGQUIT, NULL, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=0}, 8) = 0
rt_sigaction(SIGQUIT, {sa_handler=0x7aa30, sa_mask=[], sa_flags=SA_ONSTACK|SA_RESTART|SA_SIGINFO
}, NULL, 8) = 0
rt_sigaction(SIGILL, NULL, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=0}, 8) = 0
rt_sigaction(SIGILL, {sa_handler=0x7aa30, sa_mask=[], sa_flags=SA_ONSTACK|SA_RESTART|SA_SIGINFO}
, NULL, 8) = 0
...

sa_handler 就是告訴作業系統,如果這個行程遇到這個特定的訊號,請對應到這個處理程序(handler)。網路上可以找到很多 C 語言的範例,開發者如果想要的話,也能夠依照每一個想要額外處理的訊號指定不同的處理程序。

但 Golang 這裡,我們從上小節的範例中可以看到,Golang 的 Notify 將這些細部的操作隱藏起來,讓非同步的訊號可以透過 Golang 的頻道機制取得。而從 strace 的印出訊息我們發現,幾乎所有的訊號都共用這個 0x7aa30 的函數。

訊號之傳遞

000000000007aa30 <runtime.sigtramp>:
   7aa30:       fa113c23                sd      ra,-72(sp)
   7aa34:       fb810113                addi    sp,sp,-72
   7aa38:       00a12423                sw      a0,8(sp)
   7aa3c:       00b13823                sd      a1,16(sp)
   7aa40:       00c13c23                sd      a2,24(sp)
   ...
      7aa5c:       35850513                addi    a0,a0,856 # 5cdb0 <runtime.sigtrampgo>
   7aa60:       000500e7                jalr    a0
   7aa64:       00013083                ld      ra,0(sp)
   7aa68:       04810113                addi    sp,sp,72
   7aa6c:       00008067                ret

sigtramp 位在 src/runtime/sys_linux_riscv64.s 裡面,並且我們先前準備的 opensbi/riscv64 組合其實並沒有包含到這個呼叫,因為我們目前與傳統的訊號機制完全沒有關係。

這個函數之後呼叫到位在 src/runtime/signal_unix.gosigtrampgo,裡面的這個片段是筆者特別想要參考的部分:

...
        setg(g.m.gsignal)

        // If some non-Go code called sigaltstack, adjust.
        var gsignalStack gsignalStack
        setStack := adjustSignalStack(sig, g.m, &gsignalStack)
        if setStack {
                g.m.gsignal.stktopsp = getcallersp()
        }

        if g.stackguard0 == stackFork {
                signalDuringFork(sig)
        }

        c.fixsigcode(sig)
        sighandler(sig, info, ctx, g)
        setg(g)
        ...

在進入更直白的 sighandler 之前,透過 setg 函數調整了當前運作的 Golang 共常式。是的,一般使用者函數,會在這裡切換共常式,尤其是所使用的堆疊。

雖然 ethanol 核心現在沒有使用任何類似訊號的功能,但 os_opensbi.go 裡面的 mpreinit 函數還是有初始化 gsignal 共常式。所以理論上,我們有一個完全沒有使用到的堆疊

sighandler 之後,會呼叫到位在 ./src/runtime/sigqueue.go 裡面的 sigsend。這個會對應到 signal.Notify 之後的一連串處理,針對每一種訊號都會產生一個共常式,以等待訊號的來臨,並將之對應到輸出給予當初傳入的頻道,達成通知 main 函數的效果。

小結

予焦啦!今天也是機制考察的一日,主要觀察的是一般 Linux 使用者模式底下的 Golang 程式,也藉此機會把一個比較成熟的環境架起來,日後也方便使用。除此之外,雖然沒有在今天的篇幅當中介紹,但像 Golang 自己排程共常式的 gogo 呼叫,也都能看到共常式轉換堆疊時的過程。

無論如何,我們終究是要跨過這個上下文的實作障礙的。筆者會打算使用 gsignal 的堆疊來完成。無論如何,明天就來處理上下文的過程吧。各位讀者,我們明天再會!


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

尚未有邦友留言

立即登入留言