Теория операционных систем

    Введение
    Глава 1. Представление данных в вычислительных системах
    Глава 2. Машинные языки
    Глава 3. Загрузка программ
    Глава 4. Управление оперативной памятью
     Глава 5. Сегментная и страничная виртуальная память
    Глава 6. Компьютер и внешние события
    Глава 7. Параллелизм с точки зрения программиста
    Глава 8. Реализация многозадачности на однопроцессорных компьютерах
    Глава 9. Внешние устройства
    Глава 10. Драйверы внешних устройств
    Глава 11. Файловые системы
    Глава 12. Безопасность
    Приложение. Обзор архитектур современных ОС


Глава 1. Представление данных в вычислительных системах

  • Представление данных в вычислительных системах
  • Введение в двоичную арифметику
  • Представление рациональных чисел
  • Представление текстовых данных
  • Представление изображений
  • Представление звуков
  • Упаковка данных
  • Контрольные суммы
  • Введение в криптографию


Представление данных в вычислительных системах

Из курсов компьютерного ликбеза известно, что современные компьютеры оперируют числовыми данными в двоичной системе счисления, а нечисловые данные (текст, звук, изображение) так или иначе переводят в цифровую форму (оцифровывают).
В силу аппаратных ограничений процессор оперирует числами фиксированной разрядности. Количество двоичных разрядов основного арифметико-логического устройства (АЛУ) называют разрядностью процессора (впрочем, ниже мы увидим примеры, когда под разрядностью процессора подразумевается и нечто другое). Процессоры современных систем коллективного пользования (z90, UltraSPARC, Alpha) имеют 64-разрядные АЛУ, хотя в эксплуатации остается еще довольно много 32-разрядных систем, таких, как System/390. Персональные компьютеры (х86, PowerPC) и серверы рабочих групп имеют 32-разрядные процессоры. Процессоры меньшей разрядности — 16-, 8- и даже 4-разрядные — широко используются во встраиваемых приложениях.
В прошлом встречались и процессоры, разрядность которых не являлась степенью числа 2, например 36- или 48-разрядные, но из-за сложностей переноса программного обеспечения и обмена данными между системами с некратной разрядностью такие машины постепенно вымерли.


Введение в двоичную арифметику

А для такой низкой жизни были числа,
Как домашний, подъяремный скот
Потому, что все оттенки смысла
Умное число передаст
Н. Гумилев

Арифметические операции над двоичными числами осуществляются при помощи алгоритма, который в школе изучают под названием "сложение в столбик".

Таблица 1.1. Таблица сложения одноразрядных двоичных чисел

0 + 0 = 00
0+1 =01
1+0 = 01
1 + 1 = 10

Из табл. 1.1 видно, что результат сложения двух одноразрядных чисел является двухразрядным (двузначным) числом, а результат сложения двух N-разрядных — N+1 -разрядным. Образующийся дополнительный бит называется битом переноса (carry bit).
При сложении двоичных чисел в столбик мы выписываем их друг под другом (пример 1.1). Два младших разряда мы складываем в соответствии с табл. 1.1. При сложении последующих двух разрядов мы должны учитывать не только эти разряды, но и бит переноса из младшего разряда, т. е. производить сложение в соответствии с табл. 1.2. Из этой таблицы видно, что для трех одноразрядных слагаемых все равно получается только два бита суммы, так что для работы со всеми последующими разрядами мы можем обойтись только одним битом переноса (рис. 1.1).

Таблица 1.2. Таблица сложения с учетом переноса

0+0+0=0
0+1+0=01
1+0+0=01
0+0+1=01
1+1+0=10
1+0+1=10
0+1+1=10
1+1+1=11

Пример 1.1. Сложение двух 8-разрядных чисел (83 + 56 = 139)

 |  | 1 | 1 |  |  |  |  |  | перенос
+ | 0 | 1 | 0 | 1 | 0 | 0 | 1 | 1 | 1-е слагаемое
 | 0 | 0 | 1 | 1 | 1 | 0 | 0 | 0 | 2-е слагаемое
 | 1 | 0 | 0 | 0 | 1 | 0 | 1 | 1 | результат

Рис. 1.1. 8-разрядный двоичный сумматор

Дополнительный, 9-й разряд, образующийся при сложении 8-разрядных чисел, переносится в специальный бит слова состояния процессора, который также называется битом переноса, и обычно обозначается буквой С (от англ, саrrу — перенос). Этот бит можно использовать как условие в командах условного перехода, а также для реализации 16-разрядной операции сложения или одноименной операции большей разрядности на 8-разрядном АЛУ.
При операциях над беззнаковыми (неотрицательными) числами бит переноса можно интерпретировать как признак переполнения: т. е. того, что результат нельзя представить числом с разрядностью АЛУ. Игнорирование этого бита может приводить к неприятным последствиям: например, складывали мы 164 + 95, а получили в результате 3.
Иногда этот эффект, называемый "оборачиванием счетчиков", имеет и полезное применение. Например, используя "часовой" кварцевый генератор с частотой 32 768 Гц и 15-разрядный двоичный счетчик, мы можем отмерять секунды по появлению бита переноса в 16-м разряде и избавляемся от необходимости сбрасывать сам счетчик.
Двоичное вычитание может выполняться аналогичным образом, только необходимо использовать не таблицы сложения, а таблицы вычитания для двух и трех слагаемых. Не утомляя себя и читателя выписыванием этих таблиц, скажем сразу, что операция двоичного вычитания эквивалентна операции двоичного сложения уменьшаемого с двоичным дополнением вычитаемого. Двоичное дополнение строится таким образом: все биты числа инвертируются (нули заменяются на единицы, и наоборот), а затем к результату добавляется единица. Доказательство этого утверждения мы оставляем любопытному читателю, а сами просто рассмотрим пример 1.2.

Пример 1.2. Вычитание чисел (83 — 56 = 27)

Построение двоичного дополнения 56 :

0 | 0 | 1 | 1 | 1 | 0 | 0 | 0 | число
1 | 1 | 0 | 0 | 0 | 1 | 1 | 1 | побитовое отрицание
1 | 1 | 0 | 0 | 1 | 0 | 0 | 0 | побитовое отрицание+1

Сложение с двоичным дополнением (83 + 56) mod 256 = 27:
 | 1 |  | 0 |  |  |  |  |
+ | 0 | 1 | 0 | 1 | 0 | 0 | 1 | 1
 | 1 | 1 | 0 | 0 | 1 | 0 | 0 | 0
1 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 1

Из примера видно, что эквивалентность между операциями неполная: сложение с дополнением сопровождается переносом в 9-й разряд, которого нет при прямом вычитании. Этот факт приводит к тому, что мы уже не можем считать перенос в 9-й разряд критерием того, что результат сложения не может быть представлен 8-ю битами. Точный критерий переполнения для целочисленных операций сложения и вычитания теперь звучит так: переполнение произошло, если перенос в 9-й бит (для 8-разрядного АЛУ) не равен переносу в 10-й бит.
Но в остальном двоичное дополнение сильно упрощает жизнь проектировщикам процессоров: вместо двух устройств, сумматора и дифференциатора (по-русски, сложителя и вычитателя), нам достаточно иметь только сумматор. Кроме того, можно представлять отрицательные числа в двоично-дополнительном коде (табл. 1.3). При таком представлении признак переполнения называют также признаком потери знака.
Видно, что четыре бита позволяют нам представить либо ноль и натуральные числа от 1 до 15, либо целые числа от - 8 до 7. Во втором случае, старший бит может интерпретироваться как знаковый — если он равен 1, число отрицательное, если 0 — положительное. Для манипулирования числами в обоих представлениях можно использовать одни и те же команды сложения и вычитания, различие возникает только, когда мы начинаем интерпретировать результаты сравнения таких чисел или сами эти числа (например, переводить их в десятичный формат).
Для команд умножения и деления трюк с двоичным дополнением не проходит, поэтому процессоры, использующие такое представление данных, вынуждены иметь по две пары команд умножения и деления, знаковые и беззнаковые. Любознательному читателю предлагается самостоятельно Разработать алгоритмы умножения и деления двоичных чисел в двоично-Дополнительном представлении. За основу для этих алгоритмов опять-таки рекомендуется взять школьные методы умножения и деления многозначных чисел "в столбик".

Таблица 1.3. Двоичное представление знаковых и беззнаковых чисел

Беззнаковое | Знаковое | Двоичное
7 | +7 | 0111
6 | +6 | 0110
5 | +5 | 0101
4 | +4 | 0100
3 | +3 | 0011
2 | +2 | 0010
1 | +1 | 0001
0 | 0 | 0000
15 | -1 | 1111
14 | -2 | 1110
13 | -3 | 1101
12 | -4 | 1100
11 | -5 | 1011
10 | -6 | 1010
9 | -7 | 1001
8 | -8 | 1000

Подавляющее большинство современных процессоров использует двоично-дополнительное представление для целых чисел. В те времена, когда компьютеры были большими, встречались системы, применявшие для этой цели дополнительный, знаковый бит: число —1 представлялось так же, как +1, но с установленным знаковым битом. Такие процессоры должны были иметь отдельные команды для беззнаковых и знаковых арифметических операций и более сложное АЛУ. Кроме того, при таком представлении возникает специфическая проблема "отрицательного нуля".
Иногда наравне с двоичным используется и специфическое, так называемое двоично-десятичное представление чисел (рис. 1.2). Это представление особенно удобно для приложений, которые постоянно вынуждены использовать десятичный ввод и вывод (микрокалькуляторы, часы, телефоны с автоопределителем номера и т. д.) и имеют небольшой объем программной памяти, в который нецелесообразно помещать универсальную процедуру преобразования чисел из двоичного представления в десятичное и обратно.
В таком представлении десятичная цифра обозначается тетрадой, четырьмя битами. Цифры от 0 до 9 представляются 0, 1111 недопустимы.своими двоичными эквивалентами, а комбинации битов 1010, 1011, 1100, 1101, 111

Рис. 1.2. Двоично-десятичное представление чисел

Вместо арифметических операций над такими числами большинство современных микропроцессоров предлагают использовать для их сложения и вычитания обычные бинарные операции, а потом исправлять возникающие при этом недопустимые значения при помощи специальной команды двоично-десятичной коррекции. Алгоритм работы этой команды читателю предлагается разработать самостоятельно. Для него потребуется информация о том, происходил ли при сложении двоичный перенос из младшей тетрады. Процессоры, предоставляющие такую команду, имеют и бит межтетрадного переноса.
Если операция производится над числами, имеющими 16 или более двоичных разрядов (4 и более двоично-десятичных), для коррекции нам недостаточно одного межтетрадного переноса — надо иметь по биту переноса на каждую из пар последовательных тетрад. Так далеко ни один из существующих процессоров не заходит. Например, 32-разрядные процессоры х86 имеют команды двоично-десятичной коррекции только для операций над числами с 8-ю двоичными (двумя двоично-десятичными) разрядами.


Представление рациональных чисел

— Чему равна лошадиная сила?
— Это сила лошади весом один килограмм и ростом один метр
—Да где же вы видели такую лошадь?
— А ее вообще мало кто видел. Она хранится под аргоновый колпаком в Палате мер и весов под Парижем.

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

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

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

Рис. 1.3. Число с двоичной фиксированной точкой

Примечание
В русском языке принято называть разделитель целой и дробной частей позиционной дроби запятой, а в английском — точкой. Современная вычислительная терминология формировалась на английском языке, и английские словосочетания fixed-point и floating-point часто переводились на русский язык буквально, поэтому словосочетания "фиксированная точка" и "плавающая точка" прочно вошли в русскую компьютерную лексику, и мы будем использовать именно их.

Современные процессоры обычно не предоставляют арифметических операций с фиксированной точкой, однако никто не запрещает программисту или разработчику компилятора реализовать такие операции на основе стандартных целочисленных операций и команд битового сдвига.
Если нам необходима точность вычислений, определенная количеством десятичных знаков (например, при подсчете рублей с точностью до копеек), нужно помнить, что большинство десятичных дробей в позиционной двоичной записи представляют собой периодические (с бесконечным числом знаков) дроби. Для обеспечения требуемой точности нам следует либо иметь дополнительные двоичные позиции, либо вместо фиксированной двоичной использовать фиксированную десятичную точку: складывать такие числа по-прежнему можно с помощью целочисленных операций, но коррекцию умножения и деления необходимо выполнять с помощью умножения (деления) на степень десяти, а не на степень двойки.
В научных и инженерных вычислениях и цифровой обработке сигналов шире применяются числа с плавающей двоичной точкой. Дело в том, что исходные данные для таких вычислений обычно являются результатами измерений физических величин. Все физически реализуемые способы измерений сопровождаются ошибками: для объяснения этого печального факта часто ссылаются на принцип неопределенности Гейзенберга [Карнап 1971], но при практических измерениях гораздо большую роль играют следующие виды ошибок.

  1. 1. Тепловые шумы — температурные колебания измеряемого объекта и измерительного инструмента.
  2. 2. Инструментальные погрешности, обусловленные различными ошибками при изготовлении или калибровке инструмента или изменением его параметров с момента последней калибровки, например, за счет механического износа.
  3. 3. Методологические ошибки, обусловленные не только неумением пользоваться
  4. инструментом, но и тем фактом, что наши инструменты не имеют непосредственного контакта с явлением, которое мы хотим измерить: например, измеряя динамику валового внутреннего продукта, мы, в действительности, измеряем также и эффекты, обусловленные динамикой денежной массы. Возможна и обратная ситуация, когда наш инструмент имеет слишком тесный контакт с измеряемым объектом и искажает его поведение. Из школьного курса физики известны такие источники методологических ошибок, как падение напряжения на вольтметре или внутреннее сопротивление амперметра.

Если с принципом Гейзенберга бороться невозможно, то тепловые и инструментальные ошибки можно уменьшать, первые — снижением температу-Pbi, а вторые — большей тщательностью изготовления и калибровки, ужесточением условий эксплуатации и хранения или совершенствованием самого инструмента (например, интерферометрический лазерный дальномер при равной тщательности изготовления будет намного точнее рулетки).
Однако ни абсолютный нуль температуры, ни абсолютная точность изготовления физически недостижимы, и даже приближение к ним в большинстве ситуаций недопустимо дорого.
Сомневающемуся в этом утверждении читателю предлагается представить, во что бы превратилась его жизнь, если бы при взвешивании продуктов в магазине сами продукты и весы охлаждались хотя бы до температуры жидкого азота, а плотницкий метр надо было бы хранить с теми же предосторожностями, что и метрологический эталон.
Поэтому на практике целесообразно смириться с погрешностями измерений, а на приборе указать точность, которую его изготовитель реально может гарантировать при соблюдении пользователем условий хранения и эксплуатации. Полному искоренению подлежат только методологические ошибки, да и с ними во многих ситуациях приходится смириться из-за неприемлемо высокой стоимости прямых измерений.
Относительно дешевый способ повышения точности — многократные измерения и усреднение результата, но этот метод повышает стоимость измерений и, если измерения производятся одним и тем же инструментом, не позволяет устранить инструментальные и методологические ошибки.
И тепловые шумы, и инструменты порождают ошибки, которые при прочих равных условиях пропорциональны измеряемой величине. Десяти метровая рулетка имеет инструментальную ошибку, измеряемую миллиметрами, а десятисантиметровый штангенциркуль — микронами. Поэтому ошибку измерений часто указывают не в абсолютных единицах, а в процентах.
При записи результатов измерений хорошим тоном считается указывать точность этих измерений и не выписывать десятичные цифры (или знаки после запятой), значения которых лежат внутри границ ошибки. Цифры перед десятичной запятой в этом случае заменяются нулями, а после нее -просто отбрасываются. Для сокращения записи больших значений, измеренных с относительно небольшой точностью, в научной и инженерной литературе используется экспоненциальная запись чисел: незначащие (в данном случае — лежащие внутри границ ошибки) младшие десятичные цифры отбрасываются, и после числа добавляется 10N, где N — количество отброшенных цифр. Обычно рекомендуют нормализовать такую запись, перемещая десятичную запятую на место после старшей цифры (смещение запятой на одну позицию влево соответствует увеличению показателя степени множителя на единицу). В соответствии с этими правилами величина 2128506 ± 20 преобразуется к виду 2,12850х106 (обратите внимание, что младший ноль в данном случае является значащим).
При бытовых измерениях обычно обходятся двумя-тремя значащими десятичными цифрами. В научных и инженерных измерениях используются и большие точности, но на практике измерения с точностью выше шестого десятичного знака встречаются разве что при разработке и калибровке метрологических эталонов. Для сравнения, цифровая телефония обходится 8-ю битами, а аналого-цифровой преобразователь бытовой звуковой карты имеет 12, реже 14 значащих двоичных разрядов. Большинство современных карт считаются 16-битными, но на практике младшие разряды их АЦП оцифровывают только тепловой шум усилителя и собственных старших разрядов, а также ошибки калибровки того и другого. Честные (т. е. такие, у которых все разряды значащие) 16-разрядные АЦП используются в профессиональной звукозаписывающей и измерительной аппаратуре, 24-разрядные АЦП относятся к прецизионной аппаратуре, а 32-разрядные на практике не применяют.
Вычислительные системы широко используют представления чисел с плавающей точкой, только не десятичной, а двоичной (рис. 1.4). Идея этого представления состоит в том, чтобы нормализовать позиционную двоичную дробь, избавившись от незначащих старших нулевых битов и освободив место для [возможно] значащих младших разрядов. Сдвиг, который нужен для нормализации, записывается в битовое поле, называемое порядком. Само же число называется мантиссой.

Рис. 1.4. Число с плавающей двоичной точкой

Число с плавающей точкой, таким образом, состоит из двух битовых полей — мантиссы М и порядка Е. Число, представленное двумя такими полями, равно Мх2Е. Нормализация состоит в отбрасывании всех старших нулей, поэтому старший бит нормализованной двоичной мантиссы всегда равен 1. Большинство современных реализаций чисел с плавающей точкой используют этот факт для того, чтобы объявить незначащими не только старшие нули, но и эту единицу, и, таким образом, выигрывают дополнительный бит точности мантиссы.
Сложение двух чисел с плавающей точкой состоит в денормализации мантисс (совмещении двоичных точек), их сложении и нормализации результата. Перемножение таких чисел, соответственно, выполняется перемножением мантисс, сложением порядков и опять-таки нормализацией результата.
В некоторых старых архитектурах, например БЭСМ-6, все арифметические операции выполнялись над числами с плавающей точкой, однако существовала возможность выключить нормализацию мантиссы. Ненормализованные числа с плавающей точкой использовались для представления значений с фиксированной точкой, в том числе и целочисленных.
Стандарт языка ANSI С требует наличия 32-битового (8-разрядный порядок и 24-разрядная мантисса) и 64-битового (16-разрядный порядок и 48-разрядная мантисса) представлений чисел с плавающей точкой, которые называются, соответственно, числами одинарной и двойной точности (float и double float или просто double). Числа двойной точности, конечно же, не могут быть результатом прямых измерений физических величин, но позволяют избежать накопления ошибок округления при вычислениях.
Большинство современных процессоров общего назначения и ориентированных на приложения цифровой обработки сигналов (ЦОС) предоставляет операции над такими числами, а зачастую и над числами большей разрядности. Операции практически всегда включают сложение, вычитание, умножение и деление. Часто на уровне системы команд реализуются и элементарные функции: экспонента, логарифм, квадратный корень, синус, косинус и т. д. Процессоры ЦОС нередко предоставляют и отдельные шаги дискретного преобразования Фурье.
Количество операций с плавающей точкой в секунду (Floating Operations Per Second, FLOPS, в наше время чаше говорят о Mflops — миллионах операций в секунду), которые может исполнять процессор, является одной из важных его характеристик, хотя и не для всех приложений эта характеристика критична.


Представление текстовых данных

Вы можете прочитать третью снизу строку таблицы?
— Н, К, И, М... Доктор, у вас криво настроена кодировка!

Все используемые способы представления текстовых данных, так или иначе, сводятся к нумерации символов алфавита (или множества символов системы письменности интересующего нас языка, которое используется вместо алфавита — слоговой азбуки, иероглифов и т. д.) и хранения полученных целых чисел наравне с обычными числами. Способ нумерации называется кодировкой, а числа — кодами символов.
Для большинства кодировок языков, использующих алфавитную письменность (латиница, кириллица, арабский алфавит, еврейский и греческий языки) достаточно 127 символов. Самая распространенная система кодирования латиницы — ASCII — использует 7 бит на символ. Другие алфавиты обычно кодируются более сложным образом: символы алфавита получают коды в диапазоне от 128 до 255, а коды от 0 до 127 соответствуют кодам ASCII. Таким образом, любой символ этих алфавитов, в том числе и в многоязычных текстах, использующих сочетание национального алфавита и латиницы, может быть представлен 8-ю битами или одним байтом. Но для японских слоговых азбук, а тем более для китайской иероглифики, 255 кодов явно
недостаточно, и приходится использовать многобайтовые кодировки. Распространенное обозначение таких кодировок — DBCS (Double Byte Character Set — набор символов, кодируемый двумя байтами).
Двух байтов, в принципе, достаточно, чтобы сформировать единую кодировку для всех современных алфавитов и основных подмножеств иерогли-фнки. Попытка стандартизовать такое представление — Unicode — пока что не имеет полного успеха. Отчасти это можно объяснить тем, что потребность в представлении разноязыких текстов в пределах одного документа ограничена, кроме того, слишком много старого программного обеспечения использует предположение о том, что символ занимает не более байта. Такие программы не могут быть легко преобразованы для работы с Unicode.
Используются две основные кодировки латиницы -- ASCII и EBCDIC (Extended Binary Coded Decimal Information Code), применяемая системами AS/400, System/370, System/390 и z90 фирмы IBM. Для представления русского варианта кириллицы существует три основных кодировки: альтернативная (известная также как ср866), ср!251 и KOI-8 и ряд менее широко используемых (ISO 8892-5 и др.).
Арифметические операции над такими "числами" обычно бессмысленны, зато большой смысл имеют операции сравнения. Операции сравнения в современных процессорах реализованы как неразрушающее вычитание — мы производим те же действия, что и при обычном двоичном вычитании, но запоминаем не сам результат, а лишь флаги знака, переноса и равенства результата нулю. На основании значений этих флагов определяем результат сравнения: если разность равна нулю, сравниваемые символы одинаковы, если она положительна или отрицательна, один из символов больше или меньше другого.
Естественно, чаще всего мы хотим интерпретировать результаты посимвольного сравнения как лексикографическое (алфавитное) "больше" или "меньше" (для русского алфавита, "а" меньше, чем "б"). Проще всего это делать, если нумерация символов совпадает с их порядком в алфавите, но далеко не для всех распространенных кодировок это справедливо.
В кодировке ASCII (American Standard Code for Information Interchange — Американский стандартный код обмена информацией), например, все символы латиницы, цифры и большинство распространенных знаков препинания обозначаются кодами от 0 до 127, при этом коды букв расставлены в соответствии с латинским алфавитом. В США, как и в других англоязычных странах, латинский алфавит используется в неизмененном виде, а для передачи звуков, отсутствовавших в оригинальном латинском языке, применяется причудливая орфография. Большинство других европейских алфавитов обходит проблему несоответствия фонетик путем расширения набора символов латиницы — например, в немецком языке добавлены буквы ö, ä, ü и ß. Другие языки имеют множество различных "акцентов" и "диакритических символов", расставляемых над буквами для указания особенностей произношения. Некоторые языки, например французский, используют одновременно и расширения алфавита, и причудливую орфографию. Нередко встречаются и дополнительные знаки препинания, например, ¿ и ¡ в испанском языке.
Все символы-расширения в каждом из национальных алфавитов находятся на определенных местах, но при использовании кодировки ASCII для представления этих символов сохранить этот порядок невозможно — соответствующие коды уже заняты. Так, в кодировке ISO 8895-1 все символы латиницы кодируются в соответствии с ASCII, а коды расширений более или менее произвольно раскиданы между 128 и 255. Более яркий пример той же проблемы — кодировки кириллицы семейства KOI, в которых символы кириллицы сопоставлены фонетически соответствующим им символам латиницы (филе нот фоунд, или, наоборот, esli wy не movete pro^itatx eto po-russki, smenite kodirovku). Естественно, совместить такое сопоставление и алфавитную сортировку невозможно.
Стандартным решением в таких случаях является использование для сравнения и лексикографической сортировки промежуточных таблиц, в которых для каждого допустимого кода указан его номер в лексикографическом порядке. На уровне системы команд процессоры этого обычно не делают, но на уровне библиотек языков высокого уровня это осуществляется очень часто.
При обмене данными между системами, использующими разные кодировки, необходимо учитывать этот факт. Стандартный способ такого учета, применяемый во многих кросс-платформенных форматах документов (HTML, MIME) — это сообщение где-то в теле документа (обычно в его начале) или в передаваемой вместе с документом метаинформации об используемых языке и кодировке. Большинство средств просмотра почты и документов HTML умеют интерпретировать эту метаинформацию, поэтому конечный пользователь все реже и реже сталкивается с необходимостью самостоятельно разбираться в различных кодировках.
Проблемы возникают, когда метаинформация не указана или, что еще хуже, указана неправильно. Последнее особенно часто встречается при неправильной настройке почтового клиента у отправителя. Для борьбы с такими сообщениями разработаны даже специальные программы, применяющие частотный анализ текста для определения реально использованной кодировки.
Два основных подхода к представлению форматированного текста — это языки разметки и сложные структуры данных, используемые в текстовых процессорах. Примерами языков разметки являются HTML, LATEX, troff. В этих языках обычный текст снабжается командами, указывающими на то, каким шрифтом следует отображать конкретный фрагмент текста и как его следует форматировать (например, какова ширина параграфа, следует ли делать переносы в словах, надо ли выравнивать текст по левому или правому краю). Команды представляют собой специальные последовательности символов той же кодировки, в которой набран и основной текст.
Отформатированные таким образом тексты могут генерироваться как вручную (например, LATEX ориентирован именно на ручной набор текста), так и различными автоматизированными средствами.


Представление изображений

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

Рис. 1.5. Двухмерное векторное изображение

Каждый примитив описывается своими геометрическими координатами. Точность описания в разных форматах различна, нередко используются числа с плавающей точкой двойной точности или с фиксированной точкой и точностью до 16-го двоичного знака.
Координаты примитивов бывают как двух-, так и трехмерными. Для трехмерных изображений, естественно, набор примитивов расширяется, в него включаются и различные поверхности — сферы, эллипсоиды и их сегменты, параметрические многообразия и др. (рис. 1.6).

Рис. 1.6. Трехмерное векторное изображение

Двухмерные векторные форматы очень хороши для-представления чертежей, диаграмм, шрифтов (или, если угодно, отдельных букв шрифта) и отформатированных текстов. Такие изображения удобно редактировать — изображения и их отдельные элементы легко поддаются масштабированию и другим преобразованиям. Примеры двухмерных векторных форматов — PostScript, PDF (Portable Document Format, специализированное подмножество PostScript), WMF (Windows MetaFile), PCL (Printer Control Language, система команд принтеров, поддерживаемая большинством современных лазерных и струйных печатающих устройств). Примером векторного представления движущихся изображений является MacroMedia Flash. Трехмерные векторные форматы широко используются в системах автоматизированного проектирования и для генерации фотореалистичных изображений методами трассировки лучей и т. д.
Однако преобразование реальной сцены (например, полученной оцифровкой видеоизображения или сканированием фотографии) в векторный формат представляет собой сложную и, в общем случае, неразрешимую задачу. Программы-векторизаторы существуют, но потребляют очень много ресурсов, а качество изображения во многих случаях получается низким. Самое же главное — создание фотореалистичных (фотографических или имитирующих фотографию) изображений в векторном формате, хотя теоретически и, возможно, на практике требует большого числа очень сложных примитивов. Гораздо более практичным для этих целей оказался другой подход к оцифровке изображений, который использует большинство современных устройств визуализации: растровые дисплеи и многие печатающие устройства.
В растровом формате изображение разбивается на прямоугольную матрицу элементов, называемых пикселами (слегка искаженное PICture ELement — этемент картинки). Матрица называется растром. Для каждого пиксела определяется его яркость и, если изображение цветное, цвет. Если, как это часто бывает при оцифровке реальных сцен или преобразовании в растровый формат (растеризации) векторных изображений, в один пиксел попали несколько элементов, их яркость и цвет усредняются с учетом занимаемой площади. При оцифровке усреднение выполняется аналоговыми контурами аналого-цифрового преобразователя, при растеризации — алгоритмами анти-алиасинга.
Размер матрицы называется разрешением растрового изображения. Для печатающих устройств (и при растеризации изображений, предназначенных для таких устройств) обычно задается неполный размер матрицы, соответствующей всему печатному листу, а количество пикселов, приходящихся на вертикальный или горизонтальный отрезок длиной 1 дюйм; соответствующая единица так и называется — точки на дюйм (DPI, Dots Per Inch).
Для черно-белой печати обычно достаточно 300 или 600 DPI. Однако принтеры, в отличие от растровых терминалов, не умеют манипулировать яркостью отдельной точки, поэтому изменения яркости приходится имитировать, разбивая изображение на квадратные участки и регулируя яркость относительным количеством черных и белых (или цветных и белых при цветной печати) точек в этом участке. Для получения таким способом приемлемого качества фотореалистичных изображений 300 DPI заведомо недостаточно, и даже бытовым принтерам приходится использовать гораздо более высокие разрешения, вплоть до 2400 DPI.
Вторым параметром растрового изображения является разрядность одного пиксела, которую называют цветовой глубиной. Для черно-белых изображений достаточно одного бита на пиксел, для градаций яркости серого или цветовых составляющих изображения необходимо несколько битов (рис. 1.7). В цветных изображениях пиксел разбивается на три или четыре составляющие, соответствующие разным цветам спектра. В промежуточных данных, используемых при оцифровке и редактировании растровых изображений, цветовая глубина достигает 48 или 64 бит (16 бит на цветовую составляющую). Яркостный диапазон современных Мониторов, впрочем, позволяет ограничиться 8-ю битами, т. е. 256 градациями, на одну цветовую составляющую: большее количество градаций просто незаметно глазу.

Рис. 1.7. Растровое изображение

Наиболее широко используемые цветовые модели — это RGB (Red, Green, Blue — красный, зеленый, синий, соответствующие максимумам частотной характеристики светочувствительных пигментов человеческого глаза), CMY (Cyan, Magenta, Yellow — голубой, пурпурный, желтый, дополнительные к RGB) и CMYG — те же цвета, но с добавлением градаций серого. Цветовая модель RGB используется в цветных кинескопах и видеоадаптерах, CMYG — в цветной полиграфии.
В различных графических форматах используется разный способ хранения пикселов. Два основных подхода — хранить числа, соответствующие пикселам, одно за другим, или разбивать изображение на битовые плоскости -сначала хранятся младшие биты всех пикселов, потом — вторые и так далее. Обычно растровое изображение снабжается заголовком, в котором указано его разрешение, глубина пиксела и, нередко, используемая цветовая модель.


Представление звуков

Два основных подхода к хранению звуковых файлов можно сопоставить с векторным и растровым способами хранения изображений: это MIDI и подобные ему форматы, и оцифрованный звук.
В формате MIDI звук генерируется синтезатором, который умеет порождать звуки различного тембра, высоты, длительности и громкости. Тембры этих звуков обычно более или менее соответствуют звукам распространенных музыкальных инструментов. Вместо собственно звука хранится последовательность команд этого синтезатора. Используя в качестве звуковых примитивов фонемы человеческого языка, этот подход можно применить и для синтеза речи.
MIDI-файлы имеют малый объем и, при наличии аппаратного синтезатора, не требуют ресурсов центрального процессора для воспроизведения, поэтому их часто используют в качестве фонового озвучивания игровых программ и Web-страниц. К недостаткам этого формата следует отнести тот факт, что качество его воспроизведения определяется качеством синтезатора, которое у дешевых звуковых карт оставляет желать лучшего, и то, что далеко не всякий звук можно воспроизвести таким способом.
Задача преобразования реального звука в MIDI сродни задаче векторизации растрового изображения и другим задачам распознавания образов, и в общем виде не разрешима.
Оцифрованный звук, напротив, является результатом простого осуществления аналого-цифрового преобразования реального звука. Характеристиками такого звука являются частота дискретизации, разрешение АЦП и количество каналов — моно или стерео.


Упаковка данных

Данные многих форматов имеют значительный объем, поэтому их хранение и передача зачастую требуют значительных ресурсов. Одним из способов решения этой проблемы является повышение емкости запоминающих устройств и пропускной способности каналов связи. Однако во многих случаях применима и более дешевая альтернатива этим методам — упаковка данных.
Научной основой всех методов упаковки является теория информации: данные, в которых имеются статистические автокорреляции, называются избыточными или имеющими низкую энтропию. Устранив эти автокорреляции, т. е. повысив энтропию, объем данных можно уменьшить без потери смысла, а зачастую и с возможностью однозначно восстановить исходные данные. Методы повышения энтропии, которые не позволяют по упакованному потоку восстановить исходный, называются необратимыми, приблизительными или сжимающими с потерями (losing compression). Соответственно, методы, которые позволяют это сделать, называются обратимыми, точными, или сжимающими без потерь (losless compression).
Один из первых методов упаковки был предложен задолго до разработки современной теории информации; в 1844 году Сэмюэл Морзе построил первую линию проволочного телеграфа. Система кодировки букв, известная как Азбука Морзе (табл. 1.4), использовала для представления различных букв алфавита посылки различной длины, при этом длина посылки зависела от частоты использования соответствующего символа в английском языке. Часто встречающиеся символы кодировались более короткими последовательностями.

Таблица 1.4. Русская азбука Морзе

Буква | Символы Морзе | Слово | Буква | Символы Морзе | Слово
А | .- | а-том | Р | .-. | ра-ду-га
Б | -... | бес-са-раб-ка | С |  | са-ма-ра
В | .-- | ва-ви-лон | Т | - | Ток
Г | --. | го-ло-ва | У | ..- |
Д | -.. | до-бав-ка | ф | ..-. | фа-на-тич-ка
Е |  |  | X |  | ха-на-ан-ка
Ж | ...- | жат-ва-зла-ков | ц | -.-. | цы-га-ноч-ка
3 | --.. | звон-бу-ла-та | ч |  | че-ре-му-ха
И |  |  | ш | — - | ше-ре-ме-тев
К | -.- | конс-тан-тин | Щ | --.- | щу-ро-гла-зый
Л | .-.. | ла-до-жан-ка | ю | ..-- |
М | -- | ми-иин | я | .-.- |
Н | -. | но- га | ы | -.-- | ы-ка-ни-е
0 | — | о-ло-во | ь-ъ | -..- |
П | .--. | па-ни-хи-да |  |  |

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

В конце сороковых годов XX века основателем современной теории информации Шенноном, и независимо от него, Фано был разработан универсальный алгоритм построения оптимальных кодов. Более известый аналог этого алгоритма был предложен несколько позже Дэвидом Хаффманом [Huffman 1952]. Принцип построения этих кодов в целом соответствует логике, которой руководствовался Морзе, — кодировать значения, которые часто повторяются в потоке, более короткими последовательностями битов.
Коды Хаффмана и Шеннона-Фано устраняют автокорреляции, соответствующие неравномерности встречаемости символов, но сохраняют без изменений часто встречающиеся последовательности символов, а они ответственны за значительную часть избыточности текстов на естественных и синтетических языках. Для упаковки данных такого рода в конце 70-х Лемпелем и Зиффом было предложено семейство алгоритмов, наиболее известные из которых - LZ77 и LZW [Lempel-Ziv 1978].
Все эти алгоритмы сводятся к поиску в потоке повторяющихся последовательностей и замене этих последовательностей на их номер в динамически формируемом словаре. Различие состоит в способах кодирования номера и формирования словаря. Номер последовательности в словаре должен содержать больше битов, чем символы исходного потока, хотя бы уже для того, чтобы его можно было отличить от символа, поэтому алгоритмы Лемпеля-Зиффа предполагают дальнейшее перекодирование преобразованного потока кодом Хаффмана. Большинство современных архиваторов, такие, как PkZip, GNU Zip, RAR, основаны на вариациях и аналогах алгоритмов Лем-пеля-Зиффа.
При упаковке нетекстовых данных могут применяться и другие способы удаления повторений. Например, при упаковке растровых изображений широко используется метод RLE (Run-Length Encoding), когда повторяющиеся пикселы заменяются счетчиком повторений и значением пиксела.
Наиболее универсальны так называемые арифметические алгоритмы, которые находят и устраняют все автокорреляции, присутствующие во входных данных. Математическое обоснование и принципы работы этих алгоритмов заслуживают отдельной книги и увели бы нас далеко в сторону от темы. К сожалению, из-за больших вычислительных затрат эти алгоритмы и реализующие их программы не получают широкого распространения.
Все перечисленные алгоритмы способны только устранять автокорреляции, уже существующие во входном потоке. Понятно, что если автокорреляций не было, то упаковки не произойдет, поэтому гарантировать уровень упаковки эти алгоритмы не могут.
Для упаковки данных, полученных оцифровкой реальных сигналов, прежде всего изображений и звука, точные алгоритмы не подходят совершенно. Дело в том, что реальный сигнал всегда сопровождается тепловым, так называемым белым (равномерно содержащим все частоты) шумом. Этот шум искажает наличествующие в сигнале автокорреляции, сам же автокорреляций не имеет, поэтому обратимые алгоритмы с зашумленным сигналом справиться не могут.
Чтобы убедиться в этом, можно попробовать упаковать любым, или даже несколькими из распространенных архиваторов трек аудио-CD или цифровую фотографию впрочем, чтобы с цифровой фотографией фокус получился, необходимо, чтобы кадр был снят без обработки встроенным упаковщиком камеры.
Идея обширного семейства алгоритмов, пригодных для сжатия зашумлен-ных сигналов, была позаимствована из принципа работы цифровых фильт-ров-"щумодавов". Шумодав работает следующим образом: он осуществляет над сигналом преобразование Фурье и удаляет из полученного спектрального образа самые слабые частоты, которые ниже порога подавления.
Сигнал при этом, конечно, искажается, но сильнее всего при этом страдает равномерно распределенный по спектру шум, что и требуется (рис. 1.8).

Рис. 1.8. Зашумленный сигнал и его спектральный образ (результат преобразования Фурье), цит. по [Smith 1997]

Алгоритмы JFIF (лежащий в основе распространенного формата хранения растровых изображений JPG), MPEG, MP3 [www.jpeg.org] тоже начинаются с выполнения над входным потоком преобразования Фурье. Но, в отличие от шумодава, JFIF удаляет из полученного спектра не частоты, которые ниже заданного порога, а фиксированное количество частот — конечно же, стараясь отобрать самые слабые. Количество частот, которые надо выкинуть, определяется параметром настройки упаковщика. У JFIF этот параметр так и называется — коэффициентом упаковки, у МРЗ — битрейтом.
Профильтрованный сигнал заведомо содержит автокорреляции — даже если исходный, незашумленный, сигнал их и не содержал, такая фильтрация их создаст — и потому легко поддается упаковке. Благодаря этому, все перечисленные алгоритмы обеспечивают гарантированный уровень упаковки. Они способны сжать в заданное число раз даже чистый белый шум. Понятно, что точно восстановить по подвергнутому такому преобразованию потоку исходный сигнал невозможно, но такой цели и не ставится, поэтому все перечисленные методы относятся к разряду необратимых.
При разумно выбранном уровне упаковки результат — фотореалистичное изображение или музыкальное произведение — на взгляд (или, соответственно, на слух) практически неотличим от оригинала. Различие может показать только спектральный анализ данных. Но если распаковать сжатое с потерями изображение, подвергнуть его редактированию (например, отмасштабировать или пририсовать какой-нибудь логотип), а потом упаковать снова, результат будет удручающим: редактирование привнесет в изображение новые частоты, поэтому велика опасность, что повторная упаковка даже с более низким коэффициентом сжатия откусит какие-то из "полезных" частот изображения. После нескольких таких перепаковок от изображения остается только сюжет, а от музыки — только основной ритм. Поэтому всерьез предлагается использовать приблизительные алгоритмы упаковки в качестве механизма зашиты авторских прав: в спорной ситуации только настоящий автор произведения может предъявить его исходный, неупакованный вариант.
Экспериментальные варианты приблизительных алгоритмов вместо классического разложения по взвешенной сумме синусов и косинусов используют разложение по специальным функциям, так называемым вэйвлетам (wavelet). Утверждается, что вэйвлетная фильтрация при том же уровне сжатия (понятно, что сравнивать по уровню сжатия алгоритмы, которые сжимают что угодно в заданное число раз, совершенно бессмысленно) дает меньший уровень субъективно обнаружимых искажений. Но субъективное восприятие, как известно, сильно подвержено эффекту плацебо (человек склонен видеть улучшение или вообще изменение там, где его нет, если имеет основания предполагать, что изменение должно произойти), зато вэйвлетные алгоритмы сильно уступают обычным вариациям JFIF по производительности, поэтому до сих пор они не вышли из экспериментального состояния.


Контрольные суммы

Хранение данных и их передача часто сопровождается или может сопровождаться ошибками. Приемнику и передатчику информации необходимо знать, что данные в потоке должны соответствовать определенным правилам. Приводя реальный поток в соответствие с этими правилами, приемник может восстановить его исходное содержание. Количество и типы практически восстановимых ошибок определяются применяемыми правилами кодирования. Понятно, что всегда существует (и во многих случаях может быть теоретически оценен) порог количества ошибок в сообщении, после которого сообщение не поддается даже частичному восстановлению.
Соответствие потока данных тем или иным правилам теория информации описывает как наличие статистических автокорреляций или информационной избыточности в потоке. Такие данные всегда будут иметь больший объем, чем эквивалентные, но не соответствующие никаким правилам (например, упакованные), т. е. помехозащищенность достигается не бесплатно. Существование "бесплатных" средств повышения помехозащищенности каналов противоречит, ни много, ни мало, Второму Началу термодинамики (доказательство этого утверждения требует глубоких знаний в области теории информации и термодинамики, и поэтому здесь не приводится).
Естественные языки обеспечивают очень высокую (в письменной форме Двух- и трехкратную, а в звуковой еще большую) избыточность за счет применения сложных фонетических, лексических и синтаксических правил. Остроумным способом дополнительного повышения избыточности человеческой речи являются стихи (белые и, тем более, рифмованные), широко использовавшиеся до изобретения письменности для повышения надежности хранения в человеческих же головах исторических сведений и священных текстов.
К сожалению, с задачей восстановления искаженных сообщений на естественных языках в общем случае может справиться лишь человеческий мозг. Правила кодирования, применимые в вычислительных системах, должны удовлетворять не только требованиям теоретико-информационной оптимальности, но и быть достаточно просты для программной или аппаратной реализации.
Простейшим способом внесения избыточности является полное дублирование данных. Благодаря своей простоте, этот способ иногда применяется ни практике, но обладает многочисленными недостатками. Во-первых, избыточность этого метода чрезмерно высока для многих практических применений. Во-вторых, он позволяет только обнаруживать ошибки, но не исправлять их: при отсутствии других правил кодирования, мы не можем знать, какая из копий верна, а какая ошибочна.
Троекратное копирование обеспечивает еще более высокую избыточность, зато при его использовании для каждого расходящегося бита мы можем проводить голосование: считать правильным то значение, которое присутствует минимум в двух копиях данных (в данном случае мы исходим из того, что вероятность ошибки в одном и том же бите двух копий достаточно мала).
Трехкратное копирование, таким образом, позволяет восстанавливать данные, но имеет слишком уж высокую избыточность. Эти примеры, кроме простоты, любопытны -тем, что демонстрируют нам практически важную классификацию избыточных кодов: бывают коды, которые только обнаруживают ошибки, а бывают и такие, которые позволяют их восстанавливать. Далеко не всегда коды второго типа могут быть построены на основе кодов первого типа. Во многих случаях, например при передаче данных по сети, целесообразно запросить повтор испорченного пакета, поэтому коды, способные только обнаруживать ошибки, практически полезны и широко применяются.
Все данные, с которыми могут работать современные вычислительные системы, представляют собой последовательности битов, поэтому все правила, которые мы далее будем рассматривать, распространяются только на такие последовател ьности.
Простейший из применяемых способов кодирования с обнаружением ошибок — это бит четности. Блок данных снабжается дополнительным битом, значение которого выбирается так, чтобы общее количество битов, равных единице, в блоке было четным. Такой код позволяет обнаруживать ошибки в одном бите блока, но не в двух битах (строго говоря — позволяет обнаруживать нечетное количество ошибочных битов). Если вероятность ошибки в двух битах достаточно велика, нам следует либо разбить блок на два блока меньшего размера, каждый со своим битом четности, либо использовать более сложные схемы кодирования.
Самая распространенная из таких более сложных схем — это CRC (Cyclic Redundancy Code, циклический избыточный код). При вычислении CRC разрядности N выбирают число R требуемой разрядности и вычисляют остаток от деления на R блока данных (рассматриваемого как единое двоичное число), сдвинутого влево на N битов. Двоичное число, образованное блоком данных и остатком, делится на R, и этот факт можно использовать для проверки целостности блока (но не для восстановления данных при ошибке!).
Способность контрольной суммы обнаруживать ошибки логичнее измерять не в количестве ошибочных битов, а в вероятности необнаружения ошибки. При использовании CRC будут проходить незамеченными лишь сочетания ошибок, удовлетворяющие весьма специальному условию, а именно такие, вектор ошибок (двоичное число, единичные биты которого соответствуют ошибочным битам принятого блока, а нулевые — правильно принятым) которых делится на R. При случайном распределении ошибок вероятность этого может быть грубо оценена как 1/R, поэтому увеличение разрядности контрольной суммы в сочетании с выбором простых R обеспечивает достаточно быстрый и дешевый способ проверки целостности даже довольно длинных блоков. 32- разрядный CRC обеспечивает практически полную гарантию того, что данные не были повреждены, а 8-разрядный — уверенность, достаточную для многих целей. Однако ни четность, ни CRC не могут нам ничем помочь при восстановлении поврежденных данных.
Простой метод кодирования, позволяющий не только обнаруживать, но и исправлять ошибки, называется блочной или параллельной четностью и состоит в том, что мы записываем блок данных в виде двухмерной матрицы и подсчитываем бит четности для каждой строки и каждого столбца. При одиночной ошибке, таким образом, мы легко можем найти бит, который портит нам жизнь. Двойные ошибки такая схема кодирования может обнаруживать, но не способна восстанавливать (рис. 1.9).

Рис. 1.9. Параллельная четность

Широко известный и применяемый код Хэмминга (Hamming code) находится в близком родстве с параллельной четностью. Его теоретическое обоснование несколько менее очевидно, чем у предыдущего алгоритма, но в реализации он, пожалуй, даже проще [Берлекэмп 1971]. Идея алгоритма состоит в том, чтобы снабдить блок данных несколькими битами четности, подсчитанными по различным совокупностям битов данных. При выполнении неравенства Хэмминга (1.1) сформированный таким образом код обеспечивает обнаружение и исправление одиночных ошибок либо гарантированное обнаружение (но не исправление!) двойных ошибок. Важно подчеркнуть гарантию обнаружения, в отличие от всего лишь высокой вероятности обнаружения при использовании CRC.

d + р+1<= 2р, (1.1)

где d — количество битов данных, р — разрядность контрольного кода.
Код, использующий d и р, при которых выражение (1.1) превращается в равенство, называют оптимальным кодом Хэмминга.
Часто оказывается целесообразно сочетать упаковку данных с их избыточным кодированием. Наиболее удачным примером такой целесообразности опять-таки являются тексты на естественных языках: статистический анализ такого текста показывает очень высокий, более чем двукратный, уровень избыточности. С одной стороны, это слишком "много для большинства практически применяемых способов цифровой передачи и кодирования данных, а с другой — правила формирования этой избыточности слишком сложны и плохо формализованы для использования в современных программно-аппаратных комплексах. Поэтому для длительного хранения или передачи по низкоскоростным линиям целесообразно упаковывать текстовые данные и снабжать их простыми средствами избыточности, например CRC.


Введение в криптографию

Список замеченных опечаток:
В шифровке на стр. 325 напечатано: 212850А
Следует читать: 212850Б

При хранении и передаче данных нередко возникает требование защитить их от нежелательного прочтения и/или модификации. Если задача защиты от нежелательной модификации решается обсуждавшимися в предыдущем разделе избыточными кодами, то с прочтением все существенно сложнее.
Проще всего обеспечить защиту данных, лишив потенциальных злоумышленников доступа к физическому носителю данных или физическому каналу, по которому происходит их передача. К сожалению, иногда это невыполнимо (например, если обмен данными происходит по радиоканалу), а зачастую просто слишком дорого. В этом случае на помощь приходят методики, собирательное название которых — криптография (тайнопись). В отличие от большинства терминов компьютерной лексики это слово не английского, а греческого происхождения.
История криптографии насчитывает тысячи лет, и многие основополагающие принципы современной криптографии известны, возможно, с доисторических времен, однако, существенный прогресс в теории шифрования был достигнут лишь относительно недавно, в связи с разработкой современной теории информации.
Практически все методы криптографии сводятся к преобразованию данных в набор из конечного количества символов и осуществлению над этими символами двух основных операций: подстановки и перестановки. Подстановка состоит в замене одних символов на другие. Перестановка состоит в изменении порядка символов. В качестве символов при этом могут выступать различные элементы сообщения — так, при шифровании сообщений на естественных языках подстановке и перестановке могут подвергаться как отдельные буквы, так и слова или даже целые предложения (как, например, в аллегорических изложениях магических и священных текстов). В современных алгоритмах этим операциям чаще всего подвергаются блоки последовательных битов. Некоторые методики можно описать как осуществление операции подстановки над полным сообщением.
Подстановки и перестановки производятся по определенным правилам. При этом надежда возлагается "на то, что эти правила и/или используемые в них параметры известны только автору и получателю шифрованного сообщения и неизвестны посторонним лицам. В докомпьютерную эру старались засекретить обе составляющие процесса шифрования. Сейчас для шифрования, как правило, используют стандартные алгоритмы, секретность же сообщения достигается путем засекречивания используемого алгоритмом параметра, ключа (key).
Прочтение секретного сообщения посторонним лицом, теоретически, может быть осуществлено двумя способами: похищением ключевого значения либо его угадыванием путем анализа перехваченной шифровки. Если первое мероприятие может быть предотвращено только физической и организационной защитой, то возможность второго определяется используемым алгоритмом. Ниже мы будем называть процесс анализа шифровки взломом шифра, а человека, осуществляющего этот процесс, — взломщиком. По-научному эта деятельность называется более нейтрально — криптоанализ.
К примеру, сообщение на естественном языке, зашифрованное подстановкой отдельных букв, уязвимо для частотного анализа: основываясь на том факте, что различные буквы встречаются в текстах с разной частотой, взломщик легко- и с весьма высокой достоверностью- может восстановить таблицу подстановки. Существуют и другие примеры неудачных алгоритмов, которые сохраняют в неприкосновенности те или иные присутствовавшие в сообщении автокорреляции — каждый такой параметр можно использовать как основу для восстановления текста сообщения или обнаружения ключа.
Устойчивость шифра к поиску автокорреляций в сообщении называется криптостойкостыо алгоритма. Даже при использовании удачных в этом смысле алгоритмов, если взломщик знает, что исходные (нешифрованные) данные удовлетворяют тому или иному требованию, например, содержат определенное слово или снабжены избыточным кодом, он может произвести полный перебор пространства ключей: перебирать все значения ключа, допускаемые алгоритмом, пока не будет получено удовлетворяющее требованию сообщение. При использовании ключей достаточно большой разрядности такая атака оказывается чрезмерно дорогой, однако прогресс вычислительной техники постоянно сдвигает границу "достаточности" все дальше и дальше. Так, сеть распределенных вычислений Bovine в 1998 году взломала сообщение, зашифрованное алгоритмом DES с 56-разрядным ключом за 56 часов работы [www.distributed.net]!
Простым и эффективным способом борьбы с такой атакой является расширение пространства ключей. Увеличение ключа на один бит приводит к увеличению пространства вдвое -- таким образом, линейный рост размера ключа обеспечивает экспоненциальный рост стоимости перебора. Некоторые алгоритмы шифрования не зависят от разрядности используемого ключа—в этом случае расширение достигается очевидным способом. Если же в алгоритме присутствует зависимость от разрядности, расширить пространство можно, всего лишь применив к сообщению несколько разных преобразований, в том числе и одним алгоритмом, но с разными ключами. Еще один способ существенно усложнить работу взломщику — это упаковка сообщения перед шифрованием и/или дополнение его случайными битами.
Важно подчеркнуть, впрочем, что количество двоичных разрядов ключа является лишь оценкой объема пространства ключей сверху, и во многих ситуациях эта оценка завышена. Некоторые алгоритмы в силу своей природы могут использовать только ключи, удовлетворяющие определенному условию — например, RSA использует простые Числа. Это резко сужает объем работы по перебору, поэтому для обеспечения сопоставимой криптостойко-сти разрядность ключа RSA должна быть намного больше, чем у алгоритмов, допускающих произвольные ключи.
Низкая криптостойкость может быть обусловлена не только алгоритмом шифрования, но и процедурой выбора ключа: если ключ может принимать любые двоичные значения заданной разрядности, но реально для его выбора используется страдающий неоднородностью генератор псевдослучайных чи-:ел, мы можем значительно сократить объем пространства, которое реально аолжен будет перебрать взломщик наших сообщений (вопросы генерации завномерно распределенных псевдослучайных чисел обсуждаются во втором гоме классической книги [Кнут 2000]). Еще хуже ситуация, когда в качестве ключа используются "легко запоминаемые" слова естественного языка: в этом случае реальный объем пространства ключей даже довольно большой разрядности может измеряться всего лишь несколькими тысячами различных значений.
Современные алгоритмы шифрования делятся на два основных класса: с секретным и с публичным ключом (кажущееся противоречие между термином "публичный ключ" и приведенными выше рассуждениями будет разъяснено далее).
Алгоритмы- с секретным ключом, в свою очередь, делятся на потоковые (stream) и блочные (block). Потоковые алгоритмы обычно используют подстановку символов без их перестановки. Повышение криптостойкости при этом достигается за счет того, что правила подстановки зависят не только от самого заменяемого символа, но и от его позиции в потоке.
Примером простейшего — и в то же время абсолютно не поддающегося взлому — потокового алгоритма является система Вернама или одноразовый блокнот (рис. 1.10). Система Вернама основана на ключе, размер которого равен размеру сообщения или превосходит его. При передаче двоичных данных подстановка осуществляется сложением по модулю 2 (операцией исключающего или) соответствующих битов ключа и сообщения.

Рис. 1.10. Система Вернама

Если ключ порожден надежным генератором случайных чисел (например, правильно настроенным оцифровщиком теплового шума), никакая информация об автокорреляциях в исходном тексте сообщения взломщику не поможет: перебирая полное пространство ключей, взломщик вынужден будет проверить все сообщения, совпадающие по количеству символов с исходным, в том числе и все сообщения, удовлетворяющие предполагаемому автокорреляционному соотношению.
Это преимущество теряется, если один и тот же ключ будет использован для кодирования нескольких сообщений: взломщик, перехвативший их все, сможет использовать эти сообщения и предположения об их содержимом при попытках отфильтровать ключ от полезной информации — отсюда и второе название алгоритма. Применение системы Вернама, таким образом, сопряжено с дорогостоящей генерацией и, главное, транспортировкой ключей огромной длины, и поэтому она используется лишь в системах экстренной правительственной и военной связи.
Более практичным оказалось применение в качестве ключа псевдослучайных последовательностей, порождаемых детерминированными алгоритмами. В промежутке между первой и второй мировыми войнами широкое распространение получили шифровальные машины, основанные на механических генераторах таких последовательностей. Чаще всего использовались сочетания, получаемые при вращении колес с взаимно простыми количествами зубцов.
Основной опасностью при использовании таких методов шифрования является возможность определить текущую точку последовательности — узнав ее (например, по косвенным признакам догадавшись, что в данной точке сообщения должно быть такое-то слово, и восстановив использовавшийся при ее шифровании элемент ключа), взломщик может продолжить генерацию с той же точки и расшифровать весь дальнейший поток.
В системах цифровой связи широкое применение получили блочные алгоритмы, выполняющие над блоками данных фиксированной длины последовательности -- иногда довольно сложные — перестановок, подстановок и других операций, чаще всего двоичных сложений данных с ключом по какому-либо модулю. В операциях используются компоненты ключевого слова относительно небольшой разрядности.
Например, широко применяемый блочный алгоритм DES шифрует 64-битные блоки данных 56-битным ключом. Полное описание алгоритма приводится в документе [NBS FIPS PUB 46, 1977]. Русский перевод этого документа может быть найден в приложениях к книге [Дейтел 1987]. Для современной вычислительной техники полный перебор 56-битного пространства — "семечки", поэтому сейчас,все большее распространение получают алгоритмы с большей разрядностью ключа — Blowfish, IDEAL и др. [Анин 2000].
Шифры с открытым ключом называются также двухключевыми. Если в алгоритмах со скрытым ключом для кодирования и декодирования сообщений используется один и тот же ключ, то здесь используются два ключа: публичный и приватный. Для прочтения сообщения, закодированного публичным ключом, необходим приватный, и наоборот.
Помимо обычных соображений криптостойкости, к таким алгоритмам предъявляется дополнительное требование: невозможность восстановления приватного ключа по публичному иначе как полным перебором пространства ключей.
Двухключевые схемы шифрования намного сложнее в разработке, чем схемы с секретным ключом: требуется найти преобразование, не поддающееся обращению при помощи применявшегося в нем публичного ключа, но такое, чтобы с применением приватного ключа его все-таки можно было обратить. Известные криптоустойчивые схемы основаны на произведениях простых чисел большой разрядности, дискретных логарифмах и эллиптических кривых. Наиболее широкое применение получил разработанный в 1977 г. алгоритм RSA [www.rsa.com FAQ].
Известные двухключевые алгоритмы требуют соответствия ключей весьма специфическим требованиям, поэтому для достижения криптостойкости, сопоставимой с блочными алгоритмами, необходимо использовать ключи намного большей разрядности. Так, снятые в 2000 году ограничения Министерства торговли США устанавливали ограничения разрядности ключа, который мог использоваться в экспортируемом за пределы США программном обеспечении: для схем с секретным ключом устанавливался предел, равный 48 битам, а для схем с открытым — 480.
Использование ключей большой разрядности требует значительных вычислительных затрат, поэтому двухключевые схемы чаше всего применяются в сочетании с обычными: обладатель публичного ключа генерирует случайную последовательность битов, кодирует ее и отправляет обладателю приватного ключа. Затем эта последовательность используется в качестве секретного ключа для шифрования данных. При установлении двустороннего соединения стороны могут сначала обменяться своими публичными ключами, а затем использовать их для установления двух разных секретных ключей, используемых для шифрования данных, передаваемых в разных направлениях.
Такая схема делает практичной частую смену секретных ключей: так, в протоколе SSH ключ генерируется на каждую сессию [www.cs.hut.fi SSH]; в протоколе виртуальных приватных сетей IPSEC время жизни ключа ограничено восемью часами [redbooks.ibm.com sg245234.pdf].
Еще более широкое применение двухключевые схемы нашли в области аутентификации и электронной подписи. Электронная подпись представляет собой контрольную сумму сообщения, зашифрованную приватным ключом отправителя. Каждый обладатель соответствующего публичного ключа может проверить аутентичность подписи и целостность сообщения. Это может использоваться для проверки аутентичности как сообщения, так и самого отправителя. Использование в качестве контрольной суммы обычного CRC (см. разд. Контрольные суммы) невозможно, потому что генерация сообщения с заданным CRC не представляет вычислительной сложности. Для применения в электронной подписи были разработаны специальные алгоритмы вычисления контрольных сумм, затрудняющие подбор сообщения с требуемой суммой [RFC 1320, RFC 1321].


Глава 10. Драйверы внешних устройств

  • Драйверы внешних устройств
  • Функции драйверов
  • Многоуровневые драйверы
  • Загрузка драйверов
  • Архитектура драйвера
    • Введение в конечные автоматы
    • Архитектура драйвера
  • Запросы к драйверу
    • Синхронный ввод-вывод
    • Асинхронный ввод-вывод
  • Сервисы ядра, доступные драйверам
    • Автоконфигурация
    • Выделение памяти
    • Таймеры
    • Обмен данными с пользовательским процессом
    • Сервисные функции
  • Асинхронная модель ввода-вывода с точки зрения приложений
    • Дисковый кэш
  • Спулинг


Драйверы внешних устройств

Когда я на почте служил ямщиком .
Ко мне постучался косматый геолог,
И глядя на карту на белой стене,
он усмехнулся мне.
Г. Самойлов

Драйвер (driver) представляет собой специализированный программный модуль, управляющий внешним устройством. Слово driver происходит от глагола to drive (вести) и переводится с английского языка как извозчик или шофер: тот, кто ведет транспортное средство. Драйверы обеспечивают единый интерфейс для доступа к различным устройствам, тем самым устраняя зависимость пользовательских программ и ядра ОС от особенностей аппаратуры.
Драйвер не обязательно должен управлять каким-либо физическим устройством. Многие ОС предоставляют также драйверы виртуальных устройств или псевдоустройств — объектов, которые ведут себя аналогично устройству ввода-вывода, но не соответствуют никакому физическому устройству.
В виде псевдоустройств реализуются трубы в системах семейства Unix и почтовые ящики в VMS. Еще одним примером полезного псевдоустройства являются устройства /dev/null в Unix и аналогичное ему \DEV\NUL в MS DOS\Windows\OS/2. В современных системах семейства Unix в виде псевдоустройств, размещенных в псевдофайловой системе /ргос, реализован доступ к большинству параметров системы -- адресным пространствам активных процессов, статистике и параметрам настройки ядра, данным отдельных подсистем, например таблице маршрутизации сетевого протокола IP.
Прикладные программы, использующие собственные драйверы, не так уж редки - примерами таких программ могут быть GhostScript (свободно распространяемый интерпретатор языка PostScript, способный выводить программы на этом языке на различные устройства, как печатающие, так и экранные) или LATEX, который также способен печатать на самых разнообразных устройствах. Однако эта глава посвящена преимущественно драйверам, используемым ядром ОС.
Большинство ОС общего назначения запрещают пользовательским программам непосредственный доступ к аппаратуре. Это делается для повышения надежности и обеспечения безопасности в многопользовательских системах. В таких системах драйверы являются для прикладных программ единственным способом доступа к внешнему миру.
Еще одна важная функция драйвера- это взаимоисключение доступа к устройству в средах с вытесняющей многозадачностью. Допускать одновременный неконтролируемый доступ к устройству нескольких параллельно исполняющихся процессов просто нельзя, потому что для большинства внешних устройств даже простейшие операции ввода-вывода не являются атомарными.
Например, в большинстве аппаратных реализаций последовательного порта RS232 передача байта состоит из четырех шагов: записи значения в регистр данных, записи команды "передавать" в регистр команды, ожидания прерывания по концу передачи и проверки успешности передачи путем считывания статусного регистра устройства. Нарушение последовательности шагов может приводить к неприятным последствиям — например, перезапись регистра данных после подачи команды, но до завершения передачи, может привести к остановке передачи или, что еще хуже, передаче искаженных данных и т. д.
Нельзя также забывать о неприятностях более высокого уровня — например, смешивании вывода разных процессов на печати или данных — на устройстве внешней памяти. Поэтому оказывается необходимо связать с каждым внешним устройством какой-то разграничитель доступа во времени. В современных ОС эта функция возлагается именно на драйвер. Обычно одна из нитей драйвера представляет собой процесс-монитор, выполняющий асинхронно поступающие запросы на доступ к устройству. В Unix, OS/2 и Windows NT/2000/XP этот процесс называется стратегической функцией. Подробнее этот механизм обсуждается в разд. Асинхронный ввод-вывод и Асинхронная модель ввода-вывода с точки зрения приложений.
При определении интерфейса драйвера разработчики ОС должны найти правильный баланс между противоречивыми требованиями:

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

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

Подсистема ввода-вывода OS/2
В качестве примера такого консерватизма можно привести подсистему ввода-вывода OS/2. Совместный проект фирм IBM и Microsoft, OS/2 1.x разрабатывалась как операционная система для семейства персональных компьютеров Personal System/2. Младшие модели семейства были основаны на 16-разрядном процессоре 80286, поэтому вся ОС была полностью 16-битной.
Позднее разработчики фирмы IBM реализовали 32-битную OS/2 2.0, но для совместимости со старыми драйверами им пришлось сохранить 16-битную подсистему ввода-вывода. Все точки входа драйверов должны находиться в 16-битных ('USE16') сегментах кода; драйверам передаются только 16-разрядные ('far') указатели и т. д.
По утверждению фирмы IBM, они рассматривали возможность реализации также и 32-битных драйверов, но их измерения не показали значительного повышения производительности при переходе к 32-битной модели. Позднее, впрочем, суперскалярные процессоры архитектуры х86, оптимизированные Дл^ исполнения 32-разрядного кода, все-таки продемонстрировали значительный рост производительности для такого кода по сравнению с 16-разрядным, и, начиная с OS/2 4.0 32-битный интерфейс все-таки предоставляется, но без отказа от поддержки старых, 16-разрядных драйверов.
Благодаря этому сохраняется возможность использовать драйверы, разработанные еще в конце 80-х и рассчитанные на OS/2 1.x. Эта возможность оказывается особенно полезна при работе со старым оборудованием.
Напротив, разработчики фирмы Microsoft отказались от совместимости с 16-битными драйверами OS/2 1.x в создававшейся ими 32-битной версии OS/2 называвшейся OS/2 New Technology. Фольклор утверждает, что именно это техническое решение оказалось причиной разрыва партнерских отношении между Microsoft и IBM, в результате которого OS/2 NT вышла на рынок под названием Windows NT 3.1.

Подсистема ввода-вывода Windows 9x/ME
Сама фирма Microsoft, впрочем, демонстрирует почти столь же трогательную приверженность к совместимости со старыми драйверами: системы линии Windows 95/98/ME до сих пор используют весьма своеобразную архитектуру, основной смысл которой — возможность применять, пусть и с ограничениями, драйверы MS DOS.
Windows 9х и Windows 3.x в enhanced-режиме предоставляют вытесняющую многозадачность для VDM (Virtual DOS Machine— Виртуальная машина [для] DOS), однако сами используют DOS для обращения к дискам и дискетам. Ядро однозадачной DOS не умеет отдавать управление другим процессам во время исполнения запросов ввода-вывода. В результате во время обращения к диску все остальные задачи оказываются заблокированы.
У современных PC время исполнения операций над жестким диском измеряется десятыми долями секунды, поэтому фоновые обращения к жесткому диску почти не приводят к нарушениям работы остальных программ. Однако скорость работы гибких дисков осталась достаточно низкой, следовательно, работа с ними в фоновом режиме блокирует систему на очень заметные промежутки времени.
Эффектная и убедительная демонстрация этой проблемы очень проста: достаточно запустить в фоновом режиме форматирование дискеты или просто команду COPY С:\ТМР\*.* А: , если в каталоге C:\TMP достаточно много данных. При этом работать с системой будет практически невозможно: во время обращений к дискете даже мышиный курсор не будет отслеживать движений мыши, будут теряться нажатия на клавиши и т. д.
Windows 95/98/ME использует несколько методов обхода DOS при обращениях к диску, поэтому пользователи этой системы не всегда сталкиваются с описанной проблемой. Однако при использовании блочных драйверов реального режима система по-прежнему применяет DOS в качестве подсистемы ввода-вывода, и работа с дискетами в фоновых задачах также нарушает работу задач первого плана.

Если говорить о совместимости, то со многих точек зрения очень привлекательной представляется идея универсального драйвера — модуля, который без изменении или с минимальными изменениями может использоваться Для управления одним и тем же устройством в различных ОС.
К сожалению — несмотря даже на то, что, как мы увидим далее, в общих чертах архитектура драйвера в большинстве современных ОС удивительн похожа — идея эта, по-видимому, нереализуема. Даже для близкородственных ОС — например, систем семейства Unix — драйверы одного и того ж устройства не всегда могут быть легко перенесены из одной ОС в другую говоря уж о возможности использования без модификаций. Еще более удивительным является тот факт, что две линии ОС - Windows 95/98/MЕ и Windows NT/2000/XP — поставляемых одной и той же компанией Microsoft и реализующих почти один и тот же интерфейс системных вызовов — Win32 — имеют совсем разный интерфейс драйвера.
Проблема здесь в том, что интерфейс между драйвером и ядром ОС всегда двусторонний: не только прикладные программы и ядро вызывают функции драйвера, но и, наоборот, драйвер должен вызывать функции ядра. Структура интерфейсов ядра, доступных драйверу, определяет многие аспекты архитектуры ОС в целом.
В предыдущих главах мы уже обсуждали многие из ключевых вопросов: способ сборки ядра, стратегию управления памятью, способы обмена данными между ядром и пользовательскими процессами, и, наконец, механизмы межпоточного взаимодействия — между нитями самого драйвера (ниже мы увидим, что подавляющее большинство драйверов состоит как минимум из двух нитей), между драйвером и остальными нитями ядра и между драйвером и нитями пользовательских процессов.
Изложение решений перечисленных проблем составляет если и не полное описание архитектуры ОС, то, во всяком случае, значительную его часть. Так, переход от однозадачной системы или кооперативной многозадачности к вытесняющей многозадачности может потребовать не только изменения планировщика, но и радикальной переделки всей подсистемы ввода-вывода, в том числе и самих драйверов.
Таким образом, до тех пор, пока используются ОС различной архитектуры, разработка универсального интерфейса драйвера, если теоретически п возможна, то практически вряд ли осуществима.


Функции драйверов

Прежде всего, драйвер должен иметь функции, вызываемые ядром при загрузке и выгрузке модуля и при подключении модуля к конкретным устройствам. Например, в Sun Solans это перечисленные функции.

  • int _init(void) — инициализация драйвера. Эта функция вызывается при загрузке модуля. Драйвер должен зарезервировать все необходимые ему системные ресурсы и проинициализировать собственные глобальные переменные. Инициализация устройства на этом этапе не происходит.
  • int probe (dev_info_t *dip) — проверить наличие устройства в системе. Во многих системах эта функция реализуется не самим драйвером, а специальным модулем-"сниффером" (sniffer — дословно, "нюхач"), используемым программой автоконфигурации.
  • int attach (dev_info_t * dip, ddi_attach_cmd_t crtid) — инициализация копии драйвера, управляющей конкретным устройством. Эту функцию можно рассматривать как аналог конструктора объекта в объектно-ориентированном программировании. Если в системе присутствует несколько устройств, управляемых одним драйвером, некоторые ОС загружают несколько копий кода драйвера, но в системах семейства Unix функция attach просто вызывается многократно.

Каждая из инициализированных копий драйвера имеет собственный блок локальных переменных, в которых хранятся переменные состояния устройства. При вызове attach драйвер должен прочитать конфигурационный файл, где записаны параметры устройства (номенклатура этих параметров зависит от устройства и от драйвера), разместить и проинициализировать блок переменных состояния, зарегистрировать обработчики прерываний, проинициализировать само устройство и, наконец, зарегистрировать устройство как доступное для пользовательских программ, создав для него минорную запись (minor node). В ряде случаев драйвер создает для одного устройства несколько таких записей.
Например, каждый жесткий диск в Unix SVR4 должен иметь 16 записей — по две (далее мы поймем, для чего они нужны) для каждого из восьми допустимых слайсов (логических разделов, см. разд. Загрузка самой ОС) диска. Другой пример: в большинстве систем семейства Unix лентопротяжные устройства имеют две минорные записи. Одно из этих устройств при открытии перематывает ленту к началу, другое не перематывает. В действительности оба устройства управляются одним и тем же драйвером, который определяет текущий режим работы в зависимости от указанной минорной записи.
Современные Unix системы, в частности Solaris, используют отложенную инициализацию, когда для многих устройств attach вызывается только при первой попытке доступа пользовательской программы к устройству.

  • int detach(dev_info_t *dip, ddi_detach_cmd_t cmd) — аналог деструктора объекта в ООП. Впрочем, в отличие от деструктора, эта операция не безусловна — если не удается нормально завершить обрабатываемые в данный момент операции над устройством, драйвер может и даже обязан отказаться деинициализироваться. При деингщиализации драйвер должен освободить все системные ресурсы, которые он занял при инициализации и в процессе работы (в том числе и уничтожить минорную запись) и может, если это необходимо, произвести какие-то операции над устройством, например, выключить приемопередатчик, запарковать головки чтения-записи и т. д. После того, как все устройства, управляемые драйвером, успешно деинициализированы, система может его выгрузить.
  • int _fini (void) — функция, вызываемая системой перед выгрузкой дуля. Драйвер обязан освободить все ресурсы, которые он занял на этапе инициализации модуля, а также все ресурсы, занятые им во время работы на уровне модуля (не привязанные к конкретному управляемому устройству).

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

  • int open (char * fnarne, int flags, mode_t mode) — Процедура открытия
    устройства. В некоторых случаях она может содержать и дополнительные шаги инициализации устройства — например, для лентопротяжек эта процедура может включать в себя перемотку ленты к началу. Функция возвращает целочисленный идентификатор-"ручку" (handle), часто называемый также дескриптором файла, который используется программой при всех последующих обращениях к устройству.
  • int readfint handle, char * where, size_t how_much) — чтение данных с устройства. Если устройство приспособлено только для вывода (например, принтер), эта функция может быть не определена.
  • int write (int handle, char * what, size_t how_much) — запись данных
    на устройство. Если устройство приспособлено только для ввода, (например, перфоленточный ввод или мышь), эта функция также может быть не определена.
  • void dose (int handle) — процедура закрытия (освобождения) устройства.
  • int ioctitint handle, int cmd, ...) — процедура задания специальной команды, которая не может быть сведена к операциям чтения и записи. Набор таких команд зависит от устройства. Например, для растровых графических устройств могут быть определены операции установки видеорежима; для последовательных портов RS232 это могут быть команд^ установки скорости, количества битов, обработки бита четности и т. д., для дисководов — команды форматирования носителя.
  • off r lseek<int handle, off_t offset, int whence), long seek — команда перемещения головки чтения/записи к заданной позиции. Драйверы устройств, не являющихся устройствами памяти, например модема или Принтера, как правило, не поддерживают эту функцию.

Слово long в названии функции появилось по историческим причинам: версиях Unix для 16-разрядных машин индекс позиции не мог обозначать-я словом, потому что это ограничивало бы логическую длину устройства недопустимо малым значением 65334 байт. Поэтому необходимо было использовать двойное слово, что соответствовало типу long языка С. Современные системы используют 64-разрядный off_t.

  • caddr_t rranap (caddr_t addr, size_t len, int prot, int flags, int handle, off_t offset) memory map — отображение устройства в адресное пространство процесса. Параметр prot задает права доступа к отображенному участку: на чтение, на запись и на исполнение. Отображение может происходить на заданный виртуальный адрес, или же система может выбирать адрес для отображения сама.

Эта функция отсутствовала в старых версиях системы, но большинство современных систем семейства (BSD 4.4, ряд наследников BSD 4.3, SVR4 и Linux) поддерживают ее.
Речь идет об отображении в память данных, хранящихся на устройстве. Для устройств ввода-вывода, например, для принтера или терминала, эту функцию невозможно реализовать разумным образом. Напротив, для лент и других последовательных устройств памяти, поддерживающих функцию Iseek, отображение может быть реализовано с использованием аппаратных средств виртуализации памяти и операции read и write. Необходимость специальной функции отображения появляется у драйверов устройств, использующих большие объемы памяти, отображенной в адресное пространство системной шины, например, для растровых видеоадаптеров, некоторых звуковых устройств или страниц общей памяти (backpane memory — двухпортовой памяти, используемой как высокоскоростной канал обмена данными в многопроцессорных системах).
Механизм отображения доступных прикладной программе системных вызовов в функции драйвера относительно сложен. Этот механизм должен включать в себя следующее.

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

Способы, которыми эти вопросы решаются в современных операционных системах, обсуждаются в последующих разделах. А пока что мы подробнее обсудим, какие именно операции над устройством следует определить и почему.
Видно, что предлагаемый системами семейства Unix набор операций рассматривает устройство как неструктурированный поток байтов (или, ддя устройств ввода-вывода, два разнонаправленных потока — для ввода и для вывода). Такое рассмотрение естественно для устройств алфавитно-цифрового ввода-вывода и простых запоминающих устройств, например магнитных лент, однако далеко не столь естественно для более сложных устройств.
Стандартный ответ Unix-культуры в этом случае таков: любая, сколь угодно сложная структура данных может быть сериализована — преобразована в последовательный поток байтов. Например, изображение может быть превращено в последовательный поток байтов в виде растровой битовой карты или последовательности описаний графических примитивов — линий, прямоугольников и пр. Примерами такой сериализации для изображений могут являться язык PostScript [partners.adobe.com] и протокол распределенной оконной системы X Window [www.x.org] (оба протокола поддерживают как растровые образы, так и довольно богатые наборы векторных примитивов).
Нередки, впрочем, ситуации, когда нам интересна не только структура поступающих данных, но и время их поступления (в предыдущей главе мы предложили классифицировать устройства, которые могут быть использованы подобным образом, как генераторы событий) — это бывает в приложениях реального времени, а также в задачах, которые сейчас стало модно называть "задачами мягкого реального времени" — мультимедийных программах, генерирующих поток звука, синхронизованного с изображением, и, особенно, в компьютерных играх.
Для работы с таким устройством прикладная программа, так или иначе, должна зарегистрировать обработчик поступающих от устройства событии. В системах Unix такая регистрация состоит в открытии устройства для чтения, а ожидание события заключается в выполнении над этим устройством операции чтения. Для последовательных устройств ввода операция чтений разблокируется, когда с устройства поступят хоть какие-то данные (а не тогда, когда будет заполнен весь буфер), поэтому, если пришло только одно событие, мы его не пропустим. Драйверы многих устройств, способных работать в качестве генераторов событий, имеют команды ioctl, позволяющие более тонко управлять условием разблокирования функции read.
Для того чтобы во время ожидания события от генератора, заниматься еще какой-то полезной работой, предлагается либо выделить ожидающий события вызов read в отдельную нить, либо пользоваться системными вызовами lect и poll, позволяющими ожидать событий на нескольких устройствах (а также средствах межпроцессного взаимодействия) одновременно.
Другие ОС предоставляют для работы с устройствами-генераторами событий более сложные механизмы, зачастую основанные на callback (дословно — "вызов назад"; механизм взаимодействия подсистем, когда подсистема, запрашивающая сервис, передает обслуживающей подсистеме указатель на функцию, которую необходимо вызвать при наступлении определенного события).
Работа с генераторами событий требует решения еще одной задачи — хранения поступающих событий в периоды, когда пользовательская программа их не успевает обрабатывать. Необходимость относительно сложных схем работы с требуемыми для этого буферами вынудила разработчиков Unix System V Release 3 ввести еще один тип драйверов — потоковые (STREAMS) [docs.sun.com 805-7478-10]. Для прикладной программы потоковый драйвер не отличается от обычного символьного устройства, но отличий с точки зрения системы довольно много. Некоторые из этих отличий будут рассматриваться далее.
Unix System V Release 3 (SCO Open Desktop, SCO OpenServer), Release (SCO UnixWare, SGI Irix, Sun Solaris) и системы, испытавшие влияние OSF Unix (IBM AIX, HP/UX) используют потоковые драйверы для реализации таких важных псевдоустройств, как трубы и сокеты TCP/IP. Кроме того, потоковыми в этих системах являются драйверы сетевых адаптеров и терминальных устройств.
С другой стороны, в OS/2 и Windows NT/2000/XP существуют обширные номенклатуры типов драйверов с различными наборами функций. Так, в OS/2 используются драйверы физических устройств следующих типов:

  • простые драйверы последовательных устройств ввода-вывода, аналогичные драйверам символьных устройств в Unix;
  • Драйверы запоминающих устройств прямого доступа, аналогичные драйверам блочных устройств в Unix;
  • Драйверы видеоадаптеров, используемые графической оконной системой Presentation Manager (PM);
  • Драйверы позиционных устройств ввода (мышей и др.), также используемые РМ;
  • Драйверы принтеров и других устройств вывода твердой копии;
  • Драйверы звуковых устройств, используемые подсистемой "мультимедиа" MMOS/2;
  • драйверы сетевых адаптеров стандарта NDIS, используемые сетевк программным обеспечением фирм IBM и Microsoft;
  • драйверы сетевых адаптеров стандарта ODI, используемые программным обеспечением фирмы Novell;
  • DMD (Device Manager Driver - - драйвер-менеджер класса устройств (в разд. 10.2 мы подробнее разберемся с назначением драйверов этог типа);
  • различного рода "фильтры", например, ODINSUP.SYS — преобразоватеи ODI-интерфейса в NDIS.


Многоуровневые драйверы

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

М. Павич

Нередка ситуация, когда драйвер не может осуществлять управление устройством полностью самостоятельно. Как пример можно рассмотреть драйвер лентопротяжного устройства конструктива -SCSI. Для выполнения любой операции над устройством такой драйвер должен сформировать команду или последовательность команд SCSI, передать ее устройству, дождаться ответа и проанализировать его.
Проблема здесь в том, что передача команды и получение ответа происходят при посредстве еще одного устройства, НВА. НВА могут быть разнотипными и нуждаются в собственных драйверах. Дополнительная проблема состоит в том, что к одному НВА скорее всего подключены несколько устройств, и, наверняка, большинство из них не является лентопротяжками, поэтому целесообразно выделение драйвера НВА в отдельный модуль и отдельную мониторную нить.
В системах семейства Unix драйверы целевых устройств SCSI представляют собой обычные символьные или блочные драйверы, с тем лишь отличием, что они не управляют своими устройствами непосредственно, а формируют команды драйверу НВА (рис. 10.1). Команда содержит собственно команду протокола SCSI и указатели на буферы, откуда следует взять и куда следует положить данные, передачей которых должна сопровождаться обработка команды. Передав команду драйверу НВА, драйвер целевого устройства может либо дождаться ее завершения, либо заняться чем-то другим, например обработкой следующего запроса. Во втором случае, после завершения операции НВА, будет вызван предоставленный целевым драйвером callback.

Рис. 10.1. Драйверы целевых устройств SCSI и драйвер НВА

Напротив, драйвер НВА напрямую не доступен прикладным программам (в отдельных случаях, впрочем, позволяют осуществлять над этим драйвером операции ioctl), а набор функций этого драйвера отличается от символьных и блочных устройств. Порядок инициализации таких драйверов (функции _init, attach и т. д.) в целом такой же, как и у обычных драйверов.
Как правило, драйвер имеет функцию transport (например, в Solaris эта функция называется tran_start), которая осуществляет передачу целевому устройству команды, сформированной драйвером этого устройства. Если до завершения отработки предыдущей команды поступит следующая, драйвер может либо положиться на способность целевого устройства поддерживать очередь запросов, либо, если устройство этого не умеет, реализовать очередь запросов самостоятельно.
Кроме того, драйвер адаптера производит первичный анализ пришедших ответов на команды: какой из ранее переданных команд соответствует ответ, чем завершилась операция — успехом или ошибкой, пришли ли в ответ данные и если пришли, то сколько именно и куда их положить и т. д.
После завершения обработки запроса, драйвер НВА вызывает callback драйвера целевого устройства. Эта callback-процедура может сформировать следующий запрос к адаптеру (или повторную попытку, если операция завершилась восстановимой ошибкой) либо просто оповестить пользовательский процесс о завершении операции и освободить память, занятую структурой запроса и буферами данных.
Аналогичные ситуации возникают и с другими устройствами. Например, с IBM PC-совместимыми компьютерами могут работать три основных типа устройств позиционного ввода: мыши, использующие протокол обмена, совместимый с Microsoft Mouse, мыши с протоколом Logitech, планшеты дигитайзеры с протоколом Summagraphics. Устройства всех этих, а т нескольких менее распространенных типов могут подсоединяться как нимум к четырем различным периферийным портам: специальном "мышиному" разъему PS/2, к последовательной шине USB и к послсюва тельному порту RS232, причем в качестве порта RS232, может использовать ся как один из четырех стандартных портов IBM PC, так и, например. один из выходов мультипортовой платы (рис. 10.2).

Рис. 10.2. Различные типы позиционных устройств ввода

В этом случае также целесообразно реализовать четыре самостоятельных драйвера транспортных портов (тем более что к этим портам могут подключаться и другие устройства) и три драйвера протоколов обмена, способных работать с любым транспортным портом. Объединение этих драйверов в одном модуле может создать некоторые — довольно, впрочем, сомнительные — удобства администратору системы, в которой используется стандартная комбинация порта и мыши, но поставит в безвыходное положение администратора системы, в которой комбинация нестандартная, например, не позволяя использовать мышь, подключенную к мультипортовой плате.
В современных системах семейства Unix многоэтапная обработка запросов штатно поддерживается потоковыми драйверами: система предоставляет специальные системные вызовы push и pop, позволяющие добавлять дополнительные драйверы, обслуживающие поток. Дополнительные могут преобразовывать данные потока (например, символы протокола мыши в координаты курсора и нажатия и отпускания кнопок) или обрабатывать запросы ioctl [docs.sun.com 805-7478-10].
В частности, в современных системах семейства Unix драйверы терминальных устройств должны уметь обрабатывать достаточно обширный набор запросов ioctl и выполнять ряд важных функций по управлению заданиями [Хевиленд/Грэй/Салама 2000]. В монолитных системах эти функции обязан реализовать сам драйвер устройства (хотя ядро и облегчает создателю драйвера эту работу, предоставляя библиотеку сервисных функций — см. [Максвелл 2000]), в то время как в системах, имеющих потоковые драйверы, драйвер устройства может ограничиться решением своей собственной задачи — обеспечением обмена данными с устройством, а все сложные терминальные сервисы, если это необходимо, предоставляются простым добавлением к потоку драйвера модуля терминальной дисциплины (рис. 10.3).

Рис. 10.3. Модули STREAMS

Многоуровневые драйверы в OS/2
Рассмотрим еще один подход к организации многоуровневых драйверов на примере DMD (Device Manager Driver-драйвер-менеджер класса устройств)
в OS/2 [www.ibm.com OS/2 DDK] и достаточно типичной аппаратной конфцп, рации, содержащей НВА SCSI, к которому подключены пять устройств: жесткий диск, привод CD-ROM, магнитооптический диск, лентопротяжка и сканер. riD этом каждое из устройств имеет свою специфику, так что управление ими сложно свести к общему набору функций.
Жесткий и магнитооптический диски наиболее схожи между собой, так как и то и другое является запоминающим устройством большой емкости с произволь' ным доступом. Однако жесткий диск— неудаляемое устройство, а магнитооп-тический носитель можно извлечь из привода, не выключая компьютера. Это накладывает определенные требования на стратегию кэширования соответствующего устройства и требует от драйвера способности понимать и обрабатывать аппаратный сигнал о смене устройства.
Такой сигнал следует передать модулям управления дисковым кэшем и файловой системой, которые, в свою очередь, обязаны разумно обработать его: как минимум, дисковый кэш должен объявить все связанные с диском буферы неактуальными, а менеджер файловой системы должен сбросить все свои внутренние структуры данных, связанные с удаленным диском, и объяснить всем пользовательским программам, работавшим с этим диском, что их данные пропали. Другие аспекты работы с удаляемыми носителями обсуждаются в разд. Устойчивость ФС к сбоям.
CD-ROM, в свою очередь, нельзя рассматривать как удаляемый диск, доступный только для чтения: практически все CD-ROM приводы, кроме функции считывания данных, еще имеют функцию проигрывания музыкальных компакт-дисков.
Лентопротяжка и сканер вообще не являются устройствами памяти прямого доступа, а сканер даже с самой большой натяжкой нельзя рассматривать как устройство памяти.
Когда OS/2 управляет описанной аппаратной конфигурацией, оказываются задействованы пять DMD (рис. 10.4)
OS2DASD.DMD управляет классом запоминающих устройств прямого доступа и предоставляет стандартные функции для доступа к дискам.
OPTICAL.DMD обеспечивает управление устройствами прямого доступа с удаляемыми носителями. Основная его задача — обработка аппаратного сигнала смены носителя и оповещение других модулей системы (дискового кэша, файловой системы) об этой смене.
OS2CDROM.DMD обеспечивает специфические для приводов CD-ROM функции, например проигрывание аудизаписей.
OS2SCSI.DMD OS2ASPI.DMD — эти два модуля будут описаны далее.
Каждый из этих DMD не работает непосредственно с аппаратурой, а транслирует запросы пользовательских программ и других модулей ядра (в первую очередь, менеджеров файловых систем) в запросы к драйверу нижнего уровня. Такой подход позволяет вынести общую для класса устройств логику в DMD и не заниматься повторной реализацией этой логики в каждом новом драйвере.

Рис. 10.4. Взаимодействие между DMD и ADD в OS/2 (в качестве примера драйвера файловой системы приведен модуль JFS.IFS)

В данном случае, запросы предыдущих трех драйверов исполняет четвертый DMD: OS2SCSI.DMD. Этот DMD преобразует запросы к устройствам в команды SCSI и передает эти команды драйверу ADD (Adapter Device Driver — драйверу устройства-адаптера), т. е. собственно драйверу НВА. От ADD требуется только умение передавать команды на шину SCSI, обрабатывать и осуществлять диспетчеризацию пришедших на них ответов, т. е. он функционально аналогичен драйверу НВА в системах семейства Unix.
Пятый DMD— OS2ASPI.DMD— обеспечивает сервис ASPI (Advanced SCSI Programming Interface — продвинутый интерфейс для программирования SCSI). Он дает возможность прикладным программам и другим драйверам формировать произвольные команды SCSI и таким образом осуществлять доступ к устройствам, которые не являются дисками. Сервисом ASPI пользуются драйверы лентопротяжки и сканера [www.ibm.com OS/2 DDK].
При работе с устройствами ATA/ATAPI используется более простая и возможностями структура, состоящая из драйвера IBM1S506.ADD (для неко Я рых типов адаптеров EIDE может понадобиться другой драйвер) и фильт IBMATAPI.FLT. Драйвер обеспечивает инициализацию адаптера, передачу команд и работу с жесткими дисками АТА, а фильтр — формирование кома для подключаемых к тому же адаптеру устройств ATAPI (CD-ROM и магнитооптических дисков).

На практике драйверы многих устройств используются исключительно цц преимущественно другими модулями ОС (не обязательно драйверами), а не пользовательскими программами. Например, драйверы сетевых интерфейсов взаимодействуют практически исключительно с модулями ядра, реализующими сетевые и транспортные протоколы (напрямую с сетевым интерфейсом работают разве что конфигурационные утилиты, а также анализаторы протоколов и другие контрольно-диагностические средства), а драйверы жестких дисков преимущественно исполняют запросы файловых систем.
Именно этим и отличаются блочные устройства в системах семейства Unix от символьных: в классических ОС Unix блочные устройства вообще не доступны пользовательским программам, а все операции с ними осуществляются посредством файловой системы. Это несколько упрощает логику исполнения операций — драйверу не надо заботиться об обмене данными с пользовательским адресным пространством. Кроме того, в отличие от обычных операций чтения и записи, в которых допустим обмен пакетами данных произвольного размера, блочный драйвер передает данные блоками, размер которых кратен 512 байтам.
На случай, если все-таки понадобится доступ к диску в обход файловом системы, драйвер блочного устройства создает две минорных записи для устройства — одну блочную и одну символьную, и все-таки предоставляет обычные, "символьные" операции чтения и записи. На практике, такой доступ почти всегда требуется утилитам создания и восстановления файловых систем, поэтому создание двух записей является обязательным.
Более радикально подошел к решению проблемы разработчик Linux Линус Торвальдс — в этой системе драйверы блочных устройств обязаны предоставлять символьные операции чтения и записи, но им не нужно создавать вторую минорную запись: пользователям разрешено работать с блочными устройствами (открывать, читать их и писать на них) так же, как и с символьными.


Загрузка драйверов

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

Терминальный интерфейс в Unix
На практике иногда — особенно при использовании многоуровневых драйверов — оказывается возможным перенести отдельные функции работы с устройствами в контекст пользовательского процесса. Одна из относительно удачных попыток такого переноса была осуществлена в 70-е годы при разработке экранного редактора vi, для ОС Unix (которая тогда еще была единственным представителем того, что потом превратилось в весьма обширное семейство ОС).
По замыслу разработчиков этот редактор должен был работать с большим количеством разных видеотерминалов, использовавших различные несовместимые системы команд для перемещения курсора и редактирования текста в буфере терминала и столь же несовместимые схемы кодирования специальных клавиш (стрелочек, функциональных клавиш и пр.). Как и в примере с мышами, разные терминалы могли подключаться к компьютеру через различные типы последовательных портов: "токовую петлю", позднее — RS232 и др.
Как в обсуждавшемся в разд. Многоуровневые драйверы примере с мышами, в этом случае тоже было естественно разделить драйвер терминала на драйвер порта и модуль, занимающийся генерацией команд и анализом приходящих от терминала кодов расширенных клавиш. Вместо разработки отдельного модуля, решающего вторую часть задачи, была создана системная база данных для систем команд разных терминалов. Эта база данных представляет собой текстовый файл с именем /etc/termcap (terminal capabilities — возможности терминала).
Файл /etc/termcap состоит из абзацев. Заголовок абзаца представляет собой имя терминала, а текст состоит из фраз вида Name=value, разделенных символом ':'. Name представляет собой символическое имя того или иного свойства, a value— его значение. Чаще всего это последовательность символов, формирующих соответствующую команду, или генерируемых терминалом при нажатии соответствующей клавиши. Кроме того, это может быть, например, ширина экрана терминала, измеренная в символах.
Для работы с терминальной базой данных позднее было создано несколько библиотек подпрограмм — низкоуровневая библиотека termcap и высокоуровневый пакет curses.
Для терминалов описанный подход оказался если и не идеальным, то, во всяком случае, приемлемым. Но, например, для графических устройств он не подошел — системы команд различных устройств оказались слишком непохожими и не сводимыми к единой системе "свойств".
Первым приближением к решению этой проблемы стало создание слециали рованных программ-фильтров. При использовании фильтров пользовательская программа генерирует графический вывод в виде последовательности команд некоторого языка. Фильтр принимает эти команды, синтезирует на их основе изображение и выводит его на графическое устройство. Вся поддержка различных устройств вывода возлагается на фильтр, пользовательская програма должна лишь знать его входной язык.
Самым удачным вариантом языка графического вывода в наше время считавется PostScript—язык управления интеллектуальными принтерами, разработанный фирмой Adobe. PostScript предоставляет богатый набор графических примитивов, но основное его преимущество состоит в том, что это полнофункциональный язык программирования с условными операторами циклами и подпрограммами.
Первоначально этот язык использовался только для управления дорогими моделями лазерных принтеров, но потом появились интерпретаторы этого языка способные выводить PostScript на более простые устройства. Наиболее известной программой этого типа является GhostScript— программа, реализованная в рамках проекта GNU и доступная как freeware. GhostScript поставляется в виде исходных текстов на языке С, способна работать во многих операционных системах; практически во всех ОС семейства Unix, OS/2, MS/OR DOS и т. д. и поддерживает практически все популярные модели графических устройств, принтеров, плоттеров и прочего оборудования.
Аналогичный подход используется в оконной системе X Window. В этой системе весь вывод на терминал осуществляется специальной программой-сервером. На сервер возлагается задача поддержки различных графических устройств. В большинстве реализаций сервер исполняется как обычная пользовательская программа, осуществляя доступ к устройству при помощи функций ioctl "установить видеорежим" и "отобразить видеопамять в адресное пространство процесса".

В обоих случаях "драйвер" оказывается разбит на две части: собственно драйвер, исполняющийся в режиме ядра, который'занимается только обменом данными с устройством, и программу, интерпретирующую полученные данные и/или формирующую команды для устройства. Эта программа может быть довольно сложной, но ошибка в ней не будет фатальной для системы, так как она исполняется в пользовательском кольце доступа. Проше всего происходит загрузка драйверов в системах, в которых ядро собирается в статический загрузочный модуль. В них драйвер просто присоединяется редактором связей к образу ядра и без каких-либо дополнительных усилии оказывается в памяти в процессе загрузки системы.
В системах с динамической подгрузкой модулей ядра, драйвер представляет собой перемещаемый загрузочный или объектный модуль, иногда того же формата, что и стандартные объектные или загрузочные модули в системе, а иногда и специализированного. В этом случае ядро должно содержать редактор связей, возможно являющийся функциональным подмножеством полноценного системного линкера.
Связывание кода драйвера с используемыми им функциями ядра обычно производится редактором связей в момент подключения модуля к образу ядра при статической сборке и в момент подгрузки модуля при сборке лирической. Однако связывание кода ядра с функциями драйвера, как правило, производится иным образом. Дело в том, что к ядру может подключаться переменное и, в большинстве систем, практически неограниченное количество драйверов. В этом случае обычно оказывается более удобным поддерживать таблицу точек входа зарегистрированных в системе драйверов.
Два основных подхода к формированию такой таблицы — это использование таблицы точек входа в качестве обязательного элемента формата драйвера, либо регистрация точек входа в системной таблице функциями инициализации драйвера.


Архитектура драйвера

Типичный протокол работы с внешним устройством состоит из анализа запроса, передачи команды устройству, ожидания прерывания по завершении этой команды, анализа результатов операции и формирования ответа внешнему устройству. Многие запросы не могут быть выполнены в одну операцию, поэтому анализ результатов операции может привести к выводу о необходимости передать устройству следующую команду.
Драйвер, реализующий этот протокол, естественным образом распадается на две нити: основную, которая осуществляет собственно обработку запроса, и обработчик прерывания. В зависимости от ситуации, основная нить может представлять собою самостоятельную нить, либо ее код может исполняться в рамках нити, сформировавшей запрос.
В примере 10.1 приводится скелет функции write () драйвера последовательного устройства в системе Linux. Скелет упрощенный (в частности, никак не решается проблема реентерабельности функции foo_write. Использованный механизм синхронизации с обработчиком прерывания также оставляет желать лучшего), но имеет именно такую архитектуру, которая была описана ранее. Текст цитируется по документу [HOWTO khg], перевод комментариев и дополнительные комментарии автора.

Пример 10.1. Скелет драйвера последовательного устройства для ОС Linux

f* Основная нить драйвера */
static int foo_write(struct inode * inode, struct file * file, char * buf, int count)
Щ
/* Получить идентификатор устройства: */
к/с в операционные систв
unsigned int minor = MINOR(inode->i_rdev); unsigned long copy size; unsigned long total_bytes_written = 0; unsigned long bytes__written;
/* Найти блок переменных состояния устройства */ struct foo_struct *foo = &foo_table[minor];
do { copy_size = (count <= FOO_BUFFER_SIZE ?
count : FOOJ3UFFER_'SIZE) ;
/* Передать данные из пользовательского контекста */ memcpy_fromfs(foo->foo_buffer, buf, copy_size);
while (copy_size) {
/* Здесь мы должны инициализировать прерывания*/
if (some_error_has_occured) { /* Здесь мы должны обработать ошибку */
current->timeout = jiffies + FOO_INTERRUPT_TIMEOUT;
/* Установить таймаут на случай, если прерывание будет пропущено */
interruptible_sleep_on (&f oo->foo_wait_queue) ;
if (some_error_has_occured) { /* Здесь мы должны обработать ошибку */
bytes_written = foo->bytes_xfered; foo->bytes_written = 0;
if (current->signal H ~current->blocked) { if (total_bytes_written + bytes__written)
return total_bytes_written + bytes_written; else
return -EINTR; /* Ничего не было записано, системный вызов был прерван, требуется повторная попытка */
O- Драйверы внешних устройств
total_byr.c5_v;r:.i.U-.r. т= bytes_written; buf += bytes_written; count -= bytes_written;
) while (count > 0) ; return total_bytes_written;
/* Обработчик прерывания */ static void foo__interrupt (int irq)
{ struct foo_struct *foo = &foo__table [foo_irq[irq] ] ;
/* Здесь необходимо выполнить все действия, которые должны быть выполнены по прерыванию.
Флаг в foo__table указывает, осуществляется операция чтения или записи. */
/* Увеличить foo->bytes_xfered на количество фактически переданных символов * /
if (буфер полон/пуст) wake_up_interruptible (&foo->foo_wait_queue) ;
}

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

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

  • включить мотор дисковода;
  • дождаться, пока диск разгонится до рабочей скорости (большинство контроллеров генерируют по этому случаю прерывание);
  • дать устройству команду на перемещение считывающей головки;
  • дождаться прерывания по концу операции перемещения;
  • запрограммировать ПДП и инициировать операцию записи;
  • дождаться прерывания, сигнализирующего о конце операции.

Лишь после этого можно будет передать данные программе. Наивная реализация таких многошаговых операций могла бы выглядеть так (за основу по-прежнему взят код из [HOWTO khg], обработка ошибок опущена), как показано в примере 10.2.

Пример 10.2. Простой драйвер контроллера гибкого диска

 /* Обработчики прерываний в зависимости от состояния */
        void handle_spinup_interrupt(int irq, fdd_struct *fdd) {
if (motor_speed_ok(fdd)) wake_up_interruptible((&fdd->fdd_wait_queue);
void handle_seek_interrupt(int irq, fdd_struct *fdd) {
if (verify_track(fdd)) wake_up_interruptible((&fdd->fdd_wait_queue);
void handle_dma_interrupt(int irq, fdd_struct *fdd) {
/* Увеличить fdd->bytes_xfered на количество фактически переданных
        символов */
if (буфер полон/пуст) wake_up_interruptible(&fdd->fdd_wait_queue);
/* Основная нить драйвера */
static int fdd_write(struct inode * inode, struct file * file, char *
        buf, int count)
10. Драйверы внешних устройств
/* Получить идентификатор устройства: */ = MINOR ( inode->irdev) ;
unsigned long ccpy_size;
unsigned long total_bytes_written = 0;
unsigned long bytes_written;
int state;
/* Найти блок переменных состояния устройства */ struct fdd_struct *fdd
        = &fdd_table [minor] ;
do { copy_size = (count <= FDD__BUFFER_SIZE ?
count : FDD_BUFFER_SIZE) ;
/* Передать данные из пользовательского контекста */ memcpy_f rornfs (fdd->fdd_buf
        fer, buf, copy_size) ;
while (copy_size) { if ( !motor_speed_ok (fdd) ) { fdd->handler = handle__spinup_interrupt;
        turn_motor_on (fdd) ;
current->timeout = jiffies + FDD_INTERRUPT_TIMEOUT; interruptible_sleep_on
        (&fdd->fdd_wait_queue) ; if (current->signal & -current->blocked)
        { if (total_bytes_written)
return total_bytes_written; else
return -EINTR; /* Ничего не было записано, системный вызов был прерван,
        требуется повторная попытка */
if (fdd->current_track != CALCULATE_TRACK(file)) { fdd->handler
        = handle_seek_interrupt; seek_head (fdd, CALCU1ATE__TRACK (f ile) ) ;
        current->timeout = jiffies + FDD_INTERRUPTjriMEOUT; interruptible_sleep_on(&fdd->fdd__wait_queue);
        if (current->signal & ~current->blocked) ( if (total bytes written)
Введение в операционныесист^
return total_bytes_written; else
return -EINTR; /* Ничего не было записано, системный вызов был прерван,
        требуется повторная попытка */
fdd->handler = handle_dma_interrupt;
setup_fdd_dma(fdd->fdd_buffer+bytes_xfered, copy_size) issue_write_command(fdd)
        ;
current->timeout = jiffies + FDD_INTEKRUPT_TIMEOUT; interruptible_sleep_on
        (Sfdd->fdd_wait_queue) ;
bytes_written = fdd->bytes_xfered; fdd->bytes_written = 0;
if (current->signal & ~current->blocked) { if (total_bytes_written
        + bytes_written)
*
return total_bytes_written + bytes_written; else
return -EINTR; /* Ничего не было записано, системный вызов был прерван,
        требуется повторная попытка */
total_bytes_written += bytes_written; buf += bytes__written; count -=
        bytes_written;
} while (count > 0) ; return total bytes written;
/* Обработчик прерывания */ static void fdd_interrupt(int irq) { struct
        fdd_struct *fdd = &fdd_table[fdd_irq[irq]];
f (fdd->ha:idier != NULL) { fdd->handier(irq, fdd); fdd->handIer=MULL;
} else
{
/* He наше прерывание? */
}
}

Видно, что предлагаемый драйвер осуществляет обработку ошибок и формирование последующих команд в основной нити драйвера. Велик соблазн перенести эти функции или их часть в обработчик прерываний. Такое решение позволяет сократить интервал между последовательными командами, и, таким образом, возможно, повысить производительность работы устройства.
Однако слишком большое время, проводимое в обработчике прерывания, нежелательно с точки зрения других модулей системы, так как может увеличить реальное время реакции для них. Особенно важно это для систем, которые выключают планировщик на время обслуживания прерываний. Поэтому многие ОС накладывают ограничения на время обслуживания прерываний, и часто это ограничение исключает возможность формирования команд и произведения других сложных действий в обработчике.
Обработчик, таким образом, должен выполнять лишь те операции, которые требуется выполнить немедленно. В частности, многим устройствам требуется так или иначе объяснить, что прерывание обработано, чтобы они сняли сигнал запроса прерывания. Если этого не сделать, после возврата из обработчика и обусловленного этим снижения приоритета ЦПУ, обработчик будет вызван опять.
Впрочем, нередко предлагается путь к обходу и этого ограничения: обработчикам прерываний разрешено создавать высокоприоритетные нити, которые начнут исполняться сразу же после того, как будут обслужены все прерывания. В дальнейшем мы будем называть эти высокоприоритетные нити fork-процессами (этот термин используется в VMS. Другие ОС, хотя и используют аналогичные понятия, часто не имеют внятной терминологии для их описания).

Fork-процессы в VMS
С точки зрения планировщика VMS, fork-процесс представляет собой нить с укороченным контекстом. Вместо обычного дескриптора процесса (РСВ — Process Control Block) используется UCB — Unit Control Block, блок управления устройством. Укорочение заключается в том, что эта нить может работать только с одним банком виртуальной памяти из трех, имеющихся у процессора VAX, а именно с системным (полный список банков памяти VAX приведен в главе 5); таким образом, при переключении контекста задействуется меньше регистров диспетчера памяти. Fork-процесс имеет более высокий приоритет, чем пользовательские процессы, и может быть вытеснен только более приорцтСб ным fork-процессом и обработчиком прерывания.

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

Пример 10.3. Более сложный драйвер контроллера гибкого диска

 /* Обработчики прерываний в зависимости от состояния */
        void schedule_seek (fdd__struct *fdd)
if ( !motor_speed_pk (fdd) ) {
fdd->handler = schedule_seek;
retry_spinup ( ) ; }
if (fdd->current_track != CALCULATEJTRACK (fdd->f ile) ) fdd->handler
        = schedule_command; seek_head(fdd, CALCULATE_TRACK (f ile) } ; } else
/* Мы уже на нужной дорожке */ schedule operation (fdd) ;
void schedule_operation(fdd_struct *fdd) {
if (fdd->current_track != CALCULATEJTRACK(fdd->file)) { fdd->handler
        = schedule_operation; retry_seek(fdd); return; }
switch(fdd->operation) ( case FDD_WRITE:
fdd->handler = handle_dma_write_interrupt; setup_fdd_dma(fdd->fdd_buffer+fdd->bytes__xfered,
        fdd->copy_size)
I issue_write_coromand (fdd) ; break; case FDD_READ:
fdd->handler = handle_dma_read_interrupt;
setup_fdd_dma (fdd->fdd_buf fer-t-fdd->bytes_xfered, fdd->copy_size)
issue_read_command (fdd) ;
break; /* Здесь же мы должны обрабатывать другие команды,
требующие предварительного SEEK */
void handle_dma_write_interrupt (fdd_struct *fdd)
( /* Увеличить fdd->bytes_xfered на количество фактически
переданных символов * /
if (буфер полон/пуст)
/* Здесь мы не можем передавать данные из пользовательского
адресного пространства . Надо будить основную нить * /
wake_up_interruptible (&fdd->fdd_wait_queue) ; else {
fdd->handler = handle__dma__write_interrupt;
setup_fdd__dma (fdd->fdd_buf fer+fdd->bytes_xfered, fdd->copy_size)
issue_write_corranand(fdd) ;
/* Основная нить драйвера */
static int fdd_write (struct inode * inode, struct file * file,
char * buf, int count) (
/* Получить идентификатор устройства: */ unsigned int minor = MINOR (
        inode->i_rdev) ; /* Обратите внимание, что почти все переменные основной
        нити
"переехали" в описатель состояния устройства */ /* Найти блок
        переменных состояния устройства */ struct fdd struct *fdd = &fdd table
        [minor] ;
fdd->total_bytes_written = 0; fdd->operation = FDD_WRITE;
do { fdd->copy_size = (count <= FDD_BUFFER_SIZE ?
count : FDD_BOFFER_SIZE);
/* Передать данные из пользовательского контекста */ memcpy_fromfs(fdd->fdd_buffer,
        buf, copy_size);
if (!motor_5peed_ok()) (
fdd->handler = schedule_seek;
turn_motor_on(fdd); } else
schedule_seek(fdd) ;
current->timeout = jiffies + FDD_INTERRUPT__TIMEOUT; inte.rruptible_sleep_on(&fdd->fdd_wait_queue);
        if (current->signal & ~current->blocked) { if (fdd->total_bytes_written+fdd->bytes__written)'
return fdd->total_bytes_written+fdd->bytes_written; else
return -EINTR; /* Ничего не было записано,
системный вызов был прерван, требуется повторная попытка */
fdd->total_bytes_written += fdd->bytes_written; fdd~>buf += fdd->bytes_written;
        count -= fdd->bytes_written;
} while (count > 0) ; return total bytes written;
static struct tq_struct floppy_tq;
/* Обработчик прерывания */ static void fdd interrupt(int irq)
truct fdcl struct *fdd = &fdd_table [fdd_irq [irq] ] ;
Af (fdd->ha!,;;ier != NULL) {
void (Chandler)(int irq, fdd_struct * fdd) ;
f]_0ppy_tq. routine = (void *)(void *) fdd->handler;
floppy tq.parameter = (void *)fdd;
fdd->handler=NULL;
queue_task(sfloppy_tq, &tq_immediate); } else
{ /* He наше прерывание? */
}
}

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


Введение в конечные автоматы

Конечный автомат (в современной англоязычной литературе используется также более выразительное, на взгляд автора, обозначение, не имеющее хорошего русского эквивалента — state machine, дословно переводимое как машина состояний) представляет собой устройство, имеющее внутреннюю память (переменные состояния), а также набор входов и выходов. Объем внутренней памяти у конечных автоматов, как следует из названия, конечен. Автоматы с неограниченным объемом внутренней памяти называются бесконечными автоматами, нереализуемы и используются только в теоретических Построениях (Минский 1971].
Однако некоторые разновидности теоретически бесконечных автоматов — например, стековые — могут быть реализованы в форме автоматов с практически неограниченной памятью — например, достаточно глубоким стеком — и находят практическое применение, например при синтаксическом анализе языков со вложенными структурами [Кормен/Лейзерсон/Ривест 2000].
Работа автомата состоит в том, что он анализирует состояния своих входов, и, в зависимости от значений входов и своего внутреннего состояния, изменяет значения выходов и внутреннее состояние. Правила, в со ответствии с которыми происходит изменение, описываются таблицей или диаграммой переходов. Диаграмма переходов представляет собой граф, вершины которого соответствуют допустимым состояниям внутренних переменных автомата, а ребра — допустимым переходам между ними. Переходы между вершинами направленные: наличие перехода из А в В не означает, что существует переход из В в А. Наличие перехода в обоих направлениях символизируется двумя ребрами, соединяющими одну пару вершин. Такой граф называется ориентированным [Кормен/Лейзерсон/Ривест 2000]. Таблица переходов может рассматриваться как матричное представление диаграммы переходов.
Блок-схемы (рис. 10.5) являются обычным способом визуализации графов переходов и используются для описания алгоритмов с 60-х годов. Любой алгоритм, исполняющийся на фон-неймановском компьютере с конечным объемом памяти (а также любой физически исполнимый алгоритм), может быть описан как конечный автомат и изображен в виде блок-схемы.
У конечных автоматов с ограниченным числом допустимых значений входов, граф переходов всегда конечен, хотя и может содержать циклы (замкнутые пути) и контуры (совокупности различных путей, приводящих к одной и той же вершине). Понятно, что для автомата с графом, содержащим циклы, невозможно гарантировать финитности — завершения работы за конечное время. Как известно, задача доказательства финитности алгоритма, хотя и решена во многих частных случаях, в общем случае алгоритмически неразрешима [Минский 1971].
Применительно к драйверам внешних устройств, циклический граф может соответствовать повторным попыткам выполнения операции после ее не-'удачи. Понятно, что на практике количество таких попыток следует ограничивать. Самый простой способ такого ограничения — введение счетчика попыток. Формально после этого состояния с различными значениями счетчика превращаются в наборы состояний, а граф переходов становится ациклическим (рис. 10.6), но для достаточно большого количества повторений опять-таки необозримым, поэтому на практике часто используют сокращенную блок-схему, в которой состояния с разными значениями счетчика цикла изображаются как одно состояние.

Рис. 10.5. Блок-схема драйвера

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

Рис. 10.6. Развертывание циклов в графе состояния

С другой стороны, ряд даже довольно сложных алгоритмов естественным образом описывается автоматами с небольшим числом состояний, которые могут быть закодированы одной скалярной переменной состояния или стеком таких переменных. Такие автоматы находят применение в самых разнообразных задачах: лексическом и синтаксическом разборе контекстно-свободных и многих типах контекстно-связанных языков [Кормен/ Пейзерсон/Ривест 2000 1, реализации сетевых протоколов, задачах корпоративного документооборота (Керн/Линд 2000] и др. В частности, легко понять, что обсуждаемый нами алгоритм драйвера относится именно к этой категории алгоритмов.
Два основных подхода к реализации конечных автоматов — это развернутые (unrolled) автоматы и автоматы общего вида. Примером развернутого конечного автомата является код основной нити примера 10.2. Понятно, что развертыванию поддаются только автоматы с весьма специфической — линейной или древовидной — структурой графа состояний, и если в процессе уточнения требований мы выясним, что структура автомата должна быт более сложной, нам придется полностью реорганизовать код.
Автомат общего вида выглядит несколько сложнее, но, научившись распознавать его конструкцию, легко разрабатывать такие программы по задан ной блок-схеме и, наоборот, восстанавливать граф состояний по коду программы. Главным преимуществом грамотно реализованного конечного автомата является легкость модификации: если граф переходов измените нам надо будет изменить код только тех узлов, которые затронуты изменением.
Примеры реализации конечных автоматов такого типа на процедурном языке программирования приводятся во многих учебниках программированию например [Грогоно 1982). Чаще всего реализация состоит из цикла, условием выхода из которого является достижение автоматом финального состояния, и размещенного в теле цикла оператора вычислимого перехода с переменной состояния в качестве селектора. Конечный автомат, похожий на эту классическую реализацию, приведен в примере 10.4.

Пример 10.4. Конечный автомат драйвера контроллера IDE/ATA для OS/2

VOID NEAR StartSM( NPACB npACB )
}
/* ------------------------------------------------ */
* Проверка счетчика использований АСВ*/
/* -------------------- */
/* Автомат реентрантен для каждого АСВ / *
/* ------------------------------------------------ */
DISABLE
npACB->UseCount++;
iff npACB->UseCount == 1 )
{
do
{
ENABLE
do
{
npACB->Flags &= ~ACBF_WAITSTATE;
switch (npACB->State) {
case ACBS__START :
StartState(npACB);
break;
case ACBS_INTERRUPT:
InterruptState(npACB);
break;
case ACBS_DONE:
DoneState(npACB);
break;
case ACBS_SUSPEND:
SuspendState(npACB);
break;
case ACBS_RETRY:
RetryState(npACB);
break;
case ACBS_RESETCHECK:
ResetCheck(npACB);
break/case ACBS_ERROR:
ErrorState(npACB);
break;
while ( !(npACB->Flags & ACBF WAITSTATE) );
DISABLE
I
while ( — npACB->UseCount ) ;

Конечный автомат драйвера OS/2
Несмотря на простоту, пример 10.4 нуждается в комментариях. Параме! функции startSM — АСВ (Adapter Control Block — блок управления адаптере! так в OS/2 называется блок переменных состояния устройства). АСЗ содержит указатель на очередь запросов IORB (Input/Output Request Block — блок запроса на ввод/вывод) и скалярную переменную state, которая указывает, в како состоянии сейчас находится обработка первого запроса в очереди. По коду этого состояния определяется, какую функцию следует вызвать. В телах этк функций, в зависимости от результата операции, происходит установка следующего значения переменной состояния и, возможно, флага ACB_WAITSTATE.
Функция startSM (Start State Machine) вызывается как из функции обработн запросов, так и из обработчика прерывания. Поэтому перед входом в собс венно автомат и после выхода из него стоит код, использующий поле nрАСЕ >UseCount как флаговую переменную, чтобы не допустить одновременного входа в автомат из обоих возможных нитей исполнения. Обратите также внимание, что макросами ENABLE и DISABLE (запрет и разрешение прерываний окружена работа с флаговой переменной, но не сам автомат.
(В качестве упражнения читателю предлагается понять, как же обеспечиваете вызов функции interruptstate, если во время прерывания основной поте драйвера все еще находился в теле автомата.
Полный текст драйвера IDE/ATA для OS/2 включен в стандартную поставку DDK (Driver Development Kit— набор инструментов [для] разработчика драйверов), который может быть найден на сайте [www.ibm.com OS/2 DDK].

Построенный нами в примере 10.3 код внешне совсем не похож на приме 10.4, но, в действительности, также представляет собой конечный автомат в качестве переменной состояния используется переменная fdd->handier, в качестве дискретных значений этой переменной — указатели на функции обрабатывающие конкретные состояния.


Архитектура драйвера

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

Драйвер IDE/ATA для Linux
В примере 10.5 приведена основная функция обработки запроса и функция об работки прерывания, используемая при записи нескольких секторов. Обе эти функции вызываются драйвером контроллера IDE/ATA, который представляет собой диспетчер запросов к подключенным к контроллеру устройствам.
Структура *hwgroup представляет собой блок переменных состояний контроллера устройства. Эта структура содержит также указатель на текущий запрос к устройству. Информации, содержащейся в этих структурах, достаточно, чтобы очередная функция конечного автомата драйвера узнала все, необходимое ей для выполнения очередного этапа запроса. В данном случае конечный автомат весьма прост и состоит из многократного вызова функции ide_multiwrite, копирующей в контроллер очередной блок данных. Условием завершения автомата служат ошибка контроллера либо завершение запроса. Функции ide__dma_read, ide_dma_write, ide_read и ide_write, исполняемые машиной состояний при обработке других запросов не приводятся.

Пример 10.5. Фрагменты драйвера диска IDE/ATA ОС Linux 2.2, перевод комментариев автора

/*
* ide_multwrite() передает приводу блок из не более, чем mcount
* секторов как часть многосекторной операции записи. *
* Возвращает 0 при успехе. *
* Обратите внимание, что мы можем быть вызваны из двух контекстов -
* контекста do_rw и контекста IRQ. IRQ (Interrupt Request,
* запрос прерывания)может произойти в любой
* момент после того, как мы выведем полное количество секторов,
* поэтому мы должны обновлять состояние _до_ того, как мы выведем
* последнюю часть данных! */
int ide_multwrite (ide_drive__t *drive, unsigned int mcount) {
ide_hwgroup_t *hwgroup= HWGROUP(drive);
'struct request *rq = &hwgroup->wrq;
do {
char *buffer;
int nsect = rq->current_nr_sectors;
if (nsect > mcount)
nsect = mcount; mcount -= nsect; buffer = rq->buffer;
rq->sector += nsect; rq->buffer += nsect « 9; rq->nr_sectors -= nsect; rq->current nr sectors -= nsect;
/* Переходим ли мы к следующему bh после этого? */ if (!rq->current_nr_sectors) {
struct buffer_head *bh = rq->bh->b_reqnext;
/* Завершиться, если у нас кончились запросы V if (!bh) {
mcount = 0; } else (
rq->bh = bh;
rq->current_nr_sectors = bh->b_size » 9;
rq->buffer = bh->b_data;
/*
* Теперь мы все настроили, чтобы прерывание
* снова вызвало нас после последней передачи. */
idedisk_output_data(drive, buffer, nsect«7); } while (mcount);
return 0;
/*
* multwrite_intr() — обработчик прерывания многосекторной записи */
static ide_startstop_t multwrite_intr (ide_drive_t *drive) {
byte stat;
ir.t i;
ide_hwgroup_t *hwgroup = HWGROUP(drive);
struct request *rq = &hwgroup->wrq;
if (OK_STAT(stat=GET_STAT(),DRIVE_READY,drive->bad_wstat)) { if (stat & DRQ_STAT) { /*
* Привод требует данных. Помним что rq -
* копия запроса. */
if (rq->nr_sectors) {
if (ide_multwrite(drive, drive->mult_count))
return ide_stopped; «
ide_set__handler (drive, &multwrite_intr, WAIT_CMD, NULL); return ide_started; }
} else { /*
* Если копирование всех блоков завершилось,
* мы можем завершить исходный запрос. */
if ( ! rq->nr__sectors) { /* all done? */ rq = hwgroup->rq; for (i = rq->nr_sectors; i > 0;){ i -= rq->current_nr_sectors; ide_end_request(1, hwgroup); } return ide stopped;
return ide_stopped; /* Оригинальный код делал это здесь (?) */
! ьнешних
[return ide_errcr(drive, "multwrite_intr", stat);
/*
i do rw disk() передает команды READ и WRITE приводу,
* используя LBA если поддерживается, или CHS если нет, для адресации
* секторов. Функция do_rw_disk также передает специальные запросы.
*/
static ide_startstop__t do_rw_disk (ide_drive_t *drive, struct request *rq, unsigned long block)
{ if (IDE_CONTROL_REG)
OUT_BYTE (drive->ctl, IDE_CONTROL_REG) ; OUT_BYTE (rq->nr_sectors, IDE_NSECTOR_REG) ; if (drive->select.b.lba) (
OUT_BYTE (block, IDE_SECTOR_REG) ;
OUT_BYTE (block»=8, IDE_LCYL_REG) ;
OUT_BYTE (block»=8, I DE_HC YL_REG ) ;
OUT_BYTE( ( (block»8) &0x0f) I drive->select . all, IDE_SELECT_REG) ; } else f
unsigned int sect, head, cyl, track;
track = block / drive->sect;
sect = block % drive->sect + 1;
ODT^BYTE (sect, IDE__SECTOR_REG) ;
head = track % drive->head;
cyl = track / drive->head;
OUT__BYTE (cyl, IDE_LCYL_REG) ;
OUT_BYTE (cyl»8, IDE_HCYL_REG) ;
OUT_BYTE (head I drive->select .all, IDE_SELECT_REG) ;
if (rq->cmd == READ) { ^#ifdef CONFIG_BLK_DEV_IDEDMA
if (drive- >using_dma && ! (HWIF (drive) ->dmaproc (ide_dma_read, drive))
return ide_started; #endif /* CONFIG_BLK_DEV_IDEDMA */
ide_set_handler (drive, iread_intr, WAIT_CMD, NULL) ; OUT_BYTE(drive->mult_count ? WIN_MULTREAD : WIN_READ, IDE COMMAND REG) ;
''—-^
return ide started;
if (rq->cmd == WRITE) (
ide_startstop_t startstop; lifdef CONFIG_BLK_DEV_IDEDMA
if (drive->using_drna && !(HWIF(drive)->dmaproc(ide dma^write,
drive)))
return ide_started; lendif /* CONFIG_BLK_DEV_IDEDMA */
OUT_BYTE(drive->mult_COUnt ? WIN_MULTWRITE : WIN_WRITE,
IDE_COMMAND_REG); if (ide_wait_stat(Sstartstop, drive, DATA_READY, drive->bad_wstat,
WAIT^DRQ)) ( printk(KERN_ERR "%s: no DRQ after issuing %s\n", drive->na:r.e,
drive->mult_count ? "MULTWRITE" : "WRITE"); return startstop;
if (!drive->unmask)
__cli(); /* только локальное ЦПУ */
if (drive->mult_count) (
ide_hwgroup_t *hwgroup = HWGROUP(drive);
/*
* Эта часть выглядит некрасиво, потому что мы ДОЛЖНЫ установить
* обработчик перёд выводом первого блока данных.
* Если мы обнаруживаем ошибку (испорченный список буферов)
* в ide_multiwrite(),
* нам необходимо удалить обработчик и таймер перед возвратом.
* К счастью, это НИКОГДА не происходит (правильно?).
* Кажется, кроме случаев, когда мы получаем ошибку... */
hwgroup->wrq = *rq; /* scratchpad */
ide_set_handler (drive, &multwrite_intr, WAIT__CMD, NULL);
if (ide_multwrite(drive, drive->mult_count)) {
unsigned long flags;
spin_lock_irqsave (&io__request_lock, flags) ;
hwgroup->handler = NULL;
del_timer(&hwgroup->timer);
spin unlock_irqrestore(&io_request_lock, flags);
return ide_stopped;
Глава 10. Драйверы внешних
} else {
ide_set_handler (drive, &write_intr, WAIT_CMD, NULL); idedisk_output_data(drive, rq->buffer, SECTOR_WORDS);
}
i return ide_started;
)
i'-printk (KERN_ERR "%s: bad command: %d\n", drive->name, rq->cmd)
ide_end_request(0, HWGROUP(drive)); return ide_stopped;


Запросы к драйверу

Обработку запроса можно разделить на три фазы: предобработку, исполнение запроса и постобработку. Пользовательская программа запрашивает операцию, исполняя соответствующий системный вызов. В ОС семейства Unix это может быть, например, системный вызов write (int file, void * buffer, size_t size) .
Предобработка выполняется модулем системы, который, как правило, исполняется в нити процесса, сформировавшей запрос, но имеет привилегии ядра. Фаза предобработки включает в себя.

  • Проверку допустимости параметров. Пользователь должен иметь право выполнять запрошенную операцию над данным устройством, адрес буфера должен быть допустимым адресом пользовательского адресного пространства и т. д.
  • Возможно, копирование или отображение данных из пользовательского адресного пространства в системное.
  • Возможно, преобразование выводимых данных. Например, в системах семейства Unix при выводе на терминал система может заменять символ горизонтальной табуляции на соответствующее число пробелов (если терминал не поддерживает горизонтальную табуляцию) и преобразовывать символ перевода строки. Дело в том, что внутри системы в качестве разделителя строк используется символ новой строки '\n' (ASCII NL), а различные модели терминалов и принтеров могут использовать также '\r' (ASCII RET, возврат каретки) или последовательности '\r"\n' или '\n"\r'.
  • Возможно, обращение к процедурам драйвера. Эти процедуры могут блокировать код и данные драйвера в физической памяти и выделять буферы для ПДП. Эти операции реализуются нереентерабельными сервисами ядра и не всегда могут быть выполнены драйвером во время обработки запроса.
  • Передача запроса драйверу. Некоторые системы реализуют передачу запроса как простой вызов соответствующей функции драйвера, но чаще используются более сложные асинхронные механизмы, которые будет обсуждаться далее.

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


Синхронный ввод-вывод

Самым простым механизмом вызова функций драйвера был бы косвенный вызов соответствующих процедур, составляющих тело драйвера, подобно тому, как это делается в MS DOS и ряде других однозадачных систем.
В системах семейства Unix драйвер последовательного устройства исполняется в рамках той нити, которая сформировала запрос, хотя и с привилегиями ядра. Ожидая реакции устройства, драйвер переводит процесс в состояние ожидания доступными ему примитивами работы с планировщиком. В примере 10.1 это interruptibie_sieep_on. В качестве параметра этой функции передается блок переменных состояния устройства, и в этом блоке сохраняется ссылка на контекст блокируемой нити.
Доступные прикладным программам функции драйвера исполняются в пользовательском контексте — в том смысле, что, хотя драйвер и работает в адресном пространстве ядра, но при его работе определено и пользовательское адресное пространство, поэтому он может пользоваться примитивами
Обмена данными С НИМ (в примере 10.1 это memcpy_from_fs).
Обработчик прерывания наоборот работает в контексте прерывания, когда пользовательское адресное пространство не определено. Поэтому, чтобы при обслуживании прерывания можно было получить доступ к пользовательским данным, основная нить драйвера вынуждена копировать их в буфер в адресном пространстве ядра.

Синхронная модель драйвера очень проста в реализации, но имеет существенный недостаток, приведенный в примере 10.1, — драйвер нереентерабелен. Обращение двух нитей к одному устройству приведет к непредсказуемым последствиям (впрочем, для практических целей достаточно того, что среди возможных последствий числится нарушение целостности данных и последующая паника регистров на экране). Предсказуемость последствий обеспечивается включением в контекст устройства семафора, установкой этого семафора при входе в функцию foo_write и снятием его при выходе. Семафор имеет очередь ожидающих его процессов, и, таким образом, реентрантно (т. е. во время обработки предыдущего аналогичного запроса) приходящие запросы будут устанавливаться в очередь.
Альтернативный подход к организации ввода-вывода состоит в том, чтобы возложить работу по формированию очереди запросов не на драйвер, а на функцию предобработки запроса. При этом первый запрос к драйверу, какое-то время бывшему неактивным, может по-прежнему осуществляться в нити процесса, сформировавшего этот запрос, но все последующие запросы извлекаются из очереди fork-процессом драйвера при завершении предыдущего запроса. Такой подход называется асинхронным.

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


Асинхронный ввод-вывод

В системах семейства Unix драйверы блочных устройств обязательно асинхронные. Кроме того, в современных версиях системы асинхронными драйверами являются драйверы потоковых устройств. Многие другие ОС, в том числе однозадачные (такие, как DEC RT-11), используют исключительно асинхронные драйверы.
Драйвер, использующий асинхронную архитектуру, обычно предоставляет вместо отдельных функций read, write, ioctl и т. д. единую функцию, которая в системах семейства Unix называется strategy, а мы будем называть стратегической функцией (рис. 10.7).

Запросы к драйверу в VMS
В операционной системе VAX/VMS драйвер получает запросы на ввод-вывод из очереди запросов. Элемент очереди называется IRP (lnput[Output] Request Packet — пакет запроса ввода-вывода). Обработав первый запрос в очереди, драйвер начинает обработку следующего. Операции над очередью запросов выполняются специальными командами процессора VAX и являются атомарными. Если очередь пуста, основная нить драйвера завершается. При появлении новых запросов система вновь запустит ее.

Рис. 10.7. Стратегическая функция и очередь запросов

IRP содержит:

  • код операции (чтение, запись или код SPFUN— специальная функция, подобная ioctl в системах семейства Unix);
  • адрес блока данных, которые должны быть записаны, или буфера, куда данные необходимо поместить;
  • информацию, используемую при постобработке, в частности, идентификатор процесса, запросившего операцию.

В зависимости от кода операции драйвер запускает соответствующую подпрограмму. В VAX/VMS адрес подпрограммы выбирается из таблицы FDT (Function Definition Table). Подпрограмма инициирует операцию и приостанавливает процесс, давая системе возможность исполнить другие активные процессы. Затем, когда происходит прерывание, его обработчик инициирует fork-процесс, исполняющий следующие этапы этого запроса. Завершив один запрос, fork-процесс сообщает об этом процедурам постобработки (разбудив соответствующий процесс) и, если в очереди еще что-то осталось, начинает исполнение следующего запроса.

В качестве параметра стратегическая функция получает указатель на структуру запроса, в которой содержатся код требуемой операции и блок данных. При этом возникает сложный вопрос, а именно — в каком адресном пространстве размещается этот блок?
На первый взгляд, идеальным решением было бы размещение этого блока сразу в пользовательском адресном пространстве. Проблема здесь в том, что стратегическая функция — особенно при обработке не первого запроса в очереди — исполняется не в пользовательском контексте, когда можно применять примитивы обмена данными с адресным пространством задачи,в контексте fork-процесса, а то и в контексте прерывания, когда адресное пространство пользователя не определено.
Возможны два варианта решения этой проблемы: хранить в структуре запроса иноке и указатель на пользовательское адресное пространство, либо все-таки копировать данные в адресное пространство системы на этапе предобработки, и обратно в пользовательское на этапе постобработки запроса. Драйвер в этом случае не должен беспокоиться ни о каком копировании, зато разработчик ОС получает дополнительную головную боль в виде логики управления буферами в адресном пространстве системы и выделения памяти для них.
Буферизация запросов и формирование очереди к блочным устройствам в Unix осуществляется специальным модулем системы, который называется дисковым кэшем. Принцип работы дискового кэша будет обсуждаться в разд. Дисковый кэш. Очереди запросов к потоковым устройствам имеют меньший объем, поэтому выделение памяти для них осуществляется обычным kmalloc.


Сервисы ядра, доступные драйверам

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

  • Взаимодействие с конфигурацией системы — доступ к данным средств автоконфигурации и, кроме того, регистрация драйвера и управляемых им устройств в системе.
  • Сбор и сохранение статистики.
  • Запросы на выделение и освобождение системных ресурсов, в первую очередь памяти.
  • Примитивы межпоточного взаимодействия — между нитями самого драйвера, между драйвером и другими нитями ядра и, наконец, между драйвером и нитями пользовательского процесса.
  • Таймеры.
  • Передача данных из пользовательского адресного пространства и обратно и другие операции над пользовательским адресным пространством.
  • Сервисные функции.

Некоторые из групп этих функций будут подробнее описаны далее.


Автоконфигурация

— В моем поле зрения появляется новый объект.
Возможно, ты шкаф?
— Нет
— Возможно ты стол?
— Нет
— Каков твой номер?
— Женский
— Иду на вы
— Иди
Б. Гребенщиков

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

  • Каждое устройство имеет фиксированные адреса регистров. Системная шина либо генерирует исключение по отсутствию адресуемого устройства, либо при чтении с несуществующего адреса возвращает фиксированное значение (чаще всего 0 или OxFFFFFFFF). Во втором случае достаточно, чтобы один из регистров устройства после включения питания обязательно содержал значение, отличающееся от этого фиксированного. Функция probe драйвера обращается к регистрам этого устройства, и, прочитав правильное значение и не получив при этом ошибки шины, может сделать вывод, что устройство присутствует. Устройства, у которых нет драйверов, таким способом не могут быть обнаружены, но ОС все равно не сможет с ними работать. Этот метод плох тем, что трудно применим при большом числе изготовителей периферийных устройств и широкой номенклатуре этих устройств — конфликты между адресами устройств различных изготовителей практически неизбежны.
  • Каждое устройство имеет ПЗУ, которое географически отображается на адреса системной шины. После запуска загрузочный монитор сканирует все возможные адреса таких ПЗУ и исполняет хранящийся в найденных микросхемах код. Этот код регистрирует устройство в конфигурационной базе данных загрузочного монитора. ОС после загрузки обращается к этой базе. Данный метод плох тем, что применим, только если устройства подключаются к системам с бинарно совместимыми центральными процессорами.
  • Каждое устройство содержит набор конфигурационных регистров, обк но также адресуемых географически. Эти регистры содержат тот или иной уникальный идентификатор устройства и, возможно, сведения о конфигурации. Сканирование этих регистров может осуществляться как самой системой, так и загрузочным монитором. Этот метод лишен татков двух предыдущих и широко применяется в большинстве современных периферийных шин, например в PCI.

Приведенные рассуждения справедливы не только для устройств, имеющих адреса на системной шине, но и для устройств, подключаемых к шинам собственной адресацией, например SCSI. Количество адресов шины SCSI невелико, поэтому подсистема автоконфигурации быстро может просканировать их все. Каждое устройство SCSI должно поддерживать команду INQUIRY, в качестве ответа на которую обязано вернуть тип устройства и другую информацию, в частности, название изготовителя и модели, поддерживаемую версию протокола SCSI и др. Одна из форм команды INQUIRY позволяет также проверить, поддерживает ли устройство какую-либо другую команду из набора команд SCSI.
Одна из серьезных неприятностей, которая может возникнуть при автоконфигурации — это конфликт устройств по каким-либо ресурсам, чаще всего — адресам всех или некоторых регистров; линии запроса или вектору прерывания. Если первый конфликт разрешим только перенастройкой аппаратуры (устройства с одним и тем же адресом на шине гарантированно неработоспособны, а в некоторых случаях могут сделать неработоспособной всю шину), то второй может быть обойден сугубо программными средствами.
Действительно, ничто не мешает драйверам конфликтующих устройств разделять один вектор прерывания. При приходе запроса прерывания по этому вектору вызывается один из обработчиков. Он анализирует состояние своего устройства, и если обнаруживает, что прерывание вызвано им, обрабатывает его. Если же прерывание не его, он просто вызывает следующий обработчик.
Эта схема допускает каскадирование потенциально неограниченного количества обработчиков, с тем очевидным недостатком, что каждое дополнительное звено цепочки значительно увеличивает задержку прерывания для всех последующих драйверов.
Большинство современных ОС использует более сложный механизм обработки прерываний, когда пролог и эпилог прерывания исполняются сервисной функцией, предоставленной ядром системы, и уже эта функция вызывает собственно обработчик. Протокол обмена вызывающей функции с обработчиком может включать в себя код возврата: обработчик должен при этом сообщать, "его" это прерывание или не "его". В последнем случае необходимо вызвать следующий обработчик, и так далее.
Эта схема также допускает неограниченное каскадирование и обладает тем же недостатком, что и предыдущая, а именно — увеличивает задержку для последних обработчиков в цепочке.
В любом случае, разделение векторов прерываний требует активной кооперации со стороны обработчиков этих прерываний.


Выделение памяти

Алгоритмы выделения памяти, в том числе и пригодные для использования в ядре ОС, подробно обсуждались в главе 4. Кроме того, мы уже упомянули тот печальный факт, что в контексте прерывания система обычно не позволяет запрашивать ресурсы. Важно остановиться еще на двух аспектах проблемы.
go-первых, ядро ОС обычно размешается в физической памяти и не подвергается страничному обмену, поэтому память ядра представляет собой более дефицитный ресурс, чем виртуальная память, доступная прикладным программам. Разработчик модуля ядра должен иметь это в виду и не прибегать к экстравагантным схемам управления буферами, которые иногда применяются в прикладных программах. Управлению большими объемами буферов, в частности дисковым кэшем, в книге посвящен разд. Дисковый кэш.
Во-вторых, в ряде случаев, драйвер не может удовлетвориться любым участком физического ОЗУ: например, некоторые старые контроллеры ПДП или периферийной шины не могут адресовать всю физическую память. Так, контроллер ПДП шины ISA имеет 24-разрядный адрес и способен, таким образом, адресовать только младшие 16 Мбайт ОЗУ (рис. 10.8). Некоторые контроллеры ПДП требуют выравнивания буфера, чаще всего на границу страницы.

Рис. 10.8. Ограничения для буферов ПДП

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


Таймеры

Ядро обычно предоставляет два типа таймеров — часы реального времени, указывающие астрономическое время (драйверу это время обычно интересно только для сбора статистики) и собственно таймеры — механизмы, позволяющие отмерять интервалы времени.
Таймеры интересны драйверам с нескольких точек зрения. Один из важных способов их использования приведен в примере 10.1: если устройство из-за какой-либо ошибки не сгенерирует прерывания, наивный драйвер может остаться в состоянии ожидания навсегда. Чтобы этого не происходило, драйвер должен устанавливать будильник, который' сообщит основному потоку, что устройство подозрительно долго не отвечает.
Таймеры используются также как альтернатива непрерывному опросу устройства при исполнении длительных операций, например сброса устройства, если использование прерываний почему-либо нежелательно или невозможно. Если говорить именно о-сбросе, автору не известно ни одного устройства, которое генерировало бы прерывание при завершении этой операции.


Обмен данными с пользовательским процессом

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

Обработка сигналов драйвером в Unix
Так, в системах семейства Unix все операции ввода-вывода, а также все остальные операции, переводящие процесс в состояние ожидания, могут быть прерваны сигналом. Сигнал представляет собой примитив обработки исключений, отчасти похожий на аппаратное прерывание тем, что при обработке сигнала может быть вызвана предоставленная программистом функция-обработчик. Необработанный сигнал обычно приводит к принудительному завершению процесса.
Будучи прерван сигналом, системный вызов останавливает текущую операцию, и, если это была операция обмена данными, но данных передано не было, возвращает код ошибки EINTR, говорящий о том, что вызов был прерван и, возможно, операцию следует повторить. Код, делающий это, присутствует в примере 10.1.
Например, пользовательский процесс может использовать сигнал SIGALARM для того, чтобы установить свой собственный будильник, сигнализирующий, что операция над устройством исполняется подозрительно долго.
Если драйвер не установит своего будильника и не станет отрабатывать сигналы, посланные процессу, может возникнуть очень неприятная ситуация.
Дело в том, что в Unix все сигналы, в том числе и сигнал безусловного убийства SIGKILL, обрабатываются процедурой постобработки системного вызова. Если драйвер не передает управления процедуре постобработки, то и сигнал, соответственно, оказывается необработанным, поэтому процесс остается висеть.
Других средств, кроме посылки сигнала, для уничтожения процесса в системах семейства Unix не предусмотрено. Поэтому процесс, зависший внутри обращения к драйверу, оказывается невозможно прекратить ни изнутри, ни извне.
Автор столкнулся с этим при эксплуатации многопроцессорной версии системы SCO Open Desktop 4.0. Система была снабжена лентопротяжным устройством, подключаемым к внешней SCSI-шине. Из-за аппаратных проблем это устройство иногда "зависало", прекращая отвечать на запросы системы. Драйвер лен-топротяжки иногда правильно отрабатывал это состояние как аппаратную ошибку, а иногда тоже впадал в ступор, не пробуждаясь ни по собственному будильнику, ни по сигналам, посланным другими процессами. (По имеющимся у автора сведениям эта проблема специфична именно для многопроцессорной версии системы. По-видимому, это означает, что ошибка допущена не в драйвере, а в коде сервисных функций.) В результате процесс, обращавшийся в это время клеите, также намертво зависал, и от него нельзя было избавиться.
Из-за наличия неубиваемого процесса оказывалось невозможно выполнить нормальное закрытие системы; в частности, не получалось размонтировать файловые системы, где зависший процесс имел открытые файлы. Выполнение холодной перезагрузки системы с неразмонтированными файловыми томами
Введение в операционные систем
приводило к неприятным последствиям для этих томов. Одна из аварий, к ко торым это привело, подробно описывается в разд. Восстановление ФС после сбоя.


Сервисные функции

Набор сервисных функций, доступных драйверу, обычно представляет собой подмножество стандартной библиотеки того языка высокого уровня, на котором обычно пишутся драйверы. В большинстве современных ОС это С. При выборе этого подмножества используется простой критерий: удаляются или заменяются на более или менее ограниченные все функции, которые так или иначе содержат в себе системные вызовы. Так, функции memcpy или sprintf вполне можно оставить, malloc придется заменить на эквивалент (в ядре Linux эта функция называется kmaiioc), a fwrite драйверу вряд ли понадобится, особенно если учесть, что работа многих драйверов начинается до того, как будет смонтирована хоть одна файловая система.
Важную роль среди сервисных функций занимают операции, часто исполняемые одной командой, но такой, которую компиляторы ЯВУ в обычных условиях не генерируют. Это, прежде всего, операции обращения к регистрам ввода-вывода в машинах с отдельным адресным пространством ввода-вывода, а также команды разрешения и запрещения прерываний. На С такие операции реализуются в виде макроопределений, содержащих ассемблерную вставку.
Правила кодирования драйверов во многих ОС требуют, чтобы даже на машинах с единым адресным пространством обращения к регистрам устройств происходили посредством макросов. Такой код может быть легко портнро-ван на процессор с отдельным адресным пространством. В наше время, когда одни и те же устройства и одни и те же периферийные шины подключаются к различным процессорам, портирование драйверов между различными процессорами осуществляется весьма часто.
При портировании драйвера разработчик должен также принимать во внимание различия в порядке байтов устройства и текущего процессора. Для приведения этого параметра в соответствие обычно предоставляются функции или макросы перестановки байтов как в одном слове, так и в блоках значительного размера.


Асинхронная модель ввода-вывода с точки зрения приложений

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

Рис. 10.9. Опережающее чтение

При записи, казалось бы, нет необходимости дожидаться физическою вершения операции. При этом мы получаем режим, известный как отложенная запись (lazy write— "ленивая" запись, если переводить дослоцц0\ Однако такой режим создает две специфические проблемы.
Во-первых, программа должна знать, когда ей можно использовать буфер данными для других целей. Если система копирует записываемые данные из пользовательского адресного пространства в системное, то эта же проблема возникает внутри ядра; внутри ядра проблема решается использованием многобуферной схемы, и все относительно просто. Однако копирование приводит к дополнительным затратам времени и требует выделения памяти под буферы. Наиболее остро эта проблема встает при работе с дисковыми и сетевыми устройствами, с которыми система обменивается большими объемами данных (а сетевые устройства еще и могут генерировать данные неожиданно). Проблема управления дисковыми буферами подробнее обсуждается в разд. Дисковый кэш.
В большинстве современных вычислительных систем общего назначения накладные расходы, обусловленные буферизацией запросов, относительно невелики или по крайней мере считаются приемлемыми. Но в системах реального времени и/или встраиваемых контроллерах, где время и объем оперативной памяти жестко ограничены, эти расходы оказываются серьезным фактором.
Если же вместо системных буферов используется отображение данных в системное адресное пространство (системы с открытой памятью можно считать вырожденным случаем такого отображения), то ситуация усложняется. Пользовательская задача должна иметь возможность узнать о физическом окончании записи, потому что только после этого буфер действительно свободен. Фактически, программа должна самостоятельно реализовать много-буферную схему или искать другие выходы.
Во-вторых, программа должна дождаться окончания операции, чтобы узнать, успешно ли она закончилась. Часть ошибок, например, попытку записи на устройство, физически не способное выполнить такую операцию, можно отловить еще во время предобработки, однако аппаратные проблемы могут быть обнаружены только на фазе исполнения запроса.
Многие системы, реализующие отложенную запись, при обнаружении аппаратной ошибки просто устанавливают флаг ошибки в блоке управления устройством. Программа предобработки, обнаружив этот флаг, отказывается исполнять следующий запрос. Таким образом, прикладная программа считает ошибочно завершившийся запрос успешно выполнившимся и обнаруживает ошибку лишь при одной из следующих попыток записи, что не совсем правильно.
Иногда эту проблему можно игнорировать. Например, если программа встречает одну ошибку при записи, то все исполнение программы считается неуспешным и на этом заканчивается. Однако во многих случаях, например, задачах управления промышленным или исследовательским оборудованием, программе необходимо знать результат завершения операции, поэтому простая отложенная запись оказывается совершенно неприемлемой.
Так или иначе, ОС, реализующая асинхронное исполнение запросов ввода-вывода, должна иметь средства сообщить пользовательской программе о физическом окончании операции и результате этой операции.

Синхронный и асинхронный ввод-вывод в RSX-11 и VMS
Например, в системах RSX-11 и VAX/VMS фирмы DEC для синхронизации используется флаг локального события (local event flag). Как говорилось в разд. Семафоры, флаг события в этих системах представляет собой аналог двоичных семафоров Дейкстры, но с ним также может быть ассоциирована процедура AST. Системный вызов ввода-вывода в этих ОС называется QIC (Queue Input/Output [Request] — установить в очередь запрос ввода-вывода) и имеет две формы: асинхронную QIO и синхронную QIOW (Queue Input/Output and Wait— установить запрос и ждать [завершения]). С точки зрения подсистемы ввода-вывода эти вызовы ничем не отличаются, просто при запросе QIO ожидание конца запроса выполняется пользовательской программой "вручную", а при QIOW выделение флага события и ожидание его установки делается системными процедурами пред- и постобработки.
В ряде систем реального времени, например, в OS-9 и RT-11, используются аналогичные механизмы.

Напротив, большинство современных ОС общего назначения не связываются с асинхронными вызовами и предоставляют прикладной программе чисто синхронный интерфейс, тем самым вынуждая ее ожидать конца операции.
Возможно, это объясняется идейным влиянием ОС Unix. Набор операций ввода-вывода, реализованных в этой ОС, стал общепризнанным стандартом де-факто и основой для нескольких официальных стандартов. Например, набор операций ввода-вывода в MS DOS является прямой копией Unix; кроме того, эти операции входят в стандарт ANSI на системные библиотеки языка С и стандарт POSIX.
Современные системы семейства Unix разрешают программисту выбирать между отложенной записью по принципу Fire And Forget (выстрелил и забыл) и полностью синхронной, используя вызов fcntl с соответствующим кодом операции.
Если все-таки нужен более детальный контроль над порядком обработки запросов, разработчикам предлагается самостоятельно имитировать асинхронный обмен, создавая для каждого асинхронно исполняемого запроса свою нить. Эта нить тем или иным способом сообщает основной нити о завершении операции. Для этого чаще всего используются штатные средства межпоточного взаимодействия — семафоры и др. Грамотное использование нитей позволяет создавать интерактивные приложения с очень высоким субъективным временем реакции, но за это приходится платить усложнением логики программы. В Windows NT/2000/XP существует средство оргащ, зации асинхронного ввода-вывода — так называемые порты завершения (операции) (completion ports). Однако это средство не поддерживается в Windows 95, поэтому большинство разработчиков избегают использования портов завершения.
Синхронная модель ввода-вывода проста в реализации и использовании и, как показал опыт систем семейства Unix и его идейных наследников, вполне адекватна большинству приложений общего назначения. Однако, как уже было показано, она не очень удобна (а иногда и просто непригодна) для задач реального времени. Следует также иметь в виду, что имитация асинхронного обмена при помощи нитей не всегда допустима: асинхронные запросы исполняются в том порядке, в котором они устанавливались в очередь, а порядок получения управления нитями в многозадачной среде не гарантирован. Поэтому, например, стандарт POSIX.4 требует поддержки наравне с нитями и средств асинхронного обмена с вызовом callback при завершении операции.


Дисковый кэш

Функции и принципы работы дискового кэша существенно отличаются от общих алгоритмов кэширования, обсуждавшихся в разд. Страничный обмен. Дело в том, что характер обращения к файлам обычно существенно отличается от обращений к областям кода и данных задачи. Например, компилятор С и макропроцессор ТЕХ рассматривают входные и выходные файлы как потоки данных. Входные файлы прочитываются строго последовательно и полностью, от начала до конца. Аналогично, выходные файлы полностью перезаписываются, и перезапись тоже происходит строго последовательно. Попытка выделить аналог рабочей области при таком характере обращений обречена на провал независимо от алгоритма, разве что рабочей областью будут считаться все входные и выходные файлы.
Тем не менее кэширование или, точнее, буферизация данных при работе с диском имеет смысл и во многих случаях может приводить к значительному повышению производительности системы. Если отсортировать механизмы повышения производительности в порядке их важности, мы получим следующий список.

  1. 1. Размещение в памяти структур файловой системы — каталогов, FAT пли таблицы инодов (эти понятия подробнее обсуждаются в главе И) и т. д. Это основной источник повышения производительности при использовании дисковых кэшей под MS/DR DOS.
  2. 2. Отложенная запись. Само по себе откладывание записи не повышает скорости обмена с диском, но позволяет более равномерно распределить по времени загрузку дискового контроллера.
  3. 3 Группировка запросов на запись. Система имеет пул буферов отложенной записи, который и называется дисковым кэшем. При поступлении запроса на запись, система выделяет буфер из этого пула и ставит его в очередь к драйверу. Если за время нахождения буфера в очереди в то же место на диске будет произведена еще одна запись, система может дописать данные в имеющийся буфер вместо установки в очередь второго запроса. Это значительно повышает скорость, если запись происходит массивами, не кратными размеру физического блока на диске.
  4. 4 Собственно кэширование. После того, как драйвер выполнил запрос, буфер не сразу используется повторно, поэтому какое-то время он содержит копию записанных или прочитанных данных. Если за это время произойдет обращение на чтение соответствующей области диска, система может отдать содержимое буфера вместо физического чтения.
  5. 5. Опережающее считывание. При последовательном обращении к данным чтение из какого-либо блока значительно повышает вероятность того, что следующий блок также будет считан. Теоретически опережающее чтение должно иметь тот же эффект, что и отложенная запись, т. е. обеспечивать более равномерную загрузку дискового канала и его работу параллельно с центральным процессором. На практике, однако, часто оказывается, что считанный с опережением блок оказывается никому не нужен, поэтому эффективность такого чтения заметно ниже, чем у отложенной записи.
  6. 6. Сортировка запросов по номеру блока на диске. По идее, такая сортировка должна приводить к уменьшению времени позиционирования головок чтения/записи (см. разд. Производительность жестких дисков). Кроме того, если очередь запросов будет отсортирована, это облегчит работу алгоритмам кэширования, которые производят поиск буферов по номеру блока.

Кэширование значительно повышает производительность дисковой подсистемы, но создает ряд проблем, причем некоторые из них довольно неприятного свойства.
Первая из проблем — та же, что и у отложенной записи. При использовании отложенной записи программа не знает, успешно ли завершилась физическая запись. При работе с дисками один из основных источников ошибок — физические ошибки диска. Однако многие современные файловые системы поддерживают так называемый hotfixing (горячую починку) — механизм, обеспечивающий динамическую замену "плохих" логических блоков на "хорошие", что в значительной мере компенсирует эту проблему.
Вторая проблема гораздо серьезнее и тоже свойственна всем механизмам отложенной записи: если в промежутке между запросом и физической записью произойдет сбой всей системы, то данные будут потеряны. Например, пользователь сохраняет отредактированный файл и. не дождавшись окончания физической записи, выключает питание — содержимое файла оказывается потеряно или повреждено. Другая ситуация, до боли знакомая всем пользователям DOS/Windows З.х/Windows 95: пользователь сохраняет файт и в это время система зависает — результат тот же. Аналогичного результата можно достичь, не вовремя достав дискету или другой удаляемый носите-ц, из привода (чтобы избежать этого, механика многих современных дисководов позволяет программно заблокировать носитель в приводе).
Очень забавно наблюдать, как пользователь, хотя бы раз имевший неприятный опыт общения с дисковым кэшем SMARTDRV, копирует данные с чужого компьютера на дискету. Перед тем, как извлечь ее из дисковода, он оглядывается на хозяина машины и с опаской спрашивает: "У тебя там никаких кэшей нет?". В эпоху MS DOS авторам доводилось наблюдать такое поведение у нескольких десятков людей.
Если откладывается запись не только пользовательских данных, но и модифицированных структур файловой системы, ситуация еще хуже: системный сбой может привести не только к потере данных, находившихся в кэше, но и к разрушению файловой системы, т. е. в худшем случае, к потере всех данных на диске.
Методы обеспечения целостности данных при системном сбое подробнее обсуждаются в разд. Устойчивость ФС к сбоям. Находившиеся в кэше данные при фатальном сбое гибнут всегда, но существуют способы избежать.повреждения системных структур данных на диске без отказа от использования отложенной записи.
Третья проблема, связанная с дисковым кэшем, — это выделение памяти под него. Уменьшение кэша приводит к снижению производительности дисковой подсистемы, увеличение же кэша отнимает память у пользовательских процессов. В системах с виртуальной памятью это может привести к увеличению дисковой активности за счет увеличения объема подкачки, что ведет к снижению как дисковой, так и общей производительности системы. Перед администратором системы встает нетривиальная задача: найти точку оптимума. Положение этой точки зависит от следующих параметров:

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

При этом зависимость количества страничных отказов от объема памяти, доступной приложениям, имеет существенно нелинейный вид. Это же утверждение справедливо для связи между размером дискового кэша и соответствующей экономией обращений к диску. Таким образом, задача подбора оптимального размера кэша — это задача нелинейной оптимизации. Самое неприятное, что ключевой исходный параметр — характер обращений к диску — не количественный, а качественный; точнее сказать, его можно измерить лишь при помощи очень большого числа независимых количественных параметров.
Во многих ситуациях невозможно теоретически оценить положение оптимальной точки, и единственным способом оказывается эксперимент: прогон типичной для данной машины смеси заданий при различных объемах кэша. При этом нужно иметь возможность различать дисковую активность, связанную с обращениями к файлам и со страничным обменом. Большинство современных ОС предоставляют для этой цели различные инструменты системного мониторинга. Чаше, однако, объем кэша выставляется на глаз, а к дополнительной настройке прибегают, только если производительность оказывается слишком низкой.
Возникает вполне естественное желание возложить подбор размера кэша на саму систему, т. е. менять размер кэша динамически в зависимости от рабочей нагрузки. Кроме упрощения работы администратора, такое решение имеет еще одно большое преимущество: система начинает "автомагически" подстраиваться под изменения нагрузки.
Но далеко не все так просто. Если объем памяти в системе превосходит потребности прикладных программ, то динамический дисковый кэш может формироваться по очень простому "остаточному" принципу — все, что не пригодилось приложениям, отдается под кэш. Однако оперативная память до сих пор относительно дорога и представляет собой дефицитный ресурс, поэтому наибольший практический интерес представляет ситуация, когда памяти не хватает даже приложениям, не говоря уже о кэше. Тем не менее и в этой ситуации кэш некоторого объема бывает нужен.
Разумной политикой была бы подстройка кэша в зависимости от количества страничных отказов: если число отказов становится слишком большим, система уменьшает кэш; если же число отказов мало, а идут интенсивные обращения к диску, система увеличивает кэш. Получается саморегулирующаяся система с отрицательной обратной связью. Однако, если вдуматься, то видно, что вместо одной произвольной переменной (объема статического кэша) мы вынуждены ввести как минимум три:

  • количество страничных отказов, которое считается слишком большим;
  • количество отказов, которое считается достаточно малым;
  • величину, на которую следует увеличить или уменьшить кэш в этих случаях.

На практике, часто также вводятся параметры, ограничивающие минимальный и максимальный размеры кэша.
Оптимальные значения этих переменных зависят практически от тех же самых параметров, что и объем статического кэша, но подбор значений экспериментальным путем оказывается значительно сложнее, потому что вместо одномерной нелинейной оптимизации мы вынуждены занимать трехмерной нелинейной оптимизацией.
Кроме того, читатель, знакомый с теорией управления, должен знать чт неудачный подбор параметров у системы с отрицательной обратной связь может приводить к колебательному процессу вместо саморегуляции, в дм куссиях USENET news приводились примеры развития таких колебаний динамическом кэше системы Windows NT при компиляции большого проекта в условиях недостатка памяти.
Вполне возможно, что низкая производительность Windows NT/2000/XP на машинах с небольшим количеством памяти объясняется вовсе не низким качеством реализации и даже не секретным сговором между фирмой Microsoft и производителями оперативной памяти, а просто плохо сбалансированным динамическим кэшем.


Спулинг

Гигабайттебе в спул.
Популярное ругательство

Термин спулинг (spooling) не имеет общепринятого русского аналога. В соответствии с программистским фольклором, слово это происходит от аббревиатуры Simultaneous Peripherial Operation Off-Line. Эту фразу трудно дословно перевести на русский язык; имеется в виду метод работы с внешними устройствами вывода (реже — ввода) в многозадачной ОС или многомашинной среде, при котором задачам создается иллюзия одновременного доступа к устройству. При этом, однако, задачи не получают к устройству прямого доступа, а работают в режиме offline (без прямого подключения). Выводимые данные накапливаются системой, а затем выводятся на устройство так, чтобы вывод различных задач не смешивался.
Видно, что этот метод работы отчасти напоминает простую отложенную запись, но основная задача здесь не только и не столько повышение производительности, сколько разделение доступа к медленному внешнему устройству.
Чаще всего спулинг применяется для работы с печатающими устройствами, а для промежуточного хранения данных используется диск. Многие почтовые системы применяют механизм, аналогичный спулингу: если получатель не готов принять письмо, или линия связи с получателем занята, либо вообще разорвана, предназначенное к отправке письмо помещается в очередь. Затем, когда соединение будет установлено, письмо отправляется.
Классический спулинг реализован в ОС семейства Unix. В этих ОС вывод задания на печать осуществляется командой lpr. Эта команда копирует предназначенные для печати данные в каталог /usr/spoo!/lp, возможно, пропуская их при этом через программу-фильтр. Каждая порция данных помешается в отдельный файл. Имена файлов генерируются так, чтобы имя каждого вновь созданного файла было "больше" предыдущего при сравнении ASCII-колов. За счет этого файлы образуют очередь.
Системный процесс-демон (daemon) ipd (или lpshed в Unix System V) периодически просматривает каталог. Если там что-то появилось, а печатающее устройство свободно, демон копирует появившийся файл на устройство. По окончании копирования он удаляет файл, тем или иным способом уведомляет пользователя об окончании операции (в системах семейства Unix чаще всего используется электронная почта) и вновь просматривает каталог. Если там по-прежнему что-то есть, демон выбирает первый по порядку запрос и также копирует его на устройство.
Тот же механизм используется почтовой системой Unix- программой sendmail, только вместо каталога /usr/spool/lp используется /usr/spool/mail.
Этот механизм очень прост, но имеет один специфический недостаток: демон не может непосредственно ожидать появления файлов в каталоге, как можно было бы ожидать установки семафора или другого флага синхронизации. Если бы демон непрерывно сканировал каталог, это создавало бы слишком большую и бесполезную нагрузку системы. Поэтому демон пробуждается через фиксированные интервалы времени; если за это время ничего в очереди не появилось, демон засыпает вновь. Такой подход также очень прост, но увеличивает время прохождения запросов: запрос начинает исполняться не сразу же после установки, а лишь после того, как демон в очередной раз проснется.
В OS/2 и Win32 спулинг организован отчасти похожим образом с той разницей, что установка запроса в очередь может происходить не только командой PRINT, но и простым копированием данных на псевдоустройство LPT[1-9|. В отличие от систем семейства Unix как программа PRINT, так и псевдоустройства портов активизируют процесс спулинга непосредственно при установке запроса. Графические драйверы печатающих устройств в этих системах также используют спул вместо прямого обращения к физическому порту.
Novell Netware предоставляет специальный механизм для организации спулинга — очереди запросов. Элементы очереди в этом случае также хранятся на диске, но прикладные программы вместо просмотра каталога могут пользоваться системными функциями GetNextMessage и PutMessage. Вызов GetNextMessage блокируется, если очередь пуста; таким образом, нет необходимости ожидать пробуждения демона или специальным образом активизировать его — демон сам пробуждается при появлении запроса. Любопытно, что почтовая система Mercury Mail для Novell Netware может использовать для промежуточного хранения почты как очередь запросов, так и выделенный каталог в зависимости от конфигурации.


Глава 11. Файловые системы

  • Файловые системы
    • Файлы сточки зрения пользователя
    • Монтирование файловых систем
    • Формат имен файлов
    • Операции над файлами
    • Тип файла
  • Простые файловые системы
  • "Сложные" файловые системы
  • Устойчивость ФС к сбоям
    • Устойчивость к сбоям питания
    • Восстановление ФС после сбоя
    • Файловые системы с регистрацией намерений
    • Устойчивость ФС к сбоям диска
  • Драйверы файловых систем


Файловые системы

Одним из первых внешних устройств после клавиатуры и телевизора, которые перечисляются в любом руководстве по персональным компьютерам для начинающих, является магнитный диск. Вообще говоря, вместо магнитного диска в наше время может использоваться и какая-то другая энергонезависимая память, например, флэш или файловьш сервер, но наличие такой памяти является очень важным. Ведь вы же не будете набирать вашу программу каждый раз при новом включении компьютера. Правда, на 16-разрядных машинах такое еще было возможным; автору доводилось слышать легенды о людях, которые могли по памяти набрать на консольном мониторе PDP-11 тетрис. Впрочем, для современных прикладных программ, размеры загрузочных модулей которых измеряются сотнями мегабайтен, это невозможно.
Понятно также, что недостаточно иметь возможность просто запомнить программу и данные. Ведь вы можете работать с несколькими программами, или над несколькими проектами одновременно. Ясно, что записывать на бумажке, в какое место вашей энергонезависимой памяти вы что-то сохранили, по меньшей мере неудобно. Поэтому естественно желание создать специализированную программу, которая будет как-то структурировать сохраненные данные. Именно эту работу по структурированию пользовательских данных берет на себя модуль ОС, называемый файловым менеджером. Дисковые операционные системы (ДОС) состоят по сути только из файлового менеджера и загрузчика бинарных модулей.
Понятие файла вводится в самом начале любого курса компьютерного ликбеза, но мало в каком курсе дается внятное определение этого понятия. Слово файл (file) дословно переводится с английского как папка или подшивка, но такой перевод почти не добавляет ясности. Одно из наилучших определений, известных автору, звучит так: "Файл — это совокупность данных, доступ к которой осуществляется по ее имени".
файл, таким образом, противопоставляется другим объектам, доступ к которым осуществляется по их адресу, например, записям внутри файла или блокам на диске.

Примечание
ОС семейства Unix трактуют понятие файла более широко — там файлом называется любой объект, имеющий имя в файловой системе. Однако файлы, не являющиеся совокупностями данных (каталоги, внешние устройства, псевдоустройства, именованные программные каналы, семафоры Xenix), часто называют не простыми файлами, а "специальными".

Из разд. Запоминающие устройства прямого доступа нам известно, что магнитный диск или другое устройство памяти (кроме, пожалуй, файлового сервера) чаще всего организует доступ к данным не по их именам, а все-таки по адресам, например, по номеру сектора, дорожки и поверхности диска. Поэтому, если система хочет предоставлять доступ по именам, она должна хранить таблицу преобразования имен в адреса — директорию (directory), или, как чаще говорят по-русски, каталог. В каталоге хранится имя файла и другая информация о файле, такая, как его размер и местоположение на диске. Как правило, хранят также дату создания файла, дату его последней модификации, а в многопользовательских системах — идентификатор хозяина этого файла и права доступа к нему для других пользователей. Во многих файловых системах эта информация хранится не в самом каталоге, а в специальной структуре данных иноде, метафайле и т. д. В этом случае запись в каталоге содержит только имя и указатель на управляющую структуру файла.
Большинство современных операционных систем позволяет делать вложенные каталоги — файлы, которые сами являются каталогами. В таких системах файл задается полным или путевым именем (path name), состоящим из цепочки имен вложенных каталогов и имени файла в последней из них.
Совокупность каталогов и системных структур данных, отслеживающих размещение файлов на диске и свободное дисковое пространство, называется файловой системой (ФС). Иногда на диске размещается только одна файловая система. Современные ОС часто позволяют размещать на одном физическом диске несколько файловых систем, выделяя каждой из них фиксированную часть диска. Такие части диска называются разделами (partition) или слайсами (slice). Обычно разбиение диска на части производится на уровне драйвера диска, поэтому общее название частей — логические диски. С другой стороны, ряд файловых систем может занимать несколько дисков.


Файлы с точки зрения пользователя

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


Монтирование файловых систем

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

  • Проверку типа монтируемой ФС.
  • Проверку целостности ФС.
  • Считывание системных структур данных и инициализацию соответствующего модуля файлового менеджера (драйвера файловой системы).
  • В некоторых случаях — модификацию ФС с тем, чтобы указать, что она уже смонтирована. При этом устанавливается так называемый флаг загрязнения (dirty flag). Смысл этой операции будет объяснен и разд. Устойчивость ФС к сбоям.
  • Включение новой файловой системы в общее пространство имен (рис. 11.1). В различных системах это делается различными способами.

Многие пользователи MS/DR DOS никогда не сталкивались с понятием монтирования. Дело в том, что эта система (как и многие другие ДОС, например RT-11) выполняет упрощенную процедуру монтирования при каждом обращении к файлу. Упрощения состоят в пропуске шагов 1 и 2 и отсутствии шага 4 (ФС MS/DR DOS устойчива к сбоям).
ДОС, как правило, помещают в пространство имен все доступные блочные устройства, не выполняя полной процедуры монтирования. Если какое-то из этих устройств не содержит ФС известного типа, то система будет возмущаться при обращениях к такому устройству, но не удалит его из списка доступных ФС. А иногда даже не будет возмущаться — попробуйте поставить в дисковод машины под управлением MS/DR DOS-дискету, не содержащую файловой системы (например, созданную программой tar) и в командной строке набрать DIR А:. Скорее всего, вы увидите несколько экранов мусора, но ни одного сообщения об ошибке!

Рис. 11.1. Пространство имен ОС с несколькими ФС

Если мы монтируем ФС, размещенную на удаленной машине (файловом сервере), то шаги 1 и 2 заменяются на установление соединения этим сервером. В системах семейства СР/М при работе с файловыми серверами Novell Netware монтирование серверных файловых систем производится командой MAP, а с файловыми серверами, поддерживающими протокол SMB, — командой NET USE.
Обычно имя файла в подмонтированной файловой системе имеет вид 'ИМЯ_ФС:\имена\каталогов\имя.файла'. При этом вместо разделителей ':' и '/' могут использоваться другие символы.

Имена файловых систем в RT-11, RSX-11, VMS
В RT-11, RSX-11 и VMS в качестве имени файловой системы используется имя физического устройства, на котором размещена ФС. Если применяется DECNet, перед именем устройства можно поместить имя узла сети, на котором это устройство находится. Полное имя файла в VMS выглядит так: DUAO-lUSERS.FAT_BROTHER.WORKJtest.exe. При этом DUAO означает дисковое устройство 0, присоединенное к дисковому контроллеру A: Disk Unit д [device #] О, [USERS.FAT_BROTHER.WORK] означает каталог WORK в каталп FAT_BROTHER в каталоге USERS.

Имена файловых систем в ОС семейства СР/М
В системах семейств СР/М имена файловых систем обозначаются буквами ла тинского алфавита, а сами файловые системы часто почему-то называются "драйвами". При некотором желании можно использовать в качестве имен фг также символы '[' и ']'. Устройства А: и В: — это всегда приводы гибких дисков устройство С: — обычно первый жесткий диск, или первый раздел на первом жестком диске.
Автора всегда интересовал вопрос: "Что будет делать пользователь, когда у него кончатся доступные буквы алфавита?" При использовании только локальных дисков такая ситуация кажется маловероятной, но при подключении к нескольким файловым серверам количество используемых файловых систем резко возрастает...
В OS/2 и Windows for Workgroups эта проблема решена использованием так называемых UNC-имен, задающих имя файла в виде \\NODE\SHARE\ PATH\FILE.NAM (рис. 11.2), где NODE — имя сетевого узла, SHARE — имя разделяемого ресурса на этом узле (это может быть не только разделяемый каталог, но и принтер, а в OS/2 также и модем), a PATH\FILE.NAM — путь к файлу относительно разделяемого каталога. К сожалению, далеко не все старые (и даже многие не очень уж старые) программы понимают такие имена. Например, даже стандартный командный процессор системы Windows NT не может исполнить команду cd \\NODE\SHARE\DIR (проверялось на NT 4.0 sp4-6, 2000 sp 1 и ХР).

Рис. 11.2. UNC-имена

Структура пространства имен в Unix
В операционных системах семейства Unix смонтированные ФС выглядят как каталоги единого дерева (строго говоря, структура каталогов в UNIX не обязана являться деревом; но об этом см. разд. Cложные файловые системы), Это дерево начинается с корневого каталога, выделенной ФС, называемой корневой (root). Администратор системы может подмонтировать новую ФС к любому каталогу, находящемуся на любом уровне дерева (рис. 11.3). Такой каталог после этого называют точкой монтирования, но это выражение отражает только текущее состояние каталога. После того как мы размонтируем ФС, мы сможем использовать этот каталог как обычный, и наоборот, мы можем сделать точкой монтировки любой каталог.

Рис. 11.3. Структура пространства имен в Unix

Такой подход имеет неочевидное, на первый взгляд, но серьезное преимущество перед раздельными пространствами имен для разных физических файловых систем. Преимущество состоит в том, что пространство имен оказывается не связанным с физическим размещением файлов. Следовательно, администратор может поддерживать неизменную структуру дерева каталогов, перемещая при этом отдельные ветви по дискам ради более эффективного использования дискового пространства или даже просто ради удобства администрирования.
По традиции все Unix-системы имеют примерно одинаковую структуру дерева каталогов: системные утилиты находятся в каталоге /bin, системные библиотеки — в каталоге /lib, конфигурационные файлы — в каталоге /etc и т. д. Например, база данных об именах пользователей всегда находится в файле /etc/passwd.
Точки монтирования реализованы и в некоторых ОС, не относящихся к семейству Unix. Так, свободно распространяемый продукт TVFS (Toronto Virtual File System) для OS/2 позволяет объединять несколько различных файловых систем (как локальных, так и сетевых) в один логический диск, монтируя реальные ФС в каталоги виртуальной. Аналогичная функциональность была реализована в Windows 2000, но почему-то только в поставке Advanced Server.


Формат имен файлов

В различных ФС допустимое имя файла может иметь различную длину ц нем могут использоваться различные наборы символов. Так, в RT-H и RSX-Ц имена файлов состоят из символов кодировки RADIX-50 и имеют длину 9 символов: 6 символов — собственно имя, а 3 — расширение. При этом имя имеет вид "ХХХХХХ.ХХХ", но символ '.' не является частью имени — это просто знак препинания. Предполагается, что расширение должно соответствовать типу данных, хранящихся в файле: SAV будет именем абсолютного загружаемого модуля, FOR — программы на Фортране, CRH - "файлом информации о системном крахе", как было написано в одном переволе руководства (попросту говоря, это посмертная выдача ОС, по которой можно попытаться понять причину аварии).
В СР/М и ее потомках MS DOS-DR DOS, а также в VMS имена файлов хранятся в 8-битной ASCII-кодировке, но почему-то разрешено использование только букв верхнего регистра, цифр и некоторых печатаемых символов. При этом в системах линии СР/М имя файла имеет 8 символов плюс 3 символа расширения, а в VMS как имя, так и расширение могут содержать более 32 символов. Все перечисленные системы используют нечувствительный к регистру букв поиск в каталогах: имена file.с, File.С и FILE.С считаются одним и тем же именем.

Ограничения на формат имени в MS DOS
Любопытно, что MS/DR DOS при поиске в каталоге переводят в верхний регистр имя, заданное пользователем, но оставляют без изменений имя, считанное из каталога. Строго говоря, это ошибка: если мы создадим имя файла, содержащее буквы нижнего регистра, то ни одна программа не сможет открыть или переименовать такой файл.
Автору довелось столкнуться с такой проблемой при попытке прочитать дискету, записанную ОС ТС (Экспериментальная UNIX-подобная ОС для Паскаль-машины N9000). Проблему удалось решить только при помощи шестнадца-теричного дискового редактора прямым редактированием имен в каталогах. Возможно, существует и более элегантное решение, но автору не удалось его найти.
Использовать конструкцию *.* бесполезно, потому что, в действительности, операции над файлами, заданными таким образом, состоят из двух операций: FindFirst/FindNext, которая возвращает [следующее] имя файла, соответствующее шаблону, и Open. FindFirst/FindNext возвращает недопустимое имя файла и Open не может использовать его. Программа CHKDSK не возражает против имен файлов в нижнем регистре. Строго говоря, это тоже ошибка. Все остальные способы, так или иначе, сводятся к прямой (в обход ДОС) модификации ФС.
Кроме того, любопытных эффектов можно достичь, попытавшись создать файл с именем, содержащим русские буквы.
Наибольшим либерализмом в смысле имен отличаются ОС семейства Unix, в которых имя файла может состоять из любых символов кодировки ASCII, кроме символов '\000' и V, например, из восьми символов перевода каретки. При этом '\000' является ограничителем имени, а V — разделителем между именем каталога и именем файла. Никакого разделения на имя и расширение нет, и хотя имена файлов с программой на языке С заканчиваются ".с", а объектных модулей — ".о", точка здесь является частью имени. Вы можете создать файл с именем "gcc-2.5.8.tar.gz". В UNIX SVR3 длина имени файла ограничена 14 символами, а в BSD UNIX, Linux и SVR4— только длиной блока на диске, т. е. 512 байтами или более. При этом нулевой символ считается концом имени в каталоге.

Возможность использовать в именах неалфавитные символы типа перевода каретки или ASCII EOT (End Of Transmission) кажется опасным излишеством. На самом деле:

  • это не излишество а, скорее, упрощение — из процедур, работающих с именами, удалена проверка символа на "допустимость";
  • оно не столь уж опасно: такой файл всегда можно переименовать.

В некоторых случаях процесс набора имени файла в командной строке превращается в нетривиальное упражнение, потому что shell (командный процессор) рассматривает многие неалфавитные символы как команды. Но надо отметить, что, правильно используя кавычки и символ '\', пользователь может передать команде аргумент, содержащий любые символы ASCII, кроме \000".

Длинные имена файлов в ОС семейства СР/М
В последнее время в ОС стало модным поддерживать длинные имена файлов. Отчасти это, возможно, связано с тем, что производители ПО для персональных компьютеров осознали, что системы семейства Unix являются потенциально опасными конкурентами, а длинные имена файлов традиционно считаются одним из преимуществ этого семейства.
Например, OS/2, использующая файловую систему HPFS (High Performance File System — высокопроизводительная файловая система), поддерживает имена файлов длиной до 256 символов, содержащие печатаемые символы и пробелы. Точка считается частью имени, как и в UNIX, и можно создавать имена, содержащие несколько точек. Аналогичную структуру имеют имена в NTFS, используемой в Windows NT/2000/XP, VFAT (реализация файловой системы FAT16, используемая в Windows 95/98/ME) и FAT32.

Описанные ОС при поиске файла приводят к одному регистру все алфавитные символы в имени. С одной стороны, это означает дополнительное Удобство для пользователя — при наборе имени не нужно заботиться о регистре букв, с другой — пользователь не может создать в одном каталоге файлы "text.txt" и "Text.txt". Из-за этого, например, нельзя использовать принятое в UNIX соглашение о том, что файл на языке С имеет расширение "с", а на языке C++ — "С".
Главная же проблема, возникающая при работе с нечувствительными « п гистру именами, — это преобразование регистра в именах, использующи национальные алфавиты: русский, греческий, японскую слоговую азбуку т. д. Файловая система, поддерживающая такие имена, должна учитыват языковые настройки ОС, что создает много сложностей, в том числе и при считывании удаляемых носителей, записанных в одной стране, где-нибудь за границей. В системах семейства Win32 эта проблема решена за счет хранения имен в формате Unicode.
Некоторые ОС, например, RSX-11 и VMS, поддерживают также номер версии файла. В каталоге может существовать несколько версий файла с одним именем; если номер версии при открытии файла не задается, то открывается последняя версия.
Версии файла очень удобны при разработке любых объектов, от программ или печатных плат до книг: если вам не понравились изменения, внесенные вами в последнюю версию, вы всегда можете откатиться назад. Ныне функцию хранения предыдущих версий изменяемых файлов и управляемого отката к ним реализуют специальные приложения, системы управления версиями (version control system) (RCS, CVS и др.).


Операции над файлами

Большинство современных ОС рассматривают файл как неструктурированную последовательность байтов переменной длины. В стандарте POSIX над файлом определены следующие операции.

  • int open(char * fname, int flags, mode_t mode)
    Эта операция "открывает" файл, устанавливая соединение между программой и файлом. При этом программа получает "ручку" или дескриптор файла — целое число, идентифицирующее данное соединение. Фактически это индекс в системной таблице открытых файлов для данной задачи. Все остальные операции используют этот индекс для ссылки на файл. Параметр char * fname задает имя файла, int flags — это битовая маска, определяющая режим открытия файла. Файл может быть открыт только для чтения, только для записи и для чтения и записи; кроме того, можно открывать существующий файл, а можно пытаться создать новым файл нулевой длины. Необязательный третий параметр mode используется только при создании файла и задает атрибуты этого файла.
  • off_t lseek(int handle, off_t offset., int whence)
    Эта операция перемещает указатель чтения/записи в файле. Параметр offset задает количество байтов, на которое нужно сместить указатель, а параметр whence — начало отсчета смещения. Предполагается, что смещение можно отсчитывать от начала файла (SEEK_SET), от его кониа (SEEK_END) и от текущего положения указателя (SEEK_CUR). Операция возвращает положение указателя, отсчитываемое от начала файла. Таким образом, вызов iseek (handle, о, SEEK_cuR) возвратит текущее положение указателя, не передвигая его.
  • int read(int handle, char * where, size__t howjnuch)
    Операция чтения из файла. Указатель where задает буфер, куда нужно поместить прочитанные данные; третий параметр указывает, сколько данных надо считать. Система считывает требуемое число байтов из файла, начиная с указателя чтения/записи в этом файле, и перемещает указатель к концу считанной последовательности. Если файл кончился раньше, считывается столько данных, сколько оставалось до его конца. Операция возвращает количество считанных байтов. Если файл открывался только для записи, вызов read возвратит ошибку.
  • int write(int handle, char * what, size__t how_much)
    Операция записи в файл. Указатель what задает начало буфера данных; третий параметр указывает, сколько данных надо записать. Система записывает требуемое число байтов в файл, начиная с указателя чтения/записи в этом файле, заменяя хранившиеся в этом месте данные, и перемещает указатель к концу записанного блока. Если файл кончился раньше, его длина увеличивается. Операция возвращает количество записанных байтов. Если файл открывался только для чтения, вызов write возвратит ошибку.
  • int ioctl ( int handle, int cmd, .. . .)
    int fcntl(int handle, int cmd, . . . )
    Дополнительные операции над файлом. Первоначально, по-видимому, предполагалось, что ioctl — это операции над самим файлом, a fcntl — это операции над дескриптором открытого файла, но потом историческое развитие несколько перемешало функции этих системных вызовов. Стандарт POS1X определяет некоторые операции как над дескриптором, например дублирование (в результате этой операции мы получаем два дескриптора, связанных с одним и тем же файлом), так и над самим файлом, например, операцию truncate — обрезать файл до заданной длины. В большинстве версий Unix операцию truncate можно использовать и для вырезания данных из середины файла. При считывании данных из такой вырезанной области считываются нули, а сама эта область не занимает физического места на диске.

Важной операцией является блокировка участков файла. Стандарт POSIX предлагает для этой цели библиотечную функцию, но в системах семейства Unix эта функция реализована через вызов fcntl.
Большинство реализаций стандарта POSIX предлагает и свои дополнительные операции. Так, в Unix SVR4 этими операциями можно устанавливать
синхронную или отложенную запись (Подробнее понятие отложены записи обсуждается в разд. Асинхронная модель ввода-вывода с точки зрения приложений) и т. д.

  • caddr t mmap(cadclr t addr, size_t len, int prot, int flags, int handle, off_t
    Отображение участка файла в виртуальное адресное пространство процесса. Параметр prot задает права доступа к отображенному участку: на чтение, запись и исполнение. Отображение может происходить на заданный виртуальный адрес, или же система может выбирать адрес для отображения сама.
    Еще две операции выполняются уже не над файлом, а над его именем: это операции переименования и удаления файла. В некоторых системах, например в системах семейства Unix, файл может иметь несколько имен, и существует только системный вызов для удаления имени. Файл удаляется при удалении последнего имени.

Видно, что набор операций над файлом в этом стандарте очень похож на набор операций над внешним устройством. И то, и другое рассматривается как неструктурированный поток байтов. Для полноты картины следует сказать, что основное средство межпроцессной коммуникации в системах семейства Unix (труба) также представляет собой неструктурированный поток данных. Идея о том, что большинство актов передачи данных может быть сведено к байтовому потоку, довольно стара, но Unix была одной из первых систем, где эта идея была приближена к логическому завершению.
Примерно та же модель работы с файлами принята в СР/М, а набор файловых системных' вызовов MS DOS фактически скопирован с вызовов Unix v7. В свою очередь, OS/2 и Windows NT/2000/XP унаследовали принципы работы с файлами непосредственно от MS DOS.
В системах, не имеющих Unix в родословной, может использоваться несколько иная трактовка понятия файла. Чаще всего файл трактуется как набор записей (рис. 11.4). Обычно система поддерживает записи как постоянной длины, так и переменной. Например, текстовый файл интерпретируется как файл с записями переменной длины, а каждой строке текста соответствует одна запись. Такова модель работы с файлами в VMS и в ОС линии OS/360-MVS-z/OS фирмы IBM.
Практика систем с неструктурированными файлами показала, что, хотя структурированные файлы часто бывают удобны для программиста, необязательно встраивать поддержку записей в ядро системы. Это вполне можно сделать и на уровне библиотек. К тому же структурированные файлы сами по себе не решают серьезной проблемы, полностью осознанной лишь в 80-е годы при разработке новых моделей взаимодействия человека с компьютером.

Рис. 11.4. Неструктурированный файл и файлы как наборы записей


Тип файла

Легко понять, что структурированные файлы предоставляют системе и программисту информацию о структуре хранящихся данных, но не дают никаких сведений о форме представления и смысле этих данных.
Например, с точки зрения системы исходный текст программы на языке С
и документ в формате LATEX совершенно идентичны: и то, и другое представляет собой текстовый файл (или, если угодно, файл с записями переменной длины). Однако, если мы попытаемся подать наш документ на вход С-компилятора, мы получим множество синтаксических ошибок и никакого Полезного результата.
Этот пример показывает, что во многих случаях желательно связать с файлом — неважно, структурированный ли это файл или байтовый поток — какую-то метаинформацию: в каком формате хранятся данные, какие операции над ними допустимы, а иногда и сведения о том, кому и зачем эти данные нужны.
По-видимому, наиболее общим решением этой проблемы был бы объектно-ориентированный подход, в котором файл данных рассматривается как объект, а допустимые операции — как методы этого объекта. Ни в одной из известных автору ОС эта идея в полной мере не реализована, но пользовательские интерфейсы многих современных ОС предоставляют возможность ассоциировать определенные действия с файлами различных типов.
Так, например Explorer — пользовательская оболочка Windows 95 и Windows NT 4.0 — позволяет связать ту или иную программу с файлами, имеющими определенное расширение, например, программу MS Word с файлами имеющими расширение DOC. Когда пользователь нажимает левую кнопку мыши на значке, представляющем такой файл, то автоматически запускается MS Word. Эти же ассоциации доступны и из командной строки — можно напечатать start Доклад. DOC, и опять-таки запустится MS Word.
Такое связывание очень просто в реализации и сделано не только в Explorer, но и в простых текстовых оболочках вроде Norton Commander. От ОС при этом требуется только дать возможность каким-то образом различать типы файлов.
Первые попытки ассоциировать с файлом признак типа были сделаны еще в 60-е годы. При этом идентификатор типа добавлялся к имени файла в виде короткой, но мнемонической последовательности символов — расширения (extension). В большинстве современных ОС расширение отделяется от имени символом ".", но проследить истоки этой традиции автору не удалось. При этом, например, файлы на языке С будут иметь расширение "с", на
C++ — "С", а документы в формате LATEX — "tex".
В ОС семейства Unix имя файла может содержать несколько символов ".", и, таким образом, файл может иметь несколько каскадированных расширений. Например, файл "main.С" — это программа на языке C++; "main.C.gz" — это программа на языке C++, упакованная архиватором GNU Zip с целью сэкономить место; "main.C.gz.crypt" — это программа, которую упаковали и потом зашифровали, чтобы никто посторонний не смог ее прочитать; наконец, "main.C.gz.crypt.uue" — это упакованная и зашифрованная программа, преобразованная в последовательность печатаемых символов кода ASCII, например, для пересылки по электронной почте.
В принципе, расширения являются вполне приемлемым и во многих отношениях даже очень удобным способом идентификации типа файла. Одно из удобств состоит в том, что для использования этого метода не нужно никаких или почти никаких усилий со стороны ОС: просто программы договариваются интерпретировать имя файла определенным образом.

Пример 11.1. Командная строка компилятора

ее main.С c-code.c asm-code.s obj-code.o\ libraryl.a Iibrary2.so -o program

Например, стандартный драйвер компилятора в системах семейства Unix — программа со — определяет тип файла именно по расширению. Командная строка, приведенная в примере 11.1, будет интерпретироваться следующим образом.

  • main.С — текст на языке C++. Его нужно обработать препроцессором и откомпилировать компилятором C++, а затем передать то, что получится, редактору связей. Большинство компиляторов в Unix генерируют код на ассемблере, т. е. вывод компилятора еще нужно пропустить через ассемблер.
  • c-code.c — текст на языке С. Он обрабатывается так же, как и программа на С ++, только вместо компилятора C++ используется компилятор С.
  • asm-code.s — программа на языке ассемблера. Ее нужно обработать ассемблером и получить объектный модуль.
  • obj-code.o — объектный модуль, который непосредственно можно передавать редактору связей.
  • library1.a — объектная библиотека, которую нужно использовать для разрешения внешних ссылок наравне со стандартными библиотеками.
  • library2.so — разделяемая библиотека, которую надо связать с создаваемым динамическим модулем.

Многие ОС, разработанные в 70-е годы, такие как RT-11, RSX-11, VAX/VMS, CP/M, навязывают программисту разделение имени на собственно имя и расширение, интерпретируя точку в имени файла как знак препинания. В таких системах имя может содержать только одну точку и соответственно иметь только одно расширение. Напротив, в ОС нового поколения — OS/2, Windows NT и даже в Windows 95 — реализована поддержка имен файлов свободного формата, которые могут иметь несколько каскадированных расширений, как и в Unix.
Однако никакие средства операционной системы не могут навязать прикладным программам правил выбора расширения для файлов данных. Это приводит к неприятным коллизиям. Например, почти все текстовые процессоры от Лексикона до Word 2000 включительно используют расширение Файла .doc (сокращение от document), хотя форматы файлов у различных процессоров и даже у разных версий одного процессора сильно различаются.
Другая проблема связана с исполняемыми загрузочными модулями. Обычно система использует определенное расширение для исполняемых файлов.
Так, VMS и системы семейства СР/М используют расширение .ехе: сокращение от executable (исполняемый). Однако по мере развития системы формат загрузочного модуля может изменяться. Так, например, OS/2 v3.0 поддерживает по крайней мере шесть различных форматов загрузочных модулей.

  • 16-разрядные сегментированные загрузочные модули: формат OS/2 х
  • 32-разрядные загрузочные модули, использующие "плоскую" (flat) модец, памяти: формат OS/2 2.x (LE — Linear Executable).
  • 32-разрядные модули нового формата, использующие упаковку кода данных: формат OS/2 3.x (LX — Linear executable extended).
  • ехе- и corn-модули DOS.
  • Загрузочные модули Win 16 (NE).
  • Загрузочные модули Win32s (РЕ — Portable Executable).

Для исполнения последних трех типов программ OS/2 создает виртуальную машину, работающую в режиме совместимости с 8086. Эта задача запускает копию ядра DOS и, если это необходимо, копию MS Windows, которые уже выполняют загрузку программы. Загрузочные модули всех трех "родных" форматов загружаются системой непосредственно. Так или иначе, загрузчик должен уметь правильно распознавать все форматы. При этом он не может использовать расширение файла: файлы всех перечисленных форматов имеют одинаковое расширение ЕХЕ.
Похожая ситуация имеет место в системах семейства Unix, где бинарные загрузочные модули и командные файлы вообще не имеют расширения. При этом большинство современных версий системы также поддерживает несколько различных исторически сложившихся форматов загрузочного модуля.
Разработчики Unix столкнулись с этой проблемой еще в 70-е годы. В качестве решения они предложили использовать магические числа (magic number) или сигнатуры (signature— подпись) — соглашение о том, что файлы определенного формата содержат в начале определенный байт или последовательность байтов. Первоначально это были численные коды; файл /etc/magic содержал коды, соответствующие известным типам файлов. Позднее в качестве магических чисел стали использоваться длинные текстовые строки. Так, например, изображения в формате CompuServe GIF 87а должны начинаться с символов GIF87a.
Легко понять, что магические числа ничуть не лучше расширении, а во многих отношениях даже хуже. Например, пользователь, просмотрев содержимое каталога, не может сразу узнать типы содержащихся в нем файлов. Еще хуже ситуация, когда расширение файла не соответствует его реальному типу. Это будет вводить в заблуждение не только пользователя, но и некоторые программы, полагающиеся при определении формата на расширение вместо магического числа.
С длинными мнемоническими текстовыми строками связана еще одна забавная проблема, которая может иметь неприятные последствия. Например-текстовый файл следующего содержания:
GIF87a — это очень плохой формат хранения изображений. Он будет воспринят некоторыми программами как изображение в формате CompuServe (GIF 87а, каковым он, безусловно, не является.
Оригинальное развитие идея магических чисел получила в современных системах семейства Unix, где сигнатура вида #!/bin/sh означает, что данный файл представляет собой интерпретируемую программу, интерпретатор которой хранится в файле /bin/sh (в данном случае это стандартный командный процессор).
Пытаясь как-то решить проблему идентификации типа файла, разработчики Macintoch отказались как от расширений, так и от магических чисел. В MacOS каждый файл состоит из двух частей или ветвей (forks): ветви данных (data fork) и ветви ресурсов (resource fork). Кроме идентификатора типа файла, ветвь ресурсов хранит информацию о:

  • значке, связанном с этим файлом;
  • расположении значка в открытой папке (folder);
  • программе, которую нужно запустить при "открытии" этого файла.

Еще дальше в этом же направлении пошли разработчики системы OS/2. В этой системе с каждым файлом связан набор расширенных атрибутов (extended attributes). Атрибуты имеют вид "ИМЯ:Значение". При этом значение может быть как текстовой строкой, так и блоком двоичных данных произвольного формата и размера. Некоторые расширенные атрибуты используются оболочкой Workplace Shell (WPS) как эквивалент ветви ресурсов в MacOS: для идентификации типа файла, связанного с ним значка и размещения этого значка в открытом окне. Тип файла идентифицируется текстовой строкой. Например, программа на языке С идентифицируется строкой С code. Это резко уменьшает вероятность конфликта имен типов — ситуацию, довольно часто возникающую при использовании расширений или магических чисел.
Другие атрибуты могут применяться для других целей. Например, LAN Server — файловый сервер фирмы IBM — использует расширенные атрибуты для хранения информации о владельце файла и правах доступа к нему. Некоторые текстовые редакторы применяют расширенные атрибуты для хранения положения курсора при завершении последней сессии редактирования, так что пользователь всегда попадает в то место, где он остановился в прошлый раз.
Кроме того, расширенные атрибуты могут использоваться и для хранения сведений о назначении файла. В OS/2 существуют предопределенные расширенные атрибуты с именами subject (тема), comment (комментарии) и Key phrases (ключевые фразы), которые могут, например, использоваться для поиска документов, относящихся к заданной теме. К сожалению, такой поиск возможен, только если создатель документа позаботился о присвое нии этим атрибутам правильных значений.


Простые файловые системы

Наиболее простой файловой системой можно считать структуру, создаваемую архиватором системы UNIX — программой tar (Tape ARchive — архив на [магнитной] ленте). Этот архиватор просто пишет файлы один за другим помещая в начале каждого файла заголовок с его именем и длиной (рис. 11.5). Аналогичную структуру имеют файлы, создаваемые архиваторами типа arj; в отличие от них, tar не упаковывает файлы.

Рис. 11.5. Структура архива tar

Для поиска какого-то определенного файла вы должны прочитать первый заголовок; если это не тот файл, то отмотать ленту до его конца, прочитать новый заголовок и т. д. Это не очень удобно, если мы часто обращаемся к отдельным файлам, особенно учитывая то, что цикл перемотка — считывание — перемотка у большинства лентопротяжных устройств происходит намного медленнее, чем простая перемотка. Изменение же длины файла в середине архива или его стирание вообще превращается в целую эпопею. Поэтому tar используется для того чтобы собрать файлы с диска в некую единую сущность, например, для передачи по сети или для резервного копирования, а для работы файлы обычно распаковываются на диск или другое устройство с произвольным доступом.
Для того чтобы не заниматься при каждом новом поиске просмотром всего устройства, удобнее всего разместить каталог в определенном месте, например в начале ленты. Наиболее простую, из знакомых автору, файловую систему такого типа имеет ОС RT-11. Это единственная известная автору файловая система, которая с одинаковым успехом применялась как на лентах, так и на устройствах с произвольным доступом. Все более сложные ФС, обсуждаемые далее, используются только на устройствах произвольного доступа. В наше время наиболее распространенный тип запоминающего устройства произвольного доступа — это магнитный или оптический диск, поэтому далее в тексте мы будем называть такие устройства просто дисками — для краткости, хотя такое сокращение и не вполне корректно.
В этой ФС, как и во всех обсуждаемых далее, место на диске или ленте выделяется блоками. Размер блока, как правило, совпадает с аппаратным размером сектора (512 байт у большинства дисковых устройств), однако многие фС могут использовать логические блоки, состоящие из нескольких секторов (так называемые кластеры).
Использование блоков и кластеров вместо адресации с точностью до байта обусловлено двумя причинами. Во-первых, у большинства устройств произвольного доступа доступ произволен лишь с точностью до сектора, т. е. нельзя произвольно считать или записать любой байт — нужно считывать или записывать весь сектор целиком. Именно поэтому в системах семейства Unix такие устройства называются блочными (block-oriented).
Во-вторых, использование крупных адресуемых единиц позволяет резко увеличить адресуемое пространство. Так, используя 16-битный указатель, с точностью до байта можно адресовать всего 64 Кбайт, но если в качестве единицы адресации взять 512-байтовый блок, то объем адресуемых данных сможет достичь 32 Мбайт; если же использовать кластер размером 32 Кбайт, то можно работать с данными объемом до 2 Гбайт. Аналогично, 32-битовый указатель позволяет адресовать с точностью до байта 4 Гбайт данных, т. е. меньше, чем типичный современный жесткий диск; но если перейти к адресации по блокам, то адресное пространство вырастет до 2 Тбайт, что уже вполне приемлемо для большинства современных запоминающих устройств.
Таким образом, адресация блоками и кластерами позволяет использовать в системных структурах данных короткие указатели, что приводит к уменьшению объема этих структур и к снижению накладных расходов. Под накладными расходами в данном случае подразумевается не только освобождение дискового пространства, но и ускорение доступа: структуры меньшего размера быстрее считываются, в них быстрее производится поиск и т. д. Однако увеличение объема кластера имеет оборотную сторону — оно приводит к внутренней фрагментации (см. разд. Алгоритмы динамического управления памятью).
Ряд современных файловых систем использует механизм, по-английски называемый block siiballocation, т. е. размещение частей блоков. В этих ФС кластеры имеют большой размер, но есть возможность разделить кластер на несколько блоков меньшего размера и записать в эти блоки "хвосты" от нескольких разных файлов (рис. 11.6). Это, безусловно, усложняет ФС, но позволяет одновременно использовать преимущества, свойственные и большим, и маленьким блокам. Поэтому ряд распространенных ФС, например файловая система Novell Netware 4.1 и FFS (известная также как UFS и Berkley FS), используемая во многих системах семейства Unix, применяе этот механизм.

Рис. 11.6. Субаллокация блоков

Субаллокация требует от файловой системы поддержания запаса свободных блоков на случай, если пользователю потребуется увеличить длину одного из файлов, "хвост" которого был упакован во фрагментированный блок. Учебные пособия по Netware рекомендуют поддерживать на томах с субаллокацией не менее тысячи свободных кластеров, но не предоставляют штатных методов обеспечения этого требования. Напротив, UFS показывает свободное место на диске с учетом "неприкосновенного" резерва свободного пространства, так что нулевое показываемое свободное пространство соответствует 5% или 10% физического свободного места. Объем этого резерва настраивается утилитами конфигурации ФС.
Но вернемся к простым файловым системам. В RT-11 каждому файлу выделяется непрерывная область на диске. Благодаря этому в каталоге достаточно хранить адрес первого блока файла и его длину, также измеренную в блоках. В RT-11 поступили еще проще: порядок записей в каталоге совпадает с порядком файлов на диске, и началом файла считается окончание предыдущего файла. Свободным участкам диска тоже соответствует запись в каталоге (рис. 11.7). При создании файла система ищет первый свободный участок подходящего размера.
Фактически эта структура отличается от формата tar только тем, что каталог вынесен в начало диска, и существует понятие свободного участка внутри области данных. Эта простая организация имеет очень серьезные недостатки.

  1. 1. При создании файла программа должна указать его длину. Часто это бывает затруднительно. Особенно неудобно увеличивать размер уже созданного файла. Точнее, это просто невозможно: вместо удлинения старого файла приходится создавать новый файл нужной длины и копировать содержимое старого файла в него.
  2. 2. При хаотическом создании и удалении файлов возникает проблема фрагментации свободного пространства. Для ее решения существует специальная программа SQUEESE (сжать [диск]), которая переписывает файлы г так, чтобы объединить все свободные фрагменты (рис. 11.8). Эта программа требует много времени, особенно для больших дисковых томов, и потенциально опасна: если при ее исполнении произойдет сбой системы (а с машинами третьего поколения такое случалось по нескольку раз в день), то значительная часть данных будет необратимо разрушена.

Рис. 11.7. Структура файловой системы RT-11

Рис. 11.8. Дефрагментация диска в RT-11

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

Рис. 11.9. Файл в виде односвязного списка блоков

Отчасти похожее решение было реализовано в MS DOS и DR DOS. Эти системы создают на диске таблицу, называемую FAT (File Allocation Table, таблица размещения файлов). В этой таблице каждому блоку, предназначенному для хранения данных, соответствует 12-битовое значение. Если блок свободен, то значение будет нулевым. Если же блок принадлежит файлу, то значение равно адресу следующего блока этого файла. Если это последний блок в файле, то значение — OxFFF (рис. 11.10). Существует также специальный код для обозначения плохого (bad) блока, не читаемого из-за дефекта физического носителя. В каталоге хранится номер первого блока и длина файла, измеряемая в байтах.
Емкость диска при использовании 12-битовой FAT оказывается ограничена 4096 блоками (2 Мбайт), что приемлемо для дискет, но совершенно не годится для жестких дисков и других устройств большой емкости. На таких устройствах DOS использует FAT с 16-битовыми элементами. На еще больших (более 32 Мбайт) дисках DOS выделяет пространство не блоками. а кластерами из нескольких блоков. Эта файловая система так и называется - FAT.
Она очень проста и имеет одно серьезное достоинство: врожденную устойчивость к сбоям (fault tolerance), но об этом далее. В то же время у нее есть и ряд серьезных недостатков.

Рис. 11.10. Структура файловой системы FAT

Первый недостаток состоит в том, что при каждой операции над файлами система должна обращаться к FAT. Это приводит к частым перемещениям головок дисковода и в результате к резкому снижению производительности. Действительно, исполнение программы arj на одной и той же машине под MS DOS и под DOS-эмулятором систем Unix или OS/2 различается по скорости почти в 1,5 раза. Особенно это заметно при архивировании больших каталогов.
Использование дисковых кэшей, а особенно помещение FAT в оперативную память, существенно ускоряет работу, хотя обычно FAT кэшируется только для чтения ради устойчивости к сбоям. При этом мы сталкиваемся со специфической проблемой: чем больше диск, тем больше у него FAT, соответственно, тем больше нужно памяти. У тома Novell Netware 3.12 размером 1,115 Гбайт с размером кластера 4 Кбайт размер FAT достигает мегабайта (легко понять, что Netware использует FAT с 32-разрядными элементами. При 16-разрядном элементе FAT дисковый том такого объема с таким размером кластера просто невозможен). При монтировании такого тома Netware занимает под FAT и кэш каталогов около 6 Мбайт памяти.
Для сравнения, Netware 4 использует субаллокацию, поэтому можно относительно безнаказанно увеличивать объем кластера и нет необходимости делать кластер таким маленьким. Для дисков такого объема Netware 4 устанавливает размер кластера 16 Кбайт, что приводит к уменьшению всех структур данных в 4 раза. Понятно, что для MS DOS, которая умеет адресовать всего 1 Мбайт (1088 Кбайт, если разрешен НМА), хранить такой FAT в памяти целиком просто невозможно.
Разработчики фирмы MicroSoft пошли другим путем: они ограничили разрядность элемента FAT 16 битами. При этом размер таблицы не может превышать 128 Кбайт, что вполне терпимо. Зато вся файловая система может быть разбита только на 64 Кбайт блоков. В старых версиях MS DOS это приводило к невозможности создавать файловые системы размером 32 Мбайт.
В версиях старше 3.11 появилась возможность объединять блоки в кластеры Например, на дисках размером от 32 до 64 Мбайт кластер будет состоять из 2 блоков и иметь размер 1 Кбайт. На дисках размером 128—265 Мбайт кластер будет уже размером 4 Кбайта и т. д.
Windows 95 использует защищенный режим процессора х86, поэтому адресное пространство там гораздо больше одного мегабайта. Разработчики фирмы Microsoft решили воспользоваться этим и реализовали версию FAT с 32-битовым элементом таблицы — так называемый FAT32. Однако дисковый кэш Windows 95 не стремится удержать весь FAT в памяти; вместо этого FAT кэшируется на общих основаниях, наравне с пользовательскими данными. Поскольку работа с файлами большого (больше одного кластера) объема требует прослеживания цепочки элементов FAT, а соответствующие блоки таблицы могут не попадать в кэш, то производительность резко падает. В сопроводительном файле Microsoft признает, что производительность FAT32 на операциях последовательного чтения и записи может быть в полтора раза ниже, чем у кэшированного FAT 16.
В заключение можно сказать, что при использовании FAT на больших дисках мы вынуждены делать выбор между низкой производительностью, потребностями в значительном объеме оперативной памяти или большим размером кластера, который приводит к существенным потерям из-за внутренней фрагментации.
Для эффективного управления большими объемами данных необходимо что-то более сложное, чем FAT.


"Сложные" файловые системы

Структуры "сложных" файловых систем отличаются большим разнообразием, однако можно выделить несколько общих принципов.
Обычно файловая система начинается с заголовка, или, как это называется в системах семейства Unix, суперблока (superblock). Суперблок хранит информацию о размерах дискового тома, отведенного под ФС, указатели на начало системных структур данных и другую информацию, зависящую от типа ФС. Например, для статических структур может храниться их размер. Часто суперблок содержит также магическое число — идентификатор типа файловой системы. Аналог суперблока существует даже в FAT — это так называемая загрузочная запись (boot record).
Практически все современные ФС разделяют список свободных блоков и структуры, отслеживающие размещение файлов. Чаще всего вместо списка свободных блоков используется битовая карта, в которой каждому блоку соответствует один бит: занят/свободен. В свою очередь, список блоков для каждого файла обычно связан с описателем файла. На первый взгляд, эти описатели кажется естественным хранить в каталоге, но их, как правило, выносят в отдельные области, часто собранные в специальные области диска — таблицу инодов, метафайл и т. д. Такое решение уменьшает объем каталога и, соответственно, ускоряет поиск файла по имени. К тому же многие ФС сортируют записи в каталоге по имени файла, также с целью ускорения поиска. Понятно, что сортировка записей меньшего размера происходит быстрее.
В файловой системе HPFS, используемой в OS/2 и Windows NT, каждая запись в каталоге содержит имя файла и указатель vtajhode (файловую запись). Каталоги в этой ФС организованы в виде В-деревьев и отсортированы по именам файлов.
Файловая запись занимает один блок на диске и содержит список так называемых extent ("расширений") — (у этого термина нет приемлемого русского аналога. В переводах документации по OS/360 (ОС ЕС) так и писали: экстент.). Каждый экстент описывает непрерывную последовательность дисковых блоков, выделенных файлу. Он задает начальный блок такой последовательности и ее длину. Вместо списка свободных блоков используется битовая карта диска, в которой каждому блоку соответствует один бит: занят/свободен.
Нужно отметить, что идея экстентов далеко не нова: аншюгичная структура используется в некоторых версиях Unix с начала 80-х годов, а истоки этой идеи теряются в глубине 60-х.
Файловая запись обычно размещается перед началом первого экстента файла, хотя это и не обязательно. Она занимает один блок (512 байт) и может содержать до десяти описателей экстентов (рис. 11.11). Кроме того, она содержит информацию о времени создания файла, его имени и расширенных атрибутах (см. разд. Тип файла). Если для списка экстентов или расширенных атрибутов места не хватает, то для них также выделяются экстенты. В этом случае экстенты размещаются в виде В-дерева для ускорения поиска. Максимальное количество экстентов в файле не ограничено ничем, кроме размера диска.
Пользовательская программа может указать размер файла при его создании. В этом случае система сразу попытается выделить место под файл так, чтобы он занимал как можно меньше экстентов. Если программа не сообщила размера файла, используется значение по умолчанию. Фактически, HPFS размещает место под файл по принципу worst fit (наименее подходящего), начиная выделение с наибольшего непрерывного участка свободного пространства. В результате фрагментированными оказываются только файлы, длина которых увеличивалась многократно или же те, которые создавались при почти заполненном диске. При нормальной работе файл редко занимает больше 3—4 экстентов.

Рис. 11.11. Каталог и файловая запись в HPFS

Еще одно любопытное следствие применения стратегии worst fit заключается в том, что пространство, освобожденное стертым файлом, обычно используется не сразу. Отмечались случаи, когда файл на активно используемом диске удавалось восстановить через несколько дней после стирания.
Экстенты открытых файлов и карта свободных блоков во время работы размещаются в ОЗУ, поэтому производительность такой ФС в большинстве ситуаций намного (в 1,5—2 раза и более) выше, чем у FAT с тем же объемом кэша, при вполне приемлемых потребностях в памяти и размере кластера 512 байт.
Видно также, что структура HPFS упрощает произвольный доступ к файлу: вместо прослеживания цепочки блоков нам нужно проследить цепочку экстентов, которая гораздо короче.
Более подробное описание структуры HPFS можно найти в статье [PC Magazine 1995|. Пользователи OS/2 считают целесообразным форматировать все разделы емкостью более 12SM под HPFS, поскольку при этом выигрывается скорость и увеличивается эффективность использования дискового пространства; кроме того, исчезает необходимость в дефрагментацин и появляется возможность создавать файлы и каталоги с длинными именами, не укладывающимися в модель 8.3, принятую в MS DOS.
За эти преимущества приходится платить неустойчивостью к сбоям (проблема устойчивости к системным сбоям обсуждается в разд. Устойчивость ФС к сбоям). В отличие от ДОС, спонтанные разрушения системы с последующим зависанием в OS/2 случаются относительно редко, даже при запуске программ, заведомо содержащих ошибки (как при разработке или тестировании прикладного программного обеспечения). С другой стороны, на практике "неустойчивость" приводит лишь к тому, что после аварийной перезагрузки автоматически запускается программа восстановления ФС, что увеличивает время перезагрузки в несколько раз; реальный же риск потерять данные при сбое не выше, а как показывает практика, даже существенно ниже, чем при использовании FAT, поэтому игра явно стоит свеч.
Наиболее интересна структура файловых систем в ОС семейства Unix. В этих ФС каталог не содержит почти никакой информации о файле. Там хранится только имя файла и номер его инода (i-node — по-видимому, сокращение от index node: индексная запись). Иноды всех файлов в данной ФС собраны в таблицу, которая так и называется: таблица инодов. В ранних версиях Unix таблица инодов занимала фиксированное пространство в начале устройства; в современных файловых системах эта таблица разбита на участки, распределенные по диску.
Например, в файловой системе BSD Unix FFS (Fast File System — быстрая файловая система), которая в Unix SVR4 называется просто UFS (Unix File System), диск разбит на группы цилиндров. Каждая группа цилиндров содержит копию суперблока, битовую карту свободных блоков для данного участка и таблицу инодов для файлов, расположенных в пределах этого участка (рис. 11.12). Такая распределенная структура имеет два преимущества.

  • Ускорение доступа к системным структурам данных. Когда системные данные расположены вблизи от блоков пользовательских данных, уменьшается расстояние, на которое перемещаются головки дисковода.
  • Повышенная устойчивость к сбоям носителя. При повреждении участка поверхности диска теряется только небольшая часть системных данных. Даже потеря суперблока не приводит к потере структуры файловой системы.

Рис. 11.12. Блоки цилиндров FFS

Инод хранит информацию о самом файле и его размещении на диске (рис. 11.13, пример 11.2). Информационная часть инода может быть получена системным вызовом
int stat(const char * fname, struct stat * buf) ;
Формат структуры stat описан во многих руководствах по языку С. ОС UNIX и стандарту POSIX, например, в работе (Керниган-Ритчи 2000|. Эта структура содержит следующую информацию.

  • Тип файла. Исследуя это поле, можно понять, является данный объект файлом данных или специальным файлом. В этом же поле закодированы права доступа к файлу.
  • Идентификаторы владельца файла и группы. Вместе с правами доступа эти два идентификатора образуют список контроля доступа файла (см разд. Списки контроля доступа).
  • Время:
    • создания файла;
    • последней модификации файла;
    • последнего доступа к файлу.
  • Длина файла. Для специальных файлов это поле часто имеет другой смысл.
  • Идентификатор файловой системы, в которой расположен файл.
  • Количество связей файла. Это поле заслуживает отдельного обсуждения.

Рис. 11.13. Каталоги и иноды файловых систем семейства Unix

Пример 11.2. Структура инода файловой системы ext2fs

 /* * Структура данных инода second extended file system,
        считанная в память
*/
i- struct ext2_inode_info { _u32 i_data[15]; _ u32 i_flags; _u32 i^faddr;
        _ u8 i_frag_no; _ u8 i_f rag_size; _ u!6 i_osync; _ u32 i_file_acl; _
        u32 i_dir_acl; _ u32 i_dtime;
_ u32 not_used_l; /* FIX: не используется/зарезервировано для 2.2 */ _
        u32 i_block_group; _ u32 i_next_alioc_block;
u32 i next alloc goal; _ u32 i_prealloc_block;
u32 i prealloc count; __ u32 i_high_size;
int i_new _inode : 1 ; /* Этот инод только что вьделен */

Структура, описывающая физическое размещение файла на диске, недоступна пользовательским программам. Собственно, формат этой структуры может быть не очень элегантен. Например, в файловой системе Veritas это список экстентов, похожий на HPFS; в файловых системах s5, Xenix и FFS это массив из 13 чисел, задающих номера физических блоков файла. Если файл в s5 содержит более десяти блоков (т. е. его длина больше 5 Кбайт), то предпоследние три указателя обозначают не блоки данных, а так называемые косвенные блоки (indirection blocks), в которых хранятся указатели на следующие блоки данных и, возможно, на следующие косвенные блоки.
Наиболее интересная особенность ФС семейства Unix состоит не в этом. Внимательный читатель, возможно, заметил, что инод не содержит имени файла. С другой стороны, он содержит счетчик связей (link) — ссылок на этот файл из каталогов. Таким образом, на один и тот же инод можно ссылаться из различных каталогов или из одного каталога под разными именами (рис. 11.14). Иными словами, один и тот же файл в этих ФС может иметь несколько различных имен. Именно это и имелось в виду, когда го верилось, что структура каталогов в ОС UNIX не обязана быть деревом.
Это свойство предоставляет неоценимые возможности для организации не рархии каталогов, но имеет и некоторые оборотные стороны.

  1. 1. Создание нескольких связей для каталога потенциально опасно— оно может привести к возникновению кольца, в котором каталог является своим собственным подкаталогом. Отслеживать такую ситуацию сложно поэтому разработчики ОС UNIX запретили создавать дополнительные имена для каталогов.
  2. 2. Удаление файла превращается в проблему: чтобы удалить файл, нужно проследить все его связи. Поэтому UNIX не имеет средств для удатсния файла, а обладает только системным вызовом unlink — удалить связь. Когда у файла не остается связей, он действительно удаляется. Этот подход вполне разумен, но также имеет неожиданную оборотную сторону: поскольку теперь стирание файла — это операция не над файлом, а над каталогом, то для удаления файла не нужно иметь никаких прав доступа к нему. Достаточно иметь право записи в каталог.
    На практике, механизм защиты от стирания отдельных файлов предусмотрен — при установке в атрибутах каталога так называемого sticky bit ("липкий бит"), система запрещает стирать файлы, для которых вы не имеете права записи, но и этот механизм не лишен недостатков.
  3. 3. Такие связи (называемые еще жесткими (hard) связями), как легко понять, могут создаваться только в пределах одной файловой системы, поскольку каждая ФС имеет собственную таблицу инодов, и соответственно собственную их нумерацию.

Рис. 11.14. Жесткие связи в Unix

Последнее обстоятельство резко уменьшает полезность жестких связей для организации иерархии каталогов. Эта проблема была осознана еще в 70-е годы, и программисты из группы BSD придумали интересное новое понятие - символическую связь (symbolic link) илиsymlink.
Символическая связь представляет собой специальный файл. Вместо блоков данных инод такого файла содержит текстовую строку — имя того файла, с которым создана связь (рис. 11.15). Это может быть файл из другой файловой системы, в том числе и из такой, которая сама по себе не поддерживает ни жестких, ни символических связей, например, FAT, HPFS или файловый сервер Novell Netware. Такого файла может и вообще не существовать, например, потому, что его уже удалили, или потому, что файловая система, в которой он находится, не смонтирована, или просто потому, что имя было задано неправильно. Тогда попытки открыть символическую связь будут завершаться неудачей с кодом ошибки "файла не существует".

Рис. 11.15. Символическая связь

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

Пример из жизни
На двух Unix-системах с именами orasrv и Indy установлен один и тот же программный продукт: редакторная система GNU Emacs. Бинарные загрузочные модули для этих систем различаются, но большая часть Emacs — это файлы на языке Emacs Lisp, которые с одинаковым успехом могут использоваться обеими системами. Поэтому у администратора системы возникает желание использовать одну копию lisp-файлов. При этом Emacs установлена таким обра зом, что она ищет все свои lisp-файлы в каталоге /usr/local/lib/emacs/19.27/lisp Изменение этого каталога потребовало бы частичной переустановки продукта Но его не надо менять! Мы просто подмонтируем на машине orasrv каталог / машины Indy как /fs/lndy и исполняем следующие команды примера 11.3.

Пример 11.3. Перенос каталога без изменения его путевого имени

cd /usr/local/lib/emacs/19.27 # перейти в заданный каталог rm -Rf lisp
# стереть каталог lisp со всем содержимым
In -s /fs/Indy/usr/local/lib/emacs/19.27/lisp lisp
# создать символическую связь с именем lisp с соответствующим
# каталогом на машине Indy

Вся операция вместе с ее планированием занимает около двух минут. Освобождается около 9 Мбайт дискового пространства. Emacs ничего не замечает, и работает без всяких проблем.
Через неделю у администратора возникает идея, что для удобства администрирования неплохо было бы монтировать с orasrv не всю файловую систему Indy, а только ее каталог /home. За чем дело стало: переносим на Indy каталог lisp в каталог /home/emacs/19.27/lisp; создаем в старом каталоге символическую связь с новой; редактируем файл /etc/mnttab на-машине orasrv так, чтобы с Indy монтировался только /home; меняем символическую связь каталога lisp на машине orasrv. Заставляем orasrv подмонтировать диски в соответствии с новым /etc/mnttab. Готово.
Операция занимает чуть больше двух минут, потому что нужно переносить 9 Мбайт из одной файловой системы в другую. Emacs опять ничего не замечает и снова работает без всяких проблем. Администратор счастлив. Все довольны. Сравните это описание с впечатлениями пользователя, который пытается переместить с одного ДОС-вского драйва на другой типичную программу для Win32, которая при установке записывает во многие места своей конфигурации и системного реестра полное имя каталога, в который ее ставили...

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

Жесткие связи в VMS и Windows NT/2000/XP
Например, в файловой системе VAX/VMS данные о размещении файлов на диске хранятся в специальном индексном (index) файле; каталоги же хранят только индексы записей в этом файле. Основное отличие этой структуры от принятой в Unix состоит в том, что вместо статической таблицы или набора таблиц используется динамическая таблица, пространство для которой выделяется тем же методом, что и для пользовательских файлов. Этот же подход реализован в файловой системе NTFS, используемой в Windows NT/2000/XP, но в ней индексный файл называется MFT (Main File Table — главная таблица файлов). Более подробное описание структуры NTFS приводится в статье [www.digit-life.com NTFS].
VAX/VMS и Windows NT позволяют создавать дополнительные имена для файлов, хотя в VMS утилиты восстановления ФС выдают предупреждение, обнаружив такое дополнительное имя. Все имена файла в этих ФС обязаны находиться в одной файловой системе. Кроме того, операция удаления файла в VMS ведет себя не так, как в Unix: применение операции удаления к любому из имен приводит к удалению самого файла, даже если существовали и другие имена.


Устойчивость ФС к сбоям

Свойство устойчивости к сбоям питания (power-fault tolerance) является одной из важных характеристик файловой системы. Строго говоря, имеется в виду устойчивость не только к сбоям питания, но и к любой ситуации, при которой работа с ФС прекращается без выполнения операции размонтирования. Поэтому правильнее было бы говорить об устойчивости к любым сбоям системы, а не только питания.
С другой стороны, если говорить просто об "устойчивости к сбоям", возникает неприятная двусмысленность. Под устойчивостью к сбоям можно понимать устойчивость к сбоям всей системы, например, к тем же сбоям питания, а можно понимать устойчивость к дефектам физического носителя, которые могут привести к потере части данных на диске. Вторая проблема обычно решается с помощью механизма горячей замены (логической переадресации) сбойных блоков, который обсуждается к разд. Устойчивость ФС к сбоям диска, а сейчас мы будем говорить только о первой проблеме, называя ее просто "устойчивостью" для краткости.


Устойчивость к сбоям питания

На самом деле, неожиданное прекращение работы с ФС может произойти не только при сбое питания, но и в следующих ситуациях:

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

На практике часто сложно провести границы между тремя последними типами сбоев. В качестве примера можно привести проблему, описанную в разд. Обмен данными с пользовательским процессом. В системах класса ДОС последняя причина возникает очень часто, поэтому в таких системах можно использовать только устойчивые сбоям ФС.
В системах класса ОС "спонтанные" зависания происходят намного реже Система, зависающая по непонятным или неустранимым причинам раз в несколько дней, считается малопригодной для серьезного использования а делающая это раз в месяц — подозрительно ненадежной. Поэтому в таких системах можно позволить себе роскошь использовать неустойчивые к сбоям ФС. Тем более что такие системы, как правило, обладают более высокой производительностью, чем устойчивые ФС.
К тому же перед выключением системы, интегрированной в сеть, необходимо уведомить всех клиентов и все серверы о разъединении. Только чисто клиентская машина может быть выключена из сети без проблем. Поэтому на сетевых серверах в любом случае необходима процедура закрытия системы (shutdown). Так почему бы не возложить на эту процедуру еще и функцию размонтирования ФС?
В системах же реального времени, которые по роду работы могут часто и неожиданно перезапускаться, например, при отладке драйверов или программ, осуществляющих доступ к аппаратуре, стараются использовать устойчивые к сбоям ФС.
Хотя первая из перечисленных выше причин — извлечение носителя из дисковода — не является "сбоем" даже в самом широком смысле этого слова, с точки зрения ФС она мало чем отличается от сбоя. Поэтому на удаляемых носителях, таких, как дискеты, можно использовать только устойчивые ФС.
Интересный альтернативный подход используется в компьютерах Macintosh и некоторых рабочих станциях. У этих машин дисковод не имеет кнопки для извлечения дискеты. Выталкивание дискеты осуществляется программно, подачей соответствующей команды дисководу. Перед подачей такой команды ОС может выполнить нормальное размонтирование ФС на удаляемом диске.
В узком смысле слова "устойчивость" означает лишь то, что ФС после аварийной перезагрузки не обязательно нуждается в восстановлении. Такие ФС обеспечивают целостность собственных структур данных в случае сбоя, но, вообще говоря, не гарантируют целостности пользовательских данных в файлах.
Нужно отметить, что даже если ФС считается в этом смысле устойчивой, некоторые сбои для нее могут быть опасны. Например, если запустить команду DISKOPT на "устойчивой" файловой системе FAT и в подходящий момент нажать кнопку RESET, то значительная часть данных на диске может быть навсегда потеряна.
С другой стороны, можно говорить об устойчивости в том смысле, что в ФС после сбоя гарантирована целостность пользовательских данных. Достаточно простого анализа для того чтобы убедиться, что такую гарантию нельзя обеспечить на уровне ФС; обеспечение подобной целостности накладывает серьезные ограничения и на программы, работающие с данными, а иногда оказывается просто невозможным.
Характерный и очень простой пример: архиватор InfoZip работает над созданием архива. Программа сформировала заголовок файла, упаковала и записала на диск около 50% данных, и в этот момент произошел сбой. В zip-архивах каталог находится в конце архивного файла и записывается туда после завершения упаковки всех данных. Обрезанный в случайном месте zip-файл не содержит каталога и поэтому, безусловно, является безнадежно испорченным. Поэтому при серьезном обсуждении проблемы устойчивости к сбоям говорят не о гарантии целостности пользовательских данных, а об уменьшении вероятности их порчи.
Поддержание целостности структур ФС обычно гораздо важнее, чем целостность недописанных в момент сбоя пользовательских данных. Дело в том, что если при сбое оказывается испорчен создававшийся файл, это достаточно неприятно; если же окажется испорчена файловая система, в худшем случае это может привести к потере всех данных на диске, т. е. к катастрофе. Поэтому обычно обеспечению целостности ФС при сбоях уделяется гораздо больше внимания.
Упоминавшаяся выше "врожденная" устойчивость к сбоям файловой системы FAT объясняется тем, что в этой ФС удаление блока из списка свободных и выделение его файлу производится одним действием — модификацией элемента FAT (рис. 11.16). Поэтому если во время этой процедуры произойдет сбой или дискета будет вынута из дисковода, то ничего страшного не случится: просто получится файл, которому выделено на один блок больше, чем его длина, записанная в каталоге. При стирании этого файла все его блоки будут помечены как свободные, поэтому вреда практически нет.

Рис. 11.16. Модификация FAT

Нужно отметить, что при активном использовании отложенной записи FAT и родственные ФС теряют это преимущество. Отложенная запись FAT является единственным способом добиться хоть сколько-нибудь приемлемой производительности от ФС с 32-битовым FAT. Поэтому, хотя Novell NetWare и использует ФС, основанную на 32-битной FAT, после аварийной перезагрузки эта система вынуждена запускать программу аварийною становления дисковых томов. Аналогичным образом ведет себя и FAT3? Если же система хранит в одном месте список или карту свободных блоков, а в другом месте списки блоков, выделенных каждому файлу (рис. 11.17) как это делают HPFS или ФС систем семейства Unix, то при прерывании операции выделения места в неподходящий момент могут либо теряться блоки (если мы сначала удаляем блок из списка свободных— рис. 11.18) либо получаться блоки, которые одновременно считаются и свободными, и занятыми (если мы сначала выделяем блок файлу).
Первая ситуация достаточно неприятна, вторая же просто недопустима: первый же файл, созданный после перевызова системы, будет "перекрещиваться" с испорченным (рис. 11.19). Поэтому все ОС, использующие файловые системы такого типа (системы семейства Unix, OS/2, Windows NT и т. д.), после аварийной перезагрузки первым делом проверяют своп ФС соответствующей программой восстановления.

Рис. 11.17. Модификация структур данных сложной ФС

Рис. 11.18. Потерянный блок

Рис. 11.19. Пересекающиеся файлы

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


Восстановление ФС после сбоя

Чаще всего суперблок неустойчивых ФС содержит флаг flirty ("грязный"), сигнализирующий о том, что ФС, возможно, нуждается в восстановлении. Этот флаг сбрасывается при нормальном размонтировании ФС и устанавливается при ее монтировании или при первой модификации после монтирования. Таким образом, если ОС погибла, не успев размонтировать свои дисковые тома, после перезагрузки на этих томах dirty-флаг будет установлен, что и станет сигналом необходимости починки.
Восстановление состоит в том, что система проверяет пространство, выделенное всем файлам. При этом должны выполняться следующие требования.

  1. 1. Каждая запись в каталоге должна иметь правильный формат и содержать осмысленные данные. Например, если запись помечена как свободная, она не должна ссылаться на данные, помеченные как принадлежащие файлу, или на и под. Не во всех ФС можно обнаружить ошибки такого типа.
  2. 2. Каждый блок или кластер диска должен принадлежать не более, чем одному файлу. Блоки, принадлежащие одновременно двум или более файлам, являются очень серьезной ошибкой. На практике это означав что данные во всех этих файлах (в лучшем случае — во всех, кроме того запись в который была последней) безнадежно испорчены. Чаще всего программа восстановления в этой ситуации требует вмешательства попь зователя, с тем чтобы решить, какие из файлов следует удалить или обре зать по месту пересечения. Иногда для каждого из файлов создается ко пия "общего" блока, но и в этом случае пользователю все равно нужно определить, какие из файлов испорчены.

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

  1. 3. Каждому файлу должно быть выделено пространство, соответствующее его длине. Если файлу выделено больше блоков, чем требуется, лишние блоки помечаются как свободные. Если меньше, файл укорачивается. Возможно, в укороченных файлах часть данных оказывается потеряна.
  2. 4. Все блоки, не принадлежащие файлам, должны быть помечены, как свободные. Соответствующий тип ошибок — потерянные блоки — наиболее частый результат системных сбоев как в файловой системе FAT, так и в более сложных файловых системах. Сами по себе ошибки этого типа относительно безобидны.

Обычно программа восстановления не помечает потерянные блоки как свободные, а выделяет из них непрерывные цепочки и создает из этих цепочек файлы. Например, в OS/2 программа восстановления пытается найти в потерянных блоках файловые записи, а потом создает ссылки на найденные таким образом файлы в каталоге \FOUND.XXX. В DOS эти файлы помещаются в корневой каталог ФС под именами FILEXXXX.CHK (вместо ХХХХ подставляется номер). Предполагается, что пользователь просматривает все такие файлы и определяет, не содержит ли какой-то из них ценной информации, например, конца насильственно укороченного файла.
В системах семейства Unix существует несколько специфических ошибок, связанных с инодами.

  • Инод, внутренний счетчик ссылок которого не соответствует реальному количеству ссылок из каталогов. Эта проблема может возникать при системном сбое в момент удаления существовавшей связи или создания повой. Она решается коррекцией внутреннего счетчика инода. После этого можно обнаружить следующие две ошибки.
  • Инод, не имеющий ни одной ссылки, но и не помеченный как свободный — сирота (orphan) (рис. 11.20). Ссылка на такой и под создается в каталоге lost+foiind;
  • Инод с непулевым количеством ссылок из каталогов, но помеченный как свободный. Чаще всего это свидетельствует о порче самого каталога. Обычно ссылки на такой инод удаляются.

Рис. 11.20. Инод-сирота

Ручное восстановление файловой системы
В некоторых особенно тяжелых случаях программа восстановления оказывается не в состоянии справиться с происшедшей аварией и администратору системы приходится браться за дисковый редактор.
В процессе эксплуатации системы SCO Open Desktop 4.0 у автора неоднократно возникала необходимость выполнять холодную перезагрузку без нормального размонтирования файловых систем. Одна из таких перезагрузок привела к катастрофе.
Дисковая подсистема машины состояла из кэширующего контроллера дискового массива. Контроллер активно использовал отложенную запись, что, по-видимому, и послужило причиной катастрофы. Во время планового резервного копирования драйвер лентопротяжки "впал в ступор" и заблокировал процесс копирования (механизм возникновения этой аварии подробнее обсуждался в разд. Обмен данными с пользовательским процессом). Из-за наличия зависшего процесса оказалось невозможно размонтировать файловую систему, и машина была перезагружена нажатием кнопки RESET без выполнения нормального закрытия, в том числе и без выполнения операции закрытия драйвера дискового массива, которая должна была сбросить на диски содержимое буферов контроллера.
После перезагрузки система автоматически запустила программу восстановления файловой системы fsck (File System ChecK), которая выдала радостное сообщение: DUP in inode 2. Для незнакомых с системными утилитами Unix н обходимо сказать, что DUP означает ошибку перекрещивания файлов, а инп~ 2— это инод корневого каталога ФС. Таким образом, корневая директория дискового тома объемом около 2 Гбайт оказалась испорчена. При этом пода ляющее большинство каталогов и файлов были не затронуты катаклизмом оказались недоступны. Программа восстановления не могла перенести ссылк на соответствующие иноды в каталог lost+found, так как ссылка на него такж идет из корневого каталога.
Ситуация представлялась безвыходной. Катастрофа усугублялась тем, что произошла она во время резервного копирования, а последняя "хорошая" копия была сделана неделю назад. Большую часть пользовательских данных можно было бы восстановить, но среди потерянных файлов оказался журнальный файл сервера СУБД ORACLE (сама база данных находилась в другом разделе диска). Пришлось заняться восстановлением ФС с использованием дискового редактора, мотивируя это тем, что "терять все равно уже нечего" Собственно, в восстановлении ФС участвовало два человека — автор и штатный администратор системы. Автор ни в коем случае не хочет создать у читателя впечатление, что план восстановления был разработан лично им — это был плод совместных усилий, проб и ошибок.
Редактирование системных данных "сложных" ФС с использованием простого шестнадцатеричного дискового редактора является крайне неблагодарным занятием. Есть основания утверждать, что это вообще невозможно. Во всяком случае, автору не доводилось слышать об успехе такого предприятия. К счастью, системы семейства Unix предоставляют для редактирования ФС специальную программу fsdb (File System DeBugger— отладчик файловой системы). Пользовательским интерфейсом эта программа напоминает программу DEBUG.COM, поставляемую с MS DOS; главным ее преимуществом является то, что она "знакома" с основными понятиями файловой системы. Так, например, fsdb позволяет просмотреть содержимое 10-го логического блока файла с инодом 23 456, выделить физический блок 567 345 файлу с инодом 2 или пометить инод 1245 как свободный.
Первая попытка восстановления состояла в том, что мы удалили тот инод, с которым перекрещивался корневой каталог, смонтировали том командой "безусловного" монтирования (которая позволяла монтировать поврежденные тома), создали командой mkdir каталог lost+found и вновь запустили fsck. Попытка завершилась крахом. Беда была в том, что, как оказалось, корневой каталог пересекался также и со списком свободных блоков, т. е. создание каталога, а потом его расширение командой f sck снова приводило к порче корневого каталога и задача сводилась к предыдущей.
Таким образом, нам необходимо было либо исправить вручную список свободных блоков, либо найти способ создать директорию lost+found без обращений к этому списку. Дополнительная сложность состояла в том, что с каталогом lost+found не связано фиксированного инода, а определить инод старого lost+found не представлялось возможным.
Мы решили не связываться с восстановлением списка свободных блоков. Вместо этого мы просмотрели листинг последней "правильной" резервной копии, нашли там ненужный пустой каталог и присоединили его к корневому под названием lost+found. После этого нам оставалось лишь уповать на то, что вновь создаваемые f sck-ссылки на файлы не приведут к необходимости удлинить наш lost+found. К счастью, этого не произошло: все потомки корневого каталога благополучно получили имена в lost+found. По существу, ФС пришла в пригодное для чтения состояние, оставалось лишь правильно определить имена найденных каталогов. Это также оказалось относительно несложной задачей: большая часть каталогов на томе состояла из домашних каталогов пользователей и их имена можно было восстановить на основании того, кому эти каталоги принадлежали. Для остальных каталогов имя достаточно легко определялось после сопоставления их содержимого с листингом резервной копии.

Во многих современных ОС реализованы устойчивые к сбоям файловые системы: jfs в AIX и OS/2 v4.5, Veritas в UnixWare, NTFS в Windows NT/2000/XP. Практически все такие ФС основаны на механизме, который по-английски называется intention logging (регистрация намерений).


Файловые системы с регистрацией намерений

Термин, вынесенный в заголовок этого подраздела, является дословной калькой (возможно, не очень удачной) англоязычного термина intention logging. В русском языке, к сожалению, еще нет общепринятого термина для этого понятия.
Идея журналов регистрации намерений пришла из систем управления базами данных. В СУБД часто возникает задача внесения согласованных изменений в несколько разных структур данных. Например, банковская система переводит миллион долларов с одного счета на другой. СУБД вычитает 1 000 000 из суммы на первом счету, затем пытается добавить ту же величину ко второму счету и в этот момент происходит сбой.
Для СУБД этот пример выглядит очень тривиально, но мы выбрали его потому, что он похож на ситуацию в файловой системе: в каком бы порядке Ни производились действия по переносу объекта из одной структуры в другую, сбой в неудачный момент приводит к крайне неприятной ситуации. В СУБД эта проблема была осознана как острая очень давно — ведь миллион долларов всегда был намного дороже одного сектора на диске.
Удовлетворительное решение проблемы заключается в следующем.

  • Во-первых, все согласованные изменения в СУБД организуются в блоки, называемые транзакциями (transaction). Каждая транзакция осуществляется как неделимая (атомарная) операция, во время которой никакие другие операции над изменяемыми данными не разрешены.
  • Во-вторых, каждая транзакция осуществляется в три этапа (рис. 11.21)
    • Система записывает в специальный журнальный файл, что же она с бирается делать.
    • Если запись в журнал была успешной, система выполняет транзакцию.
    • Если транзакция завершилась нормально, система помечает в журнале, что намерение было успешно реализовано.

Рис. 11.21. Выполнение транзакции с регистрацией намерений

Журнал часто называют журналом регистрации намерений (intention log), что очень хорошо отражает суть дела, потому что в этот журнал записываются именно намерения (intentions).
При использовании отложенной записи транзакция считается полностью завершенной, только когда последний блок измененных данных будет физически записан на диск. При этом в системе обычно будет одновременно существовать несколько незавершенных транзакций (рис. II.22). Легко понять, что при операциях с самим журналом отложенную запись вообше нельзя использовать.

Рис. 11.22. Очередь исполняющихся транзакций

Если произошел сбой системы, после перезагрузки запускается программа восстановления базы данных (рис. 11.23). Эта программа просматривает конец журнала.

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

Нужно отметить, что данные, необходимые для выполнения отката, могут иметь большой объем. Фактически это копия всех данных, подвергающихся Изменению в ходе транзакции. Многие СУБД, такие как ORACLE, хранят эти данные не в самом журнале, а в специальной области данных, называемой сегмент отката (rollback segment).

Рис. 11.23. Журнал транзакций после сбоя

Более подробная информация о работе журналов намерений в базах данных может быть найдена в соответствующей литературе [Дейт 1999, Дейт 1988). Необходимо только отметить, что книги и даже фирменная документация по простым СУБД типа dBase или FoxPro здесь не помогут, поскольку эти пакеты не содержат средств регистрации в журнале.
Идея журнала намерений достаточно естественно переносится в программу управления файловой системой. Но здесь возникает интересный вопрос -что же считать транзакцией: только операции по распределению пространства на диске или также все операции по изменению данных?
Первый вариант проще в реализации и оказывает меньшее влияние на производительность; зато он гарантирует только целостность самой ФС, но не может гарантировать целостности пользовательских данных, если сбой произойдет в момент записи в файл.
Второй вариант требует выделения сегмента отката и сильно замедляет работу. Действительно, ведь теперь данные пишутся на диск два раза: сначала в сегмент отката, а потом в сам файл. Зато он существенно снижает вероятность порчи пользовательских данных.
ряд современных ФС с регистрацией намерений поддерживают оба режима работы и предоставляют выбор между этими вариантами администратору системы. Например, у файловой системы vxfs пли Veritas, входящей в пакет UnixWare (версия Unix SVR4, поставляемая фирмой Novell), существует две версии. Одна версия, поставляемая вместе с системой по умолчанию, включает в транзакцию только системные данные. Другая, "advanced", версия, которая поставляется за отдельные деньги, осуществляет регистрацию намерений как для системных, так и для пользовательских данных.

Журналы намерений в Veritas
В Veritas 2 дисковый том разбит на области, называемые группами цилиндров (термин, унаследованный из FFS— файловой системы BSD Unix). Каждая группа цилиндров имеет свою карту свободных блоков, участок динамической таблицы инодов и журнал намерений (рис. 11.24). Журнал намерений организован в виде кольцевого буфера записей о транзакциях. В журнал пишутся данные только о транзакциях над инодами, принадлежащими к этой группе цилиндров. При этом в каждой группе может исполняться одновременно несколько транзакций и окончанием транзакции считается физическое завершение записи модифицированных данных. Количество одновременно исполняющихся транзакций ограничено объемом журнала, но поскольку каждая группа цилиндров имеет свой журнал, это ограничение не играет большой роли.

Рис. 11.24. Группы цилиндров и журналы транзакций Veritas


Устойчивость ФС к сбоям диска

Кроме общесистемных сбоев, ФС должна обеспечивать средства восстановления при физических сбоях диска. Наиболее распространенным видом таких сбоев являются нечитаемые — "плохие" (bad) — блоки, появление которых обычно связано с физическими дефектами магнитного носителя.
Быстрее всего плохие блоки возникают на гибких магнитных дисках, которые соприкасаются с головкой чтения/записи и из-за этого подвержены физическому износу и повреждениям. Кроме того, гибкие диски подвергаются опасным воздействиям и вне дисковода. Например, при вносе дискеты с улицы в теплое помещение на поверхности диска будет конденсировать влага, а соприкосновение головки дисковода с влажным диском практич ски наверняка повредит магнитный слой.
Жесткие магнитные диски помещены в герметичный корпус и — в норме не соприкасаются с головками дисковода, поэтому срок службы таких дис ков намного больше. Появление одиночных плохих блоков на жестком диске скорее всего свидетельствует о заводском дефекте поверхности или же о том, что магнитный слой от старости начал деградировать.
Весьма опасной причиной порчи жестких дисков является соприкосновение головок чтения/записи с поверхностью вращающегося диска (head crash) например, из-за чрезмерно сильных сотрясений диска во время работы В частности, из-за этого не следует переставлять работающие компьютеры особенно во время активных операций с диском. Обычно такое соприкосновение приводит к повреждению целой дорожки или нескольких дорожек диска, а зачастую и самой головки. Для несъемных жестких дисков это нередко означает потерю целой рабочей поверхности: считывание данных с нее требует замены блока головок, что весьма дорого.
Обычно ошибки данных обнаруживаются при чтении. Дисковые контроллеры используют при записи кодировку с исправлением ошибок, чаще всего коды Хэмминга (см. разд. Контрольные суммы), которые позволяют обнаруживать и исправлять ошибки. Тем не менее, если при чтении была выявлена ошибка, большинство ОС отмечают такой блок как плохой, даже если данные удалось восстановить на основании избыточного кода.
В файловой системе FAT плохой блок или кластер, содержащий такой блок, отмечается кодом OxFFB или OxFFFB для дисков с 16-разрядной FAT. Эта файловая система не способна компенсировать плохие блоки в самой FAT или в корневом каталоге диска. Такие диски просто считаются непригодными для использования.
В "сложных" файловых системах обычно используется более сложный, но зато и более удобный способ обхода плохих блоков, называемый горячей заменой (hotfixing). При создании файловой системы отводится небольшой пул блоков, предназначенных для горячей замены. В файловой системе хранится список всех обнаруженных плохих блоков, и каждому такому блоку поставлен в соответствие блок из пула горячей замены (рис. 11.25). При этом плохие блоки, на которые оказались отображены системные структуры данных, например участок таблицы инодов, также подвергаются горячей замене. Таблица горячей замены может быть как статической, так и динамической.
Современные контроллеры жестких дисков часто предоставляют свои средства горячей замены блоков, происходящие незаметно для центрального процессора.

Рис. 11.25. Горячая замена (динамическое переназначение) блоков диска

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

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

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


Драйверы файловых систем

При эксплуатации ОС может возникнуть необходимость монтировать файловые системы, отличающиеся от "родной" ФС. Особенно часто она возникает в организациях, где используются ОС нескольких разных типов. Да и в организациях, работающих с монокультурой MS DOS/MS Windows, такая потребность возникает все чаще. Во-первых, доступ к файлам на файловом сервере осуществляется существенно иными способами, чем к файлам локальном диске, даже если на сервере стоит та же ДОС с той же Фс тип FAT. Во-вторых, дисководы для CD-ROM становятся все дешевле и расгт страняются все шире. При этом стандартная ФС на CD-ROM — вовсе FAT.
Решение этой проблемы приходит в голову сразу — необходим драйвеп файловой системы со стандартным интерфейсом, подобный драйверу ннеш него устройства. Естественно, набор функций такого драйвера должен быть существенно иным.

  • mount — монтирование ФС. В зависимости от типа ФС параметры тгой функции должны различаться. Для ФС на локальных дисках достаточно передать системный идентификатор монтируемого диска. Для ФС на удаленной машине мы должны передать сетевой адрес этой машины и имя требуемой файловой системы. Во многих случаях проводится различие между монтированием ФС для чтения/модификации или только для чтения: при монтировании с модификацией устанавливается флаг загрязнения (dirty flag) ФС.
  • umount — размонтирование ФС.
  • GetFreeSpace или df — получение информации о ФС: общее пространство, свободное пространство, количество файлов и т. д.
  • FindFirst/FindNext ИЛИ opendir/readdir — функции длячтения из каталога. Считанную информацию необходимо привести к формату, принятому в данной ОС. В частности, может оказаться необходимым сократить и/или преобразовать считанные имена файлов. Например, драйвер HPFS, используемый в-эмуляторе MS DOS, в системе OS/2 не выполняет такого преобразования. В результате — программы DOS не видят файлов и каталогов с длинными именами.
  • access — проверить существование файла и возможность доступа к нему в заданном режиме, например, для чтения или для записи.
  • stat — функция для получения информации о файле с заданным именем. Драйвер ФС должен считать доступную информацию о файле и привести ее к принятому в ОС формату. При этом возможно придется проигнорировать часть считанной информации, а другую часть, напро-- тив, драйвер вынужден сочинять сам. Так, драйвер файловой системы FAT в ОС семейства Unix вынужден сочинять идентификатор хозяина файла, права доступа и т. д.
  • open - открыть существующий или создать новый файл с заданным именем.
  • read и write — функции считывания данных из файла ц записи в него.
  • lseek — позиционирование в файле.
  • lock — функция (в действительности, набор функций) блокировки для
    синхронизации доступа к файлу или его участкам. (Подробнее о них см. в
    разд. Захват участков файлов). При работе с локальными ФС операции блокировки могут прослеживаться ядром системы и не доходить до драйвера ФС, но при
    работе с разделяемыми по сети ФС блокировки необходимо отслеживать
    на уровне протокола разделения файлов, поэтому драйвер сетевой ФС
    обязан знать о блокировках.
  • close — закрыть файл.
  • delete или unlink — удалить файл или его имя.
  • link — создать связь с файлом (новое имя). Далеко не все ОС и ФС поддерживают эту операцию.
  • mkdir — создать каталог.
  • rmdir — удалить каталог. Обычно разрешено удаление только пустых каталогов.

Кроме собственно драйвера ФС, для ее полноценной поддержки нужны следующие программы:

  • программа создания ФС — mkfs или FORMAT;
  • программа контроля и починки ФС — fsck или CHKDSK;
  • программа fstyp, которая смотрит на содержимое устройства и пытается определить, "ее" это ФС или не ее. Она полезна при монтировании ФС с автоматическим определением ее типа;
  • программа mount, которая принимает из командной строки зависящие от типа ФС параметры, проверяет их допустимость и инициализирует драйвер ФС.

Драйверы файловых систем в SCO UnixWare
Например, дистрибутив ОС UnxiWare 2.0 фирмы SCO, основанной на ядре UNIX System V R4.2, содержит драйверы следующих файловых систем.
memfs — файловая система, размещающая файлы в оперативной памяти. Может рассматриваться как эквивалент виртуального диска в MS DOS.
dosfs — файловая система FAT.
s5 — "классическая" ФС, сохранившаяся почти без изменений с самых ранних версий системы— s5, по-видимому, означает Unix System 5. Ограничивает имя файла 14 символами. Неустойчива к сбоям.
ufs — файловая система, разработанная в университете Беркли, известная также как FFS (Fast File System) и Berkley FS. Является основной ФС в большинстве версий BSD UNIX и поддерживается многими другими ОС семейства Unix. Имеет более высокую производительность, чем s5, в первую очередь за счет разбиения таблицы инодов и списка свободных блоков на участки (группы цилиндров). Поддерживает дисковые квоты ограничения на объем дискового пространства, занятого файлами того и~~ иного пользователя. Ограничивает имя файла размером блока (обычно 512 символов). Неустойчива к сбоям.
bfs — Boot File System — загрузочная файловая система. Эта ФС очень простую структуру, отчасти похожую на файловую систему
все файлы в ней обязаны занимать непрерывное пространство. "Гака структура упрощает первичный загрузчик системы, которому теперь не нужно разбираться в каталогах и инодах. bfs имеет довольно низкую про-изводительность и требует длительной процедуры размонтирования. если в нее были записаны новые данные. Фактически, при таком размонтирова-нии происходит операция сжатия ФС, эквивалентная команде SQEESE в RT-11. Используется для хранения ядра системы и нескольких конфигурационных файлов, применяемых при загрузке. Все эти данные считываются лишь при загрузке системы и перезаписываются только при изменениях конфигурации ядра, поэтому высокая производительность от этой ФС не требуется.
vxfs — устойчивая к сбоям ФС Veritas с регистрацией намерений. Версия, входящая в стандартную поставку системы, включает в регистрируемые транзакции только системные структуры данных. За отдельную плату можно приобрести "продвинутую" версию Veritas, которая обеспечивает транзакции и для записи пользовательских данных.
cdfs — файловая система ISO, используемая на CD-ROM.
nfs — Network File System — драйвер файловой системы, обеспечивающий разделение файлов с использованием сетевого протокола TCP/IP. Протокол NFS был предложен фирмой Sun Microsystems в середине 80-х годов и в настоящее время поддерживается практически всеми членами семейства Unix. NFS-клиенты и NFS-серверы реализованы практически для всех современных ОС.
rfs — Remote File Sharing — использование удаленной UNIX-системы в качестве файлового сервера. Этот протокол был разработан фирмой AT&T в 80-е годы и пригоден только для соединения систем UNIX System V.
nucfs — NetWare Unix Client File System. Этот драйвер предназначен для присоединения к файловым серверам Novell Netware. Он входит в состав системы UnixWare, поставляемой фирмой SCO, но не в остальные версии UNIX SVR4.

Любопытно, что даже MS/DR DOS версий выше 3.30 имеют возможность устанавливать драйвер файловой системы. Такой драйвер может быть реализован путем перехвата недокументированных функций прерывания 0x2F — группы функций "Network Redirector" и "IPS". Таким образом реализован ряд сетевых клиентов для MS/DR DOS. К сожалению, автор не смог найти полноценного описания этих функций.


Глава 12. Безопасность

  • Безопасность
  • Формулировка задачи
  • Сессии и идентификаторы пользователя
  • Аутентификация
  • Аутентификация в сети
    • Криптографические методы аутентификации
  • Авторизация
    • Списки контроля доступа
    • Полномочия
    • Изменение идентификатора пользователя
  • Ресурсные квоты
  • Атаки на систему безопасности
    • Отказ в сервисе
    • Троянские программы
    • Типичные уязвимые места
    • Ошибки программирования
  • Практические рекомендации


Безопасность

Justi cause you're paranoid
Don't mean they aren't after you.
K. Kobain

To, что ты параноик
He значит, что они тебя не преследуют.
К. Кобэйн

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


Формулировка задачи

А мальчик-юзер пошел пить пиво со злыми интернетчиками, а те ему и говорят: "Да какой-ты крутой хаксор. Почту ты сломал, факт. А вот этот сервак попробуй сломать". И в окно показывают. А напротив пивняка стоит здание, с надписью "Почта". И мальчик-юзер пошел почту ломать. А па почте, видать, сниффер стоял, так что через пять минут менты-модераторы приехали и так отмодерили мальчика-юзера, что с тех пор о нем ничего неизвестно.
Vitar Velazquez

Идеальная система безопасности должна обеспечивать полностью прозрачный санкционированный доступ к данным и непреодолимые трудности при попытках доступа несанкционированного. Кроме того, она должна предоставлять легкую и гибкую систему управления санкциями; во многих случаях бывает также полезно отслеживать все попытки несанкционированного доступа.
К сожалению, несмотря на огромную практическую важность, единой и связной теории безопасности вычислительных систем на сей день не разработано. Отчасти это объясняется сложностью и многосторонностью задачи: несанкционированный доступ к данным, например, может быть получен не только путем удаленного "взлома" вычислительной системы, но и посредством физического похищения компьютера или носителей данных, или с помощью подкупа сотрудника организации, имеющего доступ к данным. Следовательно, идеальная система безопасности должна предусматривать средства защиты от всех физически реализуемых способов несанкционированного доступа.
Понимаемая таким образом идеальная система безопасности, по-видимому, физически не решшзуема. В лучшем случае удается исключить отдельные способы, но для идеального решения задачи это нужно сделать по отношению ко всем способам получения несанкционированного доступа. На практике обычно исходят из требований "разумной достаточности" или экономической целесообразности: с одной стороны, стоимость установки и эксплуатации систем безопасности не должна превосходить ценности защищаемых данных. С другой, "экономически идеальная" система безопасности должна быть достаточно сложной, для того чтобы выгоды потенциального взломщика от получения доступа были ниже затрат на преодоление защиты.
Практическое применение этого критерия требует оценки стоимостей и их сравнения. Использование как показателя цен сопряжено с серьезной методологической сложностью, которую вкратце можно описать следующим образом: рыночная цена любого предмета — это цена, по которой обладатели такого предмета, желающие его продать, реально могут его продать, а желающие купить — соответственно, могут купить. В нашем же случае, обладатель защищаемого объекта часто вовсе не намерен его продавать, а те, от кого он защищается, не планируют его покупать. Даже если полный эквивалент защищаемого объекта может быть куплен, требуемая для этого сумма является лишь нулевым приближением для оценки реального ущерба пострадавшего или прибыли взломщика.
И в том случае, когда охраняемый объект — это деньги (например, база данных со счетами клиентов банка), потери от его похищения или нарушения целостности часто значительно превосходят непосредственно потерянную при этом сумму денег. Так, банк, пострадавший от взлома системы управления счетами, теряет не только переведенную на счет взломщика сумму, но и доверие клиентов.
Многие из объектов, с которыми вынуждены иметь дело разработчики систем безопасности, вообще не могут быть куплены. Все примеры конфиденциальных данных, перечисленных в начале этой главы, относятся именно к этой категории. Если охраняемый объект в принципе не покупается и не продается, то его цена попросту не определена (что, впрочем, не означает, что такой объект заведомо нельзя оценить).
Критерием принятия решения в такой ситуации могут быть лишь: субъективное решение владельца охраняемого объекта, сравнивающего стоимости объекта и стоимости охранных систем или те или иные представления о субъективной системе ценностей самого взломщика, оценивающего возможные прибыли и издержки.
Формулируя такие представления, разработчик архитектуры безопасности вынужден принимать во внимание также и людей, для которых вскрытие чужих систем безопасности является самоцелью, своего рода спортом. Обсуждение вопроса о том, можно ли охарактеризовать систему ценностей таких людей как извращенную, уведет нас далеко от темы книги. Для целей дальнейшего обсуждения важно отметить, что классификация этих людей как извращенцев и даже заочная постановка им того или иного мозговедческого диагноза никак не может защитить нас от них самих и результатов их деятельности.
Таким образом, заказчик системы безопасности вынужден принимать решение, в основе которого лежат не поддающиеся строгому обоснованию и, возможно, неверные представления о системах ценностей других людей. Задача, стоящая перед ним, находится в близком родстве с задачей, которую решает предприниматель, пытающийся оценить рыночные перспективы того или иного товара. Как и предприниматель, заказчик системы безопасности в случае принятия неверного решения рискует понести серьезные материальные потери, поэтому принятие решения о структуре и стоимости
системы безопасности вполне можно считать разновидностью предпринимательского решения.
Принятие предпринимательских решений в общем случае является неформализуемой и неалгоритмизуемой задачей. Во всяком случае, для ее формализации и алгоритмизации необходимо, ни много ни мало, создать ПОЛНУЮ универсальную формальную алгоритмическую модель человеческой психики, которая полностью описала бы поведение не только предпринимателя но и всех его потенциатьных деловых партнеров (а в случае системы безопасности — и всех потенциальных взломщиков этой системы). Даже если эта задача в принципе и разрешима, то, во всяком случае, она находится далеко за пределами возможностей современной науки и современных вычислительных систем. Возможно, этот факт и является фундаментальной причиной, по которой теория систем безопасности представляет собой логически несвязное сочетание эмпирических, теоретических или "интуитивно очевидных" рекомендаций разного уровня обоснованности.
Несмотря на все методологические и практические сложности, с которыми сопряжено применение экономической оценки к системам безопасности, по крайней мере, некоторые практически важные рекомендации этот подход нам может дать.
Во-первых, мы можем утверждать, что хорошая система безопасности должна быть сбалансированной, причем прежде всего с точки зрения взломщика: стоимости всех мыслимых путей получения доступа к системе должны быть сопоставимы. И, наоборот, должны быть сопоставимы стоимости мероприятий по обеспечению защиты от проникновения разными способами. Бессмысленно ставить бронированные ворота в сочетании с деревянным забором.
Это соображение, впрочем, ни в коем случае не должно удерживать нас от применения \;ор, которые при небольшой стоимости необычайно затрудняют несанкционированный доступ. Принцип сбалансированности необходимо применять в первую очередь к дорогостоящим, но при этом умеренно эффективным мероприятиям.
Вторая полезная рекомендация — это совет: если это оправданно по стоимостным показаниям, делать системы безопасности многослойными или, используя военную терминологию, эшелонированными. Пройдя внешний слой защиты (например, преодолев брандмауэр и установив прямое соединение с одним из серверов приватной сети компании), взломщик должен получать доступ не непосредственно к данным, а лишь к следующему слою защиты (например, средствам аутентификации ОС или серверного приложения).
Третья рекомендация в известной мере противоречит двум предыдущим и гласит, что учитывая компоненты стоимости эксплуатации системы безопасности, нельзя забывать о тех действиях, которые эта система требует от сотрудников организации. Если требования безопасности чрезмерно обременительны для них, то они могут попытаться осуществить операцию, которую в неоклассической экономике называют экстернализацией издержек (например, выдвинуть ультимативное требование — либо вы снимаете наиболее раздражающие из требований, либо повышаете зарплату, либо мы все уволимся).
Более неприятная для разработчика системы безопасности перспектива — это отказ (не всегда сознательный) от выполнения требований, создающий неконтролируемые дыры в системе безопасности. Например, ключи могут оставляться под половиком, пароли — записываться на прилепленных к монитору бумажках, конфиденциальные данные — копироваться на недостаточно защищенные настольные или переносные компьютеры и т. д.
В дальнейшем в этой главе мы будем обсуждать то подмножество задачи обеспечения безопасности, с которым практически сталкиваются разработчики программного обеспечения и системные администраторы. А именно, в большинстве случаев мы будем предполагать, что помещение, где размещена вычислительная система, защищено от несанкционированного проникновения, а персоналу, который имеет прямой или удаленный доступ к системе, в определенной мере можно доверять. Причина, по которой мы принимаем эти предположения, совершенно прозаична: в большинстве современных организаций за обеспечение такой безопасности отвечает не системный администратор, а совсем другие люди. На практике, хотя системный администратор и не обязан быть по совместительству специалистом в вопросах охраны и подбора персонала, но должен так или иначе взаимодействовать с этими людьми, вырабатывая согласованную и сбалансированную политику защиты организации.
Не следует думать, что эти допущения позволят решить перечисленные задачи идеально или, тем более, что они могут быть решены идеально. Не означают эти предположения также и того, что без разрешения этих задач системный администратор вообще не может приступать к работе — более того, мы рассмотрим и некоторые подходы к решению нашей задачи в ситуации, когда эти предположения выполняются лишь частично или не выполняются совсем. Большинство таких подходов основаны на шифровании и электронной подписи данных.
Даже в такой ограниченной формулировке задача обеспечения безопасности вовсе не проста и требует дополнительной декомпозиции. Задачу управления доступом к данным и операциям над ними разбивают на две основные подзадачи: аутентификацию (проверку, что пользователь системы действительно является тем, за кого себя выдает) и авторизацию (проверку, имеет ли тот, за кого себя выдает пользователь, право выполнять данную операцию).


Сессии и идентификаторы пользователя

Как правило, далеко не каждая авторизация отдельных операций сопровождается актом аутентификации. Чаше всего используется принцип сессий работы с вычислительной системой. В начале работы пользователь устанавливает соединение и "входит" в систему. При "входе" происходит его аутентификация.
Для того чтобы быть аутентифицированным, пользователь должен иметь учетную запись (account) в системной базе данных. Затем пользователь проводит сеанс работы с системой, а по завершении этого сеанса аннулирует регистрацию.
Одним из атрибутов сессии является идентификатор пользователя (user id) или контекст доступа (security context), который и используется при последующих авторизациях. Обычно такой идентификатор имеет две формы: числовой код, применяемый внутри системы, и мнемоническое символьное имя, используемое при общении с пользователем.

Сессии в Unix
Например, в системах семейства Unix пользователь идентифицируется целочисленным значением uid (user identifier). С каждой задачей (процессом) связано два идентификатора пользователя: реальный и эффективный. В большинстве случаев эти идентификаторы совпадают (ситуации, когда они не совпадают, подробно обсуждаются в разд. Изменение идентификатора пользователя). Таким образом, каждая задача обязательно исполняется от имени того или иного пользователя, имеющего учетную запись в системе.
Пользователь может иметь также символьное имя. В старых Unix системах соответствие между символьным и числовым идентификаторами устанавливалось на основе содержимого текстового файла /etc/passwd. Каждая строка этого файла описывает одного пользователя и состоит из семнадцати полей, разделенных символом ':'. В первом поле содержится символьное имя пользователя, во втором — числовой идентификатор в десятичной записи. Остальные поля содержат другие сведения о пользователе, например, его полное имя.
Пользовательские программы могут устанавливать соответствие между числовым и символьным идентификаторами самостоятельно, путем просмотра файла /etc/passwd, или использовать библиотечные функции, определенные стандартом POSIX. Во многих реализациях эти функции используют вместо /etc/passwd индексированную базу данных, а сам файл /etc/passwd сохраняется лишь для совместимости со старыми программами.
В современных системах семейства Unix библиотеки работы со списком пользователей имеют модульную архитектуру и могут использовать различные, в том числе и распределенные по сети базы данных. Интерфейс модуля работы с конкретным типом БД называется РАМ (Person Autentification Module -модуль аутентификации людей) (рис. 12.1).
Нужно отметить, что соответствие между символьным и числовым идентификаторами в Unix не является взаимно однозначным. Одному и тому же числовому идентификатору может соответствовать несколько имен. Кроме того, в Unix разрешено создать объекты с числовым uid, которому не соответствует никакое символьное имя.

Рис. 12.1. РАМ и различные базы учетных записей

Большинство современных ОС позволяют также запускать задания без входа систему и создания сессии. Так, практически все системы разделения времени (Unix, VMS, MVS-OS/390-z/OS) предоставляют возможность пользователям запускать задачи в заданные моменты астрономического времени периодически, например, в час ночи в пятницу каждой недели. Каждая такая задача исполняется от имени определенного пользователя — того, кто запросил запуск задачи. Для управления правами доступа в таких ситуациях идентификатор пользователя ассоциируется не с сессией, а с отдельными заданиями, а обычно даже с отдельными задачами. В Windows NT/2000/XP задачи, которые могут запускаться и работать без входа пользователя в систему, называются сервисами. По умолчанию, сервисы запускаются от имени специального [псевдо|пользователя System, но в свойствах сервиса можно указать, от чьего имени он будет запускаться. Кроме того, некоторые комплектации системы (Terminal Server Edition, Citrix ICA) допускают одновременную интерактивную работу нескольких пользователей. Чтобы обеспечить разделение доступа во всех этих случаях, каждый процесс в системе имеет контекст доступа (security context), соответствующий той или иной учетной записи.


Аутентификация

Петли дверные
Многим скрипят, многим поют:
"Кто вы такие,
Вас здесь не ждут!"
В. Высоцкий

Понятно, что если права доступа выделяются на основе машинного идентификатора пользователя, то возникает отдельная проблема установления соответствия между этим машинным идентификатором и реальным человеком. Такое соответствие не обязано быть взаимно однозначным: один человек может иметь несколько идентификаторов или наоборот, несколько человек могут пользоваться одним идентификатором. Тем не менее, способ установления такого соответствия необходим.
По-английски процесс входа в систему называется login (log in) и происходит от слова log, которое обозначает регистрационный журнал или процесс записи в такой журнал. В обычном английском языке такого слова нет, но в компьютерной лексике слова login и logout прижились очень прочно.
Наиболее точным переводом слова login является регистрация. Соответственно, процесс выхода называется logout. Его точная русскоязычная калька — разрегистрация — к сожалению, очень неблагозвучна.
Теоретически можно придумать много разных способов идентификации, например, с использованием механических или электронных ключей или даже тех или иных биологических параметров, например, рисунка глазного дна. Однако подобные способы требуют специальной и зачастую довольно дорогой аппаратуры. Наиболее широкое распространение получил более простой метод, основанный на символьных паролях.
Пароль представляет собой последовательность символов. Предполагается, что пользователь запоминает ее и никому не сообщает. Этот метод хорош тем, что для его применения не нужно никакого дополнительного оборудования — только клавиатура, которая может использоваться и для других целей. Но этот метод имеет и ряд недостатков.
Использование паролей основано на следующих трех предположениях:

  • пользователь может запомнить пароль;
  • никто не сможет догадаться, какой пароль был выбран;
  • пользователь никому не сообщит свой пароль.

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

Словарная атака в исполнении червя Морриса
Возможности словарной атаки впервые были продемонстрированы в 1987 году молодым тогда студентом Робертом Моррисом [КомпьютерПресс 1991]. Разработанная им программа — "червь Морриса" — использовала словарную атаку и последующий вход в систему с подобранным паролем как один из основных способов размножения. Червь использовал для распространения и другие приемы, продемонстрировавшие не только специфические изъяны тогдашних ОС семейства Unix, но и несколько фундаментальных проблем компьютерной безопасности, поэтому мы еще несколько раз будем возвращаться к обсуждению этой программы.

Червь Морриса использовал при подборе паролей следующие варианты:

  • входное имя пользователя;
  • входное имя с символами в обратном порядке — "задом наперед";
  • компоненты полного имени пользователя — имя (first name), фамилию (last name) и другие элементы, если они есть;
  • те же компоненты, но задом наперед;
  • слова из заранее определенной таблицы, содержащей 400 элементов.

Большая часть успешно подобранных паролей была взята из таблицы. После поимки червя таблица была опубликована с тем, чтобы пользователи не повторяли старых ошибок. Эта таблица приводится в [КомпьютерПресс 1991]. По мнению автора, включение ее в данное издание нецелесообразно, поскольку она рассчитана на англоязычных пользователей и не очень актуальна в России.

С первого взгляда видно, что таких "легко запоминаемых" вариантов довольно много — никак не меньше нескольких сотен. Набор их вручную занял бы очень много времени, но для компьютера несколько сотен вариантов — это доли секунды. Первый слой защиты от подбора пароля заключается именно в том, чтобы увеличить время подбора. Обнаружив неправильный пароль, наученные горьким опытом современные системы делают паузу, прежде чем позволят повторную попытку входа с того же терминального устройства. Такая пауза может длиться всего одну секунду, но даже этого достаточно, чтобы увеличить время подбора пароля от долей секунды до десятков минут.
Второй слой зашиты заключается в том, чтобы усложнить пароль и тем самым увеличить количество вариантов. Даже очень простые усложнения сильно увеличивают перебор. Так, простое требование использовать в пароле буквы и верхнего, и нижнего регистров увеличивает перебор в 2n раз где n — длина пароля. В большинстве современных систем пароль обязан иметь длину не менее шести символов, т. е. количество вариантов увеличивается в 64 раза. Требование использовать в пароле хотя бы один символ, не являющийся буквой, увеличивает число вариантов в 6x43 = 258 раз (в наборе ASCII 43 небуквенных графических символа). Вместо десятков минут подбор пароля, который содержит буквы разных регистров и хотя бы один спецсимвол, займет много дней.
Но если взломщику действительно нужно попасть в систему, он может подождать и несколько дней, поэтому необходим третий слой защиты — ограничение числа попыток. Все современные системы позволяют задать число неудачно набранных паролей, после которого имя блокируется. Это число всегда больше единицы, потому что пользователь — это человек, а людям свойственно ошибаться, но в большинстве случаев такой предел задается не очень большим — обычно 5—7 попыток. Однако, этот метод имеет и оборотную сторону — его можно использовать для блокировки пользователей.
Интересный вариант того же метода заключается в увеличении паузы между последовательными неудачными попытками хотя бы в арифметической прогрессии.
Наконец, последний слой защиты — это оповещение пользователя (а иногда и администратора системы) о неудачных попытках входа. Если пользователь сам только что нажал не ту кнопку, он при входе увидит, что была одна неудачная попытка, и не будет волноваться; однако, если есть сообщения о дополнительных неудачных попытках, время побеспокоиться и разобраться, что же происходит.
Современная техника выбора паролей обеспечивает достаточно высокую для большинства практических целей безопасность. В тех случаях, когда эта безопасность представляется недостаточной — например, если следует всерьез рассматривать опасность принуждения пользователя к раскрытию пароля — можно применять альтернативные методы идентификации, перечисленные ранее, т. е. основанные на механических или электронных ключах или биометрических параметрах. Впрочем, как уже говорилось, такие методы требуют применения специальной аппаратуры.
При использовании паролей возникает отдельная проблема безопасного хранения базы данных со значениями паролей. Как правило, даже администратор системы не может непосредственно получить значения паролей пользователей. Можно привести несколько соображений в пользу такого ограничения.

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

Для обеспечения секретности паролей обычно используют одностороннее шифрование, или хэширование, при котором по зашифрованному значению нельзя восстановить исходное слово. При этом программа аутентификации кодирует введенный пароль и сравнивает полученное значение (хэш) с хранящимся в базе данных. Существует много алгоритмов хэширования, при использовании которых узнать реальное значение пароля можно только путем полного перебора всех возможных вариантов и сравнения зашифрованной строки со значением в базе данных.
В старых системах семейства Unix пароль использовался в качестве ключа шифрования фиксированной строки алгоритмом DES (см. главу 1). Этот алгоритм ограничивает длину ключа 64 битами, соответственно пароли в таких системах могут содержать не более 8 символов. Современные системы используют алгоритм MD5 [RFC 1321], который допускает пароли практически неограниченной длины. Этот алгоритм специально разрабатывался с целью максимального усложнения задачи построения сообщения с заданным значением хэша.
Отцам-основателям Unix такой механизм обеспечения секретности показался настолько надежным, что они даже не стали ограничивать доступ обычных пользователей к хэшам чужих паролей, и выделили для их хранения поле в общедоступном для чтения файле /etc/passwd.
Однако "червь Морриса" продемонстрировал, что для подбора паролей не нужно перебирать все возможные комбинации символов, поскольку пользователи склонны выбирать лишь ограниченное подмножество из них. Процесс, имеющий возможность непосредственно читать закодированные значения паролей, может осуществлять подбор, не обращаясь к системным механизмам аутентификации, т. е. делать это очень быстро и практически незаметно для администратора.
После осознания опасности словарных атак разработчики систем семейства Unix перенесли значения паролей в недоступный для чтения файл /etc/shadow, где пароли также хранятся в односторонне зашифрованном виде. Это значительно усложняет реализацию словарной атаки, поскольку перед тем, как начать атаку, взломщик должен получить доступ к системе с привилегиями администратора. В наше время все способы получения не санкционированного доступа к парольной базе данных считаются "дырами" в системе безопасности.
Практически все современные системы хранят данные о паролях в односторонне зашифрованном виде в файле, недоступном для чтения обычным пользователям. Поставщики некоторых систем, например Windows NT/2000/XP, даже отказываются публиковать информацию о формате этой базы данных, хотя это само по себе вряд ли способно помешать квалифицированному взломщику.


Аутентификация в сети

Когда пользователь работает с консоли компьютера или с терминала, физически прикрепленного к терминальному порту, модель сессий является вполне приемлемой. При регистрации пользователя создается сессия, ассоциированная с данным терминалом, и далее проблем нет. Аналогично нет никаких проблем при подключении через сеть с коммутацией каналов, например, при "дозвоне" через модем, подключенный к телефонной сети (конечно же, при условии, что мы доверяем связистам). Когда соединение разрывается, сессия считается оконченной.
В сетях с коммутацией пакетов, к которым относится большинство современных сетевых протоколов (TCP/IP, IPX/SPX, ISO/OSI и т. д.), вообще нет физического понятия соединения. В лучшем случае сетевой протокол предоставляет возможность создавать виртуальные соединения с "надежной" связью, в которых гарантируется отсутствие потерь пакетов и сохраняется порядок их поступления. С таким виртуальным соединением вполне можно ассоциировать сессию, как это сделано в протоколах telnet, rlogin/rsh и ftp.

Протокол telnet
Протокол telnet используется для эмуляции алфавитно-цифрового терминала через сеть. Пользователь устанавливает соединение и регистрируется в удаленной системе таким же образом, как он регистрировался бы с физически подключенного терминала. Например, в системах семейства Unix создается виртуальное устройство, псевдотерминал (pseudotermina/) /dev/ptyXX, полностью эмулирующее работу физического терминала, и система запускает ту же программу идентификации пользователя /bin/login, которая используется для физических терминалов. При окончании сессии соединение разрывается и псевдотерминальное устройство освобождается.

Виртуальные сессии вынуждены полагаться на то, что сетевой протокол обеспечивает действительно гарантированное соединение, т. е. что никакая посторонняя машина не может вклиниться в соединение и прослушать его идИ послать свои поддельные пакеты. Обеспечение безопасности на этом уровне либо требует доверия к сетевой инфраструктуре физического, канального и сетевого уровней (линиям связи, коммутаторам и маршрутизаторам), либо сводится к использованию шифрования и/или криптографической подписи пакетов.
В протоколах, использующих датаграммные соединения, средствами протокола виртуальную сессию создать невозможно. Обычно в этом случае каждый пакет содержит идентификатор пользователя или сессии, от имени которого (в рамках которой) этот пакет послан. Такой подход применяется в протоколах работы с файловыми серверами NFS и NCP (Netware Core Protocol, используемый файловыми серверами Novell Netware). Датаграммные протоколы оказываются несколько более уязвимы для подделки пакетов и прочих вредоносных воздействий.

Подпись пакетов в NCP
Например, NCP, работающий через датаграммный протокол IPX, реализует свой собственный механизм поддержки сессий: все пакеты данной сессии имеют 8-битный номер, и пакеты с неправильными номерами просто игнорируются. Одна из техник взлома Netware 3.11 (так называемая "Голландская атака") состоит в посылке в течение короткого времени 256 пакетов с различными номерами — хотя бы один пакет окажется удачным. Для борьбы с такими действиями в Netware 3.12 была введена криптографическая подпись пакетов в пределах сессии.

Проблема дополнительно усложняется тем обстоятельством, что в сети зачастую взаимодействие происходит между системами, каждая из которых позволяет одновременную работу нескольких пользователей. Часто возникает желание требовать от пользователя регистрации только в одной из систем, а доступ в остальные системы предоставлять автоматически.
Обычно для автоматической регистрации используется модель доверяемых систем (trusted hosts). Если система В доверяет системе А, то все пользователи, зарегистрированные на системе А, автоматически получают доступ к системе В под теми же именами (рис. 12.2). Иногда аналогичную возможность можно предоставлять каждому пользователю отдельно — он сообщает, что при регистрации входа из системы А пароля запрашивать не надо. Разновидностью модели доверяемых систем можно считать единую базу учетных записей, разделяемую между машинами.

Рис. 12.2. Доверяемые системы

Межмашинное доверие в rlogin/rsh
Протоколы rlogin/rsh, обеспечивающие запуск отдельных команд или командного процессора на удаленной системе, используют файл /etc/hosts.equiv или .rhosts в домашнем каталоге пользователя на удаленной системе. Файл /etc/hosts.equiv содержит имена всех машин, которым наша система полностью доверяет. Файл .rhosts состоит из строк формата
имя.удаленной.машины имя пользователя
При этом имя.удаленной.машины не может быть произвольным, оно обязано содержаться в файле /etc/hosts, в котором собраны имена и адреса всех удаленных машин, "известных" системе. То же требование обязательно и для машин, перечисленных в /etc/hosts.equiv.
Например, пользователь fat на машине iceman.cnit.nsu.ru набирает команду
rlogin -I fat Indy.cnit.nsu.ru
— войти в систему lndy.cnit.nsu.ru под именем fat. Если домашний каталог пользователя fat на целевой машине содержит файл .rhosts, в котором есть строка iceman.cnit.nsu.ru fat, то пользователь fat получит доступ к системе Indy без набора пароля. Того же эффекта можно добиться для всех одноименных пользователей, если /etc/hosts.equiv содержит строку ice man.cnit.nsu.ru
Если же fat наберет команду
rlogin -1 root Indy.cnit.nsu.ru,
а в домашнем каталоге пользователя root файла .rhosts нет или он не содержит вышеприведенной строки, команда rlogin потребует ввода пароля, независимо от содержимого файла /etc/hosts.equiv. Нужно отметить, что администраторы обычно вообще не разрешают использовать rlogin для входа под именем root, потому что этот пользователь является администратором системы и обладает очень большими привилегиями.

Модель доверяемых систем обеспечивает большое удобство для пользователей и администраторов и в различных формах предоставляется многими сетевыми ОС. Например, в протоколе разделения файлов SMB. применяемом в системах семейства СР/М, Linux и др., используется своеобразная модель аутентификации, которую можно рассматривать как специфический случай доверяемых систем.

Аутентификация SMB
Аутентификация в SMB основана на понятии домена (domain). Каждый разделяемый ресурс (каталог, принтер и т. д.) принадлежит к определенному домену, хотя и может быть защищен собственным паролем. При доступе к каждому новому ресурсу необходимо подтвердить имя пользователя и пароль, после чего создается сессия, связанная с этим ресурсом. Для создания сессии используется надежное соединение, предоставляемое транспортным протоколом, — именованная труба NetBEUI или сокет TCP. Ввод пароля при каждом доступе неудобен для пользователя, поэтому большинство клиентов— просто запоминают пароль, введенный при регистрации в домене, и при подключении к ресурсу первым делом пробуют его. Благодаря этому удается создать у пользователя иллюзию однократной регистрации. Кроме того, если сессия по каким-то причинам оказалась разорвана, например из-за перезагрузки сервера, то можно реализовать прозрачное для пользователя восстановление этой сессии.
С точки зрения клиента нет смысла говорить о межмашинном доверии — клиенту в среде SMB никто не доверяет и вполне справедливо: обычно это система класса ДОС, не заслуживающая доверия. Однако серверы обычно передоверяют проверку пароля и идентификацию пользователя выделенной машине, называемой контроллером домена (domain controller). Домен обязан иметь один основной (primary) контроллер и может иметь несколько резервных (backup), каждый из которых хранит реплики (периодически синхронизуемые копии) базы учетных записей. При поступлении запроса на соединение сервер получает у клиента имя пользователя и пароль, но вместо сверки с собственной базой данных он пересылает их контроллеру домена и принимает решение о принятии или отказе в аутентификации на основании вердикта, вынесенного контроллером.
Только контроллеры домена хранят у себя базу данных о пользователях и паролях. При этом основной контроллер хранит основную копию базы, а резервные серверы — ее дубликаты, используемые лишь в тех случаях, когда основной сервер выключен или потерян. Благодаря тому, что все данные собраны в одном месте, можно централизованно управлять доступом ко многим серверам, поэтому домены представляют неоценимые преимущества при организации больших многосерверных сетей.

С точки зрения безопасности доверяемые системы имеют два серьезных недостатка.

  1. 1. Прорыв безопасности на одной из систем означает, по существу, прорыв на всех системах, которые доверяют первой (рис. 12.3).
  2. 2. Возникает дополнительный тип атаки на систему безопасности: машина, которая выдает себя за доверяемую, но не является таковой (рис. 12.4).

Рис. 12.3. Прорыв безопасности в сети с доверяемыми системами

12.4. Имитация доверяемой системы

Первая проблема является практически неизбежной платой за разрешение автоматической регистрации. Наиболее ярко эта проблема была проиллюстрирована упомянутым выше "Червем Морриса" (КомпьютерПресс 1991], который, проникнув в одну из систем, использовал содержимое файлов .rhosts и /etc/hosts.equiv для проникновения в доверяющие системы, полагаясь на то, что межсистемное доверие обычно делают взаимным. Поэтому в средах с высокими требованиями к безопасности часто стремятся ограничить число доверяемых систем, подобно тому, как отсеки кораблей разделяют водонепроницаемыми переборками.
Вторая проблема может быть отчасти решена использованием средств, пре-достаапяемых сетевыми протоколами, например, привязкой всех логических имен доверяемых систем к их сетевым адресам канального уровня. В протоколах TCP/IP это может быть сделано с использованием протокола аrр (Address Resolution Protocol — протокол разрешения адресов), однако надежда на это слаба: многие сетевые карты имеют настраиваемые адреса, а многие реализации сетевых протоколов допускают отправку пакетов с поддельным адресом отправителя.
Более изощренный и намного более надежный метод основан на использовании алгоритма двухключевого шифрования RSA или родственных ему механизмов.


Криптографические методы аутентификации

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

  • Система А генерирует последовательность байтов, обычно случайную, кодирует ее своим ключом и посылает системе В.
  • Система В раскодирует ее своим ключом. Это возможно, только если системы владеют парными ключами.
  • Системы тем или иным способом обмениваются "правильными" значениями зашифрованной посылки.

Аутентификация SSH
Для примера рассмотрим принцип RSA-аутентификации в пакете ssh [www.cs.hut.fi SSH] — Secure Shell. Пакет представляет собой функциональную замену программ rlogin/rsh и соответствующего этим программам демона rshd. В пакет входят программы ssh (клиент) и sshd (сервер), а также утилиты для генерации ключей RSA и управления ими. ssh использует RSA для прозрачной аутентификации пользователя при входе в удаленную систему. Кроме того, ssh/sshd могут осуществлять шифрование данных, передаваемых по линии во время сеанса связи и выполнять ряд других полезных функций.
Сервер хранит список известных общедоступных ключей для каждого из пользователей в файле SHOME/.ssh/authorized_keys, где $НОМЕ обозначает домашний каталог пользователя. Файл состоит из строк формата host_name: key— по строке для каждого из разрешенных клиентов. В свою очередь, каждый клиент хранит в файле $HOME/.ssh/private_key свой приватный ключ.
Когда из удаленной системы-клиента приходит запрос на аутентификацию, sshd запрашивает публичный ключ. Если полученный ключ совпадает с хранящимся в файле значением для этой системы, сервер генерирует случайную последовательность из 256 бит, шифрует ее публичным ключом и посылает клиенту. Клиент расшифровывает посылку своим личным ключом, вычисляет 128-битовую контрольную сумму и возвращают ее серверу. Сервер сравнивает полученную последовательность с правильной контрольной суммой и принимает аутентификацию в случае совпадения (рис. 12.5). Теоретически контрольные суммы могут совпасть и в случае несовпадения ключей, но вероятность такого события крайне мала.

Рис. 12.5. Аутентификация SSH

Аутентификация Lotus Notes
Система групповой работы Lotus Notes также использует для аутентификации открытый ключ. При создании учетной записи пользователя генерируются 621-битный приватный и соответствующий ему публичный ключи. Публичный ключ размещается в доменной адресной книге (рис. 12.6). Приватный ключ подвергается шифрованию закрытым ключом, который потом запрашивается у пользователя в качестве пароля, и сохраняется в идентификационном файле.

Рис. 12.6. Аутентификация в Lotus Notes

Чтобы аутентифицироваться в системе, пользователь должен указать идентификационный файл и набрать пароль, который позволит расшифровать хранящийся в файле приватный ключ. После этого все пакеты, которыми пользователь обменивается с сервером Notes, снабжаются цифровой подписью на основе этого ключа. Сервер может проверить аутентичность подписи, используя хранящийся в его адресной книге публичный ключ.
Более сложная ситуация возникает, когда пользователь должен аутентифицироваться в другом домене, в адресных книгах которого он не числится. Чтобы сделать такую авторизацию возможной, Notes вводит еще одно понятие: сертификат домена. Этот сертификат также представляет собой пару ключей, пароль к приватному ключу которой известен только администраторам домена. Каждый идентификационный файл, создаваемый в домене, подписывается приватным ключом этого сертификата (рис. 12.7).
Регистрируясь в чужом домене, пользователь предъявляет свои имя и публичный ключ, подписанные сертификатом своего домена. Если принимающий домен не знает такого сертификата, аутентификация отвергается. Чтобы домен мог признать чужой сертификат, его администратор должен провести кросс-сертификацию, а попросту говоря создать в доменной адресной книге документ, в котором хранится публичный ключ сертификата домена [redbooks.ibm.com sg245341] (рис. 12.8).

Рис. 12.7. Сертификат домена Lotus Notes

Рис. 12.8. Кросс-сертификация между доменами Lotus Notes

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


Авторизация

Иннокентий на кухне, он пьет молоко
Таракана завидев во мраке
На душе его чисто, светло и легко
Он не хочет впускать Полтораки
Б. Гребенщиков
Механизмы авторизации в различных ОС и прикладных системах различны, но их трудно назвать разнообразными. Два основных подхода к авторизации — это ACL (Access Control List, список управления доступом) или список контроля доступа и полномочия (capability).
Список контроля доступа ассоциируется с объектом или группой объектов и представлет собой таблицу, строки которой соответствуют учетным записям пользователей, а столбцы — отдельным операциям, которые можно осуществить над объектом. Перед выполнением операции система ищет идентификатор пользователя в таблице и проверяет, указана ли выполняемая операция в списке его прав.
Реализация списков управления доступом вполне прямолинейна и не представляет непреодолимых сложностей. Разработчики системы безопасности, впрочем, могут (и часто бывают вынуждены) предпринимать достаточно сложные меры для сокращения ACL, предлагая те или иные явные и неявные способы объединения пользователей и защищаемых объектов в группы.
Полномочие представляет собой абстрактный объект, наличие которого в контексте доступа задачи позволяет выполнять ту или иную операцию над защищаемым объектом или классом объектов, а отсутствие — соответственно,
не позволяет. При реализации такой системы разработчик должен гарантировать, что пользователь не сможет самостоятельно сформировать полномочие.
Например, полномочие может быть реализовано в виде ключа шифрования или электронной подписи. Невозможность формирования таких полномочий обеспечивается непомерными вычислительными затратами, которые нужны для подбора ключа.


Списки контроля доступа

— Что это?— вытянул шею Гмык, хмуро глядя на мои карты. — Но тут ,же только.
— Минутку, — вмешался игрок слева от него. — Сегодня вторник. Выходит, его единороги дикие.
— Но в названии месяца есть "М"— вякнул еще кто-то. — Значит, его великан идет за половину номинальной стоимости!
— Но у нас четное число игроков...
— Вы все кое-что упускаете. Эта партия — сорок третья, а Скив сидит на стуле лицом к северу!
Приняв за указание стоны и все заметнее вырисовывавшееся выражение отвращения на лицах, я сгреб банк.
Р. Асприн

В общем случае совокупность всех ACL в системе представляет собой трехмерную матрицу, строки которой соответствуют пользователям, столбцы -операциям над объектами, а слои — самим объектам. С ростом количества объектов и пользователей в системе объем этой матрицы быстро растет, поэтому, как уже говорилось, разработчики реальных систем контроля доступа предпринимают те или иные меры для более компактного представления матрицы.
Хотя ухищрения для сокращения ACL дают определенный эффект, и в большинстве случаев список имеет всего лишь несколько записей (рис. 12.9), наложение ограничений на его размер часто считают неприемлемым или устанавливают такие ограничения весьма высокими, например в несколько тысяч записей. Это приводит к тому, что с файлом в ФС, поддерживающих списки контроля доступа, кроме основного массива данных, оказывается связан еще один массив, обычно уступающий в размерах основному, но, в принципе, способный достигать очень большого объема.
Впрочем, соответствующее усложнение файловой системы не так уж велико. Многие ФС позволяют хранить в дополнительных блоках данных не только записи ACL, но и другие сущности — расширенные атрибуты, ресурсную ветвь и т. д.

Рис. 12.9. Список контроля доступа

Есть три основных подхода, используемых для сокращения ACL.

  • Использование прав по умолчанию
  • Группирование пользователей и/или объектов
  • Ограничение комбинаций прав, которыми пользователи и группы могут реально обладать

Права по умолчанию дают наибольший эффект тогда, когда большая часть прав большинства пользователей на большинство объектов одинакова. Чаще всего рекомендуют при установлении прав исходить из принципа "запрещено все, что не разрешено [явным образом]" — при последовательном его применении должно получаться, что большинство пользователей не имеет прав на подавляющую часть объектов в системе. Исходя из этого, в большинстве систем пользователи, явно или неявно не перечисленные в ACL объекта, не имеют на объект никаких прав. Многие системы также предоставляют специальную запись в ACL, соответствующую пользователям, которые не перечислены явно.
Объединение пользователей в группы представляет собой более универсальное средство, которое полезно не только для сокращения ACL, но и для других целей. Группа вводит дополнительный уровень косвенности (ACL ссылается на пользователя не прямо, а косвенно, через группу в систему установления прав и управления ими, и это дает дополнительную гибкость, полезную во многих практических случаях (рис. 12.10).

Рис. 12.10. Группы пользователей

Так, в большинстве случаев полномочиями наделяют человека не за то, что он такой хороший, а потому, что они нужны ему для выполнения служебных обязанностей. Изменение служебных обязанностей (постоянное, при переходе на другую должность, или временное, например, при замене заболевшего или ушедшего в отпуск сотрудника) сопровождается изменением прав, которыми пользователь должен обладать.
В этой ситуации целесообразно создать группу, соответствующую той или иной должности, и выдавать права на объекты этой группе. Назначение человека на должность сопровождается включением его в соответствующую группу, а снятие — исключением из нее. Без использования групп эти операции потребовати бы явной модификации ACL всех объектов, права на которые изменяются, что во многих случаях совершенно нерационально.
Группы яшшотся практически обязательным элементом систем упраатения правами на основе списков. Большинство систем предоставляет возможность создания вложенных групп (рис. 12.11). Ряд современных систем (Novell Netware 4..Y, Windows 2000, Solans 6) предоставляют также иерархические структуры БД учетных записей, почему-то называемые службами каталогов (NDS -Netware Directory Service в Netware, Active Directory в Windows, NIS+ - - Network Information Service в Solans). Впервые служба каталогов была реализована в сетевой ОС VINES (Virtual NEtwork Services) фирмы Banyan Systems в конце 80-х.

Рис. 12.11. Вложенные группы и структура организации

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

Рис. 12.12. Получение прав из нескольких групп

Чаще всего права, полученные разными путями, просто складываются. Бывают системы, в которых отдельные права тем или иным образом ранжируются, и пользователь получает список прав, соответствующий той записи в ACL, которая содержит наивысшее право. Очень часто некоторые записи в ACL обладают особым статусом. Так, если человек имеет явную (соответствующую его пользовательскому идентификатору) запись в ACL, записанные в ней права оказываются "сильнее" всех прав, которые он получает как член группы. Запись с правами по умолчанию часто рассматривается как более "слабая", чем явные и групповые записи, и при наличии у пользователя прав, полученных из записей по умолчанию, вообще игнорируется.
Каждое из этих правил по отдельности обычно Преследует цель облегчить формирование списков, предоставляющих требуемые комбинации прав, но в результате полное описание семантики ACL многих распространенных ОС напоминает вынесенные в эпиграф фрагменты правил игры в "драконий покер".
Группирование объектов используется несколько реже, но также является мощным средством управления правами и сокращения общего объема ACL в системе. Для файловых систем естественным средством группирования является иерархия каталогов.

Наследование прав на файлы в Novell Netware
По-видимому, наибольшей сложности группирование объектов достигло в системе Novell Netware. Рассмотрим схему установления прав на файлы в этой ОС.
Запись файлового ACL в Netware представляет собой битовую маску, значения разрядов которой перечислены в табл. 12.1. Видно, что некоторые из прав имеют смысл только для файлов, а некоторые — только для каталогов.
Каталоги и файлы в этой системе наследуют права доступа от родительских каталогов. Если пользователь или группа не перечислены явно в ACL объекта, их эффективные права будут определяться записями в ACL родительских каталогов (рис. 12.13). Если пользователь перечислен в ACL родительского и дочернего каталогов, его эффективные права будут равны сумме прав, указанных в обеих записях. Таким образом, по мере спуска по дереву каталогов, эффективные права могут только возрастать.

Рис. 12.13. Наследование прав на каталоги в Novell Netware (обозначения прав соответствуют табл. 12.1)

При этом пользователь не обязан иметь права на каталог, чтобы видеть его файлы и подкаталоги. Поэтому система управления доступом Netware предполагает выдачу прав как можно ближе по дереву каталогов к тем файлам, права на которые необходимы. Права на корневые каталоги томов обычно выдаются только администратору системы.
В случае если все-таки потребуется изменить принцип расширения прав по мере спуска по дереву, каталоги и файлы, кроме ACL, имеют дополнительный атрибут, называемый IRF (Inherited Rights Filter— фильтр наследуемых прав). Этот атрибут представляет собой битовую маску, биты которой (кроме бита S — он не может быть отфильтрован) соответствуют битам записи ACL (см. табл. 12.1). Установка бита в этой маске приводит к блокировке наследования соответствующего права (рис. 12.14).

Рис. 12.14. Фильтр наследуемых прав в Novell Netware

Запрет на фильтрацию права супервизора обусловлен тем, что его включение по ошибке приведет к потере администратором прав на эту иерархию. Избавиться от такого поддерева можно было бы только переразметкой тома.
В пользовательской базе данных, которая, начиная с Netware 4.x, также имеет иерархическую структуру, и наследование, блокирование супервизорских прав разрешено, поэтому можно по ошибке "даровать суверенитет" ветви дерева (рис. 12.15). Это одна из распространенных ошибок начинающих администраторов. В административных утилитах Netware 4.11 даже была введена специальная проверка, не позволяющая отфильтровать право супервизора, если ни у кого нет явно выданных супервизорских прав на соответствующий контейнер или объект.

Рис. 12.15. Дарование суверенитета ветви дерева каталогов

Таблица 12.1. Права доступа к файлу в Novell Netware 3.x и выше

Бит | Обозначение | Описание
S | Supervisor | Право осуществлять любые операции над файлом или каталогом
А | Access | Право модифицировать ACL файла или каталога
R | Read | Право читать файл
С | Create | Право создавать файлы в каталоге
W | Write | Право записи в файл
Е | Erase | Право удалять файл или каталог
М | Modify | Право изменять атрибуты файла или каталога
F | Find | Право на поиск файлов в каталоге

Альтернативой этим хитростям является третий из упомянутых путей -ограничение комбинаций прав, которые реально могут быть выданы. С одним из примеров такого ограничения мы сталкивались в разд. Сегменты, страницы и системные вызовы : диспетчер памяти VAX имеет четыре уровня привилегий, каждый из которых может иметь право чтения и записи в страницу памяти. Все возможные комбинации прав в этих условиях кодируются 8-ю битами, но наложение требования о том, что каждый более высокий уровень обязан иметь хотя бы те же права, что и более низкие, позволяет нам обойтись 15-ю допустимыми комбинациями и 4-мя битами для их кодирования.
При разработке такой системы мы сталкиваемся с нетривиальной задачей: нам нужно выработать такие ограничения, которые не только обеспечивали бы компактное представление ACL, но и позволяли так или иначе реализовать комбинации прав, необходимые для практической эксплуатации вычислительных систем.
Замечательным примером ограниченного ACL, структура которого выдержала 30-летнюю проверку практикой, является модель безопасности в системах семейства Unix.

Авторизация в Unix
В этих системах ACL состоит ровно из трех записей (рис. 12.16).
Права хозяина файла (пользователя)
Права группы
Права по умолчанию

Рис. 12.16. Установление прав в системах семейства Unix

Как правило, права хозяина выше прав группы, а права группы выше прав по умолчанию, но это не является обязательным требованием и никем специально не проверяется. Пользователь может принадлежать к нескольким группам одновременно, файл всегда принадлежит только одной группе.
Бывают три права: чтения, записи и исполнения. Для каталога право исполнения означает право на открытие файлов в этом каталоге. Каждое из прав обозначается битом в маске прав доступа, т. е. все три группы прав представляются девятью битами или тремя восьмеричными цифрами.
Права на удаление или переименование файла не существует; вообще, в Unix не определена операция удаления файла как таковая, а существует лишь операция удаления имени unlink. Это связано с тем, что файл в Unix может иметь несколько имен, и собственно удаление происходит только при уничтожении последнего имени (подробнее см. главу 11). Для удаления, изменения или создания нового имени файла достаточно иметь право записи в каталог, в котором это имя содержится.
Удаление файлов из каталога, в действительности, можно в определенных пределах контролировать: установка дополнительного бита (маска прав содержит не девять, а двенадцать бит, с семантикой еще двух из них мы познакомимся в разд. Изменение идентификатора пользователя) запрещает удаление из каталога чужих файлов. Обладатель права записи в такой каталог может создавать в нем файлы и удалять их, но только до тех пор, пока они принадлежат ему.
Кроме прав, перечисленных в маске, хозяину файла разрешается изменять права на файл: модифицировать маску прав и передавать файл другой группе и, если это необходимо, другому пользователю (в системах с дисковыми квотами передавать файлы обычно запрещают).
Еще один обладатель прав на файл, не указанный явно в его ACL, — это администратор системы, пользователь с идентификатором, равным 0. Этот пользователь традиционно имеет символическое имя root. Полномочия его по отношению к файлам, другим объектам и системе в целом правильнее описать даже не как обладание всеми правами, а как возможность делать с представленными в системе объектами что угодно, не обращая внимания на права.
В традиционных системах семейства Unix все глобальные объекты — внешние устройства и именованные программные каналы — являются файлами (точнее, имеют имена в файловой системе) и управление доступом к ним выполняется файловым механизмом. В современных версиях Unix адресные пространства исполняющихся процессов также доступны как файлы в специальной файловой (или псевдофайловой, если угодно) системе ргос. Файлы в этой ФС могут быть использованы, например, отладчиками для доступа к коду и данным отлаживаемой программы (рис. 12.17). Управление таким доступом также осуществляется стандартным файловым механизмом.
В Unix System V появились объекты, не являющиеся файлами и идентифицируемые численными ключами доступа вместо имен, а именно средства межпроцессного взаимодействия: это семафоры, очереди сообщений и сегменты разделяемой памяти. Каждый такой объект имеет маску прав доступа, аналогичную файловой, и доступ к нему контролируется точно так же, как и к файлам.

Основное преимущество этого подхода состоит в его простоте. Фактически это наиболее простая из систем привилегий, пригодная для практического применения. Иными словами, более простые и ограниченные системы установления привилегий, по-видимому, непригодны вообще.

Рис. 12.17. Дерево каталогов Unix

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


Полномочия

Ты пришли ко мне утром, ты cела на кровать
Ты спросила, есть ли у меня разрешение
дышать
И действителен ли мой пропуск
Чтобы выйти в кино
Б. Гребенщиков

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

Рис. 12.18. Хранение полномочий в системном адресном пространстве

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

Аутентификация в Unix
В системах семейства Unix процессы с эффективным пользовательским идентификатором, равным 0, имеют право исполнять системные вызовы setuid и setgid и, таким образом, становиться другими пользователями. Другие пользователи не имеют такой возможности, поэтому все процессы, в той или иной форме, осуществляющие аутентификацию и смену идентичности, как с применением стандартных системных средств (файлов /etc/passwd и /etc/shadow в старых системах и разделяемых библиотек РАМ в современных), так и самостоятельно, обязаны исполняться от имени этого пользователя.
Поскольку пользователь с идентификатором 0 (root) может приобретать идентичность других пользователей, он, как уже говорилось, фактически обладает всеми правами, которые есть у кого бы то ни было другого в системе. Признав этот факт, разработчики Unix явным образом дали пользователю root вообще все права, в том числе и такие, которых не имеет никто другой.
Мы уже отмечали в разд. Списки контроля доступа, что root может производить любые операции над файлами, безотносительно к тому, кому они принадлежат и какие права у них установлены, root также имеет возможность перезагружать систему, в ОС с динамической загрузкой модулей ядра — загружать, выгружать и перенастраивать эти модули, запускать процессы с классом планирования реального времени, посылать сигналы чужим процессам (простые смертные могут это делать только со своими процессами) и выполнять множество других операций, недоступных другим пользователям. Фактически, все действия, так или иначе затрагивающие систему в целом, являются исключительной прерогативой пользователя root [Хевиленд/Грэй/Салама 2000].
В небольших и средних организациях это не представляет проблемы, потому что все функции, требующие привилегий root, — резервное копирование и восстановление данных, регистрация пользователей и управление их полномочиями и собственно настройка системы исполняются одним человеком или командой более или менее взаимозаменяемых людей, должность которых и называется системный администратор.
В крупных организациях перечисленные функции могут исполняться разными людьми: администратором учетных записей (account manager), администратором данных (data administrator) (или администратором резервных копий) и собственно системным администратором (рис. 12.19).
Механизм, предоставляемый системами семейства Unix, который может быть использован для реализации такого разделения полномочий, будет обсуждаться в разд. Изменение идентификатора пользователя. Несколько забегая вперед, скажем, что решение состоит в том, чтобы разрешить администраторам учетных записей и данных запуск с полномочиями root только определенных программ (например, утилит управления пользователями и резервного копирования), но не запуск произвольных программ и не выполнение произвольных действий.

Рис. 12.19. Разделение полномочий администраторов системы

Многие поставщики коммерческих Unix-систем предоставляют комплектации ОС с повышенным уровнем безопасности, содержащие средства для упомянутого выше разделения полномочий между администраторами. Аутентификация для полноправного входа в систему с именем root в таких системах обычно предполагает ввод нескольких паролей, которые, по замыслу, должны быть известны разным людям, так что для выполнения всех действий, требующих полных административных полномочий, необходимо собирать комиссию., Как правило, такая комиссия состоит из одного или нескольких технических специалистов и одного или нескольких представителей руководства компании.
ОС в такой комплектации применяются на центральных серверах крупных компаний, в банковских системах и других приложениях, где ведется работа с ценными и высоко конфиденциальными данными.

Многие ОС предоставляют и полномочия доступа к отдельным объектам — для того, чтобы не проверять ACL объекта при каждой операции. Так, в системах семейства Unix права доступа к файлам проверяются только в момент открытия. При открытии необходимо указать желаемый режим доступа к файлу: только чтение, только запись или чтение/запись. После этого пользователь получает "ручку" - - индекс дескриптора открытого файла в системных таблицах.
Ручка представляет собой целое число и не имеет смысла в отрыве от соответствующего ей дескриптора, зато дескриптор является типичным полномочием: он недоступен пользовательскому коду непосредственно, потому что находится в системном адресном пространстве. Дескриптор может быть сформирован только системным вызовом open и допускает только те операции над файлом, которые были запрошены при открытии. Во время выполнения операций проверка прав доступа не производится (хотя, конечно, проверяется их физическая выполнимость: наличие места на устройстве и т. д.), так что если мы изменим ACL файла, это никакие повлияет на права процессов, открывших файл до этой модификации.


Изменение идентификатора пользователя

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

Авторизация доступа к полям документа в Notes
Программный комплекс Lotus Notes, широко признанный как лучшая основа для реализации систем документооборота, позволяет устанавливать права на отдельные поля или группы полей документа, а стандартная техника реализации вышеприведенных требований состоит в том, чтобы сначала предоставить пользователю право записи в поле, где должна быть подпись, а потом — после подписания — это право тут же снять. Впрочем, при реализации сложных циклов документооборота, включающих много взаимосвязанных документов, нередко возникают ситуации, неразрешимые с помощью только этих средств [Керн/Линд 2000].

Можно привести и другой пример, более тесно связанный с темой книги: для изменения информации о пользователе необходим доступ для записи в соответствующую базу данных, но не во всю, а только в определенную запись. Вполне естественно и даже необходимо дать пользователю возможность менять пароль, не обращаясь к администратору. С другой стороны, совершенно недопустимо, чтобы один пользователь, не являясь администратором учетных записей, мог сменить пароль другому.
Одним из решений было бы хранение пароля для каждого из пользователей в отдельном файле, но это во многих отношениях неудобно. Другое решение может состоять в использовании модели клиент-сервер с процессом-сервером, исполняющимся с привилегиями администратора, который является единственным средством доступа к паролям. Например, в Win32 весь доступ к пользовательской базе данных осуществляется через системные вызовы, т. е. функции процесса-сервера исполняет само ядро системы.
В системах семейства Unix для этой цели был предложен оригинальный механизм, известный как setuid (setting of user id - установка [эффективного] идентификатора пользователя). По легенде, идея этого механизма возникла именно при решении задачи о том, как же пользователи смогут менять свои пароли, если их хэши будут храниться в одном файле.

Установка идентификатора пользователя в Unix
В Unix каждая задача имеет два пользовательских идентификатора: реальный и эффективный. Реальный идентификатор обычно совпадает с идентификатором пользователя, запустившего задание. Для проверки прав доступа к файлам и другим объектам, однако, используется эффективный идентификатор. При запуске обычных задач реальный и эффективный идентификаторы совпадают. Несовпадение может возникнуть при запуске программы с установленным признаком setuid. При этом эффективный идентификатор для задачи устанавливается равным идентификатору хозяина файла, содержащего программу.
Признак setuid является атрибутом файла, хранящего загрузочный модуль программы. Только хозяин может установить этот признак, таким образом, передавая другим пользователям право исполнять эту программу от своего имени. При модификации файла или при передаче его другому пользователю признак setuid автоматически сбрасывается.
Например, программа /bin/passwd, вызываемая для смены пароля, принадлежит пользователю root, и у нее установлен признак setuid. Любой пользователь, запустивший эту программу, получает задачу, имеющую право модификации пользовательской базы данных, — файлов /etc/passwd и /etc/shadow. Однако, прежде чем произвести модификацию, программа passwd проверяет допустимость модификации. Например, при смене пароля она требует ввести
старый пароль (рис. 12.20). Сменить пароль пользователю, который его забыл, может только root.
Другим примером setuid-программы может служить программа /bin/ps (process status — состояние процессов) в старых системах. До установления стандарта на псевдофайловую систему /proc, Unix не предоставляла штатных средств для получения статистики об исполняющихся процессах. Программа /bin/ps в таких системах анализировала виртуальную память ядра системы, доступную как файл устройства /dev/kmem, находила в ней список процессов и выводила содержащиеся в списке данные. Естественно, только привилегированная программа может осуществлять доступ к /dev/kmem.

Рис. 12.20. Смена идентификатора пользователя в Unix

Механизм setuid был изобретен одним из отцов-основателей Unix, Деннисом Ритчи, и запатентован компанией AT&T в 1975 г. Через несколько месяцев после получения патента, ему был дан статус public domain. Официально AT&T объяснила это тем, что плату за использование данного патента нецелесообразно делать высокой, а сбор небольшой платы с большого числа пользователей неудобен и тоже нецелесообразен.
Механизм setuid позволяет выдавать привилегии, доступные только суперпользователю, как отдельным пользователям (при этом setuid-программа должна явным образом проверять реальный идентификатор пользователя и сравнивать его с собственной базой данных), так и группам (при этом достаточно передать setuid-программу соответствующей группе и дать ей право исполнения на эту программу), таким образом, отчасти компенсируя недостаточную гибкость стандартной системы прав доступа и привилегий в системе Unix.

Серверные агенты в Notes
До совершенства механизм setuid доведен в Lotus Notes (этот пакет относится к прикладным программам, а не операционным системам, но реализует собственные схемы аутентификации и авторизации). В этой системе все объекты базы данных, в том числе и агенты (хранимые процедуры), снабжены электронной подписью. Серверные агенты исполняются не от имени пользователя, инициировавшего операцию, а от имени того, кем агент подписан. В данном случае подпись кода означает, что либо пользователь сам занимался разработкой агента, либо он явным образом согласился взять на себя ответственность за результаты его работы. Подпись подделать практически невозможно (используется алгоритм RSA с 631-битным ключом) [redbooks.ibm.com sg245341].


Ресурсные квоты

Мне больно видеть белый свет,
Мне лучше в полной темноте
Я очень много-много лет
Мечтаю только о ede
A. Kнязев

Все ресурсы, управление которыми осуществляет операционная система, -дисковое пространство, оперативная память, время центрального процессора, пропускная способность внешних соединений и т. д. — с одной стороны, конечны и, таким образом, исчерпаемы, а с другой — стоят денег.
Необоснованное расходование того или иного ресурса пользователем может привести к его исчерпанию и лишению других пользователей системы доступа к нему. Если ресурс необходим для работы системы, в результате последняя станет неработоспособной. Такое расходование может происходить как просто по ошибке, так и в рамках целенаправленной атаки на вычислительную систему. Промежуточное положение между этими случаями занимает использование корпоративных вычислительнВ1х ресурсов в личных целях: даже если политика компании и допускает это, те или иные пределы такому использованию необходимо установить именно в силу ограниченности ресурса.
Стандартным способом предотвратить исчерпание ресурсов в масштабах системы является введение квот (quota) на эти ресурсы: ограничений количества ресурса, которое конкретный пользователь может занять. Иногда квоты выделяются не отдельным пользователям, а группам, но — из-за возникающей при этом проблемы разрешения ресурсных конфликтов между пользователями группы -- это делается очень редко. Практически всегда квота выделяется отдельному пользователю.
Чаще всего квотированию подвергается дисковое пространство. В системах семейства Unix, в которых большинство файловых систем используют статические таблицы инодов, квотированию подлежит также количество файлов, которые могут принадлежать данному пользователю.
В системах коллективного пользования квоты устанавливаются практически на все ресурсы: объем адресного пространства и рабочего множества страниц отдельной пользовательской задачи, количество одновременно запущенных процессов, время исполнения отдельной задачи или суммарное занятое время центрального процессора и т. д. Объясняется это тем, что любой ресурс, который пользователь может занимать неограниченно, представляет собой потенциальную точку атаки на безопасность системы. На практике квоты многих ресурсов могут устанавливаться весьма высокими, так что пользователь при разумном применении системы может никогда с ними не столкнуться.
Особую роль квоты играют в вычислительных системах, совместно используемых несколькими организациями, — в этом случае квоты ресурсов, выделяемых пользователям соответствующих организаций, могут быть пропорциональны сумме денег, которые организация вносит за пользование системой. Предельным случаем систем такого рода являются ISP (Internet Service Providers, поставщики услуг Интернет), которые предоставляют ресурсы (внешние каналы и телефонные линии, дисковое пространство для почтовых ящиков и домашних страниц) мелким организациям и индивидуальным пользователям. Организованные по тем же принципам ASP (Application Service Providers, поставщики услуг приложений) на момент написания этой книги не имели большого успеха, однако есть много оснований предполагать значительное развитие этого бизнеса в будущем.


Атаки на систему безопасности

Вместо роллингов — хакеры
Вместо битлов — юзера
Б. Гребенщиков

Как видно из разд. Формулировка задачи, система безопасности в конечном итоге предназначена для защиты организации от целенаправленных атак. Хорошо спроектированная система попутно выполняет также и другие полезные функции, защищая данные от ошибочных доступов (и, таким образом, в известной мере защищая пользователей системы от самих себя), внешние каналы -от посторонних людей, забредших по ошибке и т. д., но все-таки о ее конечной цели никогда не следует забывать.
Для того чтобы понять, способна ли система безопасности к выполнению своей основной функции, ее разработчик и администратор хотя бы время от времени должны становиться на точку зрения противника и искать в своей системе потенциальные точки атаки, которые одновременно являются слабыми местами в защите.
Атаки на систему безопасности разделяются на три основных типа.

  • Собственно несанкционированный доступ к данным или другим ресурсам атакуемой системы.
  • DoS (Denial of Service — дословно, отказ в сервисе), т. е. приведение атакуемой системы или одной из ее подсистем в неработоспособное состояние.
  • Троянская программа, т. е. помещение в атакуемую систему написанного взломщиком кода. Код при этом размещается таким образом, чтобы его рано или поздно должны были запустить.


Отказ в сервисе

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


Троянские программы

Не верьте данайцам, дары приносящим.
Гомер

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

Пример троянской программы
Простой и по-своему элегантный пример троянской программы приводится во многих учебниках по командному языку систем семейства Unix, например в [Керниган/Пайк 1992]. В указанной работе эта программа приводится для объяснения того, почему в Unix путь поиска исполняемых программ по умолчанию не включает текущего каталога, и почему включать текущий каталог в этот путь крайне нежелательно.
Программа из примера 12.1 работает следующим образом.
1. Вредитель помещает ее в общедоступный каталог под именем Is.
2. Пользователь входит в этот каталог и исполняет команду Is (просмотр текущего каталога).
3. Вместо системной команды /bin/Is исполняется троянская программа.
4. Троянская программа совершает вредоносное действие и запускает настоящий /bin/Is, позаботившись о том, чтобы отфильтровать свою запись из листинга каталога.

Пример 12.1. Троянская программа на языке shell

#!/bin/sh
# Разместите эту программу в общедоступном каталоге и назовите ее Is
# Скопировать себя в домашний каталог пользователя;
# на этом месте может стоять и другая вредоносная операция ср $0 ~
/bin/Is $# | /home/badguy/filter_ls $#

Важная часть программы — модуль, который удаляет файл Is из листинга текущего каталога — опущен из-за его сложности, ведь он должен анализировать параметры команды Is и производить удаление своей записи различными способами в зависимости от требуемого формата вывода команды.

Троянская программа может быть реализована не только в виде самостоятельного загрузочного модуля, но и в виде разделяемой библиотеки. Так, в Windows NT 4.0 вплоть до выхода Service Pack 5 присутствовала ошибка, позволявшая зарегистрировать DLL, совпадающую по имени с любой из системных, причем так, чтобы при разрешении ссылок из других модулей использовалась вновь зарегистрированная библиотека.

Аналогичный пример для Win32
Уже во время подготовки книги к печати началась пандемия вируса Nimda, один из приемов распространения которого аналогичен приведенному в примере 12.1: обнаружив каталог, в котором лежат файлы данных MS Office, вирус помещает туда файл riched20.dll. При сборке программы в момент запуска, Windows просматривает текущий каталог до перечисленных в PATH, поэтому вместо модуля Office загружается троянский код вируса. В отличие от систем семейства Unix, в Win32 порядок просмотра каталогов при сборке жестко задан и не поддается контролю со стороны системного администратора, поэтому перекрыть данный путь распространения заразы можно лишь модификацией ядра Windows.

Многие троянские программы образуются модификацией присутствующих в системе загрузочных модулей, разделяемых библиотек и даже модулей ядра.
Такая программа не обязательно должна представлять собой бинарный код — это может быть также интерпретируемый код или последовательность команд макропроцессора. Широко распространены макровирусы, распространяющиеся с файлами данных пакета Microsoft Office.
Наиболее известны два специальных типа троянских программ, часть вредоносных действий которых состоит в дальнейшем их распространении, а именно вирусы и черви. Вирусы предполагают целенаправленный запуск зараженной программы пользователем, черви же, проникнув в систему, запускают собственный процесс размножения.
Широко распространенные в эпоху DOS бинарные вирусы просто добавляют свой код ко всем исполняемым модулям, которые так или иначе оказываются в сфере досягаемости запущенной копии вируса.
Промежуточное с точки зрения классификации между собственно вирусами и червями занимают загрузочные вирусы, которые размещаются в загрузочных секторах дисковых устройств. Загрузочный вирус регистрирует себя в качестве драйвера дискового устройства и остается -жить в системе, заражая все подключаемые к системе удаляемые носители (дискеты, Zip-диски, болванки CD-ROM). Загрузка — чаще всего, по ошибке — другой системы с зараженной дискеты приводит к заражению новой системы. В современных ОС с их многоэтапной загрузкой и сложными архитектурами драйверов загрузочные вирусы представляют несколько меньшую опасность, чем в эпоху MS DOS.
Черви способны размножаться без участия пользователей системы. Заражению червями подвержены лишь многопоточные ОС с более или менее развитыми сетевыми сервисами. Заразив систему, червь запускает себя в качестве фонового процесса и начинает атаку других систем, используя фиксированный набор известных проблем в их системах безопасности. Нередко червь в обязательном порядке заражает системы, которые доверяют зараженной или просто используют общую с нею базу учетных записей — например, заразив одну из систем домена Windows NT от имени администратора системы, червь может штатными средствами установить себя на все остальные системы того же домена.
Вирусы и черви доставляют много неприятностей сами по себе, к тому же их авторы часто не отличаются большой фантазией в выборе деструктивных действий и просто уничтожают все или только наиболее важные данные в зараженных системах.
Важно подчеркнуть, что если ваша сеть подвержена вирусным атакам, или же если единственной защитой вашей сети от них служит антивирусный пакет, способный только находить известные вирусы, она будет столь же уязвима и для троянских программ, специально разработанных с целью атаки на данные вашей организации, и, таким образом, вашу систему безопасности следует признать абсолютно непригодной.


Типичные уязвимые места

Ты намерен потопишь корабль?- уточнил он. На лице Смерти отразился ужас.
РАЗУМЕЕТСЯ, НЕТ. БУДЕТ ИМЕТЬ МЕСТО СОЧЕТАНИЕ НЕУМЕЛОГО УПРАВЛЕНИЯ КОРАБЛЕМ, НИЗКОГО УРОВНЯ ВОДЫ И ПРОТИВНОГО ВЕТРА.
Т. Пратчетт

Современные системы общего назначения имеют развитую систему безопасности, основанную на сочетании ACL и полномочий. Методы получения и передачи полномочий в этих ОС, как правило, теоретически корректны в том смысле, что доказана невозможность установления полномочия для пользователя или процесса, который не должен этого полномочия получать (сказанное не относится к системам Windows 95/98/ME, в которых эффективные средства безопасности отсутствуют по проекту).
В таких системах существует пять основных источников проблем безопасности.

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

Первый источник проблем преодолим только организационными мерами, и лишь некоторые из них — например, проведение с пользователями воспитательной работы — находятся в сфере компетенции системного администратора. Исключение составляет борьба с прослушиванием сети: предотвращение технической возможности несанкционированного подключения к сети также обычно находится на грани области компетенции системного администратора, но использование шифрованных сетевых протоколов или хотя бы таких, в которых имя и пароль передаются в хэшированием виде, может значительно уменьшить пользу прослушивания для злоумышленника.
Второй источник также следует преодолевать организационными мерами. Разумной политикой, по-видимому, следует считать не полный запрет компьютерных игр, а постановку процесса под контроль путем создания легального более или менее централизованного хранилища этих игр, систематически проверяемого на предмет "заразы". Поддержание этого хранилища может выполняться как самим системным администратором, так и на общественных началах.
Третий источник проблем находится преимущественно в голове самого системного администратора. Он должен знать точную семантику записей в ACL используемой ОС, исключения из правил, хотя бы наиболее распространенные стандартные ошибки, а также то, какие и кому права даются по умолчанию.
Большую помощь в формировании оптимальных и безошибочных ACL и групп оказывает хорошо документированная организационная структура компании, из которой ясно, какие служебные обязанности требуют того или иного доступа к тем или иным ресурсам и почему, и на основании каких распоряжений тот или иной уровень доступа должен быть изменен. Сверх этого можно надеяться только на аккуратность и привычку к систематической мыслительной деятельности.
Ни в коем случае не следует полагаться на то, что права, раздаваемые системой или прикладным пакетом по умолчанию, адекватны и могут быть оставлены без изменения. Так, в Windows NT/2000/XP на разделяемый дисковый ресурс по умолчанию даются права Everyone:Full Control (т. е. всем пользователям, явно не перечисленным в ACL, даются все права, в том числе и на изменение прав).
Четвертый источник, как правило, находится вне контроля системного администратора: хотя он и может иметь право голоса в решении вопроса о судьбе таких систем, но обычно его голос не оказывается решающим. Если отсутствие или неадекватность средств безопасности в операционной системе настольного компьютера часто можно скомпенсировать, исключив хранение на нем чувствительных данных, не запуская на нем доступных извне сервисов и — в пределе — сведя его к роли малоинтеллектуального конечного устройства распределенной системы, а его локальный диск — к роли кэша программ и второстепенных данных, то с приложениями все гораздо хуже.
Используемые в организации приложения и, что самое главное, стиль их использования обычно обусловлены ее бизнес-процессом, поэтому недостаточно продуманные попытки не только миграции в другое приложение, а иногда и установка patches (заплат) или изменения настроек могут привести к нарушениям в бизнес-процессе. Тщательное же продумывание и аккуратная миграция представляет собой сложный, весьма дорогостоящий и все-таки рискованный процесс, на который руководство организации всегда -по очевидным причинам — идет крайне неохотно. Это в равной мере относится как к мелким конторам с "документооборотом" на основе MS Office, так и к организациям, в которых основное бизнес-приложение представляет собой самостоятельно разрабатываемый и поддерживаемый программно-аппаратный комплекс, сохраняющий преемственность по данным с самим собой с конца XIX века (без шуток — механизированные системы обработки данных, табуляторы Холлерита, появились уже тогда).
В то же время, некоторые распространенные приложения представляют собой настоящий рай для взломщика, настолько богатый возможностями, что многие взломщики из спортивного интереса даже считают ниже своего достоинства этими возможностями пользоваться. Речь прежде всего идет о почтовом клиенте Microsoft Outlook, который без всяких предупреждений (а старые версии и автоматически) запускает пришедшие по почте исполняемые файлы.
Не менее чудовищна модель безопасности приложений пакета Microsoft Office, в которых документ может содержать макропрограммы, в том числе и способные модифицировать файлы, не имеющие отношения к документу. Новые версии пакета содержат средства, позволяющие в определенных пределах контролировать исполнение этих макропрограмм, но крайняя непродуманность этих средств вынуждает многих пользователей отключать их.
Указанные приложения являются идеальной средой для распространения вирусов и других троянских программ. Антивирусные пакеты ни в коем случае не могут считаться адекватной мерой, потому что обнаруживают только известные вирусы, обнаружить же новый вирус или троянскую программу, написанную специально для доступа к данным вашей компании, они принципиально не способны. Автор не в состоянии предложить эффективного способа действий при использовании в компании этих приложений, и может лишь посоветовать все-таки принудительно выключить макросы в
приложениях Office и научить пользователей не открывать незнакомые фай-I лы, пришедшие по почте.
Наконец, пятая причина — ошибки в модулях ОС и приложениях — хотя и находится за пределами непосредственной сферы влияния системного администратора, но заслуживает более подробного обсуждения, особенно потому, что мы предназначаем нашу книгу не только эксплуатационщикам, но и разработчикам программного обеспечения.


Ошибки программирования

Хотя мы и утверждали в начале предыдущего раздела, что методы управления полномочиями в современных ОС общего назначения теоретически идеальны, это относится лишь к идеальной ОС, т. е. к спецификациям соответствующих модулей. В то же время, в реальном коде встречаются отклонения от спецификаций, так называемые ошибки. Полный обзор и исчерпывающая классификация всех практически встречающихся типов ошибок, по-видимому, невыполнимы. В этом разделе мы опишем только наиболее распространенные и наиболее опасные ошибки.
Наибольшую опасность с точки зрения безопасности представляют ошибки в модулях, связанных с проверкой ACL, авторизацией и повышением уровня привилегий процессора (например, в диспетчере системных вызовов), а в системах семейства Unix — в setuid-программах.
Одна из наиболее опасных — и в то же время довольно распространенная в современных программах — ошибка приведена в примере 2.4. Рассмотрим этот код еще раз (пример 12.2).

Пример 12.2. Пример программы, подверженной срыву стека

/* Фрагмент примитивной реализации сервера SMTP (RFC822) */
int parse_line(FILE * socket)
{
/* Согласно RFC822, команда имеет длину не более 4 байт,
а вся строка — не более 255 байт */
char cmd[5], args[255];
fscanf(socket, "%s %s\n", and, args);
/* Остаток программы нас не интересует */

Видно, что наша программа считывает из сетевого соединения строку, которая должна состоять из двух полей, разделенных пробелом, и заканчиваться символом перевода строки. В соответствии со спецификациями протокола SMTP, первое поле (команда) не может превышать четырех символов (к сожалению, не определено в протоколе более длинных команд), а строка целиком не может быть длиннее 255 байт.
Если наш партнер на другом конце соединения полностью соответствует требованиям fRFC 0822], наш код будет работать без проблем. Проблемы — причем серьезнейшие — возникнут, если нам передадут строку, которая этим требованиям не соответствует.
Превышение допустимой длины кодом команды не представляет большой опасности: лишние байты будут записаны в начало массива args и потеряны при записи в него его собственного поля. Настоящая опасность — это превышение длины всей строки. Для нашего партнера не представляет никаких сложностей сгенерировать последовательность из более чем 255 символов, не содержащую переводов строки (рис. 12.21).

Рис. 12.21. Срыв буфера

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

Рис. 12.22. Срыв стека с передачей троянского кода

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

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

При программировании на языке С основные источники ошибок такого рода — это использование стандартных процедур gets и fscanf. Процедура gets лечению не подлежит — ей невозможно указать размер буфера, выделенного для приема данных, поэтому она в принципе не способна проконтролировать его заполнение. Вместо нее современные версии библиотек С предоставляют функцию fgets, которой размер буфера передается в качестве параметра. Настоятельно рекомендуется использовать именно ее.
Процедура fscanf в нашем случае лечится. Вместо

fscanf(socket, "%s %s\n", cmd, args);

нам следует написать

fscanf(socket, "%4s %255s\n", cmd, args);

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

Распространение червя Морриса через срыв буфера
Широко известен срыв буфера в программе fingerd, входившей в систему BSD Unix. На процессорах VAX и MC68000 отсутствует защита страниц памяти от исполнения, поэтому любая страница памяти, доступная для чтения, может быть исполнена.
"Червь Морриса" [КомпьютерПресс 1991] пользовался этой дырой, передавая кусок кода и поддельный стековый кадр, передававший управление этому коду. Код в свою очередь запускал на целевой системе копию командного интерпретатора, исполнявшуюся с привилегиями суперпользователя. Затем червь использовал полученный командный интерпретатор для втягивания в систему своего тела.
Ошибка была обнаружена в 1987 г. и вскоре после обнаружения была исправлена. Практически все современные системы используют безопасную в этом отношении версию fingerd. С тех пор сколько-нибудь серьезных (т. е. — вышедших за пределы одной группы взаимно доверяющих машин) атак червей на Unix-системы не происходило.
Червь Code Red, пандемия которого произошла в августе 2001 г., использовал срывы буфера в IIS (флагманский сетевой продукт Microsoft, сервер FTP/HTTP и ряда других протоколов для Windows NT/2000/XP). Как выяснилось, в данном случае речь шла не об одной, а о целой группе ошибок, потому что за выпуском patch, защищавшего от Code Red, последовало несколько других атак червей и поливалентных (т. е. использующих несколько каналов размножения) вирусов, часть из которых также привела к пандемиям. 19 сентября 2001 г. аналитическая компания Gartner Group выпустила доклад [www3.gartner.com 101034], в котором настоятельно рекомендовалось как можно скорее отказаться от использования IIS и высказывалась крайне пессимистическая оценка способности Microsoft исправить положение в обозримом будущем.

Срывы буфера являются одним из наиболее распространенных уязвимых мест: так, в базе данных [www.cert.orgl они составляют 27% от общего количества документированных проблем.
При использовании других языков программирования, например C++ с библиотекой классов для работы со строками, или Java, реализовать программу, подверженную срыву буфера, несколько сложнее, однако возможности человеческие неисчерпаемы, и многим программистам это удается. Впрочем, для программирующих на Java есть одно утешение: Java Virtual Machine использует более сложную схему управления памятью, чем компилируемые алголоподобные языки, и разрушить стековый кадр посредством переполнения буфера в программах, написанных на Java, невозможно, поэтому данный прием не может применяться для передачи и исполнения вредоносного кода. Однако неправильно обработанное исключение при ошибке индексации в буфере может привести к аварийной остановке приложения Java с ничуть не меньшим успехом, чем ошибка доступа к памяти в C/C++.
В тесном концептуальном родстве со срывами буфера находятся ошибки, срабатывающие при использовании во входном потоке данных недопустимых величин смещения (рис. 12.23). Такие ошибки встречаются при анализе входного потока, который содержит взаимосвязанные структуры данных, связи между которыми реализованы в виде смешений в потоке (я ссылаюсь на структуру данных, которая последует через 50 байт после этой точки). Практически важный пример такого протокола — система квитирования (посылки подтверждений) со скользящим окном, используемая в транспортном протоколе TCP [RFC 0793]. Адресация посредством смещений широко применяется также при работе с последовательными файлами, поэтому драйверы файловых систем и сетевые файловые серверы также могут содержать такие ошибки.

Рис. 12.23. Использование недопустимых смещений

Задание смещений, превосходящих размер буфера анализирующей программы, или недопустимых по каким-либо другим правилам — например, отрицательных, если по протоколу допустимы только положительные -может приводить к формированию указателей за пределы анализируемого буфера. Модификация данных по этим указателям может приводить к разнообразным последствиям — например, таким способом можно попытаться убедить файловый сервер, что файл, открытый для чтения, в действительности открыт для записи. Даже если в пределах досягаемости сформированного таким образом указателя и нет критически важных данных, практически всегда его можно использовать для разрушения целостности переменных состояния атакованного модуля и осуществления DoS.
В многопоточных сервисах распространены также ошибки соревнования. Для их срабатывания необходима определенная последовательность и временное согласование запросов к сервису.
В некоторых типах сервисов встречаются свойственные им ошибки. Так, во многих серверах HTTP была обнаружена ошибка подъема по каталогам (directory traversal bug), когда злоумышленник, запрашивая URI, содержащие последовательности '..', мог подняться по файловой системе выше корневого каталога HTTP-сервера и, таким образом, считать или даже модифицировать файлы, не входящие в иерархию HTML-документов (рис. 12.24). Аналогичные ошибки встречаются и в сетевых файловых серверах.

Рис. 12.24. Ошибка подъема по каталогам

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


Практические рекомендации

ЯСНОСТЬ МЫСЛИ. РЕАЛИСТИЧНЫЙ ПОДХОД ЧТО ОЧЕНЬ ВАЖНО ДЛЯ РАБОТЫ. ПОДОБНОЙ НАШЕЙ. Т. Пратчетт

Некоторые из приводимых рекомендаций уже упоминались в тексте, но здесь мы постараемся собрать их воедино.
Пользователь не должен иметь доступа к данным более того, что требуется для исполнения им служебных обязанностей. Это позволяет минимизировать эффект исполняемых пользователями троянских программ и прямых проникновений в систему от имени этого пользователя, а также вред, который пользователь может причинить сам, как сознательно, так и по ошибке. Кроме того, работа с ограниченным подмножеством данных удобнее для пользователя и иногда приводит к повышению производительности.
Когда это возможно, доступ к разделяемым данным следует устанавливать не индивидуальным пользователям, а группам. Список групп и структура их вложенности должны соответствовать иерархической структуре организации и существующим в ней функциональным группам должностей. Многие современные ОС поддерживают иерархические структуры учетных записей — как правило, эта иерархия должна соответствовать структуре организации.
Умопостижимая и хорошо и внятно документированная организационная структура оказывает администратору значительную помощь в проектировании структуры групп и ACL, а хорошая документация бизнес-процесса необходима разработчику приложений поддержки бизнеса, в том числе и для проектирования модели безопасности.
Везде, где это не приводит к чрезмерным накладным расходам, следует применять шифрованные протоколы передачи данных. Данные, хранящиеся на компьютерах за пределами здания компании, а особенно на домашних и переносных компьютерах, следует шифровать в обязательном порядке.
Защита данных практически не имеет смысла без защиты самой системы и прикладного программного обеспечения: если злоумышленник имеет возможность модифицировать код системы или прикладной программы, он может встроить в него троянские подпрограммы, осуществляюшие несанкционированный доступ к данным.
Доступ к коду приложений и системы для модификации должен предоставляться только техническому персоналу, занимающемуся поддержкой и установкой обновлений этих приложений. Это позволяет защититься не только от запускаемых пользователями троянских программ (особенно вирусных), но и от ошибочных действий пользователей.
Доступ к конфигурации ОС и прикладных программ также должен требовать высоких привилегий. В идеале, пользователю следует иметь доступ только к настройкам пользовательского интерфейса ОС и приложений.
Все ресурсы системы, которые могут резервироваться пользователями, должны квотироваться. Каждый неквотируемый общедоступный ресурс является потенциальной точкой DoS.
Каждый активный серверный или сервисный процесс, исполняемый в системе, может содержать ошибки и, таким образом, является потенциальной
точкой атаки. Следовательно, все сервисы, которые не нужны непосредственно для работы системы или реально используемых прикладных программ, необходимо остановить. Это может также привести к некоторому повышению производительности.
Поставщики ОС и приложений нисколько не заинтересованы в том, чтобы в их продуктах существовали известные ошибки, особенно же — способные привести к проблемам с безопасностью. Поэтому все сколько-нибудь приличные поставщики ПО как свободно распространяемого, так и коммерческого, публикуют обновления к своим продуктам, patchs (patch — заплата), содержащие исправления обнаруженных ошибок и рекомендации по обходу ошибок известных, но еще не исправленных. Системный администратор должен следить за этими публикациями — что, впрочем, не следует понимать как рекомендацию немедленно устанавливать на промышленно эксплуатируемые серверы самые последние patchs.
При принятии решения об эксплуатации той или иной программной системы — операционной, прикладной или среды разработки — необходимо ознакомиться с политикой поддержки, которую предоставляет ее поставщик.
Во всех случаях когда это возможно, следует делать операционную среду гетерогенной — различные ОС и приложения имеют различные (и, как правило, непересекающиеся) наборы проблем с безопасностью, поэтому сложность взлома гетерогенной среды резко возрастает.
Важным источником информации об известных проблемах с безопасностью (как ошибках в коде ОС и приложений, так и распространенных ошибках при установлении прав администратором) являются списки рассылки и Web-сайты [www.cert.org], [www.ntbugtraq.com] и ряд других, а также специальные списки рассылки, поддерживаемые поставщиками ПО. Хотя антивирусные пакеты и не являются адекватным средством защиты вашей сети от троянских программ, их применением не следует пренебрегать, особенно в средах, где активно используются приложения и ОС фирмы Microsoft. Лучше иметь хоть какую-то защиту, чем вообще никакой. В качестве дополнительного эшелона защиты антивирусный пакет также может оказаться полезен.


Глава 2. Машинные языки

  • Машинные языки
  • Форматы команд машинного языка
  • Команды перехода
  • Регистры
  • Адресация оперативной памяти
  • Режимы адресации
    • Вырожденные режимы адресации
    • Абсолютная адресация
    • Косвенно-регистровый режим
    • Косвенно-регистровый режим со смещением
    • Базово-индексный режим
    • Сложные режимы адресации
    • Адресация с использованием счетчика команд
  • Банки памяти
  • CISC- и RISC-процессоры
  • Языки ассемблера
    • Многопроходное ассемблирование


Машинные языки

Центральный процессор современного компьютера — это устройство, исполняющее команды. Полный набор команд конкретного процессора называют машинным языком или системой команд (иногда систему команд называют также архитектурой, но это слово слишком перегружено различными значениями).
Различные процессоры часто имеют одну и ту же" (или слабо варьирующую) систему команд — например, процессоры Intel 80386, 486, Pentium, Pentium II, AMD Кб, Athlon и т. д. — далее в тексте книги мы будем называть все эти процессоры х86.
Процессоры, которые могут исполнять программы на одном и том же машинном языке, называются бинарно-совместимыми. Отношение бинарной совместимости не всегда симметрично: например, более новый процессор может иметь дополнительные команды — тогда он будет бинарно-совместим с более старым процессором того же семе,йства, но не наоборот. Нередко бывает и так, что более новый процессор имеет совсем другую систему команд, но умеет исполнять программы на машинном языке старого процессора в так называемом режиме совместимости — например, все процессоры семейства х86 могут исполнять программы для Intel 8086 и 80286. Некоторые ОС для х8б даже предоставляют возможность собрать единую программу из модулей, использующих разные системы команд.
Еще более обширны семейства процессоров, совместимые между собой по языку ассемблера. Такая совместимость означает всего лишь, что каждая команда одного процессора имеет полный функциональный аналог в системе команд другого, это дает возможность автоматизировать преобразование программ из одного машинного языка в другой. Так, Intel 8086 совместим по языку ассемблера с более ранними процессорами той же фирмы, 8080 и 8085.
Как уже говорилось, асимметрия отношений совместимости обычно направлена от предыдущего поколения процессоров к следующему: более новое (как правило, более мощное) устройство совместимо со своим более старым аналогом, но не наоборот, поэтому часто говорят о совместимости снизу вверх. Это отношение позволяет нам не только классифицировать вычислительные системы по поколениям, но и выделять в разных поколениях предка и совместимых с ним потомков, а в пределах одного поколения находить "братьев" и более дальних родственников.
Прослеживание генеалогий систем команд современных процессоров — увлекательное занятие, которому посвящено немало публикаций, например (jbayko vl2.1.2]. Иногда, кроме бинарной и ассемблерной совместимостсй, при построении таких генеалогий учитывают и столь размытое понятие, как концептуальное родство — и тогда, например, процессоры Motorola 680x0 оказываются родней DEC PDP-11.
Наборы команд различных процессоров отличаются большим разнообразием, однако есть операции, которые в той или иной форме умеют выполнять все современные процессоры.
Во-первых, это арифметические операции над целыми числами в двоичном представлении. Даже в наше время многие микроконтроллеры предоставляют только операции сложения и вычитания, но процессоры современных компьютеров "общего назначения" все без исключения умеют также умножать и делить, при чем не только целые, но и "вещественные" числа (в разд. Представление рациональных чисел мы видели, чем такие числа отличаются от того, что называется вещественным числом в математическом анализе и производных от него дисциплинах). Некоторые специализированные процессоры предоставляют и более экзотические математические функции, например отдельные шаги алгоритма дискретного преобразования Фурье.
Но самая главная команда (или, точнее, самое главное семейство команд), которая и делает процессор полностью программируемым, — это команда перехода, точнее, как минимум две команды: безусловного и условного перехода. На практике большинство современных процессоров имеют по несколько команд безусловного перехода (с разными механизмами вычисления адреса точки перехода), а также до десятка, а иногда и более, команд условного перехода по различным условиям.

Микропрограммные автоматы
У фон-неймановских процессоров команды исполняются последовательно, в соответствии с порядком размещения в памяти, и только команды условных и безусловных переходов нарушают этот порядок. "Младшие родственники" фон-неймановских процессоров, микропрограммные автоматы, часто имеют более мощное средство управления последовательностью исполнения: каждая команда автомата имеет битовое поле, содержащее номер следующей по порядку исполнения команды и, таким образом, одновременно является и функциональной командой, и командой безусловного перехода. Методы реализации условных переходов в устройствах такого типа отличаются большим разнообразием.
Такая структура команды облегчает размещение программы в памяти (логически последовательные команды могут быть размещены в любых свободных участках), но приводит к значительному увеличению длины команды — и потому применима лишь в устройствах с очень небольшой длиной адреса команды, т. е. с маленькой программной памятью. Микропрограммные автоматы обычно компенсируют это ограничение сложной структурой каждой отдельной команды — длина таких команд достигает нескольких сотен битов и, в действительности, они содержат по отдельной команде для каждой из функциональных подсистем автомата. Но все равно, сложность программ для таких устройств невелика по сравнению с программами для фон-неймановских процессоров общего назначения, и их часто называют не программами, а микрокодом.
Программируемые логические матрицы (ПЛМ), микропрограммные автоматы и фон-неймановские процессоры представляют собой непрерывный спектр устройств возрастающей сложности — причем далеко не всегда можно с уверенностью отнести конкретное устройство к одной из перечисленных категорий. Простая ПЛМ не может сохранять предыдущее состояние и способна только преобразовывать текущие состояния своих входов в состояния своих выходов. Замкнув некоторые из выходов ПЛМ на некоторые из ее входов через простое запоминающее устройство (регистр) мы получаем более сложное устройство, обладающее памятью. Простые микропрограммные автоматы реализуются на основе ПЛМ и нескольких регистров. Более сложные автоматы содержат много регистров и специализированные функциональные устройства, такие, как счетчики и сумматоры.
Типичный современный фон-неймановский процессор общего назначения, такой, как Pentium VI, представляет собой сложный микропрограммный автомат, микрокод которого интерпретирует коды команд х86.

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

Нейросети
Впрочем, существуют весьма сложные системы, представляющие собой набор примитивных "процессоров", не имеющих команд перехода, — речь идет о так называемых нейросетях. Идея нейросетей была позаимствована у нервных систем высших животных, которые представляют собой сложносоединенную сеть специализированных клеток, нейронов. Каждый нейрон представляет собой довольно примитивное аналоговое устройство, пороговый сумматор: он имеет много (иногда много тысяч) входов и один выход. Каждый вход нейрона имеет свой вес, возможно отрицательный. Нейрон суммирует сигналы со всех
своих входов с учетом их весов, и, если результат превосходит некоторый порог, начинает генерировать сигнал на выходе. Суммирование бывает как линейное, так и нелинейное, когда вместо самого сигнала используется некоторая монотонная его функция. Например, если в качестве функции использовать логарифм, нейрон из сумматора превратится в умножитель.
Кажущаяся примитивность одиночного нейрона компенсируется их большим количеством, сложной системой связей между нейронами и их способностью "обучаться", изменяя коэффициенты входов (веса) и пороговое значение. Биологические нейросети могут расти, создавая дополнительные соединения. Некоторые комплексы биологических нейронов имеют фиксированную структуру связей и записанные на генетическом уровне веса. Простые поведенческие реакции, обусловленные такими нейронами, называют безусловно-рефлекторными, а сложные комплексы таких реакций — инстинктом.
Нейрофизиологические исследования показывают, что, во всяком случае, первичный анализ видимого изображения (выделение контуров, объединение их в фигуры) мозг млекопитающих осуществляет как нейросеть. Есть весьма убедительные доводы в пользу того, что остальные функции мозг выполняет по тому же принципу — хотя точная анатомическая локализация многих функций восприятия, распознавания образов, мышления, памяти и координации движений до сих пор неизвестна, в мозге пока не обнаружены структуры, которые могли бы работать иначе, чем нейросеть.
Успехи биологических нейросетей поражают воображение разработчиков электронных вычислительных и управляющих систем. Даже если отвлечься от часто упоминаемых в литературе задач распознавания образов, задача координации движений в том виде, в каком ее решает мозг млекопитающих, выходит далеко за пределы возможностей самых современных вычислительных систем.
Действительно, тело млекопитающего (например, человека) с точки зрения теоретической механики представляет собой систему многих тел (туловища, головы, сочленений конечностей), связанных вязко-упругими соединениями (мышцами и сухожилиями). При этом далеко не все эти тела можно адекватно описать как твердые: тот факт, что туловище способно изгибаться и скручиваться, используется человеком почти во всех движениях и для сохранения равновесия, и для придания движению дополнительного усилия, и для других целей. Даже самая примитивная модель "палочного человечка" с жестким туловищем состоит из четырнадцати элементов: головы, туловища и четырех конечностей, каждая из которых имеет по три сочленения. Казалось бы, немного, но движение каждого из этих элементов описывается шестью переменными: перемещение центра масс по трем координатам и вращение в трех плоскостях.
Таким образом, полное движение "палочного человечка" описывается восемьюдесятью четырьмя переменными. Для адекватного описания нам надо использовать не только сами переменные, но и их первые производные по времени, линейные и угловые скорости и соответствующие им импульсы и моменты импульса, так что переменных получается 168.
Для примера, большинство задач, решаемых в ВУЗовских курсах физики и теоретической механики, описывает одно- или двухмерное движения материальных точек или одиночных твердых тел, т. е. имеет дело с двумя или четырьмя "координатами" и, соответственно, четырьмя или восемью переменными. При решении уравнений, описывающих даже такие системы, есть место подвигу. Алгоритмы же решения систем дифференциальных уравнений большой размерности содержат шаги, вычислительная стоимость которых быстро — быстрее, чем экспоненциально— растет с увеличением размерности системы. Пользуясь методами теоретической механики, можно относительно легко записать систему дифференциальных уравнений, описывающую движения "палочного человечка". Решить же эту систему ни аналитически, ни численно за приемлемое время невозможно. Причем мы еще не учли в нашей модели нелинейных вязко-упругих связей между отдельными частями тела! А человеческий мозг решает эту задачу в полном виде и в режиме реального времени, причем не только для сложных движений, таких, как танец или боевые искусства, но и для таких повседневных действий, как вставание со стула, выполнение одного шага или поднесение ложки ко рту. Следует отметить, что решение этой задачи обычно не отвлекает сколько-нибудь значительной доли вычислительных ресурсов мозга, так что мы можем без труда одновременно идти, созерцать окружающий пейзаж (распознавание зрительных образов тоже не самая вычислительно дешевая задача) и размышлять о чем-нибудь.
Относительная легкость, с которой человек осваивает новые типы движений, даже такие, которые меняют механику его тела (т. е. изменяют систему дифференциальных уравнений, которая описывает поведение тела как механической системы), — езда на велосипеде, лыжах, роликовых коньках, работа простыми инструментами — или те, которые позволяют использовать механику чего-то совсем другого (управление автомобилем или летательным аппаратом), показывает, что врожденной является способность предсказывать движение и управлять поведением сложных механических систем вообще, а не только собственного тела. Эта же легкость, возможно, свидетельствует также и о том, что при решении этих задач в мозгу не происходит ничего даже отдаленно похожего на запись и решение системы многомерных дифференциальных уравнений.
Технические нейросистемы (или, если угодно, имитации нейросистем) достигли гораздо меньших успехов. Основная сфера применения компьютерных нейро-симуляторов — поиск корреляций в статистических и экспериментальных данных. Наибольший коммерческий успех имели аппаратно реализованные папиллярные детекторы и системы автофокусировки видео- и фотокамер, задача которых — крутить объектив, пока количество четких контуров в изображении не станет максимальным. На любительских видеосъемках, а иногда и в прямом эфире коммерческого телевещания, встречаются забавные моменты, когда такой автофокус настраивает резкость совсем не на тот элемент сцены, который хочет запечатлеть оператор, а на посторонний предмет, в изображении которого много мелких элементов, — крону дерева или что-нибудь в этом роде.
В нервных системах высших животных можно выделить функциональные подсистемы, которые с определенной натяжкой сопоставляются с теми или иными модулями компьютерных ОС. Так, например, есть нейрофизиологические свидетельства, что лобные доли коры больших полушарий головного мозга млекопитающих отвечают за его способность отложить деятельность, направленную на достижение какой-то одной цели, и переключиться на что-то другое, не забывая, однако, ни о предыдущей деятельности, ни о предыдущей цели. Это неплохо коррелирует с тем фактом, что человек, у которого лобные доли развиты до беспрецедентного в животном мире размера, способен также и к построению беспрецедентных по сложности цепочек, направленных на достижение целей окольными путями. Например, какому из животных придет в голову,
что гарантированного ежедневного питания и многих других материальных вознаграждений можно достичь посредством изучения современных операционных систем? У людей, впрочем, способность строить сложные цепочки промежуточных действий и промежуточных целей иногда доходит и до патологических явлений, когда забывается начальная (или, наоборот, конечная) цель, ради которой все затевалось. В связи с этим нельзя не вспомнить знаменитую реплику Льва Троцкого: "цель — ничто, движение — все".
Лобные доли можно — как признавалось выше, с определенной натяжкой — сопоставить с планировщиком многозадачной ОС. Понятно, что натяжка здесь столь велика, что вряд ли такое сопоставление сильно поможет нам в понимании того, что же такое планировщик, и как он устроен.
Несмотря на определенное родство решаемых задач, нейросети радикально отличаются от фон-неймановских компьютеров. Главное отличие состоит в том, что нейросеть не имеет ничего даже отдаленно похожего на программу в фон-неймановском смысле. Обсуждение, а также изучение реализаций биологических нейросетей и разработка нейросетей технических — занятие очень интересное и ему посвящено несколько научных и инженерных дисциплин, но это занятие увело бы нас далеко в сторону от темы нашей книги.


Форматы команд машинного языка

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

  • NOP
  • No OPeration, отсутствие операции.
  • HALT
  • Остановка процессора

Примеры безадресных команд с неявно указанными операндами

  • RETURN
  • Возврат из подпрограммы. Выталкивает из стека адрес возврата и помещает его в счетчик команд.
  • WDR
  • WatchDog Reset, сброс сторожевого таймера микроконтроллера.
  • ADD
  • Вытолкнуть из стека два значения, сложить их и протолкнуть результат в стек.
  • scs
  • Skip if Carry Set, пропустить следующую команду, если бит переноса в слове состояния установлен.

Примеры одноадресных команд с одним операндом

  • INC x
  • INCrement, добавить к операнду 1 и сохранить результат по тому же адресу.
  • TST х TeST,
  • установить в слове состояния флаги знака и равенства нулю в соответствии со значением операнда.

Примеры одноадресных команд с неявным операндом.

  • ADD x [, Асс]
  • Сложить операнд с аккумулятором и сохранить результат в аккумуляторе.
  • PUSH х
  • Протолкнуть значение операнда в стек.
  • CALL x
  • Вызов подпрограммы, сохраняет адрес следующей команды в стеке и передает управление по указанному адресу.
  • BNEQ х
  • Передает управление по указанному адресу, если в слове состояния установлен флаг равенства нулю.

Примеры двух- и трехадресных команд.

  • MOVE x, у

Присвоить значение объекта х объекту у.

  • ADD x, у

Сложить х и у, поместить результат в у.

  • ADD x, у, z

Сложить х и у и поместить результат в z.
Четырехадресная команда:

DIV х, у, z, w

выполняет деление х на у, помещает частное в z, а остаток — в w. Шестиадресная команда:

INDEX b, I, h, s, i, a

вычисляет адрес элемента массива, расположенного по адресу b, с нижней и верхней границами индекса I и b соответственно и размером элемента s.
Операнд i — индекс элемента, а — место, куда следует поместить вычисленный адрес.
Количество адресов иногда используют и для общей характеристики системы команд. Двухадресной называют систему команд, в которой команды имеют максимум два операнда, трехадресной -- максимум три. Нередко, впрочем, вместо максимального количества операндов, адресность системы команд определяют по количеству операндов у наиболее "ходовых" команд — сложения и вычитания. Таким образом, VAX, из системы команд которого взяты примеры четырех- и шестиадресных команд, часто относят к трехадресным архитектурам.
Одноадресные системы команд обычно используют в качестве неявно заданного операнда выделенный регистр, так называемый аккумулятор, или стек. Такие архитектуры называют, соответственно, аккумуляторными и стековыми.
Одноадресную аккумуляторную архитектуру имеют микроконтроллеры семейства PIC фирмы Microchip. Большинство современных процессоров имеют двух- и трехадресные системы команд.
На примере стековой команды ADD мы видели, что многие из команд стековой архитектуры могут обойтись вообще без явно указанных операндов, однако команды проталкивания значений переменных в стек и выталкивания их оттуда все-таки необходимы, поэтому все стековые архитектуры одно-, а не безадресные.
Стеки привлекательны, во-первых, тем, что не нуждающиеся в операндах команды могут иметь очень короткий код операции (как правило, достаточно одного байта) и, во-вторых, тем, что работающая с ними программа представляет собой арифметическое выражение, записанное в обратной польской нотации — когда мы сначала пишем операнды, а потом знак операции. Например, операция а+ь в этой записи выглядит как ab+ (в программе — push a; push b; add;).
Задача преобразования привычных нам арифметических выражений в обратную польскую запись легко формализуется, поэтому стековые процессоры долгое время позиционировались как "ориентированные на языки высокого уровня". Позже, впрочем, выяснилось, что более сложная логика разбора арифметических выражений позволяет проводить разного рода оптимизации (сокращать введенные лишь для удобства записи переменные, заменять выражения, которые всегда дают одно и то же значение, на константы, выносить повторяющиеся вычисления из тела цикла и т. д.).
Аппаратно реализованные стековые архитектуры — в наше время редкость. Из относительно современных процессоров, имевших коммерческий успех, можно назвать Transputer фирмы Inmos (в настоящее время эти микропроцессоры выпускаются фирмой SGC-Thomson).
Шире всего стековая архитектура распространена в байт-кодах или, как это еще называют, системах команд виртуальных машин. Байт-код - это промежуточное представление программы, используемое интерпретатором, чтобы избежать лексического и синтаксического анализа программы на этапе исполнения. Исполнение байт-кода осуществляется не процессором, а программой-интерпретатором. Таким образом, реализуются многие современные языки программирования — многочисленные диалекты языка BASIC, Lisp, SmallTalk, Fort (этот язык любопытен тем, что сам имеет стековый синтаксис), наконец Java. Некоторые реализации интерпретаторов этих языков используют так называемую JIT-комтшяцию (Just In Time, точно в момент [исполнения]), когда перед исполнением байт-код компилируется в систему команд физического процессора. Такая технология позволяет достичь для "интерпретируемых" программ производительности, не уступающей производительности компилированного кода.
Первым промышленным применением JIT-компиляции была система AS/400 фирмы IBM. В настоящее время JIT широко используется в реати-зациях Java. JIT-компиляция привлекательна тем, что позволяет исполнять один и тот же код на разнообразных процессорах без потерь (или почти без потерь) скорости.


Команды перехода

Как говорилось в начале главы, команды условного перехода — это то, что отличает фон-неймановский процессор от непроцессора или, в крайнем случае, от не фон-неймановского процессора. Большинство современных процессоров имеет обширный набор команд условного перехода по различным арифметическим условиям и их комбинациям.
Арифметические флаги выставляются в соответствии с результатами последней арифметической и логической операции. Типичный набор арифметических флагов — это бит переноса, бит нуля (выставляется, если все биты результата равны нулю), знаковый бит (если равен нулю старший бит результата) и бит переполнения. В процессорах первых поколений нередко использовался обратный подход: процессор имел всего один флаг условия перехода (так называемый ы-признак) и одну команду условного перехода, зато несколько команд сравнения, придававших этому флагу различную семантику.
Набор команд перехода, приведенный в табл. 2.1, несколько шире обычного — команды SBRC/SBRS для процессоров общего назначения нетипичны.
Микроконтроллеры PIC (по-видимому, самая экстравагантная система команд среди современных промышленно выпускаемых процессоров) имеют всего две команды, выполняющие функции команд условного перехода: BTFCS (Bit Test, Skip if Set — проверить бит и, если он установлен, пропустить следующую команду) и BTFCC (Bit Test, Skip if Clear, пропустить следую-шую команду, если бит сброшен). Объектом проверки может служить любой бит любого регистра процессора, в том числе и биты арифметических условий статусного слова. Для реализации условного перехода следом за такой командой нужно разместить команду безусловного перехода. Именно таким образом ассемблеры для этого микроконтроллера реализуют псевдокоманды условных переходов.

Таблица 2.1. Команды условного перехода микроконтроллеров семейства AVR, цит. по [www.atmel.com]

Команда | Описание | Условие перехода
SBRC Rr, b | Пропустить, если бит в регистре сброшен | if (Rr(b)=0) PC = PC + 2 or 3
SBRS Rr, b | Пропустить, если бит в регистре установлен | if (Rr(b)=1) PC = PC + 2 or 3
SBIC P, b | Пропустить, если бит в регистре В/В сброшен | if (I/0(P,b)=0) PC = PC + 2 or 3
SBIS P, b | Пропустить, если бит в регистре В/В установлен | if (I/0(P,b)=l) PC = PC + 2 or 3
BRBS s, k | Перейти, если статусный флаг установлен | if (SREG(s) =1) PC = PC+k + 1
BRBC s, k | Перейти, если статусный флаг сброшен | if (SREG(s) =0) PC = PC+k + 1
BREQ k | Перейти, если равно | if (Z = 1} PC = PC + k + 1
BRNE k | Перейти, если не равно | if (Z=0) PC = PC + k + 1
BRCS k | Перейти, если перенос установлен | if (C = 1) PC = PC + k + 1
BRCC k | Перейти, если перенос сброшен | if (C = 0) PC = PC + k + 1
BRSH k | Перейти, если равно или выше | if (C = 0) PC = PC + k + 1
BRLO k | Перейти, если ниже | if (C = 1) PC = PC.+ k + 1
BRMI k | Перейти, если минус | if (N = 1) PC = PC + k + 1
BRPL k | Перейти, если плюс | if (N = 0) PC = PC + k + 1
BRGE k | Перейти, если больше или равно, знаковое | if (N XOR V= 0) PC = PC+ k + 1
BRLT k | Перейти, если меньше, знаковое | if (N XOR V= 1) PC = PC + k + 1
BRHS k | Перейти, если полубайтовый перенос установлен | if (H = 1) PC = PC + k + 1
BRHC k | Перейти, если полубайтовый перенос сброшен | if (H = 0) PC = PC + k + 1
BRTS k | Перейти, если Т-флаг установлен | if (T = 1) PC = PC + k + 1
BRTC k | Перейти, если Т-флаг сброшен | if (T = 0) PC = PC + k + 1
BRVS k | Перейти, если флаг переполнения установлен | if (V = 1) PC = PC + k + 1
BRVC k | Перейти, если флаг переполнения сброшен | if (V = 0) PC = PC + k + 1
BRIE k | Перейти, если прерывания разрешены | if (1 = 1) PC = PC + k + 1
BRID k | Перейти, если прерывания запрещены | if (1 = 0) PC = PC + k + 1


Регистры

Процессор соединен с банками памяти шиной, по которой за один раз передается только одно целое число. Иногда разрядность этой шины тоже называют разрядностью процессора. Тогда 16-разрядный i8088 оказывается 8-разрядным, 32-разрядные MC68000 и 180386SX -- 16-разрядными, а младшие модели современных 64-разрядных RISC-процессоров — 32-разрядными. Для разработчиков аппаратуры такая классификация имеет смысл, а разработчиков программного обеспечения она может ввести в заблуждение.
Арифметико-логическое устройство процессора обычно не может оперировать данными, непосредственно размешенными в оперативной памяти: для выполнения арифметической операции нужен доступ одновременно к трем ячейкам памяти, хранящим операнды и результат.
Для решения этой проблемы любой процессор имеет один или несколько регистров — специализированных запоминающих устройств, обычно вмещающих целое число или адрес. Все процессоры имеют как минимум шесть регистров — регистр для адреса текущей команды (счетчик команд), регистр флагов, где хранятся коды арифметических условий и, кроме того, много другой служебной информации (часто этот регистр называют словом состояния процессора), три буферных регистра АЛУ и буферный регистр, в котором хранится текущая команда (рис. 2.1).

Рис. 2.1. Типичная структура микропроцессора

Из этих регистров программисту доступны только счетчик команд и слово состояния процессора, да и то не всегда. (Под доступностью программисту в данном случае мы подразумеваем возможность указывать регистры в качестве явных и неявных операндов команд).
Регистры, доступные программисту для хранения данных, называются регистрами общего назначения (рис. 2.2). Кроме них, процессор обычно имеет множество других регистров. Некоторые из них интересны только проектировщикам аппаратуры, с другими — например, регистрами диспетчера памяти — мы еще встретимся в следующих главах.
Для доступа к регистрам процессору не нужно занимать внешнюю шину Данных, да и цикл доступа к регистру обычно очень короток и совпадает с циклом работы АЛУ. Следовательно, чем больше у процессора регистров, тем быстрее он может работать с оперативными данными. В те времена, когда компьютеры были большими, стремление к увеличению количества регистров упиралось в стоимостные и электротехнические ограничения. У компьютеров первых поколений для реализации регистров использовались отдельные транзисторы или микросхемы малой степени интеграции, Поэтому они стоили гораздо дороже (в расчете на один бит памяти), потребляли гораздо больше энергии и рассеивали гораздо больше тепла, чем ферритовая память. Переход на современные кристаллы высокой интеграции изменил положение, но не принципиально: триггеры, которые используются для реализации регистров, по перечисленным параметрам всегда хуже, чем исполненное по той же технологии динамическое ОЗУ. Поэтому компьютеры первых поколений обычно имели лишь несколько регистров, а мини- и микрокомпьютеры 70-х и начала 80-х прошлого века — не более нескольких десятков.

Рис. 2.2. Регистры общего назначения в системе команд х86

У современных процессоров количество регистров измеряется сотнями, а иногда и тысячами. Например, вместо буфера на одну только текущую команду, все без исключения современные процессоры имеют так называемую очередь предварительной выборки команд. У микроконтроллеров PIC и Atmel то, что в спецификациях указано как ОЗУ (у младших моделей 128 или 256 байт, а у старших — много килобайт), фактически представляет собой регистры.

Регистровое окно SPARC
RISC-процессор SPARC [www.sparc.com v9] имеет регистровый файл, объем которого у старых версий процессора составлял 136 32-разрядных слов, а у современных 520 и более. Этот файл состоит из групп по 8 регистров.
Программе одновременно доступно 32 регистра, нумеруемые от 0 до 31, называемые регистровым окном. Из них регистр 0 (обозначаемый как % g0) — выделенный, чтение из него всегда возвращает 0. Следующие 7 регистров (обозначаемые как %g1-%g7) используются для глобальных переменных. Эти регистры не входят в регистровое окно. Регистры с r8 по r15 (%о0-%о7) используются для передачи параметров вызываемой процедуре (r15 используется для хранения адреса возврата подпрограммы), регистры с г24 по г31 (%i0-%i7)— для доступа к параметрам, и, наконец, регистры с г16 по г23 (%I0-%I7) — для хранения локальных переменных. При вызове подпрограммы, команда save сдвигает регистровое окно, так что регистры %оО-%о7 вызывающей процедуры становятся регистрами %о0-%о7 процедуры вызванной. Регистры rО-r7 сдвигом регистрового окна не затрагиваются.
Таким образом, регистровый файл выполняет функции стека вызовов — в нем сохраняются адреса возврата, передаются параметры и хранятся локальные переменные. Впрочем, такой подход при всей его привлекательности имеет большой недостаток— глубина стека вызовов оказывается ограничена глубиной регистрового файла. Способ устранения этого ограничения описан в разд. Косвенный регистровый режим со смещением.

Важно еще раз отметить, "что обычно далеко не все имеющиеся регистры непосредственно доступны программе. Так, в системе команд х86 предусмотрено всего восемь регистров общего назначения, но современные суперскалярные реализации этой архитектуры (Pentium II, Athlon и т. д.) имеют более сотни физических регистров и несколько арифметико-логических устройств. Логика предварительной выборки дешифрует несколько команд и назначает исполнение этих команд на свободные АЛУ, по мере их поступления, с использованием свободных копий регистров, при этом, не нарушая связи по данным между последовательными командами.
При наличии большого количества регистров существует два подхода к их выбору. Первый состоит в том, что каждый код операции работает только со
СВОИМ регистром. Команда move %eax, %ebx — это одна команда, a move
%еах, %esp — совсем другая. При этом бывает и так, что некоторые команды Для некоторых регистров не определены.
Второй подход, более симпатичный автору, называется ортогональной системой команд, и состоит в том, что все команды (или хотя бы большинство) могут работать с любыми регистрами. Номер регистра при этом кодируется специальным битовым полем в коде команды (или несколькими битовыми полями, если команда оперирует несколькими регистрами) (рис. 2.3).

Рис. 2.3. Формат команды микропроцессора SPARC (цит. по [www.sparc.com v9])

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


Адресация оперативной памяти

С точки зрения процессора, оперативная память представляет собой массив пронумерованных ячеек. Номер каждой ячейки памяти называется ее адресом. Разрядность адреса является одной из важнейших характеристик процессора и реализуемой им системы команд. Разрядность важна не как самоцель, а потому, что ею обусловлен объем адресуемой памяти — адресного пространства. Системы с 16-разрядным адресом способны адресовать 64 Кбайт (65 536) ячеек памяти, а с 32-разрядным — 4 Гбайт (4 294 967 296) ячеек. В наше время адресуемая память в 4 Гбайт для многих приложений считается неприемлемо маленькой и требуется 64-разрядная адресация.
Процессору обычно приходится совершать арифметические операции над адресами, поэтому разрядность адреса у современных процессоров обычно совпадает с разрядностью основного АЛУ.
У некоторых компьютеров адресация (нумерация) ячеек памяти фиксированная: одна и та же ячейка памяти всегда имеет один и тот же номер. Такая адресация называется физической. Адрес при этом разбит на битовые поля, которые непосредственно используются в качестве номера физической микросхемы памяти, и номеров строки и столбца в этой микросхеме. Напротив, большинство современных процессоров общего назначения используют виртуальную адресацию, когда номер конкретной ячейки памяти определяется не физическим размещением этой ячейки, а контекстом, в котором происходит адресация. Способы реализации виртуальной памяти и необходимость ее применения обсуждаются в главе 5.
В старых компьютерах размер адресуемой ячейки памяти данных совпадал с разрядностью АЛУ центрального процессора и разрядностью шины данных. Адресуемая ячейка называлась словом. В процессорах манчестерской архитектуры, которые могут использовать одну и ту же память как для команд, так и для данных, оба размера определялись длиной команды. Из-за этого многие процессоры такого типа имели странные по современным представлениям разрядности — 48, 36, иногда даже 25 бит.

БЭСМ-6
Так, БЭСМ-6 имела слово разрядностью 48 бит и команды длиной 24 бита, состоявшие из 15-разрядного адресного поля и 9-разрядного кода операции. Адресное поле позволяло адресовать 32К слов. В одном слове размещалось две команды, при этом команды перехода могли указывать только на первую из упакованных в одно слово команд.

У процессоров гарвардской архитектуры (имеющих раздельные памяти для команд и данных) разрядность АЛУ и размер команды не связаны.

Microchip PIC
Микроконтроллеры семейства PIC фирмы Microchip имеют 8-разрядное АЛУ и накристалльное ОЗУ той же разрядности [www.microchip.com PICMicro]. Команды этих микроконтроллеров размещаются в ПЗУ (также накристалльном), в котором каждое слово имеет 12 бит и содержит одну команду. Аналогично БЭСМ-6, команда микроконтроллера состоит из адресного поля (которое может содержать как адрес, так и константное значение длиной 1 байт) и кода операции. Под код операции остается всего четыре бита, поэтому команд, имеющих полное адресное поле, может быть не более 16. Адресное пространство микроконтроллера составляет 8 бит, т. е. всего 256 слов кода и 256 байт данных. Однако при помощи ухищрения, называемого банковой адресацией (подробнее об этом см. в разд. Банки памяти), старшие модели семейства PIC адресуют и по несколько килобайтов как кода, так и данных.
Впрочем, для многих целей и 256 команд вполне достаточно. Самый маленький в мире Web-сервер [www-ccs.cs.umass.edu] реализован именно на основе PIC. В 512 слов кода удалось упаковать реализацию полноценных подмножеств протоколов RS232 (использованная модель микроконтроллера не имеет аппаратно реализованного последовательного порта), РРР, TCP/IP и HTTP. Web-сервер состоит из двух микросхем — собственно сервера и кристалла флэш-памяти, в котором хранятся экспортируемые сервером HTML-документы и изображения. Сервер включается в последовательный порт компьютера и получает питание от этого порта.
Разработчик сервера рекламирует его как основу (или, во всяком случае, как демонстрацию технической возможности) создания Web-интерфейсов для разнообразного бытового оборудования.

В современных компьютерах единицей адресации является байт — ячейка памяти размером 8 бит. При этом и АЛУ, и шина данных могут иметь большую разрядность (хотя, конечно, эта разрядность всегда кратна байту -8, 16, 32 или 64 бита). Разрядность АЛУ и шины данных может не совпадать: так, 18088 имел 16-разрядное АЛУ и 8-разрядную шину, MC68000 и 180386SX — 32-разрядное АЛУ и 16-разрядную шину. Что при этом понимается под "словом" - зависит от фантазии изготовителей процессора. Например, у вполне 32-разрядного VAX документация называет 16-битовую структуру словом, а 32-битовую — двойным или- длинным словом, хотя, обычно, длиной слова считается все-таки разрядность АЛУ.
При этом различные процессоры по-разному подходят к адресации слов.
Во-первых, некоторые процессоры (PDP-11, SPARC) запрещают обращения к словам, адрес которых не кратен размеру слова, и генерируют при попытках такого обращения исключительную ситуацию ошибки шины. Другие процессоры, например VAX и х86 такие обращения разрешают, но в документации есть честное предупреждение, что обращения к невыровненным словам будут минимум в два раза медленнее, чем к выровненным (рис. 2.4).

Рис. 2.4. Выровненные и невыровненные обращения к словам

Во-вторых, есть разногласия в том, как следует адресовать (или, как следует интерпретировать при арифметических операциях) байты одного слова — по какому адресу находятся старшие 8 битов, а по какому — младшие? У одних Процессоров (IBM/390, MC68000, SPARC) — старший байт располагается по меньшему адресу (big endian), у других (VAX, x86) - по большему (little etidian). Встречаются и процессоры со смешанным порядком байтов, когда, например, из двух байтов полуслова по меньшему адресу расположен младший, а из двух полуслов по меньшему адресу расположено старшее (рис. 2.5). Некоторые современные процессоры, например PowerPC, MIPS, SPARC V9 могут работать и с тем, и с другим порядком байтов, причем иногда даже в пределах одной программы.

Рис. 2.5. Порядок байтов в слове

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


Режимы адресации

Операнды команд могут быть как регистрами, так и ячейками памяти. Некоторые архитектуры, например PDP-11 и VAX, допускают произвольное сочетание регистров и ячеек памяти в одной команде. В частности, допустимы команды пересылки из памяти в память и арифметические операции, оба (у двухадресной PDP-11) или все три (у трехадресного VAX) операнда которых расположены в памяти. В других архитектурах, например в х86 и МС680хО только один операнд команды может размещаться в памяти, а второй всегда обязан быть регистром (впрочем, оба эти процессора имеют и отдельные команды память-память, например инструкции групповой пересылки данных). У RISC-процессоров арифметические операции разрешены только над регистрами, а для обращений к памяти выделены специальные команды LD (LoaD, загрузить) и ST (Store, сохранить).
В зависимости от подхода, применяемого в конкретной системе команд, архитектуры подразделяются на память-память, регистр-память и регистр-регистр. Архитектура регистр-регистр привлекательна тем, что позволяет сделать длину команды фиксированной (адресное поле могут иметь только команды LD/ST и команды вызова и перехода) и за счет этого упростить работу дешифратора и логики предварительной выборки команд. При небольшой длине адреса (как у старых компьютеров и современных микроконтроллеров) этим преимуществом обладают и архитектуры регистр-память.
Напротив, процессоры с большим адресным пространством и архитектурами память-память и регистр-память вынуждены иметь команды переменной длины. У процессоров VAX длина команды меняется от одного (безадресная команда) до 61 байта (шестиадресная команда, все операнды которой используют самый сложный из допустимых режимов адресации). Впрочем, пределом экстравагантности в этом смысле является Intel 432, команды которого имели длину, некратную байту. Адресация команд в 1432 происходила с точностью до бита.
По мере роста адресного пространства адресные поля команд, обращающихся к памяти, занимают все большую и большую долю кода. Это является дополнительным стимулом к замене, где это возможно, обращений к памяти на обращения к регистрам. Благодаря этому же, код активно использующих регистры RISC-процессоров, несмотря на гораздо большую длину кодов команд (если у х86 наиболее широко используемые операции кодируются двумя байтами, то у типичного RISC все команды имеют длину 4 байта), ненамного превосходит по объему эквивалентный код для CISC-процессоров. По мере перехода к 64-разрядным адресам, выигрыш в объеме кода может стать преимуществом RISC-архитектур.
На основе сказанного выше, у читателя могло сложиться впечатление, что единственным способом указания адреса операнда в памяти является помещение этого адреса в адресное поле команды. В действительности это не так, или, точнее, не всегда так — в зависимости от режима адресации, адрес операнда может вычисляться различными, иногда довольно сложными способами, с учетом значений одного или нескольких регистров, и как с использованием адресного поля, так и без него.
Большинство современных процессоров поддерживает многочисленные режимы адресации. Как и при работе с регистрами, это может реализоваться двумя путями: ортогональным, когда режим адресации кодируется битовым полем в коде команды, и неортогональным, когда различные режимы адресации соответствуют разным командам.
Поскольку различные режимы адресации могут как использовать адресное поле, так и не использовать его, чтобы реализовать ортогональную систему с командами фиксированной длины, нужно проявить незаурядную фантазию.

Режимы адресации VAX
У процессоров VAX операнды команд кодируются одним байтом. Старшие 4 бита операнда указывают режим адресации, младшие— номер регистра. Если режим предполагает использование адресных полей, эти поля следуют за операндом. При некоторых режимах возможно использование нескольких адресных полей и длина одного операнда, таким образом, может доходить до 10 байт (рис. 2.6).

Рис. 2.6. Форматы одно-, двух- и трехадресной команд процессора VAX

Режимы адресации SPARC
У процессоров SPARC адресацию осуществляют лишь четыре группы команд — LD (загрузка слова из памяти в регистр), ST (сохранение значения регистра в памяти), JMPL (переход по указанному адресу и сохранение текущего адреса в регистре) и команды условного перехода. Все остальные команды манипулируют регистрами и константами. Команда длиной 32 бита имеет три битовых поля: два задают 5-разрядные номера регистров, третье — либо регистр, либо 13-разрядное целое число (см. рис. 2.3). Команды LD, SP и JMPL имеют такой же формат и позволяют использовать в качестве адреса либо сумму двух регистров, либо регистра и 13-разрядного значения, интерпретируемого как знаковое число в двоично-дополнительной кодировке. Это перекрывает далеко не все перечисленные далее режимы адресации. Многие распространенные режимы адресации на SPARC приходится реализовать с помощью нескольких команд.

Даже классические полностью ортогональные архитектуры — PDP-11, VAX, MC680xO — имеют по крайней мере одно отклонение от полной ортогональности: режим адресации коротким смещением относительно счетчика команд (см. разд. Адресация с использованием счетчика команд, используемый в этих архитектурах в командах условного перехода и недоступный в других командах.


Вырожденные режимы адресации

К этой группе относятся режимы, в которых доступ к операнду не содержит адресации как таковой.
Первым из таких режимов является операнд-регистр. Режим этот концептуально крайне прост и в дополнительных комментариях не нуждается.
Второй режим — операнд-константа. В документаииях по многим процессорам этот режим называют литеральной (literal) и немедленной (immediate) адресацией.
Казалось бы, трудно придумать более простой и жизненно необходимый режим. Однако полноценно реализовать такие операнды можно используя либо команды переменной длины, либо команды, которые длиннее слова (чаще всего это бывает у процессоров гарвардской архитектуры, например, уже упоминавшегося PIC).

Литеральная адресация в системе команд SPARC
Разработчики процессоров, которых не устраивает ни одно из названных условий, вынуждены проявлять фантазию. Так, у RISC-процессоров SPARC и команда, и слово имеют одинаковую длину— 32 бита. Адресное поле такой длины в команде невозможно — не остается места для кода операции. Выход, предложенный разработчиками архитектуры SPARC, при первом знакомстве производит странное впечатление, но, как говорят в таких случаях, "не критикуйте то, что работает".
Трехадресные команды SPARC могут использовать в качестве операндов три регистра или два регистра и беззнаковую константу длиной 13 бит. Если константа, которую мы хотим использовать в операции, умещается в 13 бит, мы можем просто использовать эту возможность. На случай, если значение туда не помещается, предоставляется команда sethi const22, reg, которая имеет 22-разрядное поле и устанавливает старшие биты указанного регистра, равными этому полю, а младшие биты — равными нулю.
Таким образом, если мы хотим поместить в регистр 32-разрядную константу value, мы должны делать это с помощью двух команд: sethi %hi (value), reg; or %gO, %lo (value), reg; (в соответствии с [docs.sun.com 806-3774-10], именно так реализована ассемблерная псевдокоманда set value, reg).
С точки зрения занимаемой памяти, это ничуть не хуже, чем команда set value, reg, которая тоже должна была бы занимать 64 бита. Зато такое решение позволяет соблюсти принцип: одна команда — одно слово, который облегчает работу логике опережающей выборки команд.
Впрочем, для 64-разрядного SPARC v9 столь элегантного решения найдено не было. Способ формирования произвольного 64-битового значения требует дополнительного регистра и целой программы (пример 2.1). В зависимости от значения константы этот код может подвергаться оптимизации. Легче всего, конечно, дело обстоит, если требуемое значение помещается в 13 бит.

Пример 2.1. Формирование 64-разрядного значения на SPARC v9, цит. по [docs.sun.com 806-3774-10]

! reg — промежуточный регистр, rd — целевой.
sethi %uhi(value), reg
or reg, %ulo(value), reg
sllx reg,32,reg ! сдвиг на 32 бита
sethi %hi(value), rd
or rd, reg, rd
or rd, %lo(value), rd

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

Короткие литералы VAX
У процессоров семейства VAX есть режим адресации, позволяющий использовать битовое поле, которое в других режимах интерпретируется как номер регистра, в качестве 4-битового литерала. Вместе с двумя битами режима адресации этим способом можно задать 6-разрядный литерал, знаковый или беззнаковый в зависимости от контекста [Прохоров 1990].

Короткие литералы МСбЗОхО
У процессоров семейства МС680хО литерал может иметь длину 1 или 2 байта. Кроме того, предоставляются команды ADDQ и SUBQ, которые позволяют добавить к указанному операнду или вычесть из него целое число в диапазоне от 1 до 8.


Абсолютная адресация

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

Абсолютная адресация в системе команд SPARC
У процессоров SPARC реализация абсолютной адресации похожа на реализацию адресации литеральной: под адресацию занимается регистр, командой sethi %hi (addr) , reg в него загружается старшая часть адреса, а затем происходит собственно адресация. Для формирования 64-разрядного адреса необходимо занимать два регистра и выполнять ту же программу, что и в примере 2.1.
Обращение к переменной в памяти происходит так, как показано в примере 2.2.

Пример 2.2. Обращение к переменной на процессоре SPARC

sethi %hi(var), %g1 | ! | помещаем старшие биты адреса в %g1
Id [%gl+%lo(var)], %11 | ! | загружаем значение в %11
inc %11 | ! | производим операцию
st %11, [%gl+%lo(var)] | ! | сохраняем результат.

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


Косвенно-регистровый режим

В этом режиме, как и в регистровом, адресное поле не используется. Значение регистра интерпретируется как адрес операнда. Данный режим используется для разыменования указателей или для обращения к памяти по предварительно вычисленному адресу.
Некоторые процессоры, такие, как PDP-11, VAX, МСбЗОхО, имеют любопытные варианты этого режима — адресацию с постинкрементом и предек-рементом. Постинкремент означает, что после собственно адресации значение регистра увеличивается на величину адресуемого объекта. Предекремент, соответственно, означает, что регистр уменьшается на ту же величину перед адресацией.
Эти режимы могут использоваться для разнообразных целей, например для реализации операций над текстовыми строками или поэлементного сканирования массивов. Но одно из основных назначений -- это реализация стека.

Стек

Стек, или магазин — это структура данных, над которой мы можем осуществлять две операции: проталкивание (push) значения и выталкивание (pop). Значения выталкиваются из стека в порядке, обратном тому, в котором проталкивались: LIFO (Last In, First Out, первый вошел, последний вышел). Стековые структуры находят широкое применение при синтаксическом разборе арифметических выражений и алголоподобных языков программирования [Кормен/Лейзерсон/Ривест 2000].
Самая простая реализация стека — это массив и индекс последнего находящегося в стеке элемента (рис. 2.7). Этот индекс называется указателем стека (SP - Stack Pointer). Стек может расти как вверх, так и вниз [(рис. 2.8). Широко применяются также реализации стеков в виде односвяз-|ных списков.

Рис. 2.7. Стек на основе массива

Рис. 2.8. Стеки, растущие вверх и вниз

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

MOVE x, -(SP)

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

MOVE (SP)+, у

приведет к получению значения и продвижению указателя стека в обратном направлении. Поэтому первая команда имеет также мнемоническое обозначение

PUSH х

а вторая

POP у

Если мы поместим несколько значений в стек командой PUSH, команда POP вытолкнет их из стека в обратном порядке. Стек можно использовать для хранения промежуточных данных (см. пример 2.3) и при реализации арифметических выражений — например, команда

ADD (SP)+, (SP)

в точности воспроизводит описанную выше семантику безадресной команды ADD стековой архитектуры. Впрочем, безадресной команде ADD операнды не нужны, а в данном случае они просто не используются, но никуда не исчезают. Команда получается длиннее: у типичной стековой архитектуры команда сложения занимает 1 байт, у PDP-11 ее имитация занимает 2 байта, а у VAX — целых три. Поэтому, если мы хотим использовать стековую технику генерации кода, лучше использовать предназначенный для этого процессор.

Пример 2.3. Использование стека для хранения промежуточных значений

void swap(int &a, int &b) {
int t;
t=a;
a=b;
b=t;
}

;Для простоты мы не рассматриваем механизм передачи параметров
; и считаем, что они передаются в регистрах А и В
.GLOBL swap
swap:
POSH (A)
MOVE (B) , (A)
POP (B)
RET.

Одно из основных назначений стека в регистровых архитектурах — это сохранение адреса возврата подпрограмм. Кроме того, если принятое в системе соглашение о вызовах подпрограмм предполагает, что вызываемая процедура должна сохранить все или некоторые регистры, которые использует сама, стек обычно применяют и для этого.
Неортогональные процессоры, такие, как х86, часто предоставляют специальные команды PUSH и POP, работающие с выделенным регистром SP (Stack Pointer), который не может быть использован для других целей.


Косвенно-регистровый режим со смещением

Адрес операнда образуется путем сложения регистра и адресного поля команды. Этот режим наиболее богат возможностями и, в зависимости от стиля использования, имеет много других названий, например базовая адресация или индексная адресация. Адресное поле необязательно содержит полноценный адрес и может быть укороченным.
Команды id/st процессора SPARC, используемые в примере 2.2, реализуют косвенно-регистровую адресацию с 13-разрядным смещением.
Возможные варианты использования этого режима адресации многочисленны. Например, если смещение представляет собой абсолютный адрес начала массива, а в регистре хранится индекс, этот режим может использоваться для индексации массива. В этом случае смещение должно представлять собой полноценный адрес.
3 другом случае, в регистре может храниться указатель на структуру Данных, а смещение может означать смещение конкретного поля относительно начала структуры. Еще один вариант -- регистр хранит указатель на стековый кадр или блок параметров процедуры, а смещение -адрес локальной переменной в этом кадре или определенного параметра.
В этих случаях можно использовать (и обычно используется) укороченное смещение.

Стековый кадр

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

Рис. 2.9. Регистровый стек процессора SPARC

Для этого вызванная процедура уменьшает (если стек растет вниз) указатель стека на количество байтов, достаточное, чтобы разместить переменные. Адресация этих переменных у некоторых процессоров (например, у PDP-11) происходит относительно указателя стека, а у большинства — например, у МС680хО и VAX, с большим количеством регистров или у х86, указатель стека которого нельзя использовать для адресации со смещением — для этой цели выделяется отдельный регистр (рис. 2.10, пример 2.4).

Пример 2.4. Формирование, использование и уничтожение стекового кадра. Код на языке С и результат его обработки GNU С 2.7.2.1 (комментарии автора)

#include <stdio.h>
# include <strings.h>

/* Фрагмент примитивной реализации сервера SMTP (RFC822) */
int parse_line(FILE * socket)
{
/* Согласно RFC822, команда имеет длину не более 4 байт,а вся строка — не более 255 байт V char cmd[5], args [255]; fscanf (socket, "%s %s\n", cmd, args);
if (stricmpfcmd, "HELO")==0) {
fprintf (socket, "200 Hello, %s, glad to meet you\n", args);
return 200;
)
/* etc */
fprintf (socket, "500 Unknown command %s\n", cmd);
return 500;
.file "sample" gcc2_compiled. : _ gnu_compiled_c : .text LCO:
.ascii "%s %s\12\0" LCI:
.ascii "HELCAO" LC2:
.ascii "200 Hello, %s, glad to meet you\12\0" LC3:
.ascii "500 Unknown command %s\12\0"
.align 2, 0x90 .globl _parse_line _parse_line:
; x86 имеет для этой цели специальную команду enter, но она может ; формировать кадры размером не более 255 байт. В данном случае кадр ; имеет больший размер, и его необходимо формировать вручную.
pushl %ebp ; Сохраняем указатель кадра
; вызвавшей нас подпрограммы.
movl %esp, %ebp ; Формируем указатель нашего кадра
subl $264,%esp ; И сам кадр. ; Конец пролога функции
leal -264 (%ebp) , %еах ; Помещаем в стек указатель на args • pushl %eax
leal -8 (%ebp) , %еах ; ... на cmd
pushl %eax
pushl $LCO ; на строковую константу
; Наши собственные параметры тоже адресуются относительно кадра. movl 8(%ebp),%eax ; Параметр socket мы тоже проталкиваем pushl %eax ; в стек
call fscanf ; Вызов (параметры в стеке) addl $16,%esp ; очищаем стек
; в языке С переменное количество параметров, поэтому вычищать их из
; стека должна вызывающая процедура. Вызываемая просто не знает,
; СКОЛЬКО ИХ бЫЛО.
pushl $LC1
leal -8(%ebp),%eax
pushl %eax
call _stricmp
addl $8,%esp
movl %eax,%eax ; выключенная оптимизация в действии :)
; А ведь недалеки времена, когда компиляторы только такое и умели ; генерировать.
testl %eax,%еах
jne L14
leal -264(%ebp),%еах
pushl %eax
pushl $LC2
movl 8(%ebp),%eax
pushl %eax
call _fprintf
addl $12,%esp
; Обратите внимание, что компилятор не стал генерировать второй эпилог ; функции на втором операторе return.
movl $200,%eax
jmp L13
» Выравнивание потенциальных точек перехода на границу слова полезно: » процессор не будет тратить дополнительный цикл шины на чтение » невыровненной команды. Для выравнивания используется команда NOP ; (код операции 0x90).
align 2,0x90 L14:
leal -8(%ebp),%еах
Pushl %eax
Pushl $LC3
movl 8(%ebp),%eax pushl %eax
call _fprintf
addl ?12,%esp
movl $500,%eax
jmp L13
.align 2,0x90 L13:
; Команда leave совершает действия, обратные прологу функции: ; Она эквивалентна командам: move %ebp, %esp; pop %ebp. ; Размер кадра явным образом не указывается, поэтому ограничений ; на этот размер в данном случае нет.
leave
ret

Рис. 2.10. Стековый кадр

Примечание
Обратите внимание, что программа из примера 2.4 содержит серьезнейшую ошибку. В комментариях сказано, что команда обязана иметь длину не более 4 байт, а вся строка вместе с аргументами не более 255. Если программа-клиент на другом конце сокета (сетевого соединения) соответствует RFC822 [RFC822], так оно и будет. Но если программа требованиям этого документа не соответствует, нас ждет беда: нам могут предложить более длинную команду и/или более длинную строку. Последствия, к которым это может привести, будут более подробно разбираться в главе 12.

Но вернемся к стековым кадрам.

Стековые кадры в системе команд SPARC
Микропроцессоры SPARC также не могут обойтись без стекового кадра. Во-первых, не всегда локальные переменные процедуры помещаются в восьми 32-разрядных локальных регистрах. Именно такая процедура приведена в примере 2.4. Во-вторых, нередки ситуации, когда в качестве параметров надо передать по значению структуры, для которых 6 регистров-параметров тоже не хватит. В-третьих, глубина регистрового файла ограничена и при работе рекурсивных или просто глубоко вложенных процедур может исчерпаться. В-четвертых, в многозадачной системе регистровый файл может одновременно использоваться несколькими задачами. Все эти проблемы решаются при помощи создания стекового кадра [www.sparc.com v9].
Для этой цели используются регистры Isp (о6) и %fp (i6). Команда save %sp, -96 %sp делает следующее: она складывает первые два операнда, сдвигает стековый кадр и помещает результат сложения в третий операнд. Благодаря такому порядку исполнения отдельных операций, старый %sp становится %fp, а результат сложения помещается уже в новый %sp.
Самую важную роль стековые кадры играют при обработке переполнений регистрового файла. Регистровый файл SPARC представляет собой кольцевой буфер, доступность отдельных участков которого описывается привилегированными регистрами CANSAVE и CANRESTORE. Окна, находящиеся между значениями этих двух регистров, доступны текущей программе (рис. 2.11). На рисунке показано состояние регистрового файла, в котором текущий процесс может восстановить один стековый кадр (CANRESTORE=1) и сохранить три (CANSAVE=3). Регистр OTHERWIN указывает количество регистровых окон, занятых другим процессом. Регистровое окно w4 на рисунке (обозначенное как перекрытие) занято лишь частично. Текущее окно, частично занятое окно и участки регистрового файла, описанные перечисленными регистрами, в сумме должны составлять весь регистровый файл, так чтобы соблюдалось отношение CANSAVE + CANRESTORE + OTHERWIN = NWINDOWS - 2, Где NWINDOWS- количество окон (на рисунке регистровый файл имеет 8 окон, т. е. 128 регистров).

Рис. 2.11. Регистровый файл SPARC в виде кольцевого буфера. Регистры CANSAVE и CANRESTORE (цит. по [www.sparc.com v9])

Когда же программа пытается сдвинуть свое окно за описанные границы (в ситуации, изображенной на рис 2.11 это может произойти после вызовов четырех вложенных процедур или после возврата из двух процедур — текущей и соответствующей окну w7), генерируются исключительные состояния заполнения окна (window fill) и сброса окна (window spill). При этом вызывается системная процедура, которая освобождает окна из интервала OTHERWIN, сбрасывая их содержимое в стековые кадры соответствующих процедур и при заполнении восстанавливает содержимое принадлежащего нам окна из соответствующего кадра.
В многозадачной системе заполнение и сброс окна может произойти в любой момент, поэтому пользовательская программа всегда должна иметь по стековому кадру на каждое из используемых ею регистровых окон, а указатель на этот кадр должен всегда лежать в %sp соответствующего окна. При этом очень важно, чтобы создание стекового кадра и сдвиг регистрового окна производились одной командой.


Базово-индексный режим

В этом режиме адрес операнда образуется сложением двух или, реже, большего количества регистров и, возможно, еще и адресного смещения. Такой режим может использоваться для адресации массивов — один регистр содержит базовый адрес массива, второй — индекс, откуда и название. Иногда значение индексного регистра умножается на размер операнда, иногда — нет.
На первый взгляд, ортогональные архитектуры должны испытывать определенные сложности с-кодированием такой адресации: для этого нужно два регистровых поля, а большинство остальных режимов довольствуются одним регистром. Однако многие ортогональные архитектуры, например VAX, МС680хО, SPARC реализуют этот режим, пусть иногда и с ограничениями.

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

Индексный режим адресации в системе команд SPARC
SPARC позволяет использовать для вычисления адреса в командах LD, зт и JMPL как сумму двух регистров, так и сумму регистра и 13-разрядного смещения. Таким образом, эти команды реализуют либо косвенно-регистровый режим (если используется смещение и оно равно 0), либо косвенно-регистровый режим со смещением, либо базово-индексный режим без смещения. Это, конечно, беднее, чем у CISC-процессоров, но жить с таким набором вполне можно.

Рис. 2.12. Индексный режим адресации VAX

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


Сложные режимы адресации

реди промышленно выпускавшихся процессоров самым богатым набором экзотических режимов адресации обладает VAX. Кроме всех вышеперечисленных, предлагаются следующие.

  • Косвенный с постинкрементом: регистр содержит адрес слова, которое является адресом операнда. После адресации регистр увеличивается на 4.
  • Косвенный со смещением (не путать с косвенно-регистровым со смещением!): регистр со смещением адресует слово памяти, которое содержит адрес операнда — удобен для разыменования указателя без его загрузки в регистр.


Адресация с использованием счетчика команд

Любой процессор предоставляет как минимум один способ такой адресации: адресация самих команд при их последовательной выборке осуществляется при помощи счетчика команд с постинкрементом. У процессоров с командами переменной длины величина постинкремента зависит от кода команды.
Некоторые процессоры позволяют использовать счетчик команд наравне со всеми остальными регистрами общего назначения. Запись в этот регистр приводит к передаче управления по адресу, который соответствует записанному значению. Чтение из этого регистра позволяет узнать адрес текущей команды, что само по себе не очень полезно и часто может быть сделано Другими способами. Однако использование других режимов адресации со счетчиком команд порой позволяет делать неожиданные, но весьма полезные трюки.

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

Рис. 2.13. Реализация литеральной адресации через постинкрементную адресацию счетчиком команд

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

Пример 2.5. Реализация условного перехода с длинным смещением

Beq distant_label ; Перейти, если равно
; реализуется как
Bneq $1 ; Перейти, если не равно
Jmp distant_label
; У команд безусловного перехода обычно используется длинное смещение
; или абсолютный адрес
$1:

Относительные переходы в системе команд SPARC
У большинства CISC-процессоров адресное смещение в командах условного перехода ограничено одним байтом. У SPARC такие команды используют адресное поле длиной 22 бита. С учетом того факта, что команды у SPARC всегда выровнены на границу слова (4 байта), такая адресация позволяет непосредственно указать до 4М (4х220=4 194 304) команд или 16 Мбайт, т.е. целиком адресовать сегмент кода большинства реально используемых программ (рис. 2.14).

Рис. 2.14. Формат команд условного перехода и вызова процессора SPARC

Команда вызова подпрограммы у SPARC также использует адресацию относительно счетчика команд, но адресное поле у нее 30-разрядное и интерпретируется как адрес слова, а не байта. При сложении смещения и счетчика команд возможные переполнения игнорируются, поэтому такой командой можно адресовать любое слово (т. е. любую команду) в 32-разрядном адресном пространстве. На первый взгляд, неясно даже, какая польза от того, что адресация производится относительно счетчика команд, а не абсолютно. Но в 64-разрядных процессорах SPARC v9 польза от этого большая — абсолютный 30-разрядный адрес позволял бы адресовать только первое гигаслово памяти, а относительное смещение адресует именно сегмент кода, в какой бы части 64-разрядного адресного пространства он бы ни находился. Программ, имеющих объем более одной гигакоманды, или даже половины гигакоманды, пока что не написано, поэтому 30-разрядного смещения практически достаточно для адресации в пределах любой современной программы.

Процессоры, не предоставляющие программисту прямого доступа к счетчику команд, зачастую все-таки дают возможность записывать в него произвольные значения при помощи специальных команд вычислимого перехода и вычислимого вызова. Команды вычислимого вызова широко используются для реализации указателей на функции из таблиц виртуальных методов в объектно-ориентированных языках. Главное применение команд вычислимого перехода -- реализация операторов switch языка C/C++ или case языка Pascal.


Банки памяти

Банки памяти используются, когда адресное пространство процессора мало, а приложение требует. При этом стоимостные и электротехнические ограничения позволяют нам установить в систему гораздо больше памяти, чем процессор может адресовать. Например, у многих" микроконтроллеров адрес имеет длину всего 8 бит, однако 256 байт данных, и тем более 256 команд кода для большинства приложений недостаточно. Многие из ранних персональных компьютеров, основанных на 8-разрядных микропроцессорах i8085 и Z80 с 16-разрядным адресом, имели гораздо больше 64 Кбайт памяти. Например, популярные в годы детства авторов компьютеры Yamaha имели до 2 Мбайт оперативной памяти.
Адресация дополнительной памяти в этой ситуации обеспечивается дополнительным адресным регистром, который может быть как конструктивным элементом процессора, так и внешним устройством. Этот регистр дает нам дополнительные биты адреса, которые и обеспечивают адресацию дополнительной памяти. Регистр этот называется расширителем адреса или селектором банка, а область памяти, которую можно адресовать, не изменяя селектор банка, - банком памяти. Значение регистра-селектора называют номером банка.

Банковая адресация в 16-разрядных микропроцессорах
Внимательный читатель, знакомый с системой команд Intel 8086, не может не отметить, что "сегментные" регистры этого процессора имеют мало общего с собственно сегментацией, описываемой в главе 5. Эти регистры более похожи на причудливый гибрид селектора банков и базового регистра. Как и описываемый далее PIC, I8086 имеет команды "ближних" (внутрибанковых) и "дальних" (межбанковых) переходов, вызовов и возвратов.
Относящийся к тому же поколению процессоров Zylog 800 имеет полноценные селекторы банков. Из всех изготовителей 16-разрядных микропроцессоров только инженеры фирмы Motorola осмелились расширить адрес до 24 бит (это потребовало увеличения разрядности регистров и предоставления команд 32-разрядного сложения), все остальные так или иначе экспериментировали с селекторами банков и вариациями на эту тему.

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

Банки команд в Р/С
У микроконтроллеров PIC арифметические операции производятся только над младшими 8 битами счетчика команд, поэтому относительные и вычислимые переходы допустимы только в пределах 256-командного банка. Однако полное — с учетом селектора банка — адресное пространство для команд достигает 64 Кбайт, а у старших моделей и 16 Мбайт за счет использования двух регистров-расширителей. Переключение банка осуществляется специальными командами "длинного" — межбанкового — перехода.

Если банковая адресация реализована как внешнее устройство, проблема межбанковой передачи управления встает перед нами в полный рост. Поскольку мы не имеем команд межбанкового перехода, любой такой переход состоит минимум из двух команд: переключения банка и собственно перехода. Каждая из них нарушает порядок исполнения команд.
Рассмотрим ситуацию детальнее (рис. 2.15): из кода, находящегося в банке 1 по адресу OxlOaf, мы хотим вызвать процедуру, находящуюся в банке 2 по адресу 0x2000. Если мы сначала выполним переключение банка, мы окажемся в банке 2 по адресу ОхЮЬО, не имея никакого представления о том, какой же код или данные размещены по этому адресу. С той же проблемой мы столкнемся, если сначала попытаемся сделать переход по адресу Oxlfff.
В качестве решения можно предложить размещение по адресу Oxlfff в банке 1 команды переключения на банк 2. Возможно, для этого придется переместить какой-то код или данные, но мы попадем по желаемому адресу. Впро-Чем, если мы постоянно осуществляем межбанковые переходы, этот подход Потребует вставки команд переключения банка для каждой возможной точки входа во всех остальных банках. Ручное (да и автоматизированное)размещение этих команд — операция чрезвычайно трудоемкая, и возникает естественная идея: сконцентрировать все эти вставленные команды и соответствующие им точки входа в каком-то одном месте. Впрочем, даже эта идея не дает нам ответа на вопрос, как же при такой архитектуре возвращать управление из процедур? Вставлять команду переключения еще и для каждой команды вызова?

Рис. 2.15. Межбанковый переход

Развитие этой идеи приводит нас к чему-то, похожему на менеджер оверлеев (см. разд. Оверлеи (перекрытия). программный модуль, который присутствует во всех банках по одному и тому же адресу (рис. 2.16). Если нам нужно вызвать известную процедуру в определенном банке, мы передаем ее адрес и номер банка этому модулю, и он осуществляет сохранение текущего банка, переключение и переход. Если процедура делает возврат, она возвращает управление тому же модулю, который, в свою очередь, восстанавливает исходный банк и возвращает управление в точку вызова.
Дальнейшее развитие этой идеи приводит к мысли, что самый простой способ разместить этот код во всех банках — это усложнить схему работы селектора банков, например, всегда отображать первый килобайт адресного пространства на одни и те же физические адреса. Аппаратно это несложно: мы анализируем старшие шесть битов адресной шины процессора. Если они не равны нулю, мы подаем на старшие биты адресной шины памяти содержимое селектора банка, если же равны — нулевые биты. Примерно этим способом и расширяют память большинство микрокомпьютеров на основе 8-разрядных процессоров.
Поскольку мы вступили на путь анализа логического адреса, можно пойти и дальше: разбить адресное пространство процессора на несколько банков, каждый со своим селектором.

Рис. 2.16. Переключатель банков

Адресное пространство PDP-11
Машины серии PDP-11 имеют 16-разрядный адрес, который позволял адресовать 64 Кбайт. У старших моделей серии это пространство разбито на 8 сегментов по 8 Кбайт каждый. Каждому из этих сегментов соответствует свой селектор банка (в данном случае следует уже говорить о дескрипторе сегмента) (рис. 2.17). Физическое адресное пространство, которое может быть охвачено дескрипторами сегментов, составляет 2 Мбайт, что намного больше адресов, доступных отдельному процессу. На первый взгляд, эта конструкция представляет собой усложненную реализацию банковой адресации, цель которой — только расширить физическое адресное пространство за пределы логического, но тот факт, что, кроме физического адреса, каждый сегмент имеет и другие атрибуты, в том числе права доступа, заставляет нас признать, что это уже совсем другая история, заслуживающая отдельной главы (см. главу 5).

Рис. 2.17. Виртуальная память PDP-11/20


CISC- и RISC-процессоры

Часто приходится сталкиваться с непониманием термина RISC, общепринятая расшифровка которого — Reduced Instruction Set Computer (компьютер с уменьшенной системой команд). Какой же, говорят, SPARC или PowerPC -RISC, если у него количество кодов команд не уступает или почти не уступает количеству команд в х8б? Почему тогда х86 не RISC, ведь у него команд гораздо меньше и они гораздо проще, чем у VAX 11/780, считающегося классическим примером CISC-архитектуры. Да и технологии повышения производительности у современных х86 и RISC-процессоров используются примерно те же, что и у больших компьютеров 70-х: множественные АЛУ, виртуальные регистры, динамическая перепланировка команд.
В действительности, исходно аббревиатура RISC расшифровывалась несколько иначе, а именно как Rational Instruction Set Computer (RISC, компьютер с рациональной системой команд). RISC-процессоры, таким образом противопоставлялись процессорам с необязательно сложной (CISC - Complex Instruction Set Computer, компьютер со сложной системой команд), но "иррациональной", исторически сложившейся архитектурой, в которой, в силу требований бинарной и ассемблерной совместимости с предыдущими поколениями, накоплено множество команд, специализированных регистров и концепций, в общем-то и не нужных, но вдруг отменишь команду двоично-десятичной коррекции, а какое-то распространенное приложение "сломается"? А мы заявляли бинарную совместимость. Скандал!
Понятно, что быть "рациональными" в таком понимании могут лишь осваивающие новый рынок разработчики, которых не заботит та самая бинарная совместимость. Уже сейчас, например, фирма Sun, одним из главных достоинств своих предложений на основе процессоров UltraSPARC числит полную бинарную совместимость с более ранними машинами семейства SPARC. Новые процессоры вынуждены поддерживать бинарную совместимость с 64-разрядными младшими родственниками и режим совместимости с 32-разрядными. Где уж тут заботиться о рациональности.
С другой стороны, рациональность тоже можно понимать по-разному. В конце 70-х и первой половине 80-х годов общепринятым пониманием "рациональности" считалась своеобразная (с высоты сегодняшнего дня) ориентация на языки высокого уровня. Относительно примитивные трансляторы тех времен кодировали многие операции, например прологи и эпилоги процедур, доступ к элементу массива по индексу, вычислимые переходы (switch в С, Case в Pascal) при помощи стандартных последовательностей команд. Поскольку все меньше и меньше кода писалось вручную и все больше и больше — генерировалось трансляторами, разработчики процессоров решили пойти создателям компиляторов навстречу.
Этот шаг навстречу выразился в стремлении заменить там, где это возможно, последовательности операций, часто встречающиеся в откомпилированном коде, одной командой. Среди коммерчески успешных архитектур апофеозом этого подхода следует считать семейство миникомпьютеров VAX фирмы DEC, в котором одной командой реализованы не только пролог Функции и копирование строки символов, но и, скажем, операции удаления и вставки элемента в односвязный список. Приведенная в качестве примера шестиадресной команды команда INDEX — реальная команда этого процессора. Отдельные проявления этой тенденции без труда прослеживаются и в системах команд MC68000 и 8086. Аббревиатура CISC позднее использовалась именно для характеристики процессоров этого поколения.
- Другой стороны, неумение трансляторов этого поколения эффективно Размещать переменные и промежуточные значения по регистрам считалось Доводом в пользу того, что от регистров следует отказываться и заменять их Регистровыми стеками или кэш-памятью. (Впрочем, и у VAX, и у MC68000 с Регистрами общего назначения было все в порядке, по 16 штук.)
Ко второй половине 80-х развитие технологий трансляции позволило заменить генерацию стандартных последовательностей команд более интеллектуальным и подходящим к конкретному случаю кодом. Разработанные технологии оптимизации выражений и поиска инвариантов цикла позволяли, в частности, избавляться от лишних проверок. Например, если цикл исполняется фиксированное число раз, а счетчик цикла используется в качестве индекса массива, не надо вставлять проверку границ индекса в тело цикла — достаточно убедиться, что ограничитель счетчика не превосходит размера массива. Более того, если счетчик используется только в качестве индекса, можно вообще избавиться и от него, и от индексации, а вместо этого использовать указательную арифметику. Наконец, если верхняя граница счетчика фиксирована и невелика, цикл можно развернуть (пример 2.6).

Пример 2.6. Эквивалентные преобразования программы

/* Пример возможной стратегии оптимизации.
* Код, вставляемый компилятором для проверки границ индекса,
* выделен при помощи нестандартного выравнивания. */
int array[100];
int bubblesort(int size) ) int count; do {
count=0;
for(i=l; i<100; i++) {
if (i<0 || i>100) raise(IndexOverflow); if (i-l<0 || i-l>100) raise(IndexOverflow); if (array[i-1]<array[i]) { if (i<0 || i>100) raise(IndexOverflow);
int t=array[i];
if (i<0 || i>100) raise(IndexOverflow); if (i-l<0 || i-l>100) raise(IndexOverflow);
array[i]=array[i-1];
if (i-l<0 II i-l>100) raise(IndexOverflow); array[i-1]=t; count++; I
while (count != 0) ;
// оптимизированный внутренний цикл может выглядеть так: register int *ptr=array; register int *limit=ptr; register int t=*ptr++;
if (size<100) limit+=size; else limit+=100;
while (ptr<limit) { if (t<*ptr) { ptr[-l]=*ptr;
*ptr++=t; count++; ) else t=*ptr++;
}
if (size>100) raise (IndexOverf low) ;

По мере распространения в мини- и микрокомпьютерах кэшей команд и данных, а также конвейерного исполнения команд, объединение множества действий в один код операции стало менее выгодным с точки зрения производительности.
Это привело к радикальному изменению взглядов на то, каким должен быть идеальный процессор, ориентированный на исполнение откомпилированного кода. Во-первых, компилятору не нужна ни бинарная, ни даже ассемблерная совместимость с чем бы то ни было (отсюда "рациональность"). Во-вторых, ему требуется много взаимозаменяемых регистров — минимум тридцать два, а на самом деле чем больше, тем лучше. В-третьих, сложные комбинированные команды усложняют аппаратуру процессора, а толку от них все равно нет, или мало.
Коммерческий успех процессоров, построенных в соответствии с этими взглядами (SPARC, MIPS, PA-RISC) привел к тому, что аббревиатура
USC стала употребляться к месту и не к месту — например, уже упоминавшийся Transputer (имевший регистровый стек и реализованный на Уровне системы команд планировщик, т. е. являющийся живым воплощением описанного ранее CISC-подхода) в документации называли RISC-процессором, фирма Intel хвасталась, что ее новый процессор Pentium построен на RISC-ядре (что под этим ни подразумевалось?) и т. д.


Языки ассемблера

Непосредственно на машинном языке в наше время не программирует практически никто. Первый уровень, позволяющий абстрагироваться от схемы кодирования команд, — это уже упоминавшийся язык ассемблера. В языке ассемблера каждой команде машинного языка соответствует мнемоническое обозначение. Все приведенные ранее примеры написаны именно на языке ассемблера, да и в тексте использовались не бинарные коды команд, а их мнемоники.
Встречаются ассемблеры, которые предоставляют мнемонические обозначения для часто используемых групп команд. Большинство таких языков позволяет пользователю вводить свои собственные мнемонические обозначения — так называемые макроопределения или макросы (macros), в том числе и параметризованные (пример 2.7).
Отличие макроопределений от процедур языков высокого уровня в том, что процедура компилируется один раз, и затем ссылки на нее реализуются в виде команд вызова. Макроопределение же реализуется путем подстановки тела макроопределения (с заменой параметров) на место ссылки на него и компиляцией полученного текста. Компиляция ассемблерного текста, таким образом, осуществляется в два или более проходов — на первом осуществляется раскрытие макроопределений, на втором — собственно компиляция, которая, в свою очередь, может состоять из многих проходов, смысл которых мы поймем далее. Часть ассемблера, реализующая первый проход, называется макропроцессором.

Пример 2.7. Пример использования макроопределений

; Фрагмент драйвера LCD для микроконтроллера PIC
; (с) 1996, Дмитрий Иртегов.
; Таблица знакогенератора: 5 байт/символ.
; W содержит код символа. Пока символов может быть
; только 50, иначе возникнет переполнение.
; Scanline содержит номер байта (не строки!)
; Сначала определим макрос, а то устанем таблицу сочинять. ; Необходимо упаковать 7 скан-строк по 5 бит в 5 байт.
CharDef macro scanl, scan2, scan3, scan4, scanS, зсапб, scan7 ; Следующий символ
RetLW (scan? & Oxlc) » 2
RetLW ((scan5 E, 0x10) » 4) + ((зсапб S Oxlf) « 1) + ((scan7 & 0x3) «
6)
RetLW ((scan4 & Oxle) » 1) + ((scanb & Oxf) « 4)
RetLW ((scan2 & 0x18) » 3) + ((зсапЗ & Oxlf) « 2) + ( (scan4 & Oxl) « 7)
RetLW (scanl & Oxlf) + ( (scan2 & 0x7) « 5)
endm
FetchOneScanline IFNDEF NoDisplay
ClrF PCLATH
AddWF PCL, 1
NOP ; else
RetLW 0 endif
; А вот идет собственно таблица:
Nolist
; О
CharDef В'OHIO',В110001',В'10001',В'10001',В'10001',В'10001',В'01110' ; 1
CharDef В'00100',В'01100',В'00100',В'00100',В100100',В'00100',В'01110' ; 2
CharDef В'OHIO',В'10001',В'00001',В'00010',В100100',В'01000',В'11111' ; 3
CharDef В'01110',В'10001',В'00001',В'00110',В100001',В110001',В'OHIO' ; 4
CharDef В'00010',В'00110',В'01010',В'10010',В'11111',В'00010', В'00010' ; 5
CharDef В'11111',В'10000',В'11110',В'00001',В'00001',В110001',В1OHIO' ; б
CharDef В'OHIO1,840001' ,В' 10000',В' НПО' ,В' 10001' ,В'10001' ,В'OHIO1 ; 7
CharDef В'11111',В'00001',В'00010',В'00100',В'01000',В'01000',В'01000'
; 8
CharDef В'OHIO', В'10001' ,В'10001' ,В'OHIO' , В'10001', В'10001' ,В'OHIO' ; 9
CharDef В'OHIO', В'10001', В'10001' ,В'01111' ,В'00001' ,В'10001' ,В'OHIO' 4 Зэк Х(,
Constant CharacterA = Oxa
CharDef В ' 00100 ', В ' 01010 ', В ' 10001', В110001' ,841111' , В' 10001' ,В' 10001' Ifndef NO_ALPHABET ; В Constant CharacterW = Oxb
CharDef В'11110',В'10001',B'10001',В'11110' ,В'10001' ,В'10001' ,В'11110' else ; Р — для аона
CharDef В' 11110 ',840001', В '10001', В '10001', В' 11110 ',840000', В '10000' endif ; С
CharDef В'01110',В'10001',В'10000',ВЧОООО',В'10000',В110001',В'01110' ; о
CharDef В' 11110 ',В' 10001 ',В' 10001 ',В' 10001 ',840001', В' 10001 ',841110' ; Е
CharDef B'lllll' ,ВЧ0001', В'10000', В'11110 ' ,В'10000', В'10001' ,В'11111' ; F
CharDef В'11111',В'10001',В'10000',В'11110',В'10ЮОО',В'10000',В'10000'
; пробел Constant SPACE_CHARACTER = 0x10
CharDef В'00000',В'ООООО',В'ООООО',В'00000',В'00000',В100000',В'00000'
Constant DASH_CHARACTER = Oxll
CharDef В'00000',В'00000',В'00000',В'11111',В'00000',В'ООООО',В'ООООО'
List

Макропроцессор, кроме раскрытия макросов, обычно предоставляет также директивы условной компиляции — в зависимости от условий, те или иные участки кода могут передаваться компилятору или нет. Условия, конечно же, должны быть известны уже на этапе компиляции. Например, в зависимости от типа целевого процессора одна и та же конструкция может реализоваться как в одну команду, так и эмулирующей программой. В зависимости от используемой операционной системы могут применяться разные системные вызовы (это чаще случается при программировании на языках высокого уровня), или в зависимости от значений параметров макроопределения, макрос может порождать совсем разный код.
Макросредства есть не только в ассемблерах, но и во многих языках высокого уровня (ЯВУ). Наиболее известен препроцессор языка С. В действительности, многие средства, предоставляемые языками, претендующими на большую, чем у С, "высокоуровневость" (что бы под этим ни подразумевалось), также реализуются по принципу макрообработки, т. е. при помощи текстовых подстановок и компиляции результата: шаблоны (template) C++, параметризованные типы Ada и т. д.
Умелое использование макропроцессора облегчает чтение кода и увеличивает возможности его повторного использования в различных ситуациях. Злоупотребление же макросредствами (как, впрочем, и многими другими мощными и выразительными языковыми конструкциями) или просто бестолковое их применение может приводить к совершенно непонятному коду и трудно диагностируемым ошибкам, поэтому многие теоретики программирования выступали за полный отказ от использования макропроцессоров.
Современные методы оптимизации в языках высокого уровня — проверка константных условий, разворачивание циклов, inlme-функции — часто стирают различия между макрообработкой и собственно компиляцией.
Кроме избавления программиста от необходимости запоминать коды команд, ассемблер выполняет еще одну, пожалуй, даже более важную функцию: он позволяет снабжать символическими именами (метками) или (символами) команды или ячейки памяти, предназначенные для данных. Значение этой возможности для практического программирования трудно переоценить.
Рассмотрим простой пример из жизни: мы написали программу, которая содержит команду перехода (бывают и программы, которые ни одной команды перехода не содержат, но это вырожденный случай). Затем, в процессе тестирования этой программы или уточнения спецификаций мы поняли, что между командой перехода и точкой, в которую переход совершается, необходимо вставить еще два десятка команд. Для вставки необходимо пересчитать адрес перехода. На практике, вставка даже одной только инструкции часто затрагивает и приводит к необходимости пересчитывать адреса множества команд перехода, поэтому возможность автоматизировать этот процесс крайне важна.
Важное применение меток — организация ссылок между модулями в программах, собираемых из нескольких раздельно компилируемых файлов. Изменение объема кода или данных в любом из модулей приводит к необходимости пересчета адресов во всех остальных модулях. В современных программах, собираемых из сотен отдельных файлов и содержащих тысячи индивидуально адресуемых объектов, выполнять такой пересчет вручную невозможно. Способы автоматического решения этой задачи обсуждаются в разд. Сборка программ.
Фаза сопоставления символов с реальными адресами присутствует и при компиляции языков высокого уровня — компилятор генерирует символы не только для переменных, процедур и меток, которые могут быть использованы в операторе goto, но и для реализации "структурных" условных операторов и циклов. Нередко в описании компилятора эту фазу так и называют — ассемблирование.
Многие компиляторы как старые, так и современные, например, популярный компилятор GNU С, даже не выполняют фазу ассемблирования самостоятельно, а вместо этого генерируют текст на языке ассемблера и вызывают внешний ассемблер. Средства межпроцессного взаимодействия современных ОС позволяют передавать этот промежуточный текст, не создавая промежуточного файла, поэтому для конечного пользователя эта деталь реализации часто оказывается незаметной.
Компиляторы, имеющие встроенный ассемблер, такие, как Microsoft C/C++ или Watcom, часто могут генерировать ассемблерное представление порождаемого кода. Это бывает полезно при отладке или написании подпрограмм на ассемблере, которые должны взаимодействовать с откомпилированным кодом.


Многопроходное ассемблирование

При ассемблировании с использованием меток возникает специфическая проблема: команды могут ссылаться на метки, определенные как до, так и после них по тексту программы. Следовательно, операндом команды может оказаться метка, которая еще не определена. Адрес, соответствующий этой метке, еще неизвестен, поэтому мы должны будем, так или иначе, вернуться к ссылающейся на нее команде и записать адрес. Эта же проблема возникает и при компиляции ЯВУ: предварительное определение переменных и процедур указывает тип переменной и количество и типы параметров процедуры, но не их размещение в памяти, а именно оно нас и интересует при генерации кода.
Две техники решения этой проблемы называются одно- и двухпроходным ассемблированием [Баррон 1974].
При двухпроходном ассемблировании, на первом проходе мы определяем адреса всех описанных в программе символов и сохраняем их в промежуточной таблице. На втором проходе мы осуществляем собственно ассемблирование — генерацию кода и расстановку адресов. Если адресное поле имеет переменную длину, определение адреса метки может привести к изменению длины ссылающегося на нее кода, поэтому на таких архитектурах оказывается целесообразным трех- и более проходное ассемблирование. При однопроходном ассемблировании, мы запоминаем все точки, из которых происходят ссылки вперед, и, определив адрес символа, возвращаемся к этим точкам и записываем в них адрес. При однопроходном ассемблировании целесообразно хранить код, в котором еще не все метки расставлены, в оперативной памяти, поэтому в старых компьютерах двухпроходные ассемблеры были широко распространены. Впрочем, современные многопроходные ассемблеры также хранят промежуточные представления программы в памяти, поэтому количество проходов в конкретной реализации ассемблера представляет разве что теоретический интерес.


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

    Загрузка программ
  • Абсолютная загрузка
  • Разделы памяти
  • Относительная загрузка
  • Базовая адресация
  • Позиционно-независимый код
  • Оверлеи (перекрытия)
  • Сборка программ
  • Объектные библиотеки
  • Сборка в момент загрузки
  • Динамические библиотеки
  • Загрузка самой ОС


Загрузка программ

Выяснив, что представляет собой программа, давайте рассмотрим процедуру ее загрузки в оперативную память компьютера (многие из обсуждаемых далее концепций, впрочем, в известной мере применимы и к прошивке программы в ПЗУ).
Для начала предположим, что программа была заранее собрана в некий единый самодостаточный объект, называемый загрузочным или загружаемым модулем. В ряде операционных систем программа собирается в момент загрузки из большого числа отдельных модулей, содержащих ссылки друг на друга, но об этом ниже.
Для того чтобы не путаться, давайте будем называть программой ту часть загрузочного модуля, которая содержит исполняемый код. Результат загрузки программы в память будем называть процессом или, если нам надо отличать загруженную программу от процесса ее исполнения, образом процесса. К образу процесса иногда причисляют не только код и данные процесса (подвергнутые преобразованию как в процессе загрузки, так и в процессе работы программы), но и системные структуры данных, связанные с этим процессом. В старой литературе процесс часто называют задачей.
В системах с виртуальной памятью каждому процессу обычно выделяется свое адресное пространство, поэтому мы иногда будем употреблять термин процесс и в этом смысле. Впрочем, во многих системах значительная часть эдресных пространств разных процессов перекрывается — это используется Для реализации разделяемого кода и данных.
В рамках одного процесса может исполняться один или несколько потоков или нитей управления. Это понятие будет подробнее разбираться в главе 8.
Некоторые системы предоставляют и более крупные структурные единицы, Чем процесс. Например, в системах семейства Unix существуют группы процессов, которые используются для реализации логического объединенияют не только код и данные процесса (подвергнутые преобразованию как в процессе загрузки, так и в процессе работы программы), но и системные структуры данных, связанные с этим процессом. В старой литературе процесс часто называют задачей.
В системах с виртуальной памятью каждому процессу обычно выделяется свое адресное пространство, поэтому мы иногда будем употреблять термин процесс и в этом смысле. Впрочем, во многих системах значительная часть эдресных пространств разных процессов перекрывается — это используется Для реализации разделяемого кода и данных.
В рамках одного процесса может исполняться один или несколько потоков или нитей управления. Это понятие будет подробнее разбираться в главе 8.
Некоторые системы предоставляют и более крупные структурные единицы, чем процесс. Например, в системах семейства Unix существуют группы процессов, которые используются для реализации логического объединения процессов в задания (job). Ряд систем имеют также понятие сессии — совокупности всех заданий, которые пользователь запустил в рамках одного сеанса работы. Впрочем, соответствующие концепции часто плохо определены, а их смысл сильно меняется от одной ОС к другой, поэтому мы практически не будем обсуждать эти понятия.
В более старых системах и в старой литературе называют результат загрузки задачей, а процессами — отдельные нити управления.
Однако в наиболее распространенных ныне ОС семейств Unix и Win32, принято задачу называть процессом, а процесс — нитью (tread).
Этой терминологии мы и будем придерживаться, кроме тех случаев, когда будем обсуждать примеры из жизни ОС, в которой принята иная терминология.

Создание процессов в Unix
В системах семейства Unix новые процессы создаются системным вызовом fork. Этот вызов создает два процесса, образы которых в первый момент полностью идентичны, у них различается только значение, возвращенное вызовом fork. Типичная программа, использующая этот вызов, выглядит так, как представлено в примере 3.1.
При этом каждый из процессов имеет свою копию всех локальных и статических переменных. На процессорах со страничным диспетчером памяти физического копирования не происходит. Изначально оба процесса используют одни и те же страницы памяти, а дублируются только те из них, которые были изменены. На системах, не имеющих страничного или сегментного диспетчера памяти, fork требует копирования адресных пространств, что приводит к большим накладным расходам, да и просто не всегда возможно.
Если мы хотим запустить другую программу, то мы должны исполнить системный вызов из семейства exec. Вызовы этого семейства различаются только способом передачи параметров. Все они прекращают исполнение текущего образа процесса и создают новый процесс с новым виртуальным адресным пространством, но с тем же идентификатором процесса. При этом у нового процесса будет тот же приоритет, будут открыты те же файлы (это часто используется), и он унаследует ряд других важных характеристик.
Несколько неожиданное, но тем не менее верное описание действия exec — это замена образа процесса в рамках того же самого процесса.
Запуск другой программы в UNIX выглядит примерно так, как представлено в примере 3.2.
Программа в примере 3.2 запускает командный интерпретатор /bin/sh, известный как Bourne shell, приказывает ему исполнить команду Is -1 и перенаправляет стандартный вывод этой команды в файл ls.log.
Техника программирования, основанная на fork/exec, несколько отличается от принятой во многих других современных системах, в том числе Win32, где при создании нового процесса мы сразу же указываем программу, которую он будет исполнять.

Пример 3.1. Создание процесса в системах семейства Unix

;nt pid; /* Идентификатор порожденного процесса */
switch(pid = fork())
I
case 0: /* Порожденный процесс */
break; case -1: /Ошибка */
perror("Cannot fork");
extt(l) ; default: /* Родительский процесс */
/* Здесь мы можем ссылаться на порожденный процесс,
* используя значение pid */

Пример 3.2. Создание процесса и замена программы в системах семейства Unix

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

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


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

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

  • Система может предоставить каждому процессу свое адресное пространство. Это возможно только на процессорах, осуществляющих трансляцию виртуального адреса в физический.
  • Система может исполнять в каждый момент только один процесс. Так ведет себя СР/М, так же устроено большинство загрузочных мониторов для самодельных компьютеров. Похожим образом устроена система RT-11, но о ней чуть ниже.

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

Формат загрузочного модуля a.out
В системе UNIX на 32-разрядных машинах также используется абсолютная загрузка. Загружаемый файл формата a.out (современные версии Unix используют более сложный формат загружаемого модуля и более сложную схему загрузки, которая будет обсуждаться в разд. Разделяемые библиотеки) начинается с заголовка (рис. 3.1), который содержит:
• "магическое число" — признак того, что это именно загружаемый модуль, а не что-то другое;
• число TEXT_SIZE — длину области кода программы (TEXT);
• DATA_SIZE —длину области инициализованных данных программы (DATA);
• BSS_SIZE —длину области неинициализованных данных программы (BSS);
• стартовый адрес программы.
За заголовком следует содержимое областей TEXT и DATA. Затем может следовать отладочная информация. Она нужна символьным отладчикам, но самой программой не используется.
При загрузке система выделяет процессу TEXT_SIZE байтов виртуальной памяти, доступной для чтения/исполнения, и копирует туда содержимое сегмента TEXT. Затем отсчитывается DATA_SIZE байтов памяти, доступной для чтения/ записи, и туда копируется содержимое сегмента DATA. Затем отсчитывается
еще BSS_SIZE байтов памяти, доступной для чтения/записи, которые прописываются нулями.
Очистка выделяемой памяти нужна не столько для удобства программиста, сколько по соображениям безопасности: перед вновь загружаемым процессом эту память могли занимать (а при сколько-нибудь длительной работе системы почти наверняка занимали) другие процессы, которые могли использовать эту память для хранения важных и секретных данных, например паролей или ключей шифрования.
После этого выделяется пространство под стек, в стек помещаются позиционные аргументы и среда исполнения (environment), и управление передается на стартовый адрес. Процесс начинает исполняться.

Рис. З.1. Загрузочный модуль a.out


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

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

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

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


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

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

Рис. 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_SIGNATURE 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 ejrainalloc; // Минимальное количество дополнительных параграфов
ORD e_maxalloc; // Максимальное количество дополнительных параграфов
WORD e ss; // Начальное (относительное) значение SS
WORD e_sp; // Начальное значение SP
WORD e_csum; // Контрольная сумма
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, РIMAGE DOS HEADER;

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

Рис. 3.5. Распределение памяти в RT-11 с одним загруженным sav-файлом и двумя rel-файлами


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

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

Рис. З.6. Перемещение кода, использующего базовую адресацию

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


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

За всеми этими разговорами мы чуть было не забыли о третьем способе формирования адреса в программе. Это относительная адресация, когда адрес получается сложением адресного поля команды и адреса самой этой команды — значения счетчика команд. Код, в котором используется только такая адресация, можно загружать с любого адреса без всякой перенастройки. Такой код называется позиционно-независимым (position-independent).
Позиционно-независимые программы очень удобны для загрузки, но, к сожалению, при их написании следует соблюдать довольно жесткие ограничения, накладываемые на используемые в программе методы адресации. Например, нельзя пользоваться статически инициализованными переменными указательного типа, нельзя делать на ассемблере фокусы, вроде того, который был приведен в примере 3.5, и т. д. Возникают сложности при сборке программы из нескольких модулей.
К тому же, на многих процессорах, например, на Intel 8080/8085 или многих современных RISC-процессорах, описанная выше реализация позиционно-независимого кода вообще невозможна, так как эти процессоры не поддерживают соответствующий режим адресации для данных. На процессорах гарвардской архитектуры адресовать данные относительно счетчика команд вообще невозможно — команды находятся в другом адресном пространстве.
Поэтому такой стиль программирования используют только в особых случаях. Например, многие вирусы для MS DOS и драйверы для RT-I1 написаны именно таким образом.

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

Позиционно-независимый код в современных Unix-системах
Компиляторы современных систем семейства UNIX — GNU С или стандартный С-компилятор UNIX SVR4 имеют ключ -f PIC (Position-Independent Code). Впрочем, код, порождаемый при использовании этого ключа, не является позиционно-независимым в указанном выше смысле: этот код все-таки содержит перемещаемые адресные ссылки. Задача состоит не в том, чтобы избавиться от таких ссылок полностью, а лишь в том, чтобы собрать все эти ссылки в одном месте и разместить их, по возможности, отдельно от кода. Какая от этого польза, мы поймем несколько позже, в разд. Разделяемые библиотеки, а сейчас обсудим технические приемы, используемые для решения этой задачи.
Код, генерируемый GNU С, использует базовую адресацию: в начале функции адрес точки ее входа помещается в один из регистров, и далее вся адресация других функций и данных осуществляется относительно этого регистра. На процессоре х86 используется регистр %ebx, а загрузка адреса осуществляется командами, вставляемыми в пролог каждой функции (пример 3.6).
На процессорах, где разрешен прямой доступ к счетчику команд, соответствующий код выглядит проще, но принцип сохраняется: компилятор занимает один регистр и благодаря этому упрощает работу загрузчику.
Как мы видим в примере 3.7, на самом деле адресация происходит не относительно точки входа в функцию, а относительно некоторого объекта, называемого GOT или GLOBAL_OFFSET_TABLE. Счетчик команд используется для вычисления адреса этой таблицы, а не сам по себе. Подробнее мы разберемся с логикой работы этого кода (и заодно с тем, что означает еще один непонятный символ — PLT) в разд. Разделяемые библиотеки.
Компилированный таким образом код предназначен в первую очередь для разделяемых библиотек формата ELF (Executable and Linking Format, формат исполняемых и собираемых [модулей], используемый большинством современных систем семейства Unix).

Пример 3.6. Получение адреса точки входа в позиционно-независимую подпрограмму

call L4
L4:
popl %ebx

Пример 3.7. Позиционно-независимый код, порождаемый компилятором GNU С

/* strerror.c (emx+gcc) — Copyright (с) 1990-1996 by Eberhard Mattes */
#include <stdlib.h>
#include <string.h>
#include <emx/thread.h>

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 (rasg) — 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"
.Icomm _result.3,32
.text
.align 2, 0x90
. globl _strerror
__strerror:
pushl %ebp
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,0x90 jmp L3 .align 2,0x90
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
movl %edx,%eax
jmp LI
.align 2,0x90
L3:
LI:
movl -4 (%ebp),%ebx
leave
ret


Оверлеи (перекрытия)

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

Рис. 3.7. Образ процесса с несколькими оверлеями

Потребность в таком способе загрузки появляется, если у нас виртуальное адресное пространство мало, например 1 Мбайт или даже всего 64 Кбайт (на некоторых машинах с RT-11 бывало и по 48 Кбайт, и многие полезные программы нормально работали!), а программа относительно велика. На современных 32-разрядных системах виртуальное адресное пространство обычно измеряется гигабайтами, и большинству программ этого хватает, а проблемы с нехваткой можно решать совсем другими способами. Тем не менее, существуют различные системы, даже и 32-разрядные, в которых нет устройства управления памятью, и размер виртуальной памяти не может превышать объема микросхем ОЗУ, установленных на плате. Пример такой системы — упоминавшийся выше транспьютер.
Важно подчеркнуть, что, несмотря на определенное сходство между задачами, решаемыми механизмом перекрытий и виртуальной адресацией, одно ни в коем случае не является разновидностью другого. При виртуальной адресации мы решаем задачу отображения большого адресного пространства на ограниченную оперативную память. При использовании оверлея мы решаем задачу отображения большого количества объектов в ограниченное адресное пространство.
Основная проблема при оверлейной загрузке состоит в следующем: прежде чем ссылаться на оверлейный адрес, мы должны понять, какой из оверлейных модулей в данный момент там находится. Для ссылок на функции это просто: вместо точки входа функции мы вызываем некую процедуру, называемую менеджером перекрытий (overlay manager). Эта процедура знает, какой модуль куда загружен, и при необходимости "подкачивает" то, что загружено не было. Перед каждой ссылкой на оверлейные данные мы должны выполнять аналогичную процедуру, что намного увеличивает и замедляет программу. Иногда такие действия возлагаются на программиста (Win 16, Mac OS до версии 10 — подробнее управление памятью в этих системах описывается в разд. Управление памятью в MacOS и Win 16), иногда — на компилятор (handle pointer в Zortech C/C++ для MS DOS), но чаще всего с оверлейными данными вообще предпочитают не иметь дела. В таком случае оверлейным является только код.
В старых учебниках по программированию и руководствах по операционным системам уделялось много внимания тому, как распределять процедуры между оверлейными модулями. Действительно, загрузка модуля с диска представляет собой довольно длительный процесс, поэтому хотелось бы минимизировать ее. Для этого нужно, чтобы каждый оверлейный модуль был как можно более самодостаточным. Если это невозможно, стараются вынести процедуры, на которые ссылаются из нескольких оверлеев, в отдельный модуль, называемый резидентной частью или резидентным ядром. Это модуль, который всегда находится в памяти и не разделяет свои адреса ни с каким другим оверлеем. Естественно, оверлейный менеджер должен быть частью этого ядра.
Каждый оверлейный модуль может быть как абсолютным, так и перемещаемым. От этого несколько меняется устройство менеджера, но не более того. На архитектурах типа i80x86 можно делать оверлейные модули, каждый из которых адресуется относительно значения базового регистра cs и ссылается на данные, статически размещенные в памяти, относительно постоянного значения регистра DS. Такие модули можно загружать в память с любого адреса, может быть, даже вперемежку с данными. Именно так и ведут себя оверлейные менеджеры компиляторов Borland и Zortech.


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

Он был ловкий и весь такой собранный джентльмен, и одет — в самые лучшие и дорогие одежды; и все у него было подобрано и пригнано, даже части тела.
А. Тутуола

В предыдущем разделе шла речь о типах исполняемых модулей, но не говорилось ни слова о том, каким образом эти модули получаются. Вообще говоря, способ создания загружаемого модуля различен в различных ОС, но в настоящее время во всех широко распространенных системах этот процесс выглядит примерно одинаково. Это связано, прежде всего, с тем, что эти системы используют одни и те же языки программирования и правила межмодульного взаимодействия, в которых явно или неявно определяют логику раздельной компиляции и сборки.
В большинстве современных языков программирования программа состоит из отдельных слабо связанных модулей. Как правило, каждому такому модулю соответствует отдельный файл исходного текста. Эти файлы независимо обрабатываются языковым процессором (компилятором), и для каждого из них генерируется отдельный файл, называемый объектным модулем. Затем запускается программа,- называемая редактором связей, компоновщиком или линкером (linker — тот, кто связывает), которая формирует из заданных объектных модулей цельную программу.
Объектный модуль отчасти похож по структуре на перемещаемый загрузочный модуль. Дело в том, что сборку программы из нескольких модулей можно уподобить загрузке в память нескольких программ. При этом возникает та же задача перенастройки адресных ссылок, что и при загрузке относительного загрузочного файла (рис. 3.8). Поэтому объектный модуль должен в той или иной форме содержать таблицу перемещений. Можно, конечно, потребовать, чтобы весь модуль был позиционно-независимым, но это, как говорилось выше, накладывает очень жесткие ограничения на стиль программирования, а на многих процессорах (например Intel 8085) просто невозможно.
Кроме ссылок на собственные метки, объектный модуль имеет право ссылаться на символы, определенные в других модулях. Типичный пример такой ссылки — обращение к функции, которая определена в другом файле исходного текста (рис. 3.9 и 3.10).

Рис. 3.8. Сборка программы

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

Рис. 3.9. Разрешение внешних ссылок (объектный модуль)

Рис. 3.10. Разрешение внешних ссылок (собранная программа)

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

  • Таблицу перемещений, т. е. таблицу ссылок на перемещаемые объекты внутри модуля.
  • Таблицу ссылок на внешние объекты. Иногда это называется таблицей или списком импорта.
  • Таблицу объектов, определенных в этом модуле, на которые можно ссылаться из других модулей. В некоторых случаях ее называют списком экспорта. Иногда таблицы экспорта и импорта объединяют и называют все это таблицей глобальных символов. В этом случае для каждого символа приходится указывать, определен он в данном модуле или нет, а если определен, то как.
  • Различную служебную информацию, такую, как имя модуля, программу, которая его создала (например, строка "gcc compiled").
  • Отладочную информацию.
  • Собственно код и данные модуля.

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

  • .text — исполняемый код (современные компиляторы иногда помещают в эту секцию и данные, описанные как const);
  • .data — статически инициализированные данные;
  • .bss — неинициализированные данные.

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

Пример 3.8. Структуры данных объектного модуля ELF (цитируется по elf.h из поставки Linux 2.2.16, перевод комментариев автора)

' Заголовок файла ELF. Находится в начале каждого файла ELF. */
#define El NIDENT (16)
typedef struct
unsigned char e_ident[EI_NIDENT]; /* Магическое число и другая информация */
Elf32_Half e_type; Elf32_Half e_machine; Elf32_Word e_version; Elf32_Addr e_entry; Elf32_0ff e_phoff;
Elf32_0ff e_shoff; Elf32_Word e_flags; Elf32_Half e_ehsize; Elf32__Half e_phentsize;
Elf32_Half e_phnum; /* Elf32_Half e_shentsize; Elf32_Half e_shnum; /*
Elf32_Half e^shstrndx; ков секций */
} Elf32 Ehdr;
/* Тип объектного файла */
/* Архитектура */
/* Версия объектного файла */
/* Виртуальный адрес точки входа */
/* Смещение таблицы заголовка программы */
/* в файле */
/* Смещение таблицы заголовков секций в файле */
/* Процессорно-зависимые флаги */
/* Размер заголовка ELF в байтах */
/* Размер элемента */
/* таблицы заголовка программы */
Счетчик элементов таблицы заголовка программы */
/* Размер элемента таблицы заголовков секций */
Счетчик элементов таблицы заголовков программ */
/* Индекс таблицы имен секций в таблице заголов-
/* Поля в массиве e_indent. Макросы Е1_* суть индексы в этом массиве. Макросы, следующие за ка'ждым определением Е1_*, суть значения, которые соответствующий байт может принимать. */
#define EI_MAGO 0 /* Индекс нулевого байта сигнатуры1 */
fdefine ELFMAGO 0x7f /* Значение нулевого байта сигнатуры */
#define EI_MAG1 I /* Индекс первого байта сигнатуры */
#define ELFMAG1 'Е' /* Значение первого байта сигнатуры */
fdefine EI_MAG2 2 /* Индекс второго байта сигнатуры */
#define ELFMAG2 'L' /* Значение второго байта сигнатуры */
#define EI_MAG3 3 /* Индекс третьего байта сигнатуры */
#define ELFMAG3 'F' /* Значение третьего байта сигнатуры */
1 В данном случае — это "магическое число", код, размещаемый в определенном месте (обычно в начале) файла и подтверждающий, что это файл данного формата.
/* объединение идентификационных байтов, для сравнения по словам */ #define ELFMAG "\177ELF" ((define SELFMAG 4
((define EI_CLASS 4 /* Индекс байта, указывающего класс файла */
((define ELFCLASSNONE 0 /* Не определено */
((define ELFCLASS32 1 /* 32-разрядные объекты */
((define ELFCLASS64 2 /* 64-разрядные объекты */ ((define ELFCLASSNUM 3
tdefine EI_DATA 5 /* Индекс байта кодировки данных */
((define ELFDATANONE 0 /* Не определена кодировка данных */
((define ELFDATA2LSB 1 /* Двоичные дополнительные, младший байт первый */
#define ELFDATA2MSB 2 /* Двоичные дополнительные, старший байт первый */
tdefine ELFDATANUM 3
#define EI_VERSION 6 /* Индекс байта версии файла */ /* Значение должно быть EV__CURRENT */
#define EIJDSABI 7 /*. идентификатор OS ABI */
tdefine ELFOSABI_SYSV 0 /* UNIX System V ABI */
«define ELFOSABI_HPUX 1 /* HP-UX */
tdefine ELFOSABI_ARM 97 /* ARM */
#define ELFOSABI_STANDALONE 255 /* Самостоятельное (встраиваемое) приложение * /
#define EI_ABIVERSION 8 /* версия ABI */
#define EI_PAD 9 /* Индекс байтов выравнивания */
/* Допустимые значения для e_type (тип объектного файла). */
#define ETJTONE 0 /* Не указан тип */
#define ET_REL I /* Перемещаемый файл */
#define ET_EXEC 2 /* Исполнимый файл */
#define ET_DYN 3 /* Разделяемьй объектньй файл */
Define ET_CORE 4 /* Образ задачи */
'define ET_NUM 5 /* Количество определенных типов */
e ET_LOPROC OxffOO /* Специфичный для процессора */
ttdefine ET_HIPROC Oxffff /* Специфичный для процессора */ /* Допустимые значения для e_machine (архитектура). */
ttdefine EM_NONE 0 /* Не указана машина */
ttdefine ЕМ_М32 1 /* AT&T WE 32100 */
ttdefine EM_SPARC 2 /* SUN SPARC */
ttdefine EM_386 3 /* Intel 80386 */
ttdefine EM_68K 4 /* Motorola m68k family */
ttdefine EM_88K 5 /* Motorola m88k family */
ttdefine EM__486 6 /* Intel 80486 */
ttdefine EM_860 7 /* Intel 80860 */
ttdefine EM_MIPS 8 /* MIPS R3000 big-endian */
ttdefine EM_S370 9 /* Amdahl */
ttdefine EM_MIPS_RS4_BE 10 /* MIPS R4000 big-endian */
ttdefine EM RS6000 11 /* RS6000 */
#define EM_PARISC 15 ttdefine EM_nCUBE 16 Idefine EM VPP500 17
/* HPPA */
/* nCUBE */
/* Fujitsu VPP500 */
ttdefine EM_SPARC32PLUS 18 /* Sun's "vSplus" */ ttdefine EM_960 19 /* Intel 80960 */ ttdefine EM PPC 20 /*. PowerPC */
ttdefine ttdefine ttdefine ttdefine ttdefine ttdefine ttdefine ttdefine ttdefine ttdefine ttdefine ttdefine ttdefine ttdefine ttdefine
EM_V800 36 /* NEC V800 series */ EM_FR20 37 /* Fujitsu FR20 */ EM_RH32 38 /* TRW RH32 */ EM_MMA 39 /* Fujitsu MMA */ EM^ARM 40 /* ARM */ EM_FAKE_ALPHA 41 /* Digital Alpha J
EM_SH 42 EM_SPARCV9 43 EMJTRICORE 44 EM_ARC 45 EM_H8_300 46 EM_H8_300H 47 EM_H8S 48 EM_H8_500 49 EM IA 64 50
/* Hitachi SH */ /* SPARC v9 64-bit */ /* Siemens Tricore */ /* Argonaut RISC Core */ /* Hitachi H8/300 */ /* Hitachi H8/300H */ /* Hitachi H8S */ /* Hitachi H8/500 */ /* 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
/* Если необходимо вьщелить неофициальное значение для ЕМ_*, пожалуйста, выделяйте большие случайные числа (0x8523, Oxa7f2, etc.), чтобы уменьшить вероятность пересечения с официальными или не-GNU неофициальными значениями. */
((define EM_ALPHA 0x9026
/* Допустимые значения для e_version (версия). */
Idefine EV_NONE 0 /* Недопустимая версия ELF */ #define EV_CURRENT I /* Текущая версия */ (tdefine EV_NUM 2
/* Элемент таблицы символов. */
typedef struct f
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 I
Elf32_Half si_boundto;/* Прямая привязка, символ, к которому привязан */ Elf32_Half si_flags; /* Флаги символа */
> Elf32 Syminfo;
/*
Допустимые значения для si boundto. */
#define SYMINFO_BT_SELF Oxffff /* tdefine SYMINFO_BT_PARENT Oxfffe /* ttdefine SYMINFO_BT_LOWRESERVE OxffOO /*
/* Возможные битовые маски для si_flags #define SYMINFO_FLG_DIRECT 0x0001 /*
tfdefine SYMINFO_FLG_PASSTHRU 0x0002 /* тора */
tfdefine SYMINFO_FLG_COPY 0x0004 /* tdefine SYMINFO_FLG_LAZYLOAD 0x0008 /*
/* Значения версии Syminfo. */ #define SYMINFO_NONE 0 ttdefine SYMINFO_CURRENT 1 #define SYMINFO NUM 2
Символ привязан к себе */ Символ привязан к родителю */ Начало зарезервированных записей */
Прямо привязываемый символ *•/ Промежуточный символ для трансля-
Символ предназначен для перемещения копированием */ Символ привязан к объекту с отложенной загрузкой */
/* Как извлекать информацию из и включать ее в поле st_info. */
•
#define ELF32_ST_BIND(val) (((unsigned char) (val)) » 4) tfdefine ELF32_ST_TYPE(val) ((val) & Oxf)
ttdefine ELF32_ST_INFO(bind, type) (((bind) « 4) + ((type) & Oxf)) /* Допустимые значения для подполя STJ3IND поля st_info (привязка символов). */
#define STB_LOCAL О ttdefine STB_GLOBAL 1 tdefine STB_WEAK 2 ttdefine STB_NUM 3 #define STB_LOOS 10 #define STB HIOS 12
/* Локальный символ */
/* Глобальный символ */
/* Слабый символ */
/* Кол-во определенных типов. */
/* Начало ОС-зависимых значений */ _ /* Конец ОС-зависимых значений */
#define STB_LOPROC 13 /* Начало процессорно-зависимых значений */ tdefine STB_HIPROC 15 /* Конец процессорно-зависимых значений */
/* Допустимые значения для подполя ST TYPE поля st info (тип символа). */
#define STT_NOTYPE 0 #define STT_OBJECT 1 #define STT FUNC 2
He указан */
Символ — объект данных */
Символ — объект кода */
STT_SECTION 3 /* Символ связан с секцией */
4define STT_FILS 4 /* Имя символа — имя файла */
*define ^тт NUM ^ /* Кол-во определенных типов */
»define STT LOOS 1^ /* Начало ОС-зависимых значений */
«define STT_HIOS 12 /* Конец ОС-зависимых значений */
*define STT_LOPROC 13 /* Начало процессорно-зависимых значений */
«define STT_HIPROC 15 /* Конец процессорно-зависимых значений */
/* Индексы таблицы символов размещены в группах и цепочках хэша в секции кэш-таблицы символов. Это специальное значение индекса указывает на конец цепочки, и означает, что в этой группе более нет символов. */
((define STNJJNDEF 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) & Oxff)
#define ELF32_R_INFO(sym, type) (((sym) « 8) + ((type) & Oxff))
/* Типы перемещений для 1386 (формулы взяты из
[docs.sun.com 816-0559-10] - авт.)
А — добавочное значение, используемое при вычислении значения перемещаемого поля.
В — базовый адрес, начиная с которого разделяемый объект загружается в память при исполнении [программы]. Обычно разделяемый объект строится с базовым виртуальным адресом, равным О, но адрес при исполнении иной.
G — смещение записи в глобальной таблице смещений, где адрес перемещаемого символа находится во время исполнения. GOT — адрес глобальной таблицы смещений.
L — местоположение (смещение в секции или адрес) записи символа в процедурной таблице связывания (PLT). PLT перенаправляет вызов функции по настоящему адресу. Редактор связей создает начальную таблицу, а редактор связей времени исполнения модифицирует записи во время исполнения.
Р — местоположение (смещение в секции или адрес) перемещаемого элемента памяти (вычисляется с использованием r_offset).
S — значение символа, индекс которого находится в" элементе таблицы перемещений. */
#define R_386_NONE 0 /* Не перемещать */
ttdefine R__386_32 I /* Прямое 32-разрядное - S + А */
#define R_386_PC32 2 /* 32-разрядное относительно PC-S+A-PV
^define R_386_GOT32 3 /* 32-разрядный элемент GOT - G + А */
#define R_386_PLT32 4 /* 32-разрядный адрес PLT - L + А - Р */
ttdefine R_386_COPY 5 /* Копировать символ при исполнении */
#define R_386_GLOB_DAT 6 Л Создать запись GOT - S*/
#define R_386_JMP_SLOT 7 /* Создать запись PLT - S */
tfdefine R_386_RELATIVE 8 /* Сдвинуть относительно базы программы -
В + А */
tdefine R_386_GOTOFF 9 /* 32-разрядное смещение GOT - S + А - GOT */ ^define R_386_GOTPC 10 /*' 32-разрядное смещение GOT относительно
PC - S + А - GOT */ /* Должна быть последняя запись. */ #define R 386 NUM 11


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

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

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

Рис. 3.11. Объектная библиотека

Линкер (рис. 3.12) обычно собирает в программу все объектные модули, которые были ему заданы в командной строке, даже если на этот модуль не было ни одной ссылки. С библиотечными модулями он ведет себя несколько иначе.
Встретив ссылку на глобальный символ, компоновщик ищет определение этого символа во всех модулях, которые ему были заданы. Если там такого символа нет, то линкер ищет этот символ в заголовке библиотеки. Если его нет и там, компоновщик сообщает: "Не определен символ SYMBOL",- и завершает работу. Некоторые редакторы связей, правда, могут Продолжить работу и даже собрать загружаемый модуль, но, как правило, таким модулем пользоваться нельзя, так как в нем содержится ссылка на некорректный адрес. Если же определение символа в библиотеке есть, компоновщик "вытаскивает" соответствующий модуль ц дальше работает так, будто этот модуль был задан ему наравне с остальным^ объектными файлами. Этот процесс повторяется до тех пор, пока не будут разрешены все глобальные ссылки, в том числе и те, которые возникли в библиотечных модулях, или пока не будет обнаружен неопределенный символ. Благодаря такому алгоритму в программу включаются только те модули из библиотеки, которые нужны.
В системах семейства Unix библиотеки такой структуры называются архивными библиотеками, чтобы отличить их от разделяемых библиотек, которые рассматриваются в разд. Динамические библиотеки и Разделяемые библиотеки.

Рис. 3.12. Блок-схема работы редактора связей


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

...как только они вошли в Бесконечный Лес, собранный джентельмен стал разбираться на части и принялся выплачивать арендные деньги. Сначала он отправился к ногозаимодавцам и пришел туда, где нанял левую ногу; он отдал ее владельцу, и заплатил за аренду, и запрыгал к хозяину правой ноги; когда он вернул ее и полностью расплатился, то перевернулся вниз головой и поскакал на руках.
А. Тутуола

Как мы видели в предыдущем разделе, объектные модули и библиотеки содержат достаточно информации, чтобы собирать программу не только заранее, но и непосредственно в момент загрузки. Этот способ, безусловно, требует больших затрат процессорного времени, чем загрузка заранее собранного кода, но дает и некоторые преимущества.
Главное преимущество состоит в том, что, если мы загружаем несколько программ, использующих одну и ту же библиотеку, мы можем настроить их на работу с одной копией кода библиотеки, таким образом, сэкономив память. Разделение кода привлекательно и с функциональной точки зрения, поэтому сборка в момент загрузки находит широкое применение в самых разнообразных ситуациях.
Примером такой сборки является широко используемая в Windows всех версий и OS/2 технология DLL (на самом деле, DLL обеспечивают сборку не только в момент загрузки, но и после нее — возможность подключить дополнительный модуль к уже загруженной программе), которая будет более подробно обсуждаться далее. В качестве других примеров можно привести Novell Netware, OS-9, VxWorks и т. д. Впрочем, если мы говорим о системах, предназначенных для использования во встроенных приложениях (той же VxWorks), вопрос о том, является ли сборка перед прошивкой в ПЗУ сборкой в момент загрузки или сборкой заранее, носит схоластический характер.
Некоторые системы команд поддерживают динамически пересобираемые программы, у которых вся настройка модуля вынесена в отдельную таблицу. В этом случае модуль может быть подключен одновременно к нескольким программам, использовать одновременно разные копии сегмента данных, и каждая используемая копия модуля при этом даже не будет подозревать о существовании других. Примером такой архитектуры является Pascal-система Lilith, разработанная Н. Виртом, и ее наследники KpoHoc/N9000.

Программные модули в N9000
В этих архитектурах каждый объектный модуль соответствует одному модулю в смысле языка высокого уровня Oberon (или NIL— N9000 Instrumental Language). Далее мы будем описывать архитектуру системы N9000, поскольку автор с ней лучше знаком.
Модуль может иметь не более 256 процедур, не более 256 переменных и ссылаться не более чем на 256 других модулей. Код модуля является позиционно-независимым. Данные модуля собраны в отдельный сегмент, и для каждой используемой копии модуля, т. е. для каждой программы, которая этот модуль использует, создается своя копия сегмента данных. В начале сегмента содер.
жится таблица переменных. Строки этой таблицы содержат либо значения_
для скалярных переменных, таких как целое число или указатель, либо адреса в сегменте данных. Кроме того, сегмент данных содержит ссылку на сегмент кода. Этот сегмент кода содержит в себе таблицу адресов точек входа всех определенных в нем функций (рис. 3.13).

Рис. 3.13. Модуль N9000

Ссылки на все внешние модули собраны в таблицу, которая также содержится в сегменте данных. Внешний модуль определяется началом его сегмента данных
Все ссылки на объекты в данном модуле осуществляются через индекс в соответствующей таблице. Ссылки на внешние модули имеют вид индекс модуля:индекс объекта.
Сегмент данных не может содержать никаких статически инициализованных данных. Вся инициализация производится специальной процедурой, которая вызывается при каждом новом использовании модуля. Все эти свойства реализованы в системе команд, поэтому накладные расходы относительно невелики.
Точнее, они невелики по сравнению с Intel 80286, но уже великоваты по сравнению с i386, а по сравнению с современными RISC-процессорами или системами типа транспьютера они становятся недопустимыми. Впрочем, в разд. Разделяемые библиотеки мы увидим, как подобная структура используется и на "обычных" процессорах.
Видно, что в системе может существовать несколько программ, обращающихся к одним и тем же модулям и использующих одну и ту же копию кода модуля. Проблем с абсолютной/относительной загрузкой вообще не возникает. Операционная система ТС для 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.14. Фрагмент структуры взаимозависимостей между NLM (Netware Loadable Module) сервера Netware 4.11


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

В 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 | I Socket) в интерпретируемом языке

/**************************************************
ПРОСТОЙ HTTP клиент на REXX
Dmitry Maximovich 2:5030/544.60 aka maxim@pabl.ru
**************************************************/
PARSE VALUE ARC (1) 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 = '' THEM
DO
sRequest = 'GET / HTTP 1.0'|I"ODOAODOA"x
SAY''Requesting /' END ELSE DO
sRequest = 'GET /'|IB2I|' HTTP 1.0'|I"ODOAODOA"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
re = RxFuncAddf"SockLoadFuncs","rxSock","SockLoadFuncs")
re = SockLoadFuncs ()
END
IF RxFuncQuery("SysLoadFuncs") THEN
DO
re = RxFuncAdd( "SysLoadFuncs","RexxUtil","SysLoadFuncs") re = SysLoadFuncs()
END
rc=SockGetHostByName(sServer,"host.")
IF rс <> 1 THEN
DO
SAY 'CANNOT RESOLVE HOSTNAME TO ADDRESS: 'sServer EXIT -1 END
SAY 'Trying server : 'host .name1 , 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.familу = 'AF_INET' address.port = nPortNumber address.addr = host.addr
rc = SockConnect(socket,'address.')
IF re < 0 THEN
DO
SAY 'UNABLE TO CONNECT TO SERVER:'address.addr
SIGNAL DO
END
re = SockSend(socket, sRequest)
SAY 'REQUEST'***************************************************'
Resp = ''
DO FOREVER
re = SockRecv(socket,"sReceive",256)
IF re <= 0 THEN LEAVE
Resp = Resp || sReceive END
SAY
/* CR -> CRLF */
nStart = 1
nStop = pos(X2C("OA"), Resp)
do while nStop > 0
SAY SUBSTR(Resp,nStart,nStop-nStart)
nStart = nStop + 1
nStop = pos(X2C("OA"), 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 = SysCls @1 SYSCURPOS = SysCurPos @2 SYSCURSTATE = SysCurState @3 SYSDRIVEINFO = SysDrivelnfo @4
SYSDRIVEMAP = SysDriveMap @5
SYSDROPF0NCS = SysDropFuncs @6
SYSFILEDELETE = SysFileDelete @7
SYSFILESEARCH = SysFileSearch @8
SYSFILETREE - SysFileTree @9
SYSGETMESSAGE = SysGetMessage 010
SYSINI = Syslni 011
SYSLOADFUNCS = SysLoadFuncs @12
SYSMKDIR = SysMkDir @13
SYSOS2VER = SysOS2Ver 014
SYSRMDIR = SysRmDir @15
SYSSEARCHPATH = SysSearchPath @16
SYSSLEEP = SysSleep @17
SYSTEMPFILENAME = SysTempFileName @18
SYSTEXTSCREENREAD = SysTextScreenRead @19
SYSTEXTSCREENSIZE = SysTextScreenSize La20
SYSGETEA = SysGetEA @21
SYSPUTEA = SysPutEA @22
SYSWAITNAMEDPIPE = SysWaitNamedPipe @23

DLL являются удобным средством разделения кода и создания отдельно загружаемых программных модулей, но их использование сопряжено с определенной проблемой, которая будет подробнее объясняться в разд. Разделяемые библиотеки. Забегая вперед, скажем, что концепция разделяемых DLL наиболее естественна в системах, где веб задачи используют единое адресное пространство — но при этом ошибка в любой из программ может привести к порче данных или кода другой задачи. Стандартный же способ борьбы с этой проблемой — выделение каждому процессу своего адресного пространства — значительно усложняет разделение кода.
Другая проблема, обусловленная широким использованием разделяемого кода, состоит в слежении за версией этого кода. Действительно, представим себе жизненную ситуацию: в системе одновременно загружены тридцать программ, использующие библиотеку LIBC.DLL. При этом десять из них разрабатывались и тестировались с версией 1.0 этой библиотеки, пять — с версией 1.5 и пятнадцать — с версией 1.5а. Понятно, что рассчитывать на устойчивую работу всех тридцати программ можно только при условии, что все три версии библиотеки полностью совместимы снизу вверх не только по набору вызовов и их параметров, но и по точной семантике каждого из этих вызовов. Последнее требование иногда формулируют как bug-for-bug compatibility (корректно перевести это словосочетание можно так: полная совместимость не только по спецификациям, но и по отклонениям от них).
Казалось бы, исправление ошибок должно лишь улучшать работу программ, использующих исправленный код. На практике же бывают ситуации, когда код основной программы содержит собственные обходные пути, компенсирующие ошибки в библиотеке. Эти обходы могут быть как внесены сознательно (когда поставщик библиотеки исправит, еще неизвестно, а программа нужна сейчас), так и получиться сами собой (арифметический знак, перепутанный четное число раз и т. д.). В этих случаях исправление ошибки может привести к труднопредсказуемым последствиям. Нельзя также забывать и о возможности внесения новых ошибок при исправлении старых, поэтому при разработке и эксплуатации сложных программных систем, необходимо тщательно следить за тем, что именно и где изменилось, а не просто фиксировать ошибки.
Требование "совместимости с точностью до ошибок" — это лишь полемически заостренная формулировка требования контролируемости поведения кода. Из вышеприведенных соображений понятно, что нарушения такой контролируемости представляют собой проблему, которая, не будучи так или иначе разрешена, может серьезно усложнить работу администраторов системы и приложений.

Разделяемый код в системах семейства Windows
Катастрофические масштабы эта проблема принимает в системах семейства Windows, где принято помещать в дистрибутивы прикладных программ все потенциально разделяемые модули, которые этой программе могут потребоваться — среда исполнения компилятора и т. д. При этом каждое приложение считает своим долгом поместить свои разделяемые модули в C:\WINDOWS\SYSTEM32 (в Windows NT/2000/XP это заодно приводит к тому, что установка самой безобидной утилиты требует администраторских привилегий). Средств же проследить за тем, кто, какую версию, чего, куда и зачем положил, практически не предоставляется.
В лучшем случае установочная программа спрашивает: "Тут вот у вас что-то уже лежит, перезаписать?". Стандартный деинсталлятор содержит список DLL, которые принадлежат данному приложению, и осознает тот факт, что эти же DLL используются кем-то еще, но не предоставляет (и, по-видимому, не пытается собрать) информации о том, кем именно они используются. Наличие реестра объектов СОМ не решает проблемы, потому что большая часть приносимого каждым приложением "разделяемого" кода (кавычки стоят потому, что значительная часть этого кода никому другому, кроме принесшего его приложения, не нужна) не является сервером СОМ.
В результате, когда, например, после установки MS Project 2000 перестает работать MS Office 2000 [MSkb RU270125], это никого не удивляет, а конфликты между приложениями различных разработчиков или разных "поколений" считаются неизбежными. Установить же в одной системе и использовать хотя бы попеременно две различные версии одного продукта просто невозможно — однако, когда каждая версия продукта использует собственный формат данных, а конверсия между ними неидеальна, это часто оказывается желательно.
Разработчики же и тестеры, которым надо обеспечить совместимость с различными версиями существующих приложений, при этом просто оказываются в безвыходной ситуации. Неслучайно поставщики VmWare (системы виртуальных машин для х8б) как одно из главных достоинств своей системы рекламируют возможность держать несколько копий Windows одновременно загруженными на одной машине.
Привлекательный путь решения этой проблемы — давать каждому приложе-нию возможность указывать, какие именно DLL ему нужны и где их искать, и позволять одновременно загружать одноименные DLL с разной семантикой — на самом деле вовсе не прост как с точки зрения реализации, так и с точки зрения управления системой. Системы с виртуальной памятью предлагают некоторые подходы к реализации этого пути, но это будет обсуждаться в разд. Разделяемые библиотеки.


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

— Опять себя за волосы дергал
("Тот самый Мюнхаузен"), Г. Горин

При загрузке самой ОС возникает специфическая проблема: в пустой машине, скорее всего, нет программы, которая могла бы это сделать.
В системах, в которых программа находится в ПЗУ (или другой энергонезависимой памяти) этой проблемы не существует: при включении питания программа в памяти уже есть и сразу начинает испблняться. При включении питания или аппаратном сбросе процессор исполняет команду, находящуюся по определенному адресу, например, OxFFFFFFFA. Если там находится ПЗУ, а в нем записана программа, она и начинает исполняться.
При разработке программ для встраиваемых приложений часто используются внутрисхемные имитаторы ПЗУ, доступные целевой системе как ПЗУ, а системе разработчика — как ОЗУ или специальное внешнее устройство.
Компьютеры общего назначения также не могут обойтись без ПЗУ. Программа, записанная в нем, называется загрузочным монитором. Стартовая точка этой программы должна находиться как раз по тому адресу, по которому процессор передает управление в момент включения питания. Эта программа производит первичную инициализацию процессора, тестирование памяти и обязательного периферийного оборудования, и, наконец, начинает загрузку системы. В компьютерах, совместимых с IBM PC, загрузочный монитор известен как BIOS.
На многих системах в ПЗУ бывает прошито нечто большее, чем первичный загрузчик. Это может быть целая контрольно-диагностическая система, называемая консольным монитором. Такая система есть на всех машинах линии PDP-11/VAX и на VME-системах, рассчитанных на OS-9 или VxWorks. Такой монитор позволяет вам просматривать содержимое памяти по заданному адресу, записывать туда данные, запускать какую-то область памяти как программу и многое другое. Он же позволяет выбирать устройство, с которого будет производиться дальнейшая загрузка. В PDP-11/VAX на таком мониторе можно писать программы, почти с таким же успехом, как на ассемблере. Нужно только уметь считать в уме в восьмеричной системе счисления. иа машинах фирмы Sun в качестве консольного монитора используется интерпретатор языка Forth. На ранних моделях IBM PC в ПЗУ был прошит щтерпретатор BASIC. Именно поэтому клоны IBM PC имеют огромное ко-чичество плохо используемого адресного пространства выше сегмента ОхСООО. Вы можете убедиться в том, что BASIC там должен быть, вызвав из программы прерывание 0x60. Вы получите на мониторе сообщение вроде: NO
ROM BASIC, PRESS ANY KEY TO REBOOT. Вообщеговоря, этот BASIC не является консольным монитором в строгом смысле этого слова, так как получает управление не перед загрузкой, а лишь после того, как загрузка со всех устройств завершилась неудачей.
После запуска консольного монитора и инициализации системы вы можете приказать системе начать собственно загрузку ОС. На IBM PC такое приказание отдается автоматически, и часто загрузка производится вовсе не с того устройства, с которого хотелось бы. На этом и основан жизненный цикл загрузочных вирусов.
Чтобы загрузочный монитор смог что бы то ни было загрузить, он должен уметь проинициализировать устройство, с которого предполагается загрузка, и считать с него загружаемый код. Поэтому загрузочный монитор обязан содержать модуль, способный управлять загрузочным устройством. Например, типичный BIOS PC-совместимого компьютера содержит модули управления гибким диском и жёстким диском с интерфейсом Seagate 506 (в современных компьютерах это обычно интерфейс EIDE, отличающийся от Seagate 506 конструктивом, но программно совместимый с ним сверху вниз).
Кроме того, конструктивы многих систем допускают установку ПЗУ на платах контроллеров дополнительных устройств. Это ПЗУ должно содержать программный модуль, способный проинициализировать устройство и произвести загрузку с него (рис. 3.15).

Рис. 3.15. Системное ПЗУ и BIOS дискового контроллера

Как правило, сервисы загрузочного монитора доступны загружаемой системе. Так, модуль управления дисками BIOS PC-совместимых компьютеров предоставляет функции считывания и записи отдельных секторов диска Доступ к функциям ПЗУ позволяет значительно сократить код первичного загрузчика ОС, и, нередко, сделать его независимым от устройства.
Проще всего происходит загрузка с различных последовательных устройств — лент, перфолент, магнитофонов, перфокарточных считывателей и т. д. Загрузочный монитор считывает в память все, что можно считать с заданного устройства и передает управление на начало той информации которую прочитал.
В современных системах такая загрузка практически не используется. В них загрузка происходит с устройств с произвольным доступом, как правило — с дисков. При этом обычно в память считывается нулевой сектор нулевой дорожки диска. Содержимое этого сектора называют первичным загрузчиком. В IBM PC этот загрузчик называют загрузочным сектором, или boot-сектором.
Как правило, первичный загрузчик, пользуясь сервисами загрузочного монитора, ищет на диске начало файловой системы своей родной ОС, находит в этой файловой системе файл с определенным именем, считывает его в память и передает этому файлу управление. В простейшем случае такой файл и является ядром операционной системы.
Размер первичного загрузчика ограничен чаще всего размером сектора на диске, т. е. 512 байтами. Если файловая система имеет сложную структуру, иногда первичному загрузчику приходится считывать вторичный, размер которого может быть намного больше. Из-за большего размера этот загрузчик намного умнее и в состоянии разобраться в структурах файловой системы. В некоторых случаях используются и третичные загрузчики.
Это последовательное исполнение втягивающих друг друга загрузчиков возрастающей сложности называется бутстрапом (bootstrap), что можно перевести как "втягивание [себя] за шнурки от ботинок".
Большую практическую роль играет еще один способ загрузки — загрузка по сети. Она происходит аналогично загрузке с диска: ПЗУ, установленное на сетевой карте, посылает в сеть пакет стандартного содержания, который содержит запрос к серверу удаленной загрузки. Этот сервер передает по сети вторичный загрузчик и т. д. Такая технология незаменима при загрузке бездисковых рабочих станций. Централизованное размещение загрузочных образов рабочих станций на сервере упрощает управление ими, защищает настройки ОС от случайных и злонамеренных модификаций и существенно удешевляет эксплуатацию больших парков настольных компьютеров, поэтому по сети нередко загружаются и машины, имеющие жесткий диск.
Проще всего происходит загрузка систем, ядро которых вместе со всеми дополнительными модулями (драйверами устройств, файловых систем и др.)
Собрано в единый загрузочный модуль. Например, в системах семейства Unix, ядро так и называется /unix (в FreeBSD - /vmunix, в Linux -/vnilinux, пли, и случае упакованного ядра, /vmlinuz).
При переконфигурации системы, добавлении или удалении драйверов и других модулей необходима пересборка ядра, которая может производиться либо стандартным системным редактором связей, либо специальными утилитами генерации системы. Для такой пересборки в поставку системы должны входить либо исходные тексты (как у Linux и BSD), либо объектные модули ядра. Сборка ядра из объектных модулей на современных системах занимает не более нескольких минут. Полная перекомпиляция ядра из исходных текстов, конечно, продолжается существенно дольше.
На случай, если системный администратор ошибется и соберет неработоспособное ядро, вторичный загрузчик таких систем часто предоставляет возможность выбрать файл, который следует загрузить. Ядро таких систем обычно не использует никаких конфигурационных файлов — все настройки также задаются при генерации.
Большинство современных ОС используют более сложную форму загрузки, при которой дополнительные модули подгружаются уже после старта самого ядра. В терминах предыдущих разделов это называется "сборка в момент загрузки". Список модулей, которые необходимо загрузить, а также параметры настройки ядра, собраны в специальном файле или нескольких файлах. У DOS и OS/2 этот файл называется CONFIG.SYS, у Win32-cncreM -реестром (registry).
Сложность при таком способе загрузки состоит в том, что ядро, еще полностью не проишщиализовавшись, уже должно быть способно работать с файловой системой, находить в ней файлы и считывать их в память.
Особенно сложен этот способ тогда, когда драйверы загрузочного диска и загрузочной файловой системы сами являются подгружаемыми модулями. Обычно при этом ядро пользуется функциями работы с файловой системой, предоставляемыми вторичным (или третичным, в общем, последним по порядку) загрузчиком, до тех пор, пока не проинициализирует собственные модули. Вторичный загрузчик обязан уметь читать загрузочные файлы, иначе он не смог бы найти ядро. Если поставщики ОС не удосужились написать соответствующий вторичный загрузчик, а предоставили только драйвер файловой системы, ОС сможет работать с такой файловой системой, но не сможет из нее загружаться.
Некоторые системы, например DOS, могут грузиться только с устройств, поддерживаемых BIOS, и только из одного типа файловой системы — FAT, Драйвер которой скомпонован с ядром. Любопытное развитие этой идеи представляет Linux, модули которого могут присоединяться к ядру как статически, так и динамически. Динамически могут подгружаться любые модули, кроме драйверов загрузочного диска и загрузочной ФС.
Преимущества, которые дает динамически собираемое в момент загрузки ядро, не так уж велики по сравнению с системами, в которых ядро собирается статически. Впрочем, ряд современных систем (Solaris, Linux, Netware) идут в этом направлении дальше и позволяют подгружать модули уже после загрузки и даже выгружать их. Такая архитектура предъявляет определенные требования к интерфейсу модуля ядра (он должен уметь не только инициализировать сам себя и, если это необходимо, управляемое им устройство, н0 и корректно освобождать все занятые им ресурсы при выгрузке), но дает значительные преимущества.
Во-первых, это допускает подгрузку модулей по запросу. При этом подсистемы, нужные только иногда, могут не загрузиться вообще. Даже те модули, которые нужны всегда, могут проинициализироваться, только когда станут нужны, уменьшив тем самым время от начала загрузки до старта некоторых сервисов. Второе, пожалуй даже более важное для системного администратора, преимущество состоит в возможности реконфигурировать систему без перезагрузки, что особенно полезно для систем коллективного пользования. И, наконец, возможность выгрузки модулей ядра иногда (но не всегда, а лишь если поломка не мешает драйверу корректно освободить ресурсы) позволяет корректировать работу отдельных подсистем — опять-таки без перезагрузки всей ОС и пользовательских приложений.
Оказавшись в памяти и, так или иначе, подтянув все необходимые дополнительные модули, ядро запускает их подпрограммы инициализации. При динамической подгрузке инициализация модулей часто происходит по мере их загрузки. Обычно инициализация ядра завершается тем, что оно загружает определенную программу, которая продолжает инициализацию — уже не ядра, но системы в целом.
Так, системы семейства 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-совместимой технике. При включении компьютера запускается прошитый в ПЗУ BIOS. Он проводит тестирование процессора и памяти и инициализацию машины. В процессе инициализации BIOS устанавливает обработчик прерывания int I3h. Этот обработчик умеет считывать и записывать отдельные секторы жестких и гибких дисков и производить некоторые другие операции над дисковыми устройствами. Первичные загрузчики ОС обычно пользуются этим сервисом. Некоторые ОС, например MS/DR DOS, используют этот сервис не только при загрузке, но и при работе, и, благодаря этому, могут не иметь собственного модуля управления дисками.
Если загрузка происходит с жесткого диска, BIOS загружает в память и запускает нулевой сектор нулевой дорожки диска. Этот сектор обычно содержит не первичный загрузчик операционной системы, a MBR (Master Boot Record — главная загрузочная запись). Эта программа обеспечивает разбиение физического жесткого диска на несколько логических разделов (partition) и возможность попеременной загрузки различных ОС, установленных в этих разделах (рис. 3.16).

Рис. 3.16. Master Boot Record и таблица разделов

Разбиение физического диска на логические программа MBR осуществляет на основе содержащейся в ее теле таблицы разделов (partition table), которая содержит границы и типы разделов. MBR перехватывает прерывание int 13^ и транслирует обращения к дисковой подсистеме так, что обращения к логическому диску N преобразуются в обращения к N-ному разделу физического диска
Один из разделов диска должен быть помечен как активный или загрузочный. MBR загружает начальный сектор этого раздела — обычно это и есть первичный загру3. чик ОС. Многие реализации 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, ядро формирует дерево устройств — список установленного в системе оборудования, и в соответствии с этим списком начинает подгружать модули, управляющие устройствами— драйверы. Подгрузка по-прежнему происходит посредством сервисов вторичного загрузчика — ведь все драйверы размещены на загрузочном диске и в корневой файловой системе, в том числе и драйверы самого этого диска и этой файловой системы.

Рис. 3.17. Структура раздела Solaris

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

Существуют ОС, которые не умеют самостоятельно выполнять весь цикл бутстрапа. Они используют более примитивную операционную систему, которая исполняет их вторичный (или какой это уже будет по счету) загрузчик, и помогает этому загрузчику поместить в память ядро ОС. На процессорах х8б в качестве стартовой системы часто используется MS/DR DOS, а загрузчик новой ОС оформляется в виде ЕХЕ-файла.
Таким образом устроены системы MS Windows l.x-З.х, Windows 95/98/ME, DesqView и ряд других "многозадачников" для MS DOS. Таким же образом загружается сервер Nowell Netware, система Oberon для х86, программы, написанные для различных расширителей DOS (DOS extenders) и т. д. Многие Из перечисленных систем, например Windows (версии младше З.11 — в обязательном порядке, а З.11 и 95/98/МЕ только в определенных конфигурациях) используют DOS и во время работы в качестве дисковой подсистемы. Тем не менее, эти программные пакеты умеют самостоятельно загружать Пользовательские программы и выполнять все перечисленные во введении Функции и должны, в соответствии с нашим определением, считаться полноценными операционными системами.


Глава 4. Управление оперативной памятью

    Управление оперативной памятью
  • Открытая память
  • Алгоритмы динамического управления памятью
  • Сборка мусора
  • Открытая память (продолжение)
    • Управление памятью в MacOS и Win16
  • Системы с базовой виртуальной адресацией


Управление оперативной памятью

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


Открытая память

Самый простой вариант управления памятью — отсутствие диспетчера памяти и возможность загружать в системе только один процесс. Именно так работают СР/М и RT-ll SJ (Single-Job, однозадачная). В этих системах программы загружаются с фиксированного адреса PROG_START. В СР/М это ОхЮО; в RT-ll — 01000. По адресам от 0 до начала программы находятся векторы прерываний, а в RT-11 — также и стек программы. Операционная система размещается в старших адресах памяти. Адрес SYS_START, с которого она начинается, зависит от количества памяти у машины и от конфигурации ОС (рис. 4.1).

Рис. 4.1. Управление памятью в однопроцессной ОС с открытой памятью

В этом случае управление памятью со стороны системы состоит в том, что загрузчик проверяет, поместится ли загружаемый модуль в пространство от
PROG_START до SYS_START. Если объем памяти, который использует программа, не будет меняться во время ее исполнения, то на этом все управление и заканчивается.
Однако программа может использовать динамическое управление памятью, например функцию malloco или что-то в этом роде. В этом случае уже код maiioco должен следить за тем, чтобы не залезть в системные адреса. Как правило, динамическая память начинает размещаться с адреса PROG_END = PROG_START + PROG_SIZE. FROG_SIZE в данном случае обозначает полный размер программы, т. е. размер ее кода, статических данных и области, выделенной под стек.
Функция malloc () поддерживает некоторую структуру данных, следящую за тем, какие блоки памяти из уже выделенных были освобождены. При каждом новом запросе она сначала ищет блок подходящего размера в своей структуре данных и, только когда этот поиск завершится неудачей, просит новый участок памяти у системы. Для этого используется переменная, которая в библиотеке языка С называется brk_addr (рис. 4.2, пример 4.1).

Рис. 4.2. Загруженная программа и brk_addr

Изначально эта переменная равна PROG_END, ее значение увеличивается при выделении новых блоков, но в некоторых случаях может и уменьшаться. Это происходит, когда программа освобождает блок, который заканчивается а текущем значении brkievei.

Пример 4.1. Выделение дополнительной памяти в GNU LibC для Linux

morecore.с:
/* Copyright (С) 1991, 1992 Free Software Foundation, Inc.
Этот файл является частью библиотеки С проекта GNU (GNU С Library).
GNU С Library является свободным программным обеспечением;
вы можете передавать ее и/или модифицировать ее в соответствии
с положениями GNU General Public License версии 2 или (по вашему выбору) '
любой более поздней версии.
Библиотека GNU С распространяется в надежде, что она будет полезна, но БЕЗ КАКИХ-ЛИБО ГАРАНТИЙ; даже без неявно предполагаемых гарантий
КОММЕРЧЕСКОЙ ЦЕННОСТИ или ПРИГОДНОСТИ ДЛЯ КОНКРЕТНОЙ ЦЕЛИ. Подробнее см. GNU General Public License.
Вы должны были получить копию GNU General Public License вместе с
GNU С Library; см. файл COPYING. Если вы ее не получили, напишите по
адресу: Free Software Foundation, 675 Mass Ave, Cambridge, MA 02139, USA. */
tifndef _MALLOC_INTERNAL tfdefine _MALLOC_INTERNAL tinclude <malloc.h> #include <stdlib.h> ttinclude <stdio.h>
#include <errno.h> tinclude <sys/syscall.h> tendif
#ifndef _GNU_LIBRARY_
tfdefine __sbrk sbrk
#endif
extern void * ___brk_addr;
extern ptr t sbrk P ((int increment));
int _ initbrk Р ( (void) ) ;
fifndef NULL fdefine NULL 0 fendif
/* Выделить еще INCREMENT байтов пространства данных
и возвратить начало пространства данных или NULL при ошибках. Если INCREMENT отрицателен, сжать пространство данных. */
__ptr_t
default_morecore (ptrdiff_t increment)
{
ptr_t result = _ sbrk ((int) increment);
if (result == ( _ ptr_t) -1)
return NULL; return result;
/* Эта функция почти полностью аналогична _ def ault_morecore ( ) * Но она вызывается только однажды через morecore.
*/
_ ptr_t
_ default_morecore_init (ptrdiff_t increment)
{
_ ptr_t result;
if ( _ init_brk() != 0)
return NULL;
if ( _ morecore == _ default_morecore_init)
_mprecore = _ default_morecore;
result = _ sbrk ((int) increment);
if (result == ( _ ptr_t) -1)
return NULL;
return result;
}


Алгоритмы динамического управления памятью

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

При динамическом выделении памяти запросы на выделение памяти формируются во время исполнения задачи. Динамическое выделение, таким образом, противопоставляется статическому, когда запросы формируются на этапе компиляции программы. В конечном итоге, и те, и другие запросы нередко обрабатываются одним и тем же алгоритмом выделения памяти в ядре ОС. Но во многих случаях статическое выделение можно реализовать намного более простыми способами, чем динамическое. Главная сложность здесь в том, что при статическом выделении кажется неестественной — и поэтому редко требуется — возможность отказаться от ранее выделенной памяти. При динамическом же распределении часто требуется предоставить возможность отказываться от запрошенных блоков так, чтобы освобожденная память могла использоваться для удовлетворения последующих запросов. Таким образом, динамический распределитель вместо простой границы между занятой и свободной памятью (которой достаточно в простых случаях статического распределения) вынужден хранить список возможно несвязных областей свободной памяти, называемый пулом или кучей.
Многие последовательности запросов памяти и отказов от нее могут привести к тому, что вся доступная память будет разбита на блоки маленького размера, и попытка выделения большого блока завершится неудачей, даже если сумма длин доступных маленьких блоков намного больше требуемой. Это явление называется фрагментацией памяти (рис. 4.3). Иногда используют более точный термин — внешняя фрагментация (что такое внутренняя фрагментация будет рассказано далее). Кроме того, большое количество блоков требует длительного поиска. Существует также много мелких трудностей разного рода. К счастью, человечество занимается проблемой распределения памяти уже давно и найдено много хороших или приемлемых решений.
В зависимости от решаемой задачи используются различные стратегии поиска свободных блоков памяти. Например, программа может выделять блоки одинакового размера или нескольких фиксированных размеров. Это сильно облегчает решение задач дефрагментации и поиска свободных участков ОЗУ.
Возможны ситуации, когда блоки освобождаются в порядке, обратном тому, в котором они выделялись. Это позволяет свести выделение памяти к стеко-вой структуре, т. е. фактически, вернуться к простому запоминанию границы между занятой и свободной памятью.
Возможны также ситуации, когда некоторые из занятых блоков можно переместить по памяти — тогда есть возможность проводить дефрагментацию памяти, перемещение занятых блоков памяти с целью объединить свободные участки. Например, функцию realloc() в ранних реализациях системы UNIX можно было использовать именно для этой цели.

Рис. 4.3. Внешняя фрагментация

В стандартных библиотечных функциях языков высокого уровня, таких как
malloc/free/realloc в С, new/dispose в Pascal и т. д., как правило, используются алгоритмы, рассчитанные на наиболее общий случай: программа запрашивает блоки случайного размера в случайном порядке и освобождает их также случайным образом.
Впрочем, случайные запросы — далеко не худший вариант. Даже не зная деталей стратегии управления кучей, довольно легко построить программу, которая "испортит жизнь" многим распространенным алгоритмам (пример 4.2).

Пример 4.2. Пример последовательности запросов памяти

 while(TRUE) {
void * bl = malloc(random (10)); /* Случайный размер от 0 до 10 байт */
        void * Ь2 = malloc(random(lO)+10); Л ........ от 10 до 20 байт */
if(Ы == NULL && Ь2 == NULL) /* Если памяти нет */
break; /* выйти из цикла */
free(b1);
void * ЬЗ = malloc(150);
/* Скорее всего, память не будет выделена */

В результате исполнения такой программы вся доступная память будет "порезана на лапшу": между любыми двумя свободными блоками будет размещен занятый блок меньшего размера (рис. 4.4)

Рис. 4.4. Результат работы программы примера 4.2

К счастью, пример 4.2 имеет искусственный характер. В реальных программах такая ситуация встречается редко, и часто оказывается проше исправить программу, чем вносить изменения в универсальный алгоритм управления кучей.
Приведенный пример построен на том предположении, что система выделяет нам блоки памяти, размер которых соответствует запрошенному с точностью до байта. Если же минимальная единица выделения равна 32 байтам, никакой внешней фрагментации наш пример не вызовет: на каждый запрос будет выделяться один блок. Но при этом мы столкнемся с обратной проблемой, которая называется внутренней фрагментацией: если система умеет рыделять только блоки, кратные 32 байтам, а нам реально нужно 15 или 47 байт, то 17 байт на блок окажутся потеряны (рис. 4.5).

Рис. 4.5. Внутренняя фрагментация

Чем больше размер единицы выделения, тем меньше нам грозит фрагментация внешняя, и тем большие потери обеспечивает фрагментация внутренняя. Величина потерь зависит от среднего размера запрашиваемого блока. Грубая оценка свидетельствует о том, что в каждом блоке в среднем теряется половина единицы выделения, т. е. отношение занятой памяти к потерянной будет [image], где N— количество выделенных блоков, s— размер единицы выделения, а [image] — средний размер блока. Упростив эту формулу, мы получим выражение для величины потерь: ,т. е. потери линейно растут с увеличением размера единицы выделения.
Если средний размер блока сравним с единицей выделения, наша формула теряет точность, но все равно дает хорошую оценку порядка величины потерь. Так, если s = [image] , наша формула дает 50% потерь, что вполне согласуется Со здравым смыслом: если запрашиваемый блок чуть короче минимально возможного, теряется только это "чуть"; зато если он чуть длиннее, то для него отводится два минимальных блока, один из которых теряется почти весь. Точная величина потерь определяется распределением запрашиваемых блоков по длине, но мы предпочитаем оставить вывод точной формулы любопытному читателю.
Варианты алгоритмов распределения памяти исследовались еше в 50-е году. Итоги многолетнего изучения этой проблемы приведены в [Кнут 2000] и многих других учебниках.
Обычно все свободные блоки памяти объединяются в двунаправленный связанный список. Список должен быть двунаправленным для того, чтобы из него в любой момент можно было извлечь любой блок. Впрочем, если все действия по извлечению блока производятся после поиска, то можно слегка усложнить процедуру поиска и всегда сохранять указатель на предыдущий блок. Это решает проблему извлечения и можно ограничиться однонаправленным списком. Беда только в том, что многие алгоритмы при объединении свободных блоков извлекают их из списка в соответствии с адресом, поэтому для таких алгоритмов двунаправленный список остро необходим.
Поиск в списке может вестись тремя способами: до нахождения первого подходящего (first fit) блока, до блока, размер которого ближе всего к заданному — наиболее подходящего (best fit), и, наконец, до нахождения самого большого блока, наименее подходящего (worst fit).
Использование стратегии worst fit имеет смысл разве что в сочетании с сортировкой списка по убыванию размера. Это может ускорить выделение памяти (всегда берется первый блок, а если он недостаточно велик, мы с чистой совестью можем сообщить, что свободной памяти нет), но создает проблемы при освобождении блоков: время вставки в отсортированный список пропорционально О (n), где n — размер списка.
Помещать блоки в отсортированный массив еще хуже — время вставки становится О (n + log(n)) и появляется ограничение на количество блоков. Использование хэш-таблиц или двоичных деревьев требует накладных расходов и усложнений программы, которые себя в итоге не оправдывают. На практике стратегия worst fit используется при размещении пространства в файловых системах, например в HPFS, но ни одного примера ее использования для распределения оперативной памяти автору неизвестно.
Чаще всего применяют несортированный список. Для нахождения наиболее подходящего мы обязаны просматривать весь список, в то время как первый подходящий может оказаться в любом месте, и среднее время поиска будет меньше. Насколько меньше — зависит от отношения количества подходящих блоков к общему количеству. (Читатели, знакомые с теорией вероятности, могут самостоятельно вычислить эту зависимость.)
В общем случае best fit увеличивает фрагментацию памяти. Действительно, если мы нашли блок с размером больше заданного, мы должны отделить "хвост" и пометить его как новый свободный блок. Понятно, что в случае best fit средний размер этого "хвоста" будет маленьким, и мы в итоге получим большое количество мелких блоков, которые невозможно объединить, так как пространство между ними занято.
В тех ситуациях, когда мы размещаем блоки нескольких фиксированных размеров, этот недостаток роли не играет и стратегия best fit может оказаться оправданной. Однако библиотеки распределения памяти рассчитывают на обшпй случай, и в них обычно используются алгоритмы first fit.
при использовании first fit с линейным двунаправленным списком возникает специфическая проблема. Если каждый раз просматривать список с одного и того же места, то большие блоки, расположенные ближе к началу, будут чаще удаляться. Соответственно, мелкие блоки будут скапливаться в начале списка, что увеличит среднее время поиска (рис. 4.6). Простой способ борьбы с этим явлением состоит в том, чтобы просматривать список то в одном направлении, то в другом. Более радикальный и еще более простой метод заключается в следующем: список делается кольцевым, и каждый поиск начинается с того места, где мы остановились в прошлый раз. В это же место добавляются освободившиеся блоки. В результате список очень эффективно перемешивается и никакой "антисортировки" не возникает.

Рис. 4.6. Антисортировка

Разработчик программы динамического распределения памяти обязан решить еще одну важную проблему, а именно — объединение свободных блоков. Действительно, обидно, если мы имеем сто свободных блоков по одному килобайту и не можем сделать из них один блок в 100 килобайт. Но если все эти блоки расположены в памяти один за другим, а мы не можем их при этом объединить — это просто унизительно.
Кроме того, если мы умеем объединять блоки и видим, что объединенный блок ограничен сверху значением brkievel, то мы можем, вместо помещения этого блока в список, просто уменьшить значение brkievel и, таким образом, вернуть ненужную память системе.
Представим себе для начала, что все, что мы знаем о блоке, — это его начальный адрес и размер. Легко понять, что это очень плохая ситуация. Действительно, для объединения блока с соседями мы должны найти их в списке свободных, или же убедиться, что там их нет. Для этого мы должны просмотреть весь список. Как одну из идей мозгового штурма можно выдвинуть предложение сортировать список свободных блоков по адресу.
Гораздо проще запоминать в дескрипторе блока указатели на дескрипторы соседних блоков. Немного развив эту идею, мы приходим к методу, который называется алгоритмом парных меток и состоит в том, что мы добавляем к каждому блоку по два слова памяти. Именно слова, а не байта. Дело в том, что требуется добавить достаточно места, чтобы хранить в нем размер блока в байтах или словах. Обычно такое число занимает столько же места, сколько и адрес, а размер слова обычно равен размеру адреса. На х86 в реальном режиме это не так, но это вообще довольно странный процессор.
Итак, мы добавляем к блоку два слова — одно перед ним, другое после него. В оба слова мы записываем размер блока. Получается своеобразный дескриптор, который окружает блок. При этом мы устанавливаем, что значения длин будут положительными, если блок свободен, и отрицательными, если блок занят. Можно сказать и наоборот, важно только потом соблюдать это соглашение (рис. 4.7).
Представим, что мы освобождаем блок с адресом addr. Считаем, что addr имеет тип word *, и при добавлении к нему целых чисел результирующий адрес будет отсчитываться в словах, как в языке С. Для того чтобы проверить, свободен ли сосед перед ним, мы должны посмотреть слово с адресом addr 2. Если оно отрицательно, то сосед занят, и мы должны оставить его в покое (рис. 4.8). Если же оно положительно, то мы можем легко определить адрес начала этого блока как addr - addr[-2].
Определив адрес начала блока, мы можем легко объединить этот блок с блоком addr, нам нужно только сложить значения меток-дескрипторов и записать их в дескрипторы нового большого блока. Нам даже не нужно будет добавлять освобождаемый блок в список и извлекать оттуда его соседа!

Рис. 4.7. Парные метки

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

Рис. 4.8. Объединение с использованием парных меток

Примечание
Это действительно большое преимущество, так как оно значительно облегчает выявление ошибок работы с указателями, о которых в руководстве по Zortech C/C++ сказано, что "опытные программисты, услышав это слово [ ошибка работы с указателями — прим. авт.], бледнеют и прячутся под стол" ([Zortech v3.xj).

Итак, наилучшим из известных универсальных алгоритмов динамического распределения памяти является алгоритм парных меток с объединением свободных блоков в двунаправленный кольцевой список и поиском по принципу first fit. Этот алгоритм обеспечивает приемлемую производительность почти для всех стратегий распределения памяти, используемых в прикладных программах.
Алгоритм парных меток был предложен Дональдом Кнутом в начале 60-х. В третьем издании классической книги [Кнут, 2000], этот алгоритм приводится под названием "освобождения с дескрипторами границ". В современных системах используются и более сложные структуры дескрипторов, но всегда ставится задача обеспечить поиск соседей блока по адресному про' странству за фиксированное время. В этом смысле, практически все современные подпрограммы динамического выделения памяти (в частности, реализации стандартной библиотеки языка С) используют аналоги алгоритма парных меток. Другие известные подходы либо просто хуже, чем этот, либо проявляют свои преимущества только в специальных случаях.
Реализация maiioc в библиотеке GNU LibC (реализация стандартной биб-иотеки языка С в рамках freeware проекта GNU Not Unix) (пример 4.3) использует смешанную стратегию: блоки размером более 4096 байт выделяются стратегией first fit из двусвязного кольцевого списка с использованием щклического просмотра, а освобождаются при помощи метода, который в мазанном ранее смысле похож на алгоритм парных меток. Все выделяемые образом блоки будут иметь размер, кратный 4096 байтам.
Блоки меньшего размера объединяются в очереди с размерами, пропорциональными степеням двойки, как в описанном далее алгоритме близнецов. Элементы этих очередей называются фрагментами (рис. 4.9). В отличие от алгоритма близнецов, мы не объединяем при освобождении парные фрагменты. Вместо этого, мы разбиваем 4-килобайтовый блок на фрагменты одинакового размера. Если, например, наша программа сделает запросы на 514 и 296 байт памяти, ей будут переданы фрагменты в 1024 и 512 байт соответственно. Под эти фрагменты будут выделены полные блоки в 4 килобайта, и внутри них будет выделено по одному фрагменту. При последующих запросах на фрагменты такого же размера будут использоваться свободные фрагменты этих блоков. Пока хотя бы один фрагмент блока занят, весь блок считается занятым. Когда же освобождается последний фрагмент, блок возвращается в пул.
Описатели блоков хранятся не вместе с самими блоками, а в отдельном динамическом массиве _heapihfo. Описатель заводится не на непрерывную последовательность свободных байтов, а на каждые 4096 байт памяти (в примере 4.3 именно это значение принимает константа BLOCKSIZE). Благодаря этому мы можем вычислить индекс описателя в _heapinfo, просто разделив на 4096 смещение освобождаемого блока от начала пула.
Для нефрагментированных блоков описатель хранит состояние (занят-свободен) и размер непрерывного участка, к которому принадлежит блок. Благодаря этому, как и в алгоритме парных меток, мы легко можем найти соседей освобождаемого участка памяти и объединить их в большой непрерывный участок.
Для фрагментированных блоков описатель хранит размер фрагмента, счетчик занятых фрагментов и список свободных. Кроме того, все свободные Фрагменты одного размера объединены в общий список — заголовки этих
списков собраны в массив _f raghead.
Используемая структура данных занимает больше места, чем применяемая в Классическом алгоритме парных меток, но сокращает объем списка свободных блоков и поэтому имеет более высокую производительность. Средний oбъем блока, выделяемого современными программами для ОС общего назначения, измеряется многими килобайтами, поэтому в большинстве случа-ев повышение накладных расходов памяти оказывается терпимо.

Рис. 4.9. Фрагменты в реализации malloc из GNU LibC

Пример 4.3. Реализация malloc/fгее в GNU LibC. Функция__default_morecore приведена в примере 4.1.

 malloc.с
/* Распределитель памяти 'malloc'.
Copyright 1990, 1991, 1992 Free Software Foundation Написана в мае 1989
        Mike Haertel.
GNU С Library является свободным программным обеспечением;
вы можете передавать другим лицам и/или модифицировать ее в соответствии
с положениями GNU General Public License версии 2 или (по вашему выбору)
        любой более поздней версии.
библиотека GNU С распространяется в надежде, что она будет полезна, но
        БЕЗ КАКИХ-ЛИБО ГАРАНТИЙ; даже без неявно предполагаемых гарантий
КОММЕРЧЕСКОЙ ЦЕННОСТИ или ПРИГОДНОСТИ ДЛЯ КОНКРЕТНОЙ ЦЕЛИ.
Подробнее см. GNU General Public License.
ВЫ должны были получить копию GNU General Public License вместе с
GNU С Library; см. файл COPYING. Если вы ее не получили, напишите по адресу:
        Free Software Foundation, 675 Mass Ave, Cambridge, MA 02139, USA.
С автором можно связаться по электронной почте по адресу mike@ai.rait.edu,
        или Mike Haertel с/о Free Software Foundation. */
# ifndef _MALLOC_INTERNAL
#define _MALLOC_INTERNAL tinclude <malloc.h>
#endif
#ifdef __ELF_
ipragma weak malloc = __libc_malloc
#endif

/* Как действительно получить дополнительную память. */
__ptr_t (*__morecore) __Р ((ptrdiff_t __size))
= __default_morecore_init;
/* Предоставляемая пользователем отладочная функция (hook) для xmalloc'
        */
void (*__malloc_initialize_hook) __P ((void));
__ptr_t (*_malloc_hook) __P ((size_t __size)) ;
/* Указатель на основание первого блока. */ char *_heapbase;
* Таблица информационных записей о блоках. Размещается
через align/__free (не malloc/free). */
malloc_info *_heapinfo;
/* Количество информационных записей. */ static size_t heapsize;
/* Индекс поиска в информационной таблице. */ size_t _heapindex;
/* Ограничитель допустимых индексов в информационной таблице */ size_t
        _heaplimit;
#if 1 /* Adapted from Mike */
/* Счетчик больших блоков, размещенных для каждого из размеров
фрагментов. */
int _fragblocks[BLOCKLOG];
#endif
/* Списки свободных фрагментов по размерам.
*/ struct list _fraghead[BLOCKLOG];
/* Инструментальные переменные.
*/ size_t __chunks_used; size_t _bytes_used; size_t _chunks_free; size_t
        _bytes_free;
/* Имеем ли мы опыт? */
/* Are you experienced? */
int malloc initialized;
/* Выровненное размещение. */
static __ptr_t align __P ((size_t));
static __ptr_t
align (size)
size_t size; {
__ptr_t result;
unsigned long int adj;
[result = (*__morecore) (size);
adj = (unsigned long int) ((unsigned long int) ((char *) result -
(char *) NULL)) % BLOCKSIZE; lit (adj != 0)
{
adj = BLOCKSIZE - adj; (void) (*_morecore) (adj); result = (char *) result
        + adj;
}
return result;
/* Настроить все и запомнить, что у нас есть. */
/* Set everything up and remember that we have.
*/ static int initialize _ P ( (void) ) ; static int initialize ()
{ if ( malloc initialize hook)
(*__malloc_initialize_hook) ();
heaps ize = HEAP / BLOCKSIZE;
_heapinfo = (malloc_info *) align (heapsize * sizeof (malloc_info) ) ;
if (_heapinfo == NULL)
return 0;
memset (_heapinfo, 0, heapsize * sizeof (malloc_info) ) ; _heapinfo [0]
        . f ree. size = 0;
_heapinfo[0] .free. next = _heapinfo[0] . free.prev = 0; _heapindex =
        0;
_heapbase = (char *) _heapinfo; __malloc_initialized = 1; return 1;
/* Получить выровненную память, инициализируя
или наращивая таблицу описателей кучи по мере необходимости. static _
        ptr_t morecore _ P ( (size_t) ) ; static _ ptr_t ,Шогесоге (size)
size_t size;
_ ptr_t result;
malloc_info *newinfo, *oldinfo;
size_t newsize;
result = align (size) ; if (result == NULL) return NULL;
/* Проверить, нужно ли нам увеличить таблицу описателей. */ if ((size_t)
        BLOCK ((char *) result + size) > heapsize) I
newsize = heapsize;
while ( (size_t) BLOCK ((char *) result + size) > newsize)
newsize *= 2;
newinfo = (malloc_info *) align (newsize * sizeof (malloc_info) ) if (newinfo
        == NULL) {
(* _ morecore) (-size); return NULL; }
memset (newinfo, 0, newsize * sizeof (malloc_info) ) ; memcpy (newinfo,
        _heapinfo, heapsize * sizeof (malloc_info) ) ; oldinfo = _heapinfo;
newinfo[BLOCK (oldinfo) ] .busy. type = 0; newinfo [BLOCK (oldinfo) ]
        .busy. info. size
= BLOCKIFY (heapsize * sizeof (malloc_info) ) ; _heapinfo = newinfo; _free_internal
        (oldinfo) ; heapsize = newsize;
_heaplimit = BLOCK ((char *) result + size) ; return result; I
/* Выделить память из кучи. */ ptr t
Глава 4. Управление оперативнои
libcjnalloc (size) size_t size;
I
ptr_t result;
size t block, blocks, lastblocks, start; register size_t i; struct list
        *next;
/* Некоторые программы вызывают malloc (0) . Мы это допускаем. */
lif °
if (size == 0)
return NULL; #endif
if (! malloc initialized) if ( '.initialize () ) return NULL;
if ( _ malloc_hook != NULL)
return (*__malloc_hook) (size) ;
if (size < sizeof (struct list) ) size = sizeof (struct list) ;
/* Определить политику размещения на основании размера запроса. */ if
        (size <= BLOCKSIZE / 2) {
/* Маленькие запросы получают фрагмент блока.
Определяем двоичный логарифм размера фрагмента.
*/ register size_t log = 1; — size; while ( (size /= 2) != 0)
/* Просмотреть списки фрагментов на предмет свободного
фрагмента желаемого размера. */ next = _f raghead[ log] .next; if (next
        != NULL)
I
введение в операционные системы
/* Найдены свободные фрагменты этого размера.
Вытолкнуть фрагмент из списка фрагментов и вернуть его. Обновить счетчики
        блока nfree и first.
*/ result = ( _ ptr_t) next; next->prev->next = next->next; if
        (next->next != NULL)
next->next->prev = next->prev; block = BLOCK (result); if
( — _heapinf о [block] .busy. info. frag. nfree != 0)
_heapinfo [block] .busy. info. frag. first = (unsigned long int)
((unsigned long int) ((char *) next->next — (char *) NULL) % BLOCKSIZE)
        » log;
/* Обновить статистику. */
++_chunks_used;
_bytes_used += 1 « log;
--_chunks_f ree ;
_bytes_free -= 1 « log; t
else
/* Нет свободных фрагментов желаемого размера. Следует взять новый блок;
        поделить его на фрагменты и вернуть первый. */ result = _ libc_malloc
        (BLOCKSIZE) ; if (result == NULL)
return NULL; #if 1 /* Adapted from Mike */
++_fragblocks [log] ; #endif
/* Связать все фрагменты, кроме первого, в список свободных. */ for (i
        = 1; i < (size_t) (BLOCKSIZE » log); ++i) I
next = (struct list *) ((char *) result + (i « log)); next->next =
        _fraghead[log] .next; next->prev = &_fraghead[log] ; next->prev->next
        = next; if (next->next != NULL) next->next->prev = next;
}
/* Инициализировать счетчики nfree и first для этого блока. */ block =
        BLOCK (result); _heapinfо[block].busy.type = log;
_heapinfо[block].busy.info.frag.nfree = i — I; heapinfo[block].busy.info.frag.first
        = i — 1;
_chunks_free += (BLOCKSIZE » log) - 1;
_bytes_free += BLOCKSIZE - (1 « log); _bytes_used -= BLOCKSIZE - (1 «
        log);
} else
{
/* Большие запросы получают один или больше блоков.
Просмотреть список свободных циклически, начиная с точки, где мы были
        в последний раз. Если мы пройдем полный круг, не обнаружив достаточно
        большого блока, мы должны будем запросить еще память у системы. */
blocks = BLOCKIFY (size);
start = block = _heapindex;
while (_heapinfо[block].free.size < blocks)
block = _heapinfо[block].free.next; if (block == start)
/* Необходимо взять больше [памяти] у системы.
Проверить, не будет ли новая память продолжением последнего свободного
        блока; если это так, нам не надо будет запрашивать так много.
*/ block = _heapinfo[0].free.prev; lastblocks = _heapinfо[block].free.size;
        if (_heaplimit != 0 && block + lastblocks == _heaplimit &&
(*__morecore) (0) == ADDRESS (block + lastblocks) &&
(morecore ((blocks - lastblocks) * BLOCKSIZE)) != NULL)
tif 1
/* Adapted from Mike */ Л
Обратите внимание, что morecore() может изменить
и дудение в операционные системы
положение последнего блока, если она перемещает таблицу дескрипторов и
        старая копия таблицы сливается с последним блоком. */ block = _heapinfo[0].free.prev;
_heapinfо[block].free.size += blocks — lastblocks; #else
_heapinfо[block].free.size = blocks; tendif
_bytes_free += (blocks - lastblocks) * BLOCKSIZE; continue; }
result = raorecore (blocks * BLOCKSIZE); if (result == NULL)
return NULL; block = BLOCK (result);
_heapinfо[block].busy.type = 0; _heapinfo[block].busy.info.size = blocks;
++ chunks used;

_bytes_used += blocks * BLOCKSIZE; return result;
/* В этой точке мы [так или иначе] нашли подходящую запись в списке свободных.
        Понять, как удалить то, что нам нужно, из списка. */ result = ADDRESS
        (block); if (_heapinfо[block].free.size > blocks) {
/* Блок, который мы нашли, имеет небольшой остаток,
так что присоединить его задний конец к списку свободных. */ _heapinfо[block
        + blocks].free.size
= _heapinfo[block].free.size - blocks; _heapinfo[block + blocks].free.next
= _heapinfо[block].free.next; _heapinfo[block + blocks].free.prev
= _heapinfо[block].free.prev;
_heapinfo[_heapinfo[block].free.prev].free.next = _heapinfo[_heapinfo[block].free.next].free.prev
= _heapindex = block + blocks
else
I^H
/* Блок точно соответствует нашему запросу, поэтому
просто удалить его из списка. */ _heapinfo[_heapinfo[block] .free. next]
        .free.prev
= _heapinf о [block] . free.prev; _heapinfo[_heapinfo[block] .free.prev]
        .free. next
= _heapindex = _heapinf о [block] . free. next; — chunksf ree ;
_heapinf о [block] .busy. type = 0;
_heapinf о [block] .busy . info. size = blocks;
++_chunks_used;
_bytes_used += blocks * BLOCKSIZE;
_bytes_free -= blocks * BLOCKSIZE;
return result;
free. c:
/* Освободить блок памяти, выделенный "malloc1.
Copyright 1990, 1991, 1992 Free Software Foundation Написано в мае 1989
        Mike Haertel.
GNU С Library является свободным программным обеспечением; вы можете перераспространять
        ее и/или модифицировать ее в соответствии с положениями GNU General Public
        License версии 2 или (по вашему выбору) любой более поздней версии.
Библиотека GNU С распространяется в надежде, что она будет полезна, но
        БЕЗ КАКИХ-ЛИБО ГАРАНТИЙ; даже без неявно предполагаемых гарантий
КОММЕРЧЕСКОЙ ЦЕННОСТИ или ПРИГОДНОСТИ для КОНКРЕТНОЙ ЦЕЛИ.
Подробнее см. GNU General Public License.
С автором можно связаться по электронной почте по адресу mike@ai.mit.edu,
        или Mike Haertel с/о Free Software Foundation. */
#ifndef _MALLOC_INTERNAL #define _MALLOC_INTERNAL tinclude <malloc.h>
        tendif
#ifdef __ELF_
tpragma weak free = __libc_free
#endif
/^Предоставляемая пользователем отладочная функция (hook) для 4free'.
        */ void (*__free_hook) __P ((__ptr_t __ptr));
/* Список блоков, вьщеленных memalign. */ struct alignlist *_aligned_blocks
        = NULL;
/* Вернуть память в кучу. Аналогична 'free' но не вызывает
__free_hook даже если он определен. */
void _free_internal (ptr)
__ptr_t ptr;
{
int type;
size_t block, blocks;
register size_t i;
struct list *prev, *next;
block = BLOCK (ptr);
type = _heapinfо[block].busy.type; switch (type) { case 0:
/* Собрать как можно больше статистики как можно раньше. */ — chunks used;
-I—----
_bytes_used -= heapinfo [block] .busy. info. size * BLOCKSIZE; bytes_free
        += heapinfo [block] .busy. info. size * BLOCKSIZE;
/* Найти свободный кластер, предшествующий этому в списке свободных .
Начать поиск с последнего блока, к которому было обращение. Это может
        быть преимуществом для программ, в которых выделение локальное. */ i =
        _heapindex; if (i > block)
while (i > block)
i = _heapinfo[i) . free.prev; else { do
i = __heapinfo[i] . free. next; while (i > 0 && i < block)
        ; i = _heapinfo [i] . free.prev; 1
/* Определить, как включить этот блок в список свободных. */
if (block == i + _heapinfo[i] . free. size) {
/* Слить этот блок с его предшественником. */ _heapinfo[i] .free. size
        += _heapinfo [block] .busy. info. size/block = i;
else
/* Действительно включить этот блок в список свободных. */
_heapinf о [block] . free. size = _heapinfo [block] .busy. info. size;
_heapinf о [block] .free. next = _heapinfo[i] . free. next;
_heapinf о [block] . free.prev = i;
_heapinfo[i] .free. next = block;
_heapinf о [_heapinfo [block] .free. next] .free.prev = block;
++chunksfree;
/* Теперь, поскольку блок включен, проверить, не можем ли мы
слить его с его последователем (исключая его последователя из списка и
        складывая размеры) . */
if (block + _heapinf о [block] . free. size == heapinfo [block] .free.
        next)
{ _heapinfo [block] .free. size
+= _heapinfo [_heapinfo [block] .free. next] . free. size; _heapinfo [block]
        .free. next
= _heapinfo[_heapinfo[block] .free. next] . free. next ; _heapinf о [_heapinfo[block]
        .free. next] .free. prev = block; — chunksf ree ;
/* Проверить, не можем ли мы вернуть память системе. */
blocks = _heapinf о [block] .free. size;
if (blocks >= FINAL_FREE_BLOCKS && block + blocks == _heaplimit
&& (*_morecore) (0) == ADDRESS (block + .blocks)) {
register size_t bytes = blocks * BLOCKSIZE; _heaplimit -= blocks;
(*__morecore) (-bytes); _heapinf о [_heapinf о [block] .free. prev] .free.
        next
= _heapinf о [block] . free. next; _heapinfo[_heapinfo[block] .free. next]
        .free. prev
= _heapinf о [block] . free. prev; block = _heapinf о [block] . free.
        prev; — _chunks_f ree ; _bytes_free -= bytes;
/* Установить следующий поиск, стартовать с этого блока. */
_heapindex = block; break;
default:
/* Собрать некоторую статистику. */
—_chunks_used; _bytes_used -= 1 « type; ++_chunks_free; bytes_free +=
        1 « type;
/* Получить адрес первого свободного фрагмента в этом блоке.
*/ prev = (struct list *) ((char *) ADDRESS (block) +
(_heapinfo[block].busy.info.frag.first « type));
#if 1 /* Adapted from Mike */
if (_heapinfо[block].busy.info.frag.nfree == (BLOCKSIZE » type) - 1
&& _fragblocks[type] > 1) #else
if (_heapinfо[block].busy.info.frag.nfree == (BLOCKSIZE » type) - 1)
#endif
lif 1 #endii
/* Если все фрагменты этого блока свободны, удалить их из списка фрагментов
        и освободить полный блок. */ /* Adapted from Mike */
— _f ragblocks [type] ;
next = prev;
for (i = 1; i < (size_t) (BLOCKSIZE » type)
next = next->next; prev->prev->next = next; if (next != NULL)
next->prev = prev->prev; _heapinf о [block] .busy. type = 0; _heapinf
        о [block] .busy . info. size = 1;
/* Следим за точностью статистики.
*/ ++_chunks_used; _bytes_used += BLOCKSIZE; _chunks_free -= BLOCKSIZE
        » type; _bytes_free -= BLOCKSIZE;
libc free (ADDRESS (block));
I
else if ( heapinfо[block].busy.info.frag.nfree != 0)

/* Если некоторые фрагменты этого блока свободны, включить
        этот фрагмент в список фрагментов после первого свободного фрагмента этого
        блока. */
next = (struct list *) ptr;
next->next = prev->next;
next->prev = prev;
prev->next = next;
if (next->next != NULL) next->next->prev = next;
++_heapinfо[block].busy.info.frag.nfree;
else
/* В этом блоке нет свободных фрагментов, поэтому включить этот фрагмент
        в список фрагментов и анонсировать, что это первый свободный фрагмент
        в этом блоке. */
prev = (struct list *) ptr;
_heapinfo [block] .busy. info. frag. nfree = 1;
_heapinfo [block] .busy. info. frag. first = (unsigned long int)
((unsigned long int) ((char *) ptr - (char *) NULL) % BLOCKSIZE » type)
        ; prev->next = _fraghead[type] .next; prev->prev = &_fraghead[type]
        ; prev->prev->next = prev; if (prev->next != NULL)
prev->next->prev = prev;
break;
/* Вернуть память в кучу. */ void
_ libc_free (ptr) _ ptr_t ptr; {
register struct alignlist *1;
if (ptr == NULL) return;
for (1 = _aligned_blocks; 1 != NULL; 1 = l->next) if (l->aligned
        == ptr)
{
l->aligned = NULL;
/* Пометить элемент списка как свободный. */
ptr = l->exact;
break;
if ( _ free_hook != NULL) (* _ free_hook) (ptr) ;
else
_free_internal (ptr) ;
#include <gnu-stabs . h>
#ifdef elf_alias
elf_alias (free, cfree) ;
#endif

К основным недостаткам этого алгоритма относится невозможность оценки времени поиска подходящего блока, что делает его неприемлемым для задач реального времени. Для этих задач требуется алгоритм, который способен за фиксированное (желательно, небольшое) время либо найти подходящий блок памяти, либо дать обоснованный ответ о том, что подходящего блока не существует.
Проще всего решить эту задачу, если нам требуются блоки нескольких фиксированных размеров (рис. 4.10). Мы объединяем блоки каждого размера в свой список. Если в списке блоков требуемого размера ничего нет, мы смотрим в список блоков большего размера. Если там что-то есть, мы разрезаем этот блок на части, одну отдаем запрашивающей программе, а вторую... Правда, если размеры требуемых блоков не кратны друг другу, что мы будем делать с остатком?
Для решения этой проблемы нам необходимо ввести какое-либо ограничение на размеры выделяемых блоков. Например, можно потребовать, чтобы эти размеры равнялись числам Фибоначчи (последовательность целых чисел, в которой Fi+1 = Fi + Fi-1. В этом случае, если нам нужно Fi байт, а в наличии есть только блок размера Fi+1, мы легко можем получить два блока — один требуемого размера, а другой — Fi-1, который тоже не пропадет. Да, любое ограничение на размер приведет к внутренней фрагментации, но так ли велика эта плата за гарантированное время поиска блока?

Рис. 4.10. Выделение блоков фиксированных размеров

На практике, числа Фибоначчи не используются. Одной из причин, по-видимому, является относительная сложность вычисления такого Fi, которое не меньше требуемого размера блока. Другая причина — сложность объединения свободных блоков со смежными адресами в блок большего размера. Зато широкое применение нашел алгоритм, который ограничивает последовательные размеры блоков более простой зависимостью — степенями числа 2: 512 байт, 1 Кбайт, 2 Кбайт и т. д. Такая стратегия называется алгоритмом близнецов (рис. 4.11).
Одно из преимуществ этого метода состоит в простоте объединения блоков при их освобождении. Адрес блока-близнеца получается простым инвертированием соответствующего бита в адресе нашего блока. Нужно только проверить, свободен ли этот близнец. Если он свободен, то мы объединяем братьев в блок вдвое большего размера, и т. д. Даже в наихудшем случае время поиска не превышает О (log(Smax)-log(Smin)), где Smax и Smin обозначают, соответственно, максимальный и минимальный размеры используемых блоков. Это делает алгоритм близнецов трудно заменимым для ситуаций, в которых необходимо гарантированное время реакции — например, для
задач реального времени. Часто этот алгоритм или его варианты используются для выделения памяти внутри ядра ОС.

Рис. 4.11. Алгоритм близнецов

Существуют и более сложные варианты применения описанного выше подхода. Например, пул свободной памяти Novell Netware состоит из 4 очередей с шагом 16 байт (для блоков размерами 16, 32, 48, 64 байта), 3 очередей с шагом 64 байта (для блоков размерами 128, 192, 256 байт) и пятнадцати очередей с шагом 256 байт (от 512 байт до 4 Кбайт). При запросах большего размера выделяется целиком страница. Любопытно, что возможности работы в режиме реального времени, присущие этой изощренной стратегии, в Netware практически не используются.
Например, если драйвер сетевого интерфейса при получении очередного пакета данных обнаруживает, что у него нет свободных буферов для его приема, он не пытается выделить новый буфер стандартным алгоритмом. Вместо этого, драйвер просто игнорирует пришедшие данные, лишь увеличивая счетчик потерянных пакетов. Отдельный системный процесс следит за состоянием этого счетчика и только при превышении им некоторого порога за некоторый интервал времени выделяет драйверу новый буфер.
Подобный подход к пользовательским данным может показаться циничным, Но надо вспомнить, что при передаче данных по сети возможны и другие Причины потери пакетов, например порча данных из-за электромагнитных Помех. Поэтому все сетевые протоколы высокого уровня предусматривают средства пересылки пакетов в случае их потери, какими бы причинами эта потеря ни была вызвана. С другой стороны, в системах реального времени игнорирование данных, которые мы все равно не в состоянии принять и обработать, — довольно часто используемая, хотя и не всегда приемлемая стратегия.


Сборка мусора

Что-то с памятью моей стало
Р. Рождественский

Явное освобождение динамически выделенной памяти применяется во многих системах программирования и потому привычно для большинства программистов, но оно имеет серьезный недостаток: если программист по какой-то причине не освобождает выделенные блоки, память будет теряться. Эта ошибка, называемая утечкой памяти (memory leak), особенно неприятна в программах, которые длительное время работают без перезапуска — подсистемах ядра ОС, постоянно запущенных сервисах или серверных приложениях. Дополнительная неприятность состоит в том, что медленные утечки могут привести к заметным потерям памяти лишь при многодневной или даже многомесячной непрерывной эксплуатации системы, поэтому их сложно обнаружить при тестировании.
Некоторые системы программирования используют специальный метод освобождения динамической памяти, называемый сборкой мусора (garbage collection). Этот метод состоит в том, что ненужные блоки памяти не освобождаются явным образом. Вместо этого используется некоторый более или менее изощренный алгоритм, следящий за тем, какие блоки еще нужны, а какие — уже нет.
Самый простой метод отличать используемые блоки от ненужных — считать, что блок, на который есть ссылка, нужен, а блок, на который ни одной ссылки не осталось, — не нужен. Для этого к каждому блоку присоединяют дескриптор, в котором подсчитывают количество ссылок на него. Каждая передача указателя на этот блок приводит к увеличению счетчика ссылок на 1, а каждое уничтожение объекта, содержавшего указатель, — к уменьшению.
Впрочем, при наивной реализации такого подхода возникает специфическая проблема. Если у нас есть циклический список, на который нет ни одной ссылки извне, то все объекты в нем будут считаться используемыми, хотя они и являются мусором. Если мы по тем или иным причинам уверены, что кольца не возникают, метод подсчета ссылок вполне приемлем; если же мы используем графы произвольного вида, необходим более умный алгоритм.
Наиболее распространенной альтернативой подсчету ссылок является периодический просмотр всех ссылок, которые мы считаем "существующими' (чтобы под этим ни подразумевалось) (рис. 4.12). Если некоторые из указуемых объектов сами по себе могут содержать ссылки, мы вынуждены осуществлять просмотр рекурсивно. Проведя эту рекурсию до конца, мы можем быть уверены, что, то и только то, что мы просмотрели, является нужными данными, и с чистой совестью можем объявить все остальное мусором. Эта стратегия решает проблему кольцевых списков, но требует остановки всей остальной деятельности, которая может сопровождаться созданием или уничтожением ссылок.

Рис. 4.12. Сборка мусора просмотром ссылок

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


Открытая память (продолжение)

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

Рис. 4.13. Фрагментация при загрузке и выгрузке задач

Управление памятью в OS/360
В этой связи нельзя не упомянуть о поучительной истории, связанной с управлением памятью в системах линии IBM System 360. В этих машинах не было аппаратных средств управления памятью, и все программы разделяли общее виртуальное адресное пространство, совпадающее с физическим. Адресные ссылки в программе задавались 12-битовым смещением относительно базового регистра. В качестве базового регистра мог использоваться, в принципе, любой из 16 32-битовых регистров общего назначения. Предполагалось, что пользовательские программы не модифицируют базовый регистр, поэтому можно загружать их с различных адресов, просто перенастраивая значение этого регистра. Таким образом, была реализована одновременная загрузка многих прО" грамм в многозадачной системе OS/360.
Однако после загрузки программу уже нельзя было перемещать по памяти: например, при вызове подпрограммы адрес возврата сохраняется в виде абсолютного 24-битовог.о адреса (в System 360 под адрес отводилось 32-разрядное слово, но использовались только 24 младших бита адреса. В System 370 адрес стал 31-разрядным) и при возврате базовый регистр не применяется. Аналогично, базовый регистр не используется при ссылках на блоки параметров и сами параметры подпрограмм языка FORTRAN (в этом языке все параметры передаются по ссылке), при работе с указателями в PL/I и т. д. Перемещение программы, даже с перенастройкой базового регистра, нарушило бы все такие ссылки.
Разработчики фирмы IBM вскоре осознали пользу перемещения программ после их загрузки и попытались как-то решить эту проблему. Очень любопытный документ "Preparing to Rollin-Rollout Guideline" (Руководство по подготовке [программы] к вкатыванию и выкатыванию; к сожалению, автору не удалось найти полного текста этого документа) описывает действия, которые программа должна была бы предпринять после перемещения. Фактически программа должна была найти в своем сегменте данных все абсолютные адреса и сама перенастроить их.
Естественно, никто из разработчиков компиляторов и прикладного программного обеспечения не собирался следовать этому руководству. В результате, проблема перемещения программ в OS/360 не была решена вплоть до появления машин System 370 со страничным или странично-сегментным диспетчером памяти и ОС MVS.

В данном случае проблема фрагментации особенно остра, так как типичный образ процесса занимает значительную часть всего доступного ОЗУ. Если при выделении небольших блоков мы еще можем рассчитывать на "закон больших чисел" и прочие статистические закономерности, то самый простой сценарий загрузки трех процессов различного размера может привести нас к неразрешимой ситуации (см. рис. 4.13).
Разделы памяти (см. разд. Разделы памяти) отчасти позволяют решить проблему внешней фрагментации, устанавливая, что процесс должен либо использовать раздел Целиком, либо не использовать его вовсе. Как и все ограничения на размер единицы выделения памяти, это решение загоняет проблему внутрь, переводя внешнюю фрагментацию во внутреннюю. Поэтому некоторые системы предлагают другие способы наложения ограничения на порядок загрузки и выгрузки задач.

Управление памятью в MS DOS
Так, например, процедура управления памятью MS DOS рассчитана на случай, когда программы выгружаются из памяти только в порядке, обратном тому, в каком они туда загружались (на практике, они могут выгружаться и в другом порядке, но это явно запрещено в документации и часто приводит к проблемам). Это позволяет свести управление памятью к стековой дисциплине.
Каждой программе в MS DOS отводится блок памяти. С каждым таким блоком ассоциирован дескриптор, называемый МСВ — Memory Control Block (рис. 4.14). Этот дескриптор содержит размер блока, идентификатор программы, которой принадлежит этот блок, и признак того, является ли данный блок
последним в цепочке. Нужно отметить, что программе всегда принадлежит несколько блоков, но это уже несущественные детали. Другая малосущественная деталь та, что размер сегментов и их адреса отсчитываются в параграфах размером 16 байт. Знакомые с архитектурой процессора 8086 должны вспомнить, что адрес МСВ в этом случае будет состоять только из сегментной части с нулевым смещением.
После запуска corn-файл получает сегмент размером 64Кбайт, а ехе — всю доступную память. Обычно ехе-модули сразу после запуска освобождают ненужную им память и устанавливают brklevel на конец своего сегмента, а потом увеличивают brklevel и наращивают сегмент по мере необходимости.
Естественно, что наращивать сегмент можно только за счет следующего за ним в цепочке МСВ, и MS DOS разрешит делать это только в случае, если этот сегмент не принадлежит никакой программе.
При запуске программы DOS берет последний сегмент в цепочке и загружает туда программу, если этот сегмент достаточно велик. Если он недостаточно велик, DOS говорит: "Недостаточно памяти" и отказывается загружать программу. При завершении программы DOS освобождает все блоки, принадлежавшие программе. При этом соседние блоки объединяются. Пока программы, действительно, завершаются в порядке, обратном тому, в котором они запускались, — все вполне нормально. Другое дело, что в реальной жизни возможны отклонения от этой схемы. Например, предполагается, что TSR-программы (Terminate, but Stay Resident - Завершиться и остаться резидентно (в памяти)) никогда не пытаются по-настоящему завершиться и выгрузиться. Тем не менее, любой уважающий себя хакер считает своим долгом сделать резидентную программу выгружаемой. У некоторых хакеров она в результате выбрасывает при выгрузке все резиденты, которые заняли память после нее. Другой пример — отладчики обычно загружают программу в обход обычной DOS-овской функции LOAD & EXECUTE, а при завершении отлаживаемой программы сами освобождают занимаемую ею память.
Автор в свое время занимался прохождением некоторой программы под управлением отладчика. Честно говоря, речь шла о взломе некоторой игрушки... Эта программа производила какую-то инициализацию, а потом вызывала функцию DOS LOAD & EXECUTE. Я об этом не знал и, естественно, попал внутрь новой программы, которую и должен был взламывать.
После нескольких нажатий комбинаций клавиш <CTRL>+<Break> я наконец-то вернулся в отладчик, но при каком-то очень странном состоянии программы. Покопавшись в программе с помощью отладчика в течение некоторого времени и убедившись, что она не хочет приходить в нормальное состояние, я вышел из отладчика и увидел следующую картину: системе доступно около 100 Кбайт в то время, как сумма длин свободных блоков памяти более 300 Кбайт, а размер наибольшего свободного блока около 200 Кбайт. Отладчик выходя, освободил свою память и память отлаживаемой программы, но не освободил память, выделенную новому загруженному модулю. В результате посредине памяти ос-тался никому не нужный блок изрядного размера, помеченный как используемый (рис. 4.15). Самым обидным было то, что DOS не пыталась загрузить ни одну программу в память под этим блоком, хотя там было гораздо больше места, чем над ним.

Рис. 4.14. Управление памятью в MS DOS

Рис. 4.15. Нарушения стекового порядка загрузки и выгрузки в MS DOS

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

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

В системах с динамической сборкой первые две проблемы не так остры, потому что память выделяется и освобождается небольшими кусочками, по блоку на каждый объектный модуль, и код программы обычно не занимает непрерывного пространства. Соответственно такие системы часто разрешают и данным программы занимать несмежные области памяти.
Такой подход используется многими системами с открытой памятью — AmigaDOS, Oberon, системами программирования для транспьютера и т. д. Проблема фрагментации при этом не снимается полностью, однако для создания катастрофической фрагментации не достаточно двух загрузок задач и одной выгрузки, а требуется довольно длительная работа, сопровождающаяся интенсивным выделением и освобождением памяти.
В системе MacOS был предложен достаточно оригинальный метод борьбы с фрагментацией, заслуживающий отдельного обсуждения


Управление памятью в MacOS и Win16

В этих системах предполагается, что пользовательские программы не сохраняют указателей на динамически выделенные блоки памяти. Вместо этого каждый такой блок идентифицируется целочисленным дескриптором или "ручкой" (handle) (рис. 4.16). Когда программа непосредственно обращается к данным в блоке, она выполняет системный вызов GiobaiLock (запереть). Этот вызов возвращает текущий адрес блока. Пока программа не исполнит вызов GiobaiUniock (отпереть), система не пытается изменить адрес блока. Если же блок не заперт, система считает себя вправе передвигать его по памяти или даже сбрасывать на диск (рис. 4.17).
"Ручки" представляют собой попытку создать программный аналог аппаратных диспетчеров памяти. Они позволяют решить проблему фрагментации и даже организовать некое подобие виртуальной памяти. Можно рассматривать их как средство организации оверлейных данных — поочередного отображения разных блоков данных на одни и те же адреса. Однако за это приходится платить очень дорогой ценой.
Использование "ручек" сильно усложняет программирование вообще и в особенности перенос ПО из систем, использующих линейное адресное пространство. Все указатели на динамические структуры данных в программе нужно заменить на "ручки", а каждое обращение к таким структурам необходимо окружить вызовами GlobalLock/GlobalUnlock.

Рис. 4.16. Управление памятью с помощью "ручек"

Вызовы GlobalLock/GlobalUnlock:

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

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

Рис. 4.17. Дефрагментация при управлении памятью с помощью "ручек"

Наиболее опасной ошибкой, возникающей на фазе микрооптимизации, является вынос указателя на динамическую структуру за пределы скобок GiobaiLock/ciobaiUniock. Эту ошибку очень сложно обнаружить при тестировании, так как она проявляется, только если система пыталась передвигать блоки в промежутках между обращениями. Иными словами, ошибка может проявлять или не проявлять себя в зависимости от набора приложений, исполняющихся в системе, и от характера деятельности этих приложений. В результате мы получаем то, чего больше всего боятся эксплуатационщики — систему, которая работает иногда. При переходе от Windows 3.x к Windows 95 наработка на отказ — даже при исполнении той же самой смеси приложений — резко возросла, так что система из работающей иногда превратилась в работающую как правило. По-видимому, это означает, что большая часть ошибок в приложениях Winl6 действительно относилась к ошибкам работы с "ручками".
Не случайно фирма Microsoft полностью отказалась от управления памятью с помошью "ручек" в следующей версии MS Windows — Windows 95, в которой реализована почти полноценная виртуатьная память.
Мас OS версии 10, построенная на ядре BSD Unix, также имеет страничную виртуальную память и никогда не перемешает блоки памяти, адресуемые "ручками".


Системы с базовой виртуальной адресацией

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

Рис. 4.18. Виртуальная память на основе базовой адресации

Вы можете заметить, что термин базовая адресация уже занят — мы называли таким образом адресацию по схеме reg[offset]. Метод, о котором сейчас идет речь, состоит в формировании адреса по той же схеме. Отличие состоит в том, что регистр, относительно которого происходит адресация, не доступен прикладной программе. Кроме того, его значение прибавляется Ко всем адресам, в том числе к "абсолютным" адресным ссылкам или переменным типа указатель. По существу, такая адресация является способом организации виртуального адресного пространства.
правило, машины, использующие базовую адресацию, имеют два региcтра. Один из регистров задает базу для адресов, второй устанавливает верхний предел. В системе IСL900/Одренок эти регистры называются соответственно BASE и DATUM. Если адрес выходит за границу, установленную значением DATUM, возникает исключительная ситуация (exception) ошибочной адресации. Как правило, это приводит к тому, что система принудительно завершает работу программы.
При помощи этих двух регистров мы сразу решаем две важные проблемы.
Во-первых, мы можем изолировать процессы друг от друга — ошибки в программе одного процесса не приводят к разрушению или повреждению образов других процессов или самой системы. Благодаря этому мы можем обеспечить защиту системы не только от ошибочных программ, но и от злонамеренных действий пользователей, направленных на разрушение системы или доступ к чужим данным.
Во-вторых, мы получаем возможность передвигать образы процессов по физической памяти так, что программа каждого из них не замечает перемещения. За счет этого мы решаем проблему фрагментации памяти и даем процессам возможность наращивать свое адресное пространство. Действительно, в системе с открытой памятью процесс может добавлять себе память только до тех пор, пока не доберется до начала образа следующего процесса. После этого мы должны либо говорить о том, что памяти нет, либо мириться с тем, что процесс может занимать несмежные области физического адресного пространства. Второе решение резко усложняет управление памятью как со стороны системы, так и со стороны процесса, и часто оказывается неприемлемым (подробнее связанные с этим проблемы обсуждаются в разд. Открытая память (продолжение)). В случае же базовой адресации мы можем просто сдвинуть мешающий нам образ вверх по физическим адресам (рис. 4.19).

Рис. 4.19. Дефрагментация при использовании базовой адресации

Часто ОС, работающие на таких архитектурах, умеют сбрасывать на диск образы тех процессов, которые долго не получают управления. Это самая простая из форм своппинга (swapping— обмен) (русскоязычный термин "страничный обмен" довольно широко распространен, но в данном случае его использование было бы неверным, потому что обмену подвергаются не страницы, а целиком задачи).
решив перечисленные выше проблемы, мы создаем другие, довольно неожиданные. Мы оговорили, что базовый регистр недоступен прикладным задачам. Но какой-то задаче он должен быть доступен! Каким же образом процессор узнает, исполняет ли он системную или прикладную задачу, и не сможет ли злонамеренная прикладная программа его убедить в том, что является системной?
Другая проблема состоит в том, что, если мы хотим предоставить прикладным программам возможность вызывать систему и передавать ей параметры, мы должны обеспечить процессы (как системные, так и прикладные) теми или иными механизмами доступа к адресным пространствам друг друга.
На самом деле, эти две проблемы тесно взаимосвязаны — например, если мы предоставим прикладной программе свободный доступ к системному адресному пространству, нам придется распрощаться с любыми надеждами на защиту от злонамеренных действий пользователей. Раз проблемы взаимосвязаны, то и решать их следует в комплексе.
Стандартное решение этого комплекса проблем состоит в следующем. Мы снабжаем процессор флагом, который указывает, исполняется системный или пользовательский процесс. Код пользовательского процесса не может манипулировать этим флагом, однако ему доступна специальная команда. В различных архитектурах эти специальные команды имеют разные мнемонические обозначения, далее мы будем называть эту команду SYSCALL. SYSCALL одновременно переключает флаг в положение "системный" и передает управление на определенный адрес в системном адресном пространстве. Процедура, находящаяся по этому адресу, называется диспетчером системных вызовов (рис. 4.20).
Возврат из системного вызова осуществляется другой специальной командой, назовем ее SYSRET. Эта команда передает управление на указанный адрес в указанном адресном пространстве и одновременно переводит флаг в состояние "пользователь". Необходимость выполнять эти две операции одной командой очевидна: если мы сначала сбросим флаг, мы потеряем возможность переключать адресные пространства, а если мы сначала передадим управление, никто не может нам гарантировать, что пользовательский код добровольно выйдет из системного режима.

Рис. 4.20. Диспетчер системных вызовов

Протокол общения прикладной программы с системой состоит в следующем: программа помещает параметры вызова в оговоренное место — обычно в регистры общего назначения или в стек — и исполняет SYSCALL. Одним из параметров передается и код системного вызова. Диспетчер вызовов анализирует допустимость параметров и передает управление соответствующей процедуре ядра, которая и выполняет требуемую операцию (или не выполняет, если у пользователя не хватает полномочий). Затем процедура помещает в оговоренное место (чаще всего опять-таки в регистры или в пользовательский стек) возвращаемые значения и передает управление диспетчеру, или вызывает SYSRET самостоятельно.
Сложность возникает, когда ядру при исполнении вызова требуется доступ к пользовательскому адресному пространству. В простейшем случае, когда все параметры (как входные, так и выходные) размещаются в регистрах и преД' ставляют собой скалярные значения, проблемы нет, но большинство системных вызовов, особенно запросы обмена данными с внешними устройствами, в эту схему не укладываются.
В системах с базовой адресацией эту проблему обычно решают просто: "системном" режиме базовый и ограничительный регистры не используют-вообще, и ядро имеет полный доступ ко всей физической памяти, в том и к адресным пространствам всех пользовательских задач (рис. 4.21). решение приводит к тому, что, хотя система и защищена от ошибок пользовательских программах, пользовательские процессы оказываются совершенно не защищены от системы, а ядро — не защищено от самого себя. Ошибка в любом из системных модулей приводит к полной остановке работы.

Рис. 4.21. Системный и пользовательский режимы

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


Глава 5. Сегментная и страничная виртуальная память

  • Сегментная и страничная виртуальная память
  • Сегменты, страницы и системные вызовы
  • Взаимно недоверяющие подсистемы
  • Сегменты, страницы и системные вызовы (продолжение)
  • Разделяемые библиотеки
  • Страничный обмен
    • Поиск жертвы
  • Управление своп-файлом
  • Одноуровневая память


Сегментная и страничная виртуальная память

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

Рис. 5.1. Страничная виртуальная память, а неиспользуемым частям блоковыыы

Если длина каждого блока может задаваться, соответствуют "дыры" в виртуальном адресном пространстве, такие блоки называются сегментами (рис. 5.2). Как правило, один сегмент соответствует коду или данным одного модуля программы. Со страницей или сегментом могут быть ассоциированы права чтения записи и исполнения.

Рис. 5.2. Сегментная виртуальная память

Такая адресация реализуется аппаратно. Процессор имеет специальное устройство, называемое диспетчером памяти или, как его называли в старой русскоязычной литературе, УУП (Устройство Управления Памятью, ср. MMU — Memory Management Unit). В некоторых процессорах, например в MC68020 или MC68030 или в некоторых RISC-системах, это устройство реализовано на отдельном кристалле; в других, таких как х86 или современные RISC-процессоры, диспетчер памяти интегрирован в процессор.
В PDP-11 сегментов всего восемь, поэтому дескрипторы каждого из них размещаются в отдельном регистре (на самом деле, регистров не восемь, а шестнадцать — восемь для пользовательского адресного пространства и восемь для системного). На 32-разрядных машинах количество сегментов измеряется тысячами, а страниц — иногда и миллионами, поэтому приходится прибегать к более сложной схеме.
Диспетчер памяти содержит регистр — указатель на таблицу трансляции. Эта таблица размещается где-то в ОЗУ. Ее элементами являются дескрипторы каждой страницы/сегмента. Такой дескриптор содержит права доступа к странице, признак присутствия этой страницы в памяти и физический адрес страницы/сегмента. Для сегментов в дескрипторе также хранится его длина.
Большинство реальных программ используют далеко не все адресное пространство процессора, соответственно таблица трансляции не обязательно держит все допустимые дескрипторы. Поэтому практически все диспетчеры памяти имеют еще один регистр — ограничитель длины таблицы трансляиии. Страницы или сегменты, селектор которых превосходит ограничитель, не входят в виртуальное адресное пространство процесса. Как правило, диспетчер памяти имеет также кэш (cache) дескрипторов — быструю память с ассоциативным доступом. В этой памяти хранятся дескрипторы часто используемых страниц. Алгоритм доступа к памяти по виртуальному адресу page: off set состоит из следующих шагов (рис. 5.3).

Рис. 5.3. Блок-схема алгоритма диспетчера памяти

  • Проверить, существует ли страница page вообще. Если страницы не существует, возникает особая ситуация ошибки сегментации (segmentation violation) (понятие особой ситуации (исключения) подробнее разбирается в Разд.6).
  • Попытаться найти дескриптор страницы в кэше.
  • Если его нет в кэше, загрузить дескриптор из таблицы в памяти.
  • Проверить, имеет ли процесс соответствующее право доступа к страпице Иначе также возникает ошибка сегментации.
  • Проверить, находится ли страница в оперативной памяти. Если ее там пет, возникает особая ситуация отсутствия страницы или страничные отказ (page fault). Как правило, реакция на нее состоит в том, что вызывается специальная программа-обработчик (trap — ловушка), которая загружает требуемую страницу с диска. В многопоточных системах во время такой загрузки может исполняться другой процесс.
  • Если страница есть в памяти, взять из ее дескриптора физический адрес phys_addr.
  • Если мы имеем дело с сегментной адресацией, сравнить смещение в сегменте с длиной этого сегмента. Если смещение оказаюсь больше, также возникает ошибка сегментации.
  • Произвести доступ к памяти по адресу phys_addr[offset].

Видно, что такая схема адресации довольно сложна. Однако в современных процессорах все это реализовано аппаратно и, благодаря кэшу дескрипторов и другим ухищрениям, скорость доступа к памяти получается почти такой же, как и при прямой адресации. Кроме того, эта схема имеет неоценимые преимущества при реализации многозадачных ОС.
Во-первых, мы можем связать с каждой задачей свою таблицу трансляции, а значит и свое виртуальное адресное пространство. Благодаря этому даже в многозадачных ОС мы можем пользоваться абсолютным загрузчиком. Кроме того, программы оказываются изолированными друг от друга, и мы можем обеспечить их безопасность.
Во-вторых, мы можем сбрасывать на диск редко используемые области виртуальной памяти программ — не всю программу целиком, а только ее часть. В отличие от оверлейных загрузчиков, программа при этом вообще не обязана знать, какая ее часть будет сброшена.
Другое дело, что в системах реального времени программе может быть нужно, чтобы определенные ее части никогда не сбрасывались на диск. Система реального времени обязана гарантировать время реакции, и это гарантированное время обычно намного меньше времени доступа к диску. Код, обрабатывающий событие, и используемые при этом данные должны быть всегда в памяти.
В-третьих, программа не обязана занимать непрерывную область физической памяти. При этом она вполне может видеть непрерывное виртуальное адресное пространство. Это резко упрощает борьбу с фрагментацией памяти, а в системах со страничной адресацией проблема внешней фрагментации физической памяти вообще снимается.
В большинстве систем со страничным диспетчером свободная память отслеживается при помощи битовой маски физических страниц. В этой маске вободной странице соответствует 1, а занятой — 0. Если кому-то нужна граница, система просто ищет в этой маске установленный бит. В результате виртуальное пространство программы может оказаться отображено на Физические адреса очень причудливым образом, но это никого не волнует — скорость доступа ко всем страницам одинакова (рис. 5.4).

Рис. 5.4. Распределение адресного пространства по физической памяти

В-четвертых, система может обеспечивать не только защиту программ друг от друга, но в определенной мере и защиту программы от самой себя — например, от ошибочной записи данных на место кода или попытки исполнить данные.
В-пятых, разные задачи могут использовать общие области памяти для взаимодействия или, скажем, просто для того, чтобы работать с одной копией библиотеки подпрограмм.
Перечисленные преимущества настолько серьезны, что считается невозможным реализовать многозадачную систему общего назначения, такую как UNIX или System/390 на машинах без диспетчера памяти.
Для систем реального времени, впрочем, виртуальная память оказывается скорее вредна, чем бесполезна: наличие диспетчера памяти увеличивает объем контекста процесса (это понятие подробнее обсуждается в разд. Вытесняющая многозадачность), воспользоваться же главным преимуществом — возможностью страничного обмена — задачи реального времени в полной мере не могут. Поэтому такие системы, даже работающие на процессорах со встроенным диспетчером памяти, часто этот диспетчер не используют. Даже если виртуальная память доступна, система РВ (реального времени) обязана предоставлять средства блокировки кода и данных (не обязательно всех) пользовательского процес са в физической памяти.
Отдельной проблемой при разработке системы со страничной или сегментной адресацией является выбор размера страницы или максимального размера сегмента. Этот размер определяется шириной соответствующего битового поля адреса и поэтому должен быть степенью двойки.
С одной стороны, страницы не должны быть слишком большими, так как это может привести к внутренней фрагментации и перекачке слишком больших объемов данных при сбросе страниц на диск. С другой стороны, страницы не должны быть слишком маленькими, так как это приведет к чрезмерному увеличению таблиц трансляции, требуемого объема кэша дескрипторов и т. д.
В реальных системах размер страницы меняется от 512 байт у машин семейства VAX до нескольких килобайт. Например, х86 имеет страницу размером 4 Кбайт. Некоторые диспетчеры памяти, например у МС6801/2/30, имеют переменный размер страницы — система при запуске программирует диспетчер и устанавливает, помимо прочего, этот размер, и дальше работает со страницами выбранного размера. У процессора i860 размер страницы переключается между 4 Кбайтами и 4 Мбайтами.
С сегментными диспетчерами памяти ситуация сложнее. С одной стороны, хочется, чтобы один программный модуль помешался в сегмент, поэтому сегменты обычно делают большими, от 32 Кбайт и более. С другой стороны, хочется, чтобы в адресном пространстве можно было сделать много сегментов. Кроме того, может возникнуть проблема: как быть с большими неразделимыми объектами, например хэш-таблицами компиляторов, под которые часто выделяются сотни килобайт.
В результате ряд машин предоставляет двухступенчатую виртуальную память — сегментную адресацию, в которой каждый сегмент, в свою очередь, разбит на страницы. Это дает ряд мелких преимуществ, например, позволяет давать права доступа сегментам, а подкачку с диска осуществлять постранично. Таким образом, организована виртуальная память в IBM System 370 и ряде других больших компьютеров, а также в х86. Правда, в последнем виртуальная память используется несколько странным образом.

Адресное пространство х86
х86 может работать с двумя типами адресов:
32-разрядным адресом, в котором 16 бит задают смещение в сегменте, 14 бит— номер сегмента и 2 бита используются для разных загадочных целей. При этом размер сегмента не более 64 Кбайт, а общий объем виртуальной памяти не превышает 1 Гбайта.
48-разрядным адресом, в котором смещение в сегменте занимает 32 бита. В этом случае размер сегмента может быть до 4 Гбайт, а общий объем виртуальной памяти до 244 байт. В обоих случаях сегмент может быть разбит на страницы по 4 Кбайт.
При этом сегментная часть адреса и его смещение лежат в разных регистрах, и с ними можно работать раздельно. В реальном режиме возможность такой работы порождает весь "зоопарк моделей памяти", с которыми знакомы те, кто писал на С для MS DOS. В защищенном режиме х86 большинство систем программирования выделяют программе один сегмент с 32-разрядным смещением, и программа живет там так, будто это обычная машина с 32-разрядным линейным адресным пространством. Так поступают все известные авторам реализации Unix для х86, ряд так называемых расширителей ДОС (DOS extenders), Oberon/386, Novell Netware, реализации Win32 и т. д.


Сегменты, страницы и системные вызовы

О, порождение Земли и Тьмы, мы приказываем тебе отречься...- твердым, повелительным тоном начал Гальдер.
Смерть кивнул.
— ДА, ДА, ЗНАЮ Я ВСЕ ЭТО. ВЫЗЫВАЛИ-ТО ЧЕГО'' Т. Пратчетт

Реализовав страничную или сегментную виртуапьную память, мы сталкиваемся с той же проблемой, о которой шла речь в разд. Системы с базовой виртуальной адресацией: пользовательские программы не имеют доступа к адресным пространствам друг друга и к таблице дескрипторов, но ведь им надо иметь возможность вызывать системные сервисы и передавать им параметры!
Один из основных способов решения этой проблемы сродни методу, применяемому в системах с базовой адресацией: вводятся два режима работы процессора, "системный" и "пользовательский", в которых используются разные таблицы дескрипторов. Переключение из пользовательского режима в системный осуществляется специальной командой, которая вызывает определенную процедуру в системном адресном пространстве.
Известную сложность при этом представляет передача параметров: как отмечалось выше, пользовательское адресное пространство может быть отображено на физические адреса весьма причудливым образом. Для разрешения указателя в этом адресном пространстве нам либо необходимо анализировать пользовательскую таблицу дескрипторов вручную, либо каким-то способом временно переключить диспетчер памяти на использование этой таблицы.
Большинство процессоров с диспетчером памяти предоставляют команд, копирования данных из пользовательского адресного пространства в системное и обратно. Конечно же. доступны эти команды только из системного режима.
Некоторые архитектуры предоставляют и более изощренные способы pеализации системных вызовов и передачи управления между системой и прикладной программой.

Виртуальная память и режимы процессора VAX
Например, миникомпьютеры VAX имеют четыре режима работы процессора (в порядке возрастания прав доступа): режим пользователя (User), режим супервизора (Supervisor), режим исполнителя (Executive) и режим ядра (Kernel) Режим работы определяется битами 22 и 23 в слове состояния процессора (рис. 5.5). Каждый из режимов имеет собственный указатель стека. Операционная система VAX/VMS использует три из доступных режимов (пользовательский, исполнительный и ядра), a BSD Unix - только два.

Каждая страница адресного пространства может иметь различные права доступа для разных режимов. При этом соблюдаются следующие правила.

  1. 1. Допустимы только права чтения и записи.
  2. 2. Право записи всегда выдается вместе с правом чтения.
  3. 3. Каждый более привилегированный режим всегда имеет те же права, которые имеют менее привилегированные режимы, плюс, возможно, какие-то еще. Такая организация доступа называется кольцами защиты.

Рис. 5.5. Слово состояния процессора VAX

Все допускаемые этими правилами комбинации прав могут быть закодированы при помощи четырех бит в дескрипторе страницы (табл. 5.1). Переключение режимов осуществляется командами СНМК, СНМЕ, CHMS и CHMU. Эти команды помещают слово состояния процессора и счетчик команд в стек, соответствующий новому режиму, сохраняют предыдущий режим в специальном поле слова состояния (зачем нужно сохранять режим в двух местах, мы поймем чуть позже), устанавливают новый режим и, наконец, передают управление по фиксированному адресу, аналогично команде SYSCALL в системах с двумя уровнями доступа. Передача управления по фиксированному адресу позволяет нам защититься от бесконтрольного повышения уровня доступа, а все предыдущие операции дают возможность вернуться на предыдущий уровень доступа с одновременной передачей управления, используя специальную команду REI, которая восстанавливает и счетчик команд, и слово состояния. Команды СHМ(Х) обычно используются для повышения уровня доступа, a REI может использоваться только для его понижения или возврата на тот же уровень (рис. 5.6).

Рис. 5.6. Переключение режимов процессора VAX

Таблица 5.1. Коды защиты для различных режимов доступа процессора VAX. Цитируется по [Прохоров, 1990]

Код | Kernel | Executive | Supervisor | User
0000 | - | - | - | -
0001 | Не предсказуем
0010 | RW | - | - | -
0011 | R | - | - | -
0100 | RW | RW | RW | RW
0101 | RW | RW | - | -
0110 | RW | R | - | -
0111 | R | R | - | -
1000 | RW | RW | RW | -
1001 | RW | RW | R | -
1010 | RW | R | R | -
1011 | R | R | R | -
1100 | RW | RW | RW | R
1101 | RW | RW | R | RW
1110 | RW | R | R | R
1111 | R | R | R | R

R — право чтения, W — право записи,------отсутствие прав.

32-битное адресное пространство процессора VAX разбито на четыре части, каждая объемом по гигабайту. Первый гигабайт адресов предназначен для кода и данных пользовательской программы, второй — для пользовательского стека, третий — для системы, четвертый не используется (рис. 5.7). Каждая из частей имеет собственный указатель на таблицу дескрипторов страниц. Важно отметить, что деление адресного пространства на таблицы не обязательно связано с правами доступа на отдельные страницы — в системной таблице могут быть страницы, доступные для записи из пользовательского режима (на практике этого никогда не бывает, но на уровне диспетчера памяти контроля за этим не реализовано), а в пользовательской — страницы, доступные только ядру.

Рис. 5.7. Адресное пространство VAX

Системная таблица страниц одна во всей системе и содержится в адресных пространствах всех задач. Напротив, пользовательские таблицы у каждой задачи свои (внимательный читатель может отметить определенную параллель между этой структурой и описанным в разд. Банки памяти переключателем банков памяти, который присутствует во всех банках). Для того чтобы упростить системе управление пользовательскими таблицами дескрипторов, эти таблицы хранятся не в физическом, а в виртуальном системном адресном пространстве, и при доступе к ним происходит двойная трансляция адреса.
Ядро системы, таким образом, присутствует в адресных пространствах всех задач. Многие системные модули (например, функция для получения текущего реального времени) доступны для чтения из пользовательского режима и могут вызываться непосредственно, как обычные процедуры. Адреса точек входа этих процедур размещены в специальной таблице в начале системного адресного пространства (рис. 5.8). Другие системные модули (например, подсистема работы с файлами, RMS — Record Management Service (Служба управления записями)) требуют повышения уровня доступа: действительно, если одна из задач работает с файлами с ограниченным доступом, было бы неразумно позволять всем остальным задачам видеть используемые при этом системные буферы. Точки входа этих процедур размещаются в той же таблице, что и прямо вызываемые системные подпрограммы, но тела этих процедур состоят только из двух команд: переключения режима процессора и возврата.

Рис. 5.8. Точки входа системных подпрограмм VAX/VMS

Процедура, работающая в "повышенном" (более привилегированном) режиме процессора, имеет полный доступ ко всем данным режимов с более низким уровнем доступа. Благодаря этому мы можем передать привилегированной процедуре указатель, и она доберется до наших данных простым разрешением этого указателя, без каких бы то ни было специальных команд.
Впрочем, при таком подходе возникает определенная проблема. Поскольку система и пользователь находятся в одном адресном пространстве, пользователь может "подсунуть" системе указатель на страницу, к которой сам не имеет доступа — например, попросить считать нечто из файла в системный сегмент данных. Для исключения таких ситуаций VAX предоставляет команды PROBSR и PROBEW, которые проверяют, существует ли доступ к указанной странице в предыдущем режиме работы процессора. Как мы помним, предыдущий режим сохраняется не только в стеке, но и в слове состояния процесса, и нужно это именно для таких проверок.
Видно, что обойтись без специальных команд все-таки не удалось. К тому же платой за принятое в VAX техническое решение оказалось сокращение полезного адресного пространства задачи в два, а на самом деле даже в четыре (кому нужен стек размером 1 Гбайт?) раза. В 70-е годы, когда разрабатывался VAX, это еще не казалось проблемой.

Уровни доступа 80286
Чуть дальше в близком направлении продвинулись разработчики 80286: у этого процессора уровень доступа определяется старшими двумя битами селектора сегмента (рис. 5.9). Код, исполняющийся в сегменте с уровнем доступа 2, имеет доступ ко всем сегментам своего и более низких уровней. Межсегментный переход с повышением уровня доступа возможен лишь через сегменты со специальным дескриптором, так называемые шлюзы (gate).

Рис. 5.9. Структура адреса процессора i80286

В этой архитектуре для проверки прав доступа к сегменту в предыдущем режиме работы не нужны специальные команды, достаточно проверки селектора сегмента.
80286, хотя и предоставлял почти полноценную сегментную адресацию, не имел сегментных отказов, поэтому использовать все преимущества виртуальной памяти на этом процессоре было невозможно. Вторым недостатком было отсутствие режима совместимости с 8086 — не существовало возможности создать такую таблицу трансляции, которая бы воспроизводила специфическую структуру адресного пространства этого процессора. Отчасти это было обусловлено и использованием битов в селекторе сегмента для задания прав доступа. Обе ОС, которые разрабатывались для этого процессора,— Win16 и OS/2 1 .х — большого успеха не имели.
OS/2 использует три режима доступа: пользовательский, системные DLL и собственно ядро, из четырех, предоставляемых архитектурой х86 (рис. 5.10).
Windows NT (которая начинала свою карьеру как OS/2 New Technology) первоначально проектировалась как переносимая ОС. Требование переносимости на RISC-архитектуры с двумя уровнями привилегий заставило разработчиков отказаться от уровня системных DLL. Позже фирма Microsoft постепенно отказалась от поддержки всех аппаратных архитектур, кроме х86 (дольше всех они держались за DEC Alpha), но двухуровневая структура доступа так и осталась в новых версиях этой системы — Windows 2000/XP.

Рис. 5.10. Уровни доступа в OS/2

Системы семейства Unix используют х86 как нормальную 32-разрядную машину с двухуровневым доступом: пользовательской задаче выделяется один сегмент, ядру— другой. 4-х гигабайтового сегмента х86, разбитого на страницы размером по 4 Кбайт, достаточно для большинства практических целей. Например, в Linux системный вызов исполняется командой int 8Oh. Селектор пользовательского сегмента помещается в регистр FS. Для доступа к этому сегменту из модулей ядра используются процедуры memcpy_from_fs и memcpy_to_fs.

Многоуровневый доступ, основанный на концепции колец, не имеет принципиальных преимуществ по сравнению с двумя уровнями привилегий. Как и в двухуровневой системе, пользовательские модули вынуждены полностью доверять привилегированным, привилегированные же модули не могут защититься даже от ошибок в собственном коде. Самое лучшее, что может сделать Windows XP, обнаружив попытку обращения к недопустимому адресу в режиме ядра, — это нарисовать на синем экране дамп регистров процессора. В OS/2, фатальная ошибка в привилегированных модулях, исполняемых во втором кольце защиты, не обязательно приводит к остановке ядра, но подсистема, в которой произошла ошибка, оказывается неработоспособна. Если испорчен пользовательский интерфейс или сетевая подсистема, система в целом становится бесполезной и нуждается в перезагрузке.
Кроме того, разделение адресных пространств создает сложности при разделении кода и данных между процессами: разделяемые объекты могут оказаться отображены в разных процессах на разные адреса, поэтому в таких объектах нельзя хранить указатели (подробнее см. разд. Разделяемые библиотеки). Стремление обойти эти трудности и создать систему, в которой сочетались бы преимущества как единого (легкость и естественность межпроцессного взаимодействия), так и раздельных (защита процессов друг от друга) адресных пространств, многие годы занимало умы разработчиков аппаратных архитектур.
Например, машины фирмы Burroughs предоставляли пользователю и системе единое адресное пространство, разбитое на сегменты (единую таблицу дескрипторов сегментов). При этом возникает вопрос: если все задачи используют одну таблицу, как может получиться, что разные задачи имеют разные права на один и тот же сегмент?
Решение этого вопроса состоит в том, что права доступа кодируются не дескриптором, а селектором сегмента. Таким образом, разные права доступа на один сегмент — это, строго говоря, разные адреса. Понятно, что такое разделение работает лишь постольку, поскольку пользовательская программа не может формировать произвольные селекторы. В компьютерах Burroughs это достигалось теговой архитектурой: каждое слово памяти снабжалось дополнительными битами, тегом (tag), который определял тип данных, хранимых в этом слове, и допустимые над ним операции. Битовые операции над указателями не допускались. Благодаря этому задача не могла сформировать не только произвольные права доступа, но и вообще произвольный указатель.
Аналогичным образом реализована защита памяти в AS/400 [redbooks.ibm.com sg242222.pdf). Пользовательские программы имеют общее адресное пространство. Первоначально это общее пространство имен, в процессе же преобразования имени в адрес бинарное представление селектора сегмента снабжается и битами доступа, которые затем обрабатываются точно так же, как в машинах Burroughs, и точно так же недоступны пользовательскому коду для модификации.
Реализации языка С для этой архитектуры допускают использование указателей только в пределах одного сегмента кода или данных, но не позволяют формировать средствами С произвольные селекторы сегментов, остальные же языки, используемые на этой платформе (Cobol, RPG, языки хранимых процедур СУБД) вообще не имеют указателя как понятия.
Еще дальше в том же направлении продвинулись разработчики фирмы Intel, создавая экспериментальный микропроцессор 1АРХ432, описанный в следующем разделе.


Взаимно недоверяющие подсистемы

— Вы куда?
— У меня там портфель!
— Я вам его принесу!
— Я вам не доверяю. У меня там ценный веник. ("Ирония судьбы или с легким паром!")
Г. Горин

С точки зрения безопасности, основной проблемой систем с кольцами защиты является неспособность таких систем защитить себя от ошибок в модулях, исполняющихся в высшем кольце защиты. В свете этого, очень привлекательной концепцией представляется идея взаимно недоверяющих подсистем.
Согласно этой концепции, пользовательская задача не должна предоставлять системе доступа ко всем своим данным. Вместо этого задача должна выдавать мандат на доступ к буферу или нескольким буферам, предназначенным для обмена данными. Все акты обмена данными как между пользовательской задачей и системой, так и между двумя пользовательскими задачами или двумя модулями системы, также осуществляются при помощи передачи мандатов.
Например, при исполнении системного вызова int read (int file, void * buf, size_t size) программа должна передать системе мандат на право записи в буфер buf размером size байт (рис. 5.11). При этом буфер будет отображен в адресное пространство подсистемы ввода/вывода, но эта подсистема не получит права записи в остальное адресное пространство нашей задачи. Впрочем, этот подход имеет две очевидные проблемы.

Рис. 5.11. Передача мандатов

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

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

  • Убедиться в том, что передаваемый объект целиком входит в один сегмент исходного адресного пространства.
  • Если объект состоит из нескольких сегментов, разумным образом обработать такую ситуацию. Для программной реализации может оказаться желательным умение объединить все элементы в один сегмент. Для аппаратной или микропрограммной реализации достаточно хотя бы уметь сгенерировать соответствующее исключение.
  • Сформировать содержимое дескриптора сегмента для объекта и записать в него соответствующие права. Эта операция требует заполнения 4—5 битовых полей, и запись ее алгоритма на псевдокоде займет около десятка операторов. По сложности алгоритма одна только эта операция сравнима с наиболее сложными командами современных коммерческих CISC-процессоров, таких как VAX или транспьютер.
  • Отметить в общесистемной базе данных, что на соответствующую область физической памяти существует две ссылки. Это нужно для того, чтобы процессы дефрагментации и управления виртуатьной памятью правильно обрабатывали перемещения сегмента по памяти и его перенос на диск, отмечая изменения физического адреса или признака присутствия во всех дескрипторах, ссылающихся на данный сегмент. Далее эта проблема будет обсуждаться подробнее.
  • Найти свободную запись в таблице дескрипторов сегментов задачи-получателя. Эта операция аналогична строковым командам CISC-процессоров, которые считаются сложными командами.
  • Разумным образом обработать ситуацию отсутствия такой записи.
  • Записать сформированный дескриптор в таблицу.

Но еше больше проблем создает операция уничтожения мандата. Легко показать необходимость уничтожения мандата той же задачей, которая его создала.

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

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

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

Последнее требование непосредственно не следует из концепции взаимно недоверяющих подсистем, но диктуется простым здравым смыслом. Если мы и смиримся с двух- или более кратным увеличением потребностей в памяти, оправдывая его повышением надежности, нельзя забывать, что даже простой просмотр многомегабайтовых таблиц не может быть быстрым.
В системах, использующих страничные диспетчеры памяти, решить эти Проблемы намного проще, потому что минимальным квантом разделения Памяти является страница размером несколько килобайтов. Благодаря этому Мы смело можем связать с каждым разделяемым квантом несколько десятков байтов данных, обеспечивающих выполнение первых двух требований.
Если же минимальным квантом является байт или слово памяти, наши структуры данных окажутся во много раз больше распределяемой памяти.
Автор оставляет читателю возможность попробовать самостоятельно разработать соответствующую структуру данных. В качестве более сложного упражнения можно рекомендовать записать алгоритм работы с такой базой данных на псевдокоде, учитывая, что мы хотели бы иметь возможность реализовать этот алгоритм аппаратно или, по крайней мере, микропрограммно.
Дополнительным стимулом для читателя, решившего взяться за эту задачу, может служить тот факт, что разработчики фирмы Intel не смогли найти удовлетворительного решения.

Архитектура 1432
Автору известен только один процессор, пригодный для полноценной реализации взаимно недоверяющих подсистем: JAPX432 фирмы Intel. Вместо создания системной базы данных о множественных ссылках на объекты, специалисты фирмы Intel усложнили диспетчер памяти и соответственно алгоритм разрешения виртуальных адресов.
Виртуальный адрес в 1432 состоит из селектора объекта и смещения в этом объекте. Селектор объекта ссылается на таблицу доступа текущего домена. Домен представляет собой программный модуль вместе со всеми его статическими и динамическими данными (рис. 5.12). По идее разработчиков, домен соответствует замкнутому модулю языков высокого уровня, например пакету (package) языка Ada.

Рис. 5.12. Домен 1432

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

segment_table[seg].phys_address + offset

мы имеем

object_table[access_table[selector]].phys_address + offset.

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

Рис. 5.13. Уточнение

Целевой объект также может оказаться уточнением, и диспетчер памяти будет повторять процедуру до тех пор, пока не дойдет до объекта, ссылающегося на физическую память. Более подробно этот механизм описан в работе [Органик 1987].
Уточнения позволяют решить проблему выдачи мандатов— мандат реализуется как уточнение исходного объекта (например, сегмента данных модуля в котором выделен буфер), а программе-получателю передается даже не само уточнение, а лишь дескриптор доступа к нему. Для прекращения действия мандата достаточно удалить дескриптор уточнения. После этого все переданные другим модулям дескрипторы доступа будут указывать в пустоту, т. е. станут недействительными. Задачи же перемещения в памяти и сброса объектов на диск решаются путем изменения физического адреса или признака п(в|рутствия целевого объекта; для этого не требуется прослеживать цепочку уточнений.
Однако накладные расходы, связанные с реализацией этого сложного механизма, оказались очень большими. Процессор i432 остался экспериментальным и не получил практического применения. Трудно сказать, какой из факторов оказался важнее: катастрофически низкая производительность системы (автору не удалось найти официальных заявлений фирмы Intel по этому поводу, но народная мудрость утверждает, что экспериментальные образцы i432 с тактовой частотой 20 МГц исполняли около 20 000 операций в секунду) или просто неспособность фирмы Intel довести чрезмерно сложный кристалл до массового промышленного производства. Так или иначе, едва ли не единственной практической пользой, извлеченной из этого амбициозного проекта, оказалось тестирование системы автоматизированного проектирования, использованной впоследствии при разработке процессора 80386 и сопутствующих микросхем.
После завершения проекта I432 других попыток полностью реализовать взаимно недоверяющие подсистемы не делалось — изготовители коммерческих систем, по-видимому, сочли эту идею непродуктивной, а исследовательским организациям проекты такого масштаба просто не по плечу.


Сегменты, страницы и системные вызовы (продолжение)

Аппаратные схемы тонкого разделения доступа к адресному пространству не имели большого успеха не только из-за высоких накладных расходов, но и из-за того, что решали не совсем ту проблему, которая реально важна. Для повышения надежности системы в целом важно не обнаружение ошибок и даже не их локализация с точностью до модуля сама по себе, а возможность восстановления после их возникновения. Самые распространенные фатальные ошибки в программах — это ошибки работы с указателями и выход индекса за границы массива (в наш сетевой век ошибки второго типа более известны как "срыв буфера"). Эти ошибки не только часто встречаются, но и очень опасны, потому что восстановление после них практически невозможно. Ошибки работы с указателями еще можно попытаться устранить, искоренив само понятие указателя. Примерно этой логикой продиктован запрет на формирование указателей в машинах Burroughs, именно из этих соображений в Java и некоторых других интерпретируемых языках указателей вообще нет. Однако искоренить понятие индексируемого массива уже не так легко. д ошибки индексации присуши всем компилируемым языкам, начиная с Fortran и Algol 60. Вставка проверок на границы индекса перед каждой выборкой элемента массива создает накладные расходы, но тоже не решает проблемы: ошибка все равно обнаруживается не в момент совершения (в тот момент, когда мы вычислили неверный индекс), а позже, когда мы попытались его использовать. В момент индексации обычно уже невозможно понять, какой же элемент массива имелся в виду. Программе остается только нарисовать на экране какое-нибудь прощальное сообщение вроде "Unhandled Java Exception", и мирно завершить свой земной путь. Понятно, что реакция пользователя на подобное сообщение будет ничуть не более адекватной, чем на хрестоматийное "Ваша программа выполнила недопустимую операцию — General Protection Fault" (впрочем, кто знает, может быть, такая реакция и является самой адекватной?). Прогресс в решении этой проблемы лежит уже в сфере совершенствования технологий программирования, и вряд ли может быть обеспечен усложнением диспетчеров памяти. Уровень же надежности, обеспечиваемый современными ОС общего назначения с разделением памяти, по-видимому, оптимален в том смысле, что улучшения в сфере защиты памяти могут быть достигнуты лишь ценой значительных накладных расходов без принципиального повышения наработки на отказ.


Разделяемые библиотеки

Ранее мы упоминали разделяемые библиотеки как одно из преимуществ страничных и сегментных диспетчеров памяти перед базовыми и банковыми. При базовой адресации образ каждого процесса должен занимать непрерывные области как в физическом, так и в логическом адресном пространстве. В этих условиях реализовать разделяемую библиотеку невозможно. Но и при использовании страничной адресации не все так просто.
Использование разделяемых библиотек и/или DLL (в данном случае разница между ними не принципиальна) предполагает ту или иную форму сборки в момент загрузки: исполняемый модуль имеет неразрешенные адресные ссылки и имена библиотек, которые ему нужны. При загрузке эти библиотеки подгружаются и ссылки разрешаются. Проблема здесь в том, что при Подгрузке библиотеки ее нужно переместить, перенастроив абсолютные адресные ссылки в ее коде и данных (см. главу 3). Если в разных процессах библиотека будет настроена на разные адреса, она уже не будет разделяемой (рис. 5.14)! Если разделяемые библиотеки могут иметь неразрешенные ссылки на другие библиотеки, проблема только усугубляется — к перемещаемым ссылкам добавляются еще и внешние.

Рис. 5.14. Конфликтующие адреса отображения DLL

В старых системах семейства Unix, использовавших абсолютные загружаемые модули формата a.out, разделяемые библиотеки также поставлялись в формате абсолютных модулей, настроенных на фиксированные адреса. Каждая библиотека была настроена на СБОЙ адрес. Поставщик новых библиотек должен был согласовать этот адрес с разработчиками системы. Это было весьма непрактично, поэтому разделяемых библиотек было очень мало (особенно если не считать те, которые входили в поставку ОС).
Более приемлемое решение этой проблемы реализовано в OS/2 2.x и Win32 (обе эти архитектуры являются развитием систем с единым адресным пространством). Идея состоит в том, чтобы выделить область адресов под загрузку DLL и отображать эту область в адресные пространства всех процессов. Таким образом, все DLL, загруженные в системе, видны всем (рис. 5.15).
Очевидным недостатком такого решения (как, впрочем, и предыдущего) является неэффективное использование адресного пространства: при cколь-нибудь сложной смеси загруженных программ большей части процессов большинство библиотек будет просто не нужны. В те времена, когда эта архитектура разрабатывалась, это еще не казалось серьезной трудностью, но сейчас, когда многим приложениям становится тесно в 4 Гбайт и серверы с таким объемом оперативной памяти уже не редкость, это действительно может стать проблемой.

Рис. 5.15. Загрузка DLL в OS/2 и Win32

Менее очевидный, но более серьезный недостаток состоит в том, что эта архитектура не позволяет двум приложениям одновременно использовать Две разные, но одноименные DLL — например, две разные версии стандартной библиотеки языка С. Поэтому либо мы вынуждены требовать от всех разделяемых библиотек абсолютной (bug-fbr-bug) совместимости версий, либо честно признать, что далеко не каждая смесь прикладных программ будет работоспособна. Первый вариант нереалистичен, второй же создает значительные неудобства при эксплуатации, особенно если система интерактивная и многопользовательская.
Лишенное обоих недостатков решение предлагают современные системы семейства Unix, использующие загружаемые модули формата ELF. Впрочем, для реализации этого решения пришлось, ни много, ни мало, переделать компилятор и научить его генерировать позиционно-независимый код (см. разд. Позиционно-независимый код).

Разделяемые библиотеки формата ELF
Исполняемые модули формата ELF бывают двух типов: статические — полностью самодостаточные, не использующие разделяемых объектов, и динамические — содержащие ссылки на разделяемые объекты и неразрешенные символы. И статические, и динамические модули являются абсолютными. При создании образа процесса система начинает с того, что отображает старчески собранный исполняемый объект в адресное пространство. Статический модуль не нуждается ни в какой дополнительной настройке и может начать исполнение сразу после этого.
Для динамического же загрузочного модуля система загружает так называемый интерпретатор, или редактор связей времени исполнения (run-time linker), no умолчанию ld.so.1. Он исполняется в контексте процесса и осуществляет подгрузку разделяемых объектов и связывание их с кодом основного модуля и друге другом.
При подгрузке разделяемый объект также отображается в адресное пространство формируемого процесса. Отображается он не на какой-либо фиксированный адрес, а как получится, с одним лишь ограничением: сегменты объекта будут выровнены на границу страницы. Не гарантируется даже, что адреса сегментов будут одинаковы при последовательных запусках одной и той же программы.
Документ [HOWTO Library] без обиняков утверждает, что в разделяемых объектах можно использовать только код, компилированный с ключом -? рте. Документ [docs.sun.com 816-0559-10] менее категоричен:
"Если разделяемый объект строится из кода, который не является позиционно-независимым, текстовый сегмент скорее всего потребует большое количество перемещений во время исполнения. Хотя редактор связей и способен их обработать, возникающие вследствие этого накладные расходы могут вести к серьезному снижению производительности".
Как уже говорилось в разд. 3.5, используемый в разделяемых объектах код не является истинно позиционно-независимым: он содержит перемещаемые и даже настраиваемые адресные ссылки, такие, как статически инициализованные указатели и ссылки на процедуры других модулей. Но все эти ссылки размещены в сегменте данных. Используемые непосредственно в коде ссылки собраны в две таблицы, GOT (Global Offset Table, Глобальная таблица смещений) и PL Т (Procedure Linkage Table, Таблица процедурного связывания) (рис. 5.16). Каждый разделяемый модуль имеет свои собственные таблицы. Порожденный компилятором код определяет адреса этих таблиц, зная их смещение в разделяемом объекте относительно точки входа функции (см. примеры 3.7 и 5.1)

Рис. 5.16. Global Offset Table (Глобальная таблица смещений) и Procedure Linkage Table (Таблица процедурного связывания)

Пример 5.1. Типичный пролог функции, предназначенной для использования в разделяемом объекте

• text
•align 2,0x90
•globl _strerror
_strerror:
pushl %ebp ; Стандартный пролог функции
movl %esp,%ebp
pushl %ebx
call L4
popl %ebx ; Загрузка текущего адреса в регистр ЕВХ
acldl $_GLOBAL_OFFSET_TABLE_+ [ . -L4 ] , %ebx

Сегмент кода отображается с разделением его между всеми процессами, использующими объект (конечно, при условии, что он был компилирован с правильными ключами и не содержит перемещаемых адресов).
Напротив, сегмент данных и таблицы GOT и PLT создаются в каждом образе заново и, если это необходимо, адресные ссылки в них подвергаются перемещению. По мере разрешения внешних ссылок, интерпретатор заполняет р|т объекта ссылками на символы, определенные в других объектах (подобный стиль работы с внешними ссылками широко распространен в байт-кодах интерпретируемых языков — см. разд. Сборка в момент звгрузки).
Сегмент данных разделяемого объекта, таким образом, соответствует тому что в OS/2 и Win32 называется приватным сегментом данных DLL: каждая задача, использующая объект, имеет свою копию этого сегмента. Аналога глобальному сегменту данных разделяемые библиотеки ELF не имеют — et%; это необходимо, код библиотеки может создать собственный сегмент разделяемой памяти, но в нем невозможно иметь статически инициализованные данные и для него никто не гарантирует отображения на одни и те же адреса разных процессов, поэтому в нем невозможно хранить указатели.
По умолчанию, интерпретатор осуществляет отложенное редактирование связей: если сегмент данных он полностью настраивает до передачи управления пользовательскому коду, то записи в PLT изначально указывают на специальную процедуру редактора связей. Будучи вызвана, эта процедура по стеку вызова или другими средствами определяет, какую же процедуру пытались вызвать на самом деле, и настраивает ее запись в PLT (рис. 5.17). В случае, когда большинство программ не вызывает большую часть функций, как это часто и бывает при использовании разделяемых библиотек, это дает определенный выигрыш в производительности.

Рис. 5.17. Редактор связей времени исполнения

Пример 5.2. Структура PLT для процессора SPARC (цитируется по [docs.sun.com 816-0559-10])

Первые две (специальные) записи PLT до загрузки программы:
.PLT0:
un imp
unimp
unimp .PLTl:
unimp
unimp
unimp
Обычные записи PLT до загрузки программы:
.PLT101:
sethi (.-.PLT0),%gl
ba,a .PLTO
пор .PLT102:
sethi (.-.PLT0),%gl
ba,a .PLTO
nop
...
Специальные записи PLT после загрузки программы:
.PLT0:
save %sp,-64,°osp
call runtime-linker
пор
.PLT1:
.word identification
unimp
unimp
...
Обычные записи PLT после настройки:
PLT101:
sethi (.-.PLT0),%g1
sethi %hi(name1),%g1
jmpl %g1+%lo(namel),%g0
PLT102:
sethi (.-.PLT0),%g1
sethi %hi (name2),%g1
jmpl %g1+%lo(name2),%g0

Таким образом, каждая пользовательская задача, загруженная в Unix, имеет собственное адресное пространство с собственной структурой (рис. 5.18). Некоторые участки памяти у разных задач могут совпадать, и это позволяет сэкономить ресурсы за счет их разделения. Это существенно менее глубокое разделение, чем то, что достигается в Windows, но, как мы видели в разд. Динамические библиотеки более глубокое и принудительное разделение кода чревато серьезными проблемами.

Рис. 5.18. Разделяемые библиотеки ELF

Разделяемые объекты ELF идентифицируются по имени файла. Исполняемый модуль может ссылаться на файл как по простому имени (например, libc.so.1), так и с указанием пути (/usr/lib/libc.so.1). При поиске файла по простому имени редактор связей ищет его в каталогах, указанных в переменной среды LD LIBRARY_PATH, в записи RPATH заголовка модуля и, наконец, в каталогах по умолчанию, перечисленных в конфигурационном файле /var/ld/ld.config (именно в таком порядке, [docs.sun.com 816-0559-10]). При формировании имен каталогов могут использоваться макроподстановки с использованием следующих переменных.
$ISALIST— список систем команд— полезно на процессорах, поддерживающих несколько систем команд, например х86 и 8086, SPARC 32 и SPARC 64.
$ORIGIN — каталог, из которого загружен модуль. Полезно для загрузки приложений, которые имеют собственные разделяемые объекты.
SOSNAME, $OSREL — название и версия операционной системы.
$PLATFORM— тип процессора. Полезно для приложений, которые содержат в поставке бинарные модули сразу для нескольких процессоров, сетевых установок таких приложений, или сетевой загрузки в гетерогенной среде.
Понятно, что задание простого имени предпочтительнее, так как дает администратору системы значительную свободу в размещении разделяемых библиотек. Впрочем, системный редактор связей Idd позволяет изменять имена внешних ссылок и RPATH в уже построенном модуле, в частности заменяя одни файловые пути на другие, путевые имена на простые и наоборот. Благодаря этому, поставщик приложений для ОС, основанных на формате ELF, имеет гораздо меньше возможностей испортить жизнь системному администратору, чем поставщик приложений для Windows.
По стандартному соглашению, имя библиотеки обязательно содержит и номер версии (в обоих примерах это 1). В соответствии с требованиями фирмы Sun номер версии меняется, только когда интерфейс библиотеки меняется на несовместимый — убираются функции, изменяется их семантика и т. д. Из менее очевидных соображений [docs.sun.com 816-0559-10] требуется менять номер версии и при добавлении функции или переменной: ведь вновь добавленный символ может конфликтовать по имени с символом какой-то другой библиотеки.
Исправление ошибок, т. е. нарушение "bug-for-bug compatibility", основанием для изменения номера версии фирма Sun не считает. Напротив, в Linux принято снабжать разделяемые библиотеки минимум двумя, а иногда и более номерами версий — старшая (major) версия изменяется по правилам, приблизительно соответствующим требованиям Sun, а младшие (minor) — после исправления отдельных ошибок и других мелких изменений.
Благодаря этому соглашению, в системе одновременно может быть установлено несколько версий одного и того же модуля, а пользовательские программы могут ссылаться именно на ту версию, с которой разрабатывались и на совместимость с которой тестировались. Администратор может управлять выбором именно той библиотеки, на которую ссылаются конкретные модули, либо изменяя ссылки в этих модулях при помощи Idd, либо используя символические связи.


Страничный обмен

Подкачка, или свопинг (от англ, swapping — обмен) — это процесс выгрузки редко используемых областей виртуального адресного пространства программы на диск или другое устройство массовой памяти. Такая массовая память всегда намного дешевле оперативной, хотя и намного медленнее.
Во многих учебниках по ОС приводятся таблицы, аналогичные табл. 5.2.

Таблица 5.2. Сравнительные характеристики и стоимость различных типов памяти

Тип памяти | Время доступа | Цена 1 Мбайта (цены 1995 г.) | Способ использования
Статическая память | 15 нc | $200 | Регистры, кэш-память
Динамическая память | 70 нc | $30 (4 Мбайт SIMM) | Основная память
Жесткие магнитные диски | 1-10 мс | $3 (1.2Gb HIDE) | Файловые системы, устройства свопинга
Магнитные ленты | Секунды | $0.025 (8mm Exabyte) | Устройства резервного копирования

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

Примечание
Существует эмпирическое наблюдение, что любой объем дисковой памяти будет полностью занят за две недели.

Очевидно, что система с десятками гигабайтов статического ОЗУ будет иметь стоимость, скажем так, совершенно не характерную для персонапь-ных систем, не говоря уже о габаритах, потребляемой мощности и прочем. К счастью, далеко не все, что хранится в памяти системы, используется одновременно. В каждый заданный момент исполняется только часть установленного в системе программного обеспечения, и работающие программы используют только часть данных.
Эмпирическое правило "80—20", часто наблюдаемое в коммерческих системах обработки транзакций, гласит, что 80% операций совершаются над 20% файла [Heising 1963]. В ряде работ, посвященных построению оптимизирующих компиляторов, ссылаются на правило "90-10" (90% времени исполняется 10% кода) — впрочем, есть серьезные основания сомневаться в том, что в данном случае соотношение именно таково [Кнут 2000, т. 3].
В действительности, удивительно большое количество функций распределения реальных дискретных величин (начиная от количества транзакций на строку таблицы и заканчивая распределением богатства людей или капитализации акционерных обществ) подчиняются закону Парето [Pareto 1897]:

Р = ck [image] -1,

где [image] — число в диапазоне от 0 до 1, k — значение величины (в нашем случае — количество обращений к данной записи), р — количество записей, к которым происходит k обращений, с — нормализующий коэффициент (правило "80—20" соответствует = [image] =log80/log20 = 0,1386) или его частному случаю, распределению Зипфа [Zipf 1949]:

р = c/ k.

Детальное обсуждение этого явления, к сожалению, не доходящее до глубинных его причин, приводится в [Кнут 200, т. 3]. Как один из результатов обсуждения предлагается концепция "самоорганизующегося файла" — для ускорения поиска в несортированном массиве предлагается передвигать записи ближе к началу массива. Если обращения к массиву распределены в соответствии с законом Зипфа, наиболее востребованные записи концентрируются в начале массива и поиск ускоряется в c/ ln N раз, где N — размер массива, а с — константа, зависящая от используемой стратегии перемещения элементов.
При произвольном доступе к данным (например, по указателям или по ключам хэш-таблицы) перемещение к началу массива не имеет столь благотворного эффекта — если только вся память имеет одну и ту же скорость доступа. Но мы видели, что разные типы запоминающих устройств резко различаются по этому параметру.
Это различие приводит нас к идее многослойной или многоуровневой памяти, когда в быстрой памяти хранятся часто используемые код или данные, а редко используемые постепенно мигрируют на более медленные устройства. В случае дисковой памяти такая миграция осуществляется вручную: администратор системы сбрасывает на ленты редко используемые данные и заполняет освободившееся место чем-то нужным. Для больших и сильно загруженных систем существуют специальные программы, которые определяют, что является малоценным, а что — нет. Управление миграцией из ОЗУ на диск иногда осуществляется пользователем, но часто это оказывается слишком утомительно. В случае миграции между кэш-памятью и ОЗУ Делать что-то вручную просто физически невозможно.


Поиск жертвы

..И вот мы образовались вашему приходу,- может, вы согласитесь принести себя в жертву
А. Тутуола

Естественно, для того чтобы автоматизировать процесс удаления барахла" — редко используемых данных и программ — мы должны иметь какой-то легко формализуемый критерий, по которому определяется, какие данные считаются редко используемыми.
Один критерий выбора очевиден — при прочих равных условиях, в первую очередь мы должны выбирать в качестве "жертвы" (victim) для удаления тот объект, который не был изменен за время жизни в быстрой памяти. Действительно, вы скорее удалите с винчестера саму игрушку (если у вас есть ее копия на дискетах), чем файлы сохранения!
Для ручного переноса данных очевиден и другой критерий: нужно удалять (Для блоков данных, которые не подверглись изменению за время пребывания в быстрой памяти, удаление будет состоять в уничтожении копии. Для модифицированных же блоков новое значение данных должно быть скопировано обратно в медленную память, и только после этого можно произвести собственно удаление.) то, что дольше всего не будет использоваться в будущем. Конечно, любые предположения о будущем имеют условный характер, все может неожиданно измениться. Мы делаем предположения о том, что будет использоваться, только на основании накопленной статистики обращений к страницам. Такая экстраполяция не совсем корректна логически, поэтому может оказаться целесообразным смириться с некоторой неточностью или неполнотой этой статистики.
Самая простая стратегия — выбрасывать случайно выбранный объект. При этом не надо собирать никакой статистики о частоте использования и т. д., важно лишь обеспечить равномерность распределения генерируемых псевдослучайных чисел. Очевидно, что при этом удаленный объект совершенно необязательно будет ненужным
Можно также удалять то, что дольше всего находится в памяти. Это называется алгоритмом FIFO (First In, First Out, первый вошел, первый вышел). Видно, что это уже чуть сложнее случайного удаления — нужно запоминать, когда мы что загружали.

Поиск жертвы в VAX/VMS и Windows NT/2000/XP
В VAX/VMS и Windows NT/2000/XP применяется любопытный вариант этого алгоритма. В этих системах страница, объявленная жертвой, исключается из адресного пространства задачи (у соответствующего дескриптора выставляется бит отсутствия), но не отдается немедленно под другие нужды. Вместо этого страницы, помеченные как отсутствующие, помещаются в пул свободных страниц, который, по совместительству, используется и для дискового кэша и может занимать более половины оперативной памяти. У VAX/VMS этот пул состоит из трех очередей (рис. 5.19).
1. Очередь модифицированных страниц, ждущих записи на диск (по мере записи, эти страницы переходят во вторую очередь).
2. Очередь немодифицированных страниц.
3. Очередь свободных страниц (освобожденных прикладной программой или освободившихся после ее завершения).
Жертвой с равной вероятностью может быть объявлена как модифицированная, так и немодифицированная страница, однако для запросов прикладных программ и буферов дискового кэша используются только страницы из второй и третьей очередей.

Рис. 5.19. Виртуальная память VAX/VMS

Обрабатывая страничный отказ, система не обращается к диску за содержимым требуемой страницы, а сначала пытается найти ее в одной из очередей пула. Если страница там, ее без дальнейших вопросов включают в адресное пространство задачи (рис. 5.20).
Такой алгоритм порождает много лишних страничных отказов, но обеспечивает большую экономию обращений к диску. У VAX/VMS эта стратегия сочетается с управлением объемом пула страниц: у каждого пользователя есть квота на объем рабочего множества запущенных им программ. При превышении квоты ОС и осуществляет поиск жертвы среди адресных пространств задач этого пользователя. При разумном управлении этими квотами система обеспечивает весьма хорошие показатели даже при небольших объемах оперативной памяти.
Windows NT (и более поздние версии этой системы, 2000/ХР) пытается управлять пулом свободных страниц динамически, не предоставляя администратору никаких средств для прямой настройки. Поэтому на нехватку ОЗУ или нетипичный режим использования памяти эта система реагирует резким падением производительности. По-видимому, это падение обусловлено развитием автоколебаний в динамической настройке пула свободных страниц.
Так, автору удавалось привести в неработоспособное состояние Windows 2000 Wokrstation с 256 Мбайт ОЗУ, всего лишь открыв по ошибке для редактирования в far файл объемом 64 Мбайт. Файл не превосходил 1/4 доступной оперативной памяти. Тем не менее, машина "впала в кому", не реагируя даже на <Ctrl>+<Alt>+<Del>, и находилась в этом состоянии достаточно долго для того,чтобы собрать вокруг нее консилиум системных администраторов и, в конце концов, решить, что проще нажать на кнопку системного сброса.

Рис. 5.20. Обработка страничного отказа (блок-схема)

Фирма Microsoft вполне сознает ущербность принятой стратегии управления памятью и официально не рекомендует запускаыть на серверах под управлением Windows NT/2000/XP более одного приложения или ресурсоемкого сервиса.

Пул свободных страниц, в который входят как действительно свободные, так и отобранные у задач в качестве "жертв" страницы, в той или иной форме поддерживают все ОС, использующие страничный обмен, однако обычно этот пул отделяют от дискового кэша и при полной загрузке он не превосходит нескольких процентов ОЗУ. При запросах ядра и страничных отказах система выделяет страницы из этого пула, и только при падении его объема ниже некоторого предела, начинает поиск жертв в адресных пространствах активных процессов.
Шиболее справедливым будет удалять тот объект, к которому дольше всего не было обращений в прошлом LRU (Leasr Recently Used). Такой подход требует набора сведений обо всех обращениях. Например, диспетчер памяти должен поддерживать в дескрипторе каждой страницы счетчик обращений, и при каждой операции чтения или записи над этой страницей увеличивать этот счетчик на единицу. Это требует довольно больших накладных расходов — в ряде работ, например, в [Краковяк 1987], утверждается, что они будут недопустимо большими.
Остроумным приближением к алгоритму LRU является так называемый clock-алгоритм, применяемый во многих современных ОС, в том числе в системах семейства Unix. Он состоит в следующем (рис. 5.21).

  • Дескриптор каждой страницы содержит бит, указывающий, что к данной странице было обращение. Этот бит называют clock-битом.
  • При первом обращении к странице, в которой clock-бит был сброшен, диспетчер памяти устанавливает этот бит.
  • Программа, занимающаяся поиском жертвы, циклически просматривает все дескрипторы страниц. Если clock-бит сброшен, данная страница объявляется жертвой, и просмотр заканчивается до появления потребности в новой странице. Если clock-бит установлен, то программа сбрасывает его и продолжает поиск.

Рис. 5.21. Clock-алгоритм (блок-схема)

Название clock, по-видимому, происходит от внешнего сходства процесса циклического просмотра с движением стрелки часов (рис. 5.22). Очевидно что вероятность оказаться жертвой для страницы, к которой часто происходят обращения, существенно ниже. Накладные расходы этого алгоритма гораздо меньше, чем у LRU: вместо счетчика мы храним только один бит и изменяем его не при каждом обращении к странице, а только при первом обращении после прохода "стрелки".

Рис. 5.22. Работа clock-алгоритма

Практически все известные автору современные диспетчеры памяти предполагают использование clock-алгоритма. Такие диспетчеры хранят в дескрипторе страницы или сегмента два бита — clock-бит и признак модификации. Признак модификации устанавливается при первой записи в страницу/сегмент, в дескрипторе которой этот признак был сброшен.

Имитация clock-алгоритма
Ранние версии процессора VAX не имели аппаратно реализованного clock-бита. BSD Unix на этих процессорах реализовал clock-алгоритм, используя для этого бит отсутствия страницы.
Читателю предлагается детально представить себе алгоритм такого использования и найти отличия между этой стратегией и FIFO-алгоритмом в исполнении VAX/VMS.

Экспериментааьные исследования показывают, что реальная производительность системы довольно слабо зависит от применяемого алгоритма поиска жертвы. Статистика исполнения реальных программ говорит о том, что каждая программа имеет некоторый набор страниц, называемый рабочим множеством, который ей в данный момент действительно нужен. Размер такого набора сильно зависит от алгоритма программы, он изменяется на различных этапах исполнения и т. д.. но в большинстве моментов мы модем довольно точно указать его.
Если рабочий набор запущенных программ превосходит оперативную память, частота страничных отказов резко возрастает. При нехватке памяти программе почти на каждой команде требуется новая страница, и производительность системы катастрофически падает. Это состояние по-английски называется thrashing (общепринятого перевода для этого слова нет) и является крайне нежелательным.
В системах коллективного пользования размер памяти часто выбирают так, чтобы система балансировала где-то между состоянием, когда все программы держат свое рабочее множество в ОЗУ, и трэшингом.
Точное положение точки балансировки определяется в зависимости от соотношения между скоростью процессора и скоростью обмена с диском, а также от потребностей прикладных программ. Во многих старых учебниках рекомендуется подбирать объем памяти так, чтобы канал дискового обмена был загружен на 50% [Краковяк 1987].
Еще одно эмпирическое правило приводится в документации фирмы Amdahl: сбалансированная система должна иметь по мегабайту памяти на каждый MIPS (Million of Instructions Per Second — миллион операций в секунду) производительности центрального процессора. Если система не использует память, определенную по этой формуле, есть основания считать, что процессор также работает с недогрузкой. Иными словами, это означает, что вы купили слишком мощный для ваших целей процессор и заплатили лишние деньги.
Это правило было выработано на основе опыта эксплуатации больших компьютеров четвертого поколения, в основном на задачах управления базами данных. Скорость дисковой подсистемы в этих машинах была примерно сравнима с дисковыми контроллерами современных персональных компьютеров, поэтому в первом приближении этот критерий применим и к ПК, особенно работающим под управлением систем с виртуальной памятью — OS/2, Windows NT и системами семейства Unix. В любом случае, для выдачи рекомендаций требуется анализ смеси приложений, которая будет исполняться в системе, и других требований к ней.
В современных персональных системах пользователь, как правило, работает в каждый момент только с одной-двумя программами, и задержки в исполнении этих программ существенно снижают наблюдаемую скорость системы. Поэтому в таких системах память обычно ставят с большим запасом, так, чтобы при обычной деятельности рабочие множества программ даже близко не подходили к размеру ОЗУ. Отчасти это обусловлено тем, что в наше время динамическая память становится все дешевле и дешевле.


Управление своп-файлом

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

Рис. 5.23. Магнитный диск и магнитный барабан

В своп-файл попадают только страницы, которые изменились с момента загрузки процесса. Если ОС использует абсолютную загрузку или позиии-онно-независимып код, исполняющийся код не отличается от своего образа в загрузочном файле, поэтому страницы кода вполне можно подкачивать оттуда, и нет никакой необходимости копировать их в своп. Часто при загрузке программы система помещает в память только страницу, на которую указывает стартовый адрес, а весь остальной используемый код и данные подгружаются механизмом страничного обмена.
При загрузке статически инициализированных данных обычно используется стратегия copy-on-write (копирование при модификации): первоначально страница подкачивается из файла. Если она не будет модифицирована и ее объявят жертвой, то при повторном обращении ее снова подгрузят из того же файла (рис. 5.24). Только если страница будет изменена, ей выделят место в своп-файле.

Рис. 5.24. Копирование при модификации

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

Отображение файлов в память в Unix
Системы семейства Unix предоставляют пользователям доступ к механизмам, используемым при загрузке программ, в виде системного вызова гол-ар. Этот вызов отображает участок файла в память. Отображение возможно в двух режимах: MAP_SHARED изменения в памяти отображаются в файле— таким образом mmap можно использовать для реализации разделяемой памяти и MAP_PRIVATE (соответственно, изменения памяти в файле не отображаются — при этом измененные страницы копируются в своп-файл).
Широко используется выделение памяти при помощи отображения псевдофайла /dev/zero (файл бесконечной длины, состоящий из одних нулей) в память в режиме MAP_PRIVATE.

Даже когда место под своп-файл выделяется динамически, система обычно предоставляет возможность ограничивать его рост. У интерактивных систем
при приближении к границе емкости своп-файла система часто начинает выдавать предупреждения пользователю. Однако основным видом реакции на переполнение или превышение лимитов роста своп-файла является отказ выделять память прикладным программам. Поэтому грамотно написанные программы всегда должны проверять, нормально ли завершился запрос на выделение памяти, и по возможности разумно обрабатывать ненормальное завершение. Это нужно не только в том случае, когда программа будет переноситься в систему без виртуатьной памяти, но и во вполне штатной (хотя и относительно редкой) ситуации переполнения свопа.
Иногда, впрочем, система может выделять память (точнее, не память, а только
адресное пространство) программам без оглядки на то, сколько есть свободного свопа. Эта довольно опасная стратегия, называемая overcommit, на первый взгляд кажется бессмысленной или полезной только в очень специальных случаях, например при использовании разреженных массивов. В действительности эта стратегия оправдана и тогда, когда мы можем быть уверены, что большинство выделенных процессу страниц никогда не будут использованы, например, при широком применении стратегии copy-on-write.

Overcommit в Unix
В системах семейства Unix копирование при записи применяется не только при загрузке сегментов данных программ и отображений файлов в память, но и при создании задач. Системный вызов fork (подробнее обсуждался в главе 3) создает полную копию адресного пространства процесса, выполнившего этот вызов. Физического копирования, естественно, не происходит. Вместо этого система отображает память родительского процесса в адресное пространство потомка и устанавливает защиту от записи на все страницы обеих задач. Когда какая-то из задач пытается осуществить запись, соответствующая страница физически копируется и запись осуществляется уже в копию. Большинство порожденных задач исполняют системный вызов exec вскоре после создания, изменив лишь несколько переменных в своем адресном пространстве. При таком стиле работы с памятью, действительно, многие выделяемые страницы не используются никогда, а большинство из используемых только прочитываются, поэтому overcommit является стандартной стратегией выделения памяти в Unix.


Одноуровневая память

И каждый уже десять лет учит роли,
О которых лет десять как стоит забыть
Б.Гребенщиков

Эффективное управление рабочими наборами пользовательских программ и, с другой стороны, эффективное кэширование запросов к дискам позволяют если и не скрыть полностью, то в значительной мере сгладить различие в производительности оперативной и внешней памяти компьютера. Поэтому сразу же после возникновения первых машин с виртуальной памятью начались попытки спрятать все остальные различия между этими двумя типами памяти от программиста, реализовав так называемую одноуровневую память.
Интерес к этой идее сохраняется до сих пор. Например, на сайте [dz.yandex.ru] в конце 2000 года была серия публикаций и довольно бурная дискуссия о достоинствах и недостатках "персистентных объектов" — объектов в терминах объектно-ориентированного программирования, которые переживают перезагрузки и выключения питания системы. Для хранения таких объектов может использоваться как флэш-память, так и другие формы энергонезависимой памяти, те же жесткие диски. Владелец сайта и инициатор дискуссии, Дмитрий Завалишин, отстаивал тезис о том, что такие объекты представляют собой сокровенную мечту и своего рода Священный Грааль всего программирования и развития вычислительной техники.

Одноуровневая память в Multix
Пионером в реализации одноуровневой памяти была ОС Multix фирмы Honeywell. Эта система была разработана в конце 60-х годов и оказала огромное влияние на развитие вычислительной техники как прямо, так и посредством своего потомка Unix. Несколько машин с этой ОС эксплуатировались и были доступны через Internet (во всяком случае, отвечали на запрос ping) еще в 1997 году.
Multix предоставляла средства отображения файлов на адреса оперативной памяти наравне с более традиционными средствами ввода-вывода. Ранние версии Unix предназначались в том числе и для работы на машинах без диспетчера памяти или с виртуальной памятью на основе базовых регистров, где возможен лишь традиционный ввод-вывод. Однако современные системы этого семейства отчасти вернулись к истокам и также предоставляют этот способ доступа к файлам.
Впрочем, как отмечалось выше, предоставляемый современными Unix-системами mmap скорее представляет собой документированный внутренний интерфейс загрузчика, чем полноценное средство организации одноуровневого доступа: работа с отображенным файлом не полностью прозрачна для пользователя. Например, изменения содержимого отображенного ОЗУ и наоборот, изменения, внесенные в файл с момента отображения, необходимо синхронизовать друг с другом вручную, используя системный вызов msynch. Средства, предоставляемые для этой цели Windows NT/2000/XP, более прозрачны и просты в использовании, но тоже применяются относительно редко.

Для того чтобы понять, возможна ли полностью одноуровневая память, и если да, то в какой мере, давайте сначала установим различия между ОЗУ и наиболее распространенным типом внешней памяти, жестким магнитным Диском.
Во-первых, объем оперативной памяти в современных компьютерах измеряется десятками и сотнями мегабайт, а у систем коллективного пользования Достигает нескольких гигабайт. Характерная емкость жесткого диска начинается с нескольких гигабайт (диски меньшего объема просто не производятся) и заканчивается сотнями гигабайт. Если мы хотим адресовать все это единым образом, мы должны расширить адресное пространство. 32 бит явно недостаточно, 64 бит на ближайшие годы хватит, но рост емкости дисковых массивов идет по экспоненте.
Впрочем, расширение адресного пространства само по себе не представляет большой проблемы — мало 64 бит, сделаем 128, тем более что речь идет не об адресной шине процессора, используемой только для адресации ОЗУ а о виртуальном адресе. Современные технологии без особых проблем позволяют упаковать арифметико-логическое устройство и регистры такой разрядности в один кристалл, сделать корпус с надлежащим числом ног, печатную плату, в которую можно впаять такой корпус и автоматическую линию, которая будет распаивать корпуса по платам. Да, это будет не Spectrum, вручную не спаяешь — но и материнскую плату современного PC-совместимого компьютера вручную невозможно развести и спаять. Ну и что?
Во-вторых, оперативная память теряет свое содержимое при выключении питания, а жесткий диск — сохраняет, поэтому нередко используется еще одна характеристика дисковой памяти как противопоставление оперативной — постоянная память. Можно вспомнить и о промежуточных решениях, например о флэш-памяти и ее аналогах, которые адресуются почти как ОЗУ, а данные хранят почти как жесткий диск. Наличие промежуточных решений наводит на мысль, что особых проблем "с этой стороны ждать не приходится. На самом деле, проблема здесь есть, но обсудим мы ее чуть позже.
В-третьих, и это связано сразу с обеими вышеназванными причинами, человек снисходит до ручного наведения порядка на диске (удаления мусора и пр.) гораздо чаще, чем до выполнения той же операции в ОЗУ. Поэтому жесткие диски обычно снабжаются еще одной схемой адресации, ориентированной на использование человеком: когда вместо адреса, представляемого целым числом, используется символическое имя.
Трансляция имени в адрес (сектор, поверхность и дорожку жесткого диска) осуществляет файловая система. Вопросы организации файловых систем, каталогов и управления структурами свободной и занятой дисковой памяти обсуждаются в главе 10.
Единственная из используемых в настоящее время архитектур, предоставляющая "честную" одноуровневую память, AS/400, имеет два представления указателя, неразрешенное — с именем в качестве селектора сегмента, и разрешенное — с бинарным представлением этого селектора. Можно себе представить и другие механизмы трансляции имен в адреса, например получение указателя посредством исполнения системного вызова

void *resolve(char * object_name, int flags)

или чего-нибудь в этом роде. Особых технических проблем это не представляет, вопрос в том, надо ли это.
Изложение одного из доводов в пользу того, что это надо далеко не всегда, мы предлагаем начать издалека, а именно с весьма банального тезиса, что писать программы без ошибок человечество до сих пор не научилось и вряд ли научится в обозримом будущем. Исполнение программы, содержащей ошибки, может порождать не только обращения по неверным указателям и выход за границы массивов (наиболее разрушительные типы ошибок, от которых сегментные и страничные диспетчеры памяти предоставляют определенную защиту), но и более тонкие проблемы — фрагментацию и/или утечку свободной памяти и различные рассогласования (в СУБД применяют более точный термин — нарушения целостности данных).
Накопление этих ошибок рано или поздно приводит к тому, что программа теряет способность функционировать. Потеря этой способности может быть обнаружена и пользователем ("что-то прога глючит", "зависла"), и системой (исчерпание квоты памяти или других ресурсов, превышение лимитов роста своп-пространства или доступ по недопустимому адресу), и даже самой программой — если существуют формальные критерии целостности данных, в различных местах кода могут встречаться проверки этих критериев.
Учебники по программированию, например [Дейкстра 1978], настоятельно рекомендуют вырабатывать такие критерии и вставлять соответствующие проверки везде, где это целесообразно. Понятно, что забывать о здравом смысле и вставлять их после каждого оператора, или даже лишь перед каждой операцией, для исполнения которой требуется целостность, далеко не всегда оправдано с точки зрения производительности, так что при реальном программировании надо искать баланс.
Иногда в ходе таких проверок даже удается восстановить целостность (примеры алгоритмов проверки и восстановления структур файловой системы приводятся в главе 12), но очевидно, что далеко не всегда это возможно. В этом случае остается лишь проинформировать пользователя, что у нас "Assertion failed" (предположение нарушено) и по возможности мирно завершиться.
Сохранять при этом данные в постоянную память опасно: если мы не можем восстановиться, мы часто не можем и знать, насколько далеко зашло нарушение целостности, поэтому сохранение чего бы то ни было в таком состоянии чревато полной или частичной (что тоже неприятно) потерей информации. В частности, именно из этих соображений ОС общего назначения, обнаружив ошибку в ядре, сразу рисуют регистры на консоли (рискуя при этом целостностью файловых систем и пользовательских данных), а не предлагают пользователю предпринять какие-либо меры пс сохранению данных.
Смысл останова задачи или всей системы с последующим ее перезапуском состоит в том, чтобы заново проинициализировать структуры данных, используемые при работе программного обеспечения. Это действие можно описать ка!
"контролируемое забывание" всего плохого, что накопилось в памяти за время работы программы, и начало с более или менее чистого листа.
Сервис автоматического перезапуска в различных формах предоставляется многими приложениями, ОС и даже аппаратными архитектурами.
Например, практически обязательным элементом современных микроконтроллеров является watchdog timer (сторожевой таймер, дословно — "сторожевая собака"), часто работающий от собственного осциллятора, а не от общего тактового генератора машины. Программа микроконтроллера должна периодически сбрасывать сторожевой таймер, иначе, досчитав до конца, он делает вывод, что программа "зависла" и инициирует системный сброс.
Именно сторожевой таймер несколько раз перезагружал бортовой компьютер посадочного модуля "Аполлона-И" и, по-видимому, спас этим жизни астронавтов и лунную программу США [NASA 182505].
Аналогичные схемы часто применяются во встраиваемых приложениях, особенно ориентированных на длительную автономную работу. Действительно, встраиваемое приложение может оказаться в весьма неприятном для человека месте, например, вблизи от активной зоны реактора или ускорителя (понятно, что для таких применений необходимо специальное исполнение микросхем, например на сапфировой подложке). В этих случаях иногда выводят кнопку системного сброса в "чистые" области многометровым кабелем. Но, скажем, для управляющего компьютера космического аппарата такое решение просто нереализуемо. Бывают и ситуации, когда выводить кнопку системного сброса наружу нежелательно по более банальным, эргономическим и т. д., соображениям, например из-за опасности случайного нажатия.
О чем-то аналогичном такому сервису часто мечтают администраторы серверов. В Новосибирском FIDO однажды вполне серьезно обсуждалась такая схема автоматизированного перезапуска: сервер каждые пять минут перепрограммирует источник бесперебойного питания на то, чтобы он через десять минут от текущего момента выключился и снова включился. В FIDO встречаются описания и более экстравагантных решений, например "деглюкатор" (устройство, включаемое в шину ISA и по запросу программы выполняющее сброс внешнего модема) или рычажный механизм, посредством которого компьютер, выдвинув поднос CD-ROM, может сам себе нажать на кнопку сброса (полезен в ситуациях, когда система еще условно работоспособна, но с высокой вероятностью может "зависнуть" при попытке нормальной перезагрузки).
Понятно, что все вышеперечисленные решения не могут заменить собой отладку и исправление ошибок в прикладных и системных программах И являются скорее последней линией обороны (если даже не действиями, предпринимаемыми от отчаяния) в борьбе с системными сбоями. Но пока человечество не научится писать программы без ошибок, возможность "привести в чувство" обезумевшую программу путем ее перезапуска остается жизненно необходимой.
Понятно также, что если программа полностью сохраняет свое состояние в постоянной памяти, ее перезапуск нам ничем не поможет: программа честно восстановит весь тот мусор, который накопился в ее сегментах и файлах данных за время предыдущей сессии, и честно воспроизведет снова тот сбой, из-за которого и потребовался рестарт.
В этом смысле крайне желательно держать под контролем перенос данных из постоянной памяти в оперативную, а особенно в обратном направлении избегая его полной "прозрачности". Каждая дополнительная точка сохранения состояния системы повышает риск воспроизведения сбоя или даже возникновения новых проблем, порожденных в файлах сохранения состояния во время чрезмерно "жесткого" перезапуска.

Реестр Win32
В свете этого, например, системный реестр Win32, не имеющий адекватных средств восстановления и самоконтроля, представляет собой если и не сознательную диверсию, то, во всяком случае, недостаточно продуманное техническое решение — из-за него многие проблемы, для исправления которых в более продуманных с этой точки зрения системах достаточно перезагрузки или очистки конфигурационного файла, в Win32 приходится решать переустановкой ОС.
Да, Windows NT предоставляет некоторые средства резервного копирования реестра — "восстановительную" дискету и "последнюю хорошую (last known good) конфигурацию", но эти средства неудобны для повседневного использования, а "последняя хорошая конфигурация" и просто неадекватна: тот факт, что с данным содержимым реестра мы дошли до окошка с именем и паролем, это, конечно, определенное достижение и повод этот реестр сохранить — но ни в коем случае не повод затирать предыдущую (теперь уже предпоследнюю) "хорошую" конфигурацию!
Если говорить именно о настройках ОС, радикальнее всего эта проблема решена в современных версиях FreeBSD (свободно распространяемой системе семейства Unix), в которой все файлы настройки ОС и системных сервисов включены в систему контроля версий, обеспечивающую полный или частичный откат на неограниченное число модификаций назад. Собственно, это может быть реализовано в любой ОС, которая хранит свою конфигурацию в текстовом формате, стандартными средствами контроля версий, используемыми для разработки программного обеспечения, — CVS и др.

В свете вышеприведенных рассуждений, полезно разделять оперативные и хранимые объекты не только по способу адресации, но и по представлению данных. Эти представления должны удовлетворять различным и не всегда совместимым требованиям: при выборе внутреннего, оперативного представления данных основные критерии — это скорость, удобство доступа и умопостижпмость кода, который будет с этими данными работать, а для „нешнего, хранимого представления — прежде всего легкость проверки и, если это возможно, восстановление целостности данных. При разработке внешнего формата данных желательно также принять во внимание соображения межмашинной совместимости — возможные различия в порядке байтов и даже битов в целочисленных значениях, особенности представления чисел с плавающей точкой, различия кодировки текста и так далее.
Если хранимое и оперативное представления объектов различны, одноуровневая память для нас скорее вредна, чем бесполезна: прежде чем программа сможет работать с объектом, она должна преобразовать его во внутреннее представление (в объектно-ориентированных языках это может делать один из конструкторов объекта). Эту процедуру обычно оказывается целесообразно совместить со считыванием внешнего представления объекта из файла. "Прозрачная" же для пользовательской программы запись данных во внешний формат бывает и просто опасна — нередко для обеспечения целостности данных оказывается необходим контроль над порядком записи тех или иных полей и структур.
Объединение оперативной и долговременной памяти, таким образом, оказывается применимо лишь в тех ситуациях, когда нам, во-первых, удалось разработать модель данных, одновременно удовлетворяющую требованиям, предъявляемым и к оперативному, и к хранимому представлениям, и во-вторых, когда нас не беспокоит опасность нарушения нашей модели данных из-за неполного их сохранения в момент системного сбоя (или когда мы имеем какие-то средства предотвращения этой опасности).
Безусловно, средства для отображения файлов в память лучше иметь, чем не иметь. К тому же их можно использовать и для других целей, кроме собственно организации одноуровневого доступа к данным — для загрузки программ, выделения памяти или эмуляции сегментов данных, разделяемых между задачами и даже между машинами (при доступе к файлу по сети).
Важно еще подчеркнуть, что разделение представлений данных на внешние и внутренние не обязано полностью соответствовать способу их хранения — в ОЗУ или на диске. Кроме хранения оперативных данных в своп-пространстве и разного рода "виртуальных дисков" можно привести и более радикальный пример: таблицы, индексы и прочие файлы данных сервера реляционной СУБД представляют собой, скорее, оперативное представление данных, РОЛЬ же хранимого представления в данном случае играют форматы, Используемые для экспорта и резервного копирования содержимого таблиц.
Благодаря этому примеру становится понятнее, почему единственная из коммерчески применяемых в настоящее время систем с одноуровневой адресацией — AS/400 — ориентирована на использование в качестве сервера СУБД. В литературе, особенно в рекламной, даже встречается ее описание "аппаратного сервера баз данных".

Примечание
Вообще, описание специализированных компьютеров как "аппаратное что-то там"— нередко встречающийся, остроумный и довольно эффективный маркетинговый прием. Понятно, что чем более короткий и однозначный ответ дает технический специалист на вопросы "принимающих решения", тем легче ему будет обосновать конкретный выбор. Поэтому наравне с грамотным и исчерпывающим описанием технических достоинств, хорошее рекламное описание должно в явном или неявном виде содержать и варианты ответов на многие распространенные вопросы со стороны нетехнического персонала.
Так, если начальник спрашивает администратора: "Вот, купим мы этот компьютер — моя секретарша сможет на нем в Lines играть?", тот может ему ответить: "А это не компьютер, это аппаратный... " маршрутизатор (Cisco), сервер СУБД (AS/400), веб-сервер (попытки продавать такие серверы на основе Linux делались, но большого успеха не имели), нужное подставить.


Глава 6. Компьютер и внешние события

  • Компьютер и внешние события
  • Опрос
  • Канальные процессоры и прямой доступ к памяти
  • Прерывания
  • Исключения
  • Многопроцессорные архитектуры


Компьютер и внешние события

Мы ждали его слишком долго.
Что может бытьглупее, чем ждать?
Б. Гребенщиков

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

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

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


Опрос

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

Пример 6.1. Пример использования режима опроса

; Приведенный фрагмент кода использует опрос таймера TMRO,
; работающего от "часового" кварцевого генератора с частотой 32768Гц.
; Цикл опроса в чистом виде TMRO — регистр таймера,
; Timervalue — просто переменная,
; регистр 0 — аккумулятор, обозначаемый также как W.
; Такой цикл ожидает одного отсчета таймера.
MovF TMRO, 0
MovWF TimerValue
G5H_Continuel
MovF TimerValue, 0
SubWF TMRO, 0
BNZ G5H_Continuel
; Код содержит два цикла опроса: первый цикл генерирует ; сигнал высокого напряжения, второй — низкого.
; В результате получается периодический сигнал, называемый меандром.
; Фрагмент определителя номера на основе микроконтроллера PIC
; (с) 1996, Дмитрий Иртегов.
; Запрос к АТС ка выдачу номера.
; Генератор меандра с частотой 501.5 гц. Выдает 50 периодов (100 мс).
; генерирует 2 периода по 16 тиков и один — по 17.
; Получается очень похоже.
6enerate500Hz
MovLW 50
MovWF AONByteCounter
MovLW 3
MovWF Tmpl
MovF TMRO, 0
MovWF TimerValue G5H_NextPeriod
MovLW 8
AddWF TimerValue, 1
BSF LINE_CTL_PORT, LINE_ANSWER G5H_Continuel
MovF TimerValue, 0
SubWF TMRO, 0
BNZ G5H_Continuel
MovLW 8
AddWF TimerValue, 1
BCF LINE_CTL_PORT, LINE_ANSWER
DecFSZ Tmpl, 1 GoTo G5H_ContinueO
MovLW 3
MovWF Tmpl
IncF TimerValue, 1 G5H_ContinueO
MovF TimerValue, 0
SubWF TMRO, 0
BNZ GSH_ContinueO
DecFSZ AONByteCounter, 1 GoTo G5H_NextPeriod
Return

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


Канальные процессоры и прямой доступ к памяти

Одно из решений состоит в том, чтобы завести отдельный процессор и поручить ему всю работу по опросу. Процессор, занимающийся только организацией ввода-вывода, называют периферийным или канальным (channel).
Понятно, впрочем, что это повышает стоимость системы и не решает проблемы радикально — теперь вместо флагов, непосредственно сигнализирующих о внешних событиях, центральный процессор вынужден опрашивать флаги, выставляемые канальным процессором. В зависимости от характера событий и требуемой обработки это решение может оказаться и совсем неприемлемым, например, если на каждое событие требуется немедленная реакция именно центрального процессора.
В противном случае, если немедленно после события требуется лишь простая обработка, а сложные вычисления можно отложить на потом, канальный процессор можно упростить и сделать существенно дешевле центрального.
Так, при работе с контроллерами дисков, лент и других устройств массовой памяти возникает задача копирования отдельных байтов (или, в зависимости от разрядности шины контроллера, полуслов или слов) из контроллера в память и обратно. Передача одного блока (512 байт у большинства современных контроллеров) состоит из 128 операций передачи слова, идущих друг за другом с небольшими интервалами. Темп передачи данных определяется скоростью вращения диска или движения ленты. Этот темп обычно ниже скорости системной шины, поэтому передача данных должна включать в себя опрос признака готовности контроллера принять или предоставить следующее слово. Интервал между словами обычно измеряется несколькими циклами шины. Нередко бывает и так, что частоты шины и контроллера не кратны, поэтому последовательные слова надо передавать через различное число циклов.
Дополнительная сложность состоит в том, что, не предоставив вовремя следующее слово для записи, мы испортим весь процесс — эта проблема особенно серьезна на устройствах однократной записи, например прожигателях компакт-дисков. Аналогично, не успев прочитать очередное слово, мы потеряем его и вынуждены будем отматывать ленту назад пли ждать следующего оборота диска.
Видно, что это именно та ситуация, которую мы ранее описывали как показание к использованию режима опроса: поток следующих друг за другом с небольшим интервалом событий, каждое из которых нельзя потерять, а нужно обязательно обработать.
Обработка события, которая нужна, чтобы избежать такой неприятности, крайне проста, так что устройство, способное с ней справиться, не обязано даже быть полностью программируемым процессором.
При передаче надо всего лишь убедиться, что блок данных не кончился, взять следующее слово из памяти, дождаться готовности устройства, скопировать слово и вернуться к началу алгоритма. Если блок данных кончился или контроллер выдал ошибку, необходимо сообщить об этом центральному процессору.
Для реализации этого алгоритма достаточно трех регистров (указателя в памяти, значения текущего слова и счетчика переданных слов). Реализующее этот алгоритм устройство называют контроллером прямого доступа к памяти (Direct Memory Access controller, DMA controller) (рис. 6.1). Такие контроллеры часто рассчитаны на одновременную работу с несколькими устройствами — имеют несколько каналов — и, соответственно, больше регистров. Описание реальной микросхемы контроллера ПДП можно найтив [Паппас/Марри 1993].
Обычно контроллеры ПДП не считают процессорами, однако без большой натяжки можно сказать, что это все-таки канальный процессор, хотя и очень примитивный. Контроллеры ПДП, рассчитанные на совместную работу с процессором, обладающим виртуальной памятью, часто имеют некий аналог диспетчера памяти ЦП, для того, чтобы позволить операционной системе предоставлять указатель для ПДП в виртуальном адресном пространстве, или, во всяком случае, упростить работу по преобразованию виртуального адреса в физический.
Различают два типа реализаций ПДП:

  • мастер шины (bus master) (в старой русскоязычной литературе встречалось также словосочетание задатчик шины), когда устройство имеет свой собственный контроллер ПДП,
  • централизованный контроллер, устанавливаемый на системной плате и способный работать с несколькими различными устройствами.

Рис. 6.1. Структура контроллера ПДП

В качестве альтернативы ПДП можно предложить снабжение устройства буфером, который работает с частотой системной шины. Центральный процессор передает данные в буфер, и лишь когда заканчивает передачу, инициирует операцию устройства. Логика работы самого устройства с этим буфером, впрочем, ничем не отличается от ПДП, с той лишь разницей, что используется не общесистемная, а встроенная память. На практике, оба подхода часто используются совместно: ПДП позволяет минимизировать загрузку центрального процессора, а буфер — избежать потери данных, если системная шина занята другим устройством.
Типичный современный дисковый контроллер имеет и средства ПДП, и внутренний буфер. У кэширующих (имеющих кэш-память) и RAID-контроллеров объем буфера может измеряться многими мегабайтами. Кроме того, современные жесткие диски также имеют собственные буферы.
Периферийные процессоры находят широкое применение в современных вычислительных системах. Так, типичный современный персональный компьютер, кроме центрального процессора, обычно имеет и специализированный видеопроцессор, так называемый графический ускоритель. У кэширующих дисковых контроллеров и аппаратных реализаций RAID (см. разд. Дисковые массивы) обычно также есть собственный процессор, в данном случае, как правило, используются полностью программируемые процессоры. Лазерные и струйные печатающие устройства имеют процессор, который интерпретирует команды языка управления принтером (PCL или Postscript), есть процессоры модемах и во многих других периферийных устройствах. Впрочем, нередко встречаются и попытки обратить процесс децентрализации вычислений -так называемые "софтовые" или Win-модемы (называемые так потому, что программное обеспечение, способное работать с таким модемом, часто поставляется только под Windows), многие бытовые принтеры и т. д.
В отличие от перечисленных устройств, классический полностью программируемый канальный процессор подключен непосредственно к системной шине и может оперировать несколькими устройствами, в зависимости от загруженной в него канальной программы. Канатьные процессоры долгое время считались отличительной особенностью больших ЭВМ. В мини-и микрокомпьютерах использование специализированных канальных процессоров, более сложных, чем контроллер ПДП, считалось неприемлемым по стоимостным показателям. Удивительно, что даже современное радикальное удешевление оборудования не изменило положения: предложение консорциума I2O (Intelligent Input/Output) снабжать компьютеры на основе процессоров х86 канальным процессором Intel 960, с энтузиазмом поддержанное практически всеми поставщиками операционных систем, почему-то не было столь же горячо поддержано потребителями.
Потребители мини- и микросистем, нуждающиеся в высокой производительности, предпочитают использовать в качестве дополнительных процессоров устройства с той же архитектурой, что и центральный процессор. Это называется симметричной многопроцессорностью (SMP), и позволяет перераспределять между процессорами не только обработку событий, но и собственно вычислительную деятельность. Понятно, что обрабатывать все события по принципу опроса в такой архитектуре — бессмысленная, а зачастую и нетерпимая расточительность.
К счастью, еще с 60-х годов, практически все процессоры как центральные, так и канальные, используют стратегию работы с событиями, во многих отношениях гораздо более совершенную, чем опрос.


Прерывания

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

Прерывания в PDP-11
Для примера рассмотрим организацию прерываний в машинах семейства PDP-11. Процессоры данной архитектуры сейчас практически не используются в машинах общего назначения, но производятся и применяются в качестве микроконтроллеров. Ряд архитектурных решений PDP-11, разработанной в начале 70-х годов, не потерял актуальности и поныне. В частности, подход к реализации прерываний считается классическим [Кейслер 1986].
Процессоры семейства PDP-11 различают 128 типов прерываний и исключений (чем прерывание отличается от исключения, см. далее). Каждому типу соответствует процедура-обработчик. Адреса точек входа всех процедур собраны в таблицу векторов прерываний. Эта таблица занимает 256 слов физической памяти, начиная с нулевого адреса. Каждый элемент таблицы (вектор) содержит адрес обработчика и новое слово состояния процессора. Позже будет объяснено, для чего это сделано.
Процессор узнает о возникновении прерывания, если на один из входов запроса подан сигнал. Обычно этот сигнал генерируется одним из внешних устройств. Например, прерывание может сигнализировать о завершении операции перемещения головки дисковода или передачи данных в режиме ПДП.
Каждый вход соответствует определенному уровню приоритета. PDP-11 имеет восемь уровней приоритета прерывания. Прерывание происходит только когда уровень приоритета процессора ниже приоритета запрашиваемого прерывания. Если у процессора установлен приоритет, равный 7, внешние прерывания запрещены. Приоритет процессора задается его словом состояния. Получив запрос, процессор завершает исполнение текущей команды и выставляет
сигнал готовности к прерыванию. После этого внешнее устройство выставляет на шине данных номер вектора прерывания.
Процессор считывает номер и вызывает соответствующую процедуру из таблицы. При этом вызов обработчика прерывания отличается от вызова обычной процедуры: при обычном вызове в стеке сохраняется только адрес команды, на которую следует возвратить управление. При прерывании же в стеке сохраняются два значения: адреса команды и слова состояния процессора. Новое слово состояния берется из таблицы векторов.
При этом приоритет процессора автоматически устанавливается равным тому значению, которое разработчик программы обработки считает правильным. Обратите внимание: не равным приоритету обрабатываемого прерывания, а тому, которое требует разработчик.
При завершении процедуры обработки вызывается команда RTI (ReTurn from Interrupt— возврат из прерывания). Эта команда выталкивает из стека адрес прерванной команды и старое слово состояния, тем самым и продолжая исполнение прерванной программы, и восстанавливая приоритет процессора. [Кичев/Некрасов 1988].
Для сравнения: в процессорах семейства 180x86 вектор прерывания содержит только адрес программы-обработчика, а приоритет процессора задается не словом состояния процессора, а регистром внешнего устройства— контроллера прерываний. Контроллер прерываний обычно устанавливает приоритет, равным приоритету прерывания, обрабатываемого в данный момент. Чтобы повысить или понизить этот уровень, обработчик прерывания должен программировать контроллер. Перед завершением обработчика необходимо вернуть контроллер прерываний в исходное состояние, выполнив над ним серию магических команд — эпилог прерывания.
Обработка прерываний в системах с виртуальной памятью несколько усложняется: ведь кроме адреса обработчика нам надо еще задать адресное пространство, в котором этот адрес определен. В моделях PDP-11, имеющих диспетчер памяти, эта проблема решается просто: для процессора в каждый момент времени заданы два адресных пространства: пользовательское и системное. Все прерывания обрабатываются в системном адресном пространстве. Для реализации этого процессор имеет два набора регистров диспетчера памяти. Их наличие, с одной стороны, снимает с обработчика прерывания обязанность переключать адресные пространства, а с другой позволяет ядру при обработке системных вызовов обращаться к сегменту данных пользовательского процесса.
В защищенном режиме процессоров 180x86 использован более гибкий механизм установки адресного пространства для обработчика. По существу, с каждым обработчиком может быть ассоциировано свое виртуальное адресное пространство. О способе, которым это достигается, лучше прочитать в литературе по соответствующим процессорам, например [Паппас/Марри 1993].

Прерывания лишены недостатков, которые мы указали и выше для обработки событий при помощи опроса: ожидая события, процессор может заниматься какой-либо другой полезной работой, а когда событие произойдет, он приступит к обработке, не дожидаясь полного завершения этой работы.
Однако этот механизм имеет и собственные недостатки. В частности, обработка прерывания сопряжена с гораздо большими накладными расходами чем проверка флага и условный переход в режиме ожидания. У оптимизированных для обработки событий микроконтроллеров разница невелика или даже может быть в пользу механизма прерываний: приведенный в примере 6.1 цикл опроса занимает 5 циклов процессора, а обработчик прерывания у PIC вызывается в течение 3—4 циклов ([www.microchip.com PICMicro] утверждает, что средняя задержка прерывания составляет 3,75 цикла). Таким образом, среднее время реакции на событие в режиме опроса составляет 2,5 цикла (по среднему времени опрос в выигрыше), а максимальное -5 циклов (в данном случае преимущество на стороне прерываний).
Однако у процессоров общего назначения, которые при обработке прерывания вынуждены сохранять несколько регистров и осуществлять относительно сложный диалог с вызвавшим прерывание устройством, задержка между установкой сигнала прерывания и исполнением первой команды его обработчика — этот интервал и называется задержкой прерывания (interrupt latency) — составляет десятки тактов.
Современные суперскалярные процессоры при обработке прерываний вынуждены сбрасывать очередь предварительной выборки команд и, по крайней мере, часть кэшей команд и данных, поэтому у .них накладные расходы еще больше. Задержка прерывания у современных реализаций архитектуры х86 лишь ненамного лучше, чем у 80386 хотя по скорости исполнения последовательных программ современные процессоры превосходят 80386 на несколько порядков. Поэтому младшие модели процессоров с архитектурой х86, 8086 и даже 8085, хотя и не находят применения в персональных компьютерах, но продолжают выпускаться для использования во встраиваемых приложениях или в качестве периферийных процессоров.
Так, например, "марсоход" Sojoumer использовал в качестве управляющего процессора 8085 на сапфировой подложке (для обеспечения радиационной устойчивости).
Это же обстоятельство является дополнительным доводом в пользу включения в систему канальных процессоров, в данном случае с целью освобождения центрального процессора не от опроса, а от обработки прерываний. Разработчики больших компьютеров часто реализовывали канальные процессоры старших моделей на основе центральных процессоров младших моделей той же серии.


Исключения

Многие процессоры используют механизм, родственный прерываниям, для обработки не только внешних, но и внутренних событий: мы с вами уже сталкивались с исключительными ситуациями (exception) отсутствия страницы и ошибки доступа в процессорах с виртуальной памятью, а также некоторыми другими — ошибкой шины при доступе к невыровненным словам, заполнению и очистке регистрового окна у SPARC и т. д. Большинство современных процессоров предоставляют исключения при неизвестном коде операции, делении на ноль, арифметическом переполнении или, например, выходе значения операнда за допустимый диапазон в таких операциях, как вычисление логарифма, квадратного корня или арксинуса.
Исключительные ситуации обрабатываются аналогично внешним прерываниям: исполнение программы останавливается, и управление передается на процедуру-обработчик, адрес которой определяется природой исключения.
Отличие состоит в том, что прерывания обрабатываются после завершения текущей команды, а возврат из обработчика приводит к исполнению команды, следующей за прерванной. Исключение же приводит к прекращению исполнения текущей команды (если в процессе исполнения команды мы уже успели создать какие-то побочные эффекты, они отменяются), и сохраненный счетчик команд указывает на прерванную инструкцию. Возврат из обработчика, таким образом, приводит к попытке повторного исполнения операции, вызвавшей исключение.
Благодаря этому, например, обработчик страничного отказа может подкачать с диска содержимое страницы, вызвавшей отказ, перенастроить таблицу дескрипторов и повторно исполнить операцию, которая породила отказ. Обработчик исключения по неопределенному коду операции может использоваться для эмуляции расширений системы команд.
Например, при наличии арифметического сопроцессора операции с плавающей точкой исполняются им, а при отсутствии — пакетом эмулирующих подпрограмм. Благодаря этому может обеспечиваться полная бинарная совместимость между старшими (имеющими сопроцессор) и младшими (не имеющими его) моделями одного семейства компьютеров.
Исключения, возникающие при исполнении привилегированных команд в пользовательском режиме, могут использоваться системой виртуальных машин. Работающее в виртуальной машине ядро ОС считает, что исполняется в системном режиме. На самом же деле оно работает в пользовательском режиме, а привилегированные команды (переключения режима процессора, настройка диспетчера памяти, команды ввода/вывода) приводят к вызову СВМ.
При грамотной реализации обработчиков таких исключений их обработка Произойдет полностью прозрачно для породившей эти исключения программы. Конечно, "подкачка" страницы с диска или программная эмуляция плавающего умножения займет гораздо больше времени, чем простое обращение к памяти или аппаратно реализованное умножение, но, наверное, Потребитель вычислительной системы знал, что делал, когда устанавливал недостаточное количество памяти или приобретал машину без сопроцессора.
Многие другие исключения, такие, как деление на ноль, обычно бессмЬ1с ленно обрабатывать повторной попыткой деления на какое-то другое число В этом случае целесообразно возвратить управление не на команду, вызвав шую исключение, а в какую-то другую точку. Вопрос, впрочем, в том, куда именно следует возвращаться. Понятно, что код, который может восстано виться в случае деления на ноль, сильно зависит от контекста, в котором произошла ошибка (пример 6.2).

Пример 6.2. Обработка исключения Floating underflow (антипереполние при операциях с плавающей точкой)

#tinclude <setjmp.h>
static jmp_buf fpe_retry;
void fpe_handler (int sig) {
4> __fpreset () ; longjmp (fpe__retry, -1) ;
int compare_pgms (Image * imgO, Image * img1) {
int xsize=256, ysize=256;
int i, j , pO, pi, pd;
double avg, avgsq, scale, smooth;
scale= (double) xsize* (double) ysize;
avg = 0.0; avgsq = 0.0;
/* Подавить возможные антипереполнения */
signal (SIGFPE, fpe_handler) ;
for(i=0; i<ysize; i smooth = (double) (imgO->picture [i*xsize] -imgl->picture [i*xsize] ) ; for(j=0; j<xsize; j++) { pO=imgO->picture [ j+i*xsize] ; pl=imgl->picture [ j+i*xsize] ; pd=(pO-pl) ;
if (setjmp (fpe_retry) == 0) { smooth = smooth* (1 . 0-SMOOTH_FACTOR) + (double) pd*SMOOTH_FACTOR;
vq += smooth; avgsq += smooth*smooth;
eise
smooth=0 . 0 ;
if (Setjmp(fpe_retry) == 0)
Aspersion = avgsq/scale-avg*avg/ (scale*scale) ;
else dispersion = 0.0;
signal (SIGFPE, SIGJDFL) ;
}

При программировании на ассемблере это может быть реализовано простой подменой адреса возврата в стеке. Многие языки высокого уровня (ЯВУ) реализуют те или иные средства для обработки исключений. Уровень этих средств различен в разных языках, начиная от пары функций setjmp и longjmp в С [Керниган-Ритчи 2000] (пример 6.3) и заканчивая операторами try/catch и throw C++ [Страуструп 1999] и Java [Вебер 1999].

Пример 6.3. Исходный текст функций set jmp/ longjmp.

/ setjmp. s (emx+gcc) — Copyright (c) 1990-1996 by Eberhard Mattes
# include <emx/asm386.h>
.globl _setjmp, _longjmp
.text ALIGN
# define J_EBX 0
# define J_ESI 4
# define J_EDI 8
#define J_ESP 12
#define J_EBP 16
# define J_EIP 20
# define J_XCP 24
/ Слова со смещениями 28.. 44 зарезервированы
/ int setjmp (jmp_buf here)
_setjmp:
PROFILE__NOFRAME
movl l*4(%esp), %edx /* here */
raovl %ebx, J_EBX(%edx)
movl %esi, J_ESI(%edx)
movl ledi, J_EDI(%edx)
movl %ebp, J_EBP(%edx)
movl %esp, J_ESP(%edx)
movl 0*4(%esp), %eax /* Адрес возврата */
movl %eax, J_EIP(%edx)
cmpb $0, __osmode /* OS/2? */
je If /* No -> skip */
fs
movl 0, leax /* handler Обработчик исключений */
movl %eax, J_XCP(%edx) 1: xorl %eax, leax
EPILOGUE(setjmp)
ALIGN
/ void longjmp (jmp_buf there, int n)
_longjmp:
PROFILE_NOFRAME
cmpb $0, __osmode /* OS/2? */
je 2f /* No -> skip */
movl 1*4(%esp), %eax /* there */
pushl J_XCP(%eax)
call ___unwind2 /* восстановить обработчики сигналов */
addl $4, %esp 2: movl l*4(%esp), ledx /* there */
movl 2*4(%esp), leax /* n */
testl %eax, leax
jne 3f
incl %eax
3: movl J_EBX(%edx), %ebx
movl J_ESI(ledx), lesi
raovl J_EDI(%edx), %edi
movl J EBP(%edx), %ebp
J_ESP(%edx) ,
J_EIP(%edx>, %edx
%edx, 0*4(%espj /* адрес возврата */
EPILOGUE(longjmp) /* well, ... */

Исключения в ЯВУ часто позволяют избежать использования нелюбимого структурными программистами оператора goto. В объектно-ориентированных (ОО) языках этот механизм играет еще более важную роль: в большининстве таких языков — это единственный способ сообщить о неудаче при исполнении конструктора объекта.
Важно подчеркнуть, впрочем, что исключения в смысле ЯВУ и аппаратные исключения процессора — разные вещи. В многозадачной ОС пользовательская программа не имеет непосредственного доступа к обработке прерываний и исключений. ОС предоставляет сервис, позволяющий программисту регистрировать обработчики для тех или иных событий, как соответствующих аппаратным исключениям, так и порождаемых самой операционной системой, но вызов таких обработчиков всегда осуществляется в два этапа: сначала исполняется обработчик, зарегистрированный ядром (пример 6.4), а он, если сочтет нужным и возможным, переключается в пользовательский контекст и вызывает обработчик, зарегистрированный пользователем. Среда исполнения ЯВУ, в свою очередь, может реализовать и свои обработчики между сервисом операционной системы и средствами, доступными программисту.

Пример 6.4. Обработчик арифметических исключений в ядре Linux I

/*
* Iinux/arch/i386/traps.c *
* Copyright (С) 1991, 1992 Linus Torvalds *
* Поддержка Pentium III FXSR, SSE
* Gareth Hughes <gareth@valinux.com>, May 2000 */
void die(const char * str, struct pt_regs * regs, long err) I
console_verbose(); spin_lock_irq(&die_lock); Printk("%s: %041x\n", str, err & Oxffff}; show_registers(regs);
sPin_unlock_irq(&die_lock);
do_exit (SIGSEGV) ;
static inline void die_if_kernel (const char * str, struct pt_regs * regs long err)
{
if ( ! (regs->eflags & VM_MASK) && ! (3 & regs->xcs) ) die (str, regs, err);
static inline unsigned long get_cr2 (void) { unsigned long address;
/* получить адрес */
_ asm _ ("movl %%cr2, %0" : "=r" (address));
return address;
static void inline do_trap(int trapnr, int signr, char *str, int vm86,
struct pt_regs * regs, long error_code, siginfo_t *info) { if (vm86 && regs->eflags & VM_MASK)
goto vm86_trap; if ( ! (regs->xcs & 3) ) goto kernel_trap;
trap_signal: {
struct task_struct *tsk = current; tsk->thread. error_code = error_code; tsk->thread. trap_no = trapnr; if (info)
force_sig_info (signr, info, tsk) ; else
force_sig (signr, tsk) ; return;
kernel_trap:
unsigned long fixup = search_exception_table(regs->eip); if (fixup)
regs->eip = fixup; else
die(str, regs, error_code); return;
vm86_trap: {
int ret = handle_vm86_trap((struct kernel_vm86_regs *) regs, er-ror_code, trapnr);
if (ret) goto trap_signal; return;
fldefine DO_ERROR(trapnr, signr, str, name) \
asmlinkage void do_tt#name(struct pt_regs * regs, long error_code) \ { \ do_trap(trapnr, signr, str, 0, regs, error_code, NULL); \
Idefine DO_ERROR_INFO(trapnr, signr, str, name, sicode, siaddr) \ asmlinkage void do_t#name(struct pt_regs * regs, long error_code) \ { \
siginfo_t info; \
info.si_signo = signr; \
info.si_errno =0; \
info.si_code = sicode; \
info.si_addr = (void *)siaddr; \
do^trap(trapnr, signr, str, 0, regs, error_code, Sinfo); \ }
ttdefine DO_VM86_ERROR(trapnr, signr, str, name) \
asmlinkage void do_##name(struct pt_regs * regs, long error_code) \ ( \
do_trap(trapnr, signr, str, 1, regs, error_code, NULL); \ }
ttdefine DO_VM86__ERROR_INFO(trapnr, signr, str, name, sicode, siaddr) \
I
asmlinkage void do_##name(struct pt_regs * regs, long error_code) \ { \
siginfo_t info; \
info.si_signo = signr; \
info.si_errno =0; \
info.si_code = sicode; \
info.si_addr = (void *)siaddr; \
do_trap(trapnr, signr, str, 1, regs, error_code, sinfo); \
' "
DO_VM86_ERROR_INFO( 0, SIGFPE, "divide error", divide_error, FPE_INTDIV, regs->eip)
DO_VM86_ERROR( 3, SIGTRAP, "int3", int3)
DO_VM86_ERROR( 4, SIGSEGV, "overflow", overflow)
DO_VM86_ERROR( 5, SIGSEGV, "bounds", bounds)
DO_ERROR_INFO( 6, SIGILL, "invalid operand", invalid_op, ILL_ILLOPN, regs->eip)
DO_VM86_ERROR( 7, SIGSEGV, "device not available", device_not_available) DO_ERROR( 8, SIGSEGV, "double fault", double_fault)
DO_ERROR( 9, SIGFPE, "coprocessor segment overrun", coproces-sor_segment_overrun)
DO_ERROR(10, SIGSEGV, "invalid TSS", invalidJTSSl
DO_ERROR(11, SIGBUS, "segment not present", segment_not_present)
DO_ERROR(12, SIGBUS, "stack segment", stack_segment)
DO_ERROR_INFO(17, SIGBUS, "alignment check", alignment_check, BUS_ADRALN,
get_cr2 () )


Многопроцессорные архитектуры

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

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

Другим доводом в пользу включения в систему дополнительных процессоров является тот факт, что алгоритмы, используемые для решения многих прикладных задач, нередко поддаются распараллеливанию: разделению работы между несколькими более или менее независимо работающими процессорами. В зависимости от алгоритма (и, косвенно, от природы решаемой задачи) уровень достижимого параллелизма может сильно различаться. Отношение производительности системы к количеству процессоров и производительности однопроцессорной машины называют коэффициентом масштабирования. Для различных задач, алгоритмов, ОС и аппаратных архитектур этот коэффициент различен, но всегда меньше единицы и всегда убывает по мере увеличения количества процессоров.
Некоторые задачи, например, построение фотореалистичных изображений методом трассировки лучей, взлом шифров полным перебором пространства ключей [www.distributed.net] или поиск внеземных цивилизаций [www.seti.org] поддаются масштабированию очень хорошо: можно включить в работу десятки и сотни тысяч процессоров, передавая при этом между ними относительно малые объемы данных. В этих случаях часто оказывается Целесообразно даже не устанавливать процессоры в одну машину, а использовать множество отдельных компьютеров, соединенных относительно низкоскоростными каналами передачи данных. Это позволяет задействовать процессоры, подключенные к сети (например, к Интернет) и не занятые в данный момент другой полезной работой.
Другие задачи, например, работа с базами данных, поддаются распараллеливанию в гораздо меньшей степени, однако и в этом случае обработка запросов может быть распределена между несколькими параллельно работающими процессорами. Количество процессоров в серверах СУБД обычно измеряется несколькими штуками, они подключены к обшей шине, совместно используют одну и ту же оперативную память и внешние устройства.
Многопроцессорность в таких системах обычно применяется только для ц0. вышения производительности, но очевидно, что ее же можно использовать и для повышения надежности: когда функционируют все процессоры, система работает быстро, а с частью процессоров работает хоть что-то, пусть и медленнее.
Некоторые многопроцессорные системы поддерживают исполнение на ных процессорах различных ОС — так, на IBM z90 часть процессоров M исполнять Linux, а остальные — z/OS. В такой конфигурации, работающий под управлением Linux Web-сервер может взаимодействовать с работающим под z/OS сервером транзакций через общую физическую память. Многопроцессорные серверы Sun Fire могут исполнять несколько копий Solaris.
Промежуточное положение между этими крайностями занимают специализированные массивно-параллельные компьютеры, используемые для таких задач, как численное решение эллиптических дифференциальных уравнений и численное же моделирование методом конечных элементов в геофизических, метеорологических и некоторых других приложениях.
Современные суперкомпьютеры этого типа (IBM „SP6000, Cray Origin) состоят из десятков, сотен, а иногда и тысяч отдельных процессорных модулей (каждый модуль представляет собой относительно самостоятельную вычислительную систему, обычно многопроцессорную, с собственной памятью и, нередко, с собственной дисковой подсистемой), соединенных между собой высокоскоростными каналами. Именно к этому типу относился шахматный суперкомпьютер Deep Blue, выигравший в 1997 году матч у чемпиона мира по шахматам Гарри Каспарова [www.research.ibm.com].
Многопроцессорные системы различного рода получают все более и более широкое распространение. Если производительность отдельного процессора удваивается в среднем каждые полтора года ("закон Мура" [www.intel.com Moore]), то производительность многопроцессорных систем удваивается каждые десять месяцев [www.sun.com 2001-05].
На практике, даже хорошо распараллеливаемые алгоритмы практически никогда не обеспечивают линейного роста производительности с ростом числа процессоров. Это обусловлено, прежде всего, расходами вычислительных ресурсов на обмен информацией между параллельно исполняемыми потоками. На первый взгляд, проще всего осуществляется такой обмен в системах с процессорами, имеющими общую память, т. е. собственно многопроцессорных компьютерах.
В действительности, оперативная память имеет конечную, и небольшую по сравнению с циклом центрального процессора, скорость доступа. Даже один современный процессор легко может занять все циклы доступа ОЗУ, а несколько процессоров будут непроизводительно тратить время, ожидая доступа к памяти. Многопортовое ОЗУ могло бы решить эту проблему, но такая память намного дороже обычной, однопортовой, и применяется лишь в особых случаях и в небольших объемах.
Одно из основных решений, позволяющих согласовать скорости ЦПУ и ОЗУ, — это снабжение процессоров высокоскоростными кэшами команд и данных. Такие кэши нередко делают не только для центральных процессоров, но и для адаптеров шин внешних устройств. Это значительно уменьшает количество обращений к ОЗУ, однако мешает решению задачи, ради которой мы и объединяли процессоры в единую систему: обмена данными между потоками, исполняющимися на разных процессорах (рис. 6.2).

Рис. 6.2. Некогерентный кэш

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

Порядок доступа к памяти в SPARC
Современные процессоры предоставляют возможность управлять порядком доступа команд к памяти. Например, у микропроцессоров SPARCvQ [www.sparc.com v9] определены три режима работы с памятью (модели памяти), переключаемые битами в статусном регистре процессора.
Свободный доступ к памяти (RMO, Relaxed Memory Order), когда процессор использует все средства кэширования и динамического переупорядочения команд, и не пытается обеспечить никаких требований к упорядоченности выбор-ки и сохранению операндов в основной памяти.
Частично упорядоченный доступ (PSO, Partial Store Order), когда процессор по-прежнему использует и кэширование, и переупорядочивание, но в потоке команд могут встречаться команды MEMBAR. Встретив такую команду, сор обязан гарантировать, что все операции чтения и записи из памяти, зако дированные до этой команды, будут исполнены (в данном случае под исполнением подразумевается перенос результатов всех операций из кэша в ОЗУ), д0 того, как процессор попытается произвести любую из операций доступа к памяти, следующих за MEMBAR.
Полностью упорядоченный доступ (TSO, Total Store Order), когда процессор гарантирует, что операции доступа к памяти будут обращаться к основному ОЗУ в точности в том порядке, в котором закодированы.
Каждый следующий режим повышает уверенность программиста в том, что его программа прочитает из памяти именно то, что туда записал другой процессор, но одновременно приводит и к падению производительности. Наибольший проигрыш обеспечивает наивная реализация режима TSO, когда мы просто выключаем и динамическое переупорядочение команд, и кэширование данных (кэширование кода можно оставить, если только мы не пытаемся исполнить код, который подвергается параллельной модификации другим задатчиком шины).

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

Рис. 6.3. Шинная архитектура

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

Системы NUMA-Q
Многопроцессорные серверы IBM NUMA-Q состоят из отдельных процессорных модулей. Каждый модуль имеет собственную оперативную память и четыре процессора х86. Модули называются quad (четверки) (рис. 6.4).
Четверки соединены высокоскоростными каналами IQ-Link с центральным коммутатором. Замена общей шины на звездообразную топологию с центральным коммутатором позволяет решить проблемы арбитража доступа к шине, в частности, устранить задержки при запросе к арбитру шины и ожидании его ответа запрашивающему устройству. NUMA-системы фирмы IBM могут содержать до 16 четверок, т. е. до 64 процессоров.

Архитектура позволяет также включать в эти системы процессоры с архитектурой, отличной от х86, например RS/6000 и System/390, позволяя, таким образом, создать в пределах одной машины гетерогенную сеть со сверхвысокоскоростными каналами связи.
При большем числе модулей применяются еще более сложные топологии, например гиперкубическая. В таких системах каждый узел обычно также содержит несколько процессоров и собственную оперативную память (рис. 6.5).
При гиперкубическом соединении, количество узлов N пропорционально степени двойки, а каждый узел имеет log2N соединений с другими узлами. Каждый узел способен не только обмениваться сообщениями с непосредственными соседями по топологии, но и маршрутизировать сообщения между узлами, не имеющими прямого соединения. Самый длинный путь между узлами, находящимися в противоположных вершинах куба, имеет длину log2N и не является единственным (рис. 6.6). Благодаря множественности путей, маршрутизаторы могут выбирать для каждого сообщения наименее загруженный в данный момент путь или обходить отказавшие узлы.

Рис. 6.4. NUMA-Q с тремя четырехпроцессорными модулями

Массивно параллельные системы Cray/SGI Origin
Узлы суперкомпьютеров семейства Cray/SGI Origin соединены в гиперкуб каналами с пропускной способностью 1 Гбайт/с. Адаптеры соединений обеспечивают не просто обмен данными, а прозрачный (хотя и с падением производительности) доступ процессоров каждого из узлов к оперативной памяти других узлов и обеспечение когерентности процессорных кэшей.

Рис. 6.5. Гиперкубы с 4, 8 и 16-ю вершинами

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

  • СОМА (Cache Only Memory Architecture) — архитектура памяти, при которой работа с ней происходит как с кэшем. Система переносит страницы памяти, с которой данный процессорный модуль работает чаще других, в его локальную память.

Рис. 6.6. Самый длинный путь в гиперкубе

  • CC-NUMA (Cache-Coherent Non-Uniform Memory Access, неоднородный доступ к памяти с обеспечением когерентности кэшей). В этой архитектуре адаптеры межмодульных соединений снабжаются собственной кэшпамятью, которая используется при обращениях к ОЗУ других модулей. Основная деятельность центрального коммутатора и каналов связи состоит в поддержании когерентности этих кэшей [www.ibm.com NUMA-Q].

Понятно, что обе эти архитектуры не решают в корне проблемы неоднородности доступа: для обеих можно построить такую последовательность межпроцессорных взаимодействий, которая промоет1 все кэши и перегрузит межмодульные связи, а в случае СОМА приведет к постоянной перекачке страниц памяти между модулями. То же самое, впрочем, справедливо и для симметричных многопроцессорных систем с общей шиной.
В качестве резюме можно лишь подчеркнуть, что масштабируемость (отношение производительности системы к количеству процессоров) многопроцессорных систем определяется в первую очередь природой задачи и уровнем параллелизма, заложенным в использованный для решения этой задачи алгоритм. Разные типы многопроцессорных систем и разные топологии межпроцессорных соединений пригодны и оптимальны для различных задач.
Промывание кэша— довольно распространенный термин. Это последовательность обращении, которая намного больше объема кэша и в которой нет ни одного повторного обращения к одной и той же странице, или очень мало таких обращении.


Глава 7. Параллелизм с точки зрения программиста

  • Параллелизм с точки зрения программиста
  • Формулировка задачи
    • Примитивы взаимоисключения
  • Мертвые и живые блокировки
  • Примитивы синхронизации
    • Семафоры
    • Семафоры и прерывания
    • Захват участков файлов
    • Мониторы и серверы транзакций
  • Гармонически взаимодействующие последовательные потоки
    • Примеры реализаций средств гармонического взаимодействия
  • Системы, управляемые событиями


Параллелизм с точки зрения программиста

Ты выбежалза угол купить вина
Ты вернулся, а вместо дома стена.
Б. Гребенщиков

На палубу вышел, и палубы нет
В глазах у него помутилось.
В. Пелевин

В предыдущей главе мы видели, что даже в современном однопроцессорном персональном компьютере происходит множество параллельных процессов: звуковая карта играет, жесткий диск и сетевой интерфейс передают данные, пользователь двигает мышью — работа кипит! А что начнется, если пользователь запустит задание на печать, так и просто страшно подумать. Написание программ, способных работать в среде с множеством параллельно происходящих процессов, представляет собой нетривиальную задачу. На первый взгляд, сложности здесь никакой нет — аппаратура предоставляет нам механизм прерываний. Обработал прерывание — и наступило счастье. В действительности, никакого счастья от одной только обработки прерывания не наступит, пока мы не сообщим о происшедшем событии основному потоку программы, заинтересованной в этом событии.
Основной поток программы и реализуемые этой программой обработчики прерываний должны взаимодействовать и разделять те или иные данные. При этом в обработчике прерывания мы не всегда можем точно выяснить, в какой точке основной поток программы был прерван (в принципе, можно проанализировать сохраненный счетчик команд и, возможно, локальные переменные основного потока, но это очень сложно и само по себе вряд ли приблизит нас к реализации корректно взаимодействующих потоков), а основной поток не всегда может знать, в какой момент происходило (и происходило ли) прерывание.
Большинство практически применяемых структур данных должны соответствовать тем или иным предположениям, критериям целостности. Например, в упорядоченном массиве каждый следующий элемент должен быть больще (то, что в данном конкретном случае подразумевается под "больше", называется критерием или условием сортировки) предыдущего или равен ему основной способ модификации упорядоченного массива — это вставка в него дополнительного элемента. Вставка в такой массив может быть осуществлена различными способами, например, добавлением нового элемента в конец и выполнением сортировки методом "пузырька", или поиском места, куда элемент должен быть вставлен, и перемещением элементов с большими индексами.
Важно, что любой способ вставки происходит не мгновенно, и все время работы этой процедуры массив не является упорядоченным. Если вставка происходила в основном потоке программы, обработчик прерывания, который в это время попытается работать с массивом, как с упорядоченным — например, произвести в нем дихотомический поиск — будет жестоко разочарован.
Задача разработки программы, взаимодействующей с обработчиком прерывания, таким образом, может быть переформулирована как написание программы, некоторые переменные которой подвержены изменению в непредсказуемые моменты времени.
Это обстоятельство резко усложняет анализ алгоритмов (в частности, доказательство корректности программ) и доставило в свое время много волнений теоретикам программирования. Например, в [Дейкстра 1978] один из основателей структурного программирования, Э. Дейкстра, очень эмоционально описывает свою реакцию при первом столкновении с системой, использующей прерывания. Кроме теоретических сложностей, разработка таких программ сопряжена и со сложностями практическими.
При разработке параллельной программы мы можем неявно сделать и использовать при кодировании предположение, что состояние некоторого объекта в некоторый период времени не меняется — а оно может измениться. Если такая ошибка сделана в последовательно исполняющейся программе, она может быть выявлена при первом же тестовом прогоне. Для выявления же ее в программе с асинхронно исполняющимися модулями потребуется гораздо больше тестовых запусков, при которых мы должны вызывать прерывание в различные моменты времени.
Для исчерпывающего тестирования необходимо перебрать все возможные относительные моменты вызова прерывания, т. е. обеспечить хотя бы раз вызов прерывания после каждой из команд в каждой из возможных последовательностей исполнения основной программы. Стоимость такого тестирования запретительно высока, поэтому ошибки такого рода (в англоязычной литературе они называются race condition (дословно — ошибка соревнования), хорошего же русского термина автору неизвестно) практически невозможно искоренить в процессе тестирования.
Таким образом, единственный способ избежать ошибок соревнования — это не делать их. Для того чтобы не делать ошибок, нужна формальная методика разработки и кодирования параллельно исполняющихся программ. Понятно, что и наличие методики не может полностью исключить ошибки. Однако, если выработанная методика адекватна, каждая ошибка будет ее нарушением, поэтому ошибки могут выявляться анализом кода на соответствие формальным требованиям.
К счастью, автору этой книги нет необходимости заниматься разработкой такой методики с чистого листа. Достаточно лишь, по возможности связно, изложить уже изобретенные методы. Не буду утруждать себя и читателя полным доказательством корректности предлагаемых методик, приводя лишь "интуитивное" обоснование их применимости. Сомневающимся могу предложить либо разработать полное доказательство самостоятельно, либо обратиться к специальной литературе, например [Хоар 1989].
Приведенная ранее формулировка задачи справедлива не только для взаимодействия основного потока программы с обработчиком прерывания, но и для взаимодействия программ, исполняющихся на различных процессорах, а также для программы, непосредственно взаимодействующей с внешними событиями, например посредством опроса. В разд. Вытесняющая многозадачность мы увидим, что [псевдо]параллельные нити исполнения, не являющиеся обработчиками прерываний, довольно легко можно реализовать на однопроцессорной машине, и практически все современные ОС предоставляют такой сервис.
Большинство концепций, обсуждаемых в этой главе, приложимы ко всем перечисленным случаям, поэтому далее в тексте мы будем говорить не о программе и обработчике прерывания, а о двух или более потоках или нитях исполнения. В действительности, одна из взаимодействующих "нитей" может не быть процессом исполнения программы, а представлять собой физический процесс (например, перемотку ленты, перемещение считывающей головки дисковода, или химическую или даже ядерную реакцию в установке, которой управляет наш компьютер) или процесс, происходящий в голове или других модулях нервной системы пользователя-человека, но в большинстве случаев нас эта тонкость не интересует.


Формулировка задачи

Я что-то не вижу пивного ларька.
Должно быть, его
Успели снести за ночь.
Б. Гребенщиков

Чтобы понять, какие же методики применимы для взаимодействия параллельно исполняющихся нитей, давайте более четко сформулируем задачу. Для этого нам также надо ввести некоторое количество терминов. Кроме того, нам придется высказать ряд соображений, часть которых может показаться читателю банальностями.
Установим, что для каждой из нитей создается иллюзия строго последовательного исполнения (например, обработчик прерывания может создать для основного потока программы такую иллюзию, аккуратно сохраняя при вызове и восстанавливая при завершении все регистры процессора). Если для какой-то из нитей это условие не всегда соблюдается, мы будем считать ее двумя или большим числом различных нитей. Если это условие не соблюдается никогда, мы имеем дело с процессором нефон-неймановской архитектуры.
Общепринятое название для взаимодействия между различными нитями — асинхронное взаимодействие, в противоположность синхронному взаимодействию между различными модулями последовательно исполняемой программы.
Если нить работает только с объектами (под объектом мы, в данном случае, понимаем не только группу переменных в оперативной памяти или объект в смысле ООП, но и физические объекты, например управляемые компьютером внешние устройства), состояние которых не может быть изменено другими нитями, проблемы взаимодействия, да и самого взаимодействия, как такового, не существует.
Если нить работает с объектами, состояние которых вообще не подвергается модификации, например, с кодом или таблицами констант, проблемы также нет. Проблема возникает тогда и только тогда, когда модификации подвергается объект, разделяемый несколькими нитями. При этом для возникновения проблемы достаточно, чтобы только одна нить занималась модификацией, а остальные нити считывали состояние объекта.
Интервал, в течение которого модификация нарушает целостность разделяемой структуры данных, и, наоборот, интервал, в течение которого алгоритм нити полагается на целостность этой структуры, называется критической секцией. Задача написания корректной многопоточной программы, таким образом, может решаться двумя способами: либо искоренением критических секций из всех используемых алгоритмов, либо обеспечением гарантии того, что никакие две нити никогда одновременно не войдут в критическую секцию, связанную с одним и тем же разделяемым объектом. Наличие в программе критической секции с негарантированным исключением и есть ошибка соревнования, которая рано или поздно сработает.
Полное искоренение критических секций из кода требует глубокой переработки всех алгоритмов, которые используются для работы с разделяемыми данными. Результат такой переработки мы видели в примере 5.2: странная, на первый взгляд, двойная загрузка регистра в настроенной записи PLT в этом примере обусловлена именно желанием избежать проблем при параллельной настройке одной и той же записи двумя разными нитями (в качестве упражнения читателю предлагается разобраться, в каком порядке интерпретатор модифицирует запись, и как выглядит промежуточный код). У автора нет примеров, демонстрирующих невозможность такой переработки в обшем случае, но очевидно, что даже к крайне простым алгоритмам она совершенно не применима на практике.
Второй путь — предоставление гарантий взаимоисключения (mutual exclusion) — также непрост, но, в отличие от первого, практически реализуем и широко применяется.

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

Группа операций модификации разделяемой структуры данных, которая происходит атомарно (неделимо), не прерываясь никакими другими операциями с той же структурой данных, называется транзакцией. В разд. Мониторы и серверы транзакций мы увидим более радикальное определение термина "транзакция" как группы операций, которые всегда либо происходят все вместе, либо не происходят вообще, даже если во время попытки их выполнения случится общесистемный сбой. Понятно, что для реализации так понимаемых транзакций одного только взаимоисключения недостаточно.
Программный модуль, внутри которого имеется хотя бы одна критическая секция, для которой не обеспечено взаимное исключение, называется нереентерабельным. Вызов процедур такого модуля из различных нитей приведет к ошибкам соревнования и допустим лишь при условии, что вызывающая программа реализует взаимное исключение самостоятельно. Соответственно, модуль, в котором таких секций нет, или который сам обеспечивает взаимное исключение для них, называется реентерабельным (от англ, re-enterable — способный к повторному вхождению) или реентрантным (reentrant). В современной англоязычной литературе часто также употребляются термины thread-unsafe (для обозначения нереентерабельных процедур) и thread-safe (соответственно, для реентерабельных).
Рассмотрим простейший случай разделяемого объекта: флаговую переменную, которая может принимать значения True и False. Такая переменная, в частности, может использоваться для реализации взаимного исключения в секции, работающей с более сложной структурой данных.
Если в критической секции не находится ни одной нити, переменная равна False, иначе — True. При входе в секцию нам необходимо проверить значение переменной и, если блокируемый участок свободен, присвоить ей True. Данный пример любопытен не только своей простотой, но и тем, что совмещает в себе оба типа критических секций: изменение разделяемых данных и анализ данных, которые могут параллельно модифицироваться кем-то еще.
Наивный способ работы с такой переменной приведен в примере 7.1 (пример реализован на паскалеподобном псевдокоде. Операторы parbegin/parend символизируют параллельное исполнение заключенных между ними операторов). Казалось бы, трудно представить себе более простую программу, однако именно благодаря простоте легко понять, что она никуда не годится: проверка флага и его установка реализуются двумя различными операторами, в промежутке между которыми другой процесс может получить управление и также установить флаговую переменную! Окно, в котором происходит соревнование, составляет всего две-три команды, но при попадании обоих процессов в это окно мы получаем как раз то, чего стремились избежать: оба процесса могут войти в критическую секцию одновременно.

Пример 7.1. Наивная реализация взаимного исключения на основе флаговой переменной

program флаг;
var flag: Boolean;
procedure процессодин;
begin
while True do begin
while flag do;
flag := True;
критическаясекция!;
flag := False;
...
end
end;
procedure процессдва; begin
while True do begin
while flag do;
flag := True;
критическаясекция2;
flag := False;
end
end;
oegin
flag:= False;
parbegin
процессодин;
процессдва;
parend
еnd.


Примитивы взаимоисключения

В классической работе Г. М. Дейтела [Дейтел 1987] предлагается несколько последовательных усовершенствований механизма взаимоисключений, основанного на флаговых переменных, и как завершающий этап этого анализа приводится алгоритм взаимоисключений Деккера (пример 7.2).

Пример 7.2. Алгоритм Деккера (цит. по [Дейтел 1987])

program АлгоритмДеккера;
var
избранныйпроцесс: (первый, второй); п!хочетвойти, п2хочетвойти: Boolean; procedure процессодин;
begin
while true do begin
п1хочетвойти := True;
while п2хочетвойти do
if избранныйпроцесс=второй then
begin
п1хочетвойти := False;
while избранныйпроцесс=второй do;
п!хочетвойти := True;
end
критическийучасток1;
избранныйпроцесс := второй;
п!хочетвойти := False;
...
end
end
procedure процессдва;
begin
while true do
begin
п2хочетвойти := True;
while п1хочетвойти do
if избранныйпроцесс=первый then
begin
п2хочетвойти := False;
while избранныйпроцесс=первый do;
п2хочетвойти := True;
end
критическийучасток2 ;
избранныйпроцесс := первый;
п2хочетвойти := False;
...
end
end D
begin
п1хочетвойти := False;
п2хочетвойти := False;
избранныйпроцесс := первый;
parbegin процессодин;
процессдва;
parend
end.

Недостатки этого решения очевидны. Первый из них — для доступа к одной и той же критической секции из третьей нити мы должны значительно сложнить код обеих нитей.
Нa практике, для решения проблемы работы с флаговыми и другими ска-ярными переменными в многопроцессорных конфигурациях большинство овременных процессоров предоставляют аппаратные примитивы взаимоисключения: средства, позволяющие процессору монопольно захватить шину : выполнить несколько операций над памятью. Реализации этих примитивов различны у разных процессоров. Например, у х86 это специальный код операции, префикс захвата шины, который сам по себе не совершает никаких действий, но зато исполняет следующую за ним операцию в монопольном режиме.
Благодаря этому мы можем одновременно получить старое значение флаговой переменной и установить новое командой xchg (eXCHanGe, обменять — операнды обмениваются значениями между собой — пример 7.3)- Если память модифицируется только одним процессором, исполняющим программу, префикс блокировки шины не нужен — зато, если процессоров (или других задатчиков шины) в системе несколько, префикс гарантирует взаимоисключение и для модификаций флага с их стороны.

Пример 7.3. Реализация примитива testandset через блокировку шины

.globl flag
; 0 = False, I = True
flag: .db 0
proc process1
tryagain:
move eax, 1
lock xchg eax, flag
tst eax
bnz tryagain
критическая секция
; в данном случае о взаимоисключении можно не заботиться
xor eax, eax
move flag, eax
ret
endp

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

Пример 7.4. Реализация взаимоисключения при помощи атомарной операции testandset (ЦИТ. ПО [Дейтел 1987])

progam npimeptestandset
var активный: Boolean;
procedure процессодин;
var первомувходитьнельзя: Boolean;
begin
while true do
begin
первомувходитьнельзя := True;
while первомувходитьнельзя do
testandset(первомувходитьнельзя, активный);
критический участокодин;
активный := False;
....
end
end;
procedure процессдва;
var второмувходитьнельзя: Boolean; begin
while true do
begin
второмувходитьнельзя := True;
while второмувходитьнельзя do
testandset(второмувходитьнельзя, активный);
критический участокдва;
активный := False;
.....
end
end;

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


Мертвые и живые блокировки

Потом ударили морозы.
Замерзло все, лиса ушла в кредит. Медведь же вмерз в дупло
И до сих пор глядит.
Б. Гребенщиков.

Решив проблему взаимоисключения для одиночных разделяемых ресурсов, мы еще не можем расслабляться. Дело в том, что если мы используем любые механизмы взаимоисключения для доступа к нескольким различным ресурсам, может возникнуть специфическая проблема, называемая мертвой блокировкой (dead lock).
Рассмотрим две нити, каждая из которых работает с двумя различными ресурсами одновременно. Например, одна нить копирует данные со стриммера на кассету Exabyte, а другая — в обратном направлении. Доступ к стриммеру контролируется флаговой переменной flag1, а к кассете — flag2 (вместо флаговых переменных могут использоваться и более сложные средства взаимоисключения).
Первая нить сначала устанавливает flag1, затем fiag2, вторая поступает наоборот. Поэтому, если вторая нить получит управление и защелкнет flag2 в промежутке между соответствующими операциями первой нити, то мы получим мертвую блокировку (рис. 7.1) — первая нить никогда не освободит flag1, потому что стоит в очереди у переменной flag2, занятой второй нитью, которая стоит в очереди у flagi, занятой первой.

Рис. 7.1. Мертвая блокировка

Все остальные нити, пытающиеся получить доступ к стриммеру или кассете, также будут становиться в соответствующие очереди и ждать, пока администратор не снимет одну из "защелкнувшихся" задач.
Цикл взаимного ожидания может состоять и из большего количества нитей Возможна также мертвая блокировка с участием только одной нити и одного ресурса: для этого достаточно попытаться захватить одну и ту же флаговую переменную два раза. Критерием блокировки является образование замкнутого цикла в графе ожидающих друг друга задач.
Эта проблема может быть решена несколькими способами. Часто применяемое решение, обладающее, впрочем, серьезными недостатками — это отправка сообщения программе о том, что попытка установить примитив взаимоисключения приведет к мертвой блокировке. Это решение опасно во-первых, тем, что сильно усложняет кодирование: теперь мы вынуждены принимать во внимание не только возможность захвата примитива другой нитью, но и более сложные ситуации. Во-вторых, получив ошибку установки флага, программист испытывает сильный соблазн сделать именно то, чего делать в данном случае нельзя: повторить попытку захвата ресурса.
Рассмотрим ситуацию, когда две нити пытаются захватить необходимые им ресурсы, получают сообщение о возможности мертвой блокировки, и тут же повторяют попытку захвата того же ресурса. Поскольку освобождения ресурсов не происходит, взаимозависимость между этими нитями не устраняется, и повторный захват также приводит к сообщению о возможности мертвой блокировки. Если нити будут циклически повторять попытки захвата, мы получим состояние, которое называется живой блокировкой (livelock) (рис. 7.2). Это состояние реже рассматривается в учебниках, но теоретически оно ничуть не лучше мертвой блокировки. Практически же оно гораздо хуже — если нити, зацепившиеся намертво, тихо висят и причиняют вред только тем нитям, которым могли бы понадобиться занятые ими ресурсы, то нити, зацепившиеся заживо, непродуктивно расходуют время центрального процессора.

Рис. 7.2. Живая блокировка

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

Живая блокировка при арбитраже шины
Рассмотрим процесс арбитража шины: два устройства соревнуются за доступ к среде передачи. Устройство начинает передачу и при этом сравнивает передаваемый сигнал с принимаемым из шины. Возникновение расхождений между этими сигналами интерпретируется как коллизия (collision) — предполагается, что расхождения обусловлены работой другого передатчика. Обнаружив коллизию, устройство прекращает передачу. Сигнал распространяется по шине с конечной скоростью, поэтому прекратить передачу будут вынуждены оба устройства (в разд. Устройства графического выхода мы увидим пример того, как в низкоскоростных локальных шинах это ограничение можно обойти). Таким образом, оба устройства не могут начать передачу сразу же после того, как в шине "установится тишина": это приведет к живой блокировке. Схема разрешения коллизий CSMA/CD (Carrier Sence Multiple Access/Collision Detection, множественный доступ с прослушиванием несущей и обнаружением коллизий), используемая в протоколах локальных сетей семейства Ethernet, требует от устройства, обнаружившего коллизию, ожидания в течение случайного интервала времени.

Единственно правильная реакция на получение сообщения о мертвой блокировке — это освободить все занятые нитью ресурсы и подождать в течение случайного промежутка времени. Таким образом, поиск возможных блокировок сильно усложняет и логику работы самих примитивов взаимоисключения (нужно поддерживать граф, описывающий, кто кого ждет), и код, использующий эти примитивы.
Простейшее альтернативное решение — разрешить каждой нити в каждый момент времени держать захваченным только один объект — прост и решает проблему в корне, но часто оказывается неприемлемым. Больше подходит соглашение о том, что захваты ресурсов должны всегда происходить в определенном порядке. Этот порядок может быть любым, важно только, чтобы он всегда соблюдался.
Еще один вариант решения состоит в предоставлении возможности объединять примитивы и/или операции над ними в группы. При этом программа может выполнить операцию захвата флагов fiag1 и fiag2 единой командой, во время исполнения которой никакая другая программа не может получить доступ к этим переменным. Групповая операция выполняется как транзакция, т. е. либо происходит вся целиком, либо не происходит вообще. Если хотя бы одна из операций группы приводит к ожиданию, групповой примитив снимает все флаги, которые успел поставить до этого.
Ожидание с освобождением ресурсов, впрочем, порождает ряд более специфических проблем. Рассмотрим одну из них: нити А нужны ресурсы X и Y, которые она разделяет, соответственно, с нитями В и С. Если нить А захватывает ресурсы в соответствии с предложенным протоколом (освобождая их при неудачной попытке захвата), возможен такой сценарий исполнения нитей В и С, при котором нить А не получит доступа к ресурсам никогда. Напротив, захват ресурсов по мере их освобождения другими нитями может (если В и С сцеплены по каким-то другим ресурсам) привести к мертвой блокировке. Общего решения задачи разрешения блокировок и родственных им ситуаций при взаимоисключении доступа ко многим ресурсам на сегодня не предложено.
Описанное выше явление иногда называют "проблемой голодного философа". История этого колоритного названия восходит к модели, которую использовал для демонстрации описанного этого феномена.
Некоторое количество (у Э. Дейкстры — пять) философов сидят вокруг стола, на котором стоит блюдо спагетти (вместо спагетти можно взять, например, блины). Спагетти едят двумя вилками. В нашем случае, количество вилок равно количеству философов, так что соседи за столом разделяют вилку (гигиенический аспект проблемы мы опускаем).
Каждый философ некоторый (переменный) промежуток времени размышляет, затем пытается взять лежащие рядом с ним вилки (рис. 7.3). Если ему это удается, он принимается за еду. Ест он также переменный промежуток времени, после этого откладывает вилки и вновь погружается в размышления. Проблемы возникают, когда философ не может взять одну из вилок.

Рис. 7.3. Обедающие философы

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

Рис. 7.4. Мертвая блокировка в исполнении пяти философов

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

Рис. 7.5. Голодный философ


Примитивы синхронизации

Я слышу крик в темноте
Наверное, это сигнал.
В. Бутусов

Посмотрев на примеры 7.2 и 7.4, внимательный читатель должен отметить, что используемая конструкция подозрительно похожа на работу с внешними устройствами в режиме опроса. Действительно, опрос флаговой переменном в цикле хотя и обеспечивает гарантию взаимоисключения, но обладает всеми недостатками, которые мы указывали для опроса внешнего устройства.
g случае исполнения параллельных нитей на одном процессоре, данный метод имеет еще один недостаток: пока одна из нитей занимается опросом, никакая другая нить не может исполняться, потому что процессор загружен непродуктивной работой.
Легко видеть, что в данном случае использование прерываний или какого-то их аналога проблемы не решит: в лучшем случае, "прерывание" будет вызываться не в той нити, в которой нужно, сводя задачу взаимоисключения к предыдущей, только с уже новой флаговой переменной, а в худшем — приведет к возникновению еще одной нити. Задача взаимодействия между асинхронными нитями, таким образом, сводится к требованию того, чтобы нити в какие-то моменты переставали быть асинхронными, синхронизовались.
Если у нас уже есть примитив взаимного исключения, мы можем решить задачу синхронизации, предоставив еще один примитив, который позволяет активному процессу остановиться, ожидая, пока флаговая переменная не примет "правильное" значение, и продолжить исполнение после этого. При обработке прерываний роль такого примитива может исполнять команда остановки процессора: у всех современных процессоров прерывание останавливает "исполнение" этой команды, а возврат из обработчика передает управление на следующую команду, таким образом выводя процессор из спящего состояния. В многопроцессорной конфигурации можно ввести средство, при помощи которого один процессор может вызывать прерывание другого — и тогда каждый из процессоров системы сможет ожидать другого, переходя в режим сна. При реализации же многопоточностп на одном процессоре (см. разд. Вытесняющая многозадачность) примитив засыпания (блокировки) нити должен предоставляться модулем, ответственным за переключение потоков.
Впрочем, если операции над флагом, засыпание потока и его пробуждение реализованы разными примитивами, мы рискуем получить новую проблему (пример 7.5). Она состоит в том, что если пробуждающий сигнал поступит в промежутке между операторами testandset и pause, мы его не получим. В результате операция pause приведет к засыпанию нашей нити навсегда.

Пример 7.5. Ошибка потерянного пробуждения (lost wake-up bug)

program пауза
var flag: Boolean;
procedure процесс1
var myflag: Boolean
while True do
begin
myflag := True;
testandset(myflag, flag);
if myflag then
(* Обратите внимание, что проверка флага *
* и засыпание — это разные операторы! *)
pause;
критическаясекция();
flag := False;
end
end;

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

Пример 7.6. Обход ошибки потерянного пробуждения

.globl flag
flag: db 0
jmpbuf: dw 0
proc flag_interrupt
push eax
tst jmpbuf
bz setflagonly
; подменяем адрес возврата
move eax, jmpbuf
move sp[RETURN_ADDRESS_OFFSET] , eax setflagonly
move eax, 1
move flag, eax
pop eax
iret endp
proc process!
inove eax, setjmp
move jmpbuf, eax setjmp:
move eax, 1
lock xchg eax, flag
tst eax
bz ok
halt
ok
xor eax, eax move jmpbuf, eax
критическая секция
xor eax, eax move flag, eax
endp

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


Семафоры

Но когда ты проспишься, скрой спой испуг,
Это был не призрак, эти был только звук
Это тронулся поезд, на который ты не попадешь.
Б. Гребенщиков

Семафор Дейкстры представляет собой целочисленную переменную, с которой ассоциирована очередь ожидающих нитей. Пытаясь пройти через сема-Фор, нить пытается вычесть из значения переменной 1. Если значение переменной больше или равно 1, нить проходит сквозь семафор успешно (семафор открыт). Если переменная равна нулю (семафор закрыт), нить останавливается и ставится в очередь.
Закрытие семафора соответствует захвату объекта или ресурса, доступ к кото-Рому контролируется этим семафором. Если объект захвачен, остальные нити вынуждены ждать его освобождения. Закончив работу с объектом (выйдя из критической секции), нить увеличивает значение семафора на единицу, открывая его. При этом первая из стоявших в очереди нитей активизируется и вычитает из значения семафора единицу, снова закрывая семафор. Если же очередь была пуста, то ничего не происходит, просто семафор остается открытым. Тогда первая нить, подошедшая к семафору, успешно проГцет через него. Это действительно похоже на работу железнодорожного семафо. ра, контролирующего движение поездов по одноколейной ветке (рис. 7.6)

Рис. 7.6. Железнодорожный семафор

Наиболее простым случаем семафора является двоичный семафор. Начальное значение флаговой переменной такого семафора равно 1, и вообще она может принимать только значения 1 и 0. Двоичный семафор соответствует случаю, когда с разделяемым ресурсом в каждый момент времени может работать только одна нить.
Семафоры общего вида могут принимать любые неотрицательные значения. В современной литературе такие семафоры называют семафорами-счетчиками (counting semaphore). Это соответствует случаю, когда несколько нитей могут работать с объектом одновременно, или когда объект состоит из нескольких независимых, но равноценных частей — например, несколько одинаковых принтеров. При работе с такими семафорами часто разрешают процессам вычитать и добавлять к флаговой переменной значения, большие единицы. Это соответствует захвату/освобождению нескольких частей ресурса.
Многие системы предоставляют также сервисы, позволяющие просмотреть состояние семафора без его изменения и произвести "неблокируюшуюся" форму захвата, которая возвращает ошибку в ситуации, когда нормальный захват семафора привел бы к блокировке. Теоретики не очень любят такие примитивы, но при разработке сложных сценариев взаимодействия с участием многих семафоров они бывают полезны.
Во многих современных книгах и операционных системах семафорами называются только семафоры общего вида, двоичные же семафоры носят более краткое и выразительное имя мутекс (mutex — от MUTnal EXclusion, взаимное исключение). Проследить генезис этого названия автору не удалось, но можно с уверенностью сказать, что оно вошло в широкое употребление не ранее конца 80-х. Так, в разрабатывавшейся в середине 80-х годов OS/2 1.0, двоичные семафоры еще называются семафорами, а в Win32, разработка которой происходила в начале 90-х, уже появляется название mutex. Операции над мутексом называются захватом (acquire) (соответствует входу в критическую секцию) и освобождением (release) (соответствует выходу из нее).
Многие ОС предоставляют для синхронизации семафоры Дейкстры или похожие на них механизмы.

Флаги событий в RSX-11 и VMS
Так, например, в системах RSX-11 и VMS основным средством синхронизации являются флаги событий (event flags). Процессы и система могут очищать (clear) или взводить (set) эти флаги. Флаги делятся на локальные и глобальные. Локальные флаги используются для взаимодействия между процессом и ядром системы, глобальные — между процессами. Процесс может остановиться, ожидая установки определенного флага, поэтому флаги во многих ситуациях можно использовать вместо двоичных семафоров. Кроме того, процесс может связать с флагом события процедуру-обработчик AST (Asynchronous System Trap — асинхронно [вызываемый] системный обработчик).
AST во многом напоминают сигналы или аппаратные прерывания. В частности, флаги событий используются для синхронизации пользовательской программы с асинхронным исполнением запросов на ввод-вывод. Исполняя запрос, программа задает локальный флаг события. Затем она может остановиться, ожидая этого флага, который будет взведен после исполнения запроса. При этом мы получаем псевдосинхронный ввод-вывод, напоминающий синхронные операции чтения/записи в UNIX и MS DOS. Но программа может и не останавливаться! При этом запрос будет исполняться параллельно с исполнением самой программы, и она будет оповещена о завершении операции соответствующей процедурой AST.

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


Семафоры и прерывания

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


Захват участков файлов

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

Блокировка участков файла в Unix
Захват участков файла в качестве средства синхронизации был известен еще с 60-х годов, но в том виде, который описан в стандартах ANSI и POSIX, он был реализован в ОС UNIX в начале 70-х.
В UNIX возможны два режима захвата: допустимая (advisory) и обязательная (mandatory). Как та, так и другая блокировка может быть блокировкой чтения либо записи. Допустимая блокировка является "блокировкой для честных": она не оказывает влияния на подсистему ввода-вывода, поэтому программа, не проверяющая блокировок или игнорирующая их, сможет писать или читать из заблокированного участка без проблем. Обязательная блокировка требует больших накладных расходов, но запрещает физический доступ к файлу: чтение или запись, в зависимости от типа блокировки.
При работе с разделяемыми структурами данных в ОЗУ было бы удобно иметь аналогичные средства, но их реализация ведет к большим накладным расходам, даже на системах с виртуальной памятью, поэтому ни одна из известных автору систем таких средств не имеет. Библиотека POSIX threads предоставляет своеобразную форму мутекса, read/write lock, который, как и описанные файловые примитивы, может быть многократно захвачен для чтения и лишь однократно — для записи. Однако мы должны заводить такой примитив для каждой единицы разделяемого ресурса и не можем одним вызовом захватить сразу много подобных единиц.
Впрочем, в современных версиях системы UNIX есть возможность отображать файл в виртуальную память. Используя при этом допустимую блокировку участков файла, программы могут синхронизироовать доступ к нему (обязательная блокировка делает невозможным отображение в память).


Мониторы и серверы транзакций

Захват участков файла теоретически позволяет реализовать любую требуемую структуру взаимоисключения для процессов, работающих с этим файлом. Однако, практически, при работе с файлом большого количества нитей (например, многопользовательской системы управления базами данных), различные нити часто оказываются вынуждены ждать друг друга, что приводит к резкому увеличению времени реакции системы. С этим явлением хорошо знакомы разработчики и пользователи файловых СУБД, таких, как FoxPro или dBase IV.
В случае СУБД решение состоит в создании сервера БД, или сервера транзакций, который вместо примитивов захвата участков файлов или таблиц предоставляет пользователям понятие транзакции.
Если пользовательская сессия объявила начало транзакции, изменения, которые она вносит в таблицы, непосредственно в таблицах не отражаются, и другие сессии, которые обращаются к вовлеченным в транзакцию данным, получают их исходные значения. Завершив модификацию, пользовательская сессия объявляет завершение транзакции. Транзакция может завершиться как выполнением (commit), так и откатом (rollback).
В случае отката измененные данные просто игнорируются. В случае же выполнения сервер вносит изменения в таблицы, однако во время изменений он все равно предоставляет другим нитям данные в том состоянии, в котором они были до начала транзакции. Такой подход увеличивает потребности в оперативной и дисковой памяти (все данные, изменяемые в ходе транзакции, должны храниться минимум дважды: в измененном и в исходном видах), но обеспечивает резкое сокращение времени реакции и определенное упрощение кодирования: программист теперь не должен явно перечислять все данные, которые ему надо заблокировать в ходе транзакции. Кроме того, Двойное хранение данных позволяет восстановить либо результат транзакции, либо состояние данных до ее начала, после сбоя системы.
Современные серверы СУБД представляют собой сложные программные Комплексы, которые, кроме собственно "развязки" транзакций предоставляют сложные сервисы оптимизации запросов, индексации и кэширования Данных. Да и точное описание понятия транзакции в современных языках запросов к реляционным СУБД (SQL, RPG и др.) несколько сложнее при-Веденного выше. Однако детальное обсуждение этого вопроса увело бы нас Далеко в сторону от темы книги. Читателю, интересующемуся этой темой, Можно порекомендовать книги [Дейт 1999, Бобровски 1998].
Аналогичный серверам транзакций подход нередко используется и в более простых случаях межпроцессного взаимодействия. С разделяемым ресурсом ассоциируется специальная нить, называемая монитором ресурса. Остальны нити не могут обращаться к ресурсу напрямую и взаимодействуют только с монитором. Монитор может предоставлять нитям-клиентам непротиворечивые состояния контролируемого им ресурса (необязательно совпадающие с текущим состоянием ресурса) и устанавливать очередность запросов на модификацию.
Даже при довольно простой стратегии управления ресурсом, монитор полезен тем, что скрывает (инкапсулирует) структуру и особенности реализации разделяемого ресурса от клиентских нитей. Типичный пример мониторного процесса — драйвер внешнего устройства в многозадачных ОС.


Гармонически взаимодействующие последовательные потоки

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

  1. 1. Каждый поток (нить) представляет собой независимый программный модуль, для которого создается иллюзия чисто последовательного исполнения.
  2. 2. Нити не имеют разделяемых данных.
  3. 3. Все обмены данными и вообще взаимодействие происходят с использованием специальных примитивов, которые одновременно выполняют и передачу данных, и синхронизацию.
  4. 4. Синхронизация, не сопровождающаяся передачей данных, просто лит6" на смысла — нити, не имеющие разделяемых структур данных, совершенно независимы и не имеют ни критических точек, ни нереентерабельных модулей.

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

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

Гармоническое взаимодействие, строго говоря, не исключает проблему мертвой блокировки: замкнув гармонически взаимодействующие нити в кольцо (А получает информацию от В, В от С, С от А), мы можем получить классическую трехзвенную мертвую блокировку. Впрочем, в данном случае блокировка требует наличия циклической зависимости данных, которая свидетельствует о серьезных ошибках проектирования программы (и, возможно, о внутренней противоречивости требований к этой программе). Такая ошибка может быть относительно легко обнаружена посредством формального анализа спецификаций и потоков данных.
На практике, впрочем, гармоническое взаимодействие обходит основную массу проблем, возникающих при взаимоисключении доступа ко многим ресурсам — и блокировки, и "проблему голодного философа". Дело в том, что гармонически взаимодействующий поток имеет дело не с разделяемым ресурсом непосредственно, а с копией состояния этого ресурса. Если нам нужны два ресурса, мы (не обязательно одновременно) снимаем с них две копии — для этого нам не надо одновременно захватывать сами ресурсы.
В частности, поэтому групповые операции над примитивами гармонического взаимодействия (оператор select в Ada, системный вызов select в системах семейства Unix, команда alt в транспьютере) работают не по принципу транзакции, а возвращают управление и данные при условии, что хотя бы один из примитивов группы готов эти данные предоставить. Воспроизвести "проблему голодного философа" в этих условиях невозможно.
В современных системах реализован целый ряд средств, которые осуществляют передачу данных совместно с синхронизацией: почтовые ящики (mailboxes) в системах линии RSX-11 — VMS, трубы (pipes) (в русскоязычной литературе их часто называют программными каналами) в UNIX, рандеву (rendesvous — свидание) в языке Ada, линки (link — соединение) в микропроцессорах семейства Transputer и т. д. Большинство этих средств будет обсуждаться подробнее в разд. Примеры реализаций средств гармонического взаимодействия.
Примитивы синхронного обмена данными отличаются большим разнообразием. Основные принципы классификации таковы.

  1. 1. Примитивы могут быть двухточечные (допускающие только один прием, ник и один передатчик), либо многоточечные, допускающие несколько приемников и передатчиков. Многоточечность бывает как симметричная когда может быть несколько и приемников, и передатчиков, так и асимметричная: один передатчик и много приемников — широковещательная (broadcast) или групповая (multicast) передача — либо один приемник и много передатчиков.
  2. 2. Примитив может передавать неструктурированный поток байтов, либо структурированные данные, разбитые на сообщения определенного размера. В первом случае передатчик может порождать данные блоками одного размера, а приемник — считывать их блоками другого размера Во втором случае приемник всегда обязан прочитать сообщение целиком (возможно, отбросив какую-то его часть). Сообщения могут быть как фиксированного, так и переменного размера.
  3. 3. Примитив может быть синхронным, буферизованным или с негарантированной доставкой. В первом случае передатчик вынужден ждать, пока приемник не прочитает все переданные данные. Во втором, данные складываются в буфер и могут быть прочитаны приемником позднее. В третьем случае, если потенциальный приемник не был готов принять сообщение, оно просто игнорируется.

Синхронные примитивы могут использоваться не "только для гармонического взаимодействия, но и для реализации примитивов чистой синхронизации. В частности, в учебниках по языку Ada (например, [Василеску 1990]) и по программированию для транспьютеров (например, [Харп 1993]) почему-то любят приводить примеры реализации семафоров на основе, соответственно, рандеву и линков.
Буферизованные примитивы для синхронизации использованы быть не могут. Зато буферизация полезна, если нам надо согласовать скорости работы нитей, имеющих разные приоритеты — например, если нить реального времени должна быстро обработать событие и передать часть данных для отложенной обработки менее приоритетной нитью, не допустив при этом собственной блокировки.
Буферизованный примитив может быть легко реализован на основе пары синхронных примитивов и нити-монитора буфера (или очереди). В этом смысле, синхронные примитивы являются более низкоуровневыми, чем буферизованные.
Синхронные примитивы по природе своей всегда двухточечные. Да и в случае буферизованных примитивов многоточечное взаимодействие не всегда легко реализуемо, а иногда просто опасно, особенно в случае потоковой передачи данных: операция считывания данных из потока необратима, а естественного разбиения потока на сообщения нет, поэтому если один из приемников по ошибке захватит кусок сообщения, предназначенного не ему, то данные в потоке будут необратимо испорчены.
Впрочем, некоторые потоковые примитивы, например трубы (pipes) в системах семейства Unix, допускают (хотя документация и рекомендует пользо-яться этим с осторожностью) наличие нескольких передатчиков при одном приемнике.
Напротив, симметрично многоточечные очереди сообщений широко распространены. Часто такие примитивы позволяют потребителю отбирать сообщения из очереди по какому-то критерию, например по значению специального поля, называемому типом или тегом. Ряд широковещательных и групповых сервисов передачи данных относится к категории примитивов с негарантированной доставкой.
Второй и третий критерии нашей классификации (если пока отвлечься от сервисов с негарантированной доставкой) практически ортогональны друг другу: существуют все четыре варианта (табл. 7.1). Легко понять, что передавать поток неструктурированных данных в режиме негарантированной доставки бессмысленно: разрывы потока неизбежны, а мы не сможем даже узнать, произошел ли такой разрыв, и если произошел, то где. Все существующие сервисы с негарантированной доставкой передают только сообщения.

Таблица 7.1. Примитивы синхронизованной передачи данных

Примитивы | Синхронные | Буферизованные
Потоковые | Линки (транспьютер) | Трубы (Unix), сокеты (TCP/IP)
Структурированные | Рандеву (Ada) | Очереди сообщений

На первый взгляд, вообще непонятно, почему кому-то может быть полезен сервис с негарантированной доставкой. Но под это описание подходят многие низкоуровневые сетевые протоколы (Ethernet, IP) и некоторые относительно высокоуровневые сетевые сервисы, так называемые дейтаграммные протоколы. Примером такого сервиса является UDP (User Datagram Protocol), входящий в семейство протоколов TCP/IP.
В сетевых протоколах отсутствие гарантии доставки сообщения имеет двоякий смысл — сообщение может быть потеряно не только по невниманию получателя, но и из-за физических ошибок передачи или перегрузки маршрутизаторов и/или каналов связи по дороге от передатчика к приемнику. В этом смысле можно сказать, что разработчики сетевых протоколов вынуждены использовать негарантированную доставку не потому, что им нужна Именно она, а потому, что других средств-то и нет.
В чистом виде негарантированная доставка приемлема для реализации одиночных запросов, на которые должен последовать одиночный же ответ. Если ответа за определенный протоколом интервал времени нет, передатчик повторяет запрос, а после некоторого количества попыток приходит к выводу, что приемник не функционирует, либо вообще отсутствует.
Для реализации надежной связи на основе сервисов с негарантированной доставкой используются различного рода подтверждения, так называемое квитирование. Понятно, что при реализации квитирования необходимо принимать во внимание возможность потери не только самого подтверждаемого сообщения, но и пакета-подтверждения. Понятно также, что в большинстве случаев посылка подтверждения на каждое пришедшее сообщение нецелесообразна. Поэтому реальные протоколы такого рода относительно сложны (см. например [RFC 0793]) и их обсуждение заслуживает отдельной книги. Накладные расходы при реализации таких протоколов значительны, поэтому часто оказывается целесообразно смириться с негарантированной доставкой.
Концепция гармонически взаимодействующих процессов очень привлекательна с теоретической точки зрения и позволяет легко писать правильные программы. Однако все примитивы синхронизованной передачи данных плохи именно тем, что требуют передачи, копирования данных. И передатчик, и приемник вынуждены иметь по буферу, в котором данные следует хранить (в случае буферизованных примитивов данные хранятся трижды). Накладные расходы при копировании данных большого объема также могут оказаться значительными.
Если нити исполняются на разных компьютерах, не имеющих общей памяти и соединенных лишь относительно (по сравнению с системной шиной) низкоскоростными сетевыми каналами, мы так или иначе не можем обойтись без передачи и двойного хранения данных. Впрочем, даже и в этом случае иногда имеет смысл подумать о применении многопортовой памяти или реализаций NUMA или СОМА-архитектуры.
При исполнении же нитей на одной машине, по соображениям производительности и экономии памяти нередко оказывается нецелесообразно отказываться от разделяемой памяти и полностью переходить на примитивы гармонического взаимодействия. Чаще всего это происходит, когда к ресурсу выполняется много обращений для чтения, а модификации относительно редки, и при этом данные имеют большой объем. Даже в этом случае бывает целесообразно заменить прямое разделение памяти на мониторный процесс. а при доступе к данным получать у него лишь непосредственно необходимое их подмножество. Однако ситуации бывают разные, и не всегда такое решение оказывается оптимальным.
В этом смысле разделяемая память напоминает другой предмет ненависти структурных программистов — оператор goto. И то, и другое при неразумном использовании является потенциальным источником ошибок и проблем, но иногда без них оказывается нельзя обойтись.


Примеры реализаций средств гармонического взаимодействия

Программные каналы Unix
Одним из наиболее типичных средств такого рода является труба (pipe) или программный канал — основное средство взаимодействия между процессами в ОС семейства Unix. В русскоязычной литературе трубы иногда ошибочно называют конвейерами. В действительности, конвейер — это группа процессов, последовательно соединенных друг с другом однонаправленными трубами.
Труба представляет собой поток байтов. Поток этот имеет начало (исток) и конец (приемник). В исток этого потока можно записывать данные, а из приемника — считывать. Нить, которая пытается считать данные из пустой трубы, будет задержана, пока там что-нибудь не появится. Наоборот, пишущая нить может записать в трубу некоторое количество данных, прежде чем труба заполнится, и дальнейшая запись будет заблокирована. На практике труба реализована в виде небольшого (несколько килобайтов) кольцевого буфера. Передатчик заполняет этот буфер, пока там есть место. Приемник считывает данные, пока буфер не опустеет.
Трубу можно установить в режим чтения и записи без блокировки. При этом вызовы, которые в других условиях были бы остановлены и вынуждены были бы ожидать партнера на другом конце трубы, возвращают ошибку с особым кодом.
По-видимому, трубы являются одной из первых реализаций гармонически взаимодействующих процессов по терминологии Дейкстры.
Самым интересным свойством трубы является то, что чтение данных из и запись в нее осуществляется теми же самыми системными вызовами read и write, что и работа с обычным файлом, внешним устройством или сетевым соединением (сокетом). На этом основана техника переназначения ввода-вывода, широко используемая в командных интерпретаторах UNIX. Она состоит в том, что большинство системных утилит получают данные из потока стандартного ввода (stdin) и выдают их в поток стандартного вывода (stdout). При этом, указывая в качестве этих потоков терминальное устройство, файл или трубу, мы можем использовать в качестве ввода, соответственно: текст, набираемый с клавиатуры, содержимое файла или стандартный вывод другой программы. Аналогично мы можем выдавать данные сразу на экран, в файл или передавать их на вход другой программы.
Так, например, компилятор GNU С состоит из трех основных проходов: препроцессора, собственно компилятора, генерирующего текст на ассемблере, и ассемблера. При этом внутри компилятора, на самом деле, также выполняется несколько проходов по тексту (в описании перечислено восемнадцать), в основном для оптимизации, но нас это в данный момент не интересует, поскольку все они выполняются внутри одной задачи. При этом все три задачи объединяются трубами в единую линию обработки входного текста — конвейер (pipeline), так что промежуточные результаты компиляции не занимают места на диске.
В системе UNIX труба создается системным вызовом pipe(int flldes;2]) Этот вызов создает трубу и помещает дескрипторы файлов, соответствующие входному и выходному концам трубы, в массив fildes. Затем мы можем вы полнить fork, в различных процессах переназначить соответствующие конць трубы на место stdin и stdout и запустить требуемые программы (пример 7.7). При этом мы получим типичный конвейер — две задачи, стандартный ввод и вывод которых соединены трубой.

Пример 7.7. Код, создающий конвейер при помощи труб

#include <unistd.h>
void pipeline(void) {
/* stage 1 */
int pipel[2];
int childl;
int pipe2[2];
int child2;
int child3;

pipe(pipel);

if ((childl=fork())==0) {
close(pipel[0]); /* Закрыть лишний конец трубы */
closed); /* Переназначить стандартный вывод */
dup(pipel[1]);
close(pipel[1]);
/* Исполнить программу */
execlpC'du", "du", "-s", ".", NULL);
/* Мы можем попасть сюда только при ошибке exec */
perror("Cannot exec");
exit(0);
}
close(pipel [1] ) ;
if (childl==-l) {
perror("Cannot fork");
}
/* stage 2 */
pipe(pipe2);
if ( (child2=fork() )==0) { '. close (0) ; /J" Переназначить стандартный ввод */
dup(pipel[0]} ; close (pipel [0] ) ;
•'close (pipe2 [0] ) ; /* Закрыть лишний конец трубы */ close (1) ; /* Переназначить стандартный вывод */
close (pipe2 [1] ) ;
/* Исполнить программу */
execlp ("sort", "sort", "-nr", NULL);
/* Мы можем попасть сюда только при ошибке exec */
perror ("Cannot exec");
exit(O) ;
}
close (pipel [0] ) ;
close (pipe2 [1] ) ;
if (child2==-l) {
perror ("Cannot fork");
}
/* stage 3 */
if ( (child3=fork() )==0) {
close (0) ; /* Переназначить стандартный ввод */
dup(pipe2 [0] ) ;
close (pipe2 [0] ) ;
/* Исполнить программу */
execlp ("tail", "tail", "-1", NULL) ;
/* Мы можем попасть сюда только при ошибке exec */
perror ("Cannot exec");
exit (0) ;
}
close (pipe2 [0] ) ;
if (child3==-l) {
perror ("Cannot fork");
}
while (wait (NULL) !=-!) ;
return ;
}

Понятно, что такие трубы можно использовать только для связи родственны задач, т. е. таких, которые связаны отношением родитель-потомок или являются потомками одного процесса.
Для связи между неродственными задачами используется другое средство. именованные трубы (named pipes) в System V и UNIX domain sockets в BSD UNIX. В разных системах именованные трубы создаются различными систем ными вызовами, но очень похожи по свойствам, поэтому стандарт POSIX пред лагает для создания именованных труб библиотечную функцию mkfifc {c--ls. char * name, mode_t flags);. Эта функция создает специальный файл" Открывая такой файл, программа получает доступ к одному из концов трубы Когда две программы откроют именованную трубу, они смогут использовать ее для обмена данными точно так же, как и обычную.
Современные системы семейства Unix предоставляют возможность для одновременной работы с несколькими трубами (а также с другими объектами, описываемыми дескриптором файла — собственно файлами, сокетами и т. д.)_, системный вызов select. Этот вызов возвращает список дескрипторов файлов, которые способны передать или принять данные. Если ни один из дескрипторов не готов к обмену данными, select блокируется.
Трубы широко используются системами семейства Unix, и они внесены в стандарт POSIX. Ряд операционных систем, не входящих в семейство Unix, например VxWorks, также предоставляют этот сервис.

Почтовые ящики VMS
Система VMS предоставляет средства, отчасти аналогичные трубам, называемые почтовые ящики (mailbox). Почтовый ящик также представляет собой кольцевой буфер, доступ к которому осуществляется теми же системными вызовами, что и работа с внешним устройством. Системная библиотека языка VAX С использует почтовые ящики для реализации труб, в основном совместимые с UNIX и стандартом POSIX. Широко используемый сервис сетевой передачи данных, сокеты протокола TCP, также очень похожи на трубу.

Линки транспьютера
В микропроцессорах семейства Transputer микропрограммно реализованы линки (link — связь) — синхронный примитив, отчасти похожий на трубы.
Линки бывают двух типов — физические и логические. Операции над линками обоих типов осуществляются одними и теми же командами. Физический линк представляет собой последовательный интерфейс RS432, реализованный на кристалле процессора. С линком также ассоциировано одно слово памяти, смысл которого будет объяснен далее.
Современные транспьютеры имеют четыре физических линка. Физические линки могут передавать данные со скоростью до 20 Мбит/с и могут использоваться как для соединения транспьютеров между собой (рис. 7.7), так и для подключения внешних устройств. Благодаря этому физический линк может использоваться как для связи между процессами на разных транспьютерах, так и для синхронизации процесса с внешними событиями и даже просто для ввода-вывода.

Рис. 7.7. Сеть транспьютеров, соединенных физическими линками

Логический линк— это просто структура данных, выделенная в физическом адресном пространстве процессора. С точки зрения программы, физический и логический линки ничем не отличаются, кроме того, что описатель физического линка привязан к определенному физическому адресу. Логический линк может использоваться только для связи между процессами (напоминаем, что по принятой в транспьютере терминологии, нити называются процессами), исполняющимися на одном транспьютере.
Транспьютер Т9000 предоставляет также виртуальные линки— протокол, позволяющий двум транспьютерам организовать несколько линий взаимодействия через один физический линк, или даже через цепочку маршрутизаторов.
При передаче данных в линк процесс должен исполнить команду out. Эта команда имеет три операнда: адрес линка, адрес массива данных и количество данных. Для передачи операндов используется регистровый стек процессора. Процесс, исполнивший такую команду, задерживается до тех пор, пока все данные не будут переданы (рис. 7.8).

Рис. 7.8. Передача данных через линк

Аналогично, при приеме данных из линка, процесс должен исполнить команду in. Эта команда также имеет три операнда — адрес линка, адрес буфера, куда необходимо поместить данные, и размер буфера. При исполнении такой команды процесс блокируется до тех пор, пока буфер не будет заполнен данными. При этом приемник и передатчик могут использовать буферы разного размера, т. е. приемник может считывать большой массив данных в несколько приемов и т. д.
Существует также команда alt, позволяющая процессу ожидать данные из нескольких линков одновременно. В качестве одного из ожидаемых событий можно также использовать сигнал от системного таймера. Слово, связанное с линком, содержит указатель на дескриптор процесса, ожидающего приема или передачи данных через линк. Кроме того, это слово может принимать значение NotProcessP, указывающее, что соединения никто не ждет. Остальная информация, такая, как указатель на буфер и размер буфера, хранится в дескрипторе процесса.
Направление передачи данных определяется командой, которую исполнит очередной процесс при обращении к линку. Например, если исполняется команда out, предназначенные для записи данные копируются в буфер ожидающего процесса. При этом указатель буфера продвигается, а счетчик размера уменьшается на количество скопированных данных. Если же в линке записано значение NotProcessP, процесс переводится в состояние ожидания и указатель на его дескриптор помещается в линк (рис. 7.9).

Рис. 7.9. Алгоритм работы команд in и out

Аналогично обрабатываются запросы на чтение. Если мы имеем более двух процессов, пытающихся использовать один линк, то возникает серьезная проблема: внимательный читатель должен был заметить, что мы не сказали, где хранится информация о том, чего ожидает текущий процесс: чтения или записи. Проблема состоит в том, что эта информация нигде не хранится. Если процесс попытается записать данные в линк, на котором кто-то уже ожидает записи, то данные второго процесса будут записаны поверх данных ожидавшего. Если размеры буферов совпадут, то ожидавший процесс будет пребывать в убеждении, что он успешно передал все данные. Поэтому линки рекомендуется использовать только для однонаправленной передачи данных между двумя (не более!) процессами.
При работе с физическим линком данные не копируются, а передаются или принимаются через физический канал в режиме прямого доступа к памяти. Если на другом конце линка находится другой транспьютер, это все-таки можно считать копированием, но к линку может быть подключено и какое-то другое устройство.
В середине 90-х, в эпоху расцвета микропроцессоров этого семейства, фирма Inmos поставляла широкий набор трэмов (trem — TRansputer Extension module) — устройств ввода-вывода с линком в качестве интерфейса. В частности, поставлялись трэмы, позволявшие подключить к транспьютеру через линк адаптеры Ethernet или SCSI.
Взаимодействие с внешним устройством через линк позволяет транспьютеру синхронизовать свою деятельность с этими устройствами без использования механизма прерываний. В [INMOS 72 TRN 203 02] приводится пример программной имитации векторных прерываний с передачей вектора по линку
и мониторным процессом, который принимает эти векторы из линка и вызывает соответствующие обработчики.


Системы, управляемые событиями

В начале 70-х годов появилась новая архитектура многозадачных систем довольно резко отличающаяся от вышеописанной модели последовательных процессов. Речь идет о так называемых системах, управляемых событиями (event-driven systems).
На первый взгляд, концепция систем, управляемых событиями, близко родственна гармонически взаимодействующим процессам. Во всяком случае, одно из ключевых понятий этой архитектуры, очередь событий, мы упоминали в числе средств гармонического межпоточного взаимодействия. Различие между этими архитектурами состоит, скорее, во взгляде на то, что представляет собой программа.
В модели гармонически взаимодействующих потоков процесс исполнения программного комплекса представляет собой совокупность взаимодействующих нитей управления. В системе, управляемой событиями, программа представляет собой совокупность объектов, обменивающихся сообщениями о событиях, а также реагирующих на сообщения, приходящие из внешних источников.
В идеале, объекты взаимодействуют между собой только через сообщения. Приходящие сообщения побуждают объект изменить свое состояние и, возможно, породить некоторое количество сообщений, предназначенных для других объектов. При такой модели взаимодействия нам неважно, исполняются ли методы объектов как параллельные (или псевдопараллельные) нити, или же последовательно вызываются единой нитью, менеджером или диспетчером сообщений.
Впервые эта архитектура была реализована в экспериментальных настольных компьютерах Alto, разработанных в 1973 году в исследовательском центре PARC фирмы Xerox. Целью эксперимента было создание операционной среды, удобной для создания интерактивных программ с динамичным пользовательским интерфейсом.
В этих системах впервые была реализована многооконная графика, когда пользователь одновременно видит на экране графический вывод нескольких программ и может активизировать любую из них, указав на соответствующее окно при помощи манипулятора-"мыши".
При каждом движении мыши, нажатии на ее кнопки или клавиши на клавиатуре генерируется событие. События могут также генерироваться системным таймером или пользовательскими программами. Нельзя не упомянуть "визуальные" события, которые порождаются в ситуации, когда пользователь
сдвинул или закрыл одно из окон и открыл при этом часть окна, находившегося внизу. Этому окну посылается событие, говорящее о том, что ему нужно перерисовать часть себя (рис. 7.10).

Рис. 7.10. Визуальное событие

Каждое сообщение о событии представляет собой структуру данных, которая содержит код, обозначающий тип события: движение мыши, нажатие кнопки и т. д., а также поля, различные для различных типов событий. Для "мышиных" событий — это текущие координаты мыши и битовая маска, обозначающая состояние кнопок (нажата/отпущена). Для клавиатурных событий — это код нажатой клавиши, обычно, ASCII-код символа для алфавитно-цифровых и специальные коды для стрелок и других "расширенных" и "функциональных" клавиш — и битовая маска, обозначающая состояние различных модификаторов, таких как SHIFT, CNTRL, ALT и т. д. Для визуальных событий — это координаты прямоугольника, который нужно перерисовать, и т. д.
Все сообщения о событиях помещаются в очередь в порядке их возникновения.
В системе существует понятие обработчика событий. Обработчик событий Представляет собой объект, т. е. структуру данных, с которой связано несколько подпрограмм — методов. Один из методов вызывается при поступлении сообщения. Обычно он также называется обработчиком событий. Некоторые системы предлагают объектам-обработчикам предоставлять различные методы для обработки различных событий — например, метод onClick будет вызываться, когда придет событие, сигнализирующее о том что кнопка мыши была нажата и отпущена, когда курсор находился над областью, занимаемой объектом на экране.
Рассмотрим объект графического интерфейса, например меню. При нажатии на кнопку мыши в области этого меню вызывается обработчик события Он разбирается, какой из пунктов меню был выбран, и посылает соответствующее командное сообщение объекту, с которым ассоциировано меню Этот объект, в свою очередь, может послать командные сообщения каким-то другим объектам. Например, если была выбрана команда File/Open, меню передаст обработчику основного окна приложения сообщение FILEOPEN, а тот, в свою очередь, может передать команду Open объекту, отвечающему за отрисовку и обработку файлового диалога.
Таким образом, вместо последовательно исполняющейся программы, время от времени вызывающей систему для исполнения той или иной функции, мы получаем набор обработчиков, вызываемых системой в соответствии с желаниями пользователя. Каждый отдельный обработчик представляет собой конечный автомат, иногда даже вырожденный, не имеющий переменной состояния. Код обработчика и по реализации обычно похож на конечный
автомат, и состоит m большого оператора switch, выбирающего различные действия в зависимости от типа пришедшего сообщения (пример 7.8).

Пример 7.8. Обработчик оконных событий в OS/2 Presentation Manager

/* фрагмент примера из поставки IBM Visual Age for C++ 3.0.
* Обработчик событий меню предоставляется системой,
* а обработку командных событий, порождаемых меню,
* вынужден брать на себя разработчик приложения. */
/****************************************************************
Copyright (С) 1992 IBM Corporation
ОТКАЗ ОТ ГАРАНТИЙ. Следующий код представляет собой пример кода созданный IBM Corporation. Этот пример кода не является частью ни одного стандарта или продукта IBM и предоставляется вам с единственной целью — помочь в разработке ваших приложений. Код предоставляется "КАК ЕСТЬ", без каких-либо гарантий. IBM не несет ответственности за какие бы то ни было повреждения, возникшие в результате использования вами этого кода, даже если она и могла предполагать возможность таких повреждений.

/****************************************************************
* Имя: MainWndProc *
Описание: Оконная процедура главного окна клиента. *
* Концепции: Обрабатывает сообщения, посылаемые главному
окну клиента. Эта процедура обрабатывает основные сообщения, которые должны обрабатывать все клиентские окна, и передает все остальные [функции] UserWndProc, в которой разработчик может обработать любые другие
сообщения. *
API: He используются
* Параметры: hwnd — Идентификатор окна, которому адресовано сообщение
* msg — Тип сообщения
* mpl — Первый параметр сообщения
* тр2 — Второй параметр сообщения.
* Возвращаемое значение: определяется типом сообщения
*/
****************************************************************
MRESULT EXPENTRY MainWndProc(HWND hwnd, USHORT msg, MPARAM mpl,
MPARAM mp2)
{
switch(msg) {
case WM_CREATE:
return(InitMainWindow(hwnd, mpl, mp2)};
break;
case WM_PAINT:
«
MainPaint(hwnd); break;
case WM_COMMAND:
MainCommand(mpl,. mp2); break;
case WM_INITMENU:
Ini tMenu(mpl, mp2); break;
case HM_QUERY_KEYS_HELP:
return (MRESULT)PANEL_HELPKEYS;/* Вернуть Id панели подсказки ' break;
/*
* Все необработанные сообщения передаются
* пользовательской процедуре окна.
* Она отвечает за передачу всех необработанных
* сообщений функции WinDefWindowProc(); */
default:
return(UserWndProc(hwnd, msg, mpl, mp2)); break;
return (MRESULT)O; /* Все оконные процедуры должны по умолчанию возвращать 0 */
} /* Конец MainWndProc() */

/****************************************************************
* Имя: MainCommand
* Назначение: Главная процедура окна, обрабатывающая WM_COMMAND *
* Концепции: Процедура вызывается, когда сообщение WM_COMMAND
* отправляется главному окну. Оператор switch
* переключается в зависимости от id меню, которое
* породило сообщение и предпринимает действия,
* соответствующие этому пункту меню. Все id меню,
* не являющиеся частью стандартного набора команд,
* передаются пользовательской процедуре обработки
* WM_COMMAND.
* API : WinPostMsg *
* Параметры : mpl — Первый параметр сообщения
тр2 — Второй параметр сообщения *
* Возвращает: VOID *
\*************+**************************************************^
VOID MainCommand(MPARAM mpl, MPARAM mp2)
switch(SHORT1FROMMP(mpl))
I
case IDM_EXIT:
WinPostMsg( hwndMain, WM_QUIT, NULL, NULL break;
case IDM__FILENEW: FileNew(mp2); break;
case IDM_FILEOPEN: FileOpen(mp2); break;
case IDM_FILESAVE: FileSave(mp2); break;
case IDM_FILESAVEAS: FileSaveAs(mp2); break;
case IDM_EDITUNDO: EditUndo(mp2); break;
case IDM_EDITCUT: EditCut(mp2); break;
case IDM_EDITCOPY: EditCopy(mp2); break;
case IDM_EDITPASTE: EditPaste(mp2); break;
case IDM_EDITCLEAR: EditClear(mp2); break;
case IDM_HELPUSINGHELP: HelpUsingHelp(mp2); break;
case IDM_HELPGENERAL: HelpGeneral(mp2); break;
case IDM_HELPKEYS: HelpKeys(mp2); break;
case IDM_HELPINDEX: Helplndex(mp2); break;
case IDM_HELPPRODINFO: HelpProdInfo(mp2); break; /*
* Здесь вызывается пользовательская процедура
* обработки команд, чтобы обработать те id',
* которые еще не были обработаны. */
default:
UserCammand(mpl, mp2); break; )
} /* MainCommand() */

Специальная программа, менеджер событий (рис. 7.12), просматривает очередь и передает поступающие события обработчикам. События, связанные с экранными координатами, передаются обработчику, ассоциированному с соответствующим окном. Клавиатурные события передаются фокусу клавиатуры — текущему активному обработчику клавиатуры.
Управление событиями позволяет относительно легко разрабатывать динамичные пользовательские интерфейсы, привычные для пользователей современных графических оболочек.
Высокая динамичность интерфейса проще всего обеспечивается, если каждый обработчик быстро завершается. Если же в силу природы запрашиваемой операции она не может завершиться быстро — например, если мы вставили символ, параграф удлинился на строку, и в результате текстовому процессору Типа WYSIWYG приходится переформатировать и переразбивать на страни-Цы весь последующий текст — мы можем столкнуться с проблемой.

Рис. 7.12. Менеджер и обработчики событий

В такой ситуации (а при написании реальных приложений она возникает сплошь и рядом) мы и вынуждены задуматься о том, что же в действительности представляют собою обработчики — процедуры, синхронно вызываемые единственной нитью менеджера событий, или параллельно исполняющиеся нити. Первая стратегия называется синхронной обработкой сообщений, а вторая, соответственно, — асинхронной.
Графические интерфейсы первого поколения — Mac OS, Winl6 — реализовывали синхронную обработку сообщений, а когда обработчик задумывался надолго, рисовали неотъемлемый атрибут этих систем — курсор мыши в форме песочных часов.
Несколько более совершенную архитектуру предлагает оконная подсистема OS/2, Presentation Manager. PM также реализует синхронную стратегию обработки сообщений (менеджер событий всегда ждет завершения очередного обработчика), но в системе, помимо менеджера событий, могут существовать и другие нити. Если обработка события требует длительных вычислений или других действий (например, обращения к внешним устройствам или к сети), рекомендуется создать для этого отдельную нить и продолжить обработку асинхронно. Если же приложение этого не сделает (например, обработчик события просто зациклится или заснет на семафоре), системная очередь сообщений будет заблокирована и ни одно из графических приложений не сможет работать. Современные версии РМ предоставляют в этом случае возможность отцепить "ненормальное" приложение от очереди или паже принудительно завершить его.
Асинхронные очереди сообщений предоставляют Win32 и оконная система X Window. Впрочем, и при асинхронной очереди впавший в философские размышления однопоточный обработчик событий — тоже малоприятное зрелище, ведь он не может перерисовать собственное окно, поэтому передвижение других окон по экрану порождает любопытные спецэффекты (к сожалению, запечатлеть эти эффекты при помощи утилит сохранения экрана невозможно — все известные автору средства дожидаются, пока все попавшие в сохраняемую область окна перерисуются. А фотографии монитора обычно имеют низкое качество). Разработчикам приложений для названных систем также рекомендуется выносить длительные вычисления в отдельные нити.
Большинство реальных приложений для современных ОС, имеющих пользовательский интерфейс, таким образом, имеют двух- или более слойную архитектуру. При этом архитектура ближайшего к пользователю стоя (frontend), как правило, тяготеет к событийно-управляемой, а следующие слои (backend) обычно состоят из более традиционных взаимодействующих (не всегда, впрочем, строго гармонически) параллельно исполняющихся нитей, зачастую даже разнесенных по разным вычислительным системам.


Глава 8. Реализация многозадачности на однопроцессорных компьютерах

  • Реализация многозадачности на однопроцессорных компьютерах
  • Кооперативная многозадачность
  • Вытесняющая многозадачность
    • Планировщики с приоритетами
  • Монолитные системы и системы с микроядром


Реализация многозадачности на однопроцессорных компьютерах

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

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


Кооперативная многозадачность

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

  • struct Thread; В тексте будет обсуждаться, что должна представлять собой эта структура, называемая дескриптором нити.
  • Thread * ThreadCreate(void (*ThreadBody)(void)); Создать нить, исполняющую функцию ThreadBody.
  • void Threadswitch(); Эта функция приостанавливает текущую нить и активизирует очередную, готовую к исполнению.
  • void ThreadExit () ; Прекращает исполнение текущей нити.

Сейчас мы не обсуждаем методов синхронизации нитей и взаимодействия
между ними (для синхронизации были бы полезны также функции void
DeactivateThread(); И void ActivateThread(struct Thread *) ;). Нас интересует только вопрос: что же мы должны сделать, чтобы переключить нити?
функция ThreadSwitch называется диспетчером или планировщиком (scheduler) и ведет себя следующим образом.

  • Она передает управление на следующую активную нить.
  • Текущая нить остается активна, и через некоторое время снова получит управление.
  • При этом она получит управление так, как будто ThreadSwitch представляла собой обычную функцию и возвратила управление в точку, из которой она была вызвана.

Очевидно, что функцию ThreadSwitch нельзя реализовать на языке высокого уровня, вроде С, потому что это должна быть функция, которая не возвращает [немедленно] управления в ту точку, из которой она была вызвана. Она вызывается из одной нити, а передает управление в другую. Это требует прямых манипуляций стеком и записью активизации и обычно достигается использованием ассемблера или ассемблерных вставок. Некоторые ЯВУ (Ada, Java, Occam) предоставляют примитивы создания и переключения нитей в виде специальных синтаксических конструкций.
Самым простым вариантом, казалось бы, будет простая передача управления на новую нить, например, командой безусловной передачи управления по указателю. При этом весь описатель нити (struct Thread) будет состоять только из адреса, на который надо передать управление. Беда только в том, что этот вариант не будет работать.
Действительно, каждая из нитей исполняет программу, состоящую из вложенных вызовов процедур. Для того чтобы нить нормально продолжила исполнение, нам нужно восстановить не только адрес текущей команды, но и стек вызовов (см. разд. Косвенно-регистровый режим со смещением). Поэтому мы приходим к такой архитектуре.

  • Каждая нить имеет свой собственный стек вызовов.
  • При создании нити выделяется область памяти под стек, и указатель на эту область помещается в дескриптор нити.
  • ThreadSwitch сохраняет указатель стека (и, если таковой есть, указатель кадра) текущей нити в ее дескрипторе и восстанавливает SP из дескриптора следующей активной нити (переключение стеков необходимо реализовать ассемблерной вставкой, потому что языки высокого уровня не предоставляют средств для прямого доступа к указателю стека (пример 8.1)).
  • Когда функция ThreadSwitch выполняет оператор return, она автоматически возвращает управление в то место, из которого она была вызвана в этой нити, потому что адрес возврата сохраняется в стеке.

Пример 8.1. Кооперативный переключатель потоков

Thread * thread_queue_head;
Thread * thread_queue_tail;
Thread * current_tread;
Thread * old__thread;
void TaskSwitch () { old_thread=current_thread; add_to_queue_tail(current_thread); current_thread=get_from_queue_head(); asm { .
move bx, old_thread
push bp
move ax, sp
move thread_sp[bx], ax
move bx, current_thread
move ax, rhread_sp[bx]
pop bp
}
return;
}

Если система программирования предполагает, что при вызове функции должны сохраняться определенные регистры (как, например, С-компиляторы для х86 сохраняют при вызовах регистры SI и DI (ESI/EDI в 1386)), то они также сохраняются в стеке. Поэтому предложенный нами вариант также будет автоматически сохранять и восстанавливать все необходимые регистры.
Понятно, что кроме указателей стека и стекового кадра, struct Thread должна содержать еще некоторые поля. Как минимум, она должна содержать указатель на следующую активную нить. Система должна хранить указатели на описатель текущей нити и на конец списка. При этом ThreadSwitch переставляет текущую нить в конец списка, а текущей делает следующую за ней в списке. Все вновь активизируемые нити также ставятся в конец списка. При этом список не обязан быть двунаправленным, ведь мы извлекаем элементы только из начала, а добавляем только в конец.
Часто в литературе такой список называют очередью нитей (thread queue) или очередью процессов. Такая очередь присутствует во всех известных автору реализациях многозадачных систем. Кроме того, очереди нитей используются и при организации очередей ожидания различных событий, например, при реализации семафоров Дейкстры.
Планировщик, основанный на Threadswitch, т. е. на принципе переключения по инициативе активной нити, используется в ряде экспериментальных и учебных систем. Этот же принцип, называемый кооперативной многозадачностью, реализован в библиотеках языков Simula 67 и Modula-2. MS Windows 3.x также имеют средство для организации кооперативного переключения задач — системный вызов GetNextEvent.
Часто кооперативные нити называют не нитями, а сопрограммами — ведь они вызывают друг друга, подобно подпрограммам. Единственное отличие такого вызова от вызова процедуры состоит в том, что такой вызов не ие-рархичен — вызванная программа может вновь передать управление исходной и остаться при этом активной.
Основным преимуществом кооперативной многозадачности является простота отладки планировщика. Кроме того, снимаются все коллизии, связанные с критическими секциями и тому подобными трудностями — ведь нить может просто не отдавать никому управления, пока не будет готова к этому.
С другой стороны, кооперативная многозадачность имеет и серьезные недостатки.
Во-первых, необходимость включать в программу вызовы Threadswitch усложняет программирование вообще и перенос программ из однозадачных или иначе организованных многозадачных систем в частности.
Особенно неприятно требование регулярно вызывать Threadswitch для вычислительных программ. Чаще всего такие программы исполняют относительно короткий внутренний цикл, скорость работы которого определяет скорость всей программы. Для "плавной" многозадачности необходимо вызывать Threadswitch из тела этого цикла. Делать вызов на каждом шаге Цикла нецелесообразно, поэтому необходимо будет написать код, похожий на приведенный в примере 8.2.

Пример 8.2. Внутрений цикл программы в кооперативно многозадачной среде

int counter; // Переменная-счетчик,
while(condition) {
// Вызывать ThreadSwitch каждые rate циклов.
counter++;
if (counter % rate == 0) ThreadSwitch();
.... // Собственно вычисления j
}

Условный оператор и вызов функции во внутреннем цикле сильно усложняют работу оптимизирующим компиляторам и приводят к разрывам конвейера команд, что может очень заметно снизить производительность. Вызов функции на каждом шаге цикла приводит к еще большим накладным расходам и, соответственно, к еще большему замедлению.
Во-вторых, злонамеренная нить может захватить управление и никому не отдавать его. Просто не вызывать ThreadSwitch, и все. Это может произойти не только из-за злых намерений, но и просто по ошибке.
Поэтому такая схема оказывается непригодна для многопользовательских систем и часто не очень удобна для интерактивных однопользовательских.
Почему-то большинство коммерческих программ для Win16, в том числе и поставлявшиеся самой фирмой Microsoft, недостаточно активно использовали вызов GetNextEvent. Вместо этого такие программы монопольно захватывали процессор и рисовали известные всем пользователям этой системы "песочные часы". В это время система никак не реагирует на запросы и другие действия пользователя кроме нажатия кнопки RESET или клавиш <CTRL>+<ALT>+<DEL>.
В-третьих, кооперативная ОС не может исполняться на симметричной многопроцессорной машине, а приложения, написанные в расчете на такую ОС, не могут воспользоваться преимуществами многопроцессорности.
Простой анализ показывает, что кооперативные многозадачные системы пригодны только для учебных проектов или тех ситуаций, когда программисту на скорую руку необходимо сотворить многозадачное ядро. Вторая ситуация кажется несколько странной — зачем для серьезной работы может потребоваться быстро сделанное ядро, если существует много готовых систем реального времени, а также общедоступных (freeware или public domain) в виде исходных текстов реализаций таких ядер?


Вытесняющая многозадачность

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

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

Этот механизм, называемый time slicing или разделение времени, реализован в микрокоде транспьютера и практически во всех современных ОС. Общим названием для всех методов переключения нитей по инициативе системы является термин вытесняющая (preemptive) многозадачность. Таким образом, вытесняющая многозадачность противопоставляется кооперативной, в которой переключение происходит только по инициативе самой задачи. Разделение времени является частным случаем вытесняющей многозадачности. В системах с приоритетами, вытеснение текущей задачи происходит не только по сигналам таймера, но и в случае, когда по каким-то причинам (чаше всего из-за внешнего события) активизируется процесс, с приоритетом выше, чем у текущего.
При этом вопрос выбора кванта времени является нетривиальной проблемой. С одной стороны, чрезмерно короткий квант приведет к тому, что большую часть времени система будет заниматься переключением потоков. С другой стороны, в интерактивных системах или системах реального времени слишком большой квант приведет к недопустимо большому времени реакции.
В системе реального времени мы можем объявить нити, которым надо быстро реагировать, высокоприоритетными и на этом успокоиться. Однако нельзя так поступить с интерактивными программами в многопользовательской или потенциально многопользовательской ОС, как UNIX на настольной машине х86 или Sun.
Из психологии восприятия известно, что человек начинает ощущать задержку ответа при величине этой задержки около 100 мс. Поэтому в системах разделенного времени, рассчитанных на интерактивную работу, квант обычно выбирают равным десяткам миллисекунд. В старых системах, ориентированных на пакетную обработку вычислительных задач, таких как ОС ДИСПАК на БЭСМ-6, квант мог достигать десятых долей секунды или даже секунд. Это повышает эффективность системы, но делает невозможной или, по крайней мере, неудобной — интерактивную работу. Многие современные системы подбирают квант времени динамически для разных классов планирования и приоритетов процесса.
Системы реального времени обычно имеют два класса планирования — реального и разделенного времени. Класс планирования, как правило, дается не отдельным нитям, а целиком процессам. Процессы реального времени не прерываются по сигналам таймера и могут быть вытеснены только активизацией более приоритетной нити реального времени. Нити реального времени высочайшего приоритета фактически работают в режиме кооперативной многозадачности. Зато нити процессов разделенного времени вытесняются и друг другом по сигналам таймера, и процессами реального времени по мере их активизации.
Вытесняющая многозадачность имеет много преимуществ, но если мы про сто будем вызывать описанный в предыдущем разделе ThreadSwitchпо прерываниям от таймера или другого внешнего устройства, то такое переключение будет непоправимо нарушать работу прерываемых нитей.
Действительно, пользовательская программа может использовать какой-тл из регистров, который не сохраняется при обычных вызовах. Поэтому, например, обработчики аппаратных прерываний сохраняют в стеке все используемые ими регистры. Кстати, если наша функция ThreadSwitch будет сохранять в стеке все регистры, то произойдет именно то, чего мы хотим. ThreadSwitch вызывается по прерыванию, сохраняет регистры текущей нити в текущем стеке, переключается на стек новой нити, восстанавливает из ее стека ее регистры, и новая нить получает управление так, будто и не теряла его.
Полный набор регистров, которые нужно сохранить, чтобы нить не заметила переключения, называется контекстом нити или, в зависимости от принятой в конкретной ОС терминологии, контекстом процесса. К таким регистрам, как минимум, относятся все регистры общего назначения, указатель стека, счетчик команд и слово состояния процессора. Если система использует виртуальную память, то в контекст входят также регистры диспетчера памяти, управляющие трансляцией виртуального адреса (пример 8.3).

Пример 8.3. Функция переключения контекста в ядре Linux/x86

/* Фрагмент файла \arch\i386\kernel\process.c.
* Сохранение и восстановление регистров общего назначения
* и сегментных регистров CS, DS и S3 осуществляется при входе в ядре *и при выходе из него соответственно. */
/*
* switch_to(х,у) должна переключать задачи с х на у *
* Мы используем fsave/fwait, поэтому исключения [сопроцессора]
* сбрасываются в нужный момент времени (пока вызов со стороны
* fsave или fwait обрабатывается), и не могут быть посланы
* другому процессу. Отложенное сохранение FP более не имеет
* смысла на современных ЦПУ и это многое упрощает (SMP и UP
* [uniprocessor, однопроцессорная конфигурация] теперь
* обрабатываются одинаково).
Раньше мы использовали аппаратное переключение
* контекста. Причина, по которой мы больше так не делаем
* становится очевидна, когда мы пытаемся аккуратно восстановиться
* из сохраненного состояния, которое стало недопустимым
* (в частности, висящие ссылки в сегментных регистрах).
* При использовании аппаратного переключения контекста нет способа
* разумным образом выйти из плохого состояния [контекста]. *
* То, что Intel документирует аппаратное переключение контекста как
* медленное — откровенная ерунда, этот код не дает заметного ускорения.
* Однако здесь есть некоторое пространство для улучшения, поэтому
* вопросы производительности могут рано или поздно оказаться актуальны.
* [В данном случае], однако, нам важнее, что наша реализация * обеспечивает большую гибкость.
*/ void __switch_to(struct task_struct *prev_p, struct task_struct *next_p)
(
struct thread_struct *prev = &prev_p->thread, Д *next = &next_p->thread;
struct tss_struct *tss = init_tss + smp_processor_id();
unlazy_fpu (prev__p) ;
/*
* Перезагрузить espO, LOT и указатель на таблицу страниц: */
tss->espO = next->espO;
/*
* Сохранить %fs и %gs. He нужно сохранять %ез и %ds,
* потому что при исполнении в контексте ядра это
* всегда сегменты ядра. */
asm volatile("movl %%fs,%0":"=m" (* (int *)&prev->fs)); asm volatile("movl %%gs,%0":"=m" (*(int *)&prev->gs));
* Восстановить Its и Ч
loadsegment(fs, next->fs); loadsegment(gs, next->gs);
/*
* Если это необходимо, перезагрузить отладочные регистры */ if (next->debugreg[7]){
loaddebug(next, 0};
loaddebug(next, 1);
loaddebug(next, 2);
loaddebug(next, 3);
/* не 4 и 5 */
loaddebug(next, 6);
loaddebug(next, 7);
if (prev->ioperm || next->iopenn) { if (next->ioperm) {
*
/*
* Копирование четырех линий кэша .... не хорошо, но
* и не так уж плохо. У кого-нибудь есть идея лучше?
* Оно воздействует только на процессы, использующие iopermO.
* [Размещение этих TSS в области 4K-tlb и игры с виртуальной
* памятью для переключения битовой маски ввода/вывода на
* самом деле неприемлемы.] */
memcpy(tss->io_bitmap, next->io_bitmap,
IO_BITMAP_SIZE*sizeof(unsigned long)); tss~>bitmap = IO_BITMAP_OFFSET; } else /*
* Смещение битовой маски, указывающее за пределы ограничителя "- - >
* порождает контролируемое SIGSEGV, если процесс пытается
* использовать команды обращения к портам. Первый вызов
* sys_ioperm() устанавливает битовую маску корректно. */
tss->bitmap = INVALID 10 BITMAP OFFSET;

Примечание
Планировщик должен полностью сохранять контекст процесса. Это значительно усложняет жизнь разработчикам процессоров: добавив процессору лишний регистр (как служебный, так и общего назначения), мы рискуем потерять совместимость со всеми ОС для нашего процессора, реализующими вытесняющую многозадачность. Наличие команд сохранения контекста не решает этой проблемы — ведь ОС должна выделять память под сохраняемый контекст, а для этого необходимо знать его размер.
Именно поэтому, в частности, процессоры SPARC и х86 реализуют "мультимедийные" расширения систем команд (групповые операции над 8-битными целыми числами) с использованием уже существовавших регистров арифметического сопроцессора, а не дополнительных регистров.

Как правило, оказывается неудобным сохранять контекст именно в стеке. Тогда его сохраняют в какой-то другой области памяти, чаще всего в дескрипторе процесса. Многие процессоры имеют специальные команды сохранения и загрузки контекста. Для реализации вытеснения достаточно сохранить контекст текущей нити и загрузить контекст следующей активной нити из очереди. Необходимо предоставить также и функцию переключения нитей по их собственной инициативе, аналогичную ThreadSwitch или, точнее, DeactivateThread.
Обычно вместо DeactivateThread система предоставляет высокоуровневые примитивы синхронизации, например семафоры или примитивы гармонического взаимодействия. Вызов DeactivateThread оказывается скрытым внутри таких высокоуровневых функций.
Вытесняющий планировщик с разделением времени ненамного сложнее кооперативного планировщика — и тот, и другой реализуются несколькими десятками строк на ассемблере. В работе (Прохоров 1990| приводится полный ассемблерный текст приоритетного планировщика системы VAX/VMS, занимающий одну страницу (автору неизвестно, не нарушает ли авторские права фирмы DEC публикация этого текста). Впрочем, планировщики, рассчитанные на многопроцессорные машины, часто бывают несколько сложнее (пример 8.4).

Пример 8.4. Планировщик Linux 2.5

 /*
* 'schedule!)' — функция планировщика. Это очень простой и
* приятный планировщик: он не совершенен, но несомненно работает
* в большинстве случаев.
* The goto is "interesting".
*
* ЗАМЕЧАНИЕ!! Задача 0 является 'пустой' задачей, которая вызывается
* когда ни одна другая задача не может выполняться. Она не может быть
* "убита" и не может "спать". Информация о состоянии
        в task[0] никогда
* не используется.
*/
asmlinkage void schedule(void)
{
struct schedule_data * sched_data;
struct task_struct *prev, *next, *p;
struct list_head *tmp;
int this__cpu, c;
if (!current->active_mm) BUG(); need_resched_back: prev = current;
        this_cpu = prev~>processor;
if (in_interrupt())
goto scheduling_in_interrupt;
release_kernel_lock(prev, this_cpu);
/* Выполнить административную работу здесь, пока мы не держим
* ни одной блокировки.
*/
if (softirq_active(this_cpu) & softirq_mask(this_cpu))
goto handle_softirq; handle_softirq_back:
/*
* 'shed data" защищена неявно, тем фактом, что мы можем исполнять
* только один процесс на одном ЦПУ. */
sched__data = & aligned_data[this_cpu].schedule_data;
spin_lock_irq (&runqueue__lock) ;
/* Переместить исчерпанный процесс RR в конец [очереди] */ if (prev->policy
        == SCHED_RR)
goto move_rr_last; pove_rr_back:
switch (prev->state) { case TASKJLNTERRUPTIBLE:
if (signal_pending (prev) ) { prev->state = TASK_RUNNING; break; }
        default:
dei_f rom_runqueue (prev) ; case TASK_RUNNING: ;
}
prev->need_resched = 0;
/*
* это собственно планировщик: */
repeat_schedule : /*
* Выбрать процесс по умолчанию. . . */
next = idle_task(this_cpu) ; с = -1000;
if (prev->state == TASK_RUNNING) goto still_running;
still_running_back :
list_for_each (tmp, srunqueue_head) {
p = list_entry (tmp, struct task_struct, run_list) ; if (can_schedule
        (p, this_cpu) ) {
int weight = goodness (p, this_cpu, prev->active_mm) ; if (weight >
        c)
с = weight, next = p;
/* Следует ли перевычислить счетчики? */
if (!c)
goto recalculate; /*
* с этого момента ничто ке может помешать нам
* переключиться на следующую задачу, отметить этот
* факт в sched_data. */
sched_data->curr = next; tifdef CONFIG_SMP
riext->has_cpu = I;
next->processor = this_cpu; lendif
spin_unlock__irq (&runqueue_lock) ;
if (prev == next) goto same_process;
ttifdef CONFIG_SMP /*
* Поддерживать значение 'last schedule' для каждого процесса
* (его необходимо пересчитать лаже если мы планируем тот же
* процесс). Сейчас это значение используется только в SMP, и оно
* приблизительно, поэтому мы не обязаны поддерживать его,
* пока захвачена блокировка runqueue. */
sched_data->last_schedule = get_cycles();
/*
* Мы снимаем блокировку планировщика рано (это глобальная
* блокировка), поэтому мы должны защитить предыдущий процесс
* от повторного планирования во время switch_to(). */
ttendif /* CONFIG_SMP */
kstat.context_swtch++; /*
* Переключение контекста воздействует на три процесса:
prev
.. ==> (last => next)
* Это 'prev' , 'далеко предшествующий' размещенному в стеке 'next1,
* но функция switch_to() устанавливает prev на (только что
* работавший) процесс 'last'.
* Описание несколько запутанно, но не лишено глубокого смысла,
*/
prepare_to_switch ( ) ;
{
struct mm struct *mm = next->mm;
struct mm_struct *oldmm = prev->active_mm;
if ( !mmi (
if (next->active_mm) BUG ( ) ;
next->active_mm = oldmm;
atomic_inc (&oldmm->mra_count) ;
enter_lazy_tlb (oldmm, next, this_cpu) ; } else {
if (next->active_mm != mm) BUG ( ) ;
switch_mm(ol<±nm, mm, next, this__cpu) ;
if ( !prev->irin) (
prev->active_mm = NULL; mmdrop (oldmm) ;
/*
* Этот оператор только переключает состояние регистров
* и стека. */
switch_to(prev, next, prev); __schedule_tail(prev);
same_process:
teacquire_kernel__lock (current) ; if (current->need_resched) goto
        need reached back;
recalculate: {
struct task_struct *p;
spin_unlock_irq (&runqueue_lock) ;
read_lock (&tasklist_lock) ;
for_each_task (p)
p->counter = (p->counter » 1) + NICE_TO_TICKS (p->nice)
read_unlock (&tasklist__lock) ;
spin_lock_irq (&runqueue_lock) ; } goto repeat_schedule;
still_running:
с = goodness (prev, this_cpu, prev->active_mm) ; next = prev;
goto still_running_back;

handle_sof tirq: do_softirq ( ) ; goto handle_softirq_back;
move_rr_last :
if ( !prev->counter) (
prev->counter = NICE_TO_TICKS (prev->nice) ; move_last_runqueue
        (prev) ; } goto move_rr_back;
scheduling_in_interrupt :
printk ("Scheduling in interrupt\n") ;
BUG ( ) ;
return;

Контексты современных процессоров
У современных процессоров, имеющих десятки регистров общего назначения и виртуальную память, размер контекста процесса измеряется сотнями байтов. Например, у процессора VAX контекст процессора состоит из 64 32-разрядных слов, т. е. 256 байт. При этом VAX имеет только 16 регистров общего назначения, а большая часть остальных регистров так или иначе относится к системе управления виртуальной памятью.
У микропроцессоров SPARC, имеющих регистровый файл объемом до нескольких килобайтов, контекст, на первый взгляд, должен быть чудовищного размера. Однако программе одновременно доступны лишь 32 регистра общего назначения, 24 из которых образуют скользящее по регистровому файлу окно. Благодаря этому факту, контекст процессора SPARC состоит только из первых восьми регистров общего назначения и служебных регистров. Регистровое окно новой нити выделяется в свободной области регистрового файла, а его передвижение обрабатывается при помощи исключений заполнения и очистки окна.
Если в системе всего несколько активных процессов, может оказаться так, что их регистровые окна постоянно "живут" в регистровом файле, поэтому объем данных, реально копируемых при переключении нитей, у SPARC не больше, чем у CISC-процессоров с небольшим количеством регистров общего назначения. Впрочем, и у SPARC, и у CISC-процессоров основную по объему часть контекста процесса составляют регистры диспетчера памяти.
На этом основано преимущество транспьютера перед процессорами традиционных и RISC-архитектур. Дело в том, что транспьютер не имеет диспетчера памяти, и у него вообще очень мало регистров. В худшем случае, при переключении процессов (в транспьютере, как и в старых ОС, нити называются процессами) должно сохраняться 7 32-разрядных регистров. В лучшем случае сохраняются только два регистра — счетчик команд и статусный регистр. Кроме того, перенастраивается регистр wptr, который выполняет по совместительству функции указателя стека, базового регистра сегмента статических данных процесса и указателя на дескриптор процесса.
Транспьютер имеет три арифметических регистра, образующих регистровый стек. При этом обычное переключение процессов может происходить только, когда этот стек пуст. Такая ситуация возникает довольно часто; например, этот стек обязан быть пустым при вызовах процедур и даже при условных и безусловных переходах, поэтому циклическая программа не может не иметь точек, в которых она может быть прервана. Упомянутые в предыдущем разделе команды обращения к линкам также исполняются при пустом регистровом стеке. Поэтому, оказывается достаточно перезагрузить три управляющих регистра, и мы передадим управление следующему активному процессу.
Операция переключения процессов, а также установка процессов в очередь при их активизации полностью реализованы на микропрограммном уровне.
Деактивизация процесса происходит только по его инициативе, когда он начинает ожидать сигнала от таймера или готовности линка. При этом процесс исполняет специальную команду, которая устанавливает его в очередь ожидающих соответствующего события, и загружает контекст очередного активного процесса. Когда приходит сигнал таймера или данные по линку, то также вызывается микропрограмма, которая устанавливает активизированный процесс в конец очереди активных.
У транспьютера также существует микропрограммно реализованный режим разделения времени, когда по сигналам внутреннего таймера активные про цессы циклически переставляются внутри очереди. Такие переключения ка уже говорилось, могут происходить только, когда регистровый стек текущег процесса пуст, но подобные ситуации возникают довольно часто.
Кроме обычных процессов в системе существуют так называемые высокопри оритетные процессы. Если такой процесс получает управление в результате внешнего события, то текущий низкоприоритетный процесс будет прерван независимо от того, пуст его регистровый стек или нет. Для того чтобы при этом не разрушить прерванный процесс, его стек и весь остальной контекст записываются в быструю память, расположенную на кристалле процессора. Это и есть тот самый худший случай, о котором говорилось ранее. Весь цикл переключения занимает 640нс по сравнению с десятками и, порой, сотнями микросекунд у традиционных процессоров [INMOS 72 TRN 203 02, Харп 1993].
Благодаря такой организации транспьютер не имеет равных себе по времени реакции на внешнее событие. На первый взгляд, микропрограммная реализация такой довольно сложной конструкции, как планировщик, снижает гибкость системы. В действительности, в современных системах планировщики имеют довольно стандартную структуру, и реализация, выполненная в транспьютере, очень близка к этому стандарту, известному как микроядро (microkernel) (см. разд. Монолитные системы или системы с микроядром).


Планировщики с приоритетами

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

Приоритеты процессов в транспьютере
Простейшим случаем такой организации является транспьютер, имеющий две очереди. В транспьютере при этом планировщик не может отобрать управление у высокоприоритетного процесса. В этом смысле низкоприоритетные задачи вынуждены полагаться на "порядочность" высокоприоритетных, т. е. на то, что те освобождают процессор в разумное время.
Отчасти похожим образом организован планировщик системы VAX/VMS. Он имеет 32 приоритетных очереди, из которых старшие 16 называются процессами реального времени, а младшие — разделенного.
При этом процесс реального времени исполняется всегда, когда готов к исполнению, и в системе нет более приоритетных процессов. ОС и процессы разделенного времени также вынуждены полагаться на его порядочность. Поэтому привилегия запускать такие процессы контролируется администратором системы.

Легко понять, что разделение времени обеспечивает более или менее справедливый доступ к процессору для задач с одинаковым приоритетом. В случае транспьютера, который имеет только один приоритет и, соответственно, одну очередь для задач разделенного времени, этого оказывается достаточно. Однако современные ОС как общего назначения, так и реального времени, имеют много уровней приоритета. Для чего это нужно и как достигается в этом случае справедливое распределение времени процессора?
Дело в том, что в системах такого типа приоритет процессов разделенного времени является динамической величиной. Он изменяется в зависимости от того, насколько активно задача использует процессор и другие системные ресурсы.
В системах с пакетной обработкой, когда для задачи указывают верхнюю границу времени процессора, которое она может использовать, часто более короткие задания идут с более высоким приоритетом. Кроме того, более высокий приоритет дают задачам, которые требуют меньше памяти. В системах разделенного времени часто оказывается сложно заранее определить время, в течение которого будет работать задача. Например, вы отлаживаете программу. Для этой цели вы запускаете символьный отладчик и начинаете исполнять вашу программу в пошаговом режиме. Естественно, что вы не можете даже приблизительно предсказать как астрономическое время отладки, так и время центрального процессора, занятое при этом. Поэтому обычно такие системы не ограничивают время исполнения задачи и другие ре-сУрсы и вынуждены прогнозировать поведение программы в будущем на основании ее поведения в прошлом.
Так, если программа начала вычисления, не прерываемые никакими обращениями к внешней памяти или терминалу, мы можем предположить, что она будет заниматься такими вычислениями и дальше. Напротив, если программа сделала несколько запросов на ввод-вывод, следует ожидать, что она и дальше будет активно выдавать такие запросы. Предпочтительными для системы будут те программы, которые захватывают процессор на короткое время и быстро отдают его, переходя в состояние ожидания внешнего или внутреннего события. Таким процессам система стремится присвоить более высокий приоритет. Если программа ожидает завершения запроса на обращение к диску, то это также выгодно для системы — ведь на большинстве машин чтение с диска и запись на него происходят параллельно с работой центрального процессора.
Таким образом, система динамически повышает приоритет тем заданиям, которые освободили процессор в результате запроса на ввод-вывод или ожидание события и, наоборот, снижает тем заданиям, которые были сняты по истечении кванта времени. Однако приоритет не может превысить определенного значения — стартового приоритета задачи.
При этом наиболее высокий приоритет автоматически получают интерактивные задачи и программы, занятые интенсивным вводом-выводом. Во время выполнения таких программ процессор часто оказывается свободен, и управление получают низкоприоритетные вычислительные задания. Поэтому системы семейства UNIX и VAX/VMS даже при очень высокой загрузке обеспечивают как приемлемое время реакции для интерактивных программ, так и приемлемое астрономическое время исполнения для пакетных заданий. Благодаря наличию класса планирования реального времени, эти же ОС можно использовать и в задачах реального времени таких, как управление атомным реактором.
Система VMS повышает приоритет также и тем задачам, которые остановились в ожидании подкачки страницы. Это сделано потому, что если программа несколько раз выскочила за пределы своего рабочего набора (т. е. потребовала еще страницу, когда ее рабочий набор весь занят), то она, скорее всего, будет делать это и далее, а процессор на время подкачки она освобождает.
Нужно отметить, что процесс разделенного времени может повысить свой приоритет до максимального в классе разделения времени, но никогда не сможет стать процессом реального времени. А для процессов реального времени динамическое изменение приоритетов обычно не применяется.

Управление приоритетами во OS-9
Любопытно реализовано динамическое изменение приоритета в OS-9. В этой ОС каждый процесс имеет статически определенный приоритет и возраст
(age) — количество просмотров очереди с того момента, когда этот процесс в последний раз получал управление. Обе эти характеристики представлены 16-разрядными беззнаковыми числами. Процесс может повысить или понизить свой приоритет, исполнив соответствующий системный вызов, но система по собственной инициативе никогда не меняет его. При этом управление каждый раз получает процесс с наибольшей суммой статического приоритета и динамически изменяющегося возраста (рис. 8.1 — в изображенной на рисунке ситуации будет выбран процесс 12). Если у двух процессов такие суммы равны, то берется процесс с большим приоритетом. Если у них равны и приоритеты, то берется тот, который оказался ближе к началу очереди.

Рис. 8.1. Приоритеты и возраст в OS/9

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


Монолитные системы и системы с микроядром

Не бывет монолитных программ, бывают плохо структурированные
Реплика с семинара по ООП

Ходовая часть танка работает в экстремальных условиях и с трудом поддается модернизации
Реплика в эхоконференции RU.WEAPON сети
ФИДО

Исполняя системный вызов, пользовательская программа передает управление ядру. С понятием ядра — комплекса программ, исполняющихся в привилегированном (системном) режиме процессора, — мы уже сталкивались в разд. Системы с базовой виртуальной адресацией и главе 5. Практически во всех современных системах ядро представляет собой единый процесс — единое адресное пространство. Но организация взаимодействия между нитями этого процесса в различных системах устроена по-разному.
Три основные группы нитей, исполняющихся в режиме ядра, — это, во-первых, обработчики прерываний, во-вторых — обработчики системных вызовов со стороны пользовательских процессов и, в-третьих, — нити, исполняющие различные внутренние по отношению к системе работы, например, управление дисковым кэшем. В документации фирмы Sun нити этих трех групп в ядре Solaris называются, соответственно, исполняющимися в контексте прерывания (interrupt context), в пользовательском контексте (user context) и в контексте ядра (kernel context, или контексте системы) [docs.sun.com 805-7378-10].
Далее мы будем называть последние две категории нитей, соответственно, пользовательскими и системными, хотя и те, и другие исполняют системный код в системном адресном пространстве.
Обработчики прерываний всегда представляют собой особую статью — система практически никогда не имеет контроля над тем, когда возникают внешние события, зато практически всегда обязана обрабатывать эти события по мере их появления. Поэтому обработчики прерываний получают управление по необходимости.
В то же время, порядок получения управления пользовательскими и системными нитями в ряде ситуаций находится под контролем системы. Планировшик является частью ядра и модули системы вольны включать его и выключать по мере необходимости. Практически всегда его выключают на время работы обработчика прерывания, но некоторые системы делают это и при активизации других нитей ядра.
Полное выключение планировщика на время работы ядра фактически означает что система не реализует внутри ядра вытесняющей многозадачности. Системные и пользовательские нити в этом случае являются сопрограммами а не нитями в полном смысле этого слова.
Эта архитектура, называемая монолитным ядром, привлекательна примерно тем же, чем привлекательна кооперативная многозадачность на пользовательском уровне: любой модуль ядра может потерять управление лишь в двух случаях — при исполнении обработчика прерывания или по собственной инициативе. Благодаря этому разработчик модуля может не беспокоиться о критических секциях и прочих малоприятных аспектах доступа к разделяемым данным (кроме, разумеется, случаев, когда разделяет данные с обработчиком прерывания).
Платить за эти преимущества приходится значительными сложностями при переносе системы на многопроцессорные машины и невозможностью реализовать режим реального времени. Действительно, код ядра, написанный в расчете на кооперативную многозадачность, не может быть реентерабельным, поэтому такая система в то время, когда исполняется какая-то из ее нитей, не может обрабатывать системные вызовы. Следовательно, она не может иметь пользовательские процессы с приоритетом выше, чем у нитей ядра — а именно такие процессы нужны для поддержки приложений реального времени.

Примечание
Те из читателей, кто когда-либо пытался реализовать обработку аппаратных прерываний под MS/DR DOS, должны быть хорошо знакомы с этой проблемой. Вопреки хакерскому фольклору, нереентерабельность ядра DOS связана вовсе не с переустановкой указателя стека при входе в обработчик прерывания 21h, а именно с тем, что ядро работает с разделяемыми данными, но не предоставляет собственных средств для взаимоисключения доступа к ним.

Впрочем, если нам не требуется реальное время и перед нами не стоит задача обеспечить равномерное распределение системных нитей между процессорами симметрично многопроцессорной машины, монолитное ядро вполне приемлемо. Большинство систем семейств Unix (фактически, все широко распространенные системы этого семейства, кроме System V R4) и Win32, OS/2 и ряд других систем общего назначения более или менее успешно ее используют.
Альтернативной монолитным ядрам является микроядро. Микроядерные системы реализуют вытесняющую многозадачность не только между пользовательскими процессами, но и между нитями ядра.

Микроядро QNX
Классическая реализация микроядра, QNX, состоит из вытесняющего планировщика и примитивов гармонического межпоточного взаимодействия, средств для обмена сообщениями send и receive. Эти примитивы, конечно же, сами по себе не могут быть реализованы реентерабельным образом, однако они просты, содержат очень мало кода, исполняются быстро, и на время их исполнения система просто запрещает прерывания.
Все остальные модули системы с точки зрения микроядра представляют соб полностью равноценные нити. То, что некоторые из этих нитей исполняю в пользовательском, а другие — в привилегированном режиме доступа микC" ядру совершенно неинтересно и не влияет на их приоритет и класс планировани QNX разрабатывался для приложений реального времени, в том числе и лл использования во встраиваемых микропроцессорных системах; но, благодап компактности и фантастической производительности, эта ОС иногда заменяв системы общего назначения. Микроядро QNX действительно заслуживает на звания микро, поскольку занимает всего 12 килобайт кода и полностью входит в кэш первого уровня даже старых процессоров архитектуры х86. Все остальные модули ядра — драйверы внешних устройств, файловых систем, сетевых протоколов, имитация API систем семейства Unix — динамически загружаются и выгружаются и не являются обязательными (если, конечно, приложение не требует сервисов, предоставляемых этими модулями). Благодаря этому ОС может использоваться во встраиваемых приложениях с весьма небольшим объемом ПЗУ.

Микроядро транспьютера
Другим примером классического микроядра является транспьютер. Микропрограммно реализованное микроядро транспьютера содержит планировщик с двумя уровнями приоритета и средства для передачи данных по линкам.

Микроядро Unix SVR4
Другие системы микроядерной архитектуры, например Unix System \/ Release 4.x (на этом ядре построены такие ОС, как Sun Solaris, SCO UnixWare, SGI Irix), предоставляют нитям ядра гораздо больше примитивов межпроцессного взаимодействия — в частности, мутексы. Ядро у этих систем в результате получается не таким уж "микро", но нашему определению это никоим образом не противоречит.
Важно подчеркнуть, что приведенное определение не имеет отношения к ряду других критериев, которые иногда (например, в дискуссиях в публичных компьютерных сетях, а нередко и в публикациях в более или менее серьезных журналах) ошибочно принимают за обязательные признаки микроядерной архитектуры. Отчасти эта путаница, возможно, создавалась целенаправленно, потому что в середине 90-х "микроядро" стало популярным маркетинговым слоганом, и поставщикам многих ОС монолитной или эклектичной архитектуры захотелось получить свою долю выгоды от возникшей шумихи.
Способ сборки ядра (динамическое или статическое связывание ядра с дополнительными модулями) и возможность динамической загрузки и выгрузки модулей без перезагрузки системы к микроядерности не имеют никакого отношения. Вполне микроядерная SCO UnixWare по умолчанию предлагает собирать ядро в единый загрузочный файл /stand/unix (хотя, впрочем, и поддерживает динамическую загрузку модулей). Напротив, не то, что монолитная, а кооперативно многозадачная Novell Netware замечательным образом умеет загружав и выгружать на ходу любые модули, в том числе и драйверы устройств (выгружать, разумеется, лишь при условии, что модуль никем не используется).
Один из корней этих заблуждений состоит в том, что в большинстве других контекстов антонимом "монолитной" архитектуре считается архитектура модульная. Таким образом, любой признак, свидетельствующий о том, что ядро ОС имеет модульную структуру, считается признаком микроядерности. В данном случае, однако, дихотомия "монолитность/микроядерность" говорит не о наличии или отсутствии в ядре более или менее автономных модулей или подсистем, а о принципах взаимодействия между этими модулями или подсистемами или, точнее, об одном аспекте этого взаимодействия: о том, что в монолитных ядрах взаимодействие происходит синхронно или преимущественно синхронно, а в микроядре — асинхронно.
Совсем уж наивно было бы отказывать Solaris в праве называться микроядерным на том основании, что файл /kernel/genunix у этой системы имеет размер около 900 килобайт. Ведь, кроме собственно микроядра — планировщика нитей и примитивов взаимодействия между ними — этот файл содержит также диспетчер системных вызовов, систему динамической подгрузки других модулей ядра (см. разд. Загрузка самой ОС) и ряд других обязательных подсистем.

Микроядро концептуально очень привлекательно, но предъявляет к разработчикам модулей ядра известные требования. Например, в документе [docs.sun.com 805-7378-10] основное из этих требований — не забывать о том, что ядро Solaris многопоточное, и любая из нитей ядра может быть в любой момент вытеснена [практически любой] другой нитью — высказывается на второй странице, а выводам, которые из этого следуют, посвящена целая глава.
При разработке системы с нуля это само по себе не представляет проблемы, но если мы хотели бы обеспечить совместимость с драйверами внешних устройств и другими модулями ядра предыдущих версий ОС...
Из материала предыдущей главы легко понять, что код, рассчитанный на работу в однопоточной среде или среде кооперативной многозадачности, при переносе в многопоточную среду нуждается в значительной переработке, а нередко и в перепроектировании. Таким образом, сделать из монолитной (пусть даже модульной) системы микроядерную практически невозможно.
Следует учитывать, что требование поддержки многопроцессорных машин Или приложений реального времени часто предъявляется к разработчикам через много лет после того, как были приняты архитектурные решения. о этой ситуации разработчики часто не переходят на микроядерную архитектуру полностью, а создают архитектуру гибридную (или, если применить более эстетский термин, эклектичную).
Действительно, как говорилось ранее, в чистом микроядре взаимодействия Происходят асинхронно, а в чистом монолитном ядре — синхронно.
Если некоторые из взаимодействий происходят асинхронно (что например, в многопроцессорной машине), то мы можем сказать, что система частично микроядерная. Если же некоторые из взаимодействий обязательно синхронны, мы, наверное, вынуждены будем признать, что цаи система частично монолитная, как бы странно это ни звучало.
В зависимости от того, какого рода взаимодействия преобладают, мы може выстроить целый спектр более или менее монолитных (и, напротив, боле или менее микроядерных) архитектур. На практике, большинство современных ОС общего назначения имеют гибридную архитектуру, которая не является микроядерной, и в то же время не может быть классифицирована как монолитная. Многие из архитектур и, во всяком случае, многие из ключевых принципов взаимодействия между модулями современных операционных систем были разработаны еще до того, как появилось само слово "микроядро". При этом некоторые из этих взаимодействий имеют синхронный, а некоторые — особенно взаимодействие с драйверами внешних устройств — асинхронный характер.
Во многих современных ОС широко применяется взаимодействие драйверов с остальной системой посредством очередей запросов (или событий). Такое взаимодействие отчасти стирает различия между синхронным и полностью асинхронным межмодульным взаимодействием.


Глава 9. Внешние устройства

  • Внешние устройства
  • Доступ к внешним устройствам
  • Простые внешние устройства
  • Порты передачи данных
  • Шины
  • Устройства графического вывода
  • Запоминающие устройства прямого доступа
    • Производительность жестких дисков
    • Дисковые массивы
    • Сети доступа к дискам


Внешние устройства

Митрофан. Эта? Прилагательна.
Правдин. Почему ж?
Митрофан. Потому что она приложена ксвоему месту. Вон у чулана шеста неделя дверь стоит еще не навешана:так та покамест существительна
Д. Фонвизин

Все без исключения прилохсения вычислительных систем, так или иначе, связаны с использованием внешних, или периферийных устройств. Даже чисто вычислительные задачи нуждаются в устройствах для ввода исходных данных и вывода результата. Без преувеличения можно сказать, что процессор, не имеющий никаких внешних устройств, абсолютно бесполезен.
У вычислительных систем первых поколений набор периферийных устройств часто исчерпывался упомянутыми устройствами для ввода исходных данных и вывода результата вычислений, поэтому до сих пор модули ОС, работающие с периферией, называют подсистемой ввода-вывода (input/output subsystem). У большинства современных компьютеров набор внешних устройств весьма обширен, и функции многих из них не могут или лишь с определенной натяжкой могут быть описаны как ввод и вывод.
С функциональной точки зрения внешние устройства, подключаемые к современным компьютерам, можно разделить на следующие категории (приведенную классификацию вряд ли можно считать исчерпывающей, а порядок перечисления не является критерием важности данного типа устройств).

  • Устройства внешней памяти, которые в свою очередь, можно разделить на два класса.
    • Устройства памяти с произвольным доступом, главным образом магнитные диски. К этому же классу относятся дискеты, магнитооптические и оптические диски и практически не применяемые в настоящее время магнитные барабаны. Удачным универсальным обозначением для этого класса устройств является принятое в документации фирмы IBM сокращение DASD (Direct Access Storage Device — запоминающее устройство прямого доступа).
    • Устройства памяти с последовательным доступом. В основном, это лентопротяжные устройства (стриммеры и др.).
  • Сетевые и телекоммуникационные устройства.
  • Устройства алфавитно-цифрового ввода-вывода: печатающие устройства телетайпы, текстовые терминалы.
  • О Устройства звукового ввода-вывода.
  • Устройства графического ввода-вывода: сканеры или видеодекодеры (ввод), графические дисплеи, плоттеры, графические принтеры или видеокодеры (вывод).
  • Позиционные устройства ввода: мыши, планшеты-дигитайзеры, световые перья и т. д.
  • Сенсорные и исполнительные устройства управляющих систем.

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


Доступ к внешним устройствам

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

Рис. 9.1. Подключение внешнего устройства к шине

Два основных подхода к адресации этих регистров — это отдельное адресное пространство ввода-вывода и отображенный в память ввод-вывод (memory-mapped I/O), когда память и регистры внешних устройств размещаются в одном адресном пространстве. В первом случае для обращения к регистрам Устройств используются специальные команды IN и OUT. Во втором случае Могут использоваться любые команды, способные работать с операндами в памяти. Как правило, даже в случае раздельных адресных пространств, для обмена данными с памятью и внешними устройствами процессор использу-ет одни и те же шины адреса и данных, но имеет дополнительный сигнал адресной шины, указывающий, какое из адресных пространств используется в данном конкретном цикле.
Любопытный гибридный подход, сочетающий преимущества обоих вышеназванных, предоставляют микропроцессоры с системой команд SPARC v9 У этих процессоров команды имеют поле, служащее селектором адресного пространства. Этот селектор, в частности, может использоваться для выбора адресного пространства памяти или ввода-вывода. Благодаря этому, с одной стороны, можно применять для работы с регистрами портов любые команды работы с памятью, как при отображенном в память вводе-выводе, и в то же время полностью задействовать адресное пространство памяти.
Два основных подхода к выделению адресов внешним устройствам — это фиксированная адресация, когда одно и то же устройство всегда имеет одни и те же адреса регистров, и географическая адресация, когда каждому разъему периферийной (или системной, если внешние устройства подключаются непосредственно к ней) шины соответствует свой диапазон адресов (рис. 9.2). Географически можно распределять не только адреса регистров, но и другие ресурсы — линии запроса прерывания, каналы ПДП.

Рис. 9.2. Фиксированная и географическая адресация

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


Простые внешние устройства

У кошки четыре ноги -
Вход, выход, земля и питание

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

Рис. 9.3. Принципиальная схема ТТЛ-совместимых выходных каскадов порта вывода (здесь и далее Vss — высокое напряжение питания, Vdd — ноль)

Поскольку и внутри процессора для представления нулей и единиц также используется высокое и низкое напряжение, порт полезен прежде всего тем, что имеет регистр — чтобы удержать напряжение на выходе, npouecconv достаточно один раз установить значение регистра.
Кроме того, польза от порта состоит в том, что поддержание напряжени на выходе может потребовать пропускания через этот выход тока. Порты современных микроконтроллеров имеют внутреннее сопротивление около 200 Ом и способны без вреда для себя пропустить через вывод ток до 25 мА. Выводы шин адреса и данных микропроцессоров, как правило, рассчитаны на гораздо меньшие токи. Кроме того, порты часто имеют встроенные механизмы защиты от короткого замыкания, статического электричества и т. д.
Применения порта вывода многообразны. Например, к нему можно присоединить светодиод и получить лампочку, миганием которой можно программно управлять (такие диоды часто используются при отладке программ для микроконтроллеров — вместо диагностической печати).

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

Впрочем, сигналы, порожденные с помощью вышеперечисленных приемов, будут состоять не только из сигнала заданного тембра и частоты, по и из гармоник тактовой частоты. Если последняя частота достаточно высока, с этим можно смириться в надежде на то, что динамик не сможет се воспроизвести, а слушатель — уловить.
Кроме того, к выводу порта можно присоединить внешнюю цифровую, аналоговую или электромеханическую схему (например, шаговый электродвигатель), которая будет выполнять какую-то полезную работу. Таким образом, порт вывода, как правило, не является внешним устройством сам по себе, а служит интерфейсом между микропроцессором и собственно внешним устройством.
Другое столь же простое устройство — это порт ввода. Порт ввода также состоит из регистра и нескольких входных линий, соответствующих битам реги~ стра (рис. 9.4). Бит регистра имеет значение 0, если на вход подано низко6 напряжение (точнее говоря, если во время последнего цикла тактового генератора порта на вход было подано низкое напряжение) и, наоборот, единицу — если высокое. Понятно, что напряжение практически никогда не соответствует в точности 0 или 5 В, поэтому в спецификациях портов ввода всегда указывают диапазон напряжений, которые считаются нулем (например, от 0 до 0.2 В) и единицей (например, от 4.5 до 5 В), для промежуточных же напряжений значение соответствующего бита не определено (а на практике определяется случайными факторами). Регистр порта ввода часто называют регистром-защелкой (latch register), потому что основная его функция — зафиксировать напряжения на входах в определенный момент времени и передать их центральному процессору в виде однозначно (пусть и негарантированно правильно) определенных значений.

Рис. 9.4. Порт ввода

Разработчики микросхем часто совмещают входы портов ввода и выходы портов вывода, создавая таким образом комбинированное устройство — порт ввода-вывода. Такое устройство должно быть существенно сложнее, чем простая комбинация порта ввода и порта вывода. Если порт вывода пытается установить на определенном выходе высокое напряжение, а другое Устройство пытается установить низкое, в соответствии с законом Ома это приведет к возникновению электрического тока. Внутреннее сопротивление типичного мпкроконтроллерного порта составляет около 200 Ом. При разности напряжений в 5 В это соответствует упоминавшемуся выше предельно Допустимому току в 25 мА: таким образом, устройство с нулевым внутренним сопротивлением все-таки может выставить ноль, но попытка сделать это на нескольких линиях одновременно приведет к перегрузке схем питания контроллера. В любом случае, большие токи приводят к потерям энергии и разогреву схемы, поэтому без крайней необходимости их лучше избегать. Если мы хотим использовать одни и те же "ноги" микросхемы как для ввода, так и для вывода, мы должны иметь возможность контролируемого включения выходных каскадов порта вывода.
Отключаемые выходные каскады называются тристабильными, а третье, отключенное, состояние выхода — высокоимпедансным. Тристабильные выходы используются не только для реализации двунаправленных контактов микросхем, но и для подключения устройств к шине: устройство, перешел шее в третье состояние, освобождает шину и позволяет какому-то другому задатчику выставлять на ней свои данные. Для управления переводом выходов в высокоимпедансное состояние, порт ввода-вывода должен иметь еще один регистр, называемый регистром или маской направления данных (data direction register) (рис. 9.5).

Рис. 9.5. Принципиальная схема порта ввода-вывода

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


Порты передачи данных

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

Рис. 9.6. Временная диаграмма стробируемого порта

Передача стробирующего сигнала требует прокладки дополнительных проводов, но с реализационной точки зрения гораздо проще совмещения синхросигнала и данных и поэтому широко применяется в самых разнообразных ситуациях. Большинство стробируемых портов асимметричны: одно из устройств, ведущий (master — хозяин), генерирует стробовый сигнал, а второе ведомый (slave — раб), пользуется этим сигналом для приема или передачи. Нередко вместо одиночного строба используется несколько различных сиг налов — например, один сигнал выставляется передатчиком и сообщает чт следующая порция данных готова, а второй сигнал — приемником и сооб шает, что приемник принял эти данные и готов принять следующие Дополнительные сигналы могут также решать вопрос о том, какое из устройств в данном цикле будет приемником, а какое — передатчиком.
При асинхронном обмене данными передающее устройство посылает специальный стартовый символ, сигнализирующий о том, что сейчас пойдут данные, и с фиксированным интервалом выставляет на своих выходах символы данных. Передаваемый за один прием блок данных обычно невелик по объему — ведь необходимо считаться с опасностью того, что часы приемника и передатчика, посредством которых они отмеряют интервал между последовательными порциями данных, недостаточно точны и могут разойтись. Обычно блок данных состоит из фиксированного количества символов и называется кадром или фреймом (frame). Кадр обычно завершается одним или несколькими стоповыми символами. Не обнаружив этих символов (или обнаружив на месте этих символов неверные значения), приемник может понять, что его часы все-таки разошлись с часами передатчика.
Асинхронная передача позволяет сэкономить на проводах (не требуется стробирующих сигналов) и при этом избежать сложных способов кодирования, характерных для совмещенной синхронной передачи, однако стартовые и стоповые символы составляют значительную часть потока передаваемых данных и создают ощутимые накладные расходы.
Кроме того, при передаче большого объема данных в виде плотно следующих друг за другом кадров велика опасность, что приемник потеряет заголовок очередного кадра и не сможет восстановить структуру потока, поэтому многие асинхронные протоколы требуют паузы между последовательными кадрами.
Асинхронная передача данных удобна в ситуациях, когда объем передаваемых данных невелик, а потребность в их передаче возникает в труднопредсказуемые моменты. Обычно асинхронные порты работают на небольших скоростях, не более нескольких килобит в секунду.
Изохронная передача данных по идее напоминает асинхронную, с тем лишь отличием, что при обмене данными приемник и передатчик пользуются вы-сокостабильными, но независимыми — при использовании одного тактового генератора получится синхронная передача — тактовыми генераторами, и благодаря этому могут обмениваться кадрами большого размера. В идеале, изохронная передача сочетает преимущества синхронной и асинхронной. но на практике сложности обеспечения стабильности и калибровки тактовых операторов достаточно велики и в чистом виде изохронная передача используется очень редко.
Как синхронные, так и асинхронные порты бывают следующих типов (рис. 9.7):

  • симплексные (simplex — передавать данные может только одно устройство);
  • полудуплексные (half-duplex — оба устройства могут принимать и передавать данные, но не способны делать это одновременно, например, потому, что прием и передача идут по одному проводу);
  • полнодуплексные (full-duplex) или просто дуплексные (оба устройства способны одновременно передавать и принимать данные, чаще всего -по различным проводам).

Рис. 9.7. Симплексные, полудуплексные и полнодуплексные порты

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

Рис. 9.8. Последовательные и параллельные порты

Порт RS232
Протокол последовательного асинхронного обмена RS5232/ССIТТ V24 широко применяется для подключения к компьютеру алфавитно-цифровых терминалов, низкоскоростных печатающих устройств, позиционных устройств ввода (мышей, планшетов), низкоскоростного телекоммуникационного оборудования и т.д., а иногда и для соединения компьютеров между собой, например, если более скоростное сетевое оборудование отсутствует или не может быть использовано.
Такие порты используются для передачи данных за пределы корпуса компьютера, поэтому кроме линии передачи данных предусмотрен также провод, передающий опорное нулевое напряжение, Вместо TTL-совместимых напряжений, RS232 использует в качестве 1 напряжения в диапазоне от -25 до -3 В, а в качестве 0 — соответственно, в диапазоне от +3 до +25 В (рис. 9.9).
RS232 предполагает двусторонний обмен данными. Для этой цели предусмотрено две линии данных — для приема и для передачи, обозначаемые ТХ и RX-В соответствии со стандартом, устройства делятся на два типа: "компьютеры и "терминалы". Различие между ними состоит в том, что "компьютер" передает данные по линии ТХ, а получает по линии RX, а "терминал" — наоборот. Для соединения двух "компьютеров" необходим специальный, так называемый нуль-модемный кабель, в котором провода ТХ и RX перекрещены. Любопытно, что стандартные порты IBM PC являются "терминалами", а не "компьютерами".

Рис. 9.9. Диаграмма напряжений RS232

Обмен данными осуществляется кадрами, состоящими из стартового бита, семи или восьми битов данных (младший бит передается первым), возможно — контрольного бита четности (см. разд. Контрольные суммы), и одного или двух стоповых битов. Игнорируя ошибки четности или вообще не проверяя четность, можно использовать этот бит для передачи данных, и получить, таким образом, девять битов данных в одном кадре.
Минимальная скорость передачи составляет 300 бит/с, последующие допустимые скорости получаются удвоением предыдущей— 600 бит/с, 1200, 2400 и т.д. Современные реализации RS232 поддерживают скорости 115 200 бит/с и более. Скорость и вариации формата кадра определяются настройками приемника и передатчика. Необходимо, чтобы у соединенных портом устройств эти настройки совпадали, однако протокол сам по себе не предоставляет средств для их согласования.
Кроме линий приема, передачи, нуля и питания спецификация RS232 предусматривает ряд дополнительных сигналов, в просторечии называемых модемными линиями— признак несущей, разрешение передачи данных (очисткой этого сигнала приемник может сигнализировать передатчику, что он не успевает обрабатывать поступающие данные) и др. Эти сигналы не должны обязательно поддерживаться всеми устройствами и используются, главным образом, акустическими модемами, откуда и происходит название. Полная спецификация при использовании 25-контактного разъема предусматривает также возможность синхронной передачи данных с отдельными стробирующими сигналами, но основная масса реализаций RS232 этого не поддерживает.
Протокол RS232 весьма прост и легко может быть реализован программными средствами с использованием двух бит порта ввода-вывода (пример 9.1) — впрочем, в этом случае потребуется еще внешняя микросхема приемопередатчика, преобразующая TTL-совместимые напряжения в диапазон напряжений RS232. Однако использующие этот протокол, применяются очень широко, и многие модели микроконтроллеров и практически все комплекты вспомогательных микросхем для микропроцессоров предлагают аппаратные реализации этого стандарта.

Пример 9.1. Программная имитация Р5232-совместимого последовательного порта, цит. по [www.atmel.com AVR305]

;**** APPLICATIONNOTEAVR305 ************************ . *
;* Название : Полудуплексный программный UART
;* Версия : 1.20
;* Последнее обновление : 97.08.27
;* Целевое устройство : Все микроконтроллеры AVR . *
;* Адрес поддержки : avr@atmel.com . *
;* Размер кода : 32 слова
;* Мин. Регистров : О
;* Макс. Регистров : 4 .
;* Прерывания : Не используются
;* Описание
;* Этот пример содержит эффективный с точки зрения объема кода программный UART.
;* Программа-пример получает один символ и передает его назад.
.include "1200def.inc" .***** определения контактов
.equ RxD =0 /Контакт приема PDO .equ TxD =1 ;Контакт передачи PD1
.*****
Глобальные регистровые переменные
.def bitcnt =R16 ;счетчик битов
.def temp =R17 промежуточный регистр
.def Txbyte =R18 ;Передаваемые данные
def Rxbyte =.<19 /Полученные данные .cseg
.org
0
* "putchar" *
* Эта подпрограмма передает байт из регистра "Txbyte"
* Количество стоповых битов определяется константой sb *
* Количество слов :14 включая возврат
* Количество циклов :Зависит от частоты передачи
* Мин. Регистров :Нет
* Макс. Регистров :2 (bitcnt,Txbyte)
* Указатели :Не используются
.equ sb =1 ;Кол-во стоповых битов (1, 2, ...)
putchar: Idi bitcnt,9+sb ;1+8+sb com Txbyte ;Инвертировать все sec ;Стартовый бит
putcharO: brcc putchar1 ;Если перенос установлен cbi PORTD,TxD ; передать 'О' rjmp putchar2 ;иначе
putcharl: sbi PORTD,TxD ; передать '!' пор
Putchar2: rcall UART_delay /Задержка в один бит real! UART_delay
Isr Txbyte /Получить следующий бит dec bitcnt /Если не все биты переданы brne putcharO ; послать следующий ;иначе
.Leu ; возврат
* "getchar"
?
* Эта подпрограмма получает один байт и возвращает его в "Rxbyte" *
* Кол-во слов :14 включая возврат
* Кол-во циклов :Зависит от скорости приема
* Мин. Регистров :Нет
* Макс. Регистров :2 (bitcnt,Rxbyte)
* Указатели :Не используются
getchar: Idi bitcnt,9 ;8 бит данных + 1 столовый
*
getcharl: sbic PIND,RxD ;Ждать стартового бита rjmp getcharl
rcall UART_delay ;задержка в 0.5 бита
getchar2: rcall UART_delay /задержка в один бит rcall UART__delay
clc /очистить перенос
sbic PIND,RxD ;если вход RX = 1
sec
dec bitcnt ;Если бит столовый breq getcharS ; возврат
/иначе
ror Rxbyte ; сдвинуть бит в Rxbyte rjmp getchar2 ; получить следующий
getcharS: ret
I "UART_delay"
*
I Эта подпрограмма задержки генерирует требуемую задержку между битами
* при передаче и приеме байтов. Полное время исполнения определяется I константой "Ь":
*3*Ь + 7 cycles (включая rcall и ret)
*
f Кол-во слов :4 включая возврат
* Мин. Регистров :Нет
* Макс. Регистров :1 (temp)
* Указатели :Не используются
Допустимые значения Ь:
1 MHz crystal: 9600 bps - b=14 19200 bps - b=5 28800 bps - b=2
2 MHz crystal: 19200 bps - b=14 28800 bps - b=8 57600 bps - b=2
4 MHz crystal: 19200 bps - b=31 28800 bps - b=19 57600 bps - b=8 115200 bps - b=2
•equ ь =31 ;19200 bps @ 4 MHz crystal
UART_delay: Idi temp,b uART_delayl: dec temp Brne UART delayl
ret
;***** Исполнение программы начинается здесь ;***** Тестовая программа
reset: sbi PORTD,TxD ;Установить контакты порта sbi DDRD,TxD
Idi Txbyte,12 /Очистить терминал rcall putchar
forever: rcall getchar mov Txbyte,Rxbyte
rcall putchar ;Воспроизвести полученный символ
rjmp forever

USART микроконтроллера PIC
В качестве примера аппаратной реализации последовательного порта, которую, помимо других вариантов использования, мЪжно применить и для реализации RS232, давайте рассмотрим устройство USART (Universal Synchronous Asynchronous Receiver Transmitter), включенное в состав ряда моделей микроконтроллеров PIC фирмы Microchip [www.microchip.com PICMicro].
USART может использоваться либо как полнодуплексный асинхронный порт с форматом кадра, совместимым с RS232, либо как полудуплексный синхронный стробируемый порт. Устройство состоит из приемника (receiver), передатчика (transmitter) и генератора тактовой частоты (baud rate generator) (рис. 9.10).

Рис. 9.10. Структура USART микроконтроллера PIC

Генератор тактовой частоты представляет собой двоичный счетчик, уменьшаемый на единицу на каждом такте центрального процессора. Когда счетчик доходит до нуля, генерируется один такт и счетчик инициализируется исходным значением. Фактически, генератор представляет собой делитель тактовой частоты процессора в заданном отношении. Документ [www.microchip.com PICMicro] содержит таблицу, по которой можно рассчитать коэффициент деления, чтобы из типичных тактовых частот процессора получить (иногда с определенной ошибкой) стандартные частоты RS232.
Счетчик генератора тактовой частоты программно недоступен, а начальное значение этого счетчика (оно же коэффициент деления) задается 8-разрядным регистром SPBRG. USART использует одну и ту же тактовую частоту и для приемника, и для передатчика. При синхронной работе в режиме ведомого встроенный генератор тактовой частоты не используется вообще.
Приемник и передатчик во многом аналогичны по структуре и имеют по два программно-доступных 8-разрядных регистра. Первый регистр называется регистром управления и статуса (status and control register), а второй — регистром данных. Значения битов управляющих регистров приемника и передатчика приведены в табл. 9.1 и 9.2. И приемник, и передатчик имеют также программно-недоступный сдвиговый регистр, называемый TSR (Transmit Shift Register) у передатчика и RSR (Receive Shift Register) у приемника.
При передаче значение регистра данных передатчика помещается в сдвиговый регистр и возможно расширяется девятым битом, который берется из бита TX9D управляющего регистра. Значение младшего бита выставляется на выходе ТХ микроконтроллера. Затем на каждом такте генератора регистр сдвигается на один бит — в результате получается последовательная передача бит кадра с заданной скоростью (рис. 9.11). Когда биты кончаются, передатчик устанавливает бит TRMT управляющего регистра и, если это требуется, генерирует прерывание по завершении передачи.
Приемник имеет несколько более сложное устройство. Значение входа RX анализируется не один раз за каждый такт генератора, а 16 раз и усредняется — благодаря этому значительно увеличивается помехоустойчивость (рис. 9.12). Кроме того, приемник USART имеет скрытый буферный регистр, в который помещается значение принятого байта, если регистр данных приемника еще не был прочитан с момента последнего приема (рис. 9.13).

Таблица 9.1. Описание битов управляющего регистра передатчика USART

Бит | Описание
7 | CSRC: Clock SouRCe — выбор источника тактовой частоты при асинхронном режиме игнорируется, при синхронном режиме: 1 = режим ведущего (USART генерирует стробовый сигнал) 0 = режим ведомого (стробовый сигнал генерирует другое устройство)
6 | ТХ9: передавать 9 бит 1 = передавать 9 бит 0 = передавать 8 бит
5 | TXEN: Transmission ENabled — разрешить передачу 1 = передача разрешена 0 = передача запрещена
4 | SYNC: бит выбора режима USART 1 = синхронный режим 0 = асинхронный режим
3 | Не используется. Читается как О
2 | BRGH: Baud Rate Generator High — переключение режимов работы тактового генератора
1 | TRMT: бит состояния сдвигового регистра передатчика 1 = TSR пуст 0 = TSR содержит данные (идет передача)
0 | TX9D: девятый бит передаваемых данных (может использоваться как бит четности)

Рис. 9.11. Принципиальная схема передатчика USART

Рис. 9.12. Временная диаграмма работы приемника USART

Таблица 9.2. Описание битов управляющего регистра приемника USART

Бит | Описание
7 | SPEN: Serial Port Enable — включить последовательный порт 1 = последовательный порт включен 0 = последовательный порт выключен
6 | RX9: разрешение приема 9 бит 1 = выбран прием 9 бит 0 = выбран прием 8 бит
5 | SREN: Single Receive ENable — бит одиночного приема Асинхронный режим — не используется. Синхронный режим (ведущий): 1 = одиночный прием разрешен; 0 = одиночный прием запрещен. Этот бит очищается после завершения приема. Синхронный режим (ведомый) — не используется
4 | CREN: Continuous Receive ENable — бит непрерывного приема. Асинхронный режим: 1 = прием разрешен; 0 = прием запрещен. Синхронный режим: 1 = непрерывный прием разрешен; 0 = непрерывный прием запрещен
3 | Не используется. Читается как 0
2 | FERR: Framing ERRor — ошибка кадра 1 = ошибка кадра 0 = нет ошибки кадра
1 | OERR: Overrun ERRor — ошибка переполнения буфера 1 = переполнение буфера приема 0 = нет ошибки переполнения
0 | RX9D: девятый бит принятых данных. Может использоваться как бит четности

Рис. 9.13. Принципиальная схема приемника USART

Приемники большинства современных аппаратных реализаций RS232 имеют буферные регистры. Так, последовательные порты подавляющего большинства современных IBM PC-совместимых компьютеров основаны на микросхеме National Semiconductor PC16552 [NS PC16552D] и имеют буферы объемом 16 байт (у оригинальной IBM PC были небуферизованные порты).
Помимо освобождения центрального процессора от исполнения работы сдвиговых регистров, аппаратная реализация RS232 позволяет заменить ожидание данных в режиме опроса на работу по прерываниям, что во многих случаях тоже весьма полезно. Многопортовые адаптеры, используемые, например, для организации модемных пулов интернет-провайдеров, часто могут передавать данные в режиме ПДП.


Шины

Описанные в предыдущем разделе порты передачи данных соединяют друг с другом два устройства. Однако часто оказывается целесообразно подключить к одному порту передачи данных несколько устройств, причем необязательно однотипных. Каждая передаваемая по такому порту порция данных обязана сопровождаться указанием, какому из подключенных устройств она предназначена — адресом или селектором устройства. Такие многоточечные порты называются шинами (bus) (рис. 9.14). Как и двухточечные порты передачи данных, шины бывают синхронные и асинхронные, а также последовательные и параллельные. При описании шин термины "синхронный" и "асинхронный" используют в ином значении, чем при описании портов. Асинхронными называют шины, в которых ведомое устройство не выставляет (или не обязано выставлять) сигнал завершения операции, а синхронными, соответственно, шины, где ведомый обязан это делать.

Рис. 9.14. Шина

Подключение N устройств двухточечными портами требует, чтобы центральный процессор имел N приемопередатчиков, использование же многоточечного порта позволяет обойтись одним. В частности, за счет этого Удается уменьшить количество выводов микросхемы процессора или периферийного контроллера. Кроме того, при удачном размещении устройств Можно получить значительный выигрыш в общей длине проводов и, таким образом, например, уменьшить количество проводников на печатной плате. Во многих случаях это приводит к столь значительному снижению общей стоимости системы, что оказывается целесообразным смириться с усложнением протокола передачи данных и другими недостатками, присущими шинной архитектуре.
Основной недостаток шин состоит в том, что в каждый момент времени только одно устройство на шине может передавать данные. Если у двухточечных портов часто оказывается целесообразным реализовать полподуплексный обмен данными при помощи двух комплектов линий (один прием, другой на передачу, как в описанном выше RS232), то в случае шинной топологии это невозможно. Поэтому шины бывают только полудуплексные или симплексные.
Невозможность параллельно осуществлять обмен с двумя устройствами может привести к падению производительности по сравнению с собственным портом обмена данными у каждого устройства. Если устройства не занимают пропускную способность каналов передачи полностью, проигрыш оказывается не так уж велик. Благодаря этому шины широко используются даже в ситуациях, когда только одно устройство имеет возможность инициировать обмены данными.
Если передачу данных могут инициировать несколько устройств (как, например, в случае системной шины с несколькими процессорами и/или контроллерами ПДП), шинная топология оказывается наиболее естественным выбором. Такая конфигурация требует решения еще одной проблемы: обеспечения арбитража доступа к шине со стороны возможных инициаторов обмена — задатчиков шины (рис. 9.15). Методы арбитража отличаются большим разнообразием.

Рис. 9.15. Шина с несколькими задатчиками

Разрешение коллизий в I2С
Синхронная последовательная шина I2С (Inter-Integrated Circuit — [протокол соединения] между интегральными схемами) широко применяется для соединения цифровых микросхем во встраиваемых приложениях [Paret-Fenger 1997]. Шина состоит из двух линий, строба и данных (рис. 9.16). Предполагается, что все подключенные к шине устройства имеют общие шины питания и нуля. Хотя бы одно из устройств шины должно быть ведущим (master).

Рис. 9.16. Временная диаграмма шины I2С

Ведущее устройство генерирует стробовый сигнал и инициирует все передачи данных по шине. Частота строба может составлять 100 или 400 кГц или 1 МГц. Все передачи начинаются с того, что ведущий передает адрес целевого устройства. В зависимости от конфигурации адрес может состоять из семи или десяти бит. Восьмой или, соответственно, одиннадцатый бит адреса содержит указание на тип запроса: чтение или запись. Услышав свой адрес, ведомое устройство генерирует на линии стробового сигнала импульс подтверждения.
Импульса подтверждения может не последовать не только при отсутствии устройства с таким адресом, но и, например, если соответствующее устройство не готово принять или передать данные.
Если подтверждение все-таки последовало, начинается передача данных. Данные состоят из восьми бит. Если запрос был на чтение, данные передает ведомое устройство, если на запись — ведущее.
Спецификации I2С допускают конфигурацию с несколькими ведущими. Чтобы избежать конфликтов, ведущее устройство генерирует стробовый сигнал только во время инициируемых им передач данных, в остальное же время тихо слушает линию и может, при запросе другого ведущего, стать ведомым.
Из-за того, что сигнал распространяется по шине с конечной скоростью, да и внутренняя логика ведущих устройств срабатывает не мгновенно, возможна ситуация, когда два ведущих с небольшим интервалом попытаются захватить шину. Эта ситуация называется коллизией (collision — дословно, столкновение). Протоколы арбитража большинства других шин считают коллизией любую попытку двух устройств одновременно вести передачу. I2С предлагает более слабое определение коллизии — в соответствии со спецификациями этой шины, коллизия происходит, только когда ведущие пытаются выставить на шине данных разные значения. Таким образом, если два (или даже больше!) ведущих отправят один и тот же запрос одному и тому же ведомому, это не будет считаться коллизией!
Механизм арбитража, используемый I2С, отличается крайней простотой.Ведущий, пытающийся выставить на линии данных 1 в то время, как другой ведущий пытается выставить там же 0, считается проигравшим арбитраж. Он должен остановить передачу и дождаться, пока победитель завершит обмен и освободит шину.
Важно подчеркнуть, что протокол арбитража не может просто объявить обоим устройствам, что дескать, случилась коллизия — если оба устройства прeкратят передачу и, не предприняв более ничего, сделают вторую попытку, мы получим живую блокировку! Странное, на первый взгляд, определение коллизии принятое в I2С, дает победителю арбитража возможность продолжить передачу после обнаружения коллизии, исключая, таким образом, для него возможность повторной коллизии после повторной попытки передачи, и, соответственно, исключая опасность живой блокировки.
Видно, что спецификации I2С допускают ситуацию, когда два устройства пытаются установить на шине разные напряжения. Чтобы избежать при этом больших токов, стандарт устанавливает несколько необычный способ подключения устройств к шине (рис, 9.17). Устройство, пытающееся выставить 1, должно просто отключить свои выходные каскады — тогда через резистор Rp (для питания 5 В его сопротивление должно быть не менее 1,7 кОм) конденсатор Сb будет заряжен до напряжения питания. Только при попытке выставить ноль устройство должно пропускать через себя ток.

Рис. 9.17. Схема приемопередатчиков I2С

Необходимость обнаружения коллизий и арбитража накладывает ограничения на физический размер шины: сигналы распространяются по шине с конечной скоростью, а любые два конфликтующих устройства должны узнать коллизии за время, меньшее чем минимальный цикл передачи данных (цикл шины). Описанная выше шина 12С предназначена для низкоскоростной связи в пределах одной печатной платы, поэтому для нее эта проблема не актуальна, однако для локальных сетей, протяженность которых составляет сотни метров или даже километры, и для системных шин компьютеров с большим количеством процессоров и банков памяти это превращается в серьезную проблему. Кроме того, большое количество коллизий само по себе снижает производительность системы.
Один из основных путей решения этой проблемы упоминался в разд. Многопроцессорные архитектуры: замена шины центральным коммутатором, либо более или менее сложной сетью коммутаторов. Устройства соединяются с ближайшим коммутатором полнодуплексными двухточечными каналами, такие же каналы используются для соединения коммутаторов друг с другом, а все коллизии возникают и разрешаются только внутри коммутаторов.
Внутренняя шина коммутатора, как правило, имеет большую пропускную способность, чем внешние соединения — у многих практически используемых коммутаторов несколько внутренних шин, поэтому разрешение коллизии внутри коммутатора часто состоит в отправке данных другим путем (рис. 9.18).

Рис. 9.18. Коммутатор с несколькими внутренними шинами

Таким образом, коммутируемая магистраль позволяет уменьшить производительности, порождаемые коллизиями, и повысить реальную пропускную способность магистрали. Однако коммутируемая магистраль значительно повышает общую стоимость системы и далеко не всегда оправдана этой точки зрения.
Применяются также гибридные топологии, когда все или некоторые устройства подключаются к коммутатору полудуплексным портом или коротки участком шины — при этом возможны локальные коллизии, но их гораздо меньше, чем при единой шине. Гибридная топология позволяет использовать устройства с одними и теми же приемопередатчиками как в обычной шинной, так и в коммутируемой топологии (рис. 9.19). В частности, именно такую гибридную топологию имеют описанные в разд. Многопроцессорные архитектуры многопроцессорные системы IBM NUMA-Q и SGI Origin — процессоры и память подключаются к локальной шине процессорного модуля, а сами модули соединяются коммутируемой сетью.
Гибридная топология также позволяет снизить стоимость системы по сравнению с полностью коммутируемой сетью — коммутаторы можно устанавливать только в тех участках шины, где расстояния или большое количество коллизий становятся проблемой. В таких сетях часто используются двухпортовые коммутаторы, называемые мостами (bridge). Так же называются и адаптеры, используемые для соединения двух шин с разными протоколами.

Рис. 9.19. Гибридная топология

Во всех перечисленных случаях сохраняется логическая шинная топология: каждый потенциальный задатчик шины может обращаться по адресу к любому другому устройству, поэтому разработчикам программного обеспечения не всегда интересны детали реализации физического подключения устройств.
Шины (как физические, так и логические) находят широчайшее применение в вычислительной технике. В частности, один из важнейших сетевых протоколов канального уровня, Ethernet, начинал свою карьеру как последовательная шина с совмещением синхросигнала и данных, да и современные реализации этого протокола, хотя физически и представляют собой коммутируемую или, все реже и реже гибридную сеть, с логической точки зрения по-прежнему являются шиной.
Связь центральных процессоров с банками ОЗУ и внешними устройствами в подавляющем большинстве современных компьютеров осуществляется параллельными стробируемыми шинами.
В вычислительных системах общего назначения чаще всего используется топология с двумя или более шинами. Процессоры и память связаны системной шиной. К этой шине также присоединены один или несколько адаптеров периферийных шин, к которым и подключаются внешние устройства (рис. 9.20). Основное преимущество такого решения состоит в том, что одни и те же периферийные устройства могут использоваться в вычислительных системах с различными системными шинами. Высокоскоростные устройства, например дисковые адаптеры FC-AL у серверов или графические контроллеры у персональных компьютеров, иногда подсоединяются к системной шине непосредственно.

Рис. 9.20. Системная и периферийные шины

Шина PCI
Шина PC/ (Peripherial Component Interconnect), разработанная фирмой Intel в настоящее время используется в вычислительных системах разного уровня, начиная от персональных компьютеров с архитектурами х86 и PowerMac и заканчивая старшими моделями серверов Sun Fire. В конструктиве PCI исполняется множество различных периферийных устройств— сетевые карты, адаптеры SCS/, графические и звуковые карты, платы видеоввода и видеовывода и лр [PC Guide PCI].
PCI не рассчитана на использование в качестве системной шины. Напротив спецификации шины предполагают ее подключение к процессорам и памяти через специальное устройство, мост системной шины (host-to-PCI bridge) (рис. 9.21). Это позволяет использовать PCI в системах, основанных на различных процессорах, таких, как х86, Power, SPARC.

Рис. 9.21. Мост системной шины

PCI предусматривает как монтаж устройств на материнскую плату системы, так и их исполнение в виде плат расширения. Шина допускает подключение не более 4 устройств. По исходным спецификациям, не более трех из них может подключаться через внешние разъемы, однако это не мешает многим поставщикам оборудования изготавливать платы с четырьмя внешними разъемами без мостов. Предусмотренный стандартом способ обхода указанного ограничения состоит в установке в системе нескольких адаптеров PCI, через собственные мосты системной шины или через PCI-to-PCI bridge. Допустимы также мосты, подключающие другие периферийные шины, например ISA, USB и т. д (рис. 9.22).
Каждое устройство на шине обязано иметь набор конфигурационных регистров, адресуемых географически (по номерам шины и разъема). Эти регистры содержат информацию об изготовителе и модели устройства. Кроме того, устройство может иметь до шести базовых адресных регистров, указывающих на связанные с устройством адресуемые объекты — блоки регистров ввода-вывода и отображенные на адреса ОЗУ буферы (рис. 9.23). Адреса этих объектов определяются динамически во время инициализации системы загрузочным монитором. Как правило, эти адреса также распределяются по географическому принципу.

Рис. 9.22. Мосты PCI-to-PCI и PCI-to-ISA

Устройство может работать в пассивном режиме (передавая данные по запросам процессоров) или через ПДП в режиме задатчика шины. Во втором случае мост системной шины преобразует выставляемые устройством адреса PCI в адреса системной шины. Современные спецификации шины (т. н. PCI64) допускают 32-разрядную адресацию для регистров устройств и 32- или 64-битную адресацию для отображенных в память буферов и при работе по ПДП.

Шина SCSI
Среди других практически важных применений шин необходимо упомянуть стандарт SCS/ (Small Computer System Interface — системный интерфейс малых компьютеров), наиболее известный как интерфейс для подключения жестких дисков, но используемый для подключения широкого спектра высокоскоростных устройств (табл. 9.3), в том числе и не являющихся устройствами памяти, например, сканеров [Гук 2000].

Рис. 9.23. Конфигурационные и рабочие регистры устройства PCI

Современные спецификации SCSI по структуре аналогичны спецификациям сетевых протоколов и состоят из трех уровней: команд, протокола и соединений. Уровень команд определяет формат и семантику команд и ответов на них, т. е. приблизительно соответствует тому, что в сетевых протоколах называется прикладным уровнем. Уровень протокола определяет способ передачи команд и ответов и соответствует канальному уровню сетевых протоколов. И, наконец, уровень соединений определяет физическую реализацию линий передачи данных (способ кодирования данных, допустимые токи и напряжения, конструкцию разъемов и т. д.), т. е. соответствует физическому уровню [www.t10.org architecture, Friedhelm/Shmidt 1997]. Сетевой (в данном случае описывающий способ адресации устройств) и транспортный уровни в спецификациях SCSI также присутствуют, хотя и в рудиментарном виде.

Таблица 9.3. Типы устройств SCSI, возвращаемые командой INQUIRY, цит. по [www.t10.org commands]

Код | Описание
00h | Устройство прямого доступа (например, магнитный диск)
01h | Устройство последовательного доступа (например, магнитная лента)
02h | Печатающее устройство
03h | Процессорное устройство
04h | Устройство однократной записи (например, некоторые оптические диски)
05h | CD-ROM
06h | Сканер
07h | Оптическое запоминающее устройство (например, некоторые оптические диски)
08h | Устройство с автоматической заменой носителя (например, jukebox)
09h | Коммуникационное устройство
0Ah-0Bh | Определено ASC IT8
0Ch | Контроллер массива запоминающих устройств (например, RAID)
0Dh | Устройство enclosure services (мост к шине другого типа)
0Eh-1Eh | Зарезервировано
1Fh | Неизвестный или неопределенный тип устройства

На физическом уровне SCSI представляет собой параллельную шину с 8-ю или 16-ю (так называемая Wide SCSI) линиями передачи данных. Современные реализации стандарта обеспечивают скорости передачи данных до 160 Мбит/с. Стандартом SCSI III предусмотрены также реализации в виде последовательной шины IEEE 1394 или волоконно-оптических колец FC-AL (Fiber Channel Arbitrated Loop — волоконно-оптический канал [в виде] арбитрируемого кольца). Fiber Channel допускает объединение нескольких колец коммутаторами, подключение до 127 устройств, общую длину сети, измеряемую несколькими сотнями метров и скорости передачи до 1Гбит/с.
Обмен данными по шине происходит не байтами и даже не словами, а кадрами, которые могут содержать команды, информацию о состоянии устройства (sense data) или собственно данные. Формат кадров и способ разрешения коллизий зависит от используемого типа соединений — в этом смысле разделение уровня соединений и уровня протокола не реализовано в полной мере. Каждый кадр содержит адреса отправителя и получателя. Каждое устройство имеет собственный адрес, называемый SCSI target ID. У старых версий протокола этот адрес был 3-битным, допуская адресацию восьми устройств, у современной версии (Ultra-SCSI или SCSI III) адрес 4- или 5-битный. Адаптер, через который шина SCSI подключается к системной или периферийной шине компьютера — НВА (Host Bus Adapter— адаптер системной шины) — также считается устройством и обычно имеет ID, равный, в зависимости от разрядности адреса, 7, 15 или 127.
Устройство, имеющее один target ID, может содержать несколько логически устройств, идентифицируемых номерами логических устройств (LUN — Logjc Unit Number). Спецификации протокола предусматривают два байта для код рования LUN, но реальное число логических устройств, поддерживаемых практике, обычно невелико.
Устройства SCSI делятся на два типа: инициаторы (initiator) и целевые ройства (target). Инициатор посылает команды, а целевое устройство их полняет. Спецификация допускает, что в разных актах обмена данными инициатор может становиться целевым устройством и наоборот, но в пределах одной операции эти роли заданы однозначно. Как правило, НВА может выступать только в роли инициатора, а периферийные устройства — только в роли целевых.
Блоки команд — CDB (Command Descriptor Block — блок описания команды) — невелики по размеру и могут содержать 6, 8, 10, 12 или 16 байт. Различие в длине обусловлено размером используемых в команде адресов: 6-байтовая команда использует 21-битный адрес блока на устройстве и 8-битное поле для длины требуемого блока данных, 8-байтовая — 32-разрядный адрес и 16-битное поле длины, а более длинные команды— 32-разрядные адрес и длину. 16-байтовая форма команды содержит, кроме 32-разрядных адреса и длины, также и 32-битное поле параметра. Кроме того, команда обязательно содержит код операции и поле управления. Под код операции отводится байт. Старшие три бита этого байта кодируют формат SCB. Некоторые команды используют только один формат SCB, другие допускают применение различных форматов (обычно с адресами и полем длины различной разрядности). Кроме перечисленных в табл. 9.4, различные типы устройств имеют собственные команды.
Блоки данных, в отличие от команд, имеют практически неограниченную длину. Многие команды интерпретируют поле длины CDB как указанное в блоках. При размере блока 512 байт это позволяет передать за одну операцию до 2 Тбайт (1 Тбайт=1024 Гбайт) данных. На практике, пакет данных, передаваемых за одну операцию шины, ограничен размерами буферов устройств, поэтому запросы на передачу больших объемов данных обрабатываются в несколькр приемов. Команда может либо принимать, либо передавать данные. В качестве данных могут передаваться либо блоки параметров и ответов, либо собственно данные. Существование команд, способных и передавать, и принимать данные, явно запрещено спецификациями, поэтому в CDB предусмотрена только одна группа полей описания буфера. Впрочем, операция может исполняться в виде последовательности команд и поэтому может предусматривать двусторонний обмен данными.
Большинство команд требует от устройства более или менее длительных подготовительных операций (перемещения головки, перемотки ленты), поэтому передача данных не может следовать немедленно за командой. По этой причине большинство современных устройств поддерживает очереди команд. Чтобы связать команду с пришедшим на нее ответом, команды могут снабжаться тегом задачи. Устройство способно параллельно исполнять несколько задач, а в рамках одной задачи может быть исполнена последовательность команд.

Таблица 9.4. Список команд SCSI, поддерживаемых всеми устройствами, цит. по www.t10.org commands]

Имя команды | Код | Тип | Описание
CHANGE DEFIKTTION | 40h | O | Изменить версию протокола, используемого устройством
COMPARE | 39h | O | Сравнить данные
COPY | 18h | O | Копировать данные внутри устройства или между устройствами
COPY AND VERIFY | 3Ah | O | Копировать данные с проверкой
INQUIRY | 12h | M | Проверить наличие устройства, его тип и поддерживаемые им операции
LOG SELECT | 4Ch |  | Включить сбор статистики
LOG SENSE | 4Dh | O | Считать собранную статистику
MODE SELECT(6) | 15h | Z | Выбор режима работы устройства
MODE SELECT(10) | 55h | Z |
MODE SENSE(6) | 1Ah | Z | Считывание режима работы
MODE SENSE(10) | 5Ah | Z |
MOVE MEDIUM ATTACHED | A7h | Z | Для устройств с автоматической сменой носителя
PERSISTENT RESERVE IN | 5Eh | Z | Управление режимом блокировки устройства
PERSISTENT RESERVE OUT | 5Fh | Z |
PREVENT ALLOW MEDIUM REMOVAL | 1Eh | 0 | Заблокировать сменный носитель в устройстве
READ BUFFER | 3Ch | 0 | Чтение данных
READ ELEMENTSTATUS ATTACHED | B4h | Z | Для устройств с автоматической сменой носителя
RECEIVE DIAGNOSTIC RESULTS | 1Ch | O | Получить результаты самотестирования устройства
RELEASE (6) | 17h | Z | Разблокировка (освобождение) устройства
RELEASE (10) | 57h | z |
REPORT LUNS | A0h | O | Считывание списка логических устройств
REQUEST SENSE | 03h | М | Считывание статуса устройства
RESERVE(6) | 16h | Z | Блокировка (захват) устройства
RESERVE(10) | 56h | Z |
SEND DIAGNOSTIC | 1Dh | M | Запрос самотестирования устройства
TEST UNIT READY | 00h | M | Проверка готовности устройства
WRITE BUFFER | 3Bh | O | Запись данных

Здесь:

  • M — реализация команды обязательна;
  • O— реализация команды возможна (может быть реализована, а может и не быть);
  • Z— реализация и семантика команды зависит от типа устройства.


Устройства графического вывода

Если устройства графического ввода (сканеры, видеограбберы, цифровые фотоаппараты) пока еще относительно редки (хотя, по мере развития цифровой фотографии, положение быстро меняется), то устройствами графического вывода снабжается каждый современный настольный и переносной компьютер. Многие встраиваемые приложения также имеют хотя бы небольшие, но дисплеи, чаще всего жидкокристаллические.
Два основных практически применяемых типа дисплейных устройств — это электронно-лучевые трубки, используемые в кинескопах телевизоров и мониторах настольных компьютеров, и уже упоминавшиеся жидкокристаллические дисплеи. Устройства других типов — матрицы светодиодов и газоразрядные ("плазменные") панели пока что дороги в производстве, либо не всегда приемлемы по качеству, поэтому — во всяком случае, на момент написания этой книги — еще не находят массового применения.
Принцип действия ЭЛТ широко известен. Изображение в этих устройствах формируется катодным лучом — пучком электронов, испускаемых отрицательно заряженным электродом. Вспомогательные электроды и электромагниты фокусируют луч, а два набора управляемых электромагнитов — катушки горизонтальной и вертикальной развертки — отклоняют этот луч по вертикали и горизонтали (рис. 9.24). Попадая на лицевую поверхность трубки — экран — электроны заставляют светиться нанесенный на нее краситель-люминофор, формируя, таким образом, изображение. Изменяя напряжение на катоде, можно управлять яркостью луча и, соответственно, яркостью участка изображения. Люминофор светится еще какое-то время после ухода луча с него. За счет этого, а также за счет инерционности человеческого восприятия, изображение воспринимается как более или менее подвижная двумерная картинка, а вовсе не как быстро движущийся световой "зайчик".

Рис. 9.24. Электронно-лучевая трубка

Для формирования цветного изображения используются три фокусируемых со смещением катодных луча и маски, обеспечивающие попадание каждого из лучей на участки предназначенного для него цветного люминофора.
Чтобы изображение занимало весь экран, луч (или тройка лучей) проводится по сложной траектории, которая приведена на рис. 9.25. Генерация этой траектории достигается простой подачей пилообразного напряжения на катушки развертки. При этом частота "пилы" горизонтальной развертки называется частотой строчной развертки, а вертикальной, соответственно, частотой кадровой развертки. Соотношение кадровой и строчной частот равно количеству строк в кадре (несколько строк приходится на кадровый гасящий импульс, поэтому видимых строк на экране чуть меньше, чем следует из соотношения частот).

Рис. 9.25. Развертка ЭЛТ

Катушки развертки не могут мгновенно изменить направление магнитного поля, поэтому и обратный ход луча как по вертикали, так и по горизонтали происходит не мгновенно. Чтобы луч на обратном ходе не был виден устройство управления ЭЛТ должно генерировать кадровый (для вертикально обратного хода) и строчный гасящие импульсы (рис. 9.26).

Рис. 9.26. Строка развертки ЭЛТ

Вертикальное разрешение изображения соответствует количеству видимых строк на экране. Горизонтальное разрешение определяется двумя факторами: частотой, с которой схемы управления ЭЛТ способны модулировать луч и, у цветных мониторов, разрешением маски кинескопа. Второй параметр указывают в паспортных данных кинескопов под названием размер точки (dot pilch). У большинства мониторов первый параметр обычно более или менее соответствует второму. Электронно-лучевые трубки громоздки, создают сильные электромагнитные помехи и сами чувствительны к ним и, наконец, имеют высокое энергопотребление, но они во много раз дешевле всех альтернативных способов генерации изображений высокого качества и высокого разрешения.
Жидкокристаллические экраны основаны на способности некоторых органических соединений — жидких кристаллов — менять свою прозрачность и другие оптические свойства под воздействием электрического поля. Жидкокристаллический экран представляет собой две стеклянные или прозрачные пластиковые пластины (обкладки), на которые нанесены полупрозрачные металлические электроды. Пространство между пластинами заполнено жидким кристаллом. За пластинами находится подложка — у черно-белых дисплеев зеркальная или черная, у цветных дисплеев — цветная. Для повышения контрастности изображения подложка нередко подсвечивается.
Подавая напряжение на электроды, контроллер ЖКД может избирательно делать прозрачными те или иные участки экрана и, таким образом, формировать различные изображения. Изменяя напряжение, можно в определенных пределах управлять яркостью, или, скорее, контрастностью изображения. В современных дисплеях высокого разрешения используется более сложный способ формирования цветного изображения, чем просто разноцветная подложка: в таких дисплеях используется жидкий кристалл, поворачивающий плоскость поляризации проходящего через него света. Этот угол зависит от напряжения на электродах и от частоты световой волны. Снабдив обкладки экрана поляризационными фильтрами, можно управлять цветом участка экрана.
Формы электродов ЖКД отличаются большим разнообразием. Нередко применяются прямоугольные матрицы точек, позволяющие создавать произвольные растровые изображения. Однако многие приложения — часы, калькуляторы, простые дисплеи — не требуют произвольных изображений, поэтому часто изготавливают пластины с электродами сложной формы, соответствующей элементам цифр и букв и/или различным пиктограммам.
ЖКД низкого разрешения дешевы, компактны, имеют низкое энергопотребление и находят широкое применение в самых разнообразных устройствах — сотовых и стационарных телефонах, калькуляторах, часах, измерительных и бытовых приборах. Однако высококачественные цветные ЖКД большой площади представляют собой весьма дорогостоящие устройства, цена которых составляет более половины цены современных портативных компьютеров.
Подача напряжения на каждую пару электродов ЖКД отдельным контроллером недопустимо дорога. К счастью, пара электродов представляет собой конденсатор, который способен некоторое время без вмешательства извне сохранять электрический заряд и, следовательно, изображение. Наличие у пиксела электрической емкости позволяет свести поддержание изображения на жидкокристаллической матрице к аналогу развертки ЭЛТ — периодическому сканированию электродов с подачей на них напряжения, соответствующего яркости элемента изображения (рис. 9.27).
При изменении изображения емкость элементов матрицы скорее вредна — быстрая перезарядка элемента требует больших токов, поэтому многие ЖКД, в том числе и дисплеи старых переносных компьютеров, обладают высокой инерционностью изображения. В современных высококачественных дисплеях каждый пиксел экрана снабжается собственной усилительной схемой, способной быстро перезарядить электроды. Такие экраны называются активной матрицей. Широкое распространение получили активные матрицы с усилителями, реализованными на основе TFT (Thin Film Transistor— тонкопленочный транзистор), нанесенных на обкладки экрана.
Несмотря на коренное различие физических способов формирования изображения в электронно-лучевых трубках и жидкокристаллических панелях, функции контроллера этих устройств весьма похожи и сводятся к передаче значений яркости элементов изображения с частотой, соответствующей частоте развертки дисплея. Контроллер, предназначенный для работы с ЭЛТ, должен уметь также делать в потоке данных
паузы, соответствующие строчным и кадровым гасящим импульсам и должен быть несколько сложнее. Однако общая структура графически контроллеров для дисплеев различных типов удивительно похожа.

Рис. 9.27. Схема формирования изображения на жидкокристаллическом дисплее

Типичный графический контроллер (рис. 9.28) состоит из:

  • видеобуфера (frame buffer — буфер кадра) — блока памяти, элементы которого соответствуют пикселам дисплея;
  • генератора тактовой частоты;
  • собственно контроллера — устройства, которое передает содержимое видеобуфера, а также, если это необходимо, гасящие импульсы и синхросигналы аналоговым схемам управления дисплеем.
  • ЦАП (цифро-аналогового преобразователя), который преобразует значения пикселов в аналоговый сигнал, и, если это необходимо, усилителей видеосигнала.

Структура видеобуфера определяется цветовой глубиной дисплея. У черно-белых дисплеев обычно один бит соответствует одному пикселу, а один бант видеобуфера — восьми последовательным пикселам изображения. У черно-белых с градацией яркости ("черно-бело-серых") и цветных дисплеев видеобуфер должен иметь по несколько битов на пиксел.

Рис. 9.28. Схема графического контроллера

Это достигается двумя способами. Первый состоит в организации битовых плоскостей, когда в видеобуфере последовательно хранятся сначала младшие биты значений пикселов, затем — вторые и т. д. (рис. 9.29). При этом восемь последовательных пикселов описываются несколькими байтами, размещенными в разных частях видеобуфера. Эта, немного странная на первый взгляд, логика кодирования изображения удобна тем, что облегчает перенос программного обеспечения, предназначенного для работы с черно-белыми дисплеями, на цветные дисплеи.
Второй подход, чаще применяемый при больших цветовых глубинах, состоит в кодировании одного пиксела одним или несколькими последовательными байтами видеобуфера. Графические контроллеры современных персональных компьютеров имеют цветовую глубину 24 или 32 бита, что соответствует трем или четырем байтам на пиксел. Этого хватает, чтобы (с учетом Диапазона яркости современных дисплеев) представить любой цвет, который человеческий глаз способен отличить на дисплее от другого.
Контроллеры с такой цветовой глубиной обычно предоставляют по одному байту для кодирования каждой из трех цветовых составляющих цветного изображения. Устройства с меньшими цветовыми глубинами часто реализуют более сложную схему кодирования цвета, называемую отображением цветов (color mapping). Значение пиксела при этом представляет собой индекс в специальной таблице, палитре. Элементы палитры — это значения компонентов пиксела.

Рис. 9.29. Битовые плоскости

Большинство контроллеров включают в себя также более или менее сложную логику управления содержимым видеобуфера со стороны центрального процессора. Простейшим случаем такого управления является отображение видеобуфера на адресное пространство системной шины. Это, привлекательное во многих отношениях, решение не всегда применимо, например, если адресное пространство процессора слишком мало или плотно занято, либо устройство не подключается непосредственно ни к системной, ни к периферийной шине (например, контроллеры жидкокристаллических дисплеев, предназначенных для использования во встраиваемых приложениях, используют для общения с микропроцессором шину PC или нестандартные протоколы последовательных портов).
При простой нехватке адресного пространства нередко видеобуфер отображают на адресное пространство не целиком, а частями, банками. При этом контроллер должен иметь специальный регистр — селектор банка.
Более радикальное решение, применимое даже при подключении через последовательный порт, состоит в том, чтобы предоставить процессору два регистра — адрес в видеопамяти и регистр данных, соответствующий ячейке видеопамяти по этому адресу. Это решение можно считать вырожденным случаем банков видеопамяти, когда банк имеет размер один байт. При этом требуется всего два регистра в адресном пространстве ввода-вывода (этот метод редко применяется с видеобуферами объемом более 64 Кбайт), и доступ к видеобуферу замедляется минимум вдвое. При использовании последовательных шин и портов адрес и данные передаются последовательно. Многие графические контроллеры с такой организацией предоставляют различные способы доступа с автоинкрементом, позволяющие передать в буфер последовательность байтов без явного доступа к регистру адреса.
Контроллеры с битовыми плоскостями часто предоставляют групповые операции над байтами, кодирующими биты смежных пикселов.
Контроллеры, применяемые в современных персональных компьютерах и рабочих станциях, содержат более или менее сложные видеопроцессоры, способные без участия ЦПУ рисовать в видеобуфере различные графические примитивы, начиная от прямых линий и окружностей, и заканчивая проекциями и/или фотореалистичными изображениями трехмерных объектов, описываемых языком OpenGL.
Видеоконтроллеры представляют собой довольно сложные устройства. Подробные описания современных видеоконтроллеров можно найти во многих доступных книгах, например в [Гук 2000]. В документе [www.microchip.com PICMicrol описывается встроенный контроллер жидкокристаллического дисплея микроконтроллера PIC.


Запоминающие устройства прямого доступа

Приобрел себе винчестер у дороги во кустах Обнаружились бэд-блохи в восемнадцати местах
Ахх
Ю. Нестеренко

Как уже говорилось, основную массу устройств этого типа составляют диски — жесткие и гибкие, магнитные, магнитооптические и оптические. Ассоциация между понятиями "постоянное запоминающее устройство прямого доступа" и "диск" столь плотно укоренилась в общественном сознании, что запоминающие устройства, основанные на иных принципах, например, банки флэш-памяти, иногда называют "твердотельными дисками". В этой главе мы будем рассматривать преимущественно устройство магнитных дисковых накопителей. Принцип устройства дискового накопителя широко известен [Гук 2000].
Для хранения данных служит диск, покрытый ферромагнитным слоем. В современных накопителях запись осуществляется на обе стороны диска. Многие приводы с неудаляемыми дисками имеют несколько дисков. Диск может быть как гибким, так и жестким. Диск насажен на ось, называемую шпиндель.
В зависимости от способа крепления, диски делятся на съемные (удаляемые) и несъемные (фиксированные). В действительности, съемность накладывает серьезные ограничения не только на крепление диска к оси, но и на конструкцию блока головок и привода в целом, а также предъявляет определенные требования к контроллеру — тот должен отслеживать наличие диска в приводе и факт его замены. Несъемные диски, как правило, помещаются в герметичный корпус, защищающий головки и поверхность диска от пыли и других вредных атмосферных воздействий.
Включение двигателя и раскрутка шпинделя до рабочей скорости обязательно производятся до начала форматирования диска или обмена данными с ним. Контроллеры съемных дисков обычно останавливают мотор в то время, когда накопитель не используется, контроллеры же дисков несъемных обычно останавливают шпиндель только при выключении питания.
Запись на магнитный диск и считывание данных с него осуществляется головкой чтения-записи, по принципу действия похожей на головку обычного бытового магнитофона. Головка состоит из ферромагнитного сердечника, на который намотан провод. Сердечник имеет разрез, как раз в той части, которая ближе всего к поверхности диска. Подавая ток по проводу, можно создавать в сердечнике и, соответственно, в разрезе сердечника магнитное поле, которое будет намагничивать диск. Таким образом, осуществляется запись данных. С другой стороны, когда направление намагниченности проходящего под головкой участка диска меняется, в проводах возникает индуктивный ток — детектируя его, можно осуществлять считывание. В отличие от простой передачи по проводу, головка считывает не сам записанный сигнат, а его производную по времени (в аналоговых магнитофонах это компенсируется аналоговым интегрированием сигнала).
Способ кодирования нулей и единиц при такой записи представляет собой несколько более сложную задачу, чем при немодулированной передаче данных по проводу. Видно, что записать на диск постоянное высокое или низкое напряжение (соответствующее последовательности нулей или единиц в принципе, можно — в виде участка дорожки с постоянной намагниченностью, но такая последовательность не может быть считана головкой. Считывать можно только изменения намагниченности. Таким образом, использовать простые изменения напряжения для кодирования единиц и нулей невозможно, и мы должны изобрести более сложную схему кодирования.
Выбирая схему кодирования, мы должны обязательно решить также вопрос о способе синхронизации считывания: добиться высокой стабильности от мотора, вращающего шпиндель, невозможно. Кроме того, в приводах низкоскоростных, особенно гибких, дисков приходится считаться с опасностью механического заедания подшипников и самого диска, а в приводах жестких дисков с высокой скоростью вращения — с механическими вибрациями всей конструкции, которые могут повлиять на скорость прохождения диска под головкой даже при стабильной средней угловой скорости шпинделя.
На лентопротяжных устройствах с несколькими головками нередко используется отдельная синхродорожка, но на дисковых устройствах ее введение потребовало бы существенного усложнения и недопустимого утяжеления блока головок, поэтому обычно применяются схемы кодирования с совмещенным синхросигналом.
Простейшая форма такого кодирования, называется DFM (Double Frequency Modulation— модуляция с двойной частотой). Видно, что при записи нуля мы осуществляем одно изменение направления тока за интервал кодирования одного бита, а при кодировании единицы — два, причем первое из этих изменений смещено относительно начала интервала. Такое кодирование использовалось на 8-дюймовых гибких дисках.
Несколько более сложная схема кодирования, использовалась на 5- и 3-дюймовых гибких дисках низкой плотности. Эта схема называется модифицированной фазовой модуляцией. На жестких дисках в 80-е годы использовались частотная и модифицированная частотная Модуляция. Современные жесткие диски используют сложные схемы группового кодирования, когда одно изменение фазы или частоты намагничива-кодирует несколько битов.

Рис. 9.32. Простая и модифицированная фазовая модуляция

Головка чтения/записи может перемещаться вдоль радиуса диска шаговым электродвигателем. У жестких дисков головка обычно размещена на рычаге, напоминающем звукосниматель граммофона. У гибких дисков головка движется по направляющим под действием червячной передачи, а у CD-ROM — зубчатой рейки. Накопители, имеющие более одной рабочей поверхности, имеют столько же головок, сколько и поверхностей, но подача этого блока головок все равно осуществляется одним двигателем.
Когда блок головок неподвижен, каждая головка может считывать данные, записанные на диске в виде кольцевой дорожки (track). Совокупность дорожек всех поверхностей, соответствующих одному положению блока головок, образует цилиндр (cylinder). Количество цилиндров у накопителя определяется шириной магнитной головки (и обусловленной ею шириной намагниченной полосы) и точностью, которую может обеспечить механика подачи головки. Стандартные приводы 3-дюймовых дискет имеют 80 дорожек. Количество цилиндров у современных жестких дисков достигает нескольких тысяч. При всех перечисленных выше, а также при более сложных современных способах модуляции запись данных на дорожку осуществляется блоками или секторами. Секторы аналогичны кадрам, которыми осуществляется передача данных через последовательные порты и шины. Сектор состоит из заголовка и блока данных. Заголовок обычно содержит номер дорожки (чтобы контроллер мог убедиться, что правильно позиционировал головку) и сектора на дорожке, а иногда также и поверхности. Кроме того, заголовок практически всегда содержит контрольную сумму или иногда две отдельных контрольных суммы — для заголовка и для данных. Пространство между секторами заполнено специальными зонами, служащими для выравнивания и синхронизации (рис. 9.35 и табл. 9.5). Нередко используются также специальные маркеры — последовательности изменений сигнала, которые не могут появиться при принятой схеме модуляции. Маркеры используются для отметки начала дорожки или, реже, начала сектора.

Рис. 9.35. Структура дорожки дискеты с двойной плотностью

Перед тем, как диск может быть использован для записи данных, он должен быть отформатирован или, как говорили раньше, размечен — на его дорожки должны быть записаны заголовки секторов с правильными номерами дорожки и сектора, а также, если это необходимо, маркеры. Как правило, при этом же происходит тестирование поверхности диска для поиска дефектов магнитного слоя. Не следует путать эту операцию — физическое форматирование диска — с логическим форматированием, созданием файловых систем. Современные жесткие диски конструктивов АТА и SCSI обычно требуют физического форматирования в заводских условиях.

Таблица 9.5. Структура сектора дискеты с двойной плотностью, цит. по [МикроЭВМ 1988]

Длина в байтах | Описание
12 | Зона синхронизации
4 | Адресный маркер
1 | Номер дорожки
1 | Номер стороны
1 т | Номер сектора
1 | Длина сектора
2 | Циклическая контрольная сумма
22 | Зона типа 2 (см. рис. 9.35)
12 | Зона синхронизации
4 | Маркер данных
256/512/1024 | Данные
2 | Циклическая контрольная сумма
36-116 т | Зона типа 3 (см. рис. 9.35)

Зона типа 1 длиной 32 байта отмечает начало дорожки, а зона типа 4 длиной от 118 до 266 байт служит для заполнения дорожки до полной длины.
Количество секторов в одной дорожке определяется, с одной стороны, длиной сектора, а с другой — частотой модуляции. Частота модуляции, в свою очередь, ограничена частотной характеристикой схемы управления магнитной головкой, индуктивностью самой головки и параметрами ферромагнитной поверхности диска (размером минимального домена намагничивания). Последний фактор, фактически, ограничивает линейную плотность записи (количество битов на миллиметр или дюйм длины дорожки), поэтому в более длинных внешних дорожках целесообразно делать больше секторов, чем во внутренних (рис. 9.36). Это решение усложняет адресацию секторов и логику контроллера, поэтому начало широко применяться лишь относительно недавно.

Рис. 9.36. Диск с переменным количеством секторов на дорожках

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

  • электродвигателя шпинделя;
  • шагового двигателя подачи головки;
  • аналоговых каскадов управления магнитной головкой.

Устройство, управляющее всем этим, называется дисковым контроллером и состоит из кодеров и декодеров используемой схемы модуляции, логики формирующей заголовок сектора при записи или проверяющей его целостность при считывании, буфера данных сектора и сдвигового регистра, подключенного к кодеру и декодеру управления головкой (рис. 9.37). Современные контроллеры обычно содержат более сложную логику, обеспечивающую передачу данных в основное ОЗУ компьютера в режиме ПДП (в том числе и распределение/сборку (scatter/gather) — передачу одного сектора или группы секторов в несмежные участки памяти), очередь обслуживаемых запросов, опережающее считывание, отложенную запись и кэширование данных, переадресацию дефектных секторов и др.

Рис. 9.37. Схема контроллера диска

Контроллер гибких дисков ДВК
В качестве простейшего дискового контроллера рассмотрим микросхему К1801ВП1-097 [МикроЭВМ 1988] (Эта микросхема является советским функциональным аналогом микросхемы контроллера гибких магнитных дисков, разработанной фирмой DEC для использования в мини- и микрокомпьютерах семейства PDP-11. К сожалению, автору не удалось найти маркировку оригинальной микросхемы и ссылку на документацию о ней).
Микросхема допускает подключение четырех приводов 5-дюймовых гибких дисков и содержит в себе порты управления основным и шаговым электродвигателями привода, сдвиговый регистр, модулятор и демодулятор МФМ, логику обнаружения маркеров, генератор циклической контрольной суммы и интерфейс системной шины Q-Bus. У микросхемы есть два 16-разрядных регистра, регистр управления и регистр данных. Описание битов регистра управления приведено в табл. 9.6.

Таблица 9.6. Управляющий регистр К1801ВП1-097

Бит | Описание | Комментарий
00 (W) | Выбор накопителя 0 | —
00 (R) | Дорожка 0 | Головка чтения/записи установлена на дорожку 0
01 (W) | Выбор накопителя 1 | —
01 (R) | Накопитель готов | —
02 (W) | Выбор накопителя 2 | —
02 (R) | Запись запрещена | На дискете установлена защита записи
03 (W) | Выбор накопителя 3 | —
04 | Включение двигателя | —
05 | Поверхность | Если 1, запись (чтение) происходите верхней поверхности диска
06 | Направление шага | При записи 1 , направление к оси диска
07 (W) | Шаг | При записи 1 в этот бит, головка перемещается на один шаг и бит очищается
07 (R) | Данные готовы | Контроллер выставляет этот бит, когда считано или записано очередное слово данных. ЦПУ должно прочитать или записать следующее слово сектора
08 | Частичный сброс |
09 | Запись маркера |
10 | Включение внешней схемы прекоррекции фазовых искажений |
14 | Успех операции | 0, если операция завершилась ошибкой
15 | Начало дорожки |

Видно, что микросхема предусматривает работу с ней центрального процессора в режиме опроса. Более сложный контроллер КМД (Контроллер МиниДисков), основанный на этой микросхеме, содержит микропроцессор, ПЗУ с программой для него и 2Кб ОЗУ (которое может использоваться и для промеж, точных данных программы микропроцессора, и для хранения самих данных передаваемого блока) [МикроЭВМ 1988]. Работа с этим контроллером осуществляется посредством двух регистров, команды/состояния и данных. Центральный процессор записывает в эти регистры команду (табл. 9.7) и адрес блока параметров в основном ОЗУ. Контроллер в режиме ПДП считывает блок параметров и пытается выполнить операцию, передавая данные, если это необходимо, также в режиме ПДП. Указатель на блок данных содержится в блоке параметров. Одной операцией можно прочитать или записать несколько последовательных секторов. После завершения передачи контроллер генерирует прерывание.

Таблица 9.7. Список команд контроллера КМД, цит. по [МикроЭВМ 1988]

Код | Мнемоника | Описание
0h | RD | Чтение
1h | WR | Запись
2h | RDM | Чтение с маркером
3h | WRM | Запись с маркером
4h | RDTR | Чтение дорожки
5h | RDID | Чтение заголовка
6h | FORMAT | Форматирование дорожки
7h | SEEK | Подача головки
8h | SET | Установка параметров
9h | RDERR | Чтение состояния и ошибок
Fh | LOAD | Загрузка

Контроллер жестких дисков ST506
Более сложный контроллер ST506 был разработан фирмой Western Digital в конце 70-х годов прошлого века для управления жесткими дисками, подключаемыми к микропроцессорным системам. Этот контроллер и его усовершенствованная версия, ST412, широко применялись для подключения жестких дисков к IBM PC и совместимых с ними компьютерах [citforum.ukrpak.net IDE, Гук 2000, PC Guide IDE].
Контроллер ST506 допускал подключение двух жестких дисков емкостью до 40МЬ, использующих модифицированную частотную модуляцию, а более поздние версии контроллера — групповую модуляцию RLL. Как и описанный выше КМД, контроллер содержал логику управления мотором шпинделя и подачей головок, буфер для одного сектора и схемы модуляции и демодуляции МЧМ. Диски присоединялись к контроллеру двумя плоскими кабелями: один, с 34-мя проводами, использовался для передачи сигналов управления двигателями, а второй, 20-проводной — для передачи модулированных сигналов МЧМ.
Центральному процессору контроллер ST506 доступен в виде двух блоков регистров, управляющего и командного. Блок управляющих регистров размещается по адресам Ox3F4-Ox3F7, а блок командных— по адресам 1FO-1F7 (табл. 9.8).
Управляющие регистры используются для доступа к регистрам данных и статуса без подачи контроллеру сигнала о том, что регистр прочитан (смысл этого станет ясен далее). Пожалуй, единственный самостоятельно полезный бит в этих регистрах управляет разрешением прерываний от контроллера. Основная работа с контроллером происходит через блок из восьми командных регистров, один из которых является регистром данных.
При исполнении команды записи контроллер подает головку к дорожке, указанной регистрами CY и DK, генерирует прерывание и выставляет сигнал DRQ (табл. 9.9), сигнализируя процессору, что готов принять данные. Затем процессор производит 512 операций записи в порт данных, заполняя буфер. Контроллер выполняет запись, генерирует прерывание и, если счетчик секторов еще не сравнялся с нулем, увеличивает номер сектора и снова выставляет сигнал DRQ.
Операция чтения выполняется аналогично, с той лишь разницей, что контроллер сначала считывает сектор, и лишь потом выставляет DRQ. Циклы чтения и записи регистра данных, таким образом, приводят к увеличению счетчика буфера. Набор команд контроллера приведен в табл. 9.10.

Таблица 9.8. Командные регистры контроллера ST506, цит. по [chip.ms.mff.cuni.cz АТА2]

Адрес | Функция (чтение) | Функция (запись) | Код
0x1 F0 | Данные (младш.) | Данные (младш.) |
0x1 F1 | Ошибка | Прекомпенсация | PC
0x1 F2 | Счетчик секторов | — " — | SC
0x1 F3 | Номер сектора | — " — | SN
0x1 F4 | Цилиндр (младш.) | — " — | CY
0x1 F5 | Цилиндр (старш.) | — " — | CY
0x1 F6 | Устройство/поверхность | — " — | DH
0x1 F7 | Статус | Команда |

Таблица 9.9. Биты регистра статуса ST506, цит. по [chip.ms.mff.cuni.cz IDE]

Бит | Мнемоника | Описание
7 | BUSY | Устройство занято
6 | DRDY | Устройство готово принимать команды
5 | DWF | Ошибка записи
4 | DSC | Подача головки завершена
3 | DRQ | Запрос данных
2 | CORR | Исправимая ошибка при чтении данных
1 | INDEX | Маркер начала дорожки
0 | ERROR | Ошибка

Таблица 9.10. Команды контроллера ST506, цит. по [chip.ms.mff.cuni.cz IDE]

Код | Описание | PC | SC | SN | CY | DH
90h | Диагностика привода | — | — | — | — | D+
50h | Форматировать дорожку | — | — | — | V | V
20h | Чтение секторов с повтором | — | V | V | V | V
21h | Чтение секторов | — | V | V | V | V
40h | Проверка секторов с повтором | — | V | V | V | V
41h | Проверка секторов | — | V | V | V | V
1Xh | Возврат к дорожке | — | — | — | — | D
7Xh | Подача головки | — | — | — | V | V
30h | Запись секторов с повтором | — | V | V | V | V
31h | Запись секторов | — | V | V | V | V

V— используется значение регистра
D — используется только бит выбора диска
D+ — независимо от бита выбора диска, реагируют оба диска

Контроллеры жестких дисков ESDI, IDE, EIDE
Дальнейшее совершенствование ST506 шло в направлении переноса функции контроллера на сам жесткий диск. Первым шагом стал интерфейс ESDI, предложенный фирмой IBM в 1985 году. В ESDI модулирование сигнала выполнялось не контроллером, а платой, установленной на диске. Это позволило, во-первых, использовать жесткие диски со схемами модуляции, отличными от МЧМ и RLL и, во-вторых, увеличить допустимую длину кабелей до трех метров. Контроллер ESDI по регистрам и набору команд был полностью совместим с ST506/412, но обеспечивал работу с дисками большей емкости и большей производительности.
Следующий, более радикальный, шаг был сделан в следующем году фирмами Western Digital и Compaq, предложившими перенести весь контроллер на жесткий диск. Соответствующий конструктив был назван IDE (Integrated Drive Electronics — интегрированная на приводе электроника).
40-жильный плоский кабель IDE представляет собой, фактически, расширение шин адреса и данных ISA. По нему передаются 16 бит данных и пять линий адреса. Первые две линии адреса выбирают группу регистров — контрольный или командный блок регистров, расположенных по тем же адресам шины ISA, что и соответствующие регистры ST506. Последние три линии выбирают один из регистров соответствующей группы и через линии данных кабеля подключают его к шине данных ISA.
IDE, как и ST506, допускает подключение двух жестких дисков, Каждый из дисков имеет свой контроллер и свои блоки регистров. Когда ЦПУ производит запись в эти регистры, она происходит в регистры обоих контроллеров. Однако на запросы чтения и, тем более, исполнения команды откликается только тот контроллер, который выбран регистром он. Эта весьма своеобразная архитектура позволила сохранить полную программную совместимость с контроллером ST506 при полном же изменении конструктива. Основным недостатком этой архитектуры является тот печальный факт, что пока один диск отрабатывает команду или передает данные, обращения ко второму диску невозможны.
IDE несколько позднее получил статус стандарта ANSI под названием ATA (AT Attachment— [интерфейс] присоединения к IBM PC/AT) [www.t13.org], и до сих пор является основным способом подключения жестких дисков к настольным компьютерам и иногда даже к маломощным серверам, причем не только на основе процессоров х86, но и PowerMac, SPARCStation и др.
Современные версии стандарта поддерживают обмен 16-разрядными данными (при нормальной отработке команды код ошибки не нужен, а прекомпенсация современными контроллерами вообще не используется, поэтому для данных можно использовать регистры 0x1 FO и 0x1 F1, образующие при этом единый 16-разрядный регистр), автоидентификацию геометрии и других параметров жестких дисков, логическую адресацию блоков (LBA — Logical Block Addressing, когда регистры SN, CY и 4 младших бита он образуют линейный 28-битный адрес сектора), работу в режиме ПДП и передачу данных со скоростями до 60 Мбит/с [Гук 2000, citforum.ukrpak.net IDE, PC Guide IDE, www.t13.org].
Расширение протокола ATAPI (ATA Packet Interface) пакетный интерфейс ATA предусматривает также возможность управления недисковыми устройствами (CD-ROM, стриммерами, магнитооптическими дисками) путем передачи через регистр данных 12-байтовых блоков команд, аналогичных командам SCSI.


Производительность жестких дисков

Скорость работы дискового накопителя определяется рядом параметров. Во-первых, любой обмен данными с таким устройством предваряется двумя обязательными задержками: позиционированием блока головок и ротационной задержкой (rotational delay), когда контроллер ждет, пока требуемый сектор подъедет к головке. Кроме того, само чтение сектора происходит не мгновенно, и, наконец, необходимо принимать во внимание скорость передачи данных по шине, к которой подключен диск, и возможные коллицизии на этой шине.
Задержку позиционирования головки можно в определенных пределах сокращать облегчением блока головок и совершенствованием механики. Единственный способ сокращения ротационной задержки — это увеличение скорости вращения диска. У современных дисков эта скорость достигает 15000 об/мин. Скорость вращения повышает также и скорость чтения отдельного сектора. Кроме того, увеличение плотности записи (как за счет улучшения физических характеристик магнитного слоя, так и за счет совершенствования способов модуляции) и обусловленное этим увеличение количества секторов на дорожке также ускоряет считывание каждого отдельного сектора.
Любопытный прием сокращения ротационной задержки при чтении последовательных секторов — это форматирование дисков с чередованием (interleave), когда номера секторов на дорожке не совпадают с их физической последовательностью (рис. 9.38). Количество физических секторов, размещенных между логически последовательными секторами, называется коэффициентом чередования.

Рис. 9.38. Дорожка диска, отформатированного с чередованием

Благодаря чередованию, если ЦПУ сформирует команду чтения следующего сектора немгновенно после завершения передачи предыдущего (на практике, это всегда происходит немгновенно), не придется ждать следующего оборота диска. Впрочем, если коэффициент чередования окажется слишком большим, ждать подхода следующего сектора все-таки придется. В годы молодости автора подбор оптимальных параметров чередования для жестких дисков был почти столь же популярным развлечением, каким ныне является "разгон" (подбор частот процессора и системной и периферийной шин, которые выше паспортных, но обеспечивают более или менее устойчивую работу системы).
Ряд файловых систем, разработанных в 80-е годы, реализовали свои собственные схемы чередования, размещая последовательные блоки файлов в чередующиеся физические секторы. Современные дисковые контроллеры предоставляют логику опережающего считывания и отложенной записи, которые снижают потребность в таких приемах. Кроме того, фабричное форматирование жестких дисков обычно сразу осуществляется с оптимальным чередованием.
Для сокращения времени подачи головки нередко используют сортировку запросов по номеру дорожки. В [Дейтел 1987) приводится анализ нескольких алгоритмов такой сортировки. В наше время шире всего используется элеваторная сортировка (в |Дейтел 1987] она называется SCAN). При отсортированных в соответствии с этим алгоритмом запросах, блок головок начинает от внутренней дорожки (или, точнее, от запроса, ближайшего к этой дорожке) и движется к наружной, исполняя последовательные запросы так, чтобы направление движения головки не изменялось (рис. 9.39). Запросы, поступающие на уже пройденные дорожки, откладываются на обратный проход. Достигнув внешней дорожки (или, точнее, самой внешней из дорожек, на которую есть запрос), блок головок меняет направление движения.

Рис. 9.39. Элеваторная сортировка

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


Дисковые массивы

У тебя, Федот, жена, хоть умна, а все ж одна!
А соткать такое за ночь — их дивизия нужна!
Л. Филатов

Еще один прием оптимизация производительности дисковых накопителей — это объединение нескольких физических дисков в один большой логический диск. При таком объединении некоторые диски могут передавать данные, в то время, как другие позиционируют блок головок или ждут подхода нужного сектора к головке. Дисковые массивы выгодны не только с точки зрения производительности, но и повышают единичную емкость запоминающего устройства — это может быть, например, полезно для хранения крупных неделимых объектов, таких, как таблицы реляционной СУБД.
Следует учесть, что объединение дисков приводит к резкому снижению наработки массива на отказ: вероятности независимых событий складываются поэтому вероятность отказа любого из дисков массива равна сумме вероятностей отказа одиночного диска. Для компенсации или устранения этого недостатка данные в дисковых массивах обычно хранятся с избыточностью. Общее название всех технологий объединения дисков — RAID (Redundant Irray of Inexpensive Disks — избыточный массив недорогих дисков). Этот термин был предложен в работе [Gibson/Katz/Patterson I988], в которой проведен анализ различных технологий создания дисковых массивов с точки зрения их производительности и надежности и было рассмотрено пять возможных способов размещения избыточных данных. Предложенная авторами статьи нумерация этих технологий без ссылки на источник используется в самых разнообразных публикациях в форме RAID уровня X. В современных публикациях, например [www.acnc.com], часто упоминаются также дополнительные и комбинированные уровни RAID, в именовании которых общего согласия не достигнуто.
Впрочем, один дополнительный уровень, не упоминавшийся в статье [Gibson/Katz/Patterson 1988], практически везде понимается и именуется одинаково. Под RAID уровня 0 практически единогласно понимают простой стриппинг (stripping — дословно, разделение на полосы). Стриппинг, строго говоря, не является методом создания избыточных массивов, потому что он не предполагает избыточности: емкость результирующего логического диска равна сумме объемов физических дисков (рис. 9.40). Из-за этого RAID 0 не обсуждался в работе [Gibson/Katz/Patterson 1988].
RAID уровня 1 известен также как зеркалирование (mirroring). При зеркали-ровании на каждый из дисков записывается полная копия данных (рис. 9.41). Обычно в зеркальном режиме используется не более двух дисков. Зеркалирование обеспечивает некоторое падение производительности (обычно все-таки менее, чем двукратное), но гарантирует работоспособность системы при отказе любого из дисков. Оба эти приема широко применяются па практике, обычно с небольшим количеством дисков, не более двух, реже, трех. Они не требуют значительных вычислительных ресурсов и потому обычно реализуются программно, драйвером диска или файловой системы.
RAID уровня 2 предлагает снабжение блоков данных на дисках кодом Хэмминга (см. разд. Контрольные суммы) и размещение этого кода на отдельном диске массива. Эта технология имеет смысл, если контроллеры дисков не хранят код восстановления ошибок в собственных заголовках секторов. Поскольку современные диски практически без исключения используют восстанавливающее кодирование и динамическое переназначение дефектных секторов, RAID 2 практически не применяется.

Рис. 9.41. RAID1 (зеркалирование)

RAID уровня 3 и 4 похожи во многих отношениях. Каждый из этих методов требует не менее трех дисков и подсчета контрольной суммы таким способом, чтобы данные могли быть однозначно восстановлены при полной потере одного из дисков. В RAID уровня 3 контрольные суммы подсчитываются для каждого байта записываемых данных, а в RAID уровня 4 — для групп блоков. Для хранения контрольных сумм отводится отдельный диск.
Эти методики обеспечивают более низкую (хотя и достаточную для большинства практических целей) избыточность, чем зеркалирование, но, как стриппинг. увеличивают единичную емкость диска и повышают производительность. Для отказа всей системы требуется полный отказ более чем от одного диска; многие конструктивы дисковых массивов уровня более 2 предусматривают "горячую" (без выключения системы) замену дисков, что допускает теоретически неограниченную наработку на останов, недостижимую при использовании одиночных дисков.
RAID 3 требует изменения формата секторов диска и соответствующей переделки контроллера и не может применяться с серийными дисковыми приводами. Основным недостатком RAID 4 является необходимость обращения к диску с контрольной суммой при каждой модификации одного из блоков группы. При записи большого количества логически последовательных блоков это не проблема, но может превратиться в проблему при случайно распределенных записях одиночных блоков.
Этого недостатка лишен RAID уровня 5, в котором контрольные суммы распределены по всем дискам массива (рис. 9.42). RAID 5 находят широкое применение в серверах уровня рабочей группы, или подразделения. При программной реализации, впрочем, подсчет контрольных сумм требует значительной доли вычислительной мощности ЦПУ, поэтому широкое распространение получили "аппаратные" контроллеры RAID 5, имеющие собственный процессор и, как правило, довольно большой объем собственной памяти. Это не мешает некоторым ОС, в частности Windows NT/2000/XP, реализовать RAID 5 программно на уровне файловой системы.

Рис. 9.42. RAID уровня 5


Сети доступа к дискам

— А теперь, прикинь солдат — где Москва, а где
Багдад!
Али ты смотался за ночь до Богдаду и назад?
Л. Филатов

Волоконно-оптические каналы подключения дисковых устройств к компьютерам, как, например, упоминавшийся в разд. Шины FC-AL, обеспечивают высокую пропускную способность и возможность устанавливать диски на значительном удалении от компьютера, иногда даже в другом здании.
Высокая пропускная способность позволяет одновременно нескольким системам использовать одну и ту же логическую шину. В частности, это дает возможность перераспределять диски или даже их логические разделы между системами без их физического переключения. Распределение вычислительных систем, дисков (особенно зеркальных или включенных в RAID более высокого уровня) и устройств резервного копирования по разным помещениям позволяет снизить риск потери данных в случае пожара или какого-либо другого бедствия и даже обеспечить бесперебойный сервис в этих условиях (рис. 9.43).

Рис. 9.43. Сеть FC-AL с несколькими кольцами и коммутаторами

Такая механика подключения запоминающих устройств к компьютерам изменяет взгляд на взаимоотношение между вычислительными и запоминающими устройствами: вместо сервера с подключенными к нему одним или несколькими дисками мы имеем массив постоянной памяти, к которому подключены один или несколько серверов, осуществляющих поиск и транзакции над хранящимися в массиве данными. Такой взгляд на организацию внешней памяти называется SAN (Storage Access Network— сеть доступа запоминающим устройствам) (рис. 9.44).
Не следует путать SAN с файловыми серверами, серверами транзакции другими СУБД. Если последние предоставляют относительно высокоуров невые операции над хранимыми данными (поиск файлов в каталогах, транзакции над таблицами реляционной СУБД и т. д.), то SAN обеспечивает доступ к разделяемым устройствам на уровне команд SCSI— чтения и записи отдельных блоков данных.
Средства для разрешения возможных коллизий, возникающих при этом весьма ограничены: SCSI III предусматривает лишь простые средства взаимоисключения при доступе нескольких инициаторов к одному целевому устройству, сводящиеся к захвату или освобождению целого логического устройства. Это может быть приемлемо лишь для разделения ресурсов, относительно редко используемых различными серверами (например, устройств резервного копирования) или применяемых преимущественно для чтения (например, загрузочных дисков). Как мы видели в разд. Мониторыи серверы транзакций , для разрешения коллизий при интенсивной асинхронной модификации данных предпочтительнее архитектура сервера транзакций или мониторного процесса для реализации которой у современных устройств SCSI явно недостаточно интеллекта.
Впрочем, SAN представляет собой молодую технологию с неясными на момент написания книги перспективами развития. Повышение интеллекта запоминающих устройств и превращение их в серверы транзакций является одним из перспективных направлений. Второе возможное направление развития, не противоречащее первому, — конвергенция транспортных протоколов SAN с протоколами гигабитных локальных и даже глобальных сетей, например путем инкапсуляции команд и блоков данных SCSI в кадры Ethernet или даже в пакеты TCP/IP. Перспективы такой конвергенции обсуждаются (правда, в пессимистическом ключе) в работе [Neil 2000].

Рис. 9.44. Сеть доступа к дискам (SAN)


Приложения. Обзор архитектур современных ОС

  • Обзор архитектур современных ОС
  • MVS, OS/390, z/OS
  • Семейство Unix
    • Распространение UNIX
    • Микроядро
    • Minix
    • GNU Not Unix
    • Open Software Foundation
    • X/Open
    • UNIX System V Release 4
    • Linux
  • Семейство СР/М
    • Win16
    • OS/2 1.x
    • IBM OS/2
    • Windows NT/2000/XP
    • Windows 95/98/ME
    • Windows CE


Обзор архитектур современных ОС

Не помнящие прошлого обречены повторять
его.
Дж. Саитаяна

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


MVS, OS/390, z/OS

Первые две ОС этого семейства вышли в 1966 г., вскоре после анонса аппаратной архитектуры System/360. Это были РСР (Primary Control program -первичная управляющая программа) и DOS/360 (Disk Operating System). Архитектура обеих систем была типична для вычислительных систем второго поколения — это были пакетные мониторы, рассчитанные на работу одной прикладной программы без защиты памяти (первые компьютеры серии System/360 не имели диспетчеров памяти).
В 1967 г. были выпущены версии РСР: MVT (Multiprogramming with a Variable number of Tasks — многопрограммная [система] с переменным числом задач) и MFT (Multiprogramming with a Fixed number of Tasks — то же, но с фиксированным числом задач). Обе системы реализовывали вытесняющую многозадачность в едином адресном пространстве — MFT использовала разделы памяти, a MVT — относительную загрузку с базовым регистром. Позднее, к MVT была добавлена подсистема работы с несколькими терминалами в режиме разделения времени TSO (Timesharing Option — возможность разделения времени), ASP (Asymmetric Multiprocessing System — асимметричная многопроцессорность) и ряд других прикладных подсистем.
В 1972 г., после появления машин System/370 с диспетчером памяти, была выпушена переходная система OS/SVS (Single Virtual Storage — единая виртуальная память), которая позволяла использовать страничную подкачку, но не защиту заданий друг от друга. Наконец, в 1974 г. была выпушена MVS (Multiple Virtual Storage — множественная виртуальная память), которая предоставляла каждой задаче собственное виртуальное адресное пространство объемом до 2 Гбайт (в System/360 и первых моделях System/370 адрес был 24-разрядным). Большая часть дополнительных подсистем MVT была включена в стандартную поставку MVS.
MVS предоставляла:

  • раздельные адресные пространства;
  • пакетный и интерактивный (разделение времени) запуск задач;
  • вытесняющую многозадачность;
  • выполнение нитей в общем адресном пространстве (нити в этой системе называются единицей обслуживания (service unit));
  • многопоточное ядро;
  • примитивы взаимоисключения — замки (lock);
  • симметричную многопроцессорность;
  • динамическое подключение и отключение процессоров (как центральных, так и канальных) и их горячую замену;
  • библиотеки — аналог вложенных каталогов;
  • последовательные, блочные и индексно-последовательные файлы;
  • отображение файлов, в том числе и индексно-последовательных, в адресное пространство (VSAM);
  • систему безопасности на основе ACL;
  • развитые средства системного мониторинга;
  • сетевые средства — поддержку стека протоколов SNA (Standard Network Architecture).

MVS воплотила все наиболее прогрессивные функции и архитектурные концепции своего времени. MVS была основным продуктом IBM вплоть до середины 90-х, когда вышла новая версия системы — OS/390.
В OS/390 основные архитектурные принципы MVS не подверглись пересмотру [redbooks.ibm.com sg245597]. Новшества заключались в добавлении следующих возможностей:

  • поддержка многомашинных кластеров (Parallel Sysplex);
  • развитие сетевых средств равноправного (peer-to-peer) взаимодействия;
  • поддержка сетевых протоколов семейства TCP/IP (ранее взаимодействие с сетями TCP/IP и NETBIOS осуществлялось посредством включения в состав вычислительного комплекса модуля с процессором х86 под управлением OS/2);
  • совместимость с системами семейства Unix [redbooks.ibm.com sg245992| (в 1998 г.OS/390 прошла набор тестов Unix 95 консорциума Х/Open и получила право называться UNIX [www.opengroup.org xu007|).

В 1999 г., в связи с началом выпуска 64-разрядного семейства компьютеров z900, вышла 64-разрядная версия системы z/OS [www.ibm.com zOS].
Системы под управлением OS/390 и z/OS применяются главным образом в качестве серверов транзакций и СУБД масштаба предприятия и составляют становой хребет вычислительных систем большинства крупных компаний.


Семейство Unix

Обширное и бурно развивающееся семейство Unix оказало огромное идейное влияние на развитие операционных систем в 80-е и 90-е годы XX столетия. Генеалогия систем семейства опубликована на сайте [perso.wanadoo.fr] и слишком обширна для того, чтобы ее можно было полностью привести в книге.
Применения систем семейства крайне разнообразны, начиная от встраиваемых приложений реального времени, включая графические рабочие станции для САПР и геоинформационных систем, и заканчивая серверами класса предприятия и массивно параллельными суперкомпьютерами. Некоторые важные рыночные ниши, например передачу почты и другие структурные сервисы Internet, системы семейства занимают практически монопольно.
Родоначальником семейства следует, по-видимому, считать не первую версию Unix, a Multics, совместно разрабатывавшуюся в 1965—1969 гг. General Electric и Bell Laboratories. За это время General Electric выделило подразделение, занимавшееся работами над Multics и аппаратной платформой для нее (GE-645), в отдельную компанию Honeywell.
Multics была первой из промышленных систем, предоставлявших:

  • создание процессов системным вызовом fork;
  • страничную виртуальную память;
  • отображение файлов в адресное пространство ОЗУ;
  • вложенные каталоги;
  • неструктурированные последовательные файлы;
  • многопользовательский доступ в режиме разделения времени;
  • управление доступом на основе ограниченных ACL (колец доступа).

Multics оказала огромное влияние не только на разработчиков Unix — значительные следы идейного влияния этой системы прослеживаются так же в RSX-11 и VAX/VMS фирмы DEC. Последние Multics-системы были доступны в Internet в 1997 г.
В 1969 г. Bell Laboratories отказалась от работ над Multics и начала разработку собственной ОС для внутренних нужд. По-видимому, основной причиной этого шага было осознание несоответствия между амбициозными целями проекта Multics (ОС была весьма требовательна к ресурсам и могла работать только на больших компьютерах Honeywell), в то время как материнской компании Bell Labs — American Telephone & Telegraph — требовалась единая операционная среда, способная работать на различных миникомпыотерах, используемых в подразделениях телефонной сети США.
В Bell Laboratories был объявлен внутренний конкурс на разработку переносимой ОС, способной работать на миникомпыотерах различных поставщиков. К проекту были предъявлены следующие основные требования:

  • многоплатформенность;
  • вытесняющая многозадачность;
  • многопользовательский доступ в режиме разделения времени;
  • развитые телекоммуникационные средства.

Один из участников работ над Multics, К. Томпсон, разработал крайне упрощенное ядро ОС, названное UNIX, для миникомпьютера PDP-7. К 1972 г. К. Томпсон и Д. Ритчи переписали ядро системы в основном на языке С и продемонстрировали возможность переноса ОС на миникомпьютеры PDP-11. Это обеспечило выполнение всех требований конкурса, и UNIX была признана основной платформой для вычислительных систем, эксплуатируемых в AT&T.
Легенды доносят до нас более колоритные детали ранних этапов истории новой системы. Так, одна из очень популярных легенд, излагаемая в той или иной форме несколькими источниками, утверждает, что история UNIX началась с разработанной для Multics игровой программы под названием Star Wars (звездные войны — сама эта программа и даже правила игры до нас не дошли). Уволенный из группы разработчиков Multics за разгильдяйство (мы приводим здесь наиболее радикальный вариант легенды, не заботясь о его согласовании с историческими фактами), Томпсон занялся "оживлением" неиспользуемой PDP-7, стоявшей в углу машинного зала. Оживление заключалось в написании ядра ОС, реализующего подмножество функциональности Multics, достаточное, для того чтобы перенести и запустить его любимые Star Wars [Бах 1986].
Первые версии UNIX были рассчитаны на машины без диспетчера памяти. Процессы загружались в единое адресное пространство. Ядро системы размещалось в нижних адресах ОЗУ, начиная с адреса 0, и называлось сегментом реентерабельных процедур. Реентерабельность обеспечивалась переустановкой стека в момент системного вызова и запретом переключения задач на все время исполнения модулей ядра. На машинах с базовой адресацией выполнялось перемещение образов процессов по памяти и сброс образа процесса на диск (задачный своппинг).
В Multics и современных системах Unix fork реализуется посредством копирования страниц при модификации. Ранние версии UNIX физически копировали образ процесса. Большая часть (по некоторым оценкам, до 90%) fork немедленно продолжается исполнением системного вызова exec, поэтому одной из первых оптимизаций, придуманных в университетских версиях системы, было введение системного вызова vfork. Порожденный этим вызовом процесс исполнялся на самом образе родителя, а не на его копии. Создание нового образа процесса происходило только при исполнении exec. Для того чтобы избежать возможных проблем взаимоисключения (например, при вызове нереентерабельных функций стандартной библиотеки), исполнение родителя приостанавливалось до тех пор, пока потомок не выполнит exec или не завершится.
Выделяют [Баурн 1986] следующие отличительные особенности системы.

  • Порождение процессов системным вызовом fork, который создает копию адресного пространства в пользовательской области процесса.
  • Результат завершения процесса хранится в его дескрипторе и может быть считан только родителем. В списке процессов такой дескриптор выглядит как процесс в специальном состоянии, называемом зомби (zombie).
  • Процессы-сироты (продолжающие исполнение после завершения родителя) усыновляются процессом с идентификатором, равным 1.
  • Процесс с идентификатором 1 запускается при загрузке системы (по умолчанию это /bin/ink) и запускает все остальные обязательные задачи в системе. Наличие такого процесса иногда объявляют необходимым и достаточным критерием для причисления той или иной системы к семейству Unix.
  • Древовидная структура пространства имен файловой системы: дополнительные ФС монтируются в те или иные точки корневой ФС и идентифицируются затем точкой монтирования, а не именем устройства, на котором расположены.
  • Файлы рассматриваются ОС как неструктурированные потоки байтов и не типизованы на уровне ОС (в частности, на уровне ОС нет деления файлов на записи).
  • Файловая система поддерживает множественные имена файлов в виде жестких и, у более поздних версий, символических связей.
  • Отложенное удаление файлов: если процесс открыл файл, а другой процесс его удалил, то первый процесс может продолжать работу с файлом, и физическое удаление происходит только после того, как первый процесс его закроет.
  • Лозунг "все — файл", который, впрочем, последовательно реализован только в экспериментальной системе Plan 9 — в реальных Unix системах практически всегда присутствуют объекты, к которым не применимы файловые операции: переменные среды, структуры данных ядра в ранних версиях (позднее они были оформлены в виде псевдофайловой системы /ргос), объекты SysV IPC и примитивы взаимоисключения POSIX Thread Library в современных системах.
  • Своеобразный командный язык, основанный на широком применении переназначения ввода-вывода и конвейеров (последовательностей задач, соединенных трубами).

Некоторые из перечисленных особенностей были позаимствованы другими ОС. Так, переназначение ввода-вывода и конвейеры реализуются командными процессорами ОС семейства СР/М, начиная с MS DOS 3.30. cmd.exe — командный процессор OS/2 и Windows NT/2000/XP — даже правильно понимает конструкцию cmdiine | more 2>&1. Стандартные командные процессоры UNIX портированы практически на все современные ОС. Функциональный аналог Korn Shell включен в стандартную поставку Windows 2000.
Жесткие связи файлов (однако, без отложенного удаления) поддерживаются FCS2 (файловой системой VAX/VMS), NTFS и jfs при использовании под OS/2. Точки монтирования (подключение дополнительных ФС в дерево каталогов существующей ФС) реализованы в Windows 2000 и Toronto Virtual File System для OS/2.
Системный вызов CreateProcess Windows NT/2000/XP может имитировать семантику fork: для этого достаточно передать в качестве имени программы для нового процесса пустой указатель.
Библиотека ЕМХ для OS/2 реализует fork при помощи собственного обработчика страничного отказа.


Распространение UNIX

AT&T в 70-е годы была "естественной" монополией в области телекоммуникаций. Этот статус гарантировался законодательным запретом деятельности других телекоммуникационных компаний на территории США. В обмен на этот статус AT&T вынуждена была подчиняться ряду регуляторных мер, в частности — ей было запрещено выходить на другие, кроме телекоммуникационного, рынки, в том числе на рынок программного обеспечения. Однако разработчики UNIX чувствовали, что их системе суждено гораздо более привлекательное будущее, чем внутренний стандарт крупной компании.
В 1973 г. одна из дочерних компаний AT&T, Western Electric, дала разрешение на использование UNIX в некоммерческих целях. Началось распространение системы в университетах США. Наибольший вклад в распространение и развитие университетской версии системы внес университет Беркли, в котором было создано специальное подразделение — BSD (Berkeley Software Distribution}.
В BSD UNIX было включено множество ценных нововведений, таких, как:

  • сегментная (на старших моделях PDP-11) и страничная (на VAX-11/780) виртуальная память
  • раздельные адресные пространства процессов и выделенное адресное пространство ядра
  • абсолютные загрузочные модули формата a.out
  • примитивная форма разделяемых библиотек
  • усовершенствования механизма обработки сигналов
  • управление сессиями и заданиями в пределах сессии

Самое важное нововведение было сделано в начале 80-х, когда в рамках работ по проекту DARPA сетевое программное обеспечение ARPANet было перенесено с TOPS/20 на BSD Unix. Вскоре сетевой стек BSD стал референтной реализацией (реализация, на совместимость с которой тестируют все остальные) того, что ныне известно как семейство протоколов TCP/IP.
В 1980 г. было решено начать коммерческое распространение системы на несколько необычных принципах: AT&T предоставляла сторонним коммерческим фирмам (естественно, за плату) лицензии на использование исходных текстов ядра и основных системных утилит текущей версии UNIX, а уже эта сторонняя коммерческая фирма (дистрибьютор) строила на основе полученных и самостоятельно разработанных компонентов законченную систему — с инсталляционной программой, системой управления пакетами и т. д. — и занималась ее продажей конечным пользователям и сопровождением. Таким образом была создана специфическая бизнес-модель распространения ОС семейства UNIX, хорошо знакомая пользователям Linux.
Первым из коммерческих распространителей стала фирма Microsoft, продававшая ядро UNIX v7 в составе ОС Microsoft Xenix. Xenix поставлялся почти для всех популярных в то время 16-разрядных миникомпыотеров и микропроцессорных систем [Дейтел 1987]. Как и BSD Unix, Xenix использовал виртуальную память и имел отдельное адресное пространство для ядра. В 1983 г. торговая марка Xenix и весь дистрибьюторский бизнес был передан фирме SCO в обмен на долю акций последней.
К середине 80-х, воспитанное на университетских версиях UNIX поколение студентов пришло в промышленность. Началось бурное развитие рабочих станций (workstation) — мощных 32-разрядных персональных компьютеров, как правило, оснащенных страничными или сегментными диспетчерами памяти. Лицензия BSD допускала построение на основе кода BSD коммерческих систем без каких-либо ограничений, в том числе и без денежных выплат разработчикам ядра. Благодаря этому, а также благодаря техническому совершенству ядра BSD Unix, последнее оказалось гораздо более привлекательным, чем ядро AT&T, поэтому основная масса поставщиков рабочих станций строили свои ОС на основе BSD Unix. Это привело к быстрому и неконтролируемому размножению систем, называвших себя Unix, и при этом имевших значительное количество несовместимостей — дополнительных или, наоборот, нереализованных системных вызовов, ошибок, "документированных особенностей" и т. д.
В 1984 г. AT&T заключила с федеральным антимонопольным комитетом США соглашение, в соответствии с которым компания должна была выделить локальные телефонные сети в отдельные компании, и согласовала планы создания конкурентной среды на рынке междугородней связи и выделения в отдельные компании подразделений, не имеющих отношения к телекоммуникациям. Долгосрочные результаты этого соглашения до сих пор являются предметом горячих дебатов среди юристов и экономистов, но важным с нашей точки зрения является то, что AT&T смогла напрямую заняться продажами и поддержкой программного обеспечения. На рынок вышло ядро Unix System V — первая поддерживаемая версия ядра AT&T UNIX.
В 1987 г. вышла версия UNIX System V Release 3, включавшая в себя асинхронные драйверы последовательных устройств (STREAMS), универсальный API для доступа к сетевым протоколам (ТЫ), средства межпроцессного взаимодействия (семафоры, очереди сообщений и сегменты разделяемой памяти), ныне известные как SysV IPC, BSD-совместимые сокеты и ряд других В5Оизмов [Робачевский 1999]. SVR3 в то время воспринималась как этапная ОС, однако дальнейшее развитие системы вынуждает нас отнести ее скорее к переходным версиям.
В этом же году AT&T и Sun Microsystems заключили стратегическое соглашение о разработке перспективного ядра UNIX System VI, которое должно было обеспечить совместимость с System V, BSD Unix и Xenix и, тем самым консолидировать возникший зоопарк Unix систем.
Не имея финансовой поддержки со стороны локальных телефонных сетей AT&T оказалась вынуждена заняться поисками средств для поддержки деятельности по развитию UNIX. Во второй половине 80-х было сделано несколько попыток взыскать лицензионные отчисления с поставщиков коммерческих систем на основе BSD Unix. Нельзя сказать, чтобы эти попытки были особенно последовательными и успешными, но они породили ряд инициатив по разработке "лицензионно чистой Unix системы".
Среди этих инициатив необходимо назвать следующие.

  • Микроядро BSD Mach О Minix А. Танненбаума
  • Проект Р. Столлмэна GNU (GNU Not Unix — рекурсивная аббревиатура) [www.gnu.orgj.
  • Консорциум OSF (Open Software Foundation — фонд открытого программного обеспечения). "


Микроядро

Концепция микроядра с технической точки зрения подробно рассматривается в разд. Монолитные системы и системы с микроядром. С коммерческой (если уместно говорить о коммерческих целях разработки свободно распространяемого ПО) точки зрения BSD Mach был попыткой убить одновременно двух зайцев — совместить переписывание ядра BSD Unix для достижения лицензионной чистоты с изменением архитектуры этого ядра.
Микроядерная архитектура позволила бы избежать самой одиозной черты традиционных Unix систем — однопоточного (или, точнее, кооперативно многозадачного) ядра и сделала бы систему пригодной для использования в задачах реального времени. Проект Mach не имел успеха — полноценного ядра Unix на его основе построить не удалось ни самим участникам проекта, ни Столлмэну в рамках проекта GNU HURD.
Однако идея микроядра и сам термин получили широкое распространение. Микроядерную архитектуру имеет UNIX System V Release 4. Кроме того, на самостоятельно разработанном микроядре основана своеобразная ОС реального времени, часто относимая к семейству Unix — QNX.
Основные работы над ядром BSD UNIX пошли в другом направлении: подсистемы, которые AT&T считал основанием для требования лицензионных выплат, переписывались с нуля, но архитектура системы в целом пересмотру не подвергалась. Этот процесс был в основном завершен к 1994 г., и современные ветви BSD по-прежнему имеют монолитную архитектуру.


Minix

Minix был разработан А. Танненбаумом, преподавателем университета Врийе (Vrije University) в Амстердаме [www.cs.vu.nl minixj. Это компактная система, созданная для учебных целей, способна работать на 16- и 32-разрядных микропроцессорах, причем не только самостоятельно, но и будучи скомпилирована и запущена в качестве задачи под "нормальной" ОС Unix. Первая версия системы имела очень консервативную (чтобы не сказать — архаичную) архитектуру, очень близкую к архитектуре ранних версий UNIX. Minix 2.0, выпушенный в 1996 г., основан на микроядре и поддерживает страничную виртуальную память на процессорах х86.
Основной целью разработки было создание системы, которая, с одной стороны, была бы работоспособна и могла бы продемонстрировать основные архитектурные концепции современных многозадачных ОС, а с другой -достаточно проста, чтобы студенты могли полностью в ней разобраться. Второе требование фактически исключало возможность доработки ОС до состояния, в котором она могла бы стать коммерчески применима.
Наиболее известен прямой потомок Minix, Linux. Первая версия Linux разрабатывалась путем переписывания ядра Minix модуль за модулем, что значительно упростило Л. Торвальдсу отладку системы. Воспоминаниями об этих временах в современном Linux является поддержка файловой системы minix, название основной ФС — ext2fs (Second Extended File System — расширенная [по сравнению с minix] файловая система, вторая версия) и реликты миниксового кода в некоторых модулях.


GNU Not Unix

Проект GNU был начат преподавателем Массачусетс кого технологического института Р. Столлмэном и имел целью разработку полностью свободной операционной системы. "Полная свобода" гарантировалась своеобразным лицензионным соглашением, так называемым copyleft — текст современной версии этого соглашения, GPL (General Public License — общая публичная лицензия), размещается в заголовке каждого файла исходного текста программных продуктов, распространяемых в соответствии с этой лицензией [www.fsf.org].
Вопросы о необходимости, целесообразности и допустимости этой схемы распространения ПО, а также о моральных, юридических, экономических, социальных и других последствиях ее применения до сих пор являются предметом жарких дебатов [www.tuxedo.org homesteading]. Тем не менее, в рамках деятельности FSF (Free Software Foundation — фонд свободного программного обеспечения) было разработано немало высококачественного и полезного ПО, прежде всего — компилятор GNU C/C++, текстовый редактор (и по совместительству интегрированная среда разработки) GNU Emacs, функциональные эквиваленты стандартных утилит UNIX и ряд других программ и утилит. Основной целью проекта объявлялась разработка GNU HURD, весьма амбициозной микроядерной ОС.
В 1996 г. публике была представлена крайне сырая альфа-версия системы. К тому времени Linux уже шествовал по планете победным шагом и отвлек на себя всех специалистов, способных участвовать в разработке ядра и согласных распространять результаты своей деятельности на условиях GPL. Наверное, из-за этого HURD не привлек внимания ни разработчиков, ни бета-тестеров. С тех пор до момента публикации этой книги не поступало ни новых версий, ни объявления о прекращении работ. По-видимому, следует признать, что проект HURD завершился провалом.


Open Software Foundation

Консорциум OSF, в который вошли DEC, IBM, Hewlett-Packard и ряд менее известных поставщиков рабочих станций и серверов, был создан в 1988 г. Его деятельность началась с принятия и публикации стандарта POSIX.1 (Portable OS Interface based on uniX — переносимый интерфейс ОС, основанный на Unix).
В рамках OSF начались работы по разработке ядца Unix-совместимой ОС, по архитектуре в целом аналогичной UNIX SVR3 (монолитное ядро с поддержкой STREAMS и некоторыми особенностями BSD). К моменту завершения разработки уже был выпущен UNIX System V Release 4.2 (Destiny), достигший всех целей, заявлявшихся в проекте UNIX System VI, и консорциум фактически распался. DEC Unix (известный также как OSF/1) оказался чрезмерно тяжеловесным, не имел коммерческого успеха и, возможно, сыграл немалую роль в судьбе компании DEC. Значительно более счастливой оказалась судьба IBM AIX, лишь частично основанной на коде OSF.
В 1996 г. то, что осталось от OSF, слилось с консорциумом Х/Open, деятельность которого по стандартизации Unix-систем имела гораздо больший успех.


Х/Ореn

Консорциум Х/Ореn [www.opengroup.org] был основан в 1990 г. и имел гораздо более широкий состав, чем OSF, включая в себя практически всех производителей и поставщиков Unix-систем и ряд образовательных учреждений. Вместо разработки новой версии системы консорциум занялся разработкой стандартов, которым система любого поставщика должна была удовлетворять, чтобы иметь право называться Unix.
Одним из главных достижений в деятельности этого консорциума следует считать разработку и публикацию спецификаций X Window — протокола распределенной графической оконной системы, который стал основой графического интерфейса практически всех Unix-систем.
В 1993 г. фирма Novell, которая к тому времени приобрела авторские права и команду разработчиков AT&T, передала консорциуму торговую марку UNIX. С тех пор консорциум выдавал право носить название UNIX(TM) системам, которые проходили тесты на совместимость с текущей версией спецификаций. Было также, наконец-то, решено, что торговой маркой является только UNIX (все буквы заглавные), но не Unix. Стандартизация оказала крайне благотворное влияние на рынок Unix-систем и приложений для них, практически устранив различия, существенные для разработки прикладного ПО, между наиболее распространенными системами семейства.
Поскольку сертификация была платной, некоммерческие версии системы, такие, как ветви BSD и Linux, ее не проходили. Неожиданным результатом сертификационной политики консорциума стало право OS/390 называться UNIX(TM) после прохождения тестов в 1998 г. [www.opengroup.org xu007].


UNIX System V Release 4

Обещанная в 1987 г. UNIX System VI вышла на рынок в 1989 г. под названием UNIX SVR4. Микроядерная система обеспечивала полную бинарную совместимость с SVR3, бинарную же совместимость с 16- и 32-разрядными Xenix на процессоре х86, и совместимость на уровне исходных текстов с BSD Unix v4.3 [Хевиленд/Грей/Салама 2000]. Заявленная цель консолидации всех основных ветвей Unix в единой системе была полностью достигнута. Sun Microsystems приступила к переводу своих пользователей на Sun OS 5.x (ныне известна как Solaris), основанную на ядре SVR4.
Версия SVR4 была этапной — она включала в себя следующие компоненты.

  • Многопоточное микроядро
  • Класс планирования реального времени (процессы с этим классом планирования имеют приоритет выше, чем нити ядра)
  • Новый формат загрузочного модуля ELF (Executable and Linking Format), обеспечивавший удобную работу с разделяемыми и динамическими библиотеками
  • Динамическое подключение и отключение областей своппинга
  • Динамическую загрузку и выгрузку модулей ядра
  • Многопоточность в пределах одного процесса (так называемые LWP
    (Light Weight Processes — легкие процессы))
  • Псевдофайловую систему /рrос, обеспечивающую контролируемый доступ к
    адресным пространствам других процессов и структурам данных ядра
  • Оптимизирующий компилятор ANSI С, по качеству кода не уступающий
    GNU С.

В 1991 г. подразделение AT&T, занимающееся развитием и поддержкой UNIX, было выделено в отдельное предприятие, USL (UNIX System Laboratories). Дальнейшая история этой организации представляет неплохой сюжет для романа: в 1992 г. USL была приобретена фирмой Novell — тогдашний CEO (Chief Executive Officer — главный администратор) компании Р. Нурда пытался сформировать линию продуктов, способную конкурировать со всеми предложениями Microsoft. В 1993 г. права на торговую марку UNIX были переданы консорциуму Х/Open. В 1995 г. акционеры Novell, испуганные перспективой конфронтации с Microsoft, сняли Нурду с поста СЕО и стали распродавать его приобретения. В частности, USL и лицензионные соглашения с распространителями UNIX SVR4 (Sun, Silicon Graphics, Microport и др.) были проданы фирме SCO. Нурда основал компанию Caldera, основным бизнесом которой стало распространение и поддержка Linux. 7 мая 2000 г. в тексте этой истории была поставлена ну, скорее всего, не точка, но весьма важный знак препинания: Caldera приобрела компанию SCO вместе со всеми правами на SVR4 [www.sco.com].
Эти перипетии не мешали развитию системы, или, во всяком случае, мешали не так сильно, как можно было бы ожидать: вышедшая в 1992 г. версия UNIX System V Release 4.2 включала графический пользовательский интерфейс Motif. Построенная на этом ядре UnixWare имела сравнительно неплохой эмулятор DOS/Windows 3.x, журнальную файловую систему Veritas, средства доступа к серверам файлов и печати Novell Netware. Из более свежих новшеств необходимо упомянуть UnixWare NonStop Clusters (многомашинные кластеры из компьютеров х86) и Tarantella — средство удаленного доступа к рабочим станциям Windows NT/2000/XP через протокол Microsoft RDP [www.tarantella.com].
В 1997 г. было заключено стратегическое соглашение с Hewlett Packard о разработке единой 64-разрядной версии ядра UNIX (SVR4 с успехом переносилась на 64-разрядные процессоры MIPS и SPARCv9 фирмами SGI и Sun соответственно, но это были независимые продукты). В 1998 г. была выпущена UnixWare/Merced, первая стабильная среда разработки для нового 64-разрядного поколения процессоров Intel.
Sun Solans v8, версия SVR4, поставляемая фирмой Sun Microsystems, при работе на серверах семейства Sun Fire поддерживает динамическое подключение, исключение и горячую замену процессорных модулей и модулей ОЗУ, а также одновременную работу двух копий Solaris на разных процессорах одного вычислительного комплекса с динамическим перераспределением ресурсов (процессоров, ОЗУ, дисков) между виртуальными системами.
На всем протяжении 90-х, архитектура ядра не подверглась существенным изменениям. Как и MVS полутора десятилетиями раньше, UNIX достиг совершенства в своем роде и нуждается не в новой архитектуре, а только в оптимизации существующего кода (ядро SVR4 несколько тяжеловато по сравнению с монолитными ядрами BSD и Linux) и развитии отдельных подсистем.


Linux

В 1991 г. Л. Торвальдс, в тот момент — студент университета Хельсинки, приступил к разработке того, что ныне известно как Linux — полноценной операционной системы, основанной на исходных кодах Minix и распространяемой на условиях GPL [www.linux.org].
В 1992 г. была выпущена первая публичная версия системы. К тому времени сообщество пользователей и разработчиков freeware уже успело устать от задержек выпуска GNU HURD и обещаний Столлмэна, и приняло новый проект с огромным энтузиазмом. Ряд компаний (RedHat, Caldera, SuSe и множество других) начал распространение коммерчески поддерживаемых дистрибутивов ОС на основе ядра Linux, воспроизводя таким образом бизнес-модель распространения AT&T UNIX в начале 80-х.
Вышедшее в 1997 г. ядро Linux 2.0 имело вполне приемлемую по стандартам коммерческих ОС надежность и почти все наиболее прогрессивные черты других Unix-систем.

  • Загрузочные модули и разделяемые библиотеки формата ELF
  • Псевдофайловую систему /рrос
  • Динамическое подключение и отключение своп-файлов
  • Длинные файлы (64-разрядные — длина файла и смещение в нем)
  • Многопоточность в пределах одного процесса (POSIX thread library)
  • Поддержку симметричной многопроцессорности
  • Динамическую загрузку и выгрузку модулей ядра
  • Стек TCP/IP, совместимый с BSD 4.4, с поддержкой IPSec, фильтрации пакетов и др.
    a sysvipc
  • Бинарную совместимость с UNIX System V на процессорах х86 (iBCS -Intel Binary Compatibility Standard) и, позднее, на SPARC и MIPS
  • Поддержку задач реального времени (класс планирования реального времени в монолитном Linux невозможен; такие задачи загружаются как модули ядра).

Linux перенесен практически на все 32- и 64-разрядные машины, имеющие диспетчер памяти, начиная от Amiga и Atari и заканчивая IBM System/390 и IBM z/90. Бинарные эмуляторы Linux включены в состав Solaris/SPARC и FreeBSD.
Ядро Linux быстро развивается и еще не достигло той степени "зрелости" и стабильности, которая характерна для SVR4 и ветвей BSD. В частности, поэтому среднее количество опасных ошибок, обнаруживаемых в системе за фиксированный интервал времени, существенно выше, чем в двух указанных ОС; производительность отдельных подсистем также оставляет желать лучшего. Однако положение довольно быстро улучшается и, по-видимому, в обозримом будущем Linux может стать одним из технологических лидеров отрасли.


Семейство СР/М

Родоначальником семейства является дисковая операционная система СР/М (Control Program/Monitor) фирмы Digital Research. Первая версия системы была разработана в 1974 г. для использования в инструментальных микропроцессорных системах на основе микропроцессоров 18080 и 18085.
Инструментальные микрокомпьютеры, популярные в 70-е годы, использовались как средство кросс-разработки и отладки программ для встраиваемых микропроцессорных систем. Типичная система такого типа состояла из микропроцессорной платы, устройства чтения/записи магнитных или перфолент, а позднее — накопителя гибких дисков и, наконец, видеотерминала. Можно считать их предками персональных компьютеров, но в описываемый период такие системы были слишком громоздки и дороги для домашнего и офисного использования.
СР/М была первой ОС для машин такого рода, обеспечившей возможность использования гибких дисков, поэтому она быстро приобрела огромную популярность и стала стандартом де-факто для микрокомпьютеров [Дейтел 1987]. Система была перенесена практически на все 8- и 16-разрядные и многие 32-разрядные микропроцессоры манчестерской архитектуры. Появившиеся в конце 70-х персональные компьютеры обычно также были ориентированы на использование СР/М. В начале 80-х были реализованы многозадачная и сетевая версии СР/М. Появилось также немало клонов системы, программно совместимых с ней и в целом аналогичных по архитектуре.
С архитектурной точки зрения, СР/М представляет собой довольно типичную однозадачную ДОС, предназначенную для работы на процессоре без диспетчера памяти и средств базовой адресации. К отличительным особенностям СР/М можно отнести следующие.

  • Своеобразный командный язык, представляющий собой подмножество DCL (DEC Command Language) — командного языка систем RT-11, RSX-11, VAX/VMS . Так, в DCL команды являются полными словами английского языка, но разрешено их сокращение: DIRECTORY, например, может быть сокращена до DIR или даже до DI — в СР/М же команда называется DIR.
  • Устройства последовательного ввода-вывода обозначаются трехбуквенными аббревиатурами, например TTY: обозначает телетайп, a LPT: — строчный принтер. Некоторые устройства, например, CON: (консоль), LST: (устройство вывода листинга) могут динамически переназначаться.
  • Диски обозначаются буквами латинского алфавита.

СР/М имеет модульную архитектуру и состоит из трех основных подсистем: командного процессора ССР (Console Command Processor), базовой дисковой операционной системы BDOS (Basic Disc Operating System) и базовой системы ввода-вывода BIOS (Basic Input/Output System). ССР и BDOS представляют собой неизменные компоненты системы, BIOS содержит драйверы физических устройств и подлежит перекомпоновке при каждой перегенерации системы для новой конфигурации аппаратуры. Память, не занятая компонентами системы и таблицей векторов прерываний, называется ТРА (Transient Program Area — область пользовательских [дословно — преходящих] программ).
В 1981 г. фирма IBM анонсировала персональный компьютер IBM PC на основе 16-разрядного процессора i8088. Первоначально предполагалось, что в качестве ОС для этого компьютера будет использоваться СР/М, однако представителям IBM не удалось достичь приемлемых условий соглашения с Digital Research. Ни история, ни легенды не сообщают нам о том, что именно послужило причиной разногласий.
Легенды, однако, сохранили для нас немало подробностей (к сожалению, не очень достоверных) дальнейшего развития событий. Вместо того, чтобы пойти на компромисс с Г. Кидалом, CEO Digital Research, представитель IBM обратился к сыну одной из своих старых знакомых, Биллу Гейтсу. Билл Гейтс в это время занимался продажей собственного интерпретатора языка BASIC для любительских 8-разрядных микрокомпьютеров. Билл не имел ни опыта разработки ОС или ДОС, ни даже теоретической подготовки в этой области, поскольку был выгнан из колледжа. Однако IBM обещала щедрую предоплату и вообще довольно выгодные условия, поэтому Билл взялся за проект.

Примечание
Нужно отметить, что эта часть легенды несколько расходится с достоверными историческими сведениями. В описываемый период Microsoft занимался не только и даже не столько ВАЗЮ'ом, сколько собственной ОС, основанной на Unix v.6 - Microsoft Xenix. Эта система была реализована для нескольких микропроцессорных систем на 32-разрядных микропроцессорах, таких, как MC68000, NS32032 и даже 18086/8086.
По другой версии легенды, Билл Гейтс первоначально предложил Xenix, но представители IBM хотели что-нибудь похожее на СР/М.

Гейтс приобрел за 13 тысяч долларов лицензию на систему QDOS — клон СР/М, разработанный компанией Seattle Computer Products. По легенде, QDOS расшифровывается как Quick and Dirty Operating System - "Быстро [сделанная] и Грязная Операционная Система".
Фирма Microsoft переделала загрузчик и дисковую подсистему QDOS для работы с IBM PC и использования сервисов ПЗУ этого компьютера (эти сервисы также называются BIOS, хотя имеют довольно мало общего с BIOS СР/М), и предложила результат фирме IBM. Заказчики оказались довольны, и Билл быстро стал миллионером.
К версии 3.30 MS DOS (такое название получила новая система) уже накопила очень много отличий от оригинальной СР/М. В качестве файловой системы была использована изобретенная лично Б. Гейтсом для применения в интерпретаторе BASIC ФС FAT. Эта ФС была переделана так, чтобы в ней можно было создавать вложенные каталоги. Был добавлен новый формат загрузочного модуля — вдобавок к абсолютным загрузочным файлам формата СОМ (совместимых с СР/М]) были реализованы относительные загрузочные файлы ЕХЕ (известные также как файлы MZ). Были реализованы загружаемые драйверы внешних устройств. Динамическая загрузка и выгрузка драйверов не поддерживалась, но, по крайней мере, изменение номенклатуры драйверов теперь не требовало перегенерации системы. Список загружаемых драйверов задавался текстовым файлом C:\CONFIG.SYS. Позднее был даже реализован интерфейс для драйверов файловых систем.
Была разрешена загрузка нескольких программ в стековом порядке (впрочем, не допускалось их параллельного исполнения). Система приобрела многие черты, аналогичные примитивным версиям Unix — так, каждая загруженная программа в MS DOS снабжается дополнительным сегментом памяти, так называемым PSP (Program Segment Prefix — заголовок программного сегмента), который аналогичен User area (пользовательской области, сегменту данных ядра ОС, относящихся к конкретному процессу) в Unix. В некоторых документах сегментный адрес PSP даже называют pid (Process Identifier, по аналогии с идентификатором процесса в Unix). Как и в Unix, исполнение системного вызова сопровождалось переустановкой стека. В состав системы были включены:

  • файловый API, очень похожий на интерфейс файловых системных вызовов в Unix;
  • переменные среды;
  • переназначение ввода-вывода;
  • и даже конвейеры (последовательности задач, в которых поток стандартного вывода предыдущей задачи является потоком стандартного ввода следующей), реализуемые через промежуточный файл.

По одной из легенд, нежелание Microsoft реализовать в DOS вытесняющую многозадачность обусловлено не столько технической некомпетентностью, сколько соглашением с фирмой SCO — в соответствии с ним, передавая права на торговую марку Xenix, Microsoft обязалась не разрабатывать и не продавать функциональных эквивалентов UNIX. Такое соглашение существовало в действительности: через много лет, в 1997 г., SCO отсудила у Microsoft принадлежавшую последней долю своих акций и ряд других обязательств (упоминание Microsoft в списке держателей авторских прав, поддержку бинарной совместимости с Xenix/286) на том основании, что Microsoft рекламировала Windows NT как замену и даже "убийцу" UNIX и, таким образом, нарушала соглашение.
Со времен DOS 3.30 архитектура системы не подверглась сколько-нибудь заметным изменениям [Панкратов 2001]. Так, DOS 7, входящая в состав Windows 98/ME в качестве вторичного загрузчика, отличается от 3.30 только поддержкой файловой системы FAT32.
Digital Research не смотрела на это развитие безучастно. К концу 80-х система DR DOS, основанная на исходных текстах оригинальной СР/М, обеспечивала полную программную совместимость с MS DOS и включала в себя все новшества не только версии 3.30, но и более поздних версий.
Ряд полезных идей, впервые реализованных в DR DOS, такие, как загрузка в верхнюю память, условные операторы в CONFIG.SYS и упакованная файловая система, появились в MS DOS лишь на одну или две версии позже, а некоторые идеи — например, экранный редактор командной строки (возможность, знакомая пользователям командных процессоров DCL, bash и 4DOS/4OS2/4NT) и загрузка из расширенного раздела — не были реализованы в MS DOS и Windows 95/98/ME никогда.
Ближе к середине 90-х стало очевидно, что дни DOS как платформы сочтены (впрочем, мало кто ожидал в то время, что в той или иной форме эта платформа сможет прожить до самого конца столетия). В 1993 г. Digital Research вместе с авторскими правами на DR DOS была приобретена фирмой Novell. В 1995 г., вскоре после того, как собрание акционеров выгнало Р. Нурду с поста СЕО, авторские права на этот продукт были переданы созданной им компании Caldera. Несколько позднее Caldera опубликовала исходные тексты системы на условиях GPL под называнием Caldera OpenDOS [www.caldera.com]. С тех пор эта система широко используется в составе DOS-эмуляторов различных дистрибутивов Linux.


Win16

Вскоре после анонса Apple Macintoch в 1984 г., Microsoft выпустила электронную таблицу Excel и текстовый процессор Word для этой системы. Автор не может подтвердить это официальными данными, но трудно избавиться от впечатления, что основной задачей при разработке Win 16 было максимальное облегчение переноса приложений Мае на IBM PC.
Версии Windows 2.x—3.x воспроизводят почти все характерные черты Mac OS.

  • Событийно-ориентированную кооперативно многозадачную архитектуру
  • Единое адресное пространство
  • Сборку программ в момент загрузки с использованием DLL П "Ручечное" управление памятью
  • И даже соглашение о вызовах у процедур системного API: параметры помещаются в стек, начиная с первого, стек очищается вызываемой процедурой

Ядро системы было собрано в виде загрузочного модуля DOS (win.exe). После загрузки этот модуль брал на себя управление памятью и осуществлял загрузку собственных и пользовательских модулей формата NE (так называемые сегментированные модули). DOS, однако, сохранялась в оперативной памяти и использовалась в качестве дисковой подсистемы.
Первые версии системы были совершенно неудовлетворительными не только с точки зрения надежности, но и по производительности. Довольно большие требования к ресурсам не позволяли запустить сколько-нибудь ресурсоемкое приложение в 640К ОЗУ, системы же с большим объемом памяти были в то время редкостью. Больший объем памяти был доступен только на машинах IBM PC AT с процессором 80286. На таких компьютерах обращения к DOS требовали переключения в реальный режим процессора и поэтому происходили очень медленно.
Значительный прорыв в эксплуатационных характеристиках Windows 3.x обеспечил процессор 80386, на котором можно было создать для DOS виртуальный 8086. Это позволило избежать переключений режима процессора на каждом системном вызове и резко повысило производительность. Еще большее повышение производительности было достигнуто в Windows 3.11 с появлением так называемого 32-разрядного доступа к диску — собственной дисковой подсистемы, которая работала целиком в защищенном режиме. Тем не менее, надежность даже этих версий системы оставляла желать много лучшего.
В Win 16 впервые была реализована технология, без упоминания которой описание этой системы было бы не полным — не только потому, что это одна из немногих оригинальных концепций, впервые реализованных в системах семейства СР/М, но и потому, что эта технология оказала значительное влияние на современные методики разработки прикладного программного обеспечения. Речь идет о технологии COM (Common Object Model — общая объектная модель).
Идея, лежащая в основе СОМ, довольно проста и решает весьма насущную проблему: точки входа DLL не хранят сведений не только о семантике соответствующих процедур, но даже о количестве и типах параметров, передаваемых этим процедурам. Различные системы программирования используют разные соглашения о способе передачи параметров — заголовок DLL не хранит информации и об этом. Отсутствие перечисленных сведений затрудняет взаимодействие между подсистемами, реализованными на разных языках, и делает невозможным синтаксическую проверку допустимости вызова внешних процедур из интерпретируемых языков.
СОМ предполагает снабжение DLL внешним описателем, который перечисляет все процедуры, реализуемые данной DLL, и типы данных, используемые этими процедурами. Описание формируется на специальном языке IDL (Interface Definition Language — язык описания интерфейса), который затем компилируется в двоичное представление, используемое объектными средами и интерпретирующими системами программирования. Современные системы программирования выполняют автоматическую генерацию IDL и "болванок"(заготовок) кода, реализующего данный интерфейс, на конкретном языке программирования.
IDL является довольно простым языком, на котором можно описывать объекты — структуры данных, с которыми ассоциированы наборы процедур-методов. Поля структур (атрибуты) могут принадлежать одному из нескольких скалярных типов (целое число, число с плавающей точкой, дата и время, строка — последний тип в большинстве компилируемых ЯВУ не является скалярным). Допустимы также атрибуты, являющиеся объектами других классов. Методы объекта в качестве параметров могут получать как значения скалярных типов, так и объекты, и используют стандартное соглашение о вызовах — тем самым облегчается взаимодействие подсистем, реализованных на разных языках программирования.
Объектно-ориентированный стиль описания интерфейсов является популярной методологией описания и разработки сложных программных систем, поэтому, несмотря на многочисленные недостатки технологии СОМ (например, не поддерживается контроль версии интерфейса и наследование) она была хорошо принята сообществом разработчиков. Впрочем, заявленная в начале работ над этой технологией цель — переход от монолитных приложений к компонентным средам, составляемым из взаимозаменяемых объектов СОМ — достигнута не была. Достижению этой цели не способствовала также техническая политика Microsoft, состоявшая в низкокачественной и неполной документации и хаотических сменах флагманской объектной среды (версии OLE, ActiveX, OCX и т. д.).


OS/2 1.x

Параллельно с развитием Win 16, во второй половине 80-х Microsoft занималась разработкой еще одной операционной системы, в данном случае совместно с фирмой IBM. OS/2 создавалась как ОС для новой серии машин IBM Personal System/2, основанных на процессоре 80286. Архитектура системы представляет собой самое полное из известных автору воплощение идей, которые имел в виду Intel, разрабатывая этот процессор. Весьма ограниченный успех этой системы обусловлен, по-видимому, несостоятельностью идей Intel, а не качеством их воплощения.
Система использует сегментированную виртуальную память и сборку в момент загрузки. Формат загрузочных модулей и DLL тот же самый, что в Win 16 — NE. Однако система имеет раздельные адресные пространства — задачи не имеют доступа к сегментам данных и приватным сегментам DLL других задач. Сегменты кода — разделяемые и защищены от записи. К сожалению, 80286 не обрабатывал сегментных отказов, поэтому виртуальная память использовалась лишь для защиты задач друг от друга, но не для сегментной подкачки [Коган/Роусон 1989, Лафо/Нортон 1991].
OS/2 реализует вытесняющую многозадачность, многопоточность в пределах одной задачи и богатый набор примитивов взаимоисключения (семафоры как двоичные, так и счетчики, очереди сообщений). Ядро — кооперативно многозадачное с управляемыми сообщениями асинхронными драйверами. Одной из отличительных особенностей системы является мощный механизм обработки исключений, аналогичный используемым в MVS-OS/390-z/OS и VMS.
Одной из главных задач при разработке системы было максимальное облегчение переноса программного обеспечения (как прикладного, так и системного, включая и драйверы устройств) из MS DOS. Эта цель была в основном достигнута: все системные вызовы DOS имели полные функциональные эквиваленты в OS/2, и достаточно аккуратно написанные программы для DOS могли быть перенесены в OS/2 1.x простой перекомпиляцией.
Впрочем, оказалась неразрешимой другая, более важная задача — обеспечение бинарной совместимости. Процессор 80286 в защищенном режиме не имел возможности исполнять программы для реального режима 8086. Для исполнения бинарных модулей DOS была нужна полноценная копия DOS и переключение режима процессора. Таким образом, в системе могла исполняться только одна сессия DOS, а во время ее работы вся активность приложений OS/2 полностью прекращалась.
Из-за этого недостатка OS/2 1.x имела успех лишь в качестве серверов файлов и печати в сетях NETBIOS (LAN Manager и серверов приложений: Lotus Notes, Sybase и др).
Бинарная несовместимость с DOS могла быть преодолена только с использованием возможностей процессора 80386. Существовали и другие показания к переходу на этот процессор: например, возможность страничной подкачки. Кроме того, используемая в х86 плоская модель памяти упрощает программирование, снимает ограничение в 64Кбайт на переменную и дает много других преимуществ.
В этот момент между партнерами возникли серьезные разногласия в вопросе о том, как следует переходить на новый процессор. Предложенная фирмой Microsoft архитектура новой 32-разрядной версии системы, (OS/2 New Technology) оказалась абсолютно неприемлемой для IBM.
Камнем преткновения стал вопрос о том, как следует организовывать взаимодействие между 16-разрядным кодом, использующим сегментированную память, и 32-разрядным, использующим линейное адресное пространство.
На самом деле, адреса в обеих моделях памяти имеют длину 32 бита, но в 16-разрядной модели адрес разбит на селектор сегмента и смещение в нем. Это разбиение накладывает серьезные ограничения на указательную арифметику. Задача преобразования 16-разрядного указателя 80286 в 32-разрядный достаточно проста; задача же обратного преобразования требует нетривиальных вычислений и в общем случае во время исполнения неразрешима.
Предложение Microsoft состояло в том, чтобы сохранить бинарную совместимость с программами для OS/2 1.x и дать им возможность обращаться к новым 32-разрядным DLL и системным модулям, но не предоставлять возможности для 32-разрядных приложений обращаться к старым 16-разрядным DLL. Это решение требовало полной переделки всех сервисных подсистем (включая графическую подсистему Presentation Manager), ядра ОС и подсистемы ввода-вывода (т. е. всех драйверов) в 32-разрядную модель памяти. Переделка драйверов требовала отказа от совместимости с существующими драйверами устройств, файловых систем и сетевых протоколов для OS/2 1.x.
IBM предложила более элегантное решение, требовавшее, однако, переделки компилятора: предлагалось научить компилятор при вызове из 32-разрядного кода 16-битной процедуры генерировать специальный код, осуществляющий преобразование "плоского" указателя в сегментированный для всех параметров-указателей (пример П.1). Компилятор должен был принимать решение о необходимости такого преобразования на основе прототипа вызываемой функции.

Пример П.1. Код, порождаемый компилятором IBM Visual Age C++ при вызове 16-разрядной функции

104 /*
105 * открыть обработчик
106 *для мыши
107 */
108 MouOpenfNULL,&hmou) ; sub esp,038h
push Oh
mov eax,offset FLAT:hmou call _DosFlatToSel push eax
push 08h
push eax
mov eax,offset FLAT: MOU16OPEN
call _DosFlatToSel
xchg eax, dword ptr[esp]
push Oh
call __EDC3216
add esp,04ch

Сведений о ходе переговоров история не сохранила, однако по косвенным признакам они были весьма бурными. По причинам, изложенным в главе 10, решение об отказе от поддержки существующих драйверов и DLL было абсолютно неприемлемо для IBM. Почему переделка компилятора не устраивала Microsoft, менее понятно. Автор не располагает достоверными сведениями на этот счет, но есть ряд косвенных оснований предполагать, что взаимодействие между подразделениями Microsoft оставляет желать много лучшего, так что создатели ОС попросту не имели возможности (или даже права) выдвигать столь сложное требование к разработчикам компилятора. Возможно, что на результат переговоров повлияли и какие-то другие, например, сугубо политические или даже психологические факторы.
Важно отметить, впрочем, что никаких реальных проблем реализация данного требования не представляла: практически все 32-разрядные компиляторы для OS/2 2.x — Zortech C++, Watcom C++, IBM C/Set (позднее IBM Visual Age for C++) — с успехом выполняют преобразование указателей.
Так или иначе, переговоры не только проходили бурно, но и закончились разводом. Дальнейшая судьба OS/2 — это совсем другая история, или, точнее сказать, две разные истории.


IBM OS/2

Первая 32-разрядная версия OS/2 2.0 широко использовала прием, описанный в примере П.1, и представляла собой сочетание 32- и 16-разрядных подсистем. Так, подсистема ввода-вывода была полностью 16-разрядной и, тем самым, обеспечивала полную совместимость со старыми драйверами и другими модулями ядра.
Тем не менее, система в полной мере использовала преимущества, предоставляемые новым процессором, такие, как страничная подкачка и режим виртуального 8086 [Минаси/Камарда 1996]. Реализованный в OS/2 2.x эмулятор DOS является одним из крупнейших достижений в сфере разработки виртуальных машин — фирма IBM имеет немалый опыт создания, поддержки и эксплуатации систем виртуальных машин для System/370-390 — и, безусловно, он остается лучшим в мире эмулятором DOS на момент написания книги (в связи с общим снижением интереса к приложениям DOS, вполне возможно, что этот эмулятор останется таковым навсегда). Для сравнения, эмулятор DOS в Windows NT/2000/XP уступает ему как по возможностям настройки, так и по универсальности; сессия DOS в Windows 95/98/ME не является эмулятором — запущенное в этой сессии приложение имеет возможность модифицировать критичные для системы данные и проблемы в этом приложении часто приводят к необходимости перезапуска всей ОС, иногда даже холодного. Про эмуляторы DOS в SVR4/X86 и Linux автор может сказать лишь словами поэта:

Иных не стану поминать
Они под солнцем хладным зреют
Бумаги даже замарать
И то, как надо, не умеют.
С. Есенин

Система имеет объектно-ориентированный пользовательский интерфейс, основанный на компонентах SOM (System Object Model). Еще одной, менее известной, но не менее важной на взгляд автора, уникальной особенностью IBM OS/2, является возможность установки пользовательской программой собственного обработчика страничных отказов. Данная особенность уникальна — во всяком случае, среди известных автору промышленно используемых ОС, и позволяет реализовать в пользовательском адресном пространстве функции, которые в других ОС могут исполняться только модулями ядра. Так, свободно распространяемая библиотека ЕМХ использует этот механизм для реализации полного функционального аналога системного вызова fork ОС семейства Unix; известен ряд реализаций отображения файлов в адресное пространство памяти.
Развитие системы сопровождалось постепенной заменой 16-разрядных подсистем на 32-разрядные. В версии 4.5 — Warp Server for e-Business — была наконец-то реализована 32-разрядная подсистема ввода-вывода, и это позволило перенести в OS/2 сугубо 32-разрядный код журнальной файловой системы jfs, первоначально разработанной для IBM AIX.
В версии 4.0 появился, а в версии 4.5 был включен в стандартную комплектацию стек TCP/IP, совместимый с BSD 4.4, с поддержкой IPSec и фильтрации пакетов [redbooks.ibm.com sg245393].
Первые версии системы отличались большими по тем временам требованиями к ресурсам (для нормальной работы требовалось около 16 Мбайт ОЗУ, по меркам начала 90-х — чрезвычайно много), и поэтому тоже имели успех преимущественно в качестве серверов. Некоторые мелкие улучшения позволили в версии 3.0 снизить минимальные требования до 8 Мбайт. Параллельно шло снижение цен на оперативную память, поэтому шансы OS/2 на получение массового признания в качестве ОС для настольного компьютера все возрастали и достигли максимума примерно в 1995—1996 гг.
203ак 86
В это время, однако, подразделение персональных систем IBM увлеклось другим проектом, на который сообщество пользователей OS/2 также возлагало большие надежды — OS/2 for PPC. Дело в том, что в описываемый период и среди пользователей, и среди производителей вычислительных систем преобладала точка зрения, что Intel исчерпал резервы повышения производительности своих процессоров и не может ни поднять тактовую частоту ЦПУ выше 60—80 Мгц, ни значительно повысить количество операций, исполняемых за один такт. Единственной перспективой повышения производительности представлялись RISC-архитектуры, в том числе — разрабатываемый совместно компаниями IBM, Apple и Motorola микропроцессор архитектуры PowerRISC. Первые процессоры с такой системой команд были разработаны фирмой IBM для рабочих станций и серверов серии RS/6000, но реализовались на нескольких микросхемах. Однокристальный процессор и соответствующие микросхемы окружения (адаптеры системной и периферийной шин и т. д.) должны были резко снизить стоимость системы, переведя ее из категории рабочих станций в персональные компьютеры.
IBM рассчитывала воспроизвести успех IBM PC, опубликовав полные спецификации и, таким образом, привлечь производителей клонов новой архитектуры, получившей название Power PC. Для этой системы была начата разработка новой версии OS/2. Бинарная совместимость с существующим кодом на новом процессоре была, конечно же, невозможна, поэтому IBM с легким сердцем пошла на пересмотр архитектуры. Новая система должна была стать полностью 32-разрядной и микроядерной, совместимой с OS/2-x86 лишь на уровне исходного кода приложений.
К несчастью, пока новая система разрабатывалась, Intel все-таки посрамил скептиков и преодолел барьеры в 60, а затем и 100 Мгц, и выпустил новое семейство суперскалярных микропрограммируемых ядер — сначала двухкристальные сборки Pentium Pro и Pentium II (на самом деле, тот же Pro, только с более удачной конструкцией корпуса) и, наконец, однокристальный Pentium III. Новое ядро позволило исполнять по несколько команд за такт и, таким образом, перейти второй якобы непреодолимый для х86 барьер.
Для проекта PowerPC это было крахом. Хотя система по-прежнему превосходила машины на основе х86 как по абсолютной производительности, так и по отношению производительности к цене, теперь разница была не настолько велика, чтобы оправдать для конечного пользователя переход на новую архитектуру и отказ от старых приложений. Микропроцессоры Power имели успех в составе новой линии персональных компьютеров Apple (PowerMac) и рабочих станций, но не смогли составить конкуренции на рынке PC. Эталонная реализация спецификаций РРС была выпущена и изготавливалась небольшими сериями, вышли версии Linux, Solans и Windows NT 4.0 для новой архитектуры, но коммерческого успеха машина не имела и не могла оправдать разработку полностью новой ОС.
Работы по OS/2 for PPC были свернуты. Разочарование руководства IBM было столь сильным, что были также прекращены работы по другим перспективным технологиям — объектной модели SOM (System Object Model) и стандарту OpenDOC. Чувствуя потерю интереса к системе со стороны ее поставщиков, многие разработчики приложений отказапись от ее поддержки.
К моменту написания книги слухи о смерти OS/2 сильно преувеличены. В 2001 г. был выпущен совместный продукт IBM и Serenity Systems — eComstation [www.ecomstation.com], клиентская версия системы, основанная на ядре версии 4.5. Тем не менее, очевидно, что система вытеснена на периферию сферы внимания пользователей и разработчиков программного обеспечения для х86.


Windows NT/2000/XP

Наработки Microsoft no OS/2 New Technology были в 1993 г. выпущены на рынок под названием Windows NT. Версии 3.x и 4.0 этой системы обеспечивали совместимость с 16-разрядными приложениями для OS/2 1.x в отдельной подсистеме, без возможности обращаться из 16-разрядных приложений к 32-разрядным DLL и наоборот. В описываемый период из DEC в Microsoft в полном составе перешла команда разработчиков ядра VMS под управлением Д. Катера. Microsoft широко рекламировал этот факт и утверждал, что Windows NT находится с VMS в гораздо более близком родстве, чем с OS/2 1.x. Из табл. П.1 видно, что это утверждение не очень-то согласуется с действительностью.

Таблица П.1. Сравнение OS/2 1.2, Windows NT и VMS

 | OS/2 1.x | Windows NT 3.x | VMS
Mногозадачность | Вытесняющая | Вытесняющая | Вытесняющая
Ядро | Монолитное | Монолитное | Монолитное
Ввод-вывод | Асинхронный | Асинхронный - | Асинхронный
Защита памяти | Сегментная | Страничная | Страничная
 | Трехуровневая | Двухуровневая | Трехуровневая
Сборка при загрузке | Динамическая | Динамическая | Статическая
Подкачка | Задачная | Страничная | Страничная
Поиск жертвы | — | FIFO | FIFO
Файловая система | Без транзакций | Журнальная | Журнальная
Программный RAID | RAID I | RAIDO I 5 | RAIDO 1
Длина имени файла | 256 | 256 | 32+16
Версии файлов | Нет | Нет | Да
Форматы файлов | Потоковый | Потоковый | Блочный
 |  |  | Относительный
 |  |  | Индексно- последовательный
Командный процессор | cmd.exe | cmd.exe | DCL
Граф, подсистема | РМ | Win32 | X Window
Ид. пользователя | Вся система | Задача | Задача
БД учетных записей | Распределенная | Распределенная | Локальная
Сетевой протокол | NETBIOS/SMB | NETBIOS/SMB | DECNet

Наиболее важные заимствования из VMS — страничная подкачка и идентификация пользователя на уровне процессов — являлись ответом на насущные требования развития системы и могли быть заимствованы из любой ОС, адекватной времени. В остальном, табл. П.1 показывает, что OS/2 1.x, безусловно, приходится Windows NT гораздо более близкой родней, чем VMS.
Наиболее важной заимствованной концепцией была журнальная файловая система NTFS, представляющая собой любопытный гибрид HPFS (основной ФС OS/2) и FCS2 (основной ФС VAX/VMS). Это заимствование следует признать довольно удачным. Гораздо менее удачным было заимствование своеобразной стратегии управления рабочими множествами процессов в ОЗУ, используемой в VMS: разработчики Microsoft устранили из этой стратегии одно из ключевых понятий, квоту размера рабочего множества. В результате получилась система, практически не способная воспользоваться преимуществами страничной подкачки, потому что даже небольшая нехватка оперативной памяти приводит к резкому падению производительности из-за неспособности системы сбалансировать потребности приложений и дискового кэша. Еще одна ключевая для понимания архитектуры Win32 концепция была позаимствована вовсе не из VMS и даже не из OS/2 1.x, а была, скорее всего, введена по настоятельным просьбам разработчиков графических приложений для Apple Macintosh. Речь идет о системном реестре (system registry), централизованной базе данных, в которой все модули системы, стандартные утилиты и прикладные программы хранят все, что считают нужным сохранить.
Системный реестр впервые был реализован в Mac OS. Эта система имеет довольно простое ядро и небогатый набор системных настроек, поэтому реестр Mac OS в основном содержит настройки прикладных программ и в такой форме вполне терпим. Напротив, довольно сложная многопользовательская Windows NT, поддерживающая широкий спектр внешних устройств, нуждается в большом объеме конфигурационных данных для самой системы. Характер обращений к разным частям этих данных сильно различается — некоторые, например, нужны только при загрузке системы, а изменению подлежат только при изменении аппаратной конфигурации. Другие же меняются при каждом открытии нового окна пользовательской программой. Относительная ценность этих данных также различается очень резко: искажение некоторых может привести к невозможности загрузить систему или к потере пользователями доступа к ней, некоторые другие можно было бы и не хранить вовсе. В свете этого, идея общей "свалки", в которой содержится все на свете, — начиная от слов, которые произносил пользователь, пытаясь убрать с экрана знаменитую скрепку, и заканчивая БД учетных записей -представляется автору не очень-то здравой мыслью.
В Windows NT этот концептуальный недостаток усугубляется недостатками реализации — реестр не имеет адекватных средств резервного копирования и восстановления (при фатальных повреждениях реестра Microsoft рекомендует переустановку системы) и фактически лишен средств самоконтроля и диагностики. Во всяком случае, в версии 4.0 (автор не имел случая проверить это на более поздних версиях системы, но судя по тому, что исправление этой ошибки не анонсировалось, в 2000/ХР ситуация не изменилась) ОС никогда не уменьшала объем реестра, даже после удаления большого количества ключей.
Еще одним важным новшеством была поддержка нескольких процессоров — кроме х8б первые версии Windows NT были реализованы для RISC-процессоров MIPS и DEC Alpha, и, существенно позднее, для PowerPC. Большинство RISC-процессоров не имеют многоуровневых режимов доступа, характерных для VAX и 80286Д86, поэтому разработчики Windows NT были вынуждены отказаться от привилегированных разделяемых библиотек (понятие, которое в той или иной форме присутствовало как в OS/2 1.x, так и в VAX/VMS) и перейти к двухуровневой системе привилегий.
Разработчики приложений не проявили интереса к альтернативным аппаратным архитектурам, поэтому NT на этих архитектурах не имела большого успеха, и в 1999 г. без большого шума была прекращена поддержка Windows NT для последнего неинтеловского процессора, который к тому времени уже назывался Compaq Alpha [techupdate.zdnet.com]. К тому же, пока NT была малоиспользуемой системой с бедным набором сетевых сервисов, мало кто всерьез интересовался ее взломом. Это привело к усилению давления со стороны управленцев - "вот видите, у соседей стоит -- и ничего", поэтому серверы под управлением NT все чаще и чаше подключались к Internet, иногда даже без закрытия каким-либо брандмауэром (надо отметить, что firewall (брандмауэр) в данном случае мало чем может помочь — сайт [www.microsoft.com] закрыт маршрутизатором с фильтрацией пакетов "по самые уши", и то его "роняют" несколько раз в год). Распространение системы привело к тому, что взломщики из спортивного интереса заинтересовались ею всерьез. Первой ласточкой был выпущенный в 1997 г. свободно распространяемый продукт Back Orifice (дословно — "задний проход"), демонстрировавший целый набор способов получения неавторизованного доступа (в том числе и с последующей установкой троянских программ) к системам Win32. Устанавливаемый в качестве троянской программы компонент пакета долгое время был лучшим из доступных инструментов удаленного управления Win32-системами и автор знает немало системных администраторов, которые использовали его в своих сетях [www.sourceforge.net bo2k]. Впрочем, одна ласточка весны не делает, и еще три года пользователи Win32-cHCTCM жили относительно спокойно (если можцо считать спокойной жизнью постоянную борьбу с макровирусами для MS Office, почтовыми вирусами и другими порождениями больной фантазии). За это время в систему добавились новые сетевые сервисы и расширилась номенклатура сервисов, запускаемых при установке ОС по умолчанию — например, в их число попал флагманский серверный продукт, сервер ftp/HTTP и ряд других протоколов, IIS (Internet Information Server). Собственно весна настала в августе 2001 г. с пандемией сетевого червя Code Red, который, как и червь Морриса, использовал несколько каналов распространения, в том числе срывы буфера в сетевых сервисах IIS. Как и червь Морриса, заразив одну из машин домена, Code Red распространялся на другие машины того же домена простым копированием по сети. Дальнейшее развитие событий, впрочем, резко отличалось от последствий атаки червя Морриса: Microsoft довольно быстро выпустила заплаты (patches), исправлявшие часть используемых вирусом ошибок — однако полное количество ошибок, оставшихся в коде системы и сетевых сервисов, от этого почти не изменилось. Атаки червей и поливалентных (использующих несколько каналов распространения) вирусов, которые легко преодолевают корпоративные брандмауэры (firewalls), продолжались на протяжении всего 2001 г., демонстрируя все новые и новые проблемы в системе безопасности Windows NT/2000/XP. В опубликованном в сентябре 2001 г. докладе аналитическая компания Gartner Group рекомендовала ни при каких обстоятельствах не использовать IIS из-за огромного количества известных и весьма пессимистических прогнозов на количество неизвестных уязвимостей. К моменту написания книги прогнозировать дальнейшее развитие событий не представлялось возможным. Очевидно, что ситуация с безопасностью Windows может только ухудшаться — или, точнее, абсолютная нетерпимость положения дел с безопасностью в Windows может становиться только более и более очевидна все большему и большему кругу людей. Попытки исправить положение законодательными мерами, например, применяя уголовные наказания к разработчикам вирусов или запрещая публикацию сведений о проблемах с безопасностью, вряд ли могут изменить тенденцию. Так, наказание для разработчиков вирусов, хотя и морально оправданно, но вряд ли может быть эффективным, потому что в большинстве случаев создателя практически невозможно идентифицировать, а идентифицировав — весьма сложно доказать его вину по стандартам судопроизводства демократических стран. В свою очередь, законодательный запрет публикации сведений об ошибках, разговоры о котором начались в 2001 г., не только абсолютно не оправдан морально, но и крайне вреден с прагматической точки зрения, хотя бы только потому, что сделает невозможным принятие контрмер администраторами уязвимых систем. Оптимистический сценарий развития событий может состоять в том, что пользователи начнут массовым образом отказываться от применения Windows, или Microsoft пересмотрит свой подход к проектированию, разработке и тестированию программного обеспечения (или в данном случае включающее). Так или иначе, исправление положения потребует значительных вложений в перестройку всей вычислительной инфраструктуры и не может пройти безболезненно. Впрочем, исторический опыт дает автору весьма мало оснований для оптимизма.


Windows 95/98/ME

В первой половине 90-х годов XX столетия практически всем разработчикам и техническим специалистам было очевидно, что MS и DR DOS доживают последние дни: они не удовлетворяли запросам пользователей практически ни по одному из параметров: приложения требовали больших объемов памяти и перехода к 32-разрядной архитектуре, пользователям требовались большая надежность, многозадачность, более развитые сетевые средства. Напротив, преимущества DOS, такие, как небольшая потребность в памяти, становились все менее и менее критичными. Основным препятствием на пути перехода пользователей на другие платформы было требование совместимости с существующими приложениями и драйверами нестандартных внешних устройств для DOS. Наилучшим образом удовлетворяла этому требованию IBM OS/2, в виртуальной машине которой можно было запустить не только практически любое приложение DOS, но и использовать многие модули ядра DOS, в том числе — загружая в разных виртуальных машинах разные версии ДОС и разные наборы драйверов. Однако высокие требования этой системы к ресурсам и ориентированная на корпоративных пользователей схема лицензирования приводили к тому, что система не получила большого распространения на массовом рынке. В 1992-1993 гг. Microsoft занялась разработкой системы, которая должна была заполнить перспективную рыночную нишу "многозадачной ДОС защищенного режима". Подобно марксизму, разрабатываемая ОС имела три источника и три составные части.

  1. 1. Windows NT
  2. 2. DesqView и другие многозадачные среды для DOS
  3. 3. Windows 3.x

От Windows NT новая система получила интерфейс системных вызовов — Win32 API — и формат загружаемого модуля РЕ (Portable Executable — переносимый исполняемый [модуль]). У многозадачных сред разработчики новой ОС позаимствовали идею преобразования DOS в многозадачную среду защищенного режима: эти среды демонстрировали, что помещение ядра DOS в виртуальный 8086 и окружение его семафорами позволяет относительно малой кровью получить как многозадачность, так и совместимость. Такая архитектура была довольно-таки трудоемка в реализации и создавала специфические проблемы (так, DOS не отдавала управления при обращениях к приводу гибких дисков, поэтому работа с дискетами из любой сессии приводила к остановке всех остальных сессий), но не представляла непреодолимых концептуальных сложностей и была в целом работоспособна. Windows 3.x представляла собой пример системы, реализовавшей интерфейс между пользовательскими программами, работающими в защищенном режиме, и ядром DOS, исполняющимся в виртуальном 8086. К 1993-1994 гг. на рынке существовало более десятка других продуктов, предоставляющих аналогичный интерфейс, так называемых расширителей DOS (DOS Extender), среди которых нельзя не упомянуть PharLap DOS Extender, Rational DOS/4G и свободно распространяемый на условиях GPL djgpp. С точки зрения разработчиков новой ОС Windows 3.x представляла наибольший интерес в качестве отправной точки, потому что, в отличие от остальных расширителей DOS, она предоставляла динамическую сборку в момент загрузки и реализовывала также событийно-ориентированную архитектуру, пусть и более примитивную, чем асинхронная очередь сообщений Win32. К тому же, Windows 3.11 имела собственную дисковую подсис- тему, позволявшую работать с жестким диском в обход DOS (так называемый 32-битный доступ к диску). Первым получившим признание результатом работ над новой системой был продукт Win32s — набор DLL для Windows 3.x, позволявший исполнять загрузочные модули формата РЕ, использовавшие подмножество Win32 API. После длинной последовательности публичных бета-версий, многократного переноса сроков и большой шумихи в прессе новая система, получившая название Windows 95, вышла на рынок в 1995 г. Система с самого начала задумывалась как переходная, предназначенная для облегчения перевода пользовательской базы DOS на Windows NT, однако прошло не менее 4—5 лет, прежде чем совместимость с приложениями DOS перестала быть решающим параметром при выборе ОС для настольного компьютера. За это время успело выйти несколько версий "переходной" системы (OSR2, 98, 98SE, Millennium Edition) и даже после выхода ХР Microsoft еще не готова объявить о прекращении поддержки этой линии ОС.


Windows СЕ

Система, предназначенная для кросс-разработки приложений, прошиваемых в ПЗУ, сверхпортативных компьютеров. К моменту написания книги это единственная система из семейства СР/М, поддерживающая процессоры, отличные от х86. Использование ПЗУ позволяет отказаться от целого набора подсистем, обслуживающих виртуальную память, загрузку исполняемых модулей и сборку в момент загрузки. Система предоставляет графический пользовательский интерфейс с асинхронной очередью сообщений, вытесняющую многопоточность и базовый стек TCP/IP. В поставку системы входит среда кросс-разработки (компилятор, эмулятор целевого процессора, удаленный отладчик и интегрированная оболочка), работающая под Windows NT [Boling 2001]. Интерфейс системных вызовов этой ОС в целом похож на Win32 API — тем не менее, складывается впечатление, что основным источником требований было не обеспечение совместимости с приложениями для Win32 вообще, а пожелания разработчиков Mobile Office (пакет, включающий в себя функциональные аналоги некоторых программ из пакета Microsoft Office). Любопытно, что, рекламируя эту систему, Microsoft делает большой упор на то, что она разработана с нуля, т. е. без использования существующего кода Wiii32-cHcreM. На взгляд автора, это является косвенным признанием той репутации, которой качество кода этих систем заслуженно пользуется среди разработчиков и эксплуатационщиков.


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


Основные функции операционных систем

По современным представлениям ОС должна уметь делать следующее.

  • Обеспечивать загрузку пользовательских программ в оперативную память и их исполнение (этот пункт не относится к ОС, предназначенным для прошивки в ПЗУ).
  • Обеспечивать управление памятью. В простейшем случае это указание единственной загруженной программе адреса, на котором кончается память, доступная для использования, и начинается память, занятая системой. В многопроцессных системах это сложная задача управления системными ресурсами.
  • Обеспечивать работу с устройствами долговременной памяти, такими как магнитные диски, ленты, оптические диски, флэш-память и т. д. Как правило, ОС управляет свободным пространством на этих носителях и структурирует пользовательские данные в виде файловых систем.
  • Предоставлять более или менее стандартизованный доступ к различным периферийным устройствам, таким как терминалы, модемы, печатающие устройства или двигатели, поворачивающие рулевые плоскости истребителя.
  • Предоставлять некоторый пользовательский интерфейс. Слово некоторый здесь сказано не случайно — часть систем ограничивается командной строкой, в то время как другие на 90% состоят из интерфейсной подсистемы. Встраиваемые системы часто не имеют никакого пользовательского интерфейса.

Существуют ОС, функции которых этим и исчерпываются. Одна из хорошо известных систем такого типа — дисковая операционная система MS DOS.
Более развитые ОС предоставляют также следующие возможности:

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


Классификация ОС

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

Рис. В1. Классификация операционных систем


ДОС (Дисковые Операционные Системы)

Это системы, берущие на себя выполнение только первых четырех функций. Как правило, они представляют собой некий резидентный набор подпрограмм, не более того. ДОС загружает пользовательскую программу в память
и передает ей управление, после чего программа делает с системой все, что ей заблагорассудится. При завершении программы считается хорошим тоном оставлять машину в таком состоянии, чтобы ДОС могла продолжить работу. Если же программа приводит машину в какое-то другое состояние, что ж, ДОС ничем ей в этом не может помешать.
Характерный пример — различные загрузочные мониторы для машин класса Spectrum. Как правило, такие системы работают одновременно только с одной программой.
Дисковая операционная система MS DOS для IBM PC-совместимых машин является прямым наследником одного из таких резидентных мониторов.
Существование систем этого класса обусловлено их простотой и тем, что они потребляют мало ресурсов. Для машин класса Spectrum это более чем критичные параметры. Еще одна причина, по которой такие системы могут использоваться даже на довольно мошных машинах — требование программной совместимости с ранними моделями того же семейства компьютеров.


ОС общего назначения

К этому классу относятся системы, берущие на себя выполнение всех вышеперечисленных функций. Разделение на ОС и ДОС идет, по-видимому, от систем IBM DOS/360 и OS/360 для больших компьютеров этой фирмы, клоны которых известны у нас в стране под названием ЕС ЭВМ серии 10ХХ. (Кстати, у IBM была еще TOS/360, Tape Operating System — Ленточная Операционная Система).
Здесь под ОС мы будем подразумевать системы "общего назначения", т. е. рассчитанные на интерактивную работу одного или нескольких пользователей в режиме разделения времени, при не очень жестких требованиях ко времени реакции системы на внешние события. Как правило, в таких системах уделяется большое внимание защите самой системы, программного обеспечения и пользовательских данных от ошибочных и злонамеренных программ и пользователей. Обычно подобные системы используют встроенные в архитектуру процессора средства защиты и виртуализации памяти. К этому классу относятся такие широко распространенные системы, как Windows 2000, системы семейства Unix.


Системы виртуальных машин

Такие системы стоят несколько особняком. Система виртуальных машин -это ОС, допускающая одновременную работу нескольких программ, но создающая при этом для каждой программы иллюзию того, что машина находится в полном ее распоряжении, как при работе под управлением ДОС. Зачастую, "программой" оказывается полноценная операционная система - примерами таких систем являются VMWare для машин с архитектурой х86 или VM для System/370 и ее потомков.
Виртуальные машины являются ценным средством при разработке и тестировании кросс-платформенных приложений. Реже они используются для отладки модулей ядра или самой операционной системы.
Такие системы отличаются высокими накладными расходами и сравнительно низкой надежностью, поэтому относительно редко находят промышленное применение.
Часто СВМ являются подсистемой ОС общего назначения: MS DOS и MS Windows-эмуляторы для UNIX и OS/2, подсистема WoW в Windows NT/2000/XP, сессия DOS в Windows З.х/95/98/МЕ, эмулятор RT-11 в VAX/ VMS.
В системах виртуальных машин, как правило, приходится уделять много внимания эмуляции работы аппаратуры. Например, несколько программ могут начать программировать системный таймер. СВМ должна отследить такие попытки и создать для каждой из программ иллюзию, что она запрограммировала таймер именно так, как хотела. Разработка таких систем является сложным и часто неблагодарным делом. Архитектура таких систем сильно зависит от свойств виртуализуемой аппаратуры, поэтому мы почти не будем обсуждать этот класс ОС.


Системы реального времени

Это системы, предназначенные для облегчения разработки так называемых приложений реального времени — программ, управляющих некомпьютерным оборудованием, часто с очень жесткими ограничениями по времени. Примером такого приложения может быть программа бортового компьютера fly-by-wire (дословно - "летящий по проволоке", т. е. использующий систему управления, в которой органы управления не имеют механической и гидравлической связи с рулевыми плоскостями) самолета, системы управления ускорителем элементарных частиц или промышленным оборудованием. Подобные системы обязаны поддерживать многопоточность, гарантированное время реакции на внешнее событие, простой доступ к таймеру и внешним устройствам.
Способность гарантировать время реакции является отличительным признаком систем РВ. Важно учитывать различие между гарантированностью и просто высокой производительностью и низкими накладными расходами. Далеко не все алгоритмы и технические решения, даже и обеспечивающие отличное среднее время реакции, годятся для приложений и операционных систем РВ.
По другим признакам эти системы могут относиться как к классу ДОС (RT-11), так и к ОС (OS-9, QNX).
Любопытно, что новомодное течение в компьютерной технике — multimedia — при качественной реализации предъявляет к системе те же требования, что и промышленные задачи реального времени. В multimedia основной проблемой является синхронизация изображения на экране со звуком. Именно в таком порядке. Звук обычно генерируется внешним аппаратным устройством с собственным таймером, и изображение синхронизируется с ним. Человек способен заметить довольно малые временные неоднородности в звуковом потоке, а пропуск кадров в визуальном потоке не так заметен. Расхождение же звука и изображения фиксируется человеком уже при задержках около 30 мс. Поэтому системы высококачественного multimedia должны обеспечивать синхронизацию с такой же или более высокой точностью, что мало отличается от реального времени.
Так называемое "мягкое реальное время" (soft real lime), предоставляемое современными Win32 платформами, не является реальным временем вообще, это что-то вроде "осетрины второй свежести". Система "мягкого РВ" обеспечивает не гарантированное, а всего лишь среднее время реакции. Для мультимедийных приложений и игр различие между "средним" и "гарантированным" не очень критично — ну дернется картинка, или поплывет звук. Но для промышленных приложений, где необходимо настоящее реальное время, это обычно неприемлемо.


Средства кросс-разработки

Это системы, предназначенные для разработки программ в двухмашинной конфигурации, когда редактирование, компиляция, а зачастую и отладка кода производятся на инструментальной машине (в англоязычной литературе ее часто называют host — дословно, "хозяин"), а потом скомпилированный код загружается в целевую систему. Чаще всего они используются для написания и отладки программ, позднее прошиваемых в ПЗУ. Примерами таких ОС являются системы программирования микроконтроллеров Intel, Atmel, PIC и др., системы Windows СЕ, Palm OS и т. д. Такие системы, как правило, включают в себя:

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

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


Системы промежуточных типов

Существуют системы, которые нельзя отнести к одному из вышеперечисленных классов. Такова, например, система RT-11, которая, по сути своей, является ДОС, но позволяет одновременное исполнение нескольких программ с довольно богатыми средствами взаимодействия и синхронизации. Другим примером промежуточной системы являются MS Windows 3.x и Windows 95, которые, как ОС, используют аппаратные средства процессора для защиты и виртуализации памяти и даже могут обеспечивать некоторое подобие многозадачности, но не защищают себя и программы от ошибок других программ, подобно ДОС.
Некоторые системы реального времени, например QNX, могут использоваться как в качестве самостоятельной ОС, загружаемой с жесткого диска в оперативную память, так и будучи прошиты в ПЗУ. Эти системы могут быть отнесены одновременно и к ОС общего назначения, и к системам кросс-разработки.
Таких примеров "гибридизации" можно привести множество, поэтому к вышеприведенной классификации следует относиться с определенной осторожностью.


Семейства операционных систем

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

  • Системы для больших компьютеров фирмы IBM — OS/390, z/OS и IBM VM.
  • Обширное, бурно развивающееся и имеющее трудно определимые границы семейство Unix. В этой книге под системами данного семейства мы будем подразумевать прежде всего ОС трех основных родов:
    • Unix System V Release 4.x: Sunsoft Solans, SCO UnixWare;
    • Berkeley Software Distribution Unix: BSDI, FreeBSD;
    • Linux.
  • Семейство прямых и косвенных потомков Control Program/Monitor (СР/М) фирмы Digital Research. В этом семействе можно выделить также весьма широко известное подсемейство \sisname{Win 32}-платформ (рис. В.2).

Еще одно практически вымершее к настоящему моменту, но оставившее в наследство ряд важных и интересных концепций семейство — это операционные системы для мини- и микрокомпьютеров фирмы DEC: RT-11, RSX-11 и VAX/VMS.
Ряд систем, в том числе и коммерчески успешных, например OS/400, не могут быть с уверенностью отнесены ни к одному из перечисленных семейств, поэтому, как и к классификации предыдущего раздела, к данной классификации надо относиться с осторожностью.


Выбор операционной системы

Выбор типа операционной системы часто представляет собой нетривиальную задачу. Некоторые приложения накладывают жесткие требования, которым удовлетворяет только небольшое количество систем. Например, задачи управления промышленным или исследовательским оборудованием в режиме жесткого реального времени вынуждают нас делать выбор между специализированными ОС реального времени и некоторыми ОС общего назначения, такими как Unix System V Release 4 (хотя Unix SVR4 теоретически способна обеспечивать гарантированное время реакции, системы этого семейства имеют ряд недостатков с точки зрения задач РВ, поэтому чаще всего предпочтительными оказываются специализированные ОС -- QNX, VxWorks, OS-9 и т. д.). Другие приложения, например серверы баз данных, просто требуют высокой надежности и производительности, что отсекает системы класса ДОС и MS Windows.
Наконец, некоторые задачи, такие как автоматизация конторской работы в небольших организациях, не предъявляют высоких требований к надежности, производительности и времени реакции системы, что предоставляет широкий выбор между различными ДОС, MS Windows, Mac OS и многими системами общего назначения. При этом технические параметры системы перестают играть роль, и в игру вступают другие факторы. На заре развития персональной техники таким фактором была стоимость аппаратного обеспечения, вынуждавшая делать выбор в пользу ДОС и, позднее, MS Windows.

Рис. В2. Генеалогия ОС семейства СР/М

Нужно отметить, впрочем, что современные версии Windows, несмотря на низкую надежность, сложность конфигурации и поддержки и ряд функциональных недостатков, вполне адекватны большинству задач конторской автоматизации. Проблемы возникают, когда задачи, стоящие перед организацией, выходят за пределы распечатки прайс-листов из MS Excel и набора писем в MS Word. Лучше всего проблемы этого рода выражены в следующей притче.

Проблема
Организация имеет двенадцать велосипедов. Стоит задача: перевезти рояль. Что делать? Грузовик не предлагать.

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


Открытые системы

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

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

Эти задачи предполагается решать при помощи открытых стандартов -стандартных сетевых протоколов, стандартных форматов данных, стандартизации программных интерфейсов — API (Application Program Interface, интерфейс прикладных программ) и, наконец, стандартизации пользовательского интерфейса.
В качестве стандартного сетевого протокола предлагалась семиуровневая модель OSI, но прежде, чем на основе этой модели было разработано что-то
полезное, получило широкое распространение семейство протоколов TCP/IP. Документация по протоколам этого семейства имеет статус public domain (общественная собственность); кроме того, есть, по крайней мере, одна программная реализация этого протокола, также имеющая статус public domain — сетевое ПО системы BSD Unix, это стало вполне приемлемым основанием для применения TCP/IP в открытых системах.
Обсуждение стандартных форматов данных увело бы нас далеко от основной темы, но нужно отметить следующее: в настоящее время существует много общепризнанных стандартов представления изображений (особенно растровых) и звуковых данных, но некоторые типы данных так и не имеют признанной стандартной формы. Например, есть несколько открытых форматов представления форматированного текста: troff, LATEX и другие пакеты макросов для системы ТЕХ, и, наконец, стандарт SGML (Standard Generalized Markup Language), но ни один из этих стандартов не пользуется популярностью среди разработчиков коммерческих текстовых процессоров
(Справедливости ради нужно отметить, что форматы troff и LATEX очень неудобны для WYSIWYG текстовых процессоров, но SGML разрабатывался специально для них). Причины такого отношения понятны: предоставление пользователю возможности без проблем обмениваться данными с текстовым процессором конкурента означает дать пользователю возможность выбирать между твоим процессором и его. Впрочем, пользователю от понимания не легче.
Для того чтобы как-то обеспечить переносимость программ между системами различных типов, принимались различные стандарты интерфейса между пользовательской (обычно говорят-- прикладной, но это не всегда правильно) программой и ОС. Одним из первых таких стандартов был стандарт библиотек ANSI С. Он основан на системных вызовах ОС Unix, но функции MS DOS для работы с файлами (использующие file handle) тоже достаточно близки к этому стандарту.
Позднее делалось еще несколько попыток стандартизировать интерфейс системных вызовов. Одной из относительно удачных попыток такого рода был POSIX (Portable Operating System Interface [based on] uniX — переносимый интерфейс операционной системы, основанный на Unix), который в той или иной форме поддерживается всеми системами семейства Unix и некоторыми ОС, не входящими в это семейство, например Windows NT. Но наибольший успех имела деятельность консорциума Х/Ореп, который в 1998 году сертифицировал операционную систему OS/390 фирмы IBM как соответствующую спецификациям Unix/95 [www.opengroup.org xu007] и, таким образом, дал представителю самого древнего из современных родов операционных систем право называться UNIX ™.
Впрочем, данная книга не претендует на исчерпывающее руководство по выбору операционной среды, а тем более на справочник по программированию
или поддержке конкретной операционной системы. Наша задача — дать обзор основных задач, которые встают перед разработчиками операционной системы, и путей их решения, которые были найдены в ходе развития вычислительных технологий.