Программирование и отладка на C и ASM — Первые программы. Знакомство с C и ассемблером. Компиляция, линковка, код возврата. Вывод текста.
Подготовка к работе
Данная статья или раздел ещё не завершены Кто-то посчитал, что статья или раздел ниже не содержит какой-то важной информации или имеет проблемы с вёрсткой/текстом. Указана причина: дать ссылки на еще подобные источники в открытом доступе{{#ifeq: {{{1}}} | nopoint | | . }} |
{{#if: Доработка | }}
Итак, первая программа у нас будет выводить строку «Hello world!» в текстовый терминал. Сделано это будет на языках программирования C и ассемблер (далее ASM).
Работать будем в консоли. Про использование комманд cd, cp, mv, mkdir, pwd, ls, echo, cat и прочие базовые вещи — Что такое командная строка и как в ней работать.
Создайте себе директорию (каталог), и туда складывайте свои первые программы, можете именовать их определенным образом. Термин «папка» из мира Windows в рамках этой книги (или набора статей) использоваться не будет. Не разводите в директориях бардак, каждую свою программу держите в отдельной директории. Про системы управления версиями (VCS) тут рассказываться не будет, потому что про это уже достаточно много чего написано. Например, про Git есть книга.
Будем считать, что у вас будут такие директории:
~/learn/asm/ ~/learn/c/
Первая наша программа будет написана на языке С и будет выводить текст Hello, world!
.
Будем работать в директории
~/learn/c/01_hello/puts_hello/
У нас будет несколько версий puts-хелловорлдов на Си, с ними мы будем делать всякие интересные вещи. Первая версия будет иметь номер 0.1.
В итоге должно получиться такое дерево каталогов:
~ └── learn ├── asm └── c └── 01_hello └── puts_hello └── 0.1
Создавать все эти директории я советую через консоль, а не через графические файловые менеджеры, типа Nautilus, Dolphin и прочее.
Делать это будем в домашней директории ~
Вот примерно так:
mkdir -p ~/learn/{asm,c} cd $_ mkdir -p 01_hello/puts_hello/0.1 cd $_
Совет
Тут будут две главы, одна посвящена ассемблеру, другая Си, которые будут перекликаться между собой, предполагается что читатель будет смотреть раздел Си и соответствующий раздел ассемблера, а не читать подряд сначала все по ассемблеру, потом по Си (или наоборот Си, потом ассемблер)
Приступаем к делу
Для начала, рассмотрим простейшую программу на Си и то, в какой код на ассемблере она переводится (компилируется) компилятором
Создадим файл с таким содержанием: <syntaxhighlight lang="c">
- include <stdio.h>
int main (void) {
puts("Hello, World!"); return 0;
} </syntaxhighlight>
(все вставленные в книгу исходные коды, вставки с консольными командами, примеры работы с отладчиком, дизассемблером и прочее распространяются свободно и без каких-либо ограничений, явных и подразумеваемых, если не сказано обратное. Например, в случае, если будем рассматривать исходный код и/или дизассемблировать какую-нибудь лицензированную под GPL программу)
Для редактирования текста программ можете использовать консольные редакторы, например nano, mcedit, можете взять графические, например gedit, kate и тому подобные. Есть еще всякие сложные штуки, например emacs и vim, но на их освоение уходит достаточно много времени.
В этой книге я не буду описывать работу ни с одним из редакторов, и рассказывать о том, что надо делать все через консоль, IDE не нужны и тому подобное. Делайте как хотите. Чего уж точно не стоит делать, так это использовать офисные пакеты — они создают не обычный текстовый файл (plain text), а файл в особом формате, и компилятор на него грязно выругается. Очень желательно, чтобы в редакторе, которым вы будете пользоваться, была подсветка синтаксиса и автоматическая расстановка отступов при наборе кода.
Для начала советую начать с mcedit, т. к. там не надо думать, как из него выйти (как было когда-то с vim. Сейчас же он на Ctrl-C выдает меганужную подсказку Type :quit<Enter> to exit Vim).
Так или иначе, у вас должен получиться файл ~/learn/c/01_hello/puts_hello/0.1/hello.c
в котором есть вышеприведенный текст программы, или код. Теперь его нужно преобразовать в исполняемый процессором машинный код. Для этого нужна программа — компилятор.
Чтобы откомпилировать (собрать) наш пример, можно использовать компиляторы GCC и Clang. Мы будем использовать их оба, когда будем рассматривать код на ассемблере. Но сейчас мы просто компилируем программу и запускаем ее, так что тут это совершенно неважно. Предположим что мы выбрали GCC
gcc hello.c -O2 -o hello ./hello Hello, World!
hello.c
это имя файла с кодом. Опция -O2
задает уровень оптимизации. После опции -o
следует имя исполняемого файла. Естественно, опций у gcc значительно больше. Прочитать о них можно, набрав man gcc
. Про процесс компиляции, ассемблирования и линковки будет сказано несколько позже.
Отлично, наша программа заработала. Что тут собственно происходит? Разберем пример по строчкам.
puts("Hello, World!")
Это основная часть программы, которая и выводит строку «Hello, World!» в консоль. Что такое puts()
? Это имя вызываемой функции, которая отвечает за вывод текста. Чтобы вывести сообщение на экран, или в файл, или отправить информацию по сети, программа должна попросить об этом операционную систему (ОС, в нашем случае это ядро linux), потому что именно ОС связывает пользовательские программы (приложения) и «железо» компьютера. Для того, чтобы приложения могли обращаться к ядру, существуют системные вызовы, или сисколлы (англ. syscall). Очевидно, что если мы хотим в нашей программе вывести текст на экран, нам нужно сделать соответствующий системный вызов. Выходит, puts()
— это сисколл?
Нет, мы пошли здесь немного другим путем, более простым для первого примера программы. puts()
— это функция из стандартной библиотеки языка C. Если сисколл — это обращение к ядру, то вызов функции — это просто обращение к другой (пока скрытой) части программы. Функция — это часть программы (подпрограмма), которая выполняет определенную задачу. В нашем случае функция puts()
делает внутри себя системный вызов (сисколл), в результате которого и выводится строка на экран.
Где же скрыта функция puts()
? В нашей программе она только вызывается, но ее содержимого не видно. Где та часть программы, которая выполняется при вызове этой функции? Подробное объяснение вы узнаете из дальнейших примеров, пока же ограничимся вот чем.
Функция puts()
находится в файле libc.so, который обычно располагается по адресу
/usr/lib/libc.so
При запуске нашей программы помимо исполняемого файла hello в память загружается и файл libc.so. Таким образом, мы можем вызывать оттуда функции стандартной библиотеки C для самых необходимых операций. Такие файлы, с расширением .so, называются динамические библиотеки. Они содержат такой же исполняемый код, как и наша программа hello, но их нельзя запустить на выполнение напрямую, они должны быть добавлены в память какой-либо самостоятельной программы.
Разберем первую строку:
#include <stdio.h>
#include
— это не функция, не сисколл, не команда, которая переводится в машинный код. Это директива препроцессора, то есть команда для препроцессора(препроцессирование выполняется ДО непосредственного синтаксического разбора исходного текста компилятором и самой компиляции). Такие директивы начинаются с символа «#». Перед компиляцией(синтаксическим разбором исходника), компилятор обычно вызывает препроцессор cpp
который делает некие преобразования над текстом программы. См. https://gcc.gnu.org/onlinedocs/gcc/Preprocessor-Options.html.
#include
дает препроцессору указание — взять файл, имя которого написано дальше (stdio.h) и вставить его содержимое вместо самой директивы #include
. То есть к тексту нашей короткой программы мы добавили текст из stdio.h. Что же такого нам понадобилось в этом файле? Там содержится описание функций ввода-вывода из стандартной библиотеки C, в том числе и функции puts()
.
Более подробный разбор препроцессора Си вы сможете прочесть в статье Сишный препроцессор (todo)
Идем дальше:
int main (void)
Вся наша программа — это тоже функция, которая называется main()
. void
в скобочках означает, что функция не имеет параметров. Функция puts()
, как вы заметили, имеет один параметр. int
— тип возвращаемого значения. Каждая функция после выполнения может возвращать значение, или результат. Функция main()
должна, как правило, возвращать 0 если программа выполнилась без ошибок. Если произошла ошибка — то код ошибки. int
означает целочисленный тип, то есть функция main возвращает целое число. Узнать, что вернула программа можно, выполнив команду
echo $?
Сразу после выполнения программы.
Фигурные скобки обозначают начало и конец функции, все что внутри — это ее содержимое.
Наконец,
return 0;
команда выхода из функции, возвращаем 0.
Если заменить Hello, World!
на нечто другое, выводиться будет другой текст. Интересен другой момент. А именно, что если сделать вот так puts("Hello, World!"+1)
? Попробуйте. Пусть это будет в новой директории
user@localhost:~/learn/c/01_hello/puts_hello/0.1$ cd .. user@localhost:~/learn/c/01_hello/puts_hello$ cp -r 0.1 0.2 user@localhost:~/learn/c/01_hello/puts_hello$ cd 0.2/ user@localhost:~/learn/c/01_hello/puts_hello/0.2$
Вот таким вот образом мы создали новую версию puts()
-хелловорлда. Осталось только подредактировать, т. е. вместо puts("Hello, World!")
сделать puts("Hello, World!"+1)
и произвести компиляцию. В результате мы обнаружим, что вывелась лишь часть нашей строки Hello, World!
, пропустив ровно одну букву — ello, World!
. Почему? Что вообще значит «прибавить 1 к строке»? Все дело в том, что в функцию puts()
передается адрес, в котором расположена строка. А строка это лишь последовательность байтов, ASCII-символов. И когда мы делаем «+1» то мы увеличиваем этот самый адрес на единицу, и таким образом получается, что строка выводится не вся. Сама строка при этом не обрезается т.е. она по-прежнему будет содержать первую букву 'H'
. Но как, спросите вы, функция puts()
узнает, где наша строка заканчивается? Начинается-то она с адреса, который в нее передается, это понятно. Так вот, строка у нас нуль-терминированная, и конец строки обозначается нулевым байтом. puts()
выведет всю строку вплоть до этого самого нулевого байта. Это не очень удачное решение т. к. требуется время чтобы узнать длину строки, прежде чем ее выводить. Если надо «сшить» две строки (т. е. объединить в одну), то требуется время на то, чтобы узнать длину первой и длину второй строки. Проведем еще один эксперимент, показывающий, что строка через puts()
выводится именно до нуля. Создайте версию 0.3 нашего хелловорда, и пусть там будет вот такой вот код:
<syntaxhighlight lang="c">
- include <stdio.h>
int main (void) {
const char *str = "Hello\0 World!"; puts(str); // 5 букв puts(str+1); // 4 буквы puts(str+5); // пустота
puts(str+6); // остальной кусок puts(str+7); // остальной кусок без первого байта (пробела)
return 0;
}
</syntaxhighlight>
//
обозначает начало однострочного комментария, и весь текст, идущий после него, игнорируется. Но он прекращает свое действие на следующей строке.
Тут вводится новая для нас сущность — переменная. Но в данном случае она константа. Пока что достаточно будет сказать, что эта самая переменная содержит (хранит в себе) адрес нашей строки(указатель на строку), и когда мы вызываем puts(str)
мы фактически передаем этот адрес в нашу функцию. Делая так puts(str+1)
мы не увеличиваем значение самой переменной str
на 1, мы просто передаем в функцию puts()
адрес, который на единицу больше того адреса, который у нас заключен в str
.
На выходе должно получиться вот это:
Hello ello World! World!
Тут отлично можно видеть, что строка обрывается в том месте, где у нас вставлен \0
. \0
это не значит вывести на печать косую черту и цифру 0. Это значит что в соответствующей шестой позиции у нас в строке будет находиться нулевой байт. Си строка и так уже содержит в себе нулевой байт в конце, который помечает ее конец. Если же вставить в строку свой собственный нулевой байт, и вызвать puts()
, передав в качестве значения адрес начала строки, то строка будет выведена как раз до этого нулевого байта. Подробнее об этом написано в Escape-последовательности_в_C.
Вот как это примерно выглядит в случае puts(str);
+ + + + + +------+------+------+------+------+------+------+------+------+------+------+------+------+ | | | | | | | | | | | | | | | 'H' | 'e' | 'l' | 'l' | 'o' | '\0' | ' ' | 'w' | 'o' | 'r' | 'l' | 'd' | '\0' | | | | | | | | | | | | | | | +------+------+------+------+------+------+------+------+------+------+------+------+------+ ^ | str
Сверху через «+» помечены те байты, которые будут выведены в консоль. Кроме того, puts()
еще автоматически вставляет перевод строки в конце (код перевода строки обозначается через '\n'
). Это поведение задокументировано, см. man 3 puts
. Все эти буквы и переводы строк представляют из себя восьмибитные байты. Позже мы увидим, как сами буквы можно задавать через их коды в 16-ричной системе счисления в эскейп-последовательностях или же просто как массив байтов.
Рассмотрим случай puts(str+1);
+ + + + +------+------+------+------+------+------+------+------+------+------+------+------+------+ | | | | | | | | | | | | | | | 'H' | 'e' | 'l' | 'l' | 'o' | '\0' | ' ' | 'w' | 'o' | 'r' | 'l' | 'd' | '\0' | | | | | | | | | | | | | | | +------+------+------+------+------+------+------+------+------+------+------+------+------+ ^ | str+1
Тут вроде все ясно, передав значение на 1 большее чем str, мы выведем строку без первого байта
Рассмотрим случай puts(str+5);
+------+------+------+------+------+------+------+------+------+------+------+------+------+ | | | | | | | | | | | | | | | 'H' | 'e' | 'l' | 'l' | 'o' | '\0' | ' ' | 'w' | 'o' | 'r' | 'l' | 'd' | '\0' | | | | | | | | | | | | | | | +------+------+------+------+------+------+------+------+------+------+------+------+------+ ^ | str+5
Тут вообще ничего не выводится, кроме переноса строки т. к. функция puts()
сразу же натыкается на нулевой байт.
Рассмотрим случай puts(str+6);
+ + + + + + +------+------+------+------+------+------+------+------+------+------+------+------+------+ | | | | | | | | | | | | | | | 'H' | 'e' | 'l' | 'l' | 'o' | '\0' | ' ' | 'w' | 'o' | 'r' | 'l' | 'd' | '\0' | | | | | | | | | | | | | | | +------+------+------+------+------+------+------+------+------+------+------+------+------+ ^ | str+6
Тут мы уже перешли через нулевой байт, и таким образом выводим остаток фразы. Понятно, что если сделать puts(str+7);
то будет выведено то же самое, только без пробела в начале.
Теперь будем рассматривать ассемблерный код, который компилятор нам выдает на все наши хелловорды.
Ассемблер
Учимся читать ассемблер (синтаксис AT&T и Intel). Регистры, память, стек. Виртуальное адресное пространство процесса. Сегменты процесса
Перейдем к директории ~/learn/c/01_hello/puts_hello/0.1
и там попробуем получить ассемблерный листинг нашего самого первого хелловорда, используя компиляторы clang и gcc.
Компилировать свою программу мы будем особым образом. Примерно вот так:
gcc-5 -fno-unwind-tables -fno-asynchronous-unwind-tables -march=x86-64 -mtune=generic -O2 -S hello.c -o hello_gcc.s clang-3.8 -fno-unwind-tables -fno-asynchronous-unwind-tables -march=x86-64 -mtune=generic -O2 -S hello.c -o hello_clang.s
Примечание: тут будут рассматриватся последние доступные на момент написания этого текста пакеты для убунт из https://launchpad.net/~ubuntu-toolchain-r/+archive/ubuntu/test и http://llvm.org/apt/.
А именно: gcc-5 (Ubuntu 5.3.0-3ubuntu1~14.04) 5.3.0 20151204
и clang version 3.8.0-svn262614-1~exp1 (branches/release_38)
. Со временем будут появляться более новые версии компиляторов, и возможно это все надо будет переделать.
В компиляторах других верcий, будь то идущих в стандартной поставке вместе с дистрибутивом, из сторонних репозиториев, или собранных через ./configure && make && sudo make install
, сгенерированный ассемблерный код может отличаться. Хотя на таких простых примерах едва ли могут быть какие-нибудь значимые отличия. Но на более сложных примерах они будут почти наверняка.
Пока что мы не будем подробно разбирать директивы ассемблера, а просто посмотрим на разные версии хелловордов, чем они отличаются в ассемблерном представлении.
Посмотрим получившийся код хелловорда версии 0.1 для GCC и clang. Код прокомментирован
gcc:
.file "hello.c" .section .rodata.str1.1,"aMS",@progbits,1 .LC0: # метка .string "Hello, World!" # сама строка .section .text.unlikely,"ax",@progbits .LCOLDB1: .section .text.startup,"ax",@progbits .LHOTB1: .p2align 4,,15 .globl main .type main, @function main: subq $8, %rsp # Если мы хотим вызывать функцию, вершина стека должна быть кратна 16 байт (требования 86_64 ABI). Регистр rsp как раз за это отвечает movl $.LC0, %edi # Помещаем адрес строки (адрес метки .LC0) в регистр edi call puts # Вызов функции puts которая принимает в edi указатель на строку "Hello, World!" xorl %eax, %eax # Обнуление регистра eax через xor ( eax = eax xor eax ) (то же самое, что и eax = 0) — значение, возвращаемое функцией main addq $8, %rsp # Сдвигаем стек на 8 байт назад (стек растет вверх, так что для этого надо к регистру прибавить 8) ret # Возврат (выход) из функции main .size main, .-main .section .text.unlikely .LCOLDE1: .section .text.startup .LHOTE1: .ident "GCC: (Ubuntu 5.3.0-3ubuntu1~14.04) 5.3.0 20151204" .section .note.GNU-stack,"",@progbits
clang:
.text .file "hello.c" .globl main .align 16, 0x90 .type main,@function main: # @main # BB#0: pushq %rax # Если мы хотим вызывать функцию, вершина стека должна быть кратна 16 байт (требования 86_64 ABI). Инструкция pushq тут «заталкивает» 64-битный регистр в стек, уменьшая при этом значение %rsp регистра(указатель на вершину стека) на 8 movl $.L.str, %edi # Помещаем адрес строки (адрес метки .L.str) в регистр edi callq puts # Вызов функции puts которая принимает в edi указатель на строку «Hello, World!» xorl %eax, %eax # Обнуление регистра eax через xor ( eax = eax xor eax ) (то же самое, что и eax = 0) — значение, возвращаемое функцией main popq %rcx # Сдвигаем стек на 8 байт назад через popq интструкцию retq # Возврат (выход) из функции main .Lfunc_end0: .size main, .Lfunc_end0-main .type .L.str,@object # @.str .section .rodata.str1.1,"aMS",@progbits,1 .L.str: # метка .asciz "Hello, World!" # сама строка .size .L.str, 14 .ident "clang version 3.8.0-svn262614-1~exp1 (branches/release_38)" .section ".note.GNU-stack","",@progbits
Требования к выравниванию стека при вызове других функций (подпрограмм) описаны в документации http://www.x86-64.org/documentation_folder/abi.pdf.
В частности, про выравнивание регистра rsp — указателя на вершину стека сказано в 3.2.2 The Stack Frame страница 16.
> The end of the input argument area shall be aligned on a 16 (32, if __m256 is passed on stack) byte boundary. In other words, the value (%rsp + 8) is always a multiple of 16 (32) when control is transferred to the function entry point.
Тут основной момент заключается вот в этих двух строчках
movl $.LC0, %edi # Помещаем адрес строки (адрес метки .LC0) в регистр edi callq puts # Вызов функции puts которая принимает в edi указатель на строку «Hello, World!»
Компиляторы clang и gcc по-разному обозвали метки, посредством которых мы ссылаемся на адрес строк, но в целом они сделали то же самое. Регистры это особые ячейки памяти с быстрым доступом. Инструкция movl $.LC0, %edi
присваивает регистру edi
адрес, соответствующий метке .LC0
. Эта метка, как можно видеть, указывает в начало нашей строки "Hello, World!"
. Более подробно это (регистры и метки) будет разобрано в части, посвященной ассемблеру
Рассмотрим теперь второй пример («"Hello, World!"+1»). Я не буду приводить полный ассемблерный выхлоп, а покажу лишь отличия между полученным ассемблерным выводом
user@localhost:~/learn/c/01_hello/puts_hello/0.2$ diff ../0.1/hello_gcc.s hello_gcc.s 14c14 < movl $.LC0, %edi --- > movl $.LC0+1, %edi user@localhost:~/learn/c/01_hello/puts_hello/0.2$ diff ../0.1/hello_clang.s hello_clang.s 9c9 < movl $.L.str, %edi --- > movl $.L.str+1, %edi
Отличие есть лишь в этой одной единственной строчке. А сама строка «Hello, World!» присутствует в полном объеме, т. е. компилятор не отбросил первую букву, а просто передал в функцию адрес этой строки, увеличенный на 1. И от этого первая буква не была выведена.
Отметим, что наприме в данном случае
movl $.LC0+1, %edi
Получившийся исполняемый код не будет выполнять сложение адреса строки с единицей. Сама операция .LC0+1, %edi
будет произведена на этапе ассемблирования; готовая к «использованию» программа не будет на этапе исполнения прибавлять единицу к адресу метки, смещение относительно метки .LC0 на 1 будет высчитано на этапе перевода кода на ассемблере (с расширением .s
) в исполняемый файл.
Третий же хелловорд несколько сложнее, и получившийся из него ассемблерный вывод я пожалуй приведу в полном объеме:
gcc:
.file "hello.c" .section .rodata .LC0: .string "Hello" .string " World!" .section .text.unlikely,"ax",@progbits .LCOLDB1: .section .text.startup,"ax",@progbits .LHOTB1: .p2align 4,,15 .globl main .type main, @function main: subq $8, %rsp movl $.LC0, %edi # - edi = str call puts # - puts(str); movl $.LC0+1, %edi # - edi = str + 1 call puts # - puts(str+1); movl $.LC0+5, %edi # - edi = str + 5 call puts # - puts(str+5); movl $.LC0+6, %edi # - edi = str + 6 call puts # - puts(str+6); movl $.LC0+7, %edi # - edi = str + 7 call puts # - puts(str+7); xorl %eax, %eax # - eax = eax xor eax (то же самое, что и eax = 0) addq $8, %rsp ret .size main, .-main .section .text.unlikely .LCOLDE1: .section .text.startup .LHOTE1: .ident "GCC: (Ubuntu 5.3.0-3ubuntu1~14.04) 5.3.0 20151204" .section .note.GNU-stack,"",@progbits
clang:
.text .file "hello.c" .globl main .align 16, 0x90 .type main,@function main: # @main # BB#0: pushq %rax movl $.L.str, %edi # - edi = str callq puts # - puts(str); movl $.L.str+1, %edi # - edi = str+1 callq puts # - puts(str+1); movl $10, %edi # - edi = '\n' !! тут компилятор clang решил сделать небольшую оптимизацию callq putchar # - putchar('\n') !! вызвать putchar чтобы вставить перевод строки, вместо puts movl $.L.str+6, %edi # - edi = str+6 callq puts # - puts(str+6); movl $.L.str+7, %edi # - edi = str+7 callq puts # - puts(str+7); xorl %eax, %eax # - eax = eax xor eax (то же самое, что и eax = 0) popq %rcx retq .Lfunc_end0: .size main, .Lfunc_end0-main .type .L.str,@object # @.str .section .rodata,"a",@progbits .L.str: .asciz "Hello\000 World!" .size .L.str, 14 .ident "clang version 3.8.0-svn262614-1~exp1 (branches/release_38)" .section ".note.GNU-stack","",@progbits
Компилятор gcc для записи нашей строки "Hello\0 World!"
задействовал две директивы .string
.
.string "Hello" .string " World!"
В то время как clang ограричился одной директивой .asciiz
.
.asciz "Hello\000 World!"
Эти директивы не являются инструкциями процессора (такими как movq
, subq
, callq
и прочее). Это лишь указания транслятору ассемблера вставить в объектный файл определенные символы и/или последовательности байт. https://sourceware.org/binutils/docs/as/Pseudo-Ops.html — тут описаны соответствующие директивы.
https://sourceware.org/binutils/docs/as/String.html — про директиву .string
.
https://sourceware.org/binutils/docs/as/Asciz.html — про директиву .asciz
.
Объектный файл (с расширение .o
) это файл с промежуточным представлением отдельного модуля программы, полученный в результате обработки исходного кода соотоветствующей программой. Объектный файл содержит в себе особым образом подготовленный код (часто называемый двоичным или бинарным), который может быть объединён с другими объектными файлами при помощи редактора связей (компоновщика, линкера) для получения готового исполнимого модуля, либо библиотеки. В качестве линкера мы будем использовать ld из комплекта binutils. Компилятор GCC сам не способен произвести перевод кода в объектный и исполняемый файл, он вызывает соответствующие утилиты as
и ld
(и возможно некоторые другие) из набора GNU Binutils https://www.gnu.org/software/binutils/.
Чтобы получить объектный файл из кода на ассемблере, выполним команду as (пока будем работать с самой первой версией хелловорда):
as hello_clang.s -o hello_clang.o as hello_gcc.s -o hello_gcc.o
Получившиеся объектные файлы формата ELF (англ. Executable and Linkable Format — формат исполнимых и компонуемых файлов) — в данном случае не исполняемый, а компонуемый файл (linkable, линковка, компоновка — без разницы). Исполняемый файл hello получившийся при «полной» компиляции, тоже является ELF-ом. Утилита file
умеет «понимать» множество разных форматов. Посмотрим на то, что она скажет на наши объектные и исполняемые файлы.
$ file hello_clang.o hello_clang.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped $ file hello_gcc.o hello_gcc.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped $ file hello hello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.24, BuildID[sha1]=13996484d52cb2088e59ae5bea10867dcaf67479, not stripped
Обьектные файлы у нас relocatable т. е. перемешаемые. Это значит, что там содержатся особые секции .rel.text
и .rel.data
, содержащие списки адресов, которые должны быть модифицированы, когда компоновщик объединит объектные файлы. Объектные файлы содержат в себе символы. Получить информацию о символах можно с помощью комманды nm
. Посмотрим на символы из нашего объектного файла:
$ nm -f sysv hello_gcc.o Symbols from hello_gcc.o: Name Value Class Type Size Line Section main |0000000000000000| T | FUNC|0000000000000015| |.text.startup puts | | U | NOTYPE| | |*UND*
эту же информацию можно получить утилитой readelf:
$ readelf -Ws hello_gcc.o Symbol table '.symtab' contains 12 entries: Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 FILE LOCAL DEFAULT ABS hello.c 2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 3: 0000000000000000 0 SECTION LOCAL DEFAULT 2 4: 0000000000000000 0 SECTION LOCAL DEFAULT 3 5: 0000000000000000 0 SECTION LOCAL DEFAULT 4 6: 0000000000000000 0 SECTION LOCAL DEFAULT 5 7: 0000000000000000 0 SECTION LOCAL DEFAULT 6 8: 0000000000000000 0 SECTION LOCAL DEFAULT 9 9: 0000000000000000 0 SECTION LOCAL DEFAULT 8 10: 0000000000000000 21 FUNC GLOBAL DEFAULT 6 main 11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND puts
Нас тут пока интересуют main
и puts
. Мы объявили функцию main
и поэтому символ функции main
упомянут в таблице .symtab
. И Type
указан как FUNC
. А функция puts
у нас объявлена не была, но у нас был объявлен прототип этой функции. Прототип функции был в sdtlib.h
, чтобы в этом убедиться, сделаем так:
$ echo "#include <stdio.h>" | cpp | grep puts extern int fputs (const char *__restrict __s, FILE *__restrict __stream); extern int puts (const char *__s);
Т. е. в файл через директиву include на этапе препроцессирования было включено определение функции puts extern int puts (const char *__s);
тут используется спецификатор extern
, означающий что данный символ является внешним(слово extern так и переводится — «внешний»), самого «тела» функции не предоставлено. Спецификатором extern
мы говорим компилятору что соответствующая функция будет найдена во время компоновки(линковки), само «тело» функции будет подключено из объектных файлов, разделяемых библиотек или статических библиотек(расширение .a
). В коде на ассемблере, полученном от компилятора, никакой функции puts нет. В то же время, для функции main у присутствует само «тело» функции, т. е. что именно эта функция делает, если ее вызвать. Функция main на самом деле не является самой первым, которую программа будет выполнять; самым первым будет выполняться особый startup-код, который подключается при «обычной» комплияции, когда сам компилятор в неявном виде вызывает ld и состыковывает наш код с определенными объектными файлами и статическими библиотеками, в которых и присутствует этот startup-код. Настоящей точкой входа (адреса, с которого программа начинает выполнятся) будет функция _start
(на ассемблере мы будем сразу писать с этой точки входа _start
, без всяких main). Реализация функции puts, которую использует наша программа, содержится во внешних файлах, которые «подключаются» к нашей программе в процессе линковки. Притом, исполняемый код соответствующей функции puts
может как.wut? Упоминание символа puts
есть лишь при непосредственном вызове функции: call puts
.
Функция puts
содержится в
readelf -s /lib/x86_64-linux-gnu/libc.so.6 | less libc.a
Динамическая библиотека (в данном случае имеет расширение .so
) — файл, содержащий машинный код. Загружается в память процесса загрузчиком программ операционной системы либо при создании процесса, либо по запросу уже работающего процесса, то есть динамически. В получившемся elf файле нашего хеловорда(при обычной динамической линковке, которая происходит по умолчанию) содержится отсылка к соответствующей разделяемой библитеке, которая требуется для запуска. Есть особый исполняемый файл ld-linux.so.1
или ld-linux.so.2
(в зависимости от версии glibc), который отображает эти динамические библиотеки в адресное пространство процесса при запуске исполняемого файла, и таким образом процесс может использовать соответствующие функции и данные, которые в этой динамической библиотеке объявлены. Кроме того, есть возможность статической сборки исполняемого файла, и тогда это все не требуется — всё необходимое будет включено непосредственно в сам исполняемый файл, и не будет необходимости что-либо подгружать. Статические библиотеки — файлы с расширением .a
, представляют из себя архивы, в которые включены объектные .so
файлы. Хотя туда можно включать любые файлы, но обычно этот формат архивов применяется для статических библотек, см. https://en.wikipedia.org/wiki/Ar_(Unix).
Более подробный разбор, что такое статические и динамические библиотеки, разделяемые объекты (shared objects) и объектные файлы будет позже, пока что нам хватит и такого понимания. За дополнительной информацией можно обратиться к […].
Данная статья или раздел ещё не завершены Кто-то посчитал, что статья или раздел ниже не содержит какой-то важной информации или имеет проблемы с вёрсткой/текстом. Указана причина: todo overload — «Дать ссылки на внешние источники или еще лучше написать статью об этом отдельно. Хотя сейчас на данной стадии это слишком рано разбирать»&«ldd, objdump /lib/x86_64-linux-gnu/libc.so.6, _IO_puts …»{{#ifeq: {{{1}}} | nopoint | | . }} |
{{#if: Доработка | }}
Знакомство с ассемблером и отладчиком
Преступим к знакомству с ассемблером. Первые наши программы на ассемблере не будут вообще выводить никакой текст, а будут просто изменять значения регистров. Создадим директорию ~/learn/asm/01_test/1/
и разместим там файл test1.s
следующего содержания
.section .text,"ax",@progbits .p2align 4,,15 .globl _start .type _start, @function _start: movq $1, %rax movq $2, %rbx movq $3, %rcx movq $4, %rdx movq $5, %rsi movq $6, %rdi movq $7, %rbp movq $8, %rsp movq $9, %r8 movq $10, %r9 movq $11, %r10 movq $12, %r11 movq $13, %r12 movq $14, %r13 movq $15, %r14 movq $16, %r15 ud2 .size _start, .-_start
Тут мы заполняем наши регистры общего назначения значениями от 1 до 16. Регистр %rsp
имеет особое назначение, и с ним так лучше не делать, но поскольку со стеком мы тут не работаем, это делать можно. Регистр %rbp
также имеет отношение к работе со стеком. Регистры %rsi
и %rdi
тоже имеют особое назначение (инструкции movsb и подобные ей).
ud2
— это такая официально определенная (задокументированная) инструкция, вызывающая ошибку выполнения при попытке ее выполнить. Попытка ее выполнения приведет к ошибке, будет выдано сообщение Illegal instruction (core dumped)
и образуется файл с дампом памяти процессора (коредампом). В нем содержится информация о состоянии процесса в момент его аварийного завершения. Cм. https://ru.wikipedia.org/wiki/Дамп_памяти.
Просто убедимся, что программа аварийно завершится и он получится при запуске
user@localhost:~/learn/asm/01_test/1$ as test1.s -o test1.o user@localhost:~/learn/asm/01_test/1$ ld test1.o -o test1 user@localhost:~/learn/asm/01_test/1$ ./test1 Illegal instruction (core dumped) user@localhost:~/learn/asm/01_test/1$ ls core test1 test1.o test1.s
если вы не получили файла core после запуска ./test1
, вам вероятно надо сделать ulimit -c unlimited
и повторить запуск. Это отключит лимит на размеры коредампа. Именно эта инструкция ud2
вызывает соответствую ошибку и выпадение в кору. Пока что мы не будем ничего делать с этим коредампом. Для корректного завершения программы, надо вызвать соответствующий системный вызов, до этого мы доберемся несколько позже. Посмотрим на точку входа нашего исполняемого файла.
user@localhost::~/learn/asm/01_test/1$ readelf -h test1 ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: EXEC (Executable file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x400080 Start of program headers: 64 (bytes into file) Start of section headers: 280 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 56 (bytes) Number of program headers: 1 Size of section headers: 64 (bytes) Number of section headers: 5 Section header string table index: 2
Точка входа у нас по адресу в 0x400080
А сейчас воспользуемся отладчиком, идущим с radare2 (желательно взять версию по-новее. http://rada.re/r/down.html для установки). Еще установите плагин keystone, r2pm -i keystone
, он нам очень пригодится. Советую также обратиться к https://www.gitbook.com/book/radare/radare2book/details. В данный момент нас интересует вот эта часть https://radare.gitbooks.io/radare2book/content/introduction/basic_debugger_session.html.
user@localhost:~/learn/asm/01_test/1$ r2 -d ./test1 Process with PID 8609 started... attach 8609 8609 bin.baddr 0x00400000 Assuming filepath ./test1 Warning: Cannot initialize dynamic strings asm.bits 64 -- THE ONLY WINNING MOVE IS NOT TO PLAY. [0x00400080]> pd 17 ;-- entry0: ;-- section..text: ;-- _start: ;-- rip: 0x00400080 48c7c0010000. mov rax, 1 ; [1] va=0x00400080 pa=0x00000080 sz=114 vsz=114 rwx=--r-x .text 0x00400087 48c7c3020000. mov rbx, 2 0x0040008e 48c7c1030000. mov rcx, 3 0x00400095 48c7c2040000. mov rdx, 4 0x0040009c 48c7c6050000. mov rsi, 5 0x004000a3 48c7c7060000. mov rdi, 6 0x004000aa 48c7c5070000. mov rbp, 7 0x004000b1 48c7c4080000. mov rsp, 8 0x004000b8 49c7c0090000. mov r8, 9 0x004000bf 49c7c10a0000. mov r9, 0xa 0x004000c6 49c7c20b0000. mov r10, 0xb ; 11 0x004000cd 49c7c30c0000. mov r11, 0xc ; 12 0x004000d4 49c7c40d0000. mov r12, 0xd ; 13 0x004000db 49c7c50e0000. mov r13, 0xe ; 14 0x004000e2 49c7c60f0000. mov r14, 0xf ; 15 0x004000e9 49c7c7100000. mov r15, 0x10 ; 16 0x004000f0 0f0b ud2
pd 17
дизассемблирует нам 17 инструкций. Можно посмотреть встроенную справку по pd
если ввести pd?
.
radare2 по-умолчанию работает с intel-синтаксисом, а не AT&T на котором написан наш код. В интел синтаксисе отличается порядок операндов (источник — назначение), например mov rax, 1
в интел синтаксисе эквивалентен movq $1, %rax
. Есть и другие отличия. Мы пока переключим отображение на AT&T синтаксис т. к. код у нас написан в AT&T, к интел мы вернемся потом. Для начала убедимся, что у нас тут действительно синтаксис intel
[0x00400080]> e asm.syntax intel
И изменим его
[0x00400080]> e asm.syntax=att [0x00400080]> e asm.syntax att
После чего мы получим дизасм в at&t синтаксисе
[0x00400080]> pd 17 ;-- entry0: ;-- section..text: ;-- _start: ;-- rip: 0x00400080 48c7c0010000. movq $1, %rax ; [1] va=0x00400080 pa=0x00000080 sz=114 vsz=114 rwx=--r-x .text 0x00400087 48c7c3020000. movq $2, %rbx 0x0040008e 48c7c1030000. movq $3, %rcx 0x00400095 48c7c2040000. movq $4, %rdx 0x0040009c 48c7c6050000. movq $5, %rsi 0x004000a3 48c7c7060000. movq $6, %rdi 0x004000aa 48c7c5070000. movq $7, %rbp 0x004000b1 48c7c4080000. movq $8, %rsp 0x004000b8 49c7c0090000. movq $9, %r8 0x004000bf 49c7c10a0000. movq $0xa, %r9 0x004000c6 49c7c20b0000. movq $0xb, %r10 ; 11 0x004000cd 49c7c30c0000. movq $0xc, %r11 ; 12 0x004000d4 49c7c40d0000. movq $0xd, %r12 ; 13 0x004000db 49c7c50e0000. movq $0xe, %r13 ; 14 0x004000e2 49c7c60f0000. movq $0xf, %r14 ; 15 0x004000e9 49c7c7100000. movq $0x10, %r15 ; 16 0x004000f0 0f0b ud2
И мы получили это в том же синтаксисе, что и в исходном коде. С синтаксисом intel мы тоже вне всякого сомнения познакомимся, но не сейчас. Кроме того, тут еще есть проблема, что hex длинных опкодов выводится не полностью. Например тут
0x00400080 48c7c0010000. movq $1, %rax ^
Мы видим точку в конце, это означает что оно обрезано. Чтобы это исправить, можно изменить значение asm.nbytes. Посмотреть значение asm.nbytes и изменить его можно вот так
[0x00400080]> e asm.nbytes 6 [0x00400080]> e asm.nbytes=15 ;-- entry0: ;-- section..text: ;-- _start: ;-- rip: 0x00400080 48c7c001000000 movq $1, %rax ; [1] va=0x00400080 pa=0x00000080 sz=114 vsz=114 rwx=--r-x .text 0x00400087 48c7c302000000 movq $2, %rbx 0x0040008e 48c7c103000000 movq $3, %rcx 0x00400095 48c7c204000000 movq $4, %rdx 0x0040009c 48c7c605000000 movq $5, %rsi
Теперь шестнадцатиричное представление инструкции выводится без сокращения.
Можно в rc файле ~/.radare2rc
прописать e asm.syntax=att
и e asm.nbytes=30
, таким образом у нас эти переменные будет выставлены в нужные значения сразу же при запуске. Чтобы просмотреть список всех этих выставляемых переменных, можно ввести e
. В radare2 так же работает автодополнение по нажатию Tab
, можно прочитать встроенную справку, введя к примеру pd?
(т. е. добавив вопросительный знак в конце).
Взглянем на начальное состояние регистров:
[0x00400080]> dr= orax 0x0000003b rax 0x00000000 rbx 0x00000000 rcx 0x00000000 rdx 0x00000000 r8 0x00000000 r9 0x00000000 r10 0x00000000 r11 0x00000000 r12 0x00000000 r13 0x00000000 r14 0x00000000 r15 0x00000000 rsi 0x00000000 rdi 0x00000000 rsp 0x7fffae9975e0 rbp 0x00000000 rip 0x00400080 rflags I
Это далеко не все регистры, кроме того, регистр orax это не регистр вовсе. rsp
у нас не нулевой, он указывает на стек, с ним мы поработаем несколько позже. rip
это instruction pointer т. е. указатель на инструкции, он содержит в себе адрес. В данном случае, ни одной инструкции еще не было выполнено, и rip совпадает с Entry point address который нам показал readelf — 0x400080
. Кроме того, обратим внимание на ;-- rip:
;-- rip: 0x00400080 48c7c0010000. movq $1, %rax ; [1] va=0x00400080 pa=0x00000080 sz=114 vsz=114 rwx=--r-x .text 0x00400087 48c7c3020000. movq $2, %rbx
Тут мы видим что этот самый регистр rip указывает на конкретно вот эту инструкцию movq $1, %rax
. Слева напротив самой мнемоники мы видим 48c7c0010000
— это то, как эта самая инструкция закодирована, т.е. ее представление в 16-ричной системе счисления. Выполним одну инструкцию, после чего дизассемблируем опять и посмотрим на состояние регистров
[0x00400080]> ds 1 [0x00400080]> pd 5 ;-- entry0: ;-- section..text: ;-- _start: 0x00400080 48c7c001000000 movq $1, %rax ; [1] va=0x00400080 pa=0x00000080 sz=114 vsz=114 rwx=--r-x .text ;-- rip: 0x00400087 48c7c302000000 movq $2, %rbx 0x0040008e 48c7c103000000 movq $3, %rcx 0x00400095 48c7c204000000 movq $4, %rdx 0x0040009c 48c7c605000000 movq $5, %rsi [0x00400080]> dr= orax 0xffffffffffffffff rax 0x00000001 rbx 0x00000000 rcx 0x00000000 rdx 0x00000000 r8 0x00000000 r9 0x00000000 r10 0x00000000 r11 0x00000000 r12 0x00000000 r13 0x00000000 r14 0x00000000 r15 0x00000000 rsi 0x00000000 rdi 0x00000000 rsp 0x7fffe24a9f60 rbp 0x00000000 rip 0x00400087 rflags 1I
Видно, что ;-- rip:
у нас сместился на одну строчку ниже, и значение rip
изменилось, оно стало равным 0x00400087
, т. е. теперь оно указывает на вторую инструкцию. Первая инструкция movq $1, %rax
была успешно выполнена, и мы можем видеть что в регистре rax
у нас хранится значение 0x00000001
. Можно не делать все пошагово, а просто дать нашей программе успешно наткнуться на инструкцию ud2
и таким образом упасть. Тогда мы посмотрим состояние регистров и убедимся что в них записаны соответствующие значения
[0x00400080]> dc attach 9766 1 [+] signal 4 aka SIGILL received 0 [0x004000f0]>
Как мы можем видеть, наш процесс получил сигнал SIGILL. В POSIX-системах, SIGILL — сигнал, посылаемый процессу при попытке выполнить неправильно сформированную, несуществующую или привилегированную инструкцию. Инструкция ud2
является некорректной, притом ее некорректность закреплена в документации интел и амд. Обычно после SIGILL следует завершение с дампом памяти(в чем мы ранее убедились), но сейчас мы работаем в отладчике. Мы можем посмотреть состояние регистров
[0x004000f0]> dr= orax 0xffffffffffffffff rax 0x00000001 rbx 0x00000002 rcx 0x00000003 rdx 0x00000004 r8 0x00000009 r9 0x0000000a r10 0x0000000b r11 0x0000000c r12 0x0000000d r13 0x0000000e r14 0x0000000f r15 0x00000010 rsi 0x00000005 rdi 0x00000006 rsp 0x00000008 rbp 0x00000007 rip 0x004000f0 rflags 1IV
И действительно, в регистры были записаны соответствующие значения. Что можно сделать еще? Давайте перезапустим наш процесс и попробуем поработать в visual mode. Посмотрев встроенную помощь d?
можно найти нужную для этого команду
| do Open process (reload, alias for 'oo')
попробуем:
[0x004000f0]> do Wait event received by different pid 9792 Process with PID 9821 started... File dbg://./test1 reopened in read-write mode attach 9821 9821 Assuming filepath ./test1 Warning: Cannot initialize dynamic strings [0x00400080]> V
...
[0x00400080 608 ./test1]> x @ entry0 - offset - 0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF 0x00400080 48c7 c001 0000 0048 c7c3 0200 0000 48c7 H......H......H. 0x00400090 c103 0000 0048 c7c2 0400 0000 48c7 c605 .....H......H... 0x004000a0 0000 0048 c7c7 0600 0000 48c7 c507 0000 ...H......H..... 0x004000b0 0048 c7c4 0800 0000 49c7 c009 0000 0049 .H......I......I 0x004000c0 c7c1 0a00 0000 49c7 c20b 0000 0049 c7c3 ......I......I.. 0x004000d0 0c00 0000 49c7 c40d 0000 0049 c7c5 0e00 ....I......I.... 0x004000e0 0000 49c7 c60f 0000 0049 c7c7 1000 0000 ..I......I...... 0x004000f0 0f0b 002e 7379 6d74 6162 002e 7374 7274 ....symtab..strt 0x00400100 6162 002e 7368 7374 7274 6162 002e 7465 ab..shstrtab..te 0x00400110 7874 0000 0000 0000 0000 0000 0000 0000 xt..............
...
Это немного не то, что нам надо. Переключим режим отображения, нажав на кнопку p
некоторое количество раз, пока мы не увидем перед собой такой картины:
[0x00400080 170 ./test1]> ?0;f tmp;s.. @ entry0 - offset - 0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF 0x7ffebe940b40 0100 0000 0000 0000 0e27 94be fe7f 0000 .........'...... 0x7ffebe940b50 0000 0000 0000 0000 1627 94be fe7f 0000 .........'...... 0x7ffebe940b60 5027 94be fe7f 0000 5b27 94be fe7f 0000 P'......['...... 0x7ffebe940b70 8027 94be fe7f 0000 9227 94be fe7f 0000 .'.......'...... orax 0x0000003b rax 0x00000000 rbx 0x00000000 rcx 0x00000000 rdx 0x00000000 r8 0x00000000 r9 0x00000000 r10 0x00000000 r11 0x00000000 r12 0x00000000 r13 0x00000000 r14 0x00000000 r15 0x00000000 rsi 0x00000000 rdi 0x00000000 rsp 0x7ffebe940b40 rbp 0x00000000 rip 0x00400080 rflags I ;-- entry0: ;-- section..text: ;-- _start: ;-- rip: 0x00400080 48c7c001000000 movq $1, %rax ; [1] va=0x00400080 pa=0x00000080 sz=114 vsz=114 rwx=--r-x .text 0x00400087 48c7c302000000 movq $2, %rbx 0x0040008e 48c7c103000000 movq $3, %rcx 0x00400095 48c7c204000000 movq $4, %rdx 0x0040009c 48c7c605000000 movq $5, %rsi 0x004000a3 48c7c706000000 movq $6, %rdi 0x004000aa 48c7c507000000 movq $7, %rbp 0x004000b1 48c7c408000000 movq $8, %rsp 0x004000b8 49c7c009000000 movq $9, %r8 0x004000bf 49c7c10a000000 movq $0xa, %r9 0x004000c6 49c7c20b000000 movq $0xb, %r10 ; 11 0x004000cd 49c7c30c000000 movq $0xc, %r11 ; 12 0x004000d4 49c7c40d000000 movq $0xd, %r12 ; 13 0x004000db 49c7c50e000000 movq $0xe, %r13 ; 14 0x004000e2 49c7c60f000000 movq $0xf, %r14 ; 15 0x004000e9 49c7c710000000 movq $0x10, %r15 ; 16 0x004000f0 0f0b ud2
Вот в таком представлении мы и будем работать. Теперь, используя кнопку F7
или s
мы можем пошагово выполнять инструкции, сразу же видя изменения в соответствующих регистрах процессора. При этом регистры, изменяющие свои значения, будут подсвечиваться. И метка ;-- rip:
(как и значение самого регистра rip
) будет смещаться вниз на каждом шаге, пока не дойдет до этой некорректной ud2
инструкции. Можно еще обратить внимание, что когда будет выполнена инструкция mov rsp, 8
вот эта часть
- offset - 0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF 0x7ffea3cb7960 0100 0000 0000 0000 0f87 cba3 fe7f 0000 ................ 0x7ffea3cb7970 0000 0000 0000 0000 1787 cba3 fe7f 0000 ................ 0x7ffea3cb7980 5187 cba3 fe7f 0000 5c87 cba3 fe7f 0000 Q.......\....... 0x7ffea3cb7990 8187 cba3 fe7f 0000 9387 cba3 fe7f 0000 ................
Будет показывать что-то совсем другое, а именно
- offset - 0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF 0x00000008 ffff ffff ffff ffff ffff ffff ffff ffff ................ 0x00000018 ffff ffff ffff ffff ffff ffff ffff ffff ................ 0x00000028 ffff ffff ffff ffff ffff ffff ffff ffff ................ 0x00000038 ffff ffff ffff ffff ffff ffff ffff ffff ................
И адрес начинаться будет с 0x00000008
. Тут в этой области нам показывают содержимое стека, и когда мы меняем регистр, хранящий адрес стека (присваиваем ему число 8), мы попадаем на адреса, на которые не отображена никакая физическая память. Процесс работает не с реальными (физическими) адресами, а с виртуальными (это обеспечивается через MMU) и в это виртуальное адресное пространство процесса по тем адресам ничего нет. Можно кстати посмотреть process maps, набрав dm
. Выйти из visual mode можно комбинацией Ctrl+D
или q
[0x0040006b]> dm sys 4K 0x0000000000400000 * 0x0000000000401000 s -r-x /home/user/learn/asm/01_test/1/test1 /home/user/learn/asm/01_test/1/test1 sys 132K 0x00007ffd22881000 - 0x00007ffd228a2000 s -rwx [stack] [stack] sys 8K 0x00007ffd22917000 - 0x00007ffd22919000 s -r-x [vdso] [vdso] sys 4K 0xffffffffff600000 - 0xffffffffff601000 s -r-x [vsyscall] [vsyscall]
Тут видно, что на адреса от 0x0000000000400000 * 0x0000000000401000
отображено содержимое секции text из нашего elf файле, занимает оно 0x1000
байт (16-ричная система счисления).
readelf
показал нам ранее точку входа:
Entry point address: 0x400080
которая не совпадает с адресом 0x400000
Давайте же посмотрим на наш elf файл в 16-ричной системе счисления
user@localhost:~/learn/asm/01_test/1$ hexdump -C test1 00000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 |.ELF............| 00000010 02 00 3e 00 01 00 00 00 80 00 40 00 00 00 00 00 |..>.......@.....| 00000020 40 00 00 00 00 00 00 00 18 01 00 00 00 00 00 00 |@...............| 00000030 00 00 00 00 40 00 38 00 01 00 40 00 05 00 02 00 |....@.8...@.....| 00000040 01 00 00 00 05 00 00 00 00 00 00 00 00 00 00 00 |................| 00000050 00 00 40 00 00 00 00 00 00 00 40 00 00 00 00 00 |..@.......@.....| 00000060 f2 00 00 00 00 00 00 00 f2 00 00 00 00 00 00 00 |................| 00000070 00 00 20 00 00 00 00 00 00 00 00 00 00 00 00 00 |.. .............| 00000080 48 c7 c0 01 00 00 00 48 c7 c3 02 00 00 00 48 c7 |H......H......H.| 00000090 c1 03 00 00 00 48 c7 c2 04 00 00 00 48 c7 c6 05 |.....H......H...| 000000a0 00 00 00 48 c7 c7 06 00 00 00 48 c7 c5 07 00 00 |...H......H.....| 000000b0 00 48 c7 c4 08 00 00 00 49 c7 c0 09 00 00 00 49 |.H......I......I| 000000c0 c7 c1 0a 00 00 00 49 c7 c2 0b 00 00 00 49 c7 c3 |......I......I..| 000000d0 0c 00 00 00 49 c7 c4 0d 00 00 00 49 c7 c5 0e 00 |....I......I....| 000000e0 00 00 49 c7 c6 0f 00 00 00 49 c7 c7 10 00 00 00 |..I......I......| 000000f0 0f 0b 00 2e 73 79 6d 74 61 62 00 2e 73 74 72 74 |....symtab..strt| 00000100 61 62 00 2e 73 68 73 74 72 74 61 62 00 2e 74 65 |ab..shstrtab..te| 00000110 78 74 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |xt..............| 00000120 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| * 00000150 00 00 00 00 00 00 00 00 1b 00 00 00 01 00 00 00 |................| 00000160 06 00 00 00 00 00 00 00 80 00 40 00 00 00 00 00 |..........@.....| 00000170 80 00 00 00 00 00 00 00 72 00 00 00 00 00 00 00 |........r.......| 00000180 00 00 00 00 00 00 00 00 10 00 00 00 00 00 00 00 |................| 00000190 00 00 00 00 00 00 00 00 11 00 00 00 03 00 00 00 |................| 000001a0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| 000001b0 f2 00 00 00 00 00 00 00 21 00 00 00 00 00 00 00 |........!.......| 000001c0 00 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00 |................| 000001d0 00 00 00 00 00 00 00 00 01 00 00 00 02 00 00 00 |................| 000001e0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| 000001f0 58 02 00 00 00 00 00 00 90 00 00 00 00 00 00 00 |X...............| 00000200 04 00 00 00 02 00 00 00 08 00 00 00 00 00 00 00 |................| 00000210 18 00 00 00 00 00 00 00 09 00 00 00 03 00 00 00 |................| 00000220 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| 00000230 e8 02 00 00 00 00 00 00 20 00 00 00 00 00 00 00 |........ .......| 00000240 00 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00 |................| 00000250 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| * 00000270 00 00 00 00 03 00 01 00 80 00 40 00 00 00 00 00 |..........@.....| 00000280 00 00 00 00 00 00 00 00 01 00 00 00 12 00 01 00 |................| 00000290 80 00 40 00 00 00 00 00 72 00 00 00 00 00 00 00 |..@.....r.......| 000002a0 08 00 00 00 10 00 01 00 f2 00 60 00 00 00 00 00 |..........`.....| 000002b0 00 00 00 00 00 00 00 00 14 00 00 00 10 00 01 00 |................| 000002c0 f2 00 60 00 00 00 00 00 00 00 00 00 00 00 00 00 |..`.............| 000002d0 1b 00 00 00 10 00 01 00 f8 00 60 00 00 00 00 00 |..........`.....| 000002e0 00 00 00 00 00 00 00 00 00 5f 73 74 61 72 74 00 |........._start.| 000002f0 5f 5f 62 73 73 5f 73 74 61 72 74 00 5f 65 64 61 |__bss_start._eda| 00000300 74 61 00 5f 65 6e 64 00 |ta._end.| 00000308
В самом начале у нас есть ELF заголовок, последовательность байт 7f 45 4c 46
, дальше всякая служебная информация, например где точка входа, на какие адреса что отображать. Это мы возможно разберем потом.
Сейчас же, обратим внимание на вот эту часть нашего вывода, которая начинается с 00000080:
00000080 48 c7 c0 01 00 00 00 48 c7 c3 02 00 00 00 48 c7 |H......H......H.|
И идущее далее. Если мы посмотрим на дизасм, который нам выдавал radare2
{ вот это вот} 0x00400080 48c7c001000000 movq $1, %rax ; [1] va=0x00400080 pa=0x00000080 sz=114 vsz=114 rwx=--r-x .text 0x00400087 48c7c302000000 movq $2, %rbx
То можно увидеть полное соответствие, инструкция mov rax, 1
кодируется последовательностью байтов 48 c7 c0 01 00 00
, эту последовательность мы видем и в дизасме radare2, и в выводе hexdump. Можно это показать
{ mov $1, %rax } { mov $2, %rbx } { ... 00000080 48 c7 c0 01 00 00 00 48 c7 c3 02 00 00 00 48 c7 |H......H......H.|
Только в radare2 мы видем все по смещению 00400080 а в самом файле по смещению 00000080. Это все можно легко проверить. Используя rasm2
мы можем ассемблировать и дизассемблировать. rasm2 -L
покажет список доступных движков для ассемблирования и дизассемблирования. Можно легко отфильтровать те, которые нас на данный момент интересуют: rasm2 -L | grep x86
.
Мы будем пользоваться x86.as
для ассемблирования и x86
для дизассемблирования. Для примера, попробуем дизассемблировать 48 c7 c0 01 00 00 00
:
$ rasm2 -a x86 -s att -b 64 -d '48 c7 c0 01 00 00 00' movq $1, %rax $ rasm2 -a x86 -s att -b 64 -d '48c7c001000000' movq $1, %rax
Как мы видим, пробелы в строке полностью игнорируются. А ассемблировать можно так:
$ rasm2 -a x86.as -s att -b 64 'mov $1, %rax' 48c7c001000000
Для интел синтаксиса порядок операторов другой и нет «%» и «$» перед регистром и числовым значением соответствено:
$ rasm2 -a x86.as -s intel -b 64 'mov rax, 1' 48c7c001000000
Забегая вперед, скажу, что для более сложных инструкций отличия будут более значительны, например инструкция lea:
$ rasm2 -a x86.as -s intel -b 64 'lea rax, [rax+rbx*2+4]' 488d445804 $ rasm2 -a x86.as -s att -b 64 'leaq 4(%rax, %rbx, 2), %rax' 488d445804
Эта инструкция выполняет действие rax = rax + rbx*2 + 4 и тут intel синтаксис оказывается значительно более понятным.
Каждая «цифра» в 16-ричной системе счисления кодирует по 4 бита. f
например кодирует 1111
в двоичной системе, а 7
кодирует 0111
. Пара «цифр» в 16-ричной системе счисления образует 8-битный байт (бывают и байты другой битности, но мы их не будем рассматривать в рамках этой книги). Если мы вышеобозначенный f
заменим на 7
то последний байт будет 0111 1111
вместо 1111 1111
.
$ rasm2 -a x86.udis -s att -b 64 -d '48 c7 c0 ff ff ff 7f' mov $0x7fffffff, %rax $ rasm2 -a x86 -s att -b 64 -d '48 c7 c0 ff ff ff 7f' movq $0x7fffffff, %rax
И если заменить 7f
на 8f
(отчего последний байт станет 0111 1111
), мы на выходе получим совсем другой результат:
$ rasm2 -a x86 -s att -b 64 -d '48 c7 c0 ff ff ff 8f' movq $-0x70000001, %rax $ rasm2 -a x86.udis -s att -b 64 -d '48 c7 c0 ff ff ff 8f' mov $0xffffffff8fffffff, %rax
Очевидно, что такой вот инструкцией movq
мы не можем вписать в 64-битный регистр произвольное число от ffffffffffffffff
до 0000000000000000
— количество байт, выделенное в опкоде под запись непосредственно того числа, которое мы хотим поместить в регистр, слишком мало. Тут происходит так называемое знаковое расширение, о чем более подробно будет сказано позже.
Рассмотрим кодирование инструкции более подробно: 48 c7 c_ ?? ?? ?? ??
, где вопросительные знаки это то число, которое мы собственно и записываем в регистр. При этом используется порядок байт little endian (см. https://en.wikipedia.org/wiki/Endianness) т. е. чтоб записать единицу, необходимо сделать 48 c7 c_ 01 00 00 00
. И последний бит, как уже было ранее сказано, выставляет в ffff...
или в 0000...
старшую часть 64-битного регистра (знаковое расширение), т. е. это означает, что 48 c7 c_ 01 00 00 00
запишет в регистр 0x1
, а например 48 c7 c_ 00 01 00 00
запишет уже 0x100
. А если 00 00 00 80
то записано будет число ffffffff80000000
. Символом подчеркивания _
тут 48 c7 c_ ?? ?? ?? ??
обозначена та часть, меняя которую мы можем изменить то, в какой конкретно регистр будет записано наше число. Например, 48c7c001000000
, 48c7c101000000
, 48c7c201000000
будут записывать единицу в регистр %rax
, %rcx
, %rdx
и %rbx
соответственно. Так можно продолжать вплоть до 48c7c701000000
, дальше уже будет некорректная инструкция. Это кстати можно видеть в дизасме нашей тестовой программы:
V 0x00400080 48c7c001000000 movq $1, %rax ; [1] va=0x00400080 pa=0x00000080 sz=114 vsz=114 rwx=--r-x .text 0x00400087 48c7c302000000 movq $2, %rbx 0x0040008e 48c7c103000000 movq $3, %rcx 0x00400095 48c7c204000000 movq $4, %rdx ^
Вот эта самая частьc0
c3
c1
c2
, отвечающая за то, в какой регистр что записать. Для регистров %r8—15
применяется, как нетрудно заметить, немного другие инструкции
V 0x004000b8 49c7c009000000 movq $9, %r8 0x004000bf 49c7c10a000000 movq $0xa, %r9 0x004000c6 49c7c20b000000 movq $0xb, %r10 0x004000cd 49c7c30c000000 movq $0xc, %r11 0x004000d4 49c7c40d000000 movq $0xd, %r12 0x004000db 49c7c50e000000 movq $0xe, %r13 0x004000e2 49c7c60f000000 movq $0xf, %r14 0x004000e9 49c7c710000000 movq $0x10, %r15 ^
49c7
вместо 48c7
, а часть с c0—c7
отвечает за то, в какой из этих %r?
регистров записать число. Но подробно вникать в то, как какая инструкция каким образом кодируется, не нужно практически никогда. Это может быть нужно в случае, если вы пишете свой ассемблер, дизассемблер, делаете JIT компилятор (примечание: можно будет потом написать в книге о том, как это можно сделать) который непосредственно в процессе выполнения программы генерирует машинный код и помещает его в исполняемый регион памяти, пишете компилятор, который сразу же генерирует на выходе двоичный код (а не код на ассемблере, как это делает clang или gcc) или если необходимо сильно запутать код всяким там самомодифицирующимся кодом и прыжками в середину инструкций, усложнив тем самым его анализ. Кстати регистров нам доступно значительно больше, и даже эти регистры, которые мы сейчас рассматриваем, они «составлены» из отдельных кусочков-подрегистров.
Данная статья или раздел ещё не завершены Кто-то посчитал, что статья или раздел ниже не содержит какой-то важной информации или имеет проблемы с вёрсткой/текстом. Указана причина: «рассмотреть множественность интерпретации (дизассемблирования) определенной последовательности байт»{{#ifeq: {{{1}}} | nopoint | | . }} |
{{#if: Доработка | }}
48 | b8 | 01 | 48 | 31 | c0 | 48 | 8d | 04 | 18 |
movabsq $0x18048d48c0314801, %rax | |||||||||
movl $0xc0314801, %eax | leaq (%rax, %rbx), %rax | ||||||||
xorq %rax, %rax | |||||||||
xorl %eax, %eax | |||||||||
addl %ecx, 0x31(%rax) | rorb $4, -0x73(%rax) | ||||||||
leal (%rax, %rbx), %eax | |||||||||
addb $0x18, %al |
Если мы запустим нашу программу в отладчике и получим содержимое памяти от 0x00400000 до 0x00400308, то мы фактически получим весь наш ELF файл, который уместился в один сегмент. Конечно это не всегда так, но у нас очень простая программа, и тут это сработает.
[0x00400080]> px 0x308 @ 0x400000 - offset - 0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF 0x00400000 7f45 4c46 0201 0100 0000 0000 0000 0000 .ELF............ 0x00400010 0200 3e00 0100 0000 8000 4000 0000 0000 ..>.......@..... 0x00400020 4000 0000 0000 0000 1801 0000 0000 0000 @............... 0x00400030 0000 0000 4000 3800 0100 4000 0500 0200 ....@.8...@..... 0x00400040 0100 0000 0500 0000 0000 0000 0000 0000 ................ ...
Мы можем даже сохранить (дампнуть) это в бирнарном виде, сделать это можно следующим образом
[0x00400080]> y 0x308 @ 0x400000 [0x00400080]> yp > outfile.bin
И если сопоставить получившийся outfile.bin
с нашим test1
то отличий не будут (только в самый конец файла будет добавлен 0x0a
байт — перенос строки (newline))
Можно еще сделать это вот так:
[0x00400080]> wt outfile.bin 0x308 @ 0x400000 dumped 0x308 bytes Dumped 776 bytes from 0x00400000 into outfile.bin
тогда переноса строки в конце файла не будет, все будет в точности совпадать.
Данная статья или раздел ещё не завершены Кто-то посчитал, что статья или раздел ниже не содержит какой-то важной информации или имеет проблемы с вёрсткой/текстом. Указана причина: TODO overload:
|
{{#if: Доработка | }}
Регистры
64 | 56 | 48 | 40 | 32 | 24 | 16 | 8 |
---|---|---|---|---|---|---|---|
R?X | |||||||
E?X | |||||||
?X | |||||||
?H | ?L |
64 | 56 | 48 | 40 | 32 | 24 | 16 | 8 |
---|---|---|---|---|---|---|---|
? | |||||||
?D | |||||||
?W | |||||||
?B |
16 | 8 |
---|---|
?S |
64 | 56 | 48 | 40 | 32 | 24 | 16 | 8 |
---|---|---|---|---|---|---|---|
R?P | |||||||
E?P | |||||||
?P | |||||||
?PL |
Примечание: ?PL регистры доступны только в 64-bit mode.
64 | 56 | 48 | 40 | 32 | 24 | 16 | 8 |
---|---|---|---|---|---|---|---|
R?I | |||||||
E?I | |||||||
?I | |||||||
?IL |
Примечание: ?IL регистры доступны только в 64-bit mode.
64 | 56 | 48 | 40 | 32 | 24 | 16 | 8 |
---|---|---|---|---|---|---|---|
RIP | |||||||
EIP | |||||||
IP |
В 32-битном режиме работы, 64-битные регистры недоступны, но есть некоторые инструкции, которые могут работать с двумя 32-битными регистрами, рассматривая их как один 64-битный, например инструкция беззнакового умножения http://x86.renejeschke.de/html/file_module_x86_id_210.html. В 64-битном режиме тоже есть инструкции, которые возвращают результат сразу в двух 64-битных регистрах (например инструкция умножения или деления, которая сразу может вернуть и результат деления, и остаток от деления).
Чтобы лучше разобраться с этими кусочками регистров, напишем несколько программ-примеров, которые что-то с этими кусочками делают. Создадим в директории ~/learn/asm/01_test/2/
файл test2.s
следующего содержания
.section .text,"ax",@progbits .p2align 4,,15 .globl _start .type _start, @function _start: movabsq $0x1122334455667788, %rax movb $0xff, %al inc %al dec %al movb $0xab, %al inc %al dec %al movb $0xff, %ah inc %ah dec %ah movb $0xcd, %ah inc %ah dec %ah movw $0xffff, %ax inc %ax dec %ax movw $0xacbd, %ax movl $0xaabbccdd, %eax movl $0x7fffffff, %eax inc %eax dec %eax movl $0x80000000, %eax dec %eax inc %eax movl $0xffffffff, %eax inc %eax dec %eax movq $0xffffffffffffffff, %rax inc %eax dec %eax movq $0xffffffffffffffff, %rax inc %rax dec %rax movq $0xffffffffffffffff, %rax movabsq $0xffffffffffffffff, %rax ud2 .size _start, .-_start
Соберем его уже знакомым нам способом.
user@localhost:~/learn/asm/01_test/2$ as test2.s -o test2.o user@localhost:~/learn/asm/01_test/2$ ld test2.o -o test2
Между прочим, можно создать скрипт, который будет это делать сам. Но к этой теме мы вернемся несколько позже. Сейчас же давайте запустим получившийся исполняемый файл в отладичке radare2 и перейдем в визуальный режим.
user@localhost:~/learn/asm/01_test/2$ r2 -d test2 Process with PID 960 started... attach 960 960 bin.baddr 0x00400000 Assuming filepath ./test2 Warning: Cannot initialize dynamic strings Warning: Too big version info field 3 (496) asm.bits 64 -- In Soviet Russia, radare2 has documentation. [0x00400080]> V
Может потребоваться так же переключить отображения синтаксиса ассемблера в att: e asm.syntax=att
и длину отображение инструкций e asm.nbytes=30
если это не было сделано ранее в rc файле ~/.radare2rc
(о чем было ранее сказано). Потом нажимаем букву p
в английской раскладке на клавиатуре, пока не перейдем к такому виду:
[0x00400080 265 ./test2]> ?0;f tmp;s.. @ entry0 - offset - 0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF 0x7ffdd38518f0 0100 0000 0000 0000 0727 85d3 fd7f 0000 .........'...... 0x7ffdd3851900 0000 0000 0000 0000 0f27 85d3 fd7f 0000 .........'...... 0x7ffdd3851910 4927 85d3 fd7f 0000 5427 85d3 fd7f 0000 I'......T'...... 0x7ffdd3851920 7927 85d3 fd7f 0000 8b27 85d3 fd7f 0000 y'.......'...... orax 0x0000003b rax 0x00000000 rbx 0x00000000 rcx 0x00000000 rdx 0x00000000 r8 0x00000000 r9 0x00000000 r10 0x00000000 r11 0x00000000 r12 0x00000000 r13 0x00000000 r14 0x00000000 r15 0x00000000 rsi 0x00000000 rdi 0x00000000 rsp 0x7ffdd38518f0 rbp 0x00000000 rip 0x00400080 rflags I ;-- entry0: ;-- section..text: ;-- _start: ;-- rip: 0x00400080 48b88877665544332211 movabsq $0x1122334455667788, %rax ; [1] va=0x00400080 pa=0x00000080 sz=123 vsz=123 rwx=--r-x .text 0x0040008a b0ff movb $0xff, %al ; 255 0x0040008c fec0 incb %al 0x0040008e fec8 decb %al 0x00400090 b0ab movb $0xab, %al ; 171 0x00400092 fec0 incb %al 0x00400094 fec8 decb %al 0x00400096 b4ff movb $0xff, %ah ; 255 0x00400098 fec4 incb %ah 0x0040009a fecc decb %ah 0x0040009c b4cd movb $0xcd, %ah ; 205 0x0040009e fec4 incb %ah 0x004000a0 fecc decb %ah 0x004000a2 66b8ffff movw $0xffff, %ax 0x004000a6 66ffc0 incw %ax 0x004000a9 66ffc8 decw %ax 0x004000ac 66b8bdac movw $0xacbd, %ax 0x004000b0 b8ddccbbaa movl $0xaabbccdd, %eax 0x004000b5 b8ffffff7f movl $0x7fffffff, %eax 0x004000ba ffc0 incl %eax 0x004000bc ffc8 decl %eax 0x004000be b800000080 movl $0x80000000, %eax 0x004000c3 ffc8 decl %eax 0x004000c5 ffc0 incl %eax 0x004000c7 b8ffffffff movl $0xffffffff, %eax ; -1 ; -1 0x004000cc ffc0 incl %eax 0x004000ce ffc8 decl %eax 0x004000d0 48c7c0ffffffff movq $-1, %rax 0x004000d7 ffc0 incl %eax 0x004000d9 ffc8 decl %eax 0x004000db 48c7c0ffffffff movq $-1, %rax 0x004000e2 48ffc0 incq %rax 0x004000e5 48ffc8 decq %rax 0x004000e8 48c7c0ffffffff movq $-1, %rax 0x004000ef 48b8ffffffffffffffff movabsq $0xffffffffffffffff, %rax 0x004000f9 0f0b ud2
И теперь в этом отладочном режиме мы шаг за шагом пройдем все эти инструкции, вплоть до ud2.
Вначале идет инструкция movabs
(с суффиксом q
означающим что работаем мы с 64-битным регистром).
Инструкции ассемблер GAS как правило идут с суффиксами «b», «s», «w», «l», «q» или «t» чтобы задать размер операнда.
- b = byte (8 бит)
- s = short (16 бит integer) или single (32-бит floating point)
- w = word (16 бит)
- l = long (32 бит integer или 64-бит floating point)
- q = quad (64 бит)
- t = 10 байт (80-бит floating point)
В 64-битном коде abs
используется для кодирование mov
инструкции с 64-битным смещенем или непосредственым (immediate) операндом. При этом часть abs
может влиять, а может и не влиять на то, что нам сгенерирует ассемблер. Рассмотрим такой пример:
$ rasm2 -a x86.as -s att -b 64 'mov $0x1122334455667788, %rax' 48b88877665544332211 $ rasm2 -a x86.as -s att -b 64 'movq $0x1122334455667788, %rax' 48b88877665544332211 $ rasm2 -a x86.as -s att -b 64 'movabs $0x1122334455667788, %rax' 48b88877665544332211 $ rasm2 -a x86.as -s att -b 64 'movabsq $0x1122334455667788, %rax' 48b88877665544332211
Однако, если значение, которое мы хотим записать в регистр rax
будет в самом начале состоять из нулей (необходимо 33 подряд идущих нулевых бита в начале), результат будет отличаться:
$ rasm2 -a x86.as -s att -b 64 'mov $0x0000000055667788, %rax' 48c7c088776655 $ rasm2 -a x86.as -s att -b 64 'movq $0x0000000055667788, %rax' 48c7c088776655 $ rasm2 -a x86.as -s att -b 64 'movabs $0x0000000055667788, %rax' 48b88877665500000000 2$ rasm2 -a x86.as -s att -b 64 'movabsq $0x0000000055667788, %rax' 48b88877665500000000
Как видно, в данном случае abs
в явном виде задает, что операнд должен быть 64-битным. Если же нулевыми битами будет занято только 32 бита, ассемблер не сможет сгенерировать более короткую инструкцию. Например, возьмем такие операнды, чтобы в двоичной системе счисления тот 32 бит был нулем 0111 1111
(в 16-ричной системе счисления это будет 7f
) и чтобы бит был единицей: 1000 0000
(80
)
VV $ rasm2 -a x86.as -s att -b 64 'movq $0x000000007f667788, %rax' 48c7c08877667f VV $ rasm2 -a x86.as -s att -b 64 'movq $0x0000000080667788, %rax' 48b88877668000000000
Как видим, с единицей в 32-м бите ассемблер не может сгенерировать короткую инструкцию. Это все происходит оттого, что происходит расширение соответствующего бита на старшие разряды (это т. н. знаковое расширение). Для примера movq $0x000000007f667788, %rax
использовано всего 7 байт :48c7c08877667f
. В куске 48c7c0
закодирована как бы сама инструкция, а вот тут 8877667f
содержится само значение, только оно как бы отзеркалено, 7f
байт у нас лежит в самом конце, а 88
в самом начале. Это связано с особенностями реализации процессора, его порядком байт (endian mode). Вот таким нехитрым образом нулевой байт из 7f
расширяется на последущие разряды:
┏┳┳┳┳┳┳┳━┳┳┳┳┳┳┳┳━┳┳┳┳┳┳┳┳━┳┳┳┳┳┳┳┳━┓ ╿╿╿╿╿╿╿╿ ╿╿╿╿╿╿╿╿ ╿╿╿╿╿╿╿╿ ╿╿╿╿╿╿╿╿ ┃ 00000000 00000000 00000000 00000000 01111111 01100110 01110111 10001000 00 00 00 00 7f 66 77 88
Аналогичным образом, через расширение бита можно записать более короткую инструкцию для записи значений, в которых 33 бита заполнены единицами
$ rasm2 -a x86.as -s att -b 64 'movq $0xffffffff7f667788, %rax' 48b88877667fffffffff $ rasm2 -a x86.as -s att -b 64 'movq $0xffffffff80667788, %rax' 48c7c088776680
Т. е. тут мы наблюдаем обратную ситуацию. С 7f
инструкция выходит длиннее, чем с 80
потому что при 80
происходит заполнение старшего куска единицами, а в данном случае именно это нам и нужно. В последнем случае происходит такое знаковое расширение бит:
┏┳┳┳┳┳┳┳━┳┳┳┳┳┳┳┳━┳┳┳┳┳┳┳┳━┳┳┳┳┳┳┳┳━┓ ╿╿╿╿╿╿╿╿ ╿╿╿╿╿╿╿╿ ╿╿╿╿╿╿╿╿ ╿╿╿╿╿╿╿╿ ┃ 11111111 11111111 11111111 11111111 10000000 01100110 01110111 10001000 ff ff ff ff 80 66 77 88
Сделано это было для того, чтобы такой короткой инструкцией можно было бы записать в регистр в том числе и отрицательные числа (но при этом было вполовину урезано множество положительных чисел, которые можно записать этой инструкцией). Для представления отрицательных чисел используется т. н. дополнительный код https://ru.wikipedia.org/wiki/Дополнительный_код_(представление_числа) (еще называют two's complement).
Если мы пробуем заассемблировать инструкцию, где старшая 33-битная часть будет заполнена и не сплошными единицами и не сплошными нулями, мы всегда получаем более длинный опкод:
$ rasm2 -a x86.as -s att -b 64 'mov $0xbbaaaaaaaaaaaacc, %rax' 48b8ccaaaaaaaaaaaabb
Ассемблер старается подобрать более короткий опкод, когда возможно (если мы явно не указываем генерировать длинный опкод, как в случае с movabs
). Но разные ассемблеры могут выдавать разный результирующий двоичный код. В некоторых случаях (архитектуры Intel это не касается, хотя кто знает, может быть когда-нибудь…) ассемблер может даже переставлять инструкции местами с целью лучшей оптимизации. Например, для MIPS в GAS есть директива .set noreorder
которая отключает и .set reorder
которая включает «перетасовку» инструкций, чтоб лучше нагружать конвейер процессора. Современные процессоры исполняют инструкции не строго последовательно, а способны обрабатывать несколько инструкций за раз (на одном ядре) и исполнять инструкции не в том порядке, в котором они записаны (внеочередное исполнение, см https://en.wikipedia.org/wiki/Out-of-order_execution) но при этом важно отметить, что процессор всегда действует таким образом, чтобы все выглядело таким образом, будто бы инструкции выполняются последовательно. Иными словами, от этого внеочередного исполнения и конвейеризации программа не станет вести себя не так, как если б процессор исполнял инстукции строго последовательно. В этой книге я не планирую описывать особенности вычислительного конвейера разных процессоров. Дополнительные ссылки https://en.wikipedia.org/wiki/Instruction_pipelining https://en.wikipedia.org/wiki/Hazard_(computer_architecture), https://en.wikipedia.org/wiki/Bubble_(computing).
Некоторые ассемблеры даже инструкцию mov $0, %rax
ассемблируют таким образом, что она будет длинной. Например, сравним результат x86.as
x86.ks
x86.nasm
(для NASM придется использовать Intel синтаксис, т. к. другой синтаксис он не поддерживает)
$ rasm2 -a x86.as -s att -b 64 'mov $0, %rax' 48c7c000000000 $ rasm2 -a x86.ks -s att -b 64 'mov $0, %rax' 48b80000000000000000 $ rasm2 -a x86.nasm -s intel -b 64 'mov rax, 0' b800000000
Как видим, во всех трех случаях мы получили разные результаты. Между прочим, в более новых версиях это поведение может быть другим. Давайте разберем, какие конкретно инструкции тут были получены в результате ассемблирования.
$ rasm2 -a x86 -s att -b 64 -d '48c7c000000000' movq $0, %rax $ rasm2 -a x86 -s att -b 64 -d '48b80000000000000000' movabsq $0x0, %rax $ rasm2 -a x86 -s att -b 64 -d 'b800000000' movl $0, %eax
Все 3 инструкции тут отличаются. В первом случае используется mov с префиксом q (movq) — означает move quad (но в самой инструкии использовано всего 4 байта под само число, записываемое в регистр. Старшая часть получается от знакового расширения нулевого бита на старшую часть). Во втором используется abs означающий запись абсолютного (64-битного) без убирания нулей в опкоде. В третьем случае у нас nasm сгенерил опкод, который записывает в регистр %eax
(при этом задействуя) но инструкция, записывающая в %eax
срабатывает таким образом, что старшая часть 64-битного регистра при этом обнуляется (при этом не происходит никакого знакового расширения, т. е. иснтрукцией mov $0xffffffff, %rax
мы не заполняем весь 64-битный регистр целиком одними единицами, но старшая часть при этом всегда зануляется). Все 3 варианта по факту дают тот же результат, только сами опкоды отличаются в размере.
Примечание: перевести из 16-ричной системы счисления в двоичную можно с помощью rax2
$ rax2 Bx80 10000000b $ rax2 Bx7f 1111111b
Не лишним так же будет набрать rax2 -h
чтобы увидеть другие примеры использования.
Итак, нажав s
мы перешагнули через эту инструкцию movabsq
. Исполнение первой инструкции
0x00400080 48b88877665544332211 movabsq $0x1122334455667788, %rax ; [1] va=0x00400080 pa=0x00000080 sz=123 vsz=123 rwx=--r-x .text
Приводит к записи в регистр rax
значения 0x1122334455667788
Мы можем видеть, что значение rax у нас изменилось:
orax 0xffffffffffffffff rax 0x1122334455667788 rbx 0x00000000
При этом можно заметить, что строчка ;-- rip:
теперь опустилась ниже данной инструкии.
0x00400080 48b88877665544332211 movabsq $0x1122334455667788, %rax ; [1] va=0x00400080 pa=0x00000080 sz=123 vsz=123 rwx=--r-x .text ;-- rip: 0x0040008a b0ff movb $0xff, %al ; 255
Регистр-указатель на текущую инструкцию %rip
уменьшился на единицу, Метка ;-- rip:
— теперь она ниже предыдущей инструкции movabsq. Изменившиеся регистры подсвечиваются соответствующим цветом в консоли
Далее у нас идет инструкция movb
.
Инструкция movb
записана с суффиксом b
в конце. Это означает, что она работает с однобайтными операндами. В данном случае, она изменит значение в однобайтном регистре %al
.
Если суффикс не указан, и нет операнда памяти для команды, GAS выводит размер операнда из размера целевого регистра операнда (конечный операнд). В частности, movb $0xff, %al
можно поменять на mov $0xff, %al
и ассемблер нам выдаст то же самое. Это можно проверить через rasm:
$ rasm2 -a x86.as -s att -b 64 'movb $0xff, %al' b0ff $ rasm2 -a x86.as -s att -b 64 'mov $0xff, %al' b0ff
Пока что мы не разбирали работу прямой и относительной адресации где эти суффиксы будут необходимы (т. е. без их явного указания, ассемблер не сможет «понять», что именно мы хотим сделать). Это будет разобрано в одном из последующих примеров. В Intel синтаксисе используется иной способ задания размеров операнда, об этом также будет сказано в последующих примеров
Нажмем s
чтобы выполнить эту инструкцию, «перешагнуть через нее»:
0x00400080 48b88877665544332211 movabsq $0x1122334455667788, %rax ; [1] va=0x00400080 pa=0x00000080 sz=123 vsz=123 rwx=--r-x .text 0x0040008a b0ff movb $0xff, %al ; 255 ;-- rip: 0x0040008c fec0 incb %al
Данная инструкция записала в однобайтный регистр %al
значение 0xff
в 16-ричной системе счисления, в десятичной системе счисления будет это будет 255 что соответствующим образом показано в «примечаниях» к инструкции. Если посмотреть на состояние регистров, то мы заметим, что регистр %rax
слегка изменился. Изменилась его самая младшая половина
Было:
orax 0xffffffffffffffff rax 0x1122334455667788 rbx 0x0000000
Стало:
orax 0xffffffffffffffff rax 0x11223344556677ff rbx 0x0000000
Это все потому, что регистр %al
является самым младшим кусочком регистра %rax
. Таким вот образом мы можем частично перезаписать регистр %rax
. Что ж, идем дальше.
Дальше у нас идет инструкция incb %al
, увеличивающая регистр %al
на единицу.
;-- rip: 0x0040008c fec0 incb %al 0x0040008e fec8 decb %al
Сейчас обратите внимание на значение особого регистра rflags
и запомните его состояние т.к. после этой инструкции он изменится.
rflags 1I Выполним инструкцию. 0x0040008c fec0 incb %al ;-- rip: 0x0040008e fec8 decb %al
Как можно заметить, в регистре %rax
поменялся последний байт (последние два знака):
orax 0xffffffffffffffff rax 0x1122334455667700 rbx 0x00000000
Это все потому, что у нас произошло переполнение. Можно представить это следующим образом: если складывать в столбик два числа, 99 и 1, и при этом отбрасывать все, кроме последних двух цифр.
99 + 1 ___ 100 00
мы получаем ноль. Так и тут, 0xff
это самое большое число, которое может содержать в себе однобайтный регистр %al
, и после добавление единицы он просто обнуляется. А так как этот регистр являеся лишь кусочком 8-битного регистра %rax
, мы видим «обнуление» младшей части соответсвующего регистра. При этом значение особого регистра rflags
, на состояние которого я просил обратить внимание, тоже изменилось
Данная статья или раздел ещё не завершены Кто-то посчитал, что статья или раздел ниже не содержит какой-то важной информации или имеет проблемы с вёрсткой/текстом. Указана причина: «дописать про rflags. eflags возможно стоит добавить про cmovcc инструкции. Пока не пофикшено https://github.com/radare/radare2/issues/6374 данная часть дописана не будет»{{#ifeq: {{{1}}} | nopoint | | . }} |
{{#if: Доработка | }}
Арифметические операции
Некоторые системные вызовы, файловые дескрипторы, запись/чтение файлов/пайпов, стандартные потоки ввода-вывода, простейшие циклы, ветвления, рекурсия, вызов функции, адрес возврата, косвенная адресация
Получаем аргументы командной строки и переменных окружения в ассемблере
Некоторые алгоритмы и структуры данных
Пишем простейший стековый калькулятор (обратная польская нотация)
Сокеты, tcp, udp, raw socket, select, poll, epoll
Привилегии процесса, вызовы getrlimit(2), setrlimit(2), setuid(2), seteuid(2)…
Что-нибудь про VSDO
Си
Разные типы в Си, sizeof, указатели. Вывод на печать текста через системный вызов write(), работа с файлами, функции семейства printf() scanf()
Си является языком со слабой статической типизацией, с неявным приведением типов (кастами). Есть базовые целочисленные типы, спецификаторы для них char
, short
, int
, long
, long long
(при этом long
, long long
может идти перед int, но не char). Есть еще спецификатор, определяющее то, знаковый или беззнаковый у нас тип: signed
, unsigned
. Знаковость/беззнаковость char(без спецификатора знаковости) в стандарте не определена, для остальных целочисленных типов (без спецификатора знаковости) они являются знаковыми. Размеры типов определяются в байтах, но байты бывают разными. В Си байт далеко не всегда состоит из 8 бит. В заглавочном файле limits.h определен макрос CHAR_BIT, описывающий число бит в байте. Но байт не может быть менее чем 8-битным по современному стандарту, т. к. в 7 бит не хватит места отобразить требуемые по стандарту C11 SCHAR_MIN и SCHAR_MAX определенный в стандарте (см. 5.2.4.2.1 «Sizes of integer types <limits.h>»)
http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf, 6.7.2 «Type specifiers <limits.h>» — заглавочный файл limits.h
обычно находится в директории /usr/include/
. Узнать место, где по умолчанию производится поиск и подстановка заглавочных файлов, имена которых заключены в т. н. угловые скобки, можно через echo | gcc -E -Wp,-v -
Ну что ж, давайте попробуем поработать с некоторыми типами.
<syntaxhighlight lang="c">
- include <unistd.h>
int main (void) {
const char nl = '\n'; const char val_char = 0x4142434445464748ULL; const short val_short = 0x4142434445464748ULL; const long val_long = 0x4142434445464748ULL; const long long val_long_long = 0x4142434445464748ULL;
const int val_int = 0x4142434445464748ULL; const short int val_short_int = 0x4142434445464748ULL; const long int val_long_int = 0x4142434445464748ULL; const long long int val_long_long_int = 0x4142434445464748ULL; const __int128 val__int128 = ((__int128)(0x4142434445464748ULL)<<64) | ((__int128)(0x494A4B4C4D4E4F50ULL));
write(STDOUT_FILENO, "val_char:\n", sizeof("val_char:\n")-1); write(STDOUT_FILENO, &val_char, sizeof(val_char)); write(STDOUT_FILENO, &nl, sizeof(nl));
write(STDOUT_FILENO, "val_short:\n", sizeof("val_short:\n")-1); write(STDOUT_FILENO, &val_short, sizeof(val_short)); write(STDOUT_FILENO, &nl, sizeof(nl));
write(STDOUT_FILENO, "val_long:\n", sizeof("val_long:\n")-1); write(STDOUT_FILENO, &val_long, sizeof(val_long)); write(STDOUT_FILENO, &nl, sizeof(nl));
write(STDOUT_FILENO, "val_long_long:\n", sizeof("val_long_long:\n")-1); write(STDOUT_FILENO, &val_long_long, sizeof(val_long_long)); write(STDOUT_FILENO, &nl, sizeof(nl));
write(STDOUT_FILENO, "val_int:\n", sizeof("val_int:\n")-1); write(STDOUT_FILENO, &val_int, sizeof(val_int)); write(STDOUT_FILENO, &nl, sizeof(nl));
write(STDOUT_FILENO, "val_short_int:\n", sizeof("val_short_int:\n")-1); write(STDOUT_FILENO, &val_short_int, sizeof(val_short_int)); write(STDOUT_FILENO, &nl, sizeof(nl));
write(STDOUT_FILENO, "val_long_int:\n", sizeof("val_long_int:\n")-1); write(STDOUT_FILENO, &val_long_int, sizeof(val_long_int)); write(STDOUT_FILENO, &nl, sizeof(nl));
write(STDOUT_FILENO, "val_long_long_int:\n", sizeof("val_long_long_int:\n")-1); write(STDOUT_FILENO, &val_long_long_int, sizeof(val_long_long_int)); write(STDOUT_FILENO, &nl, sizeof(nl));
write(STDOUT_FILENO, "val__int128:\n", sizeof("val__int128:\n")-1); write(STDOUT_FILENO, &val__int128, sizeof(val__int128)); write(STDOUT_FILENO, &nl, sizeof(nl)); return 0;
} </syntaxhighlight>
Функция write
пишет в файловый дескриптор STDOUT_FILENO
(стандартный вывод) последовательность байтов, хранимую в типе. sizeof()
возвращает размер типа в байтах. Эта информация нужна функции write()
чтобы было понятно, сколько именно байтов надо вывести. Символ &
это взятие адреса переменной (получение указателя на нее). О работе указателей мы поговорим несколько позже. Если вы попробуете скомпилировать этот код, компилятор вам выдаст множество варнингов, связанных с тем, что мы пытаемся присвоить в тип бОльшее значение, чем он в себе может вместить. С типом __int128
вообще интересная история — такой тип не описан в стандарте Си, это — нестандартное расширение компилятора. И инициализируется о несколько странно, связано это опять таки с его нестандартностью, т. е. стандартом Си не предусмотрен тип __int128
и инициализировать его надо таким вот кривым методом — двумя кусками, один из которых сдвигаем на 64 бита влево… Впрочем, я забегаю вперед. Об операциях and, or и двоичных сдвигах будет написано позже.
Давайте пока разберемся, что же это за значение 0x4142434445464748ULL
и почему оно превращается в последовательность латинских букв HGFEDCBA
. Есть таблица соответствия определенных символов определенным кодам. В соответствии с этой таблицей, код 0x41
это символ A
, код 0x42
это символ B
, код 0x43
это символ C
и так далее. Но почему мы тогда получили вместо ABCDEFG
последовательность HGFEDCBA
? Ответ кроется в порядке байт (endianness), который принят в архитектуре x86 и x86-64. Более подробно этот момент освещен в соответствующей статье на вики
Взглянем на вывод нашей программы:
val_char: H val_short: HG val_long: HGFEDCBA val_long_long: HGFEDCBA val_int: HGFE val_short_int: HG val_long_int: HGFEDCBA val_long_long_int: HGFEDCBA val__int128: PONMLKJIHGFEDCBA
Для val_char
вывелась всего одна буква - это всё оттого, что размер у типа char на нашей платорме - 1 байт. Но вообще, размер char всегда будет 1 байт, но сам байт не всегда может состоять из 8 бит.
Из нашего присвоения
<syntaxhighlight lang="c">
const char val_char = 0x4142434445464748ULL;
</syntaxhighlight>
в переменную val_char
записался только самый конец - 0x48
- который соответствует символу H
Для val_short
вывелось уже целых две буквы HG
:
<syntaxhighlight lang="c">
const short val_short = 0x4142434445464748ULL;
</syntaxhighlight> Тут можно заменить это на <syntaxhighlight lang="c">
const short val_short = 0x4748;
</syntaxhighlight>
и поведение останется тем же. Компилятор просто срезает все старшие разряды, оставляя только этот кусочек 0x4748
. И выводится у нас сначала H
и потом G
из-за little-endian порядка байт. Для всех прочих типов история повторяется.
Рассмотрим более подробно некоторые моменты.
Вот например строчка write(STDOUT_FILENO, "val_char:\n", sizeof("val_char:\n")-1);
и строчка write(STDOUT_FILENO, "val_short:\n", sizeof("val_short:\n")-1);
и прочие строчки, подобные ей. Что тут происходит? Функция write, как ранее уже было сказано, является сишной оберткой над линуксовым системным вызовом write. Более подробно о нем можно почитать в соотвествующей статье на википедии, можно еще набрать man 2 write
или почитать в интернете: https://linux.die.net/man/2/write. Первым аргументом эта функция принимает номер файлового дескриптора, в данном случае это у нас STDOUT_FILENO
который определен в заглавочном файле unistd.h
, его обычно можно найти по адресу /usr/include/unistd.h
, там же определен прототип функции write().
<syntaxhighlight lang="c">
/* Write N bytes of BUF to FD. Return the number written, or -1.
This function is a cancellation point and therefore not marked with __THROW. */
extern ssize_t write (int __fd, const void *__buf, size_t __n) __wur; </syntaxhighlight>
И тут видно, что первый аргумент int
, второй void *
, третий size_t
. Первый аргумент это номер файлового дескриптора. STDOUT_FILENO
определен во все том же файле unistd.h
<syntaxhighlight lang="c">
/* Standard file descriptors. */
- define STDIN_FILENO 0 /* Standard input. */
- define STDOUT_FILENO 1 /* Standard output. */
- define STDERR_FILENO 2 /* Standard error output. */
</syntaxhighlight>
Т.е. если написать write(0, "val_char:\n", sizeof("val_char:\n")-1);
вместо write(STDOUT_FILENO, "val_char:\n", sizeof("val_char:\n")-1);
- будет то же самое. Про данный заглавочный файл еще сказано в POSIX.1-2017: http://pubs.opengroup.org/onlinepubs/9699919799/basedefs/unistd.h.html :
The <unistd.h> header shall define the following symbolic constants for file streams: STDERR_FILENO File number of stderr; 2. STDIN_FILENO File number of stdin; 0. STDOUT_FILENO File number of stdout; 1.
Параметр const void *__buf
это указатель на данные, которые мы выводим. Спецификатор const означает, что данные через этот указатель не могут быть изменены, а только лишь прочитаны. Функция write()
пишет из памяти в файл, этим мы говорим что она не меняет те данные, которые она пишет в файл. Более полно этот момент освещен в https://stackoverflow.com/a/34842262
Насчет вот этого __wur
- эта штука определена через макрос и может быть раскрыта в специальный атрибут для компилятора GCC, который применяется только при определенном значении __USE_FORTIFY_LEVEL
- https://sourceware.org/git/?p=glibc.git;a=blob;f=misc/sys/cdefs.h;h=88bc7ac94209ca7742b13c23dfe25f45aa9c5a54;hb=HEAD#l303 (в общем это пока что неважно)
Данная статья или раздел ещё не завершены Кто-то посчитал, что статья или раздел ниже не содержит какой-то важной информации или имеет проблемы с вёрсткой/текстом. Указана причина: «размеры типов, пример кода etc»{{#ifeq: {{{1}}} | nopoint | | . }} |
{{#if: Доработка | }}
Арифметические операции. Адресная арифметика, указатели, указатели на указатели, указатели на функции, сигнатуры функций, разыменования и прочее. Явное и неявное преобразование типов в C
<syntaxhighlight lang="c">
- include <stdint.h>
- include <inttypes.h>
int main () {
… return 0;
} </syntaxhighlight>
Данная статья или раздел ещё не завершены Кто-то посчитал, что статья или раздел ниже не содержит какой-то важной информации или имеет проблемы с вёрсткой/текстом. Указана причина: безблагодатность{{#ifeq: {{{1}}} | nopoint | | . }} |
{{#if: Доработка | }}
struct, union, bit field, memset, memcpy, type punning, strict aliasing rule
argv, argc, envp, getenv, getopt
Некоторые алгоритмы и структуры данных
switch, case, goto, область видимости (scope), рекурсия, приоритеты операторов в Си
Краткий обзор стандартной библиотеки Си, полезные ссылки
malloc, realloc, free, mmap, mremap
Пишем простейший стековый калькулятор (обратная польская нотация)
Сокеты, tcp, udp, raw socket, select, poll, epoll
Привилегии процесса, вызовы getrlimit(2), setrlimit(2), setuid(2), seteuid(2)…
Данная статья или раздел ещё не завершены Кто-то посчитал, что статья или раздел ниже не содержит какой-то важной информации или имеет проблемы с вёрсткой/текстом. Указана причина: SUPER MEGA TODO
|
{{#if: Доработка | }}