Впереди 14 февраля. Можно спорить об уместности этого праздника в наших краях, а можно направить энергию в мирное русло. Например, откопать Arduino, щедро обсыпать светодиодами и сформовать их во что-то сердечкоподобное. Неубедительно? Согласен. Давайте так: откопаем в дальней коробке макетку на STM32, забудем, что у нас есть готовые библиотеки и подёргаем регистры, выгрызая каждый байт ROM у злобного компилятора. Потом сделаем всё тоже самое, но без фанатизма, с привлечением CMSIS библиотек и сравним результаты. Возможно даже сделаем выводы. Будет код, надругательство над таблицей векторов. Ардуинка тоже будет, куда ж без неё.

Преамбула

Статья ориентирована на тех, кто интересуется разработкой под голое железо либо сделал это своей профессией. Любители найдут три способа написать "Hello world" на светодиодной панели а профессионалы, возможно, найдут полезными выводы из тех цифр, которые мы получим в итоге сравнения этих трёх подходов. В основном цифры будут касаться объема получившегося кода и могут стать дополнительным аргументом в холиварах "Регистры vs HAL".

Изначально я планировал просто набросать за вечерок что-то интересное для ребёнка. Он сейчас в такой стадии, когда привлекает всё, что мигает и светится. Идея была абсолютно спонтанной и поэтому провалилась. В итоге ребёнок получил светофор на базе конструктора "Вольтик", с бонусом от папы в виде автоматического переключения под управлением платой Digispark, а папа получил Nucleo-F103RB с прикрученной матрицей 8×32 красных светодиода и готовым кодом управления этой матрицей по SPI. Кстати, если у кого-то есть идея, какую игрушку можно таки из этой матрицы сделать, прошу поделиться в комментариях. Тетрис не предлагать.

В итоге родилась демка с сердечками. Хочу поделиться ею с общественностью. Вдруг, кому-то именно такая нужна на предстоящий Валентинов день? Если поторопиться, то ещё есть время сбегать в магазин.

Окружение

Проект написан в среде Keil MDK-ARM. Свежую версию можно скачать с официального сайта. Отсутствие лицензии накладывает ограничение на выходной файл программы в 32 кБ, но для нашего случая это несущественно. Эту среду часто ругают за убогий редактор кода, отсутствие тем оформления, глючность навигации по коду и пр. Всё это есть, но есть и кое-что ещё – очень удобный менеджер пакетов. Настройка окружения будет состоять из пяти простых шагов:

  1. Скачать Keil MDK-ARM версии 5.37 или выше;

  2. Установить с настройками по умолчанию;

  3. Скачать проект / клонировать репозиторий с проектом;

  4. Открыть в папке проекта файл keilprj/led_string.uvprojx;

  5. Подождать, пока среда скачает все необходимые для проекта зависимости.

Всё, окружение готово. Да, чуть не забыл. Это всё справедливо для ОС Windows. Для обладателей linux-машин все не намного сложнее – Keil прекрасно работает в Wine.

Железо

Нам понадобится:

Светодиодная матрица на базе MAX7219, состоящая из четырёх последовательно соединённых сегментов, каждый из которых представляет собой микросхему MAX7219 и матрицу 8×8 светодиодов.

Плата разработчика Nucleo-F103RB. Можно взять что попроще: Blue Pill, например. А то и вовсе голый чип STM32F103 с любыми буквами. Обвязка чипа должна обеспечивать питание и доступ к пинам PA5, PA6, PA7.

Опционально:

Пять проводов для arduino-монтажа с разъёмами мама-мама. Рекомендую использовать провода, склеенные в шлейф. На работоспособности это никак не скажется, но смотреться будет аккуратней. Если вы решите использовать blue-pill, например, приклеенный с обратной стороны LED-матрицы, то с проводами думаю разберётесь сами.

Кабель USB-mini. Нужен на этапе программирования платы Nucleo. Пишу на всякий случай, т.к. кабель из моды вышел и может отсутствовать на вашем рабочем месте. Опять же, если используете внешний программатор – действуйте по обстоятельствам.

Программатор. ST-Link, J-Link и пр. Это если вы используете вместо nucleo что-то такое, на чём набортный программатор отсутствует.

Arduino UNO. Если душа к STM не лежит, но хочется посмотреть результат. Подойдёт любая arduino-совместимая плата.

Сборка

Принципиальную схему приводить не буду, ограничусь таблицей соединений проводов.

Контакт на LED матрице

CN8, CN9 (CN10) на nucleo

Ножки МК

Arduino

VCC

5V (8)

-

5V

GND

GND (9)

-

GND

DIN (MOSI)

PWM/MOSI/D11 (15)

PA7

11

CS (SS)

MISO/D12 (13)

PA6

10

CLK (SCK)

SCK/D13 (11)

PA5

13

Структура проекта

