Об уменьшении времени отклика в Windows

Первая публикация 04.01.2015 

On response time reduction in Windows

Введение

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

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

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

Как известно, переключение на другой поток в Windows происходит в трех случаях:
— истек выделенный квант времени работы и есть потоки с таким же приоритетом;
— поток добровольно уступает время работы (например, начинает ждать события);
— появился готовый к работе поток с более высоким приоритетом. Он немедленно (на самом деле в момент ближайшего прерывания) получает управление.

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

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

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

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

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

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

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

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

К сожалению, получить текст кода ядра для анализа не так-то просто. Т.е. ядро ntoskrnl.exe невозможно просто загрузить в память с помощью какого-нибудь ntsd или windbg. Конечно, есть и специальные средства, и отладочные версии, и виртуальные машины, но в данном случае хотелось бы получить просто ассемблерный код как текст, который можно даже хотя бы частично распечатать и спокойно анализировать «за столом», не обращаясь к компьютеру. Для этой цели проще создать небольшую программу (я назвал ее sd.exe) самому. Поскольку в используемых мною средствах [2] есть встроенный отладчик, легко написать небольшую программу, просто загружающую файл 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 E825C4E8FFCALL….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……………………PUSHEBX
402B52 56……………………PUSHESI
402B53 57……………………PUSHEDI
402B54 33C9……………….XOR….ECX,ECX
402B56 FF1588104000CALLD PTR [00401088]; KeAcquireQueuedSpinLock
Raise
ToSynch
402B5C 8B7508…………………MOV….ESI,[EBP]+00000008
402B5F 8AD8…………………….MOV….BL,AL
402B61 8B4604………………….MOVEAX,[ESI]+00000004
402B64 894508………………….MOV[EBP]+00000008,EAX
402B67 64A124010000 FS:MOVEAX,[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…….MOVEAX,[00483000] ;СИСТЕМНЫЙ ТИК
40B57C 8BD8……………….MOVEBX,EAX
40B57E 0F8F84000000..JJG…..40B608

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


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

;—— ОПЯТЬ УСТАНОВИЛИ КВАНТ —-
40B68B A10C304800……MOV…..EAX,[0048300C]
40B690 010514304800ADD……[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. Если переменная не нулевая – значит время данного потока исчерпано. С помощью подпрограммы по адресу 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 C7832801000000000000MOV……….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

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


410345 8B45EC……………MOV….EAX,[EBP]+FFFFFFEC
410348 8D4EA0…………..LEA……ECX,[ESI]+FFFFFFA0
41034B 3B4168…………….CMP…..EAX,[ECX]+00000068 ;НАЧАЛЬНЫЙ ТИК
41034E 0F83E5540300JJAE….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 66BB3000MOV…..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………ADDESP,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……JJBE410354
55540B E92904EFFF……….JMP…..445839

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

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


40592A E890C10000….CALL….411ABF
40592F E9986D0400….JMP……5553DE

;—- ВОССТАНАВЛИВАЕМ ОБЫЧНЫЙ РЕЖИМ ДИСПЕТЧЕРА БАЛАНСА —-

5553DE 50…………………….PUSHEAX
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….JJNE4058D1
5553F2 C3………………………RET

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

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

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

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

Заключение

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

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

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

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

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

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

Литература

  1. М.Руссинович, Д.Соломон Внутреннее устройство Microsoft Windows, Windows Server™ 2003, Windows XP и Windows 2000 4-е издание
  2. Караваев Д.Ю. К вопросу о совершенствовании языка программирования. RSDN Magazine #4, 2011
  3. www.internet-law.ru

 

0

Автор публикации

не в сети 2 недели

admin

3
Комментарии: 28Публикации: 174Регистрация: 13-06-2019
Авторизация
*
*

4 × пять =

Регистрация
*
*
*

15 − один =

Генерация пароля

17 − девять =

Перевести »
Прокрутить вверх
Scroll to Top