Слой Storage
В каждой пробе раздела стояло thread_read_storage(ctx, сектор, число, буфер) и я пользовался этим, будто понятно, что за ним происходит. Теперь посмотрим. Слой Storage, это последний программный слой перед настоящим железом. Над ним все говорят лишь о номерах секторов, под ним начинаются порты, регистры и время ожидания.
Что делает этот слой
Он принимает "прочитай сектор X с носителя Y", выясняет, к какому контроллеру подключён носитель и вызывает подходящий драйвер. Это мост между абстрактным запросом и совсем конкретным куском железа и при этом он должен следить, чтобы два потока (threads) не ковырялись в одном контроллере сразу.
Реестр устройств
При загрузке драйверы объявляют каждый найденный накопитель и он попадает в таблицу storage_devices[] (до 32 устройств). Каждая запись говорит как добраться до устройства:
struct storage_device_info {
uint8_t controller_type; // IDE_CONTROLLER, AHCI_CONTROLLER
uint8_t device_port; // порт / привод на контроллере
uint32_t base_1, base_2; // базовые адреса контроллера
uint32_t number_of_sectors; // всего секторов
uint8_t device_type; // HARD_DISK, OPTICAL_DRIVE, USB_STORAGE
uint8_t medium_number; // глобальный индекс (hd0 = 0, hd1 = 1, ...)
} __attribute__((packed));
controller_type и два адреса base это ключ - они говорят слою Storage, звать ли драйвер IDE или AHCI и где лежат его регистры.
Контекст (StorageContext) на на каждый поток
Прежде чем читать, надо выбрать, какой носитель имеется в виду. Здесь раньше таился самый противный баг всей подсистемы. Была глобальная переменная "текущий носитель" и в системе с несколькими потоками это была тикающая бомба: поток A выбирает hd0, поток B в тот же миг выбирает hd1 и A вдруг читает с диска B. Никто не замечает, данные просто не те и это выглядит как неисправная файловая система. Классическая гонка TOCTOU - проверка и использование это не один шаг.
Решение то же что и в абстрактном слое - каждый поток несёт свой контекст.
typedef struct {
uint8_t selected_medium_type; // что выбрал ЭТОТ поток
uint8_t selected_medium_number;
Mutex context_lock; // защищает этот контекст
// ... метаданные (оглавление, размер) ...
} StorageContext;
Больше нет общей переменной которую B мог бы перезаписать у A. A читает hd0, B читает hd1, одновременно и без конфликта.
Выполнение запроса
Запрос на чтение всегда идёт по одной схеме: проверить, заблокировать дважды, выбрать носитель, вызвать драйвер, разблокировать.
StorageError thread_read_storage(StorageContext* ctx, uint32_t sector,
uint8_t num_of_sectors, void* memory) {
if (!ctx || !memory || num_of_sectors == 0) return STORAGE_ERROR_INVALID_PARAMS;
if (ctx->selected_medium_type == NO_MEDIUM) return STORAGE_ERROR_NO_MEDIUM;
mutex_get(&ctx->context_lock, true); // 1. контекст потока
mutex_get(&storage_global_lock, true); // 2. железо
StorageError result = STORAGE_ERROR_HARDWARE_FAULT;
if (ctx->selected_medium_type == MEDIUM_HARD_DISK) {
struct storage_device_info* dev =
get_device_info(MEDIUM_HARD_DISK, ctx->selected_medium_number);
if (dev->controller_type == IDE_CONTROLLER)
ide_select_drive(dev->base_1, dev->device_port); // выбор привода ПОД блокировкой
// обобщённый вызов, неважно, IDE или AHCI за ним:
const udm_storage_driver_ops_t* ops = udm_get_storage_driver_ops(dev->controller_type);
uint8_t ok = ops->read(dev->base_1, (void*)dev->base_2, sector, num_of_sectors, memory);
result = ok ? STORAGE_SUCCESS : STORAGE_ERROR_HARDWARE_FAULT;
}
// ... MEDIUM_OPTICAL_DRIVE (ops->atapi_read), MEDIUM_USB_MSD (usb_msd_read) ...
mutex_release(&storage_global_lock); // обратный порядок
mutex_release(&ctx->context_lock);
return result;
}
Тут важны три вещи:
- порядок блокировок: сперва контекст потока, потом глобальная блокировка железа, а при снятии ровно наоборот.
- udm_get_storage_driver_ops это снова шаблон договора (interface) из абстрактного слоя, интерфейс из указателей на функции (read, atapi_read, write) который исполняет драйвер IDE или AHCI. Слой Storage вызывает его обобщённо и не обязан знать какое именно железо за ним.
- ide_select_drive прямо под блокировкой: у IDE два привода делят один контроллер, поэтому выбор и доступ должны идти одним куском под той же блокировкой, иначе другой поток выберет в промежутке другой привод.
Mutex, а не spinlock, и никакого printf
Две ловушки однажды полностью заморозили эту подсистему, и обе поучительны.
Первая: почему это мьютексы, а не спинлоки? I/O железа ждёт в цикле, пока диск закончит и этот цикл опрашивает тик таймера cpu->ticks. Но спинлок выключает прерывания (cli), пока он захвачен. Тогда прерывание таймера никогда не приходит, cpu->ticks никогда не меняется, цикл ожидания никогда не кончается - система зависает даже на одном ядре. Мьютекс же блокирует через sti; hlt, то есть оставляет прерывания включёнными, прерывание таймера проходит, тик идёт дальше и ожидание кончается как задумано. Поэтому context_lock и storage_global_lock - это мьютексы.
Вторая ловушка, это печально известное зависание "csm ls" - printf под захваченным storage_global_lock. Очередь консоли переполняется, printf блокируется, а тому кто опустошил бы очередь самому нужен доступ к Storage, то есть блокировка (deadlock). Никто не движется дальше. Правило отсюда железное и действует везде в ядре - сперва снять все блокировки Storage, потом логировать.
Когда диск иногда сбоит
Настоящее железо не идеально, иногда доступ просто ненадолго срывается. Для этого есть thread_read_storage_with_retry при временной ошибке (HARDWARE_FAULT, TIMEOUT) он повторяет доступ с растущими паузами (10, 20, 40 мс), при постоянной ошибке (NO_MEDIUM, UNSUPPORTED) сразу сдаётся и выходит. Так слой ниже ловит мелкие сбои и каждому драйверу файловой системы не нужно строить собственный цикл повторов.
Правда жизни
Известная слабость - выбор привода IDE (ide_select_drive) сам по себе не атомарен между несколькими потоками, его держит вместе лишь storage_global_lock. Пока каждый доступ делает выбор и I/O под одной блокировкой, всё хорошо, но это свойство нельзя забывать. А вся история про mutex-вместо-spinlock, хороший пример того, что верный вид блокировки, это не деталь, а вопрос жизни и смерти системы.
Что дальше
За ops->read прячется первый из двух настоящих драйверов железа: IDE. Он достаёт байты по кусочку через старые порты ATA методом под названием PIO, где процессор сам читает каждое слово из регистра данных. Дальше идёт AHCI, более современный драйвер SATA, который позволяет диску писать прямо в память через DMA.
Было бы интересно увидеть ваши комментарии и улучшить статьи.
◀ Предыдущая статья Содержание Следующая статья ▶
*Система не стоит на месте, поэтому в дальнейшем тексты могут не совпадать с реальным положением