Разработка операционных систем


1 - среда разработки

Речь в первом выпуске рассылки пойдет о том, как обустроить свой компьютер, и какие программы для этого понадобятся, чтобы сделать процесс разработки ОС не только возможным, но и максимально эффективным и приятным. Поскольку на практических занятиях мы будем рассматривать операционные системы для платформы IA-32 (других процессоров у меня нет :)), то я предполагаю, что установленная на вашем компьютере ОС - либо Windows, либо один из современных клонов UNIX (Linux, *BSD и им подобные).

Первое, что нам понадобится - это ассемблер. Я выбрал NASM, который имеет версии как под Windows, так и под UNIX. Он обладает привычным Intel-синтаксисом и перейти на него пользователям TASM или MASM не составит труда.

Вторая вещь, отсутствие которой очень сильно осложнит нашу жизнь, - это виртуальная машина (эмулятор процессора). Так как обычно при разработке очень часто приходится тестировать написанный код, то, согласитесь, необходимость записывать каждый раз свежескомпилированную систему на дискету и перезагружать компьютер - это чрезвычайно раздражающий фактор. Эмулятор же не только позволит загружать нашу систему в отдельном окне, не прерывая процесс разработки, но и может использовать виртуальный образ дискеты вместо настоящей. Луший эмулятор по моему мнению - VMWare Workstation. Программа платная, но пользоваться ею можно сколько угодно - достаточно лишь обращаться каждые 30 дней на сайт разработчика за продлением лицензии. Не слишком большая цена за удобство, как вы считаете? VMWare Workstation имеет версии как под Windows, так и под Linux и пожалуй ее единственный недостаток (помимо платности разумеется) - это то, что весит она около 12 мбайт.

Третье - программа make. Эта утилита призвана спасти нас от набивания многокилометровых приказаний компилятору и компоновщику (например на моей системе для того чтобы протестировать разрабатываемую ОС, достаточно лишь запустить make, а потом нажать кнопку 'Power On' в VMWare Workstation - сборка ОС и запись ее в образ дискеты будут произведены автоматически в соответствии с Makefile). Впрочем пока эта программа нам не нужна, а когда мы в лоб столкнемся с необходимостью ее использования, я расскажу о ней поподробнее.

Четвертое - шестнадцатеричный редактор/дизассемблер. Основное, что от него требуется - скорость работы, поэтому я выбрал BIEW. У него, как и у всех вышеперечисленных программ, существуют версии и под Windows и под UNIX. Эта программа пригодится нам для быстрой проверки - "чего же этот ассемблер скомпилировал-то?!".

Вот какие инструменты использую я:

  1. NASM версии 0.98.36
  2. VMWare Workstation for Linux версии 3.2.0 build-2230
  3. GNU Make версии 3.79.1
  4. BIEW версии 5.3.2

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

2

Часть первая - практическая

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

После начала работы, процессор может выполнить операцию самотестирования, которая называется BIST - built-in self test. А может ее и не выполнять (зависит от того, "попросит" ли этого чипсет). После этого процессор выполняет свою первую инструкцию, которая находится по физическому адресу 0xFFFFFFF0. Да-да, в самой вершине адресного пространства! Именно там находится (точнее должна находится) EPROM (Erasable Programmable Read-Only Memory) [что это такое...], в которой записана программа дальнейшей инициализации аппаратного обеспечения компьютера. Программное обеспечение, которое получает управление сразу после загрузки компьютера, называется BIOS (Basic Input/Output System), т.е. Базовая Система Ввода/Вывода.

Задача BIOS - распознать аппаратную начинку компьютера, провести над ней серию тестов и (наконец-то!) отыскать и загрузить операционную систему.

В зависимости от установок BIOS, конкретный порядок устройств, на которых ищется загрузчик ОС, может меняться, но, в общем случае, этими устройствами могут быть: Floppy-дисковод, жесткие диски, привод CD-ROM/R/RW, сервер сети, и даже некоторые экзотические накопители, вроде жестких дисков, подключаемых к шине USB.

Наиболее простой способ загрузки ОС, с точки зрения ее разработчика (то есть нас с вами), - это загрузка с дискеты. Если BIOS находит дискету в дисководе, она считывает ее первый сектор (а это первые 512 байт) в оперативную память по адресу 0x0 : 0x7C00. Более подробно о том, как на самом деле хранятся данные на магнитных дисках (к которым относятся и дискеты), мы поговорим в следующий раз, а сейчас наша задача такова:

  1. Написать программу объемом 512 байт, которая выполнит вывод текста на экран
  2. Записать эту программу на дискету
  3. Вставить дискету в дисковод, нажать "reset" и наслаждаться результатом :)

Вот какая программа у меня получилась: [ORG 0x7C00]

start:

cli
mov ax, cs
mov ds, ax
mov ss, ax
mov sp, start

sti

mov si, msg
call kputs

cli
hlt
jmp short $

kputs:

.loop:
lodsb
test al, al
jz .quit
mov ah, 0x0E
int 0x10
jmp short .loop
.quit:
ret


msg: db "Hello from the world of real programming!",0x0A,0x0D,0
times 510-($-$$) db 0
db 0xAA, 0x55

Скомпилировать эту программу можно используя опцию -f bin NASM'а, которая приказывает ему создать чистный бинарный файл, без заголовков и прочих излишеств. То есть запускать NASM нужно так (учитывая что файл имеет название start.asm):
nasm -fbin start.asm -o start.bin
Получившийся файл start.bin должен иметь размер ровно 512 байт. Эти 512 байт как раз и будут тем загрузчиком, размещенным в начальном секторе дискеты, который будет найден и загружен BIOS.

Если вы используете для тестирования VMWare Workstation, то этот файл можно прямо прописывать как файл-образ дискеты (если VMWare Workstation обнаруживает образ дискеты объемом менее настоящей дискеты, то обращения к несуществующим данным будут невозможны, но существующая часть дискеты будет отлично работать)

Чтобы записать этот загрузчик на настоящую дискету (в целях проверки загрузчика в "боевых" условиях) нужно проделать следующее:
Если вы разрабатываете в UNIX, то используйте команду:
cp start.bin /dev/fd0 (где /dev/fd0 - ваш флопик)
Если же вы используете Windows, то для записи можно использовать программу rawwrite,которая находится на http://uranus.it.swin.edu.au/~jn/linux/rawwrite.htm

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

Часть вторая - теоретическая

Откуда есть пошли операционные системы, и что это вообще такое?

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

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

В сущности, все современные операционные системы выполняют такие же функции, как и наш воображаемый оператор ЭВМ 50-х годов прошлого века. Они точно так же распределяют ресурсы компьютера и предоставляют пользователю (а еще явственнее - программисту) развитые средства взаимодействия с компьютером (например в Windows окно с сообщением можно создать при помощи системной функции MessageBoxA или MessageBoxU, хотя на самом деле процессор (да и все другое аппаратное обеспечение) даже не имеют представления о том, что такое окно. Максимум, что они могут - это выводить разноцветные точки в нужных местах экрана. Но ведь прикладному программисту даже не нужно знать, что происходит под капотом системы!)

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

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

Для любопытных

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

3

Часть первая, практическая

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

В предыдущем выпуске я сказал, что BIOS - это программа, которая запускается при старте компьютера. Но обязанности BIOS не ограничиваются инициализацией и настройкой аппаратуры компьютера. BIOS также предоставляет интерфейс для доступа к аппаратной части, который гораздо проще чем программирование через порты ввода-вывода (о них позже). В этом отношении BIOS можно рассматривать как... правильно! Как операционную систему. У этой ОС есть множество недостатков, но и одно серьезнейшее преимущество - она установлена на всех IBM PC-совместимых компьютерах. А это значит, что мы можем смело использовать предоставляемые BIOS высокоуровневые (которые в тоже время очень низкоуровневы даже в сравнении с системными функциями DOS) функции не опасаясь потерять совместимость с разными моделями компьютеров. Конечно у разных BIOS существуют и несовместимые функции, но практически все, что нам понадобится, будет совместимо с компьютерами от IBP PC XT c 8086 процессором до современных с Pentium 4.

Для вызова функций BIOS используется механизм т.н. программных прерываний. По сути дела, программное прерывание (инструкция INT) - это просто вызов некоторой функции (аналогично инструкции CALL) с тем лишь отличием, что в инструкции CALL адрес указывается напрямую (регистр/переменная/непосредственный операнд), а адрес, по которому будет осуществлен переход инструкцией INT, хранится в таблице прерываний (о ней тоже попозже). Как операнд, в инструкции INT указывается индекс (номер) прерывания в таблице. При выполнении инструкции процессор достает из таблицы адрес, соответствующий указанному индексу прерывания, помещает в стек содержимое регистров EFLAGS (FLAGS в 16-битном режиме), CS, EIP (IP) и передает управление по полученному адресу. Например:
int 0x10 - вызов обработчика прерывания номер 0x10

Вводная часть на этом завершена, теперь давайте рассмотрим нашу программу:
[ORG 0x7C00] - BIOS загружает первый сектор дискеты по адресу 0x0000:0x7C00 и смещение всех меток программы должны вестись от этого адреса
Соответственно в сегментном регистре CS находится 0. Содержимое остальных сегментных регистров не определено, и мы загружаем во все нужные нам сегментные регистры то же значение, что находится в CS:
mov ax, cs
mov ds, ax
mov ss, ax

(при этом не забываем перед установкой стека отключить прерывания инструкцией CLI)
Теперь нам необходимо инициализировать регистр стека SP (именно SP, а не ESP, так как мы сейчас находимся в 16-битном режиме)

mov sp, _start - метка _start находится в самом начале нашей программы, т.е. стек будет располагаться прямо под ней (напоминаю, что стек растет сверху вниз)

После установки стека мы разрешаем прерывания, устанавливая флаг IF командой STI

В функции kputs мы используем прерывание видеосервиса BIOS int 0x10, которое предназначена для управления видеоадаптером, в том числе и для вывода текста на экран.
Номер функции этого прерывания помещается в регистр AH, мы используем функцию номер 0x0E, которая выводит на экран ASCII-символ, находящийся в регистре AL. Например программа:
mov ah, 0x0E
mov al, 'Z'
int 0x10

Выведет на экран символ 'Z'
Ну а наша функция kputs предназначена для вывода на экран строки символов (указатель на которую передается в регистре SI). Заканчиваться эта строка должна нуль-символом.

Для того, чтобы BIOS могла распознать загрузочный сектор, он должен заканчиваться (байты 510-511) байтами 0x55, 0xAA, для осуществления этого и предназначены последние две строчки программы. Хотя для некоторых BIOS наличие такой сигнатуры необязательно

Часть вторая, теоретическая

Эволюция операционных систем

Операторы - "живые" операционные системы 50х годов не могли управляться с компьютером слишком быстро, и поэтому много времени проходило между остановом одной программы и запуском другой, между запросом компьютера на выделение ресурсов и его удовлетворением и пр. Как следствие, компьютеры большую часть времени простаивали в ожидании действий оператора. Тогда возникла идея - передать функции оператора самому компьютеру! Большинство функций оператора сводилось к рутинной работе, поэтому запрограммировать их не составляло труда.

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

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

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

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

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

Следующим этапом развития операционных систем стали сетевые операционные системы во главе с UNIX; о них мы поговорим в следующих раз

4

Сказ о том, как хранится информация на магнитных дисках

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

Каждый диск носителя разделен на кольцеобразные дорожки, также их именуют цилиндрами. Каждая дорожка в свою очередь делится на секторы, размер которых обычно составляет 512 байт.

Итого: записать/считать с диска можно минимум 512 байт (не менее сектора) и для этого необходимо указать Головку, Цилиндр и Сектор. Вот такая вот трехмерная адресация...

Основные функции BIOS, которые нам понадобятся

B IOS предоставляет большое количество функций, но поскольку основная их часть может использоваться только в 16-битном реальном режиме (касательно нашей системы - только при работе загрузчика), практически полезными нам будут прерывания:
int 0x10- видеосервис
int 0x13- дисковый ввод/вывод

Напоминаю, что при старте компьютера BIOS загружает только первый сектор диска. Загрузку остальной части системы необходимо будет производить самостоятельно и самый легкий способ это сделать - использовать прерывание int 0x13

Функций, которые нам понадобятся, две (напоминаю, что номер вызываемой функции заносится в регистр AH перед вызовом прерывания):

Функция 0x0e прерывания int 0x10 - вывод на экран в режиме телетайпа (это означает что функция сама будет перемещать курсор после вывода символа, а также воспринимать управляющие символы, такие как 0x0A (Line Feed, LF) - символ новой строки, или 0x0D (Carriage Return, CR) - символ возврата каретки)
Параметр: код символа в AL

Функция 0x02 прерывания int 0x13 - считывание n-го количества секторов с диска в память
Параметры:

В случае ошибки функция устанавливает флаг CF

Эта функция нам понадобится только для чтения с дискеты, поэтому можно считать, что номер цилиндра находится в CL, а номер сектора - в CH

Эволюция операционных систем

Сегодня мы продолжим разговор, начатый в позапрошлом выпуске рассылки, но для начала вспомним, какие периоды развития прошли операционные системы до 1970 года:

  1. "Живые" ОС (оператор-человек)
  2. Системы пакетной обработки
  3. Системы разделения времени
  4. Гибриды пп. 2 и 3

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

В конце 70-х годов начали стали бурно развиваться компьютерные сети, как локальные, так и глобальные, что в немалой степени было связано с удешевлением компьютеров. Был создан стек (набор) протоколов TCP/IP, который сейчас не только используется в сети Интернет, но и очень популярен в локальных сетях. Первой операционной системой, в которой эти протоколы были полноценно реализованы, стала UNIX. Таким образом UNIX из обычной системы разделения времени стала сетевой системой - системой, способной взаимодействовать по сети с другими ОС.

В начале 80-х появление сверхбольших интегральных схем привело к созданию компьютеров, которые по размеру были меньше мини-компьютеров и стоили гораздо дешевле - персональным компьютерам. Многие компании начали выпускать такие компьютеры, но за счет открытой архитектуры (нечно вроде аппаратного Open-Source ;)) главенства на рынке (которое сохраняется и по сей день) удалось добиться компьютерам IBM PC на базе процессоров Intel. С тех пор и по сей день у большинства людей слово компьютер ассоциируется с IBM PC, а слово процессор - с Intel.

Впрочем поговорим об операционных системах для этих компьютеров. Процессор был достаточно примитивен, такой стала и первая ОС для IBM PC - MS DOS. Эволюция ОС, происходившая на мини-компьютерах повторялась на персональных - с самых простых и примитивных систем до современных Windows NT (2000, XP...), Linux, *BSD...

Следующей ОС для IBM PC стала OS/2, которая была совместно разработана IBM и Microsoft. Впрочем успеха она не имела и пальма первенства перешла к ОС Windows, которая была создана на базе MS-DOS. Примерно в это же время (после выхода 32-разрядного процессора 386, который качественно отличался от предыдущих) в мир персональных компьютеров пришла UNIX, которая представлена сейчас такими экземплярами как Linux, FreeBSD, OpenBSD, NetBSD и другими. Microsoft же создала полноценную 32-разрядную ОС Windows NT и недавно похоронила последнюю наследницу MS-DOS - Windows ME.

5

Работа над ашипками

В прошлом, четвертом выпуске, рассказывая о функции 0x02 прерывания int 0x13 BIOS, я сказал:
"Эта функция нам понадобится только для чтения с дискеты, поэтому можно считать, что номер цилиндра находится в CL, а номер сектора - в CH"

Читать эту фразу следует наоборот - номер цилиндра в CH, а номер сектора - в CL. То есть касательно дискет, таблица параметров для этой функции выглядит вот так:

Представление данных на дисках (продолжение)

В предыдущем выпуске я не упомянул два важных момента:

Отсчет головок, цилиндров ведется с нуля, а секторов с единицы. То есть загрузочный (первый) сектор дискеты - это:
0 (головка) : 0 (цилиндр) : 1 (сектор)
И в дальнейшем, говоря о конкретных секторах на диске я буду использовать представление адреса сектора в виде 3 чисел - X:X:X.

Обычная дискета 1.44 содержит 2880 секторов, 160 цилиндров и 2 головки (точнее головки содержит не дискета, а дисковод). Каждый цилиндр дискеты содержит 18 секторов, а каждая головка - 80 цилиндров.

Системы защиты современных процессоров как краеугольный камень концепции построения операционных систем

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

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

Фирма Intel пошла дальше и в процессорах архитектуры IA-32 используется не два, а аж четыре уровня привилегий, которые именуются кольцами и отсчитываются от нуля до трех. Причем 0 - наиболее привилегированный уровень, а 3 - наименее привилегированный. Можно считать, что 0 - уровень Супервизора, а 3 - уровень Пользователя. Остальные уровни привилегий в принципе не нужны и обычно не используются.

Классический вариант архитектуры ОС таков: ядро системы работает на уровне Супервизора, а пользовательские задачи на уровне Пользователя, причем пользовательские задачи не могут вмешаться в работу друг-друга или ядра, а само ядро (оно же Супервизор) может делать все, что угодно. Именно по такой схеме построены Windows NT, Linux, FreeBSD, OpenBSD, NetBSD

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

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

Языки программирования, которые мы будем использовать

У некоторых подписчиков возник вопрос, можно ли использовать при разработке ОС такие языки, как Pascal или C++

Основная проблема ЯВУ при разработке ОС заключается в том, что с их помощью нельзя контролировать на низком уровне поведение процессора и внешних устройств, поэтому ассемблер нам понадобится в любом случае. Другое дело, что языки высокого уровня обычно имеют возможность вставки ассемблерного кода в исходник, поэтому использование чистого ассемблера можно свести к минимуму (только для загрузчика). Более того, некоторые ОС изолируют весь низкоуровневый код в т.н. HAL (hardware abstraction layer), а вся остальная часть системы написана только на ЯВУ и, следовательно, портабельна на другие архитектуры

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

6

Обзор архитектуры IA-32

Напомню, что архитектуру IA-32 (Intel Architecture 32-bit) имеют все 32-разрядные процессоры Intel от Intel386 до Pentium 4 и Xeon. Все они являются обратно совместимыми вплоть до процессора 8086 (который даже к IA-32 не относится, хотя иногда Intel называет его 16-битным IA-32 процессором - получается что-то вроде 16-разрядный представитель семейства 32-разрядных процессоров :)). Другое название этой архитектуры (которое очень любит фирма AMD) - x86 или 80x86.

Эта архитектура занимает лидирующее положение на рынке процессоров для персональных компьютеров. Но за это лидирование фирме Intel пришлось заплатить большую цену - по выражению Энди Танненбаума, при разработке Pentium II основополагающими были 3 фактора:

  1. Совместимость с предыдущими моделями процессоров
  2. Совместимость с предыдущими моделями процессоров
  3. Совместимость с предыдущими моделями процессоров

Необходимость совместимости наложила огромный отпечаток на архитектуру - система команд процессора очень сложна и запутанна, программисту предоставляется слишком мало регистров (и, вследствие чего, зачастую для временного хранения данных приходится использовать оперативную память). По некоторым расчетам, избавившись от вороха старья, тянущегося еще со времен 8080 и 8086, инженеры Intel могли бы повысить производительность процессора на 20-30%.

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

Процессоры IA-32 могут функционировать в двух основных режимах: режиме реальных адресов (или реальном режиме) и защищенном режиме. Так же имеется псевдо-режим виртуального 8086 и режим SMM (system management mode).

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

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

Задача защищенного режима (при условии использования аппаратной многозадачности) может выполняться в режиме виртуального 8086 (VM86) - т.е. в режиме эмуляции 8086, который очень похож на реальный режим. Таким образом, например, осуществляется эмуляция MS-DOS в системе Windows

Режим SMM предназначен для более полного контроля аппаратуры, энергопотребления и пр. Вход в него осуществляется только при получения сигнала на входе #SMI (system management interrupt) процессора. Он предоставляет изолированную среду выполнения (которая не пересекается с другими режимами, в каком бы из них процессор не находился при получении #SMI). Этот режим нам не понадобится и рассматривать его подробно мы не будем

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

Введение в защищенный режим IA-32

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

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

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

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

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

Переключение процессора в защищенный режим (практическая часть)

Размер сегментных регистров в защищенном режиме - 10 байт. Из них 8 недоступны для пользователя и называются теневой частью или кэшем дескриптора. Доступные 2 байта сегментных регистров содержат селектор, формат которого таков:

биты 16-3: номер дескриптора сегмента в таблице (от 0 до 8191)
бит 2: используемая таблица (0 - глобальная, 1 - локальная)
биты 1-0: запрашиваемый уровень привилегий (RPL)

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

Для того, чтобы перевести процессор в защищенный режим нам понадобится глобальная таблица дескрипторов. Она содержит по одному восьмибайтному дескриптору для каждого сегмента. Дескриптор содержит 32-разрядный адрес начала сегмента (База), 20-битный размер сегмента (Лимит) и 12 бит описывающих тип сегмента. Ниже приводится полный формат дескриптора:

       бит 7   бит 6   бит 5   бит 4   бит 3   бит 2   бит 1   бит 0
     +-------+-------+-------+-------+-------+-------+-------+-------+
байт 0  | <--------------- биты 7-0 лимита сегмента ------------------> |
     +-------+-------+-------+-------+-------+-------+-------+-------+

     +-------+-------+-------+-------+-------+-------+-------+-------+
байт 1  | <--------------- биты 15-8 лимита сегмента -----------------> |
     +-------+-------+-------+-------+-------+-------+-------+-------+

     +-------+-------+-------+-------+-------+-------+-------+-------+
байт 2  | <--------------- биты 7-0 базы сегмента --------------------> |
     +-------+-------+-------+-------+-------+-------+-------+-------+

     +-------+-------+-------+-------+-------+-------+-------+-------+
байт 3  | <--------------- биты 15-8 базы сегмента -------------------> |
     +-------+-------+-------+-------+-------+-------+-------+-------+

     +-------+-------+-------+-------+-------+-------+-------+-------+
байт 4  | <--------------- биты 23-16 базы сегмента ------------------> |
     +-------+-------+-------+-------+-------+-------+-------+-------+

     +-------+-------+-------+-------+-------+-------+-------+-------+
байт 5  |   P   |      DPL      |   S   | <------ Тип сегмента -------> |
     +-------+-------+-------+-------+-------+-------+-------+-------+


P - бит присутствия сегмента. Пока мы не реализовываем виртуальную память,
он должен быть всегда установлен в 1

DPL - уровень привилегий дескриптора (Descriptor Privelege Level)
Пока мы используем только нулевой (высший) уровень привилегий

S - указывает является ли дескриптор системным (0) или обычным дескриптором
сегмента кода или данных (1)

Формат четырехбитного поля "тип сегмента" зависит от типа дескриптора.
Более подробно мы рассмотрим его в следующий раз, ну а пока используем
такие значения:
0010 - для дескриптора сегмента данных
1010 - для дескриптора сегмента кода


         7       6       5      4        3       2       1       0
     +-------+-------+-------+-------+-------+-------+-------+-------+
байт 6  |   G   |   B   |   0   |  AVL  |   биты 19-16 лимита сегмента  |
     +-------+-------+-------+-------+-------+-------+-------+-------+


G - бит гранулярности лимита. Если он установлен в 0, то лимит измеряется
в байтах (мы помним, что на лимит отводится 20 бит) и составляет от 0
до одного мегабайта. Если же установлен в 1, то лимит измеряется в
4-килобайтных единицах и составляет тогда от 0 до 4 гигабайт.

B - бит разрядности сегмента (0 - 16-битный сегмент, 1 - 32-битный)

Бит 5 должен быть установлен в 0

Бит 4 не используется процессором и, следовательно, может использоваться
операционной системой



     +-------+-------+-------+-------+-------+-------+-------+-------+
байт 7  | <--------------- биты 31-24 базы сегмента ------------------> |
     +-------+-------+-------+-------+-------+-------+-------+-------+



Фактически, за вход в защищенный режим отвечает нулевой бит регистра CR0, который также называется битом PE (Protection Enable). Но перед включением защищенного режима шестибайтная структура, состоящая из 32-разрядного линейного адреса таблицы дескрипторов (GDT) и 16-битного лимита таблицы (количества элементов таблицы; можно указывать 8192), должна быть загружена в регистр GDTR.

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

И еще один момент - необходимо включить адресную линию A20, которая для совместимости с 80186 отключена при старте компьютера. Если мы этого не сделаем, то все обращения к нечетным мегабайтам памяти будут отображаться на четные, т.е. обращение, допустим, по адресу 1.5 мб на самом деле произойдет по адресу 0.5 мб.

Итак, наша задача такова:

Вот, что у меня получилось:
[BITS 16]
[ORG 0x7c00]
_start:
cli
mov ax, cs
mov ds, ax
mov ss, ax
mov sp, _start

;; Загрузка регистра GDTR:
lgdt [gd_reg]

;; Включение A20:
in al, 0x92
or al, 2
out 0x92, al

;; Установка бита PE регистра CR0
mov eax, cr0
or al, 1
mov cr0, eax

;; С помощью длинного прыжка мы загружаем
;; селектор нужного сегмента в регистр CS
;; (напрямую это сделать нельзя)
;; 8 (1000b) - первый дескриптор в GDT, RPL=0
jmp 0x8: _protected


[BITS 32]
_protected:
;; Загрузим регистры DS и SS селектором
;; сегмента данных
mov ax, 0x10
mov ds, ax
mov ss, ax

mov esi, msg_hello
call kputs

;; Завесим процессор
hlt
jmp short $


cursor:dd 0
%define VIDEO_RAM 0xB8000

;; Функция выполняет прямой вывод в память видеоадаптера
;; которая находится в VGA-картах (и не только) по адресу 0xB8000

kputs:
pusha
 .loop:
lodsb
test al, al
jz .quit

mov ecx, [cursor]
mov [VIDEO_RAM+ecx*2], al
inc dword [cursor]
jmp short .loop

 .quit:
popa
ret


gdt:
dw 0, 0, 0, 0; Нулевой дескриптор

db 0xFF; Сегмент кода с DPL=0
db 0xFF; Базой=0 и Лимитом=4 Гб
db 0x00
db 0x00
db 0x00
db 10011010b
db 0xCF
db 0x00

db 0xFF; Сегмент данных с DPL=0
db 0xFF; Базой=0 и Лимитом=4Гб
db 0x00
db 0x00
db 0x00
db 10010010b
db 0xCF
db 0x00


;; Значение, которое мы загрузим в GDTR:
gd_reg:
dw 8192
dd gdt

msg_hello:db "Hello from the world of 32-bit Protected Mode",0

times 510-($-$$) db 0
db 0xaa, 0x55

Эту программу необходимо откомпилировать с помощью NASM в чистый бинарный формат и записать в загрузочный сектор дискеты (точно также как мы делали это во втором выпуске)

7

Прерывания и исключения процессора

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

Чем различаются прерывания и исключения? Исключения порождаются процессором, когда при выполнении кода он наталкивается на какую-нибудь ошибку, к примеру деление на ноль. В настоящее время процессоры IA-32 имеют около двух десятков исключений для разных видов ошибок.

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

Аппаратные прерывания - способ, с помощью которых внешние (относительно процессора) устройства могут влиять на работу процессора, например сигнализировать ему об окончании выполнения какого-либо действия. Они подразделяются на маскируемые (которые процессор получает на вход INTR) и одно немаскируемое прерывание (NMI - NonMaskable Interrupt) (получаемое уже на другой вход - NMI; другой вариант - получение процессором по системной шине или шине APIC (Advanced Programmable Interrupt Controller) сообщения с режимом доставки NMI). Альтернативный способ получения прерываний - через APIC, но о нем мы поговорим позже. Маскируемые прерывания могут быть отключены снятием флага IF регистра EFLAGS (для этого предназначена инструкция CLI). Для отключения немаскируемого прерывания необходимо перепрограммировать контроллер прерываний

Обработка прерываний

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

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

В защищенном режиме таблица прерываний называется таблицей дескрипторов прерываний IDT (Interrupt Descriptors Table) и на ее местонахождение указывает регистр IDTR (interrupt descriptors table register). Уже по названию можно судить, что в ней находятся не просто адреса обработчиков, а дескрипторы обработчиков прерывания. В качестве таких дескрипторов могут выступать дескрипторы трех типов:

О шлюзах задачи мы поговорим позже, когда коснемся аппаратной поддержки многозадачности в IA-32, ну а пока выясним чем отличаются шлюзы ловушек и шлюзы прерываний

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

Формат дескриптора шлюза прерывания или ловушки:

       бит 7   бит 6   бит 5   бит 4   бит 3   бит 2   бит 1   бит 0
     +-------+-------+-------+-------+-------+-------+-------+-------+
байт 0  |  ------- биты  7-0  смещения процедуры-обработчика ---------  |
     +-------+-------+-------+-------+-------+-------+-------+-------+
     +-------+-------+-------+-------+-------+-------+-------+-------+
байт 1  |  ------- биты 15-8  смещения процедуры-обработчика ---------  |
     +-------+-------+-------+-------+-------+-------+-------+-------+
     +-------+-------+-------+-------+-------+-------+-------+-------+
байт 2  |  --- биты  7-0 селектора сегмента процедуры-обработчика ----  |
     +-------+-------+-------+-------+-------+-------+-------+-------+
     +-------+-------+-------+-------+-------+-------+-------+-------+
байт 3  |  --- биты 15-8 селектора сегмента процедуры-обработчика ----  |
     +-------+-------+-------+-------+-------+-------+-------+-------+
     +-------+-------+-------+-------+-------+-------+-------+-------+
байт 4  |   0   |    0  |   0   |   0   |   0   |   0   |   0   |   0   |
     +-------+-------+-------+-------+-------+-------+-------+-------+
     +-------+-------+-------+-------+-------+-------+-------+-------+
байт 5  |   P   |      DPL      |   0   | ------- Тип шлюза ---------   |
     +-------+-------+-------+-------+-------+-------+-------+-------+
Поля P и DPL имеют такое же значение, как и в дескрипторе сегмента,
Поле "тип шлюза" принимает одно из следующих значений:

0110 - 16-битный шлюз прерывания
0111 - 16-битный шлюз ловушки
1110 - 32-битный шлюз прерывания
1111 - 32-битный шлюз ловушки


     +-------+-------+-------+-------+-------+-------+-------+-------+
байт 6  |  ------- биты 23-16 смещения процедуры-обработчика ---------  |
     +-------+-------+-------+-------+-------+-------+-------+-------+
     +-------+-------+-------+-------+-------+-------+-------+-------+
байт 7  |  ------- биты 31-24 смещения процедуры-обработчика ---------  |
     +-------+-------+-------+-------+-------+-------+-------+-------+

При вызове обработчика прерывания процессор сохраняет в стеке значения регистров EFLAGS, CS и EIP.
Возврат из обработчика осуществляется командой IRETD, которая восстанавливает значения, сохраненные в стеке.

Поподробнее об исключениях

Исключения в процессорах IA-32 бывают трех видов: fault (сбой), trap (ловушка) и abort (останов). Тип исключения зависит от того, когда процессор заметил ошибку, приведшую к исключению, и, следовательно, - куда указывают сохраненные в стеке CS:EIP, что в свою очередь определяет, возможно ли продолжение программы после завершения прерывания. Как правило, это выглядит так:

Все исключения IA-32 мы рассмотрим в следующем выпуске

8

ВОПРОС: в 6-м выпуске вы писали:
"Фактически, за вход в защищенный режим отвечает нулевой бит регистра CR0, который также называется битом PE (Protection Enable). " В доках что я переводил совсем не так. На самом деле отвечает за переход сброс регистра CS, а установка PE ни к чему не приводит. там в доках еще пример этого есть, что в PMODE мы не переходим пока не сделаем flush CS

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

Загрузчик

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

; бутсектор работает так:
; чтение можно производить только в первые 64к,
; поэтому мы сначала считываем цилиндр по адресу 0x50:0 - 0x50:0x2400,
; затем копируем его в нужное место
;
; Первый цилиндр считываем в самом конце
; (он размещается как раз по адресу 0x50:0)
[BITS 16]
[ORG 0]
; сколько цилиндров считывать
%define CYLS_TO_READ 10
; максимальное количество ошибок чтения
; (повторять операцию чтения необходимо как минимум три раза,
; т.к. ошибки возможны из-за того, что мотор привода не разогнался)
%define MAX_READ_ERRORS 5
; Точка входа:
entry:
cli ;на время установки стека запретим прерывания
mov ax, cs
mov ds, ax
mov es, ax
mov ss, ax
mov sp, start
sti ;разрешим прерывания
;; Необходимо скопировать загрузчик в
;; верхнюю часть доступной памяти, т.к.
;; текущий код (находящийся по адресу 0x0:0x7c00
     ;; будет переписан загруженными с дискеты данными
; В DS - адрес исходного сегмента
mov ax, 0x07c0
mov ds, ax

     ; В ES - адрес целевого сегмента
mov ax, 0x9000
mov es, ax
; Копируем с 0
xor si, si
xor di, di
; Копируем 128 двойных слов (512 байт)
mov cx, 128
rep movsd
; Прыжок туда, где теперь находится бутсектор
; (0x9000: 0)
jmp 0x9000:start
; следующий код выполняется по адресу 0x9000:0
start:
     ; занесем новые значения в сегментные регистры
mov ax, cs
mov ds, ax
mov ss, ax
; обрадуем пользователя сообщением
mov si, msg_loading
call kputs
; Дальнейшая процедура выполняет чтение цилиндра
; начиная с указанного в DI плюс нулевой цилиндр (в самом конце)
; В AX - адрес сегмента в который будут записаны считанные данные
mov di, 1
mov ax, 0x290
xor bx, bx
 .loop:
mov cx, 0x50
mov es, cx
push di
; Вычисляем какую головку использовать
shr di, 1
setc dh
mov cx, di
xchg cl, ch
pop di
; Уже все цилиндры считали?
cmp di, CYLS_TO_READ
je .quit
call kread_cylinder
;; Цилиндр считан по адресу 0x50:0x0 - 0x50:0x2400
;; (линейный 0x500 - 0x2900)
;; Выполним копирование этого блока по нужному адресу
pusha
push ds
mov cx, 0x50
mov ds, cx
mov es, ax
xor di, di
xor si, si
mov cx, 0x2400
rep movsb
pop ds
popa
; Увеличим DI, AX и повторим все сначала
inc di
add ax, 0x240
jmp short .loop
 .quit:
; Мы считывали начиная с 1-го цилиндра!
; (т.к. участок 0x50:0 использовался как буфер данных)
; теперь он свободен и мы можем считать нулевой цилиндр в него
mov ax, 0x50
mov es, ax
mov bx, 0
mov ch, 0
mov dh, 0
call kread_cylinder
; Прыжок на загруженный код
jmp 0x0000:0x0700
kread_cylinder:
     ;; процедура читает заданный цилиндр
;; ES:BX - буфер
;; CH - цилиндр
;; DH - головка
; Сбросим счетчик ошибок
mov [.errors_counter], byte 0
pusha
; Сообщим пользователю, какой цилиндр и головку читаем
mov si, msg_cylinder
call kputs
mov ah, ch
call kputhex
mov si, msg_head
call kputs
mov ah, dh
call kputhex
mov si, msg_crlf
call kputs
popa
pusha
 .start:
mov ah, 0x02
mov al, 18
mov cl, 1
; Прерывание дискового сервиса BIOS
int 0x13
jc .read_error
popa
ret
 .errors_counter:db 0
 .read_error:
     ; Если произошла ошибка, то увеличим счетчик,
; и выведем сообщение с кодом ошибки
inc byte [.errors_counter]
mov si, msg_reading_error
call kputs
call kputhex
mov si, msg_crlf
call kputs
; Счетчик ошибок превысил максимальное значение?
cmp byte [.errors_counter], MAX_READ_ERRORS
jl .start
; Ничего не получилось :(
mov si, msg_giving_up
call kputs
hlt
jmp short $

hex_table:db "0123456789ABCDEF"
kputhex:
     ; Процедура преобразует число в ASCII-код
; его шестнадцатеричного представления и выводит его
; (Да, я знаю, что это можно сделать четырьмя командами :))

pusha
xor bx, bx
mov bl, ah
and bl, 11110000b
shr bl, 4
mov al, [hex_table+bx]
call kputchar
mov bl, ah
and bl, 00001111b
mov al, [hex_table+bx]
call kputchar
popa
ret
; Процедура выводит символ в AL на экран
kputchar:
pusha
mov ah, 0x0E
int 0x10
popa
ret
; Процедура выводит строку, на которую указывает SI, на экран
kputs:
pusha
 .loop:
lodsb
test al, al
jz .quit
mov ah, 0x0e
int 0x10
jmp short .loop
 .quit:
popa
ret
; Служебные сообщения
msg_loading:db "the Operating System is loading...", 0x0A, 0x0D, 0
msg_cylinder:db "Cylinder:", 0
msg_head:db ", head:",0
msg_reading_error:db "Error reading from floppy. Errcode:",0
msg_giving_up:db "Too many errors, giving up",0x0A,0x0D, "Reboot your system, please", 0
msg_crlf:db 0x0A, 0x0D,0
; Сигнатура бутсектора:
TIMES 510 - ($-$$) db 0
db 0xAA, 0x55

Этот загрузчик выполняет загрузку данных, которые следуют сразу за бутсектором - начиная с сектора 0:0:2

А вот пример кода для вторичного загрузчика, который переведет систему в защищенный режим (это усовершенствование программы из 6-го выпуска). После выполнения всех необходимых действий он передает управление на код, находящийся в файле 'kernel.bin'. В следующих выпусках, когда мы начнем писать ядро, в этом файле будет находится чистый бинарный код ядра (он уже может быть написан с использованием ЯВУ). Ну а пока - можете поэкспериментировать! Попробуйте записать в kernel.bin какой-нибудь код (он должен быть скомпонован по адресу 0x200000 - сюда загрузчик копирует ядро - т.е. при использовании NASM в программы должна быть директива ORG [0x200000]) и попробуйте как это все работает

[BITS 16]
[ORG 0x700]
;; Загрузим в сегментные регистры 0 и установим стек
cli
mov ax, 0
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x700
sti
;; Выведем приветствие на экран
mov si, msg_intro
call kputs
;; Сообщение о том, что собираемся в PM
mov si, msg_entering_pmode
call kputs
;; Отключим курсор, чтобы не мозолил глаза
mov ah, 1
mov ch, 0x20
int 0x10
;; Установим базовый вектор контроллера прерываний в 0x20
mov al,00010001b
out 0x20,al
mov al,0x20
out 0x21,al
mov al,00000100b
out 0x21,al
mov al,00000001b
out 0x21,al
;; Отключим прерывания
cli
;; Загрузка регистра GDTR:
lgdt [gd_reg]
;; Включение A20:
in al, 0x92
or al, 2
out 0x92, al
;; Установка бита PE регистра CR0
mov eax, cr0
or al, 1
mov cr0, eax
;; С помощью длинного прыжка мы загружаем
;; селектор нужного сегмента в регистр CS
;; (напрямую это сделать нельзя)
jmp 0x8: _protected
;; Эта функция вывода строки работает
;; в реальном режиме!
;; (использует прерывание 0x10 BIOS)
kputs:
pusha
 .loop:
lodsb
test al, al
jz .quit
mov ah, 0x0e
int 0x10
jmp short .loop
 .quit:
popa
ret
;; Следующий код - 32-битный
[BITS 32]
;; Сюда будет передано управление
;; после входа в PM
_protected:
;; Загрузим регистры DS и SS селектором
;; сегмента данных
mov ax, 0x10
mov ds, ax
mov es, ax
mov ss, ax
;; Наше ядро (kernel.bin) слинковано по адресу 2мб
;; Переместим его туда
;; kernel_binary - метка, после которой
;; вставлено ядро
;; (фактически - его линейный адрес)
mov esi, kernel_binary
;; адрес, по которому копируем
mov edi, 0x200000
;; Размер ядра в двойных словах
;; (65536 байт)
mov ecx, 0x4000
;; Поехали :)
rep movsd
;; Ядро скопировано, передаем управление ему
jmp 0x200000
gdt:
dw 0, 0, 0, 0; Нулевой дескриптор
db 0xFF; Сегмент кода с DPL=0
db 0xFF; Базой=0 и Лимитом=4 Гб
db 0x00
db 0x00
db 0x00
db 10011010b
db 0xCF
db 0x00
db 0xFF; Сегмент данных с DPL=0
db 0xFF; Базой=0 и Лимитом=4Гб
db 0x00
db 0x00
db 0x00
db 10010010b
db 0xCF
db 0x00
;; Значение, которое мы загрузим в GDTR:
gd_reg:
dw 8192
dd gdt
msg_intro:db "Secondary bootloader received control", 0x0A, 0x0D, 0
msg_entering_pmode:db "Entering protected mode...", 0x0A, 0x0D, 0
kernel_binary:
incbin 'kernel.bin'

Руководство по компиляции вышеприведенного кода (допустим что вы назвали файлы bootsect.asm и sb.asm соотвественно):

Первый файл - NASM'ом в чистый бинарный формат
nasm -fbin -o bootsect.bin bootsect.asm

Второй - тоже:
nasm -fbin -o sb.bin sb.asm

Эти два файла должны быть записаны на дискету СРАЗУ друг за другом. Один из самых простых способов создать нужный образ дискеты - включить в самый конец (после сигнатуры бутсектора) bootsect.asm директиву:
incbin 'sb.bin' - в результате NASM включит в bootsect.bin код файла sb.bin (который, разумеется, теперь надо компилировать первым)

UNIX'оиды, конечно использовали бы для этого дела dd, но вышеприведенный метод хорош своей кроссплатформенностью :)

9

Переходим на Си.

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

Но нам и не обязательно использовать формат вроде ELF или PE - компоновщик ld, который я буду использовать, поддерживает создание сырых бинарников с помощью опции --oformat binary. Второй важный параметр, который нам понадобится - указание адреса, по которому будет произведена компоновка. Фактически все наше ядро располагается в сегменте кода, поэтому необходимо указать только его адрес: -Ttext 0x200000 (так уж повелось, что секция (сегмент) кода называется 'text').

И еще один момент: при создании объектного кода, который в дальнейшем будет компоновкой приведен к бинарному, мы указываем GCC опцию -ffreestanding. Теперь он не будет делать глупых предположений по поводу функций стандартной библиотеки (например не будет предполагать, что printf - это именно printf (const char *fmt, ...))

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

void main()
{
printf ("Hello World!\n");
}

Что сделает GCC при компиляции этого файла? Он вынесет константу "Hello World!\n" в начало файла. Получится, что в начале kernel.bin (а ведь туда мы и передаем управление!) у нас будет не код, а непонятно что. Работать оно конечно не будет

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

[BITS 32]
[EXTERN kernel_main]
[GLOBAL _start]
_start:
mov esp, 0x200000-4
call kernel_main

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

Стандартная библиотека

Вторая трудность, которая нас ожидает - отсутствие функций стандартной библиотеки. Конечно, как только мы получили возможность использовать Си, так и чешутся руки вывести на экран какой-нибудь хелловорлд, но... нечем. Обычно, функция printf бралась из библиотеки Си операционной системы, ну а сейчас ей взяться неоткуда. Будем писать ее сами, ну а заодно и несколько других функций, без которых жизнь наша будет подобна ночному кошмару. Та часть функций, которые мы напишем и будем использовать сегодня, предназначена для функционирования телетайпного устройства (tty). Еще раз напомню, что в нем:
1) можно использовать контрольные символы (например '\n') для перемещения курсора
2) автоматически производится контроль за экраном (например при выходе курсора за границы экрана происходит сдвиг экрана вверх).
Создать tty-устройство на базе видеопамяти несложно и ниже приведена его реализация:

#define VIDEO_WIDTH 80    //ширина экрана
#define VIDEO_HEIGHT 25   //высота экрана
#define VIDEO_RAM 0xb8000 //адрес видеопамяти
int tty_cursor;    //положение курсора
int tty_attribute; //текущий аттрибут символа
//Инициализация tty
void init_tty()
{
  tty_cursor = 0;
  tty_attribute = 7;
}
//Смена текущего аттрибута символа
void textcolor(char c)
{
  tty_attribute = c;
}
//Очистка экрана
void clear()
{
  char *video = VIDEO_RAM;
  int i;
  for (i = 0; i < VIDEO_HEIGHT*VIDEO_WIDTH; i++) {
 *(video + i*2) = ' ';
  }

  tty_cursor = 0;
}
//Вывод одного символа в режиме телетайпа
void putchar(char c)
{
  char *video = VIDEO_RAM;
  int i;
  switch (c) {
  case '\n': //Если это символ новой строки
 tty_cursor+=VIDEO_WIDTH;
 tty_cursor-=tty_cursor%VIDEO_WIDTH;
 break;

  default:
 *(video + tty_cursor*2) = c;
 *(video + tty_cursor*2+1) = tty_attribute;
 tty_cursor++;
 break;
  }
  //Если курсор вышел за границу экрана, сдвинем экран вверх на одну строку
  if(tty_cursor>VIDEO_WIDTH*VIDEO_HEIGHT){
 for(i=VIDEO_WIDTH*2;i<=VIDEO_WIDTH*VIDEO_HEIGHT*2+VIDEO_WIDTH*2;i++){
   *(video+i-VIDEO_WIDTH*2)=*(video+i);
 }
 tty_cursor-=VIDEO_WIDTH;
  }
}
//Вывод строки, заканчивающейся нуль-символом
void puts(const char *s)
{
  while(*s) {
 putchar(*s);
 s++;
  }
}