Итак, железка лежит на столе, проект открыт. Давайте посмотрим, что там есть интересного. Проект имеет три различных цели сборки (таргета). Итоговый результат – один, но под капотом всё немного по-разному. Начнём разбор с таргета CMSIS_drv. В этом таргете самописными являются только три исполняемых файла: main.c – основная логика приложения, генерация и анимация картинок; max7219.c/h – это драйвер микросхемы MAX7219; spi.c/csp.h – инициализация периферии МК. Из периферии мы настраиваем только модуль SPI. Этот файл используется в связке с заголовочным файлом csp.h. Почему это так, объясню несколькими абзацами ниже.

По сути – эти три файла олицетворяют три слоя нашего приложения. Main – прикладной уровень. Здесь мы реализуем пользовательские фишки и преобразуем их в команды MAX7219. Никто не мешает нам использовать другие микросхемы для реализации матрицы светодиодов. Мы можем использовать другую микросхему. Можем, например, вообще взять адресные светодиоды на WS2812b. Код прикладного уровня от этого поменяться не должен. Здесь я немного лукавлю. Код из main.c напрямую обращается к функциям драйвера max7219.c. Любой проект можно довести до совершенства. Правда, на это нужно бесконечное время, а до 14 февраля осталось совсем чуть-чуть.

Взглянем на код main.c
#include "stm32f10x.h"
#include "csp.h"
#include "max7219.h"

#if(1)
/// @brief Побитное зеркалирование 4-байтного слова
/// @example 0x80000010 -> 0x08000001
#define RBIT(dw) __RBIT(dw)
#else
uint32_t RBIT(uint32_t in)
{
    uint32_t result = 0;
    
    for (uint32_t i = 32; i; i--)
    {
        result <<= 1;
        result |= (in & 1);
        in >>= 1;
    }

    return result;
}
#endif

/// Структура строки изображения
typedef union
{
    uint32_t dw;
    uint16_t w[2];
    uint8_t  b[4];
} buf_str_t;

/// Половинка "сердца"
const uint8_t heart[STR_CNT] = {0x80, 0x40, 0x20, 0x10, 0x08, 0x08, 0x88, 0x70};

/// Буфер изображения
buf_str_t img_buf[STR_CNT];

/// Загрузить изображение
static void load_img()
{
    for (uint32_t i = 0; ++i <= STR_CNT;)
    {
        const uint32_t tmp = img_buf[i-1].dw;
        max7219_send_data(i, tmp | RBIT(tmp));
    }
    csp_delay(16);
}

/// @brief Эффект пульсации (изменение яркости)
/// @param cnt Количество пульсаций
static void pulse(const uint32_t cnt)
{
    for (uint32_t i = cnt; i-- > 0;)
    {
        for (uint32_t i = 0; ++i < 0x20;)
        {
            max7219_send_cmd(MAX7219_BRIGHTNESS, i ^ (0xF * (i >> 4)));
            csp_delay(2 << (i >> 4));
        }
        csp_delay(200);
    }
}

/** @defgroup Animation Функции анимации
 *  @{ ********************************************************************************************/

/// Сигнатура функции преобразования строки изображения
typedef void(*step_t)(const uint32_t, const uint32_t);

/// @brief Применение функции преобразования к изображению
/// @param j Количество кадров анимации
/// @param fn_p Функция преобразования строки изображения
void step(int8_t j, step_t fn_p)
{
    for (; j >= 0; j--)
    {
        for (int32_t i = STR_CNT; --i >= 0;)
        {
            fn_p(i, j);
        }

        load_img();
    }
}

/// @brief Функция преобразования "одно сердце"
/// @param i номер строки
/// @param j номер кадра
void one_heart(const uint32_t i, const uint32_t j)
{
    img_buf[i].dw = (uint16_t)heart[i] << 8 >> j;
}

/// @brief Функция преобразования "два сердца"
/// @param i номер строки
/// @param j номер кадра
void dbl_heart(const uint32_t i, const uint32_t j)
{
    img_buf[i].dw = ((uint32_t)heart[i] >> j) | (uint32_t)heart[i] << (16 - j);
}

/// @brief Функция преобразования "слияние сердец"
/// @param i номер строки
/// @param j номер кадра
void uni_heart(const uint32_t i, const uint32_t j)
{
    (void)j;

    img_buf[i].w[0] <<= 1;
    img_buf[i].w[1] >>= 1;
}

/** @} ********************************************************************************************/

/// Инициализация
__STATIC_FORCEINLINE void init()
{
    csp_spi_init();

    csp_spi_nss_inactive();

    // Base initialisation of MAX7219
    max7219_send_cmd(MAX7219_TEST, 0x00); // 1 - on, 0 - off
    max7219_send_cmd(MAX7219_SCAN_LIMIT, STR_CNT - 1);
    max7219_send_cmd(MAX7219_BRIGHTNESS, 0x00); // 0x00..0x0F
    max7219_send_cmd(MAX7219_DECODE_MODE, 0x00); // 0 - raw data
    max7219_send_cmd(MAX7219_SHUTDOWN, 0x01); // 1 - active mode, 0 - inactive mode
}

/// @brief Основная функция
/// @return Нафиг не нужен, но против стандарта не попрешь
int main()
{
    init();

    step(15, one_heart);
    pulse(1);

    step(7, dbl_heart);
    pulse(2);

    step(7, uni_heart);

    // Заливка контурного "сердца"
    for (uint32_t j = 0; ++j < 7;)
    {
        img_buf[j].dw |= img_buf[j - 1].dw;

        load_img();
    }

    // Бесконечно
    for (;; pulse(1));

    return 0;
}

Пробежимся по основным моментам. Для изображения "сердечка" нам нужна исходная картинка. Она задаётся массивом heart. Каждый байт этого массива содержит битовую маску строки светодиодов для отображения половины контура "сердечка". Т.к. фигура симметрична, вторая половина достраивается программно, путём зеркалирования первой. Для генерации изображения мы используем "видеобуфер" img_buf – массив из восьми тридцатидвухбитных значений. Каждый бит соответствует светодиоду в матрице. Для отправки буфера в матрицу используется функция load_img. Я нарочно сделал буфер глобальным, и во всех функциях работаю с ним в явном виде. Мне хотелось посмотреть, насколько компактным может получиться код, в т. ч. и на нулевой оптимизации. Передача указателя в функцию увеличит каждый вызов на 4 байта. В маленьком проекте такое вполне допустимо, мы же экспериментируем. Но никогда не делайте так в коде, за который вам платят деньги.

Генерация изображений представляет собой некоторые математические действия с данными в img_buf. Анимация может состоять из нескольких кадров, в каждом кадре мы последовательно производим преобразования каждой строки. Таким образом, для отображения одной сцены нам нужно иметь два вложенных цикла. Внешний цикл определяет номер кадра в сцене, а внутренний – номер преобразуемой строки. В конце каждой итерации внешнего цикла вызывается функция load_img для обновления изображения на матрице.

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

/// Сигнатура функции преобразования строки изображения
typedef void(*step_t)(const uint32_t, const uint32_t);

/// @brief Применение функции преобразования к изображению
/// @param j Количество кадров анимации
/// @param fn_p Функция преобразования строки изображения
void step(int8_t j, step_t fn_p)
{
    for (; j >= 0; j--)
    {
        for (int32_t i = STR_CNT; --i >= 0;)
        {
            fn_p(i, j);
        }

        load_img();
    }
}

/// @brief Функция преобразования "одно сердце"
/// @param i номер строки
/// @param j номер кадра
void one_heart(const uint32_t i, const uint32_t j)
{
    img_buf[i].dw = (uint16_t)heart[i] << 8 >> j;
}

Функция main занимается тем, что инициализирует драйвер, а потом по очереди вызывает сцены с параметрами. В конце она проваливается в бесконечный цикл, в котором бесконечно крутится эффект "бьющегося сердца".

В этом коде ещё есть куда ужаться. Например, можно освободить 4 байта ROM, удалив код возврата функции int main(). Но это уже пахнет подавлением предупреждений компилятора, и я решил, что код от этого потеряет больше, чем приобретёт прошивка. Не могу сказать, что выжал из этого кода каждый байт, но таки постарался сделать так, чтобы оптимизация объёма прошивки стала не слишком тривиальной задачей. На ОЗУ тоже сэкономил, но цифры – в конце.

max7219.c – самый неинтересный файл
#include "max7219.h"
#include "csp.h"

void max7219_send_cmd(const uint32_t cmd, const uint32_t data)
{
    csp_spi_nss_active();

    for (uint32_t i = MATRX_CNT; i-- > 0;)
    {
        csp_spi_send((cmd << 8) | data);
    }

    csp_spi_nss_inactive();
}

void max7219_send_data(const uint32_t str, const uint32_t data)
{
    csp_spi_nss_active();

    for (int8_t i = MATRX_CNT; --i >= 0;)
    {
        csp_spi_send((str << 8) | ((uint8_t *)&data)[i]);
    }

    csp_spi_nss_inactive();
}

В нем только самые необходимые для проекта функции – послать команду на все четыре микросхемы MAX7219 и отобразить строку согласно 32-битной маске, передаваемой в аргументе.

Дальше нас ждёт настройка железа. А в таргете CMSIS_drv мы настраиваем его, используя все готовые библиотеки, которые предоставляет нам среда разработки. Для того, чтобы увидеть, что же мы там наподключали, заходим в менеджер окружения (Project->manage->run-time environment...) и видим такую картину:

Подключение компонентов CMSIS
Подключение компонентов CMSIS

Это всё, что нужно, чтобы завести SPI интерфейс. Ну, почти всё. В дереве проектов находим файл RTE_Device.h и открываем его в редакторе Keil. Этот файл Keil IDE любезно нам сгенерировала. Надо его слегка поправить. Для этого внизу находим вкладку "Configuration Wizard" и переключаемся на неё. Вот это да! Файл превратился в визуальное меню конфигураци. Настраиваем выводы SPI, как на картинке:

Настройка SPI интерфейса
Настройка SPI интерфейса

