Добавил:
Upload Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
тип книга1.doc
Скачиваний:
0
Добавлен:
22.12.2019
Размер:
1.52 Mб
Скачать

Процесс ассемблирования

В следующих подразделах показано, как работает ассемблер. И хотя ассемблеры разных машин разные, процесс ассемблирования, по сути, один и тот же.

Ассемблирование за два прохода

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

Рассмотрим ситуацию, где первый оператор — переход к адресу L. Ассемблер не может ассемблировать оператор, пока не знает адрес L. Но адрес L может нахо­диться где-нибудь в конце программы, и тогда ассемблер не сможет найти этот адрес, не прочитав всю программу. Эта проблема называется проблемой ссылки вперед (forward reference problem) и заключается она в том, что символ L исполь­зуется еще до своего определения (то есть выполняется обращение к символу, определение которого появится позднее).

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

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

Еще одна цель первого прохода — сохранять все макроопределения и расши­рять вызовы по мере их появления. Следовательно, в одном проходе происходит и определение символов, и расширение макросов.

Первый проход

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

BUFSIZE EQU 8192

Приписывая значение символическому имени в поле метки команды, ассемб­лер должен знать, какой адрес будет иметь эта команда во время выполнения программы. Для этого ассемблер во время ассемблирования сохраняет специаль­ную переменную, называемую счетчиком адресов команд (Instruction Location Counter, ILC). В начале первого прохода эта переменная устанавливается в О и увеличивается после каждой обработанной команды на длину этой команды. В листинге 7.8 дан соответствующий пример для Pentium 4 (в предпоследней колонке поля комментариев показана длина каждой команды, а в последней — накопленное значение счетчика). В данном примере операторы, расположенные до метки MARIA, занимают 100 байт. Мы не будем давать примеры для SPARC и Motorola, поскольку различия между языками ассемблера не очень важны, и одного примера вполне достаточно. Кроме того, как вы уже успели убедиться, ассемблер SPARC совершенно неудобочитаем.


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

  • длину поля данных, связанного с символом;

  • биты перераспределения памяти (которые показывают, изменится ли зна­чение символа, если программа будет загружена не по тому адресу, по ко­торому ее предполагал загрузить ассемблер);

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

Таблица 7.4, Таблица символических имен для программы из листинга 7.8

Символическое имя

Значение

Прочая информация

MARIA

100

ROBERTA

111

MARILYN

125

STEPHANY

129

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

Таблица 7,5. Несколько элементов таблицы кодов операций ассемблера Pentium 4

Код

операции

Первый

операнд

Второй

операнд

Шестнадца­теричный код

Длина

команды

Класс

команды

ААА

37

1

6

ADD

ЕАХ

immed32

05

5

4

ADD

reg

reg

01

2

19

AND

ЕАХ

immed32

25

5

4

AND

reg

reg

21

2

19

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

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

L 14.=F’5’

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

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

Некоторые процедуры будут относительно короткими, например, check_for_symbol, которая просто возвращает в виде символьной строки имя, если таковое имеет­ся, или ноль, если его нет. Другие процедуры, например get_length_of_typel и get ! ength_of_type2, могут быть достаточно длинными и сами вызывать другие процедуры. Естественно, на практике типов будет не два, а больше, — это зави­сит от ассемблируемого языка и от того, сколько типов команд предусмотрено в этом языке.

Структурирование программ имеет и другие преимущества помимо про­стоты программирования. Если программа пишется группой людей, разнооб­разные процедуры могут быть поделены на фрагменты и распределены между программистами. Все подробности получения входных данных скрыты в про­цедуре read next line. Если эти детали нужно изменить (например, из-за из­менений в операционной системе), то это повлияет только на одну подчинен­ную процедуру, и никаких изменений в самой процедуре pass_one делать не нужно.По мере чтения программы во время первого прохода ассемблер должен анализировать каждую строку, чтобы найти код операции (например, ADD), оп­ределить ее тип (набор операндов) и вычислить длину команды. Эта инфор­мация понадобится при втором проходе, поэтому ее лучше записать, чтобы не анализировать строку второй раз. Однако переписывание входного файла по­требует больше операций ввода-вывода. Что лучше — увеличить количество операций ввода-вывода, чтобы меньше времени тратить на анализ строк, или сократить количество операций ввода-вывода и потратить больше времени на анализ, зависит от быстродействия центрального процессора и дисковой па­мяти, эффективности файловой системы и некоторых других факторов. В на­шем примере мы запишем временный файл, в который будут записываться тип, код и длина операции, а также сама входная строка. Именно эта строка и будет считываться при втором проходе, и читать файл по второму разу не по­требуется.

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

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

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

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

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

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

  • используемый символ не определен;

  • символ определен более одного раза;

  • имя в поле кода операции не является допустимым кодом операции;

  • слишком мало операндов для данного кода операции;

  • слишком много операндов для данного кода операции;

  • восьмеричное число содержит цифру 8 или 9;

  • недопустимое применение регистра (например, переход к регистру);

  • отсутствует оператор END.

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

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

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

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

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

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

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