在前一篇文章中,筆者介紹了什麼是 TinyGo、相關環境的安裝以及在 Arduino Uno 燒錄第一支程式。相關部分在這篇就不再贅述了。
想了解一個語言,親手實作和玩一玩是最容易體會的。我們這篇就來看看 Arduino Uno(或 Nano)的四種經典操作 — — 數位輸出、數位輸入、類比輸出、類比輸入要如何寫程式,並藉此了解 Go 與 TinyGo 的語言特色。
Go 參考資料
由於 Go 仍然很新,所以台灣買不到書可參考。網路上有些關於 Go 語言的資訊,我個人參考的是下面這兩個:
- 語言技術:Go 語言
- A Tour of Go(可線上當場執行範例)
材料準備
在看本篇之前,你會需要:
- 一片 Arduino Uno/Nano 及其 USB 線。
- 400 孔麵包板。
- 公公頭杜邦線若干,或者用其他杜邦線加 2.54mm 排針代替。
- 5mm 或類似大小的 LED 燈數個。
- 220 歐姆及 10 K 歐姆電阻數個。
- 按壓按鈕及光敏電阻。
這些在電子材料行都相當容易購得。
回頭看 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 語言中公開對外的「物件」與「方法」,其首字一定是大寫。
接下來
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() 的使用。
在下面的另一個版本中,我不使用變數來記錄腳位物件,而是直接控制腳位物件本身:
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); 一樣),第一個子迴圈會往上依次打開並關閉一個燈,第二個則將順序倒過來,創造出燈光來回閃動的效果。
再來看第二個版本:
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)
} }}
類比輸入
最後來看類比輸入,也就是讀取類比信號或介於 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 成為明日之星 — — 而這就待下回再介紹了。