Как думаете, сколько кода нам придётся написать ручками, чтобы завести SPI? Вот столько:

#include "RTE_Components.h"
#include CMSIS_device_header
#include "Driver_SPI.h"

extern ARM_DRIVER_SPI Driver_SPI1; ///< SPI Driver external

ARM_DRIVER_SPI *SPIdrv = &Driver_SPI1; ///< SPI Driver pointer

void csp_spi_init()
{
    /* Initialize the SPI driver */
    SPIdrv->Initialize(NULL);
    /* Power up the SPI peripheral */
    SPIdrv->PowerControl(ARM_POWER_FULL);
    /* Configure the SPI to Master */
    SPIdrv->Control(0
                    | ARM_SPI_MODE_MASTER
                    | ARM_SPI_CPOL0_CPHA0
                    | ARM_SPI_MSB_LSB
                    | ARM_SPI_SS_MASTER_SW
                    | ARM_SPI_DATA_BITS(16),
                    1000000);

    SPIdrv->Control(ARM_SPI_CONTROL_SS, ARM_SPI_SS_INACTIVE);
}

Настройка ножек, тактирование портов и пр. CMSIS возьмет на себя.

Весь файл spi.c выглядит так.
#include "RTE_Components.h"
#include CMSIS_device_header

#include "Driver_SPI.h"

/// SPI Driver external
extern ARM_DRIVER_SPI Driver_SPI1;

/// SPI Driver pointer
ARM_DRIVER_SPI *SPIdrv = &Driver_SPI1;

void csp_delay(const uint8_t del)
{
    for (volatile uint32_t i = 0xFFF * del; i != 0; i--);
}

void csp_spi_init()
{
    /* Initialize the SPI driver */
    SPIdrv->Initialize(NULL);
    /* Power up the SPI peripheral */
    SPIdrv->PowerControl(ARM_POWER_FULL);
    /* Configure the SPI to Master */
    SPIdrv->Control(0
                    | ARM_SPI_MODE_MASTER
                    | ARM_SPI_CPOL0_CPHA0
                    | ARM_SPI_MSB_LSB
                    | ARM_SPI_SS_MASTER_SW
                    | ARM_SPI_DATA_BITS(16),
                    1000000);

    SPIdrv->Control(ARM_SPI_CONTROL_SS, ARM_SPI_SS_INACTIVE);
}

void csp_spi_nss_active()
{
    SPIdrv->Control(ARM_SPI_CONTROL_SS, ARM_SPI_SS_ACTIVE);
}

void csp_spi_nss_inactive()
{
    while (SPIdrv->GetStatus().busy);
    SPIdrv->Control(ARM_SPI_CONTROL_SS, ARM_SPI_SS_INACTIVE);
}

void csp_spi_send(const uint32_t data)
{
    SPIdrv->Send(&data, 1);
    while (SPIdrv->GetStatus().busy);
}

Ещё один файл, который любезно сгенерировала нам Keil IDE и на который стоит обратить внимание, это startup_stm32f10x_md.s, ассемблерный файл, в котором происходит первичная инициализация, размечается таблица векторов прерываний и настраивается размер стека и кучи. Кучу мы не используем, а стека нам достаточно 208 байт (0xD0). Видите, какие мы экономные.

Прошивка

Ну, что же, пора уже заводить этот балаган. Жмём F7, и через пару секунд таргет собран. Подтыкаем в свободный USB разъем нашу Nucleo-F103RB. Жмём Alt+F7 и в открывшемся диалоге выбираем вкладку Debug. Выбираем для нашей отладочной платы дебаггер ST-Link и если всё подключено правильно, можем заливать прошивку. Можно просто нажать F8, прошивка улетит в nucleo и порадует вас анимацией. Если, конечно, вы всё правильно подключили. Если хочется поковыряться во внутренностях МК, тогда вам прямая дорога в отладчик: Ctrl+F5, а там уж как-нибудь сами.

Срезаем жирок

Если мы посмотрим на вывод линкера, то увидим примерно следующее:

Program Size: Code=9032 RO-data=392 RW-data=20 ZI-data=292

Меньше десяти килобайт! В условиях, когда приложение фонарика на телефоне может не моргнув LED'ом схавать мегабайт 10 – звучит неплохо. Или плохо? Всё познаётся в сравнении. Поменяем правила игры – откажемся от модного SPI драйвера. Снова заходим в менеджер окружения и отключаем всё лишнее:

Всё лишнее обведено красным
Всё лишнее обведено красным

Мы всё ещё используем CMSIS, но только нижний уровень – файлы начальной инициализации startup_stm32f10x_md.s и system_stm32f10x.c. Работу с SPI придётся реализовывать самостоятельно. У нас это файл spi.c, помните? Но чтобы всё окончательно запутать, мы оставим spi.c для истории а к его интерфейсу напишем новую реализацию – на регистрах. Назовем это Chip Support Package (помните, что заголовочный файл у нас называется csp.h?). Она получится чуть длиннее, чем при использовании драйвера.

Настройка и функции работы с SPI выглядят так:
#include "stm32f10x.h"

