Как написать ядро операционной системы
Перейти к содержимому

Как написать ядро операционной системы

Давай напишем ядро! Создаем простейшее рабочее ядро операционной системы

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

Как загружаются x86-машины

Прежде чем думать о том, как писать ядро, давай посмотрим, как компьютер загружается и передает управление ядру. Большинство регистров процессора x86 имеют определенные значения после загрузки. Регистр — указатель на инструкцию (EIP) содержит адрес инструкции, которая будет исполнена процессором. Его захардкоженное значение — это 0xFFFFFFF0. То есть x86-й процессор всегда будет начинать исполнение с физического адреса 0xFFFFFFF0. Это последние 16 байт 32-разрядного адресного пространства. Этот адрес называется «вектор сброса» (reset vector).

В карте памяти, которая содержится в чипсете, прописано, что адрес 0xFFFFFFF0 ссылается на определенную часть BIOS, а не на оперативную память. Однако BIOS копирует себя в оперативку для более быстрого доступа — этот процесс называется «шедоуинг» (shadowing), создание теневой копии. Так что адрес 0xFFFFFFF0 будет содержать только инструкцию перехода к тому месту в памяти, куда BIOS скопировала себя.

Итак, BIOS начинает исполняться. Сначала она ищет устройства, с которых можно загружаться в том порядке, который задан в настройках. Она проверяет носители на наличие «волшебного числа», которое отличает загрузочные диски от обычных: если байты 511 и 512 в первом секторе равны 0xAA55, значит, диск загрузочный.

Как только BIOS найдет загрузочное устройство, она скопирует содержимое первого сектора в оперативную память, начиная с адреса 0x7C00, а затем переведет исполнение на этот адрес и начнет исполнение того кода, который только что загрузила. Вот этот код и называется загрузчиком (bootloader).

Загрузчик загружает ядро по физическому адресу 0x100000. Именно он и используется большинством популярных ядер для x86.

Все процессоры, совместимые с x86, начинают свою работу в примитивном 16-разрядном режиме, которые называют «реальным режимом» (real mode). Загрузчик GRUB переключает процессор в 32-разрядный защищенный режим (protected mode), переводя нижний бит регистра CR0 в единицу. Поэтому ядро начинает загружаться уже в 32-битном защищенном режиме.

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

Что нам понадобится

  • Компьютер, совместимый с x86 (очевидно),
  • Linux,
  • ассемблер NASM,
  • GCC,
  • ld (GNU Linker),
  • GRUB.

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

Входная точка на ассемблере

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

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

Вот код на ассемблере.

kernel.asm

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

Вторая строка начинает текстовую секцию, также известную как секция кода. Сюда пойдет весь наш код.

global — это еще одна директива NASM, она объявляет символы из нашего кода глобальными. Это позволит компоновщику найти символ start , который и служит нашей точкой входа.

kmain — это функция, которая будет определена в нашем файле kernel.c . extern объявляет, что функция декларирована где-то еще.

Далее идет функция start , которая вызывает kmain и останавливает процессор инструкцией hlt . Прерывания могут будить процессор после hlt , так что сначала мы отключаем прерывания инструкцией cli (clear interrupts).

В идеале мы должны выделить какое-то количество памяти под стек и направить на нее указатель стека (esp). GRUB, кажется, это и так делает за нас, и на этот момент указатель стека уже задан. Однако на всякий случай выделим немного памяти в секции BSS и направим указатель стека на ее начало. Мы используем инструкцию resb — она резервирует память, заданную в байтах. Затем оставляется метка, указывающая на край зарезервированного куска памяти. Прямо перед вызовом kmain указатель стека (esp) направляется на эту область инструкцией mov .

Ядро на C

В файле kernel.asm мы вызвали функцию kmain() . Так что в коде на C исполнение начнется с нее.

kernel.c

Все, что будет делать наше ядро, — очищать экран и выводить строку my first kernel.

Первым делом мы создаем указатель vidptr, который указывает на адрес 0xb8000. В защищенном режиме это начало видеопамяти. Текстовая экранная память — это просто часть адресного пространства. Под экранный ввод-вывод выделен участок памяти, который начинается с адреса 0xb8000, — в него помещается 25 строк по 80 символов ASCII.

Каждый символ в текстовой памяти представлен 16 битами (2 байта), а не 8 битами (1 байтом), к которым мы привыкли. Первый байт — это код символа в ASCII, а второй байт — это attribute-byte . Это определение формата символа, в том числе — его цвет.

Чтобы вывести символ s зеленым по черному, нам нужно поместить s в первый байт видеопамяти, а значение 0x02 — во второй байт. 0 здесь означает черный фон, а 2 — зеленый цвет. Мы будем использовать светло-серый цвет, его код — 0x07.

В первом цикле while программа заполняет пустыми символами с атрибутом 0x07 все 25 строк по 80 символов. Это очистит экран.

Во втором цикле while символы строки my first kernel, оканчивающейся нулевым символом, записываются в видеопамять и каждый символ получает attribute-byte, равный 0x07. Это должно привести к выводу строки.

Компоновка

Теперь мы должны собрать kernel.asm в объектный файл с помощью NASM, а затем при помощи GCC скомпилировать kernel.c в другой объектный файл. Наша задача — слинковать эти объекты в исполняемое ядро, пригодное к загрузке. Для этого потребуется написать для компоновщика (ld) скрипт, который мы будем передавать в качестве аргумента.

link.ld

Здесь мы сначала задаем формат ( OUTPUT_FORMAT ) нашего исполняемого файла как 32-битный ELF (Executable and Linkable Format), стандартный бинарный формат для Unix-образных систем для архитектуры x86.

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

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

В фигурных скобках, которые идут за выражением SECTIONS , точка означает счетчик позиции (location counter). Он автоматически инициализируется значением 0x0 в начале блока SECTIONS , но его можно менять, назначая новое значение.

Ранее я уже писал, что код ядра должен начинаться по адресу 0x100000. Именно поэтому мы и присваиваем счетчику позиции значение 0x100000.

Взгляни на строку .text : < *(.text) >. Звездочкой здесь задается маска, под которую подходит любое название файла. Соответственно, выражение *(.text) означает все входные секции .text во всех входных файлах.

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

