Добавил:
Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
Учебник Архитектура компьютера. Э. Таненбаум..doc
Скачиваний:
109
Добавлен:
20.06.2014
Размер:
7.54 Mб
Скачать

3. Сведения о том, можно ли получить доступ к символу извне процедуры.

Таблица 7.7. Счетчик адреса команд используется для слежения за адресами команд. В данном примере операторы до MARIA занимают 100 байтов

Метка

Код операции

Операнды

Комментарии

Длина

Счетчик адреса команд

MARIA:

MOV

EAX, I

EAX=I

5

100

MOV

EBX, J

EBX=J

6

105

ROBERTA:

MOV

ECX, К

ECX=K

6

111

IMUL

EAX, EAX

EAX=I*I

2

117

IMUL

EBX, EBX

EBX=J*J

3

119

IMUL

ECX, ECX

ECX=K*K

3

122

MARILYN:

ADD

EAX, EBX

EAX=I*I+J*J

2

125

ADD

EAX, ECX

EAX=I*I+J*J+K*K

2

127

STEPHANY:

JMP

DONE

Переход к DONE

5

129

Таблица 7.8. Таблица символьных имен для программы из табл. 7.7. Символьное имя Значение Прочая информация

MARIA 100

ROBERTA 111

MARILYN 125

STEPHANY 129

В таблице кодов операций предусмотрен по крайней мере один элемент для каждого символического кода операции в языке ассемблера (табл. 7.9). В каждом элементе таблицы содержится символический код операции, два операнда, число­вое значение кода операции, длина команды и номер типа, по которому можно опре­делить, к какой группе относится код операции (коды операций делятся на груп­пы в зависимости от числа и типа операндов).

В качестве примера рассмотрим код операции АЮ Если команда в качестве первого операнда содержит регистр ЕАХ, а в качестве второго — 32-битную константу (immed32), то используется код операции 0x05, а длина команды составляет 5 бай­тов. Если используется команда ADD с двумя регистрами в качестве операндов, то длина команды составляет 2 байта, а код операции будет равен 0x01. Все комбина­ции кодов операций и операндов, которые соответствуют данному правилу, будут отнесены к классу 19 и будут обрабатываться так же, как команда ADD с двумя регистрами в качестве операндов. Класс команд обозначает процедуру, которая вызывается для обработки всех команд данного типа.

В некоторых ассемблерах можно писать команды с применением непосредствен­ной адресации, даже если соответствующей команды не существует в выходном языке. Такие команды с «псевдонепосредственными» адресами обрабатываются следующим образом. Ассемблер назначает участок памяти для непосредственного операнда в конце программы и порождает команду, которая обращается к нему. Например, универсальная вычислительная машина IBM 3090 не имеет команд с не­посредственными адресами. Тем не менее программист может написать команду

L 14.=F'5'

для загрузки в регистр 14 константы 5 размером в полное слово. Таким образом, программисту не нужно писать директиву, чтобы разместить слово в памяти, при­дать ему значение 5, дать ему метку, а затем использовать эту метку в команде L. Константы, для которых ассемблер автоматически резервирует память, называют­ся литералами. Литералы упрощают читаемость и понимание программы, делая значение константы очевидным в исходном операторе. При первом проходе ас­семблер должен создать таблицу из всех литералов, которые используются в про­грамме. Все три компьютера, которые мы взяли в качестве примеров, содержат команды с непосредственными адресами, поэтому их ассемблеры не обеспечива­ют литералы. Команды с непосредственными адресами в настоящее время счита­ются обычными, но раньше они рассматривались как нечто совершенно необыч­ное. Вероятно, широкое распространение литералов внушило разработчикам, что непосредственная адресация — это очень хорошая идея. Если нужны литералы, то во время ассемблирования сохраняется таблица литералов, в которой появляется новый элемент всякий раз, когда встречается литерал. После первого прохода таб­лица сортируется и продублированные элементы удаляются.

В листинге 7.5 показана процедура, которая лежит в основе первого прохода

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

их суть. Листинг 7.5 представляет собой хорошую отправную точку для изучения. Он достаточно короткий, он легок для понимания, и из него видно, каким должен быть следующий шаг — это написание процедур, которые используются в данном листинге.

Листинг 7.5. Первый проход простого ассемблера

public static void pass_one() {

// Эта процедура - первый проход ассемблера

boolean more_input=true; //флаг, который останавливает первый проход

String line, symbol, literal, opcode; //поля команды

int location_counter, length, value, type; //переменные

final int END STATEMENT = -2; //сигналы конца ввода

location_counter = 0; //ассемблирование первой команды в ячейке 0 imtialize_tables(), //общая инициализация

while (morejnput) { //morejnput получает значение «ложь» с помощью B\D

line = read_next_line(); //считывание строки

length =0; //# байт в команде

type =0. //тип команды

if (line_isjiot_coniment(line)) {

syntol = check_for_symbol(line), //Содержит ли строка метку? if (symbol !- null) //если да, то записывается символ и значение

enter_new_symbol(symbol. 1ocation_counter),

literal = check_for_literal(line). //Содержит ли строка литерал?

if (literal != null) //если да, то он вводится в таблицу

enter_new_literal(1itera1);

//Теперь определяем тип кода операции. //-1 значит недопустимый код операции.

opcode = extract_opcode(line). //определяем место кода операции

type =search_opcode_table(opcode). //находим формат, например. OP REG1.REG2 if (type < 0) //Если это не код операции, является

//ли это директивой?

type = search_pseudo_table(opcode). switch(type) { //определяем длину команды

case l.length=get_length_of_typel (line), break, case 2 Iength=get_length_of_type2(line); break.

//другие случаи

}

}

wnte_temp_file(type, opcode, length, line), //информация для второго прохода

location_counter = location_counter + length, //обновление счетчика адреса команд

if (type == END_STATEMENT) { //завершился ли ввод?

morejinput - false. //если да. то выполняем служебные действия-

rewind_temp_for_pass_two(). //перематываем файл обратно

sort_literal_table(). //сортируем таблицу литералов

remove_redundant_literals(); //и удаляем из нее дубликаты

}

}

}

