Pull to refresh
186.97
Ozon Tech
Команда разработки ведущего e‑com в России

Чем программисту заняться в 1990 году: осваиваем чёрную магию ассемблера

Reading time17 min
Views27K

Итак, 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 с мануалом
Вот так выглядела коробка издание MASM с мануалом

Выбираем между 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-экранов имеет всего две палитры по четыре цвета). 

Изображение на монохромном CGA-экране
Изображение на монохромном 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, микроконтроллеры, низкоуровневые оптимизации – областей применения хватает и по сей день. И старый добрый ассемблер по-прежнему помогает делать крутые вещи.

Возможно, в путешествии с Доком Брауном в наше будущее мы увидим, как искусственный интеллект пишет код вместо людей и творятся разные другие чудеса программирования. Но не удивлюсь, если и там нам доведётся встретить ассемблер – живой и здоровый.

Tags:
Hubs:
Total votes 97: ↑96 and ↓1+113
Comments161

Articles

Information

Website
ozon.tech
Registered
Founded
Employees
5,001–10,000 employees
Location
Россия