Ассемблер в Linux для программистов на C

Синтаксис ассемблера

Разбить на страницы
Показывать лекцию целиком

Команды

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

  • Конкретное значение, известное на этапе компиляции, - например, числовая константа или символ. Записываются при помощи знака $, например: $0xf1, $10, $hello_str. Эти операнды называются непосредственными.
  • Регистр. Перед именем регистра ставится знак %, например: %eax, %bx, %cl.
  • Указатель на ячейку в памяти (как он формируется и какой имеет синтаксис записи - далее в этом разделе).
  • Неявный операнд. Эти операнды не записываются непосредственно в исходном коде, а подразумеваются. Нет, конечно, компьютер не читает ваши мысли. Просто некоторые команды всегда обращаются к определённым регистрам без явного указания, так как это входит в логику их работы. Такое поведение всегда описывается в документации.
  • Внимание! Если вы забудете знак $, когда записываете непосредственное числовое значение, компилятор будет интерпретировать число как абсолютный адрес. Это не вызовет ошибок компиляции, но, скорее всего, приведёт к ошибке сегментации (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 1 1 signed byte (байт со знаком)
    Unsigned char 1 1 unsigned byte (байт без знака)
    Short signed short 2 2 signed halfword (полуслово со знаком)
    Unsigned short 2 2 unsigned halfword (полуслово без знака)
    Int signed int long signed long enum 4 4 signed word (слово со знаком)
    unsigned int unsigned long 4 4 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
    
    Страницы:

    Команды

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

  • Конкретное значение, известное на этапе компиляции, - например, числовая константа или символ. Записываются при помощи знака $, например: $0xf1, $10, $hello_str. Эти операнды называются непосредственными.
  • Регистр. Перед именем регистра ставится знак %, например: %eax, %bx, %cl.
  • Указатель на ячейку в памяти (как он формируется и какой имеет синтаксис записи - далее в этом разделе).
  • Неявный операнд. Эти операнды не записываются непосредственно в исходном коде, а подразумеваются. Нет, конечно, компьютер не читает ваши мысли. Просто некоторые команды всегда обращаются к определённым регистрам без явного указания, так как это входит в логику их работы. Такое поведение всегда описывается в документации.
  • Внимание! Если вы забудете знак $, когда записываете непосредственное числовое значение, компилятор будет интерпретировать число как абсолютный адрес. Это не вызовет ошибок компиляции, но, скорее всего, приведёт к ошибке сегментации (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 1 1 signed byte (байт со знаком)
    Unsigned char 1 1 unsigned byte (байт без знака)
    Short signed short 2 2 signed halfword (полуслово со знаком)
    Unsigned short 2 2 unsigned halfword (полуслово без знака)
    Int signed int long signed long enum 4 4 signed word (слово со знаком)
    unsigned int unsigned long 4 4 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
    
    Вернуться к учебному плану