Тема этой статьи графическому ядерному интерфейсу Windows, и писать её стоит не про абстракции, а про конкретный механизм, который связывает пользовательский процесс с видеокартой. В ядре Windows за это отвечает драйвер dxgkrnl.sys, DirectX Graphics Kernel, а прямое взаимодействие с видеодрайвером устроено через цепочку GDI, win32k.sys и дисплейного мини порта. Тот, кто отлаживает зависания отрисовки, утечки хендлов поверхностей или пишет собственный режим работы с GPU, обязан понимать эту цепочку до уровня отдельных структур и IOCTL. Дальше разбор идёт по слоям: от вызова приложения до команды, которую видит сам мини порт видеокарты, с командами WinDbg и примерами кода на C.
Как устроен стек DirectX Graphics Kernel и зачем в нём два драйвера
Графическая подсистема Windows после перехода на модель WDDM, Windows Display Driver Model, развалилась на две части. Первая живёт в пользовательском режиме: это runtime библиотеки d3d11.dll, d3d12.dll, dxgi.dll и пользовательская часть драйвера от производителя, например igfx или nvlddm в паре с системным компонентом. Вторая часть живёт в ядре: это dxgkrnl.sys, который управляет видеопамятью, планирует графические команды независимо от производителя карты и стыкуется с мини портом конкретного адаптера. Зачем такое разделение? Ответ лежит в стабильности: в модели XPDDM эпохи Windows XP весь драйвер выполнялся в ядре, и глюк шейдерного компилятора ронял систему в синий экран. В WDDM тяжёлый парсинг и компиляция переехали в процесс пользователя, а в ядре осталась только критичная книга учёта: какие поверхности где размещены, какие очереди команд принимать и в какой поток их планировать.
Разработчик видит это при загрузке модуля в отладчике. Например:
kd> lm m dxgkrnl
start end module name
fffff802'6a400000 fffff802'6a5c7000 dxgkrnl
Размер модуля уже говорит о серьёзности: это не обёртка, а полноценный менеджер, через который проходит каждый Present на экране.
Обращение к miniport через дисплейные IRP на практике
Дисплейный мини порт это модуль, написанный производителем железа по контракту WDDM. Контракт описывает наборчик обязательных колбэков: DxgkDdiStartDevice, DxgkDdiSubmitCommand, DxgkDdiBuildPagingBuffer и другие. Ядерный компонент не знает ничего про конкретный чип, он знает только таблицу указателей на функции, которую мини порт зарегистрировал через DxgkInitialize. Когда игра вызывает d3d12 функцию ExecuteCommandLists, пользовательская часть драйвера конвертирует командные буферы в DMA буферы и дёргает NtGdiDdRenderCb, а dxgkrnl через видеопамятьный менеджер подгоняет ресурсы и в итоге вызывает DxgkDdiSubmitCommand мини порта.
Пример каркаса мини порта на упрощённом С:
NTSTATUS APIENTRY DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
DRIVER_INITIALIZATION_DATA init = {0};
init.Version = DXGKDDI_INTERFACE_VERSION;
init.DxgkDdiAddDevice = MiniportAddDevice;
init.DxgkDdiStartDevice = MiniportStartDevice;
init.DxgkDdiSubmitCommand = MiniportSubmitCommand;
init.DxgkDdiBuildPagingBuffer = MiniportBuildPagingBuffer;
init.DxgkDdiSetVidPnSourceAddress = MiniportSetVidPnSourceAddress;
return DxgkInitialize(DriverObject, RegistryPath, &init);
}
NTSTATUS MiniportSubmitCommand(const HANDLE hAdapter, DXGKARG_SUBMITCOMMAND *pCmd)
{
PHYSICAL_ADDRESS dma = pCmd->DmaBufferPhysicalAddress;
ULONG size = pCmd->DmaBufferSize;
// записать дескриптор на DMA кольцо чипа
WriteRingSlot(hAdapter, dma, size);
return STATUS_SUCCESS;
}
Разработчик, который хочет действительно прямое взаимодействие, обычно спускается ещё ниже: создаёт свой KMD драйвер, исключает загрузку штатного мини порта через INF или devcon и общается с железом через IoCallDriver и MmMapIoSpace.
Инструменты наблюдения из WinDbg и их полезные команды
Когда рабочий стол чёрный, курсор мигает или GPU уходит в TDR, таймаут и рекавери, первый шаг открыть ядерный отладчик. Полезный наборчик команд такой:
- !dxgk даст адаптеры, контексты и creation allele внутри подсистемы;
- !paging в dxgkrnl покажет очередь выгрузок памяти из VRAM в системную;
- vertarget и !running продемонстрируют, не стоит ли планировщик GPU;
- k по текущему потоку dxgkrnl расскажет, на каком DDI всё встало;
- !stacks 2 dxgkrnl! подсветит зависшие потоки внутри ядерной подсистемы;
- !devstack на объекте дисплея покажет весь рельс PNP вплоть до мини порта.
Отдельно стоит упомянуть ETW трассировку. Производительность кадров проверяют провайдером Microsoft Windows DXGKRNL, и именно там видно пробег времени между Present и реальной выдачей кадра. Инструмент WPR с профилем GPUView пишет эти события в ETL, а GPUView рисует шкалу работы адаптера с точностью до отдельного DMA буфера. Для разработчика прямого доступа это незаменимый вид на систему, потому что он показывает не то, что думает приложение, а то, что реально делает подсистема.
TDR, рекавери и их влияние на прямой доступ
Механизм Timeout Detection and Recovery перезапускает адаптер, если GPU не завершил работу за несколько секунд, по умолчанию 2 секунды. При прямой работе с железом через собственный мини порт это зона высокого риска: забыл уведомить ядро о прогрессе DMA, получил рестарт адаптера на середине кадра. Разработчик должен либо корректно выставлять флаги в структуре DXGKARG_SUBMITCOMMAND, либо явно справляться с событиями DxgkDdiResetDevice. В тестовом стенде параметры таймаута меняются через реестр: HKLM\SYSTEM\CurrentControlSet\Control\GraphicsDrivers, значения TdrDelay и TdrLevel. В боевой прошивке так делать нельзя, но во время отладки это реальный способ не словить вечный рестарт.
Пример установки через командную строку:
reg add "HKLM\SYSTEM\CurrentControlSet\Control\GraphicsDrivers" /v TdrDelay /t REG_DWORD /d 10 /f
Прямые IOCTL и переход в обход GDI интерфейса
Кому нужен честный канал до своего драйвера, открывает симлинк устройства и слушает IRP MJ DEVICE CONTROL. Классический каркас выглядит примерно так:
#define IOCTL_GPU_READ_BAR0 CTL_CODE(FILE_DEVICE_UNKNOWN, 0x820, METHOD_BUFFERED, FILE_ANY_ACCESS)
NTSTATUS EvtIoDeviceControl(WDFQUEUE Queue, WDFREQUEST Request,
size_t OutputBufferLength, size_t InputBufferLength, ULONG IoControlCode)
{
if (IoControlCode == IOCTL_GPU_READ_BAR0) {
PUCHAR buffer;
WdfRequestRetrieveOutputBuffer(Request, sizeof(ULONG), &buffer, NULL);
PULONG regs = GetBar0Virtual(); // MmMapIoSpace ранее
*((PULONG)buffer) = READ_REGISTER_ULONG(regs + 0x10);
WdfRequestCompleteWithInformation(Request, STATUS_SUCCESS, sizeof(ULONG));
} else {
WdfRequestComplete(Request, STATUS_INVALID_DEVICE_REQUEST);
}
return STATUS_SUCCESS;
}
Из пользовательского режима вызов тривиален: CreateFile на \\.\CustomGpu, потом DeviceIoControl с тем же кодом. Именно так устроены многие инструменты дампа регистров и оверклокерские утилиты: они не воюют с dxgkrnl, а строят параллельный безопасный канал. Это и есть рабочий определение фразы прямое взаимодействие в контексте ядра: минуя слои рендеринга, но внутри правил подписи драйверов и безопасности.
Внутри dxgkrnl живёт отдельный менеджер видеопамяти, VidMm, который ведёт каталог всех поверхностей так же тщательно, как менеджер виртуальной памяти ведёт страницы процесса. Каждая текстура, каждый буфер вершин получает выровненный блок с атрибутами: локальная память карты, апертурный сегмент AGP или обычная системная память. Когда VRAM кончается, VidMm молча выгружает далёкие от глаз поверхности в paging буферы на диск или в системную память, и ровно это проседает в кадре игры на пару миллисекунд. Разработчик прямого доступа обязан уважать эту кухню: нельзя просто захватить физический адрес буфера, потому что через секунду VidMm перекочует его в другое место. Разумный компромисс это режим закреплённых выделений, который закрепляет блок в сегменте, но стоит заметных накладных ресурсов. Диагностика ведётся командой !vidsch и трейсом VidMm через ETW, где видно каждую эвикцию и дефрагментацию.
Примерно так выглядит запрос выделения из мини порта:
DXGKARGCB_ALLOCATEPAGESFORPAGINGALLOCA p = {0};
p.Flags.Value = 0;
p.PagingBufferStartSize = 64 * 1024;
NTSTATUS st = g_DxgkCb.DxgkCbEnqueueSetEvent(/*...*/);
// фактическое размещение решает VidMm, мини порт получает лишь сегмент и офсет
Ключевая мысль: прямой обход вовсе не отменяет менеджера памяти, он лишь добавляет ещё одного потребителя, и конфликтовать с VidMm это прямой путь к краху системы.
Планировщик GPU и почему у него есть собственная кванториум
Второй столб подсистемы это видеопланировщик, VidSch. Он принимает очереди командных буферов от процессов и решает, какой поток работ займёт единственный вычислительный конвейер карты. Честность обеспечивается механизмом вытеснения: планировщик может приостановить долгую задачу через прерывание уровня PASSIVE или прямой reset конвейера, когда та заняла GPU дольше отведённого отведённого кванта. Начиная с Windows 10 появился режим Hardware Accelerated GPU Scheduling, при котором часть планирования уезжает из программного dxgkrnl в фирмваре карты, и переключается этот режим в настройках графики или через реестр HwSchMode. Разработчик, пишущий драйвер для датаконцентрированных задач, обязан проверить свой код в обоих режимах: поведение приёма fence и сигналов sync object заметно различается, а naive тесты ловят редкий deadlock именно на аппаратном планировщике.
Когда драйвер хочет подождать завершение команды GPU, он не крутит цикл, а опирается на fence объект. Типовая схема: ядро публикует значение fence в специальной поверхности, приложение спит на семафорном событийном хендле, и аппаратная запись будит процесс без лишних переключений. Вот минимальный пользовательский код ожидания через D3DKMT:
D3DKMT_WAITFORSYNCHRONIZATIONOBJECT2 wait = {0};
wait.hContext = hContext;
wait.ObjectCount = 1;
wait.ObjectHandleArray[0] = hMonitoredFence;
wait.FenceValue = submittedFence;
wait.hWaitEvent = hEvent;
NTSTATUS st = D3DKMTWaitForSynchronizationObject2(&wait);
Разработчик прямого драйвера реализует логику зеркально: каждая отправленная в кольцо команда должна по завершении обновлять shared page и дёргать DxgkCbNotifyInterrupt, иначе fence никогда не дорастёт до ожидаемого значения и система решит, что ядро зависло.
Ошибки, из за которых новички ломают графическое ядро
Практика отладки показывает устойчивый набор промахов у тех, кто впервые лезет в прямой доступ. Во первых, маппинг BAR целиком через MmMapIoSpace без кэш политики Write Combined: на некоторых чипах это роняет PCIe со странным MCE через десятки минут. Во вторых, запись в командное кольцо без memory fence: процессор радостно переставляет записи, и карта читает полупустой дескриптор. В третьих, забытый вызов ObDereferenceObject на объекте разделённой памяти: через пару часов живая дисплейная подсистема душится утечкой хендлов. И в четвёртых, смешение IRQL: попытка коснуться пейджируемой структуры на уровне DISPATCH завершается вечным спином. Запомнить просто: графическое ядро прощает медленный код, но не прощает неправильный IRQL и непоследовательную память.
Последний барьер политический и проверочный. Начиная с Windows 10 1607 драйвер грузится только с валидной подписью, а с включённым HVCI, изоляцией ядра на виртуализации, даже подписанный драйвер не запустится, если его страницы отмечены как исполняемо перезаписываемые. Проверяется это через Driver Verifier с профилем Code integrity и через инструмент HVCIScan. Отладочный режим bcdedit /set testsigning on разрешает тестовую подпись, но на современных сборках с включённым Secure Boot это уже не поможет, требуется Attestation signing через Partner Center. Разработчику, который ломится в графическое ядро, придётся принять эту рамку заранее.
Понимание устройства dxgkrnl и его контракта с мини портом меняет взгляд на любую отладку графики. Чёрный экран перестаёт быть необъяснимым явлением, TDR перестаёт быть случайностью, а мысль написать свой драйвер обретает конкретную дорожную карту: таблица DDI, планировщик, заслуженный канал IOCTL и аккуратная подпись. В этом и сила системного подхода: не бороться с подсистемой, а говорить с ней на её родном протоколе.