ARM Without Magic. Урок 1.1 Переменные и инициализация.

В прошлый раз мы оформили только две секции в прошивке, для таблицы векторов прерываний и кода. Поморгать светодиодом таким образом можно, а вот для работы с переменными нужно еще чуть-чуть допилить.
Но для начала неплохо бы разобраться, где и как хранятся переменные. Из учебника по 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>. О том, что она делает и как работает сейчас поговорим.

У выходных секций линковщика есть два адреса, VMAVirtual Memory Address и LMALoad 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 Линковка