Планировщик Windows? Это очень просто

4aedd965132e9a28168107b839e54844.png

Введение

Реализация одной из ответственных задач моделирования в очередной раз привела к сложностям с операционной системой. Попытка решить задачу «под Windows», т.е. просто запустить программу, не применяя специальных средств, почти удалась, однако время от времени возникали недопустимые задержки. Эти, возникавшие случайно и редко (раз в несколько минут) задержки никак не удавалось убрать. Например, последовательное снятие всех «лишних» процессов Windows улучшало ситуацию, но, в конце концов, приводило к отказу самой ОС. Положение затрудняло и то, что проведение сравнительно долгого сеанса моделирования не позволяло на все 20–30 минут сеанса установить основному работающему потоку приоритет «реального времени», так как при этом нормальная работа компьютера также нарушалась.

Обидно было менять ОС и дорабатывать свое ПО, когда результат уже был почти достигнут и требовалось всего лишь изначально не предусмотренное в Windows планирование, а именно: заданный поток в течение определенного периода не должен прерываться по истечению выделенного кванта времени, и на время его работы потоки с более низким приоритетом вообще не должны получать управление. Но при этом потоки с изначально более высоким приоритетом должны выполняться как всегда. Поскольку такие высоко приоритетные потоки обычно не занимают весь свой квант времени, время отклика для нужного потока в целом уменьшается и зависит от быстродействия компьютера.

Встал вопрос: можно ли настроить Windows на такой режим работы и как это сделать?

Планирование потоков

Как известно, переключение на другой поток в Windows происходит в трех случаях:

— истек выделенный квант времени работы и есть потоки с таким же приоритетом;

— поток добровольно уступает время работы (например, начинает ждать события);

— появился готовый к работе поток с более высоким приоритетом. Он немедленно (на самом деле в момент ближайшего прерывания) получает управление.

Кроме этого, в составе планировщика Windows имеется так называемый диспетчер баланса, который поднимает приоритет до значения 15 у давно ждущих выполнения потоков. Поскольку значения приоритетов класса «реального времени» начинаются с 16, то потоки «реального времени» диспетчер баланса прервать не может, а вот остальные потоки рано или поздно уступят квант потокам с более низким приоритетом. Скорее всего, это и является источником редких непредсказуемых задержек — иногда целый квант выполняется какой-то низкоприоритетный поток.

Проведем несложный эксперимент. Запустим одновременно две копии простейшей программы, которые просто выводят на экран постоянно увеличивающееся на единицу число. Чтобы не влияла многоядерность, назначим этим задачам одно и то же ядро процессора или вообще запустим компьютер в одноядерном режиме. Две программы, как и положено, работают «одновременно» (т.е. попеременно) и примерно с одинаковой скоростью. Теперь, используя диспетчер процессов, установим одной задаче приоритет «реального времени». Эта задача продолжает работать, а вторая задача останавливается. Все ожидаемо. Однако иногда и во второй задаче выдаваемое число увеличивается!

Объясните (как говорят в Одессе, «с указочкой»), почему задача с низким приоритетом вообще получает управление, когда по условиям эксперимента есть непрерывно работающая программа с заведомо очень большим приоритетом? Ответ на этот вопрос приведен в конце статьи и, честно говоря, он имеет мало отношения к теме.

Подобные эксперименты сеют сомнения:, а все ли рассказали Руссинович и Соломон в своей книге [1] или, может быть, автор статьи просто что-то не так воспринял? Возникает еще один стимул изучить, а как работает Windows «на самом деле»? Документация документацией, но, как говорится, «это все слова — покажите код».

С другой стороны, тот же Руссинович сообщает, что Windows создавали 5000 программистов. Вряд ли одному человеку под силу разобраться во всех тонкостях такой большой и сложной системы. К счастью, разобраться требуется только в одной из многочисленных сторон ОС. А это вполне возможно и одному человеку и за сравнительно небольшое время.

Постановка задачи

Конкретизируем задачу. Требуется проанализировать код ядра Windows в части переключения с одного потока на другой. Нужно убедиться, что никаких других случаев переключения, кроме трех перечисленных, в ядре нет. Используя результаты анализа и знание конкретного кода, переключающего потоки, требуется организовать работу планировщика так, чтобы заданный поток в течение 20–30 минут не прерывался потоками с более низким приоритетом (из-за работы диспетчера баланса), но при этом не обладал приоритетом «реального времени» и поэтому не мешал различным высокоприоритетным служебным потокам и сервисам.

Получение кода для анализа

К сожалению, получить текст кода ядра для анализа не так-то просто. Т.е. ядро ntoskrnl.exe невозможно просто загрузить в память с помощью какого-нибудь ntsd или windbg. Конечно, есть и специальные средства, и отладочные версии, и виртуальные машины, но в данном случае хотелось бы получить просто ассемблерный код как текст, который можно даже хотя бы частично распечатать и спокойно анализировать «за столом». Для этой цели проще создать небольшую программу (я назвал ее sd.exe) самому. Поскольку в используемых мною средствах есть встроенный отладчик, легко написать небольшую программу, просто загружающую файл ntoskrnl в память и затем сдвигающую на нужную величину каждую секцию, перечисленную в таблице заголовка exe-файла. Выполнив эти действия, программа останавливается в контрольной точке (т.е. на команде INT 3). В результате в памяти получается правильно «развернутый» образ ядра из ntoskrnl, который теперь можно вывести на экран или в файл командами «U» и «D» встроенного интерактивного отладчика. Сложность такого дизассемблирования в том, что команды и данные идут вперемежку, и если весь файл вывести как команды, данные выведутся как набор бессмысленных команд, часто портящих начало участков настоящих команд. Приходится предварительно все просматривать на экране как данные и на глаз определять очередные границы команд и данных. Результаты просмотра оформляются в виде текста как последовательность команд «U» и «D» для будущего получения «распечатки»:

…
U EAX+17EC2B EAX+17FA09
D EAX+17FA00 EAX+17FA3F
U EAX+17FA4A EAX+180DF7
D EAX+180DF0 EAX+180EFF
…

Здесь все адреса указаны относительно регистра EAX, в который в программе sd.exe записывается адрес загрузки файла ntoskrnl в памяти. Иногда удобнее вместо команды «D» использовать также имеющуюся в данном отладчике команду «DD», выводящую данные двойными словами, т.е. адресами. Например, вот адреса рассылки по прерываниям INT 00, INT 01, INT 02, …:

DD EAX+1EE0FC
5EE0FC  00407522 00088E00-004076A1 00088E00
5EE10C  00407794 00088E00-00407AB5 0008EE00
5EE11C  00407C38 0008EE00-00407D9D 00088E00
5EE12C  00407F1E 00088E00-00408597 00088E00
5EE13C  004088A5 00088E00-0040899C 00088E00
5EE14C  00408ABA 00088E00-00408BF7 00088E00
5EE15C  00408E54 00088E00-00409150 00088E00
5EE16C  00409899 00088E00-00409B16 00088E00
5EE17C  00409C34 00088E00-00409D6E 00088E00
5EE18C  00409B16 00088E00-00409B16 00088E00
…

Кстати, найденный адрес 409150 исключения INT 0D «нарушение общей защиты» еще пригодится далее.

Теперь если отладчик выполнит последовательность команд «U» и «D», получается текст вот такого, уже более правильного вида:

…
57F9F7 90                            NOP
57F9F8 8B65E8                        MOV    ESP,[EBP]+FFFFFFE8
57F9FB 8B7DCC                        MOV    EDI,[EBP]+FFFFFFCC
57F9FE 834DFCFF                      OR     D PTR [EBP]+FFFFFFFC,FFFFFFFF
57FA02 8BC7                          MOV    EAX,EDI
57FA04 E825C4E8FF                    CALL   40BE2E
57FA09 C20800                        RET    0008

57FA00  FC FF 8B C7 E8 25 C4 E8-FF C2 08 00 0A 20 53 75                  .. Su
57FA10  62 6B 65 79 73 20 6F 70-65 6E 20 69 6E 73 69 64       bkeys open insid
57FA20  65 20 74 68 65 20 68 69-76 65 20 28 25 70 29 20       e the hive (%p) 
57FA30  28 25 2E 2A 53 29 20 3A-0A 0A 00 CC CC CC CC CC       (%.*S) :...

57FA4A 6A34                          PUSH   00000034
57FA4C 6890844500                    PUSH   00458490
57FA51 E89DC3E8FF                    CALL   40BDF3
57FA56 33DB                          XOR    EBX,EBX
57FA58 66895DCC                      MOV    [EBP]+FFFFFFCC,BX
57FA5C 33C0                          XOR    EAX,EAX
…

Таким образом, команды отделяются от данных. Всю последовательность команд для отладчика я записал в файл ud.txt и одной командой:

sd.exe ntoskrnl.exe ntos.txt

получил первый вариант кода ядра в текстовом файле ntos.txt. Этот вариант еще достаточно «слепой». Однако теперь уже несложно создать еще одну небольшую программу, которая обработает полученный результат, добавляя в текст названия импортируемых процедур, используя таблицу импорта исходного exe-файла, а также расставит метки по тексту, используя адреса таблицы экспортируемых функций. Кроме этого, программа вставляет всякие удобные мелочи вроде пустой строки после каждой команды RET, чтобы легче читать анализируемые участки и т.д.

На основе «исходного» ассемблерного текста получается обработанный текст, уже больше подходящий для анализа. В «исходный» текст можно вручную вносить правки, например, комментарии, пустые строки и т.п., после чего очередной раз обрабатывать программой и получать с каждой итерацией все более и более понятный текст кода ядра, по мере накопления в нем комментариев. Кроме этого, в обрабатывающую программу можно добавлять проверки на определенный контекст и автоматически расставлять некоторые комментарии. В результате анализируемый текст становится все менее и менее «слепым», например:

…
KeReleaseMutant:
402B4C 8BFF                 MOV    EDI,EDI
402B4E 55                   PUSH   EBP
402B4F 8BEC                 MOV    EBP,ESP
402B51 53                   PUSH   EBX
402B52 56                   PUSH   ESI
402B53 57                   PUSH   EDI
402B54 33C9                 XOR    ECX,ECX
402B56 FF1588104000         CALL D PTR [00401088]; KeAcquireQueuedSpinLockRaiseToSynch
402B5C 8B7508               MOV    ESI,[EBP]+00000008
402B5F 8AD8                 MOV    BL,AL
402B61 8B4604               MOV    EAX,[ESI]+00000004
402B64 894508               MOV    [EBP]+00000008,EAX
402B67 64A124010000         FS:    MOV    EAX,[00000124] ;ТЕКУЩИЙ ПОТОК
402B6D 807D1000             CMP    B PTR [EBP]+00000010,00
402B71 8BF8                 MOV    EDI,EAX
402B73 0F85AFB30100         JNE   41DF28
…

Самое главное, что теперь в этом большом (26 Мбайт) текстовом файле легко искать нужный контекст, например, переход на заданный адрес. А значит, можно приступать собственно к анализу кода ядра.

Анализ кода

По условиям задачи анализировать потребовалось ядро Windows-XP SP3 сборки 0421 от 4 июля 2013 года. При этом в очень большом тексте (примерно 570 000 ассемблерных строк) нужно было по возможности быстро найти элементы планировщика, отвечающие за переключение потоков.

С чего начать? Очевидно с поиска «сердца» ОС — т.е. с процедуры, вызываемой при каждом аппаратном срабатывании сигнала встроенных часов. Это просто, ведь есть экспортируемое имя KeUpdateSystemTime и его адрес 40B558 (далее комментарии в тексте частью расставлены программой, частью дописаны вручную):

;=========== ВЫПОЛНЕНИЕ ОДНОГО ТАКТА ОПЕРАЦИОННОЙ СИСТЕМЫ =============
;----- НА ВХОДЕ В ЕАХ ЧИСЛО ТИКОВ СИСТЕМНЫХ ЧАСОВ С ПРОШЛОГО ВЫЗОВА ---

KeUpdateSystemTime:

40B558 B90000DFFF           MOV    ECX,FFDF0000

;----- ЗАПОМИНАЕМ ЧИСЛО ТЕКУЩИХ ТИКОВ ЧАСОВ ----

40B55D 8B7908               MOV    EDI,[ECX]+00000008
40B560 8B710C               MOV    ESI,[ECX]+0000000C
40B563 03F8                 ADD    EDI,EAX
40B565 83D600               ADC    ESI,00000000
40B568 897110               MOV    [ECX]+00000010,ESI
40B56B 897908               MOV    [ECX]+00000008,EDI
40B56E 89710C               MOV    [ECX]+0000000C,ESI

;----- УМЕНЬШАЕМ ВРЕМЯ ТЕКУЩЕГО КВАНТА ----

40B571 290514304800         SUB    [00483014],EAX
40B577 A100304800           MOV    EAX,[00483000] ;СИСТЕМНЫЙ ТИК
40B57C 8BD8                 MOV    EBX,EAX
40B57E 0F8F84000000         JJG    40B608
…

Далее идет обновление числа «тиков» и проверки таймеров, а затем самый важный для анализа фрагмент:

…
;----- ЕСЛИ КВАНТ ИСТЕК, ВЫПОЛНЯЕМ РАБОТЫ НА ГРАНИЦЕ КВАНТА ----
40B682 833D1430480000       CMP    D PTR [00483014],00000000
40B689 7F1F                 JG     40B6AA

;----- ОПЯТЬ УСТАНОВИЛИ КВАНТ ----
40B68B A10C304800           MOV    EAX,[0048300C]
40B690 010514304800         ADD    [00483014],EAX

40B696 FF3424               PUSH   D PTR [ESP]
40B699 E846000000           CALL   40B6E4 ;KeUpdateRunTime
40B69E FA                   CLI
40B69F FF1594104000         CALL   D PTR [00401094]; HalEndSystem Interrupt
40B6A5 E983BBFFFF           JMP    40722D ;Kei386EoiHelper

;----- КВАНТ НЕ ИСТЕК - СРАЗУ ВЫХОДИМ ----
40B6AA 64FF05C4050000       FS:    INC    D PTR [000005C4]
40B6B1 FA                   CLI
40B6B2 FF1594104000         CALL   D PTR [00401094];    HalEndSystem Interrupt
40B6B8 E970BBFFFF           JMP    40722D ;Kei386EoiHelper
…

Т.е. при каждом окончании времени кванта запускается подпрограмма с вполне соответствующим случаю названием KeUpdateRunTime.

Она расположена по тексту рядом:

KeUpdateRunTime:
40B6E4 64A11C000000         FS:    MOV    EAX,[0000001C]
40B6EA 53                   PUSH   EBX
40B6EB FF80C4050000         INC    D PTR [EAX]+000005C4

;---- ДОСТАЕМ ТЕКУЩЕ ВЫПОЛНЯЕМЫЙ ПОТОК И ПРОЦЕСС ----

40B6F1 8B9824010000         MOV    EBX,[EAX]+00000124
40B6F7 8B4B44               MOV    ECX,[EBX]+00000044
…

Откуда следует, что это достаются именно текущий поток и процесс?

Это легко выясняется, например, из процедуры KeGetCurrentThread:

KeGetCurrentThread:
404622 64A124010000         FS:    MOV    EAX,[00000124] ;ТЕКУЩИЙ ПОТОК
404628 C3                   RET

И из процедуры IoGetCurrentProcess:

IoGetCurrentProcess:
40ED86 64A124010000         FS:    MOV    EAX,[00000124] ;ТЕКУЩИЙ ПОТОК
40ED8C 8B4044               MOV    EAX,[EAX]+00000044
40ED8F C3                   RET

Самое интересное место расположено в конце KeUpdateRunTime:

…
40B81A 806B6F03             SUB    B PTR [EBX]+0000006F,03
40B81E 7F19                 JG     40B839
40B820 3B982C010000         CMP    EBX,[EAX]+0000012C
40B826 7411                 JZ     40B839
40B828 89A0AC090000         MOV    [EAX]+000009AC,ESP
40B82E B902000000           MOV    ECX,00000002
40B833 FF150C114000         CALL   D PTR [0040110C]; HalRequestSoftware Interrupt
40B839 5B                   POP    EBX
40B83A C20400               RET    0004

Здесь из некоторого поля внутренней структуры текущего потока со смещением 6F вычитается 3 и, если этот счетчик становится не положительным, в некоторую переменную с относительным адресом 9AC заносится зачем-то значение ESP. А где используется такая переменная? Оказывается, контекстный поиск смещения 9AC находит одно единственное место внутри KiDispatchInterrupt:

…
405891 FB                   STI
405892 83BBAC09000000       CMP    D PTR [EBX]+000009AC,00000000
405899 0F8581000000         JJNE   405920
40589F 83BB2801000000       CMP    D PTR [EBX]+00000128,00000000
4058A6 746F                 JZ     405917
4058A8 FA                   CLI
…
4058BF B91C000000           MOV    ECX,0000001C
4058C4 FF152C104000         CALL   D PTR [0040102C];KfRaiseIrql
4058CA FB                   STI
4058CB 8B8328010000         MOV    EAX,[EBX]+00000128

4058D1 83EC0C               SUB    ESP,0000000C
…
4058DF 8BF0                 MOV    ESI,EAX
4058E1 8BBB24010000         MOV    EDI,[EBX]+00000124
4058E7 C7832801000000000000 MOV    D PTR [EBX]+00000128,00000000
4058F1 89B324010000         MOV    [EBX]+00000124,ESI
4058F7 8BCF                 MOV    ECX,EDI
4058F9 C6475001             MOV    B PTR [EDI]+00000050,01
4058FD E865FDFFFF           CALL   405667
405902 B101                 MOV    CL,01
405904 E839000000           CALL   405942
…
405917 C3                   RET
…
405920 C783AC09000000000000 MOV    D PTR [EBX]+000009AC,00000000
40592A E890C10000           CALL   411ABF
40592F 0BC0                 OR     EAX,EAX
405931 759E                 JNZ    4058D1
405933 C3                   RET

Если значение переменной 9AC не равно нулю, оно сбрасывается, затем идет обращение к некоторой процедуре по адресу 411ABF. И если процедура возвращает ненулевой EAX, то управление попадает на адрес 4058D1. А здесь это значение (командой по адресу 4058F1) пишется как новый текущий поток. Вот нужное место и найдено!

Теперь понятна вся цепочка действий ядра: на каждый «тик» встроенных часов запускается KeUpdateSystemTime, где текущий квант уменьшается на число прошедших «тиков». Если квант истек, запускается KeUpdateRunTime, которая уменьшает внутренний счетчик в структуре текущего потока. Как только этот счетчик истекает, данное событие отмечается в переменной с относительным адресом 9AC. При ближайшем прерывании запускается KiDispatchInterrupt, которая проверяет переменную 9AC. Если переменная не нулевая (именно для этого в нее занесли ESP) — значит время данного потока исчерпано.

С помощью подпрограммы по адресу 411ABF ОС ищет новый поток для работы. Если конкурента текущему потоку не находится, он продолжает выполнение. Иначе текущий поток переводится в режим ожидания с помощью процедуры по адресу 405667, и запускается (т.е. становится текущим) другой поток.

Интересно, что внутри процедуры с адресом 411ABF проверяется, равно ли нулю поле 69 структуры текущего потока. Если нет — новый поток не ищется. Это поле описано в документации как DisableQuantum. Т.е. квант работы можно сделать бесконечным!

Увы, установить это поле из режима пользователя нельзя. Сама ОС может установить любое значение этого поля с помощью внутренней процедуры по адресу 43CA4B. Однако когда она использует эту подпрограмму, всегда данное поле устанавливается в ноль. Жаль, было бы удобно с помощью какого-нибудь недокументированного сервиса задать себе таким способом «бесконечный» квант работы.

Обратите внимание, что не только переменная 9AC может вызвать смену потока. Точно так же поток меняется, если значение поля с относительным адресом 128 не нулевое. По адресу 4058D1 начинается общая для двух этих случаев часть действий ОС по смене потока. Не вызывает сомнения, что проверка переменной со смещением 128 — это как раз случай появления потока с более высоким приоритетом.

А есть ли еще места смены текущего потока? Да, есть, и они по тексту рядом.

…
405830 648B1D1C000000       FS:    MOV    EBX,[0000001C]
405837 8BF1                 MOV    ESI,ECX
405839 8BBB24010000         MOV    EDI,[EBX]+00000124
40583F 89B324010000         MOV    [EBX]+00000124,ESI
405845 8A4F58               MOV    CL,[EDI]+00000058
405848 E8F5000000           CALL   405942
…

Это подпрограмма вызывается, например, внутри KeWaitForMultipleObjects. Очевидно, что это случай «добровольной» смены потока при ожиданиях, задержках, окончании задачи и т.п.

Наконец, еще одно место изменения поля со смещением 124 находится контекстным поиском чуть выше:

…
40574A 64833D2801000000     FS:    CMP    D PTR [00000128],00000000
405752 7544                 JNZ    405798
…
405767 FF2530104000         JMP    D PTR [00401030];KfLowerIrql

40576D 64833D9409000000     FS:    CMP    D PTR [00000994],00000000
405775 75DD                 JNZ    405754
405777 51                   PUSH   ECX
405778 64A120000000         FS:    MOV    EAX,[00000020]
40577E 8D8818040000         LEA    ECX,[EAX]+00000418
405784 E81B5D0000           CALL   40B4A4 ;ОСВОБОДИТЬ ОЧЕРЕДЬ
405789 B102                 MOV    CL,02
40578B FF150C114000         CALL   D PTR [0040110C]; HalRequestSoftware Interrupt
405791 59                   POP    ECX
405792 FF2530104000         JMP    D PTR [00401030];KfLowerIrql

405798 80F902               CMP    CL,02
40579B 7DD0                 JNL    40576D
…
4057AF 648B1D1C000000       FS:    MOV    EBX,[0000001C]
4057B6 8BB328010000         MOV    ESI,[EBX]+00000128
4057BC 8BBB24010000         MOV    EDI,[EBX]+00000124
4057C2 C7832801000000000000 MOV    D PTR [EBX]+00000128,00000000
4057CC 89B324010000         MOV    [EBX]+00000124,ESI
4057D2 884F58               MOV    [EDI]+00000058,CL
4057D5 8BCF                 MOV    ECX,EDI
4057D7 C6475001             MOV    B PTR [EDI]+00000050,01
4057DB E887FEFFFF           CALL   405667
4057E0 8A4F58               MOV    CL,[EDI]+00000058
4057E3 E85A010000           CALL   405942
…

Здесь уже не проверяется переменная 9AC, но опять проверяется наличие потока с более высоким приоритетом. Очевидно, что это обработка прерывания, случившегося внутри самой ОС, где квант не может истечь по определению, но на появление готового потока с более высоким приоритетом нужно реагировать немедленно.

И это весь анализ по части смены потока. Контекстным поиском больше не найдено мест, где бы менялся текущий поток (по смещению 124). А значит, анализировать остальные сотни тысяч строк ассемблерного кода уже нет никакой необходимости. ОС именно так как описано в документации меняет текущий поток или по исчерпанию заданного числа квантов (что определяется счетчиком в поле 6F структуры текущего потока) или при появлении более высокоприоритетного или если поток сам уступает время выполнения. Других «секретных» способов не обнаружено. Для решения поставленной задачи осталось лишь понять работу диспетчера баланса. Кстати, где он?

Диспетчер баланса использует понятие «старения» ждущих потоков. Значит, он должен достать текущий «тик» (переменная по адресу [483000], меняющаяся только внутри KeUpdateSystemTime), затем отнять из него некоторую константу и полученное значение сравнивать со временем перевода данного потока в режим ожидания. Это время должно храниться где-то в структуре каждого ждущего потока. Несложно найти в тексте все вычитания из системного «тика». Например, вот место доставания текущего времени и вычитание из него константы 300:

…
40F47B A100304800           MOV    EAX,[00483000] ;СИСТЕМНЫЙ ТИК
40F480 53                   PUSH   EBX
40F481 56                   PUSH   ESI
40F482 33F6                 XOR    ESI,ESI
40F484 46                   INC    ESI
40F485 8BDE                 MOV    EBX,ESI
40F487 894DF8               MOV    [EBP]+FFFFFFF8,ECX
40F48A D3E3                 SHL    EBX,CL
40F48C 2D2C010000           SUB    EAX,0000012C ; ВЫЧИТАЕМ 300
…

Если это и есть диспетчер баланса, тогда вот в нем сама проверка степени «старения» потока по времени его ожидания в поле со смещением 68:

…
410345 8B45EC               MOV    EAX,[EBP]+FFFFFFEC
410348 8D4EA0               LEA    ECX,[ESI]+FFFFFFA0
41034B 3B4168               CMP    EAX,[ECX]+00000068 ;НАЧАЛЬНЫЙ ТИК
41034E 0F83E5540300         JJAE   445839
410354 8B45F8               MOV    EAX,[EBP]+FFFFFFF8
410357 8B36                 MOV    ESI,[ESI]
…

А вот и нашлось поднимание текущего приоритета до 15, а также указанное в документации удвоение времени работы в кванте в этом случае:

…
445856 B00F                 MOV    AL,0F
445858 2A4133               SUB    AL,[ECX]+00000033 ;ПРИОРИТЕТ ПОТОКА
44585B C6416D10             MOV    B PTR [ECX]+0000006D,10
44585F 00416E               ADD    [ECX]+0000006E,AL
445862 8B4144               MOV    EAX,[ECX]+00000044
445865 C641330F             MOV    B PTR [ECX]+00000033,0F ;НОВЫЙ ПРИОРИТЕТ ПОТОКА
445869 8A4063               MOV    AL,[EAX]+00000063
44586C D0E0                 SHL    AL,1
44586E 88416F               MOV    [ECX]+0000006F,AL ; УДВОЕНИЕ ВРЕМЕНИ КВАНТА
445871 E8F1FDFBFF           CALL   405667
…

Теперь, наконец, анализ кода можно считать оконченным. В более чем полумиллионе ассемблерных строк с помощью контекстного поиска и общих соображений достаточно легко нашлось несколько десятков команд полностью и в строгом соответствии с документацией объясняющих поведение ОС при переключении потоков.

Изменение поведения планировщика

Теперь мы вооружены знаниями о том, как на уровне кодов происходит смена потока в Windows. Но как заставить планировщик работать в соответствии с поставленной задачей? Т.е., во-первых, сделать квант «бесконечным» на время работы заданного потока, а во-вторых, не допустить, чтобы диспетчер баланса поднял приоритет давно ждущих потоков выше приоритета заданного потока.

Для этого требуется внести исправление в само ядро. Это не так уж и сложно. Конечно, потребуется позаботиться о пересчете контрольной суммы с помощью процедуры CheckSumMappedFile и тому подобных мелочах, но это не является серьезным препятствием. Самое главное — организовать удобный интерфейс задачи пользователя с ядром. Напоминаем, что это делается для единственного компьютера.

Была выбрана схема, при которой запущенный поток сам периодически сообщает ядру о своей «избранности». При получении этого сообщения ядро продлевает квант выполнения и ограничивает подъем приоритетов диспетчером баланса не выше заданного. Как только (минут через 20–30) поток завершается, он перестает давать сообщения ядру. Поэтому ОС опять начинает выполнять фрагмент кода по исчерпанию кванта (для других потоков). В этом месте будет срабатывать возврат диспетчера баланса в нормальный режим работы. Таким образом, после завершения нужного потока ядро автоматически возвращается в обычный режим работы.

Сначала нужно найти место для размещения дополнительных команд. Таких мест много, например, команды можно написать вместо вот этого длинного диагностического текста, который вряд ли когда потребуется:

555390  E8 CE DC EA FF C9 C2 04-00 90 2A 2A 2A 2A 2A 2A        ..Р******
5553A0  2A 2A 2A 2A 2A 2A 2A 2A-2A 2A 2A 2A 2A 2A 2A 2A ****************
5553B0  2A 2A 2A 2A 2A 2A 2A 2A-2A 2A 2A 2A 2A 2A 2A 2A ****************
5553C0  2A 2A 2A 2A 2A 2A 2A 2A-2A 2A 2A 2A 2A 2A 2A 2A ****************
5553D0  2A 2A 2A 2A 2A 2A 2A 2A-2A 2A 2A 2A 2A 2A 2A 2A ****************
5553E0  2A 2A 2A 2A 2A 2A 2A 2A-2A 0A 2A 0A 2A 20 54 68 *********.*.* Th
5553F0  69 73 20 69 73 20 74 68-65 20 73 74 72 69 6E 67 is is the string
555400  20 79 6F 75 20 61 64 64-20 74 6F 20 79 6F 75 72  you add to your
555410  20 63 68 65 63 6B 69 6E-20 64 65 73 63 72 69 70  checkin descrip
555420  74 69 6F 6E 0A 2A 20 44-72 69 76 65 72 20 56 65 tion.* Driver Ve
555430  72 69 66 69 65 72 3A 20-45 6E 61 62 6C 65 64 20 rifier: Enabled 
555440  66 6F 72 20 25 5A 20 6F-6E 20 42 75 69 6C 64 20 for %Z on Build 
555450  25 6C 64 20 25 77 5A 0A-2A 0A 2A 2A 2A 2A 2A 2A %ld %wZ.*.******
555460  2A 2A 2A 2A 2A 2A 2A 2A-2A 2A 2A 2A 2A 2A 2A 2A ****************
555470  2A 2A 2A 2A 2A 2A 2A 2A-2A 2A 2A 2A 2A 2A 2A 2A ****************
555480  2A 2A 2A 2A 2A 2A 2A 2A-2A 2A 2A 2A 2A 2A 2A 2A ****************
555490  2A 2A 2A 2A 2A 2A 2A 2A-2A 2A 2A 2A 2A 2A 2A 2A ****************
5554A0  2A 2A 2A 2A 2A 2A 2A 2A-2A 0A 00 CC CC CC CC CC *********..

Передача сообщения ядру происходит с помощью выполнения привилегированной команды, на которую сработает исключение INT 0D «нарушение общей защиты». При этом предварительно в одном из регистров пишется специальное значение, которое и позволит ядру отличить этот случай от всех остальных. Кстати, само ядро тоже пользуется похожим приемом, например, в интерфейсе запроса времени INT 2A в регистре EBP можно записать специальные значения F0F0F0F0 или F0F0F0F1, которые заставят ядро реагировать на INT 2A по-разному.

Для начала команды обработчика исключения INT 0D в ядре по адресу 409150 можно немного «уплотнить» и добавить вызов новой подпрограммы (размещенной по адресу 5553A0 на месте текста), не двигая остальной код обработчика:

;---- ИСХОДНЫЙ ОБРАБОТЧИК ИСКЛЮЧЕНИЯ «ОБЩЕЕ НАРУШЕНИЕ ЗАЩИТЫ» ----

409150 F744240C00000200              TEST   D PTR [ESP]+0C,00020000
409158 0F843A010000                  JJE    409298
40915E 83EC64                        SUB    ESP,0064
409161 66C74424660000                MOV    W PTR [ESP]+66,0000
409168 895C245C                      MOV    [ESP]+005C,EBX
40916C 89442444                      MOV    [ESP]+0044,EAX
…
;---- ДОРАБОТАННЫЙ ОБРАБОТЧИК ИСКЛЮЧЕНИЯ «ОБЩЕЕ НАРУШЕНИЕ ЗАЩИТЫ» ----

409150 E84BC21400                    CALL   5553A0 ; ВСТАВЛЕННЫЙ ВЫЗОВ ОБРАБОТКИ
409155 F644240E02                    TEST   B PTR [ESP]+000E,02
40915A 0F8438010000                  JJE    409298
409160 83EC64                        SUB    ESP,0064
409163 895C245C                      MOV    [ESP]+005C,EBX
409167 89442444                      MOV    [ESP]+0044,EAX
40916B 33C0                          XOR    EAX,EAX
40916D 6689442466                    MOV    [ESP]+0066,AX
…

Как видите, при необходимости даже оптимизированный код можно «ужать» и вставить дополнительные команды.

А на место диагностического текста помещаются основные команды исправления ядра:

;---- ПРОВЕРКА СИГНАТУРЫ ----
5553A0 81FE44445555                  CMP    ESI,55554444
5553A6 7401                          JZ     5553A9
5553A8 C3                            RET

;---- ОБРАБОТКА СООБЩЕНИЯ ---
5553A9 1E                            PUSH   DS
5553AA 0FA0                          PUSH   FS
5553AC 66BB3000                      MOV    BX,0030
5553B0 66B82300                      MOV    AX,0023
5553B4 8EE3                          MOV    FS,BX
5553B6 8ED8                          MOV    DS,AX

;---- ПРОДЛЕВАЕМ КВАНТ РАБОТЫ ТЕКУЩЕГО ПОТОКА ----
5553B8 64A124010000                  FS:    MOV    EAX,[00000124]
5553BE C6406F7F                      MOV    B PTR [EAX]+006F,7F

;---- ЗАДАЕМ МАКСИМАЛЬНЫЙ ПРИОРИТЕТ ДЛЯ ДИСПЕТЧЕРА БАЛАНСА ----
5553C2 8A4833                        MOV    CL,[EAX]+0033
5553C5 E800000000                    CALL   5553CA
5553CA 5B                            POP    EBX
5553CB 884B3A                        MOV    [EBX]+003A,CL
5553CE 0FA1                          POP    FS
5553D0 1F                            POP    DS

;---- ВОЗВРАЩАЕМСЯ ИЗ ИСКЛЮЧЕНИЯ В ЗАДАЧУ ----
5553D1 FF442408                      INC    D PTR [ESP]+0008
5553D5 FF442408                      INC    D PTR [ESP]+0008
5553D9 83C408                        ADD    ESP,0008
5553DC CF                            IRET

Дополнительный обработчик исключения проверяет сигнатуру ESI=55554444 и выполняет следующие действия:

— устанавливает максимальное значение счетчика 127 для текущего потока;

— достает приоритет текущего потока и вставляет его как константу прямо внутрь команды, через которую проходит управление в диспетчере баланса. Чтобы найти относительный адрес исправляемой команды, выполняется фиктивный вызов процедуры;

— пропускает команду, которая вызвала это исключение, выбрасывает из стека адрес возврата и код ошибки и возвращается прямо в задачу пользователя.

По сути, Windows вообще не «чувствует» такое исключение, поскольку управление сразу же возвращается в задачу, минуя обычные пути обработки исключений. В программе достаточно хотя бы раз в 2–3 секунды давать исключение с таким значением в ESI и тогда внутренний счетчик потока по адресу 6F никогда не достигнет нуля. А значит, переменная 9AC продолжает оставаться нулевой и Windows не ищет замену текущему потоку.

Остается поправить диспетчер баланса. В него добавляются команды, проверяющие приоритет ждущего потока. Если приоритет ниже, диспетчер действует так, как будто поток еще не «старый»:

…
410348 8D4EA0                        LEA    ECX,[ESI]+FFA0
41034B 3B4168                        CMP    EAX,[ECX]+0068
41034E E998501400                    JMP    5553FB
410353 90                            NOP
…
;---- УЧЕТ НУЖНОГО ПРИОРИТЕТА В ДИСПЕТЧЕРЕ БАЛАНСА ----
5553FB 0F8653AFEBFF                  JJB    410354
555401 80793310                      CMP    B PTR [ECX]+0033,10
555405 0F8549AFEBFF                  JJBE   410354
55540B E92904EFFF                    JMP    445839

Первоначально приоритет сравнивается с константой 16, которой у проверяемых потоков не может быть, и поэтому проверка никак не влияет на обычную работу диспетчера. Но когда начинают приходить сообщения от «избранного» потока, константа 16 прямо в команде проверки заменяется значением приоритета заданного потока. Теперь всем более низкоприоритетным потокам диспетчер уже не пытается поставить приоритет 15.

Требуется лишь вернуть константу 16 на место после того, как заданный поток закончился. В этом случае ОС опять начинает выполнять поиск потоков по исчерпанию кванта, в это место и можно добавить команды восстановления:

…
40592A E890C10000                    CALL   411ABF
40592F E9986D0400                    JMP    5553DE
…
;---- ВОССТАНАВЛИВАЕМ ОБЫЧНЫЙ РЕЖИМ ДИСПЕТЧЕРА БАЛАНСА ----
5553DE 50                            PUSH   EAX
5553DF E800000000                    CALL   5553E4
5553E4 58                            POP    EAX
5553E5 C6402010                      MOV    B PTR [EAX]+0020,10
5553E9 58                            POP    EAX
5553EA 0BC0                          OR     EAX,EAX
5553EC 0F85EF04EBFF                  JJNE   4058D1
5553F2 C3                            RET

Все перечисленные вставки кодов записаны непрерывно на месте диагностического текста и разделены здесь лишь для более наглядного пояснения их работы.

Доработка прикладного ПО

Анализ и исправление ядра ОС занял примерно неделю, зато все прикладное ПО осталось без изменения, за исключением добавления в одном месте в главный цикл основного модуля подпрограммы выдачи сообщения ядру.

На языке PL/1 подпрограмма выдачи сообщения ядру выглядит так:

Продление_Кванта:proc;
dcl ?ESI bit(32);
?ESI=’55554444’b4; // в регистр ESI помещаем сигнатуру
unspec('0F08'b4);  // код любой «запрещенной» команды из 2-х байт
end Продление_Кванта;

Достаточно хотя бы раз в 2–3 секунды (т.е. пока не истечет внутренний счетчик, нужно опять успеть присвоить ему максимальное значение) обращаться из задачи пользователя к этой процедуре, как данный поток будет работать, не прерываясь на целые кванты для менее приоритетных потоков.

Заключение

Может показаться, что затрачено непропорционально много сил и времени лишь на то, чтобы заставить Windows работать неправильно. Но это не так. Поведение ОС не должно быть незыблемой данностью, указанной свыше. Есть большое число случаев, когда программа предназначена не для массовой работы на любых компьютерах (разумеется, тогда исправление ОС невозможно), а на конкретной машине и конкретной версии ОС.

Объективно универсальная ОС не может одинаково хорошо работать во всех мыслимых случаях. В данном случае разработчики ОС не могли предполагать, что одному потоку потребуется какое-то особое планирование. Ведь Windows пытается не допустить случая, когда поток вообще никогда не получит управления. Собственно, именно эти архитектурные особенности и не позволяют назвать эту ОС системой реального времени.

Однако данный пример показывает, что даже основные архитектурные особенности самой распространенной в мире ОС могут быть перенастроены для конкретного случая, превращая ее практически в систему реального времени. И при этом не требуется доскональное изучение всей ОС (да это и нереально), достаточно, исходя из логики и общих соображений, исследовать несколько подходящих мест. Для этого понадобилась лишь пара простых программ и текстовый редактор с контекстным поиском. Подобной технологией можно создать свою версию практически для любого случая без радикальной переделки всей системы.

И с юридической точки зрения это допустимо. Например, статья 6 Директивы 2009/24/EC или статья 25 Закона РФ об авторском праве [2] разрешают адаптацию программ для функционирования на технических средствах покупателя программы.

Здесь как раз тот самый случай, когда декомпиляция и исправления приводят к улучшению функциональности, которое выразилось в уменьшении одной из важных характеристик ОС — времени отклика, так как планировщик теперь не прерывает текущий поток на целые кванты для низкоприоритетных потоков. Но никакого чуда не произошло. Улучшение работы одного потока обусловлено временной остановкой остальных, что, разумеется, не может быть допустимым во всех случаях.

P.S. А тогда почему пример с двумя задачами так странно работал, раз в ядре не нашлось недокументированного планирования? Все просто. Сам пример был в данном случае некорректен. Ведь каждая из программ выдавала значение на экран обращением к стандартному файлу консольного вывода, причем в синхронном режиме. Т.е. наступал момент, когда задача с приоритетом «реального времени» просто уступала свое время, дожидаясь окончания выдачи на экран. В этот момент планировщик запускал поток с низким приоритетом, который успевал сменить значение переменной и сам уступал из-за выдачи на экран свое время, что вызывало возобновление работы высокоприоритетной задачи. Если отменить выдачу на экран, зацикленная задача приоритета «реального времени» просто «подвесит» весь компьютер (ну, или ядро на многоядерном процессоре), о чем и предупреждает документация.

Литература

1.       М. Руссинович, Д. Соломон Внутреннее устройство Microsoft Windows, Windows Server™ 2003, Windows XP и Windows 2000 4-е издание

2.       www.internet-law.ru

© Habrahabr.ru