#define NSS_PORT  GPIOA ///< Адрес порта SS (CS)
#define NSS_PIN   (6)   ///< Номер пина SS (CS)
#define SCK_PORT  GPIOA ///< Адрес порта SCK (CLK)
#define SCK_PIN   (5)   ///< Номер пина SCK (CLK)
#define MOSI_PORT GPIOA ///< Адрес порта MOSI (DIN)
#define MOSI_PIN  (7)   ///< Номер пина MOSI (DIN)

/// Port Mode
typedef enum
{
    GPIO_MODE_INPUT     = 0x00, ///< GPIO is input
    GPIO_MODE_OUT10MHZ  = 0x01, ///< Max output Speed 10MHz
    GPIO_MODE_OUT2MHZ   = 0x02, ///< Max output Speed  2MHz
    GPIO_MODE_OUT50MHZ  = 0x03  ///< Max output Speed 50MHz
} GPIO_MODE;

/// Port Conf
typedef enum
{
    GPIO_OUT_PUSH_PULL  = 0x00, ///< general purpose output push-pull
    GPIO_OUT_OPENDRAIN  = 0x01, ///< general purpose output open-drain
    GPIO_AF_PUSHPULL    = 0x02, ///< alternate function push-pull
    GPIO_AF_OPENDRAIN   = 0x03, ///< alternate function open-drain
    GPIO_IN_ANALOG      = 0x00, ///< input analog
    GPIO_IN_FLOATING    = 0x01, ///< input floating
    GPIO_IN_PULL_DOWN   = 0x02, ///< alternate function push-pull
    GPIO_IN_PULL_UP     = 0x03  ///< alternate function pull up
} GPIO_CONF;

#define CONF_CLR(pin) (0xF << ((pin) << 2))
#define CONF_SET(pin, conf) ((((conf) << 2) | GPIO_MODE_OUT50MHZ) << ((pin) << 2))

#define BITBANDING_ADDR_CALC(ADDR, BYTE)       \
    ((uint32_t)(ADDR) & 0xF0000000UL) +        \
    (((uint32_t)(ADDR) & 0x00FFFFFFUL) << 5) + \
    0x02000000UL + ((BYTE) << 2)
#define BB_REG(ADDR, BYTE) (*(volatile uint32_t *)(BITBANDING_ADDR_CALC(ADDR, BYTE)))

void csp_delay(const uint32_t del)
{
    for (volatile uint32_t i = del << 12; --i;);
}

void csp_spi_init()
{
    // GPIO init
    RCC->APB2ENR |= RCC_APB2ENR_AFIOEN | RCC_APB2ENR_IOPAEN | RCC_APB2ENR_SPI1EN;
    GPIOA->CRL &= ~(CONF_CLR(NSS_PIN) | CONF_CLR(SCK_PIN) | CONF_CLR(MOSI_PIN));
    GPIOA->CRL |= CONF_SET(NSS_PIN, GPIO_OUT_PUSH_PULL) | CONF_SET(SCK_PIN, GPIO_AF_PUSHPULL) | CONF_SET(MOSI_PIN, GPIO_AF_PUSHPULL);

    // SPI1 init MODE_MASTER, CPOL0, CPHA0, MSB_LSB, DATA_16_BITS, Max speed
    SPI1->CR1 = SPI_CR1_MSTR  | SPI_CR1_SSI | SPI_CR1_SSM | SPI_CR1_DFF | SPI_CR1_SPE;
}

void csp_spi_nss_active()
{
    NSS_PORT->BRR = (1UL << NSS_PIN);
}

void csp_spi_nss_inactive()
{
    while (BB_REG(&(SPI1->SR), 7)); // SPI_SR_BSY
    NSS_PORT->BSRR = (1UL << NSS_PIN);
}

void csp_spi_send(const uint32_t data)
{
    while (!BB_REG(&(SPI1->SR), 1)); // SPI_SR_TXE
    SPI1->DR = data;
}

Здесь интерес представляет макрос

#define BITBANDING_ADDR_CALC(ADDR, BYTE)       \
    ((uint32_t)(ADDR) & 0xF0000000UL) +        \
    (((uint32_t)(ADDR) & 0x00FFFFFFUL) << 5) + \
    0x02000000UL + ((BYTE) << 2)
#define BB_REG(ADDR, BYTE) (*(volatile uint32_t *)(BITBANDING_ADDR_CALC(ADDR, BYTE)))

Не то, чтобы он необходим. В данном конкретном случае его использование позволяет сэкономить 4–8 байт. Но он реализует очень интересный механизм, называемый bit banding. Что это за зверь такой? Это такой механизм отображения физической памяти ОЗУ или периферийных регистров на другой диапазон адресов. Но это не простое зеркалирование. В этой новой области адресов каждому физическому биту ОЗУ выделен целый самостоятельный адрес в памяти. Это позволяет обращаться к биту, как к 32-разрядному регистру, используя те же ассемблерные инструкции. При этом в старших разрядах всегда будут нули, а младший разряд – как раз и есть наш бит. Это дико упрощает работу с полями и флагами на уровне ассемблера, что положительно сказывается как на быстродействии, так и на объёме исполняемого кода.

