ОСНОВНАЯ ИДЕЯ АССЕМБЛИРОВАНИЯ. ДВА ПРОХОДА.
Сначала будем предполагать, что транслируемая программа состоит только из одного модуля, т.е. в ней нет внешних и общих имен, а позже (в 1.5) рассмотрим, какие изменения надо внести в работу ассемблера, чтобы учесть особенности многомодульных программ. Отметим также, что ассемблер работает после макрогенератора, поэтому в программе, которая подается на вход ассемблеру, нет никаких макрокоманд и других директив макроязыка.
ТАБЛИЦЫ АССЕМБЛЕРА.
В своей работе ассемблер использует несколько таблиц. Часть из них создается заранее (одновременно с ассемблером), т.е. они попросту встроены в ассемблер, а другие ассемблер строит в процессе трансляции конкретной программы.
К заранее составленным таблицам относятся таблица директив и таблица мнемокодов.
ПЕРВЫЙ ПРОХОД АССЕМБЛЕРА.
Основные действия ассемблера на 1-м проходе.
Цель 1-го прохода - выявить в программе все имена и собрать информацию о них; эта информация записывается в таблицу имен (ТИ) и таблицу сегментов (ТС), которые будут нужны на 2-м проходе. Вначале эти таблицы, а также таблица распределения сегментных регистров (ТРСР) пусты, а затем они заполняются по мере просмотра текста программы.
Примеры обработки директив и команд на 1-м этапе.
Директива EQU:
K EQU 3
Это директива определения константы, которая "говорит", что в программе имя K будет обозначать число 3. Эти сведения ассемблер записывает в ТИ: имя - K, тип - number, значение - 3, сегмент - пусто (не играет роли).
Директива SEGMENT: S2 SEGMENT 'DATA'
С этой директивы начинается программный сегмент. Ассемблер записывается в ТС имя сегмента (S2), его начальный адрес, отсчитанный от начала программы (при необходимости ассемблер выравнивает этот адрес до ближайшего адреса, кратного 16), и имя класса (DATA), к которому отнесен сегмент. Ассемблер также обнуляет счетчик размещения (СР), т.к. отсчет смещений начинается заново, и запоминает, что начался сегмент S1.
Директивы DB, DW, DD: X DW Y
Y DB 3 DUP(0)
По первой из этих директив ассемблер заносит в ТИ информацию об имени X: имя - X, тип - word, значение - текущая величина СР, сегмент - имя текущего сегмента (к примеру, S2), после чего СР увеличивается на 2. По второй директиве в ТИ заносится информация об имени Y: имя - Y, тип - byte, значение - текущая величина СР, сегмент - S2, после чего СР увеличивается на 3. Отметим, что на 1-м походе в эти два и три байта ничего (ни адрес Y, ни 0) не записывается, это будет сделано на 2-м проходе; сейчас важно лишь знать, сколько места будет отведено под эти переменные, на сколько надо увеличивать значение СР.
Директива PROC: P PROG FAR
Начинается описание процедуры. В ТИ заносится следующая информация об имени P: тип - far, значение - текущая величина СР, сегмент - имя текущего сегмента. Поскольку эта директива носит чисто информационный характер и по ней ничего не записывается в машинную программу, то СР не меняется.
Директива
ВТОРОЙ ПРОХОД АССЕМБЛЕРА.
Теперь рассмотрим действия ассемблера на 2-м проходе. К этому моменту в ТИ и ТС уже собрана вся информация об именах программы. Ассемблер заново просматривает строчка за строчкой текст программы и, используя информацию из ТИ, уже переводит программу с ЯА на машинный язык.
Формируемые машинные команды ассемблер записывает в последовательные ячейки памяти начиная с некоторого адреса, кратного 16. Какой это конкретно адрес - не важно. Дело в том, что машинная программа, сформированная ассемблером, не будет тут же выполняться, а будет лишь записана во внешнюю память, поэтому ее можно формировать в любом месте памяти. Учитывая это, мы будем указывать адрес первой свободной ячейки, отсчитанный от начала этого места, и будем обозначать этот адрес как АДР. В начале АДР=0.
Примеры обработки директив и команд на 2-м проходе.
Директивы EQU и PROG игнорируются, т.к. вся информация из них уже извлечена на 1-м проходе.
Директива SE
Основные задачи компоновщика.
Начав работу, компоновщик считывает из внешней памяти указанные ОМ, объединяет их в единую машинную программу, которую записывает во внешнюю память, в указанный файл. Эту программу принято называть исполняемой или выполняемой программой, но чаще используется название загрузочный модуль (ЗМ), которым далее и будет пользоваться.
Более точно, задачи, которые должен решить компоновщик, следующие:
1) Объединение модулей. Компоновщик должен определить, в каком порядке в окончательной программе должны располагаться сегменты, входящие в модули, и должен в этом порядке собрать машинные коды (тела) этих сегментов, чтобы получить единый машинный код всей программы. (Замечание: компоновщик должен также осуществлять слияние некоторых сегментов из разных модулей в единые сегменты, однако для простоты эта функция компоновщика не рассматривается.)
2) Редактирование межмодульных связей. Ассемблер, транслируя модули программы по отдельности, не смог оттранслировать внешние имена в модулях, не смог заменить их на соответствующие адреса. Сделать такие замены, т.е. завершить трансляцию ссылок из одних модулей в другие, и призван компоновщик. Это основная задача компоновщика, поэтому его часто и называют редактором (межмодульных) связей.
3) Построение заголовка загрузочного модуля. Как мы увидим, компоновщик не может получить окончательный вид машинной программы, кое-что (имена сегментов) и ему не удается оттранслировать до конца. Поэтому на выходе он выдает не машинную программу, полностью готовую к счету, а только заготовку ее, которую затем надо будет еще дотранслировать. Эту заготовку и принято называть ЗМ.
ЗМ состоит из заголовка и тела. Тело - это машинный код программы, т.е. объединение машинных кодов модулей, в которых уже оттранслированы внешние имена, но кое-что так и не оттранслировано. В заголовок же включена информация, необходимая для того, чтобы позже можно было дотранслировать программу до конца и запустить ее на счет. В построении этого заголовка и заключается третья задача компоновщика.
Рассмотрим, как компоновщик решает каждую из указанных задач.
Объединение модулей.
Свою работу компоновщик начинает с того, что считывает в оперативную память заголовки всех ОМ, указанных в приказе 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 ¦----¦ ¦ ¦ L-----
¦ S3 ¦ -¬ ¦ Q1 ¦ <-- ¦
L----- ¦ 1040 ¦----¦ ¦
L--> ¦ S3 ¦ ¦
1670 ¦----¦ ¦
¦ Q2 ¦ <----
L-----
Собранные таким образом машинные коды сегментов и образуют машинный код единой программы. На этом этап объединения модулей завершен. После некоторой корректировки этот код станет телом ЗМ.
Обработка макрокоманд.
Теперь рассмотрим действия макрогенератора, когда он встречает макрокоманду (МК).
Пусть, к примеру, в тексте программы есть такой фрагмент:
исходный текст окончательный текст
| ... ...
28 | M Q,50,[BX] MOV AX,Q
29 | JMP LAB ??0000: ADD AX,50
30 | ... MOV [BX],AX
JMP LAB
...
Пусть стек сейчас пуст, а счетчики макрогенератора имеют такие значения: НОМ=0000, УР=0, НС=28 (рис. а).
¦-------------¦
¦ L | ??0000 ¦
¦-------------¦
¦ W | 50 ¦
¦-------------¦
¦ OP | ADD ¦
¦-------------¦
¦ ¦ ¦ 16 ¦ ¦ ¦
¦=============¦ ¦=============¦ ¦=============¦
¦ Z | [BX] ¦ ¦ Z | [BX] ¦ ¦ Z | [BX] ¦
¦-------------¦ ¦------------ ¦ ¦-------------¦
¦ Y | 50 ¦ ¦ Y | 50 ¦ ¦ Y | 50 ¦
¦-------------¦ ¦-------------¦ ¦-------------¦
¦ X | Q ¦ ¦ X | Q ¦ ¦ X | Q ¦
¦-------------¦ ¦-------------¦ ¦-------------¦
¦ ¦ ¦ 28 ¦ ¦ 28 ¦ ¦ 28 ¦
¦=============¦ ¦=============¦ ¦=============¦ ¦=============¦
¦/////////////¦ ¦/////////////¦ ¦/////////////¦ ¦/////////////¦
НОМ=0000,УР=0 НОМ=0000,УР=1 НОМ=0001,УР=2 НОМ=0001,УР=1
НС=28 НС=15 НС=12 НС=16
а) б) в) г)
Итак, НС=28. В 28- й строке находится МК. Как макрогенератор узнает о том, что это МК? Выделив из строки мнемокод, макрогенератор "лезет" в ТМ и смотрит, нет ли там такого имени. Если есть, значит это МК, нет - нечто иное (например, директива макроязыка, что распознается по таблице директив этого языка, или обычное предложение ЯА). В нашем случае имя М имеется в ТМ, поэтому макрогенератор и узнает, что перед ним МК. С нею макрогенератор поступает так (рис. б).
Во-первых, макрогенератор записывает в стек текущее значение НС (у нас - 28), т.е. номер строки с МК. Это как бы адрес возврата: сейчас макрогенератор перейдет к обработке строк из МО, но затем ему надо будет вернуться к МК; чтобы можно было осуществить такой возврат, номер строки с МК и запоминается в стеке. Во-вторых, используя данные из соответствующей строки ТМ, макрогенератор записывает в стек названия формальных параметров макроса (у нас - X, Y и Z), а рядом с ними записывает фактические параметры, взятые из МК (у нас - Q, 50 и [BX]). Тем самым создана таблица соответствия между формальными и фактическими параметрами, которая будет сейчас использоваться при макроподстановке (МП). В-третьих, макрогенератор увеличивает счетчик УР
на 1 (УР=1); это означает, что макрогенератор "входит" внутрь МО. В-четвертых, макрогенератор присваивает счетчику НС взятый из ТМ номер первой строки тела макроса (у нас НС=15); это значит, что макрогенератор переходит к обработке тела макроопределения.
Итак, НС=15. Обработку любого предложения программы макрогенератор всегда начинает с проверки, где он сейчас находится - вне или внутри МО. Делается это просто, сравнением УР с 0: если УР=0, то - вне МО, а иначе внутри МО. Зачем это надо знать? А затем, что, находясь внутри МО, макрогенератор всегда начинает обработку предложения с замены в нем всех вхождений формальных параметров на соответствующие фактические параметры. (При этом он не портит текст программы, а строит копию данного предложения.) Делается это так: макрогенератор выделяет в предложении очередное имя и смотрит, есть ли оно среди формальных параметров в таблице соответствия (из стека). Если нет (как в нашем случае для имен MOV и AX), тогда макрогенератор ничего не делает, а если есть (как для X), тогда заменяет это имя на соответствующий фактический параметр:
MOV AX,X --> MOV AX,Q
После такой замены макрогенератор смотрит, что получилось - обычное предложение ЯА или конструкция макроязыка. Для этого он выделяет мнемокод (у нас - MOV) и смотрит, нет ли такого имени в ТМ. Если есть, тогда это МК. Если же нет, тогда макрогенератор просматривает таблицу директив макроязыка и т.д. У нас - обычное предложение ЯА, поэтому макрогенератор передает его на обработку ассемблеру, другими словами, заносит в окончательный текст программы [записать справа от программы]. Когда ассемблер закончит обработку предложения, он возвратит управление макрогенератору, который увеличивает НС на 1 и идет дальше.
Теперь НС=16. Поскольку УР?0, то макрогенератор прежде всего делает замену формальных параметров на фактические:
M1 ADD,Y --> M1 ADD,50
Далее макрогенератор выделяет мнемокод и по ТМ узнает, что это имя макрокоманды. Следовательно, очередное предложение - МК. Действия макрогенератора такие же, как и при обработке МК из 28-й строки (рис. в). В стек записывается текущее значение НС (16) и формальные параметры макроса M1 (OP, W) вместе с фактическими параметрами (ADD, 50). Кроме того, в макросе M1 имеется локальное имя (L), которое макрогенератор также записывает в стек, причем ему в соответствие ставится специмя с текущим значением НОМ
(у нас это ??0000), после чего значение НОМ увеличивается на 1 (НОМ=0001); на это специмя и будут заменяться все вхождения L в тело макроса. Далее УР увеличивается на 1 (УР=2), т.к. макрогенератор "входит" в новое МО, и в НС
записывается начало тела макроса M1
(12), взятое из ТМ. Начинается обработка тела макроса M1.
Итак, НС=12. Поскольку УР?0, то макрогенератор прежде всего делает в предложении из 12-й строки замену формальных параметров на фактические параметры и замену локальных имен на специмена:
L: OP AX,W --> ??0000: ADD AX,50
Далее макрогенератор устанавливает, что получилось обычное предложение ЯА, поэтому передает его на обработку ассемблеру; другими словами, это предложение попадет в окончательный текст программы [записать]. После возврата ассемблером управления макрогенератору последний увеличивает НС на 1 и переходит к следующему предложению программы.
Теперь НС=13. Это директива ENDM, признак конца МО. Действия макрогенератора в этом случае такие (рис. г). Во-первых, он очищает стек от всего того, что было записано сюда, когда началась обработка последней МК, и при этом восстанавливает в НС то значение, которое хранилось в стеке (у нас НС=16); это как раз номер строки с той МК, из-за которой макрогенератор попал в МО. Во-вторых, счетчик УР уменьшается на 1 (УР=1), чем фиксируется выход из МО. На этом обработка тела макроса M1
полностью закончена и соответствующее макрорасширение получено. Макрогенератор увеличивает НС на 1 (НС=17) и переходит к предложению, следующему за МК.
Итак, НС=17. Поскольку снова УР?0, то выполняется замена формальных параметров на фактические, но уже, естественно, используется табличка соответствия, которая сейчас находится в верху стека:
MOV Z,AX --> MOV [BX],AX
Поскольку это обычное предложение ЯА, то макрогенератор его не обрабатывает, а передает ассемблеру, т.е. заносит в окончательный текст программы [записать], после чего увеличивает НС на 1.
Теперь НС=18. Снова директива ENDM, завершающая МО. Действия макрогенератора мы уже знаем: стек очищается от информации, попавшей сюда при появлении МК из строки 28, и в НС восстанавливается значение, хранившееся в стеке (28), т.е. номер МК, из-за которой макрогенератор попал в МО, далее УР уменьшается на 1 (УР=0). На этом наконец-то закончилась обработка МК из 28-й строки.
Макрогенератор увеличивает НС на 1 и идет дальше.
Вот так макрогенератор "расправляется" с МК.
В заключение хочу сделать пару замечаний. Во-первых, если внутри МО макрогенератор встречает директиву EXITM, то он выполняет такие же действия, что и при появлении директивы ENDM, т.е. покидает МО. Во-вторых, хочу обратить ваше внимание на то, что макрогенератор при обработке макрокоманд вовсю использует стек. Это очень важно, т.к. одни макросы могут обращаться к другим и, более того, допускаются рекурсивные макросы, поэтому и приходится спасать информацию о каждой МК в новом месте, а для этого и нужен стек.
Редактирование межмодульных связей
Такая корректировка заключается в замене внешних имен, использовавшихся в модулях, на соответствующие адреса. Делается это так.
Напомним, что в заголовке каждого ОМ есть таблица общих имен (ТОБ), в которой для каждого общего имени данного модуля указано само имя и его адрес внутри модуля. Компоновщик выделяет из заголовков эти таблицы и объединяет их в общую таблицу общих имен (ОТОБ). Например, если в программе имеется два модуля М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) не может, поэтому он добавляет в свою ТПА новый элемент - вторую из указанных выше строк.
Далее компоновщик просматривает ТВН из следующих модулей и поступает с ними аналогично.
На этом заканчивается замена внешних имен на их адреса, т.е. редактирование межмодульных связей. Полученный таким образом машинный код и является телом загрузочного модуля. Ничего более в нем компоновщик не будет менять.
Построение заголовка загрузочного модуля.
Но на этом работа компоновщика не заканчивается, он еще должен построить заголовок ЗМ, включив в него информацию, по которой затем можно будет дотранслировать программу до конца и запустить ее на счет.
В упрощенном виде заголовок ЗМ состоит из следующих разделов:
1) длина программы; 2) точка входа; 3) начало и длина сегмента стека; 4) таблица перемещаемых адресов.
Прежде чем рассмотреть, как компоновщик заполняет эти разделы, отметим следующее. До сих пор адреса каких-то мест в ЗМ были представлены в условной форме - с указанием имен сегментов (типа S2:8). Однако в дальнейшем имена сегментов никому не нужны, а нужны только адреса сегментов, поэтому компоновщик должен заменить имя сегмента (S2) на его начальный адрес. Но этот адрес компоновщик не знает, т.к. он зависит от того, с какого места в памяти будет размещена программа во время счета, а это станет известным только позже. Что делать?
Отметим, что абсолютный, адрес (Aабс) любой точки программы можно представить в виде суммы Aабс=Aнач+Aотн, где Aнач - начальный адрес программы, а Aотн - относительный адрес этой точки, т.е. адрес, отсчитанный от начала программы:
0 ------¬
¦ ¦
Aнач ¦-----¦ ¬ <-- начало программы
¦/////¦ ¦ Aотн
Aабс ¦=====¦ -
¦/////¦
Причем компоновщик знает относительный адрес сегмента Aотн
(он указан в ОТС) и не знает начальный адрес программы Aнач. Учитывая это, он поступает так: он запоминает только относительный адрес сегмента, чтобы позже, когда станет известным начальный адрес программы, к нему можно было прибавить этот адрес и получить уже настоящий, абсолютный адрес сегмента. Таким образом, все условные адреса компоновщик заменяет на пары Аотн:ofs. Позже ко всем этим относительным адресам будет добавлен начальный адрес программы для получения абсолютных адресов сегментов.
Длина программы.
Эта длина, т.е. число байтов, занимаемых машинным кодом программы, уже была определена компоновщиком при построении ОТС. Она и переносится в заголовок ЗМ. По этой длине затем будет определяться, хватит ли программе места в памяти.
Точка входа.
Это адрес команды, с которой надо начинать выполнение программы. Данный адрес берется из заголовка того ОМ, в котором он указан, и переносится в заголовок ЗМ. (Замечание: если точки входа указаны в нескольких модулях, то учитывается первая их них, а если точка входа вообще не указана, то фиксируется ошибка.)
Начало и длина сегмента стека.
В одном из ОМ программы указывается имя сегмента стека. (Замечание: если стеки указаны в нескольких модулях, то учитывается первый их них, а если стек вообще не указан, то выдается предупреждение.) Компоновщик заменяет имя этого сегмента на его относительный адрес, который он узнает из ОТС. Из этой же таблицы он узнает и длину сегмента, которая также записывает в заголовок ЗМ.
Таблица перемещаемых адресов.
Напомним, что программа пока не оттранслирована до конца - в некоторые ее ячейки еще надо будет записать начальные адреса сегментов программы (без последнего 0). Поскольку эти адреса зависят от места размещения программы в памяти во время ее счета, а это место пока неизвестно, то ассемблер и компоновщик так и не смогли заменить имена сегментов на их адреса. Вместо этого они в своих таблицах перемещаемых адресов (ТПА) запомнили те ячейки, куда затем надо будет записать адреса сегментов. Эти таблицы имеются в каждом ОМ (их составил ассемблер), и есть еще одна таблица, которую составил сам компоновщик при редактировании межмодульных связей. Естественно, компоновщик должен сохранить сведения обо всех таких ячейках, для чего он объединяет все эти таблицы в одну, заменив в них условные адреса сегментов на их относительные адреса. В таком виде таблица и заносится в заголовок ЗМ.
На этом составление заголовка ЗМ закончено. Компоновщик записывает весь ЗМ (заголовок и тело) во внешнюю память (например, в файл M.EXE) и на этом завершает свою работу.
Замечание. Как видно, главная задача компоновщика - объединить машинные коды нескольких ОМ в одну машинную программу и оттранслировать ссылки из одних модулей в другие. Ясно, что если программа состоит из одного модуля, то эти действия не нужны. Но чтобы не было двух схем трансляции (одной для многомодульных программ и другой для одномодульных), одномодульные программы также заставляют "проходить" через компоновщик. В этом случае компоновщик фактически делает только одно - преобразует заголовок единственного модуля из одного формата в другой, тело же модуля при этом не меняется.
КОМПОНОВЩИК
Когда все модули программы будут оттранслированы ассемблером и преобразованы в объектные модули (ОМ), их надо объединить в единую машинную программу. Таким объединением занимается специальная программа, которую называют компоновщиком (другие названия - редактор связей, сборщик модулей, линкер (linker)) и которая вызывается следующим приказом для ОС:
LINK OM1.OBJ+...OMk.OBJ, M.EXE;
Здесь OMi.OBJ - объектные файлы, которые надо объединять, а M.EXE - файл для размещения объединенной машинной программы.
ОБРАБОТКА БЛОКОВ ПОВТОРЕНИЯ.
Объяснять действия макрогенератора при обработке блоков повторения я буду на следующем примере:
исходный текст окончательный текст
-------------- -------------------
| ... ...
100 | REPT 3 DW ?
101 | DW ? DB 1
102 | IRP X,<1,2> DB 2
103 | DB X DW ?
104 | ENDM DB 1
105 | ENDM ...
106 | ...
Сразу отмечу, что из-за возможной вложенности одних блоков повторения в другие информация о каждом из них не должна мешать информации о другом, поэтому макрогенератор хранит информацию о каждом блоке повторения в стеке. Как это делается, мы сейчас и рассмотрим.
Пусть НС=100. Анализируя мнемокод из 100-й строки, макрогенератор узнает, что это директива макроязыка, поэтому именно он должен заняться ее обработкой. Эта директива начинает блок повторения типа REPT, по которому в окончательный текст программы должно быть записано 3 копии тела этого блока. Что надо знать макрогенератору при этом копировании? Две вещи: с какой строки начинается тело блока (конец определяется по директиве ENDM) и сколько копий тела осталось еще сделать. С записи этой информации в стек макрогенератор и начинает обработку блока (рис. а): номер первой строки тела определяется как текущее значение НС плюс 1 (у нас это 101), а счетчик оставшихся копий вначале совпадает со значением выражения из директивы REPT (у нас это 3).
Дальнейшие действия макрогенератора идут в следующем цикле: если хранимый в стеке счетчик копий равен 0, тогда обработка блока заканчивается, иначе этот счетчик уменьшается на 1, НС
устанавливается по значению из стека на 1-ю строку тела и затем начинается просмотр тела блока. Когда макрогенератор дойдет до директивы ENDM, все эти действия повторяются заново (счетчик копий уменьшается на 1, НС снова устанавливается на начало тела и т.д.). Этот цикл прекращается, когда при очередном достижении конца тела будет обнаружено нулевое значение у счетчика копий. Тогда макрогенератор проходит по тексту программы дальше.
ЗАГРУЗЧИК.
Итак, компоновщик построил ЗМ и записал его в файл M.EXE. Чтобы выполнить его, нужно дать приказ ОС, состоящий из названия этого файла:
M.EXE или M
Этот приказ ОС трактует как внешний, т.е. ищет на диске файл указанным именем (расширение EXE подразумевается по умолчанию), считывает его в ОП и передает на него управление. Но поскольку, как мы видим, в этом файле находится машинная программа, которая не до конца оттранслирована, то взять и просто считать эту программу в ОП и передать ей управление нельзя. Эту программу еще надо довести "до кондиции". Это замечание справедливо для всех программ, хранящихся в файлах с расширением EXE. Поэтому, если в приказе операционной системе указан файл с расширением EXE, то она вызывает специальную программу, называемую загрузчиком, и передает ей управление, а уже этот загрузчик считывает нашу программу из внешней памяти, доводит ее трансляцию до конца и запускает ее на счет.
Основные задачи загрузчика.
Загрузчик решает следующие основные задачи:
1. Загрузка программы. Загрузчик должен найти место в оперативной памяти для программы и переписать ее сюда с диска.
2. Настройка программы на место (привязка к месту). Загрузчик обязан закончить трансляцию программы в тех ее точках, что зависят от местоположения программы в памяти.
3. Запуск программы на счет. Загрузчик должен записать в определенные регистры соответствующие значения и передать управление на программу.
Рассмотрим, как загрузчик решает эти задачи.
МАКРОГЕНЕРАТОР.
Возможны два варианта взаимодействия макрогенератора (МГ) с ассемблером.
В первом варианте МГ работает до ассемблера и полностью независим от него: МГ вводит текст программы на макроязыке и преобразует его, получая новый текст на "чистом" языке ассемблера (ЯА), и только затем начинает работать ассемблер. В этом случае МГ выступает в роли т.н. препроцессора (препроцессором называют вспомогательную программу, работающую до основной программы и преобразующую исходный текст к виду, удобному для работы основной программы).
Достоинством этого варианта является то, что так легче понять сам макроязык и работу МГ, так легче реализовать МГ и ассемблер. Однако у этого варианта имеются недостатки. Во-первых, приходится дважды просматривать текст программы (а это потери времени), а во-вторых, и это главное, при таком взаимодействии МГ не может использовать информацию, извлекаемую ассемблером из программы. Поясним это на примере.
Пусть программа имеет такой вид:
N EQU 1
...
IF N EQ 1
...
Директива EQU не относится к макроязыку, поэтому МГ не должен ее обрабатывать (это задача ассемблера) и потому он не узнает, что N
обозначает константу со значением 1. Директива же IF относится к макроязыку, поэтому МГ должен ее обрабатывать, в частности должен сравнить N с 1, но сделать это он не может, т.к. не знает, что обозначает имя N.
Этот пример показывает, что если МГ работает независимо от ассемблера, то либо надо запретить использование в директивах макроязыка констант и других объектов, смысл которых становится известным позже, при работе ассемблера, либо надо заставить МГ хотя бы частично выполнять работу ассемблера (скажем, обрабатывать директивы EQU). Ясно, что оба этих требования не очень хорошие.
Отмеченные недостатки устраняются при втором варианте взаимодействия МГ с ассемблером - когда текст программы просматривается только раз, но его обрабатывают одновременно (а точнее, чередуясь) и МГ, и ассемблер. Делается это так. Очередное предложение программы сначала просматривает МГ. Если это обычное предложение ЯА (например, директива N EQU 1), тогда МГ ничего с ним не делает, а сразу передает его на обработку ассемблеру. Ассемблер же, обработав это предложение (у нас - записав в таблицу имен, что N - это имя константы со значением 1), возвращает управление МГ, который переходит к следующему предложению программы. Если же очередное предложение программы - это конструкция макроязыка (например, IF N EQ 1), тогда его обработкой занимается сам МГ. В таких случаях МГ либо ничего не сообщает ассемблеру об этом предложении (как в случае директивы IF), либо (если это макрокоманда) генерирует несколько обычных предложений, которые по одному передает на обработку ассемблеру, и только после этого переходит к следующему предложению программы. Ясно, что в данном случае МГ может пользоваться информацией, извлеченной ассемблером из программы; например, в нашем случае МГ может забраться в таблицу имен и узнать, что означает имя N.
Этот второй вариант взаимодействия макрогенератора с ассемблером можно условно изобразить так:
программа на -----¬ строка ----------¬
макроязыке --> ¦ МГ ¦ -------> ¦ассемблер¦ --> маш.программа
L----- на ЯА L----------
L----<-------------
Здесь МГ выступает, с точки зрения ассемблера, в роли процедуры ввода строки. Обе эти программы можно рассматривать как части одной программы, которую принято называть макроассемблером.
Именно второй вариант используется в системе MASM, именно его мы и будем придерживаться в рассказе про работу МГ.
ОБРАБОТКА IF-БЛОКОВ.
Напомню, что с помощью IF-блоков реализуется условное ассемблирование, т.е. возможность вставлять или не вставлять в окончательный текст программы какие-то фрагменты исходного текста.
IF-блоки имеют следующий вид:
<IF-директива>
<фрагмент-1>
ELSE
<фрагмент-2>
ENDIF
причем часть ELSE может отсутствовать. Смысл этой конструкции следующий: если условие в IF-директиве выполнено, тогда в окончательный текст программы попадает фрагмент-1 и не попадает фрагмент-2, а если условие не выполнено, то, наоборот, в окончательный текст программы не попадает фрагмент-1, а попадает фрагмент-2 (если части ELSE нет, то в последнем случае IF-блок ничего не поставляет в окончательный текст программы).
Действия макрогенератора при обработке IF-блоков очевидны. Встретив какую-то из IF-директив (например, IF или IFIDN), макрогенератор проверяет ее условие. Если оно выполнено, то макрогенератор передает ассемблеру все строки до директивы ELSE; саму же директиву ELSE и все последующие строки до ENDIF макрогенератор пропускает. Если же условие не выполнено, тогда макрогенератор пропускает все строки до ELSE и только затем начинает передавать ассемблеру строки между ELSE и ENDIF.
Единственная, пожалуй, проблема, которая возникает при обработке IF-блоков, связана с вложенностью IF-блоков. Рассмотрим
такой
пример:
IF ...
...
IFIDN ...
...
ELSE
...
ENDIF
...
ELSE
...
ENDIF
Если условие в директиве IF не выполнено, то макрогенератор должен пропустить все строки до директивы ELSE, но не до первой встретившейся, а до "своей", т.е. он должен проигнорировать все вложенные IF-блоки.
Эта проблема аналогична задаче проверки произвольного текста на сбалансированность по круглым скобкам, и решается она аналогично. Поскольку на семинарах задачу со скобками вы решали, то укажу лишь идею решения. Вводится счетчик, указывающий уровень вложенности IF-блоков: при появлении IF-директивы этот счетчик увеличивается на 1, а при появлении директивы ENDIF, он уменьшается на 1. С помощью такого счетчика макрогенератор и отыскивает нужную директиву ELSE - это директива, для которой счетчик имеет то же значение, что и для исходной IF-директивы.
Ничего более про обработку IF-блоков я говорить не буду и на этом закончу рассказ про макрогенератор.
В заключении я хочу обратить ваше внимание на одну важную вещь. Как вы видите, макрогенератор, ассемблер и другие подобные программы активно пользуются таблицами, причем эти таблицы могут быть очень большими: например, в большой программе на ЯА таблица имен, создаваемая ассемблером, может содержать сотни и тысячи имен. Ясно, что если мы хотим, чтобы эти программы работали быстро, то такие таблицы должны быть организованы не кое-как, а с умом - так, чтобы поиск в них велся как можно быстрее.
Так вот, для этого используются те способы организации таблиц, о которых вам рассказывали в первом семестре. Например, ассемблер пользуется таблицей мнемокодов, в которой перечислены символьные названия всех машинных команд. Эта таблица создается только один раз (вместе с самим ассемблером), поэтому она не меняется, а используется только для поиска. В связи с этим такую таблицу можно организовать в виде упорядоченной таблицы или в виде перемешанной таблицы с заранее подобранной хорошей функцией расстановки. С другой стороны, таблица имен создается в процессе трансляции исходной программы, и в отношении ее активно применяется как операция вставки, так и операция поиска. Ясно, что здесь уже нельзя использовать упорядоченную таблицу, т.к. она плохо приспособлена для добавления новых элементов, а надо использовать перемешанную таблицу или таблицу в виде двоичного дерева, т.к. в этих случаях будет обеспечен и быстрый поиск, и быстрые вставки.
Учтите все это, если вам в будущем придется создавать ассемблеры или трансляторы вообще.
АССЕМБЛЕР. КОМПОНОВЩИК. ЗАГРУЗЧИК. МАКРОГЕНЕРАТОР.
Полная схема трансляции и запуска на счет модуля, написанного на языке макроассемблера:
модуль на ---------------¬ модуль ----------¬ объектный
макроязыке --> ¦макрогенератор¦ --> на языке --> ¦ассемблер¦ --> модуль -->
L--------------- ассемблера L---------- --->
др.модули ---
------------¬ загрузочный модуль ----------¬
-> ¦компоновщик¦ --> (исполняемая пр-ма) --> ¦загрузчик¦ --> счет
-> L------------ L----------
м проходе уже была построена
По этой директиве на 1- м проходе уже была построена ТРСР. Однако в программе директива ASSUME может встречаться многократно, поэтому состояние этой таблицы в конце 1-го прохода может не соответствовать ее состоянию после первой из директив ASSUME. В связи с этим ассемблер на 2-м проходе заново строит ТРСР после первой из директив ASSUME и затем меняет таблицу после каждой новой такой директивы.
Команда: ADD X,K
Обработка команд на 2-м проходе во многом осуществляется так же, как и на 1-м проходе. По ТИ ассемблер узнает, что имя X
описано в сегменте S2, а по ТРСР узнает, что этому сегменту поставлен в соответствие сегментный регистр DS. Следовательно, запись X - это сокращение адресной пары DS:X. Поскольку регистр DS из этой пары совпадает с регистром, подразумеваемого по умолчанию в команде ADD, то перед этой командой ассемблер не вставит префикс сегментного регистра. (Если бы имя X было описано в сегменте, на который, согласно ТРСП, указывает регистр ES, то ассемблер записал бы префикс ES: в очередной свободный байт памяти и затем увеличил бы АДР на 1.)
Далее ассемблер формирует собственно команду. По ТИ он узнает типы операндов (m16 и i16) и затем по таблице мнемокодов узнает, что команда сложения при таких типах операндов имеет КОП 81 06, который записывает в следующие два байта памяти. После этого ассемблер формирует операнды машинной команды: узнав по ТИ адрес имени X и значение константы K, ассемблер записывает этот адрес и это число в очередные байты памяти. На этом формирование машинной команды закончено. АДР увеличивается на число байтов, занятых командой.
Директива END.
Встретив эту директиву, ассемблер завершает 2-й проход. Машинная программа сформирована, ассемблер записывает ее во внешнюю память и на этом заканчивает свою работу.
Как видно, 2-й проход выполняется достаточно просто. Это объясняется тем, что значительная часть работы была проделана на 1-м проходе.
1.5 МНОГОМОДУЛЬНЫЕ ПРОГРАММЫ.
Мы рассмотрели основные действия ассемблера, выполняемые при трансляции программы, написанной на ЯА, в том случае, когда программа состоит только из одного модуля, когда в этом модуле нет внешних и общих имен. Теперь рассмотрим, какие изменения надо внести в работу ассемблера в случае многомодульной программы.
Структура объектного модуля.
Начнем со следующего важного замечания: в общем случае ассемблер не может довести до конца трансляцию программы, данной ему на входе, и основных причин тому две.
Первая - наличие внешних имен. Если ассемблер транслирует один из модулей многомодульной программы и в нем используются внешние имена, т.е. имена из других модулей, то, транслируя этот модуль независимо от других, ассемблер, естественно, не может оттранслировать эти имена, т.е. не может заменить их на соответствующие им адреса. Эти адреса станут известными позже, на этапе объединения модулей в единую программу, только тогда и появится возможность сделать эти замены.
Вторая причина - наличие имен сегментов. Например, в командах
MOV AX,S2
MOV DS,AX
имя сегмента S2 должно быть заменено на начальный адрес (без последнего 0) соответствующего сегмента памяти, но этот адрес зависит от того, с какого места памяти будет располагаться вся программа при счете. Если, скажем, сегмент S2 является самым первым в программе и если программа размещена с адреса 50000h, тогда имя S2 надо заменять на 5000h, но если программа размещена с адреса 70000h, то имя S2
надо заменять на 7000h. Заранее же начальный адрес программы неизвестен, поэтому ассемблер и не знает, на что заменять имена сегментов. Это станет известным позже, непосредственно перед выполнением программы, тогда и появится возможность сделать эти замены.
Отметим, что адреса, зависящие от места расположения программы в памяти, принято называть перемещаемыми адресами. Имена сегментов - пример таких адресов. (Других перемещаемых адресов в языке MASM нет, хотя в иных ЯА имеются и другие примеры перемещаемых адресов.) Так что второй причиной, по которой ассемблер не может довести трансляцию до конца, являются перемещаемые адреса. Отметим также, что проблема с этими адресами возникает в любой программе - как многомодульной, так и одномодульной.
Итак, имеется ряд вещей, которые ассемблер не может оттранслировать, которые можно дотранслировать только позже. Учитывая это, ассемблер поступает так: все, что может, он транслирует, а то, что не может оттранслировать, он пропускает, оставляет как бы пустые места, но при этом запоминает информацию об этих "пустых" местах, по которой затем можно будет их дотранслировать. В связи с этим при трансляции модуля ассемблер выдает на выходе на самом деле не модуль в полностью оттранслированном виде, а некоторую заготовку его, которую принято называть объектным модулем
(ОМ).
Объектный модуль состоит из двух частей - заголовка и тела. Тело модуля - это машинный код модуля, правда, некоторые места в нем, как уже сказано, недотранслированы. В заголовке же собрана информация, по которой затем можно будет дотранслировать эти места и объединить этот модуль с другими модулями программы.
В упрощенном виде структура заголовка ОМ состоит из следующих разделов: 1) таблица сегментов; 2) точка входа; 3) сегмент стека; 4) таблица общих имен; 5) таблица вхождений внешних имен; 6) таблица перемещаемых адресов.
Прежде чем объяснить смысл этой информации, сделаем такое замечание. В заголовке приходится ссылаться на ячейки внутри тела модуля. Эти ссылки задаются в виде пар s:ofs, где s - символьное имя сегмента, которому принадлежит ячейка, а ofs
- смещение ячейки внутри этого сегмента.
Таблица сегментов.
Напомним, что во время своей работы ассемблер строит несколько таблиц, в том числе таблицу сегментов. Эта таблица и переносится в заголовок ОМ.
Точка входа.
Если модуль является головным в программе, т.е. с него должно начинаться выполнение программы, тогда в его директиве END указывается точка входа - метка той команды модуля, с которого надо начинать выполнение программы. Адрес этой метки и записывается в заголовок. Данный адрес определяется просто: эта метка - одно из имен, описанных в модуле, поэтому информация о метке имеется в таблице имен (ТИ), которую строит ассемблер, а в этой таблице для каждого имени указываются среди прочего имя сегмента, в котором оно описано, и смещение имени внутри сегмента. Когда ассемблер доходит до директивы END и встречает в ней метку, то по ТИ он узнает адрес этой метки, который и записывает в заголовок (например, S3:0).
В заголовке остальных, не головных, модулях как-то помечается, что точки входа нет.
Сегмент стека.
Как известно, если при описании сегмента стека в его директиве SEGMENT указан параметр STACK, тогда перед началом программы регистры SS и SP должны быть автоматически установлены на этот сегмент. Естественно, надо знать, какой из сегментов является стеком. Определяется этот сегмент просто: когда ассемблер встречает директиву SEGMENT с параметром STACK, то имя этого сегмента (например, S1) он заносит в заголовок ОМ.
Таблица общих имен (ТОБ).
Общим называется имя, которое указано в директиве PUBLIC (например, PUBLIC B). Содержательно - это имя, описанное в данном модуле, но доступное для всех остальных модулей программы. При объединении модулей в единую программу в тех модулях, где это имя используется, надо будет заменить его на его адрес внутри данного модуля. Ясно, что ассемблер, транслирующий модули по отдельности, не может сделать эту замену. Он ее и не делает, однако для будущего запоминает информацию о всех общих именах модуля и их адресах внутри модуля. Эта информация и образует ТОБ.
Построить такую таблицу просто. Во-первых, все общие имена описаны в модуле, поэтому информация о них имеется в ТИ. Во-вторых, общие - это те имена, которые перечислены в директиве PUBLIC. Поэтому, встречая (на 2-м проходе) директиву PUBLIC, ассемблер для каждого из указанного здесь имени извлекает информацию из ТИ и заносит ее в ТОБ.
Например, для следующего модуля (слева) будет создана такая ТОБ (справа):
PUBLIC B
EXTRN X:WORD, P:FAR
S2 SEGMENT DATA общее
имя его
адрес
A DW X ----------------------
B DW SEG X, ? B S2:2
C DD P ...
...
Таблица вхождений внешних имен (ТВН).
Внешним называется имя, указанное в директиве EXTRN (например, EXTRN X:BYTE, P:FAR). Содержательно - это имя, которое используется в данном модуле, но описано в другом модуле. Ясно, что, транслируя данный модуль независимо от других, ассемблер не знает адреса внешних имен, поэтому не может заменить их на адреса.
Такую замену внешнего имени на адрес можно будет сделать только позже, на этапе объединения модулей в единую программу, когда будет известна информация о всех модулях. Пока же ассемблер в соответствующую ячейку объектного модуля записывает 0, но фиксирует, что позже в эту ячейку надо будет записать адрес внешнего имени. Такая информация о каждом вхождении
в модуль каждого внешнего имени и запоминается в ТВН. Например, для указанного выше модуля будет создана такая ТВН:
внеш.имя адрес вхождения тип вхождения
------------------------------------------
X S2:0 ofs
X S2:2 seg
P S2:6 segofs
...
Здесь "адрес вхождения" - это адрес той ячейки текущего модуля, в которую надо будет затем вставить адрес внешнего имени, указанного в первой колонке. Однако только этой информации мало. Дело в том, что в разных случаях под "адресом внешнего имени" понимаются разные вещи. Например, в директиве DW X (или в команде MOV AL,X) имя X надо заменять на смещение (ofs) этого имени, а в директиве DW SEG X (или в команде MOV AX,SEG X) - на начальный адрес (без последнего 0) того сегмента, где имя описано (на seg). Что касается директивы DD P (или команды CALL P), то имя P должно заменяться на полный адрес (на адресную пару seg:ofs). На какую именно часть своего полного адреса должно заменяться внешнее имя - отмечается (подходящим образом) в колонке "тип вхождения".
Таблица перемещаемых адресов (ТПА).
Внешние имена - это не единственная вещь, которую ассемблер не может оттранслировать до конца. Как уже сказано, ассемблер не может оттранслировать и имена сегментов. Такие имена надо заменить на начальные адреса сегментов (без последнего 0) в памяти, но эти адреса ассемблер не знает. Они станут известны позже, только перед выполнением программы.
Но что делать сейчас ассемблеру с именем сегмента? В соответствующую ячейку модуля он записывает 0 и при этом запоминает адрес данной ячейки и имя сегмента, чтобы позже можно было сделать замену имени на адрес. Эта информация о каждом вхождении каждого имени сегмента фиксируется в ТПА.
Например, для следующего модуля (слева) будет создана такая ТПА (справа):
S2 SEGMENT DATA ТПА:
... имя сегмента адрес вхождения
S2 ENDS ------------------------------
S3 SEGMENT CODE S2 S3:1
ASSUME DS:S2,CS:S3 ...
BEG: MOV AX,S2
MOV DS,AX
...
(Замечание: указанные две символьные команды транслируются в следующие машинные команды:
0: B8 0000
3: 8E D8
поэтому адрес ячейки, куда надо затем занести начальный адрес сегмента S2, равен S3:1.)
Вот такая информация входит в заголовок объектного модуля. Когда ассемблер оттранслирует модуль (получит тело ОМ) и построит его заголовок, он записывает получившийся ОМ во внешнюю память и на этом заканчивает свою работу.
По этой директиве ассемблер заполняет
По этой директиве ассемблер заполняет ТРСР, создавая пары DS и S2, CS и S3, SS и S1. Поскольку эта директива носит чисто информационный характер, то СР не меняется.
Директива ENDS: S2 ENDS
Ассемблер фиксирует, что сегмент S2 закрыт, и в ТС заносит размер этого сегмента, т.е. число байтов, занятых всеми предложениями сегмента. Этот размер определяется очень просто - он равен текущему значению СР. Таким образом, СР используется не только для определения соответствия между именами и адресами, но и для подсчета размеров сегментов.
Обработка команды, например: ADD X,K
На 1-м проходе ассемблер не формирует машинные команды, поэтому сейчас ему безразлично, на какой цифровой КОП надо заменять мнемокод ADD, на какой адрес заменять имя X и т.д. Единственное, что ему сейчас важно знать, - это сколько байтов в памяти займет соответствующая машинная команда, на сколько надо увеличить СР. Это число определяется так.
Размер команды зависит (помимо мнемокода) от двух вещей: от типов операндов и от того, надо или нет перед этой командой ставить префикс сегментного регистра. (Тип операндов - байты это или слова - не влияет на размер команды, просто КОПы будут отличаться одним из битов.)
Типы операндов определяются по ТИ. Ассемблер выделяет из команды первый операнд (имя X), лезет в ТИ и узнает, что это имя переменной размером в слово, т.е. этот операнд имеет тип m16. Затем ассемблер выделяет второй операнд (имя K) и по ТИ узнает, что это имя константы со значением 3; это значение может быть байтом или словом, но поскольку тип 1-го операнда команды равен word, то и этой константе приписывается тип word, т.е. i16. (В общем случае операнды задаются более сложными выражениями, скажем BYTE PTR X
или K/2+1, и их типы устанавливаются сложнее, однако, зная по ТИ типы простейших элементов этих выражений, можно установить и тип выражения в целом.) Узнав типы операндов, ассемблер лезет в таблицу мнемокодов и отыскивает в ней строку с нужным мнемокодом и нужными типами операндов, а из этой строки узнает размер соответствующей машинной команды. В нашем случае в строке для мнемокода ADD и типов m16 и i16 сказано, что размер команды равен 6.
Однако это еще не окончательный размер команды, надо еще определить, должен ли в этой команде использоваться префикс или нет. Если бы этот префикс был указан в команде явно (типа DS:X), тогда здесь проблемы не было бы. Но, как правило, в программах на ЯА такой префикс опускается с расчетом, что, если надо, его подставит сам ассемблер. Для этого ассемблер по ТИ узнает, в каком сегменте описано имя X
(пусть это сегмент S2), а по ТРСР узнает, какой сегментный регистр поставлен в соответствие этому сегменту (пусть это регистр DS). Тем самым ассемблер устанавливает, что X - это на самом деле сокращение адресной пары DS:X. После этого ассемблер смотрит, не совпадает ли сегментный регистр из этой пары с тем сегментным регистром, который подразумевается в данной команде по умолчанию. Как известно, в команде ADD по умолчанию подразумевается регистр DS. Это значит, что перед нашей машинной командой можно не ставить префикс DS:. Тем самым по данной символьной команде в памяти будет занято 6 байтов, поэтому ассемблер увеличивает СР на 6 и за этом заканчивает обработку данной команды.
Но если бы имя X было описано в сегменте, на начало которого (согласно ТРСР) установлен иной регистр, скажем ES, который не совпадает с префиксом, подразумеваемым по умолчанию, тогда опускать префикс ES: перед машинной командой уже нельзя, поэтому всего символьная команда займет 6+1=7 байтов (префиксы DS:, ES: и т.п. - это самостоятельные однобайтовые машинные команды) и поэтому ассемблер увеличит СР на 7.
Директива END
По этой директиве ассемблер узнает, что текст программы закончился, поэтому он завершает свой 1-й проход. Цель этого прохода - построение ТИ и ТС - достигнута.
АССЕМБЛЕР. Компоновщик. Загрузчик. Макрогенератор
Начинается новый сегмент. Поскольку каждый сегмент должен начинаться с адреса, кратного 16, то ассемблер, если надо, увеличивает значение АДР
до ближайшего адреса, кратного 16, пропуская в памяти все промежуточные байты (что в них было в это время, то и останется).
Директивы DB, DW, DD: X DW Y
Y DB 3 DUP(0)
По этим директивам ассемблер резервирует место в памяти (начиная с текущего значения АДР), записывает в него начальные значения переменных (адрес имени Y
узнается из ТИ) и увеличивает АДР
на соответствующее число (на 2 и на 3). Если переменная описана без начального значения (типа X DW ?), то в ее ячейку ассемблер ничего не записывает - что в ней было к этому моменту, то и останется.
Директива
Настройка программы на место.
Итак, только в этот момент становится известным начальный адрес программы и лишь теперь можно полностью завершить трансляцию программы. Такое завершение трансляции заключается, как говорят, в настройке программы на занимаемое ею место в памяти, в привязке ее к этому месту. Делается это так.
Напомним, что в программе остались недотранслированными имена сегментов: не зная настоящих начальных адреса сегментов, компоновщик запомнил их относительные адреса, т.е. отсчитанные от начала программы, и запомнил адреса ячеек программы, в которые надо затем записать настоящие адреса сегментов. Эта информация хранится в ТПА загрузочного модуля. Теперь же, когда стал известным начальный адрес программы, уже можно получить и абсолютные адреса сегментов. Для этого надо к их относительным адресам добавить начальный адрес. Например, если в ТПА была строка
отн.адрес сегмента отн.адрес вхождения
-------------------------------------
1010 1000:2
и если начальный адрес программы равен 40000h, то загрузчик по адресу сегмента (1010) получает его абсолютный адрес: 40000+1010=41010, затем определяет абсолютный адрес ячейки, в которую надо записать этом адрес, для чего к относительному адресу этой ячейки 1000+2=1002 он прибавляет начальный адрес программы 40000, получая тем самым адрес 41002, и далее записывает в эту ячейку абсолютный адрес сегмента без последнего 0, т.е. величину 4101. Это уже окончательный адрес сегмента, больше его менять не надо.
Вот так загрузчик осуществляет настройку программы на адрес, с которого она разместилась в памяти. Больше в ней ничего менять не надо, она полностью оттранслирована и готова к счету.
Обработка макроопределений.
Сначала рассмотрим, что делает макрогенератор, когда он встречает макроопределение (МО). Пусть в тексте программы имеется такой фрагмент (слева - номера строк программы):
| ...
10 | M1 MACRO OP,W
11 | LOCAL L
12 | L: OP AX,W
13 | ENDM
14 | M MACRO X,Y,Z
15 | MOV AX,X
16 | M1 ADD,Y
17 | MOV Z,AX
18 | ENDM
19 | ...
Пусть НС=10, т.е. сейчас макрогенератор должен обработать 10-ю строку программы. В ней находится директива MACRO, директива макроязыка, поэтому макрогенератор должен ее обработать. Как макрогенератор узнает, что это директива макроязыка? А очень просто. В макрогенератор заранее встроена таблица, в которой перечислены названия всех директив макроязыка. Выделив в очередной строке мнемокод, макрогенератор просматривает эту таблицу, и если в ней есть такое имя, то значит, это директива макроязыка, иначе это обычное предложения ЯА.
Конкретно для директивы MACRO макрогенератор выполняет следующие действия. С этой директивы начинается новое МО. Макрогенератор заносит сведения о новом макросе в специальную таблицу, называемую таблицей макросов (ТМ). Вначале эта таблица пуста, а затем она пополняется по мере появления новых МО. Примерный вид ее такой:
имя макроса формал.параметры локал.имена начало тела
-----------------------------------------------------------
M1 OP, W L 11 -> 12
M X, Y, Z - 15
...
В 1-й колонке указывается имя макроса (у нас - M1), во 2-й - имена формальных параметров этого макроса (у нас - OP и W); эта информация извлекается из заголовка МО. В 3-й колонке указываются локальные имена, т.е. имена из директивы LOCAL, но пока макрогенератор ничего о них не знает (директиву LOCAL он еще не видит), поэтому пока оставляет эту колонку пустой. В 4-й колонке указывается номер строки программы, с которой начинается тело макроса, - это текущее значение НС
плюс 1 (у нас - 11). На этом макрогенератор заканчивает обработку 10-й строки и, увеличив НС на 1, переходит к следующей строке.
Теперь НС=11. Это директива LOCAL, которая сообщает о локальных именах макроса. Макрогенератор все перечисленные в ней имена заносит в 3-ю колонку соответствующей строки ТМ (у нас - заносит L). Кроме того, поскольку директива LOCAL не относится к телу макроса, то в этой же строчке корректируется число в 4?й колонке - оно увеличивается на 1 [зачеркнуть 11 и записать 12]. Если бы за директивой MACRO не было директивы LOCAL, то ТМ уже не менялась бы.
Далее. Поскольку сейчас макрогенератору нечего делать с МО, то он пропускает все последующие строки программы вплоть до строки с директивой ENDM, оканчивающей МО. Поэтому текущим значением НС
становится 13. На этом вся обработка МО завершается. Никакая информация о МО ассемблеру не сообщается (это не его дело).
Макрогенератор увеличивает НС на 1 и идет дальше.
Итак, НС=14. Снова директива MACRO, снова начинается МО. Действия макрогенератора аналогичны. Во-первых, в ТМ добавляется новая строка со следующей информацией [записать]: имя - М, формальные параметры - X, Y и Z, локальных переменных - нет, начало тела - 14+1=15. Поскольку далее нет директивы LOCAL, то макрогенератор пропускает все последующие строки вплоть до директивы ENDM, поэтому значением НС
становится 18. На этом обработка МО заканчивается. Макрогенератор увеличивает НС на 1 и идет дальше.
Основная идея ассемблирования.
Ассемблер постоянно находится во внешней памяти. Когда операционной системе (ОС) дан приказ
MASM M.ASM, M.OBJ, M.LST;
на трансляцию нашей программы, то ОС считывает ассемблер из внешней памяти (из файла MASM.EXE) в оперативную память (ОП) и передает ему управление. Свою работу ассемблер начинает с того, что считывает из внешней памяти (из файла M.ASM) в ОП программу на языке ассемблера (ЯА). Затем он просматривает ее текст строчка за строчкой и постепенно формирует соответствующие машинные коды, которые записывает в другое место ОП. Когда ассемблер полностью построит машинную программу, он записывает ее во внешнюю память (в файл M.OBJ) и на этом заканчивает свою работу (попутно в файл M.LST записывается листинг).
-------->-------¬ --------->-------¬
--------------------------------------------------------------
ОП: ¦программа на ЯА¦ ¦ ассемблер ¦ ¦маш.программа¦
--------------------------------------------------------------
¯
внеш.память: M.ASM MASM.EXE M.OBJ
Основная идея перевода с ЯА на машинный язык проста. Надо:
- заменить мнемонические названия команд на соответствующие цифровые коды операций (КОПы);
- заменить имена переменных и меток на соответствующие адреса;
- перевести данные в двоичное машинное представление.
Если, к примеру, имя B обозначает ячейку с адресом 0001, а операция сложения слова из памяти с непосредственным операндом имеет код 81 06 (в ПК коды многих операций занимают два байта), тогда по символьной команде ADD B,260 ассемблер должен построить следующую машинную команду:
81 06 0001 0104
Как осуществляется такой перевод?
Перевод чисел из 10-й системы счисления в 2-ю осуществляется по хорошо известным алгоритмам.
Замена мнемокодов на цифровые КОПы осуществляется с помощью заранее составленной таблицы, в которой для каждого мнемокода (ADD, MOV, JMP и т.п.) указано, на какой цифровой КОП надо заменять этот мнемокод. Выделив из символьной команды мнемокод, ассемблер отыскивает в этой таблице строчку с данным мнемокодом и берет из нее нужный КОП, который и подставляет в формируемую машинную команду.
Имя переменной ( или метка) должно заменяться на его смещение, т.е. на адрес имени, отсчитанный от начала того сегмента, в котором описано это имя. Для подсчета смещений ассемблер в своей работе использует специальный счетчик размещения (СР), в котором всегда находится смещение первой свободной, еще не занятой ячейки текущего сегмента, т.е. ячейки, куда должна быть помещена очередная машинная команда. При появлении в программе на ЯА нового сегмента СР обнуляется, а затем увеличивается по мере просмотра предложений этого сегмента.
Рассмотрим такой пример:
имя адрес СР
S SEGMENT 0
A DB ? A <-> 0 1
B DW ? B <-> 1 3
C DD ? C <-> 3 7
...
При появлении директивы SEGMENT отсчет смещений должен начаться от 0, поэтому СР получает значение 0. Далее идет директива DB, описывающая переменную A. Эта переменная размещается с самого начала сегмента, т.е. имеет смещение 0, поэтому имени A ставится в соответствие адрес 0, т.е. текущее значение СР. Переменная A
занимает 1 байт, поэтому СР увеличивается на 1 и имеет значение 1; это значит, что первая свободная ячейка в сегменте имеет адрес 1. С этого места размещается переменная B, поэтому имени B ставится в соответствие адрес 1. Так как на B отводится 2 байта, то СР увеличивается на 2 и теперь имеет значение 3. Именно этот адрес будет поставлен в соответствие имени C из следующей директивы. После этого СР увеличивается на 4, и т.д.
Вот так ассемблер с помощью СР следит за тем, какие ячейки уже заняты, а какие свободны, и с помощью этого СР определяет, какой адрес какому имени соответствует. Эти соответствия запоминаются ассемблером и затем используются для замены имен на адреса.
Такова общая идея перевода с ЯА на машинный язык. Она достаточно проста, но при ее реализации возникает ряд проблем, наиболее важные из которых рассматриваются далее.
Особые случаи на первом проходе.
Таковы в общих чертах действия ассемблера на 1-м проходе. Однако есть ряд моментов, которые осложняют его работу на этом проходе.
Напомним, что необходимость в 1-м проходе обусловлена проблемами, связанными со ссылками вперед. Но оказывается, что некоторые из этих проблем проявляются уже на 1-м проходе. Рассмотрим соответствующие случаи и то, как ассемблер реагирует на них.
Первый случай. Пусть в программе имеется такой фрагмент:
Y DB K DB DUP(0)
X DW Y
K EQU 3
Когда ассемблер встретит первую из этих директив, то он еще не будет знать, что означает имя K. Конечно, по смыслу можно предположить, что K - это имя константы, но вот чему равно ее значение - предположить нельзя. А знать это значение очень важно уже на 1-м проходе, т.к. от этого зависит, на сколько надо увеличивать значение СР при обработке директивы DB, от этого зависит адрес имени X. Таким образом, не зная значения K, ассемблер не может правильно продолжить свою работу.
Что делает ассемблер? Поскольку в данной ситуации никаких разумных действий (кроме забегания по тексту вперед, которое требует массы времени) он предпринять не может, то он фиксирует ошибку "ссылка вперед здесь недопустима". Учитывая этот и другие подобные случаи, авторы ЯА ввели в язык ограничение: в константных выражениях нельзя использовать ссылки вперед.
Второй случай. Рассмотрим такой фрагмент программы:
CALL P
L: ...
...
P PROC FAR
Здесь обращение к процедуре P встретилось раньше ее описания, и это ставит перед ассемблером следующую проблему на 1-м проходе. Если P - имя близкой процедуры, тогда машинная команда, соответствующая символьной команде CALL, займет 3 байта памяти (она имеет вид КОП ofs, где ofs
- смещение имени P), и потому ассемблер должен увеличивать СР на 3. Но если P
является именем дальней процедуры, тогда соответствующая машинная команда займет 5 байтов (она имеет вид КОП ofs seg), и потому СР должен быть увеличен на 5. Так на сколько же надо увеличивать СР - на 3 или 5? А это важно знать, от этого зависит адрес метки L и всех последующих меток.
Как видно, и здесь из- за ссылки вперед ассемблер не знает, что ему делать уже на 1-м проходе. Однако фиксировать в данной ситуации ошибку неразумно, т.к. в реальных программах такие ситуации встречаются очень часто и этих ошибок было бы слишком много. Кроме того, в данной ситуации можно сделать вполне разумное предположение относительно имени P, а именно предположить, что это имя близкой
процедуры (так чаще всего и бывает в реальных программах). Учитывая все это, ассемблер в данной ситуации не фиксирует ошибку, а делает предположение, что P - это имя близкой процедуры, и далее уже действует согласно этому предположению, т.е. считает, что данная команда CALL будет транслироваться в машинную команду близкого вызова, и потому увеличивает СР на 3. Но если затем окажется, что это предположение ошибочно (как в нашем примере), тогда ассемблер уже зафиксирует ошибку.
Третий случай. Предположим, в программе переменная X
описана в конце сегмента команд. Тогда имя X
будет использовано в команде ADD до своего описания:
ADD X,K
...
X DW Y
Встретив команду ADD, ассемблер еще не будет знать, что обозначает имя X (переменную или что-то иное), и не будет знать, в каком сегменте описано имя X, а потому не будет знать, по какому сегментному регистру должно сегментироваться это имя, надо или нет перед этой командой ставить префикс. А от всего этого зависит, сколько байтов в памяти займет соответствующая машинная команда - 6 или 7.
И здесь ассемблер не фиксирует ошибку, а предполагает, что имя X
обозначает переменную (а не константу или что-то иное) и что в данной команде не должен использоваться префикс, т.е. что переменная X будет описана в сегменте, на начало которого показывает регистр, подразумеваемый по умолчанию в данной команде. Сделав такое предположение, далее ассемблер действует уже согласно ему, а именно определяет, что эта команда в целом займет в памяти 6 байтов. И опять же, если это предположение окажется ошибочным (например, у нас имя X описано в сегменте команд и потому должно сегментироваться по регистру CS), то затем будет зафиксирована ошибка.
Таковы основные случаи, когда из-за ссылок вперед ассемблер уже на 1-м проходе не знает в точности, что ему делать. Как видно, реакция ассемблера на эти случаи может быть двоякой. Если он не может сделать никаких разумных предположений относительно ссылки вперед (как в случае с константами), то он фиксирует ошибку; при этом в ЯА вводятся соответствующие ограничения. Но если можно сделать какое-то разумное предположение относительно ссылки вперед, то ассемблер делает такое предположение и далее действует согласно ему. Отметим, что эти предположения берутся не "с потолка": из всех возможных интерпретаций ссылки вперед в качестве предположения берется вариант, который наиболее часто встречается в реальных программах. Например, процедуры чаще всего бывают близкими, и именно этот вариант выбирается в команде CALL.
Проблема ссылок вперед. Два прохода ассемблера.
Основная из этих проблем связана со ссылками вперед. Суть ее в следующем. Рассмотренный способ замены имен на адреса проходит только для ссылок назад, т.е. когда имя сначала описано (появилось в левой части какого-то предложения) и лишь затем используется (появилось в поле операндов). Ситуация существенно осложняется при ссылках вперед, т.е. когда имя используется до своего описания.
Рассмотрим следующий пример:
ADD B,260
...
B ...
...
Встретив команду ADD, ассемблер еще не знает, где описано имя B, и потому не знает его адреса, не знает, на что надо заменять имя B в этой команде. Что делать? Для решения проблемы со ссылками вперед ассемблер просматривает текст программы дважды. При первом просмотре ассемблер ничего не транслирует, ничего не переводит на машинный язык, а только собирает сведения обо всех именах, используемых в программе: каких они типов, каковы их адреса и т.д. И только при втором просмотре текста ассемблер, уже зная все необходимое об именах, осуществляет перевод символьных команд на машинный язык.
Полный просмотр текста транслируемой программы принято называть проходом транслятора, поэтому ассемблер, который делает два прохода, называется двухпроходным.
Прежде чем перейти к рассказу о действиях ассемблера на каждом из проходов, рассмотрим таблицы, которыми он пользуется.
Таблица директив.
В этой таблице перечислены названия всех директив ЯА и указаны начальные адреса тех процедур ассемблера, которые занимаются обработкой этих директив:
ASSUME - адрес процедуры обработки ASSUME
DB - адрес процедуры обработки DB
...
Эта таблица используется двояко. Прежде всего она нужна для того, чтобы отличать команды от директив: когда ассемблер обрабатывает очередное предложение программы на ЯА, то он выделяет из него название и определяет по этой таблице, имеется ли такое названием в таблице или нет. Если нет, то ассемблер считает данное предложение командой, а если да - то директивой. В последнем случае ассемблер по адресу из таблицы передает управление на ту из своих процедур, что занимается обработкой данной директивы.
Таблица имен (ТИ).
В ней записывается информация о метках, именах переменных, именах констант и т.п. Таблица имеет примерно такую структуру (все числа 16-ричные):
имя тип значение сегмент
--------------------------------------
X WORD 7048 S2
P FAR 001F S3
K NUMBER 3 -
...
В каждой строчке собрана информация об одном имени, указанном в первой колонке.
В поле "тип" указывается класс объекта, обозначенного этим именем, и, если надо, его размер. Типы BYTE, WORD и DWORD указывают, что это имя переменной соответствующего размера. Типы NEAR и FAR указывают на метку или имя процедуры. Тип NUMBER указывает на имя числовой константы. Используются и другие типы (например, для структур и записей), но мы их не будем рассматривать.
В поле "значение" указывается величина, на которую ассемблер будет заменять имя, когда оно встретится в качестве операнда какой-то команды или директивы. Для меток и имен переменных здесь указываются их адреса (смещения), а для констант - их значения.
В поле "сегмент" указывается имя того сегмента программы, в котором было описано имя из первой колонки (для констант это поле пусто). Эта информация нужна для определения того, по какому сегментному регистру должно сегментироваться данное имя.
Таблица мнемокодов.
В этой таблице перечислены мнемонические названия всех команд (ADD, MOV и т.п.) и соответствующие им цифровые КОПы. Эта информация нужна для того, чтобы ассемблер знал, на какой КОП надо заменять какой мнемокод.
Отметим, что в ПК один и тот же мнемокод в зависимости от типов операндов может заменяться на разные КОПы, поэтому эта таблица представляет собой не просто список пар "мнемокод - КОП", а имеет более сложную структуру, примерно такую:
мнемокод тип op1 тип op2 КОП размер команды
----------------------------------------------------------
ADD m16 i16 81 06 6
ADD r16 r16 ... 2
...
NEG r8 - ... 2
...
Эта таблица используется следующим образом. Когда ассемблеру встречается команда, например ADD B,260, то он выделяет из нее мнемокод (ADD) и определяет типы операндов (m16 и i16), после чего отыскивает в таблице строчку с таким мнемокодом и такими типами и берет из нее указанный КОП, который и подставляет в формируемую машинную команду.
Отметим, что эта таблица используется также для проверки правильности записи мнемокодов и для проверки правильности типов операндов. Например, если в предложении встретилось название ADS, то, не найдя такого имени в таблице, ассемблер зафиксирует ошибку. Аналогично будет зафиксирована ошибка в команде ADD B,B, поскольку в таблице нет строки с мнемокодом ADD и типами операндов m16 и m16.
Рассмотрим попутно, как решается проблема с модифицируемыми адресами, например в команде ADD B[BX],260. В машинных командах ПК информация о том, по каким регистрам-модификаторам происходит модификация операнда-адреса, указывается в последних трех разрядах второго байта КОПа (в поле m):
3
------------¬ ------------¬
¦ ¦ ¦ | m ¦
L------------ L------------
L-------- КОП ------------
Например, m= 000b означает, что адрес должен модифицироваться по двум регистрам BX и SI, m=110b - адрес не модифицируется, m=111b - адрес модифицируется по регистру BX и т.д.. Так вот, определив типы операндов команды без учета модификаторов и выбрав из таблицы мнемокодов соответствующий КОП, ассемблер затем просто слегка корректирует этот КОП, настраивая его на нужные модификаторы: в нашем примере с B[BX] код 81 06 (здесь m=110b) надо заменить на 81 07 (m=111b). Поэтому в самой таблице не надо хранить КОПы для всех сочетаний модификаторов, а достаточно хранить только один, так сказать, базовый КОП, отталкиваясь от которого уже просто получить окончательный КОП, учитывающий указанные модификаторы.
В таблице мнемокодов также указывается размер машинных команд, их длина в байтах. Эта информация очень важна для ассемблера на 1-м проходе.
Теперь рассмотрим таблицы, которые ассемблер строит в процессе своей работы и в которых собираются сведения о той программе, которую ассемблер сейчас транслирует. Вначале эти таблицы пусты, а затем по мере просмотра текста программы ассемблер на 1-м проходе заносит в них информацию о тех или иных объектах программы. Это - таблица имен, таблица сегментов и таблица распределения сегментных регистров.
Таблица распределения сегментных регистров (ТРСР).
В этой таблице указывается, какому сегментному регистру какой сегмент программы поставлен в соответствие, например:
сегм.регистр сегмент
--------------------------
CS S3
DS S2
SS S1
ES --
Эта информация узнается из директивы ASSUME и используется для определения того, по каким сегментным регистрам надо сегментировать имена из тех или иных сегментов. При появлении в тексте программы каждой новой директивы ASSUME информация в этой таблице корректируется.
Таблица сегментов (ТС).
В эту таблицу ассемблер заносит имена всех сегментов программы и некоторые сведения о них. Примерный вид таблицы:
имя сегмента начало размер класс ...
-----------------------------------------------------
S1 0 80 STACK
S2 80 2405 DATA
S3 2490 F27 -
...
В поле "начало" указывается начальный адрес сегмента, отсчитанный от начала программы, а в поле "размер" - количество байтов, занимаемых всеми предложениями сегмента. Кроме того, для каждого сегмента указываются значения параметров из директивы SEGMENT, с которой начинается описание данного сегмента; ради простоты из всех этих параметров (выравнивание, объединение и класс) мы далее будем учитывать только параметр "класс".
Отметим, что сам ассемблер не пользуется этой таблицей, а строит ее для компоновщика.
Загрузка программы.
Прежде всего загрузчик определяет место в памяти, где можно разместить программу. Для этого используются возможности ОС: в ее состав входит сервисная процедура, обратившись к которой можно узнать, какое место в памяти сейчас свободно, каковы его размер и начальный адрес. Узнав эту информацию, загрузчик по указанной в заголовке ЗМ длине программы определяет, хватит ли программе места. Если нет, то загрузчик фиксирует ошибку "мало памяти" и возвращает управление ОС - программа в этом случае не выполняется. Если же места достаточно, тогда загрузчик считывает программу (тело ЗМ) в это место.
Запуск программы на счет.
Теперь осталось только запустить программу на счет. Для этого надо сделать две вещи.
Во-первых, надо загрузить регистры SS и SP так, чтобы они указывали на сегмент стека программы. Делается это просто. Из заголовка ЗМ извлекается относительный адрес этого сегмента, к которому загрузчик прибавляет начальный адрес программы и полученный таким образом настоящий адрес записывает (без последнего 0) в регистр SS. Извлеченная же из заголовка ЗМ длина стека заносится в регистр SP.
Во-вторых, надо записать в регистры CS и IP адрес точки входа в программу. И это делается просто, т.к. этот адрес указан в заголовке ЗМ. Правда, там указан относительный адрес точки входа, но он легко преобразуется в абсолютный адрес добавлением к нему начального адреса программы. Запись же этих величин в данные регистры есть ничто иное, как переход на начальную команду программы.
Итак, загрузчик все, что надо, сделал и передал управление на нашу программу. Теперь она начинает выполняться.
Замечание: в последние годы на лекциях не рассматривается работа макрогенератора, поэтому здесь эта тема опущена.