Лекция 3: Синтаксис ассемблера

Команды

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

· Конкретное значение, известное на этапе компиляции, - например, числовая константа или символ. Записываются при помощи знака $, например: $0xf1, $10, $hello_str. Эти операнды называются непосредственными.

· Регистр. Перед именем регистра ставится знак %, например: %eax, %bx, %cl.

· Указатель на ячейку в памяти (как он формируется и какой имеет синтаксис записи - далее в этом разделе).

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

Лекция 3: Синтаксис ассемблера - student2.ru Внимание! Если вы забудете знак $, когда записываете непосредственное числовое значение, компилятор будет интерпретировать число как абсолютный адрес. Это не вызовет ошибок компиляции, но, скорее всего, приведёт к ошибке сегментации (segmentation fault) при выполнении.

Почти у каждой команды можно определить операнд-источник (из него команда читает данные) и операнд-назначение (в него команда записывает результат). Общий синтаксис команды ассемблера такой:

Операция Источник, Назначение

Для того, чтобы привести пример команды, я, немного забегая наперед, расскажу об одной операции. Команда mov источник, назначение производит копирование источника в назначение. Возьмем строку из hello.s:

movl $4, %eax /* поместить номер системного вызова

write = 4 в регистр %eax */

Как видим, источник - это непосредственное значение 4, а назначение - регистр %eax. Суффикс l в имени команды указывает на то, что ей следует работать с операндами длиной в 4 байта. Все суффиксы:

· b (от англ. byte) - 1 байт,

· w (от англ. word) - 2 байта,

· l (от англ. long) - 4 байта,

· q (от англ. quad) - 8 байт.

Таким образом, чтобы записать $42 в регистр %al (а он имеет размер 1 байт):

movb $42, %al

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

Как формируется указатель на ячейку памяти? Синтаксис:

смещение(база, индекс, множитель)

Вычисленный адрес будет равен база + индекс ? множитель + смещение. Множитель может принимать значения 1, 2, 4 или 8. Например:

· (%ecx) адрес операнда находится в регистре %ecx. Этим способом удобно адресовать отдельные элементы в памяти, например, указатель на строку или указатель на int;

· 4(%ecx) адрес операнда равен %ecx + 4. Удобно адресовать отдельные поля структур. Например, в %ecx адрес некоторой структуры, второй элемент которой находится "на расстоянии" 4 байта от её начала (говорят "по смещению 4 байта");

· -4(%ecx) адрес операнда равен %ecx ? 4;

· foo(,%ecx,4) адрес операнда равен foo + %ecx ? 4, где foo - некоторый адрес. Удобно обращаться к элементам массива. Если foo - указатель на массив, элементы которого имеют размер 4 байта, то мы можем заносить в %ecx номер элемента и таким образом обращаться к самому элементу.

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

.text

movl $42, %eax

...

Данные

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

· .byte - размещает каждое выражение как 1 байт;

· .short - 2 байта;

· .long - 4 байта;

· .quad - 8 байт.

Например:

.byte 0x10, 0xf5, 0x42, 0x55

.long 0xaabbaabb

.short -123, 456

Также существуют директивы для размещения в памяти строковых литералов:

· .ascii "STR" размещает строку STR. Нулевых байтов не добавляет.

· .string "STR" размещает строку STR, после которой следует нулевой байт (как в языке Си).

· У директивы .string есть синоним .asciz (z от англ. zero - ноль, указывает на добавление нулевого байта).

Строка-аргумент этих директив может содержать стандартные escape-последовательности, которые вы использовали в Си, например, \n, \r, \t, \\, \" и так далее.

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

.data

.string "Hello, world\n"

...

Если некоторые данные не предполагается изменять в ходе выполнения программы, их можно поместить в специальную секцию данных только для чтения при помощи директивы .section .rodata:

.section .rodata

.string "program version 0.314"

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

Тип данных в Си Размер (sizeof), байт Выравнивание, байт Название
Char signed char signed byte (байт со знаком)
Unsigned char unsigned byte (байт без знака)
Short signed short signed halfword (полуслово со знаком)
Unsigned short unsigned halfword (полуслово без знака)
Int signed int long signed long enum signed word (слово со знаком)
unsigned int unsigned long unsigned word (слово без знака)

