Текст
                    А. В. Гордеев
А. Ю. Молчанов
С^ППТЕР
СИСТЕМНОЕ ПРОГРАММНОЕ
ОБЕСПЕЧЕНИЕ
операционные среды
и системы
программирования
теоретический
материал в сочетании
с примерами
и упражнениями
для студентов
и преподавателей
высших учебных
заведений
направления
«Информатика
и вычислительная
техн и ка»

Г ордеев Молчанов СИСТЕМНОЕ ПРОГРАММНОЕ ОБЕСПЕЧЕНИЕ Санкт-Петербург Москва • Харьков • Минск 2001
ББК 32.973-018я7 УДК 681.3.06(075) Г68 Г68 Системное программное обеспечение / А. В. Гордеев, А. Ю. Молчанов. СПб.: Питер, 2001. — 736 с.: ил. ISBN 5-272-00341-1 Учебник создан в соответствии с Государственным образовательным стандартом в дисцип- лине «Системное программное обеспечение». Его основой стал учебный материал, в течение нескольких лет преподаваемый студентам Санкт-Петербургского государственного университета аэрокосмического приборостроения. Помимо общетеоретических вопросов описаны конкретные реализации различных системных программ, поэтому учебник может быть полезен не только студентам, детально изучающим системное программное обеспечение, но и тем, кто собирается самостоятельно разработать отдельные компоненты или создать комплекс параллельно исполняю- щихся взаимодействующих программ. ББК 32.973-01 f УДК 681.3.06(0/ ISBN 5-272-00341-1 ©А. Гордеев, А. Молчанов, 2001 © Издательский дом «Питер», 2001
Краткое содержание Часть I. Операционные системы и среды Глава 1. Основные понятия...................................22 Глава 2. Управление задачами и памятью в операционных системах. . 51 Глава 3. Особенности архитектуры микропроцессоров i80x86 . 100 Глава 4. Управление вводом/выводом и файловые системы......129 Глава 5. Архитектура операционных систем и интерфейсы прикладного программирования..............................191 Глава 6. Проектирование параллельных взаимодействующих вычислительных процессов..................................221 Глава 7. Проблема тупиков и методы борьбы с ними...........269 Глава 8. Современные операционные системы..................301 Часть II. Трансляторы, формальные языки и грамматики Глава 9. Формальные языки и грамматики................347 Глава 10. Регулярные языки....................................387 Глава 11. Контекстно-свободные языки..........................418 Глава 12. Классы КС-языков и грамматик.......................463 Глава 13. Основные принципы построения трансляторов..........529 Глава 14. Генерация и оптимизация кода.......................588 Глава 15. Современные системы программирования...............655 Приложение А. Тексты программы параллельных взаимодействующих задач.....................700 Приложение Б. Тексты программ комплекса параллельных взаимодействующих приложений.................709 Список литературы..........................................719 Алфавитный указатель.......................................725
Содержание Предисловие....................................................13 От издательства.............................................16 Часть I. Операционные системы и среды Глава 1. Основные понятия......................................22 Понятие операционной среды.....................................22 Понятия вычислительного процесса и ресурса.....................24 Диаграмма состояний процесса................................28 Реализация понятия последовательного процесса в ОС..........31 Процессы и треды............................................33 Прерывания.....................................................37 Основные виды ресурсов.........................................44 Классификация операционных систем..............................48 Контрольные вопросы и задачи...................................49 Вопросы для проверки........................................49 Глава 2. Управление задачами и памятью в операционных системах. . 51 Планирование и диспетчеризация процессов и задач...............53 Стратегии планирования......................................53 Дисциплины диспетчеризации..................................54 Качество диспетчеризации и гарантии обслуживания............60 Диспетчеризация задач с использованием динамических приоритетов. ... 62 Память и отображения, виртуальное адресное пространство........67 Простое непрерывное распределение и распределение с перекрытием (оверлейные структуры).......................................70 Распределение статическими и динамическими разделами...........72 Разделы с фиксированными границами..........................73 Разделы с подвижными границами..............................75 Сегментная, страничная и сегментно-страничная организация памяти.76 Сегментный способ организации виртуальной памяти............77 Страничный способ организации виртуальной памяти............82 Сегментно-страничный способ организации виртуальной памяти..86
Содержание 5 Распределение оперативной памяти в современных ОС для ПК.............88 Распределение оперативной памяти в MS-DOS.........................89 Распределение оперативной памяти в Microsoft Windows 95/98 ...... 92 Распределение оперативной памяти в Microsoft Windows NT...........95 Контрольные вопросы и задачи.........................................99 Вопросы для проверки..............................................99 Глава 3. Особенности архитектуры микропроцессоров 180x86 .......... 100 Реальный и защищенный режимы работы процессора.....................100 Новые системные регистры микропроцессоров i80x86 ................... 102 Адресация в 32-разрядных микропроцессорах i80x86 при работе в защищенном режиме..............................................104 Поддержка сегментного способа организации виртуальной памяти....104 Поддержка страничного способа организации виртуальной памяти....108 Режим виртуальных машин для исполнения приложений реального режима..............................................111 Защита адресного пространства задач.................................112 Уровни привилегий для защиты адресного пространства задач........113 Механизм шлюзов для передачи управления на сегменты кода с другими уровнями привилегий.................................115 Система прерываний 32-разрядных микропроцессоров i80x86 ........... 119 Работа системы прерываний в реальном режиме работы процессора . . . 119 Работа системы прерываний в защищенном режиме работы процессора. . 123 Контрольные вопросы и задачи........................................127 Вопросы для проверки.............................................127 Глава 4. Управление вводом/выводом и файловые системы..............129 Основные понятия и концепции организации ввода/вывода в ОС..........130 Режимы управления вводом/выводом....................................133 Закрепление устройств, общие устройства ввода/вывода................135 Основные системные таблицы ввода/вывода.............................136 Синхронный и асинхронный ввод/вывод.................................140 Кэширование операций ввода/вывода при работе с накопителями на магнитных дисках..............................................142 Функции файловой системы ОС и иерархия данных.......................146 Структура магнитного диска (разбиение дисков на разделы)............147 Файловая система FAT................................................156 Таблица размещения файлов........................................156 Структура загрузочной записи DOS.................................160 Файловые системы VFAT и FAT32....................................162 Файловая система HPFS...............................................167 Файловая система NTFS (New Technology File System)..................178 Основные возможности файловой системы NTFS.......................178 Структура тома с файловой системой NTFS..........................180 Возможности файловой системы NTFS по ограничению доступа к файлам и каталогам..........................................185 Основные отличия FAT и NTFS......................................187
6 Содержание Контрольные вопросы и задачи.......................................188 Вопросы для проверки............................................188 Задания.........................................................189 Глава 5. Архитектура операционных систем и интерфейсы прикладного программирования......................................191 Основные принципы построения операционных систем...................191 Принцип модульности.............................................191 Принцип функциональной избирательности..........................192 Принцип генерируемости ОС.......................................192 Принцип функциональной избыточности.............................193 Принцип виртуализации...........................................193 Принцип независимости программ от внешних устройств.............195 Принцип совместимости...........................................195 Принцип открытой и наращиваемой ОС..............................196 Принцип мобильности (переносимости).............................197 Принцип обеспечения безопасности вычислений.....................197 Микроядерные операционные системы..................................199 Монолитные операционные системы....................................201 Требования, предъявляемые к ОС реального времени...................202 Мультипрограммность и многозадачность...........................203 Приоритеты задач (потоков)......................................203 Наследование приоритетов........................................204 Синхронизация процессов и задач.................................204 Предсказуемость.................................................205 Принципы построения интерфейсов операционных систем................205 Интерфейс прикладного программирования.............................207 Реализация функций API на уровне ОС.............................208 Реализация функций API на уровне системы программирования.......209 Реализация функций API с помощью внешних библиотек..............211 Платформенно-независимый интерфейс POSIX...........................213 Пример программирования в различных API ОС.........................216 Текст программы для Windows (WinAPI)............................216 Текст программы для Linux (POSIX API)...........................218 Контрольные вопросы и задачи.......................................219 Вопросы для проверки............................................219 Глава 6. Проектирование параллельных взаимодействующих вычислительных процессов..........................................221 Независимые и взаимодействующие вычислительные процессы............221 Средства синхронизации и связи при проектировании взаимодействующих вычислительных процессов......................227 Использование блокировки памяти при синхронизации параллельных процессов....................................................227 Синхронизация процессов посредством операции «ПРОВЕРКА И УСТАНОВКА».......................................233
Содержание 7 Семафорные примитивы Дейкстры..................................236 Использование семафоров при проектировании взаимодействующих вычислительных процессов......................................242 Мониторы Хоара....................................................250 Почтовые ящики....................................................253 Конвейеры и очереди сообщений.....................................255 Конвейеры (программные каналы).................................255 Очереди сообщений..............................................257 Примеры создания параллельных взаимодействующих вычислительных процессов........................................258 Пример создания многозадачного приложения с помощью системы программирования Borland Delphi...............................259 Пример создания комплекса параллельных взаимодействующих программ, выступающих как самостоятельные вычислительные процессы.......................................262 Контрольные вопросы и задачи......................................267 Вопросы для проверки...........................................267 Глава 7. Проблема тупиков и методы борьбы с ними..................269 Понятие тупиковой ситуации при выполнении параллельных вычислительных процессов........................................269 Примеры тупиковых ситуаций и причины их возникновения.............271 Пример тупика на ресурсах типа CR..............................271 Пример тупика на ресурсах типа CR и SR.........................273 Пример тупика на ресурсах типа SR..............................274 Формальные модели для изучения проблемы тупиковых ситуаций........276 Сети Петри.....................................................276 Вычислительные схемы...........................................281 Модель пространства состояний системы..........................283 Методы борьбы с тупиками..........................................287 Предотвращение тупиков.........................................287 Обход тупиков..................................................288 Обнаружение тупика.............................................291 Контрольные вопросы и задачи......................................300 Вопросы для проверки...........................................300 Глава 8. Современные операционные системы.........................301 Семейство операционных систем UNIX................................301 Общая характеристика семейства операционных систем UNIX, особенности архитектуры семейства ОС UNIX.....................301 Основные понятия системы UNIX..................................303 Функционирование системы UNIX..................................308 Файловая система...............................................311 Межпроцессные коммуникации в UNIX..............................316 Операционная система Linux.....................................323 Семейство операционных систем OS/2 Warp компании IBM..............325 Особенности архитектуры и основные возможности OS/2 Warp.......328
8 Содержание Особенности интерфейса OS/2 Warp.................................331 Серверная операционная система OS/2 Warp 4.5.....................332 Сетевая ОС реального времени QNX....................................334 Архитектура системы QNX..........................................335 Основные механизмы QNX для организации распределенных вычислений......................................339 Контрольные вопросы и задачи........................................344 Вопросы для проверки.............................................344 Часть II. Трансляторы, формальные языки и грамматики Глава 9. Формальные языки и грамматики..............................347 Языки и цепочки символов. Способы задания языков....................347 Цепочки символов. Операции над цепочками символов................347 Понятие языка. Формальное определение языка......................348 Способы задания языков...........................................349 Синтаксис и семантика языка......................................350 Особенности языков программирования..............................351 Определение грамматики. Форма Бэкуса—Наура..........................353 Понятие о грамматике языка ......................................353 Формальное определение грамматики. Форма Бэкуса—Наура............354 Принцип рекурсии в правилах грамматики...........................356 Другие способы задания грамматик.................................357 Классификация языков и грамматик....................................360 Классификация грамматик. Четыре типа грамматик по Хомскому.......361 Классификация языков.............................................363 Примеры классификации языков и грамматик.........................365 Цепочки вывода. Сентенциальная форма................................367 Вывод. Цепочки вывода............................................367 Сентенциальная форма грамматики. Язык, заданный грамматикой......369 Левосторонний и правосторонний выводы............................370 Дерево вывода. Методы построения дерева вывода...................370 Проблемы однозначности и эквивалентности грамматик..................372 Однозначные и неоднозначные грамматики...........................372 Эквивалентность и преобразование грамматик.......................373 Правила, задающие неоднозначность в грамматиках..................376 Распознаватели. Задача разбора......................................376 Общая схема распознавателя.......................................376 Виды распознавателей.............................................378 Классификация распознавателей по типам языков....................380 Задача разбора (постановка задачи)...............................382 Контрольные вопросы и задачи........................................383 Вопросы..........................................................383 Задачи...........................................................384
Содержание 9 Глава 10. Регулярные языки..........................................387 Регулярные языки и грамматики.......................................387 Леволинейные и праволинейные грамматики. Автоматные грамматики . . . 387 Алгоритм преобразования регулярной грамматики к автоматному виду. . . 388 Пример преобразования регулярной грамматики к автоматному виду . . . 390 Конечные автоматы..................................................391 Определение конечного автомата..................................391 Детерминированные и недетерминированные конечные автоматы.......392 Преобразование конечного автомата к детерминированному виду.....393 Минимизация конечных автоматов...................................395 Регулярные множества и регулярные выражения.........................397 Определение регулярного множества................................397 Регулярные выражения. Свойства регулярных выражений..............398 Уравнения с регулярными коэффициентами...........................399 Способы задания регулярных языков...................................403 Три способа задания регулярных языков............................403 Связь регулярных выражений и регулярных грамматик................404 Связь регулярных выражений и конечных автоматов..................407 Связь регулярных грамматик и конечных автоматов..................408 Пример построения конечного автомата на основе заданной грамматики...........................................410 Свойства регулярных языков..........................................413 Свойства регулярных языков.......................................413 Лемма о разрастании для регулярных языков........................414 Контрольные вопросы и задачи........................................415 Вопросы..........................................................415 Задачи...........................................................416 Глава 11. Контекстно-свободные языки................................418 Распознаватели КС-языков. Автоматы с магазинной памятью.............418 Определение МП-автомата..........................................418 Эквивалентность языков МП-автоматов и КС-грамматик...............420 Детерминированные МП-автоматы....................................423 Свойства КС-языков..................................................423 Свойства произвольных КС-языков..................................423 Свойства детерминированных КС-языков.............................424 Лемма о разрастании КС-языков....................................425 Преобразование КС-грамматик. Приведенные грамматики.................426 Преобразование грамматик. Цель преобразования....................426 Приведенные грамматики...........................................427 Удаление недостижимых символов...................................427 Удаление бесплодных символов.....................................427 Устранение Х-правил..............................................429 Устранение цепных правил.........................................431 КС-грамматики в нормальной форме....................................433 Грамматики в нормальной форме Хомского......................... 433 Устранение левой рекурсии. Грамматики в нормальной форме Грейбах . . 437
10 Содержание Распознаватели КС-языков с возвратом................................441 Принципы работы распознавателей с возвратом......................441 Нисходящий распознаватель с возвратом.............................443 Распознаватель на основе алгоритма «сдвиг-свертка»................449 Табличные распознаватели для КС-языков...............................455 Общие принципы работы табличных распознавателей...................455 Алгоритм Кока—Янгера—Касами.......................................456 Алгоритм Эрли (основные принципы).................................457 Принципы построения распознавателей КС-языков без возвратов..........458 Контрольные вопросы и задачи.........................................460 Вопросы...........................................................460 Задачи............................................................461 Глава 12. Классы КС-языков и грамматик...............................463 Нисходящие распознаватели КС-языков без возвратов....................463 Левосторонний разбор по методу рекурсивного спуска................463 Определение Щк)-грамматики........................................471 Принципы построения распознавателей для 1_Цк)-грамматик...........474 Алгоритм разбора для Щ1)-грамматик................................475 Восходящие распознаватели КС-языков без возвратов....................486 Определение 1_А(к)-грамматики.....................................486 Принципы построения распознавателей для 1_А(к)-грамматик..........489 Грамматики предшествования (основные принципы)....................497 Грамматики простого предшествования...............................498 Грамматики операторного предшествования...........................508 Соотношение классов КС-языков и КС-грамматик.........................519 Особенности восходящих и нисходящих распознавателей...............519 Отношения между классами КС-грамматик.............................521 Отношения между классами КС-языков................................524 Контрольные вопросы и задачи.........................................525 Вопросы...........................................................525 Задачи............................................................527 Глава 13. Основные принципы построения трансляторов..................529 Трансляторы, компиляторы и интерпретаторы — общая схема работы......529 Определение транслятора, компилятора, интерпретатора..............529 Этапы трансляции. Общая схема работы транслятора..................535 Понятие прохода. Многопроходные и однопроходные компиляторы......538 Интерпретаторы. Особенности построения интерпретаторов............540 Трансляторы с языка ассемблера («ассемблеры»).....................542 Таблицы идентификаторов. Организация таблиц идентификаторов..........548 Назначение и особенности построения таблиц идентификаторов........548 Простейшие методы построения таблиц идентификаторов...............550 Построение таблиц идентификаторов по методу бинарного дерева.....551 Хэш-функции и хэш-адресация.......................................554 Комбинированные способы построения таблиц идентификаторов........563
Содержание 11 Лексические анализаторы (сканеры). Принципы построения сканеров.....564 Назначение лексического анализатора..............................564 Принципы построения лексических анализаторов.....................568 Построение лексических анализаторов..............................570 Автоматизация построения лексических анализаторов (программа LEX)................................................576 Синтаксические анализаторы. Синтаксически управляемый перевод.......577 Основные принципы работы синтаксического анализатора.............577 Дерево разбора. Преобразование дерева разбора в дерево операций . . . 579 Автоматизация построения синтаксических анализаторов (программа YACC)...............................................582 Контрольные вопросы и задачи........................................583 Вопросы..........................................................583 Задачи...........................................................585 Глава 14. Генерация и оптимизация кода..............................588 Семантический анализ и подготовка к генерации кода..................588 Назначение семантического анализа................................588 Этапы семантического анализа.....................................589 Идентификация лексических единиц языков программирования.........594 Распределение памяти. Принципы распределения памяти..............597 Дисплей памяти процедуры (функции). Стековая организация дисплея памяти.................................................603 Память для типов данных (RTTI-информация)........................610 Генерация кода. Методы генерации кода...............................612 Общие принципы генерации кода. Синтаксически управляемый перевод..............................612 Способы внутреннего представления программ.......................615 Обратная польская запись операций................................619 Схемы СУ-перевода................................................622 Оптимизация кода. Основные методы оптимизации.......................630 Общие принципы оптимизации кода..................................630 Оптимизация линейных участков программы..........................634 Другие методы оптимизации программ...............................641 Машинно-зависимые методы оптимизации.............................648 Контрольные вопросы и задачи........................................650 Вопросы..........................................................650 Задачи...........................................................652 Глава 15. Современные системы программирования......................655 Понятие и структура системы программирования........................655 История возникновения систем программирования....................655 Структура современной системы программирования...................658 Принципы функционирования систем программирования...................660 Функции текстовых редакторов в системах программирования.........660 Компилятор как составная часть системы программирования..........661
12 Содержание Компоновщик. Назначение и функции компоновщика................662 Загрузчики и отладчики. Функции загрузчика....................663 Библиотеки подпрограмм как составная часть систем программирования......................................665 Дополнительные возможности систем программирования...............668 Лексический анализ «на лету». Система подсказок и справок.....668 Разработка программ в архитектуре «клиент—сервер».............669 Разработка программ в трехуровневой архитектуре. Серверы приложений...........................................670 Примеры современных систем программирования......................672 Системы программирования компании Borland/lnprise.............672 Системы программирования фирмы Microsoft......................677 Системы программирования под ОС Linux и UNIX..................682 Разработка программного обеспечения для сети Интернет.........687 Контрольные вопросы и задачи.....................................696 Вопросы.......................................................696 Задачи........................................................698 Приложение А. Тексты программы параллельных взаимодействующих задач.......................700 Приложение Б. Тексты программ комплекса параллельных взаимодействующих приложений.....................709 Список литературы................................................719 Алфавитный указатель.............................................725
Предисловие Настоящий учебник предназначается в первую очередь студентам технических вузов, хотя может быть востребован и обычными подготовленными пользовате- лями, желающими углубить свои познания в области системного программного обеспечения. Согласно Государственному образовательному стандарту, по которому ведется обучение студентов, поступивших в вузы до 2000 г., в рамках дисциплины «Сис- темное программное обеспечение», относящейся к обязательным специальным дисциплинам учебного плана по направлению «Информатика и вычислительная техника», студент должен изучить следующие обязательные разделы: «...назначе- ние, функции и структура операционной системы (ОС); понятие процесса; управление процессами, способы диспетчеризации процессов; понятие ресурса, виды ресурсов, управление ресурсами; управление памятью; устройства, виды устройств, драйверы устройств, устройства в MS-DOS; файловая система на диске, структура логического диска в MS-DOS; синхронизация процессов, семафо- ры, сообщения, использование семафоров для решения задач взаимоисключения и синхронизации; тупики, способы борьбы с тупиками; загрузка и настройка ОС, файлы конфигурирования MS-DOS, основные команды MS-DOS; обзор современ- ных ОС; трансляторы; формальные языки и грамматики, типы грамматик; вы- вод цепочек; конечный и магазинный автоматы, распознаватели и преобразова- тели, построение автомата по заданной грамматике; структура компиляторов и интерпретаторов, лексический, синтаксический и семантический анализаторы, генератор кода;распределение памяти, виды переменных; статическое и динами- ческое связывание; загрузчики; функции загрузчика; настраивающий и динамиче- ский загрузчики; подключение библиотек». В новой редакции Государственного образовательного стандарта, который всту- пил в силу в 2000 г. и относится к поколению студентов, поступивших в вузы осенью 2000 г. и позже, несколько изменено основное содержание этой дисцип- лины, В частности, в стандарте записано, что в рамках дисциплины «Системное программное обеспечение» должны изучаться следующие обязательные разде- лы: пользовательский интерфейс операционной среды; управление задачами; управление памятью; управление вводом/выводом; управление файлами; пример современной операционной системы; программирование в операционной среде; ас- семблеры; мобильность программного обеспечения; макроязыки; трансляторы;
14 Предисловие формальные языки и грамматики, типы грамматик; вывод цепочек; конечный и магазинный автоматы, распознаватели и преобразователи, построение автомата по заданной грамматике; структура компиляторов и интерпретаторов, лексиче- ский, синтаксический и семантический анализаторы, генератор кода; распределе- ние памяти, виды переменных; статическое и динамическое связывание; загрузчи- ки; функции загрузчика; настраивающий и динамический загрузчики; подключение библиотек». Таким образом, мы можем констатировать, что в основном в дисциплине «Сис- темное программное обеспечение» должно уделяться внимание операционным системам, средам и системам программирования. Именно в таком ключе в основ- ном и строится настоящий учебник, поскольку предполагается, что его читателя- ми, прежде всего, будут студенты, обучающиеся по специальностям, относящимся к направлению «Информатика и вычислительная техника». Учебный материал, ставший основой для настоящего учебника, уже в течение нескольких лет изуча- ется студентами специальности 22.01.00 в Санкт-Петербургском государствен- ном университете аэрокосмического приборостроения. Другими словами, по су- ществу, в основу учебника лег расширенный конспект лекций по дисциплине «Сис- темное программное обеспечение». Эта дисциплина изучается в течение двух се- местров. В первом семестре рассматриваются операционные системы (принципы их построения и функционирования, вопросы создания параллельных взаимо- действующих задач, выполняющихся в мультизадачных операционных системах), а во втором - формальные грамматики, трансляторы и системы программирова- ния. Поэтому книга разбита на две крупные части. Эти две части связаны между собой не потому, что так построен план изучения дисциплины. Материал, рас- сматриваемый в каждой из частей учебника, тесно связан с вопросами, изучае- мыми в другой ее части. Таким образом, детальное изучение материала любой части книги требует по крайней мере знакомства с основными понятиями всего учебника. Так, например, изучение структуры и технических аспектов работы компиляторов невозможно без знания принципов распределения памяти, кото- рые относятся к вопросам построения операционных систем. Именно поэтому эти два крупных раздела объединены авторами в одну книгу и совместно пред- ставляются вниманию читателей. Помимо общетеоретических вопросов в учебнике рассмотрены и отдельные прак- тические вопросы, описаны конкретные реализации различных системных про- грамм. В первой части учебника, прежде всего, излагаются основные понятия ОС, прин- ципы их построения и функционирования. В последние годы практически по- всеместно ПК работают под управлением современных 32-битовых ОС, исполь- зующих аппаратные возможности микропроцессоров для создания и органи- зации эффективных и защищенных вычислений. Мы посчитали необходимым рассмотреть в первой части учебника эти вопросы. Наиболее популярными ОС являются системы Windows 95/98, Windows NT 4.0, начинается переход к Windows ME и семейству ОС Windows 2000 компании Microsoft. По этим ОС имеется огромное количество самых разнообразных пуб- ликаций, в том числе и учебных материалов, объем которых порой очень велик. В то же время по остальным ОС публикаций существенно меньше. Поэтому
Предисловие 15 в первой части настоящего учебного пособия мы в качестве примеров операци- онных систем и сред кратко рассматриваем такие ОС, как OS/2 Warp, UNIX и Linux, QNX. Естественно, что отдельные вопросы иллюстрируются и на примере популярных ОС Windows 95/98 и Windows NT 4.0. Во второй части учебника рассматриваются как общие вопросы, связанные с по- строением трансляторов, так и методы их практической реализации от примитив- ных распознавателей текста до законченных систем программирования. Практи- ческая реализация компиляторов и интерпретаторов рассматривается с точки зрения современных широко распространенных языков программирования вы- сокого уровня, таких как Pascal, С и C++. Кратко рассматриваются также практические вопросы построения прикладных программ на основе архитектуры «клиент—сервер» и трехзвенной архитектуры, ориентированной на работу с серверами баз данных и серверами приложений. Эти вопросы затрагиваются не с точки зрения технологии их реализации (такие сведения можно найти в появившейся сейчас специализированной литературе) в той или иной ОС, а со стороны методов разработки соответствующих приклад- ных программ с помощью той или иной системы программирования. Для более детального знакомства с конкретными реализациями читатели могут воспользо- ваться приводимыми авторами ссылками на литературные источники или на со- ответствующие технические публикации в глобальной сети Интернет. Таким образом, данный учебник должен быть полезен не только тем, кто хочет детально изучить системное программное обеспечение, но и тем, кто собирается сам разработать отдельные компоненты, в том числе отдельные системные ути- литы, распознаватели и интерпретаторы команд, компоновщик или транслятор с некоторого языка, создать комплекс параллельно исполняющихся взаимодей- ствующих программ. В учебнике рассматриваются вопросы, которые полезно знать в любом случае, если разработчик прикладной программы имеет дело с не- которым входным языком (которым может быть далеко не только язык програм- мирования, но и любой другой язык команд, в том числе заданный пользовате- лем или определенный в некоторой прикладной области). Примеры систем программирования, которые рассматриваются в этом учебнике, предназначены для работы в среде перечисленных выше операционных систем. Фрагменты программ, приведенные в книге, написаны на языках программиро- вания высокого уровня Pascal и С. В этом коротком предисловии мы, как авторы этой книги, хотим еще высказать самые теплые слова благодарности своим родным и близким за их долгое терпе- ние, доброжелательность и сердечную заботу в течение всего времени подборки материалов, написания книги и ее «бесконечного» улучшения, благодаря кото- рым только и мог появиться на свет этот труд, вынужденно оторвавший нас на некоторое время от домашних забот и хлопот. Хочется поблагодарить и Горде- ева В. А. за конструктивную критику и помощь в подготовке отдельных программ. Авторы признательны также сотрудникам издательства «Питер» Васильеву А. В. и Ваулиной Е. Ю. за их терпеливое и внимательное отношение в процессе подго- товки текста книги, его обработки и корректуры. Наше взаимное уважение и со- трудничество позволили довести учебный материал книги «до ума», хотя это и не всегда удавалось сделать в заранее оговоренные сроки.
16 Предисловие От издательства Ваши замечания, предложения, вопросы отправляйте по адресу электронной поч- ты comp@piter.com (издательство «Питер», компьютерная редакция). Мы будем рады узнать ваше мнение! Подробную информацию о наших книгах вы найдете на Web-сайте издательства http://www.piter.com.
ЧАСТЬ I Операционные системы и среды В англоязычной технической литературе термин System Software (системное про- граммное обеспечение) означает программы и комплексы программ, являющие- ся общими для всех, кто совместно использует технические средства компьютера, и применяемые как для автоматизации разработки (создания) новых программ, так и для организации выполнения программ существующих. С этих позиций системное программное обеспечение может быть разделено на следующие пять групп: 1. Операционные системы. 2. Системы управления файлами. 3. Интерфейсные оболочки для взаимодействия пользователя с ОС и программ- ные среды. 4. Системы программирования. 5. Утилиты. Рассмотрим вкратце эти группы системных программ. 1. Под операционной системой (ОС) обычно понимают комплекс управляю- щих и обрабатывающих программ, который, с одной стороны, выступает как интерфейс между аппаратурой компьютера и пользователем с его задачами, а с другой — предназначен для наиболее эффективного использования ресур- сов вычислительной системы и организации надежных вычислений. Любой из компонентов прикладного программного обеспечения обязательно рабо- тает под управлением ОС. На рис. 1 изображена обобщенная структура про- граммного обеспечения вычислительной системы. Видно, что ни один из компонентов программного обеспечения, за исключением самой ОС, не име- ет непосредственного доступа к аппаратуре компьютера. Даже пользователи
18 Часть I. Операционные системы и среды взаимодействуют со своими программами через интерфейс ОС. Любые их команды, прежде чем попасть в прикладную программу, сначала проходят че- рез ОС. Рис Л. Обобщенная структура программного обеспечения вычислительной системы Основными функциями, которые выполняет ОС, являются следующие: О прием от пользователя (или от оператора системы) заданий или команд, сформулированных на соответствующем языке — в виде директив (ко- манд) оператора или в виде указаний (своеобразных команд) с помощью соответствующего манипулятора (например, с помощью мыши), — и их обработка; О прием и исполнение программных запросов на запуск, приостановку, оста- новку других программ; О загрузка в оперативную память подлежащих исполнению программ; О инициация программы (передача ей управления, в результате чего процес- сор исполняет программу); О идентификация всех программ и данных;
19 О обеспечение работы систем управлений файлами (СУФ) и/или систем управления базами данных (СУБД), что позволяет резко увеличить эф- фективность всего программного обеспечения; О обеспечение режима мультипрограммирования, то есть выполнение двух или более программ на одном процессоре, создающее видимость их одно- временного исполнения; О обеспечение функций по организации и управлению всеми операциями ввода/вывода; О удовлетворение жестким ограничениям на время ответа в режиме реаль- ного времени (характерно для соответствующих ОС); О распределение памяти, а в большинстве современных систем и организа- ция виртуальной памяти; О планирование и диспетчеризация задач в соответствии с заданными стра- тегией и дисциплинами обслуживания; О организация механизмов обмена сообщениями и данными между выпол- няющимися программами; О защита одной программы от влияния другой; обеспечение сохранности данных; О предоставление услуг на случай частичного сбоя системы; О обеспечение работы систем программирования, с помощью которых поль- зователи готовят свои программы. 2. Назначение системы управления файлами — организация более удобного доступа к данным, организованным как файлы. Именно благодаря системе управления файлами вместо низкоуровневого доступа к данным с указанием конкретных физических адресов нужной нам записи используется логиче- ский доступ с указанием имени файла и записи в нем. Как правило, все совре- менные ОС имеют соответствующие системы управления файлами. Однако выделение этого вида системного программного обеспечения в отдельную ка- тегорию представляется целесообразным, поскольку ряд ОС позволяет рабо- тать с несколькими файловыми системами (либо с одной из нескольких, либо сразу с несколькими одновременно). В этом случае говорят о монтируемых файловых системах (дополнительную систему управления файлами можно установить), и в этом смысле они самостоятельны. Более того, можно назвать примеры простейших ОС, которые могут работать и без файловых систем, а значит, им необязательно иметь систему управления файлами, либо они мо- гут работать с одной из выбранных файловых систем. Надо, однако, понимать, что любая система управления файлами не существует сама по себе — она разработана для работы в конкретной ОС и с конкретной файловой систе- мой. Можно сказать, что всем известная файловая система FAT (file allocation table)1 имеет множество реализаций как система управления файлами, напри- мер FAT-16 для самой MS-DOS, super-FAT для OS/2, FAT для Windows NT 1 Здесь и далее без указания на источник заимствования приводятся английские эквива- ленты слов и словосочетаний.
20 Часть I. Операционные системы и среды и т. д. Другими словами, для работы с файлами, организованными в соответ- ствии с некоторой файловой системой, для каждой ОС должна быть разра- ботана соответствующая система управления файлами; и эта система управ- ления файлами будет работать только в той ОС, для которой она и создана. Для удобства взаимодействия с ОС могут использоваться дополнительные интерфейсные оболочки. Их основное назначение — либо расширить возмож- ности по управлению ОС, либо изменить встроенные в систему возможности. В качестве классических примеров интерфейсных оболочек и соответствую- щих операционных сред выполнения программ можно назвать различные варианты графического интерфейса X Window в системах семейства UNIX (например, К Desktop Environment в Linux), PM Shell или Object Desktop в OS/2 с графическим интерфейсом Presentation Manager; наконец, можно указать разнообразные варианты интерфейсов для семейства ОС Windows компании Microsoft, которые заменяют Explorer и могут напоминать либо UNIX с его графическим интерфейсом, либо OS/2, либо MAC OS. Следует отметить, что о семействе ОС компании Microsoft с общим интерфейсом, реа- лизуемым программными модулями с названием Explorer (в файле system.ini, который находится в каталоге Windows, имеется строка SHELL=EXPLORER.EXE), все же можно сказать, что заменяемой в этих системах является только ин- терфейсная оболочка, в то время как сама операционная среда остается неиз- менной; она интегрирована в ОС. Другими словами, операционная среда определяется программными интерфейсами, то есть API (application program interface). Интерфейс прикладного программирования (API) включает в себя управление процессами, памятью и вводом/выводом. Ряд операционных систем могут организовывать выполнение программ, соз- данных для других ОС. Например, в OS/2 можно выполнять как программы, созданные для самой OS/2, так и программы, предназначенные для выполне- ния в среде MS-DOS и Windows 3.x. Соответствующая операционная среда организуется в операционной системе в рамках отдельной виртуальной ма- шины. Аналогично, в системе Linux можно создать условия для выполнения некоторых программ, написанных для Windows 95/98. Определенными воз- можностями исполнения программ, созданных для иной операционной среды, обладает и Windows NT. Эта система позволяет выполнять некоторые про- граммы, созданные для MS-DOS, OS/2 1.x, Windows 3.x. Правда, в своем по- следнем семействе ОС Windows 2000 разработчики решили отказаться от поддержки возможности выполнения DOS-программ. Наконец, к этому классу системного программного обеспечения следует отне- сти и эмуляторы, позволяющие смоделировать в одной операционной сис- теме какую-либо другую машину или операционную систему. Так, известна система эмуляции WMWARE, которая позволяет запустить в среде Linux любую другую ОС, например Windows. Можно, наоборот, создать эмулятор, работающий в среде Windows, который позволит смоделировать компьютер, работающий под управлением любой ОС, в том числе и под Linux. Таким образом, термин операционная среда олшеикч соогвеклвующий ин- терфейс, необходимый программам дня обращения к ОС с целью получить
21 определенный сервис1 — выполнить операцию ввода/вывода, получить или освободить участок памяти и т. д. 3. Система программирования на рис. 1 представлена прежде всего такими компонентами, как транслятор с соответствующего языка, библиотеки подпро- грамм, редакторы, компоновщики и отладчики. Не бывает самостоятельных (оторванных от ОС) систем программирования. Любая система программи- рования может работать только в соответствующей ОС, под которую она и создана, однако при этом она может позволять разрабатывать программное обеспечение и под другие ОС. Например, одна из популярных систем про- граммирования на языке C/C++ от фирмы Watcom для OS/2 позволяет по- лучать программы и для самой OS/2, и для DOS, и для Windows. В том случае, когда создаваемые программы должны работать совсем на другой аппаратной базе, говорят о кросс-системах. Так, для ПК на базе микропроцес- соров семейства i80x86 имеется большое количество кросс-систем, позволяю- щих создавать программное обеспечение для различных микропроцессоров и микроконтроллеров. 4. Наконец, под утилитами понимают специальные системные программы, с по- мощью которых можно как обслуживать саму операционную систему, так и подготавливать для работы носители данных, выполнять перекодирование данных, осуществлять оптимизацию размещения данных на носителе и про- изводить некоторые другие работы, связанные с обслуживанием вычислитель- ной системы. К утилитам следует отнести и программу разбиения накопителя на магнитных дисках на разделы, и программу форматирования, и программу переноса основных системных файлов самой ОС. Также к утилитам относят- ся и небезызвестные комплексы программ от фирмы Symantec, носящие имя Питера Нортона (создателя этой фирмы и соавтора популярного набора ути- лит для первых IBM PC). Естественно, что утилиты могут работать только в соответствующей операционной среде. 1 Сервис (service) — обслуживание, выполнение соответствующего запроса.
ГЛАВА 1 Основные понятия Понятие операционной среды Операционная система выполняет функции управления вычислительными про- цессами в вычислительной системе, распределяет ресурсы вычислительной сис- темы между различными вычислительными процессами и образует программ- ную среду, в которой выполняются прикладные программы пользователей. Такая среда называется операционной. Любая программа имеет дело с некоторыми исходными данными, которые она обрабатывает, и порождает в конечном итоге некоторые выходные данные, резуль- таты вычислений. Очевидно, что в абсолютном большинстве случаев исходные данные попадают в оперативную память (с которой непосредственно работает процессор, выполняя вычисления по программе) с внешних (периферийных) устройств. Аналогично и результаты вычислений, в конце концов, должны быть выведены на внешние устройства. Следует заметить, что программирование опе- раций ввода/вывода относится, пожалуй, к наиболее сложным и трудоемким за- дачам. Дело в том, что при создании таких программ без использования совре- менных систем программирования, как говорится, «по старинке», нужно знать не только архитектуру процессора (его состав, назначение основных регистров, систему команд процессора, форматы данных и т. п.), но и архитектуру подсис- темы ввода/вывода (соответствующие интерфейсы, протоколы обмена данными, алгоритм работы контроллера устройства ввода/вывода и т. д.). Именно поэто- му развитие системного программирования и самого системного программного обеспечения пошло по пути выделения наиболее часто встречающихся операций и создания для них соответствующих программных модулей, которые можно в дальнейшем использовать в большинстве вновь создаваемых программ. Например, в далекие пятидесятые годы, на заре развития вычислительных сис- тем, при разработке первых систем программирования прежде всего создавали программные модули для подсистемы ввода/вывода, а уже затем — вычисления часто встречающихся математических операций и функций. Благодаря этому при создании прикладных программ программисты могли просто обращаться к соот- ветствующим функциям ввода/вывода и иным функциям и процедурам, что из- бавляло их от необходимости каждый раз создавать все программные компонен-
Понятие операционной среды 23 ты «с нуля» и от необходимости знать во всех подробностях особенности работы контроллеров ввода/вывода и соответствующих интерфейсов. Следующий шаг в автоматизации создания готовых к выполнению машинных двоичных программ заключался в том, что транслятор с алгоритмического языка более высокого уровня, нежели первые ассемблеры, уже сам мог подставить вме- сто высокоуровневого оператора типа READ или WRITE все необходимые вызовы к готовым библиотечным программным модулям. Состав и количество библио- тек систем программирования постоянно увеличивались. В конечном итоге воз- никла ситуация, когда при создании двоичных машинных программ программи- сты могут вообще не знать многих деталей управления конкретными ресурсами вычислительной системы, а должны только обращаться к некоторой программ- ной подсистеме с соответствующими вызовами и получать от нее необходимые функции и сервисы. Эта программная подсистема и есть операционная система (ОС), а набор ее функций, сервисов и правила обращения к ним как раз и обра- зуют то базовое понятие, которое мы называем операционной средой. Таким об- разом, можно сказать, что термин операционная среда означает, прежде всего, соответствующие интерфейсы, необходимые программам и пользователям для обращения к ОС с целью получить определенные сервисы. Можно спросить: а чем отличаются системные программные модули, реализую- щие основные системные функции, от тех программных модулей, что пишутся прикладными программистами? Ответ простой: тем, что эти модули, как прави- ло, используются всеми прикладными программами. Поэтому нет особого смысла на этапе создания машинной двоичной программы (которую и исполняет про- цессор) присоединять соответствующие системные программные модули к телу программы. Выгоднее просто обращаться к этим программным модулям, указы- вая их адреса и передавая им необходимые параметры, поскольку они уже и так находятся в основной памяти, ибо нужны всем. Другими словами, эти основные системные программные модули входят в состав самой ОС. Параллельное существование терминов «операционная система» и «операцион- ная среда» вызвано тем, что ОС в общем случае может поддерживать несколько операционных сред. Например, операционная система OS/2 Warp может выпол- нять следующие программы: □ так называемые «нативныеЧ программы, созданные с учетом соответствую- щего «родного» 32-битового программного интерфейса этой ОС; □ 16-битовые программы, созданные для систем OS/2 первого поколения; □ 16-битовые приложения, разработанные для выполнения в операционной сре- де MS-DOS или PC DOS; □ 16-битовые приложения, созданные для операционной среды Windows 3.x; □ саму операционную оболочку Windows 3.x и уже в ней — созданные для нее программы. Операционная среда может включать несколько интерфейсов: пользовательские и программные. Если говорить о пользовательских, то, например, система Linux 1 Native — родной.
24 Глава 1. Основные понятия имеет для пользователя как интерфейсы командной строки (можно использо- вать различные «оболочки» — shell), интерфейс наподобие Norton Commander — Midnight Commander, так и графические интерфейсы — X-Window с различны- ми менеджерами окон — KDE, Gnome и т. д. Если же говорить о программных интерфейсах, то в той же ОС Linux программы могут обращаться как к операци- онной системе за соответствующими сервисами и функциями, так и к графиче- ской подсистеме (если она используется). С точки зрения архитектуры процес- сора (и всего ПК в целом) двоичная программа, созданная для работы в среде Linux, использует те же команды и форматы данных, что и программа, создан- ная для работы в среде Windows NT. Однако в первом случае мы имеем обраще- ние к одной операционной среде, а во втором — к другой. И программа, создан- ная для Windows непосредственно, не будет выполняться в Linux; однако если в ОС Linux организовать полноценную операционную среду Windows, то наша Windows-программа сможет быть выполнена. Можно сказать, что операционная среда — это то системное программное окружение, в котором могут выполняться программы, созданные по правилам работы этой среды. Понятия вычислительного процесса и ресурса Понятие «вычислительный процесс» (или просто — «процесс») является одним из основных при рассмотрении операционных систем. Как понятие процесс яв- ляется определенным видом абстракции, и мы будем придерживаться следующе- го неформального определения, приведенного в работе [37]. Последовательный процесс (иногда называемый «задачей»1) — это выполнение отдельной программы с ее данными на последовательном процессоре. Концептуально процессор рас- сматривается в двух аспектах: во-первых, он является носителем данных и, во- вторых, он (одновременно) выполняет операции, связанные с их обработкой. В качестве примеров можно назвать следующие процессы (задачи): прикладные программы пользователей, утилиты и другие системные обрабатывающие про- граммы. Процессами могут быть редактирование какого-либо текста, трансляция исходной программы, ее компоновка, исполнение. Причем трансляция какой-ни- будь исходной программы является одним процессом, а трансляция следующей исходной программы — другим процессом, поскольку, хотя транслятор как объ- единение программных модулей здесь выступает как одна и та же программа, но данные, которые он обрабатывает, являются разными. Определение концепции процесса преследует цель выработать механизмы распре- деления и управления ресурсами. Понятие ресурса, так же как и понятие процесса, 1 В концепции, которая получила наибольшее распространение в 70-е годы, задача (task) — это совокупность связанных между собой и образующих единое целое программных мо- дулей и данных, требующая ресурсов вычислительной системы для своей реализации. В последующие годы задачей стали называть единицу работы, для выполнения которой предоставляется центральный процессор. Вычислительный процесс может включать в себя несколько задач.
Понятия вычислительного процесса и ресурса 25 является, пожалуй, основным при рассмотрении операционных систем. Термин ресурс обычно применяется по отношению к повторно используемым, относи- тельно стабильным и часто недостающим объектам, которые запрашиваются, ис- пользуются и освобождаются процессами в период их активности. Другими сло- вами, ресурсом называется всякий объект, который может распределяться внутри системы. Ресурсы могут быть разделяемыми, когда несколько процессов могут их исполь- зовать одновременно (в один и тот же момент времени) или параллельно (в те- чение некоторого интервала времени процессы используют ресурс поперемен- но), а могут быть и неделимыми (рис. 1.1). Рис. 1.1. Классификация ресурсов При разработке первых систем ресурсами считались процессорное время, память, каналы ввода/вывода и периферийные устройства [49, 89]. Однако очень скоро понятие ресурса стало гораздо более универсальным и общим. Различного рода программные и информационные ресурсы также могут быть определены для сис- темы как объекты, которые могут разделяться и распределяться и доступ к кото- рым необходимо соответствующим образом контролировать. В настоящее время понятие ресурса превратилось в абстрактную структуру с целым рядом атрибу- тов, характеризующих способы доступа к этой структуре и ее физическое пред- ставление в системе. Более того, помимо системных ресурсов, о которых мы сейчас говорили, как ресурс стали толковать и такие объекты, как сообщения и синхросигналы, которыми обмениваются задачи. В первых вычислительных системах любая программа могла выполняться только после полного завершения предыдущей. Поскольку эти первые вычислительные системы были построены в соответствии с принципами, изложенными в извест- ной работе Яноша Джон фон Неймана, все подсистемы и устройства компьютера управлялись исключительно центральным процессором. Центральный процессор осуществлял и выполнение вычислений, и управление операциями ввода/выво- да данных. Соответственно, пока осуществлялся обмен данными между опера- тивной памятью и внешними устройствами, процессор не мог выполнять вычис- ления. Введение в состав вычислительной машины специальных контроллеров позволило совместить во времени (распараллелить) операции вывода получен- ных данных и последующие вычисления на центральном процессоре. Однако все равно процессор продолжал часто и долго простаивать, дожидаясь завершения
26 Глава 1. Основные понятия очередной операции ввода/вывода. Поэтому было предложено организовать так называемый мультипрограммный (мультизадачный) режим работы вычислитель- ной системы. Суть его заключается в том, что пока одна программа (один вычис- лительный процесс или задача, как мы теперь говорим) ожидает завершения очередной операции ввода/вывода, другая программа (а точнее, другая задача) может быть поставлена на решение (рис. 1.2). CPU Задача А Задача В Вв CPU Задача А Вв CPU Задача В б Рис. 1.2. Пример выполнения двух программ: а — однопрограммный режим; б — мультипрограммный режим Из рис. 1.2, на котором в качестве примера изображена такая гипотетическая си- туация, видно, что благодаря совмещению во времени выполнения двух программ общее время выполнения двух задач получается меньше, чем если бы мы выпол- няли их по очереди (запуск одной только после полного завершения другой). Из этого же рисунка видно, что время выполнения каждой задачи в общем слу- чае становится больше, чем если бы мы выполняли каждую из них как единст- венную.
Понятия вычислительного процесса и ресурса 27 При мультипрограммировании повышается пропускная способность системы, но отдельный процесс никогда не может быть выполнен быстрее, чем если бы он выполнялся в однопрограммном режиме (всякое разделение ресурсов замедляет работу одного из участников за счет дополнительных затрат времени на ожида- ние освобождения ресурса). Как мы уже отмечали, операционная система поддерживает мультипрограмми- рование (многопроцессность) и старается эффективно использовать ресурсы пу- тем организации к ним очередей запросов, составляемых тем или иным способом. Это требование достигается поддерживанием в памяти более одного процесса, ожидающего процессор, и более одного процесса, готового использовать другие ресурсы, как только последние станут доступными. Общая схема выделения ре- сурсов такова. При необходимости использовать какой-либо ресурс (оператив- ную память, устройство ввода/вывода, массив данных и т. п.) задача обращается к супервизору операционной системы — ее центральному управляющему моду- лю, который может состоять из нескольких модулей, например: супервизор вво- да/вывода, супервизор прерываний, супервизор программ, диспетчер задач и т. д. — посредством специальных вызовов (команд, директив) и сообщает о своем тре- бовании. При этом указывается вид ресурса и, если надо, его объем (например, количество адресуемых ячеек оперативной памяти, количество дорожек или сек- торов на системном диске, устройство печати и объем выводимых данных и т. п.). Директива обращения к операционной системе передает ей управление, перево- дя процессор в привилегированный режим работы (см. раздел «Прерывания», глава 1), если такой существует. Не все вычислительные комплексы имеют два (и более) режима работы: привилегированный (режим супервизора), пользова- тельский, режим эмуляции какого-нибудь другого компьютера и т. д. Ресурс может быть выделен задаче, обратившейся к супервизору с соответствую- щим запросом, если: □ он свободен и в системе нет запросов от задач более высокого приоритета к этому же ресурсу; □ текущий запрос и ранее выданные запросы допускают совместное использо- вание ресурсов; □ ресурс используется задачей низшего приоритета и может быть временно ото- бран (разделяемый ресурс). Получив запрос, операционная система либо удовлетворяет его и возвращает управление задаче, выдавшей данный запрос, либо, если ресурс занят, ставит за- дачу в очередь к ресурсу, переводя ее в состояние ожидания (блокируя). Оче- редь к ресурсу может быть организована несколькими способами, но чаще всего это осуществляется с помощью списковой структуры. После окончания работы с ресурсом задача опять с помощью специального вы- зова супервизора (посредством соответствующей директивы) сообщает операци' онной системе об отказе от ресурса, или операционная система забирает ресурс сама, если управление возвращается супервизору после выполнения какой-либо системной функции. Супервизор операционной системы, получив управление по этому обращению, освобождает ресурс и проверяет, имеется ли очередь к ос-
28 Глава 1. Основные понятия вободившемуся ресурсу. Если очередь есть — в зависимости от принятой дисци- плины обслуживания (правила обслуживания)1 и приоритетов заявок он выводит из состояния ожидания задачу, ждущую ресурс, и переводит ее в состояние го- товности к выполнению. После этого управление либо передается данной задаче, либо возвращается той, которая только что освободила ресурс. При выдаче запроса на ресурс задача может указать, хочет ли она владеть ресур- сом монопольно или допускает совместное использование с другими задачами. Например, с файлом можно работать монопольно, а можно и совместно с други- ми задачами. Если в системе имеется некоторая совокупность ресурсов, то управлять их ис- пользованием можно на основе определенной стратегии. Стратегия подразуме- вает четкую формулировку целей, следуя которым можно добиться эффективно- го распределения ресурсов. При организации управления ресурсами всегда требуется принять решение о том, что в данной ситуации выгоднее: быстро обслуживать отдельные наиболее важ- ные запросы, предоставлять всем процессам равные возможности либо обслужи- вать максимально возможное количество процессов и наиболее полно использо- вать ресурсы [37]. Диаграмма состояний процесса Необходимо различать системные управляющие процессы, представляющие ра- боту супервизора операционной системы и занимающиеся распределением и управ- лением ресурсов, от всех других процессов: системных обрабатывающих процес- сов, которые не входят в ядро операционной системы, и процессов пользователя. Для системных управляющих процессов в большинстве операционных систем ресурсы распределяются изначально и однозначно. Эти процессы управляют ре- сурсами системы, за использование которых существует конкуренция между всеми остальными процессами. Поэтому исполнение системных управляющих программ не принято называть процессами. Термин задача можно употреблять только по отношению к процессам пользователей и к системным обрабатывающим процес- сам. Однако это справедливо не для всех ОС. Например, в так называемых «мик- роядерных» (см. главу 5 «Архитектура операционных систем и интерфейсы прикладного программирования») ОС (в качестве примера можно привести ОС реального времени QNX фирмы Quantum Software systems) большинство управ- ляющих программных модулей самой ОС и даже драйверы имеют статус высо- коприоритетных процессов, для выполнения которых необходимо выделить со- ответствующие ресурсы. Аналогично и в UNIX-системах выполнение системных программных модулей тоже имеет статус системных процессов, которые получа- ют ресурсы для своего исполнения. Если обобщать и рассматривать не только обычные ОС общего назначения, но и, например, ОС реального времени, то можно сказать, что процесс может нахо- диться в активном и пассивном (не активном) состоянии. В активном состоянии 1 Например, дисциплина «последний пришедший обслуживается первым» определяет об- служивание в порядке, обратном очередности поступления соответствующих запросов.
Понятия вычислительного процесса и ресурса 29 процесс может участвовать в конкуренции за использование ресурсов вычисли- тельной системы, а в пассивном — он только известен системе, но в конкуренции не участвует (хотя его существование в системе и сопряжено с предоставлением ему оперативной и/или внешней памяти). В свою очередь, активный процесс мо- жет быть в одном из следующих состояний: □ выполнения — все затребованные процессом ресурсы выделены. В этом состоя- нии в каждый момент времени может находиться только один процесс, если речь идет об однопроцессорной вычислительной системе; □ готовности к выполнению — ресурсы могут быть предоставлены, тогда про- цесс перейдет в состояние выполнения; □ блокирования или ожидания — затребованные ресурсы не могут быть предо- ставлены, или не завершена операция ввода/вывода. В большинстве операционных систем последнее состояние, в свою очередь, под- разделяется на множество состояний ожидания, соответствующих определенно- му виду ресурса, из-за отсутствия которого процесс переходит в заблокирован- ное состояние. В обычных ОС, как правило, процесс появляется при запуске какой-нибудь про- граммы. ОС организует (порождает или выделяет) для нового процесса соответ- ствующий дескриптор (см. об этом дальше) процесса, и процесс (задача) начи- нает развиваться (выполняться). Поэтому пассивного состояния не существует. В ОС реального времени (ОСРВ) ситуация иная. Обычно при проектировании системы реального времени уже заранее бывает известен состав программ (за- дач), которые должны будут выполняться. Известны и многие их параметры, которые необходимо учитывать при распределении ресурсов (например, объем памяти, приоритет, средняя длительность выполнения, открываемые файлы, используемые устройства и т. п.). Поэтому для них заранее заводят дескрипторы задач с тем, чтобы впоследствии не тратить драгоценное время на организацию дескриптора и поиск для него необходимых ресурсов. Таким образом, в ОСРВ многие процессы (задачи) могут находиться в состоянии бездействия, что мы и отобразили на рис. 1.3, отделив это состояние от остальных состояний пунк- тиром. За время своего существования процесс может неоднократно совершать перехо- ды из одного состояния в другое. Это обусловлено обращениями к операцион- ной системе с запросами ресурсов и выполнения системных функций, которые предоставляет операционная система, взаимодействием с другими процессами, появлением сигналов прерывания от таймера, каналов и устройств ввода/выво- да, а также других устройств. Возможные переходы процесса из одного состоя- ния в другое отображены в виде графа состояний на рис. 1.3. Рассмотрим эти пе- реходы из одного состояния в другое более подробно. Процесс из состояния бездействия может перейти в состояние готовности в сле- дующих случаях: □ по команде оператора (пользователя). Имеет место в тех диалоговых опера- ционных системах, где программа может иметь статус задачи (и при этом яв- ляться пассивной), а не просто быть исполняемым файлом и только на время
30 Глава 1. Основные понятия исполнения получать статус задачи (как это происходит в большинстве со- временных ОС для ПК); □ при выборе из очереди планировщиком (характерно для операционных сис- тем, работающих в пакетном режиме); □ по вызову из другой задачи (посредством обращения к супервизору один процесс может создать, инициировать, приостановить, остановить, уничто- жить другой процесс); □ по прерыванию от внешнего инициативного1 устройства (сигнал о свершении некоторого события может запускать соответствующую задачу); □ при наступлении запланированного времени запуска программы. г Бездействие • (пассивное состояние) Рис. 1.3. Граф состояний процесса Последние два способа запуска задачи, при которых процесс из состояния без- действия переходит в состояние готовности, характерны для операционных сис- тем реального времени. Процесс, который может исполняться, как только ему будет предоставлен про- цессор, а для диск-резидентных задач в некоторых системах — и оперативная па- мять, находится в состоянии готовности. Считается, что такому процессу уже выделены все необходимые ресурсы за исключением процессора. Из состояния выполнения процесс может выйти по одной из следующих при- чин: □ процесс завершается, при этом он посредством обращения к супервизору пе- редает управление операционной системе и сообщает о своем завершении. В результате этих действий супервизор либо переводит его в список бездей- ствующих процессов (процесс переходит в пассивное состояние), либо уничто- жает (уничтожается, естественно, не сама программа, а именно задача, которая соответствовала исполнению некоторой программы). В состояние бездействия процесс может быть переведен принудительно: по команде оператора (дейст- 1 Устройство называется «инициативным», если по сигналу запроса на прерывание от него должна запускаться некоторая задача.
Понятия вычислительного процесса и ресурса 31 вие этой и других команд оператора реализуется системным процессом, кото- рый «транслирует» команду в запрос к супервизору с требованием перевести указанный процесс в состояние бездействия), или путем обращения к супер- визору операционной системы из другой задачи с требованием остановить данный процесс; □ процесс переводится супервизором операционной системы в состояние готов- ности к исполнению в связи с появлением более приоритетной задачи или в связи с окончанием выделенного ему кванта времени; □ процесс блокируется (переводится в состояние ожидания) либо вследствие запроса операции ввода/вывода (которая должна быть выполнена прежде, чем он сможет продолжить исполнение), либо в силу невозможности предо- ставить ему ресурс, запрошенный в настоящий момент (причиной перевода в состояние ожидания может быть и отсутствие сегмента или страницы в слу- чае организации механизмов виртуальной памяти, см. раздел «Сегментная, страничная и сегментно-страничная организация памяти» в главе 2), а также по команде оператора на приостановку задачи или по требованию через су- первизор от другой задачи. При наступлении соответствующего события (завершилась операция ввода/вы- вода, освободился затребованный ресурс, в оперативную память загружена необ- ходимая страница виртуальной памяти и т. д.) процесс деблокируется и перево- дится в состояние готовности к исполнению. Таким образом, движущей силой, меняющей состояния процессов, являются со- бытия. Один из основных видов событий — это прерывания. Реализация понятия последовательного процесса в ОС Для того чтобы операционная система могла управлять процессами, она должна располагать всей необходимой для этого информацией. С этой целью на каждый процесс заводится специальная информационная структура, называемая дескрип- тором процесса (описателем задачи, блоком управления задачей). В общем слу- чае дескриптор процесса содержит следующую информацию: □ идентификатор процесса (так называемый PID — process identificator); □ тип (или класс) процесса, который определяет для супервизора некоторые пра- вила предоставления ресурсов; □ приоритет процесса, в соответствии с которым супервизор предоставляет ре- сурсы. В рамках одного класса процессов в первую очередь обслуживаются более приоритетные процессы; □ переменную состояния, которая определяет, в каком состоянии находится процесс (готов к работе, в состоянии выполнения, ожидание устройства вво- да/вывода и т. д.); □ защищенную область памяти (или адрес такой зоны), в которой хранятся те- кущие значения регистров процессора, если процесс прерывается, не закон- чив работы. Эта информация называется контекстом задачи;
32 Глава 1. Основные понятия □ информацию о ресурсах, которыми процесс владеет и/или имеет право поль- зоваться (указатели на открытые файлы, информация о незавершенных опе- рациях ввода/вывода и т. п.); □ место (или его адрес) для организации общения с другими процессами; □ параметры времени запуска (момент времени, когда процесс должен активи- зироваться, и периодичность этой процедуры); □ в случае отсутствия системы управления файлами — адрес задачи на диске в ее исходном состоянии и адрес на диске, куда она выгружается из оператив- ной памяти, если ее вытесняет другая (для диск-резидентных задач, которые постоянно находятся во внешней памяти на системном магнитном диске и за- гружаются в оперативную память только на время выполнения). Описатели задач, как правило, постоянно располагаются в оперативной памяти с целью ускорить работу супервизора, который организует их в списки (очере- ди) и отображает изменение состояния процесса перемещением соответствую- щего описателя из одного списка в другой. Для каждого состояния (за исклю- чением состояния выполнения для однопроцессорной системы) операционная система ведет соответствующий список задач, находящихся в этом состоянии. Однако для состояния ожидания может быть не один список, а столько, сколь- ко различных видов ресурсов могут вызывать состояние ожидания. Например, состояний ожидания завершения операции ввода/вывода может быть столько, сколько устройств ввода/вывода имеется в системе. В некоторых операционных системах количество описателей определяется жест- ко и заранее (на этапе генерации варианта операционной системы или в конфи- гурационном файле, который используется при загрузке ОС), в других — по мере необходимости система может выделять участки памяти под новые описатели. Например, в OS/2 максимально возможное количество описателей задач опреде- ляется в конфигурационном файле CONFIG.SYS, а в Windows NT оно в явном виде не задается. Справедливости ради стоит заметить, что в упомянутом файле указывается количество не процессов, а именно задач, и под задачей в данном случае понимается как процесс, так и поток этого же процесса, называемый по- током или тредом (см. следующий раздел). Например, строка в файле CONFIG.SYS THREADS=1024 указывает, что всего в системе может параллельно существовать и выполняться до 1024 задач, включая вычислительные процессы и их потоки. В ОС реального времени чаще всего количество процессов фиксируется и, сле- довательно, целесообразно заранее определять (на этапе генерации или конфи- гурирования ОС) количество дескрипторов. Для использования таких ОС в ка- честве систем общего назначения (что сейчас встречается редко, а в недалеком прошлом достаточно часто в качестве вычислительных систем общего назначе- ния приобретали мини-ЭВМ и устанавливали на них ОС реального времени) обычно количество дескрипторов берется с некоторым запасом, и появление новой задачи связывается с заполнением этой информационной структуры. Поскольку дескрипторы процессов постоянно располагаются в оперативной памяти (с це- лью ускорить работу диспетчера), то их количество не должно быть очень боль- шим. При необходимости иметь большое количество задач один и тот же деск-
Понятия вычислительного процесса и ресурса 33 риптор может в разное время предоставляться для разных задач, но это сильно снижает скорость реагирования системы. Для более эффективной обработки данных в системах реального времени целе- сообразно иметь постоянные задачи, полностью или частично всегда существую- щие в системе независимо от того, поступило на них требование или нет. Каждая постоянная задача обладает некоторой собственной областью оперативной памя- ти (ОЗУ-резидентные задачи) независимо от того, выполняется задача в данный момент или нет. Эта область, в частности, может использоваться для хранения данных, полученных задачей ранее. Данные могут храниться в ней и тогда, когда задача находится в состоянии ожидания или даже в состоянии бездействия. Для аппаратной поддержки работы операционных систем с этими информаци- онными структурами (дескрипторами задач) в процессорах могут быть реали- зованы соответствующие механизмы. Так, например, в микропроцессорах Intel 80x86 (см. главу 3 «Особенности архитектуры микропроцессоров i80x86 для ор- ганизации мультипрограммных операционных систем»), начиная с 80286, имеет- ся специальный регистр TR (task register), указывающий местонахождение TSS (сегмента состояния задачи1, см. раздел «Новые системные регистры микропро- цессоров i80x86», глава 3), в котором при переключении с задачи на задачу авто- матически сохраняется содержимое регистров процессора [2, 22, 84]. Как прави- ло, в современных ОС для этих микропроцессоров дескриптор задачи включает в себя TSS. Другими словами, дескриптор задачи больше по размеру, чем TSS, и включает в себя такие традиционные поля, как идентификатор задачи, ее имя, тип, приоритет и т. п. Процессы и треды Понятие процесса было введено для реализации идей мультипрограммирования. Напомним, в свое время различали термины «мультизадачность» и «мультипро- граммирование». Таким образом, для реализации «мультизадачности» в ее ис- ходном толковании необходимо было тоже ввести соответствующую сущность. Такой сущностью и стали так называемые «легковесные» процессы, или, как их теперь преимущественно называют, — потоки или треды (нити)2. Рассмотрим эти понятия подробнее. Когда говорят о процессах (process), то тем самым хотят отметить, что операци- онная система поддерживает их обособленность: у каждого процесса имеется свое виртуальное адресное пространство, каждому процессу назначаются свои ресур- сы — файлы, окна, семафоры и т. д. Такая обособленность нужна для того, чтобы защитить один процесс от другого, поскольку они, совместно используя все ре- сурсы вычислительной системы, конкурируют друг с другом. В общем случае процессы просто никак не связаны между собой и могут принадлежать даже раз- ным пользователям, разделяющим одну вычислительную систему. Другими сло- вами, в случае процессов ОС считает их совершенно несвязанными и независи- 1 TSS (task state segment) — сегмент состояния задачи. 2 Thread — поток, нить.
34 Глава 1. Основные понятия мыми. При этом именно ОС берет на себя роль арбитра в конкуренции между процессами по поводу ресурсов. Однако желательно иметь еще и возможность задействовать внутренний парал- лелизм, который может быть в самих процессах. Такой внутренний параллелизм встречается достаточно часто и его использование позволяет ускорить их реше- ние. Например, некоторые операции, выполняемые приложением, могут требо- вать для своего исполнения достаточно длительного использования центрального процессора. В этом случае при интерактивной работе с приложением пользова- тель вынужден долго ожидать завершения заказанной операции и не может управ- лять приложением до тех пор, пока операция не выполнится до самого конца. Такие ситуации встречаются достаточно часто, например, при обработке боль- ших изображений в графических редакторах. Если же программные модули, исполняющие такие длительные операции, оформлять в виде самостоятельных «подпроцессов» (легковесных или облегченных процессов — потоков, можно также воспользоваться термином задача), которые будут выполняться парал- лельно с другими «подпроцессами» (потоками, задачами), то у пользователя по- является возможность параллельно выполнять несколько операций в рамках од- ного приложения (процесса). Легковесными эти задачи называют потому, что операционная система не должна для них организовывать полноценную вирту- альную машину. Эти задачи не имеют своих собственных ресурсов, они развива- ются в том же виртуальном адресном пространстве, могут пользоваться теми же файлами, виртуальными устройствами и иными ресурсами, что и данный про- цесс. Единственное, что им необходимо иметь, — это процессорный ресурс. В од- нопроцессорной системе треды (задачи) разделяют между собой процессорное время так же, как это делают обычные процессы, а в мультипроцессорной систе- ме могут выполняться одновременно, если не встречают конкуренции из-за об- ращения к иным ресурсам. Главное, что обеспечивает многопоточность, — это возможность параллельно вы- полнять несколько видов операций в одной прикладной программе. Параллель- ные вычисления (а следовательно, и более эффективное использование ресур- сов центрального процессора, и меньшее суммарное время выполнения задач) теперь уже часто реализуется на уровне тредов, и программа, оформленная в виде нескольких тредов в рамках одного процесса, может быть выполнена быстрее за счет параллельного выполнения ее отдельных частей. Например, если электрон- ная таблица или текстовый процессор были разработаны с учетом возможностей многопоточной обработки, то пользователь может запросить пересчет своего ра- бочего листа или слияние нескольких документов и одновременно продолжать заполнять таблицу или открывать для редактирования следующий документ. Особенно эффективно можно использовать многопоточность для выполнения распределенных приложений; например, многопоточный сервер может парал- лельно выполнять запросы сразу нескольких клиентов. Как известно, операци- онная система OS/2 одной из первых среди ОС, используемых на ПК, ввела многопоточность. В середине девяностых годов для этой ОС было создано очень большое количество приложений, в которых использование механизмов много- поточной обработки реально приводило к существенно большей скорости вы- полнения вычислений.
Понятия вычислительного процесса и ресурса 35 Итак, сущность «поток» была введена для того, чтобы именно с помощью этих единиц распределять процессорное время между возможными работами. Сущ- ность «процесс» предполагает, что при диспетчеризации нужно учитывать все ресурсы, закрепленные за ним. А при манипулировании тредами можно менять только контекст задачи, если мы переключаемся с одной задачи на другую в рам- ках одного процесса. Все остальные вычислительные ресурсы при этом не затра- гиваются. Каждый процесс всегда состоит по крайней мере из одного потока, и только если имеется внутренний параллелизм, программист может «расще- пить» один тред на несколько параллельных. Потребность в потоках (threads) возникла еще на однопроцессорных вычисли- тельных системах, поскольку они позволяют организовать вычисления более эф- фективно. Для использования достоинств многопроцессорных систем с общей памятью треды уже просто необходимы, так как позволяют не только реально ускорить выполнение тех задач, которые допускают их естественное распаралле- ливание, но и загрузить процессорные элементы работой, чтобы они не простаи- вали. Заметим, однако, что желательно, чтобы можно было уменьшить взаимо- действие тредов между собой, ибо ускорение от одновременного выполнения параллельных потоков может быть сведено к минимуму из-за задержек синхро- низации и обмена данными. Каждый тред выполняется строго последовательно и имеет свой собственный программный счетчик и стек. Треды, как и процессы, могут порождать треды-по- томки, поскольку любой процесс состоит по крайней мере из одного треда. По- добно традиционным процессам (то есть процессам, состоящим из одного треда), каждый тред может находится в одном из активных состояний. Пока один тред заблокирован (или просто находится в очереди готовых к исполнению задач), другой тред того же процесса может выполняться. Треды разделяют процессор- ное время так же, как это делают обычные процессы, в соответствии с различны- ми вариантами диспетчеризации. Как мы уже знаем, все треды имеют одно и то же виртуальное адресное про- странство своего процесса. Это означает, что они разделяют одни и те же гло- бальные переменные. Поскольку каждый тред может иметь доступ к каждому виртуальному адресу, один тред может использовать стек другого треда. Между потоками нет полной защиты, так как это, во-первых, невозможно, а во-вторых, не нужно. Все потоки одного процесса всегда решают общую задачу одного поль- зователя, и механизм потоков используется здесь для более быстрого решения задачи путем ее распараллеливания. При этом программисту очень важно полу- чить в свое распоряжение удобные средства организации взаимодействия частей одной программы. Повторим, что кроме разделения адресного пространства, все треды разделяют также набор открытых файлов, используют общие устройства, выделенные процессу, имеют одни и те же наборы сигналов, семафоры и т. п. А что у тредов будет их собственным? Собственными являются программный счетчик, стек, рабочие регистры процессора, потоки-потомки, состояние. Вследствие того, что треды, относящиеся к одному процессу, выполняются в од- ном и том же виртуальном адресном пространстве, между ними легко организо- вать тесное взаимодействие, в отличие от процессов, для которых нужны специ- альные механизмы обмена сообщениями и данными. Более того, программист,
36 Глава 1. Основные понятия создающий многопоточное приложение, может заранее продумать работу мно- жества тредов процесса таким образом, чтобы они могли взаимодействовать наи- более выгодным способом, а не участвовать в конкуренции за предоставление ресурсов тогда, когда этого можно избежать. Для того чтобы можно было эффективно организовать параллельное выполне- ние рассмотренных сущностей (процессов и тредов), в архитектуру современных процессоров включена возможность работать со специальной информационной структурой, описывающей ту или иную сущность. Для этого уже на уровне архи- тектуры микропроцессора используется понятие «задача» (task). Оно как бы объединяет в себе обычный и «легковесный» процессы. Это понятие и поддер- живаемая для него на уровне аппаратуры информационная структура позволяют в дальнейшем при разработке операционной системы построить соответствую- щие дескрипторы как для процесса, так и для треда. Отличаться эти дескрипто- ры будут прежде всего тем, что дескриптор треда может хранить только контекст приостановленного вычислительного процесса, тогда как дескриптор процесса (process) должен уже содержать поля, описывающие тем или иным способом ре- сурсы, выделенные этому процессу. Другими словами, тот же task state segment (сегмент состояния задачи), подробно рассмотренный в разделе «Адресация в 32-разрядных микропроцессорах i80x86 при работе в защищенном режиме» гла- вы 3, используется как основа для дескриптора процесса. Каждый тред (в случае использования так называемой «плоской» модели памяти — см. раздел «Под- держка страничного способа организации виртуальной памяти», глава 3 — мо- жет быть оформлен в виде самостоятельного сегмента, что приводит к тому, что простая (не многопоточная) программа будет иметь всего один сегмент кода в виртуальном адресном пространстве. В завершение можно привести несколько советов по использованию потоков при создании приложений, заимствованных из работы [55]. 1. В случае использования однопроцессорной системы множество параллельных потоков часто не ускоряет работу приложения, поскольку в каждый отдельно взятый промежуток времени возможно выполнение только одного потока. Кроме того, чем больше у вас потоков, тем больше нагрузка на систему, по- траченная на переключение между ними. Если ваш проект имеет более двух постоянно работающих потоков, то такая мультизадачность не сделает про- грамму быстрее, если каждый из потоков не будет требовать частого ввода/ вывода. 2. Вначале нужно понять, для чего необходим поток. Поток, осуществляющий обработку, может помешать системе быстро реагировать на запросы ввода/ вывода. Потоки позволяют программе отзываться на просьбы пользователя и устройств, но при этом сильно загружать процессор. Потоки позволяют компьютеру одновременно обслуживать множество устройств, и созданный вами поток, отвечающий за обработку специфического устройства, в качестве минимума может потребовать столько времени, сколько системе необходимо для обработки запросов всех устройств. 3. Потокам можно назначить определенный приоритет для того, чтобы наиме- нее значимые процессы выполнялись в фоновом режиме. Это путь честного
Прерывания 37 разделения ресурсов CPU1. Однако необходимо осознать тот факт, что про- цессор один на всех, а потоков много. Если в вашей программе главная про- цедура передает нечто для обработки в низкоприоритетный поток, то сама программа становится просто неуправляемой. 4. Потоки хорошо работают, когда они независимы. Но они начинают работать непродуктивно, если вынуждены часто синхронизироваться для доступа к об- щим ресурсам. Блокировка и критические секции отнюдь не увелиичвают скорость работы системы, хотя без использования этих механизмов взаимо- действующие вычисления организовывать нельзя. 5. Помните, что память виртуальна. Механизм виртуальной памяти (см. раздел «Память и отображения, виртуальное адресное пространство», глава 2) сле- дит за тем, какая часть виртуального адресного пространства должна нахо- диться в оперативной памяти, а какая должна быть сброшена в файл подкачки. Потоки усложняют ситуацию, если они обращаются в одно и то же время к разным адресам виртуального адресного пространства приложения. Это зна- чительно увеличивает нагрузку на систему, особенно при небольшом объеме кэш-памяти. Помните, что реально память не всегда «свободна», как это пи- шут в информационных «окошках» «О системе». Всегда отождествляйте дос- туп к памяти с доступом к файлу на диске и создавайте приложение с учетом вышесказанного. 6. Всякий раз, когда какой-либо из ваших потоков пытается воспользоваться общим ресурсом вычислительного процесса, которому он принадлежит, вы обязаны тем или иным образом легализовать и защитить свою деятельность. Хорошим средством для этого являются критические секции, семафоры и оче- реди сообщений. Если вы протестировали свое приложение и не обнаружили ошибок синхронизации, то это еще не значит, что их там нет. Пользователь может создать самые непредсказуемые ситуации. Это очень ответственный момент в разработке многопоточных приложений. 7. Не возлагайте на поток несколько функций. Сложные функциональные отно- шения затрудняют понимание общей структуры приложения, его алгоритм. Чем проще и менее многозначна каждая из рассматриваемых ситуаций, тем больше вероятность, что ошибок удастся избежать. Прерывания Прерывания представляют собой механизм, позволяющий координировать па- раллельное функционирование отдельных устройств вычислительной системы и реагировать на особые состояния, возникающие при работе процессора. Таким образом, прерывание — это принудительная передача управления от выполняе- мой программы к системе (а через нее — к соответствующей программе обработ- ки прерывания), происходящая при возникновении определенного события. 1 CPU (central processing unit) — центральное обрабатывающее устройство или просто про- цессор.
38 Глава 1. Основные понятия Идея прерываний была предложена в середине 50-х годов и можно без преувели- чения сказать, что она внесла наиболее весомый вклад в развитие вычислитель- ной техники. Основная цель введения прерываний — реализация асинхронного режима работы и распараллеливание работы отдельных устройств вычислитель- ного комплекса. Механизм прерываний реализуется аппаратно-программными средствами. Струк- туры систем прерывания (в зависимости от аппаратной архитектуры) могут быть самыми разными, но все они имеют одну общую особенность — прерывание не- пременно влечет за собой изменение порядка выполнения команд процессором. Механизм обработки прерываний независимо от архитектуры вычислительной системы включает следующие элементы: 1. Установление факта прерывания (прием сигнала на прерывание) и иденти- фикация прерывания (в операционных системах иногда осуществляется по- вторно, на шаге 4). 2. Запоминание состояния прерванного процесса. Состояние процесса определя- ется прежде всего значением счетчика команд (адресом следующей команды, который, например, в i80x86 определяется регистрами CS и IP — указателем команды [2, 22, 84]), содержимым регистров процессора и может включать также спецификацию режима (например, режим пользовательский или при- вилегированный) и другую информацию. 3. Управление аппаратно передается подпрограмме обработки прерывания. В про- стейшем случае в счетчик команд заносится начальный адрес подпрограммы обработки прерываний, а в соответствующие регистры — информация из сло- ва состояния. В более развитых процессорах, например в том же i80286 и по- следующих 32-битовых микропроцессорах, начиная с i80386, осуществляется достаточно сложная процедура определения начального адреса соответствую- щей подпрограммы обработки прерывания и не менее сложная процедура инициализации рабочих регистров процессора (см. раздел «Система прерыва- ний 32-разрядных микропроцессоров i80x86>, глава 3). 4. Сохранение информации о прерванной программе, которую не удалось спа- сти на шаге 2 с помощью действий аппаратуры. В некоторых вычислитель- ных системах предусматривается запоминание довольно большого объема информации о состоянии прерванного процесса. 5. Обработка прерывания. Эта работа может быть выполнена той же подпро- граммой, которой было передано управление на шаге 3, но в ОС чаще всего она реализуется путем последующего вызова соответствующей подпрограммы. 6. Восстановление информации, относящейся к Прерванному процессу (этап, обратный шагу 4). 7. Возврат в прерванную программу. Шаги 1-3 реализуются аппаратно, а шаги 4-7 — программно. На рис. 1.4 показано, что при возникновении запроса на прерывание естествен- ный ход вычислений нарушается и управление передается программе обработ- ки возникшего прерывания. При этом средствами аппаратуры сохраняется (как правило, с помощью механизмов стековой памяти) адрес той команды, с которой
Прерывания 39 следует продолжить выполнение прерванной программы. После выполнения про- граммы обработки прерывания управление возвращается прерванной ранее про- грамме посредством занесения в указатель команд сохраненного адреса команды. Однако такая схема используется только в самых простых программных средах. В мультипрограммных операционных системах обработка прерываний происхо- дит по более сложным схемам, о чем будет более подробно написано ниже. Рис. 1.4. Обработка прерывания Итак, главные функции механизма прерываний: □ распознавание или классификация прерываний; □ передача управления соответственно обработчику прерываний; □ корректное возвращение к прерванной программе. Переход от прерываемой программы к обработчику и обратно должен выпол- няться как можно быстрей. Одним из быстрых методов является использование таблицы, содержащей перечень всех допустимых для компьютера прерываний и адреса соответствующих обработчиков. Для корректного возвращения к пре- рванной программе перед передачей управления обработчику прерываний со- держимое регистров процессора запоминается либо в памяти с прямым досту- пом, либо в системном стеке — system stack. Прерывания, возникающие при работе вычислительной системы, можно разде- лить на два основных класса: внешние (их иногда называют асинхронными) и внутренние (синхронные). Внешние прерывания вызываются асинхронными событиями, которые происхо- дят вне прерываемого процесса, например: □ прерывания от таймера; □ прерывания от внешних устройств (прерывания по вводу/выводу);
40 Глава 1. Основные понятия □ прерывания по нарушению питания; □ прерывания с пульта оператора вычислительной системы; □ прерывания от другого процессора или другой вычислительной системы. Внутренние прерывания вызываются событиями, которые связаны с работой про- цессора и являются синхронными с его операциями. Примерами являются сле- дующие запросы на прерывания: □ при нарушении адресации (в адресной части выполняемой команды указан запрещенный или несуществующий адрес, обращение к отсутствующему сег- менту или странице при организации механизмов виртуальной памяти); □ при наличии в поле кода операции незадействованной двоичной комбинации; □ при делении на нуль; □ при переполнении или исчезновении порядка; □ при обнаружении ошибок четности, ошибок в работе различных устройств аппаратуры средствами контроля. Могут еще существовать прерывания при обращении к супервизору ОС — в не- которых компьютерах часть команд может использовать только ОС, а не пользо- ватели. Соответственно в аппаратуре предусмотрены различные режимы работы, и пользовательские программы выполняются в режиме, в котором эти привиле- гированные команды не исполняются. При попытке использовать команду, за- прещенную в данном режиме, происходит внутреннее прерывание и управление передается супервизору ОС. К привилегированным командам относятся и ко- манды переключения режима работа центрального процессора. Наконец, существуют собственно программные прерывания. Эти прерывания про- исходят по соответствующей команде прерывания, то есть по этой команде про- цессор осуществляет практически те же действия, что и при обычных внутрен- них прерываниях. Данный механизм был специально введен для того, чтобы переключение на системные программные модули происходило не просто как переход в подпрограмму, а точно таким же образом, как и обычное прерывание. Этим обеспечивается автоматическое переключение процессора в привилегиро- ванный режим с возможностью исполнения любых команд. Сигналы, вызывающие прерывания, формируются вне процессора или в самом процессоре; они могут возникать одновременно. Выбор одного из них для обра- ботки осуществляется на основе приоритетов, приписанных каждому типу пре- рывания. Очевидно, что прерывания от схем контроля процессора должны обла- дать наивысшим приоритетом (если аппаратура работает неправильно, то не имеет смысла продолжать обработку информации). На рис. 1.5 изображен обыч- ный порядок (приоритеты) обработки прерываний в зависимости от типа преры- ваний. Учет приоритета может быть встроен в технические средства, а также определяться операционной системой, то есть кроме аппаратно реализованных приоритетов прерывания большинство вычислительных машин и комплексов допускают программно-аппаратное управление порядком обработки сигналов прерывания. Второй способ, дополняя первый, позволяет применять различные дисциплины обслуживания прерываний.
Прерывания 41 Рис. 1.5. Распределение прерываний по уровням приоритета Наличие сигнала прерывания не обязательно должно вызывать прерывание исполняющейся программы. Процессор может обладать средствами защиты от прерываний: отключение системы прерываний, маскирование (запрет) отдель- ных сигналов прерывания. Программное управление этими средствами (сущест- вуют специальные команда для управления работой системы прерываний) по- зволяет операционной системе регулировать обработку сигналов прерывания, заставляя процессор обрабатывать их сразу по приходу, откладывать их обработ- ку на некоторое время или полностью игнорировать. Обычно операция прерыва- ния выполняется только после завершения выполнения текущей команды. По- скольку сигналы прерывания возникают в произвольные моменты времени, то на момент прерывания может существовать несколько сигналов прерывания, ко- торые могут быть обработаны только последовательно. Чтобы обработать сигна- лы прерывания в разумном порядке им (как уже отмечалось) присваиваются приоритеты. Сигнал с более высоким приоритетом обрабатывается в первую очередь, обработка остальных сигналов прерывания откладывается. Программное управление специальными регистрами маски (маскирование сиг- налов прерывания) позволяет реализовать различные дисциплины обслужива- ния: □ с относительными приоритетами, то есть обслуживание не прерывается даже при наличии запросов с более высокими приоритетами. После окончания об- служивания данного запроса обслуживается запрос с наивысшим приоритетом. Для организации такой дисциплины необходимо в программе обслуживания данного запроса наложить маски на все остальные сигналы прерывания или просто отключить систему прерываний; □ с абсолютными приоритетами, то есть всегда обслуживается прерывание с наивысшим приоритетом. Для реализации этого режима необходимо на вре- мя обработки прерывания замаскировать все запросы с более низким приори- тетом. При этом возможно многоуровневое прерывание, то есть прерывание программ обработки прерываний. Число уровней прерывания в этом режиме изменяется и зависит от приоритета запроса; □ по принципу стека, или, как иногда говорят, по дисциплине LCFS (last come first served — последним пришел — первым обслужен), то есть запросы с бо-
42 Глава 1. Основные понятия лее низким приоритетом могут прерывать обработку прерывания с более вы- соким приоритетом. Дли этого необходимо не накладывать маски ни на один сигнал прерывания и не выключать систему прерываний. Следует особо отметить, что для правильной реализации последних двух дисци- плин нужно обеспечить полное маскирование системы прерываний при выпол- нении шагов 1-4 и 6-7. Это необходимо для того, чтобы не потерять запрос и правильно его обслужить. Многоуровневое прерывание должно происходить на этапе собственно обработки прерывания, а не на этапе перехода с одного процес- са на другой. Управление ходом выполнения задач со стороны ОС заключается в организации реакций на прерывания, в организации обмена информацией (данными и про- граммами), предоставлении необходимых ресурсов, в динамике выполнения за- дачи и в организации сервиса. Причины прерываний определяет ОС (модуль, который называют супервизором прерываний), она же и выполняет действия, необходимые при данном прерывании и в данной ситуации. Поэтому в состав любой ОС реального времени прежде всего входят программы управления сис- темой прерываний, контроля состояний задач и событий, синхронизации задач, средства распределения памяти и управления ею, а уже потом средства органи- зации данных (с помощью файловых систем и т. д.). Следует, однако, заметить, что современная ОС реального времени должна вносить в аппаратно-программ- ный комплекс нечто большее, нежели просто обеспечение быстрой реакции на прерывания. Как мы уже знаем, при появлении запроса на прерывание система прерываний идентифицирует сигнал и, если прерывания разрешены, управление передается на соответствующую подпрограмму обработки. Из рис. 1.4 видно, что в подпро- грамме обработки прерывания имеются две служебные секции. Это — первая секция, в которой осуществляется сохранение контекста прерванной задачи, ко- торый не смог быть сохранен на 2-м шаге, и последняя, заключительная секция, в которой, наоборот, осуществляется восстановление контекста. Для того чтобы система прерываний не среагировала повторно на сигнал запроса на прерывание, она обычно автоматически «закрывает» (отключает) прерывания, поэтому необ- ходимо потом в подпрограмме обработки прерываний вновь включать систему прерываний. Установка рассмотренных режимов обработки прерываний (с отно- сительными и абсолютными приоритетами, и по правилу LCFS) осуществляется в конце первой секции подпрограммы обработки. Таким образом, на время вы- полнения центральной секции (в случае работы в режимах с абсолютными при- оритетами и по дисциплине LCFS) прерывания разрешены. На время работы заключительной секции подпрограммы обработки система прерываний должна быть отключена и после восстановления контекста вновь включена. Поскольку эти действия необходимо выполнять практически в каждой подпрограмме обра- ботки прерываний, во многих операционных системах первые секции подпро- грамм обработки прерываний выделяются в специальный системный программ- ный модуль, называемый супервизором прерываний. Супервизор прерываний прежде всего сохраняет в дескрипторе текущей задачи рабочие регистры процессора, определяющие контекст прерываемого вычисли- тельного процесса. Далее он определяет ту подпрограмму, которая должна вы-
Прерывания 43 полнить действия, связанные с обслуживанием настоящего (текущего) запроса на прерывание. Наконец, перед тем как передать управление этой подпрограмме, супервизор прерываний устанавливает необходимый режим обработки прерыва- ния. После выполнения подпрограммы обработки прерывания управление вновь передается супервизору, на этот раз уже на тот модуль, который занимается дис- петчеризацией задач (см. раздел «Качество диспетчеризации и гарантии обслу- живания», глава 2). И уже диспетчер задач, в свою очередь, в соответствии с принятым режимом распределения процессорного времени (между выполняю- щимися процессами) восстановит контекст той задачи, которой будет решено выделить процессор. Рассмотренная нами схема проиллюстрирована на рис. 1.6. Рис. 1.6. Обработка прерывания при участии супервизоров ОС Как мы видим из рис. 1.6, здесь нет непосредственного возврата в прерванную ранее программу непосредственно из самой подпрограммы обработки прерыва- ния. Для прямого непосредственного возврата достаточно адрес возврата сохра- нить в стеке, что и делает аппаратура процессора. При этом стек легко обеспе- чивает возможность возврата в случае вложенных прерываний, поскольку он всегда реализует дисциплину LCFS (last come — first served). Однако если бы контекст процессов сохранялся просто в стеке, как это обычно реализуется аппаратурой, а не в описанных выше дескрипторах задач, то у нас не было бы возможности гибко подходить к выбору той задачи, которой нужно пе- редать процессор после завершения работы подпрограммы обработки прерыва- ния. Естественно, что это только общий принцип. В конкретных процессорах и в конкретных ОС могут существовать некоторые отступления от рассмотренной схемы и/или дополнения к ней. Например, в современных процессорах часто имеются специальные аппаратные возможности для сохранения контекста пре-
44 Глава 1. Основные понятия рываемого процесса непосредственно в его дескрипторе, то есть дескриптор про- цесса (по крайней мере его часть) становится структурой данных, которую под- держивает аппаратура. Для полного понимания принципов создания и механизмов реализации рассмат- риваемых далее современных ОС необходимо знать архитектуру персональных компьютеров и, в частности, особенности системы прерывания. Этот вопрос бо- лее подробно рассмотрен в главе 4 «Управление вводом/выводом и файловые системы», посвященном архитектуре микропроцессоров i80x86. Основные виды ресурсов Рассмотрим кратко основные виды ресурсов вычислительной системы и спосо- бы их разделения (см. рис. 1.1). Прежде всего, одним из важнейших ресурсов является сам процессор1, точнее — процессорное время. Процессорное время делится попеременно (параллельно). Имеется множество методов разделения этого ресурса (см. раздел «Планирование и диспетчеризация процессов и задач», глава 2). Вторым видом ресурсов вычислительной системы можно считать память. Опе- ративная память может быть разделена и одновременным способом (то есть в памяти одновременно может располагаться несколько процессов или, по край- ней мере, текущие фрагменты, участвующие в вычислениях), и попеременно (в разные моменты оперативная память может предоставляться для разных вы- числительных процессов). Память — очень интересный вид ресурса. Дело в том, что в каждый конкретный момент времени процессор при выполнении вычисле- ний обращается к очень ограниченному числу ячеек оперативной памяти. С этой точки зрения желательно память разделять для возможно большего числа парал- лельно исполняемых процессов. С другой стороны, как правило, чем больше оперативной памяти может быть выделено для конкретного текущего процесса, тем лучше будут условия для его выполнения. Поэтому проблема эффективного разделения оперативной памяти между параллельно выполняемыми вычисли- тельными процессами является одной из самых актуальных. Достаточно подробно вопросы распределения памяти между параллельно выполняющимися процессами рассмотрены в главе 2 «Управление задачами и памятью в операционных системах». Когда говорят о внешней памяти (например, память на магнитных дисках), то собственно память и доступ2 к ней считаются разными видами ресурса. Каждый из этих ресурсов может предоставляться независимо от другого. Но для полной работы с внешней памятью необходимо иметь оба этих ресурса. Собственно внеш- няя память может разделяться одновременно, а доступ к ней — попеременно. Если говорить о внешних устройствах, то они, как правило, могут разделяться параллельно, если используются механизмы прямого доступа. Если же устройст- во работает с последовательным доступом, то оно не может считаться разделяе- 1 Разговор о процессоре как об одном из ресурсов более характерен для мультипроцессор- ных систем. В случае однопроцессорных систем чаще говорят о процессорном времени. 2 Процесс обращения к данным.
Основные виды ресурсов 45 мым ресурсом. Простыми и наглядными примерами внешних устройств, кото- рые не могут быть разделяемыми, являются принтер и накопитель на магнитной ленте. Действительно, если допустить, что принтер можно разделять между дву- мя процессами, которые смогут его использовать попеременно, то результаты пе- чати, скорее всего, не смогут быть использованы — фрагменты выведенного тек- ста могут перемешаться таким образом, что в них невозможно будет разобраться. Аналогично обстоит дело и с накопителем на магнитной ленте. Если один про- цесс начнет что-то читать или писать, а второй при этом запросит перемотку лен- ты на ее начало, то оба вычислительных процесса не смогут выполнить свои вы- числения. Очень важным видом ресурсов являются программные модули. Прежде всего, мы будем рассматривать системные программные модули, поскольку именно они обычно и рассматриваются как программные ресурсы и в принципе могут быть распределены между выполняющимися процессами. Как известно, программные модули могут быть однократно и многократно (или повторно) используемыми. Однократно используемыми называют такие про- граммные модули, которые могут быть правильно выполнены только один раз. Это означает, что в процессе своего выполнения они могут испортить себя: либо повреждается часть кода, либо — исходные данные, от которых зависит ход вы- числений. Очевидно, что однократно используемые программные модули явля- ются неделимым ресурсом. Более того, их обычно вообще не распределяют как ресурс системы. Системные однократно используемые программные модули, как правило, используются только на этапе загрузки ОС. При этом следует иметь в виду тот очевидный факт, что собственно двоичные файлы, которые обычно хранятся на системном диске и в которых и записаны эти модули, не портятся, а потому могут бытЁ повторно использованы при следующем запуске ОС. Повторно используемые программные модули, в свою очередь, могут быть не- привилегированными, привилегированными и реентерабельными. Привилегированные программные модули работают в так называемом привилеги- рованном режиме, то есть при отключенной системе прерываний (часто говорят, что прерывания закрыты), так, что никакие внешние события не могут нарушить естественный порядок вычислений. В результате программный модуль выполня- ется до своего конца, после чего он может быть вновь вызван на исполнение из другой задачи (другого вычислительного процесса). С позиций стороннего на- блюдателя по отношению к вычислительным процессам, которые попеременно (причем, возможно, неоднократно) в течение срока своей «жизни» вызывают не- который привилегированный программный модуль, такой модуль будет высту- пать как попеременно разделяемый ресурс. Структура привилегированных про- граммных модулей изображена на рис. 1.7. Здесь в первой секции программного модуля выключается система прерываний. В последней секции, напротив, вклю- чается система прерываний. Непривилегированные программные модули — это обычные программные модули, которые могут быть прерваны во время своей работы. Следовательно, в общем случае их нельзя считать разделяемыми, потому что если после прерывания вы- полнения такого модуля, исполняемого в рамках одного вычислительного про- цесса, запустить его еще раз по требованию другого вычислительного процесса,
46 Глава 1. Основные понятия то промежуточные результаты для прерванных вычислений могут быть потеря- ны. В противоположность этому, реентерабельные программные модули (reente- rable1) допускают повторное многократное прерывание своего исполнения и по- вторный их запуск по обращению из других задач (вычислительных процессов). Для этого реентерабельные программные модули должны быть созданы таким образом, чтобы было обеспечено сохранение промежуточных вычислений для прерываемых вычислений и возврат к ним, когда вычислительный процесс во- зобновляется с прерванной ранее точки. Это может быть реализовано двумя спо- собами: с помощью статических и динамических методов выделения памяти под сохраняемые значения. Основной, наиболее часто используемый динамический — способ выделения памяти для сохранения всех промежуточных результатов вы- числения, относящихся к реентерабельному программному модулю, может быть проиллюстрирован с помощью рис. 1.8. Отключение прерываний Собственно тело программного модуля Включение прерываний Рис. 1.7. Структура привилегированного программного модуля Привилегированный модуль, заказывающий в системной области памяти блок ячеек для хранения текущих (промежуточных) данных Основное тело реентерабельного программного модуля, которое и может быть прервано. Работает в непривилегированном режиме Привилегированный модуль, освобождающий в системной области памяти блок памяти, использованный для хранения промежуточных данных Системная область памяти, используемая динамическим образом для буферированного ввода/вывода и реентерабельной обработки Блок ячеек памяти для текущих переменных Вершина стека Стек Рис. 1.8. Реентерабельный программный модуль 1 Reenterable — (дословно) допускающий повторные обращения (к модулю).
Основные виды ресурсов 47 Основная идея построения и работы реентерабельного программного модуля, структура которого представлена на рис. 1.8, заключается в том, что в первой (головной) своей части с помощью обращения из системной привилегированной секции осуществляется запрос на получение в системной области памяти блока ячеек, необходимого для размещения всех текущих (промежуточных) данных. При этом на вершину стека помещается указатель на начало области данных и ее объем. Все текущие переменные реентерабельного программного модуля в этом случае располагаются в системной области памяти. Поскольку в конце привиле- гированной секции система прерываний включается, то во время работы цен- тральной (основной) части реентерабельного модуля возможно ее прерывание. Если прерывание не возникает, то в третьей (заключительной) секции осуществ- ляется запрос на освобождение использованного блока системной области памя- ти. Если же во время работы центральной секции возникает прерывание и дру- гой вычислительный процесс обращается к тому же самому реентерабельному программному модулю, то для этого нового процесса вновь заказывается новый блок памяти в системной области памяти и на вершину стека записывается новый указатель. Очевидно, что возможно многократное повторное вхождение в реентерабельный программный модуль до тех пор, пока в области системной памяти, выделяемой специально для реентерабельной обработки, есть свободные ячейки, число которых достаточно для выделения нового блока. Что касается статического способа выделения памяти, то здесь речь может идти, например, о том, что заранее для фиксированного числа вычислительных про- цессов резервируются области памяти, в которых будут располагаться перемен- ные реентерабельных программных модулей: для каждого процесса — своя область памяти. Чаще всего в качестве таких процессов выступают процессы ввода/вы- вода и речь идет о реентерабельных драйверах (реентерабельный драйвер мо- жет управлять параллельно несколькими однотипными устройствами. См. более подробно в главе 4 «Управление вводом/выводом и файловые системы»). Кроме реентерабельных программных модулей существуют еще повторно входи- мые (от re-entrance). Этим термином называют программные модули, которые тоже допускают свое многократное параллельное использование, но в отличие от реентерабельных их нельзя прерывать. Повторно входимые программные мо- дули состоят из привилегированных секций и повторное обращение к ним воз- можно только после завершения какой-нибудь из таких секций. После выпол- нения очередной привилегированной секции управление может быть передано супервизору, и если он предоставит возможность выполняться другому процес- су, то возможно повторное вхождение в рассматриваемый программный модуль. Другими словами, в повторно входимых программных модулях четко предопре- делены все допустимые (возможные) точки входа. Следует отметить, что по- вторно входимые программные модули встречаются гораздо чаще реентерабель- ных (повторно прерываемых). Наконец, имеются и информационные ресурсы, то есть в качестве ресурсов мо- гут выступать данные. Информационные ресурсы могут существовать как в виде переменных, находящихся в оперативной памяти, так и в виде файлов. Если про- цессы используют данные только для чтения, то такие информационные ресурсы можно разделять. Если же процессы могут изменять информационные ресурсы,
48 Глава 1. Основные понятия то необходимо специальным образом организовывать работу с такими данными. Это одна из наиболее сложных проблем, достаточно подробно она обсуждается в главе 6. Классификация операционных систем Широко известно высказывание, согласно которому любая наука начинается с классификации. Само собой, что вариантов классификации может быть очень много, все будет зависеть от выбранного признака, по которому один объект мы будем отличать от другого. Однако, что касается ОС, здесь уже давно сформиро- валось относительно небольшое количество классификаций: по назначению, по режиму обработки задач, по способу взаимодействия с системой и, наконец, по способам построения (архитектурным особенностям систем). Во введении мы уже дали «определение» операционной системы (ОС). Поэтому просто повторим, что основным предназначением ОС является организация эф- фективных и надежных вычислений, создание различных интерфейсов для взаи- модействия с этими вычислениями и с самой вычислительной системой. Прежде всего, различают ОС общего и специального назначения. ОС специаль- ного назначения, в свою очередь, подразделяются на следующие: для переноси- мых микрокомпьютеров и различных встроенных систем, организации и ведения без данных, решения задач реального времени и т. п. По режиму обработки задач различают ОС, обеспечивающие однопрограммный и мультипрограммный режимы. Под мультипрограммированием понимается спо- соб организации вычислений, когда на однопроцессорной вычислительной сис- теме создается видимость одновременного выполнения нескольких программ. Любая задержка в решении программы (например, для осуществления операций ввода/вывода данных) используется для выполнения других (таких же, либо ме- нее важных) программ. Иногда при этом говорят о мультизадачном режиме. При этом, вообще говоря, мультипрограммный и мультизадачный режимы — это не синонимы, хотя и близкие понятия. Основное принципиальное отличие в этих терминах заключается в том, что мультипрограммный режим обеспечивает па- раллельное выполнение нескольких приложений и при этом программисты, соз- дающие эти программы, не должны заботиться о механизмах организации их параллельной работы. Эти функции берет на себя сама ОС; именно она распре- деляет между выполняющимися приложениями ресурсы вычислительной систе- мы, осуществляет необходимую синхронизацию вычислений и взаимодействие. Мультизадачный режим, наоборот, предполагает, что забота о параллельном вы- полнении и взаимодействии приложений ложится как раз на прикладных про- граммистов. В современной технической и, тем более, научно-популярной лите- ратуре об этом различии часто забывают, тем самым внося некоторую путаницу. Можно, однако, заметить, что современные ОС для ПК реализуют и мультипро- граммный, и мультизадачный режимы. При организации работы с вычислительной системой в диалоговом режиме мож- но говорить об однопользовательских (однотерминальных) и мультитерминаль-
Контрольные вопросы и задачи 49 ных ОС. В мультитерминальных ОС с одной вычислительной системой одно- временно могут работать несколько пользователей, каждый со своего терминала. При этом у пользователей возникает иллюзия, что у каждого из них имеется своя собственная вычислительная система. Очевидно, что для организации мульти- терминального доступа к вычислительной системе необходимо обеспечить муль- типрограммный режим работы. В качестве одного из примеров мультитерминаль- ных ОС для ПК можно назвать Linux. Основной особенностью операционных систем реального времени (ОСРВ) явля- ется обеспечение обработки поступающих заданий в течение заданных интерва- лов времени, которые нельзя превышать. Поток заданий в общем случае не явля- ется планомерным и не может регулироваться оператором (характер следования событий можно предсказать лишь в редких случаях), то есть задания поступают в непредсказуемые моменты времени и без всякой очередности. В ОС, не пред- назначенных для решения задач реального времени, имеются некоторые наклад- ные расходы процессорного времени на этапе инициирования (при выполнении которого ОС распознает все пожелания пользователей относительно решения своей задачи, загружает в оперативную память нужную программу и выделяет другие необходимые для ее выполнения ресурсы). В ОСРВ подобные затраты могут от- сутствовать, так как набор задач обычно фиксирован и вся информация о зада- чах известна еще до поступления запросов. Для подлинной реализации режима реального времени необходима (хотя этого и недостаточно) организация муль- типрограммирования. Мультипрограммирование является основным средством повышения производительности вычислительной системы, а для решения за- дач реального времени производительность становится важнейшим фактором. Лучшие характеристики по производительности для систем реального времени обеспечиваются однотерминальными ОСРВ. Средства организации мультитер- минального режима всегда замедляют работу системы в целом, но расширяют функциональные возможности системы. Одной из наиболее известных ОСРВ для ПК является ОС QNX. По основному архитектурному принципу ОС разделяются на микроядерные и монолитные. В некоторой степени это разделение тоже условно, однако можно в качестве яркого примера микроядерной ОС привести ОСРВ QNX, тогда как в качестве монолитной можно назвать Windows 95/98 или ОС Linux. Ядро ОС Windows мы не можем изменить, нам не доступны его исходные коды и у нас нет программы для сборки (компиляции) этого ядра. А вот в случае с Linux мы можем сами собрать ядро, которое нам необходимо, включив в него те необходимые про- граммные модули и драйверы, которые мы считаем целесообразным включить именно в ядро (а не обращаться к ним из ядра). Контрольные вопросы и задачи Вопросы для проверки 1. Объясните, в чем заключается различие между такими понятиями, как про- цесс и задача.
50 Глава 1. Основные понятия 2. Изобразите диаграмму состояний процесса, поясните все возможные перехо- ды из одного состояния в другое. 3. Объясните значения следующих терминов: task (задача), process (процесс), thread (поток, нить). Как они между собой соотносятся? 4. Для чего каждая задача получает соответствующий дескриптор? Какие поля, как правило, содержатся в дескрипторе процесса (задачи)? Что такое «кон- текст задачи»? 5. Объясните понятие ресурса. Почему понятие ресурса является одним из фун- даментальных при рассмотрении ОС? Какие виды и типы ресурсов вы знаете? 6. Как вы считаете: сколько и каких списков дескрипторов задач может быть в системе? От чего должно зависеть это число? 7. * Перечислите дисциплины обслуживания прерываний; объясните, как можно реализовать каждую из этих дисциплин. 8. С какой целью в ОС вводится специальный системный модуль, иногда назы- ваемый супервизором прерываний? 9. В чем заключается различие между повторно-входимыми (re-entrance) и по- вторно-прерываемыми (re-enterable) программными модулями? Как они реа- лизуются? 10. Что такое привилегированный программный модуль? Почему нельзя создать мультипрограммную ОС, в которой бы не было привилегированных программ- ных модулей?
ГЛАВА 2 Управление задачами и памятью в операционных системах Итак, время центрального процессора и оперативная память являются основны- ми ресурсами в случае реализации мультипрограммных вычислений. Оперативная память — это важнейший ресурс любой вычислительной системы, поскольку без нее (как, впрочем, и без центрального процессора) невозможно выполнение ни одной программы. Мы уже отмечали, что память является разде- ляемым ресурсом. От выбранных механизмов распределения памяти между выполняющимися процессорами очень сильно зависит и эффективность исполь- зования ресурсов системы, и ее производительность, и возможности, которыми могут пользоваться программисты при создании своих программ. Способы рас- пределения времени центрального процессора тоже сильно влияют и на скорость выполнения отдельных вычислений, и на общую эффективность вычислитель- ной системы. Понятие процесса (задачи) нам уже известно. В данной главе мы не будем ста- раться разделять понятия процесс (process) и поток (thread), вместо этого ис- пользуя как бы обобщающий термин task (задача). В других разделах, если спе- циально это не оговаривается, под задачей или процессом следует понимать практически одно и то же. Сейчас же мы будем говорить о разделении ресурса центрального процессора, поэтому термин задача может включать в себя и поня- тие треда (потока). Итак, операционная система выполняет следующие основные функции, связан- ные с управлением задачами: □ создание и удаление задач; □ планирование процессов и диспетчеризация задач; □ синхронизация задач, обеспечение их средствами коммуникации.
52 Глава 2. Управление задачами и памятью в операционных системах Система управления задачами обеспечивает прохождение их через компьютер. В зависимости от состояния процесса ему должен быть предоставлен тот или иной ресурс. Например, новый процесс необходимо разместить в основной па- мяти — следовательно, ему необходимо выделить часть адресного пространства. Новый порожденный поток текущего процесса необходимо включить в общий список задач, конкурирующих между собой за ресурсы центрального процессора. Создание и удаление задач осуществляется по соответствующим запросам от поль- зователей или от самих задач. Задача может породить новую задачу. При этом между процессами появляются «родственные» отношения. Порождающая задача называется «предком», «родителем», а порожденная — «потомком», «сыном» или «дочерней задачей». «Предок» может приостановить или удалить свою дочер- нюю задачу, тогда как «потомок» не может управлять «предком». Основным подходом к организации того или иного метода управления процесса- ми, обеспечивающего эффективную загрузку ресурсов или выполнение каких- либо иных целей, является организация очередей процессов и ресурсов. Очевидно, что на распределение ресурсов влияют конкретные потребности тех за- дач, которые должны выполняться параллельно. Другими словами, можно столк- нуться с ситуациями, когда невозможно эффективно распределять ресурсы с тем, чтобы они не простаивали. Например, всем выполняющимся процессам требует- ся некоторое устройство с последовательным доступом. Но поскольку, как мы уже знаем, оно не может распределяться между параллельно выполняющимися процессами, то процессы вынуждены будут очень долго ждать своей очереди. Таким образом, недоступность одного ресурса может привести к тому, что дли- тельное время не будут использоваться и многие другие ресурсы. Если же мы возьмем набор таких процессов, которые не будут конкурировать между собой за неразделяемые ресурсы при параллельном выполнении, то, ско- рее всего, процессы смогут выполниться быстрее (из-за отсутствия дополнитель- ных ожиданий), да и имеющиеся в системе ресурсы будут использоваться более эффективно. Итак, возникает задача подбора такого множества процессов, что при выполнении они будут как можно реже конфликтовать из-за имеющихся в системе ресурсов. Такая задача называется планированием вычислительных про- цессов. Задача планирования процессов возникла очень давно — в первых пакетных ОС при планировании пакетов задач, которые должны были выполняться на компь- ютере и оптимально использовать его ресурсы. В настоящее время актуальность этой задачи не так велика. На первый план уже очень давно вышли задачи дина- мического (или краткосрочного) планирования, то есть текущего наиболее эф- фективного распределения ресурсов, возникающего практически при каждом со- бытии. Задачи динамического планирования стали называть диспетчеризацией1. 1 К сожалению, здесь наблюдается терминологическая несогласованность. Собственно мо- дули супервизора, отвечающие за диспетчеризацию задач, часто называют планировщи- ками (sheduler). Но фактически, говоря о тех же планировщиках памяти или о каких- нибудь других модулях, отвечающих за динамическое распределение ресурсов, имеют в виду, что эти планировщики осуществляют диспетчеризацию. Наконец, иногда диспет- черизацию называют краткосрочным планированием.
Планирование и диспетчеризация процессов и задач 53 Очевидно, что планирование осуществляется гораздо реже, чем задача текущего распределения ресурсов между уже выполняющимися процессами и потоками. Основное отличие между долгосрочным и краткосрочным планировщиками за- ключается в частоте запуска: краткосрочный планировщик, например, может за- пускаться каждые 30 или 100 мс, долгосрочный — один раз за несколько минут (или чаще; тут многое зависит от общей длительности решения заданий пользо- вателей). Долгосрочный планировщик решает, какой из процессов, находящихся во вход- ной очереди, должен быть переведен в очередь готовых процессов в случае осво- бождения ресурсов памяти. Он выбирает процессы из входной очереди с целью создания неоднородной мультипрограммной смеси. Это означает, что в очереди готовых к выполнению процессов должны находиться — в разной пропорции — как процессы, ориентированные на ввод/вывод, так и процессы, ориентирован- ные на преимущественную работу с центральным процессором. Краткосрочный планировщик решает, какая из задач, находящихся в очереди го- товых к выполнению, должна быть передана на исполнение. В большинстве со- временных операционных систем, с которыми мы сталкиваемся, долгосрочный планировщик отсутствует. Планирование и диспетчеризация процессов и задач Стратегии планирования Прежде всего следует отметить, что при рассмотрении стратегий планирования, как правило, идет речь о краткосрочном планировании, то есть о диспетчериза- ции. Долгосрочное планирование, как мы уже отметили, заключается в подборе таких вычислительных процессов, которые бы меньше всего конкурировали ме- жду собой за ресурсы вычислительной системы. Стратегия планирования определяет, какие процессы мы планируем на выпол- нение для того, чтобы достичь поставленной цели. Известно большое количество различных стратегий выбора процесса, которому необходимо предоставить про- цессор. Среди них, прежде всего, можно назвать следующие стратегии: □ по возможности заканчивать вычисления (вычислительные процессы) в том же самом порядке, в котором они были начаты; □ отдавать предпочтение более коротким процессам; □ предоставлять всем пользователям (процессам пользователей) одинаковые услуги, в том числе и одинаковое время ожидания. Когда говорят о стратегии обслуживания, всегда имеют в виду понятие процесса, а не понятие задачи, поскольку процесс, как мы уже знаем, может состоять из не- скольких потоков (задач).
54 Глава 2. Управление задачами и памятью в операционных системах Дисциплины диспетчеризации Когда говорят о диспетчеризации, то всегда в явном или неявном виде имеют в виду понятие задачи (потока). Если ОС не поддерживает механизм тредов, то можно заменять понятие задачи на понятие процесса. Так как эти термины часто используются именно в таком смысле, мы вынуждены будем использовать тер- мин «процесс» как синоним термина «задача». Известно большое количество правил (дисциплин диспетчеризации), в соответ- ствии с которыми формируется список (очередь) готовых к выполнению задач. Различают два больших класса дисциплин обслуживания — бесприоритетные и приоритетные. При бесприоритетном обслуживании выбор задачи произво- дится в некотором заранее установленном порядке без учета их относительной важности и времени обслуживания. При реализации приоритетных дисциплин обслуживания отдельным задачам предоставляется преимущественное право по- пасть в состояние исполнения. Перечень дисциплин обслуживания и их класси- фикация приведены на рис. 2.1. Рис. 2.1. Дисциплины диспетчеризации
Планирование и диспетчеризация процессов и задач 55 Запомним о приоритетах следующее: □ приоритет, присвоенный задаче, может являться величиной постоянной; □ приоритет задачи может изменяться в процессе ее решения. Диспетчеризация с динамическими приоритетами требует дополнительных рас- ходов на вычисление значений приоритетов исполняющихся задач, поэтому во многих ОС реального времени используются методы диспетчеризации на основе статических (постоянных) приоритетов. Хотя надо заметить, что динамические приоритеты позволяют реализовать гарантии обслуживания задач. Рассмотрим кратко некоторые основные (наиболее часто используемые) дисци- плины диспетчеризации. Самой простой в реализации является дисциплина FCFS (first come — first served), согласно которой задачи обслуживаются «в порядке очереди», то есть в порядке их появления. Те задачи, которые были заблокированы в процессе работы (попа- ли в какое-либо из состояний ожидания, например, из-за операций ввода/выво- да), после перехода в состояние готовности ставятся в эту очередь готовности перед теми задачами, которые еще не выполнялись. Другими словами, образу- ются две очереди (рис. 2.2): одна очередь образуется из новых задач, а вторая очередь — из ранее выполнявшихся, но попавших в состояние ожидание. Такой подход позволяет реализовать стратегию обслуживания «по возможности закан- чивать вычисления в порядке их появления». Эта дисциплина обслуживания не требует внешнего вмешательства в ход вычислений, при ней не происходит перераспределение процессорного времени. Существующие дисциплины дис- петчеризации процессов могут быть разбиты на два класса — вытесняющие (pre- emptive) и не вытесняющие (non-preemptive). В первых пакетных ОС часто реализовывали параллельное выполнение заданий без принудительного пере- распределения процессора между задачами. В большинстве современных ОС для мощных вычислительных систем, а также и в ОС для ПК, ориентированных на высокопроизводительное выполнение приложений (Windows NT, OS/2, Linux), реализована вытесняющая многозадачность. Можно сказать, что рассмотренная дисциплина относится к не вытесняющим. Рис. 2.2. Дисциплина диспетчеризации FCFS
56 Глава 2. Управление задачами и памятью в операционных системах К достоинствам этой дисциплины, прежде всего, можно отнести простоту реали- зации и малые расходы системных ресурсов на формирование очереди задач. Однако эта дисциплина приводит к тому, что при увеличении загрузки вычисли- тельной системы растет и среднее время ожидания обслуживания, причем ко- роткие задания (требующие небольших затрат машинного времени) вынуждены ожидать столько же, сколько и трудоемкие задания. Избежать этого недостатка позволяют дисциплины SJN и SRT. Дисциплина обслуживания SJN (shortest job next, что означает: следующим будет выполняться кратчайшее задание) требует, чтобы для каждого задания была из- вестна оценка в потребностях машинного времени. Необходимость сообщать ОС характеристики задач, в которых описывались бы потребности в ресурсах вычис- лительной системы, привела к тому, что были разработаны соответствующие языковые средства. В частности, язык JCL (job control language, язык управле- ния заданиями) был одним из наиболее известных. Пользователи вынуждены были указывать предполагаемое время выполнения, и для того, чтобы они не злоупотребляли возможностью указать заведомо меньшее время выполнения (с целью получить результаты раньше других), ввели подсчет реальных потреб- ностей. Диспетчер задач сравнивал заказанное время и время выполнения и в случае превышения указанной оценки в данном ресурсе ставил данное задание не в начало, а в конец очереди. Еще в некоторых ОС в таких случаях использова- лась система штрафов, при которой в-случае превышения заказанного машинно- го времени оплата вычислительных ресурсов осуществлялась уже по другим рас- ценкам. Дисциплина обслуживания SJN предполагает, что имеется только одна очередь заданий, готовых к выполнению. И задания, которые в процессе своего исполне- ния были временно заблокированы (например, ожидали завершения операций ввода/вывода), вновь попадают в конец очереди готовых к выполнению наравне с вновь поступающими. Это приводит к тому, что задания, которым требуется очень немного времени для своего завершения, вынуждены ожидать процессор наравне с длительными работами, что не всегда хорошо. Для устранения этого недостатка и была предложена дисциплина SRT (shortest remaining time, следующее задание требует меньше всего времени для своего за- вершения). Все эти три дисциплины обслуживания могут использоваться для пакетных ре- жимов обработки, когда пользователь не вынужден ожидать реакции системы, а просто сдает свое задание и через несколько часов получает свои результаты вычислений. Для интерактивных же вычислений желательно прежде всего обес- печить приемлемое время реакции системы и равенство в обслуживании, если система является мультитерминальной. Если же это однопользовательская сис- тема, но с возможностью мультипрограммной обработки, то желательно, чтобы те программы, с которыми мы сейчас непосредственно работаем, имели лучшее время реакции, нежели наши фоновые задания. При этом мы можем пожелать, чтобы некоторые приложения, выполняясь без нашего непосредственного уча- стия (например, программа получения электронной почты, использующая модем и коммутируемые линии для передачи данных), тем не менее гарантированно по- лучали необходимую им долю процессорного времени. Для решения подобных
Планирование и диспетчеризация процессов и задач 57 проблем используется дисциплина обслуживания, называемая RR (round robin, круговая, карусельная), и приоритетные методы обслуживания. Дисциплина обслуживания RR предполагает, что каждая задача получает процес- сорное время порциями (говорят: квантами времени1, q). После окончания кван- та времени q задача снимается с процессора и он передается следующей задаче. Снятая задача ставится в конец очереди задач, готовых к выполнению. Эта дис- циплина обслуживания иллюстрируется рис. 2.3. Для оптимальной работы сис- темы необходимо правильно выбрать закон, по которому кванты времени выде- ляются задачам. Очередь готовых к исполнению задач Новые задачи Рис. 2.3. Карусельная дисциплина диспетчеризации (round robin) Величина кванта времени q выбирается как компромисс между приемлемым временем реакции системы на запросы пользователей (с тем, чтобы их простей- шие запросы не вызывали длительного ожидания) и накладными расходами на частую смену контекста задач. Очевидно, что при прерываниях ОС вынуждена сохранить достаточно большой объем информации о текущем (прерываемом) процессе, поставить дескриптор снятой задачи в очередь, загрузить контекст за- дачи, которая теперь будет выполняться (ее дескриптор был первым в очереди готовых к исполнению). Если величина q велика, то при увеличении очереди го- товых к выполнению задач реакция системы станет плохой. Если же величина мала, то относительная доля накладных расходов на переключения между ис- полняющимися задачами станет большой и это ухудшит производительность системы. В некоторых ОС есть возможность указывать в явном виде величину q либо диапазон ее возможных значений, поскольку система будет стараться вы- бирать оптимальное значение сама. Например, в OS/2 в файле CONFIG.SYS есть возможность с помощью оператора TIMESLICE указать минимальное и максимальное значение для кванта q. Так, на- пример, строка TIMESLICE=32,256 указывает, что минимальное значение q равно 32 миллисекундам, а максимальное — 256. Если некоторая задача (тред) была пре- рвана, поскольку выделенный ей квант времени q израсходован, то следующий выделенный ему интервал будет увеличен на время, равное одному периоду тай- мера (около 32 мс), и так до тех пор, пока квант времени не станет равным мак- симальному значению, указанному в операторе TIMESLICE. Этот метод позволяет 1 Time slice — квант времени.
58 Глава 2. Управление задачами и памятью в операционных системах OS/2 уменьшить накладные расходы на переключение задач в том случае, если несколько задач параллельно выполняют длительные вычисления. Дисциплина диспетчеризации RR — это одна из самых распространенных дис- циплин. Однако бывают ситуации, когда ОС не поддерживает в явном виде дис- циплину карусельной диспетчеризации. Например, в некоторых ОС реального времени используется диспетчер задач, работающий по принципам абсолютных приоритетов (процессор предоставляется задаче с максимальным приоритетом, а при равенстве приоритетов он действует по принципу очередности) [21]. Дру- гими словами, снять задачу с выполнения может только появление задачи с бо- лее высоким приоритетом. Поэтому если нужно организовать обслуживание за- дач таким образом, чтобы все они получали процессорное время равномерно и равноправно, то системный оператор может сам организовать эту дисциплину. Для этого достаточно всем пользовательским задачам присвоить одинаковые приоритеты и создать одну высокоприоритетную задачу, которая не должна ни- чего делать, но которая, тем не менее, будет по таймеру (через указанные интер- валы времени) планироваться на выполнение. Эта задача снимет с выполнения текущее приложение, оно будет поставлено в конец очереди, и поскольку этой высокоприоритетной задаче на самом деле ничего делать не надо, то она тут же освободит процессор и из очереди готовности будет взята следующая задача. В своей простейшей реализации дисциплина карусельной диспетчеризации пред- полагает, что все задачи имеют одинаковый приоритет. Если же необходимо вве- сти механизм приоритетного обслуживания, то это, как правило, делается за счет организации нескольких очередей. Процессорное время будет предоставляться в первую очередь тем задачам, которые стоят в самой привилегированной очереди. Если она пустая, то диспетчер задач начнет просматривать остальные очереди. Именно по такому алгоритму действует диспетчер задач в операционных систе- мах OS/2 и Windows NT. Вытесняющие и не вытесняющие алгоритмы диспетчеризации Диспетчеризация без перераспределения процессорного времени, то есть не вы- тесняющая многозадачность (non-preemptive multitasking) — это такой способ диспетчеризации процессов, при котором активный процесс выполняется до тех пор, пока он сам, что называется «по собственной инициативе», не отдаст управ- ление диспетчеру задач для выбора из очереди другого, готового к выполнению процесса или треда. Дисциплины обслуживания FCFS, SJN, SRT относятся к не вытесняющим. Диспетчеризация с перераспределением процессорного времени между задача- ми, то есть вытесняющая многозадачность (preemptive multitasking) — это такой способ, при котором решение о переключении процессора с выполнения одного процесса на выполнение другого процесса принимается диспетчером задач, а не самой активной задачей. При вытесняющей многозадачности механизм диспет- черизации задач целиком сосредоточен в операционной системе, и программист может писать свое приложение, не заботясь о том, как оно будет выполняться параллельно с другими задачами. При этом операционная система выполняет следующие функции: определяет момент снятия с выполнения текущей задачи,
Планирование и диспетчеризация процессов и задач 59 сохраняет ее контекст в дескрипторе задачи, выбирает из очереди готовых задач следующую и запускает ее на выполнение, предварительно загрузив ее контекст. Дисциплина RR и многие другие, построенные на ее основе, относятся к вытес- няющим. При не вытесняющей многозадачности механизм распределения процессорного времени распределен между системой и прикладными программами. Приклад- ная программа, получив управление от операционной системы, сама определяет момент завершения своей очередной итерации и передает управление суперви- зору ОС с помощью соответствующего системного вызова. При этом естествен- но, что диспетчер задач, так же как и в случае вытесняющей мультизадачности, формирует очереди задач и выбирает в соответствии с некоторым алгоритмом (например, с учетом порядка поступления задач или их приоритетов) следую- щую задачу на выполнение. Такой механизм создает некоторые проблемы как для пользователей, так и для разработчиков. Для пользователей это означает, что управление системой может теряться на не- который произвольный период времени, который определяется процессом вы- полнения приложения (а не системой, старающейся всегда обеспечить приемле- мое время реакции на запросы пользователей) [54]. Если приложение тратит слишком много времени на выполнение какой-либо работы (например, на фор- матирование диска), пользователь не может переключиться с этой задачи на дру- гую задачу (например, на текстовый или графический редактор, в то время как форматирование продолжалось бы в фоновом режиме). Эта ситуация нежела- тельна, так как пользователи обычно не хотят долго ждать, когда машина завер- шит свою задачу. Поэтому разработчики приложений для не вытесняющей операционной среды, возлагая на себя функции диспетчера задач, должны создавать приложения так, чтобы они выполняли свои задачи небольшими частями. Так, упомянутая выше программа форматирования может отформатировать одну дорожку дискеты и вернуть управление системе. После выполнения других задач система возвратит управление программе форматирования, чтобы та отформатировала следующую дорожку. Подобной метод разделения времени между задачами работает, но он существенно затрудняет разработку программ и предъявляет повышенные тре- бования к квалификации программиста. Например, в ныне уже забытой операционной среде Windows 3.x нативные при- ложения этой системы разделяли между собой процессорное время именно та- ким образом. И программисты сами должны были обеспечивать «дружествен- ное» отношение своей программы к другим выполняемым одновременно с ней программам, достаточно часто отдавая управление ядру системы. Крайним про- явлением «не дружественности» приложения является его зависание, которое приводит к общему краху системы. В системах с вытесняющей многозадачно- стью такие ситуации, как правило, исключены, так как центральный механизм диспетчеризации, во-первых, обеспечивает все задачи процессорным временем, а во-вторых, дает возможность иметь надежные механизмы для мониторинга вы- числений и позволяет снять зависшую задачу с выполнения. Однако распределение функций диспетчеризации между системой и прило- жениями не всегда является недостатком, а при определенных условиях может
60 Глава 2. Управление задачами и памятью в операционных системах быть и преимуществом, потому что дает возможность разработчику приложений самому проектировать алгоритм распределения процессорного времени, наибо- лее подходящий для данного фиксированного набора задач [54]. Так как разра- ботчик сам определяет в программе момент времени отдачи управления, то при этом исключаются нерациональные прерывания программ в « неудобные» для них моменты времени. Кроме того, легко разрешаются проблемы совместного использования данных: задача во время каждой итерации использует их моно- польно и уверена, что на протяжении этого периода никто другой не изменит эти данные. Примером эффективного использования не вытесняющей многозадач- ности является сетевая операционная система Novell NetWare, в которой в зна- чительной степени благодаря этому достигнута высокая скорость выполнения файловых операций. Менее удачным оказалось использование не вытесняющей многозадачности в операционной среде Windows 3.x. Качество диспетчеризации и гарантии обслуживания Одна из проблем, которая возникает при выборе подходящей дисциплины об- служивания, — это гарантия обслуживания. Дело в том, что при некоторых дис- циплинах, например при использовании дисциплины абсолютных приоритетов, низкоприоритетные процессы оказываются обделенными многими ресурсами и, прежде всего, процессорным временем. Возникает реальная дискриминация низ- коприоритетных задач, и ряд таких процессов, имеющих к тому же большие по- требности в ресурсах, могут очень длительное время откладываться или, в конце концов, вообще могут быть не выполнены. Известны случаи, когда вследствие высокой загрузки вычислительной системы отдельные процессы так и не были выполнены, несмотря на то, что прошло несколько лет (!) с момента их планиро- вания. Поэтому вопрос гарантии обслуживания является очень актуальным. Более жестким требованием к системе, чем просто гарантированное завершение процесса, является его гарантированное завершение к указанному моменту вре- мени или за указанный интервал времени. Существуют различные дисциплины диспетчеризации, учитывающие жесткие временные ограничения, но не сущест- вует дисциплин, которые могли бы предоставить больше процессорного време- ни, чем может быть в принципе выделено. Планирование с учетом жестких временных ограничений легко реализовать, ор- ганизуя очередь готовых к выполнению процессов в порядке возрастания их вре- менных ограничений. Основным недостатком такого простого упорядочения яв- ляется то, что процесс (за счет других процессов) может быть обслужен быстрее, чем это ему реально необходимо. Для того чтобы избежать этого, проще всего процессорное время выделять все-таки квантами. Гарантировать обслуживание можно следующими тремя способами: □ выделять минимальную долю процессорного времени некоторому классу про- цессов, если по крайней мере один из них готов к исполнению. Например, можно отводить 20 % от каждых 10 мс процессам реального времени, 40 % от каждых 2 с — интерактивным процессам и 10 % от каждых 5 мин — пакетным (фоновым) процессам;
Планирование и диспетчеризация процессов и задач 61 □ выделять минимальную долю процессорного времени некоторому конкретно- му процессу, если он готов к выполнению; □ выделять столько процессорного времени некоторому процессу, чтобы он мог выполнить свои вычисления к сроку. Для сравнения алгоритмов диспетчеризации обычно используются следующие критерии: □ Использование (загрузка) центрального процессора (CPU utilization). В боль- шинстве персональных систем средняя загрузка процессора не превышает 2-3 %, доходя в моменты выполнения сложных вычислений и до 100 %. В ре- альных системах, где компьютеры выполняют очень много работы, например, в серверах, загрузка процессора колеблется в пределах 15-40 % для легко за- груженного процессора и до 90-100 % — для сильно загруженного процес- сора. □ Пропускная способность (CPU throughput). Пропускная способность процес- сора может измеряться количеством процессов, которые выполняются в еди- ницу времени. □ Время оборота (turnaround time). Для некоторых процессов важным критери- ем является полное время выполнения, то есть интервал от момента появле- ния процесса во входной очереди до момента его завершения. Это время названо временем оборота и включает время ожидания во входной очереди, время ожидания в очереди готовых процессов, время ожидания в очередях к оборудованию, время выполнения в процессоре и время ввода/вывода. □ Время ожидания (waiting time). Под временем ожидания понимается суммар- ное время нахождения процесса в очереди готовых процессов. □ Время отклика (response time). Для интерактивных программ важным пока- зателем является время отклика или время, прошедшее от момента попада- ния процесса во входную очередь до момента первого обращения к терми- налу. Очевидно, что простейшая стратегия краткосрочного планировщика должна быть направлена на максимизацию средних значений загруженности и пропускной способности, времени ожидания и времени отклика. Правильное планирование процессов сильно влияет на производительность всей системы. Можно выделить следующие главные причины, приводящие к умень- шению производительности системы: □ Накладные расходы на переключение процессора. Они определяются не толь- ко переключениями контекстов задач, но и (при переключении на процессы другого приложения) перемещениями страниц виртуальной памяти, а также необходимостью обновления данных в кэше (коды и данные одной задачи, находящиеся в кэше, не нужны другой задаче и будут заменены, что приводит к дополнительным задержкам). □ Переключение на другой процесс в тот момент, когда текущий процесс вы- полняет критическую секцию, а другие процессы активно ожидают входа в свою критическую секцию (см. главу 6). В этом случае потери будут особо ве- лики (хотя вероятность прерывания выполнения коротких критических сек- ций мала).
62 Глава 2. Управление задачами и памятью в операционных системах В случае использования мультипроцессорных систем применяются следующие методы повышения производительности системы: □ совместное планирование, при котором все потоки одного приложения (не- блокированные) одновременно выбираются для выполнения процессорами и одновременно снимаются с них (для сокращения переключений контекста); □ планирование, при котором находящиеся в критической секции задачи не прерываются, а активно ожидающие входа в критическую секцию задачи не выбираются до тех пор, пока вход в секцию не освободится; □ планирование с учетом так называемых «советов» программы (во время ее выполнения). Например, в известной своими новациями ОС Mach имелись два класса таких советов (hints) — указания (разной степени категоричности) о снятии текущего процесса с процессора, а также указания о том процессе, который должен быть выбран взамен текущего. Диспетчеризация задач с использованием динамических приоритетов При выполнении программ, реализующих какие-либо задачи контроля и управ- ления (что характерно, прежде всего, для систем реального времени), может случиться такая ситуация, когда одна или несколько задач не могут быть реали- зованы (решены) в течение длительного промежутка времени из-за возросшей нагрузки в вычислительной системе. Потери, связанные с невыполнением таких задач, могут оказаться больше, чем потери от невыполнения программ с более высоким приоритетом. При этом оказывается целесообразным временно изме- нить приоритет «аварийных» задач (для которых истекает отпущенное для них время обработки). После выполнения этих задач их приоритет восстанавливает- ся. Поэтому почти в любой ОС реального времени имеются средства для изме- нения приоритета программ1. Есть такие средства и во многих ОС, которые не относятся к классу ОСРВ. Введение механизмов динамического изменения при- оритетов позволяет реализовать более быструю реакцию системы на короткие запросы пользователей, что очень важно при интерактивной работе, но при этом гарантировать выполнение любых запросов. Рассмотрим, например, как реализован механизм динамических приоритетов в ОС UNIX, которая, как известно, не относится к ОСРВ. Приоритет процесса вы- числяется следующим образом [70]. Во-первых, в вычислении участвуют значе- ния двух полей дескриптора процесса — p_nice и р_сри. Первое из них назнача- ется пользователем явно или формируется по умолчанию с помощью системы программирования. Второе поле формируется диспетчером задач (планировщи- ком разделения времени) и называется системной составляющей или текущим приоритетом. Другими словами, каждый процесс имеет два атрибута приорите- та, с учетом которого и распределяется между исполняющимися задачами про- цессорное время: текущий приоритет, на основании которого происходит пла- нирование, и заказанный относительный приоритет, называемый nice number (или просто nice). 1 Dynamic priority variation.
Планирование и диспетчеризация процессов и задач 63 Схема нумерации (числовых значений) текущих приоритетов различна для раз- личных версий UNIX. Например, более высокому значению текущего приорите- та может соответствовать более низкий фактический приоритет планирования. Разделение между приоритетами режима ядра и задачи также зависит от версии. Рассмотрим частный случай, когда текущий приоритет процесса варьируется в диапазоне от 0 (низкий приоритет) до 127 (наивысший приоритет). Процессы, выполняющиеся в режиме задачи, имеют более низкий приоритет, чем в режи- ме ядра. Для режима задачи приоритет меняется в диапазоне 0-65, для режима ядра — 66-95 (системный диапазон). Процессы, приоритеты которых лежат в диапазоне 96-127, являются процессами с фиксированным приоритетом, не из- меняемым операционной системой, и предназначены для поддержки приложе- ний реального времени. Процессу, ожидающему недоступного в данный момент ресурса, система опреде- ляет значение приоритета сна, выбираемое ядром из диапазона системных при- оритетов и связанное с событием, вызвавшее это состояние. Когда процесс про- буждается, ядро устанавливает значение текущего приоритета процесса равным приоритету сна. Поскольку приоритет такого процесса находится в системном диапазоне и выше, чем приоритет режима задачи, вероятность предоставления процессу вычислительных ресурсов весьма велика. Такой подход позволяет, в частности, быстро завершить системный вызов, выполнение которого, в свою очередь, может блокировать некоторые системные ресурсы. После завершения системного вызова перед возвращением в режим задачи яд- ро восстанавливает приоритет режима задачи, сохраненный перед выполнением системного вызова. Это может привести к понижению приоритета, что, в свою очередь, вызовет переключение контекста. Текущий приоритет процесса в режиме задачи p_priuser, как мы только что от- метили, зависит от значения nice number и степени использования вычислитель- ных ресурсов р_сри: p_pr1user = a*p_nice - b*p_cpu Задача планировщика разделения времени — справедливо распределить вычис- лительный ресурс между конкурирующими процессами. Для принятия решения о выборе следующего запускаемого процесса планировщику необходима инфор- мация об использовании процессора. Эта составляющая приоритета уменьшает- ся обработчиком прерываний таймера по каждому «тику» таймера. Таким обра- зом, пока процесс выполняется в режиме задачи, его текущий приоритет линейно уменьшается. Каждую секунду ядро пересчитывает текущие приоритеты процессов, готовых к запуску (приоритеты которых меньше некоторого порогового значения, в нашем примере эта величина равна 65), последовательно увеличивая их. Это осуществ- ляется за счет того, что ядро последовательно уменьшает отрицательную компо- ненту времени использования процессора. Как результат, эти действия приводят к перемещению процессов в более приоритетные очереди и повышают вероят- ность их последующего запуска. Возможно использование следующей формулы: p_cpu = р_сри/2
64 Глава 2. Управление задачами и памятью в операционных системах Это правило проявляет недостаток нивелирования приоритетов при повыше- нии загрузки системы. Происходит это потому, что в таком случае каждый про- цесс получает незначительный объем вычислительных ресурсов и, следователь- но, имеет малую составляющую р_сри, которая еще более уменьшается благодаря формуле пересчета величины р_сри. В результате степень использования процес- сора перестает оказывать заметное влияние на приоритет, и низкоприоритетные процессы (то есть процессы с высоким значением nice number) практически «от- лучаются» от вычислительных ресурсов системы. В некоторых версиях ОС UNIX для пересчета значения р_сри используется дру- гая формула: p_cpu = p_cpu*(2*load)/(2*load+l) Здесь параметр load равен среднему числу процессов, находившихся в очереди на выполнение за последнюю секунду, и характеризует среднюю загрузку систе- мы за этот период времени. Такой алгоритм позволяет частично избавиться от недостатка планирования по формуле p_cpu = р_сри/2, поскольку при значитель- ной загрузке системы уменьшение р_сри при пересчете будет происходить мед- леннее. Описанные алгоритмы планирования позволяют учесть интересы низкоприори- тетных процессов, так как в результате длительного ожидания очереди на запуск приоритет таких процессов увеличивается, соответственно увеличивается и ве- роятность запуска. Эти алгоритмы также обеспечивают более вероятный выбор планировщиком интерактивных процессов по отношению к вычислительным (фоновым). Такие задачи, как командный интерпретатор или редактор, большую часть времени проводят в ожидании ввода, имея, таким образом, высокий при- оритет (приоритет сна). При наступлении ожидаемого события (например, поль- зователь осуществил ввод данных) им сразу же предоставляются вычислительные ресурсы. Фоновые процессы, потребляющие значительные ресурсы процессора, имеют высокую составляющую р_сри и, как следствие, менее высокий приоритет. Аналогичные механизмы имеют место и в таких ОС, как OS/2 или Windows NT. Правда, алгоритмы изменения приоритета задач в этих системах иные. Напри- мер, в Windows NT каждый поток (тред) имеет базовый уровень приоритета, ко- торый лежит в диапазоне от двух уровней ниже базового приоритета процесса, его породившего, до двух уровней выше этого приоритета, как показано на рис. 2.4. Базовый приоритет процесса определяет, сколь сильно могут различаться при- оритеты потоков процесса и как они соотносятся с приоритетами потоков дру- гих процессов. Поток наследует этот базовый приоритет и может изменять его так, чтобы он стал немного больше или немного меньше. В результате получает- ся приоритет планирования, с которым поток и начинает исполняться. В процес- се исполнения потока его приоритет может отклоняться от базового. На рис. 2.4 показан динамический приоритет потока, нижней границей кото- рого является базовый приоритет потока, а верхняя — зависит от вида работ, исполняемых потоком. Например, если поток обрабатывает пользовательский ввод, то диспетчер задач Windows NT поднимает его динамический приоритет; если же он выполняет вычисления, то диспетчер постепенно снижает его при- оритет до базового. Снижая приоритет одного процесса и поднимая приоритет
Планирование и диспетчеризация процессов и задач 65 другого, подсистемы могут управлять относительной приоритетностью потоков внутри процесса. Приоритет 15 14 13 12 11 10 9 8 7 6 - 5 4 3 2 1 Диапазон значений динамического приоритета потока Базовый приоритет процесса Базовый приоритет потока Рис. 2.4. Схема динамического изменения приоритетов в Windows NT Для определения порядка выполнения потоков диспетчер использует систему приоритетов, направляя на выполнение потоки с высоким приоритетом раньше потоков с низкими приоритетами. Система прекращает исполнение или вытес- няет (preempts) текущий поток, если становится готовой к выполнению другая задача (поток) с более высоким приоритетом. Существует группа очередей — по одной для каждого приоритета. Windows NT поддерживает 32 уровня приоритетов; потоки делятся на два класса: реального времени и переменного приоритета. Потоки реального времени, имеющие при- оритеты от 16 до 31 — это высокоприоритетные потоки, используемыми про- граммами с критическим временем выполнения, то есть требующие немедленно- го внимания системы (по терминологии Microsoft). Диспетчер задач просматривает очереди, начиная с самой приоритетной. При этом если очередь пустая, то есть нет готовых к выполнению задач с таким при- оритетом, осуществляется переход к следующей очереди. Следовательно, если есть задачи, требующие процессор немедленно, они будут обслужены в первую очередь. Для собственно системных модулей, функционирующих в статусе за- дач, зарезервирована очередь с номером 0. Большинство потоков в системе относятся к классу переменного приоритета с уровнями приоритета (номером очереди) от 1 до 15. Эти очереди используются потоками с переменным приоритетом (variable priority), так как диспетчер задач корректирует их приоритеты по мере выполнения задач для оптимизации откли- ка системы. Диспетчер приостанавливает исполнение текущего потока после того, как тот израсходует свой квант времени. При этом если прерванный тред — это поток переменного приоритета, то диспетчер задач понижает его приоритет на единицу и перемещает в другую очередь. Таким образом, приоритет потока, вы- полняющего много вычислений, постепенно понижается (до значения его базо- вого приоритета). С другой стороны, диспетчер повышает приоритет потока по- сле освобождения задачи (потока) из состояния ожидания. Обычно добавка к
66 Глава 2. Управление задачами и памятью в операционных системах приоритету потока определяется кодом исполнительной системы, находящимся вне ядра ОС, однако величина этой добавки зависит от типа события, которого ожидал заблокированный тред. Так, например, поток, ожидавший ввода очеред- ного байта с клавиатуры, получает большую добавку к значению своего приори- тета, чем процесс ввода/вывода, работавший с дисковым накопителем. Однако в любом случае значение приоритета не может достигнуть 16. В операционной системе OS/2 схема динамической приоритетной диспетчериза- ции несколько иная, хотя и похожа на рассмотренную1. В OS/2 имеются четыре класса задач. И для каждого класса задач имеется своя группа приоритетов с ин- тервалом значений от 0 до 31. Итого, 128 различных уровней и, соответственно, 128 возможных очередей готовых к выполнению задач (тредов, потоков). Класс задач, имеющих самые высокие значения приоритета, называется крити- ческим (time critical). Этот класс предназначается для задач, которые мы в оби- ходе называем задачами реального времени, то есть для них должен быть обяза- тельно предоставлен определенный минимум процессорного времени. Наиболее часто встречающимися задачами, которые относят к этому классу, являются за- дачи коммуникаций (например, задача управления последовательным портом, принимающим биты с коммутируемой линии, к которой подключен модем, или задачи управления сетевым оборудованием). Если такие задачи не получат уп- равление в нужный момент времени, то сеанс связи может прерваться. Следующий класс задач имеет название приоритетного. Поскольку к этому клас- су относят задачи, которые выполняют по отношению к остальным задачам роль сервера (о модели клиент—сервер, по которой строятся современные ОС с мик- роядерной архитектурой, см. в разделе «Микроядерные операционные системы», глава 5), то его еще иногда называют серверным. Приоритет таких задач должен быть выше, это будет гарантировать, что запрос на некоторую функцию со сторо- ны обычных задач выполнится сразу, а не будет дожидаться, пока до него дойдет очередь на фоне других пользовательских приложений. Большинство задач относят к обычному классу, его еще называют регулярным или стандартным2. По умолчанию система программирования порождает задачу, относящуюся именно к этому классу. Наконец, существует еще класс фоновых задач, называемый в OS/2 остаточным. Программы этого класса получают про- цессорное время только тогда, когда нет задач из других классов, которым сейчас нужен процессор. В качестве примера такой задачи можно привести программу проверки электронной почты. Внутри каждого из вышеописанных классов задачи, имеющие одинаковый уро- вень приоритета, выполняются в соответствии с дисциплиной round-robin. Пере- ход от одного треда к другому происходит либо по окончании отпущенного ему кванта времени, либо по системному прерыванию, передающему управление за- даче с более высоким приоритетом (таким образом, система вытесняет задачи с 1 Как известно, одно время компания Microsoft принимала активное участие в разработке OS/2 совместно с IBM. Поэтому после прекращения совместных работ над этой опера- ционной системой и начале нового проекта многие решения из OS/2 были унаследованы в варианте OS/2 ver. 3.0, названной впоследствии Windows NT. 2 Regular.
Память и отображения, виртуальное адресное пространство 67 более низким приоритетом для выполнения задач с более высоким приоритетом и может обеспечить быструю реакцию на важные события). OS/2 самостоятельно изменяет приоритет выполняющихся программ независи- мо от уровня, установленного самим приложением. Этот механизм называется повышением приоритета1. Операционная система изменяет приоритет задачи в следующих трех случаях [96]: □ Увеличение приоритета активной задачи (foreground boost). Приоритет зада- чи автоматически увеличивается, когда она становится активной. Это снижа- ет время реакции активного приложения на действия пользователя по срав- нению с фоновыми программами. □ Увеличение приоритета ввода/вывода (input/output boost). По завершении операции ввода/вывода задача получает самый высокий уровень приоритета ее класса. Таким образом обеспечивается завершение всех незаконченных опе- раций ввода/вывода. □ Увеличение приоритета «забытых» задач (starvation boost). Если задача не получает управление в течение достаточно долгого времени (этот промежу- ток времени задает оператор MAXWAIT в файле CONFIG.SYS2), диспетчер задач OS/2 временно присваивает ей уровень приоритета, не превышающий крити- ческий. В результате переключение на такую «забытую» программу происхо- дит быстрее. После выполнения приложения в течение одного кванта времени его приоритет вновь снижается до остаточного. В сильно загруженных систе- мах этот механизм позволяет программам с остаточным приоритетом рабо- тать хотя бы в краткие интервалы времени. В противном случае они вообще никогда бы не получили управление. Если нам нет необходимости использовать метод динамического изменения при- оритета, то с помощью оператора PRIORITY = ABSOLUTE в файле CONFIG.SYS можно ввести дисциплину абсолютных приоритетов; по умолчанию оператор PRIORITY имеет значение DYNAMIC. Память и отображения, виртуальное адресное пространство Если не принимать во внимание программирование на машинном языке (эта технология практически не используется уже очень давно), то можно сказать, что программист обращается к памяти с помощью некоторого набора логических имен, которые чаще всего являются символьными, а не числовыми и для которо- го отсутствует отношение порядка. Другими словами, в общем случае множество переменных неупорядочено, хотя отдельные переменные и могут иметь частич- ную упорядоченность (например, элементы массива). Имена переменных и вход- ных точек программных модулей составляют пространство имен. 1 Priority boost. 2 Строка MAXWAIT=1 означает, что приоритет задачи при переключении на нее будет поднят до максимального не позже чем через одну секунду.
68 Глава 2. Управление задачами и памятью в операционных системах С другой стороны, существует понятие физической оперативной памяти, собст- венно с которой и работает процессор, извлекая из нее команды и данные и по- мещая в нее результаты вычислений. Физическая память представляет собой упорядоченное множество ячеек, и все они пронумерованы, то есть к каждой из них можно обратиться, указав ее порядковый номер (адрес). Количество ячеек физической памяти ограничено и фиксировано. Системное программное обеспечение должно связать каждое указанное пользо- вателем имя с физической ячейкой памяти, то есть осуществить отображение пространства имен на физическую память компьютера. В общем случае это ото- бражение осуществляется в два этапа (рис. 2.5): сначала системой программи- рования, а затем операционной системой (с помощью специальных программ- ных модулей управления памятью и использования соответствующих аппаратных средств вычислительной системы). Между этими этапами обращения к памяти имеют форму виртуального или логического адреса. При этом можно сказать, что множество всех допустимых значений виртуального адреса для некоторой программы определяет ее виртуальное адресное пространство или виртуальную память. Виртуальное адресное пространство программы прежде всего зависит от архитектуры процессора и от системы программирования и практически не за- висит от объема реальной физической памяти, установленной в компьютер. Мож- но еще сказать, что адреса команд и переменных в готовой машинной программе, подготовленной к выполнению системой программирования, как раз и являются виртуальными адресами. Рис. 2.5. Память и отображения
Память и отображения, виртуальное адресное пространство 69 Как мы знаем, система программирования осуществляет трансляцию и компо- новку программы, используя библиотечные программные модули (подробно об этом см. часть 2 настоящего учебника). В результате работы системы програм- мирования полученные виртуальные адреса могут иметь как двоичную форму, так и символьно-двоичную, то есть некоторые программные модули (их, как правило, большинство) и их переменные получают какие-то числовые значения, а те модули, адреса для которых не могут быть сейчас определены, имеют по- прежнему символьную форму и окончательная привязка их к физическим ячей- кам будет осуществлена на этапе загрузки программы в память перед ее непо- средственным выполнением. Одним из частных случаев отображения пространства имен на физическую па- мять является тождественность виртуального адресного пространства физической памяти. При этом нет необходимости осуществлять второе отображение. В дан- ном случае говорят, что система программирования генерирует абсолютную дво- ичную программу] в этой программе все двоичные адреса таковы, что программа может исполняться только в том случае, если ее виртуальные адреса будут точно соответствовать физическим. Часть программных модулей любой операционной системы обязательно должна быть абсолютными двоичными программами. Эти программы размещаются по фиксированным адресам и с их помощью уже мож- но впоследствии реализовывать размещение остальных программ, подготовлен- ных системой программирования таким образом, что они могут работать на раз- личных физических адресах (то есть на тех адресах, на которые их разместит операционная система). Другим частным случаем этой общей схемы трансляции адресного пространства является тождественность виртуального адресного пространства исходному про- странству имен. Здесь уже отображение выполняется самой ОС, которая во вре- мя исполнения использует таблицу символьных имен. Такая схема отображения используется чрезвычайно редко, так как отображение имен на адреса необходи- мо выполнять для каждого вхождения имени (каждого нового имени) и особен- но много времени тратится на квалификацию имен. Данную схему можно было встретить в интерпретаторах, в которых стадии трансляции и исполнения прак- тически неразличимы. Это характерно для простейших компьютерных систем, в которых вместо операционной системы использовался встроенный интерпре- татор (например, Basic). Возможны и промежуточные варианты. В простейшем случае транслятор-ком- пилятор генерирует относительные адреса, которые, по сути, являются виртуаль- ными адресами с последующей настройкой программы на один из непрерывных разделов. Второе отображение осуществляется перемещающим загрузчиком. По- сле загрузки программы виртуальный адрес теряется, и доступ выполняется не- посредственно к физическим ячейкам. Более эффективное решение достигается в том случае, когда транслятор вырабатывает в качестве виртуального адреса от- носительный адрес и информацию о начальном адресе, а процессор, используя подготавливаемую операционной системой адресную информацию, выполняет второе отображение не один раз (при загрузке программы), а при каждом обра- щении к памяти. Термин виртуальная память фактически относится к системам, которые сохра- няют виртуальные адреса во время исполнения. Так как второе отображение
70 Глава 2. Управление задачами и памятью в операционных системах осуществляется в процессе исполнения задачи, то адреса физических ячеек мо- гут изменяться. При правильном применении такие изменения могут улучшить использование памяти, избавив программиста от деталей управления ею, и даже увеличить надежность вычислений. Если рассматривать общую схему двухэтапного отображения адресов, то с пози- ции соотношения объемов упомянутых адресных пространств можно отметить наличие следующих трех ситуаций: □ объем виртуального адресного пространства программы Vv меньше объема физической памяти Vp; □ Vv = Vp; □ Vv > Vp. Первая ситуация, при которой Vv < Vp, ныне практически не встречается, но тем не менее это реальное соотношение. Скажем, не так давно 16-разрядные мини- ЭВМ имели систему команд, в которых пользователи-программисты могли ад- ресовать до 216=64К адресов (обычно в качестве адресуемой единицы выступала ячейка памяти размером с байт). А физически старшие модели этих мини-ЭВМ могли иметь объем оперативной памяти в несколько мегабайт. Обращение к памя- ти столь большого объема осуществлялось с помощью специальных регистров, содержимое которых складывалось с адресом операнда (или команды), извле- каемым и/или определяемым из поля операнда (или из указателя команды). Со- ответствующие значения в эти специальные регистры, выступающие как базовое смещение в памяти, заносила операционная система. Для одной задачи в регистр заносилось одно значение, а для второй (третьей, четвертой и т. д.) задачи, раз- мещаемой одновременно с первой, но в другой области памяти, заносилось, соот- ветственно, другое значение. Вся физическая память, таким образом, разбива- лась на разделы объемом по 64 Кбайт, и на каждый такой раздел осуществлялось отображение своего виртуального адресного пространства. Ситуация, когда Vv = VPt встречается очень часто, особенно характерна она была для недорогих вычислительных комплексов. Для этого случая имеется большое количество методов распределения оперативной памяти. Наконец, в наше время мы уже достигли того, что ситуация Vv > Vp встречается даже в ПК, то есть в самых распространенных и недорогих компьютерах. Теперь это самая распространенная ситуация и для нее имеется несколько методов рас- пределения памяти, отличающихся как сложностью, так и эффективностью. Простое непрерывное распределение и распределение с перекрытием (оверлейные структуры) Простое непрерывное распределение — это самая простая схема, согласно кото- рой вся память условно может быть разделена на три части:
Простое непрерывное распределение и распределение с перекрытием 71 □ область, занимаемая операционной системой; □ область, в которой размещается исполняемая задача; □ незанятая ничем (свободная) область памяти. Изначально являясь самой первой схемой, она продолжает и сегодня быть до- статочно распространенной. Эта схема предполагает, что ОС не поддерживает мультипрограммирование, поэтому не возникает проблемы распределения памя- ти между несколькими задачами. Программные модули, необходимые для всех программ, располагаются в области самой ОС, а вся оставшаяся память может быть предоставлена задаче. Эта область памяти при этом получается непрерыв- ной, что облегчает работу системы программирования. Поскольку в различных однотипных вычислительных комплексах может быть разный состав внешних устройств (и, соответственно, они содержат различное количество драйверов), для системных нужд могут быть отведены отличающиеся объемы оперативной памяти, и получается, что можно не привязывать жестко виртуальные адреса программы к физическому адресному пространству. Эта привязка осуществля- ется на этапе загрузки задачи в память. Чтобы для задач отвести как можно больший объем памяти, операционная сис- тема строится таким образом, что постоянно в оперативной памяти располагает- ся только самая нужная ее часть. Эту часть ОС стали называть ядром. Осталь- ные модули ОС могут быть обычными диск-резидентными (или транзитными), то есть загружаться в оперативную память только по необходимости, и после своего выполнения вновь освобождать память. Такая схема распределения влечет за собой два вида потерь вычислительных ре- сурсов — потеря процессорного времени, потому что процессор простаивает, пока задача ожидает завершения операций ввода/вывода, и потеря самой опера- тивной памяти, потому что далеко не каждая программа использует всю память, а режим работы в этом случае однопрограммный. Однако это очень недорогая реализация и можно отказаться от многих функций операционной системы. В част- ности, такая сложная проблема, как защита памяти, здесь вообще не стоит. Мы не будем рассматривать все различные варианты простого непрерывного рас- пределения памяти, которых было очень и очень много, а ограничимся схемой распределения памяти для MS-DOS (см. раздел «Распределение оперативной памяти в MS-DOS», глава 2). Если есть необходимость создать программу, логическое (и виртуальное) адрес- ное пространство которой должно быть больше, чем свободная область памяти, или даже больше, чем весь возможный объем оперативной памяти, то исполь- зуется распределение с перекрытием (так называемые оверлейные структуры1). Этот метод распределения предполагает, что вся программа может быть разбита на части — сегменты. Каждая оверлейная программа имеет одну главную часть (main) и несколько сегментов (segment), причем в памяти машины одновремен- но могут находиться только ее главная часть и один или несколько не перекры- вающихся сегментов. 1 От overlay — перекрытие, расположение поверх чего-то.
72 Глава 2. Управление задачами и памятью в операционных системах Пока в оперативной памяти располагаются выполняющиеся сегменты, осталь- ные находятся во внешней памяти. После того как текущий (выполняющийся) сегмент завершит свое выполнение, возможны два варианта. Либо он сам (если данный сегмент не нужно сохранить во внешней памяти в его текущем состоя- нии) обращается к ОС с указанием, какой сегмент должен быть загружен в па- мять следующим. Либо он возвращает управление главному сегменту задачи (в модуль main), и уже тот обращается к ОС с указанием, какой сегмент сохра- нить (если это нужно), а какой сегмент загрузить в оперативную память, и вновь отдает управление одному из сегментов, располагающихся в памяти. Простей- шие схемы сегментирования предполагают, что в памяти в каждый конкретный момент времени может располагаться только один сегмент (вместе с модулем main). Более сложные схемы, используемые в больших вычислительных систе- мах, позволяют располагать по несколько сегментов. В некоторых вычислитель- ных комплексах могли существовать отдельно сегменты кода и сегменты дан- ных. Сегменты кода, как правило, не претерпевают изменений в процессе своего исполнения, поэтому при загрузке нового сегмента кода на место отработавшего последний можно не сохранять во внешней памяти, в отличие от сегментов дан- ных, которые сохранять необходимо. Первоначально программисты сами должны были включать в тексты своих про- грамм соответствующие обращения к ОС (их называют вызовами) и тщательно планировать, какие сегменты могут находиться в оперативной памяти одновре- менно, чтобы их адресные пространства не пересекались. Однако с некоторых пор эти вызовы система программирования стала подставлять в код программы сама, автоматически, если в том возникает необходимость. Так, в известной и по- пулярной системе программирования Turbo Pascal, начиная с третьей версии, программист просто указывал, что данный модуль является оверлейным. И при обращении к нему из основной программы модуль загружался в память и ему передавалось управление. Все адреса определялись системой программирования автоматически, обращения к DOS для загрузки оверлеев тоже генерировались системой Turbo Pascal. Распределение статическими и динамическими разделами Для организации мультипрограммного режима необходимо обеспечить одновре- менное расположение в оперативной памяти нескольких задач (целиком или их частями). Самая простая схема распределения памяти между несколькими зада- чами предполагает, что память, незанятая ядром ОС, может быть разбита на не- сколько непрерывных частей (зон, разделов1). Разделы характеризуются именем, типом, границами (как правило, указываются начало раздела и его длина). Разбиение памяти на несколько непрерывных разделов может быть фиксиро- ванным (статическим), либо динамическим (то есть процесс выделения нового раздела памяти происходит непосредственно при появлении новой задачи). 1 Partition, region — раздел.
Распределение статическими и динамическими разделами 73 Вначале мы кратко рассмотрим статическое распределение памяти на несколько разделов. Разделы с фиксированными границами Разбиение всего объема оперативной памяти на несколько разделов может осу- ществляться единовременно (то есть в процессе генерации варианта ОС, кото- рый потом и эксплуатируется) или по мере необходимости оператором системы. Однако и во втором случае при выполнении разбиения памяти на разделы вы- числительная система более ни для каких целей в этот момент не используется. Пример разбиения памяти на несколько разделов приведен на рис. 2.6. Ядро операционной системы Раздел № О Транзисторная область ОС Задача А Раздел № 1 Неиспользуемая область Задача В Раздел № 2 Неиспользуемая область Задача С Неиспользуемая область Раздел № 3 Рис. 2.6. Распределение памяти разделами с фиксированными границами В каждом разделе в каждый момент времени может располагаться по одной про- грамме (задаче). В этом случае по отношению к каждому разделу можно приме- нить все те методы создания программ, которые используются для однопро- граммных систем. Возможно использование оверлейных структур, что позволяет создавать большие сложные программы и в то же время поддерживать коэффи- циент мультипрограммирования1 (под коэффициентом мультипрограммирова- ния (ц) понимают количество параллельно выполняемых программ) на должном уровне. Первые мультипрограммные ОС строились по этой схеме. Использова- лась эта схема и много лет спустя при создании недорогих вычислительных сис- 1 Обычно на практике для загрузки центрального процессора до уровня 90 % необходимо, чтобы коэффициент мультипрограммирования был не менее 4-5. А для того, чтобы наи- более полно использовать и остальные ресурсы системы, желательно иметь ц на уров- не 10-15.
74 Глава 2. Управление задачами и памятью в операционных системах тем, ибо она является несложной и обеспечивает возможность параллельного выполнения программ. Иногда в некотором разделе размещалось по несколько небольших программ, которые постоянно в нем и находились. Такие программы назывались ОЗУ-резидентными (или просто — резидентными). Они же исполь- зуются и в современных встроенных системах; правда, для них характерно, что все программы являются резидентными и внешняя память во время работы вы- числительного оборудования не используется. При небольшом объеме памяти и, следовательно, небольшом количестве раз- делов увеличить количество параллельно выполняемых приложений (особенно когда эти приложения интерактивны и во время своей работы они фактически не используют процессорное время, а в основном ожидают операций ввода/вы- вода) можно за счет своппинга (swapping). При своппинге задача может быть це- ликом выгружена на магнитный диск (перемещена во внешнюю память), а на ее место загружается либо более привилегированная, либо просто готовая к выпол- нению другая задача, находившаяся на диске в приостановленном состоянии. При своппинге из основной памяти во внешнюю (и обратно) перемещается вся программа, а не ее отдельная часть. Серьезная проблема, которая возникает при организации мультипрограммного режима работы вычислительной системы, — это защита как самой ОС от ошибок и преднамеренного вмешательства задач в ее работу, так и самих задач друг от друга. В самом деле, программа может обращаться к любым ячейкам в пределах своего виртуального адресного пространства. Если система отображения памяти не со- держит ошибок и в самой программе их тоже нет, то возникать ошибок при вы- полнении программы не должно. Однако в случае ошибок адресации, что не так уж и редко случается, исполняющаяся программа может начать «обработку» чу- жих данных или кодов с непредсказуемыми последствиями. Одной из простей- ших, но достаточно сильных мер является введение регистров защиты памяти. В эти регистры ОС заносит граничные значения области памяти раздела теку- щей исполняющейся задачи. При нарушении адресации возникает прерывание и управление передается супервизору ОС. Обращения к ОС за необходимыми сервисами осуществляются не напрямую, а через команды программных преры- ваний, что обеспечивает передачу управления только в предопределенные вход- ные точки кода ОС, и в системном режиме работы процессора, при котором регистры защиты памяти игнорируются. Таким образом, выполнение функции защиты требует введения специальных аппаратных механизмов, используемых операционной системой. Основным недостатком такого способа распределения памяти является наличие порой достаточно большого объема неиспользуемой памяти (см. рис. 2.6). Неис- пользуемая память может быть в каждом из разделов. Поскольку разделов не- сколько, то и неиспользуемых областей получается несколько, поэтому такие потери стали называть фрагментацией памяти. В отдельных разделах потери памяти могут быть очень значительными, однако использовать фрагменты сво- бодной памяти при таком способе распределения не представляется возможным. Желание разработчиков сократить столь значительные потери привело их к сле- дующим двум решениям:
Распределение статическими и динамическими разделами 75 □ выделять раздел ровно такого объема, который нужен под текущую задачу; □ размещать задачу не в одной непрерывной области памяти, а в нескольких областях. Второе решение реализовалось в нескольких способах организации виртуальной памяти. Мы их обсудим в следующем разделе, а сейчас кратко рассмотрим пер- вое решение. Разделы с подвижными границами Чтобы избавиться от фрагментации, можно попробовать размещать в оператив- ной памяти задачи плотно, одну за другой, выделяя ровно столько памяти, сколь- ко задача требует. Одной из первых ОС, реализовавшей такой способ распреде- ления памяти, была OS MVT1. Специальный планировщик (диспетчер памяти) ведет список адресов свободной оперативной памяти. При появлении новой за- дачи диспетчер памяти просматривает этот список и выделяет для задачи раздел, объем которого либо равен необходимому, либо чуть больше, если память выде- ляется не ячейками, а некими дискретными единицами. При этом модифици- руется список свободной памяти. При освобождении раздела диспетчер памяти пытается объединить освобождающийся раздел с одним из свободных участков, если таковой является смежным. При этом список свободных участков может быть упорядочен либо по адресам, либо по объему. Выделение памяти под новый раздел может осуществляться од- ним из трех способов: □ первый подходящий участок; □ самый подходящий участок; □ самый неподходящий участок. В первом случае список свободных областей упорядочивается по адресам (на- пример, по возрастанию адресов). Диспетчер памяти просматривает этот список и выделяет задаче раздел в той области, которая первой подойдет по объему. В этом случае, если такой фрагмент имеется, то в среднем необходимо просмот- реть половину списка. При освобождении раздела также необходимо просмотреть половину списка. Правило «первый подходящий» приводит к тому, что память для небольших задач преимущественно будет выделяться в области младших ад- ресов и, следовательно, это будет увеличивать вероятность того, что в области старших адресов будут образовываться фрагменты достаточно большого объема. Способ «самый подходящий» предполагает, что список свободных областей упо- рядочен по возрастанию объема этих фрагментов. В этом случае при просмотре списка для нового раздела будет использован фрагмент свободной памяти, объ- ем которой наиболее точно соответствует требуемому. Требуемый раздел будет определяться по-прежнему в результате просмотра в среднем половины списка. 1 MVT (multiprogramming with a nariable number of tasks) — мультипрограммирование с пе- ременным числом задач. Эта ОС была одной из самых распространенных при эксплуати- ровании больших ЭВМ класса IBM 360 (370).
76 Глава 2. Управление задачами и памятью в операционных системах Однако оставшийся фрагмент оказывается настолько малым, что в нем уже вряд ли удастся разместить какой-либо еще раздел и при этом этот фрагмент попадет в самое начало списка. Поэтому в целом такую дисциплину нельзя назвать эф- фективной. Как ни странно, самым эффективным правилом является последнее, по которо- му для нового раздела выделяется «самый неподходящий» фрагмент свободной памяти. Для этой дисциплины список свободных областей упорядочивается по убыванию объема свободного фрагмента. Очевидно, что если есть такой фраг- мент памяти, то он сразу же и будет найден, и поскольку этот фрагмент является самым большим, то, скорее всего, после выделения из него раздела памяти для задачи оставшаяся область памяти еще сможет быть использована в дальней- шем. Однако очевидно, что при любой дисциплине обслуживания, по которой работа- ет диспетчер памяти, из-за того, что задачи появляются и завершаются в произ- вольные моменты времени и при этом они имеют разные объемы, то в памяти всегда будет наблюдаться сильная фрагментация. При этом возможны ситуации, когда из-за сильной фрагментации памяти диспетчер задач не сможет образовать новый раздел, хотя суммарный объем свободных областей будет больше, чем не- обходимо для задачи. В этой ситуации возможно организовать так называемое «уплотнение памяти». Для уплотнения памяти все вычисления приостанавлива- ются, и диспетчер памяти корректирует свои списки, перемещая разделы в нача- ло памяти (или, наоборот, в область старших адресов). При определении физи- ческих адресов задачи будут участвовать новые значения базовых регистров, с помощью которых и осуществляется преобразование виртуальных адресов в физические. Недостатком этого решения является потеря времени на уплотне- ние и, что самое главное, невозможность при этом выполнять сами вычислитель- ные процессы. Данный способ распределения памяти тем не менее применялся достаточно дли- тельное время в нескольких операционных системах, поскольку в нем для задач выделяется непрерывное адресное пространство, а это упрощает создание сис- тем программирования и их работу. Сегментная, страничная и сегментно- страничная организация памяти Методы распределения памяти, при которых задаче уже может не предостав- ляться сплошная (непрерывная) область памяти, называют разрывными. Идея выделять память задаче не одной сплошной областью, а фрагментами требует для своей реализации соответствующей аппаратной поддержки — нужно иметь относительную адресацию. Если указывать адрес начала текущего фрагмента программы и величину смещения относительно этого начального адреса, то мож- но указать необходимую нам переменную или команду. Таким образом, вирту- альный адрес можно представить состоящим из двух полей. Первое поле будет указывать часть программы (с которой сейчас осуществляется работа) для опре- деления местоположения этой части в памяти, а второе поле виртуального адре-
Сегментная, страничная и сегментно-страничная организация памяти 77 са позволит найти нужную нам ячейку относительно найденного адреса. Про- граммист может либо самостоятельно разбивать программу на фрагменты, либо автоматизировать эту задачу и возложить ее на систему программирования. Сегментный способ организации виртуальной памяти Первым среди разрывных методов распределения памяти был сегментный. Для этого метода программу необходимо разбивать на части и уже каждой такой час- ти выделять физическую память. Естественным способом разбиения программы на части является разбиение ее на логические элементы — так называемые сег- менты. В принципе каждый программный модуль (или их совокупность, если мы того пожелаем) может быть воспринят как отдельный сегмент, и вся про- грамма тогда будет представлять собой множество сегментов. Каждый сегмент размещается в памяти как до определенной степени самостоятельная единица. Логически обращение к элементам программы в этом случае будет представ- ляться как указание имени сегмента и смещения относительно начала этого сег- мента. Физически имя (или порядковый номер) сегмента будет соответствовать некоторому адресу, с которого этот сегмент начинается при его размещении в па- мяти, и смещение должно прибавляться к этому базовому адресу. Преобразование имени сегмента в его порядковый номер осуществит система программирования, а операционная система будет размещать сегменты в память и для каждого сегмента получит информацию о его начале. Таким образом, вир- туальный адрес для этого способа будет состоять из двух полей — номер сегмента и смещение относительно начала сегмента. Соответствующая иллюстрация при- ведена на рис. 2.7. На этом рисунке изображен случай обращения к ячейке, вир- туальный адрес которой равен сегменту с номером Ии смещением от начала этого сегмента, равным 612. Как мы видим, операционная система разместила данный сегмент в памяти, начиная с ячейки с номером 19 700. Каждый сегмент, размещаемый в памяти, имеет соответствующую информаци- онную структуру, часто называемую дескриптором сегмента. Именно опера- ционная система строит для каждого исполняемого процесса соответствующую таблицу дескрипторов сегментов и при размещении каждого из сегментов в опе- ративной или внешней памяти в дескрипторе отмечает его текущее местополо- жение. Если сегмент задачи в данный момент находится в оперативной памяти, то об этом делается пометка в дескрипторе. Как правило, для этого используется «бит присутствия» (present). В этом случае в поле «адрес» диспетчер памяти за- писывает адрес физической памяти, с которого сегмент начинается, а в поле «длина сегмента» (limit) указывается количество адресуемых ячеек памяти. Это поле используется не только для того, чтобы размещать сегменты без наложения один на другой, но и для того, чтобы проконтролировать, не обращается ли код исполняющейся задачи за пределы текущего сегмента. В случае превышения длины сегмента вследствие ошибок программирования мы можем говорить о на- рушении адресации и с помощью введения специальных аппаратных средств генерировать сигналы прерывания, которые позволят фиксировать (обнаружи- вать) такого рода ошибки.
78 Глава 2. Управление задачами и памятью в операционных системах Регистр таблицы сегментов (таблицы дескрипторов сегментов) Виртуальный адрес Рис. 2.7. Сегментный способ организации виртуальной памяти Если бит present в дескрипторе указывает, что сейчас этот сегмент находится не в оперативной, а во внешней памяти (например, на винчестере), то названные поля адреса и длины используются для указания адреса сегмента в координатах внешней памяти. Помимо информации о местоположении сегмента, в дескрип- торе сегмента, как правило, содержатся данные о его типе (сегмент кода или сег- мент данных), правах доступа к этому сегменту (можно или нельзя его моди- фицировать, предоставлять другой задаче), отметка об обращениях к данному сегменту (информация о том, как часто или как давно/недавно этот сегмент ис- пользуется или не используется, на основании которой можно принять решение о том, чтобы предоставить место, занимаемое текущим сегментом, другому сег- менту). При передаче управления следующей задаче ОС должна занести в соответствую- щий регистр адрес таблицы дескрипторов сегментов этой задачи. Сама таблица дескрипторов сегментов, в свою очередь, также представляет собой сегмент дан- ных, который обрабатывается диспетчером памяти операционной системы. При таком подходе появляется возможность размещать в оперативной памяти не все сегменты задачи, а только те, с которыми в настоящий момент происходит работа. С одной стороны, становится возможным, чтобы общий объем виртуаль- ного адресного пространства задачи превосходил объем физической памяти ком- пьютера, на котором эта задача будет выполняться. С другой стороны, даже если потребности в памяти не превосходят имеющуюся физическую память, появля- ется возможность размещать в памяти как можно больше задач. А увеличение коэффициента мультипрограммирования ц, как мы знаем, позволяет увеличить загрузку системы и более эффективно использовать ресурсы вычислительной системы. Очевидно, однако, что увеличивать количество задач можно только до
Сегментная, страничная и сегментно-страничная организация памяти 79 определенного предела, ибо если в памяти не будет хватать места для часто ис- пользуемых сегментов, то производительность системы резко упадет. Ведь сег- мент, который сейчас находится вне оперативной памяти, для участия в вычис- лениях должен быть перемещен в оперативную память. При этом если в памяти есть свободное пространство, то необходимо всего лишь найти его во внешней памяти и загрузить в оперативную память. А если свободного места сейчас нет, то необходимо будет принять решение — на место какого из ныне присутствую- щих сегментов будет загружаться требуемый. Итак, если требуемого сегмента в оперативной памяти нет, то возникает пре- рывание и управление передается через диспетчер памяти программе загрузки сегмента. Пока происходит поиск сегмента во внешней памяти и загрузка его в оперативную, диспетчер памяти определяет подходящее для сегмента место. Возможно, что свободного места нет, и тогда принимается решение о выгрузке какого-нибудь сегмента и его перемещение во внешнюю память. Если при этом еще остается время, то процессор передается другой готовой к выполнению зада- че. После загрузки необходимого сегмента процессор вновь передается задаче, вызвавшей прерывание из-за отсутствия сегмента. Всякий раз при считывании сегмента в оперативную память в таблице дескрипторов сегментов необходимо установить адрес начала сегмента и признак присутствия сегмента. При поиске свободного места используется одна из вышеперечисленных дис- циплин работы диспетчера памяти (применяются правила «первого подходяще- го» и «самого неподходящего» фрагментов). Если свободного фрагмента памяти достаточного объема сейчас нет, но тем не менее сумма этих свободных фраг- ментов превышает требования по памяти для нового сегмента, то в принципе может быть применено «уплотнение памяти», о котором мы уже говорили в под- разделе «Разделы с фиксированными границами» при рассмотрении динамиче- ского способа разбиения памяти на разделы. В идеальном случае размер сегмента должен быть достаточно малым, чтобы его можно было разместить в случайно освобождающихся фрагментах оперативной памяти, но достаточно большим, чтобы содержать логически законченную часть программы с тем, чтобы минимизировать межсегментные обращения. Для решения проблемы замещения (определения того сегмента, который должен быть либо перемещен во внешнюю память, либо просто замещен новым) исполь- зуются следующие дисциплины1: □ правило FIFO (first in — first out, что означает: «первый пришедший первым и выбывает»); □ правило LRU (least recently used, что означает «последний из недавно исполь- зованных» или, иначе говоря, «дольше всего неиспользуемый»); □ правило LFU (least frequently used, что означает: «используемый реже всех ос- тальных»); □ случайный (random) выбор сегмента. 1 Их называют «дисциплинами замещения».
80 Глава 2. Управление задачами и памятью в операционных системах Первая и последняя дисциплины являются самыми простыми в реализации, но они не учитывают, насколько часто используется тот или иной сегмент и, следо- вательно, диспетчер памяти может выгрузить или расформировать тот сегмент, к которому в самом ближайшем будущем будет обращение. Безусловно, досто- верной информации о том, какой из сегментов потребуется в ближайшем буду- щем, в общем случае иметь нельзя, но вероятность ошибки для этих дисциплин многократно выше, чем у второй и третьей дисциплины, которые учитывают ин- формацию об использовании сегментов. Алгоритм FIFO ассоциирует с каждым сегментом время, когда он был помещен в память. Для замещения выбирается наиболее старый сегмент. Учет времени необязателен, когда все сегменты в памяти связаны в FIFO-очередь и каждый помещаемый в память сегмент добавляется в хвост этой очереди. Алгоритм учи- тывает только время нахождения сегмента в памяти, но не учитывает факти- ческое использование сегментов. Например, первые загруженные сегменты про- граммы могут содержать переменные, используемые на протяжении работы всей программы. Это приводит к немедленному возвращению к только что замещен- ному сегменту. Для реализации дисциплин LRU и LFU необходимо, чтобы процессор имел дополнительные аппаратные средства. Минимальные требования — достаточно, чтобы при обращении к дескриптору сегмента для получения физического адре- са, с которого сегмент начинает располагаться в памяти, соответствующий бит обращения менял свое значение (скажем, с нулевого, которое установила ОС, в единичное). Тогда диспетчер памяти может время от времени просматривать таблицы дескрипторов исполняющихся задач и собирать для соответствующей обработки статистическую информацию об обращениях к сегментам. В результа- те можно составить список, упорядоченный либо по длительности не использо- вания (для дисциплины LRU), либо по частоте использования (для дисциплины LFU). Важнейшей проблемой, которая возникает при организации мультипрограммно- го режима, является защита памяти. Для того чтобы выполняющиеся приложе- ния не смогли испортить саму ОС и другие вычислительные процессы, необхо- димо, чтобы доступ к таблицам сегментов с целью их модификации был обеспечен только для кода самой ОС. Для этого код ОС должен выполняться в некотором привилегированном режиме, из которого можно осуществлять манипуляции с дескрипторами сегментов, тогда как выход за пределы сегмента в обычной при- кладной программе должен вызывать прерывание по защите памяти. Каждая прикладная задача должна иметь возможность обращаться только к своим собст- венным сегментам. При использовании сегментного способа организации виртуальной памяти по- является несколько интересных возможностей. Во-первых, появляется возмож- ность при загрузке программы на исполнение размещать ее в памяти не целиком, а «по мере необходимости». Действительно, поскольку в подавляющем большин- стве случаев алгоритм, по которому работает код программы, является разветв- ленным, а не линейным, то в зависимости от исходных данных некоторые части программы, расположенные в самостоятельных сегментах, могут быть и не за- действованы; значит, их можно и не загружать в оперативную память.
Сегментная, страничная и сегментно-страничная организация памяти 81 Во-вторых, некоторые программные модули могут быть разделяемыми. Эти про- граммные модули являются сегментами, и в этом случае относительно легко орга- низовать доступ к таким сегментам. Сегмент с разделяемым кодом располагается в памяти в единственном экземпляре, а в нескольких таблицах дескрипторов сег- ментов исполняющихся задач будут находиться указатели на такие разделяемые сегменты. Однако у сегментного способа распределения памяти есть и недостатки. Прежде всего, из рис. 2.7 видно, что для получения доступа к искомой ячейке памяти не- обходимо потратить намного больше времени. Мы должны сначала найти и про- читать дескриптор сегмента, а уже потом, используя данные из него о местонахо- ждении нужного нам сегмента, можем вычислить и конечный физический адрес. Для того чтобы уменьшить эти потери, используется кэширование — то есть те дескрипторы, с которыми мы имеем дело в данный момент, могут быть размеще- ны в сверхоперативной памяти (специальных регистрах, размещаемых в процес- соре). Несмотря на то что этот способ распределения памяти приводит к существенно меньшей фрагментации памяти, нежели способы с неразрывным распределением, фрагментация остается. Кроме этого, мы имеем большие потери памяти и про- цессорного времени на размещение и обработку дескрипторных таблиц. Ведь на каждую задачу необходимо иметь свою таблицу дескрипторов сегментов. А при определении физических адресов необходимо выполнять операции сложения. Поэтому следующим способом разрывного размещения задач в памяти стал спо- соб, при котором все фрагменты задачи одинакового размера и длины, кратной степени двойки, чтобы операции сложения можно было заменить операциями конкатенации (слияния). Это — страничный способ организации виртуальной памяти. Примером использования сегментного способа организации виртуальной памя- ти является операционная система для ПК OS/2 первого поколения1, которая была создана для процессора i80286. В этой ОС в полной мере использованы ап- паратные средства микропроцессора, который специально проектировался для поддержки сегментного способа распределения памяти. OS/2 v.l поддерживала распределение памяти, при котором выделялись сегмен- ты программы и сегменты данных. Система позволяла работать как с именован- ными, так и неименованными сегментами. Имена разделяемых сегментов данных имели ту же форму, что и имена файлов. Процессы получали доступ к именован- ным разделяемым сегментам, используя их имена в специальных системных вызовах. OS/2 v.l допускала разделение программных сегментов приложений и подсистем, а также глобальных сегментов данных подсистем. Вообще, вся кон- цепция системы OS/2 была построена на понятии разделения памяти: процессы почти всегда разделяют сегменты с другими процессами. В этом состояло суще- ственное отличие от систем типа UNIX, которые обычно разделяют только реен- терабельные программные модули между процессами. 1 OS/2 v.l начала создаваться в 1984 г. и вышла в продажу в 1987 г.
82 Глава 2. Управление задачами и памятью в операционных системах Сегменты, которые активно не использовались, могли выгружаться на жесткий диск. Система восстанавливала их, когда в этом возникала необходимость. Так как все области памяти, используемые сегментом, должны были быть непрерыв- ными, OS/2 перемещала в основной памяти сегменты таким образом, чтобы максимизировать объем свободной физической памяти. Такое размещение назы- вается компрессией или перемещением сегментов (уплотнением памяти). Про- граммные сегменты не выгружались, поскольку они могли просто перезагружать- ся с исходных дисков. Области в младших адресах физической памяти, которые использовались для запуска DOS-программ и кода самой OS/2, не участвовали в перемещении или подкачке. Кроме этого, система или прикладная программа могли временно фиксировать сегмент в памяти с тем, чтобы гарантировать нали- чие буфера ввода/вывода в физической памяти до тех пор, пока операция вво- да/вывода не завершится. Если в результате компрессии памяти не удавалось создать необходимое свобод- ное пространство, то супервизор выполнял операции фонового плана для пере- качки достаточного количества сегментов из физической памяти, чтобы дать возможность завершиться исходному запросу. Механизм перекачки сегментов использовал файловую систему для перекачки данных из физической памяти и в нее. Ввиду того, что перекачка и сжатие влия- ют на производительность системы в целом, пользователь может сконфигуриро- вать систему так, чтобы эти функции не выполнялись. Было организовано в OS/2 и динамическое присоединение обслуживающих про- грамм. Программы OS/2 используют команды удаленного вызова. Ссылки, гене- рируемые этими вызовами, определяются в момент загрузки самой программы или ее сегментов. Такое отсроченное определение ссылок называется динами- ческим присоединением. Загрузочный формат модуля OS/2 представляет собой расширение формата загрузочного модуля DOS. Он был расширен, чтобы под- держивать необходимое окружение для свопинга сегментов с динамическим присоединением. Динамическое присоединение уменьшает объем памяти для программ в OS/2, одновременно делая возможным перемещения подсистем и обслуживающих программ без необходимости повторного редактирования ад- ресных ссылок к прикладным программам. Страничный способ организации виртуальной памяти Как мы уже сказали, при таком способе все фрагменты программы, на которые она разбивается (за исключением последней ее части), получаются одинаковыми. Одинаковыми полагаются и единицы памяти, которые мы предоставляем для размещения фрагментов программы. Эти одинаковые части называют страницами и говорят, что память разбивается на физические страницы, а программа — на виртуальные страницы. Часть виртуальных страниц задачи размещается в опе- ративной памяти, а часть — во внешней. Обычно место во внешней памяти, в ка- честве которой в абсолютном большинстве случаев выступают накопители на магнитных дисках (поскольку они относятся к быстродействующим устройст-
Сегментная, страничная и сегментно-страничная организация памяти 83 вам с прямым доступом), называют файлом подкачки или страничным файлом (paging file). Иногда этот файл называют swap-файлом, тем самым подчеркивая, что записи этого файла — страницы — замещают друг друга в оперативной памя- ти. В некоторых ОС выгруженные страницы располагаются не в файле, а в спе- циальном разделе дискового пространства. В UNIX-системах для этих целей вы- деляется специальный раздел, но кроме него могут быть использованы и файлы, выполняющие те же функции, если объема раздела недостаточно. Разбиение всей оперативной памяти на страницы одинаковой величины, причем величина каждой страницы выбирается кратной степени двойки, приводит к тому, что вместо одномерного адресного пространства памяти можно говорить о двумерном. Первая координата адресного пространства — это номер страницы, а вторая координата — номер ячейки внутри выбранной страницы (его называют индексом). Таким образом, физический адрес определяется парой (Рр, i), а вир- туальный адрес — парой (Pv, i), где Pv — это номер виртуальной страницы, Рр — это номер физической страницы и i — это индекс ячейки внутри страницы. Коли- чество битов, отводимое под индекс, определяет размер страницы, а количество битов, отводимое под номер виртуальной страницы, — объем возможной вирту- альной памяти, которой может пользоваться программа. Отображение, осущест- вляемое системой во время исполнения, сводится к отображению Pv в Рр и при- писывании к полученному значению битов адреса, задаваемых величиной i. При этом нет необходимости ограничивать число виртуальных страниц числом фи- зических, то есть не поместившиеся страницы можно размещать во внешней па- мяти, которая в данном случае служит расширением оперативной. Для отображения виртуального адресного пространства задачи на физическую память, как и в случае с сегментным способом организации, для каждой задачи необходимо иметь таблицу страниц для трансляции адресных пространств. Для описания каждой страницы диспетчер памяти ОС заводит соответствующий де- скриптор, который отличается от дескриптора сегмента прежде всего тем, что в нем нет необходимости иметь поле длины — ведь все страницы имеют одина- ковый размер. По номеру виртуальной страницы в таблице дескрипторов стра- ниц текущей задачи находится соответствующий элемент (дескриптор). Если бит присутствия имеет единичное значение, значит, данная страница сейчас раз- мещена в оперативной, а не во внешней памяти и мы в дескрипторе имеем номер физической страницы, отведенной под данную виртуальную. Если же бит при- сутствия равен нулю, то в дескрипторе мы будем иметь адрес виртуальной стра- ницы, расположенной сейчас во внешней памяти. Таким образом и осуществ- ляется трансляция виртуального адресного пространства на физическую память. Этот механизм трансляции проиллюстрирован на рис. 2.8. Защита страничной памяти, как и в случае с сегментным механизмом, основана на контроле уровня доступа к каждой странице. Как правило, возможны следую- щие уровни доступа: только чтение; чтение и запись; только выполнение. В этом случае каждая страница снабжается соответствующим кодом уровня доступа. При трансформации логического адреса в физический сравнивается значение кода разрешенного уровня доступа с фактически требуемым. При их несовпаде- нии работа программы прерывается.
84 Глава 2. Управление задачами и памятью в операционных системах Регистр таблицы страниц Виртуальный адрес Рис. 2.8. Страничный способ организации виртуальной памяти При обращении к виртуальной странице, не оказавшейся в данный момент в оперативной памяти, возникает прерывание и управление передается диспетче- ру памяти, который должен найти свободное место. Обычно предоставляется первая же свободная страница. Если свободной физической страницы нет, то диспетчер памяти по одной из вышеупомянутых дисциплин замещения (LRU, LFU, FIFO, random) определит страницу, подлежащую расформированию или сохранению во внешней памяти. На ее место он разместит ту новую виртуаль- ную страницу, к которой было обращение из задачи, но ее не оказалось в опера- тивной памяти. Напомним, что алгоритм выбирает для замещения ту страницу, на которую не было ссылки на протяжении наиболее длинного периода времени. Дисциплина LRU (least recently used) ассоциирует с каждой страницей время последнего ее использования. Для замещения выбирается та страница, которая дольше всех не использовалась. Для использования дисциплин LRU и LFU в процессоре должны быть соответ- ствующие аппаратные средства. В дескрипторе страницы размещается бит обра- щения (подразумевается, что на рис. 2.8 этот бит расположен в последнем поле), и этот бит становится единичным при обращении к дескриптору. Если объем физической памяти небольшой и даже часто требуемые страницы не удается разместить в оперативной памяти, то возникает так называемая «про- буксовка». Другими словами, пробуксовка — это ситуация, при которой загрузка нужной нам страницы вызывает перемещение во внешнюю память той страни- цы, с которой мы тоже активно работаем. Очевидно, что это очень плохое явле- ние. Чтобы его не допускать, желательно увеличить объем оперативной памяти (сейчас это стало самым простым решением), уменьшить количество параллель- но выполняемых задач либо попробовать использовать более эффективные дис- циплины замещения.
Сегментная, страничная и сегментно-страничная организация памяти 85 В абсолютном большинстве современных ОС используется дисциплина замеще- ния страниц LRU как самая эффективная. Так, именно эта дисциплина исполь- зуется в OS/2 и Linux. Однако в такой ОС, как Windows NT, разработчики, желая сделать систему максимально независимой от аппаратных возможностей процессора, пошли на отказ от этой дисциплины и применили правило FIFO. А для того, чтобы хоть как-нибудь сгладить ее неэффективность, была введена «буферизация» тех страниц, которые должны быть записаны в файл подкачки на диск1 или просто расформированы. Принцип буферирования прост. Прежде чем замещаемая страница действительно будет перемещена во внешнюю память или просто расформирована, она помечается как кандидат на выгрузку. Если в сле- дующий раз произойдет обращение к странице, находящейся в таком «буфере», то страница никуда не выгружается и уходит в конец списка FIFO. В противном случае страница действительно выгружается, а на ее место в «буфере» попадает следующий «кандидат». Величина такого «буфера» не может быть большой, по- этому эффективность страничной реализации памяти в Windows NT намного ниже, чем у вышеназванных ОС, и явление пробуксовки начинается даже при сущест- венно большем объеме оперативной памяти. В ряде ОС с пакетным режимом работы для борьбы с пробуксовкой использует- ся метод «рабочего множества». Рабочее множество — это множество «активных» страниц задачи за некоторый интервал То есть тех страниц, к которым было об- ращение за этот интервал времени. Реально количество активных страниц зада- чи (за интервал Т) все время изменяется, и это естественно, но тем не менее для каждой задачи можно определить среднее количество ее активных страниц. Это среднее число активных страниц и есть рабочее множество задачи. Наблюдения за исполнением множества различных программ показали [28, 37, 49], что даже если Т равно времени выполнения всей работы, то размер рабочего множества часто существенно меньше, чем общее число страниц программы. Таким обра- зом, если ОС может определить рабочие множества исполняющихся задач, то для предотвращения пробуксовки достаточно планировать на выполнение толь- ко такое количество задач, чтобы сумма их рабочих множеств не превышала воз- можности системы. Как и в случае с сегментным способом организации виртуальной памяти, стра- ничный механизм приводит к тому, что без специальных аппаратных средств он будет существенно замедлять работу вычислительной системы. Поэтому обычно используется кэширование страничных дескрипторов. Наиболее эффективным способом кэширования является использование ассоциативного кэша. Именно такой ассоциативный кэш и создан в 32-разрядных микропроцессорах i80x86. Начиная с i80386, который поддерживает страничный способ распределения па- мяти, в этих микропроцессорах имеется кэш на 32 страничных дескриптора. По- 1 В системе Windows NT файл с выгруженными виртуальными страницами носит назва- ние PageFile.sys. Таких файлов может быть несколько. Их совокупная длина должна быть не меньше, чем объем физической памяти компьютера плюс 11 Мб, необходимые для самой Windows NT. В системах Windows 2000 размер файла PageFile.sys намного превышает объем установленной физической памяти и часто достигает многих сотен ме- габайт.
86 Глава 2. Управление задачами и памятью в операционных системах скольку размер страницы в этих микропроцессорах равен 4 Кбайт, возможно бы- строе обращение к 128 Кбайт памяти. Итак, основным достоинством страничного способа распределения памяти явля- ется минимально возможная фрагментация. Поскольку на каждую задачу может приходиться по одной незаполненной странице, то становится очевидно, что па- мять можно использовать достаточно эффективно; этот метод организации вир- туальной памяти был бы одним из самых лучших, если бы не два следующих об- стоятельства. Первое — это то, что страничная трансляция виртуальной памяти требует суще- ственных накладных расходов. В самом деле, таблицы страниц нужно тоже раз- мещать в памяти. Кроме этого, эти таблицы нужно обрабатывать; именно с ними работает диспетчер памяти. Второй существенный недостаток страничной адресации заключается в том, что программы разбиваются на страницы случайно, без учета логических взаимосвя- зей, имеющихся в коде. Это приводит к тому, что межстраничные переходы, как правило, осуществляются чаще, нежели межсегментные, и к тому, что становит- ся трудно организовать разделение программных модулей между выполняющи- мися процессами. Для того чтобы избежать второго недостатка, постаравшись сохранить достоин- ства страничного способа распределения памяти, был предложен еще один спо- соб — сегментно-страничный. Правда, за счет дальнейшего увеличения наклад- ных расходов на его реализацию. Сегментно-страничный способ организации виртуальной памяти Как и в сегментном способе распределения памяти, программа разбивается на логически законченные части — сегменты — и виртуальный адрес содержит ука- зание на номер соответствующего сегмента. Вторая составляющая виртуально- го адреса — смещение относительно начала сегмента — в свою очередь, может состоять из двух полей: виртуальной страницы и индекса. Другими словами, по- лучается, что виртуальный адрес теперь состоит из трех компонентов: сегмент, страница, индекс. Получение физического адреса и извлечение из памяти необ- ходимого элемента для этого способа представлено на рис. 2.9. Из рисунка сразу видно, что этот способ организации виртуальной памяти вно- сит еще большую задержку доступа к памяти. Необходимо сначала вычислить адрес дескриптора сегмента и прочитать его, затем вычислить адрес элемен- та таблицы страниц этого сегмента и извлечь из памяти необходимый элемент, и уже только после этого можно к номеру физической страницы приписать но- мер ячейки в странице (индекс). Задержка доступа к искомой ячейке получа- ется по крайней мере в три раза больше, чем при простой прямой адресации. Чтобы избежать этой неприятности, вводится кэширование, причем кэш, как правило, строится по ассоциативному принципу. Другими словами, просмотры двух таблиц в памяти могут быть заменены одним обращением к ассоциативной памяти.
Сегментная, страничная и сегментно-страничная организация памяти 87 Регистр таблицы сегментов (таблицы дескрипторов сегментов) Виртуальный адрес Рис. 2.9. Сегментно-страничный способ организации виртуальной памяти Напомним, что принцип действия ассоциативного запоминающего устройства предполагает, что каждой ячейке памяти такого устройства ставится в соответст- вие ячейка, в которой записывается некий ключ (признак, адрес), позволяющий однозначно идентифицировать содержимое ячейки памяти. Сопутствующую ячей- ку с информацией, позволяющей идентифицировать основные данные, обычно называют полем тега. Просмотр полей тега всех ячеек ассоциативного устройст- ва памяти осуществляется одновременно, то есть в каждой ячейке тега есть необ- ходимая логика, позволяющая посредством побитовой конъюнкции найти данные по их признаку за одно обращение к памяти (если они там, конечно, присутству- ют). Часто поле тегов называют аргументом, а поле с данными — функцией. В качестве аргумента при доступе к ассоциативной памяти выступают номер сег- мента и номер виртуальной страницы, а в качестве функции от этих аргументов получаем номер физической страницы. Остается приписать номер ячейки в стра- нице к полученному номеру, и мы получаем искомую команду или операнд.
88 Глава 2. Управление задачами и памятью в операционных системах Оценим достоинства сегментно-страничного способа. Разбиение программы на сегменты позволяет размещать сегменты в памяти целиком. Сегменты разби- ты на страницы, все страницы сегмента загружаются в память. Это позволяет уменьшить обращения к отсутствующим страницам, поскольку вероятность вы- хода за пределы сегмента меньше вероятности выхода за пределы страницы. Страницы исполняемого сегмента находятся в памяти, но при этом они могут находиться не рядом друг с другом, а «россыпью», поскольку диспетчер памяти манипулирует страницами. Наличие сегментов облегчает реализацию разделе- ния программных модулей между параллельными процессами. Возможна и ди- намическая компоновка задачи. А выделение памяти страницами позволяет ми- нимизировать фрагментацию. Однако, поскольку этот способ распределения памяти требует очень значительных затрат вычислительных ресурсов и его не так просто реализовать, используется он редко, причем в дорогих, мощных вычислительных системах. Возможность реализовать сегментно-страничное распределение памяти заложена и в семейст- во микропроцессоров i80x86, однако вследствие слабой аппаратной поддержки, трудностей при создании систем программирования и операционной системы, практически он не используется в ПК. Распределение оперативной памяти в современных ОС для ПК Первый вопрос, который хочется задать, — это какие ОС следует относить к со- временным, а какие — нет? Стоит ли в наше время изучать такую «несовремен- ную» ОС, как MS-DOS?1 С нашей точки зрения, прежде всего к современным ОС следует отнести те, что используют аппаратные возможности микропроцес- соров, специально заложенные для организации высокопроизводительных и на- дежных вычислений. Однако эти ОС, как правило, очень сложны и громоздки. Они занимают большое дисковое пространство, требуют и большого объема опе- ративной памяти. Поэтому для решения некоторого класса задач вполне подхо- дят и системы, использующие микропроцессоры в так называемом реальном ре- жиме работы (см. об этом в следующей главе). В последние годы можно встретить студентов, обучающихся специальностям, непосредственно связанным с вычислительной техникой, которые совсем не зна- ют DOS-систем. Скорее всего, это является доказательством того, что такие ОС уже не являются современными. Однако достаточно часто для обслуживания компьютера необходимо выполнить простейшие программы — утилиты. Эти программы были созданы для DOS, они не требуют больших ресурсов, для их 1 Широко известно, что было много версий ОС, которые мы, упрощая ситуацию, относим к MS-DOS. MS-DOS — это вариант фирмы Microsoft реализации дисковой операцион- ной системы [3, 28]. Фирма Microsoft прекратила разработку подобных систем, и послед- ней их реализацией была MS-DOS 6.22. Были (и есть ныне) реализации такого рода систем и от других разработчиков. Поскольку у MS-DOS 6.22 существуют известные проблемы с 2000 годом, большой популярностью пользуется PC-DOS 7.0 от IBM.
Распределение оперативной памяти в современных ОС для ПК 89 функционирования достаточно запустить MS-DOS или аналогичную простую ОС. Однако без выполнения этих программ невозможно порой установить или за- грузить иные ОС (хоть и современные, но очень сложные и громоздкие). Поэто- му мы считаем правильным хотя бы первичное, пусть не очень глубокое озна- комление с MS-DOS. Распределение оперативной памяти в MS-DOS Как известно, MS-DOS — это однопрограммная ОС. В ней, конечно, можно орга- низовать запуск резидентных или TSR-задач1, но в целом она предназначена для выполнения только одного вычислительного процесса. Поэтому распределение памяти в ней построено по самой простой схеме, которую мы уже рассматривали в разделе «Простое непрерывное распределение и распределение с перекрытием (оверлейные структуры)». Здесь мы лишь уточним некоторые характерные детали. В IBM PC использовался 16-разрядный микропроцессор i8088, который за счет введения сегментного способа адресации позволял адресоваться к памяти объе- мом до 1 Мбайт. В последующих ПК (IBM PC AT, АТ386 и др.) было приня- то решение поддерживать совместимость с первыми, поэтому при работе с DOS прежде всего рассматривают первый мегабайт. Вся эта память разделялась на не- сколько областей, что проиллюстрировано на рис. 2.10. На этом рисунке изобра- жено, что памяти может быть и больше, чем 1 Мбайт, но более подробное рас- смотрение этого вопроса мы здесь опустим, отослав желающих изучить данную тему глубже к монографии [9]. Если не вдаваться в детали, можно сказать, что в состав MS-DOS входят следую- щие основные компоненты: □ Базовая подсистема ввода/вывода — BIOS (base input-output system), вклю- чающая в себя помимо программы тестирования ПК (POST2) обработчики прерываний (драйверы), расположенные в постоянном запоминающем уст- ройстве. В конечном итоге, почти все остальные модули MS-DOS обращают- ся к BIOS. Если и не напрямую, то через модули более высокого уровня иерархии. □ Модуль расширения BIOS — файл IO.SYS (в других DOS-системах он может называться иначе, например, IBMBIO.COM). □ Основной, базовый модуль обработки прерываний DOS — файл MSDOS.SYS. Именно этот модуль в основном реализует работу с файловой системой. (В PC-DOS аналогичный по значению файл называется IBMDOS.COM). □ Командный процессор (интерпретатор команд) — файл COMMAND.COM. 1 TSR (terminate and stay resident) — резидентная в памяти программа, которая благодаря изменениям в таблице векторов прерываний позволяет перехватывать прерывания и в случае обращения к ней выполнять необходимые нам действия. Подробно об этом можно прочесть, например, в книгах [3, 23, 24, 35]. 2 POST (power on self test) — программа самотестирования при включении компьютера. После выполнения этой программы, входящей в состав ROM BIOS, опрашиваются уст- ройства, которые могут содержать программы для загрузки ОС.
90 Глава 2. Управление задачами и памятью в операционных системах □ Утилиты и драйверы, расширяющие возможности системы. □ Программа загрузки MS-DOS — загрузочная запись (boot record), располо- женная на дискете (подробнее о ней и о других загрузчиках см. главу 4). 0000-003FF 1Кб Таблица векторов прерываний 00400-005FF 512 байт Глобальные переменные BIOS Глобальные переменные DOS В ранних версиях здесь располагались глобальные переменные интерпретатора Бейсик 00600-0А000 35—-60 Кб Модуль IO.SYS Модуль MSDOS.SYS: - обслуживающие функции; - буферы, рабочие и управляющие области; - инсталируемые драйверы Резидентная часть COMMAND.COM: - обработка программных прерываний; - системная программа загрузки; - программа загрузки транзитной части COMMAND.COM Размер этой области зависит l от версии MS-DOS и, главное, от конфигурационного файла CONFIG.SYS « 580 Кб Область памяти для выполнения программ пользователя и утилит MS-DOS. В эту область попадают программы типа *.С0М и *. EXE Объем этой области сильно зависит от объема, занимаемого ядром ОС. Программа может перекрывать транзитную область COMMAND.COM Область расположения стека исполняющейся программы Стек «растет» снизу вверх 18 Кб Транзитная часть командного процессора COMMAND.COM Собственно командный интерпретатор A0000-C7FFF 160 Кб Видеопамять. Область и размер используемого видеобуфера зависит от используемого режима При работе в текстовом режиме область памяти >• A0000-B0000 свободна и может быть использована в программе С8000-Е0000 96 Кб Зарезервировано для расширения BIOS FOOOO-FFFFF 64 Кб Область ROM BIOS (System BIOS) Обычно объем этой области равен 32 Кб, но может достигать и 128 Кб, занимая и младшие адреса 100000- High Memory Area При наличии драйвера HIMEM.SYS здесь можно расположить основные системные файлы MS-DOS, освобождая тем самым область основной памяти в первом мегабайте Может использоваться при наличии специальных >• драйверов. Используются спецификации XMS и EMS Рис. 2.10. Распределение оперативной памяти в MS-DOS
Распределение оперативной памяти в современных ОС для ПК 91 Вся память в соответствии с архитектурой IBM PC условно может быть разбита на три части. В самых младших адресах памяти (первые 1024 ячейки) размещается таблица векторов прерываний (см. раздел «Система прерываний 32-разрядных микропро- цессоров i80x86», глава 3). Это связано с аппаратной реализацией процессора i8088, на котором была реализована ПК. В последующих процессорах (начиная с i80286) адрес таблицы прерываний определяется через содержимое соответст- вующего регистра, но для обеспечения полной совместимости с первым процес- сором при включении или аппаратном сбросе в этот регистр заносятся нули. При желании, однако, в случае использования современных микропроцессоров i80x86 можно разместить векторы прерываний и в другой области. Вторая часть памяти отводится для размещения программных модулей самой MS-DOS и для программ пользователя. Рассмотрим их размещение чуть ниже. Здесь, однако, заметим, что эта область памяти называется Conventional Memory (основная, стандартная память). Наконец, третья часть адресного пространства отведена для постоянных запо- минающих устройств и функционирования некоторых устройств ввода/вывода. Эта область памяти получила название UMA (upper memory areas — область верхней памяти). В младших адресах основной памяти размещается то, что можно назвать ядром этой ОС — системные переменные, основные программные модули, блоки дан- ных для буферирования операций ввода/вывода. Для управления устройствами, драйверы которых не входят в базовую подсистему ввода/вывода, загружают- ся так называемые загружаемые (или инсталлируемые) драйверы. Перечень ин- сталлируемых драйверов определяется специальным конфигурационным файлом CONFIG.SYS. После загрузки расширения BIOS — файла IO.SYS — последний (за- грузив модуль MSDOS.SYS) считывает файл CONFIG.SYS и уже в соответствии с ним подгружает в память необходимые драйверы. Кстати, в конфигурацион- ном файле CONFIG.SYS могут иметься и операторы, указывающие на количест- во буферов, отводимых для ускорения операций ввода/вывода, и на количество файлов, которые могут обрабатываться (для работы с файлами необходимо заре- зервировать место в памяти для хранения управляющих структур, с помощью которых выполняются операции с записями файла). В случае использования мик- ропроцессоров i80x86 и наличия в памяти драйвера HIMEM.SYS модули 10.SYS и MSDOS.SYS могут быть размещены за пределами первого мегабайта в области, которая получила название НМЛ (high memory area). Память с адресами, большими чем lOFFFFh, может быть использована в DOS- программах при выполнении их на микропроцессорах, имеющих такую возмож- ность. Так, например, микропроцессор i80286 имел 24-разрядную шину адреса, а i80386 — уже 32-разрядную шину адреса. Но для этого с помощью специаль- ных драйверов необходимо переключать процессор в другой режим работы, при котором он сможет использовать адреса выше lOFFFFh. Широкое распростране- ние получили две основные спецификации: XMS (extended memory specification) и EMS (expanded memory specification). Поскольку основные утилиты, необходи- мые для обслуживания ПК, как правило, не используют эти спецификации, мы не будем здесь их рассматривать.
92 Глава 2. Управление задачами и памятью в операционных системах Остальные программные модули MS-DOS (в принципе, большинство из них яв- ляется утилитами) оформлены как обычные исполняемые файлы. В основном они являются транзитными модулями, то есть загружаются в память только на время своей работы, хотя среди них имеются и TSR-программы. Для того чтобы предоставить больше памяти программам пользователя, в MS-DOS применено то же решение, что и во многих других простейших ОС — командный процессор COMMAND.COM сделан состоящим из двух частей. Первая часть явля- ется резидентной, она размещается в области ядра. Вторая часть — транзитная; она размещается в области старших адресов раздела памяти, выделяемой для программ пользователя. И если программа пользователя перекрывает собой об- ласть, в которой была расположена транзитная часть командного процессора, то последний при необходимости восстанавливает в памяти свою транзитную часть, поскольку после выполнения программы она возвращает управление резидент- ной части COMMAND.COM. Поскольку размер основной памяти (conventional memory) относительно неболь- шой, то очень часто системы программирования реализуют оверлейные структу- ры. Для этого в MS-DOS есть специальные вызовы. Распределение оперативной памяти в Microsoft Windows 95/98 С точки зрения базовой архитектуры ОС Windows 95/98 они обе являются 32- разрядными, многопотоковыми ОС с вытесняющей многозадачностью. Основ- ной пользовательский интерфейс этих ОС — графический. Для своей загрузки они используют операционную систему MS-DOS 7.0 (MS- DOS 98), и в случае если в файле MSDOS.SYS в секции [Options] прописано BootGUI = 0, то процессор работает в обычном реальном режиме (см. следующий раздел). Распределение памяти в MS-DOS 7.0. такое же, как и в предыдущих версиях DOS. Однако при загрузке GUI-интерфейса перед загрузкой ядра Win- dows 95/98 процессор переключается в защищенный режим работы и начинает распределять память уже с помощью страничного механизма. Использование так называемой плоской модели памяти, при которой все возмож- ные сегменты, которые может использовать программист, совпадают друг с дру- гом и имеют максимально возможный размер, определяемый системными согла- шениями данной ОС, приводит к тому, что с точки зрения программиста память получается неструктурированной. За счет представления адреса как пары (Р, i) память можно трактовать и как двумерную, то есть «плоскую», но при этом ее можно трактовать и как линейную, и это существенно облегчает создание сис- темного программного обеспечения и прикладных программ с помощью соот- ветствующих систем программирования. Таким образом, в системе фактически действует только страничный механизм преобразования виртуальных адресов в физические. Программы используют клас- сическую «small» (малую) модель памяти [73]. Каждая прикладная программа определяется 32-битными адресами, в которых сегмент кода имеет то же значе- ние, что и сегменты данных. Единственный сегмент программы отображается не- посредственно в область виртуального линейного адресного пространства, кото-
Распределение оперативной памяти в современных ОС для ПК 93 рый, в свою очередь, состоит из 4 килобайтных страниц. Каждая страница может располагаться где угодно в оперативной памяти (естественно, в том месте, куда ее разместит диспетчер памяти, который сам находится в невыгружаемой облас- ти) или может быть перемещена на диск, если не запрещено использовать стра- ничный файл. Младшие адреса виртуального адресного пространства совместно используются всеми процессами. Это сделано для обеспечения совместимости с драйверами устройств реального режима, резидентными программами и некоторыми 16-раз - рядными программами Windows. Безусловно, это плохое решение с точки зре- ния надежности, поскольку оно приводит к тому, что любой процесс может непреднамеренно (или же, наоборот, специально) испортить компоненты, нахо- дящиеся в этих адресах. В Windows 95/98 каждая 32-разрядная прикладная программа выполняется в сво- ем собственном адресном пространстве, но все они используют совместно один и тот же 32-разрядный системный код. Доступ к чужим адресным пространствам в принципе возможен. Другими словами, виртуальные адресные пространства не используют всех аппаратных средств защиты, заложенных в микропроцессор. В результате неправильно написанная 32-разрядная прикладная программа мо- жет привести к аварийному сбою всей системы. Все 16-битовые прикладные программы Windows разделяют общее адресное пространство, поэтому они так же уязвимы друг перед другом, как и в среде Windows З.Х. Системный код Windows 95 размещается выше границы 2 Гбайт. В пространстве с отметками 2 и 3 Гбайт находятся системные библиотеки DLL1, используемые несколькими программами. Заметим, что в 32-битовых микропроцессорах се- мейства i80x86 имеются четыре уровня защиты, именуемые кольцами с номера- ми от 0 до 3. Кольцо с номером 0 является наиболее привилегированным, то есть максимально защищенным. Компоненты системы Windows 95, относящиеся к кольцу 0, отображаются на виртуальное адресное пространство между 3 и 4 Гбайт. К этим компонентам относятся собственно ядро Windows, подсистема управле- ния виртуальными машинами, модули файловой системы и виртуальные драй- веры (VxD). Область памяти между 2 и 4 Гбайт адресного пространства каждой 32-разрядной прикладной программы совместно используется всеми 32-разрядными приклад- ными программами. Такая организация позволяет обслуживать вызовы API не- посредственно в адресном пространстве прикладной программы и ограничивает размер рабочего множества. Однако за это приходится расплачиваться снижени- ем надежности. Ничто не может помешать программе, содержащей ошибку, про- извести запись в адреса, принадлежащие системным DLL, и вызвать крах всей системы. В области между 2 и 3 Гбайт также находятся все запускаемые 16-разрядные прикладные программы Windows. С целью обеспечения совместимости эти про- граммы выполняются в совместно используемом адресном пространстве, где они могут испортить друг друга так же, как и в Windows 3.x. 1 DLL (dynamic link library) — динамически загружаемый библиотечный модуль.
94 Глава 2. Управление задачами и памятью в операционных системах Адреса памяти ниже 4 Мбайт также отображаются в адресное пространство каж- дой прикладной программы и совместно используются всеми процессами. Бла- годаря этому становится возможной совместимость с существующими драйвера- ми реального режима, которым необходим доступ к этим адресам. Это делает еще одну область памяти незащищенной от случайной записи. К самым нижним 64 Кбайт этого адресного пространства 32-разрядные прикладные программы обращаться не могут, что дает возможность перехватывать неверные указатели, но 16-разрядные программы, которые, возможно, содержат ошибки, могут запи- сывать туда данные. Вышеизложенную модель распределения памяти можно проиллюстрировать с помощью рис. 2.11. 4 Гб 3 Гб 2 Гб 4 Мб 64 Кб О Системные компоненты, относящися к 0 кольцу защиты Системные DLL Прикладные программы Win 16 Совместно используемые DLL Прикладные программы Win 32 Компоненты реального режима Адреса между 2 и 4 Гб отображаются в адресное пространство каждой программы Win32 и совместно используются В этой области адресного пространства каждой прикладной программы располагается свое собственное адресное > пространство. «Личные» адресные пространства других программ невидимы для программы, и, следовательно, она не может никак изменить их содержимое Эта область используется всеми процессами Рис. 2.11. Модель памяти ОС Windows 95/98 Минимально допустимый объем оперативной памяти, начиная с которого ОС Windows 95 может функционировать, равен 4 Мбайт, однако при таком объеме пробуксовка столь велика, что практически работать нельзя. Страничный файл, с помощью которого реализуется механизм виртуальной памяти, по умолчанию располагается в каталоге самой Windows и имеет переменный размер. Система отслеживает его длину, увеличивая или сокращая этот файл при необходимости. Вместе с фрагментацией файла подкачки это приводит к тому, что быстродейст- вие системы становится меньше, чем если бы файл был фиксированного размера и располагался в смежных кластерах (был бы дефрагментирован). Сделать файл подкачки заданного размера можно либо через специально разработанный для этого апплет (Панель управления ► Система ► Быстродействие ► Файловая систе- ма), либо просто прописав в файле SYSTEM.INI в секции [386Enh] строчки с указа- нием диска и имени этого файла, например:
Распределение оперативной памяти в современных ОС для ПК 95 PagingDrive=C: PagingFile=C'\PageFile sys MinPagingFi1eSize=65536 MaxPagingFi1eSize=262144 Первая и вторая строчки указывают имя страничного файла и его размещение, а две последних — начальный и предельный размер страничного файла (значе- ния указываются в килобайтах). Для определения необходимого минимального размера этого файла можно рекомендовать запустить программу SysMon1 (сис- темный монитор) и, выбрав в качестве наблюдаемых параметров размер файла подкачки и объем свободной памяти, оценить потребности в памяти, запуская те приложения, с которыми чаще всего приходится работать. Распределение оперативной памяти в Microsoft Windows NT В операционных системах Windows NT тоже используется плоская модель па- мяти. Заметим, что Windows NT 4.0 server практически не отличается от Win- dows NT 4.0 workstation; разница лишь в наличии у сервера некоторых дополни- тельных служб, дополнительных утилит для управления доменом и несколько иных значений в настройках системного реестра. Однако схема распределения возможного виртуального адресного пространства в системах Windows NT рази- тельно отличается от модели памяти Windows 95/98. Прежде всего, в отличие от Windows 95/98 в гораздо большей степени используется ряд серьезных аппарат- ных средств защиты, имеющихся в микропроцессорах, а также применено прин- ципиально другое логическое распределение адресного пространства. Во-первых, все системные программные модули находятся в своих собственных виртуальных адресных пространствах, и доступ к ним со стороны прикладных программ невозможен. Ядро системы и несколько драйверов работают в нулевом кольце защиты в отдельном адресном пространстве. Во-вторых, остальные программные модули самой операционной системы, ко- торые выступают как серверные процессы по отношению к прикладным про- граммам (клиентам), функционируют также в своем собственном системном виртуальном адресном пространстве, невидимом для прикладных процессов. Ло- гическое распределение адресных пространств приведено на рис. 2.12. Прикладным программам выделяется 2 Гбайт локального (собственного) линей- ного (неструктурированного) адресного пространства от границы 64 Кбайт до 2 Гбайт (первые 64 Кбайт полностью недоступны). Прикладные программы изо- лированы друг от друга, хотя могут общаться через буфер обмена (clipboard), ме- ханизмы DDE2 и OLE3. 1 Программа SysMon.exe входит в состав штатного программного обеспечения Win- dows 95/98, но при инсталляции этих ОС на ПК в режиме «обычная» (установка), а не «по выбору», не устанавливается. 2 DDE (Dynamic Data Exchange) — механизм динамического обмена данными. 3 OLE (Object Linking and Embedding) — механизм связи и внедрения объектов.
96 Глава 2. Управление задачами и памятью в операционных системах 4 Гб Код ядра (работает в кольце защиты с номером 0) 2 Гб DLL Win32 клиентской стороны Прикладные программы обращаются к DLL, которые перенаправляют обращение к системе 64 Кб Прикладные программы Win32 (у каждой программы свое собственное виртуальное пространство памяти) Процесс системного сервера Виртуальные машины Win 16 Этот системный код находится в своем собственном адресном пространстве и недоступен вызывающим его процессам Рис. 2.12. Модель распределения виртуальной памяти в Windows NT В верхней части каждой 2-гигабайтной области прикладной программы разме- щен код системных DLL кольца 3, который выполняет перенаправление вызовов в совершенно изолированное адресное пространство, где содержится уже собст- венно системный код. Этот системный код, выступающий как сервер-процесс (server process), проверяет значения параметров, исполняет запрошенную функ- цию и пересылает результаты назад в адресное пространство прикладной про- граммы. Хотя сервер-процесс сам по себе остается процессом прикладного уровня, он полностью защищен от вызывающей его прикладной программы и изолиро- ван от нее. Между отметками 2 и 4 Гбайт расположены низкоуровневые системные компо- ненты Windows NT кольца 0, в том числе ядро, планировщик потоков и диспетчер виртуальной памяти. Системные страницы в этой области наделены привиле- гиями супервизора, которые задаются физическими схемами кольцевой защиты процессора. Это делает низкоуровневый системный код невидимым и недоступ- ным для записи для программ прикладного уровня, но приводит к падению про- изводительности во время переходов между кольцами. Для 16-разрядных прикладных Windows-программ ОС Windows NT реализует сеансы Windows on Windows (WOW). В отличие от Windows 95/98 ОС Win- dows NT дает возможность выполнять 16-разрядные программы Windows инди- видуально в собственных пространствах памяти или совместно в разделяемом адресном пространстве. Почти во всех случаях 16- и 32-разрядные прикладные программы Windows могут свободно взаимодействовать, используя OLE, неза- висимо от того, выполняются они в отдельной или общей памяти. Собственные
Распределение оперативной памяти в современных ОС для ПК 97 прикладные программы и сеансы WOW выполняются в режиме вытесняющей многозадачности, основанной на управлении отдельными потоками. Множест- венные 16-разрядные прикладные программы Windows в одном сеансе WOW выполняются в соответствии с кооперативной моделью многозадачности. Win- dows NT может также выполнять в многозадачном режиме несколько сеансов DOS. Поскольку Windows NT имеет полностью 32-разрядную архитектуру, не существует теоретических ограничений на ресурсы GDI1 и USER. При запуске приложения создается процесс со своей информационной структу- рой. В рамках процесса запускается задача. При необходимости этот тред (задача) может запустить множество других тредов (задач), которые будут выполняться параллельно в рамках одного процесса. Очевидно, что множество запущенных процессов также выполняются параллельно и каждый из процессов может пред- ставлять из себя мультизадачное приложение. Задачи (треды) в рамках одного процесса выполняются в едином виртуальном адресном пространстве, а процес- сы выполняются в различных виртуальных адресных пространствах. Отображе- ние различных виртуальных адресных пространств исполняющихся процессов на физическую память реализует сама ОС; именно корректное выполнение этой задачи гарантирует изоляцию приложений от невмешательства процессов. Для обеспечения взаимодействия между выполняющимися приложениями и между приложениями и кодом самой операционной системы используются соответст- вующие механизмы защиты памяти, поддерживаемые аппаратурой микропро- цессора (см. следующую главу). Процессами выделения памяти, ее резервирования, освобождения и подкачки управляет диспетчер виртуальной памяти Windows NT (Windows NT virtual memory manager, VMM). В своей работе этот компонент реализует сложную стратегию учета требований к коду и данным процесса для минимизации досту- па к диску, поскольку реализация виртуальной памяти часто приводит к боль- шому количеству дисковых операций. Каждая виртуальная страница памяти, отображаемая на физическую страницу, переносится в так называемый страничный фрейм (page frame). Прежде чем код или данные можно будет переместить с диска в память, диспетчер виртуальной памяти (модуль VMM) должен найти или создать свободный страничный фрейм или фрейм, заполненный нулями. Заметим, что заполнение страниц нулями пред- ставляет собой одно из требований стандарта на системы безопасности уровня С2 , принятого правительством США. Страничные фреймы должны заполняться нулями для того, чтобы исключить возможность использования их предыдущего содержимого другими процессами. Чтобы фрейм можно было освободить, необ- ходимо скопировать на диск изменения в его странице данных, и только после этого фрейм можно будет повторно использовать. Программы, как правило, не меняют страницы кода. Страницы кода, в которые программы не внесли измене- ний, можно удалить. Диспетчер виртуальной памяти может быстро и относительно легко удовлетво- рить программные прерывания типа «ошибка страницы» (page fault). Что каса- ется аппаратных прерываний типа «ошибка страницы», то они приводят к под- 1 GDI (Graphics Device Interface) — интерфейс графических устройств.
98 Глава 2. Управление задачами и памятью в операционных системах качке (paging), которая снижает производительность системы. Мы уже говорили о том, что в Windows NT, к большому сожалению, выбрана дисциплина FIFO для замещения страниц, а не более эффективные дисциплины LRU и LFU. Когда процесс использует код или данные, находящиеся в физической памяти, система резервирует место для этой страницы в файле подкачки Pagefile.sys на диске. Это делается с расчетом на тот случай, что данные потребуется выгрузить на диск. Файл Pagefile.sys представляет собой зарезервированный блок дисково- го пространства, который используется для выгрузки страниц, помеченных как «грязные», при необходимости освобождения физической памяти. Заметим, что этот файл может быть как непрерывным, так и фрагментированным; он может быть расположен на системном диске либо на любом другом и даже на несколь- ких дисках. Размер этого страничного файла ограничивает объем данных, кото- рые могут храниться во внешней памяти при использовании механизмов вирту- альной памяти. По умолчанию размер файла подкачки устанавливается равным объему физической памяти плюс 12 Мбайт, однако пользователь имеет возмож- ность изменить его размер по своему усмотрению. Проблема нехватки виртуаль- ной памяти часто может быть решена за счет увеличения размера файла подкачки. В системах Windows NT 4.0 объекты, создаваемые и используемые приложения- ми и операционной системой, хранятся в так называемых пулах памяти (memory pools). Доступ к этим пулам может быть получен только в привилегированном режиме работы процессора, в котором работают компоненты операционной сис- темы. Поэтому для того, чтобы объекты, хранящиеся в пулах, стали видимы тре- дам приложений, эти треды должны переключиться в привилегированный режим. Перемещаемый или нерезидентный пул (paged pool) содержит объекты, которые могут быть при необходимости выгружены на диск. Неперемещаемый или рези- дентный пул (nonpaged pool) содержит объекты, которые должны постоянно на- ходиться в памяти. В частности, к такого рода объектам относятся структуры данных, используемые процедурами обработки прерываний, а также структуры, используемые для предотвращения конфликтов в мультипроцессорных системах. Исходный размер пулов определяется объемом физической памяти, доступной Windows NT. Впоследствии размер пула устанавливается динамически и в зави- симости от работающих в системе приложений и сервисов будет изменяться в широком диапазоне. Вся виртуальная память в Windows NT подразделяется на классы: зарезервиро- ванную (reserved), выделенную (committed) и доступную (available). □ Зарезервированная память представляет собой набор непрерывных адресов, которые диспетчер виртуальной памяти (VMM) выделяет для процесса, но не учитывает в общей квоте памяти процесса до тех пор, пока она не будет фактически использована. Когда процессу требуется выполнить запись в па- мять, ему выделяется нужный объем из зарезервированной памяти. Если про- цессу потребуется больший объем памяти, то дополнительная память может быть одновременно зарезервирована и использована, если в системе имеется доступная память □ Память выделена, если диспетчер VMM резервирует для нее место в файле Pagefile.sys на тот случай, когда потребуется выгрузить содержимое памяти
Контрольные вопросы и задачи 99 на диск. Объем выделенной памяти процесса характеризует фактически по- требляемый им объем памяти. Выделенная память ограничивается размером файла подкачки. Предельный объем выделенной памяти в системе (commit limit) определяется тем, какой объем памяти можно выделить процессам без увеличения размеров файла подкачки. Если в системе имеется достаточный объем дискового пространства, то файл подкачки может быть увеличен и тем самым будет расширен предельный объем выделенной памяти. Вся память, которая не является ни выделенной, ни зарезервированной, явля- ется доступной. К доступной относится свободная память, обнуленная память (освобожденная и заполненная нулями), а также память, находящаяся в списке ожидания (standby list), которая была удалена из рабочего набора процесса, но может быть затребована вновь. Контрольные вопросы и задачи Вопросы для проверки Перечислите и поясните основные функции ОС, которые связаны с управлени- ем задачами. 1. Какие дисциплины диспетчеризации задач вы знаете? Опишите их. 2. Что такое «гарантия обслуживания»? Как ее можно реализовать? 3. Сравните механизмы диспетчеризации задач в ОС Windows NT и OS/2. В чем заключаются основные различия? 4. Опишите механизм динамической диспетчеризации, реализованный в UNIX- системах. 5. Что такое «виртуальный адрес», «виртуальное адресное пространство»? Чем (в общем случае) определяется максимально возможный объем виртуального адресного пространства программы? 6. Что такое «фрагментация памяти»? Какой метод распределения памяти по- зволяет добиться минимальной фрагментации? 7. Что такое «уплотнение памяти»? Когда оно применяется? 8. Объясните сегментный способ организации виртуальной памяти. Что пред- ставляет собой (в общем случае) дескриптор сегмента? 9. Сравните сегментный и страничный способы организации виртуальной памя- ти. Перечислите достоинства и недостатки каждого. 10. Какие дисциплины применяются для решения задачи замещения страниц? Ка- кие из них являются наиболее эффективными и как они реализуются? И. Что такое «рабочее множество»? Что позволяет разрешить реализация этого понятия? 12. Изложите принципы распределения памяти в MS-DOS. 13. Что означает термин «плоская модель памяти»? В чем заключаются достоин- ства (и недостатки, если они есть) использования этой модели?
ГЛАВА 3 Особенности архитектуры микропроцессоров i80x86 В рамках данного учебного пособия мы, естественно, не будем рассматривать все многообразие современных 32-разрядных микропроцессоров, используемых в ПК и иных вычислительных системах. Здесь мы ограничимся рассмотрени- ем только архитектурных, а не технических характеристик микропроцессоров. Под обозначением i80x86 будем понимать любые 32-битовые микропроцессоры, имеющие такой же основной набор команд, как и в первом 32-битовом микро- процессоре Intel 80386, и те же архитектурные решения, что и в микропроцессо- рах фирмы Intel. Реальный и защищенный режимы работы процессора Широко известно, что первым микропроцессором, на базе которого была создана IBM PC, был Intel 8088. Этот микропроцессор отличался от первого 16-разряд- ного микропроцессора фирмы Intel — 8086 — прежде всего тем, что у него была 8-битовая шина данных, а не 16-битовая (как у 8086). Оба эти микропроцессора предназначались для создания вычислительных устройств, которые бы работали в однозадачном режиме, то есть специальных аппаратных средств для поддерж- ки надежных и эффективных мультипрограммных ОС в них не было. Однако к тому времени, когда разработчики осознали необходимость включения в микропроцессор специальной аппаратной поддержки для мультипрограммных вычислений, уже было создано очень много программных продуктов. Поэтому для совместимости с первыми компьютерами в последующих версиях микропро- цессоров была реализована возможность использовать их в двух режимах — реальном (real mode — так назвали режим работы первых 16-битовых микропро-
Реальный и защищенный режимы работы процессора 101 цессоров) и защищенном (protected mode — означает, что параллельные вычисле- ния могут быть защищены аппаратно-программными механизмами). Подробно рассматривать архитектуру первых 16-битовых микропроцессоров i8086/i8088 мы не будем, поскольку этот материал должен изучаться в предыду- щих дисциплинах учебного плана. Для тех же, кто с ним не знаком, можно реко- мендовать такие книги, как [52, 73], и многие другие. Однако напомним, что в этих микропроцессорах (а значит, и в остальных микропроцессорах семейства i80x86 при работе их в реальном режиме) обращение к памяти с возможным адресным пространством в 1 Мбайт осуществляется посредством механизма сегментной адресации (рис. 3.1). Этот механизм был использован для увели- чения количества разрядов, участвующих в указании адреса ячейки памяти, с которой в данный момент осуществляется работа, с 16 до 20 и тем самым уве- личения объема памяти. Конкретизируем задачу и ограничимся рассмотрением определения адреса ко- манды. Для адресации операндов используется аналогичный механизм, только участвуют в этом случае другие сегментные регистры. Напомним, что для опре- деления физического адреса команды содержимое сегментного регистра CS (code segment) умножается на 16 за счет добавления справа (к младшим битам) четы- рех нулей, после чего к полученному значению прибавляется содержимое указа- теля команд (регистр IP, instruction pointer). Получается двадцатибитовое зна- чение, которое и позволяет указать любой байт из 220.1 Рис. 3.1. Схема определения физического адреса для процессора 8086 В защищенном режиме работы определение физического адреса осуществляется совершенно иначе. Прежде всего используется сегментный механизм для орга- низации виртуальной памяти. При этом адреса задаются 32-битовыми значения- ми. Кроме этого, возможна страничная трансляция адресов, также с 32-битовыми 1 На самом деле, поскольку происходит именно сложение и каждое из слагаемых может иметь значение в интервале от нуля до 216-1 = 65535 = 64К, мы можем указать адрес на- чала сегмента, равный FFFFFFFF00H, и к нему прибавить смещение FFFFFFFFH. В этом случае мы получим переполнение разрядной сетки, но для современных 32-битовых про- цессоров (и для уже забытого i80286) имеется возможность указать первые 64 Кбайт выше первого мегабайта.
102 Глава 3. Особенности архитектуры микропроцессоров i80x86 значениями. Наконец, при работе в защищенном режиме, который по умолча- нию предполагает 32-битовый код, возможно исполнение двоичных программ, созданных для работы микропроцессора в 16-битовом режиме. Для этого введен режим виртуальной 16-битовой машины и 20-битовые адреса реального режима транслируются с помощью страничного механизма в 32-битовые значения защи- щенного режима. Наконец, есть еще один режим — 16-битовый защищенный, по- зволяющий 32-битовым микропроцессорам выполнять защищенный 16-битовый код, который был характерен для микропроцессора 80286. Правда, следует отме- тить, что это последний режим практически не используется, поскольку программ, созданных для него, не так уж и много. Для изучения этих возможностей рассмотрим сначала новые архитектурные воз- можности микропроцессоров i80x86. Новые системные регистры микропроцессоров 180x86 Основные регистры микропроцессора i80x86, знание которых необходимо для понимания защищенного режима работы, приведены на рис. 3.2. Следует обра- тить внимание на следующее: □ указатель команды EIP — 32 битовый регистр, младшие 16 разрядов этого ре- гистра есть регистр IP; □ регистр флагов EFLAGS — 32 бита, младшие 16 разрядов представляют ре- гистр FLAGS; □ регистры общего назначения EAX, ЕВХ, ЕСХ, EDX, а также ESP, EBP, ESI, EDI — 32-битовые, однако их младшие 16 разрядов представляют собой из- вестные регистры АХ, ВХ, СХ, DX, SP, BP, SI, DI; □ сегментные регистры CS, SS, DS, ES, FS, GS — 16-битовые. При каждом из регистров CS, SS, DS, ES, FS, GS изображены пунктиром скрытые от про- граммистов (недоступны никому, кроме собственно микропроцессора) 64-би- товые регистры, в которые загружаются дескрипторы соответствующих сег- ментов; □ регистр-указатель на локальную таблицу сегментов текущей задачи— LDTR (16 битов). При этом регистре также имеется «теневой» (скрытый от про- граммиста) 64-битовый регистр, в который микропроцессор заносит дескрип- тор, указывающий на таблицу дескрипторов сегментов задачи, описывающих ее локальное виртуальное адресное пространство; □ регистр-указатель задачи TR1 (16 битов). Указывает на дескриптор в глобаль- ной таблице дескрипторов, позволяющий получить доступ к дескриптору за- дачи TSS2 — информационной структуре, которую поддерживает микропро- цессор для управления задачами; 1 TR — task register. 2 TSS — task state segment.
Новые системные регистры микропроцессоров 180x86 103 □ регистр GDTR1 (48 битов) глобальной таблицы GDT, содержащей как деск- рипторы общих сегментов, так и специальные системные дескрипторы. В ча- стности, в GDTR находятся дескрипторы, с помощь которых можно получить доступ к сегментам TSS; □ регистр IDTR (48 битов) таблицы дескрипторов прерываний. Содержит ин- формацию, необходимую для доступа к «таблице прерываний» IDT; □ управляющие регистры CR0 — CR3 (32-битовые) и некоторые другие регистры. EIP IP EFLAGS FLAGS 16 8 0 16 8 0 EAX AX ESP SP EBX BX EBP BP ECX CX ESI SI EDX DX EDI DI 15 0 63 47 15 CS Права доступа Базовый адрес T 1 Длина сегмента; SS । । । । DS । । । । ES — । । । । FS — j j , । । । GS .48. 16 .ft! 1 1 1 LDTR TR ! ! ! GDTR IDTR Базовый адрес Длина сегмента 16 0 CR3 CR2 CR1 CRO Рис. 3.2. Основные системные регистры микропроцессоров 180x86 1 GDTR — global descriptor table register.
104 Глава 3. Особенности архитектуры микропроцессоров i80x86 Управляющий регистр CR0 содержит целый ряд флагов, которые определяют режимы работы микропроцессора. Подробно об этих флагах можно прочитать в книгах [2, 22, 48]. Мы же просто ограничимся тем фактом, что самый млад- ший бит (РЕ, protect enable) этого регистра определяет режим работы процессо- ра. При РЕ=О процессор функционирует в реальном режиме работы, а при еди- ничном значении микропроцессор переключается в защищенный режим. Самый старший бит регистра CR0 (бит PG, paging) определяет, включен (PG=1) или нет (PG=0) режим страничного преобразования адресов. Регистр CR2 предназначен для размещения в нем адреса подпрограммы обра- ботки страничного исключения, то есть в случае использования страничного механизма отображения памяти обращение к отсутствующей странице будет вы- зывать переход на соответствующую подпрограмму диспетчера памяти, и для определения этой подпрограммы будет задействован регистр CR2. Регистр CR3 содержит номер физической страницы, в которой располагается таблица каталогов таблиц страниц текущей задачи. Очевидно, что, приписав к этому номеру нули, мы попадем на начало этой страницы. Адресация в 32-разрядных микропроцессорах i80x86 при работе в защищенном режиме Поддержка сегментного способа организации виртуальной памяти Как мы уже знаем, для организации эффективной и надежной работы вычисли- тельной системы в мультипрограммном режиме необходимо иметь соответст- вующие аппаратные механизмы, поддерживающие независимость адресных про- странств каждой задачи и в то же время позволяющие организовать обмен данными и разделение кода. Для этого желательно выполнение следующих двух требований: □ чтобы у каждого вычислительного процесса могло быть свое собственное (лич- ное, локальное) адресное пространство, которое никак не может пересекаться с адресными пространствами других задач; □ чтобы существовало общее (разделяемое) адресное пространство. Поэтому в микропроцессорах i80x86 реализован сегментный способ организации распределения памяти. Помимо этого, в этих микропроцессорах может быть за- действована и страничная трансляция. Поскольку для каждого сегмента нужен дескриптор, устройство управления памятью поддерживает соответствующую информационную структуру. Формат дескриптора сегмента приведен на рис. 3.3. Поля дескриптора (базовый адрес, поле предела) размещены в дескрипторе не непрерывно, а в разбивку, во-первых, из-за того, что разработчики постарались минимизировать количество перекрестных соединений в полупроводниковой
Адресация в 32-разрядных микропроцессорах i80x86 105 структуре микропроцессора, а во-вторых — вследствие необходимости обеспе- чить полную совместимость1 микропроцессоров (предыдущий микропроцессор i80286 работал с 16-битовым кодом и тоже поддерживал сегментный механизм реализации виртуальной памяти). Необходимо заметить, что формат дескрипто- ра сегмента, изображенный на рис. 3.3, справедлив только для случая нахожде- ния соответствующего сегмента в оперативной памяти. Если же бит присутствия в поле прав доступа равен нулю (сегмент отсутствует в памяти), то все биты, за исключением поля прав доступа, считаются неопределенными и могут использо- ваться системными программистами (для указания адреса сегмента во внешней памяти) произвольным образом. Первое (младшее) двойное слово дескриптора Рис. 3.3. Дескриптор сегмента Локальное адресное пространство задачи определяется через таблицу LDT (local descriptor table). У каждой задачи может быть свое локальное адресное про- странство. Общее или глобальное адресное пространство определяется через таб- лицу GDT (global descriptor table). Само собой, что работу с этими таблицами (их заполнение и последующую модификацию) должна осуществлять операци- онная система. Доступ к таблицам LDT и GDT со стороны прикладных задач должен быть исключен. При переключении микропроцессора в защищенный режим он начинает совер- шенно другим образом, чем в реальном режиме, вычислять физические адреса команд и операндов. Прежде всего, содержимое сегментных регистров интерпре- тируется иначе: считается, что там содержится не адрес начала сегмента, а номер соответствующего сегмента. Для того чтобы подчеркнуть этот факт, сегментные регистры CS, SS, DS, ES, FS, GS в таком случае даже называются иначе — селек- торами сегментов. При этом каждый селекторный регистр разбивается на сле- дующие три поля (рис. 3.4): □ поле индекса (index) — старшие 13 битов (3-15). Определяет собственно но- мер сегмента (его индекс в соответствующей таблице дескрипторов); □ поле индикатора таблицы сегментов (table index, TI) — бит с номером 2. Оп- ределяет часть виртуального адресного пространства (общее или принадле- 1 Естественно, совместимость обеспечена только «снизу вверх», то есть программы, разра- ботанные для предыдущих версий микропроцессора, должны выполняться на последую- щих без какой-либо переделки.
106 Глава 3. Особенности архитектуры микропроцессоров 180x86 жащее только данной задаче). Если Т1в0, то Index указывает на элемент в глобальной таблице дескрипторов GDT, то есть идет обращение к общей па- мяти. Если TI-1, то идет обращение к локальной области памяти текущей за- дачи; это пространство описывается локальной таблицей дескрипторов LDT; □ поле уровня привилегий — биты 0 и 1. Указывает запрашиваемый уровень привилегий (RPL, requested privilege level). Операционная система в процессе своего запуска инициализирует многие реги- стры и, прежде всего, GDTR. Этот регистр содержит начальный адрес глобаль- ной таблицы дескрипторов (GDT) и ее размер. Как мы уже знаем, в GDT нахо- дятся дескрипторы глобальных сегментов и системные дескрипторы. Index (номер дескриптора) TI RPL Рис. 3.4. Селектор сегмента Для манипулирования задачами ОС имеет информационную структуру, кото- рую мы уже определили как дескриптор задачи (см. раздел «Понятия вычисли- тельного процесса и ресурса», глава 1). Микропроцессор поддерживает работу с наиболее важной частью дескриптора задачи, которая меньше всего зависит от операционной системы. Эта инвариантная часть дескриптора, с которой и рабо- тает микропроцессор, названа сегментом состояния задачи (task state segment, TSS). Перечень полей TSS изображен на рис. 3.5. Видно, что в основном этот сегмент содержит контекст задачи. Процессор получает доступ к этой структуре с помощью регистра задачи (task register, TR). Регистр TR содержит индекс (селектор) элемента в GDT. Этот элемент пред- ставляет собой дескриптор сегмента TSS. Дескриптор заносится в теневую часть регистра (см. рис. 3.2). К рассмотрению TSS мы еще вернемся, а сейчас заметим, что в одном из полей TSS содержится указатель (селектор) на локальную табли- цу дескрипторов данной задачи. При переходе процессора с одной задачи на дру- гую содержимое поля LDTR заносится микропроцессором в одноименный ре- гистр. Инициализировать регистр TR можно и явным образом. Итак, регистр LDTR содержит селектор, указывающий на один из дескрипторов глобальной таблицы GDT. Этот дескриптор заносится микропроцессором в те- невую часть регистра LDTR и описывает таблицу LDT для текущей задачи. Те- перь, когда у нас определены как глобальная, так и локальная таблица дескрип- торов, можно рассмотреть процесс определения линейного1 адреса. Для примера рассмотрим процесс получения адреса команды. Адреса операндов определяют- ся по аналогии, но задействованы будут другие регистры. Микропроцессор анализирует бит TI селектора кода и в зависимости от его зна- чения, извлекает из таблицы GDT или LDT дескриптор сегмента кода с номером (индексом), который равен полю index (биты 3—15 селектора, см. рис. 3.4). Этот 1 В микропроцессорах i80x86 линейным называется адрес, полученный в результате пре- образования виртуального адреса формата (S, d) в 32-битовый адрес.
Адресация в 32-разрядных микропроцессорах i80x86 107 дескриптор заносится в теневую (скрытую) часть регистра CS. Далее микропро- цессор сравнивает значение регистра EIP1 с полем размера сегмента, содержаще- гося в извлеченном дескрипторе, и если смещение относительно начала сегмента не превышает размера предела, то значение EIP прибавляется к значению поля начала сегмента и мы получаем искомый линейный адрес команды. Линейный адрес — это одна из форм виртуального адреса. Исходный двоичный виртуаль- ный адрес, вычисляемый в соответствии с используемой адресацией, преобразу- ется в линейный. В свою очередь, линейный адрес будет либо равен физическо- му (если страничное преобразование отключено), либо с помощью страничной трансляции преобразуется в физический адрес. Если же смещение из регистра EIP превышает размер сегмента кода, то эта аварийная ситуация вызывает пре- рывание и управление должно передаваться супервизору ОС. Поля, определяемые ОС (их количество и состав может быть любым) 31______________________16 . 15______________________О Адрес карты ввода/вывода LDTR Сегментные регистры ES, CS, SS, DS, FS, GS (на каждый регистр отведено по 4 байта, из которых используются только 2 младших) Общие регистры (EAX, ЕСХ, EDX, ЕВХ, ESP, EBP, ESI, EDI) Регистр флагов EFLAGS Указатель команд (регистр EIP) Привилегированные указатели стеков | Link 68h 60h 48h 28h 24h 20h 4 0 Рис. 3.5. Сегмент состояния задачи (TSS) Собственно TSS (108 байтов) Рассмотренный нами процесс получения линейного адреса проиллюстрирован на рис. 3.6. Стоит отметить, что поскольку межсегментные переходы происходят нечасто, то, как правило, определение линейного адреса заключается только в сравнении значения EIP с полем предела сегмента и в прибавлении смещения к началу сегмента. Все необходимые данные уже находятся в микропроцессоре, и операция получения линейного адреса происходит очень быстро. Итак, линейный адрес может считаться физическим адресом, если не включен режим страничной трансляции адресов. Аппаратные средства микропроцессора для поддержки рассмотренного способа двойной трансляции виртуальных адре- 1 EIP (extended instruction pointer) — указатель инструкции (команды).
108 Глава 3. Особенности архитектуры микропроцессоров i80x86 сов в физические явно недостаточны, и при наличии большого количества не- больших сегментов приводят к медленной работе. В самом деле, теневой регистр при каждом селекторе имеется в единственном экземпляре, и при переходе на другой сегмент потребуется вновь находить и извлекать соответствующий деск- риптор сегмента, а это требует времени. Страничный же способ трансляции виртуальных адресов, как мы знаем, имеет немало своих достоинств. Поэтому в защищенном режиме работы, при котором всегда действует описанный выше механизм определения линейных адресов, может быть включен еще и странич- ный механизм. Рис. 3.6. Процесс получения линейного адреса команды Поддержка страничного способа организации виртуальной памяти При создании микропроцессора i80386 разработчики столкнулись с очень серь- езной проблемой в реализации страничного механизма. Дело в том, что микро- процессор имеет широкую шину адреса — 32 бита — и возникает вопрос о раз- биении всего адреса на поле страницы и поле индекса. Если большое количество битов адреса отвести под индекс, то страницы станут очень большими, что по- влечет большие потери и на фрагментацию, и на операции ввода/вывода, связан- ные с замещением страниц. Хотя количество страниц стало бы при этом меньше, и накладные расходы на их поддержание тоже уменьшились бы. Если же размер
Адресация в 32-разрядных микропроцессорах i80x86 109 страницы уменьшить, то большое поле номера страницы привело бы к появле- нию громадного количества возможных страниц и необходимо было либо вво- дить какие-то механизмы контроля за номером страницы (с тем, чтобы он не вы- ходил за размеры таблицы страниц), либо создавать эти таблицы максимально возможного размера. Разработчики пошли по пути, при котором размер страни- цы все же небольшой (он выбран равным 212 = 4096 = 4К), а поле номера страни- цы величиной в 20 битов, в свою очередь, разбивается на два поля и осуществля- ется двухэтапная (двухшаговая) страничная трансляция. Для описания каждой страницы создается соответствующий дескриптор. Длина дескриптора выбрана равной 32 битам: 20 битов линейного адреса определяют номер страницы (по существу — ее адрес, поскольку добавление к нему (припи- сывание в качестве младших разрядов) 12 нулей приводит к определению началь- ного адреса страницы), а остальные биты разбиты на следующие поля, которые изображены на рис. 3.7. Как видно, три бита дескриптора зарезервированы для использования системными программистами при разработке подсистемы орга- низации виртуальной памяти. С этими битами микропроцессор сам не работает. Прежде всего, микропроцессор анализирует самый младший бит дескриптора — бит присутствия, ибо если поле present равно нулю, то это означает отсутствие данной страницы в оперативной памяти, и такая ситуация влечет прерывание в работе процессора с передачей управления соответствующей программе, которая должна будет загрузить затребованную страницу. Бит dirty — «грязный» — пред- назначен для отметки, что данную страницу модифицировали и при замещении этого страничного кадра следующим ее необходимо сохранить во внешней памя- ти. Бит обращения (access) свидетельствует о том, что к данной таблице или странице осуществлялся доступ. Он используется для определения страницы, которая будет участвовать в замещении при использовании дисциплин LRU или LFU. Наконец, первый и второй биты используются для защиты памяти. Старшие 10 битов линейного адреса определяют номер таблицы страниц (page table entry, РТЕ), из которой посредством вторых 10 битов линейного адреса вы- бирается соответствующий дескриптор виртуальной страницы. И уже из этого дескриптора выбирается номер физической страницы, если данная виртуальная страница отображена сейчас на оперативную память. Эта схема определения фи- зического адреса по линейному изображена на рис. 3.8. Первая таблица, которую мы индексируем первыми (старшими) 10 битами ли- нейного адреса, названа таблицей каталогов таблиц страниц (page directory entry, PDE). Ее адрес в оперативной памяти определяется старшими 20 битами управ- ляющего регистра CR3.
110 Глава 3. Особенности архитектуры микропроцессоров i80x86 Линейный адрес Регистр СгЗ Рис. 3.8. Трансляция линейного адреса в микропроцессорах i80x86 Каждая из таблиц PDE и РТЕ состоит из 1024 элементов (210es1024). В свою оче- редь, каждый элемент (дескриптор страницы) имеет длину 4 байта (32 бита), по- этому размер этих таблиц как раз соответствует размеру страницы. Оценим теперь эту двухшаговую схему трансляции с позиций расхода памяти. Каждый дескриптор описывает страницу размером 4 Кбайт. Следовательно, одна таблица страниц, содержащая 1024 дескриптора, описывает пространство памя- ти в 4 Мбайт. Если наша задача пользуется виртуальным адресным пространст- вом, например, в 50 Мбайт (предположим, что речь идет о некотором графическом редакторе, который обрабатывает изображение, состоящее из большого количе- ства пикселов1), то для описания этой памяти необходимо иметь 14 страниц, со- держащих таблицы РТЕ. Кроме этого, нам потребуется для этой задачи еще одна таблица PDE (тоже размером в одну страницу), в которой 14 дескрипторов бу- дут указывать на местонахождение упомянутых таблиц РТЕ. Остальные деск- рипторы PDE могут быть не задействованы. Итого, для описания 50 Мбайт адресного пространства задачи потребуется всего 15 страниц, то есть 60 Кбайт памяти, что можно считать приемлемым. Если бы не был использован такой двухшаговый механизм трансляции, то потери памяти на описание адресного пространства могли бы составить 4(Кбайт) • 210 - в 4 (Мбайт)! Очевидно, что это уже неприемлемое решение., Итак, микропроцессор для каждой задачи, для которой у него есть TSS, позволяет иметь таблицу PDE и некоторое количество РТЕ. Поскольку это дает возмож- 1 Напомним, что термин пиксел происходит от английского picture element (графический элемент). Множество пикселов образуют изображение.
Адресация в 32-разрядных микропроцессорах i80x86 111 ность адресоваться к любому байту из 232, а шина адреса как раз и позволяет ис- пользовать физическую память с таким объемом, то можно как бы отказаться от сегментного способа адресации. Другими словами, если считать, что задача состоит из одного единственного сегмента, который, в свою очередь, разбит на страницы, то фактически мы получаем только один страничный механизм, рабо- ты с виртуальной памятью. Этот подход получил название «плоской памяти». При использовании плоской модели памяти упрощается создание и операцион- ных систем, и систем программирования. Кроме этого, уменьшаются расходы памяти для поддержки системных информационных структур. Поэтому в абсо- лютном большинстве современных 32-разрядных ОС, создаваемых для микро- процессоров i80x86, используется плоская модель памяти. Режим виртуальных машин для исполнения приложений реального режима Разработчики рассматриваемого семейства микропроцессоров в своем стремле- нии обеспечить максимально возможную совместимость архитектуры пошли не только на то, чтобы обеспечить возможность программам, созданным для первых 16-разрядных ПК, без проблем выполняться на компьютерах с более поздними моделями микропроцессоров за счет введения реального режима работы. Они также обеспечили возможность выполнения 16-разрядных приложений реального режима при условии, что сам процессор при этом функционирует в защищенном режиме работы и операционная система, используя соответствующие аппаратные средства микропроцессора, организует мультипрограммный (мультизадачный) режим. Другими словами, микропроцессоры i80x86 поддерживают возможность создания операционных сред реального режима при работе микропроцессора в защищенном режиме. Если условно назвать 16-разрядные приложения DOS- приложениями (поскольку в абсолютном большинстве случаев это именно так), то можно сказать, что введена поддержка для организации виртуальных DOS- машин, работающих вместе с обычными 32-битовыми приложениями защищен- ного режима. Это даже нашло отражение в названии режима работы микропро- цессоров i80x86 — режим виртуального процессора i8086, иногда (для краткости) его называют режимом V86 или просто виртуальным режимом, — при котором в защищенном режиме работы может исполняться код DOS-приложения. Муль- тизадачность при выполнении нескольких программ реального режима будет поддержана аппаратными средствами защищенного режима. Переход в виртуальный режим осуществляется посредством изменения бита VM (virtual mode) в регистре EFLAGS. Когда процессор находится в виртуальном режиме, для адресации памяти используется схема реального режима работы — (сегмент: смещение) с размером сегментов до 64 Кбайт, которые могут распола- гаться в адресном пространстве размером в 1 Мбайт, однако полученные адреса считаются не физическими, а линейными. В результате применения страничной трансляции осуществляется отображение виртуального адресного пространства 16-битового приложения на физическое адресное пространство. Это позволяет организовать параллельное выполнение нескольких задач, разработанных для реального режима, да еще и совместно с обычными 32-битовыми приложениями, требующих защищенного режима работы.
112 Глава 3. Особенности архитектуры микропроцессоров 180x86 Естественно, что для обработки прерываний, возникающих при выполнении 16- битовых приложений в виртуальном режиме, процессор возвращается из этого режима в обычный защищенный режим. В противном случае невозможно было бы организовать полноценную виртуальную машину. Очевидно, что обработчи- ки прерываний для виртуальной машины должны эмулировать работу подсисте- мы прерываний процессора i8086. Другими словами, прерывания отображаются в операционную систему, работающую в защищенном режиме, и уже основная ОС моделирует работу операционной среды выполняемого приложения. Вопрос, связанный с операциями ввода/вывода, которые недоступны для обыч- ных приложений (см. следующую главу), решается аналогично. При попытке выполнить недопустимые команды ввода/вывода возникают прерывания, и не- обходимые операции выполняются операционной системой, хотя задача об этом и «не подозревает». При выполнении команд IN, OUT, INS, OUTS, CLI, STI процессор, находящийся в виртуальном режиме и исполняющий код на уровне привилегий третьего (самого нижнего) кольца защиты, за счет возникающих вследствие этого прерываний переводится на выполнение высоко привилегированного кода опе- рационной системы. Таким образом, ОС может полностью виртуализировать ресурсы компьютера: и аппаратные1, и программные, создавая иную полноценную операционную сре- ду; при существовании так называемых нативных приложений, создаваемых по собственным спецификациям данной ОС. Очень важным моментом для органи- зации полноценной виртуальной машины является реализация виртуализации не только программных, но и аппаратных ресурсов. Так, например, в ОС Win- dows NT эта задача выполнена явно неудачно, тогда как в OS/2 имеется полно- ценная виртуальная машина как для DOS-приложений, так и для приложений, работающих в среде спецификаций Winl6. Правда, в последнее время это уже перестало быть актуальным, поскольку появилось большое количество приложе- ний, работающих по спецификациям Win32 API. Защита адресного пространства задач Для возможности создания надежных мультипрограммных ОС в процессорах се- мейства i80x86 имеется несколько механизмов защиты. Это и разделение адрес- ных пространств задач, и введение уровней привилегий для сегментов кода и сегментов данных. Все это позволяет обеспечить как защиту задач друг от друга, так и защиту самой операционной системы от прикладных задач, защиту одной части ОС от других ее компонентов, защиту самих задач от некоторых своих собственных ошибок. Защита адресного пространства задач осуществляется относительно легко за счет того, что каждая задача может иметь свое собственное локальное адресное про- странство. Операционная система должна корректно манипулировать табли- цами трансляции сегментов (дескрипторными таблицами) и таблицами транс- 1 Речь идет о памяти, портах ввода/вывода, системе обработки прерываний и других архи- тектурных особенностях.
Защита адресного пространства задач 113 ляции страничных кадров. Сами таблицы дескрипторов как сегменты данных (а соответственно, в свою очередь, и как страничные кадры) относятся к адрес- ному пространству операционной системы и имеют соответствующие привилегии доступа; исправлять их задачи не могут. Этими информационными структурами процессор пользуется сам, на аппаратном уровне, без возможности их читать и редактировать из пользовательских приложений. Если используется модель плоской памяти, то возможность микропроцессора контролировать обращения к памяти только внутри текущего сегмента фактически не используется, и оста- ется в основном только механизм отображения страничных кадров. Выход за пределы страничного кадра невозможен, поэтому фиксируется только выход за пределы своего сегмента. В этом случае приходится полагаться только на сис- тему программирования, которая должна корректно распределять программные модули в пределах единого неструктурированного адресного пространства зада- чи. Поэтому при создании многопоточных приложений, когда каждая задача (в данном случае — поток) может испортить адресное пространство другой зада- чи, эта проблема становится очень сложной, особенно если не использовать сис- темы программирования на языках высокого уровня. Однако для организации взаимодействия задач, имеющих разные виртуальные адресные пространства, необходимо, как мы уже говорили, иметь общее адресное пространство. И здесь, для обеспечения защиты самой ОС, а значит, и повыше- ния надежности всех вычислений, используется механизм защиты сегментов с помощью уровней привилегий. Уровни привилегий для защиты адресного пространства задач Для того чтобы запретить пользовательским задачам модифицировать области памяти, принадлежащие самой ОС, необходимо иметь специальные средства. Одного разграничения адресных пространств через механизм сегментов мало, ибо можно указывать различные значения адреса начала сегмента и тем самым получать доступ к чужим сегментам. Другими словами, необходимо в явном виде разграничивать системные сегменты данных и кода от сегментов, принадлежа- щих пользовательским программам. Поэтому были введены два основных режи- ма работы процессора: пользователя и супервизора. Большинство современных процессоров имеют по крайней мере два этих режима. Так, в режиме супервизора программа может выполнять все действия и иметь доступ по любым адресам, тогда как в пользовательском режиме должны быть ограничения, с тем чтобы обнаруживать и пресекать запрещенные действия, перехватывая их и передавая управление супервизору ОС. Часто в пользовательском режиме запрещается вы- полнение команд ввода/вывода и некоторых других, чтобы гарантировать, что только ОС выполняет эти операции. Можно сказать, что эти два режима имеют разные уровни привилегий. В микропроцессорах i80x86 имеются не два, а четыре уровня привилегий. Часто уровни привилегий называют кольцами защиты, поскольку это иногда помогает объяснить принцип действия самого механизма; поэтому говорят, что некоторый программный модуль «исполняется в кольце защиты с таким-то номером». Для
114 Глава 3. Особенности архитектуры микропроцессоров i80x86 указания уровня привилегий используются два бита, поэтому код 00 обознача- ет самый высший уровень, а код 11(2) (=3) — самый низший. Самый высокий уровень привилегий предназначен для операционной системы (прежде всего, для ядра ОС), самый низкий — для прикладных задач пользователя. Промежуточ- ные уровни привилегий введены для большей свободы системных программистов в организации надежных вычислений при создании ОС и иного системного ПО. Предполагалось, что уровень с номером (кодом) 1 может быть использован, на- пример, для системного сервиса — программ обслуживания аппаратуры, драйве- ров, работающих с портами ввода/вывода. Уровень привилегий с кодом 2 может быть использован для создания пользовательских интерфейсов, систем управле- ния базами данных и т. п., то есть для реализации специальных системных функ- ций, которые по отношению к супервизору ОС ведут себя как обычные приложе- ния. Так, например, система OS/2 использует три уровня привилегий: с нулевым уровнем привилегий исполняется код самой ОС, на втором уровне исполняются системные процедуры подсистемы ввода/вывода, на третьем уровне исполня- ются прикладные задачи пользователей. Однако чаще всего на практике исполь- зуются только два уровня — нулевой и третий. Таким образом, упомянутый ре- жим супервизора для микропроцессоров i80x86 соответствует выполнению кода с уровнем привилегий 0 (его обозначают так: PL01). Подводя итог, можно кон- статировать, что именно уровень привилегий задач определяет, какие команды в них можно использовать и какое подмножество сегментов и/или страниц в их адресном пространстве они могут обрабатывать. Основными системными объектами, которыми манипулирует процессор при ра- боте в защищенном режиме, являются дескрипторы. Дескрипторы сегментов содержат информацию об уровне привилегии соответствующего сегмента кода или данных. Уровень привилегии исполняющейся задачи определяется значени- ем поля привилегии, находящегося в дескрипторе ее текущего кодового сегмен- та. Напомним, что в каждом дескрипторе сегмента (см. рис.3.3) имеется поле DPL в байте прав доступа, которое и определяет уровень привилегии связанного с ним сегмента. Таким образом, поле DPL текущего сегмента кода становится полем CPL. При обращении к какому-нибудь сегменту в соответствующем се- лекторе указывается запрашиваемый уровень привилегий RPL2 (см. рис. 3.4). В пределах одной задачи используются сегменты с различным уровнем приви- легии и в определенные моменты времени выполняются или обрабатываются сегменты с соответствующими им уровнями привилегии. Механизм проверки привилегий работает в ситуациях, которые можно назвать межсегментными пе- реходами (обращениями). Это доступ к сегменту данных или стековому сегмен- ту, межсегментные передачи управления в случае прерываний (и особых ситуа- ций), при использовании команд CALL, JMP, INT, IRET, RET. В таких межсегментных обращениях участвуют два сегмента: целевой сегмент (к которому мы обращаем- ся) и текущий сегмент кода, из которого идет обращение. 1 PL (privilege level) — уровень привилегий. 2 RPL (requested privilege level) — запрашиваемый уровень привилегий. Поле RPL опреде- ляется программистом (системой программирования). В отличие от поля DPL поле RPL легко может быть изменено.
Защита адресного пространства задач 115 Процессор сравнивает упомянутые значения CPL, RPL, DPL и на основе поня- тия эффективного уровня привилегий1 (EPL = max (RPL, DPL)) ограничивает воз- можности доступа к сегментам по следующим правилам, в зависимости от того, идет ли речь об обращении к коду или к данным. При доступе к сегментам данным проверяется условие CPL < EPL. Нарушение этого условия вызывает так называемую особую ситуацию ошибки защиты и возникает прерывание. Уровень привилегии сегмента данных, к которому осу- ществляется обращение, должен быть таким же, как и текущий уровень, или меньше его. Обращение к сегменту с более высоким уровнем привилегии вос- принимается как ошибка, так как существует опасность изменения данных с вы- соким уровнем привилегий в программе с низким уровнем привилегии. Доступ к данным с меньшим уровнем привилегии разрешается. Если целевой сегмент является сегментом стека, то правило проверки имеет вид CPL = DPL = RPL. В случае его нарушения также возникает исключение. Поскольку стек может ис- пользоваться в каждом сегменте кода и всего имеются четыре уровня привиле- гий кода, то используются и четыре стека. Сегмент стека, адресуемый регистром SS, должен иметь тот же уровень привилегий, что и текущий сегмент кода. Правила для передачи управления, то есть когда осуществляется межсегмент- ный переход с одного сегмента кода на другой сегмент кода, несколько сложнее. Если для перехода с одного сегмента данных на другой сегмент данных считает- ся допустимым обрабатывать менее привилегированные сегменты, то передача управления из высоко привилегированного кода на менее привилегированный код должна контролироваться дополнительно. Другими словами, код операци- онной системы не должен доверять коду прикладных задач. И обратно, нельзя просто так давать задачам возможность исполнять высоко привилегированный код, хотя потребность в этом всегда имеется (ведь многие функции, в том числе и функции ввода/вывода, считаются привилегированными и должны выпол- няться только самой ОС). Для реализации возможностей передачи управления в сегменты кода с иными уровнями привилегий введен механизм шлюзования, который мы вкратце рассмотрим ниже. Итак, если DPL = CPL, то переход в дру- гой сегмент кода возможен. Более подробное рассмотрение затронутых вопросов по замыслу авторов выходит за рамки настоящего учебника (для получения бо- лее детальных сведений по этому и некоторым другим вопросам особенностей архитектуры микропроцессоров i80x86 рекомендуется обратиться к материалам [1, 8]). Здесь мы рассмотрим только основные идеи. Механизм шлюзов для передачи управления на сегменты кода с другими уровнями привилегий Поскольку межсегментные переходы контролируются с использованием уровней привилегий и существует потребность в передаче управления с одного уровня 1 EPL (effective privilege level) — эффективный уровень привилегий. Значение эффектив- ного уровня привилегий определяется минимальной привилегией, то есть как макси- мальное значение из двух: RPL и DPL.
116 Глава 3. Особенности архитектуры микропроцессоров i80x86 привилегий на другой уровень, в микропроцессорах i80x86 реализован механизм шлюзов, который мы поясним с помощью рис. 3.9. Шлюзование позволяет орга- низовать обращение к так называемым подчиненным сегментам кода, которые выполняют часто встречающиеся функции и должны быть доступны многим за- дачам, располагающимся на том же или более низком уровне привилегии. Уровень привилегий О Адресное пространство программных модулей ОС Запрашиваемый программный модуль Уровень привилегий 1 Уровень привилегий 2 Уровень привилегий 3 Адресное пространство процесса А Шлюз Адресное пространство процесса В Рис. 3.9. Механизм шлюзов для перехода на другой уровень привилегий Помимо дескрипторов сегментов системными объектами, с которыми работает микропроцессор, являются специальные системные дескрипторы, названные шлю- зами или вентилями. Главное различие между дескриптором сегмента и шлюзом вызова заключается в том, что содержимое дескриптора указывает на сегмент в памяти, а шлюз обращается к дескриптору. Другими словами, если дескриптор служит механизмом отображения памяти, то шлюз служит механизмом перена- правления. Для получения доступа к более привилегированному коду задача должна обра- титься к нему не непосредственно (путем указания дескриптора этого кода), а обращением к шлюзу этого сегмента (рис. 3.10). В этом дескрипторе вместо адреса сегмента указываются селектор, позволяющий найти дескриптор искомого сегмента кода, и адрес (смещение назначения), с ко- торого будет выполняться подчиненный сегмент, то есть полный 32-битный ад- рес. Формат дескриптора шлюза приведен на рис. 3.11. Адресовать шлюз вызова можно с помощью команды CALL1. По существу, дескрипторы шлюзов вызова не являются дескрипторами (сегментов), но они могут быть расположены среди обычных дескрипторов в дескрипторных таблицах процесса. Смещение, указы- ваемое в команде перехода на другой сегмент (FAR CALL), игнорируется, и факти- 1 FAR CALL — межсегментный вызов процедуры.
Защита адресного пространства задач 117 чески осуществляется переход на команду, адрес которой определяется через смещение из шлюза вызова. Этим гарантируется попадание только на разрешен- ные точки входа в подчиненные сегменты. CALL Рис. 3.10. Переход на сегмент более привилегированного кода Старшее двойное слово дескриптора 31 18 15 14 13 12 11 8 7 О Смещение назначения (биты 31-16) Р DPL 0 1100 ООО Счетчик DWORD Байт прав доступа Селектор сегмента назначения Смещение назначения (биты 15-0) 31 18 15 О Первое (младшее) двойное слово дескриптора Рис. 3.11. Формат дескриптора шлюза Введены следующие правила использования шлюзов'. □ значение DPL шлюза вызова должно быть больше или равно значению теку- щего уровня привилегий CPL; □ значение DPL шлюза вызова должно быть больше или равно значению поля RPL селектора шлюза; □ значение DPL шлюза вызова должно быть больше или равно значению DPL целевого сегмента кода; □ значение DPL целевого сегмента кода должно быть меньше или равно значе- нию текущего уровня привилегий CPL. Требование наличия и доступности шлюза вызова для перехода на более приви- легированный код ограничивает менее привилегированный код заданным набо-
118 Глава 3. Особенности архитектуры микропроцессоров 180x86 ром точек входа в код с большей привилегией. Так как шлюзы вызова являются элементами в дескрипторных таблицах (а мы говорили, что их не только можно, но и желательно там располагать), то менее привилегированная программа не может создать дополнительных (а значит, и неконтролируемых) шлюзов. Таким образом, рассмотренный механизм шлюзов дает следующие преимущества в ор- ганизации среды для выполнения надежных вычислений: □ привилегированный код надежно защищен и вызывающие его программы не могут его разрушить. Естественно, что такой системный код должен быть особенно тщательно отлаженным, не содержать ошибок, быть максимально эффективным; □ шлюзы межсегментных переходов для вызова системных функций делают эти самые системные функции невидимыми для программных модулей, рас- положенных на внешних (более низких) уровнях привилегий; □ поскольку вызывающая программа непосредственно адресует только шлюз вызова, реализуемые вызываемым модулем (сегментным кодом) функции можно изменить или переместить в адресном пространстве, не затрагивая ин- терфейс со шлюзом; □ программные модули вызываются с более привилегированного уровня. Изложенный коротко аппаратный механизм защиты по привилегиям оказывает- ся довольно сложным и жестким. Однако поскольку все практические ситуации учесть в схемах микропроцессора невозможно, то при разработке процедур опе- рационных систем и иного высоко привилегированного кода следует придержи- ваться приведенных ниже рекомендаций, заимствованных из книги [22]. Основной риск связан с передачей управления через шлюз вызова более приви- легированной процедуре. Нельзя предоставлять вызывающей программе ника- ких преимуществ, вытекающих из-за временного повышения привилегий. Это замечание особенно важно для процедур нулевого уровня привилегий (PLO-npo- цедур). Вызывающая программа может нарушить работу процедуры, передавая ей «пло- хие» параметры. Поэтому целесообразно как можно раньше проконтролировать передаваемые процедуре параметры. Шлюз вызова сам по себе не проверяет зна- чений параметров, которые копируются в новый стек, поэтому достоверность каждого передаваемого параметра должна контролировать вызванная процедура. Вот некоторые способы контроля передаваемых параметров. 1. Следует проверять счетчики циклов и повторений на минимальные и макси- мальные значения. 2. Необходимо проверить 8- и 16-битные параметры, передаваемые в 32-битных регистрах. Когда процедуре передается короткий параметр, его следует рас- ширить со знаком или нулем для заполнения всего 32-битного регистра. 3. Следует стремиться свести к минимуму время работы процессора с запре- щенными прерываниями. Если процедуре требуется запрещать прерывания, необходимо, чтобы вызывающая программа не могла влиять на время нахож- дения процессора с запрещенными прерываниями (флажок IF=0).
Система прерываний 32-разрядных микропроцессоров 180x86 119 4. Процедура никогда не должна воспринимать как параметр код или указатель кода. 5. В операциях процессора следует явно задавать состояние флажка направле- ния DF для цепочечных команд. 6. Заключительная команда RET или RETn в процедуре должна точно соответство- вать полю счетчика WC шлюза вызова; при этом n e 4x(WC1), так как счет- чик задает число двойных слов, а п соответствует байтам. 7. Не следует применять шлюзы вызовов для функций, которым передается пе- ременное число параметров (см. рекомендацию 6). При необходимости нуж- но воспользоваться счетчиком и указателем параметров. 8. Функции не могут возвращать значения в стеке (см. рекомендацию 6), так как после возврата стеки процедуры и вызывающей программы находятся точно в таком состоянии, в каком они были до вызова. 9. В процедуре следует сохранять и восстанавливать все сегментные регистры. Иначе, если какой-либо сегментный регистр привлекался для адресации дан- ных, недоступных вызывающей программе, процессор автоматически загру- зит в него пустой селектор. Рекомендуется контролировать все обращения к памяти. Нетрудно представить себе, что РЬЗ-программа передаст PLO-процедуре указатель селектор:смещение и запросит считывание или запись нескольких байтов по этому адресу. Типич- ным примером может служить процедура дискового ввода/вывода, которая вос- принимает как параметр системный номер файла, счетчик байт и адрес, по кото- рому записываются данные с диска. Хотя PLO-процедура имеет привилегии для производства такой операции, но у РЬЗ-программы разрешения на это может не быть. Система прерываний 32-разрядных микропроцессоров 180x86 В микропроцессорах семейства i80x86 система прерываний построена таким образом, чтобы, с одной стороны, обеспечить возможность создавать эффектив- ные и надежные мультипрограммные операционные системы, которые должны функционировать в защищенном режиме, а с другой стороны — обеспечить воз- можность выполнять программы, разработанные для реального режима. Рас- смотрим коротко оба режима. Работа системы прерываний в реальном режиме работы процессора В реальном режиме работы система прерываний использует понятие вектора прерывания. Термин «вектор прерываний» используется потому, что для указа- 1 WC (Word Counter) — счетчик слов, см. рис. 3.11.
120 Глава 3. Особенности архитектуры микропроцессоров i80x86 ния адреса используется не одно значение, а два, то есть мы имеем дело не со скалярной величиной, а с «векторной». Итак, каждый вектор прерываний состоит из 4 байтов или 2 слов: первые два содержат новое значение для регистра IP, а следующие два — новое значение регистра CS. Таблица векторов прерываний занимает 1024 байта. Таким образом, в ней может быть задано 256 векторов прерываний. В процессоре i8086 эта таб- лица располагается на адресах 00000H-003FFH. Расположение этой таблицы в про- цессорах i80286 и старше определяется значением регистра IDTR — Interrupt Descriptor Table Register. При включении или сбросе процессора i80x86 этот ре- гистр обнуляется. Однако при необходимости можно в регистре IDTR указать смещение и, таким образом, перейти на новую таблицу векторов прерываний. Таблица векторов прерываний заполняется (инициализируется) при запуске сис- темы, но в принципе может быть изменена или перемещена. Каждый вектор прерывания имеет свой номер, называемый номером прерыва- ния, который указывает его место в таблице. Этот номер, помноженный на четы- ре (сдвиг на два разряда влево и заполнение освободившихся битов нулями), и сложенный с содержимым регистра IDTR, дает абсолютный адрес первого бай- та вектора в оперативной памяти. Подобно вызову процедуры, прерывание заставляет микропроцессор сохранить в стеке информацию для последующего возврата, а затем перейти к группе ко- манд, адрес которых определяется вектором прерывания. Таким образом, преры- вание вызывает косвенный переход к своей подпрограмме обработки за счет по- лучения ее адреса из вектора прерывания. В IBM PC, как и в других вычислительных системах, прерывания бывают двух видов: внутренние и внешние. Внутренние прерывания, как мы уже знаем, возникают в результате работы про- цессора. Они возникают в ситуациях, которые нуждаются в специальном обслу- живании, или при выполнении специальных инструкций — INT или INTO. Это следующие прерывания: □ прерывание при делении на ноль; номер прерывания — 0; □ прерывание по флагу TF (trap flag1). В этом случае прерывание обычно ис- пользуется специальными программами отладки типа DEBUG. Номер прерыва- ния — 1; □ инструкции INT (interrupt — выполнить прерывание с соответствующим но- мером) и INTO (interrupt if overflow — прерывание по переполнению). Эти пре- рывания называются программными. В качестве операнда команды INT указывается номер прерывания, которое нужно выполнить, например INT ЮН. Программные прерывания как средство перехода 1 Trap Flag — специальный бит в регистре PSW (program status word, слово состояния про- граммы), который в случае значения TF-1 вызывает «останов» после каждой команды и генерируется прерывание для организации режима отладки с пошаговым выполнением программы. Чаще всего этот регистр в микропроцессорах Intel 80x86 называют регист- ром флагов.
Система прерываний 32-разрядных микропроцессоров i80x86 121 на соответствующую процедуру были введены для того, чтобы выполнение этой процедуры осуществлялось в привилегированном режиме, а не в обычном поль- зовательском. Внешние прерывания возникают по сигналу какого-нибудь внешнего устройства. Существуют два специальных внешних сигнала среди входных сигналов процес- сора, при помощи которых можно прервать выполнение текущей программы и тем самым переключить работу центрального процессора. Это сигналы NMI (no mask interrupt, немаскируемое прерывание) и INTR (interrupt request, запрос на прерывание). Соответственно, внешние прерывания подразделяются на не- маскируемые и маскируемые. Маскируемые прерывания генерируются контроллером прерываний по заявке определенных периферийных устройств1. Контроллер прерываний (его обозна- чение — i8259A) поддерживает восемь уровней (линий) приоритета; к каждому уровню «привязано» одно периферийное устройство2. Маскируемые прерывания часто называют еще аппаратными прерываниями. В ПК, начиная с IBM PC AT, построенных на базе микропроцессора i80286, используются два контроллера прерываний i8259А; они соединяются каскадным образом. Схема последователь- ного соединения этих контроллеров изображена на рис. 3.12. Таким образом, на IBM PC АТ предусмотрено 15 линий IRQ3, часть которых ис- пользуется внутренними контроллерами системной платы, а остальные заняты стандартными адаптерами либо не используются. Ниже перечислены линии за- проса на прерывание, которые мы приводим потому, что каждый специалист по вычислительной технике должен знать основные стандарты ПК. Итак, линии IRQ: О — системный таймер; 1 — контроллер клавиатуры; 2 — сигнал возврата по кадру (EGA/VGA), на АТ соединен с IRQ 9; 3 - обычно СОМ2/СОМ4; 4 - обычно СОМ1/СОМЗ; 5 — контроллер HDD (на первых компьютерах IBM PC XT), обычно свободен на IBM PC АТ и используется звуковой картой; 6 — контроллер FDD; 7 — LPT1, многими LPT-контроллерами не используется; 8 — часы реального времени с автономным питанием (RTC — real time clock); 9 — параллельна IRQ 2; 1 Сигнал запроса на прерывание чаще всего является сигналом готовности внешнего уст- ройства (соответствующего контроллера внешнего устройства) на выполнение следую- щей команды, связанной с управлением операциями ввода/вывода. 2 В качестве внешнего периферийного устройства, занимающего одну линию запроса на прерывание, может быть использовано специальное управляющее устройство, которое позволяет разделять эту самую линию запроса между несколькими внешними устройст- вами. 3 IRQ (Interrupt Request) — запрос на прерывание.
122 Глава 3. Особенности архитектуры микропроцессоров i80x86 10 — не используется, то есть свободно; 11 — свободно; 12 — обычно контроллер мыши типа PS/2; 13 — математический сопроцессор; 14 — обычно контроллер IDE0 (первый канал); 15 — обычно контроллер IDE1 (второй канал). От часов и КМОП-памяти От 8-разрядных секций слотов (бывшая линия ЗПр 9) От 16-разрядных секций слотов От сопроцессора От контроллера IDE 0 От контроллера IDE 1 От системного таймера От контроллера клавиатуры Последовательный порт СОМ 2(4) Последовательный порт СОМ 1(3) Sound Blaster или свободно От контроллера FDD Параллельный порт LPT 1 Рис. 3.12. Каскадирование контроллеров прерывания Как известно, прерывания могут быть инициированы внешним устройством ПК или специальной командой прерывания из программы. В любом случае, если прерывания разрешены, то выполняется следующая процедура: 1. В стек помещается регистр флагов PSW. 2. Флаг включения/выключения прерываний IF и флаг трассировки TF, нахо- дящиеся в регистре PSW, обнуляются для блокировки других маскируемых прерываний и исключения пошагового режима исполнения команд. 3. Значения регистров CS и IP сохраняются в стеке вслед за PSW. 4. Вычисляется адрес вектора прерывания, и из вектора, соответствующего но- меру прерывания, загружаются новые значения IP и CS. Когда системная подпрограмма принимает управление, она может снова разре- шить маскируемые прерывания командой STI (set interrupt flag, установить флаг
Система прерываний 32-разрядных микропроцессоров i80x86 123 прерываний), которая переводит флаг IF в состояние 1, что разрешает микро- процессору вновь реагировать на прерывания, инициируемые внешними устрой- ствами, поскольку стековая организация позволяет вложение прерываний друг в друга. Закончив работу, подпрограмма обработки прерывания должна выполнить инст- рукцию IRET (interrupt return), которая извлекает из стека три 16-битовых значе- ния и загружает их в указатель команд IP, регистр сегмента команд CS и регистр PSW соответственно. Таким образом, процессор сможет продолжить работу с того места, где он был прерван. В случае внешних прерываний процедура перехода на подпрограмму обработки прерывания дополняется следующими шагами: 1. Контроллер прерываний получает заявку от определенного периферийного устройства и, соблюдая схему приоритетов, генерирует сигнал INTR (inter- rupt request), который является входным для микропроцессора. 2. Микропроцессор проверяет флаг IF в регистре PSW. Если он установлен в 1, то переходим к шагу 3. В противном случае работа процессора не прерывает- ся. Часто говорят, что прерывания замаскированы, хотя правильнее говорить, что они отключены. Маскируются (запрещаются) отдельные линии запроса на прерывания посредством программирования контроллера прерываний. 3. Микропроцессор генерирует сигнал INTA (подтверждение прерывания). В от- вет на этот сигнал контроллер прерывания посылает по шине данных номер прерывания. После этого выполняется описанная нами ранее процедура пере- дачи управления соответствующей программе обработки прерывания. Номер прерывания и его приоритет устанавливаются на этапе инициализации системы. После запуска ОС пользователь, как мы уже отмечали, может изменить таблицу векторов прерывания, поскольку она ему доступна. Работа системы прерываний в защищенном режиме работы процессора В защищенном режиме работы система прерываний действует совершенно ина- че. Прежде всего, система прерываний микропроцессора i80x86 при работе в за- щищенном режиме вместо таблицы векторов, о которой мы говорили выше, име- ет дело с таблицей дескрипторов прерываний (IDT, interrupt descriptor table). Дело здесь не столько в названии таблицы, сколько в том, что таблица IDT пред- ставляет собой не таблицу с адресами обработчиков прерываний, а таблицу со специальными системными структурами данных (дескрипторами), доступ к ко- торой со стороны пользовательских (прикладных) программ невозможен. Толь- ко сам микропроцессор (его система прерываний) и код операционной системы могут получить доступ к этой таблице, которая представляет собой специальный сегмент, адрес и длина которого содержатся в регистре IDTR (см. рис. 3.2). Этот регистр аналогичен регистру GDTR в том отношении, что он инициализируется один раз при загрузке системы. Интересно заметить, что в реальном режиме ра- боты регистр IDTR так же указывает адрес таблицы прерываний, но при этом, как и в процессоре i8086, каждый элемент таблицы прерываний (вектор) зани-
124 Глава 3. Особенности архитектуры микропроцессоров i80x86 мает всего 4 байта и содержит 32-битный адрес в формате селектор-.смещение (CS:IP). Начальное значение этого регистра равно нулю, но в него можно зане- сти и другое значение. В этом случае таблица векторов прерываний будет нахо- диться в другом месте оперативной памяти. Естественно, что перед тем, как это сделать (занести в регистр IDTR новое значение), необходимо подготовить саму таблицу векторов. В защищенном режиме работы загрузку регистра IDTR может произвести только код с максимальным уровнем привилегий. Каждый элемент в таблице дескрипторов прерываний, о которой мы говорим уже в защищенном режиме, представляет собой 8-байтовую структуру, более по- хожую на дескриптор шлюза (gate), нежели на дескриптор сегмента. Как мы уже знаем, в зависимости от причины прерывания процессор автомати- чески индексирует таблицу прерываний и выбирает соответствующий элемент, с помощью которого и осуществляется перенаправление в исполнении кода, то есть передача управления на обработчик прерывания. Однако таблица IDT со- держит только шлюзы, а не дескрипторы сегментов кода, поэтому фактически получается косвенная адресация, но с использованием рассмотренного ранее ме- ханизма защиты с помощью уровней привилегии. Благодаря этому пользователи уже не могут сами изменить обработку прерываний, которая предопределяется системным программным обеспечением. Дескриптор прерываний может принадлежать к одному из трех типов: □ коммутатор прерывания (interrupt gate); □ коммутатор перехвата (trap gate); □ коммутатор задачи (task gate). При обнаружении запроса на прерывание и при условии, что прерывания сейчас разрешены, процессор действует в зависимости от типа дескриптора (коммута- тора), соответствующего номеру прерывания. Первые два типа дескриптора пре- рываний вызывают переход на соответствующие сегменты кода, принадлежащие виртуальному адресному пространству текущего вычислительного процесса. По- этому про них говорят, что обработка прерываний по этим дескрипторам осуще- ствляется под контролем текущей задачи. Последний тип дескриптора — комму- татор задачи — вызывает полное переключение процессора на новую задачу со сменой всего контекста в соответствии с сегментом состояния задачи (TSS). Рас- смотрим эти варианты. Обработка прерываний в контексте текущей задачи Рассмотрим рис. 3.13, поясняющий обработку прерывания в контексте текущей задачи. При возникновении прерывания процессор по номеру прерывания ин- дексирует таблицу IDT, то есть адрес соответствующего коммутатора определя- ется путем сложения содержимого поля адреса в регистре IDTR и номера преры- вания, умноженного на 8 (справа к номеру прерывания добавляются три нуля). Полученный дескриптор анализируется, и если его тип соответствует коммута- тору trap £ate или коммутатору interrupt gate, то выполняются следующие дей- ствия.
Система прерываний 32-разрядных микропроцессоров i80x86 125 1. В стек на уровне привилегий текущего сегмента кода помещаются: О значения SS и SP, если уровень привилегий в коммутаторе выше уровня привилегий ранее исполнявшегося кода; О регистр флагов EFLAGS; О регистры CS и IP. 2. Если рассматриваемому прерыванию соответствовал коммутатор interrupt gate, то запрещаются прерывания (флаг IF:=0 в регистре EFLAGS). В случае коммутатора trap gate флаг прерываний не сбрасывается и обработка новых прерываний на период обработки текущего прерывания тем самым не запре- щается. 3. Поле селектора из дескриптора прерываний используется для индексирова- ния таблицы дескрипторов задачи. Дескриптор сегмента заносится в теневой регистр, а смещение относительно начала нового сегмента кода определяется полем смещения из дескриптора прерывания. Регистр дескриптора Рис. 3.13. Схема передачи управления при прерывании в контексте текущей задачи Таким образом, в случае обработки прерываний, когда дескриптором прерыва- ний является коммутатор interrupt gate или trap gate, мы остаемся в том же вир- туальном адресном пространстве, и полной смены контекста текущей задачи не происходит. Просто мы переключаемся на исполнение другого (как правило, бо- лее привилегированного) кода, но также принадлежащего (или, правильнее ска-
126 Глава 3. Особенности архитектуры микропроцессоров i80x86 зать, доступного) исполняемой задаче. Этот код создается системными програм- мистами, и прикладные программисты его просто используют. В то же время механизмы защиты микропроцессора позволяют обеспечить недоступность этого кода для его исправления (со стороны приложений, его вызывающих) и недос- тупность самой таблицы дескрипторов прерываний. Удобнее всего код обработ- чиков прерываний располагать в общем адресном пространстве, то есть селекто- ры, указывающие на такой код, должны располагаться в глобальной таблице дескрипторов. Обработка прерываний с переключением на новую задачу Совершенно иначе осуществляется обработка прерываний в случае, если деск- риптором прерываний является коммутатор задачи. Формат коммутатора задачи (task gate) отличается от коммутаторов interrupt gate и trap gate, прежде всего, тем, что в нем вместо селектора сегмента кода, на который передается управле- ние, указывается селектор сегмента состояния задачи (TSS). Это иллюстрирует- ся с помощью рис. 3.14. В результате осуществляется процедура перехода на но- вую задачу с полной сменой контекста, ибо сегмент состояния задачи полностью определяет новое виртуальное пространство и адрес начала программы, а теку- щее состояние прерываемой задачи аппаратно (по микропрограмме микропро- цессора) сохраняется в ее собственном TSS. Рис. 3.14. Схема передачи управления при прерывании с переключением на новую задачу При этом происходит полное переключение на новую задачу с вложением, то есть выполняются следующие действия:
Контрольные вопросы и задачи 127 1. Сохраняются все рабочие регистры процессора в текущем сегменте TSS, базо- вый адрес этого сегмента берется в регистре TR (см. раздел «Адресация в 32- разрядных микропроцессорах i80x86 при работе в защищенном режиме»). 2. Текущая задача отмечается как занятая. 3. По селектору из Task Gate выбирается новый TSS (поле селектора помещается в регистр TR) и загружается состояние новой задачи. Напомним, что загру- жаются значения регистра LDTR, EFLAGS, восемь регистров общего назна- чения, указатель команды регистр EIP и шесть сегментных регистров. 4. Устанавливается бит NT (next task). 5. В поле обратной связи TSS помещается селектор прерванной задачи. 6. Значения CS:IP, взятые из нового TSS, позволяют найти и выполнить пер- вую команду обработчика прерывания. Таким образом, коммутатор task gate дает указание процессору произвести пере- ключение задачи, и обработка прерывания осуществляется под контролем от- дельной внешней задачи. В каждом сегменте TSS имеется селектор локальной дескрипторной таблицы LDT, поэтому при переключении задачи процессор за- гружает в регистр LDTR новое значение. Это позволяет обратиться к сегментам кода, которые абсолютно не пересекаются с сегментами кода любых других за- дач, поскольку именно локальные дескрипторные таблицы обеспечивают эффек- тивное изолирование виртуальных адресных пространств. Новая задача начина- ет свое выполнение на уровне привилегий, определяемом полем RPL нового содержимого регистра CS, которое загружается из сегмента TSS. Достоинством этого коммутатора является то, что он позволяет сохранить все регистры процес- сора с помощью механизма переключения задач, тогда как коммутаторы trap gate и interrupt gate сохраняют только содержимое регистров IFLAGS, CS и IP и со- хранение других регистров возлагается на программиста, разрабатывающего со- ответствующую программу обработки прерывания. Справедливости ради следует признать, что, несмотря на возможности коммута- тора task gate, разработчики современных операционных систем достаточно ред- ко его используют, поскольку переключение на другую задачу требует сущест- венно больших затрат времени, а полное сохранение всех рабочих регистров часто и не требуется. В основном обработку прерываний осуществляют в контексте те- кущей задачи, так как это приводит к меньшим накладным расходам и повышает быстродействие системы. Контрольные вопросы и задачи Вопросы для проверки 1. Как в реальном режиме работы микропроцессоров i80x86 осуществляется преобразование виртуального адреса в физический? 2. Какие механизмы виртуальной памяти используются в защищенном режиме работы микропроцессоров i80x86?
128 Глава 3. Особенности архитектуры микропроцессоров 180x86 3. Для чего в микропроцессоры i80x86 введен регистр-указатель задачи TR? Ка- кой он разрядности? 4. Как в микропроцессорах i80x86 реализована поддержка сегментного способа организации виртуальной памяти? 5. Что понимается под термином «линейный адрес»? Как осуществляется пре- образование линейного адреса в физический? А может ли линейный адрес быть равным физическому? 6. Что дало введение двухшаговой страничной трансляции в механизме стра- ничного способа реализации виртуальной памяти? Как разработчики микро- процессора i80386 решили проблему замедления доступа к памяти, которое при двухшаговом преобразовании адресов очень существенно? 7. Что дало введение виртуального режима? Как в этом режиме осуществляется вычисление физического адреса? 8. Что имеется в микропроцессорах i80x86 для обеспечения защиты адресного пространства задач? 9. Что такое «уровень привилигий»? Сколько уровней привилегий имеется в микропроцессорах i80x86? Для каких целей введено такое количество уров- ней привилегий? 10. Что такое текущий уровень привилегий? Что такое эффективный уровень привилегий? И. Объясните правила работы с уровнями привилегий для различных типов сег- ментов. 12. Поясните механизм шлюзования: для чего он предназначен, как осуществля- ется передача управления на сегменты кода с другими уровнями привилегий. 13. Расскажите о работе системы прерываний микропроцессоров i80x86 в реаль- ном режиме. 14. В чем заключаются основные принципиальные отличия работы системы пре- рываний микропроцессоров i80x86 в защищенном режиме по сравнению с ре- альным режимом? 15. Как осуществляется переход на программу обработки прерываний, если деск- риптор прерываний является коммутатором прерываний? 16. Как осуществляется переход на программу обработки прерываний, если деск- риптор прерываний является коммутатором перехвата? 17. Как осуществляется переход на программу обработки прерываний, если деск- риптор прерываний является коммутатором задачи?
ГЛАВА 4 Управление вводом/выводом и файловые системы Необходимость обеспечить программам возможность осуществлять обмен дан- ными с внешними устройствами и при этом не включать в каждую двоичную программу соответствующий двоичный код, осуществляющий собственно управ- ление устройствами ввода/вывода, привела разработчиков к созданию системно- го программного обеспечения и, в частности, самих операционных систем. Про- граммирование задач управления вводом/выводом является наиболее сложным и трудоемким, требующим очень высокой квалификации. Поэтому код, позво- ляющий осуществлять операции ввода/вывода, стали оформлять в виде систем- ных библиотечных процедур; потом его стали включать не в системы програм- мирования, а в операционную систему с тем, чтобы в каждую отдельно взятую программу его не вставлять, а только позволить обращаться к такому коду. Сис- темы программирования стали генерировать обращения к этому системному коду ввода/вывода и осуществлять только подготовку к собственно операциям вво- да/вывода, то есть автоматизировать преобразование данных к соответствую- щему формату, понятному устройствам, избавляя прикладных программистов от этой сложной и трудоемкой работы. Другими словами, системы программиро- вания вставляют в машинный код необходимые библиотечные подпрограммы ввода/вывода и обращения к тем системным программным модулям, которые, собственно, и управляют операциями обмена между оперативной памятью и внеш- ними устройствами. Таким образом, управление вводом/выводом — это одна из основных функций любой ОС. С одной стороны, в организации ввода/вывода в различных ОС много общего. С другой стороны, реализация ввода/вывода в ОС так сильно отличается от сис- темы к системе, что очень нелегко выделить и описать именно основные принци- пы реализации этих функций. Проблема усугубляется еще тем, что в большинст- ве ныне используемых систем эти моменты вообще, как правило, подробно не описаны, и исключение по этому вопросу касается только системы Linux, для
130 Глава 4. Управление вводом/выводом и файловые системы которой имеются комментированные исходные тексты. Детально описываются функции API, реализующие ввод/вывод. Поэтому в этом разделе, самом неболь- шом по объему, мы рассмотрим только основные идеи и концепции. Поскольку внешняя память, как правило, реализуется на таких устройствах вво- да/вывода, как накопители на магнитных дисках, мы также рассмотрим логиче- скую структуру диска. Закончим настоящую главу рассмотрением наиболее распространенных файло- вых систем. Основные понятия и концепции организации ввода/вывода в ОС Как известно, ввод/вывод считается одной из самых сложных областей проекти- рования операционных систем, в которой сложно применить общий подход из-за изобилия частных методов. Сложность возникает из-за огромного числа уст- ройств ввода/вывода разнообразной природы, которые должна поддерживать ОС. При этом перед создателями ОС встает очень непростая задача — не только обеспечить эффективное управление устройствами ввода/вывода, но и создать удобный и эффективный виртуальный интерфейс устройств ввода/вывода, по- зволяющий прикладным программистам просто считывать или сохранять дан- ные, не обращая внимание на специфику устройств и проблемы распределения устройств между выполняющимися задачами. Система ввода/вывода, способная объединить в одной модели широкий набор устройств, должна быть универсаль- ной. Она должна учитывать потребности существующих устройств, от простой мыши до клавиатур, принтеров, графических дисплеев, дисковых накопителей, компакт-дисков и даже сетей. С другой стороны, необходимо обеспечить доступ к устройствам ввода/вывода для множества параллельно выполняющихся задач, причем так, чтобы они как можно меньше мешали друг другу. Поэтому самым главным является следующий принцип: любые операции по управлению вводом/выводом объявляются привилегированными и могут вы- полняться только кодом самой ОС. Для обеспечения этого принципа в большин- стве процессоров даже вводятся режимы пользователя и супервизора. Как пра- вило, в режиме супервизора выполнение команд ввода/вывода разрешено, а в пользовательском режиме — запрещено. Использование команд ввода/вывода в пользовательском режиме вызывает исключение1 и управление через механизм прерываний передается коду ОС. Хотя возможны и более сложные системы, в которых в ряде случаев пользовательским программам разрешено непосредст- венное выполнение команд ввода/вывода. Еще раз подчеркнем, что, прежде всего, мы говорим о мультипрограммных ОС, для которых существует проблема разделения ресурсов. Одним из основных ви- 1 Исключение — это определенный вид внутреннего прерывания. Этим термином, во-пер- вых, обозначают некоторое множество синхронных прерываний, а во-вторых, подчерки- вают, что ситуация, вызвавшая запрос на прерывание, является исключительной, то есть отличается от обычной.
Основные понятия и концепции организации ввода/вывода в ОС 131 дов ресурсов являются устройства ввода/вывода и соответствующее программное обеспечение, с помощью которого осуществляется управление обменом данными между внешними устройствами и оперативной памятью. Помимо разделяемых устройств ввода/вывода (эти устройства допускают разделение посредством ме- ханизма доступа) существуют неразделяемые устройства. Примерами разделяе- мого устройства могут служить накопитель на магнитных дисках, устройство для чтения компакт-дисков. Это устройства с прямым доступом. Примеры не- разделяемых устройств — принтер, накопитель на магнитных лентах. Это уст- ройства с последовательным доступом. Операционные системы должны управ- лять и теми и другими устройствами, предоставляя возможность параллельно выполняющимся задачам использовать различные устройства ввода/вывода. Можно назвать три основные причины, по которым нельзя разрешать каждой отдельной пользовательской программе обращаться к внешним устройствам не- посредственно: □ Необходимость разрешать возможные конфликты доступа к устройствам ввода/вывода. Например, две параллельно выполняющиеся программы пыта- ются вывести на печать результаты своей работы. Если не предусмотреть внешнее управление устройством печати, то в результате мы можем получить абсолютно нечитаемый текст, так как каждая программа будет время от вре- мени выводить свои данные, которые будут перемежаться данными другой программы. Другой пример: ситуация, когда одной программе необходимо прочитать данные с некоторого сектора магнитного диска, а другой — запи- сать результаты в другой сектор того же накопителя. Если операции вво- да/вывода не будут отслеживаться каким-то третьим (внешним) процессом- арбитром, то после позиционирования магнитной головки для первого запро- са может тут же появиться команда позиционирования головки для второй задачи, и обе операции ввода/вывода не смогут быть выполнены корректно. □ Желание увеличить эффективность использования этих ресурсов. Например, у накопителя на магнитных дисках время подвода головки чтения/записи к необходимой дорожке и обращение к определенному сектору может значи- тельно (до тысячи раз) превышать время пересылки данных. В результате, если задачи по очереди обращаются к цилиндрам, далеко отстоящим друг от друга, то полезная работа, выполняемая накопителем, может быть существен- но снижена. □ Ошибки в программах ввода/вывода могут привести к краху всех вычисли- тельных процессов, ибо часть операций ввода/вывода осуществляется для самой операционной системы. В ряде ОС системный ввод/вывод имеет существен- но более высокие привилегии, чем ввод/вывод задач пользователя. Поэтому системный код, управляющий операциями ввода/вывода, очень тщательно отлаживается и оптимизируется для повышения надежности вычислений и эффективности использования оборудования. Итак, управление вводом/выводом осуществляется операционной системой, компонентом, который чаще всего называют супервизором ввода/вывода. В пере- чень основных задач, возлагаемых на супервизор, входят следующие:
132 Глава 4. Управление вводом/выводом и файловые системы □ супервизор ввода/вывода получает запросы на ввод/вывод от прикладных за- дач и от программных модулей самой операционной системы. Эти запросы проверяются на корректность, и если запрос выполнен по спецификациям и не содержит ошибок, он обрабатывается дальше, в противном случае пользо- вателю (задаче) выдается соответствующее диагностическое сообщение о не- действительности (некорректности) запроса; □ супервизор ввода/вывода вызывает соответствующие распределители каналов и контроллеров, планирует ввод/вывод (определяет очередность предостав- ления устройств ввода/вывода задачам, затребовавшим их). Запрос на ввод/ вывод либо тут же выполняется, либо ставится в очередь на выполнение; □ супервизор ввода/вывода инициирует операции ввода/вывода (передает управ- ление соответствующим драйверам) и в случае управления вводом/выводом с использованием прерываний предоставляет процессор диспетчеру задач с тем, чтобы передать его первой задаче, стоящей в очереди на выполнение; □ при получении сигналов прерываний от устройств ввода/вывода супервизор идентифицирует их (рис. 4.1) и передает управление соответствующей про- грамме обработки прерывания (как правило, на секцию продолжения драйве- ра — см. раздел «Режимы управления вводом/выводом»); □ супервизор ввода/вывода осуществляет передачу сообщений об ошибках, если таковые происходят в процессе управления операциями ввода/вывода; □ супервизор ввода/вывода посылает сообщения о завершении операции вво- да/вывода запросившему эту операцию процессу и снимает его с состояния ожидания ввода/вывода, если процесс ожидал завершения операции. В случае если устройство ввода/вывода является инициативным1, управление со стороны супервизора ввода/вывода будет заключаться в активизации соответст- вующего вычислительного процесса (перевод его в состояние готовности к вы- полнению). Таким образом, прикладные программы (а в общем случае — все обрабатываю- щие программы) не могут непосредственно связываться с устройствами ввода/ вывода независимо от использования устройств (монопольно или совместно). Установив соответствующие значения параметров в запросе на ввод/вывод, опре- деляющих требуемую операцию и количество потребляемых ресурсов, они могут передать управление супервизору ввода/вывода, который и запускает необходи- мые логические и физические операции. 1 Инициативным называют такое устройство (обычно это датчики, внешнее устройство, а не устройство ввода/вывода), по сигналу прерывания от которого запускается соответ- ствующая ему программа. Такая программа, с одной стороны, не является драйвером, и управлять операциями обмана данными нет необходимости. Но, с другой стороны, за- пуск такой программы осуществляется именно по событиям, связанным с генерацией устройством ввода/вывода соответствующего сигнала. Разница между драйверами, рабо- тающими по прерываниям, и инициативными программами заключается в статусе этих программных модулей. Драйвер является компонентом операционной системы и часто выполняется не как вычислительный процесс, а как системный объект, а инициативная программа является обычным вычислительным процессом, только его запуск осуществ- ляется по инициативе внешнего устройства.
Режимы управления вводом/выводом 133 Упомянутый выше запрос на ввод/вывод должен удовлетворять требованиям API той операционной системы, в среде которой выполняется приложение. Парамет- ры, указываемые в запросах на ввод/вывод, передаются не только в вызывающих последовательностях, создаваемых по спецификациям API, но и как данные, хранящиеся в соответствующих системных таблицах. Все параметры, которые будут стоять в вызывающей последовательности, поставляются компилятором и отражают требования программиста и постоянные сведения об операционной системе и архитектуре компьютера в целом. Переменные сведения о вычисли- тельной системе (ее конфигурация, состав оборудования, состав и особенности системного программного обеспечения) содержатся в специальных системных таблицах. Процессору, каналам прямого доступа в память, контроллерам необхо- димо передавать конкретную двоичную информацию, с помощью которой и осу- ществляется управление оборудованием. Эта конкретная двоичная информация в виде кодов и данных часто готовится с помощью препроцессоров, но часть ее хранится в системных таблицах. Режимы управления вводом/выводом Как известно, имеются два основных режима ввода/вывода: режим обмена с опро- сом готовности устройства ввода/вывода и режим обмена с прерываниями. Рас- смотрим рис. 4.1. готовности Рис. 4.1. Управление вводом/выводом Пусть для простоты управление вводом/выводом осуществляет центральный процессор (в этом случае часто говорят о наличии программного канала обмена данными между внешними устройством и оперативной памятью, в отличие от канала прямого доступа к памяти, при котором управление вводом/выводом осуществляет специальное дополнительное оборудование; эти вопросы мы обсу- дим несколько позже). Центральный процессор посылает устройству управле- ния команду выполнить некоторое действие устройству ввода/вывода. Последнее исполняет команду, транслируя сигналы, понятные центральному устройству и
134 Глава 4. Управление вводом/выводом и файловые системы устройству управления в сигналы, понятные устройству ввода/вывода. Но быст- родействие устройства ввода/вывода намного меньше быстродействия централь- ного процессора (порой на несколько порядков). Поэтому сигнал готовности (транслируемый или генерируемый устройством управления и сигнализирующий процессору о том, что команда ввода/вывода выполнена и можно выдать новую команду для продолжения обмена данными) приходится очень долго ожидать, постоянно опрашивая соответствующую линию интерфейса на наличие или от- сутствие нужного сигнала. Посылать новую команду, не дождавшись сигнала го- товности, сообщающего об исполнении предыдущей команды, бессмысленно. В режиме опроса готовности драйвер, управляющий процессом обмена данными с внешним устройством, как раз и выполняет в цикле команду «проверить нали- чие сигнала готовности». До тех пор пока сигнал готовности не появится, драй- вер ничего другого не делает. При этом, естественно, нерационально использу- ется время центрального процессора. Гораздо выгоднее, выдав команду ввода/ вывода, на время забыть об устройстве ввода/вывода и перейти на выполнение другой программы. А появление сигнала готовности трактовать как запрос на прерывание от устройства ввода/вывода. Именно эти сигналы готовности и яв- ляются сигналами запроса на прерывание (см. раздел «Прерывания», глава 1). Режим обмена с прерываниями по своей сути является режимом асинхронного управления. Для того чтобы не потерять связь с устройством (после того как процессор выдал очередную команду по управлению обменом данными и пере- ключился на выполнение других программ), может быть запущен отсчет времени, в течение которого устройство обязательно должно выполнить команду и выдать таки сигнал запроса на прерывание. Максимальный интервал времени, в течение которого устройство ввода/вывода или его контроллер должны выдать сигнал запроса на прерывание, часто называют уставкой тайм-аута. Если это время истекло после выдачи устройству очередной команды, а устройство так и не от- ветило, то делается вывод о том, что связь с устройством потеряна и управлять им больше нет возможности. Пользователь и/или задача получают соответст- вующее диагностическое сообщение. Драйверы, работающие в режиме прерываний, представляют собой сложный комп- лекс программных модулей и могут иметь несколько секций: секцию запуска, одну или несколько секций продолжения и секцию завершения. Секция запуска инициирует операцию ввода/вывода. Эта секция запускается для включения устройства ввода/вывода либо просто для инициации очередной операции ввода/вывода. Секция продолжения (их может быть несколько, если алгоритм управления об- меном данными сложный и требуется несколько прерываний для выполнения одной логической операции) осуществляет основную работу по передаче дан- ных. Секция продолжения, собственно говоря, и является основным обработчи- ком прерывания. Используемый интерфейс может потребовать для управления вводом/выводом несколько последовательностей управляющих команд, а сигнал прерывания у устройства, как правило, только один. Поэтому после выполнения очередной секции прерывания супервизор прерываний при следующем сигнале готовности должен передать управление другой секции. Это делается за счет изменения адреса обработки прерывания после выполнения очередной секции,
Закрепление устройств, общие устройства ввода/вывода 135 если же имеется только одна секция прерываний, то она сама передает управле- ние тому или иному модулю обработки. Секция завершения обычно выключает устройство ввода/вывода либо просто за- вершает операцию. Управление операциями ввода/вывода в режиме прерываний требует больших усилий со стороны системных программистов — такие программы создавать слож- нее, чем те, что работают в режиме опроса готовности. Примером тому может служить ситуация с драйверами, обеспечивающими печать. Так, в ОС Windows (и Windows 9х, и Windows NT) драйвер печати через параллельный порт работа- ет не в режиме с прерываниями, как это сделано в других ОС, а в режиме опроса готовности, что приводит к 100%-й загрузке центрального процессора на все вре- мя печати. При этом, естественно, выполняются и другие задачи, запущенные на исполнение, но исключительно за счет того, что ОС Windows реализует вытес- няющую мультизадачность и время от времени прерывает процесс управления печатью и передает центральный процессор остальным задачам. Закрепление устройств, общие устройства ввода/вывода Как известно, многие устройства не допускают совместного использования. Пре- жде всего, это устройства с последовательным доступом. Такие устройства могут стать закрепленными, то есть быть предоставленными некоторому вычислитель- ному процессу на все время жизни этого процесса. Однако это приводит к тому, что вычислительные процессы часто не могут выполняться параллельно — они ожидают освобождения устройств ввода/вывода. Для организации использова- ния многими параллельно выполняющимися задачами устройств ввода/вывода, которые не могут быть разделяемыми, вводится понятие виртуальных устройств. Использование принципа виртуализации позволяет повысить эффективность вы- числительной системы.. Вообще говоря, понятие виртуального устройства шире, нежели использование этого термина для обозначения спулинга (SPOOLing — simultaneous peripheral operation on-line, то есть имитация работы с устройством в режиме «он-лайн»). Главная задача спулинга — создать видимость параллельного разделения устрой- ства ввода/вывода с последовательным доступом, которое фактически должно использоваться только монопольно и быть закрепленным. Например, мы уже го- ворили, что в случае, когда несколько приложений должны выводить на печать результаты своей работы, если разрешить каждому такому приложению печатать строку по первому же требованию, то это приведет к потоку строк, не представ- ляющих никакой ценности. Однако можно каждому вычислительному процессу предоставлять не реальный, а виртуальный принтер и поток выводимых симво- лов (или управляющих кодов для их печати) сначала направлять в специальный файл1 на магнитном диске. Затем, по окончании виртуальной печати, в соответ- 1 Такой файл называют спул-файлом (spool-file).
136 Глава 4. Управление вводом/выводом и файловые системы ствии с принятой дисциплиной обслуживания и приоритетами приложений вы- водить содержимое спул-файла на принтер. Системный процесс, который управ- ляет спул-файлом, называется спулером (spool-reader или spool-writer). Основные системные таблицы ввода/вывода Каждая ОС имеет свои таблицы ввода/вывода, их состав (количество и назначе- ние каждой таблицы) может сильно отличаться. В некоторых ОС вместо таблиц создаются списки, хотя использование статических структур данных для органи- зации ввода/вывода, как правило, приводит к большему быстродействию. Здесь очень трудно вычленить общие составляющие, тем более что подробной доку- ментации на эту тему крайне мало, только если воспользоваться материалами ныне устаревших ОС. Тем не менее попытаемся это сделать, опираясь на идеи семейства простых, но эффективных ОС реального времени, разработанных фир- мой Hewlett-Packard для своих мини-компьютеров. Исходя из принципа управления вводом/выводом через супервизор ОС и учи- тывая, что драйверы устройств ввода/вывода используют механизм прерываний для установления обратной связи центральной части с внешними устройствами, можно сделать вывод о необходимости создания по крайней мере трех систем- ных таблиц. Первая таблица (или список) содержит информацию обо всех устройствах вво- да/вывода, подключенных к вычислительной системе. Назовем ее условно таб- лицей оборудования (equipment table), а каждый элемент этой таблицы пусть на- зывается UCB (unit control block, блок управления устройством ввода/вывода). Каждый элемент UCB таблицы оборудования, как правило, содержит следую- щую информацию об устройстве: □ тип устройства, его конкретная модель, символическое имя и характеристики устройства; □ как это устройство подключено (через какой интерфейс, к какому разъему, какие порты и линия запроса прерывания используются и т. д.); □ номер и адрес канала (и подканала), если такие используются для управле- ния устройством; □ указание на драйвер, который должен управлять этим устройством, адрес секции запуска и секции продолжения драйвера; □ информация о том, используется или нет буферирование при обмене данны- ми с этим устройством, «имя» (или просто адрес) буфера, если такой выделя- ется из системной области памяти; □ уставка тайм-аута и ячейки для счетчика тайм-аута; □ состояние устройства; □ поле указателя для связи задач, ожидающих устройство, и, возможно, много еще каких сведений.
Основные системные таблицы ввода/вывода 137 Поясним перечисленное. Поскольку во многих ОС драйверы могут обладать свойством реентерабельности (напомним, это означает, что один и тот же экземп- ляр программного модуля может обеспечить параллельное обслуживание сразу нескольких однотипных устройств), то в элементе UCB должна храниться либо непосредственно сама информация о текущем состоянии устройства и сами пе- ременные для реентерабельной обработки, либо указание на место, где такая ин- формация может быть найдена. Наконец, важнейшим компонентом элемента таблицы оборудования является указатель на дескриптор той задачи, которая сейчас использует данное устройство. Если устройство свободно, то поле указа- теля будет иметь нулевое значение. Если же устройство уже занято и рассмат- риваемый указатель не нулевой, то новые запросы к устройству фиксируются посредством образования списка из дескрипторов тех задач, которые сейчас ожидают данное устройство. Вторая таблица предназначена для реализации еще одного принципа виртуа- лизации устройств ввода/вывода — независимости от устройства. Желательно, чтобы программист не был озабочен учетом конкретных параметров (и/или воз- можностей) того или иного устройства ввода/вывода, которое установлено (или не установлено) в компьютер. Для него должны быть важны только самые об- щие возможности, характерные для данного класса устройств ввода/вывода, ко- торыми он желает воспользоваться. Например, принтер должен уметь выводить (печатать) символы или графическое изображение. А накопитель на магнитных дисках — считывать или записывать по указанному адресу (в координатах C-H-S1) порцию данных. Хотя чаще всего программист и не использует прямую адреса- цию при работе с магнитными дисками, а работает на уровне файловой системы (см. главу 4). Однако в таком случае уже разработчики файловой системы не должны зависеть от того, накопитель какого конкретного типа и модели, а также какого производителя используется в данном конкретном компьютере (напри- мер, HDD IBM DTLA 307030, WDAC 450AA или какой-нибудь еще). Важным должен быть только сам факт существования накопителя, имеющего некоторое количество цилиндров, головок чтения/записи и секторов на дорожке магнитного диска. Упомянутые значения количества цилиндров, головок и секторов должны быть взяты из элемента таблицы оборудования. При этом для программиста так- же не должно иметь значения, каким образом то или иное устройство подключе- но к вычислительной системе, а не только какая конкретная модель устройства используется. Поэтому в запросе на ввод/вывод программист указывает именно логическое имя устройства. Действительное устройство, которое сопоставляется виртуальному (логическому), выбирается супервизором с помощью таблицы, о которой мы сейчас говорим. Итак, способ подключения устройства, его кон- кретная модель и соответствующий ей драйвер содержатся в уже рассмотренной таблице оборудования. Но для того, чтобы связать некоторое виртуальное уст- ройство, использованное программистом при создании приложения с системной таблицей, отображающей информацию о том, какое конкретно устройство и ка- ким образом подключено к компьютеру, используется вторая системная табли- ца. Назовем ее условно таблицей описания виртуальных логических устройств 1 C-H-S (cylinder-head-sector) — номер цилиндра, номер головки и номер сектора.
138 Глава 4. Управление вводом/выводом и файловые системы (DRT, device reference table). Назначение этой второй таблицы — установление связи между виртуальными (логическими) устройствами и реальными устройст- вами, описанными посредством первой таблицы оборудования. Другими слова- ми, вторая таблица позволяет супервизору перенаправить запрос на ввод/вывод из приложения на те программные модули и структуры данных, которые (или адреса которых) хранятся в соответствующем элементе первой таблицы. Во мно- гих многопользовательских системах такая таблица не одна, а несколько: одна общая и по одной — на каждого пользователя, что позволяет строить необходи- мые связи между логическими (символьными) именами устройств и реальными физическими устройствами, которые имеются в системе. Наконец, третья таблица необходима для организации обратной связи между центральной частью и устройствами ввода/вывода. Это таблица прерываний, ко- торая указывает для каждого сигнала запроса на прерывание тот элемент UCB, который сопоставлен данному устройству, подключенному так, что оно исполь- зует настоящую линию (сигнал) прерывания. Как системная таблица ввода/вы- вода, таблица прерываний может в явном виде и не присутствовать. В принципе можно сразу из основной таблицы прерываний попадать на программу обработ- ки (драйвер), имеющей связи с элементом UCB. Важно наличие связи между сигналами прерываний и таблицей оборудования. В современных сложных ОС имеется гораздо больше системных таблиц или списков, используемых для организации процессами управления операциями ввода/вывода. Например, одной из возможных и часто реализуемых информа- ционных структур, сопровождающих практически каждый запрос на ввод/вы- вод, является блок управления данными (data control block, DCB). Назначение этого DCB — подключение препроцессоров к процессу подготовки данных на ввод/вывод, то есть учет конкретных технических характеристик и используе- мых преобразований. Это необходимо для того, чтобы имеющееся устройство получало не какие-то непонятные ему коды либо форматы данных, которые не соответствуют режиму его работы, а коды, созданные специально под данное устройство и используемый в настоящий момент формат представления данных. Взаимосвязи между описанными таблицами изображены на рис. 4.2. Таблица Таблица Таблица логических имен оборудования прерываний Рис. 4.2. Взаимосвязь системных таблиц ввода/вывода
Основные системные таблицы ввода/вывода 139 Теперь нам осталось лишь еще раз, с учетом изложенных принципов и таблиц, рассмотреть процесс управления вводом/вывода с помощью рис. 4.3. Рис. 4.3. Процесс управления вводом/выводом Запрос на операцию ввода/вывода от выполняющейся программы поступает на супервизор (действие 7). Тот проверяет системный вызов на соответствие при- нятым спецификациям и в случае ошибки возвращает задаче соответствующее сообщение (действие 1-1). Если же запрос корректен, то он перенаправляется в супервизор ввода/вывода (действие 2). Последний по логическому (виртуаль- ному) имени с помощью таблицы DRT находит соответствующий элемент UCB в таблице оборудования. Если устройство уже занято, то описатель задачи, за- прос которой сейчас обрабатывается супервизором ввода/вывода, помещается в список задач, ожидающих настоящее устройство. Если же устройство свобод- но, то супервизор ввода/вывода определяет из UCB тип устройства и при необ- ходимости запускает препроцессор, позволяющий получить последовательность управляющих кодов и данных, которую сможет правильно понять и отработать устройство (действие 3). Когда «программа» управления операцией ввода/вывода будет готова, супервизор ввода/вывода передаст управление соответствующему драйверу на секцию запуска (действие 4). Драйвер инициализирует операцию управления, обнуляет счетчик тайм-аута и возвращает управление супервизору (диспетчеру задач) с тем, чтобы он поставил на процессор готовую к исполне- нию задачу (действие 5). Система работает своим чередом, но когда устройство ввода/вывода отработает посланную ему команду, оно выставляет сигнал запро- са на прерывания, по которому через таблицу прерываний управление передает- ся на секцию продолжения (действие б). Получив новую команду, устройство вновь начинает ее обрабатывать, а управление процессором опять передается
140 Глава 4. Управление вводом/выводом и файловые системы диспетчеру задач, и процессор продолжает полезную работу. Таким образом, по- лучается параллельная обработка задач, на фоне которой процессор осуществля- ет управление операциями ввода/вывода. Очевидно, что если имеются специальные аппаратные средства для управления вводом/выводом, снимающие эту работу с центрального процессора (речь идет о каналах прямого доступа к памяти), то в функции центрального процессора бу- дут по-прежнему входить все только что рассмотренные шаги, за исключением последнего — непосредственного управления операциями ввода/вывода. В слу- чае использования каналов прямого доступа к памяти последние исполняют со- ответствующие канальные программы и разгружают центральный процессор, из- бавляя его от непосредственного управления обменом данными между памятью и внешними устройствами. При описании этой схемы мы не стали затрагивать вопросы распределения кана- лов, контроллеров и собственно самих устройств. Также были опущены детали получения канальных программ. Будем считать, что они выходят за круг вопро- сов, рассматриваемых в учебнике по дисциплине «Системное программное обес- печение», тем более что в последнем государственном образовательном стандар- те для направления 46 — «Информатика и вычислительная техника» имеется отдельная общепрофессиональная дисциплина «Операционные системы» и спе- циальная дисциплина «Интерфейсы периферийных устройств». Синхронный и асинхронный ввод/вывод Задача, выдавшая запрос на операцию ввода/вывода, переводится супервизором в состояние ожидания завершения заказанной операции. Когда супервизор по- лучает от секции завершения сообщение о том, что операция завершилась, он пе- реводит задачу в состояние готовности к выполнению, и она продолжает свою работу. Эта ситуация соответствует синхронному вводу/выводу. Синхронный ввод/вывод является стандартным для большинства ОС. Чтобы увеличить ско- рость выполнения приложений, было предложено при необходимости использо- вать асинхронный ввод/вывод. Простейшим вариантом асинхронного вывода является так называемый буфери- рованный вывод данных на внешнее устройство, при котором данные из при- ложения передаются не непосредственно на устройство ввода/вывода, а в спе- циальный системный буфер. В этом случае логически операция вывода для приложения считается выполненной сразу же, и задача может не ожидать окон- чания действительного процесса передачи данных на устройство. Процессом реального вывода данных из системного буфера занимается супервизор ввода/ вывода. Естественно, что выделением буфера из системной области памяти за- нимается специальный системный процесс по указанию супервизора ввода/вы- вода. Итак, для рассмотренного случая вывод будет асинхронным, если, во-пер- вых, в запросе на ввод/вывод было указано на необходимость буферирования данных, а во-вторых, если устройство ввода/вывода допускает такие асинхрон- ные операции и это отмечено в UCB.
Синхронный и асинхронный ввод/вывод 141 Можно организовать и асинхронный ввод данных. Однако для этого необходи- мо не только выделить область памяти для временного хранения считываемых с устройства данных и связать выделенный буфер с задачей, заказавшей опера- цию, но и сам запрос на операцию ввода/вывода разбить на две части (на два за- проса). В первом запросе указывается операция на считывание данных, подобно тому как это делается при синхронном вводе/выводе. Однако тип (код) запроса используется другой, и в запросе указывается еще по крайней мере один допол- нительный параметр — имя (код) того системного объекта, которое получает за- дача в ответ на запрос и которое идентифицирует выделенный буфер. Получив имя буфера (будем этот системный объект условно называть таким образом, хотя в различных ОС для его обозначения используются и другие термины, на- пример — класс), задача продолжает свою работу. Здесь очень важно подчерк- нуть, что в результате запроса на асинхронный ввод данных задача не переводится супервизором ввода/вывода в состояние ожидания завершения операции ввода/ вывода, а остается в состоянии выполнения или в состоянии готовности к вы- полнению. Через некоторое время, выполнив необходимый код, который был оп- ределен программистом, задача выдает второй запрос на завершение операции ввода/вывода. В этом втором запросе к тому же устройству, который, естествен- но, имеет другой код (или имя запроса), задача указывает имя системного объек- та (буфера для асинхронного ввода данных) и в случае успешного завершения операции считывания данных тут же получает их из системного буфера. Если же данные еще не успели до конца переписаться с внешнего устройства в систем- ный буфер, супервизор ввода/вывода переводит задачу в состояние ожидания завершения операции ввода/вывода, и далее все напоминает обычный синхрон- ный ввод данных. Обычно асинхронный ввод/вывод предоставляется в большинстве мультипро- граммных ОС, особенно если ОС поддерживает мультизадачность с помощью механизма тредов. Однако если асинхронный ввод/вывод в явном виде отсутст- вует, его идеи можно реализовать самому, организовав для вывода данных само- стоятельный поток. Аппаратуру ввода/вывода можно рассматривать как совокупность аппаратурных процессоров, которые способны работать параллельно относительно друг друга, а также относительно центрального процессора (процессоров). На таких «про- цессорах» выполняются так называемые внешние процессы. Например, для внеш- него устройства (устройства ввода/вывода) внешний процесс может представлять собой совокупность операций, обеспечивающих перевод печатающей головки, продвижение бумаги на одну позицию, смену цвета чернил или печать каких-то символов. Внешние процессы, используя аппаратуру ввода/вывода, взаимодей- ствуют как между собой, так и с обычными «программными» процессами, вы- полняющимися на центральном процессоре. Важным при этом является то об- стоятельство, что скорости выполнения внешних процессов будут существенно (порой, на порядок или больше) отличаться от скорости выполнения обычных («внутренних») процессов. Для своей нормальной работы внешние и внутрен- ние процессы обязательно должны синхронизироваться. Для сглаживания эф- фекта сильного несоответствия скоростей между внутренними и внешними про-
142 Глава 4. Управление вводом/выводом и файловые системы цессами используют упомянутое выше буферирование. Таким образом, можно говорить о системе параллельных взаимодействующих процессов (см. главу 6). Буферы являются критическим ресурсом в отношении внутренних (программ- ных) и внешних процессов, которые при параллельном своем развитии инфор- мационно взаимодействуют. Через буфер (буферы) данные либо посылаются от некоторого процесса к адресуемому внешнему (операция вывода данных на внешнее устройство), либо от внешнего процесса передаются некоторому про- граммному процессу (операция считывания данных). Введение буферирования как средства информационного взаимодействия выдвигает проблему управле- ния этими системными буферами, которая решается средствами супервизорной части ОС. При этом на супервизор возлагаются задачи не только по выделению и освобождению буферов в системной области памяти, но и синхронизации про- цессов в соответствии с состоянием операций по заполнению или освобождению буферов, а также их ожидания, если свободных буферов в наличии нет, а запрос на ввод/вывод требует буферирования. Обычно супервизор ввода/вывода для решения перечисленных задач использует стандартные средства синхронизации, принятые в данной ОС. Поэтому если ОС имеет развитые средства для решения проблем параллельного выполнения взаимодействующих приложений и задач, то, как правило, она реализует и асинхронный ввод/вывод. Кэширование операций ввода/вывода при работе с накопителями на магнитных дисках Как известно, накопители на магнитных дисках обладают крайне низкой скоро- стью по сравнению с быстродействием центральной части компьютера. Разница в быстродействии отличается на несколько порядков. Например, современные процессоры за один такт работы, а они работают уже с частотами в 1 ГГц и более, могут выполнять по две операции. Таким образом, время выполнения операции (с позиции внешнего наблюдателя, не видящего конвейеризации при выпол- нении машинных команд, благодаря которой производительность возрастает в несколько раз) может составлять 0,5 нс (!). В то же время переход магнитной головки с дорожки на дорожку составляет несколько миллисекунд. Такие же временные интервалы имеют место и при ожидании, пока под головкой чтения/ записи не окажется нужный сектор данных. Как известно, в современных приво- дах средняя длительность на чтение случайным образом выбранного сектора данных составляет около 20 мс, что существенно медленнее, чем выборка коман- ды или операнда из оперативной памяти и уж тем более из кэша. Правда, после этого данные читаются большим пакетом (сектор, как мы уже говорили, имеет размер в 512 байтов, а при операциях с диском часто читается или записывается сразу несколько секторов). Таким образом, средняя скорость работы процессора с оперативной памятью на 2-3 порядка выше, чем средняя скорость передачи данных из внешней памяти на магнитных дисках в оперативную память.
Кэширование операций ввода/вывода при работе с накопителями на магнитных дисках 143 Для того чтобы сгладить такое сильное несоответствие в производительности основных подсистем, используется буферирование и/или кэширование1 данных. Простейшим вариантом ускорения дисковых операций чтения данных можно считать использование двойного буферирования. Его суть заключается в том, что пока в один буфер заносятся данные с магнитного диска, из второго буфера ранее считанные данные могут быть прочитаны и переданы запросившей их за- даче. Аналогичный процесс происходит и при записи данных. Буферирование используется во всех операционных системах, но помимо буферирования приме- няется и кэширование. Кэширование исключительно полезно в том случае, когда программа неоднократно читает с диска одни и те же данные. После того как они один раз будут помещены в кэш, обращений к диску больше не потребуется и скорость работы программы значительно возрастет. Если не вдаваться в подробности, то под кэшем можно понимать некий пул буфе- ров, которыми мы управляем с помощью соответствующего системного процес- са. Если мы считываем какое-то множество секторов, содержащих записи того или иного файла, то эти данные, пройдя через кэш, там остаются (до тех пор, пока другие секторы не заменят эти буферы). Если впоследствии потребуется повторное чтение, то данные могут быть извлечены непосредственно из опера- тивной памяти без фактического обращения к диску. Ускорить можно и опера- ции записи: данные помещаются в кэш, и для запросившей эту операцию задачи можно считать, что они уже фактически и записаны. Задача может продолжить свое выполнение, а системные внешние процессы через некоторое время запи- шут данные на диск. Это называется операцией отложенной записи (lazy write, «ленивая запись»). Если отложенная запись отключена, только одна задача мо- жет записывать на диск свои данные. Остальные приложения должны ждать своей очереди. Это ожидание подвергает информацию риску не меньшему (если не большему), чем отложенная запись, которая к тому же и более эффективна по скорости работы с диском. Интервал времени, после которого данные будут фактически записываться, с од- ной стороны, желательно выбрать больше, поскольку если потребуется еще раз прочитать эти данные, то они уже и так фактически находятся в кэше. И после мо- дификации эти данные опять же помещаются в быстродействующий кэш. С дру- гой стороны, для большей надежности данные желательно поскорее отправить во внешнюю память, поскольку она энергонезависима и в случае какой-нибудь аварии (например, нарушения питания) данные в оперативной памяти пропадут, в то время как на магнитном диске они с большой вероятностью останутся в безопасности. Количество буферов, составляющих кэш, ограничено, поэтому возникает ситуация, когда вновь прочитанные или записываемые новые секторы данных должны будут заменить данные в этих буферах. Возможно использова- ние различных дисциплин, в соответствии с которыми будет назначен какой-ли- бо буфер под вновь затребованную операцию кэширования. Кэширование дисковых операций может быть существенно улучшено за счет введения техники упреждающего чтения (read ahead). Она основана на чтении с диска гораздо большего количества данных, чем на самом деле запросила опера- 1 Disk cache — кэш диска.
144 Глава 4. Управление вводом/выводом и файловые системы ционная система или приложение. Когда некоторой программе требуется счи- тать с диска только один сектор, программа кэширования читает еще и несколько дополнительных блоков данных. А операции последовательного чтения несколь- ких секторов фактически несущественно замедляют операцию чтения затребо- ванного сектора с данными. Поэтому, если программа вновь обратится к диску, вероятность того, что нужные ей данные уже находятся в кэше, достаточно высо- ка. Поскольку передача данных из одной области памяти в другую происходит во много раз быстрее, чем чтение их с диска, кэширование существенно сокраща- ет время выполнения операций с файлами. Итак, путь информации от диска к прикладной программе пролегает как через буфер, так и через файловый кэш. Когда приложение запрашивает с диска дан- ные, программа кэширования перехватывает этот запрос и читает вместе с необ- ходимыми секторами еще и несколько дополнительных. Затем она помещает в буфер требующуюся задаче информацию и ставит об этом в известность опера- ционную систему. Операционная система сообщает задаче, что ее запрос выпол- нен и данные с диска находятся в буфере. При следующем обращении приложе- ния к диску программа кэширования прежде всего проверяет, не находятся ли уже в памяти затребованные данные. Если это так, то она копирует их в буфер; если же их в кэше нет, то запрос на чтение диска передается операционной сис- теме. Когда задача изменяет данные в буфере, они копируются в кэш. В ряде ОС имеется возможность указать в явном виде параметры кэширования, в то время как в других за эти параметры отвечает сама ОС. Так, например, в системе Windows NT нет возможности в явном виде управлять ни объемом файлового кэша, ни параметрами кэширования. В системах Windows 95/98 та- кая возможность уже имеется, но она представляет не слишком богатый выбор. Фактически мы можем указать только объем памяти, отводимый для кэширова- ния, и объем порции данных (буфер или chunk1), из которых набирается кэш. В файле System.ini есть возможность в секции [VCACHE] прописать, например, сле- дующие значения: [vcache] M1nF11eCache=4096 MaxFileCache=32768 ChunkSize=512 Здесь указано, что минимально под кэширование данных зарезервировано 4 Мбайт оперативной памяти, максимальный объем кэша может достигать 32 Мбайт, а раз- мер данных, которыми манипулирует менеджер кэша, равен одному сектору. В других ОС можно указывать больше параметров, определяющих работу под- системы кэширования. Пример, демонстрирующий эти возможности, можно по- смотреть в разделе «Файловая система HPFS». Помимо описанных действий ОС может выполнять и работу по оптимизации перемещения головок чтения/записи данных, связанного с выполнением запро- сов от параллельно выполняющихся задач. Время, необходимое на получение данных с магнитного диска, складывается из времени перемещения магнитной 1 Chunk — кусочек.
Кэширование операций ввода/вывода при работе с накопителями на магнитных дисках 145 головки на требуемый цилиндр и времени ожидания заданного сектора; вре- менем считывания найденного сектора и затратами на передачу этих данных в оперативную память мы можем пренебречь. Таким образом, основные затраты времени уходят на поиск данных. В мультипрограммных ОС при выполнении многих задач запросы на чтение и запись данных могут идти таким потоком, что при их обслуживании образуется очередь. Если выполнять эти запросы в поряд- ке поступления их в очередь, то вследствие случайного характера обращений к тому или иному сектору магнитного диска мы можем иметь значительные поте- ри времени на поиск данных. Напрашивается очевидное решение: поскольку вы- полнение переупорядочивания запросов с целью минимизации затрат времени на поиск данных можно выполнить очень быстро (практически этим временем можно пренебречь, учитывая разницу в быстродействии центральной части и устройств ввода/вывода), то необходимо найти метод, позволяющий перестраи- вать очередь запросов оптимальным образом. Изучение этой проблемы позволи- ло найти наиболее эффективные дисциплины планирования. Перечислим известные дисциплины, в соответствии с которыми можно пере- страивать очередь запросов на операции чтения/записи данных [28]: □ SSTF (shortest seek time — first) — с наименьшим временем поиска — первым. В соответствии с этой дисциплиной при позиционировании магнитных голо- вок следующим выбирается запрос, для которого необходимо минимальное перемещение с цилиндра на цилиндр, даже если этот запрос не был первым в очереди на ввод/вывод. Однако для этой дисциплины характерна резкая дис- криминация определенных запросов, а ведь они могут идти от высокоприори- тетных задач. Обращения к диску проявляют тенденцию концентрироваться, в результате чего запросы на обращение к самым внешним и самым внутрен- ним дорожкам могут обслуживаться существенно дольше и нет никакой гаран- тии обслуживания. Достоинством такой дисциплины является максимально возможная пропускная способность дисковой подсистемы. □ Scan (сканирование). По этой дисциплине головки перемещаются то в одном, то в другом «привилегированном» направлении, обслуживая «по пути» под- ходящие запросы. Если при перемещении головок чтения/записи более нет попутных запросов, то движение начинается в обратном направлении. □ Next-Step Scan — отличается от предыдущей дисциплины тем, что на каждом проходе обслуживаются только запросы, которые уже существовали на мо- мент начала прохода. Новые запросы, появляющиеся в процессе перемеще- ния головок чтения/записи, формируют новую очередь запросов, причем та- ким образом, чтобы их можно было оптимально обслужить на обратном ходу. □ C-Scan (циклическое сканирование). По этой дисциплине головки перемеща- ются циклически с самой наружной дорожки к внутренним, по пути обслу- живая имеющиеся запросы, после чего вновь переносятся к наружным ци- линдрам. Эту дисциплину иногда реализуют таким образом, чтобы запросы, поступающие во время текущего прямого хода головок, обслуживались не по- путно, а при следующем ходе, что позволяет исключить дискриминацию за- просов к самым крайним цилиндрам; она характеризуется очень малой дис- персией времени ожидания обслуживания [28]. Эту дисциплину обслуживания часто называют «элеваторной».
146 Глава 4. Управление вводом/выводом и файловые системы Функции файловой системы ОС и иерархия данных Напомним, что под файлом обычно понимают набор данных, организованных в виде совокупности записей одинаковой структуры. Для управления этими дан- ными создаются соответствующие системы управления файлами. Возможность иметь дело с логическим уровнем структуры данных и операций, выполняемых над ними в процессе их обработки, предоставляет файловая система. Таким об- разом, файловая система — это набор спецификаций и соответствующее им про- граммное обеспечение, которые отвечают за создание, уничтожение, организацию, чтение, запись, модификацию и перемещение файловой информации, а также за управление доступом к файлам и за управление ресурсами, которые используют- ся файлами. Именно файловая система определяет способ организации данных на диске или на каком-нибудь ином носителе данных. В качестве примера можно привести файловую систему FAT, реализация для которой имеется в абсолют- ном большинстве ОС, работающих в современных ПК1. Как правило, все современные ОС имеют соответствующие системы управле- ния файлами. В дальнейшем постараемся различать файловую систему и систе- му управления файлами. Система управления файлами является основной подсистемой в абсолютном большинстве современных операционных систем, хотя в принципе можно обхо- диться и без нее. Во-первых, через систему управления файлами связываются по данным все системные обрабатывающие программы. Во-вторых, с помощью этой системы решаются проблемы централизованного распределения дискового про- странства и управления данными. В-третьих, благодаря использованию той или иной системы управления файлами пользователям предоставляются следующие возможности: □ создание, удаление, переименование (и другие операции) именованных набо- ров данных (именованных файлов) из своих программ или посредством спе- циальных управляющих программ, реализующих функции интерфейса пользо- вателя с его данными и активно использующих систему управления файлами; □ работа с не дисковыми периферийными устройствами как с файлами; □ обмен данными между файлами, между устройствами, между файлом и уст- ройством (и наоборот); □ работа с файлами с помощью обращений к программным модулям системы управления файлами (часть API ориентирована именно на работу с файлами); □ защита файлов от несанкционированного доступа. 1 В MS-DOS, OS/2, Windows 95, Windows NT, Linux имеется поддержка работы с файла- ми, организованными по принципам FAT. Однако программные модули соответствую- щих файловых систем не взаимозаменяемы. Кроме этого, все эти файловые системы имеют свои индивидуальные особенности и ограничения. Таким образом, иногда только из контекста ясно, о чем идет речь — о принципах работы файловой системы или о ее конкретной реализации.
Структура магнитного диска (разбиение дисков на разделы) 147 В некоторых ОС может быть несколько систем управления файлами, что обеспе- чивает им возможность работать с несколькими файловыми системами. Очевид- но, что системы управления файлами, будучи компонентом ОС, не являются не- зависимыми от этой ОС, поскольку они активно используют соответствующие вызовы API (application program interface, прикладной программный интерфейс). С другой стороны, системы управления файлами сами дополняют API новыми вызовами. Можно сказать, что основное назначение файловой системы и соот- ветствующей ей системы управления файлами — организация удобного доступа к данным, организованным как файлы, то есть вместо низкоуровневого доступа к данным с указанием конкретных физических адресов нужной нам записи ис- пользуется логический доступ с указанием имени файла и записи в нем. Другими словами, термин «файловая система» определяет, прежде всего, прин- ципы доступа к данным, организованных в файлы. Этот же термин часто исполь- зуют и по отношению к конкретным файлам, расположенным на том или ином носителе данных. А термин «система управления файлами» следует употреблять по отношению к конкретной реализации файловой системы, то есть это — ком- плекс программных модулей, обеспечивающих работу с файлами в конкретной операционной системе. Следует еще раз заметить, что любая система управления файлами не существу- ет сама по себе — она разработана для работы в конкретной ОС. В качестве при- мера можно сказать, что всем известная файловая система FAT (file allocation table) имеет множество реализаций как система управления файлами. Так, сис- тема, получившая это название и разработанная для первых персональных ком- пьютеров, называлась просто FAT (сейчас ее называют FAT-12). Ее разрабаты- вали для работы с дискетами, и некоторое время она использовалась при работе с жесткими дисками. Потом ее усовершенствовали для работы с жесткими диска- ми большего объема, и эта новая реализация получила название FAT-16. Это на- звание файловой системы мы используем и по отношению к системе управления файлами самой MS-DOS. Реализацию же системы управления файлами для OS/2, которая использует основные принципы системы FAT, называют super-FAT; ос- новное отличие — возможность поддерживать для каждого файла расширенные атрибуты. Есть версия системы управления файлами с принципами FAT и для Windows 95/98, для Windows NT и т. д. Другими словами, для работы с файла- ми, организованными в соответствии с некоторой файловой системой, для каж- дой ОС должна б^тть разработана соответствующая система управления файлами. Эта система управления файлами будет работать только в той ОС, для которой она и создана; но при этом она позволит работать с файлами, созданными с по- мощью системы управления файлами другой ОС, работающей по тем же основ- ным принципам файловой системы. Структура магнитного диска (разбиение дисков на разделы) Для того чтобы можно было загрузить с магнитного диска собственно саму ОС, а уже с ее помощью и организовать работу той или иной системы управления
148 Глава 4. Управление вводом/выводом и файловые системы файлами, были приняты специальные системные соглашения о структуре диска. Расположение структуры данных, несущее информацию о логической организа- ции диска и простейшую программу, с помощью которой можно находить и за- гружать программы загрузки той или иной ОС, очевидно — это самый первый сектор магнитного диска. Как известно, информация на магнитных дисках размещается и передается бло- ками. Каждый такой блок называется сектором (sector), сектора расположены на концентрических дорожках поверхности диска. Каждая дорожка (track) обра- зуется при вращении магнитного диска под зафиксированной в некотором пред- определенном положении головкой чтения/записи. Накопитель на жестких магнитных дисках (НЖМД)1 содержит один или более дисков (в современных распространенных НЖМД часто — два или три). Однако обычно под термином «жесткий диск» понимают весь пакет магнитных дисков [24]. Группы дорожек (треков) одного радиуса, расположенных на поверхностях маг- нитных дисков, образуют так называемые цилиндры (cylinder). Современные же- сткие диски могут иметь по несколько десятков тысяч цилиндров, в то время как на поверхности дискеты число дорожек (число цилиндров) ныне, как правило, составляет всего восемьдесят2. Каждый сектор состоит из поля данных и поля служебной информации, ограни- чивающей и идентифицирующей его. Размер сектора (точнее — емкость поля данных) устанавливается контроллером или драйвером. Пользовательский ин- терфейс DOS поддерживает единственный размер сектора — 512 байт. BIOS же непосредственно предоставляет возможности работы с секторами размером 128, 256, 512 или 1024 байт. Если управлять контроллером непосредственно, а не че- рез программный интерфейс более высокого уровня (например, уровень DOS), то можно обрабатывать секторы и с другими размерами. Однако в большинстве современных ОС размер сектора выбирается равным 512 байт. Физический адрес сектора на диске определяется с помощью трех «координат», то есть представляется триадой [c-h-s], где с — номер цилиндра (дорожки на по- верхности диска, cylinder), h — номер рабочей поверхности диска (магнитной го- ловки, head), as — номер сектора на дорожке. Номер цилиндра с лежит в диапа- зоне 0. .С-1, где С — количество цилиндров. Номер рабочей поверхности диска h принадлежит диапазону 0. .Н-1, где Н — число магнитных головок в накопителе. Номер сектора на дорожке s указывается в диапазоне 1. .S, где S — количество секторов на дорожке. Например, триада [1-0-2] адресует сектор 2 на дорожке 0 (обычно верхняя рабочая поверхность) цилиндра 1. В дальнейшем мы тоже бу- дем пользоваться именно этими обозначениями. 1 НЖМД обычно называется «винчестером» — так в свое время стали называть одну из первых моделей НЖМД, которая имела обозначение «30/30» и этим напоминала марки- ровку известного оружия. Возможно, также, что название происходит от места первона- чальной разработки — филиала фирмы IBM в г. Винчестере (Великобритания), где впервые в 1973 г. была применена технология создания винчестеров. 2 Это справедливо, конечно, не для всех дискет. Однако для современных 3.5" дискет мы действительно имеем 80 цилиндров, две рабочие поверхности и по 18 секторов на каждой дорожке, что в итоге дает нам 2x80x18x512 e 1 474 560 байтов (или 1,44 Мбайт).
Структура магнитного диска (разбиение дисков на разделы) 149 Напомним, что обмен информацией между ОЗУ и дисками физически осуществ- ляется только секторами. Вся совокупность физических секторов на винчестере представляет его неформатированную емкость. Жесткий диск может быть разбит на несколько разделов (partition) которые в принципе затем могут использоваться либо одной ОС, либо различными ОС. Причем самым главным является то, что на каждом разделе может быть органи- зована своя файловая система. Однако для организации даже одной-единствен- ной файловой системы необходимо определить, по крайней мере, один раздел. Разделы диска могут быть двух типов — primary (обычно этот термин переводят как первичный) и extended (расширенный). Максимальное число primary-разде- лов равно четырем. При этом на диске обязательно должен быть по крайней мере один primary-раздел. Если primary-разделов несколько, то только один из них может быть активным. Именно загрузчику, расположенному в активном разделе, передается управление при включении компьютера и загрузке операционной сис- темы. Остальные primary-разделы в этом случае считаются1 «невидимыми, скры- тыми» (hidden). Согласно спецификациям на одном жестком диске может быть только один ex- tended-раздел, который, в свою очередь, может быть разделен на большое коли- чество подразделов — логических дисков (logical). В этом смысле термин «первич- ный» следует признать не совсем удачным переводом слова primary; можно это слово перевести и как «простейший, примитивный». В этом случае становится понятным и логичным термин extended. Один из primary-разделов должен быть активным, именно с него должна загру- жаться программа загрузки операционной системы, или так называемый менед- жер загрузки, назначение которого — загрузить программу загрузки ОС из како- го-нибудь другого раздела, и уже с ее помощью загружать операционную систему. Поскольку до загрузки ОС система управления файлами работать не может, то следует использовать для указания упомянутых загрузчиков исключительно абсолютные адреса в формате [c-h-s]. По физическому адресу [0-0-1] на винчестере располагается главная загрузочная запись (master boot record, MBR), содержащая внесистемный загрузчик (non-sys- tem bootstrap — NSB), а также таблицу разделов (partition table, PT). Эта запись занимает ровно один сектор, она размещается в памяти, начиная с адреса 0:7C00h, после чего управление передается коду, содержащемуся в этом самом первом сек- торе магнитного диска. Таким образом, в самом первом (стартовом) секторе фи- зического жесткого диска находится не обычная запись boot record, как на дис- кете, a master boot record. MBR является основным средством загрузки с жесткого диска, поддерживаемым BIOS. В MBR находятся три важных элемента: □ программа начальной загрузки (non-system bootstrap). Именно она запуска- ется BIOS после успешной загрузки в память первого сектора с MBR. Она, очевидно, не превышает 512 байт и ее хватает только на то, чтобы загрузить 1 Невидимыми они являются только для тех систем, которые используют спецификации DOS.
150 Глава 4. Управление вводом/выводом и файловые системы следующую, чуть более сложную программу, обычно — стартовый сектор опе- рационной системы — и передать ему управление; □ таблица описания разделов диска (partition table). Располагается в MBR по смещению OxlBE и занимает 64 байта; □ сигнатура MBR. Последние два байта MBR должны содержать число AA55h. По наличию этой сигнатуры BIOS проверяет, что первый блок был загружен успешно. Сигнатура эта выбрана не случайно. Ее успешная проверка позво- ляет установить, что все линии передачи данных могут передавать и нули, и единицы. Таблица partition table описывает размещение и характеристики имеющихся на винчестере разделов. Можно сказать, что эта таблица разделов — одна из наибо- лее важных структур данных на жестком диске. Если эта таблица повреждена, то не только не будет загружаться операционная система (или одна из операцион- ных систем, установленных на винчестере), но перестанут быть доступными и данные, расположенные на винчестере, особенно если жесткий диск был разбит на несколько разделов. Смещение (Offset) Размер (Sise) (байт) Содержимое (Contents) 0 446 Программа анализа Partition Table и загрузки System Bootstrap с активного раздела жесткого диска +1BEh 16 Partition 1 entry (Описатель раздела) +1CEh 16 Partition 2 entry +1DEh 16 Partition 3 entry +1EEh 16 Partition 3 entry +1FEh 16 Сигнатура (AA55h) Рис. 4.4. Структура MBR Упрощенно структура MBR представлена на рис. 4.4. Из нее видно, что в начале этого сектора располагается программа анализа таблицы разделов и чтения пер- вого сектора из активного раздела диска. Сама таблица partition table располага- ется в конце MBR, и для описания каждого раздела в этой таблице отводится по 16 байтов. Первым байтом в элементе раздела идет флаг активности раздела boot indicator (0 — не активен, 128 (80Н) — активен). Он служит для определения, является ли раздел системным загрузочным и есть ли необходимость произво- дить загрузку операционной системы с него при старте компьютера. Активным может быть только один раздел. За флагом активности раздела следует байт но- мера головки, с которой начинается раздел. За ним следует два байта, означаю- щие соответственно номер сектора и номер цилиндра загрузочного сектора, где
Структура магнитного диска (разбиение дисков на разделы) 151 располагается первый сектор загрузчика операционной системы. Затем следует кодовый идентификатор System ID (длиной в один байт), указывающий на при- надлежность данного раздела к той или иной операционной системе и установке на нем соответствующей файловой системы. В табл. 4.1 приведены некоторые (наиболее известные) идентификаторы. Таблица 4.1. Сигнатуры (типы) разделов System ID Тип раздела System ID | Тип раздела 00 Empty («пустой» раздел) 41 PPC PreP Boot 01 FAT12 42 SFS 02 XENIX root 4D QNX 4.x 03 XENIX usr 4E QNX 4.x 2nd part 04 FAT16 (<32 Мбайт) 4F QNX 4.x 3nd part 05 Extended 50 OnTrack DM 06 FAT 16 51 OnTrack DM6 Aux 07 HPFS/NTFS 52 CP/M 08 AIX 53 OnTrack DM6 09 AIX bootable 54 OnTrack DM6 0А OS/2 Boot Manager 55 EZ Drive ОВ Win95 FAT32 56 Golden Bou ОС Win95 FAT32 LBA 5C Priam Edisk 0Е Win95 FAT16 LBA 61 Speed Stor 0F Win95 Extended 64 Novell Netware 10 OPUS 65 Novell Netware И Hidden FAT12 75 PC/IX 12 Compaq diagnost 80 Old Minix 14 Hidden FAT16 (<32 Мбайт) 82 Linux swap 16 Hidden FAT16 83 Linux native 17 Hidden HPFS/NTFS 84 OS/2 hidden C: 18 AST Windows swap 85 Linux Extended 1В Hidden Win95 Fat 86 NTFS volume set 1С Hidden Win95 Fat A5 BSD/386 1Е Hidden Win95 Fat A6 Open BSD 24 NEC DOS A7 Next Step ЗС Partition Magic EB Be OS 40 Venix 80286
152 Глава 4. Управление вводом/выводом и файловые системы За байтом кода операционной системы расположен байт номера головки конца раздела, за которым идут два байта — номер сектора и номер цилиндра послед- него сектора данного раздела. Ниже представлен формат элемента таблицы раз- делов. Таблица 4.2. Формат элемента таблицы разделов Название записи элемента Partition Table Длина, байт Флаг активности раздела 1 Номер головки начала раздела 1 Номер сектора и номер цилиндра загрузочного сектора раздела 2 Кодовый идентификатор операционной системы 1 Номер головки конца раздела 1 Номер сектора и цилиндра последнего сектора раздела 2 Младшее и старшее двухбайтовое слово относительного номера начального сектора 4 Младшее и старшее двухбайтовое слово размера раздела в секторах 4 Номера сектора и номер цилиндра секторов в разделах занимают по 6 и 10 бит соответственно. Ниже представлен формат записи, содержащей номера сектора и цилиндра. Биты номера цилиндра Биты номера сектора 15 14 13 12 И 10 9 8 7 6 5 4 3 2 1 0 Как мы уже сказали, загрузчик non-system bootstrap служит для поиска с по- мощью partition table активного раздела, копирования в оперативную память компьютера загрузчика system bootstrap из выбранного раздела и передачи ему управления, что позволяет осуществить загрузку ОС. Вслед за сектором MBR размещаются собственно сами разделы (рис. 4.5). В про- цессе начальной загрузки сектора MBR, содержащего таблицу partition table, работают программные модули BIOS. Начальная загрузка считается выполнен- ной корректно только в том случае, когда таблица разделов содержит допусти- мую информацию. В MS-DOS в первичном разделе может быть сформирован только один логиче- ский диск, а в расширенном — любое их количество. Каждый логический диск «управляется» своим логическим приводом. Каждому логическому диску на винчестере соответствует своя (относительная) логическая нумерация. Физиче- ская же адресация жесткого диска сквозная. Первичный раздел DOS включает только системный логический диск без каких- либо дополнительных информационных структур.
Структура магнитного диска (разбиение дисков на разделы) 153 таблица разделов Рис. 4.5. Разбиение диска на разделы Расширенный раздел DOS с логическими дисками D: и Е: Первичный раздел DOS (диск С:) Расширенный раздел DOS содержит вторичную запись MBR (secondary MBR, SMBR), в состав которой вместо partition table входит таблица логического дис- ка (LDT, logical disk table), ей аналогичная. Таблица LDT описывает размещение и характеристики раздела, содержащего единственный логический диск, а также может специфицировать следующую запись SMBR. Следовательно, если в рас- ширенном разделе DOS создано К логических дисков, то он содержит К экземп- ляров SMBR, связанных в список. Каждый элемент этого списка описывает со- ответствующий логический диск и ссылается (кроме последнего) на следующий элемент списка. Утилиты, позволяющие разбить диск на разделы и тем самым сформировать как partition table, так и, возможно, списковую структуру из LDT (подобно тому, как это изображено на рис. 4.6), обычно называются FDISK (от form disk), хотя есть утилиты и с другим названием, умеющие выполнить разбиение диска. На- помним, что FDISK от MS-DOS позволяет создать только один primary-раздел и один extended, который, в свою очередь, предлагается разделить на несколько логических дисков. Аналогичные утилиты других ОС имеют существенно боль-
154 Глава 4. Управление вводом/выводом и файловые системы ше возможностей. Так, например, FDISK системы OS/2 позволяет создавать не- сколько primary-разделов, причем их можно выделять не только последовательно, начиная с первых цилиндров, но и с конца свободного дискового пространства. Это удобно, если нужно исключить из работы некоторый диапазон дисково- го пространства (например, из-за дефектов на поверхности магнитного диска). С помощью этой утилиты можно установить и менеджер загрузки (boot-менеджер). Прежде чем перейти к boot-менеджерам, рассмотрим еще раз процесс загрузки ОС. Процедура начальной загрузки (bootstrap loader) вызывается как программное прерывание (BIOS INT 19h). Эта процедура определяет первое готовое устрой- ство из списка разрешенных и доступных (гибкий или жесткий диск, в совре- менных компьютерах это могут быть также CD-ROM, привод ZIP-drive компании iomega, сетевой адаптер или другое устройство) и пытается загрузить с него в ОЗУ короткую главную программу-загрузчик. Для винчестеров — это загруз- чик non-system bootstrap из MBR, и ему передается управление. Главный загруз- чик определяет на диске активный раздел, загружает его собственный загрузчик (system bootstrap) и передает управление ему. И, наконец, этот загрузчик загру- жает необходимые файлы операционной системы и передает ей управление. Далее операционная система выполняет инициализацию подведомственных ей программных и аппаратных средств. Она добавляет новые сервисы, вызываемые, как правило, тоже через механизм программных прерываний, и расширяет (или заменяет) некоторые сервисы BIOS. Необходимо отметить, что в современных мультипрограммных операционных системах большинство сервисов BIOS, изна- чально расположенных в ПЗУ, как правило, заменяются собственными драйве- рами, поскольку они должны работать в режиме прерываний, а не в режиме ска- нирования готовности. Соответственно рассмотренному процессу загрузки, мы каждый раз при запуске компьютера будем попадать в одну и ту же ОС. Но иногда это нас не устраивает. Так называемые boot-менеджеры (менеджеры загрузки) предназначены для того, чтобы пользователь мог выбрать среди нескольких установленных на компью- тере ОС нужную и передать управление загрузчику этой выбранной ОС. Суще- ствует большое количество таких менеджеров, хороший обзор таких программ приведен в работе [78]. В ней рекомендуется в качестве менеджера загрузки ис- пользовать System commander. Однако этот менеджер должен располагаться в активном разделе с файловой системой FAT, что в наше время нельзя признать хорошим решением. На наш взгляд, одним из лучших долгое время был Boot manager компании IBM, входящий в состав утилит OS/2. Для его размещения создается отдельный primary-раздел, который, естественно, и является активным (он помечается как startable). Раздел для Boot-менеджера OS/2 занимает всего один цилиндр и может размещаться не только на нулевом (начальном) цилиндре, но и на последнем цилиндре. В этом разделе не организуется никакой файловой системы, поскольку обращение к менеджеру идет с использованием абсолютной адресации, а сам Boot manager представляет собой простейшую абсолютную дво- ичную программу. Установка Boot manager осуществляется из программы FDISK. При этом появляется возможность указать, какие разделы могут быть загружае- мыми (они помечаются как bootable), то есть в каких разделах на первом логиче- ском секторе будут размещены программы загрузки ОС. Загружаемыми могут
Структура магнитного диска (разбиение дисков на разделы) 155 быть как primary-, так и extended-разделы. При этом, естественно, имеется воз- можность указать как время на принятие решения, так и загрузку некоторой ОС «по умолчанию». Удобным является и то, что при разбиении диска на разделы можно вообще не создавать primary-разделов. Это особенно важно, если в ком- пьютере установлено более одного дискового накопителя либо если мы подго- тавливаем винчестер, который должен достаточно часто переноситься с одного компьютера на другой. Дело в том, что в большинстве ОС принято следующее правило именования логических дисков: первый логический диск обозначается литерой С:, второй — D: и т. д. При этом литеру С: получает активный primary- раздел. Однако если к одному винчестеру в персональном компьютере подключить второй винчестер и он тоже имеет активный primary-раздел, то этот primary-раз- дел второго винчестера получит литеру D:, отодвигая логический диск D: перво- го винчестера на место диска Е: (и т. д. по цепочке). Если же второй (и после- дующие) винчестер не имеет primary-раздела с установленной на нем файловой системой, которую данная ОС знает, то «именование» логических дисков перво- го винчестера не нарушается. Естественно, что логические диски второго винче- стера получают литеры логических дисков вслед за дисками первого винчестера. Boot manager OS/2, создавая только логические диски на винчестере, на самом деле создает и «пустой» primary-раздел, однако этот раздел не становится актив- ным и не получает статус логического диска. К сожалению, все более популяр- ная в наши дни ОС Windows 2000 теперь не только снимает флаг активности с раздела, в котором размещен Boot manager (как это происходило при инсталля- ции любых ОС от компании Microsoft), но и физически уничтожает его двоичный код. Замена драйвера FASTFAT.SYS системы Windows 2000 на более раннюю вер- сию (в бета-версии ОС Windows 2000 система не уничтожала код Boot manager) помогает лишь до установки Service pack. Поэтому рекомендовать этот менеджер загрузки уже нельзя. Из последних менеджеров загрузки, пожалуй, наиболее мощ- ным является Boot star, но его нельзя рекомендовать неподготовленным пользо- вателям. Одной из самых известных и до сих пор достаточно часто используемых утилит, с помощью которой можно посмотреть и отредактировать таблицу разделов, а также выполнить и другие действия, связанные с изучением и исправлением данных как на дискете, так и на винчестере, является программа Disk editor из комплекта нортоновских утилит. Работа с ней достаточно подробно изложена в книге [9]. Следует, однако, заметить, что Disk editor, с одной стороны, очень мощ- ное средство, и поэтому его следует использовать с большой осторожностью, а с другой стороны — эта утилита работает только в среде DOS. На сегодняшний день главным недостатком этой утилиты является ограничение на максималь- ный размер диска в 8 Гбайт. Надо признать, что в последнее время появилось большое количество утилит, которые предоставляют возможность более наглядно представить разбиение дис- ка на разделы, поскольку в них используется графический интерфейс. Эти про- граммы успешно и корректно работают с наиболее распространенными типами разделов (разделы под FAT, FAT32, NTFS). Однако созданы они в основном для работы в среде Win32API, что часто ограничивает возможность их использова- ния. Одной из самых известных и мощных программ для работы с разделами же- сткого диска является Partition Magic фирмы Power Quest.
156 Глава 4. Управление вводом/выводом и файловые системы Файловая система FAT Как мы уже отмечали, аббревиатура FAT (file allocation table) расшифровывает- ся как «таблица размещения файлов». Этот термин относится к линейной таб- личной структуре со сведениями о файлах — именами файлов, их атрибутами и другими данными, определяющими местонахождение файлов (или их фрагмен- тов) в среде FAT. Элемент FAT определяет фактическую область диска, в кото- рой хранится начало физического файла. В файловой системе FAT логическое дисковое пространство любого логического диска делится на две области (рис. 4.6): системную область и область данных. R Sec -- Fat1 — Fat2 Dir Каталоги и файлы Системная область Область данных Рис. 4.6. Структура логического диска Системная область логического диска создается и инициализируется при форма- тировании, а впоследствии обновляется при манипулировании файловой структу- рой. Область данных логического диска содержит файлы и каталоги, подчиненные корневому. Она, в отличие от системной области, доступна через пользователь- ский интерфейс DOS. Системная область состоит из следующих компонентов, расположенных в логическом адресном пространстве подряд: □ загрузочной записи (boot record, BR); □ зарезервированных секторов (reserved sector, ResSecs); □ таблицы размещения файлов (file allocation table, FAT); □ корневого каталога (root directory, RDir). Таблица размещения файлов Таблица размещения файлов является очень важной информационной структу- рой. Можно сказать, что она представляет собой карту (образ) области данных, в которой описывается состояние каждого участка области данных. Область дан- ных разбивают на так называемые кластеры. Кластер представляет собой один или несколько смежных секторов в логическом дисковом адресном пространстве (точнее — только в области данных). В таблице FAT кластеры, принадлежащие одному файлу (некорневому каталогу), связываются в цепочки. Для указания номера кластера в системе управления файлами FAT-16 используется 16-бито- вое слово, следовательно, можно иметь до 216 = 65536 кластеров (с номерами от О до 65535). Кластер — это минимальная адресуемая единица дисковой памяти, выделяемая файлу (или некорневому каталогу). Файл или каталог занимает целое число
Файловая система FAT 157 кластеров. Последний кластер при этом может быть задействован не полностью, что приведет к заметной потере дискового пространства при большом размере кластера. На дискетах кластер занимает один или два сектора, а на жестких дис- ках — в зависимости от объема раздела (см. табл. 4.3). Таблица 4.3. Соотношения между размером раздела и размером кластеров в FAT 16 Емкость раздела, Мбайт Количество секторов в кластере Размер кластеров, Кбайт 16-127 4 2 128-255 8 4 256-511 16 8 512-1023 32 16 1024-2047 64 32 Номер кластера всегда относится к области данных диска (пространству, заре- зервированному для файлов и подкаталогов). Первый допустимый номер кла- стера всегда начинается с 2. Номера кластеров соответствуют элементам табли- цы размещения файлов. Логическое разбиение области данных на кластеры как совокупности секторов взамен использования одиночных секторов имеет следующий смысл: прежде всего, уменьшается размер самой таблицы FAT; уменьшается возможная фрагмента- ция файлов; ускоряется доступ к файлу, так как в несколько раз сокращается длина цепочек фрагментов дискового пространства, выделенных для него. Однако слишком большой размер кластера ведет к неэффективному использова- нию области данных, особенно в случае большого количества маленьких файлов. Как мы только что заметили, в среднем на каждый файл теряется около поло- вины кластера. Из табл. 4.3 следует, что при размере кластера в 32 сектора (объем раздела — от 512 Мбайт до 1023 Мбайт), то есть 16 Кбайт, средняя вели- чина потерь на файл составит 8 Кбайт, и при числе файлов в несколько тысяч1 потери могут составлять более 100 Мбайт. Поэтому в современных файловых системах (к ним, прежде всего, следует отнести HPFS, NTFS, FAT32 и некото- рые другие, поддерживаемые ОС семейства UNIX) размеры кластеров ограничи- ваются (обычно — от 512 байт до 4 Кбайт). В FAT32 проблема решается за счет того, что собственно сама FAT в этой файловой системе может содержать до 228 кластеров2. Наконец, заметим, что системы управления файлами, созданные для 1 Например, число 10 000-15 000 файлов (или даже более, особенно когда файлы неболь- шого размера) на логическом диске с объемом в 1000 Мбайт встречается достаточно часто. 2 В файловой системе FAT32 в 32-битовом слове, используемом для представления номе- ра кластера, фактически учитываются только 28 разрядов, что приводит к тому, что дли- на FAT в этой системе не может превышать 228 элементов.
158 Глава 4. Управление вводом/выводом и файловые системы Windows 9х и Windows NT, могут работать с разделами размером до 4 Гбайт, на которых установлена система FAT, тогда как DOS, естественно, с такими разде- лами работать не сможет. Достаточно наглядно идея файловой системы с использованием таблицы разме- щения файлов FAT проиллюстрирована рис. 4.7. Из этого рисунка видно, что файл с именем MYFILE.TXT размещается начиная с восьмого кластера. Всего файл MYFILE.TXT занимает 12 кластеров. Цепочка кластеров (chain) для нашего приме- ра может быть записана следующим образом: 8, 9, ОА, ОВ, 15, 16, 17, 19, 1А, 1В, 1С, 1D. Кластер с номером 18 помечен специальным кодом F7 как плохой (bad), он не может быть использован для размещения данных. При форматировании обычно проверяется поверхность магнитного диска, и те секторы, при контрольном чтении с которых происходили ошибки, помечаются в FAT как плохие. Кластер 1D помечен кодом FF как конечный (последний в цепочке) кластер, принадле- жащий данному файлу. Свободные (незанятые) кластеры помечаются кодом 00; при выделении нового кластера для записи файла берется первый свободный кластер. Поскольку файлы на диске изменяются — удаляются, перемещаются, увеличиваются или уменьшаются, — то упомянутое правило выделения первого свободного кластера для новой порции данных приводит к фрагментации фай- лов, то есть данные одного файла могут располагаться не в смежных кластерах, а, порой, в очень удаленных друг от друга, образуя сложные цепочки. Естествен- но, что это приводит к существенному замедлению работы с файлами. Рис. 4.7. Основная концепция FAT Так как FAT используется при доступе к диску очень интенсивно, она обычно загружается в ОЗУ (в буфера ввода/вывода или кэш) и остается там настолько долго, насколько это возможно. В связи с чрезвычайной важностью FAT она обычно хранится в двух идентич- ных экземплярах, второй из которых непосредственно следует за первым. Об- новляются копии FAT одновременно. Используется же только первый экземп- ляр. Если он по каким-либо причинам окажется разрушенным, то произойдет обращение ко второму экземпляру. Так, например, утилита проверки и восста- новления файловой структуры ScanDisk из ОС Windows 9х при обнаружении не- соответствия первичной и резервной копии FAT предлагает восстановить глав- ную таблицу, используя данные из копии.
Файловая система FAT 159 Упомянутый корневой каталог отличается от обычного каталога тем, что он, по- мимо размещения в фиксированном месте логического диска, еще имеет и фик- сированное число элементов. Для каждого файла и каталога в файловой системе хранится информация в соответствии со структурой, изображенной в табл. 4.4. Таблица 4.4. Элемент каталога Размер поля данных, байт Содержание поля 11 Имя файла или каталога 1 Атрибуты файла 1 Резервное поле 3 Время создания 2 Дата создания 2 Дата последнего доступа 2 Зарезервировано 2 Время последней модификации 2 Дата последней модификации 2 Номер начального кластера в FAT 4 Размер файла Структура системы файлов является иерархической. Это иллюстрируется рис. 4.8, из которого видно, что элементом каталога может быть такой файл, который сам, в свою очередь, является каталогом. Рис. 4.8. Структура системы файлов
160 Глава 4. Управление вводом/выводом и файловые системы Структура загрузочной записи DOS Сектор, содержащий загрузочную запись, является самым первым на логическом диске (на дискете — имеет физический адрес [0-0-1]). Boot Record состоит, как мы уже знаем, из двух частей — disk parameter block (DPB) и system bootstrap (SB). Структура блока параметров диска (DPB) служит для идентификации фи- зического и логического форматов логического диска, а загрузчик system boot- strap играет существенную роль в процессе загрузки DOS. Эта информационная структура приведена в табл. 4.5. Таблица 4.5. Структура загрузочной записи Boot Record для FAT16 Смещение поля, байт Длина поля, байт Обозначение поля Содержимое поля ООН (0) 3 JUMP ЗЕН Безусловный переход на начало SB ОЗН (3) 8 Системный идентификатор ОВН (11) 2 SectSize Размер сектора, байт 0DH (13) 1 ClastSize Число секторов в кластере ОЕН (14) 2 ResSecs Число зарезервированных секторов ЮН (16) 1 FATcnt Число копий FAT ИН (17) 2 RootSize Максимальное число элементов Rdir 13Н (19) 2 TotSecs Число секторов на логическом диске, если его размер не превышает 32 Мбайт; иначе 0000Н 15Н (21) 1 Media Дескриптор носителя 16Н (22) 2 FATsize Размер FAT, секторов 18Н (24) 2 TrkSecs Число секторов на дорожке 1АН (26) 2 HeadCnt Число рабочих поверхностей 1СН (28) 4 HidnSecs Число скрытых секторов 20Н (32) 4 Число секторов на логическом диске, если его размер превышает 32 Мбайт 24Н (36) 1 Тип логического диска (ООН — гибкий, 80Н — жесткий) 25Н (37) 1 Пусто (резерв) 26Н (38) 1 Маркер с кодом 29Н 27Н (39) 4 Серийный номер тома 2ВН (43) И Метка тома
Файловая система FAT 161 Смещение поля, байт Длина поля, байт Обозначение поля Содержимое поля 36Н (54) 8 Имя файловой системы ЗЕН (62) System bootstrap 1FEH (510) 2 Сигнатура (слово АА55Н) Первые два байта boot record занимает JMP — команда безусловного перехода в программу SB. Третий байт содержит код ЭОН (NOP — нет операции). Далее располагается восьмибайтовый системный идентификатор, включающий инфор- мацию о фирме-разработчике и версии операционной системы. Затем следует DPB, а после него — SB. Для работы с загрузочной записью удобно использовать широко известную ути- литу Disk Editor из комплекта утилит Питера Нортона. Эта утилита снабжена встроенной системой подсказок и необходимой справочной информацией. Ис- пользуя ее, можно сохранять, модифицировать и восстанавливать загрузочную запись, а также выполнять много других операций. Достаточно подробно работа с этой утилитой описана в книге [9]. Загрузочные записи других операционных систем отличаются от рассмотренной. Так, например, в загрузочном секторе для тома с FAT32 в блоке DPB содержатся дополнительные поля, а те поля, что находятся в привычном для системы FAT 16 месте, перенесены. Поэтому ОС, в которой имеется возможность работать с фай- ловой системой FAT 16, но нет системы управления файлами, понимающей спе- цификации FAT32, не может читать данные с томов, отформатированных под файловую систему FAT32. В загрузочном секторе для файловой системы FAT32 по-прежнему байты ООН по ОАН содержат команду перехода и OEM ID, а в бай- тах ОВН по 59Н содержатся данные блока DPB. Отличие заключается именно в несколько другой структуре блока DBP; его содержимое приведено1 в табл. 4.6. Таблица 4.6. Структура загрузочной записи boot record для FAT32 Смещение поля, байт Длина поля, байт Обозначение поля Содержимое поля ООН (0) 3 JUMP ЗЕН Безусловный переход на начало SB ОЗН (3) 8 Системный идентификатор ОВН (И) 2 SectSize Размер сектора, байт 0DH (13) 1 ClastSize Число секторов в кластере ОЕН (14) 2 ResSecs Число зарезервированных секторов, для FAT32 равно 32 продолжение 1 Поскольку файловая система FAT32 нынче имеет очень широкое распространение, мы считаем, что информаця, приведенная в табл. 4.6, может быть полезна.
162 Глава 4. Управление вводом/выводом и файловые системы Таблица 4.6 (продолжение) Смещение поля, байт Длина поля, байт Обозначение поля Содержимое поля ЮН (16) 1 FATcnt Число копий FAT ИН (17) 2 RootSize ООООН 13Н (19) 2 TotSecs ООООН 15Н (21) 1 Media Дескриптор носителя 16Н (22) 2 FATsize ООООН 18Н (24) 2 TrkSecs Число секторов на дорожке 1АН (26) 2 HeadCnt Число рабочих поверхностей 1СН (28) 4 HidnSecs Число скрытых секторов (располагаются перед загрузочным сектором). Используется при загрузке для вычисления абсолютного смещения корневого каталога и данных 20Н (32) 4 Число секторов на логическом диске 24Н (36) 4 Число секторов в таблице FAT 28Н (37) 2 Расширенные флаги 2АН (38) 2 Версия файловой системы 2СН (39) 4 Номер кластера для первого кластера корневого каталога 34Н (43) 2 Номер сектора с резервной копией загрузочного сектора 36Н (54) 12 Зарезервировано Файловые системы VFAT и FAT32 Одной из важнейших характеристик исходной FAT было использование имен файлов формата «8.3», в котором 8 символов отводится на указание имени фай- ла и 3 символа — для расширения имени. К стандартной FAT (имеется в виду прежде всего реализация FAT 16) добавились еще две разновидности, используе- мые в широко распространенных операционных системах Microsoft (конкретно — в Windows 95 и Windows NT): VFAT (виртуальная FAT) и FAT32, используемая в одной из редакций ОС Windows 95 и Windows 98. Ныне эта файловая система (FAT32) поддерживается и такими ОС, как Windows Millennium Edition, и все- ми ОС семейства Windows 2000. Имеются реализации систем управления фай- лами для FAT32, Windows NT и ОС Linux. Файловая система VFAT впервые появилась в Windows for Workgroups 3.11 и была предназначена для выполнения файлового ввода/вывода в защищенном
Файловая система FAT 163 режиме (см. раздел «Реальный и защищенный режимы работы процессора», глава 3). С выходом Windows 95 в VFAT добавилась поддержка длинных имен файлов (long file name, LFN). Тем не менее VFAT сохраняет совместимость с ис- ходным вариантом FAT; это означает, что наряду с длинными именами в ней поддерживаются имена формата «8.3», а также существует специальный меха- низм для преобразования имен «8.3» в длинные имена, и наоборот. Именно фай- ловая система VFAT поддерживается исходными версиями Windows 95, Win- dows NT 4. При работе с VFAT крайне важно использовать файловые утилиты, поддерживающие VFAT вообще и длинные имена в частности. Дело в том, что более ранние файловые утилиты DOS запросто модифицируют то, что кажется им исходной структурой FAT. Это может привести к потере или порче длинных имен из таблицы FAT, поддерживаемой VFAT (или FAT32). Следовательно, для томов VFAT необходимо пользоваться файловыми утилитами, которые понима- ют и сохраняют файловую структуру VFAT. В исходной версии Windows 95 основной файловой системой была 32-разрядная VFAT. VFAT может использовать 32-разрядные драйверы защищенного режима или 16-разрядные драйверы реального режима. При этом элементы FAT остают- ся 12- или 16-разрядными, поэтому на диске используется та же структура дан- ных, что и в предыдущих реализациях FAT. VFAT обрабатывает все обращения к жесткому диску и использует 32-разрядный код для всех файловых операций с дисковыми томами. Основными недостатками файловых систем FAT и VFAT являются большие по- тери на кластеризацию при больших размерах логического диска и ограничения на сам размер логического диска. Это привело к разработке новой реализации файловой системы с использованием той же идеи использования таблицы FAT. Поэтому в Microsoft Windows 95 OEM Service Release 2 (эта версия Windows 95 часто называется Windows 95 OSR2) на смену системе VFAT пришла файловая система FAT32. FAT32 является полностью самостоятельной 32-разрядной фай- ловой системой и содержит многочисленные усовершенствования и дополнения по сравнению с предыдущими реализациями FAT. Принципиальное отличие заключается в том, что FAT32 намного эффективнее расходует дисковое пространство. Прежде всего, система FAT32 использует кла- стеры меньшего размера по сравнению с предыдущими версиями, которые огра- ничивались 65 535 кластерами на том (соответственно, с увеличением размера диска приходилось увеличивать и размер кластеров). Следовательно, даже для дисков размером до 8 Гбайт FAT32 может использовать 4-килобайтные класте- ры. В результате по сравнению с дисками FAT16 экономится значительное дис- ковое пространство (в среднем 10-15 %) [53]. FAT32 также может перемещать корневой каталог и использовать резервную ко- пию FAT вместо стандартной. Расширенная загрузочная запись FAT32 позволя- ет создавать копии критических структур данных; это повышает устойчивость дисков FAT32 к нарушениям структуры FAT по сравнению с предыдущими вер- сиями. Корневой каталог в FAT32 представлен в виде обычной цепочки класте- ров. Следовательно, корневой каталог может находиться в произвольном месте диска, что снимает действовавшее ранее ограничение на размер корневого ката- лога (512 элементов).
164 Глава 4. Управление вводом/выводом и файловые системы Windows 95 OSR2 и Windows 98 могут работать и с разделами VFAT, созданны- ми Windows NT. То, что говорилось ранее об использовании файловых утилит VFAT с томами VFAT, относится и к FAT32. Поскольку прежние утилиты FAT (для FAT32 в эту категорию входят обе файловые системы, FAT и VFAT) могут повредить или уничтожить важную служебную информацию, для томов FAT32 нельзя пользоваться никакими файловыми утилитами, кроме утилит FAT32. Кроме повышения емкости FAT до величины в 4 Тбайт, файловая система FAT32 вносит ряд необходимых усовершенствований в структуру корневого каталога. Предыдущие реализации требовали, чтобы вся информация корневого каталога FAT находилась в одном дисковом кластере. При этом корневой каталог мог содержать не более 512 файлов. Необходимость представлять длинные имена и обеспечить совместимость с прежними версиями FAT привела разработчиков компании Microsoft к компромиссному решению: для представления длинного имени они стали использовать элементы каталога, в том числе и корневого. Рас- смотрим способ представления в VFAT длинного имени файла (рис. 4.9). О 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 Элемент каталога для длинного имени файла (FAT32, FAT16 и FAT32) | Номер элемента | Символы 1—5 имени файла в Unicode | Атрибуты | | Зарезервировано | | Контрольная сумма | Символы 6—11 имени файла в Unicode Должно быть равно нулю Символы 12—13 имени файла в Unicode 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 Рис. 4.9. Элементы каталогов для FAT, VFAT и FAT32
Файловая система FAT 165 Первые одиннадцать байтов элемента каталога DOS используются для хранения имени файла. Каждое такое имя разделяется на две части: в первых восьми бай- тах хранятся символы собственно имени, а в последних трех — символы так на- зываемого расширения, с помощью которого реализуются механизмы предопре- деленных типов. Были введены соответствующие системные соглашения, и файлы определенного типа необходимо (желательно) именовать с оговоренным расши- рением. Например, исполняемые файлы с расширением СОМ определяют испол- няемую двоичную программу с простейшей односегментной структурой1. Более сложные программы имеют расширение EXE. Определены расширения для боль- шого количества типов файлов, и эти расширения используются для ассоцииро- ванного запуска обрабатывающих файлы программ. Если имя файла состоит менее чем из восьми символов, то в элементе катало- га оно дополняется символами пробела, чтобы полностью заполнить все восемь байтов соответствующего поля. Аналогично и расширение может содержать от нуля до трех символов. Остальные (незаполненные) позиции в элементе катало- га, определяющем расширение имени файла, заполняются символами пробела. Поскольку при работе с именем файла учитываются все одиннадцать свободных мест, то необходимость в отображении точки, которая обычно вводится между именем файла и его расширением, отпадает. В элементе каталога она просто под- разумевается. В двенадцатом байте элемента каталога хранятся атрибуты файла. Шесть из восьми указанных разрядов используются DOS2. К атрибутам DOS относятся следующие: □ атрибут «архивный» (А — archive). Показывает, что файл был открыт про- граммой таким образом, чтобы у нее была возможность изменить содержимое этого файла. DOS устанавливает этот разряд атрибута в состояние ON (вклю- чено) при открытии файла. Программы резервного копирования нередко ус- танавливают его в OFF (выключено) при выполнении резервного копирования файла. Если применяется подобная методика, то в следующую создаваемую по порядку резервную копию будут добавлены только файлы с данным раз- рядом, установленным в состояние ON; □ атрибут каталога (D — directory). Показывает, что данный элемент каталога указывает на подкаталог, а не на файл; □ атрибут тома (V — volume). Применяется только к одному элементу каталога в корневом каталоге. В нем, собственно, и хранится имя дискового тома. Этот атрибут также применяется в случае длинных имен файлов, о чем можно бу- дет узнать из следующего раздела; □ атрибут «системный» (S — system). Показывает, что файл является частью операционной системы или специально отмечен подобным образом приклад- 1 Для программных модулей, имеющих такую структуру, может использоваться и расши- рение BIN. 2 В некоторых операционных системам, в частности в Novell Netware, используется один или два дополнительных разряда атрибутов.
166 Глава 4. Управление вводом/выводом и файловые системы ной программой, что иногда делается в качестве составной части метода за- щиты от копирования; □ атрибут «скрытый» (Н — hidden). Сюда относятся, в частности, файлы с уста- новленным в состояние ON атрибутом «системный» (S), которые не отобража- ются в обычном списке, выводимом по команде DIR; □ атрибут «только для чтения» (R — read only). Показывает, что данный файл не подлежит изменению. Разумеется, поскольку это лишь разряд байта, хра- нящегося на диске, то любая программа может изменить этот разряд, после чего DOS свободно разрешила бы изменение данного файла. Этот атрибут в основном используется для примитивной защиты от пользовательских оши- бок, то есть он позволяет избежать неумышленного удаления или изменения ключевых файлов. Следует отметить, что наличие файла, помеченного одним или более из указан- ных выше атрибутов, может иметь вполне определенный смысл. Например, боль- шинство файлов, отмечаемых в качестве системных, отмечаются также призна- ками скрытых файлов и «только для чтения». На дисках FAT12 или FAT16 следующие за именем десять байтов не используют- ся. Обыкновенно они заполняются нулями и считаются резервными значения- ми. А на диске с файловой системой FAT32 эти 10 байт содержат самую разную информацию о файле. При этом байт, отмеченный как «зарезервировано для NT», представляет собой, как подразумевает его название, поле, не используемое в DOS или Windows 9х, но применяемое в Windows NT. Из соображений совместимости поля, которые встречаются в элементах катало- га для коротких имен формата FAT12 и FAT16, находятся на тех же местах и в элементах каталога для коротких имен формата FAT32. Остальные поля, кото- рые встречаются только в элементах каталога для коротких имен формата FAT32, соответствуют зарезервированной области длиной в десять байт в элементах ка- талога для коротких имен форматов FAT12 и FAT16. Как видно из рис. 4.9, для длинного имени файла используется несколько эле- ментов каталога. Таким образом, появление длинных имен фактически приве- ло к дальнейшему уменьшению количества файлов, которые могут находиться в корневом каталоге. Поскольку длинное имя может содержать до 256 символов, всего один файл с полным длинным именем занимает до 25 элементов FAT (1 для имени 8.3 и еще 24 для самого длинного имени). Количество элементов корневого каталога VFAT уменьшается до 21. Очевидно, что это не самое изящ- ное решение, поэтому компания Microsoft советует избегать длинных имен в корневых каталогах FAT при отсутствии FAT32, у которой количество элемен- тов каталога соответственно просто увеличено. Помните и о том, что длина пол- ной файловой спецификации, включающей путь и имя файла (длинное или в формате 8.3), тоже ограничивается 260 символами. FAT32 успешно справляется с проблемой длинных имен в корневом каталоге, но проблема с ограничением длины полной файловой спецификации остается. По этой причине Microsoft ре- комендует ограничивать длинные имена 75-80 символами, чтобы оставить дос- таточно места для пути (180-185 символов).
Файловая система HPFS 167 Файловая система HPFS Сокращение HPFS расшифровывается как «High Performance File System» — вы- сокопроизводительная файловая система. HPFS впервые появилась в OS/2 1.2 и LAN Manager. HPFS была разработана совместными усилиями лучших спе- циалистов компании IBM и Microsoft на основе опыта IBM по созданию файло- вых систем MVS, VM/CMS и виртуального метода доступа1. Архитектура HPFS начала создаваться как файловая система, которая сможет использовать преиму- щества многозадачного режима и обеспечит в будущем более эффективную и на- дежную работу с файлами на дисках большого объема. HPFS была первой файловой системой для ПК, в которой была реализована поддержка длинных имен [96]. HPFS, как FAT и многие другие файловые систе- мы, обладает структурой каталогов, но в ней также предусмотрены автоматиче- ская сортировка каталогов и специальные расширенные атрибуты2, упрощающие реализацию безопасности файлового уровня и создание множественных имен. HPFS поддерживает те же самые атрибуты, что и файловая система FAT, по ис- торическим причинам, но также поддерживает и новую форму file-associated, то есть информацию, называемую расширенными атрибутами (EAs3 4). Каждый ЕА концептуально подобен переменной окружения. Но самым главным отличием все же являются базовые принципы хранения информации о местоположении файлов. Принципы размещения файлов на диске, положенные в основу HPFS, увеличи- вают как производительность файловой системы, так и ее надежность и отказо- устойчивость. Для достижения этих целей предложено несколько способов: раз- мещение каталогов в середине дискового пространства, использование методов бинарных сбалансированных деревьев для ускорения поиска информации о файле, рассредоточение информации о местоположении записей файлов по всему дис- ку, при том что записи каждого конкретного файла размещаются (по возможно- сти) в смежных секторах и поблизости от данных об их местоположении. Дейст- вительно, система HPFS стремится, прежде всего, к тому, чтобы расположить файл в смежных кластерах, или, если такой возможности нет, разместить его на диске таким образом, чтобы экстенты^ (фрагменты) файла физически были как можно ближе друг к другу. Такой подход существенно уменьшает время позиционирова- ния головок записи/чтения жесткого диска и время ожидания (rotational laten- cy) — задержка между установкой головки чтения/записи на нужную дорожку 1 Так, со стороны компании Microsoft проектом руководил известный системный програм- мист Gordon Letwin. 2 Расширенные атрибуты (extended attributes, EAs) позволяют хранить дополнительную информацию о файле. Например, каждому файлу может быть сопоставлено его уникаль- ное графическое изображение (значок или «фотография»), описание файла, коммента- рий, сведения о владельце файла и т. д. 3 Extended Atributes (EAs) — расширенные атрибуты. 4 Экстенты (extent) — фрагменты файла, располагающиеся в смежных секторах диска. Файл имеет по крайней мере один экстент, если он не фрагментирован, а в противном случае — несколько экстентов.
168 Глава 4. Управлений вводом/выводом и файловые системы диска и началом чтения данных с диска)1. Можно сказать, что файловая система HPFS имеет, по сравнению с FAT, следующие основные преимущества: □ высокая производительность; □ надежность; □ работа с расширенными атрибутами, что позволяет управлять доступом к фай- лам и каталогам; □ эффективное использование дискового пространства. Все эти преимущества обусловлены структурой диска HPFS. Рассмотрим ее бо- лее подробно (рис. 4.10). Boot Block Super Block Spare Block Band 1 Bit map 1 Bit map 2 Band 2 Band 3 Bit map 3 Bit map 4 Band 4 Рис. 4.10. Структура раздела HPFS В начале диска расположено несколько управляющих блоков. Все остальное дис- ковое пространство в HPFS разбито на части («полосы», «ленты» из смежных секторов, в оригинале — band). Каждая такая группа данных занимает на диске пространство в 8 Мбайт и имеет свою собственную битовую карту распределе- ния секторов. Эти битовые карты показывают, какие секторы данной полосы за- няты, а какие — свободны. Каждому сектору ленты данных соответствует один бит в ер битовой карте. Если бит имеет значение 1, то соответствующий сектор занят, а если 0 — свободен. Битовые карты двух полос располагаются на диске рядом, так же располагаются и сами полосы. То есть последовательность полос и карт выглядит следующим образом: битовая карта, битовая карта, лента с данными, лента с данными, бито- вая карта, битовая карта и т. д. Такое расположение «лент» позволяет непрерыв- но разместить на жестком диске файл размером до 16 Мбайт и в то же время не удалять от самих файлов информацию об их местонахождении. Это иллюстри- руется рис. 4.10. Очевидно, что если бы на весь диск была только одна битовая2 карта, как это сделано в FAT, то для работы с ней приходилось бы перемещать головки чте- ния/записи в среднем через половину диска. Именно для того, чтобы избежать этих потерь, в HPFS и разбит диск на «полосы». Получается своего рода распре- деленная структура данных об используемых и свободных блоках. Дисковое пространство в HPFS выделяется не кластерами, как в FAT, а блоками. В современной реализации размер блока взят равным одному сектору, но в принципе он мог бы быть и иного размера. По сути дела, блок — это и есть кла- 1 Эта задержка обусловлена тем, что система вынуждена ждать, пока диск не повернется таким образом, что нужный сектор окажется под головкой чтения/записи. 2 Bit тар — битовая карта.
Файловая система HPFS 169 стер. Размещение файлов в таких небольших блоках позволяет более эффектив- но использовать пространство диска, так как непроизводительные потери сво- бодного места составляют в среднем всего 256 байт на каждый файл. Вспомните, что чем больше размер кластера, тем больше места на диске расходуется напрас- но. Например, кластер на отформатированном под FAT диске объемом от 512 до 1024 Мбайт имеет размер 16 Кбайт. Следовательно, непродуктивные потери сво- бодного пространства на таком разделе в среднем составляют 8 Кбайт (8192 байт) на один файл, в то время как на разделе HPFS эти потери всегда будут состав- лять всего 256 байт на файл. Таким образом, на каждый файл экономится почти 8 Кбайт. На рис. 4.10 показано, что помимо «лент» с записями файлов и битовых карт в томе1 с HPFS имеются еще три информационные структуры. Это так называе- мый загрузочный блок (boot block), дополнительный блок (super block) и запас- ной (резервный) блок (spare block). Загрузочный блок (boot block) располагается в секторах с 0 по 15; он содержит имя тома, его серийный номер, блок парамет- ров BIOS2 и программу начальной загрузки. Программа начальной загрузки на- ходит файл OS2LDR, считывает его в память и передает управление этой про- грамме загрузки ОС, которая, в свою очередь, загружает с диска в память ядро OS/2 — OS2KRNL. И уже OS2KRNL с помощью сведений из файла CONFIG.SYS за- гружает в память все остальные необходимые программные модули и блоки дан- ных. В блоке (super block) содержится указатель на список битовых карт (bitmap block list). В этом списке перечислены все блоки на диске, в которых расположены би- товые карты, используемые для обнаружения свободных секторов. Также в до- полнительном блоке хранится указатель на список дефектных блоков (bad block list), указатель на группу каталогов (directory b and), указатель на файловый узел (F-node) корневого каталога, а также дата последней проверки раздела програм- мой CHKDSK. В списке дефектных блоков перечислены все поврежденные секто- ры (блоки) диска. Когда система обнаруживает поврежденный блок, он вносится в этот список и для хранения информации больше не используется. Кроме этого, в структуре super block содержится информация о размере «полосы». Напомним, что в текущей реализации HPFS размер «полосы» взят равным 8 Мбайт. Блок super block размещается в секторе с номером 16 логического диска, на котором установлена файловая система HPFS. Резервный блок (spare block) содержит указатель на карту аварийного замеще- ния (hotfix map или hotfix-areas), указатель на список свободных запасных бло- ков (directory emergency free block list), используемых для операций на почти переполненном диске, и ряд системных флагов и дескрипторов. Этот блок разме- щается в 17 секторе диска. Резервный блок обеспечивает высокую отказоустой- 1 По сути дела, том (volume) — это не что иное, как раздел или логический диск. 2 Блок параметров BIOS содержит информацию о жестком диске — количество цилиндров и головок диска, число секторов на дорожке. Эта информация используется программ- ными модулями HPFS для поиска конкретного сектора (блока), поскольку все блоки пронумерованы 32-разрядными числами.
170 Глава 4. Управление вводом/выводом и файловые системы чивость файловой системы HPFS и позволяет восстанавливать поврежденные данные на диске. Файлы и каталоги в HPFS базируются на фундаментальном объекте, называе- мом F-Node1. Эта структура характерна для HPFS и аналога в файловой системе FAT не имеет. Каждый файл и каталог диска имеет свой файловый узел F-Node. Каждый объект F-Node занимает один сектор и всегда располагается поблизости от своего файла или каталога (обычно — непосредственно перед файлом или ка- талогом). Объект F-Node содержит длину и первые 15 символов имени файла, специальную служебную информацию, статистику по доступу к файлу, расши- ренные атрибуты файла и список прав доступа2 (или только часть этого списка, если он очень большой), ассоциативную информацию о расположении и подчине- нии файла и т. д. Структура распределения в F-node может принимать несколь- ко форм в зависимости от размера каталога или файлов. HPFS просматривает файл как совокупность одного или более секторов. Из прикладной программы это не видно; файл появляется как непрерывный поток байтов. Если расширен- ные атрибуты слишком велики для файлового узла, то в него записывается ука- затель на них. Сокращенное имя файла (в формате 8.3) используется, когда файл с длинным именем копируется или перемещается на диск с системой FAT, не допускающей подобных имен. Сокращенное имя образуется из первых 8 символов оригиналь- ного имени файла, точки и первых трех символов расширения имени, если рас- ширение имеется. Если в имени файла присутствует несколько точек, что не противоречит правилам именования файлов в HPFS, то для расширения сокра- щенного имени используются три символа после самой последней из этих точек. Так как HPFS при размещении файла на диске стремится избежать его фрагмен- тации, то структура информации, содержащаяся в файловом узле, достаточно проста. Если файл непрерывен, то его размещение на диске описывается двумя 32-битными числами. Первое число представляет собой указатель на первый блок файла, а второе — длину экстента, то есть число следующих друг за другом бло- ков, принадлежащих файлу3. Если файл фрагментирован, то размещение его экс- тентов описывается в файловом узле дополнительными парами 32-битных чисел. Фрагментация происходит, когда на диске нет непрерывного свободного участ- ка, достаточно большого, чтобы разместить файл целиком. В этом случае файл приходится разбивать на несколько экстентов и располагать их на диске раз- дельно. Файловая система HPFS старается разместить экстенты фрагментиро- ванного файла как можно ближе друг к другу, чтобы сократить время позицио- нирования головок чтения/записи жесткого диска. Для этого HPFS использует статистику, а также старается условно резервировать хотя бы 4 килобайта места в конце файлов, которые растут. Еще один способ уменьшения фрагментирова- ния файлов — это расположение файлов, растущих навстречу друг другу, или файлов, открытых разными тредами или процессами, в разных полосах диска. 1 Файловый узел (F-Node) — это структура, в которой содержится информация о располо- жении файла и о его расширенных атрибутах. 2 ACL (access control list) — список прав доступа к данному файлу или объекту. 3 Из этого следует, что максимальный объем диска может составлять (232-1)х512 = 2 Тбайта.
Файловая система HPFS 171 В файловом узле можно разместить информацию максимум о восьми экстентах файла. Если файл имеет больше экстентов, то в его файловый узел записывается указатель на блок размещения (allocation block), который может содержать до 40 указателей на экстенты или, по аналогии с блоком дерева каталогов, на другие блоки размещения. Таким образом, двухуровневая структура блоков размеще- ния может хранить информацию о 480 секторах, что позволяет работать с файлами размером до 7,68 Гбайт. На практике размер файла не может превышать 2 Гбайт, но это обусловлено текущей реализацией интерфейса прикладного программи- рования [96]. «Полоса», находящаяся в центре диска, используется для хранения каталогов. Эта полоса называется directory band. Как и все остальные «полосы», она имеет размер 8 Мбайт. Однако, если она будет полностью заполнена, HPFS начинает располагать каталоги файлов в других полосах. Расположение этой информаци- онной структуры в середине диска значительно сокращает среднее время пози- ционирования головок чтения/записи. Действительно, для перемещения голо- вок чтения/записи из произвольного места диска в его центр требуется в два раза меньше времени, чем для перемещения к краю диска, где находится корне- вой каталог в случае файловой системы FAT. Уже только одно это обеспечивает более высокую производительность файловой системы HPFS по сравнению с FAT. Аналогичное замечание справедливо и для NTFS, которая тоже располага- ет свой master file table в начале дискового пространства, а не в его середине. Однако существенно больший (по сравнению с размещением Directory Band в середине логического диска) вклад в производительность HPFS дает использо- вание метода сбалансированных двоичных деревьев для хранения и поиска ин- формации о местонахождении файлов. Как известно, в файловой системе FAT каталог имеет линейную структуру, специальным образом не упорядоченную, поэтому при поиске файла требуется последовательно просматривать его с само- го начала. В HPFS структура каталога представляет собой сбалансированное де- рево с записями, расположенными в алфавитном порядке (рис. 4.11). Каждая за- пись, входящая в состав В-Tree дерева, содержит атрибуты файла, указатель на соответствующий файловый узел, информацию о времени и дате создания фай- ла, времени и дате последнего обновления и обращения, длине данных, содержа- щих расширенные атрибуты, счетчик обращений к файлу, длине имени файла и само имя, и другую информацию. Рис. 4.11. Сбалансированное двоичное дерево
172 Глава 4. Управление вводом/выводом и файловые системы Файловая система HPFS при поиске файла в каталоге просматривает только не- обходимые ветви двоичного дерева (В-Tree). Такой метод во много раз эффек- тивнее, чем последовательное чтение всех записей в каталоге, что имеет место в системе FAT. Для того чтобы найти искомый файл в каталоге (точнее, указатель на его информационную структуру F-node), организованном на принципах сба- лансированных двоичных деревьев, большинство записей вообще читать не нуж- но. В результате для поиска информации о файле необходимо выполнить суще- ственно меньшее количество операций чтения диска. Действительно, если, например, каталог содержит 4096 файлов, то файловая сис- тема FAT потребует чтения в среднем 64 секторов для поиска нужного файла внутри такого каталога, в то время как HPFS осуществит чтение всего только 2-4 секторов (в среднем) и найдет искомый файл. Несложные расчеты позволя- ют увидеть явные преимущества HPFS над FAT. Так, например, при использова- нии 40 входов на блок блоки каталога дерева с двумя уровнями могут содержать 1640 входов, а каталога дерева с тремя уровнями — уже 65 640 входов. Други- ми словами, некоторый файл может быть найден в типичном каталоге из 65 640 файлов максимум за три обращения. Это намного лучше файловой системы FAT, где для нахождения файла нужно прочитать в худшем случае более 4000 секторов. Размер каждого из блоков, в терминах которых выделяются каталоги в текущей реализации HPFS, равен 2 Кбайт. Размер записи, описывающей файл, зависит от размера имени файла. Если имя занимает 13 байтов (для формата 8.3), то блок из 2 Кбайт вмещает до 40 описателей файлов. Блоки связаны друг с другом по- средством списковой структуры (как и описатели экстентов) для облегчения по- следовательного обхода. При переименовании файлов может возникнуть так называемая перебаланси- ровка дерева. Создание файла, переименование или стирание может приводить к каскадированию блоков каталогов. Фактически, переименование может потер- петь неудачу из-за недостатка дискового пространства, даже если файл непо- средственно в размерах не увеличился. Во избежание этого «бедствия» HPFS поддерживает небольшой пул свободных блоков, которые могут использовать- ся при «аварии». Эта операция может потребовать выделения дополнительных блоков на заполненном диске. Указатель на этот пул свободных блоков сохраня- ется в SpareBlock. Важное значение для повышения скорости работы с файлами имеет уменьшение их фрагментации. В HPFS считается, что файл является фрагментированным, если он содержит больше одного экстента. Снижение фрагментации файлов сокращает время позиционирования и время ожидания за счет уменьшения количества перемещений головок, необходимого для доступа к данным файла. Алгоритмы работы файловой системы HPFS работают таким образом, чтобы по возможности размещать файлы в последовательных смежных секторах диска, что обеспечивает максимально быстрый доступ к данным впоследствии. В системе FAT, наоборот, запись следующей порции данных в первый же свободный клас- тер неизбежно приводит к фрагментации файлов. HPFS тоже, если это предос- тавляется возможным, записывает данные в смежные секторы диска (но не в
Файловая система HPFS 173 первый попавшийся). Это позволяет несколько снизить число перемещений го- ловок чтения/записи от дорожки к дорожке. При этом, когда данные дописыва- ются в существующий файл, HPFS сразу же резервирует как минимум 4 Кбайт непрерывного пространства на диске. Если же часть этого пространства не по- требовалась, то после закрытия файла она высвобождается для дальнейшего использования. Файловая система HPFS равномерно размещает непрерывные файлы по всему диску для того, чтобы впоследствии без фрагментации обеспе- чить их возможное увеличение. Если же файл не может быть увеличен без нару- шения его непрерывности, HPFS опять-таки резервирует 4 Кбайт смежных блоков как можно ближе к основной части файла с целью сократить время пози- ционирования головок чтения/записи и время ожидания соответствующего сек- тора. Очевидно, что степень фрагментации файлов на диске зависит как от числа фай- лов, расположенных на нем, их размеров и размеров самого диска, так и от ха- рактера и интенсивности самих дисковых операций. Незначительная фрагмента- ция файлов практически не сказывается на быстродействии операций с файлами. Файлы, состоящие из двух-трех экстентов, практически не снижают производи- тельность HPFS, так как эта файловая система следит за тем, чтобы области дан- ных, принадлежащие одному и тому же файлу, располагались как можно ближе друг к другу. Файл из трех экстентов имеет только два нарушения непрерывно- сти, и, следовательно, для его чтения потребуется всего лишь два небольших пе- ремещения головки диска. Программы (утилиты) дефрагментации, имеющиеся для этой файловой системы, по умолчанию считают наличие двух-трех экстен- тов у файла нормой. Например, программа HPFSOPT из набора утилит Gamma- Tech по умолчанию не дефрагментирует файлы, состоящие из трех и менее экс- тентов, а файлы, которые имеют большее количестве экстентов, приводятся к 2 или 3 экстентам, если это возможно (файлы объемом в несколько десятков ме- габайт всегда будут фрагментированы, ибо максимально возможный размер экс- тента, как вы помните, равен 8 Мбайт). Надо сказать, что практика показывает, что в среднем на диске имеется не более 2 процентов файлов, имеющих три и бо- лее экстентов [96]. Даже общее количество фрагментированных файлов, как пра- вило, не превышает 3 процентов. Такая ничтожная фрагментация оказывает пре- небрежимо малое влияние на общую производительность системы. Теперь кратко рассмотрим вопрос надежности хранения данных в HPFS. Любая файловая система должна обладать средствами исправления ошибок, возникаю- щих при записи информации на диск. Система HPFS для этого использует меха- низм аварийного замещения (hotfix). Если файловая система HPFS сталкивается с проблемой в процессе записи дан- ных на диск, она выводит на экран соответствующее сообщение об ошибке. Затем HPFS сохраняет информацию, которая должна была быть записана в дефектный сектор, в одном из запасных секторов, заранее зарезервированных на этот слу- чай. Список свободных запасных блоков хранится в резервном блоке HPFS. При обнаружении ошибки во время записи данных в нормальный блок HPFS выби- рает один из свободных запасных блоков и сохраняет эти данные в нем. Затем файловая система обновляет карту аварийного замещения в резервном блоке. Эта карта представляет собой просто пары двойных слов, каждое из которых яв-
174 Глава 4. Управление вводом/выводом и файловые системы ляется 32-битным номером сектора. Первый номер указывает на дефектный сек- тор, а второй — на тот сектор среди имеющихся запасных секторов, который был выбран для его замены. После замены дефектного сектора запасным карта ава- рийного замещения записывается на диск, и на экране появляется всплывающее окно, информирующее пользователя о произошедшей ошибке записи на диск. Каждый раз, когда система выполняет запись или чтение сектора диска, она просматривает карту аварийного замещения и подменяет все номера дефектных секторов номерами запасных секторов с соответствующими данными. Следует заметить, что это преобразование номеров существенно не влияет на производи- тельность системы, так как оно выполняется только при физическом обращении к диску, но не при чтении данных из дискового кэша. Очистка карты аварийного замещения автоматически выполняется программой CHKDSK при проверке дис- ка HPFS. Для каждого замещенного блока (сектора) программа CHKDSK выде- ляет новый сектор в наиболее подходящем для файла (которому принадлежат данные) месте жесткого диска. Затем программа перемещает данные из запасно- го блока в этот сектор и обновляет информацию о положении файла, что может потребовать новой балансировки дерева блоков размещения. После этого CHKDSK вносит поврежденный сектор в список дефектных блоков, который хранится в дополнительном блоке HPFS, и возвращает освобожденный сектор в список свободных запасных секторов резервного блока. Затем удаляет запись из карты аварийного замещения и записывает отредактированную карту на диск. Все основные файловые объекты в HPFS, в том числе файловые узлы, блоки размещения и блоки каталогов, имеют уникальные 32-битные идентификаторы и указатели на свои родительские и дочерние блоки. Файловые узлы, кроме того, содержат сокращенное имя своего файла или каталога. Избыточность и взаимосвязь файловых структур HPFS позволяют программе CHKDSK полно- стью восстанавливать файловую структуру диска, последовательно анализируя все файловые узлы, блоки размещения и блоки каталогов. Руководствуясь соб- ранной информацией, CHKDSK реконструирует файлы и каталоги, а затем заново создает битовые карты свободных секторов диска. Запуск программы CHKDSK следует осуществлять с соответствующими ключами. Так, например, один из вариантов работы этой программы позволяет найти и восстановить удаленные файлы. HPFS относится к так называемым монтируемым файловым системам. Это оз- начает, что она не встроена в операционную систему, а добавляется к ней при не- обходимости. Файловая система HPFS устанавливается оператором IFS1 в файле CONFIG.SYS. Этот оператор всегда помещается в первой строке данного конфи- гурационного файла. В приводимом далее примере оператор IFS устанавливает файловую систему HPFS с кэшем в 2 Мбайт, длиной записи кэша в 8 Кбайт и ав- томатической процедурой проверки дисков С и D: IFS—E:\0S2\HPFS.IFS /САСНЕ:2048 /CRECL:4 /AUTOCHECK:CD Для запуска программы управления процессом кэширования следует прописать в файле CONFIG.SYS еще одну строку: 1 IFS (installable file system) — устанавливаемая, монтируемая система управления файлами.
Файловая система HPFS 175 RUN=E:\0S2\CACHE.EXE /Lazy:On /BufferId!e:2000 /DiskIdle:4000 /MaxAge:8000 /DirtyMax:256 /ReadAhead:On В этой строке включается режим отложенной («ленивой») записи, устанавлива- ются параметры работы этого режима, а также включается режим упреждающего чтения данных, что в целом позволяет существенно сократить количество обра- щений к диску и ощутимо повысить быстродействие файловой системы. Так, ключ Lazy с параметром On включает «ленивую запись», а с параметром Off — выклю- чает. Ключ Bufferldle определяет время в миллисекундах, в течение которого бу- фер кэша должен оставаться в неактивном состоянии, чтобы стало возможным осуществить запись данных из кэша на диск. По умолчанию (то есть если не про- писывать данный ключ явным образом) это время равно 500 мс. Ключ Diskidle задает время (в миллисекундах), в течение которого диск должен оставаться в неактивном состоянии, чтобы стало возможным осуществить запись данных из кэша на диск. По умолчанию это время равно 1 с. Этот параметр позволяет избе- жать записи из кэша на диск во время выполнения других операций с диском. Ключ МахАде задает время (тоже в миллисекундах), по истечении которого часто сохраняемые в кэше данные наконец помечаются как «устаревшие» и при пере- полнении кэша могут быть замещены новыми. По умолчанию это время равно 5 с. Остальные подробности установки параметров и возможные значения клю- чей имеются в HELP-файлах, устанавливаемых вместе с операционной систе- мой OS/2 Warp. Наконец, следует сказать и еще об одной системе управления файлами — речь идет о реализации HPFS для работы на серверах, функционирующих под управ- лением OS/2. Это система управления файлами, получившая название HPFS386.IFS. Ее принципиальное отличие от системы HPFS.IFS заключается в том, что HPFS386.IFS позволяет (посредством более полного использования техноло- гии расширенных атрибутов) организовать ограничения на доступ к файлам и каталогами с помощью соответствующих списков доступа — ACL (access control list). Эта технология, как известно, используется в файловой системе NTFS. Кро- ме этого, в системе HPFS386.IFS в отличие от HPFS.IFS нет ограничений на объем памяти, выделяемой для кэширования файловых записей. Иными словами, при наличии достаточного объема оперативной памяти объем файлового кэша может быть в несколько десятков мегабайт, в то время как для обычной HPFS.IFS этот объем не может превышать 2 Мбайт, что по сегодняшним меркам безусловно мало. Наконец, при установке режимов работы файлового кэша HPFS386.IFS есть возможность явным образом указать алгоритм кэширования. Наиболее эффек- тивным алгоритмом можно считать так называемый «элеваторный», когда при записи данных из кэша на диск они предварительно упорядочиваются таким об- разом, чтобы минимизировать время, отводите на позиционирование головок чтения/ записи. Головки чтения/записи при этом перемещаются от внешних ци- линдров к внутренним и по ходу своего движения осуществляют запись и чтение данных в соответствии со специальным образом упорядочиваемым списком за- просов на дисковые операции. Приведем пример записи строк в конфигурационном файле CONFIG.SYS, кото- рые устанавливают систему HPFS386.IFS и определяют параметры работы ее под- системы кэширования:
176 Глава 4. Управление вводом/выводом и файловые системы IFS-E:\IBM386FS\HPFS386.IFS /AUTOCHECK:EGH RUN-E:\IBM386FS\CACHE386.EXE /LauzyiOn /BufferIdle:4000 /MaxAge:20000 Эти записи следует понимать следующим образом. При запуске операционной системы в случае обнаружения флага, означающего, что не все файлы были за- крыты в процессе предыдущей работы, система управления файлами HPFS386.IFS сначала запустит программу проверки целостности файловой системы для томов Е:, G: и Н:. Для кэширования файлов при работе этой системы управления фай- лами устанавливается режим отложенной записи со временем жизни буферов до 20 с. Остальные параметры, в частности алгоритм обслуживания запросов, уста- навливаются в файле HPFS386.INI, который в данном случае располагается в ди- ректории E:\IBM386FS. Опишем кратко некоторые наиболее интересные параметры, управляющие рабо- той кэша в этой системе управления файлами. Прежде всего отметим, что файл HPFS386.INI разбит на несколько секций. В настоящий момент рассмотрим сек- цию [ULTIMEDIA]: [ULTIMEDIA] QUEUESORT*{FIF0|ELEVATOR|DEFAULT|CURRENT} QUEUEMETHOD*{PRIORITY|NOPRIORITY|DEFAULT|CURRENT} QUEUEDEPTH={1...255|DEFAULT|CURRENT} Параметр QUEUESORT задает способ ведения очереди запросов к диску. Он может принимать значения FIFO, ELEVATOR, DEFAULT и CURRENT. Если задано значение FIFO, то каждый новый запрос просто добавляется в конец очереди, то есть запросы выполняются в том порядке, в котором они поступают в систему. Однако можно упорядочить некоторое количество запросов по возрастанию номеров дорожек. Если задано значение ELEVATOR, то включается режим поддержки упорядоченной очереди запросов. При этом запросы начинают обрабатываться по алгоритму ELEVATOR (он же C-SCAN или «режим плавающей головки» [24, 28]). Напомним, этот алгоритм подразумевает, что головка чтения/записи сканирует диск в вы- бранном направлении (например, в направлении возрастания номеров дорожек), останавливаясь для выполнения запросов, находящихся на пути следования. Ко- гда она доходит до последнего запроса, головка чтения/записи переносится на начальную дорожку и процесс обслуживания запросов продолжается. Если для параметра QUEUESORT задано значение DEFAULT, то выбирается алгоритм по умолчанию. Сейчас это ELEVATOR. Если задано значение CURRENT, то остается в силе тот алгоритм, который был выбран DASD Manager при инициализации. Параметр QUEUEMETHOD определяет, должны ли учитываться приоритеты запросов при построении очереди. Он может принимать значения PRIORITY, NOPRIORITY, DEFAULT и CURRENT. Если задано значение NOPRIORITY, то все запросы включаются в общую очередь, а их приоритеты игнорируются. Если задано значение PRIORITY, то модуль DASD Manager будет поддерживать несколько очередей запросов, по одной на каждый приоритет. Когда DASD Manager передает запросы на испол- нение драйверу диска, он сначала выбирает запросы из самой приоритетной оче- реди, потом из менее приоритетной и т. д. Приоритеты назначает HPFS386, а рас- пределены они следующим образом.
Файловая система HPFS 177 High: 1. Shutdown или экстренная запись из-за сбоя питания. 2. Страничный обмен. 3. Обычные запросы от foreground1 сессии. 4. Обычные запросы от background2 сессии. (Приоритеты 3 и 4 равны, если в файле CONFIG.SYS задан параметр RIORITY_DISK_IO=NO.) 5. Read-ahead и низкоприоритетные запросы страничного обмена (страничная предвыборка). 6. Lazy-Write и прочие запросы, не требующие немедленной реакции. Low: 7. Предвыборка. Если для параметра QUEUEMETHOD задано значение DEFAULT, то выбирается метод по умолчанию. Сейчас это PRIORITY. Если задано значение CURRENT, то остается в силе тот метод, который был выбран DASD Manager при инициализации. Параметр QUEUEDEPTH задает глубину просмотра очереди при выборке запросов. Он может принимать значения из диапазона (1...255), а также DEFAULT и CURRENT. Если в качестве значения параметра QUEUEDEPTH задано число, то оно определяет количество запросов, которые должны находиться в очереди дискового адаптера одновременно. Например, для SCSI-адаптеров имеет смысл поддерживать такую длину очереди, при которой они смогут загрузить все запросы в свои аппаратные структуры (tagged queue или mailbox). Если очередь запросов к адаптеру будет слишком короткой, то аппаратура будет работать с неполной загрузкой, а если она будет слишком длинной — драйвер SCSI-адаптера будет перегружен «лиш- ними» запросами. Поэтому разумным значением для QUEUEDEPTH будет число, немного превышающее длину аппаратной очереди команд адаптера. Если для параметра QUEUEDEPTH задано значение DEFAULT, то глубина просмотра очереди оп- ределяется автоматически на основании значения, которое рекомендовано драй- вером дискового адаптера. Если задано значение CURRENT, то глубина просмотра очереди не изменяется. В текущей реализации CURRENT эквивалентно DEFAULT. Итак, текущие умолчания для HPFS386 имеют вид: QUEUESORT=FIFO QUEUEMETHOD=DEFAULT QUEUEDEPTH=2 А текущие умолчания для DASD Manager таковы: QUEUESORT=ELEVATOR QUEUEMETHOD=PRIORITY 1 Foreground session — сессия «переднего плана», то есть та задача, с которой сейчас работа- ет пользователь, окно этой задачи является активным. 2 Background session — «фоновая сессия», то есть задача, запущенная пользователем, но в настоящий момент не находящаяся непосредственно в работе. Говорят, что эта задача вы- полняется на фоне текущих активных вычислений.
178 Глава 4. Управление вводом/выводом и файловые системы 0иЕ11Е0ЕРТН-<зависит от адаптера диска> Умолчания DASD Manager можно менять с помощью параметра /QF: BASEDEV=OS2DASD.DMD /QF:{1|2|3) где 1 - QUEUESORT = FIFO; 2 - QUEUEMETHOD = NOPRIORITY; 3 - QUEUESORT = FIFO и QUEUEMETHOD = NOPRIORITY. Наконец, добавим еще несколько слов об устанавливаемых файловых системах (installable file systems — IFS), представляющих собой специальные «драйверы» для доступа к разделам, отформатированным под другую файловую систему. Это очень удобный и мощный механизм добавления в ОС новых файловых систем и замены одной системы управления файлами на другую. Сегодня, например, для OS/2 уже реально существуют IFS-модули для файловой системы VFAT (FAT с поддержкой длинных имен), FAT32, Ext2FS (файловая система Linux), NTFS (правда, пока только для чтения). Для работы с данными на CD-ROM имеется CDFS.IFS. Есть и FTP.IFS, позволяющая монтировать ftp-архивы как локальные диски. Механизм устанавливаемых файловых систем был перенесен и в систему Windows NT. Файловая система NTFS (New Technology File System) В название файловой системы NTFS входят слова «New Technology», то есть «новая технология». Действительно, NTFS содержит ряд значительных усовер- шенствований и изменений, существенно отличающих ее от других файловых систем. С точки зрения пользователей, файлы по-прежнему хранятся в катало- гах (часто называемых «папками» или фолдерамьд в среде Windows). Однако в NTFS в отличие от FAT работа на дисках большого объема происходит намного эффективнее; имеются средства для ограничения в доступе к файлам и катало- гам, введены механизмы, существенно повышающие надежность файловой сис- темы, сняты многие ограничения на максимальное количество дисковых секто- ров и/или кластеров. Основные возможности файловой системы NTFS При проектировании системы NTFS особое внимание было уделено следующим характеристикам [53]: □ надежность. Высокопроизводительные компьютеры и системы совместного пользования (серверы) должны обладать повышенной надежностью, которая является ключевым элементом структуры и поведения NTFS. Одним из спо- 1 Folder — папка. Кстати, термин folder был позаимствован из других операционных систем и не имеет к системе Windows отношения.
Файловая система NTFS (New Technology File System) 179 собов увеличения надежности является введение механизма транзакций, при котором осуществляется журналирование1 файловых операций; □ расширенная функциональность. NTFS проектировалась с учетом возможного расширения. В ней были воплощены многие дополнительные возможности — усовершенствованная отказоустойчивость, эмуляция других файловых систем, мощная модель безопасности, параллельная обработка потоков данных и соз- дание файловых атрибутов, определяемых пользователем; □ поддержка POSIX2. Поскольку правительство США требовало, чтобы все закупаемые им системы хотя бы в минимальной степени соответствовали стандарту POSIX, такая возможность была предусмотрена и в NTFS. К числу базовых средств файловой системы POSIX относится необязательное ис- пользование имен файлов с учетом регистра, хранение времени последнего обращения к файлу и механизм так называемых «жестких ссылок» — альтер- нативных имен, позволяющих ссылаться на один и тот же файл по двум и бо- лее именам; □ гибкость. Модель распределения дискового пространства в NTFS отличается чрезвычайной гибкостью. Размер кластера может изменяться от 512 байт до 64 Кбайт; он представляет собой число, кратное внутреннему кванту распре- деления дискового пространства. NTFS также поддерживает длинные имена файлов, набор символов Unicode и альтернативные имена формата 8.3 для со- вместимости с FAT. NTFS превосходно справляется с обработкой больших массивов данных и доста- точно хорошо проявляет себя при работе с томами объемом от 300-400 Мбайт и выше. Максимально возможные размеры тома (и размеры файла) составляют 16 Эбайт3. Количество файлов в корневом и некорневом каталогах не ограни- чено. Поскольку в основу структуры каталогов NTFS заложена эффективная структура данных, называемая «бинарным деревом» (см. раздел «Файловая сис- 1 При журналировании файловых операций система управления файлами фиксирует в специальном служебном файле происходящие изменения. В начале операции, связанной с изменением файловой структуры, делается соответствующая пометка. Если во время операций над файлами происходит какой-нибудь сбой, то упомянутая отметка о начале операции остается указанной как незавершенная. При выполнении процедуры проверки целостности файловой системы после перезагрузки машины эти незавершенные опера- ции будут отменены и файлы будут приведены к исходному состоянию. Если же опера- ция изменения данных в файлах завершается нормальным образом, то в этом самом служебном файле поддержки журналирования операция отмечается как завершенная. 2 POSIX (Portable operating system for computing environments). Синоним IEEE Std 1003.1. C 1990 года является международным стандартом на машинно-независимый (переноси- мый) интерфейс компьютерной среды. Этот стандарт разработан американским институ- том IEEE в 1988 году. Он представляет собой набор функций, взятых из операционных систем AT&T UNIX System V и Berkeley Standard Distribution UNIX. Основное внима- ние в требованиях этого стандарта уделяется взаимодействию прикладных программ с операционной системой. Написание прикладных программ в данном стандарте позволя- ет создавать программы, легко переносимые из одной операционной среды в другую. 3 Экзабайт (один экзабайт равен 2е4, или приблизительно 16 000 млрд гигабайт).
180 Глава 4. Управление вводом/выводом и файловые системы тема HPFS»), время поиска файлов в NTFS (в отличие от систем на базе FAT) не связано линейной зависимостью с их количеством. Система NTFS также обладает определенными средствами самовосстановления. NTFS поддерживает различные механизмы проверки целостности системы, вклю- чая ведение журналов транзакций, позволяющих воспроизвести файловые опе- рации записи по специальному системному журналу. Файловая система NTFS поддерживает объектную модель безопасности NT и рассматривает все тома, каталоги и файлы как самостоятельные объекты. NTFS обеспечивает безопасность на уровне файлов; это означает, что права доступа к томам, каталогам и файлам могут зависеть от учетной записи пользователя и тех групп, к которым он принадлежит. Каждый раз, когда пользователь обращается к объекту файловой системы, его права доступа проверяются по списку разреше- ний данного объекта. Если пользователь обладает достаточным уровнем прав, его запрос удовлетворяется; в противном случае запрос отклоняется. Эта модель безопасности применяется как при локальной регистрации пользователей на компьютерах с NT, так и при удаленных сетевых запросах. Наконец, помимо огромных размеров томов и файлов, система NTFS также об- ладает встроенными средствами сжатия, которые можно применять к отдельным файлам, целым каталогам и даже томам (и впоследствии отменять или назначать их по своему усмотрению). Структура тома с файловой системой NTFS Рассмотрим теперь структуру файловой системы NTFS. Наиболее полно она описана в книге [23]. Мы же здесь коснемся только основных моментов. Одним из основных понятий, используемых при работе с NTFS, является поня- тие тома (volume)1. Возможно также создание отказоустойчивого тома, занимаю- щего несколько разделов, то есть использование RAID-технологии. Как и мно- гие другие системы, NTFS делит все полезное дисковое пространство тома на кластеры — блоки данных, адресуемые как единицы данных. NTFS поддержива- ет размеры кластеров от 512 байт до 64 Кбайт; стандартом же считается кластер размером 2 или 4 Кбайт. Все дисковое пространство в NTFS делится на две неравные части (рис. 4.12). Первые 12 % диска отводятся под так называемую MFT-зону — пространство, которое может занимать, увеличиваясь в размере, главный служебный метафайл MFT2. Запись каких-либо данных в эту область невозможна. MFT-зона всегда держится пустой — это делается для того, чтобы самый главный, служебный файл (MFT) по возможности не фрагментировался при своем росте. Остальные 88 % тома представляют собой обычное пространство для хранения файлов. 1 Следует, однако, заметить, что понятие тома известно уже около 30 лет. Оно активно ис- пользуется и в системе OS/2, использующей файловую систему HPFS. См. об этом в раз- деле «Файловая система HPFS». 2 MFT (master file table) — это специальный файл, главная системная структура данных, которая и позволяет определять местонахождение всех остальных файлов.
Файловая система NTFS (New Technology File System) 181 I MFT I Зона MFT Зона для размещения файлов и каталогов | Копия первых 16 записей MFT| Зона для размещения файлов и каталогов Рис. 4.12. Структура тома NTFS MFT (master file table, общая таблица файлов) представляет собой централизо- ванный каталог всех остальных файлов диска, в том числе и себя самого. MFT поделен на записи фиксированного размера в 1 Кбайт1, и каждая запись соответ- ствует какому-либо файлу (в общем смысле этого слова). Первые 16 файлов но- сят служебный характер и недоступны операционной системе — они называются метафайлами, причем самый первый метафайл — сам MFT. Эти первые 16 эле- ментов MFT — единственная часть диска, имеющая строго фиксированное поло- жение. Копия этих же 16 записей хранится в середине тома для надежности, по- скольку они очень важны. Остальные части MFT-файла могут располагаться, как и любой другой файл, в произвольных местах диска — восстановить его по- ложение можно с помощью его самого, «зацепившись» за самую основу — за пер- вый элемент MFT. Упомянутые первые 16 файлов NTFS (метафайлы) носят служебный характер; каждый из них отвечает за какой-либо аспект работы системы. Метафайлы нахо- дятся в корневом каталоге NTFS-тома. Все они начинаются с символа имени «$», хотя получить какую-либо информацию о них стандартными средствами сложно. В табл. 4.7 приведены основные известные метафайлы и их назначение. Таким образом, можно узнать, например, сколько операционная система тратит на каталогизацию тома, посмотрев размер файла $MFT. Таблица 4.7. Метафайлы NTFS Имя метафайла Назначение метафайла $MFT Сам Master File Table $MFTmirr Копия первых 16 записей MFT, размещенная посередине тома $LogFile Файл поддержки операций журналирования $Volume Служебная информация — метка тома, версия файловой системы и т. д. $AttrDef Список стандартных атрибутов файлов на томе продолжение & 1 Размер файловых записей MFT для тома — минимум 1 Кбайт и максимум 4 Кбайт — оп- ределяется во время форматирования тома.
182 Глава 4. Управление вводом/выводом и файловые системы Таблица 4.7 (продолжение) Имя метафайла Назначение метафайла $. Корневой каталог $ Bitmap Карта свободного места тома $Boot Загрузочный сектор (ерш раздел загрузочный) $ Quota Файл, в котором записаны права пользователей на использование дискового пространства (этот файл начал работать лишь в Windows 2000 с системой NTFS 5.0) $Upcase Файл — таблица соответствия заглавных и прописных букв в именах файлов. В NTFS имена файлов записываются в Unicode (что составляет 65 тысяч различных символов) и искать большие и малые эквиваленты в данном случае — нетривиальная задача Итак, все файлы тома упоминаются в MFT. В этой структуре хранится вся ин- формация о файлах, за исключением собственно данных. Имя файла, размер, положение на диске отдельных фрагментов и т. д. — все это хранится в соответ- ствующей записи. Если для информации не хватает одной записи MFT, то ис- пользуется несколько записей, причем не обязательно идущих подряд. Файлы могут иметь не очень большой размер. Тогда применяется довольно удачное ре- шение: данные файла хранятся прямо в MFT, в оставшемся от основных данных месте в пределах одной записи MFT. Файлы, занимающие сотни байт, обычно не имеют своего «физического» воплощения в основной файловой области — все данные такого файла хранятся в одном месте, в MFT. Файл в томе с NTFS идентифицируется так называемой файловой ссылкой (File Reference), которая представляется как 64-разрядное число. Файловая ссылка состоит из номера файла, который соответствует позиции его файловой записи в MFT, и номера последовательности. Последний увеличивается всякий раз, когда данная позиция в MFT используется повторно, что позволяет файловой системе NTFS выполнять внутренние проверки целостности. Каждый файл в NTFS представлен с помощью потоков (streams), то есть у него нет как таковых «просто данных», а есть «потоки». Для правильного понимания потока достаточно указать, что один из потоков и носит привычный нам смысл — данные файла. Но большинство атрибутов файла — это тоже потоки. Таким об- разом, получается, что базовая сущность у файла только одна — номер в MFT, а все остальное, включая и его потоки, — опционально. Данный подход может эффективно использоваться — например, файлу можно «прилепить» еще один поток, записав в него любые данные. В Windows 2000 таким образом записана информация об авторе и содержании файла (одна из закладок в свойствах фай- ла, просматриваемых, например, из проводника). Интересно, что эти дополни- тельные потоки не видны стандартными средствами работы с файлами: наблю- даемый размер файла — это лишь размер основного потока, который содержит традиционные данные. Можно, к примеру, иметь файл нулевой длины, при сти- рании которого освободится 1 Гбайт свободного места — просто потому, что ка-
Файловая система NTFS (New Technology File System) 183 кая-нибудь хитрая программа или технология «прилепила» к нему дополнитель- ный поток (альтернативные данные) такого большого размера. Но на самом деле в настоящее время потоки практически не используются, так что опасаться по- добных ситуаций не следует, хотя гипотетически они возможны1. Просто необ- ходимо иметь в виду, что файл в NTFS — это более глубокое понятие, чем можно себе представить, просматривая каталоги диска. Стандартные же атрибуты для файлов и каталогов в томе NTFS имеют фиксиро- ванные имена и коды типа, они перечислены в табл. 4.8. Таблица 4.8. Атрибуты файлов в системе NTFS Системный атрибут Описание атрибута Стандартная информация о файле Традиционные атрибуты Read Only, Hidden, Archive, System, отметки времени, включая время создания или последней модификации, число каталогов, ссылающихся на файл Список атрибутов Список атрибутов, из которых состоит файл, и файловая ссылка на файловую запись и MFT, в которой расположен каждый из атрибутов. Последний используется, если файлу необходимо более одной записи в MFT Имя файла Имя файла в символах Unicode. Файл может иметь несколько атрибутов — имен файла, подобно тому как это имеет место в UNIX-системах. Это случается, когда имеется связь POSIX с данным файлом или если у файла есть автоматически сгенерированное имя в формате 8.3 Дескриптор защиты Структура данных защиты (ACL), предохраняющая файл от несанкционированного доступа. Атрибут «дескриптор защиты» определяет, кто владелец файла и кто имеет доступ к нему Данные Собственно данные файла, его содержимое. В NTFS у файла по умолчанию есть один безымянный атрибут данных, и он может иметь дополнительные именованные атрибуты данных. У каталога нет атрибута данных по умолчанию, но он может иметь необязательные именованные атрибуты данных Корень индекса, размещение индекса, битовая карта (только для каталогов) Атрибуты, используемые для индексов имен файлов в больших каталогах Расширенные атрибуты HPFS Атрибуты, используемые для реализации расширенных атрибутов HPFS для подсистемы OS/2 и OS/2-клиентов файл-серверов Windows NT 1 Так, на идее подмены потока основан один из новейших вирусов, который был не так давно создан для распространения в среде Windows 2000.
184 Глава 4. Управление вводом/выводом и файловые системы Атрибуты файла в записях MFT расположены в порядке возрастания числовых значений кодов типа, причем некоторые типы атрибутов могут встречаться в за- писи более одного раза: например, если у файла есть несколько атрибутов данных или несколько имен. Обязательными для каждого файла в томе NTFS являются атрибут стандартной информации, атрибут имени файла, атрибут дескриптора защиты и атрибут данных. Остальные атрибуты могут встречаться при необхо- димости. Имя файла в NTFS, в отличие от файловых систем FAT и HPFS, может содер- жать любые символы, включая полный набор национальных алфавитов, так как данные представлены в Unicode — 16-битном представлении, которое дает 65 535 разных символов. Максимальная длина имени файла в NTFS — 255 сим- волов. Большой вклад в эффективность файловой системы вносит организация ката- лога. Каталог в NTFS представляет собой специальный файл, хранящий ссылки на другие файлы и каталоги, создавая иерархическое строение данных на диске. Файл каталога поделен на блоки, каждый из которых содержит имя файла, базо- вые атрибуты и ссылку на элемент MFT, который уже предоставляет полную информацию об элементе каталога. Главный каталог диска — корневой — ничем не отличается от обычных каталогов, кроме специальной ссылки на него из нача- ла метафайла MFT. Внутренняя структура каталога представляет собой бинарное дерево, подобно тому как это организовано в HPFS. Кстати, при создании файловой системы NTFS разработчики решили использовать максимально возможное количество эффек- тивных решений из HPFS. К сожалению, не было взято на вооружение разбие- ние всего дискового пространства на зоны, в каждой из которых хранилась бы информация об имеющихся свободных кластерах. В результате отказа от этого подхода и введения механизма транзакций скорость работы файловой системы NTFS существенно ниже скорости работы системы HPFS. Итак, как нам теперь известно, бинарное дерево каталога располагает имена фай- лов таким образом, чтобы поиск файла осуществлялся с помощью получения двухзначных ответов на вопросы о положении файла. Бинарное дерево способно дать ответ на вопрос: в какой группе, относительно данного элемента, находится искомое имя — выше или ниже? Мы начинаем с такого вопроса к среднему эле- менту, и каждый ответ сужает зону поиска в среднем в два раза. Если предста- вить, что файлы отсортированы по алфавиту, то ответ на вопрос осуществляется очевидным способом — сравнением начальных букв. Область поиска, суженная в два раза, начинает исследоваться аналогичным образом, начиная опять же со среднего элемента. Заметим, что добавлять файл в каталог в виде дерева не намного труднее, чем в линейный каталог системы FAT. Это сопоставимые по времени операции. Для того чтобы добавить новый файл в каталог, нужно сначала убедиться, что файла с таким именем там еще нет. Поэтому в системе FAT с линейной организацией записей каталога у нас появляются трудности не только с поиском файла. И это с лихвой компенсирует саму простоту добавления файла в каталог.
Файловая система NTFS (New Technology File System) 185 Возможности файловой системы NTFS по ограничению доступа к файлам и каталогам Рассмотрим основные возможности, связанные как с организацией различных прав доступа к файлам и каталогам при использовании сетевого доступа, так и локальные ограничения на файлы и каталоги. NTFS рассматривает каталоги (папки) и файлы как разнотипные объекты и ведет отдельные (хотя и перекры- вающиеся) списки прав доступа для каждого типа [36]. Ниже перечислены права NTFS, назначаемые папкам (соответствующие права для файлов приведены ниже): □ нет доступа (no access) (Нопе)(нет); □ полный доступ (full control) (Al 1)(А11) (все)(все); □ право чтения (read) (RX)(RX) (чтение)(чтение); □ право добавления (add) (WX)(not specified) (запись/выполнение не указано); □ право добавления и чтения (add&read) (RWX)(RX) (чтение/запись/выпол- нение) (чтение/выполнение); □ право просмотра (list) (RX)(not specified) (чтение/выполнение)(не указано); □ право изменения (change) (RWXD)(RWXD) (чтение/запись/ выполнение/ удаление) (чтение/запись/выполнение/удаление). Обратите внимание на два выражения в скобках, указанные после имени права доступа. Первое выражение относится к самой папке, а второе — ко всем фай- лам, которые могут быть созданы внутри нее. Например, при полном доступе для папки разрешаются любые действия, однако пользователь с полным досту- пом к папке также будет обладать полным правом доступа ко всем созданным в ней файлам (если только права доступа к файлу не были изменены его владель- цем или администратором). Другими словами, в NTFS файлы и папки по умол- чанию наследуют права доступа, установленные для их родительской папки, од- нако эти права могут быть изменены любым пользователем, которому разрешено изменять права доступа для соответствующих объектов NTFS. Файлы в NTFS могут обладать следующими правами: □ полный доступ (full control) (АП) (все); □ нет доступа (no access) (None) (нет); □ право изменения (change) (RWXD) (чтение/запись/выполнение/удаление); □ право чтения (read) (RX) (чтение/выполнение). Для прав доступа NTFS, как и для прав общих каталогов, действует принцип поглощения. Исключение составляет право «нет доступа», отменяющее действие всех остальных прав. При сетевом подключении пользователей права NTFS могут вступить в конфликт с правами общих каталогов. В такой ситуации применяется право доступа с наи- более жесткими ограничениями. У многих возникают проблемы с пониманием получаемых при сетевом доступе ограничений. Однако здесь можно легко разо- браться, если помнить, что при доступе по сети к каталогам и файлам, распола- гающихся на томах с NTFS, у нас получаются задействованными два последо-
186 Глава 4. Управление вводом/выводом и файловые системы вательных механизма. Сначала мы получаем доступ к файлам, который был определен сетевыми механизмами. Это право «нет доступа» — «по access», право на «чтение» — «read», право «изменение» — «change» и «полный доступ» — «full control». После этого вступают в силу ограничения на файлы и каталоги, опреде- ленные свойствами NTFS. То есть нам нужно преодолеть последовательно два препятствия. Другими словами, итоговые права на папки и файлы будут опреде- ляться максимальными ограничениями, которые были заданы в каждом из меха- низмов. Помимо перечисленных прав имеется еще так называемый специальный доступ (Special Access). Если выбрать это право доступа, то на самом деле появляется возможность выбирать несколько прав одновременно из следующего перечня: □ полный доступ (full control) (All); □ чтение (read) (R); □ запись (write) (W); □ выполнение (execute) (X); □ удаление (delete) (D); □ изменение разрешений (change permissions) (P); □ изменение владельца (take ownership) (O). В принципе можно было бы выбирать любые совокупности перечисленных раз- решений, однако на практике это, увы, не работает. Например, нельзя указать право X (исполнение) без права R (чтение), хотя в других системах управления файлами такое право обеспечивается. Оно позволяет выполнять программу, файл которой помечен таким атрибутом, но не дает возможности ее скопировать. Мно- гие другие комбинации специальных разрешений тоже не работают должным образом и это надо обязательно иметь в виду. Лучше пользоваться штатными правами на файлы и каталоги, которые были перечислены выше. Рассмотрим теперь, что происходит с правами на защищенные файлы в NTFS при их перемещении. Папки более высокого уровня в NTFS обычно обладают теми же правами, что и находящиеся в них файлы и папки. Например, если вы создаете папку внутри другой папки, для которой администраторы обладают правом полного доступа, а операторы архива — правом чтения, то новая папка унаследует эти права. То же относится и к файлам, копируемым из другой папки или перемещаемым из другого раздела NTFS. Если папка или файл перемещается в другую папку того же раздела NTFS, то ат- рибуты безопасности не наследуются от нового объекта-контейнера. Например, если из папки с правами чтения для группы everyone файл перемещается в пап- ку того же раздела с полным доступом для той же группы, то для перемещенного файла будет сохранено исходное право чтения. Дело в том, что при перемещении файлов в границах одного раздела NTFS изменяется только указатель местона- хождения объекта, а все остальные атрибуты (включая атрибуты безопасности) остаются без изменений. Три следующих важных правила помогут определить состояние прав доступа при перемещении или копировании объектов NTFS:
Файловая система NTFS (New Technology File System) 187 □ При перемещении файлов в границах раздела NTFS сохраняются исходные права доступа. □ При выполнении других операций (создании или копировании файлов, а так- же их перемещении между разделами NTFS) наследуются права доступа ро- дительской папки. □ При перемещении файлов из раздела NTFS в раздел FAT все права NTFS те- ряются. Основные отличия FAT и NTFS Если говорить о накладных расходах на хранение служебной информации, FAT отличается от NTFS большей компактностью и меньшей сложностью. В боль- шинстве томов FAT на хранение таблицы размещения, содержащей информа- цию обо всех файлах тома, расходуется менее 1 Мбайт. Столь низкие накладные расходы позволяют форматировать в FAT жесткие диски малого объема и флоп- пи-диски. В NTFS служебные данные занимают больше места, чем в FAT. Так, каждый элемент каталога занимает 2 Кбайт. Однако это имеет и свои преимуще- ства, так как содержимое файлов объемом 1500 байт и менее может полностью храниться в элементе каталога. Система NTFS не может использоваться для форматирования флоппи-дисков. Не стоит пользоваться ею для форматирования разделов объемом менее 50-100 Мбайт. Относительно высокие накладные расходы приводят к тому, что для малых раз- делов служебные данные могут занимать до 25 % объема носителя. Корпорация Microsoft рекомендует использовать FAT для разделов объемом 256 Мбайт и ме- нее, a NTFS — для разделов объемом 400 Мбайт и более1. Следующий критерий сравнения — размер файлов. Разделы FAT имеют объем до 2 Гбайт, VFAT — до 4 Гбайт и FAT32 — до 4 Тбайт. Тем не менее из-за особен- ностей своего внутреннего строения разделы FAT лучше всего работают для раз- делов объемом 200 Мбайт и менее. Разделы NTFS могут достигать 16 Эбайт, однако в настоящее время из-за аппаратных и других системных причин размер файлов ограничивается 2 Тбайт. Разделы FAT могут использоваться практически во всех операционных систе- мах. За редкими исключениями, с разделами NTFS можно работать напрямую только из Windows NT, хотя и имеются для ряда ОС соответствующие реализа- ции систем управления файлами для чтения файлов из томов NTFS. Так, напри- мер, утилита (драйвер) NTFSDOS позволяет читать данные NTFS на компьютере, загруженном в режиме MS-DOS. Однако полноценных реализаций для работы с NTFS вне системы Windows NT пока нет. Разделы FAT не обеспечивают локальной безопасности. С другой стороны, раз- делы NTFS обеспечивают локальную безопасность как файлов, так и каталогов. Для разделов FAT могут устанавливаться общие права, связанные с общим дос- 1 Следут заметить, что этим рекомендациям уже более 6 лет. Сейчас, как известно, объемы дисковых накопителей уже давно перешли рубеж в десяток гигабайт, поэтому упомина- ние логического диска объемом в 400 Мбайт представляется неактуальным.
188 Глава 4. Управление вводом/выводом и файловые системы тупом к каталогам в сети. Однако такая защита не помешает пользователю с ло- кальным входом получить доступ к файлам своего компьютера. В отношении безопасности NTFS оказывается предпочтительным вариантом. Разделы NTFS могут запрещать или ограничивать доступ как удаленных, так и локальных поль- зователей. Следовательно, к защищенным файлам смогут обратиться лишь те пользователи, которым были предоставлены соответствующие права. Напомним, что Windows NT содержит специальную утилиту CONVERT.EXE, ко- торая преобразует тома FAT в эквивалентные тома NTFS, однако для обратного преобразования (из NTFS в FAT) подобных утилит не существует. Чтобы вы- полнить такое обратное преобразование, вам придется создать раздел FAT, ско- пировать в него файлы из раздела NTFS и затем удалить оригиналы. Важно при этом не забывать и о том, что при копировании файлов из NTFS в FAT теряются все атрибуты безопасности NTFS (напомним, что в FAT не предусмотрены сред- ства для определения и последующего хранения этих атрибутов). В последнее время появилось еще одно очень важное обстоятельство, связанное с тем, что объемы дисковых механизмов намного превысили максимально до- пустимый размер, приемлемый для FAT, — 8,4 Гбайт. Этот предел объясняется максимально возможными значениями в адресе сектора, для которого, как мы уже знаем, отводится всего 3 байта. Поэтому в подавляющем большинстве слу- чаев при работе в среде Windows-систем используют либо FAT32, либо NTFS. Последняя, безусловно, лучше, но она не поддерживается в широко распростра- ненных ОС Windows 98 и ныне все более часто встречающейся Windows Millen- nium Edition. Контрольные вопросы и задачи Вопросы для проверки 1. Почему создание подсистемы ввода/вывода считается одной из самых слож- ных областей проектирования операционных систем? 2. Почему операции ввода/вывода в ОС объявляются привилегированными? 3. Перечислите основные задачи, возлагаемые на супервизор ввода/вывода. 4. В каких случаях устройство ввода/вывода называется инициативным? 5. Какие режимы управления вводом/выводом вы знаете? Опишите каждый из них. 6. Что означает термин «spooling» и что означает термин «swapping»? 7. Чем обеспечивается независимость пользовательских программ от устройств ввода/вывода, подключенных к компьютеру? 8. Что такое синхронный и асинхронный ввод/вывод? 9. Расскажите о кэшировании операций ввода/вывода при работе с накопителя- ми на магнитных дисках. 10. Что такое «файловая система»? Что обеспечивает использование той или иной файловой системы? Какие файловые системы, используемые в ОС для ПК, вы знаете?
Контрольные вопросы и задачи 189 И. Опишите структуру магнитного диска (разбиение дисков на разделы). Сколь- ко (и каких) разделов может быть на магнитном диске? 12. Как в общем случае осуществляется загрузка ОС после включения компью- тера? Что такое системный и внесистемный загрузчики? Где они располага- ются? 13. Объясните общие принципы файловой системы FAT. Что такое кластер, от чего зависит его размер? 14. Сравните файловые системы FAT16 и FAT32. В чем заключаются их досто- инства и недостатки? 15. За счет чего в файловой системе HPFS обеспечена высокая производитель- ность? 16. Что означает «журналирование» файловых операций? Что это дает? 17. Расскажите о правилах, которые определяют состояние прав доступа при пе- ремещении или копировании объектов, если используется NTFS. 18. Проведите сравнительный анализ файловых систем HPFS и NTFS; перечис- лите достоинства и недостатки каждой. Задания 1. Используя ПК с установленной на нем ОС Windows NT 4.0, исследуйте огра- ничения доступа к файлам и каталогам, которыми обладает файловая система NTFS. Расскажите о результатах. 2. Используя ПК с установленной на нем ОС Windows NT 4.0, проверьте прави- ла, которые определяют состояние прав доступа при перемещении или копи- ровании объектов при использовании NTFS. Расскажите о результатах. 3. Используя специально выделенный для этих целей ПК, изучите структуру диска и освойте работу с программой Disk Editor. Выполните следующее за- дание: О Включите компьютер. Во время выполнения программы самотестирования войдите в BIOS и установите возможность загрузки с дискеты. О Загрузите операционную систему, расположенную на магнитном диске. По- кажите преподавателю, что она работает. О Загрузитесь с системной дискеты (на ней должна быть MS-DOS 6.2 или какая-нибудь другая версия DOS). О Запустите программу Disk Editor из комплекта утилит от Питера Нортона. С помощью встроенных подсказок изучите основные возможности этой утилиты. О Посмотрите структуру диска. Сохраните MBR и загрузочный сектор на свою дискету. О Найдите таблицы размещения файлов для двух разделов магнитного дис- ка. Сохраните их на дискете. Выйдите из программы Disk Editor.
190 Глава 4. Управление вводом/выводом и файловые системы О Запустите программу FDISK. Посмотрите структуру диска — сколько и каких разделов на нем расположено? О Удалите все логические диски с помощью программы FDISK. О Перезапустите компьютер и убедитесь, что операционная система, распо- ложенная раньше на магнитном диске, больше не функционирует. Пока- жите преподавателю. О Восстановите операционную систему и файлы, расположенные на магнит- ном диске, используя программу Disk Editor и файлы, которые вы ранее создали с ее помощью. Покажите преподавателю.
ГЛАВА 5 Архитектура операционных систем и интерфейсы прикладного программирования Несмотря на тот факт, что в наши дни уже практически никто не разрабатыва- ет операционные системы (естественно, за исключением нескольких известных компаний, специализирующихся на этом направлении, кстати, одном из слож- нейших) и все являются пользователями наиболее распространенных систем, мы все-таки рассмотрим кратко вопросы архитектуры ОС. Сделать это необхо- димо потому, что многие возможности и характеристики ОС определяются в значительной мере ее архитектурой. Основные принципы построения операционных систем Среди множества принципов, которые используются при построении ОС, пере- числим несколько наиболее важных (на наш взгляд, так как в соответствующих публикациях на эту тему перечисляется существенно большее их количество). Принцип модульности Под модулем в общем случае понимают функционально законченный элемент системы, выполненный в соответствии с принятыми межмодульными интерфей- сами. По своему определению модуль предполагает возможность без труда заме- нить его на другой при наличии заданных интерфейсов. Способы обособления составных частей ОС в отдельные модули могут существенно различаться, но
192 Глава 5. Архитектура операционных систем чаще всего разделение происходит именно по функциональному признаку. В зна- чительной степени разделение системы на модули определяется используемым методом проектирования ОС (снизу вверх или наоборот). Особо важное значение при построении ОС имеют привилегированные, повторно входимые и реентерабельные модули, так как они позволяют более эффективно использовать ресурсы вычислительной системы. Как мы уже знаем (см. раздел «Основные виды ресурсов», глава 1), достижение реентерабельности реализует- ся различными способами. В некоторых системах реентерабельность программы получают автоматически, благодаря неизменяемости кодовых частей программ при исполнении (из-за особенностей системы команд машины), а также автома- тическому распределению регистров, автоматическому отделению кодовых час- тей программ от данных и помещению последних в системную область памяти. Естественно, что для этого необходима соответствующая аппаратная поддержка. В других случаях это достигается программистами за счет использования специ- альных системных модулей. Принцип модульности отражает технологические и эксплуатационные свойства системы. Наибольший эффект от его использования достижим в случае, когда принцип распространен одновременно на операционную систему, прикладные программы и аппаратуру. Принцип функциональной избирательности В ОС выделяется некоторая часть важных модулей, которые должны постоянно находиться в оперативной памяти для более эффективной организации вычис- лительного процесса. Эту часть в ОС называют ядром, так как это действительно основа системы. При формировании состава ядра требуется учитывать два про- тиворечивых требования. В состав ядра должны войти наиболее часто исполь- зуемые системные модули. Количество модулей должно быть таковым, чтобы объем памяти, занимаемый ядром, был бы не слишком большим. В состав ядра, как правило, входят модули по управлению системой прерываний, средства по переводу программ из состояния счета в состояние ожидания, готовности и об- ратно, средства по распределению таких основных ресурсов, как оперативная память и процессор. Помимо программных модулей, входящих в состав ядра и постоянно располагающихся в оперативной памяти, может быть много других системных программных модулей, которые получают название транзитных. Транзитные программные модули загружаются в оперативную память только при необходимости и в случае отсутствия свободного пространства могут быть замещены другими транзитными модулями. В качестве синонима к термину «транзитный» можно использовать термин «диск-резидентный». Принцип генерируемости ОС Основное положение этого принципа определяет такой способ исходного пред- ставления центральной системной управляющей программы ОС (ее ядра и ос- новных компонентов, которые должны постоянно находиться в оперативной па- мяти), который позволял бы настраивать эту системную супервизорную часть,
Основные принципы построения операционных систем 193 исходя из конкретной конфигурации конкретного вычислительного комплекса и круга решаемых задач. Эта процедура проводится редко, перед достаточно про- тяженным периодом эксплуатации ОС. Процесс генерации осуществляется с по- мощью специальной программы-генератора и соответствующего входного языка для этой программы, позволяющего описывать программные возможности сис- темы и конфигурацию машины. В результате генерации получается полная вер- сия ОС. Сгенерированная версия ОС представляет собой совокупность систем- ных наборов модулей и данных. Упомянутый раньше принцип модульности положительно проявляется при гене- рации ОС. Он существенно упрощает настройку ОС на требуемую конфигура- цию вычислительной системы. В наши дни при использовании персональных ком- пьютеров с принципом генерируемости ОС можно столкнуться разве что только при работе с Linux. В этой UNIX-системе имеется возможность не только ис- пользовать какое-либо готовое ядро ОС, но и самому сгенерировать (скомпили- ровать) такое ядро, которое будет оптимальным для данного конкретного персо- нального компьютера и решаемых на нем задач. Кроме генерации ядра в Linux имеется возможность указать и набор подгружаемых драйверов и служб, то есть часть функций может реализовываться модулями, непосредственно входящими в ядро системы, а часть — модулями, имеющими статус подгружаемых, транзит- ных. В остальных современных распространенных ОС для персональных компьюте- ров конфигурирование ОС под соответствующий состав оборудования осущест- вляется на этапе инсталляции, а потом состав драйверов и изменение некоторых параметров ОС может быть осуществлено посредством редактирования конфи- гурационного файла. Принцип функциональной избыточности Этот принцип учитывает возможность проведения одной и той же работы раз- личными средствами. В состав ОС может входить несколько типов мониторов (модулей супервизора, управляющих тем или другим видом ресурса), различные средства организации коммуникаций между вычислительными процессами. На- личие нескольких типов мониторов, нескольких систем управления файлами позволяет пользователям быстро и наиболее адекватно адаптировать ОС к опре- деленной конфигурации вычислительной системы, обеспечить максимально эф- фективную загрузку технических средств при решении конкретного класса за- дач, получить максимальную производительность при решении заданного класса задач. Принцип виртуализации Построение виртуальных ресурсов, их распределение и использование теперь используется практически в любой ОС. Этот принцип позволяет представить структуру системы в виде определенного набора планировщиков процессов и распределителей ресурсов (мониторов) и использовать единую централизован- ную схему распределения ресурсов.
194 Глава 5. Архитектура операционных систем Наиболее естественным и законченным проявлением концепции виртуальности является понятие виртуальной машины. По сути, любая операционная система, являясь средством распределения ресурсов и организуя по определенным прави- лам управление процессами, скрывает от пользователя и его приложений реаль- ные аппаратные и иные ресурсы, заменяя их некоторой абстракцией. В результа- те пользователи видят и используют виртуальную машину как некое устройство, способное воспринимать их программы, написанные на определенном языке программирования, выполнять их и выдавать результаты. При таком языковом представлении пользователя совершенно не интересует реальная конфигурация вычислительной системы, способы эффективного использования ее компонен- тов и подсистем. Он мыслит и работает с машиной в терминах используемого им языка и тех ресурсов, которые ему предоставляются в рамках виртуальной ма- шины. Чаще виртуальная машина, предоставляемая пользователю, воспроизводит ар- хитектуру реальной машины, но архитектурные элементы в таком представле- нии выступают с новыми или улучшенными характеристиками, часто упрощаю- щими работу с системой. Характеристики могут быть произвольными, но чаще всего пользователи желают иметь собственную «идеальную» по архитектурным характеристикам машину в следующем составе: □ единообразная по логике работы память (виртуальная) практически неогра- ниченного объема. Среднее время доступа соизмеримо со значением этого параметра оперативной памяти. Организация работы с информацией в такой памяти производится в терминах обработки данных — в терминах работы с сегментами данных на уровне выбранного пользователем языка программи- рования; □ произвольное количество процессоров (виртуальных), способных работать па- раллельно и взаимодействовать во время работы. Способы управления про- цессорами, в том числе синхронизация и информационные взаимодействия, реализованы и доступны пользователям на уровне используемого языка в терминах управления процессами; □ произвольное количество внешних устройств (виртуальных), способных ра- ботать с памятью виртуальной машины параллельно или последовательно, асинхронно или синхронно по отношению к работе того или иного виртуаль- ного процессора, которые инициируют работу этих устройств. Информация, передаваемая или хранимая на виртуальных устройствах, не ограничена допус- тимыми размерами. Доступ к такой информации осуществляется на основе либо последовательного, либо прямого способа доступа в терминах соответст- вующей системы управления файлами. Предусмотрено расширение инфор- мационных структур данных, хранимых на виртуальных устройствах. Степень приближения к «идеальной» виртуальной машине может быть большей или меньшей в каждом конкретном случае. Чем больше виртуальная машина, реализуемая средствами ОС на базе конкретной аппаратуры, приближена к «иде- альной» по характеристикам машине и, следовательно, чем больше ее архитек- турно-логические характеристики отличны от реально существующих, тем боль- ше степень виртуальности у полученной пользователем машины.
Основные принципы построения операционных систем 195 Одним из аспектов виртуализации является организация возможности выполне- ния в данной ОС приложений, которые разрабатывались для других ОС. Други- ми словами, речь идет об организации нескольких операционных сред, о чем мы уже говорили (см. главу 1). Реализация этого принципа позволяет такой ОС иметь очень сильное преимущество перед аналогичными ОС, не имеющими такой воз- можности. Примером реализации принципа виртуализации может служить VDM- машина (virtual DOS machine) — защищенная подсистема, предоставляющая полную среду MS-DOS и консоль для выполнения MS-DOS приложений. Одно- временно может выполняться практически произвольное число VDM-сессий. Такие VDM-машины имеются и в системах Microsoft Windows, и в OS/2. Принцип независимости программ от внешних устройств Этот принцип реализуется сейчас в подавляющем большинстве ОС общего при- менения. Мы уже говорили о нем, рассматривая принципы организации ввода/ вывода. Пожалуй, впервые наиболее последовательно данный принцип был реа- лизован в ОС UNIX. Реализован он и в большинстве современных ОС для ПК. Напомним, этот принцип заключается в том, что связь программ с конкретны- ми устройствами производится не на уровне трансляции программы, а в период планирования ее исполнения. В результате перекомпиляция при работе про- граммы с новым устройством, на котором располагаются данные, не требуется. Принцип позволяет одинаково осуществлять операции управления внешними устройствами независимо от их конкретных физических характеристик. Напри- мер, программе, содержащей операции обработки последовательного набора дан- ных, безразлично, на каком носителе эти данные будут располагаться. Смена носителя и данных, размещаемых на них (при неизменности структурных харак- теристик данных), не принесет каких-либо изменений в программу, если в систе- ме реализован принцип независимости. Принцип совместимости Одним из аспектов совместимости является способность ОС выполнять програм- мы, написанные для других ОС или для более ранних версий данной операцион- ной системы, а также для другой аппаратной платформы. Необходимо разделять вопросы двоичной совместимости и совместимости на уров- не исходных текстов приложений. Двоичная совместимость достигается в том случае, когда можно взять исполняемую программу и запустить ее на выполне- ние на другой ОС. Для этого необходимы: совместимость на уровне команд про- цессора, совместимость на уровне системных вызовов и даже на уровне библио- течных вызовов, если они являются динамически связываемыми. Совместимость на уровне исходных текстов требует наличия соответствующего транслятора в составе системного программного обеспечения, а также совмести- мости на уровне библиотек и системных вызовов. При этом необходима пере- компиляция имеющихся исходных текстов в новый выполняемый модуль.
196 Глава 5. Архитектура операционных систем Гораздо сложнее достичь двоичной совместимости между процессорами, осно- ванными на разных архитектурах. Для того чтобы один компьютер выполнял про- граммы другого (например, программу для ПК типа IBM PC желательно выпол- нить на ПК типа Macintosh фирмы Apple), этот компьютер должен работать с машинными командами, которые ему изначально непонятны. В таком случае процессор типа 680x0 (или PowerPC) на Мас должен исполнять двоичный код, предназначенный для процессора i80x86. Процессор 80x86 имеет свои собствен- ные дешифратор команд, регистры и внутреннюю архитектуру. Процессор 680x0 не понимает двоичный код 80x86, поэтому он должен выбрать каждую команду, декодировать ее, чтобы определить, для чего она предназначена, а затем выпол- нить эквивалентную подпрограмму, написанную для 680x0. Так как к тому же у 680x0 нет в точности таких же регистров, флагов и внутреннего арифметико- логического устройства, как в 80x86, он должен имитировать все эти элементы с использованием своих регистров или памяти. И он должен тщательно воспро- изводить результаты каждой команды, что требует специально написанных под- программ для 680x0, гарантирующих, что состояние эмулируемых регистров и флагов после выполнения каждой команды будет в точности таким же, как и на реальном 80x86. Выходом в таких случаях является использование так называе- мых прикладных сред или эмуляторов. Учитывая, что основную часть програм- мы, как правило, составляют вызовы библиотечных функций, прикладная среда имитирует библиотечные функции целиком, используя заранее написанную биб- лиотеку функций аналогичного назначения, а остальные команды эмулирует каждую по отдельности. Одним из средств обеспечения совместимости программных и пользовательских интерфейсов является соответствие стандартам POSIX. Использование стандар- та POSIX позволяет создавать программы в стиле UNIX, которые впоследствии могут легко переноситься из одной системы в другую. Принцип открытой и наращиваемой ОС Открытая ОС доступна для анализа как пользователям, так и системным специа- листам, обслуживающим вычислительную систему. Наращиваемая (модифици- руемая, развиваемая) ОС позволяет не только использовать возможности гене- рации, но и вводить в ее состав новые модули, совершенствовать существующие и т. д. Другими словами, необходимо, чтобы можно было легко внести дополне- ния и изменения, если это потребуется, и не нарушить целостность системы. Пре- красные возможности для расширения предоставляет подход к структурирова- нию ОС по типу клиент—сервер с использованием микроядерной технологии. В соответствии с этим подходом ОС строится как совокупность привилегиро- ванной управляющей программы и набора непривилегированных услуг — «сер- веров». Основная часть ОС остается неизменной и в то же время могут быть до- бавлены новые серверы или улучшены старые. Этот принцип иногда трактуют как расширяемость системы. К открытым ОС, прежде всего, следует отнести UNIX-системы и, естественно, ОС Linux.
Основные принципы построения операционных систем 197 Принцип мобильности (переносимости) Операционная система относительно легко должна переноситься с процессора одного типа на процессор другого типа и с аппаратной платформы (которая включает наряду с типом процессора и способ организации всей аппаратуры ком- пьютера, иначе говоря, архитектуру вычислительной системы) одного типа на ап- паратную платформу другого типа. Заметим, что принцип переносимости очень близок принципу совместимости, хотя это и не одно и то же. Написание переносимой ОС аналогично написанию любого переносимого кода — нужно следовать некоторым правилам. Во-первых, большая часть ОС должна быть написана на языке, который имеется на всех системах, на которые планируется в дальнейшем ее переносить. Это, прежде всего, означает, что ОС должна быть написана на языке высокого уровня, предпочтительно стандартизованном, на- пример на языке С. Программа, написанная на ассемблере, не является в общем случае переносимой. Во-вторых, важно минимизировать или, если возможно, исключить те части кода, которые непосредственно взаимодействуют с аппарат- ными средствами. Зависимость от аппаратуры может иметь много форм. Неко- торые очевидные формы зависимости включают прямое манипулирование реги- страми и другими аппаратными средствами. Наконец, если аппаратно-зависимый код не может быть полностью исключен, то он должен быть изолирован в не- скольких хорошо локализуемых модулях. Аппаратно-зависимый код не должен быть распределен по всей системе. Например, можно спрятать аппаратно-зави- симую структуру в программно задаваемые данные абстрактного типа. Другие модули системы будут работать с этими данными, а не с аппаратурой, используя набор некоторых функций. Когда ОС переносится, то изменяются только эти данные и функции, которые ими манипулируют. Введение стандартов POSIX преследовало цель обеспечить переносимость соз- даваемого программного обеспечения. Принцип обеспечения безопасности вычислений Обеспечение безопасности при выполнении вычислений является желательным свойством для любой многопользовательской системы. Правила безопасности определяют такие свойства, как защита ресурсов одного пользователя от других и установление квот по ресурсам для предотвращения захвата одним пользова- телем всех системных ресурсов (таких, как память). Обеспечение защиты информации от несанкционированного доступа является обязательной функцией сетевых операционных систем. Во многих современных ОС гарантируется степень безопасности данных, соответствующая уровню С2 в системе стандартов США. Основы стандартов в области безопасности были за- ложены в документе «Критерии оценки надежных компьютерных систем». Этот документ, изданный Национальным центром компьютерной безопасности (NCSC — National Computer Security Center) в США в 1983 году, часто называют Оранже- вой книгой. В соответствии с требованиями Оранжевой книги безопасной считается систе- ма, которая «посредством специальных механизмов защиты контролирует дос-
198 Глава 5. Архитектура операционных систем туп к информации таким образом, что только имеющие соответствующие полно- мочия лица или процессы, выполняющиеся от их имени, могут получить доступ на чтение, запись, создание или удаление информации». Иерархия уровней безопасности, приведенная в Оранжевой книге, помечает низ- ший уровень безопасности как D, а высший — как А. В класс D попадают системы, оценка которых выявила их несоответствие требо- ваниям всех других классов. Основными свойствами, характерными для систем класса С, являются наличие подсистемы учета событий, связанных с безопасностью, и избирательный кон- троль доступа. Класс (уровень) С делится на 2 подуровня: уровень С1, обеспечи- вающий защиту данных от ошибок пользователей, но не от действий злоумыш- ленников; и более строгий уровень С2. На уровне С2 должны присутствовать: □ средства секретного входа, обеспечивающие идентификацию пользователей путем ввода уникального имени и пароля перед тем, как им будет разрешен доступ к системе; □ избирательный контроль доступа, позволяющий владельцу ресурса опреде- лить, кто имеет доступ к ресурсу и что он может с ним делать. Владелец дела- ет это путем предоставляемых прав доступа пользователю или группе пользо- вателей; □ средства учета и наблюдения (auditing), обеспечивающие возможность обна- ружить и зафиксировать важные события, связанные с безопасностью, или любые попытки создать, получить доступ или удалить системные ресурсы; □ защита памяти, заключающаяся в том, что память инициализируется перед тем, как повторно используется. На этом уровне система не защищена от ошибок пользователя, но поведение его может быть проконтролировано по записям в журнале, оставленным средствами наблюдения и аудита. Системы уровня В основаны на помеченных данных и распределении пользо- вателей по категориям, то есть реализуют мандатный контроль доступа. Каждо- му пользователю присваивается рейтинг защиты, и он может получать доступ к данным только в соответствии с этим рейтингом. Этот уровень в отличие от уровня С защищает систему от ошибочного поведения пользователя. Уровень А является самым высоким уровнем безопасности, он требует в допол- нение ко всем требованиям уровня В выполнения формального, математически обоснованного доказательства соответствия системы требованиям безопасности. Различные коммерческие структуры (например, банки) особо выделяют необхо- димость учетной службы, аналогичной той, что предлагают государственные ре- комендации С2. Любая деятельность, связанная с безопасностью, может быть отслежена и тем самым учтена. Это как раз то, чего требует стандарт для систем класса С2, и что обычно нужно банкам. Однако коммерческие пользователи, как правило, не хотят расплачиваться производительностью за повышенный уровень безопасности. A-уровень безопасности занимает своими управляющими меха- низмами до 90 % процессорного времени, что, безусловно, в большинстве случа- ев уже неприемлемо. Более безопасные системы не только снижают эффектив-
Микроядерные операционные системы 199 ность, но и существенно ограничивают число доступных прикладных пакетов, которые соответствующим образом могут выполняться в подобной системе. На- пример, для ОС Solaris (версия UNIX) есть несколько тысяч приложений, а для ее аналога В-уровня — только около ста. Микроядерные операционные системы Микроядро — это минимальная стержневая часть операционной системы, слу- жащая основой модульных и переносимых расширений. Существует мнение, что большинство операционных систем следующих поколений будут обладать мик- роядрами. Однако имеется масса разных мнений по поводу того, как следует организовывать службы операционной системы по отношению к микроядру: как проектировать драйверы устройств, чтобы добиться наибольшей эффективности, но сохранить функции драйверов максимально независимыми от аппаратуры; сле- дует ли выполнять операции, не относящиеся к ядру, в пространстве ядра или в пространстве пользователя; стоит ли сохранять программы имеющихся под- систем (например, UNIX) или лучше отбросить все и начать с нуля. Основная идея, заложенная в технологию микроядра, будь то собственно ОС или ее графический интерфейс, заключается в том, чтобы конструировать необходи- мую среду верхнего уровня, из которой можно легко получить доступ ко всем функциональным возможностям уровня аппаратного обеспечения. При такой структуре ядро служит стартовой точкой для создания системы. Искусство раз- работки микроядра заключается в выборе базовых примитивов, которые должны в нем находиться для обеспечения необходимого и достаточного сервиса. В мик- роядре содержится и исполняется минимальное количество кода, необходимое для реализации основных системных вызовов. В число этих вызовов входят пе- редача сообщений и организация другого общения между внешними по отно- шению к микроядру процессами, поддержка управления прерываниями, а также ряд некоторых других функций. Остальные функции, характерные для «обыч- ных» (не микроядерных) ОС, обеспечиваются как модульные дополнения-про- цессы, взаимодействующие главным образом между собой и осуществляющие взаимодействие посредством передачи сообщений. Микроядро является маленьким, передающим сообщения модулем системного программного обеспечения, работающим в наиболее приоритетном состоянии компьютера и поддерживающим остальную часть операционной системы, рас- сматриваемую как набор серверных приложений. Интерес к микроядрам возрастал по мере того, как системные разработчики реагировали на сложность современ- ных реализаций операционных систем, и поддержан тем, что исследовательское сообщество успешно продемонстрировало реализуемость концепции микроядра. Созданная в университете Карнеги Меллон технология микроядра Mach служит основой для многих ОС. Исполняемые микроядром функции ограничены в целях сокращения его разме- ров и максимизации количества кода, работающего как прикладная программа.
200 Глава 5. Архитектура операционных систем Микроядро включает только те функции, которые требуются для определения набора абстрактных сред обработки для прикладных программ и для организа- ции совместной работы приложений в обеспечении сервисов и в действии кли- ентами и серверами. В результате микроядро обеспечивает только пять различ- ных типов сервисов: □ управление виртуальной памятью; □ задания и потоки; □ межпроцессные коммуникации (IPC1); □ управление поддержкой ввода/вывода и прерываниями; □ сервисы набора хоста2 и процессора. Другие подсистемы и функции операционной системы, такие как системы управ- ления файлами, поддержка внешних устройств и традиционные программные интерфейсы, размещаются в одном или более системных сервисах либо в задаче операционной системы. Эти программы работают как приложения микроядра. Следуя концепции множественных потоков на одно задание, микроядро созда- ет прикладную среду, обеспечивающую использование мультипроцессоров без требования, чтобы какая-то конкретная машина была мультипроцессорной: на однопроцессорной различные потоки просто выполняются в разные времена. Вся поддержка, требуемая для мультипроцессорных машин, сконцентрирована в сравнительно малом и простом микроядре. Благодаря своим размерам и способности поддерживать стандартные сервисы программирования и характеристики в виде прикладных программ сами микро- ядра проще, чем ядра монолитных или модульных операционных систем. С мик- роядром функция операционной системы разбивается на модульные части, ко- торые могут быть сконфигурированы целым рядом способов, позволяя строить большие системы добавлением частей к меньшим. Например, каждый аппарат- но-независимый нейтральный сервис логически отделен и может быть сконфи- гурирован в широком диапазоне способов. Микроядра также облегчают под- держку мультипроцессоров созданием стандартной программной среды, которая может использовать множественные процессоры в случае их наличия, однако не требует их, если их нет. Специализированный код для мультипроцессоров ограничен самим микроядром. Более того, сети из общающихся между собой микроядер могут быть использованы для обеспечения операционной системной поддержки возникающего класса массивно параллельных машин. В некоторых случаях определенной сложностью использования микроядерного подхода на практике является замедление скорости выполнения системных вызовов при пе- редаче сообщений через микроядро по сравнению с классическим подходом. С другой стороны, можно констатировать и обратное. Поскольку микроядра малы и имеют сравнительно мало требуемого к исполнению кода уровня ядра, они обеспечивают удобный способ поддержки характеристик реального време- 1 IPC (inter-process communication) — межпроцессные коммуникации. 2 Host — главный компьютер. Сейчас этим термином обозначают любой компьютер, имею- щий 1Р-адрес.
Монолитные операционные системы 201 ни, требующихся для мультимедиа, управления устройствами и высокоскорост- ных коммуникаций. Наконец, хорошо структурированные микроядра обеспе- чивают изолирующий слой для аппаратных различий, которые не маскируются применением языков программирования высокого уровня. Таким образом, они упрощают перенесение кода и увеличивают уровень его повторного использова- ния. Наиболее ярким представителем микроядерных ОС является ОС реального вре- мени QNX. Микроядро QNX поддерживает только планирование и диспетчери- зацию процессов, взаимодействие процессов, обработку прерываний и сетевые службы нижнего уровня (более подробно об ОС QNX см. в главе 8). Микроядро обеспечивает всего лишь пару десятков системных вызовов, но благодаря этому оно может быть целиком размещено во внутреннем кэше даже таких процессо- ров, как Intel 486. Как известно, разные версии этой ОС имели и различные объ- емы ядер — от 8 до 46 Кбайт. Чтобы построить минимальную систему QNX, требуется добавить к микроядру менеджер процессов, который создает процессы, управляет процессами и памя- тью процессов. Чтобы ОС QNX была применима не только во встроенных и без- дисковых системах, нужно добавить файловую систему и менеджер устройств. Эти менеджеры исполняются вне пространства ядра, так что ядро остается не- большим. Монолитные операционные системы Монолитные ОС являются прямой противоположностью микроядерным ОС. При этом можно согласиться с тем, как трактуется архитектура монолитных ОС в ра- боте [54]. В монолитной ОС, несмотря на ее возможную сильную структуриза- цию, очень трудно удалить один из уровней многоуровневой модульной струк- туры. Добавление новых функций и изменение существующих для монолитных ОС требует очень хорошего знания всей архитектуры ОС и чрезвычайно боль- ших усилий. Поэтому более современный подход к проектированию ОС, кото- рый может быть условно назван как «клиент-серверная» технология, позволяет в большей мере и с меньшими трудозатратами реализовать перечисленные выше принципы проектирования ОС. Модель клиент—сервер предполагает наличие программного компонента, являю- щегося потребителем какого-либо сервиса — клиента, и программного компо- нента, служащего поставщиком этого сервиса — сервера. Взаимодействие между клиентом и сервером стандартизируется, так что сервер может обслуживать кли- ентов, реализованных различными способами и, может быть, разными разработ- чиками. При этом главным требованием является использование единообразного интерфейса. Инициатором обмена обычно является клиент, который посылает запрос на обслуживание серверу, находящемуся в состоянии ожидания запроса. Один и тот же программный компонент может быть клиентом по отношению к одному виду услуг и сервером для другого вида услуг. Модель клиент—сер- вер является скорее удобным концептуальным средством ясного представления функций того или иного программного элемента в какой-либо ситуации, нежели
202 Глава 5. Архитектура операционных систем технологией. Эта модель успешно применяется не только при построении ОС, но и на всех уровнях программного обеспечения и имеет в некоторых случаях более узкий, специфический смысл, сохраняя, естественно, при этом все свои об- щие черты. При поддержке монолитных ОС возникает ряд проблем, связанных с тем, что все функции макроядра работают в едином адресном пространстве. Во-первых, это опасность возникновения конфликта между различными частями ядра; во- вторых — сложность подключения к ядру новых драйверов. Преимущество мик- роядерной архитектуры перед монолитной заключается в том, что каждый ком- понент системы представляет собой самостоятельный процесс, запуск или оста- новка которого не отражается на работоспособности остальных процессов. Микроядерные ОС в настоящее время разрабатываются чаще монолитных. Од- нако следует заметить, что использование технологии клиент—сервер — это еще не гарантия того, что ОС станет микроядерной. В качестве подтверждения мож- но привести пример с ОС Windows NT, которая построена на идеологии клиент- сервер, но которую тем не менее трудно назвать микроядерной. Для того чтобы согласиться с таким высказыванием, достаточно сравнить ОС QNX и ОС Win- dows NT. Требования, предъявляемые к ОС реального времени Как известно, система реального времени (СРВ) должна давать отклик на любые непредсказуемые внешние воздействия в течение предсказуемого интервала вре- мени. Для этого должны быть обеспечены следующие свойства: □ Ограничение времени отклика, то есть после наступления события реакция на него гарантированно последует до предустановленного крайнего срока. От- сутствие такого ограничения рассматривается как серьезный недостаток про- граммного обеспечения. □ Одновременность обработки: д&же если наступает более одного события од- новременно, все временные ограничения для всех событий должны быть вы- держаны. Это означает, что системе реального времени должен быть присущ параллелизм. Параллелизм достигается использованием нескольких процес- соров в системе и/или многозадачного подхода. Примерами систем реального времени являются системы управления атомными электростанциями или какими-нибудь технологическими процессами, медицин- ского мониторинга, управления вооружением, космической навигации, разведки, управления лабораторными экспериментами, управления автомобильными дви- гателями, робототехника, телеметрические системы управления, системы анти- блокировки тормозов, системы сигнализации — список в принципе бесконечен. Иногда можно услышать из разговоров специалистов, что различают системы «мягкого» и «жесткого» реального времени. Различие между жесткой и мягкой СРВ зависит от требований к системе — система считается жесткой, если «нару-
Требования, предъявляемые к ОС реального времени 203 шение временных ограничений не допустимо», и мягкой, если «нарушение вре- менных ограничений нежелательно». Ведется множество дискуссий о точном смысле терминов «жесткая» и «мягкая» СРВ. Можно даже аргументировать, что мягкая СРВ не является СРВ вовсе, ибо основное требование соблюдения временных ограничений не выполнено. В действительности термин СРВ часто неправомерно применяют по отношению к быстрым системам. Часто путают понятия СРВ и ОСРВ1, а также неправильно используют атрибу- ты «мягкая» и «жесткая». Иногда говорят, что та или иная ОСРВ мягкая или жесткая. Нет мягких или жестких ОСРВ. ОСРВ может только служить основой для построения мягкой или жесткой СРВ. Сама по себе ОСРВ не препятствует тому, что ваша СРВ будет мягкой. Например, вы решили создать СРВ, которая должна работать через Ethernet по протоколу TCP/IP. Такая система не может быть жесткой СРВ, поскольку сама сеть Ethernet в принципе непредсказуема вследствие использования случайного метода доступа к среде передачи данных, в отличие, например, от IBM Token Ring или ARC-Net, в которых используются детерминированные методы доступа. Итак, перечислим основные требования к ОСРВ. Мультипрограммность и многозадачность Требование У. ОС должна быть мультипрограммной и многозадачной (многопо- точной — multi-threaded) и активно использовать прерывания для диспетчери- зации. Как указывалось выше, ОСРВ должна быть предсказуемой. Это означает не то, что ОСРВ должна быть быстрой, а то, что максимальное время выполнения того или иного действия должно быть известно заранее и должно соответствовать требованиям приложения. Так, например, ОС Windows 3.11 даже на Pentium III 1000 MHz бесполезна для ОСРВ, ибо одно приложение может захватить управ- ление и заблокировать систему для остальных. Первое требование состоит в том, что ОС должна быть многопоточной по прин- ципу абсолютного приоритета (прерываемой). То есть планировщик должен иметь возможность прервать любой поток и предоставить ресурс тому потоку, которо- му он более необходим. ОС (и аппаратура) должны также обеспечивать преры- вания на уровне обработки прерываний. Приоритеты задач (потоков) Требование 2. В ОС должно существовать понятие приоритета потока. Проблема в том, чтобы определить, какой задаче ресурс требуется более всего. В идеальной ситуации ОСРВ отдает ресурс потоку или драйверу с ближайшим крайним сроком (это называется управлением временным ограничением, deadline driven OS). Чтобы реализовать это временное ограничение, ОС должна знать, сколько времени требуется каждому из выполняющихся потоков для завершения. 1 ОСРВ — операционная система реального времени
204 Глава 5. Архитектура операционных систем ОС, построенных по этому принципу, практически нет, так как он слишком сло- жен для реализации. Поэтому разработчики ОС принимают иную точку зрения: вводится понятие уровня приоритета для задачи, и временные ограничения сво- дят к приоритетам. Так как умозрительные решения чреваты ошибками, показа- тели СРВ при этом снижаются. Чтобы более эффективно осуществить указанное преобразование ограничений, проектировщик может воспользоваться теорией расписаний или имитационным моделированием, хотя и это может оказаться бесполезным. Тем не менее, так как на сегодняшний день не имеется иного реше- ния, понятие приоритета потока неизбежно. Наследование приоритетов Требование 3. В ОС должна существовать система наследования приоритетов. На самом деле именно этот механизм синхронизации и тот факт, что различные треды используют одно и то же пространство памяти, отличают их от процессов. Как мы уже знаем, процессы почти не разделяют одно и то же пространство па- мяти, а в основном работают в своих локальных адресных пространствах. Так, например, старые версии UNIX не являются мультитредовыми (multi-threaded). «Старый» UNIX — многозадачная ОС, где задачами являются процессы (а не треды), которые сообщаются через потоки (pipes) и разделяемую память. Оба эти механизма используют файловую систему, а ее поведение — непредсказуемо. Комбинация приоритетов тредов и разделения ресурсов между ними приводит к другому явлению — классической проблеме инверсии приоритетов. Это можно проиллюстрировать на примере, когда есть как минимум три треда. Когда тред низшего приоритета захватил ресурс, разделяемый с тредом высшего приорите- та, и начал выполняться поток среднего приоритета, выполнение треда высшего приоритета будет приостановлено, пока не освободится ресурс и не отработает тред среднего приоритета. В этой ситуации время, необходимое для завершения треда высшего приоритета, зависит от нижних приоритетных уровней, — это и есть инверсия приоритетов. Ясно, что в такой ситуации трудно выдержать огра- ничение на время исполнения. Чтобы устранить такие инверсии, ОСРВ должна допускать наследование при- оритета, то есть повышение уровня приоритета треда до уровня треда, который его вызывает. Наследование означает, что блокирующий ресурс тред наследу- ет приоритет треда, который он блокирует (разумеется, это справедливо лишь в том случае, если блокируемый тред имеет более высокий приоритет). Иногда можно услышать утверждение, что в грамотно спроектированной систе- ме такая проблема не возникает. В случае сложных систем с этим нельзя согла- ситься. Единственный способ решения этой проблемы состоит в увеличении приоритета треда «вручную» прежде, чем ресурс окажется заблокированным. Разумеется, это возможно в случае, когда два треда разных приоритетов претен- дуют на один ресурс. В общем случае решения не существует. Синхронизация процессов и задач Требование 4. ОС должна обеспечивать мощные, надежные и удобные механиз- мы синхронизации задач.
Принципы построения интерфейсов операционных систем 205 Так как задачи разделяют данные (ресурсы) и должны сообщаться друг с дру- гом, представляется логичным, что должны существовать механизмы блокирова- ния и коммуникации. Необходимы механизмы, гарантированно предоставляющие возможность параллельно выполняющимся задачам и процессам оперативно об- мениваться сообщениями и синхросигналами. Эти системные механизмы должны быть всегда доступны процессам, требующим реального времени. Следователь- но, системные ресурсы для их функционирования должны быть распределены заранее. Предсказуемость Требование 5. Поведение ОС должно быть известно и достаточно точно прогно- зируемо. Времена выполнения системных вызовов и временные характеристики поведе- ния системы в различных обстоятельствах должны быть известны разработчику. Поэтому создатель ОСРВ должен приводить следующие характеристики: □ латентную задержку прерывания (то есть время от момента прерывания до момента запуска задачи): она должна быть предсказуема и согласована с тре- бованиями приложения. Эта величина зависит от числа одновременно «вися- щих» прерываний; □ максимальное время выполнения каждого системного вызова. Оно должно быть предсказуемо и не зависимо от числа объектов в системе; □ максимальное время маскирования прерываний драйверами и ОС. Принципы построения интерфейсов операционных систем Напомним, что ОС всегда выступает как интерфейс между аппаратурой компью- тера и пользователем с его задачами. Под интерфейсами операционных систем здесь и далее следует понимать специальные интерфейсы системного и приклад- ного программирования, предназначенные для выполнения следующих задач: □ Управление процессами, которое включает в себя следующий набор основ- ных функций: О запуск, приостанов и снятие задачи с выполнения; О задание или изменение приоритета задачи; О взаимодействие задач между собой (механизмы сигналов, семафорные примитивы, очереди, конвейеры, почтовые ящики); О RPC (remote procedure call) — удаленный вызов подпрограмм. □ Управление памятью: О запрос на выделение блока памяти; О освобождение памяти;
206 Глава 5. Архитектура операционных систем О изменение параметров блока памяти (например, память может быть забло- кирована процессом либо предоставлена в общий доступ); О отображение файлов на память (имеется не во всех системах). □ Управление вводом/выводом: О запрос на управление виртуальными устройствами (напомним, что управ- ление вводом/выводом является привилегированной функцией самой ОС, и никакая из пользовательских задач не должна иметь возможности непо- средственно управлять устройствами); О файловые операции (запросы к системе управления файлами на создание, изменение и удаление данных, организованных в файлы). Здесь мы перечислили основные наборы функций, которые выполняются ОС по соответствующим запросам от задач. Что касается пользовательского интерфейса операционной системы, то он реализуется с помощью специальных программ- ных модулей, которые принимают его команды на соответствующем языке (воз- можно, с использованием графического интерфейса) и транслируют их в обычные вызовы в соответствии с основным интерфейсом системы. Обычно эти модули называют интерпретатором команд. Так, например, функции такого интерпрета- тора в MS-DOS выполняет модуль COMMAND.COM. Получив от пользователя команду, такой модуль после лексического и синтаксического анализа либо сам выполняет действие, либо, что случается чаще, обращается к другим модулям ОС, используя механизм API. Надо заметить, что в последние годы большую по- пулярность получили графические интерфейсы (GUI), в которых задействованы соответствующие манипуляторы типа «мышь» или «трекбол»1. Указание курсо- ром на объекты и щелчок (клик) или двойной щелчок по соответствующим кла- вишам приводит к каким-либо действиям — запуску программы, ассоциирован- ной с указываемым объектом, выбору и/или активизации пунктов меню и т. д. Можно сказать, что такая интерфейсная подсистема транслирует «команды» поль- зователя в обращения к ОС. Поясним также, что управление GUI — частный случай задачи управления вво- дом/выводом, не являющийся частью ядра операционной системы, хотя в ряде случаев разработчики ОС относят функции GUI к основному системному API. Следует отметить, что имеются два основных подхода к управлению задачами. Так, в одних системах порождаемая задача наследует все ресурсы задачи-роди- теля, тогда как в других системах существуют равноправные отношения, и при порождении нового процесса ресурсы для него запрашиваются у операционной системы. Обращения к операционной системе, в соответствии с имеющимся API, может осуществляться как посредством вызова подпрограммы с передачей ей необ- ходимых параметров, так и через механизм программных прерываний. Выбор метода реализации вызовов функций API должен определяться архитектурой платформы. 1 Trackball — в переносных компьютерах очень часто используется для управления переме- щением курсора специальный шарик, который размещается рядом с клавиатурой и про- кручивается пальцами.
Интерфейс прикладного программирования 207 Так, например, в операционной системе MS-DOS, которая разрабатывалась для однозадачного режима (поскольку процессор i8086 не поддерживал мультипро- граммирование), использовался механизм программных прерываний. При этом основной набор функций API был доступен через точку входа обработчика i nt 21h. В более сложных системах имеется не одна точка входа, а множество — по ко- личеству функций API. Так, в большинстве операционных систем используется метод вызова подпрограмм. В этом случае вызов сначала передается в модуль API (например, это библиотека времени выполнения1), который и перенаправля- ет вызов соответствующим обработчикам программных прерываний, входящим в состав операционной системы. Использование механизма прерываний вызва- но, главным образом, тем, что при этом процессор переводится в режим суперви- зора. Интерфейс прикладного программирования Прежде всего необходимо однозначно разделить общий термин API (application program interface, интерфейс прикладного программирования) на следующие направления: □ API как интерфейс высокого уровня, принадлежащий к библиотекам RTL; □ API прикладных и системных программ, входящих в поставку операционной системы; □ прочие API. Интерфейс прикладного программирования, как это и следует из названия, пред- назначен для использования прикладными программами системных ресурсов ОС и реализуемых ею функций. API описывает совокупность функций и процедур, принадлежащих ядру или надстройкам ОС. Итак, API представляет собой набор функций, предоставляемых системой про- граммирования разработчику прикладной программы и ориентированных на ор- ганизацию взаимодействия результирующей прикладной программы с целевой вычислительной системой. Целевая вычислительная система представляет собой совокупность программных и аппаратных средств, в окружении которых выпол- няется результирующая программа. Сама результирующая программа порожда- ется системой программирования на основании кода исходной программы, соз- данного разработчиком, а также объектных модулей и библиотек, входящих в состав системы программирования. В принципе API используется не только прикладными, но и многими системны- ми программами как в составе ОС, так и в составе системы программирования. 1 RTL (run time library) — библиотека времени выполнения; она включает в себя те стан- дартные подпрограммы, которые система программирования подставляет на этапе компиляции. В общем случае RTL включает в себя не только модули из системы про- граммирования, но и модули самой ОС.
208 Глава 5. Архитектура операционных систем Но дальше речь пойдет только о функциях API с точки зрения разработчика прикладной программы. Для системной программы существуют некоторые до- полнительные ограничения на возможные реализации API. Функции API позволяют разработчику строить результирующую прикладную программу так, чтобы использовать средства целевой вычислительной системы для выполнения типовых операций. При этом разработчик программы избавлен от необходимости создавать исходный код для выполнения этих операций. Программный интерфейс API включает в себя не только сами функции, но и со- глашения об их использовании, которые регламентируются операционной систе- мой (ОС), архитектурой целевой вычислительной системы и системой програм- мирования. Существует несколько вариантов реализации API: □ реализация на уровне ОС; □ реализация на уровне системы программирования; □ реализация на уровне внешней библиотеки процедур и функций. Система программирования в каждом из этих вариантов предоставляет разра- ботчику средства для подключения функций API к исходному коду программы и организации их вызовов. Объектный код функций API подключается к резуль- тирующей программе компоновщиком при необходимости. Возможности API можно оценивать со следующих позиций: □ эффективность выполнения функций API — включает в себя скорость вы- полнения функций и объем вычислительных ресурсов, потребных для их вы- полнения; Q широта предоставляемых возможностей; □ зависимость прикладной программы от архитектуры целевой вычислитель- ной системы. В идеале хотелось бы иметь набор функций API, выполняющихся с наивысшей эффективностью, предоставляющих пользователю все возможности современ- ных ОС и имеющих минимальную зависимость от архитектуры вычислительной системы (еще лучше — лишенных такой зависимости). Добиться наивысшей эффективности выполнения функций API практически трудно по тем же причинам, по которым невозможно добиться наивысшей эф- фективности выполнения для любой результирующей программы. Поэтому об эффективности API можно говорить только в сравнении его характеристик с другим API. Что касается двух других показателей, то в принципе нет никаких технических ограничений на их реализацию. Однако существуют организационные проблемы и узкие корпоративные интересы, тормозящие создание такого рода библиотек. Реализация функций API на уровне ОС При реализации функций API на уровне ОС за их выполнение ответственность несет ОС. Объектный код, выполняющий функции, либо непосредственно вхо-
Интерфейс прикладного программирования 209 дит в состав ОС (или даже ядра ОС), либо поставляется в составе динамически загружаемых библиотек, разработанных для данной ОС. Система программиро- вания ответственна только за то, чтобы организовать интерфейс для вызова это- го код$. В таком варианте результирующая программа обращается непосредственно к ОС. Поэтому достигается наибольшая эффективность выполнения функций API по сравнению со всеми другими вариантами реализации API. Недостатком организации API по такой схеме является практически полное от- сутствие переносимости не только кода результирующей программы, но и кода исходной программы. Программа, созданная для одной архитектуры вычисли- тельной системы, не сможет исполняться на вычислительной системе другой ар- хитектуры даже после того, как ее объектный код будет полностью перестроен. Чаще всего система программирования не сможет выполнить перестроение ис- ходного кода для новой архитектуры вычислительной системы, поскольку мно- гие функции API, ориентированные на определенную ОС, будут в новой архи- тектуре просто отсутствовать. Таким образом, в данной схеме для переноса прикладной программы с одной це- левой вычислительной системы на другую будет требоваться изменение исход- ного кода программы. Переносимости можно было бы добиться, если унифицировать функции API в различных ОС. С учетом корпоративных интересов производителей ОС такое направление их развития представляется практически невозможным. В лучшем случае при приложении определенных организационных усилий удается добить- ся стандартизации смыслового (семантического) наполнения основных функ- ций API, но не их программного интерфейса. Хорошо известным примером API такого рода может служить набор функций, предоставляемых пользователю со стороны ОС типа Microsoft Windows — WinAPI (Windows API). Надо сказать, что даже внутри этого корпоративного API суще- ствует определенная несогласованность, которая несколько ограничивает пере- носимость программ между различными ОС типа Windows. Еще одним приме- ром такого API можно считать набор сервисных функций ОС типа MS-DOS, реализованный в виде набора подпрограмм обслуживания программных преры- ваний. Реализация функций API на уровне системы программирования Если функции API реализуются на уровне системы программирования, они пре- доставляются пользователю в виде библиотеки функций соответствующего язы- ка программирования. Обычно речь идет о библиотеке времени исполнения — RTL (run time library). Система программирования предоставляет пользователю библиотеку соответствующего языка программирования и обеспечивает подклю- чение к результирующей программе объектного кода, ответственного за выпол- нение этих функций.
210 Глава 5. Архитектура операционных систем Очевидно, что эффективность функций API в таком варианте будет несколько ниже, чем при непосредственном обращении к функциям ОС. Так происходит, поскольку для выполнения многих функций API библиотека RTL языка про- граммирования должна все равно выполнять обращения к функциям ОС. Нали- чие всех необходимых вызовов и обращений к функциям ОС в объектном коде RTL обеспечивает система программирования. Однако переносимость исходного кода программы в таком варианте будет самой высокой, поскольку синтаксис и семантика всех функций будут строго регла- ментированы в стандарте соответствующего языка программирования. Они за- висят от языка и не зависят от архитектуры целевой вычислительной системы. Поэтому для выполнения прикладной программы на новой архитектуре вычис- лительной системы достаточно заново построить код результирующей програм- мы с помощью соответствующей системы программирования. Единообразное выполнение функций языка обеспечивается системой программи- рования. При ориентации на различные архитектуры целевой вычислительной системы в системе программирования могут потребоваться различные комбина- ции вызовов функций ОС для выполнения одних и тех же функций исходного языка. Это должно быть учтено в коде RTL. В общем случае для каждой архи- тектуры целевой вычислительной системы будет требоваться свой код RTL язы- ка программирования. Выбор того или иного объектного кода RTL для подклю- чения к результирующей программе система программирования обеспечивает автоматически. Например, рассмотрим функции динамического выделения памяти в языках С и Pascal. В С это функции mall ос, realloc и free (функции new и delete в C++), в Pascal — функции new и dispose. Если использовать эти функции в исходном тексте программы, то с точки зрения исходной программы они будут действо- вать одинаковым образом в зависимости только от семантики исходного кода. При этом для разработчика исходной программы не имеет значения, на какую архитектуру ориентирована его программа. Это имеет значение для системы про- граммирования, которая для каждой из этих функций должна подключить к ре- зультирующей программе объектный код библиотеки. Этот код будет выполнять обращение к соответствующим функциям ОС. Не исключено даже, что для од- нотипных по смыслу функций в разных языках (например, mall ос в С и new в языке Pascal выполняют схожие по смыслу действия) этот код будет выполнять схожие обращения к ОС. Однако для различных вариантов ОС этот код будет различен даже при использовании одного и того же исходного языка. Проблема, главным образом, заключается в том, что большинство языков про- граммирования предоставляют пользователю не очень широкий набор стандар- тизованных функций. Поэтому разработчик исходного кода существенно огра- ничен в выборе доступных функций API. Как правило, набора стандартных функций оказывается недостаточно для создания полноценной прикладной про- граммы. Тогда разработчик может обратиться к функциям других библиотек, имеющихся в составе системы программирования. В этом случае нет гарантии, что функции, включенные в состав данной системы программирования, но не входящие в стандарт языка программирования, будут доступны в другой систе- ме программирования. Особенно если она ориентирована на другую архитектуру
Интерфейс прикладного программирования 211 целевой вычислительной системы. Такая ситуация уже ближе к третьему вари- анту реализации API. Например, те же функции mall ос, realloc и free в языке С фактически не входят в стандарт языка. Они входят в состав стандартной библиотеки, которая «де- факто» входит во все системы программирования, построенные на основе язы- ка С. Общепринятые стандарты существуют для многих часто используемых функций языка. Если же взять более специфические функции, такие как функ- ции порождения новых процессов, то для них ни в С, ни в языке Pascal не ока- жется общепринятого стандарта. Реализация функций API с помощью внешних библиотек При реализации функций API с помощью внешних библиотек они предоставля- ются пользователю в виде библиотеки процедур и функций, созданной сторон- ним разработчиком. Причем разработчиком такой библиотеки может выступать тот же самый производитель. Система программирования ответственна только за то, чтобы подключить объ- ектный код библиотеки к результирующей программе. Причем внешняя библио- тека может быть и динамически загружаемой (загружаемой во время выполне- ния программы). С точки зрения эффективности выполнения этот метод реализации API имеет самые низкие результаты, поскольку внешняя библиотека обращается как к функ- циям ОС, так и к функциям RTL языка программирования. Только при очень высоком качестве внешней библиотеки ее эффективность становится сравнимой с библиотекой RTL. Если говорить о переносимости исходного кода, то здесь требование только одно — используемая внешняя библиотека должна быть доступна в любой из архитек- тур вычислительных систем, на которые ориентирована прикладная программа. Тогда удается достигнуть переносимости. Это возможно, если используемая биб- лиотека удовлетворяет какому-то принятому стандарту, а система программиро- вания поддерживает этот стандарт. Например, библиотеки, удовлетворяющие стандарту POSIX (см. следующий подраздел), доступны в большинстве систем программирования для языка С. И если прикладная программа использует только библиотеки этого стандарта, то ее исходный код будет переносимым. Еще одним примером является широко известная библиотека графического интерфейса XLib, поддерживающая стандарт графической среды X Window. Для большинства специфических библиотек отдельных разработчиков это не так. Если пользователь использует какую-то библиотеку, то она ориентирована на ограниченный набор доступных архитектур целевой вычислительной системы. Примерами могут служить библиотеки MFC (Microsoft foundation classes) фир- мы Microsoft и VCL (visual controls library) фирмы Borland, жестко ориентиро- ванные на архитектуру ОС типа Windows.
212 Глава 5. Архитектура операционных систем Тем не менее многие фирмы-разработчики сейчас стремятся создать библиоте- ки, которые бы обеспечивали переносимость исходного кода приложений между различными архитектурами целевой вычислительной системы. Пока еще такие библиотеки не получили широкого распространения, но есть несколько попыток их реализации — например, библиотека CLX производства фирмы Borland ори- ентирована на архитектуру ОС типа Linux и ОС типа Windows. В целом развитие функций прикладного API идет в направлении попытки соз- дать библиотеки API, обеспечивающие широкую переносимость исходного кода. Однако, учитывая корпоративные интересы различных производителей и сло- жившуюся ситуацию на рынке системного программного обеспечения, в бли- жайшее время вряд ли удастся достичь значительных успехов в этом направле- нии. Разработка широко применимого стандарта API пока еще остается делом будущего. Поэтому разработчики системных программ вынуждены оставаться в довольно узких рамках ограничений стандартных библиотек языков программирования. Что касается прикладных программ, то гораздо большую перспективу для них предоставляют технологии, связанные с разработками в рамках архитектуры «клиент—сервер» или трехуровневой архитектуры создания приложений. В этом направлении ведущие производители ОС, СУБД и систем программирования скорее достигнут соглашений, чем в направлении стандартизации API. Итак, нами были рассмотрены основные принципы, цели и подходы к реализа- ции системных API. Отметим еще один очень важный момент: желательно, что- бы интерфейс прикладного программирования не зависел от системы програм- мирования. Конечно, были одно время персональные компьютеры, у которых базовой системой программирования выступал интерпретатор с языка Basic, но это скорее исключение. Обычно базовые функции API не зависят от системы программирования и могут использоваться из любой системы программирова- ния, хотя и с применением соответствующих правил построения вызывающих последовательностей. В то же время в ряде случаев система программирования может сама генерировать обращения к функциям API. Например, мы можем на- писать в программе вызов функции по запросу 256 байт памяти unsigned char * ptr = malloc (256); Система программирования языка С сгенерирует целую последовательность об- ращений. Из кода пользовательской программы будет осуществлен вызов биб- лиотечной функции malloc, код которой расположен в RTL языка С. Библиотека времени выполнения в данном случае реализует вызов malloc уже как вызов сис- темной функции API НеарАПос LPVOID НеарА11ос( HANDLE hHeap, // handle to the private heap block - указатель на блок DWORD dwFlags, // heap allocation control flags - свойства блока DWORD dwBytes // number of bytes to allocate - размер блока Параметры выделяемого блока памяти в таком случае задаются системой про- граммирования, и пользователь лишен возможности задавать их напрямую. С дру-
Платформенно-независимый интерфейс POSIX 213 гой стороны, если это необходимо, возможно использование функций API прямо в тексте программы. unsigned char * ptr - (LPVOID) HeapAlloc( GetProcessHeapO, 0, 256): В этом случае программирование вызова немного усложняется, но получаемый конечный результат будет, как правило, короче и, что самое важное, будет рабо- тать эффективнее. Следует отметить, что далеко не все возможности API доступ- ны через обращения к функциям системы программирования. Непосредственное обращение к функциям API позволяет пользователю обращаться к системным ресурсам более эффективным способом. Однако это требует знания функций API, количество которых нередко достигает нескольких сотен. Как правило, API не стандартизированы. В каждом конкретном случае набор вы- зовов API определяется, прежде всего, архитектурой ОС и ее назначением. В то же время принимаются попытки стандартизировать некоторый базовый набор функций, поскольку это существенно облегчает перенос приложений с одной ОС в другую. Таким примером может служить очень известный и, пожалуй, один из самых распространенных стандартов — стандарт POSIX. В этом стандарте пе- речислен большой набор функций, их параметров и возвращаемых значений. Стандартизированными, согласно POSIX, являются не только обращения к API, но и файловая система, организация доступа к внешним устройствам, набор сис- темных команд1. Использование в приложениях этого стандарта позволяет в дальнейшем легко переносить такие программы с одной ОС в другую путем про- стейшей перекомпиляции исходного текста. Частным случаем попытки стандартизации API является внутренний корпора- тивный стандарт компании Microsoft, известный как WinAPI. Он включает в себя следующие реализации: Winl6, Win32s, Win32, WinCE. С точки зрения WinAPI (в силу ряда идеологических причин — обязательный графический «оконный» интерфейс пользователя), базовой задачей является окно. Таким образом, WinAPI изначально ориентирован на работу в графической среде. Однако базовые поня- тия дополнены традиционными функциями, в том числе частично поддержива- ется стандарт POSIX. Платформенно-независимый интерфейс POSIX POSIX (Portable Operating System Interface for Computer Environments) — плат- форменно независимый системный интерфейс для компьютерного окружения. Это стандарт IEEE, описывающий системные интерфейсы для открытых опера- ционных систем, в том числе оболочки, утилиты и инструментарии. Помимо этого, согласно POSIX, стандартизированными являются задачи обеспечения безопасности, задачи реального времени, процессы администрирования, сетевые 1 В данном контексте под системными командами следует понимать некий набор про- грамм, позволяющих управлять вычислительными процессами. Например, pstat, kill, dir и др.
214 Глава 5. Архитектура операционных систем функции и обработка транзакций. Стандарт базируется на UNIX-системах, но допускает реализацию и в других ОС. POSIX возник как попытка всемирно известной организации IEEE1 пропаганди- ровать переносимость приложений в UNIX-средах путем разработки абстракт- ного, платформенно-независимого стандарта. Однако POSIX не ограничивается только UNIX-системами; существуют различные реализации этого стандарта в системах, которые соответствуют требованиям, предъявляемым стандартом IEEE Standard 1003.1-1990 (POSIX.1). Например, известная ОС реального времени QNX соответствует спецификациям этого стандарта, что облегчает перенос приложе- ний в эту систему, но UNIX-системой не является ни в каком виде, ибо ее архи- тектура использует абсолютно иные принципы. Этот стандарт подробно описывает VMS (virtual memory system, систему вирту- альной памяти), многозадачность (МРЕ, multiprocess executing) и технологию переноса операционных систем (CTOS). Таким образом, на самом деле POSIX представляет собой множество стандартов, именуемых POSIX. 1 — POSIX. 12. В табл. 5.1 приведены основные направления, описываемые данными стандар- тами. Следует также особо отметить, что POSIX. 1 предполагает язык С как ос- новной язык описания системных функций API. Таблица 5.1. Семейство стандартов POSIX Стандарт Стандарт ISO Краткое описание POSIX.O Нет Введение в стандарт открытых систем. Данный документ не является стандартом в чистом виде, а представляет собой рекомендации и краткий обзор технологий POSIX.1 Да Системный API (язык С) POSIX.2 Нет Оболочки и утилиты (одобренные IEEE) POSIX.3 Нет Тестирование и верификация POSIX.4 Нет Задачи реального времени и нити POSIX.5 Да Использование языка ADA применительно к стандарту POSIX. 1 POSIX.6 Нет Системная безопасность POSIX.7 Нет Администрирование системы POSIX.8 Нет Сети «Прозрачный» доступ к файлам Абстрактные сетевые интерфейсы, не зависящие от физических протоколов RPC (remote procedure calls, вызовы удаленных процедур) Связь системы с протоколо-зависимыми приложениями 1 IEEE (Institute of Electrical and Electronical Engineers) — американский Институт инже- неров по электротехнике и радиоэлектронике.
Платформенно-независимый интерфейс POSIX 215 Стандарт Стандарт ISO Краткое описание POSIX.9 Да Использование языка FORTRAN применительно к стандарту POSIX. 1 POSIX.10 Нет Super-computing Application Environment Profile (AEP) POSIX.il Нет Обработка транзакций AEP POSIX.12 Нет Графический интерфейс пользователя (GUI) Таким образом, программы, написанные с соблюдением данных стандартов, будут одинаково выполняться на всех POSIX-совместимых системах. Однако стандарт в некоторых случаях носит лишь рекомендательный характер. Часть стандартов описана очень строго, тогда как другая часть только поверхностно раскрывает основные требования. Нередко программные системы заявляются как POSIX- совместимые, хотя таковыми их назвать нельзя. Причины кроются в формально- сти подхода к реализации POSIX-интерфейса в различных ОС. На рис. 5.1 изо- бражена типовая схема реализации строго соответствующего POSIX приложе- ния. Рис. 5.1. Приложения, строго соответствующие стандарту POSIX Из рис. 5.1 видно, что для взаимодействия с операционной системой программа использует только библиотеки POSIX. 1 и стандартную библиотеку RTL языка С, в которой возможно использование лишь 110 различных функций, также опи- санных стандартом POSIX. 1. К сожалению, достаточно часто с целью увеличить производительность той или иной подсистемы либо из соображений введения фирменных технологий, кото- рые ограничивают использование приложения соответствующей операционной средой, при программировании используются другие функции, не отвечающие стандарту POSIX. Реализации POSIX API на уровне операционной системы различны. Если UNIX- системы в своем абсолютном большинстве изначально соответствуют специфи- кациям IEEE Standard 1003.1-1990, то WinAPI не является POSIX-совместимым. Однако для поддержки данного стандарта в операционной системе MS Win- dows NT введен специальный модуль поддержки POSIX API, работающий на
216 Глава 5. Архитектура операционных систем уровне привилегий пользовательских процессов. Данный модуль обеспечивает конвертацию и передачу вызовов из пользовательской программы к ядру систе- мы и обратно, работая с ядром через WinAPI. Прочие приложения, написанные с использованием WinAPI, могут передавать информацию POSIX-приложениям через стандартные механизмы потоков ввода/вывода (stdin, stdout) [97]. Пример программирования в различных API ОС Для наглядной демонстрации принципиальных различий API наиболее популяр- ных современных операционных систем для ПК рассмотрим простейший при- мер, в котором реализуется следующая задача. Постановка задачи: необходимо подсчитать количество пробелов в текстовых фай- лах, имена которых должны указываться в командной строке. Рассмотрим два варианта программы, решающей эту задачу, — для Windows (с использованием WinAPI) и для Linux (POSIX API). Поскольку нас интересует работа с параллельными задачами, пусть при выпол- нении программы для каждого перечисленного в командной строке файла созда- ется свой процесс или задача (тред), который параллельно с другими процессами (тредами) производит работу по подсчету пробелов в «своем» файле. Результа- том работы программы будет являться список файлов с подсчитанным количест- вом пробелов для каждого. Следует обратить особое внимание на то, что приведенная ниже конкретная реа- лизация данной задачи не является единственно возможной. В обеих рассмат- риваемых операционных системах существуют различные методы как работы с файловой системой, так и управления процессами. В данном случае рассматри- вается только один, но наиболее характерный для соответствующего API вариант. Текст программы для Windows (WinAPI) Для того чтобы было удобнее сравнивать эту и следующую программы, а также из-за того, что настоящая задача не требует для своего решения оконного интер- фейса, в нижеприведенном тексте использованы только те вызовы API, которые не затрагивают графический интерфейс. Конечно, нынче редко какое приложе- ние не использует возможностей GUI, но в нашем случае зато сразу можно уви- деть разницу в организации параллельной работы запускаемых вычислений. #1nclude <windows.h> #1nclude <stdio.h> #1nclude <stdlib.h> // Название: processFile // Описание: исполняемый код треда // Входные параметры: IpFileName - имя файла для обработки // Выходные параметры: нет
Пример программирования в различных API ОС 217 // DWORD processFi 1 edPVOID IpFileName) { HANDLE handle, // описатель файла DWORD numRead, total = 0, char buf, // запрос к ОС на открытие файла (только для чтения) handle = CreateFi1е( (LPCTSTR)1pFi1 eName, GENERIC_READ. FILE_SHARE_READ. NULL. OPEN_EXISTING. FILE_ATTRIBUTE_NORMAL. NULL). // цикл чтения до конца файла do { // чтение одного символа из файла ReadFile( handle. (LPVOID) &buf 1. &numRead. NULL), if (buf == 0x20) total++ } while ( numRead > 0). fprintf( stderr. "(ThreadID Uu). File $s. spaces = £d\n" GetCurrentThreadId(). IpFileName total). // закрытие файла CloseHandle( handle). return(O). // Название main // Описание главная программа // Входные параметры список имен файлов для обработки // Выходные параметры нет // int main(int argc. char *argv[J) { int i. DWORD pid. HANDLE hThrd[255]. // массив ссылок на треды // для всех файлов, перечисленных в командной строке for (1 = 0. i< (argc-1). i++) { // запуск треда - обработка одного файла hThrdEi] = CreateThread( NULL. 0x4000. (LPTHREAD_START_ROUTINE) processFile. (LPVOID) argv[i+l], 0. &pid) fprintf( stdout. "processFile started (HND=£d)\n", hThrdEi]), } // ожидание окончания выполнения всех запущенных тредов
218 Глава 5. Архитектура операционных систем WaitForMultipleObjects( argc-1. hThrd, true, INFINITE): return(O): } Обратите внимание, что основная программа запускает треды и ждет окончания их выполнения. Текст программы для Linux (POSIX API) #include <sys/types.h> #1nclude <sys/stat.h> #1nclude <wait.h> #1nclude <fcntl.h> #1nclude <stdio.h> // Название: processFile // Описание: обработка файла, подсчет кол-ва пробелов // Входные параметры: fileName - имя файла для обработки // Выходные параметры: кол-во пробелов в файле // int processFile( char *fileName) { int handle, numRead, total - 0: char but: // запрос к ОС на открытие файла (только для чтения) handle - open( fileName, O_RDONLY): // цикл чтения до конца файла do { // чтение одного символа из файла numRead e read( handle, &buf, 1): if (buf »» 0x20) total++; } while (numRead > 0): // закрытие файла close( handle): return( total): } // Название: main // Описание: главная программа // Входные параметры: список имен файлов для обработки // Выходные параметры: нет // int main(int argc, char *argv[]) { int i, pid, status:
Контрольные вопросы и задачи 219 // для всех файлов, перечисленных в командной строке for (1 = 1; 1< argc; 1++) { // запускаем дочерний процесс pid = forkO: If (pid == 0) { // если выполняется дочерний процесс // вызов функции счета количества пробелов в файле printf( "(PID: Xd), File fcs, spaces = Sd\n", getpidO, argv[ 1], processF11e( argv[ 1])): // выход из процесса exitO: } // если выполняется родительский процесс else printfC "processFile started (pid-Sd)\n", pid); } // ожидание окончания выполнения всех запущенных процессов If (pid != 0) while (wait(&status)>0): return; } Из этого текста видно, что в этом случае все вычисления принимают статус про- цессов, а не тредов. В заключение можно заметить, что очень трудно сравнивать API. При их раз- работке создатели, как правило, стараются реализовать полный набор основных функций, используя которые можно решать различные задачи, хотя, порой, и раз- личными способами. Один набор будет хорош для одного набора задач, другой — для иного набора задач. Тем более что фактически у нас сейчас существенно огра- ниченное множество API. Причина в том, что доминируют наиболее распростра- ненные ОС, на распространение которых в большей степени оказали влияние не достоинства или недостатки этих ОС и их API, а правильная маркетинговая по- литика фирм, их создавших. Контрольные вопросы и задачи Вопросы для проверки 1. Перечислите и поясните основные принципы построения операционных систем. 2. Расскажите об основных моментах, характерных для микроядерных ОС. Ка- кие основные функции должно выполнять микроядро ОС? 3. Перечислите основные требования, предъявляемые к операционным системам реального времени. 4. Какие задачи возлагаются на интерфейс прикладного программирования (API)?
220 Глава 5. Архитектура операционных систем 5. Какими могут быть варианты реализации API? В чем заключаются достоин- ства и недостатки каждого варианта? 6. Что такое библиотека времени выполнения (RTL)? 7. Что такое POSIX? Какими преимуществами обладают программы, созданные с использованием только стандартных функций, предусмотренных POSIX?
ГЛАВА 6 Проектирование параллельных взаимодействующих вычислительных процессов При создании современных приложений, позволяющих использовать все возмож- ности операционных систем в плане организации параллельных и распределен- ных вычислений, одной из важнейших проблем является проблема синхрониза- ции взаимодействия параллельных вычислительных процессов, обмена между ними данными. Существующие методы синхронизации и обмена сообщениями различаются по таким параметрам, как удобство использования при программи- ровании параллельных процессов, стоимость реализации, эффективность функ- ционирования созданных приложений и всей вычислительной системы в целом. Операционные системы имеют в своем составе различные средства синхрониза- ции. Знание этих средств и их правильное использование позволяет создавать программы, которые при своей работе осуществляют корректный обмен инфор- мацией, а также исключают возможность возникновения тупиковых ситуаций. В настоящем разделе рассматриваются основные понятия и проблемы, характер- ные для параллельных процессов. Описываются основные механизмы синхрони- зации, дается их сравнительный анализ, приводятся примеры характерных про- грамм, использующих данные механизмы. Независимые и взаимодействующие вычислительные процессы Основной особенностью мультипрограммных операционных систем является то, что в их среде параллельно развивается несколько (последовательных) вычисли- тельных процессов. С точки зрения внешнего наблюдателя эти последователь-
222 Глава 6. Проектирование параллельных вычислительных процессов ные вычислительные процессы выполняются одновременно, мы будем использо- вать термин «параллельно». При этом под параллельными понимаются не только процессы, одновременно развивающиеся на различных процессорах, каналах и устройствах ввода/вывода, но и те последовательные процессы, которые разде- ляют центральный процессор и хотя бы частично перекрываются во времени. Любая мультипрограммная операционная система вместе с параллельно выпол- няющимися в ней задачами пользователей может быть логически описана как совокупность последовательных процессов, которые, с одной стороны, состяза- ются за использование ресурсов, переходя из одного состояния в другое, а с дру- гой — действуют почти независимо друг от друга, но образуют систему вследст- вие установления всевозможного рода связей между ними (путем пересылки сообщений и синхронизирующих сигналов). Итак, параллельными мы будем называть такие последовательные вычислитель- ные процессы, которые одновременно находятся в каком-либо активном состоя- нии. Два параллельных процесса могут быть независимыми (independing proces- ses) либо взаимодействующими (cooperating processes). Независимыми являются процессы, множества переменных которых не пересека- ются. Под переменными в этом случае понимают файлы данных, а также области оперативной памяти, сопоставленные определенным в программе и промежуточ- ным переменным. Независимые процессы не влияют на результаты работы друг друга, так как не могут изменить значения переменных другого независимого процесса. Они могут только явиться причиной задержек исполнения других про- цессов, так как вынуждены разделять ресурсы системы. Взаимодействующие процессы совместно используют некоторые (общие) пере- менные, и выполнение одного процесса может повлиять на выполнение другого. Как мы уже говорили, при выполнении вычислительные процессы разделяют ре- сурсы системы. Подчеркнем, что при рассмотрении вопросов синхронизации вы- числительных процессов из числа разделяемых ими ресурсов исключаются: цен- тральный процессор и программы, реализующие эти процессы; то есть с логиче- ской точки зрения каждому процессу соответствуют свои процессор и программа, хотя в реальных системах обычно несколько процессов разделяют один процес- сор и одну или несколько программ. Многие ресурсы вычислительной системы могут совместно использоваться несколькими процессами, но в каждый момент времени к разделяемому ресурсу может иметь доступ только один процесс. Ре- сурсы, которые не допускают одновременного использования несколькими про- цессами, называются критическими. Если нескольким вычислительным процессам необходимо пользоваться крити- ческим ресурсом в режиме разделения, им следует синхронизировать свои дей- ствия таким образом, чтобы ресурс всегда находился в распоряжении не более чем одного из процессов. Если один процесс пользуется в данный момент крити- ческим ресурсом, то все остальные процессы, которым нужен этот ресурс, долж- ны получить отказ и ждать, пока он не освободится. Если в операционной системе не предусмотрена защита от одновременного доступа процессов к критическим ресурсам, в ней могут возникать ошибки, которые трудно обнаружить и испра- вить. Основной причиной возникновения этих ошибок является то, что процес- сы в мультипрограммных операционных системах развиваются с различными
Независимые и взаимодействующие вычислительные процессы 223 скоростями, а относительные скорости развития каждого из взаимодействующих процессов не известны и не подвластны ни одному из них. Более того, на их ско- рости могут влиять решения планировщиков, касающиеся других процессов, с которыми ни одна из этих программ не взаимодействует. Кроме того, содер- жание одного процесса и скорость его исполнения обычно неизвестны другому процессу. Поэтому влияние, которое оказывают друг на друга взаимодействую- щие процессы, не всегда предсказуемо и воспроизводимо. Взаимодействовать могут либо конкурирующие процессы, либо процессы, совме- стно выполняющие общую работу. Конкурирующие процессы, на первый взгляд, действуют относительно независимо, но они имеют доступ к общим переменным. Процессы, выполняющие общую совместную работу таким образом, что резуль- таты вычислений одного процесса в явном виде передаются другому, то есть их работа построена именно на обмене данными, называются сотрудничающими. Взаимодействие сотрудничающих процессов удобно всего рассматривать в схеме «производитель — потребитель» (produces — consumer) или, как часто говорят — «поставщик — потребитель». В качестве первого примера рассмотрим работу двух процессов Р1 и Р2 с общей переменной X. Пусть оба процесса асинхронно, независимо один от другого, из- меняют (например, увеличивают) значение переменной X, считывая ее значение в локальную область памяти Rj1, при этом каждый процесс выполняет некоторые последовательности операций во времени (рис. 6.1). Здесь мы рассмотрим не все операторы каждого из процессов, а только те, в которых осуществляется работа с общей переменной X. Каждому из операторов мы присвоили некоторый услов- ный номер. № Процесс Р1 № Процесс Р2 оператора оператора 1 R1:=X 4 R2:=X 2 R1:=R1+X 5 R2:=R2+1 3 X:=R1 6 X:=R2 Рис. 6.1. Пример конкурирующих процессов Поскольку при мультипрограммировании процессы могут иметь различные ско- рости исполнения, то может иметь место любая последовательность выполнения операций во времени. Если сначала будут выполнены все операции процесса Р1, а уже потом — все операции процесса Р2 (или, наоборот, сначала операции 4-6, а затем — операции 1-3), то в итоге переменная X получит значение, равное Х+2 (рис. 6.2). Однако, если в промежуток времени между выполнением операций 1 и 3 будет выполнена хотя бы одна из операций 4-6 (рис. 6.3), то значение переменной X после выполнения всех операций будет не (Х+2), а (Х+1). 1 Rj — имя переменной для процесса с номером i.
224 Глава 6. Проектирование параллельных вычислительных процессов Р1: (1):=Х; (2) R1:=R1+1; (3)X:=R1; (4) R2:=X; (5) R2:=R+1; (6) X:=R2; Рис. 6.2. Первый вариант развития событий при выполнении процессов Р1: (1)R1:=X; (2) R1:=R1+1; (3)X:=R1; Р2: (4) R2:=X (5) R2:=R2+1; (6)X:=R2; ----------------------------------------------------► Рис. 6.3. Второй вариант развития событий при выполнении процессов Понятно, что это очень серьезная (и, к сожалению, неисправимая, так как ее нельзя проконтролировать) ошибка. Например, если бы процессы Р1 и Р2 осу- ществляли продажу билетов и переменная X фиксировала количество уже про- данных, то в результате некорректного взаимодействия было бы продано не- сколько билетов на одно и то же место. В качестве второго примера рассмотрим ситуацию, которая еще совсем недавно была достаточно актуальной для первых персональных компьютеров. Пусть на ПК с простейшей однопрограммной операционной системой (типа MS-DOS) ус- тановлена некоторая резидентная программа с условным названием TIME, кото- рая по нажатию на комбинацию клавиш (например, Ctrl+T) воспроизводит на экране дисплея время. Допустим, что значения переменных, указывающих час, минуты и секунды, равны 18:20:59, причем вывод на дисплей осуществляется справа налево (то есть в порядке: секунды, минуты, часы). Пусть сразу же после передачи программой TIME на дисплей информации «59 секунд» генерируется прерывание от таймера и значение времени обновляется: 18:21:00. После этого программа TIME, прерванная таймером, продолжит свое выполне- ние, и на дисплей будут выданы значения: минуты = 21, часы - 18. В итоге на эк- ране мы увидим: 18:21:59. Рассмотрим теперь несколько иной случай развития событий обновления значе- ний времени по сигналу таймера. Если программа ведения системных часов после вычислений количества секунд 59 + 1 = 60 и замены его на 00 прерывает- ся от нажатия клавиш Ctrl+T, то есть программа не успевает осуществить пе- ресчет количества минут, то время, индицируемое на дисплее, станет равным 18:20:00. И в этом случае мы получим неверное значение времени. Наконец, в качестве третьего примера приведем пару процессов, которые изме- няют различные поля записей служащих какого-либо предприятия [37]. Пусть процесс АДРЕС изменяет домашний адрес служащего, а процесс СТАТУС — его должность и зарплату. Пусть каждый процесс копирует всю запись СЛУЖАЩИЙ в свою рабочую область. Предположим, что каждый процесс должен обработать
Независимые и взаимодействующие вычислительные процессы 225 некоторую запись ИВАНОВ. Предположим также, что после того, как процесс АДРЕС скопировал запись ИВАНОВ в свою рабочую область, но до того, как он записал скорректированную запись обратно, процесс СТАТУС скопировал пер- воначальную запись ИВАНОВ в свою рабочую область. Изменения, выполнен- ные тем из процессов, который первым запишет скорректированную запись назад в файл СЛУЖАЩИЕ, будут утеряны и, возможно, никто не будет знать об этом. Чтобы предотвратить некорректное исполнение конкурирующих процессов вслед- ствие нерегламентированного доступа к разделяемым переменным, необходимо ввести механизм взаимного исключения, который не позволит двум процессам од- новременно обращаться к разделяемым переменным. Кроме реализации в операционной системе средств, организующих взаимное ис- ключение и тем самым регулирующих доступ процессов к критическим ресур- сам, в ней должны быть предусмотрены средства, синхронизирующие работу взаимодействующих процессов. Другими словами, процессы должны обращать- ся к неким средствам не только ради синхронизации с целью взаимного исклю- чения, но и чтобы обмениваться данными. Допустим, что «поставщик» — это процесс, который- отправляет порции инфор- мации (сообщения) другому процессу, имя которого «потребитель». Например, процесс пользователя, порождающий строки для вывода, может выступать как «поставщик», а процесс, который выводит эти строки на печать, — как «потреби- тель». Один из методов, применяемых при реализации передачи сообщений, со- стоит в том, что заводится пул1 свободных буферов, каждый из которых может содержать одно сообщение (длина сообщения может быть произвольной, но ог- раниченной). В этом случае между процессами «поставщик» и «потребитель» будем иметь очередь заполненных буферов, содержащих сообщения. Когда «поставщик» хо- чет послать очередное сообщение, он добавляет в конец этой очереди еще один буфер. «Потребитель», чтобы получить сообщение, забирает из очереди буфер, который стоит в ее начале. Такое решение, хотя и кажется тривиальным, требу- ет, чтобы «поставщик» и «потребитель» синхронизировали свои действия. На- пример, они должны следить за количеством свободных и заполненных буферов. «Поставщик» может передавать сообщения только до тех пор, пока имеются сво- бодные буферы. Аналогично, «потребитель» может получать сообщения только если очередь не пуста. Ясно, что для учета заполненных и свободных буферов нужны разделяемые переменные, поэтому для сотрудничающих процессов, как и для конкурирующих, тоже возникает необходимость во взаимном исключении. Таким образом, до окончания обращения одной задачи к общим переменным следует исключить возможность обращения к ним другой задачи. Эта ситуация и называется взаимным исключением. Другими словами, при организации раз- личного рода взаимодействующих процессов приходится организовывать взаим- ное исключение и решать проблему корректного доступа к общим переменным (критическим ресурсам). Те места в программах, в которых происходит обраще- 1 Пул — pool — это совокупность однородных, динамически распределяемых объектов, на- пример, блоков памяти одинаковой длины.
226 Глава 6. Проектирование параллельных вычислительных процессов ние к критическим ресурсам, называются критическими секциями или критиче- скими интервалами (Critical Section — CS). Решение этой проблемы заключает- ся в организации такого доступа к критическому ресурсу, когда только одному процессу разрешается входить в критическую секцию. Данная задача только на первый взгляд кажется простой, ибо критическая секция, вообще говоря, не яв- ляется последовательностью операторов программы, а является процессом, то есть последовательностью действий, которые выполняются этими операторами. Другими словами, несколько процессов, которые выполняются по одной и той же программе, могут выполнять критические интервалы, базирующиеся на од- ной и той же последовательности операторов программы. Когда какой-либо процесс находится в своем критическом интервале, другие процессы могут, конечно, продолжать свое исполнение, но без входа в их крити- ческие секции. Взаимное исключение необходимо только в том случае, когда процессы обращаются к разделяемым, общим данным. Если же они выполняют операции, которые не приводят к конфликтным ситуациям, они должны иметь возможность работать параллельно. Когда процесс выходит из своего критиче- ского интервала, то одному из остальных процессов, ожидающих входа в свои критические секции, должно быть разрешено продолжить работу (если в этот момент действительно есть процесс в состоянии ожидания входа в свой критиче- ский интервал). Обеспечение взаимоисключения является одной из ключевых проблем парал- лельного программирования. При этом можно перечислить следующие требова- ния к критическим секциям [37, 92]: □ в любой момент времени только один процесс должен находиться в своей кри- тической секции; □ ни один процесс не должен находиться в своей критической секции бесконеч- но долго; □ ни один процесс не должен ждать бесконечно долго входа в свой критический интервал. В частности: О никакой процесс, бесконечно долго находящийся вне своей критической секции (что допустимо), не должен задерживать выполнение других про- цессов, ожидающих входа в свои критические секции. Другими словами, процесс, работающий вне своей критической секции, не должен блокиро- вать критическую секцию другого процесса; О если два процесса хотят войти в свои критические интервалы, то принятие решения о том, кто первым войдет в критическую секцию, не должно от- кладываться бесконечно долго; □ если процесс, находящийся в своем критическом интервале, завершается либо естественным, либо аварийным путем, то режим взаимоисключения должен быть отменен, с тем чтобы другие процессы получили возможность входить в свои критические секции. Было предложено несколько способов решения этой проблемы — программные и аппаратные; частные, низкоуровневые и глобальные, высокоуровневые; преду- сматривающие свободное взаимодействие между процессами и требующие стро- гого соблюдения жестких протоколов.
Средства синхронизации и связи 227 Средства синхронизации и связи при проектировании взаимодействующих вычислительных процессов Все известные средства для решения проблемы взаимного исключения основа- ны на использовании специально введенных аппаратных возможностей, к кото- рым относятся блокировка памяти, специальные команды типа «проверка и ус- тановка» и управление системой прерываний, позволяющее организовать такие механизмы, как семафорные операции, мониторы, почтовые ящики и др. С по- мощью перечисленных средств можно разрабатывать взаимодействующие про- цессы, при исполнении которых будут корректно решаться все задачи, связан- ные с проблемой критических интервалов. Рассмотрим эти средства в порядке их появления, а значит, по мере их усложнения, перехода к функциям операци- онной системы и увеличения предоставляемых ими удобств для пользователя. При этом будем опираться на далеко не новую, но все же еще достаточно акту- альную работу Дейкстры1 [28]. Использование блокировки памяти при синхронизации параллельных процессов Все вычислительные машины и системы (в том числе и с многопортовыми бло- ками оперативной памяти) имеют такое средство для организации взаимного ис- ключения, как блокировка памяти. Это средство запрещает одновременное ис- полнение двух (и более) команд, которые обращаются к одной и той же ячейке памяти. Поскольку в некоторой ячейке памяти хранится значение разделяемой переменной, то получить доступ к ней может только один процесс, несмотря на возможное совмещение выполнения команд во времени на различных процессо- рах (или на одном процессоре, но с конвейерной организацией параллельного выполнения команд). Механизм блокировки памяти предотвращает одновременный доступ к разде- ляемой переменной, но не предотвращает чередование доступа. Таким образом, если критические интервалы исчерпываются одной командой обращения к па- мяти, данного средства может быть достаточно для непосредственной реализа- ции взаимного исключения. Если же критические секции требуют более одного обращения к памяти, то задача становится сложной, но алгоритмически разре- шимой. Рассмотрим различные попытки использования механизма блокировки памяти для организации взаимного исключения при выполнении критических интервалов и покажем некоторые важные моменты, пренебрежение которыми приводит к неприемлемым или даже ошибочным решениям. 1 На наш взгляд, эта работа Дейкстры очень полезна с методической точки зрения. Она по- зволяет понять наиболее тонкие моменты в этой проблематике.
228 Глава 6. Проектирование параллельных вычислительных процессов Возможные проблемы при организации взаимного исключения посредством использования только блокировки памяти Пусть имеются два (или более) циклических процесса, в которых есть абстракт- ные критические секции, то есть каждый из процессов состоит из двух частей: некоторого критического интервала и оставшейся части кода, в которой нет рабо- ты с общими (критическими) переменными. Пусть эти два процесса асинхронно разделяют во времени единственный процессор либо выполняются на отдель- ных процессорах, каждый из которых имеет доступ к некоторой общей области памяти, с которой и работают критические секции. Проиллюстрируем эту ситуа- цию с помощью рис. 6.4. Рис. 6.4. Модель взаимодействующих процессов Проблема кажется легко решаемой, если потребовать, чтобы процессы ПР1 и ПР2 входили в свои критические интервалы попеременно. Для этого одна общая пе- ременная может хранить указатель того, чья очередь войти в критическую сек- цию. Текст такого решения на языке, близком к Pascal, приведен в листинге 6.1. Листинг 6.1. Первый вариант попытки реализации взаимного исключения Var перекл : integer; Begin перекл := 1; {при перекл=1 в CS находится процесс ПР1 } Parbegin While true do Begin while перекл = 2 do begin end; CS1; { Критическая секция процесса ПР1 } перекл ;= 2; PR1; { оставшаяся часть процесса ПР1 } End And While true do
Средства синхронизации и связи 229 Begin while перекл = 1 do begin end CS2 { Критическая секция процесса ПР2 } перекл = 1 PR2 { оставшаяся часть процесса ПР2 } End Parend End Здесь и далее конструкция следующего типа parbegin Sil S12 SlNi and S21 S22 S2N2 and SKI SK2 SKNlk parend означает параллельность К описываемых последовательных процессов Конст- рукция из операторов Sil; S12; ; S1N1 выполняется оператор за оператором, о чем свидетельствует наличие точки с запятой между операторами. Конструкция while true do begin SI S2 SN end означает, что каждый процесс может выполняться неопределенное время. Конст- рукция типа begin end означает «пустой» оператор. Итак, решение, представленное в листинге 6.1, обеспечивает взаимное исклю- чение в работе критических интервалов Однако если бы часть программы PR1 была намного длиннее, чем программа PR2, или если бы процесс ПР1 был забло- кирован в секции PR1, или если бы процессор для ПР2 был с большим быстро- действием, то процесс ПР2 вскоре вынужден был бы ждать (и, может быть, чрез- вычайно долго) входа в свою критическую секцию CS2, хотя процесс ПР1 и был бы вне CS1. То есть при таком решении один процесс вне своей критической секции может помешать вхождению другого в свою критическую секцию. Попробуем устранить это блокирование с помощью использования двух общих переключателей, которые используются как флаги для указания, находится ли соответствующий процесс вне своей критической секции. Пусть с каждым из процессов ПР1 и ПР2 будет связана переменная, по смыслу являющаяся переключателем, которая принимает значение true, когда процесс находится в своем критическом интервале, и false — в противном случае. Преж- де чем войти в свой критический интервал, процесс проверяет значение пере- ключателя другого процесса. Если это значение равно true, процессу не разре- шается входить в свой критический интервал. В противном случае процесс «включает» свой собственный переключатель и входит в критический интервал. Этот алгоритм взаимного исключения представлен в листинге 6 2. Данный алгоритм не гарантирует полного выполнения условия нахождения толь- ко одного процесса внутри критического интервала. Отсутствие гарантий связа- но с различными, в общем случае, скоростями развития процессов. Поэтому,
230 Глава 6. Проектирование параллельных вычислительных процессов например, между проверкой значения переменной перекл2 процессом ПР1 и по- следующей установкой им значения переменной перекл1 параллельно выполняю- щийся процесс ПР2 может установить перекл2 в значение true, так как перекл1 еще не успел установиться в значение true. Отсюда следует, что оба процесса мо- гут войти одновременно в свои критические интервалы. Листинг 6.2. Второй вариант попытки реализации взаимного исключения Var перекл1,перекл2.: boolean; begin переклГ.-false: перекл2:-false; paгbegin while true do begin while перекл2 do begin end: перекл1:=true: CS1 (* Критический интервал процесса ПР1 *) перекл1:-false; PR1 (* процесс ПР1 после критического интервала *) end and while true do begin while перекл1 do begin end; nepeKn2;-true: (* Критический интервал процесса ПР2 *) перекл2:-false; (* процесс ПР2 после критического интервала *) end parend end. Следующий (третий) вариант решения этой задачи (листинг 6.3) усиливает взаимное исключение, так как в процессе ПР1 проверка значения переменной перекл2 выполняется после установки переменной перекл1 в значение true (анало- гично для ПР2). Листинг 6.3. Третий вариант попытки реализации взаимного исключения var перекл1, перекл2 : boolean; begin перекл1:-false: перекл2:-false; parbegin ПР1: while true do begin перекл!:=true;
Средства синхронизации и связи 231 while перекл2 do begin end, CS1 { Критический интервал процесса ПР1 } перекл1 =false PR1 { ПР1 после критического интервала } end and ПР2 while true do begin перекл2 “true, while перекл1 do begin end CS2 { Критический интервал процесса ПР2 } перекл2 “false, PR2 { ПР2 после критического интервала } end parend end Алгоритм, приведенный в листинге 6.3, также имеет свои недостатки. Действи- тельно, возможна ситуация, когда оба процесса одновременно установят свои пе- реключатели в значение true и войдут в бесконечный цикл. В этом случае будет нарушено требование отсутствия бесконечного ожидания входа в свой критиче- ский интервал. Предположив, что скорости исполнения процессов произвольны, мы получили такую последовательность событий, при которой процессы вообще перестанут нормально выполняться. Рассмотренные попытки решить проблему критических интервалов иллюстри- руют некоторые тонкости, лежащие в основе этой проблемы. Последний вариант решения задачи взаимного исключения, использующий только блокировку памяти, который мы рассмотрим, — это известный алгоритм, пред- ложенный математиком Деккером. Алгоритм Деккера Алгоритм Деккера основан на использовании трех переменных (листинг 6.4): перекл1, перекл2 и ОЧЕРЕДЬ. Пусть по-прежнему переменная перекл1=1гие тогда, ког- да процесс ПР1 хочет войти в свой критический интервал (для ПР2 аналогич- но), а значение переменной ОЧЕРЕДЬ указывает, чье сейчас право сделать попытку входа, при условии, что оба процесса хотят выполнить свои критические интер- валы. Листинг 6.4. Алгоритм Деккера label 1. 2 var перекл1, перекл2 boolean, ОЧЕРЕДЬ integer, Begin перекл1 “false перекл2 “false, ОЧЕРЕДЬ =1, продолжение &
232 Глава 6. Проектирование параллельных вычислительных процессов Листинг 6.4 (продолжение) parbegin while true do begin переклl:=true: 1: if перекл2Чгие then if ОЧЕРЕДЬ=1 then go to 1 else begin перекл1:«false; while 0ЧЕРЕДЬ=2 do begin end end else begin CS1 { Критический интервал ПР1 } ОЧЕРЕДЬ:=2: перекл1:«false end end and while true do begin перекл2:=1: 2: if nepewil-true then if 0ЧЕРЕДЬ=2 then go to 2 else begin nepeKn2:=false: while ОЧЕРЕДЬ=1 do begi n end end else begin CS2 { Критический интервал ПР2 } ОЧЕРЕДЬ:=1; перекл2:«false end end parend end. Если перекл2=1гие и nepeKnl=false, то выполняется критический интервал про- цесса ПР2 независимо от значения переменной ОЧЕРЕДЬ. Аналогично для случая nepeKn2=false и перекл1=4тие. Если же оба процесса хотят выполнить свои критические интервалы, то есть перекл2=1гие и переклМтие, то выполняется критический интервал того процесса, на который указывало значение переменной ОЧЕРЕДЬ, независимо от скоростей развития обоих процессов. Использование переменной ОЧЕРЕДЬ совместно с перекл1 и перекл2 в алгоритме Деккера позволяет гарантированно решать проблему кри- тических интервалов. Переменные перекл1 и перекл2 обеспечивают, что взаимное выполнение не может иметь места; переменная ОЧЕРЕДЬ гарантирует от взаимного блокирования. Взаимное блокирование невозможно, так как переменная не из- меняет своего значения во время выполнения программы принятия решения о том, кому же сейчас проходить свой критический интервал. Тем не менее реализация критических интервалов на основе описанного алго- ритма практически не используется из-за чрезмерной сложности, особенно в случаях, когда алгоритм Деккера обобщается с двух до W процессов.
Средства синхронизации и связи 233 Синхронизация процессов посредством операции «ПРОВЕРКА И УСТАНОВКА» Операция «ПРОВЕРКА И УСТАНОВКА» является, как и блокировка памяти, одним из аппаратных средств решения задачи критического интервала. Данная операция реализована на многих компьютерах. Так, в знаменитой IBM 360 (370) эта команда называлась TS (test and set). Команда TS является двухадресной (двухоперандной). Ее действие заключается в том, что процессор присваивает значение второго операнда первому, после чего второму операнду присваивается значение, равное единице. Команда TS является неделимой операцией, то есть между ее началом и концом не могут выполняться никакие другие команды. Чтобы использовать команду TS для решения проблемы критического интерва- ла, свяжем с ней переменную common, которая будет общей для всех процессов, использующих некоторый критический ресурс. Данная переменная будет при- нимать единичное значение, если какой-либо из взаимодействующих процессов находится в своем критическом интервале. С каждым процессом связана своя локальная переменная, которая принимает значение, равное единице, если дан- ный процесс хочет войти в свой критический интервал. Операция TS будет при- сваивать значение common локальной переменной и устанавливать common в единицу. Программа решения проблемы критического интервала на примере двух парал- лельных процессов приведена в листинге 6.5. Листинг 6.5. Взаимное исключение с помощью операции «ПРОВЕРКА И УСТАНОВКА» var common, local 1. local2 ; integer; begin common:=0; parbegin ПР1: while true do begin locall;=l; while local 1=1 do TSClocall. common); CS1: { Критический интервал процеса ПР1 } common;=0; PR1; { ПР1 после критического интервала } end and ПР2; while true do begin loca!2;=l; while loca!2=l do TS(local2. common); CS2; { Критический интервал процеса ПР2 } common:=0: PR2; { ПР2 после критического интервала } end parend end.
234 Глава 6. Проектирование параллельных вычислительных процессов Предположим, что первым захочет войти в свой критический интервал процесс ПР1. В этом случае значение local 1 сначала установится в единицу, а после цик- ла проверки с помощью команды TSClocall, common) — в ноль. При этом значение common станет равным единице. Процесс ПР1 войдет в свой критический интервал. После выполнения этого критического интервала common примет значение, равное нулю, что даст возможность второму процессу ПР2 войти в свой критический интервал. Безусловно, мы предполагаем, что в компьютере предусмотрена блокировка па- мяти, то есть операция common:=0 неделима. Команда «ПРОВЕРКА И УСТАНОВ- КА» значительно упрощает решение проблемы критических интервалов. Глав- ное свойство этой команды — ее неделимость. Основной недостаток использования операций типа «ПРОВЕРКА И УСТАНОВ- КА» состоит в следующем: находясь в цикле проверки переменной common, про- цессы впустую потребляют время центрального процессора и другие ресурсы. Действительно, если предположить, что произошло прерывание процесса ПР1 во время выполнения своего критического интервала в соответствии с некото- рой дисциплиной обслуживания и начал выполняться процесс ПР2, то он войдет в цикл проверки, впустую тратя процессорное время. В этом случае до тех пор, пока диспетчер супервизора не поставит на выполнение процесс ПР1 и не даст ему закончиться, процесс ПР2 не сможет войти в свой критический интервал. В микропроцессорах i80386 и старше, с которыми мы теперь сталкиваемся по- стоянно, есть специальные команды: BTC, BTS, BTR, которые как раз и являют- ся вариантами реализации команды типа «ПРОВЕРКА И УСТАНОВКА». Рас- смотрим одну из них — BTS. Команда BTS (bit test and reset — проверка бита и установка) является двухад- ресной [48]. По этой команде процессор сохраняет значение бита из первого опе- ранда со смещением, указанным вторым операндом, во флаге CF1 регистра фла- гов, а затем устанавливает указанный бит в 1. Значение индекса выбираемого бита может быть представлено постоянным непосредственным значением в ко- манде BTS или значением в общем регистре. В этой команде используется толь- ко 8-битное непосредственное значение. Значение этого операнда берется по мо- дулю 32, таким образом, смещение битов находится в диапазоне от 0 до 31. Это позволяет выбирать любой бит внутри регистра. Для битовых строк в памяти это поле непосредственного значения дает только смещение внутри слова или двойного слова. С учетом изложенного можно привести фрагмент текста, в котором используется данная команда для решения проблемы взаимного исключения (листинг 6.6). Однако здесь следует заметить, что некоторые ассемблеры поддерживают значе- ния битовых смещений больше 31, используя поле непосредственного значения в комбинации с полем смещения операнда в памяти. В этом случае ассемблером младшие 3 или 5 битов (3 — для 16-битных операндов, 5 — для 32-битных опе- рандов) смещения бита (второй операнд команды) сохраняются в поле непо- 1 CF (carry flag) — флаг переноса. Располагается в слове состояния программы. Для про- цессоров i80x86 этот регистр — управляющий регистр CR0.
Средства синхронизации и связи 235 средственного операнда, а старшие биты сдвигаются и комбинируются с полем смещения. Процессор же игнорирует ненулевые значения старших битов поля второго операнда [48]. При доступе к памяти процессор обращается к четырем байтам, начинающимся по полученному следующим образом адресу Effective Address + (4 * (BitOffset DIV 32)) для размера операнда 32 бита, или к двум байтам, начинающимся по адресу Effective Address + (2 * (BitOffset DIV 16)) для 16-битного размера операнда. Такое обращение происходит, даже если необ- ходим доступ только к одиночному байту. Поэтому избегайте ссылок к областям памяти, близким к «пустым» адресным пространствам. В частности, избегайте ссылок на распределенные в памяти регистры ввода/вывода. Вместо этого ис- пользуйте команду MOV для загрузки и сохранения значений по таким адресам и регистровую форму команды BTS для работы с данными. Листинг 6.6. Фрагмент программы на ассемблере с критическим интервалом L: BTC М.1 JC L критическая секция AND М. ОВ Несмотря на то что и алгоритм Деккера, основанный только на блокировке памяти, и операция «ПРОВЕРКА И УСТАНОВКА» пригодны для реализации взаимного исключения, оба эти приема очень неэффективны. Всякий раз, когда один из процессов выполняет свой критический интервал, любой другой про- цесс, который пытается войти в свою критическую секцию, попадает в цикл про- верки соответствующих переменных-флагов, регламентирующих доступ к кри- тическим переменным. При таком неопределенном пребывании в цикле, которое называется активным ожиданием, напрасно расходуется процессорное время, поскольку процесс имеет доступ к тем общим переменным, которые и определя- ют возможность или невозможность входа в критическую секцию. При этом процесс занимает ценное время центрального процессора, на самом деле ничего реально не выполняя. Как результат, мы имеем общее замедление вычислитель- ной системы процессами, которые реально не выполняют никакой полезной ра- боты. До тех пор, пока процесс, занимающий в данный момент критический ресурс, не решит его уступить, все другие процессы, ожидающие этого ресурса, могли бы вообще не конкурировать за процессорное время. Для этого их нужно перевести в состояние ожидания (заблокировать их выполнение). Когда вход в критиче- скую секцию снова будет свободен, можно будет опять перевести заблокирован-
236 Глава 6. Проектирование параллельных вычислительных процессов ный процесс в состояние готовности к выполнению и дать ему возможность по- лучить процессорное время. Самый простой способ предоставить процессорное время только одному вычислительному процессу —• это отключить систему пре- рываний, поскольку тогда никакое внешнее событие не сможет прервать выпол- няющийся процесс. Однако это, как мы уже знаем, приведет к тому, что система не сможет реагировать на внешние события. Вместо того чтобы связывать с каждым процессом свою собственную перемен- ную, как это было в рассмотренных нами решениях, можно со всем множеством конкурирующих критических секций связать одну переменную, которую и рас- сматривать как некоторый ключ. Вначале доступ к критической секции открыт. Однако перед входом в свой критический интервал процесс забирает «ключ» и тем самым блокирует другие процессы. Покидая критическую секцию, процесс открывает доступ, возвращая «ключ» на место. Если процесс, который хочет войти в свою критическую секцию, обнаруживает, что ключ «отсутствует», то он должен быть переведен в состояние блокирования до тех пор, пока процесс, имеющий ключ, не вернет его. Таким образом, каждый процесс, входящий в кри- тический интервал, должен вначале проверить, доступен ли ключ, и если это так, то сделать его недоступным для других процессов. Причем самым главным явля- ется то, что эти два действия должны быть неделимыми, чтобы два или более процессов не могли одновременно получить доступ к ключу. Более того, провер- ку того, можно ли войти в критический интервал, лучше всего выполнять не са- мим конкурирующим процессам, так как это приводит к активному ожиданию, а возложить эту функцию на операционную систему. Таким образом, мы подо- шли к одному из самых главных механизмов решения проблемы взаимного ис- ключения — семафорам Дейкстры. Семафорные примитивы Дейкстры Понятие семафорных механизмов было введено Дейкстрой [27]. Семафор — пе- ременная специального типа, которая доступна параллельным процессам для проведения над ней только двух операций: «закрытия» и «открытия», названных соответственно Р- и V-операциями. Эти операции являются примитивами отно- сительно семафора, который указывается в качестве параметра операций. Здесь семафор выполняет роль вспомогательного критического ресурса, так как опера- ции Р и V неделимы при своем выполнении и взаимно исключают друг друга. Семафорный механизм работает по схеме, в которой сначала исследуется со- стояние критического ресурса, идентифицируемое значением семафора, а затем уже осуществляется допуск к критическому ресурсу или отказ от него на некото- рое время. При отказе доступа к критическому ресурсу используется режим «пассивного ожидания». Поэтому в состав механизма включаются средства фор- мирования и обслуживания очереди ожидающих процессов. Эти средства реали- зуются супервизором операционной системы. Необходимо отметить, что в силу взаимного исключения примитивов попытка в различных параллельных процес- сах одновременно выполнить примитив над одним и тем же семафором приведет к тому, что она будет успешной только для одного процесса. Все остальные про- цессы будут взаимно исключены на время выполнения примитива.
Средства синхронизации и связи 237 Основным достоинством использования семафорных операций является отсут- ствие состояния «активного ожидания», что может существенно повысить эф- фективность работы мультипрограммной вычислительной системы. В настоящее время на практике используется много различных видов семафор- ных механизмов [75]. Варьируемыми параметрами, которые отличают различ- ные виды примитивов, являются начальное значение и диапазон изменения зна- чений семафора, логика действий семафорных операций, количество семафоров, доступных для обработки при исполнении отдельного примитива. Обобщенный смысл примитива P(S)1 состоит в проверке текущего значения се- мафора S, и если оно не меньше нуля, то осуществляется переход к следующей за примитивом операции. В противном случае процесс снимается на некоторое время с выполнения и переводится в состояние «пассивного ожидания». Нахо- дясь в списке заблокированных, ожидающий процесс не проверяет семафор не- прерывно, как в случае активного ожидания. Вместо него на процессоре может исполняться другой процесс, который реально совершает полезную работу. Операция V(S)2 связана с увеличением значения семафора на единицу и перево- дом одного или нескольких процессов в состояние готовности к центральному процессору. Отметим еще раз, что операции Р и V выполняются операционной системой в ответ на запрос, выданный некоторым процессом и содержащий имя семафора в качестве параметра. Рассмотрим первый вариант алгоритма работы семафорных операций, который представлен в листинге 6.7. Допустимыми значениями семафоров являются только целые числа. Двоичным семафором будем называть семафор, максимально возможное значение которого будет равно единице. В противном случае семафоры называют N-ичными. Есть реализации, в которых семафорные переменные не могут быть отрицательными, а есть и такие, где отрицательное значение указывает на длину очереди процес- сов, стоящих в состоянии ожидания открытия семафора. Листинг 6.7. Вариант реализации семафорных примитивов PCS): S:=S-1; if S<0 then { остановить процесс и поместить его в очередь ожидания к семафору S } V(s): if S<0 then {поместить один из ожидающих процессов очереди семафора S в очередь готовности}’: S:=S+1 Заметим, что для работы с семафорными переменными необходимо еще иметь операцию инициализации самого семафора, то есть операцию задания ему на- чального значения. Обычно эту операцию называют InitSem; как правило, она имеет два параметра — имя семафорной переменной и ее начальное значение; следовательно, обращение к ней имеет вид: InitSem (Имя_семафора, Начальное_значение_семафора): 1 Р — от голландского Proberen — проверить. 2 V — от голландского Verhogen — увеличить.
238 Глава 6. Проектирование параллельных вычислительных процессов Попытаемся на нашем примере двух конкурирующих процессов ПР1 и ПР2 про- анализировать использование данных семафорных примитивов для решения проблемы критического интервала. Программа, иллюстрирующая это решение, представлена в листинге 6.8. Листинг 6-8. Взаимное исключение с помощью семафорных операций var S:semafor; begin InitSem(S.l): parbegin ПР1: while true do begin P(S): CS1 { Критический интервал процесса ПР1 } V(S) end and ПР2: while true do begin P(S) ; CS2 { Критический интервал процесса ПР2 } V(S) end parend end. Семафор S имеет начальное значение, равное 1. Если процессы ПР1 и ПР2 попы- таются одновременно выполнить примитив P(S), то это удастся успешно сделать только одному из них. Предположим, это сделал процесс ПР2, тогда он закрыва- ет семафор S, после чего выполняется его критический интервал. Процесс ПР1 в рассматриваемой ситуации будет заблокирован на семафоре S. Тем самым будет гарантировано взаимное исключение. После выполнения примитива V(S) процессом ПР2 семафор S открывается, ука- зывая на возможность захвата каким-либо процессом освободившегося критиче- ского ресурса. При этом производится перевод процесса ПР1 из заблокирован- ного состояния в состояние готовности. На уровне реализации возможно одно из двух решений в отношении процессов, которые переводятся из очереди ожидания в очередь готовности при выполне- нии примитива V: □ процесс при его активизации (выборка из очереди готовности) вновь пытает- ся выполнить примитив Р, считая предыдущую попытку неуспешной; □ процесс при помещении его в очередь готовности отмечается как успешно выполнивший примитив Р. Тогда при его активизации управление будет пе- редано не на повторное выполнение примитива Р, а на команду, следующую за ним.
Средства синхронизации и связи 239 Рассмотрим первый способ реализации. Пусть процесс ПР2 в некоторый момент времени выполняет операцию PCS). Тогда семафор S становится равным нулю. Пусть далее процесс ПР1 пытается выполнить операцию PCS). Процесс ПР1 в этом случае «засыпает» на семафоре S, так как значение семафора S равнялось нулю, а теперь станет равным (-1). После выполнения критического интервала процесс ПР2 выполняет операцию V(S), при этом значение семафора S становит- ся равным нулю, а процесс ПР1 переводится в очередь готовности. Пусть через некоторое время процесс ПР1 будет активизирован, то есть выведен из состоя- ния ожидания, и сможет продолжить свое исполнение. Он повторно пытается выполнить операцию P(S), однако это ему не удается, так как S=0. Процесс ПР1 «засыпает» на семафоре, а его значение становится равным (-1). Если через не- которое время процесс ПР2 попытается выполнить P(S), то он тоже «уснет». Та- ким образом, возникнет так называемая тупиковая ситуация, так как «будить» процессы ПР1 и ПР2 некому. При втором способе реализации тупиковой ситуации не будет. Действительно, пусть все происходит также до момента окончания исполнения процессом ПР2 примитива VCS). Пусть примитив VCS) выполнен и S=0. Через некоторое время процесс ПР1 активизировался. Согласно данному способу реализации, он сразу же попадет в свой критический интервал. При этом никакой другой процесс не попадет в свой критический интервал, так как семафор остался закрытым. После исполнения своего критического интервала процесс ПР1 выполнит V(S). Если за время выполнения критического интервала процесса ПР1 процесс ПР2 не делал попыток выполнить операцию P(S), семафор S установится в единицу. В против- ном случае значение семафора будет не больше нуля. Но в любом варианте по- сле завершения операции VCS) процессом ПР1 доступ к критическому ресурсу со стороны процесса ПР2 будет разрешен. Заметим, что возникновение тупиков возможно в случае несогласованного выбо- ра механизма реактивации процессов из очереди, с одной стороны, и выбора ал- горитмов семафорных операций — с другой. Возможен другой алгоритм работы семафорных операций: PCS): if S>=1 then S:=S-1 else WAIT(S){остановить процесс и поместить в очередь ожидания к семафору S} V(S): if S<0 then RELEASE(S){поместить один из ожидающих процессов очереди семафора S в очередь готовности}; S:=S+1. Здесь вызов WAIT(S) означает, что супервизор ОС должен перевести задачу в со- стояние ожидания, причем очередь процессов связана с семафором S. Вызов RELEASE(S) означает обращение к диспетчеру задач с просьбой перевести первый из процессов, стоящих в очереди S, в состояние готовности к исполнению. Использование семафорных операций, выполненных подобным образом, позво- ляет решать проблему критических интервалов на основе первого способа реа- лизации без вероятности возникновения тупиков. Действительно, пусть ПР2 в некоторый момент времени выполнит операцию PCS). Тогда семафор S становит- ся равным нулю. Пусть далее процесс ПР1 пытается выполнить операцию PCS). Процесс ПР1 в этом случае «заснет» на семафоре S, так как S=0, причем значе-
240 Глава 6. Проектирование параллельных вычислительных процессов ние S не изменится. После выполнения своего критического интервала процесс ПР2 выполнит операцию V(S), при этом значение семафора S станет равным еди- нице, а процесс ПР1 переведется в очередь готовности. Если через некоторое время процесс ПР1 будет активизирован, он успешно выполнит PCS) и войдет в свой критический интервал. В однопроцессорной вычислительной системе неделимость Р- и V-операций мож- но обеспечить с помощью простого запрета прерываний. Сам же семафор S можно реализовать в виде записи с двумя полями (листинг 6.9). В одном поле хранится целое значение S, во втором — указатель на список процессов, заблокированных на семафоре S. Листинг 6.9. Реализация Р- и V-операций для однопроцессорной системы type Semaphore = record счетчик -.integer; указатель :pointer; end; var S Semaphore; procedure P ( var S : Semaphore); begin ЗАПРЕТИТЬ_ПРЕРЫВАНИЯ; $.счетчик:= S.счетчик-1; if S.счетчик < 0 then WAIT(S); { ВСТАВИТЬ ОБРАТИВШИЙСЯ ПРОЦЕСС В СПИСОК ПО S.указатель и УСТАНОВИТЬ НА ПРОЦЕССОР ГОТОВЫЙ К ВЫПОЛНЕНИЮ ПРОЦЕСС } РАЗРЕШИТЬ_ПРЕРЫВАНИЯ end; procedure V ( var S ; Semaphore); begin ЗАПРЕТИТЬ ПРЕРЫВАНИЯ; $.счетчик:= $.счетчик+1; if S.счетчик <- 0 then RELEASE (S); { ДЕБЛОКИРОВАТЬ ПЕРВЫЙ ПРОЦЕСС ИЗ СПИСКА ПО S.указатель } РАЗРЕШИТЬ-ПРЕРЫВАНИЯ end; procedure InitSem (var S : Semaphore); begin Б.счетчик;=1; $.указатель:=м1; end; Реализация семафоров в мультипроцессорных системах более сложна, чем в однопроцессорных. Одновременный доступ к семафору S двух процессов, вы- полняющихся на однопроцессорной вычислительной системе, предотвращается запретом прерываний. Однако этот механизм не подходит для мультипроцессор- ных систем, так как он не препятствует двум или более процессам одновременно обращаться к одному семафору. В силу того, что такой доступ должен реализо-
Средства синхронизации и связи 241 вываться как критический интервал, необходимо дополнительное аппаратное вза- имное исключение доступа для различных процессоров. Одним из решений яв- ляется использование уже знакомых нам неделимых команд типа «ПРОВЕРКА И УСТАНОВКА» (TS). Двухкомпонентный семафор в этом случае расширяет- ся включением третьего компонента — логического признака ВзаимоИскл (лис- тинг 6.10). Листинг 6.10. Реализация Р- и V-операций для мультипроцессорной системы type Semaphore = record счетчик : Integer: указатель : pointer: взаимоискл : boolean: end: var S : Semaphore: procedure InitSem (var S : Semaphore): begin With S do begin счетчик:=1: указатель:=nil: взаимоискл:«true: end: end: procedure P ( var S : Semaphore): var разрешено : boolean: begin ЗАПРЕТИТЬ-ПРЕРЫВАНИЯ: repeat Т$(разрешено, S.взаимоискл) until разрешено: S.счетчик:=S.счетчик-1: if S.счетчик < 0 then WAIT(S): { ВСТАВИТЬ ОБРАТИВШИЙСЯ ПРОЦЕСС В СПИСОК ПО S.указатель И УСТАНОВИТЬ НА CPU ГОТОВЫЙ ПРОЦЕСС } S.взаимоискл:=true; РАЗРЕШИТЬ_ПРЕРЫВАНИЯ end: procedure V ( var S : Semaphore ): var разрешено : boolean: begin ЗАПРЕТИТЬ-ПРЕРЫВАНИЯ: repeat ТБ(разрешено,$.взаимоискл) until разрешено: S.счетчик:=S.счетчик+1: if S.счетчик <= 0 then RELEASE(S); { ДЕБЛОКИРОВАТЬ ПЕРВЫЙ ПРОЦЕСС ИЗ СПИСКА ПО S.указатель } S.взаимоискл:=true: РАЗРЕШИТЬ_ПРЕРЫВАНИЯ: end:
242 Глава 6. Проектирование параллельных вычислительных процессов Обратите внимание, что в данном тексте команда типа «проверка и установка» — Т8(разрешено,5.взаимоискл) — работает не с целочисленными, а с булевыми значе- ниями. Практически это ничего не меняет, ибо текст программы и ее машинная (двоичная) реализация — это все-таки разные вещи. Мьютексы Одним из вариантов семафорных механизмов для организации взаимного ис- ключения являются так называемые мьютексы (mutex). Термин mutex произо- шел от английского словосочетания mutual exclusion semaphore, что дословно и переводится как семафор взаимного исключения. Мьютексы реализованы во многих ОС, их основное назначение — организация взаимного исключения для задач (потоков) из одного и того же или из разных процессов. Мьютексы — это простейшие двоичные семафоры, которые могут находиться в одном из двух состояний — отмеченном или неотмеченном (открыт и закрыт соответственно). Когда какая-либо задача, принадлежащая любому процессу, становится владель- цем объекта mutex, последний переводится в неотмеченное состояние. Если за- дача освобождает мьютекс, его состояние становится отмеченным. Организация последовательного (а не параллельного) доступа к ресурсам с ис- пользованием мьютексов становится несложной, поскольку в каждый конкрет- ный момент только одна задача может владеть этим объектом. Для того чтобы объект mutex стал доступен задачам (потокам), принадлежащим разным про- цессам, при создании ему необходимо присвоить имя. Потом это имя нужно пе- редать «по наследству» задачам, которые должны его использовать для взаимо- действия. Для этого вводятся специальные системные вызовы (CreateMutex), в которых указываются начальное значение мьютекса, его имя и, возможно, атрибуты защиты. Если начальное значение мьютекса равно true, то считается, что задача, создающая этот объект, будет им сразу владеть. Можно указать в ка- честве начального значение false — в этом случае мьютекс не принадлежит ни од- ной из задач и только специальным обращением к нему можно изменить его со- стояние. Для работы с мьютексом имеется несколько функций. Помимо уже упомянутой функции создания такого объекта (CreateMutex), есть функции открытия (Open- Mutex), ожидания событий (WaitForSingleObject и WaitForMultipleObjects) и, наконец, освобождение этого объекта (ReleaseMutex). Конкретные обращения к этим функциям и перечни передаваемых и получае- мых параметров нужно смотреть в документации на соответствующую ОС. Использование семафоров при проектировании взаимодействующих вычислительных процессов Семафорные примитивы чрезвычайно широко используются при проектирова- нии разнообразных вычислительных процессов. При этом некоторые задачи яв- ляются настолько «типичными», что их детальное рассмотрение уже стало клас- сическим в соответствующих учебных пособиях. Не будем делать исключений и мы.
Средства синхронизации и связи 243 Задача «поставщик — потребитель» Решение задачи «поставщик — потребитель» является характерным примером использования семафорных операций. Содержательная постановка этой задачи уже была нами описана в первом разделе данной главы. Разделяемыми перемен- ными здесь являются счетчики свободных и занятых буферов, которые должны быть защищены со стороны обоих процессов, то есть действия по посылке и по- лучению сообщений должны быть синхронизированы. Использование семафоров для решения данной задачи приведено в листинге 6.11. Листинг 6.11- Решение задачи «поставщик — потребитель» var 8_свободно.8_заполнено.8_взаимоискл : semaphore; begin In1tSem(S_CBo6oflHO,N); 1п1Ь8ет(8_заполнено,0): 1пП8ет(8_взаимоискл,1); parbegin ПОСТАВЩИК: while true do begin { приготовить сообщение } Р(8_свободно); Р(8_взаимоискл); { послать сообщение } \/(8_заполнено); \/(8_взаимоискл); end and ПОТРЕБИТЕЛЬ: while true do begin Р(8_заполнено); Р(8_взаимоискл): { получить сообщение } V(8_свободно): 7(8_взаимоискл); { обработать сообщение } end parend end. Здесь переменные 8_свободно, 8_заполнено являются числовыми семафорами, 8_взаимоискл — двоичный семафор. 8_свободно имеет начальное значение, рав- ное N, где N — количество буферов, с помощью которых процессы связываются. Предполагается, что в начальный момент количество свободных буферов рав- но N; соответственно, количество занятых равно нулю. Двоичный семафор Б_взаи- моискл гарантирует, что в каждый момент только один процесс сможет работать с критическим ресурсом, выполняя свой критический интервал. Семафоры 8_сво- бодно и 8_заполнено используются как счетчики свободных и заполненных буфе- ров.
244 Глава 6. Проектирование параллельных вычислительных процессов Действительно, перед посылкой сообщения «поставщик» уменьшает значение 8_свободно на единицу в результате выполнения Р(8_свободно), а после посылки сообщения увеличивает значение 8_заполнено на единицу в результате выпол- нения 7(8_заполнено). Аналогично, перед получением сообщения «потребитель» уменьшает значение S_3 а полнено в результате выполнения Р(8_заполнено), а после получения сообщения увеличивает значение 8_свободно в результате выполнения \/(8_свободно). Семафоры 8_заполнено, 8_свободно могут также использоваться для блокировки соответствующих процессов. Если пул буферов оказался пустым и к нему первым обратится процесс «потребитель», он заблокируется на семафоре 8_заполнено в результате выполнения Р(8_заполнено). Если пул буферов заполнен и к нему в этот момент обратится процесс «поставщик», то он будет заблокиро- ван на семафоре 8_свободно в результате выполнения Р(8_свободно). В решении задачи о «поставщике» и «потребителе» общие семафоры применены для учета свободных и заполненных буферов. Их можно также применить и для распределения иных ресурсов. Пример простейшей синхронизации взаимодействующих процессов Можно использовать семафорные операции для решения таких задач, в которых успешное завершение одного процесса связано с ожиданием завершения друго- го. Предположим что существуют два процесса ПР1 и ПР2. Необходимо, чтобы процесс ПР1 запускал процесс ПР2 с ожиданием его выполнения, то есть ПР1 не продолжал бы выполняться до тех пор, пока процесс ПР2 до конца не выполнит свою работу. Программа, реализующая такое взаимодействие, представлена в листинге 6.12. Листинг 6.12. Пример синхронизации процессов var S : Semaphore; begin InltSem(S.O); ПР1: begin ПРИ: { первая часть ПР1 } ON ( ПР2 ): { поставить на выполнение ПР2 } P(S); ПР12; { оставшаяся часть ПР1 } STOP end; ПР2; begin ПР2; { вся работа программы ПР2 } V(S); STOP end end Начальное значение семафора 8 равно нулю. Если процесс ПР1 начал выпол- няться первым, то через некоторое время он поставит на выполнение процесс
Средства синхронизации и связи 245 ПР2, после чего выполнит операцию Р(S) и «заснет» на семафоре, перейдя в со- стояние пассивного ожидания. Процесс ПР2, осуществив все необходимые дей- ствия, выполнит примитив V(S) и откроет семафор, после чего процесс ПР1 бу- дет готов к дальнейшему выполнению. Решение задачи «читатели — писатели» Другой важной и часто встречающейся задачей, решение которой также требует синхронизации, является задача «читатели — писатели». Эта задача имеет много вариантов. Наиболее характерная область использования этой задачи — при по- строении систем управления файлами. Два класса процессов имеют доступ к не- которому ресурсу (области памяти, файлам). «Читатели» — это процессы, ко- торые могут параллельно считывать информацию из некоторой общей области памяти, являющейся критическим ресурсом. «Писатели» — это процессы, запи- сывающие информацию в эту область памяти, исключая при этом и друг друга, и процессы «читатели». Имеются различные варианты взаимодействия между «писателями» и «читателями». Наиболее широко распространены следующие условия. Устанавливается приоритет в использование критического ресурса процессам «читатели». Это означает, что если хотя бы один «читатель» пользуется ресур- сом, то он закрыт для использования всем «писателям» и доступен для исполь- зования всем «читателям». Во втором варианте, наоборот, больший приоритет у процессов «писатели». При появлении запроса от «писателя» необходимо за- крыть дальнейший доступ всем тем процессам «читателям», которые выдадут за- прос на критический ресурс после него. Другим типичным примером для задачи «читатели — писатели» помимо систем управления файлами может служить система автоматизированной продажи би- летов. Процессы «читатели» обеспечивают нас справочной информацией о нали- чии свободных билетов на тот или иной рейс. Процессы «писатели» запускаются с пульта кассира, когда он оформляет для нас тот или иной билет. Имеется боль- шое количество как «читателей», так и «писателей». Пример программы, реализующей решение данной задачи в первой постановке, представлен в листинге 6.13. Процессы «читатели» и «писатели» описаны в виде соответствующих процедур. Листинг 6.13. Решение задачи «читатели — писатели» с приоритетом в доступе к критическому ресурсу «читателей» varR, W : semaphore; NR : Integer; procedure ЧИТАТЕЛЬ; begin P(R); Inc(NR); { NR:=NR +1 } if NR = 1 then P(W); V(R); Read_Data; { критический интервал } продолжением
246 Глава 6. Проектирование параллельных вычислительных процессов Листинг 6.13 (продолжение) P(R); Dec(NR); if NR = О then V(W): V(R) end; procedure ПИСАТЕЛЬ; begin P(W); Write_Data; { критический интервал } V(W) end begin NR;=O: InitSem(S.l); InitSem(W.l); parbegin while true do ЧИТАТЕЛЬ and while true do ЧИТАТЕЛЬ and while true do ЧИТАТЕЛЬ and while true do ПИСАТЕЛЬ and while true do ПИСАТЕЛЬ and while true do ПИСАТЕЛЬ parend end. При решении данной задачи используются два семафора R и W и переменная NR, предназначенная для подсчета текущего числа процессов типа «читатели», нахо- дящихся в критическом интервале. Доступ к разделяемой области памяти осу- ществляется через семафор W. Семафор R используется для взаимоисключения процессов типа «читатели». Если критический ресурс не используется, то первый появившийся процесс при входе в критический интервал выполнит операцию Р(W) и закроет семафор. Если процесс является «читателем», то переменная NR будет увеличена на единицу и последующие «читатели» будут обращаться к ресурсу, не проверяя значение семафора W, что обеспечивает параллельность их доступа к памяти. Последний «читатель», покидающий критический интервал, является единственным, кто выполнит операцию V(W) и откроет семафор W. Семафор R предохраняет от некор-
Средства синхронизации и связи 247 ректного изменения значения NR, а также от выполнения «читателями» операций Р(W) и V(W). Если в критическом интервале находится «писатель», то на семафоре W может быть заблокирован только один «читатель», все остальные будут блоки- роваться на семафоре R. Другие «писатели» блокируются на семафоре W. Когда «писатель» выполняет операцию V(W), неясно, какого типа процесс войдет в критический интервал. Чтобы гарантировать получение процессами «читате- лями» наиболее свежей информации, необходимо при постановке в очередь го- товности использовать дисциплину обслуживания, учитывающую более высо- кий приоритет «писателей». Однако этого оказывается недостаточно, ибо если в критическом интервале продолжает находиться, по крайней мере, один «чита- тель», то он не даст обновить данные, но и не воспрепятствует вновь приходя- щим процессам «читателям» войти свою критическую секцию. Необходим до- полнительный семафор. Пример правильного решения этой задачи приведен в листинге 6.14. Листинг 6.14. Решение задачи «читатели — писатели» с приоритетом в доступе к критическому ресурсу первых с дополнительным семафором var S. W R semaphore. NR integer procedure ЧИТАТЕЛЬ, begin P(S) P(R) Inc(NR) if NR = 1 then P(W). V(S). V(R). Read_Data { критический интервал } P(R). Dec(NR). if NR = 0 then V(W). V(R) end. procedure ПИСАТЕЛЬ, begin P(S) P(W) Write_Data { критический интервал } V(S). V(W) end begin NR =0 ImtSem(S.l). ImtSemCW 1) ImtSemCR 1). parbegin while true do ЧИТАТЕЛЬ and while true do ЧИТАТЕЛЬ продолжение &
248 Глава 6. Проектирование параллельных вычислительных процессов Листинг 6.14 (продолжение) and while true do ЧИТАТЕЛЬ and while true do ПИСАТЕЛЬ and while true do ПИСАТЕЛЬ and while true do ПИСАТЕЛЬ parend end. Как можно заметить, семафор S блокирует приход новых «читателей», если по- явился хотя бы один процесс «писатель». Обратите внимание, что в процедуре «читатель» использование семафора S имеет место только при входе в критиче- ский интервал. После выполнения чтения уже категорически нельзя использо- вать этот семафор, ибо он тут же заблокирует первого же «читателя», если хотя бы один «писатель» захотел войти в свою критическую секцию. И получится так называемая тупиковая ситуация, ибо «писатель» не сможет войти в критиче- скую секцию, поскольку в ней уже находится процесс «читатель». А «читатель» не сможет покинуть критическую секцию, потому что процесс «писатель» жела- ет войти в свой критический интервал. Обычно программы, решающие проблему «читатели — писатели», используют как семафоры, так и мониторные схемы с взаимным исключением, то есть такие, которые блокируют доступ к критическим ресурсам для всех остальных процес- сов, если один из них модифицирует значения общих переменных. Взаимное ис- ключение требует, чтобы «писатель» ждал завершения всех текущих операций чтения. При условии, что «писатель» имеет более высокий приоритет, чем «чи- татель», такое ожидание в ряде случаев весьма нежелательно. Кроме того, реали- зация принципа исключения в многопроцессорных системах может вызвать определенную избыточность. Поэтому ниже приводится схема, применяемая иногда для решения задачи «читатели — писатели», которая в случае одного «писателя» допускает одновременное выполнение операций чтения и записи (листинг 6.15). После чтения данных процесс «читатель» проверяет, мог ли он получить неправильное значение, некорректные данные (вследствие того, что параллельно с ним процесс «писатель» мог их изменить), и если обнаруживает, что это именно так, то операция чтения повторяется. Листинг 6.15. Синхронизация процессов «читатели» и «писатель» без взаимного исключения Var VI. V2 : Integer; Procedure ПИСАТЕЛЬ; Begin
Средства синхронизации и связи 249 Inc(Vl): Write_Data; V2:=V1 End: Procedure ЧИТАТЕЛЬ: Var V: Integer Begin Repeat V:= V2: Read_Data Until VI = V End: Begin VI := 0: V2 :» 0: Parbegin while true do ЧИТАТЕЛЬ and while true do ЧИТАТЕЛЬ and while true do ЧИТАТЕЛЬ and while true do ПИСАТЕЛЬ parend end. Этот алгоритм использует для данных два номера версий, которым соответству- ют переменные VI и V2. Перед записью порции новых данных процесс «писатель» увеличивает на 1 значение переменной VI, а после записи — переменную V2. «Чи- татель» обращается к V2 перед чтением данных, а к VI —• после. Если при этом VI и V2 равны, то очевидно, что получена правильная версия данных. Если же дан- ные обновлялись за время чтения, то операция повторяется. Данный алгоритм может быть использован в случае, если нежелательно заставлять процесс «писа- тель» ждать, пока «читатели» закончат операцию чтения, или если вероятность повторения операции чтения достаточно мала и обусловленное повторными опе- рациями снижение эффективности системы меньше потерь, связанных с избы- точностью решения при использовании взаимного исключения. Однако необхо- димо иметь в виду ненулевую вероятность зацикливания чтения при высокой интенсивности операций записи. Наконец, если само чтение представляет собой достаточно длительную операцию, то оператор V:=V2 для процесса «читатель» может быть заменен на оператор Repeat V:= V2 Until VI = V Это предотвратит выполнение «читателем» операции чтение, если «писатель» уже начал запись.
250 Глава 6. Проектирование параллельных вычислительных процессов Мониторы Хоара Анализ рассмотренных задач показывает, что, несмотря на очевидные достоинст- ва (простота, независимость от количества процессов, отсутствие «активного ожидания»), семафорные механизмы имеют и ряд недостатков. Семафорные ме- ханизмы являются слишком примитивными, так как семафор не указывает не- посредственно на синхронизирующее условие, с которым он связан, или на критический ресурс. Поэтому при построении сложных схем синхронизации ал- горитмы решения задач порой получаются весьма непростыми, ненаглядными и затруднительными для доказательства их правильности. Необходимо иметь понятные, очевидные решения, которые позволят приклад- ным программистам без лишних усилий, связанных с доказательством правиль- ности алгоритмов и отслеживанием большого числа взаимосвязанных объектов, создавать параллельные взаимодействующие программы. К таким решениям мож- но отнести так называемые мониторы, предложенные Хоаром [31]. В параллельном программировании монитор — это пассивный набор разделяе- мых переменных и повторно входимых процедур доступа к ним, которым про- цессы пользуются в режиме разделения, причем в каждый момент им может пользоваться только один процесс. Рассмотрим, например, некоторый ресурс, который разделяется между процесса- ми каким-либо планировщиком [37]. Каждый раз, когда процесс желает полу- чить в свое распоряжение какие-то ресурсы, он должен обратиться к программе- планировщику. Этот планировщик должен иметь переменные, с помощью которых он отслеживает, занят ресурс или свободен. Процедуру планировщика разделя- ют все процессы, и каждый процесс может в любой момент захотеть обратиться к планировщику. Но планировщик не в состоянии обслуживать более одного про- цесса одновременно. Такая процедура-планировщик и представляет собой при- мер монитора. Таким образом, монитор — это механизм организации параллелизма, который содержит как данные, так и процедуры, необходимые для реализации динамиче- ского распределения конкретного общего ресурса или группы общих ресурсов. Процесс, желающий получить доступ к разделяемым переменным, должен обра- титься к монитору, который либо предоставит доступ, либо откажет в нем. Необ- ходимость входа в монитор с обращением к какой-либо его процедуре (напри- мер, с запросом на выделение требуемого ресурса) может возникать у многих процессов. Однако вход в монитор находится под жестким контролем — здесь осуществляется взаимоисключение процессов, так что в каждый момент време- ни только одному процессу разрешается войти в монитор. Процессам, которые хотят войти в монитор, когда он уже занят, приходится ждать, причем режимом ожидания автоматически управляет сам монитор. При отказе в доступе монитор блокирует обратившийся к нему процесс и определяет условие, по которому процесс ждет. Проверка условия выполняется самим монитором, который и де- блокирует ожидающий процесс. Поскольку механизм монитора гарантирует взаимоисключение процессов, отсутствуют серьезные проблемы, связанные с ор- ганизацией параллельных взаимодействующих процессов.
Мониторы Хоара 251 Внутренние данные монитора могут быть либо глобальными (относящимися ко всем процедурам монитора), либо локальными (относящимися только к одной конкретной процедуре). Ко всем этим данным можно обращаться только изнут- ри монитора; процессы, находящиеся вне монитора и, по существу, только вызы- вающие его процедуры, просто не могут получить доступ к данным монитора. При первом обращении монитор присваивает своим переменным начальные зна- чения. При каждом последующем обращении используются те значения пере- менных, которые сохранились от предыдущего обращения. Если процесс обращается к некоторой процедуре монитора и обнаруживается, что соответствующий ресурс уже занят, эта процедура монитора выдает команду ожидания WAIT с указанием условия ожидания. Процесс мог бы оставаться внут- ри монитора, однако это противоречит принципу взаимоисключения, если в мо- нитор затем вошел бы другой процесс. Поэтому процесс, переводящийся в ре- жим ожидания, должен вне монитора ждать того момента, когда необходимый ему ресурс освободится. Со временем процесс, который занимал данный ресурс, обратится к монитору, чтобы возвратить ресурс системе. Соответствующая процедура монитора при этом может просто принять уведомление о возвращении ресурса, а затем ждать, пока не поступит запрос от другого процесса, которому потребуется этот ресурс. Од- нако может оказаться, что уже имеются процессы, ожидающие освобождения данного ресурса. В этом случае монитор выполняет команду извещения (сигна- лизации) SIGNAL, чтобы один из ожидающих процессов мог получить данный ре- сурс и покинуть монитор. Если процесс сигнализирует о возвращении (иногда называемом освобождением) ресурса и в это время нет процессов, ожидающих данного ресурса, то подобное оповещение не вызывает никаких других последст- вий кроме того, что монитор, естественно, вновь вносит ресурс в список свобод- ных. Очевидно, что процесс, ожидающий освобождения некоторого ресурса, дол- жен находиться вне монитора, чтобы другой процесс имел возможность войти в монитор и возвратить ему этот ресурс. Чтобы гарантировать, что процесс, находящийся в ожидании некоторого ресур- са, со временем получит этот ресурс, делается так, что ожидающий процесс име- ет более высокий приоритет, чем новый процесс, пытающийся войти в мони- тор. В противном случае новый процесс мог бы перехватить ожидаемый ресурс до того, как ожидающий процесс вновь войдет в монитор. Если допустить много- кратное повторение подобной нежелательной ситуации, то ожидающий процесс мог бы откладываться бесконечно. Однако для систем реального времени можно допустить использование дисциплины обслуживания на основе абсолютных или динамически изменяемых приоритетов. В листинге 6.16 в качестве примера приведен простейший монитор для выделе- ния одного ресурса. Листинг 6.16. Пример монитора Хоара monitor Resourse; condition free: { условие - свободный } var busy : boolean: { busy - занят } Продолжением
252 Глава 6. Проектирование параллельных вычислительных процессов Листинг 6.16 (продолжение) procedure REQUEST; { запрос } begin if busy then WAIT ( free ): busy:=true; TakeOff; { ВЫДАТЬ РЕСУРС } end; procedure RELEASE; begin TakeOn; { ВЗЯТЬ РЕСУРС } busy:“false; SIGNAL ( free ) end; begin busy:“false; end Единственный ресурс динамически запрашивается и освобождается процессами, которые обращаются к процедурам REQUEST (запрос) и RELEASE (освободить). Если процесс обращается к процедуре REQUEST в тот момент, когда ресурс использует- ся, значение переменной busy (занято) будет равно true, и процедура REQUEST выполнит операцию монитора WAIT (free). Эта операция блокирует не процедуру REQUEST, а обратившийся к ней процесс. Процесс помещается в конец очереди процессов, ожидающих, пока не будет выполнено условие free (свободный). Когда процесс, использующий ресурс, обращается к процедуре RELEASE, операция монитора SIGNAL деблокирует процесс, находящийся в начале очереди, не позво- ляя исполняться никакой другой процедуре внутри того же монитора. Этот деблокированный процесс готов возобновить исполнение процедуры REQUEST сра- зу же после операции WAIT(free), которая его и блокировала. Если операция SIGNAL(free) выполняется в то время, когда нет процесса, ожидающего условия free, то никакие действия не выполняются. Использование монитора в качестве основного средства синхронизации и связи освобождает процессы от необходимости явно разделять между собой информа- цию. Напротив, доступ к разделяемым переменным всегда ограничен телом мо- нитора, и поскольку мониторы входят в состав ядра операционной системы, то разделяемые переменные становятся системными переменными. Это автомати- чески исключает критические интервалы (так как в каждый момент монитором может пользоваться только один процесс, то два процесса никогда не могут по- лучить доступ к разделяемым переменным одновременно). Монитор является пассивным объектом в том смысле, что это не процесс; его процедуры выполняются только по требованию процесса. Хотя по сравнению с семафорами мониторы не представляют собой существен- но более мощного инструмента для организации параллельных взаимодействую- щих вычислительных процессов, у них есть некоторые преимущества перед более примитивными синхронизирующими средствами. Во-первых, мониторы
Почтовые ящики 253 очень гибки. В форме мониторов можно реализовать не только семафоры, но и многие другие синхронизирующие операции. Например, разобранный во втором разделе данной главы механизм решения задачи «поставщик — потребитель» легко запрограммировать в виде монитора. Во-вторых, локализация всех раз- деляемых переменных внутри тела монитора позволяет избавиться от малопо- нятных конструкций в синхронизируемых процессах. Сложные взаимодействия процессов можно синхронизировать наглядным образом. В-третьих, мониторы дают процессам возможность совместно использовать программные модули, представляющие собой критические секции. Если несколько процессов совмест- но используют ресурс и работают с ним совершенно одинаково, то в мониторе нужна только одна процедура, тогда как решение с семафорами требует, чтобы в каждом процессе имелся собственный экземпляр критической секции. Таким образом, мониторы обеспечивают по сравнению с семафорами значительное уп- рощение организации взаимодействующих вычислительных процессов и боль- шую наглядность при лишь незначительной потере в эффективности. Почтовые ящики Тесное взаимодействие между процессами предполагает не только синхрониза- цию — обмен временными сигналами, но и передачу, и получение произвольных данных — обмен сообщениями. В системе с одним процессором посылающий и получающий процессы не могут работать одновременно. В мультипроцессор- ных системах также нет никакой гарантии их одновременного исполнения. Сле- довательно, для хранения посланного, но еще не полученного сообщения необхо- димо место. Оно называется буфером сообщений или почтовым ящиком1. Если процесс Р1 хочет общаться с процессом Р2, то Р1 просит систему образо- вать или предоставить ему почтовый ящик, который свяжет эти два процесса так, чтобы они могли передавать друг другу сообщения. Для того чтобы послать процессу Р2 какое-то сообщение, процесс Р1 просто помещает это сообщение в почтовый ящик, откуда процесс Р2 может его в любое время взять. При приме- нении почтового ящика процесс Р2 в конце концов обязательно получит сообще- ние, когда обратится за ним, если вообще обратится. Естественно, что процесс Р2 должен знать о существовании почтового ящика. Поскольку в системе может быть много почтовых ящиков, необходимо обеспечить доступ процессу к кон- кретному почтовому ящику. Почтовые ящики являются системными объектами, и для пользования таким объектом необходимо получить его у операционной системы, что осуществляется с помощью соответствующих запросов. Если объем передаваемых данных велик, то эффективнее не передавать их непо- средственно, а отправлять в почтовый ящик сообщение, информирующее про- цесс-получатель о том, где можно их найти. Почтовый ящик может быть связан с парой процессов, только с отправителем, только с получателем, или его можно получить из множества почтовых ящиков, которые используют все или несколько процессов. Почтовый ящик, связанный с процессом-получателем, облегчает посылку сообщений от нескольких процес- 1 Название «почтовый ящик» происходит от обычного приспособления для отправки почты.
254 Глава 6. Проектирование параллельных вычислительных процессов сов в фиксированный пункт назначения. Если почтовый ящик не связан жестко с процессами, то сообщение должно содержать идентификаторы и процесса-от- правителя, и процесса-получателя. Итак, почтовый ящик — это информационная структура, поддерживаемая опера- ционной системой. Она состоит из головного элемента, в котором находится информация о данном почтовом ящике, и из нескольких буферов (гнезд), в кото- рые помещают сообщения. Размер каждого буфера и их количество обычно зада- ются при образовании почтового ящика. Правила работы почтового ящика могут быть различными в зависимости от его сложности [37]. В простейшем случае сообщения передаются только в одном на- правлении. Процесс Р1 может посылать сообщения до тех пор, пока имеются свободные гнезда. Если все гнезда заполнены, то Р1 может либо ждать, либо заняться другими делами и попытаться послать сообщение позже. Аналогично процесс Р2 может получать сообщения до тех пор, пока имеются заполненные гнезда. Если сообщений нет, то он может либо ждать сообщений, либо продол- жать свою работу. Эту простую схему работы почтового ящика можно услож- нять в нескольких направлениях и получать более хитроумные системы обще- ния — двунаправленные и многовходовые почтовые ящики. Двунаправленный почтовый ящик, связанный с парой процессов, позволяет под- тверждать прием сообщений. Если используется множество гнезд, то каждое из них хранит либо сообщение, либо подтверждение. Чтобы гарантировать переда- чу подтверждений, когда все гнезда заняты, подтверждение на сообщение поме- щается в то же гнездо, которое было использовано для сообщения, и оно уже не используется для другого сообщения до тех пор, пока подтверждение не будет получено. Из-за того, что некоторые процессы не забрали свои сообщения, связь может быть приостановлена. Если каждое сообщение снабдить пометкой времени, то управляющая программа может периодически уничтожать старые сообщения. Процессы могут быть также остановлены в связи с тем, что другие процессы не смогли послать им сообщения. Если время поступления каждого остановленного процесса в очередь заблокированных процессов регистрируется, то управляю- щая программа может периодически посылать им пустые сообщения, чтобы они не ждали чересчур долго. Реализация почтовых ящиков требует использования примитивных операторов низкого уровня, таких как Р- и V-операции, или каких-либо других средств, но пользователям может дать средства более высокого уровня (наподобие монито- ров Хоара), например, ввести следующие операции: 1. SEND_MESSAGE (Получатель, Сообщение, Буфер) переписывает сообщение в некоторый буфер, помещает его адрес в перемен- ную Буфер и добавляет буфер к очереди Получатель. Процесс, выдавший опе- рацию SEND_MESSAGE, продолжит свое исполнение. 2. WAIT_MESSAGE (Отправитель, Сообщение, Буфер) блокирует процесс, выдавший операцию, до тех пор, пока в его очереди не появится какое-либо сообщение. Когда процесс устанавливается на процес- сор, он получает имя отправителя в переменной Отправитель, текст сообщения — в Сообщение и адрес буфера — в Буфер. Затем буфер удаляется из очереди, и про- цесс может записать в него ответ отправителю.
Конвейеры и очереди сообщений 255 3. SEND_ANSWER (Результат, Ответ, Буфер) записывает Ответ в тот Буфер, из которого было получено сообщение, и добавляет буфер к очереди отправителя. Если отправитель ждет ответ, он деблокируется. 4. WAIT_ANSWER (Результат, Ответ, Буфер) блокирует процесс, выдавший операцию, до тех пор, пока в Буфер не поступит ответ. После того как ответ поступил, и процесс установлен на процессор, Ответ переписывается в память процессу, а буфер освобождается. Результат указывает, является ли ответ пустым, то есть выданным операционной систе- мой, так как сообщение было адресовано несуществующему (или так и не ставшим активным) процессу. Основные достоинства почтовых ящиков: □ процессу не нужно знать о существовании других процессов до тех пор, пока он не получит сообщения от них; □ два процесса могут обмениваться более чем одним сообщением за один раз; □ операционная система может гарантировать, что никакой процесс не вмеша- ется в «беседу» других процессов; □ очереди буферов позволяют процессу-отправителю продолжать работу, не обращая внимания на получателя. Основным недостатком буферизации сообщений является появление еще одно- го ресурса, которым нужно управлять, самих почтовых ящиков. Другим недостатком можно считать статический характер этого ресурса: количе- ство буферов для передачи сообщений через почтовый ящик фиксировано. По- этому естественным стало появление механизмов, подобных почтовым ящикам, но реализованных на принципах динамического выделения памяти под переда- ваемые сообщения. Конвейеры и очереди сообщений Конвейеры (программные каналы) Конвейер (pipe — программный канал (связи), или, как его иногда называют, транспортер) является средством, с помощью которого можно производить об- мен данными между процессами. Принцип работы конвейера основан на меха- низме ввода/вывода, который используется для работы с файлами в UNIX, то есть задача, передающая информацию, действует так, как будто она записывает данные в файл, в то время как задача, для которой предназначается эта инфор- мация, читает ее из этого файла. Операции записи и чтения осуществляются не записями, как это делается в обычных файлах, а потоком байтов, как это было принято в UNIX-системах. Таким образом, функции, с помощью которых вы- полняется запись в канал и чтение из него, являются теми же самыми, что и при работе с файлами. По сути, канал представляет собой поток данных между дву- мя (или более) процессами. Это упрощает программирование и избавляет про- граммистов от использования каких-то новых механизмов. На самом деле кон-
256 Глава 6. Проектирование параллельных вычислительных процессов вейеры не являются файлами на диске, а представляют собой буферную память, работающую по принципу FIFO, то есть по принципу обычной очереди. Однако не следует путать конвейеры с очередями сообщений; последние реализуются иначе и имеют другие возможности. Конвейер имеет определенный размер1, который не может превышать 64 Кбайт, и работает циклически. Вспомните реализацию очереди на массивах, когда име- ются указатели начала и конца очереди, которые перемещаются циклически по массиву. Имеется некий массив и два указателя: один показывает на первый эле- мент (назовем его условно head), а второй — на последний (назовем его tail). В начальный момент оба указателя равны нулю. Добавление самого первого эле- мента в пустую очередь приводит к тому, что указатели head и tail принимают значение, равное 1 (в массиве появляется первый элемент). В последующем до- бавление нового элемента вызывает изменение значения второго указателя, по- скольку он отмечает расположение именно последнего элемента очереди. Чтение (и удаление) элемента (читается и удаляется всегда первый элемент из создан- ной очереди) приводит к необходимости модифицировать значение указателя head. В результате операций записи (добавления) и чтения (удаления) элемен- тов в массиве, моделирующем очередь элементов, указатели будут перемещаться от начала массива к его концу. При достижении указателем значения индекса последнего элемента массива значение указателя вновь становится единичным (если при этом не произошло переполнение массива то есть количество элемен- тов в очереди не стало больше числа элементов в массиве). Можно сказать, что мы как бы замыкаем массив в кольцо, организуя круговое перемещение указателей head и tail, которые отслеживают первый и последний элементы в очереди. Ска- занное проиллюстрировано на рис. 6.5. Именно так и функционирует конвейер. Tail Head Рис. 6.5. Организация очереди на массиве Как информационная структура канал описывается идентификатором, разме- ром и двумя указателями. Конвейеры представляют собой системный ресурс. Чтобы начать работу с конвейером, процесс сначала должен заказать его у опера- 1 Pipe (канал или конвейер) был введен в UNIX-системах и имеет максимальный размер в 64 Кбайт, поскольку в 16-разрядных мини-ЭВМ, для которых создавалась эта система, нельзя было создать массив данных большего размера.
Конвейеры и очереди сообщений 257 ционной системы и пЪлучить в свое распоряжение. Процессы, знающие иденти- фикатор конвейера, могут через него обмениваться данными. Теперь рассмотрим основные системные запросы для работы с ними. В качестве примера возьмем вызовы из API OS/2 (в следующем разделе мы ими воспользу- емся). Традиционные вызовы для работы с каналами (конвейерами) приведены в разделе, где описывается архитектура системы UNIX (см. главу 8). Итак: □ Функция создания конвейера: DosCreatePIpe (&ReadHandlе. &WriteHandlе. PIpeSIze): где ReadHandle — описатель для чтения из конвейера, Wri teHandl е — описатель для записи в конвейер, PipeSize — размер конвейера. □ Функция чтения из конвейера: DosRead (&ReadHandle. (PVOID)&Inform, sizeof(Inform). &BytesRead); где ReadHandle — описатель для чтения из конвейера, Inform — переменная лю- бого типа, sizeof(Inform) — размер переменной Inform, BytesRead — количество прочитанных байтов. Данная функция при обращении к пустому конвейеру будет ожидать, пока в конвейере не появится информация для чтения. □ Функция записи в конвейер: DosWrite (&WriteHandle, (PVOID)&Inform, sizeof(Inform). &BytesWrite): где WriteHandl e — описатель для записи в конвейер, BytesWrite — количество записанных байтов. Читать из конвейера может только тот процесс, который знает идентификатор соответствующего конвейера. При работе с конвейером данные непосредственно помещаются в него. Еще раз отметим, что из-за ограничения на размер конвейе- ра программисты сталкиваются и с ограничениями на размеры передаваемых че- рез него сообщений. Очереди сообщений Очереди сообщений (Queue) являются более сложным методом связи между взаимодействующими процессами по сравнению с каналами. С помощью очере- дей также можно из одной или нескольких задач независимым образом посы- лать сообщения некоторой задаче-приемнику. При этом только процесс-прием- ник может читать и удалять сообщения из очереди, а процессы-клиенты имеют право лишь помещать в очередь свои сообщения. Таким образом, очередь рабо- тает только в одном направлении. Если же необходима двухсторонняя связь, то можно создать две очереди. Работа с очередями сообщений имеет много отличий от работы с конвейерами. Во-первых, очереди сообщений предоставляют возможность использовать не- сколько дисциплин обработки сообщений: □ FIFO — сообщение, записанное первым, будет первым и прочитано; □ LIFO — сообщение, записанное последним, будет прочитано первым; □ приоритетный — сообщения читаются с учетом их приоритетов; □ произвольный доступ, то есть можно читать любое сообщение, тогда как ка- нал обеспечивает только дисциплину FIFO.
258 Глава 6. Проектирование параллельных вычислительных процессов Во-вторых, если при чтении сообщения из канала (конвейера) оно удаляется из него, то при чтении сообщения из очереди этого не происходит, и сообщение при желании может быть прочитано несколько раз. В третьих, в очередях присутствуют не непосредственно сами сообщения, а толь- ко их адреса в памяти и размер. Эта информация размещается системой в сег- менте памяти, доступном для всех задач, общающихся с помощью данной оче- реди. Каждый процесс, использующий очередь, должен предварительно получить раз- решение на использование общего сегмента памяти с помощью системных за- просов API, ибо очередь — это системный механизм и для работы с ним требу- ются системные ресурсы и, соответственно, обращение к самой ОС. Во время чтения из очереди задача-приемник пользуется следующей информацией: □ идентификатор процесса (PID — process ID), который передал сообщение; □ адрес и длина переданного сообщения; □ ждать или нет, если очередь пуста; □ приоритет переданного сообщения; □ номер освобождаемого семафора, когда сообщение передается в очередь. Наконец, приведем перечень основных функций, управляющих работой очереди (без подробного описания передаваемых параметров), поскольку в различных ОС обращения к этим функциям могут существенно различаться: □ CreateQueue — создание новой очереди; □ OpenQueue — открытие существующей очереди; □ ReadQueue — чтение и удаление сообщения из очереди; □ PeekQueue — чтение сообщения без его последующего удаления из оче- реди; □ WriteQueue — добавление сообщения в очередь; □ CloseQueue — завершение использования очереди; □ PurgeQueue — удаление из очереди всех сообщений; □ QueryQueue — определение числа элементов в очереди. Примеры создания параллельных взаимодействующих вычислительных процессов В завершение данного раздела рассмотрим учебный пример, в котором ставится задача создания комплекса параллельно выполняющихся взаимодействующих программ. Пример не будет иметь содержательного смысла в том плане, что ни- какой полезной работы программные модули, взаимодействующие между собой, не выполняют. Они только синхронизируют друг друга по предопределенной
Примеры создания параллельных взаимодействующих вычислительных процессов 259 схеме, демонстрируя главным образом способы организации взаимодействую- щих вычислений — взаимное исключение при выполнении критических интер- валов, обмены синхросигналами и данными. Пусть в нашем примере каждая программа (при своем исполнении либо как самостоятельный вычислительный процесс, либо как задача) должна сообщить время начала своей работы, время окончания работы и, возможно, имена тех программ, которые она (при определенных условиях) должна будет запланиро- вать на выполнение. Для иллюстрации различий в организации взаимодействия полноценных вычис- лительных процессов и многозадачных (многопотоковых) приложений приве- дем два примера реализации, что позволит увидеть разные механизмы. Начнем с более простого случая, когда создается обычное мультитредовое при- ложение, причем воспользуемся не средствами API, а методами, специально соз- данными для системы программирования. Второй пример будет иллюстрировать применение более мощных средств для организации взаимного исключения и обмена сообщениями; здесь будут использованы средства самой ОС. Пример создания многозадачного приложения с помощью системы программирования Borland Delphi Рассмотрим пример использования механизмов, специально созданных для ор- ганизации взаимного исключения, которые имеются в системе программирова- ния Borland Delphi 3.0. Эта система программирования, будучи ориентирован- ной на создание приложений в многозадачной операционной системе Microsoft Windows 9х, содержит в себе стандартные классы, позволяющие без особых уси- лий использовать многопоточные возможности этих ОС. Воспользуемся стан- дартным классом TThread. Объект, создаваемый на основе этого класса, можно охарактеризовать следующими теперь уже очевидными для нас свойствами: □ каждый тред имеет свою, при необходимости уникальную, исполняемую часть; □ каждый тред для своего исполнения требует отдельного процессорного вре- мени, то есть диспетчер задач принимает во внимание только приоритет треда; □ диспетчеризация выполнения тредов осуществляется операционной системой и не требует вмешательства программиста; □ несколько тредов, принадлежащих одному процессу, могут использовать один и тот же ресурс (например, глобальную переменную). Как нам известно, тре- ды могут обращаться к полям другого треда из того же вычислительного про- цесса. При этом программисту необходимо самостоятельно ограничивать дос- туп к этому ресурсу во избежание известных проблем. Итак, пусть необходимо создать многопоточное приложение, схема взаимодейст- вия отдельных потоков в котором (в рамках единого вычислительного процесса) приведена на рис. 6.6.
260 Глава 6. Проектирование параллельных вычислительных процессов Рис. 6.6. Схема №1 взаимодействия параллельно выполняющихся задач Так, согласно этому рисунку, процесс А после своего завершения запускает зада- чи D, С и Е. Считаем, что задачи В, D и С завершаются примерно в одинаковое время. По крайней мере, нам не известно, какой из потоков должен быть первым, а какой — последним. Однако по условиям задачи пусть поток F будет запус- каться тем из перечисленных тредов, который завершается первым, но только после того, как завершатся два оставшихся треда, приходящие в «точку синхро- низации». Наконец, пусть задача G запускается последним закончившим работу потоком Е или F. Все указанные задачи создадим как потомки объекта TThread. Тексты всех про- граммных модулей приведены в приложении А. Поскольку, согласно условию, действия, выполняемые задачами, для нас не имеют значения, представим ис- полняемую часть простейшим циклом с соответствующей задержкой. Для нагляд- ности внутри цикла можно организовать вывод текущего состояния выполнения задачи в процентах на «строке состояния», для чего используем компонент TGauge. Благодаря тому, что все семь тредов похожи (используют одни и те же методы) и отличаются только в части принятия решения о синхронизации, опишем орга- низацию базового объекта-треда. Базовый объект (TTreadProgress) является потомком объекта TTread. При этом он имеет следующие поля: □ имя треда; □ строка состояния треда; □ «длина» треда (время его работы в отсутствие конкурентов); □ текущее состояние треда; □ признак завершения треда; □ имя запустившего треда; □ строка для вывода сообщений в компонент ТМешо. В базовом объекте объявлены следующие процедуры: □ исполняемая часть; □ завершающая часть; □ процедура прорисовки строки состояния; □ процедура вывода сообщения; □ конструктор объекта.
Примеры создания параллельных взаимодействующих вычислительных процессов 261 Все треды (от А до G) являются потомками этого объекта и перекрывают един- ственный метод — процедуру завершения процесса. В исполняемой части задачи после завершения цикла задержки, имитирующего выполнение полезной рабо- ты, устанавливается признак завершения и вызывается процедура завершения задачи, которая и выполняет соответствующие действия. Общую схему работы программы, реализующей задание, можно описать следую- щим образом. Все задачи инициализируются соответствующей процедурой од- новременно, но в режиме ожидания запуска. В качестве параметров инициализа- ции в создаваемый поток передаются его имя, длительность и имя запускающего объекта (если оно известно заранее). Сразу после инициализации запускаются задачи А и В. Обе задачи сигнализируют об этом соответствующим сообщением. После своего завершения поток А запускает задачи (потоки) С, D и Е. Далее все идет в соответствии с заданной блок-схемой. Задача, запускающая другую зада- чу, передает ей свое имя, обращаясь непосредственно к полю этого объекта. Ин- формацию о том, завершился тот или иной поток, можно получить, обратившись к соответствующему полю — признаку завершения задачи. Естественно, что при подобной организации доступа к полям тредов вероятно возникновение разного рода критических ситуаций. Напомним, основная причи- на их возникновения заключена в том, что несколько задач (в нашем случае — потоков) реально имеют возможность обращения к общим ресурсам практиче- ски одновременно, то есть с таким интервалом времени, за который этот ресурс не успеет изменить свое состояние. В результате задачи могут получать некор- ректные значения, о чем мы уже немало говорили. Каждый процесс имеет связь с так называемыми VCL-объектами — видимыми компонентами. В данном случае такими являются строка состояния TGauge и поле сообщений ТМето. Для того чтобы в процессе работы нескольких парал- лельно выполняющихся задач не возникало критических ситуаций с выводом информации на эти видимые на экране объекты, к ним необходимо обеспечить синхронизированный доступ. Это довольно легко достигается с помощью стан- дартного для объекта TThread метода Synchronize. Метод имеет в качестве пара- метра имя процедуры, в которой производится вывод на VCL-объекты. При этом сама эта процедура нигде в программе не должна вызываться напрямую без ис- пользования метода Synchronize. В нашей программе такими процедурами явля- ются прорисовка строки состояния (Do Visual Progress) и вывод текстового сообщения (WriteToMemo). Подобное использование метода Synchronize обес- печивает корректную работу нескольких параллельных процессов с VCL-объек- тами. Однако метод Synchronize не помогает в случае совместного доступа к другим общим ресурсам. Поэтому необходимо применять другие средства для организа- ции взаимного исключения. Главная цель этих средств заключается в обеспече- нии монопольного доступа для каждой задачи к общим ресурсам, то есть пока один поток не закончил обращение к подобному ресурсу, другой не имеет право этот ресурс использовать. В системе программирования Delphi для этой цели имеется довольно-таки про- стой в использовании и достаточно эффективный метод критической секции с помощью объекта TCriticalSection. Этот метод заключается в следующем:
262 Глава 6. Проектирование параллельных вычислительных процессов □ участок кода каждого потока, в котором производится обращение к общему ресурсу, заключается в «скобки» критической секции — используются мето- ды Enter и Leave; □ если какой-либо тред уже находится внутри критической секции, то другой поток, который дошел до «открывающей скобки» Enter, не имеет права вхо- дить в критическую секцию до тех пор, пока первый поток находится в ней. Когда первый тред выйдет из критической секции, второй сможет войти в нее и, в свою очередь, обратиться к критическому ресурсу. Очевидно, что такой метод надежно обеспечивает задачам монопольный доступ к общим (критическим) ресурсам. Метод критической секции имеет ряд преимуществ перед его аналогами. Так, например, использование семафоров (Semaphore) сложнее в реализации. Другой метод взаимных исключений — mutex — в целом похож на метод критической секции, но он требует больше системных ресурсов и имеет свое время тайм-аута, по истечении которого ожидающий процесс может все-таки войти в защищен- ный блок, в то время как в критической секции подобного механизма нет. Текст всей программы с необходимыми комментариями приведен в приложении А. Пример создания комплекса параллельных взаимодействующих программ, выступающих как самостоятельные вычислительные процессы Теперь рассмотрим более сложную задачу: пусть параллельно выполняющиеся задачи имеют статус полноценного вычислительного процесса, а не потока и, кроме этого, организуем обращение к соответствующим системным механизмам непосредственно на уровне API. Схема взаимодействия программ для этого при- мера изображена на рис. 6.7. Рис. 6.7. Схема № 2 взаимодействия параллельно выполняющихся вычислительных процессов На этом рисунке программа с именем А в точке 1 порождает сразу четыре парал- лельно выполняющихся задачи (вычислительных процесса): В, С, I и}. Процес- сы В и С завершаются и должны запустить, в свою очередь, два параллельных процесса D и Е. В точке 2, в которой происходит синхронизация задач В и С, процессы D и Е должны быть запущены той из задач В или С, которая закончит
Примеры создания параллельных взаимодействующих вычислительных процессов 263 свое выполнение раньше, но только после момента окончания второй. Далее, в точке 3 необходимо запустить программы G и Н. Запускает их первая завершив- шая свою работу программа (D, Е или I), но дождавшись завершения остальных программ. Точка 4 синхронизирует выполнение процессов F, G, Н и J, при этом программу К запускает последний из завершившихся процессов. Для решения поставленной задачи будем использовать соответствующие меха- низмы, описанные нами выше (применительно к OS/2), а именно конвейер (pipe) и семафоры. В нашей задаче через конвейер передается переменная типа Parent- Info: struct Parentinfo { char ParentName [15]: char LaunchTIme [12]: int Number: } Parent Info — хранит имя программы-предка; LaunchTIme — содержит время запус- ка текущей программы программой-предком (время, когда программа-предок выполнила функцию DosExecPgm); переменная Number указывает, для какого числа программ предназначена переменная Parentinfo. Читать из конвейера мы можем при помощи функции DorRead, но при этом унич- тожается запись в конвейере, и мы обязаны перезаписывать данные в конвейер для других программ. Каждая программа читает данные из конвейера и затем уменьшает значение Number. Программа, установившая значение 1, является по- следней программой, прочитавшей данные из конвейера; она посылает сигнал предку о завершении инициализации необходимых ресурсов. Программа пишет в конвейер данные только в том случае, если именно она будет запускать сле- дующие программы. Для чтения и записи в конвейер необходимы переменные WriteHandle и ReadHandle, указывающие на описатели записи и чтения в конвейер. Необходимо, чтобы зна- чения этих переменных, полученных в процессе А, были известны остальным процессам для совместного использования. Для этого значения этих переменных мы можем передать процессам-потомкам в качестве строк аргументов. При попытке считывания информации из пустого конвейера процесс переводит- ся в состояние ожидания. После записи информации в конвейер другим процес- сом ожидающий процесс считывает поступившую информацию и продолжает свою работу. Поскольку программы, которые мы сейчас рассматриваем, созданы для операци- онной системы OS/2, приведем краткое описание использованных механизмов, которые имеют отличия от вышеописанных общих принципов и схем. □ Функция создания семафора: DosCreateEventSem ("WSEMWPIPESEM", &PipeSem. 1, 0): где "WSEMWPIPESEM" — имя семафора, PipeSem — идентификатор семафора, 1 — параметр совместного использования семафора (DC_SEM_SHARED), 0 — зарезер- вированный системный параметр.
264 Глава 6. Проектирование параллельных вычислительных процессов □ Функция открытия семафора: DosOpenEventSem ("WSEMWPIPESEM", &P1peSem): где "WSEMWPIPESEM'' — имя семафора, PipeSem — идентификатор семафора. □ Функция установки семафора: DosPostEventSem (PipeSem): где PipeSem — идентификатор семафора. □ Функция сброса семафора: DosResetEventSem (PipeSem. &NPost): где PipeSem — идентификатор семафора, NPost — количество обращений к ус- тановке семафора с момента последнего сброса. □ Функция ожидания установки семафора: DosPostEventSem (PipeSem. -1): где PipeSem — идентификатор семафора, -1 — ожидание семафора до его уста- новки (положительное значение — это задержка в миллисекундах). Для синхронизации процессов и обработки критических участков программ не- обходимы семафоры, к которым имеют доступ все работающие программы. Про- грамма-потомок может унаследовать семафор от программы-предка, которая создала или открыла необходимый семафор. Но это произойдет только тогда, когда программа-предок дождется момента открытия семафора в программе-по- томке. Это обеспечивается за счет дополнительных семафоров ExitSeml, ExitSem2, ExitSem3. Когда последняя программа-потомок прочитывает данные из конвейера (работа с конвейером) и обнаруживает, что она последней прошла участок от- крытия уже созданных семафоров, она устанавливает необходимый семафор, принадлежащий программе-предку. Программа-предок, ожидающая установки семафора, завершает свою работу. Для управления запуском и завершением программ также используются соот- ветствующие функции. DosExecPgm (FailFile, sizeof(FailFile), 1, Argument, 0, ResCode, "progr_b.exe") — функция запуска программы-потомка, где □ FailFile — буфер для помещения имени объекта, из-за которого возникла ошиб- ка запуска программы, sizeof(Fai 1 Fi 1 е) — размер буфера; □ 1 означает, что процесс-потомок следует выполнять асинхронно с процессом- предком, Argument — строки аргументов программы, 0 — строки среды, ResCode — результирующие коды запуска программы, "progr_b.exe" — имя запускаемой (планируемой на выполнение) программы; □ DosExit — функция завершения процесса (и всех его подпроцессов); □ DosSetPriority — установка приоритета программы (и всех его подпроцессов). Каждую программу в соответствии с заданием создаем как независимую, то есть имеющую свое локальное адресное пространство. Благодаря этому переменные в текстах программ, имеющие одинаковые имена (см. листинги в приложении Б), фактически являются разными переменными.
Примеры создания параллельных взаимодействующих вычислительных процессов 265 Программа с именем А является начальной, стартовой. Именно она создает те системные объекты, которыми потом пользуются ее потомки для своей синхро- низации. Имена системных объектов они получают в наследство при своем по- рождении. Для совместного использования вычислительными процессами одного файла необходимо правильно его открыть в программе А. После корректного открытия или создания файла к нему могут обращаться все программы, имеющие иден- тификатор открытого файла. Значение этого идентификатора передается запус- кающимся процессам в строке аргументов, сопровождающих вызов. В соответствии с заданием каждая программа записывает в файл время своего запуска, имя процесса-предка и время завершения своей работы. Поскольку файл используется всеми программами и запись производится в строгой последова- тельности, часть программы, которая обеспечивает запись в файл, должна быть признана нами как критический участок программы (критический интервал). Алгоритм обработки этого участка описан ниже. Если не пытаться регулировать доступ процессов к файлу, может возникнуть беспорядочная запись в файл. При регулировании записи каждый процесс производит сразу все три записи в файл. Опишем теперь алгоритм обработки критических участков программ — записи в файл и работы с конвейером. □ Критический участок — работа с конвейером. Приведем фрагмент программы, обеспечивающий чтение из конвейера. 1: do { DosWaltEventSem (PipeSem. -1): 2: rc=DosResetEventSem (PipeSem. &NPost); 3: } while (rc!=0); 4: DosRead (ReadHandle, (PV0ID)&01dInform. sizeof(Oldlnform). BytesReaden); 5: DosPostEventSemCPIpeSem): Программа А создает семафор PipeSem, который затем используют все про- граммы для прохождения критической части программы, связанной с чтени- ем и записью в конвейер. В строке 1 программа ожидает установки семафора PipeSem; после того как этот семафор будет установлен, программа переходит к строке 2. Переменная гс возвращает код ошибки при попытке сбросить семафор в строке 2. Это сде- лано со следующей целью: если после завершения строки 1 будет выполнять- ся другая программа и она успеет сбросить семафор, то когда программа вновь продолжит свое выполнение, она обнаружит в строке 2, что семафор уже сброшен и гс не будет равно нулю; затем цикл повторится. Так будет про- должаться до тех пор, пока гс не станет равно нулю, то есть текущая програм- ма первой сбросила семафор. Тогда в строке 4 программа считывает из кон- вейера данные и сигнализирует об освобождении ресурса в строке 5. Для записи в конвейер используется аналогичный алгоритм. □ Критический участок — запись в файл. Здесь использован такой же алгоритм, как и при работе с конвейером, но за- действован специально созданный для этого семафор FileSem.
266 Глава 6. Проектирование параллельных вычислительных процессов Теперь рассмотрим алгоритмы, решающие задачи синхронизации в каждой из точек нашей схемы (см. рис. 6.7). Начнем с прохождения точки 2. Приведем фрагмент исходного текста программы (см. приложение Б). 1: rc=DosPostEventSem(PointlSem); 2: if (rc==0) 3: { DosWrite(WriteHandle,(PVOID)&NewInform. sizeof(NewInform),&BytesWritten), 4: DosCreateEventSem(,,\\SEM32\\EXITSEM2,,( &ExitSem2. DC_SEM_SHARED. 0); 5: DosExecPgm(FailFileb.sizeof(FailFileb).1. Argument.0. &ResCodeb."progr_d.exe"): 6: DosExecPgm(FailFileb.sizeof(FailFileb).l. Argument.0. &ResCodeb."progr_e exe"): 7: DosExecPgm(FailFileb.sizeof(FailFileb),1.Argument.0. &ResCodeb."progr_f exe"): 8: DosWaitEventSem(ExitSem2.-1); 9: DosCloseEventSem(ExitSem2); } В точке 2 программы D, E, и F должны быть запущены процессом, который пер- вым завершит свою работу. Для определения последовательности завершения работы программ (В или С) нами используется семафор PointlSem. В строке 1 производится установка данного семафора. Если значение гс не равно нулю, зна- чит, семафор уже установлен, то есть текущая программа не первой завершила свою работу. Если гс равно нулю, то текущая программа закончила работу пер- вой и осуществляется переход к строке 3. В строке 3 осуществляется запись в конвейер. Строка 4 — создание семафора ожидания. Этот семафор используется для ожидания открытия семафоров в программах-потомках. Ожидание откры- тия семафоров происходит в строке 8, затем семафор закрывается. В строках 5—7 осуществляется запуск программ D, Е, F. Теперь приведем алгоритм прохождения точки 3. Фрагмент исходного текста программы изображен ниже. 1: rc=DosPostEventSem(Point2Sem); 2: if (rc= =0) 3: { do{ DosQueryEventSem(Point2Sem.&BytesWritten); 4. }while (BytesWritten<=2); 5: DosWrite(WriteHandle.(PVOID)&NewInform. sizeof(NewInform). &BytesWritten). 6: DosCreateEventSem("\\SEM32\\EXITSEM3", &ExitSem3. DC_SEM_SHARED. 0). 7: DosExecPgm(FailFi1 eb.sizeof(FailFi1 eb).1.Argument.0. &ResCodeb,"progr_g exe"); 8: DosExecPgm(FaiIFileb.sizeof(FailFileb),1.Argument,0. &ResCodeb."progr_h exe”); 9: DosWaitEventSem(ExitSem3.-1): В точке 3 программы G и H запускаются той программой, которая первой завер- шает свою работу, но только после того, как работу завершат остальные програм- мы. Для определения последовательности завершения работы программ (I, D или Е) используется семафор Point2Sem. В строке 1 производится установка дан- ного семафора. Если значение гс не равно нулю, значит, семафор уже установ- лен, то есть текущая программа не первой завершила свою работу. Если гс равно нулю, то текущая программа закончила работу первой и осуществляется переход к строке 3. В этой строке подсчитывается количество обращений к данному семафору. Цикл повторяется до тех пор, пока количество обращений не станет равно 3, то есть когда все остальные программы завершили свою работу. После этого в строке 5 осуществляется запись в конвейер. Строка 6 — создание семафо- ра ожидания. Этот семафор используется для ожидания открытия семафоров
Контрольные вопросы и задачи 267 в программах-потомках. Ожидание открытия семафоров происходит в строке 9. В строках 7 и 8 осуществляется запуск программ G и Н соответственно. Наконец, приведем еще алгоритм прохождения точки 4. Фрагмент исходного текста программы, реализующей эту задачу, приведен ниже. 1 do { DosWaitEventSem(РотntlSem -1), 2 rc=DosResetEventSem(PointlSem,&BytesReaden) 3 } while (rcl=0), 4 DosPostEventSem(Point3Sem) 5 DosQueryEventSem(Point3Sem &BytesWritten) 6 DosPostEventSem(PointlSem) 7 if (BytesWritten==4) 8 { DosWrite(WriteHandle.(PVOID)&NewInform. sizeof(NewInform),&BytesWritten). 9 DosCreateEventSem("\\SEM32\\EXITSEM4", &ExitSem4 DC_SEM_SHARED. 0). 10 DosExecPgm(FailFileb sizeof(FaiIFileb).1.Argument 0. &ResCodeb "progr_k exe") 11 DosWaitEventSem(ExitSem4 -1). Итак, в точке 4 программа К запускается задачей, которая последней завершила свою работу. Для определения последовательности завершения работы программ (J, G, Н или F) используется семафор Point3Sem. Каждая программа должна обра- титься к данному семафору, установить его и проверить количество обращений к нему. Но при выполнении этих двух операций другие программы могут также установить семафор, и значение обращений к семафору в текущей программе окажется неверным. Для доступа к семафору Point3Sem используется семафор PointlSem. В строке 1 программа ожидает установки семафора PointlSem, который сигнализирует о доступности неразделяемого ресурса Point3Sem. В строке 2 сема- фор сбрасывается, но если другие программы успели сбросить семафор, то об этом сообщит значение гс. Если текущей программе удалось завладеть ресурсом, то есть значение гс равно нулю, то дальше в строках 4, 5 производится установка семафора Point3Sem и подсчет количества обращений. Строка 6 сигнализирует о завершении работы критической части программы установкой семафора PointlSem. Затем, как и в предыдущих алгоритмах, программа записывает информацию в транспортер (строка 8), создает семафор ожидания открытия семафоров в про- граммах-потомках (строка 9), запускает программу К (строка 10) и ожидает от- крытия семафоров в программе К (строка И). Тексты четырех программ (А, В, D и G), действующих по рассмотренным алго- ритмам, приведены в приложении Б. Остальные программы аналогичны им и от- личаются только использованием других имен. Контрольные вопросы и задачи Вопросы для проверки 1. Какие последовательные вычислительные процессы мы называем параллель- ными и почему? Какие параллельные процессы называются независимыми, а какие — взаимодействующими?
268 Глава 6. Проектирование параллельных вычислительных процессов 2. Изложите алгоритм Деккера, позволяющий разрешить проблему взаимного исключения путем использования одной только блокировки памяти. 3. Объясните команду «проверка и установка». 4. Расскажите о семафорах Дейкстры. Чем обеспечивается взаимное исключе- ние при выполнении Р- и V-примитивов? 5. Изложите, как могут быть реализованы семафорные примитивы для мульти- процессорной системы. 6. Что такое мьютекс (mutex)? 7. Изложите алгоритм решения задачи «поставщик — потребитель» при исполь- зовании семафоров Дейкстры. 8. Изложите алгоритм решения задачи «читатели — писатели» при использова- нии семафоров Дейкстры. 9. Что такое «монитор Хоара»? Приведите пример такого монитора. 10. Что представляют собой «почтовые ящики»? И. Что представляют собой «конвейеры» (программные каналы)? 12. Что представляют собой «очереди сообщений»? Чем отличаются очереди со- общений от почтовых ящиков?
ГЛАВА 7 Проблема тупиков и методы борьбы с ними Рассмотрим одну из самых серьезных и трудно разрешимых проблем, возникаю- щих при организации мультипрограммирования — проблему тупиков и основ- ные подходы при борьбе с ними. В настоящей главе приводятся некоторые моде- ли параллельных вычислительных процессов, позволяющие проводить анализ последних в аспекте корректного решения указанных проблем. Понятие тупиковой ситуации при выполнении параллельных вычислительных процессов При организации параллельного выполнения нескольких процессов одной из главных функций операционной системы является решение сложной задачи кор- ректного распределения ресурсов между выполняющимися процессами и обес- печение последних средствами взаимной синхронизации и обмена данными. При параллельном исполнении процессов могут возникать ситуации, при кото- рых два или более процесса все время находятся в заблокированном состоянии. Самым простым является случай, когда каждый из двух процессов ожидает ре- сурс, занятый другим процессом. Из-за такого ожидания ни один из процессов не может продолжить исполнение и освободить в конечном итоге ресурс, необхо- димый другому процессу. Эта тупиковая ситуация называется дедлоком1, тупи- ком или клинчем. Говорят, что в мультипрограммной системе процесс находится в состоянии тупика, если он ждет события, которое никогда не произойдет. Ту- пики чаще всего возникают из-за конкуренции несвязанных параллельных про- цессов за ресурсы вычислительной системы, но иногда к тупикам приводят и ошибки программирования. 1 Dead lock — смертельное объятие.
270 Глава 7. Проблема тупиков и методы борьбы с ними При рассмотрении проблемы тупиков целесообразно понятие ресурсов системы обобщить и разделить их все на два класса — повторно используемые (или сис- темные) ресурсы (типа RR или SR — reusable resource или system resource) и по- требляемые (или расходуемые) ресурсы (типа CR — consumable resource). Повторно используемый ресурс (SR) есть конечное множество идентичных еди- ниц со следующими свойствами [92]: □ число единиц ресурса постоянно; □ каждая единица ресурса или доступна, или распределена одному и только од- ному процессу (разделение либо отсутствует, либо не принимается во внима- ние, так как не оказывает влияния на распределение ресурсов, а значит, и на возникновение тупиковой ситуации); □ процесс может освободить единицу ресурса (сделать ее доступной), только если он ранее получил эту единицу, то есть никакой процесс не может оказы- вать какое-либо влияние ни на один ресурс, если он ему не принадлежит. Данное определение выделяет существенные для изучения проблемы тупика свойства обычных системных ресурсов, к которым мы относим такие компонен- ты аппаратуры, как основная память, вспомогательная (внешняя) память, пери- ферийные устройства и, возможно, процессоры, а также программное и инфор- мационное обеспечение, такое как файлы данных, таблицы и «разрешение войти в критическую секцию». Расходуемый ресурс (CR) отличается от ресурса типа SR в нескольких важных отношениях [37]: □ число доступных единиц некоторого ресурса типа CR изменяется по мере того, как приобретаются (расходуются) и освобождаются (производятся) от- дельные их элементы выполняющимися процессами, и такое число единиц ресурса является потенциально неограниченным; процесс «производитель» увеличивает число единиц ресурса, освобождая одну или более единиц, кото- рые он «создал»; □ процесс «потребитель» уменьшает число единиц ресурса, сначала запрашивая и затем приобретая (потребляя) одну или более единиц. Единицы ресурса, которые приобретены, в общем случае не возвращаются ресурсу, а потребля- ются (расходуются). Эти свойства потребляемых ресурсов присущи многим синхронизирующим сигналам, сообщениям и данным, порождаемым как ап- паратурой, так и программным обеспечением, и могут рассматриваться как ресурсы типа CR при изучении тупиков. В их число входят: прерывания от таймера и устройств ввода/вывода; сигналы синхронизации процессов; сооб- щения, содержащие запросы на различные виды обслуживания или данные, а также соответствующие ответы. Для исследования параллельных процессов и, в частности, проблемы тупиков было разработано несколько моделей. Одной из них является модель повторно используемых ресурсов Холта [92]. Согласно этой модели система представляет- ся как набор (множество) процессов и набор ресурсов, причем каждый из ресур- сов состоит из фиксированного числа единиц. Любой процесс может изменять
Примеры тупиковых ситуаций и причины их возникновения 271 состояние системы с помощью запроса, получения или освобождения единицы ресурса. В графической форме процессы и ресурсы представляются квадратами и круж- ками соответственно. Каждый кружок содержит некоторое количество маркеров (фишек) в соответствии с существующим количеством единиц этого ресурса. Дуга, указывающая из «процесса» на «ресурс», означает запрос одной единицы этого ресурса. Дуга, указывающая из «ресурса» на «процесс», представляет выделение ресурса процессу. Поскольку каждая единица любого ресурса типа SR может быть выделена одновременно не более чем одному процессу, то число дуг, исхо- дящих из ресурса к различным процессам, не может превышать общего числа единиц этого ресурса. Такая модель называется графом повторно используемых ресурсов. Одно из состояний примера системы из двух процессов с ресурсами типа SR представлено на рис. 7.1. Рис. 7.1. Пример модели Холта для системы из двух процессов Пусть процесс Р1 запрашивает две единицы ресурса R1 и одну единицу ресур- са R2. Процессу Р2 принадлежат две единицы ресурса R1 и ему нужна одна еди- ница R2. Предположим, что процесс Р1 получил бы теперь запрошенную им единицу R2. Если принято правило, по которому процесс должен получить все запрошенные им ресурсы, прежде чем освободить хотя бы один из них, то удов- летворение запроса Р1 приведет к тупиковой ситуации: Р1 не сможет продол- житься до тех пор, пока Р2 не освободит единицу ресурса R1, а процесс Р2 не сможет продолжиться до тех пор, пока Р1 не освободит единицу R2. Причиной этого дедлока являются неупорядоченные попытки процессов войти в критиче- ский интервал, связанный с выделением соответствующей единицы ресурса. Примеры тупиковых ситуаций и причины их возникновения Для понимания основных причин возникновения тупиков рассмотрим несколь- ко простых характерных примеров. Пример тупика на ресурсах типа CR Пусть имеются три процесса ПР1, ПР2 и ПРЗ, которые вырабатывают соответ- ственно сообщения Ml, М2 и М3. Эти сообщения представляют собой ресурсы
272 Глава 7. Проблема тупиков и методы борьбы с ними типа CR. Пусть процесс ПР1 является «потребителем» сообщения М3, процесс ПР2 получает сообщение Ml, а ПРЗ — сообщение М2 от процесса ПР2, то есть каждый из процессов является и «поставщиком» и «потребителем» одновременно, и вместе они образуют «кольцевую» систему (рис. 7.2) передачи сообщений че- рез почтовые ящики (ПЯ). Если связь с помощью этих сообщений со стороны каждого процесса устанавливается в порядке, изображенном в листинге 7.1, то никаких трудностей не возникает. Однако перестановка этих двух процедур в каждом из процессов (листинг 7.2) вызывает тупик: Рис. 7.2. Кольцевая схема взаимодействия процессов Листинг 7.1. Вариант без тупиковой ситуации ПР1: ПОСЛАТЬ СООБЩЕНИЕ (ПР2, Ml. ПЯ2); ЖДАТЬ СООБЩЕНИЕ (ПРЗ. М3. ПЯ1); ПР2: ПОСЛАТЬ СООБЩЕНИЕ (ПРЗ. М2. ПЯЗ): ЖДАТЬ СООБЩЕНИЕ (ПР1. Ml. ПЯ2): ПРЗ: ПОСЛАТЬ СООБЩЕНИЕ (ПР1. М3. ПЯ1); ЖДАТЬ СООБЩЕНИЕ (ПР2, М2. ПЯЗ); Листинг 7.2. Вариант с тупиковой ситуацией ПР1: ЖДАТЬ СООБЩЕНИЕ (ПРЗ. М3. ПЯ1); ПОСЛАТЬ СООБЩЕНИЕ (ПР2. Ml. ПЯ2); ПР2: ЖДАТЬ СООБЩЕНИЕ (ПР1. Ml. ПЯ2); ПОСЛАТЬ СООБЩЕНИЕ (ПРЗ. М2. ПЯЗ);
Примеры тупиковых ситуаций и причины их возникновения 273 ПРЗ: ЖДАТЬ СООБЩЕНИЕ (ПР2. М2. ПЯЗ): ПОСЛАТЬ СООБЩЕНИЕ (ПР1, М3. ПЯ1); В самом деле, во втором варианте ни один из процессов не сможет послать сооб- щения до тех пор, пока сам его не получит, а этого события никогда не произой- дет, поскольку ни один процесс не может этого сделать. Пример тупика на ресурсах типа CR и SR Пусть некоторый процесс ПР1 должен обменяться сообщениями с процессом ПР2 и каждый из них запрашивает некоторый ресурс R, причем ПР1 требует три еди- ницы этого ресурса для своей работы, а ПР2 — две единицы и только на время обработки сообщения. Всего же имеются только четыре единицы ресурса R. За- прос ресурса можно реализовать через соответствующий монитор с процедурами REQUEST(R.N) — запрос единиц ресурса R и RELEASE(R,N) — освобождение, возврат У единиц ресурса R. Обмен сообщениями будем осуществлять через почтовый ящик МВ. Фрагменты программ ПР1 и ПР2 приведены в листинге. 7.3. Листинг 7.3. Пример тупика на CR- и SR-pecypcax ПР1: REQUEST (R. 3); SEND_MESSAGE (ПР2, сообщение. MB); WAIT_ANSWER (ответ. MB); RELEASE (R. 3): ПР2: WAIT_MESSAGE (ПР1. сообщение. MB); REQUEST (R, 2); ОБРАБОТКА СООБЩЕНИЯ: RELEASE (R. 2); SEND-ANSWER (ответ. MB); Эти два процесса всегда будут попадать в тупик. Процесс ПР2, если будет вы- полняться первым, сначала ожидает сообщения от процесса ПР1, после чего бу-
274 Глава 7. Проблема тупиков и методы борьбы с ними дет заблокирован при запросе ресурса R, часть которого будет уже отдана ПР1. Процесс ПР1, завладев частью ресурса R, будет заблокирован на ожидании отве- та от ПР2, которого никогда не получит, так как для этого ПР2 нужно получить ресурс R, находящийся в распоряжении ПР1. Тупика можно избежать лишь при условии, что на время ожидания ответа от ПР2 процесс ПР1 будет отдавать хотя бы одну единицу ресурса R, которыми он сейчас владеет. В данном примере, как и в предыдущем, причиной тупика являются ошибки программирования. Пример тупика на ресурсах типа SR Предположим, что существуют два процесса ПР1 и ПР2, разделяющих два ре- сурса типа SR: R1 и R2. Пусть взаимное исключение доступов к этим ресурсам реализуется с помощью семафоров S1 и S2 соответственно. Процессы ПР1 и ПР2 обращаются к ресурсам следующим образом [37] (рис. 7.3): Процесс ПР1 Процесс ПР2 1: Р (S2); 5: P(S1); 2: Р (S1); 6: Р (S2); 3: V(S1); 7: V(S1); 4: V (S2); 8: V (S2); Рис. 7.3. Пример последовательности операторов для двух процессов, которые могут привести к тупиковой ситуации Здесь несущественные (с точки зрения обращения к ресурсам) детали опущены. Считаем, что оба семафора первоначально установлены в единицу. Пространст- во возможных вычислений приведено на рис. 7.4. Горизонтальная ось задает выполнение процесса ПР1, вертикальная — ПР2. Вер- тикальные линии, пронумерованные от 1 до 4, соответствуют операторам 1-4 процесса ПР1. Аналогично горизонтальные линии, пронумерованные от 5 до 8, соответствуют операторам 5-8 программы ПР2. Точка на плоскости определяет состояние вычислений в некоторый момент времени. Так, точка А соответствует ситуации, при которой ПР1 начал исполнение, но не достиг оператора 1, а ПР2 выполнил оператор 6, но не дошел до оператора 7. По мере выполнения точка будет двигаться горизонтально вправо, если исполняется ПР1, и вертикально вверх, если исполняется ПР2. Интервалы исполнения, во время которых ресурсы R1 и R2 используются каждым процессом, показаны с помощью фигурных скобок. Линии 1-8 делят простран- ство вычислений на 25 прямоугольников, каждый из которых задает состояние вычислений. Закрашенные серым цветом состояния являются недостижимыми из-за взаимного исключения ПР1 и ПР2 при доступе к ресурсам R1 и R2.
Примеры тупиковых ситуаций и причины их возникновения 275 Рис. 7.4. Пространство состояний системы двух параллельных конкурирующих процессов Рассмотрим последовательность исполнения 1-2-5-3-6-4-7-8, представленную траекторией Т1. Когда процесс ПР2 запрашивает ресурс R1 (оператор 5), ресурс недоступен (оператор выполнен, семафор закрыт). Поэтому процесс ПР2 забло- кирован в точке В. Как только процесс ПР1 достигнет оператора 3, процесс ПР2 деблокируется по ресурсу R1. Аналогично в точке С процесс ПР2 будет заблоки- рован при попытке доступа к ресурсу R2 (оператор 6). Как только процесс ПР1 достигнет оператора 4, процесс ПР2 деблокируется по ресурсу R2. Если же, например, выполняется последовательность 1-5-2-6, то процесс ПР1 заблокируется в точке X при выполнении оператора 2, а процесс ПР2 заблокиру- ется в точке Y при выполнении оператора 6. При этом процесс ПР1 ждет, когда процесс ПР2 выполнит оператор 7, а ПР2 ждет, когда ПР1 выполнит оператор 4. Оба процесса будут находиться в тупике, ни ПР1, ни ПР2 не могут закончить выполнение. При этом все ресурсы, которые получили ПР1 и ПР2, становятся недоступными для других процессов, что резко снижает возможности вычисли- тельной системы по обслуживанию их. Отметим одно очень важное обстоятель- ство: тупик будет неизбежным, если вычисления зашли в прямоугольник D, яв- ляющийся критическим состоянием. Для того чтобы возник тупик, необходимо, чтобы одновременно выполнялись четыре условия [37, 92]: □ взаимного исключения, при котором процессы осуществляют монопольный доступ к ресурсам; □ ожидания, при котором процесс, запросивший ресурс, ждет до тех пор, пока запрос не будет удовлетворен, при этом удерживая ранее полученные ресурсы; □ отсутствия перераспределения, при котором ресурсы нельзя отобрать у про- цесса, если они ему уже выделены; □ кругового ожидания, при котором существует замкнутая цепь процессов, каж- дый из которых ждет ресурс, удерживаемый его предшественником в этой цепи. Проанализировав содержательный смысл этих четырех условий, легко убедить- ся, что все они выполняются в точке У (см. рис. 7.4).
276 Глава 7. Проблема тупиков и методы борьбы с ними Формальные модели для изучения проблемы тупиковых ситуаций Проблема борьбы с тупиками становится все более актуальной и сложной по мере развития и внедрения параллельных вычислительных систем. При проек- тировании таких систем разработчики стараются проанализировать возможные неприятные ситуации, используя специальные модели и методы. Таких моделей много; к настоящему времени разработано несколько десятков различных моделей, предназначенных для анализа и моделирования систем с па- раллельными асинхронными процессами, для которых возможность возникно- вения тупиковых ситуаций является очень серьезной проблемой. Изложение и сравнительный анализ этих моделей может составить большую монографию, по- этому здесь мы кратко рассмотрим только четыре из них — сети Петри, вычис- лительные схемы, модель пространства состояний и уже упомянутую нами мо- дель Холта. Сети Петри Среди многих существующих методов описания и анализа параллельных систем уже более 35 лет значительное место занимают сетевые модели, восходящие к сетям специального вида, предложенных в 1962 году Карлом Петри для моде- лирования асинхронных информационных потоков в системах преобразования данных [64]. Взаимодействие событий в параллельных асинхронных дискретных системах име- ет, как правило, сложную динамическую структуру. Эти взаимодействия описы- ваются более просто, если указывать не непосредственные связи между события- ми, а те ситуации, при которых данное событие может реализоваться. При этом глобальные ситуации в системе формируются с помощью локальных операций, называемых условиями реализации событий. Определенные сочетания условий разрешают реализоваться некоторому событию (предусловия события), а реали- зация события изменяет некоторые условия (постусловия события), то есть события взаимодействуют с условиями, а условия — с событиями. Таким обра- зом, предполагается, что для решения задач достаточно представить системы как структуры, образованные из элементов двух типов — событий и условий. Удоб- ный формальный механизм для этого, предложенный Петри, был развит А. Хол- том, который назвал его сетью Петри. В сетях Петри события и условия представлены абстрактными символами из двух непересекающихся алфавитов, называемых соответственно множеством пе- реходов и множеством позиций. Имеется несколько формальных представлений сетей Петри: □ теоретико-множественное; □ графовое — бихроматический (двудольный ориентированный) граф и, соот- ветственно, графическое; □ матричное.
Формальные модели для изучения проблемы тупиковых ситуаций 277 При использовании теоретико-множественного подхода к описанию сети Петри (поскольку эта модель представляет и структуру, и функционирование системы) она формально может быть определена как двойка вида: У = (S, MQЗдесь S — это структура сети, которая представляется двудольным ориентированным мультиграфом S= (V,U), где V — вершины этого графа, U — его дуги. Мо — на- чальное состояние сети Петри, которое также называется начальной маркиров- кой. В силу того, что граф S является двудольным, можно перейти к формально- му описанию структуры сети Петри в виде тройки: 5(Р,Т,У), где Р — конечное множество позиций, Р ={pt},i = 1,п; Т — конечное множество переходов, Т = {^}, J = i,m; T<jP =V,T r\P = 0, то есть ТяР — это два типа вер- шин биграфа 5; Y — конечное множество дуг, заданное отношениями между вер- шинами графа S : Y g (Р * T)<j(T* Р). Поскольку двудольный мультиграф S является ориентированным, то любой пе- реход tj, j = 1, m соединяется с позициями рг g Р через входные и выходные дуги, которые задаются через функцию предшествования В:(Р * Г) -> {0, 1, 2,...} и через функцию следования Е:(Т* Р) -> {0,1, 2...}, являющиеся отображениями из мно- жества переходов в комплекты позиций [64] (синонимом термина комплект является понятие мультимножества). Эти функции определяют комплекты по- зиций {рг} 6 Р, сязанных с переходом t} еТ через множество дуг {(pt,tj),}, где / <|{(pf,£; )i'ij = W, и комплекты позиций {pk} g Р, связанных с перехо- дом tj еТ через множество_дуг {(^, pk ),}, где / < |{(£;,р* )z: j,k = cons£}| < TV. Здесь W — мультичисло графа S;P — пространство комплектов, заданное на множестве функциями Е и В; {(pt, t )n} — v-я дуга, выходящая из позиции pt и входящая в переход tf, {(tJfpk )n} — v-я дуга, выходящая из перехода t} и входящая в позицию Ph Таким образом, теперь структура S сети Петри N может быть представлена как четверка: S(P,T,B,E). Представим множество позиций Р как объединение двух пересекающихся множеств: P=/uO;/nO^0. Здесь мы через I и О обозначим следующие множества: m m I = О=иф,| ;=1 J гдеI(t}) = {p,:B(p,,tJ) > 1, i = i,n}, j = i,m; O(tJ) = {pk:E(tJ,pk ) > 1, k = l,n}, j = l,m, (pt, tj) — дуга с весом w<W, выходящая из вершины pt и входящая в вершину t;, (tp Pk} ~ дуга с весом w<W, выходящая из вершины tj и входящая в вершину pk, то есть I(tj) и O(t}) — комплекты соответственно входных и выходных позиций перехода tr Элементы множества Т обычно представляют собой те возможности (возможные ситуации, условия), при которых могут быть реализованы интересующие нас процессы (действия). Начальная маркировка MQ (как и текущая маркировка М, которая соответствует некоторому состоянию сети в текущий момент модельного времени) определяет- ся одномерной матрицей (вектором), число компонентов которого равно числу позиций сети п, п в \Р |, а значение z-ro компонента, 1 < i < п, есть натуральное
278 Глава 7. Проблема тупиков и методы борьбы с ними число которое определяет количество маркеров (меток) в позиции рь то есть Мо = (^о(Р1), т0(р2), ...тй(рп)У, М= т(р2), ...т(р„У), где Z — множество неотрицательных целых чисел. Маркиров- ку М можно представлять и как множество или комплект с той лишь только раз- ницей, что отсутствие некоторого элемента в множестве будем обозначать специ- альным элементом — нулем. В этом случае запись вида - I(t) означает разность множеств и такое изменение маркировки, при котором на соответст- вующих местах вектора будут уменьшенные значения. Передвижение маркеров по сети осуществляется посредством срабатывания ее переходов. Срабатывание возбужденного перехода, являющееся локальным ак- том, в целом ведет к изменению маркировки сети, то есть к изменению ее состоя- ния. Таким образом, если в сети задано начальное маркирование Мо, при кото- ром хотя бы один переход возбужден, то в ней начинается движение маркеров, отображающее смену состояний сети. Переход tj может сработать, если Piel(tj) : m(pi) > (р(, - w. Переход, для которого выполняется это условие, называется возбужденным. Здесь запись вида #(pf, I(tj)) означает число появлений позиций р, во входном комплекте перехода Z); оно, естественно, равно йесу w, если вместо мультиграфа рассматривать взвешенный граф. При срабатывании перехода tj маркировка MQ изменяется на маркировку следующим образом: Mr = MQ - I(tj) + O(tj). Иначе говоря, : т{(рд = mQ(pi) - #(pb I(tj)) + #(рь Из последнего выражения видно, что количество маркеров, которое переход tj изымает из своих входных позиций, может не равняться количеству маркеров, которое этот переход помещает в свои выходные позиции, так как совсем не обя- зательно, чтобы число входных дуг перехода равнялось числу его выходных дуг. В графическом представлении сетей (оно наиболее наглядно и легко интерпре- тируемо) переходы изображаются вертикальными (или горизонтальными) план- ками (черточками), а позиции — кружками (рис. 7.5). Условия-позиции и события-переходы связаны отношением непосредственной зависимости (непо- средственной причинно-следственной связи), которое изображается с помощью направленных дуг, ведущих из позиций в переходы и из переходов в позиции. Позиции, из которых ведут дуги на данный переход, называются его входными позициями, а позиции, на которые ведут дуги из данного перехода, — выходны- ми позициями. Выполнение условия изображается разметкой соответствующей позиции, а имен- но помещением числа п или изображением п маркеров (фишек) в то место, где п > 0 — емкость условия. Сети Петри могут быть использованы с точки зрения анализа системы на воз- можность возникновения в ней тупиковых ситуаций. Этот анализ проводится посредством исследования пространства возможных состояний сети Петри. При этом под последним понимается множество возможных маркировок сети. Ана-
Формальные модели для изучения проблемы тупиковых ситуаций 279 лиз сетей посредством матричных методов имеет множество проблем, поэтому в основном используется подход, основанный на построении редуцированного до дерева1 графа возможных маркировок [49]. В таком дереве вершины графа — это состояния (маркировки) сети, а ветви дерева, помеченные соответствующи- ми переходами сети, — это возможные изменения состояний сети, то есть сраба- тывания ее переходов. Если взять любую вершину в таком дереве (за исключе- нием корневой), то путь к этой вершине от корня дерева (путь из начальной маркировки к заданной) будет представлять собой последовательность срабаты- вания переходов. Говорят, что переход tj для разметки М является живым, если для всех разме- ток М' е о(М) существует последовательность срабатывания переходов, которая приводит к маркировке М', при которой переход tj может сработать. Сеть Петри называется живой, если все ее переходы живы; живучая разметка — это разметка, при которой каждый из ее переходов сможет запускаться бесконечное число раз. Когда достигнута такая разметка, при которой ни один из переходов не может быть запущен, говорят, что сеть Петри завершилась (достигнута желаемая ко- нечная маркировка) или же зависла (то есть имеет место тупиковая ситуация). Сети Петри очень удобны для описания процессов синхронизации и альтерна- тив. Например, семафор может быть представлен входной позицией, связанной с несколькими взаимоисключающими переходами (критическими секциями). Сети Петри позволяют моделировать асинхронность и недетерминизм парал- лельных независимых событий, параллелизм конвейерного типа, конфликтные взаимодействия между процессами. Сети Петри очень удобны для описания про- цессов синхронизации и альтернатив. Например, семафор может быть представ- лен входной позицией, связанной с несколькими взаимоисключающими перехо- дами (критическими секциями). Говорят, что два перехода конфликтуют, если они взаимно исключают друг друга, то есть они не могут быть оба запущены одновременно. Два перехода, готовые к срабатыванию, находятся в конфликте, если они связаны с общей входной позицией. В качестве примера рассмотрим рис. 7.5. Эта сеть соответствует рассмотренному нами ранее примеру тупиковой ситуа- ции (см. рис. 7.2), которая возникает при взаимодействии процессов ПР1 и ПР2 во время передачи сообщений и потреблении ресурса R каждым из процессов. Начальная маркировка для сети, показанной на рис. 7.5, будет равна (1,0,0,0,0,4, 0,0,1,0,0,0,0). Здесь позиция р2 означает, что процесс ПР1 получил три единицы ресурса R. Дуга, соединяющая позицию р6 (число маркеров в ней соответству- ет количеству доступных единиц ресурса R), имеет вес 3 и при срабатывании пе- рехода процесс ПР1 получает затребованные 3 единицы ресурса. Переход t2 представляет посылку процессом ПР1 сообщения для ПР2; переход tj — прием этого сообщения. Появление маркера в позиции р7 означает, что процесс ПР2 обработал сообщение и послал ответ процессу ПР1. Срабатывание перехода tj представляет возврат в систему трех единиц ресурса, которыми владел процесс ПР1. Рассмотренная сеть не является живой, так как в ней всегда будут мертвы переходы t3, tj, t8, t7nt8. 1 Напомним, что деревом в теории графов называют граф, не имеющий циклов.
280 Глава 7. Проблема тупиков и методы борьбы с ними Рис- 7-5. Сеть Петри для системы двух взаимодействующих процессов Рис. 7.6. Сеть Петри для тупиковой ситуации на ресурсах типа SR Примеру тупиковой ситуации, возникающему при работе с ресурсами типа SR, который мы также уже рассматривали ранее (см. рис. 7.3), соответствует сеть Петри, показанная на рис. 7.6.
Формальные модели для изучения проблемы тупиковых ситуаций 281 В этой сети номера переходов соответствуют отмеченным номерам операторов, которые выполняют процессы ПР1 и ПР2, а позиции рх ир2 — семафорам и 52, над которыми выполняются Р- и V-операции. Сеть на рис. 7.6 также не является живой, хотя для нее и существуют такие последовательности срабатывания пе- реходов, что тупиковой ситуации не наступит. Алгоритм построения дерева достижимости изложен, например, в работе [64]. Вычислительные схемы Вычислительная схема — это представление в графической форме асинхронной системы, состоящей из набора операторов (процессов), которые воздействуют на множество «регистов» (данных). Каждая вычислительная схема определяется с помощью двух графов: графа потока данных и графа управления [89]. Граф потока данных (информационный граф) определяет входные и выходные данные для каждого оператора [18]. Дуга (R, Sk) от регистра R, к оператору Sk означает, что данные Rj являются элементом входных данных этого оператора; дуга (Sk Rj) определяет данные Rj как выходные. Очевидно, что некоторые дан- ные R могут являться выходными для оператора Sj и входными для оператора Sr Пример графа потока данных для некоторой вычислительной схемы представ- лен на рис. 7.7, а\ операторы и регистры данных представлены соответственно кружками и прямоугольниками. Рис. 7.7. Пример вычислительной схемы: а — граф потока данных; б — граф управления
282 Глава 7. Проблема тупиков и методы борьбы с ними Граф управления определяет последовательность выполнения операторов. Каж- дый оператор (представлен кружком) связан с некоторым количеством управ- ляющих счетчиков (представлены прямоугольниками). Каждый из управляющих счетчиков содержит неотрицательное целое число. Текущие значения счетчиков совместно со значениями данных на графе потока данных определяют состоя- ние вычислительной схемы. Пример графа управления представлен на рис. 7.7, б. Если все счетчики, указывающие на оператор S (то есть входные счетчики), име- ют ненулевые значения, то говорят, что оператор S определен. В этом случае он может выполниться, изменив свои выходные регистры в соответствии с графом потока данных и изменив счетчики графа управления по следующему правилу: значения всех выходных счетчиков оператора S уменьшаются на единицу, а зна- чение выходных — увеличивается, причем для каждого выходного счетчика опе- ратора S приращение может быть свое, персональное, и определяется оно с по- мощью специальной функции от значений регистров. Обратите внимание на сходство между графом управления и сетью Петри. Если под операторами и счетчиками понимать переходы и позиции, то единственным существенным различием между этими моделями будет зависимость прираще- ния счетчика от входных данных оператора S. Такая последовательность операторов Sb S2, ... , Sn, ..., что каждый оператор Sj определен (то есть его входные счетчики не равны нулю) при тех значениях счетчиков, которые получаются в результате выполнения предшествующих опе- раторов, называется последовательностью исполнения схемы. Поскольку с опе- раторами не связано никакого особого отсчета времени (подобно сетям Петри1), то порядок, в котором операторы будут выполняться, не всегда может быть пред- сказан. Любая допустимая последовательность исполнения является возможной последовательностью событий. Как мы уже знаем, для системы взаимодействую- щих параллельных процессов результаты вычислений зависят от последователь- ности исполнения, если не обеспечить взаимное исключение для критических интервалов. В случае когда вычислительная схема вырабатывает одинаковые результаты для всех допустимых последовательностей исполнения, говорят, что она детерминирована. Схема на рис. 7.7 является детерминированной. Рассмотрим вычислительную схему на рис. 7.8. Операторы Sj и S2, как это видно из графа управления, выполняются параллельно и асинхронно. Очевидно, что значение регистра R3 будет различным в зависимости от того, выполняется ли оператор St раньше или позже оператора S2. Поскольку граф управления здесь допускает последовательности исполнения, которые приводят к различным ре- зультатам, то эта схема не детерминирована. Говорят, что два оператора соперничают в регистре R, если один из них изменяет R, а другой либо изменяет R, либо обращается к нему. Если два оператора, которые 1 Следует заметить, что к настоящему времени появилось большое количество различных модификаций исходной модели, называемой сетью Петри. Многие расширения и моди- фикации базовой модели сетей Петри позволяют учитывать модельное время, дополни- тельные условия возбуждения и срабатывания переходов, отслеживать атрибуты при маркерах, их изменения и т. д., что позволяет получать более мощные средства моделиро- вания параллельных процессов.
Формальные модели для изучения проблемы тупиковых ситуаций 283 соперничают в некотором регистре, могут быть выполнены в одно и то же время, то говорят, что в схеме существует условие соперничества и такая схема являет- ся недетерминированной. Одна из возможных форм недетерминированного ис- полнения заключается в том, что схема может «зависнуть» (попасть в тупиковую ситуацию). Рис. 7.8- Пример недетерминированной вычислительной схемы К сожалению, вычислительные схемы, как и сети Петри, не являются конструк- тивной моделью (с точки зрения борьбы с тупиковыми ситуациями, возникаю- щими в операционных системах), несмотря на свою интуитивную привлекатель- ность и возможность сделать вывод о возможности существования тупиков в той или иной системе [89]. Мы знаем, что возможность существования тупиковой ситуации в большинстве ОС существует. Но ведь это же не означает, что эти ОС нельзя использовать. Важнее уметь обнаружить существование тупиковой си- туации в конкретный момент времени и поправить ситуацию (насколько это возможно). Поэтому гораздо более продуктивной с этой точки зрения является модель Холта. Модель пространства состояний системы Приведем еще одну формальную модель (она подробно рассмотрена в работе [92]). Эта модель очень проста, однако она позволяет сформулировать, что нам нужно делать, чтобы не попасть в тупиковое состояние. Пусть состояние операционной системы будет сводиться к состоянию различ- ных ресурсов в системе (свободны они или кому-то распределены). Состояние системы изменяется процессами, когда они запрашивают, приобретают или ос- вобождают ресурсы; это будут единственно возможные действия (точнее, мы принимаем во внимание только такие действия). Если процесс не блокирован в данном состоянии, он может изменить это состояние на новое. Однако, так как в общем случае невозможно знать заранее, какой путь может избрать произ- вольный процесс в своей программе (это неразрешимая проблема!), то новое со- стояние может быть любым из конечного числа возможных. Следовательно, про- цессы нами будут трактоваться как недетерминированные объекты. Введенные
284 Глава 7. Проблема тупиков и методы борьбы с ними ограничения на известные понятия приводят нас к следующим формальным оп- ределениям: □ Система есть пара <о, тс>, где и — множество состояний системы {Sb S2, S3,... , Sn}; тс — множество процессов {Pb P2, P3, ..., Pk}. □ Процесс Pj есть частичная функция, отображающая состояние системы в не- пустые подмножества ее же состояний. Это обозначается так: Р,: о -> {о} Если Р; определен на состоянии S, то область значений Pj обозначается как Pi(S). Если Sk g Pi(Se), то мы говорим, что Pi может изменить состояние Se в со- стояние Sk посредством операции, и используем обозначение Se —%—> Sk. Наконец, запись Se —-—> Sw обозначает, что Se = Sw или Se ——> Sw для некоторого i или Se ——> Sk для некоторого i и Sk, и Sk —-—> Sw. Другими словами, система может быть переведена посредством п > 0 операций с помощью т > 0 различных процессов из состояния Se в состояние Sw. Мы говорим, что процесс заблокирован в данном состоянии, если он не может изменить состояние, то есть в этом состоянии процесс не может ни затребовать, ни получать, ни освобождать ресурсы. Поэтому справедливо будет записать сле- дующее: □ Процесс Pi заблокирован в состоянии Se, если не существует ни одного со- стояния Sk, такого что Se ——>Sk (Pj(Se) = 0). Далее, мы говорим, что процесс находится в тупике в данном состоянии Se, если он заблокирован в состоянии Se и если вне зависимости от того, какие операции (изменения состояний) произойдут в будущем, процесс остается заблокирован- ным. Поэтому дадим еще одно определение: □ Процесс Pi находится в тупике в состоянии Se> если для всех состояний Sk, та- ких что Se —-—>Sk, процесс Pj блокирован в Sk. Приведем пример. Определим систему <о, тс>: о = {Sb S2, S3, S4}; тс = {Pb P2}; Pi(Si) = {S2, S3}; P2(Si) = {S3}; Pi(S2) = 0 ; P2(S2) = {Sb S4}; Pi(S3) = {S4}; P2(S3) = 0 ; Pi(S4) = {S3}; P2(S4) = 0. Некоторые возможные последовательности изменений для этой системы таковы: St —> S3; S2 —£—> S4; St —S4.
Формальные модели для изучения проблемы тупиковых ситуаций 285 Последовательность Sj —-—> S4 может быть реализована, например, следующим образом: St —5—> S2; S2 ——> S4 или же St ——> S3; S3 —%—> S4. Заметим, что процесс Р2 находится в тупике как в состоянии S3, так и в состоя- нии S4; для последнего случая S2 —> S4 или S2 ——> Si и процесс Pt не ста- новится заблокированным ни в S4, ни в St. Диаграмма переходов этой системы изображена на рис. 7.9. Рис. 7.9. Пример системы <сг, л> на 4 состояния □ Состояние S называется тупиковым, если существует процесс Рь находящий- ся в тупике в состоянии S. Теперь мы можем сказать, что тупик предотвращается, по определению, при вве- дении такого ограничения на систему, чтобы каждое ее возможное состояние не было тупиковым состоянием. Введем еще одно определение. □ Состояние Sj есть безопасное состояние, если для всех Sk, таких что S, —*—> Sk, Sk не является тупиковым состоянием. Рассмотрим еще один пример системы <сг, л>. Граф ее состояний приведен на рис. 7.10. Здесь состояния S2 и S3 являются безопасными; из них система нико- гда не сможет попасть в тупиковое состояние. Состояния St и S4 могут привести как к безопасным состояниям, так и к опасному состоянию S5. Состояние S6 и S7 является тупиковым. Рис. 7.10. Пример системы <сг, я> с безопасными, опасными и тупиковым состояниями Наконец, в качестве еще одной простейшей системы вида <сг, л> приведем при- мер тупика с SR-ресурсами, уже рассмотренный нами в этой главе ранее. Опре-
286 Глава 7. Проблема тупиков и методы борьбы с ними делим следующим образом состояния процессов Pt и Р2, которые используют ре- сурсы Rj и R2. Таблица 7.1. Состояния процессов Рт и Р2 Состояния для процесса Р4 Состояния для процесса Р2 0 Не содержит никаких ресурсов 0 Не содержит никаких ресурсов 1 Запросил ресурс R2, не держит никаких ресурсов 1 Запросил ресурс Rb не держит никаких ресурсов 2 Держит ресурс R2 2 Держит ресурс Ri 3 Запросил ресурс Rt, держит ресурс R2 3 Запросил ресурс R2, держит ресурс Ri 4 Держит ресурсы Ri и R2 4 Держит ресурсы Ri и R2 5 Держит ресурс R2 после освобождения ресурса Ri 5 Держит ресурс R2 после освобождения ресурса Ri Пусть состояние системы Sy означает, что процесс Pt находится в состоянии Sj, а процесс Р2 — в состоянии Sj. Возможные изменения в пространстве состояний этой системы изображены на рис. 7.11. «Вертикальными» стрелками показаны возможные переходы из одного состояния в другое для процесса Pt, а «горизон- тальными» — для процесса Р2. В данной системе имеются три опасных состоя- ния. Попав в любое из них, мы неминуемо перейдем в тупиковое состояние. Рис. 7.11. Пример системы
Методы борьбы с тупиками 287 Теперь, когда мы знаем понятия надежного, опасного и безопасного состояний, можно рассмотреть и методы борьбы с тупиками. Методы борьбы с тупиками Проблема тупиков является чрезвычайно серьезной и сложной. В настоящее вре- мя разработано несколько подходов и методов разрешения этой проблемы, одна- ко ни один из них нельзя считать панацеей. В некоторых случаях цена, которую приходится платить за то, чтобы сделать систему свободной от тупиков, слиш- ком высока. В других случаях, например в системах управления процессами ре- ального времени, просто нет иного выбора, поскольку возникновение тупика мо- жет привести к катастрофическим последствиям. Проблема борьбы с тупиками становится все более актуальной и сложной по мере развития и внедрения параллельных вычислительных систем. При проектирова- нии таких систем разработчики стараются проанализировать возможные непри- ятные ситуации, используя специальные модели и методы. Борьба с тупиковыми ситуациями основывается на одной из трех стратегий: □ предотвращение тупика; □ обход тупика; □ распознавание тупика с последующим восстановлением. Предотвращение тупиков Предотвращение тупика основывается на предположении о чрезвычайно высо- кой его стоимости, поэтому лучше потратить дополнительные ресурсы системы, чтобы исключить вероятность возникновения тупика при любых обстоятельствах. Этот подход используется в наиболее ответственных системах, часто это систе- мы реального времени. Предотвращение можно рассматривать как запрет существования опасных состоя- ний. Поэтому дисциплина, предотвращающая тупик, должна гарантировать, что какое-либо из четырех условий, необходимых для его наступления, не может возникнуть. Условие взаимного исключения можно подавить путем разрешения неограничен- ного разделения ресурсов. Это удобно для повторно входимых программ и ряда драйверов, но совершенно неприемлемо к совместно используемым переменным в критических интервалах. Условие ожидания можно подавить, предварительно выделяя ресурсы. При этом процесс может начать исполнение, только получив все необходимые ресурсы заранее. Следовательно, общее число затребованных параллельными процессами ресурсов должно быть не больше возможностей системы. Поэтому предвари- тельное выделение может привести к снижению эффективности работы вычис- лительной системы в целом. Необходимо также отметить, что предварительное выделение зачастую невозможно, так как необходимые ресурсы становятся из- вестны процессу только после начала исполнения.
288 Глава 7. Проблема тупиков и методы борьбы с ними Условие отсутствия перераспределения можно исключить, позволяя операцион- ной системе отнимать у процесса ресурсы. Для этого в операционной системе должен быть предусмотрен механизм запоминания состояния процесса с целью последующего восстановления. Перераспределение процессора реализуется дос- таточно легко, в то время как перераспределение устройств ввода/вывода крайне нежелательно. Условие кругового ожидания можно исключить, предотвращая образование цепи запросов. Это можно обеспечить с помощью принципа иерархического выделения ресурсов. Все ресурсы образуют некоторую иерархию. Процесс, затребовавший ресурс на одном уровне, может затем потребовать ресурсы только на более высо- ком уровне. Он может освободить ресурсы на данном уровне только после осво- бождения всех ресурсов на всех более высоких уровнях. После того как процесс получил, а потом освободил ресурсы данного уровня, он может запросить ресур- сы на том же самом уровне. Пусть имеются процессы ПР1 и ПР2, которые могут иметь доступ к ресурсам R1 и R2, причем R2 находится на более высоком уровне иерархии. Если ПР1 захватил R1, то ПР2 не может захватить R2, так как доступ к нему проходит через доступ к R1, который уже захвачен ПР1. Таким образом, создание замкнутой цепи исключается. Иерархическое выделение ресурсов час- то не дает никакого выигрыша, если порядок использования ресурсов, опреде- ленный в описании процессов, отличается от порядка уровней иерархии. В этом случае ресурсы будут использоваться крайне неэффективно. В целом стратегия предотвращения тупиков — это очень дорогое решение про- блемы, и она используется нечасто. Обход тупиков Обход тупика можно интерпретировать как запрет входа в опасное состояние. Если ни одно из упомянутых четырех условий не исключено, то вход в опасное состояние можно предотвратить при наличии у системы информации о последо- вательности запросов, связанных с каждым параллельным процессом. Доказано [92], что если вычисления находятся в любом неопасном состоянии, то сущест- вует по крайней мере одна последовательность состояний, которая обходит опас- ное. Следовательно, достаточно проверить, не приведет ли выделение затребо- ванного ресурса сразу же к опасному состоянию. Если да, то запрос отклоняется. Если нет, его можно выполнить. Определение того, является ли состояние опас- ным или нет, требует анализа последующих запросов процессов. Рассмотрим следующий пример. Пусть имеется система из трех вычислительных процессов, которые потребляют некоторый ресурс R типа SR, который выделя- ется дискретными взаимозаменяемыми единицами, причем существует всего де- сять единиц этого ресурса. В табл. 7.2 приведены сведения о текущем распреде- лении процессами этого ресурса R, об их текущих запросах на этот ресурс и о максимальных потребностях процессов в ресурсе R. Последний столбец в табл. 7.2 показывает, сколько еще единиц ресурса может затребовать каждый из процессов, если получит ресурс на свой текущий запрос. Если запрос процесса А будет удовлетворен первым, то он в принципе может за- просить еще одну единицу ресурса R, и уже в этом случае мы тогда получим ту-
Методы борьбы с тупиками 289 пиковую ситуацию, поскольку ни один из процессов не сможет продолжить свои вычисления. Следовательно, при выполнении запроса процесса А мы попадаем в ненадежное1 состояние. Таблица 7.2. Пример распределения ресурсов Имя процесса Выделено Запрос Максимальная потребность «Остаток» потребностей А 2 3 6 1 В 3 2 7 2 С 2 3 5 0 Если первым будет выполнен запрос процесса В, то у нас останется свободной еще одна единица ресурса R. Однако если процесс В запросит еще две, а не одну единицу ресурса R, а он может это сделать, то мы опять получим тупиковую си- туацию. Если же мы сначала выполним запрос процесса С и выделим ему не две (как у процесса В), а все три единицы ресурса R и у нас при этом даже не останется никакого резерва, то, поскольку на этом его потребности в ресурсах заканчива- ются, процесс С сможет благополучно завершиться и вернуть системе все свои ресурсы. Это приведет к тому, что свободное количество ресурса R станет равно пяти. Теперь уже можно будет выполнить запрос либо процесса В, либо процес- са А, но не обоих сразу. Часто бывает так, что последовательность запросов, связанных с каждым про- цессом, неизвестна заранее. Но если заранее известен общий запрос на ресур- сы каждого типа, то выделение ресурсов можно контролировать. В этом случае необходимо для каждого требования, предполагая, что оно удовлетворено, опре- делить, существует ли среди общих запросов от всех процессов некоторая после- довательность требований, которая может привести к опасному состоянию. Дан- ный подход является примером контролируемого выделения ресурса. Классическое решение этой задачи известно как алгоритм банкира Дейкстры [89]. Алгоритм банкира напоминает процедуру принятия решения, может ли банк без- опасно для себя дать взаймы денег. Принятие решения основывается на инфор- мации о потребностях клиента (текущих и максимально возможных) и учете те- кущего баланса банка. Несмотря на то что этот алгоритм нигде практически не используется, рассмотрим его, так как он интересен с методической и академиче- ской точек зрения. Текст программы алгоритма банкира приведен в листинге 7.4. Пусть существует N процессов, для каждого из которых известно максимальное количество потребностей в некотором ресурсе R (обозначим эти потребности че- 1 Термин «ненадежное состояние» не предполагает, что в данный момент существует или в какое-то время обязательно возникнет тупиковая ситуация. Он просто говорит о том, что в случае некоторой неблагоприятной последовательности событий система может зайти в тупик.
290 Глава 7. Проблема тупиков и методы борьбы с ними рез Мах(1)). Ресурсы выделяются не сразу все, а в соответствии с текущим запро- сом. Считается, что все ресурсы i-ro процесса будут освобождены по его завер- шении. Количество полученных ресурсов для i-ro процесса обозначим Получ(1). Остаток в потребностях i-ro процесса на ресурс R обозначим через ОстатокО). Признак того, что процесс может не завершиться — это значение false для пере- менной Завершен. Наконец, переменная Своб_рес будет означать количество сво- бодных единиц ресурса R, а максимальное количество ресурсов в системе опре- делено значением Всего_рес. Листинг 7.4. Алгоритм банкира Дейкстры Begin Своб_рес := Всего_рес; For 1 := 1 to N do Begin Своб_рес := Своб_рес - Получ(1) Остатокй) := Мах(1) - Получ(1): Заверш(1) := false: { процесс может не завершиться } end flag := true: { признак продолжения анализа } while flag do begin flag := false: for i := 1 to N do begin if ( not ( Завершен )) and ( Остаток(1) <- Своб_рес ) then begin Завершен : = true: Своб_рес := Своб_рес + Получ(Н: Flag := true End End End: If Своб_рес = Bcero_pec then Состояние системы безопасное и можно выдать ресурс else Состояние не безопасное и выдавать ресурс нельзя end. Каждый раз, когда какой-то остаток может быть выделен из числа остающихся незанятыми ресурсов, предполагается, что соответствующий процесс работает, пока не окончится, а затем его ресурсы освобождаются. Если в конце концов все ресурсы освобождаются, значит, все процессы могут завершиться и система на- ходится в безопасном состоянии. Другими словами, согласно алгоритму банкира система удовлетворяет только те запросы, при которых ее состояние остается на- дежным. Новое состояние безопасно тогда и только тогда, когда каждый процесс все же может окончиться. Именно это условие и проверяется в алгоритме банки-
Методы борьбы с тупиками 291 ра. Запросы процессов, приводящие к переходу системы в ненадежное состоя- ние, не выполняются и откладываются до момента, когда его все же можно будет выполнить. Алгоритм банкира позволяет продолжать выполнение таких процессов, которым в случае системы с предотвращением тупиков пришлось бы ждать. Хотя алго- ритм банкира относительно прост, его реализация может обойтись довольно до- рого. Основным накладным расходом стратегии обхода тупика с помощью кон- тролируемого выделения ресурса является время выполнения алгоритма, так как он выполняется при каждом запросе. Причем алгоритм работает медленнее всего, когда система близка к тупику. Необходимо отметить, что обход тупика неприменим при отсутствии информации о требованиях процессов на ресурсы. Рассмотренный алгоритм примитивен, в нем учитывается только один вид ре- сурса, тогда как в реальных системах количество различных типов ресурсов бы- вает очень большим. Были опубликованы варианты этого алгоритма для боль- шого числа различных типов системных ресурсов. Однако все равно алгоритм не получил распространения. Причин тому несколько: □ Алгоритм исходит из того, что количество распределяемых ресурсов в систе- ме фиксировано, постоянно. Иногда это не так, например вследствие неис- правности отдельных устройств. □ Алгоритм требует, чтобы пользователи заранее указывали свои максималь- ные потребности в ресурсах. Это чрезвычайно трудно реализовать. Часть та- ких сведений, конечно, могла бы подготавливать система программирования, но все равно часть информации о потребностях в ресурсах должны давать пользователи. Однако, поскольку компьютеры становятся все более дружест- венными по отношению к пользователям, все чаще встречаются пользовате- ли, которые не имеют ни малейшего представления о том, какие ресурсы им потребуются. □ Алгоритм требует, чтобы число работающих процессов оставалось постоян- ным. Это возможно только для очень редких случаев. Очевидно, что выпол- нение этого требования в общем случае не реально, особенно в мультитер- минальных системах либо если пользователь может запускать по несколько процессов параллельно. Обнаружение тупика Чтобы распознать тупиковое состояние, необходимо для каждого процесса опре- делить, сможет ли он когда-либо снова развиваться, то есть изменять свои со- стояния. Так как нас интересует возможность развития процесса, а не сам про- цесс смены состояния, то достаточно рассмотреть только самые благоприятные изменения состояния. Очевидно, что незаблокированный процесс (он только что получил ресурс и по- этому не заблокирован) через некоторое время освобождает все свои ресурсы и затем благополучно завершается. Освобождение ранее занятых ресурсов может «разбудить» некоторые ранее заблокированные процессы, которые, в свою оче- редь, развиваясь, смогут освободить другие ранее занятые ресурсы. Это может
292 Глава 7. Проблема тупиков и методы борьбы с ними продолжаться до тех пор, пока либо не останется ^заблокированных процессов, либо какое-то количество процессов все же останется заблокированными. В по- следнем случае (если существуют заблокированные процессы при завершении указанной последовательности действий) начальное состояние S является состоя- нием тупика, а оставшиеся процессы находятся в тупике в состоянии S. В про- тивном случае S не есть состояние тупика. Обнаружение тупика посредством редукции графа повторно используемых ресурсов Наиболее благоприятные условия для незаблокированного процесса Pj могут быть представлены редукцией (сокращением) графа повторно используемых ресурсов (см. первый раздел данной главы, описание модели Холта). Редукция графа мо- жет быть описана следующим образом: □ Граф повторно используемых ресурсов сокращается процессом Рь который не является ни заблокированной, ни изолированной вершиной, с помощью удаления всех ребер, входящих в вершину Pi и выходящих из Pj. Эта процеду- ра является эквивалентной приобретению процессом Pj неких ресурсов, на которые он ранее выдавал запросы, а затем освобождению всех его ресурсов. Тогда Pj становится изолированной вершиной. □ Граф повторно используемых ресурсов несокращаем (не редуцируется), если он не может быть сокращен ни одним процессом. □ Граф ресурсов типа RS является полностью сокращаемым, если существует последовательность сокращений, которая удаляет все дуги графа. Приведем лемму, которая позволяет предложить алгоритмы обнаружения тупика. Лемма. Для ресурсов типа SR порядок сокращений несуществен; все последова- тельности ведут к одному и тому же несокращаемому графу. Доказательство. Допустим, что лемма неверна. Тогда должно существовать не- которое состояние S, которое сокращается до некоторого несокращаемого со- стояния St с помощью последовательности seqj и до несокращаемого состояния S2 — с помощью последовательности seq2 так, что S2 (то есть все процессы в состояниях StH S2 или заблокированы, или изолированы). Если сделать такое предположение, то мы приходим к противоречию, которое устраняется только при условии, что St = S2. Действительно, предположим, что последовательность seqt состоит из упорядоченного списка процессов (Рь Р2, ..., Рк). Тогда последовательность seqt должна содержать процесс Р, который не содержится в последовательности seq2. В противном случае = S2, потому что редукция графа только удаляет дуги, уже существующие в состоянии S, а если последовательности seql и seq2 содержат одно и то же множество процес- сов (пусть и в различном порядке), то должно быть удалено одно и то же множе- ство дуг. И доказательство по индукции покажет, что Р * Pi? (i = 1, 2,..., к) приво- дит к указанному нами противоречию. □ Р * Рь так как вершина S может быть редуцирована процессом Рь а состоя- ние S2 должно, следовательно, также содержать процесс Рь
Методы борьбы с тупиками 293 □ Пусть Р * Рь (i = 1, 2,j). Однако, поскольку после редукции процессами Рь (i = 1, 2, ..., j) возможно еще сокращение графа процессом Pj+1, это же самое должно быть справедливо и для последовательности seq2 независимо от по- рядка следования процессов. То же самое множество ребер графа удаляется с помощью процесса Р,. Таким образом, Р Pj+1. Следовательно, Р ф Р, для i = 1,2,..., к и Р не может существовать, а это противо- речит нашему предположению, что S2. Следовательно, St = S2. Теорема о тупике. Состояние S есть состояние тупика тогда и только тогда, когда граф повторно используемых ресурсов в состоянии S не является полностью со- кращаемым. Доказательство. а) Предположим, что состояние S есть состояние тупика и процесс Pj находится в тупике в S. Тогда для всех Sj, таких что S —-—> Sj, процесс Р, заблокирован в Sj (по определению). Так как сокращения графа идентичны для серии опе- раций процессов, то конечное несокращаемое состояние в последовательно- сти сокращений должно оставить процесс Pi блокированным. Следовательно, граф не является полностью сокращаемым. б) Предположим, что S не является полностью сокращаемым. Тогда существует процесс Pj, который остается заблокированным при всех возможных последо- вательностях операций редукции в соответствие с леммой. Так как любая по- следовательность операций редукции графа повторно используемых ресурсов, оканчивающаяся несокращаемым состоянием, гарантирует, что все ресурсы типа SR, которые могут когда-либо стать доступными, в действительности ос- вобождены, то процесс Pi навсегда заблокирован и, следовательно, находится в тупике. Следствие 1. Процесс Рх не находится в тупике тогда и только тогда, когда серия сокращений приводит к состоянию, в котором Pj не заблокирован. Следствие 2. Если S есть состояние тупика (по ресурсам типа SR), то по крайней мере два процесса находятся в тупике в S. Из теоремы о тупике непосредственно следует и алгоритм обнаружения тупи- ков. Нужно просто попытаться сократить граф по возможности эффективным способом; если граф полностью не сокращается, то начальное состояние было состоянием тупика для тех процессов, вершины которых остались в несокращен- ном графе. Рассмотренная нами лемма позволяет удобным образом упорядочи- вать сокращения. Граф повторно используемых ресурсов может быть представлен или матрицами, или списками. В обоих случаях экономия памяти может быть достигнута ис- пользованием взвешенных ориентированных мультиграфов (слиянием опреде- ленных дуг получения или дуг запроса между конкретным ресурсом и данным процессом в одну дугу с соответствующим весом, определяющим количество единиц ресурса). Рассмотрим вариант с матричным представлением. Поскольку граф двудольный, он представляется двумя матрицами размером цхш. Одна матрица — матрица
294 Глава 7 Проблема тупиков и методы борьбы с ними распределения D = ЦсЦ, в которой элемент dy отражает количество единиц Rj ре- сурса, распределенного процессу Рр то есть dy = |(Rp PJl, где (Rp Pj) — это дуга между вершинами Rj и Рр ведущая из Rj в Рр Вторая матрица — матрица запро- сов N = IlnJI, где n,j - |(Р„ Rj)|. В случае использования связанных списков для отображения той же структу- ры можно построить две группы списков. Ресурсы, распределенные некоторому процессу Рр связаны с Pj указателями: Pj----->(RX, dx)----->(Ry, dy)---->...--->(RZ, dz), где Rj — вершина, представ- ляющая ресурс, и dj — вес дуги, то есть dj « |(Rp PJl. Подобным образом и ресурсы, запрошенные процессом Рр связаны вместе. Аналогичные списки создаются и для ресурсов (списки распределенных и запро- шенных ресурсов). Rj ^(Pu> nu) ^(Pv> nv) ••• >(Pw,nw), ГД6 Hj = |(Pj, R1)|. Для обоих представлений удобно также иметь одномерный массив доступных единиц ресурсов (гь г2,..., гт), где указывает число доступных (нераспределен- ных единиц ресурса Rp то есть = |Rj — Z|(RP Pk)|. Простой метод прямого обнаружения тупика заключается в просмотре по поряд- ку списка (или матрицы) запросов, причем, где возможно, производятся сокра- щения дуг графа до тех пор, пока нельзя будет сделать более ни одного сокраще- ния. При этом самая плохая ситуация возникает, когда процессы упорядочены в некоторой последовательности Рь Р2, ... , Рп, а единственно возможным поряд- ком сокращений является обратная последовательность, то есть Pn, Рп.ь ..., Р2, Рь а также в случае, когда процесс запрашивает все ш ресурсов. Тогда число прове- рок процессов равно п + (п-1) + ... + 1 = пх(п+1)/2, причем каждая проверка требует испытания m ресурсов. Таким образом, время выполнения такого алгоритма в наихудшем случае пропорционально mxn2. Более эффективный алгоритм может быть получен за счет хранения некоторой дополнительной информации о запросах. Для каждой вершины процесса Pj оп- ределяется так называемый счетчик ожиданий wp отображающий количество ре- сурсов (не число единиц ресурса), которые в какое-то время вызывают блоки- ровку процесса. Кроме этого, можно сохранять для каждого ресурса запросы, упорядоченные по размеру (числу единиц ресурса). Тогда следующий алгоритм сокращений, записанный на псевдокоде, имеет максимальное время выполнения, пропорциональное mxn. For all Pel do Begin for all Ra э |(Ra P)| >0 do Begin ra = ra + | (Ra.P) |. For all P, ?0< |(P1.RJ)| <e ra do Begin = w1 - 1. If w3 » 0 then L = L U {Pi} End End
Методы борьбы с тупиками 295 End Deadlock : = Not (L = {Pl. P2. Pn}); Здесь L — это текущий список процессов, которые могут выполнять редукцию графа. Можно сказать, что L := {Pi | Wj = 0}. Программа выбирает процесс Р из списка L, процесс Р сокращает граф, увеличивая число доступных единиц г, всех ресурсов Rj, распределенных процессу Р, обновляет счетчик ожидания Wj каждо- го процесса Р,, который сможет удовлетворить свой запрос на частный ресурс Rj, и добавляет Р, к L, если счетчик ожидания становится нулевым. Методы обнаружения тупика по наличию замкнутой цепочки запросов Структура графа обеспечивает простое необходимое (но не достаточное) усло- вие для тупика. Для любого графа G = <Х, Е> и вершины х е X пусть Р(х) обо- значает множество вершин, достижимых из вершины х, то есть Р(х) = { у I (х, у) е Е } и { z I (у, z) е Е & у е Р(х) }. Можно сказать, что в ориентированном графе потомством вершины х, которое мы обозначаем как Р(х), называется множество всех вершин, в которые ведут пути из х. Тогда если существует некоторая вершина х g X : х е Р(х), то в графе G имеется цикл. Теорема 1. Цикл в графе повторно используемых ресурсов является необходи- мым условием тупика. Для доказательства этой теоремы (которое мы опустим, указав, что при жела- нии его можно найти в работе [45]) можно воспользоваться следующим свойст- вом ориентированных графов: если ориентированный граф не содержит цикла, то существует линейное упорядочение вершин, такое, что если существует путь от вершины i к вершине], то i появляется перед] в этом упорядочении. Теорема 2. Если S не является состоянием тупика и S ——> ST, где ST есть со- стояние тупика в том и только в том случае, когда операция процесса Pj есть за- прос и Pi находится в тупике в ST. Это следует понимать таким образом, что тупик может быть вызван только при запросе, который не удовлетворен немедленно. Учитывая эту теорему, можно сделать вывод, что проверка на тупиковое состояние может быть выполнена бо- лее эффективно, если она проводится непрерывно, то есть по мере развития про- цессов. Тогда надо применять редукцию графа только после запроса от некото- рого процесса Pj и на любой стадии необходимо сначала попытаться сократить с помощью процесса Р? Если процесс Pj смог провести сокращение графа, то ника- кие дальнейшие сокращения не являются необходимыми. Ограничения, накладываемые на распределители, на число ресурсов, запрошен- ных одновременно, и количество единиц ресурсов, приводят к более простым ус- ловиям для тупика. Пучок (или узел) в ориентированном графе G = <Х, Е> — это подмножество вершин Z с X, таких что V х g Z, Р(х) = Z, то есть потомством каждой вершины
296 Глава 7. Проблема тупиков и методы борьбы с ними из Z является само множество Z. Каждая вершина в узле достижима из каждой другой вершины этого узла, и узел есть максимальное подмножество с этим Следует заметить, что наличие цикла — это необходимое, но не достаточное условие для узла. Так, на рис. 7.13 изображены два подграфа. Подграф а пред- ставляет собой пучок (узел), тогда как подграф б представляет собой цикл, но узлом не является. В узел должны входить дуги, но они не должны из него выхо- дить. Рис. 7.13. Узел и цикл в ориентированном графе Цикл Z={Z1f Z2, Z3, Z4 I Если состояние системы таково, что удовлетворены все запросы, которые могут быть удовлетворены, то существует простое достаточное условие существования тупика. Эта ситуация возникает, если распределители ресурсов не откладывают запросы, которые могут быть удовлетворены, а выполняют их по возможности немедленно (большинство распределителей следует этой дисциплине). Состояние называется фиксированным, если каждый процесс, выдавший запрос, заблокирован.
Методы борьбы с тупиками 297 Теорема 3. Если состояние системы фиксированное (все процессы, имеющие за- просы, удовлетворены), то наличие узла в соответствующем графе повторно ис- пользуемых ресурсов является достаточным условием тупика. Доказательство. Предположим, что граф содержит узел Z. Тогда все процессы в этом узле должны быть заблокированы только по ресурсам, принадлежащим Z, так как никакие ребра не могут выходить из Z по определению. Аналогично, по той же самой причине все распределенные ресурсы узла Z принадлежат процес- сам из Z. Наконец, все процессы в Z должны быть заблокированы согласно усло- вию фиксированности и определению узла. Следовательно, все процессы в узле Z должны быть в тупике. Допустим, что каждый ресурс имеет единичную емкость (по одной единице ре- сурса), то есть |Rj| = 1, (i = 1, 2,..., m). При этом ограничении наличие цикла так- же становится достаточным условием тупика. Теорема 4. Граф повторно используемых ресурсов с единичной емкостью указы- вает на состояние тупика тогда и только тогда, когда он содержит цикл. Доказательство. Необходимость цикла доказывает теорема 1. Для доказательст- ва достаточности допустим, что граф содержит цикл, и рассмотрим только лишь процессы и ресурсы, принадлежащие циклу. Так как каждая вершина-процесс должна иметь входящее и исходящее ребро, она должна иметь выданный запрос на некоторый ресурс, принадлежащий циклу, и должна удерживать некоторый ресурс, принадлежащий тому же циклу. Аналогично, каждый ресурс единичной емкости в цикле должен быть захвачен некоторым процессом. Следовательно, каждый процесс в цикле блокируется на ресурсе, который может быть освобож- ден только некоторым процессом из этого цикла; поэтому процессы в цикле на- ходятся в тупике. Чтобы обнаружить тупик в случае ресурса единичной емкости, мы должны про- сто проверить граф повторно используемых ресурсов на наличие циклов. Алгоритм обнаружения тупика по наличию замкнутой цепочки запросов Итак, распознавание тупика может быть основано на анализе модели распреде- ления ресурсов. Один из алгоритмов, основанный на методе обнаружения замк- нутой цепи запросов, был разработан сотрудниками фирмы IBM; этот алгоритм использовался в одной из ОС этой компании. Он использует информацию о со- стоянии системы, содержащуюся в двух таблицах: таблице текущего распреде- ления (назначения) ресурсов RATBL и «таблице» заблокированных процессов PWTBL (для каждого вида ресурса может быть свой список заблокированных процессов). При каждом запросе на получение или освобождении ресурсов со- держимое этих таблиц модифицируется, а при запросе — анализируется в соот- ветствии со следующим алгоритмом, который описан по пунктам [49]. 1. Запрос от процесса} на занятый ресурс I. 2. Поместить номер ресурса I в PWTBL в строке с номером процесса J. 3. Использовать I в качестве смещения в RATBL, чтобы найти номер процес- са К, который владеет ресурсом.
298 Глава 7. Проблема тупиков и методы борьбы с ними 4. Использовать К в качестве смещения в PWTBL. 5. Проверить, ждет ли процесс К освобождения какого-либо ресурса Г. Если нет, то перейти к шагу 6, в противном случае — к шагу 7. 6. Перевести J в состояние ожидания и выйти из алгоритма. 7. Использовать Г в качестве смещения в RATBL, чтобы найти номер блоки- рующего его процесса К'. 8. Проверить К' = J. Если нет, то перейти к шагу 9, в противном случае — к ша- гу И. 9. Проверить, вся ли таблица PWTBL просмотрена. Если да, то перейти к ша- гу 6, в противном случае — к шагу 10. 10. Присвоить К:= К' и перейти к шагу 4. И. Вывод о наличии тупика с последующим восстановлением. 12. Конец алгоритма. Для иллюстрации описанного алгоритма распознавания тупика рассмотрим сле- дующую последовательность событий: 1. Процесс Р1 занимает ресурс R1. 2. Процесс Р2 занимает ресурс R3. 3. Процесс РЗ занимает ресурс R2. 4. Процесс Р2 занимает ресурс R4. 5. Процесс Р1 занимает ресурс R5. В результате таблица распределения ресурсов (RATBL) имеет следующий вид: Таблица 7.3. Таблица распределения ресурсов RATBL Ресурсы Процессы 1 1 2 3 3 2 4 2 5 1 1. Пусть процесс Р1 пытается занять ресурс R3, поэтому в соответствии с опи- санным алгоритмом} = 1,1 - 3, К - 2. Процесс К не ждет никакого ресурса Г, поэтому процесс Р1 блокируется по ресурсу R3. 2. Далее, пусть процесс Р2 пытается занять ресурс R2. J =3,1 =2, К =3. Процесс К не ждет никакого ресурса, поэтому процесс Р2 блокируется по ре- сурсу R2.
Методы борьбы с тупиками 299 3. И наконец, пусть процесс РЗ пытается обратиться к ресурсу R5. J = 3, I = 5, К = 1, Г= 3, К’= 2, К’<> J, поэтому берем К = 2, Г= 2, К’= 3. В этом случае К' = J, то есть тупик определен. Таблица заблокированных про- цессов (PWTBL) теперь имеет следующий вид. Таблица 7.4. Таблица заблокированных процессов PWTBL Процесс Ресурс 1 3 2 2 3 5 Равенство J = К' означает, что существует замкнутая цепь взаимоисключающих и ожидающих процессов, то есть выполняются все четыре условия существова- ния тупика. Для описанного нами примера можно построить модель Холта; ее изображение приведено на рис. 7.14. На этом рисунке пронумерованы дуги запросов, которые процессы последовательно генерировали в соответствии с нашим примером. Из рисунка сразу видно, что в результате такой последовательности запросов обра- зовалась замкнутая цепочка: (8, 5, 6, 2, 7, 3), что и говорит о существовании ту- пика. Рис. 7.14. Граф распределения ресурсов Распознавание тупика требует дальнейшего восстановления. Восстановление можно интерпретировать как запрет постоянного пребывания в опасном состоянии. Существуют следующие методы восстановления: □ принудительный перезапуск системы, характеризующийся потерей информа- ции обо всех процессах, существовавших до перезапуска; □ принудительное завершение процессов, находящихся в тупике;
300 Глава 7. Проблема тупиков и методы борьбы с ними □ принудительное последовательное завершение процессов, находящихся в ту- пике, с последующим вызовом алгоритма распознавания после каждого за- вершения до исчезновения тупика; □ перезапуск процессов, находящихся в тупике, с некоторой контрольной точ- ки, то есть из состояния, предшествовавшего запросу на ресурс; □ перераспределение ресурсов с последующим последовательным перезапус- ком процессов, находящихся в тупике. Основные издержки восстановления составляют потери времени на повторные вычисления, которые могут оказаться весьма существенными. К сожалению, в ря- де случаев восстановление может стать невозможным: например, исходные дан- ные, поступившие с каких-либо датчиков, могут уже измениться, а предыдущие значения будут безвозвратно потеряны. Контрольные вопросы и задачи Вопросы для проверки 1. Что такое тупиковое состояние? Перечислите условия, при которых возника- ет тупик. 2. Что является причиной возникновения тупиков на SR-pecypcax? 3. Приведите пример графа повторно используемых ресурсов. Что позволяет сделать эта модель Холта? 4. Приведите пример теоретико-множественного описания сети Петри. 5. Что такое маркировка сети Петри? Что представляет собой пространство воз- можных состояний сети Петри? 6. Приведите пример графического представления сети Петри. 7. Что представляет собой «предотвращение тупика»? Как его можно реализо- вать? 8. Что представляет собой «обход тупика»? Приведите алгоритм банкира Дейк- стры. 9. Что такое «опасное состояние»? Приведите пример опасного состояния на мо- дели состояний системы. 10. Изложите метод обнаружения тупика посредством редукции графа повторно- используемых ресурсов. И. Изложите алгоритм обнаружения тупика по наличию замкнутой цепочки за- просов.
ГЛАВА 8 Современные операционные системы В заключение первой части учебника кратко рассмотрим основные архитектур- ные особенности современных ОС, которые используются на ПК типа IBM PC. Прежде всего отметим тот общеизвестный факт, что наиболее популярными яв- ляются ОС семейства Windows компании Microsoft. Это и Windows 95/98, и Win- dows NT, и новое поколение Windows 2000. Однако, поскольку в настоящее вре- мя практически вся литература, связанная с программным обеспечением для ПК (в том числе и по системному программному обеспечению), в той или иной сте- пени, прежде всего, касается ОС этой компании, то в данном случае мы сделаем исключение и не будем описывать ОС Windows. Желающие без труда найдут любую литературу по этому вопросу. Мы же кратко рассмотрим ОС UNIX (не Linux, по которому сейчас тоже появ- ляется немало монографий и учебников, а именно основы UNIX, которые в абсо- лютном своем большинстве относятся и к Linux), OS/2 (хотя эта система уже практически всеми и забыта, но она была одной из первых, предоставивших пользователям ПК полноценную, мультипрограммную надежную среду, и имеет много очень интересных и эффективных механизмов) и QNX (как наиболее из- вестный и удачный вариант ОС для реализации систем реального времени). Семейство операционных систем UNIX Общая характеристика семейства операционных систем UNIX, особенности архитектуры семейства ОС UNIX UNIX является примером исключительно удачной реализации простой мульти- программной и многопользовательской операционной системы. В свое время
302 Глава 8. Современные операционные системы она проектировалась как инструментальная система для разработки программ- ного обеспечения. Своей уникальностью система UNIX обязана во многом тому обстоятельству, что она была, по сути, создана всего двумя разработчиками1, причем создававшие ее люди делали систему для себя, и первое время ее исполь- зовали на мини-ЭВМ с очень скромными вычислительными ресурсами. По этой причине UNIX, прежде всего, обладает простым, но очень мощным командным языком и независимой от устройств файловой системой. Поскольку при созда- нии этой ОС использовался язык высокого уровня, на котором пишутся не толь- ко системные, но и прикладные программы (речь идет о языке С), то система и приложения, выполняющиеся в ней, получились легко переносимыми (мобиль- ными). Компилятор с языка С для всех оттранслированных программ дает реен- терабельный и разделяемый код, что позволяет эффективно использовать имею- щиеся в системе ресурсы. Первой целью при разработке этой системы было стремление сохранить просто- ту и обойтись минимальным количеством функций. Все реальные сложности ос- тавлялись пользовательским программам. Второй целью была общность. Одни и те же методы и механизмы должны были использоваться во многих случаях. Поэтому общность в UNIX-системах прояв- ляется во многих аспектах, и в частности: □ обращения к файлам, устройствам ввода/вывода и буферам межпроцессных сообщений выполняются с помощью одних и тех же примитивов; □ одни и те же механизмы именования, присвоения альтернативных имен и за- щиты от несанкционированного доступа применяются к файлам с данными и директориями и устройствам; □ одни и те же механизмы работают в отношении программно и аппаратно ини- циируемых прерываний. Наконец, третья цель заключалась в создании операционной среды, в которой большие задачи можно было бы решать, комбинируя существующие небольшие программы, а не разрабатывая программы заново. Важным, хотя и простым с позиций его реализации, является тот факт, что сис- тема UNIX предоставляет пользователям возможность направить выход одной программы непосредственно на вход другой (речь идет о программных каналах (pipe). См. об этом ниже и в разделе «Конвейеры и очереди сообщений», гла- ва 6). В результате большие программные системы можно создавать путем ком- позиции имеющихся небольших программ, а не путем написания новых, что в большинстве случаев упрощает задачу. UNIX-системы существуют уже 30 лет, и к настоящему времени имеется чрезвычайно большой набор легко переноси- мых из системы в систему отлично отлаженных и проверенных временем прило- жений. UNIX-системы поставляются с большим набором системных и прикладных программ, включающим редакторы текстов, программируемые интерпретаторы командного языка, компиляторы с нескольких популярных языков программи- рования, включая С, C++, ассемблер, PERL, FORTRAN и многие другие, ком- 1 Создателями системы UNIX считаются Кен Томпсон и Деннис Ритчи.
Семейство операционных систем UNIX 303 поновщики (редакторы межпрограммных связей), отладчики, многочисленные библиотеки системных и пользовательских программ, средства сортировки и ве- дения баз данных, многочисленные административные и обслуживающие про- граммы. Для абсолютного большинства этих программ имеется документация, включающая в себя такие важные документы, как исходные (как правило, снаб- женные хорошими комментариями) тексты программ. Кроме этого, описание и документация в большей части доступны пользователю непосредственно за эк- раном в интерактивном режиме. Используется иерархическая файловая система с полной защитой, работа со съемными томами, обеспечивается независимость от устройств. Центральной частью системы UNIX является ядро (kernel). Основные понятия системы UNIX Одним из достоинств ОС UNIX является то, что система базируется на неболь- шом числе понятий; рассмотрим их вкратце. Необходимо заметить, что настоя- щий учебник не претендует на полноценное изложение основ работы в системе UNIX и детальное описание ее архитектуры. На эту тему имеется достаточное количество специальной литературы, например отличная монография А. М. Ро- бачевского [70]. Тем не менее, исходя из учебного плана и нашего опыта препо- давания системного программного обеспечения, мы считаем полезным изложить здесь минимальный набор основных понятий, который часто помогает студен- там погрузиться в мир UNIX, отличающийся от привычного всем окружения Windows. Виртуальная машина Система UNIX — многопользовательская. Каждому пользователю после регист- рации (входа в систему) предоставляется виртуальный компьютер, в котором есть все необходимые ресурсы: процессор (процессорное время выделяется на основе «карусельной» диспетчеризации (RR — round robin) и с использованием динамических приоритетов с тем, чтобы обеспечить равенство в обслуживании), память, устройства, файлы. Текущее состояние такого виртуального компьюте- ра, предоставляемого пользователю, называется образом. Можно сказать, что процесс — это выполнение образа. Образ состоит из: □ образа памяти; □ значений общих регистров процессора; □ состояния открытых файлов; □ текущего директория (каталога файлов) и другой информации. Образ процесса во время его выполнения размещается в основной памяти. В ста- рых версиях системы UNIX образ мог быть выгружен (откачан) на диск, если ка- кому-либо более приоритетному процессу потребуется место в основной памяти. В современных реализациях, поддерживающих, как правило, страничный меха- низм виртуальной памяти, прежде всего выгружаются неиспользуемые стра- ницы.
304 Глава 8. Современные операционные системы Образ памяти делится на три логических сегмента: □ сегмент реентерабельных процедур (начинается с нулевого адреса в вирту- альном адресном пространстве процесса); □ сегмент данных (располагается следом за сегментом процедур и может расти в сторону больших адресов); □ сегмент стека (начинается со старшего адреса и растет в сторону младших ад- ресов по мере занесения в него информации при вызовах подпрограмм и при прерываниях). Пользователь Мы уже отмечали, что с самого начала ОС UNIX замышлялась как интерактив- ная многопользовательская система. Другими словами, UNIX предназначен для мультитерминальной работы. Чтобы начать работать, человек должен «войти» в систему, введя со свободного терминала свое учетное имя (account паше) и, возможно, пароль (password). Человек, зарегистрированный в учетных файлах системы и, следовательно, имеющий учетную запись (account), называется за- регистрированным пользователем системы. Регистрацию новых пользователей обычно выполняет администратор системы. Пользователь не может изменить свое учетное имя, но может установить и/или изменить свой пароль. Пароли хранятся в отдельном файле в закодированном виде. Все пользователи ОС UNIX явно или неявно работают с файлами. Файловая система ОС UNIX имеет древовидную структуру [70]. Промежуточными узлами дерева являются каталоги со ссылками на другие каталоги или файлы, а листья дерева соответствуют файлам или пустым каталогам. Каждому зарегистрирован- ному пользователю соответствует некоторый каталог файловой системы, который называется «домашним» (home) каталогом пользователя. При входе в систему пользователь получает неограниченный доступ к своему домашнему каталогу и всем каталогам и файлам, содержащимся в нем. Пользователь может создавать, удалять и модифицировать каталоги и файлы, содержащиеся в домашнем ката- логе. Потенциально возможен доступ и ко всем другим файлам, однако он может быть ограничен, если пользователь не имеет достаточных привилегий. Интерфейс пользователя Традиционный способ взаимодействия пользователя с системой UNIX основы- вается на использовании командных языков (правда, поскольку в настоящее вре- мя все большее распространение получают графические интерфейсы, то и в ОС UNIX стали все чаще работать в X Window). После входа пользователя в систе- му для него запускается один из командных интерпретаторов (в зависимости от параметров, сохраняемых в файле /etc/passwd). Обычно в системе поддержива- ется несколько командных интерпретаторов с похожими, но различающимися своими возможностями командными языками. Общее название для любого ко- мандного интерпретатора ОС UNIX — shell (оболочка), поскольку любой интер- претатор представляет внешнее окружение ядра системы.
Семейство операционных систем UNIX 305 Вызванный командный интерпретатор выдает приглашение на ввод пользовате- лем командной строки, которая может содержать простую команду, конвейер ко- манд или последовательность команд. После выполнения очередной командной строки и выдачи на экран терминала или в файл соответствующих результатов, shell снова выдает приглашение на ввод командной строки, и так до тех пор, пока пользователь не завершит свой сеанс работы и не выйдет из системы Командные языки, используемые в ОС UNIX, достаточно просты, чтобы новые пользователи могли быстро начать работать, и достаточно мощны, чтобы можно было использовать их для написания сложных программ. Последняя возмож- ность опирается на механизм командных файлов (shell scripts), которые могут содержать произвольные последовательности командных строк. При указании имени командного файла вместо очередной команды интерпретатор читает файл строка за строкой и последовательно интерпретирует команды. Привилегированный пользователь Ядро ОС UNIX идентифицирует каждого пользователя по его идентификатору (UID — user identifier), уникальному целому значению, присваиваемому пользо- вателю при регистрации в системе. Кроме того, каждый пользователь относится к некоторой группе пользователей, которая также идентифицируется некоторым целым значением (GID — group identifier). Значения UID и GID для каждого за- регистрированного пользователя сохраняются в учетных файлах системы и при- писываются процессу, в котором выполняется командный интерпретатор, запу- щенный при входе пользователя в систему. Эти значения наследуются каждым новым процессом, запущенным от имени данного пользователя, и используют- ся ядром системы для контроля правомочности доступа к файлам, выполнения программ и т. д. Очевидно, что администратор системы, который тоже является зарегистрирован- ным пользователем, должен обладать большими возможностями, чем обычные пользователи. В ОС UNIX эта задача решается путем выделения единственного нулевого значения UID. Пользователь с таким UID называется суперпользова- телем (superuser) или root. Он имеет неограниченные права на доступ к любому файлу и на выполнение любой программы. Кроме того, такой пользователь име- ет возможность полного контроля над системой. Он может остановить ее и даже разрушить. Еще одним важным отличием суперпользователя от обычного пользователя ОС UNIX является то, что на суперпользователя не распространяются ограничения на используемые ресурсы. Для обычных пользователей устанавливаются такие ограничения, как максимальный размер файла, максимальное число сегментов разделяемой памяти, максимально допустимое пространство на диске и т. д. Су- перпользователь может изменять эти ограничения для других пользователей, но на него они не действуют. Команды и командный интерпретатор Оболочкой (shell) в системе UNIX называют механизм взаимодействия между пользователями и системой. По сути дела, это интерпретатор команд, который
306 Глава 8. Современные операционные системы считывает набираемые пользователем строки и запускает выполнение запрошен- ных системных функций. Полный командный язык, интерпретируемый оболоч- кой, богат по возможностям и достаточно сложен, однако большинство команд просты в использовании и запомнить их не составляет труда. Командная строка состоит из имени команды (то есть имени выполняемого фай- ла), за которым следует список аргументов, разделенных пробелами. Оболочка разбивает командную строку на компоненты. Указанный в команде файл загру- жается, и ему обеспечивается доступ к заданным в команде аргументам. Любой командный язык семейства she] 1 фактически состоит из трех частей: □ служебных конструкций, позволяющих манипулировать с текстовыми стро- ками и строить сложные команды на основе простых команд; □ встроенных команд, выполняемых непосредственно интерпретатором команд- ного языка; □ команд, представляемых отдельными выполняемыми файлами. В свою очередь, набор команд последнего вида включает стандартные команды (системные утилиты, такие как vi, сс и т. д.) и команды, созданные пользовате- лями системы. Для того чтобы выполняемый файл, разработанный пользовате- лем ОС UNIX, можно было запускать как команду shell, достаточно определить в одном из исходных файлов функцию с именем main (имя main должно быть гло- бальным, то есть перед ним не должно указываться ключевое слово static). Если употребить в качестве имени команды имя такого выполняемого файла, команд- ный интерпретатор создаст новый процесс и запустит в нем указанную выпол- няемую программу, начиная с вызова функции main. Тело функции main, вообще говоря, может быть произвольным (для интерпрета- тора существенно только наличие входной точки в программу с именем main), но для того, чтобы создать команду, которой можно задавать параметры, нужно придерживаться некоторых стандартных правил. В этом случае каждая функция main должна определяться с двумя параметрами — агдс и argv. После вызова ко- манды параметру агдс будет соответствовать число символьных строк, указан- ных в качестве аргументов вызова команды, a argv — массив указателей на пе- ременные, содержащие эти строки. При этом имя самой команды составляет первую строку аргументов (то есть после вызова значение агдс всегда больше или равно 1). Код функции main должен проанализировать допустимость задан- ного значения агдс и соответствующим образом обработать заданные текстовые строки. Например, следующий текст на языке С может быть использован для создания команды, которая выводит на экран текстовую строку, заданную в качестве ее аргумента: include <stdio.h> main (агдс. argv) int агдс: char *argv[]: { if (агдс != 2)
Семейство операционных систем UNIX 307 { prlntf("usage: %s your-text\n", argv[O]); exit: prlntf("Xs\n". argv[l]): Процессы Процесс в ОС UNIX понимается в классическом смысле этого термина, то есть как программа, выполняемая в собственном виртуальном адресном пространст- ве. Когда пользователь входит в систему, автоматически создается процесс, в ко- тором выполняется программа командного интерпретатора. Если командному интерпретатору встречается команда, соответствующая выполняемому файлу, то он создает новый процесс и запускает в нем соответствующую программу, начи- ная с функции main. Эта запущенная программа, в свою очередь, может создать процесс и запустить в нем другую программу (она тоже должна содержать функ- цию main) и т. д. Для образования нового процесса и запуска в нем программы используются два системных вызова API — fork() и ехес(имя_выполняемого_файла). Системный вызов fork приводит к созданию нового адресного пространства, состояние которого абсолютно идентично состоянию адресного пространства основного процесса (то есть в нем содержатся те же программы и данные). Для дочернего процесса заво- дятся копии всех сегментов данных. Другими словами, сразу после выполнения системного вызова fork основной (родительский) и порожденный процессы являются абсолютными близнецами; управление и в том и в другом находится в точке, непосредственно следующей за вызовом fork. Чтобы программа могла разобраться, в каком процессе она те- перь работает — в основном или порожденном, функция fork возвращает разные значения: 0 в порожденном процессе и целое положительное число (идентифи- катор порожденного процесса — так называемый PID) в основном процессе. Теперь, если мы хотим запустить новую программу в порожденном процессе, нужно обратиться к системному вызову ехес, указав в качестве аргументов вызо- ва имя файла, содержащего новую выполняемую программу, и, возможно, одну или несколько текстовых строк, которые будут переданы в качестве аргументов функции main новой программы. Выполнение системного вызова ехес приводит к тому, что в адресное пространство порожденного процесса загружается но- вая выполняемая программа и запускается с адреса, соответствующего входу в функцию main. Другими словами, это приводит к замене текущего программно- го сегмента и текущего сегмента данных, которые были унаследованы при вы- полнении вызова fork, на новые соответствующие сегменты, заданные в файле. Прежние сегменты теряются. Это эффективный метод смены выполняемой про- цессом программы, но не самого процесса. Файлы, уже открытые до выполнения примитива ехес, остаются открытыми после его выполнения. В следующем примере пользовательская программа, вызываемая как команда shell, выполняет в отдельном процессе стандартную команду she! 1 1 s, которая выдает на экран содержимое текущего каталога файлов.
308 Глава 8. Современные операционные системы mawC) {if(fork()==0) wait(O): /* родительский процесс */ else execl("Is". "Is", 0): /* порожденный процесс */ } Таким образом, с практической точки зрения процесс в UNIX является объек- том, создаваемым в результате выполнения функции fork(). Каждый процесс, за исключением начального (нулевого), порождается в результате запуска дру- гим процессом операции fork(). Каждый процесс имеет одного родителя, но мо- жет породить много процессов. Начальный (нулевой) процесс является особенным процессом, который создается в результате загрузки системы. После порожде- ния нового процесса с идентификатором 1 нулевой процесс становится процес- сом подкачки и реализует механизм виртуальной памяти. Процесс с идентифика- тором 1, известный под именем init, является предком любого другого процесса в системе и связан с каждым процессом особым образом. Функционирование системы UNIX Теперь, когда мы знаем основные понятия, рассмотрим наиболее характерные моменты функционирования этой системы. Выполнение процессов Процесс может выполняться в одном из двух состояний, а именно пользователь- ском и системном. В пользовательском состоянии процесс выполняет пользо- вательскую программу и имеет доступ к пользовательскому сегменту данных. В системном состоянии процесс выполняет программы ядра и имеет доступ к системному сегменту данных. Когда пользовательскому процессу требуется выполнить системную функцию, он создает системный вызов. Фактически происходит вызов ядра системы как подпрограммы. С момента появления системного вызова процесс считается сис- темным. Таким образом, пользовательский и системный процессы являются двумя фазами одного и того же процесса, но они никогда не пересекаются между собой. Каждая фаза пользуется своим собственным стеком. Стек задачи содержит аргу- менты, локальные переменные и другую информацию относительно функций, выполняемых в режиме задачи. Диспетчерский процесс не имеет пользователь- ской фазы. В UNIX-системах используется разделение времени, то есть каждому процессу выделяется квант времени. Либо процесс завершается сам до истечения отведен- ного ему кванта времени, либо он откладывается по истечении кванта. Механизм диспетчеризации характеризуется достаточно справедливым распределением процессорного времени между всеми процессами. Пользовательским процессам приписываются приоритеты в зависимости от количества получаемого ими про- цессорного времени. Процессам, которые получили большое количество процес- сорного времени, назначают более низкие приоритеты, в то время как процессам, которые получили лишь небольшое количество процессорного времени — наобо- рот, повышают приоритет. Вспомните рассмотренные ранее механизмы динами-
Семейство операционных систем UNIX 309 ческих приоритетов (см. подраздел «Диспетчеризация задач с использованием динамических приоритетов», глава 2). Такой метод диспетчеризации обеспечи- вает хорошее время реакции для всех пользователей системы. Все системные процессы имеют более высокие приоритеты по сравнению с пользовательскими и поэтому всегда обслуживаются в первую очередь. Подсистема ввода/вывода Функции ввода/вывода в UNIX задаются в основном с помощью пяти систем- ных вызовов, а именно: open, close, read, write и seek. Открыть файл можно командой file_descriptor = open (file_name, mode) где параметр mode (режим) указывает, разрешено ли чтение, запись или и то и другое; file_descriptor — дескриптор файла, служит для последующих ссылок на данный файл. Чтение и запись осуществляется командами следующего вида: after_reading_bytes = read (file_descriptor, buffer, bytes) after_writing_bytes = write (file_descriptor, buffer, bytes) где bytes — это число байтов, которые должны быть прочитаны или записаны; after_reading_bytes и after_writing_bytes — это реально прочитанное и записан- ное количество байтов соответственно. При чтении возможны три ситуации, в каждой из которых чтение происходит последовательно: □ если это первое чтение из файла, то оно осуществляется последовательно с самого начала файла; □ если операции чтения предшествовала другая операция чтения из этого фай- ла, то текущая операция предоставит нам данные, непосредственно следую- щие за предыдущими; □ если предшествовала операция поиска seek (см. далее), то чтение осуществля- ется последовательно от точки смещения, указанной в операции seek. Это же справедливо и по отношению к операции записи в файл. Обратите вни- мание, что все эти вызовы относятся к последовательному доступу и эффект прямой адресации достигается с помощью команды seek, смещающей текущую позицию файла Seek (f11e_descriptor,di splacement,di splacement_type). Здесь параметр di spl acement_type (тип смещения) определяет в команде, являет- ся ли смещение абсолютным или относительным, а также задано ли оно числом байтов или числом блоков по 512 байт. Важно заметить, что команда seek исполняется для магнитных дисков так же, как и для магнитных лент, которые нынче уже практически не используются, но во времена появления и становления UNIX-систем были часто используемым устройством.
310 Глава 8. Современные операционные системы Чтобы закрыть файл, достаточно выполнить команду close (file_descriptor) Еще три примитива — gtty, stty, stat позволяют получать и задавать информа- цию о файлах и терминалах. Те же самые команды ввода/вывода применяются и к физическим устройствам. В системе UNIX физические устройства представлены специальными файлами в единой структуре файловой системы. Это означает, что пользователь не может написать зависящую от устройств программу, если только эта зависимость не от- ражена в самом потоке передаваемых данных. Стандартные файлы ввода и выво- да, приписываемые пользовательскому терминалу, открывать обычным путем не требуется. Терминал открывается автоматически по команде входа в систему — login. Система ввода/вывода UNIX, в отличие от большинства других систем, ориенти- рована скорее на работу с потоком, а не с записями. Здесь поток (stream) — это последовательность байтов, заканчивающаяся разделителем (то есть символом конца потока end-of-stream). Понятие потока позволяет проще добиться неза- висимости от устройств и унификации файлов с физическими устройствами и транспортерами (конвейерами). Тем самым пользователь получает гибкость в работе с группами данных, но на него ложатся и дополнительные заботы, по- скольку ему приходится писать программы управления данными. Пользователь может при необходимости относительно легко самостоятельно реализовать ра- боту с записями. Чтобы работать с записями фиксированной длины, достаточно просто задавать постоянную длину во всех командах чтения и записи. Прямой доступ при фиксированной длине записей получается путем умножения длины записи на номер записи и выполнения команды seek для нахождения позиций нужной записи. Работу с записями переменной длины можно организовать, если разместить в начале каждой записи поле фиксированного размера, содержащее длину записи. Перенаправление ввода/вывода Механизм перенаправления ввода/вывода является одним из наиболее элегант- ных, мощных и одновременно простых механизмов ОС UNIX. Цель, которая ставилась при разработке этого механизма, состоит в следующем. Поскольку UNIX — это интерактивная система, которая создавалась в конце 60-х — начале 70-х годов, то обычно программы вводили текстовые строки с терминала и выво- дили результирующие текстовые строки на экран терминала. Для того чтобы обеспечить более гибкое использование таких программ, желательно было уметь обеспечить им ввод из файла или из вывода других программ и направить их вы- вод в файл или на ввод другим программам. Реализация этого механизма основывается на следующих свойствах ОС UNIX. Во-первых, любой ввод/вывод трактуется как ввод из некоторого файла и вывод в некоторый файл. Клавиатура и экран терминала тоже интерпретируются как файлы (первый можно только читать, а во второй можно только писать). Во-вто- рых, доступ к любому файлу производится через его дескриптор (положитель- ное целое число). Фиксируются три значения дескрипторов файлов. Файл с
Семейство операционных систем UNIX 311 дескриптором 1 называется файлом стандартного ввода (stdin), файл с дескрип- тором 2 — файлом стандартного вывода (stdout) и файл с дескриптором 3 — файлом стандартного вывода диагностических сообщений (stderr). В-третьих, программа, запущенная в некотором процессе, «наследует» от породившего про- цесса все дескрипторы открытых файлов. В головном процессе интерпретатора командного языка файлом стандартного ввода является клавиатура терминала пользователя, а файлами стандартного вы- вода и вывода диагностических сообщений — экран терминала. Однако при за- пуске любой команды можно сообщить интерпретатору (средствами соответст- вующего командного языка), какой файл или вывод какой программы должен служить файлом стандартного ввода для запускаемой программы и какой файл или ввод какой программы должен служить файлом стандартного вывода или вывода диагностических сообщений для запускаемой программы. Тогда интер- претатор перед выполнением системного вызова ехес открывает указанные фай- лы, подменяя смысл дескрипторов 1, 2 и 3. То же самое может проделать и любая другая программа, запускающая третью программу в специально созданном процессе. Следовательно, все, что требуется для нормального функционирования механизма перенаправления ввода/выво- да, — это придерживаться при программировании соглашения об использовании дескрипторов stdin, stdout и stderr. Это не очень трудно, поскольку в наиболее распространенных функциях библиотеки ввода/вывода printf, scant и error во- обще не требуется указывать дескриптор файла. Функция printf неявно исполь- зует stdout, функция scant — stdin, а функция error — stderr. Файловая система Файл в системе UNIX представляет собой множество символов с произвольным доступом. В файле содержатся произвольные данные, помещенные туда пользо- вателем, и файл не имеет никакой другой структуры, кроме той, какую налагает на него пользователь. Структура файловой системы Здесь мы рассмотрим одну из первых реализаций файловой системы, поскольку основные ее идеи сохраняются до сих пор. Информация на дисках размещается поблочно, по 512 байт в каждом блоке. Об- ратите внимание, что блок специально взят равным размеру сектора. Диск раз- бивается на следующие области (рис. 8.1): □ неиспользуемый блок; □ управляющий блок или суперблок, в котором содержится размер диска и гра- ницы других областей; □ i-список, состоящий из описаний файлов, называемых i-узлами; □ область для хранения содержимого файлов.
312 Глава 8. Современные операционные системы Неиспользуемый блок Суперблок Л i-узел 1 i-узел 2 i-узел 3 J > Файл i-узел п Блок с данными файла Блок с данными файла Блок с данными файла Свободный блок — Файл Свободный блок *4— • Рис. 8.1. Организация файлов в UNIX на диске Каждый i-узел содержит: □ идентификацию владельца; □ идентификацию группы владельца; □ биты защиты; □ физические адреса на диске или ленте, где находится содержимое файла; □ размер файла; □ время создания файла; □ время последнего использования файла (modification time); □ время последнего изменения атрибутов (change time); □ число связей-ссылок, указывающих на файл; □ индикацию, является ли файл директорией, обычным файлом или специаль- ным файлом. Следом за i-списком идут блоки, предназначенные для хранения содержимого файлов. Пространство на диске, оставшееся свободным от файлов, образует свя- занный список свободных блоков. Таким образом, файловая система UNIX представляет собой структуру данных, размещенную на диске и содержащую управляющий суперблок, в котором опре- делена файловая система в целом; массив i-узлов, где определены все файлы в файловой системе; сами файлы; и, наконец, совокупность свободных блоков. Выделение пространства под данные осуществляется блоками фиксированного размера.
Семейство операционных систем UNIX 313 Каждый файл однозначно идентифицируется старшим номером устройства, младшим номером устройства и i-номером (индексом i-узла данного файла в массиве i-узлов). Когда вызывается драйвер устройства, по старшему номеру ин- дексируется массив входных точек в драйверы. По младшему номеру драйвер выбирает одно устройство из группы идентичных физических устройств. Файл-директория, в котором перечислены имена файлов, позволяет установить соответствие между именами и самими файлами. Директории образуют древо- видную структуру. На каждый обычный файл или файл устройства могут иметься ссылки в различных узлах этой структуры. В непривилегированных программах запись в директории не разрешена, но при наличии соответствующих разреше- ний они могут читать их. Дополнительных связей между директориями нет. Большое число системных директорий система UNIX использует для своих соб- ственных нужд. Одна из них, корневая директория, является базой для всей структуры директорий, и, «отталкиваясь» от нее, можно найти размещение всех файлов. В других системных директориях содержатся программы и команды, предоставляемые пользователям, и файлы устройств. Имена файлов задаются последовательностью имен директорий, разделенных косой чертой («/») и приводящих к концевому узлу (листу) некоторого дерева. Если имя файла начинается с косой черты, то поиск по дереву начинается в кор- невой директории. Если же имя файла не имеет в начале косой черты, то поиск начинается с текущей директории. Имена файлов, начинающиеся с «../», подра- зумевают начало поиска в директории, родительской по отношению к текущей. Имя файла stuff (персонал) указывает на элемент stuff в текущей директории. Имя файла /work/alex/stuff приводит к поиску директории work в корневой ди- ректории. Затем к поиску директории al ex в директории work и, наконец, к поиску элемента stuff в директории alex. Сама по себе косая черта / обозначает корне- вую директорию. В приведенном примере нашла отражение типичная иерар- хическая структура файловой системы, например, work может обозначать диск (устанавливаемый при работе пользователя), alex может быть директорией поль- зователя, a stuff может принадлежать alex. Файл, не являющийся директорией, может встречаться в различных директориях, возможно, под разными именами. Это называется связыванием. Элемент в ди- ректории, относящийся к одному файлу, называется связью. В системе UNIX все такие связи имеют равный статус. Файлы не принадлежат директориям. Скорее, файлы существуют независимо от элементов директорий, а связи в директориях указывают действительно на физические файлы. Файл «исчезает», когда удаля- ется последняя связь, указывающая на него. Биты защиты, заданные в связях, могут отличаться от битов в исходном файле. Таким образом, решается пробле- ма избирательного ограничения доступа к файлам. С каждым поддерживаемым системой устройством ассоциируется один или боль- шее число специальных файлов. Операции ввода/вывода для специальных фай- лов осуществляются так же, как и для обычных дисковых файлов, с той лишь разницей, что эти операции активизируют соответствующие устройства. Специ- альные файлы обычно находятся в справочнике /dev. На специальные файлы мо- гут указывать связи точно так же, как на обычные файлы.
314 Глава 8. Современные операционные системы От файловой системы не требуется, чтобы она вся целиком размещалась на том устройстве, где находится корень. Запрос от системы mount (на установку носите- лей и т. п.) позволяет встраивать в иерархию файлов файлы на сменных томах. Команда mount имеет несколько опций, но обязательных аргументов у стандарт- ного варианта ее использования два: имя файла блочного устройства и имя ката- лога. В результате выполнения этой команды файловая система, расположенная на указанном устройстве, подключается к системе таким образом, что ее содер- жимое заменяет собой содержимое заданного в команде каталога. Поэтому для монтирования соответствующего тома обычно используют пустой каталог. Ко- манда umount выполняет обратную операцию — «отсоединяет» (размонтирует) файловую систему, после чего диск с данными можно физически извлечь из сис- темы. Например, для записи данных на дискету необходимо ее смонтировать, а после работы — размонтировать. Защита файлов Защита файлов осуществляется при помощи номера, идентифицирующего поль- зователя, и установки десяти битов защиты — атрибутов доступа. Права доступа подразделяются на три типа: чтение (read), запись (write) и выполнение (execute). Эти типы прав доступа могут быть предоставлены трем классам пользователей: владельцу файла, группе, в которую входит владелец, и всем (прочим) пользова- телям. Девять из этих битов управляют защитой по чтению, записи и исполне- нию для владельца файла, других членов группы, в которую входит владелец, и всех других пользователей. Файл всегда связан с определенным пользовате- лем — своим владельцем — и с определенной группой, то есть у него есть уже известные нам UID (user ID, идентификатор пользователя) и GID (group ID, идентификатор группы). Изменять права доступа к файлу разрешено только его владельцу. Изменить владельца файла может суперпользователь, группу — су- перпользователь или владелец файла. Программа, выполняющаяся в системе, всегда запускается от имени какого-то пользователя и какой-то группы (обычно — основной группы этого пользова- теля), но связь процессов с пользователями и группами организована сложнее: здесь различаются идентификатор для доступа к файловой системе (FSUID — file system access user ID, FSGID — file system access group ID) и эффективный идентификатор (EUID — effective user ID, EGID — effective group ID), а при дос- тупе к файлам учитываются еще и полномочия (capabilities), присвоенные само- му процессу. При создании файл получает UID, совпадающий с FSUID процесса, который его создает, и, как правило, GID, совпадающий с FSGID этого процесса. Атрибуты доступа определяют, что разрешено делать с данным файлом данной категории пользователей. Имеются всего три операции: чтение, запись и выпол- нение. При создании файла (или еще одного имени для уже существующего файла) мо- дифицируется не сам файл, а каталог, в котором появляются новые ссылки на узлы. Удаление файла заключается в удалении ссылки. Таким образом, право на создание или удаление файла — это право на запись в каталог.
Семейство операционных систем UNIX 315 Право на выполнение каталога интерпретируется как право на поиск в нем (про- хождение через него). Оно позволяет обратиться к файлу по пути, содержащему данный каталог, даже тогда, когда каталог не разрешено читать и список всех его файлов поэтому недоступен. Помимо трех названных основных атрибутов доступа существуют дополнитель- ные, используемые в следующих случаях. Атрибуты SUID и SGID существенны при запуске программы на выполнение: они требуют, чтобы программа выполня- лась не от имени запустившего ее пользователя (группы), а от имени владель- ца (группы) того файла, в котором она находится. Выражаясь формально, если файл программы имеет атрибут SUID (SGID), то FSUID и EUID (FSGID и EGID) соответствующего процесса не наследуются от процесса, запустившего его, а сов- падают с UID (GID) файла. Благодаря этому пользователи получают возмож- ность запустить системную программу, которая создает свои рабочие файлы в закрытых для них каталогах. Кроме того, если процесс создает файл в каталоге, имеющем атрибут SGID, то файл получает GID не по FSGID процесса, а по GID каталога. Это удобно для коллективной работы: все файлы и подкаталоги в каталоге автоматически ока- зываются принадлежащими одной и той же группе, хотя создавать их могут раз- ные пользователи. Есть еще один атрибут — CVTX, который теперь относится к каталогам. Он показывает, что из каталога, имеющего этот атрибут, ссылку на файл может удалить только владелец файла. Существуют две стандартные формы записи прав доступа — символьная и вось- меричная. Символьная представляет собой цепочку из десяти знаков, первый из которых не относится собственно к правам, а обозначает тип файла. Использу- ются следующие обозначения: □ «-» — обычный файл; □ «d» — каталог (директория); □ «с» — символьное устройство; □ «Ь» — блочное устройство; □ «р» — именованный канал (named pipe); □ «s» — «гнездо» (socket1); □ «I» — символическая ссылка. Далее следуют три последовательности, каждая из трех символов, соответствую- щие правам пользователя, группы и всех остальных. Наличие права на чтение обозначается буквой «г», на запись — «w», на выполнение — «х», отсутствие ка- кого-либо права — знаком «-» в соответствующей позиции. Наличие атрибута SUID (SGID) обозначается заглавной буквой «S» в позиции права на выполнение для владельца (группы), если выполнение не разрешено, и прописной буквой «s», если разрешено. 1 Socket — это понятие, связанное со стеком протоколов TCP/IP, который является «род- ным» для UNIX. Его следует понимать как некий адрес или порт, через который связыва- ются удаленные программы.
316 Глава 8. Современные операционные системы Восьмеричная запись — это шестизначное число, первые два знака которого обозначают тип файла и довольно часто опускаются, третья цифра — атрибуты GUID (4), SGID (2) и SVTX (1), а оставшиеся три — соответственно права вла- дельца, группы и всех остальных. Очевидно, что право на чтение можно предста- вить числом «4», право на запись — числом «2», а право на выполнение кодиру- ется как «1». Например, стандартный набор прав доступа для каталога /tmp в символьной фор- ме выглядит как drwxrwxrwt, а в восьмеричной — как 041777 (это каталог; чтение, запись и поиск разрешены всем; установлен атрибут SVTX). А набор прав -r-S-xw-, или в числовом виде — 102412, будет означать, что это обычный файл, владельцу разрешается читать его, но не выполнять и не изменять; пользователям из груп- пы файла (за исключением владельца) — выполнять (причем во время работы программа получит права владельца файла), но не читать и не изменять; а всем остальным — изменять, но не читать и не выполнять. Большинство программ создают файлы с разрешением на чтение и запись для всех пользователей, а каталоги — с разрешением на чтение, запись и поиск для всех пользователей. Этот исходный набор атрибутов логически складывается с «пользовательской маской» — user file-creation mask, сокращенно umask, которая обычно ограничивает доступ. Например, следующие значения для umask u=rwx, g=rwx, о=г-х следует понимать так: у владельца и группы остается полный набор прав, а всем остальным запрещается запись. В восьмеричном виде оно запишется как 002 (первая цифра — ограничения для владельца, вторая — для группы, тре- тья — для остальных, запрещение чтения — 4, записи — 2, выполнения — 1). Вла- делец файла может изменить права доступа к нему командой chmod. Межпроцессные коммуникации в UNIX ОС UNIX в своей основе наиболее полно отвечает требованиям технологии «клиент—сервер». Эта универсальная модель служит основой построения любых сколь угодно сложных систем, в том числе и сетевых. Разработчики СУБД, ком- муникационных систем, систем электронной почты, банковских систем и т. д. во всем мире широко используют технологию «клиент—сервер». Для построения программных систем, работающих по принципам модели типа «клиент—сервер» в UNIX существуют следующие механизмы: □ сигналы; □ семафоры; □ программные каналы; □ очереди сообщений; □ сегменты разделяемой памяти; □ вызовы удаленных процедур. Многие из этих механизмов нам уже знакомы, поэтому рассмотрим их вкратце.
Семейство операционных систем UNIX 317 Сигналы Если рассматривать выполнение процесса в виртуальном компьютере, который предоставляется каждому пользователю, то в такой системе должна существо- вать система прерываний, отвечающая стандартным требованиям: □ обработка исключительных ситуаций; □ средства обработки внешних и внутренних прерываний; □ средства управления системой прерываний (маскирование и демаскирование). Всем этим требованиям в UNIX отвечает техника сигналов, которая может не только воспринимать и обрабатывать сигналы, но и порождать их и посылать на другие машины (процессы). Сигналы могут быть синхронными, когда инициа- тор сигнала — сам процесс, и асинхронными, когда инициатор возникновения сигнала — интерактивный пользователь за терминалом. Источником асинхрон- ных сигналов может быть также ядро, когда оно контролирует определенные со- стояния аппаратуры, рассматриваемые как ошибочные. Сигналы можно рассматривать как простейшую форму межпроцессного взаимо- действия, которое используется для передачи от одного процесса другому или от ядра ОС какому-либо процессу уведомления о возникновении определенного события. Семафоры Механизм семафоров, реализованный в ОС UNIX, является обобщением клас- сического механизма семафоров общего вида, предложенного известным гол- ландским специалистом профессором Дейкстрой. Семафор в ОС UNIX состоит из следующих элементов: □ значение семафора; □ идентификатор процесса, который хронологически последним работал с се- мафором; □ число процессов, ожидающих увеличения значения семафора; □ число процессов, ожидающих нулевого значения семафора. Для работы с семафорами имеются следующие три системных вызова: □ semget — для создания и получения доступа к набору семафоров; □ semop — для манипулирования значениями семафоров (с помощью именно это- го системного вызова осуществляют синхронизацию процессов на основе ис- пользования семафоров); □ semctl — для выполнения разнообразных управляющих операций над набо- ром семафоров. Системный вызов semget имеет следующий синтаксис: id = semget(key. count, flag): где параметры key и flag и возвращаемое значение системного вызова (id) имеют тот же смысл, что для других системных вызовов семейства «get», а параметр
318 Глава 8. Современные операционные системы count задает число семафоров в наборе семафоров, обладающих одним и тем же ключом. После этого индивидуальный семафор идентифицируется дескрипто- ром набора семафоров и номером семафора в этом наборе. Если к моменту вы- полнения системного вызова semget набор семафоров с указанным ключом уже существует, то обращающийся процесс получит соответствующий дескриптор, но так и не узнает о реальном числе семафоров в группе (хотя позже это все-та- ки можно узнать с помощью системного вызова semctl). Основным системным вызовом для манипулирования семафором является semop: oldval = semopdd. oplist, count): где id — это ранее полученный дескриптор группы семафоров, oplist — массив описателей операций над семафорами группы, a count — размер этого массива. Значение, возвращаемое системным вызовом, является значением последнего об- работанного семафора. Каждый элемент массива opl i st имеет следующую струк- туру: □ номер семафора в указанном наборе семафоров; □ операция; □ флаги. Если проверка прав доступа проходит нормально и указанные в массиве opl i st номера семафоров не выходят за пределы общего размера набора семафоров, то системный вызов выполняется следующим образом. Для каждого элемента мас- сива opl i st значение соответствующего семафора изменяется в соответствии со значением поля «операция»: □ если значение поля операции положительно, то значение семафора увеличи- вается на единицу, а все процессы, ожидающие увеличения значения семафо- ра, активизируются (пробуждаются в терминологии UNIX); □ если значение поля операции равно нулю, то если значение семафора также равно нулю, выбирается следующий элемент массива oplist. Если же значе- ние семафора отлично от нуля, то ядро увеличивает на единицу число про- цессов, ожидающих нулевого значения семафора, а обратившийся процесс переводится в состояние ожидания (усыпляется в терминологии UNIX); □ если значение поля операции отрицательно и его абсолютное значение мень- ше или равно значению семафора, то ядро прибавляет это отрицательное зна- чение к значению семафора. Если в результате значение семафора стало ну- левым, то ядро активизирует (пробуждает) все процессы, ожидающие нулевого значения этого семафора. Если же значение семафора меньше абсолютной величины поля операции, то ядро увеличивает на единицу число процессов, ожидающих увеличения значения семафора, и откладывает (усыпляет) теку- щий процесс до наступления этого события. Интересно заметить, что основным поводом для введения массовых операций над семафорами было стремление дать программистам возможность избегать ту- пиковых ситуаций в связи с семафорной синхронизацией. Это обеспечивается тем, что системный вызов semop, каким бы длинным он ни был (по причине по- тенциально неограниченной длины массива oplist), выполняется как атомарная
Семейство операционных систем UNIX 319 операция, то есть во время выполнения semop ни один другой процесс не может изменить значение какого-либо семафора. Наконец, среди флагов-параметров системного вызова semop может содержаться флаг с символическим именем IPC_NOWAIT, наличие которого заставляет ядро ОС UNIX не блокировать текущий процесс, а лишь сообщать в ответных параметрах о возникновении ситуации, которая может привести к блокированию процес- са при отсутствии флага IPC_NOWAIT. Мы не будем обсуждать здесь возможности корректного завершения работы с семафорами при незапланированном заверше- нии процесса; заметим только, что такие возможности обеспечиваются. Системный вызов semctl имеет формат semctl(id, number, cmd, arg); где id — это дескриптор группы семафоров, number — номер семафора в группе, cmd — код операции, а arg — указатель на структуру, содержимое которой интер- претируется по-разному, в зависимости от операции. В частности, с помощью semctl можно уничтожить индивидуальный семафор в указанной группе. Однако детали этого системного вызова настолько громоздки, что мы рекомендуем в случае необходимости обращаться к технической документации используемого варианта операционной системы. Программные каналы Мы с вами уже знакомились с программными каналами в главе 6. Однако рас- смотрим этот механизм еще раз, так сказать в его исходном, изначальном толко- вании. Программные каналы (pipes) в ОС UNIX являются очень важным средством взаимодействия и синхронизации процессов. Теоретически программный канал позволяет взаимодействовать любому числу процессов, обеспечивая дисциплину FIFO (first-in-first-out). Другими словами, процесс, читающий из программного канала, прочитает самые давние записанные в программный канал данные. В тра- диционной реализации программных каналов для хранения данных использова- лись файлы. В современных версиях ОС UNIX для реализации программных ка- налов применяются другие средства IPC (в частности, очереди сообщений). В UNIX различаются два вида программных каналов — именованные и неимено- ванные. Именованный программный канал может служить для общения и син- хронизации произвольных процессов, знающих имя данного программного кана- ла и имеющих соответствующие права доступа. Неименованным программным каналом могут пользоваться только создавший его процесс и его потомки (не- обязательно прямые). Для создания именованного программного канала (или получения к нему досту- па) используется обычный файловый системный вызов open. Для создания же неименованного программного канала существует специальный системный вы- зов pipe (исторически более ранний). Однако после получения соответствующих дескрипторов оба вида программных каналов используются единообразно с по- мощью стандартных файловых системных вызовов read, write и close.
320 Глава 8. Современные операционные системы Системный вызов pipe имеет следующий синтаксис: pipe(fdptr): где f dpt г — это указатель на массив из двух целых чисел, в который после созда- ния неименованного программного канала будут помещены дескрипторы, пред- назначенные для чтения из программного канала (с помощью системного вызова read) и записи в программный канал (с помощью системного вызова write). Де- скрипторы неименованного программного канала — это обычные дескрипторы файлов, то есть такому программному каналу соответствуют два элемента табли- цы открытых файлов процесса. Поэтому при последующем использовании сис- темных вызовов read и write процесс совершенно не обязан отличать случай ис- пользования программных каналов от случая использования обычных файлов {собственно, на этом и основана идея перенаправления ввода/вывода и организа- ции конвейеров). Для создания именованных программных каналов (или получения доступа к уже существующим каналам) используется обычный системный вызов open. Ос- новным отличием от случая открытия обычного файла является то, что если именованный программный канал открывается на запись и ни один процесс не открыл тот же программный канал для чтения, то обращающийся процесс бло- кируется до тех пор, пока некоторый процесс не откроет данный программный канал для чтения. Аналогично обрабатывается открытие для чтения. Запись данных в программный канал и чтение данных из программного канала (независимо от того, именованный он или неименованный) выполняются с по- мощью системных вызовов read и write. Отличие от случая использования обыч- ных файлов состоит лишь в том, что при записи данные помещаются в начало канала, а при чтении выбираются (освобождая соответствующую область памя- ти) из конца канала. Окончание работы процесса с программным каналом (независимо от того, име- нованный он или неименованный) производится с помощью системного вызова close. Очереди сообщений Для обеспечения возможности обмена сообщениями между процессами этот ме- ханизм поддерживается следующими системными вызовами: □ msgget для образования новой очереди сообщений или получения дескрипто- ра существующей очереди; □ msgsnd для посылки сообщения (вернее, для его постановки в указанную оче- редь сообщений); □ msgrcv для приема сообщения (вернее, для выборки сообщения из очереди со- общений); □ msgctl для выполнения ряда управляющих действий. Ядро хранит сообщения в виде связного списка (очереди), а дескриптор очереди сообщений является индексом в массиве заголовков очередей сообщений.
Семейство операционных систем UNIX 321 Системный вызов msgget обладает стандартным для семейства «get» системных вызовов синтаксисом: msgqid = msgget(key, flag); При выполнении системного вызова msgget ядро ОС UNIX либо создает новую очередь сообщений, помещая ее заголовок в таблицу очередей сообщений и воз- вращая пользователю дескриптор вновь созданной очереди, либо находит эле- мент таблицы очередей сообщений, содержащий указанный ключ, и возвращает соответствующий дескриптор очереди. Для посылки сообщения используется системный вызов msgsnd: msgsnd(msgqid, msg. count, flag); где msg — это указатель на структуру, содержащую определяемый пользователем целочисленный тип сообщения и символьный массив — собственно сообщение; count задает размер сообщения в байтах, a fl ад определяет действия ядра при вы- ходе за пределы допустимых размеров внутренней буферной памяти. Для приема сообщения используется системный вызов msgrcv: count = msgrcv(id, msg, maxcount, type, flag): Здесь msg — это указатель на структуру данных в адресном пространстве пользо- вателя, предназначенную для размещения принятого сообщения; maxcount задает размер области данных (массива байтов) в структуре msg; значение type специ- фицирует тип сообщения, которое желательно принять; значение параметра fl ад указывает ядру, что следует предпринять, если в указанной очереди сообщений отсутствует сообщение с указанным типом. Возвращаемое значение системного вызова задает реальное число байтов, переданных пользователю. Системный вызов msgctUid, cmd, mstatbuf); служит для опроса состояния описателя очереди сообщений, изменения его со- стояния (например, изменения прав доступа к очереди) и для уничтожения ука- занной очереди сообщений. Разделяемая память Для работы с разделяемой памятью используются четыре системных вызова: □ shmget — создает новый сегмент разделяемой памяти или находит существую- щий сегмент с тем же ключом; □ shmat — подключает сегмент с указанным дескриптором к виртуальной памя- ти обращающегося процесса; □ shmdt — отключает от виртуальной памяти ранее подключенный к ней сегмент с указанным виртуальным адресом начала; □ shmctl — служит для управления разнообразными параметрами, связанными с существующим сегментом. После того как сегмент разделяемой памяти подключен к виртуальной памяти процесса, этот процесс может обращаться к соответствующим элементам памяти
322 Глава 8. Современные операционные системы с использованием обычных машинных команд чтения и записи, не прибегая к использованию дополнительных системных вызовов. Синтаксис системного вызова shmget выглядит следующим образом: shmld = shmget(key. size, flag): Параметр size определяет желаемый размер сегмента в байтах. Далее работа происходит по общим правилам. Если в таблице разделяемой памяти находится элемент, содержащий заданный ключ, и права доступа не противоречат текущим характеристикам обращающегося процесса, то значением системного вызова яв- ляется дескриптор существующего сегмента (и обратившийся процесс так и не узнает реального размера сегмента, хотя впоследствии его все-таки можно уз- нать с помощью системного вызова shmctl). В противном случае создается новый сегмент с размером не меньше установленного в системе минимального размера сегмента разделяемой памяти и не больше установленного максимального раз- мера. Создание сегмента не означает немедленного выделения под него основной памяти. Это действие откладывается до выполнения первого системного вызова подключения сегмента к виртуальной памяти некоторого процесса. Аналогично, при выполнении последнего системного вызова отключения сегмента от вирту- альной памяти соответствующая основная память освобождается. Подключение сегмента к виртуальной памяти выполняется путем обращения к системному вызову shmat: virtaddr = shmatdd. addr, flags): Здесь id — это ранее полученный дескриптор сегмента, a addr — желаемый про- цессом виртуальный адрес, который должен соответствовать началу сегмента в виртуальной памяти. Значением системного вызова является реальный вирту- альный адрес начала сегмента (его значение не обязательно совпадает со значе- нием прямого параметра addr). Если значением addr является нуль, ядро выбира- ет подходящий виртуальный адрес начала сегмента. Для отключения сегмента от виртуальной памяти используется системный вы- зов shmdt: shmdt(addr): где addr — это виртуальный адрес начала сегмента в виртуальной памяти, ранее полученный от системного вызова shmat. При этом система гарантирует (на осно- ве использования таблицы сегментов процесса), что указанный виртуальный ад- рес действительно является адресом начала разделяемого сегмента в виртуаль- ной памяти данного процесса. Для управления памятью служит системный вызов shmctl: shmctl(id. cmd. shsstatbuf): Он содержит прямой параметр cmd, идентифицирующий требуемое конкретное действие, и предназначен для выполнения различных функций. Наиболее важ- ной является функция уничтожения сегмента разделяемой памяти, которое про- изводится следующим образом. Если к моменту выполнения системного вызова ни один процесс не подключил сегмент к своей виртуальной памяти, то основ- ная память, занимаемая сегментом, освобождается, а соответствующий элемент таблицы разделяемых сегментов объявляется свободным. В противном случае
Семейство операционных систем UNIX 323 в элементе таблицы сегментов выставляется флаг, запрещающий выполнение системного вызова shmget по отношению к этому сегменту, но процессам, успев- шим получить дескриптор сегмента, по-прежнему разрешается подключать сег- мент к своей виртуальной памяти. При выполнении последнего системного вызова отключения сегмента от виртуальной памяти операция уничтожения сег- мента завершается. Вызовы удаленных процедур (RPC) Во многих случаях взаимодействие процессов носит характер «клиент—сервер». Один из процессов («клиент») запрашивает у другого процесса («сервера») не- которую услугу (сервис) и не продолжает свое выполнение до тех пор, пока эта услуга не будет выполнена (и пока процесс-клиент не получит соответствующие результаты). Видно, что семантически такой режим взаимодействия эквивален- тен вызову процедуры. Отсюда и соответствующее название. Кроме этого, ОС UNIX по своей идеологии идеально подходит для того, чтобы быть сетевой опе- рационной системой. И на ее основе можно создавать распределенные системы и организовывать распределенные вычисления. Свойства переносимости позволя- ют создавать «операционно однородные» сети, включающие разнородные компь- ютеры. Однако остается проблема разного представления данных в компьютерах разной архитектуры. Поэтому одной из основных идей RPC является автомати- ческое обеспечение преобразования форматов данных при взаимодействии про- цессов, выполняющихся на разнородных компьютерах. Реализация технологии вызовов удаленных процедур (remote procedure call — RPC) достаточно сложна, поскольку этот механизм должен обеспечить работу взаимодействующих процессов, которые находятся на разных компьютерах. Если в случае обращения к процедуре, расположенной на том же компьютере, процесс общается с ней через стек или общие области памяти, то в случае удаленного вы- зова передача параметров процедуре превращается в передачу запроса по сети. Соответственно, и получение результата так же осуществляется посредством ис- пользования сетевых механизмов. Удаленный вызов процедур включает следующие шаги [70]: □ процесс-клиент осуществляет локальный вызов процедуры, которую называ- ют «заглушкой» (stub). Задача этого модуля-заглушки — принять аргументы, преобразовать их в стандартную форму и сформировать сетевой запрос. Упа- ковка аргументов и создание сетевого запроса называется сборкой (marshal- ling); □ сетевой запрос пересылается на удаленную систему, где соответствующий мо- дуль ожидает такой запрос и при его получении извлекает параметры вызова процедуры (unmarshalling), а затем передает их серверу удаленной процеду- ры. После выполнения осуществляется обратная передача. Операционная система Linux Linux — это современная POSIX-совместимая и UNIX-подобная операционная система для персональных компьютеров и рабочих станций.
324 Глава 8. Современные операционные системы Как известно, Linux — это свободно распространяемая версия UNIX, которая пер- воначально была разработана Линусом Торвальдсом (Linus Torvalds) (torvalds@ kruuna.helsinki.fi) в университете Хельсинки (Финляндия). Все компоненты сис- темы, включая исходные тексты, распространяются с лицензией на свободное копирование и установку для неограниченного числа пользователей. Linux был создан с помощью многих UNIX-программистов и энтузиастов из Ин- тернета. К данному проекту добровольно подключились те, кто имеет достаточно навыков и способностей развивать систему. Большинство программ Linux разра- ботано в рамках проекта GNU из Free Software Foundation в Кэмбридже, Масса- чусетс. Но в него внесли лепту также программисты всего мира. Изначально Linux создавался как «самодельная» UNIX-подобная реализация для ПК типа IBM PC с процессором i80386. Однако Linux стал настолько популя- рен и его нынче поддерживает такое большое число компаний, что в настоящее время имеется реализация этой ОС практически для всех типов процессоров и компьютеров на их основе. На базе ОС Linux создаются и встроенные системы, и суперкомпьютеры. Система поддерживает кластеризацию и большинство со- временных интерфейсов и технологий. Linux поддерживает большинство свойств, присущих другим реализациям UNIX, плюс ряд тех, которых больше нигде нет. Поэтому этот раздел можно считать лишь поверхностным обзором характеристик ядра Linux. Linux — это полноценная многозадачная многопользовательская операционная система (точно так же, как и все другие версии UNIX). Это означает, что одно- временно много пользователей могут работать на одной машине, одновременно выполняя много программ. Linux достаточно хорошо совместим с рядом стан- дартов для UNIX (насколько можно говорить о стандартизации UNIX) на уров- не исходных текстов, включая IEEE POSIX.l, System V и BSD. Такая совмести- мость учитывалась при его создании. Большинство свободно распространяемых по сети Интернет программ для UNIX может быть откомпилировано для LINUX практически без особых изменений. Кроме того, все исходные тексты для Linux, включая ядро, драйверы устройств, библиотеки, пользовательские программы и инструментальные средства распространяются свободно. Другие специфические внутренние черты Linux включают контроль работ по стандарту POSIX (исполь- зуемый оболочками, такими как csh и bash), псевдотерминалы (pty), поддержку национальных и стандартных клавиатур динамически загружаемыми драйвера- ми клавиатур. Linux поддерживает различные типы файловых систем для хранения данных. Некоторые файловые системы, такие как файловая система ext2fs, были созданы специально для Linux. Поддерживаются также другие типы файловых систем, например Minix-1 и Xenix. Реализована также система управления файлами на основе FAT, позволяющая непосредственно обращаться к файлам, находящим- ся в разделах с этой файловой системой. Поддерживается и файловая система ISO 9660 CD-ROM для работы с дисками CD-ROM. Имеются системы управле- ния файлами и на томах с HPFS и NTFS, правда, они работают только на чте- ние файлов. Созданы варианты системы управления файлами и для доступа к FAT32.
Семейство операционных систем OS/2 Warp компании IBM 325 Linux, как и все UNIX-системы, обеспечивает полный набор протоколов стека TCP/IP для сетевой работы. Это включает драйверы устройств для многих по- пулярных сетевых адаптеров технологии Ethernet, протоколы SLIP (serial line Internet protocol, обеспечивающий доступ по TCP/IP при последовательном со- единении), PLIP (parallel line Internet protocol), PPP (point-to-point protocol), NFS (network file system) и т. д. Поддерживается весь спектр клиентов и услуг TCP/IP, таких как FTP, telnet, NNTP и SMTP. Очень часто на компьютерах, работаю- щих под управлением Linux, реализуют DNS-сервер, WWW-серверы (Apache), файерволы для защиты локальных сетей при работе в Интернете, почтовые сер- веры, сервер DHCP. Ядро Linux сразу было создано с учетом возможностей защищенного режима про- цессоров Intel 80386 и 80486. В частности, Linux использует парадигму описания памяти в защищенном режиме и другие новые свойства процессоров. В отличие от старых версий UNIX, в которых задачи выгружались во внешнюю память на магнитных дисках целиком, ядро Linux поддерживает загрузку только нужных страниц. То есть с диска в память загружаются те сегменты программы, которые действительно используются. Возможно использование одной страницы, физи- чески один раз загруженной в память, несколькими выполняемыми программами, то есть реентерабельность кода, присущая всем UNIX-системам, сохранилась. В настоящее время имеются ядра для этой системы, оптимизированные для ра- боты с процессорами Intel и AMD последнего поколения, хотя основные архи- тектурные особенности защищенного режима работы изменились мало. Ядро также поддерживает универсальный пул памяти для пользовательских про- грамм и дискового кэша. При этом для кэширования может использоваться вся свободная память, и наоборот, кэш уменьшается при работе больших программ. Этот механизм агрессивного кэширования позволяет увеличить производитель- ность системы. Выполняемые программы используют динамически связываемые библиотеки, то есть выполняемые программы могут совместно использовать библиотечную про- грамму, представленную одним физическим файлом на диске. Это позволяет выполняемым файлам занимать меньше места на диске, особенно тем, которые многократно используют библиотечные функции. Есть также статические свя- зываемые библиотеки для тех, кто желает пользоваться отладкой на уровне объ- ектных кодов или иметь «полные» выполняемые программы, которые не нужда- ются в разделяемых библиотеках. В Linux разделяемые библиотеки динамически связываются во время выполнения, позволяя программисту заменять библиотеч- ные модули своими собственными. Семейство операционных систем OS/2 Warp компании IBM История появления, расцвета и практического ухода со сцены операционных систем под общим названием OS/2 и странна, и поучительна. Будучи одной из самых лучших ОС для ПК по очень большому числу параметров и появившись
326 Глава 8. Современные операционные системы раньше своих основных конкурентных систем, она тем не менее не смогла стать самой распространенной, хотя могла бы, и с легкостью. Основная причина тому — законы бизнеса (умение рекламировать свой товар, всячески поддерживать его продвижение, вкладывать деньги в завоевание рынка), а не качество самой ОС. Во-первых, компания IBM не сочла необходимым продвигать свою ОС на рынок программного обеспечения, ориентированного на конечного пользователя, а ре- шила продолжить свою практику работы исключительно с корпоративными кли- ентами. А этот рынок (корпоративного ПО) оказался существенно уже для ПК, чем рынок ПО для конечного пользователя, ибо компьютеры типа IBM PC преж- де всего являются персональными. Во-вторых, основные доходы компания IBM получала не от продажи системного ПО для ПК, а за счет продаж дорогостоящих серверов и другого оборудования. Доходы от продажи своей ОС не представля- лись руководству компании IBM значимыми. Для успеха на рынке ОС для ПК необходимо было обеспечить всестороннюю поддержку своей системы соответ- ствующей учебной литературой, широкой рекламой, заинтересовать разработчи- ков программного обеспечения. Увы, этого не произошло, и сегодня уже практи- чески мало кто знает о системах OS/2. В то же время следует отметить, что те, кто в свое время освоил эту систему и создал для нее соответствующее ПО, до сих пор не переходят на ныне чрезвычайно популярные ОС Windows NT, по- скольку последние требуют существенно больше системных ресурсов и при этом функционируют медленнее. Семейство 32-разрядных ОС для IBM-совместимых компьютеров начало свою историю с появления первой OS/2 v 2.0 в 1992 году. Сейчас мы, как правило, имеем дело уже с четвертой версией ОС этого семейства. Все ОС в своем назва- нии имеют слово Warp, что переводится с английского как «основа». OS/2 Warp 4.0 практически представляет собой OS/2 Warp 3.0 (вышедшую еще в 1994 г.) с несколько улучшенными параметрами для DOS-задач, обновленны- ми элементами объектно-ориентированного интерфейса, и включает в себя: □ вытесняющую многозадачность (preemptive multitasking) и поддержку DOS- и Windows- (Win32s) приложений; □ по-настоящему интуитивно понятный и действительно удобный объектный пользовательский интерфейс; □ поддержку стандарта открытого объектного документооборота OpenDoc; □ поддержку стандарта OpenGL; □ поддержку и встроенную разработку на языке Java; □ поддержку шрифтов True Type (TTF); □ управление голосом без предварительной подготовки (технология Voice Туре); □ полную поддержку глобальных сетей Интернет и технологии intranet, доступ в CompuServe1; □ средства построения одноранговых сетей и клиентские части для IBM LAN Server, Windows, Lantastic, Novell Netware 4.1 (в том числе поддержку служ- бы каталогов); 1 CompuServe — американская популярная сетевая служба.
Семейство операционных систем OS/2 Warp компании IBM 327 □ систему удаленного доступа через модемные соединения; □ Mobile File System для поддержки мобильных пользователей; □ стандарт автораспознавания аппаратных устройств Plug-and-Play (но без столь навязчивого механизма, который реализован в Windows); □ набор офисных приложений1 (базы данных, электронные таблицы, текстовый процессор, генератор отчетов, деловая графика, встроенная система приема/ передачи факсимильных сообщений, информационный менеджер); □ полную MultiMedia поддержку, включая работу с видеокамерой, расширен- ную систему помощи WarpGuide. Однако наиболее заманчивы не перечисленные из рекламного буклета возмож- ности системы, а удобная и надежная среда при работе с базами данных, работа в сетях, организованная как клиентское рабочее место при работе с большими сис- темами. OS/2 Warp предлагает единый интерфейс для программирования прикладных программ (API), совместимый с рядом операционных систем, что позволяет сни- зить стоимость разработок. Все версии OS/2 и LAN Server, включая текущие версии OS/2 Warp и OS/2 Warp Server 4.5, совместимы по восходящей линии, что позволяет экономить средства, необходимые для поддержания уже сущест- вующих прикладных программ. Чрезвычайно важным для пользователей является тот факт, что компания IBM для всех версий своей ОС регулярно выпускает пакеты обновления (FixPak). Эти пакеты исправляют обнаруженные ошибки, а также вносят новые функции. Для пользователей такая практика сопровождения фирмой своей ОС, безуслов- но, более выгодна, нежели практика частого выпуска новых версий ОС (ей сле- дует компания Microsoft), в которых обещается исправление обнаруженных ранее недостатков и появление новых функций. Действительно, значительные капиталовложения требуются не только для приобретения новой системы, но и на ее освоение. Так, например, для версии одной из своих самых удачных ОС — Windows NT 4.0 — компания Microsoft выпустила всего только 6 пакетов обновления (ServicePak), тогда как для OS/2 Warp 3.0, которая вышла в свет в 1994 году, компания IBM выпустила уже несколько десятков FixPak. Для OS/2 Warp 4.0 вышло 15 FixPak. Пакеты исправлений и обновлений пользователи получают бесплатно, тогда как за новую систему приходится платить большие деньги. К тому же длительная работа по исправлению имеющихся в системе ошибок приводит к уменьшению количества последних со временем, и система становится все более надежной и функциональной, в то время как новая версия ОС, как правило, содержит су- щественно больше ошибок, нежели предыдущая, поскольку объем ее становится все больше и больше, а времени на создание ОС отводится столько же. 1 Справедливости ради следует заметить, что этот набор приложений (называемый Во- nusPak) не совместим с современными версиями Microsoft Office, и поэтому его исполь- зуют, как правило, только в «закрытых системах», когда нет обмена с документами, изготовленными посредством приложений Microsoft Office.
328 Глава 8. Современные операционные системы Очень полезным, как для управления приложениями, так и для создания не- сложных собственных программ, является наличие системы программирования на языке высокого уровня REXX, который иногда называют языком процедур. Можно сказать, что это встроенный командный язык, служащий для тех же це- лей, что и язык для пакетных (batch) файлов в среде DOS, но он обладает не- сравнимо большими возможностями. Это язык высокого уровня с нетипизиро- ванными переменными. Язык легко расширяем, любая программа OS/2 может добавлять в него новые функции. Помимо встроенного интерпретатора с языка REXX имеется система программирования Visual REXX. Есть и объектно-ори- ентированная версия языка REXX с соответствующим интерпретатором. Наиболее сильное впечатление, которое можно получить при работе в OS/2, ос- тавляет объектно-ориентированный графический пользовательский интерфейс, а особой популярностью у программистов эта система пользовалась вследствие очень хорошей организации VDM-машин и высокого быстродействия при вы- полнении обычных DOS-приложений. Особенности архитектуры и основные возможности OS/2 Warp Строение и функционирование OS/2 можно считать практически идеальными с точки зрения теории и довольно неплохими — в реализации. В качестве под- тверждения этому можно привести один пример, который представляется очень показательным: OS/2 до сегодняшних дней практически неизменна, начиная с версии 2.0, увидевшей свет в 1992 году. Этот факт говорит о глубокой продуман- ности архитектуры системы, ведь и по сей день OS/2 является одной из самых мощных и продуктивных ОС. Здесь наиболее показательными являются тесты серверов. В одной из вычислительных лабораторий Санкт-Петербургского государствен- ного университета аэрокосмического приборостроения (ГУАП) с 1995 года в те- чение нескольких лет функции сервера кафедры вычислительных систем и сетей выполняла OS/2 Warp Adwanced Server. При переходе на сервер Windows NT 4.0 пришлось в два раза увеличить объем оперативной памяти и поменять процес- сор (с Pentium 90 до Pentium II 300), и даже после этого скорость работы обыч- ных приложений на рабочих станциях не смогла достичь той производительно- сти, какую имели пользователи при сервере на базе OS/2. Аналогичные замечания можно прочесть и в зарубежных публикациях — однопроцессорный OS/2 Warp Server обгоняет по производительности двухпроцессорную WindowsNT. Раз- работчики системы OS/2 решили использовать статические структуры данных (таблицы) для различных системных информационных структур, что приводит к большему быстродействию. Для реализации механизмов поддержки виртуаль- ной памяти использованы наиболее эффективные алгоритмы. Очень удачно реа- лизована диспетчеризация задач. В OS/2 имеется несколько видов виртуальных машин для выполнения приклад- ных программ. Собственные 32- и 16-разрядные программы OS/2 выполняются на отдельных виртуальных машинах в режиме вытесняющей многозадачности и могут общаться между собой с помощью средств DDE OS/2. Прикладные про-
Семейство операционных систем OS/2 Warp компании IBM 329 граммы DOS и Win 16 могут запускаться на отдельных виртуальных машинах в многозадачном режиме. При этом они поддерживают полноценные связи DDE и OLE 2.0 друг с другом и связи DDE с 32-разрядными программами OS/2. Кроме того, при желании можно запустить несколько программ Win 16 на общей вирту- альной машине Win 16, где они работают в режиме невытесняющей многозадач- ности, как это реализовано в Windows 3.x. Разнообразные сервисные функции API OS/2, в том числе SOM (system object model — модель системных объектов), обеспечиваются с помощью системных динамических библиотек DLL, к которым можно обращаться без требующих затрат времени переходов между кольцами защиты. Ядро OS/2 предоставляет многие базовые сервисные функции API, обеспечивает поддержку файловой сис- темы, управление памятью и имеет диспетчер аппаратных прерываний. В ядре виртуальных DOS-машин (VDM-ядре) осуществляется эмуляция DOS и про- цессора 8086, а также управление VDM. Драйверы виртуальных устройств обес- печивают уровень аппаратной абстракции. Драйверы физических устройств на- прямую взаимодействуют с аппаратурой. Модуль реализации механизмов виртуальной памяти в ядре OS/2 поддерживает большие, постраничные, разбросанные адресные пространства, составленные из объектов памяти. Каждый объект памяти управляется так называемым «пейдже- ром» — задачей вне ядра, обеспечивающей резервное хранение страниц объекта памяти. Адресные пространства управляются отображением или размещением объектов памяти внутри них. Ядро управляет защитой памяти и ее распределе- нием на основе объектов памяти абстрактным образом, вне зависимости от ка- ких-либо конкретных аппаратных средств трансляции процессорных адресов. В частности, ядро интенсивно использует режим копирования при записи для придания программам способности делить объекты памяти без копирования большого числа страниц, когда новое адресное пространство получает доступ к объекту памяти. Новые копии страниц создаются только тогда, когда программа в одном из адресных пространств обновляет их. Когда ядро принимает странич- ный сбой в объекте памяти и не имеет страницы памяти в наличии или когда оно должно удалить страницы из памяти по требованию других программ, работаю- щих в машине, оно с помощью механизма IPC1 уведомляет пейджер об объекте памяти, в котором произошел сбой. Теперь дело пейджера сервера приложений определить, каким образом предоставить или сохранить данные. Это позволяет системе устанавливать различную семантику для объектов памяти, основываясь на потребностях программ, которые их используют. Ядро управляет средами исполнения для программ обеспечением множествен- ных заданий и потоков. Каждое задание имеет свое собственное адресное про- странство или отображение. Оно назначает объекты памяти, которые задание отобразило на диапазон адресов внутри адресного пространства. Задание также является блоком размещения ресурсов и защиты, при этом заданиям придаются возможности и права доступа к средствам IPC системы. Для поддержки парал- лельного исполнения с другой программой в пределах одного адресного про- странства ядро отделяет среду исполнения от действительно идущего потока 1 IPC (interprocessing communications) — межпроцессное взаимодействие.
330 Глава 8. Современные операционные системы инструкций. Потоки вычислений, включая процессорные ресурсы, потребные для их поддержки, называются потоками. Таким образом, программа может быть загружена в задание и может быть исполнена в нескольких различных местах в коде в одно и то же время на мультипроцессоре или параллельной машине. Это приводит к повышению быстродействия приложения. Система IPC обеспечивает базовый механизм, позволяющий потокам работать в различных заданиях для связи друг с другом. Система IPC поддерживает на- дежную доставку сообщений на порты. Порты представляют собой защищенные каналы между заданиями. Каждому заданию, использующему порт, приписыва- ется набор прав на этот порт. Права могут быть различными для разных заданий. Только одно задание может получать по какому-либо порту, хотя любой поток внутри задания может выполнять операцию приема. Одно или более заданий могут иметь права посылать в порт. Ядро позволяет заданиям применять систе- му IPC на передачу друг другу прав на порт. Оно также обеспечивает высоко- производительный способ передачи больших областей данных в сообщениях. Вместо того чтобы копировать данные, сообщение содержит указатель на них, он называется указателем на данные вне линии. Когда ядро передает сообщение от передатчика к приемнику, оно заставляет передаваемую память появиться в адресном пространстве приемника и, как вариант, исчезнуть из адресного пространства передатчика. Ядро само структурировано как задание с потоками, и большинство системных сервисов реализованы как механизмы IPC к ядру, а не как прямые системные вызовы. Для поддержки операций ввода/вывода (I/O) и доступа к внешним устройствам ядро ОС обеспечивает доступ к ресурсам I/O, таким как устройства с отображае- мой памятью, I/O порты и каналы прямого доступа к памяти (DMA1), а также возможность отражать прерывания на драйверы устройств, исполняемые в поль- зовательском пространстве. Оно имеет сервисы, которые позволяют приоритет- ным программам заполучать устройства в свое владение: такими программами обычно являются задачно-нейтральные сервисы, типа серверов драйверов уст- ройств, работающих как приложения. Поскольку ядро обязано разместить все прерывания (в силу того, что прерывания обычно выдаются в приоритетном со- стоянии компьютера, а также в целях поддержания целостности системы), оно имеет логику, которая определяет, должно ли оно обрабатывать прерывание или его следует отразить на сервер. Если прерывание следует отразить в приложе- ние, оно должно быть зарегистрировано в ядре и содержать код, который будет ожидать, пока ядро не отразит прерывание. Как только прерывание отражено, в приложении запускается поток по обработке прерывания. Характеристики наборов хоста (host) и процессора предоставляют два связан- ных набора функций, требующихся, если прикладным программам следует обес- печить максимум сервисов операционной системы. Характеристики набора хос- та возвращают информацию о процессорном комплексе, работающем в системе, и предоставляют определенные функции системного менеджмента типа вре- мени, даты, останова и рестарта системы. Характеристики набора процессора используются в мультипроцессорных машинах для группировки процессоров в классы. Эти классы позволяют параллельному приложению выполнять не- 1 DMA (direct nemory access) — канал прямого доступа к памяти.
Семейство операционных систем OS/2 Warp компании IBM 331 сколько потоков одновременно на различных процессорах в машине, в итоге происходит истинно параллельное исполнение. В соответствии с концепцией микроядерных ОС непосредственно поверх ядра системы OS/2 располагается ряд серверов приложений, которые обеспечивают системные сервисы общего назначения, то есть задачно-нейтральные сервисы. Они зависят только от ядра, некоторых вспомогательных сервисов, экспортируе- мых доминирующей задачей операционной системы, и от себя самих. В числе задачно-нейтральных сервисов имеются пейджер умолчания, мастер-сервер, который загружает другие задачно-нейтральные серверы в память, сервис низко- уровневых имен, сервис защиты, сервисы инициализации, набор драйверов уст- ройств со связанным кодом поддержки, а также библиотечные подпрограммы для стандартной программной среды. Дополнительные задачно-нейтральные сер- висы типа одиночного файлового сервера могут быть просто добавлены. С помощью ядра ОС и задачно-нейтральных сервисов приоритетная задача мо- жет обеспечить операционную системную среду типа UNIX. Поскольку приори- тетная задача является прикладным сервером, возможно добавлять другие сер- веры для различных задач, исполняющих программы, написанные в различных операционных системах, работающих на машине в одно и то же время. Существуют некоторые операционные системные сервисы, такие как трансля- ция сообщений об ошибках, не обеспечиваемые задачно-нейтральными сервиса- ми. Поскольку лучше не дублировать подобные сервисы, приоритетная задача обеспечивает эти сервисы не только своим клиентским приложениям, но и лю- бой другой задаче, исполняющейся в машине. Особенности интерфейса OS/2 Warp В OS/2 Warp в качестве стандартной графической оболочки используется среда Workplace Shell (WPS), организованная более логично и удобно, чем известный Windows-интерфейс. Оболочка Workplace Shell основана на мощной системно- объектной модели1 IBM — технологии, специально разработанной для решения таких проблем, как жесткая привязка объектов к их клиентам и необходимость использования одного и того же языка программирования. Объекты Workplace 'Shell работают в среде SOM, доступ в которую можно реализовать почти на всех языках программирования, предусматривающих внешние процедуры, в том чис- ле и на REXX. В отличие от GUI Windows, в котором те же ярлыки2 объектов никак не связаны между собой, в WPS объекты, имеющие аналогичные ярлыки (shadow3 в терми- нологии WPS), просто имеют дополнительные свойства — быть многократно отображенными почти как самостоятельные объекты. Можно сделать несколько shadow-значков с уже существующей shadow-значков или объекта. При этом лю- 1 System object model — SOM. 2 Shortcat (ярлык) — значок (или иконка) на рабочем столе Windows, который имеет связь с тем или иным объектом. 3 Shadow (тень) — значок на рабочем столе OS/2, которая является частью объекта, то есть имеется постоянная двухсторонняя связь между shadow и собственно объектом.
332 Глава 8. Современные операционные системы бые shadow-значки могут быть перемещены в любое место, и их связи с основ- ным объектом не теряются. Аналогично и в GUI Windows. Но в WPS можно пе- реместить основной объект, и его shadow-значки тоже изменят свои параметры, тогда как в GUI Windows произойдет разрушение связей, поскольку связи явля- ются односторонними. Про SOM можно сказать, что это не связанная ни с одним конкретным языком объектно-ориентированная технология для создания, хранения и использования двоичных библиотек классов. Ключевые слова здесь «двоичные» и «не связанная ни с одним конкретным языком». Хотя теперь многие считают OS/2 технологи- ей прошлого, модель SOM на самом деле представляет собой одну из наиболее интересных разработок в области компьютерной индустрии даже на сегодняш- ний день. По существу, некоторые идеи, реализованные в OS/2 в начале 90-х го- дов прошлого столетия, сейчас только обещают быть реализованными в новом поколении ОС Windows с кодовым названием Whistler. Объектно-ориентиро- ванное программирование (ООП) заслужило безоговорочное признание в каче- стве основной парадигмы, однако его применению в коммерческом программном обеспечении препятствуют отсутствие в языках ООП средств для обращения к библиотекам классов, подготовленным на других языках, и необходимость поставлять с библиотеками классов исходные тексты. Многим независимым раз- работчикам библиотек классов приходится продавать заказчикам исходные текс- ты, поскольку разные компиляторы по-разному отображают объекты. Настоящий потенциал SOM заключается в ее совместимости практически с любой платформой и любым языком программирования. SOM соответствует спецификации CORBA1, которая определяет стандарт условий взаимодействия между прикладными про- граммами в неоднородной сети. Интересно отметить тот факт, что существует довольно много альтернативных оболочек для OS/2, начиная с FileBar, примитивной, но зато отлично работаю- щей на компьютерах с 4 Мбайт памяти, и кончая мощной Object Desktop, кото- рая значительно улучшает внешний вид экрана OS/2 и делает работу с системой более удобной. Помимо оболочек, улучшающих интерфейс OS/2, имеется также ряд программ, расширяющих ее функциональность. В первую очередь это Xfree86 для OS/2 — полноценная система X Window, которая может использоваться как Х-терминал при работе в сети с UNIX-машинами, а также для запуска программ, перене- сенных из UNIX в OS/2. К сожалению, таких программ немного, однако боль- шое количество UNIX-программ поставляется вместе с исходными кодами, ко- торые, как правило, практически не нужно изменять для перекомпиляции под Xfree86/OS2. Серверная операционная система OS/2 Warp 4.5 Новая серверная ОС компании IBM, которая вышла в свет в 1999 году, носит название OS/2 WarpServer for e-Business, что подчеркивает ее основное назначе- 1 CORBA (common object request broker architecture) — архитектура посредника стандарт- ного объектного запроса.
Семейство операционных систем OS/2 Warp компании IBM 333 ние, но поскольку в процессе ее создания она носила кодовое название «Аврора» (Aurora), фактически все ее так теперь и называют. Как известно, предыдущие версии системы OS/2 могли предоставить програм- мисту только 512 Мбайт виртуального адресного пространства для нативных 32- битовых задач. В свое время это было очень много. Однако сегодня, хоть и край- не редко еще встречаются задачи, требующие столь большого объема оператив- ной памяти, некоторые считают серьезным недостатком ограничение в 512 Мбайт. Поэтому в последней версии системы это ограничение снято, и теперь объем вир- туальной памяти может достигать 3 Гбайт (напомним, что в Windows NT 4.0 объем виртуального адресного пространства для задач пользователя составляет 2 Гбайт). По умолчанию максимальный объем виртуальной памяти задачи OS/2 v. 4.5 со- ставляет 2 Гбайт, но командой «VIRTUALADDRESSLIMIT=3072> в конфигурационном файле CONFIG.SYS он может быть увеличен. В этой системе разработчики постарались все прежние остатки старого 16-би- тового кода, который еще оставался в предыдущих версиях системы, заменить на полностью 32-битовые реализации, что повышает скорость работы системы. Прежде всего, сделана поддержка 32-битовых драйверов инсталлируемых фай- ловых систем (IFS), ибо в предыдущих системах работа с ними велась через трансляцию вызовов 32bit—>16bit—>32bit. В то же время для обеспечения совмес- тимости со старым программным обеспечением кроме 32-битового используется и 16-битовый API. Для повышения надежности файловой подсистемы создана новая журналирую- щая файловая система JFS (journaling file system). JFS введена для удовлетворе- ния потребности в более живучей файловой системе для OS/2 Warp. JFS имеет большую безопасность в структурах данных благодаря технике, разработанной для СУБД. Работа с файловой системой происходит в режиме транзакций с ве- дением журнала транзакций. В случае системных сбоев есть возможность обра- ботки журнала транзакций с целью занесения или сброса каких-либо изменений, произведенных во время системного сбоя, эта система также повышает скорость восстановления файловой системы после сбоя. Сохраняя целостность файловой системы, эта система управления файлами не гарантирует восстановление поль- зовательских данных. Следует отметить, что файловая система JFS обеспечивает самую высокую скорость работы с файлами из всех известных систем, создан- ных для ПК, что очень важно для серверной ОС. Для работы с дисками создан специальный менеджер дисков — LVM1. Все уста- навливаемые файловые системы содержатся в LVM. LVM осуществляет опреде- ление имен дисков для программ, которые этого требуют. Это позволяет избира- тельно назначить любую букву любому разделу. И даже больше — ОС не будет сама использовать имена дисков. LVM в совокупности с JFS позволяет объеди- нять несколько томов и даже несколько физических дисков в один большой ло- гический том. 1 LVM (logical volume manager) — менеджер томов (логических дисков).
334 Глава 8. Современные операционные системы Сетевая ОС реального времени QNX Операционная система QNX является мощной операционной системой, позво- ляющей проектировать сложные программные системы, работающие в реальном времени как на одном-единственном компьютере, так и в локальной вычисли- тельной сети. Встроенные средства операционной системы QNX обеспечивают поддержку многозадачного режима на одном компьютере и взаимодействие параллельно выполняемых задач на разных компьютерах, работающих в среде локальной вычислительной сети. Основным языком программирования в сис- теме является язык С. Основная операционная среда соответствует стандартам POSIX-интерфейса. Это позволяет с небольшими доработками перенести необ- ходимое накопленное программное обеспечение в среду операционной системы QNX для организации их работы в среде распределенной обработки. ОС QNX является сетевой, мультизадачной, многопользовательской (многотер- минальной) и масштабируемой. С точки зрения пользовательского интерфейса и API она очень похожа на UNIX. Однако QNX — это не версия UNIX, хотя по- чему-то многие так считают. Она была разработана, что называется «с нуля», канадской фирмой QNX Software Systems Limited в 1989 году по заказу Мини- стерства обороны США. Причем эта система построена на совершенно других архитектурных принципах, отличных от принципов, использованных при созда- нии ОС UNIX. QNX была первой коммерческой ОС, построенной на принципах микроядра и обмена сообщениями. Система реализована в виде совокупности независи- мых (но взаимодействующих через обмен сообщениями) процессов различного уровня (менеджеры и драйверы), каждый из которых реализует определенный вид сервиса. Эти идеи позволили добиться нескольких важнейших преимуществ. Вот как они описаны на сайте, посвященном системе QNX [60]. □ Предсказуемость, означающая ее применимость к задачам жесткого реально- го времени. QNX является операционной системой, которая дает полную га- рантию в том, что процесс с наивысшим приоритетом начнет выполняться практически немедленно и что критическое событие (например, сигнал тре- воги) никогда не будет потеряно. Ни одна версия UNIX не может достичь подобного качества, поскольку нереентерабельный код ядра слишком велик. Любой системный вызов из обработчика прерывания в UNIX может привес- ти к непредсказуемой задержке (то же самое касается Windows NT, где реаль- ное время заканчивается между ISR и DPC). □ Масштабируемость и эффективность, достигаемые оптимальным использо- ванием ресурсов и означающие ее применимость для встроенных (embedded) систем; вы не увидите в каталоге /dev огромной кучи файлов, соответствую- щих ненужным драйверам. Драйверы и менеджеры можно запускать и уда- лять (кроме файловой системы, что очевидно) динамически, просто из команд- ной строки. Вы можете иметь только тот сервис, который вам реально нужен, причем это не требует серьезных усилий и не порождает проблем. □ Расширяемость и надежность одновременно, поскольку написанный вами драй- вер не нужно компилировать в ядро, рискуя вызвать нестабильность системы.
Сетевая ОС реального времени QNX 335 Менеджеры ресурсов (сервис логического уровня) работают в кольце защи- ты 3, и вы можете добавлять свои, не опасаясь за систему. Драйверы работают в кольце с уровнем привилегий 1 и могут вызвать проблемы, но не фатально- го характера. Кроме того, их достаточно просто писать и отлаживать. □ Быстрый сетевой протокол FLEETS прозрачный для обмена сообщениями, автоматически обеспечивающий отказоустойчивость, балансирование нагруз- ки и маршрутизацию между альтернативными путями доступа. □ Компактная графическая подсистема Photon, построенная на тех же принци- пах модульности, что и сама ОС, позволяет получить полнофункциональный GUI1 2 (расширенный Motif), работающий вместе с POSIX-совместимой ОС всего в 4Мбайт памяти, начиная с i80386 процессора. Вспомним основные принципы, обязательная реализация которых позволят соз- давать ОСРВ (см. раздел «Требования, предъявляемые к ОС реального време- ни», глава 5). Первым обязательным требованием к архитектуре ОСРВ являет- ся многозадачность в истинном смысле этого слова. Очевидно, что варианты с псевдомногозадачностью (а точнее — не вытесняющая многозадачность) типа MS Windows 3.x или Novell NetWare неприемлемы, поскольку они допуска- ют возможность блокировки или даже полного развала системы одним непра- вильно работающим процессом. Для предотвращения блокировок ОСРВ должна использовать квантование времени (то есть вытесняющую многозадачность), что является достаточно легкой задачей. Вторая проблема (организация надежных вычислений) может быть эффективно решена при полном использовании воз- можностей процессоров Intel 80386 и старше, что предполагает работу ОС в 32- разрядном режиме процессора. Для эффективного обслуживания прерываний ОС должна использовать алгоритм диспетчеризации, обеспечивающий вытес- няющее планирование, основанное на приоритетах. Наконец, крайне желательны эффективная поддержка сетевых коммуникаций и наличие развитых механизмов взаимодействия между процессами, поскольку реальные технологические систе- мы обычно управляются целым комплексом компьютеров и/или контроллеров. Весьма важно также, чтобы ОС поддерживала множественные потоки управле- ния (не только мультипрограммный, но и мультизадачный режимы), а также симметричную мультипроцессорность. И, наконец, при соблюдении всех перечисленных условий ОС должна быть спо- собна работать на ограниченных аппаратных ресурсах, поскольку одна из ее ос- новных областей применения — это встроенные системы. К сожалению, данное условие обычно реализуется путем урезания стандартных сервисных средств. Архитектура системы QNX QNX —• это ОС реального времени, позволяющая эффективно организовать рас- пределенные вычисления. В системе реализована концепция связи между зада- чами на основе сообщений, посылаемых от одной задачи к другой, причем зада- 1 Это фирменная технология, несколько более подробно об этом изложено ниже. 2 GUI (graphic user interface) — графический интерфейс пользователя.
336 Глава 8. Современные операционные системы чи эти могут находиться как на одном и том же компьютере, так и на удаленных, но связанных локальной вычислительной сетью. Реальное время и концепция связи между процессами в виде сообщений оказывают решающее влияние на разрабатываемое для QNX программное обеспечение и на программиста, стремя- щегося с максимальной выгодой использовать преимущества системы. Микроядро имеет объем в несколько десятков килобайт (в одной из версий — 10 Кбайт, в другой — менее 32 Кбайт), то есть это одно из самых маленьких ядер среди всех существующих операционных систем. В этом объеме помещаются [54]: □ механизм передачи сообщений между процессами (IPC); □ редиректор1 прерываний; □ блок планирования выполнения задач; □ сетевой интерфейс для перенаправления сообщений (менеджер Net). Механизм передачи межпроцессных сообщений занимается пересылкой сообще- ний между процессами и является одной из важнейших частей операционной системы, так как все общение между процессами, в том числе и системными, происходит через сообщения. Сообщение в QNX — это последовательность бай- тов произвольной длины (0-65 535 байтов) произвольного формата. Протокол обмена сообщениями выглядит таким образом. Например, задача блокируется для ожидания сообщения. Другая же задача посылает первой сообщение и при этом блокируется сама, ожидая ответа. Первая задача разблокируется, обрабаты- вает сообщение и отвечает, разблокируя при этом вторую задачу. Сообщения и ответы, пересылаемые между процессами при их взаимодействии, находятся в теле отправляющего их процесса до того момента, когда они могут быть приняты. Это значит, что, с одной стороны, уменьшается вероятность повреждения сообщения в процессе передачи, а с другой — уменьшается объем оперативной памяти, необходимый для работы ядра. Кроме того, уменьшается число пересылок из памяти в память, что разгружает процессор. Особенностью процесса передачи сообщений является то, что в сети, состоящей из нескольких компьютеров под управлением QNX, сообщения могут прозрачно передаваться процессам, выполняющимся на любом из узлов. Определены в QNX еще и два дополнительных метода передачи сообщений — метод представителей (Proxy) и метод сигналов (Signal). Представители используются в случаях, когда процесс должен передать сооб- щение, но не должен при этом блокироваться на передачу. В таком случае вызы- вается функция qnx_proxy_attach() и создается представитель. Он накапливает в себе сообщения, которые должны быть доставлены другим процессам. Лю- бой процесс, знающий идентификатор представителя, может вызвать функцию Trigger О, после чего будет доставлено первое в очереди сообщение. Функция Trigger() может вызываться несколько раз, и каждый раз представитель будет доставлять следующее сообщение. При этом представитель содержит буфер, в котором может храниться до 65 535 сообщений. 1 От redirect - перенаправлять.
Сетевая ОС реального времени QNX 337 Как известно, сигналы уже давно используются в ОС UNIX. Система QNX под- держивает множество сигналов, совместимых с POSIX, большое количество сигналов, традиционно использовавшихся в UNIX (поддержка этих сигналов реа- лизована для совместимости с переносимыми приложениями, и ни один из сис- темных процессов QNX их не генерирует), а также несколько сигналов, специ- фичных для самой QNX. По умолчанию любой сигнал, полученный процессом, приводит к завершению процесса (кроме нескольких сигналов, которые по умол- чанию игнорируются). Но процесс с приоритетом уровня «superuser» может за- щититься от нежелательных сигналов. В любом случае процесс может содержать обработчик для каждого возможного сигнала. Сигналы удобно рассматривать как разновидность программных прерываний. Редиректор прерываний является частью ядра и занимается перенаправлением аппаратных прерываний в связанные с ними процессы. Благодаря такому подхо- ду возникает один побочный эффект — с аппаратной частью компьютера работа- ет ядро, оно перенаправляет прерывания процессам — обработчикам прерыва- ний. Обработчики прерываний обычно встроены в процессы, хотя каждый из них исполняется асинхронно с процессом, в который он встроен. Обработчик ис- полняется в контексте процесса и имеет доступ ко всем глобальным переменным процесса. При работе обработчика прерываний прерывания разрешены, но обра- ботчик приостанавливается только в том случае, если произошло более высоко- приоритетное прерывание. Если это позволяется аппаратной частью, к одному прерыванию может быть подключено несколько обработчиков, и каждый из них получит управление при возникновении прерывания. Этот механизм позволяет пользователю избежать работы с аппаратным обеспе- чением напрямую и тем самым избегать конфликтов между различными процес- сами, работающими с одним и тем же устройством. Для обработки сигналов от внешних устройств чрезвычайно важно минимизировать время между возник- новением события и началом непосредственной его обработки. Этот фактор су- ществен в любой области применения, от работы терминальных устройств до возможности обработки высокочастотных сигналов. Блок планирования выполнения задач (диспетчер задач) занимается обеспечени- ем многозадачности. В этой части ОС QNX предоставляет разработчику огром- ный простор для выбора той методики выделения ресурсов процессора задаче, которая обеспечит наиболее подходящие условия для критических приложений или обеспечит такие условия для некритических приложений, что они выпол- нятся за разумное время, не мешая работе критических приложений. К выполнению своих функций как диспетчера ядро приступает в следующих случаях: □ какой-либо процесс вышел из блокированного состояния; □ истек квант времени для процесса, владеющего CPU; □ работающий процесс прерван каким-либо событием. Диспетчер выбирает процесс для запуска среди неблокированных процессов в порядке значений их приоритетов, которые располагаются в диапазоне от О (наименьший) до 31 (наибольший). Обслуживание каждого из процессов зави-
338 Глава 8. Современные операционные системы сит от метода диспетчеризации, с которым он работает (уровень приоритета и метод диспетчеризации могут динамически меняться во время работы). В QNX существуют три метода диспетчеризации: FIFO (первым пришел — первым об- служен), round-robin (процессу выделяется определенный квант времени для ра- боты) и адаптивный, который является наиболее используемым. Первый наиболее близок к кооперативной многозадачности. То есть процесс выполняется до тех пор, пока он не перейдет в состояние ожидания сообщения, состояние ожидания ответа на сообщение или не отдаст управление ядру. При переходе в одно из таких состояний процесс помещается последним в очередь процессов с таким же уровнем приоритета, а управление передается процессу с наибольшим приоритетом. Во втором варианте все происходит так же, как и в предыдущем, с той разницей, что период, в течение которого процесс может работать без перерыва, ограничи- вается неким квантом времени. Процесс, работающий с адаптивным методом, в QNX ведет себя следующим об- разом: □ Когда процесс полностью использовал выделенный ему квант времени, его приоритет снижается на 1, если в системе есть процессы с тем же уровнем приоритета, готовые к исполнению. □ Если процесс с пониженным приоритетом остается не обслуженным в тече- ние секунды, его приоритет увеличивается на 1. Если процесс блокируется, ему возвращается оригинальное значение приоритета. По умолчанию процессы запускаются в режиме адаптивной многозадачности. В этом же режиме работают все системные утилиты QNX. Процессы, работаю- щие в разных режимах многозадачности, могут одновременно находиться в па- мяти и исполняться. Важный элемент реализации многозадачности — это при- оритет процесса. Обычно приоритет процесса устанавливается при его запуске. Но есть дополнительная возможность, называемая «вызываемый клиентом при- оритет». Как правило, она реализуется для серверных процессов (исполняющих запросы на какое-либо обслуживание). При этом приоритет процесса-сервера устанавливается только на время обработки запроса и становится равным при- оритету процесса-клиента. Сетевой интерфейс в системе QNX является неотъемлемой частью ядра. Он, ко- нечно, взаимодействует с сетевым адаптером через сетевой драйвер, но базовые сетевые сервисы реализованы на уровне ядра. При этом передача сообщения процессу, находящемуся на другом компьютере, ничем не отличается с точки зрения приложения от передачи сообщения процессу, выполняющемуся на том же компьютере. Благодаря такой организации сеть превращается в однородную вычислительную среду. При этом для большинства приложений не имеет зна- чения, с какого компьютера они были запущены, на каком исполняются и куда поступают результаты их работы. Такое решение принципиально отличает QNX от остальных ОС, которые тоже имеют все необходимые средства для работы в сети, и делает системы, работающие под ее управлением, по-настоящему рас- пределенными.
Сетевая ОС реального времени QNX 339 Все сервисы QNX, не реализованные непосредственно в ядре, работают как стандартные процессы в полном соответствии с основными концепциями микро- ядерной архитектуры. С точки зрения операционной системы системные про- цессы ничем не отличаются от всех остальных. Как, впрочем, и драйверы уст- ройств. Единственное, что нужно сделать, написав новый драйвер устройства в QNX, чтобы он стал частью операционной системы, — это изменить конфигура- ционный файл системы так, чтобы драйвер запускался при загрузке. Основные механизмы QNX для организации распределенных вычислений QNX является сетевой операционной системой и позволяет организовать эффек- тивные распределенные вычисления. Для организации сети на каждой машине, называемой узлом, помимо ядра и менеджера процессов должен быть запущен уже упомянутый нами выше менеджер Net. Менеджер Net не зависит от аппа- ратной реализации сети. Данная аппаратная независимость обеспечивается за счет использования сетевых драйверов. В QNX имеются драйверы для различ- ных сетей, например Ethernet, Arcnet, Token Ring. Кроме этого, имеется возмож- ность организации сети через последовательный канал или модем. В QNX последней, четвертой, версии полностью реализовано встроенное сетевое взаимодействие «точка-точка». Например, находясь на машине А, вы можете скопировать файл с гибкого диска, подключенного к машине В, на жесткий диск, подключенный к машине С. По существу, сеть из машин QNX действует как один мощный компьютер. Любые ресурсы (модемы, диски, принтеры) могут быть до- бавлены к системе простым их подключением к любой машине в сети. QNX под- держивает одновременную работу в сетях Ethernet, Arcnet, Serial и Token Ring и обеспечивает более чем один-единственный путь для коммуникации, а также балансировку нагрузки в сетях. Если кабель или сетевая плата выходит из строя таким образом, что связь через эту сеть прекращается, то система будет автома- тически перенаправлять данные через другую сеть. Это происходит в режиме «on-line», предоставляя пользователю автоматическую сетевую избыточность и увеличивая скорость коммуникаций во всей системе. Каждому узлу в сети соответствует уникальный целочисленный идентификатор — логический номер узла. Любой поток в сети QNX имеет прозрачный доступ (при наличии достаточных привилегий) ко всем ресурсам сети, то же самое относится и к взаимодействию потоков. Для взаимодействия потоков, находящихся на раз- ных узлах сети, используются те же самые вызовы ядра, что и для потоков, вы- полняемых на одном узле. В том случае, если потоки находятся на разных узлах сети, ядро переадресует запрос менеджеру сети. Для организации обмена в сети используется надежный и эффективный протокол транспортного уровня FLEET. Каждый из узлов может принадлежать одновременно нескольким QNX-сетям. В том случае если сетевое взаимодействие может быть реализовано несколькими путями, для передачи выбирается незагруженная и более скоростная сеть. Сетевое взаимодействие является узким местом в большинстве операционных систем и обычно создает значительные проблемы для систем реального времени. Для того чтобы обойти это препятствие, разработчики QNX создали собствен-
340 Глава 8. Современные операционные системы ную специальную сетевую технологию FLEET и соответствующий протокол транспортного уровня FTL (FLEET transport layer). Этот протокол не базирует- ся ни на одном из распространенных сетевых протоколов типа IPX или NetBios и обладает рядом качеств, которые делают его уникальным. Основные его каче- ства зашифрованы в аббревиатуре FLEET, которая расшифровывается следую- щим образом. Fault-Tolerant Networking QNX может одновременно использовать несколько физических сетей. При выходе из строя любой из них данные будут автоматически перенаправлены «на лету» через другую сеть Load-Balancing on the Fly При наличии нескольких физических соединений QNX автоматически распараллеливает передачу пакетов по соответствующим сетям Efficient Performance Специальные драйверы, разрабатываемые фирмой QSSL для широкого спектра оборудования, позволяют с максимальной эффективностью использовать сетевое оборудование Extensable Architecture Любые новые типы сетей могут быть поддержаны путем добавления соответствующих драйверов TransparentDistributed Processing Благодаря отсутствию разницы между передачей сообщений в пределах одного узла и между узлами нет необходимости вносить какие-либо изменения в приложения для того, чтобы они могли взаимодействовать через сеть Благодаря этой технологии сеть компьютеров с QNX фактически можно пред- ставлять как один виртуальный суперкомпьютер. Все ресурсы любого из узлов сети автоматически доступны другим, и для этого не нужны специальные «фоку- сы» с использованием технологии RPC. Это значит, что любая программа может быть запущена на любом узле, при этом ее входные и выходные потоки могут быть направлены на любое устройство на любых других узлах [41]. Например, утилита make в QNX автоматически распараллеливает компиляцию пакетов из нескольких модулей на все доступные узлы сети, а затем собирает исполняемый модуль по мере завершения компиляции на узлах. Специальный драйвер, входящий в комплект поставки, позволяет использовать для сетевого взаимодействия любое устройство, с которым может быть ассоциирован файло- вый дескриптор, например последовательный порт, что открывает возможности для создания глобальных сетей. Достигаются все эти удобства за счет того, что поддержка сети частично обес- печивается и микроядром (специальный код в его составе позволяет QNX фак- тически объединять все микроядра в сети в одно ядро). Разумеется, за такие воз- можности приходится платить тем, что мы не можем получить драйвер для какой-либо сетевой платы от кого-либо еще, кроме фирмы QSSL, то есть исполь- зоваться может только то оборудование, которое уже поддерживается. Однако ассортимент такого оборудования достаточно широк и периодически пополняет- ся новейшими устройствами.
Сетевая ОС реального времени QNX 341 Когда ядро получает запрос на передачу данных процессу, находящемуся на уда- ленном узле, он переадресовывает этот запрос менеджеру Net, в подчинении которого находятся драйверы всех сетевых карт. Имея перед собой полную кар- тину состояния всего сетевого оборудования, менеджер Net может отслеживать состояние каждой сети и динамически перераспределять нагрузку между ними. В случае когда одна из сетей выходит из строя, информационный поток автомати- чески перенаправляется в другую доступную сеть, что очень важно при построе- нии высоконадежных систем. Кроме поддержки своего собственного протокола, Net обеспечивает передачу пакетов TCP/IP, SMB и многих других, используя то же сетевое оборудование. Производительность QNX в сети приближается к про- изводительности аппаратного обеспечения. При проектировании системы реального времени, как правило, необходимо обес- печить одновременное выполнение нескольких приложений. В QNX/Neutrino1 параллельность выполнения достигается за счет использования потоковой моде- ли POSIX, в которой процессы в системе представляются в виде совокупности потоков. Поток является минимальной единицей выполнения и диспетчериза- ции для ядра Neutrino, процесс определяет адресное пространство для потоков. Каждый процесс состоит минимум из одного потока. QNX представляет богатый набор функций для синхронизации потоков. В отличие от потоков само ядро не подлежит диспетчеризации. Код ядра исполняется только в том случае, когда ка- кой-нибудь поток вызывает функцию ядра или при обработке аппаратного пре- рывания. Напомним, что QNX базируется на концепции передачи сообщений. Переда- чу сообщений, а также их диспетчеризацию осуществляет ядро системы. Кроме того, ядро управляет временными прерываниями. Выполнение остальных функ- ций обеспечивается задачами-администраторами. Программа, желающая создать задачу, посылает сообщение администратору задач (модуль task) и блокируется для ожидания ответа. Если новая задача должна выполняться одновременно с порождающей ее задачей, администратор задач task создает ее и, отвечая, выдает порождающей задаче идентификатор (id) созданной задачи. В противном случае никакого сообщения не посылается до тех пор, пока новая задача не закончится сама по себе. В этом случае в ответе администратора задач будут содержаться ко- нечные характеристики закончившейся задачи. Сообщения отличаются количеством данных, которые передаются от одной задачи точно к другой задаче. Данные копируются из адресного пространства первой задачи в адресное пространство второй, и выполнение первой задачи при- останавливается до тех пор, пока вторая задача не вернет ответное сообщение. В действительности обе задачи кратковременно взаимодействуют во время выполнения передачи. Ничто, кроме длины сообщения (максимальная длина 65 535 байт), не заботит QNX при передаче сообщения. Существует несколько протоколов, которые могут быть использованы для сообщений. Основные операции над сообщениями — это послать, получить и ответить, а также несколько их вариантов для обработки специальных ситуаций. Получа- тель всегда идентифицируется своим идентификатором задачи, хотя существуют 1 Neutrino — один из проектов микроядерной ОС.
342 Глава 8. Современные операционные системы способы ассоциировать имена с идентификатором задачи. Наиболее интересные варианты операций включают в себя возможность получать (копировать) только первую часть сообщения, а затем получать оставшуюся часть такими кусками, какие потребуются. Это может быть использовано для того, чтобы сначала уз- нать длину сообщения, а затем динамически распределить принимающий буфер. Если необходимо задержать ответное сообщение до тех пор, пока не будет полу- чено и обработано другое сообщение, то чтение первых нескольких байт дает вам компактный «обработчик», через который позже можно получить доступ ко все- му сообщению. Таким образом, ваша задача предохраняется от того, чтобы хра- нить в себе большое количество буферов. Другие функции позволяют программе получать сообщения только тогда, когда она уже ожидает их приема, а не блокироваться до тех пор, пока не прибудет со- общение, и транслировать сообщение к другой задаче без изменения идентифи- катора передатчика. Задача, которая транслировала сообщение, в транзакции не- видима. Кроме этого, QNX обеспечивает объединение сообщений в структуру данных, называемую очередью. Очередь — это просто область данных в третьей, отдель- ной задаче, которая временно принимает передаваемое сообщение и немедленно отвечает передатчику. В отличие от стандартной передачи сообщений, передат- чик немедленно освобождается для того, чтобы продолжить свою работу. Задача администратора очереди хранит.в себе сообщение до тех пор, пока приемник не будет готов прочитать его; это он делает путем запроса сообщения у администра- тора очереди. Любое количество сообщений (ограничено только возможностью памяти) может храниться в очереди. Они хранятся и передаются в том порядке, в котором они были приняты. Может быть создано любое количество очередей. Каждая очередь идентифицируется своим именем. Помимо сообщений и очередей в QNX для взаимодействия задач и организации распределенных вычислений имеются так называемые порты, которые позволя- ют формировать сигнал одного конкретного условия, и механизм исключений, о котором мы уже упоминали выше. Порт подобен флагу, известному всем задачам на одном и том же узле (но не на различных узлах). Он имеет точно два состояния, которые могут трактоваться как «присоединить» и «освободить», хотя пользователь может использовать свою интерпретацию; например, «занят» и «доступен». Порты используются для бы- строй простой синхронизации между задачей и обработчиком прерываний уст- ройства. Они нумеруются от нуля до максимум 32 (на некоторых типах узлов возможно и больше). Первые 20 номеров зарезервированы для использования операционной системой. С портом могут быть выполнены три операции: □ присоединить порт; □ отсоединить порт; □ послать сигнал в порт. Одновременно к порту может быть присоединена только одна задача. Если дру- гая задача попытается «отсоединиться» от того же самого порта, то произойдет
Сетевая ОС реального времени QNX 343 отказ при вызове функции, и управление вернется к задаче, которая в настоя- щий момент присоединена к этому порту. Это самый быстрый способ обнару- жить идентификатор другой задачи, подразумевая, что задачи могут договорить- ся использовать один номер порта. Напомним, что все рассматриваемые задачи должны находиться на одном и том же узле. При работе нескольких узлов спе- циальные функции обеспечивают большую гибкость и эффективность. Любая задача может посылать сигнал в любой порт независимо от того, была ли она присоединена к нему или нет (предпочтительно, чтобы не была присоедине- на). Сигнал подобен не блокирующей передаче пустого сообщения. То есть пере- датчик не приостанавливается, а приемник не получает какие-либо данные; он только отмечает, что конкретный порт изменил свое состояние. Задача, присоединенная к порту, может ожидать прибытия сигнала или может периодически читать порт. QNX хранит информацию о сигналах, передаваемых в каждый порт, и уменьшает счетчик после каждой операции «приема» сигнала («чтение» возвращает счетчик и устанавливает его в нуль). Сигналы всегда при- нимаются перед сообщениями, давая им тем самым больший приоритет над со- общениями. В этом смысле сигналы часто используются обработчиками преры- ваний для того, чтобы оповестить задачу о внешних (аппаратных) событиях (действительно, обработчики прерываний не имеют возможности посылать со- общения и должны использовать сигналы). В отличие от описанных выше Методов, которые строго синхронизируются, «ис- ключения» обеспечивают асинхронное взаимодействие. То есть исключение мо- жет прервать нормальное выполнение потока задачи. Они, таким образом, явля- ются аварийными событиями. QNX резервирует для себя 16 исключений для того, чтобы оповещать задачи о прерывании с клавиатуры, нарушении памяти и подобных необычных ситуациях. Остальные 16 исключений могут быть опре- делены и использованы прикладными задачами. Системная функция может быть вызвана для того, чтобы позволить задаче управ- лять своей собственной обработкой исключений, выполняя свою собственную внутреннюю функцию во время возникновения исключения. Заметим, что функция исключения задачи вызывается асинхронно операционной системой, а не самой задачей. Вследствие этого исключения могут иметь сильно- действующее побочное влияние на операции (например, передачу сообщений), которые выполняются в это же время. Обработчики исключений должны быть написаны очень аккуратно. Одна задача может установить одно или несколько исключений на другой зада- че. Они могут быть комбинацией системных исключений (определенных выше) и исключений, определяемых приложениями, что обеспечивает другие возмож- ности для межзадачного взаимодействия. Благодаря такому свойству QNX, как возможность обмена посланиями между задачами и узлами сети, программы не заботятся о конкретном размещении ре- сурсов в сети. Это свойство придает системе необычную гибкость. Так, узлы мо- гут произвольно добавляться и изыматься из системы, не затрагивая системные программы. QNX приобретает эту конфигурационную независимость благодаря применению концепции «виртуальных» задач. У виртуальных задач непосредст-
344 Глава 8. Современные операционные системы венный код и данные, будучи на одном из удаленных узлов, возникают и ведут себя так, как если бы они были локальными задачами какого-то узла со всеми ат- рибутами и привилегиями. Программа, посылающая сообщение в сети, никогда не посылает его точно. Сначала она открывает «виртуальную цепочку». Вирту- альная цепочка включает все виртуальные задачи, связанные между собой. На обоих концах такой связи имеются буферы, которые позволяют хранить самое большое послание из тех, которые цепочка может нести в данном сеансе связи. Сетевой администратор помещает в эти буферы все сообщения для соединенных задач. Виртуальная задача, таким образом, занимает всего лишь пространство, необходимое для буфера и входа в таблице задач. Чтобы открыть связь, необхо- димо знать идентификатор узла и задачи, с которой устанавливается связь. Для этого необходимо знать идентификатор задачи администратора, ответственного за данную функцию, или глобальное имя сервера. Не раскрывая здесь подробно механизм обмена посланиями, добавим лишь, что наша задача может вообще вы- полняться на другом узле, где, допустим, имеется более совершенный процессор. Контрольные вопросы и задачи Вопросы для проверки 1. Изложите основные архитектурные особенности ОС UNIX. 2. Перечислите и поясните основные понятия системы UNIX. 3. Что делает системный вызов fork()? Каким образом осуществляется в UNIX запуск новой задачи? 4. Изложите основные моменты, связанные с защитой файлов в UNIX. 5. Расскажите об особенностях семафоров в UNIX. Зачем в семафорных опера- циях действия осуществляются сразу над множеством семафоров? 6. Что представляет собой вызов удаленной процедуры (RPC)? 7. Какие механизмы использует OS/2, чтобы уменьшить потребности в опера- тивной памяти и увеличить производительность системы? 8. Почему про QNX часто говорят «сетевая» ОС? 9. Что такое сетевой протокол FLEET? 10. Какие функции реализует ядро QNX? 11. В чем вы видите принципиальные отличия между ядром Windows NT 4.0, ко- торое считают построенным по микроядерным принципам, от ядра QNX? 12. Расскажите об основных механизмах, которые имеются в QNX для организа- ции распределенных вычислений.
ЧАСТЬ II Трансляторы, формальные языки и грамматики Давно уже минули времена, когда программирование представляло собой «маги- ческие» действия узкого круга сведущих людей над последовательностями дан- ных в виде нулей и единиц. Такая картина характерна была только для самых первых ЭВМ первого поколения. Нечего и говорить о том, каким адским трудом была эта работа и какой сложной задачей становились поиск и исправление оши- бок в созданной таким образом программе. Подобное положение дел не только вело к непомерным трудозатратам, но и искусственно ограничивало круг задач, решаемых с помощью ЭВМ, а потому не могло сохраняться долго. Уже в последующих ЭВМ первого поколения появился язык мнемокода (впо- следствии — язык ассемблера) для более понятного человеку представления машинных команд, а затем, во втором поколении вычислительных машин, воз- никли и прочно заняли свое место в решении прикладных задач другие, более сложные языки программирования. И в дальнейшем все развитие средств про- граммирования шло только в направлении повышения именно «дружественно- сти» к человеку, пользователю вычислительной машины. Поскольку компьютеры традиционной архитектуры, принципы которой были заложены еще фон Нейманом, не умеют понимать ничего другого, кроме кодов машинных команд, то возникла необходимость в появлении «переводчиков» с различных языков программирования на язык машинных кодов. Такими пере- водчиками и стали трансляторы. Кстати, и само слово «транслятор» (translator) в переводе с английского означает не что иное, как «переводчик». Наряду с тер- мином «транслятор» часто употребляется еще термин «компилятор» (compiler),
346 Часть II. Трансляторы, формальные языки и грамматики имеющий почти то же значение. Разница между ними объясняется в той главе, в которой даются точные определения этим понятиям; здесь же можно только сказать, что «транслятор» — понятие более широкое, а «компилятор» — более уз- кое (любой компилятор является транслятором, но не наоборот). Традиционная архитектура компьютера (архитектура фон Неймана) остается неизменной и преобладает в современных вычислительных системах. Столь же неизменными остаются и базовые принципы, на основе которых строятся сред- ства разработки программного обеспечения для компьютеров — трансляторы, компиляторы и интерпретаторы. Видимо, этим объясняется практически полное отсутствие современных публикаций в этой области, а те, что известны, являют- ся не достаточно широко доступными (автор может выделить книги и публика- ции [4, 13, 18, 26, 27, 35, 40, 45, 47]). Тем не менее современные средства разра- ботки, оставаясь на тех же базовых принципах, что и компьютеры традиционной архитектуры, прошли долгий путь совершенствования и развития от командных систем до интегрированных сред и систем программирования. И это обстоятель- ство нашло отражение в предлагаемом учебном пособии. Ныне существует огромное количество разнообразных языков программирова- ния. Все они имеют свою историю, свою область применения, и перечислять даже наиболее известные из них здесь не имеет смысла. Но все эти языки по- строены на основе одних и тех же принципов, основы которых определяет тео- рия формальных языков и грамматик. Именно теоретическим основам посвящены четыре первые главы пособия, поскольку для построения транслятора необходи- мо знать все те принципы, на основе которых он работает. Первая глава посвя- щена общим принципам и определениям, на которых построена теория формаль- ных языков и грамматик; вторая глава посвящена регулярным языкам, а третья и четвертая — контекстно-свободным языкам и грамматикам. В пятой и шестой главах рассматривается работа компилятора как генератора кода результирующей программы. Она рассматривается с точки зрения сущест- вующих примеров и методов реализации, поскольку семантика языков програм- мирования и генерация кода, к сожалению, не основаны на столь же «красивом», математически обоснованном аппарате, как анализ текста исходной программы в компиляторе. Наконец, все современные компиляторы являются составной частью системного программного обеспечения. Они составляют основу всех современных средств разработки как прикладных, так и системных программ. Принципы организации и структура современных систем программирования рассматриваются в послед- ней (седьмой) главе. Там же приводятся примеры некоторых современных сис- тем программирования с разъяснением принципов, положенных в их основу.
ГЛАВА 9 Формальные языки и грамматики Языки и цепочки символов. Способы задания языков Цепочки символов. Операции над цепочками символов Цепочкой символов (или строкой ) называют произвольную последовательность символов, записанных один за другим. Понятие символа (или буквы) является базовым в теории формальных языков и не нуждается в определении. Далее цепочки символов будем обозначать греческими буквами: а, р, у. Итак, цепочка — это последовательность, в которую могут входить любые допус- тимые символы. Строка, которую вы сейчас читаете, является примером цепочки, допустимые символы в которой — строчные и заглавные русские буквы, знаки препинания и символ пробела. Но цепочка — это необязательно некоторая ос- мысленная последовательность символов. Последовательность ^аввв..аагрьь ,.лл» — тоже пример цепочки символов. Для цепочки символов важен состав и количество символов в ней, а также поря- док символов в цепочке. Один и тот же символ может произвольное число раз входить в цепочку. Поэтому цепочки «а» и «аа», а также «яб» и «6я» — это раз- личные цепочки символов. Цепочки символов аир равны (совпадают), а = р, если они имеют один и тот же состав символов, одно и то же их количество и одинаковый порядок следования символов в цепочке. Количество символов в цепочке называют длиной цепочки. Длина цепочки сим- волов а обозначается как |а|. Очевидно, что если а = р, то и |а| = |р|. Основной операцией над цепочками символов является операция конкатенации (объединения или сложения) цепочек.
348 Глава 9. Формальные языки и грамматики Конкатенация (сложение, объединение) двух цепочек символов — это дописы- вание второй цепочки в конец первой. Конкатенация цепочек а и р обозначает- ся как ар. Выполнить конкатенацию цепочек просто: например, если а - «аб», а р - «ег», то ар в «ябвг». Так как в цепочке важен порядок символов, то очевидно, что операция конкате- нации не обладает свойством коммутативности, то есть в общем случае Заир такие, что аР^Ра. Также очевидно, что конкатенация обладает свойством ассо- циативности, то есть (аР)у - а(ру). Можно выделить еще две операции над цепочками. Обращение цепочки — это запись символов цепочки в обратном порядке. Обра- щение цепочки а обозначается как aR. Если a = «ябег», то aR = «гебя». Для опе- рации обращения справедливо следующее равенство V a,P: (aP)R = pRaR. Итерация (повторение) цепочки п раз, где neN, п > 0 — это конкатенация це- почки самой с собой п раз. Итерация цепочки a п раз обозначается как an. Для операции повторения справедливы следующие равенства V а: а1 = а, а2 я аа, а3 = ааа, ... и т. д. Среди всех цепочек символов выделяется одна особенная — пустая цепочка. Пустая цепочка символов — это цепочка, не содержащая ни одного символа. Пустую цепочку здесь везде будем обозначать греческой буквой X (в литературе ее иногда обозначают латинской буквой е или греческой е). Для пустой цепочки справедливы следующие равенства: 1. N = 0; 2. Va: Xa = aX = a; 3. XR = X; 4. Vn>0: V = X; 5. Va: a0 = X. Понятие языка. Формальное определение языка В общем случае язык — это заданный набор символов и правил, устанавливаю- щих способы комбинации этих символов между собой для записи осмысленных текстов. Основой любого естественного или искусственного языка является ал- фавит, определяющий набор допустимых символов языка. Алфавит — это счетное множество допустимых символов языка. Будем обозна- чать это множество символом V. Интересно, что согласно формальному опре- делению, алфавит не обязательно должен быть конечным (перечислимым) мно- жеством, но реально все существующие языки строятся на основе конечных алфавитов. Цепочка символов а является цепочкой над алфавитом V: a(V), если в нее вхо- дят только символы, принадлежащие множеству символов V. Для любого алфа- вита V пустая цепочка X может как являться, так и не являться цепочкой X(V). Это условие оговаривается дополнительно.
Языки и цепочки символов. Способы задания языков 349 Если V — некоторый алфавит, то: □ V+ — множество всех цепочек над алфавитом V без X; □ V* — множество всех цепочек над алфавитом V, включая X. Справедливо равенство: V* = V+ u {X}. Языком L над алфавитом V: L(V) называется некоторое счетное подмножество цепочек конечной длины из множества всех цепочек над алфавитом V. Из этого определения следуют два вывода: во-первых, множество цепочек языка не обяза- но быть конечным; во-вторых, хотя каждая цепочка символов, входящая в язык, обязана иметь конечную длину, эта длина может быть сколь угодно большой и формально ничем не ограничена. Все существующие языки подпадают под это определение. Большинство реаль- ных естественных и искусственных языков содержат бесконечное множество це- почек. Также в большинстве языков длина цепочки ничем не ограничена (напри- мер, этот длинный текст — пример цепочки символов русского языка). Цепочку символов, принадлежащую заданному языку, часто называют предложением языка, а множество цепочек символов некоторого языка L(V) — множеством предложе- ний этого языка. Для любого языка L(V) справедливо: L(V) с V*. Язык L(V) включает в себя язык L'(V): L’(V)cL(V), если V aeL(V): aeL'(V). Множество цепочек языка L'(V) является подмножеством множества цепочек языка L(V) (или эти множества совпадают). Очевидно, что оба языка должны строится на основе одного и того же алфавита. Два языка L(V) и L’(V) совпадают (эквивалентны): L’(V) = L(V), если L’(V)cL(V) и L(V)cL’(V); или, что то же самое: V a€L'(V): aeL(V) и V aeL’(V): aeL(V). Множества допустимых цепочек символов для эквивалентных языков должны быть равны. Два языка L(V) и L’(V) почти эквивалентны: L’(V) = L(V), если L'(V)u{X} e - L(V)u{X}. Множества допустимых цепочек символов почти эквивалентных языков могут различаться только на пустую цепочку символов. Способы задания языков Итак, каждый язык — это множество цепочек символов над некоторым алфави- том. Но кроме алфавита язык предусматривает и задание правил построения до- пустимых цепочек, поскольку обычно далеко не все цепочки над заданным алфа- витом принадлежат языку. Символы могут объединяться в слова или лексемы — элементарные конструкции языка, на их основе строятся предложения — более сложные конструкции. И те и другие в общем виде являются цепочками симво- лов, но предусматривают некоторые правила построения. Таким образом, необ- ходимо указать эти правила, или, строго говоря, задать язык. Язык задать можно тремя способами: 1. Перечислением всех допустимых цепочек языка. 2. Указанием способа порождения цепочек языка (заданием грамматики языка). 3. Определением метода распознавания цепочек языка.
350 Глава 9. Формальные языки и грамматики Первый из методов является чисто формальным и на практике не применяется, так как большинство языков содержат бесконечное число допустимых цепочек и перечислить их просто невозможно. Трудно себе представить, чтобы появи- лась возможность перечислить, например, множество всех правильных текстов на русском языке или всех правильных программ на языке Pascal. Иногда, для чисто формальных языков, можно перечислить множество входящих в них цепо- чек, прибегнув к математическим определениям множеств. Однако этот метод уже стоит ближе ко второму способу. Например, запись L({0,1}) e {0nln, п > 0} задает язык над алфавитом V = {0,1}, со- держащий все последовательности с чередующимися символами 0 и 1, начинаю- щиеся с 0 и заканчивающиеся 1. Видно, что пустая цепочка символов в этот язык не входит. Если изменить условие в этом определении с п > 0 на п>0, то получим почти эквивалентный язык L'({0,1}), содержащий пустую цепочку. Второй способ предусматривает некоторое описание правил, с помощью кото- рых строятся цепочки языка. Тогда любая цепочка, построенная с помощью этих правил из символов алфавита языка, будет принадлежать заданному языку. На- пример, с правилами построения цепочек символов русского языка вы долго и упорно знакомились в средней школе. Третий способ предусматривает построение некоторого логического устройства (распознавателя) — автомата, который на входе получает цепочку символов, а на выходе выдает ответ: принадлежит или нет эта цепочка заданному языку. На- пример, читая этот текст, вы сейчас в некотором роде выступаете в роли распо- знавателя (надеюсь, что ответ о принадлежности текста русскому языку будет положительным). Синтаксис и семантика языка Говоря о любом языке, можно выделить его синтаксис и семантику. Кроме того, трансляторы имеют дело также с лексическими конструкциями (лексемами), ко- торые задаются лексикой языка. Ниже даны определения для всех этих понятий. Синтаксис языка — это набор правил, определяющий допустимые конструкции языка. Синтаксис определяет «форму языка» — задает набор цепочек символов, которые принадлежат языку. Чаще всего синтаксис языка можно задать в виде строгого набора правил, но полностью это утверждение справедливо только для чисто формальных языков. Даже для большинства языков программирования набор заданных синтаксических конструкций нуждается в дополнительных по- яснениях, а синтаксис языков естественного общения вполне соответствует об- щепринятому мнению о том, что «исключения только подтверждают правило». Например, любой окончивший среднюю школу может сказать, что строка «3+2» является арифметическим выражением, а «3 2 +» — не является. Правда, не каж- дый задумается при этом, что он оперирует синтаксисом алгебры. Семантика языка — это раздел языка, определяющий значение предложений языка. Семантика определяет «содержание языка» — задает значение для всех допустимых цепочек языка. Семантика для большинства языков определяется неформальными методами (отношения между знаками и тем, что они обозначают,
Языки и цепочки символов. Способы задания языков 351 изучаются семиотикой). Чисто формальные языки лишены какого-либо смысла. Возвращаясь к примеру, приведенному выше, и используя семантику алгебры, мы можем сказать, что строка «3 + 2» есть сумма чисел 3 и 2, а также то, что «3 + 2 = 5» — это истинное выражение. Однако изложить любому ученику син- таксис алгебры гораздо проще, чем ее семантику, хотя в данном случае семанти- ку как раз можно определить формально. Лексика — это совокупность слов (словарный запас) языка. Слово или лексиче- ская единица (лексема) языка — это конструкция, которая состоит из элементов алфавита языка и не содержит в себе других конструкций. Иначе говоря, лекси- ческая единица может содержать только элементарные символы и не может со- держать других лексических единиц. Лексическими единицами (лексемами) русского языка являются слова русского языка, а знаки препинания и пробелы представляют собой разделители, не обра- зующие лексем. Лексическими единицами алгебры являются числа, знаки мате- матических операций, обозначения функций и неизвестных величин. В языках программирования лексическими единицами являются ключевые слова, иденти- фикаторы, константы, метки, знаки операций; в них также существуют и разде- лители (запятые, скобки, точки с запятой и т. д.). Особенности языков программирования Языки программирования занимают некоторое промежуточное положение меж- ду формальными и естественными языками. С формальными языками их объ- единяют строгие синтаксические правила, на основе которых строятся предло- жения языка. От языков естественного общения в языки программирования пе- решли лексические единицы, представляющие основные ключевые слова (чаще всего это слова английского языка, но существуют языки программирования, чьи ключевые слова заимствованы из русского и других языков). Кроме того, из алгебры языки программирования переняли основные обозначения математиче- ских операций, что также делает их более понятными человеку. Для задания языка программирования необходимо решить три вопроса: □ определить множество допустимых символов языка; □ определить множество правильных программ языка; □ задать смысл для каждой правильной программы. Только первые два вопроса полностью или частично удается решить с помощью теории формальных языков. Первый вопрос решается легко. Определяя алфавит языка, мы автоматически определяем множество допустимых символов. Для языков программирования алфавит — это чаще всего тот набор символов, которые можно ввести с клавиа- туры. Основу его составляет младшая половина таблицы международной коди- ровки символов (таблицы ASCII), к которой добавляются символы националь- ных алфавитов. Второй вопрос решается в теории формальных языков только частично. Для всех языков программирования существуют правила, определяющие синтаксис
352 Глава 9. Формальные языки и грамматики языка, но как уже было сказано, их недостаточно для того, чтобы строго опре- делить все возможные синтаксические конструкции. Дополнительные ограниче- ния накладываются семантикой языка. Эти ограничения оговариваются в не- формальной форме для каждого отдельного языка программирования. К таким ограничениям можно отнести необходимость предварительного описания пере- менных и функций, необходимость соответствия типов переменных и констант в выражениях, формальных и фактических параметров в вызовах функций и др. Отсюда следует, что практически все языки программирования, строго говоря, не являются формальными языками. И именно поэтому во всех трансляторах, кроме синтаксического разбора и анализа предложений языка, дополнительно предусмотрен семантический анализ. Третий вопрос в принципе не относится к теории формальных языков, посколь- ку, как уже было сказано, такие языки лишены какого-либо смысла. Для ответа на него нужно использовать другие подходы. В качестве таких подходов можно указать следующие: □ изложить смысл программы, написанной на языке программирования, на дру- гом языке, более понятном тому, кому адресована программа; □ использовать для проверки смысла некоторую «идеальную машину», которая предназначена для выполнения программ, написанных на данном языке. Все, кто писал программы, так или иначе обязательно использовали первый под- ход. Комментарии в хорошей программе — это и есть изложение ее смысла. По- строение блок-схемы, а равно любое другое описание алгоритма программы — это тоже способ изложить смысл программы на другом языке (например, языке графических символов блок-схем алгоритмов, смысл которого, в свою очередь, изложен в соответствующем ГОСТе). Да и документация к программе — тоже способ изложения ее смысла. Но все эти способы ориентированы на человека, которому они, конечно же, более понятны. Однако не существует пока универ- сального способа проверить, насколько описание соответствует программе. Машина же понимает только один язык — язык машинных команд. Но изложить программу на языке машинных команд — задача слишком трудоемкая для чело- века, как раз для ее решения и создаются трансляторы. Второй подход используется при отладке программы. Оценку же результатов выполнения программы при отладке выполняет человек. Любые попытки пору- чить это дело машине лишены смысла вне контекста решаемой задачи. Например, предложение в программе на языке Pascal вида: i:=0; while i=0 do i:=0; может быть легко оценено любой машиной как бессмысленное. Но если в задачу входит обеспечить взаимодействие с другой параллельно выполняемой программой или, например, просто проверить надежность и долговечность про- цессора или какой-то ячейки памяти, то это же предложение покажется уже не лишенным смысла. Некоторые успехи в процессе проверки осмысленности программ достигнуты в рамках систем автоматизации разработки программного обеспечения (CASE- системы). Их подход основан на проектировании сверху вниз — от описания за- дачи на языке, понятном человеку, до перевода ее в операторы языка программи-
Определение грамматики. Форма Бэкуса—Наура 353 рования. Но такой подход выходит за рамки возможностей трансляторов, поэто- му здесь рассматриваться не будет. Однако разработчикам компиляторов так или иначе приходится решать вопрос о смысле программ. Во-первых, компилятор должен все-таки преобразовать исходную программу в последовательность машинных команд, а для этого ему необходимо иметь представление о том, какая последовательность команд соот- ветствует той или иной части исходной программы. Обычно такие последова- тельности сопоставляются базовым конструкциям входного языка (далее будет рассмотрено, как это можно сделать). Здесь используется первый подход к изло- жению смысла программы. Во-вторых, многие современные компиляторы по- зволяют выявить сомнительные с точки зрения смысла места в исходной про- грамме — такие, как недостижимые операторы, неиспользуемые переменные, неопределенные результаты функций и т. п. Обычно компилятор указывает та- кие места в виде дополнительных предупреждений, которые разработчик может принимать или не принимать во внимание. Для достижения этой цели компиля- тор должен иметь представление о том, как программа будет выполняться — используется второй подход. Но в обоих случаях осмысление исходной програм- мы закладывает в компилятор его создатель (или коллектив создателей) — то есть человек, который руководствуется неформальными методами (чаще всего, описанием входного языка). В теории формальных языков вопрос о смысле про- грамм не решается. Итак, на основании изложенных положений можно сказать, что возможности трансляторов по проверке осмысленности предложений входного языка сильно ограничены, практически даже равны нулю. Именно поэтому большинство из них в лучшем случае ограничиваются рекомендациями разработчикам по тем местам исходного текста программ, которые вызывают сомнения с точки зрения смысла. Поэтому большинство трансляторов обнаруживает только незначитель- ный процент от общего числа смысловых («семантических») ошибок, а следова- тельно, подавляющее число такого рода ошибок всегда, к большому сожалению, остается на совести автора программы. Определение грамматики. Форма Бэкуса—Наура Понятие о грамматике языка Грамматика — это описание способа построения предложений некоторого язы- ка. Иными словами, грамматика — это математическая система, определяющая язык. Фактически, определив грамматику языка, мы указываем правила порождения цепочек символов, принадлежащих этому языку. Таким образом, грамматика — это генератор цепочек языка. Она относится ко второму способу определения языков — порождению цепочек символов.
354 Глава 9. Формальные языки и грамматики Грамматику языка можно описать различными способами; например, грамма- тика русского языка описывается довольно сложным набором правил, которые изучают в начальной школе. Но для многих языков (и для синтаксической части языков программирования в том числе) допустимо использовать формальное описание грамматики, построенное на основе системы правил (или продукций). Правило (или продукция) — это упорядоченная пара цепочек символов (а,Р). В правилах очень важен порядок цепочек, поэтому их чаще записывают в виде а->р (или а::=р). Такая запись читается как «а порождает р» или «Р по опреде- лению есть а». Грамматика языка программирования содержит правила двух типов: первые (определяющие синтаксические конструкции языка) довольно легко поддают- ся формальному описанию; вторые (определяющие семантические ограничения языка) обычно излагаются в неформальной форме. Поэтому любое описание (или общепринятый стандарт) языка программирования обычно состоит из двух частей: вначале формально излагаются правила построения синтаксических кон- струкций, а потом на естественном языке дается описание семантических пра- вил. Естественный язык понятен человеку, пользователю, который будет писать программы на языке программирования; для компилятора же семантические ограничения необходимо излагать в виде алгоритмов проверки правильности про- граммы (речь, как уже было сказано выше, не идет о смысле программ, а только лишь о семантических ограничениях на исходный текст). Такой проверкой в компиляторе занимается семантический анализатор — специально для этого раз- работанная часть компилятора. Далее, говоря о грамматиках языков программирования, будем иметь в виду только правила построения синтаксических конструкций языка. Однако следует помнить, что грамматика любого языка программирования в общем случае не ог- раничивается только этими правилами. Язык, заданный грамматикой G, обозначается как L(G). Две грамматики G и G’ называются эквивалентными, если они определяют один и тот же язык: L(G) = L(G'). Две грамматики G и G' называются почти эквива- лентными, если заданные ими языки различаются не более чем на пустую цепоч- ку символов: L(G) u {X} = L(G’) и {X}. Формальное определение грамматики. Форма Бэкуса—Наура Для полного формального определения грамматики кроме правил порождения цепочек языка необходимо задать также алфавит языка. Формально грамматика G определяется как четверка G(VT,VN,P,S), где: □ VT — множество терминальных символов; □ VN — множество нетерминальных символов: VNnVT = 0; □ Р —• множество правил (продукций) грамматики вида а->р, где aeV\ PgV*; □ S — целевой (начальный) символ грамматики SgVN.
Определение грамматики. Форма Бэкуса—Наура 355 Множество V e VNuVT называют полным алфавитом грамматики G. Рассмотрим множества VN и VT. Множество терминальных символов VT содер- жит символы, которые входят в алфавит языка, порождаемого грамматикой. Как правило, символы из множества VT встречаются только в цепочках правых час- тей правил, если же они встречаются в цепочке левой части правила, то обязаны быть и в цепочке правой его части. Множество нетерминальных символов VN содержит символы, которые определяют слова, понятия, конструкции языка. Каж- дый символ этого множества может встречаться в цепочках как левой, так и пра- вой частей правил грамматики, но он обязан хотя бы один раз быть в левой час- ти хотя бы одного правила. Правила грамматики строятся так, чтобы в левой части каждого правила был хотя бы один нетерминальный символ. Эти два множества не пересекаются: каждый символ может быть либо терми- нальным, либо нетерминальным. Ни один символ в алфавите грамматики не может быть нетерминальным и терминальным одновременно. Целевой символ грамматики — это всегда нетерминальный символ. Во множестве правил грамматики может быть несколько правил, имеющих оди- наковые правые части, вида: а->рь а->р2, ... а->рп. Тогда эти правила объединя- ют вместе и записывают в виде: а-»р1|р2|...|рп • Одной строке в такой записи соот- ветствует сразу п правил. Такую форму записи правил грамматики называют формой Бэкуса—Наура. Форма Бэкуса—Наура предусматривает, как правило, также, что нетерминальные сим- волы берутся в угловые скобки: < >. Иногда знак «-» в правилах грамматики заменяют на знак «::=» (что характерно для старых монографий), но это всего лишь незначительные модификации формы записи, не влияющие на ее суть. Ниже приведен пример грамматики для целых десятичных чисел со знаком: G({0,1,2,3,4,5,6,7,8.9,-,+},{<ч исло>.<чс>.<цифра>}.Р.<число>) Р: <число> -> <чс> | +<чс> | -<чс> <чс> -> <цифра> | <чс><цифра> <цифра> —> 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 Рассмотрим составляющие элементы грамматики G: □ множество терминальных символов VT содержит двенадцать элементов: де- сять десятичных цифр и два знака; □ множество нетерминальных символов VN содержит три элемента: символы <число>, <чс> и <цифра>; □ множество правил содержит 15 правил, которые записаны в три строки (то есть имеются только три различных правых части правил); □ целевым символом грамматики является символ <число>. Следует отметить, что символ <чс> — это бессмысленное сочетание букв русско- го языка, но это обычный нетерминальный символ грамматики, такой же, как и два других. Названия нетерминальных символов не обязаны быть осмысленны- ми, это сделано просто для удобства понимания правил грамматики человеком. В принципе в любой грамматике можно полностью изменить имена всех нетер-
356 Глава 9. Формальные языки и грамматики минальных символов, не меняя при этом языка, заданного грамматикой, — точно также, например, в программе на языке Pascal можно изменить имена идентифи- каторов, и при этом не изменится смысл программы. Для терминальных символов это неверно. Набор терминальных символов всегда строго соответствует алфавиту языка, определяемого грамматикой. Вот, например, та же самая грамматика для целых десятичных чисел со знаком, в которой нетерминальные символы обозначены большими латинскими буквами (далее это будет часто применяться в примерах): G'({0.1.2.3.4.5.6.7.8.9.-.+}.{S.T.F}.P.S) Р: S -> Т | +Т | -Т Т -> F I TF F —> 0 | 1 | 2 ] 3 | 4 | 5 ] 6 | 7 | 8 | 9 Здесь изменилось только множество нетерминальных символов. Теперь VN = = {S.T.F}. Язык, заданный грамматикой, не изменился — грамматики G и G' эк- вивалентны. Принцип рекурсии в правилах грамматики Особенность формальных грамматик в том, что они позволяют определить бес- конечное множество цепочек языка с помощью конечного набора правил (конеч- но, множество цепочек языка тоже может быть конечным, но даже для простых реальных языков это условие обычно не выполняется). Приведенная выше в примере грамматика для целых десятичных чисел со знаком определяет беско- нечное множество целых чисел с помощью 15 правил. Возможность пользоваться конечным набором правил достигается в такой фор- ме записи грамматики за счет рекурсивных правил. Рекурсия в правилах грам- матики выражается в том, что один из нетерминальных символов определяется сам через себя. Рекурсия может быть непосредственной (явной) — тогда символ определяется сам через себя в одном правиле, либо косвенной (неявной) — тогда то же самое происходит через цепочку правил. В рассмотренной выше грамматике G непосредственная рекурсия присутствует в правиле: <чс>—><чс><цифра>, а в эквивалентной ей грамматике G’ — в правиле: Т—>TF. Чтобы рекурсия не была бесконечной, для участвующего в ней нетерминального символа грамматики должны существовать также и другие правила, которые оп- ределяют его, минуя самого себя, и позволяют избежать бесконечного рекурсив- ного определения (в противном случае этот символ в грамматике был бы просто не нужен). Такими правилами являются <чс>-><цифра> — в грамматике G и T->F — в грамматике G’. В теории формальных языков более ничего сказать о рекурсии нельзя. Но, чтобы полнее понять смысл рекурсии, можно прибегнуть к семантике языка — в рас- смотренном выше примере это язык целых десятичных чисел со знаком. Рас- смотрим его смысл.
Определение грамматики. Форма Бэкуса—-Наура 357 Если попытаться дать определение тому, что же является числом, то начать мож- но с того, что любая цифра сама по себе есть число. Далее можно заметить, что любые две цифры — это тоже число, затем — три цифры и т. д. Если строить определение числа таким методом, то оно никогда не будет закончено (в матема- тике разрядность числа ничем не ограничена). Однако можно заметить, что каж- дый раз, порождая новое число, мы просто дописываем цифру справа (посколь- ку привыкли писать слева направо) к уже написанному ряду цифр. А этот ряд цифр, начиная от одной цифры, тоже в свою очередь является числом. Тогда определение для понятия «число» можно построить таким образом: «число — это любая цифра, либо другое число, к которому справа дописана любая цифра». Именно это и составляет основу правил грамматик G и G' и отражено во второй строке правил в правилах <чс>--><цифра> | <чс><цифра> и Т->F |TF. Другие правила в этих грамматиках позволяют добавить к числу знак (первая строка правил) и дают определение понятию «цифра» (третья строка правил). Они элементарны и не требуют пояснений. Принцип рекурсии (иногда его называют «принцип итерации», что не меняет сути) — важное понятие в представлении о формальных грамматиках. Так или иначе, явно или неявно рекурсия всегда присутствует в грамматиках любых ре- альных языков программирования. Именно она позволяет строить бесконечное множество цепочек языка, и говорить об их порождении невозможно без пони- мания принципа рекурсии. Как правило, в грамматике реального языка програм- мирования содержится не одно, а целое множество правил, построенных с помо- щью рекурсии. Другие способы задания грамматик Форма Бэкуса—Наура — удобный с формальной точки зрения, но не всегда дос- тупный для понимания способ записи формальных грамматик. Рекурсивные определения хороши для формального анализа цепочек языка, но не удобны с точки зрения человека. Например, то, что правила <чс>--><цифра> | <чс><цифра> отражают возможность для построения числа дописывать справа любое число цифр, начиная от одной, неочевидно и требует дополнительного пояснения. Но при создании языка программирования важно, чтобы его грамматику пони- мали не только те, кому предстоит создавать компиляторы для этого языка, но и пользователи языка — будущие разработчики программ. Поэтому существуют другие способы описания правил формальных грамматик, которые ориентирова- ны на большую понятность человеку. Далее рассмотрим два наиболее распространенных из этих способов: запись пра- вил грамматик с использованием метасимволов и запись правил грамматик в графическом виде. Запись правил грамматик с использованием метасимволов Запись правил грамматик с использованием метасимволов предполагает, что в строке правила грамматики могут встречаться специальные символы — мета-
358 Глава 9. Формальные языки и грамматики символы, — которые имеют особый смысл и трактуются специальным образом. В качестве таких метасимволов чаще всего используются следующие символы: О (круглые скобки), [] (квадратные скобки), {} (фигурные скобки), «,» (запя- тая) и " ” (кавычки). Эти метасимволы имеют следующий смысл: □ круглые скобки означают, что из всех перечисленных внутри них цепочек символов в данном месте правила грамматики может стоять только одна це- почка; □ квадратные скобки означают, что указанная в них цепочка может встречать- ся, а может и не встречаться в данном месте правила грамматики (то есть мо- жет быть в нем один раз или ни одного раза); □ фигурные скобки означают, что указанная внутри них цепочка может не встре- чаться в данном месте правила грамматики ни одного раза, встречаться один раз или сколь угодно много раз; □ запятая служит для того, чтобы разделять цепочки символов внутри круглых скобок; □ кавычки используются в тех случаях, когда один из метасимволов нужно включить в цепочку обычным образом — то есть когда одна из скобок или за- пятая должны присутствовать в цепочке символов языка (если саму кавычку нужно включить в цепочку символов, то ее надо повторить дважды — этот принцип знаком разработчикам программ). Вот как должны выглядеть правила рассмотренной выше грамматики G, если их записать с использованием метасимволов: <число> -> [(+.-)]<цифра>{<цифра>} <цифра> ->0|1|2|3|4|5|6|7|8|9 Вторая строка правил не нуждается в комментариях, а первое правило читается так: «число есть цепочка символов, которая может начинаться с символов + или -, должна содержать дальше одну цифру, за которой может следовать последова- тельность из любого количества цифр». В отличие от формы Бэкуса—Наура, в форме записи с помощью метасимволов, как видно, во-первых, убран из грам- матики малопонятный нетерминальный символ <чс>, а во-вторых — удалось пол- ностью исключить рекурсию. Грамматика в итоге стала более понятной. Форма записи правил с использованием метасимволов — это удобный и понят- ный способ представления правил грамматик. Она во многих случаях позволяет полностью избавиться от рекурсии, заменив ее символом итерации {} (фигур- ные скобки). Как будет понятно из дальнейшего материала, эта форма наиболее употребительна для одного из типов грамматик — регулярных грамматик. Запись правил грамматик в графическом виде При записи правил в графическом виде вся грамматика представляется в форме набора специальным образом построенных диаграмм. Эта форма была предло- жена при описании грамматики языка Pascal, а затем она получила широкое рас- пространение в литературе. Она доступна не для всех типов грамматик, а только
Определение грамматики. Форма Бэкуса—Наура 359 для контекстно-свободных и регулярных типов, но этого достаточно, чтобы ее можно было использовать для описания грамматик известных языков програм- мирования. В такой форме записи каждому нетерминальному символу грамматики соответ- ствует диаграмма, построенная в виде направленного графа. Граф имеет следую- щие типы вершин: □ точка входа (на диаграмме никак не обозначена, из нее просто начинается входная дуга графа); □ нетерминальный символ (на диаграмме обозначается прямоугольником, в ко- торый вписано обозначение символа); □ цепочка терминальных символов (на диаграмме обозначается овалом, кругом или прямоугольником с закругленными краями, внутрь которого вписана це- почка); □ узловая точка (на диаграмме обозначается жирной точкой или закрашенным кружком); □ точка выхода (никак не обозначена, в нее просто входит выходная дуга графа). Каждая диаграмма имеет только одну точку входа и одну точку выхода, но сколь- ко угодно вершин других трех типов. Вершины соединяются между собой на- правленными дугами графа (линиями со стрелками). Из входной точки дуги могут только выходить, а во входную точку — только входить. В остальные вер- шины дуги могут как входить, так и выходить (в правильно построенной грам- матике каждая вершина должна иметь как минимум один вход и как минимум один выход). Чтобы построить цепочку символов, соответствующую какому-либо нетерми- нальному символу грамматики, надо рассмотреть диаграмму для этого символа. Тогда, начав движение от точки входа, надо двигаться по дугам графа диаграммы через любые вершины вплоть до точки выхода. При этом, проходя через верши- ну, обозначенную нетерминальным символом, этот символ следует поместить в результирующую цепочку. При прохождении через вершину, обозначенную цепочкой терминальных символов, эти символы также следует поместить в ре- зультирующую цепочку. При прохождении через узловые точки диаграммы над результирующей цепочкой никаких действий выполнять не надо. Через любую вершину графа диаграммы, в зависимости от возможного пути движения, можно пройти один раз, ни разу или сколь угодно много раз. Как только мы попадем в точку выхода диаграммы, построение результирующей цепочки закончено. Результирующая цепочка, в свою очередь, может содержать нетерминальные символы. Чтобы заменить их на цепочки терминальных символов, нужно, опять же, рассматривать соответствующие им диаграммы. И так до тех пор, пока в це- почке не останутся только терминальные символы. Очевидно, что для того, что- бы построить цепочку символов заданного языка, надо начать рассмотрение с диаграммы целевого символа грамматики. Это удобный способ описания правил грамматик, оперирующий образами, а по- тому ориентированный исключительно на людей. Даже простое изложение его основных принципов здесь оказалось довольно громоздким, в то время как суть
360 Глава 9. Формальные языки и грамматики способа довольно проста. Это можно легко заметить, если посмотреть на описа- ние понятия «число» из грамматики G с помощью диаграмм на рис. 9.1. Число: Рис. 9.1. Графическое представление грамматики целых десятичных чисел со знаком: вверху — для понятия «число»; внизу — для понятия «цифра» Как уже было сказано выше, данный способ в основном применяется в литерату- ре при изложении грамматик языков программирования. Для пользователей — разработчиков программ — он удобен, но практического применения в компиля- торах пока не имеет. Классификация языков и грамматик Выше уже упоминались различные типы грамматик, но не было указано, как и по какому принципу они подразделяются на типы. Для человека языки быва- ют простые и сложные, но это сугубо субъективное мнение, которое зачастую за- висит от личности человека. Для компиляторов языки также можно разделить на простые и сложные, но в данном случае существуют жесткие критерии для этого разделения. Как будет показано далее, от того, к какому типу относится тот или иной язык программи-
Классификация языков и грамматик 361 рования, зависит сложность распознавателя для этого языка. Чем сложнее язык, тем выше вычислительные затраты компилятора на анализ цепочек исходной программы, написанной на этом языке, а следовательно, сложнее сам компиля- тор и его структура. Для некоторых типов языков в принципе невозможно по- строить компилятор, который бы анализировал исходные тексты на этих языках за приемлемое время на основе ограниченных вычислительных ресурсов (имен- но поэтому до сих пор невозможно создавать программы на естественных язы- ках, например на русском или английском). Классификация грамматик. Четыре типа грамматик по Хомскому Формальные грамматики классифицируются по структуре их правил. Если все без исключения правила грамматики удовлетворяют некоторой заданной струк- туре, то ее относят к определенному типу. Достаточно иметь в грамматике одно правило, не удовлетворяющее требованиям структуры правил, и она уже не по- падает в заданный тип. По классификации Хомского выделяют четыре типа грамматик. Тип О: грамматики с фразовой структурой На структуру их правил не накладывается никаких ограничений: для граммати- ки вида G(VT,VN,P,S), V = VNuVT правила имеют вид: а->Р, где ае V+, V*. Это самый общий тип грамматик. В него подпадают все без исключения фор- мальные грамматики, но часть из них, к общей радости, может быть также отне- сена и к другим классификационным типам. Дело в том, что грамматики, которые относятся только к типу 0 и не могут быть отнесены к другим типам, являются самыми сложными по структуре. Практического применения грамматики, относящиеся только к типу 0, не имеют. Тип 1: контекстно-зависимые (КЗ) и неукорачивающие грамматики В этот тип входят два основных класса грамматик. Контекстно-зависимые грамматики G(VT,VN,P,S), V = VNuVT имеют правила вида: atAa2->atPa2, где aba2GV*, AgVN, PgV+. Неукорачивающие грамматики G(VT,VN,P,S), V = VNu VT имеют правила вида: а->р, где a,PeV+, |Р|>|а|. Структура правил КЗ-грамматик такова, что при построении предложений за- данного ими языка один и тот же нетерминальный символ может быть заменен на ту или иную цепочку символов в зависимости от того контекста, в котором он встречается. Именно поэтому эти грамматики называют «контекстно-зависимы- ми». Цепочки at и а2 в правилах грамматики обозначают контекст (at — левый контекст, а а2 — правый контекст), в общем случае любая из них (или даже обе) может быть пустой. Говоря иными словами, значение одного и того же символа может быть различным в зависимости от того, в каком контексте он встречается.
362 Глава 9. Формальные языки и грамматики Неукорачивающие грамматики имеют такую структуру правил, что при построе- нии предложений языка, заданного грамматикой, любая цепочка символов мо- жет быть заменена на цепочку символов не меньшей длины. Отсюда и название «неукорачивающие». Доказано, что эти два класса грамматик эквивалентны. Это значит, что для лю- бого языка, заданного контекстно-зависимой грамматикой, можно построить неукорачивающую грамматику, которая будет задавать эквивалентный язык, и наоборот: для любого языка, заданного неукорачивающей грамматикой, мож- но построить контекстно-зависимую грамматику, которая будет задавать эквива- лентный язык. При построении компиляторов такие грамматики не применяются, поскольку языки программирования, рассматриваемые компиляторами, имеют более про- стую структуру и могут быть построены с помощью грамматик других типов. Тип 2: контекстно-свободные (КС) грамматики Контекстно-свободные (КС) грамматики G(VT,VN,P,S), V - VNuVT имеют правила вида: А-»р, где AgVN, PgV+. Такие грамматики также иногда называют неукорачивающими контекстно-свободными (НКС) грамматиками (видно, что в правой части правила у них должен всегда стоять как минимум один символ). Существует также почти эквивалентный им класс грамматик — укорачивающие контекстно-свободные (УКС) грамматики G(VT,VN,P,S), V = VNuVT, правила которых могут иметь вид: А-»р, где AgVN, |3gV*. Разница между этими двумя классами грамматик заключается лишь в том, что в УКС-грамматиках в правой части правил может присутствовать пустая це- почка (X), а в НКС-грамматиках — нет. Отсюда ясно, что язык, заданный НКС- грамматикой, не может содержать пустой цепочки. Доказано, что эти два класса грамматик почти эквивалентны. В дальнейшем, когда речь будет идти о КС- грамматиках, уже не будет уточняться, какой класс грамматики (УКС или НКС) имеется в виду, если возможность наличия в языке пустой цепочки не имеет принципиального значения. КС-грамматики широко используются при описании синтаксических конструк- ций языков программирования. Синтаксис большинства известных языков про- граммирования основан именно на КС-грамматиках, поэтому в данном курсе им уделяется большое внимание. Внутри типа КС-грамматик кроме классов НКС и УКС выделяют еще целое множество различных классов грамматик, и все они относятся к типу 1. Далее, когда КС-грамматики и КС-языки будут рассматриваться более подробно, на не- которые из этих классов грамматик и их характерные особенности будет обраще- но особое внимание. Тип 3: регулярные грамматики К типу регулярных относятся два эквивалентных класса грамматик: леволиней- ные и праволинейные.
Классификация языков и грамматик 363 Леволинейные грамматики G(VT,VN,P,S), V - VNuVT могут иметь правила двух видов: А-»Ву или А->у, где A,BgVN, yeVT*. В свою очередь, праволинейные грамматики G(VT,VN,P,S), V = VNuVT могут иметь правила тоже двух видов: А->уВ или А->у, где A,BeVN, yeVT*. Эти два класса грамматик эквивалентны и относятся к типу регулярных грам- матик. Регулярные грамматики используются при описании простейших конструкций языков программирования: идентификаторов, констант, строк, комментариев и т. д. Эти грамматики исключительно просты и удобны в использовании, поэтому в компиляторах на их основе строятся функции лексического анализа входного языка (принципы их построения будут рассмотрены далее). Типы грамматик соотносятся между собой особым образом. Из определения типов 2 и 3 видно, что любая регулярная грамматика является КС-грамматикой, но не наоборот. Также очевидно, что любая грамматика может быть отнесена и к типу 0, поскольку он не накладывает никаких ограничений на правила. В то же время существуют укорачивающие КС-грамматики (тип 2), которые не являют- ся ни контекстно-зависимыми, ни неукорачивающими (тип 1), поскольку могут содержать правила вида «А—>Х», недопустимые в типе 1. В целом можно сказать, что сложность грамматики обратно пропорциональна тому максимально воз- можному номеру типа, к которому может быть отнесена грамматика. Граммати- ки, которые относятся только к типу 0, являются самыми сложными, а грамма- тики, которые можно отнести к типу 3 — самыми простыми. Классификация языков Языки классифицируются в соответствии с типами грамматик, с помощью кото- рых они заданы. Причем, поскольку один и тот же язык в общем случае может быть задан сколь угодно большим количеством грамматик, которые могут отно- ситься к различным классификационным типам, то для классификации самого языка среди всех его грамматик всегда выбирается грамматика с максимально возможным классификационным типом. Например, если язык L может быть за- дан грамматиками Gj и G2, относящимися к типу 1 (контекстно-зависимые), грамматикой G3, относящейся к типу 2 (контекстно-свободные), и грамматикой G4, относящейся к типу 3 (регулярные), то сам язык должен быть отнесен к типу 3 и является регулярным языком. От классификационного типа языка зависит не только то, с помощью какой грамматики можно построить предложения этого языка, но также и то, насколь- ко сложно распознать эти предложения. Распознать предложения — значит по- строить распознаватель для языка (третий способ задания языка). Сами распо- знаватели, их структура и классификация будут рассмотрены далее, здесь же можно указать, что сложность распознавателя языка напрямую зависит от клас- сификационного типа, к которому относится язык. Сложность языка убывает с возрастанием номера классификационного типа языка. Самыми сложными являются языки типа 0, самыми простыми — языки типа 3. Согласно классификации грамматик, существуют также четыре типа языков.
364 Глава 9. Формальные языки и грамматики Тип О: языки с фразовой структурой Это самые сложные языки, которые могут быть заданы только грамматикой, от- носящейся к типу 0. Для распознавания цепочек таких языков требуются вычис- лители, равномощные машине Тьюринга. Поэтому можно сказать, что если язык относится к типу 0, то для него невозможно построить компилятор, который гарантированно выполнял бы разбор предложений языка за ограниченное время на основе ограниченных вычислительных ресурсов. К сожалению, практически все естественные языки общения между людьми, строго говоря, относятся именно к этому типу языков. Дело в том, что структура и значение фразы естественного языка может зависеть не только от контекста данной фразы, но и от содержания того текста, где эта фраза встречается. Одно и то же слово в естественном языке может не только иметь разный смысл в зави- симости от контекста, но и играть различную роль в предложении. Именно по- этому столь велики сложности в автоматизации перевода текстов, написанных на естественных языках, а также отсутствуют (и видимо, никогда не появятся) компиляторы, которые бы воспринимали программы на основе таких языков. Далее языки с фразовой структурой рассматриваться не будут. Тип 1: контекстно-зависимые (КЗ) языки Тип 1 — второй по сложности тип языков. В общем случае время на распознава- ние предложений языка, относящегося к типу 1, экспоненциально зависит от длины исходной цепочки символов. Языки и грамматики, относящиеся к типу 1, применяются в анализе и переводе текстов на естественных языках. Распознаватели, построенные на их основе, по- зволяют анализировать тексты с учетом контекстной зависимости в предложе- ниях входного языка (но они не учитывают содержание текста, поэтому в общем случае для точного перевода с естественного языка все же требуется вмешатель- ство человека). На основе таких грамматик может выполняться автоматизиро- ванный перевод с одного естественного языка на другой, ими могут пользоваться сервисные функции проверки орфографии и правописания в языковых процес- сорах. В компиляторах КЗ-языки не используются, поскольку языки программирова- ния имеют более простую структуру, поэтому здесь они подробно не рассматри- ваются. Тип 2: контекстно-свободные (КС) языки КС-языки лежат в основе синтаксических конструкций большинства современ- ных языков программирования, на их основе функционируют некоторые до- вольно сложные командные процессоры, допускающие управляющие команды цикла и условия. Эти обстоятельства определяют распространенность данного класса языков. В общем случае время на распознавание предложений языка, относящегося к типу 1, полиномиально зависит от длины исходной цепочки символов (в зависи- мости от класса языка это либо кубическая, либо квадратичная зависимость).
Классификация языков и грамматик 365 Однако среди КС-языков существует много классов языков, для которых эта за- висимость линейна. Многие языки программирования можно отнести к одному из таких классов. КС-языки подробно рассматриваются в главе «Контекстно-свободные языки» данного учебного пособия. Тип 3: регулярные языки Регулярные языки — самый простой тип языков. Наверное, поэтому они явля- ются самым распространенным и широко используемым типом языков в облас- ти вычислительных систем. Время на распознавание предложений регулярного языка линейно зависит от длины исходной цепочки символов. Как уже было сказано выше, регулярные языки лежат в основе простейших кон- струкций языков программирования (идентификаторов, констант и т. п.), кро- ме того, на их основе строятся многие мнемокоды машинных команд (языки ассемблеров), а также командные процессоры, символьные управляющие коман- ды и другие подобные структуры. Регулярные языки — очень удобное средство. Для работы с ними можно исполь- зовать регулярные множества и выражения, конечные автоматы. Регулярные языки подробно рассматриваются в следующей главе учебного пособия. Примеры классификации языков и грамматик Классификация языков идет от простого к сложному. Если мы имеем дело с ре- гулярным языком, то можно утверждать, что он также является и контекстно- свободным, и контекстно-зависимым, и даже языком с фразовой структурой. В то же время известно, что существуют КС-языки, которые не являются регу- лярными, и существуют КЗ-языки, которые не являются ни регулярными, ни контекстно-свободными. Далее приводятся примеры некоторых языков указанных типов. Рассмотрим в качестве первого примера ту же грамматику для целых десятич- ных чисел со знаком G({0,l,2,3,4,5,6,7,8,9, - ,+},{S,T,F},P,S): Р: S -> Т | +Т | -Т Т -> F I TF F->0|l|2|3|4|5|6|7|8|9 По структуре своих правил данная грамматика G относится к контекстно-сво- бодным грамматикам (тип 2). Конечно, ее можно отнести и к типу 0, и к типу 1, но максимально возможным является именно тип 2, поскольку к типу 3 эту грамматику отнести никак нельзя: строка T->F | TF содержит правило T->TF, которое недопустимо для типа 3, и, хотя все остальные правила этому типу соот- ветствует, одного несоответствия достаточно. Для того же самого языка (целых десятичных чисел со знаком) можно построить и другую грамматику G' ({0,1,2,3,4,5,6,7,8,9,-,+},{8,T},P,S):
366 Глава 9. Формальные языки и грамматики Р: S -> Т | +Т | -Т Т -> О I 1 I 2 I 3 I 4 I 5 I 6 I 7 I 8 I 9 I ОТ I IT I 2Т I ЗТ I 4Т I 5Т I 6Т I 7Т I 8Т I 9Т По структуре своих правил грамматика^' является праволинейной и может быть отнесена к регулярным грамматикам (тип 3). Для этого же языка можно построить эквивалентную леволинейную грамматику (тип 3) G"({0,1,2,3,4,5,6,7,8,9,-,+},{S.T},P,S): Р: Т -> + | - | X S -> ТО I Т1 I Т2 I ТЗ I Т4 I Т5 I Тб I Т7 I Т8 I T9 ) SO I SI I S2 I S3 I S4 I S5 I S6 | S7 | S8 | S9 Следовательно, язык целых десятичных чисел со знаком, заданный грамматика- ми G, G’ и G", относится к регулярным языкам (тип 3). В качестве второго примера возьмем грамматику G2({0,l},{A,S},P,S) с правила- ми Р: S -> 0А1 ОА -> 00А1 А -> л Эта грамматика относится к типу 0. Она определяет язык, множество предложе- ний которого можно было бы записать так: L(G2) = {0nln | n > 0}. Для этого же языка можно построить также контекстно-зависимую грамматику G2' ({0,1},{A,S},Р’,S) с правилами Р’: S -> 0А1 | 01 ОА -> 00А1 | 001 Однако для того же самого языка можно использовать и контекстно-свободную грамматику G2"({0,l},{S},P”,S) с правилами Р”: S -> 0S1 I 01 Следовательно, язык L = {0nln | п > 0} является контекстно-свободным (тип 2). В третьем примере рассмотрим грамматику G3({a,b,c},{B,C,D,S},P,S) с правила- ми Р: S -> BD В -> аВЬС | ab СЬ -> ЬС CD -> De bDc -> bcc abD -> abc Эта грамматика относится к типу 1. Очевидно, что она является неукорачиваю- щей. Она определяет язык, множество предложений которого можно было бы за- писать так: L(G3) = {anbncn | n > 0}. Известно, что этот язык не является КС-язы- ком, поэтому для него нельзя построить грамматики типов 2 или 3. Язык L = {anbncn | п > 0} является контекстно-зависимым (тип 1). Конечно, для произвольного языка, заданного некоторой грамматикой, в общем случае довольно сложно так легко определить его тип. Не всегда можно так
Цепочки вывода. Сентенциальная форма 367 просто построить грамматику максимально возможного типа для произвольного языка. К тому же при строгом определении типа требуется еще доказать, что две грамматики (первоначально имеющаяся и вновь построенная) эквивалентны, а также то, что не существует для того же языка грамматики с большим по номе- ру типом. Это нетривиальная задача, которую не так легко решить. Для многих языков, и в частности для КС-языков и регулярных языков, сущест- вуют специальным образом сформулированные утверждения, которые позволя- ют проверить принадлежность языка к указанному типу. Такие утверждения (леммы) будут рассмотрены далее в соответствующих главах этого учебника. Тогда для произвольного языка достаточно лишь доказать нужное утверждение, и после этого можно точно утверждать, что данный язык относится к тому или иному типу. Преобразование грамматик в этом случае не требуется. Тем не менее иногда возникает задача построения для имеющегося языка грам- матики более простого типа, чем данная. И даже в том случае, когда тип языка уже известен, эта задача остается нетривиальной и в общем случае не имеет фор- мального решения (проблема преобразования грамматик рассматривается далее). Цепочки вывода. Сентенциальная форма Вывод. Цепочки вывода Выводом называется процесс порождения предложения языка на основе правил определяющей язык грамматики. Чтобы дать формальное определение процессу вывода, необходимо ввести еще несколько дополнительных понятий. Цепочка р = 8 !у82 называется непосредственно выводимой из цепочки а = в грамматике G(VT,VN,P,S), V = VTuVN, 8Ь у, 82 g V*, со g V+, если в граммати- ке G существует правило: со->у g Р. Непосредственная выводимость цепочки р из цепочки а обозначается так: а=>р. Иными словами, цепочка р выводима из цепочки а в том случае, если можно взять несколько символов в цепочке а, заме- нить их на другие символы согласно некоторому правилу грамматики и полу- чить цепочку р. В формальном определении непосредственной выводимости любая из цепочек Si или 82 (а равно и обе эти цепочки) может быть пустой. В предельном случае вся цепочка а может быть заменена на цепочку р, тогда в грамматике G должно существовать правило: а->р g Р. Цепочка р называется выводимой из цепочки а (обозначается а=>*Р) в том слу- чае, если выполняется одно из двух условий: □ р непосредственно выводима из а (а=>Р); □ 3 у, такая, что: у выводима из а и р непосредственно выводима из у (а=>*у и у=>Р). Это рекурсивное определение выводимости цепочки. Суть его заключается в том, что цепочка р выводима из цепочки а, если а=>р или же если можно построить последовательность непосредственно выводимых цепочек от а к р следующего
368 Глава 9. Формальные языки и грамматики вида: a=>y1=>...=>yi=>...=>yn=>p, п>1. В этой последовательности каждая последую- щая цепочка у, непосредственно выводима из предыдущей цепочки уи. Такая последовательность непосредственно выводимых цепочек называется вы- водом или цепочкой вывода. Каждый переход от одной непосредственно выводи- мой цепочки к следующей в цепочке вывода называется шагом вывода. Очевидно, что шагов вывода в цепочке вывода всегда на один больше, чем промежуточных цепочек. Если цепочка р непосредственно выводима из цепочки а: а=>р, то име- ется всего один шаг вывода. Если цепочка вывода из а к Р содержит одну или более промежуточных цепочек (два или более шагов вывода), то она имеет специальное обозначение а=>+р (го- ворят, что цепочка Р нетривиально выводима из цепочки а). Если количество шагов вывода известно, то его можно указать непосредственно у знака выводи- мости цепочек. Например, запись а=>4р означает, что цепочка р выводится из це- почки а за 4 шага вывода1. Возьмем в качестве примера ту же грамматику для целых десятичных чисел со знаком G({0,l,2,3,4,5,6,7,8,9,-,+},{S,T,F},P,S): Р: S -> Т | +Т | -Т Т -> F I TF F->0|l|2|3|4|5|6|7|8|9 Построим в ней несколько произвольных цепочек вывода: 1. S => -Т => -TF => -TFF => -FFF => -4FF => -47F => -479 2. S => Т => TF => Т8 => F8 => 18 3. Т => TF => ТО => TFO => Т50 => F50 => 350 4. TFT => TFFT => TFFF => FFFF => 1FFF => 1FF4 => 10F4 => 1004 5. F=>5 Получили следующие выводы: 1. S => * -479 или S => + -479 или S => 7 -479 2. S => * 18 или S => + 18 или S => 5 18 3. Т => * 350 или Т => + 350 или Т => 6 350 4. TFT zz> * 1004 или TFT => + 1004 или TFT => 7 1004 5. F => * 5 или F^1 5 (утверждение F => + 5 неверно!) Все эти выводы построены на основе грамматики G. В принципе в этой грамма- тике (как, практически, и в любой другой грамматике реального языка) можно построить сколь угодно много цепочек вывода. Возьмем в качестве второго примера грамматику G3({a,b,c},{B,C,D,S},P,S) с пра- вилами Р, которая уже рассматривалась выше: 1 В литературе встречается также обозначение а=>°р, которое означает, что цепочка а вы- водима из цепочки р за О шагов — иными словами, в таком случае эти цепочки равны: a в р. Подразумевается, что обозначение вывода а=>*р допускает и такое толкование — включает в себя вариант а=>°р.
Цепочки вывода. Сентенциальная форма 369 S —► BD В -> аВЬС | ab СЬ -> ЬС CD -» De bDc -> bcc abD -> abc Как было сказано ранее, она задает язык L(G3) e {0nln | п > 0}. Рассмотрим при- мер вывода предложения «aaaabbbbcccc » языка L(G3) на основе грамматики G3: S BD aBbCD ==> aaBbCbCD aaaBbCbCbCD => aaaabbCbCbCD aaaabbbCCbCD => aaaabbbCbCCD aaaabbbbCCCD aaaabbbbCCDc aaaabbbbCDcc => aaaabbbbDccc => aaaabbbbcccc. Тогда для грамматики G3 получаем вывод: S => * aaaabbbbcccc. Иногда, чтобы пояснить ход вывода, над стрелкой, обозначающей каждый шаг вывода, пишут обозначение того правила грамматики, на основе которого сделан этот шаг (для этой цели правила грамматики проще всего просто пронумеровать в порядке их следования). Грамматика, рассмотренная в приведенных здесь при- мерах, содержит всего 15 правил, и на каждом шаге в цепочках вывода можно понять, на основании какого правила сделан этот шаг (читатели могут легко сде- лать это самостоятельно), но в более сложных случаях пояснения к шагам выво- да с указанием номеров правил грамматики могут быть весьма полезными. Сентенциальная форма грамматики. Язык, заданный грамматикой Вывод называется законченным, если на основе цепочки 0, полученной в резуль- тате вывода, нельзя больше сделать ни одного шага вывода. Иначе говоря, вывод называется законченным, если цепочка 0, полученная в результате вывода, пус- тая или содержит только терминальные символы грамматики G(VT,VN,P,S): 0gVT*. Цепочка 0, полученная в результате законченного вывода, называется конечной цепочкой вывода. В рассмотренном выше примере все построенные выводы являются законченны- ми, а, например, вывод S =>* -4FF (из первой цепочки в примере) будет незакон- ченным. Цепочка символов aeV* называется сентенциальной формой грамматики G(VT, VN,P,S), V = VTuVN, если она выводима из целевого символа грамматики S: S =>* а. Если цепочка ae VT* получена в результате законченного вывода, то она называется конечной сентенциальной формой. Из рассмотренного выше примера можно заключить, что цепочки символов «—479» и «18» являются конечными сентенциальными формами грамматики целых десятичных чисел со знаком, так как существуют выводы S =>* -479 и S =>* 18 (примеры 1 и 2). Цепочка F8 из вывода 2, например, тоже является сентенциальной формой, поскольку справедливо S =>* F8, но она не является конечной цепочкой вывода. В то же время в выводах примеров 3—5 явно не присутствуют сентенци- альные формы. На самом деле цепочки «350», «1004» и «5» являются конечны- ми сентенциальными формами. Чтобы доказать это, необходимо просто постро- ить другие цепочки вывода (например, для цепочки «5» строим: S => Т => F => 5
370 Глава 9. Формальные языки и грамматики и получаем S => * 5). А вот цепочка «TFT» (пример 4) не выводима из целевого символа грамматики S, а потому сентенциальной формой не является. Язык L, заданный грамматикой G(VT,VN,P,S), — это множество всех конечных сентенциальных форм грамматики G. Язык L, заданный грамматикой G, обозна- чается как L(G). Очевидно, что алфавитом такого языка L(G ) будет множество терминальных символов грамматики VT, поскольку все конечные сентенциаль- ные формы грамматики — это цепочки над алфавитом VT. Следует помнить, что две грамматики G(VT,VN,P,S) и G'(VT’,VN’,P’,S') называ- ются эквивалентными, если эквивалентны заданные ими языки: L(G) = L(G'). Очевидно, что эквивалентные грамматики должны иметь, по крайней мере, пере- секающиеся множества терминальных символов VTnVT * 0 (как правило, эти множества даже совпадают VT = VT’), а вот множества нетерминальных симво- лов, правила грамматики и целевой символ у них могут кардинально отличаться. Левосторонний и правосторонний выводы Вывод называется левосторонним, если в нем на каждом шаге вывода правило грамматики применяется всегда к крайнему левому нетерминальному символу в цепочке. Другими словами, вывод называется левосторонним, если на каж- дом шаге вывода происходит подстановка цепочки символов на основании пра- вила грамматики вместо крайнего левого нетерминального символа в исходной цепочке. Аналогично, вывод называется правосторонним, если в нем на каждом шаге вы- вода правило грамматики применяется всегда к крайнему правому нетерминаль- ному символу в цепочке. Если рассмотреть цепочки вывода из того же примера, то в нем выводы 1 и 5 яв- ляются левосторонними, выводы 2, 3 и 5 — правосторонними (вывод 5 одновре- менно является и лево- и правосторонним), а вот вывод 4 не является ни лево- сторонним, ни правосторонним. Для грамматик типов 2 и 3 (КС-грамматик и регулярных грамматик) для любой сентенциальной формы всегда можно построить левосторонний или правосто- ронний выводы. Для грамматик других типов это не всегда возможно, так как по структуре их правил не всегда можно выполнить замену крайнего левого или крайнего правого нетерминального символа в цепочке. А вот рассмотренный выше вывод S => * aaaabbbbcccc для грамматики G3, задаю- щей язык L(G3) = {0n 1 П|п > 0}, не является ни левосторонним, ни правосторон- ним. Грамматика относится к типу 1, и в данном случае для нее нельзя постро- ить такой вывод, на каждом шаге которого только один нетерминальный символ заменялся бы на цепочку символов. Дерево вывода. Методы построения дерева вывода Деревом вывода грамматики G(VT,VN,P,S) называется дерево (граф), которое соответствует некоторой цепочке вывода и удовлетворяет следующим условиям:
Цепочки вывода. Сентенциальная форма 371 □ каждая вершина дерева обозначается символом грамматики Ag(VT uVN); □ корнем дерева является вершина, обозначенная целевым символом граммати- ки — S; □ листьями дерева (концевыми вершинами) являются вершины, обозначенные терминальными символами грамматики или символом пустой цепочки X; □ если некоторый узел дерева обозначен символом AgVN, а связанные с ним узлы — символами bbb2,...,bn; п > 0, Vn > i > 0: bjG(VTuVNu{Z}), то в грамма- тике G(VT,VN,P,S) существует правило А-^ЬрЬг,...,^ g Р. Из определения видно, что по структуре правил дерево вывода в указанном виде всегда можно построить только для грамматик типов 2 и 3 (контекстно-свобод- ных и регулярных). Для грамматик других типов дерево вывода в таком виде можно построить не всегда (либо же оно будет иметь несколько иной вид). На основе рассмотренного выше примера построим деревья вывода для цепочек вывода 1 и 2. Эти деревья приведены на рис. 9.2. Рис. 9.2. Примеры деревьев вывода для грамматики целых десятичных чисел со знаком Для того чтобы построить дерево вывода, достаточно иметь только цепочку вывода. Дерево вывода можно построить двумя способами: сверху вниз и снизу вверх. Для строго формализованного построения дерева вывода всегда удобнее пользоваться строго определенным выводом: либо левосторонним, либо право- сторонним. При построении дерева вывода сверху вниз построение начинается с целевого символа грамматики, который помещается в корень дерева. Затем в грамматике выбирается необходимое правило, и на первом шаге вывода корневой символ раскрывается на несколько символов первого уровня. На втором шаге среди всех концевых вершин дерева выбирается крайняя (крайняя левая — для левосторон- него вывода, крайняя правая — для правостороннего) вершина, обозначенная нетерминальным символом, для этой вершины выбирается нужное правило грамматики, и она раскрывается на несколько вершин следующего уровня. По- строение дерева заканчивается, когда все концевые вершины обозначены терми- нальными символами, в противном случае надо вернуться ко второму шагу и продолжить построение.
372 Глава 9. Формальные языки и грамматики Построение дерева вывода снизу вверх начинается с листьев дерева. В качестве листьев выбираются терминальные символы конечной цепочки вывода, которые на первом шаге построения образуют последний уровень (слой) дерева. Построе- ние дерева идет по слоям. На втором шаге построения в грамматике выбирается правило, правая часть которого соответствует крайним символам в слое дерева (крайним правым символам при правостороннем выводе и крайним левым — при левостороннем). Выбранные вершины слоя соединяются с новой вершиной, которая выбирается из левой части правила. Новая вершина попадает в слой де- рева вместо выбранных вершин. Построение дерева закончено, если достигнута корневая вершина (обозначенная целевым символом), а иначе надо вернуться ко второму шагу и повторить его над полученным слоем дерева. Поскольку все известные языки программирования имеют нотацию записи «сле- ва — направо», компилятор также всегда читает входную программу слева на- право (и сверху вниз, если программа разбита на несколько строк). Поэтому для построения дерева вывода методом «сверху вниз», как правило, используется ле- восторонний вывод, а для построения «снизу вверх» — правосторонний вывод. На эту особенность компиляторов стоит обратить внимание. Нотация чтения программ «слева направо» влияет не только на порядок разбора программы компилятором (для пользователя это, как правило, не имеет значения), но и на порядок выполнения операций — при отсутствии скобок большинство равно- правных операций выполняются в порядке слева направо, а это уже имеет суще- ственное значение. Проблемы однозначности и эквивалентности грамматик Однозначные и неоднозначные грамматики Рассмотрим некоторую грамматику G({+,-,*,/,(,),a,b},{S},P,S): Р: S -> S+S | S-S | S*S | S/S | (S) | а | b Видно, что представленная грамматика определяет язык арифметических выра- жений с четырьмя основными операциями (сложение, вычитание, умножение и деление) и скобками над операндами а и Ь. Примерами предложений этого язы- ка могут служить: а*Ь+а, а*(а+Ь), а*Ь+а*а и т. д. Возьмем цепочку а*Ь+а и построим для нее левосторонний вывод. Получится два варианта: s => S+S S*S+S a*S+S a*b+S a*b+a S => S*S => a*S => a*S+S => a*b+S => a*b+a Каждому из этих вариантов будет соответствовать свое дерево вывода. Два вари- анта дерева вывода для цепочки «а*Ь+а» приведены на рис. 9.3. С точки зрения формального языка, заданного грамматикой, не имеет значения, какая цепочка вывода и какое дерево вывода из возможных вариантов будут построены. Однако для языков программирования, которые не являются чисто
Проблемы однозначности и эквивалентности грамматик 373 формальными языками и несут некоторую смысловую нагрузку, это имеет зна- чение. Например, если принять во внимание, что рассмотренная здесь граммати- ка определяет язык неких арифметических выражений, то с точки зрения ариф- метики порядок построения дерева вывода соответствует порядку выполнения арифметических действий. В арифметике, как известно, при отсутствии скобок умножение всегда выполняется раньше сложения (умножение имеет более высо- кий приоритет), но в рассмотренной выше грамматике это ниоткуда не следу- ет — в ней все операции равноправны. Поэтому с точки зрения арифметических операций приведенная грамматика имеет неверную семантику — в ней нет при- оритета операций, а, кроме того, для равноправных операций не определен поря- док выполнения («слева направо»), хотя синтаксическая структура построенных с ее помощью выражений будет правильной. Рис. 9.3. Два варианта дерева цепочки «а*Ь+а» вывода для неоднозначной грамматики арифметических выражений Такая ситуация называется неоднозначностью в грамматике. Естественно, для построения компиляторов и языков программирования нельзя использовать грам- матики, допускающие неоднозначности. Дадим более точное определение неод- нозначной грамматики. Грамматика называется однозначной, если для каждой цепочки символов языка, заданного этой грамматикой, можно построить единственный левосторонний (и единственный правосторонний) вывод. Или, что то же самое: грамматика называется однозначной, если для каждой цепочки символов языка, заданного этой грамматикой, существует единственное дерево вывода. В противном случае грамматика называется неоднозначной. Рассмотренная в примере грамматика арифметических выражений, очевидно, является неоднозначной. Эквивалентность и преобразование грамматик Поскольку грамматика языка программирования, по сути, всегда должна быть однозначной, то возникают два вопроса, которые необходимо в любом случае ре- шить: □ как проверить, является ли данная грамматика однозначной? □ если заданная грамматика является неоднозначной, то как преобразовать ее к однозначному виду?
374 Глава 9. Формальные языки и грамматики Однозначность — это свойство грамматики, а не языка. Для некоторых языков, заданных неоднозначными грамматиками, иногда удается построить эквивалент- ную однозначную грамматику (однозначную грамматику, задающую тот же язык). Чтобы убедиться в том, что некоторая грамматика не является однозначной (яв- ляется неоднозначной), согласно определению достаточно найти в заданном ею языке хотя бы одну цепочку, которая бы допускала более чем один левосторон- ний или правосторонний вывод (как это было в рассмотренном примере). Одна- ко не всегда удается легко обнаружить такую цепочку символов. Кроме того, если такая цепочка не найдена, мы не можем утверждать, что данная грамматика является однозначной, поскольку перебрать все цепочки языка невозможно — как правило, их бесконечное количество. Следовательно, нужны другие способы проверки однозначности грамматики. Если грамматика все же является неоднозначной, то необходимо преобразовать ее в однозначный вид. Как правило, это возможно. Например, для рассмотрен- ной выше неоднозначной грамматики арифметических выражений над операн- дами а и b существует эквивалентная ей однозначная грамматика следующего вида G'({+,-,*,/,(,),a,b},{SЛ,EJ.P’.S): Р’: S -> S+T I S-Т I т Т -> Т*Е I Т/Е I Е Е -> (S) | а | b В этой грамматике для рассмотренной выше цепочки символов языка а*Ь+а воз- можен только один левосторонний вывод: S => S+T => Т+Т => Т*Е+Т => Е*Е+Т => а*Е+Т => а*Ь+Т => а*Ь+Е => а*Ь+а Этому выводу соответствует единственно возможное дерево вывода. Оно приве- дено на рис. 9.4. Видно, что хотя цепочка вывода несколько удлинилась, но при- оритет операций в данном случае единственно возможный и соответствует их порядку в арифметике. Рис. 9.4. Дерево вывода для однозначной грамматики арифметических выражений В таком случае необходимо решить две проблемы: во-первых, доказать, что две имеющиеся грамматики эквивалентны (задают один и тот же язык); во-вторых,
Проблемы однозначности и эквивалентности грамматик 375 иметь возможность проверить, что вновь построенная грамматика является од- нозначной. Проблема эквивалентности грамматик в общем виде формулируется следующим образом: имеются две грамматики G и G’, необходимо построить алгоритм, кото- рый бы позволял проверить, являются ли эти две грамматики эквивалентными. К сожалению, доказано, что проблема эквивалентности грамматик в общем слу- чае алгоритмически неразрешима. Это значит, что не только до сих пор не суще- ствует алгоритма, который бы позволял проверить, являются ли две заданные грамматики эквивалентными, но и доказано, что такой алгоритм в принципе не существует, а значит, он никогда не будет создан. Точно так же неразрешима в общем виде и проблема однозначности грамматик. Это значит, что не существует (и никогда не будет существовать) алгоритм, который бы позволял для произвольной заданной грамматики G проверить, яв- ляется ли она однозначной или нет. Аналогично, не существует алгоритма, кото- рый бы позволял преобразовать заведомо неоднозначную грамматику G в экви- валентную ей однозначную грамматику G’. В общем случае вопрос об алгоритмической неразрешимости проблем однознач- ности и эквивалентности грамматик сводится к вопросу об алгоритмической неразрешимости проблемы, известной как «проблема соответствий Поста». Про- блема соответствий Поста формулируется следующим образом: имеется задан- ное множество пар непустых цепочек над алфавитом V: {(at,pt), (a2,P2)>-> (an,Pn)}, n > 0, Vn > i > 0: otpfteV4’; необходимо проверить, существует ли среди них такая последовательность пар цепочек: (a^PJ, (a2,P2),..., (am,Pm), m > 0 (необязательно различных), что aia2...am e PiP2-Pm- Доказано, что не существует алгоритма, ко- торый бы за конечное число шагов мог дать ответ на этот вопрос, хотя на первый взгляд постановка задачи кажется совсем несложной. То, что проблема не решается в общем виде, совсем не значит, что ее нельзя ре- шить в каждом конкретном случае. Например, для алфавита V s {а,Ь} можно по- строить множество пар цепочек {(abbb.b),(a.aab),(ba.b)} и найти одно из реше- ний: (a.aab),(a.aab),(ba,b),(abbb.b) — видно, что (a)(a)(ba)(abbb) = (aab)(aab) (b)(b). А для множества пар цепочек {(ab,aba),(aba,baa),(baa,aa)} очевидно, что решения не существует. Точно так же неразрешимость проблем эквивалентности и однозначности грам- матик в общем случае совсем не означает, что они не разрешимы вообще. Для не- которых частных случаев — например, для определенных типов и классов грам- матик (в частности, для регулярных грамматик) — эти проблемы решены. Также их иногда удается решить полностью или частично в каждом конкретном случае, и для конкретной заданной грамматики доказать, является ли она однозначной или нет. Например, приведенная выше грамматика G’ для арифметических вы- ражений над операндами а и b относится к классу грамматик операторного пред- шествования из типа КС-грамматик. На основе этой грамматики возможно по- строить распознаватель в виде детерминированного расширенного МП-автомата, а потому можно утверждать, что она является однозначной (см. раздел «Восхо- дящие распознаватели КС-языков без возвратов», глава 12).
376 Глава 9. Формальные языки и грамматики Правила, задающие неоднозначность в грамматиках В общем виде невозможно проверить, является ли заданная грамматика одно- значной или нет. Однако для КС-грамматик существуют определенного вида правила, по наличию которых в множестве правил грамматики G(VT,VN,P,S) можно утверждать, что она является неоднозначной. Эти правила имеют сле- дующий вид: 1. А -> АА | а, 2. А -> АаА | р, 3. А -> аА | Ар | у, 4. А —> аА | аАрА | у, здесь AgVN; a,p,ye(VNuVT)\ Если в заданной грамматике встречается хотя бы одно правило подобного вида (любого из приведенных вариантов), то доказано, что такая грамматика точно будет неоднозначной. Однако если подобных правил во всем множестве правил грамматики нет, это совсем не означает, что грамматика является однозначной. Такая грамматика может быть однозначной, а может и не быть. То есть отсутст- вие правил указанного вида (всех вариантов) — это необходимое, но не достаточ- ное условие однозначности грамматики. С другой стороны, установлены условия, при удовлетворении которым грамма- тика заведомо является однозначной. Они справедливы для всех регулярных и многих классов контекстно-свободных грамматик. Однако известно, что эти ус- ловия, напротив, являются достаточными, но не необходимыми для однозначно- сти грамматик. В рассмотренном выше примере грамматики арифметических выражений с опе- рандами а и b — G({+,-,*,/,(,),a,b},{S},P,S) — во множестве правил Р: S —> S+S | S-S | S*S | S/S | (S) | а | b встречаются правила 2 типа. Поэтому данная грам- матика является неоднозначной, что и было показано выше. Распознаватели. Задача разбора Общая схема распознавателя Для каждого языка программирования (как, наверное, и для многих других язы- ков) важно не только уметь построить текст программы на этом языке, но и оп- ределить принадлежность имеющегося текста к данному языку. Именно эту задачу решают компиляторы в числе прочих задач (компилятор должен не толь- ко распознать исходную программу, но и построить эквивалентную ей результи- рующую программу). В отношении исходной программы компилятор выступает как распознаватель, а человек, создавший программу на некотором языке, высту- пает в роли генератора цепочек этого языка.
Распознаватели. Задача разбора 377 Распознаватель (или разборщик) — это специальный алгоритм, который позво- ляет определить принадлежность цепочки символов некоторому языку. Задача распознавателя заключается в том, чтобы на основании исходной цепочки дать ответ, принадлежит ли она заданному языку или нет. Распознаватели, как было сказано выше, представляют собой один из способов определения языка. В общем виде распознаватель можно отобразить в виде условной схемы, пред- ставленной на рис. 9.5. Входная цепочка символов |ai|a2| Рис. 9.5. Условная схема распознавателя Следует подчеркнуть, что представленный рисунок — всего лишь условная схе- ма, отображающая работу алгоритма распознавателя. Ни в коем случае не стоит искать подобного устройства в составе компьютера. Распознаватель, являющийся частью компилятора, представляет собой часть программного обеспечения ком- пьютера. Как видно из рисунка, распознаватель состоит из следующих основных компо- нентов: □ ленты, содержащей исходную цепочку входных символов, и считывающей го- ловки, обозревающей очередной символ в этой цепочке; □ устройства управления (УУ), которое координирует работу распознавателя, имеет некоторый набор состояний и конечную память (для хранения своего состояния и некоторой промежуточной информации); □ внешней (рабочей) памяти, которая может хранить некоторую информацию в процессе работы распознавателя и в отличие от памяти УУ может иметь не- ограниченный объем. Распознаватель работает с символами своего алфавита — алфавита распознава- теля. Алфавит распознавателя конечен. Он включает в себя все допустимые сим- волы входных цепочек, а также некоторый дополнительный алфавит символов, которые могут обрабатываться УУ и храниться в рабочей памяти распознава- теля. В процессе своей работы распознаватель может выполнять некоторые элемен- тарные операции, такие как чтение очередного символа из входной цепочки, сдвиг входной цепочки на заданное количество символов (вправо или влево), доступ к рабочей памяти для чтения или записи информации, преобразование информации в памяти, изменение состояния УУ. То, какие конкретно операции должны выполняться в процессе работы распознавателя, определяется в УУ.
378 Глава 9. Формальные языки и грамматики Распознаватель работает по шагам или тактам. В начале такта, как правило, счи- тывается очередной символ из входной цепочки, и в зависимости от этого симво- ла УУ определяет, какие действия необходимо выполнить. Вся работа распозна- вателя состоит из последовательности тактов. В начале каждого такта состояние распознавателя определяется его конфигурацией. В процессе работы конфигура- ция распознавателя меняется. Конфигурация распознавателя определяется следующими параметрами: □ содержимое входной цепочки символов и положение считывающей головки в ней; □ состояние УУ; □ содержимое внешней памяти. Для распознавателя всегда задается определенная конфигурация, которая счи- тается начальной конфигурацией. В начальной конфигурации считывающая головка обозревает первый символ входной цепочки, УУ находится в заданном начальном состоянии, а внешняя память либо пуста, либо содержит строго опре- деленную информацию. Кроме начального состояния для распознавателя задается одна или несколько конечных конфигураций. В конечной конфигурации считывающая головка, как правило, находится за концом исходной цепочки (часто для распознавателей вводят специальный символ, обозначающий конец входной цепочки). Распознаватель допускает входную цепочку символов а, если, находясь в началь- ной конфигурации и получив на вход эту цепочку, он может проделать последо- вательность шагов, заканчивающуюся одной из его конечных конфигураций. Формулировка «может проделать последовательность шагов» более точна, чем прямое указание «проделает последовательность шагов», так как для многих распознавателей при одной и той же входной цепочке символов из начальной конфигурации могут быть допустимы различные последовательности шагов, не все из которых ведут к конечной конфигурации. Язык, определяемый распознавателем, — это множество всех цепочек, которые допускает распознаватель. Далее в главах этого пособия рассмотрены конкретные типы распознавателей для различных типов языков. Но все, что было сказано здесь, относится ко всем без исключения типам распознавателей для всех типов языков. Виды распознавателей Распознаватели можно классифицировать в зависимости от вида составляющих их компонентов: считывающего устройства, устройства управления (УУ) и внеш- ней памяти. По видам считывающего устройства распознаватели могут быть двусторонние и односторонние. Односторонние распознаватели допускают перемещение считывающей головки по ленте входных символов только в одном направлении. Это значит, что на каж-
Распознаватели. Задача разбора 379 дом шаге работы распознавателя считывающая головка может либо переместить- ся по ленте символов на некоторое число позиций в заданном направлении, либо остаться на месте. Поскольку все языки программирования подразумевают нота- цию чтения исходной программы «слева направо», то так же работают и все рас- познаватели. Поэтому, когда говорят об односторонних распознавателях, то пре- жде всего имеют в виду левосторонние, которые читают исходную цепочку слева направо и не возвращаются назад к уже прочитанной части цепочки. Двусторонние распознаватели допускают, что считывающая головка может пе- ремещаться относительно ленты входных символов в обоих направлениях: как вперед, от начала ленты к концу, так и назад, возвращаясь к уже прочитанным символам. По видам устройства управления распознаватели бывают детерминированные и недетерминированные. Распознаватель называется детерминированным в том случае, если для каждой допустимой конфигурации распознавателя, которая возникла на некотором шаге его работы, существует единственно возможная конфигурация, в которую распо- знаватель перейдет на следующем шаге работы. В противном случае распознаватель называется недетерминированным. Неде- терминированный распознаватель может иметь такую допустимую конфигу- рацию, для которой существует некоторое конечное множество конфигураций, возможных на следующем шаге работы. Достаточно иметь хотя бы одну такую конфигурацию, чтобы распознаватель был недетерминированным. По видам внешней памяти распознаватели бывают следующих типов: □ распознаватели без внешней памяти; □ распознаватели с ограниченной внешней памятью; □ распознаватели с неограниченной внешней памятью. У распознавателей без внешней памяти эта память полностью отсутствует. В про- цессе их работы используется только конечная память устройства управления, доступ к внешней памяти не выполняется. Для распознавателей с ограниченной внешней памятью размер внешней памяти ограничен в зависимости от длины исходной цепочки символов. Эти ограниче- ния могут налагаться некоторой зависимостью объема памяти от длины цепоч- ки — линейной, полиномиальной, экспоненциальной и т. д. Кроме того, для та- ких распознавателей может быть указан способ организации внешней памяти — стек, очередь, список и т. п. Распознаватели с неограниченной внешней памятью предполагают, что для их работы может потребоваться внешняя память неограниченного объема (как пра- вило, вне зависимости от длины входной цепочки). У таких распознавателей предполагается память с произвольным методом доступа. Вместе эти три составляющих позволяют организовать общую классификацию распознавателей. Например, в этой классификации возможен такой тип: «дву- сторонний недетерминированный распознаватель с линейно ограниченной сте- ковой памятью».
380 Глава 9. Формальные языки и грамматики Тип распознавателя в классификации определяет сложность создания такого распознавателя, а следовательно, сложность разработки соответствующего про- граммного обеспечения для компилятора. Чем выше в классификации стоит рас- познаватель, тем сложнее создавать алгоритм, обеспечивающий его работу. Раз- рабатывать двусторонние распознаватели сложнее, чем односторонние. Можно заметить, что недетерминированные распознаватели по сложности выше детер- минированных. Зависимость затрат на создание алгоритма от типа внешней па- мяти также очевидна. Классификация распознавателей по типам языков Как было показано в предыдущей главе, классификация распознавателей (вид входящих в состав распознавателя компонентов) определяет сложность алгорит- ма работы распознавателя. Но сложность распознавателя также напрямую связа- на с типом языка, входные цепочки которого может принимать (допускать) рас- познаватель. Выше было определено четыре основных типа языков. Доказано, что для каждого из этих типов языков существует свой тип распознавателя с определенным со- ставом компонентов и, следовательно, с заданной сложностью алгоритма работы. Для языков с фразовой структурой (тип 0) необходим распознаватель, равно- мощный машине Тьюринга — недетерминированный двусторонний автомат, имею- щий неограниченную внешнюю память. Поэтому для языков данного типа нель- зя гарантировать, что за ограниченное время на ограниченных вычислительных ресурсах распознаватель завершит работу и примет решение о том, принадлежит или не принадлежит входная цепочка заданному языку. Отсюда можно заклю- чить, что практического применения языки с фразовой структурой не имеют (и не будут иметь), а потому далее они не рассматриваются. Для контекстно-зависимых языков (тип 1) распознавателями являются двусто- ронние недетерминированные автоматы с линейно ограниченной внешней памя- тью. Алгоритм работы такого автомата в общем случае имеет экспоненциальную сложность — количество шагов (тактов), необходимых автомату для распознава- ния входной цепочки, экспоненциально зависит от длины этой цепочки. Следо- вательно, и время, необходимое на разбор входной цепочки по заданному алго- ритму, экспоненциально зависит от длины входной цепочки символов. Такой алгоритм распознавателя уже может быть реализован в программном обес- печении компьютера — зная длину входной цепочки, всегда можно сказать, за какое максимально возможное время будет принято решение о принадлежности цепочки данному языку и какие вычислительные ресурсы для этого потребуют- ся. Однако экспоненциальная зависимость времени разбора от длины цепочки существенно ограничивает применение распознавателей для контекстно-зависи- мых языков. Как правило, такие распознаватели применяются для автоматизи- рованного перевода и анализа текстов на естественных языках, когда временные ограничения на разбор текста несущественны (следует также напомнить, что, по- скольку естественные языки более сложны, чем контекстно-зависимый тип, то после такой обработки часто требуется вмешательство человека). В компилято-
Распознаватели. Задача разбора 381 рах для анализа текстов на различных языках программирования контекстно-за- висимые распознаватели не применяются, поскольку скорость работы компиля- тора имеет существенное значение, а синтаксический разбор текста программы можно выполнять в рамках более простого, контекстно-свободного типа языков. Поэтому в рамках этого учебного пособия контекстно-зависимые языки также не рассматриваются. Для контекстно-свободных языков (тип 2) распознавателями являются односто- ронние недетерминированные автоматы с магазинной (стековой) внешней па- мятью — МП-автоматы. При простейшей реализации алгоритма работы такого автомата он имеет экспоненциальную сложность, однако путем некоторых усо- вершенствований алгоритма можно добиться полиномиальной (кубической) за- висимости времени, необходимого на разбор входной цепочки, от длины этой цепочки. Следовательно, можно говорить о полиномиальной сложности распо- знавателя для КС-языков. Среди всех КС-языков можно выделить класс детерминированных КС-языков, распознавателями для которых являются детерминированные автоматы с мага- зинной (стековой) внешней памятью — ДМП-автоматы. Эти языки обладают свойством однозначности — доказано, что для любого детерминированного КС- языка всегда можно построить однозначную грамматику. Кроме того, для таких языков существует алгоритм работы распознавателя с квадратичной сложно- стью. Поскольку эти языки являются однозначными, именно они представляют наибольший интерес для построения компиляторов. Более того, среди всех детерминированных КС-языков существуют такие классы языков, для которых возможно построить линейный распознаватель — распозна- ватель, у которого время принятия решения о принадлежности цепочки языку имеет линейную зависимость от длины цепочки. Синтаксические конструкции практически всех существующих языков программирования могут быть отнесе- ны к одному из таких классов языков. Это обстоятельство очень важно для раз- работки современных быстродействующих компиляторов. Поэтому в главе, по- священной КС-языкам, в первую очередь будет уделено внимание именно таким классам этих языков. Тем не менее следует помнить, что только синтаксические конструкции языков программирования допускают разбор с помощью распознавателей КС-языков. Сами языки программирования, как уже было сказано, не могут быть полностью отнесены к типу КС-языков, поскольку предполагают некоторую контекстную зависимость в тексте исходной программы (например, такую, как необходимость предварительного описания переменных). Поэтому кроме синтаксического раз- бора практически все компиляторы предполагают дополнительный семантиче- ский анализ текста исходной программы. Этого можно было бы избежать, если построить компилятор на основе контекстно-зависимого распознавателя, но ско- рость работы такого компилятора была бы недопустима низка, поскольку время разбора в таком варианте будет экспоненциально зависеть от длины исходной программы. Комбинация из распознавателя КС-языка и дополнительного семан- тического анализатора является более эффективной с точки зрения скорости разбора исходной программы.
382 Глава 9. Формальные языки и грамматики Для регулярных языков (тип 3) распознавателями являются односторонние неде- терминированные автоматы без внешней памяти — конечные автоматы (КА). Это очень простой тип распознавателя, который всегда предполагает линейную зависимость времени на разбор входной цепочки от ее длины. Кроме того, конеч- ные автоматы имеют важную особенность: любой недетерминированный КА всегда может быть преобразован в детерминированный. Это обстоятельство су- щественно упрощает разработку программного обеспечения, обеспечивающего функционирование распознавателя. Простота и высокая скорость работы распознавателей определяют широкую об- ласть применения регулярных языков. В компиляторах распознаватели на основе регулярных языков используются для лексического анализа текста исходной программы — выделения в нем про- стейших конструкций языка, таких как идентификаторы, строки, константы и т. п. Это позволяет существенно сократить объем исходной информации и упрощает синтаксический разбор программы. Более подробно взаимодействие лексическо- го и синтаксического анализаторов текста программы рассмотрено дальше, в гла- ве, посвященной структуре компилятора. На основе распознавателей регулярных языков функционируют ассемблеры — компиляторы с языков ассемблера (мне- мокода) в язык машинных команд. Кроме компиляторов регулярные языки находят применение еще во многих областях, связанных с разработкой программного обеспечения вычислительных систем. На их основе функционируют многие командные процессоры как в сис- темном, так и в прикладном программном обеспечении. Для регулярных языков существуют развитые, математически обоснованные механизмы, которые позво- ляют облегчить создание распознавателей. Они положены в основу существую- щих разнообразных программных средств, которые позволяют автоматизировать этот процесс. Регулярные языки и связанные с ними математические методы рассматриваются в отдельной главе данного учебного пособия. Задача разбора (постановка задачи) Грамматики и распознаватели — два независимых метода, которые реально мо- гут быть использованы для определения какого-либо языка. Однако при разра- ботке компилятора для некоторого языка программирования возникает задача, которая требует связать между собой эти методы задания языков. Разработчики компилятора всегда имеют дело с уже определенным языком про- граммирования. Грамматика для синтаксических конструкций этого языка из- вестна. Она, как правило, четко описана в стандарте языка, и хотя форма опи- сания может быть произвольной, ее всегда можно преобразовать к требуемому виду (например, к форме Бэкуса—Наура или к форме описания с использованием метасимволов). Задача разработчиков заключается в том, чтобы построить рас- познаватель для заданного языка, который затем будет основой синтаксического анализатора в компиляторе. Таким образом, задача разбора в общем виде заключается в следующем: на осно- ве имеющейся грамматики некоторого языка построить распознаватель для это-
Контрольные вопросы и задачи 383 го языка. Заданная грамматика и распознаватель должны быть эквивалентны, то есть определять один и тот же язык (часто допускается, чтобы они были почти эквивалентны, поскольку пустая цепочка во внимание обычно не принимается). Задача разбора в общем виде может быть решена не для всех типов языков. Но как было сказано выше, разработчиков компиляторов интересуют, прежде всего, контекстно-свободные и регулярные языки. Для данных типов языков доказано, что задача разбора для них разрешима. Болееjoro, для них найдены формальные методы ее решения. Описанию и обоснованию именно методов решения задачи разбора и будет посвящена большая часть материала последующих глав. Поскольку языки программирования не являются чисто формальными языками и несут в себе некоторый смысл (семантику), то задача разбора для создания ре- альных компиляторов понимается несколько шире, чем она формулируется для чисто формальных языков. Компилятор должен не просто дать ответ, принадле- жит или нет входная цепочка символов заданному языку, но и определить ее смысловую нагрузку. Для этого необходимо выявить те правила грамматики, на основании которых цепочка была построена. Фактически работа распознавате- лей в составе компиляторов сводится к построению в том или ином виде дерева разбора входной цепочки. Затем уже это дерево разбора используется компиля- тором для синтеза результирующего кода. Кроме того, если входная цепочка символов не принадлежит заданному языку — исходная программа содержит ошибку, — разработчику программы не интересно просто узнать сам факт наличия ошибки. В данном случае задача разбора также расширяется: распознаватель в составе компилятора должен не только устано- вить факт присутствия ошибки во входной программе, но и по возможности оп- ределить тип ошибки и то место в цепочке'символов, где она встречается. Контрольные вопросы и задачи Вопросы 1. Дайте определение цепочки, языка. Какие операции можно выполнять над цепочками символов? Что такое синтаксис и семантика языка? 2. Какие из перечисленных ниже тождеств являются истинными для двух про- извольных цепочек символов а и 0, а какие нет? |ар| - |а| + |р| = |ра| а0 = 0а |aR| = |а| (a2p2)R = (pRaR)2 (a202)R = (0R)2(aR)2 3. Какие существуют методы задания языков? Почему метод перечисления всех допустимых цепочек языка не находит практического применения?
384 Глава 9. Формальные языки и грамматики 4. Какие дополнительные вопросы необходимо решить при задании языка про- граммирования? Почему любой язык программирования не является чисто формальным языком? 5. Как решается вопрос о смысле исходной программы в современных компиля- торах? 6. Что такое грамматика языка? Дайте определения грамматики. 7. Как выглядит описание грамматики в форме Бэкуса—Наура? Какие еще фор- мы описания грамматик существуют? 8. Почему в форме Бэкуса—Наура практически невозможно построить грамма- тику для реального языка так, чтобы она не содержала рекурсивных правил? 9. Какие типы грамматик выделяют по классификации Хомского? Как они меж- ду собой соотносятся? 10. Какие типы языков выделяют по классификации Хомского? Как классифи- кация языков соотносится с классификацией грамматик? И. Дайте определения выводимости цепочки, непосредственной выводимости, нетривиальной выводимости, длины вывода. 12. Что такое сентенциальная форма грамматики? Если язык, заданный грамма- тикой, представляет собой множество ее конечных сентенциальных форм, то что представляет собой множество всех сентенциальных форм грамматики? 13. Что такое левосторонний и правосторонний выводы? Можно ли построить еще какие-нибудь варианты цепочек вывода? 14. Как классифицируются распознаватели? Как их классификация соотносится с классификацией языков и грамматик? 15. Кто (или что) для любого языка программирования выступает в роли генера- тора цепочек языка? Кто (или что) выступает в роли распознавателя цепо- чек? Как решается задача разбора? Задачи 1. Ниже даны различные варианты грамматик, определяющие язык десятичных чисел с фиксированной точкой. Укажите, к какому типу относится каждая из этих грамматик. К какому типу относится сам язык десятичных чисел с фик- сированной точкой? G/C.". +, 0. 1. 2. 3. 4. 5. 6, 7, 8. 9}. {<число>. <цел.>. <дроб.>, <цифра>, <осн.>, <знак>}, Рр <число>) Pf <число> -> <знак><осн.> <знак> -> X | + | - <осн.> -> <цел.>.<дроб.> | <цел.> <цел.> -> <цифра> | <цифра><цифра> <дроб.> -> X | <цел.> <цифра><цифра> -> <цифра><цифра><цифра> <цифра> ->0|1|2|3|4|5|6|7|8|9
Контрольные вопросы и задачи 385 G2({m.m. +. 0, 1. 2, 3. 4. 5, 6. 7. 8. 9}, {<число>, <часть>. <цифра>. <осн.>}, Р2. <число>) Р2: <ЧИСЛО> -» +<ОСН.> | -<ОСН.> I <осн.> <осн.> -» <часть>.<часть> | <часть>. | <часть> <часть> -» <цифра> | <цифрахцифра> <цифра><цифра> -» <цифра><цифра><цифра> <цифра> -»0|1|2|3|4|5|6|7|8|9 бз({".", *, 0. 1, 2, 3, 4, 5. 6. 7, 8. 9}, {<число>. <часть>. <осн.>}. Р3. <число>) Р3: <ЧИСЛО> -» +<ОСН.> | -<ОСН.> | <осн.> <осн.> -> <часть>. | <часть> | <осн.>0 | <осн.>1 | <осн.>2 | <осн.>3 | <осн.>4 | <осн.>5 | <осн.>6 | <осн.>7 | <осн.>8 | <осн.>9 <часть> ->0|1|2|3|4|5|6|7|8|9| <часть>0 | <часть>1 | <часть>2 | <часть>3 | <часть>4 | <часть>5 | <часть>6 | <часть>7 | <часть>8 | <часть>9 G4({", +. 0, 1. 2. 3. 4. 5, 6. 7, 8. 9}. {<знак>. <число>. <часть>. <осн.>}, Р4. <число>) Рд: <число> -> <часть> | <часть>. | <число>0 | <число>1 | <число>2 | <число>3 | <число>4 | <число>5 | <число>6 | <число>7 | <число>8 | <число>9 <часть> -> <знак>0 | <знак>1 | <знак>2 | <знак>3 | <знак>4 | <знак>5 | <знак>6 | <знак>7 | <знак>8 | <знак>9 | <часть>0 | <часть>1 | <часть>2 | <часть>3 | <часть>4 | <часть>5 | <часть>6 | <часть>7 | <часть>8 | <часть>9 <знак> -> А, | + | - G5({".", +, 0. 1, 2, 3, 4, 5. 6. 7, 8. 9}, {<знак>. <число>. <часть>, <осн.>}. Р5. <число>) Р5: <число> -» <часть>. | <осн.>0 | <осн.>1 | <осн.>2 | <осн.>3 | <осн.>4 | <осн.>5 | <осн.>6 | <осн.>7 | <осн.>8 | <осн.>9 | <часть>0 | <часть>1 | <часть>2 | <часть>3 | <часть>4 | <часть>5 | <часть>6 | <часть>7 | <часть>8 | <часть>9 <осн.> -> <часть>. | <осн.>0 | <осн.>1 | <осн.>2 | <осн.>3 | <осн.>4 | <осн.>5 | <осн.>6 | <осн.>7 | <осн.>8 | <осн.>9 <часть> ->0|1|2|3|4|5|6|7|8|9| <знак>0 | <знак>1 | <знак>2 | <знак>3 | <знак>4 | <знак>5 | <знак>6 | <знак>7 | <знак>8 | <знак>9 | <часть>0 | <часть>1 | <часть>2 | <часть>3 | <часть>4 | <часть>5 | <часть>6 | <часть>7 | <часть>8 | <часть>9 <знак> -> + | - 2. Докажите, что если для некоторой цепочки языка, заданного грамматикой, су- ществует единственный левосторонний вывод, то для нее существует и един- ственное дерево вывода. 3. Язык десятичных чисел с фиксированной точкой, рассмотренный в задаче № 1, содержит цепочки «-22», «239», «19.4». Постройте цепочки вывода для каж- дой из этих трех цепочек символов на основе предложенных в задаче № 1 грамматик. Какие из предложенных пяти грамматик являются неоднознач- ными?
386 Глава 9. Формальные языки и грамматики 4. Язык десятичных чисел с фиксированной точкой, рассмотренный выше в за- даче № 1, не содержит цепочек вида «.29» или «.104». Перестройте любую из предложенных в задаче Xs 1 пяти грамматик так, чтобы заданный ею новый язык допускал такого рода цепочки. 5. Поездом называется произвольная последовательность локомотивов и ваго- нов, начинающаяся с локомотива. Постройте грамматику для понятия «<по- езд>» в форме Бэкуса—Наура, считая что понятия «<локомотив>» и «<вагон>» являются терминальными символами. Модернизируйте грамматику для лю- бого из следующих условий: О все локомотивы должны быть сосредоточены в начале поезда; О поезд начинается с локомотива и заканчивается локомотивом (попробуйте построить регулярную грамматику); О поезд не должен содержать два локомотива либо два вагона подряд. 6. Насколько сложно построить в форме Бэкуса—Наура определение поезда (со- гласно задаче номер № 5), содержащего не более 60 вагонов? Постройте такое определение в форме грамматики с метасимволами. Распространите его на одно из предложенных в задаче Xs 5 дополнительных условий. 7. Дана грамматика условных выражений G({" ”,i,f,t,h,e,n,l,s,b,a}, {Е}, Р, Е) с правилами: Р: Е —> If b then Е else Е: | if b then Е: | а Не строя цепочек вывода, покажите, что эта грамматика является неоднознач- ной. Проверьте это, построив некоторую цепочку вывода. Попробуйте постро- ить эквивалентную ей однозначную грамматику.
ГЛАВА 10 Регулярные языки Регулярные языки и грамматики Леволинейные и праволинейные грамматики. Автоматные грамматики К регулярным, как уже было сказано, относятся два типа грамматик: леволиней- ные и праволинейные. Леволинейные грамматики G(VT,VN,P,S), V s VNuVT могут иметь правила двух видов: А~»Ву или А~»у, где A,BeVN, yeVT*. В свою очередь, праволинейные грамматики G(VT,VN,P,S), V e VNuVT могут иметь правила также двух видов: А->уВ или А~»у, где A,BgVN, yeVT*. Доказано, что эти два класса грамматик эквивалентны. Для любого регулярного языка, заданного праволинейной грамматикой, может быть построена леволи- нейная грамматика, определяющая эквивалентный язык; и наоборот — для лю- бого регулярного языка, заданного леволинейной грамматикой, может быть по- строена праволинейная грамматика, задающая эквивалентный язык. Разница между леволинейными и праволинейными грамматиками заключается в основном в том, в каком порядке строятся предложения языка: слева направо для леволинейных, либо справа налево для праволинейных. Поскольку предло- жения языков программирования строятся, как правило, в порядке слева напра- во, то в дальнейшем в разделе регулярных грамматик будет идти речь в первую очередь о леволинейных грамматиках. Среди всех регулярных грамматик можно выделить отдельный класс — автомат- ные грамматики. Они также могут быть леволинейными и праволинейными. Леволинейные автоматные грамматики G(VT,VN,P,S), V e VNuVT могут иметь правила двух видов: A->Bt или A->t, где A,BeVN, teVT. Праволинейные автоматные грамматики G(VT,VN,P,S), V e VNuVT могут иметь правила двух видов: A~»tB или A->t, где A,BeVN, teVT. Разница между автоматными грамматиками и обычными регулярными грам- матиками заключается в следующем: там, где в правилах обычных регулярных
388 Глава 10. Регулярные языки грамматик может присутствовать цепочка терминальных символов, в автомат- ных грамматиках может присутствовать только один терминальный символ. Лю- бая автоматная грамматика является регулярной, но не наоборот — не всякая регулярная грамматика является автоматной. Доказано, что классы обычных регулярных грамматик и автоматных грамматик почти эквивалентны. Это значит, что для любого языка, который задан регу- лярной грамматикой, молено построить автоматную грамматику, определяющую почти эквивалентный язык (обратное утверждение очевидно). Чтобы классы автоматных и регулярных грамматик были полностью эквивалент- ны, в автоматных грамматиках разрешается дополнительное правило вида где S — целевой символ грамматики. При этом символ S не должен встречаться в правых частях других правил грамматики. Тогда язык, заданный автоматной грамматикой G, может включать в себя пустую цепочку: XeL(G). В таком случае автоматные леволинейные и праволинейные грамматики, так же как обычные ле- волинейные и праволинейные грамматики^ задают регулярные языки. Поскольку реально используемые языки, как правило, не содержат пустую цепочку симво- лов, разница на пустую цепочку между этими двумя типами грамматик значения не имеет и правила вида S-»X далее рассматриваться не будут. Существует алгоритм, который позволяет преобразовать произвольную регуляр- ную грамматику к автоматному виду — то есть построить эквивалентную ей ав- томатную грамматику. Этот алгоритм рассмотрен ниже. Он является исклю- чительно полезным, поскольку позволяет существенно облегчить построение распознавателей для регулярных грамматик. Алгоритм преобразования регулярной грамматики к автоматному виду Имеется регулярная грамматика G(VT,VN,P,S), необходимо преобразовать ее в почти эквивалентную автоматную грамматику G’(VT,VN’,P’,S'). Для опреде- ленности будем рассматривать леволинейные грамматики, как уже было сказано выше (для праволинейных грамматик можно легко построить аналогичный ал- горитм). Алгоритм преобразования прост и заключается он в следующей последователь- ности действий: Шаг 1. Все нетерминальные символы из множества VN грамматики G перено- сятся во множество VN' грамматики G'. Шаг 2. Необходимо просматривать все множество правил Р грамматики G. Если встречаются правила вида A->Bat, A,BgVN, ateVT или вида A-»ab AeVN, atGVT, то они переносятся во множество Р' правил грамматики G' без измене- ний. Если встречаются правила вида A—>Ba1a2...an, n> 1, A,BgVN, Vn>i>0: ajeVT, то во множество нетерминальных символов VN' грамматики G’ добавляются символы А^Агг-мАп-ф а во множество правил Р' грамматики G' добавляются правила:
Регулярные языки и грамматики 389 А-» An_tan An-i-* Ап_2ап_1 А2—> Ata2 Aj—>Baj Если встречаются правила вида A-»ata2...an, n > 1, Ае VN, Vn > i > 0: a^VT, то во множество нетерминальных символов VN’ грамматики G’ добавляются символы AltA2,...,An-b а во множество правил Р’ грамматики G’ добавляются правила: А-» An_tan Ап-1—> An_2an_j А2—> Ata2 Ai->at Если встречаются правила вида А-»В или вида А—>Х, то они переносятся во мно- жество правил Р' грамматики G' без изменений. Шаг 3. Просматривается множество правил Р’ грамматики G'. В нем ищутся пра- вила вида А-»В или вида А->Х. Если находится правило вида А-*В, то просматривается множество правил Р' грамматики G’. Если в нем присутствует правила вида В->С, В->Са, В-»а или В-»Х, то в него добавляются правила вида А-»С, А~»Са, А-»а и А-»Х соответст- венно, VA,B,CeVN’, VaeVT (при этом следует учитывать, что в грамматике не должно быть совпадающих правил, и если какое-то правило уже присутствует в грамматике G', то повторно его туда добавлять не следует). Правило А-»В уда- ляется из множества правил Р’. Если находится правило вида А->Х, то просматривается множество правил Р' грамматики G'. Если в нем присутствует правило вида В-»А или В-»Аа, то в него добавляются правила вида В-»Х и В-»а соответственно, VA,BeVN’, VaeVT’ (при этом следует учитывать, что в грамматике не должно быть совпадающих правил, и если какое-то правило уже присутствует в грамматике G’, то повторно его туда добавлять не следует). Правило А->А, удаляется из множества правил Р’. Шаг 4. Если на шаге 3 было найдено хотя бы одно правило вида А-»В или А~»Х во множестве правил Р’ грамматики G’, то надо повторить шаг 3, иначе перейти к шагу 5. Шаг 5. Целевым символом S’ грамматики G’ становится символ S. Шаги 3 и 4 алгоритма в принципе можно не выполнять, если грамматика не со- держит правил вида А—>В (такие правила называются цепными) или вида А-»Х (такие правила называются Х-правилами). Реальные регулярные грамматики обыч- но не содержат правил такого вида. Тогда алгоритм преобразования грамматики к автоматному виду существенно упрощается. Кроме того, эти правила можно было бы устранить предварительно с помощью специальных алгоритмов преоб- разования (они рассмотрены дальше, в главе, посвященной КС-грамматикам, но также применимы и к регулярным грамматикам).
390 Глава 10. Регулярные языки Пример преобразования регулярной грамматики к автоматному виду Рассмотрим в качестве примера следующую простейшую регулярную граммати- ку: G({"a","С.{S.C.K}. Р. S) (символы а, (, *. ), {, } из мно- жества терминальных символов грамматики взяты в кавычки, чтобы выделить их среди фигурных скобок, обозначающих само множество): Р: S -> С*) I К} С -> (* I Са I С{ I С} I С( I С* I С) К -> { | Ка | К( | К* | К) | К{ Если предположить, что а здесь — это любой алфавитно-цифровой символ, кро- ме символов (, *, ), {, }, то эта грамматика описывает два типа комментариев, допустимых в языке программирования Borland Pascal. Преобразуем ее в авто- матный вид. Шаг 1. Построим множество VN' - {S,C,K}. Шаг 2. Начинаем просматривать множество правил Р грамматики G. Для правила S С*) во множество VN' включаем символ Sj, а само правило разбиваем на два: S -> Sj) и St -» С*; включаем эти правила во множество пра- вил Р'. Правило S -» К} переносим во множество правил Р' без изменений. Для правила С -> (* во множество VN' включаем символ Сь а само правило раз- биваем на два: С -> Ct* и Ct -> (; включаем эти два правила во множество пра- вил Р'. Правила С -> Са | С{ | С} | С( | С* | С) переносим во множество правил Р' без изменений. Правила К -» { | Ка | К( | К* | К) | К{ переносим во множество правил Р' без из- менений. Шаг 3. Правил вида А->В или А->Х во множестве правил Р' не содержится. Шаг 4. Переходим к шагу 5. Шаг 5. Целевым символом грамматики G' становится символ S. В итоге получаем автоматную грамматику: G’({“a“.{S.Sj.C.Cj.K}, Р'. S): Р': S -> SJ | К) Si -+ С* С —> Сх* | Са | С{ | С} | С( | С* | С) С, -> ( К -+ { I Ка I К( I К* I К) I К{ Эта грамматика, так же как и рассмотренная выще, описывает два типа коммен- тариев, допустимых в языке программирования Borland Pascal.
Конечные автоматы 391 Конечные автоматы Определение конечного автомата Конечным автоматом (КА) называют пятерку следующего вида: M(Q,V,8,q0,F), где □ Q — конечное множество состояний автомата; □ V — конечное множество допустимых входных символов (алфавит автомата); □ 8 — функция переходов, отображающая V*Q (декартово произведение мно- жеств) в множество подмножеств Q: R(Q), то есть 8(a,q) s R, ае V, qeQ, R^Q □ q0 — начальное состояние автомата Q, qoeQ □ F — непустое множество конечных состояний автомата, FcQ, F#0. КА называют полностью определенным, если в каждом его состоянии сущест- вует функция перехода для всех возможных входных символов, то есть VaeV, VqeQ 38(a,q) я R, RcQ. Работа конечного автомата представляет собой последовательность шагов (или тактов). На каждом шаге работы автомат находится в одном из своих состояний Q (в текущем состоянии), на следующем шаге он может перейти в другое состоя- ние или остаться в текущем состоянии. То, в какое состояние автомат перейдет на следующем шаге работы, определяет функция переходов 8. Она зависит не только от текущего состояния^ но и от символа из алфавита V, поданного на вход автомата. Когда функция перехода допускает несколько следующих состояний автомата, то КА может перейти в любое из этих состояний. В начале работы ав- томат всегда находится в начальном состоянии q0. Работа КА продолжается до тех пор, пока на его вход поступают символы из входной цепочки сое¥+. Видно, что конфигурацию КА на каждом шаге работы можно определить в виде (q,co,n), где q — текущее состояние автомата, qeQ; со — цепочка входных симво- лов, coeV+; п — положение указателя в цепочке символов, neNu{0}, n<|co| (N — множество натуральных чисел). Конфигурация автомата на следующем шаге — это (q',со,п+1), если q’e8(a,q) и символ aeV находится в позиции п+1 цепочки со. Начальная конфигурация автомата: (q0,co,0); заключительная конфигурация ав- томата: (f,co,n), feQ, n в |со|, Она является конечной конфигурацией, если feF. КА M(QV,8,q0,F) принимает цепочку символов coeV+, если, получив на вход эту цепочку, он из начального состояния q0 может перейти в одно из конечных со- стояний feF. В противном случае КА не принимает цепочку символов. Язык L(M), заданный КА M(QV,8,q0,F), — это множество всех цепочек симво- лов, которые принимаются этим автоматом. Два КА эквивалентны, если они за- дают один и тот же язык. Таким образом, КА является распознавателем для формальных языков. Далее будет показано, что КА — это распознаватели для регулярных языков. КА часто представляют в виде диаграммы или графа переходов автомата.
392 Глава 10. Регулярные языки Граф переходов КА — это направленный помеченный граф, с символами состоя- ний КА в вершинах, в котором есть дуга (p,q) p,qeQ, помеченная символом a eV, если в КА определена 5(а,р) и qe8(a,p). Начальное и конечные состояния авто- мата на графе состояний помечаются специальным образом (в данном пособии начальное состояние — дополнительной пунктирной линией, конечное состоя- ние — дополнительной сплошной линией). Рассмотрим конечный автомат: M({H,A,B,S},{a,b},8,H,{S}); & 8(Н,Ь) « В, 8(В,а) в А, 8(А,b) в {В,S). Ниже на рис. 10.1 приведен пример графа состояний для этого КА. Рис. 10.1. Граф переходов недетерминированного конечного автомата Для моделирования работы КА его удобно привести к полностью определенно- му виду, чтобы исключить ситуации, из которых нет переходов по входным сим- волам. Для этого в КА добавляют еще одно состояние, которое можно условно назвать «ошибка». На это состояние замыкают все неопределенные переходы, а все переходы из самого состояния «ошибка,» замыкают на него же. Если преобразовать подобным образом рассмотренный выше автомат М, то по- лучим полностью определенный автомат: M({H.A,B,E.S},{a,b},8,H,{S}); 8: 8(Н,а) = Е, 8(Н,Ь) = В, 8(Вд)»А, 8(В,Ь) = Е, 8(Ад)»{Е}, 8(A,b) = {B,S}, 8(Е,а) »{Е}, 8(Е,Ь)я{Е}, 8(S,a) - {Е}, 8(S,b) = {Е}. Состояние Е как раз соответствует состоянию «ошиб- ка». Граф переходов этого КА представлен на рис. 10.2. Рис. 10.2. Граф переходов полностью определенного недетерминированного конечного автомата Детерминированные и недетерминированные конечные автоматы Конечный автомат M(Q,V,8,qo,F) называют детерминированным конечным авто- матом (ДКА), если в каждом из его состояний для любого входного символа функ- ция перехода содержит не более одного состояния: VaeV, VqeQ: либо 8(a,q) я {г}, reQ, либо 8(a,q) я 0. В противном случае конечный автомат называют недетерминированным. ДКА может быть задан в виде пятерки: M(Q,V,8,q0,F),
Конечные автоматы 393 где Q — конечное множество состояний автомата; V — конечное множество до- пустимых входных символов; 8 — функция переходов, отображающая V*Q в множество Q: 8(a,q) я г, asV, q,reQ; q0 — начальное состояние автомата Q qoeQ; F — непустое множество конечных состояний автомата, FcQ, F#0. Если функция переходов ДКА определена для каждого состояния автомата, то автомат называется полностью определенным ДКА: VaeV, VqeQ: либо 38(a,q) я г, reQ. Моделировать работу ДКА существенно проще, чем работу произвольного КА. Доказано, что для любого КА можно построить эквивалентный ему ДКА. По- этому используемый произвольный КА стремятся преобразовать в ДКА. При построении компиляторов чаще всего используют полностью определенный ДКА. । Преобразование конечного автомата к детерминированному виду Алгоритм преобразования произвольного КА M(QV,8,q0,F) в эквивалентный ему ДКА M'(Q’,V,6',q'o,F) заключается в следующем: 1. Множество состояний Q' автомата М' строится из комбинаций всех состоя- ний множества Q автомата М. Если qi,q2,-,qn, п > 0 — состояния автомата М, V0<i<n q^Q то всего будет 2п-1 состояний автомата М'. Обозначим их так: [qi,q2,...,qm], 0<m<n. 2. Функция переходов 8' автомата М’ строится так: 8'(a,[q1,q2,...,qm]) я [rt,r2,...,rk], где V0<i<m30<j<k так, что 8(a,qj) я 3. Обозначим q’o я [q0]; 4. Пусть ft,f2,...,fi, 1 > 0 — конечные состояния автомата М, VO < i < 1 fjeF, тогда множество конечных состояний F' автомата М' строится из всех состояний, имеющих вид [...,fj,...], fjeF. Доказано, что описанный выше алгоритм строит ДКА, эквивалентный заданно- му произвольному КА. После построения из нового ДКА необходимо удалить все недостижимые со- стояния. Состояние qeQ в КА M(QV,8,q0,F) называется недостижимым, если ни при ка- кой входной цепочке coeV+ невозможен переход автомата из начального состоя- ния q0 в состояние q. Иначе состояние называется достижимым. Для работы алгоритма удаления недостижимых состояний используются два множества: множество достижимых состояний R и множество текущих актив- ных состояний на каждом шаге алгоритма Рр Результатом работы алгоритма яв- ляется полное множество достижимых состояний R. Рассмотрим работу алго- ритма по шагам: 1. R:-{q0}; i:-0; P0:-{q0}; 2. Pi+1:-0;
394 Глава 10. Регулярные языки 3. VaeV, VqePj: Pi+1:-Pj+1u8(a,q); 4. Если Pi+1-R * 0, то выполнение алгоритма закончено, иначе R:-=RuPi+b i:-i+l и перейти к шагу 3. После выполнения данного алгоритма из КА можно исключить все состояния, не входящие в построенное множество R. Рассмотрим работу алгоритма преобразования произвольного КА в ДКА на при- мере автомата M({H,A,B,S},{a,b},5,H,{S}); 8: 8(Н,Ь) - В, 8(В,а) - A, 8(A,b) = {B,S}. Видно, что это недетерминированный КА (из состояния А возможны два раз- личных перехода по символу Ь). Граф переходов для этого автомата был изобра- жен выше на рис. 10.1. Построим множество состояний эквивалентного ДКА: Q'-{ ЕН]. [А]. [В]. [S]. [НА]. [НВ]. [HS]. [АВ], [AS]. [BS]. [НАВ]. [HAS], [HBS]. [ABS ]. [HABS ]}. Построим функцию переходов эквивалентного ДКА: 8'([Н],Ь) = [В] 8'([A],b) - [BS] 8'([В],а) = [А] 8'([HA],b) = [BS] 8'([НВ],а) = [А] 8'([НВ],Ь) = [В] 8'([HS],b) - [В] б'([АВ],а) - [А] 8'([AB],b) - [BS] 8'([AS],b) - [BS] 8'([BS],a) - [А] 8'([НАВ],а) = [А] 8'([HAB],b) - [BS] 8'([HAS],b) - [BS] 8'([HBS],b) = [В] 8'([HBS],a) - [А] 8'([ABS],b) - [BS] 8'([ABS],a) - [A] 8'([HABS],a) - [A] 8'([HABS],b> = [BS] Начальное состояние эквивалентного ДКА: qo' - [Н] Множество конечных состояний эквивалентного ДКА: Г “ {[S].[HS].[AS].[BS].[HAS].[HBS].[ABS].[HABS]}
Конечные автоматы 395 После построения ДКА исключим недостижимые состояния. Множество дости- жимых состояний ДКА будет следующим R - {[Н], [В], [AJ, [BS]}. В итоге, ис- ключив все недостижимые состояния, получим ДКА: М’({[H],[B].[A].[BS]}.{a.b}.[H].{[BS]}). 5([Н],Ь)*[В], 5([В],а)*[А], 5( [А]. b)«[BS]. 5( [BS]. а НА]. Ничего не изменяя, переобозначим состояния ДКА. Получим: М’({H.B.A.S}.{a.b}.Н.{S}). 5(H,b)-B, S(B,a)*A, 5(A,b)“S, 5(S.a)-A. Граф переходов полученного ДКА изображен на рис. 10.3. Рис. 10.3. Граф переходов детерминированного конечного автомата Этот автомат можно преобразовать к полностью определенному виду. Получим граф состояний, изображенный на рис. 10.4 (состояние Е — это состояние «ошиб- ка»). Рис. 10.4. Граф переходов полностью определенного детерминированного конечного автомата При построении распознавателей к вопросу о необходимости преобразования КА в ДКА надо подходить, основываясь на принципе разумной достаточности. Мо- делировать работу ДКА существенно проще, чем произвольного КА, но при вы- полнении преобразования число состояний автомата может существенно возрас- ти и, в худшем случае, составит 2п-1, где п — количество состояний исходного КА. В этом случае затраты на моделирование ДКА окажутся больше, чем на мо- делирование исходного КА. Поэтому не всегда выполнение преобразования ав- томата к детерминированному виду является обязательным. Минимизация конечных автоматов Многие КА можно минимизировать. Минимизация КА заключается в построе- нии эквивалентного КА с меньшим числом состояний. В процессе минимизации необходимо построить автомат с минимально возможным числом состояний, эк- вивалентный данному КА. Для минимизации автомата используется алгоритм построения эквивалентных состояний КА. Два различных состояния в конечном автомате M(Q,V,8, ЧоТ)
396 Глава 10. Регулярные языки q^Q и q'eQ называются п-эквивалентными (n-неразличимыми), п > 0 neNu{0}, если, находясь в одном из этих состояний и получив на вход любую цепочку символов со: cog V* |со| < п, автомат может перейти в одно и то же множество конеч- ных состояний. Очевидно, что О-эквивалентными состояниями автомата M(Q,V, 8,q0,F) являются два множества его состояний: F и Q-F. Множества эквивалент- ных состояний автомата называют классами эквивалентности, а всю их совокуп- ность — множеством классов эквивалентности R(n) причем R(0)e{F,Q-F}. Рассмотрим работу алгоритма построения эквивалентных состояний по шагам: 1. На первом шаге п:я0 строим R(0). 2. На втором шаге п:яп+1 строим R(n) на основе R(n-l): R(n) я {r/n): {q^eQ: VaeV 8(a,qy)£rj(n-l)} VijeN}. To есть в классы эквивалентности на шаге п входят те состояния, которые ио одинаковым символам переходят в п-1 эк- вивалентные состояния. 3. Если R(n) - R(n-l), то работа алгоритма закончена, иначе необходимо вер- нуться к шагу 2. Доказано, что алгоритм построения множества классов эквивалентности завер- шится максимум для п я т-2, где т — общее количество состояний автомата. Алгоритм минимизации КА заключается в следующем: 1. Из автомата исключаются все недостижимые состояния. 2. Строятся классы эквивалентности автомата. 3. Классы эквивалентности состояний исходного КА становятся состояниями ре- зультирующего минимизированного КА. 4. Функция переходов результирующего КА очевидным образом строится на ос- нове функции переходов исходного КА. Для этого алгоритма доказано, во-первых, что он строит минимизированный КА, эквивалентный заданному; во-вторых, что он строит КА с минимально возмож- ным числом состояний (минимальный КА). Рассмотрим пример: задан автомат М({А,В,С,D,E,F,G},{0,1},8,A,{D,E}), 8(А,0) в {В}, 8(А,1) - {С}, 8(В,1) - {D}, 8(С,1) - {Е}, 8(0,0) * {С}, 8(D,1) « {Е}, 8(Е,0) - {В}, 8(Е,1) - {D}, 8(F,0) - {D}, 8(F,1) - {G}, 8(G,0) - {F}, 8(G,1) - {F}; необходимо построить эквивалентный ему минимальный КА. Граф переходов этого автомата приведен на рис. 10.5. Рис. 10.5. Граф переходов конечного автомата до его минимизации
Регулярные множества и регулярные выражения 397 Состояния F и G являются недостижимыми, они будут исключены на первом шаге алгоритма. Построим классы эквивалентности автомата: R(O)-{{A,B,C},{D,E}}, п=0; R(1)-{{A},{B,C},{D,E}}, п-1; R(2)-{{A},{B,C},{D,E}}, п-2. Обозначим соответствующим образом состояния полученного минимального КА и построим автомат: M({A,BC,DE},{0,l},8',A,{DE}), 8'(А,0) - {ВС}, 5'(А,1) - {ВС}, 8'(ВС,1) - {DE}, S’(DE.O) - {ВС}, S’(DE.l) - {DE}. Граф переходов минимального КА приведен на рис. 10.6. Рис. 10.6. Граф переходов конечного автомата после его минимизации Минимизация конечных автоматов позволяет при построении распознавателей получить автомат с минимально возможным числом состояний и тем самым в дальнейшим упростить функционирование распознавателя. Регулярные множества и регулярные выражения Определение регулярного множества Определим над множествами цепочек символов из алфавита V операции конка- тенации и итерации следующим образом: PQ — конкатенация PeV* и QeV*: PQ s {pq VpeP, VqeQ}; P* — итерация PeV*: P*я {p* VpeP}. Тогда для алфавита V регулярные множества определяются рекурсивно: 1. 0 — регулярное множество. 2. {X} — регулярное множество. 3. {а} — регулярное множество VaeV. 4. Если Р и Q — произвольные регулярные множества, то множества PuQ, PQ и Р* также являются регулярными множествами. 5. Ничто другое не является регулярным множеством. Фактически регулярные множества — это множества цепочек символов над за- данным алфавитом, построенные определенным образом (с использованием опе- раций объединения, конкатенации и итерации). Все регулярные языки представляют собой регулярные множества.
398 Глава 10. Регулярные языки Регулярные выражения. Свойства регулярных выражений Регулярные множества можно обозначать с помощью регулярных выражений. Эти обозначения вводятся следующим образом: 1. 0 — регулярное выражение, обозначающее 0. 2. X — регулярное выражение, обозначающее {X}. 3. а — регулярное выражение, обозначающее {a} VaeV. 4. Если р и q — регулярные выражения, обозначающие регулярные множества Р и Q, то p+q, pq, р* — регулярные выражения, обозначающие регулярные мно- жества PuQ, PQ и Р* соответственно. Два регулярных выражения аир равны, a s р, если они обозначают одно и то же множество. Каждое регулярное выражение обозначает одно и только одно регулярное мно- жество, но для одного регулярного множества может существовать сколь угодно много регулярных выражений, обозначающих это множество. При записи регулярных выражений будут использоваться круглые скобки, как и для обычных арифметических выражений. При отсутствии скобок операции вы- полняются слева на право с учетом приоритета. Приоритет для операций принят следующий: первой выполняется итерация (высший приоритет), затем конкате- нация, потом — объединение множеств (низший приоритет). Если а, (3 и у — регулярные выражения, то свойства регулярных выражений можно записать в виде следующих формул: 1. Х+аа* “ Х+а*а - а* 2. а+р я Р+аЗ. 3. а+(Р+у) я (а+Р)+у 4. а(Р+у) я ар+ау 5. (Р+у)аяра+уа 6. а(Ру)-(аР)у 7. а+а “ а 8. а+а* = а* 9. Х+а* = а*+Х = а* 10. 0*-Х И. 0а-а0“0 12. 0+а ~ а+0 - а 13. Ха-аХ-а 14. (а*)*-а* Все эти свойства можно легко доказать, основываясь на теории множеств, так как регулярные выражения — это только обозначения для соответствующих множеств.
Регулярные множества и регулярные выражения 399 Следует также обратить внимание на то, что среди прочих свойств отсутствует равенство а₽ * Ра, то есть операция конкатенации не обладает свойством комму- тативности. Это и не удивительно, поскольку для этой операции важен порядок следования символов. Уравнения с регулярными коэффициентами На основе регулярных выражений можно построить уравнения с регулярными коэффициентами [32]. Простейшие уравнения с регулярными коэффициентами будут выглядеть следующим образом: X - аХ + р, Х= Ха + р, где a,peV‘ — регулярные выражения над алфавитом V, а переменная XgV. Решениями таких уравнений будут регулярные множества. Это значит, что если взять регулярное множество, являющееся решением уравнения, обозначить его в виде соответствующего регулярного выражения и подставить в уравнение, то по- лучим тождественное равенство. Два вида записи уравнений (правосторонняя и левосторонняя запись) связаны с тем, что для регулярных выражений опера- ция конкатенации не обладает свойством коммутативности, поэтому коэффици- ент можно записать как справа, так и слева от переменной и при этом получатся различные уравнения. Обе записи равноправны. Решением первого уравнения является множество, обозначенное регулярным вы- ражением а‘р. Проверим это решение, подставив его в уравнение вместо пере- менной X: aX+p - а(а’р)+р -6 (аа*)Р+р -13 (аа’)рПр -5 (аа*+Х)р а*р - X Над знаками равенства указаны номера свойств регулярных выражений, кото- рые были использованы для выполнения преобразований. Решением второго уравнения является множество, обозначенное регулярным выражением pa*. Xa+p - (pa’)a+p -6 P(a*a)+p -13 p(a*a)+pi -4 P(a*a+X) -1 pa* - X Указанные решения уравнений не всегда являются единственными. Например, если регулярное выражение а в первом уравнении обозначает множество, кото- рое содержит пустую цепочку, то решением уравнения может быть любое мно- жество, обозначенное выражением X - а*(Р+у), где у — выражение, обозначаю- щее произвольное множество над алфавитом V (причем это множество даже может не быть регулярным). Однако доказано, что X - а*Р и X Ра* — это наи- меньшие из возможных решений для данных двух уравнений. Эти решения на- зываются наименьшей подвижной точкой. Из уравнений с регулярными коэффициентами можно формировать систему уравнений с регулярными коэффициентами. Система уравнений с регулярными коэффициентами имеет вид (правосторонняя запись):
400 Глава 10. Регулярные языки Xi “ осю + осцХ1 + СЦ2Х2 + ... + сцпХп Х2 “ СС20 ОС21Х1 + (Х22Х2 ... 0l2nXn Xi “ ai0 + aiiXj + ai2X2 + ... + счпХп Xn “ ano + aniXi + aJ2X2 + ... + annXn или (левосторонняя запись): Xj - a10 + X^n + X2ai2 + ... + Xnain X2 e a20 + Xia2t + X2a22 + ... + Xna2n Xi “ ai0 + Х,аи + X2ai2 + ... + XBain Xn “ an0 + Xiani + X2a12 + ... + Xnann В системе уравнений с регулярными коэффициентами все коэффициенты ац яв- ляются регулярными выражениями над алфавитом V, а переменные не входят в алфавит V: Vi XjgV. Оба варианта записи равноправны, но в общем случае могут иметь различные решения при одинаковых коэффцциентах при переменных. Чтобы решить систему уравнений с регулярными коэффициентами, надо найти такие регулярные множества X,, при подстановке которых в систему все уравне- ния превращаются в тождества множеств. Иными словами, решением системы является некоторое отображение f(X) множества переменных уравнения А“{Х^ n > i > 0} на множество языков над алфавитом V*. Системы уравнений с регулярными коэффициентами решаются методом после- довательных подстановок. Рассмотрим метод решения для правосторонней запи- си. Алгоритм решения работает с переменной номера шага i и состоит из следую- щих шагов. Шаг 1. Положить i1. Шаг 2. Если i - п, то перейти к шагу 4, иначе записать i-e уравнение в виде: Xj - ajXj+Pi, где a, - ан, р; - pi0 + pii+)Xi+1 + ... + pinXn. Решить уравнение и полу- чить Xi - a/Pj. Затем для всех уравнений с переменными Xi+1.Хп подставить в них найденное решение вместо Xj. Шаг 3. Увеличить i на 1 (ii+1) и вернуться к шагу 2. Шаг 4. После всех подстановок уравнение для Хв будет иметь вид Хп = с^Хп+р, где сц, - %. Причем р будет регулярным выражением над алфавитом V* (не со- держит в своем составе переменных системы уравнений Х:). Тогда можно найти окончательное решение для Хв: Хп - an*p. Перейти к шагу 5. Шаг 5. Уменьшить i на 1 (i:- i-1). Если i - 0, то алгоритм завершен, иначе перей- ти к шагу 6. Шаг 6. Берем найденное решение для Xj - ajXj+pj, где aj - аи, Pj - pi0 + pii+jXi+i + + ... + piBXn, и подставляем в него окончательные решения для переменных Х(+1.Хп. Получаем окончательное решение для Xj. Перейти к шагу 5.
Регулярные множества и регулярные выражения 401 Для левосторонней записи системы уравнений алгоритм решения будет анало- гичным, с разницей только в порядке записи коэффициентов (справа от пере- менных). Система уравнений с регулярными коэффициентами всегда имеет решение, но это решение не всегда единственное. Для рассмотренного алгоритма решения системы уравнений с регулярными коэффициентами доказано, что он всегда на- ходит решение f(X) (отображение f: Vi Xj->V‘), которое является наименьшей неподвижной точкой системы уравнений. То есть если существует любое другое решение g(X), то всегда f(X)cg(X). В качестве примера рассмотрим систему уравнений с регулярными коэффици- ентами над алфавитом V О, 1,2, 3, 4, 5, 6, 7, 8, 9} (для ясности за- писи символы -, + и . взяты в кавычки, чтобы не путать их со знаками опера- ций): Xj - (••-'• + •+" + X) Х2 - Х1"."(0+1+2+3+4+5+6+7+8+9) + Х3"." + Х2(0+1+2+3+4+5+6+7+8+9) Х3-Х1(0+1+2+3+4+5+6+7+8+9) + Х3(0+1+2+3+4+5+6+7+8+9) Х4-Х2 + Х3 Обозначим регулярное выражение (0+1+2+3+4+5+6+7+8+9) через а для крат- кости записи: а - (0+1+2+3+4+5+6+7+8+9). Получим: Х1 = ("-" + "+" + X) Х2 = Х/'/'а + Х3Н." + Х2а Х3 — X|Ct + Х3а х4-х2 + х3 Решим эту систему уравнений. Шаг 1. i := 1. Шаг 2. Имеем i = 1 < 4. Берем уравнение для i - 1. Имеем Xi - ("-" + "+" + X). Это уже и есть решение для Хь Подставляем его в другие уравнения. Получаем: Х2 - + •+' + Х)".''а + Х3"." + Хга Х3 - ("-" + "+•' + Х)а + Х3а Х4-Х2 + Х3 Шаг ЗА-i + 1-2. Возвращаемся к шагу 2. Шаг 2. Имеем i - 2 < 4. Берем уравнение для i - 2. Имеем Х2 - ('-" + "+" + Х)"."а + Х3"." + Х2а. Преобразуем уравнение к виду: Х2 - Х2а + ((''-" + + Х)".”а + Х3"."). Тогда а2 - а, р2 - ("-'' + "+" + Х)"."а + Х3".". Решением для Х2 будет: Х2 = р2а2* = (("-" + "+" + Х)"."а + Х3".'Х = ("-" + "+" + Х)"."аа’ + Хз^'а*.
402 Глава 10. Регулярные языки Подставим его в другие уравнения. Получаем: Х3+ 1)а + Х3а Х4 - + "+" + Х)”."аа’ + Х3"."а* + Х3 Шаг 3. i:- i + 1 - 3. Возвращаемся к шагу 2. Шаг 2. Имеем i - 3 < 4. Берем уравнение для i - 3. Имеем Х3 + "+’’ +Х)а + Х3а. Преобразуем уравнение к виду Х3 - Х3а + + "+" + Х)а. Тогда а3 - а, р3 - + "+" + Х)а. Решением для Х3 будет: Х3 - р3а3’ - + "+" + Х)аа‘. Подставим его в другие уравнения. Получаем: Х4 - + "+•• + Х)"."аа‘ + + "+" + Х)аа’"."а’ + + "+" + Х)аа. ШагЗ. i:-i + 1-4. Возвращаемся к шагу 2. Шаг 2. Имеем i - 4 - 4. Переходим к шагу 4. Шаг 4. Уравнение для Х4 теперь имеет вид Х4- + •'+" + Х)"."аа + + "+" + Х)аа*'."а* + + "+" + Х)аа. Оно не нуждается в преобразованиях и содержит окончательное решение для Х4. Переходим к шагу 5. Шаг5Л:-1- 1-3>0. Переходим к шагу 6. Шаг 6. Уравнение для Х3 имеет вид Х3 - + "+" + Х)аа’. Оно уже содержит окончательное решение для Х3. Переходим к шагу 5. Шаг 5. i:- i - 1 - 2 > 0. Переходим к шагу 6. Шаг 6. Уравнение для Х2 имеет вид Х2 = + "+" + Х)"."аа‘ + Х3"."а‘. Подста- вим в него окончательное решение для Х3. Получим окончательное решение для Х2: Х2 - + "+" + Х)"."аа‘ + + "+" + Х)аа,".''а’. Переходим к шагу 5. Шаг5.1:-1- 1 - 1 > 0. Переходим к шагу 6. Шаг 6. Уравнение для Xt имеет вид Xt - + "+" +1). Оно уже содержит окон- чательное решение для Xt. Переходим к шагу 5. 1 Шаг5Л-.-1- 1-0-0. Алгоритм завершен. В итоге получили решение: X, - + "+•' + X) Х2 - + "+" + Х)"."аа* + + "+" + Х)аа‘"."а‘
Способы задания регулярных языков 403 х3 = ("-" + "+" + Л)аа Х4 = + ”+" + 1)"."аа’ + + "+" + Х)аа’"."а’ + + "+" + Х)аа Выполнив несложные преобразования, это же решение можно представить в бо- лее простом виде: X, - + "+" + X) Х2 - ("-•• + "+" + Х)("."а + аа*".")а* Х3-("-" + "+" + Х)аа Х4 - + "+" + Х)("."а + аа”'." + а)а‘ Если подставить вместо обозначения а соответствующее ему регулярное выра- жение а = (0+1+2+3+4+5+6+7+8+9), то можно заметит, что регулярное выраже- ние для Х4 описывает язык десятичных чисел с плавающей точкой. Способы задания регулярных языков Три способа задания регулярных языков Регулярные (праволинейные и леволинейные) грамматики, конечные автоматы (КА) и регулярные множества (равно как и обозначающие их регулярные выра- жения) — это три различных способа, с помощью которых можно задавать регу- лярные языки. Регулярные языки в принципе можно определять и другими способами, но именно три указанных способа представляют наибольший инте- рес. Доказано, что все три способа в равной степени могут быть использованы для определения регулярных языков. Для них можно записать следующие утвержде- ния: Утверждение 2.1. Язык является регулярным множеством тогда и только тогда, когда он задан леволинейной (праволинейной) грамматикой. Утверждение 2.2. Язык может быть задан леволинейной (праволинейной) грам- матикой тогда и только тогда, когда он является регулярным множеством. Утверждение 2.3. Язык является регулярным множеством тогда и только тогда, когда он задан с помощью конечного автомата. Утверждение 2.4. Язык распознается с помощью конечного автомата тогда и только тогда, когда он является регулярным множеством. Все три способа определения регулярных языков эквивалентны. Существуют ал- горитмы, которые позволяют для регулярного языка, заданного одним из ука- занных способов, построить другой способ, определяющий тот же самый язык. Это не всегда справедливо для других способов, которыми можно определить регулярные языки. Ниже рассмотрены некоторые из таких алгоритмов.
404 Глава 10. Регулярные языки Связь регулярных выражений и регулярных грамматик Регулярные выражения и регулярные грамматики связаны между собой следую- щим образом: □ для любого регулярного языка, заданного регулярным выражением, можно по- строить регулярную грамматику, определяющую тот же язык; □ для любого регулярного языка, заданного регулярной грамматикой, можно по- лучить регулярное выражение, определяющее тот же язык. Ниже будут рассмотрены два алгоритма, реализующие эти преобразования. В ал- горитмах будут использоваться леволинейные грамматики и леволинейная за- пись уравнений с регулярными коэффициецтами, но очевидно, что все то же самое справедливо также для праволинейных грамматик и праволинейной запи- си уравнений. Построение леволинейной грамматики для языка, заданного регулярным выражением Регулярные множества (и обозначающие их регулярные выражения) заданы с помощью рекурсивного определения. Будем строить леволинейную грамматику для регулярного выражения над алфавитом V, следуя шагам этого определения. 1. Для регулярного выражения 0 построим леволинейную грамматику G(V, {S},0,S), которая будет определять язык, заданный этим выражением (грам- матика, в которой нет ни одного правила). 2. Для регулярного выражения X построим леволинейную грамматику G(V,{S}, {S->X},S), которая будет определять язык, заданный этим выражением. 3. Длд регулярного выражения aeV построим леволинейную грамматику G(V, {S},{S—->a},S), которая будет определять язык, заданный этим выражением. 4. Имеем регулярные выражения аир, заданные ими языки Lt и L2, а также со- ответствующие им леволинейные грамматики G^V^VN^P^Si) и G2(V,VN2, P2,S2): Lt s L(Gt) и L2 я L(G2). Необходимо на основе этих данных построить леволинейные грамматики для языков, заданных выражениями а+Р (L3* = Lt u L2), ар (L4 - LiL2) и a* (L5 = Lt*): О для языка, заданного выражением а+р, строим грамматику G3(V,VN3,P3,S3): VN3 = VNt u VN2 u {S3} (алфавит нетерминальных символов G3 строится на основе алфавитов нетерминальных символов Gt и G2 с добавлением но- вого символа S3), Р3 = Pj и Р2 и {S3—>S2|S1} (множество правил G3 строит- ся на основе множеств правил Gt и G2 с добавлением двух новых правил S3->S2|St), целевым символом грамматики G3 становится символ S3; О для языка, заданного выражением ар, строим грамматику G4(V,VN4,P4,S2): VN3 = VNt u VN2 (алфавит нетерминальных символов G3 строится на ос- нове алфавитов нетерминальных символов Gt и G2), множество правил Р4 строится на основе множеств правил Pt и Р2 следующим образом:
Способы задания регулярных языков 405 все правила из множества Pt переносятся в Р4, если правило из множества Р2 имеет вид А-»Ву, A,BsVN2, ysV*, то оно пе- реносится в Р4 без изменений, если правило из множества Р2 имеет вид А—>у, As VN2, ysV*, то в Р4 добав- ляется правило A-^Sty, целевым символом грамматики G4 становится целевой символ граммати- ки G2 — S2; О для языка, заданного выражением а*, строим грамматику G5(V,VN5,P5,S5): VN3 e VNj u {S5} (алфавит нетерминальных символов G3 строится на ос- нове алфавита нетерминальных символов Gt с добавлением нового симво- ла S5), множество правил Р5 строится на основе множеств правил Pt следующим образом: если правило из множества Pt имеет вид А—>Ву, AJJeVty, yeV*, то оно пе- реносится в Р5 без изменений, если правило из множества Pt имеет вид А-»у, AeVN2, ye V*, то в Р5 добав- ляются два правила A—>S^y|y, дополнительно в Р5 добавляются два новых правила Ss-^SjX, целевым сим- волом грамматики G3 становится символ S5. Используя указанные построения в качестве базиса индукции, на основе матема- тической индукции можно доказать, что для любого регулярного языка, заданно- го регулярным выражением, можно построить определяющую этот язык леволи- нейную грамматику. Для любого произвольного регулярного выражения, напрямую применяя рас- смотренные выше выкладки, можно построить леволинейную грамматику, опре- деляющую заданный этим выражением язык. Начинать построение надо от эле- ментарных операндов выражения: символов, пустых цепочек и пустых множеств. Построение необходимо вести в порядке выполнения операций выражения. Построение регулярного выражения для языка, заданного леволинейной грамматикой Имеем леволинейную грамматику G(VT,VN,P,S), необходимо найти регулярное выражение над алфавитом VT, определяющее язык L(G), заданный этой грамма- тикой. В данном случае преобразование не столь элементарно. Выполняется оно сле- дующим образом: 1. Обозначим символы алфавита нетерминальных символов VN следующим об- разом: VN я {XI, Х2,..., Хп}. Тогда все правила грамматики будут иметь вид: Xi—>Xjy или Xi—>у Xi,XjeVN, yeVT*; целевому символу грамматики S будет соответствовать некоторое обозначение Хк. 2. Построим систему уравнений с регулярными коэффициентами на основе пе- ременных Х1,Х2,...,Хп: Xi s ccqi + Х1СЦ1 + Х2а21 + ... + Xnanl
406 Глава 10. Регулярные языки Х2s а02 + Xta12 + X2a22 + ... + Xna„2 Xn “* O-On + Xtaln + X2a2n + ... + Xnotnn, коэффициенты aOi, ao2,..., aOn выбираются следующим образом: aOiя (yi + у2 + + ••• + Ym)> если во множестве правил Р грамматики G существуют правила Xj->У1|у2|...|ут, и aoi “ 0, если правил такого вида не существует; коэффициенты <Хц, а,2,...» ajn для некоторого j выбираются следующим обра- зом: cijiя (yt + Y2 + ••• + Ym)> если во множестве правил Р грамматики G сущест- вуют правила Xj->XjYi|XjY2l-|XjYm» и aji “ если правил такого вида не суще- ствует. 3. Находим решение построенной системы уравнений. Доказано, что решение для Хк (которое обозначает целевой символ S граммати- ки G) будет представлять собой искомое регулярное выражение, обозначающее язык, заданный грамматикой G. Остальные решения системы будут представ- лять собой регулярные выражения, обозначающие понятия грамматики, соответ- ствующие ее нетерминальным символам. В принципе для поиска регулярного выражения, обозначающего весь язык, не нужно искать все решения — достаточ- но найти решение для Хк, если выражения для понятий грамматики не представ- ляют отдельного интереса. Например, рассмотрим леволинейную грамматику, определяющую язык деся- тичных чисел с плавающей точкой 6({и.и, "О", "Iй, "2й, "3", "4й, "5", "6”, "7", "8", "9"}, {<знак>, <дробное>, <целое>, <число>},Р,<число>): Р: <знак> -> - | + | х <дробное> -> <знак>.0 | <знак>.1 | <знак>.2 | <знак>.3 | <знак>.4 | <знак>.5 | <знак>.6 | <знак>.7 | <знак>.8 | <знак>.9 | <целое>. | <дробное>0 | <дробное>1 | <дробное>2 | <дробное>3 | <дробное>4 | <дробное>5 | <дробное>6 | <дробное>7 | <дробное>8 | <дробное>9 <целое> -> <знак>0 | <знак>1 | <знак>2 | <знак>3 | <знак>4 | <знак>5 | <знак>6 | <знак>7 | <знак>8 | <знак>9 | <целое>0 | <целое>1 | <целое>2 | <целое>3 | <целое>4 | <целое>5 | <целое>6 | <целое>7 | <целое>8 | <целое>9 <число> -> <дробное> | <целое> Обозначим символы множества VN » {<знак>, <дробное>, <целое>, <число>} соот- ветствующими переменными Хь получим: Vty - {Xt, Х2, Х3, Х4}. Построим систему уравнений на основе правил грамматики G: Xt - + 1) Х2 = Х1"."(0+1+2+3+4+5+б+7+8+9) + Х3И." + Х2(0+1+2+3+4+5+6+7+8+9) Х3 - Х^О+Х+г+З-И+б^+в+Э) + Х3(0+1+2+3+4+5+6+7+8+9) Х4 - Х2 + Х3 Эта система уравнений уже была решена выше. В данном случае нас интересует только решение для Х4, которое соответствует целевому символу грамматики G <число>. Решение для Х4 может быть записано в виде:
Способы задания регулярных языков 407 Х4 - + ”+" + X) ("."(0+1+2+3+4+5+6+7+8+9) + (0+1+2+3+4+5+6+7+8+9)(0+1+2+3+ +4+5+б+7+8+9)*".” + (0+1+2+3+4+5+6+7+8+9)) (0+1+2+3+4+5+6+7+8+9)* Это и есть регулярное выражение, определяющее язык, заданный грамматикой G. Связь регулярных выражений и конечных автоматов Регулярные выражения и конечные автоматы связаны между собой следующим образом: □ для любого регулярного языка, заданного регулярным выражением, можно построить конечный автомат, определяющий тот же язык; □ для любого регулярного языка, заданного конечным автоматом, можно полу- чить регулярное выражение, определяющее тот же язык. Ниже будет рассмотрен алгоритм, реализующий построение конечного автомата по регулярному выражению. Алгоритм построения регулярного выражения по конечному автомату здесь не рассматривается — он не представляет интереса, поскольку, как будет показано ниже, проще построить грамматику, эквивалент- ную заданному конечному автомату, а Потом уже найти регулярное выражение для заданного грамматикой языка (по алгоритму, который уже был выше рас- смотрен) [5, 6, т. 1, 12, 26]. Построение конечного автомата для языка, заданного регулярным выражением Регулярные множества (и обозначающие их регулярные выражения) заданы с помощью рекурсивного определения. Будем строить КА для регулярного выра- жения над алфавитом V, следуя шагам этого определения. 1. Для регулярного выражения 0 построим КА M(Q1 2 3 4 s {H,F},V,8,H,{F}), у кото- рого функция переходов VqeQ, VaeV имеет вид 8(q,a) = 0. 2. Для регулярного выражения X построим КА M(Q= {F},V,8,F,{F}), у которого функция переходов VaeV имеет вид 8(F,a) s 0, а множество конечных со- стояний содержит только начальное состояние. 3. Для регулярного выражения aeV построим КА M(Q= {H,F},V,8,H,{F}), с функ- цией переходов 8(Н,а) e {F}. 4. Имеем регулярные выражения аир, заданные ими языки Lt и L2, а также со- ответствующие им КА M^Q^VjS^q^Fj) и M2(Q2,V,82, q2,F2): Lt-LCMO и L2 - L(M2). Необходимо на основе этих данных построить КА для языков, за- данных выражениями а+р (L3 = L( u Ь2), ар (L4 - LfL2) и а* (L5 = Lf). О для языка, заданного выражением а+р, строим КА M3(Q3,V,83,q3,F3): Оз “ Qi о Q2 u {q3} (множество состояний М3 строится из множеств со- стояний Mi и М2 с добавлением нового состояния q3), 83(q3,a) “ StCqt.a) о 32(q2,a) VaeV, 83(q,a) - S^q.a) VaeV VqeQt,
408 Глава 10. Регулярные языки 83(q,a) = S2(q,a) VaeV VqeQj, F3 = Ft u F2 u {q3}, если a+p содержит X, или F3 = Ft u F2, если a+p не со- держит X, ( начальным состоянием КА М3 становится состояние q3; О для языка, заданного выражением ар, строим КА M4(Q4,V,84,qi,F4): Q4-Q1 Q2 (множество состояний М4 строится из множеств состояний и М2), S4(q,a) “ ^(фа) VaeV Vqe^/F,), 84(q,a) - S^q.a) u S2(q2,a) VaeV VqeFt, S4(q,a) = S2(q,a) VaeV VqeQj, F4 = F2, если q2«F2 или F4 - Ft u F2) если q2eF2, начальным состоянием KA M3 становится начальное состояние KA Mt — qt; О для языка, заданного выражением ,а’, строим КА M5(Q5,V,85,q5,F5): Qs - Qt kj {q5} (множество состояний М5 строится из множества состояний М] с добавлением нового состояния q5), 85(q,a) = 8t<q,a> VaeV Vqe(Q1/F1), 85(q,a) “ Si(q,a) kJ S^q^a) VaeV VqeFt, 8s(q5.a) - 51(q1,a) VaeV, F5 - Ft kJ {q5}, начальным состоянием KA M5 становится состояние q5. Используя указанные построения в качестве базиса индукции, на основе матема- тической индукции можно доказать, что для любого регулярного языка, заданно- го регулярным выражением, можно построить определяющий этот язык КА. Построение КА на основе регулярного выражения выполняется аналогично по- строению леволинейной грамматики. Связь регулярных грамматик и конечных автоматов На основе имеющейся регулярной грамматики можно построить эквивалентный ей конечный автомат и, наоборот, для заданного конечного автомата можно по- строить эквивалентную ему регулярную грамматику. Это очень важное утверждение, поскольку регулярные грамматики используют- ся для определения лексических конструкций языков программирования. Соз- дав автомат на основе известной грамматики, мы получаем распознаватель для лексических конструкций данного языка. Таким образом, удается решить задачу разбора для лексических конструкций языка, заданных произвольной регуляр- ной грамматикой. Обратное утверждение также полезно, поскольку позволяет узнать грамматику, цепочки языка которой допускает заданный автомат. Для построения конечного автомата на основании известной грамматики и для построения грамматики на основании данного конечного автомата используются достаточно простые алгоритмы.
Способы задания регулярных языков 409 Все языки программирования определяют нотацию записи «слева направо». В той же нотации работают и компиляторы. Поэтому далее рассмотрены алгоритмы для леволинейных грамматик. Построение конечного автомата на основе леволинейной грамматики Имеется леволинейная грамматика G(VT,VN,P,S), необходимо построить экви- валентный ей конечный автомат M(Q,V,8,q0,F). Прежде всего для построения автомата исходную грамматику G необходимо привести к автоматному виду. Известно, что такое преобразование можно вы- полнить для любой регулярной грамматики. Алгоритм преобразования к авто- матному виду был рассмотрен выше, поэтому здесь на данном вопросе останав- ливаться нет смысла. Можно считать, что исходная грамматика G уже является леволинейной автоматной грамматикой. Тогда построение конечного автомата M(Q,V,8,q0,F) на основе грамматики G(VT, VN,P,S) выполняется по следующему алгоритму. Шаг 1 Строим множество состояний автомата Q. Состояния автомата строятся таким образом, чтобы каждому нетерминальному символу из множества VN грам- матики G соответствовало одно состояние из множества Q автомата М. Кроме того, во множество состояний автомата добавляется еще одно дополнительное состояние, которое будем обозначать Н. Сохраняя обозначения нетерминальных символов грамматики G, для множества состояний автомата М можно записать: Q=VNu{H}. Шаг 2. Входным алфавитом автомата М является множество терминальных сим- волов грамматики G: V s VT. Шаг 3. Просматриваем все множество правил исходной грамматики. Если встречается правило вида A-»teP, где AeVN, teVT, то в функцию перехо- дов 8(H,t) автомата М добавляем состояние A: Ae8(H,t). Если встречается правило вида A->BteP, где A,BeVN, teVT, то в функцию пе- реходов 8(B,t) автомата М добавляем состояние A: Ae8(B,t). Шаг 4. Начальным состоянием автомата М является состояние Н: q0 = Н. Шаг 5. Множество конечных состояний автомата М состоит из одного состоя- ния. Этим состоянием является состояние, соответствующее целевому символу грамматики G: F я {S}. На этом построение автомата заканчивается. Построение леволинейной грамматики на основе конечного автомата Имеется конечный автомат M(Q,V,8,q0,F), необходимо построить эквивалент- ную ему леволинейную грамматику G(VT,VN,P,S). Построение выполняется по следующему алгоритму.
410 Глава 10. Регулярные языки Шаг 1. Множество терминальных символов грамматики G строится из алфавита входных символов автомата М: VT s V. Шаг 2, Множество нетерминальных символов грамматики G строится на основа- нии множества состояний автомата М таким образом, чтобы каждому состоянию автомата, за исключением начального состояния, соответствовал один нетерми- нальный символ грамматики: VN = Q\{q0}. Шаг 3. Просматриваем функцию переходов автомата М для всех возможных со- стояний из множества Q для всех возможных входных символов из множества V. Если имеем 8(A,t) в 0, то ничего не выполняем. Если имеем 8(A,t) ж {Bt,B2,...,Bn}, п >0, где AeQ, Vn>i>0: B^Q, teV, тогда для всех состояний В, выполняем следующее: □ добавляем правило В—>t во множество Р правил грамматики G, если A e q0; □ добавляем правило Bj-»At во множество Р правил грамматики G, если A#q0. Шаг 4. Если множество конечных состояний F автомата М содержит только одно состояние F = {Fo}, то целевым символом S грамматики G становится символ мно- жества VN, соответствующий этому состоянию: S - Fo; иначе, если множество конечных состояний F автомата М содержит более одного состояния Fi={F1, F2,...,Fn}, п>1, тогда во множество нетерминальных символов VN грамматики G добавляется новый нетерминальный символ S: VN я VNu{S}, а во множество пра- вил Р грамматики G добавляются правила: S->F1|F2|...|Fn. На этом построение грамматики заканчивается. Пример построения конечного автомата на основе заданной грамматики Рассмотрим грамматику G({"a",{S,C,K}, Р, S) (символы а, (, *, ), {, } из множества терминальных символов грамматики взяты в кавычки, чтобы выделить их среди фигурных скобок, обозначающих само множество): Р: S -> С*) I К} С -> (* I Са I С{ I С} I С( I С* I С) К —> { | Ка | К( | К* | К) | К{ Это леволинейная регулярная грамматика. Как было показано выше, ее можно преобразовать к автоматному виду. Получим леволинейную автоматную грамматику следующего вида: G'({"a","(", "{VTT {S.SpC.Ci.KbP'.S): Р’: S -> Sj) | К} Sj -> С* С -> Cj* | Са | С{ | С} | С( | С* 1 С) ci —* ( К -> { I Ка I К( I К* I К) I К{
Способы задания регулярных языков 411 Для удобства переобозначим нетерминальные символы Ct и Sj символами D и Е. Получим грамматику G’({"a",{S.E.C.D.K}. Р'. S): Р': S -> Е) | К) Е -> С* С —> D* | Са | С{ | С} | С( | С* | С) О -> ( К -> { | Ка | К( | К* | К) | К{ Построим конечный автомат M(Q,V,5,q0,F), эквивалентный указанной грамма- тике. Шаг 1. Строим множество состояний автомата. Получаем: Q-VNu{H}- - {S,E,C,D,K,H}. Шаг 2. В качестве алфавита входных символов автомата берем множество тер- минальных символов грамматики. Получаем: V = {"а", Шаг 3. Рассматриваем множество правил грамматики. Для правил S -> Е) | К) имеем 8(Е,")") - {S}: 8(К,*}“) « {S}. Для правила Е -> С* имеем 8(С."*") - {Е}. Для правил С —> D* | Са | С{ | CJ | С( | С* | С) имеем 8(0,"*") - {С}: 8(С,"а") - {С}: 8(С,"{") - {С}: 8(С,"}") - {С}: 8(С,"(") - {С}; 8(С,"*П) - {Е.С}: 8(С,")“) - {С}. Для правила D -» ( имеем 8(Н,"(") - {0}. Для правил К -> { | Ка | К( | К* | К) | К{ имеем 8(Н,"{") - {К}: 8(К,"а") - {К}: 8(К,"(") - {К}: 8(К,"*") - {К}; 8(К,")") - {К}: 8(К,"{") - {К}. Шаг 4. Начальным состоянием автомата является состояние q0 - Н. Шаг 5. Множеством конечных состояний автомата является множество F - {S}. Выполнение алгоритма закончено. В итоге получаем автомат M({S,E,С,D,К,Н}. {"а",8. Н, {$}) с функцией переходов: 8(Н."{") - {К} 8(Н,"(") - {D) 6(К."а") - {К} 8(К."(") - {К} 8(К."*“) " {К} 8(К.")") - {К} 8(К."{") - {К} 8(К."}“) - {$} 8(D."*“) - {С} 8(С,"а") - {С} 8(С."{") - {С} 8(С,“}") - {С} 8(С."(”) - {С} 8(С,"*”) - {Е.С}
412 Глава 10. Регулярные языки 8(С.")“) - {С} 8(Е,”)") - {S} Граф переходов этого автомата изображен на рис. 10.7. Рис. 10.7. Недетерминированный КА для языка комментариев в Borland Pascal Это недетерминированный конечный автомат, поскольку существует состояние, в котором множество, получаемое с помощью функции переходов по одному и тому же символу, имеет бол$е одного следующего состояния. Это состояние С и функция 8(С,"*'') - {Е,С}. Моделировать поведение недетерминированного КА — непростая задача, поэто- му можно построить эквивалентный ему детерминированный КА. Полученный таким путем КА можно затем минимизировать. В результате всех преобразований получаем детерминированный конечный ав- томат M'({S.E,С,D,К,Н}, {"а”, 5',H,{S}) с функцией переходов: 8'(Н."{") - {К} 3'(Н,"(") - {D} 8'(К.”а") - {К} 8'(К."(") “ {К} 8'(К."*") - {К} ЗЧК.")") - (К) 8’(К,“{") - {К} S'UC}") - (S) 8'(D."*”) - {С} 8'(С."а”) - {С} зчс.Т) - (С) 8’(С."}”) - {С) 8’(С."(") - {С} 8'(С.")") - {С} 8'(С.”*") - {Е} 8’(Е.”а”) - {С} 8'(Е."{") - {С} 8'(Е.''}’) - {С} 8'(Е,"(”) - {С} 8'(Е,“*") - {EJ 8'(Е.”)") - {S) Граф переходов этого автомата изображен на рис. 10.8.
Свойства регулярных языков 413 Рис. 10.8. Детерминированный КА для языка комментариев в Borland Pascal На основании этого автомата можно легко построить распознаватель. В данном случае мы можем получить распознаватель для двух типов комментариев языка программирования Borland Pascal, если учесть, что а может Означать любой ал- фавитно-цифровой символ, кроме символов (, *, ), {, }. Свойства регулярных языков Свойства регулярных языков Множество называется замкнутым относительно некоторой операции, если в ре- зультате выполнения этой операции над любыми элементами, принадлежащими данному множеству, получается новый элемент, принадлежащий тому же мно- жеству. Например* множество целых чисел замкнуто относительно операций сложения, умножения и вычитания, но оно не замкнуто относительно операции деления — при делении двух целых чисел не всегда получается целое число. Регулярные множества (и однозначно связанные с ними регулярные языки) замк- нуты относительно многих операций, которые применимы к цепочкам символов. Например, регулярные языки замкнуты относительно следующих операций: □ пересечения; □ объединения; □ дополнения; □ итерации; □ конкатенации; □ гомоморфизма (изменения имен символов и подстановки цепочек вместо сим- волов). Поскольку регулярные множества замкнуты относительно операций пересечения, объединения и дополнения, то они представляют булеву алгебру множеств. Су- ществуют и другие операции, относительно которых замкнуты регулярные мно- жества. Вообще говоря, таких операций достаточно много. Регулярные языки представляют собой очень удобный тип языков. Для них раз- решимы многие проблемы, неразрешимые для других типов языков. Например, доказано, что разрешимыми являются следующие проблемы.
414 Глава 10. Регулярные языки Проблема эквивалентности. Даны два регулярных языка L^V) и L2(V). Необхо- димо проверить, являются ли эти два языка эквивалентными. Проблема принадлежности цепочки языку. Дан регулярный язык L(V) и цепочка символов a eV*. Необходимо проверить, принадлежит ли цепочка данному языку. Проблема пустоты языка. Дан регулярный язык L(V). Необходимо проверить, является ли этот язык пустым, то есть найти хотя бы одну цепочку а^Х, такую что aeL(V). Эти проблемы разрешимы вне зависимости от того, каким из трех способов за- дан регулярный язык. Следовательно, эти проблемы разрешимы для всех спосо- бов представления регулярных языков: регулярных множеств, регулярных грам- матик и конечных автоматов. На самом деле достаточно доказать разрешимость любой из этих проблем хотя бы для одного из способов представления языка, то- гда для остальных способов можно воспользоваться алгоритмами преобразова- ния, рассмотренными выше1. Для регулярных грамматик также разрешима проблема однозначности — доказа- но, что для любой регулярной грамматики можно построить эквивалентную ей однозначную регулярную грамматику. Это очевидно, поскольку для любой регу- лярной грамматики можно однозначно построить регулярное выражение, опре- деляющее заданный этой грамматикой язык. Лемма о разрастании для регулярных языков Иногда бывает необходимо доказать, является или нет некоторый язык регуляр- ным. Конечно, можно пойти путем поиска определения для цепочек заданного языка через один из рассмотренных выше способов (регулярные грамматики, ко- нечные автоматы и регулярные выражения). Если для этого языка хотя бы один из способов будет определен, следовательно, язык является регулярным (и на основании одного найденного способа определения языка можно найти осталь- ные). Но если не удается построить определение языка ни одним из этих спосо- бов, то остается неизвестным: то ли язык не является регулярным, то ли просто не удалось найти определение для него. Однако существует простой метод проверки, является или нет заданный язык регулярным. Этот метод основан на проверке так называемой леммы о разраста- нии языка. Доказано, что если для некоторого заданного языка выполняется лемма о разрастании регулярного языка, то этот язык является регулярным; если же лемма не выполняется, то и язык регулярным не является [6, т. 1]. Лемма о разрастании для регулярных языков формулируется следующим обра- зом: если дан регулярный язык и достаточно длинная цепочка символов, при- надлежащая этому языку, то в этой цепочке можно найти непустую подцепочку, которую можно повторить сколь угодно много раз, и все полученные таким спо- собом новые цепочки будут принадлежать тому же регулярному языку2. 1 Возможны и другие способы представления регулярных множеств, а для них разреши- мость указанных проблем будет уже не очевидна. 2 Если найденную подцепочку повторять несколько раз, то исходная цепочка как бы «раз- растается» — отсюда и название «лемма о разрастании языков».
Контрольные вопросы и задачи 415 Формально эту лемму можно записать так: если дан язык L, то 3 константа р > О, такая, что если aeL и |а| > р, то цепочку а можно записать в виде а я Зре, где О < |р| < р, и тогда а’ я Зр’е, a'eL Vi £ 0. Используя лемму о разрастании регулярных языков, докажем, что язык L я {апЬп | п > 0} не является регулярным. Предположим, что этот язык регулярный, тогда для него должна выполняться лемма о разрастании. Возьмем некоторую цепочку этого языка а я апЬп и запи- шем ее в виде а я Зре. Если Реа+ или РеЬ0+, то тогда для i - 0 цепочка 8р°е я Зе не принадлежит языку L, что противоречит условиям леммы; если же р€а+Ь+, тогда для iя 2 цепочка 8р2е я Зрре не принадлежит языку L. Таким образом, язык L не может быть регулярным языком. Контрольные вопросы и задачи Вопросы 1. В чем заключается отличие автоматных грамматик от других регулярных грам- матик? Всякая ли регулярная грамматика является автоматной? Всякая ли регулярная грамматика может быть преобразована к автоматному виду? 2. Если язык, заданный регулярной грамматикой, содержит пустую цепочку, то может ли он быть задан автоматной грамматикой? 3. Можно ли граф переходов конечного автомата использовать для однозначно- го определения данного автомата (и если нет — то почему)? 4. Всегда ли недетерминированный КА может быть преобразован к детермини- рованному виду (если нет — то в каких случаях)? 5. Какое максимальное количество состояний может содержать детерминирован- ный КА после преобразования из недетерминированного КА? Всегда ли это количество состояний можно сократить? 6. Являются ли регулярными множествами следующие множества: О множество целых натуральных чисел; О множество вещественных чисел; О множество всех слов русского языка; О множество всех возможных строковых констант в языке Pascal; О множество иррациональных чисел; О множество всех возможных вещественных констант языка С? 7. На основании свойств регулярных выражений докажите следующие тождест- ва для произвольных регулярных выражений а, Р, у и 8: (а+р)(8+у) я аЗ + ау + рЗ + ру 8(а+р)у я Зау + Spy Р + ра + ра*я Ра* Р + Раа* + рааа* я Ра*
416 Глава 10. Регулярные языки 8ау0* + 5а‘у + бу - 5а*у (0* + аа’ + ааа*)* “ а’ 8. Используя свойства регулярных множеств, проверьте, являются ли истинны- ми следующие тождества для произвольных регулярных выражений а и ₽: (ару-а-р* (а+Х)*- а* а*р* + р*а* * а*р*а* а а я а* 9. Почему возможны две формы записи уравнений с регулярными коэффициен- тами? Как они соотносятся с двумя классами регулярных грамматик? 10. Можно ли для языка, заданного леволинейной грамматикой, построить пра- волинейную грамматику, задающую эквивалентный язык? 11. Всякая ли регулярная грамматика является однозначной? Если нет, то приве- дите пример неоднозначной регулярной грамматики. 12. Можно ли для двух леволинейных грамматик доказать, что они задают один и тот же язык? Можно ли выполнить то же самое доказательство для леволи- нейной и праволинейной грамматик? Задачи 1. Определите, какие из перечисленных ниже грамматик являются регулярны- ми, леволинейными, праволинейными, автоматными: Gi({".", +, -, 0, 1}, {<число>, <часть>, <цифра>, <осн.>}, Pt, <число>) Рр <число> -> +<осн.> | -<осн.> | <осн.> <осн.> -> <часть>.<часть> | <часть>. | <часть> <часть> -> <цифра> | <частьхцифра> <цифра> -» 0 | 1 G2({".", +, -, 0, 1}, {<число>, <часть>, <осн.>}, Р2, <число>) Р2: <ЧИСЛО> -> +<ОСН.> | -<осн.> | <осн.> <осн.> -> <часть>. | <часть> | <осн.>0 | <осн.>1 <насть> -> 0 | 1 | <часть>0 | <часть>1 G3({".", +, -, 0, 1}, {<число>, <часть>, <осн.>}, Р3, <число>) Р3: <ЧИСЛО> -> +<осн.> | -<осн.> I <осн.> <осн.> —> 0 | 1 | 0.<часть> | 1.<часть> | 0<осн.> | 1<осн.> <часть> —► 0 | 1 | 0<часть> | 1<часть> G4({".", +, -, 0, 1}, {<знак>, <число>, <часть>, <осн.>}, Р4, <число>)
Контрольные вопросы и задачи 417 Р4: <число> -> <знак>0 | <знак>1 | <часть>. | <число>0 | <число>1 <часть> -> <знак>0 | <знак>1 | <часть>0 | <часть>1 <знак> -> Л | + | - GsG".", +, 0, 1}, {<знак>, <число>, <часть>, <осн.>}, Р5, <число>) Р5: <число> -> <часть>. | <осн.>0 | <осн.>1 | <часть>0 | <часть>1 <осн.> -> <часть>. | <осн.>0 | <осн.>1 <часть> —> 0 | 1 | <знак>0 | <знак>1 | <часть>0 | <часть>1 <знак> -> + | - 2. Докажите (любым способом), что грамматики G3, G4 и G5 из задачи № 1 зада- ют один и тот же язык. 3. Преобразуйте к автоматному виду грамматики G3 и G4 из задачи № 1. 4. Постройте КА на основании грамматик G4 или G5 из задачи № 1. Преобра- зуйте построенный автомат к детерминированному виду. 5. Задан КА: M({H,I,S,E,F,G}, {b.d}, 5, Н, {S}), 8(H,b) s {I.S}, 8(H,d) = {Е}, 8(1,b) = {I,S}, 8(1,d) = {I,S}, 8(E,b) = {E.F}, 8(E,d) = {E,F}, 8(F,b) = {E}, 8(F,d) = {E}, 8(G,b) » {F.S}, 8(G,d) » {S}; минимизируйте его и постройте эк- вивалентный ДКА. 6. Задан КА: M({S,R,Z}, {a.b}, 8, S, {Z}), 8(S,a) = {S,R}, 8(R,b) = {R}, 8(R,a) = {Z}. Преобразуйте его к детерминированному виду и минимизируйте получен- ный КА. 7. Постройте и решите систему уравнений с регулярными коэффициентами для грамматики G4 из задачи № 1. Какой язык задает эта грамматика? 8. Докажите, что уравнение с регулярными коэффициентами X s аХ + р имеет решение X в а*(р + у), где у обозначает произвольное множество, в том слу- чае, когда множество, обозначенное выражением а, содержит пустую цепоч- ку. (Указание: поскольку множество, обозначенное выражением а, содержит пустую цепочку, то выражение а можно представить в виде а - 8 + X, при этом а* - 8*.) 9. Решите систему уравнений с регулярными коэффициентами над алфавитом V = (0,l}: Xi = 0X2+ ixt + х Х2 = 0Х3 + 1Х2 Х3 = 0Х! + 1Х3 10. Постройте регулярную грамматику, которая бы описывала язык строк, цело- численных констант и строковых констант языка Pascal. Постройте и решите систему уравнений с регулярными коэффициентами на основе этой грамма- тики. И. С помощью леммы о разрастании для регулярных языков докажите, что язык Lt e {an,bn | п > 2} не является регулярным, а язык L2 - {anbm | n > 1, m > 2} — является регулярным.
ГЛАВА 11 Контекстно- свободные языки Распознаватели КС-языков. Автоматы с магазинной памятью Определение МП-автомата Контекстно-свободными (КС) называются языки, определяемые грамматиками типа G(VT,VN,P,S), в которых правила Р имеют вид: А->р, где AgVN и PgV*, V=VTuVN. Распознавателями КС-языков служат автоматы с магазинной памятью (МП-ав- томаты). В общем виде МП-автомат можно определить следующим образом: R(Q,V,Z,5,qo,zo,F), где Q — множество состояний автомата; V — алфавит входных символов автома- та; Z — специальный конечный алфавит магазинных символов автомата (обычно он включает в себя алфавиты терминальных и нетерминальных символов грам- матики), VcZ; 8 — функция переходов автомата, которая отображает множество Qx(Vu{X})xZ на конечное множество подмножеств P(QxZ*); qoeQ — начальное состояние автомата; zoeZ — начальный символ магазина; FcQ — множество ко- нечных состояний. МП-автомат в отличие от обычного КА имеет стек (магазин), в который можно помещать специальные «магазинные» символы (обычно это терминальные и не- терминальные символы грамматики языка). Переход из одного состояния в дру- гое зависит не только от входного символа, но и от одного или нескольких верх- них символов стека. Таким образом, конфигурация автомата определяется тремя
Распознаватели КС-языков. Автоматы с магазинной памятью 419 параметрами: состоянием автомата, текущим символом входной цепочки (поло- жением указателя в цепочке) и содержимым стека. МП-автомат условно можно представить в виде схемы на рис. 11.1. Входная цепочка символов |ai|a2| |aj Стек (магазин) Рис. 11.1. Общая условная схема автомата с магазинной памятью (МП-автомата) Конфигурация МП-автомата описывается в виде тройки (q,a,co)GQxV*xZ*, кото- рая определяет текущее состояние автомата q, цепочку еще непрочитанных сим- волов а на входе автомата и содержимое магазина (стека) со. Вместо а в конфи- гурации можно указать пару (р,п), где 0gV* — вся цепочка входных символов, а neNu{0}, п > 0 — положение считывающего указателя в цепочке. Тогда один такт работы автомата можно описать в виде (q,aa,zco) 4- (q’,a,yco), если (q',y)6 8(q,a,z), где q,q'eQ, aeVu{X}t aeV*, zgZu{X}, y,cogZ*. При выполнении такта (перехода) из стека удаляется верхний символ, соответствующий условию перехода, и добавляется цепочка, соответствующая правилу перехода. Первый символ цепочки становится верхушкой стека. Допускаются переходы, при кото- рых входной символ игнорируется (и тем самым он будет входным символом при следующем переходе). Эти переходы (такты) называются Х-переходами (Х-так- тами). Аналогично, автомат не обязательно должен извлекать символ из стека — когда z=X, этого не происходит. МП-автомат называется недетерминированным, если из одной и той же его кон- фигурации возможен более чем один переход. Начальная конфигурация МП-автомата, очевидно, определяется как (qo,a,Zo), aeV*. Множество конечных конфигураций автомата — (q,X,co), qeF, cogZ*. МП-автомат допускает (принимает) цепочку символов, если, получив эту цепоч- ку на вход, он может перейти в одну из конечных конфигураций, — когда при окончании цепочки автомат находится в одном из конечных состояний, а стек содержит некоторую определенную цепочку. Тогда входная цепочка принимает- ся (после окончания цепочки автомат может сделать произвольное количество Х-переходов). Иначе цепочка символов не принимается. Язык, определяемый МП-автоматом, — это множество всех цепочек символов, которые допускает данный автомат. Язык, определяемый МП-автоматом R, обо- значается как L(R). Два МП-автомата называются эквивалентными, если они определяют один и тот же язык. Если два МП-автомата Ri и R2 определяют один и тот же язык, это записывается как L(Rt) - L(R2). МП-автомат допускает цепочку символов с опустошением магазина, если при окончании разбора цепочки автомат находится в одном из конечных состояний,
420 Глава 11. Контекстно-свободные языки а стек пуст — конфигурация (qAA), qeF. Если язык задан МП-автоматом R, который допускает цепочки с опустошением стека, это обозначается так: LX(R). Для любого МП-автомата всегда можно построить эквивалентный ему МП-ав- томат, допускающий цепочки заданного языка с опустошением стека. То есть V МП-автомата R: 3 МП-автомат R', такой что L(R) - LX(R'). Кроме обычного МП-автомата существует также понятие расширенного МП-ав- томата. Расширенный МП-автомат может заменять цепочку символов конечной длины в верхней части стека на другую цепочку символов конечной длины. В отличие от обычного МП-автомата, который на каждом такте работы может изымать из сте- ка только один символ, расширенный МП-автомат может изымать за один такт сразу некоторую цепочку символов, находящуюся на вершине стека. Функция переходов 8 для расширенного МП-автомата отображает множество Qx(Vu{X})xZ* на конечное множество подмножеств P(QxZ*). Доказано, что для любого расширенного МП-автомата всегда можно построить эквивалентный ему обычный МП-автомат (обратное утверждение очевидно, так как любой обычный МП-автомат является и расширенным МП-автоматом). Та- ким образом, классы МП-автоматов и расширенных МП-автоматов эквивалент- ны и задают один и тот же тип языков. Эквивалентность языков МП-автоматов и КС-грамматик Пусть задана КС-грамматика G(VT,VN,P,S). Построим на ее основе МП-автомат R({q},VT,VTuVN,8,q,S,{q}), Этот автомат имеет только одно состояние. Опреде- лим функцию переходов автомата следующим образом: (q,a)e8(q,X,A), VA->a g Р; (q,X)G3(q,a,a), Va g VT. Начальная конфигурация автомата: (q,a,S); конечная конфигурация автомата: (qAA). Докажем, что данная грамматика и построенный на ее основе автомат определя- ют один и тот же язык. Для этого надо доказать два следующих утверждения: □ если в грамматике G существует вывод А=>*а, то автомат R может сделать последовательность шагов (q,a,A)-h*(q,XA), где AgVN — некоторый произ- вольный нетерминальный символ грамматики, а aGVT; □ если автомат R может сделать последовательность шагов (q,a,A)-h*(q,XA), то в грамматике G существует вывод А=>*а. Докажем первое утверждение. Доказательство будем вести на основе математи- ческой индукции. Положим, что в грамматике G существует вывод A=>ma для некоторого ш > 0. Для m = 1 имеем A=>a, а - aia2...ak, k>0. Тогда если k s 0, то а = X, и должно су- ществовать правило грамматики А->Х, а по определению автомата R имеем:
Распознаватели КС-языков. Автоматы с магазинной памятью 421 (q,a,A) * (чЛЛ). Если к > 0, то должно существовать правило грамматики А-ча, и по определению автомата R получаем: (q.ajaz-.ak.A) + (q,a1a2...ak)a1a2...ak) - (q,a2...ak,a2...ak) ч-... + (q,ak,ak) 4- (qAA), следовательно, (q,a1a2...ak,A) - (q,a1a2...ak,a1a2...ak) -A (qAA) и (q,a1a2...ak,A) +* (qAA). Таким образом, утверждение для m = 1 доказано. Предположим, что для некоторого ш>1 это утверждение справедливо. Значит, если существует вывод А=>та в грамматике G, то существует и последователь- ность шагов автомата R: (q,a,A) ч-* (q,X,X). Следуя принципу математической индукции, докажем теперь, что это утвержде- ние справедливо и для некоторого ш+1. То есть докажем, что если существует вывод A=>m+1a в грамматике G, то существует и последовательность шагов авто- мата R: (q,a,AX*(q,X,X). Рассмотрим первый шаг вывода A=>m+1a: A=>XtX2...Xk, где V k>i>0: Xie(VTbVN). Если XjeVN, то существует вывод X, хь причем х^УТ* и т,<т; если же XjGVT, то Xi=Xj. Саму исходную цепочку а можно записать как конкатенацию цепочек терминальных символов х,: а ж xtx2...xk. Если первый шаг вывода A=>XiX2...Xk, тогда в грамматике G существует правило А~»Х1Х2...Хк, и, по определению автомата R, первым шагом его работы может быть шаг: (q,a,A) 4- (q,a,XtX2...Xk). Но для всех XjG VN, так как V k > i > 0: Xi =>mi х, и m, < ш, то по утверждению ин- дукции имеем (q,Xj,Xi) ч-* (q,X,X). А для всех’XjgVT по определению автомата R имеем (q^^Xj) 4- (q,X,X). Объединяя шаги работы автомата R для всех Xj из конфигурации (q,a,X1X2...Xk), получаем (q,a,X!X2...Xk) ч-* (q,X,X), следовательно, (q,a,A) ч- (q,a,XtX2...Xk) ч-* (q,X,X), и отсюда можно утверждать, что (q,a,A) ч-* (q,X,X). На основании положений математической индукции утверждение доказано. Докажем второе утверждение. Доказательство будем вести на основе математи- ческой индукции. Положим, что автомат R может выполнить последовательность шагов (q,a,А) ч-п (q,X,X) для некоторого п > 0. Если п - 1, тогда имеем один шаг работы автомата: (q,a,A) ч- (q,X,X) и, следова- тельно, a = X. Отсюда, согласно определению автомата R, в грамматике G долж- но существовать правило вида А—>Х. Тогда в грамматике G существует и вывод А=>Х или, что то же самое, А=>*а. Для n = 1 утверждение доказано. Предположим, что для некоторого п>1 это утверждение справедливо. Значит, если существует последовательность шагов работы автомата R: (q,a,A) ч-п (q,X,X), то в грамматике G существует вывод А=>*а. Теперь докажем, что это утверждение справедливо и для некоторого п +1. Рас- смотрим последовательность шагов работы автомата R: (q,a,A) ч-п+1 (q,X,X). Пер- вым шагом этой последовательности будет шаг (q,a,A) ч- (q,a,XtX2...Xk), V k > i > 0: XjG(VTuVN). Причем, по определению автомата R, в грамматике G должно су- ществовать правило вида А-»Х1Х2...Хк.
422 Глава 11. Контекстно-свободные языки Можно утверждать, что V XjG(VTWN), (q,Xj,Xj) 4-ni (q,X,X), XjGVT*, причем а - x1x2...xk, и в грамматике G существует вывод Xj=>*Xj. Действительно, если XjeVN и (q,Xj,Xj) -Pi (q,X,X), XjGVT*, то по сделанному пред- положению индукции справедливо утверждение, что существует вывод Xj=>*Xj в грамматике G, так как Vi: п,<п. Если же XjGVT, то по определению автомата R: (q,Xj,Xj) 4- (q,X,X), Xj=Xj, что соот- ветствует выводу Х1=>°Хр и тогда также справедливо Xj=>*Xj. 1 Тогда можно построить левосторонний вывод в грамматике G: А => =>* xtX2...Xk =>* xtx2...Xk =>* ... =>* xtx2...xk = а. Следовательно, в грамматике G суще- ствует вывод А =>* а. На основании положений математической индукции утверждение доказано. Поскольку из двух доказанных утверждений однозначно следует, что в КС-грам- матике G существует вывод S =>* а (где S — целевой символ грамматики) тогда и только тогда, когда в МП-автомате R существует последовательность шагов работы автомата (q,a,S) 4-* (q,X,X), то можно утверждать, что построенный МП- автомат R распознает язык, заданный КС-грамматикой G: L(R) = L(G). Посколь- ку построенный МП-автомат допускает входные цепочки языка с опустошением стека, то доказано также утверждение LX(R) = L(G). При доказательстве на структуру правил грамматики G не накладывалось ника- ких дополнительных ограничений, поэтому все доказанные утверждения спра- ведливы для произвольной КС-грамматики. После того как доказано, что для произвольной КС-грамматики всегда можно построить МП-автомат, распознающий заданный этой грамматикой язык, можно говорить, что МП-автоматы распознают КС-языки. На самом деле существует также и доказательство того, что для произвольного МП-автомата всегда можно построить КС-грамматику, которая задает язык, распознаваемый этим автома- том. Таким образом, КС-грамматики и МП-автоматы задают один и тот же тип языков — КС-языки. Поскольку класс расширенных МП-автоматов эквивалентен классу обычных МП- автоматов и задает тот же самый тип языков, то можно утверждать, что и расши- ренные МП-автоматы распознают языки из типа КС-языков. Следовательно, язык, который может распознавать расширенный МП-автомат, также может быть за- дан с помощью КС-грамматики. 1 Здесь используется представление о том, что обозначение «=>*» включает в себя также понятие «вывод с нулевым количеством шагов», которое обозначается «=>°» и на самом деле означает, что две цепочки символов совпадают (равны). Если каждая цепочка состо- ит из одного символа, то символы двух цепочек эквивалентны между собой. Эта особен- ность упоминалась, когда вводилось понятие — «а =>* Р» — «цепочка р выводима из цепочки а», но до настоящего момента явно нигде не использовалась. В данном случае это не влияет на структуру доказательства, но очень удобно, поскольку не требует ввода дополнительного обозначения для понятия «цепочка Р выводима из цепочки а или сов- падает с нею».
Свойства КС-языков 423 Детерминированные МП-автоматы МП-автомат называется детерминированным, если из одной и той же его конфи- гурации возможно не более одного перехода в следующую конфигурацию. Формально для детерминированного МП-автомата R(Q,V,Z,8,q0,z0,F) функция переходов 8 может VqeQ, VaeV, VzeZ иметь один из следующих трех видов: 1. 8(q,a,z) содержит один элемент: 8(q,a,z) » {(q’,y)}, yeZ* и 8(q,X,z) = 0. 2. 8(q,a,z) = 0 и 8(q,X,z) содержит один элемент: 8(q,X,z) = {(q',y)}, yeZ*. 3. 8(q,a,z) = 0 и 8(q,X,z) e 0. Класс ДМП-автоматов и соответствующих им языков значительно уже, чем весь класс МП-автоматов и КС-языков. В отличие от обычного конечного автомата, не для каждого МП-автомата можно построить эквивалентный ему ДМП-авто- мат. Иными словами, в общем случае невозможно преобразовать недетермини- рованный МП-автомат в детерминированный. Детерминированные МП-автоматы (ДМП-автоматы) определяют очень важный класс среди всех КС-языков, называемый детерминированными КС-языками. Доказано, что все языки, принадлежащие к классу детерминированных КС-язы- ков, могут быть построены с помощью однозначных КС-грамматик. Поскольку однозначность — это важное и обязательное требование в грамматике любого язы- ка программирования, ДМП-автоматы представляют особый интерес для созда- ния компиляторов. Синтаксический распознаватель цепочек любого языка про- граммирования может быть построен на основе ДМП-автомата. Кроме того, доказано, что для любого ДМП-автомата всегда можно построить эквивалентный ему ДМП-автомат, который будет учитывать входную цепочку до конца — не допускать бесконечной последовательности Х-переходов по завер- шении цепочки [6, т. 1]. Это значительно облегчает моделирование работы ДМП- автоматов. Все без исключения синтаксические конструкции языков программирования за- даются с помощью однозначных КС-грамматик (неоднозначность, конечно же, ни в одном компиляторе недопустима). Синтаксические структуры этих языков относятся к классу детерминированных КС-языков и могут распознаваться с по- мощью ДМП-автоматов. Поэтому большинство распознавателей, которые будут рассмотрены далее, относятся к классу детерминированных КС-языков. Свойства КС-языков Свойства произвольных КС-языков Класс КС-языков замкнут относительно операции подстановки. Это означает, что если в каждую цепочку символов КС-языка вместо некоторого символа под- ставить цепочку символов из другого КС-языка, то получившаяся новая цепочка также будет принадлежать КС-языку. Это основное свойство КС-языков. Формально оно может быть записано так.
424 Глава 11. Контекстно-свободные языки Если L,LapLa2,...,Lan — это произвольные КС-языки и {а!,а2,...,ап} — алфавит язы- ка L, п>0, то тогда язык L’ e а^а^.а^еЬ, х^Ьд , x2eLa xkeLa , k > 0: V k > i > 0: n > jj > 0} также является КС-язьп^эм [oj т. 1]. 2 Например: L = {0nln | n > 0}, Ц = {a}, Lt = {bmcm | m > 0} — это исходные КС-языки, тогда после подстановки получаем новый КС-язык: L’ = {anbmicmibm2cm2...bmncmn | п > 0, V i: ггц > 0}. На основе замкнутости относительно операции подстановки можно доказать дру- гие свойства КС-языков. В частности, класс КС-языков замкнут относительно следующих четырех операций: □ объединения; □ конкатенации; □ итерации; □ гомоморфизма (изменения имен символов). Интересно, что класс КС-языков не замкнут относительно операции пересече- ния, а поэтому не является классом булевой алгебры. Как следствие, этот класс не замкнут и относительно операции дополнения [6, т. 1]. Например: Li = {anbncj | n > 0, i > 0} и L2 = {аЪпсп | n > 0, i > 0} — КС-языки, но L = LinL2 = = {anbncn | n > 0} не является КС-языком (это можно проверить с помощью лем- мы о разрастании КС-языков, которая рассмотрена ниже). Для КС-языков разрешимы проблема пустоты языка и проблема принадлежно- сти заданной цепочки языку — для их решения достаточно построить МП-авто- мат, распознающий данный язык. Но для КС-языков является неразрешимой проблема эквивалентности двух произвольных КС-грамматик, а, как следствие, также и проблема однозначности заданной КС-грамматики. Не разрешима даже более узкая проблема — проблема эквивалентности заданной произвольной КС- грамматики и произвольной регулярной грамматики. Тем не менее, хотя в общем случае проблема однозначности для КС-языков не разрешима, для некоторых КС-грамматик можно построить эквивалентную им однозначную грамматику. Свойства детерминированных КС-языков Детерминированные КС-языки — это класс тех КС-языков, цепочки которых можно распознавать с помощью ДМП-автоматов. Класс детерминированных КС- языков, естественно, является собственным подмножеством всего класса КС-язы- ков [6, т. 1]. Как уже было сказано ранее, детерминированные КС-языки — это значительно более узкий класс, чем все КС-языки. Класс детерминированных КС-языков не замкнут даже относительно операции объединения, равно как и операции пере- сечения (хотя и замкнут относительно операции дополнения, в отличие от всех КС-языков в целом).
Свойства КС-языков 425 Класс детерминированных КС-языков интересен тем, что для него разрешима проблема однозначности. Доказано, что если язык может быть распознан с помо- щью ДМП-автомата (и потому является детерминированным КС-языком), то он может быть описан на основе однозначной КС-грамматики. Именно поэтому дан- ный класс как раз и используется для построения синтаксических конструкций языков программирования. Дополнительно следует заметить, что в общем случае подавляющее большинст- во языков программирования (таких, как Pascal, С, FORTRAN и т. п.) формаль- но не являются КС-языками. Причина в том, что в языках программирования всегда присутствует контекстная зависимость, которая выражается, например, в необходимости предварительного описания переменных, в соответствии коли- чества и типов формальных и фактических параметров процедур и функций и т. п. Но с целью упрощения работы компиляторов подобного рода зависимости на этапе синтаксического анализа не учитывают, рассматривая только сами кон- струкции языков программирования, которые формально могут быть описаны с помощью КС-грамматик. Соблюдение контекстных условий (зависимостей) в компиляторах проверяется уже на этапе семантического анализа при подготов- ке к генерации кода. Лемма о разрастании КС-языков Лемма о разрастании КС-языков звучит так: если взять достаточно длинную це- почку символов, принадлежащую произвольному КС-языку, то в ней всегда можно выделить две подцепочки, длина которых в сумме больше нуля, таких, что, повторив их сколь угодно большое число раз, можно получить новую цепоч- ку символов, принадлежащую данному языку [6, т. 1]. Формально ее можно определить следующим образом: если L — это КС-язык, то 3 keN, к > 0, что если |а| > к и aeL, то а = ерЗусо, где Ру^Х, |рЗу| < к и ерФукоеЬ Vi > 0 (где N — это множество целых чисел)1. Как и для регулярных языков, лемма о разрастании для КС-языков служит для проверки принадлежндсти заданного языка классу КС-языков. Доказано, что всякий язык является КС-языком тогда и только тогда, когда для него выполня- ется лемма о разрастании КС-языков. Например, докажем, что язык L = {anbncn | п > 0} не является КС-языком. Предположим, что этот язык все же является КС-языком. Тогда для него должна выполняться лемма о разрастании, и существует константа к, заданная в этой лемме. Возьмем цепочку а = akbkck, |а| > к, принадлежащую этому языку. Если ее записать в виде а = ерЗусо, то по условиям леммы |р8у| < к, следовательно, цепоч- ка рЗу не может содержать вхождений всех трех символов а, b и с — каких-то 1 Для КС-языков, как и для регулярных языков, с помощью леммы о разрастании можно повторять подцепочки сколько угодно раз и получать новые цепочки языка — исходная цепочка как бы «разрастается» (отсюда название леммы). На самом деле лемма о разрас- тании для КС-языков является частным случаем более общей леммы, известной как «лемма Огдена» [5, т. 1].
426 Глава 11. Контекстно-свободные языки символов в ней нет. Рассмотрим цепочку 8р08у0со - е8со. По условиям леммы она должна принадлежать языку, но в то же время она содержит либо к символов а, либо к символов с и при этом не может содержать к вхождений каждого из сим- волов а, b и с, так как |е8со| < 3k. Значит, какой-то символ в ней встречается мень- ше, чем другие — такая цепочка не может принадлежать языку L. Следовательно, язык L не удовлетворяет требованиям леммы о разрастании КС-языков и поэто- му не является КС-языком. Преобразование КС-грамматик. Приведенные грамматики Преобразование грамматик. Цель преобразования Как было сказано выше, для КС-грамматик невозможно в общем случае прове- рить их однозначность и эквивалентность. Но очень часто правила КС-грамма- тик можно и нужно преобразовать к некоторому заранее заданному виду таким образом, чтобы получить новую грамматику, эквивалентную исходной. Заранее определенный вид правил грамматики позволяет упростить работу с языком, за- данным этой грамматикой, и облегчает создание распознавателей для него. Таким образом, можно выделить две основные цели преобразований КС-грам- матик: упрощение правил грамматики и облегчение создания распознавателя языка. Не всегда эти две цели можно совместить. В случае с языками програм- мирования, когда итогом работы с грамматикой является создание компилятора языка, именно вторая цель преобразования является основной. Поэтому упро- щениями правил пренебрегают, если при этом удается упростить построение распознавателя языка [12, 15, 32]. Все преобразования условно можно разбить на две группы: □ первая группа — это преобразования, связанные с исключением из граммати- ки тех правил и символов, без которых она может существовать (именно эти преобразования позволяют выполнить основные упрощения правил); □ вторая группа — это преобразования, в результате которых изменяется вид и состав правил грамматики, при этом грамматика может дополняться новыми правилами, а ее словарь нетерминальных символов — новыми символами (то есть преобразования второй группы не связаны с упрощениями). Следует еще раз подчеркнуть, что всегда в результате преобразований мы полу- чаем новую КС-грамматику, эквивалентную исходной, то есть определяющую тот же самый язык. Тогда формально преобразование можно определить следующим образом: G(VT,VN,P,S) -> G'(VT,VN',P',S’): L(G) = L(G’)
Преобразование КС-грамматик. Приведенные грамматики 427 Приведенные грамматики Приведенные грамматики — это КС-грамматики, которые не содержат недости- жимых и бесплодных символов, циклов и Х-правил («пустых» правил). Приве- денные грамматики называют также КС-грамматиками в каноническом виде. Для того чтобы преобразовать произвольную КС-грамматику к приведенному виду, необходимо выполнить следующие действия: □ удалить все бесплодные символы; □ удалить все недостижимые символы; □ удалить Х-правила; □ удалить цепные правила. Следует подчеркнуть, что шаги преобразования должны выполняться именно в указанном порядке, и никак иначе. Удаление недостижимых символов Символ xg(VTuVN) называется недостижимым, если он не встречается ни в од- ной сентенциальной форме грамматики G(VT,VN,P,S). Конечно, чтобы исключить из грамматики все недостижимые символы, не надо рассматривать все ее сентенциальные формы (это просто невозможно), доста- точно воспользоваться специальным алгоритмом удаления недостижимых сим- волов. Алгоритм удаления недостижимых символов строит множество достижимых сим- волов грамматики G(VT,VN,P,S) — Vj. Первоначально в это множество входит только целевой символ грамматики S, затем оно пополняется на основе правил грамматики. Все символы, которые не войдут в данное множество, являются не- достижимыми и могут быть исключены в новой грамматике G' из словаря и из правил. Алгоритм удаления недостижимых символов по шагам 1. V0 = {S},i:=l. 2. Vj = {х | xg(VTuVN) и (А-»ахр)еР, АеУм, a,pe(VTuVN)*} u V^. 3. Если Vj * УЬ1, то i := i+1 и перейти к шагу 2, иначе перейти к шагу 4. 4. VN' - VN n Vj, VT' - VT n Vi, в P' входят те правила из Р, которые содержат только символы из множества Vb S’ я S. Удаление бесплодных символов В грамматике G(VT,VN,P,S) символ AeVN называется бесплодным, если для него выполняется: {a | А=>*а, ае VT*} = 0, то есть нетерминальный символ явля- ется бесплодным тогда, когда из него нельзя вывести ни одной цепочки терми- нальных символов.
428 Глава 11. Контекстно-свободные языки В простейшем случае символ является бесплодным, если во всех правилах, где этот символ стоит в левой части, он также встречается и в правой части. Более сложные варианты предполагают зависимости между цепочками бесплодных сим- волов, когда они в любой последовательности вывода порождают друг друга. Для удаления бесплодных символов используется специальный алгоритм удале- ния бесплодных символов. Он работает со специальным множеством нетерми- нальных символов Yj. Первоначально в это множество попадают только те сим- волы, из которых непосредственно можно вывести терминальные цепочки, затем оно пополняется на основе правил грамматики G. Алгоритм удаления бесплодных символов по шагам 1. Yo = 0,i:=l. 2. Yj = {А | (А->а)еР, аб^иУТ)*} u 3. Если Yj * Yi_t, то i := i+1 и перейти к шагу 2, иначе перейти к шагу 4. 4. VN' - Yb VT - VT, в Р' входят те правила из Р, которые содержат только символы из множества (VTVY,), S’-S. Пример удаления недостижимых и бесплодных символов Рассмотрим работу алгоритмов удаления недостижимых и бесплодных симво- лов на примере грамматики: G((a,b.c}.{A.B.C.D.E.F.G.S}.P.S) Р: S -> аАВ | Е А -> аА | ЬВ В -> АСЬ | b С -> А | ЬА | сС | аЕ Е сЕ | аЕ | ЕЬ | ED | FG D -> а | с | Fb F -> ВС | ЕС | АС G —> Ga | Gb Следует обратить внимание, что для правильного выполнения преобразований необходимо сначала удалить бесплодные символы, а потом — недостижимые сим- волы, но не наоборот. То есть порядок, в каком будут выполняться алгоритмы, имеет существенное значение. Удалим бесплодные символы: 1. Yo = 0, i:sl. 2. Yt «{B,D}, Yt#Y0: i:=2. 3. Y2 = {B,D,A}, Y2#Yt: i:-3. 4. Y3 - {B,D,A,S,C}, Y3*Y2: i:-4. 5. Y4«{B,D,A,S,C,F}, Y4*Y3: i:=5. 6. Y5 = {B,D,A,S,C,F},Y5 = Y4. 7. Строим множества VN' e {A,B,C,D,F,S}, VT' = {a,b,c} и P'.
Преобразование КС-грамматик. Приведенные грамматики 429 Получили грамматику: G'({a.b.c}.{A.B.C.D.F.S},Р'.5) Р’: S -> аАВ А -> аА | ЬВ В -> АСЬ | Ь С -> А | ЬА | сС D -> а | с | Fb F -> ВС | АС Удалим недостижимые символы: 1. V0 = {S},i:=l. 2. Vi = {S,А,В}, Vi*V0: i:=2. 3. V2 = {S,A,B,C}, V2^t: i:=2. 4. V3 = {S,A,B,C}, V3 = V2. 5. Строим множества VN" = {A,B,C,S}, VT" = {a,b,c} и P'. В итоге получили грамматику: G"({a.b.c}.{A.B.C.S}.Р”,S) р”: S -> аАВ А -> аА | ЬВ В -> АСЬ | Ь С -> А | ЬА | сС Алгоритмы удаления бесплодных и недостижимых символов относятся к первой группе преобразований КС-грамматик. Они всегда ведут к упрощению грамма- тики, сокращению количества символов алфавита и правил грамматики. Устранение Х-правил Х-правилами (или правилами с пустой цепочкой) называются все правила грам- матики вида А—>Х, где AgVN. Грамматика G(VT,VN,P,S) называется грамматикой без Х-правил, если в ней не существует правил (A—>X)gP, A^S и существует только одно правило (S—>X)gP, в том случае, когда XeL(G), и при этом S не встречается в правой части ни одно- го правила грамматики. Для того чтобы упростить построение распознавателей цепочек языка L(G), лю- бую грамматику G целесообразно преобразовать к виду без Х-правил. Существу- ет алгоритм преобразования произвольной КС-грамматики к виду без Х-правил. Он работает с некоторым множеством нетерминальных символов Wj. Алгоритм устранения Х-правил по шагам 1. Wo = {A: (A->X)gP }, i:=l. 2. Wi - Wj.j u {А: (А->а)еР, aeWn*}.
430 Глава 11. Контекстно-свободные языки 3. Если Wj * Wj_b то i := i+1 и перейти к шагу 2, иначе перейти к шагу 4. 4. VN'= VN, VT'= VT, в Р' входят все правила из Р, кроме правил вида А->Х. 5. Если (А->а)е Р и в цепочку а входят символы из множества Wj, тогда на ос- нове цепочки а строится множество цепочек {а'} путем исключения из а всех возможных комбинаций символов из Wb и все правила вида А—>а' добавля- ются в Р’. 6. Если SeWj, то значит XeL(G), и тогда в VN' добавляется новый символ S', который становится целевым символом грамматики G’, а в Р' добавляются два новых правила: S'—>X|S; иначе S' = S. Данный алгоритм часто ведет к увеличению количества правил грамматики, но позволяет упростить построение распознавателя для заданного языка. Пример устранения Х-правил Рассмотрим грамматику: G({a.b.c}.{A.B.C.S}.P.S) Р: S -> АаВ | аВ | сС А -> АВ I a I b I В В -> Ва | Л С -> АВ | с Удалим Х-правила: 1. W0»{B},i:=l. 2. Wj - {В,A}, W^W0, i:~2. 3. W2-{В,А,С}, W2#Wb i:-3. 4. W3 = {B,A,C},W3 = W2. 5. Построим множества VN' = {A,B,C,S}, VT' = {a,b,c} и множество правил Р'. 6. Рассмотрим все правила из множества Р': О Из правил S->AaB |аВ|сС исключим все комбинации А, В и С и получим но- вые правила S—>Аа | аВ | а | а | с, добавим их в Р', исключая дубликаты, полу- чим: S—>AaB | аВ | сС | Аа | аВ | а | с. О Из правил А->АВ | а | b | В исключим все комбинации А и В и получим новые правила А—>А | В, в Р' их добавлять не надо, поскольку правило А->В там уже есть, а правило А->А бессмысленно. О Из правила В->Ва исключим В и получим новое правило В—>а, добавим его в Р', получим В->Ва|а. О Из правил С-»АВ | с исключим все комбинации А и В и получим новые пра- вила С—>А|В, добавим их в Р', получим С—>АВ|А|В|с. 7. SgW3, поэтому в грамматику G' не надо добавлять новый целевой символ S', S' = S.
Преобразование КС-грамматик. Приведенные грамматики 431 Получим грамматику: G'({a.b.c}.{A.B.C.S},Р'.S) Р’: S -> АаВ | аВ | сС | Аа | а | с А -> АВ | а | b | В В —> Ва | а С -> АВ | А | В | с Устранение цепных правил Циклом (циклическим выводом) в грамматике G(VT,VN,P,S) называется вывод вида А=>*А, AgVN. Очевидно, что такой вывод абсолютно бесполезен. Поэтому в распознавателях КС-языков целесообразно избегать возможности появления циклов. Циклы возможны только в том случае, если в КС-грамматике присутствуют цеп- ные правила вида А-»В, A,BeVN. Чтобы исключить возможность появления цик- лов в цепочках вывода, достаточно устранить цепные правила из набора правил грамматики. Чтобы устранить цепные правила в КС-грамматике G(VT,VN,P,S), для каждого нетерминального символа XeVN строится специальное множество цепных сим- волов Nx, а затем на основании построенных множеств выполняются преобразо- вания правил Р. Поэтому алгоритм устранения цепных правил надо выполнить для всех нетерминальных символов грамматики из множества VN. Алгоритм устранения цепных правил по шагам 1. Для всех символов X из множества VN повторять шаги 1—4, затем перейти к шагу 5. 2. Nx0 = {X}, i:=l. 3. Nx - Nx j u {В: (A->B)gP, BgNx J. 4. Если Nx * N\b to i:=i+l и перейти к шагу 2, иначе Nx - NXj-{X} и перейти к шагу 1. 5. VN' = VN, VT - VT, в Р' входят все правила из Р, кроме правил вида А-»В, S’ = S. 6. Для всех правил (A-hx)gP', если BgNa, В^А, то в Р' добавляются правила вида В->а. Данный алгоритм, так же как и алгоритм устранения Х-правил, ведет к увеличе- нию числа правил грамматики, но упрощает построение распознавателей. Пример устранения цепных правил Рассмотрим работу алгоритмов удаления недостижимых и бесплодных симво- лов на примере грамматики: G({a.b.c}.{A.B.C.S}.P.S) Р: S -> АаВ | аВ | сС | Аа | а | с
432 Глава 11. Контекстно-свободные языки А -> АВ | а | b | В В —» Ва | а С -> АВ | А | с Устраним цепные правила: 1. Ns0 = {S}, i:=l. 2. N V {S}, NSi= Ns0, Ns = 0. 3. N^ = {A},i:=l. 4. NAt я{A,B}, NVN^: i:=2. 5. NA> = {A,B}, NV NV NA = {B}. 6. NB0 = {B}, i:=l. 7. Nb1 = {B},Nbi=Nbo,Nb = 0. 8. Nc0 = {C}, i:=l. 9. Nct я {C,A}, NCi*Nc0: i:-2. 10. Nc2 = {C,A,B}, NC2*Nct: i:=3. 11. NV {C,A,B}, Nc3 - Nc2, Nc - {A,B}. 12. Получили: Ns = 0, NA = {B}, NB = 0, Nc я {A,В}, S’ я S, построим множества VN' я {A,B,C,S}, VT’ я {a,b,c} и множество правил P'. 13. Рассмотрим все правила из множества Р’ — интерес для нас представляют только правила для символов А и В, так как NA = {В } и Nc = {А,В}. О Для правил А-»АВ | а | b имеем новые правила С->АВ | а | Ь, поскольку AgNc (пра- вило А-»В цепное и поэтому не входит в Р'), из них правило С-»АВ уже су- ществует в Р'. О Для правил В~»Ва | а имеем новые правила А-»Ва | а и С->Ва | а, поскольку BgNa и BeNc, из них правила А-»а и С~»а (последнее добавлено на преды- дущем шаге) уже существуют в Р’. Получим новую грамматику: G’({a.b.c},{A.B.C.S},Р’.S) Р': S -> АаВ | аВ | сС | Аа | а | с А -> АВ | а | b | Ва В -> Ва | а С -> АВ | с | а | b | Ва Рассмотрим дополнительно в качестве примера грамматику для арифметических выражений над символами «а» и «Ь», которая уже рассматривалась ранее в этом пособии в разделе «Проблемы однозначности и эквивалентности грамматик», гла- ва 9 - G({+,a,b}, {S.T.E}, Р, S): Р: S S+T I S-Т I т Т -> Т*Е I Т/Е I Е Е -> (S) | а | Ь
КС-грамматики в нормальной форме 433 Устраним цепные правила: 1. NV{S},i:M. 2. Nst ~ {S,T}, NS^NSO, i:=2. 3. Ns2 = {S,T,E}, Ns2*NSi, i:=3. 4. Ns3 = {S,T,E}, Ns3 = Ns2, Ns ~ {T,E}. 5. NT0 = {T}, i:=l. 6. NTt = {T,E}, NVNt0: i:=2. 7. Nt2 = {T,E}, Nt2= NTb NT = {E}. 8. = {E}, i:=l. 9. NEt = {E}, NEr NE0, NE = 0. 10. Получили: Ns = {T,E}, N7 = {E}, NE = 0, S' = S, построим множества VN' = {S,T,E}, VT' - a,b } и множество правил P'. И. Рассмотрим все правила из множества Р' — интерес представляют только пра- вила для символов Т и Е, так как Ns - {Т,Е} и NTs {Е}: О Для правил Т->Т*Е|Т/Е имеем новые правила S->T*E|T/E, поскольку ТеNs. О Для правил Е—>(S)|a|b имеем новые правила S—>(S)|a|b и Т—>(S)|a|b, посколь- ку EgNs и EeNT. Получим новую грамматику: G'({+,-,/,*,a.b}. {S.T.E}, Р’. S) Р’: S -> S+T | S-T I Т*Е I Т/Е I (S) I a I b Т -> Т*Е | Т/Е | (S) | а | b Е (S) | а | b Эту грамматику мы дальше будем использовать для построения распознавателей КС-языков. КС-грамматики в нормальной форме Грамматики в нормальной форме Хомского Нормальная форма Хомского или бинарная нормальная форма (БНФ) — это одна из предопределенных форм для правил КС-грамматики. В нормальную форму Хомского можно преобразовать любую произвольную КС-грамматику. Для пре- образования в нормальную форму Хомского предварительно грамматику надо преобразовать в приведенный вид. Определение нормальной формы Хомского КС-грамматика G(VT,VN,P,S) называется грамматикой в нормальной форме Хомского, если в ее множестве правил Р присутствуют только правила следую- щего вида:
434 Глава 11. Контекстно-свободные языки 1. А-> ВС, где A,B,CgVN. 2. А -» а, где AgVN и aeVT. 3. S -» X, если XgL(G), причем S не должно встречаться в правых частях других правил. Никакие другие формы правил не должны встречаться среди правил граммати- ки в нормальной форме Хомского [6, т. 1, 26]. КС-грамматика в нормальной форме Хомского называется также грамматикой в бинарной нормальной форме (БНФ). Название «бинарная» происходит от того, что на каждом шаге вывода в такой грамматике один нетерминальный символ может быть заменен только на два других нетерминальных символа. Поэтому в дереве вывода грамматики в нормальной форме Хомского каждая вершина либо распадается на две другие вершины (в соответствии с первым видом правил), либо содержит один последующий лист с терминальным символом (в соответст- вии со вторым видом правил). Третий вид правил введен для того, чтобы к нор- мальной форме Хомского можно было преобразовывать грамматики КС-языков, содержащих пустые цепочки символов. Алгоритм преобразования грамматики в нормальную форму Хомского Алгоритм позволяет преобразовать произвольную исходную КС-грамматику в эквивалентную грамматику в нормальной форме Хомского. Условие: дана КС-грамматика G(VT,VN,P,S), необходимо построить эквивалентную ей грамматику G'(VT,VN’,P’,S’) в нормальной форме Хомского: L(G) s s L(G'). На первом шаге исходную грамматику надо преобразовать к приведенному виду. Поскольку алгоритм преобразования КС-грамматик к приведенному виду был рассмотрен выше, можно считать, что исходная грамматика уже является приве- денной (не содержит бесполезных и недостижимых символов, цепных правил и Х-правил). В начале работы алгоритма преобразования приведенной КС-грамматики в нор- мальную форму Хомского множество нетерминальных символов VN' результи- рующей грамматики G' строится на основе множества нетерминальных симво- лов VN исходной грамматики G: VN' = VN. Затем алгоритм преобразования работает с множеством правил Р исходной грам- матики G. Он просматривает все правила из множества Р и в зависимости от вида каждого правила строит множество правил Р' результирующей граммати- ки G' и дополняет множество нетерминальных символов этой грамматики VN'. 1. Если встречается правило вида А~»а, где AgVN и acVT, то оно переносится во множество Р' без изменений. 2. Если встречается правило вида А-»ВС, где A,B,CgVN, то оно переносится во множество Р' без изменений. 3. Если встречается правило вида S~»X, где S — целевой символ грамматики G, то оно переносится во множество Р’ без изменений.
КС-грамматики в нормальной форме 435 4. Если встречается правило вида А->аВ, где A,BgVN и aeVT, то во множест- во правил Р' включаются правила А-><АаВ>В и <АаВ>-»а и новый символ <АаВ > добавляется во множество нетерминальных символов VN' граммати- ки G'. 5. Если встречается правило вида А—>Ва, где A,BgVN и aeVT, то во множество правил Р' включаются правила А—>В<АВа> и <АВа>-»а, и новый символ <АВа> добавляется во множество нетерминальных символов VN' граммати- ки G'. 6. Если встречается правило вида А->аЬ, где A gVN и a,beVT, то во множество правил Р' включаются правила А-»<Аа><АЬ>, <Аа>-»а и <АЬ>-»Ь, новые символы <Аа> и <АЬ> добавляются во множество нетерминальных симво- лов VN' грамматики G’. 7. Если встречается правило вида A-^Хр..Хь к>2, где AgVN и Vi: XjGVTuVN, то во множество правил Р' включается цепочка правил: А -» <Х1’хХ2...Хк> <Х2...Хк> -» <Х2’хХ3...Хк> <Хк4Хк> -» <Хк.1’><Хк’> новые нетерминальные символы <Х2...Хк>, <Х2...Хк>,<Хк4Хк> включают- ся во множество нетерминальных символов VN’ грамматики G’, кроме того, Vi: если X^VN, то <Х/>жХь иначе (если XjGVT) <Х/> — это новый нетер- минальный символ, он добавляется во множество VN’, а во множество пра- вил Р’ грамматики G' добавляется правило <Х/> -» Хр Целевым символом результирующей грамматики G’ является целевой символ исходной грамматики G. Пример преобразования грамматики в нормальную форму Хомского Рассмотрим в качестве примера грамматику G({a,b,c},{A,B,C,S},P,S) Р: S —> АаВ | Аа | Ьс А АВ | а | аС В -> Ва | b С -> АВ | с Эта грамматика уже находится в приведенной форме. Построим эквивалентную ей грамматику G'(VT,VN’,P’,S ) в нормальной форме Хомского. Начнем построе- ние с множества нетерминальных символов новой грамматики: VN' = {A,B,C,S}. Множество еще будет дополняться в процессе работы алгоритма. Начнем разбирать правила этой грамматики. Первое правило исходной грамматики S-»AaB подпадает под 7-й вариант рабо- ты алгоритма. В соответствии с требованиями алгоритма заменяем его на после- довательность:
436 Глава 11. Контекстно-свободные языки S -> <А’><аВ> <аВ> -> <а'><В'> Поскольку А и В — нетерминальные символы, а «а» — терминальный символ, то получаем, что <А'>=А и <В’>=В, а новое правило <а’>-»а должно быть добав- лено во множество правил Р’ новой грамматики. Получаем последовательность правил: S -> А<аВ> <аВ> -> <а’>В <а’>->а Во множество нетерминальных символов VN' новой грамматики необходимо до- бавить новые символы <аВ> и <а’>. Получаем VN' = {A,B,C,S,<aB>,<a’>}. Второе правило исходной грамматики S—>Aa подпадает под 5-й вариант работы алгоритма. Заменяем его на два правила: S _> A<SAa> <SAa> -> а Новый символ <SAa> добавляется во множество нетерминальных символов но- вой грамматики. Получаем VN’ = {A,B,C,S,<aB>,<a’>,<SAa>}. Третье правило исходной грамматики S->bc подпадает под 6-й вариант работы алгоритма. Заменяем его на три правила: S -> <Sb><Sc> <Sb> -> b <Sc> -> с Новые символы <Sb> и <Sc> добавляются во множество нетерминальных сим- волов новой грамматики. Получаем VN' = {A,B,C,S,<aB>,<a’>,<SAa>,<Sb>,<Sc>}. Четвертое правило исходной грамматики А—>АВ подпадает под 2-й вариант рабо- ты алгоритма. Переносим его во множество правил новой грамматики без изме- нений. Пятое правило исходной грамматики А—>а подпадает под 1-й вариант работы ал- горитма. Переносим его во множество правил новой грамматики без изменений. Шестое правило исходной грамматики А->аС подпадает под 4-й вариант работы алгоритма. Заменяем его на два правила: А -> <АаОС <АаО -> а Новый символ <АаС> добавляется во множество нетерминальных символов но- вой грамматики. Получаем VN’ = {A,B,C,S,<aB>,<a’>,<SAa>,<Sb>,<Sc>,<AaC>}. Седьмое правило исходной грамматики В—>Ва подпадает под 5-й вариант работы алгоритма. Заменяем его на два правила: В —> В<ВВа> <ВВа> -> а Новый символ <ВВа> добавляется во множество нетерминальных символов но- вой грамматики. Получаем VN’ = {A,B,C,S,<aB>,<a’>,<SAa>,<Sb>,<Sc>,<AaC>, <ВВа>}.
КС-грамматики в нормальной форме 437 Восьмое правило исходной грамматики В-»Ь подпадает под 1-й вариант работы алгоритма. Переносим его во множество правил новой грамматики без изменений. Девятое правило исходной грамматики С—>АВ подпадает под 2-й вариант работы алгоритма. Переносим его во множество правил новой грамматики без изменений. Десятое правило исходной грамматики С->с подпадает под 1-й вариант работы алгоритма. Переносим его во множество правил новой грамматики без изменений. Рассмотрение множества правил исходной грамматики закончено. Множество правил Р' новой грамматики G' и множество нетерминальных символов VN' этой грамматики окончательно построены. Целевым символом новой граммати- ки является символ S. Получаем новую грамматику в нормальной форме Хомского, эквивалентную ис- ходной: G'({a,b,с},{A,B,C,S,<aB>,<a'>,<SAa>,<Sb>,<Sc>,<AaC>,<BBa>},Р',S): Р’: S -> А<аВ> | A<SAa> | <Sb><Sc> <аВ> -> <а’>В <а' >->а <SAa> -> а <Sb> -> b <Sc> -> с А -> АВ | а | <АаС>С <АаС> -> а В -> В<ВВа> | b <ВВа> -> а С -> АВ | с Видно, что при приведении грамматики к нормальной форме Хомского количе- ство правил и нетерминальных символов в грамматике увеличивается. При этом растет объем грамматики и несколько затрудняется ее восприятие человеком. Од- нако цель преобразования — не упрощение грамматики, а упрощение построе- ния распознавателя языка на ее основе. Именно этой цели и служит нормальная форма Хомского. Далее будут рассмотрены методы построения распознавателей, в основе которых лежит именно эта форма представления грамматики КС- языка. Устранение левой рекурсии. Грамматики в нормальной форме Грейбах Определение левой рекурсии Символ AeVN в КС-грамматике G(VT,VN,P,S) называется рекурсивным, если для него существует цепочка вывода вида А=>+аА0, где a,pe(VTVVN)*. Если а = X и р^Х, то рекурсия называется левой, а грамматика G — леворекур- сивной; если а^Х и р = X, то рекурсия называется правой, а грамматика G — пра- ворекурсивной. Если a = X и р = X, то рекурсия представляет собой цикл. Когда грамматика G — приведенная, в ней нет цепных правил и не может встречаться циклов, поэтому далее циклы рассматриваться не будут.
438 Глава 11. Контекстно-свободные языки Любая КС-грамматика может быть как леворекурсивной, так и праворекурсив- ной, а также леворекурсивной и праворекурсивной одновременно (по различ- ным символам из множества нетерминальных символов). КС-грамматика называется нелеворекурсивной, если она не является леворекур- сивной. Аналогично, КС-грамматика является неправорекурсивной, если не яв- ляется праворекурсивной. Некоторые алгоритмы левостороннего разбора для КС-языков не работают с ле- ворекурсивными грамматиками, поэтому возникает необходимость исключить левую рекурсию из выводов грамматики. Далее будет рассмотрен алгоритм, ко- торый позволяет преобразовать правила произвольной КС-грамматики таким образом, чтобы в выводах не встречалась левая рекурсия. Следует отметить, что поскольку рекурсия лежит в основе построения языков на основе правил грамматики в форме Бэкуса—Наура, полностью исключить рекур- сию из выводов грамматики невозможно. Можно избавиться только от одного вида рекурсии — левого или правого, то есть преобразовать исходную граммати- ку G к одному из видов: нелеворекурсивному (избавиться от левой рекурсии) или неправорекурсивному (избавиться от правой рекурсии). Для левосторонних распознавателей интерес представляет избавление от левой рекурсии — то есть преобразование грамматики к нелеворекурсивному виду. Доказано, что любую КС-грамматику можно преобразовать к нелеворекурсивно- му или неправорекурсивному виду. Алгоритм устранения левой рекурсии Условие: дана КС-грамматика G(VT,VN,P,S), необходимо построить эквивалент- ную ей нелеворекурсивную грамматику G'(VN',VT,P',S'): L(G) = L(G'). Алгоритм преобразования работает с множеством правил исходной граммати- ки Р, множеством нетерминальных символов VN и двумя переменными счетчи- ками: i и j. Шаг 1. Обозначим нетерминальные символы грамматики так: VN = {А^А^.-Ли}- i := 1. Шаг 2, Рассмотрим правила для символа Аь Если эти правила не содержат левой рекурсии, то перенесем их во множество правил Р' без изменений, а символ Aj добавим во множество нетерминальных символов VN’. Иначе запишем правила для А, в виде Aj -> Aiai|Aia2|...|Aiam|p1|p2|...|pp, где Vj 1 < j < Р ни одна из цепочек pj не начинается с символов Ак, таких, что k < i. Вместо этого правила во множество Р' запишем два правила вида: Ai -> p1|p2|...|₽p|₽1Ai'|₽2Ai'|...|ppAi' Aj' ->a1|a2|...|am|aIAi'|a2Ai'|...|amAi' Символы Aj и А,' включаем во множество VN’. Теперь все правила для Aj начинаются либо с терминального символа, либо с не- терминального символа Ак, такого, что k > i.
КС-грамматики в нормальной форме 439 Шаг 3. Если i = п, то грамматика G' построена, иначе ii+1, j :я 1 и перейти к шагу 4. Шаг 4. Для символа Aj во множестве правил Р' заменить все правила вида А*—-> Aja, где (^g(VTuVN)*, на правила вида Ар->p1a|p2cc|...|pinoc, причем Aj->p1|p2|-|pm ~ все правила для символа Aj. Так как правая часть правил Aj-•>Pi|p2|---lPm Уже начинается с терминального сим- вола или нетерминального символа Ак, к > j, то и правая часть правил для симво- ла Aj будет удовлетворять этому условию. Шаг 5. Если j = i-1, то перейти к шагу 2, иначе j :s j+1 и перейти к шагу 4. Шаг 6. Целевым символом грамматики G’ становится символ Ак, соответствую- щий символу S исходной грамматики G. Рассмотрим в качестве примера грамматику для арифметических выражений над символами «а» и «Ь» G({+,а,b}, {S,T,E}, Р, S): Р: S -> S+T I S-Т I т Т -> Т*Е I Т/Е I Е Е -> (S) | а | Ь Эта грамматика является леворекурсивной. Построим эквивалентную ей нелево- рекурсивную грамматику G'. Шаг 1. Обозначим VN = {Ab А2, А3}. i1. Тогда правила грамматики G будут иметь вид: Aj —Aj+A2 | Aj-A2 | А2 А2 —> А2*А3 | А2/А3 | A3 А3 -» (Aj) | а | Ь Шаг 2. Для Aj имеем правила Aj-»Aj+A2|Aj-A2|A2. Их можно записать в виде Aj-> —>Аха11 Axa21 pi, где 04 = +А2, a2 = -А2, pi = А2. Запишем новые правила для множества Р': Aj A2|A2Aj' Aj ’ —> +А21"А21 +A2Aj ’ j -A2Aj ' Добавив эти правила в Р', а символы At и Af во множество нетерминальных символов, получим: VN’ = {Aj,Aj’}. Шаг 3. ie 1 < 3. Построение не закончено: i:- i+l = 2, j := 1. Шаг 4. Для символа А2 во множестве правил Р' нет правила вида A2-»Aja, поэто- му на этом шаге никаких действий не выполняем. Шаг 5. j - 1 - i-1, переходим опять к шагу 2. Шаг 2. Для А2 имеем правила А2->А2*А3|А2/А3|А3. Их можно записать в виде А2-> ->A2aj | A2a21 pj, где aj = *А3, a2 = /А3, pj == А3. Запишем новые правила для множества Р': А2 —> Аз|АзА2' А2’ -> *А3|/Аз|*А3А2’|/АзА2’
440 Глава 11. Контекстно-свободные языки Добавим эти правила в Р', а символы А2 и А2' во множество нетерминальных символов, получим: VN' = {А^АДА^А^}. Шаг 3. i = 2 < 3. Построение не закончено: i:- i+1 = 3, j :e 1. Шаг 4, Для символа А3 во множестве правил Р' нет правила вида Аз—^а, поэто- му на этом шаге никаких действий не выполняем. Шаг 5. j s 1 < i-1, j := j+1 - 2, переходим к шагу 4. Шаг 4. Для символа А3 во множестве правил Р' нет правила вида А3->А2а, поэто- му на этом шаге никаких действий не выполняем. Шаг 5. j = 2 = i-1, переходим опять к шагу 2. Шаг 2. Для А3 имеем правила А3 -> (At) | а | b. Эти правила не содержат левой рекурсии. Переносим их в Р’, а символ А3 добавляем в VN’. Получим: VN' в =s {А1,А1',А2,А2',А3}. Шаг 3. i - 3 s 3. Построение грамматики G' закончено. В результате выполнения алгоритма преобразования получили нелеворекурсив- ную грамматику G( а, Ь}, {ApAf ,А2,А2' ,А3}, Р', AJ с правилами: Р': Aj —А2 | A2Aj Aj —+А2 | -А2 | +A2At | ”A2Aj А2 —> А3 | А3А2' А2’ -+ *А3 | /А3 | *А3А2’ | /А3А2’ А3 -» (AJ | а | b Грамматики в нормальной форме Грейбах На основании грамматики, в которой исключена левая рекурсия, можно постро- ить грамматику в нормальной форме Грейбах. КС-грамматика G(VT,VN,P,S) называется грамматикой в нормальной форме Грей- бах, если она не является леворекурсивной и в ее множестве правил Р присутст- вуют только правила следующего вида: 1. А -> аа, где aeVT и aeVN*. 2. S X, если XgL(G), причем S не должно встречаться в правых частях других правил. Никакие другие формы правил не должны встречаться среди правил граммати- ки в нормальной форме Грейбах. Нормальная форма Грейбах является удобной формой представления грамматик для построения нисходящих левосторонних распознавателей (в тех случаях, ко- гда присутствие левой рекурсии в правилах грамматики недопустимо). В данном пособии эта нормальная форма отдельно не рассматривается. Подробнее с нею можно ознакомиться в [6, т. 1, 26].
Распознаватели КС-языков с возвратом 441 Распознаватели КС-языков с возвратом Принципы работы распознавателей с возвратом Распознаватели с возвратом — это самый примитивный тип распознавателей для КС-языков. Логика их работы основана на моделировании недетерминированно- го МП-автомата. Поскольку моделируется недетерминированный МП-автомат (который в общем виде не преобразуется в детерминированный), то на некотором шаге работы мо- делирующего алгоритма возможно возникновение нескольких допустимых сле- дующих состояний автомата. В таком случае существуют два варианта реализа- ции алгоритма [6, т. 1, 40]. В первом варианте на каждом шаге работы алгоритм должен запоминать все воз- можные следующие состояния МП-автомата, выбирать одно из них, переходить в это состояние и действовать так до тех пор, пока либо не будет достигнуто ко- нечное состояние автомата, либо автомат не перейдет в такую конфигурацию, когда следующее состояние будет не определено. Если достигнуто одно из ко- нечных состояний — входная цепочка принята, работа алгоритма завершается. В противном случае алгоритм должен вернуть автомат на несколько шагов на- зад, когда еще был возможен выбор одного из набора следующих состояний, вы- брать другой вариант и промоделировать поведение автомата с этим условием. Алгоритм завершается с ошибкой, когда все возможные варианты работы авто- мата будут перебраны и ни одно из возможных конечных состояний не было достигнуто. Во втором варианте алгоритм моделирования МП-автомата должен на каждом шаге работы при возникновении неоднозначности с несколькими возможными следующими состояниями автомата запускать новую свою копию для обработки каждого из этих состояний. Алгоритм завершается, если хотя бы одна из выпол- няющихся его копий достигнет одно из конечных состояний. При этом работа всех остальных копий алгоритма прекращается. Если ни одна из копий алгорит- ма не достигла конечного состояния МП-автомата, то алгоритм завершается с ошибкой. Второй вариант реализации алгоритма связан с управлением параллельными процессами в вычислительных системах, поэтому сложен в реализации. Кроме того, на каждом шаге работы МП-автомата альтернатив следующих состояний может быть много, а количество возможных параллельно выполняющихся про- цессов в операционных системах ограничено, поэтому применение второго ва- рианта алгоритма осложнено. По этим причинам большее распространение по- лучил первый вариант алгоритма, который предусматривает возврат к ранее запомненным состояниям МП-автомата — отсюда и название «разбор с возвра- тами».
442 Глава 11. Контекстно-свободные языки Следует отметить, что, хотя МП-автомат является односторонним распознавате- лем, алгоритм моделирования его работы предусматривает возврат назад, к уже прочитанной части цепочки символов, чтобы исключить недетерминизм в пове- дении автомата (который невозможно промоделировать). Есть еще одна особенность в моделировании МП-автомата: любой практически ценный алгоритм должен завершаться за конечное число шагов (успешно или неуспешно). Алгоритм моделирования работы произвольного МП-автомата в об- щем случае не удовлетворяет этому условию. Например, даже после считывания всей входной цепочки символов МП-автомат может совершить произвольное (в том числе и бесконечное) число Х-переходов. В том случае, если цепочка не принята, это может привести к бесконечному количеству шагов моделирующего алгоритма, который по этой причине никогда не будет закончен. Чтобы избежать таких ситуаций, алгоритмы разбора с возвратами строят не для произвольных МП-автоматов, а для МП-автоматов, удовлетворяющим некото- рым заданным условиям. Как правило, эти условия связаны с тем, что МП-авто- мат должен строиться на основе грамматики заданного языка только после того, как она подвергнется некоторым преобразованиям. Поскольку преобразования грамматик сами по себе не накладывают каких-либо ограничений на входной класс КС-языков (в результате преобразования мы всегда получаем эквивалент- ную грамматику), то они и не ограничивают применимости алгоритмов разбора с возвратами — эти алгоритмы применимы для любого КС-языка, заданного произвольной КС-грамматикой или МП-автоматом. Алгоритмы разбора с возвратами обладают экспоненциальными характеристика- ми. Это значит, что вычислительные затраты алгоритмов экспоненциально зави- сят от длины входной цепочки символов: а, аеVT*, п = |а|. Конкретная зависи- мость определяется вариантом реализации алгоритма. Доказано, что в общем случае при первом варианте реализации для произволь- ной КС-грамматики G(VT,VN,P,S) время выполнения данного алгоритма Тэ бу- дет иметь экспоненциальную зависимость от длины входной цепочки, а необхо- димый объем памяти Мэ — линейную зависимость от длины входной цепочки: Тэ я О(еп) и Мэ я О(п). При втором варианте реализации, наоборот, время вы- полнения данного алгоритма Тэ будет иметь линейную зависимость от длины входной цепочки, а необходимый объем памяти Мэ — экспоненциальную зависи- мость от длины входной цепочки: Тэ я О(п) и Мэ = О(еп). Экспоненциальная зависимость вычислительных затрат от длины входной це- почки существенно ограничивает применимость алгоритмов разбора с возврата- ми. Они тривиальны в реализации, но имеют неудовлетворительные характери- стики, поэтому могут использоваться только для простых КС-языков с малой длиной входных предложений языка1. Для многих классов КС-языков существу- Возможность использовать эти алгоритмы в реальных компиляторах весьма сомнитель- на, поскольку длина входной цепочки может достигать нескольких тысяч и даже десят- ков тысяч символов. Очевидно, что время работы алгоритма при экспоненциальной зависимости требуемых вычислительных ресурсов от длины входной цепочки символов будет в таком варианте явно неприемлемым даже на самых современных компьютерах.
Распознаватели КС-языков с возвратом 443 ют более эффективные алгоритмы распознавания, поэтому алгоритмы разбора с возвратами применяются редко. Далее рассмотрены два основных варианта таких алгоритмов. Нисходящий распознаватель с возвратом Принцип работы нисходящего распознавателя с подбором альтернатив Этот распознаватель моделирует работу МП-автомата с одним состоянием q: R({q}, V,Z,8,q,S,{q}). Автомат распознает цепочки КС-языка, заданного КС- грамматикой G(VT,VN,P,S). Входной алфавит автомата содержит терминальные символы грамматики: V = VT, а алфавит магазинных символов строится из тер- минальных и нетерминальных символов грамматики: Z я VTuVN. Начальная конфигурация автомата определяется так: (q,a,S) — автомат пребы- вает в своем единственном состоянии q, считывающая головка находится в нача- ле входной цепочки символов aeVT*, в стеке лежит символ, соответствующий целевому символу грамматики S. Конечная конфигурация автомата определяется так: (q,X,X) — автомат пребывает в своем единственном состоянии q, считывающая головка находится за концом входной цепочки символов, стек пуст. Функция переходов МП-автомата строится на основе правил грамматики: 1. (q,a)e8(q,X,A), AgVN, ae(VTuVN)*, если правило A->a содержится во мно- жестве правил Р грамматики G: A->a g Р. 2. (q,X)e8(q,a,a) VaeVT. Этот МП-автомат уже был рассмотрен выше. Работу данного МП-автомата можно неформально описать следующим образом: если на верхушке стека автомата находится нетерминальный символ А, то его можно заменить на цепочку символов а, если в грамматике языка есть правило А—>а, не сдвигая при этом считывающую головку автомата (этот шаг работы на- зывается «подбор альтернативы»); если же на верхушке стека находится терми- нальный символ а, который совпадает с текущим символом входной цепочки, то этот символ можно выбросить из стека и передвинуть считывающую головку на одну позицию вправо (этот шаг работы называется «выброс»). Данный МП-ав- томат может быть недетерминированным, поскольку при подборе альтернативы в грамматике языка может оказаться более одного правила вида А->а, следо- вательно, тогда функция 8(q,X,A) будет содержать более одного следующего со- стояния — у автомата будет несколько альтернатив. Данный МП-автомат строит левосторонние выводы для грамматики G(VT,VN, P,S). Для моделирования такого автомата необходимо, чтобы грамматика G(VT, VN,P,S) не была леворекурсивной (в противном случае, очевидно, автомат мо- жет войти в бесконечный цикл). Поскольку, как было доказано выше, произволь- ную КС-грамматику всегда можно преобразовать к нелеворекурсивному виду, то этот алгоритм применим для любой КС-грамматики, следовательно, им можно распознавать цепочки любого КС-языка.
444 Глава 11. Контекстно-свободные языки Рассмотренный МП-автомат строит левосторонние выводы и читает цепочку входных символов слева направо. Поэтому для него естественным является по- строение дерева вывода сверху вниз. Такой распознаватель называется нисходя- щим. Решение о том, выполнять ли на каждом шаге работы МП-автомата выброс или подбор альтернативы, принимается однозначно. Моделирующий алгоритм дол- жен обеспечивать выбор одной из возможных альтернатив и хранение информа- ции о том, какие альтернативы на каком шаге уже были выбраны, чтобы иметь возможность вернуться к этому шагу и подобрать другие альтернативы. Такой алгоритм разбора называется алгоритмом с подбором альтернатив. Реализация алгоритма распознавателя с подбором альтернатив Существует масса способов реализации алгоритма, моделирующего работу этого МП-автомата. Рассмотрим один из примеров реализации алгоритма нисходяще- го распознавателя с возвратом. Для работы алгоритма используется МП-автомат, построенный на основе исход- ной грамматики G(VT,VN,P,S). Для удобства работы все правила из множест- ва Р в грамматике G представим в виде A-»ai|a2|...|an, то есть пронумеруем все возможные альтернативы для каждого нетерминального символа AgVN. Вход- ная цепочка символов имеет вид а ж aia2...an, |а| = п. В алгоритме используется также еще дополнительное состояние автомата b (от «back» — «назад»), которое сигнализирует о выполнении возврата к уже прочитанной части входной цепоч- ки1. Для хранения уже выбранных альтернатив используется дополнительный стек L2, который может содержать следующую информацию: □ символы aeVT входного языка автомата; □ символы вида Aj, где AgVN — это означает, что среди всех возможных правил для символа А была выбрана альтернатива с номером j. В итоге алгоритм работает с двумя стеками: Li — стек МП-автомата и L2 — стек возвратов. Оба они представлены в виде цепочек символов. Символы в цепочку стека Li помещаются слева, а в цепочку стека L2 — справа. В целом состояние ал- горитма на каждом шаге определяется четырьмя параметрами: (Q, i, Lt, L2), где Q — текущее состояние автомата (q или b); i — положение считывающей голов- ки во входной цепочке символов а (1 < i < n+1); Li — содержимое стека МП-ав- томата; L2 — содержимое дополнительного стека. Начальным состоянием алгоритма является состояние (q, 1, S, X), где S — целе- вой символ грамматики. Алгоритм начинает свою работу с начального состояния и циклически выполняет шесть шагов до тех пор, пока не перейдет в конечное состояние или не обнаружит ошибку. На каждом шаге алгоритма проверяется, 1 Сам автомат имеет только одно состояние q, которого достаточно для его функциониро- вания, однако нет возможности моделировать на компьютере работу недетерминирован- ного автомата, поэтому приходится выполнять возврат к уже прочитанной части цепочки и вводить для этой цели дополнительное состояние.
Распознаватели КС-языков с возвратом 445 соответствует ли текущее состояние алгоритма заданному для данного шага ис- ходному состоянию, и выполняются ли заданные дополнительные условия. Если это требование выполняется, алгоритм переходит в следующее состояние, уста- новленное для этого шага, если нет — шаг пропускается, алгоритм переходит к следующему шагу. Алгоритм предусматривает циклическое выполнение следующих шагов. Шаг 1 (Разрастание), (q, i, Ар, а) -> (q, i, YiP, аАД если A-+Y1 — это первая из всех возможных альтернатив для символа А. Шаг 2 (Успешное сравнение), (q, i, ар, а) -> (q, i+1, р, аа), если а = аь aeVT. ШагЗ (Завершение), Если состояние соответствует (q, n+1, X, а), то разбор завер- шен, алгоритм заканчивает работу, иначе (q, i, X, а) -> (b, i, X, а), когда i п+1. Шаг 4 (Неуспешное сравнение), (q, i, ар, а) -+ (b, i, ар, а), если а ab aeVT. Шаг 5 (Возврат по входу), (b, i, р, аа) -> (q, i-1, ар, а), V aeVT. Шаг 6 (Другая альтернатива). Исходное состояние (b, i, у,р, aAj), действия: О перейти в состояние (q, i, Yj+iP, aAj+1), если еще существует альтернатива A->Yj+1 для символа AgVN; О сигнализировать об ошибке и прекратить выполнение алгоритма, если A=S и не существует больше альтернатив для символа S; О иначе перейти в состояние (q, i, Ар, a). В случае успешного завершения алгоритма цепочку вывода можно построить на основе содержимого стека Ь2, полученного в результате выполнения алгоритма. Цепочка вывода строится следующим образом: поместить в цепочку номер пра- вила ш, соответствующий альтернативе А—если в стеке содержится символ Ар все символы aeVT, содержащиеся в стеке Ь2, игнорируются. Этот алгоритм может быть напрямую использован для построения распознава- телей. Следует помнить, что для применения этого алгоритма исходная грам- матика не должна быть леворекурсивной. Если это условие не удовлетворяет- ся, то грамматику предварительно надо преобразовать к нелеворекурсивному виду. Рассмотрим в качестве примера грамматику G({+,-,/,*,a,b}, {S,R,T,F,E}, Р, S) с правилами: Р: S -> Т | TR R -> +Т | -Т | +TR | -TR Т -> Е | EF F -> *Е | /Е | *EF | /EF Е -> (S) | а | Ь Это нелеворекурсивная грамматика для арифметических выражений (ранее в разделе «Устранение левой рекурсии. Грамматики в нормальной форме Грейбах» она была построена с помощью алгоритма устранения левой ре- курсии).
446 Глава 11. Контекстно-свободные языки Проследим разбор цепочки а+(а*Ь) из языка этой грамматики с помощью алго- ритма нисходящего распознавателя с возвратами. Работу алгоритма будем пред- ставлять в виде последовательности его состояний, взятых в скобки {} (фигурные скобки используются, чтобы не путать их с круглыми скобками, предусмотрен- ными в правилах грамматики). Альтернативы будем нумеровать слева направо, правила — слева направо и сверху вниз. Для пояснения каждый шаг работы со- провождается номером шага алгоритма, который был применен для перехода в очередное состояние (записывается перед состоянием через символ «:» — двое- точие). Алгоритм работы нисходящего распознавателя с возвратами при разборе цепоч- ки а+(а*Ь) будет выполнять следующие шаги: 1. 0: {q, 1, S, 1} 2. 1: {q, 1, Т, SJ 3. 1: {q, 1, Е, SjTj} 4. 1: {q, 1, {S}, SjTjEj} 5. 4: {b, 1, {S}, SjTjEj} 6. 6: {q, 1, a, SjTjE2} 7. 2: {q, 2, X, SjTjE2a} 8. 3: {b, 2, X, SjTjE2a} 9. 5: {b, 1, a, SjTjE2} 10. 6: {q, 1, b, SjTjE3} 11. 4:{b, 1, b, SjTjE3} 12. 6: {b, 1, E, SjTj} 13. 6: {q, 1, EF, SjT2} 14. 1: {q, 1, {S}F, SjT2Ej} 15. 4: {b, 1, {S}F, SjT2Ej} 16. 6: {q, 1, aF, SjT2E2} 17. 2: {q, 2, F, SjT2E2a} 18. 1: {q, 2, *E, SjT2E2aFj} 19. 4: {b, 2, *E, SjT2E2aFj} 20. 6: {q, 2, /Е, SjT2E2aF2} 21.4: {b, 2, /Е, SjT2E2aF2} 22. 6: {q, 2, *EF, SjT2E2aF3} 23. 4: {b, 2, *EF, SjT2E2aF3} 24. 6: {q, 2, /EF, SjT2E2aF4} 25. 4: {b, 2, /EF, SjT2E2aF4} 26. 6: {b, 2, F, SjT2E2a} 27. 5: {b, 1, aF, SjT2E2} 28. 6: {q, 1, bF, SjT2E3}
Распознаватели КС-языков с возвратом 447 29. 4: {b, 1, bF, SiT2E3} 30. 6: {b, 1, EF, StT2} 31. 6: {b, 1, T, SJ 32. 6: {q, 1, TR, S2} 33. 1: {q, 1, ER, S^} 34. 1: {q, 1, {S}R, S^EJ 35. 4: {b, 1, {S}R, S2T1E1} 36. 6: {q, 1, aR, S2TtE2} 37. 2: {q, 2, R, S2TiE2a} 38. 1: {q, 2, +T, S2TiE2aRi} 39. 2: {q, 3, T, S^E^R^} 40. 1: {q, 3, E, S^iE^Rt+TJ 41. 1: {q, 3, (S), S^E^R^T^} 42. 2: {q, 4, S), S2T1E2aR1+T1E1(} 43. 1: {q, 4, T), S^E^+TACSj} 44. 1: {q, 4, E), S^E^R^^E/SiTJ 45. 1: {q, 4, (S)), З^Е^+Т^^Т^} 46. 4: {b, 4, (S)), SzTtE^Rt+TiE^SiT^i} 47. 6: {q, 4, a), S^^aRt+TtE^S^E^ 48. 2: {q, 5, ), S2T1E2aR1+T1E1(S1T1E2a} 49. 4: {b, 5, ), S2T1E2aR1+T1E1(S1T1E2a} 50. 5: {b, 4, a), S^tE^Ri+TjE^StTjE^ 51. 6: {q, 4, b), S2T1E2aR1+T1E1(S1T1E3} 52. 4: {b, 4, b), S2T1E2aR1+T1E1(S1T1E3} 53. 6: {b, 4, E), S2T1E2aR1+T1E1(S1T1} 54. 6: {q, 4, EF), S2T1E2aR1+T1E1(S1T2} 55. 1: {q, 4, (S)F), S2T1E2aR1+T1E1(S1T2E1} 56. 4: {b, 4, (S)F), S2T1E2aR1+T1E1(S1T2E1} 57. 6: {q, 4, aF), S^E^R^TiE^SjT^} 58. 2: {q, 5, F), S2T1E2aR1+T1E1(S1T2E2a} 59. 1: {q, 5, *E), S^E^R^TtE^/T^aPt} 60. 2: {q, 6, E), S2T1E2aR1+T1E1(S1T2E2aF1*} 61. 1: {q, 6, (S)), S^EjaRi+TtE^S/T^aF^Et} 62. 4: {b, 6, (S)), SzTjEzaRi+TiEtCSt^E^Fj’Ej} 63. 6: {q, 6, a), S^tE^Rj+TjE^SjT^aF^E^ 64. 4: {b, 6, a), З^Е^+Т^^ТгЕ^’Е;,} 65. 6: {q, 6, b), SzTjEzaRt+TjE/S^EzaFi’E-,}
448 Глава 11. Контекстно-свободные языки 66. 2: {q, 7, ), ЗгТ^а^+Т^^ТгЕгаР^ЕзЬ} 67. 2: {q, 8, X, ЗзТ^аВ^+Т^^ТзЕзаЕ^ЕзЬ)} 68. 3: Разбор закончен, алгоритм завершен. На основании полученной цепочки номеров альтернатив S2T1E2R1T1E1S1T2E2F1E3 построим последовательность номеров примененных правил: 2, 7, 14, 3, 7, 13, 1, 8, 14, 9, 15. Получаем левосторонний вывод: S => TR => ER => aR => а+Т => а+Е a+(S) => а+(Т) => a+(EF) a+(aF) => а+(а*Е) a+(a*b). Соответствующее ему дерево вывода приведено на рис. 11.2. Рис. 11.2. Дерево вывода для грамматики без левых рекурсий Из приведенного примера очевиден недостаток алгоритма нисходящего разбора с возвратами — значительная временная емкость: для разбора достаточно корот- кой входной цепочки (всего 7 символов) потребовалось 68 шагов работы алго- ритма. Такого результата и следовало ожидать, исходя из экспоненциальной зависимости необходимых для работы алгоритма вычислительных ресурсов от длины входной цепочки. Это существенный недостаток данного алгоритма. Преимуществом данного алгоритма можно считать простоту его реализации. Прак- тически этот алгоритм разбора можно использовать только тогда, когда извест- но, что длина исходной цепочки символов заведомо не будет большой (не боль- ше нескольких десятков символов). Для реальных компиляторов такое условие невыполнимо, но для некоторых небольших распознавателей вполне допустимо, и здесь данный алгоритм разбора может найти применение именно благодаря своей простоте. Еще одно преимущество алгоритма — его универсальность. На его основе можно распознавать входные цепочки языка, заданного любой КС-грамматикой, доста- точно лишь привести ее к нелеворекурсивному виду (а это можно сделать с любо! грамматикой, см. раздел «Преобразование КС-грамматик. Приведенные грамма тики»). Интересно, что грамматика даже не обязательно должна быть однознач
Распознаватели КС-языков с возвратом 449 ной — для неоднозначной грамматики алгоритм найдет один из возможных ле- восторонних выводов. Сам по себе алгоритм разбора с подбором альтернатив, использующий возвраты, не находит применения в реальных компиляторах. Однако его основные принци- пы лежат в основе многих нисходящих распознавателей, строящих левосторон- ние выводы и работающих без использования возвратов. Методы, позволяющие строить такие распознаватели для некоторых классов КС-языков, рассмотрены далее. Эти распознаватели будут более эффективны в смысле необходимых вы- числительных ресурсов, но алгоритмы их работы уже более сложны, кроме того, они не являются универсальными. Распознаватель на основе алгоритма «сдвиг-свертка» Принцип работы восходящего распознавателя по алгоритму «сдвиг-свертка» Этот распознаватель строится на основе расширенного МП-автомата с одним состоянием q: R({q},V,Z,8,q,S,{q}). Автомат распознает цепочки КС-языка, задан- ного КС-грамматикой G(VT,VN,P,S). Входной алфавит автомата содержит тер- минальные символы грамматики: V e VT; а алфавит магазинных символов стро- ится из терминальных и нетерминальных символов грамматики: Z e VTuVN. Начальная конфигурация автомата определяется так: (q,a,X) — автомат пребыва- ет в своем единственном состоянии q, считывающая головка находится в начале входной цепочки символов aeVT*, стек пуст. Конечная конфигурация автомата определяется так: (q,X,S) — автомат пребывает в своем единственном состоянии q, считывающая головка находится за концом входной цепочки символов, в стеке лежит символ, соответствующий целевому символу грамматики S. Функция переходов МП-автомата строится на основе правил грамматики: 1. (q,A)e8(q,X,Y), AeVN, yg(VTuVN)*, если правило A—>y содержится во мно- жестве правил Р грамматики G: A->y g ₽• 2. (q,a)e8(q,a,X) VaeVT. Неформально работу этого расширенного автомата можно описать так: если на верхушке стека находится цепочка символов у» то ее можно заменить на нетер- минальный символ А, если в грамматике языка существует правило вида А-»у, не сдвигая при этом считывающую головку автомата (этот шаг работы называет- ся «свертка»); с другой стороны, если считывающая головка автомата обозревает некоторый символ входной цепочки а, то его можно поместить в стек, сдвинув при этом головку на одну позицию вправо (этот шаг работы называется «сдвиг» или «перенос»). Сам алгоритм, моделирующий работу такого расширенного ав- томата, называется алгоритмом «сдвиг-свертка» или «перенос-свертка» (по на- званиям основных действий алгоритма).
450 Глава 11. Контекстно-свободные языки Данный расширенный МП-автомат строит правосторонние выводы для грам- матики G(VT,VN,P,S). Для моделирования такого автомата необходимо, чтобы грамматика G(VT,VN,P,S) не содержала Х-правил и цепных правил (в против- ном случае, очевидно, автомат может войти в бесконечный цикл из сверток). По- скольку, как было доказано выше, произвольную КС-грамматику всегда можно преобразовать к виду без Х-правил и цепных правил, то этот алгоритм применим для любой КС-грамматики, следовательно, им можно распознавать цепочки любого КС-языка. Этот расширенный МП-автомат строит правосторонние выводы и читает цепоч- ку входных символов слева направо. Поэтому для него естественным является построение дерева вывода снизу вверх. Такой распознаватель называется восхо- дящим. Данный расширенный МП-автомат потенциально имеет больше неоднозначно- стей, чем рассмотренный ваше МП-автомат, основанный на алгоритме подбора альтернатив. На каждом шаге работы автомата надо решать следующие вопросы: □ что необходимо выполнять: сдвиг или свертку; □ если выполнять свертку, то какую цепочку у выбрать для поиска правил (це- почка у должна встречаться в правой части правил грамматики); □ какое правило выбрать для свертки, если окажется, что существует несколько правил вида А->у (несколько правил с одинаковой правой частью). Чтобы промоделировать работу этого расширенного МП-автомата, надо на каж- дом шаге запоминать все предпринятые действия, чтобы иметь возможность вер- нуться к уже сделанному шагу и выполнить эти же действия по-другому. Этот процесс должен повторяться до тех пор, пока не будут перебраны все возможные варианты. Реализация распознавателя с возвратами на основе алгоритма «сдвиг-свертка» Существует несколько реализаций для алгоритма моделирования работы такого расширенного МП-автомата [6, т. 1, 40]. Один из вариантов рассмотрен ниже. Для работы алгоритма всем правилам грамматики G(VT,VN,P,S ), на основе ко- торой построен автомат, необходимо дать порядковые номера. Будем нумеровать правила грамматики в направлении слева направо и сверху вниз в порядке их за- писи в форме Бэкуса—Наура. Входная цепочка символов имеет вид а = а1а2...ап, |а| = п. Алгоритм моделирования расширенного МП-автомата, аналогично алгоритму нисходящего распознавателя, использует дополнительное состояние b и допол- нительный стек возвратов L2. В стек помещаются номера правил грамматики, использованных для свертки, если на очередном шаге алгоритма была выполне- на свертка, или 0, если на очередном шаге алгоритма был выполнен сдвиг. В итоге алгоритм работает с двумя стеками: Li — стек МП-автомата и L2 — стек возвратов. Первый представлен в виде цепочки символов, второй — цепочки це- лых чисел от 0 до ш, где ш — количество правил грамматики G. Символы в це-
Распознаватели КС-языков с возвратом 451 почку стека помещаются справа, числа в стек L2 — слева. В целом состояние алгоритма на каждом шаге определяется четырьмя параметрами: (Q, i, Lt, L2), где Q — текущее состояние автомата (q или b); i — положение считывающей го- ловки во входной цепочке символов а (1 < i < п+1); — содержимое стека МП- автомата; L2 — содержимое дополнительного стека возвратов. Начальным состоянием алгоритма является состояние (q, 1, X, X). Алгоритм начинает свою работу с начального состояния и циклически выполняет пять шагов до тех пор, пока не перейдет в конечное состояние или не обнаружит ошибку. Алгоритм предусматривает циклическое выполнение следующих шагов. Шаг 1 (Попытка свертки), (q, i, а₽, у) -> (q, i, а A, jy), если А-»р — это первое из всех возможных правил из множества правил Р с номером j для подцепочки р, причем оно есть первое подходящее правило для цепочки ар, для которой пра- вило вида А—>р существует. Если удалось выполнить свертку — возвращаемся к шагу 1, иначе — переходим к шагу 2. Шаг 2 (Перенос — сдвиг). Если i<n+l, то (q, i, а, у) -> (q, i+1, ааь Оу), gVT. Если i = п+1, то перейти к шагу 3, иначе перейти к шагу 1. ШагЗ (Завершение). Если состояние соответствует (q, n+1, S, у), то разбор завер- шен, алгоритм заканчивает работу, иначе перейти к шагу 4. Шаг 4 (Переход к возврату), (q, п+1, а, у) -> (Ь, п+1, а, у). Шаг 5 (Возврат). Если исходное состояние (b, i, а A, jy), то: О перейти в состояние (q, i, а'В, ky), если j > 0, и А->Р — это правило с номе- ром j и существует правило В—>Р' с номером к, к > j, такое, что ар = а'Р’; после чего надо вернуться к шагу 1; О перейти в состояние (Ь, п+1, ар, у), если i==n+l,j>0, А-+0 — это правило с номером j и не существует других правил из множества Р с номером k > j, таких, что их правая часть является правой подцепочкой из цепочки аР; после этого вернуться к шагу 5; О перейти в состояние (q, i+1, apab Оу), g VT, если i n+1, j > О, А->0 — это правило с номером j и не существует других правил из множества Р с но- мером k > j, таких, что их правая часть является правой подцепочкой из цепочки аР; после этого перейти к шагу 1; О иначе сигнализировать об ошибке и прекратить выполнение алгоритма. Если исходное состояние (b, i, аа, Оу), a gVT, то если i > 1, тогда перейти в сле- дующее состояние (b, i-1, а, у) и вернуться к шагу 5; иначе сигнализировать об ошибке и прекратить выполнение алгоритма. В случае успешного завершения алгоритма цепочку вывода можно построить на основе содержимого стека Ь2, полученного в результате выполнения алгоритма. Для этого достаточно удалить из стека L2 все цифры 0 — и получим последова- тельность номеров правил. Этот алгоритм может быть напрямую использован для построения распознава- телей. Следует помнить, что для применения этого алгоритма исходная грамма-
452 Глава 11. Контекстно-свободные языки тика не должна допускать циклов и не должна содержать Х-правил. Если это ус- ловие не удовлетворяется, то грамматику надо предварительно преобразовать к приведенной форме. Возьмем в качестве примера грамматику G({+,-,/,*,a,b}, {S,T,E}, Р, S): Р: S -» S+T | S-Т | Т*Е | Т/Е | (S) | а | b Т -> Т*Е | Т/Е | (S) | а | b Е -> (S) | а | b Это грамматика для арифметических выражений, в которой устранены цепные правила (ее уже рассматривали в разделе «Преобразование КС-грамматик. При- веденные грамматики»). Следовательно, в ней не может быть циклов1. Кроме того, видно, что в ней нет Х-правил. Таким образом, цепочки языка, заданного этой грамматикой, можно распознавать с помощью алгоритма восходящего распозна- вателя с возвратами. Проследим разбор цепочки а+(а*Ь) из языка этой грамматики. Работу алгоритма будем представлять в виде последовательности его состояний, взятых в скобки {} (фигурные скобки используются, чтобы не путать их с круглыми скобками, пре- дусмотренными в правилах грамматики). Правила будем нумеровать слева на- право и сверху вниз (всего в грамматике получается 15 правил). Для пояснения каждый шаг работы сопровождается номером шага алгоритма, который был при- менен для перехода в очередное состояние (записывается перед состоянием че- рез символ : — двоеточие). Алгоритм работы восходящего распознавателя с возвратами при разборе цепоч- ки а+(а*Ь) будет выполнять следующие шаги: 1. 0: {q, 1, X, X} 2. 2: {q, 2, а, [0]} 3. 1: {q, 2, S, [6,0]} 4. 2: {q, 3, S+, [0,6,0]} 5. 2: {q, 4, S+(, [0,0,6,0]} 6. 2: {q, 5, S+(a, [0,0,0,6,0]} 7. 1: {q, 5, S+(S, [6,0,0,0,6,0]} 8. 2: {q, 6, S+(S*, [0,6,0,0,0,6,0]} 9. 2: {q, 7, S+(S*b, [0,0,6,0,0,0,6,0]} 10. 1: {q, 7, S+(S*S, [7,0,0,6,0,0,0,6,0]} 1 На самом деле исходная грамматика для арифметических выражений, которая была рас- смотрена в разделе «Проблемы однозначности и эквивалентности грамматик», тоже не содержит циклов (это легко заметить из вида ее правил), однако для чистоты утвержде- ния цепные правила были исключены. Кроме того, следует отметить, что рассмотренные грамматики эквивалентны (задают один и тот же язык) — это можно утверждать, по- скольку обе они были получены из одной и той же грамматики путем строгих преобразо- ваний, не меняющих заданный грамматикой язык.
Распознаватели КС-языков с возвратом 453 И. 2: {q, 8, S+(S*S), [0,7,0,0,6,0,0,0,6,01} 12. 4: {b, 8, S+(S*S), [0,7,0,0,6,0,0,0,6,0]} 13. 5: {b, 7, S+(S*S, [7,0,0,6,0,0,0,6,0]} 14. 5: {q, 7, S+(S*T, [12,0,0,6,0,0,0,6,0]} 15. 2: {q, 8, S+(S*T), [0,12,0,0,6,0,0,0,6,0]} 16. 4: {b, 8, S+(S*T), [0,12,0,0,6,0,0,0,6,0]} 17. 5: {b, 7, S+(S*T, [12,0,0,6,0,0,0,6,0]} 18. 5: {q, 7, S+(S*E, [15,0,0,6,0,0,0,6,0]} 19. 2: {q, 8, S+(S*E), [0,15,0,0,6,0,0,0,6,0]} 20. 4: {b, 8, S+(S*E), [0,15,0,0,6,0,0,0,6,0]} 21. 5: {b, 7, S+(S*E, [15,0,0,6,0,0,0,6,0]} 22. 5: {q, 8, S+(S*a), [0,0,0,6,0,0,0,6,0]} 23. 4: {b, 8, S+(S*a), [0,0,0,6,0,0,0,6,0]} 24. 5: {b, 7, S+(S*a, [0,0,6,0,0,0,6,0]} 25. 5: {b, 6, S+(S*, [0,6,0,0,0,6,0]} 26. 5: {b, 5, S+(S, [6,0,0,0,6,0]} 27. 5: {q, 5, S+(T, [11,0,0,0,6,0]} 28. 2: {q, 6, S+(T*, [0,11,0,0,0,6,0]} 29. 2: {q, 7, S+(T*b, [0,0,11,0,0,0,6,0]} 30. 1: {q, 7, S+(T*S, [7,0,0,11,0,0,0,6,0]} 31. 2: {q, 8, S+(T*S), [0,7,0,0,11,0,0,0,6,0]} 32. 4: {b, 8, S+(T*S), [0,7,0,0,11,0,0,0,6,0]} 33. 5: {b, 7, S+(T*S, [7,0,0,11,0,0,0,6,0]} 34. 5: {q, 7, S+(T*T, [12,0,0,11,0,0,0,6,0]} 35. 2: {q, 8, S+(T*T), [0,12,0,0,11,0,0,0,6,0]} 36. 4: {b, 8, S+(T*T), [0,12,0,0,11,0,0,0,6,0]} 37. 5: {b, 7, S+(T*T, [12,0,0,11,0,0,0,6,0]} 38. 5: {q, 7, S+(T*E, [15,0,0,11,0,0,0,6,0]} 39. 1: {q, 7, S+(S, [3,15,0,0,11,0,0,0,6,0]} 40. 2: {q, 8, S+(S), [0,3,15,0,0,11,0,0,0,6,0]} 41. 1: {q, 8, S+S, [5,0,3,15,0,0,11,0,0,0,6,0]} 42. 4: {b, 8, S+S, [5,0,3,15,0,0,11,0,0,0,6,0]} 43. 5: {q, 8, S+T, [10,0,3,15,0,0,11,0,0,0,6,0]} 44. 1: {q, 8, S, [1,10,0,3,15,0,0,11,0,0,0,6,0]} 45. 3: Разбор закончен, алгоритм завершен.
454 Глава 11. Контекстно-свободные языки На основании полученной цепочки номеров правил: 1, 10, 3, 15, И, 6 получаем правосторонний вывод: S S+T S+(S) => S+(T*E) => S+(T*b) => S+(a*b) a+(a*b). Соответствующее ему дерево вывода приведено на рис. 11.3. Рис. 11.3. Дерево вывода для грамматики без цепных правил В приведенном примере очевиден тот же недостаток алгоритма восходящего разбора с возвратами, что и у алгоритма нисходящего разбора с возвратами — значительная временная емкость: для разбора достаточно короткой входной це- почки (всего 7 символов) потребовалось 45 шагов работы алгоритма. Такого результата и следовало ожидать, исходя из экспоненциальной зависимости необ- ходимых для работы алгоритма вычислительных ресурсов от длины входной це- почки. Это существенный недостаток данного алгоритма. Преимущество у данного алгоритма то же, что и у алгоритма нисходящего раз- бора с возвратами — простота реализации. Поэтому и использовать его можно практически в тех же случаях — когда известно, что длина исходной цепочки символов заведомо не будет большой (не больше нескольких десятков симво- лов). Этот алгоритм также универсален. На его основе можно распознавать входные цепочки языка, заданного любой КС-грамматикой, достаточно лишь преобразо- вать ее к приведенному виду (а это можно сделать с любой грамматикой, см. раз- дел «Преобразование КС-грамматик. Приведенные грамматики»), чтобы она не содержала цепных правил и Х-правил. Сам по себе алгоритм «сдвиг-свертка» с возвратами не находит применения в ре- альных компиляторах. Однако его базовые принципы лежат в основе многих восходящих распознавателей, строящих правосторонние выводы и работающих без использования возвратов. Методы, позволяющие строить такие распознава- тели для некоторых классов КС-языков, рассмотрены далее. Эти распознаватели будут более эффективны в смысле потребных вычислительных ресурсов, но ал- горитмы их работы уже сложнее, кроме того, они не являются универсальными. В тех случаях, когда удается дать однозначные ответы на поставленные выше три вопроса о выполнении сдвига (переноса) или свертки при моделировании данного алгоритма, он оказывается очень удобным и полезным.
Табличные распознаватели для КС-языков 455 В принципе два рассмотренных алгоритма — нисходящего и восходящего разбо- ра с возвратами — имеют схожие характеристики по потребным вычислитель- ным ресурсам и одинаково просты в реализации. То, какой из них лучше взять для реализации простейшего распознавателя в том или ином случае, зависит прежде всего от грамматики языка. В рассмотренном примере восходящий алго- ритм смог построить вывод за меньшее число шагов, чем нисходящий — но это еще не значит, что он во всех случаях будет эффективнее для рассмотренного языка арифметических выражений. Вопрос о выборе типа распознавателя — нис- ходящий либо восходящий — достаточно сложен. В компиляторах на него кроме структуры правил грамматики языка влияют и другие факторы, например, необ- ходимость локализации ошибок в программе, а также то, что предложения всех языков программирования строятся в нотации «слева направо». Этот вопрос будет затронут далее, при рассмотрении других вариантов распознавателей для КС-языков; пока же эти два типа распознавателей можно считать сопоставимы- ми по эффективности (отметим — низкой эффективности) своей работы и про- стоте реализации. Табличные распознаватели для КС-языков Общие принципы работы табличных распознавателей Табличные распознаватели используют для построения цепочки вывода КС- грамматики другие принципы, нежели МП-автоматы. Как и МП-автоматы, они получают на вход цепочку входных символов а я ata2...an, аеVT*, |а| - n, а по- строение вывода основывают на правилах заданной КС-грамматики G(VT,VN,P,S). Принцип их работы заключается в том, что искомая цепочка вывода строится не сразу — сначала на основе входной цепочки порождается некоторое промежу- точное хранилище информации объема п*п (промежуточная таблица)1, а потом уже на его основе строится вывод. Табличные алгоритмы обладают полиномиальными характеристиками требуе- мых вычислительных ресурсов в зависимости от длины входной цепочки. Для произвольной КС-грамматики G(VT,VN,P,S) время выполнения алгоритма Тэ имеет кубическую зависимость от длины входной цепочки, а необходимый объ- ем памяти Мэ — квадратичную зависимость от длины входной цепочки: a, aeVT*, п я |а|: Тэ я О(п3) и Мэ я О(п2). Квадратичная зависимость объема необходимой памяти от длины входной цепочки напрямую связана с использованием проме- жуточного хранилища данных. 1 В алгоритме Кока—Янгера—Касами промежуточная таблица используется в явном виде, а в алгоритме Эрли она завуалирована под хранилище, именуемое «список ситуаций», которое организовано несколько сложнее, чем простая таблица.
456 Глава 11. Контекстно-свободные языки Табличные распознаватели универсальны — они могут быть использованы для распознавания цепочек, порожденных с помощью произвольной КС-грамматики (возможно, саму грамматику первоначально потребуется привести к заданному виду, но это не ограничивает универсальности алгоритмов). Кроме того, таблич- ные распознаватели — это самые эффективные с точки зрения требуемых вычис- лительных ресурсов универсальные алгоритмы для распознавания цепочек КС- языков1. Алгоритм Кока—Янгера—Касами Алгоритм Кока—Янгера—Касами для заданной грамматики G(VT,VN,P,S) и цепочки входных символов а = aeVT*, |а| = п строит таблицу Тп*п, та- кую, что VAgVN: AeT[i,j], тогда и только тогда, если Аз^+а^.а^.р Таким обра- зом, элементами таблицы Тп*п являются множества нетерминальных символов из алфавита VN. Тогда существованию вывода S=>*a соответствует условие SeT[l,n]. При условии существования вывода по таблице Тп*п можно найти всю полную цепочку вывода S=>*a. Для построения вывода по алгоритму Кока—Янгера—Касами грамматика G(VT, VN,P,S) должна быть в нормальной форме Хомского. Поскольку, как было пока- зано выше, любую произвольную КС-грамматику можно преобразовать в нор- мальную форму Хомского, это не накладывает дополнительных ограничений на применимость данного алгоритма. Алгоритм Кока—Янгера—Касами фактически состоит из трех вложенных цик- лов. Поэтому ясно, что время выполнения алгоритма имеет кубическую зависи- мость от длины входной цепочки символов. Таблица Тп*п, используемая для хра- нения промежуточных данных в процессе работы алгоритма, является таблицей множеств. Очевидно, что требуемый для ее хранения объем памяти имеет квад- ратичную зависимость от длины входной цепочки символов. Сам алгоритм Кока—Янгера—Касами можно описать следующим образом: Шаг 1, Цикл для j от 1 до п T[l.j] := {А | 3 A->3j е Р} - в T[l.j] включаются все нетерминальные символы. для которых в грамматике G существует правило A->3j. Конец цикла для J. 1 Хотя табличные распознаватели и являются самыми эффективными среди универсаль- ных распознавателей, тем не менее они не находят широкого применения. Дело в том, что полиномиальная зависимость требуемых вычислительных ресурсов от длины входной цепочки символов является неудовлетворительной для компиляторов (длины входных цепочек — тысячи и десятки тысяч символов). Поэтому практически всегда используют- ся не универсальные, а более узкие, специализированные алгоритмы распознавателей, которые имеют обычно линейную зависимость требуемых вычислительных ресурсов от длины входной цепочки символов. К универсальным алгоритмам прибегают только то- гда, когда специализированный распознаватель построить не удается.
Табличные распознаватели для КС-языков 457 Шаг 2. Цикл для 1 от 2 до п Цикл для j от 1 до n-1+l Т[1.j] := 0: Цикл для к от 1 до 1-1 Т[1,j] := T[1.j] kj {А | 3 А->ВС g Р. ВеТ[к,j], CgT[1 -к,j+k]} Конец цикла для к. Конец цикла для j. Конец цикла для 1. Результатом работы алгоритма будет искомая таблица Тп.п. Для проверки суще- ствования вывода исходной цепочки в заданной грамматике остается только про- верить условие SeT[l,n]. Если вывод существует, то необходимо получить цепочку вывода. Для этого существует специальная рекурсивная процедура R. Она выдает последователь- ность номеров правил, которые нужно применить, чтобы получить цепочку вы- вода. Ее можно описать следующим образом: R(i,j,A), где AgVN. 1. Если j = 1 и существует правило А->аь то выдать номер этого правила. 2. Иначе (если j > 1) возьмем к как наименьшее из чисел, для которых 3 А—> ->ВХ g Р, BeT[k,j], CGT[i-k,j+k] (таких правил может быть несколько, для определенности берем наименьшее к). Пусть правило А~»ВС имеет номер ш. Тогда нужно выдать этот номер ш, потом вызвать сначала R(i,k,B), а затем — R(i-k,j+k,C). Для получения всей последовательности номеров правил нужно вызвать R(l,n,S). Рекурсивная процедура R не требует дополнительной памяти для своего выпол- нения, кроме стека, необходимого для организации рекурсии. Время ее выполне- ния имеет квадратичную зависимость от длины входной цепочки. На основании последовательности номеров правил, полученной с помощью алго- ритма Кока—Янгера—Касами и рекурсивной процедуры R, можно построить лево- сторонний вывод для заданной грамматики G(VT,VN,P,S) и цепочки входных сим- волов а. Таким образом, с помощью данного алгоритма решается задача разбора. Алгоритм Эрли (основные принципы) Алгоритм Эрли основан на том, что для заданной КС-грамматики G(VT,VN,P,S) и входной цепочки со = aia2...an, cogVT*, |<х>| - п строится последовательность спис- ков ситуаций Io, 1ь ..., 1п. Каждая ситуация, входящая в список Ij для входной це- почки со, представляет собой структуру вида [A-->X1X2...Xk«Xk+1...Xm,i], Vk: XkG(VNuVT), причем правило A-^Xj...Xm принадлежит множеству правил Р грамматики G, и 0<i<n, 0<k^m. Символ • («точка») — это метасимвол особого вида, который не входит ни во множество терминальных (VN), ни во множест- во нетерминальных (VT) символов грамматики. В ситуации этот символ может стоять в любой позиции, в том числе в начале (•Х1...Хт) или в конце (Хр^Хщ*) всей цепочки символов правила А-»Хр..Хт. Если цепочка символов правила пустая (А-»Х), то ситуация будет выглядеть так: [A->«,i].
458 Глава 11. Контекстно-свободные языки Список ситуаций строится таким образом, что Vj, 0<j<n: [A—>a*p,i]Glj тогда и только тогда, если 3 S=>*yA8, у^ар..^ и a=>*ai+1...aj. Иначе говоря, между вто- рым компонентом ситуации и номером списка, в котором он появляется, заклю- чена часть входной цепочки, выводимая из А. Условия ситуации Ij гарантируют возможность применения правила А->аР в выводе некоторой входной цепочки, совпадающей с заданной цепочкой со до позиции]. Условием существования вывода заданной входной цепочки со в грамматике G(VN,VT,P,S) после завершения алгоритма Эрли служит [5->со*,0]е1п. На осно- вании полученного в результате выполнения алгоритма списка ситуаций можно затем с помощью специальной процедуры построить всю цепочку вывода и по- лучить номера применяемых правил. Причем проще построить правосторонний вывод. Алгоритм Эрли подробно здесь не рассматривается. Его описание можно найти, например, в книге [6, т. 1]. Как и все табличные алгоритмы, алгоритм Эрли обладает полиномиальными характеристиками в зависимости от длины входной цепочки. Доказано, что для произвольной КС-грамматики G(VN,VT,P,S) время выполнения данного алго- ритма Тэ будет иметь кубическую зависимость от длины входной цепочки, а не- обходимый объем памяти Мэ — квадратичную зависимость от длины входной цепочки: а, ае VT*, п - |а|: Тэ s О(п3) и Мэ = О(п2). Но для однозначных КС- грамматик алгоритм Эрли имеет лучшие характеристики — его время выполне- ния в этом случае квадратично зависит от длины входной цепочки: Тэ - О(п2). Кроме того, для некоторых классов КС-грамматик время выполнения этого ал- горитма линейно зависит от длины входной цепочки (правда, для этих классов, как правило, существуют более простые алгоритмы распознавания). В целом алгоритм Эрли имеет лучшие характеристики среди всех универсаль- ных алгоритмов распознавания входных цепочек для произвольных КС-грамма- тик. Он превосходит алгоритм Кока—Янгера—Касами для однозначных грамма- тик (которые представляют интерес в первую очередь), хотя и является более сложным в реализации. Принципы построения распознавателей КС-языков без возвратов Выше были рассмотрены различные универсальные распознаватели для КС- языков — то есть распознаватели, позволяющие выполнить разбор цепочек для любого КС-языка (заданного произвольной КС-грамматикой). Они универсаль- ны, но имеют неудовлетворительные характеристики. Распознаватели с возвра- тами имеют экспоненциальную зависимость требуемых для выполнения алго- ритма разбора вычислительных ресурсов от длины входной цепочки символов, а табличные распознаватели — полиномиальную. Для практического примене- ния в реальных компиляторах такие характеристики являются неудовлетвори- тельными.
Принципы построения распознавателей КС-языков без возвратов 459 К сожалению, универсальных распознавателей с лучшими характеристиками для КС-языков построить не удается. Среди универсальных распознавателей лучши- ми по эффективности являются табличные. С другой стороны, универсальные распознаватели для КС-языков на практике и не требуются. В каждом конкретном случае компилятор имеет дело с синтак- сическими структурами, заданными вполне определенной грамматикой. Чаще всего эта грамматика является не просто КС-грамматикой, а еще и относится к какому-нибудь из известных классов КС-грамматик (нередко сразу к несколь- ким классам). Как минимум грамматика синтаксических конструкций языка про- граммирования должна быть однозначной, а это уже значит, что она относится к классу детерминированных КС-языков. Для многих классов КС-грамматик (и соответствующих им классов КС-языков) можно построить распознаватели, имеющие лучшие характеристики, чем рассмот- ренные выше распознаватели с возвратами и табличные. Эти распознаватели уже не будут универсальными — они будут применимы только к заданному классу КС-языков с соответствующими ограничениями, зато они будут иметь лучшие характеристики. Далее будут рассмотрены некоторые из таких распознавателей. Все они имеют линейные характеристики — линейную зависимость необходимых для выпол- нения алгоритма разбора вычислительных ресурсов от длины входной цепочки. Для каждого распознавателя рассматривается класс КС-грамматик, с которым он связан. Это значит, что он может принимать только входные цепочки из КС- языков, заданных такими грамматиками. Всегда описываются ограничения, на- лагаемые на правила грамматики, или дается алгоритм проверки принадлежно- сти произвольной КС-грамматики к заданному классу. Однако следует всегда помнить, что проблема преобразования КС-грамматик алго- ритмически неразрешима. Если какая-то грамматика не принадлежит к требуе- мому классу КС-грамматик, это еще не значит, что заданный ею язык не может быть описан грамматикой такого класса. Иногда удается выполнить преобразо- вания и привести исходную грамматику к требуемому виду. Но, к сожалению, этот процесс не формализован, не поддается алгоритмизации и требует участия человека. Чаще всего такую работу вынужден выполнять разработчик компиля- тора (правда, выполняется она только один раз для синтаксических конструкций каждого языка программирования). Существуют два принципиально разных класса распознавателей. Первый — нис- ходящие распознаватели, которые порождают цепочки левостороннего вывода и строят дерево вывода сверху вниз. Второй — восходящие распознаватели, кото- рые порождают цепочки правостороннего вывода и строят дерево вывода снизу вверх. Названия «нисходящие» и «восходящие» связаны с порядком построения дерева вывода. Как правило, все распознаватели читают входную цепочку симво- лов слева направо, поскольку предполагается именно такая нотация в написании исходного текста программ. Нисходящие распознаватели используют модификации алгоритма с подбором альтернатив. При их создании применяются методы, которые позволяют одно- значно выбрать одну и только одну альтернативу на каждом шаге работы МП-
460 Глава 11. Контекстно-свободные языки автомата (шаг «выброс» в этом автомате всегда выполняется однозначно). Алго- ритм подбора альтернатив без модификаций был рассмотрен выше. Восходящие распознаватели используют модификации алгоритма «сдвиг-сверт- ка» (или «перенос-свертка», что то же самое). При их создании применяются методы, которые позволяют однозначно выбрать между выполнением «сдвига» («переноса») или «свертки» на каждом шаге работы расширенного МП-автома- та, а при выполнении свертки однозначно выбрать правило, по которому будет производиться свертка. Алгоритм «сдвиг-свертка» без модификаций был рассмот- рен выше. Далеко не все известные распознаватели с линейными характеристиками рассмат- риваются в данном пособии. Более полный набор распознавателей, а также опи- сание связанных с ними классов КС-грамматик и КС-языков вы можете найти в [5, 6, 23, 42, 65]. Далее будут рассмотрены только самые часто встречающиеся и употребительные классы. Контрольные вопросы и задачи Вопросы 1. Какие из следующих утверждений справедливы: О если язык задан КС-грамматикой, то он может быть задан с помощью МП- автомата; О если язык задан КС-грамматикой, то он может быть задан с помощью ДМП-автомата; О если язык задан ДМП-автоматом, то он может быть задан КС-граматикой; О если язык задан однозначной КС-грамматикой, то он может быть задан с помощью ДМП-автомата; О если язык задан расширенным МП-автоматом, то он может быть задан КС-грамматикой. 2. Алфавит магазинных символов МП-автомата включает в себя алфавит вход- ных символов автомата. Почему? 3. Может ли согласно определению МП-автомат продолжить работу, если его стек пуст? Может ли продолжить работу в той же ситуации расширенный МП-автомат? 4. Какие из приведенных ниже МП-автоматов являются детерминированными: О {a.b}, {a.b.A}, Sp qb A, {q2}), 8^,а,А) = {(q^AA)}, 81(q2,a,A) » {(q2A)}, S^q^b.A) « {(q2,A)}, 81(q2,b,A) = {(q2,Aab)}, SjCqjA.a) = {(q^X)}, б^А.Ь) = {(qtA)}; О R2({q}. {a.b}, {a.b.A}, S2, q, A, {q}), 82(q,a,A) = {(q,AA)}, 82(q,b,A) = {(q,A)}, 82(qA,a) « {(q,X)}, 82(qA,b) = {(q A)}, 82(qA,A) = {(q,A)}; О R3<{q}. {a.b}, {a.b.A}, 83, q, A, {q}), 83(q,a,A) = {(q,AA), (q,a), (qA)}, 83(q,b,A) = {(q,A)}, 83(qA,a) = {(q.A)};
Контрольные вопросы и задачи 461 О R4({qJ. {a,b}, {a.b.A}, 84, q, A, {q}), 64(q,a,A) » {(q.AAb)}, 84(q,b,A) = {(q,a)}, 84(qA,a) = {(qA)}, 84(qA,b) = {(q.l)}? 5. Почему возможность построить ДМП-автомат, который учитывает входную цепочку до конца, значительно упрощает задачу моделирования работы ДМП-автомата? 6. Почему синтаксические конструкции языков программирования могут быть распознаны с помощью ДМП-автоматов? Можно ли полностью проверить структуру входной программы с помощью ДМП-автоматов для большинства языков программирования? Если нет, то можно ли решить ту же задачу, ис- пользуя МП-автоматы или расширенные МП-автоматы? 7. Если проблема однозначности неразрешима для произвольной КС-грамма- тики, то почему можно утверждать, что некоторые КС-грамматики являются однозначными? Всегда ли должны быть однозначными грамматики синтак- сических конструкций языков программирования? 8. Какой цели служит преобразование правил КС-грамматик? Всегда ли это преобразование ведет к упрощению правил? 9. Почему при преобразовании КС-грамматики к приведенному виду сначала необходимо удалить бесплодные символы, а потом — недостижимые символы? 10. Почему для языка, заданного грамматикой, содержащей Х-правила и циклы, существуют трудности с моделированием работы расширенного МП-автома- та, выполняющего восходящий разбор с правосторонним выводом? Что мож- но сказать об обычном МП-автомате, выполняющем нисходящий разбор для цепочек, построенных с помощью таких грамматик? И. Почему необходимо устранить именно левую рекурсию из правил граммати- ки? Для моделирования работы какого (восходящего или нисходящего) рас- познавателя представляет сложности левая рекурсия? Можно ли полностью устранить рекурсию из правил грамматики, записанных в форме Бэкуса—Наура? 12. Какие преимущества дает запись правил грамматики в нормальной форме Хомского? Ведет ли она к упрощению правил грамматики? Где используются правила грамматики в нормальной форме Хомского? 13. Сравните восходящий и нисходящий распознаватели. Какими преимущества- ми и недостатками обладает каждый из этих методов? 14. Табличные распознаватели являются самыми лучшими универсальными рас- познавателями для КС-языков. Почему они не получили широкого практиче- ского применения? 15. В чем главное преимущество линейных распознавателей для КС-языков пе- ред всеми другими видами распознавателей? Какие существуют сложности в построении линейных распознавателей для КС-языков? Задачи 1. Задана грамматика G({".0,1},{<число>,<часть>,<цифра>,<осн.>},Р,<число>) Р: <ЧИСЛО> -> +<ОСН.> I -<осн.> | <осн.>
462 Глава 11. Контекстно-свободные языки <осн.> -> <часть>.<часть> | <часть>. | <часть> <часть> -> <цифра> | <часть><цифра> <цифра> -> 0 | 1 постройте для нее обычный и расширенный МП-автоматы. Являются ли построенные автоматы детерминированными? 2. Используя лемму о разрастании для КС-языков, докажите, что язык Ц - e {anbn | п > 2} является контекстно-свободным, а язык L2 * {anbncn | n > 2} — не является. Постройте КС-грамматику для языка Ц. 3. Дана грамматика: G({a,b,с}, {A,B,C,D,E,F,G,S}, Р, S) Р: S аАЬВ | Е А —> ВСа | а | X В -> АСЬ | b | X С -> А | В | ЬА | аВ | сС | аЕ | ЬЕ Е -> Еа | ЕЬ | Ес | ED | FG | DG D -> с | Fb | Fa | X F -> ВС | АС | DC | ЕС G -> Ga | Gb | Gc | GD преобразуйте ее к приведенному виду. 4. Дана грамматика G({" о,г,a,n,d,t,b}, {S.T.E.F}, Р, S) Р: S -> S or Т I Т Т -> Т and Е I Е Е -> not Е | F F —> (S) | b О преобразуйте ее к виду без цепных правил; О исключите левую рекурсию в правилах грамматики. 5. Преобразуйте грамматику, заданную в задаче № 3, в нормальную форму Хом- ского. 6. Постройте нисходящий распознаватель с возвратом для грамматики, задан- ной в задаче № 4. Выполните разбор цепочки символов «В ог В and not В and (В or not not В)». 7. Постройте восходящий распознаватель с возвратом для грамматики, задан- ной в задаче № 4. Выполните разбор цепочки символов «В ог В and not В and (В or not not В)». 8. Для грамматики, построенной в задаче № 5, выполните разбор цепочки сим- волов «abccabb» с помощью алгоритма Кока—Янгера—Касами.
ГЛАВА 12 Классы КС-языков и грамматик Нисходящие распознаватели КС-языков без возвратов Левосторонний разбор по методу рекурсивного спуска Стремление улучшить алгоритм нисходящего разбора заключается в первую оче- редь в определении метода, по которому на каждом шаге алгоритма можно было бы однозначно выбрать одну из всего множества возможных альтернатив. В та- ком случае алгоритм моделирования работы МП-автомата не требовал бы возвра- та на предыдущие шаги и за счет этого обладал бы линейными характеристика- ми от длины входной цепочки. В случае неуспеха выполнения такого алгоритма входная цепочка однозначно не принимается, повторные итерации разбора не выполняются. Наиболее очевидным методом выбора одной из множества альтернатив является выбор ее на основании символа ае VT, обозреваемого считывающей головкой ав- томата на каждом шаге его работы (находящегося справа от положения текущей головки во входной цепочке символов). Поскольку в процессе нисходящего раз- бора именно этот символ должен появиться на верхушке магазина для продви- жения считывающей головки автомата на один шаг (условие 8(q,a,a) - {(qA)}, VaeVT в функции переходов МП-автомата), то разумно искать альтернативу, где он присутствует в начале цепочки, стоящей в правой части правила грамма- тики. По такому принципу действует алгоритм разбора по методу рекурсивного спуска. Алгоритм разбора по методу рекурсивного спуска В реализации этого алгоритма для каждого нетерминального символа AeVN грамматики G(VN,VT,P,S) строится процедура разбора, которая получает на вход цепочку символов а и положение считывающей головки в цепочке i. Если
464 Глава 12. Классы КС-языков и грамматик для символа А в грамматике G определено более одного правила, то процедура разбора ищет среди них правило вида А—>ау, aeVT, yg(VNuVT)*, первый сим- вол правой части которого совпадал бы с текущим символом входной цепочки a s aj. Если такого правила не найдено, то цепочка не принимается. Иначе (если найдено правило А—>ау или для символа А в грамматике G существует только одно правило А->у), то запоминается номер правила, и когда а - ab то считываю- щая головка передвигается (увеличивается i), а для каждого нетерминального символа в цепочке у рекурсивно вызывается процедура разбора этого символа. Название метода происходит из реализации алгоритма, которая заключается в последовательности рекурсивных вызовов процедур разбора. Для начала разбо- ра входной цепочки нужно вызвать процедуру для символа S с параметром i - 1. Условия применимости метода можно получить из описания самого алгорит- ма — в грамматике G(VN,VT,P,S) VAeVN возможны только два варианта пра- вил: А->у, ye(VNuVT)* и это единственное правило для А; А->а1р1|а2р2|...|апрп, Vi: a^eVT, Pje(VNuVT)* и если i*j, то а^а,. Этим условиям удовлетворяет незначительное количество реальных грамматик. Это достаточные, но не необходимые условия. Если грамматика не удовлетворя- ет этим условиям, еще не значит, что заданный ею язык не может распознаваться с помощью метода рекурсивного спуска. Возможно, над грамматикой просто не- обходимо выполнить ряд дополнительных преобразований. К сожалению, не существует алгоритма, который бы позволил преобразовать произвольную КС-грамматику к указанному выше виду, равно как не существу- ет и алгоритма, который бы позволял проверить, возможны ли такого рода пре- образования. То есть для произвольной КС-грамматики нельзя сказать, анализи- руема ли она методом рекурсивного спуска или нет. Можно рекомендовать ряд преобразований, которые способствуют приведению грамматики к требуемому виду, но не гарантируют его достижения. 1. Исключение Х-правил. 2. Исключение левой рекурсии. 3. Добавление новых нетерминальных символов. Например: если правило: A->aa1|aa2|...|aan|b1p1|b2P2|...|bmpnv то заменяем его на два: А->аА'| b1p1|b2p2|...|bmpm и A'->a1|a2|...|an. 4. Замена нетерминальных символов в правилах на цепочки их выводов. Например: если имеются правила: A->B1|B2|...|Bn|b1p1|b2p2|...|bmpm, Bi~»a11|a12|...|a1k, Bn^anl|an2|...|anp.
Нисходящие распознаватели КС-языков без возвратов 465 заменяем первое правило на A->a11|a12|...|alk |...|anl|an2|...|anp|b1₽1|b2p2|...|bmpm. В целом алгоритм рекурсивного спуска эффективен и прост в реализации, но имеет очень ограниченную применимость. Пример реализации метода рекурсивного спуска Дана грамматика G({a,b,c},{A,B,C,S},P,S): Р: S -> аА | ЬВ А -> а | ЬА | сС В -> b | аВ | сС С -> АаВЬ Необходимо построить распознаватель, работающий по методу рекурсивного спуска. Видно, что грамматика удовлетворяет условиям, необходимым для построения такого распознавателя. Напишем процедуры на языке программирования С, которые будут обеспечи- вать разбор входных цепочек языка, заданного данной грамматикой. Согласно алгоритму, необходимо построить процедуру разбора для каждого нетерминаль- ного символа грамматики, поэтому дадим процедурам соответствующие наиме- нования. Входные данные для процедур разбора будут следующие: □ цепочка входных символов; □ положение указателя (считывающей головки МП-автомата) во входной це- почке; □ массив для записи номеров примененных правил; □ порядковый номер очередного правила в массиве. Результатом работы каждой процедуры может быть число, отличное от нуля («истина»), или 0 («ложь»). В первом случае входная цепочка символов прини- мается распознавателем, во втором случае — не принимается. Для удобства реа- лизации в том случае, если цепочка принимается распознавателем, будем воз- вращать текущее положение указателя в цепочке. Кроме того, потребуется еще одна дополнительная процедура для ведения записей в массиве последователь- ности правил (назовем ее WriteRules). void WriteRules(int* piRul. int* iP. int iRule) { piRul[*iP] = iRule: *iP - *iP + 1: } int proc_S (char* szS, int iN. int* piRul, int* iP) { switch (szSLiNJ)
466 Глава 12. Классы КС-языков и грамматик { case 'а' WriteRulesCpiRul,1Р,1), return ргос_А(szS.1N+l.piRul, i P). case 'b' WriteRulesCpiRul,1P,2), return proc_B(szS.iN+l,piRul ,1P), } return 0, } int proc_A (char* szS. int iN. int* piRul, int* iP) { switch (szS[iNJ) { case ’a' WriteRulesCpiRul .1P.3), return iN+1, case 'b' WriteRulesCpiRul,1P,4), return proc_A(szS,iN+l.piRul.iP). case ’c' WriteRulesCpiRul,1P,5), return proc_C(szS.1N+l.piRul. 1P). } return 0. } int proc_B (char* szS. int 1N, int* piRul. int* iP) ( switch (szS[iNJ) { case ’b’ WriteRulesCpiRul ,1P,6). return iN+1, case 'a' WriteRulesCpiRul ,1P,7). return proc_B(szS,iN+1 piRul,iP). case ’c' WriteRulesCpiRul,1P,8). return proc_B(szS,iN+l.piRul ,1P), } return 0. } int proc_C (char* szS. int 1N. int* piRul. int* iP) { int i.
Нисходящие распознаватели КС-языков без возвратов 467 WriteRulesCpiRul,iP,9): 1 - proc_A(szS,1N,p1Rul ,1Р): If (1 — 0) return 0; if (szS[i] !“ ’a') return 0: i++: i - proc_B(szS,i.piRul ,iP): 1f (i -- 0) return 0; if (szS[i] !“ 'b') return 0; return i+1: } Теперь для распознавания входной цепочки необходимо иметь целочисленный массив Rules достаточного объема для хранения номеров правил. Тогда работа распознавателя заключается в вызове процедуры proc_S(Str,0,Rules,&N), где Str — это входная цепочка символов, N — переменная для запоминания количества при- мененных правил (первоначально N - 0). Затем требуется обработка полученно- го результата: если результат на 1 превышает длину цепочки — цепочка принята, иначе — цепочка не принята. В первом случае в массиве Rules будем иметь по- следовательность номеров правил грамматики, необходимых для вывода цепоч- ки, а в переменной N — количество этих правил. На основе этой цепочки можно легко построить дерево вывода. Объем массива Rules заранее не известен, так как заранее не известно количество шагов вывода. Чтобы избежать проблем с недостаточным объемом статического массива, приведенные выше процедуры распознавателя можно модифицировать так, чтобы они работали с динамическим распределением памяти (изменив про- цедуру WriteRules и тип параметра piRul в вызовах остальных процедур). На ло- гику работы распознавателя это никак не повлияет. Следует помнить также, что метод рекурсивного спуска основан на рекурсивном вызове множества проце- дур, что при значительной длине входной цепочки символов может потребовать соответствующего объема стека вызовов для хранения адресов процедур, их па- раметров и локальных переменных (более подробно об этом можно посмотреть в данном пособии в разделе «Семантический анализ и подготовка к генерации кода», глава 14). Из приведенного примера видно, что алгоритм рекурсивного спуска удобен и прост в реализации. Главным препятствием в его применении является то, что класс грамматик, допускающих разбор на основе этого алгоритма, сильно ограничен. Расширенное применение распознавателей на основе метода рекурсивного спуска Метод рекурсивного спуска позволяет выбрать альтернативу, ориентируясь на текущий символ входной цепочки, обозреваемый считывающей головкой МП- автомата. Если имеется возможность просматривать не один, а несколько симво- лов вперед от текущего положения считывающей головки, то можно расширить область применимости метода рекурсивного спуска. В этом случае уже можно искать правила на основе некоторого терминального символа, входящего в пра- вую часть правила. Естественно, и в таком варианте выбор должен быть однознач-
468 Глава 12. Классы КС-языков и грамматик ным — для каждого нетерминального символа в левой части правила необходи- мо, чтобы в правой части правила не встречалось двух одинаковых терминальных символов. Этот метод требует также анализа типа присутствующей в правилах рекурсии, поскольку один и тот же терминальный символ может встречаться во входной строке несколько раз, и в зависимости от типа рекурсии следует искать его край- нее левое или крайнее правое вхождение в строке. Рассмотрим грамматику арифметических выражений без скобок для символов а и b G({+,a,b}, {S,T,E}, Р, S): Р: s _> S+Т | S-Т I т Т -> Т*Е I Т/Е I Е Е —> (S) | а | b Это грамматика для арифметических выражений, которая уже была рассмотрена в разделе «Проблемы однозначности и эквивалентности грамматик», глава 9 и служила основой для построения распознавателей в разделе «Распознаватели КС-языков с возвратом», глава И. Запишем правила этой грамматики в форме с применением метасимволов. По- лучим: Р: S -> Т{(+Т.-Т)} Т -> Е{(*Е,/Е)} Е -> (S) | а | b При такой форме записи процедура разбора для каждого нетерминального сим- вола становится тривиальной. Для символа S распознаваемая строка должна всегда начинаться со строки, до- пустимой для символа Т, за которой может следовать любое количество симво- лов + или -, и если они найдены, то за ними опять должна быть строка, допусти- мая для символа Т. Аналогично, для символа Т распознаваемая строка должна всегда начинаться со строки, допустимой для символа Е, за которой может сле- довать любое количество символов * или /, и если они найдены, то за ними опять должна быть строка, допустимая для символа Е. С другой стороны, для симво- ла Е строка должна начинаться строго с символов (, а или Ь, причем в первом случае за ( должна следовать строка, допустимая для символа S, а за ней — обя- зательно символ ). Исходя из этого, построены процедуры разбора входной строки на языке Pascal (используется Borland Pascal или Borland Delphi, которые допускают тип string — строка). Входными данными для них являются: □ исходная строка символов; □ текущее положение указателя в исходной строке; □ длина исходной строки (в принципе, этот параметр можно опустить, но он введен для удобства); □ результирующая строка правил.
Нисходящие распознаватели КС-языков без возвратов 469 Процедуры построены так, что в результирующую строку правил помещаются номера примененных правил в строковом формате, перечисленные через запя- тую (,). Правила номеруются в грамматике, записанной в форме Бэкуса—Наура, в порядке слева направо и сверху вниз (всего в исходной грамматике 9 правил). Распознаватель строит левосторонний вывод, поэтому на основе строки номеров правил всегда можно получить цепочку вывода или дерево вывода. Для начала разбора нужно вызвать процедуру proc_S(S,l,N,Pr), где S — входная строка символов; N — длина входной строки (в языке Borland Pascal вместо N можно взять Length(S)); Рг — строка, куда будет помещена последовательность примененных правил. Результатом proc_S(S,l,N,Pr) выполнения будет N+1, если строка S принимается, и некоторое число, меньшее N+1, если строка не принимается. Если строка S при- нимается, то строка Рг будет содержать последовательность номеров правил, ко- торые необходимо применить для того, чтобы вывести S1. procedure proc_S (S: string; i.n: Integer: var pr: string): integer; var si : string; begin i proc_T(S,i.n.sl); if i > 0 then begin pr := '3,’ + si; while (i <* n) and (i <> 0) do case S[i] of begin if i - n then i 0 else begin i proc_T(S.i+l,n,sl); pr '1,' + pr si; end; end; : begin if i - n then i 0 else begin i := proc_T(S,i+l,n.sl); pr '2.' + pr si; 1 Использование языка программирования Borland Pascal накладывает определенные тех- нические ограничения на данный распознаватель — длина строки в этом языке не может превышать 255 символов. Однако данные ограничения можно снять, если реализовать свой тип данных «строка», или используя тот же алгоритм в другом языке программиро- вания. При использовании Borland Delphi эти ограничения отпадают. Конечно, такого рода ограничения не имеют принципиального значения при теоретическом исследова- нии работы распознавателя, тем не менее автор считает необходимым упомянуть о них.
470 Глава 12. Классы КС-языков и грамматик end end. else break end.{case} end.{if} proc_S = i. end. procedure proc_S (S string, i.n integer, var pr string) integer var si string begin i « proc_E(S,i,n,sl) if i > 0 then begin pr - '6.' + si. while (i <« n) and (i <> 0) do case S[i] of begin if i = n then i « 0 else begin i = proc_E(S.i+l,n.sl). pr - '4.' + pr +'.'+ si. end. end. '/' begin if i = n then i = 0 else begin i s proc_E(S.i+l.n.sl), pr - '5.' + pr + '.'+ si. end. end else break, end.{case} end.{if} proc_S = i. end. procedure proc_E (S string, i.n integer, var pr string) integer var si string. begin case S[i] of 'a' begin pr - '8'. proc_E * i+l. end.
Нисходящие распознаватели КС-языков без возвратов 471 'b': begin рг :« '9'; ргос_Е 1+1; end; '(’: begin proc_E :» 0; if i < n then begin i proc_S(S,1+1.n.sl); if (i > 0) and (i < n) then begin pr ;- *7,’ + si; if S[i] « ')' then proc_E i+1; end; end; end; else proc__E := 0: end;{case} end; Конечно, и в данном случае алгоритм рекурсивного спуска позволил построить достаточно простой распознаватель, однако, прежде чем удалось его применить, потребовался неформальный анализ правил грамматики. Далеко не всегда тако- го рода неформальный анализ является возможным, особенно если грамматика содержит десятки и даже сотни различных правил — человек не всегда в состоя- нии уловить их смысл и взаимосвязь. Поэтому расширения алгоритма рекурсив- ного спуска, хотя просты и удобны, но не всегда применимы. Даже понять сам факт того, можно или нет в заданной грамматике построить такого рода распо- знаватель, бывает очень непросто [15, 26, 74]. Далее будут рассмотрены распознаватели и алгоритмы, которые основаны на строго формальном подходе. Они предваряют построение распознавателя рядом обоснованных действий и преобразований, с помощью которых подготавлива- ются необходимые исходные данные. Все такого рода действия построены на ос- нове операций над множествами и символами, а исходными данными для них служат множества символов и правил исходной грамматики. В этом случае под- готовку всех исходных данных для распознавателя можно формализовать и ав- томатизировать вне зависимости от объема грамматики (количества правил и символов в ней). Эти предварительные действия можно выполнить на компью- тере, в то время как расширенная трактовка рекурсивного спуска предполагает неформальный анализ грамматики человеком, и в этом серьезный недостаток метода. Определение 1.1.(к)-грамматики Логическим продолжением идеи, положенной в основу метода рекурсивного спус- ка, является предложение использовать для выбора одной из множества альтер- натив не один, а несколько символов входной цепочки. Однако напрямую пере-
472 Глава 12. Классы КС-языков и грамматик дожить алгоритм выбора альтернативы для одного символа на такой же алгоритм для цепочки символов не удастся — два соседних символа в цепочке на самом деле могут быть выведены с использованием различных правил грамматики, по- этому неверным будет напрямую искать их в одном правиле. Тем не менее суще- ствует класс грамматик, основанный именно на этом принципе — выборе одной альтернативы из множества возможных на основе нескольких очередных симво- лов в цепочке. Это так называемые LL(k)-грамматики. Правда, алгоритм работы распознавателя для них не так очевидно прост, как рассмотренный выше алго- ритм рекурсивного спуска. Грамматика обладает свойством LL(k), к > 0, если на каждом шаге вывода для однозначного выбора очередной альтернативы МП-автомату достаточно знать символ на верхушке стека и рассмотреть первые к символов от текущего поло- жения считывающей головки во входной цепочке символов. Грамматика называется ЬЬ(к)-грамматикой, если она обладает свойством LL(k) для некоторого к > О1. Название «LL(k)» несет определенный смысл. Первая литера «L» происходит от слова «left» и означает, что входная цепочка символов читается в направлении слева направо. Вторая литера «L» также происходит от слова «left» и означает, что при работе распознавателя используется левосторонний вывод. Вместо «к» в названии класса грамматики стоит некоторое число, которое показывает, сколь- ко символов надо рассмотреть, чтобы однозначно выбрать одну из множества аль- тернатив. Так, существуют ЬЬ(1)-грамматики, ЬЬ(2)-грамматики и другие классы. В совокупности все LL(k)-грамматики для всех к>0 образуют класс LL-грам- матик. На рис. 12.1 схематично показано частичное дерево вывода для некоторой LL(k)- грамматики. В нем со обозначает уже разобранную часть входной цепочки а, ко- торая построена на основе левой части дерева у. Правая часть дерева х — это еще не разобранная часть, а А — текущий нетерминальный символ на верхушке стека МП-автомата. Цепочка х представляет собой незавершенную часть цепочки вы- вода, содержащую как терминальные, так и нетерминальные символы. После за- вершения вывода символ А раскрывается в часть входной цепочки и, а правая часть дерева х преобразуется в часть входной цепочки т. Свойство LL(k) предпо- лагает, что однозначный выбор альтернативы для символа А может быть сделан на основе к первых символов цепочки от, являющейся частью входной цепоч- ки а. Алгоритм разбора входных цепочек для ЬЬ(к)-грамматики носит название «к- предсказывающего алгоритма». Принципы его выполнения во многом соответст- вуют функционированию МП-автомата с той разницей, что на каждом шаге ра- 1 Требование к > 0, безусловно, является разумным — для принятия решения о выборе той или иной альтернативы МП-автомату надо рассмотреть хотя бы один символ входной цепочки. Если представить себе LL-грамматику с k в 0, то в такой грамматике вывод со- всем не будет зависеть от входной цепочки. В принципе такая грамматика возможна, но в ней будет всего одна-единственная цепочка вывода. Поэтому практическое применение языка, заданного такого рода грамматикой, представляется весьма сомнительным.
Нисходящие распознаватели КС-языков без возвратов 473 боты этот алгоритм может просматривать к символов вперед от текущего поло- жения считывающей головки автомата. Рис. 12.1. Схема построения дерева вывода для Щк)-грамматики Для ЬЬ(к)-грамматик известны следующие полезные свойства: □ всякая ЬЬ(к)-грамматика для любого к>0 является однозначной; □ существует алгоритм, позволяющий проверить, является ли заданная грамма- тика LL(k)-грамматикой для строго определенного числа к. Кроме того, известно, что все грамматики, допускающие разбор по методу рекур- сивного спуска, являются подклассом ЬЦ1)-грамматик. То есть любая грамма- тика, допускающая разбор по методу рекурсивного спуска, является LL(1 ^-грам- матикой (но не наоборот!). Есть, однако, неразрешимые проблемы для произвольных КС-грамматик: □ не существует алгоритма, который бы мог проверить, является ли заданная КС-грамматика ЕЦк)-грамматикой для некоторого произвольного числа к; □ не существует алгоритма, который бы мог преобразовать произвольную КС- грамматику к виду ЬЬ(к)-грамматики для некоторого к (или доказать, что преобразование невозможно). Это несколько ограничивает применимость LL(k)-грамматик, поскольку не все- гда для произвольной КС-грамматики можно очевидно найти число к, для кото- рого она является ЬЬ(к)-грамматикой, или узнать, существует ли вообще для нее такое число к. Для ЬЬ(к)-грамматики при к>1 совсем не обязательно, чтобы все правые части правил грамматики для каждого нетерминального символа начинались с к различ- ных терминальных символов. Принципы распознавания предложений входного языка такой грамматики накладывают менее жесткие ограничения на правила грамматики, поскольку к соседних символов, по которым однозначно выбирает- ся очередная альтернатива, могут встречаться и в нескольких правилах грамма- тики (эти условия рассмотрены ниже). Грамматики, у которых все правые части правил для всех нетерминальных символов начинаются с к различных терми- нальных символов, носят название «сильно ЬЬ(к)-грамматик». Метод построе- ния распознавателей для них достаточно прост, алгоритм разбора очевиден, но, к сожалению, такие грамматики встречаются крайне редко.
474 Глава 12. Классы КС-языков и грамматик Для ЬЬ(1)-грамматики, очевидно, для каждого нетерминального символа не мо- жет быть двух правил, начинающихся с одного и того же терминального симво- ла. Однако это менее жесткое условие, чем то, которое накладывает распознава- тель по методу рекурсивного спуска, поскольку в принципе ЬЬ(1)-грамматика допускает в правой части правил цепочки, начинающиеся с нетерминальных символов, а также Х-правила. LL(1)-грамматики позволяют построить достаточ- но простой и эффективный распознаватель, поэтому они рассматриваются далее отдельно в соответствующем разделе. Поскольку все ЬЬ(к)-грамматики используют левосторонний нисходящий рас- познаватель, основанный на алгоритме с подбором альтернатив, очевидно, что они не могут допускать левую рекурсию. Поэтому никакая леворекурсивная грам- матика не может быть LL-грамматикой. Следовательно, первым делом при по- пытке преобразовать грамматику к виду LL-грамматики необходимо устранить в ней левую рекурсию (соответствующий алгоритм был рассмотрен выше). Класс LL-грамматик широк, но все же он недостаточен для того, чтобы покрыть все возможные синтаксические конструкции в языках программирования (к ним относим все детерминированные КС-языки). Известно, что существуют детер- минированные КС-языки, которые не могут быть заданы ЬЬ(к)-грамматикой ни для каких к. Однако LL-грамматики удобны для использования, поскольку по- зволяют построить распознаватели с линейными характеристиками (линейной зависимостью требуемых для работы алгоритма распознавания вычислительных ресурсов от длины входной цепочки символов). Принципы построения распознавателей для Ьк(к)-грамматик Для построения распознавателей LL(k)-rpaMMarnK используются два важных мно- жества, определяемые следующим образом: □ FIRST(k,a) — множество терминальных цепочек, выводимых из ae(VTuVN)*, укороченных до к символов; □ FOLLOW(k,A) — множество укороченных до к символов терминальных це- почек, которые могут следовать непосредственно за Ае VN в цепочках вывода. Формально эти два множества могут быть определены следующим образом: FIRST(k,a) я {cogVT* | либо |со|<к и а=>*со, либо |со|>к и а=>*сох, xg(VTuVN)*}, ae(VTUVN)*, к>0. FOLLOW(k,A) я {coeVT* | S=>*aAy и coeFIRST(y,k), aeVT*}, AeVN, k>0. Очевидно, что если имеется цепочка терминальных символов aeVT, то FIRST(k,a) — это первые к символов цепочки а. Доказано, что грамматика G(VT,VN,P,S) является LL(k )-грамматикой тогда и только тогда, когда выполняется следующее условие: V А->р g Р и V А—> —>g е Р (Р^у): FIRST(k,pco) n FIRST(k,yco) = 0 для всех цепочек со таких, что S =>* аАсо.
Нисходящие распознаватели КС-языков без возвратов 475 Иначе говоря, если существуют две цепочки вывода: S =>* аАу => azy =>* асе S =>* аАу => aty =>* аи то из условия FIRST(k,co) = FIRST(k,u) следует, что z ж t. На основе этих двух множеств строится алгоритм работы распознавателя для ЬЦк)-грамматик, который представляет собой k-предсказывающий алгоритм для МП-автомата, заданного так: R({q},VT,Z,8,q,S,{q}), где Z « VTVVN, aS — целевой символ грамматики G. Функция переходов автомата строится на основе управ- ляющей таблицы М, которая отображает множество (Zu{X}) х VT*k на множест- во, состоящее из следующих элементов: □ пар вида (p,i), где р — цепочка символов, помещаемая автоматом на верхушку стека, a i — номер правила вида А->0, AgVN, peZ*; □ «выброс»; □ «допуск»; □ «ошибка». Конфигурацию распознавателя можно отобразить в виде конфигурации МП- автомата с дополнением цепочки л, в которую помещаются номера применен- ных правил. Поскольку автомат имеет только одно состояние, его в конфигура- ции можно не указывать. Если считать, что X — это символ на верхушке стека автомата, a — непрочитанная автоматом часть входной цепочки символов, а u = FIRST(k,a), то работу алгоритма распознавателя можно представить сле- дующим образом: □ (а, Ху, л) 4- (а, Ру, Tii), yeZ*, если М(Х,и) s (P,i); □ (а, Ху, 7i) 4- (а’, у, тс), если X s ае VT и а * аа’, М(а,и) в «выброс»; □ (X, X, 7i) — завершение работы, при этом М(Х,Х) s «допуск»; □ иначе — «ошибка». Цепочка и = FIRST(k,a) носит в работе автомата название «аванцепочка». Таким образом, для создания алгоритма распознавателя языка, заданного произ- вольной ЬЬ(к)-грамматикой, достаточно уметь построить управляющую табли- цу М. Управляющую таблицу М, а также множества FIRST и FOLLOW можно получить на основе правил исходной грамматики. Методы построения этой таб- лицы для к>1 в данном пособии не рассматриваются, с ними можно ознакомить- ся в работах [5, 6, т. 1, 65]. При k= 1 все существенно проще. Методы построения для ЕЕ(1)-грамматик, а также проверки принадлежности грамматики к классу ЕЕ(1)-грамматик рас- смотрены ниже. Алгоритм разбора для LL(1)-грамматик Для ЕЕ(1)-грамматик алгоритм работы распознавателя предельно прост. Он за- ключается всего в двух условиях, проверяемых на шаге выбора альтернати- вы. Исходными данными для этих условий являются символ aeVT, обозревав-
476 Глава 12. Классы КС-языков и грамматик мый считывающей головкой МП-автомата (текущий символ входной цепочки), и символ AgVN, находящийся на верхушке стека автомата1. Эти условия можно сформулировать так: □ необходимо выбрать в качестве альтернативы правило А->х, если aeFIRST(l,x); □ необходимо выбрать в качестве альтернативы правило А—>Х, если aeFOLLOW(l,A). Если ни одно из этих условий не выполняется (нет соответствующих правил), то цепочка не принадлежит заданному языку и МП-автомат не принимает ее (алго- ритм должен сигнализировать об ошибке). Работа автомата на шаге «выброса» остается без изменений. Кроме того, чтобы убедиться, является ли заданная грамматика G(VT,VN,P,S) ЬЬ(1)-грамматикой, необходимо и достаточно проверить следующее условие: для каждого символа AgVN, для которого в грамматике существует более одного правила вида А -» ajaj...|an, должно выполняться требование FIRST(l,a1FOLLOW(l,A)) n FIRST(l,ajFOLLOW(l,A)) = 0 Vi^j, n>i>0, n>j >0. Очевидно, что если для символа AgVN отсутствует правило вида А-»Х, то соглас- но этому требованию все множества FIRST(l,at), FIRST(l,a2), FIRST(l,an) должны попарно не пересекаться, если же присутствует правило А~»Х, то они не должны также пересекаться со множеством FOLLOW(1,A). Отсюда видно, что ЬЬ(1)-грамматика не может содержать для одного и того же нетерминального символа AgVN двух правил, начинающихся с одного и того же терминального символа. Условие, накладываемое на правила LL(1/грамматики, является довольно жест- кимгОчень немногие реальные грамматики могут быть отнесены к классу LL(1)- грамматик. Например, даже довольно простая грамматика G({a},{S}, {S—>a|aS}, S) не удовлетворяет этому условию (хотя она является ЬЬ(2)-грамматикой и даже регулярной праволинейной грамматикой). Иногда удается преобразовать правила грамматики так, чтобы они удовлетворя- ли требованию ЬЬ(1)-грамматик. Например, приведенная выше грамматика мо- 1 Может показаться, что класс LL(1)-грамматик соответствует классу грамматик, анализи- руемых методом рекурсивного спуска, где выбор альтернативы также основан на теку- щем символе входной цепочки. На самом деле это не так — класс LL(1)-грамматик является более широким, чем класс грамматик, анализируемым методом рекурсивно- го спуска. Любая грамматика, анализируемая методом рекурсивного спуска, является ЬЬ(1)-грамматикой, но не наоборот: существуют LL(1/грамматики, которые напрямую методом рекурсивного спуска не анализируются. Дело в том, что распознаватель на базе LL(1)-грамматики (который основан на множествах FIRST и FOLLOW) при выборе альтернативы фактически анализирует не одно, а сразу несколько правил, связанных с текущим символом на верхушке стека, в то время как рекурсивный спуск основан на ана- лизе только одного правила, непосредственно относящегося к этому символу.
Нисходящие распознаватели КС-языков без возвратов 477 жет быть преобразована к виду G'({a},{S,A}, {S-»aA, A~»X|S}, S)* 1. В такой форме она уже является LL(1/грамматикой (это можно проверить). Но формального метода преобразовать произвольную КС-грамматику к виду LL(1)-грамматики или убедиться в том, что такое преобразование невозможно, не существует. Пер- вое преобразование правил грамматики, которое можно рекомендовать, — устра- нение левой рекурсии2. Второе преобразование носит название «левая фактори- зация», оно уже было упомянуто выше при знакомстве с методом рекурсивного спуска. Это преобразование заключается в следующем: если для символа AgVN существует ряд правил А~»ар1|ар2|...|арп|у1|у2|...|ут, Vi: р^(УТ^)’, Vj: y^VTuVN)*, aeVT и ни одна цепочка символов yj не начинается с символа а, тогда во множество не- терминальных символов грамматики VN добавляется новый символ А', а прави- ла для А и А' записываются следующим образом: А-»аА'|у1|у2|...|ут и А'->Р1|Р2|-..|РП- Левую факторизацию можно применять к правилам грамматики несколько раз с целью исключить для каждого нетерминального символа правила, начинающие- ся с одних и тех же терминальных символов. Однако применение этих двух пре- образований отнюдь не гарантирует, что произвольную КС-грамматику удастся привести к виду LL(1/грамматики. Для того чтобы запрограммировать работу МП-автомата, выполняющего разбор входных цепочек символов языка, заданного ЬЬ(1)-грамматикой, надо научить- ся строить множества символов FIRST(l,x) и FOLLOW(1,A). Для множества FIRST(l,x) все очевидно, если цепочка х начинается с терминального симво- ла, если же она начинается с нетерминального символа В (х = By, xg(VTuVN)+, yG(VTuVN)*), то FIRST(l,x) = FIRST(1,B). Следовательно, для LL(1/грамматик остается только найти алгоритм построения множеств FIRST(1,B) и FOLLOW(1,A) для всех нетерминальных символов A,BgVN. Исходными данными для этих алгоритмов служат правила грамматики. Алгоритм построения множества FIRST(1,A) Алгоритм строит множества FIRST(1,A) сразу для всех нетерминальных сим- волов грамматики G(VT,VN,P,S), AgVN. Для выполнения алгоритма надо пред- варительно преобразовать исходную грамматику G(VT,VN,P,S) в граммати- ку G’(VT,VN',P',S'/ не содержащую Х-правил (см. алгоритм преобразования в разделе «Преобразование КС-грамматик. Приведенные грамматики», глава И). На основании полученной грамматики G' и выполняется построение множеств FIRST(1,A) для всех AgVN (если AgVN, то согласно алгоритму преобразова- 1 Можно убедиться, что две приведенные грамматики задают один и тот же язык: L(G) = = L(G'). Это легко сделать, поскольку обе они являются не только КС-грамматиками, но и регулярными праволинейными грамматиками. Кроме того, формальное преобразова- ние G' в G существует — достаточно устранить в грамматике G' Х-правила и цепные пра- вила, и будет получена исходная грамматика G. А вот формального преобразования G в G' нет. В общем случае все может быть гораздо сложнее. 1 Устранение левой рекурсии — это, конечно, необходимое, но не достаточное условие для преобразования грамматики к виду LL-грамматики. Это будет видно далее из примера.
478 Глава 12. Классы КС-языков и грамматик ния также справедливо AgVN'). Множества строятся методом последовательно- го приближения. Если в результате преобразования грамматики G в граммати- ку G' множество VN’ содержит новый символ S', то при построении множества FIRST(1,A) он не учитывается. Алгоритм состоит из нескольких шагов. Шаг 1. Для всех AgVN: FIRSTo(1,A) = {X | А->Ха е Р, Xg(VTuVN), ae(VTbVN)*} (первоначально вносим во множество первых символов для каждого нетерми- нального символа А все символы, стоящие в начале правых частей правил для этого символа A); i:=0. Шаг 2. Для всех AgVN: FIRSTi+1(l,A) = FIRSTS,A) u FIRSTS,В), для всех не- терминальных символов Bg(FIRSTi(1,A) n VN). Шаг 3. Если 3 AgVN: FIRSTi+1(l,A) * FIRSTj(l,A), то i:=i+l и вернуться к шагу 2, иначе перейти к шагу 4. Шаг 4. Для всех AgVN: FIRST(1,A) = FIRSTj(l,A) \ VN (исключаем из постро- енных множеств все нетерминальные символы). Алгоритм построения множества FOLLOW(1,A) Алгоритм строит множества FOLLOW(1,A) сразу для всех нетерминальных сим- волов грамматики G(VT,VN,P,S), AgVN. Для выполнения алгоритма предвари- тельно надо построить все множества FIRST(1,A), V AgVN. Множества строят- ся методом последовательного приближения. Алгоритм состоит из нескольких шагов. Шаг 1. Для всех AgVN: FOLLOW0(1,A) = {X | 3 B->aAXp g Р, BgVN, Xg(VTuVN), cc,Pg(VTWN)*} (первоначально вносим во множество последующих символов для каждого нетерминального символа А все символы, которые в правых частях правил встречаются непосредственно за символом A); i:=0. Шаг 2. FOLLOW0(1,S) = FOLLOW0(1,S) u {X} (вносим пустую цепочку во мно- жество последующих символов для целевого символа S — это означает, что в конце разбора за целевым символом цепочка кончается, иногда для этой цели используется специальный символ конца цепочки: 1к). Шаг 3. Для всех AgVN: FOLLOW^ (1,А) - FOLLOW^,А) и FIRST(1,B), для всех нетерминальных символов В g (FOLLOW^ 1,A) n VN). Шаг 4. Для всех AgVN: FOLLOW", (1,А) = FOLLOW’^!,A) u FOLLOW’^!,В), для всех нетерминальных символов В g (FOLLOW’^ 1,A) n VN) и существует пра- вило В—>Х. Шаг 5. Для всех AgVN: FOLLOWi+1(l,A) = FOLLOW"i(l,A) и FOLLOW"^,В), для всех нетерминальных символов BgVN, если существует правило В-»аА, aG(VTuVN)*. Шаг 6. Если 3 AgVN: FOLLOWi+1(l,A) * FOLLOWj(l,A), то i:ei+l и вернуться к шагу 3, иначе перейти к шагу 7. Шаг 7. Для всех AgVN: FOLLOW(1,A) » FOLLOW^!,А) \ VN (исключаем из построенных множеств все нетерминальные символы).
Нисходящие распознаватели КС-языков без возвратов 479 Пример построения распознавателя для Щ1)-грамматики Рассмотрим в качестве примера грамматику G({+,-,/,*,a,b}, {S,R,T,F,E}, Р, S) с правилами: Р: S -> Т | TR R -> +Т | -Т | +TR | -TR Т -> Е | EF F -> *Е | /Е | *EF | /EF Е (S) | а | b Это нелеворекурсивная грамматика для арифметических выражений (ранее она была построена в разделе «Распознаватели КС-языков с возвратом», глава И). Эта грамматика не является ЬЬ(1)-грамматикой. Чтобы убедиться в этом, доста- точно обратить внимание на правила для символов R и F — для них имеется по два правила, начинающихся с одного и того же терминального символа. Преобразуем ее в другой вид, добавив Х-правила. В результате получим новую грамматику G'({+,-,/,*,a,b}, {S,R,T,F,E}, Р', S) с правилами: Р’: S -> TR R -> X | +TR | -TR Т -> EF F -> X | *EF | /EF Е (S) | а | b Построенная грамматика G' эквивалентна исходной грамматике G. В этом мож- но убедиться, если воспользоваться алгоритмом устранения Х-правил из разде- ла «Преобразование КС-грамматик. Приведенные грамматики», глава И. Приме- нив его к грамматике G', получим грамматику G, а по условиям данного алгорит- ма L(G') = L(G). Таким образом, мы получили эквивалентную грамматику, хотя она и построена неформальным методом (следует помнить, что не существует формального алгоритма, преобразующего произвольную КС-грамматику в LL(k)- грамматику для заданного к). Эта грамматика является ЬЬ(1)-грамматикой. Чтобы убедиться в этом, построим множества FIRST и FOLLOW для нетерминальных символов этой грамматики (поскольку речь заведомо идет об ЕЕ(1)-грамматике, цифру «1» в обозначении множеств опустим для сокращения записи). Для построения множества FIRST будем использовать исходную грамматику G, так как именно она получается из G' при устранении Х-правил. Построение множеств FIRST. Шаг 1. FIRSTO(S) = {Т}; FIRSTO(R) »{+,-}; FIRSTO(T) - {Е}; FIRSTO(F) - {*,/};
480 Глава 12. Классы КС-языков и грамматик FIRSTO(E) - {(,a,b}; i-0. Шаг 2. FIRSTl(S) = {Т,Е}; FIRSTl(R) = {+,-}; FIRSTl(T) = {E,(,a,b}; FIRSTl(F) - {*,/}; FIRSTl(E) - {(,a,b}. Шаг 3. Шаг 2. i - 1, возвращаемся к шагу 2. FIRST2(S) = {T,E,(,a,b}; FIRST2(R) = {+,-}; FIRST2(T) - {E,(,a,b}; FIRST2(F) - {*,/}; FIRST2(E) = {(,a,b}. ШагЗ. Шаг 2. i = 2, возвращаемся к шагу 2. FIRST3(S) = {T,E,(,a,b}; FIRST3(R) - {+,-}; FIRST3(T) = {E,(,a,b}; FIRST3(F) - {*,/}; FIRST3(E) - {(,a,b}. ШагЗ. Шаг 4. i = 2, переходим к шагу 4. FIRST(S)»{(,a,b}; FIRST(R) = {+,-}; FIRST(T) - {(,a,b}; FIRST(F) = {*,/}; FIRST(E) = {(,a,b}. Построение закончено. Построение множества FOLLOW. Шаг 1. FOLLOWO(S) = {)}; FOLLOWO(R) - 0; FOLLOWO(T) = {R}; FOLLOWO(F) = 0; FOLLOWO(E) = {F}; i = 0. Шаг 2. FOLLOWO(S) - {),!}; FOLLOWO(R) = 0; FOLLOWO(T) - {R}; FOLLOWO(F) - 0; FOLLOWO(E) - {F}.
Нисходящие распознаватели КС-языков без возвратов 481 ШагЗ. FOLLOW'O(S) = {)Л}; FOLLOW’O(R) = 0; FOLLOW’O(T) = {R,+Н; FOLLOW’O(F) - 0; FOLLOW’O(E) = {F,*,/}. Шаг 4. FOLLOWO(S) = {)Л}; FOLLOWO(R) - 0; FOLLOW"0(T) - {R,+ FOLLOW"0(F) = 0; FOLLOW"0(E) = {F,* ,/}. Шаг 5. FOLLOWl(S) - {)Л}; FOLLOWl(R) - {),!}; FOLLOWl(T) = {R,+,-}; FOLLOWl(F) = {R,+ FOLLOWl(E)-{F,*,/}. Шаг 6. Шаг 3. i-1, возвращаемся к шагу 3. FOLLOW'1(S) = {)A}; FOLLOW’ 1 (R) = {),!}; FOLLOW’1 (T) = {R,+,-}; FOLLOW’l(F) - {R,+,-}; FOLLOW’l(E) - {F,*,/}. Шаг 4. FOLLOW"1(S)-{)A}; FOLLOW" 1 (R) - {),X}; FOLLOW" 1(T) = {R,+,-,)A}; FOLLOW" 1(F) - {R,+,-,),X}; FOLLOW"1(E) = {F,R, Шаг 5. FOLLOW2(S) - {),!}; FOLLOW2(R) - {),!}; FOLLOW2(T) = {R, FOLLOW2(F) = {R,+, FOLLOW2(E) - {F,R, Шаг 6. Шаг 3. i - 2, возвращаемся к шагу 3. FOLLOW'2(S) - {)Л}; FOLLOWER) - {)Л}; FOLLOW’2(T) = {R,+ FOLLOW’2(F) = {R,+,-,),X}; FOLLOW’2(E) = {F,R,
482 Глава 12. Классы КС-языков и грамматик Шаг 4. FOLLOW"2(S) = {)Л}; FOLLOW”2(R) ~ {)Д}; FOLLOW”2(T) «{R,+ FOLLOW"2(F) = {R,+ FOLLOW"2(E) = {F,R, Шаг 5. FOLLOW3(S) = {)Д}; FOLLOW3(R) »{)Д}; FOLLOW3(T) = {R,+,-,)Л}; FOLLOW3(F) = {R,+,-,),X}; FOLLOW3(E) = {F,R,*,/,+,-,)Л}. Шаг 6. i = 2, переходим к шагу 7. Шаг 7. FOLLOW(S) = {)Л}; FOLLOW(R) = {)Л}; FOLLOW(T) = )Л}; FOLLOW(F) »)Л}; FQLLOW(E) «{*,/,+,-,)Л}. Построение закончено. В результате выполненных построений можно видеть, что необходимое и доста- точное условие принадлежности КС-грамматики к классу ЬЬ(1)-грамматик вы- полняется. Построенные множества FIRST и FOLLOW можно представить в виде таблицы. Результат выполненных построений отражает табл. 12.1. Таблица 12.1. Множества FIRST и FOLLOW для грамматики G' Символ AeVN FIRST(1,A) FOLLOW(1,A) S ( а b )Х R + - )Х Т ( а b + - ) А, F */ + - ) X Е ( а b */+-)* Рассмотрим работу распознавателя. Ход разбора будем отражать по шагам рабо- ты автомата в виде конфигурации МП-автомата, к которой добавлена цепочка, содержащая последовательность примененных правил грамматики. Состояние автомата q, указанное в его конфигурации, можно опустить, так как оно единст- венное: (a, Z, у), где а — непрочитанная часть входной цепочки символов; Z — содержимое стека (верхушка стека находится слева); у — последовательность но- меров примененных правил (последовательность дополняется слева, так как ав- томат порождает левосторонний вывод).
Нисходящие распознаватели КС-языков без возвратов 483 Примем, что правила в грамматике номеруются в порядке слева направо и сверху вниз. На основе номеров примененных правил при успешном завершении разбо- ра можно построить цепочку вывода и дерево вывода. В качестве примера возьмем две правильные цепочки символов а+а*Ь и (а+а)*Ь и две ошибочные цепочки символов а+а* и (+а)*Ь. Разбор цепочки а+а*Ь. 1. (a+a*b, S, X) 2. (a+a*b, TR, 1), так как aeFIRST(l.TR) 3. (a+a*b, EFR, 1,5), так как aeFIRST(l.EF) 4. (a+a*b, aFR, 1,5,10), так как aeFIRST(l,a) 5. (+a*b, FR, 1,5,10) 6. (+a*b, R, 1,5,10,6), так как +eFOLLOW(l,F) 7. (+a*b, +TR, 1,5,10,6,3), так как +eFIRST(l,+TR) 8. (a*b, TR, 1,5,10,6,3) 9. (a*b, EFR, 1,5,10,6,3,5), так как aeFIRST(l.EF) 10. (a*b, aFR, 1,5,10,6,3,5,10), так как aeFIRST(l,a) И. (*b, FR, 1,5,10,6,3,5,10) 12. (*b, *EFR, 1,5,10,6,3,5,10,7), так как *eFIRST(l,*EF) 13. (b, EFR, 1,5,10,6,3,5,10,7) 14. (b,bFR, 1,5,10,6,3,5,10,7,11), так как beFIRST(l,b) 15. (X, FR, 1,5,10,6,3,5,10,7,11) 16. (X, R, 1,5,10,6,3,5,10,7,11,6), так как XeFOLLOW(l,F) 17. (1, X, 1,5,10,6,3,5,10,7,11,6,2), так как XeFOLLOW(l,R), разбор закончен. Це- почка принимается. Получили цепочку вывода: S => TR => EFR => aFR => aFR =s- aR => a+TR => a+EFR =s- a+aFR => a+a*EFR => a+a*bFR => a+a*bR => a+a*b Соответствующее ей дерево вывода приведено на рис. 12.2. Разбор цепочки (а+а)*Ь. 1. ((a+a)*b, S, X) 2. ((a+a)*b, TR, 1), так как (eFIRST(l,TR) 3. ((a+a)*b, EFR, 1,5), так как (eFIRST(l.EF) 4. ((a+a)*b, (S)FR, 1,5,9), так как (eFIRST(l,(S)) 5. (a+a)*b, S)FR, 1,5,9) 6. (a+a)*b, TR)FR, 1,5,9,1), так как aeFIRST(l.TR) 7. (a+a)*b, EFR)FR, 1,5,9,1,5), так как aeFIRST(l,EF) 8. (a+a)*b, aFR)FR, 1,5,9,1,5,10), так как aeFIRST(l.a) 9. (+a)*b, FR)FR, 1,5,9,1,5,10)
484 Глава 12. Классы КС-языков и грамматик Рис. 12.2. Дерево вывода в Щ1)-грамматике для цепочки «а+а*Ь» 10. (+a)*b, R)FR, 1,5,9,1,5,10,6), так как +eFOLLOW(l,F) И. (+a)*b, +TR)FR, 1,5,9,1,5,10,6,3), так как +eFIRST(l,+TR) 12. (a)*b, TR)FR, 1,5,9,1,5,10,6,3) 13. (a)*b, EFR)FR, 1,5,9,1,5,10,6,3,5), так как aeFIRST(l,EF) 14. (a)*b, aFR)FR, 1,5,9,1,5,10,6,3,5,10), так как aeFIRST(l,a) 15. (q,)*b, FR)FR, 1,5,9,1,5,10,6,3,5,10) 16. ()*b, R)FR, 1,5,9,1,5,10,6,3,5,10,6), так как )eFOLLOW(l,F) 17. ()*b, )FR, 1,5,9,1,5,10,6,3,5,10,6,2), так как )eFOLLOW(l,R) 18. (*b, FR, 1,5,9,1,5,10,6,3,5,10,6,2) 19. (*b, *EFR, 1,5,9,1,5,10,6,3,5,10,6,2,7), так как *eFOLLOW(l,*EF) 20. (b, EFR, 1,5,9,1,5,10,6,3,5,10,6,2,7) 21. (b, bFR, 1,5,9,1,5,10,6,3,5,10,6,2,7,11), так как beFIRST(l.b) 22. (X, FR, 1,5,9,1,5,10,6,3,5,10,6,2,7,11) 23. (X, R, 1,5,9,1,5,10,6,3,5,10,6,2,7,11,6), так как Xe FOLLOW(1,F) 24. (X, X, 1,5,9,1,5,10,6,3,5,10,6,2,7,11,6,2), так как XeFOLLOW(l.R), разбор закон- чен. Цепочка принимается. Получили цепочку вывода: S => TR => EFR => (S)FR => (TR)FR => (EFR)FR => (aFR)FR => (aR)FR => (a+TR)FR => (a+EFR)FR => (a+aFR)FR => (a+aR)FR => (a+a)FR => (a+a)*EFR => (a+a)*bFR => (a+a)*bR => (a+a)*b Соответствующее ей дерево вывода приведено на рис. 12.3. Разбор цепочки а+а*. 1. (а+а*, S, X) 2. (а+а*, TR, 1), так как aeFIRST(l.TR) 3. (а+а*, EFR, 1,5), так как aeFIRST(l,EF) 4. (а+а*, aFR, 1,5,10), так как aeFIRST(l,a) 5. (+а*, FR, 1,5,10)
Нисходящие распознаватели КС-языков без возвратов 485 Рис. 12.3. Дерево вывода в Щ1)-грамматике для цепочки «(а+а)*Ь» 6. (+а*, R, 1,5,10,6), так как +eFOLLOW(l,F) 7. (+а*, +TR, 1,5,10,6,3), так как +eFIRST(l,+TR) 8. (a*, TR, 1,5,10,6,3) 9. (a*, EFR, 1,5,10,6,3,5), так как aeFIRST(l.EF) 10. (a*, aFR, 1,5,10,6,3,5,10), так как aeFIRST(l,a) И. (*, FR, 1,5,10,6,3,5,10) 12. (*, *EFR, 1,5,10,6,3,5,10,7), так как *eFIRST(l,*EF) 13. (X, EFR, 1,5,10,6,3,5,10,7) 14. Ошибка, так как XeFOLLOW(l,E), но нет правила вида Е->1. Цепочка не принимается. Разбор цепочки (+а)*Ь. 1. ((+a)*b, S, Л) 2. ((+a)*b, TR, 1), так как (eFIRST(l,TR) 3. ((+a)*b, EFR, 1,5), так как (eFIRST(l.EF) 4. ((+a)*b, (S)FR, 1,5,9), так как (eFIRST(l,(S)) 5. (+a)*b, S)FR, 1,5,9) 6. Ошибка, так как нет правил для S вида S->a таких, чтобы +eFIRST(l,a) и +«?FOLLOW(1,S). Цепочка не принимается. Из рассмотренных примеров видно, что алгоритму разбора цепочек, построен- ному на основе распознавателя для ЕЬ(1)-грамматик, требуется гораздо меньше шагов на принятие решения о принадлежности цепочке языку, чем рассмотрен- ному выше алгоритму разбора с возвратами. Надо отметить, что оба алгоритма распознают цепочки одного и того же языка. Данный алгоритм имеет большую эффективность, поскольку при росте длины цепочки количество шагов его рас-
486 Глава 12. Классы КС-языков и грамматик тет линейно, а не экспоненциально. Кроме того, ошибка обнаруживается этим алгоритмом сразу, в то время как разбор с возвратами будет просматривать для неверной входной цепочки возможные варианты до конца, пока не переберет их все. Очевидно, что этот алгоритм является более эффективным, но жесткие ограни- чения на правила для LL(1)-грамматик сужают возможности его применения. Восходящие распознаватели КС-языков без возвратов Определение ЬЯ(к)-грамматики Восходящие распознаватели выполняют построение дерева вывода снизу вверх. Результатом их работы является правосторонний вывод. Функционирование таких распознавателей основано на модификациях алгоритма «сдвиг-свертка» (или «перенос-свертка»), который был рассмотрен в разделе «Распознаватели КС-языков с возвратом», глава И. Идея состоит в том, чтобы модифицировать этот алгоритм таким образом, чтобы на каждом шаге его работы можно было однозначно дать ответ на следующие во- просы: □ что следует выполнять: сдвиг (перенос) или свертку; □ какую цепочку символов а выбрать из стека для выполнения свертки; □ какое правило выбрать для выполнения свертки (в том случае, если сущест- вует несколько правил вида At—>а, А2->а, ... Ап->а). Тогда восходящий алгоритм распознавания цепочек КС-языка не требовал бы выполнения возвратов, поскольку сам язык мог бы быть задан детерминирован- ным расширенным МП-автоматом. Конечно, как уже было сказано, это нельзя сделать в общем случае, для всех КС-языков (поскольку даже сам класс детер- минированных КС-языков более узкий, чем весь класс КС-языков). Но, вероят- но, среди всех КС-языков можно выделить такой класс (или классы), для кото- рых подобная реализация распознающего алгоритма станет возможной. В первую очередь можно использовать тот же самый подход, который был поло- жен в основу определения ЬЬ(к)-грамматик. Тогда мы получим другой класс КС-грамматик, который носит название LR(k)-грамматик. КС-грамматика обладает свойством LR(k), k>0, если на каждом шаге выво- да для однозначного решения вопроса о выполняемом действии в алгоритме «сдвиг-свертка» («перенос-свертка») расширенному МП-автомату достаточно знать содержимое верхней части стека и рассмотреть первые к символов от те- кущего положения считывающей головки автомата во входной цепочке сим- волов.
Восходящие распознаватели КС-языков без возвратов 487 Грамматика называется LR(k)-грамматикой, если она обладает свойством LR(k) для некоторого к>(Г. Название «LR(k)», как и рассмотренное выше «LL(k)», также несет определен- ный смысл. Первая литера «Ь» также обозначает порядок чтения входной цепоч- ки символов: слева— направо. Вторая литера «R» происходит от слова «right» и, по аналогии с LL(k), означает, что в результате работы распознавателя получа- ется правосторонний вывод. Вместо «к» в названии грамматики стоит число, ко- торое показывает, сколько символов входной цепочки надо рассмотреть, чтобы принять решение о действии на каждом шаге алгоритма «сдвиг-свертка». Так, существуют ЬП(О)-грамматики, LR(1)-грамматики и другие классы. В совокупности все ЬП(к)-грамматики для всех к>0 образуют класс LR-грамма- тик. На рис. 12.4 схематично показано частичное дерево вывода для некоторой LR(k)- грамматики. В нем со обозначает уже разобранную часть входной цепочки а, на основе которой построена левая часть дерева у. Правая часть дерева х — это еще не разобранная часть, а А — это нетерминальный символ, к которому на очеред- ном шаге будет свернута цепочка символов z, находящаяся на верхушке стека МП-автомата. В эту цепочку уже входит прочитанная, но еще не разобранная часть входной цепочки и. Правая часть дерева х будет построена на основе части входной цепочки т. Свойство LR(k) предполагает, что однозначный выбор дей- ствия, выполняемого на каждом шаге алгоритма «сдвиг-свертка», может быть сделан на основе цепочки и и к первых символов цепочки т, являющихся частью входной цепочки а. Этим очередным действием может быть свертка цепочки z к сим- волу А или перенос первого символа из цепочки т и добавление его к цепочке z. Рис. 12.4. Схема построения дерева вывода для 1_А(к)-грамматики Рассмотрев схему построения дерева вывода для ЬП(к)-грамматики на рис. 12.4 и сравнив ее с приведенной выше на рис. 12.1 схемой для ЬЦк)-грамматики, 1 Существование ЬК(О)-грамматик уже не является бессмыслицей в отличие от LL(0)- грамматик. В данном случае используется расширенный МП-автомат, который анализи- рует не один, а сразу несколько символов, находящихся на верхушке стека. Причем среди этих символов могут быть и терминальные символы из входной цепочки, попавшие в стек при выполнении сдвигов (переносов). Поэтому даже если автомат при к = 0 и не бу- дет смотреть на текущий символ входной цепочки, построенный им вывод все равно бу- дет зависеть от содержимого стека, а значит, и от содержимого входной цепочки.
488 Глава 12. Классы КС-языков и грамматик можно предположить, что класс LR-грамматик является более широким, чем класс LL-грамматик. Основанием для такого предположения служит тот факт, что на каждом шаге работы распознавателя LR(k)-грамматики обрабатывается больше информации, чем на шаге работы распознавателя ЬЬ(к)-грамматики. Действительно, для принятия решения на каждом шаге алгоритма распознава- ния LL(k)-грамматики используются первые к символов из цепочки от, а для принятия решения на шаге распознавания ЕК(к)-грамматики — вся цепочка и и еще первые к символов из цепочки т. Очевидно, что во втором случае можно проанализировать больший объем информации и, таким образом, построить вы- вод для более широкого класса КС-языков. Приведенное выше довольно нестрогое утверждение имеет строго обоснованное доказательство. Доказано, что класс LR-грамматик является более широким, чем класс LL-грамматик [6, т. 2]. То есть для каждого КС-языка, заданного LL-грам- матикой, может быть построена LR-грамматика, задающая тот же язык, но не на- оборот. Существуют также языки, заданные LR-грамматиками, для которых не- возможно построить LL-грамматику, задающую тот же язык. Иначе говоря, для всякой LL-грамматики существует эквивалентная ей LR-грамматика, но не для всякой LR-грамматики существует эквивалентная ей LL-грамматика1. Для LR(k)-rpaMMaTHK известны следующие полезные свойства: □ всякая LR(k)-rpaMMaraKa для любого к > О является однозначной; □ существует алгоритм, позволяющий проверить, является ли заданная грамма- тика LR(k)-rpaMMaTHKoft для строго определенного числа к. Есть, однако, неразрешимые проблемы для произвольных КС-грамматик (они аналогичны таким же проблемам для других классов КС-грамматик): □ не существует алгоритма, который бы мог проверить, является ли заданная КС-грамматика ЕК(к)-грамматикой для некоторого произвольного числа к; □ не существует алгоритма, который бы мог преобразовать (или доказать, что преобразование невозможно) произвольную КС-грамматику к виду LR(k)- грамматики для некоторого к. Кроме того, для LR-грамматик доказано еще одно очень интересное свойство — класс LR-грамматик полностью совпадает с классом детерминированных КС- языков. То есть, во-первых, любая ЕК(к)-грамматика задает детерминированный КС-язык (это очевидно следует из однозначности всех LR-грамматик), а во-вто- рых, для любого детерминированного КС-языка можно построить LR-граммати- ку, задающую этот язык. Второе утверждение уже не столь очевидно, но доказа- но в теории формальных языков [6, т. 1, 65]2. 1 Говоря о соотношении классов LL-грамматик и LR-грамматик, мы не затрагиваем вопрос о значениях к для этих грамматик. Если для некоторой ЕЕ(к)-грамматики всегда сущест- вует эквивалентная ей LR-грамматика, то это вовсе не значит, что она будет LR(k)-rpaM- матикой с тем же значением к, и наоборот. Но если говорится, что существуют LR- грамматики, для которых нет эквивалентных им LL-грамматик, то это означает, что нет эквивалентных им ЕЕ(к)-грамматик для всех возможных значений к>0. 2 Более того, доказано даже, что любой детерминированный КС-язык может быть задан LR( 1)-грамматикой.
Восходящие распознаватели КС-языков без возвратов 489 В принципе класс LR-грамматик очень удобен для построения распознавателей детерминированных КС-языков (а все языки программирования, безусловно, относятся к этому классу). Но тот факт, что для каждого детерминированного КС-языка существует задающая его LR-грамматика, еще ни о чем не говорит, по- скольку из-за неразрешимости проблемы преобразования отсутствует алгоритм, который позволил бы эту грамматику построить всегда. Данный детерминиро- ванный КС-язык может быть изначально задан грамматикой, которая не отно- сится к классу LR-грамматик. В таком случае совсем не очевидно, что для этого языка удастся построить распознаватель на основе LR-грамматики, потому что в общем случае нет алгоритма, который бы позволил эту грамматику получить, хотя и известно, что она существует. То, что проблема не разрешима в общем случае, совсем не означает, что ее не удастся решить в конкретной ситуации. И здесь факт существования LR-грамматики для каждого детерминированного КС-языка играет важную роль — всегда есть смысл в каждом конкретном случае пытаться построить такую грамматику. Принципы построения распознавателей для ЬП(к)-грамматик Для того чтобы формально определить LR(k) свойство для КС-грамматик, вве- дем понятие пополненной КС-грамматики. Грамматика G’ является пополнен- ной грамматикой, построенной на основании исходной грамматики G(VT,VN,P,S), если выполняются следующие условия: □ грамматика G’ совпадает с грамматикой G, если целевой символ S не встреча- ется нигде в правых частях правил грамматики G; □ грамматика G' строится как грамматика G'(VT,VNu{S'},Pu{S'->S},S'), если це- левой символ S встречается в правой части хотя бы одного правила из множе- ства Р в исходной грамматике G. Фактически пополненная КС-грамматика строится таким образом, чтобы ее це- левой символ не встречался в правой части ни одного правила. Если нужно, то в исходную грамматику G для этого добавляется новый терминальный символ S', который становится целевым символом, и новое правило S'—>S. Очевидно, что пополненная грамматика G' эквивалентна исходной грамматике G, то есть L(G') = L(G). Теперь рассмотрим формальное определение LR(k) свойства. Если для произвольной КС-грамматики G в ее пополненной грамматике G' для двух произвольных цепочек вывода из условий: 1. S' =>* aAw => a₽w 2. S' =>* уВх => aPy 3. FIRST(k,w) ~ FIRST(k,y) следует, что aAw = уВх (то есть a = у, А = В и х = у), то доказано, что граммати- ка G обладает LR(k) свойством. Очевидно, что тогда и пополненная граммати- ка G' также обладает LR(k) свойством.
490 Глава 12. Классы КС-языков и грамматик Понятие «пополненной грамматики» введено исключительно с той целью, чтобы в процессе работы алгоритма «сдвиг-свертка» выполнение свертки к целевому символу пополненной грамматики S' служило сигналом к завершению алгорит- ма (поскольку в пополненной грамматике символ S’ в правых частях правил ни- где не встречается). Если условие отсутствия целевого символа в правых частях правил грамматики не будет соблюдаться, то на алгоритм распознавателя по- требуется наложить дополнительные ограничения, так как появление целевого символа на вершине стека уже не будет означать завершение работы алгоритма. Поскольку построение пополненных грамматик выполняется элементарно и не накладывает никаких дополнительных ограничений на исходную КС-граммати- ку, то дальше будем считать, что все распознаватели для ЬК(к)-грамматик рабо- тают с пополненными грамматиками. Распознаватель для Ы1(к)-грамматик функционирует на основе управляющей таблицы Т. Эта таблица состоит из двух частей, называемых «действия» и «пере- ходы». По строкам таблицы распределены все цепочки символов на верхушке стека, которые могут приниматься во внимание в процессе работы распознавате- ля. По столбцам в части «действия» распределены все части входной цепочки символов длиной не более к (аванцепочки), которые могут следовать за считы- вающей головкой автомата в процессе выполнения разбора; а в части «перехо- ды» — все терминальные и нетерминальные символы грамматики, которые мо- гут появляться на верхушке стека автомата при выполнении действий (сдвигов или сверток). Клетки управляющей таблицы Т в части «действия» содержат следующие дан- ные: □ «сдвиг» — если в данной ситуации требуется выполнение сдвига (переноса те- кущего символа из входной цепочки в стек); □ «успех» — если возможна свертка к целевому символу грамматики S и разбор входной цепочки завершен; □ целое число {«свертка») — если возможно выполнение свертки (число обо- значает номер правила грамматики, по которому должна выполняться сверт- ка); □ «ошибка» — во всех других ситуациях. Действия, выполняемые распознавателем, можно вычислять всякий раз на осно- ве состояния стека и текущей аванцепочки. Однако этого вычисления можно из- бежать, если после выполнения действия сразу же определять, какая строка таб- лицы Т будет использована для выбора следующего действия. Тогда эту строку можно поместить в стек вместе с очередным символом и выбирать затем в мо- мент, когда она понадобится. Таким образом, автомат будет хранить в стеке не только символы алфавита, но и связанные с ними строки управляющей табли- цы Т. Клетки управляющей таблицы Т в части «переходы» как раз и служат для выяс- нения номера строки таблицы, которая будет использована для определения вы- полняемого действия на очередном шаге. Эти клетки содержат следующие дан- ные:
Восходящие распознаватели КС-языков без возвратов 491 □ целое число — номер строки таблицы Т; □ «ошибка» — во всех других ситуациях. Для удобства работы распознаватель ЬК(к)-грамматики использует также два спе- циальных символа ±н и ±к. Считается, что входная цепочка символов всегда на- чинается символом ±н и завершается символом ±к. Тогда в начальном состоянии работы распознавателя символ JLH находится на верхушке стека, а считывающая головка обозревает первый символ входной цепочки. В конечном состоянии в стеке должны находиться символы S (целевой символ) и ±н, а считывающая го- ловка автомата должна обозревать символ ±к. Алгоритм функционирования распознавателя ЬК(к)-грамматики можно описать следующим образом: Шаг 1. Поместить в стек символ 1н и начальную (нулевую) строку управляющей таблицы Т. В конец входной цепочки поместить символ ±к. Перейти к шагу 2. Шаг 2. Прочитать с вершины стека строку управляющей таблицы Т. Выбрать из этой строки часть «действие» в соответствии с аванцепочкой, обозреваемой счи- тывающей головкой автомата. Перейти к шагу 3. Шаг 3. В соответствии с типом действия выполнить выбор из четырех вариантов: □ «сдвиг» — если входная цепочка не прочитана до конца, прочитать и запом- нить как «новый символ» очередной символ из входной цепочки, сдвинуть считывающую головку на одну позицию вправо, иначе прервать выполнение алгоритма и сообщить об ошибке; □ целое число {«свертка») — выбрать правило в соответствии с номером, уда- лить из стека цепочку символов, составляющую правую часть выбранного правила, взять символ из левой части правила и запомнить его как «новый символ»; □ «ошибка» — прервать выполнение алгоритма, сообщить об ошибке; □ «успех» — выполнить свертку к целевому символу S, прервать выполнение алгоритма, сообщить об успешном разборе входной цепочки символов, если входная цепочка прочитана до конца, иначе сообщить об ошибке. Конец выбора. Перейти к шагу 4. Шаг 4, Прочитать с вершины стека строку управляющей таблицы Т. Выбрать из этой строки часть «переход» в соответствии с символом, который был запомнен как «новый символ» на предыдущем шаге. Перейти к шагу 5. Шаг 5. Если часть «переход» содержит вариант «ошибка», тогда прервать выпол- нение алгоритма и сообщить об ошибке, иначе (если там содержится номер стро- ки управляющей таблицы Т) положить в стек «новый символ» и строку табли- цы Т с выбранным номером. Вернуться к шагу 2. Для работы алгоритма кроме управляющей таблицы Т используется также неко- торая временная переменная («новый символ»), хранящая значение терминаль- ного или нетерминального символа, полученного в результате сдвига или сверт- ки. В программной реализации алгоритма вовсе не обязательно помещать в стек сами строки управляющей таблицы — поскольку сама таблица неизменна в про- цессе выполнения алгоритма, то достаточно запоминать соответствующие ссылки.
492 Глава 12. Классы КС-языков и грамматик Доказано, что данный алгоритм имеет линейную зависимость необходимых для его выполнения вычислительных ресурсов от длины входной цепочки символов. Следовательно, распознаватель для LR(k)-грамматики имеет линейную зависи- мость сложности от длины входной цепочки, а потому является линейным рас- познавателем. Для построения распознавателя осталось научиться строить управляющую таб- лицу Т. Исходными данными для ее построения служат правила исходной грам- матики. Вопросы построения этой таблицы в данном пособии не рассматрива- ются, они достаточно подробно освещены в [6, т. 1]. Далее будут рассмотрены только примеры управляющих таблиц для некоторых достаточно простых грам- матик. Распознаватель для М?(О)-грамматики Простейшим случаем ЬК(к)-грамматик являются ЬК(О)-грамматики. При к = О распознающий расширенный МП-автомат совсем не принимает во внимание те- кущий символ, обозреваемый его считывающей головкой. Решение о выполняе- мом действии принимается только на основании содержимого стека автомата. При этом не должно возникать конфликтов между выполняемым действием (сдвиг или свертка), а также между различными вариантами при выполнении свертки. Управляющая таблица для Ы1(0)-грамматики строится на основании понятия «левых контекстов» для нетерминальных символов: очевидно, что после выпол- нения свертки для нетерминального символа А в стеке МП-автомата ниже этого символа будут располагаться только те символы, которые могут встречаться в цепочке вывода слева от А. Эти символы и составляют «левый контекст» для А. Поскольку выбор между сдвигом или сверткой, а также между типом свертки в ЬК(О)-грамматиках выполняется только на основании содержимого стека, то Ы1(0)-грамматика должна допускать однозначный выбор на основе левого кон- текста для каждого символа [5, 65, т. 1, 15, 65]. Рассмотрим простую КС-грамматику G({a,b}, {S}, {S-»aSS|b}, S). Пополненная грамматика для нее будет иметь вид G({a,bj, {S, S'}, {S'—•>S, S —>aSS|b}, S’). Эта грамматика является ЬЕ(0)-грамматикой. Управляющая таблица для нее приведена в табл. 12.2. Таблица 12.2. Пример управляющей таблицы для 1_В(0)-грамматики Стек Действие Переход S а b -Ьн сдвиг 1 2 3 S успех, 1 а сдвиг 4 2 3 ь свертка, 3 aS сдвиг 5 2 3 aSS свертка, 2
Восходящие распознаватели КС-языков без возвратов 493 Колонка «Стек», присутствующая в таблице, в принципе не нужн^ для распозна- вателя. Она введена исключительно для пояснения каждого состояния стека автомата. Пустые клетки в таблице соответствуют состоянию «ошибка». Прави- ла в грамматике пронумерованы от 1 до 3 (при этом будем считать, что состоя- нию «успех» — свертке к нулевому символу — в пополненной грамматике всегда соответствует первое правило). Распознаватель работает, невзирая на текущий символ, обозреваемый считывающей головкой расширенного МП-автомата, по- этому колонка «Действие» в таблице имеет только один столбец, не помеченный никаким символом, — указанное в ней данное действие выполняется всегда для каждой строки таблицы. Рассмотрим примеры распознавания цепочек этой грамматики. Работу распозна- вателя будем отображать по шагам. Конфигурацию расширенного МП-автомата будем отображать в виде трех компонентов: не прочитанная еще часть входной цепочки символов, содержимое стека МП-автомата, последовательность номеров примененных правил грамматики (поскольку автомат имеет только одно состоя- ние, его можно не учитывать). В стеке МП-автомата вместе с помещенными туда символами показаны и номера строк управляющей таблицы, соответствующие этим символам в формате {символ, номер строки}. Разбор цепочки abababb. 1. (abababb±K, {JLH,O}, X) 2. (bababblK, {±н,0}{а,2}, X) 3. (ababblK, {lH,0}{a,2}{b,3}, X) 4. (ababblK, {JLH,0}{a,2}{S,4}, 3) 5. (babblK, {lH,0}{a,2}{S,4}{a,2}, 3) 6. (abblK, {±H,0}{a,2}{S,4}{a,2}{b,3}, 3) 7. (abblK, {_L„,0}{a,2}{S,4}{a,2}{S,4}, 3,3) 8. (bblK, {lH,0}{a,2}{S,4}{a,2}{S,4}{a,2}, 3,3) 9. (blK, {JLH,0}{a,2}{S,4}{a,2}{S,4}{a,2}{b,3}, 3,3) 10. (b±K, {±H,0}{a,2}{S,4}{a,2}{S,4}{a,2}{S,4}, 3,3,3) 11. (±K, {±H,0}{a,2}{S,4}{a,2}{S,4}{a,2}{S,4}{b,3}, 3,3,3) 12. (±K, {±H,0}{a,2}{S,4}{a,2}{S,4}{a,2}{S,4}{S,5}, 3,3,3,3) 13. (±K, {lH,0}{a,2}{S,4}{a,2}{S,4}{S,5}, 3,3,3,3,2) 14. (±K, {±H,0}{a,2}{S,4}{S,5}, 3,3,3,3,2,2) 15. (±K, {JLH,O}{S,1}, 3,3,3,3,2,2,2) 16. (±K, {±H,0}{S',*}, 3,3,3,3,2,2,2,1) — разбор завершен. Соответствующая цепочка вывода будет иметь вид (используется правосторон- ний вывод): S' => S => aSS aSaSS => aSaSaSS => aSaSaSb => aSaSabb => aSababb => abababb. Разбор цепочки aabbb. 1. (aabbblK, {lH,0}, X) 2. (abbb±K, {lH,0}{a,2}, X)
494 Глава 12. Классы КС-языков и грамматик 3. (bbblK, {±н,0}{а,2}{а,2}, X) 4. (bblK, {lH,0}{a,2}{a,2}{b,3}, X) 5. (bblK, {JLn,0}{a,2}{a,2}{S,4}, 3) 6. (b±K, {±„,0}{a,2}{a,2}{S,4}{b,3}, 3) 7. (b±K, {±H,0}{a,2}{a,2}{S,4}{S,5}, 3,3) 8. (blK, {±H,0}{a,2}{S,4}, 3,3,2) 9. (1К, {JLH,0}{a,2}{S,4}{b,3}, 3,3,2) 10. (1К, {JLH,0}{a,2}{S,4}{S,5}, 3,3,2,3) И. (1К, {JLH,O}{S,1}, 3,3,2,3,2) 12. (±к, {JLH,O}{S*,*}, 3,3,2,3,2,1) — разбор завершен. Соответствующая цепочка вывода будет иметь вид (используется правосторон- ний вывод): S’ => S => aSS => aSb => aaSSb => aaSbb => aabbb. Разбор цепочки aabb. 1. (aabblK, {±H,0}, X) 2. (abblK, {±н,0}{а,2}, X) 3. (bblK, {JLH,0}{a,2}{a,2}, X) 4. (blK, {JLH,0}{a,2}{a,2}{b,3}, X) 5. (blK, {±H,0}{a,2}{a,2}{S,4}, 3) 6. (JLK, {±H,0}{a,2}{a,2}{S,4}{b,3}, 3) 7. (1K, {±H,0}{a,2}{a,2}{S,4}{S,5}, 3,3) 8. (±K, {J_H,0}{a,2}{S,4}, 3,3,2) 9. Ошибка, невозможно выполнить сдвиг. Распознаватель для ЬК(О)-грамматики достаточно прост. Приведенный выше пример можно сравнить с методом рекурсивного спуска или с распознавателем для ЬЁ(1)-грамматики — оба эти метода применимы к описанной выше грамма- тике. По количеству шагов работы распознавателя эти методы сопоставимы, но по реализации нисходящие распознаватели в данном случае немного проще. Распознаватель для кР(1)-грамматики Другим употребительным классом ЬК(к)-грамматик являются LR(1 ^граммати- ки. В этих грамматиках основанием для принятия расширенным МП-автоматом решения о выполнении сдвига или свертки служит информация о содержимом стека автомата и текущий символ, обозреваемый считывающей головкой. Рассмотрим простую КС-грамматику G({a,b}, {S}, {S->SaSb|X}, S). Пополненная грамматика для нее будет иметь вид G({a,b}, {S. S’}, {S’—S~>SaSb|X}, S'). Эта грамматика является LR(1)-грамматикой [5, 15, 65]. Управляющая таблица для нее приведена в табл. 12.3. Колонка «Стек», присутствующая в таблице, в принципе не нужна для распозна- вателя. Она введена исключительно для пояснения каждого состояния стека автомата. Пустые клетки в таблице соответствуют состоянию «ошибка». Прави-
Восходящие распознаватели КС-языков без возвратов 495 ла в грамматике пронумерованы от 1 до 3 (при этом будем считать, что состоя- нию «успех» — свертке к нулевому символу — в пополненной грамматике всегда соответствует первое правило). Колонка «Действие» в таблице содержит пере- чень действий, соответствующих текущему входному символу, обозреваемому считывающей головкой расширенного МП-автомата. Таблица 12.3. Пример управляющей таблицы для 1_В(1)-грамматики Стек Действие Переход a b а b S -Lh свертка, 3 свертка, 3 1 s сдвиг успех, 1 2 Sa свертка, 3 свертка, 3 3 SaS сдвиг сдвиг 4 5 SaSa свертка, 3 свертка, 3 6 SaSb свертка, 2 свертка, 2 SaSaS сдвиг сдвиг 4 7 SaSaSb свертка, 2 свертка, 2 Рассмотрим примеры распознавания цепочек этой грамматики по шагам, кото- рые совершает распознаватель. Конфигурацию расширенного МП-автомата бу- дем отображать в виде трех компонентов: не прочитанная еще часть входной це- почки символов, содержимое стека МП-автомата, последовательность номеров примененных правил грамматики (поскольку автомат имеет только одно состоя- ние, его можно не учитывать). В стеке МП-автомата вместе с помещенными туда символами показаны и номера строк управляющей таблицы, соответствующие этим символам в формате {символ, номер строки}. Разбор цепочки abababb. 1. (abababb±K, {±н,0}, 2. (abababblK, {±H,O}{S,1}, 3) 3. (bababblK, {±H,0}{S,l}{a,2}, 3) 4. (bababb±K, {±H,0}{S,l}{a,2}{S,3}, 3,3) 5. (ababblK, {JLH,O}{S, l}{a,2}{S,3}{b,5}, 3,3) 6. (ababb±K, {J_H,O}{S,1}, 3,3,2) 7. (babb±K, {J_H,0}{S,l}{a,2}, 3,3,2) 8. (babb±K, {J_H,0}{S,l}{a,2}{S,3}, 3,3,2,3) 9. (abblK, {JLH,0}{S,l}{a,2}{S,3}{b,5}, 3,3,2,3) 10. (abb±K, {JLH,O}{S,1}, 3,3,2,3,2) 11. (bb±K, {JLH,0}{S,l}{a,2}, 3,3,2,3,2) 12. (bblK, {±H,0}{S,l}{a,2}{S,3}, 3,3,2,3,2,3)
496 Глава 12. Классы КС-языков и грамматик 13. (blK, {lH,0}{S,l}{a,2}{S,3}{b,5}, 3,3,2,3,2,3) 14. Ошибка, нет данных для «Ь» в строке 5. Разбор цепочки aababb. 1. (aababb±K, {JLH,O}, X) 2. (aababb±K, {1H,O}{S,1}, 3) 3. (ababb±K, {±H,0}{S,l}{a,2}, 3) 4. (ababb±K, {JLH,0}{S,l}{a,2}{S,3}, 3,3) 5. (babb±K, {±H,0}{S,l}{a,2}{S,3}{a,4}, 3,3) 6. (babb±K, {JLH,O}{S, l}{a,2}{S,3}{a,4}{S,6}, 3,3,3) 7. (abblK, {lH,0}{S,l}{a,2}{S,3}{a,4}{S,6}{b,7}, 3,3,3) 8. (abb±K, {J_H,0}{S,l}{a,2}{S,3}, 3,3,3,2) 9. (bblK, {±H,0}{S,l}{a,2}{S,3}{a,4}, 3,3,3,2) 10. (bblK, {J_H,O}{S, l}{a,2}{S,3}{a,4}{S,6}, 3,3,3,2,3) 11. (b±K, {lH,0}{S,l}{a,2}{S,3}{a,4}{S,6}{b,7}, 3,3,3,2,3) 12. (blK, {J_H,0}{S,l}{a,2}{S,3}, 3,3,3,2,3,2) 13. (±K, {lH,0}{S,l}{a,2}{S,3}{b,5}, 3,3,3,2,3,2) 14. (1K, {JLH,O}{S,1}, 3,3,3,2,3,2,2) 15. (±K, {±H,0}{S',*}, 3,3,3,2,3,2,2,1) — разбор завершен. Соответствующая цепочка вывода будет иметь вид (используется правосторон- ний вывод): S' => S => SaSb => SaSaSbb => SaSabb => SaSaSbabb => SaSababb => Saababb => aababb. Невозможно непосредственно сравнить работу двух рассмотренных вариантов распознавателей: восходящего (LR) и нисходящего (LL). Это очевидно, посколь- ку приведенная в примере грамматика не является ЬЬ(1)-грамматикой. Соответ- ственно, она не может быть разобрана и методом рекурсивного спуска (можно убедиться в этом, построив множества FIRST и FOLLOW для символов грамма- тики: FIRST(1,S) = {a}, FOLLOW(1,S) = {a,b,X}; FIRST(1,S) n FOLLOW(1,S) * 0). На основании этой грамматики вообще невозможно построить нисходящий рас- познаватель, поскольку она явно содержит левую рекурсию. Устранив левую рекур- сию (см. алгоритм в разделе «Преобразование КС-грамматик. Приведенные грам- матики», глава И) и выполнив ряд несложных преобразований, можно получить эквивалентную ей грамматику G"({а,b},{SJ,{S->X|aSbS},S), которая будет относить- ся к классу LL(l)-rpaMMainK (действительно, д ля нее FIRST(1,S) = {a}, FOLLOW(1,S) = = {b,X};. FIRST(1,S) n FOLLOW(1,S) = 0)1. Теперь уже возможно сравнить ра- боту двух вариантов распознавателей — нисходящего и восходящего. 1 Чтобы доказать, что две рассмотренные грамматики эквивалентны (определяют один и тот же язык: L(G) - L(G")), предлагаем читателям выполнить преобразования G в G" са- мостоятельно. Это несложно: первым шагом будет устранение левой рекурсии, затем не- обходимо несколько раз выполнить левую факторизацию (см. раздел «Нисходящие распознаватели КС-языков без возвратов» этой главы), после чего дальнейшее преобра- зование становится очевидным.
Восходящие распознаватели КС-языков без возвратов 497 Несмотря на то что распознаватель на основе LL(1)-грамматики и в данном слу- чае имеет более простой алгоритм функционирования, нельзя сказать, что им легче воспользоваться, поскольку его создание требует дополнительных преоб- разований исходной грамматики. Для более сложных Ы1(1)-грамматик такие преобразования могут быть в принципе невозможны, поскольку класс языков, заданных Ы1(1)-грамматиками, шире, чем класс языков, заданных LL(1 ^грам- матиками. Поэтому для конкретной заданной грамматики чаще бывает проще построить восходящий распознаватель, чем нисходящий. На практике ЬК(к)-грамматики при k > 1 не применяются. На это имеются две причины. Во-первых, управляющая таблица для ЬК(к)-грамматики при к > 1 бу- дет содержать очень большое число состояний, и распознаватель будет достаточ- но сложным и не столь эффективным1. Во-вторых, для любого языка, определяе- мого LR(k)-грамматикой, существует ЬК(1)-грамматика, определяющая тот же язык. То есть для любой ЬК(к)-грамматики с k > 1 всегда существует эквива- лентная ей Ы1(1)-грамматика. Более того, для любого детерминированного КС- языка существует Ы1(1)-грамматика (другое дело, что далеко не всегда такую грамматику можно легко построить)2. Грамматики предшествования (основные принципы) Еще одним распространенным классом КС-грамматик, для которых возмож- но построить восходящий распознаватель без возвратов, являются грамматики предшествования. Так же как и распознаватель рассмотренных выше LR-грам- матик, распознаватель для грамматик предшествования строится на основе алго- ритма «сдвиг-свертка» («перенос-свертка»), который в общем виде был рассмот- рен в разделе «Распознаватели КС-языков с возвратом», глава И. Принцип организации распознавателя входных цепочек языка, заданного грам- матикой предшествования, основывается на том, что для каждой упорядоченной пары символов в грамматике устанавливается некоторое отношение, называемое отношением предшествования. В процессе разбора входной цепочки расширен- ный МП-автомат сравнивает текущий символ входной цепочки с одним из сим- волов, находящихся на верхушке стека автомата. В процессе сравнения проверя- ется, какое из возможных отношений предшествования существует между этими двумя символами. В зависимости от найденного отношения выполняется либо 1 Безусловно, при любых значениях к распознаватель для ЬК(к)-грамматик остается ли- нейным распознавателем — необходимые вычислительные ресурсы для него линейно за- висят от длины входной цепочки символов. Но с ростом к будет расти и коэффициент зависимости. Из алгоритма функционирования распознавателя видно, что этот коэффи- циент напрямую связан с объемом управляющей таблицы, причем ее объем возрастает в квадратичной зависимости от величины к. 2 Число состояний управляющей таблицы для практически интересных LR(1)-грамматик также весьма велико. А к классу LR(0)-грамматик такие грамматики почти никогда не относятся. На практике чаще всего используются промежуточные между LR(0) и LR(1) методы, известные под названиями SLR(l) — Simple («Простые») LR(1) — и LALR(l) — Look Ahead («С заглядыванием вперед») LR(1) [4, 5, 13].
498 Глава 12. Классы КС-языков и грамматик сдвиг (перенос), либо свертка. При отсутствии отношения предшествования ме- жду символами алгоритм сигнализирует об ошибке. Задача заключается в том, чтобы иметь возможность непротиворечивым образом определить отношения предшествования между символами грамматики. Если это возможно, то грамматика может быть отнесена к одному из классов грамматик предшествования. Существует несколько видов грамматик предшествования. Они различаются по тому, какие отношения предшествования в них определены и между какими ти- пами символов (терминальными или нетерминальными) могут быть установле- ны эти отношения. Кроме того, возможны незначительные модификации функ- ционирования самого алгоритма «сдвиг-свертка» в распознавателях для таких грамматик (в основном на этапе выбора правила для выполнения свертки, когда возможны неоднозначности) [5, 6, 23, 65]. Выделяют следующие виды грамматик предшествования: □ простого предшествования; □ расширенного предшествования; □ слабого предшествования; □ смешанной стратегии предшествования; □ операторного предшествования. Далее будут рассмотрены два наиболее простых и распространенных типа — грамматики простого и операторного предшествования. Грамматики простого предшествования Грамматикой простого предшествования называют такую приведенную КС-грам- матику1 G(VN,VT,P,S), V - VTuVN, в которой: 1. Для каждой упорядоченной пары терминальных и нетерминальных символов выполняется не более чем одно из трех отношений предшествования: О В, =• Bj (V Bi,BjGV), если и только если 3 правило А->хВ^у gP, где AgVN, x,yGV*; О В, <• Bj (V BpBjGV), если и только если 3 правило A~->xBjDy gP и вывод D=>*SjZ, где A,DgVN, x,y,zGV*; О В, •> Bj (V Bj,BjGV) , если и только если 3 правило A->xCBjy gP и вывод C=>*zBj или 3 правило A-»xCDy gP и выводы C=>*zBj и D=>*BjW, где A,C,DgVN, x,y,z,WGV*. 2. Различные правила в грамматике имеют разные правые части (то есть в грам- матике не должно быть двух различных правил с одной и той же правой ча- стью). 1 Напоминаем, что КС-грамматика называется приведенной, если она не содержит циклов, бесплодных и недостижимых символов и Х-правил (см. раздел «Преобразование КС- грамматик. Приведенные грамматики», глава И).
Восходящие распознаватели КС-языков без возвратов 499 Отношения =•, <• и •> называют отношениями предшествования для символов. Отношение предшествования единственно для каждой упорядоченной пары символов. При этом между какими-либо двумя символами может и не быть от- ношения предшествования — это значит, что они не могут находиться рядом ни в одном элементе разбора синтаксически правильной цепочки. Отношения пред- шествования зависят от порядка, в котором стоят символы, и в этом смысле их нельзя путать со знаками математических операций — они не обладают ни свой- ством коммутативности, ни свойством ассоциативности. Например, если извест- но, что Bi •> Bj, то не обязательно выполняется Bj <• В, (поэтому знаки предшест- вования иногда помечают специальной точкой: в-, <•, •>). Для грамматик простого предшествования известны следующие полезные свой- ства: □ всякая грамматика простого предшествования является однозначной; □ легко проверить, является или нет произвольная КС-грамматика граммати- кой простого предшествования (для этого достаточно проверить рассмотрен- ные выше свойства грамматик простого предшествования или воспользовать- ся алгоритмом построения матрицы предшествования, который рассмотрен далее). Как и для многих других классов грамматик, для грамматик простого предшест- вования не существует алгоритма, который бы мог преобразовать произвольную КС-грамматику в грамматику простого предшествования (или доказать, что пре- образование невозможно). Метод предшествования основан на том факте, что отношения предшествования между двумя соседними символами распознаваемой строки соответствуют трем следующим вариантам: □ Bj <• Bi+1, если символ Bi+1 — крайний левый символ некоторой основы (это отношение между символами можно назвать «предшествует основе» или про- сто «предшествует»); □ Bj •> Bi+1, если символ Bj — крайний правый символ некоторой основы (это отношение между символами можно назвать «следует за основой» или просто «следует»); □ Bj =• Bi+1, если символы В, и Bi+1 принадлежат одной основе (это отношение между символами можно назвать «составляют основу»). Исходя из этих соотношений, выполняется разбор строки для грамматики пред- шествования. Суть принципа такого разбора можно пояснить на рис. 12.5. На нем изображена входная цепочка символов аурЗ в тот момент, когда выполняется свертка цепоч- ки у. Символ а является последним символом подцепочки а, а символ b — первым символом подцепочки р. Тогда, если в грамматике удастся установить непроти- воречивые отношения предшествования, то в процессе выполнения разбора по алгоритму «сдвиг-свертка» можно всегда выполнять сдвиг до тех пор, пока меж- ду символом на верхушке стека и текущим символом входной цепочки сущест- вует отношение <• или я-. А как только между этими символами будет обнару-
500 Глава 12. Классы КС-языков и грамматик жено отношение •>, так сразу надо выполнять свертку. Причем для выполнения свертки из стека надо выбирать все символы, связанные отношением =•. То, что все различные правила в грамматике предшествования имеют различные правые части, гарантирует непротиворечивость выбора правила при выполнении свертки. Таким образом, установление непротиворечивых отношений предшествования между символами грамматики в комплексе с несовпадающими правыми частями различных правил дает ответы на все вопросы, которые надо решить для органи- зации работы алгоритма «сдвиг-свертка» без возвратов. a a T b p 6 Рис. 12.5. Отношения между символами входной цепочки в грамматике предшествования На основании отношений предшествования строят матрицу предшествования грамматики. Строки матрицы предшествования помечаются первыми (левыми) символами, столбцы — вторыми (правыми) символами отношений предшество- вания. В клетки матрицы на пересечении соответствующих столбца и строки по- мещаются знаки отношений. При этом пустые клетки матрицы говорят о том, что между данными символами нет ни одного отношения предшествования. Матрицу предшествования грамматики сложно построить, опираясь непосредст- венно на определения отношений предшествования. Удобнее воспользоваться двумя дополнительными множествами — множеством крайних левых и множе- ством крайних правых символов относительно нетерминальных символов грам- матики G(VN,VT,P,S), V = VTuVN. Эти множества определяются следующим образом: □ L(A) = {X | 3 A=>*Xz}, AgVN, XgV, zgV* — множество крайних левых симво- лов относительно нетерминального символа А (цепочка z может быть и пус- той цепочкой); □ R(A) = {X | 3 A=>*zX}, AgVN, XgV, zgV* — множество крайних правых сим- волов относительно нетерминального символа А. Иными словами, множество крайних левых символов относительно нетерми- нального символа А — это множество всех крайних левых символов в цепочках, которые могут быть выведены из символа А. Аналогично, множество крайних правых символов относительно нетерминального символа А — это множество всех крайних правых символов в цепочках, которые могут быть выведены из символа А. Тогда отношения предшествования можно определить так: □ В, =• Bj (V Bj,BjGV), если 3 правило А->хВ^у g Р, где AgVN, x,yGV*; □ Bj <• Bj (V Bj,BjeV), если 3 правило A-»xBjDy g P и BjGL(D), где A,DgVN, x,yGV*; □ Bj •> Bj (V Bj,BjGV), если 3 правило A->xCBjy g P и BjgR(C) или 3 правило A-»xCDyG P и B{gR(C), BjeL(D), где A,C,DgVN, x,yeV*.
Восходящие распознаватели КС-языков без возвратов 501 Такое определение отношений удобнее на практике, так как не требует построе- ния выводов, а множества L(A) и R(A) могут быть построены для каждого не- терминального символа AgVN грамматики G(VN,VT,P,S), V = VTuVN по очень простому алгоритму. Шаг 1. V AgVN: R0(A) = {X | А->уХ, XgV, YgV*}, L0(A) = {X | A->Xy, XgV, yGV*}, i := 1. Для каждого нетерминального символа А ищем все правила, содержащие А в ле- вой части. Во множество L(A) включаем самый левый символ из правой части правил, а во множество R(A) — самый крайний правый символ из правой части. Переходим к шагу 2. Шаг 2. V AgVN: Ri (А) = Ri-i(A) и Rm(B), V В g (Ри(А) n VN), Ц (А) = Li.i(A) и LM(B), V В g (ЬИ(А) n VN). Для каждого нетерминального символа А: если множество L(A) содержит нетер- минальные символы грамматики А', А”, ..., то его надо дополнить символами, входящими в соответствующие множества L(A'), L(A"), ... и не входящими в L(A). Ту же операцию надо выполнить для R(A). Шаг 3. Если 3 AgVN: Rj(A) * ЯИ(А) или Ц(А) * ЬМ(А), то i:=i+l и вернуться к шагу 2, иначе построение закончено: R(A) = Ri(A) и L(A) = Ц(А). Если на предыдущем шаге хотя бы одно множество L(A) или R(A) для неко- торого символа грамматики изменилось, то надо вернуться к шагу 2, иначе по- строение закончено. После построения множеств L(A) и R(A) по правилам грамматики создается матрица предшествования. Матрицу предшествования дополняют символами ±н и ±к (начало и конец цепочки). Для них определены следующие отношения предшествования: ±н <• X, V acV, если 3 S=>*Xy, где SgVN, ycV* или (с другой стороны) если XgL(S); ±к •> X, V acV, если 3 S=>*yX, где SgVN, ycV* или (с другой стороны) если XgR(S). Здесь S — целевой символ грамматики. Матрица предшествования служит основой для работы распознавателя языка, заданного грамматикой простого предшествования. Алгоритм «сдвиг-свертка» для грамматики простого предшествования Данный алгоритм выполняется расширенным МП-автоматом с одним состояни- ем. Отношения предшествования служат для того, чтобы определить в процессе выполнения алгоритма, какое действие — сдвиг или свертка — должно выпол- няться на каждом шаге алгоритма, и однозначно выбрать цепочку для свертки. Однозначный выбор правила при свертке обеспечивается за счет различия пра- вых частей всех правил грамматики. В начальном состоянии автомата считываю-
502 Глава 12. Классы КС-языков и грамматик щая головка обозревает первый символ входной цепочки, в стеке МП-автомата находится символ ±н (начало цепочки), в конец цепочки помещен символ ±к (ко- нец цепочки). Символы ±н и JLK введены для удобства работы алгоритма, в язык, заданный исходной грамматикой, они не входят. Разбор считается законченным (алгоритм завершается), если считывающая го- ловка автомата обозревает символ ±к и при этом больше не может быть выполне- на свертка. Решение о принятии цепочки зависит от содержимого стека. Автомат принимает цепочку, если в результате завершения алгоритма в стеке находятся начальный символ грамматики S и символ ±н. Выполнение алгоритма может быть прервано, если на одном из его шагов возникнет ошибка. Тогда входная це- почка не принимается. Алгоритм состоит из следующих шагов. Шаг 1. Поместить в верхушку стека символ ±н, считывающую головку — в нача- ло входной цепочки символов. Шаг 2. Сравнить с помощью отношения предшествования символ, находящийся на вершине стека (левый символ отношения), с текущим символом входной це- почки, обозреваемым считывающей головкой (правый символ отношения). Шаг 3. Если имеет место отношение <• или =•, то произвести сдвиг (перенос теку- щего символа из входной цепочки в стек и сдвиг считывающей головки на один шаг вправо) и вернуться к шагу 2. Иначе перейти к шагу 4. Шаг 4. Если имеет место отношение •>, то произвести свертку. Для этого надо найти на вершине стека все символы, связанные отношением =• («основу»), уда- лить эти символы из стека. Затем выбрать из грамматики правило, имеющее правую часть, совпадающую с основой, и поместить в стек левую часть выбран- ного правила (если символов, связанных отношением =-<$]command>, на вер- хушке стека нет, то в качестве основы используется один, самый верхний символ стека). Если правило, совпадающее с основой, найти не удалось, то необходимо прервать выполнение алгоритма и сообщить об ошибке, иначе, если разбор не за- кончен, то вернуться к шагу 2. Шаг 5. Если не установлено ни одно отношение предшествования между теку- щим символом входной цепочки и символом на верхушке стека, то надо пре- рвать выполнение алгоритма и сообщить об ошибке. Ошибка в процессе выполнения алгоритма возникает, когда невозможно выпол- нить очередной шаг — например, если не установлено отношение предшествова- ния между двумя сравниваемыми символами (на шагах 2 и 4) или если не удается найти нужное правило в грамматике (на шаге 4). Тогда выполнение алгоритма немедленно прерывается. Пример распознавателя для грамматики простого предшествования Рассмотрим в качестве примера грамматику G({+,-,/,*,a,bj, {S,R,T,F,E}, Р, S) с правилами: Р: S -> TR | Т
Восходящие распознаватели КС-языков без возвратов 503 R -> +Т | -Т | +TR | -TR Т -> EF | Е F -> *Е | /Е | *EF | /EF Е —► (S) | а | b Эта нелеворекурсивная грамматика для арифметических выражений над симво- лами а и b уже несколько раз использовалась в качестве примера для построения распознавателей (см. раздел «Распознаватели КС-языков с возвратом», глава И). Хотя эта грамматика и содержит цепные правила, легко увидеть, что она не со- держит циклов, совпадающих правых частей правил и Х-правил, следовательно, по формальным признакам ее можно отнести к грамматикам простого предшест- вования. Осталось определить отношения предшествования. Построим множества крайних левых и крайних правых символов относительно нетерминальных символов грамматики. 1. Шаг 1. L0(S) = {T} R0(S) = {R,T} Lo(R) = {+,-} R0(R) = {R, Т} L0(T) = {E} R0(T) = {E,F} L0(F) -{*,/} R0(F) = {E, F} 2. Lo(E) = {(, a, b} R0(E) = {), a, b} Шаг 2. 3. Lj(S) = {T, E} Ri(S) - {R, T, E, F} Li(R) “{+,-} R1(R)-{R,T,E,F} L1(T) = {E,(,a,b} Ri(T) - {E, F, ), a, b} Lt(F)-{*,/} R1(F)-{E,F!),a)b} L1(E) = {(,a,b} Rt(E) - {), a, b} Шаг 3. Имеется L0(S) * L/S), возвращаемся к шагу 2. 4. Шаг 2. 5. L2(S) = {Т, Е, (, a, b} R2(S) = {R, Т, Е, F,), а, Ь} L2(R) - {+- -} R2(R) “ {R> Т, Е, F,), а, Ь} Ь2(Т) = {Е, (, a, b} R2(T) = {Е, F, ), а, Ь} L2(F) = {*,/} R2(F) = {Е, F,), а, b } L2(E) = {(, a, b} R2(E) = {), а, Ь} Шаг 3. Имеется Lt(S) * L2(S), возвращаемся к шагу 2. 6. Шаг 2. L3(S) = {Т, Е, (, a, b} R3(S) = {R, Т, Е, F, ), а, Ь} L3(R) = {+, -} R3(R) = {R, Т, Е, F, ), а, Ь} L3(T) = {Е, (, a, b} R3(T) = {Е, F, ), а, Ь} L3(F) = {*,/} R3(F) = {Е, F, ), а, b } L3(E) = {(, a, b} R3(E) = {), а, Ь}
504 Глава 12. Классы КС-языков и грамматик R(S) = {R, Т, Е, F, ), а, Ь} R(R) - {R, Т, Е, F, ), а, Ь} R(T) = {Е, F, ), а, Ь} R(F) = {Е, F, ), а, b } R(E)-{), а, Ь} 7. Шаг 3. Ни одно множество не изменилось, построение закончено. Результат: L(S) = {Т, Е, (, а, Ь} L(R) = {+, -} L(T) - {Е, (, а, Ь} L(F) = {*,/} L(E) = {(, а, b} На основании построенных множеств крайних левых и крайних правых симво- лов и правил грамматики построим матрицу предшествования. Результат приве- ден в табл. 12.4. Таблица 12.4. Таблица предшествования для грамматики простого предшествования- + — * / ( ) а b S R Т F Е А + <• <. <• =. <• - <. <. <• =. <• * <• <• <• =• / <• <• <• =. ( <. <• < =. <• <• ) •> > > > > > •> > а > > > > > > > > b > •> •> > > > > > S =. R > > Т <. <. •> =• > F •> > •> > > Е > > <. <• > > =• > J-H <. <• <• <• <• Поясним построение таблицы на примере символа +. Во-первых, в правилах грамматики R -> +Т | +TR символ + стоит слева от симво- ла Т. Значит, в строке символа + в столбце, соответствующем символу Т, ставим знак =•. Кроме того, во множество L(T) входят символы Е, (, а, Ь. Тогда в строке символа + во всех столбцах, соответствующих этим четырем символам, ставим знак <•. Во-вторых, символ + входит во множество L(R), а в грамматике имеются прави- ла вида S -> TR и R +TR | -TR. Следовательно, надо рассмотреть также мно-
Восходящие распознаватели КС-языков без возвратов 505 жество R(T). Туда входят символы Е, F, ), а, Ь. Значит, в столбце символа + во всех строках, соответствующих этим пяти символам, ставим знак •>. Больше символ + ни в какие множества не входит и ни в каких правилах не встречается. Продолжая эти рассуждения для остальных терминальных и нетерминальных символов грамматики, заполняем все ячейки матрицы предшествования, приве- денной выше. Если окажется, что согласно логике рассуждений в какую-либо клетку матрицы предшествования необходимо поместить более чем один-един- ственный знак =•, <• или •>, то это означает, что исходная грамматика не являет- ся грамматикой простого предшествования. Отдельно можно рассмотреть заполнение строки для символа ±н (начало стро- ки) и столбца для символа ±к (конец строки). Множество L(S), где S — целевой символ, содержит символы Т, Е, (, а, Ь. Помещаем знак <• в строку, соответствую- щую символу ±н для всех пяти столбцов, соответствующих этим символам. Ана- логично, множество R(S) содержит символы R, Т, Е, F, ), а, Ь. Помещаем знак •> в столбец, соответствующий символу ±к для всех семи строк, соответствующих этим символам. Рассмотрим работу алгоритма распознавания на примерах. Последовательность разбора будем записывать в виде последовательности конфигураций МП-авто- мата из трех составляющих: □ не просмотренная автоматом часть входной цепочки; □ содержимое стека; □ последовательность примененных правил грамматики. Так как автомат имеет только одно состояние, то для определения его конфигу- рации достаточно двух составляющих — положения считывающей головки во входной цепочке и содержимого стека. Последовательность номеров правил не- сет дополнительную полезную информацию, по которой можно построить це- почку или дерево вывода (кроме того, последовательность примененных правил делает пример более наглядным). Правила в грамматике нумеруются в направ- лении слева направо и сверху вниз (всего в грамматике имеется 15 правил). Будем обозначать такт автомата символом Введем также дополнительное обо- значение -ьп, если на данном такте выполнялся перенос, и ч-с, если выполнялась свертка. Последовательности разбора цепочек входных символов будут, таким образом, иметь вид. Пример 1. Входная цепочка а+а*Ь. 1. {а+а*Ык; 1„; 0} 2. {+а*Ь±к; ±на; 0}-е-с 3. {+а*Ь±к; ±НЕ; 14} -ьс 4. {+а*Ь±к; ±НТ; 14,8} +п 5. {а*Ык; 1НТ+; 14,8} +п 6. {*ЫК; ±нТ+а; 14,8} +с 7. {*Ь±К; ±„Т+Е; 14,8,14} ч-п
506 Глава 12. Классы КС-языков и грамматик 8. {Ь±к; 1НТ+Е*; 14,8,14} +п 9. {±к; ±НТ+Е*Ь; 14,8,14} -нс 10. {±к; ±НТ+Е*Е; 14,8,14,15} +е И. {±к; ±HT+EF; 14,8,14,15,9} +с 12. {±к; ±ИТ+Т; 14,8,14,15,9,7} -нс 13. {1к; _LHTR; 14,8,14,15,9,7,3} +е 14. {±к; ±HS; 14,8,14,15,9,7,3,1}, алгоритм завершен, цепочка принята. Соответствующая цепочка вывода будет иметь вид (используется правосторон- ний вывод): S => TR => Т+Т => T+EF => Т+Е*Е => T+E*b => T+a*b => Е+а*Ь => а+а*Ь. Дерево вывода, соответствующее этой цепочке, приведено на рис. 12.6. Рис. 12.6. Первый пример дерева вывода для грамматики простого предшествования Пример 2. Входная цепочка (а+а)*Ь. 1. {(а+а)*Ык; ±н; 0} +п 2. {а+а)*Ык; 1н(; 0} +п 3. {+а)*Ык; ±н(а; 0}-ьс 4. {+а)’Ык; _LH(E; 14} +с 5. {+а)*Ь±к; 1Н(Т; 14,8} -ьп 6. {а)‘Ык; ±Н(Т+; 14,8} +п 7. {)*ЫК; ±н(Т+а; 14,8} -нс 8. {)*b_LK; ±И(Т+Е; 14,8,14} +с 9. {)*Ь1К; ±Н(Т+Т; 14,8,14,8} +с 10. {)*b±K; ±H(TR; 14,8,14,8,3} +с И. {)*b±K; ±H(S; 14,8,14,8,3,1} тп 12. {*b±K; 1H(S); 14,8,14,8,3,1}
Восходящие распознаватели КС-языков без возвратов 507 13. {*b_LK; ±НЕ; 14,8,14,8,3,1,13} 14. {b_LK; 1НЕ*; 14,8,14,8,3,1,13} +п 15. {±к; 1НЕ*Ь; 14,8,14,8,3,1,13} -+с 16. {1к; ±НЕ*Е; 14,8,14,8,3,1,13,15} +с 17. {±к; ±HEF; 14,8,14,8,3,1,13,15,9} +с 18. {1к; 1НТ; 14,8,14,8,3,1,13,15,9,7} +с 19. {±к; ±HS; 14,8,14,8,3,1,13,15,9,7,2}, алгоритм завершен, цепочка принята. Соответствующая цепочка вывода будет иметь вид (используется правосторон- ний вывод): S => Т => EF => Е*Е => E*b => (S)*b => (TR)*b => (T+T)*b => (T+E)*b => (T+a)*b => (E+a)*b => (a+a)*b. Дерево вывода, соответствующее этой цепочке, приведено на рис. 12.7. Рис. 12.7. Второй пример дерева вывода для грамматики простого предшествования Пример 3. Входная цепочка а+а*. 1. {а+а*1к; ±н; 0}+„ 2. {+а*1к; 1на; 0}+с 3. {+а*±к; ±НЕ; 14} +с 4. {+а*±к; ±НТ; 14,8} +п 5. {а*±к; 1НТ+; 14,8} +п 6. {*±к; ±нТ+а; 14,8} +с 7. {*±к; 1НТ+Е; 14,8,14} +п 8. {±к; 1НТ+Е*; 14,8,14} + 9. Ошибка! (Нет отношений предшествования между символами * и ±к.)
508 Глава 12. Классы КС-языков и грамматик Пример 4. Входная цепочка а+а)*Ь. 1. {а+а)*Ык; ±н; 0} -ьп 2. {+а)*Ь±к; ±на; 0}-5-с 3. {+а)*Ь±к; 1НЕ; 14} +с 4. {+а)*Ь±к; 1„Т; 14,8} -е-п 5. {а)*Ь±к; ±НТ+; 14,8} 6. {)*b_LK; ±нТ+а; 14,8} +с 7. {)*Ь±К; 1НТ+Е; 14,8,14} +с 8. {)*b_LK; ±НТ+Т; 14,8,14,8} +с 9. {)*b±K; ±HTR; 14,8,14,8,3} +с 10. {)*b_LK; ±HS; 14,8,14,8,3,1} -s-n И. {*b_LK; JLHS); 14,8,14,8,3,1}-нс 12. Ошибка! (Невозможно выбрать правило для свертки на этом шаге.) Грамматики простого предшествования являются удобным механизмом для ана- лиза входных цепочек КС-языков. Распознаватель для этого класса грамматик строить легче, чем для рассмотренных выше LR-грамматик. Однако при этом класс языков, заданных грамматиками простого предшествования уже, чем класс языков, заданных LR-грамматиками. Отсюда ясно, что не всякий детермини- рованный КС-язык может быть задан грамматикой простого предшествования, а следовательно, не для каждого детерминированного КС-языка можно постро- ить распознаватель по методу простого предшествования. У грамматик простого предшествования есть еще один недостаток — при боль- шом количестве терминальных и нетерминальных символов в грамматике мат- рица предшествования будет иметь значительный объем (при этом следует заме- тить, что значительная часть ее ячеек может оставаться пустой). Поиск в такой матрице может занять некоторое время, что существенно при работе распознава- теля — фактически время поиска линейно зависит от числа символов в грамматике, а объем матрицы — квадратично. Для того чтобы избежать хранения и обработки таких матриц, можно выполнить «линеаризацию матрицы предшествования». Тогда каждый раз, чтобы установить отношение предшествования между двумя символами, будет выполняться не поиск по матрице, а вычисление некой специ- ально организованной функции. Вопросы линеаризации матрицы предшествова- ния здесь не рассматриваются, с применяемыми при этом методами можно озна- комиться в [5, 6, т. 2, 23, 32]. Грамматики операторного предшествования Операторной грамматикой называется КС-грамматика без Х-правил, в которой правые части всех правил не содержат смежных нетерминальных символов. Для операторной грамматики отношения предшествования можно задать на множе- стве терминальных символов (включая символы ±н и ±к).
Восходящие распознаватели КС-языков без возвратов 509 Грамматикой операторного предшествования называется операторная КС-грам- матика G(VN,VT,P,S), V = VTuVN, для которой выполняются следующие усло- вия: 1. Для каждой упорядоченной пары терминальных символов выполняется не более чем одно из трех отношений предшествования: О а =• Ь, если и только если существует правило A~»xaby еР или правило А->хаСЬу, где a,beVT, A,CgVN, x,yeV*; О а <• b, если и только если существует правило А->хаСу еРи вывод C=>*bz или вывод C=>*Dbz, где a,beVT, A,C,DgVN, x,y,zeV*; О а •> b, если и только если существует правило А—>хСЬу еР и вывод C=>*za или вывод C=>*zaD, где a,beVT, A,C,DgVN, х,у,геУЧ 2. Различные порождающие правила имеют разные правые части, Х-правила от- сутствуют. Отношения предшествования для грамматик операторного предшествования определены таким образом, что для них выполняется еще одна особенность — правила грамматики операторного предшествования не могут содержать двух смежных нетерминальных символов в правой части. То есть в грамматике опера- торного предшествования G(VN,VT,P,S), V - VTuVN не может быть ни одного правила вида: А~»хВСу, где A,B,CgVN, x,yeV* (здесь х и у — это произвольные цепочки символов, могут быть и пустыми). Для грамматик операторного предшествования также известны следующие свой- ства: □ всякая грамматика операторного предшествования задает детерминирован- ный КС-язык (но не всякая грамматика операторного предшествования при этом является однозначной!); □ легко проверить, является или нет произвольная КС-грамматика граммати- кой операторного предшествования (точно так же, как и для простого пред- шествования). Как и для многих других классов грамматик, для грамматик операторного пред- шествования не существует алгоритма, который бы мог преобразовать произ- вольную КС-грамматику в грамматику операторного предшествования (или до- казать, что преобразование невозможно). Принцип работы распознавателя для грамматики операторного предшествова- ния аналогичен грамматике простого предшествования, но отношения предшест- вования проверяются в процессе разбора только между терминальными симво- лами. 1 В литературе отношения операторного предшествования иногда обозначают другими символами, отличными от «<•>>, «•>» и «=•», чтобы не путать их с отношениями простого предшествования. Например, встречаются обозначения «<°», «°>» и «=°». В данном по- собии путаница исключена, поэтому будут использоваться одни и те же обозначения, хотя, по сути, отношения предшествования несколько различны.
510 Глава 12. Классы КС-языков и грамматик Для грамматики данного вида на основе установленных отношений предшество- вания также строится матрица предшествования, но она содержит только терми- нальные символы грамматики. Для построения этой матрицы удобно ввести множества крайних левых и край- них правых терминальных символов относительно нетерминального символа А - 1?(А) или Ю(А): □ U(A) - {t | 3 A=>*tz или 3 A=>*Ctz }, где teVT, A,CeVN, zeV*; □ Rl(A) - {t | 3 A=>*zt или 3 A=>*ztC }, где teVT, A,CeVN, zeV*. Тогда определения отношений операторного предшествования будут выглядеть так: □ а =• Ь, если 3 правило A—>xaby еР или правило U->xaCby, где a,beVT, A,CeVN, x,yeV*; □ а <• b, если 3 правило А~»хаСу еР и Ье1?(С), где a,beVT, A,CeVN, x,yeV*; □ а •> b, если 3 правило А->хСЬу еР и acR^C), где a,beVT, A,CeVN, x,yeV*. В данных определениях цепочки символов x,y,z могут быть и пустыми цепочками. Для нахождения множеств 1?(А) и R*(A) предварительно необходимо выпол- нить построение множеств L(A) и R(A), как это было рассмотрено ранее. Далее для построения 1?(А) и Rl(A) используется следующий алгоритм: Шаг 1. V AgVN: R^A) = {t | A->ytB или A~»yt, teVT, BgVN, yeV*}, 1?0(А) ~ {t | A->Bty или A->ty, teVT, BgVN, ycV*}. Для каждого нетерминального символа А ищем все правила, содержащие А в ле- вой части. Во множество L(A) включаем самый левый терминальный символ из правой части правил, игнорируя нетерминальные символы, а во множество R(A) — самый крайний правый терминальный символ из правой части правил. Перехо- дим к шагу 2. Шаг 2. V AgVN: RS(A) » Р1И(А) u RViCB), V В g (R(A) n VN), L\(A) - 1?И(А) u LVi(B), V В g (L(A) n VN). Для каждого нетерминального символа А: если множество L(A) содержит нетер- минальные символы грамматики А’, А", ..., то его надо дополнить символами, входящими в соответствующие множества 1?(А’), 1?(А"), ... и не входящими в 1?(А). Ту же операцию надо выполнить для множеств R(A) и Rl(A). Шаг 3. Если 3 AgVN: RS(A) * R^.^A) или 1Д(А) * 1?и(А), то i:ei+l и вернуться к шагу 2, иначе построение закончено: R(A) - RS(A) и L(A) = Ь\(А). Если на предыдущем шаге хотя бы одно множество 1?(А) или Rl(A) для неко- торого символа грамматики изменилось, то надо вернуться к шагу 2, иначе по- строение закончено. Для практического использования матрицу предшествования дополняют симво- лами ±н и JLK (начало и конец цепочки). Для них определены следующие отноше- ния предшествования:
Восходящие распознаватели КС-языков без возвратов 511 1Н <• а, V aeVT, если 3 S=>*ax или 3 S=>*Cax, где S,CeVN, xgV* или если aeLXS); ±к •> а, V aeVT, если 3 S=>*xa или 3 S=>*xaC, где S,CgVN, xgV* или если aeRXS). Здесь S — целевой символ грамматики. Матрица предшествования служит основой для работы распознавателя языка, заданного грамматикой операторного предшествования. Поскольку она содержит только терминальные символы, то, следовательно, будет иметь меньший размер, чем аналогичная матрица для грамматики простого предшествования. Следует отметить, что напрямую сравнивать матрицы двух грамматик нельзя — не всякая грамматика простого предшествования является грамматикой операторного пред- шествования, и наоборот. Например, рассмотренная далее в примере грамматика операторного предшествования не является грамматикой простого предшество- вания (читатели могут это проверить самостоятельно). Однако если две грамма- тики эквивалентны и задают один и тот же язык, то их множества терминальных символов должны совпадать. Тогда можно утверждать, что размер матрицы для грамматики операторного предшествования всегда будет меньше, чем размер матрицы эквивалентной ей грамматики простого предшествования. Все, что было сказано выше о способах хранения матриц для грамматик просто- го предшествования, в равной степени относится также и к грамматикам опера- торного предшествования, с той только разницей, что объем хранимой матрицы будет меньше. Алгоритм «сдвиг-свертка» для грамматики операторного предшествования Этот алгоритм в целом похож на алгоритм для грамматик простого предшество- вания, рассмотренный выше. Он также выполняется расширенным МП-автома- том и имеет те же условия завершения и обнаружения ошибок. Основное отличие состоит в том, что при определении отношения предшествования этот алгоритм не принимает во внимание находящиеся в стеке нетерминальные символы и при сравнении ищет ближайший к верхушке стека терминальный символ. Однако после выполнения сравнения и определения границ основы при поиске правила в грамматике нетерминальные символы следует, безусловно, принимать во вни- мание. Алгоритм состоит из следующих шагов. Шаг 1. Поместить в верхушку стека символ 1н, считывающую головку — в нача- ло входной цепочки символов. Шаг 2. Сравнить с помощью отношения предшествования терминальный сим- вол, ближайший к вершине стека (левый символ отношения), с текущим симво- лом входной цепочки, обозреваемым считывающей головкой (правый символ отношения). При этом из стека надо выбрать самый верхний терминальный сим- вол, игнорируя все возможные нетерминальные символы.
512 Глава 12. Классы КС-языков и грамматик Шаг 3. Если имеет место отношение <• или =•, то произвести сдвиг (перенос теку- щего символа из входной цепочки в стек и сдвиг считывающей головки на один шаг вправо) и вернуться к шагу 2. Иначе перейти к шагу 4. Шаг 4. Если имеет место отношение •>, то произвести свертку. Для этого надо найти на вершине стека все терминальные символы, связанные отношением =• («основу»), а также все соседствующие с ними нетерминальные символы (при определении отношения нетерминальные символы игнорируются). Если терми- нальных символов, связанных отношением =•, на верхушке стека нет, то в качест- ве основы используется один, самый верхний в стеке терминальный символ сте- ка. Все (и терминальные, и нетерминальные) символы, составляющие основу, надо удалить из стека, а затем выбрать из грамматики правило, имеющее правую часть, совпадающую с основой, и поместить в стек левую часть выбранного пра- вила. Если правило, совпадающее с основой, найти не удалось, то необходимо прервать выполнение алгоритма и сообщить об ошибке, иначе, если разбор не за- кончен, то вернуться к шагу 2. Шаг 5. Если не установлено ни одно отношение предшествования между теку- щим символом входной цепочки и самым верхним терминальным символом в стеке, то надо прервать выполнение алгоритма и сообщить об ошибке. Конечная конфигурация данного МП-автомата совпадает с конфигурацией при распознавании цепочек грамматик простого предшествования. Пример построения распознавателя для грамматики операторного предшествования Рассмотрим в качестве примера грамматику для арифметических выражений над символами а и b G({+,a,b}, {S,T,E}, Р, S): S -> S+T | S-Т I т Т -» Т*Е I Т/Е I Е Е > (S) | а | b Эта грамматика уже много раз использовалась в качестве примера для построе- ния распознавателей. Видно, что эта грамматика является грамматикой операторного предшествова- ния. Построим множества крайних левых и крайних правых символов L(A), R(A) от- носительно всех нетерминальных символов грамматики. Рассмотрим работу ал- горитма построения этих множеств по шагам. 1. Шаг 1. L0(S) = {S, Т}, L0(T) = {T, Е}, Lo(E) = {(, а, Ь}, Ro(S)“{T}, Ro(T) = {E}, R0(E) = {), a, b},i = l. Ri(S) = {T}, 2. Шаг 2. L1(S) = {S,T,E}, Ri(S)-{T}, Lt(T) = {T, E, (, a, b}, RifO-HE},
Восходящие распознаватели КС-языков без возвратов 513 LKE) - {(, a, b}, R1(E) = {),a,b}. 3. Шаг 3. Так как Lo(S) * L^S), то i = 2 и возвращаемся к шагу 2. 4. Шаг 2. L2(S) = {S, Т, Е, (, a, b}, R2(S) = {Т}, Ь2(Т) = {Т, Е, (, a, b}, R2(T) = {E}, L2(E) = {(, a, b}, R2(E) = {), а, Ь}. 5. Шаг 3. Так как Lj(S) * L2(S), то i = 3 и возвращаемся к шагу 2. 6. Шаг 2. L3(S) = {S, Т, Е, (, a, b}, R3(S) = {T}, L3(T) = {Т, Е, (, a, b}, R3(T) = {E}, L3(E) = {(, a, b}, R3(E) = {), а, Ь}. 7. Построение закончено. Получили результат: L(S) - {S, Т, Е, (, а, Ь}, L(T) - {Т, Е, (, а, Ь}, L(E) = {(, а, Ь}, R(S) = {T}, R(T) = {E}, R(E) = {), a, b}. На основе полученных множеств построим множества крайних левых и крайних правых терминальных символов L‘(A), R‘(A) относительно всех нетерминаль- ных символов грамматики. Рассмотрим работу алгоритма построения этих мно- жеств по шагам. 1. 2. Rco(S) = {+, R‘o(T)-{*. /}, R‘0(E) = {), a, b}, i = 1. 3. 4. Шаг 1. L‘0(S) = {+, L‘0(T) “{*,/}, L‘o(E)-{(, a, b}, Шаг 2. LS(S) - {+, - *, /, (, a, b},RS(S) = {+, *, /}, L‘i(T) = {*,/, (, a, b}, LS(E) = {(, a, b}, Шаг 3. Так как L‘0(S) * U^S), то i = 2 и возвращаемся к шагу 2. Шаг 2. L‘2(S) = {+, *, /, (, a, b},R‘2(S) - {+, *, /, ), а, b }, L‘2(T)-{•,/, (, а, Ь}, L‘2(E)-{(, а, Ъ}, Шаг 3. Так как R'i(S) * R‘2(S), то i = 3 и возвращаемся к шагу 2. R‘i(T) = {*,/,), a, b}, R‘1(E) = {), a, b}. R‘2(T)-{•,/, ), a, b}, R‘2(E)-{), a, b}. 5. 6. Шаг 2. L‘3(S) = {+, *, /, (, a, b},Rt3(S) = {+, *, /,), а, b }, L'3(T) = {*, /, (, a, b}, R‘3(T) = {*, /, ), а, Ь},
514 Глава 12. Классы КС-языков и грамматик Ll3(E) - {(, a, b}, R‘3(E) - {), а, Ь}. 7. Построение закончено. Получили результат: L4S) = {+, *, /, (, a, b}, R4S) = {+, -, *, /, ), а, b }, L‘(T) = {*, /, (, a, b}, R‘(T) = {*, /,), а, Ь}, L‘(E) = {(, a, b}, R‘(E) - {), а, Ь}. На основе этих множеств и правил грамматики G построим матрицу предшест- вования грамматики (табл. 12.5). Таблица 12.5. Матрица предшествования грамматики Сим- волы + — * / ( ) а b -L. + •> .> <. <. <. .> <. <. .> - .> .> <. <. <• .> <. <. .> * •> .> .> .> <. •> <. <. .> / .> .> .> .> <. .> <• <. .> ( <• <. <. <• <. =• <• <• ) •> .> .> .> .> .> а .> .> .> •> .> .> b .> .> .> .> •> •> -Lh <• <. <. <• <. <• <• Поясним, как заполняется матрица предшествования в таблице на примере сим- вола +. В правиле грамматики S->S+T (правило 1) этот символ стоит слева от не- терминального символа Т. Во множество 1?(Т) входят символы: *,/,(, а, Ь. Ста- вим знак <• в клетках матрицы, соответствующих этим символам, в строке для символа +. В то же время в этом же правиле символ + стоит справа от нетерми- нального символа S. Во множество R4S) входят символы: +, -, *,/,), а, Ь. Ставим знак •> в клетках матрицы, соответствующим этим символам, в столбце для сим- вола +. Больше символ + ни в каком правиле не встречается, значит, заполнение матрицы для него закончено, берем следующий символ и продолжаем заполнять матрицу таким же методом, пока не переберем все терминальные символы. Отдельно рассмотрим символы ±п и ±к. В строке символа ±п ставим знак <• в клетках символов, входящих во множество I?(S). Это символы +, -, *, /, (, а, Ь. В столбце символа ±к ставим знак •> в клетках символов, входящих во множест- во R4S). Это символы +, -, *,/,), а, Ь. Еще можно отметить, что в клетке соответствующей открывающей скобки (сим- вол () слева и закрывающей скобке (символ )) справа помещается знак =• («со- ставляют основу»). Так происходит, поскольку в грамматике присутствует пра- вило Е—>(S), где эти символы стоят рядом (через нетерминальный символ) в его
Восходящие распознаватели КС-языков без возвратов 515 правой части. Следует отметить, что понятия «справа» и «слева» здесь имеют важное значение: в клетке соответствующей закрывающей скобке (символ )), слева и открывающей скобке (символ () справа знак отсутствует — такое сочета- ние символов недопустимо (отношение (=•) верно, а отношение )=•( — неверно). Алгоритм разбора цепочек грамматики операторного предшествования игнори- рует нетерминальные символы. Поэтому имеет смысл преобразовать исходную грамматику таким образом, чтобы оставить в ней только один нетерминальный символ. Тогда получим следующий вид правил: Р: S -> S+S | S-S | S (правила 1, 2 и 3) S -> S*S | S/S | S (правила 4. 5 и 6) S -> (S) | а | b (правила 7, 8 и 9) Если теперь исключить бессмысленные правила вида S—>S, то получим следую- щее множество правил (нумерацию правил сохраним в соответствии с исходной грамматикой): Р: S -> S+S | S-S (правила 1. 2) S -> S*S | S/S (правила 4, 5) S т-> (S) | а | b (правила 7, 8 и 9) Такое преобразование не ведет к созданию эквивалентной грамматики и выпол- няется только для упрощения работы алгоритма (который при выборе правил все равно игнорирует нетерминальные символы) после построения матрицы предшествования. Полученная в результате преобразования грамматика не яв- ляется однозначной, но в алгоритм распознавания уже были заложены все необ- ходимые данные о порядке применения правил при создании матрицы предше- ствования, поэтому распознаватель остается детерминированным. Построенная таким способом грамматика называется «остовной» грамматикой. Вывод, полу- ченный при разборе на основе остовной грамматики, называют результатом «ос- товного» разбора или «остовным» выводом [6, 23, 32]. По результатам остовного разбора можно построить соответствующий ему вы- вод на основе правил исходной грамматики. Однако эта задача не представляет практического интереса, поскольку остовный вывод отличается от вывода на ос- нове исходной грамматики только тем, что в нем отсутствуют шаги, связанные с применением цепных правил, и не учитываются типы нетерминальных симво- лов. Для компиляторов же распознавание цепочек входного языка заключается не в нахождении того или иного вывода, а в выявлении основных синтаксиче- ских конструкций исходной программы с целью построения на их основе цепо- чек языка результирующей программы. В этом смысле типы нетерминальных символов и цепные правила не несут никакой полезной информации, а напро- тив, только усложняют обработку цепочки вывода (или дерева вывода). Поэтому для реального компилятора нахождение остовного вывода является даже более полезным, чем нахождение вывода на основе исходной грамматики. Найденный остовный вывод в дальнейших преобразованиях уже не нуждается (более под- робно см. главу «Основные принципы построения трансляторов»).
516 Глава 12. Классы КС-языков и грамматик Рассмотрим работу алгоритма распознавания на примерах. Последовательность разбора будем записывать в виде последовательности конфигураций расширен- ного МП-автомата из трех составляющих: □ не просмотренная автоматом часть входной цепочки; □ содержимое стека; □ последовательность примененных правил грамматики. Так как автомат имеет только одно состояние, то для определения его конфигу- рации достаточно двух составляющих — положения считывающей головки во входной цепочке и содержимого стека. Последовательность номеров правил не- сет дополнительную полезную информацию, по которой можно построить це- почку или дерево вывода (кроме того, последовательность примененных правил делает пример более наглядным). Будем обозначать такт автомата символом 4-. Введем также дополнительное обо- значение 4-п, если на данном такте выполнялся перенос, и -гс, если выполнялась свертка. Последовательности разбора цепочек входных символов будут, таким образом, иметь вид, приведенный ниже. Пример 1. Входная цепочка а+а*Ь. 1. {а+а*Ык; ±н; 0} -ьп 2. {+а*Ык; 1на; 0} 4-с 3. {+a*blK; 1HS; 8} +п 4. {a*b±K; ±HS+; 8}-5-п 5. {*b±K; ±„S+a; 8} 4-с 6. {*ЫК; 1HS+S; 8,8} 7. {b_LK; ±HS+S*; 8,8} 8. {1к; 1HS+S*b; 8,8} +с 9. {±к; 1„S+S*S; 8,8,9} 4-с 10. {1К; 1„S+S; 8,8,9,4} +с И. {±к; JLHS; 8,8,9,4,1} — разбор завершен, цепочка принята. Соответствующая цепочка вывода будет иметь вид (используется правосторон- ний вывод): S => S+S => S+S*S => S+S*b => S+a*b => a+a*b. Дерево вывода, соответствующее этой цепочке, приведено на рис. 12.8. Следует отметить, что распознаватель, работающий на основе грамматики опера- торного предшествования, имеет еще одно весьма полезное свойство — за счет того, что при построении цепочки вывода не учитываются типы нетерминаль- ных символов, в результате игнорируются цепные правила, присутствующие в грамматике. Это сокращает длину цепочки вывода и размер дерева вывода1. 1 Из цепочки (и дерева) вывода удаляются цепные правила, которые, как будет показано да- лее, все равно не несут никакой полезной семантической (смысловой) нагрузки, а потому для компилятора являются бесполезными. Это положительное свойство распознавателя.
Восходящие распознаватели КС-языков без возвратов 517 Рис. 12.8. Первый пример дерева вывода для грамматики операторного предшествования Пример 2. Входная цепочка (а+а)*Ь. 1. {(а+а)*Ь±к; 1Н; 0} +п 2. {а+а)*Ь±к; ±н(; 0} +п 3. {+а)*Ык; ±„(а; 0} -с 4. {+a)*b±K; ±H(S; 8} +п 5. {a)*b±K; ±H(S+; 8} +п 6. {)*b±K; -LH(S+a; 8} +с 7. {)*b±K; 1H(S+S; 8,8} ч-с 8. {)*ЫК; _LH(S; 8,8,1} +п 9. {*b±K; ±H(S); 8,8,1} +с 10. {*bJ_K; ±HS; 8,8,1,7} +„ И. {b±K; ±HS*; 8,8,1,7} ч-п 12. {±к; ±HS*b; 8,8,1,7} +с 13. {±к; 1HS*S; 8,8,1,7,9} +с 14. {±к; ±HS; 8,8,1,7,9,4} — разбор завершен, цепочка принята. Соответствующая цепочка вывода будет иметь вид (используется правосторон- ний вывод): S => S*S => S*b => (S)*b => (S+S)*b => (S+a)*b => (a+a)*b. Дерево вывода, соответствующее этой цепочке, приведено на рис. 12.9. Пример 3. Входная цепочка а+а*. 1. {а+а*±к; ±н; 0} +п 2. {+а*±к; ±на; 0}-е-с 3. {+а*±к; -L„S; 8} -гп 4. {а*±к; ±HS+; 8} 5. {*1к; ±HS+a; 8} 6. {*±к; ±HS+S; 8,8} 7. {±к; 1„S+S*; 8,8НС 8. Ошибка! (Нет правила для выполнения свертки на этом шаге.)
518 Глава 12. Классы КС-языков и грамматик Рис. 12.9. Второй пример дерева вывода для грамматики операторного предшествования Пример 4. Входная цепочка а+а)*Ь. 1. {а+а)*Ь±к; ±н; 0}-е-п 2. {+а)*Ь±к; ±на; 0} 4-с 3. {+a)*b±K; ±HS; 8} +п 4. {a)*b; ±HS+; 7} -ьп 5. {)*blK; lHS+a; 8} тс 6. {)*b±K; ±HS+S; 8,8} 4-с 7. {)*b±K; _LHS; 8,8,1} 8. Ошибка! (Нет отношений предшествования между символами ±н и ).) Два первых примера наглядно демонстрируют, что приоритет операций, установ- ленный в грамматике, влияет на последовательность разбора и последователь- ность применения правил, несмотря на то что нетерминальные символы распо- знавателем не учитываются. Как было сказано выше, матрица для грамматики операторного предшествова- ния всегда имеет меньший объем, чем матрица для эквивалентной ей грамматики простого предшествования (поскольку в первую из них входят только терминаль- ные символы, а во вторую также и нетерминальные). Кроме того, распознаватель грамматики операторного предшествования игнорирует нетерминальные симво- лы в процессе разбора, а значит, не учитывает цепные правила, делает меньше шагов и порождает более короткую цепочку вывода. Поэтому распознаватель для грамматики операторного предшествования всегда проще, чем распознава- тель для эквивалентной ей грамматики простого предшествования. Интересно, что поскольку распознаватель на основе грамматик операторного предшествования не учитывает типы нетерминальных символов, то он может ра- ботать даже с некоторыми неоднозначными грамматиками, в которых есть пра- вила, различающиеся только типами нетерминальных символов. Примером та- кой грамматики может служить грамматика G"({a,b}, {S,A,B}, Р, S) с правилами: Р: S -> А | В
Соотношение классов КС-языков и КС-грамматик 519 А -> aAb | ab В -> aBb | ab Как и для любой другой грамматики операторного предшествования, распозна- ватель для этой грамматики будет детерминированным. Остовная грамматика, построенная на ее основе, будет иметь только два правила вида: S—>aSb | ab. Неод- нозначность заключается в том, что каждому найденному остовному выводу бу- дет соответствовать не один, а несколько выводов в исходной грамматике (в дан- ном случае — всегда два вывода в зависимости от того, какое правило из S—>А | В будет применено на первом шаге вывода). Грамматики, содержащие правила, различающиеся только типами нетерминальных символов, практического значе- ния не имеют, а потому интереса для компиляторов не представляют. К сожалению, хотя классы грамматик простого и операторного предшествования несопоставимы1, но класс языков операторного предшествования уже, чем класс языков простого предшествования. Поэтому не всегда возможно для языка, за- данного грамматикой простого предшествования, построить грамматику опера- торного предшествования. Соответственно, поскольку класс языков, заданных грамматиками операторного предшествования, еще более узок, чем даже класс языков, заданных грамматиками простого предшествования, то с помощью этих грамматик можно определить далеко не каждый детерминированный КС-язык. Грамматики операторного предшествования — это очень удобный инструмент для построения распознавателей, но они имеют ограниченную область примене- ния. Соотношение классов КС-языков и КС-грамматик В этом подразделе рассматриваются вопросы, связанные с отношениями между различными классами известных КС-языков и КС-грамматик. Эти соотношения представляются весьма интересными с точки зрения теории формальных язы- ков. Многие из них совсем неочевидны. Однако читатель, интересующийся толь- ко практическими аспектами построения трансляторов и компиляторов, может в принципе пропустить этот подраздел. Особенности восходящих и нисходящих распознавателей Выше были рассмотрены варианты двух основных типов распознавателей для цепочек КС-языков — восходящих и нисходящих. Далее можно не рассматри- вать распознаватели с возвратом и табличные распознаватели, поскольку их практическое применение сильно ограничено. Реальные компиляторы не строят- 1 В том, что эти два класса грамматик несопоставимы, можно убедится, рассмотрев два приведенных примера — в них взяты различные по своей сути и классам грамматики, хотя они и являются эквивалентными — задают один и тот же язык.
520 Глава 12. Классы КС-языков и грамматик ся на основе универсальных распознавателей из-за их высокой требовательности к необходимым вычислительным ресурсам. Как правило, конкретную КС-грам- матику и заданный ею КС-язык удается отнести к тому или иному классу, для которого существует специального рода распознаватель с линейной зависимо- стью требуемых вычислительных ресурсов от длины входной цепочки — линей- ный распознаватель. Линейные распознаватели (распознаватели без возвратов) существуют и в виде восходящих, и в виде нисходящих распознавателей. И те и другие имеют линей- ную зависимость вычислительных ресурсов, необходимых для выполнения алго- ритмов разбора, от длины входной цепочки символов. Существуют также другие варианты линейных распознавателей, сочетающие в себе некоторые черты вос- ходящего и нисходящего разбора [6, т. 1]. Но возникает другой вопрос: очень часто одна и та же КС-грамматика может быть отнесена не к одному, а сразу к нескольким классам грамматик, допускаю- щих построение линейных распознавателей. Иногда для этого надо выполнить некоторые преобразования правил грамматики1. В этом случае необходимо ре- шить, какой из нескольких возможных распознавателей выбрать для практиче- ской реализации. Если можно выбрать один из двух явно сопоставимых по сложности распозна- вателей (например, распознаватели ЬК(1)-грамматик и грамматик предшество- вания можно сопоставить по объему управляющих таблиц, так как они исполь- зуют схожие алгоритмы работы), тогда ответ очевиден. Однако ответить на этот вопрос не всегда легко, поскольку могут быть построены два принципиально разных распознавателя, алгоритмы работы которых несопоставимы. В первую очередь речь идет именно о восходящих и нисходящих распознавателях: в осно- ве первых лежит алгоритм подбора альтернатив, в основе вторых — алгоритм «сдвиг-свертка». На вопрос о том, какой распознаватель — нисходящий или восходящий — вы- брать для построения распознавателя, нет однозначного ответа. Эту проблему необходимо решать, опираясь на некую дополнительную информацию о том, как будут использованы или каким образом будут обработаны результаты работы распознавателя. Тут следует вспомнить, что распознаватель входных цепочек КС-языков — это всего лишь один из этапов компиляции, составная часть ком- пилятора, лежащая в основе синтаксического анализатора входного языка про- граммирования. И с этой точки зрения результаты работы распознавателя служат исходными данными для различных этапов компиляции: прежде всего синтакси- ческого анализа, затем — генерации кода. Поэтому выбор того или иного распо- знавателя во многом зависит от реализации компилятора, от того, какие принци- пы положены в его основу. Восходящий синтаксический анализ, как правило, привлекательнее нисходяще- го, так как для данного языка программирования часто легче построить право- 1 Примером такой грамматики может служить грамматика арифметических выражений, которая была впервые упомянута в разделе «Проблемы однозначности и эквивалентно- сти грамматик», глава 9, а потом многократно использовалась в качестве примера для ил- люстрации работы различных распознавателей.
Соотношение классов КС-языков и КС-грамматик 521 сторонний (восходящий) распознаватель на основе правоанализируемой грам- матики. С этим вопросом мы уже сталкивались в данном пособии, когда говорили о том, что класс языков, заданных LR-грамматиками, шире, чем класс языков, заданных LL-грамматиками (хотя следует сказать, что не все здесь столь одно- значно). С другой стороны, как будет показано далее, левосторонний (нисходящий) синтак- сический анализ предпочтителен с точки зрения процесса трансляции, поскольку на его основе легче организовать процесс порождения цепочек результирующего языка. Ведь в задачу компилятора входит не только распознать (проанализиро- вать) входную программу на входном языке, но и построить (синтезировать) ре- зультирующую программу. Более подробную информацию об этом можно полу- чить в разделе «Генерация кода. Методы генерации кода», глава 14 или в работах [6, т. 2, 42]. Левосторонний анализ, основанный на нисходящем распознавателе, оказывается предпочтительным также при учете вопросов, связанных с обнару- жением и локализацией ошибок в тексте исходной программы [40, 82]. Желание использовать более простой класс грамматик для построения распо- знавателя может потребовать каких-то манипуляций с заданной грамматикой, необходимых для ее преобразования к требуемому классу. При этом нередко грамматика становится неестественной и мало понятной, что в дальнейшем затрудняет ее использование в схеме синтаксически управляемого перевода и трансляции на этапе генерации результирующего кода (см. главу «Генерация и оптимизация кода»). Поэтому часто бывает удобным использовать исходную грамматику такой, какая она есть, не стремясь преобразовать ее к более просто- му классу. В целом следует отметить, что с учетом всего сказанного выше, интерес пред- ставляют как левосторонний, так и правосторонний анализ. Конкретный выбор зависит от реализации конкретного компилятора, а также от сложности грамма- тики входного языка программирования. Отношения между классами КС-грамматик В данном учебном пособии было рассмотрено несколько основных классов КС- грамматик, для которых существуют линейные распознаватели, — это LL-грам- матики, LR-грамматики и грамматики предшествования. Не все они были рас- смотрены достаточно подробно, к тому же этими классами далеко не исчерпыва- ется список всех известных КС-грамматик такого рода. Можно еще, например, упомянуть класс грамматик ограниченного правого кон- текста (ш,п) — ОПК(т,п). Это грамматики, допускающие построение распознава- теля, основанного на алгоритме «сдвиг-свертка», в котором однозначный выбор между сдвигом и сверткой делается исходя из анализа m символов, находящихся на верхушке стека, и п текущих символов входной цепочки, считая от положе- ния считывающей головки расширенного МП-автомата [6, т. 1]. Все ОПК(т,п)- грамматики для всех значений шип составляют класс ОПК-грамматик. Про- стейшим вариантом грамматик такого класса являются ОПК(1,1)-грамматики. Интересно, что с помощью этих грамматик, как и с помощью LR-грамматик, можно определить любой детерминированный КС-язык.
522 Глава 12. Классы КС-языков и грамматик Далее в этом пункте будут рассмотрены различные классы КС-грамматик и су- ществующие между ними нетривиальные соотношения. В общем случае можно выделить правоанализируемые и левоанализируемые КС- грамматики. Об этих двух принципиально разных классах грамматик уже гово- рилось выше: первые предполагают построение левостороннего (восходящего) распознавателя, вторые — правостороннего (нисходящего). Это вовсе не значит, что для КС-языка, заданного, например, некоторой левоанализируемой грамма- тикой, невозможно построить расширенный МП-автомат, который порождает правосторонний вывод. Указанное разделение грамматик относится только к по- строению на их основе детерминированных МП-автоматов и детерминированных расширенных МП-автоматов. Только эти типы автоматов представляют интерес при создании компиляторов и анализе входных цепочек языков программирова- ния. Недетерминированные автоматы, порождающие как левосторонние, так и правосторонние выводы, можно построить в любом случае для языка заданного любой КС-грамматикой, но для создания компилятора такие автоматы интереса не представляют (см. раздел «Распознаватели КС-языков. Автоматы с магазин- ной памятью», глава И). На рис. 12.10 изображена условная схема, дающая представление о соотношении классов левоанализируемых и правоанализируемых КС-грамматик [5, 6, т. 2,42, 65]. Рис. 12.10. Соотношение классов левоанализируемых и правоанализируемых КС-грамматик Интересно, что классы левоанализируемых и правоанализируемых грамматик являются несопоставимыми. То есть существуют левоанализируемые КС-грам- матики, на основе которых нельзя построить детерминированный расширенный МП-автомат, порождающий правосторонний вывод; и наоборот — существуют правоанализируемые КС-грамматики, не допускающие построение МП-автома- та, порождающего левосторонний вывод. Конечно, существуют грамматики, под- падающие под оба класса и допускающие построение детерминированных авто- матов как с правосторонним, так и с левосторонним выводом. Следует помнить также, что все упомянутые классы КС-грамматик — это счет- ные, но бесконечные множества. Нельзя построить и рассмотреть все возмож- ные левоанализируемые грамматики или даже все возможные LL( ^-граммати- ки. Сопоставление классов КС-грамматик производится исключительно на основе анализа структуры их правил. Только на основании такого рода анализа произ- вольная КС-грамматика может быть отнесена в тот или иной класс (или не- сколько классов).
Соотношение классов КС-языков и КС-грамматик 523 Все это тем более интересно, если вспомнить, что рассмотренный в данном посо- бии класс левоанализируемых LL-грамматик является собственным подмноже- ством класса LR-грамматик: любая LL-грамматика является LR-грамматикой, но не наоборот — существуют LR-грамматики, которые не являются LL-грамма- тиками. Этот факт также нашел свое отражение в схеме на рис. 12.10. Значит, любая LL-грамматика является правоанализируемой, но существуют также и другие левоанализируемые грамматики, не попадающие в класс правоанализи- руемых грамматик. Для LL(k)-rpaMMaraK, составляющих класс LL-грамматик, интересна еще одна особенность: доказано, что всегда существует язык, который может быть задан LL(k)-rpaMMaraKoft для некоторого к > 0, но не может быть задан LL(k-l ^грам- матикой. Таким образом, все кк(к)-грамматики для всех к представляют опре- деленный интерес (другое дело, что распознаватели для них при больших значе- ниях к будут слишком сложны). Интересно, что проблема эквивалентности для двух LL(k)-грамматик разрешима. С другой стороны, для LR(k)-rpaMMaraK, составляющих класс LR-грамматик, доказано, что любой язык, заданный LR(k)-rpaMMaraKoft с k > 1, может быть за- дан LR(1)-грамматикой. То есть LR(k)-rpaMMaraKH с к > 1 интереса не представ- ляют. Однако доказательство существования LR(l)-rpaMMaraKH вовсе не означает, что такая грамматика всегда может быть построена (проблема преобразования КС-грамматик неразрешима). На рис. 12.11 условно показана связь между некоторыми классами КС-грамма- тик, упомянутых в данном пособии. Из этой схемы видно, например, что любая ОПК-грамматика является LR-грамматикой, а также любая LL-грамматика явля- ется LR-грамматикой, но не всякая LL-грамматика является LR(l)-rpaMMaraKoft. Рис. 12.11. Схема взаимосвязи некоторых классов КС-грамматик
524 Глава 12. Классы КС-языков и грамматик Если вспомнить, что любой детерминированный КС-язык может быть задан, например, Ы1(1)-грамматикой (или ОПК(1,1)-грамматикой), но в то же время, классы левоанализируемых и правоанализируемых грамматик несопоставимы, то напрашивается вывод: один и тот же детерминированный КС-язык может быть задан двумя или более несопоставимыми между собой грамматиками. Та- ким образом, можно вернуться к мысли о том, что проблема преобразования КС- грамматик неразрешима (на самом деле, конечно, наоборот: из неразрешимости проблемы преобразования КС-грамматик следует возможность задать один и тот же КС-язык двумя несопоставимыми грамматиками). Это, наверное, самый ин- тересный вывод, который можно сделать из сопоставления разных классов КС- грамматик. Отношения между классами КС-языков КС-язык называется языком некоторого класса КС-языков, если он может быть задан КС-грамматикой из данного класса КС-грамматик. Например, класс LL- языков составляют все языки, которые могут быть заданы с помощью LL-грам- матик. Соотношение классов КС-языков представляет определенный интерес, оно не сов- падает с соотношением классов КС-грамматик. Это связано с многократно уже упоминавшейся проблемой преобразования грамматик. Например, выше уже говорилось о том, что любой LL-язык является и ЕК(1)-языком — то есть язык, заданный LL-грамматикой, может быть задан также и ЕК(1)-грамматикой. Одна- ко не всякая LL-грамматика является при этом ЕК(1)-грамматикой и не всегда можно найти способ, как построить LR(1)-грамматику, задающую тот же самый язык, что и исходная LL-грамматика. На рис. 12.12 приведено соотношение между некоторыми известными классами КС-языков [6, т. 2, 42, 47]. КС-языки Детерминированные КС-языки LR(1 )-языки=1И-языки (1,1 )ОПК-языки^О ПК-языки Языки простого предшествования LL-языки Языки операторного предшествования Рис. 12.12. Соотношение между различными классами КС-языков Следует обратить внимание прежде всего на то, что интересующий разработ- чиков компиляторов в первую очередь класс детерминированных КС-языков
Контрольные вопросы и задачи 525 полностью совпадает с классом LR-языков и, более того, совпадает с классом ЬК(1)-языков. То есть доказано, что для любого детерминированного КС-языка существует задающая его ВК(1)-грамматика. Этот факт уже упоминался выше. Проблема состоит в том, что не всегда возможно найти такую грамматику и нет формализованного алгоритма, как ее построить в общем случае. То же самое от- носится к упоминавшимся здесь ОПК-грамматикам и ОПК(1,1)-грамматикам. Также уже упоминалось, что LL-языки являются собственным подмножеством LR-языков: всякий LL-язык является одновременно LR-языком, но существуют LR-языки, которые не являются LL-языками. Поэтому LL-языки образуют более узкий класс, чем LR-языки. Языки простого предшествования, в свою очередь, также являются собственным подмножеством LR-языков, а языки операторного предшествования — собствен- ным подмножеством языков простого предшествования. Интересно, что языки операторного предшествования представляют собой более узкий класс, чем язы- ки простого предшествования. В то же время языки простого предшествования и LL-языки несопоставимы ме- жду собой: существуют языки простого предшествования, которые не являются LL-языками, и в то же время существуют LL-языки, которые не являются языка- ми простого предшествования. Однако существуют языки, которые одновремен- но являются и языками простого предшествования, и LL-языками. Аналогичное замечание относится также к соотношению между собой языков операторного предшествования и LL-языков. Можно еще отметить, что язык арифметических выражений над символами а и Ь, заданный грамматикой G({+,-,/,* a.b},{S,T,E}.P,S), Р = {S-»S+T|S-T|T, Т->Т*Е|Т/Е|Е, E->(S)|a|b}, который многократно использовался в примерах в данном учебном пособии, подпадает под все указанные выше классы языков. Из приведенных ра- нее по всей главе 3 примеров можно заключить, что этот язык является и LL- языком, и языком операторного предшествования, а следовательно, и языком простого предшествования и, конечно, ВК(1)-языком. В то же время этот язык по мере изложения материала пособия описывался различными грамматиками, не все из которых могут быть отнесены в указанные классы. Более того, в главе 1 он был задан с помощью грамматики, которая не являлась даже однозначной. Таким образом, соотношение классов КС-языков не совпадает с соотношением задающих их классов КС-грамматик. Это связано с неразрешимостью проблем преобразования и эквивалентности грамматик, которые не имеют строго форма- лизованного решения. Контрольные вопросы и задачи Вопросы 1. На алгоритме какого распознавателя основан метод рекурсивного спуска? В чем заключается принципиальное изменение, внесенное в алгоритм? Ведет ли это изменение к ограничению применимости данного алгоритма?
526 Глава 12. Классы КС-языков и грамматик 2. Какие преобразования возможно выполнить над грамматикой, чтобы к ней был применим метод рекурсивного спуска? Можно ли формализовать и авто- матизировать выполнение этих преобразований? 3. За счет чего можно расширить применимость метода рекурсивного спуска? Как влияет рекурсия, присутствующая в грамматике, на применимость дан- ного метода? Можно ли реализовать распознаватель по методу расширенного рекурсивного спуска для грамматики, содержащей левую рекурсию? 4. За счет чего класс ЬЬ(1)-грамматик является более широким, чем класс КС- грамматик, для которых можно построить распознаватель по методу рекур- сивного спуска? 5. На каком алгоритме основана работа распознавателя для ЬЬ(к)-грамматики? Какие изменения внесены в алгоритм? Насколько они сузили применимость данного алгоритма? 6. Объясните работу алгоритмов построения множеств FIRST(1,A) и FOLLOW(1,A). Можно ли эти алгоритмы тривиально распространить на мно- жества FIRST(k,A) и FOLLOW(k,A)? Если нет, то почему? 7. Почему класс языков, заданных LR-грамматиками, является более широким, чем класс языков, заданных LL-грамматиками? 8. На каком алгоритме основана работа распознавателя для LR(k)-грамматики? Какие изменения внесены в алгоритм? Насколько они сузили применимость данного алгоритма? 9. Почему не существует языков, заданных ЬЦО)-грамматиками, но существу- ют языки, заданные ЬК(О)-грамматиками? 10. Почему можно утверждать, что любая LL-грамматика и любая LR-граммати- ка являются однозначными? И. В чем особенности реализации алгоритма типа «сдвиг-свертка» для грамма- тик простого и операторного предшествования? Какие ограничения эти осо- бенности накладывают на правила грамматики? 12. Почему в грамматике простого предшествования не могут присутствовать цепные правила, а в грамматике операторного предшествования — могут? По- чему в обоих классах грамматик не могут присутствовать Х-правила? 13. Распознаватели на основе грамматик операторного предшествования имеют массу преимуществ перед распознавателями на основе грамматик простого предшествования. Почему же тем не менее используются оба класса КС- грамматик? 14. Класс языков, заданных ЕК(1)-грамматиками, совпадает с классом детерми- нированных КС-языков. Зачем же тогда используются другие классы грамма- тик? 15. Как объяснить тот факт, что любой язык, заданный LL-грамматикой, может быть задан и LR(1)-грамматикой, но не всякая LL-грамматика при этом явля- ется ЕК(1)-грамматикой? 16. Почему любая LL-грамматика является LR-грамматикой?
Контрольные вопросы и задачи 527 Задачи 1. Постройте распознаватель, основанный на методе рекурсивного спуска для грамматики: G({a,b,с}, {S.A.B.C}, Р, S) Р: S -> аАВЬ ЬВАа | сСс А —> аА | ЬВ | сС В -> b | аАС С -> аА | ЬА | сС выполните разбор цепочки символов «aabbabbcabbb». 2. Постройте распознаватель, основанный на расширенном методе рекурсивно- го спуска для грамматики: G( {а, {S,T,E}, Р, S) Р: s _> Т<Т | Т>Т I Т<=Т I т>=т Т -» Т+Е I Т-Е I Е Е -> а*а | а/а | а выполните разбор цепочки символов «а*а+а<а/а-а*а». 3. Дана грамматика G({ a}, {S,R,T,G,E,F}, Р, S) Р: S -> Т | TR r _> АТ | ATR Т -> Е | EG G -> &Е | &EG Е -Е | F F -> (S) | а О докажите, что она не является ЬЦ1)-грамматикой; О преобразуйте данную грамматику к виду ЬЬ(1)-грамматики; О постройте распознаватель на основе полученной грамматики и выполните разбор цепочки символов: «аЛа&~а&(аЛ—а)». 4. Постройте распознаватель на основе грамматики простого предшествования для грамматики, заданной в задаче № 3. Выполните разбор цепочки симво- лов, указанной в задаче № 3. 5. Дана грамматика G({(,a}, {S.T.E.F}, Р, S) Р: S -> SAT I т Т -> Т&Е | Е Е -> ~Е | F F -> (S) | а постройте для нее распознаватель на основе грамматики операторного пред- шествования. Выполните разбор цепочки символов, указанной в задаче № 3. 6. Придумайте функцию линеаризации матрицы предшествования для грамма- тики, заданной в задаче № 5.
528 Глава 12. Классы КС-языков и грамматик 7. Дана грамматика: G({«if»,«then»,«else»,a,b}, {S,T,E,FJ, P, S) P: S -> if b then T else S | if b then S | a T -> if b then T else S | a постройте для нее распознаватель на основе грамматики операторного пред- шествования, считая «if», «then» и «else» едиными терминальными символа- ми. Является ли данная грамматика однозначной? Выполните разбор цепоч- ки символов «if b then if b then if b then a else а». К первому или к последнему «if» будет отнесено «else» в этой цепочке? 8. Перестройте правила грамматики, данной в задаче № 7 так, чтобы «else» все- гда относилось к объемлющему «if», а не к ближайшему. 9. Придумайте функцию линеаризации матрицы предшествования для грамма- тики, заданной в задаче № 7.
ГЛАВА 13 Основные принципы построения трансляторов Выше были рассмотрены теоретические вопросы, связанные с теорией формаль- ных языков и грамматик. В этом разделе пойдет речь о том, как эти теоретиче- ские аспекты применяются на практике при построении трансляторов. Трансляторы, компиляторы и интерпретаторы — общая схема работы Определение транслятора, компилятора, интерпретатора Для начала дадим несколько определений — что же все-таки такое есть уже мно- гократно упоминавшиеся трансляторы и компиляторы. Формальное определение транслятора Транслятор — это программа, которая переводит входную программу на исход- ном (входном) языке в эквивалентную ей выходную программу на результирую- щем (выходном) языке. В этом определении слово «программа» встречается три раза, и это не ошибка и не тавтология. В работе транслятора, действительно, уча- ствуют всегда три программы.
530 Глава 13. Основные принципы построения трансляторов Во-первых, сам транслятор является программой1 — обычно он входит в состав системного программного обеспечения вычислительной системы. То есть транс- лятор — это часть программного обеспечения (ПО), он представляет собой на- бор машинных команд и данных и выполняется компьютером, как и все прочие программы в рамках операционной системы (ОС). Все составные части трансля- тора представляют собой фрагменты или модули программы со своими входны- ми и выходными данными. Во-вторых, исходными данными для работы транслятора служит текст входной программы — некоторая последовательность предложений входного языка про- граммирования. Обычно это символьный файл, но этот файл должен содержать текст программы, удовлетворяющий синтаксическим и семантическим требова- ниям входного языка. Кроме того, этот файл несет в себе некоторый смысл, оп- ределяемый семантикой входного языка. В-третьих, выходными данными транслятора является текст результирующей программы. Результирующая программа строится по синтаксическим правилам, заданным в выходном языке транслятора, а ее смысл определяется семантикой выходного языка. Важным требованием в определении транслятора является эк- вивалентность входной и выходной программ. Эквивалентность двух программ означает совпадение их смысла с точки зрения семантики входного языка (для исходной программы) и семантики выходного языка (для результирующей про- граммы). Без выполнения этого требования сам транслятор теряет всякий прак- тический смысл. Итак, чтобы создать транслятор, необходимо прежде всего выбрать входной и выходной языки. С точки зрения преобразования предложений входного язы- ка в эквивалентные им предложения выходного языка транслятор выступает как переводчик. Например, трансляция программы с языка С в язык ассемблера по сути ничем не отличается от перевода, скажем, с русского языка на английский, с той только разницей, что сложность языков несколько иная (почему не суще- ствует трансляторов с естественных языков — см. раздел «Классификация язы- ков и грамматик», глава 9). Поэтому и само слово «транслятор» (английское: translator) означает «переводчик». * Результатом работы транслятора будет результирующая программа, но только в том случае, если текст исходной программы является правильным — не со- держит ошибок с точки зрения синтаксиса и семантики входного языка. Если исходная программа неправильная (содержит хотя бы одну ошибку), то резуль- татом работы транслятора будет сообщение об ошибке (как правило, с допол- нительными пояснениями и указанием места ошибки в исходной программе). В этом смысле транслятор сродни переводчику, например, с английского, кото- рому подсунули неверный текст. 1 Теоретически возможна реализация транслятора с помощью аппаратных средств. Автору встречались такого рода разработки, однако широкое практическое применение их не из- вестно. В таком случае и все составные части транслятора могут быть реализованы в виде аппаратных средств и их фрагментов — вот тогда схема распознавателя может получить вполне практическое воплощение!
Трансляторы, компиляторы и интерпретаторы — общая схема работы 531 Определение компилятора. Отличие компилятора от транслятора Кроме понятия «транслятор» широко употребляется также близкое ему по смыс- лу понятие «компилятор». Компилятор — это транслятор, который осуществляет перевод исходной програм- мы в эквивалентную ей объектную программу на языке машинных команд или на языке ассемблера. Таким образом, компилятор отличается от транслятора лишь тем, что его ре- зультирующая программа всегда должна быть написана на языке машинных ко- дов или на языке ассемблера. Результирующая программа транслятора, в общем случае, может быть написана на любом языке — возможен, например, транслятор программ с языка Pascal на язык С. Соответственно, всякий компилятор являет- ся транслятором, но не наоборот — не всякий транслятор будет компилятором. Например, упомянутый выше транслятор с языка Pascal на С компилятором яв- ляться не будет1. Само слово «компилятор» происходит от английского термина «compiler» («со- ставитель», «компоновщик»). Видимо, термин обязан своему происхождению способности компиляторов составлять объектные программы на основе исход- ных программ. Результирующая программа компилятора называется «объектной программой» или «объектным кодом». Файл, в который она записана, обычно называется «объ- ектным файлом». Даже в том случае, когда результирующая программа порож- дается на языке машинных команд, между объектной программой (объектным файлом) и исполняемой программой (исполняемым файлом) есть существенная разница. Порожденная компилятором программа не может непосредственно выполняться на компьютере, так как она не привязана к конкретной области па- мяти, где должны располагаться ее код и данные (более подробно — см. раздел «Принципы функционирования систем программирования», глава 15)2. Компиляторы, безусловно, самый распространенный вид трансляторов (многие считают их вообще единственным видом трансляторов, хотя это не так). Они име- ют самое широкое практическое применение, которым обязаны широкому рас- пространению всевозможных языков программирования. Далее всегда будем говорить о компиляторах, подразумевая, что выходная программа написана на 1 В некоторых литературных источниках эти два понятия не разделяют между собой, хотя разница между ними все-таки существует. 2 Здесь есть еще один «подводный камень», связанный с терминологической путаницей. Термины «объектный код» и «объектный файл» возникли достаточно давно. Первона- чально с ними не было связано никаких проблем. Однако сейчас появилось понятие «объектно-ориентированное программирование» (а также анализ и проектирование), где под термином «объект» подразумевается совершенно иное, нежели в «объектном коде». Следует помнить, что «объектный код» никакого отношения к «объекту» с точки зрения объектно-ориентированного программирования не имеет! И хотя в результате компиля- ции программ, написанных на объектно-ориентированных языках, тоже получаются объ- ектный код и объектные файлы, это никак не связывает между собой совершенно различные по смыслу термины.
532 Глава 13. Основные принципы построения трансляторов языке машинных кодов или языке ассемблера (если это не так, то это будет спе- циально указываться отдельно)1. Естественно, трансляторы и компиляторы, как и все прочие программы, разраба- тывает человек (люди) — обычно это группа разработчиков. В принципе они могли бы создавать его непосредственно на языке машинных команд, однако объем кода и данных современных компиляторов таков, что их создание на язы- ке машинных команд практически невозможно в разумные сроки при разумных трудозатратах. Поэтому практически все современные компиляторы также соз- даются с помощью компиляторов (обычно в этой роли выступают предыдущие версии компиляторов той же фирмы-производителя). И в этом качестве ком- пилятор является уже выходной программой для другого компилятора, которая ничем не лучше и не хуже всех прочих порождаемых выходных программ2. Определение интерпретатора. Разница между интерпретаторами и трансляторами Кроме схожих между собой понятий «транслятор» и «компилятор» существует принципиально отличное от них понятие интерпретатора. Интерпретатор — это программа, которая воспринимает входную программу на исходном языке и выполняет ее. В отличие от трансляторов интерпретаторы не порождают результирующую про- грамму (и вообще какого-либо результирующего кода) — и в этом принципиаль- ная разница между ними. Интерпретатор, так же как и транслятор, анализирует текст исходной программы. Однако он не порождает результирующей программы, а сразу же выполняет исходную в соответствии с ее смыслом, заданным семанти- кой входного языка. Таким образом, результатом работы интерпретатора будет результат, заданный смыслом исходной программы, в том случае, если эта про- грамма правильная, или сообщение об ошибке, если исходная программа неверна. Конечно, чтобы исполнить исходную программу, интерпретатор так или иначе должен преобразовать ее в язык машинных кодов, поскольку иначе выполнение программ на компьютере невозможно. Он и делает это, однако полученные ма- шинные коды не являются доступными — их не видит пользователь интерпрета- тора. Эти машинные коды порождаются интерпретатором, исполняются и унич- 1 Следует особо упомянуть, что сейчас в современных системах программирования стали появляться компиляторы, в которых результирующая программа создается не на языке машинных команд и не на языке ассемблера, а на некотором промежуточном языке. Сам по себе этот промежуточный язык не может непосредственно исполняться на компьюте- ре, а требует специального промежуточного интерпретатора для выполнения написан- ных на нем программ. Хотя в данном случае термин «транслятор» был бы, наверное, более правильным, в литературе употребляется понятие «компилятор», поскольку про- межуточный язык является языком очень низкого уровня, будучи родственным машин- ным командам и языкам ассемблера. 2 Здесь возникает извечный вопрос «о курице и яйце». Конечно, в первом поколении са- мые первые компиляторы писались непосредственно на машинных командах, но потом, с появлением компиляторов, от этой практики отошли. Даже самые ответственные части компиляторов создаются, как минимум, с применением языка ассемблера — а он тоже об- рабатывается компилятором.
Трансляторы, компиляторы и интерпретаторы — общая схема работы 533 тожаются по мере надобности — так, как того требует конкретная реализация интерпретатора. Пользователь же видит результат выполнения этих кодов — то есть результат выполнения исходной программы (требование об эквивалентно- сти исходной программы и порожденных машинных кодов и в этом случае, без- условно, должно выполняться). Более подробно вопросы, связанные с реализацией интерпретаторов и их отли- чием от компиляторов, рассмотрены далее в соответствующем разделе. Назначение трансляторов, компиляторов и интерпретаторов. Примеры реализации Первые программы, которые создавались еще для ЭВМ первого поколения, пи- сались непосредственно на языке машинных кодов. Это была поистине адская работа. Сразу стало ясно, что человек не должен и не может говорить на языке машинных команд, даже если он специалист по вычислительной технике. Одна- ко и все попытки научить компьютер говорить на языках людей успехом не увенчались и вряд ли когда-либо увенчаются (на что есть определенные объек- тивные причины, рассмотренные в первой главе этого пособия). С тех пор все развитие программного обеспечения компьютеров неразрывно свя- зано с возникновением и развитием компиляторов. Первыми компиляторами были компиляторы с языков ассемблера или, как они назывались, мнемокодов. Мнемокоды превратили «филькину грамоту» языка машинных команд в более-менее доступный пониманию специалиста язык мне- монических (преимущественно англоязычных) обозначений этих команд. Соз- давать программы уже стало значительно проще, но исполнять сам мнемокод (язык ассемблера) ни один компьютер неспособен, соответственно, возникла не- обходимость в создании компиляторов. Эти компиляторы элементарно просты, но они продолжают играть существенную роль в системах программирования по сей день. Более подробно о языке ассемблера и компиляторах с него рассказано далее в соответствующем разделе. Следующим этапом стало создание языков высокого уровня. Языки высокого уровня (к ним относится большинство языков программирования) представля- ют собой некоторое промежуточное звено между чисто формальными языками и языками естественного общения людей. От первых им досталась строгая фор- мализация синтаксических структуру предложений языка, от вторых — значи- тельная часть словарного запаса, семантика основных конструкций и выражений (с элементами математических операций, пришедшими из алгебры). Появление языков высокого уровня существенно упростило процесс программи- рования, хотя и не свело его до «уровня домохозяйки», как самонадеянно пола- гали некоторые авторы на заре рождения языков программирования1. Сначала 1 Вопрос о творчестве в процессе написания программ — вопрос спорный. Автор не скло- нен сводить процесс программирования к простому «кодированию», как это предполага- ют некоторые. Однако даже если и так, то творческий процесс выходит на другой уровень — на уровень постановки задачи, создание алгоритмов ее решения. В общем, этот процесс ориентирован на человека, на пользователя и без его участия тут не обойтись. Все системы искусственного интеллекта пока не продвинулись дальше уровня мухи.
534 Глава 13. Основные принципы построения трансляторов таких языков были единицы, затем десятки, сейчас, наверное, их насчитывается более сотни. Процессу этому не видно конца. Тем не менее по-прежнему преоб- ладают компьютеры традиционной, «неймановской», архитектуры, которые уме- ют понимать только машинные команды, поэтому вопрос о создании компилято- ров продолжает быть актуальным. Как только возникла массовая потребность в создании компиляторов, стала раз- виваться и специализированная теория. Со временем она нашла практическое приложение во множестве созданных компиляторов. Компиляторы создавались и продолжают создаваться не только для новых, но и для давно известных язы- ков. Многие производители от известных, солидных фирм (таких, как Microsoft или Inprise) до мало кому знакомых коллективов авторов выпускают на рынок все новые и новые образцы компиляторов. Это обусловлено рядом причин, кото- рые будут рассмотрены далее. Наконец, с тех пор как большинство теоретических аспектов в области ком- пиляторов получили свою практическую реализацию (а это, надо сказать, про- изошло довольно быстро, в конце 60-х годов), развитие компиляторов пошло по пути их дружественности человеку — пользователю, разработчику программ на языках высокого уровня. Логичным завершением этого процесса стало создание систем программирования — программных комплексов, объединяющих в себе кроме непосредственно компиляторов множество связанных с ними компонен- тов программного обеспечения. Появившись, системы программирования быстро завоевали рынок и ныне в массе своей преобладают на нем (фактически, обособ- ленные компиляторы — это редкость среди современных программных средств). О том, что представляют собой и как организованы современные системы про- граммирования, см. в главе «Современные системы программирования». Ныне компиляторы являются неотъемлемой частью любой вычислительной сис- темы. Без их существования программирование любой прикладной задачи было бы затруднено, а то и просто невозможно. Да и программирование специали- зированных системных задач, как правило, ведется если не на языке высокого уровня (в этой роли в настоящее время чаще всего применяется язык С), то на языке ассемблера, следовательно, применяется соответствующий компилятор. Программирование непосредственно на языках машинных кодов происходит ис- ключительно редко и только для решения очень узких вопросов. Несколько слов о примерах реализации компиляторов и интерпретаторов, а также о том, как они соотносятся с другими существующими программными средствами. Компиляторы, как будет показано далее, обычно несколько проще в реализации, чем интерпретаторы. По эффективности они также превосходят их — очевидно, что откомпилированный код будет исполняться всегда быстрее, чем происходит интерпретация аналогичной исходной программы. Кроме того, не каждый язык программирования допускает построение простого интерпретатора. Однако ин- терпретаторы имеют одно существенное преимущество — откомпилированный код всегда привязан к архитектуре вычислительной системы, на которую он ори- ентирован, а исходная программа — только к семантике языка программирова- ния, которая гораздо легче поддается стандартизации. Этот аспект первоначаль- но не принимали во внимание.
Трансляторы, компиляторы и интерпретаторы — общая схема работы 535 Первыми компиляторами были компиляторы с мнемокодов. Их потомки — со- временные компиляторы с языков ассемблера — существую практически для всех известных вычислительных систем. Они предельно жестко ориентированы на архитектуру. Затем появились компиляторы с таких языков, как FORTRAN, ALGOL-68, PL/1. Они были ориентированы на большие ЭВМ с пакетной обра- боткой задач. Из вышеперечисленных только FORTRAN, пожалуй, продолжает использоваться по сей день, поскольку имеет огромное количество библиотек различного назначения [7]. Многие языки, родившись, так и не получили широ- кого распространения — ADA, Modula, Simula известны лишь узкому кругу спе- циалистов. В то же время на рынке программных систем доминируют компиля- торы языков, которым не прочили светлого будущего. В первую очередь, сейчас это С и C++. Первый из них родился вместе с операционными системами типа UNIX, вместе с нею завоевал свое «место под солнцем», а затем перешел под ОС других типов. Второй удачно воплотил в себе пример реализации идей объектно- ориентированного программирования на хорошо зарекомендовавшей себя прак- тической базе1. Еще можно упомянуть довольно распространенный Pascal, кото- рый неожиданно для многих вышел за рамки чисто учебного языка для универ- ситетской среды. История интерпретаторов не столь богата (пока!). Как уже было сказано, изна- чально им не предавали существенного значения, поскольку почти по всем пара- метрам они уступают компиляторам. Из известных языков, предполагавших интерпретацию, можно упомянуть разве что Basic, хотя большинству сейчас из- вестна его компилируемая реализация Visual Basic, сделанная фирмой Microsoft [3, 63]. Тем не менее сейчас ситуация несколько изменилась, поскольку вопрос о переносимости программ и их аппаратно-платформенной независимости приоб- ретает все большую актуальность с развитием сети Интернет. Самый известный сейчас пример — это язык Java (сам по себе он сочетает компиляцию и интерпре- тацию), а также связанный с ним JavaScript. В конце концов, язык HTML, на ко- тором зиждется протокол HTTP, давший толчок столь бурному развитию Все- мирной сети, — это тоже интерпретируемый язык. По мнению автора, в области появления новых интерпретаторов всех еще ждут сюрпризы, и появились уже первые из них — например, язык C# («си-диез», но название везде идет как «Си шарп»), анонсируемый фирмой Microsoft. Об истории языков программирования и современном состоянии рынка компи- ляторов можно говорить долго и много. Автор считает возможным ограничиться уже сказанным, поскольку это не является целью данного пособия. Желающие могут обратиться к литературе [7, 8, 14, 23, 30, 45, 66, 77, 81]. Этапы трансляции. Общая схема работы транслятора На рис. 13.1 представлена общая схема работы компилятора. Из нее видно, что в целом процесс компиляции состоит из двух основных этапов — синтеза и анализа. 1 Назвать реализацию «удачной» можно, если судить по достигнутым результатам, а не по качеству заложенных в С ++ идей объектно-ориентированного подхода. Последний во- прос способен вызвать споры, которые выходят за рамки данного учебного пособия.
536 Глава 13. Основные принципы построения трансляторов Рис. 13.1. Общая схема работы компилятора На этапе анализа выполняется распознавание текста исходной программы, соз- дание и заполнение таблиц идентификаторов. Результатом его работы служит некое внутреннее представление программы, понятное компилятору. На этапе синтеза на основании внутреннего представления программы и инфор- мации, содержащейся в таблице (таблицах) идентификаторов, порождается текст результирующей программы. Результатом этого этапа является объектный код. Кроме того, в составе компилятора присутствует часть, ответственная за анализ и исправление ошибок, которая при наличии ошибки в тексте исходной про- граммы должна максимально полно информировать пользователя о типе ошиб- ки и месте ее возникновения. В лучшем случае компилятор может предложить пользователю вариант исправления ошибки. Эти этапы, в свою очередь, состоят из более мелких этапов, называемых фазами компиляции. Состав фаз компиляции приведен в самом общем виде, их конкрет- ная реализация и процесс взаимодействия могут, конечно, различаться в зави- симости от версии компилятора. Однако в том или ином виде все представлен- ные фазы практически всегда присутствуют в каждом конкретном компиляторе [26, 40]. Компилятор в целом с точки зрения теории формальных языков выступает в «двух ипостасях», выполняет две основные функции.
Трансляторы, компиляторы и интерпретаторы — общая схема работы 537 Во-первых, он является распознавателем для языка исходной программы. То есть он должен получить на вход цепочку символов входного языка, проверить ее принадлежность языку и, более того, выявить правила, по которым эта цепочка была построена (поскольку сам ответ на вопрос о принадлежности «да» или «нет» представляет мало интереса). Интересно, что генератором цепочек входно- го языка выступает пользователь — автор входной программы. Во-вторых, компилятор является генератором для языка результирующей про- граммы. Он должен построить на выходе цепочку выходного языка по опреде- ленным правилам, предполагаемым языком машинных команд или языком ас- семблера. Распознавателем этой цепочки будет выступать уже вычислительная система, под которую создается результирующая программа. Далее дается перечень основных фаз (частей) компиляции и краткое описание их функций. Более подробная информация дана в подразделах, соответствую- щих этим фазам. Лексический анализ (сканер) — это часть компилятора, которая читает литеры программы на исходном языке и строит из них слова (лексемы) исходного язы- ка. На вход лексического анализатора поступает текст исходной программы, а выходная информация передается для дальнейшей обработки компилятором на этапе синтаксического разбора. С теоретической точки зрения лексический анализатор не является обязательной, необходимой частью компилятора. Одна- ко существует причины, которые определяют его присутствие практически во всех компиляторах. Более подробно см. раздел «Лексические анализаторы (ска- неры). Принципы построения сканеров». Синтаксический разбор — это основная часть компилятора на этапе анализа. Она выполняет выделение синтаксических конструкций в тексте исходной програм- мы, обработанном лексическим анализатором. На этой же фазе компиляции проверяется синтаксическая правильность программы. Синтаксический разбор играет главную роль — роль распознавателя текста входного языка программи- рования (см. раздел «Синтаксические анализаторы. Синтаксически управляе- мый перевод» этой главы). Семантический анализ — это часть компилятора, проверяющая правильность текста исходной программы с точки зрения семантики входного языка. Кроме непосредственно проверки, семантический анализ должен выполнять преобра- зования текста, требуемые семантикой входного языка (такие, как добавление функций неявного преобразования типов). В различных реализациях компиля- торов семантический анализ может частично входить в фазу синтаксического разбора, частично — в фазу подготовки к генерации кода. Подготовка к генерации кода — это фаза, на которой компилятором выполняют- ся предварительные действия, непосредственно связанные с синтезом текста ре- зультирующей программы, но еще не ведущие к порождению текста на выход- ном языке. Обычно в эту фазу входят действия, связанные с идентификацией элементов языка, распределением памяти и т. п. (см. раздел «Семантический анализ и подготовка к генерации кода», глава 14). Генерация кода — это фаза, непосредственно связанная с порождением команд, составляющих предложения выходного языка и в целом текст результирующей
538 Глава 13. Основные принципы построения трансляторов программы. Это основная фаза на этапе синтеза результирующей программы. Кроме непосредственного порождения текста результирующей программы, гене- рация обычно включает в себя также оптимизацию — процесс, связанный с обра- боткой уже порожденного текста. Иногда оптимизацию выделяют в отдельную фазу компиляции, так как она оказывает существенное влияние на качество и эффективность результирующей программы (см. разделы «Генерация кода. Ме- тоды генерации кода» и « Оптимизация кода. Основные методы оптимизации», глава 14). Таблицы идентификаторов (иногда — «таблицы символов») — это специальным образом организованные наборы данных, служащие для хранения информации об элементах исходной программы, которые затем используются для порожде- ния текста результирующей программы. Таблица идентификаторов в конкрет- ной реализации компилятора может быть одна, или же таких таблиц может быть несколько. Элементами исходной программы, информацию о которых нужно хра- нить в процессе компиляции, являются переменные, константы, функции и т. п. — конкретный состав набора элементов зависит от используемого входного языка программирования. Понятие «таблицы» вовсе не предполагает, что это хранилище данных должно быть организовано именно в виде таблиц или других массивов информации — возможные методы их организации подробно рассмотрены далее, в разделе «Таблицы идентификаторов. Организация таблиц идентификаторов». Представленное на рис. 13.1 деление процесса компиляции на фазы служит ско- рее методическим целям и на практике может не соблюдаться столь строго. Да- лее в подразделах этого пособия рассматриваются различные варианты техниче- ской организации представленных фаз компиляции. При этом указано, как они могут быть связаны между собой. Здесь рассмотрим только общие аспекты тако- го рода взаимосвязи. Во-первых, на фазе лексического анализа лексемы выделяются из текста вход- ной программы постольку, поскольку они необходимы для следующей фазы син- таксического разбора. Во-вторых, как будет показано ниже, синтаксический раз- бор и генерация кода могут выполняться одновременно. Таким образом, эти три фазы компиляции могут работать комбинированно, а вместе с ними может вы- полняться и подготовка к генерации кода. Далее рассмотрены технические во- просы реализации основных фаз компиляции, которые тесно связаны с поняти- ем прохода. Понятие прохода. Многопроходные и однопроходные компиляторы Как уже было сказано, процесс компиляции программ состоит из нескольких фаз. В реальных компиляторах состав этих фаз может несколько отличаться от рас- смотренного выше — некоторые из них могут быть разбиты на составляющие, другие, напротив, объединены в одну фазу. Порядок выполнения фаз компиля- ции также может меняться в разных вариантах компиляторов. В одном случае компилятор просматривает текст исходной программы, сразу выполняет все фазы компиляции и получает результат — объектный код. В другом варианте он вы- полняет над исходным текстом только некоторые из фаз компиляции и получает
Трансляторы, компиляторы и интерпретаторы — общая схема работы 539 не конечный результат, а набор некоторых промежуточных данных. Эти данные затем снова подвергаются обработке, причем этот процесс может повторяться несколько раз. Реальные компиляторы, как правило, выполняют трансляцию текста исходной программы за несколько проходов. Проход — это процесс последовательного чтения компилятором данных из внеш- ней памяти, их обработки и помещения результата работы во внешнюю память. Чаще всего один проход включает в себя выполнение одной или нескольких фаз компиляции. Результатом промежуточных проходов является внутреннее пред- ставление исходной программы, результатом последнего прохода — результи- рующая объектная программа. В качестве внешней памяти могут выступать любые носители информации — оперативная память компьютера, накопители на магнитных дисках, магнитных лентах и т. п. Современные компиляторы, как правило, стремятся максимально использовать для хранения данных оперативную память компьютера, и только при недостатке объема доступной памяти используются накопители на жестких магнитных дисках. Другие носители информации в современных компиляторах не используются из-за невысокой скорости обмена данными. При выполнении каждого прохода компилятору доступна информация, полу- ченная в результате всех предыдущих проходов. Как правило, он стремится ис- пользовать в первую очередь только информацию, полученную на проходе, не- посредственно предшествовавшем текущему, но в принципе может обращаться и к данным от более ранних проходов вплоть до исходного текста программы. Информация, получаемая компилятором при выполнении проходов, недоступна пользователю. Она либо хранится в оперативной памяти, которая освобождает- ся компилятором после завершения процесса трансляции, либо оформляется в виде временных файлов на диске, которые также уничтожаются после завер- шения работы компилятора. Поэтому человек, работающий с компилятором, мо- жет даже не знать, сколько проходов выполняет компилятор — он всегда видит только текст исходной программы и результирующую объектную программу. Но количество выполняемых проходов — это важная техническая характеристика компилятора, солидные фирмы — разработчики компиляторов обычно указыва- ют ее в описании своего продукта. Понятно, что разработчики стремятся максимально сократить количество про- ходов, выполняемых компиляторами. При этом увеличивается скорость работы компилятора, сокращается объем необходимой ему памяти. Однопроходный ком- пилятор, получающий на вход исходную программу и сразу же порождающий результирующую объектную программу, — это идеальный вариант. Однако сократить число проходов не всегда удается. Количество необходимых проходов определяется прежде всего грамматикой и семантическими правилами исходного языка. Чем сложнее грамматика языка и чем больше вариантов пред- полагают семантические правила — тем больше проходов будет выполнять ком- пилятор (конечно, играет свою роль и квалификация разработчиков компилято- ра). Например, именно поэтому обычно компиляторы с языка Pascal работают быстрее, чем компиляторы с языка С — грамматика языка Pascal более проста, а семантические правила более жесткие.
540 Глава 13. Основные принципы построения трансляторов Однопроходные компиляторы — редкость, они возможны только для очень про- стых языков. Реальные компиляторы выполняют, как правило, от двух до пяти проходов. Таким образом, реальные компиляторы являются многопроходными. Наиболее распространены двух- и трехпроходные компиляторы, например: пер- вый проход — лексический анализ, второй — синтаксический разбор и семанти- ческий анализ, третий — генерация и оптимизация кода (варианты исполнения, конечно, зависят от разработчика). В современных системах программирования нередко первый проход компилятора (лексический анализ кода) выполняется параллельно с редактированием кода исходной программы (такой вариант по- строения компиляторов рассмотрен далее в этой главе). Интерпретаторы. Особенности построения интерпретаторов Интерпретатор — это программа, которая воспринимает входную программу на исходном языке и выполняет ее. Как уже было сказано выше, основное отличие интерпретаторов от трансляторов и компиляторов заключается в том, что интер- претатор не порождает результирующую программу, а просто выполняет исход- ную программу. Термин «интерпретатор» (interpreter), как и «транслятор», означает «перевод- чик». С точки зрения терминологии эти понятия схожи, но с точки зрения тео- рии формальных языков и компиляции между ними большая принципиальная разница. Если понятия «транслятор» и «компилятор» почти неразличимы, то с понятием «интерпретатор» их путать никак нельзя. Простейшим способом реализации интерпретатора можно было бы считать ва- риант, когда исходная программа сначала полностью транслируется в машинные команды, а затем сразу же выполняется. В такой реализации интерпретатор, по сути, мало бы чем отличался от компилятора с той лишь разницей, что результи- рующая программа в нем была бы недоступна пользователю. Недостатком тако- го интерпретатора было бы то, что пользователь должен был бы ждать компиля- ции всей исходной программы прежде, чем начнется ее выполнение. По сути, в таком интерпретаторе не было бы никакого особого смысла — он не давал бы никаких преимуществ по сравнению с аналогичным компилятором1. Поэтому подавляющее большинство интерпретаторов действует так, что испол- няет исходную программу последовательно, по мере ее поступления на вход ин- терпретатора. Тогда пользователю не надо ждать завершения компиляции всей исходной программы. Более того, он может последовательно вводить исходную программу и тут же наблюдать результат ее выполнения по мере ввода команд [82]. При таком порядке работы интерпретатора проявляется существенная особен- ность, которая отличает его от компилятора, — если интерпретатор исполняет команды по мере их поступления, то он не может выполнять оптимизацию ис- ходной программы. Следовательно, фаза оптимизации в общей структуре интер- 1 Иногда такая схема используется в современных системах программирования при отлад- ке программ — более подробно см. главу «Современные системы программирования».
Трансляторы, компиляторы и интерпретаторы — общая схема работы 541 претатора будет отсутствовать. В остальном же она будет мало отличаться от структуры аналогичного компилятора. Следует только учесть, что на последнем этапе — генерации кода — машинные команды не записываются в объектный файл, а выполняются по мере их порождения. Отсутствие шага оптимизации определяет еще одну особенность, характерную для многих интерпретаторов: в качестве внутреннего представления программы в них очень часто используется обратная польская запись (см. раздел «Генера- ция кода. Методы генерации кода», глава 14). Эта удобная форма представления операций обладает только одним существенным недостатком — она плохо подда- ется оптимизации. Но в интерпретаторах именно это как раз и не требуется. Далеко не все языки программирования допускают построение интерпретаторов, которые могли бы выполнять исходную программу по мере поступления команд. Для этого язык должен допускать возможность существования компилятора, выполняющего разбор исходной программы за один проход. Кроме того, язык не может интерпретироваться по мере поступления команд, если он допускает по- явление обращений к функциям и структурам данных раньше их непосредствен- ного описания. Поэтому данным методом не могут интерпретироваться такие языки, как С и Pascal. Отсутствие шага оптимизации ведет к тому, что выполнение программы с помо- щью интерпретатора является менее эффективным, чем с помощью аналогично- го компилятора. Кроме того, при интерпретации исходная программа должна за- ново разбираться всякий раз при ее выполнении, в то время как при компиляции она разбирается только один раз, а после этого всегда используется объектный файл. Таким образом, интерпретаторы всегда проигрывают компиляторам в про- изводительности. Преимуществом интерпретатора является независимость выполнения програм- мы от архитектуры целевой вычислительной системы. В результате компиляции получается объектный код, который всегда ориентирован на определенную архи- тектуру. Для перехода на другую архитектуру целевой вычислительной системы программу требуется откомпилировать заново. А для интерпретации программы необходимо иметь только ее исходный текст и интерпретатор с соответствующе- го языка. Интерпретаторы долгое время значительно уступали в распространенности ком- пиляторам. Как правило, интерпретаторы существовали для ограниченного кру- га относительно простых языков программирования (таких, например, как Basic). Высокопроизводительные профессиональные средства разработки программно- го обеспечения строились на основе компиляторов. Новый импульс развитию интерпретаторов придало распространение глобаль- ных вычислительных сетей. Такие сети могут включать в свой состав ЭВМ раз- личной архитектуры, и тогда требование единообразного выполнения на каждой из них текста исходной программы становится определяющим. Поэтому с разви- тием глобальных сетей и распространением всемирной сети Интернет появилось много новых систем, интерпретирующих текст исходной программы. Многие языки программирования, применяемые во Всемирной сети, предполагают имен- но интерпретацию текста исходной программы без порождения объектного кода.
542 Глава 13. Основные принципы построения трансляторов В современных системах программирования существуют реализации программ- ного обеспечения, сочетающие в себе и функции компилятора, и функции интер- претатора — в зависимости от требований пользователя исходная программа либо компилируется, либо исполняется (интерпретируется). Кроме того, некото- рые современные языки программирования предполагают две стадии разработ- ки: сначала исходная программа компилируется в промежуточный код (некото- рый язык низкого уровня), а затем этот результат компиляции выполняется с помощью интерпретатора данного промежуточного языка. Более подробно вари- анты таких систем рассмотрены в главе «Современные системы программирова- ния». Широко распространенным примером интерпретируемого языка может служить HTML (Hypertext Markup Language) — язык описания гипертекста. На его осно- ве в настоящее время функционирует практически вся структура сети Интернет. Другой пример — языки Java и JavaScript — сочетают в себе функции компиля- ции и интерпретации. Текст исходной программы компилируется в некоторый промежуточный двоичный код, не зависящий от архитектуры целевой вычисли- тельной системы, этот код распространяется по сети и выполняется на прини- мающей стороне — интерпретируется. Трансляторы с языка ассемблера («ассемблеры») Язык ассемблера — это язык низкого уровня. Структура и взаимосвязь цепочек этого языка близки к машинным командам целевой вычислительной системы, где должна выполняться результирующая программа. Применение языка ассемб- лера позволяет разработчику управлять ресурсами (процессором, оперативной памятью, внешними устройствами и т. п.) целевой вычислительной системы на уровне машинных команд. Каждая команда исходной программы на языке ас- семблера в результате компиляции преобразуется в одну машинную команду. Транслятор с языка ассемблера всегда, безусловно, будет и компилятором, по- скольку языком результирующей программы являются машинные коды. Транс- лятор с языка ассемблера зачастую просто называют «ассемблер» или «програм- ма ассемблера». Реализация компиляторов с языка ассемблера Язык ассемблера, как правило, содержит мнемонические коды машинных ко- манд. Чаще всего используется англоязычная мнемоника команд, но существуют и другие варианты языков ассемблера (в том числе существуют и русскоязыч- ные варианты). Именно поэтому язык ассемблера раньше носил названия «язык мнемокодов» (сейчас это название уже практически не употребляется). Все воз- можные команды в каждом языке ассемблера можно разбить на две группы: в первую группу входят обычные команды языка, которые в процессе трансля- ции преобразуются в машинные команды; вторую группу составляют специаль- ные команды языка, которые в машинные команды не преобразуются, но ис- пользуются компилятором для выполнения задач компиляции (таких, например, как задача распределения памяти).
Трансляторы, компиляторы и интерпретаторы — общая схема работы 543 Синтаксис языка чрезвычайно прост. Команды исходной программы записыва- ются обычно таким образом, чтобы на одной строке программы располагалась одна команда. Каждая команда языка ассемблера, как правило, может быть раз- делена на три составляющих, следующих последовательно одна за другой: поле метки, код операции и поле операндов. Компилятор с языка ассемблера обычно предусматривает и возможность наличия во входной программе комментариев, которые отделяются от команд заданным разделителем [93]. Поле метки содержит идентификатор, представляющий собой метку, либо явля- ется пустым. Каждый идентификатор метки может встречаться в программе на языке ассемблера только один раз. Метка считается описанной там, где она не- посредственно встретилась в программе (предварительное описание меток не требуется). Метка может быть использована для передачи управления на поме- ченную ею команду. Нередко метка отделяется от остальной части команды спе- циальным разделителем (чаще всего — двоеточием «:»). Код операции всегда представляет собой строго определенную мнемонику одной из возможных команд процессора или также строго определенную команду са- мого компилятора. Код операции записывается алфавитными символами вход- ного языка. Чаще всего его длина составляет 3-4, реже — 5 или 6 символов. Поле операндов либо является пустым, либо представляет собой список из одно- го, двух, реже — трех операндов. Количество операндов строго определено и за- висит от кода операции — каждая операция языка ассемблера предусматривает жестко заданное число своих операндов. Соответственно, каждому из этих вари- антов соответствуют безадресные, одноадресные, двухадресные или трехадресные команды (большее число операндов практически не используется, в современ- ных ЭВМ даже трехадресные команды встречаются редко). В качестве операн- дов могут выступать идентификаторы или константы. Особенностью языка ассемблера является то, что ряд идентификаторов в нем выделяется специально для обозначения регистров процессора. Такие иденти- фикаторы, с одной стороны, не требуют предварительного описания, но, с дру- гой, они не могут быть использованы пользователем для иных целей. Набор этих идентификаторов предопределен для каждого языка ассемблера. Иногда язык ассемблера допускает использование в качестве операндов опре- деленных ограниченных сочетаний обозначений регистров, идентификаторов и констант, которые объединены некоторыми знаками операций. Такие сочета- ния чаще всего используются для обозначения типов адресации, допустимых в машинных командах целевой вычислительной системы. Например, следующая последовательность команд datas db 16 dup(O) loops: mov datas[bx+4],ex dec bx jnz loops представляет собой пример последовательности команд языка ассемблера про- цессоров семейства Intel 80x86. Здесь присутствуют команда описания набора данных (db), метка (loops), коды операций (mov, dec и jnz). Операндами явля- ются идентификатор набора данных (datas), обозначения регистров процессора
544 Глава 13. Основные принципы построения трансляторов (Ьх и сх), метка (loops) и константа (4). Составной операнд datas[bx+4] отобража- ет косвенную адресацию набора данных datas по базовому регистру Ьх со смеще- нием 4. Подобный синтаксис языка без труда может быть описан с помощью регулярной грамматики. Поэтому построение распознавателя для языка ассемблера не пред- ставляет труда. По этой же причине в компиляторах с языка ассемблера лекси- ческий и синтаксический разбор, как правило, совмещены в один распознава- тель. Семантика языка ассемблера целиком и полностью определяется целевой вычис- лительной системой, на которую ориентирован данный язык. Семантика языка ассемблера определяет, какая машинная команда соответствует каждой команде языка ассемблера, а также то, какие операнды и в каком количестве допустимы для того или иного кода операции. Поэтому семантический анализ в компиляторе с языка ассемблера также прост, как и синтаксический. Основной его задачей является проверить допустимость операндов для каждого кода операции, а также проверить, что все идентифика- торы и метки, встречающиеся во входной программе, описаны и обозначающие их идентификаторы не совпадают с предопределенными идентификаторами, ис- пользуемыми для обозначения кодов операции и регистров процессора. Схемы синтаксического и семантического анализа в компиляторе с языка ассемб- лера могут быть, таким образом, реализованы на основе обычного конечного ав- томата. Именно эта особенность определила тот факт, что компиляторы с языка ассемблера исторически явились первыми компиляторами, созданными для ЭВМ. Существует также ряд других особенностей, которые присущи именно языкам ассемблера и упрощают построение компиляторов для них. Во-первых, в компиляторах с языка ассемблера не выполняется дополнительная идентификация переменных — все переменные языка сохраняют имена, присво- енные им пользователем. За уникальность имен в исходной программе отвечает ее разработчик, семантика языка никаких дополнительных требований на этот процесс не накладывает. Во-вторых, в компиляторах с языка ассемблера предель- но упрощено распределение памяти. Компилятор с языка ассемблера работает только со статической памятью. Если используется динамическая память, то для работы с нею нужно использовать соответствующую библиотеку или функции ОС, а за ее распределение отвечает разработчик исходной программы. За переда- чу параметров и организацию дисплея памяти процедур и функций также отве- чает разработчик исходной программы. Он же должен позаботиться и об отделе- нии данных от кода программы — компилятор с языка ассемблера, в отличие от компиляторов с языков высокого уровня, автоматически такого разделения не выполняет. И в-третьих, на этапе генерации кода в компиляторе с языка ассемб- лера не производится оптимизация, поскольку разработчик исходной програм- мы сам отвечает за организацию вычислений, последовательность машинных ко- манд и распределение регистров процессора. За исключением этих особенностей компилятор с языка ассемблера является обычным компилятором, но значительно упрощенным по сравнению с любым компилятором с языка высокого уровня.
Трансляторы, компиляторы и интерпретаторы — общая схема работы 545 Компиляторы с языка ассемблера реализуются чаще всего по двухпроходной схеме. На первом проходе компилятор выполняет разбор исходной программы, ее преобразование в машинные коды и одновременно заполняет таблицу иденти- фикаторов. Но на первом проходе в машинных командах остаются незаполнен- ными адреса тех операндов, которые размещаются в оперативной памяти. На втором проходе компилятор заполняет эти адреса и одновременно обнаруживает неописанные идентификаторы. Это связано с тем, что операнд может быть опи- сан в программе после того, как он первый раз был использован. Тогда его адрес еще не известен на момент построения машинной команды, а поэтому требует- ся второй проход. Типичным примером такого операнда является метка, преду- сматривающая переход вперед по ходу последовательности команд. Макроопределения и макрокоманды Разработка программ на языке ассемблера — достаточно трудоемкий процесс, требующий зачастую простого повторения одних и тех же многократно встре- чающихся операций. Примером может служить последовательность команд, вы- полняемых каждый раз для организации стекового дисплея памяти при входе в процедуру или функцию. Для облегчения труда разработчика были созданы так называемые макроко- манды. Макрокоманда представляет собой текстовую подстановку, в ходе выполнения которой каждый идентификатор определенного вида заменяется на цепочку символов из некоторого хранилища данных. Процесс выполнения макрокоман- ды называется макрогенерацией, а цепочка символов, получаемая в результате выполнения макрокоманды, — макрорасширением. Процесс выполнения макрокоманд заключается в последовательном просмотре текста исходной программы, обнаружении в нем определенных идентифика- торов и их замене на соответствующие строки символов. Причем выполняется именно текстовая замена одной цепочки символов (идентификатора) на другую цепочку символов (строку). Такая замена называется макроподстановкой [66, 83, 93]. Для того чтобы указать, какие идентификаторы на какие строки необходимо за- менять, служат макроопределения. Макроопределения присутствуют непосред- ственно в тексте исходной программы. Они выделяются специальными ключе- выми словами либо разделителями, которые не могут встречаться нигде больше в тексте программы. В процессе обработки все макроопределения полностью ис- ключаются из текста входной программы, а содержащаяся в них информация за- поминается для обработки при выполнении макрокоманд. Макроопределение может содержать параметры. Тогда каждая соответствующая ему макрокоманда должна при вызове содержать строку символов вместо каждого параметра. Эта строка подставляется при выполнении макрокоманды в каждое место, где в макроопределении встречается соответствующий параметр. В каче- стве параметра макрокоманды может оказаться другая макрокоманда, тогда она будет рекурсивно вызвана всякий раз, когда необходимо выполнить подстановку параметра. В принципе макрокоманды могут образовывать последовательность
546 Глава 13. Основные принципы построения трансляторов рекурсивных вызовов, аналогичную последовательности рекурсивных вызовов про- цедур и функций, но только вместо вычислений и передачи параметров они вы- полняют лишь текстовые подстановки1. Макрокоманды и макроопределения обрабатываются специальным модулем, на- зываемым макропроцессором или макрогенератором. Макрогенератор получает на вход текст исходной программы, содержащий макроопределения и макроко- манды, а на выходе его появляется текст макрорасширения исходной програм- мы, не содержащий макроопределений и макрокоманд. Оба текста являются только текстами программы, никакая другая обработка не выполняется. Именно макрорасширение исходного текста поступает на вход компилятора. Синтаксис макрокоманд и макроопределений не является строго заданным. Он может различаться в зависимости от реализации компилятора с языка ассембле- ра. Но сам принцип выполнения макроподстановок в тексте программы неизме- нен и не зависит от их синтаксиса. Макрогенератор чаще всего не существует в виде отдельного программного мо- дуля, а входит в состав компилятора с языка ассемблера. Макрорасширение ис- ходной программы обычно недоступно ее разработчику. Более того, макропод- становки могут выполняться последовательно при разборе исходного текста на первом проходе компилятора вместе с разбором всего текста программы, и тогда макрорасширение исходной программы в целом может и вовсе не существовать как таковое. Например, следующий текст определяет макрокоманду push O в языке ассембле- ра процессора типа Intel 8086: push_O macro xor ax,ax push ax endm Семантика этой макрокоманды заключается в записи числа «0» в стек через ре- гистр процессора ах. Тогда везде в тексте программы, где встретится макроко- манда push_O она будет заменена в результате макроподстановки на последовательность ко- манд: хог ах,ах push ах Это самый простой вариант макроопределения. Существует возможность созда- вать более сложные макроопределения с параметрами. Одно из таких макрооп- ределений описано ниже: 1 Глубина такой рекурсии, как правило, сильно ограничена. На последовательность рекур- сивных вызовов макрокоманд налагаются обычно существенно более жесткие ограниче- ния, чем на последовательность рекурсивных вызовов процедур и функций, которая при стековой организации дисплея памяти ограничена только размером стека передачи пара- метров.
Трансляторы, компиляторы и интерпретаторы — общая схема работы 547 add_abx macro xl.x2 add ax.xl add bx.xl add cx.x2 push ax endm Тогда в тексте программы макрокоманда также должна быть указана с соответ- ствующим числом параметров. В данном примере макрокоманда add_abx4.8 будет в результате макроподстановки заменена на последовательность команд: add ах.4 add bx,4 add сх,8 push ах Во многих компиляторах с языка ассемблера возможны еще более сложные кон- струкции, которые могут содержать локальные переменные и метки. Примером такой конструкции может служить макроопределение: loop_ax macro xl.x2.yl local 1оорах mov .ax.xl xor bx.bx 1оорах: add bx.yl sub ax.x2 jge endm 1оорах Здесь метка 1 оорах является локальной, определенной только внутри данного мак- роопределения. В этом случае уже не может быть выполнена простая текстовая подстановка макрокоманды в текст программы, поскольку если данную макро- команду выполнить дважды, то это приведет к появлению в тексте программы двух одинаковых меток 1 оорах. В таком варианте макрогенератор должен исполь- зовать более сложные методы текстовых подстановок, аналогичные тем, что ис- пользуются в компиляторах при идентификации лексических элементов вход- ной программы, чтобы дать всем возможным локальным переменным и меткам макрокоманд уникальные имена в пределах всей программы. Макроопределения и макрокоманды нашли применение не только в языках ас- семблера, но и во многих языках высокого уровня. Там их обрабатывает специ- альный модуль, называемый препроцессором языка (например, широко известен препроцессор языка С). Принцип обработки остается тем же самым, что и для программ на языке ассемблера — препроцессор выполняет текстовые подстанов- ки непосредственно над строками самой исходной программы. В языках высокого уровня макроопределения должны быть отделены от текста самой исходной программы, чтобы препроцессор не мог спутать их с синтаксиче- скими конструкциями входного языка. Для этого используются либо специаль- ные символы и команды (команды препроцессора), которые никогда не могут встречаться в тексте исходной программы, либо макроопределения встречаются
548 Глава 13. Основные принципы построения трансляторов внутри незначащей части исходной программы — входят в состав комментариев (такая реализация существует, например, в компиляторе с языка Pascal, создан- ном фирмой Borland). Макрокоманды, напротив, могут встречаться в произволь- ном месте исходного текста программы, и синтаксически их вызов может не от- личаться от вызова функций во входном языке. Следует помнить, что, несмотря на схожесть синтаксиса вызова, макрокоманды принципиально отличаются от процедур и функций, поскольку не порождают результирующего кода, а представляют собой текстовую подстановку, выполняе- мую прямо в тексте исходной программы. Результат вызова функции и макроко- манды может из-за этого серьезно отличаться. Рассмотрим пример на языке С. Если описана функция int fl(int а) { return а + а; } и аналогичная ей макрокоманда #define f2(a) ((а) + (а)) то результат их вызова не всегда будет одинаков. Действительно, вызовы j=fl(i) и j=f2(i) (где i и j — некоторые целочисленные переменные) приведут к одному и тому же результату. Но вызовы >fl(++i) и j=f2(++i) дадут разные значения переменной j. Дело в том, что поскольку f2 — это макроопределение, то во втором случае будет выполнена текстовая подста- новка, которая приведет к последовательности операторов j=((++i) + (++i)). Видно, что в этой последовательности операция ++1 будет выполнена дважды, в отличие от вызова функции fl(++i), где она выполняется только один раз. Таблицы идентификаторов. Организация таблиц идентификаторов Назначение и особенности построения таблиц идентификаторов Проверка правильности семантики и генерация кода требуют знания характери- стик переменных, констант, функций и других элементов, встречающихся в про- грамме на исходном языке. Все эти элементы в исходной программе, как пра- вило, обозначаются идентификаторами. Выделение идентификаторов и других элементов исходной программы происходит на фазе лексического анализа. Их характеристики определяются на фазах синтаксического разбора, семантическо- го анализа и подготовки к генерации кода. Состав возможных характеристик и методы их определения зависят от семантики входного языка. В любом случае компилятор должен иметь возможность хранить все найденные идентификаторы и связанные с ними характеристики в течение всего процесса компиляции, чтобы иметь возможность использовать их на различных фазах
Таблицы идентификаторов. Организация таблиц идентификаторов 549 компиляции. Для этой цели, как было сказано выше, в компиляторах использу- ются специальные хранилища данных, называемые таблицами символов или таблицами идентификаторов. Любая таблица идентификаторов состоит из набора полей, количество которых равно числу различных идентификаторов, найденных в исходной программе. Каждое поле содержит в себе полную информацию о данном элементе таблицы. Компилятор может работать с одной или несколькими таблицам идентификато- ров — их количество зависит от реализации компилятора. Например, можно ор- ганизовывать различные таблицы идентификаторов для различных модулей ис- ходной программы или для различных типов элементов входного языка. Состав информации, хранимой в таблице идентификаторов для каждого элемен- та исходной программы, зависит от семантики входного языка и типа элемента. Например, в таблицах идентификаторов может храниться следующая информация: □ для переменных: О имя переменной; О тип данных переменной; О область памяти, связанная с переменной; □ для констант: О название константы (если оно имеется); О значение константы; О тип данных константы (если требуется); □ для функций: О имя функции; О количество и типы формальных аргументов функции; О тип возвращаемого результата; О адрес кода функции. Приведенный выше состав хранимой информации, конечно же, является только примерным. Другие примеры такой информации указаны в [23, 42, 74]. Конкрет- ное наполнение таблиц идентификаторов зависит от реализации компилятора. Кроме того, не вся информация, хранимая в таблице идентификаторов, заполняется компилятором сразу — он может несколько раз выполнять обращение к данным в таблице идентификаторов на различных фазах компиляции. Например, имена переменных могут быть выделены на фазе лексического анализа, типы данных для переменных — на фазе синтаксического разбора, а область памяти связыва- ется с переменной только на фазе подготовки к генерации кода. Вне зависимости от реализации компилятора принцип его работы с таблицей идентификаторов остается одним и тем же — на различных фазах компиляции компилятор вынужден многократно обращаться к таблице для поиска информа- ции и записи новых данных. Как правило, каждый элемент в исходной програм- ме однозначно идентифицируется своим именем (вопросы идентификации более подробно рассмотрены в разделе «Семантический анализ и подготовка к генера- ции кода», глава 14). Поэтому компилятору приходится часто выполнять поиск
550 Глава 13. Основные принципы построения трансляторов необходимого элемента в таблице идентификаторов по его имени, в то время как процесс заполнения таблицы выполняется нечасто — новые идентификаторы описываются в программе гораздо реже, чем используются. Отсюда можно сде- лать вывод, что таблицы идентификаторов должны быть организованы таким об- разом, чтобы компилятор имел возможность максимально быстрого поиска нуж- ного ему элемента [33]. Простейшие методы построения таблиц идентификаторов Простейший способ организации таблицы состоит в том, чтобы добавлять эле- менты в порядке их поступления. Тогда таблица идентификаторов будет пред- ставлять собой неупорядоченный массив информации, каждая ячейка которого будет содержать данные о соответствующем элементе таблицы. Поиск нужного элемента в таблице будет в этом случае заключаться в последо- вательном сравнении искомого элемента с каждым элементом таблицы, пока не будет найден подходящий. Тогда, если за единицу принять время, затрачиваемое компилятором на сравнение двух элементов (как правило, это сравнение двух строк), то для таблицы, содержащей N элементов, в среднем будет выполнено N/2 сравнений [14]. Заполнение такой таблицы будет происходить элементарно просто — добавлени- ем нового элемента в ее конец, и время, требуемое на добавление элемента (Т3), не будет зависеть от числа элементов в таблице N. Но если N велико, то поиск потребует значительных затрат времени. Время поиска (Тп) в такой таблице можно оценить как Тп = O(N). Поскольку поиск в таблице идентификаторов является чаще всего выполняемой компилятором операцией, а количество раз- личных идентификаторов даже в реальной исходной программе достаточно ве- лико (от нескольких сотен до нескольких тысяч элементов), то такой способ организации таблиц идентификаторов является неэффективным. Поиск может быть выполнен более эффективно, если элементы таблицы упоря- дочены (отсортированы) согласно некоторому естественному порядку. В нашем случае, когда поиск будет осуществляться по имени идентификатора, наиболее естественно расположить элементы таблицы в прямом или обратном алфавитном порядке. Эффективным методом поиска в упорядоченном списке из N элементов является бинарный или логарифмический поиск. Символ, который следует найти, сравнивается с элементом (N+l)/2 в середине таблицы. Если этот элемент не является искомым, то мы должны просмотреть только блок элемен- тов, пронумерованных от 1 до (N+l)/2-1, или блок элементов от (N+l)/2+l до N в зависимости от того, меньше или больше искомый элемент того, с кото- рым его сравнили. Затем процесс повторяется над нужным блоком в два раза меньшего размера. Так продолжается до тех пор, пока либо элемент не будет найден, либо алгоритм не дойдет до очередного блока, содержащего один или два элемента (с которыми уже можно выполнить прямое сравнение искомого элемента). Так как на каждом шаге число элементов, которые могут содержать искомый эле- мент, сокращается наполовину, то максимальное число сравнений равно l+log2(N).
Таблицы идентификаторов. Организация таблиц идентификаторов 551 Тогда время поиска элемента в таблице идентификаторов можно оценить как Тп s O(log2N). Для сравнения: при N=128 бинарный поиск требует самое боль- шее 8 сравнений, а поиск в неупорядоченной таблице — в среднем 64 сравнения. Метод называют «бинарным поиском», поскольку на каждом шаге объем рас- сматриваемой информации сокращается в два раза, а «логарифмическим» — по- скольку время, затрачиваемое на поиск нужного элемента в массиве, имеет лога- рифмическую зависимость от общего количества элементов в нем. Недостатком метода является требование упорядочивания таблицы идентифи- каторов. Так как массив информации, в котором выполняется поиск, должен быть упорядочен, то время его заполнения уже будет зависеть от числа элемен- тов в массиве. Таблица идентификаторов зачастую просматривается еще до того, как она заполнена полностью, поэтому требуется, чтобы условие упорядоченно- сти выполнялось на всех этапах обращения к ней. Следовательно, для построе- ния таблицы можно пользоваться только алгоритмом прямого упорядоченного включения элементов. При добавлении каждого нового элемента в таблицу сначала надо определить место, куда поместить новый элемент, а потом выполнить перенос части инфор- мации в таблице, если элемент добавляется не в ее конец. Если пользоваться стандартными алгоритмами, применяемыми для организации упорядоченных массивов данных [14], а поиск места включения вести с помощью алгоритма би- нарного поиска, то среднее время, необходимое на помещение всех элементов в таблицу, можно оценить следующим образом: Т3 = O(N*log2 N) + k*O(N2). Здесь к — некоторый коэффициент, отражающий соотношение между времена- ми, затрачиваемыми компьютером на выполнение операции сравнения и опера- ции переноса данных. В итоге при организации логарифмического поиска в таблице идентификаторов мы добиваемся существенного сокращения времени поиска нужного элемента за счет увеличения времени на помещение нового элемента в таблицу. Посколь- ку добавление новых элементов в таблицу идентификаторов происходит сущест- венно реже1, чем обращение к ним, то этот метод следует признать более эффек- тивным, чем метод организации неупорядоченной таблицы. Построение таблиц идентификаторов по методу бинарного дерева Можно сократить время поиска искомого элемента в таблице идентификато- ров, не увеличивая значительно время, необходимое на ее заполнение. Для этого надо отказаться от организации таблицы в виде непрерывного массива данных. 1 Как минимум при добавлении нового идентификатора в таблицу компилятор должен проверить, существует или нет там такой идентификатор, так как в большинстве языков программирования ни один идентификатор не может быть описан более одного раза. Следовательно, каждая операция добавления нового элемента влечет, как правило, не менее одной операции поиска.
552 Глава 13. Основные принципы построения трансляторов Существует метод построения таблиц, при котором таблица имеет форму бинар- ного дерева. Каждый узел дерева представляет собой элемент таблицы, причем корневой узел является первым элементом, встреченным при заполнении табли- цы. Дерево называется бинарным, так как каждая вершина в нем может иметь не более двух ветвей (и, следовательно, не более двух нижележащих вершин). Для определенности будем называть две ветви «правая» и «левая». Рассмотрим алгоритм заполнения бинарного дерева. Будем считать, что алго- ритм работает с потоком входных данных, содержащим идентификаторы (в ком- пиляторе этот поток данных порождается в процессе разбора текста исходной программы). Первый идентификатор, как уже было сказано, помещается в вер- шину дерева. Все дальнейшие идентификаторы попадают в дерево по следующе- му алгоритму. Шаг 1. Выбрать очередной идентификатор из входного потока данных. Если оче- редного идентификатора нет, то построение дерева закончено. Шаг 2. Сделать текущим узлом дерева корневую вершину. Шаг 3. Сравнить очередной идентификатор с идентификатором, содержащемся в текущем узле дерева. Шаг 4. Если очередной идентификатор меньше, то перейти к шагу 5, если ра- вен — сообщить об ошибке и прекратить выполнение алгоритма (двух одинако- вых идентификаторов быть не должно!), иначе — перейти к шагу 7. Шаг 5. Если у текущего узла существует левая вершина, то сделать ее текущим узлом и вернуться к шагу 3, иначе — перейти к шагу 6. Шаг 6. Создать новую вершину, поместить в нее очередной идентификатор, сде- лать эту новую вершину левой вершиной текущего узла и вернуться к шагу 1. Шаг 7. Если у текущего узла существует правая вершина, то сделать ее текущим узлом и вернуться к шагу 3, иначе — перейти к шагу 8. Шаг 8. Создать новую вершину, поместить в нее очередной идентификатор, сде- лать эту новую вершину правой вершиной текущего узла и вернуться к шагу 1. Рассмотрим в качестве примера последовательность идентификаторов GA, DI, М22, Е, А12, ВС, F. На рис. 13.2 проиллюстрирован весь процесс построения бинарного дерева для этой последовательности идентификаторов. Поиск нужного элемента в дереве выполняется по алгоритму, схожему с алго- ритмом заполнения дерева. Шаг 1. Сделать текущим узлом дерева корневую вершину. Шаг 2. Сравнить искомый идентификатор с идентификатором, содержащемся в текущем узле дерева. Шаг 4. Если идентификаторы совпадают, то искомый идентификатор найден, ал- горитм завершается, иначе — надо перейти к шагу 5. Шаг 5. Если очередной идентификатор меньше, то перейти к шагу 6, иначе — пе- рейти к шагу 7.
Таблицы идентификаторов. Организация таблиц идентификаторов 553 Рис. 13.2. Пошаговое заполнение бинарного дерева для последовательности идентификаторов GA, D1, М22, Е, А12, ВС, F Шаг 6. Если у текущего узла существует левая вершина, то сделать ее текущим узлом и вернуться к шагу 2, иначе искомый идентификатор не найден, алгоритм завершается. Шаг 7. Если у текущего узла существует правая вершина, то сделать ее текущим узлом и вернуться к шагу 2, иначе искомый идентификатор не найден, алгоритм завершается. Например, произведем поиск в дереве, изображенном на рис. 13.2, идентифи- катора А12. Берем корневую вершину (она становится текущим узлом), сравни- ваем идентификаторы GA и А12. Искомый идентификатор меньше — текущим узлом становится левая вершина D1. Опять сравниваем идентификаторы. Иско- мый идентификатор меньше — текущим узлом становится левая вершина А12. При следующем сравнении искомый идентификатор найден. Если искать отсутствующий идентификатор — например, All, — то поиск опять пойдет от корневой вершины. Сравниваем идентификаторы GA и All. Искомый идентификатор меньше — текущим узлом становится левая вершина D1. Опять сравниваем идентификаторы. Искомый идентификатор меньше — текущим уз- лом становится левая вершина А12. Искомый идентификатор меньше, но левая вершина у узла А12 отсутствует, поэтому в данном случае искомый идентифика- тор не найден. Для данного метода число требуемых сравнений и форма получившегося дерева во многом зависят от того порядка, в котором поступают идентификаторы. На- пример, если в рассмотренном выше примере вместо последовательности идеи-
554 Глава 13. Основные принципы построения трансляторов тификаторов GA, DI, М22, Е, А12, ВС, F взять последовательность А12, GA, D1, М22, Е, ВС, F, то полученное дерево будет иметь иной вид. А если в качестве при- мера взять последовательность идентификаторов А, В, С, D, Е, F, то дерево вы- родится в упорядоченный однонаправленный связный список. Эта особенность является недостатком данного метода организации таблиц идентификаторов. Дру- гим недостатком является необходимость работы с динамическим выделением памяти при построении дерева. Если предположить, что последовательность идентификаторов в исходной про- грамме является статистически неупорядоченной (что в целом соответствует действительности), то можно считать, что построенное бинарное дерево будет невырожденным. Тогда среднее время на заполнение дерева (Т3) и на поиск эле- мента в нем (ТГ1) можно оценить следующим образом [6, т. 2]: Т3 = N*O(log2 N). Тп = O(log2 N). В целом метод бинарного дерева является довольно удачным механизмом для организации таблиц идентификаторов. Он нашел свое применение в ряде ком- пиляторов. Иногда компиляторы строят несколько различных деревьев для идентификаторов разных типов и разной длины [23, 74]. Хэш-функции и хэш-адресация Принципы работы хэш-функций Логарифмическая зависимость времени поиска и времени заполнения таблицы идентификаторов — это самый хороший результат, которого можно достичь за счет применения различных методов организации таблиц. Однако в реальных исходных программах количество идентификаторов столь велико, что даже лога- рифмическую зависимость времени поиска от их числа нельзя признать удов- летворительной. Необходимы более эффективные методы поиска информации в таблице идентификаторов. Лучших результатов можно достичь, если применить методы, связанные с ис- пользованием хэш-функций и хэш-адресации. Хэш-функцией F называется некоторое отображение множества входных элемен- тов R на множество целых неотрицательных чисел Z: F(r) == n, reR, neZ. Сам термин «хэш-функция» происходит от английского термина «hash function» (hash — «мешать», «смешивать», «путать»). Вместо термина «хэширование» ино- гда используются термины «рандомизация», «переупорядочивание». Множество допустимых входных элементов R называется областью определе- ния хэш-функции. Множеством значений хэш-функции F называется подмно- жество М из множества целых неотрицательных чисел Z: McZ, содержащее все возможные значения, возвращаемые функцией F: VreR: F(r)eM. Процесс ото- бражения области определения хэш-функции на множество значений называет- ся «хэшированием». При работе с таблицей идентификаторов хэш-функция должна выполнять ото- бражение имен идентификаторов на множество целых неотрицательных чисел.
Таблицы идентификаторов. Организация таблиц идентификаторов 555 Областью определения хэш-функции будет множество всех возможных имен идентификаторов. Хэш-адресация заключается в использовании значения, возвращаемого хэш- функцией, в качестве адреса ячейки из некоторого массива данных. Тогда размер массива данных должен соответствовать области значений используемой хэш- функции. Следовательно, в реальном компиляторе область значений хэш-функ- ции никак не должна превышать размер доступного адресного пространства компьютера. Метод организации таблиц идентификаторов, основанный на использовании хэш- адресации, заключается в размещении каждого элемента таблицы в ячейке, адрес которой возвращает хэш-функция, вычисленная для этого элемента. Тогда в иде- альном случае для размещения любого элемента в таблице идентификаторов достаточно только вычислить его хэш-функцию и обратиться к нужной ячейке массива данных. Для поиска элемента в таблице необходимо вычислить хэш- функцию для искомого элемента и проверить, не является ли заданная ею ячей- ка массива пустой (если она не пуста — элемент найден, если пуста — не най- ден). На рис. 13.3 проиллюстрирован метод организации таблиц идентификаторов с использованием хэш-адресации. Трем различным идентификаторам Аь А2, А3 со- ответствуют на рисунке три значения хэш-функции пь п2, п3. В ячейки, адресуе- мые пь п2, п3, помещается информация об идентификаторах Аь А2, А3. При поис- ке идентификатора А3 вычисляется значение адреса п3 и выбираются данные из соответствующей ячейки таблицы. Рис. 13.3. Организация таблицы идентификаторов с использованием хэш-адресации Этот метод весьма эффективен — как время размещения элемента в таблице, так и время его поиска определяются только временем, затрачиваемым на вычисле- ние хэш-функции, которое в общем случае несопоставимо меньше времени, не- обходимого на многократные сравнения элементов таблицы.
556 Глава 13. Основные принципы построения трансляторов Метод имеет два очевидных недостатка. Первый из них — неэффективное ис- пользование объема памяти под таблицу идентификаторов: размер массива для ее хранения должен соответствовать области значений хэш-функции, в то вре- мя как реально хранимых в таблице идентификаторов может быть существенно меньше. Второй недостаток — необходимость соответствующего разумного вы- бора хэш-функции. Этому существенному вопросу посвящены следующие два подраздела. Построение таблиц идентификаторов на основе хэш-функций Существуют различные варианты хэш-функций. Получение результата хэш- функции — «хэширование» — обычно достигается за счет выполнения над це- почкой символов некоторых простых арифметических и логических операций. Самой простой хэш-функцией для символа является код внутреннего представ- ления в ЭВМ литеры символа. Эту хэш-функцию можно использовать и для це- почки символов, выбирая первый символ в цепочке. Так, если двоичное ASCII представление символа А есть 00100001, то результатом хэширования идентифи- катора АТаЫе будет код 00100001. Хэш-функция, предложенная выше, очевидно не удовлетворительна: при ис- пользовании такой хэш-функции возникнет новая проблема — двум различным идентификаторам, начинающимся с одной и той же буквы, будет соответство- вать одно и то же значение хэш-функции. Тогда при хэш-адресации в одну ячей- ку таблицы идентификаторов по одному и тому же адресу должны быть помеще- ны два различных идентификатора, что явно невозможно. Такая ситуация, когда двум или более идентификаторам соответствует одно и то же значение функции, называется коллизией. Возникновение коллизии проиллюстрировано на рис. 13.4 — двум различным идентификаторам At и А2 соответствуют два совпадающих зна- чения хэш-функции Hi = п2. Рис. 13.4. Возникновение коллизии при использовании хэш-адресации
Таблицы идентификаторов. Организация таблиц идентификаторов 557 Естественно, что хэш-функция, допускающая коллизии, не может быть напря- мую использована для хэш-адресации в таблице идентификаторов. Причем дос- таточно получить хотя бы один случай коллизии на всем множестве идентифи- каторов, чтобы такой хэш-функцией нельзя было пользоваться непосредственно. Но в примере взята самая элементарная хэш-функция. А возможно ли построить нетривиальную хэш-функцию, которая бы полностью исключала возникновение коллизий? Очевидно, что для полного исключения коллизий хэш-функция должна быть взаимно однозначной: каждому элементу из области определения хэш-функции должно соответствовать одно значение из ее множества значений, но и каждому значению из множества значений этой функции должен соответствовать только один элемент из области ее определения. Тогда любым двум произвольным эле- ментам из области определения хэш-функции будут всегда соответствовать два различных ее значения. Теоретически для идентификаторов такую хэш-функ- цию построить можно, так как и область определения хэш-функции (все воз- можные имена идентификаторов), и область ее значений (целые неотрицатель- ные числа) являются бесконечными счетными множествами. Теоретически мож- но организовать взаимно однозначное отображение одного счетного множества на другое, но практически это сделать исключительно сложно1. Практически существует ограничение, делающее создание взаимно однозначной хэш-функции для идентификаторов невозможным. Дело в том, что в реальности область значений любой хэш-функции ограничена размером доступного адрес- ного пространства в данной архитектуре компьютера. При организации хэш-ад- ресации значение, используемое в качестве адреса таблицы идентификаторов, не может выходить за пределы, заданные адресной сеткой компьютера2. Множество адресов любого компьютера с традиционной архитектурой может быть велико, но всегда конечно, то есть ограничено. Организовать взаимно однозначное ото- бражение бесконечного множества на конечное даже теоретически невозможно. Можно учесть, что длина принимаемой во внимание строки идентификатора в реальных компиляторах также практически ограничена — обычно она лежит в пределах от 32 до 128 символов (то есть и область определения хэш-функции конечна). Но и тогда количество элементов в конечном множестве, составляю- щем область определения функции, будет превышать их количество в конечном множестве области значений функции (количество всех возможных идентифи- каторов все равно больше количества допустимых адресов в современных ком- пьютерах). Таким образом, создать взаимно однозначную хэш-функцию практи- 1 Элементарным примером такого отображения для строки символов является сопостав- ление ей двоичного числа, полученного путем конкатенации кодов символов, входящих в строку. Фактически сама строка тогда будет выступать адресом при хэш-адресации. Практическая ценность такого отображения весьма сомнительна. 2 Можно, конечно, организовать адресацию с использованием внешних накопителей для организации виртуальной памяти, но накладные затраты для такой адресации будут весьма велики; кроме того, компилятор не должен брать на себя функции ОС (более под- робно см. часть 1 данного учебного пособия). Но даже в таком варианте адресное про- странство никогда не будет бесконечным.
558 Глава 13. Основные принципы построения трансляторов чески ни в каком варианте невозможно. Следовательно, невозможно избежать возникновения коллизий. Пока для двух различных элементов результаты хэширования различны, время поиска совпадает со временем, затраченным на хэширование. Однако возникает затруднение, когда результаты хэширования двух разных элементов совпадают. Для решения проблемы коллизии можно использовать много способов. Одним из них является метод «рехэширования» (или «расстановки»). Согласно этому методу, если для элемента А адрес h(A), вычисленный с помощью хэш-функции, указывает на уже занятую ячейку, то необходимо вычислить значение функции n^h^A) и проверить занятость ячейки по адресу пР Если и она занята, то вы- числяется значение h2(A), и так до тех пор, пока либо не будет найдена свободная ячейка, либо очередное значение hj(A) совпадет с h(A). В последнем случае считается, что таблица идентификаторов заполнена и места в ней больше нет — выдается информация об ошибке размещения идентификатора в таблице. Осо- бенностью метода является то, что первоначально таблица идентификаторов должна быть заполнена информацией, которая позволила бы говорить о том, что все ее ячейки являются пустыми (не содержат данных). Например, если исполь- зуются указатели для хранения имен идентификаторов, то таблицу надо предва- рительно заполнить пустыми указателями. Такую таблицу идентификаторов можно организовать по следующему алгорит- му размещения элемента. Шаг 1. Вычислить значение хэш-функции n = h(A) для нового элемента А. Шаг 2. Если ячейка по адресу п пустая, то поместить в нее элемент А и завер- шить алгоритм, иначе i:el и перейти к шагу 3. Шаг 3. Вычислить n, s hj(A). Если ячейка по адресу nj пустая, то поместить в нее элемент А и завершить алгоритм, иначе перейти к шагу 4. Шаг 4. Если n s nb то сообщить об ошибке и завершить алгоритм, иначе i:ei+1 и вернуться к шагу 3. Тогда поиск элемента А в таблице идентификаторов, организованной таким об- разом, будет выполняться по следующему алгоритму. Шаг 1. Вычислить значение хэш-функции n = h(A) для нового элемента А. Шаг 2. Если ячейка по адресу п пустая, то элемент не найден, алгоритм завер- шен, иначе сравнить имя элемента в ячейке п с именем искомого элемента А. Если они совпадают, то элемент найден и алгоритм завершен, иначе i:=l и перей- ти к шагу 3. Шаг 3. Вычислить n, = h^A). Если ячейка по адресу щ пустая или п = пь то эле- мент не найден и алгоритм завершен, иначе сравнить имя элемента в ячейке п, с именем искомого элемента А. Если они совпадают, то элемент найден и алго- ритм завершен, иначе i:ei+l и повторить к шаг 3. Алгоритмы размещения и поиска элемента схожи по выполняемым операциям. Поэтому они будут совпадать и по оценкам времени, необходимого для их вы- полнения.
Таблицы идентификаторов. Организация таблиц идентификаторов 559 При такой организации таблиц идентификаторов в случае возникновения кол- лизии алгоритм размещает элементы в пустых ячейках таблицы, выбирая их оп- ределенным образом. При этом элементы могут попадать в ячейки с адресами, которые потом будут совпадать со значениями хэш-функции, что приведет к воз- никновению новых, дополнительных коллизий. Таким образом, количество опе- раций, необходимых для поиска или размещения в таблице элемента, зависит от заполненности таблицы. Естественно, функции h/A ) должны вычисляться еди- нообразно на этапах размещения и поиска элемента. Вопрос только в том, как организовать вычисление функций Ь,(А). Самым простым методом вычисления функции h/A) является ее организация в виде hj(A) = (h(A) + р,) mod Nm, где р, — некоторое вычисляемое целое число, a Nm - максимальное число элементов в таблице идентификаторов. В свою оче- редь, самым простым подходом здесь будет положить р, = i. Тогда получаем формулу hj(A) s (h(A)+i) mod Nm. В этом случае при совпадении значений хэш- функции для каких-либо элементов поиск свободной ячейки в таблице начина- ется последовательно от текущей позиции, заданной хэш-функцией h(A). Этот способ нельзя признать особенно удачным — при совпадении хэш-адресов элементы в таблице начинают группироваться вокруг них, что увеличивает чис- ло необходимых сравнений при поиске и размещении. Среднее время поиска элемента в такой таблице в зависимости от числа операций сравнения можно оценить следующим образом [23]: Тп- O((l-Lf/2)/(l- Lf)). Здесь Lf — (load factor) степень заполненности таблицы идентификаторов — от- ношение числа занятых ячеек таблицы к максимально допустимому числу эле- ментов в ней: Lf я N/Nm. Рассмотрим в качестве примера ряд последовательных ячеек таблицы пь п2, п3, п4, п5 и ряд идентификаторов, которые надо разместить в ней: Аь А2, А3, А4, А5 при условии, что h(Ai) я h(A2) = h(A5) = nt; h(A3) = n2; h(A4) = n4. Последова- тельность размещения идентификаторов в таблице при использовании простей- шего метода рехэширования показана на рис. 13.5. В итоге после размещения в таблице для поиска идентификатора At потребуется 1 сравнение, для А2 — 2 сравнения, для А3 — 2 сравнения, для А4 — 1 сравнение и для А5 — 5 сравнений. Даже такой примитивный метод рехэширования является достаточно эффектив- ным средством организации таблиц идентификаторов при неполном заполнении таблицы. Имея, например, даже заполненную на 90 % таблицу для 1024 иденти- фикаторов, в среднем необходимо выполнить 5,5 сравнений для поиска одного идентификатора, в то время как даже логарифмический поиск дает в среднем от 9 до 10 сравнений. Сравнительная эффективность метода будет еще выше при росте числа идентификаторов и снижении заполненности таблицы. Среднее время на помещение одного элемента в таблицу и на поиск элемента в таблице можно снизить, если применить более совершенный метод рехэширо- вания. Одним из таких методов является использование в качестве р, для функ- ции hj(A) = (h(A)+pj)modNm последовательности псевдослучайных целых чисел Рь Рг> •••> Рк- При хорошем выборе генератора псевдослучайных чисел длина по-
560 Глава 13. Основные принципы построения трансляторов следовательности к будет k=Nm. Тогда среднее время поиска одного элемента в таблице можно оценить следующим образом [23]: Еп = O((l/Lf)*log2 (1-Lf)). m П2 пз h(A4)----► гц Al Аг. Аг. Al Рис. 13.5. Заполнение таблицы идентификаторов при использовании простейшего рехэширования Существуют и другие методы организации функций рехэширования h^A), осно- ванные на квадратичных вычислениях или, например, на вычислении по формуле: hj(A) = (h(A)*i) mod Nm, если Nm — простое число [23]. В целом рехэширование позволяет добиться неплохих результатов для эффективного поиска элемента в таблице (лучших, чем бинарный поиск и бинарное дерево), но эффективность метода сильно зависит от заполненности таблицы идентификаторов и качества используемой хэш-функции — чем реже возникают коллизии, тем выше эффек- тивность метода. Требование неполного заполнения таблицы ведет к неэффек- тивному использованию объема доступной памяти. Построение таблиц идентификаторов по методу цепочек Неполное заполнение таблицы идентификаторов при применении хэш-функций ведет к неэффективному использованию всего объема памяти, доступного ком- пилятору. Причем объем неиспользуемой памяти будет тем выше, чем больше информации хранится для каждого идентификатора. Этого недостатка можно избежать, если дополнить таблицу идентификаторов некоторой промежуточной хэш-таблицей. В ячейках хэш-таблицы может храниться либо пустое значение, либо значение указателя на некоторую область памяти из основной таблицы идентификаторов. Тогда хэш-функция вычисляет адрес, по которому происходит обращение снача- ла к хэш-таблице, а потом уже через нее по найденному адресу — к самой табли- це идентификаторов. Если соответствующая ячейка таблицы идентификаторов пуста, то ячейка хэш-таблицы будет содержать пустое значение. Тогда вовсе не
Таблицы идентификаторов. Организация таблиц идентификаторов 561 обязательно иметь в самой таблице идентификаторов ячейку для каждого воз- можного значения хэш-функции — таблицу можно сделать динамической так, чтобы ее объем рос по мере заполнения (первоначально таблица идентификаторов не содержит ни одной ячейки, а все ячейки хэш-таблицы имеют пустое значение). Такой подход позволяет добиться двух положительных результатов: во-первых, нет необходимости заполнять пустыми значениями таблицу идентификаторов — это можно сделать только для хэш-таблицы; во-вторых, каждому идентификато- ру будет соответствовать строго одна ячейка в таблице идентификаторов (в ней не будет пустых неиспользуемых ячеек). Пустые ячейки в таком случае будут только в хэш-таблице, и объем неиспользуемой памяти не будет зависеть от объ- ема информации, хранимой для каждого идентификатора — для каждого значе- ния хэш-функции будет расходоваться только память, необходимая для хране- ния одного указателя на основную таблицу идентификаторов. На основе этой схемы можно реализовать еще один способ организации таблиц идентификаторов с помощью хэш-функций, называемый «метод цепочек». Для метода цепочек в таблицу идентификаторов для каждого элемента добавляется еще одно поле, в котором может содержаться ссылка на любой элемент таблицы. Первоначально это поле всегда пустое (никуда не указывает). Также для этого метода необходимо иметь одну специальную переменную, которая всегда указы- вает на первую свободную ячейку основной таблицы идентификаторов (перво- начально — указывает на начало таблицы). Метод цепочек работает следующим образом по следующему алгоритму. Шаг 1. Во все ячейки хэш-таблицы поместить пустое значение, таблица иденти- фикаторов не должна содержать ни одной ячейки, переменная FreePtr (указатель первой свободной ячейки) указывает на начало таблицы идентификаторов; i:=l. Шаг 2. Вычислить значение хэш-функции п, для нового элемента Аь Если ячей- ка хэш-таблицы по адресу пустая, то поместить в нее значение переменной FreePtr и перейти к шагу 5; иначе — перейти к шагу 3. Шаг 3. Положить j:=l, выбрать из хэш-таблицы адрес ячейки таблицы иденти- фикаторов nij и перейти к шагу 4. Шаг 4. Для ячейки таблицы идентификаторов по адресу т, проверить значение поля ссылки. Если оно пустое, то записать в него адрес из переменной FreePtr и пе- рейти к шагу 5; иначе j:=j+l, выбрать из поля ссылки адрес ш, и повторить шаг 4. Шаг 5. Добавить в таблицу идентификаторов новую ячейку, записать в нее ин- формацию для элемента Aj (поле ссылки должно быть пустым), в переменную FreePtr поместить адрес за концом добавленной ячейки. Если больше нет иден- тификаторов, которые надо разместить в таблице, то выполнение алгоритма закончено, иначе i:=i+l и перейти к шагу 2. Поиск элемента в таблице идентификаторов, организованной таким образом, бу- дет выполняться по следующему алгоритму. Шаг 1, Вычислить значение хэш-функции п для искомого элемента А. Если ячейка хэш-таблицы по адресу п пустая, то элемент не найден и алгоритм завер- шен, иначе положить j:=l, выбрать из хэш-таблицы адрес ячейки таблицы иден- тификаторов шрп.
562 Глава 13. Основные принципы построения трансляторов Шаг 2. Сравнить имя элемента в ячейке таблицы идентификаторов по адресу nij с именем искомого элемента А. Если они совпадают, то искомый элемент найден и алгоритм завершен, иначе — перейти к шагу 3. Шаг 3. Проверить значение поля ссылки в ячейке таблицы идентификаторов по адресу nij. Если оно пустое, то искомый элемент не найден и алгоритм завершен; иначе j:=j+l, выбрать из поля ссылки адрес nij и перейти к шагу 2. При такой организации таблиц идентификаторов в случае возникновения кол- лизии алгоритм размещает элементы в ячейках таблицы, связывая их друг с дру- гом последовательно через поле ссылки. При этом элементы не могут попадать в ячейки с адресами, которые потом будут совпадать со значениями хэш-функ- ции. Таким образом, дополнительные коллизии не возникают. В итоге в таблице возникают своеобразные цепочки связанных элементов, откуда происходит и на- звание данного метода — «метод цепочек». На рис. 13.6 проиллюстрировано заполнение хэш-таблицы и таблицы идентифи- каторов для примера, который ранее был рассмотрен на рис. 13.5 для метода простейшего рехэширования. После размещения в таблице для поиска иденти- фикатора At потребуется 1 сравнение, для А2 — 2 сравнения, для А3 — 1 сравне- ние, для А4 — 1 сравнение и для А5 — 3 сравнения (сравните с результатами про- стого рехэширования). h(Aa)—>ni П2 ПЗ П4 П5 2 h(Ae)—>ni П2 Пз П4 П5 5 П1 П2 ПЗ h(A4)—кгц П5 4 Рис. 13.6. Заполнение хэш-таблицы и таблицы идентификаторов при использовании метода цепочек
Таблицы идентификаторов. Организация таблиц идентификаторов 563 Метод цепочек является очень эффективным средством организации таблиц иден- тификаторов. Среднее время на размещение одного элемента и на поиск элемен- та в таблице для него зависит только от среднего числа коллизий, возникающих при вычислении хэш-функции. Накладные расходы памяти, связанные с необхо- димостью иметь одно дополнительное поле указателя в таблице идентификато- ров на каждый ее элемент, можно признать вполне оправданными. Этот метод позволяет более экономно использовать память, но требует организации работы с динамическими массивами данных. Комбинированные способы построения таблиц идентификаторов Выше в примере была рассмотрена весьма примитивная хэш-функция, которую никак нельзя назвать удовлетворительной. Хорошая хэш-функция распределя- ет поступающие на ее вход идентификаторы равномерно на все имеющиеся в распоряжении адреса, так что коллизии возникают не столь часто. Существует большое множество хэш-функций. Каждая из них стремится распределить адре- са под идентификаторы по своему алгоритму, но идеального хэширования дос- тичь не удается. В реальных компиляторах практически всегда так или иначе используется хэш- адресация. Алгоритм применяемой хэш-функции обычно составляет «ноу-хау» разработчиков компилятора. Обычно при разработке хэш-функции создатели компилятора стремятся свести к минимуму количество возникающих коллизий не на всем множестве возможных идентификаторов, а на тех их вариантах, кото- рые наиболее часто встречаются во входных программах. Конечно, принять во внимание все допустимые исходные программы невозможно. Чаще всего выпол- няется статистическая обработка встречающихся имен идентификаторов на не- котором множестве типичных исходных программ, а также принимаются во вни- мание соглашения о выборе имен идентификаторов, общепринятые для входного языка. Хорошая хэш-функция — это шаг к значительному ускорению работы компилятора, поскольку обращения к таблицам идентификаторов выполняются многократно на различных фазах компиляции. То, какой конкретно метод применяется в компиляторе для организации таблиц идентификаторов, зависит от реализации компилятора. Один и тот же компиля- тор может иметь даже несколько разных таблиц идентификаторов, организован- ных на основе различных методов. Как правило, применяются комбинированные методы. В этом случае, как и для метода цепочек, в таблице идентификаторов организуется специальное дополни- тельное поле ссылки. Но в отличие от метода цепочек оно имеет несколько иное значение. При отсутствии коллизий для выборки информации из таблицы ис- пользуется хэш-функция, поле ссылки остается пустым. Если же возникает кол- лизия, то через поле ссылки организуется поиск идентификаторов, для которых значения хэш-функции совпадают по одному из рассмотренных выше методов: неупорядоченный список, упорядоченный список или же бинарное дерево. При хорошо построенной хэш-функции коллизии будут возникать редко, поэтому количество идентификаторов, для которых значения хэш-функции совпали, бу-
564 Глава 13. Основные принципы построения трансляторов дет не столь велико. Тогда и время поиска одного среди них будет незначитель- ным (в принципе при высоком качестве хеш-функции подойдет даже перебор по неупорядоченному списку). Такой подход имеет преимущество по сравнению с методом цепочек: для хране- ния идентификаторов с совпадающими значениями хэш-функции используют- ся области памяти, не пересекающиеся с основной таблицей идентификаторов, а значит, их размещение не приведет к возникновению дополнительных колли- зий. Недостатком метода является необходимость работы с динамически распре- деляемыми областями памяти. Эффективность такого метода, очевидно, в пер- вую очередь зависит от качества применяемой хэш-функции, а во вторую — от метода организации дополнительных хранилищ данных. Хэш-адресация — это метод, который применяется не только для организации таблиц идентификаторов в компиляторах. Данный метод нашел свое приме- нение и в операционных системах (см. часть 1 данного пособия), и в системах управления базами данных. Интересующиеся читатели могут обратиться к соот- ветствующей литературе [23, 74]. Лексические анализаторы (сканеры). Принципы построения сканеров Назначение лексического анализатора Прежде чем перейти к рассмотрению лексических анализаторов, необходимо дать четкое определение того, что же такое лексема. Лексема (лексическая единица языка) — это структурная единица языка, кото- рая состоит из элементарных символов языка и не содержит в своем составе дру- гих структурных единиц языка. Лексемами языков естественного общения являются слова1. Лексемами языков программирования являются идентификаторы, константы, ключевые слова язы- ка, знаки операций и т. п. Состав возможных лексем каждого конкретного языка программирования определяется синтаксисом этого языка. Лексический анализатор (или сканер) — это часть компилятора, которая читает исходную программу и выделяет в ее тексте лексемы входного языка. На вход лексического анализатора поступает текст исходной программы, а выходная ин- формация передается для дальнейшей обработки компилятором на этапе син- таксического анализа и разбора. С теоретической точки зрения лексический анализатор не является обязатель- ной частью компилятора. Все его функции могут выполняться на этапе син- таксического разбора, поскольку полностью регламентированы синтаксисом 1 В языках естественного общения лексикой называется словарный запас языка. Лексиче- ский состав языка изучается лексикологией и фразеологией, а значение лексем (слов языка) — семасиологией. В языках программирования словарный запас, конечно, не столь интересен и специальной наукой не изучается.
Лексические анализаторы (сканеры). Принципы построения сканеров 565 входного языка. Однако существует несколько причин, по которым в состав практически всех компиляторов включают лексический анализ: □ применение лексического анализатора упрощает работу с текстом исходной программы на этапе синтаксического разбора и сокращает объем обрабаты- ваемой информации, так как лексический анализатор структурирует посту- пающий на вход исходный текст программы и выкидывает всю незначащую информацию; □ для выделения в тексте и разбора лексем возможно применять простую, эф- фективную и теоретически хорошо проработанную технику анализа, в то вре- мя как на этапе синтаксического анализа конструкций исходного языка ис- пользуются достаточно сложные алгоритмы разбора; □ сканер отделяет сложный по конструкции синтаксический анализатор от ра- боты непосредственно с текстом исходной программы, структура которого может варьироваться в зависимости от версии входного языка — при такой конструкции компилятора для перехода от одной версии языка к другой дос- таточно только перестроить относительно простой лексический анализатор. Функции, выполняемые лексическим анализатором, и состав лексем, которые он выделяет в тексте исходной программы, могут меняться в зависимости от версии компилятора. То, какие функции должен выполнять лексический анализатор и какие типы лексем он должен выделять во входной программе, а какие оставлять для этапа синтаксического разбора, решают разработчики компилятора. В основ- ном лексические анализаторы выполняют исключение из текста исходной про- граммы комментариев, незначащих пробелов, символов табуляции и перевода строки, а также выделение лексем следующих типов: идентификаторов, строко- вых, символьных и числовых констант, ключевых (служебных) слов входного языка, знаков операций и разделителей. В простейшем случае фазы лексического и синтаксического анализа могут вы- полняться компилятором последовательно. Но для многих языков программиро- вания на этапе лексического анализа может быть недостаточно информации для однозначного определения типа и границ очередной лексемы. Иллюстрацией та- кого случая может служить пример оператора программы на языке FORTRAN, когда по части текста DO 10 1=1 невозможно определить тип оператора (а соот- ветственно, и границы лексем). В случае DO 10 1=1.15 это присвоение веществен- ной переменной D010I значения константы 1.15 (пробелы в языке FORNTAN игнорируются), а в случае DO 10 1=1,15 — цикл с перечислением от 1 до 15 по це- лочисленной переменной I до метки 10. Другим примером может служить оператор языка С, имеющий вид: k=i+++++j;. Существует только одна единственно верная трактовка этого оператора: k = 1++ + ++j; (если явно пояснить ее с помощью скобок, то данная конструкция имеет вид: k = (i++) + (++j);). Однако найти ее лексический анализатор может, лишь про- смотрев весь оператор до конца и перебрав все варианты, причем неверные вари- анты могут быть обнаружены только на этапе семантического анализа (напри- мер, вариант k = (i++)++ + j; является синтаксически правильным, но семантикой языка С не допускается). Конечно, чтобы эта конструкция была в принципе до-
566 Глава 13. Основные принципы построения трансляторов пустима, входящие в нее операнды к, 1 и j должны быть описаны и должны до- пускать выполнение операций языка ++ и +. Поэтому в большинстве компиляторов лексический и синтаксический анализа- торы — это взаимосвязанные части. Возможны два принципиально различных метода организации взаимосвязи лексического анализа и синтаксического раз- бора: □ последовательный; □ параллельный1. При последовательном варианте лексический анализатор просматривает весь текст исходной программы от начала до конца и преобразует его в структурированный набор данных. Этот набор данных называют также таблицей лексем. В таблице лексем ключевые слова языка, идентификаторы и константы, как правило, заме- няются на специально оговоренные коды, им соответствующие (конкретная кодировка определяется при реализации компилятора). Для идентификаторов и констант, кроме того, устанавливается связь между таблицей лексем и табли- цей идентификаторов, которая заполняется параллельно. В этом варианте лексический анализатор просматривает весь текст исходной про- граммы один раз от начала до конца. Таблица лексем строится полностью вся сразу, и больше к ней компилятор не возвращается. Всю дальнейшую обработку выполняют следующие фазы компиляции. При параллельном варианте лексический анализ исходного текста выполняется поэтапно так, что синтаксический анализатор, выполнив разбор очередной кон- струкции языка, обращается к сканеру за следующей лексемой. При этом он мо- жет сообщить информацию о том, какую лексему следует ожидать. В процессе разбора при возникновении ошибки может происходить «откат назад», чтобы попытаться выполнить анализ текста на другой основе. И только после того, как синтаксический анализатор успешно выполнит разбор очередной конструкции языка (обычно такой конструкцией является оператор исходного языка), лекси- ческий анализатор помещает найденные лексемы в таблицу лексем и таблицу идентификаторов и продолжает разбор дальше в том же порядке. Работа синтаксического и лексического анализаторов в варианте их параллель- ного взаимодействия изображена в виде схемы на рис. 13.7. В качестве варианта таблицы лексем можно рассмотреть некоторый фрагмент кода на языке Pascal и соответствующую ему таблицу лексем, представленную в табл. 13.1: begin for i:-l to N do fg := fg * 0.5 1 Параллельный метод работы лексического анализатора и синтаксического разбора вовсе не означает, что они должны будут выполняться как параллельные взаимодействующие процессы. Такой вариант возможен, но не обязателен.
Лексические анализаторы (сканеры). Принципы построения сканеров 567 Рис. 13.7. Параллельное взаимодействие лексического и синтаксического анализаторов Таблица 13.1. Лексемы программы Лексема Тип лексемы Значение begin Ключевое слово XI for Ключевое слово Х2 i Идентификатор i: 1 := Знак присваивания 1 Целочисленная константа 1 to Ключевое слово ХЗ N Идентификатор N:2 do Ключевое слово Х4 fg Идентификатор fg:3 := Знак присваивания := fg Идентификатор fg:3 ♦ Знак арифметической операции •= 0.5 Вещественная константа 0.5 Поле «Значение» в табл. 13.1 подразумевает некое кодовое значение, которое бу- дет помещено в итоговую таблицу лексем в результате работы лексического ана- лизатора. Конечно, значения, которые записаны в примере, являются услов- ными. Конкретные коды определяются при реализации компилятора. Важно отметить также, что для идентификаторов устанавливается связка таблицы лек- сем с таблицей идентификаторов (в примере это отражено некоторым индексом, следующим после идентификатора за знаком :, а в реальном компиляторе все опять же определяется его реализацией).
568 Глава 13. Основные принципы построения трансляторов Очевидно, что последовательный вариант организации взаимодействия лексиче- ского анализа и синтаксического разбора является более эффективным, так как он не требует организации сложных механизмов обмена данными и не нуждает- ся в повторном прочтении уже разобранных лексем. Этот метод является и более простым. Однако не для всех языков программирования возможно организовать такое взаимодействие. Это зависит в основном от синтаксиса языка, заданного его грамматикой. Большинство современных широко распространенных языков программирования, таких как С и Pascal, тем не менее позволяют построить лексический анализ по более простому, последовательному методу, что дает ряд определенных преимуществ. Принципы построения лексических анализаторов Лексический анализатор имеет дело с такими объектами, как различного рода константы и идентификаторы (к последним относятся и ключевые слова). Язык констант и идентификаторов в большинстве случаев является регулярным — то есть может быть описан с помощью регулярных грамматик (см. главу «Регуляр- ные языки»). Распознавателями для регулярных языков являются конечные автоматы. Существуют правила, с помощью которых для любой регулярной грамматики может быть построен недетерминированный конечный автомат, рас- познающий цепочки языка, заданного этой грамматикой (см. раздел «Конечные автоматы», глава 10). Конечный автомат для каждой входной цепочки языка дает ответ на вопрос о том, принадлежит или нет цепочка языку, заданному автома- том. Однако в общем случае задача сканера несколько шире, чем просто проверка це- почки символов лексемы на соответствие ее входному языку. Кроме этого, ска- нер должен выполнить следующие действия: □ четко определить границы лексемы, которые в исходном тексте явно не за- даны; □ выполнить действия для сохранения информации об обнаруженной лексеме (или выдать сообщение об ошибке, если лексема неверна). Определение границ лексем Выделение границ лексем представляет определенную проблему. Ведь во вход- ном тексте программы лексемы не ограничены никакими специальными симво- лами. Если говорить в терминах программы-сканера, то определение границ лек- сем — это выделение тех строк в общем потоке входных символов, для которых надо выполнять распознавание. В общем случае эта задача может быть сложной, и тогда требуется параллельная работа сканера (лексического анализатора), син- таксического разбора и, возможно, семантического анализа. Для большинства входных языков границы лексем распознаются по заданным терминальным сим- волам. Эти символы — пробелы, знаки операций, символы комментариев, а так- же разделители (запятые, точки с запятой и т. п.). Набор таких терминальных символов может варьироваться в зависимости от синтаксиса входного языка.
Лексические анализаторы (сканеры). Принципы построения сканеров 569 Важно отметить, что знаки операций сами также являются лексемами и необхо- димо не пропустить их при распознавании текста. Как правило, сканеры действуют по следующему принципу: очередной символ из входного потока данных добавляется в лексему всегда, когда он может быть туда добавлен. Как только символ не может быть добавлен в лексему, то считается, что он является границей лексемы и началом следующей лексемы (если символ не является пустым разделителем — пробелом, символом табуляции или перево- да строки, знаком комментария). Такой принцип не всегда позволяет правильно определить границы лексем в том случае, когда они не разделены пустыми сим- волами. Например, приведенная выше строка языка С k=i+++++j; будет разбита на лексемы следующим образом: k = i++ ++ + j; — и это разбиение неверное, ком- пилятор должен будет выдать пользователю сообщение об ошибке, хотя пра- вильный вариант распознавания строки существует. Однако разработчики компиляторов сознательно идут на то, что отсекают неко- торые правильные, но не вполне читаемые варианты исходных программ. По- пытки усложнить лексический распознаватель неизбежно приведут к необходи- мости его взаимосвязи с синтаксическим разбором. Это потребует организации их параллельной работы и неизбежно снизит эффективность работы всего ком- пилятора. Возникшие накладные расходы никак не оправдываются достигаемым эффектом — чтобы распознавать строки с сомнительными лексемами, достаточ- но лишь обязать пользователя явно указать с помощью пробелов (или других незначащих символов) границы лексем, что значительно проще1. Не для всех входных языков такой подход возможен. Например, для рассмот- ренного выше примера с языка FORTRAN невозможно применить указанный метод — разница между оператором цикла и оператором присваивания слишком существенна, чтобы ею можно было пренебречь. Здесь придется прибегнуть к связи с синтаксическим разбором2. В таком случае говорят о непрямой работе лексического анализатора. Лексический анализатор работает прямо, если для данного входного текста (це- почки символов входного языка) и текущего положения указателя в нем он оп- ределяет лексему, расположенную непосредственно справа от указателя, и сдви- гает сам указатель вправо от части текста, образующей эту лексему. При прямой работе лексического анализатора возможно его последовательное взаимодейст- вие с синтаксическим распознавателем. Лексический анализатор работает непрямо, если для данного входного текста (цепочки символов входного языка), заданного типа лексемы и текущего поло- жения указателя в нем он определяет лексему, расположенную непосредственно справа от указателя, и если она соответствует требуемому типу, то сдвигает ука- затель вправо от части текста, образующей эту лексему. В отличие от прямого 1 Желающие могут воспользоваться любым доступным компилятором С и проверить, на- сколько он способен разобрать приведенный здесь пример. 2 Приведенные здесь два оператора на языке FORTRAN, различающиеся только на один символ — «.» (точка) или «,» (запятая), — представляют собой не столько проблему для компилятора, сколько для программиста, поскольку позволяют допустить очень непри- ятную и трудно обнаруживаемую ошибку [6].
570 Глава 13. Основные принципы построения трансляторов варианта данному лексическому анализатору на входе требуется задавать также и тип ожидаемой лексемы. Поэтому при непрямой работе лексического анализа- тора требуется его параллельное взаимодействие с синтаксическим распознава- телем. Выполнение действий, связанных с лексемами Выполнение действий в процессе распознавания лексем представляет для скане- ра гораздо меньшую проблему. Фактически КА, который лежит в основе распо- знавателя лексем, должен иметь не только входной язык, но и выходной. Он дол- жен не только уметь распознать правильную лексему на входе, но и породить связанную с ней последовательность символов на выходе. В такой конфигура- ции КА преобразуется в конечный преобразователь [5, 6, т. 1, 42]. Для сканера действия по обнаружению лексемы могут трактоваться несколько шире, чем только порождение цепочки символов выходного языка. Сканер дол- жен уметь выполнять такие действия, как запись найденной лексемы в таблицу лексем, поиск ее в таблице символов и запись новой лексемы в таблицу симво- лов. Набор действий определяется реализацией компилятора. Обычно эти дейст- вия выполняются сразу же по обнаружению конца распознаваемой лексемы. В КА сканера эти действия можно отобразить сравнительно просто — достаточно иметь возможность с каждым переходом на графе автомата (или в функции пе- реходов автомата) связать выполнение некоторой произвольной функции f(q,a), где q — текущее состояние автомата, а — текущий входной символ. Функция может выполнять произвольные действия, доступные сканеру, в том числе рабо- тать с хранилищами данных, имеющимися в компиляторе (функция может быть и пустой — не выполнять никаких действий). Такую функцию, если она есть, обычно записывают на графе переходов КА под дугами, соединяющими состоя- ния КА. Построение лексических анализаторов Теперь можно рассмотреть практическую реализацию сканеров. В принципе ком- пилятор может иметь в своем составе не один, а несколько лексических анализа- торов, каждый из которых предназначен для выборки и проверки определенного типа лексем. Таким образом, обобщенный алгоритм работы простейшего лексического анали- затора в компиляторе можно описать следующим образом: □ из входного потока выбирается очередной символ, в зависимости от которого запускается тот или иной сканер (символ может быть также проигнорирован либо признан ошибочным); □ запущенный сканер просматривает входной поток символов программы на исходном языке, выделяя символы, входящие в требуемую лексему, до обна- ружения очередного символа, который может ограничивать лексему, либо до обнаружения ошибочного символа; □ при успешном распознавании информация о выделенной лексеме заносится в таблицу лексем и таблицу идентификаторов, алгоритм возвращается к перво-
Лексические анализаторы (сканеры). Принципы построения сканеров 571 му этапу и продолжает рассматривать входной поток символов с того места, на котором остановился сканер; □ при неуспешном распознавании выдается сообщение об ошибке, а дальней- шие действия зависят от реализации сканера — либо его выполнение прекра- щается, либо делается попытка распознать следующую лексему (идет возврат к первому этапу алгоритма). Рассмотрим пример анализа лексем, представляющих собой целочисленные константы в формате языка С. В соответствии с требованиями языка, такие константы могут быть десятичными, восьмеричными или шестнадцатеричны- ми. Восьмеричной константой считается число, начинающееся с 0 и содержащее цифры от 0 до 7; шестнадцатеричная константа должна начинаться с последова- тельности символов Ох и может содержать цифры и буквы от а до f. Остальные числа считаются десятичными (правила их записи напоминать, наверное, не сто- ит). Константа может начинаться также с одного из знаков + или а в конце цифры, обозначающей значение константы, в языке С может следовать буква или две буквы, явно обозначающие ее тип (u, U — unsigned; h, Н — short; 1, L — long). При построении сканера будем учитывать, что константы входят в общий текст программы на языке С. Для избежания путаницы и сокращения объема инфор- мации в примере будем считать, что все допустимые буквы являются строчными (читатели легко могут самостоятельно расширить пример для прописных букв, которые язык С в константах не отличает от строчных). Рассмотренные выше правила могут быть записаны в форме Бэкуса—Наура в грам- матике целочисленных констант для языка С. G({S. W. и. L. V. D. G. X. Q. Z. N}. {0...9. х. a...f. и. 1. h. 1}. Р. S) Р: S -> И -> и -> L -> V -> D -> G1 | Z1 | D1 Lu | Vu | U1 Gu | Zu | Hu G1 | Z1 | Hl Gh | Zh | Hh X -> Q -> Z -> N -> 1 | 2 | 3 | 4 N1 | N2 | N3 DO | DI | D2 Z8 | Z9 | Q8 XO | XI | X2 Xa | Xb | Xc GO | G1 | G2 Ga | Gb | Gc Zx ZO | Z1 | Z2 QO | QI | Q2 0 | NO | QI | U1 | LI | V± | W1 Uh Qu QI Qh | 5 | 6 | 7 | 8 | 9 | N4 | N5 | N6 | N7 | N8 | N9 | D3 | D4 | D5 | D6 | D7 | D8 | D9 | Q9 X3 | X4 | X5 | X6 | X7 | X8 | X9 | Xd | Xe | Xf | G3 | G4 | G5 | G6 | G7 | G8 | G9 | Gd | Ge | Gf Z3 | Z4 | Z5 | Z6 | Z7 | Q3 | Q4 | Q5 | Q6 | Q7 G -> Эта грамматика является леволинейной автоматной регулярной грамматикой. По ней можно построить КА, который будет распознавать цепочки входного
572 Глава 13. Основные принципы построения трансляторов языка (см. раздел «Конечные автоматы», глава 10). Граф переходов этого авто- мата приведен на рис. 13.8. Видно, что построенный автомат является детерми- нированным КА, поэтому в дальнейших преобразованиях он не нуждается. На- чальным состоянием автомата является состояние Н. Рис. 13.8. Граф конечного автомата для распознавания целочисленных констант языка С Приведем автомат к полностью определенному виду — дополним его новым со- стоянием Е, на которое замкнем все дуги неопределенных переходов. На графе автомата дуги, идущие в это состояние, не нагружены символами — они обозна- чают функцию перехода по любому символу, кроме тех символов, которыми уже помечены другие дуги, выходящие из той же вершины графа (такое соглашение принято, чтобы не загромождать обозначениями граф автомата). На рис. 13.8 это состояние и связанные с ним дуги переходов изображены пунктирными линиями. При построении КА знаком «±» были обозначены любые символы, которыми может завершаться целочисленная константа языка С. Однако в реальной про- грамме этот символ не встречается. При моделировании КА следует принять во внимание, какими реальными символами входного языка может завершаться це- лочисленная константа языка С. В языке С в качестве границы константы могут выступать: □ знаки пробелов, табуляции, перевода строки; □ разделители (, ), [, ], {, □ знаки операций +, -, *, /, &, |, ?, ~, <, >, А, =, %. Теперь можно написать программу, моделирующую работу указанного автомата. Ниже приводится текст функции на языке Pascal, реализующей данный распо-
Лексические анализаторы (сканеры). Принципы построения сканеров 573 знаватель. Результатом функции является текущее положение считывающей го- ловки анализатора в потоке входной информации после завершения разбора лексемы при успешном распознавании либо 0 при неуспешном распознавании (если лексема содержит ошибку). В программе переменная i State отображает текущее состояние автомата, пере- менная i является счетчиком символов входной строки. В текст программы впи- саны комментарии в те места функции, где возможно выполнить запись найден- ной лексемы в таблицу лексем и таблицу символов, а также выдать сообщение об обнаруженной ошибке. function RunAuto (const slnput; string; iPos: integer): integer; type TAutoState » (AUTO_H, AUTO_S, AUTO_W. AUTOJJ. AUTOJ, AUTO_V. AUTO_D. AUTO_G. AUTO_X. AUTOJ). AUTO J, AUTO J. AUTO J); const AutoStop - #10. #13. #7. ' ('. ’)*. ’[‘. {’. ’+’. *’. /’. •&’. ’I’. ’?’, ’-Л ,A’. •=’. T]; var iState : TAutoState; i.iL : integer; sOut : string; begin i := iPos; iL := Length(slnput); sOut := ''; iState ;= AUTO_H; repeat case iState of AUTOJ4: case slnputCi] of ' + '.: iState := AUTO_N; ’O’: iState := AUTO_Z; ’Г./д1: iState ;= AUTO_D; else iState : = AUTO_E; end; AUTO-N: case slnputCiJ of ’O’: iState := AUTO_Z: ’Г./д1: iState ;= AUTO_D; else iState : = AUTO_E; end; AUTO-Z; case slnputCi] of *x’: iState := AUTO_X; ‘0*..’7’; iState := AUTO_Q; '8'.'g': iState := AUTO J); else
574 Глава 13 Основные принципы построения трансляторов if slnputCi] in AutoStop then iState = AUTO_S else iState = AUTO_E end AUTO-X case slnputCi] of '0' '9' ’a’ T i State = AUTO_G else iState - AUTO_E end AUTO-Q case slnputCi] of ’O’ iState = AUTO_Q ’8’ ’9’ 1 State = AUTO_D ’u iState = AUTOJJ 'I iState = AUTO_L ’h’ iState = AUTO-V else if sInput[i] in AutoStop then iState = AUTO_S else iState = AUTO_E end AUTO-D case slnputCi] of ’0 ’9' iState = AUTO_D u' iState - AUTO_U ' Г i State = AUTO-L h' iState = AUTO_V else if sInput[i] in AutoStop then iState = AUTO-S else iState = AUTO_E end AUTO-G case slnputCi] of O' '9' 'a' ’ Г iState = AUTO_G ’ u' iState = AUTO_U ' 1' iState = AUTO_L 'h iState = AUTO_V else if slnputCi] in AutoStop then iState = AUTO_S else iState = AUTO_E end AUTO_U case slnputCi] of
Лексические анализаторы (сканеры). Принципы построения сканеров 575 ' I’.'h'; iState := AUT0_W; else if sInput[i] in AutoStop then iState := AUT0_S else iState : = AUTO_E; end; AUTO-L: case slnput[i] of ’u’: iState := AUT0_W; else if sInput[i] in AutoStop then iState :» AUTO_S else iState ;e AUT0_E; end; AUTO-V: case slnput[i] of *u*; iState ;= AUT0_W; else if slnput[i] in AutoStop then iState ;= AUT0_S else iState := AUTO_E; end; AUTO-W; if slnput[i] in AutoStop then iState := AUTO_S else iState ; = AUTO_E; end {case}; if not (iState in [AUTO_E,AUTO_S]) then begin sOut ;= sOut + slnput[i]; i ;= i + 1; end; until ((iState = AUTO_E) or (iState = AUTO_S) or (i > iL)); if (iState = AUT0_S) or (i > iL) then begin { Сюда надо вставить вызов функций записи лексемы sOut } RunAuto := i; end else begin { Сюда надо вставить вызов функции формирования сообщения об ошибке } RunAuto 0; end; end; { RunAuto } Конечно, рассмотренная программа — это всего лишь пример для моделиро- вания такого рода автомата. Она построена так, чтобы можно было четко отеле-
576 Глава 13. Основные принципы построения трансляторов дить соответствие между этой программой и построенным на рис. 13.8 автоматом. Конкретный распознаватель будет существенно зависеть от того, как организо- вано его взаимодействие с остальными частями компилятора. Это влияет на воз- вращаемое функцией значение, а также на то, как она должна действовать при обнаружении ошибки и при завершении распознавания лексемы. В данном примере предусмотрено, что основная часть лексического анализатора сначала считывает в память весь текст исходной программы, а потом начинает его последовательный просмотр. В зависимости от текущего символа вызывает- ся тот или иной сканер. По результатам работы каждого сканера разбор либо продолжается с позиции, которую вернула функция сканера, либо прерывается, если функция возвращает 0. Приведенный в примере сканер можно дополнить сканерами для констант с пла- вающей точкой языка С, а также идентификаторов и ключевых слов языка. В по- следнем случае распознающий КА будет явно недетерминированным, поскольку в языке С возможны, например, идентификатор i ff 1 и ключевое слово i f, имею- щие принципиально разное значение. Такой КА потребует дополнительных пре- образований. В целом техника построения сканеров основывается на моделировании работы детерминированных и недетерминированных КА с дополнением функций распо- знавателя вызовами функций обработки ошибок, а также заполнения таблиц символов и таблиц идентификаторов. Такая техника не требует сложной матема- тической обработки и принципиально важных преобразований входных грамма- тик. Для разработчиков сканера важно только решить, где кончаются функции сканера и начинаются функции синтаксического разбора. После этого процесс построения сканера легко поддается автоматизации. Автоматизация построения лексических анализаторов (программа LEX) Лексические распознаватели (сканеры) — это не только важная часть компиля- торов. Лексический анализ применяется во многих других областях, связанных с обработкой текстовой информации на компьютере. Прежде всего, лексического анализа требуют все возможные командные процессоры и утилиты, требующие ввода командных строк и параметров пользователем. Кроме того, хотя бы самый примитивный лексический анализ вводимого текста применяют практически все современные текстовые редакторы и текстовые процессоры. Практически любой более-менее серьезный разработчик программного обеспечения рано или поздно сталкивается с необходимостью решать задачу лексического анализа (разбора)1. С одной стороны, задачи лексического анализа имеют широкое распространение, а с другой — допускают четко формализованное решение на основе техники мо- делирования работы КА. Все это вместе предопределило возможность автомати- зации решения данной задачи. И программы, ориентированные на ее решение, 1 С другой стороны, далеко не каждый разработчик, столкнувшись с этой задачей, понима- ет, что он имеет дело именно с лексическим анализом текста. Поэтому не всегда такие за- дачи решаются должным образом, в чем автор мог убедиться на собственном опыте.
Синтаксические анализаторы. Синтаксически управляемый перевод 577 не замедлили появиться. Причем поскольку математический аппарат КА извес- тен уже длительное время, да и с задачами лексического анализа программисты столкнулись довольно давно, то и программам, их решающим, насчитывается не один десяток лет. Для решения задач построения лексических анализаторов существуют различ- ные программы. Наиболее известной среди них является программа LEX. LEX — программа для генерации сканеров (лексических анализаторов). Входной язык содержит описания лексем в терминах регулярных выражений. Результа- том работы LEX является программа на некотором языке программирования, которая читает входной файл (обычно это стандартный ввод) и выделяет из него последовательности символов (лексемы), соответствующие заданным регуляр- ным выражениям. История программы LEX тесно связана с историей операционных систем типа UNIX. Эта программа появилась в составе утилит ОС UNIX и в настоящее вре- мя входит в поставку практически каждой ОС этого типа. Однако сейчас суще- ствуют версии программы LEX практически для любой ОС, в том числе для ши- роко распространенной MS-DOS. Поскольку LEX появилась в среде ОС UNIX, то первоначально языком про- граммирования, на котором строились порождаемые ею лексические анализато- ры, был язык С. Но со временем появились версии LEX, порождающие сканеры и на основе других языков программирования (например, известны версии для языка Pascal). Принцип работы LEX достаточно прост: на вход ей подается текстовый файл, содержащий описания нужных лексем в терминах регулярных выражений, а на выходе получается файл с текстом исходной программы сканера на заданном языке программирования (обычно — на С). Текст исходной программы сканера может быть дополнен вызовами любых функций из любых библиотек, поддер- живаемых данным языком и системой программирования. Таким образом, LEX позволяет значительно упростить разработку лексических анализаторов, практи- чески сводя эту работу к описанию требуемых лексем в терминах регулярных выражений. Более подробную информацию о работе с программой LEX можно получить в [5, И, 31,39, 67, 68]. Синтаксические анализаторы. Синтаксически управляемый перевод Основные принципы работы синтаксического анализатора Синтаксический анализатор (синтаксический разбор) — это часть компилятора, которая отвечает за выявление основных синтаксических конструкций входного языка. В задачу синтаксического анализа входит: найти и выделить основные
578 Глава 13. Основные принципы построения трансляторов синтаксические конструкции в тексте входной программы, установить тип и про- верить правильность каждой синтаксической конструкции и, наконец, предста- вить синтаксические конструкции в виде, удобном для дальнейшей генерации текста результирующей программы. В основе синтаксического анализатора лежит распознаватель текста входной программы на основе грамматики входного языка. Как правило, синтаксические конструкции языков программирования могут быть описаны с помощью КС- грамматик (см. главу «Контекстно-свободные языки»), реже встречаются языки, которые могут быть описаны с помощью регулярных грамматик (см. главу «Ре- гулярные языки»). Чаще всего регулярные грамматики применимы к языкам ас- семблера, а языки высокого уровня построены на основе синтаксиса КС-языков. Распознаватель дает ответ на вопрос о том, принадлежит или нет цепочка вход- ных символов заданному языку. Однако, как и в случае лексического анализа, задача синтаксического разбора не ограничивается только проверкой принад- лежности цепочки заданному языку. Необходимо выполнить все перечисленные выше задачи, которые должен решить синтаксический анализатор. В таком вари- анте анализатор уже не является разновидностью МП-автомата — его функции можно трактовать шире. Синтаксический анализатор должен иметь некий вы- ходной язык, с помощью которого он передает следующим фазам компиляции всю информацию о найденных и разобранных синтаксических структурах. В та- ком случае он уже является преобразователем с магазинной памятью — МП-пре- образователем1 [6, 23, 26, 40, 42]. Синтаксический разбор — это основная часть компилятора на этапе анализа. Без выполнения синтаксического разбора работа компилятора бессмысленна, в то время как лексический разбор в принципе является необязательной фазой. Все задачи по проверке синтаксиса входного языка могут быть решены на этапе син- таксического разбора. Сканер только позволяет избавить сложный по структуре синтаксический анализатор от решения примитивных задач по выявлению и за- поминанию лексем входной программы. Выходом лексического анализатора является таблица лексем (или цепочка лек- сем). Эта таблица образует вход синтаксического анализатора, который исследу- ет только один компонент каждой лексемы — ее тип. Остальная информация о лексемах используется на более поздних фазах компиляции при семантическом анализе, подготовке к генерации и генерации кода результирующей программы. Синтаксический анализ (или разбор) — это процесс, в котором исследуется таб- лица лексем и устанавливается, удовлетворяет ли она структурным условиям, явно сформулированным в определении синтаксиса языка. 1 Во всех разобранных выше в главах «Контекстно-свободные языки» и «Классы КС-язы- ков и грамматик» примерах результатом работы МП-автомата являлся не только ответ на вопрос о принадлежности входной цепочки заданному языку («да» или «нет»), но и последовательность номеров правил грамматики, использованных для построения вход- ной цепочки. Затем на основе этих правил строились цепочка вывода и дерево вывода. Поэтому, строго говоря, все рассмотренные ранее примеры МП-автоматов являлись не только МП-автоматами, но и МП-преобразователями.
Синтаксические анализаторы. Синтаксически управляемый перевод 579 Синтаксический анализатор воспринимает выход лексического анализатора и раз- бирает его в соответствии с грамматикой входного языка. Однако в грамматике входного языка программирования обычно не уточняется, какие конструкции следует считать лексемами. Примерами конструкций, которые обычно распозна- ются во время лексического анализа, служат ключевые слова, константы и иден- тификаторы. Но эти же конструкции могут распознаваться и синтаксическим анализатором. На практике не существует жесткого правила, определяющего, ка- кие конструкции должны распознаваться на лексическом уровне, а какие надо оставлять синтаксическому анализатору. Обычно это определяет разработчик компилятора исходя из технологических аспектов программирования, а также синтаксиса и семантики входного языка. Принципы взаимодействия лексиче- ского и синтаксического анализаторов были рассмотрены нами в разделе «Лек- сические анализаторы (сканеры). Принципы построения сканеров» этой главы. Ниже рассмотрены технические аспекты, связанные с реализацией синтаксиче- ских анализаторов для использования результатов их работы на этапе генерации кода. Тем не менее основу любого синтаксического анализатора всегда состав- ляет распознаватель, построенный на основе какого-либо класса КС-грамматик. Поэтому главную роль в том, как функционирует синтаксический анализатор и какой алгоритм лежит в его основе, играют принципы построения распознава- телей КС-языков, рассмотренные в главе «Контекстно-свободные языки». Без применения этих принципов невозможно выполнить эффективный синтаксиче- ский разбор предложений входного языка. Дерево разбора. Преобразование дерева разбора в дерево операций Результатом работы распознавателя КС-грамматики входного языка является последовательность правил грамматики, примененных для построения входной цепочки. По найденной последовательности, зная тип распознавателя, можно построить цепочку вывода или дерево вывода. В этом случае дерево вывода вы- ступает в качестве дерева синтаксического разбора и представляет собой резуль- тат работы синтаксического анализатора в компиляторе. Однако ни цепочка вывода, ни дерево синтаксического разбора не являются целью работы компилятора. Дерево вывода содержит массу избыточной инфор- мации, которая для дальнейшей работы компилятора не требуется. Эта инфор- мация включает в себя все нетерминальные символы, содержащиеся в узлах дерева, — после того как дерево построено, они не несут никакой смысловой нагрузки и не представляют для дальнейшей работы интереса. Для полного представления о типе и структуре найденной и разобранной син- таксической конструкции входного языка в принципе достаточно знать после- довательность номеров правил грамматики, примененных для ее построения. Однако форма представления этой достаточной информации может быть раз- личной как в зависимости от реализации самого компилятора, так и от фазы ком- пиляции. Эта форма называется внутренним представлением программы (иногда используется также термины «промежуточное представление» или «промежуточ- ная программа»).
580 Глава 13. Основные принципы построения трансляторов Различные формы внутреннего представления программы рассмотрены в раз- деле «Генерация кода. Методы генерации кода», глава 14. Здесь пойдет речь толь- ко о связных списочных структурах, представляющих деревья, так как именно эти структуры наиболее просто и эффективно строить на этапе синтаксического разбора, руководствуясь правилами и типом вывода, порожденного синтаксиче- ским распознавателем. В синтаксическом дереве внутренние узлы (вершины) соответствуют операциям, а листья представляют собой операнды. Как правило, листья синтаксического дерева связаны с записями в таблице идентификаторов. Структура синтаксиче- ского дерева отражает синтаксис языка программирования, на котором написана исходная программа. Синтаксические деревья могут быть построены компилятором для любой части входной программы. Не всегда синтаксическому дереву должен соответствовать фрагмент кода результирующей программы — например, возможно построение синтаксических деревьев для декларативной части языка. В этом случае опе- рации, имеющиеся в дереве, не требуют порождения объектного кода, но несут информацию о действиях, которые должен выполнить сам компилятор над со- ответствующими элементами. В том случае, когда синтаксическому дереву соот- ветствует некоторая последовательность операций, влекущая порождение фраг- мента объектного кода, говорят о дереве операций. Дерево операций можно непосредственно построить из дерева вывода, порож- денного синтаксическим анализатором. Для этого достаточно исключить из де- рева вывода цепочки нетерминальных символов, а также узлы, не несущие се- мантической (смысловой) нагрузки при генерации кода. Примером таких узлов могут служить различные скобки, которые меняют порядок выполнения опера- ций и операторов, но после построения дерева никакой смысловой нагрузки не несут, так как им не соответствует никакой объектный код. То, какой узел в дереве является операцией, а какой — операндом, никак невоз- можно определить из грамматики, описывающей синтаксис входного языка. Так- же ниоткуда не следует, каким операциям должен соответствовать объектный код в результирующей программе, а каким — нет. Все это определяется только исходя из семантики — «смысла» — языка входной программы. Поэтому только разра- ботчик компилятора может четко определить, как при построении дерева опера- ций должны различаться операнды и сами операции, а также то, какие операции являются семантически незначащими для порождения объектного кода. Алгоритм преобразования дерева семантического разбора в дерево операций мож- но представить следующим образом. Шаг 1. Если в дереве больше не содержится узлов, помеченных нетерминальны- ми символами, то выполнение алгоритма завершено; иначе — перейти к шагу 2. Шаг 2. Выбрать крайний левый узел дерева, помеченный нетерминальным сим- волом грамматики и сделать его текущим. Перейти к шагу 3. Шаг 3. Если текущий узел имеет только один нижележащий узел, то текущий узел необходимо удалить из дерева, а связанный с ним узел присоединить к узлу вышележащего уровня (исключить из дерева цепочку) и вернуться к шагу 1; иначе — перейти к шагу 4.
Синтаксические анализаторы. Синтаксически управляемый перевод 581 Шаг 4. Если текущий узел имеет нижележащий узел (лист дерева), помеченный терминальным символом, который не несет семантической нагрузки, тогда этот лист нужно удалить из дерева и вернуться к шагу 3; иначе — перейти к шагу 5. Шаг 5. Если текущий узел имеет один нижележащий узел (лист дерева), поме- ченный терминальным символом, обозначающим знак операции, а остальные узлы помечены как операнды, то лист, помеченный знаком операции, надо уда- лить из дерева, текущий узел пометить этим знаком операции и перейти к шагу 1; иначе — перейти к шагу 6. Шаг 6. Если среди нижележащих узлов для текущего узла есть узлы, помечен- ные нетерминальными символами грамматики, то необходимо выбрать крайний левый среди этих узлов, сделать его текущим узлом и перейти к шагу 3; иначе — выполнение алгоритма завершено. Этот алгоритм всегда работает с узлом дерева, который считает текущим, и стре- мится исключить из дерева все узлы, помеченные нетерминальными символами. То, какие из символов считать семантически незначащими, а какие считать зна- ками операций, решает разработчик компилятора1. Если семантика языка задана корректно, то в результате работы алгоритма из дерева будут исключены все нетерминальные символы. Возьмем в качестве примеров синтаксические деревья, построенные для цепочки (а+а)*Ь из языка, заданного различными вариантами грамматики арифметиче- ских выражений. Эти деревья приведены выше в качестве примеров на рис. 12.3, 12.7 и 12.9. Семантически незначащими символами в этой грамматике являются скобки (они задают порядок операций и влияют на синтаксический разбор, но результирующего кода не порождают) и пустые строки. Знаки операций заданы символами +, -, / и *, остальные символы (а и Ь) являются операндами. В результате применения алгоритма преобразования деревьев синтаксического разбора в дерево операций к деревьям, представленным на рис. 12.3, 12.7 и 12.9, получим дерево операций, представленное на рис. 13.9. Причем, несмотря на то что исходные синтаксические деревья имели различную структуру, зависящую от используемой грамматики, результирующее дерево операций в результате всегда имеет одну и ту же структуру, зависящую только от семантики входного языка. Рис. 13.9. Пример дерева операций для языка арифметических выражений Дерево операций является формой внутреннего представления программы, ко- торой удобно пользоваться на этапах синтаксического разбора, семантического анализа и подготовки к генерации кода, когда еще нет необходимости работать 1 Повтор того, что сказано до начала Шагов. — Примеч. ред.
582 Глава 13. Основные принципы построения трансляторов непосредственно с кодами команд результирующей программы. Дерево операций четко отражает связь всех операций между собой, поэтому его удобно использо- вать также для преобразований, связанных с перестановкой и переупорядочива- нием операций без изменения конечного результата (таких, как арифметические преобразования). Более подробно эти вопросы рассмотрены в [5, 6, т. 2, 82]. Автоматизация построения синтаксических анализаторов (программа YACC) При разработке различных прикладных программ часто возникает задача син- таксического разбора некоторого входного текста. Конечно, ее можно всегда ре- шить, полностью самостоятельно построив соответствующий анализатор. И хотя задача выполнения синтаксического разбора встречается не столь часто, как задача выполнения лексического разбора, но все-таки и для ее решения были предложены соответствующие программные средства. Автоматизированное построение синтаксических анализаторов может быть вы- полнено с помощью программы YACC. Программа YACC (Yet Another Compiler Compiler) предназначена для построе- ния синтаксического анализатора контекстно-свободного языка. Анализируемый язык описывается с помощью грамматики в виде, близком форме Бэкуса—Наура (нормальная форма Бэкуса—Наура — НФБН). Результатом работы YACC яв- ляется исходный текст программы синтаксического анализатора. Анализатор, который порождается YACC, реализует восходящий LALR(l) распознаватель [5, 6, т. 1, 15, 65]. Как и программа LEX, служащая для автоматизации построения лексических анализаторов, программа YACC тесно связана с историей операционных систем типа UNIX. Эта программа входит в поставку многих версий ОС UNIX или Linux. Поэтому чаще всего результатом работы YACC является исходный текст синтаксического распознавателя на языке С. Однако существуют версии YACC, выполняющиеся под управлением ОС, отличных от UNIX, и порождающие ис- ходный код на других языках программирования (например, Pascal). Принцип работы YACC похож на принцип работы LEX: на вход поступает файл, содержащий описание грамматики заданного КС-языка, а на выходе получаем текст программы синтаксического распознавателя, который, естественно, можно дополнять и редактировать, как и любую другую программу на заданном языке программирования. Исходная грамматика для YACC состоит из трех секций, разделенных символом %%, — секции описаний, секции правил, в которой описывается грамматика, и секции программ, содержимое которой просто копируется в выходной файл. Например, ниже приведено описание простейшей грамматики для YACC, ко- торая соответствует грамматике арифметических выражений, многократно ис- пользовавшейся в качестве примера в данном пособии: fctoken а Xstart е %%
Контрольные вопросы и задачи 583 е : е '+' ш | е m | m ; t : а | '(' е : %% Секция описаний содержит информацию о том, что идентификатор а является лексемой (терминальным символом) грамматики, а символ е — ее начальным не- терминальным символом. Грамматика записана обычным образом — идентификаторы обозначают терми- нальные и нетерминальные символы; символьные константы типа ' + ' и счи- таются терминальными символами. Символы :, |, ; принадлежат к метаязыку YACC и читаются согласно НФБН «есть по определению», «или» и «конец пра- вила» соответственно. В отличие от LEX, который всегда способен построить лексический распознава- тель, если входной файл содержит правильное регулярное выражение, YACC не всегда может построить распознаватель, даже если входной язык задан пра- вильной КС-грамматикой. Ведь заданная грамматика может и не принадлежать к классу LALR(l). В этом случае YACC выдаст сообщение об ошибке (наличии неразрешимого LALR(l) конфликта в грамматике) при построении синтаксиче- ского анализатора. Тогда пользователь должен либо преобразовать грамматику, либо задать YACC некоторые дополнительные правила, которые могут облег- чить построение анализатора. Например, YACC позволяет указать правила, явно задающие приоритет операций и порядок их выполнения (слева направо или справа налево). С каждым правилом грамматики может быть связано действие, которое будет выполнено при свертке по данному правилу. Оно записывается в виде заключен- ной в фигурные скобки последовательности операторов языка, на котором поро- ждается исходный текст программы распознавателя (обычно это язык С). По- следовательность должна располагаться после правой части соответствующего правила. Также YACC позволяет управлять действиями, которые будут выпол- няться распознавателем в том случае, если входная цепочка не принадлежит за- данному языку. Распознаватель имеет возможность выдать сообщение об ошиб- ке, остановиться либо же продолжить разбор, предприняв некоторые действия, связанные с попыткой локализовать либо устранить ошибку во входной це- почке. Более подробные сведения о программе автоматизированного построения син- таксических распознавателей YACC можно получить в [5, 11, 39, 68]. Контрольные вопросы и задачи Вопросы 1. Что такое трансляция, компиляция, транслятор, компилятор? Из каких про- цессов состоит компиляция? Расскажите об общей структуре компилятора.
584 Глава 13. Основные принципы построения трансляторов 2. Какую роль выполняет лексический анализ в процессе компиляции? Как мо- гут быть связаны между собой лексический и синтаксический анализ? 3. Компилятор можно построить, не используя лексический анализатор. Объяс- ните, почему все-таки подавляющее большинство современных компилято- ров используют фазу лексического анализа. 4. Постройте общую схему работы интерпретатора. В чем ее отличие от общей схемы работы компилятора, приведенной в этом пособии? 5. Постройте общую схему работы компилятора с языка ассемблера. Какие осо- бенности присущи данному компилятору? Какие фазы компиляции в нем мо- гут отсутствовать? 6. От чего зависит количество проходов, необходимых компилятору для построе- ния результирующей объектной программы на основе исходной программы? Как влияют на необходимое количество проходов синтаксис исходного языка программирования, семантика этого языка, архитектура целевой вычисли- тельной системы? 7. Почему в глобальной сети Интернет используются в основном языки про- граммирования, предусматривающие интерпретацию исходного кода? Какие проблемы проистекают из этого факта? 8. Почему для языка ассемблера, который имеет простую (часто — регулярную) грамматику, чаще всего используется двухпроходный компилятор? Что ме- шает свести компиляцию исходного кода на языке ассемблера в один проход? 9. Зачем в описании макрокоманды «#define f2(a) ((а) + (а))» параметр «а» взят в скобки, хотя операция этого не требует? 10. Что такое таблица идентификаторов и для чего она предназначена? Какая информация может храниться в таблице идентификаторов? И. Какие цели преследуются при организации таблицы идентификаторов? Ис- ходя из каких характеристик оценивается эффективность того или иного ме- тода организации таблицы? 12. Какие существуют способы организации таблиц идентификаторов? 13. Что такое коллизия? Почему она происходит при использовании хэш-функ- ций для организации таблиц идентификаторов? В чем заключаются преиму- щества и недостатки метода цепочек? 14. Метод логарифмического поиска позволяет значительно сократить время по- иска идентификатора в таблице. Однако он же значительно увеличивает вре- мя на помещение нового идентификатора в таблицу. Почему тем не менее можно говорить о преимуществах этого метода по сравнению с поиском мето- дом прямого перебора? 15. Проблемы создания хорошей хэш-функции связаны с ограниченной разряд- ной сеткой компьютера и, следовательно, ограниченным размером доступно- го адресного пространства. Но, как сказано в первой части данного учебного пособия, размер адресного пространства можно увеличить, используя внеш- ние накопители данных (прежде всего, жесткие диски) и механизм виртуаль- ной памяти. Почему эти возможности не используются компиляторами при организации хэш-функций?
Контрольные вопросы и задачи 585 16. Как могут быть скомбинированы различные методы организации таблиц идентификаторов? 17. Чем различаются таблица лексем и таблица идентификаторов? В какую из этих таблиц лексический анализатор не должен помещать ключевые слова, разделители и знаки операций? 18. Какие проблемы необходимо решить при построении сканера на основе ко- нечного автомата? Чем отличается функционирование лексического анализа- тора в составе компилятора от работы обычного КА? 19. В чем различия между деревом вывода и деревом операций, если и то и дру- гое получается в результате синтаксического разбора? Задачи 1. В программе на языке С макрокоманда Incl(a,b) и функция Inc2(a,b), выпол- няющие действие а+2*Ь, описаны следующим образом: #define Incl(a.b) ((а) + (b) + (Ь)) int Inc2(int a. int b) { return a + b + b; } к каким результатам приведут следующие вызовы для целочисленных пере- менных i, j и к: k=Incl(i,1): k=Incl(i,j); k=Incl(i,j+1); k=Incl(i,j++); k=Incl(i++,Inc2(j,l)); k=Inc2(i,l): k=Inc2(i,j); k=Inc2(i,j+1): k=Inc2(i,j++); k=Inc2(i++,Incl(j,l)); В каких из перечисленных случаев будет получен логически неверный ре- зультат? 2. Изменится ли результат, полученный при выполнении макрокоманды Incl(a,b) из предыдущей задачи, если ее определение будет дано следующим образом: #define Incl(a.b) ((а) + (2*(b))) Приведет ли это к логически правильным результатам во всех вышеприве- денных случаях? Будет ли верным следующее определение макрокоманды: #define Incl(a.b) ((а) + (2*b)) Если оно содержит ошибку, укажите, в каком случае выполнение данной мак- рокоманды даст неверный результат. 3. Напишите программу на языке Pascal или С, реализующую метод логариф- мического поиска в упорядоченном массиве строк. В качестве исходных дан- ных для заполнения массива возьмите любой текстовый файл, считая, что все слова в нем являются идентификаторами. 4. Напишите программу на языке Pascal или С, реализующую метод построения бинарного дерева. Используйте динамические структуры данных для органи- зации дерева. В качестве исходных данных для заполнения дерева возьмите любой текстовый файл, считая, что все слова в нем являются идентифика- торами. Проверьте эффективность метода на случайных текстовых файлах, а также попробуйте в качестве источника данных файл с упорядоченным тек- стом.
586 Глава 13. Основные принципы построения трансляторов 5. Напишите программу на языке Pascal или С, создающую таблицу идентифи- каторов с помощью хэш-функций на основе метода простого рехэширования. В качестве исходных данных для заполнения дерева возьмите любой текстовый файл, считая, что все слова в нем являются идентификаторами. Организуйте программу таким образом, чтобы в ней можно было бы легко подменять исполь- зуемую хэш-функцию. Подсчитывая число коллизий и среднее количество срав- нений для поиска идентификатора, сравните результаты для различных хэш- функций. В качестве исходных данных для хэш-функции можно предложить: О коды первых двух букв идентификатора; О коды последних двух букв идентификатора; О код первой и код последней букв идентификатора; О коды первой, последней и средней букв идентификатора. 6. Выполните действия, описанные в задаче № 5, для таблицы идентификато- ров, организованной на основе метода цепочек. Сравните результаты. 7. В языке C++ возможны два типа комментариев: обычный комментарий, огра- ниченный символами «/*» и «*/», и строчный комментарий, начинающийся с символов «//» и заканчивающийся концом строки. Постройте сканер, кото- рый бы находил и исключал из входного текста все комментарии языка C++. 8. Постройте сканер, который выделял бы из текста входной программы на язы- ке Borland Pascal все содержащиеся в ней строковые константы и записывал их в отдельный файл. Строковые константы в языке Pascal состоят из строк, одиночных символов и кодов символов, которые могут быть объединены ме- жду собой знаком «+». Строки ограничены символами "" (двойные кавычки), одиночные символы — символами '' (одинарные кавычки), а коды символов начинаются со знака # (диез) и содержат цифры1. 9. Даны две грамматики (см. задачи к предыдущей главе): Gj({ a}, {S.R.T.G.E }, Pt, S) Рр S -> TR R -> X | AT | ATR T -> EG G —> X | &E | &EG E ч-E | (S) | a G2({(,a}, {S,T,E}, P2, S) P2: S -> S^T I T 1 Следует заметить, что эта задача отнюдь не так проста, как может показаться. Программа должна уметь не только находить в исходном тексте строки и объединять их между со- бой, но и правильно игнорировать комментарии (которых в языке Pascal два типа). По- этому лучше сначала корректно описать грамматику, а потом на ее основе построить автомат. Автор непосредственно имел опыт постановки задачи построения такого скане- ра, причем без предварительной теоретической работы программа сканера представляла собой плохо работающий набор из многочисленных заплаток.
Контрольные вопросы и задачи 587 Г -> Т&Е | Е Е -> ~Е | (S) | а Постройте для каждой из них цепочку вывода для цепочки символов: «аЛа&~а&(аЛ—а)», постройте деревья вывода для каждой из полученных це- почек символов. Преобразуйте полученные деревья вывода в дерево опера- ций. Проделайте то же самое для других цепочек, допустимых в языке, задан- ном этими грамматиками.
ГЛАВА 14 Генерация и оптимизация кода Семантический анализ и подготовка к генерации кода Назначение семантического анализа Мы уже неоднократно упоминали, что практически вся языки программирова- ния, строго говоря, не являются КС-языками. Поэтому полный разбор цепочек символов входного языка компилятор не может выполнить в рамках КС-языков с помощью КС-грамматик и МП-автоматов. Полный распознаватель для боль- шинства языков программирования может быть построен в рамках КЗ-языков, поскольку все реальные языки программирования контекстно-зависимы1. Итак, полный распознаватель для языка программирования можно построить на основе распознавателя КЗ-языка. Однако известно, что такой распознаватель имеет экспоненциальную зависимость требуемых для выполнения разбора це- почки вычислительных ресурсов от длины входной цепочки [6, т. 1]. Компиля- тор, построенный на основе такого распознавателя, будет неэффективным с точки зрения либо скорости работы, либо объема необходимой памяти. Поэтому такие компиляторы практически не используются, а все реально существующие ком- пиляторы на этапе разбора входных цепочек проверяют только синтаксические конструкции входного языка, не учитывая его семантику. С целью повысить эффективность компиляторов разбор цепочек входного языка выполняется в два этапа: первый — синтаксический разбор на основе распозна- вателя одного из известных классов КС-языков; второй — семантический анализ входной цепочки. 1 Примером контекстной зависимости, часто встречающейся во многих языках програм- мирования, может служить необходимость предварительно описать идентификатор до его первого использования.
Семантический анализ и подготовка к генерации кода 589 Для проверки семантической правильности входной программы необходимо иметь всю информацию о найденных лексических единицах языка. Эта информация помещается в таблицу лексем на основе конструкций, найденных синтаксиче- ским распознавателем. Примерами таких конструкциями являются блоки описа- ния констант и идентификаторов (если они предусмотрены семантикой языка) или операторы, где тот или иной идентификатор встречается впервые (если опи- сание происходит по факту первого использования). Поэтому полный семанти- ческий анализ входной программы может быть произведен только после полного завершения ее синтаксического разбора. Таким образом, входными данными для семантического анализа служат: □ таблица идентификаторов; □ результаты разбора синтаксических конструкций входного языка. Результаты выполнения синтаксического разбора могут быть представлены в одной из форм внутреннего представления программы в компиляторе. Как правило, на этапе семантического анализа используются различные варианты деревьев син- таксического разбора, поскольку семантический анализатор интересует прежде всего структура входной программы. Семантический анализ обычно выполняется на двух этапах компиляции: на этапе синтаксического разбора и в начале этапа подготовки к генерации кода. В пер- вом случае всякий раз по завершении распознавания определенной синтаксиче- ской конструкции входного языка выполняется ее семантическая проверка на основе имеющихся в таблице идентификаторов данных (такими конструкциями, как правило, являются процедуры, функции и блоки операторов входного языка). Во втором случае, после завершения всей фазы синтаксического разбора, выпол- няется полный семантический анализ программы на основании данных в таб- лице идентификаторов (сюда попадает, например, поиск неописанных иденти- фикаторов). Иногда семантический анализ выделяют в отдельный этап (фазу) компиляции. В каждом компиляторе обычно присутствуют оба варианта семантического ана- лизатора. Конкретная их реализация зависит от версии компилятора и семанти- ки входного языка [6, т. 2, 40, 74, 82]. Этапы семантического анализа Семантический анализатор выполняет следующие основные действия: □ проверка соблюдения во входной программе семантических соглашений вход- ного языка; □ дополнение внутреннего представления программы в компиляторе операто- рами и действиями, неявно предусмотренными семантикой входного языка; □ проверка элементарных семантических (смысловых) норм языков програм- мирования, напрямую не связанных с входным языком. Проверка соблюдения во входной программе семантических соглашений вход- ного языка заключается в сопоставлении входных цепочек программы с требова- ниями семантики входного языка программирования. Каждый язык программи-
590 Глава 14. Генерация и оптимизация кода рования имеет четко заданные и специфицированные семантические соглашения, которые не могут быть проверены на этапе синтаксического разбора. Именно их в первую очередь проверяет семантический анализатор. Примерами таких соглашений являются следующие требования: □ каждая метка, на которую есть ссылка, должна один раз присутствовать в про- грамме; □ каждый идентификатор должен быть описан один раз, и ни один идентифи- катор не может быть описан более одного раза (с учетом блочной структуры описаний); □ все операнды в выражениях и операциях должны иметь типы, допустимые для данного выражения или операции; □ типы переменных в выражениях должны быть согласованы между собой; □ при вызове процедур и функций число и типы фактических параметров долж- ны быть согласованы с числом и типами формальных параметров. Это только примерный перечень такого рода требований. Конкретный состав тре- бований, которые должен проверять семантический анализатор, жестко связан с семантикой входного языка (например, некоторые языки допускают не описы- вать идентификаторы определенных типов). Варианты реализаций такого рода семантических анализаторов детально рассмотрены в [6, т. 2, 74]. Например, если мы возьмем оператор языка Pascal, имеющий вид: а := b + с; то с точки зрения синтаксического разбора это будет абсолютно правильный оператор. Однако мы не можем сказать, является ли этот оператор правильным с точки зрения входного языка (Pascal), пока не проверим семантические требо- вания для всех входящих в него лексических элементов. Такими элементами здесь являются идентификаторы а, b и с. Не зная, что они собой представляют, мы не можем не только окончательно утверждать правильность приведенного выше оператора, но и понять его смысл. Фактически необходимо знать описание этих идентификаторов. В том случае, если хотя бы один из них не описан, имеет место явная ошибка. Если это числовые переменные и константы, то мы имеем дело с оператором сложения, если же это строковые переменные и константы — с оператором кон- катенации строк. Кроме того, идентификатор а, например, ни в коем случае не может быть константой — иначе нарушена семантика оператора присваивания. Также невозможно, чтобы одни из идентификаторов были числами, а другие — строками, или, скажем, идентификаторами массивов или структур — такое соче- тание аргументов для операции сложения недопустимо. И это только некоторая часть соглашений, которые должен проверить компилятор с точки зрения семан- тики входного языка (в данном примере — Pascal). Следует еще отметить, что от семантических соглашений зависит не только пра- вильность оператора, но и его смысл. Действительно, операции алгебраического сложения и конкатенации строк имеют различный смысл, хотя и обозначаются в рассмотренном примере одним знаком операции — «+». Следовательно, от семан- тического анализатора зависит также и код результирующей программы.
Семантический анализ и подготовка к генерации кода 591 Если какое-либо из семантических требований входного языка не выполняется, то компилятор выдает сообщение об ошибке и процесс компиляции на этом, как правило, прекращается. Дополнение внутреннего представления программы операторами и действиями, неявно, предусмотренными семантикой входного языка, связано с преобразова- нием типов операндов в выражениях и при передаче параметров в процедуры и функции. Если вернуться к рассмотренному выше элементарному оператору языка Pascal: а := b + с; то можно отметить, что здесь выполняются две операции: одна операция сложе- ния (или конкатенации, в зависимости от типов операндов) и одна операция присвоения результата. Соответствующим образом должен быть порожден и код результирующей программы. Однако не все так очевидно просто. Допустим, что где-то перед рассмотренным оператором мы имеем описание его операндов в виде: var а : real: b : integer: с : double; из этого описания следует, что а — вещественная переменная языка Pascal, b — целочисленная переменная, с — вещественная переменная с двойной точностью. Тогда смысл рассмотренного оператора с точки зрения входной программы существенным образом меняется, поскольку в языке Pascal нельзя напрямую выполнять операции над операндами различных типов. Существуют правила преобразования типов, принятые для данного языка. Кто выполняет эти преоб- разования? Это может сделать разработчик программы — но тогда преобразования типов в явном виде будут присутствовать в тексте входной программы (в рассмотрен- ном примере это не так). В другом случае это делает код, порождаемый компиля- тором, когда преобразования типов в явном виде в тексте программы не присут- ствуют, но неявно предусмотрены семантическими соглашениями языка. Для этого в составе библиотек функций, доступных компилятору, должны быть функ- ции преобразования типов (более подробно о библиотеках функций см. в раз- деле «Принципы функционирования систем программирования», глава 15). Вызо- вы этих функций как раз и будут встроены в текст результирующей програм- мы для удовлетворения семантических соглашений о преобразованиях типов во входном языке, хотя в тексте программы в явном виде они не присутствуют. Чтобы это произошло, эти функции должны быть встроены и во внутреннее представ- ление программы в компиляторе. За это также отвечает семантический анализатор. С учетом предложенных типов данных, в рассмотренном примере будут не две, а четыре операции: преобразование целочисленной переменной b в формат веще- ственных чисел с двойной точностью; сложение двух вещественных чисел с двой- ной точностью; преобразование результата в вещественное число с одинарной точностью; присвоение результата переменной с. Количество операций возросло вдвое, причем добавились два вызова весьма нетривиальных функций преобра-
592 Глава 14. Генерация и оптимизация кода зования типов. Разработчик программы должен помнить об этом, если хочет до- биваться высокой эффективности результирующего кода. Преобразование типов — это только один вариант операций, неявно добавляе- мых компилятором в код программы на основе семантических соглашений. Дру- гим примером такого рода операций могут служить операции вычисления адреса, когда происходит обращение к элементам сложных структур данных. Существу- ют и другие варианты такого рода операций (преобразование типов — только са- мый распространенный пример). Таким образом, и здесь действия, выполняемые семантическим анализатором, существенным образом влияют на порождаемый компилятором код результи- рующей программы. Проверка элементарных смысловых норм языков программирования, напрямую не связанных с входным языком, — это сервисная функция, которую предостав- ляют большинство современных компиляторов. Эта функция обеспечивает про- верку компилятором некоторых соглашений, применимых к большинству совре- менных языков программирования, выполнение которых связано со смыслом как всей входной программы в целом, так и отдельных ее фрагментов. Примерами таких соглашений являются следующие требования: □ каждая переменная или константа должна хотя бы один раз использоваться в программе; □ каждая переменная должна быть определена до ее первого использования при любом ходе выполнения программы (первому использованию перемен- ной должно всегда предшествовать присвоение ей какого-либо значения); □ результат функции должен быть определен при любом ходе ее выполнения; □ каждый оператор в исходной программе должен иметь возможность хотя бы один раз выполниться; □ операторы условия и выбора должны предусматривать возможность хода вы- полнения программы по каждой из своих ветвей; □ операторы цикла должны предусматривать возможность завершения цикла. Конечно, это только примерный перечень основных соглашений. Конкретный состав проверяемых соглашений зависит от семантики языка. Однако, в отличие от семантических требований языка, строго проверяемых семантическим анали- затором, выполнение данных соглашений не является обязательным. Поэтому то, какие конкретно соглашения будут проверяться и как они будут обрабаты- ваться, зависит от качества компилятора, от функций, заложенных в него разра- ботчиками. Простейший компилятор вообще может не выполнять этот этап се- мантического анализа и не проверять ни одного такого соглашения, даже если это и возможно с точки зрения семантики входного языка1. 1 Конечно, современные компиляторы, создаваемые известными фирмами-разработчика- ми, стремятся выполнить проверку максимально возможного числа такого рода соглаше- ний (насколько это позволяет входной язык). Эта функция обычно преподносится как одно из достоинств того или иного компилятора и способствует завоеванию им хороших позиций на рынке.
Семантический анализ и подготовка к генерации кода 593 Необязательность соглашений такого типа накладывает еще одну особенность на их обработку в семантическом анализаторе: их несоблюдение не может трак- товаться как ошибка. Даже если компилятор полностью уверен в своей «право- те», тот факт, что какое-то из указанных соглашений не соблюдается, не должен приводить к прекращению компиляции входной программы. Обычно факт обна- ружения несоблюдения такого рода соглашений трактуется компилятором как «предупреждение» (warning). Компилятор выдает пользователю сообщение об обнаружении несоблюдения одного из требований, не прерывая сам процесс ком- пиляции, — то есть он просто обращает внимание пользователя на то или иное место в исходной программе. То, как реагировать на «предупреждение» (вносить изменения в исходный код или проигнорировать этот факт), — это уже забота и ответственность разработчика программы. Необязательность указанных соглашений объясняется тем, о чем уже говорилось выше (см. раздел «Языки и цепочки символов. Способы задания языков», гла- ва 9), — ни один компилятор не способен полностью понять и оценить смысл исходной программы. А поскольку смысл программы доступен только человеку (для плохо написанной программы — только ее разработчику, а в другом слу- чае — некоторому кругу лиц), то он и должен нести ответственность за семанти- ческие соглашения1. Задача проверки семантических соглашений входного языка во многом связана с проблемой верификации программ. Эта проблема детально рассмотрена в [1, 25, 46, 51]. Рассмотрим в качестве примера функцию, представляющую собой фрагмент вход- ной программы на языке С: int f_test(int а) { int b.c: b = 0; с = 0: if (b=l) { return a: } c = a + b; } Практически любой современный компилятор с этого языка обнаружит в данном месте входной программы массу «неточностей». Например, переменная с описа- на, ей присваивается значение, но она нигде не используется; значение перемен- ной Ь, присвоенное в операторе Ь=0, тоже никак не используется; наконец, услов- 1 Обычно хорошим стилем считается построить программу так, чтобы она компилирова- лась «без предупреждений», — то есть так, чтобы компилятор не обнаруживал в ней ни одного несоблюдения соглашений о смысле операторов входного языка. В большинстве случаев это возможно. Тем не менее практически все современные компиляторы позво- ляют отключить в процессе компиляции программы проверку того или иного соглаше- ния, вплоть до полного исключения всех возможных вариантов такого рода проверки. Эти соглашения входят в состав «опций» компиляторов. Следует отметить, что отклю- чать можно только необязательные соглашения — ни одну проверку семантических тре- бований языка отключить невозможно.
594 Глава 14. Генерация и оптимизация кода ный оператор лишен смысла, так как всегда предусматривает ход выполнения только по одной своей ветке (и значит, оператор с=а+Ь; никогда выполнен не бу- дет). Скорее всего, компилятор выдаст еще одно предупреждение, характерное именно для языка С, — в операторе if(b=l) присвоение стоит в условии (это не запрещено ни синтаксисом, ни семантикой языка, но является очень рас- пространенной смысловой ошибкой в С). В принципе смысл (а точнее, бес- смысленность) этого фрагмента будет правильно воспринят и обработан ком- пилятором. Однако если взять аналогичный по смыслу, но синтаксически более сложный фрагмент программы, то картина будет несколько иная: int f_test_add(int* a. int* b) { *а = 1; *b = 0; return *a: } int f_test(int a) { int b.c: b = 0: if (f_test_add(&b,&c) !s 0) { return a: } c * a + b; } Здесь уже компилятор вряд ли сможет выяснить порядок изменения значения переменных и выполнение условий в данном фрагменте из двух функций (обе они сами по себе независимо вполне осмысленны!). Единственное предупрежде- ние, которое, скорее всего, получит в данном случае разработчик, — это то, что функция f_test не всегда корректно возвращает результат (отсутствует оператор return перед концом функции). И то это предупреждение на самом деле не будет соответствовать истинному положению вещей. В целом проверка дополнительных семантических соглашений является весьма полезной функцией компиляторов, но ответственность за смысл исходной про- граммы всегда по-прежнему остается на ее разработчике. Идентификация лексических единиц языков программирования Идентификация переменных, типов, процедур, функций и других лексических единиц языков программирования — это установление однозначного соответст- вия между данными объектами и их именами в тексте исходной программы. Идентификация лексических единиц языка чаще всего выполняется на этапе се- мантического анализа.
Семантический анализ и подготовка к генерации кода 595 Как правило, большинство языков программирования требуют, чтобы в исход- ной программе имена лексических единиц не совпадали как между собой, так и с ключевыми словами синтаксических конструкций языка1. Однако чаще всего этого бывает недостаточно, чтобы установить однозначное соответствие между лексическими единицами и их именами, поскольку существуют дополнительные смысловые (семантические) ограничения, накладываемые языком на употребле- ние этих имен. Например, локальные переменные в большинстве языков программирования име- ют так называемую «область видимости», которая ограничивает употребление имени переменной рамками того блока исходной программы, где эта переменная описана. Это значит, что с одной стороны, такая переменная не может быть использована вне пределов своей области видимости. С другой стороны, имя пе- ременной может быть не уникальным, поскольку в двух различных областях видимости допускается существование двух различных переменных с одинако- вым именем (причем в большинстве языков программирования, допускающих блочные структуры, области видимости переменных в них могут перекрывать- ся). Другой пример такого рода ограничений на уникальность имен — это тот факт, что в языке программирования С две разные функции или процедуры с различными аргументами могут иметь одно и то же имя. Безусловно, полный перечень таких ограничений зависит от семантики конкрет- ного языка программирования. Все они четко заданы в описании языка и не могут допускать неоднозначности в толковании, но не могут быть полностью определены на этапе лексического разбора, а потому требуют от компилятора дополнительных действий на этапах синтаксического разбора и семантического анализа. Общая направленность этих действий такова, чтобы дать каждой лекси- ческой единице языка уникальное имя в пределах всей исходной программы и потом использовать это имя при синтезе результирующей программы. Можно дать примерный перечень действий компиляторов для идентификации переменных, констант, функций, процедур и других лексических единиц языка: □ имена локальных переменных дополняются именами тех блоков (функций, процедур), в которых эти переменные описаны; □ имена внутренних переменных и функций модулей исходной программы до- полняются именем самих модулей, причем это касается только внутренних имен и не должно происходить, если переменная или функция доступны из- вне модуля; □ имена процедур и функций, принадлежащих объектам (классам), в объектно- ориентированных языках программирования дополняются наименованием типа объекта (класса), которому они принадлежат; 1 Существуют языки программирования (например, PL/1), которые допускают, чтобы имена идентификаторов, задаваемые пользователями, совпадали с ключевыми словами языка. В таких языках возможны весьма интересные синтаксические конструкции. Од- нако, на взгляд автора, такая возможность не предоставляет ничего хорошего, кроме лишних хлопот разработчикам компилятора. Даже если такая особенность присуща входному языку, пользоваться ею настоятельно не рекомендуется — читаемость програм- мы будет сильно затруднена, а это немаловажный фактор.
596 Глава 14. Генерация и оптимизация кода □ имена процедур и функций модифицируются в зависимости от типов их фор- мальных аргументов. Конечно, это далеко не полный перечень возможных действий компилятора, каж- дая реализация компилятора может предполагать свой набор действий. То, ка- кие из них будут использоваться и как они будут реализованы на практике, зави- сит от языка исходной программы и разработчиков компилятора. Как правило, уникальные имена, которые компилятор присваивает лексическим единицам языка, используются только во внутреннем представлении исходной программы компилятором, и человек, создавший исходную программу, не стал- кивается с ними. Но они могут потребоваться пользователю в некоторых случа- ях — например, при отладке программы, при порождении текста результирующей программы на языке ассемблера или при использовании библиотеки, созданной версией компилятора для одного языка программирования в другом языке (или даже просто в другой версии компилятора). Тогда пользователь должен знать, по каким правилам компилятор порождает уникальные имена для лексических еди- ниц исходной программы1. Во многих современных компиляторах (и обрабатываемых ими входных языках) предусмотрены специальные настройки и ключевые слова, которые позволяют отключить процесс порождения компилятором уникальных имен для лекси- ческих единиц языка. Эти слова учтены в специальных синтаксических конст- рукциях языка (как правило, это конструкции, содержащие слова export или external). Если пользователь использует эти средства, то компилятор не приме- няет механизм порождения уникальных имен для указанных лексических еди- ниц. В этом случае разработчик программы сам отвечает за уникальность имени данной лексической единицы в пределах всей исходной программы или даже в пределах всего проекта (если используются несколько различных исходных мо- дулей — более подробно см. раздел «Принципы функционирования систем про- граммирования», глава 15). Если требование уникальности не будет выполняться, могут возникнуть синтаксические или семантические ошибки на стадии компи- ляции либо же другие ошибки на более поздних этапах разработки программно- 1 Очень часто бывает, что пользователь не может вызвать и использовать по имени функ- цию (или процедуру), которая содержится в некоторой библиотеке. Сообщения компи- лятора вида «Функция такая-то не найдена» вызывают немало удивления, особенно когда пользователь сам создал библиотеку и точно знает, что эта функция в ней есть. Чаще всего проблема как раз связана с наименованием функций — компилятор дает функции в библиотеке не совсем то имя, которое дал ей пользователь в исходном коде. Правила, по которым происходит модификация имен, достаточно просты, можно их вы- яснить для конкретной версии компилятора и использовать при обращении к библиоте- ке. Однако лучше этого не делать, так как нет гарантии, что при переходе к другой версии компилятора (даже от той же фирмы-разработчика) эти правила не изменятся и код ста- нет неработоспособным. Правильным средством будет аккуратное использование отклю- чения механизма именования лексических единиц, предоставляемое синтаксисом исход- ного языка. Это надо сделать как при создании библиотеки, так и при обращении к ней. Тогда в принципе код не будет зависеть от типа компилятора и даже от входного языка (тут важно еще учесть соглашения о выделении памяти при передаче параметров проце- дур и функций, которые рассмотрены в следующем пункте).
Семантический анализ и подготовка к генерации кода 597 го обеспечения. Поскольку наиболее широко используемыми лексическими еди- ницами в различных языках программирования являются, как правило, имена процедур или функций, то этот вопрос, прежде всего, касается именно их. Распределение памяти. Принципы распределения памяти Распределение памяти — это процесс, который ставит в соответствие лексиче- ским единицам исходной программы адрес, размер и атрибуты области памяти, необходимой для этой лексической единицы. Область памяти — это блок ячеек памяти, выделяемый для данных, каким-то образом объединенных логически. Логика таких объединений задается семантикой исходного языка. Распределение памяти работает с лексическими единицами языка — перемен- ными, константами, функциями и т. п. — и с информацией об этих единицах, полученной на этапах лексического и синтаксического анализа. Как правило, исходными данными для процесса распределения памяти в компиляторе служат таблица идентификаторов, построенная лексическим анализатором, и деклара- тивная часть программы (так называемая «область описаний»), полученная в ре- зультате синтаксического анализа. Не во всех языках программирования декла- ративная часть программы присутствует явно, некоторые языки предусматривают дополнительные семантические правила для описания констант и переменных; кроме того, перед распределением памяти надо выполнить идентификацию лек- сических единиц языка. Поэтому распределение памяти выполняется уже после семантического анализа текста исходной программы. Процесс распределения памяти в современных компиляторах, как правило, ра- ботает с относительными, а не абсолютными адресами ячеек памяти (более под- робно о разнице между абсолютными и относительными адресами см. в разделе «Принципы функционирования систем программирования», глава 15). Распре- деление памяти выполняется перед генерацией кода результирующей програм- мы, потому что его результаты должны быть использованы в процессе генерации кода. Во всех языках программирования существует понятие так называемых «базо- вых типов данных» (основных или «скалярных» типов). Размер области памяти, необходимый для лексической единицы базового типа, считается заранее извест- ным. Он определяется семантикой языка и архитектурой вычислительной систе- мы, на которой должна выполняться созданная компилятором результирующая программа. Размер памяти, выделяемой под лексические единицы базовых ти- пов, не зависит от версии компилятора, что обеспечивает совместимость и пере- носимость исходных программ. Этого правила строго придерживаются ведущие разработчики компиляторов. Идеальным вариантом для разработчиков программ был бы такой компилятор, у которого размер памяти для базовых типов зависел бы только от семантики языка. Но чаще всего зависимость результирующей программы от архитектуры вычислительной системы полностью исключить не удается. Тогда создатели ком- пиляторов и языков программирования разрабатывают механизмы, позволяющие свести эту зависимость к минимуму.
598 Глава 14. Генерация и оптимизация кода Например, в языке программирования С базовыми типами данных являются ти- пы char, int, long int и т. п. (реально этих типов, конечно, больше, чем три), а в языке программирования Pascal — типы byte, char, word, integer и т. п. Размер ба- зового типа int в языке С для архитектуры компьютера на базе 16-разрядных процессоров составляет 2 байта, а для 32-разрядных процессоров — 4 байта. Раз- работчики исходной программы на этом языке, конечно, могут узнать данную информацию (она, как правило, известна для каждой версии компилятора), но если ее использовать в программе напрямую, то такая программа будет жестко привязана к конкретной архитектуре компьютера. Чтобы исключить подобную зависимость, лучше использовать механизм определения размера памяти для типа данных, предоставляемый языком программирования, — в языке С, напри- мер, это функция sizeof. Для более сложных структур данных используются правила распределения па- мяти, определяемые семантикой (смыслом) этих структур. Эти правила доста- точно просты и в принципе одинаковы во всех языках программирования. Вот правила распределения памяти под основные виды структур данных: □ для массивов — произведение числа элементов в массиве на размер памяти для одного элемента (то же правило применимо и для строк, но во многих языках строки содержат еще и дополнительную служебную информацию фик- сированного объема); □ для структур (записей с именованными полями) — сумма размеров памяти по всем полям структуры; □ для объединений (союзов, общих областей, записей с вариантами) — размер максимального поля в объединении; □ для реализации объектов (классов) — размер памяти для структуры с такими же именованными полями плюс память под служебную информацию объект- но-ориентированного языка (как правило, фиксированного объема). Формулы для вычисления объема памяти можно записать следующим образом: □ для массивов: VMt i=l,n где п — размерность массива, тп, — количество элементов i-й размерности, Кл ~ объем памяти для одного элемента; □ для структур: VOTp = i=l,n где п — общее количество полей в структуре, Уполя. — объем памяти для i-ro поля структуры; □ для объединений: V = max Уполя., к :=1,п ’ где п — общее количество полей в объединении, Уполя. — объем памяти для i-ro поля объединения. Для более сложных структур данных входного языка объем памяти, отводимой под эти структуры данных, вычисляется рекурсивно. Например, если имеется массив структур, то при вычислении объема отводимой под этот массив памяти
Семантический анализ и подготовка к генерации кода 599 для вычисления объема памяти, необходимой для одного элемента массива, бу- дет вызвана процедура вычисления памяти структуры. Такой подход определе- ния объема занимаемой памяти очень удобен, если декларативная часть языка представлена в виде дерева типов. Тогда для вычисления объема памяти, зани- маемой типом из каждой вершины дерева, нужно вычислить объем памяти для всех потомков этой вершины, а потом применить формулу, связанную непосред- ственно с самой вершиной (этот механизм подобен механизму СУ-перевода, применяемому при генерации кода). Как раз такого типа древовидные конструк- ции строит синтаксический разбор для декларативной части языка. Далеко не все лексические единицы языка требуют для себя выделения памяти. То, под какие элементы языка нужно выделять ячейки памяти, а под какие нет, определяется исключительно реализацией компилятора и архитектурой исполь- зуемой вычислительной системы. Так, целочисленные константы можно размес- тить в статической памяти, а можно и непосредственно в тексте результирующей программы (это позволяют практически все современные вычислительные сис- темы), то же самое относится и к константам с плавающей точкой, но их разме- щение в тексте программы допустимо не всегда. Кроме того, в целях экономии памяти, занимаемой результирующей программой, под различные элементы языка компилятор может выделить одни и те же ячейки памяти. Например, в одной и той же области памяти могут быть размещены одинаковые строковые констан- ты или две различные локальные переменные, которые никогда не используются одновременно. Говоря об объеме памяти, занимаемой различными лексическими единицами и структурами данных языка, следует упомянуть еще один момент, связанный с выравниванием отводимых для различных лексических единиц границ облас- тей памяти. Архитектура многих современных вычислительных систем преду- сматривает, что обработка данных выполняется более эффективно, если адрес, по которому выбираются данные, кратен определенному числу байт (как прави- ло, это 2, 4, 8 или 16 байт)1. Современные компиляторы учитывают особенности вычислительных систем, на которые ориентирована результирующая программа. При распределении данных они могут размещать области памяти под лексиче- ские единицы наиболее оптимальным образом. Поскольку не всегда размер па- мяти, отводимой под лексическую единицу, кратен указанному числу байт, то в общем объеме памяти, отводимой под результирующую программу, могут появ- ляться неиспользуемые области. Например, если мы имеем описание переменных на языке С: static char cl. с2, сЗ; то, зная, что под одну переменную типа char отводится 1 байт памяти, можем ожидать, что для описанных выше переменных потребуется всего 3 байта памя- ти. Однако если кратность адресов для доступа к памяти установлена 4 байта, то под эти переменные будет отведено в целом 12 байт памяти, из которых 9 не бу- дут использоваться. 1 Некоторые вычислительные системы даже вообще не могут работать с адресами, не крат- ными определенному числу байт.
600 Глава 14. Генерация и оптимизация кода Как правило, разработчику исходной программы не нужно знать, каким образом компилятор распределяет адреса под отводимые области памяти. Чаще всего ком- пилятор сам выбирает оптимальный метод, и, изменяя границы выделенных об- ластей, он всегда корректно осуществляет доступ к ним. Вопрос об этом может встать, если с данными программы, написанной на одном языке, работают про- граммы, написанные на другом языке программирования (чаще всего, на языке ассемблера), реже такие проблемы возникают при использовании двух различ- ных компиляторов с одного и того же входного языка. Большинство компилято- ров позволяют пользователю в этом случае самому указать, использовать или нет кратные адреса и какую границу кратности установить (если это вообще воз- можно с точки зрения архитектуры целевой вычислительной системы). Память можно разделить на локальную и глобальную память, динамическую и статическую память. Глобальная и локальная память Глобальная область памяти — это область памяти, которая выделяется один раз при инициализации результирующей программы и действует все время выпол- нения программы. Как правило, глобальная область памяти может быть доступ- на из любой части исходной программы, но многие языки программирования по- зволяют налагать синтаксические и семантические ограничения на доступность даже для глобальных областей памяти. При этом сами области памяти и связан- ные с ними лексические единицы остаются глобальными, ограничения налага- ются только на возможность использования их в тексте программы на входном языке (и эти ограничения в принципе возможно обойти). Локальная область памяти — это область памяти, которая выделяется в начале выполнения некоторого фрагмента результирующей программы (блока, функции, процедуры или оператора) и может быть освобождена по завершении выполне- ния данного фрагмента. Доступ к локальной области памяти всегда запрещен за пределами того фрагмента программы, в котором она выделяется. Это определя- ется как синтаксическими и семантическими правилами языка, так и кодом ре- зультирующей программы. Даже если удастся обойти ограничения, налагаемые входным языком, использование таких областей памяти вне их области види- мости приведет к катастрофическим последствиям для результирующей про- граммы. Распределение памяти на локальные и глобальные области целиком определяет- ся семантикой языка исходной программы. Только зная смысл синтаксических конструкций исходного языка, можно четко сказать, какая из них будет отнесена в глобальную область памяти, а какая — в локальную. Иногда в исходном языке для некоторых конструкций нет четкого разграничения, тогда решение об их от- несении в ту или иную область памяти принимается разработчиками компиля- тора и может зависеть от используемой версии компилятора. При этом разработ- чики исходной программы не должны полагаться на тот факт, что один раз приня- тое решение будет неизменным во всех версиях компилятора.
Семантический анализ и подготовка к генерации кода 601 Рассмотрим для примера фрагмент текста модуля программы на языке Pascal: const Global_l = 1: Globdl_2 : integer = 2; var Global_I : integer, function Test (Param: integer): pointer: const Local-1 = 1: Local-2 . integer - 2: var Local_I • integer: begin end; Согласно семантике языка Pascal, переменная Global_I является глобальной пе- ременной языка и размещается в глобальной области памяти, константа Global_1 также является глобальной, но язык не требует, чтобы компилятор обязатель- но размещал ее в памяти — значение константы может быть непосредственно подставлено в код результирующей программы там, где она используется. Типи- зированная константа Global_2 является глобальной, но в отличие от констан- ты Global_1 семантика языка предполагает, что эта константа обязательно будет размещена в памяти, а не в коде программы. Доступность идентификаторов Global_I, Global_2 и GlobalJL из других модулей зависит от того, где и как они описаны. Например, для компилятора Borland Pascal переменные и константы, описанные в заголовке модулей, доступны из других модулей программы, а пе- ременные и константы, описанные в теле модулей, недоступны, хотя и те и дру- гие являются глобальными. Параметр Param функции Test, переменная Local_I и константа Local_l являются локальными элементами этой функции. Они не доступны вне пределов данной функции и располагаются в локальной области памяти. Типизированная кон- станта Local _2 представляет собой очень интересный элемент программы. С од- ной стороны, она не доступна вне пределов функции Test и потому является ее локальным элементом. С другой стороны, как типизированная константа она бу- дет располагаться в глобальной области памяти, хотя ниоткуда извне не может быть доступна (аналогичными конструкциями языка С, например, являются ло- кальные переменные функций, описанные как static). Семантические особенности языка должны учитывать создатели компилятора, когда разрабатывают модуль распределения памяти. Безусловно, это должен знать и разработчик исходной программы, чтобы не допускать семантических (смы- словых) ошибок — этот тип ошибок сложнее всего поддается обнаружению. Так, в приведенном выше примере в качестве результата функции Test может выступать адрес переменной Global_I или типизированной константы Global_2. Результатом функции не может быть адрес констант Global_1 или Local_1 — это запрещено се-
602 Глава 14. Генерация и оптимизация кода мантикой языка. Гораздо сложнее вопрос с переменной Local_1 или параметром функции Param. Будучи элементами локальной области памяти функции Test, они никак не могут выступать в качестве результата функции, потому что он бу- дет использован вне самой функции, когда область ее локальной памяти может уже не существовать. Но ни синтаксис, ни семантика языка не запрещают ис- пользовать адрес этих элементов в качестве результата функции (и далеко не всегда компилятор способен хотя бы обнаружить факт такого использования ад- реса локальной переменной). Эти особенности языка должен учитывать разра- ботчик программы. А вот адрес типизированной константы Local_2 в принципе может быть результатом функции Test. Ведь хотя она и является локальной кон- стантой, но размещается в глобальной области памяти (другое дело, что этим пользоваться не рекомендуется, поскольку нет гарантии, что принцип размеще- ния локальных типизированных констант не изменится с переходом к другой версии компилятора)1. Статическая и динамическая память Статическая область памяти — это область памяти, размер которой известен на этапе компиляции. Поскольку для статической области памяти известен ее раз- мер, компилятор всегда может выделить эту область памяти и связать ее с соот- ветствующим элементом программы. Поэтому для статической области памяти компилятор порождает некоторый адрес (как правило, это относительный адрес в программе — см. раздел «Принципы функционирования систем программиро- вания», глава 15). В статическую область памяти попадает большинство пере- менных и констант исходного языка. Статические области памяти обрабатываются компилятором самым простейшим образом, поскольку напрямую связаны со своим адресом. Динамическая область памяти — это область памяти, размер которой на этапе компиляции программы не известен. Размер динамической области памяти будет известен только в процессе выполнения результирующей программы. Поэтому для динамической области памяти компилятор не может непосредственно выде- лить адрес — для нее он порождает фрагмент кода, который отвечает за распре- деление памяти (ее выделение и освобождение). Как правило, с динамическими областями памяти связаны многие операции с указателями и с экземплярами объектов (классов) в объектно-ориентированных языках программирования. Динамические области памяти, в свою очередь, можно разделить на динамиче- ские области памяти, выделяемые пользователем, и динамические области памя- ти, выделяемые непосредственно компилятором. Динамические области памяти, выделяемые пользователем, появляются в тех случаях, когда разработчик исходной программы явно использует в тексте про- 1 Рассуждения о возможности или невозможности использовать адреса тех или иных эле- ментов программы в качестве результата функции в приведенном примере носят чисто теоретический характер. Вопрос о практическом использовании такого рода функций ав- тор оставляет открытым, поскольку он касается проблем читаемости и наглядности ис- ходного текста программ, а также стиля программирования. Эти проблемы представляют большой интерес, но выходят за рамки данного учебного пособия.
Семантический анализ и подготовка к генерации кода 603 граммы функции, связанные с распределением памяти (примером таких функ- ций являются New и Dispose в языке Pascal, mall ос и free в языке С, new и delete в языке C++ и др.). Функции распределения памяти могут использовать либо напрямую средства ОС, либо средства исходного языка (которые, в конце кон- цов, все равно основаны на средствах ОС). В этом случае за своевременное выде- ление и освобождение памяти отвечает сам разработчик, а не компилятор (прин- ципы динамического распределения памяти в ОС более подробно описаны в части 1 этого пособия). Компилятор должен только построить код вызова соот- ветствующих функций и сохранения результата — в принципе, для него работа с динамической памятью пользователя ничем не отличается от работы с любыми другими функциями и дополнительных сложностей не вызывает. Другое дело — динамические области памяти, выделяемые компилятором. Они появляются тогда, когда пользователь использует типы данных, операции над которыми предполагают перераспределение памяти, не присутствующее в явном виде в тексте исходной программы1. Примерами таких типов данных могут слу- жить строки в некоторых языках программирования, динамические массивы и, конечно, многие операции над экземплярами объектов (классов) в объектно- ориентированных языках (наиболее характерный пример — тип данных string в версиях Borland Delphi языка Object Pascal). В этом случае сам компилятор отвечает за порождение кода, который будет всегда обеспечивать своевременное выделение памяти под элементы программы, и за освобождение ее по мере ис- пользования. Многие компиляторы объектно-ориентированных языков програм- мирования используют для этих целей специальный менеджер памяти, к которому обращаются во всех случаях при явном или неявном использовании динамиче- ской памяти. Код менеджера памяти включается в текст результирующей про- граммы или поставляется в виде отдельной библиотеки. Как статические, так и динамические области памяти сами по себе могут быть глобальными или локальными. Дисплей памяти процедуры (функции). Стековая организация дисплея памяти Дисплей памяти процедуры (функции) — это область данных, доступных для обработки в этой процедуре (функции). Как правило, дисплей памяти процедуры включает следующие составляющие: □ глобальные данные (переменные и константы) всей программы; □ формальные аргументы процедуры; □ локальные данные (переменные и константы) данной процедуры. 1 В принципе в этом случае разработчик исходной программы может даже и не подозре- вать о том, что используемая им операция предполагает перераспределение памяти. Однако это не лучшим образом будет сказываться на эффективности созданной разра- ботчиком программы.
604 Глава 14. Генерация и оптимизация кода С глобальными данными никаких проблем не возникает — их адреса известны, они все устанавливаются на этапе распределения памяти. Даже если память под эти данные распределяется динамически, компилятору однозначно известно, как к этой памяти обратиться, и способ обращения одинаков для всей программы. Поэтому он всегда может создать код для обращения к этим данным вне зависи- мости от того, откуда это обращение происходит. Сложнее с локальными параметрами и переменными процедуры. Они относятся к локальной памяти процедуры и потому должны быть как-то связаны с нею. Существует несколько вариантов связывания локальных переменных и парамет- ров с кодом соответствующей процедуры или функции. Им соответствуют вари- анты организации дисплея памяти процедуры, перечисленные ниже: □ статическая организация дисплея памяти процедуры; □ динамическая организация дисплея памяти процедуры; □ стековая организация дисплея памяти процедуры. Статическая организация дисплея памяти процедуры (функции) предусматри- вает, что с каждой процедурой (функцией) исходной программы при распреде- лении памяти компилятор связывает фиксированную область памяти, предна- значенную для размещения ее локальных данных (переменных и параметров). Адрес этой области памяти фиксируется на этапе распределения памяти. Каждо- му формальному параметру процедуры (функции) и каждой локальной пере- менной соответствует своя группа ячеек в этой области памяти. Отдельная груп- па ячеек отводится для хранения адреса возврата — адреса, по которому должно быть передано управление по завершению выполнения процедуры (функции). Тогда в каждой точке вызова процедуры компилятор порождает код, который размещает фактические параметры вызова процедуры (функции) в ячейках об- ласти памяти процедуры, соответствующих ее формальным параметрам. Перед вызовом процедуры запоминается адрес возврата, который должен указывать на фрагмент кода непосредственно за местом вызова, после чего управление пере- дается самой процедуре. В коде процедуры обращение к локальным данным (па- раметрам и переменным) происходит по фиксированным адресам, которые для каждой процедуры известны после этапа распределения памяти. После заверше- ния выполнения процедуры (функции) из фиксированной ячейки памяти извле- кается код возврата и происходит передача управления по нему. Схема статической организации памяти проиллюстрирована на рис. 14.1. Данная схема мало чем отличается от обычного распределения памяти под ста- тические глобальные данные, за исключением того, что области памяти проце- дур, которые никогда в тексте программы не вызывают друг друга, могут частич- но или полностью перекрываться. Эта схема проста и удобна, она не требует сложной адресации. За счет использования одной и той же области памяти для В принципе в этом случае разработчик исходной программы может даже и не подозре- вать о том, что используемая им операция предполагает перераспределение памяти. Однако это не лучшим образом будет сказываться на эффективности созданной разра- ботчиком программы.
Семантический анализ и подготовка к генерации кода 605 хранения локальных данных процедур и функций, которые в исходной програм- ме никогда не вызывают друг друга, компилятор может добиться достаточно эффективного распределения памяти в результирующей программе. У этой схе- мы есть один серьезный недостаток — она не допускает рекурсивных вызовов процедур и функций. Код Вызов (1) процедуры А Заполнение параметров процедуры А Вызов (2) процедуры А программы Заполнение параметров процедуры А Код поцедуры А Рис. 14.1. Схема статической организации дисплея памяти процедуры (функции) Действительно, если в схеме, приведенной на рис. 14.1, в момент выполнения кода процедуры А вновь выполнить рекурсивный вызов той же самой процеду- ры, то все ее локальные данные будут записаны заново (в том числе и код воз- врата!). После возврата из вызванной процедуры данные вызвавшей процедуры будут безвозвратно потеряны. Процедура, рекурсивно вызвавшая сама себя, в та- кой схеме просто не сможет корректно завершиться. То же самое произойдет, если рекурсивный вызов будет происходить не непосредственно, а через цепочку вызовов других процедур. Этот серьезный недостаток ограничивает применение данной схемы организа- ции дисплея памяти процедур и функций в современных компиляторах1. 1 Тем не менее такую простую и эффективную схему организации дисплея памяти проце- дуры (функции) можно встретить, например, в некоторых старых версиях компиляторов с языков FORTRAN и Basic (эти языки не допускают рекурсивных вызовов процедур, а потому в них возможно использование данной схемы).
606 Глава 14. Генерация и оптимизация кода Динамическая организация дисплея памяти процедуры (функции) основана на тех же принципах, что и статическая, но память для локальных данных процеду- ры выделяется в ней динамически всякий раз в момент вызова, а по завершении процедуры — освобождается. Адрес области памяти, связанной с каждой проце- дурой, в данном случае уже не может быть фиксированным — его надо каким- либо образом передать в процедуру (функцию). Для этой цели используется один из регистров процессора, называемый регистром адресации. Тогда при вызове процедуры необходимо выполнить следующие действия: □ выделить область памяти, необходимую для хранения локальных данных про- цедуры, и запомнить ее адрес; □ заполнить значения параметров процедуры и адрес возврата; □ запомнить состояние регистра адресации в точке вызова; □ поместить в регистр адресации адрес области памяти процедуры и передать управление процедуре. После выполнения этих действий может выполняться код вызванной процедуры. Доступ ко всем локальным данным и параметрам в нем осуществляется только через регистр адресации. При возврате из процедуры необходимо выполнить следующие действия: □ выбрать из области памяти процедуры адрес возврата (сразу за точкой вызо- ва) и передать туда управление; □ освободить область памяти, на которую указывает регистр адресации; □ по смещению относительно точки возврата выбрать сохраненное значение и поместить его в регистр адресации. После этого можно продолжить выполнение кода результирующей программы, следующего за вызовом процедуры. Данная схема, хотя и допускает рекурсивные вызовы, не получила широкого распространения. Это вызвано целым рядом причин. Во-первых, она требует по- стоянного динамического перераспределения памяти — это достаточно сложная операция, которая замедляет выполнение вызовов. Во-вторых, такая схема пред- полагает достаточно сложную схему адресации локальных данных. Наконец, она требует хранения промежуточных данных непосредственно в исполняемом коде программы, что представляет определенную сложность. В настоящее время эта схема не используется, поскольку уступает почти по всем показателям схеме стековой организации дисплея памяти. Стековая организация дисплея памяти процедуры (функции) основана на том, что в момент вызова все параметры процедуры и адрес возврата помещаются в единый для всей результирующей программы стек параметров, а при заверше- нии выполнения программы они извлекаются («выталкиваются») из стека. Код процедуры адресуется к локальным данным по смещениям относительно вер- хушки стека. Эта схема получила наибольшее распространение в современных компиляторах, поэтому далее она рассмотрена наиболее подробно.
Семантический анализ и подготовка к генерации кода 607 Стековая организация дисплея памяти процедуры (функции) В этой схеме компилятор организует единый для всей программы стек парамет- ров. Верхушка стека адресуется одним из регистров процессора — регистром стека. Для доступа к данным удобно использовать еще один регистр процессора, называемый базовым регистром. В начале выполнения результирующей про- граммы стек пуст. Тогда при вызове процедуры необходимо выполнить следующие действия: □ поместить в стек все параметры процедуры; □ запомнить в стеке адрес возврата и передать управление вызываемой проце- дуре; □ запомнить в стеке значение базового регистра; □ запомнить состояние регистра стека в базовом регистре; □ в начале выполнения вызываемой процедуры разместить в стеке все необхо- димые ей локальные данные. После выполнения этих действий может выполняться код вызванной процеду- ры. Доступ ко всем локальным данным и параметрам в нем может выполняться через базовый регистр (параметры лежат в стеке ниже места, указанного базо- вым регистром, а локальные переменные и константы — выше места, указанного базовым регистром, но ниже места, указанного регистром стека). При возврате из процедуры необходимо выполнить следующие действия: □ выбрать из стека все локальные данные процедуры; □ выбрать из стека значение базового регистра; □ выбрать из стека адрес возврата; □ передать управление по адресу возврата и выбрать из стека все параметры процедуры. После этого можно продолжить выполнение кода результирующей программы, следующего за вызовом процедуры. На рис. 14.2 показано, как изменяется содержание стека параметров при выпол- нении трех последовательных вызовов процедур: сначала процедуры А, затем процедуры В и снова процедуры А. При вызовах стек заполняется, а при возвра- те из кода процедур — освобождается в обратном порядке. Из рис. 14.2 видно, что при рекурсивном вызове все локальные данные последо- вательно размещаются в стеке и при этом каждая процедура работает только со своими данными. Более того, такая схема легко обеспечивает поддержку вызо- вов вложенных процедур, которые допустимы, например, в языке Pascal. Стековая организация памяти получила широкое распространение в современ- ных компиляторах. Практически все существующие ныне языки высокого уров- ня предполагают именно такую схему организации памяти, ее также используют и в программах на языках ассемблера. Широкое распространение стековой орга- низации дисплея памяти процедур и функций тесно связано также с тем, что большинство операций, выполняемых при вызове процедуры в этой схеме, реа-
608 Глава 14. Генерация и оптимизация кода лизованы в виде соответствующих машинных команд во многих современных вычислительных системах. Это, как правило, все операции со стеком парамет- ров, а также команды вызова процедуры (с автоматическим сохранением адреса возврата в стеке) и возврата из вызванной процедуры (с выборкой адреса воз- врата из стека). Такой подход значительно упрощает и ускоряет выполнение вы- зовов при стековой организации дисплея памяти процедуры (функции). Вызов 1 процедуры А Содержимое стека: j---------------- ---------------------------------4—| Регистр стека | Локальные данные процедуры А Значение базового регистра 1-------------------------- ---------------------------------◄— Базовый регистр Адрес возврата вызова 1---------1-------------------1 Фактические параметры процедуры А Вызов 2 процедуры В Содержимое стека: _________________ ---------------------------------◄—I Регистр стека | Локальные данные процедуры В .Значение базового регистра 2----.--------------------- ---------------------------------<— Базовый регистр Адрес возврата вызова 2 1---------------1 Фактические параметры процедуры В Локальные данные процедуры А Значение базового регистра 1 Адрес возврата вызова 1 Фактические параметры процедуры А Вызов 3 процедуры А Содержимое стека: ----------------- ----------------------------------◄—| Регистр стека | Локальные данные процедуры А Значение базового регистра 3 .--------------. ----------------------------------◄— Базовый регистр Адрес возврата вызова 3 1--------------1 Фактические параметры процедуры А Локальные данные процедуры В Значение базового регистра 2 Адрес возврата вызова 2 Фактические параметры процедуры В Локальные данные процедуры А Значение базового регистра 1 Адрес возврата вызова 1 Фактические параметры процедуры А Рис. 14.2. Содержание стека параметров при выполнении трех последовательных вызовов процедур
Семантический анализ и подготовка к генерации кода 609 Стековая организация дисплея памяти процедуры позволяет организовать рекур- сию вызовов (в отличие от статической схемы) и имеет все преимущества перед динамической схемой. Однако у этой схемы есть один существенный недоста- ток — память, отводимая под стек параметров, используется неэффективно. Оче- видно, что стек параметров должен иметь размер, достаточный для хранения всех параметров, локальных данных и адресов при самом глубоком вложенном вызо- ве процедуры в результирующей программе. Как правило, ни компилятор, ни разработчик программы не могут знать точно, какая максимальная глубина вло- женных вызовов процедур возможна в программе. Следовательно, не известно заранее, какая глубина стека потребуется результирующей программе во время ее выполнения. Размер стека обычно выбирается разработчиком программы «с за- пасом»1, так, чтобы гарантированно обеспечить любую возможную глубину вло- женных вызовов процедур и функций. С другой стороны, большую часть време- ни своей работы результирующая программа не использует значительную часть стека, значит, и часть оперативной памяти компьютера не используется, так как стек параметров сам по себе не может быть использован для других целей. Стековая организация дисплея памяти процедур и функций присутствует прак- тически во всех современных компиляторах. Тем не менее процедуры и функ- ции, построенные с помощью одного входного языка высокого уровня, не всегда могут быть использованы в программах, написанных на другом языке. Дело в том, что стековая организация определяет основные принципы организации дис- плея памяти процедуры, но не определяет механизм реализации данной схемы. В разных входных языках детали реализации этой схемы могут отличаться по- рядком помещения параметров процедуры в стек и извлечения из него. Эти во- просы определяются в соглашениях о вызове и передаче параметров, принятых в различных входных языках программирования. Параметры могут помещаться в стек в прямом порядке (в порядке их следова- ния в описании процедуры) или в обратном порядке. Извлекаться из стека они могут либо в момент возврата из процедуры (функции), либо непосредственно после возврата2. Кроме того, для повышения эффективности программ и увели- 1 К сожалению, нет точных расчетных методов для определения требуемого размера стека. Разработчик обычно оценивает его приближенно, исходя из логики результирующей программы. Когда во время отладки программы возникают сообщения о недостатке объ- ема стека (а в них имеется в виду именно стек параметров), то размер стремятся зна- чительно увеличить. С другой стороны, очень большой объем необходимого стека пара- метров может говорить о невысоком качестве программы. Если объем доступной памяти под стек ограничен (например, в MS-DOS — не более 64 Кбайт), это ведет к ограничению допустимой вложенности рекурсий, а бесконечная рекурсия всегда ведет к переполне- нию стека параметров. 2 Существуют два общепринятых соглашения о вызове и передаче параметров: соглашение языка Pascal и соглашение языка С. В первом случае параметры помещаются в стек в прямом порядке и извлекаются из стека в момент выполнения возврата из процедуры (функции) — то есть внутри самой процедуры (функции); во втором случае параметры помещаются в стек в обратном порядке, а извлекаются из стека после выполнения воз- врата из функции — то есть непосредственно после выполнения вызова в вызывающем коде.
610 Глава 14. Генерация и оптимизация кода чения скорости вызова процедур и функций компиляторы могут предусматри- вать передачу части их параметров не через стек, а через свободные регистры процессора. Обычно разработчику нет необходимости знать о том, какое соглашение о пере- даче параметров принято во входном языке программирования. При обработке вызовов внутри текста исходной программы компилятор сам корректно форми- рует код для выполнения вызовов процедур и функций. Наиболее развитые ком- пиляторы могут варьировать код вызова в зависимости от типа и количества параметров процедуры (функции). Однако если разработчику необходимо ис- пользовать процедуры и функции, созданные на основе одного входного языка, в программе на другом входном языке, то компилятор не может знать заранее соглашение о передаче параметров, принятое для этих процедур и функций. В этом случае разработчик должен сам позаботиться о том, чтобы соглашения о передаче параметров в двух языках были согласованы, поскольку несогласо- ванность в передаче параметров может привести к полной неработоспособности результирующей программы. Для этого во многих языках программирования су- ществуют специальные ключевые слова, которые позволяют разработчику явно указать, какое соглашение о передаче параметров будет использовано для той или иной процедуры (функции). Память для типов данных (RTTI-информация) Современные компиляторы с объектно-ориентированных языков программиро- вания предусматривают, что результирующая программа может обрабатывать не только переменные, константы и другие структуры данных, но и информацию о типах данных, описанных в исходной программе. Эта информация получила на- звание RTTI — Run Time Type Information — «информация о типах во время вы- полнения». Состав RTTI-информации определяется семантикой входного языка и реализа- цией компилятора. Как правило, для каждого типа данных в объектно-ориенти- рованном языке программирования создается уникальный идентификатор типа данных, который используется для сопоставления типов. Для него может хра- ниться наименование типа, а также другая служебная информация, которая ис- пользуется компилятором в коде результирующей программы. Вся эта инфор- мация может быть в той или иной мере доступна пользователю. Еще одна цель хранения RTTI-информации — обеспечить корректный механизм вызова вирту- альных процедур и функций (так называемое «позднее связывание»), предусмот- ренный во всех объектно-ориентированных языках программирования [8, 34, 35, 76, 71]. RTTI-информация хранится в результирующей программе в виде RTTI-табли- цы. RTTI-таблица представляет собой глобальную статическую структуру дан- ных, которая создается и заполняется в момент начала выполнения результи- рующей программы. Компилятор с объектно-ориентированного входного языка отвечает за порождение в результирующей программе кода, ответственного за заполнение RTTI-таблицы.
Семантический анализ и подготовка к генерации кода 611 Каждому типу данных в RTTI-таблице соответствует своя область данных, в ко- торой хранится вся необходимая информация об этом типе данных, его взаимо- связи с другими типами данных в общей иерархии типов данных программы, а также все указатели на код виртуальных процедур и функций, связанных с этим типом. Всю эту информацию и указатели размещает для каждого типа дан- ных в RTTI-таблице код, автоматически порождаемый компилятором. В принципе всю эту информацию можно было бы разместить непосредственно в памяти, статически или динамически отводимой для каждого экземпляра объ- екта (класса) того или иного типа. Но поскольку данная информация для всех объектов одного и того же типа совпадает, то это привело бы к нерациональному использованию оперативной памяти. Поэтому компилятор размещает всю инфор- мацию по типу данных в одном месте, а каждому экземпляру объекта (класса) при выделении памяти для него добавляет только небольшой объем служебной информации, связывающей этот объект с соответствующим ему типом данных (как правило, эта служебная информация является указателем на нужную область данных в RTTI-таблице). При статическом распределении памяти под экземпля- ры объектов (классов) компилятор сам помещает в нее необходимую служебную информацию, при динамическом распределении — порождает код, который за- полнит эту информацию во время выполнения программы (поскольку RTTI- таблица является статической областью данных, адрес ее известен и фиксиро- ван). На рис. 14.3 приведена схема, иллюстрирующая построение RTTI-таблицы и ее связь с экземплярами объектов в результирующей программе. Рис. 14.3. Взаимосвязь RTTI-таблицы с экземплярами объектов в результирующей программе Компилятор всегда сам автоматически строит код, ответственный в результи- рующей программе за создание и заполнение RTTI-таблицы и за ее взаимосвязь с экземплярами объектов (классов) различных типов. Разработчику не надо за-
612 Глава 14. Генерация и оптимизация кода ботиться об этом, тем более что он, как правило, не может воздействовать на слу- жебную информацию, помещаемую компилятором в RTTI-таблицу. Проблемы могут появиться в том случае, когда некоторая программа взаимодей- ствует с динамически загружаемой библиотекой. При этом могут возникнуть ситуации, вызванные несоответствием типов данных в программе и библиотеке, хотя и та и другая могут быть построены на одном и том же входном языке с по- мощью одного и того же компилятора. Дело в том, что код инициализации про- граммы, порождаемый компилятором, ответственен за создание и заполнение своей RTTI-таблицы, а код инициализации библиотеки, порождаемый тем же компилятором, — своей RTTI-таблицы. Если программа и динамически загру- жаемая библиотека работают с одними и теми же типами данных, то эти RTTI- таблицы будут полностью совпадать, но при этом они не будут одной и той же таблицей. Это уже должен учитывать разработчик программы при проверке со- ответствия типов данных. Еще большие сложности могут возникнуть, если программа, построенная на ос- нове входного объектно-ориентированного языка программирования, будет взаи- модействовать с библиотекой или программой, построенной на основе другого объектно-ориентированного языка (или даже построенной на том же языке, но с помощью другого компилятора). Поскольку формат RTTI-таблиц и состав хра- нимой в них служебной информации не специфицированы для всех языков про- граммирования, то они полностью зависят от используемой версии компилято- ра. Соответствующим образом различается и порождаемый компилятором код результирующей программы. В общем случае организовать такого рода взаимо- действие между двумя фрагментами кода, порожденного двумя различными ком- пиляторами, напрямую практически невозможно (о других возможностях такого рода взаимодействия см. в разделе «Примеры современных систем программи- рования», глава 15). Генерация кода. Методы генерации кода Общие принципы генерации кода. Синтаксически управляемый перевод Генерация объектного кода — это перевод компилятором внутреннего представ- ления исходной программы в цепочку символов выходного языка. Генерация объектного кода порождает результирующую объектную программу на языке ас- семблера или непосредственно на машинном языке (в машинных кодах). Внут- реннее представление программы может иметь любую структуру в зависимости от реализации компилятора, в то время как результирующая программа всегда представляет собой линейную последовательность команд. Поэтому генерация объектного кода (объектной программы) в любом случае должна выполнять дей- ствия, связанные с преобразованием сложных синтаксических структур в линей- ные цепочки.
Генерация кода. Методы генерации кода 613 Генерацию кода можно считать функцией, определенной на синтаксическом де- реве и на информации, содержащейся в таблице идентификаторов. Поэтому ге- нерация объектного кода выполняется после того, как выполнен синтаксический анализ программы и все необходимые действия по подготовке к генерации кода: распределено адресное пространство под функции и переменные, проверено соответствие имен и типов переменных, констант и функций в синтаксических конструкциях исходной программы и т. д. Характер отображения входной про- граммы в последовательность команд, выполняемого генерацией, зависит от вход- ного языка, архитектуры вычислительной системы, на которую ориентирована результирующая программа, а также от качества желаемого объектного кода. В идеале компилятор должен выполнить синтаксический разбор всей входной программы, затем провести ее семантический анализ, после чего приступить к подготовке генерации и непосредственно генерации кода. Однако такая схема ра- боты компилятора практически почти никогда не применяется. Дело в том, что в общем случае ни один семантический анализатор и ни один компилятор не способны проанализировать и оценить смысл всей входной программы в целом. Формальные методы анализа семантики применимы только к очень незначи- тельной части возможных входных программ. Поэтому у компилятора нет прак- тической возможности порождать эквивалентную выходную программу на осно- ве всей входной программы. Как правило, компилятор выполняет генерацию результирующего кода поэтапно, на основе законченных синтаксических конструкций входной программы. Ком- пилятор выделяет законченную синтаксическую конструкцию из текста входной программы, порождает для нее фрагмент результирующего кода и помещает его в текст выходной программы. Затем он переходит к следующей синтаксической конструкции. Так продолжается до тех пор, пока не будет разобрана вся входная программа. В качестве анализируемых законченных синтаксических конструк- ций выступают операторы, блоки операторов, описания процедур и функций. Их конкретный состав зависит от входного языка и реализации компилятора. Смысл (семантику) каждой такой синтаксической конструкции входного языка можно определить, исходя из ее типа, а тип определяется синтаксическим анали- затором на основании грамматики входного языка. Примерами типов синтак- сических конструкций могут служить операторы цикла, условные операторы, операторы выбора и т. д. Одни и те же типы синтаксических конструкций харак- терны для различных языков программирования, при этом они различаются синтаксисом (который задается грамматикой языка), но имеют схожий смысл (который определяется семантикой). В зависимости от типа синтаксической конструкции выполняется генерация кода результирующей программы, соответ- ствующего данной синтаксической конструкции. Для семантически схожих кон- струкций различных входных языков программирования может порождаться ти- повой результирующий код. Чтобы компилятор мог построить код результирующей программы для синтак- сической конструкции входного языка, часто используется метод, называемый синтаксически управляемым переводом — СУ-переводом. СУ-перевод — это ос- новной метод порождения кода результирующей программы на основании ре-
614 Глава 14. Генерация и оптимизация кода зультатов синтаксического разбора. Для удобства понимания сути метода можно считать, что результат синтаксического разбора представлен в виде дерева син- таксического разбора (либо же дерева операций), хотя в реальных компиляторах это не всегда так. Суть принципа СУ-перевода заключается в следующем: с каждой вершиной де- рева синтаксического разбора N связывается цепочка некоторого промежуточного кода C(N). Код для вершины N строится путем сцепления (конкатенации) в фик- сированном порядке последовательности кода С(N) и последовательностей кодов, связанных со всеми вершинами, являющимися прямыми потомками N. В свою очередь, для построения последовательностей кода прямых потомков вершины N потребуется найти последовательности кода для их потомков — потомков вто- рого уровня вершины N - и т. д. Процесс перевода идет, таким образом, снизу вверх в строго установленном порядке, определяемом структурой дерева. Для того чтобы построить СУ-перевод по заданному дереву синтаксического разбора, необходимо найти последовательность кода для корня дерева. Поэтому для каждой вершины дерева порождаемую цепочку кода надо выбирать таким образом, чтобы код, приписываемый корню дерева, оказался искомым кодом для всего оператора, представленного этим деревом. В общем случае необходимо иметь единообразную интерпретацию кода C(N), которая бы встречалась во всех ситуациях, где присутствует вершина N. В принципе эта задача может оказаться нетривиальной, так как требует оценки смысла (семантики) каждой вершины дерева. При применении СУ-перевода задача интерпретации кода для каждой вершины дерева решается только разработчиком компилятора. Возможна модель компилятора, в которой синтаксический анализ входной про- граммы и генерация кода результирующей программы объединены в одну фазу. Такую модель можно представить в виде компилятора, у которого операции ге- нерации кода совмещены с операциями выполнения синтаксического разбора. Для описания компиляторов такого типа часто используется термин «СУ-ком- пиляция» (синтаксически управляемая компиляция). Схему СУ-компиляции можно реализовать не для всякого входного языка про- граммирования. Если принцип СУ-перевода применим ко всем входным КС- языкам, то применить СУ-компиляцию оказывается не всегда возможным. Од- нако известно, что схемы перевода на основе СУ-компиляции можно построить для многих из широко распространенных классов КС-языков, в частности для LR- и LL-языков [6, 15]. В процессе СУ-перевода и СУ-компиляции не только вырабатываются цепочки текста выходного языка, но и совершаются некоторые дополнительные действия, выполняемые самим компилятором. В общем случае схемы СУ-перевода могут предусматривать выполнение следующих действий: □ помещение в выходной поток данных машинных кодов или команд ассембле- ра, представляющих собой результат работы (выход) компилятора; □ выдача пользователю сообщений об обнаруженных ошибках и предупрежде- ниях (которые должны помещаться в выходной поток, отличный от потока, используемого для команд результирующей программы);
Генерация кода. Методы генерации кода 615 □ порождение и выполнение команд, указывающих, что некоторые действия должны быть произведены самим компилятором (например, операции, вы- полняемые над данными, размещенными в таблице идентификаторов). Ниже рассмотрены основные технические вопросы, позволяющие реализовать схемы СУ-перевода и СУ-компиляции. Но прежде чем рассматривать их, необ- ходимо разобраться со способами внутреннего представления программы в ком- пиляторе. От того, как исходная программа представляется внутри компилятора, во многом зависят методы, используемые для обработки команд этой программы. Способы внутреннего представления программ Возможны различные формы внутреннего представления синтаксических конст- рукций исходной программы в компиляторе. На этапе синтаксического разбора часто используется форма, именуемая деревом вывода (методы его построения рассматривались выше). Но формы представления, используемые на этапах син- таксического анализа, оказываются неудобными в работе при генерации и опти- мизации объектного кода. Поэтому перед оптимизацией и непосредственно пе- ред генерацией объектного кода внутреннее представление программы может преобразовываться в одну из соответствующих форм записи. Все внутренние представления программы обычно содержат в себе две принци- пиально различные вещи — операторы и операнды. Различия между формами внутреннего представления заключаются лишь в том, как операторы и операн- ды соединяются между собой. Также операторы и операнды должны отличаться друг от друга, если они встречаются в любом порядке. За различение операндов и операторов, как уже было сказано выше, отвечает разработчик компилятора, который руководствуется семантикой входного языка. Известны следующие формы внутреннего представления программ1: □ связочные списочные структуры, представляющие синтаксические деревья; □ многоадресный код с явно именуемым результатом (тетрады); □ многоадресный код с неявно именуемым результатом (триады); □ обратная (постфиксная) польская запись операций; □ ассемблерный код или машинные команды. В каждом конкретном компиляторе может использоваться одна из этих форм, выбранная разработчиками. Но чаще всего компилятор не ограничивается ис- 1 Существуют три формы записи выражений — префиксная, инфиксная и постфиксная. При префиксной записи операция записывается перед своими операндами, при инфикс- ной — между операндами, а при постфиксной — после операндов. Общепринятая запись арифметических выражений является примером инфиксной записи. Запись математиче- ских функций и функций в языках программирования является префиксной (другие примеры префиксной записи — команды ассемблера, триады и тетрады, в том виде, как они рассмотрены далее). Постфиксная запись в повседневной жизни встречается редко. С нею сталкиваются разве что пользователи стековых калькуляторов и программисты на языке Forth.
616 Глава 14. Генерация и оптимизация кода пользованием только одной формы для внутреннего представления программы. На различных фазах компиляции могут использоваться различные формы, кото- рые по мере выполнения проходов компилятора преобразуются одна в другую. Не все из перечисленных форм широко используются в современных компиля- торах, и об этом будет сказано по мере их рассмотрения. Некоторые компиляторы, незначительно оптимизирующие результирующий код, генерируют объектный код по мере разбора исходной программы. В этом случае применяется схема СУ-компиляции, когда фазы синтаксического разбора, семан- тического анализа, подготовки и генерации объектного кода совмещены в одном проходе компилятора. Тогда внутреннее представление программы существует только условно в виде последовательности шагов алгоритма разбора. В любом случае компилятор всегда будет работать с представлением программы в форме машинных команд — иначе он не сможет построить результирующую программу. Далее все перечисленные формы представления рассматриваются более подробно. Синтаксические деревья Синтаксические деревья уже были рассмотрены выше. Это структура, представ- ляющая собой результат работы синтаксического анализатора. Она отражает синтаксис конструкций входного языка и явно содержит в себе полную взаимо- связь операций. Очевидно также, что синтаксические деревья — это машинно- независимая форма внутреннего представления программы. Недостаток синтаксических деревьев заключается в том, что они представля- ют собой сложные связные структуры, а потому не могут быть тривиальным об- разом преобразованы в линейную последовательность команд результирующей программы. Тем не менее они удобны при работе с внутренним представлением программы на тех этапах, когда нет необходимости непосредственно обращаться к командам результирующей программы. Синтаксические деревья могут быть преобразованы в другие формы внутренне- го представления программы, представляющие собой линейные списки, с учетом семантики входного языка. Алгоритмы такого рода преобразований рассмотрены далее. Эти преобразования выполняются на основе принципов СУ-компиляции. Многоадресный код с явно именуемым результатом (тетрады) Тетрады представляют собой запись операций в форме из четырех составляю- щих: операция, два операнда и результат операции. Например, тетрады могут выглядеть так: <операция>(<операнд1>,<операнд2>,<результат>). Тетрады представляют собой линейную последовательность команд. При вычис- лении выражения, записанного в форме тетрад, они вычисляются одна за другой последовательно. Каждая тетрада в последовательности вычисляется так: опера- ция, заданная тетрадой, выполняется над операндами и результат ее выполнения помещается в переменную, заданную результатом тетрады. Если какой-то из операндов (или оба операнда) в тетраде отсутствует (например, если тетрада представляет собой унарную операцию), то он может быть опущен или заменен пустым операндом (в зависимости от принятой формы записи и ее реализации).
Генерация кода. Методы генерации кода 617 Результат вычисления тетрады никогда опущен быть не может, иначе тетрада полностью теряет смысл. Порядок вычисления тетрад может быть изменен, но только если допустить наличие тетрад, целенаправленно изменяющих этот поря- док (например, тетрады, вызывающие переход на несколько шагов вперед или назад при каком-то условии). Тетрады представляют собой линейную последовательность, а потому для них несложно написать тривиальный алгоритм, который будет преобразовывать последовательность тетрад в последовательность команд результирующей про- граммы (либо последовательность команд ассемблера). В этом их преимущество перед синтаксическими деревьями. А в отличие от команд ассемблера тетрады не зависят от архитектуры вычислительной системы, на которую ориентирована результирующая программа. Поэтому они представляют собой машинно-незави- симую форму внутреннего представления программы. Тетрады требуют больше памяти для своего представления, чем триады, они так- же не отражают явно взаимосвязь операций между собой. Кроме того, есть слож- ности с преобразованием тетрад в машинный код, так как они плохо отобража- ются в команды ассемблера и машинные коды, поскольку в наборах команд большинства современных компьютеров редко встречаются операции с тремя операндами. Например, выражение A:=B*C+D-B*10, записанное в виде тетрад, будет иметь вид: 1. * ( В, С, Т1 ) 2. + ( Tl.D, Т2 ) 3. * ( В, 10,ТЗ ) 4. - ( Т2,ТЗ,Т4 ) 5. := ( Т4,0, А ) Здесь все операции обозначены соответствующими знаками (при этом присвое- ние также является операцией). Идентификаторы Т1... Т4 обозначают времен- ные переменные, используемые для хранения результатов вычисления тетрад. Следует обратить внимание, что в последней тетраде (присвоение), которая тре- бует только одного операнда, в качестве второго операнда выступает незначащий операнд «0». Многоадресный код с неявно именуемым результатом (триады) Триады представляют собой запись операций в форме из трех составляющих: операция и два операнда. Например, триады могут иметь вид: <операция>(<операнд1>, <операнд2>). Особенностью триад является то, что один или оба операнда могут быть ссылками на другую триаду в том случае, если в качестве операнда данной триады выступает результат выполнения другой триады. Поэтому триады при записи последовательно нумеруют для удобства указания ссылок одних триад на другие (в реализации компилятора в качестве ссылок можно использовать не но- мера триад, а непосредственно ссылки в виде указателей — тогда при изменении нумерации и порядка следования триад менять ссылки не требуется).
618 Глава 14. Генерация и оптимизация кода Триады представляют собой линейную последовательность команд. При вычис- лении выражения, записанного в форме триад, они вычисляются одна за другой последовательно. Каждая триада в последовательности вычисляется так: опера- ция, заданная триадой, выполняется над операндами, а если в качестве одного из операндов (или обоих операндов) выступает ссылка на другую триаду, то берет- ся результат вычисления той триады. Результат вычисления триады нужно со- хранять во временной памяти, так как он может быть затребован последующими триадами. Если какой-то из операндов в триаде отсутствует (например, если триада представляет собой унарную операцию), то он может быть опущен или заменен пустым операндом (в зависимости от принятой формы записи и ее реа- лизации). Порядок вычисления триад, как и для тетрад, может быть изменен, но только если допустить наличие триад, целенаправленно изменяющих этот поря- док (например, триады, вызывающие переход на несколько шагов вперед или на- зад при каком-то условии). Триады представляют собой линейную последовательность, а потому для них несложно написать тривиальный алгоритм, который будет преобразовывать по- следовательность триад в последовательность команд результирующей програм- мы (либо последовательность команд ассемблера). В этом их преимущество перед синтаксическими деревьями. Однако здесь требуется также и алгоритм, отвечающий за распределение памяти, необходимой для хранения промежуточ- ных результатов вычисления, так как временные переменные для этой цели не используются. В этом отличие триад от тетрад. Так же как и тетрады, триады не зависят от архитектуры вычислительной систе- мы, на которую ориентирована результирующая программа. Поэтому они пред- ставляют собой машинно-независимую форму внутреннего представления про- граммы. Триады требуют меньше памяти для своего представления, чем тетрады, они также явно отражают взаимосвязь операций между собой, что делает их приме- нение удобным. Необходимость иметь алгоритм, отвечающий за распределение памяти для хранения промежуточных результатов, не является недостатком, так как удобно распределять результаты не только по доступным ячейкам времен- ной памяти, но и по имеющимся регистрам процессора. Это дает определенные преимущества. Триады ближе к двухадресным машинным командам, чем тетра- ды, а именно эти команды более всего распространены в наборах команд боль- шинства современных компьютеров. Например, выражение A:=B*C+D-B*10, записанное в виде триад, будет иметь вид: 1. * ( В, С ) 2. + Г1, D ) 3. * ( В, 10 ) 4. - ("2, '3 ) 5. :в ( А, ^4 ) Здесь операции обозначены соответствующим знаком (при этом присвоение так- же является операцией), а знак * означает ссылку операнда одной триады на ре- зультат другой.
Генерация кода. Методы генерации кода 619 Обратная польская запись операций Обратная (постфиксная) польская запись — очень удобная для вычисления вы- ражений форма записи операций и операндов. Эта форма предусматривает, что знаки операций записываются после операндов. Более подробно обратная польская запись рассмотрена далее. Ассемблерный код и машинные команды Машинные команды удобны тем, что при их использовании внутреннее пред- ставление программы полностью соответствует объектному коду и сложные пре- образования не требуются. Команды ассемблера представляют собой лишь форму записи машинных команд (см. раздел «Трансляторы, компиляторы и интерпре- таторы — общая схема работы», глава 13), а потому в качестве формы внутренне- го представления программы практически ничем не отличаются от них. Однако использование команд ассемблера или машинных команд для внутрен- него представления программы требует дополнительных структур для отображе- ния взаимосвязи операций. Очевидно, что в этом случае внутреннее представле- ние программы получается зависимым от архитектуры вычислительной системы, на которую ориентирован результирующий код. Значит, при ориентации ком- пилятора на другой результирующий код потребуется перестраивать как само внутреннее представление программы, так и методы его обработки (при исполь- зовании триад или тетрад этого не требуется). Тем не менее машинные команды — это язык, на котором должна быть записана результирующая программа. Поэтому компилятор так или иначе должен рабо- тать с ними. Кроме того, только обрабатывая машинные команды (или их пред- ставление в форме команд ассемблера), можно добиться наиболее эффективной результирующей программы (см. далее в разделе «Оптимизация кода. Основные методы оптимизации»). Отсюда следует, что любой компилятор работает с пред- ставлением результирующей программы в форме машинных команд, однако их обработка происходит, как правило, на завершающих этапах фазы генерации кода. Обратная польская запись операций Обратная польская запись — это постфиксная запись операций. Она была пред- ложена польским математиком Я. Лукашевичем, откуда и происходит ее назва- ние [6, т. 2, 23, 82]Ч В этой записи знаки операций записываются непосредственно за операндами. По сравнению с обычной (инфиксной) записью операций в польской записи опе- ранды следуют в том же порядке, а знаки операций — строго в порядке их вы- 1 Ошибочно думать, что польская запись выражений используется для записи арифмети- ческих выражений в Польше — это не так. В Польше, как и во многих других странах мира, пользуются привычной всем инфиксной формой записи, когда знаки математиче- ских операций ставятся между операндами.
620 Глава 14. Генерация и оптимизация кода полнения. Тот факт, что в этой форме записи все операции выполняются в том порядке, в котором они записаны, делает ее чрезвычайно удобной для вычис- ления выражений на компьютере. Польская запись не требует учитывать при- оритет операций, в ней не употребляются скобки, и в этом ее основное преиму- щество. Она чрезвычайно эффективна в тех случаях, когда для вычислений использует- ся стек. Ниже будет рассмотрен алгоритм вычисления выражений в форме об- ратной польской записи с использованием стека. Главный недостаток обратной польской записи также проистекает из метода вы- числения выражений в ней: поскольку используется стек, то для работы с ним всегда доступна только верхушка стека, а это делает крайне затруднительной оптимизацию выражений в форме обратной польской записи. Практически вы- ражения в форме обратной польской записи почти не поддаются оптимизации. Но там, где оптимизация вычисления выражений не требуется или не имеет большого значения, обратная польская запись оказывается очень удобным мето- дом внутреннего представления программы. Обратная польская запись была предложена первоначально для записи арифме- тических выражений. Однако этим ее применение не ограничивается. В компи- ляторе можно порождать код в форме обратной польской записи для вычисления практически любых выражений1. Для этого достаточно ввести знаки, предусмат- ривающие вычисление соответствующих операций. В том числе, возможно ввести операции условного и безусловного перехода, предполагающие изменение после- довательности хода вычислений и перемещение вперед или назад на некоторое количество шагов в зависимости от результата на верхушке стека [6, т. 2, 74, 82]. Такой подход позволяет очень широко применять форму обратной польской за- писи. Преимущества и недостатки обратной польской записи определяют и сферу ее применения. Так, она очень широко используется для вычисления выражений в интерпретаторах и командных процессорах, где оптимизация вычислений либо отсутствует вовсе, либо не имеет существенного значения. Вычисление выражений с помощью обратной польской записи Вычисление выражений в обратной польской записи идет элементарно просто с помощью стека. Для этого выражение просматривается в порядке слева напра- во, и встречающиеся в нем элементы обрабатываются по следующим правилам: 1. Если встречается операнд, то он помещается в стек (попадает на верхушку стека). 2. Если встречается знак унарной операции (операции, требующей одного опе- ранда), то операнд выбирается с верхушки стека, операция выполняется и результат помещается в стек (попадает на верхушку стека). 1 Язык программирования Forth является хорошим примером языка, где для вычисления любых выражений явно используется стек и обратная польская запись операций.
Генерация кода. Методы генерации кода 621 3. Если встречается знак бинарной операции (операции, требующей двух опе- рандов), то два операнда выбираются с верхушки стека, операция выполняет- ся и результат помещается в стек (попадает на верхушку стека). Вычисление выражения заканчивается, когда достигается конец записи выраже- ния. Результат вычисления при этом всегда находится на верхушке стека. Очевидно, что данный алгоритм можно легко расширить и для более сложных операций, требующих три и более операндов. На рис. 14.4 рассмотрены примеры вычисления выражений в обратной польской записи. Вычисление выражения 6+(7+10) * 4=74 74 Вычисление выражения 6+7+10 * 4 = 53 Рис. 14.4. Вычисление выражений в обратной польской записи с использованием стека Схема СУ-компиляции для перевода выражений в обратную польскую запись Существует множество алгоритмов, которые позволяют преобразовывать выра- жения из обычной (инфиксной) формы записи в обратную польскую запись. Далее рассмотрен алгоритм, построенный на основе схемы СУ-компиляции для языка арифметических выражений с операциями +, -, * и /. В качестве основы алгоритма выбрана грамматика арифметических выражений, которая уже много- кратно рассматривалась ранее в качестве примера. Эта грамматика приведена далее.
622 Глава 14. Генерация и оптимизация кода G({+,a,b}. {S.T.E}, Р, S): Р: S -> S+T I S-Т I т Т -> Т*Е I Т/Е I Е Е -> (S) | а | b Схему СУ-компиляции будем строить с таким расчетом, что имеется выходная цепочка символов R и известно текущее положение указателя в этой цепочке р. Распознаватель, выполняя свертку или подбор альтернативы по правилу грам- матики, может записывать символы в выходную цепочку и менять текущее по- ложение указателя в ней. Построенная таким образом схема СУ-компиляции для преобразования арифме- тических выражений в форму обратной польской записи оказывается исключи- тельно простой [6, т. 2]. Она приведена ниже. В ней с каждым правилом грамма- тики связаны некоторые действия, которые записаны через : (точку с запятой) сразу за правой частью каждого правила. Если никаких действий выполнять не нужно, в записи следует пустая цепочка (X). S -> S+T; R(p) = р=р+1 S -» S-T; R(p) = р=р+1 S -> Т; X Т —► Т*Е: R(p) = р=р+1 Т -> Т/Е; R(p) » "/", р=р+1 Т -> Е; X Е -> (S); X Е -> a; R(p) = "а". р=р+1 Е -> b: R(p) = "b", р=р+1 Эту схему СУ-компиляции можно использовать для любого распознавателя без возвратов, допускающего разбор входных цепочек на основе правил данной грамматики (например, для распознавателя на основе грамматики операторного предшествования, рассмотренного в разделе «Восходящие распознаватели КС- языков без возвратов», глава 12). Тогда, в соответствии с принципом СУ-перево- да, каждый раз выполняя свертку на основе некоторого правила, распознаватель будет выполнять также и действия, связанные с этим правилом. В результате его работы будет построена цепочка R, содержащая представление исходного выра- жения в форме обратной польской записи (строго говоря, в данном случае авто- мат будет представлять собой уже не только распознаватель, но и преобразова- тель цепочек [6, 42]). Представленная схема СУ-компиляции, с одной стороны, иллюстрирует, насколь- ко просто выполнить преобразование выражения в форму обратной польской за- писи, а с другой стороны, демонстрирует, как на практике работает сам принцип СУ-компиляции. Схемы СУ-перевода Выше был описан принцип СУ-перевода, позволяющий получить линейную последовательность команд результирующей программы или внутреннего пред-
Генерация кода. Методы генерации кода 623 ставления программы в компиляторе на основе результатов синтаксического разбора. Теперь построим вариант алгоритма генерации кода, который получает на входе дерево операций и создает по нему фрагмент объектного кода для ли- нейного участка результирующей программы. Рассмотрим примеры схем СУ-пе- ревода для арифметических операций. Эти схемы достаточно просты, и на их основе можно проиллюстрировать, как выполняется СУ-перевод в компиляторе при генерации кода. В качестве формы представления результатов синтаксического разбора возьмем дерево операций (метод построения дерева операций рассмотрен выше). Для по- строения внутреннего представления объектного кода (в дальнейшем — просто кода) по дереву операций может использоваться простейшая рекурсивная про- цедура обхода дерева. Можно использовать и другие методы обхода дерева — важно, чтобы соблюдался принцип, что нижележащие операции в дереве всегда выполняются перед вышележащими операциями (порядок выполнения опера- ций одного уровня не важен, он не влияет на результат и зависит от порядка об- хода вершин дерева). Процедура генерации кода по дереву операций прежде всего должна опреде- лить тип узла дерева. Он соответствует типу операции, символом которой поме- чен узел дерева. После определения типа узла процедура строит код для узла де- рева в соответствии с типом операции. Если все узлы следующего уровня для текущего узла есть листья дерева, то в код включаются операнды, соответствую- щие этим листьям, и получившийся код становится результатом выполнения процедуры. Иначе процедура должна рекурсивно вызвать сама себя для генера- ции кода нижележащих узлов дерева и результат выполнения включить в свой порожденный код. Фактически процедура генерации кода должна для каждого узла дерева выполнить конкатенацию цепочек команд, связанных с текущим уз- лом и с нижележащими узлами. Конкатенация цепочек выполняется таким обра- зом, чтобы операции, связанные с нижележащими узлами, выполнялись до вы- полнения операции, связанной с текущим узлом. Поэтому для построения внутреннего представления объектного кода по дереву вывода в первую очередь необходимо разработать формы представления объект- ного кода для четырех случаев, соответствующих видам текущего узла дерева вывода: □ оба нижележащих узла дерева — листья, помеченные операндами; □ только левый нижележащий узел является листом дерева; □ только правый нижележащий узел является листом дерева; □ оба нижележащих узла не являются листьями дерева. Рассмотрим построение двух видов внутреннего представления по дереву вы- вода: □ построение списка триад по дереву вывода; □ построение ассемблерного кода по дереву вывода. Далее рассматриваются две функции, реализующие схемы СУ-перевода для каж- дого их этих случаев.
624 Глава 14. Генерация и оптимизация кода Пример схемы СУ-перевода дерева операций на язык ассемблера В качестве языка ассемблера возьмем язык ассемблера процессоров типа Intel 80x86. При этом будем считать, что операнды могут быть помещены в 16-разряд- ные регистры процессора и в коде результирующей объектной программы могут использоваться регистры АХ (аккумулятор) и DX (регистр данных), а также стек для хранения промежуточных результатов. Функцию, реализующую перевод узла дерева в последовательность команд ас- семблера, назовем Code. Входными данными функции должна быть информация об узле дерева операций. В ее реализации на каком-либо языке программирова- ния эти данные можно представить в виде указателя на соответствующий узел дерева операций. Выходными данными является последовательность команд языка ассемблера. Будем считать, что она передается в виде строки, возвращае- мой в качестве результата функции. Тогда четырем формам текущего узла дерева для каждой арифметической опера- ции будут соответствовать фрагменты кода на языке ассемблера, приведенные в табл. 14.1. Каждой допустимой арифметической операции будет соответствовать своя ко- манда на языке ассемблера. Если взять в качестве примера операции сложения (+), вычитания (-), умножения (*) и деления (/), то им будут соответствовать ко- манды add, sub, mul и div. Причем в ассемблере Intel 80x86 от типа операции зави- сит не только тип, но и синтаксис команды (операции mul и div в качестве первого операнда всегда предполагают регистр процессора АХ, который не нужно указы- вать в команде). Соответствующая команда должна записываться вместо act при порождении кода в зависимости от типа узла дерева. Таблица 14.1. Преобразование узлов дерева вывода в код на языке ассемблера для арифметических операций Вид узла дерева Результирую- щий код Примечание act mov ах, operl act ах, орег2 act — команда соответствующей операции operl, орег2 — операнды (листья дерева) орег 1 орег 2 act Code (Узел 2) mov dx, ах mov ах, operl act ах, dx Узел 2 — нижележащий узел (не лист!) дерева Code (Узел 2) — код, порождаемый процедурой для нижележащего узла орег 1 Узел 2
Генерация кода. Методы генерации кода 625 Вид узла дерева Результирую- щий КОД Примечание act Code (Узел 2) act ах, орег2 Code (Узел 2) — код, порождаемый процедурой для нижележащего узла Узел 2 oper 2 act Code (Узел 2) push ах Code (Узел 3) mov dx, ах pop ах act ах, dx Code (Узел 2) — код, порождаемый процедурой для нижележащего узла Code (Узел 3) — код, порождаемый процедурой для нижележащего узла push и pop — команды сохранения результатов в стеке и извлечения результатов из стека Узел 2 Узел 3 Код, порождаемый для операции присвоения результата, будет отличаться от кода, порождаемого для арифметических операций. Кроме того, семантика язы- ка требует, чтобы в левой части операции присвоения всегда был операнд, поэто- му для нее возможны только два типа узлов дерева, влекущие порождение кода (два других типа узлов должны приводить к сообщению об ошибке, которая в компиляторе обнаруживается синтаксическим анализатором). Фрагменты кода для этой операции приведены ниже в табл. 14.2. Таблица 14.2. Преобразование узлов дерева вывода в код на языке ассемблера для операции присвоения Вид узла дерева Результирую- щий код Примечание mov ах, орег2 mov operl, ах operl, oper2 — операнды (листья дерева) орег 1 орег 2 орег 1 Узел 2 Code (Узел 2) mov operl, ах Узел 2 — нижележащий узел (не лист!) дерева Code (Узел 2) — код, порождаемый процедурой для нижележащего узла
626 Глава 14. Генерация и оптимизация кода Теперь последовательность порождаемого кода определена для всех возможных типов узлов дерева. Рассмотрим в качестве примера выражение A:=B*C+D-B*1O. Соответствующее ему дерево вывода приведено на рис. 14.5. Рис. 14.5. Дерево операций для арифметического выражения «A:=B*C+D-B*10» Построим последовательность команд языка ассемблера, соответствующую де- реву операций на рис. 14.5. Согласно принципу СУ-перевода, построение начи- нается от корня дерева. Для удобства иллюстрации рекурсивного построения последовательности команд все узлы дерева помечены от U1 до U5. Рассмотрим последовательность построения цепочки команд языка ассемблера по шагам ре- курсии. Эта последовательность приведена ниже. Шаг 1. Code(U2) mov А, ах Шаг 2. Code(U3) push ах Code(U5) mov dx. ах pop ах sub ах. dx mov A, ax Шаг 3. Code(U4) add ax. D push ax Code(U5) mov dx. ax pop ax sub ax. dx mov A.ax Шаг 4. mov ax, В mul C add ax. D
Генерация кода. Методы генерации кода 627 Шаг 5. push ах Code(U5) mo dx. ах pop ах sub ах. dx mov А. ах mov ах. В mul С add ах. D push ах mov ax. В mul 10 mov dx. ax pop ax sub ax. dx mov A. ax Полученный объектный код на языке ассемблера, очевидно, может быть оптими- зирован. Вопросы оптимизации результирующего кода рассмотрены далее в раз- деле «Оптимизация кода. Основные методы оптимизации». В рассмотренном примере при порождении кода преднамеренно не были приня- ты во внимание многие вопросы, возникающие при построении реальных ком- пиляторов. Это было сделано для упрощения примера. Например, фрагменты кода, соответствующие различным узлам дерева, принимают во внимание тип операции, но никак не учитывают тип операндов. А в языке ассемблера операн- дам различных типов соответствуют существенно различающиеся команды, мо- гут использоваться различные регистры (например, если сравнить операции с целыми числами и числами с плавающей точкой). Некоторые типы операндов (например, строки) не могут быть обработаны одной командой языка, а требуют целой последовательности команд. Такую последовательность разумнее оформить в виде функции библиотеки языка, тогда выполнение операции будет представ- лять собой вызов этой функции. Кроме того, ориентация на определенный язык ассемблера, очевидно, сводит на нет универсальность метода. Каждый тип операций жестко связывается с после- довательностью определенных команд, ориентированных на некоторую архитек- туру вычислительной системы. Все эти требования ведут к тому, что в реальном компиляторе при генерации ко- да надо принимать во внимание очень многие особенности, зависящие как от се- мантики входного языка, так и от архитектуры целевой вычислительной системы, на которой будет выполняться результирующая программа. В результате увели- чится число различных типов узлов дерева, каждому из которых будет соответ- ствовать своя последовательность команд. Это может осложнить алгоритм гене- рации кода, что особенно нежелательно в том случае, если порождаемый код не является окончательным и требует дальнейшей обработки.
628 Глава 14. Генерация и оптимизация кода Указанных проблем можно избежать, если порождать код не в виде цепочки команд ассемблера, а в виде последовательности команд машинно-независимого метода внутреннего представления программы — например, тетрад или триад. Пример схемы СУ-перевода дерева операций в последовательность триад Как и в случае с порождением команд ассемблера, функцию, реализующую пере- вод узла дерева в последовательность триад, назовем Code. Входные и выходные данные у этой функции будут те же, что и при работе с командами ассемблера. Триады являются машинно-независимой формой внутреннего представления в компиляторе результирующей объектной программы, а потому не требуют ого- ворки дополнительных условий при генерации кода. Триады взаимосвязаны ме- жду собой, поэтому для установки корректной взаимосвязи функция генерации кода должна учитывать также текущий номер очередной триады (1). Будем рассматривать те же варианты узлов дерева, что и для команд ассемблера. Тогда четырем формам текущего узла дерева будут соответствовать последова- тельности триад объектного кода, приведенные в табл. 14.3. Таблица 14.3. Преобразование типовых узлов дерева вывода в последовательность триад Вид узла дерева Результирую- щий код Примечание act ''к i) act (operl, oper2) act — тип триады operl, oper2 — операнды (листья дерева вывода) орег 1 орег 2 / act i) Code (Узел 2, i) i+j) act (operl, Ai+j-l) Узел 2 — нижележащий узел дерева вывода Code (Узел 2, i) — последовательность триад, порождаемая для Узла 2, начиная с триады с номером i, j — количество триад, порождаемых для Узла 2 орег 1 Узел 2 Узел 2 act орег 2 i) Code (Узел 2, i) i+j) act (Ai+j-l, oper2) Узел 2 — нижележащий узел дерева вывода Code (Узел 2, i) — последовательность триад, порождаемая для Узла 2, начиная с триады с номером i, j — количество триад, порождаемых для Узла 2
Генерация кода. Методы генерации кода 629 Вид узла дерева Результирую- щий код Примечание Узел 2 act Узел 3 i) Code (Узел 2, i) i+j) Code (Узел 3, i+j) i+j+k) act Ci+j-t Ai+j+k-l) Узел 2, Узел 3 — нижележащие узлы дерева вывода Code (Узел 2, i) — последовательность триад, порождаемая для Узла 2, начиная с триады с номером i, j — количество триад, порождаемых для Узла 2 Code (Узел 3, i+j) — последовательность триад, порождаемая для Узла 3, начиная с триады с номером i+j, к — количество триад, порождаемых для Узла 3 Для триад не требуется учитывать операцию присваивания как отдельный тип узла дерева. Она преобразуется В триаду обычного вида. Рассмотрим в качестве примера то же самое выражение A:=B*C+D-B*1O. Соответст- вующее ему дерево вывода уже было приведено на рис. 14.5. Построим последовательность триад, соответствующую дереву операций на рис. 14.5. Согласно принципу СУ-перевода, построение начинается от корня дерева. Шаг 1. 1) Code (U2. 1) 1) := (А. "1-1) Шаг 2. 1) Code (U3, 1) j) Code (U5, j) 1-1) - ("j-1. "1-2) 1) : = (A. "1-1) ШагЗ. 1) Code (U4. 1) к) + ("k-1. D) J) Code (U5. j) 1-1) - ("j-1. "1-2) 1) := (A. "1-1) Шаг 4. 1) * (B. C) 2) + ("1. D) 3) Code (U5. 3) 1-1) - ("j-1. "1-2) 1) := (A. "I-D
630 Глава 14. Генерация и оптимизация кода Шаг 5. 1) * (В. С) 2) + Г1, D) 3) * (В. 10) 4) - (*2. *3) 5) := (А. *4) Следует обратить внимание, что в данном алгоритме последовательные номера триад (а следовательно, и ссылки на них) устанавливаются не сразу. Это не имеет значения при рекурсивной организации процедуры, но если используется дру- гой способ обхода дерева операций, при реализации генерации кода лучше увя- зывать триады между собой именно по ссылке (указателю), а не по порядковому номеру. Использование триад позволяет сократить количество рассматриваемых вариан- тов узлов дерева операций. В этом случае не требуется принимать во внимание типы операндов и ориентироваться на архитектуру целевой вычислительной сис- темы. Все эти аспекты необходимо будет принимать во внимание уже на этапе преобразования триад в последовательность команд ассемблера или непосредст- венно в последовательность машинных команд результирующей программы. Как и последовательность команд ассемблера, последовательность триад можно оптимизировать. Для триад разработаны универсальные (машинно-независимые) алгоритмы оптимизации кода. Уже после их выполнения (оптимизации внутрен- него представления) триады могут быть преобразованы в команды на языке ассемблера. Оптимизация кода. Основные методы оптимизации Общие принципы оптимизации кода Как уже говорилось выше, в подавляющем большинстве случаев генерация кода выполняется компилятором не для всей входной программы в целом, а последо- вательно для отдельных ее конструкций. Полученные для каждой синтаксиче- ской конструкции входной программы фрагменты результирующего кода также последовательно объединяются в общий текст результирующей программы. При этом связи между различными фрагментами результирующего кода в полной мере не учитываются. В свою очередь, для построения результирующего кода различных синтаксиче- ских конструкций входного языка используется метод СУ-перевода. Он также объединяет цепочки построенного кода по структуре дерева без учета их взаимо- связей (что видно из примеров, приведенных выше). Построенный таким образом код результирующей программы может содержать лишние команды и данные. Это снижает эффективность выполнения резуль- тирующей программы. В принципе компилятор может завершить на этом гене-
Оптимизация кода. Основные методы оптимизации 631 рацию кода, поскольку результирующая программа построена и она является эквивалентной по смыслу (семантике) программе на входном языке. Однако эф- фективность результирующей программы важна для ее разработчика, поэтому большинство современных компиляторов выполняют еще один этап компиля- ции — оптимизацию результирующей программы (или просто «оптимизацию»), чтобы повысить ее эффективность насколько это возможно. Важно отметить два момента: во-первых, выделение оптимизации в отдельный этап генерации кода — это вынужденный шаг. Компилятор вынужден произво- дить оптимизацию построенного кода, поскольку он не может выполнить семан- тический анализ всей входной программы в целом, оценить ее смысл и, исходя из него, построить результирующую программу. Оптимизация нужна, поскольку результирующая программа строится не вся сразу, а поэтапно. Во-вторых, опти- мизация — это необязательный этап компиляции. Компилятор может вообще не выполнять оптимизацию, и при этом результирующая программа будет правиль- ной, а сам компилятор будет полностью выполнять свои функции. Однако прак- тически все компиляторы так или иначе выполняют оптимизацию, поскольку их разработчики стремятся завоевать хорошие позиции на рынке средств разработки программного обеспечения. Оптимизация, которая существенно влияет на эф- фективность результирующей программы, является здесь немаловажным фак- тором. Теперь дадим определение понятию «оптимизация». Оптимизация программы — это обработка, связанная с переупорядочиванием и изменением операций в компилируемой программе с целью получения более эф- фективной результирующей объектной программы. Оптимизация выполняется на этапах подготовки к генерации и непосредственно при генерации объектного кода. В качестве показателей эффективности результирующей программы можно ис- пользовать два критерия: объем памяти, необходимый для выполнения результи- рующей программы (для хранения всех ее данных и кода), и скорость выполнения (быстродействие) программы. Далеко не всегда удается выполнить оптимизацию так, чтобы удовлетворить обоим этим критериям. Зачастую сокращение необхо- димого программе объема данных ведет к уменьшению ее быстродействия, и на- оборот. Поэтому для оптимизации обычно выбирается либо один из упомянутых критериев, либо некий комплексный критерий, основанный на них. Выбор кри- терия оптимизации обычно выполняет непосредственно пользователь в настрой- ках компилятора. Но, даже выбрав критерий оптимизации, в общем случае практически невозмож- но построить код результирующей программы, который бы являлся самым ко- ротким или самым быстрым кодом, соответствующим входной программе. Дело в том, что нет алгоритмического способа нахождения самой короткой или самой быстрой результирующей программы, эквивалентной заданной исходной про- грамме. Эта задача в принципе неразрешима. Существуют алгоритмы, которые можно ускорять сколь угодно много раз для большого числа возможных вход- ных данных, и при этом для других наборов входных данных они окажутся неоп- тимальными [6, т. 2]. К тому же компилятор обладает весьма ограниченными средствами анализа семантики всей входной программы в целом. Все, что можно
632 Глава 14. Генерация и оптимизация кода сделать на этапе оптимизации, — это выполнить над заданной программой по- следовательность преобразований в надежде сделать ее более эффективной. Чтобы оценить эффективность результирующей программы, полученной с по- мощью того или иного компилятора, часто прибегают к сравнению ее с эквива- лентной программой (программой, реализующей тот же алгоритм), полученной из исходной программы, написанной на языке ассемблера. Лучшие оптимизи- рующие компиляторы могут получать результирующие объектные программы из сложных исходных программ, написанных на языках высокого уровня, почти не уступающие по качеству программам на языке ассемблера. Обычно соотноше- ние эффективности программ, построенных с помощью компиляторов с языков высокого уровня, к эффективности программ, построенных с помощью ассемб- лера, составляет 1,1-1,3. То есть объектная программа, построенная с помощью компилятора с языка высокого уровня, обычно содержит на 10-30 % больше ко- манд, чем эквивалентная ей объектная программа, построенная с помощью ас- семблера, а также выполняется на 10-30 % медленнее1. Это очень неплохие результаты, достигнутые компиляторами с языков высокого уровня, если сравнить трудозатраты на разработку программ на языке ассембле- ра и языке высокого уровня. Далеко не каждую программу можно реализовать на языке ассемблера в приемлемые сроки (а значит, и выполнить напрямую при- веденное выше сравнение можно только для узкого круга программ). Оптимизацию можно выполнять на любой стадии генерации кода, начиная от завершения синтаксического разбора и вплоть до последнего этапа, когда порож- дается код результирующей программы. Если компилятор использует несколько различных форм внутреннего представления программы, то каждая из них может быть подвергнута оптимизации, причем различные формы внутреннего представ- ления ориентированы на различные методы оптимизации [6, т. 2, 74, 82]. Таким образом, оптимизация в компиляторе может выполняться несколько раз на эта- пе генерации кода. Принципиально различаются два основных вида оптимизирующих преобразова- ний: □ преобразования исходной программы (в форме ее внутреннего представления в компиляторе), не зависящие от результирующего объектного языка; □ преобразования результирующей объектной программы. Первый вид преобразований не зависит от архитектуры целевой вычислитель- ной системы, на которой будет выполняться результирующая программа. Обычно он основан на выполнении хорошо известных и обоснованных математических и логических преобразований, производимых над внутренним представлением программы (некоторые из них будут рассмотрены ниже). Второй вид преобразований может зависеть не только от свойств объектного языка (что очевидно), но и от архитектуры вычислительной системы, на которой 1 Обычно такое сравнение выполняют для специальных тестовых программ, для которых код на языке ассемблера уже заранее известен. Полученные результаты распространяют на все множество входных программ, поэтому их можно считать очень усредненными.
Оптимизация кода. Основные методы оптимизации 633 будет выполняться результирующая программа. Так, например, при оптимиза- ции может учитываться объем кэш-памяти и методы организации конвейерных операций центрального процессора. В большинстве случаев эти преобразования сильно зависят от реализации компилятора и являются «ноу-хау» производите- лей компилятора. Именно этот тип оптимизирующих преобразований позволяет существенно повысить эффективность результирующего кода. Используемые методы оптимизации ни при каких условиях не должны при- водить к изменению «смысла» исходной программы (то есть к таким ситуаци- ям, когда результат выполнения программы изменяется после ее оптимизации). Для преобразований первого вида проблем обычно не возникает. Преобразова- ния второго вида могут вызывать сложности, поскольку не все методы оптими- зации, используемые создателями компиляторов, могут быть теоретически обос- нованы и доказаны для всех возможных видов исходных программ. Именно эти преобразования могут повлиять на «смысл» исходной программы. Поэтому боль- шинство компиляторов предусматривает возможность отключать те или иные из возможных методов оптимизации. Нередко оптимизация ведет к тому, что смысл программы оказывается не совсем таким, каким его ожидал увидеть разработчик программы, но не по причине на- личия ошибки в оптимизирующей части компилятора, а потому, что пользо- ватель не принимал во внимание некоторые аспекты программы, связанные с оптимизацией. Например, компилятор может исключить из программы вызов некоторой функции с заранее известным результатом, но если эта функция име- ла «побочный эффект» — изменяла некоторые значения в глобальной памяти, — смысл программы может измениться. Чаще всего это говорит о плохом стиле программирования исходной программы. Такие ошибки трудноуловимы, для их нахождения следует обратить внимание на предупреждения разработчику про- граммы, выдаваемые семантическим анализом, или отключить оптимизацию. Применение оптимизации также нецелесообразно в процессе отладки исходной программы. У современных компиляторов существуют возможности выбора не только обще- го критерия оптимизации, но и отдельных методов, которые будут использовать- ся при выполнении оптимизации. Методы преобразования программы зависят от типов синтаксических конструк- ций исходного языка. Теоретически разработаны методы оптимизации для мно- гих типовых конструкций языков программирования. Оптимизация может выполняться для следующих типовых синтаксических кон- струкций: □ линейных участков программы; □ логических выражений; □ циклов; □ вызовов процедур и функций; □ других конструкций входного языка. Во всех случаях могут использоваться как машинно-зависимые, так и машинно- независимые методы оптимизации.
634 Глава 14. Генерация и оптимизация кода Далее будут рассмотрены только некоторые машинно-независимые методы оп- тимизации, касающиеся в первую очередь линейных участков программы. Пере- чень их здесь далеко не полный. С остальными машинно-независимыми методами можно более подробно ознакомиться в [6, т. 2]. Что касается машинно-зависи- мых методов, то они, как правило, редко упоминаются в литературе. Некоторые из них рассматриваются в технических описаниях компиляторов. Здесь эти ме- тоды будут рассмотрены только в самом общем виде. Оптимизация линейных участков программы Принципы оптимизации линейных участков Линейный участок программы — это выполняемая по порядку последовательность операций, имеющая один вход и один выход. Чаще всего линейный участок со- держит последовательность вычислений, состоящих из арифметических опера- ций и операторов присвоения значений переменным. Любая программа предусматривает выполнение вычислений и присваивания зна- чений, поэтому линейные участки встречаются в любой программе. В реальных программах они составляют существенную часть программного кода. Поэтому для линейных участков разработан широкий спектр методов оптимизации кода. Кроме того, характерной особенностью любого линейного участка является по- следовательный порядок выполнения операций, входящих в его состав. Ни одна операция в составе линейного участка программы не может быть пропущена, ни одна операция не может быть выполнена большее число раз, чем соседние с нею операции (иначе этот фрагмент программы просто не будет линейным участком). Это существенно упрощает задачу оптимизации линейных участков программ. Поскольку все операции линейного участка выполняются последовательно, их можно пронумеровать в порядке их выполнения. Для операций, составляющих линейный участок программы, могут применяться следующие виды оптимизирующих преобразований: □ удаление бесполезных присваиваний; □ исключение избыточных вычислений (лишних операций); □ свертка операций объектного кода; □ перестановка операций; □ арифметические преобразования. Удаление бесполезных присваиваний заключается в том, что если в составе ли- нейного участка программы имеется операция присвоения значения некоторой произвольной переменной А с номером 1, а также операция присвоения значения той же переменной А с номером j, 1<j и ни в одной операции между 1 и j не ис- пользуется значение переменной А, то операция присвоения значения с номером 1 является бесполезной. Фактически бесполезная операция присваивания значе- ния дает переменной значение, которое нигде не используется. Такая операция может быть исключена без ущерба для смысла программы.
Оптимизация кода. Основные методы оптимизации 635 В общем случае, бесполезными могут оказаться не только операции присваива- ния, но и любые другие операции линейного участка, результат выполнения ко- торых нигде не используется. Например, во фрагменте программы А := В * С; D := В + С; А := D * С; операция присвоения А:«В*С; является бесполезной и может быть удалена. Вме- сте с удалением операции присвоения здесь может быть удалена и операция ум- ножения, которая в результате также окажется бесполезной. Обнаружение бесполезных операций присваивания далеко не всегда столь оче- видно, как было показано в примере выше. Проблемы могут возникнуть, если между двумя операциями присваивания в линейном участке выполняются дей- ствия над указателями (адресами памяти) или вызовы функций, имеющих так называемый «побочный эффект». Например, в следующем фрагменте программы Р :» @А: А := В * С; D :« РА + С; А := D* С; операция присвоения А:«В*С; уже не является бесполезной, хотя это и не столь очевидно. В этом случае неверно следовать простому принципу о том, что если переменная, которой присвоено значение в операции с номером 1, не встречается ни в одной операции между i и j, то операция присвоения с номером i является бесполезной. Не всегда удается установить, используется или нет присвоенное переменной значение, только на основании факта упоминания переменной в операциях. Тог- да устранение лишних присваиваний становится достаточно сложной задачей, требующей учета операций с адресами памяти и ссылками. Исключение избыточных вычислений (лишних операций) заключается в нахож- дении и удалении из объектного кода операций, которые повторно обрабатывают одни и те же операнды. Операция линейного участка с порядковым номером 1 считается лишней, если существует идентичная ей операция с порядковым но- мером j, j<i и никакой операнд, обрабатываемый этой операцией, не изменялся никакой операцией, имеющей порядковый номер между 1 и j. Свертка объектного кода — это выполнение во время компиляции тех операций исходной программы, для которых значения операндов уже известны. Тривиаль- ным примером свертки является вычисление выражений, все операнды которых являются константами. Более сложные варианты алгоритма свертки принима- ют во внимание также и переменные, и функции, значения для которых уже из- вестны. Не всегда компилятору удается выполнить свертку, даже если выражение допус- кает ее выполнение. Например, выражение А:«2*В*С*3; может быть преобразова- но к виду А:=б*В*С;, но при порядке вычислений А:=2*(В*(С*3)); это не столь оче-
636 Глава 14. Генерация и оптимизация кода видно. Для более эффективного выполнения свертки объектного кода возможно совместить ее выполнение с другим методом — перестановкой операций. Хорошим стилем программирования является объединение вместе операций, про- изводимых над константами, чтобы облегчить компилятору выполнение свертки. Например, если имеется константа Р 1=3.14, представляющая соответствующую математическую постоянную, то операцию b=sin(2rca); лучше записать в виде B:=sin(2*PI*A); или даже B:=sin((2*PI)*A);, чем в виде B:=sin(2*A*PI);. Перестановка операций заключается в изменении порядка следования операций, которое может повысить эффективность программы, но не будет влиять на ко- нечный результат вычислений. Например, операции умножения в выражении А:=2*В*3*С; можно переставить без изменения конечного результата и выполнить в порядке А:=(2*3)*(В*С);. То- гда представляется возможным выполнить свертку и сократить количество опе- раций. Другое выражение A:=(B+C)+(D+E); может потребовать как минимум одной ячей- ки памяти (или регистра процессора) для хранения промежуточного результата. Но при вычислении его в порядке A:=B+(C+(D+E)); можно обойтись одним регист- ром, в то время как результат будет тем же. Особое значение перестановка операций приобретает в тех случаях, когда порядок их выполнения влияет на эффективность программы в зависимости от архитек- турных особенностей целевой вычислительной системы (таких, как использова- ние регистров процессора, конвейеров обработки данных и т. п.). Эти особенности рассмотрены далее в подразделе, посвященном машинно-зависимым методам оптимизации. Арифметические преобразования представляют собой выполнение изменения ха- рактера и порядка следования операций на основании известных алгебраических и логических тождеств. Например, выражение A:=B*C+B*D; может быть заменено на A:=B*(C+D);. Конеч- ный результат при этом не изменится, но объектный код будет содержать на одну операцию умножения меньше. К арифметическим преобразованиям можно отнести и такие действия, как за- мена возведения в степень на умножение; а целочисленного умножения на кон- станты, кратные 2, — на выполнение операций сдвига. В обоих случаях удается повысить быстродействие программы заменой сложных операций на более про- стые. Далее подробно рассмотрены два метода: свертка объектного кода и исключение лишних операций. Свертка объектного кода Свертка объектного кода — это выполнение во время компиляции тех операций исходной программы, для которых значения операндов уже известны. Нет необ- ходимости многократно выполнять эти операции в результирующей програм- ме — вполне достаточно один раз выполнить их при компиляции программы.
Оптимизация кода. Основные методы оптимизации 637 Простейший вариант свертки — выполнение в компиляторе операций, операнда- ми которых являются константы. Несколько более сложен процесс определения тех операций, значения которых могут быть известны в результате выполнения других операций. Для этой цели при оптимизации линейных участков програм- мы используется специальный алгоритм свертки объектного кода. Алгоритм свертки для линейного участка программы работает со специальной таблицей Т, которая содержит пары (<переменная>,<константа>) для всех перемен- ных, значения которых уже известны. Кроме того, алгоритм свертки помечает те операции во внутреннем представлении программы, для которых в результате свертки уже не требуется генерация кода. Так как при выполнении алгоритма свертки учитывается взаимосвязь операций, то удобной формой представления для него являются триады, так как в других формах представления операций (таких, как тетрады или команды ассемблера) требуются дополнительные струк- туры, чтобы отразить связь результатов одних операций с операндами других. Рассмотрим выполнение алгоритма свертки объектного кода для триад. Для по- метки операций, не требующих порождения объектного кода, будем использо- вать триады специального вида С(К,0). Алгоритм свертки триад последовательно просматривает триады линейного уча- стка и для каждой триады делает следующее: 1. Если операнд есть переменная, которая содержится в таблице Т, то операнд заменяется на соответствующее значение константы. 2. Если операнд есть ссылка на особую триаду типа С(К,0), то операнд заменяет- ся на значение константы К. 3. Если все операнды триады являются константами, то триада может быть свернута. Тогда данная триада выполняется и вместо нее помещается особая триада вида С(К.О), где К — константа, являющаяся результатом выполнения свернутой триады. (При генерации кода для особой триады объектный код не порождается, а потому она в дальнейшем может быть просто исключена.) 4. Если триада является присваиванием типа А:=В, тогда: О если В — константа, то А со значением константы заносится в таблицу Т (если там уже было старое значение для А, то это старое значение исключа- ется); О если В — не константа, то А вообще исключается из таблицы Т, если оно там есть. Рассмотрим пример выполнения алгоритма. Пусть фрагмент исходной программы (записанной на языке типа Pascal) имеет вид: I := 1 + 1; I := 3; J := 6*1 + I: Ее внутреннее представление в форме триад будет иметь вид: 1. + (1,1) 2. := (I, Ч)
638 Глава 14. Генерация и оптимизация кода 3. := (I, 3) 4. * (6, I) 5. + Г4, I) 6. := (J, "5) Процесс выполнения алгоритма свертки показан в табл. 14.4. Таблица 14.4. Пример работы алгоритма свертки Триада Шаг 1 Шаг 2 Шаг 3 Шаг 4 Шаг 5 Шаг 6 1 С (2, 0) С (2, 0) С (2, 0) С (2, 0) С (2, 0) с (2, 0) 2 (I, Л1) (I, 2) (I, 2) (I. 2) (I, 2) (I, 2) 3 (I, 3) := (I, 3) (I, 3) := (I, 3) := (I, 3) := (I, 3) 4 * (6,1) * (6,1) * (6,1) С (18, 0) С (18, 0) С (18, 0) 5 + (л4,1) + (л4, I) + (% I) + (Ч I) С (21, 0) с (21,0) 6 0, л5) а, л5) 0, л5) (J, л5) := О, л5) 0,21) т (,) (I, 2) (I. 3) а з) (1,3) (I, 3) 0,21) Если исключить особые триады типа С (К, 0) (которые не порождают объектного кода), то в результате выполнения свертки получим следующую последователь- ность триад: 1. := (I, 2) 2. := (I, 3) 3. := (J, 21) Алгоритм свертки объектного кода позволяет исключить из линейного участка программы операции, для которых на этапе компиляции уже известен результат. За счет этого сокращается время выполнения1, а также объем кода результирую- щей программы. Свертка объектного кода в принципе может выполняться не только для линей- ных участков программы. Когда операндами являются константы, логика выпол- нения программы значения не имеет — свертка может быть выполнена в любом случае. Если же необходимо учитывать известные значения переменных, то нуж- но принимать во внимание и логику выполнения результирующей программы. 1 Даже если принять во внимание, что время на выполнение операции будет потрачено компилятором при порождении результирующей программы, то все равно мы имеем вы- игрыш во времени. Во-первых, любая результирующая программа создается (а значит, и компилируется окончательно) только один раз, а выполняется многократно; во-вто- рых, оптимизируемый линейный участок может входить в состав оператора цикла или вызова функции, и тогда выигрыш очевиден.
Оптимизация кода. Основные методы оптимизации 639 Поэтому для нелинейных участков программы (ветвлений и циклов) алгоритм будет более сложным, чем последовательный просмотр линейного списка триад. Исключение лишних операций Алгоритм исключения лишних операций просматривает операции в порядке их следования. Так же как и алгоритму свертки, алгоритму исключения лишних операций проще всего работать с триадами, потому что они полностью отражают взаимосвязь операций. Рассмотрим алгоритм исключения лишних операций для триад. Чтобы следить за внутренней зависимостью переменных и триад, алгоритм при- сваивает им некоторые значения, называемые числами зависимости, по следую- щим правилам: □ изначально для каждой переменной ее число зависимости равно 0, так как в на- чале работы программы значение переменной не зависит ни от какой триады; □ после обработки i-й триады, в которой переменной А присваивается некото- рое значение, число зависимости A (dep(A)) получает значение 1, так как зна- чение А теперь зависит от данной i-й триады; □ при обработке i -й триады ее число зависимости (dep(i)) принимается равным значению 1+(максимальное из чисел зависимости операндов). Таким образом, при использовании чисел зависимости триад и переменных можно утверждать, что если i-я триада идентична j-й триаде (j<i), то i-я триада счита- ется лишней в том и только в том случае, когда dep(i)=dep( j). Алгоритм исключения лишних операций использует в своей работе триады осо- бого вида SAME(J.0). Если такая триада встречается в позиции с номером i, то это означает, что в исходной последовательности триад некоторая триада i идентич- на триаде j. Алгоритм исключения лишних операций последовательно просматривает триа- ды линейного участка. Он состоит из следующих шагов, выполняемых для каж- дой триады: 1. Если какой-то операнд триады ссылается на особую триаду вида SAME( j.O), то он заменяется на ссылку на триаду с номером j (xj). 2. Вычисляется число зависимости текущей триады с номером i, исходя из чи- сел зависимости ее операндов. 3. Если в просмотренной части списка триад существует идентичная j-я триада, причем j<i и dep(i)=dep( j), то текущая триада i заменяется на триаду особого вида SAME(j.O). 4. Если текущая триада есть присвоение, то вычисляется число зависимости со- ответствующей переменной. Рассмотрим работу алгоритма на примере: D := D + С*В; А := D + С*В: С := D + С*В;
640 Глава 14. Генерация и оптимизация кода Этому фрагменту программы будет соответствовать следующая последователь- ность триад: 1. * (С. В) 2. + (D, А1) 3. := (D, А2) 4. * (С, В) 5. + (D, А4) 6. := (А, А5) 7. * (С, В) 8. + (D. А7) 9. := (С. А8) Видно, что в данном примере некоторые операции вычисляются дважды над од- ними и теми же операндами, а значит, они являются лишними и могут быть ис- ключены. Работа алгоритма исключения лишних операций отражена в табл. 14.5. Таблица 14.5. Пример работы алгоритма исключения лишних операций Обрабатываемая триада i Числа зависимости переменных Числа зависимости триад dep(i) Триады, полученные после выполнения алгоритма А в с D 1) * (С, В) 0 0 0 0 1 1) * (С, В) 2) + (D, А1) 0 0 0 0 2 2) + (D, А1) 3)(D, Л2) 0 0 0 3 3 3):- (D, Л2) 4) * (С, В) 0 0 0 3 1 4) SAME (1, 0) 5) + (D, л4) 0 0 0 3 4 5) + (D, А1) 6):- (А, л5) 6 0 0 3 5 6)(А, л5) 7) * (С, В) 6 0 0 3 1 7) SAME (1, 0) 8) + (D, л7) 6 0 0 3 4 8) SAME (5, 0) 9)(С, л8) 6 0 9 3 5 9) := (С, л5) Теперь, если исключить триады особого вида SAME( j, 0), то в результате выполне- ния алгоритма получим следующую последовательность триад: 1. * (С, В) 2. + (D. А1) 3. := (D, А2) 4. + (D, А1) 5. := (А. А4) 6. := (С. А4)
Оптимизация кода. Основные методы оптимизации 641 Обратите внимание, что в итоговой последовательности изменилась нумерация триад и номера в ссылках одних триад на другие. Если в компиляторе в качестве ссылок использовать не номера триад, а непосредственно указатели на них, то изменения ссылок в таком варианте не потребуется. Алгоритм исключения лишних операций позволяет избежать повторного выпол- нения одних и тех же операций над одними и теми же операндами. В результате оптимизации по этому алгоритму сокращается и время выполнения, и объем кода результирующей программы. Другие методы оптимизации программ Оптимизация вычисления логических выражений Особенность оптимизации логических выражений заключается в том, что не всегда необходимо полностью выполнять вычисление всего выражения для того, чтобы знать его результат. Иногда по результату первой операции или даже по значению одного операнда можно заранее определить результат вычисления все- го выражения. Операция называется предопределенной для некоторого значения операнда, если ее результат зависит только от этого операнда и остается неизменным (инвари- антным) относительно значений других операндов. Операция логического сложения (or) является предопределенной для логиче- ского значения «истина» (true), а операция логического умножения — предопре- делена для логического значения «ложь» (false). Эти факты могут быть использованы для оптимизации логических выражений. Действительно, получив значение «истина» в последовательности логических сло- жений или значение «ложь» в последовательности логических умножений, нет никакой необходимости далее производить вычисления — результат уже опреде- лен и известен. Например, выражение A or В or С or D не имеет смысла вычислять, если извест- но, что значение переменной А есть True («истина»). Компиляторы строят объектный код вычисления логических выражений таким образом, что вычисление выражения прекращается сразу же, как только его зна- чение становится предопределенным. Это позволяет ускорить вычисления при выполнении результирующей программы. В сочетании с преобразованиями ло- гических выражений на основе тождеств булевой алгебры и перестановкой опе- раций эффективность данного метода может быть несколько увеличена. Не всегда такие преобразования инвариантны к смыслу программы. Например, при вычислении выражения A or F(B) or G(C) функции F и G не будут вызваны и выполнены, если значением переменной А является true. Это не важно, если ре- зультатом этих функций является только возвращаемое ими значение, но если они обладают «побочным эффектом» (о котором было сказано выше в замечани- ях), то семантика программы может измениться. В большинстве случаев современные компиляторы позволяют исключить такого рода оптимизацию, и тогда все логические выражения будут вычисляться до конца, без учета предопределенности результата. Однако лучше избегать такого
642 Глава 14. Генерация и оптимизация кода рода «побочных эффектов», которые, как правило, говорят о плохом стиле про- граммирования. Хорошим стилем считается также принимать во внимание эту особенность вычис- ления логических выражений. Тогда операнды в логических выражениях следу- ет стремиться располагать таким образом, чтобы в первую очередь вычислялись те из них, которые чаще определяют все значение выражения. Кроме того, значе- ния функций лучше вычислять в конце, а не в начале логического выражения, чтобы избежать лишних обращений к ним. Так, рассмотренное выше выражение лучше записывать и вычислять в порядке A or F(B) or G(C), чем в порядке F(B) ог G(C) ог А. Не только логические операции могут иметь предопределенный результат. Не- которые математические операции и функции также обладают этим свойством. Например, умножение на 0 не имеет смысла выполнять. Другой пример такого рода преобразований — это исключение вычислений для инвариантных операндов. Операция называется инвариантной относительно некоторого значения операн- да, если ее результат не зависит от этого значения операнда и определяется дру- гими операндами. Например, логическое сложение (ог) инвариантно относительно значения «ложь» (False), логическое умножение (and) — относительно значения «истина»; алгеб- раическое сложение инвариантно относительно 0, а алгебраическое умножение — относительно 1. Выполнение такого рода операций можно исключить из текста программы, если известен инвариантный для них операнд. Этот метод оптимизации имеет смысл в сочетании с методом свертки операций. Оптимизация передачи параметров в процедуры и функции Выше рассмотрен метод передачи параметров в процедуры и функции через стек. Этот метод применим к большинству языков программирования и используется практически во всех современных компиляторах для различных архитектур це- левых вычислительных систем (на которых выполняется результирующая про- грамма). Данный метод прост в реализации и имеет хорошую аппаратную поддержку во многих архитектурах. Однако он является неэффективным в том случае, если процедура или функция выполняет несложные вычисления над небольшим ко- личеством параметров. Тогда всякий раз при вызове процедуры или функции компилятор будет создавать объектный код для размещения ее фактических па- раметров в стеке, а при выходе из нее — код для освобождения ячеек, занятых параметрами. При выполнении результирующей программы этот код будет за- действован. Если процедура или функция выполняет небольшие по объему вы- числения, код для размещения параметров в стеке и доступа к ним может состав- лять существенную часть ко всему коду, созданному для этой процедуры или функции. А если эта функция вызывается многократно, то этот код будет замет- но снижать эффективность ее выполнения.
Оптимизация кода. Основные методы оптимизации 643 Существуют методы, позволяющие снизить затраты кода и времени выполнения на передачу параметров в процедуры и функции и повысить в результате эффек- тивность результирующей программы: □ передача параметров через регистры процессора; □ подстановка кода функции в вызывающий объектный код. Метод передачи параметров через регистры процессора позволяет разместить все или часть параметров, передаваемых в процедуру или функцию, непосредствен- но в регистрах процессора, а не в стеке. Это позволяет ускорить обработку пара- метров функции, поскольку работа с регистрами процессора всегда выполняется быстрее, чем с ячейками оперативной памяти, где располагается стек. Если все параметры удается разместить в регистрах, то сокращается также и объем кода, поскольку исключаются все операции со стеком при размещении в нем параметров. Понятно, что регистры процессора, используемые для передачи параметров в про- цедуру или функцию, не могут быть задействованы напрямую для вычислений внутри этой процедуры или функции. Поскольку программно доступных регист- ров процессора всегда ограниченное количество, то при выполнении сложных вычислений могут возникнуть проблемы с их распределением. Тогда компилятор должен выбрать: либо использовать регистры для передачи параметров и сни- зить эффективность вычислений (часть промежуточных результатов, возможно, потребуется размещать в оперативной памяти или в том же стеке), либо исполь- зовать свободные регистры для вычислений и снизить эффективность передачи параметров. Поэтому реализация данного метода зависит от количества программ- но доступных регистров процессора в целевой вычислительной системе и от ис- пользуемого в компиляторе алгоритма распределения регистров. В современных процессорах число программно доступных регистров постоянно увеличивается с разработкой все новых и новых их вариантов, поэтому и значение этого метода постоянно возрастает. Этот метод имеет ряд недостатков. Во-первых, очевидно, он зависит от архитек- туры целевой вычислительной системы. Во-вторых, процедуры и функции, оп- тимизированные таким методом, не могут быть использованы в качестве проце- дур или функций библиотек подпрограмм ни в каком виде. Это вызвано тем, что методы передачи параметров через регистры процессора не стандартизованы (в отличие от методов передачи параметров через стек) и зависят от реализации компилятора. Наконец, этот метод не может быть использован, если где бы то ни было в процедуре или функции требуется выполнить операции с адресами (ука- зателями) на параметры. В большинстве современных компиляторов метод передачи параметров в проце- дуры и функции выбирается самим компилятором автоматически в процессе ге- нерации кода для каждой процедуры и функции. Однако разработчик имеет, как правило, возможность запретить передачу параметров через регистры процессо- ра либо для определенных функций (для этого используются специальные клю- чевые слова входного языка), либо для всех функций в программе (для этого ис- пользуются настройки компилятора). Некоторые языки программирования (такие, например, как С и C++) позволяют разработчику исходной программы явно указать, какие параметры или локаль-
644 Глава 14. Генерация и оптимизация кода ные переменные процедуры он желал бы разместить в регистрах процессора. Тог- да компилятор стремится распределить свободные регистры в первую очередь именно для этих параметров и переменных. Метод подстановки кода функции в вызывающий объектный код (так называе- мая inline-подстановка) основан на том, что объектный код функции непосредст- венно включается в вызывающий объектный код всякий раз в месте вызова функции. Для разработчика исходной программы такая функция (называемая inline-функ- цией) ничем не отличается от любой другой функции, но для вызова ее в резуль- тирующей программе используется принципиально другой механизм. По сути, вызов функции в результирующем объектном коде вовсе не выполняется — про- сто все вычисления, производимые функцией, выполняются непосредственно в самом вызывающем коде в месте ее вызова. Очевидно, что в общем случае такой метод оптимизации ведет не только к уве- личению скорости выполнения программы, но и к увеличению объема объектно- го кода. Скорость увеличивается за счет отказа от всех операций, связанных с вызовом функции, — это не только сама команда вызова, но и все действия, свя- занные с передачей параметров. Вычисления при этом идут непосредственно с фактическими параметрами функции. Объем кода растет, так как приходится всякий раз включать код функции в место ее вызова. Тем не менее если функция очень проста и включает в себя лишь несколько машинных команд, то можно даже добиться сокращения кода результирующей программы, так как при вклю- чении кода самой функции в место ее вызова оттуда исключаются операции, свя- занные с передачей ей параметров. Как правило, этот метод применим к очень простым функциям или процедурам, иначе объем результирующего кода может существенно возрасти. Кроме того, он применим только к функциям, вызываемым непосредственно по адресу, без при- менения косвенной адресации через таблицы RTTI (см. раздел «Семантический анализ и подготовка к генерации кода» этой главы). Некоторые компиляторы допускают применять его только к функциям, предполагающим последователь- ные вычисления и не содержащим циклов. Ряд языков программирования (например, C++) позволяют разработчику явно указать, для каких функций он желает использовать inline-подстановку. В C++, например, для этой цели служит ключевое слово входного языка inline. Оптимизация циклов Циклом в программе называется любая последовательность участков програм- мы, которая может выполняться повторно. Циклы присущи подавляющему большинству программ. Во многих программах есть циклы, выполняемые многократно. Большинство языков программирования имеют синтаксические конструкции, специально ориентированные на организа- цию циклов. Очевидно, такие циклы легко обнаруживаются на этапе синтакси- ческого разбора. Однако понятие цикла с точки зрения объектной программы, определенной выше, является более общим. Оно включает в себя не только циклы, явно описанные
Оптимизация кода. Основные методы оптимизации 645 в синтаксисе языка. Циклы могут быть организованы в исходной программе с помощью любых конструкций, допускающих передачу управления (прежде все- го, с помощью условных операторов и операторов безусловного перехода). Для результирующей объектной программы не имеет значения, с помощью каких конструкций организован цикл в исходной программе. Чтобы обнаружить все циклы в исходной программе, используются методы, ос- нованные на построении графа управления программы [6, т. 2]. Циклы обычно содержат в себе один или несколько линейных участков, где про- изводятся вычисления. Поэтому методы оптимизации линейных участков позво- ляют повысить также и эффективность выполнения циклов, причем они оказы- ваются тем более эффективными, чем больше кратность выполнения цикла. Но есть методы оптимизации программ, специально ориентированные на оптимиза- цию циклов. Для оптимизации циклов используются следующие методы: □ вынесение инвариантных вычислений из циклов; □ замена операций с индуктивными переменными; □ слияние и развертывание циклов. Вынесение инвариантных вычислений из циклов заключается в вынесении за пределы циклов тех операций, операнды которых не изменяются в процессе вы- полнения цикла. Очевидно, что такие операции могут быть выполнены один раз до начала цикла, а полученные результаты потом могут использоваться в теле цикла. Например, цикл1 for i ;=1 to 10 do begin A[i] :» В * С * АСЮ: end; может быть заменен на последовательность операций D ;= В * С; for i:=l to 10 do begin A[i] ;= D * АСЮ; end; если значения В и С не изменяются нигде в теле цикла. При этом операция умно- жения В*С будет выполнена только один раз, в то время как в первом варианте она выполнялась 10 раз над одними и теми же результатами. 1 Конечно, во внутреннем представлении компилятора циклы не могут быть записаны в таком виде, но для наглядности здесь мы их будем представлять в синтаксической за- писи входного языка (в данном случае — языка Pascal, как и во всех примерах далее). На суть выполняемых операций оптимизации это никак не влияет.
646 Глава 14. Генерация и оптимизация кода Неизменность операндов в теле цикла может оказаться не столь очевидной. От- сутствие присвоения значений переменным не может служить достоверным при- знаком их неизменности. В общем случае компилятору придется принимать во внимание все операции, которые так или иначе могут повлиять на инвариант- ность переменных. Такими операциями являются операции над указателями, вызовы функций (даже если сами переменные не передаются в функцию в каче- стве параметров, она может изменять их значения через «побочные эффекты»). Поскольку невозможно отследить все изменения указателей (адресов) и все «по- бочные эффекты» на этапе компиляции, компилятор вынужден отказаться от вынесения из цикла инвариантных переменных всякий раз, когда в теле цикла появляются «подозрительные» операции, которые могут поставить под сомне- ние инвариантность той или иной переменной. Замена операций с индуктивными переменными заключается в изменении слож- ных операций с индуктивными переменными в теле цикла на более простые опе- рации. Как правило, выполняется замена умножения на сложение. Переменная называется индуктивной в цикле, если ее значения в процессе вы- полнения цикла образуют арифметическую прогрессию. Таких переменных в цик- ле может быть несколько, тогда в теле цикла их иногда можно заменить на одну единственную переменную, а реальные значения для каждой переменной будут вычисляться с помощью соответствующих коэффициентов соотношения (за пре- делами цикла значения всем переменным должны быть присвоены, если, конеч- но, они используются). Простейшей индуктивной переменной является переменная-счетчик цикла с пе- речислением значений (цикл типа for, который встречается в синтаксисе многих современных языков программирования). Более сложные случаи присутствия индуктивных переменных в цикле требуют специального анализа тела цикла. Не всегда выявление таких переменных является тривиальной задачей. После того как индуктивные переменные выявлены, необходимо проанализиро- вать те операции в теле цикла, где они используются. Часть таких операций мо- жет 6j>iTb упрощена. Как правило, речь идет о замене умножения на сложение [6, т. 2, 82]. Например, цикл S :» 10; for 1:=1 to N do A[i] ; = i*S; может быть заменен на последовательность операций S :« 10: Т := S; 1 := 1; while 1 <- 10 do begin A[i] := T; T ;= T + 10; i := i + 1; end; Здесь использован синтаксис языка Pascal, а Т — это некоторая новая временная переменная (использовать для той же цели уже существующую переменную S не вполне корректно, так как ее значение может быть использовано и после завер- шения этого цикла). В итоге удалось отказаться от выполнения N операций ум-
Оптимизация кода. Основные методы оптимизации 647 ножения, заменив их на N операций сложения (которые обычно выполняются быстрее). Индуктивной переменной в первом варианте цикла являлась i, а во втором варианте — 1 и Т. В другом примере S 10; for i:-l to N do R R + FCS): S :» S + 10; две индуктивных переменных - i и S. Если заменить их на одну, то выяснится, что переменная 1 вовсе не имеет смысла, тогда этот цикл можно заменить на по- следовательность операций S 10; М :« 10 + N*10; while S <= М do begin R := R + FCS); S :» S + 10; end; Здесь удалось исключить N операций сложения для переменной 1 за счет добав- ления новой временной переменной М (как и в предыдущем примере, использо- ван синтаксис языка Pascal). В современных реальных компиляторах такие преобразования используются дос- таточно редко, поскольку они требуют достаточно сложного анализа программы, в то время как достигаемый выигрыш невелик — разница в скорости выполне- ния сложения и умножения, равно как и многих других операций, в современ- ных вычислительных системах не столь существенна. Кроме того, существуют варианты циклов, для которых эффективность указанных методов преобразова- ния является спорной [6, т. 2]. Слияние и развертывание циклов предусматривает два различных варианта пре- образований: слияния двух вложенных циклов в один и замена цикла на линей- ную последовательность операций. Слияние двух циклов можно проиллюстрировать на примере циклов for i:=l to N do for j:=l to M do A[i.j] := 0; Здесь происходит инициализация двумерного массива. Но в объектном коде двумерный массив — это всего лишь область памяти размером N*M, поэтому (с точки зрения объектного кода, но не входного языка!) эту операцию можно представить так: К :» N*M; for i;-1 to К do A[i] ;» 0; Развертывание циклов можно выполнить для циклов, кратность выполнения ко- торых известна уже на этапе компиляции. Тогда цикл, кратностью N, можно за- менить на линейную последовательность N операций, содержащих тело цикла. Например, цикл for i:-1 to 3 do A[i] :» i; можно заменить операциями A[l] ;= 1; А[2] := 2; А[3] 3;
648 Глава 14. Генерация и оптимизация кода Незначительный выигрыш в скорости достигается за счет исключения всех опе- раций с индуктивной переменной, однако объем программы может существенно возрасти. Машинно-зависимые методы оптимизации Машинно-зависимые методы оптимизации ориентированы на конкретную архи- тектуру целевой вычислительной системы, на которой будет выполняться ре- зультирующая программа. Как правило, каждый компилятор ориентирован на одну определенную архитектуру целевой вычислительной системы. Иногда воз- можно в настройках компилятора явно указать одну из допустимых целевых ар- хитектур. В любом случае результирующая программа всегда порождается для четко заданной целевой архитектуры. Архитектура вычислительной системы есть представление аппаратной и програм- мной составляющих частей системы и взаимосвязи между ними с точки зрения системы как единого целого. Понятие «архитектура» включает в себя особенно- сти и аппаратных, и программных средств целевой вычислительной системы. При выполнении машинно-зависимой оптимизации компилятор может прини- мать во внимание те или иные ее составляющие. То, какие конкретно особенно- сти архитектуры будут приняты во внимание, зависит от реализации компилято- ра и определяется его разработчиками. Количество существующих архитектур вычислительных систем к настоящему времени очень велико. Поэтому не представляется возможным рассмотреть все ориентированные на них методы оптимизации даже в форме краткого обзора. Интересующиеся этим вопросом могут обратиться к специализированной лите- ратуре [6, т. 2, 40, 74, 82]. Далее будут рассмотрены только основные два аспекта машинно-зависимой оптимизации: распределение регистров процессора и поро- ждение кода для параллельных вычислений. Распределение регистров процессора Процессоры, на базе которых строятся современные вычислительные системы, имеют, как правило, несколько программно-доступных регистров. Часть из них может быть предназначена для выполнения каких-либо определенных целей (на- пример, регистр — указатель стека или регистр — счетчик команд), другие могут быть использованы практически произвольным образом при выполнении раз- личных операций (так называемые «регистры общего назначения»). Использование регистров общего назначения для хранения значений операндов и результатов вычислений позволяет добиться увеличения быстродействия про- граммы, так как действия над регистрами процессора всегда выполняются быст- рее, чем над ячейками памяти. Кроме того, в ряде процессоров не все операции могут быть выполнены над ячейками памяти, а потому часто требуется предва- рительная загрузка операнда в регистр. Результат выполнения операции чаще всего тоже оказывается в регистре, и если необходимо, его надо выгрузить (запи- сать) в ячейку памяти. Программно доступных регистров процессора всегда ограниченное количество. Поэтому встает вопрос об их распределении при выполнении вычислений. Этим
Оптимизация кода. Основные методы оптимизации 649 занимается алгоритм распределения регистров, который присутствует практиче- ски в каждом современном компиляторе в части генерации кода результирую- щей программы. При распределении регистров под хранение промежуточных и окончательных ре- зультатов вычислений может возникнуть ситуация, когда значение той или иной переменной (связанной с нею ячейки памяти) необходимо загрузить в регистр для дальнейших вычислений, а все имеющиеся доступные регистры уже заняты. Тогда компилятору перед созданием кода по загрузке переменной в регистр не- обходимо сгенерировать код для выгрузки одного из значений из регистра в ячейку памяти (чтобы освободить регистр). Причем выгружаемое значение за- тем, возможно, придется снова загружать в регистр. Встает вопрос о выборе того регистра, значение которого нужно выгрузить в память. При этом возникает необходимость выработки стратегии замещения регистров процессора. Она чем-то сродни стратегиям замещения процессов и страниц в па- мяти компьютера, которые были рассмотрены в части 1 данного пособия. Одна- ко отличие заключается в том, что, в отличие от диспетчера памяти в ОС, компи- лятор может проанализировать код и выяснить, какое из выгружаемых значений ему понадобится для дальнейших вычислений и когда (следует помнить, что компилятор сам вычислений не производит — он только порождает код для них, иное дело — интерпретатор). Как правило, стратегии замещения регистров про- цессора предусматривают, что выгружается тот регистр, значение которого будет использовано в последующих операциях позже всех (хотя не всегда эта страте- гия является оптимальной). Кроме общего распределения регистров могут использоваться алгоритмы рас- пределения регистров специального характера. Например, во многих процессо- рах есть регистр-аккумулятор, который ориентирован на выполнение различных арифметических операций (операции с ним выполняются либо быстрее, либо имеют более короткую запись). Поэтому в него стремятся всякий раз загрузить чаще всего используемые операнды; он же используется, как правило, при пере- даче результатов функций и отдельных операторов. Могут быть также регистры, ориентированные на хранение счетчиков циклов, базовых указателей и т. п. Тогда компилятор должен стремиться распределить их именно для тех целей, на вы- полнение которых они ориентированы. Оптимизация кода для процессоров, допускающих распараллеливание вычислений Многие современные процессоры допускают возможность параллельного вы- полнения нескольких операций. Как правило, речь идет об арифметических опе- рациях. Тогда компилятор может порождать объектный код таким образом, чтобы в нем содержалось максимально возможное количество соседних операций, все опе- ранды которых не зависят друг от друга. Решение такой задачи в глобальном объеме для всей программы в целом не представляется возможным, но для кон- кретного оператора оно, как правило, заключается в порядке выполнения опера- ций. В этом случае нахождение оптимального варианта сводится к выполнению перестановки операций (если она возможна, конечно). Причем оптимальный ва-
650 Глава 14. Генерация и оптимизация кода риант зависит как от характера операции, так и от количества имеющихся в про- цессоре конвейеров для выполнения параллельных вычислений. Например, операцию A+B+C+D+E+F на процессоре с одним потоком обработки дан- ных лучше выполнять в порядке ((((A+B)+C)+D)+E)+F. Тогда потребуется меньше ячеек для хранения промежуточных результатов, а скорость выполнения от по- рядка операций в данном случае не зависит. Та же операция на процессоре с двумя потоками обработки данных в целях уве- личения скорости выполнения может быть обработана в порядке ((А+В)+С)+ +((D+E)+F). Тогда по крайней мере операции А+В и D+E, а также сложение с их ре- зультатами могут быть обработаны в параллельном режиме. Конкретный поря- док команд, а также распределение регистров для хранения промежуточных ре- зультатов будут зависеть от типа процессора. На процессоре с тремя потоками обработки данных ту же операцию можно уже разбить на части в виде (A+B)+(C+D)+(E+F). Теперь уже три операции А+В, C+D и E+F могут быть выполнены параллельно. Правда, их результаты уже должны быть обработаны последовательно, но тут уже следует принять во внимание соседние операции для нахождения наиболее оптимального варианта. Контрольные вопросы и задачи Вопросы 1. Справедливы ли следующие утверждения: О любой язык программирования является контекстно-свободным языком; О синтаксис любого языка программирования может быть описан с помо- щью контекстно-свободной грамматики; О любой язык программирования является контекстно-зависимым языком; О семантика любого языка программирования может быть описана с помо- щью контекстно-свободной грамматики? 2. Какие задачи в компиляторе решает семантический анализ? Можно ли по- строить компилятор без семантического анализатора? Если да, то какие усло- вия должны при этом выполняться? 3. Цепочка символов, принадлежащая любому языку программирования, может быть распознана с помощью распознавателя для контекстно-зависимых язы- ков. При этом не будет требоваться дополнительный семантический анализ цепочки. Почему такой подход не применяется в компиляторах на практике? 4. На языке С описаны переменные: int i.j.k; /* целочисленные переменные */ double x.y.z; /* вещественные переменные */ объясните, есть ли различия в объектном коде, порождаемом компиляторов для перечисленных ниже пар операторов, и если есть, то в чем они заключаются: О i = х/у; i = (int)(х/у); О i в х/у; i = ((int)x)/((int)y);
Контрольные вопросы и задачи 651 О i = j/k; i = ((double)j)/(double)k); О z « x/y; z=((int)x)/((int)y); О z = j/k; z = (double)(j/k): О z = j/k; z = ((double)j)/( (double)k); Объясните, какие функции по преобразованию типов должен будет включить в объектный код компилятор в каждом из перечисленных случаев. В каких случаях выполняется неявное преобразование типов? 5. Функция ftest, описанная в исходной программе на языке C++ как «void ftest(int i)», помещается в библиотеку. В объектном коде библиотеки она по- лучит имя, присвоенное компилятором. Это имя может иметь вид, подобный «ftest@i@v». Если разработчик пользуется одним и тем же компилятором, то имя функции будет присваиваться одно и то же. Почему тем не менее не сто- ит вызывать функцию из библиотеки по этому имени? Что должен предпри- нять разработчик, чтобы функция в библиотеке имела имя «ftest»? 6. Почему распределение памяти не может быть выполнено до выполнения се- мантического анализа? Как работает процесс распределения памяти с табли- цей идентификаторов? 7. Может ли компилятор для языков С и Pascal использовать статическую схему организации дисплея памяти процедуры (функции)? А динамическую схему? 8. Синтаксис языка Pascal предусматривает возможность вложения процедур и функций. Как это должно отразиться на организации дисплея памяти, если учесть, что, согласно семантике языка, вложенные процедуры и функции мо- гут работать с локальными данными объемлющих процедур и функций? Тре- буются ли какие-либо изменения в стековой организации дисплея памяти в таком случае? 9. От чего зависит состав информации, хранящейся в таблице RTTI? Регламен- тируется ли состав информации в этой таблице синтаксисом или семантикой исходного языка программирования? Зависит ли информация в таблице RTTI от архитектуры целевой вычислительной системы? 10. Почему в большинстве компиляторов помимо генерации результирующего объектного кода выполняется еще и оптимизация его? Можно ли построить компилятор, исключив фазу оптимизации кода? И. От чего зависит эффективность объектного кода, построенного компилято- ром? Влияют ли на эффективность результирующего кода синтаксис и се- мантика исходного языка программирования? 12. В чем заключается основной принцип СУ-перевода? Является ли СУ-пере- вод наиболее эффективным методом порождения результирующего кода? 13. Сравните между собой основные способы внутреннего представления программ. На каких этапах компиляции лучше всего использовать каждый из этих спо- собов? Какие (или какой) из способов внутреннего представления програм- мы обязательно должен уметь обрабатывать компилятор? 14. Почему имеются трудности с оптимизацией выражений, представленных в фор- ме обратной польской записи?
652 Глава 14. Генерация и оптимизация кода 15. От чего зависит код, порождаемый процедурой СУ-перевода для каждого узла дерева операций? Должна ли процедура СУ-перевода обращаться к таблице символов? Должна ли процедура СУ-перевода учитывать тип операндов? Как зависят действия этой процедуры от типа порождаемого ею внутреннего пред- ставления программы? 16. Какой из двух основных методов оптимизации: машинно-зависимый или ма- шинно-независимый может порождать более эффективный результирующий код? Как сочетаются эти два метода оптимизации в компиляторе? 17. Почему линейные участки легче поддаются оптимизации, чем все другие час- ти внутреннего представления программы и объектного кода? 18. Почему можно утверждать, что исключение из исходного языка операций с указателями и адресами значительно усиливает возможности компилятора по оптимизации кода? 19. Массив А содержит 10 элементов (индексы от 0 до 9). Будет ли условный оператор языка Pascal «if (i<10) and (A[i]=0) then ...» правильным с точки зрения порождаемого компилятором объектного кода? Будет ли правильным с этой же точки зрения оператор «if (i<=0) or (i>=10) or (A[i]=0) then ...»? Что можно сказать об объектном коде, порождаемом компилятором для каж- дого из этих операторов, если компилятор не будет выполнять оптимизацию логических выражений? 20. Какое значение будет иметь переменная i после выполнения следующего опе- ратора языка С: if (++1>10 || 1++>20 && 1++<50 || i++<0) 1--; если переменная i имеет перед его выполнением следующие значения: О 9 О И О 21 О 0 О -2? Как изменится значение переменной при выполнении этого оператора, если предположить, что при порождении объектного кода компилятор не выпол- няет оптимизацию логических выражений? 21. Какие варианты распараллеливания выполнения операций может использо- вать компилятор при порождении объектного кода, необходимого для вычис- ления выражения «А*В*С + A*D/E - В/Е - F»? Задачи 1. Функции описаны на языке Pascal следующим образом: procedure A(i : integer; j: integer); var t; float; begin
Контрольные вопросы и задачи 653 end; procedure В; var a.b.c: integer; begin end; Функция А вызывает функцию В, которая затем рекурсивно вызывает функ- цию А. Отобразите содержимое стека, после выполнения одного такого вызо- ва, двух таких вызовов. Объясните, как будет происходить возврат в вызы- вающую программу, если после двух вызовов рекурсия заканчивается. 2. На языке Pascal описаны следующие типы данных: type TestArray = array[1..4] of integer; TestRecord = record A : TestArray; В ; char; C : integer: end; TestUnion = record J : byte; case I : integer of 1: (A ; TestRecord); 2: (B : TestArray); 3; (C : integer); end; а также переменные: var cl,c2 : char; il.i2 ; integer; al : TestArray; rl,r2,r3 : TestRecord; ul ; TestUnion; Какой размер памяти потребуется для этих переменных, если считать, что размер памяти, необходимой для типов данных «char» и «byte», составляет 1 байт, а размер памяти для типа данных «integer» — 2 байта? Как изменится размер необходимой памяти, если предположить, что архитек- тура целевой вычислительной системы предполагает выравнивание данных по границе 2 байта? 4 байта? 3. Вычислите следующие выражения, записанные в форме обратной польской записи: О 12*354* + + 012354**++ 012+3*5+4-
654 Глава 14. Генерация и оптимизация кода 012+3*54+- напишите для каждого из них выражение в форме инфиксной (обычной) записи. 4. Дана грамматика G({( а}, {S,T,E}, Р, S) Р: S -> SAT I т Т —> Т&Е | Е Е -> ~Е | (S) | а Постройте схему СУ-компиляции выражений входных цепочек языка, задан- ного этой грамматикой, во внутреннее представление в форме обратной поль- ской записи. 5. Для грамматики, приведенной выше в задаче № 4, постройте схему СУ-пере- вода во внутреннее представление программы в форме триад. Выполните пе- ревод в форму триад цепочки символов «аЛа£~а£(аЛ—а)». 6. Для грамматики, приведенной выше в задаче № 4, постройте схему СУ-пере- вода во внутреннее представление программы в форме команд ассемблера (на- пример, Intel 80x86). Выполните перевод в ассемблерный код цепочки симво- лов «аЛа£~а£(аЛ—а)». 7. Дана последовательность операций: А :» 2: В :» А * 3; С :» 4*3 + 1; А :» D; D :» В + С; С :» А; В :» А + 4*С + 3*(D+12); О преобразуйте ее в форму записи на основе триад; О выполните свертку операций. 8. Дана последовательность операций: А := В*С + D*E: В :» В*С + D*E; С := В*С + D*E; D := В*С - (D*E + 2): Е :• В*С - (D*E + 3); А А*Е + В*Е + 1; В :• А*Е + В*Е + 1; О преобразуйте ее в форму записи на основе триад; О выполните исключение лишних операций. 9. Постройте алгоритм распределения регистров для хранения промежуточных результатов на основе внутреннего представления программы в форме триад. 10. Постройте алгоритм перевода внутреннего представления программы в фор- ме триад в последовательность команд ассемблера.
ГЛАВА 15 Современные системы программирования Понятие и структура системы программирования История возникновения систем программирования Всякий компилятор является составной частью системного программного обес- печения. Основное назначение компиляторов — служить для разработки новых прикладных и системных программ с помощью языков высокого уровня. Любая программа, как системная, так и прикладная, проходит этапы жизненного цикла, начиная от проектирования и вплоть до внедрения и сопровождения [13, 25, 43]. А компиляторы — это средства, служащие для создания программного обеспечения на этапах кодирования, тестирования и отладки. Однако сам по себе компилятор не решает полностью всех задач, связанных с разработкой новой программы. Средств только лишь компилятора недостаточ- но для того, чтобы обеспечить прохождение программой указанных этапов жиз- ненного цикла. Поэтому компиляторы — это программное обеспечение, которое функционирует в тесном взаимодействии с другими техническими средствами, применяемыми на данных этапах. Основные технические средства, используемые в комплексе с компиляторами, включают в себя следующие программные модули: □ текстовые редакторы, служащие для создания текстов исходных программ; □ компоновщики, позволяющие объединять несколько объектных модулей, по- рождаемых компилятором, в единое целое; □ библиотеки прикладных программ, содержащие в себе наиболее часто ис- пользуемые функции и подпрограммы в виде готовых объектных модулей;
656 Глава 15. Современные системы программирования □ загрузчики, обеспечивающие подготовку готовой программы к выполнению; □ отладчики, выполняющие программу в заданном режиме с целью поиска, об- наружения и локализации ошибок. Первоначально компиляторы представляли собой обособленные программные модули, решающие исключительно задачу перевода исходного текста программы на входном языке в язык машинных кодов. Компиляторы разрабатывались вне связи с другими техническими средствами, с которыми им приходилось взаимо- действовать. В задачу разработчика программы входило обеспечить взаимосвязь всех используемых технических средств: □ подать входные данные в виде текста исходной программы на вход компиля- тора; □ получить от компилятора результаты его работы в виде набора объектных файлов; □ подать весь набор полученных объектных файлов вместе с необходимыми библиотеками подпрограмм на вход компоновщику; □ получить от компоновщика единый файл программы (исполняемый файл) и подготовить его к выполнению с помощью загрузчика; □ поставить программу на выполнение, при необходимости использовать от- ладчик для проверки правильности выполнения программы. Все эти действия выполнялись с помощью последовательности команд, иници- ировавших запуск соответствующих программных модулей с передачей им всех необходимых параметров. Параметры передавались каждому модулю в команд- ной строке и представляли собой набор имен файлов и настроек, реализованных в виде специальных «ключей». Пользователи могли выполнять эти команды по- следовательно вручную, а с развитием средств командных процессоров ОС они стали объединять их в командные файлы. Со временем разработчики компиляторов постарались облегчить труд пользова- телей, предоставив им все необходимое множество программных модулей в со- ставе одной поставки компилятора. Теперь компиляторы поставлялись уже вку- пе со всеми необходимыми сопровождающими техническими средствами. Кроме того, были унифицированы форматы объектных файлов и файлов библиотек подпрограмм. Теперь разработчики, имея компилятор от одного производителя, могли в принципе пользоваться библиотеками и объектными файлами, получен- ными от другого производителя компиляторов. Для написания командных файлов компиляции был предложен специальный командный язык — язык Makefile [31, 39, 67, 68, 90]. Он позволял в достаточно гибкой и удобной форме описать весь процесс создания программы от порожде- ния исходных текстов до подготовки ее к выполнению. Это было удобное, но достаточно сложное техническое средство, требующее от разработчика высокой степени подготовки и профессиональных знаний, поскольку сам командный язык Makefile был по сложности сравним с простым языком программирования. Язык Makefile стал стандартным средством, единым для компиляторов всех раз- работчиков.
Понятие и структура системы программирования 657 Такая структура средств разработки существовала достаточно долгое время, а в некоторых случаях она используется и по сей день (особенно при создании сис- темных программ). Ее широкое распространение было связано с тем, что сама по себе вся эта структура средств разработки была очень удобной при пакетном вы- полнении программ на компьютере, что способствовало ее повсеместному при- менению в эпоху «mainframe». Следующим шагом в развитии средств разработки стало появление так называе- мой «интегрированной среды разработки». Интегрированная среда объединила в себе возможности текстовых редакторов исходных текстов программ и команд- ный язык компиляции. Пользователь (разработчик исходной программы) теперь не должен был выполнять всю последовательность действий от порождения ис- ходного кода до его выполнения, от него также не требовалось описывать этот процесс с помощью системы команд в Makefile. Теперь ему было достаточно только указать в удобной интерфейсной форме состав необходимых для созда- ния программы исходных модулей и библиотек. Ключи, необходимые компиля- тору и другим техническим средствам, также задавались в виде интерфейсных форм настройки. После этого интегрированная среда разработки сама автоматически готовила всю необходимую последовательность команд Makefile, выполняла их, получала ре- зультат и сообщала о возникших ошибках при их наличии. Причем сам текст ис- ходных модулей пользователь мог изменить здесь же, не прерывая работу с ин- тегрированной средой, чтобы потом при необходимости просто повторить весь процесс компиляции. Создание интегрированных сред разработки стало возможным благодаря бурно- му развитию персональных компьютеров и появлению развитых средств интер- фейса пользователя (сначала текстовых, а потом и графических). Их появление на рынке определило дальнейшие развитие такого рода технических средств. Пожалуй, первой удачной средой такого рода можно признать интегрированную среду программирования Turbo Pascal на основе языка Pascal производства фир- мы Borland [34, 71, 72, 98]. Ее широкая популярность определила тот факт, что со временем все разработчики компиляторов обратились к созданию интегриро- ванных средств разработки для своих продуктов. Развитие интегрированных сред несколько снизило требования к профессио- нальным навыкам разработчиков исходных программ. Теперь в простейшем слу- чае от разработчика требовалось только знание исходного языка (его синтаксиса и семантики). При создании прикладной программы ее разработчик мог в про- стейшем случае даже не разбираться в архитектуре целевой вычислительной системы. Дальнейшее развитие средств разработки также тесно связано с повсеместным распространением развитых средств графического интерфейса пользователя. Та- кой интерфейс стал неотъемлемой составной частью многих современных ОС и так называемых графических оболочек [35, 71]. Со временем он стал стандар- том «де-факто» практически во всех современных прикладных программах. Это не могло не сказаться на требованиях, предъявляемым к средствам разра- ботки программного обеспечения. В их состав были сначала включены соответ-
658 Глава 15. Современные системы программирования ствующие библиотеки, обеспечивающие поддержку развитого графического ин- терфейса пользователя и взаимодействие с функциями API (application program interface, прикладной программный интерфейс) операционных систем. А затем для работы с ними потребовались дополнительные средства, обеспечивающие разработку внешнего вида интерфейсных модулей. Такая работа была уже более характерна для дизайнера, чем для программиста. Для описания графических элементов программ потребовались соответствую- щие языки. На их основе сложилось понятие «ресурсов» (resources)1 приклад- ных программ. Ресурсами прикладной программы будем называть множество данных, обеспечи- вающих внешний вид интерфейса пользователя этой программы и не связанных напрямую с логикой выполнения программы. Характерными примерами ресур- сов являются: тексты сообщений, выдаваемых программой; цветовая гамма эле- ментов интерфейса; надписи на таких элементах, как кнопки и заголовки окон и т. п. Для формирования структуры ресурсов, в свою очередь, потребовались редакто- ры ресурсов, а затем и компиляторы ресурсов, обрабатывающие результат их ра- боты2. Ресурсы, полученные с выхода компиляторов ресурсов, стали обрабаты- ваться компоновщиками и загрузчиками. Весь этот комплекс программно-технических средств в настоящие время состав- ляет новое понятие, которое здесь названо «системой программирования». Структура современной системы программирования Системой программирования будем называть весь комплекс программных средств, предназначенных для кодирования, тестирования и отладки программного обес- печения. Нередко системы программирования взаимосвязаны и с другими тех- ническими средствами, служащими целям создания программного обеспечения на более ранних этапах жизненного цикла (от формулировки требований и ана- лиза до проектирования). Однако рассмотрение таких систем выходит за рамки данного учебного пособия. Системы программирования в современном мире доминируют на рынке средств разработки. Практически все фирмы-разработчики компиляторов поставляют свои продукты в составе соответствующей системы программирования в комплексе всех прочих технических средств. Отдельные компиляторы являются редкостью и, как правило, служат только узко специализированным целям. 1 Термин «ресурсы» следует признать не слишком удачным, так как этим словом обозна- чаются очень многие понятия, связанные с вычислительными системами (например, ре- сурсы вычислительного процесса). Однако так сложилось, что этот термин применяется при работе со средствами разработки, поэтому придется принять его. 2 Наверное, с точки зрения терминологии компиляторы ресурсов правильнее было бы на- звать «трансляторы», так как в результате своей работы они обычно порождают не объ- ектный файл, а некий промежуточный код ресурсов. Однако термин «компилятор ресурсов» стал уже общепринятым.
Понятие и структура системы программирования 659 На рис. 15.1 приведена общая структура современной системы программирова- ния. На ней выделены все основные составляющие современной системы про- граммирования и их взаимосвязь. Отдельные составляющие разбиты по группам в соответствии с этапами развития средств разработки. Эти группы отражают все этапы развития от отдельных программных компонентов до цельной систе- мы программирования. Рис. 15.1. Общая структура и этапы развития систем программирования Из рис. 15.1 видно, что современная система программирования — это достаточ- но сложный комплекс различных программно-технических средств. Все они слу- жат цели создания прикладного и системного программного обеспечения. Тенденция такова, что все развитие систем программирования идет в направле- нии неуклонного повышения их дружественности и сервисных возможностей. Это связано с тем, что на рынке в первую очередь лидируют те системы програм- мирования, которые позволяют существенно снизить трудозатраты, потребные для создания программного обеспечения на этапах жизненного цикла, связан- ных с кодированием, тестированием и отладкой программ. Показатель снижения трудозатрат в настоящее время считается более существенным, чем показатели, определяющие эффективность результирующей программы, построенной с по- мощью системы программирования. В качестве основных тенденций в развитии современных систем программиро- вания следует указать внедрение в них средств разработки на основе так назы- ваемых «языков четвертого поколения» — 4GL (four generation languages), — а также поддержка систем «быстрой разработки программного обеспечения» — RAD (rapid application development). Языки четвертого поколения — 4GL — представляют собой широкий набор средств, ориентированных на проектирование и разработку программного обеспечения. Они строятся на основе оперирования не синтаксическими структурами языка и описаниями элементов интерфейса, а представляющими их графическими об- разами. На таком уровне проектировать и разрабатывать прикладное программ- ное обеспечение может пользователь, не являющийся квалифицированным про- граммистом, зато имеющий представление о предметной области, на работу в
660 Глава 15. Современные системы программирования которой ориентирована прикладная программа. Языки четвертого поколения яв- ляются следующим (четвертым по счету) этапом в развитии систем программи- рования. Описание программы, построенное на основе языков 4GL, транслируется затем в исходный текст и файл описания ресурсов интерфейса, представляющие со- бой обычный текст на соответствующем входном языке высокого уровня. С этим текстом уже может работать профессиональный программист-разработчик — он может корректировать и дополнять его необходимыми функциями. Дальнейший ход создания программного обеспечения идет уже традиционным путем, как это показано на рис. 15.1. Такой подход позволяет разделить работу проектировщика, ответственного за общую концепцию всего проекта создаваемой системы, дизайнера, отвечающего за внешний вид интерфейса пользователя, и профессионального программис- та, отвечающего непосредственно за создание исходного кода создаваемого про- граммного обеспечения. В целом языки четвертого поколения решают уже более широкий класс задач, чем традиционные системы программирования. Они составляют часть средств автоматизированного проектирования и разработки программного обеспечения, поддерживающих все этапы жизненного цикла — CASE-систем. Их рассмотре- ние выходит за рамки данного учебного пособия. Более подробную информацию о языках 4GL, технологии RAD и CASE-систе- мах в целом можно получить в [35, 43]. Принципы функционирования систем программирования Функции текстовых редакторов в системах программирования Текстовый редактор в системе программирования — это программа, позволяю- щая создавать, изменять и обрабатывать исходные тексты программ на языках высокого уровня. В принципе текстовые редакторы появились вне какой-либо связи со средства- ми разработки. Они решали задачи создания, редактирования, обработки и хра- нения на внешнем носителе любых текстов, которые не обязательно должны были быть исходными текстами программ на языках высокого уровня. Эти функции многие текстовые редакторы выполняют и по сей день. Возникновение интегрированных сред разработки на определенном этапе разви- тия средств разработки программного обеспечения позволило непосредственно включить текстовые редакторы в состав этих средств. Первоначально такой под- ход привел к тому, что пользователь (разработчик исходной программы) работал только в среде текстового редактора, не отрываясь от нее для выполнения ком- пиляции, компоновки, загрузки и запуска программы на выполнение. Для этого
Принципы функционирования систем программирования 661 потребовалось создать средства, позволяющие отображать ход всего процесса разработки программы в среде текстового редактора, — такие, например, как ме- тод отображения ошибок в исходной программе, обнаруженных на этапе компи- ляции, с позиционированием на место в тексте исходной программы, содержа- щее ошибку. Можно сказать, что с появлением интегрированных сред разработки ушло в про- шлое то время, когда разработчики исходных текстов вынуждены были перво- начально готовить тексты программ на бумаге с последующим вводом их в ком- пьютер. Процессы написания текстов и собственно создание программного обеспечения стали единым целым. Интегрированные среды разработки оказались очень удобным средством. Они стали завоевывать рынок средств разработки программного обеспечения. А с их развитием расширялись и возможности, предоставляемые разработчику в среде текстового редактора. Со временем появились средства пошаговой отладки про- грамм непосредственно по их исходному тексту, объединившие в себе возможно- сти отладчика и редактора исходного текста. Другим примером может служить очень удобное средство, позволяющее графически выделить в исходном тексте программы все лексемы исходного языка по их типам — оно сочетает в себе воз- можности редактора исходных текстов и лексического анализатора компиля- тора. В итоге в современных системах программирования текстовый редактор стал важ- ной составной частью, которая не только позволяет пользователю подготавли- вать исходные тексты программ, но и выполняет все интерфейсные и сервисные функции, предоставляемые пользователю системой программирования. И хотя современные разработчики по-прежнему могут использовать произвольные средст- ва для подготовки исходных текстов программ, как правило, они все же предпо- читают пользоваться именно тем текстовым редактором, который включен в со- став данной системы программирования. Компилятор как составная часть системы программирования Компиляторы являются, безусловно, основными модулями в составе любой сис- темы программирования. Поэтому не случайно, что они стали одним из главных предметов рассмотрения в данном учебном пособии. Без компилятора никакая система программирования не имеет смысла, а все остальные ее составляющие на самом деле служат лишь целям обеспечения работы компилятора и выполне- ния им своих функций. От первых этапов развития систем программирования вплоть до появления интегрированных сред разработки пользователи (разработчики исходных про- грамм) всегда так или иначе имели дело с компилятором. Они непосредственно взаимодействовали с ним как с отдельным программным модулем. Сейчас, работая с системой программирования, пользователь, как правило, име- ет дело только с ее интерфейсной частью, которую обычно представляет тексто- вый редактор с расширенными функциями. Запуск модуля компилятора и вся
662 Глава 15. Современные системы программирования его работа происходят автоматически и скрытно от пользователя — разработ- чик видит только конечные результаты выполнения компилятора. Хотя многие современные системы программирования сохранили прежнюю возможность не- посредственного взаимодействия разработчика с компилятором (это и Makefile, и так называемый «интерфейс командной строки»), но пользуется этими средст- вами только узкий круг профессионалов. Большинство пользователей систем программирования сейчас редко непосредственно сталкиваются с компилято- рами. На самом деле, кроме самого основного компилятора, выполняющего перевод исходного текста на входном языке в язык машинных команд, большинство сис- тем программирования могут содержать в своем составе целый ряд других ком- пиляторов и трансляторов. Так, большинство систем программирования содер- жат в своем составе и компилятор с языка ассемблера, и компилятор (транслятор) с входного языка описания ресурсов. Все они редко непосредственно взаимодей- ствуют с пользователем. Тем не менее, работая с любой системой программирования, следует помнить, что основным модулем ее всегда является компилятор. Именно технические характеристики компилятора, прежде всего, влияют на эффективность результи- рующих программ, порождаемых системой программирования. Компоновщик. Назначение и функции компоновщика Компоновщик (или редактор связей) предназначен для связывания между собой объектных файлов, порождаемых компилятором, а также файлов библиотек, входящих в состав системы программирования1. Объектный файл (или набор объектных файлов) не может быть исполнен до тех пор, пока все модули и секции не будут в нем увязаны между собой. Это и делает редактор связей (компоновщик). Результатом его работы является единый файл (часто называемый «исполняемым файлом»), который содержит весь текст ре- зультирующей программы на языке машинных кодов. Компоновщик может по- рождать сообщение об ошибке, если при попытке собрать объектные файлы в единое целое он не смог обнаружить какой-либо необходимой составляющей. Функция компоновщика достаточно проста. Он начинает свою работу с того, что выбирает из первого объектного модуля программную секцию и присваивает ей начальный адрес. Программные секции остальных объектных модулей получают адреса относительно начального адреса в порядке следования. При этом может выполняться также функция выравнивания начальных адресов программных секций. Одновременно с объединением текстов программных секций объединя- ются секции данных, таблицы идентификаторов и внешних имен. Разрешаются межсекционные ссылки. 1 Автору встречались в технической литературе примеры, когда компоновщик называли загрузчиком. С точки зрения автора это принципиально неправильно. Функции компо- новщика и загрузчика существенно различаются. В современных системах программиро- вания загрузчик, как правило, отсутствует, его функции выполняет ОС.
Принципы функционирования систем программирования 663 Процедура разрешения ссылок сводится к вычислению значений адресных кон- стант процедур, функций и переменных с учетом перемещений секций относи- тельно начала собираемого программного модуля. Если при этом обнаруживают- ся ссылки к внешним переменным, отсутствующим в списке объектных модулей, редактор связей организует их поиск в библиотеках, доступных в системе про- граммирования. Если же и в библиотеке необходимую составляющую найти не удается, формируется сообщение об ошибке. Обычно компоновщик формирует простейший программный модуль, создавае- мый как единое целое. Однако в более сложных случаях компоновщик может создавать и другие модули: программные модули с оверлейной структурой, объ- ектные модули библиотек и модули динамически подключаемых библиотек (ра- бота с оверлейными и динамически подключаемыми модулями в ОС описана в первой части данного пособия). Загрузчики и отладчики. Функции загрузчика Большинство объектных модулей в современных системах программирования строятся на основе так называемых относительных адресов. Компилятор, порож- дающий объектные файлы, а затем и компоновщик, объединяющий их в единое целое, не могут знать точно, в какой реальной области памяти компьютера будет располагаться программа в момент ее выполнения. Поэтому они работают не с реальными адресами ячеек ОЗУ, а с некоторыми относительными адресами. Та- кие адреса отсчитываются от некоторой условной точки, принятой за начало области памяти, занимаемой результирующей программой (обычно это точка начала первого модуля программы). Конечно, ни одна программа не может быть исполнена в этих относительных адресах. Поэтому требуется модуль, который бы выполнял преобразование отно- сительных адресов в реальные (абсолютные) адреса непосредственно в момент запуска программы на выполнение. Этот процесс называется трансляцией адре- сов и выполняет его специальный модуль, называемый загрузчиком. Однако загрузчик не всегда является составной частью системы программирова- ния, поскольку выполняемые им функции очень зависят от архитектуры целе- вой вычислительной системы, в которой выполняется результирующая программа, созданная системой программирования. На первых этапах развития ОС загруз- чики существовали в виде отдельных модулей, которые выполняли трансляцию адресов и готовили программу к выполнению — создавали так называемый «об- раз задачи». Такая схема была характерна для многих ОС (например, для ОСРВ на ЭВМ типа СМ-1, ОС RSX/11 или RAFOS на ЭВМ типа СМ-4 и т. п. [20, 57]). Образ задачи можно было сохранить на внешнем носителе или же создавать его вновь всякий раз при подготовке программы к выполнению. С развитием архитектуры вычислительных средств компьютера появилась воз- можность выполнять трансляцию адресов непосредственно в момент запуска про- граммы на выполнение. Для этого потребовалось в состав исполняемого файла включить соответствующую таблицу, содержащую перечень ссылок на адреса, которые необходимо подвергнуть трансляции. В момент запуска исполняемого
664 Глава 15. Современные системы программирования файла ОС обрабатывала эту таблицу и преобразовывала относительные адреса в абсолютные. Такая схема, например, характерна для ОС типа MS-DOS, кото- рые широко распространены в среде персональных компьютеров. В этой схеме модуль загрузчика как таковой отсутствует (фактически он входит в состав ОС), а система программирования ответственна только за подготовку таблицы транс- ляции адресов — эту функцию выполняет компоновщик. В современных ОС существуют сложные методы преобразования адресов, ко- торые работают непосредственно уже во время выполнения программы. Эти ме- тоды основаны на возможностях, аппаратно заложенных в архитектуру вычис- лительных комплексов. Методы трансляции адресов могут быть основаны на сегментной, страничной и сегментно-страничной организации памяти (все эти методы рассмотрены в первой части данного пособия). Тогда для выполнения трансляции адресов в момент запуска программы должны быть подготовлены соответствующие системные таблицы. Эти функции целиком ложатся на моду- ли ОС, поэтому они не выполняются в системах программирования. Еще одним модулем системы программирования, функции которого тесно связа- ны с выполнением программы, является отладчик. Отладчик — это программный модуль, который позволяет выполнить основные задачи, связанные с мониторингом процесса выполнения результирующей при- кладной программы. Этот процесс называется отладкой и включает в себя сле- дующие основные возможности: □ последовательное пошаговое выполнение результирующей программы на ос- нове шагов по машинным командам или по операторам входного языка; □ выполнение результирующей программы до достижения ею одной из задан- ных точек останова (адресов останова); □ выполнение результирующей программы до наступления некоторых заданных условий, связанных с данными и адресами, обрабатываемыми этой програм- мой; □ просмотр содержимого областей памяти, занятых командами или данными результирующей программы. Первоначально отладчики представляли собой отдельные программные модули, которые могли обрабатывать результирующую программу в терминах языка ма- шинных команд. Их возможности в основном сводились к моделированию вы- полнения результирующих программ в архитектуре соответствующей вычисли- тельной системы. Выполнение могло идти непрерывно либо по шагам. Дальнейшее развитие отладчиков связано со следующими принципиальными моментами: □ появлением интегрированных сред разработки; □ появление возможностей аппаратной поддержки средств отладки во многих вычислительных системах. Первый из этих шагов дал возможность разработчикам программ работать не в терминах машинных команд, а в терминах исходного языка программирова- ния, что значительно сократило трудозатраты на отладку программного обес-
Принципы функционирования систем программирования 665 печения. При этом отладчики перестали быть отдельными модулями и стали интегрированной частью систем программирования, поскольку они должны были теперь поддерживать работу с таблицами идентификаторов (см. раздел «Табли- цы идентификаторов. Организация таблиц идентификаторов», глава 15) и вы- полнять задачу, обратную идентификации лексических единиц языка (см. раздел «Семантический анализ и подготовка к генерации кода», глава 14). Это связано с тем, что в такой среде отладка программы идет в терминах имен, данных поль- зователем, а не в терминах внутренних имен, присвоенных компилятором. Со- ответствующие изменения потребовались также в функциях компиляторов и компоновщиков, поскольку они должны были включать таблицу имен в состав объектных и исполняемых файлов для ее обработки отладчиком. Второй шаг позволил значительно расширить возможности средств отладки. Теперь для них не требовалось моделировать работу и архитектуру соответст- вующей вычислительной системы. Выполнение результирующей программы в режиме отладки стало возможным в той же среде, что и в обычном режиме. В за- дачу отладчика входили только функции перевода вычислительной системы в соответствующий режим перед запуском результирующей программы на отлад- ку. Во многом эти функции являются приоритетными, поскольку зачастую тре- буют установки системных таблиц и флагов процессора вычислительной систе- мы (мы рассмотрели большую часть этих вопросов в первой части данного учебного пособия). Отладчики в современных системах программирования представляют собой модули с развитым интерфейсом пользователя, работающие непосредственно с текстом и модулями исходной программы. Многие их функции интегрированы с функциями текстовых редакторов исходных текстов, входящих в состав систем программирования. Библиотеки подпрограмм как составная часть систем программирования Библиотеки подпрограмм составляют существенную часть систем программиро- вания. Наряду с дружественностью пользовательского интерфейса состав дос- тупных библиотек подпрограмм во многом определяет возможности системы программирования и ее позиции на рынке средств разработки программного обеспечения. Библиотеки подпрограмм входили в состав средств разработки, начиная с самых ранних этапов их развития. Даже когда компиляторы еще представляли собой отдельные программные модули, они уже были связаны с соответствующими биб- лиотеками, поскольку компиляция так или иначе предусматривает связь программ со стандартными функциями исходного языка. Эти функции обязательно долж- ны входить в состав библиотек. С точки зрения системы программирования, библиотеки подпрограмм состоят из двух основных компонентов. Это собственно файл (или множество файлов) библиотеки, содержащий объектный код, и набор файлов описаний функций, под- программ, констант и переменных, составляющих библиотеку. Описания оформ-
666 Глава 15. Современные системы программирования ляются на соответствующем входном языке (например, для языка С или C++ это будет набор заголовочных файлов). Иногда эти файлы могут быть совме- щены. Объектный код библиотеки подключается компоновщиком к результирующей программе при создании исполняемого модуля. По структуре он обычно мало чем отличается от обычных объектных файлов, порождаемых компилятором. Чаще всего система программирования хранит объектный код входящих в ее со- став библиотек в некотором упакованном виде. Набор файлов описания библиотеки служит для информирования компилятора о составе входящих в библиотеку функций. Обрабатывая эти файлы, компиля- тор получает всю необходимую информацию о составе библиотеки с точки зре- ния входного языка программирования. Эти файлы предназначены только для того, чтобы избавить разработчика от необходимости постоянного описания биб- лиотечных функций, подпрограмм, констант и переменных. В состав системы программирования может входить большое количество разно- образных библиотек. Среди них всегда можно выделить основную библиотеку, содержащую обязательные функции входного языка программирования. Эта биб- лиотека всегда используется компилятором, поскольку без нее разработка про- грамм на данном входном языке невозможна. Разработчик даже не должен ука- зывать необходимость использования этой библиотеки (некоторые разработчики программ и не догадываются, что пользуются подобной библиотекой). Все ос- тальные библиотеки необязательны и подключаются к результирующей про- грамме только по прямому указанию разработчика. В процессе развития систем программирования состав библиотек постоянно рас- ширялся. В них включалось все большее и большее число функций. Это было вызвано тем, что система программирования, предоставляющая пользователю более широкий выбор библиотечных функций, получала лучшие позиции на рынке средств разработки программного обеспечения. Сами по себе библиотеки подпрограмм и функций, созданных для того или иного языка, также станови- лись товаром на рынке средств разработки. Этот естественный процесс продол- жается до сих пор, и чем длиннее история существования того или иного языка программирования, тем шире диапазон существующих для него библиотек. Системы программирования для некоторых языков продолжают существовать во многом благодаря тому, что для них создан мощный аппарат библиотечных функций. Так язык FORTRAN существует благодаря широчайшему набору ма- тематических и физических функций, а язык COBOL — функций из финансо- вой сферы. В ходе развития систем программирования принципы создания и использова- ния библиотечных функций претерпели мало изменений. Принципиально новые возможности предоставили только современные ОС, которые позволили под- ключать к результирующим программам не статические, а динамические биб- лиотеки. Динамические библиотеки в отличие от традиционных (статических) библио- тек подключаются к программе не в момент ее компоновки, а непосредственно в ходе выполнения, как только программа затребовала ту или иную функцию,
Принципы функционирования систем программирования 667 находящуюся в библиотеки. Преимущества таких библиотек очевидны — они не требуют включать в программу объектный код часто используемых функций, чем существенно сокращают объем кода. Различные программы, выполняемые в ОС, могут пользоваться кодом одной и той же библиотеки, содержащейся в данной ОС. Формат файлов динамических библиотек может быть различным — как прави- ло, он строго определяется требованиями соответствующей ОС. Как и статиче- ские библиотеки, динамические библиотеки предусматривают описание входя- щих в них функций в виде текста на соответствующем входном языке, чтобы дать информацию о них компилятору в ходе обработки исходного текста про- граммы и избавить разработчика от создания таких описаний. Конечно, динамические библиотеки требуют наличия в ОС специального меха- низма, позволяющего подключать часть объектного кода непосредственно по ходу выполнения программы. Однако для современных ОС, выполняющихся в вычислительных системах, которые поддерживают широкий набор методов адресации, это не является проблемой (см. часть 1 данного пособия). Проблемы, главным образом, связаны с тем, что различные прикладные про- граммы связываются с объектным кодом, непосредственно не входящим в их со- став. Таким образом, логика (алгоритм) работы всех этих программы становится зависимой от кода библиотек. Изменение библиотеки, которое может происхо- дить независимо от разработчиков прикладных программ, может непроизвольно повлиять и на функционирование данных программ. Такое развитие событий порой оказывается неожиданным для пользователя программ. Поэтому использование динамических библиотек накладывает определенные обя- зательства как на разработчика программы, так и на создателя самой библиоте- ки. Разработчик прикладной программы должен использовать библиотеку только так, как это определено ее создателем, а создатель библиотеки в случае ее моди- фикации должен организовывать изменения таким образом, чтобы они не сказы- вались на логике работы программ, ориентированных на предыдущие версии его библиотеки. Подавляющее большинство разработчиков (как программ, так и библиотек) стремится придерживаться этих правил, но не всегда им это уда- ется1. Широкий набор динамических библиотек поддерживается всеми современны- ми ОС. Как правило, они содержат системные функции ОС и общедоступные функции программного интерфейса (API). Кроме того, многие независимые раз- работчики предоставляют для различных систем программирования свои дина- мические библиотеки как отдельные товары на рынке средств разработки при- кладных программ. 1 В современных ОС появились средства, позволяющие поддерживать не только файлы, но и версии динамических библиотек. Они во многом используют методы, применяемые в системах управления версиями исходных файлов. Тогда приложение, обращаясь к биб- лиотеке, может указать не только имя, но и требуемую версию вызываемой функции библиотеки.
668 Глава 15. Современные системы программирования Дополнительные возможности систем программирования Лексический анализ «на лету». Система подсказок и справок Лексический анализ «на лету» — это функция текстового редактора в составе системы программирования. Она заключается в поиске и выделении лексем вход- ного языка в тексте программы непосредственно в процессе ее создания разра- ботчиком. Реализуется это следующим образом: разработчик создает исходный текст про- граммы (набирает его или получает из некоторого другого источника), и в то же время система программирования параллельно выполняет поиск лексем в этом тексте. В простейшем случае обнаруженные лексемы просто выделяются в тексте с по- мощью графических средств интерфейса текстового редактора — цветом, шриф- том и т. п. Это облегчает труд разработчика программы, делает исходный текст более наглядным и способствует обнаружению ошибок на самом раннем этапе — на этапе подготовки исходного кода. В более развитых системах программирования найденные лексемы не просто выделяются по ходу подготовки исходного текста, но и помещаются в таблицу идентификаторов компилятора, входящего в состав системы программирования. Такой подход позволяет экономить время на этапе компиляции, поскольку пер- вая ее фаза — лексический анализ — уже выполнена на этапе подготовки исход- ного текста программы. Следующей сервисной возможностью, предоставляемой разработчику системой программирования за счет лексического анализа «на лету», является возможность обращения разработчика к таблице идентификаторов в ходе подготовки исход- ного текста программы. Разработчик может дать компилятору команду найти нужную ему лексему в таблице. Поиск может выполняться по типу или по ка- кой-то части информации лексемы (например, по нескольким первым буквам). Причем поиск может быть контекстно-зависимым — система программирова- ния предоставит разработчику возможность найти лексему именно того типа, который может быть использован в данном месте исходного текста. Кроме самой лексемы разработчику может быть предоставлена некоторая информация о ней — например, типы и состав формальных параметров для функции, перечень дос- тупных методов для типа или экземпляра класса. Это опять же облегчает труд разработчика, поскольку избавляет его от необходимости помнить состав функ- ций и типов многих модулей (прежде всего, библиотечных) или обращаться лишний раз к документации и справочной информации. Лексический анализ «на лету» — мощная функция, значительно облегчающая труд, связанный с подготовкой исходного текста. Она входит не только в состав многих систем программирования, но также и в состав многих текстовых редак- торов, поставляемых отдельно от систем программирования (в последнем случае она позволяет настроиться на лексику того или иного языка).
Дополнительные возможности систем программирования 669 Другой удобной сервисной функцией в современных системах программирова- ния является система подсказок и справок. Как правило, она содержит три ос- новные части: □ справку по семантике и синтаксису используемого входного языка; □ подсказку по работе с самой системой программирования; □ справку о функциях библиотек, входящих в состав системы программирова- ния. Система подсказок и справок в настоящее время является составной частью мно- гих прикладных и системных программ. Как правило, она поддерживается соот- ветствующими утилитами ОС. Поэтому, кроме всего прочего, многие системы программирования включают в свой состав сервисные функции, позволяющие создавать и дополнять систему подсказок и справок. Это делается таким обра- зом, чтобы разработчик мог создавать и распространять вместе со своими при- кладными программами соответствующие им подсказки и справки. Разработка программ в архитектуре «клиент—сервер» Распространение динамически подключаемых библиотек и ресурсов прикладных программ привело к ситуации, когда большинство прикладных программ стало представлять собой не единый программный модуль, а набор сложным образом взаимосвязанных между собой компонентов. Многие из этих компонентов либо входили в состав ОС, либо же требовалась их поставка и установка от других разработчиков, которые очень часто могли быть никак не связаны с разработчи- ками самой прикладной программы. При этом среди всего множества компонентов прикладной программы можно было выделить две логически цельные составляющие: первая — обеспечивающая «нижний уровень» работы приложения, отвечающая за методы хранения, досту- па и разделения данных; вторая — организующая «верхний уровень» работы приложения, включающий в себя логику обработки данных и интерфейс пользо- вателя. Первая составляющая, как правило, представляла собой набор компонентов сто- ронних разработчиков. Часто она так или иначе была связана с доступом к базам данных, которые могли предусматривать достаточно сложную организацию. Для работы ее компонентов требовалось наличие высокопроизводительной вычисли- тельной системы. Вторая составляющая включала в себя собственно алгоритмы, логику и весь ин- терфейс, созданные разработчиками программы. Она требовала наличие связи с методами доступа к данным, содержащимся в первой составляющей. Требова- ния к вычислительным системам, необходимым для выполнения ее компонен- тов, были обычно существенно ниже, чем для первой составляющей. Тогда сложилось понятие приложения, построенного на основе архитектуры «клиент—сервер». В первую (серверную) составляющую такого приложения от- носят все методы, связанные с доступом к данным. Чаще всего их реализует сер-
670 Глава 15. Современные системы программирования вер БД (сервер данных) из соответствующей СУБД (системы управления база- ми данных) в комплекте с драйверами доступа к нему. Во вторую (клиентскую) часть приложения относят все методы обработки данных и представления их пользователю. Клиентская часть взаимодействует, с одной стороны, с сервером, получая от него данные, а с другой стороны — с пользователем, ресурсами при- ложения и ОС, осуществляя обработку данных и отображение результатов. Ре- зультаты обработки клиент опять-таки может сохранить в БД, воспользовав- шись функциями серверной части. Кроме того, со временем на рынке СУБД стали доминировать несколько наибо- лее известных компаний-производителей. Они предлагали стандартизованные интерфейсы для доступа к создаваемым ими СУБД. На них, в свою очередь, ста- ли ориентироваться и разработчики прикладных программ. Такая ситуация оказала влияние и на структуру систем программирования. Мно- гие из них стали предлагать средства, ориентированные на создание приложений в архитектуре «клиент—сервер». Как правило, эти средства поставляются в соста- ве системы программирования и поддерживают возможность работы с широким диапазоном известных серверов данных через один или несколько доступных интерфейсов обмена данными. Разработчик прикладной программы выбирает одно из доступных средств плюс возможный тип сервера (или несколько воз- можных типов), и тогда его задача сводится только к созданию клиентской части приложения, построенной на основе выбранного интерфейса. Создав клиентскую часть, разработчик может далее использовать и распростра- нять ее только в комплексе с соответствующими средствами из состава системы программирования. Интерфейс обмена данными обычно входит в состав систе- мы программирования. Большинство систем программирования предоставляют возможность распространения средств доступа к серверной части без каких-либо дополнительных ограничений. Что касается серверной части, то возможны два пути: простейшие серверы БД требуют от разработчиков приобретения лицензий на средства создания и отлад- ки БД, но часто позволяют распространять результаты работы без дополнитель- ных ограничений; мощные серверы БД, ориентированные на работу десятков и сотен пользователей, требуют приобретения лицензий как на создание, так и на распространение серверной части приложения. В этом случае конечный пользователь приложения получает целый комплекс программных продуктов от множества разработчиков. Более подробно об организации приложений на основе архитектуры «клиент- сервер» можно узнать в [9, 35, 63]. Разработка программ в трехуровневой архитектуре. Серверы приложений Трехуровневая архитектура разработки приложений явилась логическим про- должением идей, заложенных в архитектуре «клиент—сервер». Недостатком архитектуры приложений типа «клиент—сервер» стал тот факт, что в клиентской части приложения совмещались как довольно сложные функции,
Дополнительные возможности систем программирования 671 связанные с обработкой получаемых от сервера данных, так и более простые функции организации интерфейса пользователя. Для выполнения этих функций к вычислительной системе должны предъявляться различные требования, и в том случае когда выполняется сложная обработка данных, эти требования со сторо- ны «клиентской» части приложения могут быть непомерно велики. Кроме того, обработка данных («бизнес-логика» приложения), как правило, изменяется не- значительно по мере прохождения жизненного цикла, развития приложения и выхода его новых версий. В то же время интерфейсная часть может серьезно ви- доизменяться и в предельном случае подстраиваться под требования конкретно- го заказчика. Еще одним фактором, повлиявшим на дальнейшее развитие архитектуры «кли- ент-сервер», стало распространение глобальных сетей и всемирной сети Интернет. Многие приложения стали нуждаться в предоставлении пользователю возмож- ности доступа к данным посредством сети. Возможностей архитектуры «кли- ент-сервер» для этой цели стало во многих случаях недостаточно, поскольку клиент зачастую мог не иметь никаких вычислительных ресурсов, кроме про- граммы навигации по сети (браузера, browser). Поэтому дальнейшим развитием архитектуры «клиент—сервер» стало разделе- ние клиентской части в свою очередь еще на две составляющих: сервер приложе- ний (английские термины «application server» или «middleware»), реализующий обработку данных («бизнес-логику» приложения), и «тонкий клиент» («thin client»), обеспечивающий интерфейс и доступ к результатам обработки (далее «тонкий клиент» будем называть просто «клиентом»). Серверная часть осталась без из- менений, но теперь она получила название «сервер баз данных» («database ser- ver»), чтобы не путаться с сервером приложений. Разделение клиентской части на две составляющих потребовало организации взаимодействия между этими составляющими. Причем это взаимодействие долж- но, с одной стороны, быть достаточно тесным, так как клиент должен выполнять отображение получаемых от сервера данных за время с разумной задержкой (же- лательно минимальной); а с другой стороны, он должен допускать обмен данны- ми по протоколам, поддерживаемым глобальными сетями. Поэтому появилось некоторое число стандартов, ориентированных на организацию такого рода взаи- модействия. Стали появляться новые интерфейсы обмена данными. Среди них можно выделить семейство стандартов COM/DCOM, предложенных фирмой Microsoft для ОС семейства Microsoft Windows [103], а также семейство стан- дартов CORBA (Common Object Request Broker Architecture), поддерживаемое широким кругом производителей и разработчиков программного обеспечения для большого спектра различных ОС [99]. Детальное рассмотрение такого рода стандартов и принципов их организации не входит в рамки данного учебного по- собия. Системы программирования в настоящее время ориентируются на поддержку средств разработки приложений в трехуровневой (трехзвенной) архитектуре. Средства поддержки тех или иных стандартов сейчас появляются в составе мно- гих систем программирования [3, 35, 63, 98, 104, 105]. Принципы их использова- ния и распространения аналогичны принципам, применяемым для средств под- держки архитектуры типа «клиент—сервер». Следует заметить, что существуют
672 Глава 15. Современные системы программирования системы программирования, ориентированные на разработку либо клиентской части, либо сервера приложений; и в то же время многие системы программиро- вания стремятся предоставить разработчикам средства для создания обеих час- тей в трехуровневой архитектуре. Серверная часть (сервер баз данных) в трех- уровневой архитектуре по-прежнему чаще всего является продуктом другого разработчика1. Более подробно об организации приложений на основе трехуровневой техноло- гии можно узнать в [2, 35, 63]. Примеры современных систем программирования В этом разделе пособия ни в коем случае не ставится задача полного описания тех или иных систем программирования, доступных в настоящее время на рынке современных средств разработки программного обеспечения. Полное описание многих из них по объему будет значительно превышать весь материал данного пособия. Автор только дает краткий обзор наиболее известных и распространен- ных в настоящее время систем программирования с точки зрения их компонен- тов, ложащихся на общую структуру типовой системы программирования. Для краткого описания автором были выбраны только самые известные из всего широкого спектра систем программирования, распространенные именно на рын- ке Российской Федерации. Информация о данных системах программирования дается как на основании соответствующей литературы, так и на оснований лич- ного опыта работы автора. Системы программирования компании Borland/lnprise Системы программирования компании Borland достаточно широко известны раз- работчикам в России. Известность и распространенность этих систем програм- мирования определила, прежде всего, простота их использования, поскольку именно в системах программирования этой компании были впервые реализова- ны на практике идеи интегрированной среды программирования. Turbo Pascal Система программирования Turbo Pascal была создана компанией Borland на ос- нове расширения языка Pascal, получившего название Borland Pascal. Отсюда происходит и само название системы программирования. 1 Конечно, сервер БД — это программа, и как всякая программа она создается с использо- ванием соответствующего компилятора; но в этом случае речь уже не идет о создании части трехуровневого приложения — здесь сервер БД выступает как отдельное закончен- ное приложение.
Примеры современных систем программирования 673 Сам язык Pascal был предложен Н. Виртом в конце 70-х годов как хорошо струк- турированный учебный язык. Расширения, привнесенные в язык компанией Bor- land, преследовали две основные цели: □ упрощение обработки в языке структур, представляющих наиболее распро- страненные типы данных — строки и файлы (например, в язык был внесен новый тип данных string); □ реализация в языке основных возможностей объектно-ориентированных язы- ков программирования. Последнее нововведение потребовало серьезной доработки синтаксиса языка. В него были внесены новые ключевые слова, синтаксические конструкции и типы дан- ных. Однако предложенный вариант языка нельзя признать удачным, хотя бы потому, что в нем не полностью реализованы все механизмы объектно-ориенти- рованного программирования. Например, отсутствуют такие мощные средства, как исключения и шаблоны. Есть и другие сложности в использовании языка. Компания Borland построила и реализовала эффективный однопроходный ком- пилятор с языка Borland Pascal. За счет этого в данной системе программиро- вания удалось добиться относительно высокой скорости компиляции исходных программ. Для ускорения работы компоновщика компанией Borland был пред- ложен собственный уникальный формат объектных файлов — модулей исходной программы — TPU (Turbo Pascal Unit)1. По этой причине модули, созданные в системе программирования Turbo Pascal, не могли быть использованы в дру- гих системах программирования. Также из них невозможно было создавать биб- лиотеки, ориентированные на другие языки и системы программирования. Об- ратная задача — использование стандартных объектных файлов и библиотек в системе программирования Turbo Pascal — была решаема, но имела серьезные ограничения. В состав системы программирования Turbo Pascal, кроме компи- лятора с языка Borland Pascal, входил также компилятор с языка ассемблера (а с появлением возможности разработки результирующих программ для среды Microsoft Windows — компилятор ресурсов). Среда программирования позволя- ла компоновать как единые исполняемые файлы, так и оверлейные программы для ОС типа MS-DOS. Первоначально система программирования Turbo Pascal строилась на основе библиотеки RTL (run time library) языка Borland Pascal. Эта библиотека не пре- доставляла пользователю широкого набора функций — в основном она толь- ко реализовывала базовые математические функции и функции языка. Однако можно сказать об одной характерной черте данной библиотеки — она включала в свой состав объектный код менеджера памяти для управления распределени- ем динамической памяти («кучей» — heap — в терминах языка Pascal), который автоматически подключался к каждой результирующей программе, созданной с помощью данной системы программирования. Этот модуль получился доволь- 1 Следует отметить также тот недостаток, что формат объектного модуля менялся от одной реализации системы программирования Turbo Pascal к другой. По этой причине объект- ные модули от двух разных версий системы программирования Turbo Pascal оказыва- лись несовместимы друг с другом.
674 Глава 15. Современные системы программирования но удачным и нашел свое дальнейшее применение в других системах программи- рования данной компании-разработчика. Несмотря на недостатки, система программирования Turbo Pascal получила ши- рокое распространение и завоевала свое место на рынке. Основной причиной явилось то, что система впервые была построена в виде интегрированной среды. Данный факт предопределил ее широкое распространение, и, прежде всего, в уни- верситетской среде, где требовались простые и понятные в использовании сред- ства разработки. Первые версии системы программирования были ориентированы только на ра- боту в ОС MS-DOS персональных компьютеров на базе процессоров типа Intel 80x86. На исполнение в среде этой ОС были ориентированы и результирующие программы, разрабатываемые с помощью данной среды программирования. Система программирования Turbo Pascal получила широкое распространение и дальнейшее развитие. Компания Borland выпустила несколько ее реализаций (наиболее распространенные из них — версии 5.5 и 7.0). Последние реализации данной системы программирования могли создавать результирующие программы, ориентированные на работу как в ОС типа MS-DOS, так и в среде типа Microsoft Windows. В них были реализованы все основные преимущества, предоставляе- мые интегрированной средой программирования, такие как лексический анализ программ «на лету» и встроенная контекстная подсказка. По мере распространения системы программирования Turbo Pascal шла разра- ботка библиотек подпрограмм и функций для нее. Были созданы такие библио- теки, как Turbo Professional (TP), Turbo Vision, Object Window Library (OWL) для среды MS-DOS и ObjectWindows для среды Microsoft Windows. Широкому распространению данных библиотек по-прежнему мешал тот факт, что в системе программирования Turbo Pascal используется уникальный, нестандартный фор- мат объектных файлов. Отсутствие стандарта языка Borland Pascal во многом сдерживало развитие этой системы программирования и не способствовало ее применению как профессионального средства разработки. Системе программирования Turbo Pascal здесь уделено много внимания по той причине, что это одна из самых распространенных в настоящее время систем программирования учебного назначения. Кроме того, это первая появившаяся на рынке система программирования, которая полностью реализовала в себе идеи интегрированной среды программирования. Эти идеи, заложенные в системе про- граммирования Turbo Pascal, нашли применение во многих современных систе- мах программирования. По системе программирования Turbo Pascal выпущена огромная масса литературы — можно обратиться, например, к книгам [34, 71, 72]. Borland Delphi Система программирования Borland Delphi явилась логическим продолжением и дальнейшим развитием идей, заложенных компанией-разработчиком еще в сис- теме программирования Turbo Pascal. В качестве основных в новой системе программирования можно указать следую- щие принципиальные изменения:
Примеры современных систем программирования 675 □ новый язык программирования — Object Pascal, явившийся серьезной пере- работкой прежней версии языка Borland Pascal; □ компонентная модель среды разработки, в первую очередь ориентированная на технологию разработки RAD (rapid application development). Язык программирования Object Pascal создавался в то время, когда на рынке средств разработки уже существовало значительное количество объектно-ориен- тированных языков, включая такие известные, как C++ и Java. Компания Borland попыталась учесть все недостатки существующих языков объектно-ориентиро- ванного программирования, а также свой опыт создания языка Borland Pascal. По мнению автора, во многом ей это удалось. Новый язык вышел довольно удач- ным как с точки зрения синтаксиса, так и с точки зрения предоставляемых воз- можностей. Этот язык поддерживает практически все основные механизмы объ- ектно-ориентированного программирования. Компонентная модель среды разработки предусматривает создание основной части программы в виде набора взаимосвязанных компонентов — классов объ- ектно-ориентированного языка. Во время разработки исходной программы (design time) компоненты предстают в виде графических образов и обозначений, связан- ных между собой. Каждый компонент обладает определенным набором свойств (properties), событий (events) и методов. Каждому из них соответствует свой фрагмент исходного кода программы, отвечающий за обработку метода или ре- акции на какое-то событие. Разработчик может располагать на экране и свя- зывать между собой компоненты, а также редактировать связанный с ними ис- ходный код программы. Причем поведение компонентов во время выполнения программы (run time) полностью определяется их взаимосвязью, исходным ко- дом программы и объектным кодом самого компонента. Система программирования Borland Delphi предназначена для создания резуль- тирующих программ, выполняющихся в среде ОС Windows различных типов. Основу системы программирования Borland Delphi и ее компонентной модели составляет библиотека VCL (visual component library). В этой библиотеке реали- зованы в виде компонентов все основные органы управления и интерфейса ОС. Также в ее состав входят классы, обеспечивающие разработку приложений для архитектуры «клиент—сервер» и трехуровневой архитектуры (в современных реа- лизациях Borland Delphi). Разработчик имеет возможность не только использо- вать любые компоненты, входящие в состав библиотеки VCL, но также и раз- рабатывать свои собственные компоненты, основанные на любом из классов данной библиотеки. Эти новые компоненты становятся частью системы про- граммирования и затем могут быть использованы другими разработчиками. Для поддержки разработки результирующих программ для архитектуры «кли- ент-сервер» в состав Borland Delphi входит средство BDE (Borland database engine). Оно обеспечивает результирующим программам возможность доступа к широ- кому диапазону серверов БД посредством классов библиотеки VCL. Посредст- вом BDE результирующая программа может взаимодействовать с серверами БД типа Microsoft SQL Server, Interbase, Sybase, Oracle и т. п. Система програм- мирования Borland Delphi поддерживает также создание результирующих про- грамм, выполняющихся в архитектуре «клиент—сервер», на базе других техно- логий, например ADO (ActiveX Data Objects).
676 Глава 15. Современные системы программирования Система программирования Borland Delphi выдержала несколько реализаций. Последние реализации данной системы программирования (прежде всего, вер- сии 4 и 5) включают широкий набор средств для поддержки разработки ре- зультирующих программ в трехуровневой архитектуре приложений. Система программирования Borland Delphi позволяет разрабатывать как серверную, так и клиентскую часть приложения в данной архитектуре. Возможно использова- ние как технологий COM/DCOM (наиболее распространенных в среде ОС типа Microsoft Windows), так и технологии CORBA (но только при разработке кли- ентской части приложения). В качестве недостатков данной системы программирования можно указать ис- пользование нестандартного формата объектных файлов (сохранился еще от сис- темы Turbo Pascal, но в последней версии Borland Delphi 5 можно использовать стандартный формат), а также нестандартного формата для хранения ресурсов пользовательского интерфейса. Кроме того, сам язык Object Pascal не является признанным стандартом. Этот факт несколько затрудняет использование Bor- land Delphi в масштабных проектах в качестве основного средства разработки. Тем не менее система программирования Borland Delphi получила широкое рас- пространение среди разработчиков в Российской Федерации. Более подробную информацию по данной системе программирования можно найти в технической литературе (например, [35]), либо на сайте компании-производителя [98]. Borland C++ Builder Система программирования Borland C++ Builder объединила в себе идеи интег- рированной среды разработки, реализованные компанией в системах программи- рования Turbo Pascal и Borland Delphi с возможностями языка программирова- ния C++. История этой системы программирования начинается с интегрированной среды разработки Borland Turbo С. Среда Turbo С представляла собой реализацию идей, заложенных компанией- разработчиком в системе программирования Turbo Pascal для языка программи- рования С. Компания Borland стремилась перенести удачную реализацию идей интегрированной среды разработки на новую основу. Компилятор Turbo С не был однопроходным, и потому время компиляции исходной программы превы- шало время компиляции аналогичной программы в Turbo Pascal. Кроме того, в системе программирования использовался стандартный компоновщик испол- няемых файлов MS-DOS. Преимущество Turbo С заключалось в том, что эта система программирования строилась на базе стандартного языка программирования С. Данный язык полу- чил широкое распространение среди разработчиков в качестве языка системно- го программирования, для него существовали компиляторы под многие типы целевых архитектур. В этом было главное отличие системы программирования Turbo С от схожей по организации системы программирования Turbo Pascal, ко- торая строилась на основе поддержки нестандартного расширения языка Pascal. С развитием системы программирования на базе Turbo Pascal развивались и сис- темы программирования на основе Turbo С.
Примеры современных систем программирования 677 Современная реализация Borland C++ Builder ориентирована на разработку результирующих программ, выполняющихся под управлением ОС Microsoft Windows всех типов. Сама система программирования Borland C++ Builder, как и Borland Delphi, также функционирует под управлением ОС типа Microsoft Windows. Он полностью поддерживает стандарт языка С, что делает возможным создание с помощью данной системы программирования модулей и библиотек, используемых в других средствах разработки (чего очень сложно достигнуть с помощью Borland Delphi). По возможностям, внешнему виду и технологиям система программирования Borland C++ Builder схожа с системой программирования Borland Delphi. В ее основу положены те же основные идеи и технологии. Структура классов языка C++ в системе программирования Borland C++ Builder построена в той же биб- лиотеке VCL (visual control library), в которой строится структура классов Object Pascal в системе программирования Borland Delphi. Правда, разработчик, создаю- щий программы на C++, может не пользоваться классами VCL и взять за основу любую другую библиотеку, чего нельзя сказать о разработчике, использующем Object Pascal — набор доступных библиотек для последнего языка сильно огра- ничен. Успешное распространение систем программирования Turbo Pascal и Borland Delphi способствовало и внедрению на рынок системы программирования Bor- land C++ Builder от той же компании-разработчика. Эта система программиро- вания занимает прочную позицию на рынке средств разработки для языка C++, где существует довольно жесткая конкуренция. Более подробную информацию по данной системе программирования можно найти в технической литературе ([80]), либо на сайте компании-производителя [98]. Системы программирования фирмы Microsoft Компания Microsoft в настоящее время как производитель операционных сис- тем и программного обеспечения доминирует на рынке персональных компью- теров, построенных на базе процессоров типа Intel 80x86. Прежде всего, это от- носится ко всем вариантам ОС типа Microsoft Windows. Этот факт явился одним из главных факторов, которые обусловили прочную по- зицию данной компании на рынке средств разработки программных продуктов для ОС типа Microsoft Windows. Все виды ОС типа Microsoft Windows созда- вались как закрытые системы. Поэтому безусловное знание компанией-разра- ботчиком структуры и внутреннего устройства «своей» ОС зачастую являлось определяющим в ситуации, когда надо было создать средство разработки прило- жений для данной ОС. Хорошие финансовые ресурсы и положение компании на рынке позволили ей создать довольно удачные системы программирования, не- смотря на то, что она начала их разработку довольно поздно и не являлась «зако- нодателем мод» в данной области. Microsoft Visual Basic Это средство разработки прошло долгую историю под руководством компании Microsoft. История языка Basic на персональных компьютерах началась с прими-
678 Глава 15. Современные системы программирования тивных интерпретаторов данного языка. Сам по себе язык Basic позволял легко организовать интерпретацию исходного кода программ, а его синтаксис и семан- тика достаточно просты для понимания даже непрофессиональными разработ- чиками. Система программирования Microsoft Visual Basic также первоначально была ориентирована на интерпретацию исходного кода. Однако требования и условия на рынке средств разработки толкнули компанию-производителя на создание компилятора, вошедшего в состав данной системы программирования. При этом основные функции библиотеки языка были вынесены в отдельную, динамически подключаемую библиотеку VBRun, которая должна присутствовать в ОС для выполнения результирующих программ, созданных с помощью данной системы программирования. Различные версии системы программирования Microsoft Vi- sual Basic ориентированы на различные версии данной библиотеки. Интерпрета- тор языка был сохранен и внедрен компанией-разработчиком в состав модулей другого программного продукта — Microsoft Office. Развитие системы программирования Visual Basic потребовало существенного изменения синтаксиса и семантики самого языка. С точки зрения автора, это ре- шение нельзя признать технически удачным, так как изначально простой и дос- таточно примитивный язык исходного текста (не ориентированный на создание объектно-ориентированных программ) был изменен так, чтобы иметь возмож- ность решать несвойственные ему задачи. Однако этот ход компании был про- диктован скорее маркетинговой политикой, чем техническими требованиями. При всем множестве привнесенных в язык новшеств компании удалось сохра- нить присущую ему простоту и наглядность всей системы программирования в целом. Последняя версия данной системы программирования — Microsoft Visual Ba- sic 6.0 — является одним из эффективных средств для создания результирующих программ, ориентированных на выполнение под управлением ОС типа Microsoft Windows. Эта система программирования ориентирована на технологию разра- ботки RAD. Microsoft Visual Basic 6.0 содержит интегрированные средства визу- альной работы с базами данных, поддерживающие проектирование и доступ к базам данных SQL Server, Oracle и т. п. К этим средствам относятся Visual Data- base Tools, ADO/OLE DB, Data Environment Designer, Report Designer и ряд других. В данной системе программирования поддерживается также создание серверных Web-приложений, работающих с любым средством просмотра на базе новых Web- классов. В новой версии обеспечивается и отладка приложений для сервера IIS (Internet information server) производства компании Microsoft. В Microsoft Visu- al Basic 6.0 возможно создание интерактивных Web-страниц. Microsoft Visual Basic 6.0 обеспечивает простое создание приложений, ориенти- рованных на данные. Visual Basic 6.0 позволяет создавать результирующие про- граммы, выполняемые в архитектуре «клиент—сервер», которые могут работать с любыми базами данных. Система программирования Microsoft Visual Basic ориентирована, прежде всего, на создание клиентской части приложений. Теперь Visual Basic 6.0 поддерживает универсальный интерфейс доступа к дан- ным Microsoft при помощи технологии ADO. Visual Basic 6.0 обеспечивает про-
Примеры современных систем программирования 679 смотр таблиц, изменение данных, создание запросов SQL из среды разработки для любой совместимой с ODBC или OLE DB базы данных. Так же как и в ре- дакторе Visual Basic, синтаксис SQL выделяется цветом и незамедлительно про- веряется на наличие ошибок. Это делает код SQL легче читаемым и менее под- верженным случайным ошибкам. Новая версия продукта поддерживает коллективную разработку, масштабируе- мость, создание компонентов промежуточного слоя, пригодных к многократно- му использованию в любом COM-совместимом продукте. Поддержка широкого спектра интерфейсов доступа к данным дает возможность применять эту сис- тему программирования для разработки клиентской части приложений, выпол- няющихся в трехуровневой архитектуре. Среда разработки обладает множеством новых возможностей, таких как выде- ление синтаксиса и автоматическое завершение ключевых слов. Система про- граммирования Microsoft Visual Basic интегрируется с семейством программных продуктов Microsoft BackOffice, которое обеспечивает среду для выполнения и создания сложных приложений масштаба предприятия для работы в локальных сетях или в Интернете. Использование новых интегрированных визуальных средств работы с данными облегчает выполнение рутинных задач по обеспече- нию доступа к ним; эти средства доступны прямо из среды разработки Visual Basic. Система программирования Visual Basic неплохо сочетает в себе простоту и эф- фективность разработки. Все недостатки, присущие данной системе, в большин- стве своем проистекают из недостатков используемого исходного языка про- граммирования. Средства языка Basic даже после значительной модификации ограничивают возможности его применения в современных архитектурах взаи- модействия приложений, которые в значительной мере основаны на объектно- ориентированном подходе. Кроме того, язык программирования в системе Visual Basic не является признанным стандартом, а потому возникают трудности по ис- пользованию созданных на его основе модулей и компонентов в других средст- вах разработки. Информацию о системе программирования Visual Basic можно найти в много- численной технической литературе (например, [3, 63]), либо же на сайте пред- ставительства компании [105]. Microsoft Visual C++ Система программирования Microsoft Visual C++ представляет собой реализа- цию среды разработки для распространенного языка системного программиро- вания C++, выполненную компанией Microsoft. Эта система программирования в настоящее время построена в виде интегрированной среды разработки, вклю- чающей в себя все необходимые средства для разработки результирующих про- грамм, ориентированных на выполнение под управлением ОС типа Microsoft Windows различных версий. Основу системы программирования Microsoft Visual C++ составляет библиотека классов MFC (Microsoft foundation classes). В этой библиотеке реализованы в виде классов C++ все основные органы управления и интерфейса ОС. Также
680 Глава 15. Современные системы программирования в ее состав входят классы, обеспечивающие разработку приложений для архи- тектуры «клиент—сервер» и трехуровневой архитектуры (в современных версиях библиотеки). Система программирования Microsoft Visual C++ позволяет разра- батывать любые приложения, выполняющиеся в среде ОС типа Microsoft Windows, в том числе серверные или клиентские результирующие программы, осуществ- ляющие взаимодействие между собой по одной из указанных выше архитектур. Классы библиотеки MFC ориентированы на использование технологий СОМ/ DCOM, а также построенной на их основе технологии ActiveX для организации взаимодействия между клиентской и серверной частью разрабатываемых прило- жений. На основе классов библиотеки пользователь может создавать свои собст- венные классы в языке C++, организовывать свои структуры данных. В отличие от систем программирования компании Borland, система програм- мирования Microsoft Visual C++ ориентирована на использование стандартных средств хранения и обработки ресурсов интерфейса пользователя в ОС Windows. Это не удивительно, поскольку все версии ОС типа Windows разрабатываются самой компанией Microsoft. Microsoft Visual C++ обеспечивает все необходимые средства для создания профессиональных Windows-приложений. От версии к версии продукт становится проще в использовании, расширяются возможности применения, повышается производительность. Система программирования Microsoft Visual C++ выдержала несколько реали- заций. В процессе выхода новых версий системы программирования было вы- пущено и несколько версий библиотеки MFC, на которой основана данная сис- тема. Сама по себе библиотека MFC является, по мнению автора, довольно удачной реализацией широкого набора классов языка C++, ориентированного на разра- ботку результирующих программ, выполняющихся под управлением ОС типа Microsoft Windows. Это во многом обусловлено тем, что создатель библиотеки — компания Microsoft — одновременно является и создателем ОС типа Microsoft Windows, на которые ориентирован объектный код библиотеки. Библиотека мо- жет быть подключена к результирующей программе с помощью обычного ком- поновщика либо использоваться как динамическая библиотека, подключаемая к программе во время ее выполнения. Библиотека MFC достаточно широко рас- пространена. Ее возможно использовать не только в составе систем программи- рования производства компании Microsoft, но и в системах программирования других производителей. В комплект поставки данной системы программирования входят также новый мастер для поэтапного создания приложений, интегрированный отладчик созда- ваемых приложений, профилировщик исходного кода, галерея классов и объек- тов, а также средство для создания дистрибутивов — InstallShield. Visual C++ 6.0 полностью интегрируется с Visual Studio 6.0 и другими средства- ми разработки, входящими в состав данного пакета. Эта интеграция, в частности, обеспечивает мгновенный доступ к MSDN (Microsoft developer network) library, содержащей документацию, примеры кода, статьи и другую информацию для разработчиков.
Примеры современных систем программирования 681 Система программирования Visual C++ 6.0 широко известна. Информацию о ней можно найти в многочисленной технической литературе, а также на сайте пред- ставительства компании [104]. Концепция .NET Концепция .NET (произносится как «dot net» — «дот нет») — это не система программирования, а новейшая технология, предложенная фирмой Microsoft с целью унификации процесса разработки программного обеспечения с помощью различных систем программирования. Концепция .NET разработана компанией совсем недавно, и в настоящее время (2000-2001 годы) происходит ее постепен- ное внедрение на рынок средств разработки. Концепция Microsoft .NET — это целостный взгляд компании Microsoft на но- вую эпоху в развитии Интернета. В рамках этой концепции самые разнообраз- ные программные приложения предоставляются пользователям и разработчикам как сервисы, которые взаимодействуют между собой в соответствии с конкрет- ными потребностями бизнеса, доступны на самых разных устройствах, имеют понятный и полностью адаптирующийся к потребностям каждого пользователя интерфейс. Концепция Microsoft .NET — закономерный этап в развитии информационных технологий, приложений и сервисов, позволяющий разработчикам воспользо- ваться преимуществами сочетания открытых стандартов и архитектуры новой ОС производства компании Microsoft — Microsoft Windows 2000. Большинство операционных систем, существовавших до настоящего времени, были неспособны предоставить необходимую корпоративным приложениям сре- ду исполнения. Для них приходилось создавать специальные серверы приложе- ний. Новая ОС производства компании Microsoft — Microsoft Windows 2000 — непосредственно предоставляет многие функций сервера приложений на уров- не системных сервисов. Поэтому в концепции .NET управление приложениями концентрируется на более специфических для корпоративной среды и Интер- нет-служб задачах — управлении множеством объектов, приложений и серверов. Их решает сервер масштабирования приложений. Концепция .NET содержит много новых идей и предложений, выдвинутых и реализуемых компанией Microsoft. С точки зрения систем программирования основные идеи архитектуры .NET заключаются в том, что в ОС типа Windows 2000 организуется специальная виртуальная машина, исполняющая (интерпре- тирующая) команды некоторого промежуточного низкоуровневого языка. Любая программа, исполняемая в .NET, представляет собой набор команд данного про- межуточного языка. При этом сами команды этого языка интерпретируются не- зависимо от архитектуры вычислительной системы и версии ОС, где они испол- няются. Таким образом, любая система программирования, ориентированная на данную концепцию, сможет создавать код результирующей программы не в виде после- довательности машинных команд, специфичных для архитектуры той или иной ОС, а в виде промежуточного низкоуровневого кода. Этот код будет интерпрети- роваться единообразно и независимо от архитектуры целевой вычислительной
682 Глава 15. Современные системы программирования системы. Кроме того, любая результирующая программа, исполняемая в .NET, сможет воспользоваться сервисными функциями другой программы, доступной в .NET, вне зависимости от того, с помощью какой системы программирования были разработаны обе эти программы. Это значительно расширяет возможности программ, исполняемых в рамках .NET, особенно с учетом возможности удален- ного (сетевого) доступа ко многим предоставляемым сервисам. В первую очередь на поддержку концепции .NET будут, безусловно, ориентиро- ваны системы программирования производства компании Microsoft. Это кос- нется рассмотренных выше систем программирования Microsoft Visual C++ и Microsoft Visual Basic. Кроме того, компания предлагает новую систему програм- мирования, построенную на базе нового языка C# (произносится «Си-шарп»), которая специально ориентирована на поддержку концепции .NET. При успеш- ном развитии концепции и ее внедрении на рынок ОС и средств разработки на нее будут ориентироваться и системы программирования других разработчи- ков. Поддержка концепции .NET в системах программирования не отменяет в них поддержку традиционного пути создания программного обеспечения. Возмож- ность создания результирующих программ, исполняющихся под управлением .NET, идет в дополнение к возможности создания «традиционных» результи- рующих программ, построенных из машинных команд, ориентированных на оп- ределенную архитектуру целевой вычислительной системы. Однако далеко не все модули существующих в настоящий момент библиотек могут быть использо- ваны в рамках данной концепции. Это в равной степени касается и распростра- ненных библиотек MFC и VBRun, используемых в системах программирования от Microsoft. Концепция .NET ориентирована исключительно на новые ОС типа Windows 2000 производства компании Microsoft (а также на все последующие версии дан- ного типа ОС). Она не переносима на ОС других типов, а также на архитектуру, не совместимую с традиционной архитектурой персональных компьютеров, по- строенных на базе процессоров Intel 80x86. Этот факт ограничивает ее широкое распространение, поскольку она поддерживается пока только одной компанией- производителем, хотя и доминирующей на рынке ОС для персональных компь- ютеров. Концепция .NET еще достаточно нова и не нашла пока широкого отражения в технической литературе. Со временем эта ситуация, конечно, изменится, а пока для более подробной информации рекомендуется обратиться на сайт представи- тельства компании Microsoft [106]. Системы программирования под ОС Linux и UNIX Системы программирования в составе ОС типа UNIX Вся история ОС UNIX тесно связана с историей языка программирования С. Фактически эти два программных продукта не смогли бы существовать друг без друга.
Примеры современных систем программирования 683 Язык программирования С появился как базовый язык программирования в ОС типа UNIX. Более того, сама операционная система представляла (и представля- ет) собой набор файлов, написанных на исходном языке программирования С. То есть практически любая ОС типа UNIX поставляется в виде исходного текста на языке С (кроме незначительной части ядра ОС, ориентированной на особен- ности архитектуры вычислительной системы, где она выполняется). Всякий раз при изменении основных параметров ОС происходит компиляция и компоновка ядра ОС заново, а при перезапуске ОС вновь созданное ядро активируется. Этот принцип характерен для всех ОС типа UNIX. Таким образом, ни одна ОС типа UNIX фактически не может существовать без наличия в ее составе компилятора и компоновщика для языка программирова- ния С (поскольку речь идет о ядре ОС, то роль загрузчика несколько отличается от его роли в обычной системе программирования). Соответственно, все произ- водители ОС типа UNIX включают в ее состав и системы программирования языка С. По указанным причинам этот язык программирования стал основным для этого типа ОС (хотя под ОС типа UNIX существуют системы программиро- вания с других языков — например, Lisp, FORTRAN, Pascal, — они используются не так часто, как С). В ОС типа UNIX сложились основные особенности и характеристики второго этапа в развитии систем программирования. Именно здесь системы программи- рования стали представлять собой комплекс из библиотек языка, компилятора и компоновщика, выполняемых под управлением специальных командных фай- лов. Функции загрузчика в данном случае большей частью выполняются са- мой ОС. В ОС типа UNIX сложился, стал применяться и был стандартизован командный язык компиляции Makefile. Кроме того, именно в ОС этого типа были разработаны упомянутые в данном учебном пособии методы автоматизации раз- работки компиляторов, основанные на использовании программ LEX и YACC [И, 31, 39, 67, 68, 90]. К тому же практически все ОС типа UNIX обладают мощ- ными командными процессорами, выполняющими команды пользователя в ОС. Возможности этих командных процессоров, по сути, соответствуют возможно- стям интерпретаторов языков программирования. Принцип, по которому распространяется и устанавливается любая ОС типа UNIX, стал применяться и к прикладным программам, разрабатываемым для исполне- ния под управлением данного типа ОС. Обычно прикладная программа, ориен- тированная на выполнение под ОС типа UNIX, поставляется в виде набора исходных кодов (чаще всего на языке программирования С). При установке при- кладной программы на конкретную ОС такого типа она автоматически компили- руется и компонуется, после чего готова к выполнению. Такой метод распро- странения прикладных программ в виде исходных кодов значительно снижает их зависимость от архитектуры конкретной вычислительной системы1. Однако 1 Распространение прикладных программ, разработанных для ОС типа UNIX, в виде ис- ходного кода не отменяет авторского права и права собственности на код программы. Пользователь программы, даже получив исходный код, не имеет права изменять его и распространять без санкций разработчика. Совсем иное дело — распространение исход- ного кода программ в рамках проекта GNU.
684 Глава 15. Современные системы программирования он требует обязательного наличия соответствующей системы программирования в составе ОС (вот почему в качестве языка разработки прикладных программ под ОС типа UNIX чаще всего выступает именно С). Системы программирования в ОС типа UNIX долгое время не выходили за рам- ки второго этапа развития систем программирования. Они продолжают оста- ваться такими и по сей день в той части, которая отвечает за распространение исходного кода самой ОС и прикладных программ, разработанных для нее. Так происходит, поскольку в этом случае система программирования не требует на- личия интегрированной среды и вполне может быть ограничена командными файлами. Однако развитие графического интерфейса пользователя оказало свое влияние и на системы программирования под ОС данного типа. Системы программирования, построенные на базе интегрированных сред разра- ботки, стали появляться и под ОС типа UNIX. Характерная черта их заключа- лась в том, что практически все они строились именно в графической среде на основе стандартного графического интерфейса. Так произошло, потому что в ОС типа UNIX (в отличие от ОС, ориентированных на персональные компьютеры) практически сразу в качестве стандарта «де-факто» установился стандарт графи- ческого интерфейса пользователя на основе среды X Window [31, 67, 68]. Это по- зволило унифицировать библиотеки систем программирования (все они строятся на базе библиотеки Xlib), компиляторы и компоновщики ресурсов пользователь- ского интерфейса. Широкие возможности командных процессоров в ОС типа UNIX облегчают построение интегрированной среды в системах программирова- ния. Поэтому сейчас существует широкий выбор таких сред от многих произво- дителей (при наличии хотя бы небольшого опыта работы в ОС разработчик име- ет возможность сам построить интегрированную среду программирования под управлением ОС). Описания базовых систем программирования под ОС типа UNIX можно найти в любой технической литературе по этим ОС, которая имеется в настоящее время в достаточном количестве [11, 31, 34, 67, 68]. Системы программирования проекта GNU Проект GNU был начат в 1984 году. Само название «GNU» является рекурсивной аббревиатурой «GNU's Not UNIX» — «GNU не UNIX». Манифест проекта GNU [102] был написан Ричардом Столлменом (Richard Stallman) в начале работы над проектом GNU и адресован желающим помочь или принять участие в проек- те. В течение нескольких первых лет работы над проектом он был незначительно обновлен, чтобы отразить ход работ. Идея проекта GNU заключается в свободном распространении всех программ, созданных в рамках этого проекта. Программы распространяются в виде исход- ного кода, причем пользователь не только получает этот код в пользование, но и имеет право вносить в него изменения, а также распространять исходный или модифицированный код всем желающим участникам проекта. Во многом своему существованию и развитию проект обязан наличию по всему миру большого ко- личества профессионалов-энтузиастов, не возражающих против свободного рас- пространения и использования созданных ими программ. Более точные форму-
Примеры современных систем программирования 685 лировки сути проекта и условий участия в нем можно найти на сайте [100] или [102] (в русском переводе). GNU — это название полной UNIX-совместимой программной системы, которая разрабатывается и безвозмездно предоставляется всем желающим ее использо- вать в рамках проекта. Система GNU способна исполнять UNIX-программы, но не идентична ОС типа UNIX. Улучшения основаны на имеющемся опыте рабо- ты с другими операционными системами. В качестве языка системного програм- мирования в системе доступны как С, так и Lisp. Кроме этого, поддерживаются коммуникационные протоколы UUCP, MIT Chaosnet и сети Интернет. Безусловно, по самой своей сути проект предполагает наличие в своем составе систем программирования. В противном случае распространение исходных ко- дов программ в рамках проекта будет просто бессмысленным. Очевидно, базовой системой программирования в проекте GNU стала система программирования на языке С. Хотя GNU и не UNIX, но проект перенял многие положительные черты ОС этого типа, в том числе — создание и распространение ядра ОС на основе языка С. Другим фактором, обусловившим создание системы програм- мирования языка С в рамках проекта GNU, стало то, что большинство профес- сионалов-энтузиастов, основавших проект, использовали именно этот язык про- граммирования. Со временем система программирования GNU С преобразовалась в более разви- тую систему программирования GNU C++, поскольку именно язык C++ полу- чил широкое распространение в среде профессионалов. Затем в рамках проекта стали доступны и системы программирования на основе многих других языков программирования, в частности известного языка Pascal. По внешнему виду, интерфейсу и функциональным возможностям системы про- граммирования, созданные в рамках проекта GNU, мало чем отличаются от сис- тем программирования, ориентированных на ОС типа UNIX. Главным отличием являются условия, определяющие правила распространения и использования данных систем программирования. Особенности проекта обусловили и тот факт, что большинство созданных в нем систем программирования не ориентировано на интегрированные среды разработки, а функционируют в рамках командных процессоров. И хотя возможности ОС, созданной в проекте GNU, позволяют разработчику, использующему одну из систем программирования, самому соз- дать интегрированную среду разработки (при наличии у него соответствующих профессиональных навыков), эта особенность не является положительной чер- той проекта. Поэтому в последнее время и в рамках проекта GNU стали появляться системы программирования, построенные на основе интегрированной среды разработки. Кроме того, известные компании-производители средств разработки стали обра- щать внимание на данный проект по причине его широкого распространения. Они стали предлагать свои системы программирования, ориентированные на выполнение в ОС, созданной в рамках проекта GNU. Об одной из такой систем программирования (Borland Kylix) несколько слов сказано ниже. Операционная система, созданная и поддерживаемая сообществом разработчи- ков в рамках проекта GNU, получила название Linux. В ходе работы над проек-
686 Глава 15. Современные системы программирования том было создано большое количество программного обеспечения, в том числе и систем программирования (кроме указанной уже системы GNU C++). Даже краткий обзор их возможностей здесь не представляется возможным. Информа- цию о них можно найти в любой технической литературе, посвященной ОС Linux и проекту GNU, либо в сети Интернет [67, 68, 100]. Положительной чертой проекта можно считать так же тот факт, что многие сис- темы программирования, созданные в рамках проекта, оказались совместимыми и с ОС типа UNIX, которые формально не входят в проект GNU. Та же система программирования GNU C++, созданная в рамках проекта GNU, может с успе- хом использоваться (и используется) под многими версиями ОС типа UNIX. Проект Borland Kylix Проект Borland Kylix представляет собой попытку компании Borland перенести на ОС типа Linux опыт создания систем программирования, который компания накопила за долгое время работы в этой сфере для ОС типа Microsoft Windows. В рамках проекта Borland Kylix компания Borland создала и распространяет на рынке программного обеспечения одноименную систему программирования, основанную на языке программирования Object Pascal. Данный язык известен в среде разработчиков для системы программирования Borland Delphi, ориенти- рованной на ОС типа Windows. Компания Borland реализовывает проект Bor- land Kylix таким образом, чтобы перенести в созданную систему программиро- вания под ОС Linux все черты, присущие системе программирования Borland Delphi, уже существующей под ОС типа Microsoft Windows (эта система про- граммирования была рассмотрена выше в данной главе). Основой проекта Borland Kylix стала библиотека компонентов CLX (произно- сится «клик»), в которой компания Borland реализовала все основные органы управления пользовательского интерфейса и средства обработки данных, ориен- тированные на ОС Linux. Библиотека построена в виде компонентов на основе иерархии классов языка Object Pascal. Надо сказать, что данная библиотека от- лична от библиотеки VCL, которая в настоящее время используется в системе программирования Borland Delphi и построена на основе того же языка. Перене- сти классы библиотеки VCL в новый проект системы программирования под ОС Linux оказалось для компании Borland принципиально невозможным. Следующим шагом в проекте Borland Kylix компания предполагает создание в его рамках системы программирования на основе языка C++. Эта система про- граммирования должна быть построена на основе той же библиотеки CLX, что и система программирования на основе Object Pascal (примерно так же, как Borland Delphi и Borland C++ Builder построены на основе одной и той же биб- лиотеки VCL). Дальнейшим развитием проекта Borland Kylix компания Borland видит внедре- ние библиотеки CLX в существующие системы программирования Borland Delphi и Borland C++ Builder, ориентированные на ОС типа Windows. В случае успеш- ного развития проекта использование одной из указанных систем программи- рования компании Borland существенно облегчит перенос приложений между двумя принципиально различными типами ОС. В рамках этого проекта разра-
Примеры современных систем программирования 687 ботчик, пользуясь только средствами библиотеки CLX, сможет создавать исход- ный код на языке Object Pascal или C++ таким образом, что результирующие программы, построенные на основе этого кода с помощью Borland Kylix, будут выполняться в ОС типа Linux, а построенные с помощью Borland Delphi или Borland C++ Builder — в ОС типа Microsoft Windows. В случае успеха проект Borland Kylix может быть распространен и на различные ОС типа UNIX. Этот проект был выбран в качестве примера потому, что системы программи- рования производства компании Borland достаточно широко распространены на рынке России. Внимание, уделяемое компанией Borland ОС типа Linux, говорит о широком распространении проекта GNU в среде разработчиков. Проект Borland Kylix только начал развиваться. Он еще не нашел широкого от- ражения в технической литературе, поэтому подробности лучше получить на сайте компании [98]. Разработка программного обеспечения для сети Интернет Интернет — это всемирная «сеть сетей». Он объединяет в себя вычислительные системы и локальные сети, построенные на базе различных аппаратно-программ- ных архитектур. При осуществлении взаимодействия по сети двух компьютеров один из них выступает в качестве источника данных (Интернет-сервера), а дру- гой — приемника данных (Интернет-клиента). Сервер подготавливает данные, а клиент принимает их и каким-то образом обрабатывает. Нередко в качестве данных выступают тексты программ, которые подготавливает сервер, а испол- нять должен клиент. В таких условиях определяющим становится требование унифицированного исполнения кода программы вне зависимости от архитектуры вычислительной системы. Компиляция и создание объектного кода в условиях всемирной сети становятся бессмысленными, потому что заранее не известно, на какой вычисли- тельной системе потребуется исполнять полученный в результате компиляции код. По этой причине сервер не может передавать по сети объектный код или ко- манды ассемблера — вполне может так случиться, что клиент просто не способен их исполнить. Можно попытаться создать сервер таким образом, чтобы он мог подготавливать программы для всех известных типов клиентов. Однако это, во- первых, значительно увеличит нагрузку на сервер, а во-вторых, не гарантирует от ситуации, когда связь с сервером установит клиент нового, еще не известного серверу типа (поскольку количество возможных архитектур компьютеров непо- стоянно и ничем в принципе не ограничено). Поэтому основной особенностью программирования в сети Интернет является использование в качестве основного средства программирования интерпрети- руемых языков. При интерпретации исполняется не объектный код, а сам исход- ный код программы, и уже непосредственно интерпретатор на стороне клиента отвечает за то, чтобы этот исходный код был исполнен всегда одним и тем же об- разом вне зависимости от архитектуры вычислительной системы. Тогда сервер готовит код программы всегда одним и тем же образом вне зависимости от типа клиента.
688 Глава 15. Современные системы программирования В такой ситуации нагрузка на Интернет-клиента может возрасти. Но задачу мож- но несколько упростить: Интернет-сервер может готовить не высокоуровневый код исходной программы, а некий унифицированный промежуточный код низ- кого уровня, предназначенный для исполнения на стороне клиента. Тогда в обмене данными участвуют еще две дополнительные программы: компилятор (точнее — транслятор) на стороне сервера, транслирующий исходный код программы на некотором языке высокого уровня в промежуточный низкоуровневый код; и ин- терпретатор на стороне клиента, отвечающий за исполнение промежуточного кода вне зависимости от архитектуры вычислительной системы клиента. Во многих случаях при использовании исполнения программ в глобальной сети применяется именно такая схема. Для реализации такого рода схем существует много технических и языковых средств. Существует также значительное количество языков программирования, ориентированных на использование в глобальной сети [10, 16, 91]. Интернет- программирование — это отдельная и довольно интересная область разработки программ, но в целом вопросы, затрагиваемые в ней, лежат за пределами данного учебного пособия. Далее дается только очень краткий обзор принципов, поло- женных в основу тех или иных языков программирования с глобальной сети. Язык HTML. Программирование статических Web-страниц Язык HTML (hypertext markup language, язык разметки гипертекста) во многом определил развитие и широкое распространение сети Интернет по всему миру. Сам по себе язык достаточно прост, а для овладения им нужны только самые примитивные знания в области программирования. Язык позволяет описывать структурированный текст (гипертекст), содержащий ссылки и взаимосвязи фрагментов; графические элементы (изображения), кото- рые могут быть связаны как с текстовой информацией, так и между собой; а так- же простейшие элементы графического интерфейса пользователя (кнопки, спи- ски, поля редактирования). На основе описания, построенного в текстовом виде на HTML, эти элементы могут располагаться на экране, им могут присваиваться различные атрибуты, определяющие используемые ресурсы интерфейса поль- зователя (такие, как цвет, шрифты, размер и т. п.). В результате получается гра- фический образ — Web-страница (от «web» — «паутина» — слова, входящего в состав аббревиатуры WWW — World Wide Web — Всемирная паутина). Она в принципе может содержать различные мультимедийные элементы, включая графику, видео и анимацию. Широкому распространению HTML послужил принцип, на основе которого этот язык стал использоваться в глобальной сети. Суть его достаточно проста: Интер- нет-сервер создает текст на языке HTML и передает его в виде текстового файла на клиентскую сторону сети по специальному протоколу обмена данными HTTP (hypertext transfer protocol, протокол передачи гипертекста). Клиент, получая исходный текст на языке HTML, интерпретирует его и в соответствии с резуль- татом интерпретации строит соответствующие интерфейсные формы и изобра- жения на экране клиентского компьютера.
Примеры современных систем программирования 689 Грамматика HTML проста (она относится к регулярным грамматикам), а потому не составляет сложности построить соответствующий интерпретатор. Такими интерпретаторами явились программы-навигаторы в сети Интернет (браузеры, browser), которые, по сути, минимально должны были содержать две составляю- щих: клиентскую часть для обмена данными по протоколу HTTP и интерпрета- тор языка HTML. Некоторое время на рынке существовало огромное количество такого рода программ, в настоящее время преобладают две — Internet Explorer (производства компании Microsoft) и Netscape Navigator (производства компа- нии Netscape). Первый из них доминирует на архитектуре персональных компь- ютеров на базе процессоров типа Intel 80x86 под управлением ОС типа Microsoft Windows. Гораздо разнообразнее программное обеспечение серверной части. Это вызвано тем, что в протоколе HTTP нигде строго не специфицирован источник HTML- текста. Им может быть обычный текстовый файл, и тогда клиент будет видеть у себя статическую картинку всякий раз, когда устанавливает соединение с дан- ным сервером. Но может быть и так, что сервер будет порождать новый HTML- текст всякий раз, когда клиент устанавливает с ним соединение, или даже ме- нять текст по мере работы клиента с сервером. Тогда и изображение на стороне клиента, зависящее от интерпретируемого текста HTML, будет динамически из- меняться по мере изменения текста. Последний вариант представляет гораздо больший интерес с точки зрения предоставляемых возможностей. Вопрос только в том, как организовать динамическое изменение HTML-текста. Вот в этом на- правлении и шло развитие основных средств Интернет-программирования. Описание языка HTML можно найти на многих сайтах в глобальной сети, а так- же в многочисленной литературе по Интернет-программированию (например, в [16,91]). Язык HTML прост и тем удобен. Однако отсюда проистекают и основные его недостатки. Во-первых, он не предоставляет средств динамического изменения содержимого интерфейсных форм и изображений, поэтому основной метод — динамическое изменение самого текста HTML. Во-вторых, данный язык не пре- доставляет никаких методов поддержки современных архитектур типа «клиент- сервер» или трехуровневой архитектуры. Он не позволяет обмениваться данны- ми ни с серверами БД, ни с серверами приложений как на стороне сервера, где готовятся тексты HTML, так и на стороне клиента, где эти тексты интерпретиру- ются. Наконец, этот язык имеет очень ограниченные средства для реакции на действия пользователя в интерфейсных формах, созданных с его помощью. Для устранения этих недостатков были предложены различные средства. Неко- торые из них рассмотрены ниже. Программирование динамических Web-страниц Основная идея динамической генерации Web-страниц заключается в том, что большая часть HTML-страницы не хранится в файле на сервере, а порождается непосредственно каждый раз при обращении клиента к серверу. Тогда сервер фор- мирует страницу и сразу же по готовности передает ее клиенту. Таким образом, всякий раз при новом обращении клиент получает новый текст HTML, и не ис-
690 Глава 15. Современные системы программирования ключено, что тексты могут значительно различаться между собой даже при обра- щении к одному и тому же серверу. Вопрос только в том, как обеспечить динамическую генерацию HTML-кода на стороне сервера. Самое очевидное решение заключается в том, чтобы разработать некоторый исполняемый файл (приложение), который будет динамически строить новый HTML-код. Тогда на вход такого исполняемого файла поступают некоторые па- раметры (например, данные, которые пользователь ввел в форме или командной строке), а на выходе он должен порождать HTML-код в виде текста. Для этой цели служат специальные приложения, называемые CGI-приложениями. CGI (common gateway interface, общедоступный шлюзовой интерфейс) — это ин- терфейс для запуска внешних программ на сервере в ответ на действия клиента, установившего соединение с ним через глобальную сеть. Пользуясь этим интер- фейсом, приложения могут получать информацию от удаленного пользователя, анализировать ее, формировать HTML-код и отсылать его клиенту. CGI-прило- жения могут получать данные из заполненной формы, построенной с помощью HTML, либо из командной строки описания URL (universal resource locator, уни- версальный указатель ресурса). Строка URL вводится в программе-навигаторе, осуществляющей доступ к серверу со стороны клиента через глобальную сеть по протоколу HTTP. То, какие CGI-приложения по каким действиям пользователя должны выполняться на сервере, указывается непосредственно в коде HTML- страницы, которую сервер передает клиенту. Кроме интерфейса CGI существуют и другие варианты интерфейсов, позволяю- щие динамически создавать HTML-код путем запуска на сервере приложений в ответ на действия клиента. Например, можно выделить интерфейс ISAPI (In- ternet server application programming interface, интерфейс прикладных программ Интернет-сервера). О'Лтичие ISAPI от CGI заключается в том, что для поддержки CGI создаются отдельные приложения, выполняющиеся в виде самостоятельных программ, a ISAPI поддерживается с помощью библиотек, динамически подклю- чаемых к серверу. ISAPI-библиотеки исполняются непосредственно в адресном пространстве сервера, имеют большие возможности и обеспечивают более высо- кую производительность сервера, в то время как CGI-приложения исполняются в ОС сервера как отдельные процессы и вынуждены определенным образом организовывать обмен данными с самим сервером (что снижает производитель- ность). Но, с другой стороны, ошибка в библиотеке ISAPI может привести к вы- ходу всего сервера из строя и его длительной неработоспособности. В то же вре- мя ошибка в коде CGI-приложения может, в худшем случае, привести только к аварийному завершению выполнения этого приложения, а сам сервер при этом сохранит работоспособность. Тогда в результате ошибки будет неверно отобра- жена только какая-то одна HTML-страница либо часть страницы, а все осталь- ные части сервера будут продолжать исправно работать. Современные системы программирования, в том числе и те из них, что были рас- смотрены в качестве примеров в данном пособии, позволяют создавать приложе- ния и библиотеки, рассчитанные на работу в глобальной сети в соответствии со стандартами CGI или ISAPI. При этом создание исходного кода приложения практически ничем не отличается от создания обычной исполняемой програм-
Примеры современных систем программирования 691 мы, компилятор по-прежнему порождает объектные файлы, но компоновщик со- бирает исполняемый файл или библиотеку с учетом того, что они будут испол- няться в архитектуре сервера глобальной сети. Функции загрузчика выполняет ОС по команде сервера либо сам Интернет-сервер. Этот метод удобен, но имеет один серьезный недостаток: при изменении содер- жимого динамической HTML-страницы или же при изменении логики ее реак- ции на действия Интернет-клиента требуется создать новые код CGI или ISAPI- приложения. А для этого нужно выполнить полностью весь цикл построения ре- зультирующей программы, начиная от изменения исходного кода, включая ком- пиляцию и компоновку. Поскольку содержимое Web-страниц меняется доволь- но часто (в отличие от обычных программ), то такой подход нельзя признать очень эффективным. Кроме того, может потребоваться перенос Интернет-серве- ра с одной архитектуры вычислительной системы на другую, а это также потре- бует перестройки всех используемых CGI-приложений и ISAPI-библиотек. Лучших результатов можно добиться, если не выполнять на сервере уже ском- пилированный и готовый объектный код, а интерпретировать код программы, написанной на некотором языке. При интерпретации исходного кода сервер, конечно, будет иметь производительность ниже, чем при исполнении готового объектного кода, но чаще всего этим можно пренебречь, поскольку производи- тельность серверов Интернета ограничивает чаще всего не мощность вычис- лительной системы, а пропускная способность канала обмена данными. Тогда зависимость кода сервера от архитектуры вычислительной системы будет мини- мальной, а изменить содержимое порождаемой HTML-страницы можно будет сразу же, как только будет изменен порождающий ее исходный код (без допол- нительной перекомпиляции). Существует несколько языков и соответствующих им интерпретаторов, которые нашли применение в этой области и успешно служат цели порождения HTML- страниц. Среди них можно назвать язык Perl, лежащий в основе различных вер- сий Web-технологии PHP (Personal home pages), и язык сценариев, на котором основана Web-технология ASP (Active server pages), — последний предложен и поддерживается известным производителем программного обеспечения — фир- мой Microsoft. Компонент, интерпретирующий ASP, появился в составе версии IIS (Internet information server) 3.0. В результате разработчики получили более простые в применении и вместе с тем более мощные средства создания Web- приложений. Текст на интерпретируемых языках, которые поддерживаются такими Web-тех- нологиями, как ASP или РНР, представляет собой часть текста обычных HTML- страниц со встроенными в них сценариями (script). Эти сценарии можно писать на любом языке, поддерживаемом сервером; Интернет-сервер обрабатывает их при поступлении запроса о URL-адресе соответствующего файла. Он разбирает текст HTML-страницы, находит в нем тексты сценариев, вырезает их и интер- претирует в соответствии с синтаксисом и семантикой данного языка. В резуль- тате интерпретации получается выходной текст на языке HTML, который сервер вставляет непосредственно в то место исходной страницы, где встретился сце- нарий. Так обрабатывается динамическая Web-страница на любом интерпрети- руемом языке, ориентированном на работу в глобальной сети. Естественно, для
692 Глава 15. Современные системы программирования работы со страницей сервер должен иметь в своем составе интерпретатор соот- ветствующего языка. Все эти языки сценариев обладают присущими им характерными особенно- стями. Во-первых, они имеют мощные встроенные функции и средства для рабо- ты со строками, поскольку основной задачей программ, написанных с помощью таких языков, является обработка входных параметров (строковых) и порожде- ние HTML-кода (который также является текстом). Во-вторых, все они имеют средства для работы в архитектуре «клиент—сервер» для обмена информацией с серверами БД, а многие современные версии таких языков (например, язык, под- держиваемый Web-технологией ASP) — средства для функционирования в трех- уровневой архитектуре для обмена данными с серверами приложений. Технологии интерпретируемых языков сценариев, отделившие процесс написа- ния кода HTML от процесса создания прикладных компонентов, способствовали более эффективному сотрудничеству между дизайнерами Web-страниц и разра- ботчиками программ. Благодаря тому, что динамические данные и приложения генерируются с помощью интерпретируемых языков непосредственно на Интер- нет-сервере, информация остается там, где ею легче всего управлять. Недостаток всех перечисленных методов заключается в том, что сначала сервер вынужден строить HTML-описание, собирая его некоторым образом из каких-то своих данных, а затем Интернет-клиент на своей стороне разбирает (интерпрети- рует) полученное описание HTML-страницы. Таким образом, выполняется как бы двойная работа по генерации, а затем интерпретации текстов языка HTML. Кроме того, между клиентом и сервером по сети передаются довольно громозд- кие описания HTML-страниц, что может значительно увеличивать трафик сети. Тем не менее, несмотря на недостатки, данные методы довольно распространены в глобальной сети, поскольку они очень просты, а кроме того, не требуют от кли- ентского компьютера ничего, кроме способности интерпретировать тексты HTML. Эта особенность довольно существенна. Есть и другие варианты генерации динамических HTML-страниц. Рассмотрен- ные далее методы, хотя и снижают нагрузку на сеть, но предъявляют определен- ные требования к клиентской части, что не всегда приемлемо. Информацию по перечисленным выше языкам, поддерживающим такие методы, можно найти в соответствующей технической литературе (она частично имеется в [10, 91]), но лучше обратиться на соответствующие сайты во Всемирной сети [103, 107, 108]. Языки программирования Java и Java Script Язык Java был разработан компанией Sun в качестве средства Web-программи- рования. Этот язык, в отличие от языка описания гипертекста HTML, является полноценным языком программирования. Он содержит в себе все основные опе- раторы, конструкции и структуры данных, присущие языкам программирова- ния. Синтаксические конструкции и семантика языка Java большей частью были заимствованы из языков программирования С и C++. Основная идея, отличающая язык Java от многих других языков программирова- ния, не ориентированных на применение в глобальной сети, заключается в том,
Примеры современных систем программирования 693 что Java не является полностью компилируемым языком. Исходная программа, созданная на языке Java, не преобразуется в машинные коды. Компилятор языка порождает некую промежуточную результирующую программу на специальном низкоуровневом двоичном коде (эта результирующая программа называется Java- апплетом, а код, на котором она строится, — Java байт-код ом). Именно этот код интерпретируется при исполнении результирующей Java-программы. Такой ме- тод исполнения кода, основанный на интерпретации, делает его практически не- зависимым от архитектуры целевой вычислительной системы. Таким образом, для исполнения Java-программы необходимы две составляю- щие: компилятор промежуточного двоичного кода, порождающий его из исход- ного текста Java-программы, и интерпретатор, исполняющий этот промежуточ- ный двоичный код. Такой интерпретатор получил название виртуальной Java- машины. Описание основных особенностей реализации виртуальной Java-маши- ны (интерпретатора промежуточного низкоуровневого кода) с точки зрения сис- темы программирования описаны в [74, 91]. Одной из отличительных особенностей данного языка является использование специального механизма распределения памяти (менеджера памяти). В языке Java не могут быть использованы функции динамического распределения памя- ти и связанные с ними операции над адресами и указателями, поскольку они за- висят от архитектуры вычислительной системы. Динамическая память в языке может выделяться только под классы и объекты самого языка. Для этого менед- жер памяти должен сам организовывать своевременное выделение областей па- мяти при создании новых классов и объектов, а затем освобождать области памя- ти, которые уже больше не используются. В последнем случае должна решаться непростая задача сборки мусора — поиска неиспользуемых фрагментов в памяти и их освобождение. Причем, поскольку в языке Java за распределение памяти от- вечает не пользователь, а интерпретатор кода, эти задачи должны решаться неза- висимо от хода выполнения самой Java-программы. Менеджер памяти входит в состав виртуальной Java-машины и, безусловно, за- висит от архитектуры вычислительной системы, где функционирует эта машина, но интерпретируемые ею программы при этом остаются независимыми от архи- тектуры. Хороший менеджер памяти — важная составляющая виртуальной Java- машины наряду с быстродействующим интерпретатором промежуточного низ- коуровневого кода. При выполнении Java-программы в глобальной сети компилятор, порождающий промежуточный низкоуровневый код, находится на стороне Интернет-сервера, а интерпретатор, выполняющий этот код, — на стороне клиента. По сети от сер- вера к клиенту передается только уже скомпилированный код. С этой точки зре- ния использование языка Java для исполнения программ в сети и организации динамических Web-страниц дает преимущества по сравнению с использованием других языков, выполняемых на стороне сервера, как описано выше. Преимуще- ство заключается в том, что по сети не надо передавать громоздкие HTML-опи- сания страниц, что значительно снижает трафик в сети. Однако отсюда проистекают и основные недостатки, присущие языку Java. Глав- ный из них заключается в том, что на клиентской стороне должна присутство-
694 Глава 15. Современные системы программирования вать виртуальная Java-машина для интерпретации поступающего из сети кода. Это значит, что так или иначе интерпретатор языка Java должен входить в со- став архитектуры целевой вычислительной системы, а без его наличия функцио- нирование такой схемы становится невозможным. Кроме того, промежуточный код языка исполняется на стороне клиента, а значит, скорость его выполнения и возможности Java-программы во многом зависят от производительности клиент- ского компьютера, которая может оказаться недостаточно высокой у машины, ориентированной только на подключение к глобальной сети. Речь идет не только о скорости интерпретации команд, но и о том, что клиентская система должна быть способна работать со всеми базовыми классами, присущими языку Java. Таким образом, требования к производительности клиентского компьютера для выполнения на ней Java-программ могут оказаться непомерно высокими. Еще одна особенность связана с необходимостью обеспечения безопасности при исполнении Java-программ. Поскольку код исполняемой программы поступает из глобальной сети, то нет никакой гарантии, что этот код не содержит фаталь- ную ошибку. Кроме того, такой код может быть преднамеренно создан со злым умыслом. Имея все возможности языка программирования (в отличие от текста HTML) и исполняясь в среде вычислительной системы клиента, он может при- вести к выходу из строя этой системы или к повреждению хранящихся в ней данных. Потому при построении интерпретатора виртуальной Java-машины нужно целенаправленно выделять и отслеживать потенциально опасные команды (та- кие, как выполнение программ или обращение к файлам на клиентской маши- не). Несмотря на то что большинство производителей виртуальных Java-машин считаются с этим правилом, проблема безопасности остается актуальной при ис- пользовании языка Java. Язык Java быстро завоевал популярность и занял значительное место на рынке языков программирования и связанных с ними средств разработки. По сути, этот язык явился первой удачной реализацией двухэтапной модели выполнения про- грамм, построенной на использовании компилятора промежуточного кода с по- следующей интерпретацией полученного кода. Независимость выполнения Java- программ от архитектуры целевой вычислительной системы способствовала росту популярности языка, который стал применяться не только как удобное средство Web-программирования, но и как средство разработки прикладных программ, ориентированных на различные вычислительные архитектуры. Росту популярности языка программирования Java и построенных на его основе систем программирования способствовал также тот факт, что в его состав вхо- дят встроенные средства поддержки интерфейса с серверами БД, ориентирован- ные на разработку приложений, выполняющихся в архитектуре «клиент—сервер». Системы программирования языка Java совместимы также с известной группой стандартов, ориентированной на разработку результирующих программ, выпол- няющихся в трехуровневой архитектуре — CORBA. Первоначально язык поддерживался только одним разработчиком — компанией Sun. Но со временем и с ростом популярности языка в его поддержку стали включаться и другие известные компании-производители программных продук- тов. Среди них можно выделить уже упоминавшуюся в данном пособии компа- нию Borland, создавшую на базе языка Java систему программирования Borland
Примеры современных систем программирования 695 J Builder. В настоящее время компании, поддерживающие системы программиро- вания на основе языка Java, образуют специальное сообщество для поддержки и распространения средств разработки на основе данного языка. Всю необхо- димую информацию можно получить на сайте [101] и во многих других местах всемирной сети Интернет, которая во многом способствовала развитию этого языка1. Основной проблемой языка Java остается его производительность. При прочих равных условиях интерпретируемая программа, построенная на основе системы программирования для Java, будет уступать в скорости откомпилированной про- грамме, созданной в любой другой современной системе программирования. По- этому язык Java практически не применяется в программах, требующих сложных расчетов и математических функций. Но во многих прикладных программах, где производительность не играет столь принципиального значения, он вполне может быть использован, так как дает преимущества в независимости результирующей программы от архитектуры целевой вычислительной системы. Большинство со- временных ОС допускают наличие в своем составе виртуальных Java-машин для интерпретации результирующего кода Java-программ. Необходимость иметь в составе архитектуры вычислительной системы клиент- ского компьютера виртуальную Java-машину, а также довольно высокие требо- вания к производительности компьютеров на клиентской стороне в ряде случаев ограничивают возможности применения языка Java. С другой стороны, зачастую для организации динамической Web-страницы достаточно лишь выполнить ряд простых действий, не укладывающихся в рамки статического HTML. Для этого нет необходимости создавать, компилировать и передавать по сети полноценную Java-программу. Чтобы решить эти проблемы, был предложен командный язык Java Script, кото- рый можно назвать упрощенным вариантом языка Java. Фрагменты кода, написанные на Java Script, передаются непосредственно внут- ри текста HTML-страниц. Синтаксис и семантика Java Script в целом соответст- вуют языку Java, но возможности его выполнения сильно ограничены — они не выходят за рамки той программы, которая интерпретирует HTML-страницу. В таком случае эта программа выступает и в роли виртуальной Java-машины для выполнения операторов Java Script. Чаще всего это программа навигации по сети — браузер. Он находит в тексте HTML-страницы операторы языка Java Script, выделяет их и исполняет по всем правилам языка Java. Однако выполне- ние Java Script происходит только в рамках адресного пространства программы- навигатора. Это, с одной стороны, ограничивает его возможности, но, с другой стороны, увеличивает безопасность выполнения операторов, так как в худшем 1 Известная компания — производитель ОС и систем программирования Microsoft — не- которое время тоже способствовала распространению языка Java и занималась его под- держкой. Однако затем, к сожалению, отказалась от использования данного средства разработки по причинам маркетинговых и других разногласий с компанией Sun. Многие идеи, первоначально заложенные в Java, нашли свою реализацию в языке C# (Си-шарп), предложенному этой компанией и продвигаемой ею в настоящее время на рынок средств разработки.
696 Глава 15. Современные системы программирования случае при наличии некорректных операторов языка неработоспособной окажется только запущенная программа-навигатор, но не вся клиентская система в целом. Операторы Java Script сейчас широко используются для организации динами- ческих Web-страниц. Их выполнение (интерпретация) обеспечивается всеми современными программами-навигаторами в глобальной сети. Более подробную информацию о языке Java Script и его возможностях можно найти в [10] или на многих сайтах, посвященных языку Java [101]. Контрольные вопросы и задачи Вопросы 1. Почему различные функции, выполняемые программными модулями в составе системы программирования, разнесены по различным модулям, а не включе- ны в состав компилятора? Какие для этого есть исторические и технические причины? 2. Используя схему, приведенную в учебном пособии, объясните функциониро- вание системы программирования на различных этапах развития: О в виде комплекса программных модулей, выполняемых по командам Ma- kefile; О в виде единой интегрированной среды разработки; О в виде единой интегрированной среды разработки, объединенной со сред- ствами проектирования программного обеспечения. 3. Каким целям служит командный файл Makefile? В чем его отличие от обыч- ных командных файлов? В каких случаях в настоящее время продолжают ис- пользоваться средства разработки на основе Makefile? 4. Что такое Application Program Interface (API)? Какие задачи решаются в при- кладных программах с помощью API? Какие средства реализации API суще- ствуют? (При необходимости обратитесь к первой части данного учебного пособия.) 5. Что такое «ресурсы прикладной программы»? Относятся ли к понятию «ре- сурсы прикладной программы» следующие типы данных: О текст сообщения, выдаваемого программой при возникновении ошибки; О тексты пунктов главного меню программы; О файлы, обрабатываемые прикладной программой; О цвет фона главного окна программы; О структуры данных, используемые программой в процессе работы; О файл подсказки, выдаваемой программой по запросу пользователя? 6. В каких случаях на современном рынке программных средств встречаются компиляторы и интерпретаторы, не входящие в состав систем программиро- вания? Приведите примеры.
Контрольные вопросы и задачи 697 7. Если в процессе создания современной системы программирования встанет вопрос о разработке более эффективного компилятора либо о развитии сер- висных средств и интерфейса пользователя, то какому направлению будет от- дано предпочтение? Почему? 8. Какие специфические функции выполняет текстовый редактор в составе сис- темы программирования? Как он должен быть взаимосвязан с другими моду- лями системы? 9. В чем особенности функционирования компилятора в составе системы про- граммирования по сравнению с его функционированием в виде отдельного программного модуля? 10. Почему компоновщик носит также название «редактор связей»? В чем за- ключается процедура разрешения внешних ссылок объектного файла? 11. В современных системах программирования загрузчик, как правило, отсутст- вует. Его функции выполняет ОС. Расскажите, как эти функции выполняют- ся в различных ОС (при необходимости обратитесь к первой части учебного пособия). Какую информацию должна получить ОС от системы программи- рования для выполнения загрузки исполняемого файла программы? 12. Современные отладчики используют средства ОС и возможности архитекту- ры целевой вычислительной системы для отладки прикладных программ. Из материала первой части данного учебного пособия вспомните, какие средства отладки существуют. 13. Если система программирования выполняет отладку результирующей про- граммы путем моделирования архитектуры целевой вычислительной систе- мы, то чем ограничены возможности такого отладчика? Чем отличается такое выполнение программы от работы интерпретатора? 14. В чем преимущества и недостатки динамически загружаемых библиотек по сравнению с обычными (статически подключаемыми) библиотеками? Если подключение к результирующей программе статической библиотеки выпол- няет компоновщик, то какая часть системы программирования или ОС ответ- ственна за подключение динамически загружаемой библиотеки? 15. Как вызов функции динамически загружаемой библиотеки обрабатывается ком- пилятором? Какие действия выполняет в этом случае компоновщик? Должна ли динамически загружаемая библиотека быть доступна системе программи- рования в момент компоновки результирующей программы? 16. Какие дополнительные возможности предоставляет пользователю лексический анализ исходного текста программы «на лету»? Как влияет реализация тако- го лексического анализа в системе программирования на эффективность ра- боты компилятора, на количество проходов, выполняемых компилятором? 17. Какие преимущества имеет приложение (результирующая программа), функ- ционирующее в составе архитектуры «клиент—сервер» по сравнению с обыч- ным приложением? Какие дополнительные требования предъявляет такое приложение к вычислительным ресурсам? Какие требования предъявляются к системе программирования, позволяющей создавать приложения, функцио- нирующие в составе архитектуры «клиент—сервер»?
698 Глава 15. Современные системы программирования 18. Клиентскую часть приложения, функционирующего в составе архитектуры «клиент—сервер», часто называют «толстым клиентом». Сравните функции и характеристики «толстого клиента» в архитектуре «клиент—сервер» с функ- циями и характеристиками «тонкого клиента» в трехуровневой архитектуре. В чем их принципиальное различие? 19. Почему требования к вычислительным ресурсам у «тонкого клиента», как правило, ниже, чем у «толстого клиента»? 20. Сравните функции сервера приложений и сервера данных. В чем различие между ними? 21. Какие основные особенности определили успех и долгое существование сис- темы программирования Turbo Pascal производства компании Borland на рос- сийском рынке средств разработки? В чем вы видите недостатки этой системы? 22. Сравните между собой системы программирования Borland Delphi и Borland C++ Builder. В чем преимущества и недостатки каждой из этих систем? 23. Какие особенности систем программирования компании Microsoft позволили им, на ваш взгляд, завоевать свое место на рынке средств разработки? 24. Сравните между собой системы программирования Microsoft Visual Basic и Microsoft Visual C++. В чем преимущества и недостатки каждой из этих сис- тем? Есть ли у каждой из них характерные черты, общие с системами про- граммирования производства компании Borland? 25. В чем сходство и в чем различие принципов распространения прикладного программного обеспечения, созданного для ОС типа UNIX и для ОС Linux проекта GNU? Почему системы программирования (и прежде всего, системы программирования для языков Си и Си++) являются неотъемлемой частью этих ОС? 26. В чем заключаются основные особенности разработки программного обеспе- чения для глобальной сети Интернет? Почему в этой сети часто используют- ся интерпретируемые языки? 27. В чем принципиальная разница функционирования динамических Web-стра- ниц, организованных с помощью программного обеспечения на основе интер- фейса CGI (или ISAPI), и динамических Web-страниц, построенных на осно- ве языков типа Perl, PHP или ASP? 28. В чем принципиальная разница функционирования динамических Web-стра- ниц, организованных с помощью программного обеспечения на основе интер- фейса CGI (или ISAPI), и динамических Web-страниц, построенных с исполь- зованием JavaScript? В чем принципиальная разница выполнения команд на языке JavaScript и команд на языках типа Perl, PHP или ASP? Задачи 1. Функция F1 содержится в библиотеке, построенной с помощью компилято- ра в некоторой системе программирования. При попытке использовать эту функцию при построении исполняемого файла в другой системе програм- мирования компоновщик выдает сообщение об ошибке вида «функция F1 не
Контрольные вопросы и задачи 699 найдена». Какова может быть причина появления такого рода ошибки? Что следует предпринять разработчику программы? 2. Функция F1 содержится в библиотеке, построенной с помощью компилятора в некоторой системе программирования. Функция F1 отлажена и проверена, однако при использовании ее в исполняемом файле, созданном в другой сис- теме программирования, возникает ошибка, связанная с защитой памяти. Ре- зультирующая программа не может выполняться корректно. Какова может быть причина появления такого рода ошибки? Что следует предпринять раз- работчику программы? 3. Функция F1 является виртуальной функцией некоторого класса С1. На ос- нове одного и того же исходного кода в одной системе программирования построены динамически загружаемая библиотека и исполняемый файл. Ис- полняемый файл создает экземпляр класса С1 и передает его в качестве пара- метра в динамически загружаемую библиотеку, где вызывается функция F1. Приведет ли такая схема к возникновению ошибок во время выполнения программы? Если ошибки возможны, то что следует предпринять разработ- чику программы? Как изменится ситуация, если предположить, что для соз- дания исполняемого модуля и динамически загружаемой библиотеки исполь- зовался один и тот же исходный код, но разные системы программирования? 4. Имеется класс С1. На основе одного и того же исходного кода в одной сис- теме программирования построены динамически загружаемая библиотека и исполняемый файл. Исполняемый файл создает экземпляр класса С1 и пере- дает его в качестве параметра в динамически загружаемую библиотеку. Дина- мически загружаемая библиотека проверяет экземпляр класса на принадлеж- ность к типу С1. Приведет ли такая схема к возникновению ошибок во время выполнения программы? Если ошибки возможны, то что следует предпри- нять разработчику программы?
ПРИЛОЖЕНИЕ А Тексты программы параллельных взаимодействующих задач Здесь приведены тексты программы, реализующей параллельное функциониро вание задач, взаимодействующих между собой в соответствии с рис. 6.6. program Multi Threads: uses Forms. ThrForm in 'ThrForm.pas' {Forml} . Threads in 'Threads.pas' ; ($R *.RES} begin Application.Initialize: Application.CreateForm (TForml.Forml); Application.Run; end. unit ThrForm; interface uses Windows. Messages. SysUtils. Classes. Graphics. Controls. Forms. Dialogs. Gauges. StdCtrls. Threads; type TForml = class (TForm) StrButtonl: TButton; GaugeA: Tgauge; GaugeB: Tgauge; GaugeC: Tgauge; GaugeD: Tgauge;
Тексты программы параллельных взаимодействующих задач 701 GaugeE: Tgauge: GaugeF: Tgauge: GaugeG: Tgauge: Memol : TMemo : Label 1: Tlabel: Label2: Tlabel: Label3: Tlabel; Label4: Tlabel: Label5: Tlabel: Label6: Tlabel: Label7: Tlabel: Scroll Bari: TScrollBar; Label8: Tlabel: Label 9: TLabel; procedure StrButtonlC11ck(Sender: TObject); { Запуск задач ] procedure FormCreate(Sender: TObject): procedure Scroll BariChange(Sender: TObject}: private { Private declarations } public { Public declarations } end: var Forml: TForml: Inpiementation {$R *.DFM} procedure TForml.StrButtonlCLickCSender: TObject) : { Запуск задач } begin StrButtonl.Enabled:=f alse; Memol.Clear: GaugeA.Progress:=0: GaugeB.Progress:=0: GaugeC.Progress:=0: GaugeD.Progress:=0: GaugeE.Progress:=0: GaugeF.Progress:=0; GaugeG.Progress.-=0; Al 1Sta rts(GaugeA,GaugeB.GaugeC,GaugeD.GaugeE,GaugeF.GaugeG): End: procedure Tforml.FormCreate(Sender: TObject): begin StartBtn:=StrButtonl: Del ay:=Scrol1 Bari.Posi ti on;
702 Приложение А. Тексты программы параллельных взаимодействующих задач Messaged st:=Memol; end: procedure TForml.Scroll BariChange(Sender: TObject); begin Delay:=ScrollBarl.Position: end: end. unit Threads: { Описание тредов } interface uses Classes.Gauges.StdCtrls.Syncobjs: type TParams = record { Параметры для инициализации треда } OwnName:char: ParentName:char; Length:integer: end: ThreadProgress = class(TThread) private { Основной объект-родитель } FGauge:TGauge: FLength:integer: FProgress:Longint: Finished:boolean; FName:char: FParentName:char; FText: string: { Строка состояния } { Длина задачи } { Текущее состояние задачи } { Признак завершения задачи ] { Имя задачи } { Имя запустившей задачи } { Строка для вывода сообщений } protected procedure Execute: override: procedure DoVisualProgress: procedure WriteToMemo; { Выполнение задачи } { Прорисовка строки состояния } { Вывод сообщения } procedure Finish; virtual: abstract: { Конечная часть задачи } public constructor create(Gauge:TGauge: Pa rams:TParams): end; TThreadA = cl ass(ThreadProgress) protected procedure Finish; override ; end: TThreadB = class(ThreadProgress) protected procedure Finish: override;
Тексты программы параллельных взаимодействующих задач 703 end: TThreadC = class(ThreadProgress) protected procedure Finish; override: end: TThreadD = class(ThreadProgress) protected procedure Finish: override: end: TThreadE = class(ThreadProgress) protected procedure Finish; override : end: TThreadF = class(ThreadProgress) protected procedure Finish: override; end: TThreadG = class(ThreadProgress ) protected procedure Finish; override: end: procedure Al 1 Starts(GaugeA,GaugeB.GaugeC,GaugeD,GaugeE,GaugeF,GaugeG:TGauge): var StartBtn.-TButton; MessageList:TMemo: Del ay-.integer; Params-.TParams; ThrA:TThreadA; ThrB:TThreadB: ThrC:TThreadC: ThrD:TThreadD; ThrE:TThreadE: ThrF.-TThread.F: ThrG:TThreadG; CriticalSection: TCriticalSection; { Данный объект позволяет организовать монопольный доступ к общим ресурсам } implementation procedure ATlStarts(GaugeA,GaugeB,GaugeC.GaugeD,GaugeE,GaugeF,GaugeG:TGauge): { Начало выполнения задач } begin Params.OwnName:= 'А'
704 Приложение А. Тексты программы параллельных взаимодействующих задач Params.ParentName : =' U ' : Params.Length:=2: ThrA:=TThreadA.Create(GaugeA.Params): Params.OwnName:e'B': Params.ParentName : -=,U' ; Params.Length:=4: ThrB:=TThreadB.Create(GaugeB,Params): Params.OwnName:=,C': Params.ParentName:-'A': Params.Length:=2: ThrC:=TThreadC.Create(GaugeC.Params): Params.OwnName:=,D’: Params.ParentName:=’A’; Params.Length:=2; ThrD:=TThreadD.Create(GaugeD.Params): Params.OwnName:='E': Params.ParentName:='A': Params.Length:=4: ThrE:=TThreadE.Create(GaugeE.Params): Params.OwnName-.-'F'; Params.ParentName:= Params.Length:=2: ThrF:-TThreadF.Create(GaugeF,Params): Params.OwnName:»'G': Pa rams.Pa rentName: = Pa rams.Length:=1: ThrG:-TThreadG.Create(GaugeG,Params): Cri ti cal Sect!on:=TCri11 cal Sect!on.Create; ThrA.Resume: ThrB.Resume: end: { ThreadProgress } procedure ThreadProgress.Execute: var i’.integer: begin FText :-'Запущена задача '+Fname+' (задачей ' +FParentName+')' : Synchronize(WriteToMemo); for i:=l to FLength do begin
Тексты программы параллельных взаимодействующих задач 705 FProgress:-FProgress+l: Synchronize{doVisualProgress): end: Finish: Destroy: end: constructor Threadprogress.create(Gauge:TGauge:Params:TParams): begin inherited create(true): FGauge:=Gauge: FLength:-delay*Params.Length: FGauge.MaxValue:=FLength: FGauge.MinValue:=O: FGauge.Progress:=0: FProgress:=0: FName:=Pa rams.OwnName: FPa rentName:=Pa rains.Pa rentName: Finished:»false: end: procedure Threadprogress.DoVi sualProgress: begin FGauge.Progress:»FProgress: end: procedure ThreadProgress.WriteToMemo: begin MessageList.Lines.Add(FText): end: { TThreadA } procedure TThreadA.Finish: begin ThrC.Resume; ThrD.Resume: ThrE.Resume: FText:= 'Задача А завершила работу и запустила задачи C.D.E’: Synchronize{WriteToMemo): Finished:=true: end: { TThreadB } procedure TThreadB.Finish: begin FText:»'3afla4a В завершила свою работу': Synchronize(WriteToMemo):
706 Приложение А. Тексты программы параллельных взаимодействующих задач Finished:=true; Critical Section.Enter; { Начало защищенного блока команд } try if (ThrC.Finished=false)and(ThrD.Finishedefalse) then begin ThrF.FParentName;='B'; repeat Synchronize(DoVisualprogress); until (ThrC.Finished)and(ThrD.Finished); ThrF.Resume; FText:='3afla4a В запустила задачу F*; Synchronize{WriteToMemo); end; finally CriticalSection.Leave; { Конец защищенного блока команд } end; end; { TthreadC } procedure TThreadC.Finish; begin FText:='Задача С завершила свою работу'; Synchronize(WriteToMenro); Finished;=true; CriticalSection.Enter; try if (ThrB.Finished=false)and(ThrD.Finishedefalse) then begin ThrF.FParentName:='C'; repeat Synchronize(DoVisualprogress}; until (ThrB.Finished)and(ThrD.Finished); ThrF.Resume; FText:='Задача С запустила задачу F*; Synchronize(WriteToMemo); end; finally CriticalSection.Leave; end; end; { TthreadD } procedure TThreadD.Finish; begin FText;e‘ Задача D завершила свою работу'; Synchronize{WriteToMemo); Finished;=true;
Тексты программы параллельных взаимодействующих задач 707 Critical Section.Enter; try if (ThrC.Finished=false)and{ThrB.Finished=false) then begin ThrF.FParentName:=' D ' ; repeat Synchronize(DoVisualprogress}: unti1 (ThrC.Fi ni shed)and(ThrB.Fi ni shed): ThrF.Resume: FText:='Задача D запустила задачу F’; Synchronize{WriteToMemo): end: finally Critical Sect!on.Leave: end: end: { TThreadE } procedure TThreadE.Finish: begin Finished:=true: CriticalSecti on.Enter: try if ThrF.Finished then begin ThrG.FParentName:=,E'; ThrG.Resume: FText:='3afla4a E завершила работу и запустила задачу G'; Synchronize{WriteToMemo): end else begin FText:='3afla4a E завершила свою работу'; Synchronize{WriteToMemo); end: finally CriticalSection.Leave: end; end: { TThreadF } procedure TThreadF.Finish; begin Finished:=true: CriticalSection.Enter; try if ThrE.Finished then begin
708 Приложение А. Тексты программы параллельных взаимодействующих задач ThrG.FParentName:» *F* : ThrG.Resume; FText;»'3адача F завершила работу и запустила задачу G': Synchronize(WritеТоМето): end else begin FTextЗадача F завершила свою работу’; Synchronize{WritеТоМето); end; finally CriticalSection.Leave: end; end; { TThreadG } procedure TThreadG.Finish; begin Finished;=true; FText;»’Задача G завершила работу’; Synchronize{WnteToMemo): StartBtn.Enabled:»true: Cri ti calSecti on.F ree: end: end.
ПРИЛОЖЕНИЕ Б Тексты программ комплекса параллельных взаимодействующих приложений Здесь приведены тексты следующих программ: А, В, D и G. Эти программы от- ражают алгоритмы взаимодействия, заданные на рис. 6.7. Остальные программы содержат аналогичные алгоритмы взаимодействия. Текст программы А #define INCL_BASE #define INCL_DOS #include <os2.h> #include <string.h> #include <stdio.h> #1nclude <stdlib.h> #1nclude "progr.h" #1nclude "func.h" /*Глобальные переменные*/ char myword[]= "PROGR_A.EXE": BYTE atrib[2]; /*Главная функция*/ int mainO {/*Локальные переменные*/ int i.j; Pipe$ize»250; strcpy(Word.myword); atribCO]»' atrib[l]=0x02f: VIoScrollUp(0.0.25.80.25.&atrib.0);
710 Приложение Б. Тексты программ комплекса параллельных приложений /*Фиксация времени начала процесса*/ DosGetDateT1me(&T1me); st перу (BegT i me,i toa(Ti me. hours, MyBuffer, 10)): strcat (BegT i me.":"); strcat(BegTi me,i toa(Ti me.mi nutes,MyBuffer,10)); strcat(BegTime strcat(BegTime.itoa(Time.seconds,MyBuffer,10)): strcat(BegTime,":"); strcat(BegT i me.i toa(Ti me.hundredths.MyBuffer .10)); /*Установка одинакового приоритета всех подпроцессов*/ rc=DosSetPriori ty(PRTYS_PROCESSTREE.2,10,0): if (rc’=0) {HaltSystemC'Set Priority ",rc); } /* Создание файла, доступного всем подпроцессам */ гс = DosOpenC result,txt”.&Fi1 eOpen.&Action.0.0.0x0010|0x0002, 0x0002|0x0040|0x4000,0): if (rc!=0) {HaltSystemC'DosOpen ",rc): } /*Создание семафоров*/ гс = DosCreateEventSem("\\SEM32\\PIPESEM", &PipeSem, DC_SEM_SHARED, 0): if (rc!e0) {HaltSystem("Create Sem PipeSem ",rc): } rc = DosCreateEventSem("\\SEM32\\EXITSEM", &ExitSem. DC_SEM_SHARED. 0): if (rc!=0) { HaltSystem("Create Sem (ExitSem) ".rc);} rc = DosCreateEventSem("\\SEM32\\WRITEFILE", &FileSem, DC_SEM_SHARED, 0): if (rc!=0) { HaltSystem("Create Sem (FileSem) ",rc):} rc = DosCreateEventSem("\\SEM32\\P0INT2SEM",&Po,int2Sem, DC_SEM_SHARED. 0): if (rc!s0) { HaltSystem("Create Sem (Point2Sem) ",rc):} rc = DosCreateEventSem("\\SEM32\\POINT3SEM",&Point3Sem, DC J>EM_SHARED, 0): if (rc!=0) { HaltSystem("Create Sem (Point3Sem) ".rc);} rc = DosCreateEventSem("\\SEM32\\P0INTlSEM",&PointlSem, DC_SEM_SHARED, 0): if (rc!=0) { HaltSystem("Create Sem (PointlSem) ".rc):} rc = DosPostEventSem(PipeSem): if (rc!=0) {HaltSystemC'PostSem (PipeSem)",rc): } rc e DosPostEventSem(FileSem): if (rc!=0) {HaltSystemC'PostSem (FileSem)",rc): } /*Создание Pipe (транспортера)*/ rc»DosCreatePi pe(&ReadHandle,&Wri teHandle,Pi peSi ze); if (rc!=0) {HaltSystem("Create Pipe",rc);} /*3адание строк аргументов*/ for (i*0:i<sstrlen(myword):i++ ) { Argument[i]=myword[i]: } j»i: Argument2=itoa(ReadHandle.MyBuffer,10): for (i-0;i<=strlen(Argument2);i++) { Argument[j+i]=MyBuffer[i]: } j+-i: Arguments-1]=' '; Argument2-i toa(Wri teHandle,MyBuffer,10): for (i=0:i<»strlen(Argument2):i++) { Argument[i+j]=MyBuffer[i]: } j+-i: Argument[j-1]-' ': Argument2=itoa(FileOpen,MyBuffer,10); for (i»0;i<-strlen(Argument2):i++) { Argument[i+j]=MyBuffer[i]: } /*Вывод сообщений и выполнение некоторого процесса (цикла)*/ strcpy(PMessege,"Программа "): strcat(PMessege.myword); VioWrtNAttr(&atr,80*25,0,0.0); VioWrtCharStr(PMessege.sizeof(PMessege),2,1,0): strcpy(PMessege,"Запущена в "): strcat(PMessege,BegTime); VioWrtNAttr(&atr.80*25.0,0,0); VioWrtCharStr(PMessege,sizeof(PMessege).3,1,0); strcpy(FMessegel.myword): strcat(FMessegel," "): strcat (FMessegel.PMessege): strcpy(PMessege,"Программой "); strcat(PMessege," "): VioWrtNAttr(&atr,80*25,0,0.0); VioWrtCharStr(PMessege,sizeof(PMessege),4.1,0); strcpy(FMessege2.myword): strcat(FMessege2," "); strcat (FMessege2,PMessege):
Текст программы В 711 for(1-0:i<22:1++) { VioWrtNAttr(&atг.80*25,0,0,0): VioWrtCharStr(" |", 1,5, l+i, 0): for (j»0:j<(12500*Speed):j++): } /^Получение времени загрузки программ потомков*/ DosGetDateTime(&Т1me): st rcpy(MyTime,itoa(Time.hou rs,MyBuffer,10)); strcat(MyTime,":"): strcat(MyTime,itoa(Time.minutes.MyBuffer,10)): strcat(MyTime,":"): strcat(MyTime,itoa(Time.seconds.MyBuffer,10)); strcat(MyTime,":"): strcat(MyTime,itoa(Time. hundredths,MyBuffer,10)): strcpy(Inform.LaunchTime,MyTime); strcpy(Inform.ParentName.myword); Inform.Number-4: strcpy(PMessege,"Завершена в ”); strcat(PMessege.MyTime), VioWrtNAttr(&atr,80*25,0,0.0): VioWrtCharStr(PMessege.sizeof(PMessege).6,1.0). strcpy(FMessege3,myword): strcat(FMessege3," "): strcat (FMessege3.PMessege): /*0жидание доступности записи в файл и немедленная запись в файл*/ do{ DosWaitEventSem(FileSem,-1): rc=DosResetEventSem(Fi1eSem,&BytesWritten); } while (rc!»0): DosWrite(FileOpen,(PVOID)&FMessegel.sizeof(FMessegel),&BytesWri tten): DosWrite(File0pen,(PV0ID)&FMessege2,sizeof(FMessege2),&BytesWritten): DosWrite(File0pen,(PV0ID)&FMessege3.sizeof(FMessege3),&BytesWritten): DosPostEventSemCFileSem): /*3апись сообщения в Pipe: имя предка и время запуска программ*/ rc= DosWrite(WriteHandle.(PVOID)&Inform,sizeof(Inform), &BytesWritten): if (rc!»0) { HaltSystem("(DosWrite)",rc): } /*3апуск программ - потомков*/ rc=DosExecPgm(FailFileb,sizeof(FailFileb),1,Argument.O.&ResCodeb,"progr_b.exe"); if (rc!»0) {HaltSystem("(DosExecPgm) B",rc):} rc=DosExecPgm(Fai1Fi1 eb,sizeof(FailFileb).1.Argument,0,&ResCodeb,"progr_c.exe"): if (rc’-O) {HaltSystem("(DosExecPgm) C",rc):} rc»DosExecPgm(Fai1Fi1 eb,sizeof(Fai1Fi1 eb),1.Argument.0,&ResCodeb,"progr__i.exe"): if (rc’-O) {HaltSystem("(DosExecPgm) I",re):} rc=DosExecPgm(FailFileb,sizeof(FailFileb),1,Argument,O.&ResCodeb,"progrj.exe"): if (rc!»0) {HaltSystem("(DosExecPgm) J".re):} /*0жидание загрузки семафоров в программах-потомках*/ DosWaitEventSem(ExitSem.-1): DosCloseEventSem(ExitSem); return(0):} Текст программы В #define INCL-BASE #define INCL__DOS #include <os2.h> #include <string.h> #include <stdio.h> #include <stdlib.h> #include "progr.h"
712 Приложение Б. Тексты программ комплекса параллельных приложении #include "func.h" /*Глобальные переменные*/ char myword[]= "PROGR_B.EXE"; /*Главная функция*/ int main(int argc. char *argv[]. char *envp[]) {/*Локальные переменные*/ int i.j: strcpy(Word.myword): /Инициализация семафоров*/ гс » Dos0penEventSem("\\SEM32\\PIPESEM", &PipeSem); if (rc!»0) {HaltSystem("Open Sem (PipeSem) ”.rc); } rc - Dos0penEventSem("\\SEM32\\EXITSEM". &ExitSem): if (rc!»0) { HaltSystem("Open Sem (ExitSem) ".rc);} rc » Dos0penEventSem("\\SEM32\\WRITEFILE". &FileSem): if (rc!»0) { HaltSystem("Open Sem (FileSem) ”,rc):} rc - Dos0penEventSem("\\SEM32\\P0INTlSEM".&PointlSem): if (rc’«0) { HaltSystem("Create Sem (PointlSem) ”.rc):} rc » Dos0penEventSem("\\SEM32\\P0INT2SEM".&Point2Sem); if (rc’»0) { HaltSystemC"Create Sem (Point2Sem) ".rc);} rc » Dos0penEventSem("\\SEM32\\P0INT3SEM".&Point3Sem): if (rc!»0) { HaltSystem("Create Sem (Point3Sem) ".rc):} /Проверка количества аргументов */ if (argc!»4) { HaltSystemC "Ошибка командной строки".rc):} /Инициализация переменных для записи в транспортер и файл*/ WriteHandle»atoi(argv[2]): ReadHandle=atoi(argv[l]): FileOpen»atoi(argv[3]); /*0жидание свободного транспортера*/ do { DosWaitEventSem(PipeSem.-l): rc»DosResetЕventSem(Pi peSem.&NPost): } while (rc!»0): /* Работа с транспортером*/ rc»DosRead(ReadHandle. (PV0ID)&01dInform. sizeof(Oldlnform). &BytesReaden); DosPostEventSem(PipeSem): if (rc’»0) {HaltSystemC"Read Pipe".rc):} /*Уведомление предка о завершении инициализации*/ if (01 dinform.Number=»l) { rc » DosPostEventSem(ExitSem): if (rc!»0) { HaltSystemC"PostSem (ExitSem) ".rc):} } else { do { Oldlnform.Number--: DosWaitEventSem(PipeSem.-1); rc»DosResetEventSemC Pi peSem.&NPost): } while (rc’»0); rc» DosWrite(WriteHandle.(PV0ID)&01dInform.sizeof(01dInform).&BytesWritten): DosPostEventSemCPipeSem): if (rc’»0) {HaltSystemC"Write Pipe".rc):} } /*3адание строк аргументов*/ for (i«0:i<»strlen(myword):i++ ) { Arguments ]»myword[i]: } j»i: Argument2»itoa(ReadHandle,MyBuffer.lO); for (i-0:i<»$trlen(Argument2);1++) { Argument[j+i>MyBuffer[i]: } j+»i: Arguments-1]»' ': Argument2»itoa(WriteHandle.HyBuffer.lO): for (i»0;i<»strlen(Argument2):i++) { Argument[i+J]eMyBuffer[i]; }
Текст программы В 713 j+=i; Arguments-1]=' Argument2==1 toa(FileOpen,MyBuffer. 10); for (i=0;i<=strlen(Argument2);i++) { Argument[i+j]=MyBuffer[iJ: } /*Выполнение процесса - цикл ★/ strcpy(PMessege,"Программа "); strcatCPMessege.myword); VioWrtNAttr(&atr.80*25.0.0.0); VioWrtCharStrCPMessege.sizeof(PMessege).2.27.0); strcpy(PMessege."3anymeHa в "): strcat(PMessege.Oldlnform.LaunchTime); VioWrtNAttr(&atr,80*25.0.0.0); VioWrtCharStr(PMessege.sizeofCPMessege).3,27.0): strcpy(FMessegel.myword): strcat(FMessegel." "); strcat (FMessegel.PMessege); strcpy(PMessege."Программой "); strcat(PMessege.Oldlnform.ParentName); VioWrtNAttr(&atr.80*25.0.0.0); VioWrtCharStr(PMessege,sizeof(PMessege).4.27.0); strcpy(FMessege2.myword); strcat(FMessege2." "); strcat (FMessege2.PMessege): for(i=0;i<22;i++) {VioWrtNAttr(&atr.80*25.0.0.0): VioWrtCharStrC,1.5.27+i.0): for (j=0:j<(25000*Speed):j++); }; /Получение времени загрузки программ-потомков*/ DosGetDateTi me(&Ti me); strcpy(MyTi me.i toa(Ti me.hou rs.MyBuffer.10)); strcat(MyTime.";"); strcat(MyTi me.i toa(Ti me.minutes.MyBuffer.10)); strcat(MyTime.";"); strcat(MyTime.itoaCTime.seconds.MyBuffer.10)); strcat(MyTime.”;"); strcatCMyTime.itoaCTime.hundredths.MyBuffer.10)); strcpy(NewInform.LaunchTime.MyTime); strcpy(Newlnform.ParentName.myword); Newlnform.Number»3; strcpy(PMessege,"Завершена в "); strcat(PMessege.MyTime); VioWrtNAttr(&atr.80*25.0,0.0): VioWrtCharStrCPMessege.sizeof(PMessege).6.27.0); strcpy(FMessege3.myword); strcat(FMessege3." "); strcat (FMessege3.PMessege): /*0жидание доступности записи в файл и немедленная запись в файл*/ do{ DosWaitEventSemCFileSem.-1); rc=DosResetEventSemC F i1eSem.&BytesWri tten); } while (rc!=0); DosWriteCFileOpen.(PVOID)&FMessegel.sizeof(FMessegel).&BytesWritten); DosWriteCFileOpen.(PV0ID)&FMessege2.sizeof(FMessege2),&BytesWritten); DosWriteCFileOpen.(PV0ID)&FMessege3.sizeof(FMessege3).&BytesWritten); DosPostEventSemCFileSem); /*Алгоритм прохождения точки 2*/ rc=DosPostEventSem(PointlSem); if (rc==0) { /*3апись сообщения в Pipe; имя предка и время запуска программ*/ rc= DosWri te(Wri teHandlе.(PVOID)&NewInform.sizeof(NewInform), &BytesWntten); if (rc!=0) { HaltSystemC"(DosWrite)".rc): } /*Создание семафора ожидания инициализации ресурсов потомками*/ rc » DosCreateEventSem("\\SEM32\\EXITSEM2". &ExitSem2. DCJEMJHARED. 0); if (rc!=0) { HaltSystemC"Create Sem (ExitSem2) ".rc):} /*3апуск программ-потомков*/ rc=DosExecPgm(FailFi1 eb.sizeof(FailFileb).1.Argument.0,&ResCodeb,"progr_d.exe"); if (rc!=0) {HaltSystem("(DosExecPgm)".rc);} rcsDosExecPgm(Fa i1Fi1 eb.s1zeof(Fa i1Fi1 eb).1.Argument.0.&ResCodeb."progr__e.exe"); if (rc’=0) {HaltSystem("(DosExecPgm)".rc):} rc=DosExecPgm(FailFileb.sizeof(FailFileb).1.Argument.O.&ResCodeb."progr_f.exe"):
714 Приложение Б. Тексты программ комплекса параллельных приложений if (гс!»0) {HaltSystemC"(DosExecPgm)",гс);} /*0жидание инициализации ресурсов потомками*/ DosWa i t ЕventSem(Exi tSem2, -1); DosCloseEventSem(ExitSem2); return(O);} Текст программы D #define INCLJ3ASE #define INCL_DOS #include <os2.h> #include <string.h> #include <stdio.h> #include <stdlib.h> #include "progr.h" #include "func.h" /*Глобальные переменные*/ char myword[]» "PROGR_D.EXE"; /*Главная функция*/ int mainCint argc. char *argv[], char *envp[]) {/*Локальные переменные*/ int i, j: strcpy(Word.niyword); /Инициализация семафоров*/ гс = Dos0penEventSem("\\SEM32\\PIPESEM". &PipeSem); if (rc!=0) {HaltSystem("Open Sem (PipeSem) ".rc); } rc = Dos0penEventSem("\\SEM32\\WRITEFILE". &FileSem); if (rc’=0) { HaltSystemC"Open Sem (FileSem) ",rc);} rc - Dos0penEventSem("\\SEM32\\P0INT2SEM",&Point2Sem); if (rc’=0) { HaltSystemC"Create Sem (Point2Sem) ",rc);} rc » Dos0penEventSem("\\SEM32\\P0INT3SEM".&Point3Sem); if (rc’»0) { HaltSystemC"Create Sem (Point3Sem) ".rc);} rc - Dos0penEventSem("\\SEM32\\P0INTlSEM",&PointlSem); if (rc!=0) { HaltSystemC"Create Sem (PointlSem) ",rc);} rc » Dos0penEventSem("\\SEM32\\EXITSEM2", &ExitSem2); if (rc!=0) { HaltSystemC"Create Sem (ExitSem2) ".rc);} /Проверка количества аргументов */ if (argc!=4) { HaltSystemC "Ошибка командной строки".rc);} /Инициализация переменных для записи в транспортер и файл*/ Fi 1 eOpen»atoi(argv[3]);WriteHandle=atoiCargv[2]);ReadHandle=atoi(argv[1]); /*0жидание свободного транспортера*/ do { DosWaitEventSem(PipeSem.-l); rc=DosResetEventSemC Pi peSem.&NPost); } while (rc!»0); /* Работа c Pipe каналом*/ rc=DosRead(ReadHandle. (PV0ID)&01dInform. sizeof(Oldlnform). &BytesReaden); DosPostEventSemCPipeSem); if (rc’»0) {HaltSystemC"Read Pipe".rc);}
Текст программы D 715 /*Уведомление предка о завершении инициализации*/ if (OldInform.Number==l) { rc = DosPostEventSem(ExitSem2); if (rc’=0) { HaltSystemC'PostSem (ExitSem2) ",rc);} } else { do { 01 dinform.Number--; DosWaitEventSem(PipeSem,-1): rc=DosResetEventSem(Pi peSem,&NPost): } while (rc!=0); rc= DosWrite(WriteHandle.(PV0ID)&01dInform, sizeof(Oldlnform). &BytesWritten); DosPostEventSem(Pi peSem); if (rc!s0) {HaltSystem("Write Pipe",rc);} } /*3адание строк аргументов*/ for (i=0;i<=strlen(myword);i++ ) { Argument[i]=myword[i]; } j=i; Argument2=itoa(ReadHandle,MyBuffer,10); for (i=0;i<=strlen(Argument2);i++) { Argument[j+i]=MyBuffer[i]; } j+=i; Arguments-1]=' '; Argument2=itoa(WriteHandle,MyBuffer,10); for (i=0;i<=strlen(Argument2);i++) { Arguments+J]-MyBuffer[i]; } j+=i; Arguments-1]=' Argument2=itoa(FileOpen.MyBuffer,10); for (i=0;i<=strlen(Argument2);i++) { Argument[i+j]=MyBufferCi]; } /*Выполнение процесса - цикл */ strcpy(PMessege,"Программа "); strcat(PMessege.myword): VioWrtNAttr(&atr,80*25,0,0,0); VioWrtCharStr(PMessege,sizeof(PMessege),8,54,0); strcpy(PMessege,"Запущена в "); strcat(PMessege,01dInform.LaunchTime); VioWrtNAttr(&atr,80*25,0,0,0): VioWrtCharStr(PMessege,sizeof(PMessege),9,54,0): strcpy(FMessegel.myword); strcat(FMessegel," "); strcat (FMessegel.PMessege); strcpy(PMessege,"Программой "); strcat(PMessege,01 dinform.ParentName); VioWrtNAttr(&atr,80*25,0,0,0); VioWrtCharStr(PMessege,sizeof(PMessege),10,54,0); strcpy(FMessege2,myword); strcat(FMessege2," "); strcat (FMessege2,PMessege): for(i-0:i<22;i++) {Vi oWrtNAttr(&atr,80*25,0,0,0); Vi oWrtCharStr("|",1.11,54+i.0); for (j=0;j<(15000*Speed);j++); }; ^Получение времени загрузки программ-потомков*/ DosGetDateTime(&Time); strcpy(MyTime,i toa(Ti me.hours,MyBuffer,10)); strcat(MyTime,":"); strcat(MyTime.itoa(Time.minutes,MyBuffer,10)); strcat(MyTime,";"); strcat(MyTime.itoa(Time.seconds,MyBuffer,10)); strcat(MyTime,":"); strcat(MyTime,itoa(Time.hundredths,MyBuffer,10)); strcpy(Newlnform.LaunchTime,MyTime); strcpy(NewInform.ParentName.myword); NewInform.Number=2; strcpy(PMessege,"Завершена в "); strcat(PMessege.MyTime); VioWrtNAttr(&atr,80*25,0.0,0);VioWrtCharStr(PMessege,sizeof(PMessege),12,54,0); strcpy(FMessege3,myword); strcat(FMessege3," "); strcat (FMessege3,PMessege); /*0жидание доступности записи в файл и немедленная запись в файл*/ do{ DosWaitEventSem(FileSem,-1); rc=DosResetEventSem(F i1eSem,&BytesWri tten); } while (rc!=0); DosWrite(FileOpen,(PVOID)&FMessegel,sizeof(FMessegel),&BytesWritten); DosWrite(FileOpen,(PV0ID)&FMessege2,sizeof(FMessege2),&BytesWritten);
716 Приложение Б. Тексты программ комплекса параллельных приложений DosWriteCFi 1 eOpen. (PV0ID)&FMessege3. sizeof (FMessege3) ,&BytesWri tten); DosPostEventSemCFileSem); /*Алгоритм прохождения точки 3*/ rc»DosPostEvent$em(Point2Sem); if (rc»»0) { do{ DosQueryEventSem(Point2Sem.&BytesWritten); }while (BytesWritten<=2); /*3апись сообщения в Pipe: имя предка(Progr_A) и время запуска программ*/ гс» DosWriteCWriteHandle,(PVOID)&NewInform.sizeof(NewInform).&BytesWritten); if (rc!»0) { HaltSystemC"(DosWrite)".rc); } /*Создание семафора ожидания инициализации ресурсов потомками*/ rc - DosCreateEventSem("\\SEM32\\EXITSEM3". &ExitSem3. DC_SEM_SHARED. 0); if (rc’»0) { HaltSystemC"Create Sem (ExitSem3) ".rc);} /*3апуск программ-потомков*/ rc»DosExecPgm(FaiIFileb.sizeof(FaiIFileb).1.Argument.O.&ResCodeb. "progr_g.exe"); if (rc’»0) {HaltSystemC"(DosExecPgm)".rc);} rc»DosExecPgm(FaiIFileb.sizeof(Fai1Fi1 eb).1. Argument.0.&ResCodeb."progr_h.exe"): if (rc’»0) {HaltSystemC"(DosExecPgmF.rc);} /*0жидание инициализации ресурсов потомками*/ DosWa i tEventSemC Exi tSem3.-1): DosCloseEventSem(ExitSem3): } return(O):} Текст программы G #define INCL_BASE #define INCLJXJS #include <os2.h> #include <string.h> #include <stdio.h> #include <stdlib.h> #include "progr.h" #include "func.h" /*Глобальные переменные*/ char myword[]» "PROGR_G.EXE"; /*Главная функция*/ int mainCint argc. char *argv[], char *envp[]) {/*Локальные переменные*/ int i.j; strcpy(Word.myword); /Инициализация семафоров*/ rc » Dos0penEventSem("\\SEM32\\PIPESEM". &PipeSem): if (rc’»0) {HaltSystemC"Open Sem (PipeSem) ".rc): } rc » Dos0penEventSem("\\SEM32\\EXITSEM3". &ExitSem3); if (rc!=0) { HaltSystemC"Open Sem (ExitSem3) ".rc);} rc « Dos0penEventSem(,’\\SEM32\\WRITEFILE". &FileSem); if (rc’«0) { HaltSystemC"Open Sem (FileSem) ”,rc);}
Текст программы G 717 rc = Dos0penEventSem("\\SEM32\\P0INT2SEM",&Point2Sem): if (rc!=0) { HaltSystemC"Create Sem (Point2Sem) ".rc):} rc = Dos0penEventSem("\\SEM32\\P0INT3SEM".&Point3Sem): if (rc!=0) { HaltSystemC"Create Sem (Point3Sem) ".rc):} rc = Dos0penEventSem("\\SEM32\\P0INTlSEM",&PointlSem): if (rc!=0) { HaltSystem("Create Sem (PointlSem) ".rc):} /Проверка количества аргументов */ if (argc!=4) { HaltSystemC "Ошибка командной строки".rc):} /Инициализация переменных для записи в транспортер и файл*/ WriteHandle=atoiCargv[2]):ReadHandle=atoiCargv[l]);Fi1eOpen»atoi(argv[3]); /*0жидание свободного транспортера*/ do { DosWaitEventSem(PipeSem.-l): rc=DosResetEventSem(Pi peSem.&NPost); } while (rc!=0); /* Работа c Pipe каналом*/ rc=DosRead(ReadHandle. (PV0ID)&01dInform. sizeof(Oldlnform). &BytesReaden): DosPostEventSemCPipeSem); if (rc’=0) {HaltSystemC"Read Pipe",rc):} /*Уведомление предка о завершении инициализации*/ if (01 dinform.Number==l) { rc = DosPostEventSem(ExitSem3): if (rc*=0) { HaltSystemC"PostSem (ExitSem3) ".rc):} } else { do { 01 dinform.Number--: DosWa i tEventSemC Pi peSem.-1): rc=DosResetEventSemC Pi peSem.&NPost): } while (rc!e0); rc= DosWrite(WriteHandle.(PV0ID)&01dInform. sizeof(Oldlnform).&BytesWritten): DosPostEventSemCPipeSem): if (rc!=0) {HaltSystemC"Write Pipe",rc);} } /*3адание строк аргументов*/ for (i=O;i<=strlen(myword):i++ ) { Arguments]=myword[i]: } j=i: Argument2=itoa(ReadHandle.MyBuffer.10): for Ci=0:i<=strlen(Argument2):i++) { Argument[j+i]=MyBuffer[i]: } j+=i: Argument[j-1]=' ': Argument2»itoa(WriteHandle.MyBuffer.10): for Ci=0:i<=strlen(Argument2);i++) { Arguments+j]=MyBuffer[i]; } j+=i: Arguments-1]»' ': Argument2=itoa(Fi1 eOpen.MyBuffer.10): for Ci=0:i<=strlen(Argument2);i++) { Arguments+j]=MyBuffer[i]: } /*Выполнение процесса - цикл */ strcpyCPMessege."Программа "): strcatCPMessege.myword): VioWrtNAttr(&atr,80*25.0.0,0): VioWrtCharStr(PMessege.sizeofCPMessege).14.54.0): strcpyCPMessege."Запущена в "); strcat(PMessege.Oldlnform.LaunchTime): VioWrtNAttr(&atr.80*25.0,0.0): V i oWrtCha rSt r(PMes sege,s i zeof(PMes sege),15.54.0); strcpyCFMessegel.myword): strcat(FMessegel," "): strcat (FMessegel.PMessege): strcpyCPMessege."Программой "); strcatCPMessege.Oldlnform.ParentName): V i oWrtNAtt r(&at r,80*25,0,0,0): V i oWrtCha rSt r(PMes s ege.s i zeof(PMes sege).16.54.0):
718 Приложение Б. Тексты программ комплекса параллельных приложений strcpy(FMessege2,myword), strcat(FMessege2." "). strcat (FMessege2.PMessege). for(i=0,i<22.i++) { Vi oWrtNAttr(&atr.80*25.0.0.0).VioWrtCharStr(.1,17.54+1,0). for (j-0.j<(25000*Speed).j++). }. /Получение времени загрузки программ-потомков*/ DosGetDateTime(&Time), strcpy(MyTime,itoa(Time hours,MyBuffer.10)). strcat(MyTime." "). strcat(MyTime.itoa(Time minutes.MyBuffer.10)). strcat(MyTime." "). strcat(MyTime.itoa(Time seconds.MyBuffer.10)). strcat(MyTime," "). strcat(MyTime.itoa(Time hundredths.MyBuffer.10)). strcpy(NewInform LaunchTime.MyTime). strcpy(New!nform ParentName.myword). Newlnform Number-1. strcpy(PMessege,"Завершена в "). strcat(PMessege,MyTime), VioWrtNAttr(&atr.80*25.0,0,0). VioWrtCharStr(PMessege.sizeof(PMessege),18 54,0), strcpy(FMessege3.myword). strcat(FMessege3," "), strcat (FMessege3,PMessege). /*0жидание доступности записи в файл и немедленная запись в файл*/ do{ DosWaitEventSem(FileSem.-l). rc=DosResetEventSem(Fi1eSem,&BytesWritten). } while (rc’-O), DosWri te(Fi1 eOpen.(PVOID)&FMessegel.sizeof(FMessegeD.&BytesWri tten). DosWrite(File0pen.(PV0ID)&FMessege2.sizeof(FMessege2) &BytesWritten) DosWrite(FileOpen.(PV0ID)&FMessege3.sizeof(FMessege3),&BytesWritten), DosPostEventSem(Fi1eSem). /*Алгоритм прохождения точки 4*/ do { DosWaitEventSem(PointlSem.-l). rc-DosResetEventSem(PointISem,&BytesReaden). } while (гс|я0), DosPostEventSem(Point3Sem). DosQueryEventSem(Point3Sem.&BytesWritten), DosPostEventSem(PointlSem). if (Bytes Written—4) { /*3апись сообщения в Pipe имя предка(Progr_A) и время запуска программ*/ rc= DosWrite(WriteHandle,(PVOID)&NewInform.sizeof(NewInform),&BytesWritten). if (rc’-O) { HaltSystem("(DosWrite)".rc). } /*Создание семафора ожидания инициализации ресурсов потомками*/ гс - DosCreateEventSem("\\SEM32\\EXITSEM4". &ExitSem4. DC_SEM_SHARED. 0), if (rc'-O) { HaltSystem("Create Sem (ExitSem4) ".rc),} /*3апуск программ-потомков*/ rc-DosExecPgm(FailFileb.sizeof(FailFileb),l. Argument.0,&ResCodeb."progr_k exe"). if (rc!=0) {HaltSystem("(DosExecPgm)".rc).} /*0жидание инициализации ресурсов потомками*/ DosWaitEventSem(ExitSem4.-1). DosCloseEventSem(ExitSem4). } return(0),}
Список литературы 1. Абрамова Н. А. и др. Новый математический аппарат для анализа внешнего поведения и верификации программ — М.: Институт проблем управления РАН, 1998. - 109 с. 2. Александров Е. К., Рудня Ю. Л. Микропроцессор 80386: как он работает и как работают с ним: Учебное пособие / Под ред. проф. Д. В. Пузанкова. — С-Пб.: Элмор, 1994. — 274 с. 3. Эпплман Д. Win32 API и Visual Basic. — СПб.: Питер, 2001. 4. Кэнту М. Delphi 5 для профессионалов. — СПб.: Питер, 2001. 5. Афанасьев А. Н. Формальные языки и грамматики: Учебное пособие. — Улья- новск: УлГТУ, 1997. — 84 с. 6. Ахо А., Ульман Дж. Теория синтаксического анализа, перевода и компиля- ции. — М.: Мир, 1978. — т.1, 612 с. — т.2, 487 с. 7. Бартеньев О. В. Фортран для студентов. — М.: Диалог-МИФИ, 1999. — 342 с. 8. Павловская Т. А. C/C++: Учебник. — СПб.: Питер, 2001. 9. Богумирский Б. С. Руководство пользователя ПЭВМ: В 2-х ч. — С-Пб.: Ассо- циация OILCO, 1992. — 357 с. 10. Бранденбау Дж. JavaScript: сборник рецептов для профессионалов. — СПб.: Питер, 2000. — 416 с. И. Браун С. Операционная система UNIX. — М.: Мир, 1986. — 463 с. 12. Бржезовский А. В., Корсакова Н. В., Фильчаков В. В. Лексический и синтак- сический анализ. Формальные языки и грамматики. — Л.: ЛИАП, 1990. — 31 с. 13. Бржезовский А. В., Фильчаков В. В. Концептуальный анализ вычислительных систем. — СПб.: ЛИАП, 1991. — 78 с. 14. Вирт Н. Алгоритмы и структуры данных. — М.: Мир, 1989. — 360 с. 15. Волкова И. А., Руденко Т. В. Формальные языки и грамматики. Элементы тео- рии трансляции. — М.: Диалог-МГУ, 1999. — 62 с. 16. Гончаров А. Самоучитель HTML. — СПб.: Питер, 2000. — 240 с.
720 Список литературы 17. Гордеев А. В., Молчанов А. Ю. Применение сетей Петри для анализа вычисли- тельных процессов и проектирования вычислительных систем: Учебное посо- бие. - Л.: ЛИАП, 1993. - 80 с. 18. Гордеев А. В., Никитин А. В., Фильчаков В. В, Организация пакетов приклад- ных программ: Учебное пособие. — Л.: ЛИАП, 1988. — 78 с. 19. Гордеев А. В., Кучин Н. В. Проектирование взаимодействующих процессов в операционных системах: Учебное пособие. — Л.: ЛИАП, 1991. — 72 с. 20. Гордеев A. В., Решетникова Н. H.t Соловьев А. П. Дисковая операционная сис- тема реального времени. — СПб.: ГААП, 1994. — 44 с. 21. Гордеев А. В., Штепен В. А. Управление процессами в операционных системах реального времени: Учебное пособие. — Л.: ЛИАП, 1988. — 76 с. 22. Григорьев В. Л. Микропроцессор i486. Архитектура и программирование (в 4-х кн.). — М.: Гранал, 1993. 23. Грис Д. Конструирование компиляторов для цифровых вычислительных ма- шин. — М.: Мир, 1975. — 544 с. 24. Гудмэн Дж. Секреты жесткого диска. — Киев: Диалектика, 1994. — 256 с. 25. Д. Ван Тассел. Стиль, разработка, эффективность, отладка и испытание про- грамм. — М.: Мир, 1985. — 332 с. 26. Дворянкин А. И. Основы трансляции: Учебное пособие. — Волгоград: ВолгГТУ, 1999. - 80 с. 27. Дейкстра Е. Взаимодействующие последовательные процессы//Языки про- граммирования (под ред. Ф. Женюи). — М.: Мир, 1972. 28. Дейтел Г Введение в операционные системы. В двух томах/Пер. с англ. Л. А. Теп- лицкого, А. Б. Ходулева, В. С. Штаркмана под ред. В. С. Штаркмана. — М.: Мир, 1987. 29. Джордейн Р. Справочник программиста персональных компьютеров типа IBM PC, XT и АТ/Пер. с англ. — М.: Финансы и статистика, 1991. — 544 с. 30. Донован Дж. Системное программирование. — М.: Мир, 1975. — 540 с. 31. Дунаев С. UNIX System V. Release 4.2. Общее руководство. — М.: Диалог-МИ- ФИ, 1995. - 287 с. 32. Жаков В. И., Коровинский В. В., Фильчаков В. В. Синтаксический анализ и ге- нерация кода. — СПб.: ГААП, 1993. — 26 с. 33. Жаков В. И., Корсакова Н. В., Никитин A. В., Фильчаков В. В. Структуры дан- ных: Учебное пособие. — Л.: ЛИАП, 1989. — 76 с. 34. Немнюгин С., Перколаб Л. Изучаем Turbo Pascal. — СПб.: Питер, 2000. 35. Калверт Ч. Delphi 4. Энциклопедия пользователя. — Киев: ДиаСофт, 1998. 36. Кастер X. Основы Windows NT и NTFS /Пер. с англ. — М.: Изд. отдел «Рус- ская редакция» ТОО «Channel Trading Ltd.», 1996. — 440 с. 37. Кейлингерт П. Элементы операционных систем. Введение для пользователей/ Пер. с англ. Б. Л. Лисса и С. П. Тресковой. — М.: Мир, 1985. — 295 с.
Список литературы 721 38. Кейслер С. Проектирование операционных систем дня малых ЭВМ. — М.: Мир, 1986. - 680 с. 39. Керншап Б., Пайк Р. UNIX — универсальная среда программирования. — М.: Финансы и статистика, 1992. — 420 с. 40. Клочко В. И. Теория вычислительных процессов и структур: Учебное посо- бие. — Краснодар: Изд-во КубГТУ, 1999. — 118 с. 41. Коваленко И. Н. QNX: Золушка в семье UNIX. — http://www.lgg.ru/~nigl/QNX/ doc/Kovalenko_cinderella.html, 1995. 42. Компаниец Р. И., Маньков Е. В., Филатов Н. Е. Системное программирование. Основы построения трансляторов/Учебное пособие для высших и средних учебных заведений. — СПб.: КОРОНА принт, 2000. — 256 с. 43. Концептуальное моделирование информационных систем/Под ред. В. В. Филь- чакова. - СПб.: СПВУРЭ ПВО, 1998. - 356 с. 44. Краковяк С. Основы организации и функционирования ОС ЭВМ. — М.: Мир, 1988. - 480 с. 45. Льюис Ф. и др. Теоретические основы построения компиляторов. — М.? Мир, 1979. - 483 с. 46. Майерс Дж. Надежность программного обеспечения. — М.: Мир, 1987. — 360 с. 47. Мельников Б. Ф. Подклассы класса контекстно-свободных языков. — М.: Изд- во МГУ, 1995. - 174 с. 48. Микропроцессоры 80x86, Pentium: Архитектура, функционирование, програм- мирование, оптимизация кода/В. М. Михальчук, А. А. Ровдо, С. В. Рыжи- ков. — Мн.: Битрикс, 1994. — 400 с. 49. Мурата Т. Сети Петри: Свойства, анализ, приложения (обзор) // ТИИЭР, 1989, № 4. С. 41-85. 50. Мэдник С., Донован Дж. Операционные системы. — М.: Мир, 1978. — 792 с. 51. Непомнящий В. А., Рякин О. М. Прикладные методы верификации программ/ Под ред. А. П. Ершова. — М.: Радио и связь, 1988. — 256 с. 52. Нортон П. Персональный компьютер фирмы IBM и операционная система MS-DOS /Пер. с англ. — М.: Радио и связь, 1992. — 416 с. 53. Нортон П., Гудмен Дж. Внутренний мир персональных компьютеров. Изд. 8-е. Избранное от Питера Нортона /Пер. с англ. — К.: Диасофт, 1999. — 584 с. 54. Обухов И. QNX: Как надо делать операционные системы / PC Week RE № 7, 1998. С. 58-59. 55. Озеров В. Советы по Дельфи (Версия 1.3.1 от 1.07.2000). — http://www. webmachine.ru/delphi. 56. Олифер Н. А., Олифер В. Г. Сетевые операционные системы. — СПб.: Питер, 2001. 57. Операционная система СМ ЭВМ РАФОС: Справочник /Под ред. В. П. Се- мика. — М.: Финансы и статистика, 1984. — 207 с.
722 Список литературы 58. Операционные системы — от PC до PS/2 / Ж. Фодор, Д. Бонифас, Ж. Танги. Пер. с франц. — М.: Мир, 1992. — 319 с. 59. Орловский Г. В. Введение в архитектуру микропроцессора 80386. — СПб: Се- анс-Пресс Ltd, Инфокон, 1992. — 240 с. 60. ОС QNX: Обзор системы. — http://www.lgg.ru/~nigl/QNX/doc/about_qnx.html. 61. Red Hat Linux 6.2/Под ред. А. Пасечника — СПб.: Питер, 2000. 62. Петзолд Ч. Программирование для Windows 95 /Пер. с англ. — СПб.: BHV, 1997. В 2-х т. 63. Петруцос Э., Хау К. Visual Basic 6 и VBA. — СПб. и др.: Питер, 2000. — 425 с. 64. Питерсон Дж. Теория сетей Петри и моделирование систем/Пер. с англ. — М.: Мир, 1984. - 264 с. 65. Полетаева И. А. Методы трансляции: Конспект лекций. — Новосибирск: Изд- во НГТУ, 1998. - Ч. 2. - 51 с. 66. Пратт Т., Зелковиц М. Языки программирования: реализация и разработка. — СПб.: Питер, 2001. 67. Рассел Ч., Кроуфорд Ш. UNIX и Linux: книга ответов. — СПб.: Питер, 1999. — 297 с. 68. Рейчард К., Фостер-Джонсон Э. UNIX: справочник. — СПб.: Питер, 2000. — 384 с. 69. Ресурсы Microsoft Windows NT Workstation 4.0 /Пер. с англ. — СПб.: BHV, 1998. - 800 с. 70. Робачевский А. М. Операционная система UNIX. — СПб.: BHV, 1997. — 528 с. 71. Рогаткин Д., Федоров A. Borland Pascal в среде Windows. — Киев: Диалектика, 1993.- 511 с. 72. Рудаков П. И., Федотов М. А. Основы языка Pascal: Учебный курс. — М.: Ра- дио и связь: Горячая линия — Телеком, 2000. — 205 с. 73. Рудаков П. И., Финогенов К. Г. Программируем на языке ассемблера IBM PC. Ч. 3. Защищенный режим. — М.: Энтроп, 1996. — 320 с. 74. Серебряков В. И. Лекции по конструированию компиляторов. — М.: МГУ, 1997. - 171 с. 75. Соловьев Г. Н., Никитин В. Д. Операционные системы ЭВМ: Учебное посо- бие. — М.: Высшая школа, 1989. — 255 с. 76. Страуструп Б. Язык программирования Си++. — М.: Радио и связь, 1991. — 348 с. 77. Стрыгин В. В., Щарев Л. С. Основы вычислительной, микропроцессорной тех- ники и программирования. — М.: Высшая школа, 1989. — 478 с. 78. Студнев А. Boot-менеджеры — кто они и откуда?//Ву!е Россия, 1998, № 4. С. 70-75. 79. Тревеннор А. Операционные системы малых ЭВМ /Пер. с англ. А. Г. Василье- ва. — М.: Финансы и статистика, 1987. — 188 с. 80. Уинер Р. Язык Турбо С. — М.: Мир, 1991. — 380 с.
Список литературы 723 81. Уокерли Дж. Архитектура и программирование микро-ЭВМ. — М.: Мир, 1984. - 486 с. 82. Федоров В. В. Основы построения трансляторов: Учебное пособие. — Обнинск: ИАТЭ, 1995. - 105 с. 83. Финогенов К. Г. Основы языка ассемблера. — М.: Радио и связь, 1999. — 288 с. 84. Фролов А. В., Фролов Г. В. Защищенный режим процессоров Intel 80286, 80386, 80486. Практическое руководство по использованию защищенного режима. — М.: Диалог-МИФИ, 1993. - 240 с. 85. Фролов А. В., Фролов Г. В. Операционная система OS/2 Warp. —М.: Диалог- МИФИ (Библиотека системного программиста; т. 20), 1995. — 272 с. 86. Фролов А. В., Фролов Г. В. Программирование для IBM OS/2 Warp: 4.1. — М.: Диалог-МИФИ, 1996. - 288 с. 87. Фролов А. В., Фролов Г. В. Программирование для Windows NT. — М.: Диа- лог-МИФИ (Библиотека системного программиста; т. 26, 27), 1996. 88. Хоар Ч. Взаимодействующие последовательные процессы. — М.: Мир, 1989. — 264 с. 89. Цикритзис Д., Бернстайн Ф. Операционные системы /Пер. с англ. В. Л. Уш- ковой и Н. Б. Фейгельсон. — М.: Мир, 1977. — 336 с. 90. Чернышов А. В. Инструментальные средства программирования из состава ОС UNIX и их применение в повседневной практике. — М.: Изд-во МГУП, 1999. - 191 с. 91. Шапошников И. В. Интернет-программирование. — СПб.: БХВ — Санкт-Пе- тербург, 2000. — 214 с. 92. Шоу А. Логическое проектирование операционных систем /Пер. с англ. В. В. Ма- карова и В. Д. Никитина. — М.: Мир, 1981. — 360 с. 93. Юров В. Assembler: Учебник. — СПб. и др.: Питер, 2000. — 622 с. 94. Ющенко С. В. ОС QNX — реальное время, реальные возможности /Мир ПК, № 5-6, 1995. 95. Windows 2000 для системного администратора. Microsoft Windows 2000: Ser- ver и Professional. Русские версии / Под общ. ред. А. Н. Чекмарева и Д. Б. Виш- някова. — СПб.: BHV, 2000. — 1056 с. 96. OS/2 Warp изнутри. В 2-х томах. / М. Минаси, Б. Камарда и др. Пер. с англ. С. Сокорновой. — С-Пб.: Питер, 1996. Т. 1. — 528 с. Т. 2 — 512 с. 97. Understanding Windows NT POSIX Compatibility by Ray Cort Microsoft Corporate Technology Team, Created: May/June 1993. 98. http://www.borland.ru/. 99. http://www.corba.ru/. 100. http://www.gnu.org/. 101. http://java.sun.com/. 102. http://www.linux.zp.ua:8100/philosophy/philosophy.ru.html. 103. http://www.microsoft.com/RUS/lnternet/intranets.html.
724 Список литературы 104. http://www.microsoft.com/RUS/mssol99/prod/dev/vc.htm. 105. http://www.microsoft.com/RUS/mssol99/prod/dev/vb.htm. 106. http://www.microsoft.com/rus/net/. 107. http://www.perl.org/. 108. http://www.php.net/.
Алфавитный указатель А ACL (access control list, список прав доступа), 170 API (application program interface), 207, 658, 667 Application server, 671 ASP, 691 c CGI-приложение, 690 CPU throughput (пропускная способность процессора), 61 utilization, 61 CS (critical section), критическая секция, 226 D DPB (disk parameter block, блок параметров диска), 160 E EAs (extended attributes, расширенные атрибуты), 167 EGID (effective group ID), 314 EPL (effective privilege level), 115 EUID (effective user ID), 314 F FAT (file allocation table), 156 FCFS (first come — first served), 55 FixPak — пакет исправлений и обновлений, 327 FSGID (file system access group ID), 314 FSUID (file system access user ID), 314 G GDT (global descriptor table), 105 GDTR (global descriptor table), 103 GID (group identifier), идентификатор группы, 305 н HPFS (high performance file system), 167 I IDT (interrupt descriptor table), 123 IDTR (interrupt descriptor table register), 120 IEEE, 213 IFS (installable file system), 174, 178 Internet, 671, 681, 685, 687, 688, 695 INTR (interrupt request, запрос на прерывание), 121 IRQ (interrupt request, запрос на прерывание), 121 J JCL (job control language), 56 JFS (journaling file system), 333 L LDT (local descriptor table), 105 LFU (least frequently used), 79 LRU (least recently used), 79 LVM (logical volume manager), 333 M MFT (master file table), 181 Middleware, 671 MPE (MultiProcess Executing), 214 p PDE (page directory entry), 109 PHP, 691 PID — Process Idcntificator, 31 POSIX, 179, 213 PTE (page table entry), 109
726 Алфавитный указатель R RAD (Rapid Application Development), 659, 675, 678 request (запрос), 252 release (освободить), 239, 252 RPC (remote procedure call), 205, 323 RPL (requested privilege level), 106, 114 RR (round robin), 57 RTL (run time library), 207, 209 s secondary MBR — SMBR, 153 SJN (shortest job next), 56 SOM (system object model), 329 SRT (shortest remaining time), 56 SSTF (shortest seek time — first), 145 super-FAT, 147 system software, 17 T TSS (task state segment), 102 и UCB (unit control block), 136 UID (user identifier) — идентификатор пользователя, 305 V VDM-машина, 195 Virtual Memory Manager (VMM) — диспетчер памяти, 97 VMS (virtual memory system), 214 w wait (ожидать), 239, 252 Web-страница, 678, 688, 691 динамическая, 689, 691, 693, 695 WinAPI, 213 WPS (workplace shell), 331 A Аванцепочка, 475 аварийное замещение (секторов), 173 Адрес абсолютный, 597, 663 виртуальный, 68 возврата, 604, 606 линейный, 107 относительный, 597, 602, 663 активная сессия (foreground session), 177 активное ожидание, 235 Алгоритм к-предсказывающий, 472 банкира Дейкстры, 289 Деккера, 231 Кока—Янгера—Касами, 456 лексического анализатора, 570 метода цепочек, 561 преобразования в дерево операций, 580 в обратную польскую запись, 621 в объектный код, 624 в триады, 628 размещения элемента с рехэшированием, 558 распознавателя LL(1/грамматик, 475, 485 кк(к)-грамматик, 475 кР(к)-грамматик, 491 рекурсивного спуска, 463, 465 с подбором альтернатив, 445, 459, 463, 472, 474 сдвиг-свертка, 450, 452, 459, 486, 490, 492, 494, 497, 501,505,511,515 устранения левой рекурсии, 438 Эрли, 457 Алфавит, 348, 355, 377, 391, 397, 404-405, 409,411,418 магазинных символов, 418 Архитектура вычислительной системы, 648, 663, 665, 681, 687 клиент—сервер, 669-670, 675, 678, 680, 689, 692, 694 трехзвенная, 671 трехуровневая, 670—671, 675, 679, 680, 689, 692, 694 Ассемблер, 542, 614, 632 атрибуты файла, 165, 184 Б Базовый тип данных, 597 Базы данных, 669, 675, 678 безопасное состояние, 285 Библиотека времени выполнения, 207, 209 подпрограмм и функций, 596, 612, 655, 662, 665, 677 CLX, 686 MFC, 679, 680 VBRun, 678 VCL, 675, 677, 686 Xlib, 684
Алфавитный указатель 727 Библиотека (продолжение) динамически загружаемая, 612, 663, 666-667, 669, 678, 690 языка, 544, 627, 665-666 Borland Pascal, 673 бинарное дерево (B-tree), 171, 184 бит обращения, 80, 109 присутствия, 77, 109 блокировка памяти, 227 браузер (browser), 671, 689, 695 буфер сообщений, 253 в вектор прерывания, 119 взаимное исключение, 225 взаимодействующие процессы (cooperating processes), 222 виртуальная машина, 194 виртуальное адресное пространство, 68 внесистемный загрузчик (non-system bootstrap), 149 Внутреннее представление программы, 536, 579, 581, 589, 591, 596, 612, 615, 619, 623, 632, 637 обратная польская запись, 541, 615, 619, 620 тетрады, 615-617, 619 триады, 615, 618-619, 628, 637, 639 время ожидания (rotational latency), 167 Вызов fork, 307 inline-функции, 644 удаленных процедур, 323 Вычисление выражений в обратной польской записи, 620 вычислительные процессы взаимодействующие, 222 независимые, 222 параллельные, 222 последовательные, 24 г Генератор языка, 537 Генерация кода, 537, 548, 597, 612, 614, 623, 627, 630 Гипертекст, 688 главная загрузочная запись (master boot record), 149 глобальное адресное пространство, 105 Грамматика, 353, 372, 539, 613 арифметических выражений, 372, 374, 432, 439, 445, 452, 479, 502, 512, 581-582, 621 без скобок, 468 десятичных чисел с плавающей точкой, 406 контекстно-зависимая, 361, 366 контекстно-свободная, 362, 365-366, 420, 423, 429, 449, 455, 457, 459, 463, 465, 468, 473, 519, 578-579, 582 LL(k), 472-475, 479, 488, 521, 523 LR(k), 487, 489, 492, 494, 497, 508, 521, 523 в нормальной форме Грейбах, 440 в нормальной форме Хомского, 433-434, 437, 456 левоанализируемая, 522 леворекурсивная, 438-440, 443, 474 нелеворекурсивная, 445 неоднозначная, 518 ограниченного правого контекста, 521 однозначная, 458, 473, 488, 499, 509 операторная, 508 операторного предшествования, 498, 509, 512, 518, 622 пополненная, 489, 492, 494 правоанализируемая, 522 праворекурсивная, 438 преобразование, 426, 434-435, 438, 442, 450, 479, 496 приведенная, 427, 434-435, 437 простого предшествования, 498, 508, 518 расширенного предшествования, 498 слабого предшествования, 498 смешанной стратегии предшествования, 498 леволинейная, 362, 366, 387-388, 403-406, 409,410, 571 автоматная, 387-388, 409-410 неоднозначная, 373, 376 неукорачивающая, 361, 366 однозначная, 373-374, 414 праволинейная, 362, 366, 387, 403 автоматная, 387 регулярная, 362, 366, 387-388, 390, 403-404, 408, 410, 414, 476, 544, 568, 571, 578, 689
728 Алфавитный указатель Грамматика (продолжение) формальная, 354, 361, 382 целочисленных констант для языка С, 571 целых десятичных чисел, 355-356, 365, 368 языка программирования, 354 д дедлок (deadlock — смертельное объятие), 269 Дерево вывода, 370, 372, 448, 453, 472, 483-484, 487, 506-507, 516-517, 579-581, 615 операций, 580-581, 614, 623, 626, 628 дескриптор задачи, 33, 106 прерывания, 123 процесса, 29, 31 сегмента, 77, 104 страницы, 83, 109 шлюза, 116 диспетчер виртуальной памяти, 97 Дисплей памяти процедуры (функции), 603 динамический, 604, 606 статический, 604 стековая организация, 604, 606-607, 609 дисциплина обслуживания, 28 Дополнение программы неявными функциями, 589, 591 дорожка (track), 148 ж Жизненный цикл программного обеспечения, 655, 659 журналирование файловых операций, 179 3 Заголовочные файлы, 666 загрузочная запись (boot record), 156 Загрузчик, 656, 658, 663 задача (task), 24, 34, 51, 97 диск-резидептпая, 32 ОЗУ-резидентная, 33 запрос на ввод/вывод, 132 зарезервированная память (reserved memory), 98 зона с каталогами (directory band), 171 Идентификация лексических элементов, 537, 544, 594-595, 665 Инвариантные операнды, 642 Индуктивная переменная, 646 инициативное устройство, 30, 132 Интегрированная среда разработки, 657, 660, 664, 674, 676, 684-685 Интернет, 542 Интернет-клиент, 687-688, 692 Интерпст-программирование, 688-689 Интернет-сервер, 687-688, 691 Интерпретатор, 532, 535, 540-541, 620, 678, 681, 683, 687-688, 691, 693 интерфейс CGI, 690 ISAPI, 690 обмена данными, 670, 671 пользовательский, 23, 657, 665, 671 графический, 657, 659, 684, 688 па основе среды X Window, 684 прикладного программирования, 207 программный, 23 Информация о типах во время выполнения, 610 исключение, 130 лишних операций для линейного участка, 639 Исходная программа, 530, 532, 536-537, 540, 545, 547, 566, 568, 576, 578, 580, 589, 597, 600, 610, 612, 614, 616, 630, 633, 660, 668, 675, 687 Итерация цепочки, 348, 397, 424 к квант времени (timeslice), 57 кластер, 156 Клиентская часть приложения, 670—671, 676, 678, 680 Коллизия, 556, 562-563 Команда BTS (BTR,BTC), 234 языка ассемблера, 543 командные файлы (shell scripts), 305 Командный язык Makefile, 656-657, 662, 683 коммутатор задачи (task gate), 124, 126 перехвата (trap gate), 124 прерывания (interrupt gate), 124
Алфавитный указатель 729 Компилятор, 345, 353, 382, 454, 521, 524, 531, 534-535, 539, 541, 546, 549, 554, 560, 563-564, 569-570, 576-577, 579-581, 588, 591, 594, 596-597, 600-607, 609-615, 619-620, 623, 627, 630-633, 635, 641-642, 646-649, 655, 657-658, 661, 663, 666, 668, 676, 683, 687-688, 691, 693 генерация предупреждений, 593, 614 двухпроходпый, 540 многопроходный, 540 однопроходный, 540, 673 ресурсов, 658, 662, 673, 684 с языка ассемблера, 544, 546 Компонентная модель, 675 Компоновщик, 655, 658, 662, 664, 666, 683, 691 Конечный автомат (КА), 382, 391, 403, 407-409,411,568, 570, 576 граф переходов, 392, 395, 412 детерминированный, 392-393, 395, 412, 572 полностью определенный, 572 минимизация, 395 недетерминированный, 392, 412 полностью определенный, 391 Конкатенация цепочек, 348, 397, 424, 614, 623 конкурирующие процессы, 223 контекст задачи, 31 концепция .NET, 681-682 концепция виртуальных задач, 343 корневой каталог (root directory), 156 коэффициент мультипрограммирования, 73 критическая секция, 226 л Лексема (лексическая единица), 351, 382, 537, 564, 579, 589, 596-597, 599-600, 668 выделение границ, 568 Лексический анализ, 382, 537, 544, 548, 564, 566, 568, 570, 576, 579, 595 на лету, 668, 674 непрямой, 569 параллельный, 566, 568 последовательный, 566, 568 прямой, 569 ленивая запись (lazy write), 143, 175 Линейный участок, 645 программы, 634 Лишняя операция, 635 логический диск, 149 локальное адресное пространство, 102, 105 м Макрогенератор, 546 Макрогеперация, 545 Макрокоманда, 545-548 Макроопределение, 545-547 с параметрами, 546 сложное, 547 Макроподстановка, 545 Макропроцессор, 546 Макрорасширение, 545 Матрица предшествования, 504, 508, 510-511,514,518 Менеджер загрузки, 149, 154 памяти, 603, 673, 693 Метасимвол грамматики, 358, 468 метафайл, 180 Метод рекурсивного спуска, 464-465, 467, 473 цепочек, 561, 563 механизм шлюзования, 115 микроядро, 199 Мнемокод, 533, 542 Многоадресный код с неявно именуемым результатом, 615, 617 с явно именуемым результатом, 615-616 многопоточность, 34 многопоточный (multi-threaded), 203 Множество FlRST(l), 476-477, 479, 482 FIRST(k), 474-475 FOLLOW(l), 476, 478, 480, 482 FOLLOW(k), 474-475 крайних левых символов, 500, 503, 510, 512 крайних левых терминальных символов, 510, 513 крайних правых символов, 500, 503, 510,512 крайних правых терминальных символов, 510, 513 модель клиент-сервер, 201 Холта, 270
730 Алфавитный указатель модуль, 191 монитор Хоара, 250 МП-автомат, 381, 418, 441, 443, 455, 463, 467, 472, 475-476, 482, 486, 522, 588 детерминированный, 381, 423, 522 недетерминированный, 441, 444, 449 расширенный, 420, 449-450, 492, 494, 497, 501,511,516, 522 МП-преобразователь, 578 мультипрограммирование, 27 мьютекс (mutex), 242 н независимые процессы (independing processes), 222 ненадежное состояние, 289 О Область видимости, 595 памяти, 597, 600, 602, 604, 606, 663, 693 выравнивание границ, 599 Обнаружение и локализация ошибок, 521, 536, 614, 657, 662 оболочка (командный интерпретатор, shell), 304-305 Объектная программа, 531, 612, 616, 624, 631-632, 644 Объектный код, 541, 580, 616, 619, 623, 627, 631, 635-636, 641-642, 649, 665, 687, 691 файл, 531, 656, 662, 665, 673, 676, 691 оверлейная структура, 71 операционная система, 17 среда, 22, 112 Оптимизация кода, 538, 540, 544, 616, 620, 627, 630-631, 633 для вызовов процедур и функций, 633, 643 для линейных участков программы, 633 арифметические преобразования, 636 исключение лишних операций, 635 перестановка операций, 636 свертка объектного кода, 635, 637 удаление бесполезных присваиваний, 634 для логических выражений, 633, 641 для параллельного выполнения операций, 649 Оптимизация кода (продолжение) для циклов, 633, 645 вынесение инвариантных вычислений, 645 замена операций с индуктивными переменными, 645-646 слияние и развертывание циклов, 645, 647 критерии оптимизации, 631 машинно-зависимая, 632, 633, 648 машинно-независимая, 632-633 Оранжевая книга, 197 ОС UNIX, 577, 582, 682 ОСРВ (операционная система реального времени), 49 QNX, 201,214, 334 Отладка программы, 596, 633, 664 Отладчик, 656, 664-665 Отношения предшествования, 498-499, 501,509,511 очередь сообщений (queue), 257, 320 ошибка страницы (page fault), 97 п Память выделеная, 98 глобальная, 600, 603-604 динамическая, 544, 602, 604, 606, 611, 673, 693 выделяемая компилятором, 603 выделяемая пользователем, 602 локальная, 595, 600, 603-604 статическая, 544, 602, 611 папка (folder), 178 программный модуль, привилегированный, 45 пассивное ожидание, 236 перенаправление ввода/вывода, 310 плоская модель памяти, 92, 111 Подготовка к генерации кода, 537, 613 подкачка (paging), 98 Позднее связывание, 610 пользовательская маска (файла), 316 поток (stream), 182, 310 (thread), 33 потомство вершины, 295 почтовый ящик, 254 правила использования шлюзов, 118
Алфавитный указатель 731 Правило грамматики (продукция), 354-355, 357, 361, 376, 388, 390, 409-411, 428, 434, 438, 464, 468, 473, 483, 498, 510, 579, 622 лямбда-правила (пустые), 389, 427, 429, 434, 450, 452, 464, 479, 508 цепные правила, 389, 427, 431-432, 434, 450, 452, 516 использования шлюзов, 117 Преобразования типов, 592 Препроцессор языка, 547 прерывание, 37, 119 внешнее (асинхронное), 39, 121 внутреннее (синхронное), 40, 120 маскируемое, 41, 121 немаскируемое, 121 программное, 40 принцип иерархического выделения ресурсов, 288 Проблема преобразования грамматик, 367, 375, 426, 459, 488, 499, 509 эквивалентности грамматик, 375, 414, 426, 442, 473, 488 пробуксовка, 84 Программа LEX, 577, 582, 683 YACC, 582, 683 абсолютная двоичная, 69 нативная, 23, 112 резидентная, 74 программный канал (pipe), 255, 319 модуль повторно входимый, 47 реентерабельный, 46 системный, 23 транзитный, 192 Проект GNU, 684 производитель — потребитель (produces — consumer), 223 Промежуточный код компиляции, 542, 681, 688, 693 Протокол обмена данными HTTP, 688, 690 Проход компиляции, 539, 616 процедура начальной загрузки (bootstrap loader), 154 процесс (process), 24, 33, 97 внешний, 141 внутренний, 141 легковесный, 33 пул, 225 нерезидентный (paged pool), 98 памяти (memory pool), 98 резидентный (nonpaged pool), 98 пучок (в ориентированном графе), 295 р рабочее множество, 85 раздел (partition), 149 активный, 149 первичный (primary partition), 149 расширенный (extended partition), 149 Распределение памяти, 544 Регулярное множество, 397 режим супервизора, ИЗ Распознаватель, 350, 363, 377, 382, 413, 418, 431, 433, 438, 458, 537, 576, 578-579 582, 588, 622 для языка ассемблера, 544 линейный, 381, 459, 463, 465, 471, 520 LL(1)-грамматики, 482, 494 ЬЬ(к)-грамматики, 475 ЬК(0)-грамматики, 494 ЬИ(к)-грамматики, 490 восходящий левосторонний, 486, 496, 505, 520 нисходящий левосторонний, 474, 496, 520 операторного предшествования, 509, 518 простого предшествования, 505 с возвратом, 441 восходящий, 449 нисходящий, 443-444, 446 табличный, 455-456, 458 управляющая таблица, 475, 490, 492, 494 Распределение памяти, 537, 597-598, 600, 601 расширенные атрибуты, 167 регистр задачи (task register), 33, 106 Регистры процессора адресация параметров, 606 базовый регистр, 607 обозначение, 543 общего назначения, 648 передача параметров, 610, 643 распределение, 649 регистр стека, 607 регистр флагов, 120
732 Алфавитный указатель Регулярное выражение, 398-399, 403-405, 407 множество, 399, 403-404, 407, 413 Редактор ресурсов, 658 связей, 662 редукция графа повторно используемых ресурсов, 292 режим виртуальный, 111 защищенный (protected mode), 101 ленивой (отложенной) записи, 143, 175 мультизадачный, 26, 48 мультипрограммный, 26, 48 обмена с прерываниями, 133-134 опроса готовности, 133-134 пользователя, ИЗ, 130 привилегированный, 27 супервизора, 130 Результирующая программа, 521, 530-532 Рекурсивный вызов процедур и функций, 605-606, 609 Рекурсия в правилах грамматики, 356, 438, 464 ресурс, 25 интерфейса прикладной программы, 658, 669, 676, 680 критический, 222 повторно используемый (reusable resource), 270 расходуемый (consumable resource), 270 Рехэширование, 558-560 с сбалансированное двоичное дерево, 171 Свертка объектного кода, 635-636, 638 для линейного участка, 637 своппинг (swapping — замена, замещение), 74 сегмент состояния задачи (task state segment), 33, 106 сектор (sector), 148 селектор дескриптора, 116 сегмента, 105 Семантические соглашения языка, 589, 590 Семантический анализатор, 354, 537, 544, 548, 588-589, 594, 597, 631 семафор, 236 в UNIX, 317 Сентенциальная форма, 369, 427 Сервер данных, 670-671, 675, 689 приложений, 671, 676, 680, 689 сети Петри, 276 теоретико-множественное представление, 277 сигналы, 317, 337 Скрипт Web-страницы, 691 Символ, 347 бесплодный, 427-428 недостижимый, 427, 429 нетерминальный, 354, 371, 388, 405, 409-410, 438, 443, 449, 463, 465, 468, 473, 477-478, 498, 503, 508, 510-511, 515, 518, 580-581, 583 левая факторизация, 464, 477 левый контекст, 492 рекурсивный, 437 терминальный, 354, 371-372, 409-410, 443, 449, 474, 498, 509, 568, 583 Синтаксический анализ, 520, 537, 544, 564, 577-578, 582, 588, 597, 613-614 анализатор, 382, 566 разбор, 577-578, 588, 599, 613, 615, 623 Синтаксическое дерево, 580, 589, 613-616 система программирования, 21, 534, 542 реального времени (СРВ), 202 управления файлами, 19, 146 инсталлируемая, 178 Система программирования, 658, 661-663, 665-666, 668, 670-671, 682 Borland C++ Builder, 676 Borland Delphi, 674-675, 677, 686 Borland JBuilder, 694 Borland Kylix, 685-686 GNU C++, 685 Microsoft Visual Basic, 678 Microsoft Visual C++, 679-680 Turbo Pascal, 657, 672-673 в сети Интернет, 687, 690 для ОС UNIX, 683-684 проекта GNU, 685 система подсказок и справок, 669 системный вызов ехес( ), 307 Скалярный тип данных, 597 Сканер, 564, 568, 570-571, 576 Сложные структуры данных, 598 Соглашение о вызове и передаче параметров, 609
Алфавитный указатель 733 сотрудничающие процессы, 223 спулинг (spooling), 135 спул-файл, 135 Стек параметров процедуры (функции), 607, 609 Стиль программирования, 633, 636, 642 страничный файл, 82 фрейм (page frame), 97 Строка символов, 347 СУБД, 670 СУ-компиляция, 614, 616, 621—622 супервизор (superviser), 27 ввода/вывода, 132 прерываний, 42 СУ-псревод, 613-614, 622, 626, 629-630 суперпользователь, 305 т таблица RTTI, 610-612, 644 векторов прерываний, 91, 120 виртуальных (логических) устройств, 137 дескрипторов прерываний, 123 идентификаторов, 536, 538, 549, 558-559, 562-563, 589, 597, 613, 615, 665, 668 бинарное дерево, 552 линейный список, 550 метод цепочек, 560, 563 хэш-функция, 554 каталогов таблиц страниц, 104, 109 лексем, 566, 578, 589 оборудования, 136 разделов (partition table), 149 размещения файлов, 156 сегментов, 78 страниц, 83 Текстовый редактор, 655, 660, 665, 668 том (volume), 169, 180 Тонкий клиент, 671 Транслятор, 529, 532, 540, 662, 688 с языка ассемблера, 542, 673 Трансляция адресов, 663 тред (thread), 33 У уплотнение памяти, 76 упреждающее чтение (read ahead), 143 Уравнение с регулярными коэффициентами, 399, 401, 405-406 уровень безопасности С2, 97 привилегий, ИЗ установка тайм-аута, 134 утилиты, 21 учетная запись (account), 304 Фаза компиляции, 536, 538, 540, 549, 589, 614, 616 файл, 146 нодкачКи (pagefile.sys), 98 файловая система, 146 ссылка (file reference), 182 файловый узел (F-node), 170 фиксированное состояние, 296 флаг активности раздела (boot indicator), 150 фоновая сессия (background session), 177 Форма Бэкуса—Наура, 354, 382, 438, 469, 571 фрагмент файла (extent), 167 фрагментация памяти, 74 файлов, 158, 172 функции микроядра, 200 X хэш-адресация, 554-555, 564 хэширование, 556 хэш-таблица, 560 хэш-функция, 554, 556-557, 561, 563 ц целевая вычислительная система, 207 Целевой символ грамматики, 354-355, 371,389, 406, 475, 489 Цепочка вывода, 367, 369-370, 372, 374, 427, 457-458, 474, 579 левосторонняя, 443, 448, 483-484 правосторонняя, 450, 453, 493-494, 496, 506-507, 516-517 рекурсивная, 437 циклическая, 431, 437 символов, 347, 378, 391, 444, 450, 465, 474, 499, 545, 569, 579, 588, 612 пустая, 348 цилиндр (cylinder), 148 ч Числа зависимости, 639
734 Алфавитный указатель ш шлюз (gate), 116, 124 э экстент, 167 я ядро (ОС), 192 Язык HTML, 542, 688 Java Script, 695 REXX, 328 арифметических выражений, 525 ассемблера, 531, 533, 535, 542-543, 545, 578, 596, 600, 607, 612, 615, 619, 624, 626-627, 632, 637, 662 машинных кодов, 531, 608, 612, 614-616, 619 машинных команд, 681 программирования, 351, 425, 489, 521, 533, 535, 541, 547, 565, 568, 582, 588, 594, 597, 600, 607, 609, 624, 632, 642 Borland Pascal, 390, 413, 468, 548, 566, 572, 590-591, 598, 600, 657, 672 С, 465, 547, 548, 565, 569, 571-572, 582, 593, 598, 643, 676, 683, 685 С#, 535, 682 C++, 603, 643-644, 676, 679, 685, 686 FORTRAN, 535, 565, 569, 666 Java, 535, 542, 692 Object Pascal, 603, 675, 686 Язык (продолжение) Perl, 691 Visual Basic, 535, 677-678 в глобальной сети Интернет, 688 объектно-ориентированный, 603, 610, 612, 673, 675 семантика, 352, 539, 544, 580, 589, 591, 595, 613, 627 семантические нормы, 589, 592 синтаксис, 423, 474, 537, 543, 547, 568, 578, 580, 596-597 способы определения, 351, 382 формальный, 349, 363, 370, 391 LL, 521, 524, 614 LR, 521, 524, 614 детерминированный, 381, 423-424, 474, 488, 509, 519, 524 контекстно-зависимый, 364, 366, 380, 588 контекстно-свободный, 364, 366, 381, 418, 423, 425, 442, 458, 486, 508, 519, 524, 578, 582, 588, 614 лексика, 351 операторного предшествования, 519, 525 простого предшествования, 519, 525 регулярный, 365, 382, 387, 403, 405, 408, 413-414, 568 семантика, 350, 356, 530 синтаксис, 350, 381 способы определения, 349, 382 четвертого поколения (4GL), 659
ИЗДАТЕЛЬСКИЙ ДОМ ВНИМАНИЕ СПЕЦИАЛИСТАМ КНИЖНОГО БИЗНЕСА! УВАЖАЕМЫЕ ГОСПОДА! ИЗДАТЕЛЬСКИЙ ДОМ «ПИТЕР» ПРИГЛАШАЕТ ВАС К ВЗАИМОВЫГОДНОМУ СОТРУДНИЧЕСТВУ. ЗА ДОПОЛНИТЕЛЬНОЙ ИНФОРМАЦИЕЙ ОБРАЩАЙТЕСЬ В НАШИ ПРЕДСТАВИТЕЛЬСТВА, КОТОРЫЕ НАХОДЯТСЯ ПО СЛЕДУЮЩИМ АДРЕСАМ: Россия, г. Москва Представительство издательства «Питер», м. «Калужская», ул. Бутлерова, д. 176, оф. 207 и 240, тел./факс (095) 234-38-15. E-mail: sales@piter.rfisk.ru Россия, г. Санкт-Петербург Представительство издательства «Питер», м. «Электро- сила», ул. Благодатная, д. 67, тел. (812) 327-93-37, 294-54-65. E-mail: sales@piter.com Украина, г. Харьков Представительство издательства «Питер», тел. (0572) 28-20-04, 28-20-05, факс (0572) 14-96-09. Почтовый адрес: 61093, г. Харьков, а/я 9130. E-mail: piter@tender.kharkov.ua Украина, г. Киев Филиал Харьковского представительства издательства «Питер», тел./факс (044) 490-35-68, 490-35-69. Почтовый адрес: 04073, г. Киев, пр. Красных Казаков, д.6, корп.1. E-mail: office@piter-press.kiev.ua Беларусь, г. Минск Представительство издательства «Питер», тел./факс (0172) 16-00-06. Почтовый адрес: 220012, г. Минск, ул. Кедышко, д.19, а/я 104. E-mail: piterbel@mail.ru КАЖДОЕ ИЗ ЭТИХ ПРЕДСТАВИТЕЛЬСТВ РАБОТАЕТ ПО ЕДИНОМУ СТАНДАРТУ ИЗДАТЕЛЬСКОГО ДОМА «ПИТЕР». КНИГИ ИЗДАТЕЛЬСКОГО ДОМА «ПИТЕР» ВЫ МОЖЕТЕ ПРИОБРЕСТИ ОПТОМ И В РОЗНИЦУ У НАШИХ РЕГИОНАЛЬНЫХ ПАРТНЕРОВ. Башкортостан Уфа, «Азия», ул. Зенцова, д. 70 (оптовая продажа), маг. «Оазис», ул. Чернышевского, д. 88, тел./факс (3472) 50-39-00. E-mail: asiaufa@ufanet.ru Дальний Восток Владивосток, «Приморский Торговый Дом Книги», тел./факс (4232) 23-82-12. Почтовый адрес: 690091, ул. Светланская, д. 43. E-mail: bookbase@mail.primorye.ru Хабаровск, «Мирс», тел. (4212) 22-74-58, факс 22-73-30. Почтовый адрес: 680000, г. Хабаровск, ул. Ким-Ю-Чена, д. 21. E-mail: postmaster@bookmirs.khv.ru Хабаровск, «Книжный мир», тел. (4212) 32-85-51, факс 32-82-50. Почтовый адрес: 680000, ул. Карла Маркса, Д. 37. Европейские регионы России Архангельск, «Дом Книги», тел. (8182) 64-00-56, факс 65-41-34. Почтовый адрес: 163061, пл. Ленина, д. 3. E-mail: book@atnet.ru Калининград, «Пресса», тел. (0112) 53-66-97, факс 53-63-87. Почтовый адрес: 236040, ул. Подполковника Иванникова, д. За. E-mail: book@pressa.gazinter.net Краснодар, «Когорта», тел./факс (8612) 62-54-97. Почтовый адрес: 350033, ул. Ленина, д. 101. Северный Кавказ Ессентуки, «Россы», ул. Октябрьская, 424, тел./факс (86534) 6-93-09. Почтовый адрес: 357600, г. Ессентуки, а/я 80. E-mail: rossy@kmv.ru Сибирь Братск, «Прометей», тел./факс (3953) 43-18-76. Почтовый адрес: 665708, ул.Кирова, д. 35. Иркутск, «Антей-книга», тел./факс (3952) 34-69-38. Почтовый адрес: 664003, ул. Карла Маркса, д. 20. E-mail: antey@irk.ru Иркутск, «Продалитъ», тел. (3952) 59-13-70, факс 51-23-31. Почтовый адрес: 664031, г. Иркутск, ул. Бай- кальская, д. 172, а/я 1397. E-mail: prodalit@irk.ru; http://www.prodalit.irk.ru Красноярск, «Книжный Мир», тел./факс (3912) 27-39-71. Почтовый адрес: 660049, пр. Мира, д. 86. E-mail: book-world@public.krasnet.ru ^Нижневартовск, «Дом книги», тел. (3466) 23-59-50, факс 23-27-14. Почтовый адрес: 626440, пр. Победы, д.12. Новосибирск, «Топ-книга», тел. (3832) 36-10-26, факс 36-10-27. Почтовый адрес: 630117, а/я 560. E-mail: office@top-kniga.ru; http://www.top-kniga.ru Тюмень, «Друг», ул. Республики, д. 211, тел./факс (3452) 21-02-20. E-mail: drug@tyumen.ru Тюмень, «Фолиант», тел. (3452) 27-36-06, факс 27-36-11. Почтовый адрес: 625039, ул. Харьковская, д. 83а. E-mail: foliant@tyumen.ru Татарстан Казань, «Таис», ул. Гвардейская, д. 9а, тел. (8432) 76-34-55, факс 38-24-32. E-mail: tais@bancorp.ru Урал Екатеринбург, магазин № 14, ул. Челюскинцев, д. 23, тел. (3432) 53-24-90. E-mail: gvardia@mail.ur.ru Екатеринбург, «Валео Плюс», тел. (3432) 42-07-75, 42-56-00. E-mail: valeo@emts.ru
Гордеев Александр Владимирович, Молчанов Алексей Юрьевич Системное программное обеспечение Главный редактор Е. Строганова Ответственный редактор И. Корнеев Руководитель проекта А. Васильев Литературный редактор Е. Ваулина Художник Р. Яцко Иллюстрации В. Шендерова Корректор В. Листова Верстка 10. Сергиенко Лицензия ИД № 01940 от 05.06.00. Подписано в печать 28.08.01. Формат 70* 1001/1б. Усл. п. л. 59,34. Тираж 5000 экз. Заказ № 1423. ЗАО «Питер Бук». 196105, Санкт-Петербург, ул. Благодатная, 67. Налоговая льгота - общероссийский классификатор продукции ОК 005-93, том 2; 953000 - книги и брошюры. Отпечатано с диапозитивов в ФГУП «Печатный двор» им. А. М. Горького Министерства РФ по делам печати, телерадиовещания и средств массовых коммуникаций 197110, Санкт-Петербург, Чкаловский пр., 15.
А. В.Гордеев А. Ю. Молчанов СИСТЕМНОЕ ПРОГРАММНОЕ ОБЕСПЕЧЕНИЕ УЧЕБНИК Базовый курс для студентов, обучающихся в высших учебных заведениях по специальностям направления «Информатика и вычислительная техника», преподавателей и профессионалов, связанных с системным программированием. Особое внимание в этом учебнике уделяется операционным системам, средам и системам программирования. Его основой стал учебный материал, в течение нескольких лет преподаваемый в Санкт-Петербургском государственном университете аэрокосмического приборостроения. Помимо общетеоретических вопросов в учебнике описаны конкретные реализации различных системных программ, поэтому он может быть полезен не только ступс“-|тям. детально изучающим систс;.;:;сс обеспечение, но и тем, ктс ссС ,__ самостоятельно разрабатг ^: 1 z компоненты. Темы, рассмотренные в книге: • управление задачами и ресурсами в операционных системах • архитектура операционных систем • интерфейсы прикладного программирования проектирование параллельных взаимодействующих процессов проблема тупиков и методы борьбы с ними формальные языки и грамматики основные принципы построения трансляторе • генерация и оптимизация кода современные операционные системы и системы программирования Посетите наш Web-магазин: http:/www.piter.com