Firmware в Linux. Коротко и своими словами
Рано или поздно системный программист сталкивается с понятием firmware. В данной статье мы коротко рассматриваем, что это, зачем, и как с этим работать.
Disclaimer:
Материал не является сборником лучших практик и не претендует на фундаментальный труд. Это шпаргалка. Если угодно — application note в свободной форме. Основная задача материала — «сделать короткую статью, которая помогла бы лично мне быстро разобраться, если бы я впервые столкнулся с темой». Если вы знаете что-то лучше — дополнения и замечания по существу приветствуются. Мотивацией для написания послужила недавно встреченная статья, которая как раз объясняла эти аспекты не очень хорошо.
Что такое firmware
Это слово принято переводить как «прошивка». На свете есть множество устройств, центром которых является микроконтроллер или процессор. Чтобы устройство выполняло свою конечную задачу — в него должна быть прошита программа для этого микроконтроллера или процессора. Вот эта программа и называется «прошивка» или «firmware». Простите за такое уточнение на данном ресурсе, но любая современная микроволновка, стиралка или другая бытовая техника — имеют в своем составе микроконтроллер, который, в свою очередь, имеет прошивку.
Периферийные устройства, входящие в состав ПК или встраиваемой системы типа Raspberry Pi, тоже могут быть построены на базе микроконтроллера, и тоже должны иметь прошивку. Ярким примером таких устройств являются Wifi/BT-модули в составе любого ноутбука или смартфона. Ядром такого модуля является микроконтроллер, выполняющий как обсчет радиочасти, так и обеспечивающий взаимодействие с хостом по шине SDIO, PCIe, USB или UART.
Есть, однако, важное отличие:
бытовые девайсы живут сами по себе, без всякого хоста, поэтому прошивка в них живет в энергонезависимой памяти, и при каждом включении устройство сразу выполняет ее, без дополнительных действий. Это базовое условие, т.к. эти устройства самодостаточны. Вряд ли кто-то обрадуется, если стиралка начнет требовать подключения к ПК для выполнения своих функций.
устройства в составе систем типа ПК или смартфона чаще всего имеют в энергонезависимой памяти базовую прошивку, по сути — загрузчик. Основная же прошивка, выполняющая целевые функции, находится на файловой системе хоста. И хост загружает эту прошивку в устройство (в тот же Wifi/BT-модуль).
Не знаю, почему такая схема сложилась, но могу предположить, что основной причиной оказалось удобство. Как любая программа, прошивка может содержать ошибки. А учитывая особенности разработки под микроконтроллеры — она почти наверняка будет содержать ошибки. Поэтому гораздо удобнее для всех, если обновление прошивки можно загрузить в целевое устройство без дополнительных плясок с программатором или переводом устройства в режим прошивки. Именно такую возможность дает этот подход — достаточно лишь обновить сам файл прошивки на целевой файловой системе. То есть, банально скачать его.
Как можно хранить firmware
Есть, в целом, два варианта:
Хранить бинарный файл на файловой системе устройства.
Хранить прошивку как массив бинарных данных в коде драйвера данного устройства.
Первый вариант, в целом, выглядит достаточно удобным, чтоб не изобретать альтернативных. Но второй вариант все же существует, и вот почему. Дело в том, что драйвер постоянно общается с устройством, для которого он предназначен. Логично, что код, реализующий протокол общения, есть и в прошивке, и в драйвере. И здесь возможны варианты.
Некоторые вендоры явно версионируют этот протокол, и перед загрузкой прошивки — проверяют совместмость версий. Так, например, делает Intel в драйвере своего модуля:
https://github.com/torvalds/linux/blob/master/drivers/net/wireless/intel/iwlwifi/cfg/7000.c#L11
/* Highest firmware API version supported */
#define IWL7260_UCODE_API_MAX 17
#define IWL7265_UCODE_API_MAX 17
#define IWL7265D_UCODE_API_MAX 29
#define IWL3168_UCODE_API_MAX 29
/* Lowest firmware API version supported */
#define IWL7260_UCODE_API_MIN 17
#define IWL7265_UCODE_API_MIN 17
#define IWL7265D_UCODE_API_MIN 22
#define IWL3168_UCODE_API_MIN 22
Чаще, однако, вендоры таким не заморачиваются, и никаких явных обозначений версий протоколов обмена между хостом и устройством не декларируется. Гарантируется только корректная работа в сочетании «именно эта версия драйвера+именно эта версия прошивки». И вот в этой парадигме уже вполне логичным выглядит вариант «хранить прошивку прямо в коде драйвера». В этом случае исключается возможность использовать несовместимые версии прошивки и драйвера. Так, например, сделано в драйверах модулей Realtek:
https://github.com/orangepi-xunlong/linux-orangepi/blob/orange-pi-5.4/drivers/net/wireless/rtl88×2bu/hal/rtl8822b/hal8822b_fw.c
#ifdef LOAD_FW_HEADER_FROM_DRIVER
#if (defined(CONFIG_AP_WOWLAN) || (DM_ODM_SUPPORT_TYPE & (ODM_AP)))
u8 array_mp_8822b_fw_ap[] = {
0x22, 0x88, 0x00, 0x00, 0x1B, 0x00, 0x06, 0x00,
0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00,
0x09, 0x15, 0x11, 0x24, 0xE2, 0x07, 0x00, 0x00,
...
Есть и драйверы, использующие оба подхода. Так, например, делает Unisoc (aka Spreadtrum) в драйвере UWE5622. В мейкфайле есть дефайн, определяющий, каким способом работать. В зависимости от этого дефайна, драйвер использует тот или иной способ загрузки:
https://github.com/Ran-Thegoth/uwe5622/blob/main/unisocwcn/platform/wcn_boot.c#L1007
#ifdef CONFIG_WCN_DOWNLOAD_FIRMWARE_FROM_HEX
/* Some customer (amlogic) download fw from /fw/wcnmodem.bin.hex */
WCN_INFO("marlin %s from wcnmodem.bin.hex start!\n", __func__);
mfirmware->data = firmware_hex_buf;
mfirmware->size = FIRMWARE_HEX_SIZE;
mfirmware->is_from_fs = 0;
mfirmware->priv = firmware_hex_buf;
#else /* CONFIG_WCN_DOWNLOAD_FIRMWARE_FROM_HEX */
if (marlin_dev->is_btwf_in_sysfs != 1) {
/*
* If first power on, download from partition,
* else download from backup firmware.
*/
if (marlin_dev->first_power_on_flag == 1) {
WCN_INFO("%s from %s%s start!\n", __func__,
wcn_fw_path[0], WCN_FW_NAME);
ret = request_firmware(&firmware, WCN_FW_NAME, NULL);
Файл же в этом случае исходно лежит как бинарник в составе драйвера, но преобразуется в hex-массив при сборке.
Как загружать firmware
Если абстрагироваться от реализации, то надо:
загрузить файл прошивки из файловой системы в память;
распарсить его заголовок (вдруг прошивка повреждена, или не той версии, например);
передать в целевое устройство по интерфейсу, через который устройство подключено.
Если прошивка хранится в коде в виде массива — то можно переходить к последнему пункту и вопрос чтения файла уже неважен. Если же читать прошивку надо из файловой системы — то для драйвера в ядре есть специальное API, которое подробно описано здесь:
https://www.kernel.org/doc/html/latest/driver-api/firmware/index.html
Вкратце же — ядро дает вызов request_firmware()
, который ищет файл с указанным именем в определенном наборе директорий. Если файл найден — то его содержимое вычитывается в память, и доступно как буфер в памяти. После того, как необходимые действия выполнены — можно отпустить экземпляр firmware вызовом функции release_firmware()
. Там вокруг всего этого есть ряд оберток и прочих вещей, упрощающих использование, но концептуально под капотом именно оно.
Ограничением этого способа является то, что прошивка должна лежать в одном из предопределенных мест. Как правило, это /lib/firmware
. Хотя это можно кастомизировать.
Хотя принципиально никто не запретит из пространства ядра открыть файл через обычный вызов open()
и прочитать содержимое файла через стандартные же системные функции. Вероятно, если углубиться в историю API request_firmware()
, можно найти коллизии, которых таким образом удается избежать. Но в данном случае мы не проводим исторических исследований, просто примем, что доступны оба варианта, и рекомендуется все же использовать первый.
Выше мы практически везде говорили про загрузку прошивки из драйвера, то есть, из пространства ядра. Это, однако, необязательное условие. Существует целый ряд устройств, прошивку в которые загружает код, исполняемый в пространстве пользователя. В первую очередь это те устройства, которые в качестве основного порта общения используют UART. Так, например, это Bluetooth-часть модулей Wifi/BT (да, часто у BT и Wifi в одном модуле — раздельные прошивки). Исходно Bluetooth спроектирован именно таким образом, что хост общается с контроллером по протоколу HCI поверх UART, и именно на это рассчитывает платформенно-независимая часть драйвера Bluetooth в ядре Linux (я имею в виду то, что расположено в директории net/bluetooth в исходниках ядра). Даже есть сам Wifi/BT-модуль аппаратно общается с хостом через другой порт (например, SDIO) — драйвер от вендора создает виртуальный UART поверх SDIO. Так, например, делает уже упомянутый UWE5622 от Unisoc/Spreadtrum — для этого у него выделен целый отдельный модуль ядра:
https://github.com/Ran-Thegoth/uwe5622/blob/main/tty-sdio/Kconfig
config TTY_OVERY_SDIO
tristate "Spard TTY Overy SDIO Driver"
help
Spard tty overy sdio driver
Так вот, в этом случае мы автоматически избавлены от любых коллизий ядра, и можем работать с файлом прошивки, как с любым обычным драйвером. Именно так поступают в пакете BlueZ, используя для этого утилиту hciattach. Вендор, желающий использовать такой вариант, просто добавляет свою платформенно-зависимую часть:
https://github.com/bluez/bluez/blob/master/tools/hciattach_qualcomm.c
И работает с прошивкой, как с обычным файлом:
https://github.com/bluez/bluez/blob/master/tools/hciattach_qualcomm.c#L88
static int qualcomm_load_firmware(int fd, const char *firmware, const char *bdaddr_s)
{
int fw = open(firmware, O_RDONLY);
fprintf(stdout, "Opening firmware file: %s\n", firmware);
FAILIF(fw < 0,
"Could not open firmware file %s: %s (%d).\n",
firmware, strerror(errno), errno);
fprintf(stdout, "Uploading firmware...\n");
И это — все, что стоит знать о сущности firmware в Linux, если вы столкнулись с этим понятием первый раз. Разумеется, есть еще много интересного вокруг этой темы, но это уже частные случаи, которые требуют дополнительных исследований и становятся интересны обычно в ходе отладки каких-то более узких проблем. Поэтому здесь мы рассматривать их не будем, но обязательно вернемся с какими-нибудь другими темами несколько позже.