LINUX.ORG.RU

Осваиваем STM32 снизу: часть 9 - подключаем libc

 ,


4

1

Часть 1 Часть 2 Часть 3 Часть 4 Часть 5 Часть 6 Часть 7 Часть 8 Часть 9

Часть 9: подключаем libc

libc это стандартная библиотека языка С. До сих пор мы избегали использования любых функций из стандартной библиотеки, если бы мы попробовали это сделать, то линкер выдал бы ошибку.

Нужно ли использовать libc или нет - на этот вопрос нельзя дать однозначный ответ. С одной стороны там хватает нужных функций, которые придётся реализовывать самостоятельно, если не использовать libc. С другой стороны некоторые части libc вроде printf весьма объёмные и раздуют размер вашей прошивки. Как бы то ни было, в этой статье мы подключим libc, напишем всё необходимое для того, чтобы заработал printf с выводом в UART и посмотрим на итоговый размер прошивки.

Набор для разработки от ARM поставляется вместе с библиотекой newlib, которая реализует libc.

Для начала мы скопируем Makefile и STM32F103XB_FLASH.ld из предыдущей части. После этого мы немного перепишем стадию компоновки. Вместо вызова линкера ld напрямую, мы будем вызывать gcc. Он самостоятельно вызовет линкер, но при этом передаст ему необходимые опции для подключения стандартной библиотеки.

Ниже представлено правило для компоновки:

LDSCRIPT := STM32F103XB_FLASH.ld
LDFLAGS := -Wl,-T,$(LDSCRIPT)
...
CC := arm-none-eabi-gcc
...
uartlc.elf: startup_stm32f103xb.o system_stm32f1xx.o main.o
	$(CC) -o $@ $(CFLAGS) $(LDFLAGS) $^

Опция -Wl... позволяет передать указанную опцию линкеру.

В main.c пока просто добавим пустую функцию main:

int main(void)
{
}

Если попробовать скомпилировать программу в таком виде, то мы получим множество ошибок компоновки вида:

closer.c:(.text._close_r+0x18): undefined reference to '_close'
lseekr.c:(.text._lseek_r+0x24): undefined reference to '_lseek'
readr.c:(.text._read_r+0x24): undefined reference to '_read'
...

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

От этих ошибок можно избавиться, если включить т.н. сборщик мусора при компоновке. Для того, чтобы он работал, нужно, чтобы каждая функция и каждая глобальная переменная во входных объектных файлах была объявлена в отдельной секции. А также необходимо проинструктировать линкер о тех секциях, которые являются «корнями», т.е. которые в любом случае нужно включить в итоговый объектный файл. Линкер отследит ссылки между секциями и включит лишь те секции, на которые есть ссылки из «корней».

Для включения сборщика мусора необходимо передать линкеру опцию --gc-sections. Т.к. мы вызываем gcc вместо ld, то опцию нужно передать gcc в виде -Wl,--gc-sections.

«Корни» определяются с помощью команды KEEP в скрипте линкера. Нам специально этого делать не нужно, скрипт STM32F103XB_FLASH.ld уже содержит эту команду в нужных местах, например:

  .isr_vector :
  {
    . = ALIGN(4);
    KEEP(*(.isr_vector)) /* Startup code */
    . = ALIGN(4);
  } >FLASH

Как видно, секция .isr_vector помечена как «корень» с помощью команды KEEP. Эта секция содержит ссылку на функцию Reset_Handler, которая, в свою очередь, содержит ссылку на функцию main. Таким образом в наш итоговый образ будет включена функция main, а также функция __libc_init_array, которую на этот раз линкер найдёт в библиотеке newlib. А вот функции close и подобные в наш итоговый образ включены не будут и ошибок компоновки из-за отсутствующих реализаций функций _close и подобных не будет.

При сборке программы линкер будет выдавать предупреждение

ld: warning: uartlc.elf has a LOAD segment with RWX permissions

Оно безвредно и его можно отключить параметром --no-warn-rwx-segments, или же исправить линкер скрипт. Мы воспользуемся первым вариантом, т.к. наш линкер скрипт скопирован из стандартного шаблона от ST.

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

Выше мы написали, что нужно, чтобы каждая функция и каждая глобальная переменная во входных объектных файлах была объявлена в отдельной секции. В объектных файлах newlib так и сделано, но наша программа по прежнему собирается в одной секции .text. Чтобы включить механизм сборки мусора и для нашей программы, нужно добавить флаги -ffunction-sections -fdata-sections в опции компилятора.

На этом этапе мы уже можем использовать множество полезных функций из libc, к примеру memcpy, memset, strlen и тд. Для них в newlib имеются качественные реализации, а компилятор знает про них и может применять разнообразные оптимизации.

Но мы пойдём дальше и сделаем так, чтобы заработала функция printf. Это будет не так просто. Для начала просто вызовем её и попробуем скомпилировать программу:

#include <stdio.h>

int main(void)
{
    printf("Hello, world!\n");
    for (;;)
    {
    }
}

При компоновке мы опять столкнёмся с теми же ошибками:

closer.c:(.text._close_r+0x18): undefined reference to `_close'
lseekr.c:(.text._lseek_r+0x24): undefined reference to `_lseek'
readr.c:(.text._read_r+0x24): undefined reference to `_read'
...

На этот раз уже ничего не поделать. Реализация функции printf использует или теоретически может использовать все эти функции, а значит нам придётся их реализовать. Большинство из этих функций на самом деле вызваны не будут, поэтому их реализацией будет служить обычный вечный цикл. Если вдруг мы туда попадём (это можно выяснить с помощью отладчика), значит реализация всё же нужна. Реализацию мы поместим в отдельный файл os.c.

Пример реализации функции _close:

int _close(int fd)
{
    (void)fd;

    for (;;)
    {
    }
}

Конструкция (void)fd нужна для того, чтобы компилятор не выдавал предупреждение на неиспользованный параметр функции.

Аналогичным образом реализуем функции _exit, _getpid, _kill, _lseek, _read.

Функции _fstat и _isatty реализуем следующим образом:

int _fstat(int fd, struct stat *st)
{
    (void)fd;

    st->st_mode = S_IFCHR;
    return 0;
}

int _isatty(int fd)
{
    (void)fd;

    return 1;
}

Эти функции уже действительно вызываются при вызове функции printf и вечным циклом здесь обойтись не выйдет.

Функция _sbrk нужна для работы malloc и подобных функций, которые printf также использует. На обычных платформах она запрашивает блоки памяти у операционной системы и в дальнейшем malloc использует эти блоки для дальнейшего распределения памяти. У нас операционной системы нет. Наша оперативная память распределена следующим образом (конкретные значения приведены лишь для примера):

0x2000_5000 - конец SRAM
0x2000_4900 - минимальное значение регистра $sp при максимальном размере стека
...        - неиспользуемая память
0x2000_09e8 - конец секции .bss
...        - содержимое секции .bss
0x2000_06b8 - начало секции .bss
0x2000_06b8 - конец секции .data
...        - содержимое секции .data
0x2000_0000 - начало секции .data
0x2000_0000 - начало SRAM

Как видно, снизу расположены секции .data и .bss, сверху расположен стек, а между ними расположена неиспользуемая память. Вот эту неиспользуемую память мы и будем возвращать из sbrk. В нашем коде никаких проверок не будет, но вообще говоря такие проверки стоило бы добавить, чтобы куча и стек не пересеклись.

void *_sbrk(ptrdiff_t incr)
{
    extern char _ebss[];
    static char *heap_end = _ebss;
    char *base = heap_end;
    heap_end += incr;
    return base;
}

Cимвол _ebss определён в скрипте линкера и обозначает конец секции .bss (0x2000_09e8 в примере выше) или начало неиспользуемой памяти.

Осталась последняя функция, которую нужно реализовать: _write. Она занимается непосредственно выводом символов в UART. Скопируем код из предыдущей части:

ssize_t _write(int fd, const void *buf, size_t cnt)
{
    (void)fd;

    const char *string = buf;

    for (size_t index = 0; index < cnt; index++)
    {
        char ch = string[index];

        // wait until data is transferred to the shift register
        while ((USART3->SR & USART_SR_TXE) == 0)
        {
        }

        // write data to the data register
        USART3->DR = (uint32_t)ch & 0x000000ff;
    }

    return cnt;
}

Ну и, конечно, нужно добавить инициализацию UART в функцию main.

На этом наш hello world завершён и если всё было сделано правильно, то он должен работать аналогично предыдущим.

Если посмотреть размер получившегося бинарного файла, то он будет занимать 31660 байтов. Иными словами реализация функции printf занимает почти половину всей флеш-памяти. Конечно вряд ли стоит использовать её на таких маломощных платформах. Альтернативой может служить функция iprintf, она имеет меньше возможностей, но и занимает в 2 раза меньше места, хотя на мой взгляд это всё ещё очень большая цена за такой функицонал.

Если резюмировать: libc содержит полезный функционал, но стоит внимательно относиться к тем функциям, которые вы планируете использовать. Стандартный ввод-вывод больше подходит для «больших компьютеров» и на микроконтроллерах стоит задуматься о других подходах.

Полный код доступен на гитхабе.

★★★

Проверено: hobbit ()
Последнее исправление: vbr (всего исправлений: 2)

Попробуй флаг линкера --specs=nano.specs, он должен сильно уменьшить размер выходного файла.

Также для отключения всяких atexit я добавлял в проект следующий файл (nosys.cpp) :

extern "C" void __aeabi_atexit() {}     // for virtual destructors
extern "C" void __cxa_pure_virtual();   // for abstract base class
extern "C" void __cxa_guard_acquire();  // for local static init
extern "C" void __cxa_guard_release();  // for local static init
extern "C" void __dso_handle();         // dynamic shared object handle, used by __aeabi_atexit().
                                        // must be int per shared object, but it's ok to be any address in freestanding environment
                                        // so we use func address

#pragma weak __cxa_pure_virtual = __aeabi_atexit
#pragma weak __cxa_guard_acquire = __aeabi_atexit
#pragma weak __cxa_guard_release = __aeabi_atexit
#pragma weak __dso_handle = __aeabi_atexit

(у меня плюсы, для си часть функций здесь лишняя, но не помешает)

Beewek ★★
()

Первый момент: я бы предложил вместо десятка заглушек (для _exit, _close и пр.) сделать одну-единственную, а все остальные имена повесить на нее weak alias’ами. Это копейки, безусловно, но зачем их тратить, если можно не тратить?

Второй момент: «Скопируем код из предыдущей части» уж очень буквально получилось) Или это упражнение для читателя?

Еще пара нюансов (возможно, специфичных):

  • В моем случае newlib была собрана с поддержкой инициализаторов / финализаторов, поэтому в файл os.c добавилась еще пустая функция _init без аргументов.
  • Почему-то GCC при линковке, несмотря на флаги, подсовывал мне армовую сборку libc (вместо тумбовой). Программа при этом собирается и даже стартует, но валится внутри __libc_init_array. Решилось возвращением ld в качестве компоновщика (впрочем, это не обязательно) и ручным указанием правильных путей к библиотекам. Возможно, я просто не нашел нужный флаг у GCC (ни -mcpu, ни -march, ни -mtune, ни -mthumb не помогают).

По итогу на C6T6 прошивка с printf даже в ПЗУ не влезает (тут его всего 32 кило). С iprintf помещается и работает вполне приемлемо.

Barracuda72 ★★
()
Последнее исправление: Barracuda72 (всего исправлений: 2)
Ответ на: комментарий от Barracuda72

Первый момент: я бы предложил вместо десятка заглушек (для _exit, _close и пр.) сделать одну-единственную, а все остальные имена повесить на нее weak alias’ами. Это копейки, безусловно, но зачем их тратить, если можно не тратить?

Чтобы потом различать, вестимо.

t184256 ★★★★★
()
Ответ на: комментарий от Barracuda72

Первый момент: я бы предложил вместо десятка заглушек (для _exit, _close и пр.) сделать одну-единственную, а все остальные имена повесить на нее weak alias’ами. Это копейки, безусловно, но зачем их тратить, если можно не тратить?

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

А так - согласен, так правильней в конечном варианте сделать.

Второй момент: «Скопируем код из предыдущей части» уж очень буквально получилось) Или это упражнение для читателя?

Это я запутался, извиняюсь, имелось в виду _write, исправил.

vbr ★★★
() автор топика
Последнее исправление: vbr (всего исправлений: 1)
Ответ на: комментарий от vbr

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

А на STMках разве нет SWI? Логичнее было бы его использовать для таких целей, КМК

Barracuda72 ★★
()
Ответ на: комментарий от Barracuda72

Кстати, оффтоп, но что с ценами на али на STMки? Пару недель назад смотрел чипы C6T6 / C8T6, стоили в районе 40р / штука. Сейчас заглянул - и, блин, один чип теперь стоит дороже, чем целая «синяя таблетка» с этим чипом в сборе. Опять какой-то дефицит случился?

Barracuda72 ★★
()
Последнее исправление: Barracuda72 (всего исправлений: 1)

Общий комментарий, не по теме данной статьи. Посмотрел список тем, всё это примитивщина, которую только и обсасывают в статейках в инете. А реально сложные и важные вещи, такие, как USB, Ethernet, беспроводная связь, да даже работа с сд-картами - этого нет.

Т.е. как бы есть Линукс, и там всё про всё, и есть тупые МК, которые светодиодом мигают, а чего-то посередине…

seiken ★★★★★
()
Ответ на: комментарий от seiken

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

Насколько я знаю, с другими протоколами работают примерно так же - используя готовую периферию (встроенную в контроллер или отдельную).

По сути программирование микроконтроллера заключается в том, чтобы вызывать нужные функции, либо путём использования библиотек от вендора, либо путём взаимодействия с периферийным устройством.

vbr ★★★
() автор топика
Последнее исправление: vbr (всего исправлений: 1)

Чувак, тебе надо было издать свою книгу с этим материалом. Тираж в 2000 экземпляров точно был бы раскуплен.

Xintrea ★★★★★
()
Вы не можете добавлять комментарии в эту тему. Тема перемещена в архив.