Часть 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 содержит полезный функционал, но стоит внимательно относиться к тем функциям, которые вы планируете использовать. Стандартный ввод-вывод больше подходит для «больших компьютеров» и на микроконтроллерах стоит задуматься о других подходах.
Полный код доступен на гитхабе.