Отдельных объяснений требует колонка "Выравнивание". Выравнивание задано у каждого фундаментального типа данных (типа данных, которым процессор может оперировать непосредственно). Например, выравнивание word - 4 байта. Это значит, что данные типа word должны располагаться по адресу, кратному 4 (например, 0x00000100, 0x03284478). Архитектура рекомендует, но не требует выравнивания: доступ к невыровненным данным может быть медленнее, но принципиальной разницы нет и ошибки это не вызовет.

Для соблюдения выравнивания в распоряжении программиста есть директива .p2align.

.p2align степень_двойки, заполнитель, максимум

Директива .p2align выравнивает текущий адрес до заданной границы. Граница выравнивания задаётся как степень числа 2: например, если вы указали .p2align 3 - следующее значение будет выровнено по 8-байтной границе. Для выравнивания размещается необходимое количество байт-заполнителей со значением заполнитель. Если для выравнивания требуется разместить более чем максимум байт-заполнителей, то выравнивание не выполняется.

Второй и третий аргумент являются необязательными.

Примеры:

.data

.string "Hello, world\n" /* мы вряд ли захотим считать,

сколько символов занимает эта

строка, и является ли следующий

адрес выровненным */

.p2align 2 /* выравниваем по границе 4 байта

для следующего .long */

.long 123456

Метки и прочие символы

Вы, наверно, заметили, что мы не присвоили имён нашим данным. Как же к ним обращаться? Очень просто: нужно поставить метку. Метка - это просто константа, значение которой - адрес.

hello_str:

.string "Hello, world!\n"

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

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

Возьмём hello.s и скомпилируем его так:

[user@host:~]$ gcc -c hello.s

[user@host:~]$

Обратите внимание на параметр -c. Мы компилируем исходный код не в исполняемый файл, а лишь только в отдельный объектный файл hello.o. Теперь воспользуемся программой nm(1):

[user@host:~]$ nm hello.o

00000000 d hello_str

0000000e a hello_str_length

00000000 T main

nm(1) выводит список символов в объектном файле. В первой колонке выводится значение символа, во второй - его тип, в третьей - имя. Посмотрим на символ hello_str_length. Это длина строки Hello, world!\n. Значение символа чётко определено и равно 0xe, об этом говорит тип a - absolute value. А вот символ hello_str имеет тип d - значит, он находится в секции данных (data). Символ main находится в секции кода (text section, тип T). А почему a представлено строчной буквой, а T - прописной? Если тип символа обозначен строчной буквой, значит это локальный символ, который видно только в пределах данного файла. Заглавная буква говорит о том, что символ глобальный и доступен другим модулям. Символ main мы сделали глобальным при помощи директивы .global main.

Для создания нового символа используется директива .set. Синтаксис:

.set символ, выражение

Например, определим символ foo = 42:

.set foo, 42

Ещё пример из hello.s:

hello_str:

.string "Hello, world!\n" /* наша строка */

.set hello_str_length, . - hello_str - 1 /* длина строки */

Сначала определяется символ hello_str, который содержит адрес строки. После этого мы определяем символ hello_str_length, который, судя по названию, содержит длину строки. Директива .set позволяет в качестве значения символа использовать арифметические выражения. Мы из значения текущего адреса (метка "точка") вычитаем адрес начала строки - получаем длину строки в байтах. Потом мы вычитаем ещё единицу, потому что директива .string добавляет в конце строки нулевой байт (а на экран мы его выводить не хотим).

Неинициализированные данные

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

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

.space количество_байт

.space количество_байт, заполнитель

Директива .space резервирует количество_байт байт.

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

Например:

.bss

long_var_1: /* по размеру как .long */

.space 4

buffer: /* какой-то буфер в 1024 байта */

.space 1024

struct: /* какая-то структура размером 20 байт */

.space 20

Лекция 4: Методы адресации

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

Наши рекомендации