А теперь о том, как заставить это все работать. Предположим, что tty у вас находится в файле ktty.c, "переходник" для си-кода - в startup.asm, а главная функция ядра такого содержания:

void kernel_main()
{
  init_tty();
  clear();
  puts("We use C, isn't this great?\n");
  for(;;);
}
находится в файле kernel.c

Сборка ядра производится таким образом:
gcc -ffreestanding -c -o ktty.o ktty.c
gcc -ffreestanding -c -o kernel.o kernel.c
nasm -felf -o startup.o startup.asm
ld --oformat binary -Ttext 0x200000 -o kernel.bin startup.o ktty.o kernel.o

Не правда ли, набивать все это в консоли достаточно утомительно? О том как процесс сборки упростить - читайте в следующем абзаце.

Программа 'Make'

Программа 'make' может управлять компиляцией основываясь на зависимостях(например: объектный файл kernel.o зависит от исходного файла kernel.c). Лучше всего принцип ее действия рассмотреть на конкретном Makefile(он будет предназначен для компиляции кода рассмотренного выше).
#Определим переменные OBJECTS и CFLAGS
#(к ним можно будет обращаться как $(OBJECTS) и $(CFLAGS)
OBJECTS=startup.o kernel.o ktty.o
CFLAGS=-ffreestanding -c
#цель 'all' - "глобальная" цель всех наших пертурбаций -
#получение образа дискеты image.bin из файла bootsect.asm
#(я предполагаю, что файл sb.bin был вставлен в конце bootsect.asm
#директивой incbin 'sb.bin')
#для успешного выполнения 'all' должны присутствовать файлы
#bootsect.asm и sb.bin (они указываются после двоеточия)
#(это и есть зависимости)
#действие для выполнения (nasm -fbin -o image.bin bootsect.asm)
#указано строчкой ниже
all: bootsect.asm sb.bin
nasm -fbin -o image.bin bootsect.asm
#для создания sb.bin нам нужны sb.asm и kernel.bin
sb.bin: sb.asm kernel.bin
nasm -fbin -o sb.bin sb.asm
#для создания kernel.bin нам нужны файлы, указанные в переменной OBJECTS
kernel.bin: $(OBJECTS)
ld --oformat binary -Ttext 0x200000 -o kernel.bin $(OBJECTS)
#для создания startup.o нам нужен только startup.asm
startup.o: startup.asm
nasm -felf -o startup.o startup.asm
#для создания kernel.o - нужен kernel.c
kernel.o: kernel.c
gcc $(CFLAGS) -o kernel.o kernel.c
#ну а для ktty.o - ktty.c
ktty.o: ktty.c
gcc $(CFLAGS) -o ktty.o ktty.c

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

nasm -felf -o startup.o startup.asm
gcc -ffreestanding -c -o kernel.o kernel.c
gcc -ffreestanding -c -o ktty.o ktty.c
ktty.c: In function clear':
ktty.c:18: warning: initialization makes pointer from integer without a cast
ktty.c: In function putchar':
ktty.c:31: warning: initialization makes pointer from integer without a cast
ld --oformat binary -Ttext 0x200000 -o kernel.bin startup.o kernel.o ktty.o
nasm -fbin -o sb.bin sb.asm
sb.asm:151: warning: uninitialised space declared in .text section: zeroing
nasm -fbin -o image.bin bootsect.asm

Как видите, make поочередно провела все этапы компиляции, причем именно в том порядке, какой был необходим (т.к. она руководствовалась указанными в Makefile зависимостями)

Исключения IA-32

В теоретической части этого выпуска мы поговорим об исключениях, присутствующих в процессорах IA-32. Ниже приведена полная их таблица для процессора Pentium 4 (примечание: все исключения обратно-совместимы).
Номер исключения Описание
0 Деление на нуль (Divide Error Exception или #DE )
Тип исключения: fault
1 Отладочное прерывание (Debug Exception или #DB)
Тип исключения: trap или fault - смотря как вызвано
2 NMI - немаскируемое прерывание
3 Точка останова (Breakpoint Exception или #BP)
Тип: trap
Примечание: используется отладчиками, т.к. инструкция INT3 занимает один байт (0xCC)
4 Переполнение (Overflow Exception или #OF)
Тип: trap
Примечание: вызвается если при выполнении инструкции INTO (Interrupt on overflow) флаг переполнения OF установлен
5 Выход за допустимые границы при BOUND (Bound Range Exceeded Exception или #BR)
Тип: fault
Примечание: вызвается если операнд инструкции BOUND выходит за границы массива
6 Неправильная инструкция (Invalid Opcode Exception или #UD)
Тип: fault
Примечание: вызывается при попытке выполнить несуществующую инструкцию или инструкцию с недопустимыми операндами
7 Математический сопроцессор не доступен (No Math или #NM).
Тип: fault
Примечание: вызывается при попытке выполнить инструкцию FPU, если его использование запрещено (проверяются несколько флагов CR0)
8 Двойная ошибка (Double Fault Exception или #DF)
Тип: abort
Примечание: вызывается, если при вызове обработчика для исключения случилось еще одно исключение. Если при вызове #DF опять случится исключение, то процессор получит сигнал RESET# (обычно это приводит к перезагрузке системы).
9 Зарезервировано
Примечание: на 386 это было Coprocessor Segment Overrun
10 (0xA) Ошибочный TSS (Invalid TSS или #TS)
11 (0xB) Несуществующий сегмент (Segment Not Present или #NP)
Тип: fault
Примечание: вызывается при обращении к сегменту (или дескриптору какого-либо шлюза), бит P которого установлен в 0.
12 (0xC) Ошибка стека (Stack Fault Exception или #SS)
Тип: fault
Примечание: вызывается при превышении лимита сегмента стека или загрузке несуществующего (P=0) дескриптора в SS
13 (0xD) Общее исключение защиты (General Protection Exception или #GP)
Тип: fault
Примечание: основное исключение защищенного режима. Не перечесть всех случаев в которых оно вызывается :)
14 (0xE) Ошибка страничной адресации (Page Fault Exception или #PF)
Тип: fault
Примечание: в регистре CR2 находится адрес, обращение к которому вызвало ошибку
15 (0xF) Зарезервировано
16 (0x10) Ошибка сопроцессора (FPU Error или #MF)
Тип: fault
17 (0x11) Ошибка выравнивания (Alignment Check Exception или #AC)
Тип: fault
Примечание: вызывается при невыровненном обращении к памяти непривилегированным (CPL=3) кодом если установлены флаги AC в EFLAGS и AM в CR0
18 (0x12) Машинно-зависимая ошибка (Machine Check Exception или #MC)
Тип: abort
19 (0x13) Ошибка SSE/SSE2 (SIMD Floating Point Exception или #XF)
Тип: fault
20 - 31 (0x14 - 0x1F) Зарезервированы

9.a от LaFlour

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

Изменения для пользователей win32 платформ.
Все действия Lonesome описывал для *nix систем. Но пользователи windows столкнутся с особеностями софта, который не позволит им создать исходный бинарник с применением кода на си как было описано в 9м выпуске. Здесь я постараюсь внести некоторые пояснения и опишу какие изменения нам надо будет предпринять с вами.

Все дело заключается в компоновщике, который не хочет понимать полученный от Nasm объектный код в формате ELF (Примечание: для тех кто не смог создать объектный файл с ключом -felf - вам надо взять Nasm для win32) Но из этой ситуации есть выход. Давайте заменим наш startup.asm на аналогичный, но переделанный на си. Вот код startup.c:

void _start()
{
kernel_main();
}

Теперь давайте его откомпилируем:
gcc -ffreestanding -c -o startup.o startup.c

Проблем быть не должно.

После этого если мы попробуем скомпоновать файлы:
ld --oformat binary -Ttext 0x200000 -o kernel.bin startup.o ktty.o kernel.o
То получим ошибку:
":ld: PE operations on non PE file"

Вот такой наш LD. Ну не хочет он создавать сырые бинарники под Win32. Но из этой ситуации есть выход. Скомпонуем наши файлы в бинарник (не сырой), то есть в тот формат, который нам LD предлагает по умолчанию:
ld -Ttext 0x200000 -o kernel.bin startup.o ktty.o kernel.o

Все! Победили :) Теперь осталось получить сырой бинарник. Для этого делаем:
objcopy kernel.bin -O binary

Готово. Теперь по очереди соберем sb.bin и bootsect.bin. Итоговый bootsect.bin (содержащий все наши мучения) можем смело писать на флоп и тестить в VMWare. В итоге мы должны получить на экране надпись:
"We use C, isn't this great?"

Если у вас она не появилась, то что-то вы сделали не так. Проверьте все шаги по очереди. На этом все.

Данный пример был проверен и отлажен на системe Windows XP, Nasm for Win32.

9.b

На этом проблемы для пользователей Windows не заканчиваются. Оказалось, что у них под рукой нет утилиты Make. Взять GNU Make для Windows можно здесь:
http://www.atnetsend.net/files/make-3.77.zip

Но не спешите ее скачивать (сначала посмотрите, может она у вас уже есть :) - Make обычно поставляется вместе с другими средствами разработки, например она есть в комплекте LCC-WIN32.
Также существует версия Make от Microsoft - называется NMake.

10

Inline Assembler в GCC

При разработке операционной системы, нам придется (раз уж мы перешли на Си) использовать встроенный ассемблер (Inline Assembler) компилятора GCC. Он имеет формат AT&T (в противоположность обычному формату Intel). Это означает, что:

Вот несколько примеров соответствия синтаксиса AT&T синтаксису Intel:
AT&T Intel
movl $0, %eax mov eax, 0
movl $variable, %ebx mov ebx, variable
movw variable, %ebx mov ebx, word [variable]
movb $0, (,%eax,) mov [eax], byte 0
movl %eax, $111(%ebx, %edx, 2) mov [111 + eax + edx*2], eax

Код на ассемблере можно вставить в исходник Си с помощью команды asm(..):
asm("movw %ax, %bx");

Несколько инструкций можно разделить обычным символом новой строки ('\n'):
asm("xorl %eax, %eax \n incl %eax");

GCC constraints или расширенные возможности Inline Assembler'а

По сути дела, constraints - это параметры вызова Inline Assembler'а. В основном они нам понадобятся для того, чтобы связать Inline Assembler с кодом на Си (чтобы использовать одни и те же константы и пр.). Рассмотрим примеры:
asm("lidt 0(,%0,)"::"a"(IDT_REG)); - это означает: поместить значение IDT_REG в регистр EAX (который указывается символом 'a', и выполнить команду lidt 0(,%eax,)

asm("cpuid":"=a"(result_eax),
"=b"(result_ebx), "=c"(result_ecx), "=d"(result_edx):"a"(source_eax)
- это означает: поместить содержимое переменной source_eax в регистр EAX, выполнить команду CPUID и поместить содержимое регистров EAX, EBX, ECX, EDX в переменные result_eax, result_ebx, result_ecx, result_edx соответственно

Как видите, если перед параметром указывается префикс '=', то после выполнения инструкции происходит присвоение указанным переменным

Теперь, с учетом параметров, в общем виде команда asm(..) выглядит так:
asm("ИНСТРУКЦИЯ":"ВЫХОДНЫЕ_ПАРАМЕТРЫ":"ВХОДНЫЕ_ПАРАМЕТРЫ");

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

Обработка прерываний в системе

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

/*
  intslib.c
*/
#define IDT_TABLE 0x100000
#define IDT_REG 0x100800
#define SYS_CODE_SELECTOR 0x8
//Функция i_install() устанавливает в качестве обработчика vector функцию func.
//Тип шлюза (прерывания [0x8e] или ловушки [0x8f]) указывается параметром type.
//Фактически, эта функция создает (или изменяет) соответствующий дескриптор в таблице IDT
void i_install(unsigned char vector, void (*func)(), unsigned char type)
{
  char * idt_table=IDT_TABLE;

  unsigned char i;
  unsigned char b[8];
  b[0]=  (unsigned int)func & 0x000000FF;
  b[1]=( (unsigned int)func & 0x0000FF00) >> 8;
  b[2]=SYS_CODE_SELECTOR;
  b[3]=0;
  b[4]=0;
  b[5]=type;
  b[6]=( (unsigned int)func & 0x00FF0000) >> 16;
  b[7]=( (unsigned int)func & 0xFF000000) >> 24;


  for(i=0;i<8;i++){
 *(idt_table+vector*8+i)=b[i];
  }


}
//Функция i_setup() загружает регистр IDTR
void i_setup()
{
  unsigned short *table_limit = IDT_REG;
  unsigned int *table_address = IDT_REG+2;
  *table_limit = 256*8 - 1;
  *table_address = IDT_TABLE;
  asm("lidt 0(,%0,)"::"a"(IDT_REG));
  asm("sti");
}
//Включение обработки прерываний
void i_enable()
{
  asm("sti");
}
//Отключение обработки прерываний
void i_disable()
{
  asm("cli");
}

Теперь эти четыре функции будут предоставлять остальной части ядра удобный высокоуровневый интерфейс для установки обработчиков прерываний

Работа с портами ввода/вывода

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

/*
  ioports.c - функции работы с портами ввода/вывода
*/
__inline__ void outportb(unsigned short port, unsigned char value)
{
  asm("outb %b0,%w1":: "a"(value), "d"(port));
}
__inline__ char inportb(unsigned short port)
{
  char value;
  asm("inb %w1, %b0": "=a"(value): "d"(port));
  return value;
}

11

VMware Workstation vs Bochs

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

В общем, я перехожу на Bochs (http://bochs.sourceforge.net/). Во первых, он Free и Open Source, во вторых возможности отладки в нем громадные, а в третьих, надеюсь, глючит он меньше (благодаря первому пункту). Хотя, конечно, по большому счету, на эмулятор надеяться никогда нельзя - все равно на "живом" компьютере работает по другому (одна из причин - различное время отклика настоящей аппаратуры и виртуальной)

Разбор полетов

Опубликованный в номере 8 загрузчик начинается таким кодом:

entry:
cli
mov ax, cs
mov ds, ax
mov es, ax
mov ss, ax
mov sp, start
Большое спасибо Михаилу Александровичу Ананьину AKA Smile за указание на ошибку - т.к. мы помещаем в стек метку start, то прерывание таймера (если оно вдруг произойдет) при копировании загрузчика по адресу 0x9000:0 перезапишет код, находящийся перед start.

Для исправления этой проблемы необходимо поместить в SP не start, а, скажем, 0x7c00 (Мы не можем использовать entry, т.к. она равна нулю из-за директивы ORG 0)

Контроллер клавиатуры

Хотя поддержка клавиатуры имеет смехотворно низкий приоритет при разработке полнофункциональных ОС, она является краеугольной фичей и вообще, основной возможностью нашей системы :). Клавиатура занимает линию IRQ1. Следовательно, раз мы поменяли базовый вектор контроллера прерываний на 0x20, то прерывание от клавиатуры окажется под номером 0x21 (на всякий случай напомню, что на IRQ0 (а у нас - int 0x20) находится таймер, генерирующий прерывание 18.2 раза в секунду). Для управления клавиатурой предназначены порты 0x60 - 0x6f.

Нас будут интересовать порт 0x60 (из него мы будем считывать скан-код нажатой клавиши) и порт 0x61.

Порт 0x61 - нам нужен только бит 7, который указывает, заблокирована ли клавиатура. Этот бит необходимо сбрасывать в конце обработчика прерывания клавиатуры.

Примечание: при чтении из порта 0x60 мы получаем скан-код клавиатуры (не ASCII-код!), который затем необходимо будет по таблице преобразовать в ASCII. Вот таблица скан-кодов, которая подойдет для нашей системы (фактически это два массива - второй для клавиш с нажатым шифтом):

/* scancode.h */
char scancodes[] = {
  0,
  0, //ESC
  '1','2', '3', '4', '5', '6', '7', '8', '9', '0',
  '-', '=',
  8, //BACKSPACE
  '\t',//TAB
  'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']',
  '\n', //ENTER
  0, //CTRL
  'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', '\'', '',
  0, //LEFT SHIFT,
  '\\', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/',
  0, //RIGHT SHIFT,
  '*', //NUMPAD
  0, //ALT
  ' ', //SPACE
  0, //CAPSLOCK
  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, //F1 - F10
  0, //NUMLOCK
  0, //SCROLLOCK
  0, //HOME
  0,
  0, //PAGE UP
  '-', //NUMPAD
  0, 0,
  0, //(r)
  '+', //NUMPAD
  0, //END
  0,
  0, //PAGE DOWN
  0, //INS
  0, //DEL
  0, //SYS RQ
  0,
  0, 0, //F11-F12
  0,
  0, 0, 0, //F13-F15
  0, 0, 0, 0, 0, 0, 0, 0, 0, //F16-F24
  0, 0, 0, 0, 0, 0, 0, 0
};
char scancodes_shifted[] = {
  0,
  0, //ESC
  '!', '@', '#', '$', '%', '^', '&', '*', '(', ')',
  '_', '+',
  8, //BACKSPACE
  '\t',//TAB
  'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '{', '}',
  '\n', //ENTER
  0, //CTRL
  'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', ':', '"', '~',
  0, //LEFT SHIFT,
  '|', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', '<', '>', '?',
  0, //RIGHT SHIFT,
  '*', //NUMPAD
  0, //ALT
  ' ', //SPACE
  0, //CAPSLOCK
  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, //F1 - F10
  0, //NUMLOCK
  0, //SCROLLOCK
  0, //HOME
  0,
  0, //PAGE UP
  '-', //NUMPAD
  0, 0,
  0, //(r)
  '+', //NUMPAD
  0, //END
  0,
  0, //PAGE DOWN
  0, //INS
  0, //DEL
  0, //SYS RQ
  0,
  0, 0, //F11-F12
  0,
  0, 0, 0, //F13-F15
  0, 0, 0, 0, 0, 0, 0, 0, 0, //F16-F24
  0, 0, 0, 0, 0, 0, 0, 0
};

Примечание 2: прерывание вызывается как при нажатии, так и при отпускании клавиши, и скан-код отпущенной клавиши равен скан-коду нажатой плюс 0x80.

Вы уже догадались, как определить, была ли нажата клавиша 'Shift'?

А теперь реализуем на практике взаимодействие с контроллером клавиатуры:

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

#define IRQ_HANDLER(func) void func (void);\
 asm(#func ": pusha \n call _" #func " \n movb $0x20, %al \n outb %al, $0x20 \n popa \n iret \n");\
 void _ ## func(void)

Выглядит он достаточно коряво, но в этом вина не моя, а разработчиков CPP - препроцессора Си.

Теперь можно создавать обработчики аппаратных прерываний вот так:

IRQ_HANDLER(имя_функции)
{
}

Обработчик таймера нам нужен только как "заглушка", поэтому внутри у него ничего не будет:

IRQ_HANDLER(irq_timer)
{

}

А вот обработчик IRQ1 (клавиатуры) уже выполняет кое-какую работу, а именно - выводит на экран нажатую клавишу.

//Здесь мы храним состояние шифта
char shift = 0;
IRQ_HANDLER(irq_keyboard)
{
  unsigned char scancode, ascii;
  unsigned char creg;
  //Прочитаем скан-код из порта 0x60
  scancode = inportb(0x60);

  switch(scancode) {

 //Скан-коды нажатого шифта
  case 0x36:
  case 0x2A:
 shift = 1;
 break;
 //Скан-коды отпущенного шифта
  case 0x36 + 0x80:
  case 0x2A + 0x80:
 shift = 0;
 break;

  default:
  //Если клавиша отпущена...
 if(scancode >= 0x80) {
 //То ничего не делать

 } else {
 //А если нажата...

   if(shift){
   //Если шифт нажат, но преобразовать скан-код в "шифтнутое" ASCII
ascii = scancodes_shifted[scancode];
   } else {
   //А если не нажат - то в обычное
ascii = scancodes[scancode];
   }

   //Если в результате преобразования нажата клавиша с каким-либо
   //символом, то вывести его на экран
   if(ascii != 0) {
putchar(ascii);
   }
 }
 break;
  }
  //Считаем байт состояния клавиатуры
  creg = inportb(0x61);

  //Установим в нем старший бит
  creg |= 1;
  //И запишем обратно
  outportb(0x61, creg);
}

Ну и наконец - функция, которая установит все обработчики прерываний и разрешит их обработку:

void init_interrupts()
{
  i_install(0x20, &irq_timer, 0x8e);
  i_install(0x21, &irq_keyboard, 0x8e);
  i_setup();
  i_enable();
}

Что нам осталось? Сохранить вышенаписанные функции как файл handlers.c (не забыв сделать в начале #include "scancode.h"), подключить его к нашей системе, и вызвать из функции kernel_main() функцию init_interrupts(). Все! Можно нажимать на клавиши - если все в порядке, то соответствующие символы отобразятся на экране монитора

12

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

Для начала определимся с функциями, которые нам понадобятся:
printf - форматированный вывод
strcmp - сравнение строк
strcpy - копирование строки
memcpy - копирование памяти
getsn - ввод строки пользователем

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

printf

Наш printf будет представлять собой сильно урезанный (и немного измененный) аналог printf стандартной библиотеки Си. Будут поддерживаться форматы:
%i - беззнаковое целое десятичное число
%s - строка
%x - беззнаковое целое шестнадцатеричное число от 0 до 0xFF
%X - беззнаковое целое шестнадцатеричное число от 0 до 0xFFFFFFFF
%c - ASCII-символ
%z - установка цвета терминала

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

Для printf понадобятся некоторые вспомогательные функции. В первую очередь - это vprintf, которая и будет выполнять всю работу по расшифровке и выводу форматов. По сути дела, задача printf сводится только к "упаковке" переданных параметров (количество которых, как известно, варьируется) в структуру типа va_list и вызову vprintf.

Другие вспомогательные функции будут выполнять работу по выводу на экран чисел в текстовом виде. Это:
putdec - вывод десятичного числа
puthexd - вывод шестнадцатеричной или десятичной цифры
puthex - вывод шестандцатеричного числа от 0 до 0xFF
puthexi - вывод шестнадцатеричного числа от 0 до 0xFFFFFFFF

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

#include <stdarg.h>
void putdec(unsigned int byte)
{
  unsigned char b1;
  int b[30];
  signed int nb;
  int i=0;

  while(1){
 b1=byte%10;
 b[i]=b1;
 nb=byte/10;
 if(nb<=0){
   break;
 }
 i++;
 byte=nb;
  }

  for(nb=i+1;nb>0;nb--){
 puthexd(b[nb-1]);
  }
}
void puthexi(unsigned int dword)
{
  puthex( (dword & 0xFF000000) >>24);
  puthex( (dword & 0x00FF0000) >>16);
  puthex( (dword & 0x0000FF00) >>8);
  puthex( (dword & 0x000000FF));
}
void puthex(unsigned char byte)
{
 unsigned  char lb, rb;
  lb=byte >> 4;

  rb=byte & 0x0F;


  puthexd(lb);
  puthexd(rb);
}
void puthexd(unsigned char digit)
{
  char table[]="0123456789ABCDEF";
putchar(table[digit]);
}
void printf(const char *fmt, ...)
{
  va_list args;
  va_start(args, fmt);
  textcolor(7);
  vprintf(fmt, args);
  va_end(args);
}
void vprintf(const char *fmt, va_list args)
{
  while (*fmt) {

 switch (*fmt) {
 case '%':
   fmt++;
   switch (*fmt) {
   case 's':
puts(va_arg(args, char *));
break;
   case 'c':
putchar(va_arg(args, unsigned int));
break;
   case 'i':
putdec(va_arg(args, unsigned int));
break;
   case 'x':
puthex(va_arg(args, unsigned int));
break;
   case 'X':
puthexi(va_arg(args, unsigned int));
break;
   case 'z':
textcolor(va_arg(args,unsigned int));
break;
   }

   break;

 default:
   putchar(*fmt);
   break;
 }
 fmt++;
  }
}

getsn

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

Для реализации getsn нам понадобится вспомогательная функция getc, которая считывает введенный символ (если символа в буфере нет - то ждет, пока он появится). А функция ungetc() будет использоваться в обработчике прерывания клавиатуры для помещения в буфер нажатой клавиши. Примечание: принцип работы нашей ungetc() отличается от стандартной!

struct {
  int pointer;
  char buff[0x100];
} tty_buffer;
//Синхронный getc (возвращает символ; если буфер пуст -
//ждет пока символ появится)
char getc(void)
{
  char symbol;
  int i;
  while (tty_buffer.pointer == 0) {
 asm("hlt");
  }

  symbol = tty_buffer.buff[0];

  for (i = 0; i < tty_buffer.pointer; i++) {
 tty_buffer.buff[i] = tty_buffer.buff[i-1];
  }
  tty_buffer.pointer--;

  return symbol;
}
void ungetc(char c)
{
  tty_buffer.buff[tty_buffer.pointer] = c;
  tty_buffer.pointer++;
}
void getsn(const char *s, int num)
{
  char c = 0;
  char *start;

  start = s;
  while ( (c = getc()) != '\n') {

 switch(c) {
 case 8:

   if( start < s) {
s--;
*s = 0;
putchar(c);
   }
   break;

 default:
   if ( (s - start) <= num ) {
*s = c;
putchar(c);
s++;
*s = 0;
   }
   break;
 }
  }

  putchar('\n');
}

В качестве буфера введенных символов мы создаем структуру tty_buffer. Теперь осталось только изменить строчку putchar(ascii) в обработчике клавиатуры на ungetc(ascii)

Все! Можно прямо сейчас внести изменения в главный файл ядра kernel.c и опробовать наши замечательные функции.

void kernel_main()
{
 init_tty();
 clear();
 init_interrupts();

 printf("Decimal %i\n"
"Hexadecimal short: %x\n"
"Hexadecimal long: %X\n"
"String: %s\n",
12345, 0x77, 0x12345678, __DATE__);

 getsn_test();

 for(;;);
}
void getsn_test()
{
  char buffer[0x100];

  printf("Enter something:");

  getsn(buffer, 0x100);

  printf("You entered: %s\n", buffer);
}

Чего-то не хватает, не так ли? Мы забыли добавить специальный обработчик случая нажатия Backspace (ascii-код - 8) в функцию putchar(), поэтому нажатие Backspace выводит на экран какую-то абракадабру. Впрочем нет ничего проще, чем исправить эту ситуацию - достаточно добавить в искомый switch вот такой обработчик:

  case 8: //backspace
 tty_cursor--;
 *(video + tty_cursor*2) = ' ';
 break;

13

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

Разбор полетов и ваших писем

Спасибо uncle Bob за ценные замечания:

> 2. После того, как ядро считано в память, можно остановить двигатель
> дисковода. В файле bootsect.asm, перед прыжком на загруженный код
> (перед jmp 0x0000:0x0700) предлагаю добавить вызов функции остановки
> привода дисковода:...
> call kill_motor
> jmp 0x0000:0x0700
> ...
>
> Функция имеет следующее содержание:
>
> kill_motor:
> push dx
> push ax
> mov dx,0x3f2
> xor al,al
> out dx,al
> pop ax
> pop dx
> ret
>
> 3. В функции i_setup() предлагаю убрать последнюю инструкцию -
> asm("sti"). В установщике обработчиков прерываний init_interrupts()
> следует вызов функций i_setup и i_enable. Получается, инструкция
> asm("sti") вызывается два раза подряд.

14

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

Многозадачность

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

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

Наиболее важным (но далеко не единственным) ресурсом, который разделяют задачи, является сам процессор, а точнее процессорное время - т.е. время, которое отводится процессором на выполнение какой-либо задачи.

По какому принципу может осуществляться многозадачность?
1) сама задача может время от времени передавать управление другой задаче
2) управление может поочередно отбираться от задачи и передаваться другой задаче

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

Три параметра многозадачной вычислительной системы

Эффективность работы многозадачной вычислительной системы определяется тремя параметрами:

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

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

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

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

15

Один вопрос мне задают столь часто, что придется ответить на него в интре :)
Q: где взять под Windows GCC, ld и objcopy?
A: все эти программы есть в комплекте MinGW (Minimalist GNU for Win32), который находится по адресу: http://mingw.sourceforge.net/. Для самых ленивых привожу ссылку на то, что именно нужно качать: http://prdownloads.sf.net/mingw/MinGW-2.0.0-3.exe?download

Различные типы архитектур ядра ОС

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

Давайте подумаем, каким образом мы можем структурировать ядро операционной системы (тот вариант, при котором оно вообще никак не структурируется, мы не рассматриваем, ибо отсутствие структуры допустимо только для самых простейших и примитивнейших ОС).

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

[Аппаратная часть компьютера]
 ^
 |
 v
==========================================
========= уровень супервизора ============
[уровень аппаратной абстракции]

 ^
 |
 v
[основные механизмы ядра]
 ^
 |
 v
[распределение ресурсов, вспомогательные модули (ФС и пр.)]
 ^
 |
 v
[системные вызовы ядра]

 ^
======================================
========= уровень пользователя =======
 v

[пользовательские приложения]

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

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

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

В системах с монолитной архитектурой все ядро работает на уровне супервизора. Именно ядро такой архитектуры приведено на схеме

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

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

В следующем выпуске мы подробно рассмотрим микроядерную архитектуру и немного коснемся экзоядерной. Монолитная же нас особенно не интересует - во первых, она достаточно проста для понимания, во вторых она уже сильно устарела (хотя все современные популярные системы: Windows NT, Linux и пр. представляют собой именно монолитные архитектуры).

16

Микроядерная архитектура ядра ОС

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

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

Как правило, абстракции, предоставляемые ядром, являются ОС-нейтральными. Это значит, что на их базе можно создать множество разных и очень сильно различающихся между собой операционных систем (в том числе можно и портировать на микроядро существующие системы с монолитным ядром; к примеру, Apple когда-то портировала Linux на микроядро Mach для использования на PowerMac, также существует проект Linux на L4).

Кстати, название "микроядро" подразумевает малый размер ядра (еще бы, ведь оно предоставляет гораздо меньше сервисов, чем монолитное), но важно отметить, что малый размер - лишь следствие, и многие (большинство) микроядра достаточно объемны. Но все же встречаются приятные исключения, например, Neutrino (микроядро систем QNX), а микроядро L4 занимает в памяти всего лишь 32Кб.

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

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

Какие же преимущества у микроядерных систем?

Самые сообразительные читатели уже наверняка догадались, что же является главным в микроядре. Конечно же, это механизмы межпроцессного взаимодействия, или IPC (InterProcess Communication). Микроядро в своем идеальном проявлении состоит только из средств IPC (взаимодействие приложений с ядром тоже осуществляется при помощи тех же средств)

Выше приведено много теории, и хотелось бы рассмотреть все это на примере какого-нибудь микроядра?

Без проблем :) Давайте подробно рассмотрим применение микроядерных концепций на примере микроядра операционной системы Tyros, в разработке которого ваш покорный слуга непосредственным образом участвует.

За основу IPC были выбраны обычные очереди сообщений (т.е. при передаче сообщения оно добавляется в очередь сообщения получателя, и только если очередь заполнена, пославший сообщение процесс останавливается до тех пор пока очередь освободится и станет возможным передача сообщения).

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

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

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

Как реализуется второе преимущество - стабильность и безопасность?

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

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

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

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

void main()
{
int tty_attribute, tty_cursor;
message_t msg;

  //Запросим доступ к видеопамяти
ureq_sys_mem(VIDEO_RAM, 2, 0);
  //Стандартная последовательность инициализации
tty_attribute = 7;
tty_cursor = 0;
clear(&tty_attribute, &tty_cursor);
  //Зарегистрируемся на системном сервере имен под именем 'console'
  //(теперь любая программа может выполнить open("console")
  //чтобы получить возможность посылать нам сообщения)
msg.header = MSG_REGISTER_NAME;
msg.pdata = "console";
msg.size = 10;
usend_msg(NAMESERVER, &msg, 0);

puts("console driver is ready\n", &tty_attribute, &tty_cursor);


  //Цикл получения сообщений
for(;;){
 //Подождем прихода сообщения
 uwait_msg();
 //Получим сообщение в структуру msg
 urecv_msg(&msg, 0);
 //Проанализируем заголовок сообщения
 switch(msg.header) {
 //если это MSG_WRITE
 case MSG_WRITE:
   //И если сообщение не пустое
   if(msg.size > 0) {
     //то выведем его на экран
puts(msg.pdata, &tty_attribute, &tty_cursor);
   }
   break;
 //если это MSG_CLEAR
 case MSG_CLEAR:
   //то очистим экран
   clear(&tty_attribute, &tty_cursor);
   break;
}

}
}

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

Вы уже прониклись великой мощью микроядерного подхода? :)

17

Экзоядерные операционные системы

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

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

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

Вы спросите - неужели в экзоядерных системах нет, к примеру, файловой системы, а программист вынужден программировать диск напрямую? На самом деле, все там есть :) Все реализуется с помощью обычных библиотек. По сути дела, такая библиотека и составляет операционную систему. Причем в экзоядерной системе могут быть несколько таких "систем-библиотек", причем они все могут быть одновременно использованы пользовательским приложением (или приложение может их не использовать вовсе, а содержать в себе, скажем, свою файловую систему...). Представьте себе программу, которая одновременно использует API системы UNIX (воплощенной в виде одной библиотеки) и системы Windows (воплощенной в другой библиотеке). Такое в экзоядерных системах - вполне обычное явление.

И самое главное - зачем все это нужно? Ключевой фактор - производительность, как следствие невероятной гибкости. Веб-сервер Cheetah на экзоядерной ОС XOK по скорости обработки запросов опережает своих братьев с обычных систем в 8 (ВОСЕМЬ!) раз.

И еще один плюс - некоторые программисты еще со времен DOS приходят в восторг от возможности прямого доступа к аппаратуре :)

18

Виртуальная память

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

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

Необходимо отметить, что мы не можем просто скопировать образ процесса в произвольное место памяти и запустить его. Проблема состоит во внутренних ссылках, которые присутствуют в коде (это могут быть как ссылки на данные, так и ссылки на какие-либо функции в коде и пр.). Большинство этих ссылок являются абсолютными, и пока код находится в объектном формате, каждая ссылка записывается в виде, к примеру: "адрес переменной SomeVariable: начало данных + 38 байт" или "адрес функции some_func: начало кода + 880 байт" (вообще говоря, и переменные и функции являются метками, и с точки зрения процессора разницы между ними нет).

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

Вы уже поняли суть проблемы?

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

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

А если система многозадачная? Одновременно в памяти находятся десятки программ, которые, заметьте, должны находится по одному и тому же адресу!

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

Изначально, виртуальная память появилась в виде сегментной организации памяти. Т.е. процесс получал в свое распоряжение сегмент памяти, а все обращения к памяти происходили внутри этого сегмента (по сути дела, сегменты представляли собой те же секции объектного файла, но уже на аппаратном уровне). К примеру, обращение к переменной, имеющей адрес 0x100 фактически осуществлялось процессором по адресу "начало сегмента данных + 0x100", а начальные адреса сегментов хранились в специальных регистрах. При переключении процессов достаточно было загрузить в эти регистры новые значения и каждый процесс оказывался в своем собственном адресном пространстве. Процессы обращались к памяти по одним и тем же адресам, но теперь эти адреса отсчитывались от базовых адресов (базовый адрес - адрес начала сегмента) сегментов, которые были разными у разных процессов. В результате, эти обращения отображались на различные части физической памяти.

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

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

Суть страничной адресации в том, что все адресное пространство предоставляемое процессором (на 32-битных процессорах это, как правило, 4 гигабайта - 2**32) разбивается на отрезки равной длины (называемые страницами). И, что самое главное, программист получает возможность отобразить любую страницу виртуальной памяти на любую страницу физической памяти. Фактически, он указывает, где именно находится данная страница.

Например, программист указал, что страница памяти, находящаяся по адресу 0x80000000, отображена на следующий адрес физической памяти: 0x100000. Что же будет? Все обращения к памяти, находящейся внутри этой страницы (например к 0x80000001) будут физически осуществлены к странице по адресу 0x100000 (в нашем случае - 0x100001).

Информационная структура, которах хранит физические адреса виртуальных страниц (плюс параметры этих страниц) называется каталогом страниц.

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

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

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

И, в завершение, рассмотрим так называемую "корову" (метод COW - copy-on-write) или механизм "копирование только при записи". Допустим, пользователь одновременно запустил два сеанса какой-либо программы. Разумеется, страницы кода будут разделятся между двумя сеансами (см. вышестоящий абзац). Но кроме кода в этих программах есть еще и данные и, более того, эти данные тоже будут разделены! Вы спросите - как?! Ведь процессы должны иметь возможность писать в область данных! Очень просто! На страницах секции данных опять же у обеих процессов ставится аттрибут "только чтение", но! При первой попытке записи (действительно, почему бы процессам не записывать что-нибудь в область данных?) в такую страницу процессор сгенерирует исключение. И уже обработчик этого исключения сделает физическую копию требуемой страницы и снимет аттрибут "только чтение" с соответствующих виртуальных страниц обеих процессов. В результате будут созданы копии только тех страниц, которые действительно в этом нуждаются. Налицо солидная экономия оперативной памяти :)

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

19

Реализация виртуальной памяти на IA-32 (x86)

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

Сегментация

Для реализации виртуальной памяти с помощью сегментов в многозадачной системе необходимо, чтобы каждый процесс мог иметь свой собственный набор сегментов. На IA-32 это реализуется несколькими способами: 1) Способ первый, притянутый за уши: в GDT описываются все сегменты всех процессов и при переключении процесса в сегментные регистры загружаются соответствующие дескрипторы.
2) Способ второй, ненамного лучше первого: при переключении процессов заменяется GDT (теперь селекторы сегментов у разных процессов могут быть одинаковыми, но соответствующие им дескрипторы будут различны)
3) Способ третий, оптимальный: кроме глобальной таблицы дескрипторов GDT существует и локальная таблица дескрипторов LDT. Соответственно, при переключении процессов эту таблицу можно заменить и опять же новый процесс окажется со своими сегментами.

По поводу реализации вышеозначенных методов отсылаю читателя к руководству от Intel (у кого его еще нет - бегом на developer.intel.com заказывать, пока бесплатно). Но, повторюсь, что сегментация устарела и вряд ли может иметь смысл реализация сегментации в операционной системе общего назначения (а вот в какой-нибудь узкоспециализированной RTOS - наверняка)

Страничная адресация

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

Для начала отключим сегментацию, чтобы она нам не мешала. Фактически. сегментация в IA-32 действует всегда и отключить ее нельзя, но можно сделать ее максимально прозрачной. Для этого достаточно загрузить все сегментные регистры дескрипторами сегментов с базой 0 и лимитом 4Гб.Все! Теперь в нашем распоряжении в свободном доступе имеется все адресное пространство. (именно в такое состояние переводит процессор загрузчик, который мы написали ранее)

Страничная адресация, в отличие от сегментации, по умолчанию отключена и включается установкой бита 31 регистра CR0. Но прежде чем мы ее включим, нам необходимо создать рабочий каталог страниц и поместить его в регистр CR3 (также называемый PDBR - page directory base register).

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

Что представляет из себя каталог страниц? Он состоит из 1024 указателей на таблицы страниц (они именуются записями каталога страниц или PDE - page directory entry), которые имеют тип dword. (если указатель на каталог имеет вид int *pagedir, то pagedir[n] будет n-ым PDE)

Таблица страниц представляет из себя 1024 указателя на физические страницы (эти указатели именуются записями таблицы страниц или PTE - page table entry), которые также имеют тип dword

Каждая таблица страниц (и каждая страница) обязана быть выровнена по 4-кб адресу. А это означает, что младшие 12 бит PDE и PTE не используются при указании адреса

Эти 12 бит описывают параметры страницы или таблицы страниц и имеют следующий формат:

Разберемся, как же процессор получает физический адрес виртуальной страницы. Допустим было произведено обращение к адресу 0x80050075 (двоичное 1000 0000 0000 0101 0000 0000 0111 0101). Если страничная адресация включена, то:

Разумеется, кроме проверки на существование страницы (бит 0 PDE или PTE) осуществляются и другие, но для ясности их мы опустили. Чем же эта проверка так отличается от других? Дело в том, что если вдруг процессор замечает, что таблица/страница не существует, то он немедленно сигнализирует об этом с помощью Page Fault и не смотрит на остальные биты (в которых, к примеру, операционная система может хранить информацию о том, где же на самом деле находится эта страница). То есть, если бит 0 равен 0, то ВСЕ ОСТАЛЬНЫЕ биты PTE или PDE доступны для использования операционной системе

Алгоритм нахождения адреса, приведенный выше, слишком медлителен, и использование его при каждом обращении к памяти привело бы к существенному понижению скорости работы. Поэтому адреса страниц кэшируются и если обращение происходило недавно, то процессор достает адрес страницы из кэша TLB (translation lookaside buffer)

Установленный бит 8 "глобальная страница" определяет, что адрес страницы НЕ выгружается из TLB при перезагрузке CR3. Поясним на примере. В ОС Tyros младшие два гигабайта адресного пространства (0x00000000 - 0x7FFFFFFF) являются системными (флаг "пользователь" снят) и на равных принадлежат всем процессам. Разумеется, что при переключении процессов эта часть адресного пространства не изменяется. Соответственно установленный бит 8 приходится очень кстати - не теряется время на выгрузку этой части из TLB и загрузку ее заново

В следующем выпуске мы продолжим разговор о реализации виртуальной памяти

21

GRUB или Для Тех Кто Ищет Легких Путей

Сегодня, уважаемые подписчики, речь пойдет о творении ребят из обожаемого нами проекта GNU - загрузчике GRUB.

GRUB, или Grand Unified Bootloader, способен загружать операционную систему с дискеты или жесткого диска, причем поддерживаются файловые системы ext2fs, ReiserFS, JFS, XFS, UFS, VSTa, MinixFS и старый добрый FAT (скажу по секрету - у меня систему(ы) грузит именно GRUB).

Ну. насчет загрузки Linux, *BSD, HURD и т.п. - все понятно, а вот каким образом GRUB может быть полезен разработчикам операционных систем вообще и нам в частности? Посмотрев на название выпуска, вы догадаетесь, что GRUB - очень легкий способ создать загрузчик для своей собственной операционной системы (в отличие от "ручной" загрузки, а точнее загрузки "с нуля", чем мы занимались в выпуске номер 8).

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

В чем же выгода? Все дело в том, что мы, как разработчики операционной системы, можем предполагать, что изначально уже находимся в очень дружественной обстановке, что выражается в 32-битном защищенном режиме, 4-гигабайтном flat-адресном пространстве, нужном видеорежиме и прочих прелестях (и все это за нас сделал GRUB!). Нам не придется сталкиваться с ужасами реального режима, функциями BIOS, сложнейшей процедурой (:)) перехода в защищенный режим etc.

Алгоритм создания загрузочной дискеты с GRUB'ом таков: дискета форматируется под нужную файловую систему, на ней создается директория /boot/grub, в которую помещаются файлы stage1 и stage2, а кроме того - файл загрузочного меню menu.lst, который может быть примерно такого содержания:

default 0
timeout 10
title MyMegaOS
kernel megaos.elf

Послеэтого запускается утилита grub-installв качестве параметра которой указывается нужный дисковод, например: grub-install /dev/fd0.
Дискета готова!

Осталось разобраться с тем, что это за файл megaos.elf. Судя по названию, это файл ядра нашей ОС в формате ELF. Но это не просто ELF: для того, чтобы GRUB мог загрузить ядро системы, оно должно содержать так называемый multiboot-заголовок помимо обычного ELF-заголовка, который должен находиться в первых 8 килобайтах ядра и быть выровнен на границу двойного слова (4 байта). Простой способ создать заголовок - разместить его прямо в секции кода. Это может выглядеть примерно вот так:

/* main.c */
/* Заголовок multiboot */
asm("...");
asm("...");
  ...

/* Эта функция выполняется при старте ядра */
void main () 
{
  ...
}

За форматом multiboot-заголовка отсылаю интересующихся к Multiboot Specification, которую можно просмотреть, к примеру, набрав info multiboot

P.S. Один из промежуточных снапшотов Tyros/Neutronix имел в качестве загрузчика GRUB, но от него пришлось отказаться в связи с необходимостью при загрузке выполнить некоторые действия в реальном режиме (напомню, что GRUB "выбрасывает" нас сразу в защищенный).

22

Формат ELF - основные основы :)

Формат ELF - далеко не единственный формат исполняемых файлов. Почему же мы уделяем такое внимание именно ему? Дело в том, что он во первых очень логичен, во вторых очень прост, а в третьих обладает достаточной гибкостью и расширяемостью, благодаря чему он и стал настолько распространен (в настоящий момент это самый популярный формат для UNIX-подобных операционных систем на платформе i386. В ELF может быть и ядро системы и приложения (как в случае с Tyros/Neutronix)

Существует как 32-битный, так и 64-битный ELF-формат. Нас интересует в первую очередь первый вариант.

Формат имеет три основные разновидности:

Как вы уже догадались, ELF определяется заголовком, формат которого мы рассмотрим позже (наша основная задача сейчас - понять основные принципы работы с ELF-файлами). Помимо прочих данных, заголовок содержит адрес таблицы программных секций (Program Header Table) и таблицы объектных секций (Section Header Table)

Программные секции необходимы для исполняемого файла, объектные, соответственно - для объектного.

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

Для загрузки в память исполняемого файла (другими словами - для создания образа процесса в памяти) необходимо загрузить в память все его программные секции, на которых стоит флаг LOAD и выделить память для тех секций, на которых стоит ALLOC (такой к примеру будет секция неинициализированных данных - .bss)

Каждая программная секция имеет в своем заголовке адрес LMA (Load Memory Address) - адрес загрузки в память. По этому адресу секция должна находится в момент выполнения (как правило, это означает, что по этому адресу она была скомпонована). Фактически это означает, что таблицы страниц для процесса, образ которого мы грузим, должны быть подготовлены соответствующим образом. Если же мы загружаем ядро системы (а значит, страничная адресация еще не включена), то секции должны физически находится по адресам LMA.

Итак, алгоритм загрузки ядра формата ELF в память таков: из заголовка извлекаем адрес таблицы программных секций, затем по очереди анализируем все секции в этой таблице, если LOAD - то копируем секцию в память по указанному в ее заголовке адресу LMA. Передаем управление на точку входа. Готово!

23

Реализация многозадачности (продолжение)

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

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

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

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

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

Зрители из зала подсказывают, что лирическое вступление пора заканчивать и переходить к реализации :). Итак, встречайте: элемент, отвечающий в IA-32 сразу за два дела - многозадачность и разделение привилегий между пользователем и супервизором - TSS - Task State Segment или сегмент состояния задачи.

Забудьте на время о слове "сегмент" в названии TSS (оно сбивает с толку и вносит лишнюю путаницу) и представьте его как структуру данных, в которой сохраняется состояние процессора при переключении задач. Что считать состоянием процессора? Очевидно - регистры. Выглядит TSS таким образом:

                                 
+----------------+---------------+
0  |   backlink     |ЗАРЕЗЕРВИРОВАНО| 4 
+----------------+---------------+
4  |              ESP0              | 8
+----------------+---------------+
8  |      SS0       |ЗАРЕЗЕРВИРОВАНО| 12
+----------------+---------------+
12 |              ESP1              | 16
+----------------+---------------+
16 |      SS1       |ЗАРЕЗЕРВИРОВАНО| 20
+----------------+---------------+
20 |              ESP2              | 24
+----------------+---------------+
24 |      SS2       |ЗАРЕЗЕРВИРОВАНО| 28
+----------------+---------------+
28 |               CR3              | 32
+----------------+---------------+
32 |               EIP              | 36
+----------------+---------------+
36 |              EFLAGS            | 40
+----------------+---------------+
40 |               EAX              | 44
+----------------+---------------+
44 |               ECX              | 48
+----------------+---------------+
48 |               EDX              | 52
+----------------+---------------+
52 |               EBX              | 56
+----------------+---------------+
56 |               ESP              | 60
+----------------+---------------+
60 |               EBP              | 64
+----------------+---------------+
64 |               ESI              | 68
+----------------+---------------+
68 |               EDI              | 72
+----------------+---------------+
72 |       ES       |ЗАРЕЗЕРВИРОВАНО| 76
+----------------+---------------+
76 |       CS       |ЗАРЕЗЕРВИРОВАНО| 80
+----------------+---------------+
80 |       SS       |ЗАРЕЗЕРВИРОВАНО| 84
+----------------+---------------+
84 |       DS       |ЗАРЕЗЕРВИРОВАНО| 88
+----------------+---------------+
88 |       FS       |ЗАРЕЗЕРВИРОВАНО| 92
+----------------+---------------+
92 |       GS       |ЗАРЕЗЕРВИРОВАНО| 96
+----------------+---------------+
96 |       LDT      |ЗАРЕЗЕРВИРОВАНО| 100
+----------------+---------------+
100|T| ЗАРЕЗЕРВ.    | адрес карты IO| 104
+----------------+---------------+
(Зарезервированные биты должны быть установлены в 0)
                                

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

Почему же TSS все-таки называется сегментом? Потому для того, чтобы указать процессору на эту структуру (иначе говоря - адресовать ее) используется не адрес ее в памяти, а более хитрый способ: селектор сегмента в GDT, база которого уже указывает на линейный адрес TSS. Отсюда получается, что фактически TSS оформлена как отдельный сегмент в глобальной таблице дескрипторов сегментов. Адрес же текущей TSS находится в регистре TR (Task Register)

Итак, теперь процедура переключения процессора между задачами интуитивно ясна: каждая задача имеет свой TSS; допустим выполняется задача А, тогда при переключении с A на B процессор записывает значения регистров в текущий TSS, заменяет текущий TSS на TSS задачи B и загружает из текущего TSS значения регистров. Исполнение следующей инструкции произойдет уже в рамках задачи B.

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

А также вы узнаете, что же это за загадочные стеки SS0-2:ESP0-2 :)

24

Реализация многозадачности (часть 3)

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

Из этого следует, что организовывая многозадачность наиболее последовательным (хотя и неоптимальным) путем (имеется ввиду последовательным с документацией от Intel) нужно:

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

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

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

Дескриптор в IDT для прерывания таймера мы оформим как шлюз задачи. Теперь, наконец-то, спустя 17 выпусков и восемь с половиной месяцев, пришло время рассказать о нем (надеюсь вы уже сами, сгорая от нетерпения, слазили в документацию и узнали, что это такое? :)).
Если дескриптор некого прерывания описывает шлюз задачи, то при возникновении этого прерывания, процессор переключает текущую задачу на ту, на селектор TSS которой указывает этот дескриптор. Т.е. процессор находит нужную TSS следующим образом:

Дескриптор Прерывания -> Номер Селектора -> Селектор в GDT -> Адрес TSS -> TSS
Далее все происходит обычным при аппаратном переключении задач образом - контекст текущей задачи сохраняется в текущей TSS (селектор которой в регистре TR), из найденной TSS извлекается контекст новой задачи, в том числе и CS:EIP и процессор продолжает обычное выполнение.

Т.е., фактически, обработчик прерывания выполняется как отдельная задача.

Для нашего простейшего переключателя мы как раз и создадим обработчик прерывания таймера, как отдельную задачу. Зачем? Сейчас увидите :)

В хорошо знакомом нам регистре флагов EFLAGS существует флажок NT aka Nested Task, что в максимально отражающем исходный смысл переводе звучит как "Вложенная задача". Если этот флаг установлен, то команда IRET (возврат из прерывания) инициирует переключение задачи на ту, селектор TSS которой указан в поле backlink TSS текущей задачи. При вызове же прерывания, дескриптор которого есть шлюз задачи, и соответствующем переключении контекста, как раз и устанавливается флаг NT.

Самые проницательные уже догадались, что обработчик прерывания таймера, который есть отдельная задача, будет у нас изменять свой backlink (backlink TSS'a своей задачи, если быть точным) на селектор той задачи, на которую нужно переключиться, благодаря чему и будет достигнута столь желанная нами многозадачность. Ура! :)

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

  1. Успехов в разработке ОС достигнут в любом случае лишь те, кто способен написать код этих примеров самостоятельно, а не только скомпилировать готовый. Базовые же примеры, для "разгона", были уже даны в начальных выпусках рассылки.
  2. Большинство из описанных механизмов (да еще и кучу других) можно найти как в Tyros, так и множестве других свободно распространяемых ОС с открытыми исходниками.
Также спешу опровергнуть возникающие у некоторых из вас из-за отсутствия кода сомнения в алгоритмах, используемых в примерах. Все примеры, приведенные в рассылке были в свое время воплощены мною в коде, независимо от того приводится ли их код в рассылке или нет. Соответственно за работоспособность и правильность алгоритмов этих примеров я ручаюсь.

Продолжение следует...

25

Переключение задач

Содержание:

Введение

Рассматривается пример кода, реализующего переключение задач.

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

<< к оглавлению<<


Файл app.asm - код задачи.

Этот код будет загружен в качестве отдельной задачи.
Физически код будет находиться внутри основного модуля (по адресу 0x25000), при этом в пространстве задачи он будет спроецирован с адреса 4 (первые 4 байта достанутся от "охватывающего" файла - см. далее). С адреса 0x2000 к задаче будет подключена видеопамять.
Задача будет в бесконечном цикле выводить символы в заданную позицию.

[BITS 32]
[ORG 4]

start: 
 mov eax, [count]
 inc eax
 mov [count], eax
 and eax, 0x3f
 add eax, 0x20
 mov [0x2080], al ; put a symbol
 jmp start

count: dd 0

<< к оглавлению<<


Распределение памяти и определения

Файл Defs.h Определения для простых типов:

#define bool int
#define true  1
#define false 0
#define NULL 0
#define const
#define pointer void*
#define address unsigned long
#define DWORD unsigned long

Файл Map.h

Распределение памяти ядра. Данные и код (все, кроме кучи) находятся в пространстве, где физические адреса совпадают с линейными.

Приведенная карта памяти рассчитана на использование в прототипе ОС, и некоторые элементы в данном примере не используются. В том числе не используется точка монтирования TSS_APP_LINK, вместо этого TSS задачи располагается по фиксированному адресу.

#include "Defs.h"

#define KERNEL_STACK_BOTTOM 0x1000
// нижняя граница стека для ядра, стек имеет фиксированный размер

#define KERNEL_STACK_ORG (0x10000-4)
// начало (верхняя граница) стека для ядра

#define GDT_MAIN        0x10000
// физический адрес главной (общей для всех и единственной) GDT

#define IDT_MAIN        0x20000
// физический адрес IDT

#define TSS_KERNEL_MAIN 0x21000
// сегмент статуса для ядра

#define PAGE_DIRECTORY  0x23000
// каталог разделов для ядра
#define PAGE_TABLE      0x24000
// каталог страниц для первого раздела 

#define KERNEL_START    0x25000
// начало кода ядра

#define VIRTUAL_START  0x100000
// начало действия страничного преобразования (для ядра)
// с этого момента все адреса линейные и не совпадают с физическими

#define PAGE_DIR_LINK  0x100000
// точка монтирования для каталогов разделов задач 
// ядро не имеет постоянного доступа к данным процессов, 
// поэтому нужны точки монтирования для временного подключения

#define PAGE_TAB_LINK  0x101000
// точка монтирования для каталогов страниц задач 

#define TSS_APP_LINK   0x102000
// точка монтирования для сегмента статуса процессов

#define IPC_LINK       0x104000
// точка монтирования для данных

#define KERNEL_HEAP    0x200000
// начало кучи ядра

#define GDT_VIRTUAL        0xFFF00000
// дополнительный линейный адрес GDT (ссылка на тот же объект)
// нужен для обращения из процессов (которым недоступен первичный адрес)
// линейное пространство процессов ограничено этим адресом (0xFFEFFFFF)

#define IDT_VIRTUAL        0xFFF10000
// дополнительный линейный адрес IDT

#define TSS_KERNEL_VIRTUAL 0xFFF11000
// дополнительный линейный адрес сегмента статуса ядра

#define TSS_APP_VIRTUAL    0xFFF13000
// сегмент статуса для процессов

<< к оглавлению<<


Файл startup.asm - головной модуль.

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

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

В тело модуля непосредственно вставлен код задачи, на которую в конце передается управление. Передача управления делается командой jmp с указанием селектора TSS, при этом последующий адрес (смещение) не используется, так как EIP загружается из TSS.

[BITS 32]
[EXTERN kernel_main]
[EXTERN kernel_last]
[GLOBAL start]

start: dd begin  ; loader jumps indirectly

incbin 'app.bin'

begin:
 mov esp, 0x10000 ; init stack
 mov ax, ds
 mov es, ax

 call kernel_main

 call kernel_last

 jmp 0x20: 0 ; to app task

<< к оглавлению<<


Основной модуль kernel.c

Модуль разбит на две функции. Их вполне можно объединить в одну, так как они вызываются последовательно.

Функция kernel_main() первым шагом проводит очистку экрана.

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

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

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

Наконец, ассемблерная вставка загружает регистр CR3 и включает страничное преобразование (бит PE).

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

Далее выполняется kernel_last().

В начале этой функции помещена демонстрация работы перемонтирования страниц: видеобуфер монтируется на свободный адрес и уже по этому адресу выводится символ '!'.

Наконец, вызывается функция, подготавливающая сегмент статуса для новой задачи.

#include "Process.h"
#include "ktty.h"

void kernel_main()
{
  unsigned long addr;
  int i;
  unsigned long* p_dir=(unsigned long*) PAGE_DIRECTORY;
  
  init_tty();
  clear();
 
  puts("  Kernel started..\n");

  for (addr=0x100000; addr<0xFFF00000; addr+=0x1000){
 *((unsigned long*)addr)=0xF0B0C;
 if (*((unsigned long*)addr)!=0xF0B0C) break;
 }
  puts("We have ");  put16(addr); puts(" bytes of memory\n");
  g_PhysicalPagesNumber=addr/0x1000;

  create_Directory(PAGE_DIRECTORY);
  create_Directory(PAGE_TABLE);
  p_dir[0]=(PAGE_TABLE&0xfffff000)|1; // link to first page dir

  for (addr=0; addr<VIRTUAL_START; addr+=4096)
 if (mount_page_kernel(addr,addr)==0) puts("##error -- mount_page()\n");
  puts("\n");
  for (i=0; i<9; i++) put16(p_dir[i]);
  for (i=0; i<9; i++) put16(p_dir[1024+i]);

  puts("Pages directory prepared.\n");

  asm("cli\n mov $0x23000, %eax\n mov %eax, %cr3");
  asm("mov %cr0, %eax\n or $0x80000000, %eax\n mov %eax, %cr0");
  puts("Pagination made on!\n");
 
  init_GDT();
  puts("GDT reloaded.\n");
}

void kernel_last()
{
  char* b=(char*) 0x70000;
  puts("Kernel: last part..\n");
  if (mount_page(0x70000,0xb8000,PAGE_DIRECTORY)==0) puts("##error -- mount_page() - new\n");
  b[0]='!';
  init_TSS();
  puts("init_TSS - completed");
}

<< к оглавлению<<


Инициализация GDT и TSS.

Файл Process.h

Определяем структуру сегмента статуса задачи (TSS).

#include "Memory.h"

bool init_GDT();
bool init_TSS();

struct TSS
{
 DWORD back_link;
 DWORD ESP0;
 DWORD SS0;
 DWORD ESP1;
 DWORD SS1;
 DWORD ESP2;
 DWORD SS2;
 DWORD CR3;
 DWORD EIP;
 DWORD EFLAGS;
 DWORD EAX;
 DWORD ECX;
 DWORD EDX;
 DWORD EBX;
 DWORD ESP;
 DWORD EBP;
 DWORD ESI;
 DWORD EDI;
 DWORD ES;
 DWORD CS;
 DWORD SS;
 DWORD DS;
 DWORD FS;
 DWORD GS;
 DWORD LDT;
 DWORD offset_andT;
 DWORD IOPB;
};

Файл Process.c

Функция init_GDT() подготавливает и загружает глобальную таблицу дескрипторов.

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

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

И, наконец, дескриптор TSS целевой задачи. Под него также выделим свободную область (TSS_APP), которую проинициализируем позже.

Теперь ассемблерной вставкой загружаем GDT и TR. Последнее необходимо для сохранения состояния текущей задачи.

Функция init_TSS() подготавливает TSS для новой задачи.

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

В адресное пространство задачи по адресу 0 монтируется страница с кодом, по адресу 0x1000 выделяется память под стек, а следом монтируется видеобуфер. Также необходимо смонтировать GDT, причем по тому же адресу, что и в текущей задаче.

#include "Process.h"

unsigned long g_gdtr[2];

#define TSS_APP (TSS_KERNEL_MAIN+0x1000)

bool init_GDT()
{
  unsigned long* gdt=(unsigned long*) GDT_MAIN;
  gdt[0]=0;     // not used
  gdt[1]=0;
  gdt[2]=0x0000FFFF;    // code
  gdt[3]=0x00CF9A00;    
  gdt[4]=0x0000FFFF;    // data
  gdt[5]=0x00CF9200;
  gdt[6]=((TSS_KERNEL_MAIN<<16)&0xFFFF0000)|((sizeof(struct TSS))&0x0000FFFF);
  gdt[7]=(TSS_KERNEL_MAIN&0xFF000000)|0x8900|((TSS_KERNEL_MAIN>>16)&0x000000FF);
     // TSS kernel
  gdt[8]=((TSS_APP<<16)&0xFFFF0000)|((sizeof(struct TSS))&0x0000FFFF);
  gdt[9]=(TSS_APP&0xFF000000)|0x8900|((TSS_APP>>16)&0x000000FF);
     // TSS app
  g_gdtr[0]=(GDT_MAIN<<16)|0xFFFF;
  g_gdtr[1]=(GDT_MAIN>>16)&0xFFFF;
  asm("lgdt g_gdtr");
  asm("mov $0x18, %eax\n ltr %ax");
  return true;
}

bool init_TSS()
{
  struct TSS* tss=(struct TSS*)TSS_APP;
  address Dir;
  tss->back_link=0;
  tss->ESP0=0x2000;
  tss->SS0=0x10;
  tss->ESP1=0x2000;
  tss->SS1=0x10;
  tss->ESP2=0x2000;
  tss->SS2=0x10;
  tss->CR3=0;
  tss->EIP=0x4;
  tss->EFLAGS=0;
  tss->EAX=0;
  tss->ECX=0;
  tss->EDX=0;
  tss->EBX=0;
  tss->ESP=0x2000;
  tss->EBP=0;
  tss->ESI=0;
  tss->EDI=0;
  tss->ES=0x10;
  tss->CS=0x8;
  tss->SS=0x10;
  tss->DS=0x10;
  tss->FS=0x10;
  tss->GS=0x10;
  tss->LDT=0;
  tss->offset_andT=0;
  tss->IOPB=0xFFFFFFFF;

  Dir=alloc_page(0);
  put16(Dir);
  create_Directory(Dir);
  mount_page(0,KERNEL_START,Dir);
  put16(*((unsigned long*)Dir));
  mount_page(GDT_MAIN,GDT_MAIN,Dir);    // GDT
  mount_page(0x1000,alloc_page(0),Dir); // for stack
  mount_page(0x2000,0xB8000,Dir); // screen
  tss->CR3=Dir;
  return true;
}

<< к оглавлению<<


Управление страницами памяти (memory.c)

Файл Memory.h

#include "Map.h"

extern address g_FreePhysicalMemory; 
extern unsigned long g_PhysicalPagesNumber;

address alloc_page(long owner);
// выделить свободную страницу для owner

bool mount_page(address logical, address physical, address Directory);
// подключает логическую страницу к физической (address=long)
// Directory - физический адрес каталога, в котором проводится подключение
// если это не каталог ядра, то производится временное монтирование на PAGE_DIR_LINK

#define mount_page_kernel(l,p) mount_page(l,p,PAGE_DIRECTORY)

void create_Directory(address add);
// инициализирует новый каталог

Файл Memory.c

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

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

Функция монтирования страниц имеет три параметра:
- линейный адрес, на который проводится монтирование;
- физический адрес монтируемой страницы;
- физический адрес каталога страниц, в котором ведется монтирование.

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

#include "Memory.h"

unsigned long g_PhysicalPagesNumber=0;
struct MemPage* g_PhysicalPages=NULL;

address g_FreePhysicalMemory=0x00100000; 

address alloc_page(long owner)
{
  address a=g_FreePhysicalMemory;
  if ((a&0xfff) || (a/4096>=g_PhysicalPagesNumber)) return 0;
  g_FreePhysicalMemory+=4096;
  return a;
}

unsigned long g_inv_addr;

bool mount_page(address logical, address physical, address Directory)
// подключает логическую страницу к физической
// Directory - физический адрес каталога, в котором проводится подключение
// при необходимости производится временное монтирование на PAGE_DIR_LINK
{
  address Page;
  unsigned long* p_dir=(unsigned long*) Directory;
  int i_dir=(logical>>22)&0x3ff;
  int i_page=(logical>>12)&0x3ff;
  unsigned long* p_page=NULL; 
  int i;
  bool new_dir;

  if (Directory>=VIRTUAL_START){
 p_dir=(unsigned long*) PAGE_DIR_LINK;
 mount_page_kernel(PAGE_DIR_LINK,Directory);
 g_inv_addr=PAGE_DIR_LINK;
 asm("mov g_inv_addr, %eax\n invlpg (%eax)");
 }

  new_dir=!((p_dir[i_dir])&1);
  if (new_dir){ // directory does not exist => create
 Page=alloc_page(0);
 if (!Page) return false;
 p_dir[i_dir]=(Page&0xfffff000) | 1;
 }
  Page=p_dir[i_dir]&0xfffff000; 
  if (Page>=VIRTUAL_START){
 p_page=(unsigned long*) PAGE_TAB_LINK;
 mount_page_kernel(PAGE_TAB_LINK,Page);
 g_inv_addr=PAGE_TAB_LINK;
 asm("mov g_inv_addr, %eax\n invlpg (%eax)");
 }
  else p_page=(unsigned long*) Page; 

  if (new_dir) for (i=0; i<1024; i++) p_page[i]=0;
  p_page[i_page]=(physical&0xfffff000) | 1;
  return true;
}

void create_Directory(address add)
// создать новый каталог
{
  unsigned long* p=(unsigned long*) add;
  int i;
  if (add>=VIRTUAL_START){
 p=(unsigned long*) PAGE_DIR_LINK;
 mount_page_kernel(PAGE_DIR_LINK,add);
 g_inv_addr=PAGE_DIR_LINK;
 asm("mov g_inv_addr, %eax\n invlpg (%eax)");
 }
  for (i=0; i<1024; i++) p[i]=0;
}

<< к оглавлению<<


Модуль вывода на экран.

Использован, с небольшими изменениями, модуль из 9-го выпуска.

Файл Ktty.h

void init_tty();
void clear();
void putchar(char c);
void puts(const char *s);
void put16(unsigned long addr);

extern int tty_cursor;
extern int tty_attribute;

Файл Ktty.c

#define VIDEO_WIDTH 80
#define VIDEO_HEIGHT 25
#define VIDEO_RAM 0xb8000
#include "ktty.h"

int tty_cursor;
int tty_attribute;

void init_tty()
{
  tty_cursor = 0;
  tty_attribute = 10;
}

void clear()
{
  char *video = (char*) VIDEO_RAM;
  int i;
  for (i = 0; i < VIDEO_HEIGHT*VIDEO_WIDTH; i++) {
 *(video + i*2) = ' ';
  }
  tty_cursor = 0;
}

void putchar(char c)
{
  char *video = (char*) VIDEO_RAM;
  int i;
  switch (c) {
  case '\n': //Если это символ новой строки
 tty_cursor+=VIDEO_WIDTH;
 tty_cursor-=tty_cursor%VIDEO_WIDTH;
 break;
  default:
 *(video + tty_cursor*2) = c;
 *(video + tty_cursor*2+1) = tty_attribute;
 tty_cursor++;
 break;
  }
  //Если курсор вышел за границу экрана, сдвинем экран вверх на одну строку
  if(tty_cursor>VIDEO_WIDTH*VIDEO_HEIGHT){
 for(i=VIDEO_WIDTH*2;i<=VIDEO_WIDTH*VIDEO_HEIGHT*2+VIDEO_WIDTH*2;i++){
   *(video+i-VIDEO_WIDTH*2)=*(video+i);
 }
 tty_cursor-=VIDEO_WIDTH;
  }
}

void puts(const char *s)
{
  while(*s) {
 putchar(*s);
 s++;
  }
}

static void conv16(char* s, unsigned long addr)
{
  int i;
  unsigned char c;
  for (i=0; i<8; i++){
 c=(unsigned char)((addr>>(4*(7-i)))&0xf);
 if (c<10) s[i]=c+'0';
 else s[i]=c-10+'A';
 }
  s[8]=0;
}
void put16(unsigned long addr)
{
  char s[16];
  conv16(s,addr);
  puts(s);
  if (tty_cursor%80!=0) puts(" ");
}

<< к оглавлению<<


Начальный загрузчик (Boot.asm)

Следующий код используется для загрузки программы в память (по адресу 0x25000), перехода в защищенный режим и передачи управления по адресу, находящемуся в ячейке 0x25000.

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

Следует только следить, чтобы число загружаемых секторов (старший байт слова по адресу control:) соответствовало размеру файла kernel.bin.

[BITS 16]
[ORG 0x7c00]
_start: 
 cli
 mov ax, cs
 mov ds, ax
 mov ss, ax
 mov sp, _start
 lgdt [gd_reg]
 in al, 0x92
 or al, 2
 out 0x92, al

 sti
 mov si, msg
 call kputs

 .loop:
 mov al, [control+3]
 test al,al
 jz .cont
 call read_record

 mov si, msg_read
 call kputs

 mov [proc_addr], long move_record
 call prot_call

 jmp .loop
 .cont:

 mov si, msg_jump
 call kputs

 mov [proc_addr], long prot_jump
 call prot_call

 ;; Завесим процессор
 hlt
 jmp short $

%define SIGNATURE 0xF0B0C
;; ---------------- device control ------------------- 
signature: dd SIGNATURE
control: dd 0x07000002
;;CL=$  CL - младшие шесть бит - номер сектора 
;;CH=$+1 старшие два бита CL и CH - номер цилиндра (дорожки) 
;;DH=$+2 DH - номер головки 
;;AL=$+3  AL - количество секторов (в сумме не более одного цилиндра) 
destination: dd 0x25000
;; address to place section
next_record: dd 0x0
;; ---------------------------------------------------

entry_point: dd 0x25000
device: dd 0
;;DL=$  DL - номер диска (носителя). Например 0 - дисковод A: 
;;----------------------------------------------------

read_record:
 mov [.errors_counter], byte 0
 cmp [signature], long SIGNATURE
 je .start
 mov si, msg_giving_up_sig
 call kputs
 hlt
 jmp short $
 
 .start:
 mov ax, 0x800
 mov es, ax
 mov bx, 0
 mov cx, [control]
 mov dh, [control+2]
 mov al, [control+3]
 mov ah, 0x02

 int 0x13
     jc .read_error
 ret

%define MAX_READ_ERRORS 5 
 .errors_counter: db 0 
 .read_error:
 inc byte [.errors_counter]
 cmp byte [.errors_counter], MAX_READ_ERRORS
 jl .start

 mov si, msg_giving_up
 call kputs
 hlt
 jmp short $

kputs: 
 .loop: 
 lodsb 
 test al, al 
 jz .quit 
 mov ah, 0x0E 
 int 0x10 
 jmp short .loop 
 .quit: 
 ret 

msg: db "Startup..",0x0A,0x0D,0 
msg_read: db "<=read<=",0x0A,0x0D,0 
msg_jump: db "Jump..",0x0A,0x0D,0 
msg_giving_up: db "Fatal: Too many errors",0x0A,0x0D, 0
msg_giving_up_sig: db "Fatal: Wrong record signature",0x0A,0x0D, 0

proc_addr: dd prot_jump
         ;; make a call in protected mode and return to real
prot_call:
 pushf
 cli

 mov eax, cr0 
 or al, 1 
 mov cr0, eax  

 jmp 0x8: _protected

back:
 mov ax, cs
 mov ds, ax
 mov ss, ax
 popf
 ret


[BITS 32]
_protected: 
 mov ax, 0x10
 mov ds, ax
 mov ss, ax

 call [proc_addr]

 ;; Сброс бита PE регистра CR0
 mov eax, cr0 
 and al, 11111110b
 mov cr0, eax

 jmp 0: back


prot_jump:
 mov eax, [entry_point]
 jmp [eax]

move_record:

 ; В DS - адрес исходного сегмента
     ; В ES - адрес целевого сегмента
 mov ax, ds
 mov es, ax

 mov esi, 0x8000 ; source
 mov edi, [destination]
 
 ; Копируем [control+3] секторов по 512 байт
 xor eax, eax
 mov al, [control+3]
 shl eax, 7          ; *128
 mov ecx, eax

 cld ; choose direction
 cmp esi, edi
 jz .get_next
 jg .copy
 std
 mov eax, ecx         ; backward from the top
 dec eax
 shl eax, 2
 add esi, eax
 add edi, eax

 .copy: rep movsd
 
 .get_next: 
 mov esi,[next_record]
 test esi, esi
 jnz .load_record
 mov [control+3], byte 0
 ret
 .load_record:
 mov edi, signature
 cld
 movsd ; signature
 movsd   ; control
 movsd ; destination
 movsd   ; next address
 ret

gdt:
 dw 0, 0, 0, 0 ; Нулевой дескриптор

 db 0xFF  ; Сегмент кода с DPL=0 
 db 0xFF  ; Базой=0 и Лимитом=4 Гб 
 db 0x00
 db 0x00
 db 0x00
 db 10011010b
 db 0xCF
 db 0x00
 
 db 0xFF  ; Сегмент данных с DPL=0
 db 0xFF  ; Базой=0 и Лимитом=4Гб 
 db 0x00 
 db 0x00
 db 0x00
 db 10010010b
 db 0xCF
 db 0x00

 ;; Значение, которое мы загрузим в GDTR: 
gd_reg:
 dw 8192
 dd gdt

 times 510-($-$$) db 0
 db 0x55,0xAA

 incbin 'kernel.bin'

<< к оглавлению<<


Командный файл для компиляции.

Проект собирался под Windows средствами nasmw и djgcc.

Тестирование проведено в эмуляторе Bochs.

Нужно заметить, что для Windows нет нормально работающих GNU инструментов. Так, при сборке средствами djgcc линковщик не находит глобальных символов, определенных в ассемблерных модулях. Однако данная ошибка не мешает запускать данный проект.

gcc -fno-leading-underscore -ffreestanding -c -o ktty.o ktty.c
gcc -fno-leading-underscore -ffreestanding -c -o memory.o memory.c
gcc -fno-leading-underscore -ffreestanding -c -o process.o process.c
gcc -fno-leading-underscore -ffreestanding -c -o kernel.o kernel.c
nasmw -fbin -o app.bin app.asm
nasmw -fcoff -o startup.o startup.asm
ld -Ttext 0x25000 -o kernel.bin startup.o ktty.o memory.o process.o kernel.o
objcopy kernel.bin -O binary
nasmw -fbin -o image.bin boot.asm

<< к оглавлению<<