Go и Arduino: два честных сценария — хост-программа по последовательному порту и TinyGo на микроконтроллере

Go и Arduino: два честных сценария — хост-программа по последовательному порту и TinyGo на микроконтроллере

Миф, который надо убить первым

Откройте любой поиск по запросу «Arduino на Go» — и вы упрётесь в статьи, где автор радостно показывает, как написать func loop() и прошить это в Uno. Почти всегда это либо устаревшая информация, либо подмена понятий, либо откровенная выдумка. Поэтому начнём не с того, «как», а с того, «почему нет», потому что понимание границы спасает больше времени, чем любой пример кода.

Короткий ответ: на классических Arduino на AVR (Uno, Nano, Mega) скетч на Go написать нельзя. Не «сложно», не «нужна библиотека» — а физически невозможно по двум независимым причинам, и обе проверяются за минуту.

Первая причина — архитектура процессора. Стандартный компилятор Go генерирует нативный код для ограниченного набора архитектур, заданных парами GOOS/GOARCH (операционная система и набор команд). AVR — это отдельный набор команд, и в списке поддерживаемых Go-целевых платформ его нет. Компилятор Go попросту не умеет производить машинный код для AVR. Это не баг и не «пока не добавили» — это фундаментальное свойство инструмента, и ждать, что оно появится в обозримом будущем, не приходится.

Вторая причина — память, и она даже нагляднее. Возьмём самую популярную плату, Uno, на микроконтроллере ATmega328P. Согласно даташиту на этот чип, у него 32 килобайта флеш-памяти программ (из которых около полкилобайта занято загрузчиком, то есть для скетча остаётся чуть меньше 32 килобайт), 2 килобайта оперативной памяти и 1 килобайт энергонезависимой памяти данных. Теперь посмотрим, что такое «hello world» на Go, собранный стандартным компилятором. Даже с агрессивным удалением отладочной информации и без использования библиотеки Си, минимальный бинарник Go измеряется сотнями килобайт, а типично — мегабайтами, потому что в каждый бинарник Go вкладывается среда выполнения: планировщик лёгких потоков, сборщик мусора, диспетчер, таблицы типов. Этих сотен килобайт и мегабайт в 32 килобайта флеш и 2 килобайта оперативной памяти не помещается ни при каких условиях. Точка. Никакая «оптимизация» не сожмёт среду выполнения Go до размеров, совместимых с AVR Uno.

Именно поэтому связка «Go и Arduino» в реальности распадается на два разных сценария, и путать их — главная ошибка новичка. Первый сценарий: Go живёт на обычном компьютере и управляет Arduino как периферийным устройством. Второй сценарий: на микроконтроллере работает не стандартный Go, а отдельный компилятор подмножества Go, который умеет генерировать компактный код под конкретные архитектуры. Разберём оба честно.

Сценарий первый: Go как хост, Arduino как периферия

Это самый надёжный, самый старый и самый «проверенный» путь, и он работает на любой Arduino-плате — хоть на AVR Uno, хоть на ESP32, хоть на ARM-плате. Идея простая: скетч на плате по-прежнему пишется на языке Arduino (по сути Си-плюс-плюс с обвязкой), а Go-программа на компьютере выступает «мозгом», который общается с платой по последовательному порту и при необходимости прошивает её.

Здесь Go не лезет в микроконтроллер вообще. Он решает задачи, для которых он и создан: сетевой ввод-вывод, обработка данных, логику, базы данных, веб-сервер, очередь, агрегацию показаний. А плата делает то, для чего она хороша: читает ножки, меряет датчики, крутит моторы, зажигает светодиоды. Это честное разделение труда, и оно не требует чудес.

Как устроено общение по последовательному порту

Классический Arduino подключается к компьютеру по USB, и на компьютере это видится как последовательный порт (на Linux обычно /dev/ttyACM0 или /dev/ttyUSB0, на Windows — COM3, COM4 и так далее). Скетч на плате вызывает Serial.begin, Serial.read, Serial.println — и всё это течёт по этому порту. Go-хост открывает тот же порт и читает/пишет в него байты.

Чтобы общение было предсказуемым, используют построчный протокол: каждая команда и каждый ответ заканчиваются переводом строки, и обе стороны читают до перевода строки. Это не какая-то магия — это ровно то, как устроены текстовые протоколы вроде HTTP по сути: разделение на строки, парсинг, ответ. Ниже — типовая карта такого протокола для проекта-термометра.

