Haskell + Raspberry Pi Zero W = шаг в embedded-мир

В последнее время сообщества разработчиков всё больше увлекаются разными встраиваемыми штуками, IoT, роботы всякие. Да и железо идёт нам на встречу: SBC (Single Board Computer) становятся всё более дешёвыми, а их выбор ширится месяц от месяца. Сегодня мы узнаем, как запустить Haskell-программу на известной модели SBC 2017 года, а именно Raspberry Pi Zero W.

Почему Raspberry Pi?

У меня интерес к этой теме сугубо личный. А на Raspberry Pi Zero W выбор пал по следующим причинам:

  1. Известная марка и не менее известная модель, с большим сообществом вокруг.
  2. Модель дешёвая (порядка $10) и компактная, к тому же есть куча мест, где её можно заказать.
  3. Да, есть ещё одна причина, но о ней говорить в этой заметке смысла нет, это отдельная тема.

Впрочем, выбор в пользу данной модели не окончательный и может измениться в будущем (я знаю, что у Raspberry уже есть куча альтернатив).

А Haskell вообще подходит для SBC?

Это я и хотел выяснить. :-) Ну в самом деле, я видел, как на этих малютках запускают программы на Python, и даже на NodeJS, “ну чем я хуже?!” (С) Фрекен Бок. К тому же это не микроконтроллер с 128 КБ памяти: здесь и оперативки 512 MB, и 1 GHz ARM-процессор, да ещё и MicroSD карта имеется (у меня на 16 GB). В общем, есть где развернуться.

К тому же, не стоит забывать и о преимуществах Haskell, а они в моём проекте необходимы, код должен быть максимально надёжным и предсказуемым.

Операционная система

Работать наш SBC будет под управлением Raspbian, официальной Debian-версии, заточенной специально под Raspberry-устройства. Мы возьмём Raspbian Stretch Lite от 2018-04-18, свеженькую версию без десктопа. О том, насколько она свеженькая, можно судить по ядру, оно здесь 4.14. Качаем архив с образом диска, 2018-04-18-raspbian-stretch-lite.zip, он понадобится нам позже.

На всякий случай уточню: моя основная система - Debian 9.3 с ядром 4.9, все приводимые далее команды тестировались на ней.

Собираем? Погодите…

Существует несколько способов сборки Haskell-проекта под ARM-архитектуру:

  1. классическая кросс-компиляция,
  2. нативная компиляция прямо на Raspberry,
  3. через QEMU-эмуляцию ARM-системы,
  4. через QEMU-эмуляцию пользовательского уровня.

Формально, вы можете выбрать любой из этих способов. Реально же - нет, не любой. И вот почему.

Классическая кросс-компиляция - это решение, традиционно считающееся самым правильным. Идея проста: мы собираем кросс-версию GHC, которая работать будет на одной платформе (на нашей x86_64), а код производить для другой. За подробностями - сюда. И хотя способ этот считается самым каноническим, я отказался от него по причине сложности. Посмотрев на документацию по сборке кросс-компилятора, я подумал, что должен быть, просто обязан быть способ попроще.

Нативная компиляция прямо на SBC - да, это решение, вроде бы напрашивающееся самим собой. Раз есть устройство, и Haskell-программа будет работать на нём - вот на нём и собирай. Но мечты разбиваются о реальность. Haskell - это вам не C. Сборка на самом устройстве оказалась очень, нет, ОЧЕНЬ долгой. А хуже всего то, что она вообще не завершилась, ибо ghc: out of memory. Да, друзья, пол-гигабайта оперативки - это не так уж и много. Даже при том, что сам Raspbian Lite забирает не более 30 MB, и я поставил для GHC максимальное RTS-ограничение по памяти, выделенной кучи всё равно не хватало. И учтите, что мой проект - это, на данный момент, лишь 2к строк, причём без тяжёлых зависимостей типа lens. В общем, не судьба.

