У каждой операционной системы есть вежливая ложь, которую она рассказывает самой себе. Windows говорит своему ядру: "железо под тобой устроено понятным образом, прерывания приходят с предсказуемых линий, таймер тикает знакомо, процессоров ровно столько, сколько я сказал". Правда скромнее и шумнее: на конкретной материнской плате стоит конкретный interrupt controller, таймеры разбросаны по чипсету прихотливым образом, а процессорных ядер может быть два, восемь или тридцать два. Прослойка, которая переводит грубую правду на язык вежливой лжи, называется HAL, Hardware Abstraction Layer. В Windows это всего один файл, hal.dll, лежащий рядом с ntoskrnl.exe в System32, но без него ядро не поняло бы, откуда пришло прерывание и куда писать, чтобы загасить его. Эта статья разбирает, что именно скрывает HAL, почему создатели NT так упорно не хотели знать устройство вашего процессора, чем эта конструкция отличается от подхода Linux и прошивок UEFI, и почему идея, казавшаяся пережитком девяностых, тихо вернулась в ARM64-Windows.

Что именно прячет слой hal.dll от любопытного ядра

Слово "абстракция" здесь не фигура речи, а конкретный список деталей, которые ядру видеть нельзя, если вы не хотите переписывать его под каждую новую плату. Главная из них - устройство interrupt controller. Классический PC девяностых тащил пару каскадированных чипов 8259 PIC, наследие ранних IBM PC: пятнадцать линий IRQ, каскадирование через IRQ2. Современная машина живёт на APIC: Local APIC в каждом ядре и I/O APIC на чипсете, ремаппинг прерываний через таблицы, message-signaled interrupts от устройств PCI Express. С точки зрения ядра обе вселенные должны выглядеть одинаково: "вот вектор прерывания, вот процедура подтверждения, вот способ запретить линию". Эту унификацию и дает HAL: ntoskrnl вызывает функцию вроде HalRequestIpi или регистрирует вектор через HalStartSystemInterrupt, не ведая, сидит ли за вызовом дряхлый 8259 или кластер APIC.

Второй скрываемый слой - таймеры. PC исторически пользовался 8253/8254 PIT, потом появились HPET, APIC timer, а в современных системах - TSC deadline mode, где ядро процессора само отмеряет время в тактах. Планировщику Windows нужно одно: "разбуди меня через N миллисекунд" и "посади периодический тик". Какой кремниевый будильник реально заводится под капотом, планировщику безразлично - и это безразличие куплено тем, что HAL знает, как программировать конкретный таймер данной платы.

Третий пласт - SMP-топология и межпроцессорный обмен. IPI, inter-processor interrupt, посылается по-разному на разных платформах; инициализация процессоров, запуск и остановка ядер, работа с IOMMU и DMA-mappings - всё это территория HAL. Ядро оперирует абстракциями: "пошли IPI процессору 5", "разреши этому устройству DMA в вот этот диапазон". Детали регистров, константы стартовых векторов, перевод шинных адресов в физические через IOMMU - забота hal.dll.

Стек Windows от приложения до кремния через вежливую ложь

Удобно держать в голове полный путь вызова, потому что HAL - только нижний этаж длинной лестницы:

  1. приложение вызывает привычную функцию Win32 API, например ReadFile или VirtualAlloc, и почти не подозревает о существовании системных вызовов;
  2. подсистемные библиотеки kernel32.dll и kernelbase.dll переводят вызов в нативный интерфейс, где имена начинаются с Nt и Zw;
  3. ntdll.dll кладёт номер системного вызова в регистр и исполняет инструкцию syscall, проваливаясь в режим ядра;
  4. ядро ntoskrnl.exe диспетчеризует вызов, обращается к драйверам через I/O manager и планирует потоки, не зная ничего о чипсете;
  5. для операций "подтвердить прерывание", "завести таймер", "послать IPI" ядро обращается к hal.dll;
  6. HAL пишет в регистры конкретного железа, и только здесь запрос встречает физическую плату.

