Пишем свою операционную систему Защита памяти и работа над ошибками
В этом выпуске мы проставим правильные атрибуты для памяти ядра, а также исправим некоторые ошибки в коде.
Открытие адресной линии A20
Работа с виртуальной машиной Bochs немного отличается от работы с реальным железом. Например, тем, что адресная линия A20 после загрузки в Bochs по умолчанию открыта. Это приводит к тому, что разработчику ОС не требуется открывать её вручную, потому что всё и так работает, однако ошибка проявится на реальном железе. Адресная линия A20 позволяет процессору обращаться
к памяти выше первого мегабайта и появилась на процессорах, которые поддерживают защищённый режим, а для обратной совместимости изначально закрыта. Открыть A20 не сложно: достаточно вставить следующий код перед переходом в защищённый режим в нашем файле boot.asm:
; Загрузим значение в CR3
mov eax, 0x1000
mov cr3, eax
; Откроем адресную линию A20
in al, 0x92
or al, 2
out 0x92, al
; Загрузим значение в GDTR
lgdt [gdtr32]
; Запретим прерывания
cli
; Перейдём в защищённый режим
mov eax, cr0
or eax, 0x80000001
mov cr0, eax
Теперь наша ОС сможет корректно обращаться к памяти свыше первого мегабайта и на реальном железе, а не только в эмуляторе Bochs.
Защита от излишней оптимизации
Сейчас все оптимизации кода GCC отключены, поэтому мы используем некие допущения, из-за которых наш код выполняет то что нужно. Но для нормальной работы, а не разработки ядро стоит компилировать с различными оптимизациями, ведь это уменьшит его размер и увеличит скорость работы. Однако в случае низкоуровневого
программирования компилятор часто не знает определённых тонкостей среды, где работает код. Например, мы можем записать значение на TEMP_PAGE, затем вызвать temp_map_page и переключить временную страницу на другую область физической памяти и прочитать значение. В случае разрешённых оптимизаций, компилятор просто сохранит записанное значение и сразу подставит его. Откуда ему знать, что переменные могут меняться без явного присваивания? Или компилятор может убрать инструкции, которые на его взгляд бессмысленны.
Например, код, который присваивает переменной сначала одно значение, а потом сразу другое, может быть сокращён до последнего присваивания - результат тот же. Откуда компилятору знать, что мы работали с MMIO-регистром устройства? А ещё компилятор может переставлять команды местами, если они не влияют друг на друга (например, работа с независимыми переменными), но в случае работы с оборудованием совершенно обособленные переменные на самом деле могут оказаться тесно связанными.
Для того, чтобы избежать такого
поведения оптимизатора, есть ключевое слово volatile. Его следует поставить перед объявлением переменной. Оно обозначает, что эта переменная может менять своё значение неявным образом и оптимизировать работу с ней нельзя. Злоупотреблять этим ключевым словом тоже не стоит, поэтому что оно сведёт на нет преимущества включения оптимизации.
В первую очередь добавим volatile в менеджер памяти - многие функции работают с TEMP_PAGE, отследить изменения которой компилятор не в силах:
Доступ к этим переменным осуществляется как в обычном коде, так и в обработчике прерывания, поэтому компилятор не может отследить изменение переменных правильно. Простейший цикл while (key_buffer_head == key_buffer_tail) превратиться в бесконечный, если в самом начале эти переменные были равны, потому что значения будут прочитаны лишь один раз.
Ещё одна ошибка проявившаяся при включении оптимизации - работа со стеком в ассемблерной вставке в функции set_int_handler.
Мы запихивали в стек регистр флагов, а потом его восстанавливали, но теперь для извлечения параметров из стека используется сам ESP вместо его копии в EBP, поэтому временная дестабилизация стека приводила к неправильному доступу к аргументу функции, хотя возврат из неё был корректен (до инструкции RET стек был приведён к правильному виду). Пока единственное что приходит на ум - сохранять регистр файлов не в стеке, а в локальной переменной (на самом деле она тоже оказывается в стеке, но об этом заботится уже
компилятор). Так и сделаем:
Ну вот и всё. Теперь наше ядро правильно работает с любым уровнем оптимизации. Чтобы включить оптимизацию следует в Makefile добавить к CFLAGS опцию -On, где n число от 0 до 3 (0 - отсутствие оптимизации, 3 - максимальная оптимизация). Обычно рекомендуется использовать 0-ой уровень для отладки и 2-ой для обычной работы. 3-ий уровень является режимом агрессивной оптимизации скорости, который может приводить к нестабильной работе приложения и не рекомендуется к применению без необходимости. Пример нового
Makefile:
Теперь можно и правильно настроить атрибуты памяти ядра - запретить запись в область кода, поставить атрибут глобальности для всех страниц.
Для начала нужно как-то позволить реализовать определение базовых адресов секций кода и данных и их размеров. Всё это сделаем с помощью файла script.ld:
Теперь страниц настроены более привычно, если бы мы писали обычное приложение - доступ к страницам с кодом на запись запрещён. Также страницы ядра теперь имеют флаг PAGE_GLOBAL, который предотвращает выгрузку этих элементов из кеша при переключении каталога страниц поскольку ядро общее для всех процессов.
Заключение
В этом выпуске мы исправили ряд предыдущих недостатков ядра и загрузчика системы, которые бы могли помешать дальнейшей разработке. Также мы сделали контроль памяти
более строгим, чтобы сразу замечать ошибки вроде случайной перезаписи кода ядра.