После того как компоновщик выдаст текстовую секцию, значение счетчика позиции будет 0x100000 плюс размер текстовой секции. Точно так же секции data и bss будут слиты и помещены по адресу, который задан счетчиком позиции.

GRUB и мультизагрузка

Теперь все наши файлы готовы к сборке ядра. Но поскольку мы будем загружать ядро при помощи GRUB, остается еще один шаг.

Существует стандарт для загрузки разных ядер x86 с помощью бутлоадера. Это называется «спецификация мультибута». GRUB будет загружать только те ядра, которые ей соответствуют.

В соответствии с этой спецификацией ядро может содержать заголовок (Multiboot header) в первых 8 килобайтах. В этом заголовке должно быть прописано три поля:

  • magic — содержит «волшебное» число 0x1BADB002, по которому идентифицируется заголовок;
  • flags — это поле для нас не важно, можно оставить ноль;
  • checksum — контрольная сумма, должна дать ноль, если прибавить ее к полям magic и flags .

Наш файл kernel.asm теперь будет выглядеть следующим образом.

kernel.asm

Инструкция dd задает двойное слово размером 4 байта.

Собираем ядро

Итак, все готово для того, чтобы создать объектный файл из kernel.asm и kernel.c и слинковать их с применением нашего скрипта. Пишем в консоли:

По этой команде ассемблер создаст файл kasm.o в формате ELF-32 bit. Теперь настал черед GCC:

Параметр -c указывает на то, что файл после компиляции не нужно линковать. Мы это сделаем сами:

Эта команда запустит компоновщик с нашим скриптом и сгенерирует исполняемый файл под названием kernel .

WARNING

Хакингом ядра лучше всего заниматься в виртуалке. Чтобы запустить ядро в QEMU вместо GRUB, используй команду qemu-system-i386 -kernel kernel .

Настраиваем GRUB и запускаем ядро

GRUB требует, чтобы название файла с ядром следовало конвенции kernel-<версия> . Так что переименовываем файл — я назову свой kernel-701 .

Теперь кладем ядро в каталог /boot . На это понадобятся привилегии суперпользователя.

В конфигурационный файл GRUB grub.cfg нужно будет добавить что-то в таком роде:

Не забудь убрать директиву hiddenmenu, если она прописана.

GRUB 2

Чтобы запустить созданное нами ядро в GRUB 2, который по умолчанию поставляется в новых дистрибутивах, твой конфиг должен выглядеть следующим образом:

Благодарю Рубена Лагуану за это дополнение.

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

Это и есть твое ядро!

Пишем ядро с поддержкой клавиатуры и экрана

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

Полный исходный код ты можешь найти в репозитории автора на GitHub.

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

Работа с портами: чтение и вывод

Доступ к портам ввода-вывода осуществляется при помощи инструкций in и out , входящих в набор x86.

В read_port номер порта передается в качестве аргумента. Когда компилятор вызывает функцию, он кладет все аргументы в стек. Аргумент копируется в регистр edx при помощи указателя на стек. Регистр dx — это нижние 16 бит регистра edx . Инструкция in здесь читает порт, номер которого задан в dx , и кладет результат в al . Регистр al — это нижние 8 бит регистра eax . Возможно, ты помнишь из институтского курса, что значения, возвращаемые функциями, передаются через регистр eax . Таким образом, read_port позволяет нам читать из портов ввода-вывода.

Функция write_port работает схожим образом. Мы принимаем два аргумента: номер порта и данные, которые будут записаны. Инструкция out пишет данные в порт.

Прерывания

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

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

За прерывания в архитектуре x86 отвечает чип под названием Programmable Interrupt Controller (PIC). Он обрабатывает хардверные прерывания и направляет и превращает их в соответствующие системные прерывания.

Когда пользователь что-то делает с устройством, чипу PIC отправляется импульс, называемый запросом на прерывание (Interrupt Request, IRQ). PIC переводит полученное прерывание в системное прерывание и отправляет процессору сообщение о том, что пора остановить то, что он делает. Дальнейшая обработка прерываний — это задача ядра.

Без PIC нам бы пришлось опрашивать все устройства, присутствующие в системе, чтобы посмотреть, не произошло ли событие с участием какого-то из них.

Давай разберем, как это работает в случае с клавиатурой. Клавиатура висит на портах 0x60 и 0x64. Порт 0x60 отдает данные (когда нажата какая-то кнопка), а порт 0x64 передает статус. Однако нам нужно знать, когда конкретно читать эти порты.

Прерывания здесь приходятся как нельзя более кстати. Когда кнопка нажата, клавиатура отправляет PIC сигнал по линии прерываний IRQ1. PIС хранит значение offset , сохраненное во время его инициализации. Он добавляет номер входной линии к этому отступу, чтобы сформировать вектор прерывания. Затем процессор ищет структуру данных, называемую «таблица векторов прерываний» (Interrupt Descriptor Table, IDT), чтобы дать функции — обработчику прерывания адрес, соответствующий его номеру.

Затем код по этому адресу исполняется и обрабатывает прерывание.

Задаем IDT

IDT — это массив, объединяющий структуры IDT_entry. Мы еще обсудим привязку клавиатурного прерывания к обработчику, а сейчас посмотрим, как работает PIC.

Современные системы x86 имеют два чипа PIC, у каждого восемь входных линий. Будем называть их PIC1 и PIC2. PIC1 получает от IRQ0 до IRQ7, а PIC2 — от IRQ8 до IRQ15. PIC1 использует порт 0x20 для команд и 0x21 для данных, а PIC2 — порт 0xA0 для команд и 0xA1 для данных.

Оба PIC инициализируются восьмибитными словами, которые называются «командные слова инициализации» (Initialization command words, ICW).

В защищенном режиме обоим PIC первым делом нужно отдать команду инициализации ICW1 (0x11). Она сообщает PIC, что нужно ждать еще трех инициализационных слов, которые придут на порт данных.

Эти команды передадут PIC:

  • вектор отступа (ICW2),
  • какие между PIC отношения master/slave (ICW3),
  • дополнительную информацию об окружении (ICW4).

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

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