Хост (Go) -> Плата (скетч Arduino)
  "READ\n"        -> запрос текущего показания
  "SET 25\n"      -> установить порог, например 25 градусов
  "PING\n"        -> проверка живости

Плата (скетч Arduino) -> Хост (Go)
  "T=24.6,H=51.2\n"   -> температура и влажность
  "OK\n"              -> подтверждение установки порога
  "ERR sensor\n"      -> ошибка чтения датчика

Скетч на стороне Arduino читает строку из Serial, смотрит первое слово-команду, выполняет и печатает ответ строкой. Go-хост пишет команду с переводом строки и читает ответ до перевода строки. Всё. Никаких двоичных фреймов, никаких CRC, никаких таймеров — для настольного проекта по проводам этого более чем достаточно, а усложнять протокол имеет смысл только при шуме линии или больших скоростях.

Прошивка Arduino из Go через avrdude

Второе, что умеет и часто нужно в этом сценарии, — прошивать плату программно, без открытия Arduino IDE. Здесь Go снова не изобретает протокол загрузчика, а делегирует это проверенному инструменту. Для AVR-плат канонический программатор — avrdude, утилита, которая существует десятилетиями и знает все протоколы загрузчиков (Optiboot по последовательному порту, ISP через программатор, STK500 и так далее). Go вызывает avrdude как внешний процесс через стандартную библиотеку os/exec, и вот здесь я могу дать конкретный код без оговорок, потому что os/exec — это стабильная часть Go, API которой не менялся годами.

package main

import (
	"context"
	"fmt"
	"os/exec"
	"time"
)

// flashUno прошивает скомпилированный скетч в Arduino Uno через avrdude.
// sketchHex — путь к .hex-файлу, полученному из Arduino IDE или из
// командной сборки (arduino-cli compile ... --output-dir ...).
// port — последовательный порт платы: на Linux /dev/ttyACM0, на Windows COM3.
func flashUno(ctx context.Context, port, sketchHex string) error {
	args := []string{
		"-p", "m328p",        // процессор ATmega328P (Uno/Nano)
		"-c", "arduino",      // программатор = загрузчик Arduino по serial
		"-P", port,           // порт платы
		"-b", "115200",       // скорость загрузчика Optiboot на Uno
		"-D",                 // не стирать флеш перед записью
		"-U", "flash:w:" + sketchHex + ":i", // записать .hex во флеш
	}

	cmd := exec.CommandContext(ctx, "avrdude", args...)
	out, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("avrdude завершился с ошибкой: %w\nвывод:\n%s", err, out)
	}
	fmt.Printf("прошивка успешна:\n%s\n", out)
	return nil
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	if err := flashUno(ctx, "/dev/ttyACM0", "build/sketch.ino.hex"); err != nil {
		fmt.Println(err)
	}
}

Этот код я даю уверенно: флаги avrdude для Uno канонические, а os/exec.CommandContext/CombinedOutput — стандартная библиотека. Единственные внешние допущения, которые надо проверить под свою машину: имя порта (на Linux это /dev/ttyACM0 для плат с原生 USB-интерфейсом вроде Uno R3/SMD и /dev/ttyUSB0 для плат с мостом CH340/FTDI), скорость загрузчика (для Uno с Optiboot это 115200, но на старых загрузчиках и других платах бывает 57600 — это берётся из даташита/настроек платы, см. статью про чтение даташита) и права доступа к порту. На Linux пользователю, запускающему прошивку, обычно нужна группа dialout (или uucp на некоторых дистрибутивах), иначе avrdude не откроет порт. Это не особенность Go, а особенность прав операционной системы на последовательные устройства.

Для ESP32 и ARM-плат avrdude не подходит — у них другие загрузчики. ESP32 прошивается по своему протоколу загрузчика Espressif (по UART или по Wi-Fi), ARM-платформы вроде Zero/MKR — через bossac/протокол SAM-BA или через Mass Storage (плата видится как флешка). В этих случаях Go так же вызывает внешний инструмент (esptool.py для ESP32, bossac для SAMD), но конкретные флаги этих утилит я в этом ответе не перечитывал и дословно не привожу — сверяйте с их официальной документацией, по тому же принципу, что и в статье про даташит: не угадывать флаг, а открыть справочник инструмента.

Чтение данных с платы в Go

Здесь начинается зона, где я обязан поставить рамку. Для открытия последовательного порта из Go нужен пакет, потому что стандартная библиотека Go не имеет удобного переносимого API для последовательных портов (низкоуровнево это syscall/termios под Linux и свои вызовы под Windows, писать это руками — боль и источник ошибок). Де-факто стандартный пакет для этой задачи в Go-сообществе — go.bug.st/serial. Я уверен, что он существует и является основным выбором, но точные сигнатуры его функций (Open, SetMode, Read, Write и структура режима с baud-рейтом) я в этом ответе не перечитывал по документации пакета, поэтому не буду выдавать их за проверенные дословно. Ниже — каркас, показывающий архитектуру, с явной пометкой, где свериться с docs.

// Каркас чтения построчного протокола с Arduino.
// ТОЧНЫЙ API пакета go.bug.st/serial сверь с его официальной документацией:
// имена функций, структура Mode и поля (BaudRate и т.п.) могут отличаться
// от версии к версии. Я не перечитывал docs пакета в рамках этой статьи,
// поэтому привожу структуру логики, а не гарантированно компилируемый код.

// 1. Открыть порт по имени (/dev/ttyACM0 на Linux, COM3 на Windows).
// 2. Задать скорость, совпадающую с Serial.begin в скетче (например 115200).
// 3. В цикле: записать команду "READ\n", прочитать ответ до "\n",
//    распарсить "T=24.6,H=51.2" в числа, передать дальше (в лог, в HTTP, в БД).
// 4. Обрабатывать таймауты чтения и ошибки порта как штатные события,
//    а не как падения: плата может быть выдернута, датчик может вернуть ERR.

Архитектурно всё тривиально и проверяемо: порт открыт, скорость согласована с обеих сторон, построчное чтение-запись. Конкретные вызовы — в документацию go.bug.st/serial. Это честнее, чем вставить «красивый» код, который не соберётся из-за угаданного поля структуры.

Скелет скетча на стороне Arduino

Чтобы картина была полной, вот каркас скетча, который соответствует построчному протоколу выше. Язык здесь — Arduino (Си-плюс-плюс с обвязкой), и это правильно: скетч остаётся в родной для платы экосистеме, а Go работает снаружи.

// Каркас скетча для Uno/Nano: построчный протокол с хостом на Go.
// Точные имена функций библиотеки датчика сверяйте с её документацией
// (см. статью про поделку с DHT и OLED про библиотеки Adafruit).

void setup() {
  Serial.begin(115200);          // скорость совпадает с -b 115200 у avrdude
  // инициализация датчика, например dht.begin();
}

void loop() {
  if (Serial.available() > 0) {
    String line = Serial.readStringUntil('\n');
    line.trim();

    if (line == "PING") {
      Serial.println("PONG");
    } else if (line == "READ") {
      // float t = ...; float h = ...;  // чтение датчика
      // Serial.printf("T=%.1f,H=%.1f\n", t, h);
      Serial.println("T=0.0,H=0.0");   // заглушка, пока датчик не подключён
    } else if (line.startsWith("SET ")) {
      // парсинг числа после "SET ", установка порога
      Serial.println("OK");
    } else {
      Serial.println("ERR unknown");
    }
  }
}

Обратите внимание на деталь, которая связывает эту статью со статьёй про питание: Serial.begin(115200) и -b 115200 у avrdude — это не совпадение по вкусу, а требование согласования скорости на обеих сторонах, ровно как в статье про питание согласовывались напряжения и тайминги. Рассогласование здесь даст «мусор» в порту, и новичок будет искать баг в парсере Go, хотя дело в скорости.

Сценарий второй: TinyGo — компилятор подмножества Go на микроконтроллере

Теперь про тот самый «скетч на Go», который существует, но не там, где все ждут. TinyGo — это отдельный компилятор, который принимает подмножество языка Go и генерирует компактный код под встраиваемые архитектуры. Он не является стандартным Go-компилятором и не обещает совместимости со всем языком; это инструмент со своей целью — дать Go-синтаксис там, где полный Go не влезает.

Ключевое, что надо понять про TinyGo, прежде чем радоваться: он не спасает сценарий «прошу Go в Uno». TinyGo тоже не поддерживает AVR как целевую архитектуру, и даже если бы поддерживал, 2 килобайта оперативной памяти Uno слишком малы для среды выполнения TinyGo (у него тоже есть сборщик мусора и планировщик, просто заметно легче, чем у полного Go). То есть на Uno/Nano/Mega TinyGo не работает. Это важно сказать прямо, потому что многие статьи про «Go на Arduino» молча подразумевают именно TinyGo, но рисуют при этом картинку с Uno.

Где TinyGo реально работает — это платы на архитектурах, для которых у него есть бэкенды и достаточно памяти:

  • ESP32 и ESP8266 (наборы команд Xtensa) — и это естественная точка стыковки со статьями про ESP32-S3 и про новинки процессоров: та же плата, которую мы прошивали через Arduino IDE и через esptool, умеет принимать и TinyGo-код.
  • ARM-платформы на Cortex-M: например, Arduino Nano 33 BLE (на чипе nRF52840), Arduino Zero/MKR (на SAMD), Raspberry Pi Pico (на RP2040), micro:bit.
  • Ряд других встраиваемых целей, полный список которых меняется от релиза к релизу.

Точный перечень поддерживаемых плат и имена таргетов для флага -target при сборке я в этом ответе не перечитывал по документации TinyGo, поэтому не привожу их как гарантированный список. Канонический способ узнать, что доступно в вашей версии TinyGo, — команда tinygo targets, которая печатает именно те имена, что понимает установленный компилятор. Это ровно тот принцип «иди в первоисточник, не угадывай», который мы разбирали в статье про даташит: список таргетов живёт в самом инструменте, а не в моей памяти.

Что даёт TinyGo и чем он ограничен

TinyGo позволяет писать логику на знакомом синтаксисе Go и получать бинарник, который влезает в сотни килобайт флеш и десятки-сотни килобайт оперативной памяти — то есть в ESP32 и в ARM-платы, но не в AVR. Это делает его удобным для проектов, где хочется Go-стиль (статическая типизация, простые структуры, каналы и лёгкие потоки там, где они поддержаны) без перехода на Си-плюс-плюс.

Но это подмножество, и ограничения надо знать заранее, иначе проект упрётся в стену на середине:

  • Не все возможности стандартной библиотеки Go доступны; что именно поддержано для вашего таргета — смотрите в документации TinyGo по стандартной библиотеке.
  • Часть возможностей языка работает иначе или ограничена (например, отражение типов, небезопасные указатели, некоторые аспекты дженериков и интерфейсов) — точный список зависит от версии и сверяется с docs.
  • Поведение лёгких потоков и сборщика мусора на микроконтроллере отличается от десктопа: есть задержки, есть стоимость памяти, и «бесплатные» горутинки на десктопе здесь не бесплатны.
  • Отладка и экосистема библиотек беднее, чем у Arduino IDE или у полного Go: многие драйверы датчиков придётся писать самим или брать из репозиториев драйверов TinyGo, а не из мира Arduino-библиотек.

Конкретные флаги сборки (-target, -opt, -size) и точные имена таргетов я сознательно не привожу дословно как «рабочую команду», потому что не перечитывал их в этом ответе; направление такое: сборка идёт через tinygo build/tinygo flash с таргетом из tinygo targets, а прошивка — либо встроенным механизмом tinygo flash для поддерживаемой платы, либо внешним инструментом (esptool для ESP32, bossac/Mass Storage для SAMD), как и в первом сценарии. Точные параметры — в официальную документацию TinyGo.

Когда выбирать TinyGo, а когда — хост-сценарий

Это не вопрос «что круче», а вопрос соответствия задаче, и рамка тут простая.

Нужна логика на компьютере: сеть, база, веб, агрегация, много данных
  -> сценарий первый: Go-хост + скетч на Arduino по последовательному порту
  -> работает на ЛЮБОЙ плате, включая Uno

Нужен компактный код прямо на микроконтроллере, и плата не AVR
  (ESP32, ESP8266, ARM-плата с достаточной памятью)
  -> сценарий второй: TinyGo

Нужен код на микроконтроллере, но плата — AVR Uno/Nano/Mega
  -> TinyGo не подходит, полный Go не подходит
  -> пишите скетч на Arduino (Си-плюс-плюс), а Go используйте снаружи

Нужна максимальная экосистема библиотек датчиков и «всё из коробки»
  -> сценарий первый со скетчем на Arduino, потому что Arduino IDE
     имеет огромную базу готовых библиотек, которой у TinyGo нет

Обратите внимание на последний пункт: даже когда TinyGo технически возможен, экосистема Arduino-библиотек (те самые Adafruit-библиотеки из статьи про поделку) часто перевешивает, и тогда разумнее писать скетч на Arduino, а Go держать на хосте. TinyGo выигрывает там, где вам критичен именно Go-синтаксис и где вы готовы платить меньшим набором готовых драйверов.

Практический мини-проект, связывающий обе статьи