Ну и зачем так мучаться, спросите вы? А вот зачем:

Before
Program Size: Code=9032 RO-data=392 RW-data=20 ZI-data=292
After
Program Size: Code=1296 RO-data=260 RW-data=0  ZI-data=240  

Ого, ужались в шесть раз. Неплохо, для начала. А что мы ещё можем отрезать? У нас остались ZI данные. Это такие данные, которые инициализируются нулями перед тем как компоновщик передаст управление главной функции – main. Но нам эта инициализация не нужна. А ещё мы не используем прерывания, а в файле startup_stm32f10x_md.s целая здоровенная таблица векторов. Давайте-ка и от этого всего избавимся.

Обгладываем кости

Отключаем в менеджере окружения всё, кроме CMSIS Core. Мы же не хотим писать хардкорные адреса вместо имён регистров? Заветный startup_stm32f10x_md.s исчезает, а значит, стек, и таблицу векторов нам придётся рисовать самостоятельно. Поехали!

Структура нашей таблицы векторов будет выглядеть так:

typedef volatile const struct
{
    const void *const sp; ///< Указатель на вершину стека
    int (*main)(void); ///< Вектор сброса
} vectors_t;

Первое поле – это указатель на вершину стека, а второе – вектор сброса – единственный, который нам нужен. Без него никак. Стек – это просто массив байтов в ОЗУ, а про указатели на функции вы и так всё знаете. Весь файл startup.c получился таким:

#include <stdint.h>

/// Размер стека. Должен быть кратен 8 байтам.
#define STACK_SIZE (0x68)

/// @brief Структура таблицы векторов
typedef volatile const struct
{
    const void *const sp; ///< Указатель на вершину стека
    int (*main)(void); ///< Вектор сброса
} vectors_t;

/// Стек. Должен быть кратен 8 байтам.
volatile uint64_t stack[STACK_SIZE / sizeof(uint64_t)];

/// @brief Указатель на вершину стека.
/// @details Стек растет сверху вниз.
const void *const __initial_sp = (void *)((uint32_t)stack + sizeof(stack));

/// @brief Главная функция
extern int main();

/// @brief Таблица векторов
vectors_t vectors __attribute__((section("reset"))) =
{
    .sp = __initial_sp,
    .main = main,
};

Директива __attribute__((section("reset"))) говорит линкеру, что переменная должна располагаться не абы где, а в секции памяти, имеющей имя "reset". Это мы так назвали секцию, со стартовым адресом 0x08000000. Адрес начала таблицы векторов прерываний в нашем МК. А как линкер сопоставит имя и адрес? А для этого у нас заготовлен кастомный scatter-файл. Который выглядит примерно так:

LR_IROM1 0x08000000 0x00020000  {
  ER_IROM1 0x08000000 0x00020000  {
   *.o (reset, +First)
   .ANY (+RO)
   .ANY (+XO)
  }
  RW_IRAM1 0x20000000 UNINIT 0x5000 {
   .ANY (+RW)
   .ANY (+ZI)
  }
}

Подробно разбирать мы его не будем, есть всякие мануалы на эту тему. Видите имя секции "reset"? Вот эта именованная секция и будет содержать нашу таблицу векторов. Ключевое слово +First говорит о том, что секция будет располагаться первой в регионе ER_IROM1, берущим своё начало по адресу 0x08000000. То, что доктор прописал. Да, регион ОЗУ – RW_IRAM1 – пометим как UNINIT. Расположенные в нем переменные, а в нашем случае это стек и "видеобуфер" – не будут инициализированы нулями или чем либо ещё. Конечно, при старте они могут содержать бог знает что, но мы об этом помним и ручками инициализируем всё что нужно. В нашем случае – ничего.

F7, погнали. Что мы получили в итоге наших издевательств? Примерно следующее:

Program Size: Code=900 RO-data=20 RW-data=0 ZI-data=136

Вот это уже хорошо! Не потому, что лучше нельзя. Компилятор говорит, что можно. Выставим оптимизацию по объёму кода и не меняя ни строчки получим:

Program Size: Code=616 RO-data=20 RW-data=0 ZI-data=136

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

Выводы

Пользовать библиотеки – легко и приятно. Глюков в том же HAL'е я не находил не потому, что их там нет, а потому, что их за меня уже нашли другие. Велосипеды, вроде финального варианта программы из этой статьи – лютое зло, если они попадают на прод. Да, есть исключения, есть супермаленькие камни, древние загрузчики, которые надо впихнуть "туда, где был, но добавить USB", есть прерывание, которое должно обрабатывать условия раз в полгода, но делать это за доли микросекунды. В таких ситуациях и ассемблером не грех воспользоваться. Именно для таких редких случаев полезны подобные упражнения. А в остальное время помните, что ваш код будут читать гораздо чаще, чем вы его править. Любите ближних, коллег и проекты, над которыми работаете.

Ссылка на репозиторий с проектом на github. Там же скетч для Arduino, реализующий то же самое. Объем результирующего кода – 5k5.

Комментарии (33)


  1. ionicman
    00.00.0000 00:00

    Извините, но матрица с адресными диодами + ATTINY45/85 сделает все тоже самое гораздо быстрее и в RGB (причем можно даже без библиотек), а использовать для такой задачи такой мощный проц да еще и обычные светодиодные матрицы - я даже не знаю что сказать.


    1. IgorRJ
      00.00.0000 00:00
      +6

      Вообще-то статья не о камнях и светиках. Так что об этом и говорить не стоит - даже если и знаешь, что сказать.


    1. Kudesnick33 Автор
      00.00.0000 00:00
      +1

      Любой инструмент хорош, если его применять по назначению. Для данного проекта идеальный МК - тот который лежит на расстоянии вытянутой руки. Гляньте последние абзацы, кстати, там как раз безбиблиотечный результат.


      1. ionicman
        00.00.0000 00:00
        +1

        Ну мне, как инженеру-программисту жалко проц, который 99.99999% времени проводит в idle)

        Это как гончая, которая сидит дома и ее не пускают на улицу.

        Для каждой задачи есть свой инструмент, это точно, но здесь, имхо, микроскопом гвозди на 80 вгоняются в бетон одним ударом.


        1. Kudesnick33 Автор
          00.00.0000 00:00

          Именно этот проц большую часть своей активной жизни проводит в брейкпойнте под отладчиком, и это не первая и не последняя его прошивка )


        1. Demonter
          00.00.0000 00:00

          Ну еще лет пятнадцать назад многие микроскопы подешевели, и стали дешевле молотков. У вас не было проектов, где в толстом SoC используется одно-два ядра из 4-6? Я с этим в промышленной разработке сталкиваюсь постоянно.


          1. Kudesnick33 Автор
            00.00.0000 00:00

            Хм, не доводилось. Но у меня обычно крупносерийка в проектах. Ядер свободных не наблюдал. Но вот запас по памяти двух, трехкратный - это в порядке вещей. Но это тоже не от разгильдяйства - уменьшение номенклатуры на складах, оптовые закупки всё такое. Вообще, когда в проекты вмешивается экономика - всё становится гораздо интереснее. Учитывается даже минимальная необходимая квалификация разрабов для поддержки проекта и сложность входа в проект.


            1. Demonter
              00.00.0000 00:00

              Вот из-за экономики предпочитаю заниматься всякой мелкосерийкой для измериловки или военки - стоимость комплектухи не на первых местах, можно выбрать подходящий камень, не смотря на цену. Был опыт, когда себестоимость одной железки для крупносерийного производства надо было утолкать в 75 баксов. Не хочу его повторять.
              А по мощности используемых чипов - в 2009 вроде бы году был провал по поставкам AVR, когда они свой последний завод продали, и свежевышедшие stm32f100 оказались вдвое-втрое дешевле привычных атмег. Куча разработчиков тогда переехали на arm, хотя стм-ки для тех проектов были сильно избыточные и вполне себе выглядели микроскопами вместо молотков.


              1. vconst
                00.00.0000 00:00
                +3

                или военки
                А потом, внезано — невыездной 15 лет ))


                1. Demonter
                  00.00.0000 00:00

                  Обошлось )). Вообще много где можно выше 3 формы не залезать.


                  1. vconst
                    00.00.0000 00:00
                    +1

                    Ну хорошо если так, а то есть одна знакомая, работает в каком-то почтовом ящике, бумажки перекладывает — никакой практической работы. Отдыхать они ездят в Анапу… И там надо обязательно заехать в военкомат и отметиться, минус 1 день отдыха на это дело, в пробке километров на 10

                    Такое себе


                    1. Demonter
                      00.00.0000 00:00

                      2 форма для гражданских - это +30% к окладу/зарплате. Поэтому часто это осознанный выбор.


                      1. vconst
                        00.00.0000 00:00
                        +1

                        Ну, раз она все еще там работает — то ее устраивает. А я бы не смог…


                1. DarkMike
                  00.00.0000 00:00

                  Для второй формы - 5 лет. Ну и в зависимости от обстоятельств можно и при увольнении оформить бумагу, что реального доступа к гос. тайне не имел.


                  1. vconst
                    00.00.0000 00:00

                    Когда наука в оборонке начала разваливаться, из закрытого НИИ, где работал отец, народ «пропадал» таким образом: уезжал в какуюнить произвольную республику бывшего сср, «к родственникам в отпуск». А потом всплывал где-то в Штатах или Западной Европе.

                    Невыезд там был 15 лет, как раз


              1. Kudesnick33 Автор
                00.00.0000 00:00

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


    1. nixtonixto
      00.00.0000 00:00
      +2

      ATtiny45 сейчас стоит дороже 20-ногого F030, в который можно перекомпилировать этот код.


  1. vconst
    00.00.0000 00:00

    Почему все сердечко сразу не положили в массив? Отсутствие необходимости отражать его программно — не сделает код короче, проще и понятнее?


    1. Kudesnick33 Автор
      00.00.0000 00:00
      +1

      В один байт оно не помещается, полсердечка - 5x8 точек. Так что это плюс 8 байт к прошивке, а анимация от этого проще бы не стала, даже наоборот, ибо она симметрична. Так что проще оказалось именно зеркалить видеобуфер на финальном этапе. Возможно, стоило положить его "на бок". Можно бы было еще 3 байта отжать. Впрочем, код открыт. Может, кому будет интересно поэкспериментировать.


      1. vconst
        00.00.0000 00:00

        Так что это плюс 8 байт к прошивке
        Даже не знаю, что сказать :)

        Я не в теме современной низкоуровневой разработки на микроконтроллерах, так — библиотеками развлекаюсь. Потому любопытно, сильно ли это отличается времен DOS, когда массив передавали в видеопамять через указатель и он просто «появлялся» на экране. Может и здесь такое работает?


        1. Kudesnick33 Автор
          00.00.0000 00:00

          Может и здесь такое работает?

          Пожалуй, аналогия уместна. Если, предположить, что SPI-прослойка - это уже генерация видеосигнала, вместо VGA. Но для МК - это только один из возможных путей. Т.е. здесь приходится писать не только "DOS-программку", но еще и саму DOS, и прошивку к видеокарте. Спасает только то, что весь функционал писать не надо, а только то, что нужно здесь и сейчас.


          1. vconst
            00.00.0000 00:00

            Ну, «писать саму DOS» — это только если на совсем низком уровне, с прямой записью прямо в регистры. Почти как здесь.

            Еще мысль.
            Можно же не удалять библиотеки из проекта целиком, а оставлять в них только инициализацию устройств и те 2-3 функции, которые нужны. Остальное просто стереть из файла. Исполняемый файл получится в разы меньше, ибо компилироваться будет не вся либа, а процентов 5 от нее, но сохранится удобство высокого уровня, когда все передается через удобные аналоги print()


            1. Kudesnick33 Автор
              00.00.0000 00:00

              Любой современный линкер так и делает. Неиспользуемые куски библиотек в итоговый бинарник не попадают. Но оверхед всё равно имеется. Естественно, на больших проектах он не так заметен. Собственно, величина этого оверхеда в статье и измеряется.


              1. vconst
                00.00.0000 00:00

                Отстал я от жизни


        1. mayorovp
          00.00.0000 00:00

          Видеопамять во времена DOS аппаратно отображалась на физическое адресное пространство — видеоконтроллер "слушал" системную шину и перехватывал обращения к определённым адресам. При этом, подозреваю, поначалу оперативной памяти на этих адресах не было физически. А потом пришлось настраивать чипсет чтобы тот "не пускал" запросы к определённым адресам в оперативную память, во избежание конфликта.


          Ключевым моментом тут является то, что видеопамять физически находится в видеокарте, а отображение так или иначе работает за счёт вклинивания в системную шину.


          Особенностью микроконтроллеров является то, что память у них внутри, как и системная шина. Наружу не "торчит" ничего хотя бы отдалённо напоминающего шины адреса и данных, все ножки по максимуму отданы под GPIO. Как в этих условиях делать отображение чего-то на память? Никак, оно и не делается.


          Автор явно упоминает, что взаимодействие с дисплеем идёт по SPI. SPI — это последовательный интерфейс на 4х проводах.


          1. DSarovsky
            00.00.0000 00:00
            +1

            А как же FMC+8080, по которому, вроде, часто дисплеями управляют?


            1. mayorovp
              00.00.0000 00:00

              И правда, есть там на старших моделях встроенный видеоконтроллер… Однако, к SPI он всё равно никак не относится.


          1. Kudesnick33 Автор
            00.00.0000 00:00
            +3

            подозреваю, поначалу оперативной памяти на этих адресах не было физически

            По началу, как раз, это и была самая обычная оператива, просто периодически, соблюдая тайминги, её сканировал видеоконтроллер (если его можно так назвать) и превращал байты в видеосигнал. На ПК типа Спектрума эту память спокойно можно было пользовать для своих нужд, если, конечно, вас не смущала каша на дисплее.


          1. vconst
            00.00.0000 00:00

            Значит моя аналогия не верна. Увы


  1. DSarovsky
    00.00.0000 00:00
    +1

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

    Про библиотечный код: если взять библиотеку на шаблонах C++ (хотя нужным оказался только SPI и GPIO), то со стандартным стартапом получилось 1136/60.


    1. Kudesnick33 Автор
      00.00.0000 00:00

      Печалит, что производители МК не спешат поддерживать такой подход. Это очень здорово тормозит проникновение C++ в baremetal разработку.


    1. Dooez
      00.00.0000 00:00

      А что за библиотека? Поделитесь ссылкой, пожалуйста.


      1. DSarovsky
        00.00.0000 00:00
        +2

        Минутка рекламы :)