[Перевод] Ускорение процесса разработки под Embedded Linux
Любой программист, решивший заняться разработкой под Embedded Linux, придя буть-то из высокоуровневых языков программирования, либо из программирования микроконтроллеров на С/С++, неизбежно оказывается удивлен крайней недружелюбностью embedded linux. Текстовый блокнот и консольные утилиты вместо столь привычных IDE, и отладка по логам вместо отладки программатором сильно замедляют процесс разработки. В статье описывается, как мне удалось снизить время доставки изменений до целевого железа при кросс-компиляции в 10 раз.
Кросс-компиляция
Под кросс-компиляцией подразумевается процесс, когда сборка прошивки целевой железки (target device) под управлением Embedded Linux производится на удаленной машине (host machine), которой, как правило, является обычный ПК. С этим приходится сталкиваться при разработке кода под относительно слабые микропроцессоры с ограниченными объемами доступной оперативной и постоянной памяти (128 Мб и 32 Мб соответственно в нашем случае).
В этом случае, типовой процесс обновления программного кода на целевой платформе состоит из трех этапов:
доставка исходников программного кода до хоста (проводится лишь один раз при первой сборке прошивки). git clone в нашем случае.
сборка всей прошивки целевой железки либо отдельного исполняемого файла на хосте. Утилитой make в нашем случае.
доставка свеже-собранной прошивки/исполняемого файла до целевой платформы. Утилиты scp и sysupgrade в нашем случае.
Больше всего времени, естественно, уходит на второй этап. Особенно сильно он замедляется в случае работы с тяжеловесными библиотеками, тянущими за собой кучу зависимостей. В нашем случае это библиотеки tensorflow-lite (для нейронок) и alexa voice sdk (голосовой ассистент) .
На все три этапа (полная пересборка прошивки и перепрошивка железки) у нас уходило 150 минут. Наиболее редко применяемый сценарий.
Второй этап в случае повторной пересборки прошивки занимал 30 минут. В случае пересборки тяжеловесного приложения, применящего нейронки, — 13 минут.
Третий этап сам по себе (при обновлении прошивки) занимал минут 5: вначале прошивка копировалась с хоста на целевое железо утилитой scp. После чего производилось обновление прошивки утилитой sysupgrade.
В какой-то момент времени такое положение дел стало вымораживать, поскольку разработка выливалась в многочасовые потери времени на компиляцию. В итоге, нам удалось получить:
16 минут вместо 150 минут для всех трех этапов вместе при первой сборке прошивки;
3 минуты вместо 30 минут для второго этапа в случае пересборки всей прошивки и 1,5 минуты вместо 13 минут при перекомпиляции отдельного приложения.
10 секунд вместо 5 минут для третьего этапа самого по себе.
Наиболее просто удалось максимально ускорить, естественно, первый этап. Для этого оказалось достаточно доплатить пару сотен рублей, чтобы переключиться на самый быстрый интернет тариф нашего провайдера.
Ускоряем процесс кросс-компиляции
Долго сомневались, будет ли смысл, но все же пошли ва-банк, начитавшись статей о вдохновляющих бенчмарках, и раскошелились на рабочую станцию на базе процессора AMD Threadripper 3970X (64 ядра):
попутно приобретя видео-карту NVIDIA GeForce 3080TI для работы с нейронками, 128 Гб оперативной памяти и 1 Тб ssd диск. Закончил сборку системника уже глубокой ночи, установил Ubuntu и запустил сборку прошивки, естественно, не забыв про make -j64… Разочарованию не было предела, когда получил 30 минут, вместо обещанных бенчмарками 2 минут. Плюнул на все и лег спать.
С утра принялся выжимать все соки из дорогостоящей покупки. Первым делом запустил htop параллельно с процессом компиляции. Лишь изредка наблюдал подобную картину:
иногда такую:
но, чаще всего, такую:
Это говорило о том, что задействуются не все ядра. Ответ в интернете нашел быстро: Enable parallel builds by default?. Недолго думая закоммитил соответствующие изменения к себе в репу и получил заветные 3 минуты на пересборку прошивки и 1,5 минуты на пересборку приложения. htop показывал полную загрузку. Радости не было предела! Деньги не ушли на ветер!
Вдобавок к этому попробовал поиграться работой из RAMDISK:
$ sudo mkdir /mnt/ramdisk
$ sudo mount -t tmpfs -o rw,size=2G tmpfs /mnt/ramdisk
$ df -h
$ cd /mnt/ramdisk
$ git clone ...
но особого ускорения не заметил.
Единственный, омрачавший радость, нюанс, — сборка иногда, непредсказуемым образом падала на самым разных пакетах. Погуглив, выяснилось, что такое случается, — нестабильность при много ядерной работе.
Но этот кейс довольно легко закрыл костылем. В сборочном скрипте заменил:
make -j $(nproc);
на:
compiled_successful_flag=0;
compile() {
compiled_successful_flag=0;
if make -j $(nproc); then
compiled_successful_flag=1;
return
else
return
fi
}
...
while [ $compiled_successful_flag -eq 0 ];
do
compile;
sleep 1;
done
т.е., повторный запуск сборки при падении. Замедление в таких случаях было незначительных и даже при падениях удавалось собрать образ за 3–4 минуты.
Ускоряем процесс обновления прошивки целевой железки
Погуляв на просторах интернета, узнал, что вместо копирования прошивки утилитой scp и ее обновления утилитой sysupgrade можно грузить и ядро, и файловую систему по сети, с помошью tftp и nfs.
Сразу же возникло желание включить и tftp-сервер и nfs-сервер в состав репозитория, чтобы отладив все один раз в будущем не тратить на это время при переезде на другой хост. И в этом помогли великие и могучие docker и docker-compose. Если вкратце, — то это волшебная пилюля, которая позволяет разработчику запускать свой софт на любой другой машине одним нажатием кнопки ($ docker-compose up -d
) не тратя время на бесконечные ошибки, неизбежно возникающие при установке и запуске каких-либо программ в первый раз или на другом ПК.
Итак,
Запуск TFTP сервера для загрузки ядра
Его Dockerfile
:
FROM debian:stretch-slim
MAINTAINER danrue drue@therub.org
# https://github.com/danrue/docker-tftpd-hpa
RUN apt-get update && \
apt-get install -y --no-install-recommends \
tftpd-hpa && \
rm -rf /var/lib/apt/lists/*
CMD echo -n "Starting " && in.tftpd --version && in.tftpd -L --user tftp -a 0.0.0.0:69 -s -B1468 -v /srv/tftp
docker-compose.yml
файл:
version: '3.4'
services:
tftpd-hpa:
container_name: tftp
build: .
volumes:
- ./volume:/srv/tftp
ports:
- 69:69/udp
restart: always
Запуск на хосте командой:
$ docker-compose up -d
После этого остается положить в каталог volume образ ядра (uImage). В моем случае это делается командой:
$ cp bin/targets/imx6ull/cortexa7/openwrt-imx6ull-cortexa7-tensorflow_wifi_dev-uImage _utilities/tftpd-hpa/volume/
Исходники можно найти тут.
Все готово для загрузки ядра загрузчиком (u-boot, в нашем случае). Но перед тем как переключиться в консоль целевой железки проверьте состояние фаерволла:
$ sudo ufw status
и если он включен, то временно отключите его:
$ sudo ufw disable
Загрузка ядра с tftp-сервера
Ядро грузит загрузчик. В случае, если у вас это u-boot, то он, скорее всего умеет это делать из коробки. Для этого, в консоли вашей целевой железки введите reboot
. Дождитесь перезагрузки и, увидев обратный отсчет таймера, нажмите Enter. Если увидите значок стрелочки: =>
, то вы в консоли U-boot.
Итак, первым делом сохраняем текущие настройки загрузки:
=> setenv defbootcmd "$bootcmd"
=> saveenv
Saving Environment to SPI Flash...
board_spi_cs_gpio bus 2 cs 0
SF: Detected w25q256 with page size 256 Bytes, erase size 4 KiB, total 32 MiB
Erasing SPI flash...Writing to SPI flash...done
к которым вы в дальнейшем сможете вернуться в любой момент:
=> setenv bootcmd "$defbootcmd"
=> saveenv
Теперь задаем сетевые настройки, чтобы u-boot знал, куда стучаться за образом ядра:
=> setenv ipaddr 192.168.31.99
=> setenv ethaddr 86:72:04:c5:7e:83
=> setenv serverip 192.168.31.37
=> setenv uimage openwrt-imx6ull-cortexa7-tensorflow_wifi_dev-uImage
=> printenv loaddadr
где 192.168.31.99 — это IP-адрес, присваиваемый железке, а 192.168.31.37 — это IP-адрес хоста, на котором вы ранее запустили tftp-сервер. openwrt-imx6ull-cortexa7-tensorflow_wifi_dev-uImage — название файла (образ ядра, который вы ранее положили в каталог volume на хосте), который нужно скачать с tftp-сервера. loadaddr
— адрес, по которому нужно его расположить для дальнейшей загрузки (у вас он, скорее всего уже задан).
Попробуйте пингануть сервер:
=> ping ${serverip}
Using FEC device
host 192.168.31.37 is alive
Теперь попробуйте скачать образ с сервера:
=> tftp ${loadaddr} ${serverip}:${uimage}
Using FEC device
TFTP from server 192.168.31.37; our IP address is 192.168.31.99
Filename 'openwrt-imx6ull-cortexa7-tensorflow_wifi_dev-uImage'.
Load address: 0x82000000
Loading: #################################################################
#################################################################
#########################################################
345.7 KiB/s
done
Bytes transferred = 2742194 (29d7b2 hex)
Отлично. Значит пока нигде не напартачили и можно сохранять настройки и запускать ядро:
=> saveenv
=> bootm ${loadaddr}
## Booting kernel from Legacy Image at 82000000 ...
Image Name: ARM OpenWrt Linux-4.14.199
Image Type: ARM Linux Kernel Image (uncompressed)
Data Size: 2742130 Bytes = 2.6 MiB
Load Address: 80008000
Entry Point: 80008000
Verifying Checksum ... OK
Loading Kernel Image ... OKStarting kernel ...[ 0.004494] /cpus/cpu@0 missing clock-frequency property
[ 0.128440] imx6ul-pinctrl 2290000.iomuxc-snvs: no groups defined in /soc/aips-bus@02200000/iomuxc-snvs@02290000
[ 0.917942] fec 20b4000.ethernet: Invalid MAC address: 00:00:00:00:00:00
[ 1.800403] mxs-dcp 2280000.dcp: Failed to register sha1 hash!
[ 7.239994] DHCP/BOOTP: Reply not for us on eth1, op[2] xid[e2dbd4aa]
Press the [f] key and hit [enter] to enter failsafe mode
Press the [1], [2], [3] or [4] key and hit [enter] to select the debug level
Please press Enter to activate this console.
Теперь снова возвращайтесь в консоль U-boot и настройте автоматическую загрузку ядра с TFTP-сервера с последующей загрузкой:
=> setenv devbootcmd "tftp ${loadaddr} ${serverip}:${uimage}; bootm ${loadaddr}"
=> setenv bootcmd "$defbootcmd"
=> saveenv
Теперь u-boot должен будет автоматически грузить образ с сервера и стартовать с него. Файловая система, при этом, пока еще, будет использоваться дефолтная. Та, что у вашей железки во флеш-памяти/microSD-карте.
Запуск NFS-сервера для загрузки файловой системы
Следующий шаг — научиться монтировать сетевую файловую систему.
Для начала, аналогично tftp-серверу соберем и запустим в контейнере NFS-сервер.
Dockerfile
NFS-сервера:
FROM alpine:3.6
MAINTAINER Tang Jiujun
# https://github.com/tangjiujun/docker-nfs-server
RUN set -ex && { \
echo 'http://mirrors.aliyun.com/alpine/v3.6/main'; \
echo 'http://mirrors.aliyun.com/alpine/v3.6/community'; \
} > /etc/apk/repositories \
&& apk update && apk add bash nfs-utils && rm -rf /var/cache/apk/*
EXPOSE 111 111/udp 2049 2049/udp \
32765 32765/udp 32766 32766/udp 32767 32767/udp 32768 32768/udp
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
entrypoint.sh
, используемые в Dockerfile:
#!/bin/bash
set -ex
: ${EXPORT_DIR:="/nfsshare"}
: ${EXPORT_OPTS:="*(rw,fsid=0,insecure,no_root_squash,no_subtree_check,sync)"}
mkdir -p $EXPORT_DIR
echo "$EXPORT_DIR $EXPORT_OPTS" > /etc/exports
mount -t nfsd nfsd /proc/fs/nfsd
# Fixed nlockmgr port
echo 'fs.nfs.nlm_tcpport=32768' >> /etc/sysctl.conf
echo 'fs.nfs.nlm_udpport=32768' >> /etc/sysctl.conf
sysctl -p > /dev/null
rpcbind -w
rpc.nfsd -N 2 -V 3 -N 4 -N 4.1 8
exportfs -arfv
rpc.statd -p 32765 -o 32766
rpc.mountd -N 2 -V 3 -N 4 -N 4.1 -p 32767 -F
и docker-compose.yml
:
version: '3.4'
services:
nfs:
build: .
container_name: nfs
volumes:
- ./volume:/nfsshare
ports:
- 111:111
- 111:111/udp
- 2049:2049
- 2049:2049/udp
- 32765-32768:32765-32768
- 32765-32768:32765-32768/udp
privileged: true
restart: always
запускаем точно также как и TFTP-сервер:
$ docker-compose up -d
Программы разные (tftp-hpa
и nfs-kernel-server
), а запускаются одной и той же командой).
Осталось распаковать в каталог volume
архив с файловой системой, который у вас должен был быть скомпилировать вместе с образом ядра. В моем случае:
$ tar -xzvf bin/targets/imx6ull/cortexa7/openwrt-imx6ull-cortexa7-device-tensorflow-wifi-dev-rootfs.tar.gz –directory _utilities/nfs/volume/
Протестировать сервер можно с другой машины в той же сети командой:
$ sudo mount -v -o vers=3 192.168.31.37:/nfsshare /home/al/mnt
mount.nfs: timeout set for Fri Aug 20 23:28:36 2021
mount.nfs: trying text-based options 'vers=3,addr=192.168.31.37'
mount.nfs: prog 100003, trying vers=3, prot=6
mount.nfs: trying 192.168.31.37 prog 100003 vers 3 prot TCP port 2049
mount.nfs: prog 100005, trying vers=3, prot=17
mount.nfs: trying 192.168.31.37 prog 100005 vers 3 prot UDP port 32767
$ ls mnt/
bin etc mnt overlay rom sbin tmp var
dev lib mobilenet_v1_0.25_128_quant.tflite proc root sys usr www
где 192.168.31.37 — IP-адрес машины, на которой вы запустили сервер, а /home/al/nfs — каталог, к которому вы примонтируете удаленную файловую систему.
Загрузка с NFS-сервера
Теперь пришло время загрузиться с NFS-сервера. Первым делом необходимо выяснить, включена ли в вашем дистрибутиве поддержка NFS. Для этого, на хосте, в корне репозитория с вашими исходниками Linux нужно ввести:
make menuconfig
В открывшемся окне нужно найти опцию Compile the kernel with rootfs on NFS. В моем случае она расположена по пути: Global build settings-->Kernel build options-->Compile the kernel with rootfs on NFS
:
Если видите значок *
, значит вы можете загрузиться с NFS задав лишь дополнительные настройки в u-boot. Если нет (как было в моем случае), то вам потребуется:
включить данную опцию, нажав кнопку
Y
выйдите из конфигуратора, сохранив изменения.
пересоберите образ ядра по новой и сохраните результирующий uImage файл в каталоге volume вашего TFTP-сервера.
перезагрузите u-boot на всякий случай, если вдруг вы уже успели загрузить образ ядра командой
tftp
Итак, единственная настройка в u-boot, необходима для того, чтобы примонтировать сетевую файловую систему вместо файловой системы, расположенной на флеш-накопителе:
=> printenv bootargs
=> setenv defbootargs "${bootargs}"
=> setenv devbootargs "ip=dhcp console=ttymxc1 rootwait rw root=/dev/nfs nfsroot=${serverip}:/nfsshare,nolock,v3,intr,hard,noacl"
=> setenv bootargs "${devbootargs}"
=> saveenv
Обратите внимание на вывод первой команды и запомните его.
После этого введите:
=> reset
U-boot перезагрузится, передаст управление ядру. После этого введите:
# dmesg | grep nfs
[ 0.000000] Kernel command line: console=ttymxc0,115200 rootwait fixrtc quiet ip=dhcp console=ttymxc1 rootwait rw root=/dev/nfs nfsroot=192.168.31.37:/nfsshare,nolock,v3,intr,hard,noacl
[ 4.175865] VFS: Mounted root (nfs filesystem) on device 0:10
Отлично! Сетевая файловая система примонтирована. Теперь любое изменение на хосте в каталоге volume TFTP-сервера будет моментально отображаться в файловой системе целевой железки. Для проверки можете в каталоге volume NFS-сервера (на хосте) создать какой-нибудь файл:
$ touch nfs/volume/hello
Он должен сразу же отобразиться в файловой системе железки:
# ls /
Development и production режимы загрузки
В U-boot вы теперь можете любой момент включать отладочный (сетевой) режим загрузки:
=> setenv bootcmd "${devbootcmd}"
=> setenv bootargs "${devbootargs}"
=> saveenv
таки и «продакшн» режим загрузки:
=> setenv bootcmd "${defbootcmd}"
=> setenv bootargs "${defbootargs}"
=> saveenv
или:
=> setenv bootcmd "${defbootcmd}"
=> setenv bootargs
=> saveenv
если ранее => printenv bootargs
вам ничего не выдало.
Даже прошив флешку утилитой sysupgrade, но с флагом -c вы сохраните переменные загрузчика U-boot и сохраните возможность в любой момент переключиться в отладочных режим. Можно пойти еще дальше и разработать отладочную и продуктовую конфигурации прошивки. Коммиты, реализующие данный функционал.
В чем же его преимущества? В том, что любое изменение на хосте в каталоге volume TFTP-сервера будет моментально отображаться в файловой системе целевой железки. Дело осталось за малым написать скрипт, который будет копировать свеже-собранный образ ядра и распаковывать свеже-собранный архив файловой системы в соответствующие каталоги volume TFTP- и NFS-серверов. Таким образом, длительность третьего этапа (доставки изменений в коде) до целевой железки сокращается до 10 секунд в худшем случае (время перезагрузки ядра) при внесении изменений в ядро Линукс и до 0 секунд при внесении изменений в приложения пользовательского пространства!
Итоги
В общем, длительность наиболее типового сценария разработки кода (второй + третий этап): пересборки ядра либо пользовательского приложения с последующей доставкой на целевое железо удалось сократь с 30+ минут до 2–3 минут! Это дало возможность тестировать порядка 20 изменений в коде на целевой платформе, вместо 1–2 изменений ранее. Скорость разработки возрасла на порядок! Даже не верится, что раньше я мирился с такими потерями времени!
P.s. Фото видео с полей разработки embedded linux — в Телеграм канале.