В статье про память в самом верху адресного пространства была область, которую я пометил "отдельная статья": окно MMIO по адресу 0xF0000000, размером 192 МБ. Теперь рассчитаемся по этому долгу. Вопрос за этим простой: как ядро вообще разговаривает с железом, с видеокартой, диском, сетевой картой?
Два способа говорить с железом
Исторически их два (DMA особый случай).
Первый способ называется порт-I/O (или PMIO, port-mapped I/O): у процессора для него есть особые команды, in и out, и отдельное, крошечное адресное пространство из 65536 портов. Я его ещё использую в паре мест, например при сканировании PCI, но он неуклюж и рассчитан только на маленькие регистры.
Второй способ называется MMIO, memory-mapped I/O. Идея: регистры устройства появляются как самые обычные адреса памяти. Пишешь по нужному адресу, и значение попадает в регистр устройства. Читаешь его, и читаешь текущее состояние устройства. Никакой особой команды, просто доступ к памяти, и именно этот способ я использую везде, где можно.
Коротко об аббревиатурах
Чтобы остальное читалось, все сокращения сразу:
- MMIO - memory-mapped I/O, регистры устройства как адреса памяти.
- BAR - base address register, там PCI-устройство сообщает, где физически лежит его окно памяти.
- LFB - linear frame buffer, непрерывный буфер изображения видеокарты.
- DMA - direct memory access, устройство напрямую обращается в RAM, без процессора.
- PCD - page cache disable, бит в записи страницы, выключающий кэш для этой страницы.
- FIFO - first in, first out, первый пришел-первый ушел , здесь очередь команд виртуальной видеокарты.
Проблема: адрес лежит в другом месте
PCI-устройство через свой BAR говорит, где физически живут его регистры, скажем, по адресу 0xFD000000. Но мы это уже знаем из статьи про память: ядро живёт высоко, по адресу 0xFFC00000, и этого физического адреса в его виртуальной картине вовсе нет. Тронуть его напрямую оно не может, ровно как не могло тронуть свободную физическую страницу.
Решение то же, что и всегда: создать виртуальный адрес для физического. Под это есть отдельный кусок адресного пространства, окно MMIO от 0xF0000000 до 0xFFBFFFFF, и туда отображают регистры устройства.
mmio_map: создать отображение окна регистров
Центральная функция, mmio_map. Она берёт физический адрес (BAR) и размер и возвращает виртуальный адрес, через который ядро добирается до регистров:
void* mmio_map(uintptr_t phys, size_t size) {
uintptr_t base = phys & ~(PAGE_SIZE - 1); // округлить вниз до границы страницы
uintptr_t off = phys - base; // запомнить смещение внутри страницы
size_t count = (size + off + PAGE_SIZE - 1) / PAGE_SIZE;
void* v = alloc_mmio_pages(base, count,
PAGE_PRESENT | PAGE_WRITABLE | PAGE_CACHE_DISABLED); // кэш ВЫКЛ!
return v ? (void*)((uintptr_t)v + off) : 0;
}
По сути это тот же map_pages из статьи про память, только в область MMIO. Единственный флаг, который тут важен, это PAGE_CACHE_DISABLED.
Почему кэш должен быть выключен (PCD)
Это главное отличие от обычной памяти и самая распространненная ошибка. Обычный RAM лежит спокойно: что записал, то потом и прочитаешь, и неважно, держит ли процессор значение в кэше между делом.
Регистры железа совсем другой случай. Регистр статуса меняется сам, потому что устройство работает. А запись часто вовсе не "запомни значение", а команда: выставь этот бит, и устройство запускает сброс или передачу. Если бы процессор кэшировал MMIO, он читал бы устаревшие значения статуса из кэша, а не от устройства, и копил бы записи, отправляя их пачкой позже, вместо того чтобы пропускать сразу. И то и другое было бы фатально.
Поэтому PCD, page cache disable: для этих страниц кэш выключен, каждый доступ идёт напрямую к железу.
Почему volatile
Та же забота уровнем выше, у компилятора. Ему нельзя выкидывать доступы к MMIO ("ты читаешь тот же адрес дважды, я запомню результат") и нельзя их переставлять, оптимизировать. Поэтому маленькие помощники доступа помечены volatile. Это относится к любым переменным, не допускающим оптимизации компилятором:
static inline void mmio_write32(void* addr, uint32_t value) {
*((volatile uint32_t*)addr) = value; // volatile- не оптимизировать
}
static inline uint32_t mmio_read32(void* addr) {
return *((volatile uint32_t*)addr); // реально выполнять каждое чтение
}
Весь путь на картинке
Доступ: читать, писать, ждать
С отображённым адресом остальное просто: mmio_read32, mmio_write32 и их 8- и 16-битные собратья. Поверх них сидит пара помощников (helpers), которых я тут только назову: атомарные операции с битами (выставить или сбросить бит так, чтобы между делом не влез переключатель задач Task Switch) и, прежде всего, mmio_wait_for_bit_atomic, которым драйвер ждёт, пока устройство выставит бит готовности, с таймаутом, чтобы зависшее устройство не заморозило всю систему.
DMA: обратное направление
MMIO, это процессор получает доступ к устройству. Иногда наоборот: устройство должно само забрать большой объём данных из RAM или записать туда, без того чтобы процессор пропускал каждый байт. Это DMA (Direct Memory Access ).
Тут есть подвох: устройство ничего не знает про страничную память. Оно понимает только физические адреса. Дашь ему виртуальный указатель, и оно запишет совсем не туда. Для этого есть два помощника (helpers):
// физически непрерывный буфер: vaddr для процессора, phys для устройства
void* dma_alloc_coherent(size_t size, uintptr_t* phys_out);
// получить физический адрес уже отображённого буфера (обход страниц)
uintptr_t dma_phys(const void* vaddr);
Тонкое отличие: буферы DMA отображаются кэшируемыми, не с PCD. В отличие от регистров железа, это ведь настоящий RAM, а на x86 DMA когерентен по кэшу, железо само следит, чтобы устройство и процессор видели одно и то же.
Где мы это используем
Это не теория, это работает на каждом углу:
- PCI, в общем виде. При сканировании устройств слой PCI сразу вписывает BAR'ы каждого найденного устройства и сохраняет виртуальные адреса: out->mmio_virt[i] = mmio_map(mmio_phys, size);. Каждый драйвер находит своё окно готовым.
- Графика. Драйвер VESA отображает linear frame buffer (alloc_mmio_pages(fb_phys, ...)), а драйвер VMware SVGA II отображает свой регистровый BAR, фреймбуфер и очередь команд FIFO, последнюю намеренно без кэша (mmio_map(fifo_phys, ...)), потому что порядок записей должен сохраняться.
- Накопители. Драйвер AHCI раскладывает свои command tables и структуры FIS в RAM и отдаёт контроллеру их физические адреса: phyaddr command_table_phys = dma_phys(command_table_virt);.
- Сеть. Карта Intel E1000 получает свои кольца (кольцевые буферы) дескрипторов через тот же самый dma_phys.
Правда жизни
Ошибки MMIO особенно коварны, потому что часто всплывают поздно. Забудешь PCD на области памяти поставить, и вроде бы всё работает, пока устройство под нагрузкой не сменит статус, а процессор упрямо читает старое, кэшированное значение, и тогда драйвер виснет на условии, которое давно выполнено. Поэтому весь доступ к MMIO остаётся в самом нижнем слое (Layer 0, HAL), ни один модуль выше не трогает аппаратные адреса напрямую. Это одно из правил, которых я обязан держаться сам, как раз потому, что его нарушение так долго оставалось бы незамеченным.
◀ Предыдущая статья Содержание Следующая статья ▶
*Система не стоит на месте, поэтому в дальнейшем тексты могут не совпадать с реальным положением.