Перейти к основному содержимому

Процедура сборки проекта

Данный документ описывает процедуру сборки проекта средствами GCC и Binutils применительно к процессорам серии «Мультикор».

Основные этапы сборки проекта

Если абстрагироваться от задач, которые должно выполнять конечное устройство, у программиста основная цель – создание финального файла (также известного как «исполняемый файл»), содержащего машинный код, выполняющий все заданные действия. Помимо самого кода, в этом файле должна содержаться информация о том, какой код и какие данные по каким адресам загружать. Помимо этого, в файле может содержаться отладочная информация, которая в память процессора не загружается, но нужна программисту при отладке программы. Существует еще ряд особенностей и нюансов, которые в итоге подводят нас к тому, что структура финального файла совсем не так проста, как кажется. По ряду причин, в Linux-системах (откуда родом компиляторы GCC) форматом исполняемого файла выбран ELF (Executable and Linkable Format). Его структура регламентирована стандартом, а также подробно рассмотрена на множестве интернет ресурсов.

Наша задача – рассказать о процессе перехода набора файлов исходного кода в файл ELF. Сначала процедура будет рассмотрена на общих примерах для процессоров MIPS32, безотносительно архитектуры «Мультикор» и среды разработки MCStudio 2/3M/4, далее будут приведены более конкретные примеры. Несмотря на первичную отстранённость от MCStudio, конкретные действия с компилятором будут производиться на примере инструментов, входящих в состав MCStudio 4, в директории %MCS4%/ToolsMGCC/bin.

Препроцессор