Одни процедуры будут относительно короткими, например checkjorsymbol, которая просто выдает соответствующее обозначение в виде цепочки символов, если таковое имеется, и выдает ноль, если его нет. Другие процедуры, например get_length_of_type1 и get_length_ofjtype2, могут быть достаточно длинными и мо­гут сами вызывать другие процедуры. Естественно, на практике типов будет не

два, а больше, и это будет зависеть от языка, который ассемблируется, и от того,

сколько типов команд предусмотрено в этом языке.

Структурирование программ имеет и другие преимущества помимо простоты

программирования. Если ассемблер пишется группой людей, разнообразные про­цедуры могут быть разбиты на куски между программистами. Все подробности получения входных данных спрятаны в процедуре read_next_line. Если эти детали нужно изменить (например, из-за изменений в операционной системе), то это по­влияет только на одну подчиненную процедуру, и никаких изменений в самой про­цедуре passjone делать не нужно.

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

строку во второй раз. Однако переписывание входного файла потребует больше

операций ввода-вывода. Что лучше — увеличить количество операций ввода-вы­вода, чтобы меньше времени тратить на анализ строк, или сократить количество операций ввода-вывода и потратить больше времени на анализ, зависит от скоро­сти работы центрального процессора и диска, эффективности файловой системы

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

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

Второй проход

Задача второго прохода — произвести объектную программу и напечатать прото­кол ассемблирования (если нужно). Кроме того, при втором проходе должна вы­водиться информация, необходимая компоновщику для связывания процедур,

которые ассемблировались в разное время, в один выполняемый файл. В листин­ге 7.6 показана процедура для второго прохода.

Листинг 7.6. Второй проход простого ассемблера

public static void pass_two() {

//Эта процедура - второй проход ассемблера boolean morejnput = true: //флаг, который останавливает второй проход

String line, opcode; //поля команды

int location_counter, length, type: //переменные final int END_STATEMENT = -2: //сигналы конца ввода final int MAX_CODE =16; //максимальное количество байтов в команде

byte code[] = new byte[MAX_CODE]; //количество байтов в команде в порожденном коде

location_counter = 0; //ассемблирование первой команды в адресе 0

while (morejnput) { //morejnput устанавливается на «ложь» с помощью END

type = readj:ype(): //считывание поля типа следующей строки

opcode = read_opcode(); //считывание поля кода операции следующей строки

length = readJengthO; //считывание поля длины в следующей строке

line = readJineO; //считывание самой входной строки

if (type != 0) { //тип 0 указывает на строки комментария

switch(type) { //порождение выходного кода

case l:evalj:ypel(opcode, length, line, code): break;

case 2: eval_type2(opcode, length, line, code); break;

//Другие случаи

}

}

write_output(code): // запись двоичного кода

writejisting(code. line); // вывод на печать одной строки

location_counter = location_counter + length; //обновление счетчика адреса команд if (type == END_STATEMENT) { // завершен ли ввод?

morejnput = false; // если да, то выполняем служебные операции

finishjjpO; // завершение

}

}

}

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

мы записали в начале каждой строки тип, код операции и длину (во временном файле), все они считываются, и таким образом, нам не нужно проводить анализ строк во второй раз. Основная работа по порождению кода выполняется процеду­рами eval_type1, eval_type2 и т. д. Каждая из них обрабатывает определенную мо­дель (например, код операции и два регистра-операнда). Полученный в результа­те двоичный код команды сохраняется в переменной code. Затем совершается контрольное считывание. Желательно, чтобы процедура writecode просто сохра­няла в буфере накопленный двоичный код и записывала файл на диск большими порциями, чтобы сократить рабочую нагрузку на диск.

Исходный оператор и выходной (объектный) код, полученный из него (в шес-

тнадцатеричной системе), можно напечатать или поместить в буфер, чтобы напе­чатать потом. После переустановки счетчика адреса команды вызывается следую­щий оператор.

До настоящего момента предполагалось, что исходная программа не содержит

никаких ошибок. Но любой человек, который когда-нибудь писал программы на

каком-либо языке, знает, насколько это предположение не соответствует действи­тельности. Наиболее распространенные ошибки приведены ниже:

  1. Используемый символ не определен.

  2. Символ был определен более одного раза.

  3. Имя в поле кода операции не является допустимым кодом операции.

  4. Код операции не снабжен достаточным количеством операндов.

  5. У кода операции слишком много операндов.

  6. Восьмеричное число содержит 8 или 9.

  7. Недопустимое применение регистра (например, переход к регистру).

  8. Отсутствует оператор END.

Программисты весьма изобретательны по части новых ошибок. Ошибки с не­определенным символом часто возникают из-за опечаток. Хороший ассемблер может вычислить, какой из всех определенных символов в большей степени соот­ветствует неопределенному, и подставить его. Для исправления других ошибок ничего кардинального предложить нельзя. Лучшее, что может сделать ассемблер

при обнаружении оператора с ошибкой, — это вывести сообщение об ошибке на экран и попробовать продолжить процесс ассемблирования.

Таблица символов

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

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

Проще всего реализовать таблицу символьных имен в виде массива пар, где пер­вый элемент является именем (или указателем на имя), а второй — значением (или указателем на него). Если нужно найти какой-нибудь символ, то таблица символь­ных имен просто последовательно просматривается, пока не будет найдено соответ­ствие. Такой метод довольно легко запрограммировать, но он медленно работает, по­скольку в среднем при каждом поиске придется просматривать половину таблицы.

