Регистры vs библиотеки на примере сердечек

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

Преамбула

Статья ориентирована на тех, кто интересуется разработкой под голое железо либо сделал это своей профессией. Любители найдут три способа написать «Hello word» на светодиодной панели, а профессионалы, возможно, найдут полезными выводы из тех цифр, которые мы получим в итоге сравнения этих трёх подходов. В основном цифры будут касаться объема получившегося кода и могут стать дополнительным аргументом в холиварах «Регистры 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.

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

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

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

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

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

Сборка

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

Контакт на 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…) и видим такую картину:

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

Это всё, что нужно, чтобы завести 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 

/// Размер стека. Должен быть кратен 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.

© Habrahabr.ru