Глава 3. Загрузка программ.

СОДЕРЖАНИЕ

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

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

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

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

В рамках одного процесса может исполняться один или несколько потоков или нитей управления. Это понятие будет подробнее разбираться в главе 8.

Некоторые системы предоставляют и более крупные структурные единицы, чем процесс. Например, в системах семейства Unix существуют группы про­цессов, которые используются для реализации логического объединения процессов в задания (job). Ряд систем имеют также понятие сессии - сово­купности всех заданий, которые пользователь запустил в рамках одного се­анса работы. Впрочем, соответствующие концепции часто плохо определе­ны, а их смысл сильно меняется от одной ОС к другой, поэтому мы практически не будем обсуждать эти понятия.

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

Однако в наиболее распространенных ныне ОС семейств Unix и Win32, принято задачу называть процессом, а процесс - нитью (tread).

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

Создание процессов в UNIX

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

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

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

Несколько неожиданное, но тем не менее верное описание действия ехес - это замена образа процесса в рамках того же самого процесса.

Запуск другой программы в UNIX выглядит примерно так, как представлено в примере 3.2. Программа в примере 3.2 запускает командный интерпретатор /bin/sh, извест­ный как Bourne shell, приказывает ему исполнить команду is -l и перенаправ­ляет стандартный вывод этой команды в файл Is.log.

Техника программирования, основанная на fork/exec. несколько отличается от принятой во многих других современных системах, в том числе Win32, где при создании нового процесса мы сразу же указываем программу, которую он будет исполнять.

Пропустить примеры

Пример 3.1 Создание процесса в системах семейства UNIX.
int pid; /* Идентификатор порожденного процесса */
switch (pid = fork ()) 
{
	case 0: /* Порожденный процесс */
		…..
		break;
	case -1: /* Ошибка */
		perror("Cannot fork") ;
		exit(1) ;
	default: /* Родительский процесс */
	…..
	/* Здесь мы можем ссылаться на порожденный процесс, используя значение pid */
}
	
Пример 3.2 Создание процесса и замена программы в системах семейства UNIX.
int pid; /* Идентификатор порожденного процесса */
switch (pid = fork ()) 
{
	case 0: /* Порожденный процесс */
		dup2(1, open (“ls.log”, 0_WRONLY | 0_CREAT)) ;
			/* Перенаправить открытый файл  #1 
			* (stdout) в файл Is.log */
		execl (“/bin/sh", "sh", "-с", "ls", "-1", 0) ;
			/* Сюда мы попадаем только при ошибке! */
			/* fail through */ 
		case -1: /* Ошибка */
		perror("Cannot fork or exec") ;
		exit(1) ;
		default: /* Родительский процесс */
		…..
		/* Здесь мы можем ссылаться на порожденный процесс, * используя значение pid */
}
	

Но вернемся к способам загрузки программ.

3.1. Абсолютная загрузка.

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

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

Начальное содержимое образа процесса формируется путем простого копи­рования модуля в память. В системе RT-11 такие файлы имеют расширение .sav от saved - сохраненный.

Создание процессов в UNIX

В системе UNIX на 32-разрядных машинах также используется абсолютная за­грузка. Загружаемый файл формата a.out (современные версии Unix использу­ют более сложный формат загружаемого модуля и более сложную схему за­грузки, которая будет обсуждаться в разд. 5.4) начинается с заголовка (рис. 3.1), который содержит:

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

При загрузке система выделяет процессу text_size байтов виртуальной па­мяти, доступной для чтения/исполнения, и копирует туда содержимое сегмента text. Затем отсчитывается data_size байтов памяти, доступной для чтения/ записи, и туда копируется содержимое сегмента data. Затем отсчитывается еще bss_size байтов памяти, доступной для чтения/записи, которые прописы­ваются нулями.

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

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

3.2. Разделы памяти.

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

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

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

3.3. Относительная загрузка.

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

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

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

Пропустить примеры

Пример 3.3 Примеры статически инициализованных указателей в С
Int buf[20], *bufptr = buf;

char * message = "No message defined yet\n";

void do_nothing_hook(int);

void (*hook)(int)=do_nothing_hook;
	

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

Пропустить примеры

Пример 3.4 Реализация косвенного перехода по адресу dst_seg:dst_offs
push dst_seg ; Это и будет ссылкой на абсолютный адрес 

push dst_offs 

retf
	

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

Ассемблер при каждой ссылке на такой символ генерирует не только "заготовку" адреса в коде, но и запись в таблице перемещений (relocation table). Эта запись хранит место ссылки на такой символ в коде или данных. Если в ссылке используется только часть адреса, как в командах sethi %10, %hi(addr) процессора SPARC, или move ax, segment addr процессора 8086, мы запоминаем и этот факт.

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

Файл, содержащий таблицу перемещений, гораздо сложнее абсолютного загружаемого модуля и носит название относительного или перемещаемого загрузочного модуля. Именно такой формат имеют ехе-файлы в системе MS DOS (пример 3.5).

Пропустить примеры

Пример 3.5. Заголовок ЕХЕ-файла MS DOS. Цитируется по WINT.H из поставки MS Visual C++ v6.0 (перевод комментариев автора)
#define IMAGE_DOS_SIGMATURE     Ox4D5A // MZ
typedef  struct  _IMAGE_DOS_HEADER {      // Заголовок DOS .EXE 
  WORD e_magic;                       	// Магическое число (сигнатура) 
  WORD e_cblp;         		// Длина последней страницы файла в байтах 
  WORD e_cp;           		// Количество страниц в файле 
  WORD e_crlc;         		// Количество перемещений 
  WORD e_cparhdr;      		// Размер заголовка в параграфах 
  WORD e_minalloc;     	// Минимальное количество дополнительных параграфов
  WORD e_maxalloc;     	// Максимальное количество дополнительных параграфов     
  WORD e_ss;           		// Начальное (относительное) значение SS
  WORD e_sp;			// Начальное значение SP
  WORD e_сsum; 		// Контрольная сумма
  WORD e_ip;			// Начальное значение IP
  WORD e_cs;			// Начальное (относительное) значение CS
  WORD e_lfarlc;		// Адрес таблицы перемещений в файле
  WORD e_ovno;		// Номер перекрытия
  WORD e_res[4] ;		// Зарезервировано
  WORD e_oemid;		// OEM идентификатор (для e_oeminfo)
  WORD e_oeminfo;		// Информация OEM; специфично для e_oemid
  WORD e_res2[10] ;		// Зарезервировано
  LONG e_lfanew;		// Адрес следующего заголовка в файле
} IMAGE_DOS_HEADER, *PIMAGE DOS HEADER;
	

Наиболее поучительна в этом отноше­нии система RT-11, в которой сущест­вуют загружаемые модули обоих типов. Обычные программы имеют расширение sav, представляют собой абсолютные за­гружаемые модули и грузятся всегда с адреса 01000. Ниже этого магического адреса находятся векторы прерываний и стек программы. Сама операционная система вместе с драйверами размеща­ется в верхних адресах памяти. Естест­венно, вы не можете загрузить одновре­менно два sav-файла.

Однако, если вам обязательно нужно исполнять одновременно две про­граммы, вы можете собрать вторую из них в виде относительного модуля: файла с расширением rel. Такая про­грамма будет загружаться в верхние адреса памяти, каждый раз разные, в зависимости от конфигурации ядра системы, количества загруженных драйверов устройств и других rel-модулей (рис. 3.5).

3.4. Базовая адресация.

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


Рис. 3.6.

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

3.5. Позиционно-независимый код.

За всеми этими разговорами мы чуть было не забыли о третьем способе формирования адреса в программе. Это относительная адресация, когда ад­рес получается сложением адресного поля команды и адреса самой этой ко­манды - значения счетчика команд. Код, в котором используется только такая адресация, можно загружать с любого адреса без всякой перенастрой­ки. Такой код называется позиционно-независамым (position-independent).

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

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

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

Любопытное наблюдение

В эпоху RT-11 хакеры писали драйверы. Сейчас они пишут вирусы. Еще любо­пытнее, что для некоторых персональных платформ, например, для Amiga, ви­русов почти нет. Хакеры считают более интересным писать игры или демонст­рационные программы для Amiga. Похоже, общение с IBM PC порождает у программиста какие-то агрессивные комплексы. Наблюдение это принадлежит не автору: см. [КомпьютерПресс 1993].;

Позиционно-независимый код в современных Unix-системах

Компиляторы современных систем семейства UNIX - GNU С или стандартный С-компилятор UNIX SVR4 имеют ключ -f pic (Position-Independent Code). Впрочем, код, порождаемый при использовании этого ключа, не является позиционно-независимым в указанном выше смысле: этот код все-таки содержит перемещаемые адресные ссылки. Задача состоит не в том, чтобы избавиться от таких ссылок полностью, а лишь в том, чтобы собрать все эти ссылки в од­ном месте и разместить их, по возможности, отдельно от кода. Какая от этого польза, мы поймем несколько позже, в разд. 5.4, а сейчас обсудим технические приемы, используемые для решения этой задачи. Код, генерируемый GNU С, использует базовую адресацию: в начале функции адрес точки ее входа помещается в один из регистров, и далее вся адресация других функций и данных осуществляется относительно этого регистра. На процессоре х86 используется регистр %ebx, а загрузка адреса осуществляется командами, вставляемыми в пролог каждой функции (пример 3.6), На процессорах, где разрешен прямой доступ к счетчику команд, соответст­вующий код выглядит проще, но принцип сохраняется: компилятор занимает один регистр и благодаря этому упрощает работу загрузчику.

Как мы видим в примере 3.7, на самом деле адресация происходит не относи­тельно точки входа в функцию, а относительно некоторого объекта, называемо­го got или GLOBAL_OFFSET_TABLE. Счетчик команд используется для вычис­ления адреса этой таблицы, а не сам по себе. Подробнее мы разберемся с логикой работы этого кода (и заодно с тем, что означает еще один непонятный символ - plt) в разд. 5.4.Компилированный таким образом код предназначен в первую очередь для раз­деляемых библиотек формата ELF (Executable and Linking Format, формат ис­полняемых и собираемых [модулей], используемый большинством современ­ных систем семейства Unix).

Пропустить примеры

Пример 3.6. Получение адреса точки входа в позиционно-независимую под­программу
  call L4
L4:
  popl  %ebx
	
Пример 3.7. Позиционно-независимый код, порождаемый компилятором GNU С
/* strerror.c lemx+gcc) -- Copyright (с) 1990-1996 by Eberhard Mattes */
#include 	 
#include	
#include 	

char *strerror (int errnum)
{
	if (errnum >= 0 && errnum < _sys_nerr) 
	return (char *) sys_errlist [errnum] ;
	else 
	{
		static char msg[] = "Unknown error ";
#if defined (_MT_)
		struct   _thread  *tp = _thread();
#define result (tp->_th_error) 
#else
	static char result[32];
#endif
	memcpy (result, msg, sizeof (msg) - 1);
	_itoa (errnum, result + sizeof  (msg)  - 1, 10); 
	return result;
	} 
}

gcc -f  PIC -S strerror.c
	
	.file "strerror" 
gcc2_compiled.:
__gnu_compiled_c:
.data 
_msg. 2:
	.ascii "Unknown error \0"
.lcomm  result.3,32
.text
	.align 2,0х90
.globl  _strerror 
_strerror:
	pushl %ebx
	movl %esp,%ebp
	pushl %ebx
call L4
L4:
	popl %ebx
	addl $_GLOBAL_OFFSET_TABLE_+[.-L4],%ebx
	cmpl $0,8(%ebp)
	jl L2
	movl  _sys__nerr@GOT(%ebx),%eax
	movl 8(%ebp),%edx
	cmpl %edx,(%eax)
	jle L2
	movl 8(%ebp),%eax
	movl %eax,%edx
	leal 0(,%edx,4),%eax
	movl _sys_errlist@GOT(%ebx),%edx
	movl (%edx,%eax),%eax
	jmp LI
	.align 2,0х90 
	jmp L3
	.align 2,0х90 
L2:	
	pushl $14
	leal_msg.2@GOTOFF(%ebx),%edx
	movl %edx,%eax
	pushl %eax
	leal _result.3@GOTOFF(%ebx),%edx
	movl %edx,%eax
	pushl %eax
	call  _memcpy@PLT
	addl $12,%esp
	pushl $10
	leal _result.3@GOTOFF(%ebx),%edx
	leal 14(%edx),%eax
	pushl %eax
	movl 8(%ebp),%eax
	pushl %eax
	call _itoa@PLT
	addl $12,%esp
	leal  _result.3@GOTOFF(%ebx),%edx
	ovl %edx,%eax
	jmp L1
	.align 2,0х90 
L3:
L1:   
	movl -4(%ebp),%ebx
	leave
ret
	

3.6. Оверлеи.

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

Потребность в таком способе загрузки появляется, если у нас виртуальное адресное пространство мало, например 1 Мбайт или даже всего 64 Кбайт (на некоторых машинах с RT-11 бывало и по 48 Кбайт, и многие полезные программы нормально работали!), а программа относительно велика. На со­временных 32-разрядных системах виртуальное адресное пространство обычно измеряется гигабайтами, и большинству программ этого хватает, а проблемы с нехваткой можно решать совсем другими способами. Тем не менее, существуют различные системы, даже и 32-разрядные, в которых нет устройства управления памятью, и размер виртуальной памяти не может превышать объема микросхем ОЗУ, установленных па плате. Пример такой системы - упоминавшийся выше транспьютер.

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

Основная проблема при оверлейной загрузке состоит в следующем: прежде чем ссылаться на оверлейный адрес, мы должны понять, какой из оверлей­ных модулей в данный момент там находится. Для ссылок на функции это просто: вместо точки входа функции мы вызываем некую процедуру, назы­ваемую менеджером перекрытий (overlay manager). Эта процедура знает, ка­кой модуль куда загружен, и при необходимости "подкачивает" то, что за­гружено не было. Перед каждой ссылкой на оверлейные данные мы должны выполнять аналогичную процедуру, что намного увеличивает и замедляет программу. Иногда такие действия возлагаются на программиста (Winl6, Mac OS до версии 10 - подробнее управление памятью в этих системах описывается в разд. 4.4.1), иногда - на компилятор (handle pointer в Zortech C/C++ для MS DOS), но чаще всего с оверлейными данными вообще пред­почитают не иметь дела. В таком случае оверлейным является только код.

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

Каждый оверлейный модуль может быть как абсолютным, так и перемещае­мым. От этого несколько меняется устройство менеджера, но не более того. На архитектурах типа i80x86 можно делать оверлейные модули, каждый из ко­торых адресуется относительно значения базового регистра cs и ссылается на данные, статически размещенные в памяти, относительно постоянного значения регистра ds. Такие модули можно загружать в память с любого адреса, может быть, даже вперемежку с данными. Именно так и ведут себя оверлейные менеджеры компиляторов Borland и Zortech.

3.7. Сборка программ.

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

В большинстве современных языков программирования программа состоит из отдельных слабо связанных модулей. Как правило, каждому такому моду­лю соответствует отдельный файл исходного текста. Эти файлы независимо обрабатываются языковым процессором (компилятором), и для каждого из них генерируется отдельный файл, называемый объектным модулем. Затем запускается программа, называемая редактором связей, компоновщиком или линкером {linker- тот, кто связывает), которая формирует из заданных объ­ектных модулей цельную программу.

Объектный модуль отчасти похож по структуре на перемещаемый загрузоч­ный модуль. Дело в том, что сборку программы из нескольких модулей можно уподобить загрузке в память нескольких программ. При этом возни­кает та же задача перенастройки адресных ссылок, что и при загрузке отно­сительного загрузочного файла (рис. 3.8). Поэтому объектный модуль дол­жен в той или иной форме содержать таблицу перемещений. Можно, конечно, потребовать, чтобы весь модуль был позиционно-независимым, но это, как говорилось выше, накладывает очень жесткие ограничения на стиль программирования, а на многих процессорах (например Intel 8085) просто невозможно.

Кроме ссылок на собственные метки, объектный модуль имеет право ссы­латься на символы, определенные в других модулях. Типичный пример та­кой ссылки - обращение к функции, которая определена в другом файле исходного текста (рис. 3.9 и 3.10).


Рис. 3.10.

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

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

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

Как правило, код и данные разбиты на именованные секции. В masm/tasm (MASM - Microsoft Assembler, Tasm - Turbo Assembler) такие секции назы­ваются сегментами, в DЕСовских и UNIX'овскиx ассемблерах - программны­ми секциями (psect). В готовой программе весь код или данные, описанный в разных модулях, но принадлежащий к одной секции, собирается вместе. Например, в системах семейства Unix программы, написанные на языке С, состоят из минимум трех программных секций:

Некоторые форматы объектных модулей, в частности ELF (Executable and Linking Format- формат исполняемых и собираемых [модулей], используе­мый современными системами семейства Unix), предоставляют особый тип глобального символа - слабый (weak) символ (пример 3.8). При сборке про­граммы компоновщик не выдает сообщения об ошибке, если обнаруживает два различных определения такого символа, при условии, что одно из опре­делений является слабым - таким образом, слабый символ может быть лег­ко переопределен при необходимости. Особенно полезен этот тип при по­мещении объектного модуля в библиотеку.

Пропустить примеры

Пример 3.7. Структура данных объектного модуля ELF (цитируется по elf.h из поставки Linux 2.2.16, перевод комментариев автора).
/* Заголовок файла ELF. Находится в начале каждого файла ELF. */ 
#define EI_NIDENT (16)
typedef struct
{
	unsigned char e_ident[EI_NIDENT]; /* Магическое число и другая информа­ция */
	Elf32_Half e_type;      /* Тип объектного файла */
	Elf32_Half e_machine;   /* Архитектура */
	E1f32_Word e_version;   /* Версия объектного файла */
	Elf32_Addr e_entry;     /* Виртуальный адрес точки входа */
	Elf32_0ff e_phoff;      /* Смещение таблицы заголовка программы */
/* в файле */
	Elf32_0ff e_shoff;      /* Смещение таблицы заголовков секций в файле */ 
	Elf32_Word e_flags;     /* Процессорно-зависимые флаги */ 
	Elf32_Half e_ehsize;    /* Размер заголовка ELF в байтах */ 
	Elf32_Half e_phentsize; /* Размер элемента */
/* таблицы заголовка программы */ 
	Elf32_Half e_phnum;  /* Счетчик элементов таблицы заголовка программы */ 
	Elf32_Half e_shentsize; /* Размер элемента таблицы заголовков секций */ 
	Elf32_Half e_shnum;  /* Счетчик элементов таблицы заголовков программ */
	Elf32_Half e_shstrndx;  /* Индекс таблицы имен секций в таблице заголов­ков секций */
} Elf32_Ehdr;


/* Поля в массиве e_indent. Макросы EI_* суть индексы в этом массиве. 
Макросы, следующие за каждым определением EI_*, суть значения, 
которые соответствующий байт может принимать. */

#define EI_MAG0 0    /* Индекс нулевого байта сигнатуры1 */
#define ELFMAG0 0x7f /* Значение нулевого байта сигнатуры */

#define EI MAGI 1    /* Индекс первого байта сигнатуры */ 
#define ELFMAG1 'E'  /* Значение первого байта сигнатуры */

#define EI_MAG2 2    /* Индекс второго байта сигнатуры */ 
#define ELFMAG2 'L'  /* Значение второго байта сигнатуры */

#define EI MAG3 3    /* Индекс третьего байта сигнатуры */ 
#define ELFMAG3 'F'  /* Значение третьего байта сигнатуры */

/* Объединение идентификационных байтов, для сравнения по словам */

#define ELFMAG "\177ELF" 
#define SELFMAG 4

#define EI_CLASS 4      /* Индекс байта, указывающего класс файла */
#define ELFCLASSNONE 0  /* Не определено */ 
#define ELFCLASS32 1    /* 32-разрядные объекты */ 
#define ELFCLASS64 2    /* 64-разрядные объекты */ 
#define ELFCLASSNUM 3

#define EI_DATA 5      /* Индекс байта кодировки данных */ 
#define ELFDATANONE 0  /* Не определена кодировка данных */
#define ELFDATA2LSB 1  /* Двоичные дополнительные, младший байт первый */
#define ELFDATA2MSB 2  /* Двоичные дополнительные, старший байт первый */
#define ELFDATANUM 3

#define EI_VERSION 6   /* Индекс байта версии файла */ 
/* Значение должно быть EV_CURRENT */

#define EI_OSABI 7       /* идентификатор OS ABI */
#define ELFOSABI_SYSV 0  /* UNIX System V ABI */
#define ELFOSABI_HPUX 1  /* HP-UX */ 
#define ELFOSABI_ARM  97  /* ARM */
#define ELFOSABI_STANDALONE 255 /* Самостоятельное (встраиваемое) прило­жение * /

#define EI_ABIVERSION 8 /* версия ABI */

#define EI_PAD 9    /* Индекс байтов выравнивания */

/* Допустимые значения для е_type (тип объектного файла). */
#define ET_NONE 0   /* Не указан тип */
#define ET_REL I    /* Перемещаемый файл */
#define ET_EXEC 2   /* Исполнимый файл */
#define ET_DYN 3    /* Разделяемый объектный файл */
#define ET_CORE 4   /* Образ задачи +/
#define ET_NUM 5    /* Количество определенных типов */
#define ET_LOPROC 0xff00   /* Специфичный для процессора */

#define ET_HIPROC 0xffff   /* Специфичный для процессора */ 

/* Допустимые значения для e_machine (архитектура). */

#define EM_HONE 0     /* Не указана машина */
#define ЕМ_М32 1      /* AT&T WE 32100 */
#define EM_SPARC 2    /* SUN SPARC */
#define EM_386 3      /* Intel 80386 */
#define EM_68K 4      /* Motorola m68k family */
#define EM_88K 5      /* Motorola m88k family */
#define EM_486 6      /* Intel 80486 */
#define EM_860 7      /* Intel 80860 */
#define EM__MIPS 8     /* MIPS R3000 big-endian */
#define EM_S370 9     /* Amdahl */
#define EM_MIPS_RS4_BE 10   /* MIPS R4000 big-endian */
#define EM_RS6000 11  /* RS6000 */

#define EM_PARISC 15  /* HPPA */
#define EM_nCUBE 16   /* nCUBE */
#define EM_VPP500 17  /* Fujitsu VPP500 */
#define EM_SPARC32PLUS 18 /* Sun's "v8plus" */
#define EM_960 19    /* Intel 80960 */
#define EM_PPC 20    /* PowerPC */

#define EM_V800 36   /* NEC V800 series */ 
#define EM_FR20 37   /* Fujitsu FR20 */ 
#define EM_RH32 38   /* TRW RH32 */ 
#define EM_MMA 39    /* Fujitsu MMA */ 
#define EM_ARM 40    /* ARM */ 
#define EM_FAKE_ALPHA 41 /* Digital Alpha */ 
#define EM_SH 42       /* Hitachi SH */ 
#define EM_SPARCV9 43  /* SPARC v9 64-bit */ 
#define EM_TRICORE 44   /* Siemens Tricore */ 
#define EM__ARC 45       /* Argonaut RISC Core */ 
#define EM_H8_300 46   /* Hitachi H8/300 */ 
#define EM_H8_300H 47   /* Hitachi H8/300H */ 
#define EM_H8S 48       /* Hitachi H8S */ 
#define EM_H8_500 49    /* Hitachi H8/500 */ 
#define EM_IA_64 50     /* Intel Merced */
#define EM_MIPS__X 51    /* Stanford MIPS-X */ 
#define EM_COLDFIRE 52  /* Motorola Coldfire */ 
#define EM_68HC12 53    /* Motorola M68HC12 */
#define EM_NUM 54

/* Если необходимо выделить неофициальное значение для ЕМ_*, пожалуйста, 
выделяйте большие случайные числа (0х8523, 0xa7f2, etc.), чтобы уменьшить 
вероятность пересечения с официальными или не-GNU неофициальными значениями. */

#define EM_ALPHA 0х9026

/* Допустимые значения для e_version (версия). */

#define EV_NONE 0 /* Недопустимая версия ELF */ 
#define EV_CURRENT 1 /* Текущая версия */ 
#define EV_NUM 2

/* Элемент таблицы символов. */
typedef struct
{
	Elf32_Word st_name;     /* Имя символа (индекс в таблице строк) */
	Elf32_Addr st_value;    /* Значение символа */
	Elf32_Word st_size;     /* Размер символа */
	unsigned char st_info;  /* Тип и привязка символа */
	unsigned char st_other; /* Значение не определено, 0 */
	Elf32_Section st_shndx; /* Индекс секции */ 
} Elf32_Sym;

/* Секция syminfo, если присутствует, содержит дополнительную информацию о каждом динамическом символе. */
typedef struct
{
	Elf32_Half si_boundto; /* Прямая привязка, символ, к которому привязан */ 
	Elf32_Half si_flags;                /* Флаги символа */
} Elf32_Syminfo;

/* Допустимые значения для si_boundto. */

#define SYMINFO_BT_SELF 0xffff      /*	Символ привязан к себе */
#define SYMINFO_BT__PARENT 0xfffe /* Символ привязан к родителю */
#define SYMINFO_ВТ_LOWRESERVE 0xff00 /* Начало зарезервированных записей */

/* Возможные битовые маски для si_flags. */

#define SYMINFO_FLG_DIRECT 0х0001 /* Прямо привязываемый символ */
#define SYMINFO_FLG_PASSTHRU 0х0002 /* Промежуточный символ для транслятора */
#define SYMINFO_FLG_COPY 0х0004  /* Символ предназначен для перемещения 
копированием */
#define SYMINFO_FLG_LAZYLOAD 0х0008 /* Символ привязан к объекту
с отложенной загрузкой */

/* Значения версии Syminfo. */ 

#define SYMINFO_NONE 0
#define SYMINFO_CURRENT 1 
#define SYMINFO_NUM 2


/* Как извлекать информацию из и включать ее в поле st-info */
#define ELF32_ST_BIND(val) (((unsigned char) (val)) >> 4) 
#define ELF32_ST_TYPE(val) ((val) & 0xf)
#define ELF32_ST_INFO(bind, type) (((bind) << 4) + ((type) & 0xf)
/* Допустимые значения для подполя ST_BIND поля st_in?o (привязка символов). */

#define STB_LOCAL 0   /* Локальный символ */ 
#define STB_GLOBAL 1 /* Глобальный символ */
#define STB_WEAK 2 /* Слабый символ */
#define STB_NUM 3 /* Кол-во определенных типов. */
#define STB_LOOS 10 /* Начало ОС-зависимых значений */
#define STB_HIOS 12 /* Конец ОС-зависимых значений */
#define STB_LOPROC 13 /* Начало процессорно-зависимых значений */
#define STB_HIPROC 15 /* Конец процессорно-зависимых значений */

/* Допустимые значения для подполя ST_TYPE поля st_info (тип символа). */

#define STT_NOTYPE 0  /* Не указан */
#define STT_OBJECT 1  /* Символ - объект данных */
#define STT_FUNC 2    /* Символ - объект кода */
#define STT_SECTION 3  /* Символ связан с секцией */,
#define STT_FILE 4     /* Имя символа - имя файла */
#define STT_NUM 5      /* Кол-во определенных типов */
#define STT_LOOS 11    /* Начало ОС-зависимых значений */
#define STT_HIOS 12    /* Конец ОС-зависимых значений */
#define STT_LOPROC 13  /* Начало процессорно-зависимых значений */
#define STT_HIPROC 15  /* Конец процессорно-зависимых значений */

/* Индексы таблицы символов размещены в группах и цепочках кэша в секции 
хэш-таблицы символов. Это специальное значение индекса указывает на конец 
цепочки, и означает, что в этой группе более нет символов. */

#define STN_UNDEF  0 /* Конец таблицы. */

/* Элемент таблицы перемещений без добавочного значения (в секциях типа SHT_REL). */

typedef struct
{
	Elf32_Addr r_offset; /* Адрес */ 
	Elf32_Word r_info; /* Тип перемещения и индекс символа */ 
} Elf32_Rel;

/* /* Элемент таблицы перемещений с добавочным значением (в секциях типа SHT_RELA). */

typedef struct
{
	Elf32_Addr r_offset;  /* Адрес */
	Elf32_Word r_info;    /* Тип перемещения и индекс символа */ 
	Elf32_Sword r_addend; /* Добавочное значение */ 
} Elf32_Rela;

/* Как извлекать информацию из и включать ее в поле r_info. */
#define ELF32_R_SYM(val) ((val) >> 8)
#define ELF32_R_TYPE(val)  ((val) & 0xff)
#define ELF32_R_INFO(sym, type)  (((sym) << 8) + ((type) & 0xff))

/* Типы перемещений для i386 (формулы взяты из
[docs.sun.com 816-0559-10] - авт.) 
А - добавочное значение, используемое при вычислении значения перемещаемого поля.
В - базовый адрес, начиная с которого разделяемый объект загружается в память при 
исполнении [программы]. Обычно разделяемый объект строится с базовым виртуальным 
адресом, равным 0, но адрес при исполнении иной.
G - смещение записи в глобальной таблице смещений, где адрес перемещаемого 
символа находится во время исполнения. 
GOT - адрес глобальной таблицы смещений. 
L - местоположение (смещение в секции или адрес) записи символа в процедурной таблице 
связывания (PLT). PLT перенаправляет вызов функции по настоящему адресу. Редактор 
связей создает начальную таблицу, а редактор связей времени исполнения модифицирует 
записи  во время исполнения. 
Р - местоположение (смещение в секции или адрес) перемещаемого элемента памяти 
(вычисляется с использованием г_offset). 
S - значение символа, индекс которого находится в элементе таблицы перемещений. */

#define R_386_NONE 0     /* Не перемещать */
#define R_386_32 1       /* Прямое 32-разрядное - S + А */
#define R_386_PC32 2     /* 32-разрядное относительно PC - S + А - Р */
#define R_386_GOT32 3    /* 32-разрядный элемент GOT - G + A */
#define R_386_PLT32 4    /* 32-разрядный адрес PLT - L + А - Р */
#define R_386_COPY 5     /* Копировать символ при исполнении */
#define R_386_GLOB_DAT 6 /* Создать запись GOT - S*/
#define R_386_JMP_SLOT 7 /* Создать запись PLT - S */
#define R_386_RELATIVE 8 /* Сдвинуть относительно базы программы - В + А */
#define R_386_GOTOFF 9   /* 32-разрядное смещение GOT - S + А - GOT */
#define R_386_GOTPC 10   /* 32-разрядное смещение GOT относительно
PC - S + A - GOT */ 

/* Должна быть последняя запись. */ 
#define R_386_NUM 11
	

3.8. Объектные библиотеки.

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

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

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

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

В системах семейства Unix библиотеки такой структуры называются архив­ными библиотеками, чтобы отличить их от разделяемых библиотек, которые рассматриваются в разд. 3.10 и 5.4

3.9. Сборка в момент загрузки.

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

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

Примером такой сборки является широко используемая в Windows всех вер­сий и OS/2 технология DLL (на самом деле, DLL обеспечивают сборку не только в момент загрузки, но и после нее - возможность подключить до­полнительный модуль к уже загруженной программе), которая будет более подробно обсуждаться далее. В качестве других примеров можно привести Novell NetWare, OS-9, VxWorks и т. д. Впрочем, если мы говорим о системах, предназначенных для использования во встроенных приложениях (той же VxWorks), вопрос о том, является ли сборка перед прошивкой в ПЗУ сбор­кой в момент загрузки или сборкой заранее, носит схоластический характер.

Некоторые системы команд поддерживают динамически пересобираемые программы, у которых вся настройка модуля вынесена в отдельную таблицу. В этом случае модуль может быть подключен одновременно к нескольким программам, использовать одновременно разные копии сегмента данных, и каждая используемая копия модуля при этом даже не будет подозревать о существовании других. Примером такой архитектуры является Pascal-система Lilith, разработанная Н. Виртом, и ее наследники KpoHoc/N9000.

Программные модули в N9000

В этих архитектурах каждый объектный модуль соответствует одному модулю в смысле языка высокого уровня Оbегоn (или NIL - N9000 Instrumental Language). Далее мы будем описывать архитектуру системы N9000, поскольку автор с ней лучше знаком.

Модуль может иметь не более 256 процедур, не более 256 переменных и ссы­латься не более чем на 256 других модулей. Код модуля является позиционно-независимым. Данные модуля собраны в отдельный сегмент, и для каждой ис­пользуемой копии модуля, т. е. для каждой программы, которая этот модуль использует, создается своя копия сегмента данных. В начале сегмента содер­жится таблица переменных. Строки этой таблицы содержат либо значения- для скалярных переменных, таких как целое число или указатель, либо адреса в сегменте данных. Кроме того, сегмент данных содержит ссылку на сегмент кода. Этот сегмент кода содержит в себе таблицу адресов точек входа всех оп­ределенных в нем функций (рис. 3.13).

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

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

Точнее, они невелики по сравнению с Intel 80286, но уже великоваты по сравнению с i386, а по сравнению с современными RISC-процессорами или системами типа транспьютера они становятся недопустимыми. Впрочем, в разд. 5.4 мы увидим, как подобная структура используется и на "обычных" процессорах.

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

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

На практике, подобная архитектура более характерна для байт-кодов - прекомпилированных представлений программы, предназначенных для дальней­шей обработки интерпретатором - Java Virtual Machine, интерпретатором Smalltalk и т. д., чем для аппаратно реализованных систем команд. В таких сис­темах команд порой используются и более экстравагантные решения.

Архитектура AS/400

Система команд AS/400 (сервер баз данных среднего уровня, производимый IBM) представляет собой машинно-независимый байт-код. При загрузке про­граммы этот байт-код компилируется в бинарный код "реального" процессора, подобно тому, как это делается в большинстве современных реализации Java Virtual Machine. Точнее, наоборот, успех AS/400 был одним из важных факто­ров, которые подвигли фирму Sun на разработку Java, поэтому правильнее го­ворить, что современные JVM основаны на том же принципе компиляции при загрузке, что и AS/400.

Это решение обеспечивает невысокую стоимость аппаратуры (современные AS/400 основаны на микропроцессорах архитектуры Power PC. Их более высо­кая по сравнению с машинами, основанными на процессорах х86, цена обу­словлена более производительными системной шиной и периферией), высокую производительность и возможность заменять архитектуру "реального" процес­сора без перекомпиляции пользовательского программного обеспечения. За время выпуска машин этой серии такая замена происходила дважды.

С другой стороны, отсутствие необходимости думать о том, как та или иная возможность может быть реализована аппаратно, позволила принимать весьма авангардистские решения, на которые не решался никто из разработчиков аппаратно реализованных CISC-архитектур, таких как VAX, Eclipse и даже апо­феоза CISC, Intel 432.

AS/400 имеет единое адресное пространство в том смысле, что адресуемыми объектами являются не только сегменты кода и скалярных данных, но и объек­ты реляционной СУБД, такие, как таблицы, индексы, курсоры и т. д.

Фактически, адресации подлежит вся память системы как оперативная, так и дисковая. Адрес имеет два представления: его сегментная часть может хранить имя адресуемого объекта [в контексте этой главы это можно уподобить неразрешенной внешней ссылке) или собственно адрес, 64-битовое бинарное значение. Перед тем, как обратиться к объекту, адрес-имя надо преобразовать в бинарный формат, для чего существуют специальные команды [redbooks.ibm.com sg242222,pdf].

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

Сборка при загрузке замедляет процесс загрузки программы (впрочем, для современных процессоров это замедление вряд ли имеет большое значение), но упрощает, с одной стороны, разделение кода, а с другой стороны - разработку программ. Действительно, из классического цикла внесения изменения в программу: редактирование текста - перекомпи­ляция - пересборка - перезагрузка (программы, не обязательно всей системы) выпадает целая фаза. В случае большой программы это может быть длительная фаза. В случае Novell Netware решающим оказывается первое преимущество (рис. 3.14), в случае систем реального времени одинаково важны оба.

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


3.10. Динамические библиотеки

В Windows и OS/2 используется именно такой способ загрузки. Исполняе­мый модуль в этих системах содержит ссылки на другие модули, называе­мые DLL (Dynamically Loadable Library, динамически загружаемая библиоте­ка). Фактически, каждый модуль в этих системах обязан содержать хотя бы одну ссылку на DLL, потому что интерфейс к системным вызовам в этих ОС также реализован в виде DLL.

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

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

Особенно удобна возможность вызывать любую функцию по имени при об­ращении к внешним модулям из интерпретируемых языков. В примере 3.9 для подключения внешних библиотек (в данном случае это стандартная библиотека RexxUtil и библиотека доступа к сетевым сервисам rxSock) при­меняются две процедуры: сначала RxFuncAdd с тремя параметрами: имя сим­вола rexx, который будет использоваться для обращения к вызываемой функции, имя DLL и имя символа в этой DLL, а потом специальная функ­ция, предоставляемая модулем (sysLoadFunc и sockLoadFunc соответствен­но), которая регистрирует в интерпретаторе REXX остальные функции мо­дуля.

Пропустить примеры

Пример 3.9 Пример использования динамической библиотеки (здесь - REXX Socket) в интерпретируемом языке.
/**********************************************************************************
ПРОСТОЙ HTTP клиент на REXX Dmitry Maximovich 2:5030/544.60 aka maxim@pabi.ru
***************************************************************************/
PARSE VALUE ARG(l) WITH Al A2

IF Al = '' THEN 
DO
	SAY 'USAGE: wwwget hostname[/path] [port]' 
	EXIT 
END 
ELSE 
DO
	PARSE VALUE Al WITH B1'/'B2 
	sServer = Bl
	IF B2 = '’ THEN 
	DO
		sRequest - "GET / HTTP 1.0'|I"0D0A0D0A"x
		SAY 'Requesting /' 
	END 
	ELSE 
	DO
		sRequest = 'GET /'||B2||' HTTP 1.0'||"0D0A0D0A"x
		SAY 'Requesting /'||B2 
	END 
END
IF A2 <> ‘’ THEN 
DO 
	nPortNumber = A2
END 
ELSE 
DO
	nPortNumber = 80 
END

/* Загрузить REXX Socket Library если еще не загружена*/ 

IF RxFuncQuery('SockLoadFuncs') THEN
DO
	rc = RxFuncAdd("SockLoadFuncs","rxSock","SockLoadFuncs")
	rc = SockLoadFuncs()
END
IF RxFuncQueryC'SysLoadFuncs") THEN 
DO
	rc = RxFuncAdd( "SysLoadFuncs","RexxUtil","SysLoadFuncs") 
	rc = SysLoadFuncs() 
END

rc=SockGetHostByName(sServer,"host.")

IF rc <> 1 THEN 
DO
	SAY 'CANNOT RESOLVE HOSTNAME TO ADDRESS; 'SServer 
	EXIT -1 
END

SAY 'Trying server: 'host .name', address: 'host.addr', port: 'nPortNumber

socket = SockSocket('AF_INET','SOCK_STREAM',0) 
IF socket < 0 THEN 
DO
	SAY 'UNABLE TO CREATE A SOCKET' 
	EXIT -1 
END

address.family = 'AF_INET' 
address.port = nPortNumber 
address.addr = host.addr

rc = SockConnect(socket,'address.') 
IF rc < 0 THEN 
DO
	SAY 'UNABLE TO CONNECT TO SERVER:'address.addr 
	SIGNAL DO 
END

rc = SockSend(socket, sRequest) 
SАY ' REQUEST: ***************************************************'

Resp = '' 
DO FOREVER
	rc = SockRecv(socket,"sReceive",256)
	IF rc <= 0 THEN 
		LEAVE
	Resp =  Resp || sReceive 
END

SAY ' '

/* CR -> CRLP */

nStart = 1
nStop = pos(X2C("0A"), Resp)
do while nStop > 0
	SAY SUBSTR(Resp,nStart,nStop-nStart)
	nStart = nStop + 1
	nStop = pos(X2C("0A"), Resp, nStart) 
end
DO:
rc = SockShutDown(socket,2)
rc = SockClose(socket)
	

При сборке DLL из нескольких объектных модулей программист должен предоставить DEF-файл (пример 3.10). В этом файле содержится перечис­ление символов, экспортируемых библиотекой (в отличие от обычных, "архивных" библиотек, набор этих символов не обязательно равен объедине­нию наборов экспортных символов всех включенных в библиотеку объек­тов), а также некоторые другие параметры. Например, можно указать, что DLL имеет функции инициализации и терминации. Эти функции могут за­пускаться как при первой загрузке библиотеки (initglobal), так и при под­ключении библиотеки очередной программой (initinstance). Можно также управлять разделением сегмента данных DLL - применять общий сегмент данных для всех программ, использующих библиотеку, или создавать свою копию для каждой программы.

Пропустить примеры

Пример 3.10. DEF-файл из примеров кода VisualAge C++ V3.0
LIBRARY REXXUTIL INITINSTANCE LONGNAMES
PROTMODE
DESCRIPTION 'REXXUTIL Utilities - (c) Copyright IBM Corporation 1991'
DATA MULTIPLE NONSHARED
STACKSIZE 32768
EXPORTS
SYSCLS   = SysCIs   @1
SYSCURPOS   = SysCurPos   @2
SYSCURSTATE  = SysCurState  @3
SYSDRIVEINFO = SysDriveInfo  @4
SYSDRIVEMAP  = SysDriveMap  @5
SYSDROPFUNCS = SysDropFuncs @6 
SYSFILEDELETE = SysFileDelete @7 
SYSFILESEARCH = SysFileSearch @8 
SYSFILETREE = SysFileTree @9 
SYSGETMESSAGE = SysGetMessage @10 
SYSINI   - Sysini   @11 
SYSLOADFUNCS = SysLoadFuncs @12 
SYSMKDIR   =• SysMkDir   @13 
SYSOS2VER   = SysOS2Ver   @14 
SYSRMDIR   = SysRmDir   @15 
SYSSEARCHPATH = SysSearchPath @16 
SYSSLEEP   = SysSleep   @17 
SYSTEMPFILENAME = SysTempFileName @18 
SYSTEXTSCREENREAD = SysTextScreenRead @19  
SYSTEXTSCREENSIZE = SysTextScreenSize @20 
SYSGETEA   = SysGetEA   @21 
SYSPUTEA   - SysPutEA   @22 
SYSWAITNAMEDPIPE = SysWaitNamedPipe @23
	

DLL являются удобным средством разделения кода и создания отдельно загружаемых программных модулей, но их использование сопряжено с оп­ределенной проблемой, которая будет подробнее объясняться в разд. 5.4. Забегая вперед, скажем, что концепция разделяемых DLL наиболее естест­венна в системах, где все задачи используют единое адресное пространст­во - но при этом ошибка в любой из программ может привести к порче данных или кода другой задачи. Стандартный же способ борьбы с этой про­блемой - выделение каждому процессу своего адресного пространства - значительно усложняет разделение кода.

Другая проблема, обусловленная широким использованием разделяемого кода, состоит в слежении за версией этого кода. Действительно, предста­вим себе жизненную ситуацию: в системе одновременно загружены три­дцать программ, использующие библиотеку LIBC.DLL. При этом десять из них разрабатывались и тестировались с версией 1.0 этой библиотеки, пять - с версией 1.5 и пятнадцать - с версией 1.5а. Понятно, что рас­считывать на устойчивую работу всех тридцати программ можно только при условии, что все три версии библиотеки полностью совместимы снизу вверх не только по набору вызовов и их параметров, но и по точ­ной семантике каждого из этих вызовов. Последнее требование иногда формулируют как bug-for-bug compatibility (корректно перевести это сло­восочетание можно так: полная совместимость не только по спецификаци­ям, но и по отклонениям от них).

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

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

Разделяемый код в системах семейства Windows

Катастрофические масштабы эта проблема принимает в системах семей­ства Windows, где принято помещать в дистрибутивы прикладных программ все потенциально разделяемые модули, которые этой программе могут по­требоваться- среда исполнения компилятора и т.д. При этом каждое приложение считает своим долгом поместить свои разделяемые модули в C:\W1NDOWS\SYSTEM32 (в Windows NT/2000/XP это заодно приводит к тому, что установка самой безобидной утилиты требует администраторских привиле­гий). Средств же проследить за тем, кто, какую версию, чего, куда и зачем по­ложил, практически не предоставляется.

В лучшем случае установочная программа спрашивает: "Тут вот у вас что-то уже лежит, перезаписать?". Стандартный деинсталлятор содержит список DLL, которые принадлежат данному приложению, и осознает тот факт, что эти же DLL используются кем-то еще, но не предоставляет (и, по-видимому, не пыта­ется собрать) информации о том, кем именно они используются. Наличие рее­стра объектов СОМ не решает проблемы, потому что большая часть приноси­мого каждым приложением "разделяемого" кода (кавычки стоят потому, что значительная часть этого кода никому другому, кроме принесшего его прило­жения, не нужна) не является сервером СОМ.

В результате, когда, например, после установки MS Project 2000 перестает ра­ботать MS Office 2000 [MSkb RU270125], это никого не удивляет, а конфликты между приложениями различных разработчиков или разных "поколений" счи­таются неизбежными. Установить же в одной системе и использовать хотя бы попеременно две различные версии одного продукта просто невозможно - од­нако, когда каждая версия продукта использует собственный формат данных. а конверсия между ними неидеальна, это часто оказывается желательно.

Разработчики же и тестеры, которым надо обеспечить совместимость с различными версиями существующих приложений, при этом просто оказываются в безвыходной ситуации. Неслучайно поставщики VmWare (системы виртуальных машин для х86) как одно из главных достоинств своей системы рекламируют возможность держать несколько копий Windows одновременно загруженными на одной машине.

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

3.11. Загрузка самой ОС.

При загрузке самой ОС возникает специфическая проблема: в пустой ма­шине, скорее всего, нет программы, которая могла бы это сделать.

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

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

Компьютеры общего назначения также не могут обойтись без ПЗУ. Про­грамма, записанная в нем, называется загрузочным монитором. Стартовая точка этой программы должна находиться как раз по тому адресу, по кото­рому процессор передает управление в момент включения питания. Эта программа производит первичную инициализацию процессора, тестирова­ние памяти и обязательного периферийного оборудования, и, наконец, на­чинает загрузку системы. В компьютерах, совместимых с IBM PC, загрузоч­ный монитор известен как BIOS.

На многих системах в ПЗУ бывает прошито нечто большее, чем первичный за­грузчик. Это может быть целая контрольно-диагностическая система, называе­мая консольным монитором. Такая система есть на всех машинах линии PDP-11/VAX и на VME-системах, рассчитанных на OS-9 или VxWorks. Такой мони­тор позволяет вам просматривать содержимое памяти по заданному адресу, за­писывать туда данные, запускать какую-то область памяти как программу и многое другое. Он же позволяет выбирать устройство, с которого будет производиться дальнейшая загрузка. В PDP-11/VAX на таком мониторе можно даже писать программы, почти с таким же успехом, как на ассемблере. Нужно только уметь считать в уме в восьмеричной системе счисления.

На машинах фирмы Sun в качестве консольного монитора используется ин­терпретатор языка Forth. На ранних моделях IBM PC в ПЗУ был прошит интерпретатор BASIC. Именно поэтому клоны IBM PC имеют огромное ко­личество плохо используемого адресного пространства выше сегмента 0хC000. Вы можете убедиться в том, что BASIC там должен быть, вызвав из программы прерывание 0х60. Вы получите на мониторе сообщение вроде: no rom basic. press any key то reboot. Вообще говоря, этот BASIC не является консольным монитором в строгом смысле этого слова, так как получает управление не перед загрузкой, а лишь после того, как загрузка со всех уст­ройств завершилась неудачей.

После запуска консольного монитора и инициализации системы вы можете приказать системе начать собственно загрузку ОС. На IBM PC такое прика­зание отдается автоматически, и часто загрузка производится вовсе не с того устройства, с которого хотелось бы. На этом и основан жизненный цикл загрузочных вирусов.

Чтобы загрузочный монитор смог что бы то ни было загрузить, он должен уметь проинициализировать устройство, с которого предполагается загрузка, и считать с него загружаемый код. Поэтому загрузочный монитор обязан содержать модуль, способный управлять загрузочным устройством. Напри­мер, типичный BIOS PC-совместимого компьютера содержит модули управ­ления гибким диском и жестким диском с интерфейсом Seagate 506 (в со­временных компьютерах это обычно интерфейс EIDE, отличающийся от Seagate 506 конструктивом, но программно совместимый с ним сверху вниз).

Кроме того, конструктивы многих систем допускают установку ПЗУ на пла­тах контроллеров дополнительных устройств. Это ПЗУ должно содержать программный модуль, способный проинициализировать устройство и про­извести загрузку с него (рис. 3.15).

Как правило, сервисы загрузочного монитора доступны загружаемой систе­ме. Так, модуль управления дисками BIOS PC-совместимых компьютеров предоставляет функции считывания и записи отдельных секторов диска. Доступ к функциям ПЗУ позволяет значительно сократить код первичного загрузчика ОС, и, нередко, сделать его независимым от устройства

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

В современных системах такая загрузка практически не используется. В них загрузка происходит с устройств с произвольным доступом, как правило - с дисков. При этом обычно в память считывается нулевой сектор нулевой дорожки диска. Содержимое этого сектора называют первичным загрузчиком. В IBM PC этот загрузчик называют загрузочным сектором, или boot-сектором.

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

Это последовательное исполнение втягивающих друг друга загрузчиков воз­растающей сложности называется бутстрапом (bootstrap), что можно пере­вести как "втягивание [себя] за шнурки от ботинок".

Большую практическую роль играет еще один способ загрузки - загрузка по сети. Она происходит аналогично загрузке с диска: ПЗУ, установленное на сетевой карте, посылает в сеть пакет стандартного содержания, который со­держит запрос к серверу удаленной загрузки. Этот сервер передает по сети вторичный загрузчик и т. д. Такая технология незаменима при загрузке без­дисковых рабочих станций. Централизованное размещение загрузочных об­разов рабочих станций на сервере упрощает управление ими, защищает на­стройки ОС от случайных и злонамеренных модификаций и существенно удешевляет эксплуатацию больших парков настольных компьютеров, поэто­му по сети нередко загружаются и машины, имеющие жесткий диск.

Проще всего происходит загрузка систем, ядро которых вместе со всеми до­полнительными модулями (драйверами устройств, файловых систем и др.) собрано в единый загрузочный модуль. Например, в системах семейства Unix, ядро так и называется /unix (в FreeBSD - /vmunix, в Linux - /vmlinux, или, в случае упакованного ядра, /vmlinuz).

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

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

Большинство современных ОС используют более сложную форму загрузки, при которой дополнительные модули подгружаются уже после старта самого ядра. В терминах предыдущих разделов это называется "сборка в момент загрузки". Список модулей, которые необходимо загрузить, а также пара­метры настройки ядра, собраны в специальном файле или нескольких фай­лах. У DOS и OS/2 этот файл называется CONFIG.SYS, у Win32-систем - реестром (registry).

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

Некоторые системы, например DOS, могут грузиться только с устройств, поддерживаемых BIOS, и только из одного типа файловой системы - FAT, драйвер которой скомпонован с ядром. Любопытное развитие этой идеи представляет Linux, модули которого могут присоединяться к ядру как ста­тически, так и динамически. Динамически могут подгружаться любые моду­ли, кроме драйверов загрузочного диска и загрузочной ФС.

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

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

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

Так, системы семейства UNIX имеют специальную инициализационную программу, которая так и называется - init. Эта программа запускает раз­личные процессы-демоны, например cron - программу, которая умеет за­пускать другие заданные ей программы в заданные моменты времени, раз­личные сетевые сервисы, программы, которые ждут ввода с терминальных устройств (getty), и т. д. Набор запускаемых программ задается в файле /etc/inittab (в разных версиях системы этот файл может иметь разные имена. /etc/inittab используется в System V). Администратор системы может редак­тировать этот файл и устанавливать те сервисы, которые в данный момент нужны, избавляться от тех, которые не требуются, и т. д.

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

Аналогичный инициализационный сервис в той или иной форме предостав­ляют все современные операционные системы.

Загрузка Sun Solaris

Полный цикл загрузки Solaris (версия Unix System V Release 4, поставляющаяся фирмой Sun) на компьютерах х86 происходит в шесть этапов. Первые три эта­па стандартны для всех ОС, работающих на IBM PC-co вмести мой технике. При включении компьютера запускается прошитый в ПЗУ BIOS. Он проводит тести­рование процессора и памяти и инициализацию машины- В процессе инициа­лизации BIOS устанавливает обработчик прерывания int 13h. Этот обработ­чик умеет считывать и записывать отдельные секторы жестких и гибких дисков и производить некоторые другие операции над дисковыми устройствами. Пер­вичные загрузчики ОС обычно пользуются этим сервисом. Некоторые ОС, на­пример MS/DR DOS, используют этот сервис не только при загрузке, но и при работе, и, благодаря этому, могут не иметь собственного модуля управления дисками. Если загрузка происходит с жесткого диска, BIOS загружает в память и запус­кает нулевой сектор нулевой дорожки диска. Этот сектор обычно содержит не первичный загрузчик операционной системы, a MBR (Master Boot Record- главная загрузочная запись). Эта программа обеспечивает разбиение физиче­ского жесткого диска на несколько логических разделов (partition) и возмож­ность попеременной загрузки различных ОС, установленных в этих разделах (рис. 3.16).

Разбиение физического диска на логические программа MBR осуществляет на основе содержащейся в ее теле таблицы разделов (partition table), которая содержит границы и типы разделов. MBR перехватывает прерывание Int 13h и транслирует обращения к дисковой подсистеме так, что обращения к логиче­скому диску N преобразуются в обращения к N-ному разделу физического диска.

Один из разделов диска должен быть помечен как активный или загрузочный. MBR загружает начальный сектор этого раздела - обычно это и есть первичный загруз­чик ОС. Многие реализации MBR, в том числе и поставляемая с Solaris, могут пре­доставлять пользователю выбор раздела, с которого следует начинать загрузку. Выбор обычно предоставляется в форме паузы, в течение которой пользователь может нажать какую-то клавишу или комбинацию клавиш. Если ничего не будет на­жато, начнется загрузка с текущего активного раздела.

Так или иначе, но загрузочный сектор- по совместительству, первичный за­грузчик Solaris оказывается в памяти и начинает исполняться. Исполнение его состоит в том, что он загружает - нет, еще не ядро, а специальную программу, называемую DCU (Device Configuration Utility, утилита конфигурации устройств). Основное назначение этой программы - имитация сервисов консольного мо­нитора компьютеров фирмы Sun на основе процессоров SPARC.

DCU производит идентификацию установленного в машине оборудования. Пользователь может вмешаться в этот процесс и, например, указать системе, что такого-то устройства в конфигурации нет, даже если физически оно и при­сутствует, или установить драйверы для нового типа устройств. Драйверы, ис­пользуемые DCU, отличаются от драйверов, используемых самим Solaris, на­зываются они BEF (Boot Executable File), и начинают исполнение, как и сама DCU, в реальном режиме процессора х86.

Найдя все необходимое оборудование, DCU запускает вторичный загрузчик Solaris. Логический диск, выделенный Solaris, имеет внутреннюю структуру и также разбит на несколько разделов (рис. 3,17). Чтобы не путать эти разделы с разделами, создаваемыми MBR, их называют слайсами (slice). Загрузочный диск Solaris должен иметь минимум два слайса - Root (корневая файловая система) и Boot, в котором и размещаются вторичный загрузчик и DCU.

Вторичный загрузчик, пользуясь BEF-модулем загрузочного диска для доступа к этому диску, считывает таблицу слайсов и находит корневую файловую систе­му. В этой файловой системе он выбирает файл /kernel/unix, который и являет­ся ядром Solaris. В действительности, вторичный загрузчик исполняет команд­ный файл, в котором могут присутствовать условные операторы, и, в зависимости от тех или иных условий, в качестве ядра могут быть использова­ны различные файлы, /kernel/unix используется по умолчанию. Кроме того, пользователю предоставляется пауза (по умолчанию 5 секунд), в течение которой он может прервать загрузку по умолчанию и приказать загру­зить какой-то другой файл, или тот же файл, но с другими параметрами.

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

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

Существуют ОС, которые не умеют самостоятельно выполнять весь цикл бутстрапа. Они используют более примитивную операционную систему, ко­торая исполняет их вторичный (или какой это уже будет по счету) загруз­чик, и помогает этому загрузчику поместить в память ядро ОС. На процес­сорах х8б в качестве стартовой системы часто используется MS/DR DOS, а загрузчик новой ОС оформляется в виде ЕХЕ-файла.

Таким образом устроены системы MS Windows l.x-З.х, Windows 95/98/ME, DesqView и ряд других "многозадачников" для MS DOS. Таким же образом загружается сервер Nowell Netware, система Oberon для х8б, программы, на­писанные для различных расширителей DOS (DOS extenders) и т. д. Многие из перечисленных систем, например Windows (версии младше 3.11 - в обя­зательном порядке, а 3.11 и 95/98/ME только в определенных конфигураци­ях) используют DOS и во время работы в качестве дисковой подсистемы. Тем не менее, эти программные пакеты умеют самостоятельно загружать пользовательские программы и выполнять все перечисленные во введении функции и должны, в соответствии с нашим определением, считаться пол­ноценными операционными системами.