Hello world на ассемблере
Hello world на ассемблере
Первая программа на Assembler.
Вот и пришло время написать нашу первую программу на языке Assembler. Начнем мы с процессора Intel 8086. Будем писать программу под DOS. Программирование под Windows и Linux сложнее. Поэтому начнем с простого и понятного 16-битного процессора 8086.
DOS (дисковая операционная система, ДОС) — семейство операционных систем для персональных компьютеров, ориентированных на использование дисковых накопителей, таких как жёсткий диск и дискета.
DOS является однозадачной операционной системой. После запуска управление передаётся прикладной программе, которая получает в своё распоряжение все ресурсы компьютера и может осуществлять ввод-вывод посредством как функций, предоставляемых операционной системой, так и функций базовой системы ввода-вывода (BIOS), а также работать с устройствами напрямую.
Практическая ценность от программирования под DOS в наше время не очень большая. Но она позволит нам быстро освоить основы ассемблера, а потом мы уже перейдем к программированию под 32-битные системы.
Необходимые инструменты:
Для программирования на ассемблере нам необходим компилятор. Наиболее известные компиляторы это TASM, MASM и FASM. В этом курсе я решил использовать FASM. Это довольно новый, удобный, быстро развивающийся компилятор ассемблера, написанный на себе самом. Его преимущества — это поддержка сложных макросов и мультиплатформенность. Есть версии под DOS, Windows и Linux.
С его помощью можно сгенерировать файл любого формата, не обязательно исполняемый файл, так что FASM — это превосходный инструмент для экспериментов и исследований. Скачать его вы можете на официальном сервере.
Если у вас Windows, тогда устанавливать надо на 32 битную версию (х86) т.к. на 64 битной версии (х64) будет выдавать ошибку. Мой компьютер 64 битной версии, поэтому я установил Windows 7 32 bit на виртуальную машину и уже туда установил FASM. Как установить Windows на виртуальную машину я объяснял в своей статье “Установка Windows 7 на виртуальную машину“.
Приступим к написанию первой программы:
После всех манипуляций открывайте FASM и начнем писать код. Как и всегда мы напишем приветствие и выведим его в консоль. Я напишу код и внизу него объясню каждую строчку.
«use16» – сообщает, что нужно генерировать 16-битный код. Нам нужен именно такой для нашей первой программы.
«org 100h» – объясняет, что следующие команды и данные будут располагаться в памяти, начиная с адреса 100h. Дело в том, что при загрузке нашей программы в память, DOS размещает в первых 256 байтах (с адресов 0000h — 00FFh) свои служебные данные. Нам эти данные изменять нежелательно.
“mov dx,hello” – Помещаем “hello” в регист dx. Делаем что-то типо переменной.
“mov ah,9” – пишем номер функции DOS.
“int 21h” – обращаемся к функции DOS.
“mov ax,4C00h и int 21h” — это стандартное завершение процесса в DOS. Так мы будем завершать все наши программы.
“hello db ‘Hello, World!$’” – сообщаем что в “hello” хранится наше приветствие, которое будет выведено в консоль.
Чтобы откомпилировать программу надо выбрать меню Run->Compile. FASM предложит сохранить файл, если вы этого ещё не сделали, а затем скомпилирует. То есть переведет текст, набранный нами, в машинный код и сделает его программой.
MASM под Windows: быстрый старт
Олег Французов
2017
В этом документе кратко описан процесс установки учебной среды на основе ассемблера MASM под ОС Windows, а также порядок работы с ней.
Установка MASM
Скачайте архив c MASM с сайта arch32.cs.msu.su.
Скачайте файл prompt.bat и положите его в ваш рабочий каталог.
Простейшая программа
Для следующего шага вам потребуется текстовый редактор, пригодный для работы с программным кодом. Заметим, что Microsoft Word или встроенный в Windows редактор WordPad являются текстовыми процессорами и для работы с программным кодом непригодны. Редактор Notepad (Блокнот) подходит для работы с текстовыми файлами (plain text), но неудобен в качестве программистского редактора — в нем отсутствует подсветка синтаксиса и другие стандартные для таких редакторов функции.
Вы можете воспользоваться вашим любимым текстовым редактором или, если вы затрудняетесь с выбором, скачать простой программистский текстовый редактор Notepad2.
Примечание: Если вы решили скачать Notepad2, при первом запуске установите ширину табуляции (Tabulator width) в значение 8 при помощи меню Settings > Tab Settings.
Создайте в вашем рабочем каталоге файл hello.asm следующего содержания:
Трансляция и запуск программы
Для запуска программы требуется ее оттранслировать. Первый шаг — запуск ассемблера MASM, который построит по исходному тексту програмы объектный файл:
Аргумент /c инструктирует ассемблер выполнить только трансляцию в объектный файл, без компоновки (которую мы выполним чуть позже). Аргумент /coff указывает формат объектного файла — COFF (Common Object File Format).
Аргумент /subsystem:console говорит компоновщику, что нужно построить консольное Windows-приложение.
Как это устроено
Командный файл prompt.bat запускает окно командной строки и задает переменные окружения так, чтобы программы ml и link были доступны без указания пути к ним, а пути к include- и lib-файлам MASM также были известны.
Пути заданы жестко, поэтому и требовалось распаковать архив в строго определенный каталог.
Командный файл для упрощения запуска
Когда вам надоест каждый раз набирать три команды для трансляции и запуска программ, создайте такой командный файл (назвать его можно, например, mkr.bat — то есть make/run):
Использовать его можно будет следующим образом:
Несколько комментариев по устройству этого командного файла:
Команда @echo off отключает дублирование каждой исполняемой команды в окне командной строки.
Аргумент /nologo при вызове ассемблера и компоновщика убирает строчку “Copyright (C) Microsoft”, захламляющую экран.
%1 меняется на аргумент, который передан командному файлу, то есть имя программы на ассемблере (выше — hello.asm ).
n1 меняется на тот же аргумент, но без расширения (выше — hello ).
Связка && выполняет очередную команду, только если предыдущая завершилась успешно. В случае ошибок трансляции ваша программа запущена не будет.
То, что получилось в итоге — это простейшая система программирования, состоящая из транслятора (ассемблера MASM), текстового редактора (Notepad2 или иного, если вы его предпочли) и примитивной системы сборки на единственном командном файле.
Несмотря на простоту этой системы, она основывается на тех же общих принципах, что и более сложные системы программирования. Подробнее с этим вы сможете познакомиться на втором курсе.
Пишем Hello World на FASM
Одним томным пятничным вечером взбрела мне в голову безумная идея: а почему бы мне не поразмять мозг, и не написать HelloWorld на ассемблере. Однако это показалось слишком простым. А давайте соберем не x86 программу, а java class? Сказано — сделано.
Первым делом находим спецификацию JVM. Как мы видим, файл класса Java состоит из:
Вообще, язык макросов FASM настолько мощный, что на нем можно написать еще один язык, причем не одним способом.
Константный пул устроен довольно запутанно, один дескриптор ссылается на другой, он на третий, но разобраться можно — это же все-таки люди сочиняли.
Элементы константного пула в общем случае выглядят так:
Полный список тегов приведен в документации, подробно я их описывать не буду. Опишу трюк, который я использовал для автоматического подсчета размера пула (а так же методов, полей и т.д.).
Конструкция вида const#name — склеивает текст const и значение из константы name. Конструкция аналогична такой же из макросов языка C.
Хоть в терминологии FASM-a константы называются константами, на самом деле они ведут себя как переменные, и с ними можно производить многие манипуляции.
Дальше объявляем макросы для начала и для конца:
Но ведь такой переменной не существует, скажете вы. И будете правы. Пока не существует. FASM — многопроходной ассемблер, и что не было найдено при первом проходе, он запомнит и подключит при втором или дальнейших.
При следующем проходе ассемблер подставит вместо const_count_end ровно столько, сколько он насчитал констант.
Методы и поля организованы схожим образом. Приведу пример макроса, генерирующего константу Method
Ну и напоследок сам исходник:
» Полный код можно посмотреть здесь.
Погружение в ассемблер. Делаем первые шаги в освоении асма
Партнер
Содержание статьи
От редакции
В 2017 году мы опубликовали первую статью из планировавшегося цикла про ассемблер x86. Материал имел огромный успех, однако, к нашему стыду, так и остался единственным. Прошло два с половиной года, и теперь за дело берется новый автор. В честь этого мы делаем прошлую статью бесплатной, а Антона Карева попросили пропустить введение и без оглядки нырять в практику.
Читай далее:
Готовимся к работе
Я буду исходить из того, что ты уже знаком с программированием — знаешь какой-нибудь из языков высокого уровня (С, PHP, Java, JavaScript и тому подобные), тебе доводилось в них работать с шестнадцатеричными числами, плюс ты умеешь пользоваться командной строкой под Windows, Linux или macOS.
Если наборы инструкций у процессоров разные, то на каком учить ассемблер лучше всего?
Знаешь, что такое 8088? Это дедушка всех компьютерных процессоров! Причем живой дедушка. Я бы даже сказал — бессмертный и бессменный. Если с твоего процессора, будь то Ryzen, Core i9 или еще какой-то, отколупать все примочки, налепленные туда под влиянием технологического прогресса, то останется старый добрый 8088.
SGX-анклавы, MMX, 512-битные SIMD-регистры и другие новшества приходят и уходят. Но дедушка 8088 остается неизменным. Подружись сначала с ним. После этого ты легко разберешься с любой примочкой своего процессора.
Больше того, когда ты начинаешь с начала — то есть сперва выучиваешь классический набор инструкций 8088 и только потом постепенно знакомишься с современными фичами, — ты в какой-то миг начинаешь видеть нестандартные способы применения этих самых фич. Смотри, например, что я сделал с SGX-анклавами и SIMD-регистрами.
Что и как процессор делает после того, как ты запускаешь программу
После того как ты запустил софтину и ОС загрузила ее в оперативную память, процессор нацеливается на первый байт твоей программы. Вычленяет оттуда инструкцию и выполняет ее, а выполнив, переходит к следующей. И так до конца программы.
Некоторые инструкции занимают один байт памяти, другие два, три или больше. Они выглядят как-то так:
Хотя погоди! Только машина может понять такое. Поэтому много лет назад программисты придумали более гуманный способ общения с компьютером: создали ассемблер.
Благодаря ассемблеру ты теперь вместо того, чтобы танцевать с бубном вокруг шестнадцатеричных чисел, можешь те же самые инструкции писать в мнемонике:
Согласись, такое читать куда легче. Хотя, с другой стороны, если ты видишь ассемблерный код впервые, такая мнемоника для тебя, скорее всего, тоже непонятна. Но мы сейчас это исправим.
Продолжение доступно только участникам
Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее
Пишем операционную систему. Часть 1. Загрузчик
Всем привет! Сегодня мы напишем загрузчик, который будет выводить «Hello World» и запустим его на VirtualBox. Писать будем на ассемблере FASM. Скачать его можно отсюда. Также нам понадобится собственно VirtualBox и UltraISO. Перед тем как писать код, разберемся как загружаются операционные системы.
Итак, когда мы нажимаем большую кнопку включения на нашем компьютере запускается система, которая есть на любом компьютере — BIOS (Basic Input/Output System или базовая система ввода/вывода). Задача BIOS это:
Этот код требует немного пояснений. Командой
мы говорим, что код нужно загружать в ОЗУ по адресу 0x7C00. В строках
мы устанавливаем видео режим 80х25 (80 символов в строке и 25 строк) и тем самым очищаем экран.
Здесь мы устанавливаем курсор. За это отвечает функция 2h прерывания 10h. В регистр dh мы помещаем координату курсора по Y, а в регистр dl — по X.
Печатаем строку. За это отвечает функция 13h прерывания 10h. В регистр bp мы помещаем саму строку, в регистр cx — число символов в строке, в регистр bl — атрибут, в нашем случае цвет, он будет зеленым. На цвет фона влияют первые 4 бита, на цвет текста — вторые 4 бита. Ниже представлена таблица цветов
Откомпилируем код нажатием клавиш Ctrl + F9 и сохраним полученный файл как boot.bin.
Запуск
Запускаем UltraISO и перетаскиваем наш бинарник в специальную область (отмечено красной стрелкой).
Далее кликаем правой кнопкой мыши по бинаринку и нажимаем кнопку примерно с такой надписью: «Установить загрузочным файлом». Далее сохраняем наш файл в формате ISO.
Открываем VIrtualBox и создаем новую виртуальную машину (если вы не знаете, как это делается, кликайте сюда). Итак, после того, как вы создали виртуальную машину, нажимаем «Настроить, выбираем пункт „Носители“, нажимаем на „Пусто“, там где „Привод“ есть значок оптического диска. Нажимаем на него и выбираем „Выбрать образ оптического диска“, ищем наш ISO файл, нажимаем „Открыть“. Сохраняем все настройки и запускаем виртуальную машину. На экране появляется наш „Hello World!“.
На этом первый выпуск подходит к концу. В следующей части мы научим наш загрузчик читать сектора диска и загрузим свое первое ядро!
FasmWorld Программирование на ассемблере FASM для начинающих и не только
Учебный курс. Часть 2. Первая программа
Автор: xrnd | Рубрика: Учебный курс | 04-02-2010 |
Распечатать запись
Итак, поехали! Курс обучения любому языку программирования принято начинать с написания программы «Hello, world!». Однако мы этого делать не будем. Потому что «Hello, world!» на ассемблере придется долго объяснять и трудно понять сходу. А я хочу сделать курс из коротких понятных статей.
Поэтому мы напишем совсем простую программу. Сразу оговорюсь, что мы будем писать только COM-программы под DOS. Они проще, чем EXE, а подробно разбирать тонкости программирования под DOS мне не интересно, во всяком случае в учебном курсе.
Для того, чтобы написать программу, нам надо запустить fasmw.exe. Откроется окошко, в которое можно смело набивать код:
В это окошко надо ввести следующее (я подробно объясню ниже, что значит каждая строчка):
use16 ;Генерировать 16-битный код org 100h ;Программа начинается с адреса 100h mov ax,255 ;Поместить 255 в регистр AX inc ax ;Увеличить содержимое AX на 1 nop ;Эта команда ничего не делает mov bx,ax ;Поместить в BX содержимое AX mov ax,4C00h ;\ int 21h ;/ Завершение программы
Первая строка «use16» сообщает FASM’у, что нужно генерировать 16-битный код. Нам нужен именно такой для нашей первой программы. Точка с запятой — это символ комментария. Все что идет после «;» до конца строки игнорируется компилятором. Там можно писать все что угодно.
Вторая строка «org 100h» объясняет FASM’у, что следующие команды и данные будут располагаться в памяти, начиная с адреса 100h. Дело в том, что при загрузке нашей программы в память, DOS размещает в первых 256 байтах (с адресов 0000h — 00FFh) свои служебные данные. Нам эти данные изменять нежелательно.
Далее идут непосредственно команды! Программа на ассемблере состоит из команд процессора. Каждая команда обозначается мнемоникой (символическим именем). Например «mov», «inc», «nop» и т.д. После мнемоники могут идти операнды. Они отделяются одним или несколькими пробелами (или табуляцией).
Команды бывают без операндов, с одним или несколькими операндами. Если операндов больше одного, то они отделяются друг от друга запятыми.
Отступы не обязательны, но желательны — с ними код гораздо легче читать. Пустые строки игнорируются. Регистр символов значения не имеет. Можно писать большими буквами, или маленькими, или в перемешку.
Четвертая строка определяет команду «поместить число 255 в регистр AX». «mov» — это мнемоника команды (от английского «MOVe»). AX — первый операнд — приёмник. 255 — второй операнд — источник. Первый операнд является регистром. Второй операнд — константа 255.
Пятая строка. Тут команда «inc» с одним операндом. Она заставит процессор выполнить инкремент, то есть увеличение на единицу. Единственный операнд — это регистр AX, содержимое которого и будет увеличено на 1.
Шестая строка. Команда «nop» — без операндов. Эта команда ничего не делает 🙂 Зачем она нужна я ещё расскажу как-нибудь.
Седьмая строка. Снова команда «mov», но на этот раз оба операнда являются регистрами. Команда скопирует в BX содержимое AX.
Две последние строки — это стандартное завершение процесса в DOS. Так мы будем завершать все наши программы. Команда «mov» должна быть вам понятна, а про команду «int» я ещё расскажу, это отдельная тема.
В общем, наша программа ничего не делает 🙂 Но в следующей статье я расскажу как работать с отладчиком. И мы её отладим и увидим как она работает! 🙂
Как написать hello world на ассемблере под Windows?
Я хотел написать что-то базовое в сборке под Windows, использую NASM, но ничего не получается.
Как написать и скомпилировать hello world без помощи функций C в Windows?
Также существует The Clueless Newbies Guide to Hello World в Nasm без использования библиотеки C. Тогда код будет выглядеть так.
В этом примере показано, как перейти непосредственно к Windows API, а не ссылаться на стандартную библиотеку C.
Для компиляции вам понадобятся NASM и LINK.EXE (из Visual Studio Standard Edition).
Это примеры Win32 и Win64 с использованием вызовов Windows API. Они предназначены для MASM, а не для NASM, но взгляните на них. Вы можете найти более подробную информацию в этой статье.
Это использует MessageBox вместо вывода на стандартный вывод.
Win32 MASM
Win64 MASM
Чтобы собрать и связать их с помощью MASM, используйте это для 32-битного исполняемого файла:
или это для 64-битного исполняемого файла:
Используя invoke директиву MASM (которая знает соглашение о вызовах), вы можете использовать один ifdef, чтобы создать его версию, которая может быть 32-битной или 64-битной.
Вы можете разобрать вывод, чтобы увидеть, насколько invoke расширен.
Плоский Ассемблер не требует дополнительного компоновщика. Это упрощает программирование на ассемблере. Он также доступен для Linux.
Это hello.asm из примеров Fasm:
Fasm создает исполняемый файл:
Если этот код сохранен, например, в «test64.asm», то для компиляции:
Производит «test64.obj» Затем для ссылки из командной строки:
где path_to_link может быть C: \ Program Files (x86) \ Microsoft Visual Studio 10.0 \ VC \ bin или где бы ни была ваша программа link.exe на вашем компьютере, path_to_libs может быть C: \ Program Files (x86) \ Windows Kits \ 8.1 \ Lib \ winv6.3 \ um \ x64 или где бы то ни было ваши библиотеки (в этом случае и kernel32.lib, и user32.lib находятся в одном месте, в противном случае используйте один вариант для каждого нужного опции необходимо, чтобы линкер не жаловался на адреса слишком долго (в данном случае для user32.lib). Кроме того, как это сделано здесь, если компоновщик Visual вызывается из командной строки, необходимо предварительно настроить среду (запустить один раз vcvarsall.bat и / или просмотреть MS C ++ 2010 и mspdb100.dll пути) и / largeaddressaware: no ).
Если вы не вызовете какую-либо функцию, это совсем не тривиально. (И, серьезно, нет реальной разницы в сложности между вызовом printf и вызовом функции api win32.)
Даже DOS int 21h на самом деле просто вызов функции, даже если это другой API.
Если вы хотите сделать это без посторонней помощи, вам нужно напрямую поговорить с вашим видеооборудованием, вероятно, записывая растровые изображения букв «Hello world» во фреймбуфер. Даже тогда видеокарта выполняет работу по преобразованию этих значений памяти в сигналы VGA / DVI.
Обратите внимание, что на самом деле ничто из этого, вплоть до аппаратного обеспечения, в ASM не интереснее, чем в C. Программа «hello world» сводится к вызову функции. В ASM есть одна приятная особенность: вы можете довольно легко использовать любой ABI; вам просто нужно знать, что это за ABI.
Если вам нужна консольная программа, которая позволяет перенаправлять стандартный вход и стандартный выход, что также возможно. Доступен (очень нетривиальный) пример программы, которая не использует графический интерфейс и работает строго с консолью, то есть с самим fasm. Это можно проредить до самого необходимого. (Я написал четвертый компилятор, который является еще одним примером, не связанным с графическим интерфейсом, но он также нетривиален).
В такой программе есть следующая команда для создания правильного заголовка для 32-разрядного исполняемого файла, обычно выполняемая компоновщиком.
Раздел под названием ‘.idata’ содержит таблицу, которая помогает окнам во время запуска связывать имена функций с адресами среды выполнения. Он также содержит ссылку на KERNEL.DLL, которая является операционной системой Windows.
Ваша программа находится в разделе ‘.text’. Если вы объявляете этот раздел доступным для чтения, записываемым и исполняемым, это единственный раздел, который вам нужно добавить.
В итоге: есть таблица с именами asci, которые связаны с ОС Windows. Во время запуска она преобразуется в таблицу вызываемых адресов, которую вы используете в своей программе.
Пишем Android-приложение на ассемблере
Эта рассказ о нестандартном подходе к разработке Android-приложений. Одно дело — установка Android Studio и написание «Hello, World» на Java или Kotlin. Но я покажу, как эту же задачу можно выполнить иначе.
Напоминаем: для всех читателей «Хабра» — скидка 10 000 рублей при записи на любой курс Skillbox по промокоду «Хабр».
Skillbox рекомендует: Образовательный онлайн-курс «Профессия Java-разработчик».
Как работает мой смартфон с Android OS?
Сначала небольшая предыстория. Однажды вечером мне позвонила знакомая по имени Ариэлла. Она спросила меня: «Слушай, а как работает мой смартфон? Что у него внутри? Как электрическая энергия и обычные единицы и нули позволяют всему этому функционировать?»
Моя знакомая не нуб в разработке, она создала несколько проектов на Arduino, которые состояли как из программной, так и из аппаратной частей. Может быть, именно поэтому она захотела узнать больше. Мне удалось ответить при помощи знаний, полученных на одном из курсов информатики, пройденных в университете.
Затем мы работали пару недель вместе, поскольку Ариэлла захотела узнать, как работают кирпичики электронной техники, то есть полупроводниковые элементы, включая транзисторы. Далее мы вышли на более высокий уровень: я ей показал, как можно создавать логические вентили, к примеру NAND (логическое И) плюс NOR (логическое ИЛИ) c использованием специфической комбинации транзисторов.
Мы исследовали логические элементы разных видов, объединяли их для выполнения вычислений (например, добавления двух бинарных чисел) и ячеек памяти (триггеров). Когда все прояснилось, начали разрабатывать простой процессор (воображаемый), в котором было два регистра общего назначения и две простые инструкции (добавление этих регистров). Мы даже написали простую программу, которая умножает эти два числа.
Кстати, если вас интересует эта тема, то прочитайте инструкцию по созданию 8-битного компьютера с нуля. Здесь объясняется практически все, с самых основ. Хотел бы я прочитать это раньше!
Hello, Android!
После завершения всех этапов изучения мне показалось, что у Ариэллы хватит знаний, чтобы понять, как работает процессор смартфона. Ее смартфон — Galaxy S6 Edge, база которого — архитектура ARM (как, собственно, и у большинства смартфонов). Мы решили написать «Hello, World»-приложение для Android, но на ассемблере.
Если вы никогда раньше не сталкивались с кодом ассемблера, то этот блок может вас напугать. Но ничего страшного, давайте разберем код вместе.
В строке 2 мы определяем глобальную функцию с названием _start. Она представляет собой точку входа в приложение. ОС начинает выполнять код именно с этой точки. Определение функции объявлено в строке 4.
Кроме того, функция выполняет еще две вещи. В строках 5–9 сообщение выводится на экран, в строках 11–13 программа завершается. Даже если удалить 11–13 строки, программа выведет нашу строку «Hello, World» и завершится. Тем не менее выход не будет корректным, поскольку программа завершится с ошибкой. Без строк 11–13 приложение попытается выполнить недопустимую инструкцию.
Что касается параметров для системного вызова, то они передаются через другие регистры. Например, r0 показывает номер дескриптора файла, который нам необходимо напечатать. Мы помещаем туда значение 1 (строка 5), указывающее стандартный вывод (stdout), то есть вывод на экран.
r1 указывает на адрес памяти данных, которые мы хотим записать, поэтому мы просто загружаем в эту область адрес строки «Hello, World» (строка 6), а регистр r2 показывает, сколько байтов мы хотим записать. В нашей программе для него установлено значение message_len (строка 7), вычисляемое в строке 18 с использованием специального синтаксиса: символ точки обозначает текущий адрес памяти. По этой причине. — message обозначает текущий адрес памяти минус адрес message. Ну а поскольку мы заявляем message_len сразу же после message, то все это вычисляется как длина message.
Если записать код строк 5–9 при помощи языка С, получится следующее:
Завершить работу программы несколько проще. Для этого мы просто прописываем код выхода в регистр r0 (строка 11), после чего добавляем значение 1, являющееся номером вызова системной функции exit(), в r7 (строка 12), затем снова вызываем ядро (строка 13).
Полный список системных вызовов Android и их номеров можно найти в исходном коде операционной системы. Также там есть и реализация write() и exit(), вызывающих соответствующие системные функции.
Собираем программу
Для того чтобы скомпилировать наш проект, понадобится Android NDK (Native Development Kit). Он содержит набор компиляторов и инструментов сборки для ARM-платформы. Загрузить его можно с официального сайта, установить — например, через Android Studio.
После того как NDK установлен, нам понадобится файл arm-linux-androideabi-as, это ассемблер для ARM. Если вы произвели загрузку через Android Studio, то поищите его в папке Android SDK. Обычно ее расположение —
После того как ассемблер найден, сохраните написанное в файл с названием hello.s, после чего выполните следующую команду для преобразования его в машинный код:
Эта операция позволяет создать объектный ELF-файл с именем hello.o. Для того чтобы преобразовать его в двоичный файл, который может работать на вашем девайсе, вызовите компоновщик:
Теперь у нас есть файл hello, который содержит программу, вполне готовую к использованию.
Запускаем приложение на своем девайсе
Для того чтобы избежать проблем при запуске приложения, в примере был использован adb, что позволило скопировать его во временную папку нашего устройства Android. После этого пускаем в ход adb shell для того, чтобы запустить приложение и оценить результат:
adb push hello /data/local/tmp/hello
adb shell chmod +x /data/local/tmp/hello
И, наконец, запускаем приложение:
adb shell /data/local/tmp/hello
А что напишете вы?
Сейчас у вас есть рабочее окружение, похожее на то, которое было у Ариэллы. Она потратила на изучение ARM-ассемблера несколько дней, придумав затем несложный проект — это игра Sven Boom (разновидность Fizz Buzz родом из Израиля). Игроки считают по очереди и каждый раз, когда число делится на 7 или содержит число 7, они должны сказать «бум» (отсюда и название игры).
Стоит отметить, что программа по игре — не такая уж и простая задача. Ариэлла написала целый метод, который выводит на экран числа, по одной цифре за раз. Поскольку она писала все на ассемблере без вызова стандартных функций библиотеки С, на решение пришлось потратить несколько дней.
Первая программа Ариэллы для Adnroid размещена вот здесь. Кстати, некоторые идентификаторы кода в приложении — на самом деле еврейские слова (например, _sifra_ahrona).
Написание Android-приложения на ассемблере — хороший способ познакомиться поближе с архитектурой ARM, а также лучше понять внутреннюю кухню гаджета, используемого вами ежедневно. Я предлагаю вам заняться этим вплотную и попробовать создать небольшое приложение на ассемблере для вашего устройства. Это может быть простая игра или что-нибудь еще.
Hello world на ассемблере для DOS (COM,EXE) и WINDOWS
.386
model tiny ;Указание модели памяти
Code segment use16 ;Начало описания сегмента кода
ASSUME cs:Code, ds:Code ;Ассоциация регистров с сегментом
org 100h ;Генерация смещения на 256 байт
start: ;Метка начала программы
push cs ;Запись регистра CS в стек
pop ds ;Загрузка регистра DS значением из стека
mov dx, offset mess ;Помещение в DS смещения строки mess
mov ah, 09h ;Запись в AH номера функции вывода строки
int 21h ;Вызов сервиса MS-DOS
int 20h ;Завершение COM программы в MS-DOS
mess db ‘Hello world!’,’$‘ ;Объявление строки
Code ends ;Завершение описания строки
end start
.386
model small ;Указание модели памяти
Stack SEGMENT STACK use16 ;Объявление сегмента стека
ASSUME ss:Stack ;Ассоциация регистра SS с сегментом стека
DB 100h dup(?) ;Резервирование 256 байт под стек
Stack ENDS ;Завершение описания сегмента стека
Data SEGMENT use16 ;Объявление сегмента данных
ASSUME ds:Data ;Ассоциирование регистра DS с сегментом данных
mess db ‘Hello world!’,’$‘ ;Объявление строки
Data ENDS ;Завершение описания сегмента данных
Code SEGMENT use16 ;Объявление сегмента кода
ASSUME cs:Code ; Ассоциирование регистра CS с сегментом кода
start: ;Метка начала программы
mov ax, seg mess ;Загрузка в AX адреса сегмента строки mess
mov ds, ax ;Запись в DS значения AX
mov dx, offset mess ;Запись в DX смещения строки mess
mov ah, 09h ;Запись в AH номера функции вывода строки
int 21h ;Вызов сервиса MS-DOS
mov ax, 4c00h ;Запись в AX функции завершения программы
int 21h ;Завершение EXE программы в MS-DOS
Code ENDS ;Завершение описания сегмента данных
end start
.code ;Объявление сегмента кода
start: ;Метка начала программы
call main ;вызов процедуры main
inkey ;вызов макроса ожидания нажатия клавиши
exit ;вызов макроса завершения программы
main proc ;объявление процедуры main
cls ;вызов макроса очистки экрана
print «Hello World!»,13,10 ;вызов макроса вывода сообщения
ret ;команда выхода из процедуры
main endp ;конец описания процедуры
end start
MS-DOS и TASM 2.0. Часть 4. Анализ кода.
Анализ программного кода «Hello World!» на ассемблере.MS-DOS и TASM 2.0
Проведем анализ программного кода нашей первой программы на ассемблере.
Вид строки программного кода.
Программа на языке ассемблера состоит из строк, имеющих следующий вид:
метка команда(директива) операнды ;комментарий
Любое из этих полей необязательно.
Во втором поле, поле команды, может располагаться команда процессора, которая транслируется в исполняемый код, или директива, которая не приводит к появлению нового кода, а управляет работой самого ассемблера.
В поле операндов располагаются требуемые командой или директивой операнды (то есть нельзя указать операнды и не указать команду или директиву).
В поле комментариев, начало которого отмечается символом ; (точка с запятой), можно написать все что угодно — текст от символа «;»
до конца строки не анализируется ассемблером.
Метки и директивы.
Данные.
В этом же сегменте кода, разместились и наши данные:
Между метками start и message мы видим исполняемый код программы. Он состоит из ассемблерных команд. Продолжим анализ программного кода и разберём эти команды.
Прерывание DOS — это сигнал процессору немедленно осуществить определённые действия. Прерывания бывают аппаратными и программными. Программное прерывание — это практически функция, имеющая определённые параметры (обработчик прерывания). После выполнения функции обработчика прерывания, код выполняется далее.
Фактически мы видим вызов системной функции с параметрами. Что такое системная функция? Системная функция — это часть системы — код, встроенный в систему. Манипулируя с системными функциями можно упростить написание собственного кода. В этом есть один минус — код получается системозависимый. Но в реальности большинство программ пишутся под конкретные операционки, поэтому этот «минус» можно не принимать во внимание.
Анализ программного кода нашей первой программы закончен. Скачать TASM и всё необходимое для урока, включая исходники, можно здесь. В следующей статье мы рассмотрим очень важные ключевые понятия, которые необходимы для написания программ на ассемблере.
Добавить комментарий Отменить ответ
Для отправки комментария вам необходимо авторизоваться.
Assembler. Установка интерпретатора и запуск первой программы через DOSBox
В данной статье разбирается способ установки интерпретатора и запуск файла EXE через DOSBox. Планировалось погрузить читателя в особенности программирования на TASM, но я согласился с комментаторами. Есть много учебников по Ассемблер и нет смысла перепечатывать эти знания вновь. Лично мне в изучении очень помог сайт av-assembler.ru. Рекомендую. В комментариях также вы найдёте много другой литературы по Assembler. А теперь перейдём к основной теме статьи.
Для начала давайте установим наш старенький интерпретатор.
Ссылка
Почему именно vk.com?
Я прекрасно понимаю, что это ещё тот колхоз делиться файлами через обсуждения VK, но кто знает, во что может превратиться эта маленькая группа в будущем.
После распаковки файлов, советую сохранить их в папке Asm на диск C, чтобы иметь меньше расхождений с представленным тут материалом. Если вы разместите директорию в другое место, изменится лишь путь до файлов, когда вы будете использовать команду mount.
Для запуска интерпретатора нам так же потребуется эмулятор DOSBox. Он и оживит все наши компоненты. Скачаем и установим его!
Ссылка
В папке Asm я специально оставил файл code.asm. Именно на нём мы и потренируемся запускать нашу программу. Советую сохранить его копию, ибо там хранится весь код, который в 99% случаев будет присутствовать в каждом вашем проекте.
Итак. Запускаем наш DOSBox и видим следующее:
Для простоты сопоставим имя пути, по которому лежит наша папка Asm. Чтобы это сделать, пропишем следующую команду:
Здесь вместо d: мы можем использовать любую другую букву. Например назвать i или s. А C это наш реальный диск. Мы прописываем путь до наших файлов ассемблера.
Теперь, откроем смонтированный диск:
Прописав команду dir, мы сможем увидеть все файлы, которые там хранятся. Здесь можно заметить и наш файл CODE с расширением ASM, а также дату его создания.
И только теперь мы начинаем запускать наш файл! Бедные программисты 20 века, как они только терпели всё это? Пропишем следующую команду:
После мы увидим следующее сообщение, а наша директория пополнится новым файлом с расширением OBJ.
Теперь пропишем ещё одну команду:
В нашей папке появилась ещё пара файлов – CODE.MAP и CODE.EXE. Последний как раз и есть исполняемый файл нашего кода assembler.
Если он появился, значит, мы можем запустить режим отладки нашей программы, введя команду последнюю команду. Обратите внимание, теперь мы не указываем расширение файла, который запускаем.
Этот старинный интерфейс насквозь пропитан духом ушедшей эпохи старых операционных систем. Тем не менее…
Нажав F7 или fn + F7 вы сможете совершить 1 шаг по коду. Синяя строка начнёт движение вниз, изменяя значения регистров и флагов. Пока это всего лишь шаблон, на котором мы потренировались запускать нашу программу в режиме дебага. Реальное “волшебство” мы увидим лишь с полноценным кодом на asm.
Небольшой пример для запуска
Прога проверяет, было ли передано верное число открывающих и закрывающих скобок:
Давайте ознакомимся с имеющимися разделами.
Code segment – место, где turbo debug отражает все найденные строки кода. Важное замечание – все данные отражаются в TD в виде 16-ричной системы. А значит какая-нибудь ‘12’ это на самом деле 18, а реальное 12 это ‘C’. CS аналогичен разделу “Begin end.” на Pascal или функции main.
Data segment, отражает данные, которые TD обнаружил в d_s. Справа мы видим их символьную (char) интерпретацию. В будущем мы сможем увидеть здесь наш “Hello, world”, интерпретируемый компилятором в числа, по таблице ASCII. Хорошей аналогией DS является раздел VAR, как в Pascal. Для простоты можно сказать, что это одно и тоже.
Stack segment – место хранения данных нашего стека.
Регистры
Все эти ax, bx, cx, si, di, ss, cs и т. д. – это наши регистры, которые используются как переменные для хранения данных. Да, это очень грубое упрощение. Переменные из Pascal и регистры Assembler это не одно и тоже, но надеюсь, такая аналогия даёт более чёткую картину. Здесь мы сможем хранить данные о циклах, арифметических операциях, системных прерываниях и т. д.
Флаги
Все эти c, z, s, o, p и т.д. это и есть наши флаги. В них хранится промежуточная информация о том, например, было ли полученное число чётным, произошло ранее переполнение или нет. Они могут хранить результат побитого сдвига. По опыту, могу сказать, на них обращаешь внимание лишь при отладке программы, а не во время штатного исполнения.
Маленькая шпаргалка для заметок:
mount d: c:\asm – создаём виртуальный диск, где корень –папка asm
tasm code.asm – компилируем исходный код
tlink code.obj – создаём исполняемый файл
td code – запускаем debug
F7 – делаем шаг в программе
Буду ждать комментарии от всех, кому интересен Assembler. Чувствую, я где-то мог накосячить в терминологии или обозначении того или иного элемента. Но статья на Habr отличный повод всё повторить.
Пишем Hello World на FASM
Comments 25
Вообще, язык макросов FASM настолько мощный, что на нем можно написать еще один язык, причем не одним способом.
А можно написать макро для определения управляющих структур типа if/while? С вложенностью и генерацией меток?
Есть и «можно сделать» — все же немного разные вещи. Чтобы сделать if, нужно иметь таккую структуру, как стек.
Предлагаем вашему вниманию лучший макроязык над ассемблером — Си
А если серьезно, то я щупал как-то ассемблер, причем именно FASM. И пытался писать на нем Win32-приложение. 20 строчек на один вызов MessageBox… В общем, я быстро пришел к необходимости писать макросы. И спустя полчаса поймал себя на том, что изобретаю Паскаль 🙂 И на этом заглох.
Для программирования на Асме нужен особый склад ума, видимо, противоположный моему. Я, приступая к задаче, первым делом стараюсь собрать ее в как можно более высокоуровневые блоки, продумать уровни абстракции… В язык уровня Си или Асма я могу влезать либо из соображений оптимизации, либо из спортивного интереса.
И пытался писать на нем Win32-приложение. 20 строчек на один вызов MessageBox…
Вот код примера из стандартной поставки:
… что, собственно, возвращает нас к шутке про Си.
Мне с моей чисто «академической» колокольни интересно, для какой надобности нынче пишут именно на Ассемблере, если мнемокод мигом превращает его в более высокоуровневый язык (но с явно «протекающей» абстракцией, мешающей, например, отлаживаться)
Макрокод, видимо его вы имел в виду. Мнемокод и «язык ассемблера» — это синонимы.
Зачем? Есть специализированные задачи, шеллкоды какие-нибудь, например.
Да и разница между С и ассемблером с макросами все же есть. Вы возьмете FASM, используете макросные ifы, вызовы функций итд и в итоге получите крохотный файл в, скажем, бинарном формате. Без линкера, без заголовков.
Я использовал(ю периодически) для разработки своей ОС, как небольшое хобби.
Hello world на ассемблере
Вот две строчки из моего батника (*.bat), который позволяет не «парится» с командной строкой:
Бряк 3. Консоль мы можем использовать как устройство ввода (input device), устройство вывода (output device), устройство для отчета об ошибках (error device). Для того чтобы работать с этим «девайсом», мы должны получить его хэндл при помощи следующей функции:
Хэндл стандартного ввода | -10 |
Хэндл стандартного вывода | -11 |
Хэндл «ошибок» | -12 |
Теперь, когда мы разобрали все параметры, обратите внимание на то, что MSDN’овская очередность параметров не соответствует той очередности, в которой мы записываем их в стек в нашем исходнике. Вернитесь еще раз к Минимальному приложению, п.12 и внимательно прочитайте пункты соглашения stdcall. Теперь понятно?
Бряк 5. Дабы мы успели полюбоваться результатом трудов своих праведных, при помощи функции Sleep вызываем программную задержку в 2 секунды. Думаю, с параметрами вы без труда разберетесь.
Вообще-то, правильный стиль предполагает явное освобождение всех занятых ресурсов по минованию надобности в них, в том числе и хэндлов, несмотря на то что они автоматически закрываются ExitProcess‘ом. Но будем надеяться, что если мы не сделаем это в такой маленькой программулине как наша, ничего страшного не случится. Естественно, «формат цэ» не в счет.
Value | Meaning |
STD_INPUT_HANDLE | Standard input handle |
STD_OUTPUT_HANDLE | Standard output handle |
STD_ERROR_HANDLE | Standard error handle |
В ответ на это ассемблер сам извлечет из windows.inc всю имеющуюся в этом файле информацию и преподнесет ее транслятору на блюдечке с голубой каемочкой.
Как видим, команда разработчиков MASM32 позаботилась не только о простыне прототипов, но и о «независимости» нашего исходника от выбранной кодировки. То есть для того, чтобы «перезаточить» программу под UNICODE, нам вовсе не нужно заменять окончание A на W в имени функции. Достаточно просто приинклюдить другой файл с прототипами и эквивалентами наподобие
и не «париться» с переписыванием исходника.
Надо отметить, в MASM32 подобного «юникодного» инклуда нет, однако вы легко можете сделать его сами.
Знакомимся с программированием на ассемблере x86
Архитектура x86 лежит в сердце процессоров, на которых уже более трех десятилетий работают наши домашние компьютеры и удаленные серверы. Умение читать и писать код на низкоуровневом языке ассемблера – это очень весомый навык. Он позволяет создавать более быстрый код, использовать недоступные в Си возможности машин и выполнять реверс-инжиниринг скомпилированного кода.
Однако начать, как правило, оказывается непросто. Официальная документация Intel содержит более тысячи страниц. Двадцать лет постепенной эволюции с обратной совместимостью сформировали ландшафт, где мы находим конфликтующие принципы проектирования из разных этапов развития, занимающий место неактуальный функционал, наслоения переключателей режимов и исключения из правил для каждого шаблона.
В этом руководстве я помогу вам сформировать устойчивое понимание x86 ISA на основе базовых принципов. Здесь мы сосредоточимся на построении понятной ментальной модели происходящего, не акцентируясь на деталях (что заняло бы много времени и оказалось скучным чтивом). Если вы хотите в итоге применять эти знания, то лучше будет заиметь под рукой список инструкций ЦПУ, а также параллельно изучать какое-нибудь другое руководство, которое научит писать и компилировать простые функции. В отличие от другой документации, которая обычно вываливает на вас всю информацию скопом, свой урок я начну с уже знакомой всем территории и буду постепенно повышать его сложность.
Для понимания содержания статьи вам потребуется навык работы с двоичными числами, некоторый опыт в программировании на императивном языке (С/С++/Java/Python/ и т.д.), а также понимание принципа работы указателей памяти (С/С++). При этом знать внутреннее устройство ЦПУ или иметь опыт работы с ассемблером не обязательно.
Содержание
1. Инструменты и тестирование
Параллельно с чтением будет полезно также писать и тестировать ваши собственные программы ассемблера. Проще всего это делать под Linux (менее удобно под Windows). Вот образец функции на ассемблере:
Имейте ввиду, что в моем руководстве используется синтаксис AT&T, а не Intel. Отличаются они только нотацией, внутренние же принципы работы остаются одинаковыми. При этом всегда можно механически перевести программу из одного синтаксиса в другой, так что беспокоиться особо не о чем.
2. Базовая среда выполнения
В ЦПУ х86 есть восемь 32-битных универсальных регистров. По историческим причинам они имеют следующие названия:
3. Базовые арифметические инструкции
Этой важной схеме следуют многие инструкции, например:
Многие арифметические инструкции могут получать в качестве первого операнда непосредственное значение. Это значение является фиксированным (не переменным) и кодируется в саму инструкцию.
Сейчас будет кстати разобрать один из принципов программирования на ассемблере: «Не каждая желаемая операция может быть непосредственно выражена в одной инструкции. В типичных языках программирования многие конструкции являются компонуемыми и подстраиваются под разные ситуации, а арифметика может быть вложенной. Тем не менее в ассемблере можно прописать только то, что позволяет набор инструкций. Покажу на примерах:
Из всего этого следует, что вам не нужно стараться угадывать или изобретать несуществующие синтаксические конструкции (такие как addl %eax, %ebx, %ecx ). Также, если вам не удается найти необходимую инструкцию в огромном списке поддерживаемых, тогда нужно реализовать ее вручную как последовательность имеющихся инструкций (и, возможно, выделить регистры для хранения промежуточных значений).
4. Регистр флагов и операции сравнения
5. Работа с памятью
Одного только процессора для эффективной работы компьютера будет недостаточно. Наличие всего 8 регистров данных сильно ограничивает объем вычислений ввиду невозможности хранения большого количества информации. Для увеличения вычислительного потенциала процессора у нас есть ОЗУ, представляющее обширную системную память. По сути, ОЗУ представляет собой огромный массив байт – например, 128МиБ ОЗУ – это 134,217,728 байт, в которых можно хранить значения.
Очевидно, что у процессора есть инструкции для считывания и записи значений в память. В частности, можно загружать или сохранять один или более байтов в любой желаемый адрес памяти. Самым простым действием в этом случае будет считывание или запись одного байта:
Режимы адресации
Когда мы пишем код с циклами, то нередко один регистр содержит базовый адрес массива, а другой текущий обрабатываемый индекс. Несмотря на то, что адрес обрабатываемого элемента можно вычислить вручную, x86 ISA предоставляет более элегантное решение – у нас есть режимы адресации памяти, которые позволяют складывать и перемножать содержимое определенных регистров.
Это будет проще показать, чем объяснять:
6. Переходы, метки и машинный код
Каждую инструкцию в ассемблере можно предварить нужным числом меток. Эти метки пригодятся, когда потребуется перейти к определенной инструкции. Вот несколько примеров:
Инструкция jmp говорит процессору перейти к выполнению размеченной инструкции, а не следующей ниже по порядку, как это происходит по умолчанию. Вот простой бесконечный цикл:
Несмотря на то, что jmp условия не имеет, у нее есть родственные инструкции, которые смотрят на состояние eflags и переходят либо к метке (при выполнении условия), либо к очередной инструкции ниже. К инструкциям с условным переходом относятся: ja (перейти, если больше), jle (перейти, если меньше либо равно), jo (перейти, если переполнение), jnz (перейти, если не нуль) и так далее.
Всего таких инструкций 16, и у некоторых есть синонимы – например jz (перейти, если нуль) равнозначна je (перейти, если равно), ja (перейти, если больше) равнозначна jnbe (перейти, если не меньше или равно).
Вот пример использования условного перехода:
Пока мы говорим о машинном коде, стоит добавить, что ассемблер на деле не является самым нижним уровнем, до которого может добраться программист. Самым фундаментом выступает сырой двоичный машинный код. (Инсайдеры Intel имеют доступ к еще более низким уровням, таким как отладка пайплайна и микрокод – но обычным программистам туда не попасть). Писать машинный код вручную – задача не из легких (да и вообще, писать на ассемблере уже непросто), но это дает пару выгодных возможностей. При написании машинного кода можно кодировать некоторые инструкции альтернативными способами (например, использовать удлиненную последовательность байт, которая будет иметь тот же эффект при выполнении), а также намеренно генерировать недействительные инструкции для проверки поведения ЦПУ (не все ЦПУ обрабатывают ошибки одинаково).
7. Стек
8. Соглашение о вызовах
Когда мы компилируем код Си, он переводится в код ассемблера и в последствии в машинный код. Соглашение о вызовах определяет то, как функции Си получают аргументы и возвращают значения, помещая значения в стек и/или в регистры. Это соглашение применяется к функции Си, вызывающей другую функцию Си, фрагменту кода ассемблера, вызывающему функцию Си, либо функции Си, вызывающей функцию ассемблера. (Оно не применяется к фрагменту кода ассемблера, вызывающему произвольный фрагмент кода ассемблера; в этом случае ограничения отсутствуют).
9. Повторяемые строковые инструкции
Строковую инструкцию можно изменить с помощью приставки rep (сюда же относятся repe и repne ), чтобы она выполнялась ecx раз (при автоматическом уменьшении ecx ). К примеру, rep movsb %esi, %edi означает:
Эти строковые инструкции и приставки rep привносят в ассемблер некоторые итерируемые составные операции. Они отражают часть парадигмы дизайна CISC, где для программистов считается нормальным писать код прямо на ассемблере, и предоставляют более высокоуровневые возможности для упрощения работы. (Однако современным решением считается писать код на Си или даже более высокоуровневом языке, а генерацию муторного кода ассемблера поручать компилятору).
10. Плавающая точка и SIMD
Математический сопроцессор x87 имеет восемь 80-битных регистров с плавающей точкой (но вся функциональность x87 сейчас уже встроена в основной ЦПУ x86), и у процессора x86 также есть восемь 128-битных регистров xmm для инструкций SSE. У меня мало опыта работы с FP/x87, так что по этой теме вам стоит обратиться к другим руководствам. Стек в x87 FP работает несколько странным образом, и сегодня удобнее выполнять арифметику с плавающей точкой, используя вместо этого регистры xmm и инструкции SSE/SSE2.
Отмечу, что все операции SSE/SIMD можно эмулировать с меньшей скоростью, используя базовые скалярные операции (например, 32-битную арифметику, рассмотренную в разделе 3). Осторожный программист может создать прототип программы с использованием скалярных операций, оценить ее корректность и постепенно преобразовать под использование более скоростных инструкций SSE, обеспечив получение тех же результатов.
11. Виртуальная память
До этого момента мы предполагали, что когда инструкция запрашивает считывание из/запись в адрес памяти, то это будет адрес, обрабатываемый ОЗУ. Но, если мы добавим в промежутке переводящий слой, то сможем выполнять интересные действия. Этот принцип известен как виртуальная память, пейджинг и под другими именами.
Основная идея в том, что у нас есть таблица страниц, которая описывает, с чем сопоставлена каждая страница (блок) из 4096 байтов 32-битного виртуального адресного пространства. Например, если страница ни с чем не сопоставлена, то попытка считать/записать адрес памяти на эту страницу вызовет прерывание/исключение/ловушку. Либо, к примеру, тот же виртуальный адрес 0x08000000 можно сопоставить с другой страницей физической ОЗУ в каждом запущенном процессе приложения. Кроме того, каждый процесс может иметь собственный набор страниц и никогда не видеть содержимое других процессов или ядра операционной системы. Принцип пейджинга, по большому счету, относится к сфере написания ОС, но его поведение иногда затрагивает и разработчиков приложений, поэтому им стоит о нем знать.
Имейте ввиду, что отображение адресов не обязательно должно происходить по схеме 32 бита в 32 бита. Например, 32 бита виртуального адресного пространства можно сопоставить с 36 битами области физической памяти (PAE). Либо 64-битное виртуальное адресное пространство можно сопоставить с 32 битами области физической памяти на компьютере, имеющем всего 1ГиБ ОЗУ.
12. 64-битный режим
Здесь я только немного расскажу о режиме x86-64 и примерно обрисую, какие изменения он собой привнес. При желании в сети можно найти множество статей и справочных материалов, которые поясняют все отличия детально.
Арифметические инструкции могут оперировать с 8-, 16-, 32- или 64-битными регистрами. При работе с 32-битными верхние 32 бита очищаются на нуль, но при меньшей ширине операнда все старшие биты остаются неизменными. Многие нишевые инструкции из 64-битного набора были удалены – например, связанные с BCD, большинство инструкций, задействующих 16-битные сегментные регистры, а также добавляющие/извлекающие 32-битные значения из стека.
Не так уж много отличий x86-64 от старой x86-32 касаются конкретно разработчиков приложений. Если говорить в общем, то работать стало легче ввиду доступности большего числа регистров и удаления ненужного функционала. Все указатели памяти должны быть 64-битными (к этому нужно привыкать) в то время, как значения данных могут быть 32-, 64-, 8-битными и так далее, в зависимости от ситуации (не обязательно использовать для данных именно 64 бита).
Рассмотренное соглашение о вызовах существенно упрощает извлечение аргументов функций в коде ассемблера, потому что первые
6 аргументов помещаются не в стек, а в регистры. В остальном принцип работы остался прежним. (Хотя для программистов систем архитектура x86-64 представляет новые режимы, возможности, новые проблемы и новые кейсы для обработки).
13. Сравнение с другими архитектурами
Принцип работы архитектур ЦПУ RISC в некоторых аспектах отличен от x86. Память затрагивают только явные инструкции загрузки/сохранения, обычные арифметические этого не делают. Инструкции имеют фиксированную длину, а именно 2 или 4 байта каждая. Операции с памятью обычно нужно объединять, например загрузка 4-байтового слова должна содержать адрес памяти, кратный 4.
Для сравнения, в x86 ISA операции с памятью встраиваются в арифметические инструкции, инструкции кодируются как последовательности байтов переменной длины, и почти всегда допускается невыравненное обращение к памяти. Кроме того, если в x86 есть полный набор 8-, 16- и 32-битных арифметических операций ввиду обратной совместимости, то архитектуры RISC обычно являются просто 32-битными. Для работы с более короткими значениями они загружают байт или слово из памяти, расширяют его значение на полный 32-битный регистр, выполняют арифметические операции в 32 битах и в завершении сохраняют нижние 8 или 16 бит в памяти. К популярным RISC ISA относятся ARM, MIPS и RISC-V.
Архитектуры VLIW позволяют явно выполнять несколько параллельных подинструкций. К примеру, можно написать add a, b; sub c, d на одной строке, потому что у процессора есть два независимых арифметических блока, работающих одновременно. Процессоры x86 тоже могут выполнять несколько инструкций параллельно (суперскалярная обработка), но инструкции в этом случае не прописываются явно – ЦПУ внутренне анализирует параллелизм в потоке инструкций и распределяет допустимые инструкции по нескольким блокам выполнения.
14. Обобщение
Разбор архитектуры процессоров x86 мы начали с их рассмотрения как простой машины, которая содержит пару регистров и последовательно следует списку инструкций. Мы познакомились с базовыми арифметическими операциями, которые можно выполнять на этих регистрах. Далее мы узнали о переходе к различным участкам кода, о сравнении и условных переходах. После мы разобрали принцип работы ОЗУ как огромного адресуемого хранилища данных, а также поняли, как можно использовать режимы адресации x86 для лаконичного вычисления адресов. В завершении мы кратко рассмотрели принцип работы стека, соглашение о вызовах, продвинутые инструкции, перевод адресов виртуальной памяти и отличия режима x86-64.
Надеюсь, этого руководства было достаточно, чтобы вы сориентировались в общем принципе устройства архитектуры x86. В эту ознакомительную статью мне не удалось вместить очень много деталей – полноценное написание простой функции, отладку распространенных ошибок, эффективное использование SSE/AVX, работу с сегментацией, знакомство с системными структурами данных вроде таблиц страниц и дескрипторов прерываний, да и многое другое. Тем не менее теперь у вас есть устойчивое представление о работе процессора x86, и вы можете приступить к изучению более продвинутых уроков, попробовать написать код с пониманием происходящего внутри и даже решиться полистать чрезвычайно подробные руководства Intel по ЦПУ.
«Hello World!» на C массивом int main[]
Я хотел бы рассказать о том, как я писал реализацию «Hello, World!» на C. Для подогрева сразу покажу код. Кого интересует как до этого доходил я, добро пожаловать под кат.
Предисловие
Итак, начал я с того, что нашел эту статью. Вдохновившись ею, я стал думать, как сделать это на windows.
В той статье вывод на экран был реализован с помощью syscall, но в windows мы сможем использовать только функцию printf. Возможно я ошибаюсь, но ничего иного я так и не нашел.
Набравшись смелости и взяв в руки visual studio я стал пробовать. Не знаю, зачем я так долго возился с тем, чтобы подставлять entry point в настройках компиляции, но как выяснилось позже компилятор visual studio даже не кидает warning если main является массивом, а не функцией.
Основной список проблем, с которыми мне пришлось столкнуться:
Поясню чем тут плох вызов функции. Обычно адрес вызова подставляется компилятором из таблицы символов, если я не ошибаюсь. Но у нас ведь обычный массив, где мы сами должны написать адрес.
Решение проблемы «исполняемых данных»
Первая проблема, с которой я столкнулся, ожидаемо оказалось то, что простой массив хранится в секции данных и не может быть исполнен, как код. Но немного покопав stackoverflow и msdn я все же нашел выход. Компилятор visual studio поддерживает препроцессорную директиву section и можно объявить переменную так, чтобы она оказалась в секции с разрешением на исполнение.
Проверив, так ли это, я убедился, что это работает и функция массив main спокойно исполняет opcode ret и не вызывает ошибки «Access violation».
Немного ассемблера
Теперь, когда я мог исполнять массив нужно было составить код который будет выполняться.
Далее нам нужно разделить по 4 байта и положить на стек в обратном порядке, не забыв перевернуть в little-endian.
Добавим в конец терминальный ноль.
Делим с конца на 4 байтные hex числа.
Переворачиваем в little-endian и меняем порядок на обратный
Я немного опустил момент с тем, как я пытался напрямую вызывать printf и чтобы сохранить потом этот адрес в массиве. Получилось у меня только сохранив указатель на printf. Позже будет видно почему так.
Компилируем и смотрим дизассемблер.
Отсюда нам нужно взять байты кода.
Регулярное выражение для последовательности после байтов кода:
Начало строк можно убрать с помощью плагина для notepad++ TextFx:
TextFX->«TextFx Tools»->«Delete Line Numbers or First Word», выделив все строки.
После чего у нас уже будет почти готовая последовательность кода для массива.
Вызов функции с «заранее известным» адресом
Я долго думал, как же можно оставить в готовой последовательности адрес из таблицы функций, если это знает только компилятор. И немного поспрашивав у знакомых программистов и поэкспериментировав я понял, что адрес вызываемой функции можно получить с помощью операции взятия адреса от переменной указателя на функцию. Что я и сделал.
Как видно в указателе лежит именно тот самый вызываемый адрес. То, что нужно.
Собираем все вместе
Итак, у нас есть последовательность байт ассемблерного кода, среди которых нам нужно оставить выражение, которое компилятор преобразует в адрес, нужный нам для вызова printf. Адрес у нас 4 байтный(т.к. пишем для код для 32 разрядной платформы), значит и массив должен содержать 4 байтные значения, причем так, чтобы после байт FF 15 у нас шел следующий элемент, куда мы и будем помещать наш адрес.
Берем полученную ранее последовательность байт нашего ассемблерного кода. Отталкиваясь от того, что 4 байта после FF 15 у нас должны составлять одно значение форматируем под них. А недостающие байты заменим на операцию nop с кодом 0x90.
И опять составим 4 байтные значения в little-endian. Для переноса столбцов очень полезно использовать многострочное выделение в notepad++ с комбинацией alt+shift:
Теперь у нас есть последовательность 4 байтных чисел и адрес для вызова функции printf и мы можем наконец заполнить наш массив main.
Для того чтобы вызывать break point в дебаггере visual studio надо заменить первый элемент массива на 0x646C68CC
Запускаем, смотрим.
Заключение
Я извиняюсь если кому-то статья показалась «для самых маленьких». Я постарался максимально подробно описать сам процесс и опустить очевидные вещи. Хотел поделиться собственным опытом такого небольшого исследования. Буду рад если статья окажется кому-то интересной, а возможно и полезной.
Оставлю тут все приведенные ссылки:
Также не исключаю, что можно было ещё сократить вызов printf и использовать другой код вызова функции, но я не успел исследовать этот вопрос.
Как написать hello world на ассемблере под Windows?
Хотел написать что-нибудь базовое в сборке под Windows, использую NASM, но ничего не получается.
Как написать и скомпилировать hello world без помощи функций C в Windows?
В этом примере показано, как перейти непосредственно к Windows API, а не ссылаться на стандартную библиотеку C.
Для компиляции вам понадобятся NASM и LINK.EXE (из Visual Studio Standard Edition).
Также существует The Clueless Newbies Guide to Hello World в Nasm без использования библиотеки C. Тогда код будет выглядеть так.
Это примеры Win32 и Win64 с использованием вызовов Windows API. Они предназначены для MASM, а не для NASM, но взгляните на них. Вы можете найти более подробную информацию в этой статье.
При этом используется MessageBox вместо вывода на стандартный вывод.
Win32 MASM
Win64 MASM
Чтобы собрать и связать их с помощью MASM, используйте это для 32-битного исполняемого файла:
или это для 64-битного исполняемого файла:
Используя invoke директиву MASM (которая знает соглашение о вызовах), вы можете использовать один ifdef, чтобы создать его версию, которая может быть 32-битной или 64-битной.
Вы можете разобрать вывод, чтобы посмотреть, насколько он invoke расширен.
Flat Assembler не требует дополнительного компоновщика. Это упрощает программирование на ассемблере. Он также доступен для Linux.
Это hello.asm из примеров Fasm:
Fasm создает исполняемый файл:
Если этот код сохранен, например, в «test64.asm», то для компиляции:
Производит «test64.obj» Затем для ссылки из командной строки:
где path_to_link может быть C: \ Program Files (x86) \ Microsoft Visual Studio 10.0 \ VC \ bin или где бы ни была ваша программа link.exe на вашем компьютере, path_to_libs может быть C: \ Program Files (x86) \ Windows Kits \ 8.1 \ Lib \ winv6.3 \ um \ x64 или где бы то ни было, где находятся ваши библиотеки (в этом случае и kernel32.lib, и user32.lib находятся в одном месте, в противном случае используйте один параметр для каждого нужного пути) и / largeaddressaware: no option is необходимо, чтобы линкер не жаловался на адреса слишком долго (в данном случае для user32.lib). Кроме того, как это сделано здесь, если компоновщик Visual вызывается из командной строки, необходимо предварительно настроить среду (запустить один раз vcvarsall.bat и / или просмотреть MS C ++ 2010 и mspdb100.dll ).
Если вы не вызовете какую-либо функцию, это совсем не тривиально. (И, серьезно, нет реальной разницы в сложности между вызовом printf и вызовом функции api win32.)
Даже DOS int 21h на самом деле просто вызов функции, даже если это другой API.
Если вы хотите сделать это без посторонней помощи, вам нужно напрямую поговорить с вашим видеооборудованием, вероятно, записывая растровые изображения букв «Hello world» во фреймбуфер. Даже тогда видеокарта выполняет работу по преобразованию этих значений памяти в сигналы DisplayPort / HDMI / DVI / VGA.
Обратите внимание, что на самом деле ничто из этого, вплоть до аппаратного обеспечения, в ASM не интереснее, чем в C. Программа «hello world» сводится к вызову функции. В ASM есть одна приятная особенность: вы можете довольно легко использовать любой ABI, какой захотите; вам просто нужно знать, что это за ABI.
Если вы хотите использовать NASM и компоновщик Visual Studio (link.exe) с примером Hello World от anderstornvig, вам придется вручную установить связь с библиотекой времени выполнения C, содержащей функцию printf ().
Надеюсь, это кому-то поможет.
Если вам нужна консольная программа, которая позволяет перенаправлять стандартный вход и стандартный выход, что также возможно. Существует (очень нетривиальный) пример программы, которая не использует графический интерфейс и работает строго с консолью, то есть с самим fasm. Это можно проредить до самого необходимого. (Я написал четвертый компилятор, который является еще одним примером, не связанным с графическим интерфейсом, но он также нетривиален).
В такой программе есть следующая команда для генерации правильного заголовка для 32-битного исполняемого файла, обычно выполняемая компоновщиком.
Раздел под названием ‘.idata’ содержит таблицу, которая помогает окнам во время запуска связывать имена функций с адресами среды выполнения. Он также содержит ссылку на KERNEL.DLL, которая является операционной системой Windows.
Ваша программа находится в разделе ‘.text’. Если вы объявляете этот раздел доступным для чтения, записываемым и исполняемым, это единственный раздел, который вам нужно добавить.
В итоге: есть таблица с именами asci, которые связаны с ОС Windows. Во время запуска она преобразуется в таблицу вызываемых адресов, которую вы используете в своей программе.
Создание игр для NES на ассемблере 6502: оборудование NES и знакомство с ассемблером
Оглавление
Часть I: подготовка
Часть II: графика
4. Оборудование NES
Консоль
Если открыть консоль NES и посмотреть внутрь, то можно увидеть нечто подобное:
Материнская плата NTSC-версии NES (США/Япония). Фото Эвана Эмоса.
Внешне на материнской NES выделяются разъём для картриджей в верхней части и два больших чипа. Слева находится вот такой чип с маркировкой «RP2A03»:
Центральный процессор/аудиопроцессор Ricoh 2A03
А справа расположен другой чип с маркировкой «RP2C02»:
Вместе эти два чипа обеспечивают всю вычислительную мощь NES. Первый чип (2A03) — это центральный процессор (CPU, central processing unit) консоли NES. 2A03 создан на основе процессора 6502 компании MOS Technologies и дополнен его производителем Ricoh несколькими особыми возможностями.
[Когда я говорю, что 2A03 «создан на основе» 6502, то подразумеваю, что между ними есть только одно важное отличие: в 2A03 отсутствует поддержка функции, которая в 6502 называется режимом «binary coded decimal» (BCD). BCD позволяет двоичным числам, с которыми работает центральный процессор, вести себя при сложении или вычитании как десятичные числа. Электронная схема BCD в процессоре 6502 находилась в сфере действия отдельного лицензионного соглашения, стороной которого не была компания Ricoh, поэтому она не могла легально включить режим BCD в свои процессоры, несмотря вся схема работы этого режима была реализована в кремнии. Чтобы обойти эту проблему, Ricoh изготовила «полный» 6502, но перерезала все электрические соединения между участком BCD чипа и остальной частью процессора.]
Также Ricoh включила в 2A03 полный аудиопроцессор (APU, audio processing unit), обрабатывающий музыку и звуковые эффекты.
Второй чип (2C02) — это PPU консоли NES (picture processing unit, «устройство обработки изображений»). Сегодня его можно воспринимать как «графическую карту». PPU получает команды от CPU и преобразует их в выводимую на экран информацию. CPU ничего не знает о том, как работают телевизоры; всю эту обработку он оставляет на долю PPU.
Картриджи
Игры для NES распространяются на пластмассовых картриджах (или Game Pak’ах, как называла их Nintendo в США). Внутри каждого картриджа находится небольшая печатная плата, выглядящая примерно так:
Печатная плата картриджа игры Tetris. Фото Эвана Эмоса.
Как и на материнской плате консоли, на печатной плате картриджа выделяются два больших чипа. Левый имеет маркировку «PRG», а правый — «CHR». PRG-ROM — это «ROM программы», он содержит весь код игры (машинный код). CHR-ROM — это «ROM символов», содержащий все графические данные игры.
[На этой плате есть два дополнительных чипа. Справа от двух основных ROM находится CIC (Checking Integrated Circuit), или чип «блокировки». Это защита, при помощи которой Nintendo пыталась сделать так, чтобы на NES запускались только те картриджи, которые произведены компанией. (Также чип CIC является причиной того, что игры для NES часто многократно мерцают, пока не вытащишь их из разъёма и не вставишь заново, и лучше не подув при этом на контакты.) Под двумя чипами ROM находится «MMC1» — контроллер управления памятью, который мы подробно рассмотрим в других главах книги.]
Контактны этих двух чипов ROM соединены с группой золотых контактов на плате картриджа. Когда картридж вставляют в разъём картриджа на консоли, золотые контакты образуют электрическое соединение с аналогичной группой контактов в консоли. В результате этого PRG-ROM электрически соединяется напрямую с процессором 2A03, а CHR-ROM напрямую соединяется с PPU 2C02.
Схема соединения чипов ROM картриджа с процессорами консоли.
Как это связано с нашим тестовым проектом?
Два сегмента не являются непосредственно частями кода самой игры. Сегмент STARTUP на самом деле ничего не делает; он необходим для кода на C, компилируемого в ассемблерный код 6502, но мы его не используем. Сегмент HEADER содержит информацию для эмуляторов о том, какие чипы есть в картридже.
Остальные сегменты относятся к разделению PRG/CHR. CODE — это, разумеется, код, сохраняемый в PRG-ROM. VECTORS — это способ задания кода, который должен находиться в самом конце блока PRG-ROM (о причинах этого мы поговорим позже). А CHARS обозначает всё содержимое CHR-ROM, обычно включаемое в виде двоичного файла.
Каким же будет содержимое чипа CHR-ROM, а значит, какую графику будет отображать игра?
.res — это ещё одна ассемблерная директива, приказывающая ассемблеру «зарезервировать» определённый объём пустого пространства; в данном случае это 8192 байта. Но если весь CHR-ROM пуст, то откуда берётся зелёный фон в нашем тестовом проекте?
Цвета и палитры
Когда выit я сказал, что в чипе CHR-ROM содержится «вся» графика игры, я немного упростил. Если точнее, чип CHR-ROM содержит паттерны различных цветов, отображаемых в игре. Однако сами цвета являются частью PPU. Устройство PPU знает, как отображать фиксированный набор из 64 цветов.
[Внимательные читатели могут заметить, что на изображении с цветовой палитрой есть только 56 цветов, а не 64. Так получилось потому, что восемь из 64 цветов, известных PPU — это просто чёрный цвет. Это вызвано особенностями отображения цвета ЭЛТ-телевизорами NTSC, а не ошибка в проектировании оборудования.
Палитра цветов NES.
Из-за аппаратных ограничений мы не можем использовать все 64 цвета одновременно. Мы назначаем цвета восьми палитрам системы из четырёх цветов. Четыре таких палитры используются для «фона», а остальные четыре — для «переднего плана».
Восемь палитр имеют одно дополнительное ограничение: первый цвет каждой из этих палитр должен быть одинаковым. Этот первый цвет используется как «стандартный» цвет фона (когда в текущем пикселе на фоне ничего не отрисовывается, используется стандартный цвет), а также как цвет прозрачности для переднего плана (при отрисовке пикселей переднего плана первый цвет считается прозрачным, что позволяет пикселям фона «за» ним просвечивать). Из-за этих ограничений NES одновременно может отображать не более 25 цветов — один «стандартный» цвет и восемь палитр по три цвета каждая.
Восемь палитр, используемых в World 1-1 игры Super Mario Bros. Верхние четыре палитры используются для переднего плана, а четыре нижние — для фона. Обратите внимание, что многие элементы переднего плана и фона составлены из одинаковых паттернов, но разных палитр, например, разноцветные черепахи на переднем плане и графика кустов/облаков на фоне. Первый голубой цвет в каждой палитре — это цвет «неба» фона в World 1-1.
Цветовая палитра NES, на которую наложены байты, которые PPU использует для обозначения каждого цвета.
Возвращаемся к тестовому проекту
5. Знакомство с языком ассемблера 6502
Как вы могли догадаться из названия этой главы, в helloworld.asm содержится ассемблерный код.
Ассемблер находится всего на один уровень абстракции выше машинного кода. Для написания ассемблерного кода требуется подробное знание набора команд процессора, но он гораздо лучше читается, чем поток байтов машинного кода. Давайте рассмотрим пример ассемблерного кода, чтобы понять, как он работает:
Разделение команд и данных
Трёхбуквенные слова в верхнем регистре в строках 2-6 и 9-13 называются опкодами. Каждое из них представляет собой команду из набора команд процессора, однако здесь мы обозначаем их не числами, а по названиям. Например, LDA означает «load accumulator». В этой книге мы изучим несколько десятков опкодов; всего существует 56 «официальных» опкодов ассемблера 6502.
Константы и метки
Комментарии
При помощи символа точки с запятой (;) можно добавлять в ассемблерный код комментарии. Всё, начиная с точки запятой и до конца строки считается комментарием и будет вырезано после обработки кода ассемблером. Комментарии позволяют напоминать себе, что делает конкретный фрагмент кода. В показанном выше примере кода комментарий присутствует в строке 12.
Директивы ассемблера
Наши первые опкоды: перемещение данных
Теперь, когда мы увидели, как выглядит ассемблерный код 6502, давайте приступим к его изучению! Я разбил опкоды, которые вы изучите в этой книге, на семь основных групп. Первая группа состоит из команд, перемещающих данные между регистрами и памятью.
Команды «LD» загружают данные в регистр. Напомню, что в процессоре 6502 есть три регистра, с которыми можно работать: «A», или «accumulator» («накопитель»), позволяющий выполнять математические действия, а также «X» и «Y» — «индексные регистры». LDA загружает данные в накопитель, LDX загружает данные в регистр X, а LDY загружает данные в регистр Y.
Существует два основных источника этих данных: загрузка из адреса памяти и загрузка конкретного значения. Используемый источник зависит от того, как вы запишете операнд для опкода. Если вы используете 16-битное значение (четыре шестнадцатеричных цифры), то опкод загрузит содержимое этого адреса памяти. Если вы используете знак решётки (#), за которым следует 8-битное значение (две шестнадцатеричных цифры), то он загрузит точное значение. Вот пример:
Эти отличающиеся форматы операндов называются режимами адресации (addressing modes). Процессор 6502 может использовать одиннадцать различных режимов адресации (однако большинство опкодов пользуется только их подмножеством).
[На случай, если вам интересно, эти 11 режимов адресации называются Accumulator, Immediate, Implied, Relative, Absolute, Zeropage, Indirect, Absolute Indexed, Zeropage Indexed, Indexed Indirect и Indirect Indexed.]
Два описанных выше режима называются абсолютным (absolute) (передающим адрес памяти) и непосредственным (immediate) (передающим конкретное значение) режимами. По мере необходимости в дальнейшем мы будем узнавать дополнительную информацию о режимах адресации.
Опкоды «ST» выполняют операцию, обратную опкодам «LD» — они сохраняют содержимое регистра в адрес памяти. STA сохраняет содержимое накопителя в место, заданное его операндом, STX сохраняет содержимое регистра X, а STY — содержимое регистра Y. Команды ST не могут использовать непосредственный режим, потому что невозможно сохранить содержимое регистра в число. После выполнения операции сохранения в сохранённом регистре остаётся то же значение, что позволяет сразу же сохранить то же значение регистра в другое место.
Команды «T» передают данные из одного реестра в другой. Все эти регистры читаются как «передать из регистра в регистр»; например, TAX — это команда «передать из накопителя в регистр X». Точнее будет назвать «передачу» в этих командах «копированием», потому что после применения одной из этих команд в обоих регистрах будет находиться значение из первого регистра.
Небольшой пример
Теперь, когда вы изучили свои первые десять опкодов (и два режима адресации), можно рассмотреть небольшой пример, в котором они используются.
Что делает этот код? Давайте разберём его построчно:
Возвращаемся к тестовому проекту
Ввод-вывод с отображением в память
[Если вы хотите подробнее узнать об MMIO-адресах PPU (или о любой другой теме, связанной с NES), то изучите NESDev Wiki. Хоть это и не лучший ресурс для обучения, он является бесценным справочным руководством по системе, в основе которого лежат тщательные исследования его участников.]
В нашем коде используется четыре MMIO-адреса; давайте посмотрим, что делает каждый из них (вместе с именем, под которым известен каждый из адресов).
$2002 : PPUSTATUS
В нашем тестовом проекте мы считываем («загружаем») из PPUSTATUS, а затем пытаемся записать адрес в PPUADDR.
[Этот процесс — считывание из PPUSTATUS, запись двух байтов в PPUADDR и запись байтов в PPUDATA — мы будем использовать постоянно. Всё, что изменяет отображаемое на экране использует этот процесс, чтобы сообщить PPU, что отрисовывать, и буквально всё в игре будет изменять отображаемое на экране. Тщательное изучение этого процесса сильно пригодится в последующих главах.]
$2001 : PPUMASK
№ бита | Действие |
---|---|
0 | Включить режим градаций серого (0: обычный цвет, 1: градации серого) |
1 | Включить фон с левого края (8 пикселей) (0: скрыть, 1: показать) |
2 | Включить передний план с левого края (8 пикселей) (0: hide, 1: show) |
3 | Включить фон |
4 | Включить передний план |
5 | Выделить красный |
6 | Выделить зелёный |
7 | Выделить синий |
Прежде чем переходить к тестовому проекту, сделаем несколько примечаний по этим опциям. Биты 1 и 2 включают или отключают отображение графических элементов самых левых восьми пикселей экрана. В некоторых играх они отключены, чтобы избежать мерцания при скроллинге, о котором мы подробнее узнаем позже. Биты 5, 6 и 7 позволяют коду «выделить» определённые цвета — сделать зелёный, зелёный или синий ярче, а остальные два цвета сделать темнее. Использование одного из битов выделения, по сути, придаёт экрану оттенок цвета. Использование всех трёх одновременно делает весь экран темнее, что многие игры применяют для перехода от одной области к другой.
Давайте снова взглянем на код тестового проекта. Какое значение записывает наш тестовый проект в PPUMASK?
Вот побитовое описание опций, которые мы задаём:
№ бита | Значение | Действие |
---|---|---|
0 | 0 | Режим оттенков серого отключен |
1 | 1 | Отображаются левые 8 пикселей фона |
2 | 1 | Отображаются левые 8 пикселей переднего плана |
3 | 1 | Фон включен |
4 | 1 | Передний план включен |
5 | 0 | Нет выделения красного |
6 | 0 | Нет выделения зелёного |
7 | 0 | Нет выделения синего |
Так как при записи в PPUMASK наш тестовый проект включает рендеринг фона, после этой строки отображается зелёный фон.
Завершаем разбор кода main
Наш тестовый код записал цвет в память PPU и включил рендеринг на дисплей, так что, наверное, всё готово?
Не будем торопиться. Вспомним, что ЦП получает и исполняет команды по одной за раз, непрерывно. Если код записи не будет давать процессору работу, то он продолжит считывать память (пустую) и «исполнять» её, что может привести к катастрофическим результатам. К счастью, у этой проблемы есть простое решение:
Подробнее о том, как это работает, мы узнаем позже, но, по сути, эти две строки кода создают метку ( forever ), а затем приказывают ЦП получать эту метку как следующую инструкцию для исполнения. ЦП входит в бесконечный цикл, и ваш проект не делает ничего, кроме отображения зелёного фона.
Домашняя работа
Теперь, когда вы знаете, как тестовый проект задаёт цвет фона, попробуйте изменить его, чтобы он отображал другой цвет. Ниже представлена таблица цветов из Главы 4. Не забывайте переассемблировать и перекомпоновать свой код перед открытием файла в Nintaco.
Простая программа на ассемблере x86: Решето Эратосфена
Вступительное слово
По своей профессии я не сталкиваюсь с низкоуровневым программированием: занимаюсь программированием на скриптовых языках. Но поскольку душа требует разнообразия, расширения горизонтов знаний или просто понимания, как работает машина на низком уровне, я занимаюсь программированием на языках, отличающихся от тех, с помощью которых зарабатываю деньги – такое у меня хобби.
И вот, я хотел бы поделиться опытом создания простой программы на языке ассемблера для процессоров семейства x86, с разбора которой можно начать свой путь в покорение низин уровней абстракции.
Итак, посмотрим, что получилось.
С чего начать?
Пожалуй, самая сложная вещь, с которой сталкиваешься при переходе от высокоуровневых языков к ассемблеру, это организация памяти. К счастью, на эту тему на Хабре уже была хорошая статья.
Так же встает вопрос, каким образом на таком низком уровне реализуется обмен данными между внутренним миром программы и внешней средой. Тут на сцену выходит API операционной системы. В DOS, как уже было упомянуто, интерфейс был достаточно простой. К примеру, программа «Hello, world» выглядела так:
В Windows же для этих целей используется Win32 API, соответственно, программа должна использовать методы соответствующих библиотек:
Здесь используется файл win32n.inc, где определены макросы, сокращающие код для работы с Win32 API.
Я решил не использовать напрямую API ОС и выбрал путь использования функций из библиотеки Си. Так же это открыло возможность компиляции программы в Linux (и, скорее всего, в других ОС) – не слишком большое и нужное этой программе достижение, но приятное достижение.
Вызов подпрограмм
Потребность вызывать подпрограммы влечет за собой несколько тем для изучения: организация подпрограмм, передача аргументов, создание стекового кадра, работа с локальными переменными.
Для ее вызова нужно было бы использовать инструкцию call :
Для себя я решил передавать аргументы подпрограммам через регистры и указывать в комментариях, в каких регистрах какие аргументы должны быть, но в языках высокого уровня аргументы передаются через стек. К примеру, вот так вызывается функция printf из библиотеки Си:
Аргументы передаются справа налево, обязанность по очистке стека лежит на вызывающей стороне.
При входе в подпрограмму необходимо создать новый стековый кадр. Делается это следующим образом:
Соответственно, перед выходом нужно восстановить прежнее состояние стека:
Для локальных переменных так же используется стек, на котором после создания нового кадра выделяется нужное количество байт:
Так же архитектура x86 предоставляет специальные инструкции, с помощью которых можно более лаконично реализовать эти действия:
Второй параметр инструкции enter – уровень вложенности подпрограммы. Он нужен для линковки с языками высокого уровня, поддерживающими такую методику организации подпрограмм. В нашем случае это значение можно оставить нулевым.
Непосредственно программа
Создание игр для NES на ассемблере 6502: приступаем к разработке
Оглавление
Часть I: подготовка
Часть II: графика
2. Фундаментальные понятия
Вопрос кажется простым, но он затрагивает самую суть того, что делаем мы как программисты. Пока скажем, что «компьютер» — это нечто, исполняющее программу. «Программа» — это просто последовательность команд, а под исполнением программы подразумевается, что команды выполняются с начала и одна за другой. (Если вы читаете программу и сами исполняете команды, то поздравляю! Вы — компьютер!)
У каждого компьютера есть конкретный набор команд, которые он умеет исполнять. Мы называем его набором команд компьютера (да, очень оригинальное название). Набор команд можно описать множеством способов, но пока давайте будем считать, что команды в наборе команд обозначены числами. То есть программа — это просто перечень чисел, каждое из которых задаёт определённое выполняемое действие. Вот пример гипотетического набора команд:
По сути, это набор команд Logo — языка программирования «черепашьей графики», позволяющего перемещать робота по листу бумаги при помощи ручки, чтобы он создавал интересные рисунки. Изображение: Valiant Technology Ltd., CC-BY-SA 3.0.
Запущенная на компьютере с этим набором команд программа, которая должна переместиться вперёд три раза, повернуть вправо, дважды переместиться вперёд, повернуть влево и переместиться вперёд четыре раза, будет выглядеть так:
Работа с данными
Часто команды, которые должен исполнять компьютер, получают в каком-нибудь виде данные. Компьютеры часто складывают числа; гораздо проще иметь одну команду сложения, чем целое множество подобных команд:
Сопровождающие команду данные должны находиться в какой-то части программы. В различных языках программирования эта задача решается по-разному. В некоторых языках «код» (команды) должен храниться совершенно отдельно от данных, в других они объединяются. Оба подхода имеют свои плюсы и минусы, но пока давайте рассмотрим объединённые команды и данные.
В нашем гипотетическом компьютере для «сложения каких-то чисел» набор команд мог бы выглядеть следующим образом:
Пошагово двигаясь по программе по одному числу за раз, мы видим команду «1» («сохранить следующее число как первое число»). Следующее число — это «2», поэтому 2 сохраняется как первое число. Далее мы видим команду «2» («прибавить следующее число к первому числу»). Следующее число — это «7», поэтому наша программа прибавляет 7 к 2, получая результат 9. Здесь данные и команды перемешаны. Увидев «1 2 2 7», невозможно понять, какие из «2» — это команда «прибавить следующее число к первому числу», а какие — алгебраическое число «2», не посмотрев на начало и не пройдя пошагово всю программу.
Где находится результат (9)? Как дальше в программе нам сделать что-нибудь с этим результатом? И что же подразумевается под «сохранением» чего-либо?
Регистры процессора
Как мы только что увидели, программам часто требуется место для временного хранения данных. В большинстве компьютеров для этого используются регистры — небольшие участки внутри процессора, каждое из которых может хранить одно значение. [«Значения» — это просто числа; как мы уже делали с командами в наборе команд, можно взять значение любого типа и представить его в виде числа при условии, если у нас есть какое-то сопоставление между числами и обозначаемыми ими данными. Например, Unicode при помощи 32-битного числа описывает все возможные символы из каждой системы письма на Земле. (Подробнее о «битах» мы поговорим ниже.)]
Регистры могут быть обобщёнными или связанными с определёнными типами функциональности. Например, в процессоре NES есть регистр под названием накопитель (accumulator), часто сокращаемый до «A»; он занимается всеми математическими операциями. В наборе команд 6502 есть команды, работающие следующим образом:
Память
Компьютеры предоставляют программам доступ к какому-то объёму (непостоянной) памяти для временного хранения, что позволяет компьютеру иметь небольшое количество (дорогих) регистров, в то же время обеспечивая возможность хранения за пределами самой программы приемлемого количества значений. Эта память выглядит как последовательность «ящиков» размером с регистр, каждый из которых содержит одно значение и ссылка к которому осуществляется по номеру. NES предоставляет разработчику программу с двумя килобайтами (2 КБ) пространства памяти, пронумерованную от нуля до 2047 — номер в пространстве памяти называется его адресом (как адрес дома). То есть рассмотренный нами ранее набор команд 6502 на самом деле ближе к такому:
Как задаются данные
И это приводит нас к последнему вопросу этой главы — как все эти числа представлены внутри компьютера?
Ранее мы использовали «стандартные» десятичные числа (по основанию 10). Это те числа, которые мы пользуемся повседневно, например, «2», или «7», или «2048». Однако компьютеры работают на электрических токах, которые могут быть или «включенными» или «выключенными», без каких-то промежуточных значений. Эти токи образуют основу всех данных внутри компьютера, поэтому компьютеры используют двоичные числа (по основанию 2).
Наименьшей единицей информации, которую может обработать компьютер, является «бит» (bit, сокращение от binary digit). Бит хранит в себе одно из двух значений — 0 или 1, «включено» или «выключено». Если мы объединим как одно число несколько битов, то сможем задавать больший диапазон значений. Например, двумя битами можно задать четыре разных значения:
Три бита позволяют задать восемь разных значений:
Каждый добавленный нами бит позволяет задавать в два раза больше значений, аналогично тому, как каждый десятичный разряд, добавляемый к десятичному числу, позволяет задавать в десять раз больше значений (1 → 10 → 100 → 1000). Объединённые вместе восемь бит, задающих одно значение, используются настолько часто, что имеют собственное название: байт (byte). В байте может храниться одно из 256 значений. Так как четыре бита являются половиной байта, их иногда называют полубайтом (nybble). В полубайте может храниться одно из 16 значений.
Часто говорят, что компьютеры (в том числе и видеоигровые консоли), имеют определённую битность. Современные десктопные компьютеры/ноутбуки обычно являются 64-битными, старые версии Windows, например, Windows XP, называют 32-битными операционными системами, а NES — это 8-битная система. Все эти числа характеризуют размер регистров компьютера — количество битов, которые может одновременно хранить один регистр. [Несколько усложняет понимание то, что адресная шина NES имеет ширину 16 бит, то есть NES может обрабатывать 65536 различных адресов памяти, а не 256. Однако каждый адрес памяти всё равно хранит только один байт.] Так как NES — это «8-битный» компьютер, каждый его регистр хранит 8-битное значение (один байт). Кроме того, каждый адрес памяти может хранить один байт.
Как же работать с числами больше 255? Игроки в Super Mario Bros. часто зарабатывают десятки тысяч очков, и одного байта явно недостаточно для хранения таких чисел. Когда нам нужно задать значение намного больше, чем может храниться в одном байте, мы используем несколько байтов. В двух байтах (16 бит) можно хранить одно из 65536 значений, а при увеличении количества байтов возможности задания чисел резко возрастают. В трёх байтах можно хранить число до 16777215, а в четырёх — до 4294967295. Когда мы используем таким образом больше одного байта, мы всё равно ограничены размером регистра компьютера. Чтобы работать с 16-битным числом на 8-битной системе, нам нужно получать или сохранять число в двух частях — «младший» (low) байт справа, «старший» (high) байт слева. [Именно из-за необходимости работы с такими значениями из нескольких регистров у процессоров есть порядок следования байтов (endianness) — т. е., у них определено, какой байт идёт первым при работе с большими числами. В процессорах с прямым порядком байтов (он называется little-endian), например, в 6502, сначала идёт младший байт, а затем старший. В процессорах с обратным порядком байтов (big-endian), например, в Motorola 68000, ситуация противоположная — ожидается, что сначала идёт старший байт, а за ним следует младший. Большинство современных процессоров является little-endian из-за очень популярной архитектуры x86 компании Intel, тоже являющейся little-endian.]
Так как управляющий консолью NES процессор 6502 одновременно работает с восемью битами данных, для задания чисел меньшего размера всё равно используется восемь бит. Это может быть неэффективно, поэтому при необходимости в одном байте часто хранят несколько значений меньшего размера. Один байт может содержать два четырёхбитных числа, или четыре двухбитных числа, или даже восемь отдельных значений «включено»/«выключено» (мы называем их флагами).
Например, байт 10110100 может задавать:
Как сделать данные человекочитаемыми
Как мы видели, байты — это очень гибкий способ задания различных типов данных в компьютерной системе. Однако недостаток использования байтов заключается в том, что их сложно читать. Приходится прикладывать усилия, чтобы мысленно преобразовать «10110100» в десятичное число «180». Когда вся программа представлена в виде последовательности байтов, проблема сильно усугубляется.
Для решения этой проблемы основная часть кода представлена в виде шестнадцатеричных чисел. «Шестнадцатеричное» означает «по основанию 16»; одно шестнадцатеричное (hexadecimal, «hex») число может содержать одно из шестнадцати значений. Вот числа от нуля до пятнадцати, представленные в шестнадцатеричном виде:
Шестнадцатеричная запись полезна, потому что шестнадцатеричное число и полубайт хранят одинаковый диапазон значений. Это значит, что можно задать байт двумя шестнадцатеричными значениям, то есть в гораздо более компактном и удобном виде.
Десятичные | Двоичные (1 байт) | Шестнадцатеричные |
---|---|---|
0 | 00000000 | 00 |
7 | 00000111 | 07 |
31 | 00011111 | 1f |
94 | 01011110 | 5e |
187 | 10111011 | bb |
255 | 11111111 | ff |
Соединяем всё вместе
Рассмотрев множество вопросов, связанных с работой компьютеров (и программ), давайте ещё раз взглянем на весь процесс, происходящий при выполнении программы.
Во-первых, сама программа представлена в виде последовательности байтов (так называемого машинного кода). Каждый байт — это или команда для процессора, или сопровождающие команду данные.
С самого начала программы процессор многократно выполняет трёхэтапный процесс. Сначала процессор получает следующий байт из программы. В процессоре есть специальный регистр под названием счётчиком программ (program counter), он отслеживает, каким будет следующий номер байта программы. Счётчик программ (program counter, PC) работает совместно с регистром под названием адресная шина (address bus), отвечающим за получение и сохранение байтов из программы или из памяти, для получения байтов.
Далее процессор декодирует полученный им байт, выясняя, какой записи в наборе команд соответствует этот байт (или какую команду сопровождает байт данных). Наконец, он исполняет команду, внося изменения в регистры процессора или в память. Процессор выполняет инкремент счётчика программ на единицу, чтобы получить следующий байт программы, и цикл начинается снова.
3. Приступаем к разработке
Настройка среды разработки
Здесь перечислены все инструменты, которые мы будем устанавливать. Некоторые из них мы будем использовать сразу (и постоянно), другие более специализированы и пригодятся позже. Для каждой категории я указал конкретное ПО, которое буду использовать в этой книге; однако есть множество других вариантов, так что освоившись с рекомендуемыми мной, вы сможете поэкспериментировать и с другими инструментами.
Текстовый редактор
Во-первых, вам понадобится текстовый редактор. Думаю, вы уже что-то программировали, поэтому у вас есть любимый текстовый редактор. Если нет, то можно попробовать одну из следующих программ:
Ассемблер и компоновщик
Ассемблер компилирует ассемблерный код (который мы будем писать в этой книге) в машинный код — сырой поток байтов, считываемый процессором. Компоновщик (linker) берёт набор файлов, который был пропущен через ассемблер, и превращает их в единый файл программы. Так как у каждого процессора свой машинный код, ассемблеры обычно предназначены только для одного типа процессора. Существует множество вариантов ассемблеров и компоновщиков для процессора 6502, но в этой книге мы будем использовать ca65 и ld65. Они имеют открытый исходный код и являются кроссплатформенными, а также обладают очень полезными функциями для разработки больших программ. ca65 и ld65 — это часть более масштабного комплекта программ «cc65», включающего в себя компилятор C и многое другое.
Для установки ca65 и ld65 на Mac нужно для начала установить менеджер пакетов Mac Homebrew. Скопируйте команду с главной страницы, вставьте её в терминал и нажмите Enter; выполните инструкции, после чего Homebrew будет готов к работе. Установив Homebrew, введите brew install cc65 и нажмите Enter.
Windows
Linux
Эмулятор
Эмулятор — это программа, запускающая программы, предназначенные для другой компьютерной системы. Мы будем использовать эмулятор NES, чтобы запускать создаваемые нами программы на том же компьютере, где их разрабатываем, вместо запуска на аппаратной NES. Существует множество эмуляторов NES (а когда вы наберётесь опыта в разработке для NES, будет интересно и написать собственный!), но для этой книги мы будем использовать Nintaco.
Он кроссплатформенный, и к тому же является одним из немногих эмуляторов, имеющих инструменты отладки, которые пригодятся, когда мы будем писать программы.
Установка Nintaco на всех платформах происходит одинаково — достаточно скачать его с веб-сайта Nintaco и распаковать. Чтобы запустить Nintaco, нужно дважды нажать на Nintaco.jar. Для запуска Nintaco требуется Java; если на вашем компьютере не установлена Java, скачайте «Java Runtime Environment» с сайта java.com.
Графические инструменты
NES хранит графику в собственном уникальном формате, непохожем на традиционные типы изображений наподобие JPEG или PNG. Нам понадобится программа, способная работать с изображениями NES. Существуют плагины для больших графических пакетов типа Photoshop или GIMP, но мне нравится использовать для этого компактный специализированный инструмент. Для этой книги мы будем использовать NES Lightbox — кроссплатформенную производную от NES Screen Tool.
Windows
Скачайте Windows-установщик (для 64-битных систем). Дважды нажмите на «NES Lightbox Setup 1.0.0.exe», чтобы установить программу.
Linux
В системах с Ubuntu можно скачать файл Snap, который является автономным пакетом приложения. В случае других дистрибутивов Linux (или если вы предпочитаете AppImage) нужно скачать файл AppImage. Прежде чем запустить файл AppImage, его нужно пометить как исполняемый.
Инструменты для создания музыки
Как и в случае с графикой, звук на NES представлен в уникальном формате — это команды аудиопроцессора, а не что-то типа MP3. Самая популярная программа для создания звука для NES — FamiTracker, это мощный, но сложный инструмент, предназначенный только для Windows. Для этой книги мы будем использовать FamiStudio — кроссплатформенную программу с более дружественным интерфейсом, результаты работы в которой сохраняются в простой для интеграции формат.
Windows / Mac / Linux
Скачайте последнюю версию с веб-сайта FamiStudio.
Соединяем всё вместе
Установив все инструменты, нужно убедиться, что они работают. Мы создадим аналог «Hello World» для игр на NES: заполним весь экран одним цветом.
Запустите Nintaco и выберите в меню «File» пункт «Open». Выберите только что созданный файл helloworld.nes и нажмите Open. В результате вы увидите зелёный экран.
Дальнейшие шаги
Если вы увидели в Nintaco зелёный экран, поздравляю! Ваша среда разработки готова к использованию. В следующей главе мы расскажем, что же делает скопированный нами код, и немного узнаем о том, как работает оборудование NES.
Библиотека Интернет Индустрии I2R.ru
Малобюджетные сайты.
Продвижение веб-сайта.
Контент и авторское право.
Win32ASM: «Hello, World» и три халявы MASM32
Вот две строчки из моего батника (*.bat), который позволяет не «парится» с командной строкой:
Бряк 3. Консоль мы можем использовать как устройство ввода (input device), устройство вывода (output device), устройство для отчета об ошибках (error device). Для того чтобы работать с этим «девайсом», мы должны получить его хэндл при помощи следующей функции:
Хэндл стандартного ввода | -10 |
Хэндл стандартного вывода | -11 |
Хэндл «ошибок» | -12 |
Теперь, когда мы разобрали все параметры, обратите внимание на то, что MSDN’овская очередность параметров не соответствует той очередности, в которой мы записываем их в стек в нашем исходнике. Вернитесь еще раз к Минимальному приложению, п.12 и внимательно прочитайте пункты соглашения stdcall. Теперь понятно?
Бряк 5. Дабы мы успели полюбоваться результатом трудов своих праведных, при помощи функции Sleep вызываем программную задержку в 2 секунды. Думаю, с параметрами вы без труда разберетесь.
Вообще-то, правильный стиль предполагает явное освобождение всех занятых ресурсов по минованию надобности в них, в том числе и хэндлов, несмотря на то что они автоматически закрываются ExitProcess‘ом. Но будем надеяться, что если мы не сделаем это в такой маленькой программулине как наша, ничего страшного не случится. Естественно, «формат цэ» не в счет.
Value | Meaning |
STD_INPUT_HANDLE | Standard input handle |
STD_OUTPUT_HANDLE | Standard output handle |
STD_ERROR_HANDLE | Standard error handle |
В ответ на это ассемблер сам извлечет из windows.inc всю имеющуюся в этом файле информацию и преподнесет ее транслятору на блюдечке с голубой каемочкой.
Как видим, команда разработчиков MASM32 позаботилась не только о простыне прототипов, но и о «независимости» нашего исходника от выбранной кодировки. То есть для того, чтобы «перезаточить» программу под UNICODE, нам вовсе не нужно заменять окончание A на W в имени функции. Достаточно просто приинклюдить другой файл с прототипами и эквивалентами наподобие
и не «париться» с переписыванием исходника.
Надо отметить, в MASM32 подобного «юникодного» инклуда нет, однако вы легко можете сделать его сами.
мы с легкостью можем заменить одной-единственной строчкой:
Обратите внимание, что при использовании этой команды параметры мы передаем слева направо, в той же очередности, что и вещает нам MSDN. В отличие от простыни «пушей» c «каллом» в конце.
#7. Теперь самый главный момент. Затаите дыхание!
В свете вышесказанного, вышерасписанного и вышерасжеванного наш исходник принимает весьма красивый «высокоуровневый» вид:
Assembler Linux
Компиляторы ассемблера в Linux
В Linux традиционно используется компилятор ассемблера GNU Assembler (GAS, вызываемый командой as), входящий в состав пакета GCC. Этот компилятор является кроссплатформенным, т. е. может компилировать программы, написанные на различных языках ассемблера для разных процессоров. Однако GAS использует синтаксис AT&T, а не Intel, поэтому его использование программистами, привыкшими к синтаксису Intel, вызывает некоторый дискомфорт.
Например программа, выводящая на экран сообщение «Hello, world!» (далее будем называть ее hello) выглядит следующим образом:
Как видно из примера, различия видны как в синтаксисе команд, так и в синтаксисе директив ассемблера и комментариях.
В последних версиях GAS появилась возможность использования синтаксиса Intel для команд, но синтаксис директив и комментариев остается традиционным. Включение синтаксиса Intel осуществляется директивой .intel_syntax с параметром noprefix. При этом программа, приведенная выше изменится следующим образом:
Другим широко распространенным компилятором ассемблера для Linux является Netwide Assembler (NASM, вызываемый командой nasm). NASM использует синтаксис Intel. Кроме того, синтаксис директив ассемблера NASM частично совпадает с синтаксисом MASM. Пример приведенной выше программы для ассемблера NASM выглядит следующим образом:
Кроме перечисленных ассемблеров в среде Linux можно использовать ассемблеры FASM и YASM. Оба поддерживают синтаксис Intel, но FASM имеет свой синтаксис директив, а YASM синтаксически полностью аналогичен NASM и отличается от него только типом пользовательской лицензии. В дальнейшем изложении материала все примеры будут даваться применительно к синтаксису, используемому NASM. Желающим использовать GAS можно порекомендовать статью о сравнении этих двух ассемблеров. Кроме того, при использовании в GAS директивы .intel_syntax noprefix различия между ними будут не столь значительными. Тексты программ, подготовленные для NASM, как правило, без проблем компилируются и YASM.
Структура программы
Использование библиотечных функций
В программах на ассемблере можно использовать функции библиотеки Си. Для использования функции ее надо предварительно объявить директивой EXTERN. Например, для того. чтобы использовать функцию printf необходимо предварительно указать выполнить следующую директиву:
EXTERN printf
Программу hello можно модифицировать так, чтобы она использовала для вывода информации не функцию API Linux, а функцию printf библиотеки Си. Код программы, назовем ее hello-c, будет выглядеть так:
Компиляция программ, использующих библиотечные функции ничем не отличается от компиляции программ, использующих только функции API. Различия появляются только на этапе компоновки. Особенности компоновки будут рассмотрены далее.
Что такое ассемблер и нужно ли его изучать
Этому языку уже за 70, но на пенсию он пока не собирается.
Полина Суворова для Skillbox Media
Есть традиция начинать изучение программирования с вывода на экран строки «Hello world!». На языке Python, например, это всего одна команда:
Всё просто, понятно и красиво! Но есть язык программирования, в котором, чтобы получить тот же результат, нужно написать солидный кусок кода:
Это ассемблер. Только не нужно думать, что он плох. Просто Python — это язык высокого уровня, а ассемблер — низкого. Одна команда Python при выполнении вызывает сразу несколько операций процессора, а каждая команда ассемблера — всего одну операцию.
Сложно? Давайте разбираться.
Немного о процессорах и машинном языке
Чтобы объяснить, что такое язык ассемблера, начнём с того, как вообще работает процессор и на каком языке с ним можно «разговаривать».
Процессор — это электронное устройство (сейчас крошечная микросхема, а раньше процессоры занимали целые залы), не понимающее слов и цифр. Он реагирует только на два уровня напряжения: высокий — единица, низкий — ноль. Поэтому каждая процессорная команда — это последовательность нулей и единиц: 1 — есть импульс, 0 — нет.
Для работы с процессором используется машинный язык. Он состоит из инструкций, записанных в двоичном коде. Каждая инструкция определяет одну простую машинную операцию: арифметическую над числами, логическую (поразрядную), ввода-вывода и так далее.
Например, для Intel 8088 инструкция 0000001111000011B — это операция сложения двух чисел, а 0010101111000011B — вычитания.
Программировать на машинном языке нелегко — приходится работать с огромными цепочками нулей и единиц. Трудно написать или проверить такую программу, а уж тем более разобраться в чужом коде.
Поэтому много лет назад был создан язык ассемблера, в котором коды операций обозначались буквами и сокращениями английских слов, отражающих суть команды. Например, команда mov ax, 6 означает: «переместить число 6 в ячейку памяти AX».
Когда и как был создан ассемблер?
Это произошло ещё в сороковых годах прошлого века. Ассемблер был создан для первых ЭВМ на электронных лампах, программы для которых писали на машинном языке. А так как памяти у компьютеров было мало, то команды вводили, переключая тумблеры и нажимая кнопки. Даже несложные вычисления занимали много времени.
Проблему решили, когда ЭВМ научились хранить программы в памяти. Уже в 1950 году была разработана первая программа-транслятор, которая переводила в машинный код программы, написанные на понятном человеку языке. Эту программу назвали программой-сборщиком, а язык — языком ассемблера (от англ. assembler — сборщик).
Появление ассемблера сильно облегчило жизнь программистов. Они смогли вместо двоичных кодов использовать команды, состоящие из близких к обычному языку условных обозначений. Кроме того, ассемблер позволил уменьшить размеры программ — для машин того времени это было важно.
Как устроен язык ассемблера?
Ассемблер можно считать языком второго поколения, если за первый принять машинный язык. Он работает непосредственно с процессором, и каждая его команда — это инструкция процессора, а не операционной или файловой системы. Перевод языка ассемблера в машинный код называется ассемблированием.
Коды операций в языке ассемблера мнемонические, то есть удобные для запоминания:
Регистрам и ячейкам памяти присваиваются символические имена, например:
EAX, EBX, AX, AH — имена для регистров;
meml — имя для ячейки памяти.
Например, так выглядит команда сложения чисел из регистров AX и BX:
А это команда вычитания чисел из регистров AX и BX:
Кроме инструкций, в языке ассемблера есть директивы — команды управления компилятором, то есть программой-ассемблером.
Вот некоторые из них:
Не думайте, что ассемблер — всего лишь набор инструкций процессора с удобной для программиста записью. Это полноценный язык программирования, на котором можно организовать циклы, условные переходы, процедуры и функции.
Вот, например, код, на ассемблере, выводящий на экран цифры от 1 до 10:
Здесь действие будет выполняться в цикле — как, например, в циклах for или do while в языках высокого уровня.
Единого стандарта для языков ассемблера нет. В работе с процессорами Intel разработчики придерживаются двух синтаксисов: Intel и AT&T. Ни у того ни у другого нет особых преимуществ: AT&T — стандартный синтаксис в Linux, а Intel используется в мире Microsoft.
Одна и та же команда в них выглядит по-разному.
Например, в синтаксисе Intel:
mov eax, ebx — команда перемещает данные из регистра eax в регистр ebx.
В синтаксисе AT&T эта команда выглядит так:
Почему для разных семейств процессоров нужен свой ассемблер?
Дело в том, что у каждого процессора есть набор характеристик — архитектура. Это его конструкция и принцип работы, а также регистры, адресация памяти и используемый набор команд. Если у процессоров одинаковая архитектура, то говорят, что они из одного семейства.
Так как наборы команд для разных архитектур процессоров отличаются друг от друга, то и программы на ассемблере, написанные для одних семейств, не будут работать на процессорах из других семейств. Поэтому ассемблер называют машинно-ориентированным языком.
Кому и зачем нужен язык ассемблера?
Даже из нашего примера «Hello, World!» видно, что ассемблер не так удобен в разработке, как языки высокого уровня. Больших программ на этом языке сейчас никто не пишет, но есть области, где он незаменим:
Если вы хотите разрабатывать новые микропроцессоры или стать реверс-инженером, то есть смысл серьёзно заняться изучением языка ассемблера.
Востребованы ли программисты на ассемблере сегодня?
Конечно. Хотя на сайтах по поиску работу вы вряд ли найдёте заявки от работодателей с заголовками: «Нужен программист на ассемблере», зато там много таких, где требуется знание ассемблера дополнительно к языкам высокого уровня: C, C++ или Python. Это вакансии реверс-инженеров, специалистов по компьютерной безопасности, разработчиков драйверов и программ для микроконтроллеров/микропроцессоров, системных программистов и другие.
Предлагаемая зарплата — обычная в сфере IT: 80–300 тысяч рублей в зависимости от квалификации и опыта. Вот, например, вакансия реверс-инженера на HeadHunter, где требуется знание ассемблера:
Стоит ли начинать изучение программирования с языка ассемблера?
Нет, так делать не нужно. Для этого есть несколько причин:
Поэтому, даже если вы решили заняться профессией, связанной с ассемблером, изучение программирования вам лучше начинать с языка высокого уровня. А уж ассемблер после него будет выучить несложно.
Микропроцессор, выпущенный компанией Intel в 1979 году. Использовался в оригинальных компьютерах IBM PC.
Данные, которые обрабатываются командой — грамматической конструкцией языка программирования, обозначающей аргумент операции.
Центральная часть операционной системы, координирующая доступ приложений к процессорному времени, памяти, внешним устройствам.
Программа, которая обеспечивает загрузку самой OC сразу после включения компьютера.
Пишем Android-приложение на ассемблере
Эта рассказ о нестандартном подходе к разработке Android-приложений. Одно дело — установка Android Studio и написание «Hello, World» на Java или Kotlin. Но я покажу, как эту же задачу можно выполнить иначе.
Напоминаем: для всех читателей «Хабра» — скидка 10 000 рублей при записи на любой курс Skillbox по промокоду «Хабр».
Skillbox рекомендует: Образовательный онлайн-курс «Профессия Java-разработчик».
Как работает мой смартфон с Android OS?
Сначала небольшая предыстория. Однажды вечером мне позвонила знакомая по имени Ариэлла. Она спросила меня: «Слушай, а как работает мой смартфон? Что у него внутри? Как электрическая энергия и обычные единицы и нули позволяют всему этому функционировать?»
Моя знакомая не нуб в разработке, она создала несколько проектов на Arduino, которые состояли как из программной, так и из аппаратной частей. Может быть, именно поэтому она захотела узнать больше. Мне удалось ответить при помощи знаний, полученных на одном из курсов информатики, пройденных в университете.
Затем мы работали пару недель вместе, поскольку Ариэлла захотела узнать, как работают кирпичики электронной техники, то есть полупроводниковые элементы, включая транзисторы. Далее мы вышли на более высокий уровень: я ей показал, как можно создавать логические вентили, к примеру NAND (логическое И) плюс NOR (логическое ИЛИ) c использованием специфической комбинации транзисторов.
Мы исследовали логические элементы разных видов, объединяли их для выполнения вычислений (например, добавления двух бинарных чисел) и ячеек памяти (триггеров). Когда все прояснилось, начали разрабатывать простой процессор (воображаемый), в котором было два регистра общего назначения и две простые инструкции (добавление этих регистров). Мы даже написали простую программу, которая умножает эти два числа.
Кстати, если вас интересует эта тема, то прочитайте инструкцию по созданию 8-битного компьютера с нуля. Здесь объясняется практически все, с самых основ. Хотел бы я прочитать это раньше!
Hello, Android!
После завершения всех этапов изучения мне показалось, что у Ариэллы хватит знаний, чтобы понять, как работает процессор смартфона. Ее смартфон — Galaxy S6 Edge, база которого — архитектура ARM (как, собственно, и у большинства смартфонов). Мы решили написать «Hello, World»-приложение для Android, но на ассемблере.
Если вы никогда раньше не сталкивались с кодом ассемблера, то этот блок может вас напугать. Но ничего страшного, давайте разберем код вместе.
В строке 2 мы определяем глобальную функцию с названием _start. Она представляет собой точку входа в приложение. ОС начинает выполнять код именно с этой точки. Определение функции объявлено в строке 4.
Кроме того, функция выполняет еще две вещи. В строках 5–9 сообщение выводится на экран, в строках 11–13 программа завершается. Даже если удалить 11–13 строки, программа выведет нашу строку «Hello, World» и завершится. Тем не менее выход не будет корректным, поскольку программа завершится с ошибкой. Без строк 11–13 приложение попытается выполнить недопустимую инструкцию.
Что касается параметров для системного вызова, то они передаются через другие регистры. Например, r0 показывает номер дескриптора файла, который нам необходимо напечатать. Мы помещаем туда значение 1 (строка 5), указывающее стандартный вывод (stdout), то есть вывод на экран.
r1 указывает на адрес памяти данных, которые мы хотим записать, поэтому мы просто загружаем в эту область адрес строки «Hello, World» (строка 6), а регистр r2 показывает, сколько байтов мы хотим записать. В нашей программе для него установлено значение message_len (строка 7), вычисляемое в строке 18 с использованием специального синтаксиса: символ точки обозначает текущий адрес памяти. По этой причине. — message обозначает текущий адрес памяти минус адрес message. Ну а поскольку мы заявляем message_len сразу же после message, то все это вычисляется как длина message.
Если записать код строк 5–9 при помощи языка С, получится следующее:
Завершить работу программы несколько проще. Для этого мы просто прописываем код выхода в регистр r0 (строка 11), после чего добавляем значение 1, являющееся номером вызова системной функции exit(), в r7 (строка 12), затем снова вызываем ядро (строка 13).
Полный список системных вызовов Android и их номеров можно найти в исходном коде операционной системы. Также там есть и реализация write() и exit(), вызывающих соответствующие системные функции.
Собираем программу
Для того чтобы скомпилировать наш проект, понадобится Android NDK (Native Development Kit). Он содержит набор компиляторов и инструментов сборки для ARM-платформы. Загрузить его можно с официального сайта, установить — например, через Android Studio.
После того как NDK установлен, нам понадобится файл arm-linux-androideabi-as, это ассемблер для ARM. Если вы произвели загрузку через Android Studio, то поищите его в папке Android SDK. Обычно ее расположение —
После того как ассемблер найден, сохраните написанное в файл с названием hello.s, после чего выполните следующую команду для преобразования его в машинный код:
Эта операция позволяет создать объектный ELF-файл с именем hello.o. Для того чтобы преобразовать его в двоичный файл, который может работать на вашем девайсе, вызовите компоновщик:
Теперь у нас есть файл hello, который содержит программу, вполне готовую к использованию.
Запускаем приложение на своем девайсе
Для того чтобы избежать проблем при запуске приложения, в примере был использован adb, что позволило скопировать его во временную папку нашего устройства Android. После этого пускаем в ход adb shell для того, чтобы запустить приложение и оценить результат:
adb push hello /data/local/tmp/hello
adb shell chmod +x /data/local/tmp/hello
И, наконец, запускаем приложение:
adb shell /data/local/tmp/hello
А что напишете вы?
Сейчас у вас есть рабочее окружение, похожее на то, которое было у Ариэллы. Она потратила на изучение ARM-ассемблера несколько дней, придумав затем несложный проект — это игра Sven Boom (разновидность Fizz Buzz родом из Израиля). Игроки считают по очереди и каждый раз, когда число делится на 7 или содержит число 7, они должны сказать «бум» (отсюда и название игры).
Стоит отметить, что программа по игре — не такая уж и простая задача. Ариэлла написала целый метод, который выводит на экран числа, по одной цифре за раз. Поскольку она писала все на ассемблере без вызова стандартных функций библиотеки С, на решение пришлось потратить несколько дней.
Первая программа Ариэллы для Adnroid размещена вот здесь. Кстати, некоторые идентификаторы кода в приложении — на самом деле еврейские слова (например, _sifra_ahrona).
Написание Android-приложения на ассемблере — хороший способ познакомиться поближе с архитектурой ARM, а также лучше понять внутреннюю кухню гаджета, используемого вами ежедневно. Я предлагаю вам заняться этим вплотную и попробовать создать небольшое приложение на ассемблере для вашего устройства. Это может быть простая игра или что-нибудь еще.
Пишем собственную виртуальную машину
В этом руководстве я расскажу, как написать собственную виртуальную машину (VM), способную запускать программы на ассемблере, такие как 2048 (моего друга) или Roguelike (моя). Если вы умеете программировать, но хотите лучше понять, что происходит внутри компьютера и как работают языки программирования, то этот проект для вас. Написание собственной виртуальной машины может показаться немного страшным, но я обещаю, что тема удивительно простая и поучительная.
Окончательный код составляет около 250 строк на C. Достаточно знать лишь основы C или C++, такие как двоичная арифметика. Для сборки и запуска подходит любая Unix-система (включая macOS). Несколько API Unix используются для настройки ввода и отображения консоли, но они не являются существенными для основного кода. (Реализация поддержки Windows приветствуется).
Примечание: эта VM — грамотная программа. То есть вы прямо сейчас уже читаете её исходный код! Каждый фрагмент кода будет показан и подробно объяснён, так что можете быть уверены: ничего не упущено. Окончательный код создан сплетением блоков кода. Репозиторий проекта тут.
1. Оглавление
2. Введение
Что такое виртуальная машина?
Виртуальная машина — это программа, которая действует как компьютер. Она имитирует процессор с несколькими другими аппаратными компонентами, позволяя выполнять арифметику, считывать из памяти и записывать туда, а также взаимодействовать с устройствами ввода-вывода, словно настоящий физический компьютер. Самое главное, VM понимает машинный язык, который вы можете использовать для программирования.
Сколько аппаратного обеспечения имитирует конкретная VM — зависит от её предназначения. Некоторые VM воспроизводят поведение одного конкретного компьютера. У людей больше нет NES, но мы всё ещё можем играть в игры для NES, имитируя аппаратное обеспечение на программном уровне. Эти эмуляторы должны точно воссоздать каждую деталь и каждый основной аппаратный компонент оригинального устройства.
Другие VM не соответствуют никакому конкретному компьютеру, а частично соответствуют сразу нескольким! В первую очередь это делается для облегчения разработки ПО. Представьте, что вы хотите создать программу, работающую на нескольких компьютерных архитектурах. Виртуальная машина даёт стандартную платформу, которая обеспечивает переносимость. Не нужно переписывать программу на разных диалектах ассемблера для каждой архитектуры. Достаточно сделать только небольшую VM на каждом языке. После этого любую программу можно написать лишь единожды на языке ассемблера виртуальной машины.
Примечание: компилятор решает подобные проблемы, компилируя стандартный высокоуровневый язык для разных процессорных архитектур. VM создаёт одну стандартную архитектуру CPU, которая симулируется на различных аппаратных устройствах. Одно из преимуществ компилятора в том, что отсутствуют накладные расходы во время выполнения, как у VM. Хотя компиляторы хорошо работают, написание нового компилятора для нескольких платформ очень трудно, поэтому VM всё ещё полезны. В реальности на разных уровнях и VM, и компиляторы используются совместно.
Виртуальная машина Java (JVM) — очень успешный пример. Сама JVM относительно среднего размера, она достаточно мала для понимания программистом. Это позволяет писать код для тысяч разнообразных устройств, включая телефоны. После реализации JVM на новом устройстве любая написанная программа Java, Kotlin или Clojure может работать на нём без изменений. Единственными затратами будут только накладные расходы на саму VM и дальнейшее абстрагирование от машинного уровня. Обычно это довольно хороший компромисс.
VM не обязательно должна быть большой или вездесущной, чтобы обеспечить аналогичные преимущества. Старые видеоигры часто использовали небольшие VM для создания простых скриптовых систем.
VM также полезны для безопасной изоляции программ. Одно из применений — сборка мусора. Не существует тривиального способа реализовать автоматическую сборку мусора поверх C или C++, так как программа не может видеть собственный стек или переменные. Однако VM находится «вне» запущенной программы и может наблюдать все ссылки на ячейки памяти в стеке.
Ещё один пример такого поведения демонстрируют смарт-контракты Ethereum. Смарт-контракты — это небольшие программы, которые выполняются каждым узлом валидации в блокчейне. То есть операторы разрешают выполнение на своих машинах любых программ, написанных совершенно незнакомыми людьми, без какой-либо возможности изучить их заранее. Чтобы предотвратить вредоносные действия, они выполняются на VM, не имеющей доступа к файловой системе, сети, диску и т.д. Ethereum — также хороший пример переносимости. Благодаря VM можно писать смарт-контракты без учёта особенностей множества платформ.
3. Архитектура LC-3
Наша VM будет симулировать вымышленный компьютер под названием LC-3. Он популярен для обучения студентов ассемблеру. Здесь упрощённый набор команд по сравнению с x86, но сохраняются все основные концепции, которые используются в современных CPU.
Во-первых, нужно сымитировать необходимые аппаратные компоненты. Попытайтесь понять, что представляет собой каждый компонент, но не волнуйтесь, если не уверены, как он вписывается в общую картину. Начнём с создания файла на С. Каждый фрагмент кода из этого раздела следует поместить в глобальную область видимости этого файла.
Память
В компьютере LC-3 есть 65 536 ячеек памяти (2 16 ), каждая из которых содержит 16-разрядное значение. Это означает, что он может хранить всего 128 Кб — намного меньше, чем вы привыкли! В нашей программе эта память хранится в простом массиве:
Регистры
Регистр — это слот для хранения одного значения в CPU. Регистры подобны «верстаку» CPU. Чтобы он мог работать с каким-то фрагментом данных, тот должен находиться в одном из регистров. Но поскольку регистров всего несколько, в любой момент времени можно загрузить только минимальный объём данных. Программы обходят эту проблему, загружая значения из памяти в регистры, вычисляя значения в другие регистры, а затем сохраняя окончательные результаты обратно в память.
В компьютере LC-3 всего 10 регистров, каждый на 16 бит. Большинство из них —общего назначения, но некоторым назначены роли.
Как и память, будем хранить регистры в массиве:
Набор инструкций
Инструкция — это команда, которая говорит процессору выполнить какую-то фундаментальную задачу, например, сложить два числа. У инструкции есть опкод (код операции), указывающий тип выполняемой задачи, а также набор параметров, которые предоставляют входные данные для выполняемой задачи.
Каждый опкод представляет собой одну задачу, которую процессор «знает», как выполнить. В LC-3 всего 16 опкодов. Компьютер может вычислить только последовательность этих простых инструкций. Длина каждой инструкции 16 бит, а левые 4 бита хранят код операции. Остальные используются для хранения параметров.
Позже подробно обсудим, что делает каждая инструкция. На данный момент определите следующие опкоды. Удостоверьтесь, что сохраняют такой порядок, чтобы получать правильное значение enum:
Примечание: в архитектуре Intel x86 сотни инструкций, в то время как в других архитектурах, таких как ARM и LC-3, очень мало. Небольшие наборы инструкций называются RISC, а более крупные — CISC. Большие наборы инструкций, как правило, не предоставляют принципиально новых возможностей, но часто упрощают написание ассемблерного кода. Одна инструкция CISC может заменить несколько инструкций RISC. Однако процессоры CISC более сложны и дороги в проектировании и производстве. Это и другие компромиссы не позволяют назвать «оптимальный» дизайн.
Флаги условий
У каждого процессора множество флагов состояния для сигнализации о различных ситуациях. LC-3 использует только три флага условий, которые показывают знак предыдущего вычисления.
Мы закончили настройку аппаратных компонентов нашей виртуальной машины! После добавления стандартных включений (см. по ссылке выше) ваш файл должен выглядеть примерно так:
Здесь указаны ссылки на пронумерованные разделы статьи, откуда берутся соответствующие фрагменты кода. Полный листинг см. в рабочей программе — прим. пер.
4. Примеры на ассемблере
Теперь рассмотрим программу на ассемблере LC-3, чтобы получить представление, что фактически выполняет виртуальная машина. Вам не нужно знать, как программировать на ассемблере, или всё тут понимать. Просто постарайтесь получить общее представление, что происходит. Вот простой «Hello World»:
Как и в C, программа выполняет по одному оператору сверху вниз. Но в отличие от C, здесь нет вложенных областей <> или управляющих структур, таких как if или while ; только простой список операторов. Поэтому его гораздо легче выполнить.
Обратите внимание, что имена некоторых операторов соответствуют опкодам, которые мы определили ранее. Мы знаем, что в инструкциях по 16 бит, но каждая строка выглядит как будто с разным количеством символов. Как возможно такое несоответствие?
Это происходит потому что код, который мы читаем, написан на ассемблере — в удобочитаемой и доступной для записи форме обычным текстом. Инструмент, называемый ассемблером, преобразует каждую строку текста в 16-разрядную двоичную инструкцию, понятную виртуальной машине. Эта двоичная форма, которая по сути представляет собой массив 16-разрядных инструкций, называется машинным кодом и фактически выполняется виртуальной машиной.
Примечание: хотя компилятор и ассемблер играют схожую роль в разработке, они не одинаковы. Ассемблер просто кодирует то, что программист написал в тексте, заменяя символы их двоичным представлением и упаковывая их в инструкции.
Циклы и условия выполняются с помощью goto-подобной инструкции. Вот еще один пример, который считает до 10.
Примечание: для этого руководства необязательно учиться ассемблеру. Но если вам интересно, можете написать и собрать собственные программы LC-3 с помощью LC-3 Tools.
5. Выполнение программ
Ещё раз, предыдущие примеры просто дают представление, что делает VM. Для написания VM вам не нужно полное понимание ассемблера. Пока вы следуете соответствующей процедуре чтения и исполнения инструкций, любая программа LC-3 будет корректно работать, независимо от её сложности. В теории, VM может запустить даже браузер или операционную систему, как Linux!
Если глубоко задуматься, то это философски замечательная идея. Сами программы могут производить сколь угодно сложные действия, которые мы никогда не ожидали и, возможно, не сможем понять. Но в то же время вся их функциональность ограничивается простым кодом, который мы напишем! Мы одновременно знаем всё и ничего о том, как работает каждая программа. Тьюринг упоминал эту чудесную идею:
«Мнение о том, что машины не могут чем-либо удивить человека, основывается, как я полагаю, на одном заблуждении, которому в особенности подвержены математики и философы. Я имею в виду предположение о том, что коль скоро какой-то факт стал достоянием разума, тотчас же достоянием разума становятся все следствия из этого факта». — Алан М. Тьюринг
Процедура
Вот точное описание процедуры, которую нужно написать:
Начнём изучение этого процесса на примере основного цикла:
6. Реализация инструкций
Теперь ваша задача — сделать правильную реализацию для каждого опкода. Подробная спецификация каждой инструкции содержится в документации проекта. Из спецификации нужно узнать, как работает каждая инструкция, и написать реализацию. Это проще, чем кажется. Здесь я продемонстрирую, как реализовать две из них. Код для остальных можно найти в следующем разделе.
Инструкция ADD берёт два числа, складывает их и сохраняет результат в регистре. Спецификация в документации на стр. 526. Каждая инструкция ADD выглядит следующим образом:
Таким образом, мы знаем, где сохранить результат, и знаем первое число для сложения. Осталось только узнать второе число для сложения. Здесь две строки начинают различаться. Обратите внимание, что вверху 5-й бит равен 0, а внизу — 1. Этот бит соответствует или непосредственному режиму, или регистровому режиму. В регистровом режиме второе число хранится в регистре, как и первое. Оно отмечено как SR2 и содержится в битах со второго по нулевой. Биты 3 и 4 не используются. На ассемблере это будет написано так:
В непосредственном режиме вместо добавления содержимого регистра непосредственное значение внедряется в саму инструкцию. Это удобно, потому что программе не нужны дополнительные инструкции для загрузки этого числа в регистр из памяти. Вместо этого оно уже внутри инструкции, когда нам нужно. Компромисс в том, что там могут храниться только небольшие числа. Если быть точным, максимум 2 5 =32. Это наиболее полезно для увеличения счётчиков или значений. На ассемблере можно написать так:
Вот выдержка из спецификации:
Если бит [5] равен 0, то второй исходный операнд получают из SR2. Если бит [5] равен 1, то второй исходный операнд получают путём расширения значения imm5 до 16 бит. В обоих случаях второй исходный операнд добавляется к содержимому SR1, а результат сохраняется в DR. (стр. 526)
Примечание: если вас интересуют двоичные отрицательные числа, можете прочитать о дополнительном коде. Но это не существенно. Достаточно просто скопировать код выше и использовать его, когда спецификация говорит расширить значение.
В спецификации есть последнее предложение:
Коды условий задаются в зависимости от того, является ли результат отрицательным, нулевым или положительным. (стр. 526)
Ранее мы определили условие flags enum, а теперь пришло время использовать этих флаги. Каждый раз, когда значение записывается в регистр, нам нужно обновить флаги, чтобы указать его знак. Напишем функцию для повторного использования:
Теперь мы готовы написать код для ADD :
В этом разделе много информации, поэтому подведём итоги.
LDI означает «косвенную» или «непрямую» загрузку (load indirect). Эта инструкция используется для загрузки в регистр значения из места в памяти. Спецификация на стр. 532.
Вот как выглядит двоичная компоновка:
Это может показаться окольным путём для чтения из памяти, но так нужно. Инструкция LD ограничена адресным смещением 9 бит, тогда как память требует для адреса 16 бит. LDI полезна для загрузки значений, которые хранятся где-то за пределами текущего компьютера, но для их использования адрес конечного местоположения должен храниться рядом. Вы можете думать о ней как о локальной переменной в C, которая является указателем на некоторые данные:
Как и раньше, после записи значения в DR следует обновить флаги:
Коды условий задаются в зависимости от того, является ли результат отрицательным, нулевым или положительным. (стр. 532)
Вот код для данного случая: ( mem_read обсудим в следующем разделе):
7. Шпаргалка по инструкциям
В этом разделе — полные реализации оставшихся инструкций, если вы застряли.
Язык ассемблера: предельно просто про синтаксис и кодирование
Редакция Highload разобралась, что такое язык ассемблера, разобрала его синтаксис и варианты использования. Ведь умение читать и писать код на низкоуровневом языке ассемблера – это весомый навык для любого системного программиста. Он позволяет создавать более оптимизированный код, использовать недоступные в Си возможности и выполнять реверс-инжиниринг скомпилированного кода.
«Купи мне истребитель». Сбор средств для Воздушных Сил ВСУ
1. Что такое язык ассемблера? Для чего он нужен?
Язык ассемблера — это низкоуровневый язык программирования, который транслируется непосредственно в инструкции машинного языка. Трансляцию выполняет специальная программа — ассемблер (от англ. assembler — сборщик).
У каждого семейства процессоров есть собственный набор инструкций для выполнения различных операций, например для получения ввода с клавиатуры, вывода информации на экран и выполнения других действий. Эти наборы инструкций называются «инструкциями машинного языка».
Инструкции машинного языка представлены в виде последовательностей из нулей и единиц. Создавать программное обеспечение в виде таких последовательностей очень сложно для человека. Поэтому был разработан низкоуровневый язык сборки, в котором инструкции представлены в более понятной для человека форме.
Ассемблеры, как и их языки, предназначены для определенных семейств процессоров. Вместе с тем, они могут работать на разных платформах и в разных операционных системах. Также существуют кросс-ассемблеры, которые работают на одной архитектуре, а транслируют для другой.
Для Intel, например, самые популярные ассемблеры это MASM (входит в состав Visual Studio), FASM и TASM, которые достаточно современны и поддерживают Win32 API (хотя их развитие, нужно признать, уже пару лет как остановлено).
2. Пример кода на языке ассемблера
Для DOS
Для примера используем FASM (Flat Assembler). Редактор кода FEditor 2.0 предлагает следующий шаблон кода приложения типа «Hello World!» для DOS.
Это инструкции языка ассемблера как таковые. В этом приложении не используются макросы, которые широко применяются в современных приложениях на языке ассемблера. Рассмотрим вкратце, что делает эта программа.
Windows — консольное
Пример 64-разрядного консольного приложения типа « Hello World! » на FASM, опять же из примеров FEditor.
В результате компиляции получаем программу, которая выводит в консоли строку:
Windows— GUI
В FASM используются макросы, позволяющие сократить текст программы. Программа на ассемблере с макросами напоминает программу на высокоуровневом языке. Вот пример, поставляемый с FASM.
Он выводит диалоговое окно с сообщением, а затем, если будет нажата кнопка «Да», выводит еще одно диалоговое окно.
3. Синтаксис языка ассемблера
Синтаксис языка ассемблера зависит, в основном, от набора команд процессора. С другой стороны его определяют директивы определенного транслятора. Поэтому, например, для процессоров, совместимых с Intel, есть синтаксис Intel и синтаксис AT&T.
Метки
В машинном коде инструкции программы и данные располагаются в последовательно расположенных ячейках памяти. Команды выполняются в указанном порядке одна за другой, пока не будет вызвана команда перехода по иному указанному адресу, например для выполнения подпрограммы.
Адреса памяти, по которым будут осуществляться переходы, в ассемблере можно обозначать метками. Метки записываются с начала строки (с первой позиции) с двоеточием в конце. Команда перехода по метке может располагаться как до, так и после метки.
В приведенных выше примерах вы уже видели метки, задающие точку входа в программу:
Числовые константы
Процессоры «понимают» двоичный код. Ассемблеры позволяют использовать числа и в других системах счисления: десятичной, восьмеричной, шестнадцатеричной. Чтобы транслятор мог определить, в какой системе записано число, используется специальное представление чисел в разных системах счисления, причем эти представления для различных трансляторов могут отличаться друг от друга.
Десятичные* | Двоичные | Восьмеричные | Шестнадцатеричные | ||||||||||||||||||||||||||||||||||||||||||
TASM: по последнему символу (не зависит от регистра) | D | B | O по последнему символу (не зависит от регистра) | D по префиксу или последнему символу (не зависит от регистра) | D | B | O | H по префиксу или последнему символу (не зависит от регистра) | D** | B** * В перечисленных ассемблерах по умолчанию используется десятичная система счисления, поэтому постфикс/префикс можно не указывать, когда используется основание 10. ** В NASM может быть как постфиксом, так и префиксом. Ассемблер работает и с вещественными числами. Но это тема довольно сложная и обширная для обзорной статьи, оставим ее для более подробного знакомства с языком. Инструкции процессораКоманды ассемблера соответствуют командам семейства микропроцессоров, для которых они созданы. Это более удобная для человека запись команд — мнемокоды. Рассмотрим инструкции Intel-синтаксиса. Он используется в ассемблерах, которые мы уже упоминали: Borland Turbo Assembler (TASM), Microsoft Macro Assembler (MASM), Flat Assembler (FASM), Netwide Assembler (NASM), и во многих других ассемблерах. В нем используются такие команды: Формат записи команды ассемблера таков: ДирективыПрограмма содержит не только команды, но и директивы. Они не транслируются в команды процессора, а управляют работой транслятора, поэтому зависят именно от транслятора, а не от процессора. Директивы выполняют следующие функции: В рассмотренном выше примере встречались, в частности, такие директивы: 4. Достоинства и недостаткиДостоинства языка ассемблераНедостатки языка ассемблераПреимущества этого языка позволяют использовать его в рассмотренных ниже сферах. 5. Применение языка ассемблераУникальные характеристики языка ассемблера позволяют писать программы с такими возможностями, которые недоступны для других языков. Этот язык целесообразно использовать, когда необходимо обеспечить экономию памяти и быстродействие. Сферы примененияС помощью языка ассемблера создаются: Нелегальная сфера деятельностиБлагодаря возможностям дизассемблирования и реинжиниринга можно получить доступ к низкоуровневому коду программы. Это позволяет внедрить в нее собственный код: например вирус или обход защиты программы. В любом случае такие действия незаконны, если только вы не собираетесь обнаружить и устранить вирус или легально восстановить алгоритм работы программы, исходный код которой недоступен. Так вот, давайте попробуем дизассембилровать приведенное здесь консольное приложение для Windows. Для этого используем отличный дизассемблер IDA (Interactive DisAssembler). Уже сразу при открытии IDA Pro выяснила, что использовался процессор AMD. Но это еще цветочки. Она полностью раскрыла наши планы. Вот сегмент кода: Видно, что куда передавали и какие функции вызывали. Переменные dword_402017 и т. п. — это ссылки на переменные в сегменте данных. Имена переменных из нашего кода в машинном коде не сохраняются: они не нужны процессору. Это всего лишь условное обозначение адресов ячеек памяти. Дизассемблер дает им свои имена. А вот и наша переменная, и ссылка на ее использование в секции кода: Для удобства чтения мы можем переименовать ее, как было в нашем коде: Можем запустить процесс в отладчике и вычислить, как работает программа, наглядно увидеть переходы и так далее: Можем работать с трассировкой чтения, трассировкой записи и трассировкой выполнения. Выполнять программу до определенного места в пошаговом режиме. Просматривать перекрестные ссылки, и вообще делать все, что доступно в отладчике. Возможно, даже больше, чем в некоторых. Завершается наш код сегментом импорта: Красиво, с перекрестными ссылками, удобными сигнатурами функций на Си. На вкладке Hex View (шестнадцатеричное представление) можно просматривать шестнадцатеричное представление и редактировать код. Вызовем контекстное меню, щелкнув правой кнопкой мыши на w из слова world. Выберем Edit, Прямо по тексту наберем «IDA!», а оставшиеся буквы заменим на нули слева в шестнадцатеричном коде. Осталось применить изменения… потом сохранить — и… Дальнейшее освоение реинжиниринга остается на вашей совести 😉 Книга Криса Касперски «Искусство дизассемблирования» вам в помощь! Связывание программ на разных языках (линкинг)Часто язык ассемблера используется для написания фрагментов высокоуровневых программ. Когда фрагменты кода написаны на разных языках, их нужно связать между собой. Небольшие фрагменты кода можно вставлять непосредственно в код на другом языке. Например, в C++ это выглядит так: В этом случае связывание производится на этапе компиляции. Если же на языке ассемблера написан код с подпрограммами, данными и т. п., где реализуются возможности, не поддерживаемые в высокоуровневом языке, то связывание выполняется на этапе компоновки, а код на разных языках компилируется по отдельности. 6. Происхождение и критика языка сегодняЯзык ассемблера получил свое название от англ. слова assembler — «сборщик». Сборщиком он назван потому, что позволил автоматически «собирать» программу, а не вводить машинные коды вручную, например в виде чисел в восьмеричной системе счисления. (Это еще упрощенное объяснение. О реальном опыте программирования в машинных кодах в 60–70-х годах прошлого века можно узнать из этой интересной статьи.) Набор и отладка таких программ занимали много времени, разбираться в них было довольно сложно. Сложность росла и по мере роста объема этих программ, соответственно требовались большие расходы времени и денег. Возникла необходимость в упрощении работы для человека. Первые ассемблеры появились в 1947-м и 1948-м году (акторы — Кэтлин Бут и Дэвид Уиллер). Они предназначались для машин ARC2 и EDSAC. Ассемблеры позволили ускорить и упростить разработку. После ассемблеров появились и языки высокого уровня, компилируемые и интерпретируемые, со множеством библиотек. Некоторые из них очень приблизились к человеческому языку. Сейчас можно создавать игры, даже не зная языка программирования, перемещая блоки программы с помощью мыши. В наше время для разработки низкоуровневого ПО (например, драйверов) чаще используется Си и библиотеки доступа к Win32 API. Благодаря оптимизации кода Си может работать с эффективностью ассемблера. На ассемблере пишут фрагменты кода, которые используют возможности, недоступные для языков высокого уровня: фрагменты ядра ОС и системных библиотек, вызовы каких-то недокументированных функций и т.п.. Ассемблер дает возможность понять, что происходит за кулисами, как работает процессор, как распределяется память, и разобраться, что к чему. А если вы разбираетесь, то будет проще разрабатывать эффективные программы на языках высокого уровня. Язык ассемблера — это то, что дает программисту его могущество. Программисты используют ассемблер… AssemblerAssembler — язык программирования низкого уровня, представляющий собой формат записи машинных команд, удобный для восприятия человеком. Команды языка ассемблера один в один соответствуют командам процессора и, фактически, представляют собой удобную символьную форму записи (мнемокод) команд и их аргументов. Также язык ассемблера обеспечивает базовые программные абстракции: связывание частей программы и данных через метки с символьными именами и директивы. Директивы ассемблера позволяют включать в программу блоки данных (описанные явно или считанные из файла); повторить определённый фрагмент указанное число раз; компилировать фрагмент по условию; задавать адрес исполнения фрагмента, менять значения меток в процессе компиляции; использовать макроопределения с параметрами и др. Каждая модель процессора, в принципе, имеет свой набор команд и соответствующий ему язык (или диалект) ассемблера. Достоинства и недостаткиСинтаксисОбщепринятого стандарта для синтаксиса языков ассемблера не существует. Однако, существуют стандарты де-факто — традиционные подходы, которых придерживаются большинство разработчиков языков ассемблера. Основными такими стандартами являются Intel-синтаксис и AT&T-синтаксис. Общий формат записи инструкций одинаков для обоих стандартов: Опкод — непосредственно мнемоника инструкции процессору. К ней могут быть добавлены префиксы (повторения, изменения типа адресации и пр.). В качестве операндов могут выступать константы, названия регистров, адреса в оперативной памяти и пр.. Различия между стандартами Intel и AT&T касаются, в основном, порядка перечисления операндов и их синтаксиса при различных методах адресации. Используемые мнемоники обычно одинаковы для всех процессоров одной архитектуры или семейства архитектур (среди широко известных — мнемоники процессоров и контроллеров Motorola, ARM, x86). Они описываются в спецификации процессоров. ДирективыКроме инструкций, программа может содержать директивы: команды, не переводящиеся непосредственно в машинные инструкции, а управляющие работой компилятора. Набор и синтаксис их значительно разнятся и зависят не от аппаратной платформы, а от используемого компилятора (порождая диалекты языков в пределах одного семейства архитектур). В качестве набора директив можно выделить: Происхождение и критика термина «язык ассемблера»Данный тип языков получил свое название от названия транслятора (компилятора) с этих языков — ассемблера (англ. assembler — сборщик). Название последнего обусловлено тем, что на первых компьютерах не существовало языков более высокого уровня, и единственной альтернативой созданию программ с помощью ассемблера было программирование непосредственно в кодах. Язык ассемблера в русском языке часто называют «ассемблером» (а что-то связанное с ним — «ассемблерный»), что, согласно английскому переводу слова, неправильно, но вписывается в правила русского языка. Однако, сам ассемблер (программу) тоже называют просто «ассемблером», а не «компилятором языка ассемблера» и т. п. Использование термина «язык ассемблера» также может вызвать ошибочное мнение о существовании единого языка низкого уровня, или хотя бы стандарта на такие языки. При именовании языка, на котором написана конкретная программа, желательно уточнять, для какой архитектуры она предназначена и на каком диалекте языка написана. Руководство по симулятору простого ассемблера для 8-битного процессора на JavascriptПрограммирование на ассемблере Внутри этого процессора также есть несколько ячеек памяти, называемых флагами, каждый из которых занимает один бит памяти и используется для представления булевых значений. Таким образом, в любой момент времени каждый из этих 1-битных флагов имеет значение TRUE, либо FALSE. Эти регистры и флаги вместе определяют внутреннее состояние процессора в любой момент времени и служат для различных целей, о которых вы сейчас узнаете! Данный процессор имеет четыре регистра общего назначения, которые называются A, B, C и D. Они так называются (общего назначения), потому что программист сам решает, как их использовать. Часто бывает удобно и даже необходимо иметь место для временного хранения значений, которыми манипулируют, и вот тут-то и пригодятся эти регистры. Указатель инструкции показывает на следующую инструкцию программы в памяти, которая должна быть выполнена, а указатель стека — на текущую вершину стека (подробнее об этих двух флагах позже). Память Ввод/вывод ПрограммыПрограмма — это последовательность инструкций, которые указывают центральному процессору, что делать. Большинство инструкций состоит из операции и одного или нескольких операндов, в зависимости от операции. Операция — это как функция, встроенная в процессор и предоставляемая программисту для немедленного использования. Каждая операция имеет короткое запоминающееся имя, называемое мнемоникой. В письменном языке ассемблера операции обозначаются этой мнемоникой. Операнд — это как аргумент операции. Операндом может быть регистр процессора, ячейка памяти или литеральное значение. В конце концов, центральный процессор понимает только цифры. Когда вы нажимаете кнопку «Assemble», код ассемблера преобразуется в числовое представление программы, называемое машинным кодом, а затем помещается в память. Помимо мнемоники, каждая операция имеет соответствующее представление в числовой форме, называемое опкодом. Между каждой мнемоникой и каждым опкодом существует прямая корреляционная зависимость. Когда инструкция собрана в машинный код, мнемоники системно заменяются на соответствующие им опкоды. Режимы адресацииРежим адресации — это способ обращения к фактическому значению, используемому в качестве операнда. Непосредственная адресация — это когда значение указывается сразу после указания операции. Она называется непосредственной адресацией, потому что закодированное значение помещается непосредственно после опкода в машинном коде. Прямая адресация — это когда используемое значение находится где-то в памяти. Вместо прямого указания значения указывается адрес значения в памяти. Это называется прямой адресацией в отличие от косвенной адресации. Косвенная адресация — это когда используемое значение находится где-то в памяти, и его адрес также находится там. Вместо того чтобы указывать значение напрямую или указывать адрес значения в памяти, указывается адрес адреса. Это называется косвенной адресацией, потому что. ну. она довольно косвенная, вам не кажется 🙂 Она используется не так часто, как другие, но все же полезна в ситуациях, когда вы не знаете заранее, где находится значение. Стек — это структура данных, которая выглядит подобно обычной стопке предметов. Это структура LIFO (last in first out. последним пришёл — первым ушёл), то есть первым из стека достается то, что было помещено в него последним. Представьте себе стопку блоков. Единственный блок, который вы можете снять с вершины стека, это последний блок, который был туда помещен. Стек в памяти реализован как последовательность значений, составляющих элементы, плюс указатель стека. Указатель всегда показывает на то, что считается вершиной стека. Каждый раз, когда вы хотите загрузить элемент в стек (другими словами, поместить новый элемент на вершину стека), вы просто копируете значение в место в памяти, на которое указывает указатель стека, а затем увеличиваете (или уменьшаете, в зависимости от направления роста стека) указатель стека, чтобы он указывал на следующее свободное место, место прямо над элементом, который вы только что добавили в стек. Каждый раз, когда вы хотите убрать элемент из стека (другими словами, удалить последний элемент, который был помещен на вершину стека), вы просто перемещаете указатель стека обратно вниз. Если вам нужно значение, которое вы только что выгрузили, просто обратитесь к значению в памяти, на которое теперь показывает указатель стека. Центральный процессор предоставляет операции PUSH и POP, которые позволяют «заталкивать» и «выталкивать» значения в стек и из стека. При работе со стеками операции занесения и извлечения элемента являются основными. Программист сам решает, как использовать стек, но обычно он используется при временном сохранении значений, во время передачи аргументов между функциями или для отслеживания адресов возврата (подробнее об этом позже). Выполнение программыВ процессе работы центральный процессор выполняет инструкции в памяти одну за другой. Сначала он ищет инструкцию в памяти, на которую ссылается указатель. Это включает в себя опкод, а также любые значения байтов операндов, которые могут быть использованы в зависимости от операции. Затем он выполняет эту инструкцию, вероятно, воздействуя на внутреннее состояние процессора и/или содержимое памяти. В итоге указатель инструкции устанавливается в местоположение, следующее непосредственно за только что выполненной инструкцией, и процесс продолжается. При перезагрузке процессора указатель инструкций устанавливается в 0, что означает, что первой выполняемой инструкцией будет та, которая находится в самом начале памяти. Когда процессор запущен, он будет работать до тех пор, пока не встретит инструкцию остановки (HLT), в этот момент он зависнет. В качестве альтернативы вы можете выполнять по одной инструкции за раз (так называемый степпинг). Язык ассемблераИнструкция записывается на языке ассемблера, начиная с мнемоники, за которой следуют любые операнды, разделенные запятыми. Литеральные значения можно использовать в качестве операндов, просто включив в них численное значение или символ ASCII, заключив его в одинарные кавычки. Это непосредственная адресация. Вместо явного указания адресов памяти гораздо более распространенным и удобным является использование меток для обозначения ячеек памяти в программном коде. Вы можете поместить метку в ассемблерный код, чтобы отметить скомпонованный адрес того, что следует непосредственно за ним, написав имя, после которого следует двоеточие. Например, start: создает метку с именем «start». Затем вы можете использовать имя start в любом другом месте программы вместо адреса памяти для ссылки на это место в коде. Вы можете включать произвольные данные в свою программу с помощью директивы DB. Это означает «байт данных» и не является мнемоникой для операции процессора, а скорее служит инструкцией для ассемблера включить некоторые бинарные данные в эту точку, а не собирать там код. Это полезно для включения в программу предопределенных постоянных значений. Наконец, комментарии в языке ассемблера обычно обозначаются при помощью точки с запятой. Hello World!Давайте рассмотрим образец Hello World! Для справки здесь приведен код примера из симулятора: Как упоминалось ранее, когда процессор перезагружается, указатель инструкций устанавливается в 0, а это означает, что начало программы находится в верхней части файла. Поэтому первая инструкция в программе будет такой: Операция JMP просто устанавливает указатель инструкции на свой операнд. То есть она переходит к заданному месту в памяти и продолжает выполнение программы оттуда. В этом случае операндом является start — метка, обозначающая данную часть кода: После выполнения перехода следующая инструкция будет такой: Это необработанная строка «Hello World!», которая будет напечатана. Итак, теперь регистр C содержит местоположение строки, которую мы собираемся напечатать. Другими словами, регистр C указывает на строку. Следующая инструкция — это еще один MOV : Это помещает значение 232 в регистр D. 232 ($E8 в шестнадцатеричном исчислении) — это адрес памяти для нашего отображаемого в памяти дисплея вывода символов. Таким образом, если мы запишем строку в память, начиная с этого места, она появится на нашем дисплее. Напомним, что на данном этапе выполнения регистр C указывает на строку, которую мы хотим вывести на экран, а регистр D указывает на саму память дисплея. Все, что нам теперь нужно сделать, это скопировать строку, на которую указывает C, в место, на которое указывает D. Следующая инструкция — это вызов нашей функции print : Операция CALL очень похожа на JMP тем, что она также переходит к другому месту в памяти для дальнейшего выполнения. Разница лишь в том, что перед переходом она помещает текущее значение указателя инструкции в стек. Ячейка памяти, в которую был выполнен переход, предназначена для начала функции (на языке ассемблера она также называется сабрутиной (subroutine)). В ассемблере существует несколько способов передачи аргументов функциям. Способ, используемый здесь, заключается в предварительной загрузке регистров процессора значениями для передачи функции. В данном случае наша функция печати принимает два аргумента: указатель на строку, которую нужно напечатать, и указатель на ячейку в памяти, куда ее нужно напечатать. Она ожидает, что эти аргументы будут переданы через регистры C и D соответственно. Именно поэтому мы ранее загрузили эти регистры этими значениями! Таким образом, в этом случае вызывается функция, помеченная меткой print, поэтому адрес возврата помещается в стек, а затем указатель инструкции устанавливается на начало функции печати, и мы продолжаем оттуда. Первая пара инструкций в функции печати выглядит следующим образом: Это типично для пролога функции. Пролог функции — это некоторый начальный код в функции, который подготавливает стек и регистры процессора к использованию. В данном случае мы помещаем регистры A и B в стек, чтобы их значение сохранилось. Так мы поступаем потому, что тело этой функции собирается исказить содержимое регистров. Сначала сохранив их значения, затем мы сможем восстановить их перед возвратом из программы. Таким образом, любой части кода, вызывающей эту функцию, не придется беспокоиться о том, что содержимое регистров будет изменено после вызова функции. После вставки мы также инициализируем регистр B, чтобы он содержал значение 0. Скоро вы увидите, почему. Первое, что находится в теле нашей функции после пролога, — это цикл: Я заранее расскажу вам, что делает этот цикл: он копирует каждый символ из исходной строки в пункт назначения. Поскольку C в настоящее время указывает на исходную строку, первое, что нужно сделать, это захватить первый символ, символ по адресу указателя: Это копирует значение в памяти, на которое указывает содержимое регистра C, в регистр A. Следующим шагом будет копирование полученного символа на выходной дисплей, куда в данный момент указывает регистр D: Это копирует символ (в A) в память, на которую указывает D. Теперь, когда мы успешно скопировали первый символ на выходной дисплей, пришло время заняться следующим! Для этого нам просто нужно увеличить исходный и указатель назначения на единицу, чтобы мы могли получить следующий символ из исходной строки и записать его в следующую ячейку на дисплее символов. Операция INC делает именно это, она инкрементирует (увеличивает) содержимое регистра на единицу: Которая в данном случае является эпилогом функции! Эпилог функции — это аналог пролога функции. Здесь стек и регистры процессора подготовлены к возврату из функции. В нашем случае мы просто восстанавливаем ранее сохраненные значения регистров A и B, чтобы та часть кода, которая первоначально вызвала эту функцию, не знала, что регистры A и B вообще использовались. Это важно для случая, когда вызывающий код будет использовать A и B в своих собственных целях: Последней инструкцией нашей программы является HLT : Инструкция HLT останавливает работу процессора, обозначая завершение выполнения программы.
На занятии узнаем, что такое алфавит, грамматика, форма Бэкуса-Наура и попробуем построить формальное определение простейшего языка программирования. Рассмотрим ключевые стадии (лексический, синтаксический анализ), определения и алгоритмы разбора программ, описанных подобными грамматиками. Построим схему построения компилятора и реализуем отдельные части компилятора на Golang (C/Python). Ассемблер в Linux для программистов CСодержаниеВведение [ править ]
Эта книга ориентирована на программистов, которые уже знают Си на достаточном уровне. Почему так? Вряд ли, зная только несколько интерпретируемых языков вроде Perl или Python, кто-то захочет сразу изучать ассемблер. Используя Си и ассемблер вместе, применяя каждый язык для определённых целей, можно добиться очень хороших результатов. К тому же программисты Си уже имеют некоторые знания об архитектуре процессора, особенностях машинных вычислений, способе организации памяти и других вещах, которые новичку в программировании понять не так просто. Поэтому изучать ассемблер после Си несомненно легче, чем после других языков высокого уровня. В Си есть понятие «указатель», программист должен сам управлять выделением памяти в куче, и так далее — все эти знания пригодятся при изучении ассемблера, они помогут получить более целостную картину об архитектуре, а также иметь более полное представление о том, как выполняются их программы на Си. Но эти знания требуют углубления и структурирования. Следует подчеркнуть, что для чтения этой книги никаких знаний о Linux не требуется (кроме, разумеется, знаний о том, «как создать текстовый файл» и «как запустить программу в консоли»). Да и вообще, единственное, в чём выражается ориентированность на Linux, — это используемые синтаксис ассемблера и ABI. Программисты на ассемблере в DOS и Windows используют синтаксис Intel, но в системах *nix принято использовать синтаксис AT&T. Именно синтаксисом AT&T написаны ассемблерные части ядра Linux, в синтаксисе AT&T компилятор GCC выводит ассемблерные листинги и так далее. Большую часть информации из этой книги можно использовать для программирования не только в *nix, но и в Windows, нужно только уточнить некоторые системно-зависимые особенности (например, ABI). А стоит ли? [ править ]При написании кода на ассемблере всегда следует отдавать себе отчёт в том, действительно ли данный кусок кода должен быть написан на ассемблере. Нужно взвесить все «за» и «против», современные компиляторы умеют оптимизировать код, и могут добиться сравнимой производительности (в том числе большей, если ассемблерная версия, написанная программистом, изначально неоптимальна). Самый главный недостаток языка ассемблера — будущая непереносимость полученной программы на другие платформы. Как править этот викиучебник [ править ]Так как изначально этот учебник писался не в вики-формате, автор допускал повествование от первого лица. В вики такое не приветствуется, поэтому такие обороты нужно вычистить. При внесении первых правок насчёт архитектуры x86_64 (сейчас эта тема мало где освещена) нужно разграничить и чётко отметить все архитектурно-зависимые абзацы: что относится к IA-32, а что к x86_64, так как ABI (application binary interface) i386 и x86_64 отличаются. Архитектура [ править ]x86 или IA-32? [ править ]Вы, вероятно, уже слышали такое понятие, как «архитектура x86». Вообще оно довольно размыто, и вот почему. Само название x86 или 80×86 происходит от принципа, по которому Intel давала названия своим процессорам: Этот список можно продолжить. Принцип наименования, где каждому поколению процессоров давалось имя, заканчивающееся на 86, создал термин «x86». Но, если посмотреть внимательнее, можно увидеть, что «процессором x86» можно назвать и древний 16-битный 8086, и новый Core i7. Поэтому 32-битные расширения были названы архитектурой IA-32 (сокращение от Intel Architecture, 32-bit). Конечно же, возможность запуска 16-битных программ осталась, и она успешно (и не очень) используется в 32-битных версиях Windows. Мы будем рассматривать только 32-битный режим. Регистры [ править ]Регистр — это небольшой объем очень быстрой памяти, размещённой на процессоре. Он предназначен для хранения результатов промежуточных вычислений, а также некоторой информации для управления работой процессора. Так как регистры размещены непосредственно на процессоре, доступ к данным, хранящимся в них, намного быстрее доступа к данным в оперативной памяти. Регистры общего назначения (РОН, англ. General Purpose Registers, сокращённо GPR). Размер — 32 бита. Не следует бояться такого жёсткого закрепления назначения использования регистров. Большая их часть может использоваться для хранения совершенно произвольных данных. Единственный случай, когда нужно учитывать, в какой регистр помещать данные — использование неявно обращающихся к регистрам команд. Такое поведение всегда чётко документировано. В ОС Linux используется плоская модель памяти (flat memory model), в которой все сегменты описаны как использующие всё адресное пространство процессора и, как правило, явно не используются, а все адреса представлены в виде 32-битных смещений. В большинстве случаев программисту можно даже и не задумываться об их существовании, однако операционная система предоставляет специальные средства (системный вызов modify_ldt() ), позволяющие описывать нестандартные сегменты и работать с ними. Однако такая потребность возникает редко, поэтому тут подробно не рассматривается. Есть команды, которые устанавливают флаги согласно результатам своей работы: в основном это команды, которые что-то вычисляют или сравнивают. Есть команды, которые читают флаги и на основании флагов принимают решения. Есть команды, логика выполнения которых зависит от состояния флагов. В общем, через флаги между командами неявно передаётся дополнительная информация, которая не записывается непосредственно в результат вычислений. Указатель команды eip (instruction pointer). Размер — 32 бита. Содержит указатель на следующую команду. Регистр напрямую недоступен, изменяется неявно командами условных и безусловных переходов, вызова и возврата из подпрограмм. Стек [ править ]Мы полагаем, что читатель имеет опыт программирования на Си и знаком со структурами данных типа стек. В микропроцессоре стек работает похожим образом: это область памяти, у которой определена вершина (на неё указывает %esp ). Поместить новый элемент можно только на вершину стека, при этом новый элемент становится вершиной. Достать из стека можно только верхний элемент, при этом вершиной становится следующий элемент. У вас наверняка была в детстве игрушка-пирамидка, где нужно было разноцветные кольца надевать на общий стержень. Так вот, эта пирамидка — отличный пример стека. Также можно провести аналогию с составленными стопкой тарелками. На разных архитектурах стек может «расти» как в сторону младших адресов (принцип описан ниже, подходит для x86), так и старших. Стек растёт в сторону младших адресов. Это значит, что последний записанный в стек элемент будет расположен по адресу младше остальных элементов стека. При помещении нового элемента в стек происходит следующее (принцип работы команды push ): При выталкивании элемента из стека эти действия совершаются в обратном порядке(принцип работы команды pop ): Память [ править ]В Си после вызова malloc(3) программе выделяется блок памяти, и к нему можно получить доступ при помощи указателя, содержащего адрес этого блока. В ассемблере то же самое: после того, как программе выделили блок памяти, появляется возможность использовать указывающий на неё адрес для всевозможных манипуляций. Наименьший по размеру элемент памяти, на который может указать адрес, — байт. Говорят, что память адресуется побайтово, или гранулярность адресации памяти — один байт. Отдельный бит можно указать как адрес байта, содержащего этот бит, и номер этого бита в байте. Правда, нужно отметить ещё одну деталь. Программный код расположен в памяти, поэтому получить его адрес также возможно. Стек — это тоже блок памяти, и разработчик может получить указатель на любой элемент стека, находящийся под вершиной. Таким образом организовывают доступ к произвольным элементам стека. Порядок байтов. Little-endian и big-endian [ править ]
Вот эта байтовая последовательность располагается в оперативной памяти, адрес всего слова в памяти — адрес первого байта последовательности. Если первым располагается младший байт (запись начинается с «меньшего конца») — такой порядок байт называется little-endian, или «интеловским». Именно он используется в процессорах x86. Если первым располагается старший байт (запись начинается с «большего конца») — такой порядок байт называется big-endian. У порядка little-endian есть одно важное достоинство. Посмотрите на запись числа 0x00000033 : См. также [ править ]Hello, world! [ править ]Вспомним, как вы писали Hello, world! на Си. Скорее всего, приблизительно так: Вот только printf(3) — функция стандартной библиотеки Си, а не операционной системы. «Чем это плохо?» — спросите вы. Да, в общем, всё нормально, но, читая этот учебник, вы, вероятно, хотите узнать, что происходит «за кулисами» функций стандартной библиотеки на уровне взаимодействия с операционной системой. Это, конечно же, не значит, что из ассемблера нельзя вызывать функции библиотеки Си. Просто мы пойдём более низкоуровневым путём. А вот и сама программа: Напомним, сейчас наша задача — скомпилировать первую программу. Подробное объяснение этого кода будет потом. Если компиляция проходит успешно, GCC ничего не выводит на экран. Кроме компиляции, GCC автоматически выполняет и компоновку, как и при компиляции программ на C. Теперь запускаем нашу программу и убеждаемся, что она корректно завершилась с кодом возврата 0. Теперь было бы хорошо прочитать главу про отладчик GDB. Он вам понадобится для исследования работы ваших программ. Возможно, сейчас вы не всё поймёте, но эта глава специально расположена в конце, так как задумана больше как справочная, нежели обучающая. Для того, чтобы научиться работать с отладчиком, с ним нужно просто работать. Синтаксис ассемблера [ править ]Команды [ править ]Команды ассемблера — это те инструкции, которые будет исполнять процессор. По сути, это самый низкий уровень программирования процессора. Каждая команда состоит из операции (что делать?) и операндов (аргументов). Операции мы будем рассматривать отдельно. А операнды у всех операций задаются в одном и том же формате. Операндов может быть от 0 (то есть нет вообще) до 3. В роли операнда могут выступать: Почти у каждой команды можно определить операнд-источник (из него команда читает данные) и операнд-назначение (в него команда записывает результат). Общий синтаксис команды ассемблера такой: Для того, чтобы привести пример команды, я, немного забегая наперед, расскажу об одной операции. Команда mov источник, назначение производит копирование источника в назначение. Возьмем строку из hello.s : Важной особенностью всех команд является то, что они не могут работать с двумя операндами, находящимися в памяти. Хотя бы один из них следует сначала загрузить в регистр, а затем выполнять необходимую операцию. Как формируется указатель на ячейку памяти? Синтаксис: Вычисленный адрес будет равен база + индекс × множитель + смещение. Множитель может принимать значения 1, 2, 4 или 8. Например: Данные [ править ]Существуют директивы ассемблера, которые размещают в памяти данные, определенные программистом. Аргументы этих директив — список выражений, разделенных запятыми. Также существуют директивы для размещения в памяти строковых литералов: Приведём небольшую таблицу, в которой сопоставляются типы данных в Си на IA-32 и в ассемблере. Нужно заметить, что размер этих типов в языке Си на других архитектурах (или даже компиляторах) может отличаться.
Отдельных объяснений требует колонка «Выравнивание». Выравнивание задано у каждого фундаментального типа данных (типа данных, которым процессор может оперировать непосредственно). Например, выравнивание word — 4 байта. Это значит, что данные типа word должны располагаться по адресу, кратному 4 (например, 0x00000100, 0x03284478). Архитектура рекомендует, но не требует выравнивания: доступ к невыровненным данным может быть медленнее, но принципиальной разницы нет и ошибки это не вызовет. Второй и третий аргумент являются необязательными. Метки и прочие символы [ править ]Вы, наверно, заметили, что мы не присвоили имён нашим данным. Как же к ним обращаться? Очень просто: нужно поставить метку. Метка — это просто константа, значение которой — адрес. Значение метки как константы — это всегда адрес. А если вам нужна константа с каким-то другим значением? Тогда мы приходим к более общему понятию «символ». Символ — это просто некоторая константа. Причём он может быть определён в одном файле, а использован в других. Возьмём hello.s и скомпилируем его так: Например, определим символ foo = 42: Ещё пример из hello.s : Неинициализированные данные [ править ]Хорошо, но известные нам директивы размещения данных требуют указания инициализирующего значения. Поэтому для неинициализированных данных используются специальные директивы: Также эту директиву можно использовать для размещения инициализированных данных, для этого существует параметр заполнитель — этим значением будет инициализирована память. Методы адресации [ править ]Пространство памяти предназначено для хранения кодов команд и данных, для доступа к которым имеется богатый выбор методов адресации (около 24). Операнды могут находиться во внутренних регистрах процессора (наиболее удобный и быстрый вариант). Они могут располагаться в системной памяти (самый распространенный вариант). Наконец, они могут находиться в устройствах ввода/вывода (наиболее редкий случай). Определение местоположения операндов производится кодом команды. Причем существуют разные методы, с помощью которых код команды может определить, откуда брать входной операнд и куда помещать выходной операнд. Эти методы называются методами адресации. Эффективность выбранных методов адресации во многом определяет эффективность работы всего процессора в целом. Прямая или абсолютная адресация [ править ]Физический адрес операнда содержится в адресной части команды. Формальное обозначение: где Аi – код, содержащийся в i-м адресном поле команды. Непосредственная адресация [ править ]В команде содержится не адрес операнда, а непосредственно сам операнд. Непосредственная адресация позволяет повысить скорость выполнения операции, так как в этом случае вся команда, включая операнд, считывается из памяти одновременно и на время выполнения команды хранится в процессоре в специальном регистре команд (РК). Однако при использовании непосредственной адресации появляется зависимость кодов команд от данных, что требует изменения программы при каждом изменении непосредственного операнда. Косвенная (базовая) адресация [ править ]Адресная часть команды указывает адрес ячейки памяти или регистр, в котором содержится адрес операнда: Применение косвенной адресации операнда из оперативной памяти при хранении его адреса в регистровой памяти существенно сокращает длину поля адреса, одновременно сохраняя возможность использовать для указания физического адреса полную разрядность регистра. Недостаток этого способа – необходимо дополнительное время для чтения адреса операнда. Вместе с тем он существенно повышает гибкость программирования. Изменяя содержимое ячейки памяти или регистра, через которые осуществляется адресация, можно, не меняя команды в программе, обрабатывать операнды, хранящиеся по разным адресам. Косвенная адресация не применяется по отношению к операндам, находящимся в регистровой памяти. Предоставляемые косвенной адресацией возможности могут быть расширены, если в системе команд ЭВМ предусмотреть определенные арифметические и логические операции над ячейкой памяти или регистром, через которые выполняется адресация, например увеличение или уменьшение их значения. Автоинкрементная и автодекрементная адресация [ править ]Иногда, адресация, при которой после каждого обращения по заданному адресу с использованием механизма косвенной адресации, значение адресной ячейки автоматически увеличивается на длину считываемого операнда, называется автоинкрементной. Адресация с автоматическим уменьшением значения адресной ячейки называется автодекрементной. Регистровая адресация [ править ]Предполагается, что операнд находится во внутреннем регистре процессора. Относительная адресация [ править ]Этот способ используется тогда, когда память логически разбивается на блоки, называемые сегментами. В этом случае адрес ячейки памяти содержит две составляющих: адрес начала сегмента (базовый адрес) и смещение адреса операнда в сегменте. Адрес операнда определяется как сумма базового адреса и смещения относительно этой базы: Для задания базового адреса и смещения могут применяться ранее рассмотренные способы адресации. Как правило, базовый адрес находится в одном из регистров регистровой памяти, а смещение может быть задано в самой команде или регистре. Рассмотрим два примера: Главный недостаток относительной адресации – большое время вычисления физического адреса операнда. Но существенное преимущество этого способа адресации заключается в возможности создания «перемещаемых» программ – программ, которые можно размещать в различных частях памяти без изменения команд программы. То же относится к программам, обрабатывающим по единому алгоритму информацию, расположенную в различных областях ЗУ. В этих случаях достаточно изменить содержимое базового адреса начала команд программы или массива данных, а не модифицировать сами команды. По этой причине относительная адресация облегчает распределение памяти при составлении сложных программ и широко используется при автоматическом распределении памяти в мультипрограммных вычислительных системах. Команды ассемблера [ править ]Команда mov [ править ]Команда mov производит копирование источника в назначение. Рассмотрим примеры: Внимательно следите, когда вы загружаете адрес переменной, а когда обращаетесь к значению переменной по её адресу. Например: Команда lea [ править ]lea — мнемоническое от англ. Load Effective Address. Синтаксис: Команда lea помещает адрес источника в назначение. Источник должен находиться в памяти (не может быть непосредственным значением — константой или регистром). Например: Команды для работы со стеком [ править ]Предусмотрено две специальные команды для работы со стеком: push (поместить в стек) и pop (извлечь из стека). Синтаксис: GCC вернёт следующее: Интересный вопрос: какое значение помещает в стек вот эта команда Арифметика [ править ]Арифметических команд в нашем распоряжении довольно много. Синтаксис: Давайте подумаем, каким будет результат выполнения следующего кода на Си: Большинство сразу скажет, что результат (250 + 14 = 264) больше, чем может поместиться в одном байте. И что же напечатает программа? 8. Давайте рассмотрим, что происходит при сложении в двоичной системе. Этот код выдаёт правильную сумму в регистре %ax с учётом переполнения, если оно произошло. Попробуйте поменять числа в строках 2 и 3. Команда lea для арифметики [ править ]Вспомним, как формируется адрес операнда: Вычисленный адрес будет равен база + индекс × множитель + смещение. Чем это нам удобно? Так мы можем получить команду с двумя операндами-источниками и одним результатом: Вспомните, что при сложении командой add результат записывается на место одного из слагаемых. Теперь, наверно, стало ясно главное преимущество lea в тех случаях, где её можно применить: она не перезаписывает операнды-источники. Как вы это сможете использовать, зависит только от вашей фантазии: прибавить константу к регистру и записать в другой регистр, сложить два регистра и записать в третий… Также lea можно применять для умножения регистра на 3, 5 и 9, как показано выше. Команда loop [ править ]Напишем программу для вычисления суммы чисел от 1 до 10 (конечно же, воспользовавшись формулой суммы арифметической прогрессии, можно переписать этот код и без цикла — но ведь это только пример). На Си это выглядело бы так: Команды сравнения и условные переходы. Безусловный переход [ править ]Команда cmp выполняет вычитание операнд_1 – операнд_2 и устанавливает флаги. Результат вычитания нигде не запоминается. | Внимание! Обратите внимание на порядок операндов в записи команды: сначала второй, потом первый. |
Команды jcc не существует, вместо cc нужно подставить мнемоническое обозначение условия.
Мнемоника | Английское слово | Смысл | Тип операндов |
---|---|---|---|
e | equal | равенство | любые |
n | not | инверсия условия | любые |
g | greater | больше | со знаком |
l | less | меньше | со знаком |
a | above | больше | без знака |
b | below | меньше | без знака |
Таким образом, je проверяет равенство операндов команды сравнения, jl проверяет условие операнд_1 и так далее. У каждой команды есть противоположная: просто добавляем букву n :
Теперь пример использования этих команд:
Сравните с кодом на Си:
Кроме команд условного перехода, область применения которых ясна сразу, также существует команда безусловного перехода. Эта команда чем-то похожа на оператор goto языка Си. Синтаксис:
Эта команда передаёт управление на адрес, не проверяя никаких условий. Заметьте, что адрес может быть задан в виде непосредственного значения (метки), регистра или обращения к памяти.
Произвольные циклы [ править ]
Все инструкции для написания произвольных циклов мы уже рассмотрели, осталось лишь собрать всё воедино. Лучше сначала посмотрите код программы, а потом объяснение к ней. Прочитайте её код и комментарии и попытайтесь разобраться, что она делает. Если сразу что-то непонятно — не страшно, сразу после исходного кода находится более подробное объяснение.
Программа: поиск наибольшего элемента в массиве [ править ]
Этот код соответствует приблизительно следующему на Си:
Возможно, такой способ обхода массива не очень привычен для вас. В Си принято использовать переменную с номером текущего элемента, а не указатель на него. Никто не запрещает пойти этим же путём и на ассемблере:
Рассматривая код этой программы, вы, наверно, уже поняли, как создавать произвольные циклы с постусловием на ассемблере, наподобие do<> while(); в Си. Ещё раз повторю эту конструкцию, выкинув весь код, не относящийся к циклу:
эквивалентен такой конструкции:
Таким образом, нам достаточно и уже рассмотренных двух видов циклов.
Логическая арифметика [ править ]
Кроме выполнения обычных арифметических вычислений, можно проводить и логические, то есть битовые.
Команда not инвертирует каждый бит операнда (изменяет на противоположный), так же как и оператор языка Си
Команду test можно применять для сравнения значения регистра с нулём:
К логическим командам также можно отнести команды сдвигов:
Принцип работы команды shl :
Принцип работы команды shr :
Многие программисты Си знают об умножении и делении на степени двойки (2, 4, 8…) при помощи сдвигов. Этот трюк отлично работает и в ассемблере, используйте его для оптимизации.
Кроме сдвигов обычных, существуют циклические сдвиги:
Объясню на примере циклического сдвига влево на три бита: три старших («левых») бита «выдвигаются» из регистра влево и «вдвигаются» в него справа. При этом в флаг cf записывается самый последний «выдвинутый» бит.
Принцип работы команды rol :
Принцип работы команды ror :
Принцип работы команды rcl :
Принцип работы команды rcr :
Эти сложные циклические сдвиги вам редко понадобятся в реальной работе, но уже сейчас нужно знать, что такие инструкции существуют, чтобы не изобретать велосипед потом. Ведь в языке Си циклический сдвиг производится приблизительно так:
Подпрограммы [ править ]
Термином «подпрограмма» будем называть и функции, которые возвращают значение, и функции, не возвращающие значение ( void proc(…) ). Подпрограммы нужны для достижения одной простой цели — избежать дублирования кода. В ассемблере есть две команды для организации работы подпрограмм.
Используется для вызова подпрограммы, код которой находится по адресу метка. Принцип работы:
Существует несколько способов передачи аргументов в подпрограмму.
Рассмотрим передачу аргументов через стек подробнее. Предположим, нам нужно написать подпрограмму, принимающую три аргумента типа long (4 байта). Код:
Как видите, если идти от вершины стека в сторону аргументов, то мы будем встречать аргументы в обратном порядке по отношению к тому, как их туда поместили. Нужно сделать одно из двух: или помещать аргументы в обратном порядке (чтобы доставать их в прямом порядке), или учитывать обратный порядок аргументов в подпрограмме. В Си принято при вызове помещать аргументы в обратном порядке. Так как операционная система Linux и большинство библиотек для неё написаны именно на Си, для обеспечения переносимости и совместимости лучше использовать «сишный» способ передачи аргументов и в ваших ассемблерных программах.
Вы не можете делать никаких предположений о содержимом локальных переменных. Никто их для вас не инициализировал нулём. Можете для себя считать, что там находятся случайные значения.
Остаётся одна маленькая проблема: в стеке всё ещё находятся аргументы для подпрограммы. Это можно решить одним из следующих способов:
Обратите внимание на обратный порядок аргументов и очистку стека от аргументов.
Программа: печать таблицы умножения [ править ]
Рассмотрим программу посложнее. Итак, программа для печати таблицы умножения. Размер таблицы умножения вводит пользователь. Нам понадобится вызвать функцию scanf(3) для ввода, printf(3) для вывода и организовать два вложенных цикла для вычислений.
Программа: вычисление факториала [ править ]
Функция — на то и функция, что её можно заменить, при этом не изменяя вызывающий код. Для запуска следующего кода просто замените функцию из предыдущей программы вот этой новой версией:
Что же здесь изменено? Рекурсия переписана в виде цикла. Кадр стека больше не нужен, так как в стек ничего не перемещается и другие функции не вызываются. Пролог и эпилог поэтому убраны, при этом регистр %ebp не используется вообще. Но если бы он использовался, сначала нужно было бы сохранить его значение, а перед возвратом восстановить.
Умножение 64-битного числа на 32-битное делается как при умножении «в столбик»:
Но произведение %esi × %ecx не поместится в 32 бита, останутся ещё старшие 32 бита. Их мы должны прибавить к старшим 32-м битам результата. Приблизительно так вы это делаете на бумаге в десятичной системе:
Системные вызовы [ править ]
Программа, которая не взаимодействует с внешним миром, вряд ли может сделать что-то полезное. Вывести сообщение на экран, прочитать данные из файла, установить сетевое соединение — это всё примеры действий, которые программа не может совершить без помощи операционной системы. В Linux пользовательский интерфейс ядра организован через системные вызовы. Системный вызов можно рассматривать как функцию, которую для вас выполняет операционная система.
Следует отметить, что не следует использовать системные вызовы везде, где только можно, без особой необходимости. В разных версиях ядра порядок аргументов у некоторых системных вызовов может отличаться, и это приводит к ошибкам, которые довольно трудно найти. Поэтому стоит использовать функции стандартной библиотеки Си, ведь их сигнатуры не изменяются, что обеспечивает переносимость кода на Си. Почему бы нам не воспользоваться этим и не «заложить фундамент» переносимости наших ассемблерных программ? Только если вы пишете маленький участок самого нагруженного кода и для вас недопустимы накладные расходы, вносимые вызовом стандартной библиотеки Си, — только тогда стоит использовать системные вызовы напрямую.
В качестве примера можете посмотреть код программы Hello world.
Структуры [ править ]
Объявляя структуры в Си, вы не задумывались о том, как располагаются в памяти её элементы. В ассемблере понятия «структура» нет, зато есть «блок памяти», его адрес и смещение в этом блоке. Объясню на примере:
0x23 | 0x72 | 0x45 | 0x17 |
Приблизительно так в ассемблере организована работа со структурами: к базовому адресу структуры прибавляется смещение, по которому находится нужный элемент. Теперь вопрос: как определить смещение? В Си компилятор руководствуется следующими правилами:
Примеры (внизу указано смещение элементов в байтах; заполнители обозначены XX ):
Обратите внимание на два последних примера: элементы структур одни и те же, только расположены в разном порядке. Но размер структур получился разный!
Программа: вывод размера файла [ править ]
Напишем программу, которая выводит размер файла. Для этого потребуется вызвать функцию stat(2) и прочитать данные из структуры, которую она заполнит. man 2 stat :
Значит, sizeof(dev_t) = 8.
Обратите внимание на обработку ошибок: если передано не 2 аргумента — выводим описание использования программы и выходим, если stat(2) вернул ошибку — выводим сообщение об ошибке и выходим.
Наверное, могут возникнуть некоторые сложности с пониманием, как расположены argc и argv в стеке. Допустим, вы запустили программу как
Тогда стек будет выглядеть приблизительно так:
Программа: печать файла наоборот [ править ]
Напишем программу, которая читает со стандартного ввода всё до конца файла, а потом выводит введённые строки в обратном порядке. Для этого мы во время чтения будем помещать строки в связный список, а потом пройдем этот список в обратном порядке и напечатаем строки.
Для того, чтобы послать с клавиатуры сигнал о конце файла, нажмите Ctrl-D.
Поэтому, используя текстовые метки, приходится каждый раз придумывать уникальное имя. А можно использовать метки-числа, компилятор преобразует их в уникальные имена сам. Чтобы поставить метку, просто используйте любое положительное число в качестве имени. Чтобы сослаться на метку, которая определена ранее, используйте Nb (мнемоническое значение — backward), а чтобы сослаться на метку, которая определена дальше в коде, используйте Nf (мнемоническое значение — forward).
Операции с цепочками данных [ править ]
«Странно», — скажет кто-то, — «откуда эти команды знают, где брать данные и куда их записывать? Ведь у них и аргументов-то нет!» Вспомните про регистры %esi и %edi и про их немного странные имена: «индекс источника» (англ. source index) и «индекс приёмника» (англ. destination index). Так вот, все цепочечные команды подразумевают, что в регистре %esi находится указатель на следующий необработанный элемент цепочки-источника, а в регистре %edi — указатель на следующий элемент цепочки-приёмника.
Направление просмотра цепочки задаётся флагом df : 0 — просмотр вперед, 1 — просмотр назад.
Итак, команда lods загружает элемент из цепочки-источника в регистр %eax / %ax / %al (размер регистра выбирается в зависимости от суффикса команды). После этого значение регистра %esi увеличивается или уменьшается (в зависимости от направления просмотра) на значение, равное размеру элемента цепочки.
Команда stos записывает содержимое регистра %eax / %ax / %al в цепочку-приёмник. После этого значение регистра %edi увеличивается или уменьшается (в зависимости от направления просмотра) на значение, равное размеру элемента цепочки.
Вот пример программы, которая работает с цепочечными командами. Конечно же, она занимается бестолковым делом, но в противном случае она была бы гораздо сложнее. Она увеличивает каждый байт строки str_in на 1, то есть заменяет a на b, b на с, и так далее.
Но с цепочками мы часто выполняем довольно стандартные действия. Например, при копировании блоков памяти мы просто пересылаем байты из одной цепочки в другую, без обработки. При сравнении строк мы сравниваем элементы двух цепочек. При вычислении длины строки в Си мы считаем байты до тех пор, пока не встретим нулевой байт. Эти действия очень просты, но, в тоже время, используются очень часто, поэтому были введены следующие команды:
Размер элементов цепочки, которые обрабатывают эти команды, зависит от использованного суффикса команды.
Команда movs выполняет копирование одного элемента из цепочки-источника в цепочку-приёмник.
После того, как эти команды выполнили своё основное действие, они увеличивают/уменьшают индексные регистры на размер элемента цепочки.
Подчеркну тот факт, что эти команды обрабатывают только один элемент цепочки. Таким образом, нужно организовать что-то вроде цикла для обработки всей цепочки. Для этих целей существуют префиксы команд:
Также следует указать команды для управления флагом df :
Пример: memcpy [ править ]
Вооружившись новыми знаниями, попробуем заново изобрести функцию memcpy(3) :
Пример: strlen [ править ]
В заключение обсуждения цепочечных команд нужно сказать следующее: не следует заново изобретать стандартные функции, как мы это только что сделали. Это всего лишь пример и объяснение принципов их работы. В реальных программах используйте цепочечные команды, только когда они реально смогут помочь при нестандартной обработке цепочек, а для стандартных операций лучше вызывать библиотечные функции.
Конструкция switch [ править ]
Оператор switch языка Си можно переписать на ассемблере разными способами. Рассмотрим несколько вариантов того, какими могут быть значения у case:
Рассмотрим решение для первого случая. Вспомним, что команда jmp принимает адрес не только в виде непосредственного значения (метки), но и как обращение к памяти. Значит, мы можем осуществлять переход на адрес, вычисленный в процессе выполнения. Теперь вопрос: как можно вычислить адрес? А нам не нужно ничего вычислять, мы просто поместим все адреса case-веток в массив. Пользуясь проверяемым значением как индексом массива, выбираем нужный адрес case-ветки. Таким образом, процессор всё вычислит за нас. Посмотрите на следующий код:
Этот код эквивалентен следующему коду на Си:
Единственное, на что хочется обратить внимание, — на расположение ветки default : если все сравнения оказались ложными, код default выполняется автоматически.
Наконец, третий, комбинированный, вариант. Путь имеем варианты 35, 36, 37, 39, 1200, 1600 и 7000. Тогда мы видим промежуток [35; 39] и ещё три числа. Код будет выглядеть приблизительно так:
В этом примере, как и в предыдущих, имеет смысл переставить некоторые части этого кода в начало, если вы заранее знаете, какие значения вам придётся обрабатывать чаще всего.
Пример: интерпретатор языка Brainfuck [ править ]
Brainfuck — это эзотерический язык программирования, то есть язык, предназначенный не для практического применения, а придуманный как головоломка, как задача, которая заставляет программиста думать нестандартно. Команды Brainfuck управляют массивом целых чисел с неограниченным набором ячеек, есть понятие текущей ячейки.
В начальном состоянии все ячейки содержат значение 0, а текущей является крайняя левая ячейка.
Вот несколько программ с объяснениями:
Вот программы, которые вызовут ошибки загрузки:
А эти программы вызовут ошибки выполнения:
Булевы выражения [ править ]
Рассмотрим такой код на языке Си:
В принципе, булево выражение можно вычислять как обычное арифметическое, то есть в такой последовательности:
Такой способ вычисления выражений называется сокращённым (от англ. short-circuit evaluation), потому что позволяет вычислить выражение, не проверяя всех входящих в него подвыражений. Можно вывести такие формальные правила:
В принципе, сокращённое вычисление булевых выражений помогает написать более быстрый (а часто и более простой) код. С другой стороны, возникают проблемы, если одно из подвыражений при вычислении вызывает побочные эффекты (англ. side effects), например вызов функции:
Во многих языках высокого уровня сокращённое вычисление выражений требуется от компилятора стандартом языка (например, в Си). Однако, обычно задаются более строгие правила вычислений. В большинстве стандартов языков требуется, чтобы выражения соединённые оператором OR (или AND) вычислялись строго слева направо, и если очередное значение будет true (соответственно, false для AND), то вычисление данной цепочки OR-ов (AND-ов) прекращается. Но нужно отметить, что первый пример в этой главе всё равно является корректным с точки зрения стандарта Си (хотя c == 0 стоит в конце выражения, а вычисляется первым), так как сравнение локальных переменных не вызывает побочных эффектов и компилятор вправе реорганизовать код таким образом.
Теперь перейдём к тому, как это реализовывается на ассемблере. Начнём с полного вычисления:
Требуется заметить, что команды setcc работают только с операндами (хранящимися в регистрах и памяти) размером один байт.
Тогда полное вычисление будет выглядеть так:
Обратите внимание, что команда or устанавливает флаги, и нам не нужно отдельно сравнивать %al с нулём.
Как видите, этот код является не только более коротким, но и завершает своё исполнение, как только результат становится известен. Таким образом, сокращённое вычисление намного быстрее полного.
См. также [ править ]
Отладчик GDB [ править ]
Начать отладку можно с определения точки останова (breakpoint), если вы уже приблизительно знаете, какой участок кода нужно исследовать. Этот способ используется чаще всего: ставим точку останова, запускам программу и проходим её выполнение по шагам, попутно наблюдая за необходимыми переменными и регистрами. Вы также можете просто запустить программу под отладчиком и поймать момент, когда она аварийно завершается из-за segmentation fault, — так можно узнать, какая инструкция пытается получить доступ к памяти, подробнее рассмотреть приводящую к ошибке переменную и так далее. Теперь можно исследовать этот код ещё раз, пройти его по шагам, поставив точку останова чуть раньше момента сбоя.
GDB запустился, загрузил исследуемую программу, вывел на экран приглашение (gdb) и ждёт команд. Мы хотим пройти программу «по шагам» (single-step mode). Для этого нужно указать команду, на которой программа должна остановиться. Можно указать подпрограмму — тогда остановка будет осуществлена перед началом исполнения инструкций этой подпрограммы. Ещё можно указать имя файла и номер строки.
GDB остановил программу и ждёт команд. Вы видите команду вашей программы, которая будет выполнена следующей, имя функции, которая сейчас исполняется, имя файла и номер строки. Для пошагового исполнения у нас есть две команды: step (сокращённо s ) и next (сокращённо n ). Команда step производит выполнение программы с заходом в тела подпрограмм. Команда next выполняет пошагово только инструкции текущей подпрограммы.
Команда list (сокращённо l ) выводит на экран исходный код вашей программы. В качестве аргументов ей можно передать:
Если передавать один аргумент, команда list выведет 10 строк исходного кода вокруг этого места. Передавая два аргумента, вы указываете строку начала и строку конца листинга.
Так, а кроме регистров у нас ведь есть ещё и память, и частный случай памяти — стек. Как просмотреть их содержимое? Команда x/формат адрес отображает содержимое памяти, расположенной по адресу в заданном формате. Формат — это (в таком порядке) количество элементов, буква формата и размер элемента. Буквы формата: o(octal), x(hex), d(decimal), u(unsigned decimal), t(binary), f(float), a(address), i(instruction), c(char) и s(string). Размер: b(byte), h(halfword), w(word), g(giant, 8 bytes). Например, напечатаем 14 символов строки hello_str :
То же самое, только в шестнадцатеричном виде:
Напечатаем 8 верхних слов (4 байта) из стека (для «погружения в стек» читаем слева направо и сверху вниз):