ICW4 задает дополнительные параметры окружения. Нам нужно определить только нижний бит, чтобы PIC знали, что мы работаем в режиме 80×86.

Та-дам! Теперь PIC проинициализированы.

У каждого PIC есть внутренний восьмибитный регистр, который называется «регистр масок прерываний» (Interrupt Mask Register, IMR). В нем хранится битовая карта линий IRQ, которые идут в PIC. Если бит задан, PIC игнорирует запрос. Это значит, что мы можем включить или выключить определенную линию IRQ, выставив соответствующее значение в 0 или 1.

Чтение из порта данных возвращает значение в регистре IMR, а запись — меняет регистр. В нашем коде после инициализации PIC мы выставляем все биты в единицу, чем деактивируем все линии IRQ. Позднее мы активируем линии, которые соответствуют клавиатурным прерываниям. Но для начала все же выключим!

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

На какой номер прерывания нам нужно завязать в IDT обработчик клавиатуры?

Клавиатура использует IRQ1. Это входная линия 1, ее обрабатывает PIC1. Мы проинициализировали PIC1 с офсетом 0x20 (см. ICW2). Чтобы получить номер прерывания, нужно сложить 1 и 0x20, получится 0x21. Значит, адрес обработчика клавиатуры будет завязан в IDT на прерывание 0x21.

Задача сводится к тому, чтобы заполнить IDT для прерывания 0x21. Мы замапим это прерывание на функцию keyboard_handler , которую напишем в ассемблерном файле.

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

В записи IDT нам осталось прописать тип, обозначив таким образом, что все это делается, чтобы отловить прерывание. Еще нам нужно задать офсет сегмента кода ядра. GRUB задает GDT за нас. Каждая запись GDT имеет длину 8 байт, где дескриптор кода ядра — это второй сегмент, так что его офсет составит 0x08 (подробности не влезут в эту статью). Гейт прерывания представлен как 0x8e. Оставшиеся в середине 8 бит заполняем нулями. Таким образом, мы заполним запись IDT, которая соответствует клавиатурному прерыванию.

Когда с маппингом IDT будет покончено, нам надо будет сообщить процессору, где находится IDT. Для этого существует ассемблерная инструкция lidt, она принимает один операнд. Им служит указатель на дескриптор структуры, которая описывает IDT.

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

В переменной idr_ptr у нас есть указатель, который мы передаем инструкции lidt в функции load_idt() .

Дополнительно функция load_idt() возвращает прерывание при использовании инструкции sti .

Заполнив и загрузив IDT, мы можем обратиться к IRQ клавиатуры, используя маску прерывания, о которой мы говорили ранее.

0xFD — это 11111101 — включаем только IRQ1 (клавиатуру).

Функция — обработчик прерывания клавиатуры

Итак, мы успешно привязали прерывания клавиатуры к функции keyboard_handler , создав запись IDT для прерывания 0x21. Эта функция будет вызываться каждый раз, когда ты нажимаешь на какую-нибудь кнопку.

Эта функция вызывает другую функцию, написанную на C, и возвращает управление при помощи инструкций класса iret. Мы могли бы тут написать весь наш обработчик, но на C кодить значительно легче, так что перекатываемся туда. Инструкции iret/iretd нужно использовать вместо ret , когда управление возвращается из функции, обрабатывающей прерывание, в программу, выполнение которой было им прервано. Этот класс инструкций поднимает флаговый регистр, который попадает в стек при вызове прерывания.

Здесь мы сначала даем сигнал EOI (End Of Interrupt, окончание обработки прерывания), записав его в командный порт PIC. Только после этого PIC разрешит дальнейшие запросы на прерывание. Нам нужно читать два порта: порт данных 0x60 и порт команд (он же status port) 0x64.

Первым делом читаем порт 0x64, чтобы получить статус. Если нижний бит статуса — это ноль, значит, буфер пуст и данных для чтения нет. В других случаях мы можем читать порт данных 0x60. Он будет выдавать нам код нажатой клавиши. Каждый код соответствует одной кнопке. Мы используем простой массив символов, заданный в файле keyboard_map.h , чтобы привязать коды к соответствующим символам. Затем символ выводится на экран при помощи той же техники, что мы применяли в первой версии ядра.

Чтобы не усложнять код, я здесь обрабатываю только строчные буквы от a до z и цифры от 0 до 9. Ты с легкостью можешь добавить спецсимволы, Alt, Shift и Caps Lock. Узнать, что клавиша была нажата или отпущена, можно из вывода командного порта и выполнять соответствующее действие. Точно так же можешь привязать любые сочетания клавиш к специальным функциям вроде выключения.

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

Школа ассемблера: разработка операционной системы

Оригинал: AsmSchool: Make an operating system
Автор: Mike Saunders
Дата публикации: 15 апреля 2016 г.
Перевод: А. Панин
Дата перевода: 16 апреля 2016 г.

Часть 4: Располагая навыками, полученными в ходе чтения предыдущих статей серии, вы можете приступить к разработке своей собственной операционной системы!

  • Для понимания принципов работы компиляторов.
  • Для понимания инструкций центрального процессора.
  • Для оптимизации вашего кода в плане производительности.

В течение нескольких месяцев мы прошли сложный путь, который начался с разработки простых программ на языке ассемблера для Linux и закончился в прошлом статье серии разработкой самодостаточного кода, исполняющегося на персональном компьютере без операционной системы. Ну а сейчас мы попытаемся собрать всю информацию воедино и создать самую настоящую операционную систему. Да, мы пойдем по стопам Линуса Торвальдса, но для начала стоит ответить на следующие вопросы: «Что же представляет собой операционная система? Какие из ее функций нам придется воссоздать?».

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

Наша операционная система в работе: вывод приветствия, исполнение команды и запуск программы с диска

Наша операционная система в работе: вывод приветствия, исполнение команды и запуск программы с диска

Разработка системного загрузчика

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

Вы можете загрузить исходный код рассмотренных в статье примеров по ссылке www.linuxvoice.com/code/lv015/asmschool.zip . А это код нашего системного загрузчика из файла с именем boot.asm :

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

