РАЗБОР Статья

Windows x64 - путешествие из User в Kernel

Marylin
Marylin Mod.Assembler · 401 сообщений
Подписаться
733
[ обложка статьи ]
Режим чтения
Диспетчер задач Windows 7, вкладка «Быстродействие»: 57 процессов и 724 потока, счётчик потоков выделен красной рамкой

Переходы из пространства пользователя в ядро - это маршрут, который совершают данные, когда программе требуется доступ к физ.оборудованию. Обычно такие переходы происходят тысячи раз в секунду (например при клике мышкой, вызовах WinAPI, и прочее), а потому сам механизм телепорта должен быть отшлифован до блеска. Но, что мы имеем в современных реалиях, и что предлагает нам ЦП на аппаратном уровне? Давайте разбираться.

Оглавление:
1. Контроль исполняемых потоков процессором​
- стек ядра и стек пользователя​
- сегмент состояния задачи TSS​
2. Асинхронные переходы по прерываниям и исключениям​
- структура TRAP_FRAME​
3. Синхронные переходы по окончании кванта потока​
- структура SWITCH_FRAME​
4. Выводы​



1. Контроль исполняемых потоков процессором​


Чтобы не возникало лишних вопросов, начнём с базовых основ.
Значит всякий процесс в ядре - это просто структура EPROCESS, где перечислены все выделенные ему системные ресурсы. А вот код исполняют уже потоки процесса, которые олицетворяет структура ETHREAD. Что примечательно, ЦП ориентируется исключительно на потоки и даже не знает, какому именно процессу они принадлежат. Далее под терминами "процесс" и "поток" мы будем подразумевать их структуры.

В процессах имеются по 2 кольцевых списка LIST_ENTRY - в первом лежат указатели на следующий и предыдущий процесс в системе (позволяют вести учёт активных), а во втором - линки на их собственные потоки. Вот, что думает на этот счёт отладчик WinDBG:

Код:
0: kd> dt _eprocess
nt!_EPROCESS
   +0x000 Pcb                : _KPROCESS     $$ общая информация (Control Block)
   +0x180 UniqueProcessId    : Ptr64         $$ PID процесса
   +0x188 ActiveProcessLinks : _LIST_ENTRY   $$ линк на сл.процесс в системе
   +0x308 ThreadListHead     : _LIST_ENTRY   $$ список потоков данного процесса
   +0x328 ActiveThreads      : Uint4B        $$ всего потоков в списке выше ^^^
   +0x338 Peb                : Ptr64 _PEB    $$ линк на РЕВ в пространстве юзера
........
0: kd>

Всё-бы хорошо, только умиротворённый пейзаж портит системный планировщик. Каждые 0.02 сек (квант 20мс) он подкидывает на конвейер потоки соседних процессов из своей очереди, чтобы эмулировать многозадачность. За этот промежуток ЦП с тактовой частотой 2 ГГц успевает простучать 40 млн раз (2 млрд * 0.02), что равносильно выполнению ~20 млн простых инструкций. Этого более чем достаточно, чтобы утилита среднего звена полностью отработала свой код всего за 1 выделенный ей квант.

Но если поток приложения ожидает диалога с юзером, то 20 милли/сек ему по любому не хватит. Тогда планировщик приостанавливает наш поток, отдаёт управление следующему в очереди, и если нет других, возвращает наш обратно в прежнее состояние. Эту процедуру назвали "Context-Switch", и поскольку она требует переходов в ядро, инженеры стараются макс.оптимизировать её. Рассмотрим, как и при каких именно обстоятельствах это происходит.

1.1. Стек ядра и стек пользователя​

Межуровневая миграция потоков в нулевое кольцо и обратно требует обязательной смены стека, иначе любая малварь сможет легко проникнуть на защищённую территорию системы. Поэтому ещё на этапе создания потоков, кроме обычного стека в Ring(3), ОС сразу выделяет каждому из них индивидуальный свой стек и в ядерной памяти, а указатель на него сохраняет в поле "StackBase" их структур KTHREAD.

Код:
0: kd> dt _kthread fffffa8010b96710
nt!_KTHREAD
   +0x000 Header      : _DISPATCHER_HEADER
   +0x030 StackLimit  : 0xfffff880`171fd000
   +0x038 KernelStack : 0xfffff880`17206370 <--- RSP
   +0x278 StackBase   : 0xfffff880`17207000
.......
   ThreadKrnlStackSize = Base - Limit = 0xA000 = 40 КБ

В доках говорится, что под стек-ядра каждому из юзер-потоков выделяется по 24 КБ, но у себя я вижу 40. При этом если открыть вкладку "Быстродействие" в диспетчере задач, то всего активных потоков 724. Тогда выходит, что на карманные расходы всем потокам ОС выделяет сейчас из своего невыгружаемого пула ~28 МБ памяти, и это только на ядерные стеки, а ведь ещё имеются сами структуры EPROCESS, и у каждой по несколько ETHREAD размером 1200 байт.

В свою очередь, у лог.ядер ЦП имеется личное пространство PCR (Processor Control Region), на которое смотрит их регистр IA32_GS_BASE - этот регион описывает структура KPCR. Когда система планирует отправить юзер-поток в Kernel, она сначала прописывает указатель на него в поле "CurrentThread" структуры KPCR так, что ядро ЦП всегда знает, по какому именно адресу нужно искать стек вновь прибывшего потока в своём невыгружаемом пуле.

У соседнего cpu(2) уже свой регион KPCR, поэтому никаких конфликтов не возникает и планировщик ОС волен тасовать потоки по любым ядрам процессора, в зависимости от их занятности на текущий момент - если первый квант поток отработал на cpu(3), то на втором кванте он может попасть на cpu(1), и т.д. Схематично эта связь представлена ниже:

Схема связи региона KPCR логического ядра (GS_BASE, поле CurrentThread) со структурой KTHREAD и стеком ядра потока

Кстати размер невыгружаемого в своп NonPagedPool ограничен, и этот фактор влияет на макс.возможное число потоков в системе. В пакете "SysinternalsSuite" есть тулза TestLimit64.exe, и будучи запущенная с ключом(-t) она в цикле порождает треды до тех пор, пока не нарвётся на исключение. Так вот на своей Win7 с 16 ГБ памяти ОЗУ я получил лимит потоков 360.000 штук, при этом невыгружаемый пул ядра динамически увеличился с 75 ровно до 600 МБ (походу это предел). Для справки, на системах х32 этот лимит застрял на отметке всего 2.000.

Пользовательский поток может переходить в ядро по нескольким причинам, которые делятся на синхронные по времени (кратно 20мс = квант), и непредсказуемые асинхронные, например когда происходит какое-нибудь исключение в коде (типа ошибка доступа к памяти), внезапно возникнет прерывание от физ.девайсов клавиатура-мышь-etc, а так-же при вызовах API-функций ядра через шлюз Ntdll.dll. Такая причинно-следственная связь отражается и на самом механизме перехода в ядро.

1.2. Сегмент состояния задачи TSS​

На системах х32, за смену уровней привилегий Ring(3-0) полностью отвечал ЦП на аппаратном уровне. Процедура начиналась с того, что в момент исключения все регистры текущего потока система скопом сохраняла в сегмент TSS, а процессор считывал их от туда. Ключевую роль играли поля Esp+Ss0 в структуре TSS, в которых планировщик передавал процессору указатель на стек-ядра переходного потока из его KTHREAD.

Код:
kd> dt _ktss -v
struct _KTSS, 35 elements, 0x20ac bytes
   +0x000 Backlink         : Uint4B
   +0x004 Esp+Ss0          : Uint8B <---- стек потока в Ring(0)
   +0x00c Esp+Ss1          : Uint8B <---- стек Ring(1) - резерв
   +0x014 Esp+Ss2          : Uint8B <---- стек Ring(2) - резерв
   +0x01c CR3              : Uint4B
   +0x020 Eip              : Uint4B
   +0x024 EFlags           : Uint4B
   +0x028 Eax              : Uint4B
   +0x02c Ecx              : Uint4B
   +0x030 Edx              : Uint4B
   +0x034 Ebx              : Uint4B
   +0x038 Esp              : Uint4B <---- стек Ring(3)
   +0x03c Ebp              : Uint4B
   +0x040 Esi              : Uint4B
   +0x044 Edi              : Uint4B
   +0x048 Es               : Uint2B
   +0x04c Cs               : Uint2B
   +0x050 Ss               : Uint2B <-------- ^^^^^
   +0x054 Ds               : Uint2B
   +0x058 Fs               : Uint2B
   +0x05c Gs               : Uint2B
   +0x060 LDT              : Uint2B
   +0x064 Flags            : Uint2B
   +0x066 IoMapBase        : Uint2B
   +0x068 IoMaps           : [01] struct _KiIoAccessMap, 2 elements, 0x2024 bytes
   +0x208c IntDirectionMap : [32] UChar
kd>

Но сейчас на х64 инженеры отказались от такой схемы, и сегмент TSS играет чисто символическую роль. В нём остались всего 2 актуальных поля - это как и прежде Rsp0, а так-же массив IST из восьми 8-байтных значений. Вообще процессоры х86 поддерживают 4 уровня защиты Ring(0.1.2.3), хотя по факту используют лишь кольца 0 и 3. Поэтому стеки ядра для уровней Rsp1 + Rsp2 в сегменте TSS64 никогда не используются.

В TSS64 появилась новая "Interrupt Stack Table" - IST. Это 7 указателей на резервные стеки для обработки критических аппаратных сбоев, например рекурсивная ошибка DoubleFault(#DF) или MachineCheck(#MC), чтобы процессор мог гарантированно обработать исключение, даже если основной стек ядра был полностью разрушен в труху.

Сегмент TSS имеет свой дескриптор в глобальной таблице GDT, где указывается его база и лимит - система сохраняет их в регистре TR процессора. Обратите внимание на размер/лимит нового сегмента 0x68=104 байта, хотя раньше был 0x20ac=8.364 байта. Это потому, что огромная карта доступа к портам "IoMap" в хвосте, вместе с картой перенаправления прерываний была полностью удалена, т.к. прежде использовалась в вирт.режиме V86 процессора, который ныне не поддерживается вообще.

Вывод WinDbg: дескриптор сегмента TSS по регистру TR, структуры KPCR и KTSS64 с полями Rsp0 и таблицей IST, выделенными оранжевым

Когда поток выполняется в Ring(3) и происходит прерывание, исключение или вызов api, ЦП должен переключиться на стек ядра. В этот момент он автоматически считывает поле Rsp0 из TSS и загружает его значение в регистр RSP. Это обеспечивает безопасный вход в ядро, не затрагивая пользовательский стек. Таким образом, Rsp0 используется только для первоначального переключения стека при входе в Kernel из пользовательского режима.

Процессор никогда не записывает никаких значений в TSS, а лишь читает то, что записала система из поля "KernelStack" структуры KTHREAD потока, в момент возникновения исключений/прерываний в нём. У каждого лог.ядра ЦП свой сегмент TSS и свои регистры TR, т.е. они не делят общий.


2. Асинхронные переходы

Когда в исполняемом потоке происходит исключение, срабатывает системная ловушка "Trap" и управление передаётся обработчику форс-мажорного обстоятельства. В этот момент ЦП должен сохранить контекст регистров прерванного потока (чтобы восстановить его работу позже). Но куда сохранять-то, ведь TSS для этих целей уже не используется?

Поэтому ОС бежит здесь вперёд паровоза, заранее подготавливая всё к такой ситуации. Ещё на этапе создания потока (сразу после выделения ему стека-ядра), в поле "TrapFrame" структуры KTHREAD система сохраняет указатель на кадр-ловушки в этом стеке, куда и сбрасываются все регистры потока про запас. На рис.ниже отображён этот фрейм, а значение регистра RSP указано уже после его заполнения, т.к. изначально находится внизу и двигается вверх:

Схема «Позиция кадра ловушки в стеке ядра потока»: поле TrapFrame структуры KTHREAD и буфер контекста потока внутри стека ядра

Поскольку вариантов переходов в ядро здесь всего три (исключение, прерывание, системный вызов), ОС обрабатывает их немного по разному, но во всех случаях первым вступает в дело сам ЦП, который на аппаратном уровне сбрасывает во-фрейм ловушки 5 основных регистров - это пары cs:rip, ss:rsp, и регистр флагов rflags. Процессор начинает пушить их в стек первыми, на каждом шаге двигая rsp всё выше-и-выше к лимиту, поэтому в структуре KTRAP_FRAME эти 5 регистров находятся в самом конце - вот её формат:

Код:
0: kd> dt 0xfffff880`1755eae0 _KTRAP_FRAME -v
struct _KTRAP_FRAME, 63 elements, 0x190 bytes

   +0x000 P1Home           : 0xfffffa80`0d351b50 ---+
   +0x008 P2Home           : 0xfffff880`1755eb60    |    резерв под первые 4 аргумента
   +0x010 P3Home           : 0                      +--> вызываемой функции
   +0x018 P4Home           : 0xfffffa80`10b0c070    |    rcx, rdx, r8, r9
   +0x020 P5               : 0  --------------------+    (пятый push, опционально)
   +0x028 PreviousMode     : 1   <----- предыдущий режим (от куда вызов: 0=Kernel, 1=User)
   +0x029 PreviousIrql     : 0
   +0x02a NmiMsrIbrs       : 0x8
   +0x02b ExceptionActive  : 0x2
   +0x02c MxCsr            : 0x1f80
   +0x030 Rax              : 0x1f
   +0x038 Rcx              : 0x14
   +0x040 Rdx              : 0x6fd70
   +0x048 R8               : 0x01
   +0x050 R9               : 0x6ff00
   +0x058 R10              : 0
   +0x060 R11              : 0x6fe68
   +0x068 GsSwap           : 0x7ff`fffde000
   +0x070 Xmm0             : _M128A
   +0x080 Xmm1             : _M128A
   +0x090 Xmm2             : _M128A
   +0x0a0 Xmm3             : _M128A
   +0x0b0 Xmm4             : _M128A
   +0x0c0 Xmm5             : _M128A
   +0x0d0 ContextRecord    : 0x7746c000
   +0x0d8 Dr0              : 0
   +0x0e0 Dr1              : 0
   +0x0e8 Dr2              : 0
   +0x0f0 Dr3              : 0
   +0x0f8 Dr6              : 0
   +0x100 Dr7              : 0
   +0x108 DebugControl     : 0
   +0x110 LastBranchToRip      : 0
   +0x118 LastBranchFromRip    : 0
   +0x120 LastExceptionToRip   : 0
   +0x128 LastExceptionFromRip : 0
   +0x130 SegDs            : 0
   +0x132 SegEs            : 0
   +0x134 SegFs            : 0
   +0x136 SegGs            : 0
   +0x138 TrapFrame        : 0
   +0x140 Rbx              : 0x6fd70
   +0x148 Rdi              : 0
   +0x150 Rsi              : 0
   +0x158 Rbp              : 1
   +0x160 ExceptionFrame   : 0x77a02a1c
   +0x168 Rip              : 0x776e99ca  ---+
   +0x170 SegCs            : 0x33           |
   +0x178 EFlags           : 0x246          +-> CPU заполняет аппаратно
   +0x180 Rsp              : 0x6fd18        |
   +0x188 SegSs            : 0x2b  ---------+
   +0x18c CodePatchCycle   : 0
0: kd>

Теперь рассмотрим, на каких этапах и какими функциями заполняется остальная часть KTRAP_FRAME.

2.1. Системные вызовы "syscall"

Когда поток вызывает api типа CreateFile(), процессор переходит по адресу, который жёстко прошит в MSR-регистре IA32_LSTAR=0xC0000082. В нём лежит указатель на функцию ядра nt!KiSystemCall64(), которая и получит первой управление от пользовательской syscall. Она сразу резервирует место в стеке под KTRAP_FRAME и вручную заполняет его - вот фрагмент.

Здесь на входе встречает нас инструкция swapgs. Регистр GS под юзером указывает на ТЕВ текущего потока (Thread Environment Block), но в ядре он должен смотреть на структуру KPCR процессора. Так вот swapgs на входе как-раз меняет их значения местами, а на выходе опять восстанавливает. Значение ядра берётся из MSR-регистра IA32_GS_BASE=0xC0000101:

Код:
0: kd> rdmsr 0xc0000082           <------ IA32_LSTAR
msr[c0000082] = fffff800`022abb00

0: kd> ln fffff800`022abb00       <------ проверим адрес
(fffff800`022abb00)   nt!KiSystemCall64

0: kd> uf /i KiSystemCall64
       393 instructions scanned   <---- общее число инструкций

nt!KiSystemCall64:
fffff800`022abb00 0f01f8          swapgs
fffff800`022abb03 65488924251000  mov     qword ptr gs:[10h],rsp
fffff800`022abb0c 65488b2425a801  mov     rsp,qword ptr gs:[1A8h]
fffff800`022abb15 6a2b            push    2Bh
fffff800`022abb17 65ff3425100000  push    qword ptr gs:[10h]
fffff800`022abb1f 4153            push    r11
fffff800`022abb21 6a33            push    33h
fffff800`022abb23 51              push    rcx
fffff800`022abb24 498bca          mov     rcx,r10
fffff800`022abb27 4883ec08        sub     rsp,8
fffff800`022abb2b 55              push    rbp

fffff800`022abb2c 4881ec58010000  sub     rsp,158h        <------ выделение Trap-фрейма!
fffff800`022abb33 488dac24800000  lea     rbp,[rsp+80h]
fffff800`022abb3b 48899dc0000000  mov     qword ptr [rbp+0C0h],rbx
fffff800`022abb42 4889bdc8000000  mov     qword ptr [rbp+0C8h],rdi
fffff800`022abb49 4889b5d0000000  mov     qword ptr [rbp+0D0h],rsi
fffff800`022abb50 488945b0        mov     qword ptr [rbp-50h],rax
fffff800`022abb54 48894db8        mov     qword ptr [rbp-48h],rcx
fffff800`022abb58 488955c0        mov     qword ptr [rbp-40h],rdx
.....
fffff800`022abd44 e80726ffff      call    nt!KiSaveDebugRegisterState (fffff800`0229e350)
.....

