學 Go 玩創客,TinyGo 初體驗(2):以 Arduino Uno/Nano 控制腳位數位與類比輸出入

Alan Wang
19 min readJul 1, 2020

--

Photo by Ross Findon on Unsplash

在前一篇文章中,筆者介紹了什麼是 TinyGo、相關環境的安裝以及在 Arduino Uno 燒錄第一支程式。相關部分在這篇就不再贅述了。

想了解一個語言,親手實作和玩一玩是最容易體會的。我們這篇就來看看 Arduino Uno(或 Nano)的四種經典操作 — — 數位輸出、數位輸入、類比輸出、類比輸入要如何寫程式,並藉此了解 Go 與 TinyGo 的語言特色。

Go 參考資料

由於 Go 仍然很新,所以台灣買不到書可參考。網路上有些關於 Go 語言的資訊,我個人參考的是下面這兩個:

  1. 語言技術:Go 語言
  2. A Tour of Go(可線上當場執行範例)

材料準備

在看本篇之前,你會需要:

  1. 一片 Arduino Uno/Nano 及其 USB 線。
  2. 400 孔麵包板。
  3. 公公頭杜邦線若干,或者用其他杜邦線加 2.54mm 排針代替。
  4. 5mm 或類似大小的 LED 燈數個。
  5. 220 歐姆及 10 K 歐姆電阻數個。
  6. 按壓按鈕及光敏電阻。

這些在電子材料行都相當容易購得。

Photo by Robin Glauser on Unsplash

回頭看 Blinky 範例

上一篇我們用範例程式令 Arduino Uno 內建的 LED 燈閃爍,這裡我們再來看一次程式:

package main

import (
"machine"
"time"
)

func main() {
led := machine.LED
led.Configure(machine.PinConfig{Mode: machine.PinOutput})
for {
led.Low()
time.Sleep(time.Millisecond * 500)
led.High()
time.Sleep(time.Millisecond * 500)
}
}

package main 是必要的,Go 的程式檔第一行一定是這個,代表程式所屬的套件。目前只要寫 main 就行了。

接著是 import 區,即匯入要用的套件(package)。大部分情況下一定會用到 machine,這套件包含了跟腳位、I2C、SPI、UART 等相關的控制功能。time 套件則是取自 Go 套件(但據我所知匯入的版本不會和 Go 標準套件一樣),提供時間延遲和計時等功能。

要注意,沒有使用的套件就得刪掉或註解掉,否則會引發錯誤。

func main() 是主程式區。TinyGo 和 Go 語言一樣只有 main,不像 Arduino 有 setup() 和 loop();所以在 main() 內得放個 for{} 當作無窮迴圈。

在 main() 內的第一句程式

led := machine.LED

這其實也可以寫成

var led machine.Pin = machine.LED

var led machine.Pin = machine.D13

在 Go 宣告變數時,正式做法是用「var 變數名 變數型別」這樣的寫法。「變數名 := 值」是簡短版寫法,Go 會根據值的型別來決定變數的型別,因此一定要給個值。相對的使用 var 的好處是你可以不用先給初始值,Go 會自己設定它。

要注意的是,:= 只能用在建立新變數的時候,之後同樣的變數就只能使用 = 了。此外,在 Go 程式中宣告但未使用的變數也會在編譯時引發錯誤。

machine.Pin 是 TinyGo 的腳位類別。其實 Go 並沒有類別,而是透過稱為 struct 的資料結構來「掛上」函式,使之變成像物件方法一樣(這之後有機會再講)。machine.D13 會傳回 13 號腳位的物件(Arduino Uno/Nano 的該腳位有接一個內建 LED 燈,因此該腳位通電時燈就會亮)。

machine.LED 是事先定義好的常數,對 Uno/Nano 來說就會對應到 machine.D13。

在 0.13.1 版之前,Arduino Uno/Nano 的腳位是用 machine.Pin(x) 來表示 X 號腳位。從 0.14.0 版後這都改成了 machine.Dx,好與其他開發板更一致。有些板子可能會用 machine.Px 來表示。

官網對於每個板子都有列出其 machine 套件的內容,你可藉此參考腳位名稱:https://tinygo.org/microcontrollers/machine/arduino/

注意物件的大小寫;Go 語言中公開對外的「物件」與「方法」,其首字一定是大寫。

Photo by Danielle MacInnes on Unsplash

接下來

led.Configure(machine.PinConfig{Mode: machine.PinOutput})

這裡就有一點長了。這是設定腳位的輸出入模式,腳位的 Configure() 方法要傳入名為 PinConfig 的 struct 結構,好透過該結構的 Mode 欄位來指定輸出入模式。下面是此 struct 的定義:

type PinConfig struct {
Mode PinMode
}

PinMode 型別本身則是 uint8 數值:

type PinMode uint8

const (
PinInput PinMode = iota // 0
PinInputPullup // 1
PinOutput // 2
)

用 const 宣告常數時,你可給第一項指定為 iota,後面的不寫。這樣第一項就會是 0,第二項是 1…以此類推。

既然 machine.PinOutput 常數的值等於 2,你也能簡寫如下,不過這樣閱讀性就比較差了:

led.Configure(machine.PinConfig{2)

現在看到 for 迴圈內,腳位物件的 High() 與 Low() 方法會讓腳位別輸出高與低電壓,並用 time.Sleep() 製造延遲時間(這相當於 Arduino 的 delay() 或 MicroPython 的 time.sleep())。

High() 與 Low() 也能用 Set() 方法取代:

led.Set(true) // High
led.Set(false) // Low

Go 的 time.Sleep() 有點特別,它的最小單位是奈秒(Nanosecond),而 time.Sleep() 接受的資料格式為 Duration(它本身則是 int64 數值)。我們不妨來看看 Go 對此的定義:

const (
Nanosecond Duration = 1
Microsecond = 1000 * Nanosecond
Millisecond = 1000 * Microsecond
Second = 1000 * Millisecond
Minute = 60 * Second
Hour = 60 * Minute
)

可以看到 Go 定義了六種時間級距,每一個都拿前一個乘上去。因此下面這句

time.Sleep(time.Millisecond * 500)

意思就是用 Millisecond(毫秒)為基礎乘上 500,即 500 毫秒(半秒)。後面我們會談怎麼用自訂函式來稍微簡化 time.Sleep() 的使用。

Photo by AbsolutVision on Unsplash

在下面的另一個版本中,我不使用變數來記錄腳位物件,而是直接控制腳位物件本身:

package mainimport (
"machine"
"time"
)
func main() {machine.Pin(13).Configure(machine.PinConfig{Mode: machine.PinOutput})for {
machine.D13.High()
time.Sleep(time.Millisecond * 500)
machine.D13.Low()
time.Sleep(time.Millisecond * 500)
}
}

這個特點和 MicroPython 頗為相似(不管哪個版本,MicroPython 的腳位物件都能用來直接操縱腳位),不若 Arduino 只用分離的函式來控制之。當然,為了方便起見,一般我們還是會用變數來儲存腳位物件,好賦予它更易懂的名稱。

數位輸出

接下來要開始接線了,這回我們改用 Uno/Nano 的 2 號腳位來控制一顆外接 LED 燈。同時,我們將嘗試用個不太一樣的程式版本來控制它。

Arduino Uno/Nano 等這些較舊型的開發板,其腳位輸出的電流量較大(40 mA,也有人說 50 mA),很容易令 LED 燈燒壞,因此必須在 LED 的正極端加上電阻。紅色與橘色 LED 大概需要 150 歐姆左右的電阻;220 歐姆電阻比較常見,所以一般習慣就用 220 歐姆。

有些教材會教人把 LED 燈直接插入 Uno 腳位 13 及隔壁的 GND,理由是腳位 13 有內建保護電阻。不過,這電阻其實沒有直接和對外腳位連接,只對板子的內建 LED 有作用,因此該腳位的外接 LED 燈還是請乖乖接上電組!

下面我們來寫個稍微不同的 Blinky 版本:

package mainimport (
"machine"
"time"
)
func main() { led := machine.D2
led.Configure(machine.PinConfig{Mode: machine.PinOutput})
for {
led.Set(!led.Get())
delay(500)
}
}
func delay(t uint32) {
time.Sleep(time.Duration(1000000 * t))
}

這裡有兩個值得注意的地方:首先,我直接讀取 LED 腳位狀態後,反轉之並拿來設定腳位,這麼一來在迴圈裡只要寫一次 led.Set() 和 delay()。其次 delay() 是我們加入的自訂函式,它接受一個 uint64 數值當作毫秒數,然後用這來控制 time.Sleep() 的延遲時間。(1 毫秒等於 1 百萬奈秒。)

delay() 的參數 t 其實可以設成 uint16 型別,反正一般我們也不太會讓裝置暫停超過十幾秒(十萬毫秒)的長度,而 time.Duration() 也會將括號內的值轉為自己的型別。問題就在於,t 乘上 1 百萬後值就會超過 uint16(0–65535),這時編譯器會告訴你 t 溢位了!

假如把 t 設為 uint16,那麼你在拿 t 去做計算前,得先把它轉成範圍更大的型別,才不至於引發錯誤:

func delay(t uint16) {    time.Sleep(time.Duration(1000000 * uint32(t)))}

Go 語言對於型別的適當轉換是要求很高的,你也必須考量到值運算後的範圍。

附帶一提,Go 語言的布林值(true/false)是不允許轉成數字的;這點和 C++ 或 Python 可以把 1/0 當成布林值有所不同。

數位輸出 — — 陣列版

剛才只是 1 顆燈的範例,假如現在改成一排燈(腳位 2 至 8),我們希望它能來回閃動呢?這時我們可以使用陣列來處理之。

首先我們要展示的是個比較「笨」、但比較直覺的寫法:

package mainimport (
"machine"
"time"
)
func main() { leds := []machine.Pin{
machine.D2,
machine.D3,
machine.D4,
machine.D5,
machine.D6,
machine.D7,
machine.D8,
}
for i := 0; i < len(leds); i++ {
leds[i].Configure(machine.PinConfig{Mode: machine.PinOutput}})
}
for { for i := 0; i < len(leds); i++ {
leds[i].High()
delay(75)
leds[i].Low()
}
for i := len(leds) - 1; i >= 0; i-- {
leds[i].High()
delay(75)
leds[i].Low()
}
}
}
func delay(t uint16) {
time.Sleep(time.Duration(1000000 * uint32(t)))
}

雖然說是個較笨的寫法,但這邊可以清楚展示 Go 的 for 迴圈。看起來和 C++ 很像,只是沒有小括號。

在此我們先用 := 宣告一個陣列 leds,並直接將 7 顆燈的腳位物件填進去。你能看到這邊並不需要指定陣列長度,後面需要用到長度時用 len(leds) 來取得就好。

在後面的 for 無窮迴圈中(你會發現只要不給條件,for {} 就會像 C++ 的while(true); 一樣),第一個子迴圈會往上依次打開並關閉一個燈,第二個則將順序倒過來,創造出燈光來回閃動的效果。

Photo by Lawrence Hookham on Unsplash

再來看第二個版本:

package mainimport (
"machine"
"time"
)
func main() { delay := func(t uint16) {
time.Sleep(time.Duration(1000000 * uint32(t)))
}
leds := []machine.Pin{
machine.D2,
machine.D3,
machine.D4,
machine.D5,
machine.D6,
machine.D7,
machine.D8,
}
for _, led := range leds {
led.Configure(machine.PinConfig{Mode: machine.PinOutput})
}
index, delta := 0, 1 for { for i, led := range leds {
led.Set(i == index)
}
index += delta if index == 0 || index == len(leds)-1 {
delta *= -1
}
delay(75) }
}

首先,為了示範起見,自訂函式 delay() 被挪到 main() 內部,變成隱含(implicit)宣告的函式。程式會在執行到此處時才建立 delay(),而它也只能在 main() 內被呼叫。

這回我們改用 for i, item := range array 的寫法(i 為元素的索引,item 則為取出的元素;i 沒用到得改成底線,item 則可省略)。這種做法和 Python 的 for in 或 Java 的 for each 很像,好處是不必再去管陣列該有多長。當然,如果是處理一般資料的話,在迴圈內修改 item 並不會影響到原陣列,你還是得用索引版的 for 迴圈才行。

此外,這回使用一個變數 index 來代表「目前得點亮」的索引號碼(對應到陣列索引而不是腳位號碼)。index 每次會加上 delta,而每當 index 的值等於最小或最大索引時,delta 的值就會逆轉正負。如此一來,index 就會在整個索引範圍內「震盪」,而我們只要用單一一個 for 迴圈就能更新所有 LED 的狀態了。

數位輸入

看完數位輸出,接著看類比輸入。我們將偵測一個按鈕(腳位 2)提供的數位信號(高電位/低電位),並用這信號來控制 LED 燈(腳位 9)。

為了讓按鈕能切換高低電位,你在接按鈕時需要給它一個 10K 歐姆上拉電阻,這樣腳位平常會讀到高電位,按下時接通接地線,便會讀到低電位:

package mainimport (
"machine"
"time"
)
func main() { button := machine.D2
button.Configure(machine.PinConfig{Mode: machine.PinInput})
led := machine.D9
led.Configure(machine.PinConfig{Mode: machine.PinOutput})
for {
led.Set(!button.Get())
time.Sleep(time.Millisecond * 100)
}
}

這回回歸到比較正統的寫法來設定腳位物件,按鈕 button 是輸入,led 則是輸出。腳位物件的 Get() 方法若傳回 true 就代表它處在高電位,反之 false 是低電位。當然,要記得電壓上拉的按鈕被按下時電壓會降到 0,所以在把值餵給 led 物件時要逆轉結果。

使用內部上拉電阻的數位輸入

以上接線還可以進一步簡化,改用板子本身的內建上拉電阻來接按鈕。(Uno 擁有介於 20K 至 50K 歐姆的內部上拉電阻:)

    button := machine.D2
button.Configure(machine.PinConfig{Mode: machine.PinInputPullup})

類比輸出

這裡我們直接沿用前一個範例的接線,但只用到 LED 燈。

一些人或許知道,所謂的類比輸出其實是用 PWM (脈衝寬度調變)來實現的,而 Arduino Uno 有 6 個腳位支援 PWM,包括 LED 所在的腳位 9。

注意:TinyGo 在近幾次的版本改變了 PWM 介面,所以會變得比較複雜一點。你必須先用 machine.TimerX 建立 PWM 物件,然後用它建立一個 channel 物件。

每個開發板實作 PWM 的方式略有不同,所以你得去官方網站參考文件。以 Arduino Uno 為例,它有三個 Timer 對應到三組腳位:

var (
Timer0 = PWM{0} // 8 bit timer for PD5 (D5) and PD6 (D6)
Timer1 = PWM{1} // 16 bit timer for PB1 (D9) and PB2 (D10)
Timer2 = PWM{2} // 8 bit timer for PB3 (D11) and PD3 (D3)
)

由於我們要使用 D9,就得用到 Timer1。以下程式能讓 LED 燈緩緩明滅,即所謂的呼吸燈:

package mainimport (
"machine"
"time"
)
func main() { pwm := machine.Timer1
pin := machine.D9
if err := pwm.Configure(machine.PWMConfig{Period: 0}); err != nil {
println("failed to configure PWM")
return
}
channel, err := pwm.Channel(pin)
if err != nil {
println("failed to configure channel")
return
}
for { for i := uint32(50); i >= 1; i-- {
pwm.Set(channel, pwm.Top()/i)
time.Sleep(time.Millisecond * 50)
}
for i := uint32(1); i <= 50; i++ {
pwm.Set(channel, pwm.Top()/i)
time.Sleep(time.Millisecond * 50)
}
}}
Photo by Dil on Unsplash

類比輸入

最後來看類比輸入,也就是讀取類比信號或介於 0–5V 間的電壓值。

這裡我們使用光敏電阻(LDR)做一個分壓電路,搭配一個 10K 歐姆電阻。若 10 K 電阻是 L1,光敏電阻是 L2,那麼兩個電阻之間的電壓會是 L2 / (L1 + L2) * 5。當光敏電阻接收的光線量變多時,其電阻值會變小,於是電阻之間的輸出電壓也就會跟著變小了。我們會用 A0 腳位來讀取這個「類比」讀數。

和前面的 PWM 一樣,TinyGo 的類比讀取值是 uint16 格式(0-65535)而不是 Arduino C++ 內的 0-1023。我們設定若類比信號大於 40000(用手遮住光敏電阻時)就打開 LED 燈:

mackage mainimport (
"machine"
"time"
)
func main() { machine.InitADC()
ldr := machine.ADC{machine.ADC0}
ldr.Configure()
led := machine.Pin(machine.D9)
led.Configure(machine.PinConfig{Mode: machine.PinOutput})
for {
print(ldr.Get())
if ldr.Get() > 40000 {
led.Set(true)
} else {
led.Set(false)
}

time.Sleep(time.Millisecond * 100)
}
}

上面展示了使用 if…else 的方式。不過,在控制 LED 燈時其實可以寫得更簡單:

led.Set(ldr.Get() > 40000)

尾聲

在本篇我們看了 Arduino Uno 基本的腳位輸出入功能,並藉此了解 Go 語言的一些特性。不過這還不是終點;在後續的篇幅中,筆者會帶大家看看 TinyGo 能做的更多事情,例如使用驅動程式來控制更多的外部模組,以及使用幾種其他不同的控制板。

若想實作簡單的應用,使用 Uno/Nano 搭配 TinyGo 是完全可行的。只不過由於 Uno 記憶體太小,目前 TinyGo 的周邊 drivers 多半無法在 Uno 上運行(有另一個原因是這些 drivers 使用了 int64 或 float64 這種 Uno 無法應付的資料格式)。

然而,筆者自己發現到,有另一個令人意想不到的開發板,或許有機會靠著 TinyGo 成為明日之星 — — 而這就待下回再介紹了。

Photo by Bethany Legg on Unsplash

--

--