
- •Спекулятивная загрузка
- •Краткое содержание главы
- •Вопросы и задания
- •Глава 6
- •Виртуальная память
- •Страничная организация памяти
- •Реализация страничной организации памяти
- •Вызов страниц по требованию и рабочее множество
- •Политика замещения страниц
- •Размер страниц и фрагментация
- •Сегментация
- •Реализация сегментации
- •Виртуальная память Pentium 4
- •Виртуальная память UltraSparc III
- •Виртуальная память и кэширование
- •Виртуальные команды ввода-вывода
- •Реализация виртуальных команд ввода-вывода
- •Команды управления каталогами
- •Виртуальные команды для параллельной работы
- •Формирование процесса
- •Состояние гонок
- •Синхронизация процесса с использованием семафоров
- •Краткое содержание главы
- •Вопросы и задания
- •Уровень ассемблера
- •Знакомство с ассемблером
- •Макросы
- •Процесс ассемблирования
- •Компоновка и загрузка
- •Глава 8
- •Параллельные компьютерные архитектуры
Процесс ассемблирования
В следующих подразделах показано, как работает ассемблер. И хотя ассемблеры разных машин разные, процесс ассемблирования, по сути, один и тот же.
Ассемблирование за два прохода
Поскольку ассемблерная программа состоит из ряда операторов, на первый взгляд может показаться, что ассемблер сначала должен считать оператор, затем перевести его на машинный язык и, наконец, передать полученный машинный язык в файл, а соответствующий фрагмент листинга — в другой файл. Этот процесс должен повторяться до тех пор, пока вся программа не будет оттранслирована. Однако, к сожалению, такая стратегия не работает.
Рассмотрим ситуацию, где первый оператор — переход к адресу 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.