Это завершающая часть проекта по написанию собственно ОС. Здесь мы реализуем дисковый ввод/вывод и файловую систему, а также вкратце подведём итоги и наметим дальнейшие шаги на пути доработки системы.
▍ Навигация по частям
Дисковый ввод/вывод
В этом разделе мы реализуем драйвер устройства для
virtio-blk
, то есть виртуального диска. И хотя virtio-blk
— это не физическое устройство, его интерфейс очень похож на интерфейс реального диска.▍ Virtio
Virtio — это стандарт интерфейсов для виртуальных устройств (устройств
virtio
). Иными словами, это один из API для драйверов устройств, позволяющий этими устройствами управлять. Подобно тому, как с помощью HTTP вы получаете доступ к веб-серверам, с помощью virtio
вы получаете доступ к устройствам virtio
. Этот стандарт широко используется в средах виртуализации вроде QEMU и Firecracker.▍ Virtqueue
Устройства
virtio
имеют особую структуру, называемую virtqueue
. Как и предполагает её имя, фактически это очередь, которую совместно используют драйверы и устройства. В общих чертах virtqueue
состоит из трёх областей:Название области | Кем записывается | Содержимое/назначение | Содержимое |
Область дескриптора | Драйвер | Таблица дескрипторов: адрес и размер запроса | Адрес памяти, длина и индекс следующего дескриптора |
Avail Ring (доступные дескрипторы) |
Драйвер | Обработка запросов к устройству | Верхний индекс цепочки дескрипторов |
Used Ring (использованные дескрипторы) |
Устройство | Обработка запросов устройством | Верхний индекс цепочки дескрипторов |
Каждый запрос (например, запись на диск) состоит из цепочки дескрипторов. Разделяя запрос на серию дескрипторов, вы можете указывать разбросанные по памяти данные (так называемый Scatter-Gather I/O, векторный ввод/вывод) или присваивать дескрипторам разные атрибуты (определяя возможность записи устройством).
Например, при записи на диск
virtqueue
будет использоваться так:- Драйвер записывает запрос чтения/записи в область дескриптора.
- Драйвер добавляет индекс верхнего дескриптора в кольцевой буфер Avail Ring.
- Драйвер уведомляет устройство о поступлении нового запроса.
- Устройство считывает этот запрос из Avail Ring и обрабатывает его.
- Устройство записывает в кольцевой буфер Used Ring индекс дескриптора и уведомляет драйвер о завершении.
Подробнее читайте в спецификации virtio. В нашей же реализации мы сосредоточимся на устройстве под названием
virtio-blk
.▍ Активация устройств virtio
Прежде чем писать драйвер устройства, подготовим тестовый файл. Создайте файл
lorem.txt
и внесите в него какой-нибудь текст, например:$ echo "Lorem ipsum dolor sit amet, consectetur adipiscing elit. In ut magna consequat, cursus velit aliquam, scelerisque odio. Ut lorem eros, feugiat quis bibendum vitae, malesuada ac orci. Praesent eget quam non nunc fringilla cursus imperdiet non tellus. Aenean dictum lobortis turpis, non interdum leo rhoncus sed. Cras in tellus auctor, faucibus tortor ut, maximus metus. Praesent placerat ut magna non tristique. Pellentesque at nunc quis dui tempor vulputate. Vestibulum vitae massa orci. Mauris et tellus quis risus sagittis placerat. Integer lorem leo, feugiat sed molestie non, viverra a tellus." > lorem.txt
Также прикрепите устройство
virtio-blk
к QEMU:run.sh
$QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot \
-d unimp,guest_errors,int,cpu_reset -D qemu.log \
-drive id=drive0,file=lorem.txt,format=raw,if=none \ # new
-device virtio-blk-device,drive=drive0,bus=virtio-mmio-bus.0 \ # new
-kernel kernel.elf
У нас появились следующие опции:
-
-drive id=drive0
: определяет дискdrive0
, используя в качестве его образаlorem.txt
. Этот образ представлен в формате raw, то есть содержимое файла рассматривается в качестве данных диска как есть. -
-device virtio-blk-device
: добавляет устройствоvirtio-blk
с дискомdrive0
.bus=virtio-mmio-bus.0
отображает это устройство в шинуvirtio-mmio
(virtio
с отображением в память [Memory Mapped I/O]).
▍ Определение макросов и структур на C
Для начала добавим в
kernel.h
связанные с virtio
определения:
kernel.h
kernel.h
#define SECTOR_SIZE 512
#define VIRTQ_ENTRY_NUM 16
#define VIRTIO_DEVICE_BLK 2
#define VIRTIO_BLK_PADDR 0x10001000
#define VIRTIO_REG_MAGIC 0x00
#define VIRTIO_REG_VERSION 0x04
#define VIRTIO_REG_DEVICE_ID 0x08
#define VIRTIO_REG_QUEUE_SEL 0x30
#define VIRTIO_REG_QUEUE_NUM_MAX 0x34
#define VIRTIO_REG_QUEUE_NUM 0x38
#define VIRTIO_REG_QUEUE_ALIGN 0x3c
#define VIRTIO_REG_QUEUE_PFN 0x40
#define VIRTIO_REG_QUEUE_READY 0x44
#define VIRTIO_REG_QUEUE_NOTIFY 0x50
#define VIRTIO_REG_DEVICE_STATUS 0x70
#define VIRTIO_REG_DEVICE_CONFIG 0x100
#define VIRTIO_STATUS_ACK 1
#define VIRTIO_STATUS_DRIVER 2
#define VIRTIO_STATUS_DRIVER_OK 4
#define VIRTIO_STATUS_FEAT_OK 8
#define VIRTQ_DESC_F_NEXT 1
#define VIRTQ_DESC_F_WRITE 2
#define VIRTQ_AVAIL_F_NO_INTERRUPT 1
#define VIRTIO_BLK_T_IN 0
#define VIRTIO_BLK_T_OUT 1
// Запись области дескрипторов virtqueue.
struct virtq_desc {
uint64_t addr;
uint32_t len;
uint16_t flags;
uint16_t next;
} __attribute__((packed));
// Avail Ring.
struct virtq_avail {
uint16_t flags;
uint16_t index;
uint16_t ring[VIRTQ_ENTRY_NUM];
} __attribute__((packed));
// Запись Used Ring.
struct virtq_used_elem {
uint32_t id;
uint32_t len;
} __attribute__((packed));
// Used Ring.
struct virtq_used {
uint16_t flags;
uint16_t index;
struct virtq_used_elem ring[VIRTQ_ENTRY_NUM];
} __attribute__((packed));
// Virtqueue.
struct virtio_virtq {
struct virtq_desc descs[VIRTQ_ENTRY_NUM];
struct virtq_avail avail;
struct virtq_used used __attribute__((aligned(PAGE_SIZE)));
int queue_index;
volatile uint16_t *used_index;
uint16_t last_used_index;
} __attribute__((packed));
// Запрос virtio-blk.
struct virtio_blk_req {
uint32_t type;
uint32_t reserved;
uint64_t sector;
uint8_t data[512];
uint8_t status;
} __attribute__((packed));
Примечание
__attribute__((packed))
— это расширение, которое инструктирует компилятор упаковать членов структур без заполнения. В противном случае он может добавить скрытые заполняющие байты, и драйвер/устройство увидит другие значения.
Далее добавьте в
kernel.c
вспомогательные функции для доступа к регистрам MMIO:kernel.c
uint32_t virtio_reg_read32(unsigned offset) {
return *((volatile uint32_t *) (VIRTIO_BLK_PADDR + offset));
}
uint64_t virtio_reg_read64(unsigned offset) {
return *((volatile uint64_t *) (VIRTIO_BLK_PADDR + offset));
}
void virtio_reg_write32(unsigned offset, uint32_t value) {
*((volatile uint32_t *) (VIRTIO_BLK_PADDR + offset)) = value;
}
void virtio_reg_fetch_and_or32(unsigned offset, uint32_t value) {
virtio_reg_write32(offset, virtio_reg_read32(offset) | value);
}
Предупреждение
Обращение к регистрам MMIO происходит не так, как к типичной памяти. Для этого нужно использовать ключевое словоvolatile
, чтобы компилятор в ходе оптимизации не отбросил операции чтения/записи. В MMIO доступ к памяти может вызывать побочные эффекты (например, отправлять устройству команду).
▍ Отображение области MMIO
Первым делом отобразите область MMIO
virtio-blk
в таблицу страниц, чтобы ядро могло обращаться к регистрам MMIO. Делается это просто:kernel.c
struct process *create_process(const void *image, size_t image_size) {
/* код опущен */
for (paddr_t paddr = (paddr_t) __kernel_base;
paddr < (paddr_t) __free_ram_end; paddr += PAGE_SIZE)
map_page(page_table, paddr, paddr, PAGE_R | PAGE_W | PAGE_X);
map_page(page_table, VIRTIO_BLK_PADDR, VIRTIO_BLK_PADDR, PAGE_R | PAGE_W); // добавлено
▍ Инициализация устройства virtio
Подробно процесс инициализации описан в спецификации virtio:
3.1.1 Требования к драйверу: инициализация устройства. При инициализации устройства драйвер должен выполнить следующее:
1. Сбросить устройство.
2. Установить бит состоянияACKNOWLEDGE
, означающий, что гостевая ОС обнаружила устройство.
3. Установить бит состоянияDRIVER
, означающий, что гостевая ОС знает, как управлять устройством.
4. Считать биты функциональности устройства и записать на него те из них, которые связаны с понятными для ОС и драйвера функциями. На этом этапе драйвер может считывать (но не должен записывать) относящиеся к устройству поля конфигурации, чтобы предварительно проверить возможность его поддержки.
5. Установить бит состоянияFEATURES_OK
. После этого шага драйвер не должен принимать новые биты функциональности.
6. Повторно считать состояние устройства, проверяя, установлен ли по-прежнему битFEATURES_OK
: если нет, значит, устройство не поддерживает обозначенное подмножество функций, и использовать его не получится.
7. Выполнить настройку устройства, включая поискvirtqueues
для него, настройку по шинам (опционально), считывание и, возможно, запись конфигурацииvirtio
устройства, а также заполнение всехvirtqueue
.
8. Установить бит состоянияDRIVER_OK
. На этом этапе устройство «оживает».
Не стоит пугаться этих кажущихся сложными шагов. В своей базовой форме они реализуются очень легко:
kernel.c
kernel.c
struct virtio_virtq *blk_request_vq;
struct virtio_blk_req *blk_req;
paddr_t blk_req_paddr;
unsigned blk_capacity;
void virtio_blk_init(void) {
if (virtio_reg_read32(VIRTIO_REG_MAGIC) != 0x74726976)
PANIC("virtio: invalid magic value");
if (virtio_reg_read32(VIRTIO_REG_VERSION) != 1)
PANIC("virtio: invalid version");
if (virtio_reg_read32(VIRTIO_REG_DEVICE_ID) != VIRTIO_DEVICE_BLK)
PANIC("virtio: invalid device id");
// 1. Сброс устройства.
virtio_reg_write32(VIRTIO_REG_DEVICE_STATUS, 0);
// 2. Установка бита состояния ACKNOWLEDGE: гостевая ОС обнаружила устройство.
virtio_reg_fetch_and_or32(VIRTIO_REG_DEVICE_STATUS, VIRTIO_STATUS_ACK);
// 3. Установка бита состояния DRIVER.
virtio_reg_fetch_and_or32(VIRTIO_REG_DEVICE_STATUS, VIRTIO_STATUS_DRIVER);
// 5. Установка бита состояния FEATURES_OK.
virtio_reg_fetch_and_or32(VIRTIO_REG_DEVICE_STATUS, VIRTIO_STATUS_FEAT_OK);
// 7. Настройка устройства, включая обнаружение virtqueues для него.
blk_request_vq = virtq_init(0);
// 8. Установка бита состояния DRIVER_OK.
virtio_reg_write32(VIRTIO_REG_DEVICE_STATUS, VIRTIO_STATUS_DRIVER_OK);
// Уточнение ёмкости диска.
blk_capacity = virtio_reg_read64(VIRTIO_REG_DEVICE_CONFIG + 0) * SECTOR_SIZE;
printf("virtio-blk: capacity is %d bytes\n", blk_capacity);
// Аллокация области для хранения запросов к устройству.
blk_req_paddr = alloc_pages(align_up(sizeof(*blk_req), PAGE_SIZE) / PAGE_SIZE);
blk_req = (struct virtio_blk_req *) blk_req_paddr;
}
kernel.c
void kernel_main(void) {
memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);
WRITE_CSR(stvec, (uint32_t) kernel_entry);
virtio_blk_init(); // добавлено
▍ Инициализация virtqueue
Virtqueue
тоже нужно инициализировать. Заглянем в спецификацию:Виртуальная очередь настраивается так:
1. Выберите очередь, записав её индекс вQueueSel
(первая — это 0).
2. Проверьте, не используется ли эта очередь: считайтеQueuePFN
— должен вернуться нуль (0x0
).
3. Узнайте максимальный размер очереди (количество элементов), считавQueueNumMax
. Если вернётся нуль (0х0
), очередь недоступна.
4. Аллоцируйте и обнулите страницы очередей в непрерывной области виртуальной памяти, выровняв Used Ring по оптимальной границе (обычно по размеру страницы). Драйвер должен выбирать такую очередь, размер которой меньше либо равенQueueNumMax
.
5. Уведомите устройство о размере очереди, записав его вQueueNum
.
6. Уведомите устройство об используемом выравнивании, записав его значение в байтах вQueueAlign
.
7. Запишите физический номер первой страницы очереди в регистрQueuePFN
.
Вот простая реализация всех этих этапов:
kernel.c
kernel.c
struct virtio_virtq *virtq_init(unsigned index) {
// Аллокация области под virtqueue.
paddr_t virtq_paddr = alloc_pages(align_up(sizeof(struct virtio_virtq), PAGE_SIZE) / PAGE_SIZE);
struct virtio_virtq *vq = (struct virtio_virtq *) virtq_paddr;
vq->queue_index = index;
vq->used_index = (volatile uint16_t *) &vq->used.index;
// 1. Выбор очереди путём записи её индекса в QueueSel (первая — это 0).
virtio_reg_write32(VIRTIO_REG_QUEUE_SEL, index);
// 5. Уведомление устройства о размере очереди путём его записи в QueueNum.
virtio_reg_write32(VIRTIO_REG_QUEUE_NUM, VIRTQ_ENTRY_NUM);
// 6. Уведомление устройства об используемом выравнивании путём записи его значения в байтах в QueueAlign.
virtio_reg_write32(VIRTIO_REG_QUEUE_ALIGN, 0);
// 7. Запись физического номера первой страницы очереди в регистр QueuePFN.
virtio_reg_write32(VIRTIO_REG_QUEUE_PFN, virtq_paddr);
return vq;
}
Эта функция аллоцирует память под
virtqueue
и сообщает устройству её физический адрес. Далее устройство будет использовать эту область для чтения/записи запросов.Подсказка
В процессе инициализации драйверы проверяют возможности/функции устройства, выделяют ресурсы ОС (например, память) и устанавливают параметры. Не напоминает рукопожатие в сетевых протоколах?
▍ Отправка запросов ввода/вывода
Вот мы и инициализировали устройство
virtio-blk
. Пора отправить на диск запрос ввода/вывода. Подобные запросы реализуются путём «добавления в virtqueue
запроса на обработку»:
kernel.c
kernel.c
// Уведомляем устройство о поступлении запроса. `desc_index` - это индекс его верхнего дескриптора.
void virtq_kick(struct virtio_virtq *vq, int desc_index) {
vq->avail.ring[vq->avail.index % VIRTQ_ENTRY_NUM] = desc_index;
vq->avail.index++;
__sync_synchronize();
virtio_reg_write32(VIRTIO_REG_QUEUE_NOTIFY, vq->queue_index);
vq->last_used_index++;
}
// Определяем, обрабатывает ли в данный момент устройство какие-либо запросы.
bool virtq_is_busy(struct virtio_virtq *vq) {
return vq->last_used_index != *vq->used_index;
}
// Считываем/записываем из/в устройство virtio-blk.
void read_write_disk(void *buf, unsigned sector, int is_write) {
if (sector >= blk_capacity / SECTOR_SIZE) {
printf("virtio: tried to read/write sector=%d, but capacity is %d\n",
sector, blk_capacity / SECTOR_SIZE);
return;
}
// Создаём запрос в соответствии со спецификацией virtio-blk.
blk_req->sector = sector;
blk_req->type = is_write ? VIRTIO_BLK_T_OUT : VIRTIO_BLK_T_IN;
if (is_write)
memcpy(blk_req->data, buf, SECTOR_SIZE);
// Создаём дескрипторы virtqueue (в данном случае 3).
struct virtio_virtq *vq = blk_request_vq;
vq->descs[0].addr = blk_req_paddr;
vq->descs[0].len = sizeof(uint32_t) * 2 + sizeof(uint64_t);
vq->descs[0].flags = VIRTQ_DESC_F_NEXT;
vq->descs[0].next = 1;
vq->descs[1].addr = blk_req_paddr + offsetof(struct virtio_blk_req, data);
vq->descs[1].len = SECTOR_SIZE;
vq->descs[1].flags = VIRTQ_DESC_F_NEXT | (is_write ? 0 : VIRTQ_DESC_F_WRITE);
vq->descs[1].next = 2;
vq->descs[2].addr = blk_req_paddr + offsetof(struct virtio_blk_req, status);
vq->descs[2].len = sizeof(uint8_t);
vq->descs[2].flags = VIRTQ_DESC_F_WRITE;
// Уведомляем устройство о новом запросе.
virtq_kick(vq, 0);
// Ожидаем, пока устройство завершит обработку.
while (virtq_is_busy(vq))
;
// virtio-blk: возвращение ненулевого значения указывает на ошибку.
if (blk_req->status != 0) {
printf("virtio: warn: failed to read/write sector=%d status=%d\n",
sector, blk_req->status);
return;
}
// При операциях чтения копируем данные в буфер.
if (!is_write)
memcpy(buf, blk_req->data, SECTOR_SIZE);
}
Для отправки запроса проделайте следующее:
- Создайте запрос в
blk_req
. Укажите номер секции, к которой нужен доступ, и тип запроса: чтение/запись. - Создайте цепочку дескрипторов, указывающих каждый на свою область
blk_req
(подробнее ниже). - Добавьте индекс верхнего дескриптора цепочки в Avail Ring.
- Уведомите устройство о поступлении нового запроса.
- Подождите, пока устройство закончит обработку (используя холостой цикл или опрос).
- Проверьте полученный от устройства ответ.
Далее мы создадим цепочку из трёх дескрипторов. Нам нужно именно три, так как каждый из них будет иметь свои атрибуты (флаги):
struct virtio_blk_req {
// Первый дескриптор: только чтение с устройства
uint32_t type;
uint32_t reserved;
uint64_t sector;
// Второй дескриптор: возможна запись устройством, если это операция чтения (VIRTQ_DESC_F_WRITE)
uint8_t data[512];
// Третий дескриптор: возможна запись устройством (VIRTQ_DESC_F_WRITE)
uint8_t status;
} __attribute__((packed));
Поскольку мы каждый раз ожидаем завершения обработки, можно просто использовать первые 3 дескриптора по кругу. Но имейте ввиду, что на практике вам нужно отслеживать свободные/используемые дескрипторы для одновременной обработки нескольких запросов.
▍ Проверим
Пришло время опробовать нашу систему ввода/вывода. Добавьте в
kernel.c
следующий код:kernel.c
virtio_blk_init();
char buf[SECTOR_SIZE];
read_write_disk(buf, 0, false /* чтение с диска */);
printf("first sector: %s\n", buf);
strcpy(buf, "hello from kernel!!!\n");
read_write_disk(buf, 0, true /* запись на диск */);
Поскольку мы указываем
lorem.txt
как «необработанный» образ диска, его содержимое должно выводиться без изменений:$ ./run.sh
virtio-blk: capacity is 1024 bytes
first sector: Lorem ipsum dolor sit amet, consectetur adipiscing elit ...
Кроме того, первая секция переписывается строкой
hello from kernel!!!
:$ head lorem.txt
hello from kernel!!!
amet, consectetur adipiscing elit ...
Поздравляю! Вы успешно реализовали драйвер дискового ввода/вывода.
Подсказка
Вы могли подметить, что драйверы устройств — это лишь связующий элемент между ОС и устройствами. Они не управляют оборудованием напрямую. Драйверы взаимодействуют с другим программным обеспечением устройства (например, прошивкой). Именно сами устройства и их программное обеспечение, а не драйвер ОС, будут выполнять всю основную работу, такую как перемещение головок чтения/записи.
Файловая система
Итак, за вашими плечами уже серьёзный путь — вы реализовали процесс, оболочку, механизм управления памятью и драйвер диска. Завершим же мы наш проект реализацией файловой системы.
▍ Файловая система на базе tar
В этом руководстве я решил создать файловую систему интересным способом — на основе tar.
Tar — это формат архива, содержащего множество файлов. Он хранит в себе содержимое файлов, их имена, даты создания и прочую информацию, необходимую файловой системе. Если сравнивать tar с типичными системами вроде FAT или ext2, то его отличает более простая структура данных, плюс возможность работать с образом файловой системы посредством уже знакомой вам команды tar. Ну разве не идеальный формат для образовательных целей?
Подсказка
Сегодня tar используется как альтернатива ZIP, но изначально создавался в качестве своего рода файловой системы для хранения данных на магнитной ленте. То есть его можно использовать в роли файловой системы, чем мы и займёмся далее, хотя вы заметите, что для произвольного доступа он не годится. Кстати, вам наверняка будет интересно почитать о структуре файловой системы FAT.
▍ Создание образа диска (файла tar)
Начнём с подготовки содержимого нашей файловой системы. Создайте каталог
disk
и добавьте в него несколько файлов. Один из них назовите hello.txt
:$ mkdir disk
$ vim disk/hello.txt
$ vim disk/meow.txt
Добавьте в скрипт сборки команду для создания файла tar и его передачи в качестве образа диска в QEMU:
run.sh
(cd disk && tar cf ../disk.tar --format=ustar ./*.txt) # добавлено
$QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot \
-d unimp,guest_errors,int,cpu_reset -D qemu.log \
-drive id=drive0,file=disk.tar,format=raw,if=none \ # изменено
-device virtio-blk-device,drive=drive0,bus=virtio-mmio-bus.0 \
-kernel kernel.elf
Здесь используется две опции команды
tar
:-
cf
: создание файла tar. -
--format=ustar
: создание в формате ustar.
Подсказка
Скобки(...)
обеспечивают создание внутренней оболочки, чтобы командаcd
не влияла на другие части скрипта.
▍ Структура файлов tar
Файл tar имеет следующую структуру:
+----------------+
| заголовок tar |
+----------------+
| данные |
+----------------+
| заголовок |
+----------------+
| данные |
+----------------+
| ... |
Если в двух словах, то файл tar представляет собой последовательность пар «заголовок» и «данные», по одной для каждого файла. Существует несколько видов форматов tar, но мы остановимся на ustar (Wikipedia).
Эту структуру файла мы будем использовать в качестве структуры данных нашей файловой системы. Ради интереса и в образовательных целях рекомендую сравнить её с реальной файловой системой.
▍ Чтение файловой системы
Для начала определим в
kernel.h
структуры данных, связанные с файловой системой tar:
kernel.h
kernel.h
#define FILES_MAX 2
#define DISK_MAX_SIZE align_up(sizeof(struct file) * FILES_MAX, SECTOR_SIZE)
struct tar_header {
char name[100];
char mode[8];
char uid[8];
char gid[8];
char size[12];
char mtime[12];
char checksum[8];
char type;
char linkname[100];
char magic[6];
char version[2];
char uname[32];
char gname[32];
char devmajor[8];
char devminor[8];
char prefix[155];
char padding[12];
char data[]; // Массив, указывающий на область данных после заголовка
// (изменчивый член массива)
} __attribute__((packed));
struct file {
bool in_use; // Указывает, используется ли эта запись файла
char name[100]; // Имя файла
char data[1024]; // Содержимое файла
size_t size; // Размер файла
};
В нашей реализации файловой системы все файлы считываются с диска в память при загрузке.
FILES_MAX
определяет, сколько максимум файлов может быть загружено, а DISK_MAX_SIZE
устанавливает максимальный размер образа диска.Далее мы считаем в память весь диск:
kernel.c
kernel.c
struct file files[FILES_MAX];
uint8_t disk[DISK_MAX_SIZE];
int oct2int(char *oct, int len) {
int dec = 0;
for (int i = 0; i < len; i++) {
if (oct[i] < '0' || oct[i] > '7')
break;
dec = dec * 8 + (oct[i] - '0');
}
return dec;
}
void fs_init(void) {
for (unsigned sector = 0; sector < sizeof(disk) / SECTOR_SIZE; sector++)
read_write_disk(&disk[sector * SECTOR_SIZE], sector, false);
unsigned off = 0;
for (int i = 0; i < FILES_MAX; i++) {
struct tar_header *header = (struct tar_header *) &disk[off];
if (header->name[0] == '\0')
break;
if (strcmp(header->magic, "ustar") != 0)
PANIC("invalid tar header: magic=\"%s\"", header->magic);
int filesz = oct2int(header->size, sizeof(header->size));
struct file *file = &files[i];
file->in_use = true;
strcpy(file->name, header->name);
memcpy(file->data, header->data, filesz);
file->size = filesz;
printf("file: %s, size=%d\n", file->name, file->size);
off += align_up(sizeof(struct tar_header) + filesz, SECTOR_SIZE);
}
}
В этой функции мы сначала используем
read_write_disk
для загрузки образа диска во временный буфер (переменная disk
). Эта переменная объявлена не как локальная (стек), а как статическая. Причина в том, что размер стека ограничен, и желательно не использовать его для обширных областей данных.После загрузки содержимого дисков мы последовательно копируем их в записи переменной
files
. Обратите внимание, что числа в заголовке tar даны в восьмеричном формате. И такое представление сбивает с толку, так как очень похоже на десятичной форму. Для преобразования этих восьмеричных значений в целочисленные используется функция oct2int
.Наконец, не забываем в
kernel_main
после инициализации устройства virtio-blk
(virtio_blk_init
) вызвать функцию fs_init
:kernel.c
void kernel_main(void) {
memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);
WRITE_CSR(stvec, (uint32_t) kernel_entry);
virtio_blk_init();
fs_init();
/* код опущен */
}
▍ Проверка чтения файлов
Что ж, проверим! Скрипт должен выводить имена файлов из каталога
disk
и их размеры:$ ./run.sh
virtio-blk: capacity is 2560 bytes
file: world.txt, size=0
file: hello.txt, size=22
▍ Запись на диск
Запись файлов мы реализуем путём записи содержимого переменной
files
обратно на диск в формате tar:
kernel.c
kernel.c
void fs_flush(void) {
// Копируем всё содержимое файла в буфер `disk`.
memset(disk, 0, sizeof(disk));
unsigned off = 0;
for (int file_i = 0; file_i < FILES_MAX; file_i++) {
struct file *file = &files[file_i];
if (!file->in_use)
continue;
struct tar_header *header = (struct tar_header *) &disk[off];
memset(header, 0, sizeof(*header));
strcpy(header->name, file->name);
strcpy(header->mode, "000644");
strcpy(header->magic, "ustar");
strcpy(header->version, "00");
header->type = '0';
// Превращаем размер файла в восьмеричную строку.
int filesz = file->size;
for (int i = sizeof(header->size); i > 0; i--) {
header->size[i - 1] = (filesz % 8) + '0';
filesz /= 8;
}
// Вычисляем контрольную сумму.
int checksum = ' ' * sizeof(header->checksum);
for (unsigned i = 0; i < sizeof(struct tar_header); i++)
checksum += (unsigned char) disk[off + i];
for (int i = 5; i >= 0; i--) {
header->checksum[i] = (checksum % 8) + '0';
checksum /= 8;
}
// Копируем данные файла.
memcpy(header->data, file->data, file->size);
off += align_up(sizeof(struct tar_header) + file->size, SECTOR_SIZE);
}
// Записываем содержимое буфера `disk` в virtio-blk.
for (unsigned sector = 0; sector < sizeof(disk) / SECTOR_SIZE; sector++)
read_write_disk(&disk[sector * SECTOR_SIZE], sector, true);
printf("wrote %d bytes to disk\n", sizeof(disk));
}
В этой функции файл tar вносится в переменную
disk
, после чего записывается на диск с помощью read_write_disk
. Элементарно.▍ Проектирование системных вызовов для чтения/записи файлов
Теперь, когда мы реализовали операции чтения и записи, пора обеспечить для приложений возможность их использования. Для этого мы создадим два системных вызова:
readfile
и writefile
. Оба будут получать в качестве аргументов имя файла, буфер памяти для чтения или записи и размер этого буфера. common.h
#define SYS_READFILE 4
#define SYS_WRITEFILE 5
user.c
int readfile(const char *filename, char *buf, int len) {
return syscall(SYS_READFILE, (int) filename, (int) buf, len);
}
int writefile(const char *filename, const char *buf, int len) {
return syscall(SYS_WRITEFILE, (int) filename, (int) buf, len);
}
user.h
int readfile(const char *filename, char *buf, int len);
int writefile(const char *filename, const char *buf, int len);
Подсказка
Рекомендую почитать о проектировании системных вызовов в типичных операционных системах и проанализировать, что мы опустили в своей. Например, почему системные вызовыread(2)
иwrite(2)
в Linux получают в качестве аргументов не имена файлов, а файловые дескрипторы?
▍ Реализация системных вызовов
Теперь реализуем системные вызовы, которые определили выше:
kernel.c
kernel.c
struct file *fs_lookup(const char *filename) {
for (int i = 0; i < FILES_MAX; i++) {
struct file *file = &files[i];
if (!strcmp(file->name, filename))
return file;
}
return NULL;
}
void handle_syscall(struct trap_frame *f) {
switch (f->a3) {
/* код опущен */
case SYS_READFILE:
case SYS_WRITEFILE: {
const char *filename = (const char *) f->a0;
char *buf = (char *) f->a1;
int len = f->a2;
struct file *file = fs_lookup(filename);
if (!file) {
printf("file not found: %s\n", filename);
f->a0 = -1;
break;
}
if (len > (int) sizeof(file->data))
len = file->size;
if (f->a3 == SYS_WRITEFILE) {
memcpy(file->data, buf, len);
file->size = len;
fs_flush();
} else {
memcpy(buf, file->data, len);
}
f->a0 = len;
break;
}
default:
PANIC("unexpected syscall a3=%x\n", f->a3);
}
}
Операции чтения и записи файлов очень похожи, поэтому расположены рядом. Функция
fs_lookup
ищет в переменной files
запись на основе имени файла и либо считывает содержимое этой записи, либо его изменяет. В завершении функция fs_flush
производит запись на диск.Предупреждение
Чтобы не усложнять, мы разыменовываем указатели, передаваемые из приложений, напрямую, но это ставит под угрозу безопасность. Если у пользователей будет возможность указывать произвольные области памяти, то они смогут использовать системные вызовы для чтения из памяти ядра и записи в неё.
▍ Команды для чтения/записи файлов
Пора заняться чтением и записью файлов из оболочки. Поскольку в оболочке не реализован парсинг аргументов командной строки, мы создадим команды
readfile
и writefile
пока что просто для чтения и записи жёстко прописанного файла hello.txt
:shell.c
else if (strcmp(cmdline, "readfile") == 0) {
char buf[128];
int len = readfile("hello.txt", buf, sizeof(buf));
buf[len] = '\0';
printf("%s\n", buf);
}
else if (strcmp(cmdline, "writefile") == 0)
writefile("hello.txt", "Hello from shell!\n", 19);
Проще простого! Однако здесь возникает отказ страницы:
$ ./run.sh
> readfile
PANIC: kernel.c:561: unexpected trap scause=0000000d, stval=01000423, sepc=8020128a
Давайте разбираться. Согласно
llvm-objdump
, отказ происходит в функции strcmp
:$ llvm-objdump -d kernel.elf
...
80201282 <strcmp>:
80201282: 03 46 05 00 lbu a2, 0(a0)
80201286: 15 c2 beqz a2, 0x802012aa <.LBB3_4>
80201288: 05 05 addi a0, a0, 1
8020128a <.LBB3_2>:
8020128a: 83 c6 05 00 lbu a3, 0(a1) ← отказ страницы здесь (a1 содержит 2-й аргумент)
8020128e: 33 37 d0 00 snez a4, a3
80201292: 93 77 f6 0f andi a5, a2, 255
80201296: bd 8e xor a3, a3, a5
80201298: 93 b6 16 00 seqz a3, a3
Проверка содержимого таблицы страниц в мониторе QEMU показывает, что страница по адресу
0x1000423
(vaddr = 01000000
) действительно отображается в виде страницы режима пользователя (u
) с разрешениями чтения, записи и выполнения (rwx
):QEMU 8.0.2 monitor - type 'help' for more information
(qemu) info mem
vaddr paddr size attr
-------- ---------------- -------- -------
01000000 000000008026c000 00001000 rwxu-a-
Выведем содержимое памяти по этому виртуальному адресу (командой
x
):(qemu) x /10c 0x1000423
01000423: 'h' 'e' 'l' 'l' 'o' '.' 't' 'x' 't' '\x00' 'r' 'e' 'a' 'd' 'f' 'i'
01000433: 'l' 'e' '\x00' 'h' 'e' 'l' 'l' 'o' '\x00' '%' 's' '\n' '\x00' 'e' 'x' 'i'
01000443: 't' '\x00' 'w' 'r' 'i' 't' 'e' 'f'
Если проблема в настройках таблицы страниц, то команда
x
покажет ошибку или содержимое других страниц. Здесь же мы видим, что настроена таблица корректно, и указатель действительно ведёт на строку hello.txt
.Что же тогда может вызывать отказ страницы? А то, что в SCR
sstatus
не установлен бит SUM
.▍ Обращение к указателям пространства пользователя
В RISC-V поведение S-Mode (ядро) можно настроить с помощью
sstatus
, включая SUM
(Supervisor User Memory, доступ к памяти пространства пользователя). Если SUM
не установлен, программы S-Mode (ядра) не могут обращаться к страницам U-Mode (пространства пользователя).Подсказка
Это защитная мера с целью исключить нежелательное обращение к областям памяти пространства пользователя. Так совпало, что процессоры Intel имеют аналогичную функцию под названием SMAP (Supervisor Mode Access Prevention, предотвращение доступа в режиме супервизора).
Определим состояние бита
SUM
:kernel.h
#define SSTATUS_SUM (1 << 18)
Нам достаточно установить бит
SUM
при входе в пространство пользователя:kernel.c
__attribute__((naked)) void user_entry(void) {
__asm__ __volatile__(
"csrw sepc, %[sepc]\n"
"csrw sstatus, %[sstatus]\n"
"sret\n"
:
: [sepc] "r" (USER_BASE),
[sstatus] "r" (SSTATUS_SPIE | SSTATUS_SUM) // updated
);
}
Подсказка
Я просто сказал, что «причиной был битSUM
», но некоторые читатели спросят, как можно было узнать это самим? Честно говоря, нелегко. Даже если вы знаете, что происходит отказ страницы, определить его причину зачастую трудно. К сожалению, процессоры даже не предоставляют нам подробные коды ошибок. Мне же удалось определить источник проблемы, так как я знал о битеSUM
.
Вот несколько методов отладки для случаев, когда что-то работает не так:
- Внимательно прочтите спецификацию RISC-V. В ней говорится, что «когда бит
SUM
установлен, режим S-Mode имеет доступ к страницам режима U-Mode». - Прочтите исходный код QEMU. Причина упомянутого выше отказа страницы находилась здесь. Однако этот путь может оказаться труднее, чем внимательное прочтение спецификации.
- Обратитесь к LLM. Я серьёзно. Эти инструменты уже становятся вашим лучшим напарником в программировании.
Отладка — это одна из основных причин, по которой создание ОС с нуля занимает много времени, и начинающие это дело часто его бросают. Но нужно учитывать, чем больше проблем вы преодолеваете, тем больше получаете опыта и испытываете радости от преодоления!
▍ Тестирование чтения/записи файлов
Ещё раз попробуем выполнить чтение и запись файлов. Команда
readfile
должна отобразить содержимое hello.txt
:$ ./run.sh
> readfile
Can you see me? Ah, there you are! You've unlocked the achievement "Virtio Newbie!"
Теперь попробуем запись. По её завершению должно отобразиться количество записанных байтов:
> writefile
wrote 2560 bytes to disk
На этом изменение внутренностей образа завершено. Выйдите из QEMU и извлеките
disk.tar
. Теперь вы увидите обновлённое содержимое:$ mkdir tmp
$ cd tmp
$ tar xf ../disk.tar
$ ls -alh
total 4.0K
drwxr-xr-x 4 seiya staff 128 Jul 22 22:50 .
drwxr-xr-x 25 seiya staff 800 Jul 22 22:49 ..
-rw-r--r-- 1 seiya staff 26 Jan 1 1970 hello.txt
-rw-r--r-- 1 seiya staff 0 Jan 1 1970 meow.txt
$ cat hello.txt
Hello from shell!
Вот вы и реализовали ключевую часть ОС — файловую систему. Класс!
▍ Подведём итоги
Что ж, поздравляю! Книга закончена. В ней вы узнали, как с нуля реализовать ядро операционной системы и познакомились с базовыми принципами ОС, такими как загрузка процессора, переключение контекста, таблицы страниц, пространство пользователя, системные вызовы, дисковый ввод/вывод и файловая система.
И хотя наша ОС уместилась всего в 1 000 строк кода, проект был довольно трудный. Всё дело в том, что вы создавали основу основ самого ядра.
Для тех, кто желает пойти дальше, подскажу следующие шаги:
▍ Расширьте функциональность
В этом руководстве мы реализовали базовые функции ядра. Но можно реализовать и многие другие. Например, будет интересно добавить в него:
- Полноценный аллокатор памяти с возможностью её освобождения.
- Обработку прерываний вместо использования холостого цикла при обработке дискового ввода/вывода.
- Полноценную файловую систему. Хорошим началом станет реализация ext2.
- Сетевые возможности (TCP/IP). Реализовать UDP/IP несложно (TCP уже потруднее). Устройство
virtio-net
очень похоже наvirtio-blk
!
▍ Почитайте реализации других ОС
При этом самым веским шагом будет чтение реализаций существующих ОС. Сравнение вашей реализации с другими невероятно полезно для обучения.
Мне больше всех нравится версия RISC-V для xv6. Это UNIX-подобная ОС, используемая в образовательных целях. Причём она сопровождается пояснительным руководством (на английском). Рекомендую её тем, кто хочет больше узнать о характерных для UNIX функциях вроде
fork(2)
.В качестве ещё одного интересного образца советую мой личный проект Starina. Это операционка на базе микроядра, которую я написал на Rust. Она всё ещё на экспериментальной стадии, но станет интересным примером для тех, кто интересуется архитектурой микроядер и хочет побольше узнать о достоинствах Rust при разработке ОС.
▍ Обратная связь приветствуется!
Если у вас есть вопросы или вам просто есть, чем поделиться, пишите мне на GitHub или на почту.
Удачи вам на пути безграничного программировании ОС!
Telegram-канал со скидками, розыгрышами призов и новостями IT ?