Соберём то, что уже разбирали, в один контур. Берём поделку из статьи про термогигрометр: Arduino Uno читает DHT22 и показывает на OLED. Теперь добавляем Go-хост, который не трогает дисплей, а забирает показания по последовательному порту и пишет их в файл или отдаёт по HTTP — то есть превращает настольную станцию в сетевое устройство телеметрии, не меняя скетч радикально.

Архитектура получается такая: скетч на Uno по-прежнему рисует температуру на OLED (это его локальная задача, и он с ней справляется), и параллельно по запросу хоста печатает строку T=...,H=... в последовательный порт. Go-хост открывает порт, периодически шлёт READ\n, читает ответ, парсит числа и складывает в лог или в HTTP-ответ. Прошивка скетча — через avrdude из Go, как в примере выше. Ни один из двух процессов не лезет в чужую память: плата делает своё, хост делает своё, связь — построчный протокол по проводам.

Это и есть взрослый ответ на «Go и Arduino»: не «пишем скетч на Go», а «Go оркеструет Arduino». И если завтра вы захотите уйти от Uno к ESP32-S3 ради Wi-Fi (тема статей про ESP32-S3 и про новинки процессоров), сценарий первый останется валидным без изменений — поменяется только инструмент прошивки (avrdude на esptool) и транспорт (последовательный порт на сокет по Wi-Fi), а идея «хост на Go управляет платой» не изменится. А сценарий второй с TinyGo на ESP32-S3 тоже станет возможен, потому что это уже не AVR.

Чек-лист перед стартом

  • Поняли, какая у вас плата: AVR (Uno/Nano/Mega) или не-AVR (ESP32/ESP8266/ARM). От этого зависит, доступен ли TinyGo вообще.
  • Для AVR приняли как факт: скетч пишется на Arduino, Go работает снаружи. Не тратьте время на попытки «прошить Go в Uno».
  • Согласовали скорость последовательного порта на обеих сторонах (Serial.begin в скетче и скорость открытия порта в хосте; для прошивки Uno — -b 115200 у avrdude).
  • Проверили права на порт в операционной системе (на Linux — группа dialout/uarp-аналог, см. примечание выше; на Windows — что порт не занят Arduino IDE).
  • Для прошивки не-AVR-плат выбрали правильный внешний инструмент (esptool для ESP32, bossac/Mass Storage для SAMD) и сверили его флаги с документацией инструмента, а не по памяти.
  • Для TinyGo уточнили список таргетов командой tinygo targets в своей установленной версии и прочитали раздел об ограничениях подмножества в официальной документации, прежде чем переносить туда существующий Go-код.
  • Для API пакета последовательного порта (go.bug.st/serial) сверили сигнатуры с его официальной документацией, потому что каркас в этой статье намеренно не является гарантированно компилируемым кодом.
  • Держали в голове питание и тайминги: построчный протокол по проводам прощает многое, но просадки и рассогласование скоростей ломают чтение так же, как в статье про питание ломают радио.

Итог

«Go и Arduino» — это не одна технология, а два разных вопроса, и смешивать их значит гарантированно нарваться на миф. Первый вопрос: «можно ли писать скетчи на Go для Uno?» — нет, и это проверяется картой памяти ATmega328P из даташита и отсутствием AVR в целевых архитектурах Go; среда выполнения полного Go физически не влезает в 32 килобайта флеш и 2 килобайта оперативной памяти. Второй вопрос: «можно ли использовать Go в проекте с Arduino?» — да, и даже двумя способами: Go как хост, управляющий платой по последовательному порту и прошивающий её через проверенные внешние инструменты вроде avrdude (работает на любой плате), либо TinyGo как компилятор подмножества Go прямо на микроконтроллере (работает на ESP32, ESP8266 и ARM-платах, но не на AVR). Выбор между ними — это выбор между «логика на компьютере» и «компактный код на плате», и он определяется задачей, а не модой.

Эта статья естественно встраивается в цепочку блога: железо и выбор чипа разбирались в ESP32-S3 и в Новинках процессоров, практическая поделка с датчиком и дисплеем — в Термогигрометре с OLED на Arduino, почему «плавают» показания и как это связано с питанием и таймингами — в Питании ESP32 и ESP32-S3, а принцип «не угадывай цифру, иди в первоисточник», который я применял к флагам avrdude, к таргетам TinyGo и к API пакета последовательного порта, — в Как читать даташит. Маркировка резисторов в обвязке датчика — в Маркировке SMD-резисторов, а место локальной обработки сигналов в общей картине — в Искусственном интеллекте для радиолюбителей.