Другой способ организации — отсортировать таблицу по именам и для поиска имен использовать алгоритм двоичного поиска. В соответствии с этим алгорит­мом средний элемент таблицы сравнивается с символьным именем. Если нужное имя по алфавиту идет раньше среднего элемента, значит, оно находится в первой половине таблицы. Если символьное имя по алфавиту идет после среднего эле­мента, значит, оно находится во второй части таблицы. Если нужное имя совпада­ет со средним элементом, то поиск на этом завершается.

Предположим, что средний элемент таблицы не равен символу, который мы

ищем. Мы уже знаем, в какой половине таблицы он находится. Алгоритм двоичного поиска можно применить к соответствующей половине. В результате мы либо полу­чим совпадение, либо определим нужную четверть таблицы. Таким образом, в таб­лице из п элементов нужный символ можно найти примерно за lo&n попыток. Оче­видно, что такой алгоритм работает быстрее, чем просто постедовательный просмотр таблицы, но при этом элементы таблицы нужно сохранять в алфавитном порядке.

Совершенно другой подход — хэш-кодирование. Для этого подхода требуется хэш-функция, которая отображает символы (имена) в целые числа в промежутке

от 0 до к-1. Такой функцией может быть функция перемножения кодов ASCII

всех символов в имени. Можно перемножить все коды ASCII символов с игнори­рованием переполнения, а затем взять значение по модулю к или разделить полу­ченное значение на простое число. Фактически подойдет любая входная функция, которая дает равномерное распределение значений.

Символьные имена можно хранить в таблице, состоящей из к участков, от 0 до к-1. Все пары (символьное имя, значение), в которых имя соответствует i, сохра­няются в связном списке, на который указывает слот i в хэш-таблице. Если в хэш-таблице содержится п символьных имен и к слотов, то в среднем длина списка будет n/k. Если мы выберем к, приблизительно равное п, то на нахождение нужно­го символьного имени в среднем потребуется всего один поиск. Путем корректи­ровки к мы можем сократить размер таблицы, но при этом скорость поиска сни­зится. Хэш-код показан на рис. 7.1.

Связывание и загрузка

Большинство программ содержат более одной процедуры. Компиляторы и ассем­блеры транслируют одну процедуру и помещают полученный на выходе результат на диск. Перед запуском программы должны быть найдены и связаны все оттран-

слированные процедуры. Если виртуальной памяти нет, связанная программадолж-на загружаться в основную память. Программы, которые выполняют эти функ­ции, называются по-разному: компоновщиками, связывающими загрузчиками и редакторами связей. Для полной трансляции исходной программы требуется два шага, как показано на рис. 7.2:

  1. Компиляция или ассемблирование исходных процедур.

  2. Связывание объектных модулей.

Andy 1 14025 I 0

Anton 31253 | 4

Cathy

65254

5

Dick

54185

0

Erik

47357

6

Frances 56445

3

Frank

14332

3

Gerrit

32334

4

Hans

44546

4

Henri

75544

2

Jan

17097

5

Jaco

64533

6

Maarten

23267

0

Reind

63453

1

Roel

76764

7

Willem

34544

6

Wiebern

34344

1

Хэш-таблица

Связная таблица

Reind | 63453 Henri 75544 Frances | 56445 |

Andy | 14025 | -Ы Maarten | 23267 |

Wiebern | 34344

3

Hans 44546

Ц->\ Frank | '14332 |~

Gerrit 32334

Jan j 17097 I *" H Cathy | 65254 |

4+-|

Dick

54185~ I I 4

3 _____

Anton 31253

Willem j 34544 j Erjk

"r7357

Roel

76764

Рис. 7.1. Хэш-кодирование: символьные имена, значения и хэш-коды, образованные от символьных имен (а); хэш-таблица из 8 элементов со связным списком символьных

имен и значений (б)

Первый шаг выполняется ассемблером или компилятором, а второй новщиком.

компо-

Трансляция исходной процедуры в объектном модуле — это переход на другой уровень, поскольку исходный язык и выходной язык имеют разные команды и за­пись. Однако при связывании перехода на другой уровень не происходит, поскольку программы на входе и на выходе компоновщика предназначены для одной и той же виртуальной машины. Задача компоновщика — собрать все процедуры, кото­рые транслировались раздельно, и связать их вместе, чтобы в результате получился исполняемый двоичный код. В системах MS-DOS, Windows 95/98 и NT объект­ные модули имеют расширение .obj, а исполняемые двоичные программы — рас­ширение .ехе. В системе UNIX объектные модули имеют расширение .о, а испол­няемые двоичные программы не имеют расширения.

Рис. 7.2. Для получения исполняемой двоичной программы из совокупности оттранслированных независимо друг от друга процедур используется компоновщик

Компиляторы и ассемблеры транслируют каждую исходную процедуру как от­дельную единицу. На это есть веская причина. Если компилятор или ассемблер считывал бы целый ряд исходных процедур и сразу переводил бы их в готовую программу на машинном языке, то при изменении одного оператора в исходной процедуре потребовалось бы заново транслировать все исходные процедуры.

Если каждая процедура транслируется по отдельности, как показано на рис. 7.2, то транслировать заново нужно будет только одну измененную процедуру, хотя

понадобится заново связать все объектные модули. Однако связывание происхо­дит гораздо быстрее, чем трансляция, поэтому выполнение этих двух шагов (транс­ляции и связывания) сэкономит время при доработке программы. Это особенно важно для программ, которые содержат сотни или тысячи модулей.

Задачи компоновщика

В начале первого прохода ассемблирования счетчик адреса команды устанавлива­ется на 0. Этот шаг эквивалентен предположению, что объектный модуль во время выполнения будет находиться в ячейке с адресом 0. На рис. 7.3 показаны 4 объек­тных модуля для типичной машины. В этом примере каждый модуль начинается с команды перехода ЕМЧН к команде МЖ в том же модуле.

Чтобы запустить программу, компоновщик помещает объектные модули в ос­новную память, формируя отображение исполняемого двоичного кода (рис. 7.4, а). Цель — создать точное отображение виртуального адресного пространства ис­

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

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

Объектный модуль В

400 300

200 100 0

Объектный модуль А

CALL В

MOVE P ТО X

BRANCH TO 200

еии

500

400 300

200

100

0

CALL С

MOVE Q ТО X

BRANCH TO 300

Объектный модуль С

500

400

CALLD

Объектный модуль D

300

300

MOVE R ТО X

200

MOVE S ТО X

200

100

100

BRANCH TO 200

BRANCH TO 200

Рис. 7.3. Каждый модуль имеет свое собственное адресное пространство, начинающееся с нуля

Посмотрите на рис. 7.4, а. Хотя программа уже загружена в отображение ис­полняемого двоичного файла, она еще не готова для выполнения. Посмотрим, что произойдет, если выполнение программы начнется с команды в начале модуля А. Программа не совершит перехода к команде TVOE, поскольку эта команда находит­ся в ячейке с адресом 300. Фактически все команды обращения к памяти не будут выполнены по той же причине.

1900 1800 1700 1600

1500

1400 1300

1200 1100

1000

900

800

700

600

500 400

300 200

100

MOVE S ТО X

BRANCH TO 200

CALLD

MOVE R ТО X

BRANCH TO 200

CALL С

MOVE Q ТО X

BRANCH TO 300

CALL В

MOVE P ТО X

BRANCH TO 200

Объектный У модуль D

Объектный

модуль С

Объектный / модуль В

Объектный

модуль А

1900

1800

1700

1600

1500 1400

13 0 0

1200

1100 1000

900

800

700

600

500 400

300 200

100

MOVE S ТО X

BRANCH ТО 1800

CALL 1600

MOVE R TO X

BRANCH TO 1300

CALL 1100

MOVE Q TO X

BRANCH TO 800

CALL 500

MOVE P TO X

BRANCH TO 300

Объектный

/" модуль D

V Объектный

/ модуль С

V Объектный } модуль В

Объектный

модуль А

п

0

Рис. 7.4. Объектные модули после размещения в двоичном отображении, но до перераспределения памяти и связывания (а); те же объектные модули после связывания и перераспределения памяти (б). В результате получается исполняемая двоичная программа, которую можно запускать

Здесь возникает проблема перераспределения памяти, поскольку каждый объектный модуль на рис. 7.3 занимает отдельное адресное пространство. В маши­не с сегментированным адресным пространством (например, в Pentium II) каж­дый объектный модуль теоретически может иметь свое собственное адресное про­странство, если его поместить в отдельный сегмент. Однако для Pentium II только система OS/2 поддерживает такую структуру1. Все версии Windows и UNIX под­держивают только одно линейное адресное пространство, поэтому все объектные модули должны быть слиты вместе в одно адресное пространство.

Более того, команды вызова процедур (см. рис. 7.4, а) вообще не будут рабо­тать. В ячейке с адресом 400 программист намеревается вызвать объектный мо­дуль В, но поскольку каждая процедура транслируется отдельно, ассемблер не может определить, какой адрес вставлять в команду C4IL В. Адрес объектного мо­дуля В не известен до времени связывания. Такая проблема называется пробле­мой внешней ссылки. Обе проблемы решаются с помощью компоновщика.

Компоновщик сливает отдельные адресные пространства объектных модулей в единое линейное адресное пространство. Для этого совершаются следующие шаги:

  1. Компоновщик строит таблицу объектных модулей и их длин.

  2. На основе этой таблицы он приписывает начальные адреса каждому объект­ному модулю.

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

  4. Компоновщик находит все команды, которые обращаются к процедурам,

и вставляет в них адрес этих процедур.

Ниже показана таблица объектных модулей рис. 7.4, построенная на первом

шаге. В ней дается имя, длина и начальный адрес каждого модуля.

Модуль Длина Начальный адрес

А 400 100

В 600 500

С 500 1100

D 300 1600

На рисунке 7.4, б показано, как адресное пространство выглядит после выпол­нения компоновщиком всех шагов.

Структура объектного модуля

Объектные модули обычно состоят из шести частей (рис. 7.5). В первой части

1 Необходимо отметить, что сегментный способ организации был использован только в первой версии OS/2, которая была 16-битовой и разрабатываласьдля 286-го микропроцессора. Поэтому относить эту систему к Pentium II представляется не вполне правильно. Начиная с 1993 годавсе последующие версии OS/2 были 32-битовыми и, как и остальные современные операционные системы, перестали поддер­живать сегментирование, а стали использовать только страничный механизм. — Примеч. научн.ред.

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

Конец модуля

Словарь перемещений

Машинные команды и константы

Таблица внешних ссылок Таблица точек входа Идентификация

Рис. 7.5. Внутренняя структура объектного модуля

Вторая часть объектного модуля — это список символов, определенных в моду­ле, вместе с их значениями. К этим символам могут обращаться другие модули.

Например, если модуль состоит из процедуры bigbug, то элемент таблицы будет

содержать цепочку символов «bigbug», за которой будет следовать соответствую­щий адрес. Программист на языке ассемблера с помощью директивы FUBLC указы­вает, какие символьные имена считаются точками входа.

Третья часть объектного модуля состоит из списка символьных имен, которые используются в этом модуле, но определены в других модулях. Здесь также имеет­ся список, который показывает, какие именно символьные имена используются теми или иными машинными командами. Второй список нужен для того, чтобы компоновщик мог вставить правильные адреса в команды, которые используют внешние имена. Процедура может вызывать другие независимо транслируемые процедуры, объявив имена вызываемых процедур внешними. Программист на язы­ке ассемблера с помощью директивы EXIERN указывает, какие символы нужно объя­вить внешними. В некоторых компьютерах точки входа и внешние ссылки объеди­нены в одной таблице.

Третья часть объектного модуля — это машинные команды и константы. Это единственная часть объектного модуля, которая будет загружаться в память для выполнения. Остальные 5 частей используются компоновщиком, а затем отбрасы­ваются еще до начала выполнения программы.

Пятая часть объектного модуля — это словарь перемещений. К командам, ко­торые содержат адреса памяти, должна прибавляться константа перемещения

(см. рис. 7.4). Компоновщик сам не может определить, какие слова в четвертой

части содержат машинные команды, а какие — константы. Поэтому в этой таблице содержится информация о том, какие адреса нужно переместить. Это может быть

битовая таблица, где на каждый бит приходится потенциально перемещаемый

адрес, либо явный список адресов, которые нужно переместить.

Шестая часть содержит указание на конец модуля, а иногда — контрольную сумму для определения ошибок, сделанных во время чтения модуля, и адрес, с кото­рого нужно начинать выполнение.

Большинству компоновщиков требуется два прохода. На первом проходе компоновщик считывает все объектные модули и строит таблицу имен и длин мо­дулей и глобальную таблицу символов, которая состоит из всех точек входа и вне­шних ссылок. На втором проходе модули считываются, перемещаются в памяти и связываются.

Время принятия решения и динамическое перераспределение памяти

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

На рис. 7.6 показано, что произойдет, если уже перемещенная программа (см. рис. 7.4, б) будет загружена в адрес 400, а не в адрес 100, куда ее изначально по­местил компоновщик. Все адреса памяти будут неправильными. Более того,

информация о перемещении уже давно удалена. Даже если эта информация была

бы доступна, перемещать все адреса при каждой перекачке программы было бы неудобно.

Проблема перемещения программ, уже связанных и размещенных в памяти, близко связана с моментом времени, в который совершается финальное связыва­ние символических имен с абсолютными адресами физической памяти. В програм­ме содержатся символические имена для адресов памяти (например, BR L). Время, в которое определяется адрес в основной памяти, соответствующий L, называется временем принятия решения. Существует по крайней мере шесть вариантов для времени принятия решения относительно привязок:

  1. Когда пишется программа.

  2. Когда программа транслируется.

  3. Когда программа компонуется, но еще до загрузки.

  4. Когда программа загружается.

  5. Когда загружается базовый регистр, который используется для адресации.

  6. Когда выполняется команда, содержащая адрес.

Если команда, содержащая адрес памяти, перемещается после связывания, этот

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

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

Здесь возникают два вопроса. Первый — когда символические имена связыва­ются с виртуальными адресами, а второй — когда виртуальные адреса связыва­ются с физическими адресами? Только после двух этих операций процесс связы­вания можно считать завершенным. Когда компоновщик связывает отдельные адресные пространства объектных модулей в единое линейное адресное простран­ство, он фактически создает виртуальное адресное пространство. Перемещение в памяти и связывание нужно для связи символических имен с определенными виртуальными адресами. Это наблюдение верно независимо от того, используется виртуальная память или нет.

2200 2100 2000 1900

1800

1700 1600

1500

1400

1300

1200

1100 1000

900

800

700

600

500 400

MOVE S ТО X

BRANCH TO 1800

CALL 1800

MOVE R TO X

BRANCH TO 1300

CALL 1100

MOVE Q TO X

BRANCH TO 800

CALL 500

MOVE P TO X

BRANCH TO 300

>

Объектный модуль D

Объектный

модуль С

Объектный

модуль В

Объектный

модуль А

О I 1

Рис. 7.6. Двоичная программа с рис. 7.4, б, передвинутая вверх на 300 адресов. Многие команды теперь обращаются к неправильным адресам памяти

Предположим, что адресное пространство, изображенное на рис. 7.4, б, было разбито на страницы. Ясно, что виртуальные адреса, соответствующие символи­ческим именам А, В, С и D, уже определены, хотя их физические адреса будут за­висеть от содержания таблицы страниц. Исполняемая двоичная программа пред­ставляет собой связывание символических имен с виртуальными адресами.

Любой механизм, который позволяет легко изменять отображение виртуаль­ных адресов на адреса основной физической памяти, будет облегчать перемещение программы в основной памяти, даже если они уже связаны с виртуальным адрес­ным пространством. Одним из таких механизмов является разбиение на страни­цы. Если программа перемещается в основной памяти, нужно изменить только ее таблицу страниц, но не саму программу.

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

начала текущей программы. Аппаратное обеспечение прибавляет регистр переме­щения ко всем адресам памяти, прежде чем отправить их в память. Весь процесс перемещения является «прозрачным» для каждой пользовательской программы. Пользовательские программы даже не подозревают, что этот процесс происходит. Если программа перемещается, операционная система должна обновить регистр перемещения. Такой механизм менее обычен, чем разбиение на страницы, посколь­ку перемещаться должна вся программа целиком (однако если есть отдельные ре­гистры для перемещения кода и перемещения данных, как, например, в процессо­ре Intel 8088, то в этом случае программу нужно перемещать как два компонента).

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

(например, обращения к регистрам устройств ввода-вывода в абсолютных адре­сах), называется позиционно-независимой программой. Позиционно-независи-мую процедуру можно поместить в любом месте виртуального адресного простран­ства без настройки адресов.

Динамическое связывание

