Итак, DeLorean доставил вас в США 1990 года. Как и полагается в таких случаях, в машине что-то сломалось, так что вам предстоит задержаться на некоторое время. Пока Док Браун занимается ремонтом, вам тоже надо чем-то заняться.
Вы вспоминаете, что вы ж программист – можно заняться программированием!
В этой статье давайте пофантазируем о том, как могло бы выглядеть программирование в 1990 году.
Для начала нужен компьютер
Как человек прагматичный, но привыкший к некоторым удобствам, вы решаете воспользоваться ноутбуком, одной из самых доступных моделей на рынке. Это Amstrad PPC 640.
Весит чудо техники 6 кг. Время автономной работы – шесть часов, если не пользоваться дискетниками (с ними – часа три). Вместо аккумулятора – десять батареек типа С.
Процессор – 8088 CPU @ 4,77 MHz. Для сравнения: частота современных процессоров – в районе 3,6 GHz.
Оперативная память – 640 Kб. Тут даже сравнивать не хочется ни с чем современным, чтобы не расстраиваться.
Жёсткого диска нет, вместо него используется дискета. У компьютера два флоппи-дисковода: на A работает операционная система и могут храниться отдельные файлы, B используется исключительно как хранилище. Кстати, вот откуда взялось название знакомого нам по Windows диска C – со времён, когда жёсткий диск ставили в довесок к флоппи-приводам A и B (следующая буква по алфавиту).
Экран – монохромный. Размер: 640 x 200 пикселей в графическом режиме или 25 линий x 80 колонок — в текстовом. Мало!
Операционная система – DOS 3.3. Это хорошо, ведь как программист из 2022-го вы застали DOS или по крайней мере как-то умеете пользоваться командной строкой (этого навыка будет достаточно).
Разбираемся с программным обеспечением – без интернета
Окей, железо есть. Теперь нам нужно программное обеспечение.
И тут в повествование врывается настоящий главный герой (он же злодей и то, ради чего это всё): ассемблер.
Как программист из 2022-го вы сталкивались с ассемблером разве что на лабораторных работах в университете. Но за неимением идей получше отправляемся за ним в ближайший офлайн-магазин.
Неплохой расклад: в некоторых изданиях вместе с лицензией на ассемблер в комплекте идёт мануал.
Выбираем между MASM и TASM. Решаем купить лицензию ассемблера TASM компании Borland: он полностью совместим с С++ и Turbo Pascal от той же компании (может быть полезно для нас в дальнейшем).
Ассемблер есть. Переходим к остальному ПО.
Отладчик кода
Берём Turbo Debugger, тоже от Borland.
В отладчике мы будем смотреть значения регистров, переменных и наполнение стека. С его помощью будем понимать, какая строчка привела к зависанию нашей программы (подозреваем, что такое может случиться).
Редактор
Возьмём TASMED – чтобы иметь возможность подсвечивать синтаксис разными цветами, а ещё делать сборку и запуск нашей программы.
Другие покупки (не только ПО)
Пока не ушли далеко от магазина, покупаем ещё и адаптер для нашего портативного компьютера – чтобы подключать его к телевизору и видеть наш прекрасный разукрашенный синтаксис не в монохроме, а всё-таки в цвете.
Готовимся программировать – без Stack Overflow
Разобрались с оборудованием и софтом – можно начинать писать код. Изучаем инструкцию к ассемблеру, выписываем необходимую для работы информацию.
Первое, что нам будет нужно постоянно, – это список регистров процессора и их предназначение. Есть регистры, в которых можно хранить свои данные, и есть неизменяемые. Некоторые регистры разделены на подрегистры, которые содержат старшие и младшие байты. Чтобы не вспоминать каждый раз, выпишем их.
Общие регистры: используются для хранения значений (кроме BX) | ||||||||
16 бит | AX | BX | CX | DX | ||||
8 бит | AH | AL | BH | BL | CH | CL | DH | DL |
Используется при умножении, делении и операциях ввода-вывода; оптимизирован | Может хранить как данные, так и адреса | Чаще всего используется как счётчик в циклах | Используется для операций ввода-вывода в инструкциях IN и OUT | |||||
Сегментные регистры: указатели на блоки размером 64 Кб | ||||||||
16 бит | CS | DS | ES | SS | ||||
Указывает на сегмент кода, в котором находится следующая вызываемая инструкция | Указывает на сегмент с операндами (данными) | Дополнительный сегмент, у которого нет конкретной цели | Указывает на сегмент стека | |||||
Адресные регистры: используются для хранения адресов | ||||||||
16 бит | SI | DI | BP | |||||
Чаще всего используется в операциях переноса данных; хранит указатель на исходные данные; при операциях переноса смещается на 1; относителен сегменту DS | Чаще всего используется в операциях переноса данных; хранит адрес переноса данных; относителен сегменту ES | Указывает на адрес базы (начала) стека; относителен сегменту SS | ||||||
Регистры управления | ||||||||
16 бит | SP | IP | FLAGS | |||||
Указывает на последний элемент стека; относителен сегменту SS | Указатель на следующую инструкцию; нельзя изменить или прочитать, кроме как через специальную команду перехода; относителен сегменту CS | Хранит флаги системы и результаты выполнения последней команды; регистр стоит рассматривать побитово | ||||||
()-()-()-()-OF-DF-IF-TF-SF-ZF-()-AF-()-PF-()-CF | ||||||||
OF – флаг переполнения DF – флаг направления IF – флаг прерывания TF – флаг перехвата SF – флаг знака ZF – флаг нуля AF – флаг дополнительного переноса PF – флаг чётности CF – флаг переноса |
Ещё нам понадобится список прерываний и команд MS-DOS и BIOS.
Прерывания в ассемблере – это команды, которые забирают управление у программы и передают его MS-DOS или BIOS для выполнения конкретной работы, а после завершения операции управление возвращается программе. Часто программа сама вызывает прерывание, чтобы общаться с операционной системой и BIOS или выполнять операции ввода-вывода, например, выводить текст на экран, а также считывать нажатия клавиш.
Прерывание со своим кодом могут вызывать и сами DOS, и BIOS. Например, при делении на ноль вызовется прерывание с номером “00H”, при нажатии клавиши “PrtSc” – “05H”.
Обработка прерываний
Для обработки прерываний существует таблица векторов прерываний, в которой каждому прерыванию соответствует адрес кода, который его обрабатывает. Эти адреса можно переопределять. Данная таблица – общая для всей ОС, так что перед окончанием программы надо не забыть вернуть изначальные значения – мы не можем знать, что будет в оперативной памяти после окончания программы.
Список прерываний можно посмотреть не только в мануале, но и в специальных справочных программах, которые содержат в себе полный перечень с довольно удобным поиском. Один из самых распространённых справочников в 1990 году – “TECH Help!”.
Этот справочник будет нашим локальным интернетом. С инструкцией по TASM мы даже сможем жить без Stack Overflow.
Разбираемся со спецификой ассемблера
В 2022 году мы пишем на высокоуровневых языках. При программировании на ассемблере в 1990-м нам нужно быть начеку – учесть ряд особенностей языка.
Первое – это типы данных, с которыми предстоит работать. Забудем про bool, int, double, string и прочие. По факту мы будем иметь дело с двумя типами: байт и слово. Слово равняется двум байтам. Это максимальная величина, которую за одну операцию может обработать 16-битный процессор. Так и определяется максимальная разрядность системы.
Если нам надо сложить два числа размером до 2 байт, мы можем это сделать с помощью одной операции, а вот сложить два четырёхбайтных числа (привычный нам int) за одну операцию не выйдет.
В конце шестнадцатеричных чисел ставится h: например, «7A1h». Если число начинается не с цифры, то в начале ставится 0: «0FFh».
Дальше – строки. Здесь это просто последовательность байтов в кодировке ASCII. Что касается bool: бери любой понравившийся байт – и вот у тебя целых 8 бит, или восемь булевых значений. А если ты тру-хардкор-ассемблист, то будешь использовать каждый бит, группируя все флаги в нескольких байтах.
Также у нас могут быть, кхм, некоторые сложности с числами с плавающей точкой. В первую очередь из-за весьма вероятного отсутствия математического сопроцессора 8087, который в 1990 году есть далеко не на всех компьютерах. В будущем процессоры будут включать в себя этот самый 8087, но, пока у нас его нет, будем обходить числа с плавающей точкой стороной.
Создать переменную и обратиться к ней можно так:
; BYTE
someByte DB ?; Выделить байт с неопределённым значением (теоретически может оказаться мусором)
someString DB 'Some text'; Ассемблер сам определит длину строки и выделит необходимое количество байтов. Помним, что в ASCII один символ весит 1 байт
MOV someByte, AL; Запишем в переменную someByte значение регистра AL
; WORD
someWord DW 0FFAH; Выделим 2 байта и присвоим им значение FFA (4090 в десятичной системе)
MOV AX, someWord; Запишем в регистр AX значение переменной someWord
;ARRAY
someByteArray DB 10 DUP(5); Выделим десять ячеек по одному байту и запишем в каждую ячейку число 5 в десятичной форме
MOV someByteArray[2], AH; Запишем в третью (отсчёт идёт с нуля) ячейку массива someByteArray значение регистра AH
; POINTERS
MOV BX, OFFSET someByte; Запишем в регистр BX адрес someByte. Несмотря на то, что переменная хранит байт, адрес всегда содержит два байта (зависит от разрядности системы)
MOV someByteArray[0], BYTE PTR[BX]; Прочитаем байт из адреса, хранящегося в регистре BX, и запишем его в первый элемент массива
MOV BYTE PTR[BX], 0F1H; Запишем 1 байт со значением 0F1H в ячейку памяти по адресу из регистра BX
В ассемблере нет привычных нам конструкций из высокоуровневых языков: if/else, for/while. На этом моменте может показаться, что вот теперь-то мы влипли окончательно. Но не всё так плохо. Не зря учебное пособие по TASM начинается так:
Возможно, вы слышали, что программирование на языке ассемблера – это чёрная магия, подходящая только для хакеров и волшебников. Однако ассемблер – это не что иное, как компьютерный язык, который понятен человеку. И стоит ожидать, что язык компьютера в высшей степени логичен. Язык ассемблера очень мощный! Фактически язык ассемблера – это единственный способ использовать всю мощь семейства Intel 80x86, процессоров, лежащих в основе IBM-совместимых ПК.
Преисполнившись энтузиазмом стать чёрным магом, продолжаем погружение.
Вместо if/else в ассемблере используется система условных и безусловных переходов к определёнными отметкам кода. Такой подход кажется знакомым по школьным урокам информатики, где на Pascal или Basic мы писали конструкции GOTO.
Вариантов условных переходов много, и все они смотрят на флаги из регистра управления FLAGS. Безусловный – только оператор JMP. Флаги выставляются после команд сравнения (CMP, TEST), после математических операций (ADD, SUB) и после прерываний. Формат команды: JXX <метка>.
У TASM есть режим Ideal. В него входят дополнительные конструкции, которые есть только у данного ассемблера. И среди них есть привычная нам if/else. Но поскольку режим Ideal – это особенность только лишь TASM, мы его рассматривать не будем.
Вот пример проверки значения регистра BX на чётность/нечётность:
TEST BX,1 ;Логическое побитовое «И» между двумя операндами (число чётное, если последний бит равен 0)
JZ EVEN_ROW_CALL ; Переход, если флаг ZF равен 1. Флаг ZF равен 1, если результат предыдущей команды равен 0
JNZ ODD_ROW_CALL ; Переход, если флаг ZF равен 0
EVEN_ROW_CALL: ; Метка кода
; …SOME EVEN LOGIC…
JMP END_ALL ; Безусловный переход в конец
ODD_ROW_CALL: ; Метка кода
; …SOME ODD LOGIC…
END_ALL: ; Метка кода
; Continue...
А вот с for/while всё намного проще и привычнее. Есть конструкция LOOP. Она переходит на определённую метку, если регистр CX не равен 0. Формат команды: LOOP <метка>.
Пример LOOP:
; some code…
index DW 0
MOV CX, 100 ; Задаём количество циклов
MY_LOOP:
; …SOME LOOP LOGIC…
INC index; Увеличиваем счётчик
LOOP MY_LOOP; CX автоматически уменьшается на 1
; Continue…
C функциями всё проще и сложнее одновременно. У ассемблера есть ключевые слова. PROC указывает на начало процедуры, ENDP – на окончание. Выйти из процедуры можно с помощью команды RET.
Общий синтаксис такой:
<имя> PROC
; SOME PROCEDURE LOGIC
RET <число>
<имя> ENDP
Для вызова процедуры используется команда CALL. Формат команды: CALL <имя процедуры>.
А вот теперь начнётся настоящее веселье: передача параметров и возврат значений. Для входных параметров можно использовать регистры. Но их всего четыре, чего часто бывает недостаточно. Да и можно запутаться, какие регистры ты изменил, а какие – ещё нет. Для решения этой проблемы есть стек.
Стек – это участок памяти, который реализует принцип «первый вошёл, последний вышел». Каждая ячейка стека содержит в себе одно слово: 2 байта. Для работы со стеком используются две команды: PUSH – для записи значения в стек; POP – для получения значения из него. Регистр BP указывает на начало стека, SP – на его последний элемент. Регистры BP и SP относительны сегменту SS.
Вот так выглядит метод, который принимает два параметра:
; Определение процедуры
SOME_PROC PROC NEAR
PUSH BP;
MOV BP, SS:SP; Переносим значение начала стека в регистр BP, так как он может хранить адрес
PUSH AX; Сохраняем регистр
firstParam equ [BP + 4]; Параметры начинаются со второго элемента, так как при вызове процедуры в стек записывается адрес команды, к которой надо вернуться после завершения процедуры, и ещё 2 байта из-за сохранённого BP
secondParam equ [BP + 6]
MOV AX, secondParam; Изменяем регистр
; …SOME PROCEDURE LOGIC…
POP AX; Возвращаем значение регистра, которое было до начала процедуры
POP BP;
RET 4; Если с RET передать число, то в стеке очистится такое количество байтов (в нашем случае – два параметра по 2 байта)
SOME_PROC ENDP
; Вызов процедуры
PUSH 10
PUSH OFFSET someVariable
CALL secondParam
Ого, если придётся делать всю эту жуть в каждой процедуре, то можно сойти с ума. Если добавится параметр, надо не забыть поменять число в RET и следить за тем, чтобы количество операций записи в стек и чтения из него всегда совпадало. Очень легко ошибиться при вычислении адреса переменных. А ещё нужно сохранять регистры, чтобы процедура не влияла на всю программу. Вычисления превращаются в кошмар!
Чтобы не стрелять себе в ногу, в ассемблере TASM есть прекрасные макросы, которые не только упрощают написание процедур, но и делают их совместимыми с Turbo Pascal и Borland C++. Несмотря на то, что это макросы из TASM, в других ассемблерах они либо идентичны, либо очень на них похожи. Например, этот синтаксис будет MASM-совместимым.
Вот так будет выглядеть процедура, которая использует макросы:
SOME_PROC PROC PASCAL; Указываем, с каким языком будет совместим этот метод
ARG firstParam:WORD, secondParam:WORD; Входные параметры и их типы
USES AX; Сохраняем регистры в стеке, чтобы при завершении процедуры восстановить их изначальные значения
MOV AX, secondParam
; …SOME PROCEDURE LOGIC…
RET; Больше не надо никаких чисел
SOME_PROC ENDP
; Лёгкий вызов процедуры
CALL SOME_PROC PASCAL, 10, OFFSET someVariable; Не забываем прописать режим совместимости с языком
; Это необходимо, поскольку у разных языков разная последовательность входных параметров.
; Также у разных языков очисткой стека занимается либо сама процедура, либо вызывающий её код
Фуф, больше никаких магических чисел: передача параметров стала явной, а ещё можно одной строкой сохранять регистры. И что немаловажно, такие процедуры привычнее для путешественника во времени из 2022 года.
Чтобы упростить себе жизнь, будем относиться к ассемблеру как к процедурному языку вроде С. Процедуры, определение кастомных типов – ассемблер это умеет. Справедливости ради стоит сказать, что ассемблер умеет и в ООП, но это крайне неудобно: как правило, используется только для комбинации с С++.
PERSON STRUC
name DB 'Alex'; Значения по умолчанию
age DB 20
salary DW ?
PERSON ENDS
boss PERSON<,?,1000>; name по умолчанию, age неизвестен, salary – 1000
; Это эквивалентно
; name DB 'Alex'
; age DB ?
; salary DB 1000
MOV AX, boss.salary ; Обращение к элементу структуры
У TASM есть ещё много возможностей, которые могут упростить написание кода, но на данном этапе нам будет достаточно тех, которые мы обсудили. Будем считать, что весь необходимый инструментарий у нас есть.
Приступаем к чёрной магии. Позаботимся о чистоте кода
Когда мы начинаем программировать, понимаем, почему у ассемблера репутация «чёрной магии». Волшебные числа, мистические метки, странные операции. Ассемблер не интуитивен.
Например, вызов любого прерывания сопровождается магическими числами, понять смысл которых можно, только заглянув в мануал. Сплошь и рядом встречаются побитовые операции, код которых тоже непонятен без пояснений. Из-за всей этой жути нам критически важно содержать код в чистоте, чтобы через какую-нибудь неделю не запутаться в написанном.
Первое и одно из ключевых условий порядка в коде – это говорящие названия методов.
Дальше:
любое прерывание будем оборачивать в метод;
будем разбивать код на блоки или отдельные файлы;
вместо магических чисел станем объявлять их названия через EQU; за длину названий можно не переживать, так как транслятор заменит все метки на их значения.
Вот пример того, как может выглядеть код на ассемблере:
; main.asm
DATASEG
actualVideoMode DB 0
CODESEG
START:
;=====================CONSTS=================
BIOS_VIDEO_INTERRUPT EQU 10H; Объявим константы, которые заменят магические числа
BIOS_KEYBOARD_INTERRUPT EQU 16H
DOS_INERRUPT EQU 21H
;============================================
CALL SAVE_CURRENT_VIDEO_MODE PASCAL, OFFSET actualVideoMode
CALL CGA_MODE; Если процедура не принимает параметров, то можно опустить ключевое слово совместимости с языком
; DO SOME DRAWING
CALL WAIT_KEY
CALL RESTORE_VIDEO_MODE PASCAL, actualVideoMode; Восстановим изначальный видеорежим перед окончанием программы
CALL TERMINATE
INCLUDE utils.asm; Транслятор вставит сюда весь код из файла
END START
; utils.asm
CGA_MODE PROC PASCAL
USES AX,BX
CGA_COLOR_MODE EQU 4
MOV AX,CGA_COLOR_MODE ; CGA 320x200 4color
INT BIOS_VIDEO_INTERRUPT
RET
CGA_MODE ENDP
RESTORE_VIDEO_MODE PROC PASCAL
ARG videoMode:WORD
USES AX
MOV AX,videoMode
INT BIOS_VIDEO_INTERRUPT
RET
RESTORE_VIDEO_MODE ENDP
WAIT_KEY PROC PASCAL
USES AX
WAIT_ANY_KEY_COMMAND_CODE EQU 00H
MOV AH,WAIT_ANY_KEY_COMMAND_CODE
INT BIOS_KEYBOARD_INTERRUPT
RET
WAIT_KEY ENDP
SAVE_CURRENT_VIDEO_MODE PROC PASCAL
ARG currentModePtr:WORD
USES AX,BX
CURRENT_VIDEO_MODE_CODE EQU 0FH
MOV BX,currentModePtr
XOR AX,AX
MOV AH,CURRENT_VIDEO_MODE_CODE
INT DOS_INERRUPT
MOV BYTE PTR [BX],AL
RET
SAVE_CURRENT_VIDEO_MODE ENDP
TERMINATE PROC PASCAL
USES AX
TEMINATE_COMMAND_CODE EQU 4C00H
MOV AX,TEMINATE_COMMAND_CODE
INT DOS_INERRUPT
RET
TERMINATE ENDP
Даже не понимая, что происходит в процедурах, можно легко разобраться, что делает программа. А самое важное – код теперь не выглядит страшно. Я бы даже сказал, что видел намного более страшные и запутанные SQL-процедуры.
Чёрная магия вблизи: сильные и слабые стороны ассемблера
Ассемблер обладает наименьшим количеством технических ограничений из всех существующих языков, благодаря чему можно добиться крайне высокой оптимизации в рамках быстродействия и потребления ресурсов компьютера.
Но есть и недостатки. На ассемблере крайне неудобно писать бизнес-логику. Он заставит вас из раза в раз повторять рутинные операции, например выделение и освобождение памяти, в результате чего получится код большего объёма, чем на высокоуровневых языках.
Но есть вещи, которые ассемблер делает лучше всех. Это, например, общение с периферией. Надо написать драйвер? Драйвер должен быть максимально быстрым и потреблять минимум ресурсов? В этом ассемблер вам поможет. Надо что-то вывести на экран, нарисовать линию, текст или отрисовать картинку? Ассемблер хорош и в этом: можно использовать как отдельные прерывания, так и писать в видеопамять напрямую (на самом деле вы будете писать в оперативную память, участок которой мапится с видеопамятью, но это мелочи жизни). Запись напрямую в видеопамять выглядит как весьма производительный способ что-либо показать на экране, правда? Ещё мы можем прочитать данные с экрана. Не уверен, что такое может пригодиться, но возможность есть!
И тут мы подходим к одному из главных преимуществ ассемблера: мы можем сами решать, где и что оптимизировать.
Например, мы хотим вывести изображение из файла на экран. Допустим, файл заранее подготовлен и представлен в бинарном виде, где каждым двум битам соответствует один цвет (графический режим у CGA-экранов имеет всего две палитры по четыре цвета).
Мы можем пойти двумя путями.
Первый – это прочитать сразу весь файл, сохранить его в буфер, а уже из буфера перенести в видеопамять. Такой подход будет самым быстрым, так как потребует совершения наименьшего количества операций (конечно, мы могли бы обратиться к диску в обход операционной системы, но кажется, тут игра не стоит свеч). Минус такого подхода заключается в том, что нам в какой-то момент придётся хранить в оперативной памяти весь файл. А мы как-никак в 1990 году: не можем похвастаться, что у нас в распоряжении так уж много памяти.
Второй путь – это читать файл побайтово и сразу записывать его в видеопамять. В таком случае размер буфера будет всего 1 байт. За экономию места придётся заплатить скоростью, потому что теперь для каждого байта нужно вызывать команду чтения файла.
То, что издалека могло показаться той самой чёрной магией ассемблера, вблизи оказывается эффективной и крайне низкоуровневой оптимизацией со всеми вытекающими.
Назад в будущее
Док Браун вернул вас домой – в мир высокоуровневых языков, многопоточности, десятков гигабайтов оперативки и терабайтов жёсткого диска.
Винтажные компьютеры, DOS – чему разработчика из 2022 года мог научить опыт программирования в 1990-м?
Мы копнули вглубь разработки, спустились вниз на несколько уровней. Увидели, как программа общается с операционной системой и с процессором. Вспомнили бинарные операции: AND, OR, XOR, сдвиги влево и вправо (могут заменять целочисленное умножение и деление на 2).
Всё это крайне познавательно. Но есть ли тут практическая польза для разработчика из 2022 года?
Навскидку: ассемблер и .NET
При компиляции любого .NET-приложения генерируется код Intermediate Language (IL). Во время выполнения программы IL-код транслируется в нативный код через компилятор Just-In-Time (JIT). Представим себе ситуацию, когда нам хочется понять, что наделал этот JIT.
Нативный код мы прочитать не сможем – нули и единицы выглядят живописно, но не слишком информативно для человеческого глаза. Давайте лучше заглянем в Disassembly от Microsoft (или просто дизассемблер). Там мы найдём листинг на ассемблере, который полностью соответствует нативному коду. И вот тут понимание ассемблера очень пригодится.
На learn.microsoft.com в разделе про дизассемблер даже есть такая плашка:
Чтобы использовать возможности дизассемблера по максимуму, требуются базовые знания программирования на ассемблере.
Конечно, дизассемблить код приходится не так уж часто. Многие .NET-разработчики совершенно справедливо никогда этого не делали.
В дизассемблер вас может привести желание написать некоторый суперпроизводительный код или (почему бы и нет?) хакнуть .NET. Это не так уж и сложно. Например, можно заменить вообще любой метод на другой во всей программе.
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace HackTest;
public class Program
{
sealed public class A
{
public string LocalParam = "LocalParam";
public void WriteA(string d)
{
Console.WriteLine($"Method A {d}");
}
}
public class B
{
public void WriteB(string d)
{
var valueFromA = ((A)(object)this).LocalParam;// Доступ к родительскому классу тоже есть
Console.WriteLine($"Method B {d} with {valueFromA}");
}
}
public static void Main(string[] args)
{
var a = new A();
HackReplace(typeof(A).GetMethod("WriteA"), typeof(B).GetMethod("WriteB"));// Это замена навсегда. Можно сохранять ссылку на изначальный метод и заменять её обратно
a.WriteA("TEST"); // Посмотрите в консоли, что выведется :)
}
public static void HackReplace(MethodBase source, MethodBase dest)
{
RuntimeHelpers.PrepareMethod(source.MethodHandle); // Чтобы замена сработала, JIT-компилятор должен генерировать нативный код для метода – мы будем его менять
RuntimeHelpers.PrepareMethod(dest.MethodHandle);
var firtPrt = source.MethodHandle.GetFunctionPointer(); // Получаем указатель на метод
var secondPrt = dest.MethodHandle.GetFunctionPointer().ToInt64(); // Говорим, что нам надо относиться к указателю как к обычному long-значению
var sourcePtrEnd = firtPrt.ToInt64() + 1 + 4; // Нам нужно вычислить offset к следующей инструкции
Marshal.WriteIntPtr(firtPrt + 1, new IntPtr(secondPrt - sourcePtrEnd));
}
}
Пример хака; и даже без unsafe
В примере выше мы, по сути, заменяем одну команду на другую в нативном коде. Без знания ассемблера разобраться будет непросто. Например, будет непонятно, откуда взялись волшебные числа 1 и 4.
Разобраться нам поможет листинг, который генерируется при вызове метода:
№ Нативный код Ассемблерный код
1 00007FF7A2E642A3 mov byte ptr [0000000200007FF7h],al
2 00007FF7A2E642AC add byte ptr [rax],al
3 00007FF7A2E642AE add byte ptr [rax],al
4 00007FF7A2E642B0 jmp HackTest.Program+A.WriteA()(07FF7A2E64820h)
5 00007FF7A2E642B5 pop rdi
6 00007FF7A2E642B6 add byte ptr [rcx],al
7 00007FF7A2E642B8 jmp HackTest.Program+A..ctor()(07FF7A2E647C0h)
8 00007FF7A2E642BD pop rdi
9 00007FF7A2E642BE add eax,dword ptr [rax]
10 00007FF7A2E642C0 adc byte ptr [rax+7FF7A2EEh],bh
Чтобы узнать полную длину команды, надо из адреса следующей команды вычесть адрес искомой. В нашем случае – из адреса строки 5 вычесть адрес строки 4. Возьмём две последние цифры и получим: B5-B0=5. Один байт – это сама команда JMP, а остальные четыре хранят разницу между следующей по порядку командой и командой, на которую нужно перепрыгнуть. Вот эти четыре байта мы и заменяем. Помещаем туда разницу между следующей командой и командой по вызову метода WriteB
. В итоге эта JMP
перепрыгивает на JMP HackTest.Program+B.WriteB()
, которая уже и вызывает нужный метод.
Работает это настолько хорошо, что даже breakpoint при дебаге там останавливается и в stack trace отображает всё корректно:
Unhandled exception. System.NotImplementedException: The method or operation is not implemented.
at HackTest.Program.B.WriteB() in C:\Users\Mike\source\repos\HackTest\HackTest\Program.cs:line 21
at HackTest.Program.Main(String[] args) in C:\Users\Mike\source\repos\HackTest\HackTest\Program.cs:line 29
Стоит сказать, что такой хак может по-разному работать на разных процессорах и версиях .NET. Представленная выше реализация актуальна для x64 и .NET 6. В идеале она должна работать и на x32 и других версиях .NET начиная с .NET Core 2.0. А вот за работу на .NET Framework и архитектуре ARM поручиться не могу.
Если покопаться, можно делать и многое другое. Например – декорировать методы. Такие хаки особенно актуальны, когда надо обернуть какой-нибудь sealed-метод сторонней библиотеки. Например, можно добавить шифрование и логирование в методы, в которых они не предусмотрены.
Ассемблер не только в .NET
Ассемблер жив сам по себе! До сих пор можно встретить свежий код, намеренно написанный на ассемблере – если требуется максимальная производительность.
Если посмотреть на список самых популярных языков по версии TIOBE, то там ассемблер занимает строчку между JS и SQL:
Да, TASM уже давно мёртв (хотя вы всё ещё можете написать на нём 32-битное приложение под Windows). Но есть другие ассемблеры: MASM, GAS, NASM.
Нужно сделать максимально быстрый обработчик видеопотока? Ассемблер вам в помощь. Хотите написать драйвер? Опять обращайтесь к ассемблеру.
Большое направление программирования на ассемблере – это написание программ для микроконтроллеров. У этих девайсов обычно большие ограничения по мощности процессора и объёму памяти, так что в этом случае ассемблер – это не альтернатива, а единственное средство решения задачи.
Ассемблер на все времена
С 1990 года многое изменилось: железо стало мощнее, софт – разнообразнее и удобнее, появились новые языки программирования.
Ассемблер ещё тогда в 1990-м считали чёрной магией (помните напутствие из мануала TASM?). А сейчас и подавно: кажется, зачем этот входной порог и приседания, если под рукой столько более комфортных инструментов?
Но не так страшен ассемблер, как его репутация. Хардкор в .NET, микроконтроллеры, низкоуровневые оптимизации – областей применения хватает и по сей день. И старый добрый ассемблер по-прежнему помогает делать крутые вещи.
Возможно, в путешествии с Доком Брауном в наше будущее мы увидим, как искусственный интеллект пишет код вместо людей и творятся разные другие чудеса программирования. Но не удивлюсь, если и там нам доведётся встретить ассемблер – живой и здоровый.