Сейчас же нам придется использовать флоппи-диск с подходящей файловой системой MS-DOS (FAT12), а для того, чтобы корректно работать с данной файловой системой, нужно добавить набор специальных данных рядом с началом сектора. Этот набор называется «блоком параметров BIOS» (BIOS Parameter Block — BPB) и содержит такие данные, как метка диска, количество секторов и так далее. Он не должен интересовать нас на данном этапе, так как подобным темам можно посвятить не одну серию статей, именно поэтому мы разместили все связанные с ним инструкции и данные в отдельном файле исходного кода с именем bpb.asm .

Исходя из вышесказанного, данная директива из нашего кода крайне важна:

Это директива NASM, позволяющая включить содержимое указанного файла исходного кода в текущий файл исходного кода в процессе ассемблирования. Таким образом мы сможем сделать код нашего системного загрузчика максимально коротким и понятным, вынеся все подробности реализации блока параметров BIOS в отдельный файл. Блок параметров BIOS должен располагаться через три байта после начала сектора, а так как инструкция jmp занимает лишь два байта, нам приходится использовать инструкцию nop (ее название расшифровывается как «no operation» — это инструкция, которая не делает ничего, кроме траты циклов центрального процессора) с целью заполнения оставшегося байта.

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

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

Работа со стеком

Далее нам придется использовать инструкции, аналогичные рассмотренным в прошлой статье, для подготовки регистров и стека, а также инструкцию cld (расшифровывается как «clear direction»), позволяющую установить флаг направления для определенных инструкций, таких, как инструкция lodsb , которая после ее исполнения будет увеличивать значение в регистре SI , а не уменьшать его.

После этого мы помещаем адрес строки в регистр SI и вызываем нашу функцию load_file . Но задумайтесь на минуту — мы ведь еще не разработали эту функцию! Да, это правда, но ее реализацию можно найти в другом подключаемом нами файле исходного кода с именем disk.asm .

Файловая система FAT12, используемая на флоппи-дисках, которые форматируются в MS-DOS, является одной простейших существующих файловых систем, но для работы с ее содержимым также требуется немалый объем кода. Подпрограмма load_file имеет длину около 200 строк и не будет приведена в данной статье, так как мы рассматриваем процесс разработки операционной системы, а не драйвера для определенной файловой системы, следовательно, не очень разумно тратить таким образом место на страницах журнала. В общем, мы подключили файл исходного кода disk.asm практически перед окончанием текущего файла исходного кода и можем забыть про него. (Если же вас все-таки заинтересовала структура файловой системы FAT12, вы можете ознакомиться с отличным обзором по адресу http://tinyurl.com/fat12spec , после чего заглянуть в файл исходного кода disk.asm — код, содержащийся в нем, хорошо прокомментирован.)

В любом случае, подпрограмма load_file загружает бинарный код из файла с именем, заданном в регистре SI , в сегмент 2000 со сдвигом 0, после чего мы осуществляем переход к его началу для исполнения. И это все — ядро операционной системы загружено и системный загрузчик выполнил свою задачу!

Вы наверняка заметили, что в качестве имени файла ядра операционной системы в нашем коде используется MYKERNELBIN вместо MYKERNEL.BIN , которое вполне вписывается в схему имен 8+3, используемую на флоппи-дисках в DOS. На самом деле, в файловой системе FAT12 используется внутреннее представление имен файлов, а мы экономим место, используя имя файла, которое гарантированно не потребует реализации в рамках нашей подпрограммы load_file механизма поиска символа точки и преобразования имени файла во внутреннее представление файловой системы.

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

Для ассемблирования системного загрузчика следует использовать следующую команду:

Теперь нам нужно создать образ виртуального флоппи-диска в формате MS-DOS и добавить бинарный код нашего системного загрузчика в его первые 512 байт с помощью следующих команд:

На этом процесс разработки системного загрузчика можно считать оконченным! Теперь у нас есть образ загрузочного флоппи-диска, который позволяет загрузить бинарный код ядра операционной системы из файла с именем mykernel.bin и исполнить его. Далее нас ждет более интересная часть работы — разработка самого ядра операционной системы

Ядро операционной системы

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

Перед рассмотрением кода следует обратить внимание на последнюю строку с директивой подключения файла исходного кода lib.asm , который также находится в архиве asmschool.zip с нашего веб-сайта. Это библиотека полезных подпрограмм для работы с экраном, клавиатурой, строками и дисками, которые вы также можете использовать — в данном случае мы подключаем этот файл исходного кода в самом конце основного файла исходного кода ядра операционной системы для того, чтобы сделать последний максимально компактным и красивым. Обратитесь к разделу «Подпрограммы библиотеки lib.asm» для получения дополнительной информации обо всех доступных подпрограммах.

В первых трех строках кода ядра операционной системы мы осуществляем заполнение регистров сегментов данными для указания на сегмент 2000, в который была осуществлена загрузка бинарного кода. Это важно для гарантированной корректной работы таких инструкций, как lodsb , которые должны читать данные из текущего сегмента, а не из какого-либо другого. После этого мы не будем выполнять каких-либо дополнительных операций с сегментами; наша операционная система будет работать с 64 Кб оперативной памяти!

Далее в коде расположена метка, соответствующая началу цикла. В первую очередь мы используем одну из подпрограмм из библиотеки lib.asm , а именно lib_print_string , для вывода приветствия. Байты 13 и 10 перед строкой приветствия являются символами перехода на новую строку, благодаря которым приветствие будет выводиться не сразу же после вывода какой-либо программы, а всегда на новой строке.

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

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

Далее мы выполняем проверку пользовательского ввода. Если первый байт буфера user_input является нулевым, то пользователь просто нажал клавишу Enter, не вводя какой-либо команды; не забывайте о том, что все строки оканчиваются нулевыми символами. Таким образом, в данном случае мы должны просто перейти к началу цикла и снова вывести приветствие. Однако, в том случае, если пользователь вводит какую-либо команду, нам придется сначала проверить, не ввел ли он команду ls . До текущего момента вы могли наблюдать в наших программах на языке ассемблера лишь сравнения отдельных байт, но не стоит забывать о том, что также имеется возможность осуществления сравнения двухбайтовых значений или машинных слов. В данном коде мы сравниваем первое машинное слово из буфера user_input с машинным словом, соответствующим строке ls и в том случае, если они идентичны, перемещаемся к расположенному ниже блоку кода. В рамках этого блока кода мы используем другую подпрограмму из библиотеки lib.asm для получения разделенного запятыми списка расположенных на диске файлов (для хранения которого должен использоваться буфер file_list ), выводим этот список на экран и перемещаемся назад в цикл для обработки пользовательского ввода.

Исполнение сторонних программ

Если пользователь не вводит команду ls , мы предполагаем, что он ввел имя программы с диска, поэтому имеет смысл попытаться загрузить ее. Наша библиотека lib.asm содержит реализацию полезной подпрограммы lib_load_file , которая осуществляет разбор таблиц файловой системы FAT12 диска: она принимает указатель на начало строки с именем файла посредством регистра AX , а также значение смещения для загрузки бинарного кода из файла программы посредством регистра CX . Мы уже используем регистр SI для хранения указателя на строку с пользовательским вводом, поэтому мы копируем этот указатель в регистр AX , после чего помещаем значение 32768, используемое в качестве смещения для загрузки бинарного кода из файла программы, в регистр CX .

Но почему мы используем именно это значение в качестве смещения для загрузки бинарного кода из файла программы? Ну, это просто один из вариантов карты распределения памяти для нашей операционной системы. Из-за того, что мы работаем в одном сегменте размером в 64 Кб, а бинарный код нашего ядра загружен со смещением 0, нам приходится использовать первые 32 Кб памяти для данных ядра, а остальные 32 Кб — для данных загружаемых программ. Таким образом, смещение 32768 является серединой нашего сегмента и позволяет предоставить достаточный объем оперативной памяти как ядру операционной системы, так и загружаемым программам.

После этого подпрограмма lib_load_file выполняет крайне важную операцию: если она не может найти файл с заданным именем на диске или по какой-то причине не может считать его с диска, она просто завершает работу и устанавливает специальный флаг переноса (carry flag). Это флаг состояния центрального процессора, который устанавливается в процессе выполнения некоторых математических операций и в данный момент не должен нас интересовать, но при этом мы можем определять наличие этого флага для принятия быстрых решений. Если подпрограмма lib_load_asm устанавливает флаг переноса, мы задействуем инструкцию jc (переход при наличии флага переноса — jump if carry) для перехода к блоку кода, в рамках которого осуществляется вывод сообщения об ошибке и возврат в начало цикла обработки пользовательского ввода.

В том же случае, если флаг переноса не установлен, можно сделать вывод, что подпрограмма lib_load_asm успешно загрузила бинарный код из файла программы в оперативную память по адресу 32768. Все что нам нужно в этом случае — это инициировать исполнение бинарного кода, загруженного по этому адресу, то есть начать исполнение указанной пользователем программы! А после того, как в этой программе будет использована инструкция ret (для возврата в вызывающий код), мы должны будем просто вернуться в цикл обработки пользовательского ввода. Таким образом мы создали операционную систему: она состоит из простейших механизмов разбора команд и загрузки программ, реализованных в рамках примерно 40 строк ассемблерного кода, хотя и с большой помощью со стороны подпрограмм из библиотеки lib.asm .

Для ассемблирования кода ядра операционной системы следует использовать следующую команду:

После этого нам придется каким-то образом добавить файл mykernel.bin в файл образа флоппи-диска. Если вы знакомы с приемом монтирования образов дисков с помощью loopback-устройств, вы можете получить доступ к содержимому образа диска floppy.img , воспользовавшись им, но существует и более простой способ, заключающийся в использовании инструментария GNU Mtools ( www.gnu.org/software/mtools ). Это набор программ для работы с флоппи-дисками, на которых используются файловые системы MS-DOS/FAT12, доступный из репозиториев пакетов программного обеспечения всех популярных дистрибутивов Linux, поэтому вам придется лишь воспользоваться утилитой apt-get , yum , pacman или любой другой утилитой, используемой для установки пакетов программного обеспечения в вашем дистрибутиве.

После установки соответствующего пакета программного обеспечения для добавления файла mykernel.bin в файл образа диска floppy.img вам придется выполнить следующую команду:

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

Данный код просто использует функцию BIOS для вывода символа 'X' на экран, после чего возвращает управление вызвавшему его коду — в нашем случае этим кодом является код операционной системы. Строка org , с которой начинается исходный код приложения, является не инструкцией центрального процессора, а директивой ассемблера NASM, сообщающей ему о том, что бинарный код будет загружен в оперативную память со смещением 32768, следовательно, необходимо пересчитать все смещения с учетом данного обстоятельства.

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

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

Вуаля: системный загрузчик boot.img , который мы интегрировали в первый сектор образа диска, загружает ядро операционной системы mykernel.bin , которое выводит приветствие. Введите команду ls для получения имен двух файлов, расположенных на диске ( mykernel.bin и test.bin ), после чего введите имя последнего файла для его исполнения и вывода символа X на экран.

Это круто, не правда ли? Теперь вы можете начать дорабатывать командную оболочку вашей операционной системы, добавлять реализации новых команд, а также добавлять файлы дополнительных программ на диск. Если вы желаете запустить данную операционную систему на реальном ПК, вам стоит обратиться к разделу «Запуск системного загрузчика на реальной аппаратной платформе» из предыдущей статьи серии — вам понадобятся точно такие же команды. В следующем месяце мы сделаем нашу операционную систему более мощной, позволив загружаемым программам использовать системные функции и реализовав таким образом концепцию разделения кода, направленную на сокращение его дублирования. Большая часть работы все еще впереди.

Пишем свою операционку для Arduino. Шаг 4 — первое ядро и многозадачный код

Сегодня настанет момент истины: мы напишем первое ядро нашей операционной системы atmos и запустим на нем простую многозадачную программу на Arduino!

coding in cpp

Вот это будет простыня, ребята.

А начнем мы с того, что добавим в наш проект несколько вспомогательных файлов. Сначала напишем пару классов, чтобы можно было объявлять не-копируемые, не-перемещаемые и статические классы в C++. Класс первого типа можно будет создать и переместить, но не копировать, второго — только создать, а третьего — даже создать нельзя будет, только вызывать его статические методы. В файле noncopyable.h напишем определения первых двух классов noncopyable и nonmoveable :

В файле static_class.h определим еще один класс:

Итак, если мы будем создавать новый класс, который захотим сделать не-копируемым, то будем наследовать его от базового класса noncopyable . А если, например, статическим, то от static_class .

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

Теперь при создании объекта класса kernel_lock прерывания будут отключаться, а при удалении этого объекта — включаться обратно (только если были включены на момент его создания). Этот класс является аналогом стандартного макроса ATOMIC_BLOCK из библиотеки AVR-LibC, но написанным на C++. После компиляции, кстати, код нашего класса будет занимать ровно столько же места в программной памяти, сколько и макрос на Си.

В файл defines.h я добавил пару новых макросов, которые помогут нам при создании ассемблерных вставок:

Если контроллер, под который собирается операционная система, поддерживает инструкции call и jmp , то макросы ATMOS_CALL и ATMOS_JUMP будут раскрываться именно в эти инструкции, а если не имеет, то в инструкции rcall и rjmp . Последние две инструкции позволяют осуществить вызов функции или переход к метке, расположенной относительно недалеко от текущей точки выполнения кода (это так называемый относительный переход, relative call/jump). Но при этом они занимают меньше программной памяти и быстрее выполняются. В контроллерах, имеющих мало программной памяти (меньше 8 Кб), нет смысла в "продвинутых" инструкциях call и jmp , потому что и без них можно перейти к любой точке адресного пространства программной памяти. В то же время, не всегда есть нужда вызывать эти самые "продвинутые" инструкции, если мы действительно не собираемся передавать управление на слишком отдаленный участок кода. У линковщика GCC есть отличная фича, позволяющая при возможности заменять call / jmp на rcall / rjmp в целях оптимизации, и мы эту фичу активировали еще в первой статье, передав линкеру и компилятору опцию -mrelax в конфигурации Release . Таким образом, на контроллерах с большим объемом программной памяти мы в ассемблере будем использовать call / jmp , но линковщик по возможности будет их заменять на rcall / rjmp .

Последний вспомогательный кусок кода, который нам понадобится — это функция offset_of , которую мы добавим в файл utils.h :

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

По смещению 0 расположено поле x размером 1 байт. Далее, по смещению 1 байт, идет поле y с размером 2 байта. Дальше, по смещению 1 + 2 = 3 байта, идет поле z . Чтобы узнать смещение поля z от начала структуры на этапе компиляции, мы сможем выполнить код offset_of(&test::z) и получить искомое значение 3. Разумеется, сама структура может быть гораздо сложнее. Она может быть унаследована от других структур (или даже иметь несколько базовых классов), в которых тоже будут какие-то поля. К сожалению, макрос offsetof (без подчеркивания) из стандартного файла stddef.h в этом случае нам не подойдет (он позволяет работать только с типами, имеющими стандартную компоновку), поэтому мы и запилили собственную функцию. У нашей функции тоже есть ограничения: тот объект, для которого будет вычисляться смещение к полю, должен иметь возможность создаваться на этапе компиляции. Таким образом, вызов функции offset_of не добавит ни байта в программный код ОС, выполнившись полностью на этапе компиляции.

Теперь, имея кучу необходимых нам инструментов, мы переходим к программированию самого ядра и обвязки для него. Определим статический класс process , в котором будут объявляться методы, типы и константы, так или иначе имеющие отношение к процессам операционной системы. Этот класс мы разместим в одноименном файле process.h . Далее я буду опускать директивы #include и объявления пространств имен, чтобы укоротить код в статье и показать только его суть.

Здесь мы объявляем тип точки входа (aka главной функции) для процесса ОС. Пока что это будет просто функция без аргументов, которая не возвращает никакого значения. Также мы объявили тип указателя на стек процесса. В зависимости от контроллера, он может занимать либо 1, либо 2 байта. Наконец, мы объявили тип идентификатора процесса, который в нашей ОС будет представлять из себя указатель на начало адресного пространства (блока памяти) процесса (и благодаря этому он всегда будет уникальным). Идем дальше.

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

Здесь у нас определяется константа, обозначающая недопустимый идентификатор процесса (0). Это идентификатор недопустим, потому что обычная оперативная память в контроллерах AVR начинается не с нулевого адреса. А идентификатор процесса, как я уже говорил, как раз и будет указателем на начало личной памяти процесса. Далее идет размер счетчика команд (зависит от устройства; на устройствах, у которых больше 128 Кб программной памяти, он занимает аж 3 байта). Этот размер указывает, сколько байтов в адресе возврата, который нам контроллер положит на стек при вызове какой-нибудь процедуры инструкцией call / rcall . Затем идет суммарный размер в байтах всех регистров общего назначения (как вы помните, в случае архитектуры avrtiny у нас таких регистров в два раза меньше), далее размер регистра SREG (всегда, конечно же, один байт). В конце мы определяем, каков должен быть минимальный размер контекста процесса. Этот размер определяет, сколько байтов памяти нам потребуется, чтобы при переключении контекста сохранить значения всех регистров общего назначения, регистра SREG , адрес возврата в процесс, а также объект process_list_element (который содержит control_block и информацию о списке, в котором процесс содержится). Меньше этого размера память процессу выдавать нельзя — будет переполнение, и наша система рухнет (возможно, не сразу, а после того, как мы получим кучу непонятных глюков).

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

Первый метод доступен извне, он публичный. В него мы будем передавать указатель на главную функцию (точку входа) процесса, а также память, выделенную для его работы. Здесь мы на этапе компиляции контролируем, чтобы невозможно было выделить памяти меньше, чем minimal_context_size . Вторую функцию определим в cpp -файле позже, она непосредственно будет создавать процесс в ядре нашей ОС. Шаблонный класс process_memory_block , который представляет из себя обертку над блоком памяти процесса, мы рассмотрим следующим. Его мы определим в отдельном файле process_memory.h :

Здес все просто: шаблонный параметр — это количество байтов стековой памяти, которые процессу необходимы для работы. В классе всего два метода — конструктор, инициализирующий всю память нулевыми байтами, и get_memory , возвращающий указатель на начало памяти. memory_ — это, непосредственно, блок памяти процесса. Этот блок заведомо будет иметь размер, равный и больший minimal_context_size , так что контекст процесса в нем поместится. Перед тем, как мы перейдем к написанию кода ядра, рассмотрим подробнее, что этот блок будет содержать. Я такое описание уже приводил в предыдущей статье, но там оно было неточным. Сейчас, когда мы пишем код, мы сможем корректнее и точнее определить содержимое этого блока памяти на разных этапах жизни процесса.

atmos process memory block layout

Из того, что поменялось, по сравнению с предыдущей статьей: теперь блок с информацией о процессе находится в начале памяти, а не в конце, а вот стек растет снизу вверх (в архитектуре avr / avrtiny стек растет от бОльших адресов к меньшим, как и в x86 ). На этапе (4), когда контекст процесса сохранен, указатель stack_pointer в структуре control_block указывает как раз на вершину стека. Стек может быть заполнен не до верха, потому что сам процесс может в момент прерывания не израсходовать всю стековую память, которая была для него выделена.

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

Осталась самая важная часть: код ядра. Начнем с файла kernel.h :

И тут у нас пока что единственный метод, который запускает нашу систему. Этот метод помечен атрибутами ATMOS_NORETURN и ATMOS_NAKED . Первый означает, что метод никогда не вернет управление в вызвавшую его функцию, а второй — что метод у нас будет написан на ассемблере, и у него не будет пролога, эпилога и инструкции возврата. Т.е. это будет пустышка, код которой мы напишем сами без помощи компилятора GCC.

Ну а теперь нас ждет тяжелая артиллерия. Нужно будет написать самый первый код ядра, который будет позволять создавать процессы и переключать управление между ними по таймеру планировщика. Это все будет реализовываться в файле kernel.cpp . Что у нас этот файл будет содержать? Мы должны определить список выполняющихся процессов, а также указатель на процесс, который выполняется в данный момент времени. При создании новых процессов мы будем добавлять их в начало списка (потому что список у нас односвязный, и дешевле всего добавить новый элемент в начало такого списка). Давайте с этого и начнем:

Здесь все достаточно ожидаемо, кроме директивы asm("current_process") указателя current_process . Эту переменную мы будем использовать из ассемблерного кода, поэтому нам необходимо выдать ей адекватное имя, которое будет связывать эту переменную с ассемблерным кодом. C++ мог бы неким недокументированным образом переименовать этот указатель, но мы задаем строго то имя, которое хотим использовать в ассемблере. Кроме того, компилятор у меня ругался, что переменная не определена и удалял ее определение (хотя она использовалась и в коде на C++), поэтому пришлось ее явно пометить атрибутом ATMOS_USED . Далее мы перейдем к коду, создающему новый процесс:

Это объявленная ранее в файле process.h функция create , а здесь мы ее определяем. Сначала мы создаем сам процесс и получаем указатель на класс process_list_element для этого свежесозданного процесса. Затем мы, отключив прерывания, добавляем этот процесс в список всех выполняющихся процессов, а потом конвертируем указатель на process_list_element к идентификатору процесса и возвращаем его из функции. Давайте рассмотрим, что делает функция create_process :

Мы передаем в эту функцию указатель на точку входа процесса, указатель на начало блока памяти процесса, а также размер этой памяти. Функция рассчитывает на то, что память уже проинициализирована и заполнена нулевыми байтами. Это обеспечивается тем, что пользователь будет передавать нам не сырой указатель на буфер памяти, а ссылку на шаблонный класс process_memory , определенный выше, который блок памяти и зануляет. Итак, что же здесь происходит? Мы преобразуем переданный нам указатель на память к указателю на класс process_list_element . Это не очень легальная операция, потому что у данного класса могут быть конструкторы, которые как-то хитро его инициализируют. Но сейчас в нем нет ничего, кроме указателя на следующий элемент списка и указателя на вершину стека процесса, поэтому нам не нужна какая-то особая инициализация. Поэтому вызов конструктора класса можно опустить. Далее мы подготавливаем начальный контекст для процесса, вызывая функцию prepare_process_context . Эта функция вернет нам указатель на вершину стека после заполнения контекста, и это значение мы записываем в переменную stack_pointer контрольного блока процесса. Переходим к функции prepare_process_context :

Сначала мы записываем адрес главной функции процесса ( entry_point ) на самое дно стека (т.е. в самый низ блока памяти процесса). Это будет адрес возврата, на которое ядро будет передавать управление после восстановления контекста. Далее начинается контекст процесса. Изначально в нем все регистры будут равны нулю, а в регистре SREG будет установлен единственный бит, включающий прерывания для процесса. Таким образом, для всех свежесозданных процессов прерывания по умолчанию будут включены. Мы перемещаем указатель стека на один байт вверх ( —stack_bottom ). Это ячейка, в которой размещается регистр R31 . Память уже заполнена нулями, поэтому нет необходимости еще раз записывать в эту ячейку (как и во все другие) нулевое значение. Далее в нашем контексте всегда идет SREG , и туда мы записываем значение _BV(ATMOS_AVR_INTERRUPT_BIT) . Это, по сути, значение 1 << ATMOS_AVR_INTERRUPT_BIT , которое равно 0b10000000 в двоичной системе. Все биты установлены в ноль, а бит, отвечающий за прерывания, — в единицу. Далее мы уменьшаем стековый указатель на то количество регистров общего назначения, которое есть в контроллере ( gpr_size ) минус единица (потому что первый регистр, R31 , уже получил свой байт). Наконец, мы возвращаем из функции указатель на вершину стека. Здесь помимо прочего используется функция push_function_address :

Она не делает ничего особенного, кроме как записывает на стек указатель на главную функцию (точку входа) процесса. Этот указатель в большинстве случаев имеет размер два байта, но в некоторых случаях, когда программной памяти у контроллера больше, чем 128 Кб (это как раз случай Arduino Mega 2560), этот указатель может занимать три байта. GCC не поддерживает вызовы функций по длинным указателям, да и трехбайтные указатели в целом тоже, но нам нужно все равно зарезервировать один дополнительный байт на стеке, потому что инструкция ret / reti , которую мы используем для возврата в код процесса из ядра (как описано в предыдущей статье), будет считывать со стека именно три байта для таких контроллеров. Нам осталось рассмотреть только функцию to_pid :

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

Нам осталось запилить функционал, который будет:

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

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

Да, вот так вот сразу по-хардкору! Сначала объявляем функцию save_context_and_switch_to_next_process_func , чтобы задать ей определенные атрибуты: hot , naked , used (горячая, обнаженная и попользованная, мммм, на самом деле: часто вызываемая, без пролога и эпилога и используемая в коде). Также мы задаем для функции имя, которое будет видно в ассемблерном коде. Далее идет макрос save_context_and_switch_to_next_process , который является просто переходом ( jmp ) на эту функцию. Мы не можем ее вызывать классическим способом, потому что это испортит наш стек, ведь контроллер в этом случае на стек положит адрес возврата. Мы можем только "перепрыгнуть" на ее тело, чтобы стековый указатель SP не изменился, и ничего на стеке не перетерлось. Поэтому вместо save_context_and_switch_to_next_process_func() для вызова этой функции мы будем писать save_context_and_switch_to_next_process() . Кроме того, макрос save_context_and_switch_to_next_process содержит барьер памяти (увидели там "memory" ?). Эта шняга нужна для того, чтобы сказать компилятору: мы сейчас будем творить безобразие (а именно, переключим контекст процесса), поэтому давай прямо сейчас из временных регистров быстро запиши все данные в память. Если у компилятора на момент вызова хранились в некоторых регистрах какие-то данные, которые он планировал перенести в ячейки оперативной памяти, то он их перенесет сиюминутно перед непосредственно выполнением макроса. В противном случае эти регистры мы можем затереть, сменить стековый указатель, и компилятору уже поздно будет что-либо делать, все полетит к чертям.

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

. и дело сделано! Но, к сожалению, этого мы делать не можем будем. Документация на AVR-GCC в описании атрибута функции naked явно указывает на то, что мы не имеем права в таких вот функциях без пролога и эпилога использовать код на Си или C++. Работоспособность такого кода в naked -функциях не гарантируется. Конечно, мы могли бы обернуть эту строку кода в отдельную, классическую, функцию и вызвать ее из этой, но, черт возьми, после сохранения контекста у нас на стеке процесса может не быть свободного места, и контроллеру негде будет сохранить адрес возврата. Можно выгрузить часть контекста снова в регистры, которые GCC использовать не будет, но это слишком большая лишняя работа в данном случае. Поэтому эту строку кода нам будет оптимальнее всего написать сразу на ассемблере. Тут появляются всякие нюансы. Поле stack_pointer структуры process (которая имеет тип control_block ) находится на определенном смещении от начала этой структуры. Точно так же, сама структура process , содержащаяся в структуре process_list_element находится на каком-то смещении от начала этой структуры. Эти смещения нам надо как-то вычислить, чтобы добраться до интересующего нас поля. Для этого мы будем использовать написанную ранее функцию offset_of , которая произведет нужные вычисления на этапе компиляции. Затем мы, используя связку "M" , пробросим сумму этих смещений в ассемблерный код. Таким образом, в ассемблере мы будем знать, на каком смещении от начала структуры process_list_element расположено интересующее нас поле stack_pointer .

В самой же ассемблерной вставке мы записываем в регистры R28 и R29 значение стекового указателя. В контроллерах с малым объемом оперативной памяти регистра SPH (старшая часть стекового регистра) может и не быть, это мы тоже учли. Далее у нас два варианта кода: для архитектуры avrtiny и для всех остальных. Для avrtiny мы сначала считываем в регистр Z (который, как вы помните из предыдущих статей, состоит из двух восьмибитных регистров R31:R30 ) указатель на ячейки оперативной памяти, в которой лежит указатель на структуру текущего процесса. Затем мы с помощью инструкции ld этот указатель разыменовываем. Теперь у нас в регистрах R27:R26 (они же — регистр X ) лежит указатель на структуру process_list_element текущего процесса. Далее мы прибавляем к этому указателю вычисленное смещение к полю stack_pointer . Делается это двумя инструкциями subi и sbci , которые выполняют вычитание и вычитание с переносом, соответственно. Почему вычитание? Потому что в AVR отсутствует инструкция сложения регистра с числом, да. Сложить регистр с другим регистром можно, а вот регистр с числом — нет. Поэтому мы вычитаем отрицательные смещения, а минус на минус дает плюс, так оптимальнее и по скорости, и по размеру получающегося кода. Наконец, с помощью команды st мы записываем по этому указателю X (который теперь указывает на поле stack_pointer avr код несколько проще, потому что они имеют продвинутую инструкцию lds . Она за нас разыменовывает указатель на ячейку памяти, где лежит указатель на текущий процесс, и его-то мы на блюдечке и получаем в регистре Z . Остается с помощью другой продвинутой инструкции std записать в него стековый указатель.

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

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

Но вот незадача: если мы выполним инструкцию call / rcall , то контроллер нам на стек по текущему стековому указателю положит адрес возврата, чтобы потом мы вернулись на место вызова, когда функция, которую мы вызвали, выполнит инструкцию ret . А стек-то у нас уже забит! Придется его слегка освободить. Регистры-то свободны все, мы их только что сохранили в контекст процесса. Вот и освободим из контекста два или три байта (три — если у контроллера более 128 Кб программной памяти), поместив данные со стека в регистры R28 , R29 и, при необходимости R2 . Почему именно в эти? Потому что по конвенции вызовов AVR GCC, эти регистры все функции не трогают, а если и трогают, то после вызова восстанавливают их значения на те, которые были в них записаны на момент вызова. Окей, сохранили, потом вызываем choose_next_process , которая нам выберет следующий по очереди процесс на выполнение и вернет указатель на вершину стека процесса. Далее мы запихиваем эти злосчастные три байта из R28 , R29 и R2 обратно в стек, а потом двумя инструкциями out перезаписываем стековый указатель. Вот он — момент истины: после выполнения этих двух инструкций мы уже находимся в контексте следующего процесса, которому пора выполняться! Поэтому все, что остается сделать, — это восстановить его контекст и передать ему управление, что мы и делаем, вызвав два соответствующих макроса.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *