Написание и отладка кода на ассемблере x86/x64 в Linux

17 августа 2016

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

Если вы знаете ассемблер, то любая программа для вас — open source.

Народная мудрость.

Введение

Существует два широко используемых ассемблерных синтаксиса — так называемые AT&T-синтаксис и Intel-синтаксис. Они не сильно друг от друга отличаются и легко переводятся один в другой. В мире Windows принято использовать синтаксис Intel. В мире *nix систем, наоборот, практически всегда используется синтаксис AT&T, а синтаксис Intel встречается крайне редко (например, он используется в утилите perf). Поскольку Windows, как известно, не существует, далее мы сосредоточимся на правильном AT&T-синтаксисе :)

Компиляторов ассемблера существует много. Мы будем использовать GNU Assembler (он же GAS, он же /usr/bin/as). Скорее всего, он уже есть вашей системе. К тому же, если вы пользуетесь GCC и собираетесь писать ассемблерные вставки в коде на C, то именно с этим ассемблером вам предстоит работать. Из достойных альтернатив GAS можно отметить NASM и FASM.

Наконец, язык ассемблера отличается в зависимости от архитектуры процессора. Пока что мы сосредоточимся на ассемблере для x86 (он же i386) и x64 (он же amd64), так как именно с этими архитектурами приходится чаще всего иметь дело. Впрочем, ARM тоже весьма распространен, главным образом на телефонах и планшетах. Еще из сравнительно популярного есть SPARC и PowerPC, но шансы столкнуться с ними весьма малы. Отмечу, что x86 и x64 можно было бы рассматривать отдельно, но эти архитектуры во многом похожи, поэтому я не вижу в этом большого смысла.

«Hello, world» на int 0x80

Рассмотрим типичный «Hello, world» для архитектуры x86 и Linux:

.data
msg:
  .ascii "Hello, world!\n"
  .set len, . - msg

.text

.globl _start
_start:
  # write
  mov $4,   %eax
  mov $1,   %ebx
  mov $msg, %ecx
  mov $len, %edx
  int $0x80

  # exit
  mov $1,   %eax
  xor %ebx, %ebx
  int $0x80

Компиляция:

# Или: gcc -m32 -c hello-int80.s
as --32 hello-int80.s -o hello-int80.o
ld -melf_i386 -s hello-int80.o -o hello-int80

Коротко рассмотрим первые несколько действий, выполняемых программой: (1) программа начинает выполнение с метки _start, (2) в регистр eax кладется значение 4, (3) в регистр ebx помещается значение 1, (4) в регистр ecx кладется адрес строки, (5) в регистр edx кладется ее длина, (6) происходит прерывание 0x80. Так в мире Linux традиционно происходит выполнение системных вызовов. Конкретно int 0x80 считается устаревшим и медленным, но из соображений обратной совместимости он все еще работает. Далее мы рассмотрим и более новые механизмы.

Нетрудно догадаться, что eax — это номер системного вызова, а ebx, ecx и edx — его аргументы. Какой системный вызов имеет какой номер можно подсмотреть в файлах:

# для x86
/usr/include/x86_64-linux-gnu/asm/unistd_32.h
# для x64
/usr/include/x86_64-linux-gnu/asm/unistd_64.h

Следующая строчка из файла unistd_32.h:

#define __NR_write 4

… как бы намекает нам, что производится вызов write. В свою очередь, из man 2 write мы можем узнать, какие аргументы этот системный вызов принимает:

#include <unistd.h>

ssize_t write(int fd, const void *buf, size_t count);

То есть, рассмотренный код эквивалентен:

// напомню, что stdout == 1
write(stdout, "Hello, world!\n", 14)

Затем аналогичным образом производится вызов:

// команда `xor %ebx, %ebx` обнуляет регистр %ebx
exit(0)

Совсем не сложно!

В общем случае системный вызов через 0x80 производится по следующим правилам. Регистру eax присваивается номер системного вызова из unistd_32.h. До шести аргументов помещаются в регистры ebx, ecx, edx, esi, edi и ebp. Возвращаемое значение помещается в регистр eax. Значения остальных регистров при возвращении из системного вызова остаются прежними.

Выполнение системного вызова через sysenter

Начиная с i586 появилась инструкция sysenter, специально предназначенная (чего нельзя сказать об инструкции int) для выполнения системных вызовов.

Рассмотрим пример использования ее на Linux:

.data
msg:
  .ascii "Hello, world!\n"
  len = . - msg

.text
.globl _start

_start:
  # write
  mov   $4,   %eax
  mov   $1,   %ebx
  mov   $msg, %ecx
  mov   $len, %edx
  push  $write_ret
  push  %ecx
  push  %edx
  push  %ebp
  mov   %esp, %ebp
  sysenter

write_ret:
  # exit
  mov   $1,   %eax
  xor   %ebx, %ebx
  push  $exit_ret
  push  %ecx
  push  %edx
  push  %ebp
  mov   %esp, %ebp
  sysenter

exit_ret:

Сборка осуществляется аналогично сборке предыдущего примера.

Как видите, принцип тот же, что при использовании int 0x80, только перед выполнением sysenter требуются поместить в стек адрес, по которому следует вернуть управление, а также совершить кое-какие дополнительные манипуляции с регистрами. Причины этого более подробно объясняются здесь.

Инструкция sysenter работает быстрее int 0x80 и является предпочтительным способом совершения системных вызовов на x86.

Выполнение системного вызова через syscall

До сих пор речь шла о 32-х битных программах. На x64 выполнение системных вызовов осуществляется так:

.data
msg:
  .ascii "Hello, world!\n"
  .set len, . - msg

.text

.globl _start
_start:
  # write
  mov  $1,   %rax
  mov  $1,   %rdi
  mov  $msg, %rsi
  mov  $len, %rdx
  syscall

  # exit
  mov  $60, %rax
  xor  %rdi, %rdi
  syscall

Собирается программа таким образом:

as --64 hello-syscall.s -o hello-syscall.o
ld -melf_x86_64 -s hello-syscall.o -o hello-syscall

Принцип все тот же, но есть важные отличия. Номера системных вызовов нужно брать из unistd_64.h, а не из unistd_32.h. Как видите, они совершенно другие. Так как это 64-х битный код, то и регистры мы используем 64-х битные. Номер системного вызова помещается в rax. До шести аргументов передается через регистры rdi, rsi, rdx, r10, r8 и r9. Возвращаемое значение помещается в регистр rax. Значения, сохраненные в остальных регистрах, при возвращении из системного вызова остаются прежними, за исключением регистров rcx и r11.

Интересно, что в программе под x64 можно одновременно использовать системные вызовы как через syscall, так и через int 0x80.

Отладка ассемблерного кода в GDB

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

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

info files

Увидим что-то вроде:

[...]
    Entry point: 0x4000b0
[...]

Далее говорим:

b *0x4000b0
r

Какого-либо исходного кода у нас тоже нет, поэтому команда l работать не будет. Сами ассемблерные инструкции и есть исходный код! Так, например, можно посмотреть следующие 5 ассемблерных инструкций:

x/5i $pc

По понятным причинам, переход к очередной строчке кода при помощи команд n или s работать не будет. Вместо этих команд следует использовать команды перехода к следующей инструкции — ni, si, и так далее.

Смотреть и изменять значения переменных мы тоже не можем. Однако ничто не мешает смотреть и изменять значения регистров:

info registers
p/x $rcx
p $xmm1
set $r15 = 0x123

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

x/8g $sp

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

gcc -S test.c -o -
objdump -d ./myprog

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

Внезапно отладка программы, собранной без -g и/или с -O2, перестала казаться таким уж страшным делом, не так ли?

Заключение

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

Примите во внимание, что в Linux есть еще как минимум два способа сделать системный вызов — через так называемые vsyscall (считается устаревшим, но поддерживается для обратной совместимости) и VDSO (пришедший ему на замену). Эти способы основаны на отображении страницы ядра в адресное пространство процесса и призваны ускорить выполнение системных вызовов, не требующих проверки привилегий и других тяжелых действий со стороны ядра системы. В качестве примера вызова, который может быть ускорен таким образом, можно привести gettimeofday. К сожалению, рассмотрение vsyscall и VDSO выходит за рамки данного поста. Больше информации о них вы найдете по приведенным ниже ссылкам.

Ссылки по теме:

Кроме того, вас могут заинтересовать статьи, посвященные ассемблеру, в замечательных блогах alexanius-blog.blogspot.ru и 0xax.blogspot.ru.

Дополнение: Шпаргалка по основным инструкциям ассемблера x86/x64

Метки: , .


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