Русский
Русский
English
Статистика
Реклама

Ассемблер

Перевод Реверс-инжиниринг калькулятора с логикой -17В и частотой работы 200КГц

06.03.2021 12:15:20 | Автор: admin

Осторожно! Впереди кроличья нора





Разбираем внутреннее устройство старого промышленного калькулятора Rockwell 920 и на аппаратно-программном уровне пытаемся отследить неисправность, из-за которой он не работает. Процесс оказывается не столь простым, как можно было предположить, и на пути возникает ряд странностей.

Rockwell 920/3


У меня есть калькулятор Rockwell 920/3.
Родом этот внушительный зверь, где-то года так из 1975. Он программируемый, а его полностью расширенная версия может иметь 996 программных шагов. Помимо этого, он оснащен устройством чтения/записи магнитных карт и шестнадцатизначным дисплеем. Этот калькулятор может сохранять на картах как данные, так и программы. Конкретно первый оказавшийся у меня экземпляр использовался для ведения платежных ведомостей в крупной английской каталожной компании.

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


Программы я напечатал и как-нибудь займусь их транскрибированием. Позже в продаже появился третий аналогичный калькулятор, который я также без сомнений приобрел. В результате это оказалась модель 920/2, а не 920/3, что означало меньший объем памяти. Внутренне эти машины оснащены одинаковой материнской платой, снаряженной скромным ОЗУ. Вот одна из этих плат с моими подключениями для отладки:



Исходя из его архитектуры, этот калькулятор можно поистине назвать одноплатным компьютером. Здесь есть процессор, ОЗУ и ПЗУ вместе с интерфейсом ввода/вывода общего назначения (GPIO). В качестве процессора установлен Rockwell PPS-4, четырехбитный чип, использовавшийся для небольшого числа устройств в 70-х годах, в частности калькуляторов и машин для игры в pinball. Работает он от нестандартного, по крайней мере на сегодня, источника питания -17В. Логика здесь отрицательная, следовательно ноль это -17В, а единица это 0В. В результате подключить такое устройство к логическому анализатору, да даже к осциллографу, оказалось проблематичным. Обычно логический анализатор позволяет отлаживать напряжения от нуля до +5В, где земля это логический нуль, а +5В логическая единица (прим. переводчика). Частота процессора тоже далека от современных показателей и составляет всего 200 кГц. Второй и третий экземпляр были в нерабочем состоянии, так что я взялся за их починку. И поскольку было бы кстати понять, что именно делает код, я решил инструментировать сигналы шины и проследить его выполнение, а также, если повезет, создать дамп ПЗУ.

Я мог бы выпаять эти ПЗУ (все из которых находятся в нестандартном 42-контактном корпусе QIP с шахматным порядком выводов), но тогда мне бы понадобилось устройство для их дампа, работающее также от -17В. Позже я, может, и соберу такое, если мне не удастся сделать дамп всего ПЗУ с помощью перехвата шины на работающем калькуляторе. Думаю, что если прогнать его по всем функциям при подключенном сканере, то должно получиться постепенно перехватить содержимое ПЗУ. Как минимум выполняемые его части. Я собрал тестовую схему, чтобы оценить, заработает ли вообще хоть что-то при -17В. Результат на этом видео:


Заработала схема отлично, поэтому я продолжил и собрал плату с достаточным количеством входов для перехвата нужных сигналов и анализа выполнения потока кода. Оказалось, что простой (и дешевый, что будет на руку, если при -17В я вдруг допущу ошибку) микроконтроллер STM32F103C8 имеет достаточно GPIO для обработки адресной шины (на языке PPS-4 это A/B), шины данных (или I/D) и сигналов управления шинами.



Техническое описание PPS-4 доступно в интернете, и мне удалось найти образец кода в его патенте. Теперь я могу протестировать свой дизассемблер и любой объектный код на работоспособность. При рассмотрении шина процессора может вызвать пугающие ощущения, по крайней мере в сравнении с Z80. Это мультиплексированная, чередующаяся двухфазная штуковина. В ней присутствует четыре фазы тактов (моя терминология). Адреса ПЗУ поступают на адресную шину в фазе 1, в то время как данные ОЗУ или ввода/вывода находятся на шине данных. Затем в фазе 3 адрес ОЗУ или ввода/вывода передается на адресную шину, а ПЗУ на шину данных. Для лучшего понимания стоит взглянуть на схему из технической документации:


Если поразглядывать ее минут 10, то постепенно все становится понятно. Да, у процессора есть два тактовых входа, работающих на двух разных частотах и несколько отличающихся по фазам. Здесь есть специальная микросхема, генерирующая эти тактовые сигналы. На самом деле выпаивание микросхем ПЗУ для считывания является наиболее простой частью всего процесса. После выпаивания нужно сделать их дамп, используя описанную схему шин, и при этом не забывая, что логические уровни сигналов равны 0В и -17В.

Из полезного в данной схеме шин можно выделить пару фаз освобождения шины. Это позволяет легко управлять фазами при трассировке кода. Можно было подумать, что все просто, но PPS-4 ставит еще одну подножку, инвертируя частоту B и некоторые (только некоторые) данные на шине до их использования. Иногда он инвертирует полубайт, иногда байт. В данном случае уцепиться можно за пустую фазу.

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

Трассировка шины выглядят так:

A:000 D:04 CLKA:0 CLKB:1 WOL:0 DO:0  - 0A:000 D:00 CLKA:0 CLKB:1 WOL:0 DO:0  - 1A:000 D:00 CLKA:0 CLKB:1 WOL:0 DO:0  - 2A:000 D:00 CLKA:0 CLKB:1 WOL:0 DO:0  - 3 A:003 D:00 CLKA:0 CLKB:0 WOL:0 DO:0  - 0A:003 D:00 CLKA:0 CLKB:0 WOL:0 DO:0  - 1A:003 D:07 CLKA:1 CLKB:0 WOL:0 DO:0  - 2 A:000 D:0F CLKA:1 CLKB:1 WOL:0 DO:0  - 0A:000 D:00 CLKA:1 CLKB:1 WOL:0 DO:0  - 1A:000 D:00 CLKA:1 CLKB:1 WOL:0 DO:0  - 2A:000 D:00 CLKA:1 CLKB:1 WOL:0 DO:0  - 3 A:003 D:1C CLKA:1 CLKB:0 WOL:0 DO:0  - 0A:FFF D:1C CLKA:1 CLKB:0 WOL:0 DO:0  - 1 1CA:FFF D:1C CLKA:1 CLKB:0 WOL:0 DO:0  - 2A:6AB D:1C CLKA:0 CLKB:0 WOL:0 DO:0  - 3

Обратите внимание на адрес 3 во втором блоке это адрес ПЗУ. Затем в четвертом блоке данные из ПЗУ помещаются на шину. Содержимое шины данных при представлении адреса ПЗУ это данные ОЗУ или ввода-вывода из предыдущей инструкции ввода-вывода или чтения.

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

000     0x81       t 0x1001     0x82       t 0x2002     0x74       ldi 0xB003     0x1C       iol 0x1E  005     0x7E       ldi 0x1006     0x1C       iol 0x1D  

Процессор после сброса начинает с адреса 000, так что у нас есть точка для начала трассировки. Следующая инструкция это переход (t: transfer) из адреса 000 к адресу 001. Это несколько странно, но похоже на правду. Затем идет переход из 001 к 002. Опять же, странно, но может процессору требуется произвести какие-то настройки в первых циклах инструкций, либо счетчик программы таким образом завершается, или что-то в том духе? Я нашел в патенте какой-то код PPS-4, который начинается так:

0000 81        T       #1                                                    *SET O/P TO ZERO                                                           0001 7F        LDI     0                                                     0002 10 0E   IOL     #E                                                    0004 7F        LDI     0                                                     0005 10 0D   IOL     #D                                                    0007 7F        LDI                                                           0008 10 07   IOL     1   

Здесь мы видим такую же инструкцию перехода, что еще раз подтверждает наличие конкретной точки для трассировки. Тем не менее перехода из 001 к 002здесь уже нет. Мне не удалось найти объяснения, откуда они вообще взялись.

Инструкция IOL отправляет команду микросхемам GPIO. Они не отображаются в память или I/O, вместо чего в них заложены номера устройств. IOL ox1E отправляет команду oxE устройству 1. Так происходит настройка сигналов сканирования клавиатуры/дисплея. Я отследил достаточно сигналов на печатной плате, чтобы определить номера устройств для микросхем GPIO. До этого момента я называл микросхему PPS-4 процессором, но это не совсем верно. У этой микросхемы действительно есть порты ввода/вывода. Это не порты GPIO, поскольку являются фиксированными вводами или выводами, поэтому в некотором смысле данная микросхема больше походит на микроконтроллер. Порт вывода в 920-м используется для управления демультиплексором, который контролирует каждую цифру дисплея. Вводы же используются для распознавания матрицы клавиатуры (сигналы сканирования для клавиатуры это те же сигналы сканирования, которые используются для управления цифрами дисплея).

Набор инструкций


Я знаком с 8-битными процессорами Z80 и 6502, а также с набором инструкций ARM. Мне также доводилось программировать на ассемблере для PIC, Z8, 6301, 8086, 8051, 4-битных микроконтроллеров и т.д. Но при этом некоторые из инструкций PPS-4 меня удивили. Такое ощущение, что они были придуманы до того, как люди сформулировали устойчивые правила. Например, инструкция load immediate:


Четырехбитное содержимое, поле immediate field I(4:1) инструкции, помещается в накопитель (см. примечание ниже)

Хорошо, это 4-битный вариант, и промежуточным значением является полубайт в коде операции. Тогда обратим внимание на правую часть, где идет ссылка на примечание 3.


Инструкции ADI, LD, EX, EXD, LDI, LB и LBL содержат в своих immediate field (мгновенных полях) закодированное числовое значение. Это числовое значение должно присутствовать на шине в виде дополнения. Все мгновенные поля, которые инвертируются, показываются в квадратных скобках
Например: инструкцияADI 1, которую программист пишет, желая добавить единицу к значению в накопителе, преобразуется в 6E(16)=0110[1110]. В скобках указано двоичное значение в том виде, в каком оно представлено на шине данных.

Если программист использует Rockwell Assembler, ему не нужно вручную определять подходящее инвертированное значение, так как ассемблер делает это сам.

Хорошо, для этих инструкций (не всех инструкций) полубайт, являющийся мгновенными данными, инвертируется в ПЗУ и, следовательно, на шине. Мда, несколько странно, но дизассемблер или ассемблер это обработает. Далее мы замечаем, что в четвертом блоке слева есть ссылка на еще одно примечание. На этот раз ссылка ведет сюда:


Будет выполнено только первое вхождение инструкции LB или LBL . Программа будет игнорировать оставшиеся LB или LBL и выполнять следующую действительную инструкцию. Внутри подпрограмм инструкция LB должна использоваться с осторожностью, потому что содержимое SB было изменено.

Так, теперь наблюдается некоторая странность. Если загрузить накопитель с 6, а затем с 4, то в итоге его значение будет 6. Когда я столкнулся с этим впервые, то удивился, но после изучения кода решил, что в этом есть смысл. Если вам нужно ввести программу с различными параметрами, то вы можете сделать следующее:

Enter6LDI 6Enter7LDI 7Enter 8   LDI 8           * Выполнить действие с A          RTN

Если перейти к Enter6, то накопитель загрузится с 6, последующие загрузки игнорируются, и вы выполняете действия с 6. Если перейти к Enter7, тогда накопитель загружается с 7, и следующая загрузка игнорируется, а вы выполняете нужные действия с 8. Я понимаю, в чем здесь польза.

Вот инструкция:



48 последовательных адресов на странице 3 ПЗУ содержат данные указателей, которые определяют адреса входа подпрограмм. Эти адреса входа ограничены страницами с 4 по 7. Данная инструкция TM будет сохранять адрес следующего слова ПЗУ в регистр SA после загрузки исходного содержимого SA в SB. После этого происходит переход к одному из адресов входа подпрограмм. Эта инструкция занимает в ПЗУ одно слово, но для выполнения требует два цикла.

Она показывает использование таблиц данных в ПЗУ на уровне инструкций.

Что дальше?


Теперь мне нужно отследить выполнение кода вплоть до цикла, в котором оно заканчивается. Может, тогда мне удастся понять, что здесь не так. Мне также нужно нарисовать схему. Плотность заполнения печатных плат невысока, так что это не должно оказаться затруднительным.

Расширение памяти


Небольшое отступление.

Модель 920/2 содержит одну подключаемую плату расширения памяти, а в 920/3 их две:



Удивляет, что на плате ОЗУ в модели 920/2 присутствует четыре микросхемы, а на платах 920/3 их по 6. Еще один сюрприз в том, что эти две платы микросхем отличаются друг от друга. Мне кажется, что у них разные распиновки разъемов, несмотря на то, что объем памяти на них одинаковый. Поэтому вместо того, чтобы задействовать одну печатную плату для обеих конфигураций расширения памяти (часть, заполняющая плату 920/2) я использовал три разные.

От переводчика: Как видно, автор реверс-инжиниринга данного калькулятора делает много интересных открытий. Мы будем и дальше следить за его публикациями, и как только он напишет что-то новое по данной теме, обязательно опубликуем.

Подробнее..

Перевод bdshemu эмулятор шелл-кода в Bitdefender

16.11.2020 16:04:47 | Автор: admin
Совсем скоро, 19 ноября, у нас стартует курс Этичный хакер, а специально к этому событию мы подготовили этот перевод о bdshemu написанном на языке C эмуляторе с открытым исходным кодом в Bitdefender для обнаружения эксплойтов на 32- и 64-битной архитектуре. Эмулятор очень прост, а благодаря нацеленности на уровень инструкций он работает с любой операционной системой. Кроме того, этот эмулятор зачастую сохраняет расшифрованный эксплойт в бинарный файл. Подробности и пример обнаружения Metasploit под катом, ссылка на репозиторий проекта на Github в конце статьи.





Введение


Обнаружение эксплойтов одна из основных сильных сторон Hypervisor Memory Introspection (HVMI). Возможность мониторинга гостевых страниц физической памяти на предмет различных видов доступа, таких как запись или выполнение, позволяет HVMI накладывать ограничения на критические области памяти: например, страницы стека или кучи могут быть помечены как невыполняемыми на уровне EPT, поэтому, когда эксплойту удается получить доступ к произвольному выполнению кода, вмешивается логика интроспекции и блокирует выполнение шелл-кода.

Теоретически перехвата попыток выполнения из областей памяти, например стека или кучи, должно быть достаточно, чтобы предотвратить действия большинства эксплойтов. Реальная жизнь часто бывает сложнее, и бывает много случаев, когда легальное программное обеспечение использует приемы, напоминающие атаку JIT-компиляция в браузерах один из хороших примеров. Кроме того, злоумышленник может хранить полезную нагрузку в других областях памяти, за пределами стека или кучи, поэтому полезно распознавание хорошего и плохого кода.

В этом посте поговорим об эмуляторе Bitdefender Shellcode Emulator, или, для краткости, bdshemu. Это библиотека, способная эмулировать базовые инструкции x86, наблюдая при этом похожее на шелл-код поведение. Легальный код например JIT-код будет выглядеть иначе, чем традиционный шелл-код, bdshemu пытается определить, ведёт ли себя код в эмуляции, как шелл-код.

Обзор bdshemu


bdshemu библиотека на C, частью проекта bddisasm (и, конечно же, она использует bddisasm для расшифровки инструкций). Библиотека bdshemu создана только для эмуляции кода x86, поэтому не имеет поддержки вызовов API. На самом деле, среда эмуляции сильно ограничена и урезана, доступны только две области памяти:

  1. Содержащие эмулируемый код страницы.
  2. Стек.

Обе эти области виртуализированы, то есть на самом деле являются копиями эмулируемой фактической памяти, поэтому внесенные в них изменения не влияют на фактическое состояние системы. Любой доступ эмулируемого кода за пределы этих областей (которые мы будем называть шелл-кодом и стеком соответственно), спровоцирует немедленное завершение эмуляции. Например, вызов API автоматически вызовет ветку вне области шелл-кода, тем самым прекратив эмуляцию. Однако в bdshemu всё, что нас волнует это поведение кода на уровне инструкций, которого достаточно, чтобы понять, вредоносен код или нет.

Хотя bdshemu предоставляет основную инфраструктуру для обнаружения шелл-кодов внутри гостевой операционной системы, стоит отметить, что это не единственный способ, которым HVMI определяет, что выполнение определённой страницы является вредоносным. Используются ещё два важных индикатора:

  1. Выполняемая страница находится в стеке это обычное дело для уязвимостей на основе стека.
  2. Подделка стека когда страница выполняется впервые, а регистр RSP указывает за пределы выделенного для потока обычного стека.

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

Архитектура bdshemu


bdshemu создаётся как отдельная библиотека C и зависит только от bddisasm. Работать с bdshemu довольно просто, поскольку у этих двух библиотек имеется общий API:

SHEMU_STATUSShemuEmulate(    SHEMU_CONTEXT *Context    );

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

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

Первоначально контекст заполняется следующей основной информацией (обратите внимание, что результат эмуляции может измениться в зависимости от значения предоставленных регистров и стека):

  1. Входные регистры, такие как сегменты, регистры общего назначения, регистры MMX и SSE; их можно оставить в значении 0, если они неизвестны или не актуальны.
  2. Входной код, то есть код для эмуляции.
  3. Входной стек, который может содержать фактическое содержимое стека, или может быть оставлен со значением 0.
  4. Информация о среде, например режим (32 или 64 бита) или кольцо (0, 1, 2 или 3).
  5. Параметры управления: минимальная длина строки стека, минимальная длина цепочки NOP или максимальное количество инструкций, которые должны быть эмулированы.

Основной выходной параметр поле Flags, которое содержит список обнаруженных во время эмуляции индикаторов шелл-кода. Как правило, ненулевое значение этого поля убедительно свидетельствует, что код эмуляции это шелл-код.

bdshemu построен как быстрый и простой эмулятор инструкций x86. Поскольку он работает только с самим шелл-кодом и небольшим виртуальным стеком, ему не нужно имитировать какие-то архитектурные особенности прерывания или исключения, таблицы дескрипторов, таблицы страниц. Кроме того, поскольку мы имеем дело только с шелл-кодом и стековой памятью, bdshemu не выполняет проверку доступа к памяти потому, что не разрешает доступ даже к другим адресам. Единственное состояние, кроме регистров, к которому можно получить доступ, это сам шелл-код и стек, и они оба копии фактического содержимого памяти. То есть состояние системы никогда не изменяется во время эмуляции, изменяется только предоставленный SHEMU_CONTEXT. Это делает bdshemu чрезвычайно быстрым, простым и позволяет сосредоточиться на его основной цели: обнаружении шелл-кода.

Что касается поддержки инструкций, bdshemu поддерживает все основные инструкции x86, такие как ветвления, арифметика, логика, сдвиг, манипуляции с битами, умножение и деление, доступ к стеку и инструкции передачи данных. Кроме того, он также поддерживает другие инструкции, например, некоторые базовые инструкции MMX или AVX. Два хороших примера PUNPCKLBW и VPBROADCAST.

Методы обнаружения bdshemu


Есть несколько индикаторов, которые использует bdshemu. Чтобы определить, ведёт ли себя эмулируемый фрагмент кода как шелл-код.

NOP Sled


Это классическое представление шелл-кода; поскольку точка его входа при выполнении может быть неизвестна точно, злоумышленники обычно добавляют длинную последовательность инструкций NOP, закодированную 0x90. Параметры длины последовательностей NOP можно контролировать при вызове эмулятора через контекстное поле NopThreshold. Значение SHEMU_DEFAULT_NOP_THRESHOLD по умолчанию равно 75. Это означает, что минимум 75 % всех эмулируемых инструкций должны быть инструкциями NOP.

RIP Load


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

  1. CALL $ + 5/POP ebp выполнение этих двух инструкций приведёт к тому, что значение указателя инструкции сохранится в регистре ebp; затем можно получить доступ к данным внутри шелл-кода, используя смещения относительно значения ebp
  2. FNOP/FNSTENV [esp-0xc]/POP edi первая инструкция это любая инструкция FPU (не обязательно FNOP), а вторая инструкция FNSTENV сохраняет среду FPU в стеке; третья инструкция получит указатель инструкции FPU из esp-0xc, который является частью среды FPU и содержит адрес последнего выполненного FPU в нашем случае FNOP. С этого момента для доступа к данным шелл-кода можно использовать адресацию относительно edi
  3. Внутренне bdshemu отслеживает все экземпляры указателя инструкции, сохранённые в стеке. Последующая загрузка указателя инструкции из стека каким-либо образом приведёт к срабатыванию этого обнаружения. Благодаря тому, что bdshemu отслеживает сохранённые указатели инструкций, не имеет значения, когда, где и как шелл-код пытается загрузить регистр RIP и использовать его: bdshemu всегда будет запускать обнаружение.

В 64-битном режиме относительная адресация RIP может использоваться напрямую: это позволяет кодировка инструкций. Однако, как ни странно, большое количество шелл-кода по-прежнему использует классический метод получения указателя инструкций (обычно технику CALL/POP), но, вероятно, указывает на то, что 32-битные шелл-коды были перенесены на 64-битные с минимальными изменениями.

Запись шелл-кода самим шелл-кодом


Чаще всего шелл-код закодирован или зашифрован, чтобы избежать некоторых плохих символов (например 0x00, который должен напоминать строку, может сломать эксплойт), или чтобы избежать обнаружения технологиями безопасности например, AV-сканерами. Это означает, что во время выполнения шелл-код должен декодировать себя обычно на месте изменяя свое собственное содержимое, а затем выполняя текстовый код. Типичные методы декодирования включают алгоритмы дешифрования на основе XOR или ADD.

Конечно, bdshemu знает о таком поведении и отслеживает каждый изменённый внутри шелл-кода байт. Каждый раз, когда предполагаемый шелл-код записывает какую-то часть себя, а затем выполняет её, срабатывает обнаружение самостоятельной записи шелл-кода.

Доступ к TIB


После того как шелл-код получил возможность выполнения, ему необходимо найти несколько функций внутри разных модулей, чтобы позаботиться о полезной нагрузке (например, загрузке файла или создании процесса). В Windows наиболее распространенный способ сделать это разобрать структуры загрузчика пользовательского режима, чтобы найти адреса, по которым загружены требуемые модули, а затем найти необходимые функции внутри этих модулей. Ниже дана последовательность структур, к которым будет обращаться шелл-код:

  1. Блок среды потока (TEB), который расположен в fs: [0] (32-битный поток) или gs: [0] (64-битный поток).
  2. Блок среды процесса (PEB), который расположен по адресу TEB + 0x30 (32 бит) или TEB + 0x60 (64 бит).
  3. Информация о загрузчике (PEB_LDR_DATA), расположенная внутри PEB.


Внутри PEB_LDR_DATA есть несколько списков, содержащих загруженные модули. Шелл-код будет перебирать эти списки, чтобы найти столь необходимые ему библиотеки и функции.

При каждом обращении к памяти bdshemu увидит, пытается ли предполагаемый шелл-код получить доступ к полю PEB внутри TEB. bdshemu отслеживает обращения к памяти, даже если они выполняются без классических префиксов сегментов fs/gs до тех пор, пока идентифицирован доступ к полю PEB внутри TEB, будет срабатывать обнаружение доступа к TIB.

Направленный вызов SYSCALL


Легитимный код будет полагаться на несколько библиотек для вызова служб операционной системы например для создания процесса в Windows обычный код будет вызывать одну из функций CreateProcess. Легитимный код редко вызывает SYSCALL напрямую, поскольку интерфейс SYSCALL со временем может измениться. По этой причине bdshemu запускает обнаружение SYSCALL всякий раз, когда обнаруживает, что предполагаемый шелл-код напрямую вызывает системную службу с помощью инструкций SYSCALL, SYSENTER или INT.

Строки стека


Другой распространённый способ маскировки содержимого шелл-кода динамическое построение строк в стеке. Это может устранить необходимость в написании независимого от позиции кода (PIC), поскольку шелл-код будет динамически строить нужные строки в стеке, вместо того, чтобы ссылаться на них внутри шелл-кода как на обычные данные. Типичные способы сделать это сохранить содержимое строки в стеке, а затем ссылаться на строку с помощью указателя стека:

push 0x6578652Epush 0x636C6163

В приведённом выше коде в стеке сохраняется строка calc.exe, которую затем можно использовать как обычную строку во всем шелл-коде.

Для каждого сохранённого в стеке значения, напоминающего строку, bdshemu отслеживает общую длину созданной в стеке строки. Как только порог, указанный полем StrLength внутри контекста, будет превышен, будет запущено обнаружение строки стека. Значение по умолчанию для этого поля SHEMU_DEFAULT_STR_THRESHOLD равно 8. Это означает, что динамическое построение строки длиной не менее 8 символов в стеке вызовет это обнаружение.

Методы обнаружения для шелл-кода режима ядра


Хотя вышеупомянутые методы общие и могут применяться к любому шелл-коду, в любой операционной системе, как в 32-, так и в 64-битной версии (за исключением обнаружения доступа к TIB, которое специфично для Windows) bdshemu может определять специфичное для ядра поведение шелл-кода.

Доступ KPCR


Область управления процессором ядра (KPCR) это структура для каждого процессора в системах Windows, которая содержит много критически важной для ядра информации, но также может быть полезной для злоумышленника. Обычно шелл-код может ссылаться на текущий выполняющийся поток, который можно получить, обратившись к структуре KPCR, со смещением 0x124 в 32-битных системах и 0x188 в 64-битных системах. Так же, как и в методе обнаружения доступа к TIB, bdshemu отслеживает обращения к памяти, и когда эмулируемый код считывает текущий поток из KPCR, он запускает обнаружение доступа к KPCR.

Выполнение SWAPGS


SWAPGS это системная инструкция, которая выполняется только при переходе из пользовательского режима в режим ядра, и наоборот. Иногда, из-за специфики определенных эксплойтов ядра злоумышленнику в конечном счёте нужно выполнить SWAPGS например, полезная нагрузка ядра EternalBlues, как известно, перехватывает обработчик SYSCALL, поэтому нужно выполнить SWAPGS, когда состоялся SYSCALL, подобно тому, как это сделал бы обычный системный вызов.

bdshemu запускает обнаружение SWAPGS всякий раз, когда инструкция SWAPGS выполняется подозрительным шелл-кодом.

Чтение и запись MSR


Иногда шелл-код (например вышеупомянутая полезная нагрузка ядра EternalBlue) должен изменить обработчик SYSCALL, чтобы перейти в стабильную среду выполнения (например, потому что исходный шелл-код выполняется в высоких значениях диапазона IRQL, которые необходимо снизить перед вызовом полезных подпрограмм). Это делается путём изменения MSR SYSCALL с помощью инструкции WRMSR, а затем ожидания выполнения системного вызова (который находится на более низком уровне IRQL) для продолжения выполнения (здесь также пригодится метод SWAPGS потому, что на 64-битной версии SWAPGS должна выполняться после каждого SYSCALL).

Кроме того, чтобы найти образ ядра в памяти и, следовательно, полезные процедуры ядра, быстрый и простой способ запросить SYSCALL MSR (которая обычно указывает на обработчик SYSCALL внутри образа ядра), а затем пройти по страницам назад, пока не будет найдено начало образа ядра.

bdshemu будет запускает обнаружение доступа MSR всякий раз, когда подозрительный шелл-код обращается к MSR SYSCALL, (как в 32-, так и в 64-битном режиме).

Пример


Проект bdshemu содержит несколько синтетических тестовых примеров, но лучший способ продемонстрировать его функциональность использовать реальный шелл-код. В этом отношении Metasploit замечателен в создании разных видов полезной нагрузки с использованием всех видов кодировщиков. В качестве чисто дидактического [прим. перев. назидательного] примера возьмём такой код:

DA C8 D9 74 24 F4 5F 8D 7F 4A 89 FD 81 ED FE FFFF FF B9 61 00 00 00 8B 75 00 C1 E6 10 C1 EE 10 83 C5 02 FF 37 5A C1 E2 10 C1 EA 10 89 D3 09 F3 21 F2 F7 D2 21 DA 66 52 66 8F 07 6A 02 03 3C 24 5B 49 85 C9 0F 85 CD FF FF FF 1C B3 E0 5B 62 5B 62 5B 02 D2 E7 E3 27 87 AC D7 9C 5C CE 50 45 02 51 89 23 A1 2C 16 66 30 57 CF FB F3 9A 8F 98 A3 B8 62 77 6F 76 A8 94 5A C6 0D 4D 5F 5D D4 17 E8 9C A4 8D DC 6E 94 6F 45 3E CE 67 EE 66 3D ED 74 F5 97 CF DE 44 EA CF EB 19 DA E6 76 27 B9 2A B8 ED 80 0D F5 FB F6 86 0E BD 73 99 06 7D 5E F6 06 D2 07 01 61 8A 6D C1 E6 99 FA 98 29 13 2D 98 2C 48 A5 0C 81 28 DA 73 BB 2A E1 7B 1E 9B 41 C4 1B 4F 09 A4 84 F9 EE F8 63 7D D1 7D D1 7D 81 15 B0 9E DF 19 20 CC 9B 3C 2E 9E 78 F6 DE 63 63 FE 9C 2B A0 2D DC 27 5C DC BC A9 B9 12 FE 01 8C 6E E6 6E B5 91 60 F2 01 9E 62 B0 07 C8 62 C8 8C

Сохранение этого кода как двоичного файла как shellcode.bin с последующим просмотром его содержимого дает плотно упакованный фрагмент кода, весьма характерный для зашифрованного шелл-кода:



В disasmtool инструменте проекта bddisasm, для запуска эмулятора шелл-кода на входе можно воспользоваться параметром -shemu.

disasmtool -b32 -shemu -f shellcode.bin

Выполнение отобразит пошаговую информацию о каждой эмулируемой команде, но так как эта трассировка длинная, давайте перейдем непосредственно к концу if:

Emulating: 0x0000000000200053 XOR       eax, eax        RAX = 0x0000000000000000 RCX = 0x0000000000000000 RDX = 0x000000000000ee00 RBX = 0x0000000000000002        RSP = 0x0000000000100fd4 RBP = 0x0000000000100fd4 RSI = 0x0000000000008cc8 RDI = 0x000000000020010c        R8  = 0x0000000000000000 R9  = 0x0000000000000000 R10 = 0x0000000000000000 R11 = 0x0000000000000000        R12 = 0x0000000000000000 R13 = 0x0000000000000000 R14 = 0x0000000000000000 R15 = 0x0000000000000000        RIP = 0x0000000000200055 RFLAGS = 0x0000000000000246Emulating: 0x0000000000200055 MOV       edx, dword ptr fs:[eax+0x30]Emulation terminated with status 0x00000001, flags: 0xe, 0 NOPs        SHEMU_FLAG_LOAD_RIP        SHEMU_FLAG_WRITE_SELF        SHEMU_FLAG_TIB_ACCESS

Мы видим, что последняя эмулированная инструкция MOV edx, dword ptr fs: [eax + 0x30] это инструкция доступа к TEB, но она также запускает эмуляцию, которая должна быть остановлена, поскольку это доступ за пределы памяти шелл-кода (вспомним, что bdshemu остановится при первом обращении к памяти вне шелл-кода или стека). Более того, этот небольшой шелл-код (сгенерированный с помощью Metasploit) вызвал 3 обнаружения в bdshemu:

  1. SHEMU_FLAG_LOAD_RIP шелл-код загружает RIP в регистр общего назначения, чтобы определить его позицию в памяти.
  2. SHEMU_FLAG_WRITE_SELF расшифровывает сам себя, а затем выполняет расшифрованные фрагменты.
  3. SHEMU_FLAG_TIB_ACCESS обращается к PEB, чтобы найти важные библиотеки и функции.


Этих срабатываний более чем достаточно, чтобы сделать вывод, что эмулируемый код, без сомнения, является шелл-кодом. Что еще более удивительно в bdshemu, так это то, что обычно в конце эмуляции память содержит расшифрованную форму шелл-кода. disasmtool достаточно хорош, чтобы сохранить память шелл-кода после завершения эмуляции: создаётся новый файл с именем shellcode.bin_decoded.bin, содержащий декодированный шелл-код. Давайте посмотрим на него:



Глядя на декодированный шелл-код, можно сразу увидеть не просто отличия, но и простой текст зоркий глаз быстро обнаружит строку calc.exe в конце шелл-кода, намекая нам, что это классический calc.exe, порождающий шелл-код.

Заключение


В этом посте мы представили эмулятор шелл-кода Bitdefender, который является важной частью технологии обнаружения эксплойтов HVMI. bdshemu построен для обнаружения индикаторов шелл-кода на уровне двоичного кода, без необходимости эмулировать сложные вызовы API, сложное расположение памяти или архитектурные объекты, такие как таблицы страниц, таблицы дескрипторов и т. д. bdshemu фокусируется на том, что имеет наибольшее значение, эмулируя инструкции и определяя шелл-код по поведению.

Благодаря своей простоте bdshemu работает с шелл-кодом, нацеленным на любую операционную систему: большинство методов обнаружения определены на уровне поведения инструкций, а не на высокоуровневом поведении (например на уровне вызовов API). Кроме того, он работает как с 32-битным, так и с 64-битным кодом, а также с кодом, специфичным для режимов пользователя или ядра.

Ссылка на Github

На тот случай если вы задумали сменить сферу или повысить свою квалификацию промокод HABR даст вам дополнительные 10 % к скидке указанной на баннере.

image



Рекомендуемые статьи


Подробнее..

Дизассемблируем код на Си switch() case assembler disass()

19.11.2020 20:15:16 | Автор: admin

Доброго времени суток.

Сегодня мы будем смотреть дизассемблированный код инструкций if, for, while, swich, которые написаны на языке Си.

1605795529033.png1605795529033.png

Инcтрукция if

Данную инструкцию довольно просто отличить в дизассемблированном виде от других инструкций. Её отличительное свойство - одиночные инструкции условного перехода je, jne и другие команды jump.

1605790962062.png1605790962062.png1605790989407.png1605790989407.png

Напишем небольшую программу на языке Си и дизассемблируем её с помощью radare2. Разницы между IDA PRO и radare2 при дизассемблировании этих программ не было обнаружено, поэтому я воспользуюсь radare2. Вы можете использовать IDA PRO.

IDA PRO

2020-11-17_08-22.png2020-11-17_08-22.png2020-11-17_08-23.png2020-11-17_08-23.png

radare2

1605792900362.png1605792900362.png

Код на Си

#include <stdio.h>void main() {    int x = 1;    int y = 2;    if(x == y) {        printf("x = y\n");    }    else{        printf("x != y\n");    }}

Компилируем при помощи gcc. Команда gcc -m32 prog_if.c -o prog_if. -m32 озночает, что компилироваться код будет под архитектуру x86.

Чтобы посмотреть на код в radare2, напишем команду r2 prog_if. Далее прописываем aaa для анализа кода и переходим к функции main s main. Посмотрим на код с помощью команды pdf.

Дизассемблированный вариант в radare2

1605792900362.png1605792900362.png

Первым делом в программе происходит объявление переменных ( int x; int y ), а затем значение 1 перемещается в varch (это переменная x) и значение 2 в var10h (это переменная y). Далее идёт сравнение (cmp) 1 и 2 (cmp edx, dword [var_10h]). Эти значения не равны. Значит jne ( jump if noe equal) перейдёт по адресу 0x000011e1. Проще всего инструкцию if запомнить и опрелелить в режиме графов (команда VV для для radare2 или клавиша пробел для IDA).

Режим графов

1605792914861.png1605792914861.png

Немного усложним задачу. Добавим вложенные инструкции. Попробуйте проанализировать этот код.

Код на Си

#include <stdio.h>void main() {    int x = 0;    int y = 1;    int z = 2;    if(x == y) {        if(z == 0) {        printf("z = 0; x = y\n");        }        else{            printf("z = 0; x != y\n");        }    }    else {        if(z == 0) {            printf("z = zero and x != y.\n");        } else {            printf("z non-zero and x != y.\n");        }    }}

Дизассемблированный вариант в radare2

1605792935104.png1605792935104.png

Режим графов

1605792950680.png1605792950680.png

В режиме графов это воспринимать намного проще.

Инструкция for

Циклы for всегда состоят из четырех этапов: инициализации, сравнения, выполнения инструкций и инкремента/декремента. По этим четырём этапом мы будем распозновать for в ассемблерном коде.

Код на Си

#include <stdio.h>void main() {    int x;    for(x = 0; x < 100; x++) {        printf("x = %d", x);    }}

Дизассемблированный вариант в radare2

1605792973718.png1605792973718.png

1 - инициализации переменной var_ch (x = 0)
2 - сравнение, а затем jle. ( пока x не будет меньше или равен 2, выполнять цикл.)
3 - выполнения инструкций (printf)
4 - инкрмент переменной var_
ch (++x)

Режим графов

1605792987678.png1605792987678.png

Инструкция while

Цикл while часто используется при ожидании, пока не будет выполнено какое-то условие, например получение команды или пакета. В ассемблере циклы while похожи на for, но их легче понять. В ассемблере это выражение похоже на цикл for, но инкремента может и не быть.

Код на Си

#include <stdio.h>int func_1(int x);int change_status();int main() {    int status = 0;    while(status == 0) {        printf("int e = %d", func_1(5) );        status = change_status();    }    return 0;}int change_status() {    return 1;}int func_1(int x) {    int c;    int e;    int l;    c = 1 + 2;    e = x / 5;    l = 4 - 2;    return e;}

Дизассемблированный вариант в radare2

1605793002542.png1605793002542.png

1 - инициализации переменной var_4h (status = 0)
2 - сравнение, а затем je. ( пока x равен 0, выполнять цикл.)
3 - выполнения инструкций (func
1, printf, change_status)

Режим графов

1605793019409.png1605793019409.png

Инструкция switch

Конструкция switch обычно компилируется двумя способами: по примеру условного выражения или как таблица переходов.

Компиляция по примеру условного выражения

Код на Си

#include <stdio.h>int main() {    int i = 3;    switch(i) {        case 1:            printf("CASE_1 i = %d", i+4);            break;        case 2:            printf("CASE_2 i = %d", i+9);            break;        case 3:            printf("CASE_3 i = %d", i+14);            break;    }    return 0;}

Дизассемблированный вариант в radare2

1605793045836.png1605793045836.png

1 - инициализации переменной var_4h (i = 3)
2 - выполнения инструкций (add, printf)

Чтобы понять какой "case" выбран, происходит сравниение (cmp, а затем je, jne) переменной i с значением case.

Режим графов

1605793347813.png1605793347813.pngscreen13.pngscreen13.pngscreen_13_2.pngscreen_13_2.png

Глядя на этот код, сложно (если вообще возможно) сказать, что представлял собой оригинальный исходный текст конструкцию switch или последовательность выражений if . В обоих случаях код выглядит одинаково, поскольку оба выражения используют множество инструкций cmp и je или jne.

Таблица переходов

Следующий пример ассемблерного кода часто можно встретить в больших смежных выражениях switch. Мы добавим case 4 и инструкцию по умолчанию.

Код на Си

#include <stdio.h>int main() {    int i = 3;    switch(i) {        case 1:            printf("CASE_1 i = %d", i+4);            break;        case 2:            printf("CASE_2 i = %d", i+9);            break;        case 3:            printf("CASE_3 i = %d", i+14);            break;        case 4:            printf("CASE_3 i = %d", i+19);            break;        default:            break;    }    return 0;}

Дизассемблированный вариант в radare2

1605793648917.png1605793648917.png1605793656018.png1605793656018.png

1 - инициализации переменной var_4h (i = 3)
2 - выполнения инструкций (add, printf)

Вот этот дизасcемблированный код довольно сложно быстро отличить от if и вообще понять что и как тут. В режиме графов всё будет более понятно.

Режим графов

1605793750977.png1605793750977.png1605793673414.png1605793673414.png1605793684884.png1605793684884.png1605793691266.png1605793691266.png

Режим графов - ваш друг в дизасcемблировании :)

На этом всё. Рекомендую попробовать самому написать программы на Си, скомпилировать и изучить дизасcемблированный код. Практика и ещё раз практика!

Спасибо за внимание. Не болейте.

Подробнее..

Реверсим и улучшаем SATA контроллер

13.12.2020 18:11:50 | Автор: admin

Вы когда-нибудь задумывались, как много вокруг умной электроники? Куда ни глянь, натыкаешься на устройство, в котором есть микроконтроллер с собственной прошивкой. Фотоаппарат, микроволновка, фонарик... Да даже некоторые USB Type C кабели имеют прошивку! И всё это в теории можно перепрограммировать, переделать, доработать. Вот только как это сделать без документации и исходников? Конечно же реверс-инжинирингом! А давайте-ка очень подробно разберём этот самый процесс реверса, от самой идеи до конечного результата, на каком-нибудь небольшом, но интересном примере!

Идея

Меня давно манила шина PCI Express. Сами посудите - высокие скорости, DMA доступ к памяти компьютера, множество разнообразных устройств и производителей, тонна стандартов и реализаций. Что, если взять некоторое PCI-E устройство и переделать прошивку так, чтобы в процессе работы параллельно ещё и читать/писать ОЗУ компьютера по своему желанию?

Вы скажете - но есть же готовые решения, например, PCI Leech и Thunderclap. Но PCI Leech не умеет работать как "обычное" устройство, а Thunderclap, хоть и имитирует сетевую карту, но базируется на громоздкой и дорогущей FPGA девборде. По моей же задумке, устройство должно корректно определяться драйверами в любой системе и при этом недорого стоить.

Такое устройство можно использовать, к примеру, в качестве аппаратного отладчика x86 - имея возможность чтения и записи ОЗУ, разрабатывать драйвера для BIOS "на коленке" гораздо проще.

Выбираем подопытного

К различным PCI-E устройствам я начал присматриваться ещё несколько лет назад, но большинство из встреченных мной девайсов либо были слишком просты и имели намертво зафиксированную функциональность, и очень небольшие возможности со стороны прошивки:

USB 3.0 контроллер на чипе ASMediaUSB 3.0 контроллер на чипе ASMedia

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

Продвинутый сетевой контроллер FujitsuПродвинутый сетевой контроллер Fujitsu

Мне же требовалось устройство, которое имеет:

  • встроенный мощный микроконтроллер

  • легко перепрограммируемое ПЗУ

  • отладочные интерфейсы (UART, JTAG)

  • встроенную прошивку (а не загружаемую драйвером)

Ну и в добавок, это должно быть широко поддерживаемым всеми системами устройством, например, SATA или USB контроллером. И, как вы уже догадались из названия статьи, недавно я наткнулся на вот такой SATA контроллер:

А именно, меня привлекла крайняя простота контроллера (только проц, да ПЗУ) и радиатор на нём (а значит внутри мощный CPU!). Быстрый поиск по названию чипа ещё больше подогрел к нему интерес. Найденная прошивка для него весила аж 500 КБ, имела признаки ARM кода, не была пожата, и имела достаточно текстовых отладочных строк:

Даже по беглому взгляду на прошивку уже можно сказать, что исследовать её будет несложноДаже по беглому взгляду на прошивку уже можно сказать, что исследовать её будет несложно

И на него была некоторая документация, в которой очень много выводов было не подписано, что давало шанс на наличие отладочных интерфейсов:

Какие подозрительные ряды неиспользуемых пинов, не правда ли?Какие подозрительные ряды неиспользуемых пинов, не правда ли?

В скором времени контроллер был куплен, и я взглянул на него повнимательнее:

Без радиатора контроллер выглядит ещё более простымБез радиатора контроллер выглядит ещё более простым

Контроллер действительно выглядел очень просто и перспективно. Но более подробное изучение даташитов показало, что NC пины - на самом деле NC, и отладки на нём ждать не стоит. Более продвинутый контроллер с поддержкой как SATA, так и IDE, ровно в таком же корпусе имел на этих выводах дополнительные сигналы:

Увы, скорей всего эти выводы не тестовыеУвы, скорей всего эти выводы не тестовые

Но зато я нашёл даташиты на другие очень похожие контроллеры, с явным упоминанием отладки:

То, что производитель просит не подключать TST2-TST6 ну очень намекает на наличие JTAG, а прямое указание UART на TST0 и TST1 (в другом даташите) это уже джекпот. Засим было решено купить 88SE9215 как самый недорогой из доступных, и издеваться уже над ним:

Проверяем работоспособность

И вот объект изучения у нас в руках, первым делом проверяем, что он работает. Это важный момент, именно тут мы устраняем возможные будущие вопросы "Это я сломал или оно и было нерабочим??"

Для этого мне пришлось купить M2 райзер, поскольку единственный PCI Express слот моего ПК занят видеокартой:

Впервые вживую увидел разъём miniUSB 3.0. Солидно!Впервые вживую увидел разъём miniUSB 3.0. Солидно!

В общем, тест прошёл успешно, я даже установил систему на HDD, подключенный к этому контроллеру, всё подцепилось стандартными драйверами:

При запуске ПК мелькает информация о состоянии контроллера, это PCI Option ROM и по идее из этого меню можно что-то настраивать, но мне никак не удалось зайти в настройки:

Чтобы поймать этот момент, пришлось записывать видеоЧтобы поймать этот момент, пришлось записывать видео

Анализируем компоненты

Теперь нужно определить, что с чем соединено, где процессор, а где ПЗУ, и какие компоненты за что отвечают. В нашем случае анализ крайне простой, особенно учитывая наличие даташитов:

Да, простые компоненты вроде транзисторов и конденсаторов нас не интересуютДа, простые компоненты вроде транзисторов и конденсаторов нас не интересуютНу а соединения компонентов за нас нарисовал производительНу а соединения компонентов за нас нарисовал производительИсследователям на заметку

Для быстрого осмотра плат я применяю дурацкий, но зачастую действенный подход - если микросхема квардатная и с множеством выводов, то это исполнительный элемент (процессор, FPGA, DSP), а если прямоугольный, то это память. Некоторые производители дополнительно отмечают краской компоненты, содержащие прошивку внутри (очень пригождается, когда ПЗУ находится в микроконтроллере и метка явно об этом говорит).

Что-то первое попавшееся из гугла для примераЧто-то первое попавшееся из гугла для примера

Точное назначение и тип компонентов узнаём по маркировке и даташитам. В нашем случае список компонентов довольно небольшой:

Маркировка

Назначение

Параметры

88SE9215-NAA2

Центральный контроллер

SATA III x4 / PCI-E 2.0 x1

25Q40H

ПЗУ с прошивкой

SPI, 512 КБ

Получаем прошивку

Вот мы убедились, что устройство работает, проанализировали, из каких компонентов оно состоит, теперь следует его "забэкапить", по максимуму извлечь из него данные, чтобы в будущем можно было его к этому же состоянию и вернуть. Прошивка может находиться как во внешнем ПЗУ, так и в самом контроллере, это тоже следует учитывать и внимательно смотреть документацию.

Обычно прошивку можно получить тремя способами, это:

  • обновления от производителя

  • программатором из ПЗУ / контроллера

  • по отладочным интерфейсам из устройства

В сети прошивка для моего контроллера нашлась на сайте station-drivers, впрочем, оттуда же я брал прошивку и для предыдущего купленного контроллера. Несмотря на то, что это .exe файл, 7-zip его прожевал, и внутри обнаружились .bin файлы самой прошивки:

Я скачал все прошивки, что только смог найти в сети, в том числе и для похожих контроллеров, и это тоже дало результаты - в одном из архивов обнаружился Readme с описанием чтения ПЗУ:

Но для этого нужно было готовить загрузочную DOS флешку, поэтому я просто считал ПЗУ программатором, благо здесь стоит типичная SPI Flash. Самый простой способ - клипсой. Цепляемся к ПЗУ и пытаемся читать:

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

И в два клика ПЗУ определилось и прочиталось программатором:

Да, автор программатора очень любит писать "флешь" с мягким знакомДа, автор программатора очень любит писать "флешь" с мягким знаком

Часто контроллер мешает считыванию и приходится выпаивать микросхему или хотя бы подавать питание на устройство. В этом случае повезло, но на всякий случай считываем несколько раз и сравниваем содержимое, чтобы убедиться, что считалось корректно.

Анализируем прошивку

В качестве объекта для анализа я взял скачанную с сайта прошивку (чтобы начать исследование ещё до того, как купленный контроллер приедет ко мне). Первым делом нужно определить структуру образа прошивки. При беглом просмотре сразу видно, что большую часть образа занимает пустое место, а полезные данные начинаются на некоторых адресах, кратных 0x1000. И по адресу 0x2000 видим достаточно интересный набор данных:

Ну вот, за нас даже структуру прошивки расписали! Итак, согласно описанию, в образе мы имеем:

Смещение

Размер

Название

Назначение

0x00000

0x000A0

Autoload

??

0x0C000

0x00834

Loader

Загрузчик

0x20000

0x07800

BIOS

PCI-E Option ROM

0x30000

0x74558

Firmware

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

Мне повезло, что в образе уже было описание разделов. В общем случае, столкнувшись с неизвестным образом, желательно прогнать его через binwalk, эта утилита сразу покажет известные форматы файловых систем и упакованных данных. В этом конкретном случае он нашёл только таблицы коэффициентов для CRC32:

Также полезно разбить прошивку на части и прогнать их через cpu_rec, чтобы знать, с какой программной архитектурой имеем дело. Ещё в начале статьи я предположил, что контроллер должен иметь архитектуру ARM, ну а Option ROM должен быть архитектуры x86, поскольку исполняется он на хосте. Проверим это:

А вот теперь возьмёмся за реверс. Начнем с Loader - он ARM архитектуры и имеет небольшой размер. Скорее всего с него контроллер начинает загрузку. Попробуем его загрузить в дизассемблер:

И видим, что первые инструкции выполняют прыжки на адреса 0xFFFF00**, а это значит, что либо контроллер первым делом при старте прыгает в Mask ROM по адресу 0xFFFF0000 (что сомнительно), либо код Loader сам грузится в этот адрес. Перезагружаем код в дизассемблер по 0xFFFF0000 и действительно, всё корректно парсится:

Функций здесь очень немного (целых четыре), и выяснить что делает код не представляет труда:

  • По адресу 0xF8064000 код обращается к содержимому ПЗУ

  • В ПЗУ происходит поиск сигнатуры "MAGIIMGF"

  • Блок данных с этой сигнатурой парсится и раскидывается по ОЗУ

  • Происходит запуск основной системы прыжком по адресу 0

И да, именно с сигнатуры "MAGIIMGF" начинается Firmware, который мы вырезали ранее! Немного проанализировав загрузчик, получаем вот такой формат блока прошивки:

И теперь мы можем распарсить Firmware и правильно прогрузить его в дизассемблер! Ожидаемо всё идеально прогружается и можно начать анализ основной системы:

Основная задача реверс-инжиниринга - дать имена функциям и понять, что происходит в коде. В этом нам помогут:

  • часто вызываемые стандартные функции (malloc, memset, memcpy)

  • текстовая отладочная информация из прошивки

  • различные уникальные константы

Очень полезно найти функцию Assert, которая выводит в отладочную консоль наименование файла исходников и номер строки с возникшей ошибкой. Например, здесь название файла намекает, что эта функция занимается выделением памяти:

А здесь явно происходит инициализация последовательного порта:

Кстати, немного проанализировав задачи инициализации системы, можно наткнуться на функцию, которая принимает указатель на другую функцию и некоторое имя. Очень похоже на запуск новой задачи! Так и назовём:

В процессе изучения кода задач натыкаемся на интересную функцию, которой часто передаются красивые десятичные значения (100, 1000)... И это очень похоже на функцию задержки исполнения, sleep:

Судя по числам, наш процессор работает на частоте 300 MHz - неплохо так. А ещё из этой функции получаем очень важную информацию - по адресу 0xD0020314 расположен системный таймер. Попытки поискать этот адрес в сети привели к очередному успеху - PDF с детальным описанием другого процессора Marvell:

И вот у нас уже есть какая-никакая, но документация, с помощью которой мы находим то, что искали изначально, а именно функцию отображения адресного пространства ПК в адреса самого контроллера:

Этой функцией контроллер взаимодействует с ПК, к которому он подключен. Достаточно задать нужный адрес в аппаратных регистрах транслятора, и чтение/запись в пределах заданного "окна" по адресу 0x40000000 автоматически приведут к чтению/записи физической памяти ПК!

Теперь осталось найти, как взаимодействовать с прошивкой и подавать команды извне, внедрить в прошивку свой код, который будет лезть в ОЗУ компьютера и ... готово?

Ищем отладочные интерфейсы

Надоело смотреть на скриншоты ассемблерного кода? Возвращаемся к железякам! У нас есть набор тестовых контактов, но мы не знаем, где на них какие отладочные интерфейсы (и есть ли они там вообще). Сначала нужно что? Правильно, подпаяться к ним и вывести на гребёнку:

Импровизированное рабочее место инженераИмпровизированное рабочее место инженера

Производитель совсем не заботится о глазах реверс-инженеров, нет бы хоть пятаки сделать!

Уфф, надо будет купить микроскоп. Паять такую мелкоту - жестьУфф, надо будет купить микроскоп. Паять такую мелкоту - жесть

А дальше - подключаем прибор для поиска JTAG, подрубаем питание и вперед!

Раскрыт главный секрет статьи! PF означает Pin FinderРаскрыт главный секрет статьи! PF означает Pin Finder

Ииии ничего не нашлось:

Ну хоть узнали, какие выводы In, а какие Out...Ну хоть узнали, какие выводы In, а какие Out...

Ожидаемо, подумал я, и вспомнил про пин "TESTMODE" из даташита. Вероятно, его нужно задействовать и тогда.... Что-ж, паяем ещё проводков, ставим подтяжку на TESTMODE:

Иии снова ничего не нашлось, правда, картина немного иная, почему-то всё стало Input:

Теперь почему-то все выводы стали InputТеперь почему-то все выводы стали Input

Да что ж такое, ну ладно JTAG, хотя бы UART найду, подумал я. Подключаем логический анализатор...

А это уже другая коробочка, хоть и выглядит похожеА это уже другая коробочка, хоть и выглядит похоже

И не видим признаков UART ни на одном выводе...

А с поднятым TESTMODE, тест выводы и вовсе колбасит по-черному, и это точно не UART:

Ну, думаю, он просто выключен в прошивке. Нужно его включить! Вношу небольшие изменения и сталкиваюсь с тем, что через клипсу прошивка не хочет записываться. Контроллер питается от программатора и мешает записи. Да что за день-то такой?! Психанул, сделал ПЗУ съёмной:

Когда ничего не получается, уже не до красоты пайкиКогда ничего не получается, уже не до красоты пайки

Но никакие мои попытки включить UART не привели к результатам. Вообще никак, хоть убей. И тут я вспомнил один факт, на который я поначалу не обратил внимания. В прошивке с сайта ARM код был, а в прошивке, что я считал программатором - был только код BIOS. Я посчитал, что добавления ARM кода хватит для того, чтобы контроллер его подхватил и запустился. В общем, возник вопрос, а грузится ли прошивка вообще??

Для этого я подключил логический анализатор прямо к SPI микросхеме:

И проанализировал чтение флешки по SPI:

Здесь уже использую более скоростной анализатор DSLogicЗдесь уже использую более скоростной анализатор DSLogic

И да, читался только неведомый Autoload (причём трижды) и BIOS! Загрузчик и прошивка и не думали грузиться! Я наконец понял, что мой контроллер имеет только функциональность SATA, а прошивка нужна для RAID, который у меня не поддерживается..

В отчаянии, используя SPI логи, я разреверсил формат Autoload, он оказался очень простой:

Сначала сигнатура (0xA5A5A5A5), дальше пары адрес/значение, в конце сигнатура окончания (0xFFFFFFFF). Я подумал, а вдруг эти данные загружаются встроенным микроконтроллером. Тогда, испортив ему стек и адрес возврата из функции, мы перехватим управление и прыгнем на внешний загрузчик. И как только процессор прыгнет на адрес ПЗУ - мы это увидим в логе SPI, произойдёт чтение!

Я собрал Autoload, в котором во все возможные адреса стека записывался адрес ПЗУ (0xF8064000), запихнул на флешку и... Эта зараза прожевала все 128 КБ и не подавилась, дважды!! (первый раз подавилась, похоже, по таймауту)

Скриншот одной из первых попыток, 128 КБ лог не сохранилСкриншот одной из первых попыток, 128 КБ лог не сохранил

Короче, я сдался. Похоже, нет внутри 88se9215 чипа ARM контроллера, и мне нужен 88se9230 чип. Нет слов. Купил 9230 и ... поехали всё заново, что поделать!

Привет, Espada FG-EST11B-1, ты как, живой?Привет, Espada FG-EST11B-1, ты как, живой?Пайка к ногам без микроскопа. Кошмар, надеюсь, это в последний раз.Пайка к ногам без микроскопа. Кошмар, надеюсь, это в последний раз.Выводить так по-полной! 8 тестпинов + 8 GPIOВыводить так по-полной! 8 тестпинов + 8 GPIOДА ЛАДНО ?!ДА ЛАДНО ?!

В общем, да. На 9230 JTAG нашёлся с пол-пинка и даже без TESTMODE пина:

Вывод

Назначение

TST2

TRST

TST3

TMS

TST4

TDI

TST5

TCK

TST6

TDO

Кстати, в этом самом TESTMODE неслабо колбасит вообще все пины (как TST, так и GPIO), чип выводит на каждый из них некоторую частоту:

Вероятно, это действительно тест, но тест именно физических соединенийВероятно, это действительно тест, но тест именно физических соединений

Ладно, у нас теперь хотя бы JTAG есть. Продолжаем эпопею!

Играемся с JTAG

JTAG штука хорошая, с JTAG можно ставить брейкпоинты, читать и писать RAM, в общем, производить полную отладку.

Подключаем JTAG программатор к только что найденным пинам...

Угу, эта коробочка и такое умеетУгу, эта коробочка и такое умеет

Пишем небольшой скрипт для OpenOCD с одним ARM ядром

interface usb-sreset_config trst_pulls_srst trst_push_pulladapter_khz 2000telnet_port 4444gdb_port 3333jtag newtap marvell cpu -irlen 4 -ircapture 0x1 -irmask 0xf -expected-id 0x140003d3target create core feroceon -chain-position marvell.cpu 

И подключаемся к контроллеру:

Тадам, ну неужели у нас есть отладкаТадам, ну неужели у нас есть отладка

Наконец можно проверить, работает ли маппинг памяти! Запускаем UEFI Shell с флешки, читаем адрес 0x100000000:

Теперь по JTAG из контроллера мапим этот же адрес и перезаписываем содержимое:

И проверяем из UEFI Shell:

Оно работает! Мы действительно можем перезаписывать оперативную память ПК из SATA контроллера! Теперь нужно включить UART, чтобы потом по нему принимать управляющие команды. Из реверса я нашёл, что чтобы вывести символ в UART, нужно записать в регистр 0xD0072000. Но как я ни записывал в него, на анализаторе ничего не происходило.

С помощью отладки по JTAG выяснилось, что до инициализации UART дело не доходит:

Регистр 0xD0071054 имеет значение 0x3F, а для инициализации UART нужно значение 0x2F:

Банальное повторение всего того, что делает процедура serial_init, не помогло, UART не завёлся. Пришлось исследовать более детально. На тот же самый регистр 0xD0071054, по которому код решал, включать UART или нет, ссылалась и другая процедура, i2c_init, только она уже реагировала на значение 1:

Но подождите-ка, на нашей плате пины TST0 и TST1 идут как раз к посадочному месту будто бы под i2c флешку:

А значит, должен быть способ переключить регистр 0xD0071000 (точнее его биты 4-5, которые проверяются в этих функциях) хотя бы в положение "1". И на плате, похоже, для этого предусмотрительно оставлены места под резисторы "на землю" возле некоторых GPIO. А ну-ка подтянем GPIO4 на землю (чтобы в 0x3F обнулить бит 4), не зря же мы все GPIO распаяли:

Читаем снова по JTAG и видим, что изменился регистр, правда не тот, но обнулился нужный бит!

Уже хоть что-то! Попадание совсем рядом. Значит это действительно GPIO. А что если включить контроллер с уже замкнутым GPIO? Может, регистр 0x54 показывает состояние GPIO на момент начального включения? Перезагружаем контроллер:

И да, так и оказалось, эти регистры отвечают за конфигурацию и состояние GPIO. Теперь у нас должен быть инициализирован UART, записываем в 0xD0072000 значение 0x30 иии:

Заодно и скорость узнали - 115200Заодно и скорость узнали - 115200

Есть UART! RxD на TST0 и TxD на TST1

Более того, подключаем USB-UART адаптер и видим:

Лог загрузки контроллера из UART
FW version 2.3.0.1041 Built: 18:55:01 Jul  4 2012Interrupt initialization.PUNIT initialization.Scratchpad RAM initialization.SPD RAM @ 0x30060280 with 130432 bytes.Entering dma_init.Leaving dma_init.ide_fifo_init_moduleMagni device id: 9230, rev: 60ide_host_init.IDE host allocates 16896.ahci_host_init.Gigabyte early post spd: 20Console initialization.CORE initialization.Backend CCCS  not supported.Core allocated 26624 bytes.No supported I2C(2).spi_init.SPI device: mxic 25l4005 detected.SPI size:80000SPI block size:1000mv_init_phy_tuning.No phy_tuning this page. Using default value.mv_init_modifing_vdname.No modifying_vdname this page.mv_init_aes_page.raid_inithd_info.flag = 0init req_ct_pool_buf(dtcm), size : 1280init req_ext_ct_pool_buf(sram), size : 5376Free 2080 size, initialization done!Front thread started.Backend thread started.Starting AHCI controller.AHCI controller started.Set BGA Buffer to SRAM memory, 0x1000 bytesraid_init_work_queue, raid_bga_next_handlerport 7 Auto-fetch enableconsole device: 12 mapping to vportid: 7

Теперь осталось запрограммировать команды и реакцию на них

Расширяем функциональность прошивки

Для начала, напишем довольно простой код, который выдаёт обратно всё, что мы ввели в терминал. В качестве буфера используем верхнюю половину буфера, что прошивка использует под sprintf.

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

Небольшой цикл обработки UART
asm("B mainCycle");asm(  "get_char:\n\t"  "LDR R12, =0x30042695\n\t"  "BX R12\n\t"  );asm(  "put_s:\n\t"  "LDR R12, =0x300426A9\n\t"  "BX R12\n\t"  );  void mainCycle (){char c;char * in_buffer = (char *)0x30059CB8;int in_max_size = 0x80;int buf_ptr = 0;while(1){c = get_char();if (c == 0x0A || c == 0x00)continue;if (c != 0x0D && buf_ptr >= in_max_size)continue;in_buffer[buf_ptr++] = c;if (c == 0x0D){put_s(in_buffer);buf_ptr = 0;}}}

Чтобы скомпилировать код под ARM, нам понадобится GCC. Возьмём его из ARM GNU Toolchain

Поскольку нужно скомпилировать совсем сырой бинарь, добавляем опцию -nostdlib, а после сборки преобразуем полученный файл в binary формат:

arm-none-eabi-gcc.exe echo.c -nostdlib -O2 -o echo.out

arm-none-eabi-objcopy.exe -O binary echo.out echo.bin

К счастью, ARM код (по большей части) использует относительные адреса, поэтому с линковкой по нужному адресу не заморачиваемся. По-хорошему нужно явно указать компилятору, по какому адресу будет расположен код директивой -Wl,--section-start=.text=0x30400000 (последнее - требуемый адрес)

А вот и наш скомпилированный модульА вот и наш скомпилированный модуль

Теперь нужно вставить наш код в прошивку. И снова можно пойти правильным путём - расширить секцию кода, поменять размер в заголовках, добавить код в самый конец файла... А можно поступить проще, найти неиспользуемый участок кода в прошивке и перезаписать его. Помните, мы обнаружили инициализацию I2C, которая теперь точно не нужна?

Нам пока хватит, если что, поищем другое место. Вставляем код по адресу 0x30054EB0 и меняем адрес Idle задачи на этот:

А вот чтобы перешивать контроллер программатором (не делать же снова "внешнее ПЗУ") делаем аппаратную хитрость - "изолируем" питание ПЗУ диодами так, чтобы при подключении клипсы питание не уходило на контроллер:

Проводок нужен, чтобы питание на контроллер поступало в обход ПЗУПроводок нужен, чтобы питание на контроллер поступало в обход ПЗУ

Проверяем и вроде бы работает, после ввода строки и нажатия Enter, в терминал выводится наша введенная строка:

Правда, выводится она без перевода строки и только после нажатия Enter...Правда, выводится она без перевода строки и только после нажатия Enter...

Наконец, запрограммируем чтение ОЗУ компьютера! Заодно поправим недостатки, а именно, будем выводить символы, что получили, а ещё избавимся от ассемблерных вставок:

Spoiler
asm("B mainCycle");typedef unsigned int uint32;typedef int (*get_char_func)();typedef void (*put_char_func)(char symbol); typedef void (*hex_dump_func)(char * bufptr, uint32 size); typedef void (*hex_to_int_func)(char * bufptr, uint32 * out_val); typedef char * (*dma_map_func)(uint32 low, uint32 hi, int size, int map_id); typedef void (*dma_unmap_func)(int map_id); void mainCycle (){get_char_func get_char = (get_char_func)0x30042695;put_char_func put_char = (put_char_func)0x30042671;hex_to_int_func hextoint = (hex_to_int_func)0x3007EECD;hex_dump_func hexdump = (hex_dump_func)0x30041FFC;dma_map_func mapdma = (dma_map_func)0x1830;dma_unmap_func unmapdma = (dma_unmap_func)0x18DC;char c;char * in_buffer = (char *)0x30059CB8;int in_max_size = 0x80;int buf_ptr = 0;unsigned int low_addr = 0;unsigned int hi_addr = 0;while(1){c = get_char();if (c == 0x00)continue;if (c != 0x0D && buf_ptr >= in_max_size)continue;in_buffer[buf_ptr++] = c;if (c == 0x0D)c = 0x0A;put_char(c);if (c == 0x0A && buf_ptr == 18){hextoint(in_buffer, &hi_addr);hextoint(in_buffer+9, &low_addr);hexdump(mapdma(low_addr, hi_addr, 0x40, 1), 0x40);unmapdma(1);}if (c == 0x0A)buf_ptr = 0;}}

Теперь при вводе в терминал адреса, контроллер считает нам физическую память ПК:

И это полный успех, можно наконец-то передохнуть!

Заключение

Итак, цель достигнута, мы модифицировали прошивку и можем командами по UART читать физическую память ПК. Конечно, этот пример крайне простой, но при желании можно реализовать абсолютно любую функциональность, ведь внутри стоит мощный и энергоэффективный ARM контроллер на 300 MHz. Например, никто не мешает написать код, который за несколько секунд сдампит всю ОЗУ компьютера на подключенный к контроллеру SSD диск (да да, там есть функции читать/писать диск). Или добавить на плату WiFi модуль, чтобы подавать команды удалённо - на что хватит фантазии!

Подробнее..

Пишем программу для компьютера ALTAIR 8800 1975г выпуска

07.02.2021 16:04:25 | Автор: admin

Привет, Хабр.

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

В те годы компьютеры использовались лишь учеными и инженерами на больших предприятиях. И тут появляется компьютер, купить который может любой желающий. Altair 8800 содержал процессор 8080, 256 байт памяти в первой версии, и имел цену ниже 1000$ - это был первый успешно продаваемый персональный компьютер. Это был тот самый компьютер, для которого Билл Гейтс и Пол Аллен разрабатывали язык BASIC, компьютер благодаря которому сотни и тысячи увлеченных студентов и школьников пришли в мир программирования.

Разумеется, дешевизна имела свою цену - первая версия ALTAIR не имела ни клавиатуры, ни экрана, только панель тумблеров, как на фото. Стало интересно разобраться, как же написать и запустить программу на таком ПК. Для тех, кто хочет узнать как это работает, продолжение под катом.

Код

Первым доступным языком был лишь Ассемблер. ALTAIR мог иметь до 64 КБайт памяти, и процессор 8080, работающий с тактовой частотой 2 МГц.

Чтобы лучше понять как это работает, я написал несложную программу, вычисляющую сумму чисел от 1 до 5:

; Code segment:        ORG    0o       ; Set Program Counter to address 0START:  LDA    I        MOV    B,A      ; RegB => I (1..N)        LDA    STEP        MOV    C,A      ; RegC => STEP (always 1)        LDA    SUM        MOV    D,A      ; RegD => SUM (result)LOOP:     MOV    A,D      ; Move value to Accumulator from Register D (SUM)          ADD    B        ; Add value in Register B to value in Accumulator          MOV    D,A      ; Save result back to D     I          MOV    A,B      ; Mov B to A and decrement it          SUB    C          JZ     PEND     ; If A is zero, the calculation is complete          MOV    B,A      ; If not, continue          JMP    LOOP     PEND:   MOV A,D         ; Save result in SUM value        STA SUMPWAIT:  JMP PWAIT       ; Nothing to do, infinite loop; Data segment:        ORG    200o     ; Set Program Counter to address 200I:      DB     5o       ; Data Byte at address 200 = 5STEP:   DB     1o       ; Data Byte at address 201 = 8 (10 octal)SUM:    DB     0o       ; Data Byte at address 202 = 0        END             ; End

Как можно видеть, я создал в памяти 3 переменные, I, STEP и SUM, которые используются для организации цикла от 1 до 5 с шагом 1. Далее эти значения загружаются в регистры B, C и D, с которыми и производятся арифметические операции. Команда JZ (Jump if Zero) завершает цикл, когда значение регистра А становится равным нулю. Последним шагом мы записываем результат обратно в ячейку памяти с именем SUM. Кстати, для ячеек памяти (data segment) мы указываем адрес первой ячейки, который в нашем случае равен 200o ("o" здесь это octet, 8-ричная система счисления).

В общем, вышенаписанный код делает то же, что в Python можно записать одной строкой:

s = sum(range(6))

Кстати забавно, что на Хабре нет подсветки синтаксиса для ассемблера, видимо было решено что вряд ли это кому-нибудь еще пригодится. Но пойдем дальше.

Компиляция

Строго говоря, никакого компилятора по условию задачи у нас нет, перевести команды в машинные коды придется вручную. К примеру, можно найти описание команды LDA:

Команда "LDA I", где I это ячейка памяти 200о = 80h, будет записана как 3A 80 00.

Следующая команда MOV B,A описывается так:

Получаем код команды 01000111b = 47h

Я все-таки сжульничал и скачал готовый компилятор, взять который можно здесь (полезно также посмотреть пример использования). Но как можно видеть, и на бумажке "скомпилировать" программу вручную вполне реально, хотя конечно, это и заняло бы куда больше времени. В итоге мы должны получить следующий бинарный код:

3a 80 00 47 3a 81 00 4f 3a 82 00 57 7a 80 57 78 91 ca 18 00 47 c3 0c 00 7a 32 82 00 c3 1c 00

Размер программы - 38 байт. Никаких префиксов MZ, переключения страниц памяти и прочего - программа просто выполняется с адреса 0. До того времени, когда чтобы запустить программу на устройстве, нужно подписать её платным серфтификатом, было еще лет 40...

Загрузка и запуск

Для тестирования программы я воспользовался бесплатным симулятором ALTAIR 8800, скачать который можно с github. Его можно запустить прямо в браузере:

Еще раз повторюсь, в первой версии ALTAIR не было ни экрана ни клавиатуры. Все что доступно пользователю - это фактически панель прямого доступа к ячейкам памяти. Например, чтобы загрузить в ячейку памяти с адресом 1 значение 10001000b, нужно выставить соответствующие тумблеры и нажать тумблер DEPOSIT, чтобы ввести следующий код, нужно снова переключить тумблеры и нажать DEPOSIT NEXT. Чтобы прочитать значение ячейки памяти, есть соответствующий тумблер EXAMINE/EXAMINE NEXT. Запустить программу можно нажатием тумблера RUN или SINGLE STEP.

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

Результат выполнения показан на скриншоте. Запускаем программу, затем выбираем тумблерами ячейку памяти 202о = 10000010b, нажимаем тумблер EXAMINE. В ячейках D7..D0 получаем значение 00001111b = 15, что соответствует искомой сумме чисел от 1 до 5:

Заключение

Знакомство с подобными технологиями оказалось довольно любопытно. Также, посмотреть как работает ALTAIR, было интересно и с профессиональной точки зрения - понять, насколько может современный программист писать код под систему почти 50 летней давности. И надо сказать, что это оказалось ничуть не проще. Даже просто умножить 2 числа, если у процессора нет готовой команды для этого, будет поинтереснее любой задачи про гномиков, не говоря уже про написание кода "на бумажке". И чтобы написать интерпретатор BASIC в таких условиях, нужно быть весьма незаурядным программистом.

Интересно, что ALTAIR не забыт и до сих пор. Кроме онлайн-симулятора, можно собрать и "железный", на базе Ардуино:

И наконец, симулятор ALTAIR входит в пакет simh, доступный для Linux, с помощью которого может быть удобно запускать программу по шагам в консоли и просматривать содержимое ячеек памяти:

В общем, желающие могут поэкспериментировать самостоятельно.

Как обычно, всем удачных экспериментов.

Подробнее..

Программистское везение

08.01.2021 14:08:13 | Автор: admin

Более двух десятков лет назад мы разрабатывали устройство, передающее и принимающее данные, используя телевизионный сигнал. Это сейчас все избалованы гигагерцами и гигабайтами, а тогда, имея компьютер типа IBM/PC-AT, на таких скоростях можно было работать только с помощью встроенного контроллера прямого доступа к памяти (ПДП), реализованного в виде микросхем 8237А-5. Это устройство позволяло писать или читать данные, не привлекая центральный процессор.

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

И вот, при заключительном просмотре текста, я вдруг увидел глупую описку в программировании ПДП. Адрес в 16-разрядной 8237А-5 приходилось задавать по частям и при задании номера станицы (т.е. номера куска памяти в 128 Кбайт) вместо команды

OUT DX,AL

было написано

OUT DX,AX

что совершенно бессмысленно, поскольку все используемые в ПДП порты 8-разрядные.
Исправил ляпсус, перетранслировал НЕ работает! Вернул опять бессмысленный AX вместо AL работает. Не может быть!

Начинаю рассуждать. Команда OUT выполняется ведь подобно обычной записи в память, только специальный сигнал INOUT на шине выставляется. Следовательно, если вместо AL я выдаю AX, то это равнозначно тому, что я в один порт (в данном случае 83H) выдаю значение из AL, а в соседний, т.е. получается в 84H значение из AH, а там в этот момент просто ноль.
А что это за порт такой? Вот же таблица всех портов из тогда единственной имевшейся у нас книги Фроловых Аппаратное обеспечение IBM PC:

Назначение и адреса регистров страниц контроллера для IBM AT:
81h Регистр страниц канала 2
82h Регистр страниц канала 3
83h Регистр страниц канала 1
87h Регистр страниц канала 0
89h Регистр страниц канала 6
8Bh Регистр страниц канала 5
8Ah Регистр страниц канала 7
8Fh Регенерация динамической памяти

Нет вообще здесь никакого порта 84H!

Честно говоря, я и сейчас не знаю, что это такое. Может быть, разрешение на работу данного ПДП из всего каскада, а может какая-то особенность конкретной материнской платы или еще одна часть адреса, позволяющая задать его больше чем 16 Мбайт.
Теперь, в эпоху Интернета, доступа к электронным библиотекам и компьютерным форумам, наверное, несложно докопаться, что именно делала запись нуля в неведомый мне порт 84Н. Но это уже неинтересно, поскольку все эти ПДП (DMA) давно ушли в прошлое вместе с шиной ISA и другими древними интерфейсами.

Однако речь о другом. Какое невероятное, прямо волшебное везение для программиста! Кто толкнул меня под руку написать AX вместо AL и именно в этом месте? Ведь ни в одном примере этого не было. Напиши я как положено AL, и мы провозились бы месяцы, подозревая, конечно, неправильную работу нового устройства, а не настройку ПДП. Да и как догадаться, что нужно обратиться к порту, которого даже нет в документации! А сроки были жесткие и не уложись мы в них, проект вообще был бы закрыт с наклеиванием на нас ярлыка неумех и неудачников.

Я стал вспоминать, были ли у меня другие случаи программистского везения. Были, конечно. Например, сляпанный наскоро тест, покрывавший ничтожную часть области возможных значений, вдруг с первого (!) запуска ловил комбинацию параметров, выявлявших тонкую и редко воспроизводимую ошибку.

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

Подробнее..

Микрохирургия ELFа или А что, так можно было?!

12.01.2021 02:13:34 | Автор: admin

На Хабре есть множество статей на тему ELF - что только люди с ними не делают - объясняют их устройство, собирают вручную и автоматически, редактируют, шифруют и еще много чего. Я, в свою очередь, хотел бы поделиться интересным, на мой взгляд, кейсом, познакомившим меня сразу со многими аспектами низкоуровневого программирования на практике:

  • компиляция программ,

  • своеобразный реверс-инжиниринг и портирование runtime-библиотек,

  • устройство исполняемых файлов Windows и Linux,

  • сборка и редактирование таких файлов вручную

Эти и некоторые другие аспекты, а так же множество нестандартных ходов будут затронуты при портировании компилятора. При этом, часть работы, связанная с исполняемыми файлами (и ELF'ами в частности) показалась мне наиболее интересной, поэтому она и станет лейтмотивом статьи.

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

В ходе работы нам понадобятся:

  • компилятор gcc, линкер ld, отладчик gdb

  • утилиты из binutils (readelf, strip, hexdump)

  • базовое понимание устройства PE (Portable executable) и ELF

  • знакомство с Pascal и ассемблером

Компилятор

Компилятор, который мы хотим портировать называется Bero TinyPascal Compiler (далее, BTPC) был написан в 2016 году немецким программистом-музыкантом и энтузиастом Pascal, Бенжамином Россо (Benjamin Rosseaux). Он компилирует подмножество Pascal'я (Delphi 7-XE7 и FreePascal >= 3) в бинарный код Windows x32. К тому же, компилятор самоприменимый.

Проект доступен на github и сейчас репозиторий содержит почти все необходимые для работы над ним инструменты. Однако, на момент выполнения работы, репозиторий выглядел примерно так:

Содержимое проекта: 2 файла с исходниками (btpc.pas на Pascal и rtl.asm на ассемблере) и 1 бинарникСодержимое проекта: 2 файла с исходниками (btpc.pas на Pascal и rtl.asm на ассемблере) и 1 бинарник

Компилятор BTPC (и собранный из него btpc.exe) выполнен в self-contained виде - на все про все - один файл на ~3 тыс. строк паскаля. В нем представлен конвейер со всеми основными шагами компиляции - чтением входного потока, лексическим и синтаксическим анализом, генерацией промежуточного представления, генерацией кода. Поскольку, это не промышленный продукт, то никаких оптимизаций нет.

Начальные фазы разбора Pascal'я, вплоть до построения промежуточного кода, нас не очень интересуют - во-первых, это абсолютно типичные лексер и парсер, а во-вторых, они практически не будут изменены при портировании. Так что, примем их как данность. Наибольший интерес для нас представляет фаза синтеза и "все что после".

Фаза синтеза или умышленное падение с Seg fault

Итог работы компилятора BTPC - исполняемый файл формата PE (Portable Executable, подробнее на Википедии или Mircosoft), т.е. "ELF для Windows". В нем так же, как и в ELF, содержится все необходимое для отображения файла в память и запуска процесса, но организовано это все несколько иначе - отличия в основном касаются размещения в памяти.

Вот, что Википедия говорит о PE в 2х словах

Portable Executable (PE) формат исполняемых файлов, объектного кода и динамических библиотек, используемый в Microsoft Windows. Формат PE представляет собой структуру данных, содержащую всю информацию, необходимую PE-загрузчику для отображения файла в память. Исполняемый код включает в себя ссылки для связывания динамически загружаемых библиотек, таблицы экспорта и импорта API-функций и т.д.

PE представляет собой модифицированную версию COFF формата файла для Unix. Основные конкуренты PE ELF (используемый в Linux и большинстве других версий Unix) и Mach-O (используемый в Mac OS X).

Начнем рассмотрение фазы синтеза с момента, когда фронтенд уже выполнил свою часть, разобрав Pascal, и, когда у нас уже есть промежуточный код программы, байт-код:

  • каждой инструкции байт-кода компилятор ставит в соответствие набор ассемблерных инструкций

  • этот набор ассемблерных инструкций - по сути представление "бизнес-логики" программы в ассемблере

  • бизнес-логика "подкладывается" в некоторый существующий PE-файл

  • PE-файл редактируется, чтобы эта логика не "отвалилась" в процессе.

Концептуально это напоминает работу какого-нибудь фреймворка. Хотя фактически, это классическая библиотека времени выполнения (Runtime Library, RTL).

О Runtime Library в 2х словах

Пример RTL-библиотеки - CRT для C/C++. Функции такой библиотеки отвечают, например, за подготовку и освобождение стека вызовов, инициализацию переменных и т.д. Эти функции, как правило, нельзя вызвать самостоятельно из основной программы.

Самый простой пример - pre-start и post-exit "хуки", срабатывающие до и после вызова main'а вашей программы. Они разбирают аргументы командной строки, вызывают конструкторы статических объектов, вызывают непосредственно main, а затем освобождают память (вызывая деструкторы), когда main возвращает управление.

Наш PE-файл содержит библиотеку RTL с реализацией 9 простых функций:

  • RTLHalt остановка программы

  • RTLWriteChar запись charа в stdout

  • RTLWriteInteger запись integer'а в stdout

  • RTLWriteLn вывод linebreak'а в stdout

  • RTLReadChar сохранение в EAX символа из STDIN

  • RTLReadInteger сохранение в EAX integer'а из STDOUT

  • RTLReadLn пропуск STDIN до EOF (конец файла) или ближайшего перевода строки

  • RTLEOF возвращает в EAX положительное число в случае EOF. 0 в противном случае.

  • RTLEOLN устанавливает 1 в DL, если следующий символ - \n, 0 в противном случае

Технически - это ассемблерный файл с одной лишь секцией кода на пару сотен строк, со следующей структурой:

.ENTRYPOINT  JMP StubEntryPoint# точка входа тут же отправляет нас в конец файлаRTLHalt:  ...                                 # определение фукнции RTLHaltRTLWriteChar:  ...                                 ...                                   # опеределения остальных функций RTLRTLFunctionTable:                     # таблица указателей на функции  DD OFFSET RTLHalt  DD OFFSET RTLWriteChar  DD OFFSET RTLWriteInteger  ...StubEntryPoint:  INVOKE HeapAlloc ...                # резервирование памяти  MOV ESI, OFFSET RTLFunctionTable    # сохранение таблицы функцийProgramEntryPoint:

Теперь, если скомпилировать и запустить приведенный выше RTL, происходит следующее:

  • входная точка программы отправляет нас на метку StubEntryPoint

  • резервируется память для будущей программы

  • таблица функций сохраняется в неизменяемый в процессе работы регистр ESI

  • управление передается программе

И программа падает, ведь метка ProgramEntryPoint указывает в никуда!

Но, можно предположить, что BTPC умеет дописывать код компилируемой программы следом за меткой ProgramEntryPoint.

Предположение

В рамках статьи это кажется очевидным, но в тот момент у нас на руках было всего 2 файла - никаких инструкций или скриптов.

Их взаимодействие было не очень понятным: файлы btpc.pas и rtl.asm не имели никаких упоминаний друг друга и не были никак связаны. Но в файле btpc.pas был blob, который наталкивал на некоторые мысли:

{ фронтенд компилятора }procedure EmitStubCode;begin  OutputCodeDataSize := 0;  OutputCodeString(#77#90#82#195#66#101#82#111#94#102#114#0#80#69#0#0#76#1#1#0#0#0#0#0#...  OutputCodeString(#0#0#0#0#0#0#0#0#0#0#0#0#0#0#16#0#0#0#16#0#0#143##16#0#0#0#0...  OutputCodeString(#0#0#0#0#0#0#0#0#0#0#255#255#255#255#40#16#0#0#53#0#0#0...  OutputCodeString(#101#110#106#97#109#105#110#32#39#66#101#82#111#...  OutputCodeDataSize := 1423;end;{ бэкенд компилятора }

Мысли оказались верными - внутри Pascal-кода компилятора находится сериализованная библиотека, полученная из rtl.asm.

Некоторое время спустя Бенджамин выложил собственные инструменты, которыми он собирал PE и внедрял в компилятор.

И кажется, теперь стала понятна и архитектура компилятора:

  • Библиотека RTL (с реализациями базовых функций) компилируется и дает нам исходный, готовый к работе PE-файл (хоть он и падает, но он чисто технически - рабочий)

  • Этот PE-файл сериализуется в паскаль-строки (те самые, с ограничением в 255 символов в длину)

    • Если кода меньше, то окончание добивается инструкциями-заглушками NOP

  • Эти паскаль-строки помещаются в качестве шаблона (набора констант) непосредственно в компилятор

Таким путем код из rtl.asm попадает в btpc.pasТаким путем код из rtl.asm попадает в btpc.pas

Компилятор BTPC работает следующим образом:

  • Считывает программу из stdin

  • В процессе анализа генерирует промежуточный код программы, байт-код

  • В процессе синтеза, инструкциям байт-кода ставит в соответствие ассемблерные инструкции

  • Дописывает получающиеся инструкции в конец шаблона (сериализованного в паскаль-строки PE-файла)

  • Редактирует шаблон таким образом, чтобы загрузчик "увидел" новый, дописанный в конец, код компилируемой программы

  • Десериализует результат в исполняемый файл

Редактирование PE32

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

Пример правильного патчевания: размер секции кода занимает 4 байта, начиная со 156 байта. При этом во время компиляции было сгенерировано 100 байтов кода. Тогда, необходимо извлечь значение из этих 4х байтов, прибавить к нему 100 и записать обратно.

Стоит отметить, что изначально для компиляции RTL, Бенджамин использовал свой собственный тулчейн Excagena, который во-первых собирает крайне минималистичный выходной PE-файл. Обычно компиляторы/линкеры добавляют отладочную информацию в выходной файл, но Excagena вырезает все, что не влияет напрямую на работу.

Во-вторых, секция кода находится в самом конце полученного PE. То есть, буквально, метка ProgramEntryPoint - последнее, что в нем есть.

Цель всего этого - облегчить последний этап - редактирование PE-файла. И Бенджамину это вполне удалось - от него требуется лишь обновить пару значений в общей структуре:

  • размер кода (OptionalHeader.SizeOfCode)

  • размер секций (SectionTable.VirtualSize)

  • размер образа (OptionalHeader.SizeOfImage), загруженного в память

Так что вся его хирургия выглядит вполне компактно - он считывает предыдущее значение поля, корректирует его с учетом добавленного фрагмента и выравнивания, и записывает обратно по прежнему адресу:

{ Вычисление размера кода } CodeSize := OutputCodeGetInt32($29) + (OutputCodeDataSize - CodeStart);OutputCodePutInt32($29, CodeSize);{ Определение текущего значения выравнивания } SectionAlignment := OutputCodeGetInt32($45);{ Вычисление и редактирование виртуального размера секции с учетом выравнивания }SectionVirtualSize := CodeSize;Value := CodeSize mod SectionAlignment;SectionVirtualSize := SectionVirtualSize + (SectionAlignment - Value);OutputCodePutInt32($10d, SectionVirtualSize);{ Редактирование размера образа при загрузке в память }OutputCodePutInt32($5d, SectionVirtualSize + OutputCodeGetInt32($39));

Хотя общая идея сейчас ясна, магическим $29, $45, $115 в оригинальном коде не помешали бы имена

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

Мы разобрались с устройством компилятора, но наша конечная цель - получить работающий под Linux x64 компилятор. Чтобы ее достичь, понадобится выполнить следующее:

  • переписать RTL библиотеку с использованием системных вызовов Linux x64

  • собрать из нее исходный ELF-файл

  • научиться динамически редактировать ELF-файл, дополненный кодом

Всего-то 3 шага

Первый эта достаточно простой - нужно "лишь" заменить системные вызовы к WinApi на линуксовые.

Но, как всегда, просто было на бумаге. Для примера можно взять вспомогательную функцию чтения символа. Устройство функции достаточно простое - подготовка регистров, обращение к Win32 API, установка еще пары значений:

ReadCharBuffer: DB 0x90ReadCharBytesRead: DB 0x90,0x8D,0x40,0x00ReadCharEx:  PUSHAD  INVOKE  ReadFile, DWORD PTR StdHandleInput, OFFSET ReadCharBuffer, 1, OFFSET ReadCharBytesRead, BYTE 0  TEST    EAX, EAX  SETZ    AL  OR      BYTE PTR IsEOF, AL  CMP     DWORD PTR [ReadCharBytesRead], 0  SETZ    AL  OR      BYTE PTR IsEOF, AL  POPAD  RET

Как упоминалось ранее, секция кода у нас единственная, при этом она и исполняемая и перезаписываемая. Причина в том, что среди кода хранятся и данные - в данном случае "между функций" приталились несколько байт под буферы чтения - ReadCharBuffer и ReadCharBytesRead. Пожалуй, мы знаем причину этого

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

Напомню, что в 64-битных системах системные вызовы совершаются инструкцией syscall (хотя это и не единственный способ). Аргументы передаются через регистры по порядку, RDI, RSI, RDX и так далее. Результат возвращается через RAX.

В x64 исчезли аналоги pusha, pushad, popa, popad. В качестве замены введем свои макросы pushall, popall, работающие аналогично. Поместим эти макросы в секцию неинициализированных данных - bss.

В свою очередь, буферы уходят в секцию данных - data.

В итоге описанная выше функция принимает вид:

.section .data# буфер чтения - в секции данных ReadCharBuffer:    .byte 0x3c.section .text# код - в секции кодаReadCharEx:    PUSHALL# макрос - в секции bss    XORQ    %RAX, %RAX              # syscall #0: read(int fd, void *buf, size_t count)    XORQ    %RDI, %RDI              # fd        : 0 == stdin    MOVQ    $ReadCharBuffer, %RSI   # buf       : ReadCharBuffer    MOVQ    $1, %RDX                # count     : 1 byte    SYSCALL    CMPQ    $0, %RAX    SETZ    %BL                         ORB     %BL, (IsEOF)    POPALL    RET

Следующее, что делал Бенджамин после заполнения таблицы указателей на функции - резервировал стек. В нашем случае для резервирования можно было бы пройти с некоторым шагом по стеку, записывая какие-нибудь значения, пока не встретится Guard Page.

Однако, выяснилось, что Linux для каждого запущенного процесса запоминает список регионов виртуальной памяти и, в случае ошибки обращения, проверяет, не произошел ли Segmentation fault. Если не произошел, значит было обращение к неинициализированной странице, а значит необходимо лишь выделить еще одну страницу виртуальной памяти и добавить к региону. Получатся, резервировать память заранее не требуется. В вот столкнуться с Guard Page в принципе непростая задача. Значит, необходимость в подготовке стека в новой библиотеке отпала, что для нас, конечно же, плюс.

Осталось скомпилировать и слинковать библиотеку:

$ gcc -c rtl64.s$ ld rtl64.o -g --output rtl64

Полученный ELF сериализуем в паскаль-строки и помещаем в BTPC вместо прежнего шаблона. На этом библиотеку можно считать портированной.

Кодогенерация

Как мы уже видели, BTPC в процессе компиляции переводит байт-код в ассемблерные инструкции и конкатенирует их с шаблоном процедурами EmitByte:

procedure OCPopESI;begin  EmitByte($5e);  LastOutputCodeValue := locPopESI;end;procedure OCMovzxEAXAL;begin  EmitByte($0f);   EmitByte($b6);   EmitByte($c0);  LastOutputCodeValue := locMovzxEAXAL;end;

Нам, в свою очередь, придется подбайтово записывать инструкций из набора x64. Это можно сделать двумя способами - либо вручную, либо немного схитрить и упростить себе жизнь

Способ 1 - хардкорный перевод вручную

Переведем вручную инструкцию MOV R10, [R12 + R13], (напомню, квадратные скобки обозначают взятие значения по адресу) воспользовавшись материалами методички "Intel 64 and IA-32 Architectures Developers Manual"

  1. Переводим номера операндов в двоичный вид. Регистр-приемник R10 подходит под формат "R?".

  2. Находим инструкцию MOV в таблице кодов инструкций i8086+. Нужен формат "r/m R?". Итого, получаем 0x8B.

  3. Операнд R10 в двоичном представлении имеет длину 4 бита, а значит не входит в отведенные 3 бита Rn байта ModR/M.

  4. Для представления подобных "больших" регистров понадобится байт "Префикс REX".

  5. Старший бит R10 уходит в бит R префикса REX и расширяет Rn в байте ModR/M.

  6. Согласно таблице ModR/M для 32-разрядных инструкций, биты R/M байта ModR/M = 100, а биты Mod = 00. Это дает дополнительный байт SIB.

  7. Старший бит регистра R12, равный 1, взводит бит X в префиксе REX и расширяет номер индекса в SIB.

  8. Байт SIB выглядит, как [#Base + #Index2^(Scale)]. Базу Base образует регистр R12. 3 младших его бита = 100 и устанавливаются на место 3х битов Base в SIB.

  9. Индекс(Index) в SIB составляют 3 младших бита регистра R13 = 101.

  10. Так как в инструкции лишь сложение (1 регистр + 1 регистр), биты Scale в байте SIB = 00 и дают 2^(Scale) = 2^0 = 1.

  11. Старший бит регистра R13 уходит в бит B префикса REX. Это расширяет набор базового регистра в байте SIB.

  12. Конкатенируем байт префикса REX: "0100" + "W:1" + "R:1" + "X:1" + "B:1" = 01001111, что в hex записи дает 0x4F. Префикс REX найден.

  13. Конкатенируем байт ModR/M: "Mod:00" + "Rn:010" + "R/M:100" = 00010100, что дает байт 0x14.

  14. Конкатенируем наконец байт SIB: "Scale:00" + "Index:101" + "Base:100" = 00101100, что дает байт 0x2C.

Путем конкатенации полученных байтов имеем инструкцию MOV R10, (R12 + R13) в виде 0x4F 0x8B 0x14 0x2C .

Мы уже видели, как происходит хирургическая операция над PE-файлом и делать еще одну операцию, взводя и обнуляя биты, совсем не хочется. Тем более, что таких инструкций в BTPC около 70 штук.

Мы же сразу перейдем к упрощенному способу: надо лишь приготовить все нужные нам инструкции, скомпилировать, а затем дизассемблировать полученный файл и достать нужные нам байты.

Осталось заменить в коде выше значения байтов и на этом этап кодогенерации можно считать завершенным.

Хирургическая операция над ELF'ом

Очевидно, код, который BTPC генерирует сейчас - невидим для исполняемого файла, ведь он дописывается даже не в конец, а "после" файла, то есть, грубо говоря, лежит рядом. При этом генерируемый код конситентен с уже существующим.

Для нас это означает то, что настал шаг, которого мы ждали и боялись - микрохирургия ELF'а - будем пришивать хвост живому эльфу код рабочему ELF'у.

Но для начала оценим, насколько все плохо. Опасение вызывают 2 факта:

  • ELF64 отличается от PE32

  • PE32 был собран вручную, с некоторыми хаками

Воспользуемся readelf'ом и прочтем собранный ранее rtl64:

$ readelf --section-headers rtl64Section Headers:  [Nr] Name              Type             Address           Offset       Size              EntSize          Flags  Link  Info  Align  [ 0]                   NULL             0000000000000000  00000000       0000000000000000  0000000000000000           0     0     0  [ 1] .text             PROGBITS         00000000004000b0  000000b0       0000000000000317  0000000000000000  AX       0     0     1  [ 2] .data             PROGBITS         00000000006003c7  000003c7       00000000000000bf  0000000000000000  WA       0     0     1  [ 3] .symtab           SYMTAB           0000000000000000  00000488       0000000000000408  0000000000000018           4    39     8  [ 4] .strtab           STRTAB           0000000000000000  00000890       0000000000000248  0000000000000000           0     0     1  [ 5] .shstrtab         STRTAB           0000000000000000  00000ad8       0000000000000027  0000000000000000           0     0     1

Чуда не случилось - в нашем ELF'е аж 6 секций!:

  • нулевая пустая секция (согласно стандарту)

  • секция кода text

  • секция данных data

  • секция имен секций shstrtab (Section header string table)

  • таблица символов в symtab

  • метки из ассемблерного ассемблерного кода в strtab

Секция кода отобразилась в начало файлаСекция кода отобразилась в начало файла

Чем ближе секция кода к концу файла, тем лучше для нас. Нам не повезло от слова совсем - секция кода плотно зажата данными и метаданными. Если мы сейчас найдем метку ProgramEntryPoint и пойдем записывать код после нее, мы с большой вероятностью "потеряем пациента".

Откуда столько секций? Мы ведь компилировали лишь код и данные. Получается, они подтянулись на стадии линковки и наш файл стал весить почти 4 Кб, а там ведь всего пара сотен строк кода, не считая внутренностей самого ELF'а.

Попросим ld не включать ничего лишнего (--nostdlib, --strip-all):

$ ld rtl64.o -g --output rtl64-min -nostdlib --strip-all

ELF сжался в 2 раза - теперь весит всего 1.4 Кб. Взглянем еще раз на секции:

$ readelf --section-headers rtl64-minSection Headers:  [Nr] Name              Type             Address           Offset       Size              EntSize          Flags  Link  Info  Align  [ 0]                   NULL             0000000000000000  00000000       0000000000000000  0000000000000000           0     0     0  [ 1] .text             PROGBITS         00000000004000b0  000000b0       0000000000000317  0000000000000000  AX       0     0     1  [ 2] .data             PROGBITS         00000000006003c7  000003c7       00000000000000bf  0000000000000000  WA       0     0     1  [ 3] .shstrtab         STRTAB           0000000000000000  00000486       0000000000000017  0000000000000000           0     0     1

Минус 2 секции. Уже лучше, хотя shstrtab все еще на месте. Вспоминаем, что в комплекте binutils есть утилита strip, которая умеет редактировать исполняемые файлы. Пробуем вырезать shstrtab из нашего файла и снова прочесть оставшиеся секции:

$ strip -R shstrtab rtl64-min$ readelf --section-headers rtl64-minSection Headers:  [Nr] Name              Type             Address           Offset       Size              EntSize          Flags  Link  Info  Align  [ 0]                   NULL             0000000000000000  00000000       0000000000000000  0000000000000000           0     0     0  [ 1] .text             PROGBITS         00000000004000b0  000000b0       0000000000000317  0000000000000000  AX       0     0     1  [ 2] .data             PROGBITS         00000000006003c7  000003c7       00000000000000bf  0000000000000000  WA       0     0     1  [ 3] .shstrtab         STRTAB           0000000000000000  00000486       0000000000000017  0000000000000000           0     0     1

Shstrtab и ныне там. Посмотрим, что же внутри:

$ hexdump -C rtl64-min00000480  40 00 00 00 00 00 00 2e  73 68 73 74 72 74 61 62  |@.......shstrtab|00000490  00 2e 74 65 78 74 00 2e  64 61 74 61 00 00 00 00  |..text..data....|000004a0  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|

В секции с именами секций лежат имена секций! Хотя бы здесь все предсказуемо. Оказывается, что секция .shstrtab всегда генерируется при линковке. Хотя, согласно спецификации, для исполнения она не нужна - то есть, теоретически избавиться от нее возможно.

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

Концептуально, минималистичный скрипт линкера выглядел бы для нас следующим образом:

ENTRY(_start)                           /* точка входа */SECTIONS{    . = 0x4000b0;                       /* адрес размещения секции данных */    .data : { *(.data) }    .bss :  { *(.bss)  *(COMMON) }    . = 0x6000d3;                       /* адрес размещения секции кода */    .text : { *(.text) }                /* секция кода идет последней в файле */}

В реальности скрипт ld занимает две сотни строк - приводить его здесь не имеет смысла. Интересующиеся, однако, могут воспользоваться командой ld --verbose - выглядит этот скрипт довольно пугающе, но найти блок, отвечающий за размещение секции кода все же можно. Перемещаем этот блок в самый конец.

Теперь у нас имеется специальный скрипт линковки. Воспользуемся им и вновь прочтем секции:

$ ld rtl64.o -g --output rtl64-custom-ld -T linkerScript.ld -nostdlib --strip-all$ readelf --section-headers rtl64-custom-ldSection Headers:  [Nr] Name              Type             Address           Offset       Size              EntSize          Flags  Link  Info  Align  [ 0]                   NULL             0000000000000000  00000000       0000000000000000  0000000000000000           0     0     0  [ 1] .data             PROGBITS         00000000006000b0  000000b0       00000000000000bf  0000000000000000  WA       0     0     1  [ 2] .text             PROGBITS         0000000000a00170  00000170       0000000000000317  0000000000000000  AX       0     0     1  [ 3] .shstrtab         STRTAB           0000000000000000  00000487       0000000000000017  0000000000000000           0     0     1

Бинго, скрипт сработал! Секция text, действительно, поменялась местами с секцией data.

Однако, кажется, мы выстрелили себе в ногу. До этого мы могли отлаживать процесс старым добрым gdb, но сейчас из-за того, что мы вырезали и таблицы символов, и метки в коде, и всю отладочную информацию, отлаживать стало просто невозможно.

Хотя ранее по соседству содержались и код и данные, так что некоторые команды из-за этого неверно отображались в gdb и запутаться было крайне легко. Как с этим всем справился Бенджамин, история умалчивает

Мы же, поняв, что погорячились, возвращаем секции symtab и strtab на место, убрав опции линкера. И вот, у нашей реализации появилось преимущество над оригиналом - несравнимо более удобный для отладки код - в нем только актуальные команды (все отображается корректно, т.к. не перемешано с данными) и в нем есть отладочные символы

Тем не менее, код все еще зажат в глубине файла. Кажется, пришло время волевым решением разрезать файл напополам, вставить в середину код и склеить обратно. Мой аналогичный вопрос на stackoverflow был, мягко скажем, воспринят неоднозначно. Но мы будем проделывать этот процесс при каждой компиляции:

Тогда, чтобы добиться работспособности файла, необходимо отредактировать поля следующих структур:

Название поля

Смещение до поля

Elf__hdr.e__shoff

0x28

Text_phdr.p_filesz

sizeof(Elf__hdr) + sizeof(p_hdr) + 0x20

Text_phdr.p_memsz

sizeof(Elf_hdr) + sizeof(p_hdr) + 0x28

Text_shdr.sh_size

Elf_hdr.e_shoff + sizeof(injection) + 2*sizeof(s_hdr) + 0x20

Shstrtab_shdr.sh_offs

Elf_hdr.e_shoff + sizeof(injection) + 3*sizeof(s_hdr) + 0x18

Symtab_shdr.sh_offs

Elf_hdr.e_shoff + sizeof(injection) + 4*sizeof(s_hdr) + 0x18

Strtab_shdr.sh_offs

Elf_hdr.e_shoff + sizeof(injection) + 5*sizeof(s_hdr) + 0x18

Приведенные выше значения надо увеличить на размер сгенерированного кода: sizeof(injection). Значения учитывают порядок размещения секций в файле.

Внимательный читатель заметит, что после того, как мы научились редактировать ELF'ы, можно не утруждаться перемещением секций - ведь теперь, где бы ни хранился код, мы сможем до него добраться, не сломав все вокруг.

И, поскольку теперь мы знаем, где "резать", где "пришивать" и куда "колоть", реализовать эти функции в компиляторе не составит труда.

На этом процесс разработки можно считать завершенным. Теперь, у нас генерируется ELF, мы проводим над ним операцию, которая на проверку оказалась не такой и страшной. Осталось лишь итеративно отладить результаты компиляции - начать с самых простых однострочных программ и постепенно их усложнять. В качестве образца берем исходный компилятор и ожидаем, что для одной и той же программы наш новый компилятор выдаст нам файл с идентичными инструкциями с точностью до разрядности и деталей портирования RTL.

Через несколько итераций получаем работоспособный компилятор.

Еще через несколько - выполняем bootstrapping
# Получаем кросскомпилятор$ btpc.exe < btpc64.pas > btpcCrossWin.exe# Еще шаг и получаем его Linuxверсию$ btpcCrossWin.exe < btpc64.pas > btpc64Linux# Еще один, контрольный шаг, уже на Linux$ btpc64Linux < btpc64.pas > btpc64Check

Компилятор раскручен

И в конце концов получаем самоприменимый компилятор подмножества Pascal, работающий под Linux x64, как и было запланировано.

Заключение

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

  • разобрались с архитектурой компилятора BTPC

  • Выяснили, как устроен процесс от исходного кода на Pascal до исполняемого файла Windows

  • Пересобрали библиотеку времени выполнения (RTL)

  • Собрали из RTL файл ELF и разобрались в его внутреннем устройстве и научились динамически его редактировать

  • Исправили процесс кодогенерации

  • Собрали полученное в рабочий компилятор

Результаты работы доступны в репозитории на github.

Если Вы дочитали до этого момента, то, надеюсь, не жалеете о потраченном времени. Надесюь, что Вы открыли для себя что-то новое. Но, даже если Вас ничего не удивило, возможно вы освежили знания

P.S.

Наверняка, многие уже догадались, что задача походит на учебный проект. Это, действительно, курсовая работа по компиляторам на кафедре ИУ9 в МГТУ им. Баумана, которую я выполнил несколько лет назад. Предстоящий объем работы казался тогда невыполнимым. И чем больше работы было проделано, тем, казалось, больше работы предстоит впереди. Но процесс и достигнутый результат оказались более занимательным, чем шаги в неизвестность (и возгласы "а так можно было?") на каждом этапе работы. И вот, спустя несколько лет, у меня дошли руки написать краткую статью о проделанной тогда работе, попытавшись сохранить интересные и важные "открытия", сделанные в ходе нее, и опустив лишние технические детали ("невошедшее" можно найти в отчете в репозитории выше).

Также, не могу не поблагодарить своего научного руководителя, Александра Коновалова.

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

Ссылки

Подробнее..

О специальных макро в ассемблере

16.01.2021 06:18:31 | Автор: admin

Введение

Много лет назад американским специалистом Гарри Килдэллом (Gary Kildall) в рамках создания системы программирования для персональных компьютеров был разработан транслятор с языка ассемблера для процессора Intel 8086, который он назвал RASM-86 (Relocating ASseMbler). Этот во многом типичный для своего времени продукт имел особенность: он позволял, не меняя транслятора, добавлять описания новых команд процессора с помощью специальных макросредств.

Автор статьи, используя и развивая этот транслятор, успешно применял данные средства по мере появления новых поколений процессоров. Конечно, иногда и сам транслятор требовал ряда доработок, например, при переходе на архитектуру IA-32, а затем и на x86-64 (IA-32e). Тем не менее, изначально заложенная идея позволила легко продолжать эволюцию транслятора до настоящего времени. Некоторые итоги этой работы рассматриваются далее.

Организация генерации команд в трансляторе с ассемблера

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

Транслятор RASM имеет общую внутреннюю таблицу всех возможных команд процессора. Элементы таблицы начинаются с текста мнемоники команды, за которым следует связанный список всех возможных форм данной команды в зависимости от числа, типа и размера ее операндов. Содержимое очередной формы команды состоит из последовательности микрокодов, каждый из которых представляет отдельно обрабатываемую и уже неделимую часть команды.

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

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

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

Макросредства описания команд

По виду специальные макросредства RASM похожи на обычные средства макроподстановки: имеются ключевые слова CodeMacro и EndM, между которыми пишется тело макроопределения. В первой строке пишется имя макро и, возможно, список его параметров. Например:

CodeMacro AAA  DB 37HEndMCodeMacro DIV divisor:EbSEGFIX divisor  DB 6FHEndMCodeMacro OR dst:Re, src:Ee  SEGFIX src  DB 0BH  MODRM dst,srcEndM

Описание формальных параметров

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

Типы формальных параметров:

A  сумматор EAX/AX/ALC  выражение типа метка D  непосредственный операндE  адресное выражение, записанное в регистре или памятиM  адресное выражение, может иметь базовые и индексные регистрыR  один из общих регистровS  сегментный регистрX  прямое обращение к памяти при обмене с сумматором

Размеры формальных параметров

n - длина неопределеннаb  байтw  словоe  двойное словоd  длина при использовании адреса смещение+сегментsb  знаковый байт, расширяемый до словаse  знаковый байт, расширяемый до двойного слова

Примеры описания формальных параметров:

CodeMacro IN dst:Aw, port:Rw (DX)CodeMacro ROR dst:Ee, count:Rb (CL)

Директивы макроопределений

Первоначально все описания команд х86 свелись к нескольким директивам, часть из которых используются редко. Перевод транслятора на архитектуру IA-32 потребовал добавления лишь одной новой директивы управления префиксами размера/адреса 66H/67H, причем, чтобы не вводить новых ключевых слов используется уже имевшаяся директива, но с другой формой параметра.

Директивы DB, DW и DD

Данные директивы в макро почти эквиваленты обычным операторам ассемблера и используются для задания констант и адресов. Эти директивы характерны и для любых других (не специальных) макросредств.

Директива DW используется для задания адреса (4 байта в 32-х разрядном режиме), а директива DD для задания адреса в виде смещение+сегмент. Примеры использования DB, DW и DD:

CodeMacro CLC  DB 0F8HEndMCodeMacro XOR dst:Ee,src:De  SEGFIX dst  DB 81H  MODRM 6,dst  DW srcEndMCodeMacro CALLF label:Cd  DB 9AH  DD labelEndM

Директива адресации MODRM

Это главная из специальных директив, определяющая адресацию архитектуры IA-32. Она определяет и основное отличие данных макросредств от обычных. Именно микрокод, порождаемый этой директивой, указывает транслятору генерировать адресную часть команды, включая и байт режимов адресации и смещение и SIB-байт, если операнды подразумевают это. Директива имеет два параметра. Это или два имени формальных параметров макро или константа-число и имя. Например:

CodeMacro RCR dst:Ee, count:Rb(CL)  SEGFIX dst  DB 0D3H  MODRM 3,dstEndMCodeMacro XOR dst:Re,src:Ee  SEGFIX src  DB 33H  MODRM dst,srcEndM

Директивы определения относительного адреса RELB, RELW

Эти директивы используются для описания команд передачи управления по относительному адресу, занимающему или байт или 4 байта для IA-32. Пример:

CodeMacro LOOP place:Cb  DB 0E2H  RELB placeEndM

Директива задания кодов DBIT

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

CodeMacro DEC dst:Re  DBIT 5(9), 3(dst(0))EndM

Директива формирования префикса сегмента SEGFIX

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

Директива контроля сегментов NOSEGFIX

Директива имеет параметры в виде имени сегментного регистра и имени формального параметра. Она не генерирует кода, а проверяет, что обращение к данному параметру идет с использованием указанного сегментного регистра, иначе сообщает об ошибке. Эта директива требуется лишь в общих формах команд CMPS и MOVS, где один из операндов может адресоваться только через ES.

Данная директива была расширена для управления префиксами размера и адреса 66H/67H. В этом случае в директиве указывается параметр-число: 0 нет префиксов, 1- может быть префикс 66H, 2 может быть префикс 67H, 3 могут быть оба префикса, 4 всегда есть оба, 5 никогда нет 66H, 6 никогда нет 67H и т.п.

Такими простыми средствами удается описать все множество команд IA-32, например:

CodeMacro FLDCW src:Mw  SEGFIX src  DB 0D9H  MODRM 5, srcEndMCodeMacro CMOVAE dst:Re, src:Ee  SEGFIX src  DB 0FH  DB 43H  MODRM dst,srcEndM

Некоторое исключение из стройной системы описаний составляют команды FPU, имеющие операнд в памяти. Для простоты в RASM разрядность таких команд указывается прямо в мнемонике, а не определяется по размеру операнда в памяти. Поэтому в RASM есть, например, команды FIST16, FIST32 и FIST64. Однако на практике, с точки зрения ясности текста, указание разрядности операнда прямо в имени команды FPU оказалось вполне приемлемым.

Создание псевдокоманд с помощью макросредств

Используя возможность добавления новых комбинаций операндов можно конструировать новые команды процессора. Например, команду MOV ECX,10 часто целесообразно заменять двумя командами с более коротким кодом PUSH 10 и POP ECX. А эти две команды можно описать в виде одного макроопределения:

CodeMacro MOVSX dst:Re, src:Dse  NOSEGFIX 6  DB 6AH  DB src  DBIT 5(0BH),3(dst(0))EndM

Такую псевдокоманду можно создать и обычными макросредствами, однако назвать ее именем уже существующей команды MOVSX другие трансляторы (кроме RASM), скорее всего, не позволят. С точки зрения результата, это именно выполнение MOVSX с параметром-константой. Но такой команды в процессоре нет. А с макросредствами RASM можно считать, что на самом деле есть такая формы команды. Тот факт, что реально выполнение идет за два приема и стек меняется, а затем восстанавливается, в большинстве случаев можно не учитывать.

За время использования транслятора RASM накопился ряд таких полезных псевдокоманд, например:

MOV X,Y, где X,Y переменные в памяти;

MOV DS,0 или MOV DS,ES;

Команды PUSH и POP для нескольких регистров сразу, т.е. PUSH EAX,EBX,ECX;

Обращение к портам без указания регистра DX и т.п.

Добавление новых типов команд

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

При этом в транслятор иногда приходится добавлять и новую группу имен специальных регистров этих команд (внутри транслятора имена это просто переименованные числа). Так, коды имен регистров CR0-CR7 являются внутри транслятора RASM числами 10H-17H, коды имен регистров MM0-MM7 числами 40H-47H, коды имен регистров XMM0-XMM7 числами 50H-57H, и т.д. Младшая цифра чисел (всегда 0-7) участвует в генерации кода через директиву MODRM, а собственно значения чисел используются для задания допустимого диапазона в формальных параметрах новых макро.

При поиске подходящих операндов транслятор проверит, что указанный в команде регистр входит в допустимый диапазон и поэтому, например, в командах MMX вместо MM0 нельзя указать чужой регистр CR0 или XMM0.

Часто в новых множествах команд требуется применить директиву NOSEGFIX 5, выключающую обычные правила использования префикса 66H (в зависимости от размера операндов), поскольку в описываемых командах этот префикс используется по-своему.

Тогда, например, для команд из множества MMX описания выглядят так:

CodeMacro MOVQ dst:Rn(40H,47H),src:Mn  NOSEGFIX 5  SEGFIX src  DB 0FH  DB 6FH  MODRM dst,srcEndM

Для команд из множества XMM:

CodeMacro ADDPS dst:Rn(50H,57H),src:Mn  NOSEGFIX 5  SEGFIX src  DB 0FH  DB 58H  MODRM dst,srcEndM

Для команд из множества SSE2:

CodeMacro ADDPD dst:Rn(50H,57H),src:Mn  NOSEGFIX 5  DB 66H  SEGFIX src  DB 0FH  DB 58H  MODRM dst,srcEndM

Для команд из множества 3DNow!:

CodeMacro PFACC dst:Rn(40H,47H),src:Mn  NOSEGFIX 5  SEGFIX src  DB 0FH   DB 0FH  MODRM dst,src  DB 0AEHEndM

Расширение макросредств для x86-64 (IA-32e), AVX-команд и т.д.

Разумеется, расширение транслятора для генерации 64-х разрядных команд потребовало очередных доработок макросредств в виде добавления новой длины операнда Q (64-битный операнд/регистр) и новой директивы REX, формирующей REX-префикс команд. Потребовалось также ввести новые диапазоны регистров, ну и конечно дополнить таблицу служебных слов названиями требуемых регистров, вроде SPL или R14D или YMM15.

Однако все эти доработки потребовали именно расширения, но не кардинальной переделки транслятора.

Использование макросредств для генерации команд процессоров другой архитектуры

При выполнении работ по программированию RISK-процессора микроконтроллера AT90S2313 штатный транслятор с ассемблера показался автору после работы с RASM непривычным и поэтому неудобным. Возникла идея использовать специальные макросредства и для того, чтобы генерировать коды команд RISK-архитектуры в соответствии с документацией Atmel, но при этом остаться в привычной среде RASM. Дело упрощалось тем, что RASM имеет режим формирования загрузочного модуля сразу, без использования редактора связей.

Анализ показал, что имеется лишь три препятствия такого использования RASM для генерирования команд RISK-архитектуры: конфликт мнемоники команды ST с названием регистра FPU, форма записи инкремента указателя типа X+ и другой способ вычисления относительного адреса, делающий директиву RELW неподходящей.

Первые два препятствия были обойдены с помощью введения новых директив в RASM, позволяющих исключать из лексического анализа заданную лексему (в данном случае ST) и разрешать синтаксические конструкции инкремента типа X+.

Для вычисления относительного адреса команд RISK-архитектуры были доработаны директивы макро RELW и DBIT. В директиве RELW стало возможно указывать необязательные дополнительные параметры в виде добавки и сдвига вправо, позволяющие не просто вычислить адрес относительно текущего места, но и пересчитать его к нужному виду прибавлением добавки и сдвигом на заданную величину. При этом новая форма директивы RELW сама уже не генерирует адрес, а запоминает его для последующего использования в директиве DBIT. Доработка DBIT заключалась в возможности использования адреса, вычисленного выше директивой RELW. Для указания такого адреса используется строка S вместо имени параметра.

Такие несложные доработки транслятора повысили универсальность макросредств. Все RISK-команды были легко описаны с их помощью, например:

CODEMACRO RJMP  k:Cw  RELW 2,12,k  DBIT 8('S'(1))  DBIT 4(0CH),4('S'(9))ENDMCODEMACRO LDI   R_d:Db(16,31),K:Dn  DBIT 4(R_d(0)),4(K(0))  DBIT 4(0EH),4(K(4))ENDMCODEMACRO OUT   P:Dn(0,63),R1_r:Db(0,31)  DBIT 4(R1_r(0)),4(P(0))  DBIT 5(17H),2(P(4)),1(R1_r(4))ENDMи т.д.

И наконец стало можно программировать микроконтроллер AT90S2313 на RASM:

                 ;---- ПЕРЕХОД ПО RESET (0) ----0000 02C0  0006  rjmp РЕСТАРТ                 ;---- ПЕРЕХОД ПО INT 0 (1) ----0002 CBC0  019A  rjmp ПРЕРВАНИЕ_ОТ_ГПРРЕСТАРТ:                 ;---- ИНИЦИАЛИЗАЦИЯ СТЕКА ---- 0006 BFED       ldi  СЧ_ТМ,СТЕК   ;КОНЕЦ РАБОЧЕЙ ПАМЯТИ 0008 BDBF       out  SPL,СЧ_ТМ    ;УСТАНОВИЛИ СТЕК                 ;---- ИНИЦИАЦИЯ ВХОДОВ ПОРТА "B" ---- 000A 2FE5       ldi  tmp,РАЗР_B 000C 27BB       out  DDRB,tmp                 ;---- ИНИЦИАЦИЯ ВХОДОВ ПОРТА "D" ---- 000E 22E0       ldi  tmp,РАЗР_D 0010 21BB       out  DDRD,tmp                 ;---- ИНИЦИАЦИЯ RS-232 ---- 0012 24E0       ldi  tmp,4           ;115200 БОД 0014 29B9       out  UBRR,tmp 0016 28E1       ldi  tmp,(1 SHL RXEN) OR (1 SHL TXEN) 0018 2AB9       out  UCR,tmp

Заключение

Предложенная много лет назад идея, образно говоря, оставить открытой дверь в транслятор с ассемблера оказалось конструктивной и легко реализуемой. Почти не меняя транслятора можно оперативно отслеживать постоянно дополняемое множество команд современных процессоров. Легкость реализации обусловлена тем, что все множество команд состоит из небольшого числа базовых элементов.

Подобные средства целесообразно было бы включать вообще во все трансляторы, поскольку они позволяют начинать использование новых возможностей процессоров, не дожидаясь обновления соответствующих средств разработки. Кроме этого, с помощью данных средств можно совершенствовать систему команд добавлением псевдокоманд типа MOVSX EAX,10, практически ничем не отличающихся от реальных команд процессора.

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

Использование RASM сегодня, разумеется, не требует таскать вместе с ним еще и файлы с описаниями команд, накопившиеся за все это время их эволюции. С помощью примитивной программы такие описания переводятся во внутреннее представление и становятся частью общей таблицы после очередной пересборки транслятора.

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

Литература

1. RASM-86 Programmers Guide Digital Research, Сalifornia

http://bitsavers.org/pdf/digitalResearch/pl1/

2. М.Гук, В. Юров Процессоры Pentium 4, Athlon и Duron. СПб.: Из-во Питер, 2001

Подробнее..

Перевод числа в строку с помощью FPU

17.01.2021 06:18:51 | Автор: admin

Введение

Часто требуемое для вывода результатов расчетов преобразование числа с плавающей точкой из формата IEEE-754 в текстовую строку в научной нотации (т.е. с показателем степени E) не является совсем уж тривиальной задачей. В силу обстоятельств автору пришлось самостоятельно изобретать велосипед такого преобразования. Причем хотелось сделать это максимально эффективно, в полной мере используя аппаратные возможности обработки чисел.

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

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

Формализуем задачу. Необходимо перевести восьмибайтовое число из формата IEEE-754 в текстовую строку, где указан знак числа, мантисса заданной длины и десятичный порядок с признаком E и своим знаком.

Так, если задано число 1234.567890, то строка с мантиссой, например, в 16 знаков должна выглядеть как 1.23456788999999E+003. Вместо плюса у мантиссы нужно писать пробел, а сама мантисса должна быть не меньше единицы. Кстати, данный пример иллюстрирует дискретность и приближенность представления чисел в формате IEEE-754: приведенное исходное число не может быть точно представлено как 1.23456789000000E+003, что, может быть, интуитивно ожидалось.

Использование команд FPU для преобразования

На первый взгляд, решение выглядит простым. В устройстве FPU (Floating Point Unit) процессора даже имеются команды, явно предназначенные и для перевода чисел из формата IEEE-754 в текст. Это, во-первых, команда EXTRACT разделения числа на мантиссу и порядок, а во-вторых, команда FBSTP выдающая мантиссу сразу в виде двоично-десятичного значения. Однако при ближайшем рассмотрении эти команды не дают нужного результата.

Например, если применить команду FBSTP к указанному выше числу, то получится 10 байт со значением 35 12 00 00 00 00 00 00 00 00, поскольку нецелочисленные значения сначала округляются согласно полю RC управляющего слова FPU. Это двухбитовое поле RC может принимать 4 значения, но среди них нет режима отключить округление. Поэтому часть мантиссы просто теряется, единственное чего можно добиться режимами округления это еще получить значение 34 12 при округлении к меньшему.

Да и порядок числа, так легко выделяемый командой EXTRACT, является все же степенью двойки, а не десятки, как требуется в данном случае для вывода.

Итак, при использовании возможностей FPU придется решать две задачи.

Во-первых, умножением (или делением) исходного числа на десять нужно перегнать всю мантиссу в целочисленную часть числа, тогда команда FBSTP выдаст, наконец, в двоично-десятичном виде все цифры мантиссы.

Во-вторых, нужно определить такой десятичный порядок (опять-таки умножением или делением на десять), при котором результат попадет в диапазон между 1 и 10. Это нужно для представления числа в виде мантиссы с одной цифрой перед точкой и найденным порядком. Увы, совместить эти две задачи в едином цикле умножения/деления невозможно.

Причем есть и подводный камень в виде значения числа, максимально приближенного к единице, но не равного единице. Циклом деления или умножения легко можно ошибиться в показателе степени и вместо требуемого в данном случае 9.999E-001 получить неправильное значение типа 9.999E+000. К сожалению, при всем богатстве команд FPU обойтись без циклов деления и умножения на десять не удается.

Алгоритм преобразования

При формировании мантиссы для числа типа double умножение или деление на 10 продолжаются до тех пор, пока двоичный порядок числа не достигнет (или не станет больше/меньше) 53, поскольку под мантиссу выделено именно 53 двоичных разряда.

При формировании десятичного порядка для числа типа double умножение или деление на 10 продолжаются до тех пор, пока двоичный порядок разницы числа и 1 не достигнет (или не станет больше/меньше) -53, что означает максимальное приближение к 1. При этом необходимо еще отслеживать частный случай ближайшего к 1 значения из одних девяток, поскольку цикл умножения или деления в этом случае дает одну лишнюю или одну недостающую степень.

Пример реализации преобразования

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

Пара комментариев к тексту. Подпрограмма написана на языке ассемблера RASM, который имеет некоторые особенности.

В частности здесь используются временные метки в виде символа @. Алгоритм их реализации следующий: транслятор имеет внутренний счетчик меток. Когда метка @ встречается в команде перехода, к ней автоматически дописывается значение этого счетчика (т.е. реально создаются метки @0000, @0001 и т.д.). Когда встречается символ @ с двоеточием, к нему также автоматически приписывается значение счетчика и после этого счетчик увеличивается на 1.

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

Кроме этого, для команд FPU в RASM не анализируется размер операнда, он имеется прямо в названиях команд в виде значений 16, 32, 64 или 80.

Но вернемся к задаче. Подпрограмме по адресу в EBX передается исходное восьмибайтовое число в формате IEEE-754 и требуемая длина текстовой строки-результата в AL.

В результате работы число записывается в стек в виде текста в научной нотации и в AL сохраняется длина этой строки. Другие регистры не сохраняются.

С целью разгрузить конвейеры процессора некоторые команды рассыпаны по тексту и не образуют непрерывных логических цепочек, что несколько затрудняет чтение текста. Но таких мест немного. Программа реентерабельна и может быть использована в параллельных вычислениях. При начале работы программы управляющее слово FPU CW=0360.

  1. Сначала в стеке выделяется место для ответа и заполняется нулевым шаблоном, т.е. значением 0.000 E+000.

  2. Затем проверяется знак числа и в зависимости от него формируется мантисса умножением или делением числа на десять.

  3. Командой FBSTP мантисса переписывается в память из FPU в двоично-десятичном виде и ее часть (заданной длины) переносится в ответ.

  4. После этого исходное число опять-таки умножением или делением на десять как можно ближе подгоняется к 1, что позволяет вычислить требуемый десятичный порядок, который и записывается на заранее подготовленное в ответе место.

      CSEGPUBLIC ?QFCMW:;---- ПОДГОТОВКА МЕСТА В СТЕКЕ ----      MOVZX     ECX,AL          ;ЗАДАННАЯ ДЛИНА СТРОКИ      POP       EAX             ;ДОСТАЛИ АДРЕС ВОЗВРАТА      SUB       ESP,ECX         ;ВДЕЛИЛИ МЕСТО В СТЕКЕ      MOV       EDI,ESP         ;ОНО ЖЕ НАЧАЛО СТРОКИ-РЕЗУЛЬТАТА      MOV       ESI,ESP         ;ЗАПОМНИЛИ НАЧАЛО      PUSH      EAX             ;АДРЕС ВОЗВРАТА ВЕРНУЛИ НА МЕСТО      PUSH      ECX             ;ЗАПОМНИЛИ ЗАДАННУЮ ДЛИНУ ОТВЕТА;---- СНАЧАЛА ЗАПОЛНЕНИЕ СТРОКИ-РЕЗУЛЬТАТА НУЛЕВМ ШАБЛОНОМ ----      MOV       EAX,'0.0 '      ;ЗНАК И ПЕРВОЕ ЧИСЛО С ТОЧКОЙ      STOSD      DEC       EDI             ;ОСТАВЛЯЕМ ПЕРВЕ ТРИ СИМВОЛА ШАБЛОНА      SUB       CL,8            ;ВЧЛИ СЛУЖЕБНЕ ЗНАКИ И ПОРЯДОК      MOV       AL,'0'      JBE       @               ;ОШИБКА, НЕТ МЕСТА ПОД МАНТИССУ      REP       STOSB           ;ЗАПОЛНИЛИ МАНТИССУ НУЛЯМИ@:    MOV       EAX,'00+E'      ;ПОКА ДВА НУЛЯ ПОРЯДКА      STOSD                     ;ЗАПИСАЛИ НУЛЕВОЙ ПОРЯДОК      LEA       EBP,[EDI]-2     ;ЗАПОМНИЛИ АДРЕС ПОРЯДКА      FLD64     [EBX]           ;ЗАГРУЗИЛИ ЗАДАННОЕ ЧИСЛО      MOV       B PTR [EDI],'0' ;ПОКА ТРЕТИЙ НУЛЬ ПОРЯДКА;---- ПРОВЕРКА ЗАДАННОГО ЧИСЛА НА ПЛЮС/МИНУС/НОЛЬ ----      FTST                      ;СРАВНИЛИ С НУЛЕМ      FNSTSW    AX      SAHF      JNZ       @               ;ЗАДАННОЕ ЧИСЛО НЕ НОЛЬ;---- СРАЗУ ВХОД С ЧИСТМ НУЛЕМ ----      POP       EAX             ;ВХОД С ЗАДАННОЙ ДЛИНОЙ И НУЛЕМ      FSTP      ST              ;ОЧИСТИЛИ FPU ОТ ИСХОДНОГО ЧИСЛА      RET;---- ОБРАБОТКА ЗНАКА ЗАДАННОГО ЧИСЛА ----@:    JNB       @               ;ЕСЛИ ЧИСЛО ПОЛОЖИТЕЛЬНО      MOV       B PTR [ESI],'-' ;ОТМЕТИЛИ ЗНАК ОТРИЦАТЕЛЬНОГО      FABS                      ;УБРАЛИ ЗНАК В САМОМ ЧИСЛЕ@:    MOV       EDX,OFFSET ДЕСЯТЬ ;ДЕСЯТИЧНАЯ СИСТЕМА;---- ПРОВЕРКА ВЕЛИЧИН ПОРЯДКА ИСХОДНОГО ЧИСЛА ----      FLD       ST              ;РАЗМНОЖИЛИ ПОЛОЖИТЕЛЬНОЕ ЧИСЛО      POP       ECX             ;ОПЯТЬ ДОСТАЛИ ДЛИНУ СТРОКИ      FXTRACT                   ;РАЗДЕЛИЛИ МАНТИССУ И ПОРЯДОК      PUSH      ECX             ;ОПЯТЬ СОХРАНИЛИ ДЛИНУ СТРОКИ      FSTP      ST              ;ВКИНУЛИ МАНТИССУ      SUB       ESP,11          ;ВДЕЛИЛИ МЕСТО ПОД МАНТИССУ КАК BCD      FIST32P   [ESP]           ;ЗАПИСАЛИ ПОРЯДОК      CMP       D PTR [ESP],53  ;ОН УЖЕ 53 РАЗРЯДА ?      JE        M0003           ;ТОГДА МАНТИССА УЖЕ ГОТОВА      JL        M0002           ;ИНАЧЕ ЧИСЛО ОЧЕНЬ МАЛО;---- УМЕНЬШЕНИЕ ПОРЯДКА ЧИСЛА ОТ ОЧЕНЬ БОЛЬШОГО ----M0001:FLD       ST              ;РАЗМНОЖИЛИ ЧИСЛО      FXTRACT                   ;РАЗДЕЛИЛИ МАНТИССУ И ПОРЯДОК      FSTP      ST              ;ВКИНУЛИ МАНТИССУ      FIST32P   [ESP]           ;ДОСТАЛИ ПОРЯДОК      CMP       D PTR [ESP],53  ;УЖЕ 53 РАЗРЯДА ?      JLE       M0003           ;ТОГДА МАНТИССА УЖЕ ГОТОВА      FIDIV16   [EDX]           ;РАЗДЕЛИЛИ ЧИСЛО НА ДЕСЯТЬ      JMPS      M0001           ;ПРОВЕРЯЕМ НОВЙ ПОРЯДОК;---- УВЕЛИЧЕНИЕ ПОРЯДКА ЧИСЛА ОТ ОЧЕНЬ МАЛОГО ----M0002:FLD       ST              ;РАЗМНОЖИЛИ ЧИСЛО      FXTRACT                   ;РАЗДЕЛИЛИ МАНТИССУ И ПОРЯДОК      FSTP      ST              ;ВКИНУЛИ МАНТИССУ      FIST32P   [ESP]           ;ДОСТАЛИ ПОРЯДОК      CMP       D PTR [ESP],53  ;УЖЕ 53 РАЗРЯДА ?      JGE       M0003           ;ТОГДА МАНТИССА УЖЕ ГОТОВА      FIMUL16   [EDX]           ;УМНОЖИЛИ ЧИСЛО НА 10      JMPS      M0002           ;ПРОВЕРЯЕМ НОВЙ ПОРЯДОК;---- ВДАЧА МАНТИСС В ДВОИЧНО-ДЕСЯТИЧНОМ ВИДЕ ----M0003:FBSTP     [ESP]+1         ;ЗАПОМНИЛИ МАНТИССУ КАК BCD-ФОРМАТ;---- ФОРМИРОВАНИЕ ТЕКСТА ИЗ BCD-МАНТИСС ----      LEA       EDI,[ESI]+1     ;АДРЕС ОТВЕТА ПОСЛЕ ЗНАКА      FLD1                      ;ЗАГРУЗИЛИ КОНСТАНТУ 1E0      SUB       CL,7            ;ЗАДАННАЯ ДЛИНА МАНТИСС В ОТВЕТЕ      FLD64     [EBX]           ;ОПЯТЬ ЗАГРУЗИЛИ ИСХОДНОЕ ЧИСЛО      LEA       ESI,[ESP]+10    ;АДРЕС ПЕРВХ ЦИФР МАНТИСС      STD      MOV       DH,0            ;ПОКА НУЛИ НЕ ПИШЕМ      FABS                      ;ЗНАК ИСХОДНОГО ЧИСЛА БОЛЬШЕ НЕ НУЖЕН      MOV       AH,0            ;НАЧИНАЕМ С ЧЕТНОЙ ТЕТРАД      FCOM                      ;ЗАРАНЕЕ СРАВНИВАЕМ ЧИСЛО С 1E0      MOV       BL,1            ;ПОКА ОНО МОЖЕТ БТЬ ВБЛИЗИ +1/-1;---- ЦИКЛ ПЕРЕПИСИ ПО ВСЕМ ТЕТРАДАМ BCD-МАНТИСС ----M0004:XOR       AH,1            ;ОЧЕРЕДНАЯ ТЕТРАДА      JZ        @               ;ЕСЛИ НЕЧЕТНАЯ ТЕТРАДА      LODSB                     ;ДОСТАЛИ БАЙТ МАНТИСС      CMP       ESI,ESP         ;ЗА ПРЕДЕЛАМИ МАНТИСС ?      MOV       DL,AL           ;ДВЕ ТЕТРАД МАНТИСС      JB        M0007           ;ЗАКОНЧИЛИ ВВОД;---- ЧЕТНАЯ ТЕТРАДА ----      SHR       AL,4            ;ЧЕТНАЯ ТЕТРАДА BCD      JMPS      M0005;---- НЕЧЕТНАЯ ТЕТРАДА ----@:    MOV       AL,DL      AND       AL,0FH          ;НЕЧЕТНАЯ ТЕТРАДА BCD;---- ПРОПУСК ЛИДИРУЮЩИХ НУЛЕЙ МАНТИСС ----M0005:OR        DH,AL           ;ЕЩЕ ИДУТ НУЛИ МАНТИСС ?      JNZ       @               ;УЖЕ ИДУТ ЦИФР МАНТИСС      INC       ECX             ;НЕ УЧИТВАЕМ ЭТОТ НОЛЬ В МАНТИССЕ      JMPS      M0006           ;ПРОПУСКАЕМ НЕЗНАЧАЩИЙ НОЛЬ;---- ПРОВЕРКА НА ВСЕ ДЕВЯТКИ (Т.Е. НА ЧИСЛО ВБЛИЗИ +1/-1) ----@:    CMP       AL,9            ;ИДУТ СПЛОШНЕ ДЕВЯТКИ ?      JZ        @               ;ДА, ПРОДОЛЖАЮТСЯ ДЕВЯТКИ      MOV       BL,0            ;УЖЕ НЕ ВБЛИЗИ +1/-1 (НЕ ДЕВЯТКА);---- ПРОПУСК ТОЧКИ, ЗАРАНЕЕ ЗАПИСАННОЙ В ОТВЕТ ----@:    CMP       B PTR [EDI],'.' ;ЗДЕСЬ В ШАБЛОНЕ ТОЧКА ?      JNZ       @      INC       EDI             ;ПРОПУСКАЕМ ТОЧКУ;---- ЗАПИСЬ ОЧЕРЕДНОЙ ЦИФР МАНТИСС КАК ТЕКСТА ----@:    ADD       [EDI],AL        ;ПИШЕМ ОЧЕРЕДНУЮ ЦИФРУ В ОТВЕТ      INC       EDI             ;СЛЕДУЮЩИЙ АДРЕСM0006:LOOP     M0004            ;ЗА СЛЕДУЮЩЕЙ ТЕТРАДОЙM0007:CLD;---- ФОРМИРОВАНИЕ ВЕЛИЧИН ПОРЯДКА ----      MOV       ESI,OFFSET ДЕСЯТЬ      FNSTSW    AX      XOR       EDX,EDX         ;ПОКА ПОРЯДОК НОЛЬ      SAHF      JZ        M0011           ;ЧИСЛО СТРОГО РАВНО 1 - ПОРЯДОК НОЛЬ      JA        M0009           ;ЧИСЛО БОЛЬШЕ 1 - ПОРЯДОК ПОЛОЖИТЕЛЕН      MOV       B PTR [EBP]-1,'-' ;ОТМЕТИЛИ ОТРИЦАТЕЛЬНЙ ПОРЯДОК;---- УВЕЛИЧЕНИЕ ПОРЯДКА ДО ЧИСЛА БОЛЬШЕ 1 ----M0008:FIMUL16   [ESI]           ;УВЕЛИЧИЛИ ЧИСЛО В 10 РАЗ      INC       EDX             ;УВЕЛИЧИЛИ ПОРЯДОК      FCOM                      ;СРАВНИВАЕМ С 1      FNSTSW    AX              ;ЗАПОМНИЛИ РЕЗУЛЬТАТ СРАВНЕНИЯ      SAHF      JZ        M0011           ;СТРОГО РАВНО 1 - НАШЛИ ПОРЯДОК      FLD       ST              ;РАЗМНОЖИЛИ ЧИСЛО      FSUB      ST,ST2          ;РАЗНИЦА С 1      FXTRACT                   ;ДОСТАЛИ МАНТИССУ И ПОРЯДОК      FSTP      ST              ;ВБРОСИЛИ МАНТИССУ      FIST32P   [ESP]           ;ПОРЯДОК РАЗНИЦ      CMP       D PTR [ESP],-53 ;УЖЕ ВПЛОТНУЮ К 1 ?      JLE       M0010           ;ДА, ВПЛОТНУЮ, БЛИЖЕ НЕ БУДЕТ      SAHF                      ;ОПЯТЬ ДОСТАЛИ ФЛАГИ СРАВНЕНИЯ С 1      JA        M0011           ;УЖЕ БОЛЬШЕ - НАШЛИ ПОРЯДОК      JMPS      M0008           ;ПРОДОЛЖАЕМ УМНОЖАТЬ НА 10;---- УМЕНЬШЕНИЕ ПОРЯДКА ДО ЧИСЛА МЕНЬШЕ 1 ----M0009:FIDIV16   [ESI]           ;УМЕНЬШИЛИ ЧИСЛО В 10 РАЗ      INC       EDX             ;УМЕНЬШИЛИ ПОРЯДОК      FCOM                      ;СРАВНИВАЕМ С 1      FNSTSW    AX              ;ЗАПОМНИЛИ РЕЗУЛЬТАТ СРАВНЕНИЯ      SAHF      JZ        M0011           ;СТРОГО РАВНО 1 - НАШЛИ ПОРЯДОК      FLD       ST              ;РАЗМНОЖИЛИ ЧИСЛО      FSUB      ST,ST2          ;РАЗНИЦА С 1      FXTRACT                   ;ДОСТАЛИ МАНТИССУ И ПОРЯДОК      FSTP      ST              ;ВБРОСИЛИ МАНТИССУ      FIST32P   [ESP]           ;ПОРЯДОК РАЗНИЦ      CMP       D PTR [ESP],-53 ;УЖЕ ВПЛОТНУЮ К 1 ?      JG        @               ;ЕЩЕ НЕ ВПЛОТНУЮM0010:INC       EBX             ;ПРИЗНАК НАХОЖДЕНИЯ ВБЛИЗИ 1      JMPS      M0011           ;ПЕРЕСТАЕМ ИСКАТЬ ПОРЯДОК@:    SAHF                      ;ОПЯТЬ ЗАГРУЗИЛИ ФЛАГИ СРАВНЕНИЯ С 1      JNB       M0009           ;ПРОДОЛЖАЕМ ДЕЛИТЬ НА 10      DEC       EDX             ;ЧИСЛО В ОТВЕТЕ ДОЛЖНО БТЬ БОЛЬШЕ 1M0011:ADD       ESP,11          ;ОСВОБОДИЛИ СТЕК ОТ BCD-МАНТИСС;---- КОРРЕКТИРОВКА ПОРЯДКА ВБЛИЗИ +/-1 ----      CMP       BL,2            ;БЛИ ВСЕ ДЕВЯТКИ И ПОЧТИ 1 ?      XCHG      EAX,EDX         ;ДОСТАЛИ ЗНАЧЕНИЕ ПОРЯДКА      JNZ       @               ;НЕТ, ЧИСЛО НЕ ВБЛИЗИ 1      DEC       EAX             ;ВБЛИЗИ 1 СВЕРХУ, ДЕЛАЕМ 0.999...E+000      CMP       B PTR [EBP]-1,'-' ;ЧИСЛО МЕНЬШЕ 1E0 ?      JNZ       @               ;НЕТ, БОЛЬШЕ      INC       EAX             ;ВЕРНУЛИ ПОРЯДОК      INC       EAX             ;ВБЛИЗИ 1 СНИЗУ,  ДЕЛАЕМ 9.999...E-001;---- ЗАПИСЬ СТАРШЕЙ ЦИФР ПОРЯДКА ----@:    PUSH      100      XOR       EDX,EDX      POP       EBX             ;ДЕЛИМ НА КОНСТАНТУ 100      FSTP      ST              ;ОЧИЩАЕМ FPU ОТ ПОИСКА ПОРЯДКА      DIV       EBX             ;ПОЛУЧАЕМ ЧАСТНОЕ - ПЕРВУЮ ЦИФРУ      ADD       [EBP],AL        ;СТАРШАЯ ЦИФРА ПОРЯДКА;---- ЗАПИСЬ ДВУХ МЛАДШИХ ЦИФР ПОРЯДКА ----      MOV       BL,10           ;ДЕЛИМ НА КОНСТАНТУ 10      XCHG      EAX,EDX         ;ОСТАТОК ОТ ДЕЛЕНИЯ НА 100      DIV       BL              ;ЧАСТНОЕ И ОСТАТОК - ДВЕ ЦИФР ПОРЯДКА      FSTP      ST              ;ВБРОСИЛИ КОНСТАНТУ 1 ИЗ FPU      ADD       [EBP]+1,AX      ;ДВЕ ОСТАЛЬНЕ ЦИФР ПОРЯДКА;---- ВХОД С ОТВЕТОМ В СТЕКЕ И ДЛИНОЙ СТРОКИ В AL ----      POP       EAX             ;ДЛИНА СТРОКИ ОТВЕТА В СТЕКЕ      RET      DSEGДЕСЯТЬ DW 10                    ;БАЗА ДЛЯ ПЕРЕВОДА В ДЕСЯТИЧНУЮ СИСТЕМУ

Заключение

Использование команд FPU сделало данную реализацию довольно компактной (326 байт команд) и удовлетворительной по скорости. Например, на компьютере с процессором Intel Core Solo 1.33 GHz сто миллионов преобразований числа 1234.567890 в текст заняли 89 секунд.

Таким образом, скорость работы данной подпрограммы составила 845 преобразований в секунду на каждый мегагерц тактовой частоты процессора. При этом полный отказ от использования команд FPU вряд ли позволит увеличить скорость требуемого преобразования.

Однако эффективность преобразования числа в текст могла бы быть повышена, если бы разработчики FPU предусмотрели режим без округления для команды FBSTP. Это позволило бы при применении этой команды для перевода мантиссы в текст избежать циклов умножения и деления на десять лишь только для получения всех значащих цифр.

Подробнее..

Экстракоды при синтезе программ

23.01.2021 12:21:22 | Автор: admin

Введение

Впервые термин экстракод я услышал еще применительно к командам БЭСМ-6. Сейчас это слово практически не используется, наиболее близкое понятие - системный вызов. Из-за особенностей системы команд БЭСМ-6, те экстракоды действительно больше напоминали дополнительные встроенные инструкции, чем, например, вызов функции в MS-DOS с помощью INT 21H.

Смысл термина экстракод вполне прозрачен: это расширение системы команд для того, чтобы создать из реальной машины виртуальную машину для заданного языка, например, Паскаль-машину или Лисп-машину. А транслятор после этапа анализа исходного текста программы должен провести синтез программы т.е. перевести результаты анализа в команды этой виртуальной машины.

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

Пример использования экстракода при компиляции

Я утверждаю, что понятия языка PL/1 лучше, чем у многих других языков отражены в командах архитектуры IA-32, поскольку в момент разработки процессора 8086 (вторая половина 70-х годов) требования поддержки языков высокого уровня ориентировались на основные языки того времени, среди которых заметное место занимал и PL/1.

Рассмотрим на простом примере, как понятия языка высокого уровня преобразуются в команды IA-32. Например, в PL/1 есть такие объекты, как строки постоянной длины.

В рассматриваемом подмножестве языка длина такой строки не должна превышать число, занимающее байт. Со строками можно работать различным образом, например, сравнивать на равенство или даже на больше-меньше. А в архитектуре IA-32 есть команда CMPSB, которая как раз и сравнивает две цепочки байт в памяти. Казалось бы, и компилятор должен реализовать сравнение строк постоянной длины с помощью одной этой команды (с повторением) REPE CMPSB. Однако на самом деле сравнение реализовано так:

declares1 char(10),s2 char(15);if s1>s2 then BF0A000000          mov    edi,offset S2B00F                mov    al,15BE00000000          mov    esi,offset S1B10A                mov    cl,10E800000000          call   ?SCCCM7505                jbe    @1

Почему же даже для такой простой операции потребовался системный вызов? Дело в том, что команда CMPSB близка к понятию сравнения строк в PL/1, но не полностью совпадает. В языке, если сравниваются две строки разной длины, то сначала более короткая из них дополняется пробелами, а уже после идет само сравнение.

Команда же CMPSB по определению не сравнивает строки разной длины. Зачем же потребовалось вводить в язык такое странное требование, как продолжать сравнивать одну строку, когда другая уже закончилась? Как раз в понятиях языка все логично. Короткая строка дополняется пробелами не только при сравнении, но и при присваивании в более длинную строку. Тогда после такого присваивания и сравнения, получится правильный результат строки равны, хотя они и продолжают иметь разную длину. Если же заканчивать сравнение по исчерпанию одной из строк, можно получить неверный результат сравнения, например, для строк 12345 и 123456, которые, очевидно, не равны.

Вот если бы в процессоре была команда сравнения PL1_CMPSB, которая не только бы выполняла действия, аналогичные CMPSB, но и по значению, скажем, в регистре AL определяла бы, сколько еще байт осталось в более длинной строке и сравнивала бы этот остаток с пробелами, вот тогда компилятор мог бы генерировать сравнение строк в смысле языка PL/1 одной этой командой.

А ведь в примере компилятор как раз это и делает. Только несуществующую команду PL1_CMPSB он заменяет вызовом системной подпрограммы, которую я называю экстракодом. Этот конкретный экстракод имеет странное имя-аббревиатуру ?SCCCM, буквы которой систематизированы и в данном случае показывают, что идет работа со строками (String) и сравниваются (Compare) две строки постоянной длины (Сhar и Char), находящиеся не в стеке, а в статической памяти (Memory). Система при составлении названий экстракодов нужна, поскольку их разновидностей достаточно много.

Отличия экстракодов от системных подпрограмм

Может показаться, что слишком много внимания уделено обычному вызову системной процедуры при генерации программы. Но это не совсем обычный вызов.

Первое отличие экстракодов от обычных вызовов подпрограмм это отсутствие общих правил передачи параметров. В этом они больше похожи на команды IA-32, где тоже нет общих правил, а в каждом случае могут быть свои. Например, просто из вида команды REPE CMPSB, не имеющей параметров, невозможно догадаться, что она одновременно использует и меняет регистры ESI, EDI и ECX.

Конечно, и передача параметров в обычные подпрограммы может быть организована через регистры, а не, например, через стек. Собственно так и сделано в 64-разрядных Windows API, где используются регистры RCX, RDX, R8 и R9. Но там всегда используются эти регистры, в то время как в каждом конкретном экстракоде (как и в каждой конкретной команде) могут быть разные. Поэтому в приведенном выше примере загрузка адресов и длин строк идет в конкретные регистры, сразу, так сказать, на свои места и дополнительных пересылок внутри экстракода уже не требуется.

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

Это позволяет проводить ряд оптимизаций при распределении регистров. Хотя часто и при работе с обычными подпрограммами компилятор также использует некоторую информацию (например, соглашение о не меняющихся внутри подпрограммы ESI и EDI), в случае экстракода работа идет по-другому. Соглашение о ESI и EDI по существу означает запрет на их использование. Точнее, подпрограмма при использовании должна сохранить, а затем восстановить их значения. Внутри же экстракода, как и внутри команды, никаких запретов нет и сохранять регистры не требуется (например, они и являются результатом), а при необходимости это сделает компилятор снаружи вызова.

Наконец, третье отличие от простых вызовов системных подпрограмм это отсутствие общих правил возврата результата. В примере со сравнением строк экстракод возвращает не какое-либо значение в регистре EAX как типичная подпрограмма, а прямо состояние регистра флагов процессора, собственно как это и должна делать команда сравнения CMPSB (так как внутри экстракода ?SCCCM она и является основным действием).

Все эти отличия делают экстракоды более похожими на команды, чем на подпрограммы, а стиль генерации программы экстракодами больше похожим на программирование на ассемблере, хотя формально в программе получается много вызовов системных подпрограмм.

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

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

Да и слово интринсик очень неудобопроизносимое по-русски.

Типы экстракодов при компиляции выражений

Несмотря на то, что входные и выходные параметры экстракодов могут быть организованы совершенно по-разному, при компиляции выражений над объектами, которые не помещаются в обычные регистры (например, строки или даже 8-байтные числа в формате IEEE-754), получается 4 группы схожих экстракодов, в зависимости от операндов или как просто переменных, или как результатов предыдущих вычислений. Например, вот 4 разновидности экстракода операции деления :

declare(x,y,z) float(53);z=x/y;          оба операнда в переменных, экстракод деления ?FDF_Mz=(x+1)/y;      левый операнд в стеке,     экстракод деления ?FDF_Lz=x/(y+1);      правый операнд в стеке,    экстракод деления ?FDF_Rz=(x+1)/(y+1);  оба операнда в стеке,      экстракод деления ?FDF_S

Различаются эти разновидности, естественно, способом загрузки исходных параметров, или через указатель стека ESP (RSP) или через адрес переменной. При генерации выражения компилятор выбирает нужный тип экстракода исходя из числа операций внутреннего представления программы. Если операнд загружается одной единственной операцией значит, он берется из переменной. Если же операций несколько значит, данный операнд сам является результатом вычисления и как результат уже помещен в стек предыдущими сгенерированными командами.

В случае работы с FPU процессора в большинстве случаев используется стек FPU, а не стек самого процессора. Только в этом случае последний из типов экстракода, например, деления ?FDF_S может быть сведен к генерации единственной команды FDIV.

Наибольшее число разновидностей экстракодов получается при работе с комплексными числами, поскольку в этом случае каждая из 4 групп экстракодов делится еще на 3 подгруппы, в зависимости оба ли операнда комплексные или один операнд комплексный, а другой действительный.

Пример оптимизации при использовании экстракодов

Рассмотрим еще один простой пример, часто встречающийся в программах на PL/1:

s=substr(s,2); 

Здесь из строки s выбрасывается первый символ, что обычно используется в цикле обработки символов, пока строка s не станет пустой. В данном случае обрабатывается объект строка переменной длины (ее текущая длина записана первым байтом по адресу строки).

declares char(*) varying;s=substr(s,2);B202                mov    dl,2BE00000000          mov    esi,offset S8BFE                mov    edi,esiE800000000          call   ?VS2ADE800000000          call   ?SMCVF

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

Причина, по которой нельзя выполнить это прямо двумя командами типа LEA ESI,[ESI+EDX]-1 и REP MOVSB по сути та же самая, что и в предыдущем примере: эти команды близки, но не тождественны командам PL/1-машины выделение подстроки и присвоение строке.

Поэтому хотя команды LEA и MOVSB и являются основами соответствующих экстракодов, требуется выполнить еще ряд действий. Например, при выделении подстроки в смысле PL/1 нужно еще убедиться, что строка не пустая и заданное начало подстроки не выходит за ее границу. А при присваивании строки в общем случае может еще потребоваться дописывание пробелами, как и в разобранном выше сравнении строк.

В данном примере видно, как информация о работе экстракодов используется для сокращения команд подготовки параметров. Компилятор знает, что экстракод выделения подстроки ?VS2AD не использует EDI и поэтому сразу загружает его значением, нужным для последующей пересылки. А поскольку это значение сначала совпадает с загрузкой ESI, вместо команды mov edi,offset s используется более короткая команда mov edi,esi.

Экстракод ?VS2AD возвращает результат через регистр ESI (начало подстроки) и AL (длина подстроки), причем всегда еще и CL=AL. Для работы второго экстракода пересылки ?SMCVF нужно установить значения в регистры ESI, EDI и CL, поскольку внутри все сведется, в конце концов, к выполнению команды REP MOVSB.

Но регистр EDI уже установлен, нужное значение в регистре ESI загружено после работы первого экстракода и в CL уже находится длина переписываемой подстроки. Поэтому компилятору можно сразу генерировать вызов второго экстракода без дополнительных загрузок регистров. Получается, что на выполнение оператора отбрасывания первого символа строки в программе потребовалось всего 19 байт кодов.

При этом выполнение требуемых действий осуществляется внутри экстракодов максимально эффективно для архитектуры IA-32, а именно командами загрузки адреса LEA и цепочечной пересылкой MOVS. Кроме этого внутри экстракодов выполняются дополнительные проверки и действия, необходимые для соблюдения требований языка PL/1. Компилятору не нужно каждый раз генерировать эти действия, он имеет дело только с экстракодами, по существу со своей специальной системой команд.

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

Отличие компилятора от транслятора

Немного отвлекаясь от темы статьи, хотелось бы отметить, что я везде использую термин компилятор, а не транслятор.

В определении отличия компилятора от транслятора, на мой взгляд, существует некоторая путаница. Например, в Википедии прямо написано, что компиляция это трансляция программ. Получается, что компилятор и транслятор это одно и то же.

Наверное, правильнее было бы обратить внимание в той же Википедии на статью литературная компиляция, поскольку человек, первый применивший термин компиляция в программировании, конечно же имел ввиду именно ее аналог в литературе. Эта компиляция происходит от латинского слова compilatio - ограбить.

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

Если же чужие коды в принципе не используются это транслятор. Например, не может быть компилятора с ассемблера, поскольку никаких чужих кодов при генерации ассемблерных команд ему использовать не требуется. Но вернемся к экстракодам.

Случай совпадения команд виртуальной и реальной машины

Разумеется, если команды виртуальной машины совпадают с реальными командами, экстракоды исчезают. Например, это происходит при работе с целыми числами:

declare(x,y,z) fixed(31);x=y*10-z/4;6B05040000000A      imul   eax,Y,108B1D08000000        mov    ebx,ZC1FB02              sar    ebx,22BC3                sub    eax,ebxA300000000          mov    X,eax

Здесь все действия однозначно отображаются на соответствующие команды процессора, хотя некоторые встроенные функции, например min и max могут реализовываться и через экстракоды.

Экстракоды и CISC-команды

Анализ применения экстракодов заставил даже по-новому взглянуть на RISC- и CISC-процессоры (т.е. на процессоры с сокращенным и обычным набором команд).

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

Экстракоды при компиляции с языка достаточно высокого уровня это, конечно, аналог CISC-команд. Но даже в простой операции сравнения строк имеющейся CISC-команды CMPSB оказалось недостаточно для полного соблюдения требований языка.

Я бы предпочел, чтобы, например, все действия гипотетической команды PL1_CMPSB выполнялись бы внутри процессора. Это еще усложнило бы CISC-команды, но повысило бы общую производительность программы. Т.е. для упрощения компиляции и снижения числа обращений к памяти не хватает именно CISC- , а не RISC-команд. И с помощью экстракодов приходится создавать все новые и новые CISC-команды.

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

Преимущества такого подхода хорошо видны на примере команды извлечения квадратного корня. Эта команда необходима во многих алгоритмах. Конечно, можно было бы не включать команду FSQRT в FPU процессора, а вычислять ее подпрограммой (например, по методу Ньютона). Однако вряд ли удалось бы реализовать такое вычисление быстрее, чем микропрограммой в кристалле процессора. Притом, что во время (длительного) выполнения команды FSQRT возможно параллельное выполнение других команд, например, целочисленной арифметики, что еще больше увеличивает общую производительность.

Заключение

Технология генерации программ с помощью экстракодов, как и любая другая, имеет и достоинства и недостатки.

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

Использование экстракодов с одной стороны несколько усложняет компилятор, поскольку появляется большое число разновидностей передачи параметров и возврата результатов, что отражает архитектуру процессора, в данном случае команды IA-32. Зато, с другой стороны, весь процесс компиляции упрощается и сводится к единообразным вызовам экстракодов даже в случае достаточно сложных действий, над сложными объектами, описанными в программе.

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

Экстракоды находятся между двумя крайними стилями генерации кодов программы. Одна крайность это генерация в виде байт-кода, когда виртуальная машина совершенно не похожа на реальную, и требуется интерпретатор для исполнения каждой виртуальной команды. Другая крайность это генерация строго в машинные коды, по существу программирование на ассемблере.

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

Подробнее..

Архитектура и программирование микрокалькулятора HP-41

15.01.2021 18:06:16 | Автор: admin
"...Often you need to execute a synthetic two-byte instruction from the keyboard. This can occur during your day-to-day user of the HP-41..."
/ HP-41 Advanced Programming Tips /



Как многие знают, в конце 1980-х в СССР были весьма популярны программируемые микрокалькуляторы, совместимые с Б3-34: МК-54, МК-61, МК-52. Для них создавали программы, игры, исследовали недокументированные возможности, писали статьи. Я и сам через это прошёл в своё время. И вот недавно задумался: а ведь в США тоже должно было быть что-то подобное, близкое по духу именно ко всему тому, что происходило вокруг наших программируемых калькуляторов. И да я оказался прав. Встречайте: HP-41.

Как и Б3-34, HP-41 это программируемый RPN калькулятор (RPN обратная польская запись, вычисления в форме 2 2 +, а не 2 + 2 = ) с похожей идеологией, но значительно более функциональный. Появился он практически в то же время, что и наш Б3-34 1979 год и вскоре стал культовым: для него написано множество программ, книги в том числе, о недокументированных возможностях, и даже до сих пор выпускаются модули расширения. Всего было выпущено полтора миллиона этих калькуляторов.

К схожей судьбе можно добавить что, как наш МК-52 летал на Союзах в качестве резервного вычислительного устройства, так же и HP-41 летал на Шаттлах.

Хотя существует три модификации HP-41 (C, CV, CX), их можно считать полностью совместимыми, так как отличаются они очень незначительно по сути, только объёмом памяти. Калькуляторы HP с другими номерами несовместимы с HP-41, хотя и имеют некоторые общие черты.

Одной из особенностей HP-41 является достаточно редкий для калькуляторов индикатор 14-сегментный. Это позволяет отображать на HP-41 буквы и различные символы что, наряду со звуком и модулями расширения, является большим преимуществом перед Б3-34.

Память HP-41C с точки зрения пользователя 63 регистра, по 7 байт каждый. При этом можно выбирать, сколько используется под программу и сколько под данные. Модули расширения увеличивают доступный объём памяти скажем, 82106A это ещё 64 регистра. Максимум при помощи таких модулей можно получить порядка 2кб, если занять все четыре слота.

Процессор свой, специфический. Чаще всего его называют NUT CPU, хотя это обобщённое название нескольких разных процессоров. Тактовая частота 0.35 мГц. Что касается разрядности то, как это нередко бывает с калькуляторами, в силу специфики архитектуры сложно назвать точную цифру.

Помимо модулей памяти (для HP-41C) существует много других модулей расширения библиотеки программ на ПЗУ, устройство записи/чтения на магнитных картах, считыватель штрихкодов и пр.

Наряду с традиционным, для всех калькуляторов, командным режимом, когда вычисления выполняются непосредственно, существуют три способа программировать HP-41.

Первый и основной из них штатный язык программируемого калькулятора. Идеологически он похож на язык Б3-34 и, хотя и называется FOCAL, к одноимённому языку программирования отношения не имеет расшифровывается слово как Forty One Calculator Language. Команды FOCAL это, по сути, вызовы подпрограмм в машинных кодах что-то вроде инструкций виртуальной машины, заточенной под вычисления, десятичную систему и плавающую точку.

Второй, весьма популярный, способ называется Synthetic programming и является набором недокументированных расширений FOCAL, основанных на использовании уязвимости в прошивке калькулятора, позволяющей создавать новые команды.

Третий, редкий способ программирование непосредственно в машинных кодах микропроцессора калькулятора, называемых MCODE. Этот способ довольно сложен по причинам, о которых будет сказано ниже.

В данной статье я буду рассказывать преимущественно о первом способе, остальных двух коснусь лишь вкратце.

Клавиатура и командный режим


Несмотря на продвинутый алфавитно-цифровой индикатор, клавиатура у калькулятора самая обычная. Что, при огромном числе различных режимов и функций, делает ввод программы и операции с ней весьма утомительным занятием (вполне сопоставимым с таковым для Б3-34 совместимых калькуляторов).

Каждая клавиша имеет три функции (в отдельных случаях больше). Скажем, кнопка 0, помимо цифры 0, предназначена для ввода пробела и числа Пи.
Не все функции доступны через комбинации кнопок некоторые требуется набирать по буквам, в режиме ALPHA. Буквы подписаны над кнопками в порядке ABCDEF....


Надо сказать, что клавиатура сделана очень качественно. Использован типовой для HP железок приём кнопка имеет ось в нижней части и при нажатии поворачивается вокруг неё. Индикатор, несмотря на отсутствие подсветки, также вполне читаемый. Напрягает только медленное обновление изображения (что связано, скорее всего, с медленным процессором).

Интересно, что операторы доступные через комбинацию кнопок можно вводить и побуквенно. К примеру, звуковой сигнал (BEEP) получается нажатием SHIFT 4, но можно нажать кнопку XEQ, затем ALPHA, набрать слово BEEP по буквам, снова нажать ALPHA.

Собственно, XEQ (от слова execute) позволяет сразу выполнить любую встроенную функцию или вызвать имеющуюся в ОЗУ или ПЗУ в том числе, в модуле расширения.

Список всех фактически доступных в калькуляторе функций можно получить через SHIFT CATALOG 3 (управление просмотром через R/S, SST, BST)

Регистры и работа с ними


Ниже речь идёт о регистрах, непосредственно доступных пользователю калькулятора. Это не регистры микропроцессора!

Регистр ALPHA (A) может хранить до 24 символов и его содержимое отображается на экране.
0,1,2,3, регистры данных, могут хранить или одно число или до 6 символов (или до 7 шагов программы)
X,Y,Z,T стековые регистры (на самом деле тоже регистры данных, но организованы в виде стека). X верхний.
L сюда сохраняется последнее, перед изменением, содержимое регистра X
PC текущий шаг программы

На дисплее обычно отображается содержимое X или ALPHA регистра, но можно вывести и другие.

Если просто набрать число на клавиатуре, оно попадает в X регистр (соответственно, он и отображается на экране).
Если набрать на клавиатуре строку символов (после нажатия кнопки ALPHA), то строка помещается в ALPHA регистр (аналогично, он и отображается на экране).

Однако, само нахождение на экране какой-либо информации не означает, что она находится в регистре. Это касается, к примеру, сообщений об ошибках и результатов VIEW. Они как бы закрывают сверху отображаемый регистр. В таких случаях, чтобы убрать сообщение не меняя содержимого регистров, используется клавиша "<-".
В случае же, когда на экране отображено содержимое регистра, та же клавиша "<-" удаляет его содержимое.

Нажатие ENTER проталкивает копию числа в стек. Т.е., если набрать 1 ENTER то 1 окажется и в регистре X и в регистре Y. Если затем набрать 2, то в регистре X будет 2, в регистре Y будет 1.

CLX очищает X, CLA очищает ALPHA

X<>Y меняет местами содержимое X и Y
+, -, *, / совершают операцию над содержимым X и Y и помещают результат в X, при этом то, что было в Y регистре теряется, а то, что было в X помещается в регистр L (при необходимости может быть скопировано обратно в X командной LASTX).

RCL номер_регистра копирует содержимое регистра данных с указанным номером в X (т.е., отображает его)
ARCL номер_регистра присоединяет содержимое регистра данных с указанным номером к регистру ALPHA

ASHF сдвигает содержимое регистра ALPHA влево на 6 символов (первые 6 символов теряются).

Просмотреть содержимое регистра можно и не помещая его в отображаемый X. Для этого используется команда VIEW (для просмотра регистров стека) и AVIEW (для просмотра ALPHA регистра)

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

STO номер_регистра копирует содержимое регистра X в указанный регистр данных
ASTO номер_регистра копирует содержимое регистра ALPHA (только первые 6 символов!) в указанный регистр данных

Чтобы RCL и STO работали с именованными регистрами стека, нужно добавлять ".":STO .Z

Команда SIZE устанавливает количество регистров данных, которые можно использовать (соответственно, при этом увеличивается или уменьшается количество доступных шагов программы):
Чем меньше SIZE, тем больше места под код.

Чтобы очистить всю память, нужно включить калькулятор, удерживая кнопку "<-" и после включения сразу отпустить её. Должно появится сообщение MEMORYLOST (срабатывает не очень стабильно).

Программный режим


Переход в режим программирования (и обратно) по клавише PRGM. В отсутствие программы отображается 00 REG nn. Число nn показывает количество регистров, доступных для шагов программы (см. выше про SIZE). По мере набора программы калькулятор иногда пишет PACKING, пытаясь уплотнить код. Если памяти для очередной команды не хватает, пишет TRY AGAIN.

При вводе программы слева показывается текущий шаг. Один шаг одна команда (неважно, введённая одной клавишей или побуквенно). Но надо учитывать, что один шаг может занимать разный объём памяти мало, если это простая команда типа CLA, и много, если это, скажем, длинная текстовая строка.
Перемещение по шагам SST (вперёд) и BST (назад). Удаление текущего шага "<-".

Программа запускается из командного режима (т.е. надо снова нажать PRGM) клавишей R/S. Ей же и останавливается.

В программном режиме доступны практически все функции имеющиеся в командном. Ввод команд, которые отмечены на клавишах, производится простым нажатием. Остальные команды вводятся через XEQ. Например, чтобы ввести TONE 3 надо нажать XEQ потом нажать ALPHA потом набрать побуквенно TONE снова нажать ALPHA и затем нажать 3.

Стирание программы: CLP метка (стирается от метки до END)

Переход на конкретный шаг: GTO.002 (предварительно надо выйти из программного режима).
Переход на начало: SHIFT RTN

Узнать текущее положение из командного режима можно, нажав и удерживая клавишу R/S либо SST

Метки, на которые потом можно осуществить переход, задаются через LBL метка и бывают двух типов глобальные (имена текстовые, вводятся в режиме ALPHA) и локальные (имена цифровые, либо однобуквенные текстовые). Цифровые занимают меньше памяти.
Переход на метку GTP метка

Полезно всегда ставить метку в первый шаг программы. Это позволяет запускать её, не переходя каждый раз в начало через XEQ метка или GTO метка.

Есть также косвенный переход GTO IND (фанаты HP-41 приводят это как свидетельство того, что машина Turing complete ;).

В конце программы вводится GTO (при этом появляется сообщение PACKING). В этом месте на экране появляется END

Например, программа по умножению любого числа на 2 выглядит так:

LBL "PRGNAME"2*END

Работа с подпрограммами (допускается вложенность до шести):

XEQ 04...LBL 04...подпрограмма...RTN

Условные переходы:

X=Y?21

В этом примере если X равно Y, то в стек (регистр X) помещается 2. В противном случае 1
Другими словами, если условие выполняется, то команда следующая после проверки пропускается.

Циклы

ISG Increment and Skip if Greater
DSE Decrement and Skip if Equal to or less than

Пример

1.00301STO 01LBL 01     BEEPISG 01     GTO 01     

Этот фрагмент можно использовать на собеседовании вместо крышек люков. С вопросом Сколько раз выполнится BEEP и почему?. Правильный ответ 3 раза.

Объяснение: параметры цикла задаются единственным дробным числом, которое помещается в стек. Число имеет формат iiiii.fffcc, где:

iiii начальное, оно же текущее, значение счётчика (индекс),
fff конечное значение
cc шаг

Таким образом, 1.00301 означает счёт от 1 до 3 с шагом 1
Очевидно, такое своеобразное решение позволяет экономить память, хотя читаемость кода, скажем так, слегка страдает.

Немного о выводе на экран строк:

AVIEW выводит на экран регистр ALPHA, VIEW регистр X

Команда APPEND присоединяет указанные символы к строке в ALPHA регистре. Вводится с клавиатуры как SHIFT K, в исходниках это выглядит как >TEXT

Пример:

"HELLO WORLD!";помещаем строку в ALPHA регистрAVIEW ; выводим на экранPSE ; делаем паузуCLD ; очищаем экран

При очистке экрана на него возвращается стандартный символ летящего гуся, показывающий, что программа выполняется. Если на экране текст, гусь не появляется.

Хотя на экране 12 знакомест, максимальная длина строки в одном программном шаге 15. С применением APPEND можно получить 24 (т.е. полная длина регистра ALPHA). При выводе на экран длинной строки, она автоматически скроллится:

"1234567890">"ABCDEFGHIJKLMN"AVIEW

Операции со строками ограничены тремя командами:
ASTO X помещает 6 первых символов из ALPHA в указанный регистр
ARCL X присоединяет в конец ALPHA строку из указанного регистра
ASHF сдвиг ALPHA на 6 символов влево (они теряются)

Ввод данных:

PROMPT выводит на экран содержимое ALPHA регистра и останавливает программу (соответственно, можно что-то ввести и нажать R/S, таким образом продолжив выполнение)
PSE приостанавливает выполнение программы примерно на секунду. При этом, если были нажаты цифры или буквы, пауза продлевается ещё на секунду, а итоговое значение помещается в регистр для дальнейшей обработки.

О звуке:

BEEP проигрывает стандартную последовательность одних и тех же четырёх нот
TONE цифра короткий писк одной из 10 частот (0 самая низкая...9 самая высокая). Частоты выбраны довольно странным образом. По-видимому, это было обусловлено экономией памяти.

Одно из объяснений
The biggest problem is the fact that the high or low time of the signal driving the piezo element has to be a multiple of the instruction cycle time. This cycle time is nominally 155.6uS. So, for example TONE 9 has a three-instruction low and high time, giving a frequency of 1071Hz. TONE 8 has a four-instruction low and high time, giving a frequency of 803Hz. TONE 7 has a five-instruction low and high time, giving a frequency of 643Hz. These tones are individually coded. The remainder of the tones use a common routine to save code space. This common routine is 6+n instruction time long (for each phase of the piezo drive). And n is set by the TONE number as follows: TONE 6 has n=2, TONE 5 has n=4, and so on, down to TONE 0 with n=14. So, you could get better control at the low end of the frequency range, but it would take more code space. I guess that what they came up with was a reasonable compromise.


Периферия


Среди периферийных устройств есть модули расширения памяти, ПЗУ с готовыми программами, устройство чтения/записи магнитной ленты (HP 82161A), магнитных карт (HP82104A), считыватель штрих-кодов, инфракрасный порт, принтер, плоттер, часы, интерфейс HP-IL (через который можно подключить калькулятор к различному оборудованию) и другое.

Модуль чтения/записи на магнитные карты мне достался в комплекте с HP-41. Карты это полоски магнитной плёнки на бумажном основании (в московском метро раньше были похожего типа проездные).


Каждая полоска имеет две дорожки т.е. её можно вставлять левой, либо правой стороной. На каждую сторону влезает 112 байт. Типичная программа занимает несколько карт.
Можно защитить сторону карты от записи, отрезав уголок.
Когда модуль вставлен в калькулятор, задействовано его ПЗУ. Соответственно, в калькуляторе появляется много новых команд для работы с картами. Можно читать и писать программы, регистры, и т.п. Можно даже защитить записываемую программу от просмотра (т.е. можно будет её загрузить и запустить, но нельзя будет увидеть саму программу).

Здесь можно посмотреть, как работает накопитель на магнитных картах.

К сожалению, устройство это ненадёжно и очень прожорливо до батареек (питается от самого калькулятора). Моё не работало жужжало мотором, но не протягивало ленту. Оказалось, что прижимной ролик внутри не просто развалился, а полностью исчез, оставив после себя лишь каплю вязкой грязи. Ролик я поменял на самодельный, но ленту он протягивает явно с трудом нужно точно подгонять диаметр. Проблема настолько типична, что на ebay даже продаются комплекты этих роликов.

Другим интересным устройством. также довольно распространённым среди владельцев HP-41, является считыватель штрих-кодов, который используется, в том числе, для загрузки программ в калькулятор непосредственно из книг. Представьте никаких долгих вбиваний программ с клавиатуры!.. Выглядит это так:



Разработка


Конечно, писать на FOCAL можно и прямо на калькуляторе. Но это довольно утомительно куда удобнее писать программу в текстовом файле. Но с компиляторами и эмуляторами ситуация сложная. Все они довольно странные и не очень стабильно работающие. Из тех, что запускаются под Win10, есть sim41 и v41 (v.7b). Первый запускается только из Visual Runfox, но зато в нём есть отдельный редактор программы (т.е. необязательно вводить и редактировать её с клавиатуры калькулятора).
Второй запускается без прелюдий, заметно лучше эмулирует калькулятор (хотя и не на уровне железа, о чём говорит, например, рассинхронизация звука с кодом), но программу вводить надо либо полностью вручную, либо загружать в виде бинарного .raw, который является не машинным кодом, а бинарным представлением FOCAL). Проблема в том, что для компиляции текстового исходника в raw придётся использовать утилиту HP41UC.EXE, которая запускается только из под DOS. Я использовал vDos с батником, замапив нужный директорий на диск через use f: c:\tmp

Компиляция исходника в бинарник:
hp41uc /t=test.txt /r=test.raw

Декомпиляция бинарника в исходник:
hp41uc /r=text.raw /t=text.txt

Чтобы лучше прочувствовать платформу, я написал небольшое 256 байт интро для DiHALT demo party.


Именно 256 байт просто потому, что больше в калькулятор, даже с установленным модулем расширения ОЗУ, не влезло бы. Понятно, что особых визуальных эффектов ожидать от калькулятора не приходится. Используется вывод различных строк, в том числе автоматический скроллинг длинных строк. Анимация с лицом вывод двух строк в цикле. DTMF имитируется очень условно, музыка тоже совершенно не похожа не оригинал в силу того, что не получится выбрать ни нужной тональности, ни длительности. Тем не менее, на музыку это всё же похоже. В конце используется стандартная особенность калькулятора отображать летящего гуся при занятости процессора и пустом регистре ALPHA.

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

Здесь можно посмотреть оба исходника.

Синтетическое программирование


Synthetic programming методика, основанная на использовании уязвимости, обнаруженной в редакторе программы калькулятора. Обычные штатные инструкции закодированы в памяти калькулятора несколькими байтами. Уязвимость позволяет (после довольно сложной подготовительной процедуры) изменять эти байты, получая новые инструкции с разнообразной функциональностью. К примеру, можно получить от команды TONE больше звуков, чем позволено штатно. Можно вывести на экран больше символов (из прошитого в ПЗУ набора), получать доступ к системным флагам и ряду других полезных вещей. Повторюсь, на практике использовать эту методику сложно и утомительно. Правда, существуют модули с подпрограммами, которые это облегчают.


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

Ещё одна сложность с применением синтетический инструкций заключается в том, что иногда они состоят из не-ASCII символов. Там возникают специфические сложности как с их вводом, так и с печатью такой программы на штатном принтере для HP-41. Да даже просто опубликовать такую программу в книге или журнале целая проблема (обычно рядом с командами пишут пояснения, как их следует понимать). Одно из решений проблемы считывание каждого символа при помощи считывателя штрихкодов из специальной таблицы:



Собственно, synthetic programming очень близко по духу к еггогологии в Б3-34 совместимых калькуляторах. В качестве иллюстрации можно посмотреть вот это письмо.

Люди даже писали на эту тему стихи! (взято из книги Synthetic Programming for the HP41C (W.C.Wickes)

KEYBOARDLOCKY
KEYBOARDLOCKY

'Twas octal, and the synthetic codes
were scanned without a loss.
In and out of PRGM mode,
Byte-jumpers nybbled the CMOS.

Beware 0 STO c, my son,
The MEMORY LOST, the keyboard lock.
Beware the NNN, and shun
The curious phase 1 clock.

He took his black box codes in hand,
Long time the backwards goose he sought;
The secret beast from Aitchpee land--
All searches came to nought.

In demented thought he stood, and then:
The goose, with LCD's alight,
A leap for every LBL 10,
Came honking left-to-right!

STO b! STO d!, and RCL P!
His keyboard went clickety-clack.
With the proper code in number mode
The goose came flapping back.

And hast thou found the phantom fowl?
Come to my arms, my binary boy.
Let Corvallis hear us howl
As we chortle in our joy!

'Twas octal, and the synthetic codes
Were scanned without a loss.
In and out of PRGM mode,
Byte-jumpers nybbled the CMOS.

--Apologies to Lewis Carroll


MCODE


Машинный код, выполняемый непосредственно микропроцессором калькуляторам HP-41 называемый MCODE он в 5-120 раз быстрее, чем стандартный FOCAL.

Для запуска на калькуляторе программа в MCODE должна быть записана в ПЗУ (либо в эмулятор ПЗУ). Существуют специальные модули, позволяющие загружать в себя код по USB или RS232 и даже писать в M-CODE непосредственно на калькуляторе. Обобщённо они называются MLDL и бывают как древними, от самой HP, так и современными.
Из кросс-ассемблеров я нашёл только древний под DOS.

Пара слов об архитектуре процессора. Поскольку он заточен главным образом под математику, есть своя специфика. Основные регистры (а регистры процессора это вовсе не регистры используемые в FOCAL!) A,B,C,N,M 56-разрядные.
Есть также более короткие регистры флагов, клавиатуры, динамика, указателей, 16-разрядный счётчик команд, а также четырёхуровневый стек возвратов (четыре 16-разрядных регистра).

В ПЗУ, связанном с процессором последовательной шиной и где, собственно, находится управляющая программа калькулятора, написанная на MCODE, байты имеют ширину 10 бит. Процессор адресует 64K ПЗУ, из которых 12K занимает операционная система. Что касается ОЗУ, то оно не отображается в адресное пространство и является для процессора периферийным устройством. Байты ОЗУ имеют ширину 8 бит, но логически процессор работает с ОЗУ как с 56-разрядными регистрами.

Поскольку я не писал на MCODE (задушила жаба на $250 за эмулятор ПЗУ), личным опытом программирования в MCODE поделиться не смогу.

Инструкции там вполне традиционны, хотя многие мнемоники довольно специфические. К примеру:

B=A; скопировать содержимое регистра A в BA<>C; поменять местами A и CA=A+B ; сложить A и B и поместить результат в AA=B=C=0; поместить 0 в регистры A,B,CC=0 M; поместить 0 в мантиссу (нибблы 3-12) регистра C?A<C; установить флаг переноса, если A меньше CJC -02; перейти по смещению, если установлен флаг переносаREAD n; поместить содержимое регистра ОЗУ (от 1 до 15) в регистр CPUSH addr; поместить адрес в стек подпрограммGOSUB 815B ; переход к подпрограмме


Примерный MCODE аналог FOCAL команды TONE n:

178 C=REG 5/M; recalls status register M358 ST=C; rightmost byte (nybbles 1 and 0 ) are loaded in status bits (flags 0 to 7)379 *05A NCGO 16DE ; переход на адрес подпрограммы XTONE в ПЗУ

Что касается управления индикатором, то его контроллер не позволяет включать и выключать произвольные сегменты можно выводить лишь существующие в знакогенераторе символы. Это тоже стало причиной, по которой я не стал заморачиваться с эмулятором ПЗУ и программированием в MCODE.

Для вывода символов нужно выбрать индикатор инструкцией процессора PRPH SLCT FD и далее работать с регистрами индикатора через WRIT/READ

Эпилог


Честно говоря, логика работы и система команд калькулятора довольно запутанные. На мой взгляд, для человека, который может освоить подобное нет никакой проблемы просто писать в машинных кодах какого-нибудь простого процессора. В наших Б3-34 совместимых калькуляторов всё, конечно, тоже непросто, но там и возможностей намного меньше, из-за чего не было ощущения такой запутанности.
В принципе, аргумент за нагромождение в HP-41 псевдокода поверх микропроцессора необходимость математических вычислений, поскольку, всё же, именно это должно быть простым для типичного пользователя калькулятора.

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

Я собрал различную документацию по HP41 в один архив, если кому интересно можете скачать (выложил на время, потом уберу).
Подробнее..

Перевод Как игре Pitfall для Atari удалось поместить 255 комнат в картридж на 4КБ

24.05.2021 10:16:39 | Автор: admin
Игры для Atari 2600 разрабатывались в условиях сильных ограничений. Когда Уоррен Робинетт продвигал идею, которая в дальнейшем станет игрой Adventure (в ней нужно исследовать мир из множества комнат и подбирать предметы, которые помогают игроку в пути), ему отказали, потому что посчитали, что её невозможно реализовать. И это было логично. Консоль появилась в конце 70-х; до Робинетта никто ещё не создавал игру с несколькими экранами. Это была эпоха Space Invaders и Pac Man, когда весь игровой мир постоянно находился у игрока перед глазами, поэтому то, что выпущенная в 1980 году Adventure состояла из 30 комнат, было весьма впечатляюще.


Первый экран игры Adventure. Игрок управляет точкой (которую Робинетт называл человеком).

Разработчикам даже пришлось объяснять эту концепцию в руководстве к игре:

Каждая область, показанная на экране телевизора, будет иметь один или несколько барьеров или стен, через которые вы НЕ можете проходить. Также там есть один или несколько проёмов. Чтобы перейти из одной области в соседнюю, выйдите с экрана телевизора через один из проёмов, и на экране появится соседняя область.

Наличие нескольких комнат было довольно большой инновацией, а то, что в Adventure удалось реализовать целых 30 комнат, стало настоящей революцией. Однако в созданной Дэвидом Крэйном и выпущенной в 1983 году Pitfall! таких комнат было 255, и каждая из них была более сложной (с точки зрения графики), чем любая комната Adventure. В статье я расскажу, как этого удалось добиться.

Примечание: в игре Superman было несколько комнат и её выпустили до Adventure, но она создавалась на основе кода Adventure.


Типичный экран Pitfall!

Но чтобы в полной мере осознать сложность реализации этого достижения, стоит рассказать о сложностях, с которыми сталкивались программисты игр для Atari. Сама консоль имела всего 128 байт ОЗУ. Это 1024 бит. Для сравнения: это предложение при кодировании в ASCII занимает больше места, не говоря уже о формате UTF, в котором оно на самом деле закодировано. Этого достаточно, чтобы показать, что в Atari было не так много памяти

Но это ведь не важно, в самом картридже ведь будет достаточно места? Ну, в какой-то мере да. В то время картриджи Atari 2600 обычно содержали 4 КБ ПЗУ, подавляющее большинство которого приходилось занимать кодом игры. Даже если опустить необходимость хранения кода, на каждую комнату можно выделить всего 16 байт, а ведь код всё равно нужно где-то хранить.

Примечание: адресуемое пространство в Atari 2600 составляло всего 2 КБ. Использование 4 КБ было возможно благодаря технике под названием переключение банков (bank switching).

Так как же Крэйн справился с такими ограничениями пространства при создании игры?

Процедурная генерация


Чтобы создать большой мир, не сохраняя много данных, нужно, чтобы его генерировал код.

Однако самая большая проблема такого решения заключается в том, что сгенерированные данные нужно куда-то сохранять. Именно так поступают игры типа Rogue и Minecraft. Они случайным образом генерируют миры, чтобы дать игрокам разнообразие, но после генерации данных они их сохраняют. Ограничения Atari не позволяли воспользоваться этой роскошью.

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

Описание комнаты


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

Байт, в котором хранится схема текущей комнаты, разделён на четыре части:

Биты 0-2: объекты


Первые три бита определяют типы создаваемых объектов. Эта система усложняется двумя аспектами, которые контролируют биты 3-5.

Во-первых, комната может содержать сокровище (если значения битов 3-5 равны 101). Если в ней есть сокровище, то обычный предмет, определяемый битами 0-2, не будет создан, и его место займёт соответствующее сокровище.

Во-вторых, существуют крокодилы (если значения битов 3-5 равны 100), при которых не создаются никакие другие объекты. Кроме того, если значения битов 0-2 равны 010, 011, 110 или 111, то создаётся лоза, позволяющая игроку раскачаться и перепрыгнуть через крокодилов. При всех других значениях лозы не будет и игроку придётся прыгать по головам крокодилов.

Примечание: я всегда записываю первым старший бит, поэтому 100 точнее было бы назвать битами с 5-й по 3-й.

Правила создания предметов и сокровищ:

Биты Предмет Сокровище
000 одно катящееся бревно деньги
001 два катящихся бревна серебро
010 два катящихся бревна золото
011 три катящихся бревна кольцо
100 одно неподвижное бревно деньги
101 три неподвижных бревна серебро
110 огонь золото
111 змея кольцо

(С этим было довольно сложно разобраться.)

Биты 3-5: тип ямы


Биты 3-5 контролируют тип ямы или ям, с которыми столкнётся игрок.

Биты Тип ямы
000 одна дыра в земле
001 три дыры в земле
010 ноль дыр в земле
011 ноль дыр в земле
100 крокодилы в воде
101 подвижная битумная яма с сокровищем
110 подвижная битумная яма
111 подвижные зыбучие пески

Подвижные битумные ямы без сокровища (биты 110) всегда имеют лозу, а если сокровище есть (биты 101), то над битумной ямой не будет лозы (благодарю Майка Ширальди за то, что сообщил мне это).

Биты 6-7: деревья


Биты 6 и 7 определяют паттерн деревьев. Это никак не влияет на геймплей, но даёт игроку ощущение смены локаций. Паттерны деревьев похожи друг на друга, поэтому я не буду вдаваться в подробности, но если вы хотите посмотреть, то они используются в комнатах 1, 2, 3 и 5, и имеют битовые паттерны 11, 10, 00 и 01.

Бит 7: подземная стена


Бит 7 также используется для того, чтобы управлять расположением подземной стены справа или слева. Он не управляет наличием стены, оно определяется в другой части кода, но если стена есть, то значение бита, равное 0, помещает её слева, а значение 1 справа.

Вот так единственный байт определяет структуру текущей комнаты. Но как я говорил, в памяти хранится только текущая комната. Это возможно благодаря способу, которым генерируются комнаты.

Регистры сдвига с линейной обратной связью


Описывающие комнату байты генерируются системой, которую Крэйн назвал полиномным счётчиком (polynomial counter); сегодня мы называем её регистром сдвига с линейной обратной связью (linear feedback shift register, LFSR).

LFSR это способ генерации псевдослучайных чисел по порождающему значению (seed): берётся двоичное число, выполняется логический сдвиг влево или вправо, а затем вычисляется входящий бит через линейную функцию исходных битов. Обычно этой функцией является последовательность из нескольких XOR.

В качестве примера давайте используем LFSR в Pitfall!

Когда игрок начинает игру, байт комнаты имеет шестнадцатеричное значение C4 (11000100 в двоичном виде, 196 в десятичном). Это порождающее значение (seed). Когда игрок переходит на одну комнату вправо, байт сдвигается влево, и младший бит (бит 0) становится XOR битов 3, 4, 5 и 7. Формула такова:

b0 b3' + b4' + b5' + b7'

Где + обозначает XOR, а апостроф бит в предыдущем состоянии. Этот паттерн имеет желательное свойство является LFSR максимальной длины, то есть создаёт каждое сочетание из 8 бит, за исключением одним нулей. Это позволяет миру Pitfall! и содержать максимальное количество комнат, и иметь равную вероятность любой битовой строки (повторюсь, за исключением нулей).

Примечание: + обозначает XOR, потому что сложение mod 2 эквивалентно операции XOR над битами.

То есть когда мы перемещаемся после первой комнаты вправо, байт меняет значение с 11000100 на 10001001. Все биты сдвигаются влево, а затем биту 0 присваивается значение 1, так как 1 = 0 + 0 + 0 + 1.

На ассемблере 6502 это было реализовано так:

; room' = room << 1 | (bit3 + bit4 + bit5 + bit7)LOOP_ROOM:  LDA ROOM  ASL  EOR ROOM  ASL  EOR ROOM  ASL  ASL  EOR ROOM  ASL  ROL ROOM  DEX  BPL LOOP_ROOM

Код целиком можно посмотреть здесь. Данный фрагмент начинается со строки 3012.

ROOM это байт, описывающий текущую комнату. Прежде чем переходить к тому, как он работает, важно обратить внимание на последние две строки и понять, почему всё это находится в цикле. Крэйн хотел, чтобы если Pitfall Harry (главный герой Pitfall!) находится в подземелье, то прохождение через комнату перемещало его через три комнаты. DEX выполняет декремент регистра X, а BPL выполняет ветвление, если результаты предыдущего вычисления не были отрицательными, поэтому Крэйн реализовал это поведение, задавая регистру X значение 2 перед вызовом этой подпроцедуры, если Гарри находится под землёй. В противном случае регистр X имеет значение 0 и зацикленность отсутствует.

Если точнее, ROOM это место в памяти, где находится байт, описывающий комнату.

Вот поэтому это цикл. Остальная часть кода, как часто бывает с ассемблером для Atari, довольно запутанна. Я пишу статью не про ассемблер 6502, поэтому не буду вдаваться в подробности, но, по сути, команды ASL (arithmetic shift left, арифметический сдвиг влево) перемещают биты в правильные позиции, а команды EOR (exclusive or, исключающее ИЛИ) выполняют XOR битов. В конце команда ROL (rotate left, вращение влево) сдвигает байт ROOM влево, записывая в бит 0 бит переноса. Этот бит переноса является результатом предыдущих EOR и ASL. Всё вышеописанное создаёт нужное поведение.

Если мы хотим увидеть каждую комнату, которую генерирует этот код, то можем воспользоваться приведённым ниже кодом ассемблера 6502, который обходит в цикле приведённый выше код, пока байт не вернётся к начальному значению, и сохраняет каждй сгенерированный байт по порядку в адреса с $00 по $FF (с 0 по 255).

  LDA #0          ; initialize address offset to 0  TAXdefine ROOM $00define SEED $C4  LDA #SEED  STA ROOMLOOP_ROOM:        ; do all the LFSR stuff  ASL  EOR ROOM  ASL  EOR ROOM  ASL  ASL  EOR ROOM  ASL  ROL ROOM  LDA ROOM  INX             ; increment address offset  STA $00,X       ; store generated byte  CMP #SEED       ; stop if we complete a cycle  BEQ STOP  JMP LOOP_ROOM   ; get next room byteSTOP:  BRK

Примечание: хороший эмулятор 6502 можно найти здесь.

Но всё это не даёт понимания того, почему дизайн Крэйна был настолько гениальным. Выше описывается происходящее, когда мы идём вправо, но что если мы пойдём влево, чтобы вернуться в предыдущую комнату? Восемь битов, описывающих эту комнату, никогда не сохраняются в памяти; в памяти хранится только текущая комната. Как Pitfall! реализует движение влево? При помощи этого LFSR:

b7 b4' + b5' + b6' + b0'

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

Этот LFSR примечателен тем, что является инверсией предыдущего. Каждый раз, когда игрок идёт влево, этот LFSR отменяет последнее действие, сделанное LFSR, когда игрок шёл вправо. Здесь и далее я буду называть этот LFSR левым LFSR, а предыдущий правым LFSR.

На ассемблере 6502 левый LFSR был реализован следующим образом:

; room' = room >> 1 | ((bit4 + bit5 + bit6 + bit0) * 128)LOOP_ROOM:  LDA ROOM  ASL  EOR ROOM  ASL  EOR ROOM  ASL  ASL  ROL  EOR ROOM  LSR  ROR ROOM  DEX  BPL LOOP_ROOM

Можно заметить, что этот LFSR тоже имеет метку LOOP_ROOM. Её мы взяли из дизассемблированного кода, потому что не знаем, как сам Крэйн назвал этот фрагмент кода, но то, что они имеют одинаковую метку это вполне нормально. Так получилось потому, что команды ветвления (например, BPL) могут выполнять смещение счётчика программ максимум на 255, а эти две метки разделены более чем тысячей команд. Чтобы перемещаться на более дальние расстояния, потребуется или JMP или JSR, то есть команды безусловных переходов.

Давайте минуту поразмыслим над тем, чего добился Крэйн. Он нашёл LFSR, который является инвертируемым и имеет максимальную длину. Это очень впечатляющий программный трюк. Но не верьте мне на слово, что эти два LFSR являются обратными друг другу, я докажу это вам.

Операции LFSR игры Pitfall инвертируемы. Доказательство:


Рассмотрим последовательность из восьми бит B = b7b6b5b4b3b2b1b0. Используем Br для обозначения B, к которому применён правый LFSR и Bl для обозначения B, к которому применён левый LFSR. Мы хотим показать, что Brl = Blr = B. То есть мы хотим показать, что результат применения сначала правого, а потом левого LFSR, или сначала левого, а потом правого, аналогичны отсутствию действий.

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

Чтобы показать, что Brl = B, вспомним, что такое правый LFSR:

b0 b3' + b4' + b5' + b7'

Применив это уравнение к B = b7b6b5b4b3b2b1b0, мы получим следующее:

Бит 7 Бит 6 Бит 5 Бит 4 Бит 3 Бит 2 Бит 1 Бит 0
B b7 b6 b5 b4 b3 b2 b1 b0
Br__ b6 b5 b4 b3 b2 b1 b0 b3 + b4 + b5 + b7

Тогда применив левый LFSR, который, как мы помним, имеет вид:

b7 b4' + b5' + b6' + b0'

к Br, мы получим:

Бит 7 Бит 6 Бит 5 Бит 4 Бит 3 Бит 2 Бит 1 Бит 0
B b7 b6 b5 b4 b3 b2 b1 b0
Br b6 b5 b4 b3 b2 b1 b0 b3 + b4 + b5 + b7
Brl___ 2(b3 + b4 + b5) + b7 = b7 b6 b5 b4 b3 b2 b1 b0

И это подтверждает факт, что Brl = B. Доказательство того, что Blr = B, почти такое же, поэтому я оставлю его в качестве задания для читателя*.

* Всегда ненавидел, когда так писали в учебниках.

Сказанное выше можно подтвердить и простым кодом, поэтому если вы хотите попробовать, то вот маленькая программа на JavaScript. Я добавил в неё и функцию, перечисляющую все комнаты.

Вот так Pitfall! создаёт свой мир. Компактная запись в сочетании с инвертируемым регистром сдвига с линейной обратной связью.



Постскриптум: как я во всём этом разобрался


Можно решить, что информация о столь важной для истории и популярной игры, как Pitfall!, широко распространена и доступна в Интернете. Но на самом деле это не так.

Многие знали, что в игре используется LFSR, но способ его реализации, насколько я знаю, не описан подробно нигде, за исключением самого ассемблера. Но когда я нашёл проанализированную и откомментированную версию ассемблера, то оказалось, что представленное там описание LFSR на самом деле неверно! По крайней мере, неверно описание левого LFSR. Но ошибка была довольно очевидна, поэтому было несложно понять, как он реализуется на самом деле.

Примечание: изначально в комментарии говорилось, что за декрементом LFSR следовал XOR с битом 1 вместо бита 0. Теперь эта ошибка исправлена.

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

Как же я это сделал? Я написал небольшую программу для генерации последовательности LFSR (программу на JavaScript, ссылка на которую дана выше) и сравнил её с комнатами. Проведение этого анализа для бита 7, управляющего стороной экрана, на которой отрисовывалась подземная стена, было простой задачей, как и биты 6 и 7, управляющие деревьями. Но всё остальное оказалось довольно монотонным. Бесценным ресурсом для меня стала эта карта.

Меня удивило, что, насколько я могу судить, мне довелось первым подробно описать способ рендеринга мира в игре Pitfall!, но в то же время я разочарован. Если вы не смотрели этот доклад на GDC о сохранении истории игр, то вам точно стоит это сделать. В отличие от истории многих других дисциплин, история ПО сохраняется не очень хорошо, несмотря на то, что сохранить её должно быть проще всего. У нас не сохранился оригинальный исходный код почти ни одной игры для Atari, NES, SNES, ColecoVision, и так далее. Дизассемблированный код бесценен, но всё-таки это не оригинал. И он не позволяет прочитать комментарии разработчиков.

Возможно, нам повезёт, и у Activision, Atari и Nintendo где-то в хранилищах есть оригинальный код, который они опубликуют в свободном доступе ради блага человечества, но я бы не особо на это надеялся. Каждый, кто может, должен работать над сохранением любого возможного фрагмента истории, потому что сама себя она не сохранит.

Подробнее..

Перевод Рассматриваем отдельные биты на снимке микросхемы как действовать, когда архитектура неизвестна

07.04.2021 18:21:37 | Автор: admin

Введение

Я только начинаю путешествие в сферу обратного проектирования интегральных схем (ICRE), но меня уже совершенно обуяла страсть к данной отрасли. Кроме компьютерных и электротехнических аспектов ICRE, для работы в этой сфере нужны обширные знания по физике и химии. Поначалу химическая составляющая меня пугала, поскольку химию я почти не знал. Не говоря уже о том, как опасна работа с продуктами, необходимыми для вскрытия (декапсуляции) и послойного препарирования чипов.

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

Подготовка

Первым делом мне требовалось разобраться в целой совокупности весьма дорогих вещей и растворителей, которые требовалось купить. Вот список оборудования и расходников, которые я купил.

Почему мне понадобился именно металлургический микроскоп, а не стереомикроскоп или не составной микроскоп? Потому что у большинства микроскопов подсветка снизу, и свет отражается от двухкоординатной пластины, а с ИС так работать нельзя, поскольку они не двусторонние. Рассматриваемые слои кристалла нужно правильно освещать, чтобы свет как следует отражался в направлении сверху вниз. В металлургических микроскопах используется так называемое наблюдение в отраженном свете (EPI illumination), уникальный тип освещения, также именуемого эпифлуоресцентным. Решение позволяет не только освещать объект ИС/образец; более того, объектив микроскопа собирает свет, отражающийся от поверхности образца.

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

Выбор лабораторного образца

Забавно, что когда я впервые вскрыл CH340Gиз платы Arduino Nano v3, я даже не подозревал, что наткнусь на целую секцию ПЗУ, прежде, чем приступлю к послойному препарированию. Как правило, берясь за проект с ПЗУ, нужно весьма хорошо понимать выбранный образец, в частности, познакомиться с его архитектурой и процессором, почитав даташиты. К счастью, у меня все вышло иначе, почему к счастью расскажу дальше, читайте.

Я взялся за распайку интегральных схем с платы Arduino прежде всего потому, что это была первая разработочная плата, которую я освоил в ходе моих упражнений по части безопасности встраиваемых систем. Следовательно, заключил я, можно будет знатно поностальгировать, бросив пару столь любимых мною чипов в жаркую дымящую серную кислоту. Честно говоря, я очень надеялся, что в ATmega328P, с которой я проводил мои первые эксперименты, найдется какое-нибудь ПЗУ, но, после многократных попыток разобрать ее по слоям при помощи плавиковой кислоты, я нашел только ЭСППЗУ, статическую память с произвольным доступом и флэш-память.

Разобранная на слои Atmega328PРазобранная на слои Atmega328P

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

Примечание:если вы хотите посмотреть эту картинку в высоком разрешении, перейдите по следующей ссылке на личную страницу автора: siliconpr0n.

Сравнение флэш-памяти и ПЗУ. Как они выглядят под микроскопом

Возможно, вас интересует, а почему нельзя попросту считывать данные из сегментов флэш-памяти при помощи металлургического микроскопа, как это делалось бы с ПЗУ? Для начала давайте обсудим разницу. Масочное ПЗУ (MROM) содержит код прошивки, записываемый в кремниевую основу чипа на этапе конструирования в процессе производства полупроводника. МПЗУ производится путем расстановки транзисторов еще до начала процесса фотолитографии. Под микроскопом они могут весьма отличаться друг от друга:

ПЗУ TI TMS5200NL в сравнении с ПЗУ CBM 65CE02 от SiliconPr0nПЗУ TI TMS5200NL в сравнении с ПЗУ CBM 65CE02 от SiliconPr0n

Это просто два отдельных примера, позволяющих оценить, как может выглядеть транзистор МПЗУ на уровне подложки. В таких транзисторах может применяться либо материал n-типа с высокой концентрацией электронов, допированный атомом фосфора, либо материал p-типа, допированный атомом бора и отличающийся более низкой концентрацией электронов.

С другой стороны, флэш-память устроена сложнее. 1 транзистор != 1 бит. Причина, по которой сканирующий электронный микроскоп требуется для вытягивания бит из памяти типа ЭСППЗУ в том, что во флэш-памяти используется система карманов, в которых можно запасать остаточные электроны от пропускаемого тока, независимо от того, идет ли ток через схему в настоящий момент. Соответственно, такая память считается энергонезависимой. Во флэш-транзисторе четыре основные части: источник, сток и два затвора, которые называются плавающим и управляющим[1][2], а также изолирующий материал, отделяющий три остальные части друг от друга. По форме вся структура напоминает перевернутую букву Т, причем, в нижней части транзистора располагаются источник и сток, а в верхней части затворы, причем, управляющий затвор находится выше плавающего. Затворы заключены в оксидные слои, через которые ток, как правило, не проникает.

Модель транзистора в NAND FlashМодель транзистора в NAND Flash

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

Однако, сток не работает ожидаемым образом, поскольку между источником и стоком в транзисторе присутствует материал-изолятор, в котором мало электронов. При нагнетании положительного напряжения на электрические контакты на стоке и затворах, отрицательно заряженные электроны притягиваются к положительно заряженным электронам и так преодолевают сток. Немногочисленные электроны подтягиваются в плавающий затвор через оксидные слои в ходе процесса, именуемого квантовым туннелированием. Электроны, попавшие в плавающий затвор и застрявшие там, не могут прорваться обратно через оксидные слои и останутся в плавающем затворе неопределенно долго. Флэш-транзисторы с запасенными электронами расположены параллельно транзисторам ОЗУ, через которые идет ток. Для разделения электронов отрицательное напряжение регистрируется на электрическом контакте над плавающим затвором, это приводит к отталкиванию электронов от плавающего затвора.

Ячейки транзистора в энергонезависимой памяти Ячейки транзистора в энергонезависимой памяти

Примерно так будет выглядеть флэш-транзистор на уровне поликремниевого слоя под металлургическим микроскопом. Вы не увидите никаких зарядов, проходящих через плавающий затвор транзистора, так как такой микроскоп не может просканировать пучок электронов над поверхностью и дать картинку, а вот сканирующий электронный микроскоп (СЭМ) мог бы. Рассматривая изображения, полученные при помощи СЭМ, можно увидеть, как пучок электронов взаимодействует с поверхностью интегральной схемы и производит многочисленные сигналы, при помощи которых можно получать информацию о топографии и составе поверхности.

Как считываются биты

Причина, по которой под металлургическим микроскопом различимы отдельные биты (единицы и нули) в том, что биты физически закодированы в кристалле. Как показано в статье Кена ШириффаExtracting ROM Constants, биты программируются в МПЗУ путем изменения паттерна допирования кремния, создания транзисторов или оставления изолирующих участков. В примере Кена, если в строке присутствует транзистор, то можно предположить, что это транзистор в 1 бит. Как правило, строка в МПЗУ NOR (чтение быстрее, запись медленнее) будет содержать два транзистора, уложенных друг на друга, верхний и нижний, как показано на следующем рисунке.

Изображение ПЗУ из TMS320C52Изображение ПЗУ из TMS320C52

В ПЗУ обычно используются мультиплексоры для выбора бит по столбцу и по строке. При использовании 16-битного MUX будет 4 линии выбора, которые можно активировать. Для ПЗУ, которое я собираюсь показать, каждая линия выбора может перевести транзисторы в состояние напряжения HIGH, если будет активирована. Если в заданной позиции (столбец и строка) транзистор отсутствует, то выходная линия окажется в состоянии напряжения LOW.

Примечание:В нашем случае с CH340G МПЗУ будет выглядеть совершенно иначе, нежели на картинках, показанных выше.

Подготовка образца

В случае Arduino Nano, CH340G всегда находится снизу печатной платы. Я вооружился тепловым пистолетом для отпайки и под температурой около 200C обработал пины интересовавшего меня чипа микросхемы. Таким образом припой снимается с узлов и расплавляется, что позволяет безопасно снять чип с платы.

CH340, припаянная к Arduino Nano (чипы не размечены)CH340, припаянная к Arduino Nano (чипы не размечены)

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

Химические реакции при вскрытии

Теперь требуется проявлять исключительную осторожность, поскольку крайне опасно работать с очень едкими кислотами. Возьмем стеклянную пипетку (поскольку стекло не реагирует сHSO) и наберем в нее около 20 мл 98% концентрированной серной кислоты из контейнера и нальем ее в 100-мл химический стакан. Можно бросить наш образец в кислоту, взяв его щипчиками, а потом поставить стакан на нагревательную плитку.

Вскрытие оболочки в вытяжном шкафуВскрытие оболочки в вытяжном шкафу

Рекомендую температуру не менее 170C, а не 150C, как показано выше, поскольку плитка никогда не показывает температуру абсолютно точно. Такой сильный жар нужен, поскольку при комнатной температуре HSOокисляет эпоксидную смолу очень медленно. Нагревая образец, можно ускорить эту реакцию. На крайней справа картинке видно, как жидкость начинает приобретать желтоватый цвет, и это просто отлично. Это означает, что реакция пошла, и эпоксидная смола начинает плавиться.

Если вам интересно, зачем стакан прикрыт стеклянной крышкой объясню, на то есть две причины:

1. Так в стакане лучше удерживаются испарения, поднимающиеся из горячей серной кислоты. На данном этапе у вас в вытяжном шкафу уже должен работать экстрактор дыма.

2. Потенциально крышка может поспособствовать частичной рециркуляции паров диоксида серы (SO), что, в свою очередь, будет поддерживать высокую концентрацию кислоты. Если концентрация кислоты чрезмерно снизится, то возрастет вероятность коррозии. Я не вполне в этом уверен, поэтому смело пингуйте меня, если я не прав. Знаю, что такой подход хорош при работе с азотной кислотой (HNO), поскольку пары диоксида азота (NO) при рециркуляции могут превращаться обратно вHNOв присутствии воды.

Большой вопрос как долго это должно продолжаться? Зависит от толщины чипа; в данном конкретном случае, согласно даташиту, мы имеем дело с корпусом кристалла SOP-16, толщина которого составляет около 1,50 мм. При приблизительно такой толщине и температуре весь процесс должен занять около часа.

Чип обрабатывается в кислотной банеЧип обрабатывается в кислотной бане

Еще один верный признак, что стоит остановиться когда весь стакан заполнится таким черным осадком; это должно означать, что вся эпоксидная смола сошла с кристалла. В таком случае нужно снять образец с плитки и дать ему остыть.

Примечание: как только вы снимете крышку со стакана, оттуда сильно попрут парыSO, поэтому убедитесь, что дымовые заслонки как следует закрыты, надежно удалите все эти токсичные пары через вакуумный отсос. Хороший пример, позволяющий оценить, как идет эта реакция повторить подобный опыт с полиэтиленом ((CH)), который обычно входит в состав эпоксидного пластика. По мере того, как серная кислота разогревает эпоксидную смолу, HSOразлагается наSO,CO иHO. Вот химическое уравнение с коэффициентами:6HSO + (CH) 6SO +2CO +8HO. Точка кипения такой кислоты составляет около 337C, именно в таких условиях обычно и получают азеотропную серную кислоту. Если взять серу (S), кислород (O) и воду (HO), а затем сжечь серу для получения диоксида серы (SO);S + O SO, то в дальнейшем диоксид серы можно окислить до триоксида серы (SO), воспользовавшись кислородом и взяв в качестве катализатора оксид ванадия (VO), имеем2SO + O + VO 2SO. Вода служит для гидратации триоксида серы в серную кислоты,SO + HO HSO. Могут использоваться и иные методы, например, с добавлением электролизованных растворов, таких, как раствор сульфата меди (II) (CuSO) или бромводородной кислоты (HBr) для реакции с серой.

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

На данном этапе я перелью кислоту в другой пустой химический стакан и проверю, не видно ли на дне первого стакана кремниевого кристалла. Иногда приходится на самом деле пристально поискать, ведь эти кристаллы совсем маленькие.

Кремниевый кристалл, извлеченный из эпоксидной оболочкиКремниевый кристалл, извлеченный из эпоксидной оболочки

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

Исследуем первый образец

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

CH340G, снятый через объектив с линзой, дающей пятикратное увеличениеCH340G, снятый через объектив с линзой, дающей пятикратное увеличение

Впервые взглянув на этот чип, я не нашел ничего, что напоминало бы ПЗУ; учитывая, что я знаю сейчас, я кажусь себе в тот момент довольно глупым. Еще немного разобравшись, я смог предположить, что в левой части содержится какая-то ЭСППЗУ или ОЗУ, либо это просто какая-то емкость для энергозависимой памяти. Область сверху справа, казалось, отведена под МПЗУ. Итак, разбираемся дальше.

Картинка в более высоком разрешении предлагается здесь: https://siliconpr0n.org/map/wch/ch340/mz_20x/Картинка в более высоком разрешении предлагается здесь: https://siliconpr0n.org/map/wch/ch340/mz_20x/

Присмотревшись к логическим вентилям через объектив с 50-кратным увеличением, постепенно начинаем понимать, что здесь происходит. Первым делом интересно отметить, что на этом ПЗУ 14 мультиплексоров, то есть, 14 групп столбцов. Каждый мультиплексор имеет разрядность 16:1. Таким образом, он позволяет проложить 16 различных путей данных на месте единственного.

14 групп столбцов значит, мы имеем дело с 14-битной архитектурой, и это странно. Как правило, встречаются микропроцессоры в диапазоне 4 бит, 8 бит, 16 бит или даже 32 бит. Ситуация значительно усложнится позже, после извлечения бит из этих изображений, так как мы, вероятно, имеем дело с нетипичной архитектурой.

Также нужно отметить следующие наблюдения. Во-первых, верхние слои в этом чипе представляют собой адресные строки. В вертикальном столбце видим 10 металлических линий, которые в итоге транслируются в 6 адресных разрядов, а 6 металлических линий по горизонтали транслируются в 4 адресных разряда. Суть этого будет объяснена ниже, когда мы займемся послойным препарированием чипа, 4 адресных разряда дадут нам 2 = 16для мультиплексоров, описанных выше. Теперь 6 адресных строк будут использоваться, чтобы выбрать одну из 64 строк в пространстве ПЗУ, что даст 16 бит x 14 столбцов по горизонтали. Вот почему нам требуется суммарно 10 адресных разрядов по вертикали.

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

Реакции послойного препарирования

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

Примечание:Существует некоторое сходство между двумя этими неорганическими кислотами. ИHCl, иHFявляются ионными соединениями, то есть, они должны полностью диссоциировать в ионном растворе, например, в воде. С HClтак и происходит, и поэтому она считается сильной кислотой, а с HFнет. Дело в том, что фтор и хлор отличаются по силе. У фтора сильная связь с водородом, поэтому диссоциация у фтора выражена не очень ярко. Электроотрицательность у иона фтора настолько велика, что даже мощный ионный эффект воды не позволяет полноценно оторвать его от атома водорода в сущности, именно поэтому HFтак опасна. Она прореагирует со всем, к чему сможет прикрепить свой электрон, в особенности с ионами кальция из ваших костей. Определенный процент молекул HFне диссоциирует, поэтому HFсчитается слабой кислотой. В нашем случае важно отметить, что как соляная, так и плавиковая кислота бурно реагируют с металлами, но их использование при послойном препарировании металлов может весьма отличаться.

Итак, как же определить, какую кислоту использовать? Как правило, в процессе производства полупроводников используется два типа металлов: алюминиевый (Al) сплав 6061 и/или медь (Cu). В чипах, подобным рассматриваемому, обычно встречается Al, но изредка может бытьCu. Дело в том, что у меди ниже сопротивление, и это положительно сказывается на включаемости металлов.

Если работать сCu, то приходится использовать HCl, посколькуHFне вытравливаетCu и, фактически, спровоцирует сильную коррозию, вызываемую атмосферным кислородом. Любой окислительный агент плохо подойдет вам для послойного препарирования Cuс использованиемHF. Помните, чтоHClсама по себе также не будет вытравливатьCu, к ней нужен окислительный агент, например, пероксид водорода (HO), который позволит кислоте съесть всю Cu(восстановительный агент), поднимая pKa (константу диссоциации) кислотного раствора. Значение pkA характеризует силу кислоты. Две эти реакции вместе в соотношении 1:1 позволяют получить хлорноватистую кислоту (HOCl) и воду (HO). Как только в реакцию входит Cuиз кристалла, медь прореагирует сHOCl, и получится хлорид меди (II), который и станет нашим травящим агентом. Таким образом, мы послойно препарируем медную область кристалла при комнатной температуре до получения зеленоватого раствора хлорида меди (CuCl).

HO (aq.) + HCl (aq.) HO + HOCl (aq.)
2HOCl + Cu Cu(HOCl)

Примечание:Будьте крайне осторожны, поскольку при комнатной температуре растворHCl, в отличие от большинства кислот, быстро разогревается и, скорее всего, будет выделять газообразный хлороводород. Это нужно делать[1][2] в вытяжном шкафу.

В нашем случае, при работе с CH340, нам придется протравить лишь очень тонкий слой Al, а диоксида кремния (SiO) там почти нет. Вот почему при послойном препарировании мы будем использовать HF. Влажная HFочень быстро накидывается на алюминиевые связи и контактные площадки при температуре выше 40C, но также можно вытравливать при комнатной температуре и очень низкой концентрации, используя Whink. Этот удалитель ржавчины содержит плавиковую кислоту в концентрации 3%, но не обманывайтесь, поскольку и это может быть более чем фатально. Рекомендую класть чип в тефлоновый химический стакан и держать его там при комнатной температуре с интервалами примерно в 15 минут, в зависимости от того, какой именно чип мы тестируем. Так будет вытравливаться не только Al, но иSiO.

SiO + 4HF SiF + 2HO

Эта реакция достаточно медленно протекает при низких концентрациях и без нагревания, но так безопаснее, если приходится иметь дело с парами. В результате должно получиться нечто подобное:

Послойно препарированная CH340Послойно препарированная CH340

Итак, мы успешно протравили себе путь до самой подложки или уровня транзисторов в чипе. Изображение в более высоком разрешении предлагается наsiliconpr0n. Присмотревшись к правому верхнему углу, найдем наше драгоценное МПЗУ.

Справа вверху: МПЗУ в CH340Справа вверху: МПЗУ в CH340

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

Автоматическое извлечение битов ПЗУ

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

Перед запуском инструмента нужно узнать, с каким объемом ПЗУ нам придется работать. Смотрим на картинку и видим 14 групп столбцов, в каждом по 16 бит в строку, то есть, мы имеем дело с 224 бит в каждой строке. Строки следующая важная тема, которую мы обсудим, по-видимому, тут просматривается 64 строки. Таким образом, размер ПЗУ, с которым нам предстоит работать, составляет 1,7 Кб.

При запуске rompar ожидает получить 3 аргумента; файл ищображения, количество столбцов и количество строк.

python3 rompar.py image1-50x-ROM.jpg 16 1
Changing edit mode to GRID
Changing edit mode to GRID
Image is 11694x4318; 3 channels
process_image time 0.18801593780517578
read_data: computing
grid line redraw time: 6.4373016357421875e-06
grid circle redraw time: 1.1920928955078125e-05
render_image time: 0.22574210166931152

А почему тут конфигурация 16x1? могли бы спросить вы. Потому, что, если взглянуть на картинку, заметен большой промежуток между 14 группами столбцов, поэтому мы и делим их на две части. То же касается и строк, по-видимому, между ними есть какой-то разделитель, поэтому мы не можем жестко закодировать строки.

Экран Rompar в инфракрасном спектре. Экран Rompar в инфракрасном спектре.

Первый экран графического интерфейса (GUI), который мы рассмотрим, дан в инфракрасном спектре, чтобы по ярким и темным пятнам мы уяснили, чем допированный транзистор отличается от пустого места. Можно скорректировать порог, перейдя в CV Options -> Pixel Threshold. Корректируем, пока у нас не получится нечто подобное:

Увеличенное изображение бит в инфракрасном спектреУвеличенное изображение бит в инфракрасном спектре

Нужно, чтобы программа поняла, что первая строка будет возвращать0000001, а второй01110101. Помните, что более яркие области принято обозначать через1, а более темные через0. При постобработке это правило по необходимости может инвертироваться, если нужно получить на выходе готовый двоичный файл. Теперь давайте перейдем к Display -> Base Image -> Original. Далее мы хотим сделать сетку из столбцов и строк, поэтому щелкнем ctrl+clickпо столбцу 1, перейдем к столбцу 16 и сделаем то же самое. Продолжаем, пока не обработаем все столбцы. В конечном итоге у нас должна получиться примерно такая сетка:

Голубая сетка для столбцов ПЗУГолубая сетка для столбцов ПЗУ

Голубые линии немного бледные, но при увеличении видны гораздо лучше. Теперь давайте подсветим отдельные биты. Нажимаем cmd+click в каждой строке, получаем следующее:

Двоичные разряды обведены кружочкамиДвоичные разряды обведены кружочками

Как видите, тут есть несколько ошибок, но мы можем откорректировать отдельные разряды, перейдя в Edit -> Mode -> Data Edit Mode. Далее щелкнемctrl+clickпо каждому отдельному биту, чтобы превратить голубые кружочки в зеленые или наоборот. Программа трактует зеленые маркеры как 1, а голубые как0. К сожалению, с этим изображением ПЗУ мне пришлось многое редактировать вручную, но, как только результат нас устроит, можно экспортировать его в матрицу двоичных разрядов, перейдя в Data -> Export Data as Text. В итоге у вас получится файл со всеми вашими двоичными данными, такой, как выложен у меня на Github.

Декодируем биты

Теперь, когда у нас готовфайл битовой матрицы, время превратить его в удобочитаемый и дизассемблированный файл прошивки. Этого можно добиться при помощи одного из двух инструментов,zorromилиbitviewer. В принципе, если уже знаем архитектуру, то используем zorrom, утилиту, преобразующую данные из физического представления в логическое и обратно при работе с топологией памяти чипа. Как написано в README от Zorrom, например, фотография загрузочного ПЗУ, преобразованная в двумерный битовый массив (.txt) может быть преобразована в машинно-читаемый двоичный формат. Затем этот .bin можно эмулировать, дизассемблировать и т.д., делать с ним все, что вы бы делали с обычным файлом прошивки . У программы есть отличный API, чтобы писать и настраивать, как именно должно считываться ПЗУ; то есть, здесь указывается топология, порядок следования байтов, требуется или нет инвертирование битов, а также порядки битов на выходе.

Причина, по которой мы не можем сразу начать работать с zorrom в том, что мы не знаем тип процессора. Потратив дни и недели на изыскания в головной корпорации, WCH, я не нашел ничего и близко напоминающего 14-битную архитектуру. Размышляя, с чем же мы имеем дело, мы, возможно, найдем ответ только тупо присмотревшись, а для этого нужен такой инструмент как bitviewer. С этим инструментом единственная корректировка, которая нам потребуется подогнать файл двоичной матрицы под 16-битную архитектуру. По-видимому, эта программа не слишком хороша для работы с 14-столбцовыми группами, но это как раз нормально, поскольку, когда мы извлечем bin-файл, эти заполняющие байты не повлияют на информативные байты прошивки.

В принципе, в ситуациях, когда мы не знаем, какой байт соответствует какому коду операции, мы не можем просто дизассемблировать тестовые bin-файлы. Вместо этого ищем в файле жестко закодированные последовательности символов или какие-либо константы, которые описывали бы внутренние конфигурации. Зачастую приходится как следует покорпеть над схемой, чтобы понять, что происходит. Я еще совсем новичок в этом деле, но я всегда начинаю с соединений, которые выходят из самого ПЗУ.

Металлический слой (слева), Слой подложки (справа)Металлический слой (слева), Слой подложки (справа)

Вот что мы можем узнать из этой картинки. В ПЗУ 64 бит по вертикали и 16 x 14 по горизонтали; как я уже говорил, объем этого ПЗУ почти 2k. Выяснил, что для него нужно всего 10 адресных разрядов. В первом столбце транзисторов в вертикальных адресных разрядов за адресным битом 0 идет неадресный бит 0. В следующем столбце переключение происходит каждые два бита, и так далее. Я считаю, что здесь видно 4 бита по горизонтали и 6 битов по вертикали. У каждой строки должна быть дополнительная, чтобы по возможности упростить декодирование мультиплексорами 16:1. Если сделать очень сильное увеличение, то видно, что, благодаря мультиплексору экономится место, в которое можно добавить 14 инверторов, а не прокладывать дополнительную сигнальную строку по горизонтали.

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

Экран, которым открывается Bitviewer Экран, которым открывается Bitviewer

С битами на строку кажется все верно, но я знаю, что в столбцах не по 32 бита, я же определил, что в каждой группе столбцов содержится 16 бит на столбец, это видно по изображениям. Нам нужно всего лишь откорректировать это число и картинка автоматически изменится.

Bitview 16-битные столбцыBitview 16-битные столбцы

Далее по списку нам нужно взглянуть на шестнадцатеричный дамп и посмотреть, есть ли там какие-то признаки, что мы правильно задали порядок. Для этого щелкнем по кнопке Byte view (hex). Прокручивая биты, ничего узнаваемого мы не увидим, так как 1) мы не знаем, как должны выглядеть коды операций, поскольку не знаем архитектуру и 2) мы пока не видим ни одной жестко закодированной последовательности символов. Так что мы полагаемся на то, что все-таки увидим здесь некие последовательности символов, по которым сможем судить, правильно ли выполнили декодирование.

Давайте кое-что откорректируем, нажав кнопку Export Options. Как видите, здесь мы можем откорректировать топологию ПЗУ, порядок следования байтов, а также внести еще некоторые изменения, например, инвертировать порядок бит. Большинство опций мы оставим без изменения, в том числе, порядок следования битов, который будет иметь вид:0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15. Иногда приходится немного поэкспериментировать, чтобы получить правильный порядок на выходе, но я после нескольких попыток обнаружил, что полезно расставить галочки вот здесь:Reverse output bit order иAddress run right-to-left. Теперь можем снова прокрутить шестнадцатеричное представление.

05C0: FE 73 FF DB EF ... .s...t...t.b.|.j
05D0: FE 50 C6 5F D6 ... .P._._.Q...P....
05E0: DD 74 DF F8 ED ... .t...&.m...S.p..
05F0: FF 6D ED 00 FF ... .m...y...|.....>
0600: FF 7A FF 6A ED ... .z.j.<.g.Z.X.s..
0610: D9 74 CE 65 ED ... .t.e...W.p...[..
0620: E6 F0 F5 5B F0 ... ...[.W.W.W.W....

По-прежнему никаких подвижек. Есть еще один вариант, который мы не проверили возможно, наши биты нужно инвертировать/перевернуть. То есть, единицы должны стать нулями и наоборот. К счастью, в bitviewer это сделать можно; щелкаем кнопкуSelect all, и программа подсветит все биты во всех строках и столбцах. Когда они будут подсвечены, нажмите Invert Sel.

Инвертированные биты из BitviewИнвертированные биты из Bitview

Все удалось успешно инвертировать, и теперь можно открыть вкладку с просмотрщиком байт, чтобы посмотреть, что у нас получилось.

0770: 10 03 10 09 ... .............U..
0780: 10 53 10 00 ... .S...B...2......
0790: 10 30 10 00 ... .0...-..3...3...
07A0: 33 F3 10 00 ... 3...3...3...3...
07B0: 2F A4 10 00 ... /.....(.....+...
07C0: 10 23 29 08 ... .#)...../.. .'/.
07D0: 10 02 10 03 ... ..../.....+..P.S
07E0: 2F A4 10 72 ... /..r.e/..i.r/..n
07F0: 10 6D 2F A4 ... .i/..t.a+.. .l..

Я этого на первый взгляд не заметил, но теперь же у нас есть полноценный файл прошивки, и мы можем его изучить! Посмотрев на отступы0x0770и0x0780, можно выделить последовательность символов USB 2.0. Что еще? Следующую последовательность заметить не так легко, поскольку между первой и второй большой скачок. Последовательности символовPrintиSerialнаходятся на отступах0x07D00x07F0. Другие интересующие нас подсказки будут в верхней части файла прошивки, например, та или иная таблица переходов и /или таблица векторов исполнения. Из этого сделаем вывод о наличии повторяющихся инструкций или, в нашем случае, байт.

Таблица переходовТаблица переходов

Хотя мы до сих пор не знаем, что это за архитектура, и даже как вообще будет выглядеть ассемблер, в начале здесь просматривается определенная закономерность, именно такая, какую мы рассчитываем увидеть в начале любого действующего файла прошивки. Давайте дампируем эту информацию в bin-файл прошивки, щелкнувSave bin. Начиная отсюда, нам придется изрядно гадать и заниматься обратным проектированием схем, чтобы как следует понять, каким образом можно превратить эти байты в пригодный для чтения ассемблерный код.

Примечание:Возвращаясь к использованию zorrom, теперь мы знаем, какие опции нужны, чтобы получился правильный файл прошивки. Значит, мы можем автоматизировать этот процесс, написав подходящий алгоритм. Воспользовавшись API zorrom, пользователю всего-то останется скормить программе двоичный текстовый файл, а в ответ она выдаст файл прошивки. Здесь можете посмотреть код, который я написал для CH340. Этот инструмент можно запустить вот так:

python3 txt2bin.py --arch ch340t ch340_binary.txt ch340_fw.bin

Также отметим, что при работе сzorrom не приходится заботиться о заполнении разрядов в двоичном текстовом файле, чтобы получилось 16 групп столбцов, нам вполне хватит наших исходных 14. Не забывайте, что иногда можно получить неверную ориентацию, извлекая биты с изображений. Расположение может случайно получиться зеркальным, либо придется определить, как правильно повернуть чип. Если дампировать двоичный текстовый файл, подобный тому, ссылка на который дана выше, то можно воспользоваться инструментом вроде rotate, чтобы просто перевернуть текст в файле вверх тормашками.

Дизассемблирование неизвестного

Самый большой вопрос как дизассемблировать информацию, об архитектуре которой мы не имеем представления? Если бы не было найденных нами жестко закодированных последовательностей символов, откуда бы мы знали, что расставили байты в правильном порядке? Ответ на этот вопрос не так прост и очевиден. Придется воспользоваться IDA Pro в качестве основного инструмента дизассемблирования, но при этом нам придется написать наш собственный специальный плагин.

Имея дело с незнакомой архитектурой, необходимо как следует разобраться с изображениями, чтобы понять, как используется ПЗУ. Ситуация примерно такова, как если бы врач выполнил КТ всего тела пациента, и изучил бы томограмму каждого органа в отдельности, чтобы найти подсказки.

Итак, в данном случае мы рассматриваем весь чип, а не только ПЗУ:

Послойно препарированный CH340 с аннотациямиПослойно препарированный CH340 с аннотациями

Аннотации важный аспект обратного проектирования интегральных схем, а в нашем случае придется пояснить очень многое. Прочитав даташит, мы знаем, что этот чип поддерживает возможности USB, поскольку служит мостом от USB к USART. Мы также знаем, что этому чипу требуется внешний источник колебаний для отсчета тактов. Некоторые допущения, которые мы можем сделать относительно чипа: здесь должен быть блок статической памяти с произвольным доступом для энергозависимого хранения данных, область регистров, которые будут использоваться для приема, хранения и переноса данных, а также команды, которые будут использоваться непосредственно ПЗУ при работе с ядром процессора. Поскольку, согласно даташиту, на этом чипе отсутствует ЭСППЗУ, мы отметили на большом участке слева, как данные принимаются и передаются USB-портом.

Как упоминалось выше, в IDA нет процессорного плагина для этого (поскольку мы имеем дело с неизвестной архитектурой), и мы с моим коллегой Крисом начали писать собственный плагин для этого, работапока не окончена. Оказавшись в подобной ситуации, нужно выдвинуть пару стартовых гипотез. Например, в нескольких первых байтах прошивки должен прослеживаться повторяющийся паттерн, то есть, здесь должен быть какой-то небольшой переход к таблице адресов.

Частично дизассемблированное представление кода ch340Частично дизассемблированное представление кода ch340

Как понятно из кода на python, нам также удалось в точности выяснить, как именно передавались жестко закодированные последовательности символов. Подобно кодам операций в таблице переходов, CALL-ы покажутся вам весьма знакомыми, когда будете просматривать шестнадцатеричный дамп прошивки неизвестного типа. Увидите байт инструкции, а за ним еще два байта, выделенных под адрес.

Предстоит сделать еще немало. Даже при допущении, что отношение между инструкцией и регистром в формате начиная со старшего, нам придется перейти на формат начиная с младшего, поскольку по какой-то причине is IDA не преобразует или не дизассемблирует как следует двухбайтовые фрагменты WORD, когда работает в формате начиная со старшего. По-видимому, к этой статье придется написать вторую часть, в которой я наконец смогу объяснить, как все работает в этом чипе. А эту статью я завершу рассказом о том, как прибрать тот беспорядок, который мы учинили в лаборатории, работая со всеми этими неорганическими кислотами.

Убираем за собой. Кислотно-основная нейтрализация

Серную кислоту (HSO) можно нейтрализовать при помощи исключительно сильной щелочи, гидроксида натрия (NaOH). Эта кислота реагирует сNaOH, продуктами реакции являются сульфат натрия (NaSO) и вода. Это реакция кислотно-основной нейтрализации. После расстановки коэффициентов в уравнении видим, что нужное нам соотношение между щелочным раствором и кислотой 2:1.

2NaOH (aq) + HSO (aq) 2HO (l) + NaSO (aq)

В нашем случае мы воспользуемся гораздо более высоким соотношением, поскольку, как помните, мы задействовали всего 20 мл HSOдля декапсуляции чипа.NaOH существует в форме кристаллов, напоминающих соль, и всего 15 граммNaOHна 150 мл воды (раствор 10%) хватит для приготовления раствора. NaOH+HOдадут раствор с катионамиNa+и анионами OH-, дополнительно выделится некоторое количество тепла. Уравнение реакции выглядит так:

NaOH +2HO Na+ + OH- + HO (delta H < 0)

Медленно наливаем кислоту в свежеприготовленный щелочной раствор. При этом вы можете заметить, что смесь немного нагревается. Это происходит потому, что процесс экзотермический, то есть, кислотно-основная реакция происходит с выделением избыточной теплоты. Эта теплота сухой остаток процессов разрыва и образования химических связей. Проще говоря, конечные продукты этой реакции находятся в более низком энергетическом состоянии, чем реагенты. Энергетическая разница между двумя этими состояниями равна той энергии, что выделяется в виде теплоты. Остаток раствора можно разбавить, долив в полученную кислотно-основную смесь еще 150 мл воды. Медленно помешиваем в течение около 2 минут, затем проверяем нейтральность раствора при помощи лакмусовой бумажки.

Проверка уровня pH нейтрализованной серной кислотыПроверка уровня pH нейтрализованной серной кислоты

Примечание:чтобы проверить нейтрализацию, можно воспользоваться бытовой химией, например, бикарбонатом натрия (NaHCO), бросив его в стакан и посмотрев, пойдут ли пузырьки. Если разбавленный кислотно-водный раствор начинает пузыриться, это означает, что кислотность его по-прежнему немного повышена из-за связанного диоксида углерода (CO), который рвется наружу из раствора угольной кислоты. После того, как в растворе все успокоится, складываем все химикаты в специальный контейнер для химических отходов и относим в компанию, которая за нас их как следует утилизирует.

Спасибо, что дочитали! Надеюсь, вам понравилось не меньше, чем мне. Если у вас остались вопросы по этой статье, пишите мне пожалуйста в Instagram:@hackersclubили в Twitter:@ringoware

Доброй охоты :)

Ссылки и благодарности

Ken Shirriffhttp://www.righto.com/2020/05/extracting-rom-constants-from-8087-math.html, за то, что лично уделил время и рассказал мне, как биты считываются из ПЗУ.

John McMasterhttps://siliconpr0n.org/archive/doku.phpза то, что провел со мной многие часы и, в частности, рассказал, как автоматизировать извлечение ПЗУ, как делается декапсуляция и послойное препарирование.

Chris Gerlinsky этот товарищ сильно мне помог, когда понадобилось выяснить, что делать с файлом двоичной матрицы, составленным из данных, извлеченных с изображений кремниевых кристаллов. Никогда не довел бы этот проект так далеко, если бы не его всемерная помощь в понимании архитектуры.

Elijah Hawk Всегда был великодушно готов потратить на меня пару часов, вычитывая грамматику и структуру предложений во всех моих статьях.

О нейтрализации https://chem.libretexts.org/Bookshelves/Physical_and_Theoretical_Chemistry_Textbook_Maps/Supplemental_Modules_(Physical_and_Theoretical_Chemistry)/Acids_and_Bases/Acid_Base_Reactions/Neutralization

Получение серной кислоты https://www.cs.mcgill.ca/~rwest/wikispeedia/wpcd/wp/s/Sulfuric_acid.htm

Конструирование сверхбольших интегральных схем (VLSI) https://www.tutorialspoint.com/vlsi_design/vlsi_design_digital_system.htm

Понятие о NAND Flash https://www.simms.co.uk/nand-flash-basics/understanding-nand

Подробнее..

Перевод Сравнение векторных расширений ARM и RISC-V

20.05.2021 22:11:13 | Автор: admin

Сравнение векторного расширения RISC-V (RVV) и масштабируемого векторного расширения ARM (SVE/SVE2).

Микропроцессоры с векторными командами ожидает большое будущее. Почему? Беспилотные автомобили, распознавание речи, распознавание образов, всё это основано на машинном обучении, а машинное обучение на матрицах и векторах.

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


Увеличение производительности остановилось, что делает необходимым распараллеливать вычисления различными способами, либо с помощью многоядерности, либо с помощью векторизации, либо с помощью исполнения не в порядке очереди (out-of-order).Увеличение производительности остановилось, что делает необходимым распараллеливать вычисления различными способами, либо с помощью многоядерности, либо с помощью векторизации, либо с помощью исполнения не в порядке очереди (out-of-order).

Сейчас мы придумали тысячи умных способов получить большую производительность, будь то создание многоядерных процессоров, внеочередное (out-of-order) исполнение, более совершенное предсказание переходов и SIMD-команды.

Все эти способы основаны на одной центральной идее: пытаться различными способами распараллеливать работу. Когда вы выполняете какие-то вычисления в цикле над массивом элементов, у вас появляется возможность параллелизма данных. Этот цикл при достаточно умном компиляторе может быть превращён в последовательность SIMD или векторных команд.

SIMD-инструкции, в отличие от SISD-инструкций, каждая инструкция (зелёный цвет) обрабатывает множество независимых потоков данных (синий цвет).SIMD-инструкции, в отличие от SISD-инструкций, каждая инструкция (зелёный цвет) обрабатывает множество независимых потоков данных (синий цвет).

SIMD-команды, такие, как Neon, MMX, SSE2 и AVX замечательно сработали в мультимедийных приложениях, в таких вещах, как кодирование видео и т.п. Но нам нужно получить большую производительность во многих областях. Векторные команды предлагают большую гибкость в превращении почти любого цикла в векторные команды. Однако есть много различных способов это сделать.

Я описал векторные команды RISC-V здесь:RISC-V Vector Instructions vs ARM and x86 SIMD.

Позже я описал векторные команды ARM:ARMv9: What is the Big Deal?.

Когда я писал последнюю статью, я впал в замешательство. Кажется, ничто не работает таким образом, как меня учили. Я считал, что я знаю, как работают векторные команды, изучая их для моей первой статьи. После окончания последней статьи, я начал писать статью об их сравнении.

Это заставило меня обнаружить, что ARM и RISC-V следуют принципиально разным стратегиям. Стоит написать об этом, потому что это одна из моих любимых тем. Я люблю простые, элегантные и эффективные технологии:The Value of Simplicity.

Векторное расширение RISC-V по сравнению с ARM SVE это элемент элегантности и простоты.

Проблема с масштабируемыми векторными командами ARM (Scalable Vector Instructions, SVE)

В процессе изучения SVE, было неочевидно, почему это так трудно для понимания, но когда я взял книгу по RISC-V и перечитал главу по векторному расширению, это стало ясно.

Честно говоря, ARM является большим шагом вперёд по сравнению с большим сложным и беспорядочным ассемблером Intel x86. Давайте не будем про это забывать. Также мы не можем пройти мимо того факта, что ARM не молодая платформа, и содержит много легаси. Когда мы имеем дело с ARM, у нас есть три различных набора команд: ARM Thumb2, ARM32 и ARM64. Когда вы гуглите руководства и пытаетесь их читать, возникает ряд проблем. Люди не всегда понимают, какой набор команд изучать.

Инструкции Neon SIMD имеют две разновидности: 32- и 64-битные. Проблема, конечно, не в ширине слова, а в том, что для 64-битной архитектуры ARM перепроектировал весь набор команд и изменил многие вещи, даже соглашение об именовании регистров.

Вторая проблема в том, что ARM большой. Система команд содержит свыше 1000 команд. Сравните с базовым набором RISC-V, в котором всего лишь 48 команд. Это означает, что читать ассемблер ARM не так просто. Посмотрим на команду SVE:

LD1D z1.D, p0/Z, [x1, x3, LSL #3]

Здесь делается много. Если у вас есть опыт в ассемблере, вы можете догадаться, что префиксLDозначаетLoaD. Но что означает1D? Вы должны это выяснять. Дальше вы должны выяснить, что означают странные суффиксы имён регистров, такие как .Dand/Z. Дальше вы видите скобки[]. Вы можете догадаться, что они составляют адрес, зачем там странная запись LSL #3, что означает логический сдвиг влево (Logic Shift Left)три раза. Что сдвигается? Все данные? Или только содержимое регистраx3? Это снова нужно смотреть в справочнике.

Команды ARM SVE содержат множество концепций, не являющихся очевидными, от которых голова идёт кругом. Мы сделаем глубокое сравнение, но сначала скажем несколько слов о RISC-V.

Красота векторного набора команд RISC-V

Обзор всех команд векторных расширений RISC-V (RVV) помещается на одной странице. Команд немного, и, в отличие от ARM SVE, они имеют очень простой синтаксис. Вот команда загрузки вектора в RISC-V:

VLD v0, x10

Команда загружает векторный регистрvданными, находящимися по адресу, который хранится в обычном целочисленном регистреx10. Но сколько данных загружается? В наборе команд SIMD, таком, как ARM Neon это определяется именем векторного регистра.

LD1 v0.16b, [x10]  # Load 16 byte values at address in x10

Есть другой способ сделать это. Такой же результат достигается таким образом:

LDR d0, [x10]    # Load 64-bit value from address in x10

Эта команда загружает младшую 64-битную часть 128-битного регистраv0. Для SVE2 у нас есть другой вариант:

LD1D z0.b, p0/z, [x10] # Load ? number of byte elementsLD1D z0.d, p0/z, [x10] # Load double word (64-bit) elements

В этом случае регистр предикатаp0определяет в точности, сколько элементов мы загружаем. Еслиp0 = 1110000, мы загружаем три элемента.v0 это 128-битная младшая частьz0.

Регистры имеют одинаковые имена?

Причина этому в том, что регистры d,vиz находятся в одной ячейке. Давайте поясним. У вас есть блок памяти, называемый регистровый файл в каждом CPU. Или, если быть более точным, в CPU расположено много регистровых файлов. Регистровый файл, это память, в которой расположены регистры. Вы не можете получить доступ к ячейкам памяти в регистровом файле, так же как в обычной памяти, вместо этого вы обращаетесь к области памяти, используя имя регистра.

ARM floating point registers are overlapping in the same register file (memory in CPU holding registers).ARM floating point registers are overlapping in the same register file (memory in CPU holding registers).

Различные регистры могут отображаться на одну и ту же область регистрового файла. Когда мы используем скалярную операцию с плавающей точкой, мы используем часть векторных регистров. Рассмотрим 4-й векторный регистр, и рассмотрим, как ещё он используется:

  • z3 регистр SVE2 переменной длины.

  • v3 младшие 128 бит z3. Регистр Neon.

  • d3 младшие 64 битаv3.

  • s3 младшие 32 битd3

RISC-V, однако, устроен не так. Векторные регистры RISC-V находятся в отдельном регистровом файле, не разделяемом с регистрами скалярной плавающей точки.

  • x0-x31 скалярные целочисленные регистры.

  • f0-f31 скалярные регистры с плавающей точкой.

  • v0-v31 векторные регистры. Длина не зависит от ISA.

Сложность векторных команд ARM

Я только поцарапал поверхность векторных команд ARM, потому что их очень много. Просто найти, что делает команда загрузки Neon и SVE2, занимает много времени. Я просмотрел много документации ARM и записей в блогах. Сделать то же самое для RISC-V очень просто. Практически все команды RISC-V можно разместить на двойном листе бумаги. У него есть только три команды загрузки вектораVLD,VLDSиVLDX.

Я не могу сказать, сколько этих команд у ARM. Их очень много, и я не собираюсь становиться профессионалом по программированию ARM. на ассемблере.

Как ARM и RISC-V обрабатывают вектора переменной длины

Это довольно интересный вопрос, так как ARM и RISC-V используют существенно различные подходы и я считаю, что простота и гибкость решения RISC-V просто блестящая.

Вектора переменной длины в RISC-V

Чтобы начать обработку векторов, вы делаете две вещи:

  • VSETDCFG Vector SET Data ConFiGuration. Устанавливает битовый размер каждого элемента, тип, который может быть вещественным, знаковым целым или беззнаковым целым. Также конфигурация определяет, сколько векторных регистров используется.

  • SETVL SET Vector Length. Устанавливает, сколько элементов содержит вектор. Максимальное количество элементов, которое вы не можете превысить MVL(max vector length).

Регистровый файл RISC-V может быть скофигурирован так, чтобы иметь меньше 32 регистров. Может быть, например, 8 регистров или 2 регистра большего размера. Регистры могут занимать весь объём регистрового файла.Регистровый файл RISC-V может быть скофигурирован так, чтобы иметь меньше 32 регистров. Может быть, например, 8 регистров или 2 регистра большего размера. Регистры могут занимать весь объём регистрового файла.

И здесь всё становится интереснее. В отличие от ARM SVE, я могу разделить файл векторных регистров именно так, как я хочу. Пусть регистровый файл имеет размер 512 байт. Я могу теперь объявить, что я хочу иметь два векторных регистра, по 256 байт каждый. Далее я могу сказать, что я хочу использовать 32-битные элементы, другими словами, элементы по 4 байта. Получаем следующее:

Два регистра: 512 байт / 2 = 256 байт на регистр256 байт / 4 байта на элемент = 128 элементов

Это означает, что я могу складывать или умножать 128 элементов просто одной командой. В ARM SVE вы этого сделать не можете. Количество регистров фиксировано, и память аллоцирована для каждого регистра. И RISC-V, и ARM позволяют вам использовать максимум 32 векторных регистра, но RISC-V позволяет вам отключать регистры и отдавать используемую ими память оставшимся регистрам, увеличивая их размер.

Вычисление максимальной длины вектора (Max Vector Length, MVL)

Давайте посмотрим, как это работает на практике. Процессор, конечно, знает размер регистрового файла. Программист этого не знает, и не предполагается, что знает.

Когда программист использует VSETDCFG, чтобы установить типы элементов и количество используемых регистров, процессор использует эту информацию, чтобы вычислить максимальную длину вектора Max Vector Length (MVL).

LI        x5, 2<<25  # Load register x5 with 2<<25VSETDCFG  x5         # Set data configuration to x5

В примере выше происходят две вещи:

  • Включаем два регистра:v0иv1.

  • Устанавливаем тип элементов в 64-битные вещественные числа. Давайте сравним это с ARM Neon, в котором каждый регистр имеет длину 128 бит. Это означает, что Neon может обрабатывать два таких числа параллельно. Но в RISC-V 16 таких регистров можно объединить в один. Это позволяет обрабатывать 32 значения параллельно.

На самом деле это не буквально так. За сценой у нас есть конечное число вещественных умножителей, блоков АЛУ и т.п., что ограничивает число параллельных операций. Однако всё это уже детали реализации.

Итак, мы получили значениеMVL, равное 32. Разработчик не должен напрямую знать это число. Команда SETVLработает так:

SETVL rd, sr  ; rd  min(MVL, sr), VL  rd

Если вы попытаетесь установить Vector Length (VL)в значение 5, это сработает. Однако, если вы попытаетесь установить значение 60, вы получите вместо этого значение 32. Итак, величина Max Vector Length (MVL) важна, она не фиксирована конкретным значением при изготовлении процессора. Она может быть вычислена исходя из конфигурации (типа элементов и количества включенных регистров).

Вектора переменной длины в ARM

В ARM вы не устанавливаете длину вектора явным образом. Вместо этого вы устанавливаете длину вектора косвенно, используя предикатные регистры. Они являются битовыми масками, которыми вы включаете и выключаете элементы в векторном регистре. Регистры предикатов также существуют в RISC-V, но не имеют центральной роли, как в ARM.

Чтобы получить эквивалентSETVLна ARM , используйте командуWHILELT, что является сокращением отWhile Less Than:

WHILELT p3.d, x1, x4

Довольно сложно объяснить словами, что делает эта команда, и я использую псевдокод, чтобы объяснить её работу.

i = 0while i < M   if x1 < x4      p3[i] = 1   else      p3[i] = 0  end  i += 1  x1 += 1end

Концептуально, мы переворачиваем биты в регистре предиката p3в зависимости от того, меньше лиx1,чемx4. В данном случаеx4содержит длину вектора. Еслиp3выглядит так, то длину вектора можно считать равной 3.

1110000

То есть вектор переменной длины реализуется за счёт того, что все операции используют предикат. Рассмотрим эту операцию сложения. Представьте, чтоv0[p0]извлекает из v0только те элементы, для которыхp0истинно.

ADD v4.D, p0/M, v0.D, v1.D ; v4[p0]  v0[p0] + v1[p0]

Итак, мы сделали некоторое вступление. Сейчас рассмотрим более полный пример кода, чтобы увидеть, как эти наборы команд работают на практике.

Пример кода DAXPY

Рассмотрим сейчас, как функции C могут быть реализованы различными векторными командами:

void daxpy(size_t n, double a, double x[], double y[]) {        for (int64_t i = 0; i < n; ++i) {                y[i] = x[i] * a + y[i];        }}

Почему такое странное имя daxpy? Это простая функция в библиотеке линейной алгебры BLAS, популярной в научной работе. В BLAS эта функция называется daxpyи она очень популярна для демонстрации примеров работы разнообразных SIMD и векторных команд. Она реализует такую формулу:

aX + Y

гдеa скаляр, а XиY вектора. Без векторных команд нужно было бы обрабатывать все элементы в цикле. Но с умным компилятором, эти команды могут быть векторизованы в код, который выглядит на RISC-V так, как показано ниже. Комментарии показывают, какой регистр какой переменной соответствует:

daxpy(size_t n, double a, double x[], double y[]) n - a0  int   register (alias for x10) a - fa0 float register (alias for f10)  x - a1  (alias for x11)  y - a2  (alias for x12

Код:

LI       t0, 2<<25    VSETDCFG t0             # enable two 64-bit float regsloop:    SETVL  t0, a0           # t0  min(mvl, a0), vl  t0    VLD    v0, a1           # load vector x    SLLI   t1, t0, 3        # t1  vl * 2 (in bytes)    VLD    v1, a2           # load vector y    ADD    a1, a1, t1       # increment pointer to x by vl*8    VFMADD v1, v0, fa0, v1  # v1 += v0 * fa0 (y = a * x + y)    SUB    a0, a0, t0       # n -= vl (t0)    VST    v1, a2           # store Y    ADD    a2, a2, t1       # increment pointer to y by vl*8    BNEZ   a0, loop         # repeat if n != 0    RET    

Это код, скопированный из примера. Отметим, что мы не используем именаf иxдля целочисленных и вещественных регистров. Чтобы помочь разработчикам лучше помнить соглашения, ассемблер RISC-V определяет ряд псевдонимов. Например, аргументы функции передаются в регистрахx10-x17. Но нет необходимости запоминать эти номера, для аргументов предусмотрены псевдонимыa0-a7.

t0-t6 псевдонимы регистров временных переменных. Они не сохраняются между вызовами.

Для сравнения мы приведём ниже код ARM SVE. Пометим, какой регистр какую переменную содержит.

daxpy(size_t n, double a, double x[], double y[]) n - x0  register a - d0  float register x - x1  register  y - x2  register i - x3  register for the loop counter

Код:

daxpy:        MOV z2.d, d0            // a        MOV x3, #0              // i        WHILELT p0.d, x3, x0    // i, nloop:        LD1D z1.d, p0/z, [x1, x3, LSL #3] // load x        LD1D z0.d, p0/z, [x2, x3, LSL #3] // load y        FMLA z0.d, p0/m, z1.d, z2.d        ST1D z0.d, p0, [x2, x3, LSL #3]        INCD x3                 // i        WHILELT p0.d, x3, x0    // i, n        B.ANY loop        RET

Код ARM немного короче, так как команды ARM делают больше, чем одно действие. Это является причиной того, что код RISC-V гораздо проще читать. Команды в RISC-V делают что-то одно, и не требуют специального сложного синтаксиса. Такая простая вещь, как загрузка векторного регистра в ARM выглядит сложно:

LD1D z1.d, p0/z, [x1, x3, LSL #3]

Часть в квадратных скобках вычисляет адрес, из которого происходит загрузка:

[x1, x3, LSL #3] = x1 + x3*2 = x[i * 8]

Итак, здесь видно, чтоx1представляет базовый адрес переменнойx.x3 счётчикi. Сдвигом влево на три бита мы умножаем на 8, то есть на количество байт в 64-битном вещественно числе.

Заключение

Как начинающий в векторном кодинге, я должен сказать, что ARM переусложнён. Это не значит, что ARM плохой. Я также изучал систему команд Intel AVX, и она в 10 раз хуже. Я совершенно определённо не хочу тратить время на изучение AVX, принимая во внимание, сколько усилий отняли SVE и Neon.

Для меня совершенно ясно, что любой, кто хочет изучать кодинг на ассемблере, должен начать с RISC-V. Для начинающих это на порядки проще в освоении. И это не удивительно. Эта система команд специально разработана для преподавания в университете.

Архитектура сложна, потому что это легаси. Эта архитектура развивалась десятилетиями, и пыталась сохранять обратную совместимость. ARM имеет более ясный дизайн, но он усложнён просто потому, что дизайн продиктован в первую очередь требованиями отрасли, а не дружественностью к новичкам.

Если для вас, как и для меня, это хобби, и вы просто хотите понимать, как развивается технология, и как работают вещи, такие, как векторная обработка, сберегите свои усилия и просто прочитайте книгу по RISC-V.

Люди могут поспорить, что ARM или Intel или что-то ещё проще, потому что по ним больше книг и больше ресурсов. Ничего подобного! Я могу сказать вам на своём собственном опыте, что документация часто представляет собой препятствие, а не помощь. Это означает, что вам нужно раскопать больше материала. Вы найдёте много противоречий, корни которых лежат в устаревших принципах работы.

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

Подробнее..

Перевод Объяснение легковесные потоков в 200 строк на Rust

18.02.2021 20:14:00 | Автор: admin

Объяснение легковесных потоков в 200 строк на Rust


Легковесные потоки (ligthweight threads, coroutines, корутины, green threads) являются очень мощным механизмом в современных языках программирования. В этой статье Carl Fredrik Samson попытался реализовать рантайм для легковесных потоков на Раст, попутно объясняя, как они устроены "под капотом".


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


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

Green Threads


Грин треды решают общую проблему в программировании вам не хочется, чтобы ваш код блокировал процессор, растрачивая ресурсы. Решается это использованием многозадачности, которая позволяет приостановить выполнение одного участка кода, запуская на выполнение другой, переключаясь между "контекстами".


Легко спутать с параллелизмом, но не делайте этого это две совершенно разные вещи. Параллелизм это как забрасывание проблемы вычислительными ресурсами. Грин треды позволяют работать умнее и эффективнее, эффективнее расходовать ресурсы.


Два способа реализовать их:


  • вытесняющая многозадачность
  • невытесняющая (кооперативная) многозадачность

Вытесняющая многозадачность


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


Невытесняющая многозадачность


О ней и поговорим сегодня. Задача сама решает, что процессору лучше бы заняться чем-то ещё вместо того, чтобы ждать, когда в текущей задаче что-то случится. Обычно это выполняется с помощью передачи контроля (yielding control) планировщику. Нормальным юз-кейсом является передача контроля, когда что-то блокирует выполнение кода. Например, это могут быть операции ввода/вывода. Когда контроль уступили центральному планировщику напрямую, процессор возобновляет выполнение другой задачи, которая готова делать что-то ещё, кроме как блокировать.


Предварительная информация


Это самая техническая часть статьи, но если вы на самом деле хотите понимать происходящее, то просто обязаны пробраться сквозь её дебри. Обещаю сделать это быстро и по делу, как только возможно. Достаточно быстро перейдём к коду.


Перво-наперво, мы будем взаимодействовать и управлять процессором напрямую. Это не очень переносимо, т.к. видов процессоров целая прорва. Идея всё та же самая, но мелкие детали реализации отличаются.


Сосредоточимся на одной из самых широко используемых архитектур x86-64. В этой архитектуре процессор снабжён набором из 16 регистров:



Если интересно, остальную часть спецификации можно найти здесь


Вне нашего интереса прямо сейчас регистры, помеченные как "callee saved". Это регистры, которые хранят контекст следующую инструкцию для выполнения, указатель на базу, указатель на верхушку стека и т.д. Рассмотрим их чуть позже подробнее.


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


mov %rsp, %rax

Супербыстрое введение в ассемблер


В первую очередь. Язык ассемблера не слишком переносимый. Каждый ЦПУ может иметь специальный набор инструкций, однако, некоторые (наборы инструкций) являются общими на большинстве десктопных машинах


Есть два популярных диалекта: AT&T и Интел.


Диалект AT&T является стандартным при использовании ассемблерных вставок на Rust. Но можно использовать и диалект от Интел, указав на это компилятору. По большей части Раст перекладывает работу с ассемблерными вставками на LLVM. Для LLVM он очень похож на синтаксис ассемблерных вставок Си, но не точно такой же.


В примерах будем использовать диалект AT&T.


Ассемблер имеет строгие гарантии обратной совместимости. Вот поэтому вы видите те же самые регистры, которые адресуют данные разными способами. Взглянем на регистр %rax, который мы использовали в примере выше:


%rax    # 64 битный регистр (8 байт)%eax    # младшые 32 бита регистра "rax"%ax     # младшие 16 бит регистра "rax"%ah     # старшие 8 бит части "ax" регистра "rax"%al     # младшие 8 бит регистра "rax"

+-----------------------------------------------------------------------+|76543210 76543210 76543210 76543210 76543210 76543210 76543210 76543210|+--------+--------+--------+--------+--------+--------+--------+--------+|        |        |        |        |        |        |   %ah  |   %al  |+--------+--------+--------+--------+--------+--------+--------+--------+|        |        |        |        |        |        |       %ax       |+--------+--------+--------+--------+--------+--------+-----------------+|        |        |        |        |               %eax                |+--------+--------+--------+--------+-----------------------------------+|                                 %rax                                  |+-----------------------------------------------------------------------+

По сути, можно разглядеть историю эволюции процессоров. Так как большинство процессоров сейчас являются 64битными, то будем использовать 64битные регистры.


Указание размеров в "словах" в ассемблере обусловлено историческими причинами. Оно пошло из тех времён, когда у процессоров были шины данных в 16 бит, так что размер слова равен 16 битам. Это важно знать, т.к. в диалекте AT&T будете встречать множество инструкций с суффиксом q (quad-word четверное слово) или l (long-word длинное слово). Так что movq означает перемещение 4 * 16 бит = 64 бит.


Простая мнемоника mov будет использовать размер, заданный указанным регистром. Это стандартное поведение в диалекте AT&T.


Так же стоит обратить внимание на выравнивание стека по границе 16 байт в архитектуре x86-64. Просто стоит помнить об этом.


Пример, который мы будем собирать


В этом примере мы создадим свой собственный стек и заставим процессор вернуться из текущего контекста и перейти к использованию стеку, который мы создали.


Подготовка проекта


Начнём новый проект в каталоге с названием "green_threads". Запустите команду


cargo init

Из-за того, что некоторые нужные нам возможности ещё не стабилизированы, будем использовать ночную версию Раст:


rustup override set nightly

В файле main.rs начнём с установления флага, который позволит использовать макрос llvm_asm!:


#![feature(llvm_asm)]

Установим небольшой размер стека, только 48 байт, так что можно будет лишь поглядеть на него да вывести перед переключением контекстов.


const SSIZE: isize = 48;

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


#[derive(Debug, Default)]#[repr(C)]struct ThreadContext {    rsp: u64,}

В дальнейших примерах мы будем использовать все регистры, помеченные как "callee saved" в документации, на которую ссылка была ранее. Эти регистры описаны в ABI x86-64. Но прямо сейчас обойдёмся лишь одним, заставив процессор перейти по нашему стеку.


Так же стоит заметить, что из-за обращения к данным из ассемблера, нужно указать #[repr(C)]. У Раста нет стабильного ABI, так что нет уверенности в том, что значение rsp будет занимать первые 8 байт. У Си же есть стабильный ABI, и именно его компилятор будет использовать при указании атрибута.


fn hello() -> ! {    println!("I LOVE WAKING UP ON A NEW STACK!");    loop {}}

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


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


unsafe fn gt_switch(new: *const ThreadContext) {    llvm_asm!("        mov     0x00($0), %rsp        ret    "    :    : "r"(new)    :    : "alignstack" // пока работает без этого, но будет нужно позднее    );}

Здесь мы используем трюк. Мы пишем адрес функции, которую хотим запустить на нашем новом стеке. Затем мы передаём адрес первого байта, где мы сохранили этот адрес на регистр rsp (адрес в new.rsp будет указывать на адрес в нашем стеке, который ведёт к функции, указанной выше). Разобрались?


Ключевое слово ret передаёт контроль программе по адресу возврата, находящегося на вершине стека. Загрузив в регистр %rsp наш адрес, мы заставляем процессор думать, что это адрес, на который нужно будет вернуться из выполняемой в данный момент функции. Так что передав инструкцию ret, мы заставили процессор вернуться к выполнению задач в нашем собственном стеке.


И первое, что делает процессор, читает адрес нашей функции и запускает её.


Краткое введение в макрос для ассемблерных вставок


Если до этого вы ещё не использовали ассемблерные вставки, то он может вам показаться чужеродным. Мы будем использовать его расширенную версию чуть позже для переключения контекста. Объясню, что тут и как строка за строкой:


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


gt_switch(new: *const ThreadContext) принимаем указатель на экземпляр структуры ThreadContext, из которой мы будем читать только одно поле.


llvm_asm!(" макрос из стандартной библиотеки Раста. Он проверяет синтаксис и предоставляет сообщения об ошибках, если встречает что-то непохожее на валидный ассемблер диалекта AT&T (по-умолчанию).


Первое, что макрос принимает в качестве входных данных ассемблер с шаблоном mov 0x00($0), %rsp. Это простая инструкция, которая копирует значение, хранящееся по смещению 0x00 (в шестнадцатеричной системе; в данном случае оно нулевое) от позиции $0 в памяти, в регистр rsp. Регистр rsp хранит указатель на следующее значение в стеке. Мы перезаписываем значение, указывающее на верхушку стеку, на предоставленный адрес.


В нормальном ассемблерном коде вы не встретите $0. Это часть ассемблерного шаблона и означает первый параметр. Параметры нумеруются, как 0, 1, 2 и т.д., начиная с параметров output и двигаясь к параметрам input. У нас только один входной параметр, который соответствует $0.


Если встретите символ $ в ассемблере, то, скорее всего, он означает непосредственное значение (целочисленную константу), но это так не всегда так (да, символ доллара может означать разные вещи в зависимости от диалекта ассемблера и в зависимости от того, ассемблер это x86 или x86-64).


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


output:

Ассемблерные вставки немного отличаются от обычного ассемблера. В них четыре дополнительных параметра, которые мы передаём после ассемблерного шаблона. Первый из них называется output, и в нём передаются выходные параметры, которые используются для возвращения значений в нашей функции.


input: "r"(new)

Второй параметр это параметр input. Литерал "r" это то, что при написании ассемблерных вставок называется ограничением (constraint). Можно использовать эти ограничения для инструктирования компилятора о решении, где размещать ваши входные параметры (например, в одном из регистров или где-то в памяти). "r" означает, что нужно разместить значение в регистре общего назначения по выбору компилятора. Ограничения в ассемблерных вставках довольно обширная тема сама по себе, но, к счастью, нам слишком много и не нужно.


clobber list:

Следующая опция clobber list список, в котором указываются регистры, которые компилятор не должен трогать, и который позволяет ему сообщить, что мы хотим управлять ими в своём ассемблерном коде. Если мы хотим вытолкнуть из стека значения, то тут нужно указать, в какие регистры, и сообщить компилятору, что он не сможет спокойно распоряжаться этими ими. Ну а т.к. мы просто возвращаемся к новому стеку, то тут нам ничего и не требуется указывать.


options: "alignstack"

И последний параметр это опции. Для Раста они уникальные и мы можем задать три: alignstack, volatile и intel. Я прост оставлю ссылку на документацию, где объясняется их назначение. Для работы под Windows нам требуется указать опцию alignstack.


Запуск примера


fn main() {    let mut ctx = ThreadContext::default();    let mut stack = vec![0_u8; SSIZE as usize];    unsafe {        let stack_bottom = stack.as_mut_ptr().offset(SSIZE);        let sb_aligned = (stack_bottom as usize & !15) as *mut u8;        std::ptr::write(sb_aligned.offset(-16) as *mut u64, hello as u64);        ctx.rsp = sb_aligned.offset(-16) as u64;        gt_switch(&mut ctx);    }}

Это пример создания нашего нового стека. hello уже является указателем (на функцию), так что мы просто кастуем значение напрямую в u64, потому что все указатели на 64хбитных системах имеют такую длину, а потом записываем этот указатель в наш новый стек.


Чуть позже поговорим про стек подробнее, но сейчас уже нужно знать одну вещь стек растёт вниз (в сторону младших адресов). Если 48байтный стек начинается с индекса 0 и заканчивается индексом 47, индекс 32 будет находиться по смещению 16 байт от начала/базы нашего стека.

|0          1           2          3           4       |4  5|0123456789 012345|6789 0123456789 01|23456789 01234567|89 0123456789|                 |                  |XXXXXXXX         ||                 |                  |                 stack bottom|0th byte         |16th byte         |32nd byte

Заметьте, что мы записали указатель по смещению в 16 байт от базы нашего стека (помните, что я писал про выравнивание по границе 16 байт?)


Что делает строка let sb_aligned = (stack_bottom as usize & !15) as *mut u8;? Когда мы запрашиваем память при создании Vec<u8>, нет гарантии, что она будет выравнена по границе 16 байт. Эта строка просто округляет адрес до ближайшего меньшего, кратного 16 байтам. Если он уже кратен, то ничего не делает.

Мы кастуем указатель так, чтобы он указывал на тип u64 вместо u8. Мы хотим записать данные наше значение u64 на позиции 32-39, которые как раз и составляют 8 байт места под него. Без этого приведения типов мы будем пытаться записать наше u64-значение только в позицию 32, а это не то, что мы хотим сделать.


В регистр rsp (Stack Pointer указатель на стек) мы кладём адрес индекса 32 в нашем стеке. Мы не передаём само u64-значение, которое там хранится, а только адрес на первый байт этого значения.


Когда мы выполняем команду cargo run, то получаем:


dau@dau-work-pc:~/Projects/rust-programming-book/green_threads/green_threads$ cargo run   Compiling green_threads v0.1.0 (/home/dau/Projects/rust-programming-book/green_threads/green_threads)    Finished dev [unoptimized + debuginfo] target(s) in 0.44s     Running `target/debug/green_threads`I LOVE WAKING UP ON A NEW STACK!

Так что же происходит? Мы не вызываем функцию hello ни в каком виде, но она всё равно запускается. Происходит следующее: мы заставляем процессор перейти на работу с нашим собственным стеком и выполнить код. Мы сделали первый шаг к реализации переключения контекста.


Стек


Это очень важно знать. У компьютера есть только память. Нет специальной "стековой" или "памяти для кучи" это всё части одной и той же памяти.


Разница в том, как осуществляется доступ к этой памяти и как она используется. Стек поддерживает простые операции заталкивания и выталкивания (push/pop) в непрерывном участке памяти, что и делает его быстрым. Память в куче выделяется аллокатором по запросу и может быть разбросана.


Не будем погружаться в различия между стеком и кучей есть множество статей, где это описано, включая главу в Rust Programming Language.


Как выглядит стек


Начнём с упрощённого представления стека. 64битные процессоры читают по 8 байт за раз. В примере выше, даже представив стек в виде длинной строки из значений типаu8, передавая указатель, нам нужно быть уверенными, что он указывает на адрес 000, 0008 или 0016.



Стек растёт вниз, так что мы начинаем сверху и спускаемся ниже.


Когда мы помещаем в stack pointer указатель на стек, выровненный по границе 16 байт, мы нужно быть уверенными, что адрес, на который он указывает, кратен 16 (считая от начала стека прим). В примере выше, единственным адресом, удовлетворяющим условию, является адрес 0008 (помните, что стек начинается сверху).


Если мы добавим следующие строки в код нашего примера перед переключением (перед вызовом gt_switch), мы увидим содержимое нашего стека.


Прим: код изменён добавлен вывод адреса функции hello, значения в ячейках выводятся в шестнадцатеричной системе, добавлен вывод пустых строк для группировки и удобочитаемости.


print!(    "hello func address: 0x{addr:016X} ({addr})\n\n",    addr = hello as usize);for i in (0..SSIZE).rev() {    print!(        "mem: {}, value: 0x{:02X}\n{}",        stack.as_ptr().offset(i as isize) as usize,        *stack.as_ptr().offset(i as isize),        if i % 8 == 0 { "\n" } else { "" }    );}

Вот примерно такой вывод будет:


hello func address: 0x0000560CD80B50B0 (94613164216496)mem: 94613168839439, value: 0x00mem: 94613168839438, value: 0x00mem: 94613168839437, value: 0x00mem: 94613168839436, value: 0x00mem: 94613168839435, value: 0x00mem: 94613168839434, value: 0x00mem: 94613168839433, value: 0x00mem: 94613168839432, value: 0x00mem: 94613168839431, value: 0x00mem: 94613168839430, value: 0x00mem: 94613168839429, value: 0x56mem: 94613168839428, value: 0x0Cmem: 94613168839427, value: 0xD8mem: 94613168839426, value: 0x0Bmem: 94613168839425, value: 0x50mem: 94613168839424, value: 0xB0mem: 94613168839423, value: 0x00mem: 94613168839422, value: 0x00mem: 94613168839421, value: 0x00mem: 94613168839420, value: 0x00mem: 94613168839419, value: 0x00mem: 94613168839418, value: 0x00mem: 94613168839417, value: 0x00mem: 94613168839416, value: 0x00mem: 94613168839415, value: 0x00mem: 94613168839414, value: 0x00mem: 94613168839413, value: 0x00mem: 94613168839412, value: 0x00mem: 94613168839411, value: 0x00mem: 94613168839410, value: 0x00mem: 94613168839409, value: 0x00mem: 94613168839408, value: 0x00mem: 94613168839407, value: 0x00mem: 94613168839406, value: 0x00mem: 94613168839405, value: 0x00mem: 94613168839404, value: 0x00mem: 94613168839403, value: 0x00mem: 94613168839402, value: 0x00mem: 94613168839401, value: 0x00mem: 94613168839400, value: 0x00mem: 94613168839399, value: 0x00mem: 94613168839398, value: 0x00mem: 94613168839397, value: 0x00mem: 94613168839396, value: 0x00mem: 94613168839395, value: 0x00mem: 94613168839394, value: 0x00mem: 94613168839393, value: 0x00mem: 94613168839392, value: 0x00I LOVE WAKING UP ON A NEW STACK!

Я вывел адреса в памяти в виде значений u64, чтобы было легче в них разобраться, если вы не очень знакомы с шестнадцатеричной системой исчисления (что вряд ли прим.).


Первое, что заметно, так это то, что это непрерывный участок памяти, который начинается с адреса 94613168839392 и заканчивается адресом 94613168839439.


Адреса с 94613168839424 по 94613168839431 включительно представляют для нас особый интерес. Первый адрес это первый адрес нашего stack pointer, значения, которое мы записываем в регистр %rsp%. Диапазон представляет собой значения, которые мы пишем в стек перед переключением. (прим коряво и сомнительно!!!)


Ну а сами значения 0xB0, 0x50, 0x0B, 0xD8, 0x0C, 0x56, 0x00, 0x00 это указатель (адрес в памяти) на функцию hello(), записанный в виде значений u8.


По мере того, как мы будем писать более сложные функции, нашего крайне скудного 48байтного стека скоро перестанет хватать, вы увидите, как функции запускаются, а код инструктирует процессор заталкивать и выталкивать значения из нашего стека при выполнении программы.


Размеры стека


Когда вы запускаете процесс в большинстве современных операционных системах, стандартный размер стека обычно составляет 8 Мб, но может быть сконфигурирован и другой размер. Этого хватает для большинства программ, но на плечах программиста убедиться, что не используется больше, чем есть. Это и есть причина пресловутого "переполнения стека" (stack overflow), с которым многие из нас сталкивались.


Однако, когда мы сами управляем стеками, мы можем выбирать для них размер. 8 Мб для каждого контекста определённо больше, чем нам требуется при выполнении простых функций, например, в веб-сервере. Таким образом, уменьшив размер стека мы сможем держать миллионы легковесных потоков запущенными на машине, в то время, как при использовании стеков, предоставляемых операционной системой, мы очень скоро столкнёмся с нехваткой памяти.


Расширяемые стеки


Некоторые реализации используют расширяемые (growable способные расти) стеки. При этом выделяется небольшой объём памяти, достаточный для большинства задач. Но при его перерасходе, вместо переполнения стека, происходит выделение памяти большего объёма под новый стек, в который переносится всё из старого, после чего выполнение программы возобновляется.


Примером такой стратегии является реализация в языке Go. Работа начинается со стека размера в 8 Кб, и когда в нём заканчивается место, выделяется стек побольше. Как и в любом аспекте программирования, в данном поведении есть компромиссы, все имеющиеся указатели должны быть правильно обновлены, а это не очень простая задача. Если вас заинтересовало то, как GO управляет стеком (что является хорошим примером использования и компромиссной реализации растущего стека), то отправляю вас к этой статье.


Ещё одна вещь, что будет важна позже: мы использовали обычный вектор (Vec<u8>) из стандартной библиотеки. Он очень удобен, но с ним есть проблемы. Среди прочего, нет гарантии, что он останется на прежнем месте в памяти.
Как вы можете понять, если стек будет перемещён в памяти, то программа аварийно завершится из-за того, что все наши указатели станут недействительными. Что-нибудь такое простое, как вызов push() для нашего стека (имеется в виду вектор прим.) может вызвать его расширение. А когда вектор расширяется, то запрашивается новый кусок памяти большего размера, в который потом перемещаются значения.

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


Как настроить стек


Windows x86-64 организует стек чуть иначе, чем регламентируется соглашением вызовов x86-64 psABI. Я уделю чуть больше времени стеку Windows в приложении, но важно знать, что различия не такие большие, когда вы настраиваете стек под простые функции, которые не принимают параметры, что мы и делаем.


Организация стека в psABI выглядит следующим образом:



Как вы уже знаете, %rsp это наш указатель на стек. Теперь, как вы видите, нужно положить указатель на стек в позицию от base pointer, кратную 16. Адрес возврата располагается в соседних 8 байтах, и, как вы видите, выше ещё есть место для аргументов. Нужно держать всё это в уме, когда хотим сделать что-нибудь более сложное.


Вы заметите, что мы регулярно записываем адрес на нашу функцию по адресу stack_ptr + SSIZE - 16 без объяснения, почему именно так. По-любому SSIZE это размер стека в байтах.


Думайте об этом следующим образом. Мы знаем, что размер указателя (в нашем случае указателя на функцию) равен 8 байтам. Мы знаем, что rsp должен быть записан на границе 16 байт, чтобы соответствовать ABI.


У нас на самом деле и выбора-то нет, кроме как писать по адресу stack_ptr + SSIZE - 16. Записывая байты по адресам от младшего к старшему:


  • Мы не можем записать их по адреса, начиная с stack_ptr + SSIZE (является границей 16 байт), т.к. мы выйдем за границы выделенного участка памяти, что запрещено.
  • Мы не можем записать их по адресам, начиная с stack_ptr + SSIZE - 8, которые находятся внутри валидного адресного пространства, но не выровнены по границе 16 байт.

Остаётся только stack_ptr + SSIZE - 16 в качестве первой подходящей позиции. На практике мы пишем 8 байт в позиции -16, -15, -14, ..., -9 от старшего адреса нашего стека (который, запутывая, часто зовётся bottom of stack, т.к. растёт в сторону младших адресов (прим если адреса записать в колонку по порядку, вверху будут младшие адреса, а внизу старшие, то дно стека как раз будет внизу)).


Бонусный материал


Если вы достаточно любознательны, вы можете заинтересоваться тем, что происходит со стеком после того, как мы переключаемся на него.


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


К сожалению, чтобы показать это, пришлось увеличить размер стека до 1024 байт, чтобы в нём оказалось достаточно места для того, чтобы наш код смог распечатать его содержимое. Так что тут вывод не будет приводиться.


Взглянем на стек


Однако, я написал альтернативную версию нашего примера, который вы можете запустить. Он создаст два текстовых файла: BEFORE.txt (содержимое стека перед переключением на него) и AFTER.txt (содержимое стека после переключения). Вы можете своими глазами посмотреть, как живёт стек и используется нашим кодом.


Если вы видете что-то незнакомое в этом коде, то расслабьтесь позже мы подробно рассмотрим детали.

#![feature(llvm_asm)]#![feature(naked_functions)]use std::io::Write;const SSIZE: isize = 1024;static mut S_PTR: *const u8 = 0 as *const u8;#[derive(Debug, Default)]#[repr(C)]struct ThreadContext {    rsp: u64,    r15: u64,    r14: u64,    r13: u64,    r12: u64,    rbx: u64,    rbp: u64,}fn print_stack(filename: &str) {    let mut f = std::fs::File::create(filename).unwrap();    unsafe {        for i in (0..SSIZE).rev() {            writeln!(                f,                "mem: {}, val: {}",                S_PTR.offset(i as isize) as usize,                *S_PTR.offset( i as isize)            )            .expect("Error writing to file.");        }    }}fn hello() {    println!("I LOVE WAKING UP ON A NEW STACK!");    print_stack("AFTER.txt");    loop {}}unsafe fn gt_switch(new: *const ThreadContext) {    llvm_asm!("        mov     0x00($0), %rsp        ret        "        :        : "r"(new)        :        : "alignstack"    );}fn main() {    let mut ctx = ThreadContext::default();    let mut stack = vec![0_u8; SSIZE as usize];    let stack_ptr = stack.as_mut_ptr();    unsafe {        S_PTR = stack_ptr;        std::ptr::write(stack_ptr.offset(SSIZE - 16) as *mut u64, hello as u64);        print_stack("BEFORE.txt");        ctx.rsp = stack_ptr.offset(SSIZE - 16) as u64;        gt_switch(&mut ctx);    }}

Реализация грин тредов


Прежде, чем начать, замечу, что код, который мы напишем, не совсем безопасный, а так же не соответствует "лучшим практикам" (best practicies) в Расте. Я хочу попытаться сделать его, как можно безопаснее без привнесения множества ненужной сложности, так что если вы видите, что что-то можно сделать ещё безопаснее без сверхусложнения кода, то призываю тебя, дорогой читатель, предложить соответствующий PR в репозиторий.


Приступим


Первое, что сделаем удалим наш предыдущий пример в файле main.rs, начав всё с нуля, и добавим следующее:


#![feature(llvm_asm)]#![feature(naked_functions)]use std::ptr;const DEFAULT_STACK_SIZE: usize = 1024 * 1024 * 2;const MAX_THREADS: usize = 4;static mut RUNTIME: usize = 0;

Мы задействовали две фичи: ранее рассмотренную asm и фичу naked_functions, которую требуется в разъяснении.


naked_functions


Когда Раст компилирует функцию, он добавляет к ней небольшие "пролог" и "эпилог", которые вызывают некоторые проблемы из-за того, что при переключении контекстов стек оказывается невыровненным. Хотя в нашем простом примере всё работает нормально, но когда нам нужно переключаться обратно на тот же самый стек, то возникают трудности. Атрибут #[naked] убирает генерацию пролога и эпилога для функции. Главным образом этот атрибут используется в связке с ассемблерными вставками.


Если интересно, то про naked_functions можно почитать в RFC #1201.

Функции naked не совсем функции. Когда вызывается обычная функция, то сохраняются регистры, в стек заталкивается адрес возврата, стек выравнивается и т.п. При вызове naked-функций ничего из этого не происходит. Если слепо вызвать ret в naked-функции (без предварительных манипуляций как в прологе и эпилоге, описанных в ABI), то попадёте на территорию неопределённого поведения. В лучшем случае закончите в caller's caller коде с мусором в регистрах.

Размер стека DEFAULT_STACK_SIZE задан в 2 МБ, чего более, чем достаточно для наших нужд. Так же задаём количество тредов (MAX_THREADS) равным 4, т.к. для примера больше не нужно.


Последняя строка константа RUNTIME указатель на структуру, содержащую информацию о системе времени исполнения (да, я знаю, что использовать мутабельную глобальную переменную для этого не очень красиво, она нам нужна дальше, а задаём для неё значение только во время инициализации).


Для представления наших данных допишем кое-что свежее:


pub struct Runtime {    threads: Vec<Thread>,    current: usize,}#[derive(Debug, Eq, PartialEq)]enum State {    Available,    Running,    Ready,}struct Thread {    id: usize,    stack: Vec<u8>,    ctx: ThreadContext,    state: State,}#[derive(Debug, Default)]#[repr(C)]struct ThreadContext {    rps: u64,    r15: u64,    r14, u64,    r13: u64,    r12: u64,    rbx: u64,    rbp: u64,}

Главной точкой входа будет структура Runtime. Мы создадим очень маленький, простой рантайм для планирования выполнения потоков и переключения между ними. Структура содержит в себе вектор структур Thread и поле current, которое указывает на поток, который выполняется в данный момент.


Структура Thread содержит данные для потока. Для того, чтобы отличать потоки друг от друга, они имеют уникальный id. Поле stack такое же, какое мы видели в примерах ранее. Поле ctx содержит данные для процессора, которые нужны для продолжения работы потока с того места, где он покинул стек. Поле state это состояние потока.


State это перечисление состояний потока, которое может принимать значения:


  • Available поток доступен и готов быть назначенным для выполнения задачи, если нужно.
  • Running поток выполняется
  • Ready поток готов продолжать и возобновить выполнение.

ThreadContext содержит данные регистров, которые нужны процессору для возобновления исполнения на стеке.


Если запамятовали, то вернитесь к части "Предварительные сведения" для того, чтобы почитать о регистрах. В спецификации архитектуры x86-64 Эти регистры помечены как "callee saved".

Продолжаем:


impl Thread {    fn new(id: usize) -> Self {        Thread {            id,            stack: vec![0_u8; DEFAULT_STACK_SIZE],            ctx: ThreadContext::default(),            state: State::Available,        }    }}

Тут всё довольно просто. Новый поток стартует в состоянии Available, которое означает, что он готов принять задачу для исполнения.


Нужно отметить, что здесь мы выделяем стек. Это не слишком необходимое и оптимальное решение с точки зрения использования ресурсов, т.к. память можно выделить при первом использовании структуры. Но это позволяет снизить сложность в некоторых частях кода на которых можно сфокусировать внимание, вместо того, чтобы выделять память для стека.


Важно заметить, что после того, как стек выделен, он не должен перемещаться. Никаких вызовов метода push() и других, которые могут спровоцировать перевыделение памяти. Лучше было бы использовать свой собственный тип данных, который предоставляет только безопасные с этой точки зрения методы.

Так же упомянем, что у Vec<T> есть метод into_boxed_slice(), который возвращает Box<[T] срез, выделенный в куче. Срезы не могут быть расширены, так что мы можем использовать их для решения проблемы перевыделения памяти.

Реализация рантайма


Весь код этой части находится внутри блока impl Runtime, реализуя методы для одноимённой структуры.


impl Runtime {    pub fn new() -> Self {        let base_thread = Thread {            id: 0,            stack: vec![0_u8; DEFAULT_STACK_SIZE],            ctx: ThreadContext::default(),            state: State::Running,        };        let mut threads = vec![base_thread];        let mut available_threads: Vec<Thread> = (1..MAX_THREADS).map(|i| Thread::new(i)).collect();        threads.append(&mut available_threads);        Runtime {            threads,            current: 0,        }    }    // code of other methods is here    // ...}

При инстанцировании структуры Runtime создаётся базовый поток в состоянии Running. Он держит среду исполнения запущенной пока все задачи не будут завершены. Потом создаются остальные потоки, а текущим назначается поток с номером 0, который является базовым.


// Читерство, но нам нужен указатель на структуру Runtime// сохранённый таким образом, чтобы мы могли вызывать метод yield// без прямого обращения к этой структуре по ссылке.// по сути мы дублируем указатель втихомолку от компилятораpub fn init(&self) {    unsafe {        let r_ptr: *const Runtime = self;        RUNTIME = r_ptr as usize;    }}

Сейчас нам это нужно. Как я упоминал, когда расписывал константы, нам нужна глобальная переменная для того, чтобы вызывать метод yield позже. Не очень симпатично, но мы знаем, что наш рантайм должен жить, пока есть потоки.


pub fn run(&mut self) -> ! {    while welf.t_yield() {};    std::process::exit(0);}

А это то место, где мы запускаем наш рантайм. Он беспрестанно вызывает метод t_yield(), пока тот не вернёт значение false, что означает, задач больше нет, и мы можем завершить процесс.


fn t_return(&mut self) {    if self.current != 0 {        std.threads[self.current].state = State::Available;        self.t_yield();    }}

Когда процесс завершается, то вызываем эту функцию. Мы назвали её t_return, т.к. слово return входит в список зарезервированных. Заметьте, что пользователь наших потоков не вызывает эту функцию мы организуем стек таким образовам, что эта функцию вызывается, когда задача завершается.


Если вызывающий поток является базовым, то ничего не делаем. Наш рантайм вызывает метод yield для базового потока. Если же вызов приходит из порождённого (spawned) потока, мы узнаём, что он завершился, т.к. у всех потоков на вершине их стека находится функция guard (её объясню позже). Так что единственное место, откуда t_return может быть вызвана это как раз функция guard.


Мы назначаем потоку состояние Available, сообщая рантайму, что готовы принять новую задачу (task), а затем немедленно вызываем t_yield, который дёргает планировщик для запуска нового потока.


Далее рассмотрим функцию yield:


fn t_yield(&mut self) -> bool {    let mut post = self.current;    while self.threads[pos].state != State::Ready {        pos += 1;        if pos == self.threads.len() {            pos = 0;        }        if pos == self.current {            return false;        }    }    if self.threads[self.current].state != State::Available {        self.threads[self.current].state = State::Ready;    }    self.threads[pos].state = State::Running;    let old_pos = self.current;    self.current = pos;    unsafe {        let old: *mut ThreadContext = &mut self.threads[old_pos].ctx;        let new: *const ThreadContext = &self.threads[pos].ctx;        llvm_asm!(            "            mov $0, %rdi            mov $1, %rsi            call switch            "            :            : "r"(old), "r"(new)            :            :        );    }    self.threads.len() > 0}

Это сердце нашего рантайма. Пришлось выбрать имя t_yield, т.к. yield является зарезервированным словом (прим. используется в генераторах, которые ещё не стабилизированы).


Здесь мы обходим все треды и смотрим, есть ли какой-нибудь из них в состоянии Ready, что означает, что у него есть задача, которую он готов выполнять. Это может быть обращение к базе данных, которое вернулось в приложение реального мира (Чтоа? "This coould be a database call that has returned in a real world application").


Если же потоков в состоянии Ready, то все задачи выполнены. Это крайне просто планировщик, который использует только алгоритм round-robin. Реальные планировщики могут иметь более сложный способ определения, какую задачу выполнять следующей.


Это очень наивная реализация для нашего примера. Что случится, если тред не готов двигаться дальше (не в состоянии Ready) и всё ещё ожидает чего-то, например, ответа от базы данных?

Не слишком сложно обработать такой случай. Вместо запуска нашего кода напрямую, когда поток готов, мы можем запросить (poll) его о статусе. Например, он может вернуть значение IsReady, если он дейсвительно готов заняться работой, или вернуть Pending, если он ожидает завершения какой-либо операции. В последнем случае мы можем просто оставить его в состоянии Ready, чтобы опросить позднее. Звучит знакомо? Если читали про то, как работают Футуры, то наверное в голове у вас складывается картина того, как это всё состыкуется вместе.

Если мы находим поток, который готов к работе, мы меняем состояние текущего потока с Running на Ready.


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


Неудобная правда о naked функциях



Функции naked не похожи на обычные. Например, они не принимают формальных аргументов. Обычно, когда вызывается функция с двумя аргументами, компилятор разместит каждый из них в регистрах, согласно соглашению о вызове функций для данной платформы. Когда же мы вызываем функцию, помеченную как #[naked], об этом придётся заботиться самостоятельно. Таким образом мы передаём адрес наших "новой" и "старой" структур ThreadContext, используя ассемблер. В соглашении о вызове на платформе Linux первый аргумент помещатся в регистр %rdi, а второй в регистр %rsi.

Часть self.threads.len() > 0 просто способ указать компилятору, чтобы он не применял оптимизацию. У меня такое нежелательное поведение проявлялось на Windows, но не на Linux, и является общей проблемой при запуске бенчмарков, например. Таким же образом мы можем использовать std::hint::black_box для того, чтобы указать компилятору, что не нужно ускорять код, пропуская шаги, которые нам необходимы. Я выбрал другой путь, а даже если его закомментировать, всё будет ок. В любом случае, код никогда не попадёт в эту точку.


Следом идёт наша функция spawn():


pub fn spawn(&mut self, f: fn()) {    let available = self        .threads        .iter_mut()        .find(|t| t.state == State::Available)        .expect("no available thread.");    let size == available.stack.len();    unsafe {        let s_ptr = available.stack.as_mut_ptr().offset(size as isize);        let s_ptr = (s_ptr as usize & !15) as *mut u8;        std::ptr::write(s_ptr.offset(-16) as *mut u64, guard as u64);        std::ptr::write(s_ptr.offset(-24) as *mut u64, skip as u64);        std::ptr::write(s_ptr.offset(-32) as *mut u64, f as u64);        available.ctx.rsp = s_ptr.offset(-32) as u64;    }    available.state = State::Ready;}// не забудьте про закрывающую скобку блока `impl Runtime`

В то время, как t_yield интересна в плане логики, в техническом плане фукнция spawn наиболее интересна.


Это то, где мы настраиваем стек так, как обсуждали ранее, и убеждаемся, что он выглядит так, как указано в psABI.


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


Когда нашли доступный поток, мы получаем его стек (в виде ссылки на массив u8) и его длину.


В следующем сегменте приходится использовать некоторые unsafe-функции. Сперва убеждаемся, что сегмент памяти выровнен по границе 16 байт. Затем мы записываем адрес функции guard, которая будет вызвана, когда предоставленная нами задача завершится и функция вернёт значение. Следом мы записываем адрес функции skip, которая здесь нужна лишь для обработки промежуточного этапа, когда мы возвращаемся из функции f таким образом, чтобы функция guard вызывалась по границе памяти в 16 байт. И следующее значение, которое мы записываем, это адрес функции f.


Вспомните объяснения того, как работает стек. Мы хотим, чтобы f была первой функцией, которая будет запущена. Поэтому на неё и указывает base pointer с учётом выравнивания. Затем мы заталкиваем в стек адрес на фукнции skip и guard. Таким образом мы обеспечиваем выравнивание функции guard по границе 16 байт, что необходимо сделать по требованиям ABI.

После того, как мы записали указатели на функции в стек, мы записываем в регистр rsp (который явялется указателем на стек) адрес предоставленной функции. Таким образом мы запускаем её на исполнение в первую очередь при очередном цикле планировщика.


И наконец, мы устанавливаем состояние потока в Ready, что означает, у нас есть работа для выполнения, и мы готовы её делать. Вспомните, что это знак для нашего "планировщика" запустить этот поток.


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


Функции guard, skip и switch


fn guard() {    unsafe {        let rt_ptr = RUNTIME as *mut Runtime;        (*rt_ptr).t_return();    };}

Когда функция, которую мы передали на исполнение, возвращает результат, что так же означает, что поток завершил выолнение своей задачи, мы разыменовываем глобальную переменную, и вызываем метод t_return() нашей среды исполнения. Мы могли бы написать функцию, которая проделывала некоторую дополнительную работу, когда поток завершается, но в данный момент того, что делает t_return, нам достаточно. Она помечает тред как Available (если это не базовый тред), и вызывает метод t_yield, что позволяет возобновить работу над другой задачей в другом потоке.


#[naked]fn skip() { }

В функции skip не так много всего происходит. Мы используем атрибут #[naked], так что фукнция компилируется в единственную инструкцию ret, которая просто выталкивает очередное значение из стека и переходит по адресу, который в этом значении содержится. В нашем случае, это значение указывает на функцию guard.


pub fn yield_thread() {    unsafe {        let rt_ptr = RUNTIME as *mut Runtime;        (*rt_ptr).t_yield();    };}

А это просто вспомогательная функция, которая позволяет вызывать метод t_yield из любого места в коде. Она так же небезопасная, потому что наш рантайм может быть ещё неинициализирован или наоборот, уже деинициализирован (дропнут), что приведёт к неопределённому поведению. Однако же, сделать это более безопасным для нашего примера, не в приоритете.


Совсем скоро мы подойдём к последней строке, всего одна функция осталась. Если внимательно читали всё предыдущее, то можно понять её суть и без обилия комментариев.


#[naked]#[inline(never)]unsafe fn switch() {    llvm_asm!("        mov     %rsp, 0x00(%rdi)        mov     %r15, 0x08(%rdi)        mov     %r14, 0x10(%rdi)        mov     %r13, 0x18(%rdi)        mov     %r12, 0x20(%rdi)        mov     %rbx, 0x28(%rdi)        mov     %rbp, 0x30(%rdi)        mov     0x00(%rsi), %rsp        mov     0x08(%rsi), %r15        mov     0x10(%rsi), %r14        mov     0x18(%rsi), %r13        mov     0x20(%rsi), %r12        mov     0x28(%rsi), %rbx        mov     0x30(%rsi), %rbp        "    );}

Здесь ассемблерная вставка. Как помните из первого примера, это просто более расширенная версия, где мы сначала читаем значения из нужных регистров, а затем записываем в них новые значения, которые были сохранены, когда мы приостановили выполнение "нового" потока.


Это всё, что необходимо, чтобы запомнить и потом восстановить исполнение.


Так же мы снова видим использование атрибута #[naked]. Обычно каждая функция имеет пролог и эпилог, а здесь мы не хотим их, так как функция состоит целиком из ассемблерной вставки, и мы обо всём заботимся самостоятельно. Если же не включить этот атрибут, то будут проблемы при повторном переключении обратно к нашему стеку.


Так же есть ещё одно отличие от нашей первой функции. Это атрибут #[inline(never)], который запрещяет компилятору встраивать эту функцию. Я провёл некоторое время, выясняя, почему код фейлится при сборке с флагом --release.


Функция main


fn main() {    let mut runtime = Runtime::new();    runtime.init();    runtime.spawn(|| {        println!("THREAD 1 STARTING");        let id = 1;        for i in 1..=10 {            println!("thread: {} counter: {}", id, i);            yield_thread();        }        println!("THREAD 1 FINISHED");    });    runtime.spawn(|| {        println!("THREAD 2 STARTING");        let id = 2;        for i in 1..=15 {            println!("thread: {} counter: {}", id i);            yield_thread();        }        println!("THREAD 2 FINISHED");    });    runtime.run();}

Как видите, мы тут инициализируем рантайм и порождаем два потока, которые считают от 0 до 9 и до 15, а так же уступают работу между итерациями. Если запустим наш проект через cargo run, то должны увидеть следующий вывод:


THREAD 1 STARTINGthread: 1 counter: 1THREAD 2 STARTINGthread: 2 counter: 1thread: 1 counter: 2thread: 2 counter: 2thread: 1 counter: 3thread: 2 counter: 3thread: 1 counter: 4thread: 2 counter: 4thread: 1 counter: 5thread: 2 counter: 5thread: 1 counter: 6thread: 2 counter: 6thread: 1 counter: 7thread: 2 counter: 7thread: 1 counter: 8thread: 2 counter: 8thread: 1 counter: 9thread: 2 counter: 9thread: 1 counter: 10thread: 2 counter: 10THREAD 1 FINISHEDthread: 2 counter: 11thread: 2 counter: 12thread: 2 counter: 13thread: 2 counter: 14thread: 2 counter: 15THREAD 2 FINISHED

Прекрасно. Наши потоки сменяют друг друга после каждого отсчёта, уступая друг другу контроль. А когда поток 1 завершается, то поток 2 продолжает свои отсчёты.


Поздравления


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

Подробнее..

Перевод Объяснение легковесных потоков в 200 строк на Rust

18.02.2021 22:12:39 | Автор: admin

Объяснение легковесных потоков в 200 строк на Rust


Легковесные потоки (ligthweight threads, coroutines, корутины, green threads) являются очень мощным механизмом в современных языках программирования. В этой статье Carl Fredrik Samson попытался реализовать рантайм для легковесных потоков на Раст, попутно объясняя, как они устроены "под капотом".


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


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

Green Threads


Грин треды решают общую проблему в программировании вам не хочется, чтобы ваш код блокировал процессор, растрачивая ресурсы. Решается это использованием многозадачности, которая позволяет приостановить выполнение одного участка кода, запуская на выполнение другой, переключаясь между "контекстами".


Легко спутать с параллелизмом, но не делайте этого это две совершенно разные вещи. Параллелизм это как забрасывание проблемы вычислительными ресурсами. Грин треды позволяют работать умнее и эффективнее, эффективнее расходовать ресурсы.


Два способа реализовать их:


  • вытесняющая многозадачность
  • невытесняющая (кооперативная) многозадачность

Вытесняющая многозадачность


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


Невытесняющая многозадачность


О ней и поговорим сегодня. Задача сама решает, что процессору лучше бы заняться чем-то ещё вместо того, чтобы ждать, когда в текущей задаче что-то случится. Обычно это выполняется с помощью передачи контроля (yielding control) планировщику. Нормальным юз-кейсом является передача контроля, когда что-то блокирует выполнение кода. Например, это могут быть операции ввода/вывода. Когда контроль уступили центральному планировщику напрямую, процессор возобновляет выполнение другой задачи, которая готова делать что-то ещё, кроме как блокировать.


Предварительная информация


Это самая техническая часть статьи, но если вы на самом деле хотите понимать происходящее, то просто обязаны пробраться сквозь её дебри. Обещаю сделать это быстро и по делу, как только возможно. Достаточно быстро перейдём к коду.


Перво-наперво, мы будем взаимодействовать и управлять процессором напрямую. Это не очень переносимо, т.к. видов процессоров целая прорва. Идея всё та же самая, но мелкие детали реализации отличаются.


Сосредоточимся на одной из самых широко используемых архитектур x86-64. В этой архитектуре процессор снабжён набором из 16 регистров:



Если интересно, остальную часть спецификации можно найти здесь


Вне нашего интереса прямо сейчас регистры, помеченные как "callee saved". Это регистры, которые хранят контекст следующую инструкцию для выполнения, указатель на базу, указатель на верхушку стека и т.д. Рассмотрим их чуть позже подробнее.


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


mov %rsp, %rax

Супербыстрое введение в ассемблер


В первую очередь. Язык ассемблера не слишком переносимый. Каждый ЦПУ может иметь специальный набор инструкций, однако, некоторые (наборы инструкций) являются общими на большинстве десктопных машинах


Есть два популярных диалекта: AT&T и Интел.


Диалект AT&T является стандартным при использовании ассемблерных вставок на Rust. Но можно использовать и диалект от Интел, указав на это компилятору. По большей части Раст перекладывает работу с ассемблерными вставками на LLVM. Для LLVM он очень похож на синтаксис ассемблерных вставок Си, но не точно такой же.


В примерах будем использовать диалект AT&T.


Ассемблер имеет строгие гарантии обратной совместимости. Вот поэтому вы видите те же самые регистры, которые адресуют данные разными способами. Взглянем на регистр %rax, который мы использовали в примере выше:


%rax    # 64 битный регистр (8 байт)%eax    # младшые 32 бита регистра "rax"%ax     # младшие 16 бит регистра "rax"%ah     # старшие 8 бит части "ax" регистра "rax"%al     # младшие 8 бит регистра "rax"

+-----------------------------------------------------------------------+|76543210 76543210 76543210 76543210 76543210 76543210 76543210 76543210|+--------+--------+--------+--------+--------+--------+--------+--------+|        |        |        |        |        |        |   %ah  |   %al  |+--------+--------+--------+--------+--------+--------+--------+--------+|        |        |        |        |        |        |       %ax       |+--------+--------+--------+--------+--------+--------+-----------------+|        |        |        |        |               %eax                |+--------+--------+--------+--------+-----------------------------------+|                                 %rax                                  |+-----------------------------------------------------------------------+

По сути, можно разглядеть историю эволюции процессоров. Так как большинство процессоров сейчас являются 64битными, то будем использовать 64битные регистры.


Указание размеров в "словах" в ассемблере обусловлено историческими причинами. Оно пошло из тех времён, когда у процессоров были шины данных в 16 бит, так что размер слова равен 16 битам. Это важно знать, т.к. в диалекте AT&T будете встречать множество инструкций с суффиксом q (quad-word четверное слово) или l (long-word длинное слово). Так что movq означает перемещение 4 * 16 бит = 64 бит.


Простая мнемоника mov будет использовать размер, заданный указанным регистром. Это стандартное поведение в диалекте AT&T.


Так же стоит обратить внимание на выравнивание стека по границе 16 байт в архитектуре x86-64. Просто стоит помнить об этом.


Пример, который мы будем собирать


В этом примере мы создадим свой собственный стек и заставим процессор вернуться из текущего контекста и перейти к использованию стеку, который мы создали.


Подготовка проекта


Начнём новый проект в каталоге с названием "green_threads". Запустите команду


cargo init

Из-за того, что некоторые нужные нам возможности ещё не стабилизированы, будем использовать ночную версию Раст:


rustup override set nightly

В файле main.rs начнём с установления флага, который позволит использовать макрос llvm_asm!:


#![feature(llvm_asm)]

Установим небольшой размер стека, только 48 байт, так что можно будет лишь поглядеть на него да вывести перед переключением контекстов.


const SSIZE: isize = 48;

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


#[derive(Debug, Default)]#[repr(C)]struct ThreadContext {    rsp: u64,}

В дальнейших примерах мы будем использовать все регистры, помеченные как "callee saved" в документации, на которую ссылка была ранее. Эти регистры описаны в ABI x86-64. Но прямо сейчас обойдёмся лишь одним, заставив процессор перейти по нашему стеку.


Так же стоит заметить, что из-за обращения к данным из ассемблера, нужно указать #[repr(C)]. У Раста нет стабильного ABI, так что нет уверенности в том, что значение rsp будет занимать первые 8 байт. У Си же есть стабильный ABI, и именно его компилятор будет использовать при указании атрибута.


fn hello() -> ! {    println!("I LOVE WAKING UP ON A NEW STACK!");    loop {}}

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


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


unsafe fn gt_switch(new: *const ThreadContext) {    llvm_asm!("        mov     0x00($0), %rsp        ret    "    :    : "r"(new)    :    : "alignstack" // пока работает без этого, но будет нужно позднее    );}

Здесь мы используем трюк. Мы пишем адрес функции, которую хотим запустить на нашем новом стеке. Затем мы передаём адрес первого байта, где мы сохранили этот адрес на регистр rsp (адрес в new.rsp будет указывать на адрес в нашем стеке, который ведёт к функции, указанной выше). Разобрались?


Ключевое слово ret передаёт контроль программе по адресу возврата, находящегося на вершине стека. Загрузив в регистр %rsp наш адрес, мы заставляем процессор думать, что это адрес, на который нужно будет вернуться из выполняемой в данный момент функции. Так что передав инструкцию ret, мы заставили процессор вернуться к выполнению задач в нашем собственном стеке.


И первое, что делает процессор, читает адрес нашей функции и запускает её.


Краткое введение в макрос для ассемблерных вставок


Если до этого вы ещё не использовали ассемблерные вставки, то он может вам показаться чужеродным. Мы будем использовать его расширенную версию чуть позже для переключения контекста. Объясню, что тут и как строка за строкой:


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


gt_switch(new: *const ThreadContext) принимаем указатель на экземпляр структуры ThreadContext, из которой мы будем читать только одно поле.


llvm_asm!(" макрос из стандартной библиотеки Раста. Он проверяет синтаксис и предоставляет сообщения об ошибках, если встречает что-то непохожее на валидный ассемблер диалекта AT&T (по-умолчанию).


Первое, что макрос принимает в качестве входных данных ассемблер с шаблоном mov 0x00($0), %rsp. Это простая инструкция, которая копирует значение, хранящееся по смещению 0x00 (в шестнадцатеричной системе; в данном случае оно нулевое) от позиции $0 в памяти, в регистр rsp. Регистр rsp хранит указатель на следующее значение в стеке. Мы перезаписываем значение, указывающее на верхушку стеку, на предоставленный адрес.


В нормальном ассемблерном коде вы не встретите $0. Это часть ассемблерного шаблона и означает первый параметр. Параметры нумеруются, как 0, 1, 2 и т.д., начиная с параметров output и двигаясь к параметрам input. У нас только один входной параметр, который соответствует $0.


Если встретите символ $ в ассемблере, то, скорее всего, он означает непосредственное значение (целочисленную константу), но это не всегда так (да, символ доллара может означать разные вещи в зависимости от диалекта ассемблера и в зависимости от того, ассемблер это x86 или x86-64).


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


output:

Ассемблерные вставки немного отличаются от обычного ассемблера. В них четыре дополнительных параметра, которые мы передаём после ассемблерного шаблона. Первый из них называется output, и в нём передаются выходные параметры, которые используются для возвращения значений в нашей функции.


input: "r"(new)

Второй параметр это параметр input. Литерал "r" это то, что при написании ассемблерных вставок называется ограничением (constraint). Можно использовать эти ограничения для инструктирования компилятора о решении, где размещать ваши входные параметры (например, в одном из регистров или где-то в памяти). "r" означает, что нужно разместить значение в регистре общего назначения по выбору компилятора. Ограничения в ассемблерных вставках довольно обширная тема сама по себе, но, к счастью, нам слишком много и не нужно.


clobber list:

Следующая опция clobber list список, в котором указываются регистры, которые компилятор не должен трогать, и который позволяет ему сообщить, что мы хотим управлять ими в своём ассемблерном коде. Если мы хотим вытолкнуть из стека значения, то тут нужно указать, в какие регистры, и сообщить компилятору, что он не сможет спокойно распоряжаться ими. Ну а т.к. мы просто возвращаемся к новому стеку, то тут нам ничего и не требуется указывать.


options: "alignstack"

И последний параметр это опции. Для Раста они уникальные и мы можем задать три: alignstack, volatile и intel. Я просто оставлю ссылку на документацию, где объясняется их назначение. Для работы под Windows нам требуется указать опцию alignstack.


Запуск примера


fn main() {    let mut ctx = ThreadContext::default();    let mut stack = vec![0_u8; SSIZE as usize];    unsafe {        let stack_bottom = stack.as_mut_ptr().offset(SSIZE);        let sb_aligned = (stack_bottom as usize & !15) as *mut u8;        std::ptr::write(sb_aligned.offset(-16) as *mut u64, hello as u64);        ctx.rsp = sb_aligned.offset(-16) as u64;        gt_switch(&mut ctx);    }}

Это пример создания нашего нового стека. hello уже является указателем (на функцию), так что мы просто кастуем значение напрямую в u64, потому что все указатели на 64хбитных системах имеют такую длину, а потом записываем этот указатель в наш новый стек.


Чуть позже поговорим про стек подробнее, но сейчас уже нужно знать одну вещь стек растёт вниз (в сторону младших адресов). Если 48байтный стек начинается с индекса 0 и заканчивается индексом 47, индекс 32 будет находиться по смещению 16 байт от начала/базы нашего стека.

|0          1           2          3           4       |4  5|0123456789 012345|6789 0123456789 01|23456789 01234567|89 0123456789|                 |                  |XXXXXXXX         ||                 |                  |                 stack bottom|0th byte         |16th byte         |32nd byte

Заметьте, что мы записали указатель по смещению в 16 байт от базы нашего стека (помните, что я писал про выравнивание по границе 16 байт?)


Что делает строка let sb_aligned = (stack_bottom as usize & !15) as *mut u8;? Когда мы запрашиваем память при создании Vec<u8>, нет гарантии, что она будет выравнена по границе 16 байт. Эта строка просто округляет адрес до ближайшего меньшего, кратного 16 байтам. Если он уже кратен, то ничего не делает.

Мы кастуем указатель так, чтобы он указывал на тип u64 вместо u8. Мы хотим записать данные наше значение u64 на позиции 32-39, которые как раз и составляют 8 байт места под него. Без этого приведения типов мы будем пытаться записать наше u64-значение только в позицию 32, а это не то, что мы хотим сделать.


В регистр rsp (Stack Pointer указатель на стек) мы кладём адрес индекса 32 в нашем стеке. Мы не передаём само u64-значение, которое там хранится, а только адрес на первый байт этого значения.


Когда мы выполняем команду cargo run, то получаем:


dau@dau-work-pc:~/Projects/rust-programming-book/green_threads/green_threads$ cargo run   Compiling green_threads v0.1.0 (/home/dau/Projects/rust-programming-book/green_threads/green_threads)    Finished dev [unoptimized + debuginfo] target(s) in 0.44s     Running `target/debug/green_threads`I LOVE WAKING UP ON A NEW STACK!

Так что же происходит? Мы не вызываем функцию hello ни в каком виде, но она всё равно запускается. Происходит следующее: мы заставляем процессор перейти на работу с нашим собственным стеком и выполнить код. Мы сделали первый шаг к реализации переключения контекста.


Стек


Это очень важно знать. У компьютера есть только память. Нет специальной "стековой" или "памяти для кучи" это всё части одной и той же памяти.


Разница в том, как осуществляется доступ к этой памяти и как она используется. Стек поддерживает простые операции заталкивания и выталкивания (push/pop) в непрерывном участке памяти, что и делает его быстрым. Память в куче выделяется аллокатором по запросу и может быть разбросана.


Не будем погружаться в различия между стеком и кучей есть множество статей, где это описано, включая главу в Rust Programming Language.


Как выглядит стек


Начнём с упрощённого представления стека. 64битные процессоры читают по 8 байт за раз. В примере выше, даже представив стек в виде длинной строки из значений типаu8, передавая указатель, нам нужно быть уверенными, что он указывает на адрес 000, 0008 или 0016.



Стек растёт вниз, так что мы начинаем сверху и спускаемся ниже.


Когда мы помещаем в stack pointer указатель на стек, выровненный по границе 16 байт, мы нужно быть уверенными, что адрес, на который он указывает, кратен 16 (считая от начала стека прим). В примере выше, единственным адресом, удовлетворяющим условию, является адрес 0008 (помните, что стек начинается сверху).


Если мы добавим следующие строки в код нашего примера перед переключением (перед вызовом gt_switch), мы увидим содержимое нашего стека.


Прим: код изменён добавлен вывод адреса функции hello, значения в ячейках выводятся в шестнадцатеричной системе, добавлен вывод пустых строк для группировки и удобочитаемости.


print!(    "hello func address: 0x{addr:016X} ({addr})\n\n",    addr = hello as usize);for i in (0..SSIZE).rev() {    print!(        "mem: {}, value: 0x{:02X}\n{}",        stack.as_ptr().offset(i as isize) as usize,        *stack.as_ptr().offset(i as isize),        if i % 8 == 0 { "\n" } else { "" }    );}

Вот примерно такой вывод будет:


hello func address: 0x0000560CD80B50B0 (94613164216496)mem: 94613168839439, value: 0x00mem: 94613168839438, value: 0x00mem: 94613168839437, value: 0x00mem: 94613168839436, value: 0x00mem: 94613168839435, value: 0x00mem: 94613168839434, value: 0x00mem: 94613168839433, value: 0x00mem: 94613168839432, value: 0x00mem: 94613168839431, value: 0x00mem: 94613168839430, value: 0x00mem: 94613168839429, value: 0x56mem: 94613168839428, value: 0x0Cmem: 94613168839427, value: 0xD8mem: 94613168839426, value: 0x0Bmem: 94613168839425, value: 0x50mem: 94613168839424, value: 0xB0mem: 94613168839423, value: 0x00mem: 94613168839422, value: 0x00mem: 94613168839421, value: 0x00mem: 94613168839420, value: 0x00mem: 94613168839419, value: 0x00mem: 94613168839418, value: 0x00mem: 94613168839417, value: 0x00mem: 94613168839416, value: 0x00mem: 94613168839415, value: 0x00mem: 94613168839414, value: 0x00mem: 94613168839413, value: 0x00mem: 94613168839412, value: 0x00mem: 94613168839411, value: 0x00mem: 94613168839410, value: 0x00mem: 94613168839409, value: 0x00mem: 94613168839408, value: 0x00mem: 94613168839407, value: 0x00mem: 94613168839406, value: 0x00mem: 94613168839405, value: 0x00mem: 94613168839404, value: 0x00mem: 94613168839403, value: 0x00mem: 94613168839402, value: 0x00mem: 94613168839401, value: 0x00mem: 94613168839400, value: 0x00mem: 94613168839399, value: 0x00mem: 94613168839398, value: 0x00mem: 94613168839397, value: 0x00mem: 94613168839396, value: 0x00mem: 94613168839395, value: 0x00mem: 94613168839394, value: 0x00mem: 94613168839393, value: 0x00mem: 94613168839392, value: 0x00I LOVE WAKING UP ON A NEW STACK!

Я вывел адреса в памяти в виде значений u64, чтобы было легче в них разобраться, если вы не очень знакомы с шестнадцатеричной системой исчисления (что вряд ли прим.).


Первое, что заметно, так это то, что это непрерывный участок памяти, который начинается с адреса 94613168839392 и заканчивается адресом 94613168839439.


Адреса с 94613168839424 по 94613168839431 включительно представляют для нас особый интерес. Первый адрес это первый адрес нашего stack pointer, значения, которое мы записываем в регистр %rsp%. Диапазон представляет собой значения, которые мы пишем в стек перед переключением. (прим коряво и сомнительно!!!)


Ну а сами значения 0xB0, 0x50, 0x0B, 0xD8, 0x0C, 0x56, 0x00, 0x00 это указатель (адрес в памяти) на функцию hello(), записанный в виде значений u8.


По мере того, как мы будем писать более сложные функции, нашего крайне скудного 48байтного стека скоро перестанет хватать, вы увидите, как функции запускаются, а код инструктирует процессор заталкивать и выталкивать значения из нашего стека при выполнении программы.


Размеры стека


Когда вы запускаете процесс в большинстве современных операционных системах, стандартный размер стека обычно составляет 8 Мб, но может быть сконфигурирован и другой размер. Этого хватает для большинства программ, но на плечах программиста убедиться, что не используется больше, чем есть. Это и есть причина пресловутого "переполнения стека" (stack overflow), с которым многие из нас сталкивались.


Однако, когда мы сами управляем стеками, мы можем выбирать для них размер. 8 Мб для каждого контекста определённо больше, чем нам требуется при выполнении простых функций, например, в веб-сервере. Таким образом, уменьшив размер стека мы сможем держать миллионы легковесных потоков запущенными на машине, в то время, как при использовании стеков, предоставляемых операционной системой, мы очень скоро столкнёмся с нехваткой памяти.


Расширяемые стеки


Некоторые реализации используют расширяемые (growable способные расти) стеки. При этом выделяется небольшой объём памяти, достаточный для большинства задач. Но при его перерасходе, вместо переполнения стека, происходит выделение памяти большего объёма под новый стек, в который переносится всё из старого, после чего выполнение программы возобновляется.


Примером такой стратегии является реализация в языке Go. Работа начинается со стека размера в 8 Кб, и когда в нём заканчивается место, выделяется стек побольше. Как и в любом аспекте программирования, в данном поведении есть компромиссы, все имеющиеся указатели должны быть правильно обновлены, а это не очень простая задача. Если вас заинтересовало то, как GO управляет стеком (что является хорошим примером использования и компромиссной реализации растущего стека), то отправляю вас к этой статье.


Ещё одна вещь, что будет важна позже: мы использовали обычный вектор (Vec<u8>) из стандартной библиотеки. Он очень удобен, но с ним есть проблемы. Среди прочего, нет гарантии, что он останется на прежнем месте в памяти.
Как вы можете понять, если стек будет перемещён в памяти, то программа аварийно завершится из-за того, что все наши указатели станут недействительными. Что-нибудь такое простое, как вызов push() для нашего стека (имеется в виду вектор прим.) может вызвать его расширение. А когда вектор расширяется, то запрашивается новый кусок памяти большего размера, в который потом перемещаются значения.

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


Как настроить стек


Windows x86-64 организует стек чуть иначе, чем регламентируется соглашением вызовов x86-64 psABI. Я уделю чуть больше времени стеку Windows в приложении, но важно знать, что различия не такие большие, когда вы настраиваете стек под простые функции, которые не принимают параметры, что мы и делаем.


Организация стека в psABI выглядит следующим образом:



Как вы уже знаете, %rsp это наш указатель на стек. Теперь, как вы видите, нужно положить указатель на стек в позицию от base pointer, кратную 16. Адрес возврата располагается в соседних 8 байтах, и, как вы видите, выше ещё есть место для аргументов. Нужно держать всё это в уме, когда хотим сделать что-нибудь более сложное.


Вы заметите, что мы регулярно записываем адрес на нашу функцию по адресу stack_ptr + SSIZE - 16 без объяснения, почему именно так. По-любому SSIZE это размер стека в байтах.


Думайте об этом следующим образом. Мы знаем, что размер указателя (в нашем случае указателя на функцию) равен 8 байтам. Мы знаем, что rsp должен быть записан на границе 16 байт, чтобы соответствовать ABI.


У нас на самом деле и выбора-то нет, кроме как писать по адресу stack_ptr + SSIZE - 16. Записывая байты по адресам от младшего к старшему:


  • Мы не можем записать их по адреса, начиная с stack_ptr + SSIZE (является границей 16 байт), т.к. мы выйдем за границы выделенного участка памяти, что запрещено.
  • Мы не можем записать их по адресам, начиная с stack_ptr + SSIZE - 8, которые находятся внутри валидного адресного пространства, но не выровнены по границе 16 байт.

Остаётся только stack_ptr + SSIZE - 16 в качестве первой подходящей позиции. На практике мы пишем 8 байт в позиции -16, -15, -14, ..., -9 от старшего адреса нашего стека (который, запутывая, часто зовётся bottom of stack, т.к. растёт в сторону младших адресов (прим если адреса записать в колонку по порядку, вверху будут младшие адреса, а внизу старшие, то дно стека как раз будет внизу)).


Бонусный материал


Если вы достаточно любознательны, вы можете заинтересоваться тем, что происходит со стеком после того, как мы переключаемся на него.


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


К сожалению, чтобы показать это, пришлось увеличить размер стека до 1024 байт, чтобы в нём оказалось достаточно места для того, чтобы наш код смог распечатать его содержимое. Так что тут вывод не будет приводиться.


Взглянем на стек


Однако, я написал альтернативную версию нашего примера, который вы можете запустить. Он создаст два текстовых файла: BEFORE.txt (содержимое стека перед переключением на него) и AFTER.txt (содержимое стека после переключения). Вы можете своими глазами посмотреть, как живёт стек и используется нашим кодом.


Если вы видете что-то незнакомое в этом коде, то расслабьтесь позже мы подробно рассмотрим детали.

#![feature(llvm_asm)]#![feature(naked_functions)]use std::io::Write;const SSIZE: isize = 1024;static mut S_PTR: *const u8 = 0 as *const u8;#[derive(Debug, Default)]#[repr(C)]struct ThreadContext {    rsp: u64,    r15: u64,    r14: u64,    r13: u64,    r12: u64,    rbx: u64,    rbp: u64,}fn print_stack(filename: &str) {    let mut f = std::fs::File::create(filename).unwrap();    unsafe {        for i in (0..SSIZE).rev() {            writeln!(                f,                "mem: {}, val: {}",                S_PTR.offset(i as isize) as usize,                *S_PTR.offset( i as isize)            )            .expect("Error writing to file.");        }    }}fn hello() {    println!("I LOVE WAKING UP ON A NEW STACK!");    print_stack("AFTER.txt");    loop {}}unsafe fn gt_switch(new: *const ThreadContext) {    llvm_asm!("        mov     0x00($0), %rsp        ret        "        :        : "r"(new)        :        : "alignstack"    );}fn main() {    let mut ctx = ThreadContext::default();    let mut stack = vec![0_u8; SSIZE as usize];    let stack_ptr = stack.as_mut_ptr();    unsafe {        S_PTR = stack_ptr;        std::ptr::write(stack_ptr.offset(SSIZE - 16) as *mut u64, hello as u64);        print_stack("BEFORE.txt");        ctx.rsp = stack_ptr.offset(SSIZE - 16) as u64;        gt_switch(&mut ctx);    }}

Реализация грин тредов


Прежде, чем начать, замечу, что код, который мы напишем, не совсем безопасный, а так же не соответствует "лучшим практикам" (best practicies) в Расте. Я хочу попытаться сделать его, как можно безопаснее без привнесения множества ненужной сложности, так что если вы видите, что что-то можно сделать ещё безопаснее без сверхусложнения кода, то призываю тебя, дорогой читатель, предложить соответствующий PR в репозиторий.


Приступим


Первое, что сделаем удалим наш предыдущий пример в файле main.rs, начав всё с нуля, и добавим следующее:


#![feature(llvm_asm)]#![feature(naked_functions)]use std::ptr;const DEFAULT_STACK_SIZE: usize = 1024 * 1024 * 2;const MAX_THREADS: usize = 4;static mut RUNTIME: usize = 0;

Мы задействовали две фичи: ранее рассмотренную asm и фичу naked_functions, которую требуется в разъяснении.


naked_functions


Когда Раст компилирует функцию, он добавляет к ней небольшие "пролог" и "эпилог", которые вызывают некоторые проблемы из-за того, что при переключении контекстов стек оказывается невыровненным. Хотя в нашем простом примере всё работает нормально, но когда нам нужно переключаться обратно на тот же самый стек, то возникают трудности. Атрибут #[naked] убирает генерацию пролога и эпилога для функции. Главным образом этот атрибут используется в связке с ассемблерными вставками.


Если интересно, то про naked_functions можно почитать в RFC #1201.

Naked-функции не совсем функции. Когда вызывается обычная функция, то сохраняются регистры, в стек заталкивается адрес возврата, стек выравнивается и т.п. При вызове naked-функций ничего из этого не происходит. Если слепо вызвать ret в naked-функции (без предварительных манипуляций как в прологе и эпилоге, описанных в ABI), то попадёте на территорию неопределённого поведения. В лучшем случае закончите в вызывающем коде с мусором в регистрах.

Размер стека DEFAULT_STACK_SIZE задан в 2 МБ, чего более, чем достаточно для наших нужд. Так же задаём количество тредов (MAX_THREADS) равным 4, т.к. для примера больше не нужно.


Последняя строка константа RUNTIME указатель на структуру, содержащую информацию о системе времени исполнения (да, я знаю, что использовать мутабельную глобальную переменную для этого не очень красиво, она нам нужна дальше, а задаём для неё значение только во время инициализации).


Для представления наших данных допишем кое-что свежее:


pub struct Runtime {    threads: Vec<Thread>,    current: usize,}#[derive(Debug, Eq, PartialEq)]enum State {    Available,    Running,    Ready,}struct Thread {    id: usize,    stack: Vec<u8>,    ctx: ThreadContext,    state: State,}#[derive(Debug, Default)]#[repr(C)]struct ThreadContext {    rps: u64,    r15: u64,    r14, u64,    r13: u64,    r12: u64,    rbx: u64,    rbp: u64,}

Главной точкой входа будет структура Runtime. Мы создадим очень маленький, простой рантайм для планирования выполнения потоков и переключения между ними. Структура содержит в себе вектор структур Thread и поле current, которое указывает на поток, который выполняется в данный момент.


Структура Thread содержит данные для потока. Для того, чтобы отличать потоки друг от друга, они имеют уникальный id. Поле stack такое же, какое мы видели в примерах ранее. Поле ctx содержит данные для процессора, которые нужны для продолжения работы потока с того места, где он покинул стек. Поле state это состояние потока.


State это перечисление состояний потока, которое может принимать значения:


  • Available поток доступен и готов быть назначенным для выполнения задачи, если нужно.
  • Running поток выполняется
  • Ready поток готов продолжать и возобновить выполнение.

ThreadContext содержит данные регистров, которые нужны процессору для возобновления исполнения на стеке.


Если запамятовали, то вернитесь к части "Предварительные сведения" для того, чтобы почитать о регистрах. В спецификации архитектуры x86-64 Эти регистры помечены как "callee saved".

Продолжаем:


impl Thread {    fn new(id: usize) -> Self {        Thread {            id,            stack: vec![0_u8; DEFAULT_STACK_SIZE],            ctx: ThreadContext::default(),            state: State::Available,        }    }}

Тут всё довольно просто. Новый поток стартует в состоянии Available, которое означает, что он готов принять задачу для исполнения.


Нужно отметить, что здесь мы выделяем стек. Это не слишком необходимое и оптимальное решение с точки зрения использования ресурсов, т.к. память можно выделить при первом использовании структуры. Но это позволяет снизить сложность в некоторых частях кода на которых можно сфокусировать внимание, вместо того, чтобы выделять память для стека.


Важно заметить, что после того, как стек выделен, он не должен перемещаться. Никаких вызовов метода push() и других, которые могут спровоцировать перевыделение памяти. Лучше было бы использовать свой собственный тип данных, который предоставляет только безопасные с этой точки зрения методы.

Так же упомянем, что у Vec<T> есть метод into_boxed_slice(), который возвращает Box<[T]> срез, выделенный в куче. Срезы не могут быть расширены, так что мы можем использовать их для решения проблемы перевыделения памяти.

Реализация рантайма


Весь код этой части находится внутри блока impl Runtime, реализуя методы для одноимённой структуры.


impl Runtime {    pub fn new() -> Self {        let base_thread = Thread {            id: 0,            stack: vec![0_u8; DEFAULT_STACK_SIZE],            ctx: ThreadContext::default(),            state: State::Running,        };        let mut threads = vec![base_thread];        let mut available_threads: Vec<Thread> = (1..MAX_THREADS).map(|i| Thread::new(i)).collect();        threads.append(&mut available_threads);        Runtime {            threads,            current: 0,        }    }    // code of other methods is here    // ...}

При инстанцировании структуры Runtime создаётся базовый поток в состоянии Running. Он держит среду исполнения запущенной пока все задачи не будут завершены. Потом создаются остальные потоки, а текущим назначается поток с номером 0, который является базовым.


// Читерство, но нам нужен указатель на структуру Runtime// сохранённый таким образом, чтобы мы могли вызывать метод yield// без прямого обращения к этой структуре по ссылке.// по сути мы дублируем указатель втихомолку от компилятораpub fn init(&self) {    unsafe {        let r_ptr: *const Runtime = self;        RUNTIME = r_ptr as usize;    }}

Сейчас нам это нужно. Как я упоминал, когда расписывал константы, нам нужна глобальная переменная для того, чтобы вызывать метод yield позже. Не очень симпатично, но мы знаем, что наш рантайм должен жить, пока есть потоки.


pub fn run(&mut self) -> ! {    while self.t_yield() {};    std::process::exit(0);}

А это то место, где мы запускаем наш рантайм. Он беспрестанно вызывает метод t_yield(), пока тот не вернёт значение false, что означает, задач больше нет, и мы можем завершить процесс.


fn t_return(&mut self) {    if self.current != 0 {        std.threads[self.current].state = State::Available;        self.t_yield();    }}

Когда процесс завершается, то вызываем эту функцию. Мы назвали её t_return, т.к. слово return входит в список зарезервированных. Заметьте, что пользователь наших потоков не вызывает эту функцию мы организуем стек таким образом, что эта функцию вызывается, когда задача завершается.


Если вызывающий поток является базовым, то ничего не делаем. Наш рантайм вызывает метод yield для базового потока. Если же вызов приходит из порождённого (spawned) потока, мы узнаём, что он завершился, т.к. у всех потоков на вершине их стека находится функция guard (её объясню позже). Так что единственное место, откуда t_return может быть вызвана это как раз функция guard.


Мы назначаем потоку состояние Available, сообщая рантайму, что готовы принять новую задачу (task), а затем немедленно вызываем t_yield, который дёргает планировщик для запуска нового потока.


Далее рассмотрим функцию yield:


fn t_yield(&mut self) -> bool {    let mut post = self.current;    while self.threads[pos].state != State::Ready {        pos += 1;        if pos == self.threads.len() {            pos = 0;        }        if pos == self.current {            return false;        }    }    if self.threads[self.current].state != State::Available {        self.threads[self.current].state = State::Ready;    }    self.threads[pos].state = State::Running;    let old_pos = self.current;    self.current = pos;    unsafe {        let old: *mut ThreadContext = &mut self.threads[old_pos].ctx;        let new: *const ThreadContext = &self.threads[pos].ctx;        llvm_asm!(            "            mov $0, %rdi            mov $1, %rsi            call switch            "            :            : "r"(old), "r"(new)            :            :        );    }    self.threads.len() > 0}

Это сердце нашего рантайма. Пришлось выбрать имя t_yield, т.к. yield является зарезервированным словом (прим. используется в генераторах, которые ещё не стабилизированы).


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


Если же потоков в состоянии Ready нет, то все задачи выполнены. Это крайне простой планировщик, который использует только алгоритм циклического перебора (round-robin). Реальные планировщики могут иметь более сложный способ определения, какую задачу выполнять следующей.


Это очень наивная реализация для нашего примера. Что случится, если тред не готов двигаться дальше (не в состоянии Ready) и всё ещё ожидает чего-то, например, ответа от базы данных?

Не слишком сложно обработать такой случай. Вместо запуска нашего кода напрямую, когда поток готов, мы можем запросить (poll) его о статусе. Например, он может вернуть значение IsReady, если он дейсвительно готов заняться работой, или вернуть Pending, если он ожидает завершения какой-либо операции. В последнем случае мы можем просто оставить его в состоянии Ready, чтобы опросить позднее. Звучит знакомо? Если читали про то, как работают Футуры, то наверное в голове у вас складывается картина того, как это всё состыкуется вместе.

Если мы находим поток, который готов к работе, мы меняем состояние текущего потока с Running на Ready.


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


Неудобная правда о naked функциях



Функции naked не похожи на обычные. Например, они не принимают формальных аргументов. Обычно, когда вызывается функция с двумя аргументами, компилятор разместит каждый из них в регистрах, согласно соглашению о вызове функций для данной платформы. Когда же мы вызываем функцию, помеченную как #[naked], об этом придётся заботиться самостоятельно. Таким образом мы передаём адрес наших "новой" и "старой" структур ThreadContext, используя ассемблер. В соглашении о вызове на платформе Linux первый аргумент помещатся в регистр %rdi, а второй в регистр %rsi.

Часть self.threads.len() > 0 просто способ указать компилятору, чтобы он не применял оптимизацию. У меня такое нежелательное поведение проявлялось на Windows, но не на Linux, и является общей проблемой при запуске бенчмарков, например. Таким же образом мы можем использовать std::hint::black_box для того, чтобы указать компилятору, что не нужно ускорять код, пропуская шаги, которые нам необходимы. Я выбрал другой путь, а даже если его закомментировать, всё будет ок. В любом случае, код никогда не попадёт в эту точку.


Следом идёт наша функция spawn():


pub fn spawn(&mut self, f: fn()) {    let available = self        .threads        .iter_mut()        .find(|t| t.state == State::Available)        .expect("no available thread.");    let size == available.stack.len();    unsafe {        let s_ptr = available.stack.as_mut_ptr().offset(size as isize);        let s_ptr = (s_ptr as usize & !15) as *mut u8;        std::ptr::write(s_ptr.offset(-16) as *mut u64, guard as u64);        std::ptr::write(s_ptr.offset(-24) as *mut u64, skip as u64);        std::ptr::write(s_ptr.offset(-32) as *mut u64, f as u64);        available.ctx.rsp = s_ptr.offset(-32) as u64;    }    available.state = State::Ready;}// не забудьте про закрывающую скобку блока `impl Runtime`

В то время, как t_yield интересна в плане логики, в техническом плане фукнция spawn наиболее интересна.


Это то, где мы настраиваем стек так, как обсуждали ранее, и убеждаемся, что он выглядит так, как указано в psABI.


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


Когда нашли доступный поток, мы получаем его стек (в виде ссылки на массив u8) и его длину.


В следующем сегменте приходится использовать некоторые unsafe-функции. Сперва убеждаемся, что сегмент памяти выровнен по границе 16 байт. Затем мы записываем адрес функции guard, которая будет вызвана, когда предоставленная нами задача завершится и функция вернёт значение. Следом мы записываем адрес функции skip, которая здесь нужна лишь для обработки промежуточного этапа, когда мы возвращаемся из функции f таким образом, чтобы функция guard вызывалась по границе памяти в 16 байт. И следующее значение, которое мы записываем, это адрес функции f.


Вспомните объяснения того, как работает стек. Мы хотим, чтобы f была первой функцией, которая будет запущена. Поэтому на неё и указывает base pointer с учётом выравнивания. Затем мы заталкиваем в стек адрес на фукнции skip и guard. Таким образом мы обеспечиваем выравнивание функции guard по границе 16 байт, что необходимо сделать по требованиям ABI.

После того, как мы записали указатели на функции в стек, мы записываем в регистр rsp (который явялется указателем на стек) адрес предоставленной функции. Таким образом мы запускаем её на исполнение в первую очередь при очередном цикле планировщика.


И наконец, мы устанавливаем состояние потока в Ready, что означает, у нас есть работа для выполнения, и мы готовы её делать. Вспомните, что это знак для нашего "планировщика" запустить этот поток.


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


Функции guard, skip и switch


fn guard() {    unsafe {        let rt_ptr = RUNTIME as *mut Runtime;        (*rt_ptr).t_return();    };}

Когда функция, которую мы передали на исполнение, возвращает результат, что так же означает, что поток завершил выолнение своей задачи, мы разыменовываем глобальную переменную, и вызываем метод t_return() нашей среды исполнения. Мы могли бы написать функцию, которая проделывала некоторую дополнительную работу, когда поток завершается, но в данный момент того, что делает t_return, нам достаточно. Она помечает тред как Available (если это не базовый тред), и вызывает метод t_yield, что позволяет возобновить работу над другой задачей в другом потоке.


#[naked]fn skip() { }

В функции skip не так много всего происходит. Мы используем атрибут #[naked], так что фукнция компилируется в единственную инструкцию ret, которая просто выталкивает очередное значение из стека и переходит по адресу, который в этом значении содержится. В нашем случае, это значение указывает на функцию guard.


pub fn yield_thread() {    unsafe {        let rt_ptr = RUNTIME as *mut Runtime;        (*rt_ptr).t_yield();    };}

А это просто вспомогательная функция, которая позволяет вызывать метод t_yield из любого места в коде. Она так же небезопасная, потому что наш рантайм может быть ещё неинициализирован или наоборот, уже деинициализирован (дропнут), что приведёт к неопределённому поведению. Однако же, сделать это более безопасным для нашего примера, не в приоритете.


Совсем скоро мы подойдём к последней строке, всего одна функция осталась. Если внимательно читали всё предыдущее, то можно понять её суть и без обилия комментариев.


#[naked]#[inline(never)]unsafe fn switch() {    llvm_asm!("        mov     %rsp, 0x00(%rdi)        mov     %r15, 0x08(%rdi)        mov     %r14, 0x10(%rdi)        mov     %r13, 0x18(%rdi)        mov     %r12, 0x20(%rdi)        mov     %rbx, 0x28(%rdi)        mov     %rbp, 0x30(%rdi)        mov     0x00(%rsi), %rsp        mov     0x08(%rsi), %r15        mov     0x10(%rsi), %r14        mov     0x18(%rsi), %r13        mov     0x20(%rsi), %r12        mov     0x28(%rsi), %rbx        mov     0x30(%rsi), %rbp        "    );}

Здесь ассемблерная вставка. Как помните из первого примера, это просто более расширенная версия, где мы сначала читаем значения из нужных регистров, а затем записываем в них новые значения, которые были сохранены, когда мы приостановили выполнение "нового" потока.


Это всё, что необходимо, чтобы запомнить и потом восстановить исполнение.


Так же мы снова видим использование атрибута #[naked]. Обычно каждая функция имеет пролог и эпилог, а здесь мы не хотим их, так как функция состоит целиком из ассемблерной вставки, и мы обо всём заботимся самостоятельно. Если же не включить этот атрибут, то будут проблемы при повторном переключении обратно к нашему стеку.


Так же есть ещё одно отличие от нашей первой функции. Это атрибут #[inline(never)], который запрещяет компилятору встраивать эту функцию. Я провёл некоторое время, выясняя, почему код фейлится при сборке с флагом --release.


Функция main


fn main() {    let mut runtime = Runtime::new();    runtime.init();    runtime.spawn(|| {        println!("THREAD 1 STARTING");        let id = 1;        for i in 1..=10 {            println!("thread: {} counter: {}", id, i);            yield_thread();        }        println!("THREAD 1 FINISHED");    });    runtime.spawn(|| {        println!("THREAD 2 STARTING");        let id = 2;        for i in 1..=15 {            println!("thread: {} counter: {}", id i);            yield_thread();        }        println!("THREAD 2 FINISHED");    });    runtime.run();}

Как видите, мы тут инициализируем рантайм и порождаем два потока, которые считают от 0 до 9 и до 15, а так же уступают работу между итерациями. Если запустим наш проект через cargo run, то должны увидеть следующий вывод:


THREAD 1 STARTINGthread: 1 counter: 1THREAD 2 STARTINGthread: 2 counter: 1thread: 1 counter: 2thread: 2 counter: 2thread: 1 counter: 3thread: 2 counter: 3thread: 1 counter: 4thread: 2 counter: 4thread: 1 counter: 5thread: 2 counter: 5thread: 1 counter: 6thread: 2 counter: 6thread: 1 counter: 7thread: 2 counter: 7thread: 1 counter: 8thread: 2 counter: 8thread: 1 counter: 9thread: 2 counter: 9thread: 1 counter: 10thread: 2 counter: 10THREAD 1 FINISHEDthread: 2 counter: 11thread: 2 counter: 12thread: 2 counter: 13thread: 2 counter: 14thread: 2 counter: 15THREAD 2 FINISHED

Прекрасно. Наши потоки сменяют друг друга после каждого отсчёта, уступая друг другу контроль. А когда поток 1 завершается, то поток 2 продолжает свои отсчёты.


Поздравления


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

Подробнее..

И на Солнце есть пятна

18.01.2021 06:23:16 | Автор: admin

Введение

В предыдущей заметке Планировщик Windows? Это очень просто было рассказано о технологии получения дизассемблированного текста ядра операционной системы Windows XP образца 2013 года. Такой текст потребовался для анализа и корректировки кода ядра, что позволило изменить политику планирования потоков в Windows и выполнить одну конкретную задачу с уменьшением времени отклика операционной системы.

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

Поскольку Windows является, наверное, самой дорогой программой в мире по затратам на разработку и сопровождение, уровень качества кода ее ядра должен бы быть одним из самых высоких. Именно поэтому было интересно посмотреть, как устроен код с точки зрения эффективности отдельных команд. Однако я увидел не совсем то, что ожидал и поэтому решил поделиться несколькими соображениями. Для иллюстрации ниже приведены фрагменты дизассемблированного кода ядра Windows XP сборки от 4 июля 2013 года.

Хотя Windows XP и Windows 7 уже, так сказать, сняты с вооружения, на мой взгляд, изучение даже неподдерживаемых программ имеет смысл. Ядро Windows XP сопровождалось и развивалось около 10 лет. Поэтому на основании анализа кода можно, например, даже прогнозировать пути дальнейшего развития системы. Замечу также, что различия в коде ядер различных версий Windows не так велики как различия некоторых других компонентов.

Оптимизация команд

Разумеется, в тексте кода ядра попалось множество приемов оптимизации отдельных команд.

Например, транслятор заменяет умножение на степень двойки командой сдвига:

40AD14 0FB74018             MOVZX  EAX,W PTR [EAX]+1840AD18 8B4904               MOV    ECX,[ECX]+440AD1B C1E005               SHL    EAX,5

Широко используется загрузка константы в регистр через пару команд обращения к стеку:

40AD5B 6A0A                 PUSH   0A40AD5D 59                   POP    ECX

Я считаю такую пару командой MOVSX ECX,0AH (которая на самом деле не существует, но если бы была, то эффект она давала бы такой же).

Остроумно реализовано громоздкое вычисление адресации, например, вот быстрое умножение индекса на 12:

4038E8 8D0C76               LEA    ECX,[ESI+ESI*2]4038EB 837C8F0400           CMP    D PTR [EDI+ECX*4]+4,0

Сплошь и рядом используется приемы, которые уменьшают зависимость соседних команд и, тем самым, ускоряют работу конвейера процессора и уменьшают число переходов:

4050F8 F7410406000000       TEST   D PTR [ECX]+4,64050FF B801000000           MOV    EAX,1405104 7512                 JNZ    405118

Эти и другие многочисленные примеры подтверждают, что используемые для создания кода ядра трансляторы пытаются генерировать команды оптимальным способом.

Недостатки кода

Однако наряду с эффективно сформированными командами встречаются вещи, которые, мягко говоря, далеко не оптимальны.

Первое, что бросается в глаза это странное выравнивание подпрограмм. Я понимаю смысл такого выравнивания в уменьшении числа обращений к памяти при чтении команд подпрограммы. Поскольку в процессор считывается сразу целая кэш-строка кодов, выгодно расположить команды начала подпрограммы с начала такой строки и, тем самым, избежать, по крайней мере, одной лишней подкачки кодов. Выравнивание обычно идет командами NOP или иногда INT 3.

Но посмотрите, например, на один из бесчисленных фрагментов ядра:

4025AA 90                   NOP4025AB 90                   NOP4025AC 90                   NOP4025AD 90                   NOP4025AE 90                   NOP4025AF 8BFF                 MOV    EDI,EDI4025B1 55                   PUSH   EBP

Здесь, как и в сотнях других подобных мест, выравнивание превратилось в свою противоположность, и подпрограмма начинается из-за команд NOP как раз НЕ с адреса, кратного 16 или хотя бы 4, а сами команды NOP стали просто бессмысленным раздуванием кода. Такое впечатление, что где-то в одном месте в ядре выравнивание съехало и далее везде дает противоположный эффект.

Причем, чтобы увидеть это, вовсе не требуется дизассемблировать код, достаточно любой подходящей утилитой посмотреть таблицу экспорта ядра NTOSKRNL.EXE. Там полно, например, нечетных (т.е. явно никак не выровненных) адресов входов.

Вообще код во многих местах напоминает заячьи петли. Например, после проверки управление вдруг передается в другое далеко отстоящее место ядра:

40A661 8B4D0C               MOV    ECX,[EBP]+0C40A664 85C9                 TEST   ECX,ECX40A666 0F840989FFFF         JJE    402F75

Однако в том месте выполняется всего пара команд, и управление возвращается, так сказать, обратно:

402F75 8BCE                 MOV    ECX,ESI402F77 E8EB260000           CALL   405667402F7C E9FB760000           JMP    40A67C

и подобных прыжков огромное количество. Сначала я решил, что это отражение конструкций типа try-catch, но возможно это и случаи единственного обращения к подпрограмме, где транслятор убирает команды CALL и RET и ставит вместо них длинный условный и безусловный переходы. При этом код становится на 3 байта длиннее по сравнению с суммой команд короткого условного перехода, вызова и возврата. Разумеется, нет здесь и никакого выравнивания.

Встречаются и такие конструкции:

IoFreeIrp:414012 8BFF                 MOV    EDI,EDI414014 55                   PUSH   EBP414015 8BEC                 MOV    EBP,ESP414017 5D                   POP    EBP414018 FF258C474800         JMP    D PTR [0048478C]IoAllocateIrp:41406D 8BFF                 MOV    EDI,EDI41406F 55                   PUSH   EBP414070 8BEC                 MOV    EBP,ESP414072 5D                   POP    EBP414073 FF2588474800         JMP    D PTR [00484788]

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

Возможно, в исходном тексте находится какой-то закомментированный фрагмент и это дает такой эффект. Возможно, это было сделано для каких-то отладочных остановок. Но как бы то не было, каждый раз, когда происходит обращение к подпрограммам IoAllocateIrp и IoFreeIrp, сначала выполняются бессмысленные команды.

Во многих местах ядра происходит обращение к двухбайтовым объектам, например:

409378 668B4564             MOV    AX,[EBP]+6440937C 6683E0FC             AND    AX,FFFC409380 668B4A04             MOV    CX,[EDX]+4409384 6683E1FC             AND    CX,FFFC409388 663BC8               CMP    CX,AX40938B 7547                 JNZ    4093D4

Недостаток здесь в том, что у транслятора не хватило смелости всегда читать эти объекты в четырехбайтовые регистры, т.е. сделать код короче из-за исключения префиксов в командах чтения (даже оставляя префиксы в командах умножения и сравнения):

409378 8B4564               MOV    EAX,[EBP]+6440937B 83E0FC               AND    AX,FFFC40937F 8B4A04               MOV    ECX,[EDX]+4409382 6683E1FC             AND    CX,FFFC409386 663BC8               CMP    CX,AX409389 7545                 JNZ    4093D4

Это тем более удивительно, что иногда обработка двухбайтового объекта идет без префикса 66, например:

407F3D 668B442424           MOV    AX,[ESP]+24407F42 C1E004               SHL    EAX,4

Чтение всех двухбайтовых объектов как четырехбайтовых позволяет существенно сократить код.

К тому же, напомню, к 32-х разрядным процессорам подходит все-таки не 32, а только 30 адресных линий. Тем самым, из памяти физически не может читаться менее 4 байт за раз. Две младших адресных линии используются только внутри самого процессора для выбора нужных байт из числа 4 считанных. Так, что физически все равно читается 4 байта, даже когда требуется достать только два.

Еще один существенный недостаток кода большое число пересылок регистров. Например, типичный фрагмент:

40FAE0 64A120000000         MOV    EAX,FS:[00000020]40FAE6 8BF8                 MOV    EDI,EAX40FAE8 8BB748050000         MOV    ESI,[EDI]+54840FAEE FF460C               INC    D PTR [ESI]+0C40FAF1 8BCE                 MOV    ECX,ESI40FAF3 E87FA7FFFF           CALL   40A277 ;ExInterlockedPopEntrySList40FAF8 85C0                 TEST   EAX,EAX40FAFA 0F84843A0000         JJE    413584

Его можно было бы короче записать так:

40FAE0 648B3D20000000       MOV    EDI,FS:[00000020]40FAE7 8B8F48050000         MOV    ECX,[EDI]+54840FAED FF410C               INC    D PTR [ECX]+0C40FAF0 E87DA7FFFF           CALL   40A277 ;ExInterlockedPopEntrySList40FAF5 85C0                 TEST   EAX,EAX40FAF7 0F84823A0000         JJE    413584

Не пересылая каждый раз данные сначала в регистры EAX и ESI.

Может возникнуть резонный вопрос, а причем здесь вообще ядро Windows? Ведь код генерирует транслятор. Используя специальные тесты можно и без ядра посмотреть, насколько хорошо он это делает. И что собственно предлагает автор? Перетранслировать Windows другими средствами из-за нескольких неэффективных команд? Или исправить транслятор?

Для большинства программ практически значимый результат от совершенствования кода бывает редко. Допустим, имеется программа, которая проводит расчет за минуту. Допустим, потратив час, ее ускорили на 10%. Тогда только примерно через 600 прогонов общий выигрыш от работы улучшенной версии достигнет того же часа и компенсирует затраченное время. Если и не требуется запускать программу более 600 раз, улучшение не оправдает затраты.

И хотя, наверное, неправомерно считать час, потраченный на анализ программы, потерянным, но, тем не менее, данный пример показывает, что многие программы просто бессмысленно пытаться ускорить, особенно на какие-то наносекунды из-за изменения нескольких десятков команд.

А вот ядро операционной системы это как раз другая крайность, где даже исправление отдельных команд (дающее ничтожный выигрыш времени) имеет смысл с точки зрения практического эффекта. Ведь это ядро работает десятилетиями на миллионах компьютеров. Когда мы общаемся с компьютером, мы большей частью общаемся как раз с этим подпрограммами ядра. Эффективность компьютера во многом определяется и эффективностью кода операционной системы.

Мне безразлично, какими средствами создано ядро Windows и как эти средства генерируют команды для тестовых примеров. Ценен только окончательный код в реальной программе. Ввиду исключительной важности ядра операционной системы его код с точки зрения эффективности должен быть безупречным.

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

Возникает впечатление, что вообще никто из разработчиков Windows никогда и не анализировал конечный код. Иначе обратили бы внимание, что, например, выравнивание подпрограмм не получилось.

Технология улучшения кода

Какова может быть технология улучшения кода? С моей точки зрения менять технологию разработки и компилирования Windows уже поздно слишком много усилий потрачено на отработку и тестирование.

А вот выделить хотя бы одного программиста для анализа конечного результата (т.е. конечного кода) вполне возможно. Особенно, если учесть, что общее число разработчиков Windows (как уверяют) около 5 тысяч. И этот один (из почти целой пехотной дивизии программистов!) мог бы дизассемблировать самые важные части операционной системы, подобно тому, как, например, за несколько дней это сделал я, проанализировать код и дать свои рекомендации по повышению его эффективности.

Но лучше не просто дать рекомендации, а сразу создать новый EXE-файл из дисассемблированного текста. Первоначально он должен строго совпадать с исходным файлом. Это, кстати, хорошая проверка правильности дизассемблирования. А затем вручную или с помощью простых программ внести исправления в дизассемблированный текст так, чтобы убрать большинство его дефектов и бессмыслицу.

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

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

Например, в фрагменте:

411AC8 64A120000000         MOV    EAX,FS:[00000020]411ACE 8BF8                 MOV    EDI,EAX411AD0 64A124010000         MOV    EAX,FS:[00000124]411AD6 33C9                 XOR    ECX,ECX

можно гарантированно без последствий заменить первую команду MOV EAX, на MOV EDI, и исключить лишнюю пересылку MOV EDI,EAX, поскольку уже в следующей команде EAX принимает новое значение и его старое значение никак не может быть использовано далее. При этом в ядре нет команд перехода на адрес 411ACE (что также легко проверить), а, значит, никакой ошибки от прямой пересылки в другой регистр не может быть в принципе.

Другое дело, вот такой фрагмент:

412D09 8B06                 MOV    EAX,[ESI]412D0B 57                   PUSH   EDI412D0C 8BF8                 MOV    EDI,EAX412D0E C1EF0C               SHR    EDI,0C412D11 BBFF0F0000           MOV    EBX,00000FFF412D16 0F8423010000         JJE    412E3F

Здесь менять пересылку регистров опасно, поскольку управление куда-то передается (по адресу 412E3F) и транслятор далее может использовать имеющееся значение в EAX. Анализ так ли это или нет, уже достаточно сложен и не может быть выполнен формально и простыми средствами как в предыдущем случае.

Заключение

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

С другой стороны, в огромной команде Microsoft, по-видимому, не нашлось сотрудников, которые взяли бы на себя регулярный анализ качества конечного результата собственно кодов операционной системы. Поэтому в коде ядра Windows XP, которое сопровождалось не менее 10 лет, осталось много мест, которые хорошо было бы улучшить.

Совершенствование кода принципиально возможно даже без изменения самих процессов разработки и компиляции операционной системы. Один из таких способов это дизассемблирование, анализ, внесение изменений и трансляция дизассемблерного текста обратно в EXE-файл.

Большинство изменений могут быть достаточно простыми и формальными, а потому не требовать сложного анализа. В таком случае риск внесения ошибок минимален.

Для такого повышения качества кода достаточно небольших усилий (в масштабах всего проекта Windows), а отдача могла бы получиться заметной буквально на планетарном уровне.

Подробнее..

Перевод Исправляем кривой запуск первого Mass Effect

23.11.2020 10:05:21 | Автор: admin
image

Часть 1


В последнее время я работал над собственным форком ME3Explorer [неофициальный редактор игр серии Mass Effect], содержащим множество важных улучшений и даже новые инструменты. Также я поработал над Mod Manager 5.1, который имеет удобные новые функции импорта сторонних модов, однако был отодвинут на второй план, пока я работал над новым фронтендом установщика ALOT.

ALOT Installer с манифестом 2017 года

Для его реализации я сотрудничал с CreeperLava и Aquadran; он должен упростить жизнь конечным пользователям, устанавливающим ALOT и его аддон (сторонние текстуры). Одна из моих проблем заключалась в том, что Origin не запускал игру после установки ALOT, если не запустить его с правами администратора. И поскольку запуск Origin при загрузке невозможно выполнить с правами админа, это очень раздражает. К тому же это влияет на мод MEUITM. Поэтому я начал разбираться, почему это происходит. Дело оказалось в идеальном сочетании реализации защиты, плохого кода и желания упростить жизнь других людей.

Давайте посмотрим, как работает Mass Effect с Origin в неизменённом состоянии под Windows 10.

  • Пользователь запускает MassEffect.exe. Файл немедленно запрашивает повышение прав до администраторских.
  • В конце образа MassEffect.exe есть код вызова Origin для запуска игры, это некая DRM. Он вызывает Origin, а затем выполняет выход.
  • Origin проверяет права пользователя на запуск игры, а затем запускает MassEffect.exe в соответствии с указаниями в реестре (не тот, который вы запустили сами), после чего пытается запустить исполняемый файл.
  • Origin не может запустить исполняемый файл, потому что он требует повышения прав. Чтобы DRM работала, она должна иметь возможность взаимодействия с процессом, поэтому она повышает права одного из внутренних сервисов, чтобы он мог общаться с игрой в целях DRM-защиты.
  • MassEffect.exe выполняется с правами администратора. Origin обменивается данными с MassEffect.exe и выполнение игры продолжается, как это было бы в случае с DVD-версией.

Всё это работает (через два UAC-запроса) на немодифицированной игре. Но если установить MEUITM или ALOT, то вы больше не сможете запускать игру через Origin как стандартный пользователь. Что за дела?

Сигнатуры файлов


И MEUITM, и ALOT модифицируют исполняемый файл MassEffect.exe, чтобы он мог использовать Large Address Aware. Это позволяет 32-битному процессу Mass Effect использовать до 4 ГБ ОЗУ вместо обычного 32-битного ограничения в 2 ГБ. При модификации флага LAA цифровая сигнатура MassEffect.exe оказывается поломанной сигнатура используется для проверки того, что файл не модифицирован. После модификации файла сигнатура становится неверной.

Origin при выполнении процесса с повышенными правами проверяет, подписан ли EXE компанией EA и правилен ли он. Если он не подписан EA, то он не повышает права модуля обмена данными DRM. Mass Effect загружается, а затем немедленно закрывается, потому что разблокировка DRM не работает, ведь со стороны Origin ей не с чем общаться, поскольку отказано в повышении прав.

То есть при модификации EXE Origin будет отказываться запускать исполняемый файл игры с повышенными правами. Но нам нужен LAA, поэтому необходимо как-то обойти эту проблему. Наша единственная надежда заключается в том, чтобы помешать MassEffect.exe запускаться с правами администратора. Сначала нужно разобраться, как задан его запуск с правами администратора.

Изучив манифест EXE, я увидел, что он запускается как инициатор вызова пользователь, запускающий EXE. Это означает, что этот исполняемый файл не должен требовать администраторских прав. Я проверил свои параметры совместимости, там тоже ничего не было. Каким-то образом права повышаются при запуске, но не через сам exe и не из-за моих настроек. В чём же хитрость? В Microsoft Windows Compatibility Database.

mirh (я встречался с ним в кругах любителей моддинга) провёл исследование того, почему Mass Effect вынужден запускаться с правами администратора. Он соответствует критериям базы данных в ней есть запись для Mass Effect, в которой указано, что нужно всегда принудительно запускать параметры совместимости. Это логично пользователь не должен конфигурировать параметры, если MS уже знает, какие из них работают (теоретически).


Как видите, для этой игры есть две записи MassEffect.exe (игра) и загрузчик (который, к сожалению, не включён в версию Origin). Для совместимости включается RunAsHighest (что означает права администратора). Критерии включения таковы:

  • EXE имеет название MassEffect.exe
  • Название компании в манифесте: BioWare
  • Название продукта в манифесте: Mass Effect
  • Версия продукта равна или меньше 1.2.0.0.

Эти критерии соответствуют всем известным версиям игры, в том числе, полагаю, и пиратским. Поэтому из-за совпадения всех этих критериев exe принудительно запускается с правами администратора. Это можно легко проверить, переименовав MassEffect.exe, после чего ему не потребуются администраторские права. (Однако Origin будет недоволен).

Исправление


Итак, теперь у нас есть понимание, как это исправить, но почему в базе данных есть эта запись? Поскольку Demiurge/Bioware не поддерживают идею Least User Access (LUA), Mass Effect при самом первом запуске требует прав администратора для выполнения записи в ключ реестра HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432NODE\AGEIA Technologies. Если этот ключ не существует, он пытается создать его без прав администратора у него нет для этого доступа, и игра просто вываливается. Похоже, в этом ключе содержится некая информация о том, что сейчас называется PhysX. Вероятно, запись в реестр мог внести и установщик игры, но разработчики реализовали это в самой игре.

Именно поэтому Microsoft вынуждает игру всегда запускаться с правами администратора, из-за этого единственного пункта. Это логично если заставить её запускаться под администратором, но пользователю не нужно будет беспокоиться о параметрах совместимости. Однако из-за этой комбинации трёх проблем (LAA портит сигнатуру, MS принуждает запускаться игру с правами администратора, Origin отказывается работать с процессами с повышенными правами, имеющими сломанную сигнатуру EA) Mass Effect не запускается с Origin и LAA.

Как же нам это исправить? Просто изменим в EXE название продукта с Mass Effect на Mass_Effect. Серьёзно, это всё. Проверка критериев не срабатывает, игре больше не нужны права администратора и Origin доволен (если не считать постоянного ворчания из-за обновлений). В MEUITM и ALOT Installer мы добавили код, создающий ключ реестра с правами записи для текущего пользователя, поэтому если Mass Effect нужно создать эти ключи (допустим, если его никогда не запускали), то игра будет довольна.

Часть 2


Mass Effect на PC: что ожидать от порта с консолей середины 2000-х


Если вы не знали, Mass Effect вышла на PC в 2008 году, она была портирована с Xbox 360 студией под названием Demiurge, которая также разработала Pinnacle Station для Mass Effect. Это очень посредственный порт, не особо хорошо переживший смену времён. Он приемлем как игра, но имел множество проблем даже на момент выхода. LOD частиц работали неправильно, LOD текстур считывались в обратном порядке, параметры ini случайным образом сбрасывались на значения по умолчанию проблем было довольно много. Но не было ничего, что бы полностью ломало игру.

Ну, или типа того. Была одна проблема, но вызванная не конкретно самой Mass Effect. Серьёзная проблема заключается в том, что Mass Effect требует для запуска прав администратора потому что Demiurge, похоже, считала, что все должны запускать игру как администратор это вполне могло быть приемлемым, если бы игра разрабатывалась во время, когда была только Windows XP, однако на момент выпуска игры уже больше года существовала Windows Vista. Но даже Windows XP имела концепцию LUA (Least User Access) с разделёнными аккаунтами пользователей. Подробнее об этом можно прочитать в первой части статьи.

Ух ты, PhysX, моя любимая библиотека физики!



Наверно, у меня небольшая неприязнь к этому SDK.

Mass Effect для PC работает на немного модифицированной версии Unreal Engine 3, который был выпущен примерно в конце 2006. По словам некоторых бывших разработчиков из BioWare, эта версия Unreal Engine тогда была немного сыроватой, если не сказать больше. Согласно рассказам этих разработчиков, было очень сложно работать с ней, потому что Epic Games сосредоточенно работала над Gears of War и не уделяла особо много времени своим партнёрам, тоже использующим движок.

Для расчёта физических взаимодействий Unreal Engine 3 использует PhysX, поэтому Epic Games создала dll, реализующую интерфейс между PhysX и форматами данных Unreal Engine через файл под названием PhysXLoader.dll, который загружает библиотеки PhysX с обеих сторон. PhysX это библиотека симуляции физики, приобретённая компанией AGEIA Technologies в середине 2000-х перед тем, как саму AGEIA в начале 2008 года купила Nvidia. Возможно, вы помните карты Physics Processing Unit (PPU) они использовали PhysX до того, как Nvidia похоронила эту идею.


PhysXLoader.dll, PhysXCore.dll и NxCooking.dll составляют библиотеки PhysX для Mass Effect.

Все три части Mass Effect используют PhysX, однако Mass Effect 2 и Mass Effect 3 используют установленную в систему PhysX, а Mass Effect локальную PhysX игры. Кроме того, в Mass Effect 2 и Mass Effect 3 применяется современная версия PhysX, а не устаревшая, которая была выпущена AGEIA. После приобретения Nvidia изменила некоторые пути внутри библиотеки, отделив устаревшие части от современных версий.

Но, похоже, это не мешает программе удаления старой PhysX удалять файлы/ключи реестра современной PhysX, поэтому в процессе тестирования моего исправления другие копии Mass Effect 2/3 не работали даже после установки современного дистрибутива PhysX. Очень бесит, что BioWare не смогла просто установить библиотеку на 8 МБ вместе с игрой в комплекте с игрой всё равно поставляется установщик PhysX, то есть это даже не экономило место!

Ну да ладно

Проблема PhysXLoader.dll компании Epic Games в том, что она может загружать PhysXCore.dll локально или из установленной в систему версии


Что? Как это может быть проблемой? Разве нельзя просто загружать локальную dll, и если она не существует, загружать системную? Почему это вообще проблема?


Вы не поверите, сколько фейспалмов я испытывал в процессе создания этого исправления.

При запуске Mass Effect записывает в реестр Windows HKEY_LOCAL_MACHINE два значения:

REG_BINARY HKLM\SOFTWARE\AGEIA Technologies enableLocalPhysXCore [mac-адрес, 6 байт]

REG_DWORD HKLM\SOFTWARE\AGEIA Technologies EpicLocalDllHack [1]

*Mass Effect это 32-битная программа, поэтому на 64-битной системе она выполняет запись в HKLM\SOFTWARE\WOW6432Node\AGEIA Technologies (на случай, если вы захотите проверить сами).

Запомните эти значения реестра, они будут важны в дальнейшем!

Именно из-за этих значений реестра Mass Effect требует административных прав. В первой части мы говорили о том, почему этих операций было достаточно, чтобы Microsoft внесла Mass Effect в базу данных совместимостей, заставляющую игру запускаться под администратором при совпадении определённых критериев исполняемого файла. Мы обошли эту проблему, изменив критерии так, чтобы они больше не совпадали.

Нам нужно изменить исполняемый файл, чтобы включить Large Address Aware, благодаря чему игра сможет загружать текстуры повышенного разрешения без переполнения памяти, поэтому нет никакого способа избежать порчи сигнатуры. Это, в свою очередь, привело к тому, что Origin больше не мог запускать игру, потому что он не может повышать права игры без правильной сигнатуры EA. Но если игра не имеет возможности записывать эти ключи реестра при запуске, то она может вылететь

Итак, это само по себе уже длинная цепь проблем, но мы обошли необходимость прав администратора в Mass Effect, просто дав аккаунту пользователя разрешение на этот конкретный ключ реестра AGEIA Technologies. Это позволит процессу игры записывать нужные ему значения. Я предполагал, что игра вылетает, потому что ей запрещался доступ для записи, а Demiurge не озаботилась написать try/catch вокруг кода записи в реестр.

Вероятно, не стоит называть значения реестра hack


Наше решение этой проблемы не изменило поведения Mass Effect значения, которые игра хотела записать в реестр, тем или иным способом будут в него записаны, поэтому мы просто позволяем ей делать то, что она всегда делала, только без прав администратора. На самом деле, никаких изменений в поведении приложения не было.


Два значения реестра, записываемые Mass Effect.

Модератор PC Gaming Wiki под ником mirh долгие годы бил тревогу о том, что мы каким-то образом ломали другие игры в ALOT Installer, даже несмотря на то, что наше приложение никак не меняла способ записи Mass Effect этих значений, поэтом наше изменение никак не может сломать другие игры.

Спустя много месяцев он написал довольно подробное обоснование того, почему ALOT Installer (то есть на самом деле это была Mass Effect) ломает другие игры: находящийся в реестре enableLocalPhysXCore используется другими играми, работающими с PhysXLoader.dll. Когда я писал версию V4 установщика ALOT Installer, то сказал mirh, что серьёзнее рассмотрю его идею решения, не позволяющего ломать другие игры, хотя тогда я ещё не понимал, как ключ реестра с MAC-адресом системы может ломать другие игры и зачем вообще используется MAC-адрес.

Похоже, mirh был уверен, что эта enableLocalPhysXCore позволяет Mass Effect использовать PhysXCore.dll/NxCooking.dll в локальной папке, а не загружаться из установленного дистрибутива PhysX. Mass Effect не устанавливает дистрибутив PhysX, поэтому не может полагаться на её существование и вынуждена использовать локальные библиотеки.

Держитесь, теперь начинается нечто совершенно тупое:

MAC-адрес, сохраняемый в реестр файлом MassEffect.exe, считывается библиотекой PhysXLoader.dll и сравнивается с MAC-адресом вашей системы, чтобы определить, нужно ли загружать библиотеки PhysX из локальной папки или из системной.


Какой MAC-адрес?

\_()_/


Итак, Mass Effect работает следующим образом:

  1. В самом начале процесса загрузки MassEffect.exe MAC-адрес вашей системы считывается и записывается в реестр как enableLocalPhysXCore (вместе с EpicLocalDllHack)
  2. MassEffect.exe загружает PhysXLoader.dll
  3. PhysXLoader.dll считывает значение enableLocalPhysXCore и сравнивает с ним MAC-адрес вашей системы
  4. Если они совпадают, она использует PhysX из локальной папки, если нет, то версию дистрибутива PhysX из системы

Да, вы всё поняли правильно.

Оказалось, что другие игры, например, Mirrors Edge, имеют PhysXLoader.dll, которая тоже считывает эти значения (так как они основаны на одинаковом коде), но в этих играх нет локальных библиотек PhysX. Поэтому эти игры загружаются, видят enableLocalPhysXCore и пытаются загрузить локальную библиотеку, терпят неудачу и игра не запускается. Эту информацию я получил от mirh сам я не тестировал другие игры, поломанные этим значением реестра.

Обычно этого значения не существует, и игра должна использовать PhysX. Это поведение можно протестировать в Mass Effect, запретив доступ на запись к ключу реестра, удалив значения и установив старую версию PhysX она будет использовать системные библиотеки. Если системная PhysX не установлена, приложение не загрузится именно поэтому мы изначально разрешали записывать эти ключи Mass Effect, в противном случае бы казалось, что установщик портит Mass Effect, хотя на самом деле виновата ужасная реализация со стороны Epic Games.


Сложно представить сценарий, при котором это было бы хорошей идеей.

Если вы реализуете интерфейс с библиотекой, имеющей экспорт, который можно вызвать для инициализации/загрузки PhysX SDK, то разве нельзя просто передать ей булево значение, приказывающее ей загрузиться локально? Почему она вообще не начинает с локального поиска? И что за дела с MAC-адресом? Почему он находится в реестре, где ведёт себя как глобальный параметр???

Всё это выглядит как ужасные архитектурные решения, а после дизассемблирования PhysXLoader.dll кажется, что всё это и есть ужасные архитектурные решения. Давайте внимательнее присмотримся к Mass Effect и рассмотрим процесс её исправления от начала до конца.

Находим начальную точку


Предупреждение: я совершенный новичок в реверс-инжиниринге. Я создавал ассемблерные моды для игр Megaman Battle Network (и написал неплохое руководство по созданию хуков), проектировал моды на ActionScript2 P-Code и работал с байт-кодом UnrealScript, но никогда не углублялся в ассемблер x86. Я множество раз открывал IDA и могу находить нужные мне вещи, но никогда не понимал их. Уверен, что для более опытных реверс-инженеров этот процесс намного проще.


Сложно получать удовольствие от реверс-инжиниринга, если почти ничего не понимаешь в том, с чего начать. Это режим графа IDA, который очень помогает визуализировать ассемблер, но его всё равно очень сложно понять в большом двоичном файле на 20 МБ.

Недавно (пару лет назад), Агентство национальной безопасности США (АНБ) выпустило Ghidra бесплатный тулкит для реверс-инжиниринга с открытыми исходниками, который может отреверсировать ассемблерный код в довольно читаемый код на C; его бесконечно проще читать, чем ассемблерные графы IDA. И IDA, и Ghidra имеют свои сильные стороны: в IDA есть отладчик, позволяющий пошагово пройти по ассемблеру и посмотреть, какие пути кода будут выполняться, а также она может находить Unicode-строки (которые используются в Mass Effect ). Ghidra может рекомпилировать ассемблерный код из его декомпилированного кода на C (иногда), имеет преобразователь из ассемблера в C (простите, не знаю его названия), обладает открытыми исходниками и работает на куче платформ и со множеством двоичных форматов.


Ghidra это отличный инструмент для новичков в реверс-инжиниринге, поскольку она позволяет просматривать ассемблер как код на C, хоть и без имён переменных.

Итак, в начале я знал, что Mass Effect записывает enableLocalPhysXCore и EpicLocalDllHack. Давайте начнём с изучения MassEffect.exe, найдём эти строки и посмотрим, что на них ссылается. Открыв шестнадцатеричный редактор, я знал, что это unicode-строки, поэтому я буду искать их в IDA, потому что Ghidra, похоже, не поддерживает эту функцию.


Окно IDA Strings. Я наконец узнал, что эта полезная вкладка открывается по Shift + F12.

Поискав внутри окна IDA Strings строку enableLocalPhysXCore, я её нашёл. При двойном нажатии программа переносит нас к области данных исполняемого файла, в которой она задаётся:


На изображении вы видите, где задаются enableLocalPhysXCore, EpicLocalDLLHack и даже ключ реестра, все они находятся прямо рядом друг с другом.

Выше мы видим задание строк, которые, похоже, связаны с нашей целью. Над заданием текста мы видим DATA XREF, то есть что-то напрямую ссылается на эти данные вероятно, записывает их. Давайте дважды щёлкнем на XREF и посмотрим, куда это нас отправит.


Режим IDA View, а не Graph View.

Изучив это, мы видим, что здесь записывается RegSetValueExW. Я очень слабый разработчик на C, поэтому после гугления я понял, что это подготовка стека для вызова на C метода из Windows API, что можно увидеть по отображаемому IDA названию параметра, например, lpData и dwType. Мы знаем, что значению enableLocalPhysXCore присваивается MAC-адрес системы. Давайте посмотрим, где выполняется это присваивание. Чтобы выглядело логичнее, переключимся на режим графа.


В третьем блоке мы видим, что eax записывается в стек для lpData, а также записывается в стек для этого загадочного вызова sub_10929393. В этой подпроцедуре нет других вызовов с заданными названиями, поэтому вероятно именно там получается MAC. Давайте перейдём к ней.


Похоже, это какая-то подпроцедура-обёртка, или так это трактовала IDA, но она просто указывает на другую подпроцедуру. Давайте перейдём к ней.


Эта подпроцедура содержит названия, взятые из Windows API, и они показывают нам, что это как-то связано с сетью. Нас не волнует MAC-адрес, но давайте зададим название этой подпроцедуре. Назовём её GetMacAddress. Вернёмся к исходной подпроцедуре, которую изучали, и тоже переименуем её похоже, это что-то типа SetupPhysXSDKLoad, поэтому назовём её так.


Это относительно небольшая подпроцедура, всё, что она делает это производит запись двух вышеупомянутых значений реестра. Кроме ранних этапов загрузки двоичного файла, на эту подпроцедуру больше нет других ссылок, поэтому на этом этапе я вполне уверен, что исполняемый файл Mass Effect никогда не считывает эти значения, и пока я не знаю, что же делает EpicLocalDllHack.

Вскрываем PhysXLoader.dll


Теперь мы знаем, что исполняемый файл Mass Effect никогда не считывает этот ключ; значит, это делает одна из dll. Здесь я этого не показал, но в ProcMon (отличном инструменте для моддинга и подобных вещей в целом) я вижу, что значение реестра считывается непосредственно перед загрузкой библиотеки в процессе MassEffect.exe и перед загрузкой локальной dll. Я увидел, что после того, как запретил Mass Effect доступ на запись в эту папку, он считывает системную библиотеку, и игра не загружается, если не установлена системная версия старой PhysX.

Первой из dll загружается PhysXLoader, после которой загружается PhysXCore.dll, поэтому логично будет анализировать её. Давайте откроем её в IDA и посмотрим, где там используется enableLocalPhysXCore. Также я открою эту dll в Ghidra, чтобы лучше понимать, что происходит. Проделав ту же последовательность действий по поиску мест использования строки enableLocalPhysXCore, мы находим подпроцедуру:


Подпроцедуру читать не так уж сложно, особенно в режиме графа мы видим, что есть цикл, идущий из левого блока в блок над ним. Тем не менее, всё это не так просто читать для новичка, поэтому давайте посмотрим, как это выглядит в Ghidra. Я использую адрес этой подпроцедуры, чтобы перейти к ней в Ghidra (0x10001640).


Это даёт нам чуть больше понимания происходящего вызовов подпроцедуры, цикла, возвращаемого подпроцедурой типа. Мы можем находить элементы в одном из инструментов и помечать их в обоих, чтобы разобраться в том, что происходит. Мы сразу же видим, что есть вызов подпроцедуры, передающий путь к ключу реестра, название значения, а также две переменные. В цикле видим сравнение 6 элементов под индексу для определения их равенства.

Чтобы немного сократить пост, скажу, что эта подпроцедура поиска в реестре и в самом деле является простой обёрткой для получения информации из реестра. После реверс-инжиниринга (который в основном заключался в сопоставлении входящих данных подпроцедуры с тем, чем они являются в вызовах windows api) выяснилось, что её параметры являются подключом, именем значения, возвращаемым указателем на данные и возвращаемым размером данных.

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


Мы знаем, что Mass Effect записала в реестр 6-байтный mac-адрес, и что PhysXLoader.dll просто считала это значение из реестра, и что подпроцедура сравнивает что-то побайтно 6 раз. Логически мы можем предположить, что local_14 с показанного выше изображения это MAC-адрес. Зная это, мы также можем предположить, что FUN_10001580 получает MAC-адрес и задаёт его, поэтому мы переименуем ещё несколько элементов подпроцедуры.

Похоже, что вызов подпроцедуры не выполняет саму загрузку, он просто проверяет ключ и совпадение MAC-адреса. Зная название и информацию о действиях этого ключа, мы можем дать этой подпроцедуре обоснованное название ShouldUseLocalPhysX. Однако сравнение декомпиляции этой подпроцедуры в IDA и Ghidra приводит к немного различающимся результатам, и Ghidra, похоже, ошибается:


IDA показывает, что al присваивается 1 при нормальном выходе из цикла и 0 (xor al,al), если какие-то байты не совпадают. Ghidra этого не показывает, на самом деле она показывает, что возвращаемый тип равен void, что кажется ошибкой.

Немного погуглив информацию для этой части поста, я узнал, что EAX обычно используется как регистр возврата для x86, а регистр al это нижние 8 бит EAX. Я не имею достаточно опыта в Ghidra, чтобы знать, как сменить тип сигнатуры для этого вида возвращаемых нижних 8 битов; возможно, Ghidra пока этого не поддерживает, или я упустил какую-то настройку, которую нужно использовать.


Дизассемблированная подпроцедура, вызывающая ту, которая ищет enableLocalPhysXCore.

Однако если мы взглянем на ссылки на эту подпроцедуру (их две скорее всего, по одной на каждую библиотеку) в IDA и Ghidra, то увидим, что при вызове ShouldUseLocalPhysX она проверяет, не равен ли al нулю. Если он не равен нулю, то она загружает локальную PhysXCore.dll. Если равен, то она ищет библиотеку через системную установку PhysX, которая находится по ещё одному значению реестра в ключе AGEIA Technologies под названием PhysXCore Path. На самом деле это нам неинтересно, потому что мы хотим заставить PhysX всегда загружаться локально, вне зависимости от значения enableLocalPhysXCore.

Посмотрев на другую перекрёстную ссылку, можно и в самом деле увидеть, что она загружает библиотеку NxCooking, используя ShouldUseLocalPhysX таким же образом:


Вооружённые этим знанием, мы можем решить эту проблему несколькими способами. Я много занимался моддингом функций в байт-коде UnrealScript, который очень долгое время нельзя было увеличивать и уменьшать в размере, поэтому нахождение способов успешного или неуспешного прохождения логических проверок без изменения размера скрипта оказалось интересной задачей.

Например, если мне нужно было удалить проверку if, то мне приходилось находить способ изменить сравнения таким образом, чтобы они всегда были true или false. Один из способов возврата false проверкой if заключается в изменении ссылок на объект и токенов байт-кода сравнений, чтобы создать условный оператор вида if (objectA != objectA), всегда возвращающий false (если они не равны null). Мне нужно найти способ, чтобы в ShouldUseLocalPhysX всегда получался результат true.

Когда я писал таблицу символов для Megaman Battle Network 3, то научился всегда комментировать всё, что узнал об дизассемблированном коде. Я работал часами, совершенно забывая, что уже сделал, но мог вернуться к своим комментариям, и снова во всём разобраться.

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

Патчим худшую в мире проверку boolean



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

В x86 есть удобная однобайтная команда nop, которая в буквальном смысле не делает ничего, но занимает один байт. Также удобно то, что команда перехода в этот блок занимает 2 байта и состоит из 0x75 (jnz rel8) и 0x19 (относительного смещения).


[ЗАБАВНАЯ ИСТОРИЯ] Увидев это однобайтное смещение, я вспомнил времена, когда я работал над моддингом Megaman Battle Network. Тогда от команд перехода/ветвления зависела возможность моддинга отдельных частей ROM. При написании хука (перенаправляющего счётчик программы к вашему собственному коду) вам нужно найти команду перехода или ветвления, относительное смещение которой можно модифицировать так, чтобы оно указывало на ваш код. Затем нужно записать регистры в стек, запустить код, а затем вернуть стек обратно, чтобы подпроцедура выполняла выход правильным образом.

ARM (а конкретнее THUMB) имеет ограниченные команды ветвления, использующие в качестве относительных смещений разные размеры, которые не всегда могли перейти в любую точку ROM из-за своего местоположения в ROM. Так как игра была написана на ассемблере, находить свободное место временами было сложновато иногда приходилось соединять в цепочку несколько хуков, пока не удавалось переместить счётчик программы в свободную область, чтобы писать новый ассемблерный код. Этот jnz использует опкод 0x75, что даёт jnz rel8, то есть он может переходить только на расстояние до 128 байт (или, если переход возможен только вперёд, на 255?), что было бы настоящей проблемой, если бы я выполнял моддинг ассемблера так же, как мы работали раньше, когда не было мощных инструментов наподобие IDA и Ghidra. [КОНЕЦ ЗАБАВНОЙ ИСТОРИИ]

После замены nop-ами этого jnz наша подпроцедура ShouldUseLocalPhysX выглядит так:


Теперь в блок условия неравенства попасть нельзя. Проверка по-прежнему выполняется, но она никогда не возвращает false. Будет всегда использоваться локальное ядро PhysX.

Недостатки


Файл PhysXLoader.dll подписан Epic Games, поэтому это очевидно разрушает сигнатуру, ведь мы модифицировали файл. Игра не проверяет сигнатуры при загрузке, поэтому это не проблема. Некоторые антивирусы могут жаловаться на сломанные сигнатуры, но со временем обычно перестают. Кроме написания патча внутри памяти (как мы делаем это в загрузчике мода asi), нам нужно будет модифицировать двоичный файл библиотеки.

Получившееся поведение


Благодаря пропатченной dll игра работает как со значением реестра, так и без него, то есть Mass Effect для запуска больше не требуются права администратора. Дизассемблирование этого кода сопровождалось сильной руганью, потому что я не мог смириться с тупостью реализации этой проверки проверяется не только значение в реестре, но и MAC-адрес. В процессе отладки и пошагового выполнения команд я на самом деле сломал игру, потому что включил VPN и мой MAC-адрес сменился.

Этот процесс оказался хорошим опытом учёбы, я намного больше узнал о Ghidra и IDA, а также о других проблемах в PC-версии Mass Effect. Этот патч автоматически применяется в процессе установки ALOT Installer, поэтому пользователям не придётся беспокоиться о задании ключа enableLocalPhysXCore. Также мы модифицировали исполняемый файл Mass Effect для записи значения enableLocalPhysXCor_, чтобы наши пропатченные версии не записывали значение, портящее игры. Ванильные исполняемые файлы Mass Effect всё равно портят другие игры, но защита программ от криво написанных загрузчиков PhysX уже не входит в мои задачи.

О, а что насчёт EpicLocalDllHack? Ну, оно в буквальном смысле ничего не делает. Абсолютно бесполезное значение, оно никогда не считывается. Единственная возможная причина его существования, которую я могу придумать сохранение ключа реестра на случай деинсталляции дистрибутива PhysX, потому что оно не находится в списке значений дистрибутива PhysX, но это всё только догадки.

Разве добавление параметра PreferLocalSDK для PhysXLoader.dll это слишком сложно для Epic Games?
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru