Мысли о программирование на ассемблере. Погружение в assembler. Полный курс по программированию на асме от ][. Пример из другой области

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

Краткое описание языков Ассемблера

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

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

Кратко о структуре языка

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

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

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

Язык Ассемблера имеет несколько синтаксисов, которые будут рассмотрены в статье.

Плюсы языка

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

Драйвера, операционные системы, BIOS, компиляторы, интерпретаторы и т. д. - это все программа на языке Ассемблера.

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

Минусы языка

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

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

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

Команды языка

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


Использование директив

Программирование микроконтроллеров на языке (Ассемблер это позволяет и прекрасно справляется с функционированием) самого низкого уровня в большинстве случаев заканчивается удачно. Лучше всего использовать процессоры с ограниченным ресурсом. Для 32-разрядной техники данный язык подходит отлично. Часто в кодах можно заметить директивы. Что же это? И для чего используется?

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


Происхождение названия

Благодаря чему получил название язык - "Ассемблер"? Речь идет о трансляторе и компиляторе, которые и производят зашифровку данных. С английского Assembler означает не что иное, как сборщик. Программа не была собрана вручную, была использована автоматическая структура. Более того, на данный момент уже у пользователей и специалистов стерлась разница между терминами. Часто Ассемблером называют языки программирования, хотя это всего лишь утилита.

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

Макросредства

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

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

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

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

Комментарии в ваших программах оставляются после точки с запятой. Точно также как в дельфи или си через //.

Числа в ассемблере могут представляться в двоичной, десятеричной или шестнадцатеричной системе. Для того, чтобы показать в какой системе использовано число надо поставить после числа букву. Для бинарной системы пишется буква b (пример: 0000010b, 001011010b), для десятеричной системы можно ничего не указывать после числа или указать букву d (примеры: 4589, 2356d), для шестнадцатеричной системы надо указывать букву h, шестнадцатеричное число надо обязательно писать с нулём в начале (примеры: 00889h, 0AC45h, 056Fh, неправильно F145Ch, С123h).

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

Mov приемник, источник

Вы можете копировать значение из одного регистра в другой.

Mov edx, ecx

Вышеприведенная команда копирует содержание ecx в edx. Размер источника и приемника должны быть одинаковыми,

например: эта команда - НЕ допустима:

Mov al, ecx ; не правильно

Этот опкод пытается поместить DWORD (32-битное) значение в байт (8 битов). Это не может быть сделано mov командой (для этого есть другие команды).

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

Mov al, bl mov cl, dl mov cx, dx mov ecx, ebx

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

смещение 34 35 36 37 38 39 3A 3B 3C 3D 3E 3F 40 41 42
данные 0D 0A 50 32 44 57 25 7A 5E 72 EF 7D FF AD C7
(Каждый блок представляет байт)

Значение смещения обозначено здесь как байт, но на самом деле это это - 32-разрядное значение. Возьмем для примера 3A, это также - 32-разрядное значение: 0000003Ah. Только, чтобы с экономить пространство, некоторые используют маленькие смещения.

Посмотрите на смещение 3A в таблице выше. Данные на этом смещении - 25, 7A, 5E, 72, EF, и т.д. Чтобы поместить значение со смещения 3A, например, в регистр, вы также используете команду mov:

Mov eax, dword ptr

Означает: поместить значение с размером DWORD (32-бита) из памяти со смещением 3Ah в регистр eax. После выполнения этой команды, eax будет содержать значение 725E7A25h. Возможно вы заметили, что это - инверсия того что находится в памяти: 25 7A 5E 72. Это потому, что значения сохраняются в памяти, используя формат little endian . Это означает, что самый младший байт сохраняется в наиболее значимом байте: порядок байтов задом на перед. Я думаю, что эти примеры покажут это:

  • dword (32-бит) значение 10203040 шестнадцатиричное сохраняется в памяти как: 40, 30, 20, 10
  • word (16-бит) значение 4050 шестнадцатиричное сохраняется в памяти как: 50, 40

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

Mov cl, byte ptr ; cl получит значение 0Dh mov dx, word ptr ; dx получит значение 7DEFh

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

Byte - 1 байт Word - 2 байта Dword - 4 байта

Иногда размер можно не указывать:

Mov eax,

Так как eax - 32-разрядный регистр, ассемблер понимает, что ему также требуется 32-разрядное значение, в данном случае из памяти со смещением 403045h.

Можно также непосредственные значения:

Mov edx, 5006h

Эта команда просто запишет в регистр edx, значение 5006. Скобки, [ и ], используются, для получения значения из памяти (в скобках находится смещение), без скобок, это просто непосредственное значение.

Можно также использовать регистр как ячейку памяти (он должен быть 32-разрядным в 32-разрядных программах):

Mov eax, 403045h ; пишет в eax значение 403045 mov cx, ; помещает в регистр CX значение (размера word) из памяти; указанной в EAX (403045)

В mov cx, , процессор сначала смотрит, какое значение (= ячейке памяти) содержит eax, затем какое значение находится в той ячейке памяти, и помещает это значение (word, 16 бит, потому что приемник, cx, является 16-разрядным регистром) в CX.

Стековые операции - PUSH, POP. Перед тем, как рассказать вам о стековых операциях, я уже объяснял вам, что такое стек. Стек это область в памяти, на которую указывает регистр стека ESP. Стек это место для хранения адресов возврата и временных значений. Есть две команды, для размещения значения в стеке и извлечения его из стека: PUSH и POP. Команда PUSH размещает значение в стеке, т.е. помещает значение в ячейку памяти, на которую указывает регистр ESP, после этого значение регистра ESP увеличивается на 4. Команда Pop извлекает значение из стека, т.е. извлекает значение из ячейки памяти, на которую указывает регистр ESP, после этого уменьшает значение регистра ESP на 4. Значение, помещенное в стек последним, извлекается первым. При помещении значения в стек, указатель стека уменьшается, а при извлечении - увеличивается. Рассмотрим пример:

(1) mov ecx, 100 (2) mov eax, 200 (3) push ecx ; сохранение ecx (4) push eax (5) xor ecx, eax (6) add ecx, 400 (7) mov edx, ecx (8) pop ebx (9) pop ecx

  • 1: поместить 100 в ecx
  • 2: поместить 200 в eax
  • 3: разместить значение из ecx (=100) в стеке (размещается первым)
  • 4: разместить значение из eax (=200) в стеке (размещается последним)
  • 5/6/7: выполнение операций над ecx, значение в ecx изменяется
  • 8: извлечение значения из стека в ebx: ebx станет 200 (последнее размещение, первое извлечение)
  • 9: извлечение значения из стека в ecx: ecx снова станет 100 (первое размещение, последнее извлечение)

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

(стек здесь заполнен нулями, но в действительности это не так, как здесь). ESP стоит в том месте, на которое он указывает)

Mov ax, 4560h push ax mov cx, FFFFh push cx pop edx edx теперь 4560FFFFh.

Вызов подпрограмм возврат из них - CALL, RET. Команда call передает управление ближней или дальней процедуре с запоминанием в стеке адреса точки возврата. Команда ret возвращает управление из процедуры вызывающей программе, адрес возврата получает из стека. Пример:

Code.. call 0455659 ..more code.. Код с адреса 455659: add eax, 500 mul eax, edx ret

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

Подробнее: команда call помещает регистр EIP (указатель на следующюю команду, которая должна быть выполнена) в стек, а команда ret извлекает его и передаёт управление этому адресу. Вы также можете определить аргументы, для вызываемой программы (процедуры). Это можно сделать через стек:

Push значение_1 push значение_2 call procedure

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

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

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

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

Назад | Оглавление |

Оставить комментарий

Комментарии

стёк растёт в низ и чем он больше тем ESP меньше

То есть как то так?:
|| типа пустой стёк
V
| |ESP
| |6
| |5
| |4
| |3
| |2
| |1
| |0

После кода
push 0fh
push 0ffh

|| типа стёк
V
|0fh|7
|0ffh|6
| |ESP
| |4
| |3
| |2
| |1
| |0

Если это так то
какое значение имеет ESP когда стёк пуст?

Оригинал: Get started in assembly language. Part 1
Автор: Mike Saunders
Дата публикации: 30 октября 2015 г.
Перевод: А.Панин
Дата перевода: 10 ноября 2015 г.

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

Для чего это нужно?

  • Для понимания принципов работы компиляторов.
  • Для понимания инструкций центрального процессора.
  • Для оптимизации вашего кода в плане производительности.

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

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

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

Ваша первая программа на языке ассемблера

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

Некоторые текстовые редакторы, такие, как Vim, осуществляют подсветку синтаксиса языка ассемблера (попробуйте использовать команду set syn=nasm )

Скопируйте следующий код в в текстовое поле любого текстового редактора и сохраните его в файле с именем myfirst.asm в вашей домашней директории:

Section .text global _start _start: mov ecx, message mov edx, length mov ebx, 1 mov eax, 4 int 0x80 mov eax, 1 int 0x80 section .data message db "Assembly rules!", 10 length equ $ - message

(Примечание: для отступов в коде вы можете использовать как как символы пробелов, так и символы табуляции - это не имеет значения.) Данная программа просто выводит строку "Assembly rules!" на экран и завершает работу.

Инструмент, который мы будем использовать для преобразования данного кода языка ассемблера в исполняемый бинарный файл носит довольно забавное название "ассемблер". Существует много различных ассемблеров, но моим любимым ассемблером является NASM; он находится в репозитории пакетов программного обеспечения практически любого дистрибутива, поэтому вы можете установить его с помощью менеджера пакетов программного обеспечения с графическим интерфейсом, команды yum install nasm , apt-get install nasm или любой другой команды, актуальной для вашего дистрибутива.

Теперь откройте окно эмулятора терминала и введите следующие команды:

Nasm -f elf -o myfirst.o myfirst.asm ld -m elf_i386 -o myfirst myfirst.o

Первая команда предназначена для генерации с помощью NASM (исполняемого) файла объектного кода с именем myfirst.o формата ELF (формат исполняемых файлов, используемый в Linux). Вы можете спросить: "Для чего генерируется файл объектного кода, ведь логичнее сгенерировать файл с инструкциями центрального процессора, которые он должен исполнять?" Ну, вы могли бы использовать исполняемый файл с инструкциями центрального процессора в операционных системах 80-х годов, но современные операционные системы предъявляют больше требований к исполняемым файлам. Бинарные файлы формата ELF включают информацию для отладки, они позволяют разделить код и данные благодаря наличию отдельных секций, что позволяет предотвратить переписывание данных в этих секциях.

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

Взгляд в прошлое

На данный момент в нашем распоряжении имеется файл myfirst.o с исполняемым кодом нашей программы. При этом процесс сборки программы еще не завершен; с помощью линковщика ld мы должны связать код из этого файла со специальным системным кодом запуска программ (т.е., шаблонным кодом, который исполняется при запуске каждой программы) для генерации исполняемого файла с именем myfirst . (Параметр elf_i386 описывает тип бинарного формата - в данном случае это означает, что вы можете использовать 32-битный ассемблерный код даже если вы используете 64-битный дистрибутив.)

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

В результате вы должны увидеть вывод: "Assembly rules!". Это означает, что вы добились своего - создали полноценную независимую программу для Linux, код которой написан полностью на языке ассемблера. Разумеется, данная программа не выполняет каких-либо полезных действий, но при этом она является отличным примером, демонстрирующим структуру программы на языке ассемблера и позволяющим проследить процесс преобразования исходного кода в бинарный файл.

Перед тем, как мы перейдем к углубленному изучению кода, было бы неплохо узнать размер бинарного файла нашей программы. После выполнения команды ls -l myfirst вы увидите, что размер бинарного файла равен примерно 670 байтам. Теперь оценим размер эквивалентной программы на языке C:

#include int main() { puts("Assembly rules!"); }

Если вы сохраните этот код в файле с именем test.c , скомпилируете его (gcc -o test test.c ) и рассмотрите параметры результирующего бинарного файла с именем test , вы обнаружите, что этот файл имеет гораздо больший размер - 8.4k. Вы можете удалить из этого файла отладочную информацию (strip -s test ), но и после этого его размер сократится незначительно, лишь до 6 k. Это объясняется тем, что компилятор GCC добавляет большой объем упомянутого выше кода для запуска и завершения работы приложения, а также связывает приложение с библиотекой языка программирования C большого размера. Благодаря данному примеру несложно сделать вывод о том, что язык ассемблера является лучшим языком программирования для разработки приложений, предназначенных для эксплуатации в условиях жесткого ограничения объема носителя данных.

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

Дизассемблирование кода

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

Objdump -d -M intel myfirst

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

Mov ecx,0x80490a0

В процессе ассемблирования NASM заменил метку строки "message" на числовое значение, соответствующее расположению этой строки в секции данных бинарного файла. Таким образом, результаты дизассемблирования бинарных файлов менее полезны, чем их оригинальный код, ведь в них отсутствуют такие вещи, как комментарии и строки, но они все же могут оказаться полезными для ознакомления с реализациями критичных к времени исполнения функций или взлома систем защиты приложений. Например, в 80-х и 90-х годах многие разработчики использовали инструменты для дизассемблирования программ с целью идентификации и нейтрализации систем защиты от копирования игр.

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

Анализ кода

А теперь давайте обсудим назначение каждой из строк кода нашей программы. Начнем с этих двух строк:

Section .text global _start

Это не инструкции центрального процессора, а директивы ассемблера NASM ; первая директива сообщает о том, что приведенный ниже код должен быть расположен в секции кода "text" финального исполняемого файла. Немного неочевидным является тот факт, что секция с названием "text" содержит не обычные текстовые данные (такие, как наша строка "Assembly rules!"), а исполняемый код, т.е., инструкции центрального процессора. Далее расположена директива global _start , сообщающая линковщику ld о том, с какой точки должно начаться исполнение кода из нашего файла. Эта директива может оказаться особенно полезной в том случае, если мы захотим начинать исполнение кода не с самого начала секции кода, а из какой-либо заданной точки. Параметр global позволяет читать данную директиву не только ассемблеру, но и другим инструментам, поэтому она обрабатывается линковщиком ld .

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

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

Mov ecx, message

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

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

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

Это 32-х битный регистр (следовательно, он может хранить числа из диапазона от 0 до 4,294,967,295). При рассмотрении следующих строк кода вы увидите, что мы также работаем с регистрами edx , ebx и eax - это регистры общего назначения, которые могут использоваться для выполнения любых задач, в отличие от специализированных регистров, с которыми мы познакомимся в следующем месяце. А это небольшое пояснение для тех, кому не терпится узнать о происхождении имен регистров: регистр ecx носил имя c во время выпуска 8-ми битных процессоров, после чего был переименован в сх для хранения 16-и битных значений и в ecx для хранения 32-х битных значений. Таким образом, несмотря на то, что имена регистров в настоящее время выглядят немного странно, во времена выпуска старых центральных процессоров разработчики использовали регистры общего назначения с отличными именами a , b , c и d .

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

Одним из вопросов, которые мы будем рассматривать в следующем месяце, является вопрос использования стека, поэтому мы подготовим вас к его рассмотрению прямо сейчас. Стек является областью памяти, в которой могут храниться временные значения тогда, когда необходимо освободить регистры для других целей. Но наиболее важной возможностью стека является способ хранения данных в нем: вы будете "помещать" ("push") значения в стек и "извлекать" ("pop") их из него. В стеке используется принцип LIFO (last in, first out - первый вошел, последний вышел), следовательно, последнее добавленное в стек значение будет первым извлечено из него.

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

Push 2 push 5 push 10 pop eax pop ebx pop ecx

После исполнения этих шести инструкций регистр eax будет содержать значение 10, регистр ebx - значение 5 и регистр ecx - значение 2. Таким образом, использование стека является отличным способом временного освобождения регистров; если, к примеру, в регистрах eax и ebx имеются важные значения, но вам необходимо выполнить текущую работу перед их обработкой, вы можете поместить эти значения в стек, выполнить текущую работу и извлечь их из стека, вернувшись к предыдущему состоянию регистров.

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

Двигаемся дальше

Вернемся к коду: инструкция mov перемещает (на самом деле, копирует) число из одного места в другое, справа налево. Таким образом, в данном случае мы говорим: "следует поместить message в регистр ecx ". Но что такое "message"? Это не другой регистр, это указатель на расположение данных. Ближе концу кода в секции данных "data" вы можете обнаружить метку message , после которой следует параметр db , указывающий на то, что вместо метки message в коде должно быть размещено несколько байт. Это очень удобно, так как нам не придется выяснять точное расположение строки "Assembly rules!" в секции данных - мы можем просто сослаться на нее с помощью метки message . (Число 10 после нашей строки является всего лишь символом перехода на новую строку, аналогичным символу \n , добавляемому к строкам при работе с языком программирования C).

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

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

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

Length equ $ - message

В данной строке используется другая метка length , но вместо параметра db для связывания этой метки с какими-либо данными, мы используем параметр equ для того, чтобы сообщить, что данная метка является эквивалентом чего-либо (это немного похоже на директиву препроцессора #define в языке программирования C). Символ доллара соответствует текущей позиции в коде, поэтому в данном случае мы говорим: "метка length должна быть эквивалентна текущей позиции в коде за вычетом расположения строки с меткой "message"".

Вернемся к секции кода приложения, в которой мы размещаем данное значение в регистре edx :

Mov edx, length

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

Теперь мы крайне близки к осуществлению системного вызова, но остался еще один регистр, который должен быть заполнен. Ядро ОС может выполнять большое количество различных операций, таких, как монтирование файловых систем, чтение данных из файлов, удаление файлов и других. Соответствующие механизмы активируются с помощью упомянутых системных вызовов и перед тем, как мы передадим управление ядру ОС, нам придется сообщить ему, какой из системных вызовов следует использовать. На странице вы можете ознакомиться с информацией о некоторых системных вызовах, доступных программам - в нашем случае необходим системный вызов sys_write ("запись данных в дескриптор файла") с номером 4. Поэтому мы разместим его номер в регистре eax :

И это все! Мы выполнили все необходимые приготовления для осуществления системного вызова, поэтому сейчас мы просто передадим управление ядру ОС следующим образом:

Инструкция int расшифровывается как "interrrupt" ("прерывание") и буквально прерывает поток исполнения данной программы, переходя в пространство ядра ОС. (В данном случае используется шестнадцатеричное значение 0x80 - пока вам не следует беспокоиться о нем.) Ядро ОС осуществит вывод строки, на которую указывает значение в регистре ecx , после чего вернет управление нашей программе.

Для завершения исполнения программы следует осуществить системный вызов sys_exit , который имеет номер 1. Поэтому мы размещаем данный номер в регистре eax , снова прерываем исполнение нашей программы, после чего ядро ОС аккуратно завершает исполнение нашей программы и мы возвращаемся к приветствию командной оболочки. Можно сказать, что вы выполнили поставленную задачу: реализовали завершенную (хотя и очень простую) программу на языке ассемблера, код которой разработан вручную без использования каких-либо объемных библиотек.

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

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

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

Если вы столкнулись с трудностями и нуждаетесь в помощи, заходите на наш форум по адресу http://forums.linuxvoice.com - автор руководства будет рядом и с удовольствием направит вас по правильному пути. Удачного программирования!

После многих лет занятия чем не попадя, решил вернуться к истокам. К программированию. Опять же, ввиду множества «современных достижений» в этой области было трудно определиться, чего же на самом деле не хватет, за что взяться чтобы было и приятно и полезно. Попробовав много чего понемногу, все же решил вернуться туда, куда тянуло с первых дней знакомства с компьютером (еще с копией творения сэра Синклера) – к программированию на ассемблере. На самом деле, в свое время Ассемблер я знал достаточно неплохо (в данном случае говорю про x86), но почти 15 лет ничего на нем не писал. Таким образом это своеобразное возвращение «блудного сына».
Но тут поджидало первое разочарование. Найденные на просторах Интернета книги, руководства и прочие справочники по ассемблеру, к моему глубокому сожалению, содержат минимум информации о том, как надо программировать на ассемблере, почему именно так, и что это дает.

Пример из другой области

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


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

Идилия?

Маленькое замечание, далее по тексту будет использована классификация, отличающаяся от распространненной в настоящее время. Однако это не является поводом для «споров о цвете истины», просто в данном виде проще объяснить точку зрения автора на программирование.

Итак, на сегодняшний день, казалось бы, для программистов наступила эпоха счастья. Огромный выбор средств на все случаи жизни и пожелания. Тут тебе и миллионы «фреймворков»/«паттернов»/«шаблонов»/«библиотек» и тысячи средств «облегчающих» программирование, сотни языков и диалектов, десятки методологий и различные подходы у программированию. Бери – нехочу. Но не «берется». И дело не в религиозных убеждениях, а в том, что это все выглядит как попытка питаться чем-то невкусным. При желании и усердии можно приноровиться и к этому, конечно. Но, возвращаясь к программированию, в большинстве из предлагаемого не видно технической красоты – видно лишь множество «костылей». Как результат, при использовании этих «достижения», из-под «кисти художников» вместо завораживающих пейзажей выходит сплошная «абстракция», или лубки - если повезет. Неужели большинство программистов такие бездари, неучи и имеют проблемы на уровне генетики? Нет, не думаю. Так в чем же причина?
На сегодняшний день имеется множество идей и способов программирования. Рассмотрим наиболее «модные» из них.

  • Императивное программирование – в данном подходе программист задает последовательность действий, приводящих к решению задачи. В основе лежит разделение программы на части, выполняющие логически независимые операции (модули, функции, процедуры). Но в отличии от типизированного подхода (см. ниже) тут есть важная особенность – отсутствие «типизации» переменных. Иными словами отсутствует понятие «тип переменной», вместо него используется понимание, что значения у одной и той же переменной могут иметь различный тип. Яркими представителем данного подхода являются Basic, REXX, MUMPS.
  • Типизированное программирование – модификация императивного программирования, когда программист и система ограничивают возможные значения переменных. Из наиболее известных языков - это Pascal, C.
  • Функциональное программирование – это более математический способ решения задачи, когда решение состоит в «конструировании» иерархии функций (и соответственно создание отсутствующих из них), приводящей к решению задачи. Как примеры: Lisp, Forth.
  • Автоматное программирование – подход, где программист строит модель/сеть, состоящую из обменивающихся сообщениями объектов/исполнительных элементов, как изменяющих/хранящих свое внутреннее «состояние» так и могущих взаимодействовать с внешним миром. Иными словами это то, что обычно называют «объектное программирование» (не объектно-ориентированное). Этот способ программирования представлен в Smalltalk.
А как-же множество других языков? Как правило, это уже «мутанты». Например, смешение типизированного и автоматного подхода дало «объектно-ориентированное программирование».

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

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

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

Чем дальше в лес, тем толще

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

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

Еще один пример из другой области

Как яркий пример системного подхода можно привести производство грузовиков в США. В данном случае, производитель грузовика – это просто изготовитель рамы и кабины + сборщик конструктора. Все остальное (двигатель, трансмиссия, подвеска, электрооборудование и т.д.) берется исходя из пожеланий заказчика. Захотел один заказчик получиться себе некий Kenworth с двигателем от Detroit Diesel, ручной коробкой Fuller, рессорной подвеской от какой-нибудь Dana – пожалуйста. Понадобилась другу этого заказчика та же модель Kenworth, но с «родным» двигателем Paccar, коробкой-автоматом Allison и пневмоподвеской от другого производителя – легко! И так делают все сборщики грузовиков в США. То есть грузовик – это система, в котором каждый модуль может быть заменен на другой, того же назначения и безпроблемно состыкован с уже имеющимися. Причем способ стыковки модулей сделан с максимально доступной универсальностью и удобством дальнейшего расширения функционала. Вот к чему должен стремиться инженер.

К сожалению, нам придется жить с тем, что есть, но в дальнейшем подобного следует избегать. Итак, программа – это, по сути, набор модулей (невожно как они называются, и как себя «ведут»), компонуя которые мы добиваемся решения стоящей задачи. Для эффективности крайне желательно, чтобы можно было эти модули использовать повторно. Причем не просто использовать любой ценой, а использовать удобным способом. И вот тут нас ждет очередной неприятный «сюрприз». Большинство языков высокого уровня оперируют такими структурными единицами как «фунция» и «процедура». И, как способ взяимодействия с ними, применяется «передача параметров». Это вполне логично, и тут никаких вопросов не возникает. Но как всегда, «важно не то, что делается – важно как делается» (ц). И вот тут начинается самое непонятное. На сегодня распространены 3 способа организации передачи параметров: cdecl , stdcall , fastcall . Так вот, ни один из этих способов не является «родным» для x86. Более того, все они ущербны с точки зрения расширения функционала вызываемых подпрограмм. То есть, увеличив количество передаваемых параметров, мы вынуждены менять все точки вызова этой функции/подпрограммы, или же плодить новую подпрограмму с похожим функционалом, которая будет вызываться немного иным способом.

Указанные выше методы передачи параметров относительно неплохо работают на процессорах с 2мя раздельными стеками (стеком данных, и стеком адресов/управления) и развитыми командами манипулирования стеком (хотя бы индексное обращение к элементам стека). Но при программировании на x86 приходится сначала извращаться при передаче/получении параметров, а потом не забыть «структурное» их удаление из стека. Попутно стараясь угадать/рассчитать максимальную глубину стека. Напомним, что x86 (16/32 битный режим), это процессор, у которого:

  • специализированные регистры (РОНы – регистры общего назначения – как таковые отсутствуют: то есть, мы не можем одной командой умножить содержимое регистра GS на значение из EDI и результат получить в паре EDX:ECX, или же разделить значение из пары регистров EDI:ESI на содержимое регистра EAX);
  • регистров мало;
  • один стек;
  • ячейка памяти не дает никакой информации от типа хранящегося там значения.
Иначе говоря, методы программирования, используемые для процессоров с большим регистровым файлом, с поддержкой нескольких независимых стеков и т.д. в большинстве своем не применимы при программировании на x86.

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

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

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

Что делать?

Предварительные выводы с учетом 15ти летного перерыва в программировании на ассемблере.
Во-первых, по поводу модулей или частей программы. В общем случае стоит выделить два вида исполнительных модулей программы на языке ассемблера – «операция» и «подпрограмма».
  • «Операцией» будем называть модуль, выполняющий «атомарное» действие и не требующий для своего выполнения множества параметров (например, операция очистки всего экрана, или операция расчета медианы числового ряда и т.п.).
  • «Подпрограммой» же стоит назвать фунциональный модуль, требующий, для корректного функционирования, множество входных параметров (больше 2х-3х).
И тут стоит оценить опыт императивных и функциональных языков. Они нам подарили 2 ценных инструмента, которыми стоит воспользоваться: «структура данных» (или, на примере REXX – составные/дополняемые переменные) и «немутабельность данных».

Полезно также следовать правилу немутабельности – то есть неизменности передаваемых параметров. Подпрограмма не может (не должна) менять значения в передаваемой ей структуре и результат возврашает либо в регистрах (не более 2х-3х параметров), либо также в новой, создаваемой структуре. Таким образом мы избавлены от необходимости делать копии структур, на случай «забытого» изменения данных подпрограммами, и можем использовать уже созданную структуру целиком или основную ее часть для вызова нескольких подпрограмм, оперирующих одним/схожим набором параметров. Более того, практически «автоматом» приходим к очередному «функциональному» правилу – внутренней контексто-независимости подпрограмм и операций. Иными словами - к разделению состояния/данных от метода/подпрограммы их обработки (в отличие от автоматной модели). В случаях параллельного программирования, а также совместного использования одной подпрограммы мы избавляемся как от необходимости плодить множество контекстов исполнения и следить за их «непересечением», так и от создания множества экземляров одной подпрограмм с разными «состояниями», в случае нескольких ее вызовов.

Что касается «типов» данных, то тут можно как оставить «все как есть», а можно тоже не изобретать велосипеда и воспользоваться тем, что давно используют разработчики трансляторов императивных языков – «идентификатор типа значения». То есть все данные, поступающие из внешнего мира анализируются и каждому полученному значению присваивается идентификатор обрабатываемого типа (целое, с плавающей точкой, упакованное BCD, код символа и т.д.) и размер поля/значения. Имея эту информацию, программист, с одной стороны, не загоняет пользователя в излишне узкие рамки «правил» ввода значений, а с другой - имеет возможность в процессе работы выбрать наиболее эффективный способ обработки данных пользователя. Но, повторюсь еще раз, это касается только работы с пользовательскими данными.

Это были общие соображения о программировании на ассемблере, не касающиеся вопросов проектирования, отладки и обработки ошибок. Надеюсь что разработчикам ОС, которые пишут их с 0-ля (а тем более на ассемблере), будет о чем подумать и они выберут (пусть не описанные выше, а любые иные) способы сделать программирование на ассемблере более систематизированным, удобным и приятным, а не будут слепо копировать чужие, зачастую безнадежно «кривые» варианты.

Программирование на языке ассемблера

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

Все процессы в машине на самом низком, аппаратном уровне приводятся в действие только командами (инструкциями) машинного языка. Язык ассемблера – это символическое представление машинного языка . Ассемблер позволяет писать короткие и быстрые программы. Однако этот процесс чрезвычайно трудоёмкий. Для написания максимально эффективной программы необходимо хорошее знание особенностей команд языка ассемблера, внимание и аккуратность. Поэтому реально на языке ассемблера пишутся в основном программы, которые должны обеспечить эффективную работу с аппаратной частью. Также на языке ассемблера пишутся критичные по времени выполнения или расходованию памяти участки программы. Впоследствии они оформляются в виде подпрограмм и совмещаются с кодом на языке высокого уровня.

1. Регистры

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

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

1.1. Регистры общего назначения

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

Как видно из рисунка, регистры ESI, EDI, ESP и EBP позволяют обращаться к младшим 16 битам по именам SI, DI, SP и BP соответственно, а регистры EAX, EBX, ECX и EDX позволяют обращаться как к младшим 16 битам (по именам AX, BX, CX и DX), так и к двум младшим байтам по отдельности (по именам AH/AL, BH/BL, CH/CL и

Названия регистров происходят от их назначения:

EAX/AX/AH/AL (accumulator register) – аккумулятор;

EBX/BX/BH/BL (base register ) –регистр базы;

ECX/CX/CH/CL (counter register) – счётчик;

EDX/DX/DH/DL (data register ) – регистр данных;

ESI/SI (source index register ) – индекс источника;

EDI/DI (destination index register ) – индекс приёмника (получателя);

ESP/SP (stack pointer register ) – регистр указателя стека;

EBP/BP (base pointer register ) – регистр указателя базы кадра стека.

Несмотря на существующую специализацию, все регистры можно использовать в любых машинных операциях. Однако надо учитывать тот факт, что некоторые команды работают только с определёнными регистрами. Например, команды умножения и деления используют регистры EAX и EDX для хранения исходных данных и результата операции. Команды управления циклом используют регистр ECX в качестве счётчика цикла.

Ещё один нюанс состоит в использовании регистров в качестве базы , т.е. хранилища адреса оперативной памяти. В качестве регистров базы можно использовать любые регистры, но желательно использовать регистры EBX, ESI, EDI или EBP. В этом случае размер машинной команды обычно бывает меньше.

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

1.2. Указатель команд

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

1.3. Регистр флагов

Флаг – это бит, принимающий значение 1 («флаг установлен»), если выполнено некоторое условие, и значение 0 («флаг сброшен») в противном случае. Процессор имеетрегистр флагов , содержащий набор флагов, отражающий текущее состояние процессора.

Обозначение

Название

Флаг перено

Зарезервиро

Флаг чётност

Зарезервиро

Auxiliary Carry Flag

Вспомогател

Зарезервиро

Флаг нуля

Флаг знака

Флаг трассир

Interrupt Enable Flag

Флаг разреш

Флаг направ

Флаг перепо

I/O Privilege Level

Уровень при

Флаг вложен

Зарезервиро

Флаг возобн

Virtual-8086 Mode

Режим вирту

Проверка вы

Virtual Interrupt Flag

Виртуальны

Virtual Interrupt Pending

Ожидающее

Проверка на

Зарезервиро

Значение флагов CF, DF и IF можно изменять напрямую в регистре флагов с помощью специальных инструкций (например, CLD для сброса флага направления), но нет инструкций, которые позволяют обратиться к регистру флагов как к обычному регистру. Однако можно сохранять

регистр флагов в стек или регистр AH и восстанавливать регистр флагов из них с помощью инструкций LAHF ,SAHF ,PUSHF ,PUSHFD ,POPF иPOPFD .

1.3.1. Флаги состояния

Флаги состояния (биты 0, 2, 4, 6, 7 и 11) отражают результат выполнения арифметических инструкций, таких как ADD ,SUB ,MUL ,DIV .

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

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

Вспомогательный флаг переноса AF устанавливается при переносе из бита 3-го результата/заёме в 3-ий бит результата. Этот флаг ориентирован на использование в двоично-десятичной (binary coded decimal, BCD) арифметике.

Флаг нуля ZF устанавливается, если результат равен нулю.

Флаг знака SF равен значению старшего значащего бита результата, который является знаковым битом в знаковой арифметике.

Флаг переполнения OF устанавливается, если целочисленный результат слишком длинный для размещения в целевом операнде (регистре или ячейке памяти). Этот флаг показывает наличие переполнения в знаковой целочисленной арифметике.

Из перечисленных флагов только флаг CF можно изменять напрямую с помощью инструкций STC ,CLC иCMC .

Флаги состояния позволяют одной и той же арифметической инструкции выдавать результат трёх различных типов: беззнаковое, знаковое и двоично-десятичное (BCD) целое число. Если результат считать беззнаковым числом, то флаг CF показывает условие переполнения (перенос или заём), для знакового результата перенос или заём показывает флаг OF, а для BCD-результата перенос/заём показывает флаг AF. Флаг SF отражает знак знакового результата, флаг ZF отражает и беззнаковый, и знаковый нулевой результат.

В длинной целочисленной арифметике флаг CF используется совместно с инструкциями сложения с переносом (ADC ) и вычитания с заёмом (SBB ) для распространения переноса или заёма из одного вычисляемого разряда длинного числа в другой.

Инструкции

условного

перехода Jcc (переход

условию cc ),SETcc (установить

значение

байта-результата

зависимости

условия cc ),LOOPcc (организация

и CMOVcc (условное

копирование)

используют

один или несколько

флагов состояния для проверки условия. Например, инструкция перехода JLE (jump if less or equal – переход, если «меньше или равно») проверяет условие «ZF = 1 или SF ≠ OF».

Флаг PF был введён для совместимости с другими микропроцессорными архитектурами и по прямому назначению используется редко. Более распространено его использование совместно с остальными флагами состояния в арифметике с плавающей запятой: инструкции сравнения (FCOM ,FCOMP и т. п.) в математическом сопроцессоре устанавливают в нём флаги-условия C0, C1, C2 и C3, и эти флаги можно скопировать в регистр флагов. Для этого рекомендуется использовать инструкциюFSTSW AX для сохранения слова состояния сопроцессора в регистре AX и инструкциюSAHF для последующего копирования содержимого регистра AH в младшие 8 битов регистра флагов, при этом C0 попадает во флаг CF, C2 – в PF, а C3 – в ZF. Флаг C2 устанавливается, например, в случае несравнимых аргументов (NaN или неподдерживаемый формат) в инструкции сравненияFUCOM .

1.3.2. Управляющий флаг

Флаг направления DF (бит 10 в регистре флагов) управляет строковыми инструкциями (MOVS ,CMPS ,SCAS ,LODS иSTOS ) – установка флага заставляет уменьшать адреса (обрабатывать строки от старших адресов к младшим), обнуление заставляет увеличивать адреса. ИнструкцииSTD иCLD соответственно устанавливают и сбрасывают флаг DF.

1.3.3. Системные флаги и поле IOPL

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

Флаг разрешения прерываний IF – обнуление этого флага запрещает отвечать на маскируемые запросы на прерывание.

Флаг трассировки TF – установка этого флага разрешает пошаговый режим отладки, когда после каждой выполненной

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

Поле IOPL показывает уровень приоритета ввода-вывода исполняемой программы или задачи: чтобы программа или задача могла выполнять инструкции ввода-вывода или менять флаг IF, её текущий уровень приоритета (CPL) должен быть ≤ IOPL.

Флаг вложенности задач NT – этот флаг устанавливается, когда текущая задача «вложена» в другую, прерванную задачу, и сегмент состояния TSS текущей задачи обеспечивает обратную связь с TSS предыдущей задачи. Флаг NT проверяется инструкцией IRET для определения типа возврата – межзадачного или внутризадачного.

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

VM – установка этого флага в защищённом режиме вызывает переключение в режим виртуального 8086.

Флаг проверки выравнивания AC – установка этого флага вместе с битом AM в регистре CR0 включает контроль выравнивания операндов при обращениях к памяти: обращение к невыравненному операнду вызывает исключительную ситуацию.

VIF – виртуальная копия флага IF; используется совместно с флагом VIP.

VIP – устанавливается для указания наличия отложенного прерывания.

ID – возможность программно изменить этот флаг в регистре флагов указывает на поддержку инструкции CPUID.

1.4. Сегментные регистры

Процессор имеет 6 так называемых сегментных регистров: CS, DS, SS, ES, FS и GS. Их существование обусловлено спецификой организации и использования оперативной памяти.

16-битные регистры могли адресовать только 64 Кб оперативной памяти, что явно недостаточно для более или менее приличной программы. Поэтому память программе выделялась в виде несколькихсегментов , которые имели размер 64 Кб. При этомабсолютные адреса были 20-битными, что позволяло адресовать уже 1 Мб оперативной памяти. Возникает вопрос – как имея 16-битные регистры хранить 20-битные адреса? Для решения этой задачи адрес разбивался набазу исмещение . База – это адрес начала сегмента, а смещение – это номер байта внутри сегмента. На адрес начала сегмента накладывалось ограничение – он должен был быть кратен 16. При этом последние 4 бита были равны 0 и не хранились, а подразумевались. Таким образом, получались две 16-битные части адреса. Для получения

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

Сегментные регистры использовались для хранения адреса начала сегмента кода (CS – code segment),сегмента данных (DS – data segment) исегмента стека (SS – stack segment). Регистры ES, FS и GS были добавлены позже. Существовало несколько моделей памяти, каждая из которых подразумевала выделение программе одного или нескольких сегментов кода и одного или нескольких сегментов данных:tiny ,small ,medium ,compact ,large иhuge . Для команд языка ассемблера существовали определённые соглашения: адреса перехода сегментировались по регистру CS, обращения к данным сегментировались по регистру DS, а обращения к стеку – по регистру SS. Если программе выделялось несколько сегментов для кода или данных, то приходилось менять значения в регистрах CS и DS для обращения к другому сегменту. Существовали так называемые «ближние» и «дальние» переходы. Если команда, на которую надо совершить переход, находилась в том же сегменте, то для перехода достаточно было изменить только значение регистра IP. Такой переход называлсяближним . Если же команда, на которую надо совершить переход, находилась в другом сегменте, то для перехода необходимо было изменить как значение регистра CS, так и значение регистра IP. Такой переход называлсядальним и осуществлялся дольше.

32-битные регистры позволяют адресовать 4 Гб памяти, что уже достаточно для любой программы. Каждую Win32-программу Windows запускает в отдельном виртуальном пространстве. Это означает, что каждая Win32-программа будет иметь 4-х гигабайтовое адресное пространство, но вовсе не означает, что каждая программа имеет 4 Гб физической памяти, а только то, что программа может обращаться по любому адресу в этих пределах. А Windows сделает все необходимое, чтобы память, к которой программа обращается, «существовала». Конечно, программа должна придерживаться правил, установленных

Windows, иначе возникает ошибка General Protection Fault.

Под архитектурой Win32 отпала необходимость в разделении адреса на базу и смещение, и необходимость в моделях памяти. На 32-

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

1.5. Использование стека

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

Пусть у нас есть функция f1 , которая вызывает функциюf2 , а функцияf2 , в свою очередь, вызывает функциюf3 . При вызове функцииf1 ей отводится определённое место в стеке под локальные данные. Это место отводится путём вычитания из регистра ESP значения, равного размеру требуемой памяти. Минимальный размер отводимой памяти равен 4 байтам, т.е. даже если процедуре требуется 1 байт, она должна занять 4 байта.

Функция f1 выполняет некоторые действия, после чего вызывает

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

которое было до её вызова.

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

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

Локальные данные автоматически не инициализируются. Если в вышеприведённом примере функция f2 после функцииf3 вызовет функциюf4 , то функцияf4 займёт в стеке место, которое до этого было занято функциейf3 , таким образом, функцииf4 «в наследство» достанутся данные функцииf3 . Поэтому каждая процедура обязательно должна заботиться об инициализации своих локальных данных.

2. Основные понятия языка ассемблера

2.1. Идентификаторы

Понятие идентификатора в языке ассемблера ничем не отличается от понятия идентификатора в других языках. Можно использовать латинские буквы, цифры и знаки _ . ? @ $ , причём точка может быть только первым символом идентификатора. Большие и маленькие буквы считаются эквивалентными.

2.2. Целые числа

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




Top