При сборке проекта первым по исходному коду проходит препроцессор. Он никак не анализирует сам по себе код. Его задача – раскрыть все директивы препроцессора (#define, #include). О синтаксисе языка Си (или другого используемого языка) он не знает. Рассмотрим на нескольких примерах.

Создадим файл main.c с таким содержанием:

#define A 10
#define B 15

int main() {
int c;
c = A+B;
return 0;
}

Теперь пропустим его через препроцессор:

mipsel-elf32-gcc –E main.c

Получим такой вывод:

# 1 "main.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 1 "main.c"

int main()
{
int c;
c = 10 +15;
return 0;
}

Как можно увидеть, A и B были заменены их значениями.

Попробуем теперь привнести заведомую ошибку – объявим «A» два раза, причем по-разному:

#define A 10
#define B 15

#define A 25

int main() {
int c;
c = A+B;
return 0;
}

И пропустим этот код через препроцессор (тем же вызовом mipsel-elf32-gcc):

mipsel-elf32-gcc -E main.c
# 1 "main.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 1 "main.c"
main.c:4:1: warning: "A" redefined
main.c:1:1: warning: this is the location of the previous definition


int main() {
int c;
c = 25 + 15;
return 0;
}

Видно, что препроцессор выдал warning (предупреждение), но не ошибку, и взял последний вариант макроопределения A. Главные выводы по директиве #define таковы:

  • вместо макроопределений обязательно подставляется их содержимое (этим макроопределения отличаются от функций и переменных);
  • необходимо тщательно отслеживать макроопределения с одинаковыми именами, иначе возможно непредсказуемое поведение программы.

Раскрытие директивы #include

Предположим, что макроопределения A и B могут использоваться во многих файлах. В этом случае удобнее вынести их в отдельный заголовочный *.h файл, который будет включаться в исходники директивой include. Важно понимать, что расширение *.h - это просто удобное для всех соглашение. На самом деле, расширение файла для препроцессора неважно.

Итак, следуя заданной в предыдущем абзаце вводной, создадим файл defs.h:

#define A 10
#define B 15

int c;
Cкорректированный файл main.c:
#include "defs.h"

int main() {
c = A + B;
return 0;
}

Теперь вызовем GCC с тем же ключом:

mipsel-elf32-gcc -E main.c
# 1 "main.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 1 "main.c"
# 1 "defs.h" 1

int c;
# 2 "main.c" 2

int main() {
c = 10 + 15;
return 0;
}

Видим мы следующее: Препроцессор включил в состав main.c весь файл defs.h. Но кроме этого, он раскрыл все директивы #define, поэтому мы видим только часть файла defs.h. Можно задать препроцессору опцию показать все директивы #define (ключ -dD), в этом случае вывод будет устрашающе большим в силу наличия множества служебных макроопределений, добавляемых по умолчанию, поэтому результат выполнения команды приведен в сокращенном виде:

mipsel-elf32-gcc -E -dD main.c
# 1 "main.c"
# 1 "<built-in>"
#define __STDC__ 1
#define __BIGGEST_ALIGNMENT__ 8
# 1 "<command-line>"
# 1 "main.c"
# 1 "defs.h" 1
#define A 10
#define B 15

int c;
# 2 "main.c" 2

int main() {
c = 10 + 15;
return 0;
}

Здесь мы видим следующее:

  • длинный список служебных макроопределений типа __STDC__;
  • результирующий вариант файла main.c после раскрытия директивы #include. Как видно, результат аналогичен тому, как если бы текст файла defs.h был бы вставлен в тело main.c обычным copy-paste.

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

Условное включение/исключение текста директивами #ifdef / #endif

В ряде случаев бывает необходимо использовать или не использовать определенный участок кода в зависимости от конкретной собираемой конфигурации. Конкретный пример – одна и та же программа, будучи собираемой в разных конфигурациях, может использовать или не использовать вывод в UART. Соответственно, в варианте без использования UART совершенно не требуется код, работающий с данным портом. Поэтому код может быть написан так:

#define UART

#ifdef UART
void UART_init(unsigned int UART_speed, unsigned int CPU_freq) {}
void UART_sendByte(unsigned char byte) {}
#endif

Соответственно, если в коде будет присутствовать строка #define UART – препроцессор увидит, что макрос «UART» определен, и оставит код, обрамленный условием #ifdef UART. Если же закомментировать строчку с определением «UART» – этот участок кода будет выброшен. Убедиться в вышесказанном можно, повторив запуск препроцессора тем же способом, что и в предыдущих пунктах.

Компилятор

После того, как препроцессор раскрыл все директивы, и получен код на языке Си, не содержащий макроопределений, а только имена функций/переменных и конкретные числовые значения, по нему проходит компилятор, преобразуя данный файл в ассемблерный код. Для демонстрации вернемся к первому варианту файла main.c:

#define A 10
#define B 15

int main() {
int c;
c = A + B;
return 0;
}

Теперь прогоним его через компилятор с опцией сохранения полученного ассемблерного файла -S:

mipsel-elf32-gcc -S main.c

Результат будет сохранен в файле main.s. Вот интересующая нас выдержка из него:

main:
.frame $fp,16,$31 # vars= 8, regs= 1/0, args= 0, gp= 0
.mask 0x40000000,-4
.fmask 0x00000000,0
.set noreorder
.set nomacro

addiu $sp,$sp,-16
sw $fp,12($sp)
move $fp,$sp
li $2,25 # 0x19
sw $2,0($fp)
move $2,$0
move $sp,$fp
lw $fp,12($sp)
addiu $sp,$sp,16
j $31
nop

Ключевое здесь: Стандартное обрамление любой функции на языке Си начинается с инструкции addiu и заканчивается инструкцией nop. Регистр $sp (он же $29) в архитектуре MIPS32, согласно MIPS ABI, содержит в себе указатель стека. При входе в функцию указатель сдвигается, и функция работает в своем «персональном» отрезке стека. Инструкции li и sw выделен код, который соответствует непосредственно функциональной части нашего кода на языке Си. Можно видеть, что функциональный код состоит всего из двух инструкций – занесения числа 25 в регистр и сохранения полученного значения в стек – по адресу локальной переменной «c»,объявленной в функции main(). Сам по себе процесс сложения отсутствует, поскольку складываются заведомо константные значения. Поэтому компилятор оптимизировал данный участок кода, выполнив сложение на стадии компиляции, и подставив уже готовый результат.

Далее опять идет код стандартного обрамления функции. Восстанавливается указатель стека, после чего происходит переход (инструкция «JR») по адресу, содержащемуся в регистре $31. Инструкция NOP после команды перехода – особенность всех архитектур типа RISC. Это так называемая инструкция в «слоте задержки перехода», которая, хотя и находится уже после перехода, но все равно выполняется. Подробнее об этом – в литературе, посвященной архитектурам процессоров. Для больше наглядности можно все же заставить код выполнять сложение на стадии исполнения. Для этого необходимо убрать макроопределения и добавить еще две переменных:

int main() {
int a = 10;
int b = 15;
int c;
c = a + b;
return 0;
}

Вот в какой ассемблерный код это транслируется:

  addiu $sp,$sp,-24
sw $fp,20($sp)
move $fp,$sp
li $2,10 # 0xa
sw $2,8($fp) // присваиваем значение одной переменной
li $2,15 # 0xf
sw $2,4($fp) // присваиваем значение второй переменной
lw $3,8($fp) // загружаем обе переменных в регистры
lw $2,4($fp)
nop
addu $2,$3,$2 // выполняем сложение
sw $2,0($fp) // сохраняем полученное значение в третью переменную
move $2,$0
move $sp,$fp
lw $fp,20($sp)
addiu $sp,$sp,24
j $31
nop

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

int sum (int a, int b) {
return (a + b);
}

int main() {
int c;
c = sum(10,15);
return 0;
}

В результате получим ассемблерный код:

sum:
.frame $fp,8,$31 # vars= 0, regs= 1/0, args= 0, gp= 0
.mask 0x40000000,-4
.fmask 0x00000000,0
.set noreorder
.set nomacro

addiu $sp,$sp,-8
sw $fp,4($sp)
move $fp,$sp
sw $4,8($fp) // полученные аргументы сохраняем
sw $5,12($fp) // в локальные переменные нашей функции
lw $3,8($fp) // загружаем наши копии аргументов в регистры
lw $2,12($fp)
nop
addu $2,$3,$2 // выполняем сложение

// результат возвращаем в регистр $2
move $sp,$fp
lw $fp,4($sp)
addiu $sp,$sp,8
j $31
nop

main:
.frame $fp,32,$31 # vars= 8, regs= 2/0, args= 16, gp= 0
.mask 0xc0000000,-4
.fmask 0x00000000,0
.set noreorder
.set nomacro

addiu $sp,$sp,-32
sw $31,28($sp)
sw $fp,24($sp)
move $fp,$sp
li $4,10 // первый параметр вносится в регистр $4
li $5,15 // второй параметр – в регистр $5
jal sum // вызываем функцию (адрес
nop

sw $2,16($fp) // полученный результат сохраняем в переменную
move $2,$0
move $sp,$fp
lw $31,28($sp)
lw $fp,24($sp)
addiu $sp,$sp,32
j $31
nop

Ассемблер

После получения ассемблерного кода, он пропускается через ассемблер, после чего на выходе получается объектный файл – файл, содержащий в себе машинный код, который может быть исполнен процессором. Объектный файл уже имеет формат ELF. Важно понимать, что есть промежуточный объектный файл (в нем не проставлены адреса функций и переменных) и финальный (исполняемый) объектный файл – в нем уже все проставлено и он может исполняться процессором без дополнительных действий. Вернемся к последнему варианту файла main.c. Выполним такую последовательность действий:

mipsel-elf32-gcc -c main.c

Ключ -c говорит компилятору выполнить всю последовательность сборки, за исключением линковки – финального шага. Результатом этой «оборванной» цепочки сборки будет как раз промежуточный объектный файл main.o, который мы разберем с помощью утилиты OBJDUMP:

main.o:     file format elf32-littlemips

Disassembly of section .text:

00000000 <sum>:
0: 27bdfff8 addiu sp,sp,-8
4: afbe0004 sw s8,4(sp)
8: 03a0f021 move s8,sp
c: afc40008 sw a0,8(s8)
10: afc5000c sw a1,12(s8)
14: 8fc30008 lw v1,8(s8)
18: 8fc2000c lw v0,12(s8)
1c: 00000000 nop
20: 00621021 addu v0,v1,v0
24: 03c0e821 move sp,s8
28: 8fbe0004 lw s8,4(sp)
2c: 27bd0008 addiu sp,sp,8
30: 03e00008 jr ra
34: 00000000 nop

00000038 <main>:
38: 27bdffe0 addiu sp,sp,-32
3c: afbf001c sw ra,28(sp)
40: afbe0018 sw s8,24(sp)
44: 03a0f021 move s8,sp
48: 2404000a li a0,10
4c: 2405000f li a1,15
50: 0c000000 jal 0 <sum>
54: 00000000 nop
58: afc20010 sw v0,16(s8)
5c: 00001021 move v0,zero
60: 03c0e821 move sp,s8
64: 8fbf001c lw ra,28(sp)
68: 8fbe0018 lw s8,24(sp)
6c: 27bd0020 addiu sp,sp,32
70: 03e00008 jr ra
74: 00000000 nop

Из полученного дизассемблера видно, что адреса функций (00000000 и 00000038) не заданы. Соответственно, не задан и адрес перехода JAL, вызывающего функцию sum(). Воспользуемся теперь утилитой READELF:

>mipsel-elf32-readelf -a main.o

Поскольку вывод данной утилиты с ключом -a (all) громоздкий, рассмотрим только важные в данный момент части. Начнем с разбора заголовка ELF-файла:

ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: REL (Relocatable file)
Machine: MIPS R3000
Version: 0x1
Entry point address: 0x0
Start of program headers: 0 (bytes into file)
Start of section headers: 400 (bytes into file)
Flags: 0x1001, noreorder, o32, mips1
Size of this header: 52 (bytes)
Size of program headers: 0 (bytes)
Number of program headers: 0
Size of section headers: 40 (bytes)
Number of section headers: 14
Section header string table index: 11

Помимо прочей информации, видно, что данный ELF-файл имеет тип «Relocatable file», что как раз и означает промежуточный объектный файл, он же – переносимый. Дальше идет таблица секций:

Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al

[ 0] NULL 00000000 000000 000000 00 0 0 0

[ 1] .text PROGBITS 00000000 000034 000078 00 AX 0 0 4

[ 2] .rel.text REL 00000000 0004d8 000008 08 12 1 4

[ 3] .data PROGBITS 00000000 0000ac 000000 00 WA 0 0 1

[ 4] .bss NOBITS 00000000 0000ac 000000 00 WA 0 0 1

[ 5] .reginfo MIPS_REGINFO 00000000 0000ac 000018 01 0 0 4

[ 6] .pdr PROGBITS 00000000 0000c4 000040 00 0 0 4

[ 7] .rel.pdr REL 00000000 0004e0 000010 08 12 6 4

[ 8] .mdebug.abi32 PROGBITS 00000000 000104 000000 00 0 0 1

[ 9] .comment PROGBITS 00000000 000104 000012 01 MS 0 0 1

[10] .gnu.attributes LOOS+ffffff5 00000000 000116 000010 00 0 0 1

[11] .shstrtab STRTAB 00000000 000126 000069 00 0 0 1

[12] .symtab SYMTAB 00000000 0003c0 0000c0 10 13 9 4

[13] .strtab STRTAB 00000000 000480 000057 00 0 0 1

Секция — это общее название для именованного блока данных в объектном файле. «Данные» в данном случае понятие обобщенное. Это могут быть как некие переменные (или константы), так и исполняемый код. С точки зрения компоновщика (линковщика/линкера/etc), на стадии компоновки программы и отладчика на стадии загрузки программы в процессор, важно, что каждая секция — это набор байтов заданной в таблице длины, имеющая ряд атрибутов (в частности, это может быть загружаемая или не загружаемая секция) и имеющая адрес, в случае атрибута «загружаемая», по которому ее необходимо загружать. Также в ELF-файле содержится таблица символов. Символ — это, в общем случае, пара «имя-значение». Чаще всего, значением является адрес.

There are no unwind sections in this file.

Symbol table '.symtab' contains 12 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
1: 00000000 0 SECTION LOCAL DEFAULT 1 .text
2: 00000000 0 SECTION LOCAL DEFAULT 3 .data
3: 00000000 0 SECTION LOCAL DEFAULT 4 .bss
4: 00000000 0 SECTION LOCAL DEFAULT 8 .mdebug.abi32
5: 00000000 0 SECTION LOCAL DEFAULT 5 .reginfo
6: 00000000 0 SECTION LOCAL DEFAULT 6 .pdr
7: 00000000 0 SECTION LOCAL DEFAULT 9 .comment
8: 00000000 0 SECTION LOCAL DEFAULT 10 .gnu.attributes
9: 00000000 0 FILE LOCAL DEFAULT ABS main.c
10: 00000000 56 FUNC GLOBAL DEFAULT 1 sum
11: 00000038 64 FUNC GLOBAL DEFAULT 1 main
примечание

Явно объявленные нами символы main и sum. Они имеют тип «FUNC», а значением своим – адреса соответствующих файлов. Поскольку это промежуточный объектный файл, адреса заданы относительно начала секции .text.

Компоновщик

На этой стадии появляется ряд уточнений, которые надо озвучить явно:

  1. сама по себе программа MIPSEL-ELF32-GCC не является ни препроцессором, ни компилятором, ни ассемблером, ни компоновщиком. Ее задача – только вызывать упомянутые инструменты с некими заранее заданными параметрами. На предыдущих этапах важно было объяснить сам принцип работы препроцессора, компилятора и ассемблера, не вдаваясь в подробности, что именно вызывается после GCC, то здесь в подробности вдаваться придется. Поэтому сначала мы будем работать с компоновщиком MIPSEL-ELF32-LD;
  2. Как можно было заметить из дизассемблера, функции активно оперируют стеком. Это значит, что перед входом в функцию main() необходимо выполнить ряд подготовительных действий, в частности, определить, какая область памяти будет служить для хранения стека и занести адрес верхушки стека в регистр $29(sp). Если этого не сделать – после старта регистр $29 будет содержать произвольное значение, и первое же наше обращение (чтение или запись) приведет к непредсказуемым последствиям (поскольку неизвестно, что мы прочитаем по этому произвольному адресу, или по какому адресу мы произведем запись).
  3. Помимо установки стека, в предварительной инициализации принято обнулять неинициализированные переменные, объявленные в коде, а для процессоров MIPS32, имеющих сопроцессор FPU, еще и инициализировать этот сопроцессор.

Исходя из вышесказанного, для инициализации был сформирован файл StartUp.s, содержащий в себе следующий код:

   .set noreorder
.set mips32
.text
#------------------------------------------------
# Точка входа в программу
#
_start: .global _start
.ent _start
la $28,_gp # Установка gp
la $29,__stack # Установка sp
# Очистка области неинициализированных переменных
la $2,_bss_start
la $4,_end
beq $2,$4,2f
nop
1: sw $0,0($2)
bne $2,$4,1b
addiu $2,4
# Переход на main()
2: lui $2,%hi(main)
ori $2,%lo(main)
jr $2
nop

В данном коде используется ряд символов, о которых ранее ничего не говорилось и которые нигде ранее не были объявлены. После прохождения ассемблера по этому файлу будет сформирован промежуточный объектный файл:

mipsel-elf32-objdump -d startup.o
startup.o:     file format elf32-littlemips

Disassembly of section .text:

00000000 <_start>:
0: 3c1c0000 lui gp,0x0
4: 279c0000 addiu gp,gp,0
8: 3c1d0000 lui sp,0x0
c: 27bd0000 addiu sp,sp,0
10: 3c020000 lui v0,0x0
14: 24420000 addiu v0,v0,0
18: 3c040000 lui a0,0x0
1c: 24840000 addiu a0,a0,0
20: 10440004 beq v0,a0,34 <_start+0x34>
24: 00000000 nop
28: ac400000 sw zero,0(v0)
2c: 1444fffe bne v0,a0,28 <_start+0x28>
30: 24420004 addiu v0,v0,4
34: 3c020000 lui v0,0x0
38: 34420000 ori v0,v0,0x0
3c: 00400008 jr v0
40: 00000000 nop

Как можно увидеть, здесь вместо символов __stack (адрес верхушки стека), _gp (адрес секции «маленьких» переменных), _bss_start (адрес секции неинициализированных переменных), _end (конец секции неинициализированных переменных) и main в регистры заносятся также нули. В итоге, для получения финального исполняемого объектного файла, мы имеем два промежуточных объектных файла, из которых компоновщик должен сделать один и проставить все адреса. Однако если ассемблер или препроцессор прекрасно знают сами, что и как преобразовывать (хотя и с вариациями в зависимости от ключей запуска), с компоновщиком история другая. По умолчанию он не знает, в какие адреса и что класть. Поэтому для компоновщика обязательно должен присутствовать файл линковочного скрипта. Как правило, он имеет расширение *.LD или *.XL. Рассмотрим типичный скрипт компоновщика (назовем его program.xl):

OUTPUT_ARCH("mips:isa32")
TARGET("elf32-littlemips")
ENTRY(_start)
SEARCH_DIR(.)
SECTIONS {

. =0xB8001000;
.text :
{
* (.text);
* (.init)
* (.rel.sdata)
* (.fini)
}

.data :
{
* (.data);
}

_gp = ALIGN(16);
.lit4 : { *(.lit4) }
.sdata : { *(.sdata) }
.rodata : { *(rodata) }
. = ALIGN(8);
PROVIDE (edata = .);
_edata = .;

_fbss = .;
.sbss : { *(.sbss*) *(.scommon) *(*ABS*)}

.bss :
{
_bss_start = .;
*(.bss);
*(COMMON)
}

. += 0x0000800;
PROVIDE(__stack = ALIGN(8));
. += 0x10;


PROVIDE(end = .);
_end = .;
}

Теперь, чтобы скомпоновать финальный ELF-файл, необходимо вызвать компоновщик:

mipsel-elf32-ld -T program.xl main.o startup.o -o program.o

Ключ -T указывает на используемый скрипт линковки, дальше перечисляются промежуточные объектные файлы, из которых надо собирать финальный файл, ну а ключ -o задает имя результирующего файла. Выполним OBJDUMP:

>mipsel-elf32-objdump -d program.o
program.o:     file format elf32-littlemips


Disassembly of section .text:

b8001000 <sum>:
b8001000: 27bdfff8 addiu sp,sp,-8
b8001004: afbe0004 sw s8,4(sp)
b8001008: 03a0f021 move s8,sp
b800100c: afc40008 sw a0,8(s8)
b8001010: afc5000c sw a1,12(s8)
b8001014: 8fc30008 lw v1,8(s8)
b8001018: 8fc2000c lw v0,12(s8)
b800101c: 00000000 nop
b8001020: 00621021 addu v0,v1,v0
b8001024: 03c0e821 move sp,s8
b8001028: 8fbe0004 lw s8,4(sp)
b800102c: 27bd0008 addiu sp,sp,8
b8001030: 03e00008 jr ra
b8001034: 00000000 nop

b8001038 <main>:
b8001038: 27bdffe0 addiu sp,sp,-32
b800103c: afbf001c sw ra,28(sp)
b8001040: afbe0018 sw s8,24(sp)
b8001044: 03a0f021 move s8,sp
b8001048: 2404000a li a0,10
b800104c: 2405000f li a1,15
b8001050: 0e000400 jal b8001000 <sum>
b8001054: 00000000 nop
b8001058: afc20010 sw v0,16(s8)
b800105c: 00001021 move v0,zero
b8001060: 03c0e821 move sp,s8
b8001064: 8fbf001c lw ra,28(sp)
b8001068: 8fbe0018 lw s8,24(sp)
b800106c: 27bd0020 addiu sp,sp,32
b8001070: 03e00008 jr ra
b8001074: 00000000 nop

b8001078 <_start>:
b8001078: 3c1cb800 lui gp,0xb800
b800107c: 279c10d0 addiu gp,gp,4304
b8001080: 3c1db800 lui sp,0xb800
b8001084: 27bd18d0 addiu sp,sp,6352
b8001088: 3c02b800 lui v0,0xb800
b800108c: 244210d0 addiu v0,v0,4304
b8001090: 3c04b800 lui a0,0xb800
b8001094: 248418e0 addiu a0,a0,6368
b8001098: 10440004 beq v0,a0,b80010ac <_start+0x34>
b800109c: 00000000 nop
b80010a0: ac400000 sw zero,0(v0)
b80010a4: 1444fffe bne v0,a0,b80010a0 <_start+0x28>
b80010a8: 24420004 addiu v0,v0,4
b80010ac: 3c02b800 lui v0,0xb800
b80010b0: 34421038 ori v0,v0,0x1038
b80010b4: 00400008 jr v0
b80010b8: 00000000 nop
b80010bc: 0001000d break 0x1
b80010c0: 00000000 nop
b80010c4: 1000ffff b b80010c4 <_start+0x4c>
b80010c8: 00000000 nop

Как видно, все функции уже находятся на конкретных адресах. В инструкциях перехода также проставлены все адреса. Не очень логичным на первый взгляд выглядит расположение стартового кода (отмечен символом _start) – после main(). Это вызвано лишь тем, что при вызове компоновщика в перечислении объектных файлов первым был файл main.o. Если в вызове компоновщика входные объектные файлы поменять местами startup.o main.o – стартовый код будет предшествовать функциям sum() и main(). Впрочем, в финальном ELF-файле все равно есть адрес точки входа, и он равен значению символа _start:

mipsel-elf32-readelf -h program.o
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: MIPS R3000
Version: 0x1
Entry point address: 0xb8001078
Start of program headers: 52 (bytes into file)
Start of section headers: 4528 (bytes into file)
Flags: 0x50001001, noreorder, o32, mips32
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 1
Size of section headers: 40 (bytes)
Number of section headers: 9
Section header string table index: 6

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

OUTPUT_ARCH("mips:isa32")
TARGET("elf32-littlemips")

Эти строки касаются типа архитектуры процессора, для которого собирается проект. Применительно к рассматриваемой теме ценности не имеют и рассматриваться не будут.

ENTRY(_start)

Эта строка говорит линковщику, что точка входа в программу – значение символа _start. Вместо символа _start может быть указан любой другой символ (лишь бы он был определен в коде или дальше в скрипте линковки), или конкретный адрес. Если эту строчку удалить вовсе – точкой входа будет некоторое значение по умолчанию.

SEARCH_DIR(.)

Эта строка указывает нам, что все файлы, подключаемые к программе, надо искать только в текущей директории.

SECTIONS {

Этой строкой мы говорим компоновщику, что начинается раздел, задающий непосредственно расположение секций.

. =0xB8001000;

Точка – это символ, обозначающий «текущее положение». После данной строки мы «остановились» на адресе 0xB800_1000 и все, что мы будем класть в память, будет ложиться, начиная с этого адрес.

.text :
{
* (.text);
* (.init)
* (.rel.sdata)
* (.fini)
}

Первыми, начиная с этого адреса, «ложатся» секции исполняемого кода (то есть, все секции с именами «.text», «.init», «.rel.sdata», «.fini», содержащиеся во всех входных объектных файлах).

.data :
{
* (.data);
}

Далее, вслед за секциями кода (то есть, по адресу 0xB800_1000 плюс длина исполняемого кода), располагаются статически проинициализированные данные в секции .data.

  _gp = ALIGN(16);
.lit4 : { *(.lit4) }
.sdata : { *(.sdata) }
.rodata : { *(rodata) }
. = ALIGN(8);
PROVIDE (edata = .);
_edata = .;

_fbss = .;
.sbss : { *(.sbss*) *(.scommon) *(*ABS*)}

.bss :
{
_bss_start = .;
*(.bss);
*(COMMON)
}

Приведенный участок скрипта содержит большое количество «маленьких» секций. В частности, объявляется символ _gp (его значение выровнено по границе 128-разрядного слова), после которого идут секции с «маленькими» переменными («small data» в английской терминологии). Это переменные, адресуемые по базовому адресу, содержащемуся в регистре $28 (gp) – это быстрее, чем адресоваться по полному адресу (одна инструкция для доступа к переменной против двух инструкций). Далее идут секции с именами *bss – это секции неинициализированных переменных. Там же определяется символ _bss_start, который нужен в стартовом коде.

  . += 0x0000800;
PROVIDE(__stack = ALIGN(8));
. += 0x10;


PROVIDE(end = .);
_end = .;
}

И, наконец, финальная часть скрипта компоновщика. Здесь отмеряется еще 0x800 байт (первая строка), далее объявляется символ __stack (со значением, выровненным по границе 64-разрядного слова), а далее объявляется символ _end и завершается линковочный скрипт в целом и раздел SECTIONS в частности.

Теперь, после рассмотрения скрипта компоновщика, можно переосмыслить текст стартового кода и понять, что перед переходом в функцию main() осуществляется цикл, обнуляющий область памяти, начиная от символа _bss_start и заканчивая символом _end. То есть, обнуляется вся секция неинициализированных переменных .bss и весь стек, начинающийся от конца секции .bss и занимающий в памяти (в данном случае) 0x800 байт.

Вызов компоновщика через GCC

Теперь, рассмотрев вызов компоновщика «вручную» и осознав принципы его работы, имеет смысл рассмотреть, что же будет, если пойти «стандартным» путем и попытаться собрать из main.c финальный объектный файл за один вызов GCC:

mipsel-elf32-gcc main.c -o program.o
c:/mcs3m_2014_07_15/tools4x/bin/../lib/gcc/mipsel-elf32/4.4.5/../../../../mipsel-elf32/lib/crt0.o: I
n function `zerobss':
(.text+0xdc): undefined reference to `__stack' c:/mcs3m_2014_07_15/tools4x/bin/../lib/gcc/mipsel-elf
32/4.4.5/../../../../mipsel-elf32/lib/crt0.o: In function `zerobss':
(.text+0xe0): undefined reference to `__stack'
c:/mcs3m_2014_07_15/tools4x/bin/../lib/gcc/mipsel-elf32/4.4.5/../../../../mipsel-elf32/lib/crt0.o: I
n function `init':
(.text+0x104): undefined reference to `atexit'
collect2: ld returned 1 exit status

Видно, что компоновщик сообщает об ошибках – есть два неопределенных символа, __stack и atexit. А упоминаются они в объектном файле crt0.o, о котором мы вроде бы ни слова не говорили. Это связано с тем, что GCC вызывает линковщик с рядом параметров по умолчанию, которые обычно не выводятся. Чтобы увидеть всю цепочку вызовов, осуществляемых GCC, запустим его с ключом «–v»:

mipsel-elf32-gcc –v main.c -o program.o

В результате мы увидим вызов препроцессора, компилятора, ассемблера и так далее.

Но в данный момент нас интересует вызов линковщика:

c:/mcs3m_2014_07_15/tools4x/bin/../libexec/gcc/mipsel-elf32/4.4.5/collect2.exe -EL -o program.o c:/m
cs3m_2014_07_15/tools4x/bin/../lib/gcc/mipsel-elf32/4.4.5/crti.o c:/mcs3m_2014_07_15/tools4x/bin/../
lib/gcc/mipsel-elf32/4.4.5/crtbegin.o c:/mcs3m_2014_07_15/tools4x/bin/../lib/gcc/mipsel-elf32/4.4.5/
../../../../mipsel-elf32/lib/crt0.o
-Lc:/mcs3m_2014_07_15/tools4x/bin/../lib/gcc/mipsel-elf32/4.4.5
-Lc:/mcs3m_2014_07_15/tools4x/bin/../lib/gcc -Lc:/mcs3m_2014_07_15/tools4x/bin/../lib/gcc/mipsel-el
f32/4.4.5/../../../../mipsel-elf32/lib C:\Users\BOCHK~1\AppData\Local\Temp\ccCLJxaE.o -lgcc -lgcc c
:/mcs3m_2014_07_15/tools4x/bin/../lib/gcc/mipsel-elf32/4.4.5/crtend.o c:/mcs3m_2014_07_15/tools4x/b
in/../lib/gcc/mipsel-elf32/4.4.5/crtn.o

Первое, что вызывает вопросы – почему вызывается какое-то collect2.exe, а не mipsel-elf32-ld.exe. Это особенности построения пакета BINUTILS, содержащего в себе все эти утилиты – ассемблер, компонвщик и так далее. Второй вопрос – подключаемые объектные файлы crtn.o, crtbegin.o, crtend.o, crt0.o, а также файл с именем ccCLJxaE.o, расположенный в какой-то временной директории. И третий вопрос – что обозначают ключи -lgcc.

Файл ccCLJxaE.o – это промежуточный объектный файл, созданный в результате компиляции файла main.c, поданного на вход. Файлы crt*.o – это промежуточные объектные файлы, уже содержащие в себе скомпилированный стартовый код, выполняющий примерно те же действия, что и наш файл startup.s. Ключ -l предписывает компоновщику подключить библиотеку с именем lib*.a. В частности, ключ -lgcc предписывает подключение библиотеки libgcc.a. А ключ -L добавляет компоновщику еще один путь, по которому необходимо производить поиск библиотек. Сама по себе библиотека (в данном случае – статическая библиотека) по своей сути представляет архив обычных промежуточных объектных файлов с функциями, скомпилированными в машинный код.

Все это реализовано в связи с тем, что, как правило, вместе с компилятором портируют и набор функций стандартной библиотеки Си, реализующей стартовый и финишный код, работу с динамическим выделением памяти, а также математические функции, работу со строками, ввод-вывод и прочее. Реализация стандартной библиотеки, как правило, зависит от того, в каком окружении будет работать результирующий исполняемый файл. В случае, если он будет исполняться в операционной системе (ОС), большинство функций стандартной библиотеки реализуются в составе ОС, и исполняемый файл умеет вызывать соответствующие функции ОС. Если исполняемый файл предназначен для работы с «голым железом» (английский термин «bare metal»), как правило, разработчик компилятора портирует одну из распространенных библиотек, реализующих в себе требуемый функционал. В частности, для процессоров серии «Мультикор» используется библиотека newlib.

Собственно, содержащиеся в приведенном выше вызове объектные файлы/библиотеки и являются частью стандартной библиотеки. Попытаемся же собрать эту же программу, но без использования нашего startup.s, а с использованием готовой стандартной библиотеки.

В первую очередь необходим скрипт линковщика – обязанность определять используемые адреса памяти никто не отменял. Скрипт у нас уже есть, так что используем его:

mipsel-elf32-gcc -T program.xl main.c -o program.o
c:/mcs3m_2014_07_15/tools4x/bin/../lib/gcc/mipsel-elf32/4.4.5/../../../../mipsel
-elf32/lib/crt0.o: In function `init':
(.text+0x104): undefined reference to `atexit'
collect2: ld returned 1 exit status

Видно, что ошибка, связанная с символом __stack, решена. Однако осталась проблема, связанная с символом atexit.

Поскольку мы знаем, что в данном случае используется библиотека newlib, воспользуемся поисковыми интернет-системами и найдем документацию по данной библиотеке, где и узнаем, что «atexit» - это одна из функций стандартной библиотеки языка Си, вызываемая после завершения main(). Исходя из этой информации, можно пойти двумя путями: поскольку мы пишем программу, выполняемую без ОС, наша функция main() никогда не будет завершена, а значит и функция atexit() никогда не будет вызвана. В силу этого, достаточно объявить символ atexit где-либо – в линковочном скрипте или в коде. Например, можно создать пустую функцию void atexit(){ } в файле main.c. - более правильный вариант с точки зрения перфекционизма. Поскольку эта функция все же реализована, правильнее ее подключить. Очевидно, что в подключенных объектных файлах и библиотеках ее нет. Однако не только файлом libgcc.a ограничивается набор объектных файлов, содержащих стандартную библиотеку. Есть еще libm.a – это реализация математических функций, а также libc.a – вот в нем и содержится реализация фунции atexit(). Чтобы подключить и эту библиотеку – надо вызвать GCC так:

mipsel-elf32-gcc -T program.xl main.c -lc -o program.o

Результатом данного действия будет файл program.o, дизассемблер которого здесь приводиться не будет в силу его чрезмерной объемности, но вкратце можно сказать, что достаточно малый его объем занимают функции main() и sum(), и достаточно много занимают используемые функции стандартной библиотеки.

Резюме

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

Цепочка сборки программ CPU-ядра в среде MCStudio 4

Во всех версиях среды разработки MCStudio для программ CPU-ядра реализована практически одна и та же цепочка сборки. Рассмотрим ее подробнее на примере среды разработки MCStudio 4.

Вызов препроцессора, компилятора или ассемблера

Рисунок 1. Настройки GCC как препроцессора, компилятора и ассемблера.

Строки, выделенные слева – это вызовы GCC как препроцессора, компилятора и ассемблера для файлов с расширениями *.c, *.s, *.cpp. Результатом этих вызовов является отдельный промежуточный объектный файл для каждого файла исходного кода. Рассмотрим по порядку ключи, с которыми вызывается GCC, они выделены справа:

-mips32 - целевая архитектура.

-EL - little-endian – «младший байт по младшему адресу».

-G0 - определяет ширину переменных, располагаемых в секции small data («маленькие переменные»). Если ширина переменной меньше цифры, заданной в данном ключе (задается в байтах) – переменная расположена в small data. По умолчанию задается ноль, то есть, секция small data не используется.

-g - опция, связанная с формированием отладочной информации.

-fno-delayed-branch - опция, запрещающая компилятору располагать что-либо, кроме инструкции NOP, в слотах задержки перехода (связано с особенностями реализации архитектуры MIPS32 в процессорах серии «Мультикор»).

-Wa,-O0 - на самом деле, -Wa - это префикс, предписывающий последующий ключ передать вызываемому ассемблеру. Сам же ключ -O0 обозначает нулевой уровень оптимизации кода.

-Wa,-mc24r2 - ключ, указывающий ассемблеру на необходимость генерировать код с учетом особенностей реализации MIPS32 в процессорах серии Мультикор (особенности всегда перечислены в руководстве пользователя на соответствующий процессор).

-c - ключ, требующий формировать промежуточные объектные файлы.

-I . - добавить к списку директорий include-файлов директорию с проектом.

-o %File.o - имя результирующего объектного файла совпадает с именем файла с исходным кодом.

%File.s/c/cpp - имя входного файла.

Вызов компоновщика, собирающий объектный файл для данного модуля.

Рассмотрим важные для нас ключи:

-r - указывает, что на выходе должен появиться перемещаемый (relocatable, промежуточный) объектный файл.

-T %Unit.xl - указывает соответствующий скрипт линковки (генерируется средой разработки автоматически, можно заменить на свой).

%Files.o - перечисление входных промежуточных объектных файлов.

-o %Unit.o - имя выходного объектного файла, совпадает с названием модуля.

То есть, на данном этапе формируется промежуточный объектный файл, соответствующий данному модулю (Unit) для CPU-ядра. Это еще не финальный объектный файл. Также генерируется дизассемблер этого объектного файла (вызов OBJDUMP). В отдельный пункт данное событие выделять не стоит.

Конфигурация без подключенных библиотек

Рисунок 2. Свойства финального линковщика при создании проекта без newlib.

Проект может собираться с подключенной библиотекой newlib или без нее. Сейчас рассматривается конфигурация без подключенных библиотек. Обратим внимание на выделенные строки. Слева выделен вызов компоновщика для финальной линковки. Здесь уже формируется исполняемый файл. Входными файлами являются объектные файлы модулей. Соответственно, именно здесь используемый скрипт линковки учитывает расположение секций, не объявляемых явно — .sdata, .bss и других. Выбрав General, можно увидеть опции (выделены справа), иллюстрирующие, что в проекте не используются стандартные стартовые файлы и библиотеки.

Можно также увидеть следующие ключи: -nostartfiles, -nodefaultlibs, -nostdlib, с которыми вызывается финальный линковщик, см. Рисунок 3.

Рисунок 3. Ключи вызова GCC для линковщика без использования newlib.

Конфигурация с библиотекой newlib

Отличий от предыдущего варианта здесь не так много. Компоновщик вызывается без ключей -nostartfiles, -nodefaultlibs, -nostdlib, выделенных на Рисунке 4, приведенном ниже.

Рисунок 4. Вызов линковщика проекта с использованием newlib.

Названия подключаемых библиотек можно увидеть, выделив вкладку Libraries:

Рисунок 5. Подключаемые библиотеки.

Сборка секций кода в разных адресах

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

Рассмотрим, как это реализуется в MCStudio 4 на примере обработчика прерываний интервального таймера (проект «MFBSP_LPORT_DMA_int» для процессора NVCom-02T из стандартных примеров в составе MCStudio 4).

В состав проекта входит три файла – StartUp.s, main.c и handler.s. Приведем здесь текст последнего:

.set noat
.text
Interrupt:
/* Сместить указатель стека на 31*4+24 байта вниз */
addiu $29,$29,-(31*4+24)
/* Сохранить в стеке регистры 1-28,30,31 */
sw $1,(0)($29)
sw $2,(4)($29)
sw $3,(8)($29)
sw $4,(12)($29)
sw $5,(16)($29)
sw $6,(20)($29)
sw $7,(24)($29)
sw $8,(28)($29)
sw $9,(32)($29)
sw $10,(36)($29)
sw $11,(40)($29)
sw $12,(44)($29)
sw $13,(48)($29)
sw $14,(52)($29)
sw $15,(56)($29)
sw $16,(60)($29)
sw $17,(64)($29)
sw $18,(68)($29)
sw $19,(72)($29)
sw $20,(76)($29)
sw $21,(80)($29)
sw $22,(84)($29)
sw $23,(88)($29)
sw $24,(92)($29)
sw $25,(96)($29)
sw $26,(100)($29)
sw $27,(104)($29)
sw $28,(108)($29)
sw $30,(112)($29)
sw $31,(116)($29)

/* Вызов функции непосредственно обработчика */

la $26, int_handler
jalr $26
nop

/* Восстановить регистры 1-28,30,31 из стека */
lw $1,(0)($29)
lw $2,(4)($29)
lw $3,(8)($29)
lw $4,(12)($29)
lw $5,(16)($29)
lw $6,(20)($29)
lw $7,(24)($29)
lw $8,(28)($29)
lw $9,(32)($29)
lw $10,(36)($29)
lw $11,(40)($29)
lw $12,(44)($29)
lw $13,(48)($29)
lw $14,(52)($29)
lw $15,(56)($29)
lw $16,(60)($29)
lw $17,(64)($29)
lw $18,(68)($29)
lw $19,(72)($29)
lw $20,(76)($29)
lw $21,(80)($29)
lw $22,(84)($29)
lw $23,(88)($29)
lw $24,(92)($29)
lw $25,(96)($29)
lw $26,(100)($29)
lw $27,(104)($29)
lw $28,(108)($29)
lw $30,(112)($29)
lw $31,(116)($29)
/* Восстановить указатель стека */
addiu $29,$29,31*4+24

/* Возврат из прерывания */
eret
nop

Видно, что здесь выполняется сохранение всех регистров CPU-ядра, переход по адресу int_handler, а также восстановление регистров и инструкция ERET (возврат из исключения), тогда как действий, связанных непосредственно с обработкой исключения здесь нет.

Сохранение регистров происходит потому, что на момент события исключения любой из регистров мог содержать какое-то важное для программы значение, и если в ходе обработки исключения значение в этом регистре будет изменено, то после возврата из обработчика может быть нарушено нормальное функционирование программы. Сохранение всех регистров – достаточно длительная операция, в принципе, можно реализовать обработчик исключения, используя только некоторые из регистров – тогда можно и сохранять/восстанавливать только их. Однако же, учитывая, что приведенный проект является лишь примером программы, было решено выбрать путь универсальности.

Функциональная же часть обработчика находится в файле main.c, в функции int_handler().

Рассмотрим настройки расположения секций проекта в MCStudio 4:

Рисунок 6. Расположение секций.

Видно, что секции всех файлов находятся в адресах 0xB800_1000. И только секция .text файла handler.s находится, начиная с адреса 0xB800_0180. Именно на этот адрес совершает переход процессор при возникновении исключения прерывания (с учетом тех настроек, что производятся в коде main.c). При этом, имя секции .text для файла handler.s имеет уникальное значение (поле Section Name). Это связано с тем, что компоновщик должен как-то отличать секции одинакового типа, расположенные по разным адресам. Рассмотрим файл Project.xl:

OUTPUT_ARCH("mips:isa32")
TARGET("elf32-littlemips")
ENTRY(_start)
PROVIDE(_mem_size = 0x100);
SECTIONS {
. =0xb8000180;
.handler :
{
* (.handler);
}
. =0xB8001000;
.text :
{
* (.text);
* (.init)
* (.rel.sdata)
* (.fini)
}
.data :
{
* (.data);
}
.DSPX_tmp :
{
* (.DSP0_tmp);
* (.DSP1_tmp);
* (.DSP2_tmp);
* (.DSP3_tmp);
}
_gp =ALIGN(4);

.lit4 :
{
*(.lit4)
}
.sdata :
{
*(.sdata)
}
.rodata :
{
*(rodata)
}
. = ALIGN(4);
PROVIDE (edata = .);
_edata = .;
_fbss = .;
.sbss :
{
*(.sbss)
*(.scommon)
*(*ABS*)
}
.bss :
{
_bss_start = .;
*(.bss);
*(COMMON)
}
. += 0x8000;
PROVIDE(__stack = ALIGN(4));
. += 0x10;
PROVIDE(_end = ALIGN(4));
end = .;
. =0xb8440000;
.DSP0_text :
{
* (.DSP0_text);
}
. =0xb8480000;
.DSP0_data :
{
* (.DSP0_data);
}
.DSP0_bss :
{
*(.DSP0_bss);
}
. =0xb8840000;
.DSP1_text :
{
* (.DSP1_text);
}
. =0xb8880000;
.DSP1_data :
{
* (.DSP1_data);
}
.DSP1_bss :
{
*(.DSP1_bss);
}

. =0xb8c40000;
.DSP2_text :
{
* (.DSP2_text);
}
. =0xb8c80000;
.DSP2_data :
{
* (.DSP2_data);
}
.DSP2_bss :
{
*(.DSP2_bss);
}
. =0xb9040000;
.DSP3_text :
{
* (.DSP3_text);
}
. =0xb9080000;
.DSP3_data :
{
* (.DSP3_data);
}
.DSP3_bss :
{
*(.DSP3_bss);
}

Здесь явно видно, что среда разработки создала секцию handler и расположила ее по адресу 0xB800_0180, то есть, по адресу обработчика, а все остальные секции расположены друг за другом с адреса 0xB800_1000.

Сборка для исполнения кода из флеш-памяти

Одна из конфигураций расположения секций исполняемой программы широко распространена при программировании микроконтроллеров, для которых характерен небольшой объем имеющейся в наличии памяти. В этой конфигурации исполняемый код и константы располагаются во флеш-памяти, а все переменные – в ОЗУ. Поскольку процессоры серии «Мультикор» предназначены, прежде всего, для задач, критичных по времени сполнения, такая конфигурация не является широко распространенной – флеш-память, как правило, имеет достаточно большое время доступа, что не позволяет исполнять код из нее достаточно быстро (тогда как для простых микроконтроллеров с тактовой частотой в районе 10-20 МГц время доступа к флеш-памяти является приемлемым и несущественно влияет на скорость исполнения программы).

Кроме того, реализация задач ЦОС подразумевает большой объем обрабатываемых данных, что так или иначе ставит разработчика перед необходимостью вводить в состав системы большое количество внешнего ОЗУ. В силу вышеперечисленных факторов, данная конфигурация в среде разработки MCStudio не реализована в качестве стандартной. Тем не менее, реализовать ее просто, обладая базовыми знаниями о том, как располагаются секции программы. Возможны два варианта:

  1. Упрощенный вариант. Если стартовый код реализован в исходнике – можно вместо символа __stack занести в регистр $29 любой адрес ОЗУ. Например, если занести адрес 0xB800_4000, под стек будет использованы адреса 0xB800_0000 - 0xB800_4000. При этом важно понимать, что на самом деле, при превышении объема переменных в стеке выше 0x4000 байт, программа начнет обращаться к адресам 0xB7FF_FFFC и дальше,но это будет некорректно в силу того, что эти адреса никуда не отображаются. Отслеживание соответствия размера стека реально используемому количеству переменных по-прежнему лежит на программисте.

  2. Более правильный метод – коррекция скрипта %Project.xl, например, так:

SECTIONS {

. =0xBFC00000; // адреса флеш-памяти
.text :
{
* (.text);
* (.init)
* (.rel.sdata)
* (.fini)
* (.reginfo)
}

.data :
{
* (.data);
}

_gp = ALIGN(16);
.lit4 : { *(.lit4) }
.sdata : { *(.sdata) }
.rodata : { *(.rodata) }
. = ALIGN(8);
PROVIDE (edata = .);
_edata = .;

_fbss = .;
.sbss : { *(.sbss*) *(.scommon) *(*ABS*)}
. =0xB8000000; // адреса CRAM
.bss :
{
_bss_start = .;
*(.bss);
*(COMMON)
}

. += 0x0000800;
PROVIDE(__stack = ALIGN(8));
. += 0x10;


PROVIDE(end = .);
_end = .;

}