В прошлый раз мы оформили только две секции в прошивке, для таблицы векторов прерываний и кода. Поморгать светодиодом таким образом можно, а вот для работы с переменными нужно еще чуть-чуть допилить.
Но для начала неплохо бы разобраться, где и как хранятся переменные. Из учебника по C мы помним, что переменные бывают: локальные, статические и динамические. Напомню, что из себя они представляют.
- Переменные локальные, которые объявляются внутри функций хранятся в стеке, всю работу по выделению памяти, инициализации и последующему удалению берет на себя компилятор, нам никаких действий предпринимать не нужно.
- Статические переменные хранятся в специальных секциях в области оперативной памяти, память под них выделяется на этапе линковки, а инициализация должна быть выполнена при загрузке микроконтроллера.
- Память под динамические переменные выделяется во время работы программы, они так же хранятся в специально отведенном регионе памяти. Но с точки зрения линковщика нужно только сообщить системным вызовам размер этого региона, все остальное остается на совести программиста. В основном считается, что использование динамической памяти на микроконтроллерах — это плохо (почему?). Поэтому пока мы не будем их использовать, оставим этот вопрос открытым на потом.
А сегодня мы займемся статическими переменными.
Это все глобальные переменные и локальные объявленные с ключевым словом static
.
Если хорошо подумать, и немного проанализировать свой опыт, можно понять, что статические переменные бывают двух видов: инициализированные до начала работы программы и во время работы. Проиллюстрирую:
int var_a; int var_b = 0; int var_с = 0x1234; |
По учебнику мы считаем, что в var_a
лежит непонятно что, а в var_b
и var_c
на старте программы лежит указанное нами значение.
Вопрос: откуда оно там взялось? И ответ: ниоткуда, сами по себе только кошки родятся.
Даже если мы объявим все необходимые секции линковщик сможет выделить память под них, но на микроконтроллерах никакой операционной системы не завезли, переменные будут с тем, что было в памяти при включении. Получается, что инициализация переменных предопределёнными значениями тоже наша забота.
Давайте о переменных, инициализированных нулем, поговорим отдельно. Поскольку такие переменные встречаются очень часто, нет никакого смысла запоминать отдельно все значения инициализации, выделим их в отдельную секцию и забьем её нулями на старте. Это не какое-то ноу-хау в оптимизации, это стандартное поведение, придуманное десятки лет назад. Компилятор об этом знает и сам помещает все инициализированные нулем переменные в специальную секцию .bss
. Как правильно расшифровывается название сегодня уже не важно, так сложилось исторически, мы же запомним расшифровку «Better Save Space»
Никак не инициализированные переменные попадают в секцию COMMON
, а переменные со стартовым значением отличным от нуля попадают в секцию .data
. Это поведение по умолчанию, его можно переопределить, но это требуется в довольно редких случаях.
Теперь нам нужно рассказать линковщику, как что раскладывать.
С неинициализированными переменными все просто – описываем с скрипте линковки секцию в регионе RAM и готово.
Добавим секцию в скрипт линковки (в раздел SECTIONS {}
конечно же):
.common ORIGIN(RAM) (NOLOAD) : { *(COMMON) } > RAM
Давайте разберем что значит каждая строчка, прошлый раз мы этот момент упустили, но сегодня уже совсем пора.
Первая строка это заголовок. Полный формат команды такой: section_name [address] [(type)] : [AT(lma)]
.common ORIGIN(RAM) (NOLOAD) : {
если полный адрес не указывать, то будет использован «текущий адрес», но текущий адрес двигается сверху вниз по скрипту, а поскольку выше мы работали с ROM
секциями – текущий адрес будет указывать именно на ROM-регион. Поэтому нужно или указать адрес явно (что мы и сделали), или изменить текущий адрес на нужный. Мы укажем явно.
Тип секции NOLOAD
, мы явно сообщим, что не требуется ничего загружать в эту секцию. AT(lma)
мы тут опускаем, ниже мы разберем, что такое LMA
, но использовать сегодня будем немного по-другому.
*(COMMON)
– тут мы говорим линковищку, что в этом месте нужно разложить из всех файлов (*) все символы помеченные как COMMON
} > RAM
— ну и в конце закрывающая блок скобка и инструкция > указывает на регион памяти к которому эта секция привязана. Только на этот раз регион другой. Название региона – это не более чем удобное слово, мы вольны называть его как угодно, лишь бы это называние было описано в разделеMEMORY
скрипта линковки. Причем, эта инструкция не размещает секцию в регионе, а проверяет размещение. Повторюсь, секция размещается по адресу указанному в заголовке или текущему, если адрес опущен.
С секций «`.bss« чуть посложнее. Мы попросим линковищик сообщить нам адреса начала и конца этой секции и как договорились ранее, при старте программы забьем все нулями.
.bss ALIGN(4) (NOLOAD) : { PROVIDE(__bss_start__ = .); *(.bss*) PROVIDE(__bss_end__ = .); } > RAM
Заголовок очень похож, добавилась команда ALIGN(4)
вместо адреса сообщает линковщику, что перед тем, как начинать секцию, текущий адрес нужно выровнять по слову (4 байта). Выравнивание памяти в ARM вообще довольно необходимая штука. Из-за архитектуры процессора он не умеет загружать за одну команду не выровненное (по 4 байтам) слово, если попытаться выполнить инструкцию с не ровным адресом — процессор упадет в HardFault, компилятор этого конечно же не допустит, но будет обращаться к памяти не одной быстрой инструкцией, а начнет загружать слова байтами или полу-словами. Это сильно дольше, так что давайте сразу положим ровный адрес.
Инструкция PROVIDE(__bss_start = .) ;
означает cоздать символ __bss_start__
и положить в него текущий адрес. Точка (.
) в скрипте линковищка почти всегда обозначает текущий адрес. Текущий, потому что линковщик выполняет скрипт сверху вниз, и двигает адрес в соответствии с инструкциями скрипта.
После, строка *(.bss*)
указывает линковщику, что вот в этом место , нужно разложить взять из всех файлов (первая звездочка) все символы привязанные к секциям начинающимся со слова .bss
. Звездочка означает, как и обычно – любое количество любых символов. Можно указывать и конкретно: main.o(.bss)
– развернет вместо этой строки только символы привязанные к секции .bss
(строго) и только из файла main.o
. Но пока такая конкретика нам особо не требуется.
Дальше идет опять инструкция создать символ с текущим адресом (текущий адрес подвинулся на размер всех развернутых в предыдущей строке символов).
При инициализации программы нам нужно будем получить адреса переменных __bss_start__
и __bss_end__
и обнулить все что между ними включая __bss_start__
, т.к. в нем будет находится адрес первой переменной секции, но не включая __bss_end__
– в ней будет адрес следующего байта после последней переменной секции. Почему именно так, будет ясно после того, как мы соберем исполняемый файл и посмотрим командой NM на все адреса.
С предопределёнными не нулевыми значениями переменными будет посложнее. Во-первых, нам нужно сделать секцию в оперативной памяти. Во-вторых сделать слепок секции с значениями инициализации в энергонезависимой памяти. И еще при старте программы скопировать данные из этого слепка в оперативную память.
.data ALIGN(4): { PROVIDE(__data_start__ = .); *(.data*) PROVIDE(__data_end__ = .); } > RAM AT>ROM
Описание похоже на .bss
, кроме указания места хранения. Добавилась новая инструкция AT>
. О том, что она делает и как работает сейчас поговорим.
У выходных секций линковщика есть два адреса, VMA
– Virtual Memory Address и LMA
– Load Memory Address. При обращении к символу из секции линковщик всегда подставляет VMA
, а при запаковке данных в исполняемый файл складывает данные в LMA
. Если ничего не указать до адреса эти будут совпадать, нас всегда это устраивало, но теперь ситуация изменилась. Как и с предыдущими секциями переменных нам нужно, чтобы VMA
указывал на адреса оперативной памяти, что и сделает инструкция >RAM
, а вот LMA
должен указывать на флеш-память, чтобы при запаковке исполняемого файла все значения, которыми инициализируются переменные попали в энергонезависимую память. Для этого и служит инструкция AT>ROM
. А вот эта команда как раз указывает линковщику, что нужно положить данные этой секции в регион ROM. Причем адресом поуправлять не получится. Да еще путаница с тем что AT>ROM
данные кладет, а >RAM
– только делает проверку, адрес же используется из заголовка секции. Такой синтаксис секций вызывает взрыв мозга у любого нормально человека, придумали его под наркозом, но поскольку самостоятельно написать скрипт линковки могут единицы и такая конструкция кочует из проекта в проект, оставим для ознакомления. Следующий раз мы перепишем его человеческим языком и разложим все адреса по полочкам.
Мы так же получаем два символа __data_start__
и __data_end__
указывающие на начало и конец секции данных. Еще нам потребуется адрес LMA этой секции, для его получения есть команда LOADADDR()
:
PROVIDE(__data_lma__ = LOADADDR(.data));
Теперь поработаем с инициализацией в коде программы.
extern uint8_t __data_start__, __data_end__, __data_lma__, __bss_start__, __bss_end__; uint32_t *dst; dst = &__bss_start__; while (dst < &__bss_end__) *dst++ = 0; dst = &__data_start__; uint32_t *src = &__data_lma__; while (dst < &__data_end__) *dst++ = *src++; |
На этом вся инициализация закончена.
Давайте посмотрим полные файлы:
Script.ld:
OUTPUT_FORMAT("elf32-littlearm", "elf32-littlearm", "elf32-littlearm") OUTPUT_ARCH(arm) MEMORY { ROM (rx) : ORIGIN = 0x08000000, LENGTH = 64K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K } SECTIONS { .isr_vector ORIGIN(ROM) : { KEEP(*(.isr_vector)) } >ROM .text ALIGN(4) : { *(.text*); } > ROM .common ORIGIN(RAM) (NOLOAD) : { *(COMMON) } >RAM .bss ALIGN(4) (NOLOAD): { PROVIDE(__bss_start__ = .); *(.bss*) PROVIDE(__bss_end__ = .); } > RAM .data ALIGN(4): { PROVIDE(__data_start__ = .); *(.data*) PROVIDE(__data_end__ = .); } > RAM AT>ROM PROVIDE(__data_lma__ = LOADADDR(.data)); PROVIDE(__stack_top__ = ORIGIN(RAM) + LENGTH(RAM)); }
Кроме описанных выше секций изменились заголовки секций векторов прерываний и кода, но уже должно быть понятно, что именно поменялось. Также был приведен к общему виду указатель на вершину стека
Main.c:
#include <stdint.h> #define APB2ENR (*(uint32_t*)(0x40021000+0x18)) #define APB2ENR_ENIOC (1u<<4u) #define GPIOC_CRH (*(uint32_t*)(0x40011000+0x04)) #define xGPIO_CRH_MODE13_0 (1u <<20u) #define xGPIO_CRH_MODE13_1 (1u <<21u) #define GPIOC_ODR (*(uint32_t*)(0x40011000+0x0c)) #define GPIO_ODR_PIN_13 (1u<<13u) __attribute__((unused)) int var_a; __attribute__((unused)) int var_b= 0; __attribute__((unused)) int var_c= 0x1234; __attribute__((noreturn)) void Reset_Handler(){ //Импортируем символы, которые мы создали в скрпите линковки extern uint8_t __data_start__, __data_end__, __data_lma__, __bss_start__, __bss_end__; uint8_t *dst; //Обнулим сецию BSS dst = &__bss_start__; while (dst < &__bss_end__) *dst++ = 0; dst = &__data_start__; //Инициализируем переменные в .data данным из флеш-памяти uint8_t *src = &__data_lma__; while (dst < &__data_end__) *dst++ = *src++; //Разрешаем тактировать GPIOC на шине APB2 APB2ENR |= APB2ENR_ENIOC; // Настраиваем GPIO Pin 13 как выход Push-Pull на максимальной частоте GPIOC_CRH |= xGPIO_CRH_MODE13_0 | xGPIO_CRH_MODE13_1; while(1){ // Переключаем пин 13 на порте C GPIOC_ODR ^= GPIO_ODR_PIN_13; } } // Объявим тип - указатель на прерывание typedef void (*isr_routine)(void); // Опишем структуру таблицы векторов прерываний typedef struct { const uint32_t * stack_top; const isr_routine reset; } ISR_VECTOR_t; //Получим адрес указателья на стек из скрипта линковки extern const uint32_t __stack_top__; //Укажем линковщику, что эту константу нужно положить в секцию .isr_vector __attribute__((section(".isr_vector"), __unused__)) const ISR_VECTOR_t isr_vector = { .stack_top = &__stack_top__, .reset = &Reset_Handler, }; |
Теперь можно собрать прошивку тем же способом, что мы использовали выше (в последний раз). И посмотрим на её внутренности:
Команда arm-none-eabi-nm -a -n main.elf покажет все символы и секции, которые появились. Посмотрим адреса секций в памяти:?
20000000 b .common 20000000 B var_a 20000004 b .bss 20000004 B __bss_start__ 20000004 B var_b 20000008 d .data 20000008 B __bss_end__ 20000008 D __data_start__ 20000008 D var_c 2000000c D __data_end__ 20005000 D __stack_top__
Все ровно так, как мы и планировали. В начале региона идет секция .common и в ней одна переменная var_a которую не инициализировали. Дальше секция .bss
с соответствующими символами и секция .data
Видно, что символ начала секции указывает на её первый байт, а конца секции всегда указывает на следующий байт после последнего символа.
Можно прошагать в отладчике и с помощью команд x и p посмотреть что хранится в памяти и как происходит инициализация
Следующая часть: ARM Without Magic. Урок 1.2 Линковка