Граница между ntoskrnl и hal.dll - это граница между "логикой ОС" и "знанием о плате". Ядро содержит диспетчер объектов, подсистему виртуальной памяти, планировщик - всё то, что железонезависимо по своей сути. HAL содержит код, зависящий от того, какой interrupt controller установлен, как подключены шины, как организован запуск ядер. Отдельное замечание про драйверы: они встраиваются между ядром и устройствами как плагины, и это второй полюс той же философии. Microsoft не обязана поддерживать каждый NVMe-контроллер внутри ntoskrnl; производитель пишет драйвер по модели WDM, а нынче WDF, и ядро грузит его как расширение. HAL решает проблему "ядро против платы", драйверная модель - "ядро против периферии". Вместе они объясняют, почему Windows не разрастается безгранично по мере выхода нового железа.

История NT когда портируемость была главным спортом

Сегодня легко забыть, что NT задумывалась не "Windows для x86", а переносимая ОС нового поколения. Команда под руководством Дэвида Катлера пришла из мира VMS, где ценили архитектурную чистоту, и одним из первых требований поставила способность ОС жить на разных процессорах. В девяностые NT реально собирали для MIPS R4000, DEC Alpha AXP, PowerPC. Это был не маркетинговый жест, а инженерная страховка: никто не знал, какая архитектура победит в серверной гонке десятилетия.

Чтобы портировать NT, нужно было переписать три вещи: компиляторную инфраструктуру, малую часть ядра, заботящуюся о переключениях контекста и страничной адресации, и HAL под новую плату. Всё остальное - диспетчер объектов, планировщик, файловые системы, Win32-подсистема - ехало на новую архитектуру почти без изменений. Это и была настоящая проверка гипотезы: если портируемость достигается переписыванием десятка процентов кода, значит границы абстракций проведены верно. Рыночная судьба альтернативных архитектур сложилась грустно: x86 победил по совокупности масштаба и цены, MIPS ушёл во встраиваемку, Alpha исчезла вместе с DEC, PowerPC покинул десктоп. Портируемость как тактика проиграла рынку, но как структурное решение она осталась в ядре навсегда. Аккуратные границы позже сослужили службу при появлении x64, потом ARM в Windows RT, потом ARM64-Windows. Инфраструктура не ржавеет, если её построили честно.

Linux, прошивки и другие способы спрятать плату

Идея абстрагировать железо не уникальна для Windows. В Linux нет отдельного "hal.dll": машинозависимый код собран внутри архитектурных подсистем arch/x86, arch/arm64 и подкаталогов конкретных плат. Граница проведена по-другому: не "отдельный бинарный модуль", а "отдельные каталоги, собираемые по конфигурации". Плюс подхода - видимость и единая система сборки; минус исторический - платформенный код плодился, пока в ARM-лесу не пришлось наводить порядок через Device Tree, где описание платы вынесено в отдельный бинарный блоб.

Прошивки - третий механизм с той же целью, но на другом уровне. UEFI и ACPI работают как своего рода "драйверы материнской платы": прошивка публикует таблицы, которые ОС читает на старте. ACPI-таблицы содержат AML-байткод, исполняемый интерпретатором внутри ОС, карты APIC-контроллеров (MADT), описания HPET, тепловых зон и состояний питания. Ядру не нужно знать, по какому адресу лежит контроллер прерываний на этой конкретной плате, - оно спрашивает у таблиц. HAL в Windows плотно сотрудничает с ACPI: читает MADT, чтобы инициализировать APIC, достаёт HPET для таймера, договаривается с AML-методами о состояниях питания. Архитектурно это тот же жест: вынести специфику платы из кода в данные. Сравнение трёх подходов поучительно: Windows положилась на бинарную абстракцию, поставляемую вместе с ОС; Linux - на исходную структуру и device tree; ACPI-мира - на таблицы в фирмваре, интерпретируемые на лету. Все три решают одно уравнение.

Чего HAL не делает и где проходят границы его полномочий

Абстракцию легко переоценить, поэтому полезно перечислить, чего HAL не делает. Он не выравнивает производительность между платами: медленный чипсет останется медленным, как бы хорошо ни был написан код доступа к нему. Он не виртуализирует память - подсистема виртуальной памяти живёт в ядре и опирается на MMU процессора, не на hal.dll. Он не эмулирует инструкции, не занимается безопасностью, привилегиями, изоляцией процессов. Его улица начинается там, где начинается адресное пространство регистров конкретного железа, и заканчивается примерно там же.