2.2. Прерывания и исключения

В целом, программные исключения "Fault" и аппаратные прерывания "Interrupt" обрабатываются одинаково - через таблицу IDT (Interrupt Dispatch Table). Это массив из 256 структур KIDTENTRY64 по 16-байт каждая (итого 4КБ), а его база в памяти лежит в структуре KPCR процессора (система сохраняет в регистре IDTR). Первые 20=0x14 векторов в таблице всегда резервируются под исключения и ловушки, а остальные система раздаёт обработчикам прерываний от физ.устройств.

Вывод WinDbg: поле IdtBase структуры KPCR, регистр IDTR и дамп таблицы IDT с обработчиками исключений в Shadow-пространстве

Раньше можно было прямо в WinDBG узнать адрес обработчиков прерываний ISR от физ.устройств типа мышь, клава, диск, сеть и прочих. Но теперь появилась защита от уязвимостей Meltdown и Spectre, в результате чего доступ только через затенённое пространство ShadowSpace (см.жёлтый блок выше). В отладчике есть и окольные пути, например чз объекты драйверов, но сейчас речь не о том, т.к. сути это не меняет.

Когда поток нарывается на исключение/прерывание, система передаёт управление на вектор из таблицы IDT, а функции его обработки формируют уже KTRAP_FRAME для фиксации состояния прерванного потока. Вот пролог самой первой процедуры из списка IDT (в остальных аналогично) - как видим, в нём много общего с предыдущей KiSystemCall64():

Код:
0: kd> uf /i nt!KiDivideErrorFaultShadow
       670 instructions scanned

nt!KiDivideErrorFault:
fffff800`022a6f00 4883ec08        sub     rsp,8
fffff800`022a6f04 55              push    rbp

fffff800`022a6f05 4881ec58010000  sub     rsp,158h
fffff800`022a6f0c 488dac24800000  lea     rbp,[rsp+80h]
fffff800`022a6f14 c645ab01        mov     byte ptr [rbp-55h],1

fffff800`022a6f18 488945b0        mov     qword ptr [rbp-50h],rax
fffff800`022a6f1c 48894db8        mov     qword ptr [rbp-48h],rcx
fffff800`022a6f20 488955c0        mov     qword ptr [rbp-40h],rdx
fffff800`022a6f24 4c8945c8        mov     qword ptr [rbp-38h],r8
fffff800`022a6f28 4c894dd0        mov     qword ptr [rbp-30h],r9
fffff800`022a6f2c 4c8955d8        mov     qword ptr [rbp-28h],r10
fffff800`022a6f30 4c895de0        mov     qword ptr [rbp-20h],r11

fffff800`022a6f34 f685f000000001  test    byte ptr [rbp+0F0h],1
fffff800`022a6f3b 752d            jne     nt!KiDivideErrorFault+0x6a (fffff800`022a6f6a)
......

2.3. Волатильные регистры

В отличии от х32, сейчас Win не сохраняют буквально все регистры процессора в TRAP_FRAME. Неволатильные RBX,RDI,RSI,RBP,R12-R15 намеренно опускаются для оптимизации скорости. Если ядру в процессе обработки прерывания всё же потребуется их изменить, оно сохраняет их отдельно. Но здесь возникает вопрос, что означают термины Volatile и NonVolatile?

Это деление определяет, кто несёт ответственность за сохранение регистра при вызове функций, и какие из них попадают в KTRAP_FRAME при входе в ядро.

1. Волатильные: RAX,RCX,RDX,R8,R9,R10,R11,XMM0–15
Это регистры, которые вызываемая WinAPI имеет право перезаписать без предварительного сохранения. Если нам как вызывающим значения этих регистров ещё нужны, придётся самостоятельно сохранить их до вызова функции. В _fastcall первые 4 аргумента передаются в RCX,RDX,R8,R9, а в регистре RAX функция возвращает результат. Пара R10-11 черновики "scratch", которые api может использовать для временных вычислений.

2. Не волатильные: RBX,RBP,RSI,RDI,RSP,R12–R15
Их вызываемая функция обязана сохранить и восстановить перед возвратом управления. Они часто используются для хранения всякого рода переменных (указателей на структуры, счетчиков циклов и т.д.). Если-бы любая функция могла их безнаказанно рипать, нам пришлось-бы сохранять их вручную перед каждым вызовом, что крайне неудобно.

3. Синхронные переходы по окончании кванта потока

Ну и последний вариант - это переход юзер-потока в ядро при переключение планировщиком. Здесь уже контекст сохраняется не в прежнюю структуру KTRAP_FRAME, а в недокументированную KSWITCH_FRAME, которой нет даже в публичных символах *.pdb отладчика WinDBG. После входа потока в ядро, структура заполняется вполне прозрачной ассемблерной процедурой nt!KiSwapContext(), а потому дизассемблировав последнюю можно собрать KSWITCH_FRAME самим:
Код:
dt nt!_KSWITCH_FRAME
   +0x000 P5Home           : Uint8B    ; Зарезервировано под домашние регистры (Home registers)
   +0x008 P6Home           : Uint8B    ; согласно соглашению вызовов x64 fastcall
   +0x010 ApcBypass        : UChar     ; Флаг обхода APC прерываний
   +0x011 Rgb              : UChar
   +0x012 Status           : UChar
   +0x018 Rbp              : Uint8B    ; Сохраненный указатель кадра стека (Frame Pointer)
   +0x020 NonVolatileRegs  : _KNONVOLATILE_CONTEXT_POINTERS ; Указатели на неволатильные регистры
   +0x028 Rbx              : Uint8B    ; \
   +0x030 Rdi              : Uint8B    ;  | Сохраненные неволатильные регистры общего назначения
   +0x038 Rsi              : Uint8B    ;  | (сохраняются, так как они должны пережить вызов функции)
   +0x040 R12              : Uint8B    ;  |
   +0x048 R13              : Uint8B    ;  |
   +0x050 R14              : Uint8B    ;  |
   +0x058 R15              : Uint8B    ; /
   +0x060 Xmm6             : _M128A    ; \  Сохраненные неволатильные регистры SSE
   +0x070 Xmm7             : _M128A    ; /  (используются для мультимедиа и точных вычислений)
   +0x080 ReturnAddress    : Uint8B    ; Адрес возврата, куда передать управление после переключения
Код:
0: kd> uf /i KiSwapContext
   44 instructions scanned

nt!KiSwapContext:
fffff800`022a02e0 4881ec38010000  sub     rsp,138h       <--- фрейм в ядерном стеке потока
fffff800`022a02e7 488d8424000100  lea     rax,[rsp+100h]

fffff800`022a02ef 0f29742430      movaps  xmmword ptr [rsp+30h],xmm6
fffff800`022a02f4 0f297c2440      movaps  xmmword ptr [rsp+40h],xmm7
fffff800`022a02f9 440f29442450    movaps  xmmword ptr [rsp+50h],xmm8
fffff800`022a02ff 440f294c2460    movaps  xmmword ptr [rsp+60h],xmm9
fffff800`022a0305 440f29542470    movaps  xmmword ptr [rsp+70h],xmm10
fffff800`022a030b 440f295880      movaps  xmmword ptr [rax-80h],xmm11
fffff800`022a0310 440f296090      movaps  xmmword ptr [rax-70h],xmm12
fffff800`022a0315 440f2968a0      movaps  xmmword ptr [rax-60h],xmm13
fffff800`022a031a 440f2970b0      movaps  xmmword ptr [rax-50h],xmm14
fffff800`022a031f 440f2978c0      movaps  xmmword ptr [rax-40h],xmm15

fffff800`022a0324 488918          mov     qword ptr [rax+00h],rbx
fffff800`022a0327 48897808        mov     qword ptr [rax+08h],rdi
fffff800`022a032b 48897010        mov     qword ptr [rax+10h],rsi
fffff800`022a032f 4c896018        mov     qword ptr [rax+18h],r12
fffff800`022a0333 4c896820        mov     qword ptr [rax+20h],r13
fffff800`022a0337 4c897028        mov     qword ptr [rax+28h],r14
fffff800`022a033b 4c897830        mov     qword ptr [rax+30h],r15

fffff800`022a033f 65488b1c252000  mov     rbx,qword ptr gs:[20h]
fffff800`022a0348 488bf9          mov     rdi,rcx
fffff800`022a034b 488bf2          mov     rsi,rdx
fffff800`022a034e 0fb68f66010000  movzx   ecx,byte ptr [rdi+166h]
fffff800`022a0355 e8a6020000      call    nt!SwapContext (fffff800`022a0600)

fffff800`022a035a 488d8c24000100  lea     rcx,[rsp+100h]
fffff800`022a0362 0f28742430      movaps  xmm6, xmmword ptr [rsp+30h]
fffff800`022a0367 0f287c2440      movaps  xmm7, xmmword ptr [rsp+40h]
fffff800`022a036c 440f28442450    movaps  xmm8, xmmword ptr [rsp+50h]
fffff800`022a0372 440f284c2460    movaps  xmm9, xmmword ptr [rsp+60h]
fffff800`022a0378 440f28542470    movaps  xmm10,xmmword ptr [rsp+70h]
fffff800`022a037e 440f285980      movaps  xmm11,xmmword ptr [rcx-80h]
fffff800`022a0383 440f286190      movaps  xmm12,xmmword ptr [rcx-70h]
fffff800`022a0388 440f2869a0      movaps  xmm13,xmmword ptr [rcx-60h]
fffff800`022a038d 440f2871b0      movaps  xmm14,xmmword ptr [rcx-50h]
fffff800`022a0392 440f2879c0      movaps  xmm15,xmmword ptr [rcx-40h]

fffff800`022a0397 488b19          mov     rbx,qword ptr [rcx]
fffff800`022a039a 488b7908        mov     rdi,qword ptr [rcx+8]
fffff800`022a039e 488b7110        mov     rsi,qword ptr [rcx+10h]
fffff800`022a03a2 4c8b6118        mov     r12,qword ptr [rcx+18h]
fffff800`022a03a6 4c8b6920        mov     r13,qword ptr [rcx+20h]
fffff800`022a03aa 4c8b7128        mov     r14,qword ptr [rcx+28h]
fffff800`022a03ae 4c8b7930        mov     r15,qword ptr [rcx+30h]

fffff800`022a03b2 4881c438010000  add     rsp,138h
fffff800`022a03b9 c3              ret
0: kd>

В отличие от KTRAP_FRAME, которую частично заполняет cpu, KSWITCH_FRAME создаётся исключительно программно. Обратите внимание, что здесь сохраняются только неволатильные регистры. В зависимости от причины переключения контекста, волатильные сохраняются в разных местах и разными компонентами. Здесь есть всего 2 сценария:

1. Добровольное переключение​
Если поток сам решает уступить время, например вызывает Sleep() или WaitForSingleObject(), код потока просто вызывает функцию ядра чз трамплин Ntdll.dll. Зная правила соглашения о вызовах, компилятор системных DLL автоматически генерирует код для сохранения всех волатильных регистров в стек пользовательского потока задолго до того, как передать управление дальше. То-есть когда функция ядра KiSwapContext() получает управление, волатильные регистры уже сохранены.​
​
2. Принудительное переключение​
Если поток выполнялся, и вдруг закончился выделенный ему планировщиком квант 0.02 сек, срабатывает аппаратное прерывание от таймера, и в поисках его обработчика ЦП лезет в таблицу диспетчеризации IDT. Этот сценарий уже рассматривался выше на примере функции KiDivideErrorFaultShadow(). Только после этого вызывается api планировщика KiDispatchInterrupt(), которая тянет за собой KiSwapContext(). Когда управление вернется в этот поток, обработчик прерывания восстановит абсолютно все регистры из KTRAP_FRAME, и поток даже не заметит, что его прерывали.​

В среднем, на современных ЦП вся процедура "Context Switch" занимает до 5 мкс = 0.000005 сек, что эквивалентно нескольким тысячам тактов процессора. Точное время зависит от типа переключения (внутри одного процесса или между разными) и архитектуры ОС. Входит-ли время смены контекста в выделенный потоку квант? Ответ - нет! ..поскольку квант это чистая продолжительность работы потока. Алгоритм выглядит примерно так:

1. Поток(A) заканчивает свой квант, а таймер прерываний фиксирует это.​
2. Системный планировщик приостанавливает поток(A) и запускает процедуру Switch.​
3. В этот момент счётчик кванта потока(A) уже остановлен, а для потока(B) ещё не запущен.​
4. Как только контекст потока(B) будет полностью загружен в регистры цп, планировщик отдаст ему руль, и начнётся отсчёт нового кванта для потока(B).​

Таким образом, смена контекста - это чистый оверхед системы с точки зрения выполнения полезных задач, и в то-же время необходимый компромисс для поддержки многозадачности. Из-за аппаратных факторов (типа кэш и буферы TLB), переключение между потоками внутри одного процесса всегда обходится дешевле, чем между разными процессами.

4. Выводы

Зачем это знать исследователю?
Ну во-первых отладка ядра. При анализе дампов памяти BSOD, вызовы KiSwapContext() часто находятся на макушке стека потоков, находящихся в ожидании Waiting. Понимание фреймов ловушек Trap и Switch помогает восстановить состояние регистров спящих потоков. А во-вторых разработка драйверов и руткитов. Мониторинг или подмена данных в структурах контекста позволяет низкоуровневому коду осуществлять перехват управления потоками ядра, или скрывать своё присутствие. В общем были-бы знания, а применение им обязательно найдётся. Всем удачи, пока!

Ссылки по теме:
https://medium.com/windows-os-internals/windows-internals-thread-management-part-1-9f4227a9e17c
Exploiting WRMSR in vulnerable drivers
Полезно

Комментарии

0