Стратегия связывания, которую мы обсуждали в разделе «Задачи компоновщи­ка», имеет одну особенность: связь со всеми процедурами, нужными программе, устанавливается до начала работы программы. Однако если мы будем устанавли­вать все связи до начала работы программы в компьютере с виртуальной памятью, то мы не используем всех возможностей виртуальной памяти. Многие программы содержат процедуры, которые вызываются только при определенных обстоятель­ствах. Например, компиляторы содержат процедуры для компиляции редко ис­пользуемых операторов, а также процедуры для исправления ошибок, которые встречаются редко.

Более гибкий способ связывания отдельно скомпилированных процедур — уста­новление связи с каждой процедурой в тот момент, когда она впервые вызывается. Этот процесс называется динамическим связыванием. Впервые он был применен в системе MULTICS. В следующих разделах мы рассмотрим применение динами­ческого связывания в нескольких системах.

Динамическое связывание в системе MULTICS

В системе MULTICS с каждой программой соотносится сегмент, так называемый сегмент связи, содержащий один блок информации для каждой процедуры, кото­

рая может быть вызвана. Этот блок информации начинается со слова, зарезерви­рованного для виртуального адреса процедуры, а за ним следует имя процедуры, которое сохраняется в виде цепочки символов.

Сегмент процедуры А ^ Сегмент связывания

Косвенная адресация

Слово с косвенным адресом

CALL EARTH

Косвенная адресация

Косвенная адресация

CALL WATER

Косвенная адресация

CALL EARTH

W

CALL FIRE

CALL ATR

EIAIRM »У/

1 R

I Информация о связывании для процедуры AIR

f I 1 I R I Е У/%Уг- Имя процедуры хранится

AT Е R У/у


в виде цепочки символов

CALL WATER

Сегмент процедуры А

Сегмент связывания

Адрес процедуры EARTH

E | A | R | T | H

CALL EARTH

Косвенная адресация

Косвенная адресация

CALL WATER

Косвенная адресация

CALL EARTH

W | A | T | E 1 R

CALL WATER

CALL FIRE

CALL ATR

R

R Е

Связан с процедурой EARTH

Рис. 7.7. Динамическое связывание: до вызова процедуры EARTH (a); после того как процедура EARTH была вызвана и связана (б)

При применении динамического связывания вызовы процедур во входном язы­ке транслируются в команды!, которые с помощью косвенной адресации обраща­ются к первому слову соответствующего блока, как показано на рис. 7.7, а. Компи­лятор заполняет это слово либо недействительным адресом, либо специальным

набором битов, который вызывает системное прерывание (ловушку).

Когда вызывается процедура в другом сегменте, попытка косвенно обратиться к недействительному слову вызывает системное прерывание компоновщика. За­тем компоновщик находит цепочку символов в слове, которое следует за недей­ствительным адресом, и начинает искать пользовательскую директорию для ском­пилированной процедуры с таким именем. Затем этой процедуре приписывается виртуальный адрес (обычно в ее собственном сегменте), и этот виртуальный адрес записывается поверх недействительного адреса, как показано на рис. 7.7, б. После этого команда, которая вызвала ошибку, выполняется заново, что позволяет программе продолжать работу с того места, где она находилась до системного пре­рывания.

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

Динамическое связывание в системе Windows

Все версии операционной системы Windows, в том числе NT, поддерживают дина­мическое связывание. При динамическом связывании используется специальный файловый формат, который называется DLL (Dynamic Link Library динамиче­ски подключаемая библиотека). Динамически подключаемые библиотеки могут содержать процедуры, данные или и то и другое вместе. Обычно они используют­ся для того, чтобы два и более процессов могли разделять процедуры и данные библиотеки. Большинство файлов DDL имеют расширение .сШ, но встречаются и другие расширения, например .drv (для библиотек драйверов — driver libraries) и .fon (для библиотек шрифтов — font libraries).

Самая распространенная форма динамически подключаемой библиотеки —

библиотека, состоящая из набора процедур, которые могут загружаться в память и к которым имеют доступ несколько процессов одновременно. На рис. 7.8 показа­ны два процесса, которые разделяют файл DLL, содержащий 4 процедуры, А, В, С и D. Программа 1 использует процедуру А; программа 2 использует процедуру С, хотя они вполне могли бы использовать одну и ту же процедуру.

Файл DLL строится компоновщиком из коллекции входных файлов. Построе­ние файла DDL очень похоже на построение исполняемого двоичного кода, только при создании файла DLL компоновщику передается специальный флаг, который сообщает ему, что создается именно файл DLL. Файлы DLL обычно конструиру­ются из набора библиотечных процедур, которые могут понадобиться нескольким процессорам. Типичными примерами файлов DLL являются процедуры сопряже­ния с библиотекой системных вызовов Windows и большими графическими биб­лиотеками. Применяя файлы DDL, мы можем сэкономить пространство в памяти и на диске. Если какая-то библиотека была связана с каждой программой, ис­пользующей ее, то она будет появляться во многих исполняемых двоичных про­граммах в памяти и на диске, а забивать пространство такими дубликатами неэко­номно. Если мы будем использовать файлы DLL, то каждая библиотека будет по­являться один раз на диске и один раз в памяти.

Пользовательсий процесс 1

Пользовательский процесс 2

dll

Заголовок

В

d

Рис. 7.8. Два процесса используют один файл DLL

Этот подход, кроме того, упрощает обновление библиотечных процедур и позво­ляет осуществлять обновление процедур, даже после того как программы, исполь­зующие их, были скомпилированы и связаны. Для коммерческих программных пакетов, где пользователям обычно недоступна входная программа, использова­ние файлов DLL означает, что поставщик программного обеспечения может обна­руживать ошибки в библиотеках и исправлять положение, просто распространяя новые файлы DLL по Интернету, причем при этом не требуется производить ни­каких изменений в основных бинарных программах.

Основное различие между файлом DLL и исполняемой двоичной программой состоит в том, что файл DLL не может запускаться и работать сам по себе (по­скольку у него нет ведущей программы). Он также содержит совершенно другую информацию в заголовке. Кроме того, файл DLL имеет несколько дополнитель­ных процедур, не связанных с процедурами в библиотеке. Например, существует одна процедура, которая вызывается автоматически всякий раз, когда новый про­цесс связывается с файлом DLL, и еще одна процедура, которая вызывается авто­матически всякий раз, когда связь процесса с файлом DLL отменяется. Эти про­цедуры могут выделять и освобождать память или управлять другими ресурсами,

которые необходимы файлу DLL.

Программа может установить связь с файлом DLL двумя способами: с помо­щью неявного связывания и с помощью явного связывания. При неявном связы­вании пользовательская программа статически связывается со специальным фай­лом, так называемой библиотекой импорта, которая образована обслуживающей программой (утилитой), извлекающей определенную информацию из файла DLL. Библиотека импорта обеспечивает связующий элемент, который позволяет пользо­вательской программе получать доступ к файлу DLL. Пользовательская програм­ма может быть связана с несколькими библиотеками импорта. Когда программа, которая применяет неявное связывание, загружается в память для выполнения, система Windows проверяет, какие файлы DLL использует эта программа и все ли эти файлы уже находятся в памяти. Те файлы, которых еще нет в памяти, загружа­ются туда немедленно (но необязательно целиком, поскольку они разбиты на стра­ницы). Затем производятся некоторые изменения в структурах данных в биб­лиотеках импорта так, чтобы можно было определить местоположение вызываемых процедур (это похоже на изменения, показанные на рис. 7.7). Их тоже нужно отобразить в виртуальное адресное пространство программы. С этого момента пользовательскую программу можно запускать. Теперь она может вызывать про­цедуры в файлах DLL, как будто они статически связаны с ней.

Альтернативой неявного связывания является явное связывание. Такой под­ход не требует библиотек импорта, и при нем не нужно загружать файлы DLL одновременно с пользовательской программой. Вместо этого пользовательская

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

финальный вызов, чтобы разорвать связь с файлом DLL Когда последний про­цесс разрывает связь с файлом DLL, этот файл может быть удален из памяти.

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

Динамическое связывание в системе UNIX

В системе UNIX используется механизм, по сути сходный с файлами DLL в Win­dows. Это библиотека коллективного доступа. Как и файл DLL, библиотека кол­лективного доступа представляет собой архивный файл, содержащий несколько

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

коллективного доступа.

Система UNIX поддерживает только неявное связывание, поэтому библиотека коллективного доступа состоит из двух частей: главной библиотеки (host library), которая статически связана с исполняемым файлом, и целевой библиотеки (target library), которая вызывается во время работы программы. Несмотря на некоторые различия в деталях, по существу это то же, что файлы DLL.

Краткое содержание главы

Хотя большинство программ можно и нужно писать на языках высокого уровня, существуют такие ситуации, в которых необходимо применять язык ассемблера, по крайней мере отчасти. Это программы для компьютеров с недостаточным коли­чеством ресурсов (например, кредитные карточки, различные приборы и портатив­ные цифровые устройства). Программа на языке ассемблера — это символическая репрезентация программы на машинном языке. Она транслируется на машинный

язык специальной программой, которая называется ассемблером.

Если для успеха какого-либо аппарата требуется быстрое выполнение програм­мы, то лучше всего сначала написать программу на языке высокого уровня, затем путем измерений установить, выполнение какой части программы занимает боль­шую часть времени, и переписать на языке ассемблера только эту часть програм­мы. Практика показывает, что часто небольшая часть всей программы занимает большую часть всего времени выполнения этой программы.

Во многих ассемблерах предусмотрены макросы, которые позволяют програм­мистам давать символические имена целым последовательностям команд. Обыч­но эти макросы могут быть параметризированы прямым путем. Макросы реализу­ются с помощью алгоритма обработки строковых литералов.

Большинство ассемблеров двухпроходные. Во время первого прохода строится таблица символов для меток, литералов и объявляемых идентификаторов. Сим­вольные имена можно либо не сортировать и искать путем последовательного про­смотра таблицы, либо сначала сортировать, а потом применять двоичный поиск, либо хэшировать. Если символьные имена не нужно удалять во время первого про­хода, то хэширование — это лучший метод. Во время второго прохода происходит порождение программы. Одни директивы выполняются при первом проходе, а другие — при втором.

Программы, которые ассемблируются независимо друг от друга, можно свя­зать вместе и получить исполняемую двоичную программу, которую можно запус­кать. Эту работу выполняет компоновщик. Его задачи — это перемещение в памя­ти и связывание имен. Динамическое связывание — это технология, при которой определенные процедуры не связываются до тех пор, пока они не будут вызваны. Библиотеки коллективного пользования в системе UNIX и файлы DLL (динами­чески подсоединяемые библиотеки) в системе Windows используют технологию динамического связывания.

Вопросы и задания

1. 1% определенной программы отвечает за 50% времени выполнения этой программы. Сравните следующие три стратегии с точки зрения времени программирования и времени выполнения. Предположим, что для написа- ния программы на языке С потребуется 100 человеко-месяцев, а программу на языке ассемблера написать в 10 раз труднее, но зато она работает в 4 раза эффективнее.

  1. Вся программа написана на языке С.

  2. Вся программа написана на ассемблере.

  3. Программа сначала написана на С, а затем нужный 1% программы пере­писан на ассемблере.

2. Для двухпроходных ассемблеров существуют определенные соглашения.

Подходят ли они для компиляторов?

  1. Придумайте, как программисты на языке ассемблера могут определять си­нонимы для кодов операций. Как это можно реализовать?

  2. Все ассемблеры для процессоров Intel имеют в качестве первого операнда адрес назначения, а в качестве второго — исходный адрес. Какие проблемы могут возникнуть при другом подходе?

  3. Можно ли следующую программу ассемблировать в два прохода? Примеча­ние: HU — это директива, которая приравнивает метку и выражение в поле операнда.