Другая ловушка - думать, что HAL "скрывает" железо в смысле полной прозрачности. Драйвер конкретного устройства всё равно знает свое устройство; HAL лишь даёт ему унифицированные сервисы: получить DMA-адаптер, привязать вектор прерывания, отобразить регистры. Если устройство требует экзотики, выходящей за эти сервисы, экзотика ложится на драйвер. Отдельная деталь: когда-то hal.dll был семейством. Существовали halacpi.dll, halmacpi.dll для многопроцессорных APIC-систем; при установке выбирался подходящий вариант и переименовывался в hal.dll, и неверный выбор был одной из причин, по которым образ Windows не загружался на радикально другой плате. Сегодня семейство сжалось почти до одного файла, потому что ландшафт железа выровнялся.

Почему x86 сделал HAL невидимым и как ARM64 вернул ему работу

Победа x86 имела неожиданный побочный эффект: необходимость в многочисленных HAL испарилась. Если все платы примерно одинаковы - APIC, HPET, MSI, стандартный таймерный ландшафт - то и абстракция вырождается в одну конфигурацию. Десятилетие x64 сделало hal.dll почти невидимым: он есть, он загружается, но никто не выбирает между вариантами.

Архитектура ARM64 всё изменила. Мир ARM не знает такой унификации, какая сложилась вокруг ПК: каждый SoC от Qualcomm, Samsung, MediaTek имеет свой контроллер прерываний GIC конкретной версии, свои таймеры, свою схему питания, свой способ запуска ядер. Windows для ARM64 вернулась к реалиям, ради которых HAL и изобретался. Дополнительно появились так называемые HAL Extensions - отдельные библиотеки, поставляемые вендором SoC, которые подгружаются к базовому hal.dll и закрывают его "платформенные дыры". Это прямое признание: Microsoft не в состоянии и не хочет знать устройство каждого телефона, пусть производитель SoC допишет своё.

Вторая арена, где HAL снова задышал, - виртуализация. Под Hyper-V гостевая Windows общается не с физическим APIC, а с синтетическим interrupt controller и синтетическими таймерами гипервизора. Можно сказать, что появился виртуальный HAL: ту же роль "спрятать детали" выполняет граница между гостем и гипервизором, а enlightenments подсказывают гостю не делать лишней работы там, где гипервизор справится лучше. Абстракция, придуманная, чтобы спрятать чипсет, оказалась удобной, чтобы спрятать и факт виртуализации.

Практический угол как посмотреть версию и урок для архитекторов

Любопытство к конкретному hal.dll удовлетворяется элементарно. Файл живёт в C:\Windows\System32\hal.dll; правый щелчок, свойства, вкладка "Подробно" покажет версию, совпадающую с билдом ОС, - hal.dll собирается в ногу с ntoskrnl. Из PowerShell достаточно (Get-Item C:\Windows\System32\hal.dll).VersionInfo, чтобы увидеть FileVersion и ProductVersion. Для более глубокого осмотра есть dumpbin /imports от ntoskrnl.exe, где перечислены функции, экспортируемые hal.dll, - по именам вроде HalRequestIpi, HalBeginSystemInterrupt, HalGetAdapter можно буквально читать границу абстракции, не имея исходников.

Из всей истории с HAL архитектору стоит вынести один большой урок: стабильный интерфейс старше железа. Платы, контроллеры, целые архитектуры процессоров приходят и уходят, а ntoskrnl как вызывал HalRequestIpi в начале девяностых, так и вызывает его сегодня. Инвестиция в честную границу абстракции окупается не сразу - MIPS и Alpha умерли, и казалось, что портируемость оказалась мертвым грузом. А потом пришёл ARM64, и граница, проведённая три десятилетия назад, снова отделила ОС, способную встать на новое железо за месяцы, от ОС, которой нужны годы.

Второй урок скромнее: абстракция не обязана быть большой, чтобы быть важной. hal.dll - это узкая, плотно спроектированная прослойка между двумя мирами. Его сила именно в узости. Он фиксирует один конкретный договор: ядро говорит на языке прерываний, таймеров и процессоров в абстрактном виде; HAL отвечает на языке регистров и линий конкретной платы. Пока обе стороны держат договор, железо под ними может меняться сколько угодно быстро. Это, пожалуй, самое ценное, что инженер может вынести из истории с hal.dll, - не ностальгию по MIPS, а уважение к границе, проведённой один раз и проведённой честно.