Разработка
Разработка для встроенных систем с использованием Swift
В последние годы встроенная разработка становится все сложнее из-за быстрого развития оборудования. Вся отрасль остро нуждается в каких-то новых способах улучшить ситуацию. Но все равно результата нет. И Swift действительно подходит для такого случая использования.
Всем привет! Поскольку tkremenek сказал, что в сообществе Swift появятся новые рабочие группы, я хочу поделиться своим опытом разработки для встраиваемых систем с использованием Swift. Я знаю, что это должно быть очень незнакомо большинству из вас, ребята. Но разработка встраиваемых систем в последние годы находится в тренде, особенно в области Интернета вещей. Аппаратное обеспечение становится все более мощным, а программное обеспечение все более сложным. Кроме того, большинству разработчиков встраиваемых систем приходится использовать C без каких-либо альтернатив. В результате разработка программного обеспечения для устройств превратилась в разочарование.
Действительно, отрасль ищет улучшения. Но универсального решения до сих пор нет. Я считаю, что у Swift есть потенциал стать чем-то лучшим в этой области (еще один кандидат — Rust). У команды Swift также есть планы по расширению Swift в системное программировании. Давайте заставим Swift править миром :)
Что такое «Встроенное, Bare-metal, реальное время, микроконтроллер, Arduino, Raspberry Pi»
Извините, что вставил так много технических терминов в начале. Я встречал так много людей, которые чувствовали себя сбитыми с толку этими терминами. Конечно будешь сбит с толку, потому что трудно найти какие-либо точные определения, даже если погуглить. Прежде всего, я дам краткое объяснение для них.
- Встроенное. Навскидку, значение встроенного приложения интуитивно понятно. Это относится к типу программного обеспечения, встроенного в любую машину, кроме компьютера общего назначения. Но в наши дни его значение становится расплывчатым и запутанным. В гугле можно найти много разных объяснений. Я предпочитаю тот, что есть в википедии: если основные программные функции инициируются/контролируются не через человеческий интерфейс, а через машинные интерфейсы, вы можете назвать это встроенным приложением. От простой прошивки, которая управляет микроволновой печью, до сложной автомобильной HMI на базе Android. Многие люди могут подумать, что это связано со сложностью и производительностью приложений или с использованием ОС, но это не так.
- Bare-metal (Голый металл). Этот термин означает, что ваш код работает на оборудовании напрямую, без какой-либо типичной ОС. Это не имеет ничего общего с конкретным оборудованием. Вы можете написать программу на простом микроконтроллере или (возможно) на сложной машине x86-64.
- В режиме реального времени. Программы реального времени должны гарантировать ответ в течение заданных временных ограничений, часто называемых «крайними сроками». Короче говоря, время работы должно быть детерминированным. Вы можете написать программу реального времени непосредственно на оборудовании (Bare-metal) или на основе ОС реального времени. Кстати, типичные ОС, такие как macOS, Windows, Linux, iOS, Android, не гарантируют работу в реальном времени, поэтому очень сложно (в большинстве случаев невозможно) написать программу реального времени на основе этих платформ.
- Микроконтроллер/MCU. Микроконтроллер — это небольшой компьютер, объединенный с различными периферийными устройствами. Большинство периферийных устройств используются для связи с внешними машинами или датчиками. Как правило, микроконтроллер содержит всё (ЦП, ПЗУ, ОЗУ, периферийные устройства) в одном чипе. Вам просто нужно включить его, после чего начнется выполнение кода. Обычно производительность/сложность микроконтроллера намного меньше, чем у любого обычного компьютера, поэтому разработчики предпочитают писать свои приложения непосредственно на оборудовании (Bare-metal). Но в последние годы ситуация меняется. Многие микроконтроллеры быстро становятся все более и более мощными и сложными. Для них сложно разрабатывать Bare-metal приложения.
- Arduino. Очень известный бренд/компания, производящая серию плат микроконтроллеров. Платы могут быть запрограммированы с помощью C или C++. Самым большим преимуществом Arduino является огромное количество библиотек C/C++ и огромное сообщество. При программировании платы Arduino вы фактически программируете «голое железо». Тем не менее, вам не нужно иметь дело с низкоуровневым аппаратным обеспечением, потому что Arduino предоставляет серийный API C/C++, с которым очень легко начать работу инженерам-программистам.
- Raspberry Pi. RPi выглядит как Arduino, но совершенно другой. RPi — это компьютер общего назначения, но небольшого размера (к тому же очень дешевый). Аппаратная архитектура похожа на мобильный телефон или планшет. Он может работать на стандартном Linux. Разработка приложений для RPi такая же, как и любых других приложений Linux. RPi предоставляет некоторые периферийные устройства, поэтому вы можете использовать его для связи (через API драйвера Linux) с внешними датчиками. Я думаю, что это единственное сходство с Arduino.
Разница при программировании для компьютера общего назначения и встроенного
Зная эти термины выше, вы можете обнаружить основное различие между обычным и встроенным приложением: первое предназначено для людей, а второе — для машин.
Обычное приложение работает на компьютерах общего назначения в нашей повседневной жизни: ПК, Mac, мобильный телефон, планшет и т.д. На этих компьютерах люди могут устанавливать любое приложение, которое им нравится, и запускать их одновременно. В этой ситуации компьютер управляется через человеческий интерфейс. Основная задача ОС — гарантировать, что все приложения/процессы не будут влиять друг на друга.
Напротив, приложение во встроенном устройстве в основном ориентировано на машины. Все требования фиксированы. Поэтому нормально работает только одно выделенное приложение.
Разница в железе: MMU
Нетрудно заметить множество различий между компьютером общего назначения и встроенным устройством/микроконтроллером. Но пусть вас не смущают эти сложные детали. ИМХО, ключевое отличие только одно — наличие блока управления памятью (MMU, Memory Management Unit).
MMU — это аппаратная единица компьютера, которая решает, поддерживает ли центральный процессор ОС общего назначения. Это дает ОС возможность запускать несколько приложений/процессов одновременно. Каждый процесс имеет собственное виртуальное адресное пространство. MMU сопоставляет их с фактической физической оперативной памятью.
Напротив, микроконтроллер используется в более стабильных условиях. Ему не нужно поддерживать несколько процессов. Весь код, включая приложение, библиотеки, ОС (если она у вас есть) использует одно и то же адресное пространство. Кроме того, все аппаратные компоненты, такие как ПЗУ, ОЗУ, периферийные устройства, также отображаются в одно и то же адресное пространство.
Единое адресное пространство значительно упрощает вычислительную архитектуру и задает еще одну характеристику микроконтроллера — место, где хранится приложение.
Мы знаем, что обычное приложение хранится во внешнем хранилище, таком как жесткий диск, SSD, EMMC, SD-карта и т.д. Файловая система позволяет ОС находить и загружать нужное приложение. ОС должна скопировать приложение в оперативную память перед запуском. Затем ЦП и MMU могут работать вместе для преобразования адресов памяти и запуска приложения.
При разработке приложения для микроконтроллера все адреса фиксируются после линкинга. Затем вы можете поместить приложение в ПЗУ, у которого есть собственный адрес. Таким образом, запуск вашего кода после включения устройства занимает всего несколько миллисекунд. Это называется выполнением на месте (XIP, eXecute In Place).
Разница в программном обеспечении: операционная система
Как программист Swift, вы должны быть знакомы с разработкой приложений для macOS или iOS. ОС управляет компьютерным оборудованием, программными ресурсами и предоставляет общие службы для приложений. ОС похожа на огромный черный ящик. Все ресурсы ограничены системным API. Вы не можете общаться с оборудованием напрямую, а лишь через пакет системных API. Любое простое приложение может зависеть от множества скрытых библиотек системного уровня (большинство из них линкуются динамически). ОС поможет вызывать эти библиотеки во время выполнения.
Напротив, каждое приложение микроконтроллера является автономным. Это означает, что все вещи статически связаны друг с другом, включая код приложения, зависимые библиотеки и саму ОС. В таком контексте ОС обычно предоставляется на уровне исходного кода (иногда в двоичном виде), и вы можете рассматривать ее как обычную библиотеку планировщика, как и любые другие зависимости. Собственно, для описания такой ОС есть термин: Библиотечная операционная система (Library operating system).
В большинстве случаев программная арка для микроконтроллеров намного проще, чем для компьютеров общего назначения. В такой ситуации ОС — это просто еще одна зависимость, вы даже можете изменить (но не рекомендуется этого делать) ОС, если вам это нужно.
Почему Swift и как?
Предпосылки
C по-прежнему является популярным языком для низкоуровневого программирования встраиваемых систем. Это из-за того, что:
- C является наиболее широко поддерживаемым языком различных аппаратных арок в мире встраиваемых систем. Считается своего рода портативным ассемблером. У вас нет другого выбора, если вы ориентируетесь на редко встречающуюся аппаратную платформу.
- Несмотря на то, что количество встроенных устройств огромно, большинству из них требуется относительно простая программная архитектура. В таких случаях вам не нужно использовать более высокую абстракцию, предоставляемую современными языками программирования.
В последние годы встраиваемые устройства развиваются, и, следовательно, быстро растут как производительность, так и сложность. Разработчики встраиваемых систем пробуют разные способы справиться с растущей сложностью. Есть Arduino (C++), MicroPython, Espruino (JavaScript), TinyGo (Golang), Meadow (C#) и т.д. Но, кроме C++, ни один из этих языков не предназначен для программирования на системном уровне.
Насколько мне известно, в последние десятилетия только Swift и Rust объявляют себя (современными) языками системного уровня. Когда у меня впервые возникла мысль о портировании Swift для встраиваемых систем, я тщательно сравнил Swift и Rust. Мой вывод таков: Rust слишком сложен для разработки приложений. Но сейчас, в 2022 году, вы можете видеть, что Rust уже завоевал репутацию языка программирования системного уровня. Все больше и больше людей пытаются использовать Rust в разработке встраиваемых систем и у них действительно активное сообщество. Невероятно, неужели в наши дни люди обожают страдания? :) Да ладно! Swift! Мы можем это сделать!
Вот некоторые типичные функции, которые должен иметь язык системного уровня:
- Компилируемый язык
- Простота взаимодействия с C API
- Сильная типизация
- Быстрота
- Нет GC
- Детерминированный
Эти функции есть и в Swift, и в Rust. К сожалению, сейчас Swift не такой быстрый и детерминированный, как Rust. Потому что за кодом много скрытых операций, и найти их сейчас сложно. Поскольку основная команда поставила это в качестве основной цели в Swift 6, я считаю, что все готово к приходу Swift в мир встраиваемых систем.
Кросс-компиляция
Как мы все знаем, любой язык программирования в конце концов переводится на ассемблер (я не буду здесь обсуждать интерпретируемые языки, они относительно медленные по сравнению с компилируемыми языками).
Набор инструментов Swift/LLVM, естественно, является кросс-компилятором. Он предоставляет простой способ перевода кода Swift для различных аппаратных архитектур.
Вот очень простая демонстрация:
func empty() { return }
У нас есть empty.swift, вы можете использовать такую команду, чтобы транслировать его в сборку для другой аппаратной архитектуры:
swiftc -target x86_64-apple-macosx12.0.0 -c empty.swift
В зависимости от вашей платформы вы можете столкнуться с ошибкой:
error: unable to load standard library for target ***
Просто добавьте аргумент -parse-stdlib, он говорит компилятору не искать стандартную библиотеку Swift. Вот так:
swiftc -target x86_64-apple-macosx12.0.0 -parse-stdlib -c empty.swift
Изменив цель, вы можете перевести исходный код в объектные файлы для разных арок/систем. Вот некоторые примеры:
- x86_64-apple-macosx12.0.0
- x86_64-unknown-windows
- x86_64-unknown-linux
- arm64-apple-macosx12.0.0
- arm64-unknown-linux
- arm64-apple-ios15.0.0
В текущем проекте MadMachine я добавил цель thumbv7em-none-unknown-eabi для микроконтроллера серии ARM Cortex-M в цепочку инструментов Swift. Благодаря удобному фреймворку проекта Swift/LLVM включить новую цель очень просто.
Так это сделано? Можете ли вы сейчас написать код Swift для микроконтроллеров Cortex-M? Еще нет!
Среда выполнения
Как видно из демонстрации, аппаратных арок всего две: x86_64 и arm64. Но что здесь делают эти ОС? Раз арки одинаковые, то разве у них не должна быть одинаковая сборка? Технически так и должно быть. Но ваш код Swift нуждается в помощи некоторых фундаментальных функций, предоставляемых ОС.
Например, когда вы создаете экземпляр класса в коде Swift, вам нужно некоторое пространство памяти в куче. Различные ОС могут иметь разные API для этой операции. Таким образом, Swift toolchain реализовал очень фундаментальный уровень абстракции, чтобы покрыть эти различия. Эта (библиотека) называется Swift Runtime и она реализована на C++ (поэтому она может напрямую взаимодействовать с C API ОС). В приведенном выше примере это функция swift_allocObject. Среда выполнения используется для очень низкоуровневого управления, такого как кастинг, ARC, метаданные и т.д. Это не что-то волшебное, а еще одна низкоуровневая библиотека. Кстати, вы не можете напрямую обращаться к этой Runtime библиотеке в коде Swift, она используется компилятором Swift только при необходимости.
Если вы хотите использовать Swift на некоторых новых архитектурах или операционных системах, вам необходимо сначала связать вызовы среды выполнения с API ОС или, по крайней мере, заглушить их. После того, как вы реализовали эти API, используемые Runtime, большая часть кода Swift сможет работать правильно.
Подключение Swift Runtime к Zephyr
Как я уже упоминал ранее, встраиваемые устройства становятся все более сложными и имеют более высокую производительность. Для проекта MadMachine я выбрал микроконтроллер серии NXP RT10xx с ядром ARM Cortex-M7 с частотой 600 МГц и различными сложными периферийными устройствами. Использовать все возможности в bare-metal непросто.
Поэтому я использую ОС реального времени под названием Zephyr. Это проект Linux Foundation, который фокусируется на областях, которые Linux не может охватить. Он предоставляет набор стандартных API для доступа к низкоуровневым периферийным устройствам. Многие полупроводниковые компании, такие как NXP, вносят свой вклад в реализацию этих API. Так что вам не нужно тратить столько сил на низкоуровневые детали при разработке приложений.
Zephyr Real-time OS поставляется с исходным кодом и использует Kconfig для настройки ядра (точно так же, как ядро Linux). Обычное приложение на C, основанное на Zephyr, компилируется вместе с исходным кодом ОС. Но для определенного оборудования вы можете скомпилировать весь уровень ОС как библиотеку и использовать ее во время компоновки. Так работает проект MadMachine.
Стандартная библиотека Swift
После подключения Swift Runtime и Zephyr все сложные части готовы. Теперь вы можете перекрестно скомпилировать всю стандартную библиотеку Swift в ожидаемую цель: thumbv7em-none-unknown-eabi.
Тут появляется новая проблема. Как я уже говорил, приложение микроконтроллера должно статически связать все вместе. Swift std — это такая огромная библиотека, что размер двоичного файла превышает 2 МБ. В настоящее время этот размер неприемлем для большинства микроконтроллеров (размер ПЗУ < 2 МБ по-прежнему является основным).
В традиционной разработке C очень легко включить оптимизацию времени компоновки, чтобы уменьшить размер двоичного файла приложения:
- Разделить каждую функцию/данные C на разные сегменты при компиляции в объектные файлы.
- Удалить те сегменты, которые не используются в приложении при линковке.
Но Swift использует некоторые новые технологии, такие как метаданные, поэтому такая оптимизация не очень полезна. Из-за того, что линкер не может сделать вывод, используются ли эти функции/данные в этих объектных файлах сейчас.
В настоящее время плата микроконтроллера MadMachine включает в себя 32 МБ внешней оперативной памяти. Двоичный файл приложения связан с начальным адресом ОЗУ. Разработчикам необходимо скопировать двоичный файл приложения на встроенную SD-карту. После включения платы микропрограмма в ПЗУ скопирует это приложение на указанный адрес ОЗУ, а затем начнет выполнение.
Помните, что такое XIP? Да, эта плата содержит 8 МБ ПЗУ. Технически вы можете связать свое приложение с адресом ПЗУ и записать в ПЗУ, поэтому вам больше не нужна SD-карта (относительно медленно записывает данные в ПЗУ).
Возможности для улучшений
Язык развивается
Как я упоминал ранее, команда Swift обратит внимание на производительность и детерминированность в Swift 6. Это самые важные функции для программирования в мире встраиваемых систем. Я перечислю их здесь:
- Быстродействие
- Детерминированность
Размер двоичного файла
Затем размер двоичного файла становится критической проблемой. Даже если аппаратное обеспечение со временем будет развиваться, мы должны попытаться улучшить ситуацию. В настоящее время существует в основном два пути:
- Реализовать еще одну стандартную библиотеку Swift, предназначенную для ограниченных сред.Это будет единственный выбор для некоторых младших микроконтроллеров (они могут иметь только 64 КБ ПЗУ или даже меньше). Но в такой ситуации, я думаю, люди предпочитают использовать C, а не любые современные языки.
- Оставьте текущий Swift std, но сделайте его более удобным для оптимизации во время компоновки.Лично я предпочитаю этот способ. Во многих случаях люди выбирают язык не из-за самого языка, а из-за мощных встроенных функций и библиотек. Нам лучше постараться сохранить фундаментальный стандарт неизменным, чтобы мы могли использовать всю экосистему, а не синтаксис языка.
В отличие от Rust, в котором стандартная библиотека разделена на две части (core и std), стандартная библиотека Swift разработана как единый модуль с высокой степенью связанности из-за требований к производительности. Это затрудняет уменьшение его размера. Даже если вы используете стандартный тип Int или простые операторы присваивания, вам придется импортировать всю стандартную библиотеку.
Кстати, команда Swift усердно работает над тем, чтобы внедрить технологию оптимизации во время компоновки. Мы просто не знаем, насколько уменьшится размер двоичного файла. Надеюсь, это сработает отлично!
Параллелизм
Swift добавил эту недостающую часть в Swift 5.5. Я не рассматривал это внимательно. Кажется, основная часть реализована в виде runtime библиотеки. Она зависит от внешней библиотеки libdispatch, которая зависит от ряда API-интерфейсов ОС, таких как pthread (пожалуйста, поправьте меня, если я ошибаюсь).
Это действительно недружественно для Bare-metal разработки или RTOS. Нам еще предстоит расколоть этот крепкий орешек.
Резюме
Встроенная/Bare-metal разработка существует уже почти полвека. Она почти все это время эквивалентна программированию на ассемблере или C. В последние годы встроенная разработка становится все сложнее из-за быстрого развития оборудования. Вся отрасль остро нуждается в каких-то новых способах улучшить ситуацию. Но все равно результата нет. И Swift действительно подходит для такого случая использования.
ИМХО, Swift проще расшириться в совершенно новую и растущую область, чем заменить другие языки в какой-то существующей области. Тем более, что конкурентов в этой вертикали практически нет. Пожалуйста, следите за обновлениями. Это должно быть круто и весело!