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.

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

$ sudo apt install qemu-user-static binfmt-support

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

Теперь:

$ sudo update-binfmts --enable
$ sudo update-binfmts --display

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

...
qemu-arm (enabled):
     package = qemu-user-static
        type = magic
      offset = 0
       magic = \x7f\x45\x4c\x46\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x28\x00
        mask = \xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff
 interpreter = /usr/bin/qemu-arm-static
...

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

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

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

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

$ mkdir /tmp/raspbian
$ cd /tmp/raspbian
$ cp ~/Downloads/2018-04-18-raspbian-stretch-lite.zip .
$ unzip 2018-04-18-raspbian-stretch-lite.zip

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

$ truncate -s +2G ./2018-04-18-raspbian-stretch-lite.img

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

$ sudo losetup /dev/loop0 ./2018-04-18-raspbian-stretch-lite.img

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

$ sudo fdisk -l /dev/loop0

И вот что там:

Disk /dev/loop0: 3.8 GiB, 4009754624 bytes, 7831552 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0xc7cb7e34

Device       Boot Start     End Sectors  Size Id Type
/dev/loop0p1       8192   96453   88262 43.1M  c W95 FAT32 (LBA)
/dev/loop0p2      98304 3637247 3538944  1.7G 83 Linux

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

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

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

$ sudo fdisk /dev/loop0

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

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

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

$ sudo fdisk -l /dev/loop0

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

...
Device       Boot Start     End Sectors  Size Id Type
/dev/loop0p1       8192   96453   88262 43.1M  c W95 FAT32 (LBA)
/dev/loop0p2      98304 7831551 7733248  3.7G 83 Linux

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

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

$ sudo partprobe /dev/loop0
$ sudo losetup -d /dev/loop0
$ sudo losetup /dev/loop0 ./2018-04-18-raspbian-stretch-lite.img
$ sudo e2fsck -f /dev/loop0p2
$ sudo resize2fs /dev/loop0p2
$ sudo losetup -d /dev/loop0

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

Монтируем Raspbian

Выполняем:

$ sudo mount ./2018-04-18-raspbian-stretch-lite.img -o loop,offset=$((512 * 98304)) /mnt/raspbian/

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

Готовим QEMU

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

$ sudo cp $(which qemu-arm-static) /mnt/raspbian/usr/bin

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

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

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

$ sudo chroot /mnt/raspbian/ qemu-arm-static /bin/bash
root@linux:/#

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

Linux linux 4.9.0-4-amd64 #1 SMP Debian 4.9.65-3+deb9u1 (2017-12-23) armv7l GNU/Linux

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

Linux linux 4.9.0-4-amd64 #1 SMP Debian 4.9.65-3+deb9u1 (2017-12-23) x86_64 GNU/Linux

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

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

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

root@linux:/# df -h /
/bin/df: Warning: cannot read table of mounted file systems: No such file or directory
Filesystem      Size  Used Avail Use% Mounted on
-               3.6G  949M  2.5G  28% /

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

Git

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

root@linux:/# sudo apt install git

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

...
Get:3 http://mirror.ossplanet.net/raspbian/raspbian stretch/main armhf git armhf 1:2.11.0-3+deb9u2 [3380 kB]
...

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

GHC и cabal-install

Выполняем:

root@linux:/# sudo apt install ghc cabal-install

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

Сборка

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

root@linux:/# cabal update
root@linux:/# cabal install

В моём проекте, в файле 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-устройств можно, и это наш главный вывод!