Ассемблер. Компоновщик. Загрузчик. Макрогенератор (1108377), страница 4
Текст из файла (страница 4)
Здесь OMi.OBJ - объектные файлы, которые надо объединять, а M.EXE - файл для размещения объединенной машинной программы.
2.1 Основные задачи компоновщика.
Начав работу, компоновщик считывает из внешней памяти указанные ОМ, объединяет их в единую машинную программу, которую записывает во внешнюю память, в указанный файл. Эту программу принято называть исполняемой или выполняемой программой, но чаще используется название загрузочный модуль (ЗМ), которым далее и будет пользоваться.
Более точно, задачи, которые должен решить компоновщик, следующие:
1) Объединение модулей. Компоновщик должен определить, в каком порядке в окончательной программе должны располагаться сегменты, входящие в модули, и должен в этом порядке собрать машинные коды (тела) этих сегментов, чтобы получить единый машинный код всей программы. (Замечание: компоновщик должен также осуществлять слияние некоторых сегментов из разных модулей в единые сегменты, однако для простоты эта функция компоновщика не рассматривается.)
2) Редактирование межмодульных связей. Ассемблер, транслируя модули программы по отдельности, не смог оттранслировать внешние имена в модулях, не смог заменить их на соответствующие адреса. Сделать такие замены, т.е. завершить трансляцию ссылок из одних модулей в другие, и призван компоновщик. Это основная задача компоновщика, поэтому его часто и называют редактором (межмодульных) связей.
3) Построение заголовка загрузочного модуля. Как мы увидим, компоновщик не может получить окончательный вид машинной программы, кое-что (имена сегментов) и ему не удается оттранслировать до конца. Поэтому на выходе он выдает не машинную программу, полностью готовую к счету, а только заготовку ее, которую затем надо будет еще дотранслировать. Эту заготовку и принято называть ЗМ.
ЗМ состоит из заголовка и тела. Тело - это машинный код программы, т.е. объединение машинных кодов модулей, в которых уже оттранслированы внешние имена, но кое-что так и не оттранслировано. В заголовок же включена информация, необходимая для того, чтобы позже можно было дотранслировать программу до конца и запустить ее на счет. В построении этого заголовка и заключается третья задача компоновщика.
Рассмотрим, как компоновщик решает каждую из указанных задач.
2.2 Объединение модулей.
Свою работу компоновщик начинает с того, что считывает в оперативную память заголовки всех ОМ, указанных в приказе LINK. Далее он определяет, в каком порядке будут располагаться в окончательной программе сегменты из ее модулей и фиксирует эту информацию в общей таблице сегментов (ОТС).
Эта таблица строится на основе таблиц сегментов (ТС) из модулей. Пусть, к примеру, в программе имеются два модуля М1 и М2 с такими ТС:
модуль М1: модуль М1:
сегмент нач.адрес размер класс сегмент нач.адрес размер класс
(в модуле) (в модуле)
------------------------------------ ------------------------------------
S1 0 1000 STACK Q1 0 22 DATA
S2 1000 8 DATA Q2 30 103 CODE
S3 1010 625 CODE
Тогда ОТС будет иметь следующий вид:
сегмент модуль начальный адрес размер класс
в модуле в пр-ме
----------------------------------------------------
S1 M1 0 0 1000 STACK
S2 M1 1000 1000 8 DATA
Q1 M2 0 1010 22 DATA
S3 M1 1010 1040 625 CODE
Q2 M2 30 1670 103 CODE
-------------------------------------------
длина программы: 1670 + 103 = 1773
В каждой строке ОТС собирается информация об одном сегменте, в ней указывается: имя сегмента, имя его модуля, его начальный адрес в модуле и в единой программе, его длина и класс. Вся эта информация, кроме адреса в программе, берется из ТС соответствующего модуля.
Первое, что должен сделать компоновщик, - это так расположить сегменты, чтобы сегменты одного класса оказались рядом. Решается эта задача следующим образом. Из заголовка ОМ, указанного в приказе LINK первым (у нас это M1), берется ТС и информация из нее переносится в ОТС (колонка "нач. адрес в программе" пока пуста). Затем берется ТС из ОМ, указанного в приказе LINK вторым (у нас - из M2), и последовательно рассматриваются перечисленные в ней сегменты. Первым идет сегмент Q1; смотрится, какого он класса и нет ли уже в ОТС сегмента того же класса. Есть, это S2. Значит, S2 и Q1 должны располагаться рядом. Поэтому строка с S3 сдвигается вниз, а после S2 записывается информация о Q1. Берется следующий сегмент Q2; он того же класса, что и сегмент S3; значит, S3 и Q2 должны быть расположены рядом. Однако в данном случае ничего в ОТС сдвигать не надо и информация о Q2 записывается в конец ОТС. Поскольку больше модулей и сегментов нет, то получившееся расположение сегментов является окончательным, именно так они и будут расположены в объединенной программе.
Итак, манипулируя строками из ТС разных модулей, компоновщик расположил сегменты этих модулей так, чтобы сегменты одного класса оказались рядом. При этом сами сегменты, т.е. их машинные коды, никак не переставляются, не переписываются с места на место (их вообще пока не в ОП), что было бы долго. Перестановки идут только на уровне строк таблиц сегментов, а это делается просто.
Далее компоновщик пересчитывает начальные адреса сегментов. Дело в том, что в ТС модулей указаны адреса сегментов относительно начала модулей, а теперь нужны адреса сегментов относительно начала всей программы. Такой пересчет делается просто. Первый сегмент S1, естественно, получает смещение 0. Поскольку он занимает 1000h байтов, то адрес первой свободной ячейки за ним равен 1000h. Это адрес кратен 16, поэтому с него можно начинать размещать следующий сегмент, поэтому этот адрес становится начальным адресом сегмента S2. Этот сегмент занимает 8 байтов, поэтому первый свободный адрес за ним - 1008h, но этот адрес не кратен 16 и с него нельзя начинать сегмент. Компоновщик берет ближайший адрес, кратный 16 (у нас это 1010h), и именно его делает начальным адресом следующего сегмента Q1. Аналогично по адресу и длине сегмента Q1 определяется начальный адрес для сегмента S3 (это 1040h), а по адресу и длине сегмента S3 определяется начальный адрес сегмента Q2 (это 1670h). Тем самым адреса всех сегментов относительно начала программы установлены.
Отметим попутно, что если сложить начальный адрес последнего сегмента (1670h) и длину этого сегмента (103h), то мы получим размер всей программы (1773h). Это число запоминается, оно еще пригодится.
Составив ОТС и тем самым определив, как внутри программы должны располагаться сегменты, компоновщик далее считывает с диска машинные коды (тела) ОМ в оперативную память и размещает их сегменты согласно указанному в ОТС порядку. Делается это так. В какое-то свободное место ОП считываются весь машинный код модуля M1, а затем сегменты этого модуля переносятся в соответствующие места той части ОП, где формируется машинный код всей программы (сколько переписывать, откуда и куда - все это определяется по ОТС). В нашем случае первые 1000h байтов из M1 переписываются в программу по адресу 0, затем 8 байтов из M1 начиная с его адреса 1000h переписываются в программу по адресу 1000h и, наконец, переписываются 625h байтов из M1 начиная с его адреса 1010h переписываются в программу по адресу 1040h. Затем аналогично поступают с сегментами из модуля M2:
M1 M M2
0 ┌────┐ 0 ┌────┐ 0 ┌────┐
│ S1 │ ────> │ S1 │ ┌────── │ Q1 │
1000 │────│ 1000 │────│ │ 30 │────│
│ S2 │ ────> │ S2 │ │ ┌──── │ Q2 │
1010 │────│ 1010 │────│ │ │ └────┘
│ S3 │ ─┐ │ Q1 │ <─┘ │
└────┘ │ 1040 │────│ │
└──> │ S3 │ │
1670 │────│ │
│ Q2 │ <───┘
└────┘
Собранные таким образом машинные коды сегментов и образуют машинный код единой программы. На этом этап объединения модулей завершен. После некоторой корректировки этот код станет телом ЗМ.
2.3 Редактирование межмодульных связей
Такая корректировка заключается в замене внешних имен, использовавшихся в модулях, на соответствующие адреса. Делается это так.
Напомним, что в заголовке каждого ОМ есть таблица общих имен (ТОБ), в которой для каждого общего имени данного модуля указано само имя и его адрес внутри модуля. Компоновщик выделяет из заголовков эти таблицы и объединяет их в общую таблицу общих имен (ОТОБ). Например, если в программе имеется два модуля М1 и М2 с такими ТОБ:
модуль М1: модуль М1:
общее имя адрес общее имя адрес
------------------ ------------------
B S2:2 X Q1:20
P Q2:0
тогда ОТОБ будет выглядеть так:
Общая ТОБ: общее имя адрес
---------------------
B S2:2
X Q1:20
P Q2:0
Теперь вспомним, что общее имя одного модуля - это внешнее имя другого модуля. Значит, ОТОБ - это одновременно и таблица всех внешних имен с указанием их адресов. Поэтому компоновщик теперь может сделать то, что в свое время не удалось сделать ассемблеру, - заменить все внешние ссылки во всех модулях на соответствующие им адреса.
Для этого компоновщик использует таблицы вхождений внешних имен (ТВН) из объектных модулей. Напомним, что в такой таблице указаны сведения о каждом вхождении в модуль каждого внешнего имени, а именно: само имя, адрес ячейки, в которую надо записать адрес имени, и то, какую часть адреса имени надо использовать. Компоновщик проходится по всем этим таблицам и делает замены.
Пусть, к примеру, в модуле М1 была такая ТВН:
внеш.имя адрес вхождения тип вхождения
------------------------------------------
X S2:0 ofs
X S2:2 seg
P S2:6 segofs
Первая ее строка указывает, что смещение имени X надо записать в ячейку с адресом S2:0, т.е. в 0-ю ячейку сегмента S2. Смещение имени X компоновщик узнает по ОТОБ, оно равно 20h, а начальный адрес сегмента узнает по ОТС, он равен 1000h, поэтому число 20h он заносит в ячейку с адресом 1000h+0=1000h, отсчитанным от начала программы.
Следующая строка таблицы указывает, что в ячейку с адресом S2:2 надо записать номер сегмента (начальный адрес без последнего 0), в котором находится ячейка с именем X. По ОТОБ компоновщик узнает, что сегментом имени X является Q1. Однако компоновщик не знает настоящий начальный адрес этого сегмента: он знает только его адрес относительно начала программы, а настоящий же адрес зависит от того, с какого места памяти будет расположена вся программа при счете, а это пока неизвестно. Что делать компоновщику? Напомним, что с такой же проблемой сталкивается и ассемблер. Как поступает ассемблер? Он ничего не записывает в соответствующую ячейку, но в таблице перемещаемых адресов (ТПА) запоминает, что затем в эту ячейку надо будет записать номер этого сегмента. Аналогично поступает и компоновщик: он строит свою (новую) ТПА, где запоминает, в какие ячейки он должен был бы записать номера каких сегментов, но не смог этого сделать. У нас в этой ТПА появится первая из следующих строк:
имя сегмента адрес вхождения
------------------------------
Q1 S2:2
Q2 S2:8
...
Третья строка ТВН из модуля M1 указывает, что в ячейку S2:6 надо записать и смещение, и номер сегмента имени P, т.е. здесь объединены два уже рассмотренных нами случая. Узнав по ОТОБ, что смещение имени P равно 0, компоновщик записывает 0 в ячейку S2:6. Из ОТОБ компоновщик узнает, что имя P - из сегмента Q2, однако записать номер этого сегмента во вторую половину данной ячейки (в S2:8) не может, поэтому он добавляет в свою ТПА новый элемент - вторую из указанных выше строк.
Далее компоновщик просматривает ТВН из следующих модулей и поступает с ними аналогично.
На этом заканчивается замена внешних имен на их адреса, т.е. редактирование межмодульных связей. Полученный таким образом машинный код и является телом загрузочного модуля. Ничего более в нем компоновщик не будет менять.
2.4 Построение заголовка загрузочного модуля.
Но на этом работа компоновщика не заканчивается, он еще должен построить заголовок ЗМ, включив в него информацию, по которой затем можно будет дотранслировать программу до конца и запустить ее на счет.
В упрощенном виде заголовок ЗМ состоит из следующих разделов: 1) длина программы; 2) точка входа; 3) начало и длина сегмента стека; 4) таблица перемещаемых адресов.
Прежде чем рассмотреть, как компоновщик заполняет эти разделы, отметим следующее. До сих пор адреса каких-то мест в ЗМ были представлены в условной форме - с указанием имен сегментов (типа S2:8). Однако в дальнейшем имена сегментов никому не нужны, а нужны только адреса сегментов, поэтому компоновщик должен заменить имя сегмента (S2) на его начальный адрес. Но этот адрес компоновщик не знает, т.к. он зависит от того, с какого места в памяти будет размещена программа во время счета, а это станет известным только позже. Что делать?
Отметим, что абсолютный, адрес (Aабс) любой точки программы можно представить в виде суммы Aабс=Aнач+Aотн, где Aнач - начальный адрес программы, а Aотн - относительный адрес этой точки, т.е. адрес, отсчитанный от начала программы:
0 ┌─────┐
│ │
Aнач │─────│ ┐ <-- начало программы