ARM Without Magic. Урок 1.2 Линковка

Пришла пора привести в понятный вид скрипт линковки для прошивки под STM32 (или любой другой ARM Cortex-M).
Прошлый раз мы писали как попало и не очень понятно было, где и что лежит. Будем устранять непонятности.
Я все время говорю – символы, секции. Давайте займемся терминологией.
Первое и главное – это символ. Для компоновщика символ это именованный адрес не более того. (правда похоже на переменную в любом языке программирования?). Причем не важно, что скрывается под этим символом – адрес переменной, код функции, все что угодно. Плюс некоторые «атрибуты», такие как принадлежность к секции, размер. Когда в коде написано uint32_t var_a = 12345;, при компиляции создается символ var_a, в атрибуты помещается размер в 4 байта и принадлежность к секции .data (мы уже говорили, что инициализированные переменные попадают в эту секцию). Для функций – примерно тоже самое, только секция будет .text, ну и размер нельзя вот прямо так сразу назвать, но после компиляции он конечно же будет известен.

Секции, на самом деле под этим словом скрываются два понятия, с точки зрения компоновщика есть два типа секций – входные и выходные. Входные секции, это значение того самого атрибута символа, про который мы говорили выше, не более того. Выходные секции — это именованные логические области памяти в выходном (исполняемом и бинарном) файле. Тут не следует путать с регионами памяти (описываемые в блоке MEMORY), регион памяти — это физический блок (флешка, SRAM, внешняя память и так далее). А выходная секция логический блок внутри региона. Выходные секции, как, наверное, стало понятно, описываются в разделе SECTIONS скрипта линковки.
Еще одно очень важное понятие, которым компоновщик оперирует — это «текущий адрес». В начале скрипта он устанавливается на начало первого описанного региона и двигается при размещении данных. В скриптах линковки к этому адресу всегда можно обратиться через оператор «.» (Точка). Можно получать его значение (и назначать символам), можно двигать в абсолютное значение (например, . = ORIGIN(RAM); — помещает в текущий адрес начало региона RAM), или относительное (например, . = ALIGN(16); — подвинет, если требуется, текущий адрес так, чтобы его значение стало кратно 16). Так же, можно подвинуть на заданное количество байт (. += 10 — подвинет текущий адрес на 10 байт вперед). Самое главное, что там где адрес не указан явно – всегда используется текущий адрес, а там где разворачиваются какие-то данные, текущий адрес всегда двигается вперед на размер этих данных.

Давайте перепишем скрипт линковки с учетом этих знаний и приведем в понятный не только компьютеру вид.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
OUTPUT_FORMAT("elf32-littlearm", "elf32-littlearm", "elf32-littlearm")
OUTPUT_ARCH(arm)
MEMORY
{
    ROM  (rx) : ORIGIN = 0x08000000, LENGTH = 64K  /* Объявляем регион ROM */
    RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K  /* Объявляем регион RAM */
}
 
SECTIONS
{
    .isr_vector ORIGIN(ROM):  /* Объявляем выходную секцию .isr_vector, адрес укажем явно: начало региона ROM */
    {
        KEEP(*(.isr_vector))  /* помещаем сюда все символы с атрибутом section(".isr_vector") */
    }
 
    .text ALIGN(16):        /* Объявляем выходную секцию .text, попросим компоновщик выровнять секцию кратно 16 байт */
    {
        *(.text*);          /* Развернем сюда все символы с атрибутом section начинающимся со слова .text */
    }
 
    .data_rom ALIGN(4):     /* Выходная секция для значений инициализации данных из .data */
    {
       /* Создадим символ __data_rom__ с адресом равным текущему */
        PROVIDE(__data_rom__ = .);   
        /* Подвинем указатель на текущий адрес на размер выходной секции .data, она будет объявлена позже */
        . += SIZEOF(.data);     
    }
 
 
    /* Секция для неинициализированных переменных, адрес - начало региона RAM
       Тип NOLOAD, говорит компоновщику, что нужно только распределить адреса, данные нам не важны*/
    .common ORIGIN(RAM) (NOLOAD) : 
    {
        *(COMMON*)                 
    }
 
    .bss ALIGN(4) (NOLOAD): {
        PROVIDE(__bss_start__ = .);
        *(.bss*)
        PROVIDE(__bss_end__ = .);
    }
 
    /* Секция .data, для инициализированных переменных, 
    оператор AT() назначает LMA для этой секции */
    .data ALIGN(4): AT(__data_rom__)
    {
        PROVIDE(__data_start__ = .);
        *(.data*)
        PROVIDE(__data_end__ = .);
    } >RAM
 
    /* Создадим символ __stack_top__ */
    PROVIDE(__stack_top__ = ORIGIN(RAM) + LENGTH(RAM));
}

Небольшое примечание: символ __data_lma__ мы больше не используем, для сборки с этим скриптом потребуется немного поправить исходник из прошлого занятия. Теперь адрес начала секции данными мы указали как __data_rom__

Теперь все адреса известны и явно указаны, мы всегда знаем где находится какая секция. А мы разберем вопрос, почему символы указываются как *(.text*) и зачем столько звездочек. Во-первых напомню формат инструкции, документация говорит о трёх возможных форматах:

filename( section ) 
filename( section, section, ... ) 
filename( section section ... ) 

Мы пользуемся первым (одна секция на строку). В качестве filename мы указываем * — любой файл, а в качестве section (входной секции) – мы указываем .text*, что значит «любая секция, начинающаяся на .text. Так нужно делать, потому что один из базовых способов оптимизации размера программы это помещение КАЖДОГО символа в свою собственную секцию вида .section.symbolname, например для переменной int var_a = 0; при компиляции с ключом GCC -fdata-sections в выводе arm-none-eabi-nm можно будет увидеть что-то похожее на:

> arm-none-eabi-nm -a main.o
... 
00000000 b .bss.var_a
...

Для того, чтобы все такие символы из всех файлов попали в одну нужную выходную секцию и указывается *(.section*)