raspberry-pi go Run Go applications on Raspberry Pi Pico using TinyGo

TinyGo - Go compiler for small places. TinyGo 自 2019 年正式公開以來,就逐漸受到關注,尤其是 Google 在 2019 年 10 years of Go 也有特別提到 Go 原本將目標放在網路或是雲端等應用程式,不過未來期望能夠更廣泛地應用在 microcontroller 上。在近期 TinyGo 開始支援 Raspberry Pi Pico,再加上 TinyGo 架構也逐漸成熟,因此就來試用看看。

Run applications on Raspberry Pi Pico

在分析 TinyGo 如何編譯適合跑在 Raspberry Pi Pico 的 Go program 之前,先來複習如何讓 user application 在 Raspberry Pi Pico (以下皆簡稱為 Pico) 上執行。在 Pico 進入 user application 的 entry point 之前,會有幾個前置流程,包含:

  1. hardware controlled boot
  2. enter the bootroom from ROM
  3. initialize CPU
  4. load second boot loader to RAM
  5. jump to the entry point of second boot loader
  6. setup the flash to XIP mode
  7. jump to the entry point of application

根據 Pico firmware 架構,second boot loader 是被放在 pico-sdk repository 中,當 user 使用 Pico 原生的 pico-sdk 來編譯自己所撰寫的程式時,會連同 second boot loader 一起編譯成最終的執行程式,並且放置到 flash 起始位置 (0x10000000),如此一來 Pico 在退出 bootroom 後,就會從 0x10000000 位置開始執行後續流程 (Pico default memory layout 圖示可參考 Boot Flow of Raspberry Pi Pico)。

而這點也意味著,透過 TinyGo 所編譯出來的執行程式,除了 user application 之外,還必須包含 second boot loader,並且也要預留對應的 stack 空間,才能讓 Pico 正確進入到對應的 entry point 並執行程式。

How TinyGo works

在 TinyGo Pipeline documentation 中有簡單介紹 TinyGo compiler 流程:

tinygo flow

在我認為,TinyGo 這個 project 扮演著整合的角色,可以看到在 compiler front end 和 back end 處理,主要是運用既有的 Go compiler libraries 和成熟的 LLVM 架構,在過程中進行額外的調整處理,使其最後產出的 object file 能夠更符合 microcontroller 本身的硬體條件。

首先,以 Pico device 為例,來分析 TinyGo 如何支援各種不同的 device。

json config file

在使用 TinyGo compile Go source code 的時候,我們會下以下指令:

1tinygo build -o hello -target="pico" ./hello.go

而這裡的 option target 具體指的就是 TinyGo 針對不同 device 硬體架構而事先配置好的 json config file。以 Pico 來說,它的 config file 內容如下:

 1// tinygo/targets/pico.json
 2{
 3    "inherits": [
 4        "rp2040"
 5    ],
 6    "build-tags": ["pico"],
 7    "serial": "uart", // build be usb or uart
 8    "linkerscript": "targets/pico.ld",
 9    "extra-files": [
10        "targets/pico-boot-stage2.S" // bootloader
11    ]
12}

可以觀察到 config 提供相關 serial output 和 linker script 資訊,以及我們在上述所提及到 second boot loader source code。此外,config file 採用物件繼承的架構,讓 device 重複使用相同 microcontroller 的設定,像是 Pico 就繼承 rp2040 config:

 1// tinygo/targets/rp2040.json
 2{
 3    "inherits": ["cortex-m0plus"],
 4    "build-tags": ["rp2040", "rp"],
 5    "flash-method": "msd",
 6    "msd-volume-name": "RPI-RP2",
 7    "msd-firmware-name": "firmware.uf2",
 8    "binary-format": "uf2",
 9    "uf2-family-id": "0xe48bff56",
10    "rp2040-boot-patch": true,
11    "extra-files": [
12        "src/device/rp/rp2040.s"
13    ],
14    "openocd-transport": "swd",
15    "openocd-target": "rp2040"
16}

而在 rp2040 config 可以看到更多針對 microcontroller 的設定,其內容主要是參考 RP2040 datasheet,以 binary-formatuf2-family-id 為例,在 datasheet 2.8.3.2 UF2 Format Details 有提到關於 UF2 的需求:

All data destined for the device must be in a UF2 block with familyID present and set to 0xe48bff56, and a payload_size of 256.

總而言之,TinyGo target option 以及其對應 json config 最主要的目的,是能夠更彈性地讓 compiler 針對不同 device 硬體配置而調整編譯內容和方式。

linker script

要讓程式正確執行的另一個重點,就是要告知 linker 的記憶體資源和範圍,讓 linker 能正確地將 object 映射到可使用的記憶體空間,因此 TinyGo 也內建不同 device 的 linkerscript 以供編譯時使用。一樣以 Pico 為例:

 1# targets/pico.ld
 2MEMORY
 3{
 4    /* Reserve exactly 256 bytes at start of flash for second stage bootloader */
 5    BOOT2_TEXT (rx) : ORIGIN = 0x10000000, LENGTH = 256
 6    FLASH_TEXT (rx) : ORIGIN = 0x10000000 + 256, LENGTH = 2048K - 256
 7    RAM (rwx)       : ORIGIN = 0x20000000, LENGTH = 256k
 8}
 9
10INCLUDE "targets/rp2040.ld"

pico.ld 將記憶體主要分成三部分:BOOT2、FLASH、RAM,其 memory layout 分配是基於 datasheet 和 Pico SDK 的 linkerscript,同樣地為了能重複利用 linkerscript,因此將部分共用的 script 拆分開來。例如 Pico 的 linkerscript 包含 rp2040.ld,而 rp2040.ld 則是包含 arm.ld。

Note: 這樣的 linkerscript 雖然可以達到重複利用的目的,不過卻缺少一些客製化空間。例如 rp2040 本身為 dual core,不過 stack 空間卻是寫在 arm.ld,因此只有分配到一個 core 可以使用的 stack,不同於原生 Pico SDK 能為兩個 core 分配各自的 stack 空間。

Multiple Devices

除了不同 device 的 config file 差異之外,TinyGo 也透過 Go build tag 來實現不同 device 的硬體抽象層實作。在 json config file 的 "build-tags": ["pico"] 即可看出端倪。以最基本的 Pin 為例:

  • 根據 Pico datasheet 定義出具體的 Pin 腳。
1// +build pico
2
3package machine
4
5const (
6	// GPIO pins
7	GPIO0  Pin = 0
8  // ...more pins
9)
  • 抽象層,提供通用的操作 function。
 1package machine
 2
 3type Pin uint8
 4
 5func (p Pin) High() {
 6	p.Set(true)
 7}
 8
 9func (p Pin) Low() {
10	p.Set(false)
11}
  • 根據 RP2040 microcontroller datasheet 來實作硬體控制。
 1// +build rp2040
 2
 3package machine
 4
 5func (p Pin) set() {
 6	mask := uint32(1) << p
 7	rp.SIO.GPIO_OUT_SET.Set(mask)
 8}
 9
10func (p Pin) get() bool {
11	return rp.SIO.GPIO_IN.HasBits(1 << p)
12}

藉由這樣的介面設計,即可抽換硬體控制方式,讓程式可以被移植到其他 device 上。

Experiment

以下撰寫簡單的 Go 程式來示範如何結合 Go 語言和 TinyGo library 來實際操作硬體。

需求

使用 interrupt handler,讓 user 透過觸碰 touch sensor 來開關 Pico 內建的 led 燈。

實驗環境

  • Raspberry Pi 3 Model B (host)
  • Raspberry Pico
  • TTP223 Touch sensor

我們在 Pi 3 主機撰寫和編譯程式,並且透過 Pico 的 Serial Wire Debug (SWD) port 將編譯好的執行檔放到 Pico 開發板上。詳細編譯和載入程式的流程可以參考 Getting started with Raspberry Pi Pico。

Compiler Version

  • Go version go1.17.1 linux/arm
  • TinyGo version 0.20.0 linux/arm
  • LLVM version 11.0.0

GPIO

實驗過程

在實作 code 之前,先來分析一下需要哪些硬體控制行為。要讓 CPU 收到由 touch sensor 傳遞過來的 interrupt,並且由對應的 interrupt handler 處理,需要幾個前置條件:

  1. Enable GPIO28(與 touch sensor 的 signal pin 對接) 的 interrupt。
  2. Enable NVIC(Nested Vectored Interrupt Controller) 指定的 interrupt source。
  3. 在 vector table 對應位置設置指定的 interrupt handler。

Enable GPIO28 interrupt

根據 RP2040 datasheet 2.19 GPIO 章節提及:

RP2040 has 36 multi-functional General Purpose Input / Output (GPIO) pins, divided into two banks. In a typical use case, the pins in the QSPI bank (QSPI_SS, QSPI_SCLK and QSPI_SD0 to QSPI_SD3) are used to execute code from an external flash device, leaving the User bank (GPIO0 to GPIO29) for the programmer to use.

可以知道 User Bank 提供控制 GPIO 功能。另外, 2.19.3 小節提到四種 GPIO interrupt scenario:Level HighLevel LowEdge HighEdge Low。結合 2.19.6.1. IO - User Bank 的 register table,即可設置 register 來針對指定的 GPIO 啟用不同的 interrupt scenario。

 1type PicoIO struct {
 2	status volatile.Register32
 3	ctrl   volatile.Register32
 4}
 5
 6type PicoIRQCtrl struct {
 7	interruptEnable [4]volatile.Register32
 8	interruptForce  [4]volatile.Register32
 9	interruptStatus [4]volatile.Register32
10}
11
12// the struct is based on 2.19.6.1. IO - User Bank registers
13type PicoIOBank0Type struct {
14	io                 [30]PicoIO
15	intR               [4]volatile.Register32
16	proc0IRQctrl       PicoIRQCtrl
17	proc1IRQctrl       PicoIRQCtrl
18	dormantWakeIRQctrl PicoIRQCtrl
19}
20
21type TouchChange uint8
22
23// rp package provides pre-defined registers for rp2040
24var ioBank0 = (*PicoIOBank0Type)(unsafe.Pointer(rp.IO_BANK0))
25
26// trigger an interrupt when logical change
27var (
28	Released TouchChange = 4
29	Touched  TouchChange = 8
30)

Enable NVIC interrupt source

GPIO 的 interrupt 被觸發後,會 route 到 Nested Vectored Interrupt Controller(NVIC) 以通知 CPU 有 interrupt event。因此我們也需要 enable NVIC 有關 GPIO 的 IRQ。同樣參考 datasheet 2.3.2. Interrupts,其中 IRQ 13: IO_IRQ_BANK0 就是我們要 enable 的 interrupt source。

Config Interrupt Handler

最後就是將我們的 interrupt handler address 設置在 vector table,當 CPU 接收到此 interrupt event 後,就會根據 vector table 來執行對應的 handler。

Implementation

相較於 C,使用 Go 操作硬體會較為複雜,主要是因為 Go 簡化了其語法,但是也代表 Go source code 在經過語意分析後的結果會複雜許多(舉例來說, Go function 其實會包含 context 和 function method 等部分,在設置 interrupt handler 時就需要額外的處理)。

幸好 TinyGo 提供了相關的 structure 和 package,例如 volatile.Register32interrupt 等,以及針對各 deivce 提供最基本的 register address package(例如 device/rp),善用這些 package 就能夠正確地調整 register 值,讓硬體執行如預期的行為。

 1package main
 2
 3import (
 4	"device/rp"
 5	"runtime/volatile"
 6	"runtime/interrupt"
 7	"unsafe"
 8	"machine"
 9)
10
11type TouchChange uint8
12
13type Touch struct {
14	pin  machine.Pin
15	intr interrupt.Interrupt
16	action func()
17	change TouchChange
18}
19
20type PicoIO struct {
21	status volatile.Register32
22	ctrl   volatile.Register32
23}
24
25type PicoIRQCtrl struct {
26	interruptEnable [4]volatile.Register32
27	interruptForce  [4]volatile.Register32
28	interruptStatus [4]volatile.Register32
29}
30
31type PicoIOBank0Type struct {
32	io                 [30]PicoIO
33	intR               [4]volatile.Register32
34	proc0IRQctrl       PicoIRQCtrl
35	proc1IRQctrl       PicoIRQCtrl
36	dormantWakeIRQctrl PicoIRQCtrl
37}
38
39var ioBank0 = (*PicoIOBank0Type)(unsafe.Pointer(rp.IO_BANK0))
40
41var (
42	Released TouchChange = 4
43	Touched  TouchChange = 8
44)
45
46var interTouch Touch
47var led = machine.LED
48
49func (t *Touch) irqEnableCtrl() *volatile.Register32 {
50	return &ioBank0.proc0IRQctrl.interruptEnable[t.pin / 8]
51}
52
53func (t *Touch) enableChangeEvent() {
54	target := (t.pin % 8) * 4
55	t.irqEnableCtrl().ClearBits(0xF << target)
56	t.irqEnableCtrl().SetBits(uint32(t.change) << target)
57}
58
59func (t *Touch) acknowledgeChange() {
60	ioBank0.intR[t.pin / 8].SetBits(uint32(t.change) << ((t.pin % 8) * 4))
61}
62
63func (t *Touch) handleInterrupt(interrupt.Interrupt) {
64	t.acknowledgeChange()
65	t.action()
66}
67
68func (t *Touch) Enable() error {
69	t.enableChangeEvent()
70	t.intr.Enable()
71	return nil
72}
73
74func NewTouch(pin machine.Pin, change TouchChange, action func()) *Touch {
75	touch := &interTouch
76	touch.pin = pin
77	touch.action = action
78	touch.change = change
79	touch.intr = interrupt.New(rp.IRQ_IO_IRQ_BANK0, interTouch.handleInterrupt)
80	return touch
81}
82
83func main() {
84	led.Configure(machine.PinConfig{Mode: machine.PinOutput})
85
86	touchAtion := func(){
87		if led.Get() {
88			led.Low()
89		} else {
90			led.High()
91		}
92	}
93
94	touch := NewTouch(machine.GPIO28, Touched, touchAtion)
95	touch.Enable();
96
97	select{}
98}

Challenges

雖然 TinyGo 提供另一種撰寫 microcontroller 應用程式的可能性,不過實際測試之後,發現還是有很多挑戰需要去克服。

  1. 缺乏完整的 SDK 和 API:以 Pico 為例,原生的 pico-sdk 提供非常齊全的硬體控制 API,甚至還額外提供許多 wrapper function 讓 user 可以更方便地呼叫想要的功能。這部分 TinyGo 可能就必須仰賴大量 contributor 來將 device 既有的 SDK 移植。

  2. 共用性和客制化的權衡:雖然 TinyGo 期望能支援多種不同的 device,因此將 device 硬體設定分層共用,不過也因此可能會造成部分共用設定其實不能很好地適應在特定 device 上。

Conclusion

TinyGo 結合原有 Go compiler 來延伸出適合 microcontroller 的 TinyGo compiler。雖然因為部分限制,不太可能實際應用在 production 上,但是拿來教學教育也是還不錯的選擇,希望之後在開發板可以和 microPython 一樣,能提供 Go 相關 library 供開發者使用。