
- •Э. Таненбаум
- •Глава 2. Организация компьютерных систем 56
- •Глава 3. Цифровой логический уровень 139
- •Глава 4. Микроархитектурный уровень 230
- •Глава 5. Уровень архитектуры команд 334
- •Глава 6. Уровень операционной системы 437
- •Глава 7. Уровень языка ассемблера 517
- •Глава 8. Архитектуры компьютеров параллельного
- •Глава 9. Библиография 647
- •Глава 8 (архитектура компьютеров параллельного действия) полностью изменена. В ней подробно описываются мультипроцессоры (uma, numa и сома) и мультикомпьютеры (мрр и cow).
- •Глава 1
- •Глава 2 знакомит читателей с основными компонентами компьютера: процессорами, памятью, устройствами ввода-вывода. В ней дается краткое описание системной архитектуры и введение к следующим главам.
- •Глава 2
- •Центральный процессор Центральный процессор
- •12 Битов б
- •24 Входные линии
- •50 Входных линий
- •Глава 4
- •Старший бит
- •Блок выборки команд
- •Сигналы управления
- •Глава 5
- •Intel ia-64
- •Глава 6
- •Глава 7
- •3. Сведения о том, можно ли получить доступ к символу извне процедуры.
- •Глава 8
- •64 Элемента на каждый регистр
- •Intel/Sandia Option Red
- •00 Процессор 2
- •Глава 9
- •4. Mazidi and Mazidi, The 80x86ibm pc and Compatible Computers, 2nd ed.
- •5. McKee et al., Smarter Memory: ImprovingBandwidthforStreamed References.
- •4. McKusick et al., Design and Implementation ofthe 4.4bsd Operating System.
- •3. Hill, Multiprocessors Should Support Simple Memory-Consistency Models.
- •Ieee Scalable Coherent Interface Working Group, ieee, 1989.
- •Ieee Micro Magazine, vol. 18, p. 60-75, July/Aug. 1998b.
- •3Rd ed., Reading, ma: Addison-Wesley, 1998.
- •1988 Int'l. Conf. On Parallel Proc. (Vol. 11), ieee, p. 94-101, 1988.
- •Implementation of the 4.4 bsd Operating System», Reading, ma: Addison-Wesley, 1996.
- •In Shared Memory Multiprocessing», ieee Computer Magazine, vol. 30, p. 4450, Dec. 1997.
- •78Jan.-March 1999.
- •0 123456789Abcdef
- •I и Ijmii him
- •Э. Таненбаум
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 просто сохраняла в буфере накопленный двоичный код и записывала файл на диск большими порциями, чтобы сократить рабочую нагрузку на диск.
Исходный оператор и выходной (объектный) код, полученный из него (в шес-
тнадцатеричной системе), можно напечатать или поместить в буфер, чтобы напечатать потом. После переустановки счетчика адреса команды вызывается следующий оператор.
До настоящего момента предполагалось, что исходная программа не содержит
никаких ошибок. Но любой человек, который когда-нибудь писал программы на
каком-либо языке, знает, насколько это предположение не соответствует действительности. Наиболее распространенные ошибки приведены ниже:
Используемый символ не определен.
Символ был определен более одного раза.
Имя в поле кода операции не является допустимым кодом операции.
Код операции не снабжен достаточным количеством операндов.
У кода операции слишком много операндов.
Восьмеричное число содержит 8 или 9.
Недопустимое применение регистра (например, переход к регистру).
Отсутствует оператор END.
Программисты весьма изобретательны по части новых ошибок. Ошибки с неопределенным символом часто возникают из-за опечаток. Хороший ассемблер может вычислить, какой из всех определенных символов в большей степени соответствует неопределенному, и подставить его. Для исправления других ошибок ничего кардинального предложить нельзя. Лучшее, что может сделать ассемблер
при обнаружении оператора с ошибкой, — это вывести сообщение об ошибке на экран и попробовать продолжить процесс ассемблирования.
Таблица символов
Во время первого прохода ассемблер аккумулирует всю информацию о символах и их значениях. Эту информацию он должен сохранить в таблице символьных имен, к которой будет обращаться при втором проходе. Таблицу символьных имен можно организовать несколькими способами. Некоторые из них мы опишем ниже.
При применении любого из этих способов мы пытаемся смоделировать ассоциативную память, которая представляет собой набор пар (символьное имя, значение). По имени ассоциативная память должна выдавать его значение.
Проще всего реализовать таблицу символьных имен в виде массива пар, где первый элемент является именем (или указателем на имя), а второй — значением (или указателем на него). Если нужно найти какой-нибудь символ, то таблица символьных имен просто последовательно просматривается, пока не будет найдено соответствие. Такой метод довольно легко запрограммировать, но он медленно работает, поскольку в среднем при каждом поиске придется просматривать половину таблицы.
Другой способ организации — отсортировать таблицу по именам и для поиска имен использовать алгоритм двоичного поиска. В соответствии с этим алгоритмом средний элемент таблицы сравнивается с символьным именем. Если нужное имя по алфавиту идет раньше среднего элемента, значит, оно находится в первой половине таблицы. Если символьное имя по алфавиту идет после среднего элемента, значит, оно находится во второй части таблицы. Если нужное имя совпадает со средним элементом, то поиск на этом завершается.
Предположим, что средний элемент таблицы не равен символу, который мы
ищем. Мы уже знаем, в какой половине таблицы он находится. Алгоритм двоичного поиска можно применить к соответствующей половине. В результате мы либо получим совпадение, либо определим нужную четверть таблицы. Таким образом, в таблице из п элементов нужный символ можно найти примерно за lo&n попыток. Очевидно, что такой алгоритм работает быстрее, чем просто постедовательный просмотр таблицы, но при этом элементы таблицы нужно сохранять в алфавитном порядке.
Совершенно другой подход — хэш-кодирование. Для этого подхода требуется хэш-функция, которая отображает символы (имена) в целые числа в промежутке
от 0 до к-1. Такой функцией может быть функция перемножения кодов ASCII
всех символов в имени. Можно перемножить все коды ASCII символов с игнорированием переполнения, а затем взять значение по модулю к или разделить полученное значение на простое число. Фактически подойдет любая входная функция, которая дает равномерное распределение значений.
Символьные имена можно хранить в таблице, состоящей из к участков, от 0 до к-1. Все пары (символьное имя, значение), в которых имя соответствует i, сохраняются в связном списке, на который указывает слот i в хэш-таблице. Если в хэш-таблице содержится п символьных имен и к слотов, то в среднем длина списка будет n/k. Если мы выберем к, приблизительно равное п, то на нахождение нужного символьного имени в среднем потребуется всего один поиск. Путем корректировки к мы можем сократить размер таблицы, но при этом скорость поиска снизится. Хэш-код показан на рис. 7.1.
Связывание и загрузка
Большинство программ содержат более одной процедуры. Компиляторы и ассемблеры транслируют одну процедуру и помещают полученный на выходе результат на диск. Перед запуском программы должны быть найдены и связаны все оттран-
слированные процедуры. Если виртуальной памяти нет, связанная программадолж-на загружаться в основную память. Программы, которые выполняют эти функции, называются по-разному: компоновщиками, связывающими загрузчиками и редакторами связей. Для полной трансляции исходной программы требуется два шага, как показано на рис. 7.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 |
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 В. Адрес объектного модуля В не известен до времени связывания. Такая проблема называется проблемой внешней ссылки. Обе проблемы решаются с помощью компоновщика.
Компоновщик сливает отдельные адресные пространства объектных модулей в единое линейное адресное пространство. Для этого совершаются следующие шаги:
Компоновщик строит таблицу объектных модулей и их длин.
На основе этой таблицы он приписывает начальные адреса каждому объектному модулю.
Компоновщик находит все команды, которые обращаются к памяти, и прибавляет к каждой из них константу перемещения, которая равна начальному адресу этого модуля.
Компоновщик находит все команды, которые обращаются к процедурам,
и вставляет в них адрес этих процедур.
Ниже показана таблица объектных модулей рис. 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, называется временем принятия решения. Существует по крайней мере шесть вариантов для времени принятия решения относительно привязок:
Когда пишется программа.
Когда программа транслируется.
Когда программа компонуется, но еще до загрузки.
Когда программа загружается.
Когда загружается базовый регистр, который используется для адресации.
Когда выполняется команда, содержащая адрес.
Если команда, содержащая адрес памяти, перемещается после связывания, этот
адрес будет неправильным (предполагается, что объект, на который происходит ссылка, тоже перемещен). Если транслятор производит исполняемый двоичный
код, то связывание происходит во время трансляции и программа должна быть запущена в адресе, в котором этого ожидает транслятор. При применении метода, описанного в предыдущем разделе, во время связывания символические имена соотносятся с абсолютными адресами, и именно по этой причине перемещать программы после связывания нельзя (см. рис. 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 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 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 в Windows. Это библиотека коллективного доступа. Как и файл DLL, библиотека коллективного доступа представляет собой архивный файл, содержащий несколько
процедур или модулей данных, которые присутствуют в памяти во время работы программы и одновременно могут быть связаны с несколькими процессами. Стандартная библиотека С и большинство сетевых программ являются библиотеками
коллективного доступа.
Система UNIX поддерживает только неявное связывание, поэтому библиотека коллективного доступа состоит из двух частей: главной библиотеки (host library), которая статически связана с исполняемым файлом, и целевой библиотеки (target library), которая вызывается во время работы программы. Несмотря на некоторые различия в деталях, по существу это то же, что файлы DLL.
Краткое содержание главы
Хотя большинство программ можно и нужно писать на языках высокого уровня, существуют такие ситуации, в которых необходимо применять язык ассемблера, по крайней мере отчасти. Это программы для компьютеров с недостаточным количеством ресурсов (например, кредитные карточки, различные приборы и портативные цифровые устройства). Программа на языке ассемблера — это символическая репрезентация программы на машинном языке. Она транслируется на машинный
язык специальной программой, которая называется ассемблером.
Если для успеха какого-либо аппарата требуется быстрое выполнение программы, то лучше всего сначала написать программу на языке высокого уровня, затем путем измерений установить, выполнение какой части программы занимает большую часть времени, и переписать на языке ассемблера только эту часть программы. Практика показывает, что часто небольшая часть всей программы занимает большую часть всего времени выполнения этой программы.
Во многих ассемблерах предусмотрены макросы, которые позволяют программистам давать символические имена целым последовательностям команд. Обычно эти макросы могут быть параметризированы прямым путем. Макросы реализуются с помощью алгоритма обработки строковых литералов.
Большинство ассемблеров двухпроходные. Во время первого прохода строится таблица символов для меток, литералов и объявляемых идентификаторов. Символьные имена можно либо не сортировать и искать путем последовательного просмотра таблицы, либо сначала сортировать, а потом применять двоичный поиск, либо хэшировать. Если символьные имена не нужно удалять во время первого прохода, то хэширование — это лучший метод. Во время второго прохода происходит порождение программы. Одни директивы выполняются при первом проходе, а другие — при втором.
Программы, которые ассемблируются независимо друг от друга, можно связать вместе и получить исполняемую двоичную программу, которую можно запускать. Эту работу выполняет компоновщик. Его задачи — это перемещение в памяти и связывание имен. Динамическое связывание — это технология, при которой определенные процедуры не связываются до тех пор, пока они не будут вызваны. Библиотеки коллективного пользования в системе UNIX и файлы DLL (динамически подсоединяемые библиотеки) в системе Windows используют технологию динамического связывания.
Вопросы и задания
1. 1% определенной программы отвечает за 50% времени выполнения этой программы. Сравните следующие три стратегии с точки зрения времени программирования и времени выполнения. Предположим, что для написа- ния программы на языке С потребуется 100 человеко-месяцев, а программу на языке ассемблера написать в 10 раз труднее, но зато она работает в 4 раза эффективнее.
Вся программа написана на языке С.
Вся программа написана на ассемблере.
Программа сначала написана на С, а затем нужный 1% программы переписан на ассемблере.
2. Для двухпроходных ассемблеров существуют определенные соглашения.
Подходят ли они для компиляторов?
Придумайте, как программисты на языке ассемблера могут определять синонимы для кодов операций. Как это можно реализовать?
Все ассемблеры для процессоров Intel имеют в качестве первого операнда адрес назначения, а в качестве второго — исходный адрес. Какие проблемы могут возникнуть при другом подходе?
Можно ли следующую программу ассемблировать в два прохода? Примечание: HU — это директива, которая приравнивает метку и выражение в поле операнда.
A EQU В В EQU С С EQU D О EQU 4
Одна компания планирует разработать ассемблер для компьютера с 40-битным словом. Чтобы снизить стоимость, менеджер проекта, доктор Скрудж, решил ограничить длину символьных имен, чтобы каждое имя можно было хранить в одном слове. Скрудж объявил, что символьные имена могут состоять только из букв, причем буква Q запрещена. Какова максимальная длина символьного имени? Опишите вашу схему кодировки.
Чем отличается команда от директивы?
Чем отличается счетчик адреса команд от счетчика команд? А существует
ли вообще между ними различие? Ведь и тот и другой следят за следующей командой в программе.
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 байта)
Можете ли вы представить себе обстоятельства, при которых метка совпадет с кодом операции (например, может ли быть МУ меткой)? Аргументируйте.
Какие шаги нужно совершить, чтобы, используя двоичный поиск, найти элемент «Berkeley» в следующем списке: Ann Arbor, Berkeley, Cambridge, Eugene, Madison, New Haven, Palo Alto, Pasadena, Santa Cruz, Stony Brook, Westwood, Yellow Springs. Когда будете вычислять средний элемент в списке из четного числа элементов, возьмите элемент, который идет сразу после среднего индекса.
Можно ли использовать двоичный поиск в таблице, в которой содержится
простое число элементов?
13. Вычислите хэш-код для каждого из следующих символьных имен. Для это- го сложите буквы (А=1, В=2 и т. д.) и возьмите результат по модулю разме- ра хэш-таблицы. Хэш-таблица содержит 19 слотов (от 0 до 18).
els, jan, jelle, maaike
Образует ли каждое символьное имя уникальное значение хэш-функции? Если нет, то как можно разрешить эту коллизию?
14. Метод хэш-кодирования, описанный в тексте, связывает все элементы, име- ющие один хэш-код, в связном списке. Альтернативный метод — иметь толь- ко одну таблицу из п слотов, в которой в каждом слоте имеется простран- ство для одного ключа и его значения (или для указателей на них). Если алгоритм хэширования порождает слот, который уже заполнен, производится вторая попытка с использованием того же алгоритма хэширования. Если и на этот раз слот заполнен, алгоритм используется снова и т. д. Так продол- жается до тех пор, пока не будет найден пустой слот. Если доля слотов, кото- рые уже заполнены, составляет R, сколько попыток в среднем понадобится
для того, чтобы ввести в таблицу новый символ?
Вероятно, когда-нибудь в будущем на одну микросхему можно будет помещать тысячи идентичных процессоров, каждый из которых содержит несколько слов локальной памяти. Если все процессоры могут считывать и записывать три общих регистра, то как можно реализовать ассоциативную память?
Pentium II имеет сегментированную архитектуру. Сегменты независимы. Ассемблер для этой машины может содержать директиву SKj N, которая помещает последующий код и данные в сегмент N. Повлияет ли такая схема на
счетчик адреса команды?
17. Программы часто связаны с многочисленными файлами DLL (динамичес- ки подсоединяемыми библиотеками). А не будет ли более эффективным
просто поместить все процедуры в один большой файл DLL, а затем установить связь с ним?
Можно ли отобразить файл DLL в виртуальные адресные пространства двух процессов с разными виртуальными адресами? Если да, то какие проблемы при этом возникают? Можно ли их разрешить? Если нет, то что можно сделать, чтобы устранить их?
Опишем один из способов связывания. Перед сканированием библиотеки компоновщик составляет список необходимых процедур, то есть имен, которые в связываемых модулях определены как внешние (EXTERN). Затем компоновщик последовательно просматривает всю библиотеку, извлекая каждую процедуру, которая находится в списке нужных имен. Будет ли работать такая схема? Если нет, то почему, и как это можно исправить?
Может ли регистр использоваться в качестве фактического параметра в макровызове? А константа? Если да, то почему. Если нет, то почему.
Вам нужно реализовать макроассемблер. Из эстетических соображений ваш начальник решил, что макроопределения не должны предшествовать вызовам макросов. Как повлияет это решение на реализацию?
Подумайте, как можно поместить макроассемблер в бесконечный цикл.
Компоновщик считывает 5 модулей, длины которых составляют 200, 800,
600, 500 и 700 слов соответственно. Если они загружаются в этом порядке,
то каковы константы перемещения?
Вопросы и задания 555
Напишите модуль таблицы символов, состоящий из двух процедур: enterisymbol, value) и lookup(symbol, value). Первый вводит новые символьные имена в таблицу, а второй ищет их в таблице. Используйте какую-либо хэш-кодировку.
Напишите простой ассемблер для компьютера Mic-1, о котором мы говорили в главе 4. Помимо оперирования машинными командами обеспечьте возможность приписывать константы символьным именам во время ассемблирования, а также способ ассемблировать константу в машинное слово.
Добавьте макросы к ассемблеру, который вы должны были написать, выполняя предыдущее задание.