A EQU В В EQU С С EQU D О EQU 4

  1. Одна компания планирует разработать ассемблер для компьютера с 40-бит­ным словом. Чтобы снизить стоимость, менеджер проекта, доктор Скрудж, решил ограничить длину символьных имен, чтобы каждое имя можно было хранить в одном слове. Скрудж объявил, что символьные имена могут со­стоять только из букв, причем буква Q запрещена. Какова максимальная длина символьного имени? Опишите вашу схему кодировки.

  2. Чем отличается команда от директивы?

  3. Чем отличается счетчик адреса команд от счетчика команд? А существует

ли вообще между ними различие? Ведь и тот и другой следят за следующей командой в программе.

9. Какой будет таблица символов (имен) после обработки следующих опера- торов ассемблера для Pentium II (первому оператору приписан адрес 1000)?

EVEREST: POP BX (1 байт) К2: PUSH BP (1 байт) WHITNEY: MOV BP.SP (2 байта) MCKINLEY: PUSH X (3 байта) FUJI: PUSH SI (1 байт) KIBO: SUB SI.300 (3 байта)

  1. Можете ли вы представить себе обстоятельства, при которых метка совпа­дет с кодом операции (например, может ли быть МУ меткой)? Аргументи­руйте.

  2. Какие шаги нужно совершить, чтобы, используя двоичный поиск, найти эле­мент «Berkeley» в следующем списке: Ann Arbor, Berkeley, Cambridge, Eugene, Madison, New Haven, Palo Alto, Pasadena, Santa Cruz, Stony Brook, Westwood, Yellow Springs. Когда будете вычислять средний элемент в списке из четно­го числа элементов, возьмите элемент, который идет сразу после среднего индекса.

  3. Можно ли использовать двоичный поиск в таблице, в которой содержится

простое число элементов?

13. Вычислите хэш-код для каждого из следующих символьных имен. Для это- го сложите буквы (А=1, В=2 и т. д.) и возьмите результат по модулю разме- ра хэш-таблицы. Хэш-таблица содержит 19 слотов (от 0 до 18).

els, jan, jelle, maaike

Образует ли каждое символьное имя уникальное значение хэш-функции? Если нет, то как можно разрешить эту коллизию?

14. Метод хэш-кодирования, описанный в тексте, связывает все элементы, име- ющие один хэш-код, в связном списке. Альтернативный метод — иметь толь- ко одну таблицу из п слотов, в которой в каждом слоте имеется простран- ство для одного ключа и его значения (или для указателей на них). Если алгоритм хэширования порождает слот, который уже заполнен, производится вторая попытка с использованием того же алгоритма хэширования. Если и на этот раз слот заполнен, алгоритм используется снова и т. д. Так продол- жается до тех пор, пока не будет найден пустой слот. Если доля слотов, кото- рые уже заполнены, составляет R, сколько попыток в среднем понадобится

для того, чтобы ввести в таблицу новый символ?

  1. Вероятно, когда-нибудь в будущем на одну микросхему можно будет поме­щать тысячи идентичных процессоров, каждый из которых содержит несколь­ко слов локальной памяти. Если все процессоры могут считывать и записы­вать три общих регистра, то как можно реализовать ассоциативную память?

  2. Pentium II имеет сегментированную архитектуру. Сегменты независимы. Ассемблер для этой машины может содержать директиву SKj N, которая по­мещает последующий код и данные в сегмент N. Повлияет ли такая схема на

счетчик адреса команды?

17. Программы часто связаны с многочисленными файлами DLL (динамичес- ки подсоединяемыми библиотеками). А не будет ли более эффективным

просто поместить все процедуры в один большой файл DLL, а затем устано­вить связь с ним?

  1. Можно ли отобразить файл DLL в виртуальные адресные пространства двух процессов с разными виртуальными адресами? Если да, то какие проблемы при этом возникают? Можно ли их разрешить? Если нет, то что можно сде­лать, чтобы устранить их?

  2. Опишем один из способов связывания. Перед сканированием библиотеки компоновщик составляет список необходимых процедур, то есть имен, ко­торые в связываемых модулях определены как внешние (EXTERN). Затем компоновщик последовательно просматривает всю библиотеку, извлекая каждую процедуру, которая находится в списке нужных имен. Будет ли ра­ботать такая схема? Если нет, то почему, и как это можно исправить?

  3. Может ли регистр использоваться в качестве фактического параметра в мак­ровызове? А константа? Если да, то почему. Если нет, то почему.

  4. Вам нужно реализовать макроассемблер. Из эстетических соображений ваш начальник решил, что макроопределения не должны предшествовать вызо­вам макросов. Как повлияет это решение на реализацию?

  5. Подумайте, как можно поместить макроассемблер в бесконечный цикл.

  6. Компоновщик считывает 5 модулей, длины которых составляют 200, 800,

600, 500 и 700 слов соответственно. Если они загружаются в этом порядке,

то каковы константы перемещения?

Вопросы и задания 555

  1. Напишите модуль таблицы символов, состоящий из двух процедур: enterisymbol, value) и lookup(symbol, value). Первый вводит новые символьные имена в таб­лицу, а второй ищет их в таблице. Используйте какую-либо хэш-кодировку.

  2. Напишите простой ассемблер для компьютера Mic-1, о котором мы говори­ли в главе 4. Помимо оперирования машинными командами обеспечьте воз­можность приписывать константы символьным именам во время ассембли­рования, а также способ ассемблировать константу в машинное слово.

  3. Добавьте макросы к ассемблеру, который вы должны были написать, вы­полняя предыдущее задание.