*
Мы предполагаем, что вы знакомы с базовыми понятиями, т.е., вам не нужно
объяснять, что оперативная память представляет собой последовательность
ячеек-слов, и что номер каждой ячейки называется адресом, и т.д.
С другой стороны, нам нужно пользоваться хорошо определенной терминологией.
Поэтому часть этого раздела может оказаться пересказом известных для читателя
сведений.
*
Как известно, существуют два типа адресов. Адреса первого типа называются виртуальными, или логическими. Это то число, которое вы увидите, если, скажем, распечатаете значение указателя. Говоря точнее, это тот адрес, который видит ваша программа, номер ячейки памяти в ее собственном адресном пространстве. У многих машин эти адресные пространства для разных программ различны.
Адреса другого типа называются физическими. Это тот адрес, который передается по адресным линиям шины процессора, когда этот процессор считывает или записывает данные в ОЗУ.
Вообще говоря, эти два адреса могут не иметь между собой ничего общего. Теоретически, могут существовать адреса физической памяти, которым не соответствует никакой виртуальный адрес ни у какой из программ. Это может просто означать, что в данный момент эта память никем не используется. Более интересная ситуация - это виртуальные адреса, которым не соответствует никакой физический адрес. Такая ситуация часто возникает в системах, использующих так называемую страничную подкачку (page swapping или просто paging). В этой ситуации сумма объемов адресных пространств всех программ в системе может превышать объем доступной физической памяти, то есть на машине с четырьмя мегабайтами ОЗУ вы можете исполнять программы, требующие 8 и более мег, например CorelDRAW!. Правда, организация такой подкачки - нетривиальная задача, но об этом в следующих разделах.
Для начала попробуем рассмотреть загрузку программы в виртуальную память. Для простоты мы будем считать, что эта виртуальная память представляет собой непрерывное адресное пространство. Это не всегда так, но на всех системах, которые мы будем серьезно обсуждать, это предположение выполняется. Кроме того, будем считать, что программа была заранее собрана в некий единый самодостаточный объект, называемый загрузочным или загружаемым модулем. В ряде операционных систем программа собирается в момент загрузки из большого числа отдельных модулей, содержащих ссылки друг на друга, но об этом ниже.
Первый, самый простой, вариант состоит в том, что мы всегда будем загружать программу с одного и того же адреса. Это возможно в следующих случаях:
Такой модуль называется абсолютным загрузочным модулем.
Он представляет собой копию содержимого виртуального пространства программы
в момент ее запуска. Точнее, наоборот, начальное содержимое адресного
пространства формируется путем простого копирования модуля в память.
В системе RT-11 такие файлы называются .sav
от saved
- сохраненный.
В системе UNIX на 32-разрядных машинах также используется абсолютная загрузка. Загружаемый файл начинается с заголовка, который содержит:
TEXT_SIZE
- длину области кода программы (TEXT).
DATA_SIZE
- длину области инициализованных данных программы
(DATA).
BSS_SIZE
- длину области неинициализованных данных программы
(BSS).
При загрузке система выделяет программе TEXT_SIZE
байтов виртуальной памяти, доступной для чтения/исполнения, и копирует туда
содержимое сегмента TEXT. Затем отматывается DATA_SIZE
байтов памяти,
доступной для чтения/записи, и туда копируется содержимое сегмента DATA.
Затем отматывается еще BSS_SIZE
байтов памяти, доступной для
чтения/записи, и прописываются нулями.
После этого программе выделяется пространство под стек, в стек помещаются
позиционные аргументы и среда исполнения (environment),
и управление передается на стартовый адрес. Программа начинает исполняться.
Другой способ загрузки состоит в том, что мы грузим программу каждый раз с нового адреса. При этом мы должны настроить ее на новые адреса, а для этого...
Как известно, программа представляет собой последовательность команд. Каждая команда состоит из кода операции (который в данный момент нас не интересует) и одного или нескольких адресных полей. В процессорах, разработанных в 70-е гг. и в начале 80-х, у команды может быть переменное количество таких полей, от нуля до 5-6, а то и больше. У современных RISC-процессоров количество полей уменьшилось... Собственно, нас это тоже мало интересует. Зато нас очень интересует, каким образом вычисляется адрес на основании этого поля.
Существуют три способа вычисления адреса в команде. Первый способ
очень прост - берется адресное поле, возможно, расширенное со знаком или
нулями, и говорится, что это и будет наш виртуальный адрес.
Это называется абсолютной адресацией.
Очевидно, что если мы захотим ``сдвинуть'' программу по адресам виртуальной
памяти так, чтобы она начиналась, скажем, не с адреса 01000
,
а с адреса 02000
,
то мы должны будем найти все команды с абсолютными адресными полями
и прибавить ко всем этим полям разность нового и старого адресов.
Второй способ адресации состоит в том, что мы берем значение одного из регистров процессора, который указан соответствующем поле команды, прибавляем к нему значение адресного поля и получаем адрес. Такая адресация называется базовой. Если адрес формируется сложением двух или более регистров c адресным полем, то это называется базово-индексной адресацией.
*
Внимательный читатель может отметить, что способ формирования адреса в
процессоре 8086, а также в реальном режиме более поздних
процессоров той же линии, представляет собой именно базовую или
базово-индексную адресацию, а вовсе никакую не сегментацию.
*
В этом случае для перемещения программы нам нужно только изменить значения базовых регистров, и программа даже не узнает, что загружена с другого адреса, если только сама не будет перезагружать эти регистры.
Именно так происходит загрузка .com
-файлов в системе
MS DOS.
Система выделяет свободную память, настраивает программе базовые регистры
DS
и CS
, которые почему-то называются сегментными, и передает
управление на стартовый адрес. Ничего больше делать не надо.
В более сложных ситуациях работа с базовой адресацией почти не отличается от абсолютной: мы должны запомнить все места в программе, где загружаются базовые регистры, и добавить к загружаемым значениям новый стартовый адрес. Единственное преимущество состоит в том, что таких мест в программе гораздо меньше, чем при абсолютной адресации. На практике разница здесь составляет десятки и сотни раз.
Для перемещения программы мы обязаны найти в ней и обработать все ссылки на абсолютные адреса. Такими ссылками могут быть не только адресные поля обычных команд и команды загрузки базовых регистров, но и статически инициализованные указатели, или даже изощрения на ассемблере вроде:
push dst_seg ; Это и будет ссылкой на абсолютный адрес push dst_offs retf
Как бы то ни было, в перемещаемой программе мы вынуждены запоминать
все ссылки на абсолютные адреса и в момент загрузки производить их настройку
на реальный начальный адрес. Обычно это делается при помощи так называемой
таблицы перемещений (relocation table), которая присоединяется к
телу загружаемого модуля, и содержит смещения от начала модуля для каждой
такой ссылки. Такой файл гораздо сложнее абсолютного загружаемого модуля,
и носит название относительного или перемещаемого загрузочного
модуля. Именно такой формат имеют .exe
-файлы в системе
MS DOS.
Наиболее поучительна в этом отношении система RT-11, в которой
существуют загружаемые модули обоих типов. Обычные программы имеют
расширение .sav
, представляют собой абсолютные загружаемые модули
и грузятся всегда с адреса 01000
.
Ниже этого магического адреса находятся вектора прерываний и стек программы.
Сама операционная система вместе с драйверами размещается в верхних адресах
памяти.
Естественно, вы не можете загрузить одновременно два .sav
-файла.
Однако, если вам обязательно нужно исполнять одновременно
две программы, вы можете собрать вторую из них в виде относительного модуля:
файла с расширением .rel
.
Такая программа будет загружаться в верхние адреса памяти, каждый раз разные,
в зависимости от конфигурации ядра системы, количества загруженных драйверов
устройств и других .rel
-модулей.
За всеми этими разговорами мы чуть было не забыли о третьем
способе формирования адреса в программе. Это так называемая относительная
адресация, когда адрес получается сложением адресного поля команды и
адреса самой этой команды - значения счетчика команд
(IP
- Instruction Pointer или PC
- Program Counter).
Видно, что модуль, в котором используется
только такая адресация, можно грузить с любого адреса без всякой
перенастройки. Такой код называется позиционно-независимым.
Позиционно-независимые программы очень удобны для загрузки, но, к сожалению, их написание накладывает довольно жесткие ограничения на стиль программирования. Например, нельзя пользоваться статически инициализованными переменными указательного типа, нельзя делать на ассемблере фокусы, вроде того, который был приведен в примере 1, и т.д. На многих процессорах, например, на Intel 8080/8085 или многих современных RISC-процессорах позиционно-независимый код вообще невозможен - эти процессоры не поддерживают соответствующий режим адресации для данных. Возникают серьезные неудобства при сборке программы из нескольких модулей. Поэтому такой стиль программирования используют только в особых случаях. Например, многие вирусы под MS DOS и драйверы под RT-11 написаны именно таким образом.
*
Любопытное наблюдение. В эпоху RT-11 хакеры писали драйверы.
Сейчас они пишут вирусы. Еще любопытнее, что для некоторых персональных
платформ, например, для Amiga, вирусов почти нет.
Хакеры считают более интересным писать игры или демонстрационые программы
для Amiga. Похоже, общение с IBM PC порождает
у программиста какие-то агрессивные комплексы.
Наблюдение это принадлежит не авторам: см.[3].
*
Еще более интересный способ загрузки программы - это оверлей (over-lay - лежащий сверху) или, как это называли в старой русскоязычной литературе, перекрытие. Смысл оверлея состоит в том, чтобы не загружать программу в память целиком, а разбить ее на несколько модулей и затаскивать их в память по мере необходимости. При этом на одни и те же адреса в различные моменты времени будут отображены различные модули. Отсюда и название.
Потребность в таком способе загрузки появляется, если у нас виртуальное адресное пространство мало, например 1 мегабайт или даже всего 64 килобайта (на некоторых машинах под RT-11 бывало и по 48К, и многие полезные программы нормально работали!), а программа относительно велика. На современных 32-разрядных системах виртуальное адресное пространство обычно измеряется гигабайтами, и большинству программ этого хватает, а проблемы с нехваткой можно решать совсем другими способами. Тем не менее, существуют различные системы, даже и 32-разрядные, в которых нет устройства управления памятью, и размер виртуальной памяти не может превышать объема микросхем ОЗУ, установленных на плате. Пример такой системы - упоминавшийся выше транспьютер.
Основная проблема при оверлейной загрузке состоит в следующем: прежде чем ссылаться на оверлейный адрес, мы должны понять, какой из оверлейных модулей в данный момент там находится. Для ссылок на функции это просто: вместо точки входа функции мы вызываем некую процедуру, называемую менеджером перекрытий (overlay manager). Эта процедура знает, какой модуль куда загружен, и при необходимости подкачивает то, что загружено не было. Перед каждой ссылкой на оверлейные данные мы должны выполнять аналогичную процедуру, что намного увеличивает и замедляет программу. Иногда такие действия возлагаются на программиста (MS Windows, Mac OS, иногда - на компилятор (handle pointer в Zortech C/C++ для MS DOS), но чаще всего с оверлейными данными вообще предпочитают не иметь дела. В таком случае оверлейным является только код.
В старых учебниках по программированию и руководствах по операционным системам уделялось много внимания тому, как распределять процедуры между оверлейными модулями. Действительно, подкачка модуля с диска представляет собой довольно длительный процесс, поэтому хотелось бы минимизировать эту подкачку. Для этого нужно, чтобы каждый оверлейный модуль был как можно более самодостаточным. Если это невозможно, стараются вынести процедуры, на которые ссылаются из нескольких оверлеев, в отдельный модуль, называемый резидентной частью или резидентным ядром. Это модуль, который всегда находится в памяти и не разделяет свои адреса ни с каким другим оверлеем. Естественно, оверлейный менеджер должен быть частью этого ядра.
Каждый оверлейный модуль может быть как абсолютным, так и перемещаемым.
От этого несколько меняется устройство менеджера, но не более того.
На архитектурах типа i80x86 можно делать оверлейные модули,
каждый из которых адресуется относительно своего значения базового
регистра CS
и ссылается на данные, статически размещенные в памяти,
относительно постоянного значения регистра DS
.
Такие модули можно загружать в память с любого адреса, может быть,
даже вперемежку с данными. Именно так и ведут себя оверлейные менеджеры
компиляторов Borland и Zortech.
При загрузке самой ОС возникает специфическая проблема: в пустой машине, скорее всего, нет программы, которая могла бы это сделать.
В кросс-системах эта проблема решается просто. Как правило, значительная часть памяти разделяется между host-системой и целевой. Память целевой системы может быть видна host-машине как часть ее собственного физического адресного пространства (так называемая backpane memory), либо как специальное внешнее устройство. По принципу backpane memory организованы многие многопроцессорные машины, например транспьютерные ``фермы''.
Например, в ``Микропроцессорном Практикуме'' память целевого компьютера на процессоре К580 (Советский клон процессора Intel 8080) загружалась из host-машины Электроника-60 (Советский клон PDP-11) через параллельный порт последней. Часто такая же техника используется при отладке программ для микроконтроллеров. Для этой цели на целевой плате должно стоять специальное устройство, имитатор ПЗУ, который имеет разъемы, совпадающие с кристаллом ПЗУ, который предполагается там поставить. Содержимое имитируемого ПЗУ может загружаться в такой имитатор через последовательный или параллельный порт, или какими-то другими методами.
В процессорах семейства транспьютер фирмы Inmos
может использоваться похожая техника. Транспьютер имеет четыре или более
высокоскоростных последовательных канала, называемых линками.
Эти линки не являются внешними устройствами, они глубоко интегрированы
в архитектуру процессора.
Кроме того, транспьютер имеет специальный разъем, по которому передается
сигнал boot from link
.
Если этот сигнал установлен, при включении питания или после получения
сигнала сброса (reset
) транспьютер начинает ждать,
пока с какого-то из линков не придет байт, больший чем 1. Такой байт
воспринимается как длина сообщения, которое сейчас должно прийти по этому
же линку. Сообщение загружается в память с выделенного адреса и
запускается как программа. В Inmos'овской документации такая программа
называется первичным загрузчиком. Как правило, эта программа загружает
следующую, более сложную, называемую вторичным загрузчиком, которая
и занимается инициализацией всей системы. В случае сложной конфигурации
она может затянуть в память еще один, третичный загрузчик, который
и произведет инициализацию.
Похожий процесс происходит при загрузке современных операционных систем с диска. Это последовательное исполнение втягивающих друг друга загрузчиков возрастающей сложности называется бутстрапом (bootstrap), что можно перевести как ``втягивание [себя] за шнурки от ботинок''.
Большинство современных процессоров не имеют ничего похожего на линки.
При включении питания или аппаратном сбросе такие процессоры исполняют
команду, находящуюся по определенному адресу, что-нибудь типа
0xFFFFFFFA
.
Обычно по этому адресу находится ПЗУ, в котором содержится программа
первичного загрузчика.
Кстати, транспьютер будет вести себя так же, если сигнал boot from link
не установлен.
На многих системах в ПЗУ бывает прошито нечто большее, чем первичный загрузчик. Это может быть целая контрольно-диагностическая система, называемая консольным монитором. Такая система есть на всех машинах линии PDP-11/VAX и на VME-системах, рассчитанных на OS-9 или VxWorks. Такой монитор позволяет вам просматривать содержимое памяти по заданному адресу, записывать туда данные, запускать какую-то область памяти как программу, и многое другое. Он же позволяет выбирать устройство, с которого будет производиться дальнейшая загрузка. В PDP-11/VAX на таком мониторе можно даже писать программы, почти с таким же успехом, как на ассемблере. Нужно только уметь считать в уме в восьмеричной системе счисления.
На машинах фирмы Sun в качестве консольного монитора
используется интерпретатор языка Forth.
На ранних моделях IBM PC в ПЗУ был прошит интерпретатор
BASIC.
Именно поэтому клоны IBM PC имеют огромное количество плохо
используемого адресного пространства выше сегмента 0xC000
. Вы можете
убедиться в том, что BASIC там должен быть, вызвав из программы
прерывание 0x60
. Вы получите на мониторе сообщение вроде:
NO ROM BASIC. PRESS ANY KEY TO REBOOT
. Вообще говоря, этот
BASIC
не является консольным монитором в строгом смысле этого слова, так как
получает управление не перед загрузкой, а лишь после того, как загрузка
со всех устройств завершилась неудачей.
На многих больших компьютерах существует отдельная консольная подсистема. Это маломощная машина, используемая для инициализации центрального процессора и его загрузки. Так, у VAX-11/780 внутри стоит консольная PDP-11/03 (с которой и скопирована Электроника-60; у нее даже корпус точно такой же), которая загружает микропрограмму в процессор VAX и инициализует консольный терминал.
После запуска консольного монитора вы можете приказать системе начать загрузку. На IBM PC такое приказание отдается автоматически, и часто загрузка производится вовсе не с того устройства, с которого хотелось бы. На этом и основан жизненный цикл загрузочных вирусов.
Проще всего происходит загрузка с различных последовательных устройств - лент, перфолент, магнитофонов, перфокарточных считывателей и т.д. Первичный загрузчик считывает в память все, что можно считать с заданного устройства и передает управление на начало того, что прочитал.
В современных системах такая загрузка практически не используется. В них загрузка происходит с устройств с произвольным доступом, как правило - с дисков. При этом обычно в память считывается нулевой сектор нулевой дорожки диска. Содержимое этого сектора часто называют первичным загрузчиком, хотя в терминологии фирмы Inmos это будет уже вторичный загрузчик (первичный прошит в ПЗУ). Иначе этот загрузчик называют загрузочным сектором, или boot-сектором. Как правило, этот загрузчик ищет на диске начало файловой системы своей родной ОС, ищет в этой файловой системе файл с определенным именем, закачивает его в память и передает на него управление. Часто такой файл и является ядром операционной системы.
Размер вторичного загрузчика ограничен, чаще всего размером сектора на диске, то есть 512 байтами. Если файловая система имеет сложную структуру, иногда вторичному загрузчику приходится подкачать третичный загрузчик, размер которого может быть намного больше. Из-за большего размера этот загрузчик намного умнее и в состоянии разобраться в структурах данных файловой системы.
В старых веpсиях MS DOS (3.30
и pанее) эта
проблема была решена еще проще: файлы IO.SYS
и MSDOS.SYS
обязаны занимать на диске непрерывное пространство.
Загрузочный сектор находит корневую директорию, находит в ней ссылки
на соответствующие файлы и их длину и считывает требуемое количество
секторов, начиная со стартового. Это существенно проще, чем разбираться
с типом FAT, размером кластера и всей относящейся к делу информацией.
В ранних версиях DOS эти файлы вообще должны были лежать в
начале диска.
Легко понять, что первичный и вторичный загрузчики должны быть абсолютными загружаемыми модулями, потому что возлагать на первичный загрузчик настройку и перемещение вторичного просто нечестно, а для первичного этого просто никто не может сделать. Как правило, третичный и последующие загрузчики также являются абсолютными модулями, потому что у вторичного загрузчика хватает своих проблем, кроме настройки адресов у загрузчика следующего уровня.
В современных системах возможен еще один интересный способ загрузки - загрузка с сети. Он происходит аналогично загрузке с диска - стартовое ПЗУ посылает в сеть пакет стандартного содержания, который содержит запрос к серверу удаленной загрузки. Этот сервер передает по сети вторичный загрузчик, и т.д. Такая механика может использоваться при загрузке бездисковых рабочих станций. Таким же образом умеют грузиться VAX/VMS, VxWorks и многие другие системы.
Когда ядро системы, наконец-то, окажется в памяти, оно обычно запускает
некоторую специальную программу инициализации. В случае
MS DOS такая программа содержится в модулях MSDOS.SYS/IO.SYS
.
Имеется в виду процедура интерпретации файла CONFIG.SYS
.
Эта процедура определяет параметры настройки системы, драйверы
устройств, которые нужно загрузить, и т.д.
В системе UNIX старых версий: System V стаpее,
чем SVR4, или стаpых системах ветви BSD UNIX,
все эти драйверы и параметры настройки намертво зашиты в ядро.
Для изменения конфигурации системы вы должны собирать ядро заново.
В случае BSD, которая поставляется в виде исходных текстов на
C и ассемблере, вам, возможно, придется также перекомпилировать
часть модулей.
Тем не менее, в UNIX имеется специальная инициализационная
программа, которая так и называется - init
.
Эта программа запускает различные процессы-демоны, например cron
- программу, которая умеет запускать другие заданные ей программы в
заданные моменты времени, различные сетевые сервисы, программы, которые
ждут ввода с терминальных устройств (getty
), и т.д.
То, что она запускает, вообще говоря, задается в специальном файле
/etc/inittab
.
Администратор системы может редактировать этот файл и устанавливать
те сервисы, которые в данный момент нужны, избавляться от
тех, которые не нужны, и т.д.
Отчасти это похоже на группу startup
в MS Windows.
Вообще, аналогичный инициализационный сервис предоставляют все
современные операционные системы.
Существуют операционные системы, которые не умеют самостоятельно
выполнять весь цикл бутстрапа. Они используют более примитивную
операционную систему, которая исполняет их вторичный (или какой это уже
будет по счету) загрузчик, и помогает этому загрузчику затянуть в память
ядро ОС. На процессорах i80x86 в качестве стартовой
системы часто используется MS/DR DOS, а загрузчик новой ОС
оформляется в виде .EXE
-файла.
Таким образом устроены MS Windows, DesqView
и ряд других ``многозадачников'' для MS DOS. Таким же образом
загружается сервер Nowell Netware, система Oberon для
i386, программы, написанные для различных
расширителей DOS (DOS extenders) и т.д.
Многие из перечисленных ОС, например MS Windows 3.x и Windows95, используют DOS и во время работы в качестве дисковой подсистемы. Тем не менее, эти системы умеют самостоятельно загружать пользовательские программы и выполнять все перечисленные во введении функции и должны, в соответствии с нашим определением, считаться полноценными операционными системами.
В предыдущем разделе шла речь о типах исполняемых модулей, но не говорилось ни слова о том, каким образом эти модули получаются. Вообще говоря, способ получения загружаемого модуля различен в различных ОС, но в настоящее время во всех широко распространенных системах этот процесс выглядит примерно одинаково. Это связано, прежде всего, с тем, что эти системы используют одни и те же языковые процессоры.
В большинстве современных языков программирования программа состоит из отдельных слабо связанных модулей. Как правило, каждому такому модулю соответствует отдельный файл исходного текста. Эти файлы независимо обрабатываются языковым процессором (компилятором), и для каждого из них генерируется отдельный файл, называемый объектным модулем. Затем запускается программа, называемая редактором связей, компоновщиком или линкером (linker - тот, кто связывает), которая формирует из заданных объектных модулей цельную программу.
Интегрированные среды, типа Turbo Pascal или Borland C/C++ организованы таким же образом, только в них компилятор и редактор связей по каким-то соображениям собраны вместе с текстовым редактором и ``оболочкой'' - модулями, которые перехватывают сообщения об ошибках компиляции и т.д.
Объектный модуль отчасти похож по структуре на перемещаемый загрузочный модуль. Действительно, мы, как правило, не знаем, в каком месте готовой программы окажутся объекты, определенные в нашем модуле. Объект в данном случае означает любую сущность, обладающую адресом. Это может быть переменная в смысле языка высокого уровня, точка входа функции, и т.д. Поэтому объектный модуль должен содержать структуру данных, похожую на таблицу перемещений в загрузочном модуле. Можно, конечно, потребовать, чтобы весь модуль был позиционно-независимым, но это, как говорилось выше, накладывает очень жесткие ограничения на стиль программирования, а на многих процессорах (например Intel 8085) просто невозможно. Кроме того, по причинам, описанным ниже, это невозможно вообще.
Кроме ссылок на собственные объекты, объектный модуль имеет право ссылаться на объекты, определенные в других модулях. Типичный пример такой ссылки - обращение к функции, которая определена в другом файле исходного текста. Самый простой способ отслеживать такие ссылки - собрать их в таблицу.
Таких таблиц должно быть две: внешние объекты, на которые ссылается модуль, и объекты, определенные внутри модуля, на которые можно ссылаться извне. Обычно с каждым таким объектом ассоциировано имя, называемое глобальным символом. Как правило, это имя совпадает с именем соответствующей функции или переменной в исходном языке.
Одна из трудностей состоит в том, что значение символа может быть определено двумя способами: как относительный адрес внутри модуля или как абсолютное значение. Чаще всего используется первый способ. Все адреса функций и переменных в обычных языках высокого уровня, таких как C, C++ или Fortran, определяются именно таким образом. Второй способ может использоваться в ассемблере:
.asect ; Дальнейший код входит в специальный абсолютный .psect .org 0100 .globl SYMBOL SYMBOL: .word ?
Такая конструкция определит объект, который будет находиться по адресу
0100
независимо от положения соответствующего объектного модуля внутри программы,
и даже от загрузочного адреса самой программы. Это может быть полезно при
работе с отображенными на память внешними устройствами, межпрограммном
взаимодействии и т.д.
Читатель, наверное, еще не понял, в чем же трудность. А трудность состоит в том, что при ссылке на символ мы не знаем, каким из двух способов он был определен. Поэтому при ссылках на внешние объекты мы должны использовать только абсолютную адресацию. Это условие, кроме того, значительно упрощает работу линкера. Но, с другой стороны, это делает невозможной сборку позиционно-независимых программ из нескольких модулей.
В большинстве существующих систем мы все-таки можем ссылаться на внешние символы при помощи относительной адресации. Например, мы можем написать на ассемблере нечто вроде:
jmp SYMBOL - $
где $
означает адрес текущей команды.
Если наш линкер собирает абсолютный загрузочный модуль, такая конструкция
породит вполне разумный код, даже если SYMBOL
представляет собой
абсолютный символ.
В случае же перемещаемого модуля такая конструкция может создать большие
сложности или вообще оказаться недопустимой. Действительно, в перемещаемом
модуле мы добавляем начальный загрузочный адрес ко всем ссылкам на абсолютные
адреса, а из такой ссылки мы должны его вычесть. Для этого мы должны,
как минимум, уметь задавать такую операцию в таблице перемещений.
Простая таблица на глазах превращается в целый командный язык.
Насколько авторам известно, формат .exe
-файла MS DOS
такого не позволяет.
Если в загрузочном модуле превращение таблицы перемещений в программу на некотором командном псевдоязыке может быть нежелательно, то в объектном модуле оно неизбежно. Действительно, для каждой ссылки на внешний объект мы должны уметь сказать, является эта ссылка абсолютной или относительной, или это вообще должна быть разность или сумма двух или даже более адресов, и т.д. Для определения объекта, с другой стороны, мы должны уметь сказать, что это абсолютный или перемещаемый символ, или даже что он равен другому символу плюс заданное смещение, и т.д. Кроме того, в объектных файлах может содержаться отладочная информация, формат которой может быть очень сложным. Поэтому объектный файл представляет собой довольно сложную и рыхлую структуру. Размер собранной программы может оказаться в два или три раза меньше суммы длин объектных модулей.
Итак, типичный объектный модуль содержит следующие структуры данных:
Иногда эту таблицу объединяют с предыдущей и называют все это таблицей глобальных символов. В этом случае для каждого символа приходится указывать, определен он в данном модуле или нет, а если определен, то как.
"gcc compiled"
)
Крупные программы часто состоят из сотен и более отдельных модулей. Кроме того, существуют различные пакеты подпрограмм, также состоящие из большого количества модулей. Один из таких пакетов используется практически в любой программе на языке высокого уровня - это так называемая стандартная библиотека. Для решения проблем, возникающих при поддержании порядка в наборах из большого количества объектных модулей, еще на заре вычислительной техники были придуманы библиотеки объектных модулей.
Библиотека, как правило, представляет последовательный файл, состоящий из заголовка, за которым последовательно уложены объектные модули. В заголовке содержится следующая информация:
Линкер обычно собирает в программу все объектные модули, которые были ему заданы в командной строке, даже если на этот модуль не было ни одной ссылки. С библиотечными модулями он ведет себя несколько иначе.
Встретив ссылку на глобальный символ, линкер ищет определение
этого символа во всех модулях, которые ему были заданы. Если там такого
символа нет, то линкер ищет этот символ в заголовке библиотеки.
Если его нет и там, линкер говорит: Не определен символ SYMBOL
и завершает работу.
Некоторые линкеры, правда, могут продолжить работу и даже собрать
загружаемый модуль, но, как правило, таким модулем пользоваться нельзя,
так как в нем содержится ссылка на некорректный адрес.
Если же определение символа в библиотеке есть, линкер вытаскивает
соответствующий модуль и дальше работает так, будто этот модуль был задан
ему наравне с остальными объектными файлами. Этот процесс повторяется до
тех пор, пока не будут разрешены все глобальные ссылки, в том числе и те,
которые возникли в библиотечных модулях - или пока не будет обнаружен
неопределенный символ. Благодаря такому алгоритму в программу включаются
только те модули из библиотеки, которые нужны.
Во многих современных системах с виртуальной памятью существует понятие разделяемой библиотеки. С точки зрения линкера она отличается от обычной тем, что он всегда обязан настраивать ее на одни и те же виртуальные адреса, и не имеет права производить перенастройку самого кода библиотеки. Кроме того, этот код хранится вовсе не в загружаемом модуле, а в отдельном файле. Часто этот файл представляет собой загружаемый модуль специальной структуры. Все программы, использующие такую библиотеку, в действительности работают с одной копией ее кода, но каждая из них создает свою копию ее данных. Это достаточно сильно экономит память и дисковое пpостpанство, используемое для хpанения пpогpамм, особенно в случае больших библиотек, таких, как X Window Toolkit.
В существуют также операционные системы, производящие сборку программ в момент загрузки. В таких системах исчезает различие между абсолютными и относительными загружаемыми модулями. Как правило, программа привязывается к тому адресу, с которого она будет загружена, как при абсолютной загрузке, но при этом сам адрес может изменяться.
Некоторые архитектуры процессоров поддерживают динамически пересобираемые программы, у которых вся настройка модуля вынесена в отдельную таблицу. В этом случае модуль может быть прилинкован одновременно к нескольким программам, использовать одновременно разные копии сегмента данных, и каждая инкарнация модуля при этом даже не будет подозревать о существовании других. Примером такой архитектуры является Pascal-система Lilith, разработанная Н.Виртом, и ее наследники Кронос/N9000.
В этих архитектурах каждый объектный модуль соответствует одному модулю в смысле языка высокого уровня Oberon (или NIL - N9000 Instrumental Language). Далее мы будем описывать архитектуру системы N9000, поскольку авторы с ней лучше знакомы.
Модуль может иметь не более 256 процедур, не более 256 переменных и ссылаться не более, чем на 256 других модулей. Код модуля является позиционно-независимым. Данные модуля собраны в отдельный сегмент, и для каждой инкарнации модуля, то есть для каждой программы, которая этот модуль использует, создается своя копия сегмента данных. В начале сегмента содержится таблица переменных. Строки этой таблицы содержат либо значения - для скалярных переменных, таких как целое число или указатель, либо адреса в сегменте данных. Кроме того, сегмент данных содержит ссылку на сегмент кода. Этот сегмент кода содержит в себе таблицу адресов точек входа всех определенных в нем функций.
Ссылки на все внешние модули собраны в таблицу, которая также содержится в сегменте данных. Внешний модуль определяется началом его сегмента данных.
Все ссылки на объекты в данном модуле осуществляются через индекс
в соответствующей таблице. Ссылки на внешние модули имеют вид
индекс модуля: индекс объекта
.
Сегмент данных не может содержать никаких статически инициализованных данных. Вся инициализация производится специальной процедурой, которая вызывается при создании каждой новой инкарнации модуля.
Все эти свойства реализованы в системе команд, поэтому накладные расходы относительно невелики. Точнее, они невелики по сравнению с Intel 80286, но уже великоваты по сравнению с i386, а по сравнению с современными RISC-процессорами или системами типа транспьютера они становятся уже недопустимыми.
Видно, что в системе может существовать несколько программ, обращающихся к одним и тем же модулям и использующих одну и ту же копию кода модуля. Проблем с абсолютной/относительной загрузкой вообще не возникает.
Операционная система TC для N9000 была
основана на сборке программ в момент загрузки.
В системе имелась специальная команда load
- ``загрузить все модули,
используемые программой, и разместить для них сегменты данных, но саму
программу не запускать''. В памяти могло сидеть одновременно несколько
программ; при этом модули, используемые несколькими из них, загружались
в одном экземпляре. Это значительно ускоряло работу. Например, можно было
загрузить в память текстовый редактор, и запуск его занимал бы доли секунды,
вместо десятков секунд, которые нужны для загрузки с жесткого диска фирмы
ИЗОТ.
Любопытно, что когда началась реализация системы программирования на языке C для этой машины, по ряду причин было решено не связываться с динамической сборкой, а собирать обычные перемещаемые загрузочные модули.
Вообще, среди современных ОС довольно много систем, использующих тот или иной способ сборки при загрузке. Таким образом устроен, например, Novell Netware. Таким же образом организован ряд систем реального времени, таких как OS-9 или VxWorks.
Сборка при загрузке существенно замедляет процесс загрузки программы, но упрощает, с одной стороны, разделение кода, а с другой стороны - разработку программ. Действительно, из классического цикла внесения изменения в программу: редактирование текста - перекомпиляция - пересборка - перезагрузка (программы, не обязательно всей системы), выпадает целая фаза. В случае большой программы это может быть длительная фаза. В случае Novell Netware решающим оказывается первое преимущество, в случае систем реального времени одинаково важны оба.
В системах MS Windows и OS/2 используется способ загрузки, промежуточный между сборкой в момент загрузки и сборкой заранее. Загрузочный модуль в этих системах может быть полностью самодостаточным, а может содержать ссылки на другие модули, называемые DLL (Dynamically Loadable Library - динамически загружаемая библиотека). Самое хорошее в этой схеме то, что модуль, по собственному желанию, может выбирать различные библиотеки. Единственное ограничение состоит в том, что такие библиотеки обязаны быть совместимыми по вызовам.
Например, программа CorelDRAW! может импортировать
и экспортировать изображения в различных видах, начиная от собственного
внутреннего формата .CDR
или Windows Bitmap, и кончая
сильноупакованным форматом Jpeg или специализированными
форматами, вроде Targa-файлов.
Импорт и экспорт каждого формата выполняется отдельной DLL.
DLL на первый взгляд кажутся удобным средством разделения кода и создания отдельно загружаемых программных модулей, но они имеют очень серьезное ограничение.
Это ограничение проявляется, когда мы пытаемся создать в многопроцессной среде DLL для работы с разделяемым ресурсом, например многооконную графическую систему для работы нескольких задач с разделяемым терминалом. Как будет показано в разделе 4.2, DLL, работающая с разделяемыми данными, не сможет обрабатывать одновременно два вызова из разных процессов. Это создаст много проблем, разрешение которых либо невозможно в принципе, либо сопряжено с большими неудобствами. Поэтому в многопроцессной среде нам придется выделить оконную систему в отдельный процесс и оформлять обращения к ней не как обычные вызовы процедур, а как посылку сообщений средствами межпроцессного взаимодействия. Например, таким образом реализована сетевая оконная система X Windows, реализованная на всех ОС семейства Unix и многих других системах.
Средства межпроцессного взаимодействия будут подробнее обсуждаться в разделе 5, а сейчас мы скажем только, что эти средства в современных ОС по мощности не уступают прямому вызову процедуры. Существует даже модель взаимодействия, которая так и называется - RPC (Remote Procedure Call - Удаленный вызов процедуры). Само собой, такое использование этих средств сопровождается большими накладными расходами, чем прямой вызов; зато мы получаем намного большую свободу - например, мы можем обращаться к программе, исполняющейся на другом процессоре или вообще на другой машине, или разделять общий ресурс (например, общую базу данных) с другим процессом.
Поэтому то, что в MS Windows делается при помощи DLL, в большинстве современных ОС реализуется средствами межпроцессного взаимодействия.
Кроме того, использование DLL сильно замедляет процесс загрузки программ и несколько снижает общую производительность систем с виртуальной памятью. Системы семейства Unix использующие монолитный загрузочный имодуль и/или разделяемые библиотеки, заметно быстрее, чем OS/2 и Windows NT, использующие DLL.
Next: Управление оперативной памятью Up: Contents
Т.Б.Большаков: tbolsh@inp.nsk.su