QEMU-эмуляция всей ARM-системы - третий путь, который оказался, к сожалению, ещё хуже второго. Идея понятна: QEMU эмулирует SBC целиком, эдакая VirtualBox для Raspberry. Берём QEMU-ядро и ранее упомянутый образ Raspbian, затем запускаем qemu-system-arm - готово, вот вам виртуальный SBC. Однако потолок по памяти тут ещё ниже, а именно 256 МБ (поэтому компилятору приходится отдавать и того меньше). Вроде бы есть способ указать и 512 МБ, но я его не нашёл. А потому сборка происходила ещё медленнее, чем на самом устройстве, ведь всё работает в виртуальной среде. И опять же, довольно быстро мы упираемся в ту же ghc: out of memory.

К счастью, существует и четвёртый путь, которым мы и пойдём. Его можно рассматривать как компромисс между вторым и третьим путями.

QEMU-эмуляция пользовательского уровня

Идея проста: незачем эмулировать всю систему целиком, сэмулируем лишь окружение, позволяющее прямо на нашей основной платформе запускать ARM-нативные исполняемые файлы, такие как ghc и cabal. То есть запускать будет ARM-овский GHC, который и построит нам ARM-овский исполняемый файл, готовый для запуска на Raspberry.

Ставим пакеты:

Пакет binfmt-support обеспечивает поддержку различных бинарных форматов. А пакет qemu-user-static предоставляет ту самую эмуляцию пользовательского уровня, благодаря чему мы сможем на одном CPU запустить программу, скомпилированную для другого CPU.

Теперь:

Результатом второй команды будет список активированных платформ, среди которой и нужная нам:

Перейдём к диску.

Увеличение образа

Скачанный нами образ диска Raspbian Stretch Lite, после распаковки, будет весить приблизительно 1.8 GB. Но из них самой системой уже занято порядка 900 MB, а оставшегося места явно маловато для установки нужных нам инструментов и сборки Haskell-зависимостей. Поэтому образ желательно сразу же расширить.

Всё будет происходить в папке /tmp/raspbian:

Как видите, размер образа 1.8 GiB, увеличим его:

Свяжем образ с виртуальным устройством /dev/loop0:

А теперь заглянем внутрь:

И вот что там:

Раздел с FAT32 нам сейчас неинтересен: он монтируется как boot и предназначен для первичных конфигов SBC (таких как активация SSH и настройка WiFi-доступа). А вот второй раздел, который Linux - это наша цель.

Запомните начало второго раздела, 98304 и размер сектора, 512: эти значения понадобятся нам.

Теперь нужно переформатировать второй раздел, ведь сейчас он всё ещё 1.7 GiB:

Далее, войдя в диалог, вводим следующие команды:

  1. d
  2. 2
  3. n
  4. p
  5. 2
  6. 98304
  7. Просто Enter, принимая умолчальное значение.
  8. N
  9. w

Значение на шаге 6 - это тот самый сдвиг до начала второго раздела с Linux-системой. Теперь, вновь заглянув внутрь:

Мы увидим, что размер увеличился:

Обратите внимание, что сдвиг к началу второго раздела остался прежним, 98304, мы лишь добавили секторов.

Для окончательного изменения размера раздела с ext4-файловой системы выполняем:

В результате размер Linux-раздела будет изменён, и мы наконец-то можем смонтировать наш диск.

Монтируем Raspbian

Выполняем:

где 98304 - тот самый сдвиг до начала Raspbian-системы.

Готовим QEMU

Теперь необходимо разместить исполняемый файл qemu-arm-static внутри Raspbian:

Важно скопировать его именно в /usr/bin: я, в первый раз, скопировал в /bin - нет, не заработало.

Прыгаем внутрь

И теперь начинается волшебство. Прыгаем внутрь Raspbian-системы с помощью chroot:

Готово. Обратите внимание на вывод uname -a:

А вот вывод на моей основной системе:

Видите различие архитектур? armv7l против x86_64. И теперь мы можем устанавливать и запускать ARM-овские пакеты.

А диск точно расширился?

Проверим это:

Всё в порядке, нам доступно 2.5 GB, этого достаточно.

Git

Нам будет нужен код нашего проекта, поэтому устанавливаем git:

В процессе установки вы увидите нечто такое:

Видите armhf? Это значит, что устанавливаемый git - это пакет для архитектуры armhf, у нашего Raspberry именно такая. Кстати, armhf означает ARM hard float, что свидетельствует о способности CPU производить вычисления с плавающей точкой на аппаратном (hardware) уровне.

GHC и cabal-install

Выполняем:

В репозитории пакетов Debian Stretch используется версия ghc-8.0.1. Эта же версия устанавливается и успешно запускается на самом SBC (проверено!), а значит, собранное этим компилятором тут гарантированно заработает и там.

Сборка

После клонирования репозитория целевого проекта делаем обычные:

В моём проекте, в файле package.yaml (а следовательно, и в .cabal), я явно указал версии всех зависимостей, ведь cabal ничего не знает про Haskell LTS.

А почему не Stack?

Причины две:

  1. stack можно установить на ARM-платформу, однако, при выполнении stack setup, он жрёт дисковое пространство как не в себя. Конечно, можно было бы расширить образ диска гигабайт до 5 или 6, но я не вижу большого смысла: компилятор 8.0.1 прекрасно собирает мой код, собираемый на основной системе компилятором 8.2.2.
  2. Когда я попробовал установить компилятор 8.2.2 на ARM, у меня получилось, однако собранная им программа так и не запустилась на SBC, выдав ошибку Illegal instruction. Это значит, что формат исполняемого файла не подошёл. Признаться, я не знаю, почему, но факт остаётся фактом.

В итоге я решил остаться на системном ghc-8.0.1. Конечно, для единства сборки я прописал в stack.yaml именно эту версию компилятора, чтобы в процессе разработки пользоваться тем же, чем и в процессе целевой сборки под Raspberry.

Время сборки

Да, первая сборка, когда собираются все зависимости, получается долгой. Под словом “долгой” я имею в виду не один час. Конечно, на это время непосредственно влияет количество и “вес” Haskell-зависимостей, но быстрой сборки не ждите в любом случае.

Впрочем, утешает то, что это лишь в первый раз, в дальшейшем будет компилироваться лишь собственный код проекта. Ориентировочно, 2k строк моего кода компилируются чуть более 3-х минут. Вполне приемлемо.

Значит можно и CI?

А почему бы и нет? Последовательность шагов в нашем гипотетическом CI-скрипте такова:

  1. Скачиваем архив нашего подготовленного образа диска откуда-нибудь, куда мы предварительно его положили.
  2. Распаковываем архив.
  3. Монтируем образ диска, к той же /mnt/raspbian.
  4. Переходим внутрь, с помощью chroot.
  5. Вытаскиваем последние изменения нашего кода (ну или делаем чистый клон каждый раз).
  6. Собираем, прогоняем тесты, вот этот вот всё.
  7. Выходим из chroot-режима.
  8. Копируем собранные исполняемые файлы в CI-артефакты, чтобы их потом можно было забрать.
  9. Отмонтируем образ диска.

Всё. После этого CI-артефакты можно скачивать на SBC и запускать.

Кэширование

Как известно, CI-сервисы умеют кэшировать данные между сборками, и мы можем воспользоваться этим. Тогда нам не придётся каждый раз скачивать один и тот же архив с образом диска. Вместо этого, на последнем шаге, мы снова сжимаем образ в архив, который, на основе нашего CI-конфига, будет кэшироваться. А это значит, что при следующей сборке наш архив будет восстановлен, и нам останется лишь распаковать его.

Это полезно ещё и потому, что, сделав некие изменения во время сборки №5 (например, установив дополнительные Haskell-зависимости), мы “вспомним” их перед началом сборки №6.

Но вы спросите, а как же размер? Да, мы втискиваемся впритык. Известно, что CI-сервисы ограничивают размер файла/каталога, подлежащего кэшированию. Например, у Travis CI это 1 GB. Размер архива образа диска, используемого в этой статье, получается разным, в зависимости от используемого сжатия:

  1. .zip - 1.2 GB,
  2. .tgz - 1.2 GB,
  3. .bz2 - 1.1 GB,
  4. .7z - 781 MB.

Как видите, p7zip способен сжать образ до приемлемого для кэширования размера. Он, конечно, и сжимает дольше остальных архиваторов, но результат важнее.

Выводы

Собирать Haskell-проекты для Raspberry-устройств можно, и это наш главный вывод!