Go в embedded: Пишем прошивку на языке, который не взрывает стек

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Go и железо: почему это не шутка?

Когда слышишь словосочетание «embedded разработка», обычно представляешь себе Си, ассемблер и мучительную отладку через светодиод. Но мир не стоит на месте. Сегодня Go (или Golang) всё увереннее стучится в дверь мира микроконтроллеров. И стучится не просто так, а с горутинами, сборщиком мусора и встроенными интерфейсами.

В этой статье мы разберёмся, как выглядит embedded разработка на Go, напишем реальный код для гипотетического микроконтроллера и поймём, стоит ли овчинка выделки. Спойлер: да, если вы устали от утечек памяти и любите читаемый код.

Почему Go, а не Си? Три кита

Классический embedded — это Си. Но у Go есть три killer-фичи, которые заставляют инженеров пересматривать привычки:

  • Безопасность памяти: никаких диких указателей, которые могут улететь в космос. Компилятор Go строже, чем Си-компилятор.
  • Горутины: легковесные потоки, которые идеально ложатся на событийно-ориентированную архитектуру микроконтроллеров.
  • Кроссплатформенность: вы пишете код на Mac, а компилируете под ARM Cortex-M. Без танцев с бубном.

Конечно, есть нюанс: нативный Go (gc) не умеет компилироваться для маленьких MCU. Зато есть проект TinyGo — компилятор, который адаптирует Go под железки с килобайтами памяти. Именно на нём мы и сосредоточимся.

Настройка окружения: TinyGo и ваша плата

Прежде чем писать код, давайте подготовим рабочее место. Для embedded разработки на Go вам понадобится:

  1. Установить TinyGo: brew install tinygo (или скачать с официального сайта).
  2. Установить тулчейн для вашей платы (например, ARM GCC для STM32).
  3. Подключить плату через 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: общаемся с датчиками

Б

Блоги

Рекомендаций по книгам