Процедура сборки проекта
Данный документ описывает процедуру сборки проекта средствами 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;
#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.
Компоновщик
На этой стадии появляется ряд уточнений, которые надо озвучить явно:
- сама по себе программа MIPSEL-ELF32-GCC не является ни препроцессором, ни компилятором, ни ассемблером, ни компоновщиком. Ее задача – только вызывать упомянутые инструменты с некими заранее заданными параметрами. На предыдущих этапах важно было объяснить сам принцип работы препроцессора, компилятора и ассемблера, не вдаваясь в подробности, что именно вызывается после GCC, то здесь в подробности вдаваться придется. Поэтому сначала мы будем работать с компоновщиком MIPSEL-ELF32-LD;
- Как можно было заметить из дизассемблера, функции активно оперируют стеком. Это значит, что перед входом в функцию main() необходимо выполнить ряд подготовительных действий, в частности, опред елить, какая область памяти будет служить для хранения стека и занести адрес верхушки стека в регистр $29(sp). Если этого не сделать – после старта регистр $29 будет содержать произвольное значение, и первое же наше обращение (чтение или запись) приведет к непредсказуемым последствиям (поскольку неизвестно, что мы прочитаем по этому произвольному адресу, или по какому адресу мы произведем запись).
- Помимо установки стека, в предварительной инициализации принято обнулять неинициализированные переменные, объявленные в коде, а для процессоров 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 байт.