Go и железо: почему это не шутка?
Когда слышишь словосочетание «embedded разработка», обычно представляешь себе Си, ассемблер и мучительную отладку через светодиод. Но мир не стоит на месте. Сегодня Go (или Golang) всё увереннее стучится в дверь мира микроконтроллеров. И стучится не просто так, а с горутинами, сборщиком мусора и встроенными интерфейсами.
В этой статье мы разберёмся, как выглядит embedded разработка на Go, напишем реальный код для гипотетического микроконтроллера и поймём, стоит ли овчинка выделки. Спойлер: да, если вы устали от утечек памяти и любите читаемый код.
Почему Go, а не Си? Три кита
Классический embedded — это Си. Но у Go есть три killer-фичи, которые заставляют инженеров пересматривать привычки:
- Безопасность памяти: никаких диких указателей, которые могут улететь в космос. Компилятор Go строже, чем Си-компилятор.
- Горутины: легковесные потоки, которые идеально ложатся на событийно-ориентированную архитектуру микроконтроллеров.
- Кроссплатформенность: вы пишете код на Mac, а компилируете под ARM Cortex-M. Без танцев с бубном.
Конечно, есть нюанс: нативный Go (gc) не умеет компилироваться для маленьких MCU. Зато есть проект TinyGo — компилятор, который адаптирует Go под железки с килобайтами памяти. Именно на нём мы и сосредоточимся.
Настройка окружения: TinyGo и ваша плата
Прежде чем писать код, давайте подготовим рабочее место. Для embedded разработки на Go вам понадобится:
- Установить TinyGo:
brew install tinygo(или скачать с официального сайта). - Установить тулчейн для вашей платы (например, ARM GCC для STM32).
- Подключить плату через USB.
Проверим, что всё работает:
tinygo version
tinygo: 0.31.0 (linux/amd64)
Теперь можно компилировать код для любой поддерживаемой платы: tinygo build -target=arduino-nano -o firmware.hex main.go.
Первый blink: светодиод на Go
Классика жанра. Подключаемся к пину, дёргаем ногой. Вот как выглядит программирование микроконтроллеров на Go:
package main
import (
"machine"
"time"
)
func main() {
// Встроенный светодиод обычно на пине 13 (или LED_BUILTIN)
led := machine.LED
led.Configure(machine.PinConfig{Mode: machine.PinOutput})
for {
led.High()
time.Sleep(500 * time.Millisecond)
led.Low()
time.Sleep(500 * time.Millisecond)
}
}
Компилируем и заливаем на плату:
tinygo flash -target=arduino-nano -port=/dev/ttyUSB0 main.go
Вуаля! Светодиод моргает. Обратите внимание: мы используем пакет machine — это аппаратная абстракция TinyGo. Она скрывает за собой работу с регистрами, но даёт чистый Go-интерфейс.
Работа с GPIO и прерываниями
В реальных проектах просто моргать мало. Нужно читать кнопки, датчики, реагировать на события. В Go для встраиваемых систем прерывания реализованы через каналы и горутины. Пример: повесим обработчик нажатия кнопки.
package main
import (
"machine"
"fmt"
)
func main() {
button := machine.D2
button.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
// Ждём нажатия (pin == LOW, так как подтяжка к питанию)
for {
if !button.Get() {
fmt.Println("Кнопка нажата!")
}
}
}
Но это — busy loop. Хотим эффективнее? Используем прерывание через канал:
func main() {
button := machine.D2
button.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
// Канал для событий
ch := make(chan bool, 1)
button.SetInterrupt(machine.PinFalling, func(pin machine.Pin) {
select {
case ch <- true:
default:
}
})
for {
<-ch
println("Прерывание сработало!")
}
}
Горутина слушает канал и не тратит процессор на пустые проверки. Это очень похоже на то, как мы пишем серверы на Go — только теперь на железе.
Работа с памятью: сборщик мусора в embedded
Главный страх любого embedded-инженера: сборщик мусора. Он может остановить выполнение кода на неопределённое время. В TinyGo эту проблему решили двумя способами:
- Precise GC — точный сборщик, который работает только когда вы явно вызываете
runtime.GC(). - No GC — можно отключить сборку мусора вообще, если вы не выделяете память динамически.
Для жёсткого real-time лучше не использовать make и new в горячих циклах. Вместо этого — статические буферы. Пример bare metal Go без аллокаций:
var buf [64]byte
func readSensor() []byte {
// Читаем данные напрямую в статический буфер
for i := range buf {
buf[i] = readByte() // гипотетическая функция
}
return buf[:]
}
Такой код не создаёт мусора и работает детерминированно.
Таймеры и PWM: точность до микросекунд
В embedded критична работа с временем. TinyGo предоставляет аппаратные таймеры через пакет machine. Создадим ШИМ-сигнал (PWM) для управления яркостью светодиода или сервоприводом:
package main
import (
"machine"
"time"
)
func main() {
// Настраиваем PWM на пине D3 (таймер 2, канал 0)
pwm := machine.Timer2
pwm.Configure(machine.PWMConfig{
Period: 1e6, // 1 кГц
})
ch, _ := pwm.Channel(machine.D3)
pwm.Set(ch, 500000) // 50% duty cycle
for {
time.Sleep(time.Second)
}
}
Этот код генерирует стабильный ШИМ-сигнал, не загружая процессор. Таймеры работают на аппаратном уровне — идеально для управления моторами или светом.
Работа с I2C и SPI: общаемся с датчиками
Б