Введение
Реализация одной из ответственных задач моделирования в очередной раз привела к сложностям с операционной системой. Попытка решить задачу «под 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 <ud.txt >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-е издание
Автор: Dukarav