Как писать на ассемблере в linux
Unix, который мы будем использовать - 32 битная система, работающая в защищенном режиме, и использующая плоскую модель памяти.
Как и большинство операционных систем, Unix предоставляет программе набор различных функций (по другому - Api). Но, в отличие от, например, WinAPI, где вызовы производятся с помощью call'ов, в unix - больше свободы: можно вызывать функция ядра напрямую, а можно использовать многочисленные библиотеки. Рассмотрим для начала первый способ.
Системный вызов производится с помощью прерывания 0x80 (чаще всего). К сожалению, (а может и к счастью) существует несколько конвенций вызова, что приводит к несовместимости кода между многими unix-like осями. Я рассмотрю только две, самые популярные платформы: Linux и *BSD.
FreeBSD (а также OpenBSD и NetBSD)
Эта система использует традиционную unix конвенцию вызова: номер функции помещается в eax, параметры в стек, вызов производится с помощью функции содержащей int 0x80, а результат возвращается в eax.
Наверное, понятнее будет, если рассмотреть это на примере:
Впрочем, от функции sys_call можно отказаться, достаточно просто помещать в стек лишний dword:
Если это не сработает (а такое возможно из-за не совместимости форматов), придется перекомпилировать fasm, заменив формат файла “format PE executable” на простой “format ELF”, а потом слинковать ld.
Linux
В линуксе используется fastcall конвенция. Номер функции, все так же,
помещается в eax, а вот параметры, вместо стека, помещаются в регистры. Пример:
Порядок размещения параметров такой:
№ параметра | Регистры |
1 | ebx |
2 | ecx |
3 | edx |
4 | esi |
5 | edi |
6 | ebp |
Как видите максимальное количество параметров - 6. Если их больше,
приходиться помещать все параметры в структуру и передавать ее адрес в ebx.
После того как вы разобрались с вызовом функций, будет логичным вопрос: "А где взять описание этих самых функций?".
Ничего похожего на msdn, в unix среде к сожалению не существует, но не нужно забывать: unix - система с открытым исходном кодом и все нужное, можно найти там.
Для каждой функции можно посмотреть описание, используя man(2).
Пришло время написать, тот самый, жутко всем надоевший - HelloWorld.
Я приведу пример только FreeBSD версии, переписать это под linux - будет вашим домашним заданием. (для самых ленивых - см. примеры к статье)
Сборка.
Сначала скомпилируем файл, вот так:
А потом слинкуем:
А теперь посмотрите на размер. 600 байт, впечатляет?! ( размер можно еще очень сильно уменьшить, но об этом, как-нибудь в другой раз)
Некрасивый и совсем не дзенский способ, но все же мы его рассмотрим - для полноты картины.
Итак, libc (c library) - это стандартная библиотека с для UNIX. Она содержит в себе кучу полезных функций, типа printf, и используется почти во всех обычных программах (кстати сказать, многие функции этой библиотеки - простые обертки над вызовами ядра).
В FASMе существуют удобные макросы, для вызова си функций. но я не буду их использовать, отдав предпочтение чистому ассемблеру.
Компилируется это дело так:
Ну вот вы и написали свою первую программу на ассемблере под UNIX.
В папке у нас будет бинарный файл fasm, который мы можем использовать для компиляции. Для удобства вы можете создать симлинк на него:
sudo ln -s /home/username/fasm/fasm /usr/local/bin
ald и shed устанавливаются не сложнее:
В итоге у нас будет 3 полезных инструмента для программирования на ассемблере.
Системные вызовы
Как и большинство других операционных систем, Linux предоставляет т.н. API — набор полезных для программиста функций. В большинстве случаев вызов системной функции производится с помощью прерывания 80h. Следует отметить, что Linux используется fastcall-конвенция передачи параметров. Согласно ей параметры передаются через регистры (в windows, например, используется stdcall, где параметры передаются через стек). Номер вызываемой функции кладется в eax, а параметры в регистры:
Номер параметра / Регистр
1 / ebx
2 / ecx
3 / edx
4 / esi
5 / edi
6 / ebp
Как видите все не так сложно. Узнать номер системной функции, ее описание и параметры можно, хотя бы здесь. Возьмем, к примеру sys_exit . Как можно увидеть на той странице у нее есть один параметр — код возврата и она имеет порядковый номер 1. Таким образом мы можем вызвать ее следующим кодом:
mov eax, 1 ; 1 - номер системной функции
sub ebx, ebx ; Обнуляем регистр (можно было записать mov ebx, 0)
int 80h ; Вызываем прерывание 80h
Надеюсь, что все понятно.
Hello, World!
Ну что же. Писать мы ничего не будем, т.к. за нас все написано :) В папке fasm/examples/elfexe есть файл hello.asm, в котором находится следующий код:
; fasm demonstration of writing simple ELF executable
format ELF executable 3
entry start
segment readable executable
mov eax,4
mov ebx,1
mov ecx,msg
mov edx,msg_size
int 0x80
mov eax,1
xor ebx,ebx
int 0x80
segment readable writeable
msg db 'Hello world!',0xA
msg_size = $-msg
Как видите здесь вызываются 2 системных функции — sys_write (с порядковым номером 4) и sys_exit . sys_write принимает 3 параметра — дескриптор потока вывода (1 — stdout), указатель на строку и размер строки. Сам номер функции, как уже говорилось, мы должны положить в eax. Функцию sys_exit мы уже разобрали. Скомпилировать это чудо можно так: fasm hello.asm (но не обязательно, т.к. там же, где лежит исходник, есть и бинарник).
Посмотрим, что внутри
Думаю, что самое время заглянуть в наш бинарник. Для начала воспользуемся шестнадцатеричным редактором, чтобы посмотреть что у нас получилось. Выполним команду:
Мы видим всю нашу программу, данные, elf-заголовок. Неплохо? Теперь мы посмотрим на нашу программу в отладчике. Наберем в консоли:
Нас должна поприветствовать строка с предложением ввести команду. Список команд вы можете узнать, набрав help или получить помощь по отдельной команде, набрав help command . Дизассемблировать нашу программу можно командой disassemble (или ее алиас — " d "). Вы увидете дизассемблированный листинг вашей программы. Слева — адрес, справа — сама команда, а посередине — опкод команды.
Получить дамп можно командой dump (странно, но ее нет в выводе команды help ).
Теперь попробуем поработать с командой next . Выполните ее и в ответ вам покажут значения регистров, установленные флаги, а так же адрес, опкод и дизассемблированную команду, которая должна выполниться следующей. Попробуйте выполнять команды и следите за изменением флагов и регистров. После вызова первого прерывания у вас на экране должна появиться надпись «Hello world!».
Целью данной статьи было показать основы программирования на ассемблере в linux, а не программирования на ассемблере в общем. Надеюсь, что вы подчерпнули для себя что-то полезное от сюда.
Написание и отладка кода на ассемблере x86/x64 в Linux
17 августа 2016
Сегодня мы поговорим о программировании на ассемблере. Вопрос «зачем кому-то в третьем тысячелетии может прийти в голову писать что-то на ассемблере» раскрыт в заметке Зачем нужно знать всякие низкоуровневые вещи, поэтому здесь мы к нему возвращаться не будем. Отмечу, что в рамках поста мы сосредоточимся на вопросе компиляции и отладки программ на ассемблере. Сам же язык ассемблера заслуживает отдельного большого поста, а то и серии постов.
Введение
Компиляторов ассемблера существует много. Мы будем использовать GNU Assembler (он же GAS, он же /usr/bin/as). Скорее всего, он уже есть вашей системе. К тому же, если вы пользуетесь GCC и собираетесь писать ассемблерные вставки в коде на C, то именно с этим ассемблером вам предстоит работать. Из достойных альтернатив GAS можно отметить NASM и FASM.
Наконец, язык ассемблера отличается в зависимости от архитектуры процессора. Пока что мы сосредоточимся на ассемблере для x86 (он же i386) и x64 (он же amd64), так как именно с этими архитектурами приходится чаще всего иметь дело. Впрочем, ARM тоже весьма распространен, главным образом на телефонах и планшетах. Еще из сравнительно популярного есть SPARC и PowerPC, но шансы столкнуться с ними весьма малы. Отмечу, что x86 и x64 можно было бы рассматривать отдельно, но эти архитектуры во многом похожи, поэтому я не вижу в этом большого смысла.
«Hello, world» на int 0 x80
Рассмотрим типичный «Hello, world» для архитектуры x86 и Linux:
.data
msg :
. ascii "Hello, world!\n"
. set len , . - msg
Коротко рассмотрим первые несколько действий, выполняемых программой: (1) программа начинает выполнение с метки _start, (2) в регистр eax кладется значение 4, (3) в регистр ebx помещается значение 1, (4) в регистр ecx кладется адрес строки, (5) в регистр edx кладется ее длина, (6) происходит прерывание 0 x80. Так в мире Linux традиционно происходит выполнение системных вызовов. Конкретно int 0 x80 считается устаревшим и медленным, но из соображений обратной совместимости он все еще работает. Далее мы рассмотрим и более новые механизмы.
Следующая строчка из файла unistd_32.h:
То есть, рассмотренный код эквивалентен:
Затем аналогичным образом производится вызов:
Совсем не сложно!
В общем случае системный вызов через 0 x80 производится по следующим правилам. Регистру 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
Сборка осуществляется аналогично сборке предыдущего примера.
Как видите, принцип тот же, что при использовании int 0 x80, только перед выполнением sysenter требуются поместить в стек адрес, по которому следует вернуть управление, а также совершить кое-какие дополнительные манипуляции с регистрами. Причины этого более подробно объясняются здесь.
Инструкция sysenter работает быстрее int 0 x80 и является предпочтительным способом совершения системных вызовов на x86.
Выполнение системного вызова через syscall
До сих пор речь шла о 32-х битных программах. На x64 выполнение системных вызовов осуществляется так:
.data
msg :
. ascii "Hello, world!\n"
. set len , . - msg
Собирается программа таким образом:
as --64 hello-syscall.s -o hello-syscall.old -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 0 x80.
Отладка ассемблерного кода в GDB
Статья была бы не полной, если бы мы не затронули вопрос отладки всего этого хозяйства. Так как мы все равно очень плотно сидим на GNU-стэке, в качестве отладчика воспользуемся GDB. По большому счету, отладка не сильно отличается от отладки обычного кода на C, но есть нюансы.
Например, вы не можете так просто взять и поставить брейкпоинт на процедуру main. Как минимум, у вас попросту нет отладочных символов с информацией о том, где эту main искать. Решение заключается в том, чтобы самостоятельно определить адрес точки входа в программу и поставить брейкпоинт на этот адрес:
Эта статья призвана описать программирование на ассемблере под Linux. В этой статье мы сравним AT&T и Intel синтаксисы ассемблера и рассмотрим, как использовать системные вызовы. В некоторых частях этой статьи содержится код, полученный экспериментальным путём, и поэтому в нём могут быть допущены ошибки.
Для прочтения этой статьи требуется только базовое знание ассемблера.
Intel и AT&T синтаксисы.
Intel и AT&T синтаксисы очень разные по «внешности», и это может привести путанице при переходе, допустим, с Intel’овского на AT&T’шный, и наоборот.
В Intel синтаксисе не используются приставки не перед регистрами, не перед данными. В AT&T же, однако, перед регистрами ставится приставка «%», а перед данными «$». В Intel синтаксисе после шестнадцатеричных и двоичных данных ставятся суффиксы «h» и «b», соответственно. Также если шестнадцатеричное число начинается с буквы, то добавляется приставка «0»
В Intel и AT&T порядок операндов противоположен. В Intel первый операнд – приемник, а второй – источник. А в AT&T первый – источник, второй – приемник. Преимущество AT&T тут очевидно. Мы читаем слева направо, мы пишем слева направо, таким образом, этот порядок привычнее и естественнее.
Операнды памяти, как вы, наверное, уже заметили, тоже различаются. В Intel синтаксисе регистры, содержащие указатель на некоторую область памяти, заключаются в квадратные скобки: «[» и «]». А в AT&T в круглые: «(» и «)».
AT&T’шная форма инструкций, содержащих математические действия, очень сложна по сравнению с Intel’овской. Допустим, имеется инструкция на Intel синтаксисе: «segreg:[base+index*scale+disp]». На AT&T аналогичная инструкция будет выглядеть так: «%segreg:disp(base,index,scale)».
Index/scale/disp/segreg опциональны и могут быть опущены. Если index и/или scale не определены, то будут принимать дефолтное значение – «1».Segreg зависит от того выполняется ли приложение в реальном или защищенном режиме. В реальном режиме зависит от инструкции, а в защищенном нет.
Как видите AT&T не очень понятен. «segreg:[base+index*scale+disp]» гораздо легче для восприятия, чем «%segreg:disp(base,index,scale)».
Как вы, возможно, заметили в AT&T’шной мнемонике используются суффиксы. Эти суффиксы указывают на размер операндов. AT&T’шный суффикс «l» соответствует Intel’овскому «DWORD», «w» - «WORD» и «b» - «byte».
Системные вызовы с < 6 аргументами.
Для всех системных вызовов номер функции ложится в %eax. Для функций количество аргументов, которых не превышает пяти аргументы ложатся в %ebx, %ecx, %edx, %esi, %edi соответственно. Результат выполнения функции возвращается в %eax.
Номера функций вы можете найти в /usr/include/sys/syscall.h. Выглядят она так: «SYS_имя функции». Например: SYS_exit, SYS_close, и т.д.
(Напишем Hello World, куда же без него)
Согласно описанию из man страницы, функция «write» объявлена так: «ssize_t write(int fd, const void *buf, size_t count);».
Следовательно «fd» ложем в %ebx, «buf» в %ecx, «count» в %edx и «SYS_write» в %eax. Далее вызываем прерывание «int $0x80», которое выполнит нашу функцию и вернет в %eax результат.
Приветствую всех на моем канале Old Programmer .
Все разделы моего канала здесь .
На моем канале довольно много материалов по программированию на языке ассемблера (Linux, x86-64). Данной статьей я начинаю новую серию статей. Я бы сказал, что это ассемблер 2.0 . Начал вплотную заниматься книгой по ассемблеру под предварительным названием Ассемблер для Linux . Буду публиковать часть материалов из книги с минимальными изменениями или совсем без них. Да в чем-то они будут повторять те материалы, которые здесь имеются. Но они будут более подробно описывать те или иные вопросы и должны охватить более широкий круг тем. Сегодня первый материал. Да, материалы, можно сказать, еще в 0-м приближении, и будут меняться, возможно и с вашей помощью.
Параграф 1.4.
Первые программы для Linux на GNU Assembler
В данном параграфе, не вдаваясь в детали, мы рассмотрим простую ассемблерную программу, ее трансляцию и исполнение. Но прежде вспомним как происходит компилирование программ, написанных на языках высокого уровня.
В листинге 2 представлена простая программа на языке C.
Для компиляции программы используем универсальный модуль gcc для вызова компилятора из GNU Compiler Collection.GCC - стандартный набор компиляторов, разрабатываемых в рамках проекта GNU.
gcc -ol2 l2.c
В результате выполнения на диске появляется двоичный исполняемый модуль l2, который может быть запущен командой ./l2. Программа gcc является управляющей. Она в частности определяет какой из компиляторов должен быть запущен. В данном случае запускается компилятор классического языка C. При необходимости также запускается транслятор с языка ассемблера (программа as) и компоновщик (программа ld).
При желании можно получить промежуточный модуль на языке ассемблера командой
В результате ее выполнения появляется файл l2.s, содержащий ассемблерный код. Мы не будем пока касаться содержимого этого файла, нам важно было подчеркнуть, что ассемблер является промежуточным звеном трансляции программы с языков высокого уровня. Это важный момент, в дальнейшем мы увидим что язык ассемблера довольно просто можно использовать совместно с языками высокого уровня.
В листинге 3 представлена простая программа на языке ассемблера, которая ничего не делает. При запуске она сразу же выполняет команду выхода.
Откомпилировать программу, т.е. получить исполняемый модуль можно с помощью последовательности следующих команд.
as --64l3.s -ol3.o
ld -sl3.o -ol3
Как видим трансляция происходит в два этапа. При выполнении первой команды (программа as) на диске появляется файл l3.o. Такие файлы называются объектными. В дальнейшем они сыграют важную роль при рассмотрении вопроса многомодульных программ и интеграции ассемблера с другими языками программирования (раздельная компиляция). Второй этап называется компоновкой и выполняется он с помощью программы-компоновщика ld . В результате на диске в текущем каталоге появляется исполняемый двоичный модуль l3, который можно запустить на выполнение командой ./l3.
Процесс компоновки называется также редактированием связей и соответственно утилита ld редактором связей .
Обратимся теперь к листингу 3. Данный листинг в значительной степени представляет из себя каркас, который мы в дальнейшем, будем использовать для написания программ на ассемблере, возможно несколько дополняя или видоизменяя его. Остановимся подробнее на всех элементах представленного шаблона программы.
2. Заметим, что некоторые строки в программе начинаются с точки. Это директивы ассемблера, которые влияют на компиляцию программы, используются для задания данных. Директива .text определяет так называемую секцию программы. В нашем случае в программе всего одна секция. В дальнейшем мы обычно будем добавлять еще секцию для хранения данных.
3. В программе есть директива, после которой стоит двоеточие. Это _start . Это так называемые метки. Метка указывает на конкретное место в программе. После компиляции она превращается в конкретный адрес. Метка указывает на команду или данное. Формально между меткой, которая указывает на команду или данное нет никакой разницы. Разница существует на уровне логики выполнения программы.
4. Директива .globl указывает компилятору as, что указанная в метка является глобальной и она будет представлена в объектном модуле. В данном случае мы говорим, что глобальной меткой будет _start . Вы можете посмотреть объектный модуль l3.o и действительно в нем присутствует слово _start . Для компоновщика ld метка _start по умолчанию является местом, с которого начинается выполняться программа. Если убрать строку .globl _start , то компоновщик не будет знать с какого адреса должна начинать выполняться программа и выдаст предупреждение. При этом исполняемый бинарный файл будет создан на основе предположения компоновщика об адреса, с которого должна выполняться программа. Кстати, метка начала выполнения программы не обязательно должна называться _start . С помощью опции -e можно указать компоновщику другую существующую метку. Например так
ld -s -e main l3.o -ol3
В данном случае компоновщик указывает, что адресом запуска для программы будет метка main .
5. Последние три строки программы являются реальными командами процессора. В дальнейшем мы подробно будем говорить о командах x86-64. mov это команда пересылки данных. В нашем случае она засылает число 60 в регистр процессора rax (не большая память процессора состоит из регистров). Далее команда xor обнуляет содержимое регистра rdi . Данная команда равносильна команде mov $0, %rdi . Команда syscall основная команда, которую мы будем использовать для вызова системных функций ядра операционной системы. При программировании на ассемблере обращение к функциям ядра дело повседневное. При этом регистр rax должен содержать номер функции ядра. В нашем случае мы используем функцию с номером 60 , которая позволяет закончить выполнение программы. Регистр rdi , для данной функции он должен содержать код выхода, который можно отловить, средствами запускающего процесса. Обычно 0 означает, что программа закончила свою работу без ошибок.
Подписываемся на мой канал Old Programmer и пишем свои комментарии.
Читайте также: