Ещё статья про ассемблер для тех кто с ним не знаком. В предыдущей про 5 ассемблеров последний примерчик вызвал критику за "упрощенизм". Давайте посмотрим вместе как его улучшить и немножко нарастить - в качестве "продолжения знакомства".
Заодно полюбуемся на несовместимость Linux и BSD, а также на различие 32 и 64-битной версии обеих ОС - и подумаем как с этим бороться.
Автор не претендует на непогрешимость, поэтому приглашаем умудрённых коллег делиться идеями и подсказками в комментариях если что упущено.
План нашего повествования
Вспомним пример с "Hello World" на ассемблере под линукс, напомним что там к чему - регистры, системные вызовы и т.п.
Макросы - научимся "абстрагировать" фрагменты кода с их помощью
Подпрограммы - аналоги функций и процедур
Реальная задачка - добавим функцию определения длины строки
Результат рефакторинга - окинем взглядом что получилось
Поговорим о функциях чтении строки и печати целого числа (упражнение)
Стек - его мы до сих пор не касались, а вещь краеугольная
Как меняются системные вызовы в зависимости от ОС и разрядности (боль) - бегло обсудим возможности создания "более переносимого" кода
Пример с которого всё началось
Мы говорили об ассемблерах для разных процессоров - смотрели на сходства и различия, чтобы немножко освоиться в теме - и какие-то подробности про разные платформы узнать. И последним шёл вариант с использованием ассемблера на нашем популярном x86 / amd64 "железе" - в виде "Hello World" под Linux. Как мы разобрались - там делов всего лишь - вызвать системную функцию (из самого ядра) - подготовив ей нужные параметры.
Вот этот код - сейчас мы немножко освежим воспоминания о нём и перейдём к улучшениям:
.section .data
msg: .ascii "Hi, Peoplez!\n"
len = . - msg
.section .text
.global _start
_start:
mov $4, %eax
mov $1, %ebx
mov $msg, %ecx
mov $len, %edx
int $0x80
mov $1, %eax
mov $0, %ebx
int $0x80
Это 32-битная версия - она без проблем соберется и запустится и на 64-битном линуксе. Про 64-битную версию тоже поговорим ниже - но очевидно её на 32-битной машине вы запустить не сможете.
Как выглядит результат работы такой программы, можно понять из этого скриншота:
первая строчка - вызов ассемблера, вторая - линкера, ну а в третьей мы вызываем скомпилированную программу. Если вы захотите собственными руками потренироваться с этим и последующими примерами - используйте любой линукс с gcc
(если живёте на win - сгодится загрузочный диск или флешка - или их образ для виртуалки). Если всё это вас не устраивает - напишите, добавлю возможность компилировать и выполнять ассемблерный код у себя на сайте (вот забава будет задачки на нем решать).
Как это работает
Напомню, EAX
, EBX
, ECX
, EDX
- регистры процессора, его ячейки памяти которые можно использовать для всевозможных временных переменных. Команда MOV
это присваивание - числа в регистр или значения из одного регистра в другой, например - очевидно от слова "move" - хотя правильнее сказать что мы не "передвигаем" значение а копируем его. Ну исторически сложилось.
Первые строчки посвящены объявлению секции с данными - здесь мы размещаем текстовую строчку и отмечаем её адрес меткой msg:
- далее следует секция с кодом (text) и вот здесь происходит главное, после метки _start:
.
Команта int 0x80
вызывает "вручную" прерывание (interrupt) с указанным номером - и на этом номере сидят системные функции ядра ОС. Функций много, и нужная определяется тем, какое число оказалось загружено в регистр EAX
- поэтому немного ранее мы записали туда 4
, что соответствует функции write
. Она принимает три параметра в трех следующих регистрах:
куда записать - в
EBX
номер "потока" ввода-вывода, в данном случае 1 означает stdout, вывод в консольс какого адреса брать данные - в
ECX
указан адрес с помощью меткиmsg
сколько байт записать - в
EDX
записывается автоматически посчитанная компилятором константаlen
(мы её потом уберем, поэтому не будем углубляться)
После того как строчка напечатана, следующим этапом мы снова "дёргаем ядро" - но теперь уже с функцией EAX=1
- это просто exit
- завершение программы. В EBX
передаётся код возврата (ноль - значит, без ошибки). Почему нужен exit
? да ведь завершение программы это сложная операция - высвобождается поток и так далее - всё это не происходит "само собой" просто по достижении последней инструкции. Без системного вызова процессор просто побежит дальше, пытаясь исполнять случайные байты встреченные на пути в памяти программ.
В общем, вот это безобразие мы сейчас попробуем улучшить :)
Первые улучшения - макросы
Например вот это int 0x80
- хотя подобное ностальгически мило многим кто знаком с x86 ассемблером, но оставлять так нехорошо. И опечататься можно, и смысловая нагрузка непонятна. А главное - в 64-битном линуксе вместо этого используется специальная инструкция syscall
. Вот захотим на более современную архитектуру перейти - и придётся по коду заменять одну команду на другую - нехорошо!
Практически все ассемблеры позволяют макроопределения, например вот так:
.macro sys_call
int $0x80
.endm
Мы определим словечко sys_call
и будем вписывать его а не саму команду. Потом можно будет команду заменить в одном месте.
Некоторые ассемблеры (и GNU в том числе) позволяют добавлять параметры к макроопределениям, например можно номер требуемой функции задать параметром. И сами номера переопределить константами компилятора:
.equ fn_write, 4
.equ fn_exit, 1
.macro sys_call fn
mov \fn, %eax
int $0x80
.endm
; ...
_start:
mov $1, %ebx
mov $msg, %ecx
mov $len, %edx
sys_call $fn_write
В первых строчках, как видно, мы задаём константы чтобы использовать функции по именам а не по номерам. Макрос теперь объединяет две команды - причем в первую подставляется значение параметра переданного ему. Напомним что макрос просто копируется (с учетом подстановок параметров) в каждом месте где он использован.
Стало ли лучше? Быть может, немножко - но всё же перед каждым вызовом мы пишем довольно много всякой абракадабры. Попробуем спрятать это в подпрограммы.
Подпрограммы (aka процедуры или функции)
Нам было бы удобно назвать первую "часть" как-нибудь print_str
и передавать ей только один параметр - адрес строки. Что делать с остальными двумя? Номер "потока" можно захардкодить, если считать что выводим всегда в консоль. А как быть с длиной?Давайте посмотрим на вот такую "заготовку" из двух функций:
# ECX - string address
print_str:
mov $1, %ebx
call str_len
mov %eax, %edx
sys_call $fn_write
ret
# ECX - string address
# returns length in EAX
str_len:
; ...
ret
Итак, мы соорудили две подпрограммы. Их можно вызывать инструкцией call
а возврат из них делается инструкцией ret
. Никаких специальных механизмов передачи параметров или возврата результата нет (хотя бывают соглашения, упомянем дальше) - мы просто подпишем в комментарии через какие регистры передать нужные значения.
В первой подпрограмме мы сделаем системный вызов для записи строки, но перед этим как и раньше занесем EBX=1
, адрес же строки должен быть передан в саму функцию в ECX
, а длину мы вычислим в отдельной подпрограмме, которую ещё предстоит написать. Результат из подпрограмм принято возвращать через регистр EAX
, хотя нужен он в EDX
- поэтому сделаем дополнительный MOV
между ними. Кажется что это заведомая маленькая "неэффективность" но программы на ассемблере маленькие и быстрые так что мы обычно не смущаемся добавлять где-то лишние команды лишь бы было удобнее пользоваться.
Заметьте - мы добавили отступы, чтобы метки визуально лучше выделялись. Хоть и ассемблер, а минимальную "визуальную" структуру лучше поддерживать.
Но как же будет работать функция определения длины строки?
Функция определения длины строки
Давайте использовать строки с нулём в конце, как в Си. Тогда подпрограмма str_len
сможет посчитать длину пробежавшись в цикле пока не встретит этот самый 0.
В общих чертах понятно. А как это сделать в подробностях? Давайте скопируем адрес строки в EAX
и будем в цикле увеличивать его, пока не обнаружим что он указывает на байт со значением 0. После этого достаточно вычесть из него исходный адрес строки (он так и остался в ECX
:
str_len:
mov %ecx, %eax
strlen_next:
cmpb $0, (%eax)
jz strlen_done
inc %eax
jmp strlen_next
strlen_done:
sub %ecx, %eax
ret
В начале, как сказано выше, копируем адрес в EAX
. Дальше идёт тело цикла, начиная с метки strlen_next
- следом идёт команда CMP
- она сравнивает два аргумента (на самом деле вычитает первый из второго но результат никуда не записывает). В результате её устанавливаются арифметические флаги, в частности флаг нуля Z
если операнды были равны (т.е. в результате вычитания получился 0).
И вот этот флаг мы проверяем командой JZ
(jump if zero) - если нашли 0, то перепрыгнем на указанную метку strlen_done
. Если нет - просто идём дальше.
А дальше у нас увеличение EAX
с помощью команды INC
(increment) - и безусловный переход JMP
к началу цикла.
Когда выйдем из цикла, после метки strlen_done
останется только вычесть начальный адрес из регистра EAX
- это делается командой SUB
(subtract). И всё - результат (длина строки) - в регистре EAX
.
Это очень короткий код но он потребовал некоторых умственных усилий. Давайте "причешем" программу и посмотрим как теперь это выглядит целиком.
Результат рефакторинга
Заодно вынесем в отдельную подпрограмму и второй системный вызов - который завершает выполнение. Получится вот так:
.equ fn_write, 4
.equ fn_exit, 1
.macro sys_call fn
mov \fn, %eax
int $0x80
.endm
.section .data
msg: .ascii "Hi, Peoplez!!!\n\0"
.section .text
.global _start
#===========================
_start:
mov $msg, %ecx
call print_str
call normal_exit
#===========================
# no input arguments
normal_exit:
mov $0, %ebx
sys_call $fn_exit
#===========================
# address of string in ECX
print_str:
call str_len
mov %eax, %edx
mov $1, %ebx
sys_call $fn_write
ret
#===========================
# address of string in ECX
# returns length in EAX
str_len: # string in ecx
mov %ecx, %eax
strlen_next:
cmpb $0, (%eax)
jz strlen_done
inc %eax
jmp strlen_next
strlen_done:
sub %ecx, %eax
ret
Как видите, основная часть программы стала предельно простой - в ней всего лишь загрузка адреса строки в регистр - после чего два вызова подпрограмм.
Чтение строчки и вывод целого числа
В примере программы, который использован в статье про "Голый Линукс" чуть более сложный функционал - мы не только печатаем приветственную строку но и:
ждём ввода строки текста от пользователя
печатаем длину введённой строки
повторяем все это в цикле
Из этого функционала маленький кусочек у нас уже есть - функция определения длины строки. А что с остальными? Мне не хочется усугублять данную статью лишним кодом, поэтому предлагаю так - ниже будет краткое описание и кому любопытно - тот попробует разобраться с кодом (или написать похожий) - если будет много пожеланий, давайте вынесем это в отдельную статью (а кому неинтересно - с теми перейдём дальше к вопросам переносимости и совместимости).
Итак для ввода строки используется другая системная функция (#3) - чтение из канала ввода. Ей тоже передаётся номер канала (0 - stdin) - и сколько байт прочитать. Здесь есть загвоздка - мы не знаем сколько пользователь введет и хотим читать до конца строки. Поэтому будем читать по 1 байту (сдвигая указатель перед каждым вызовом) пока не введен символ возврата строки - после этого допишем в строку нулевой байт как признак конца. Всё это оформлено в подпрограмму gets
. Проверки на максимальную длину буфера в ней нет (можете добавить конечно).
Длину посчитать мы можем - а теперь нужно превратить её в число в строковом представлении. Принцип известен - делим на 10, берем остатки (и добавляем к ним ASCII-код нуля) - однако строчка получится задом-наперед, так что ещё надо будет её развернуть. Можно действовать и иначе, пофантазируйте. Эта подпрограмма зовётся itoa
.
Код в примере не очень аккуратный, так что не стесняйтесь применить к нему свои познания в рефакторинге :)
Зато главная программа выглядит компактно и довольно понятно - тут только вызовы подпрограмм и передача им то буфера то строчки в качестве параметра:
_start:
movl $msg, %ecx
call puts
again:
movl $strbuf, %ecx
call gets
call strlen
movl $strbuf, %ecx
call itoa
movl $strbuf, %ecx
call puts
movl $nl, %ecx
call puts
jmp again
Стек, SP, PUSH, POP
Маленькая но важная вещь о которой мы до сих пор умалчивали - стек процессора. Это удобная фича многих процессоров - есть команды PUSH
и POP
- первая из них позволяет сохранить значение регистра на стеке - а вторая наоборот выталкивает из стека и записывает в регистр. Например это очень удобно при входе в подпрограмму - все регистры значения которых будут "повреждены" - можно временно затолкать в стек (а при выходе восстановить оттуда, в обратном порядке).
Где этот "стек" хранится физически - в большинстве случаев это просто область памяти на которую указывает специальный регистр SP
(он же ESP
или RSP
в зависимости от разрядности процессора) - от слова stack pointer. Но не всегда. Например в младших AVR бывает встроенный хардварный стек - в него никакими другими средствами не залезешь.
Кстати и сам вызов подпрограммы обычно (не в любой архитектуре однако!) делается с помощью стека: команда CALL
заталкивает адрес следующей за ней команды в стек а потом делает прыжок (такой же как JMP
) на нужную метку. Команта возврата RET
выталкивает адрес из стека и делает прыжок на полученное значение. Именно этот механизм обеспечивает возможность рекурсивного вызова функций между прочим.
Почему мы сейчас решили про стек рассказать? Как вы увидите, он используется для системных вызовов в альтернативных ОС.
Системные вызовы в Linux-64 и FreeBSD
Мы до сих пор пользовались системными вызовами в стиле 32-битного Linux. Теперь посмотрим что изменилось в "родственных" системах - рассмотрим 64-битную FreeBSD, далее 64-битный Linux и наконец вернёмся в 32-бита на FreeBSD. Именно в таком порядке - чтобы легче воспринять разницу осуществления системных вызовов.
Из общих сведений обратим внимание на то что в 64-битных системах мы обычно используем 64-битные регистры с префиксом R
(rax, rbx... вместо eax, ebx) - причем прежде виденные 32-битные регистры являются частью 64-битных (т.е. eax это младшая половина rax и так далее).
Итак, FreeBSD 64 bit
Как сказано выше, на 64-битной системе мы должны пользоваться регистрами с другими префиксами. Но и соглашение в каких регистрах передаётся какой параметр - оно тоже поменялось.
раньше мы видели EAX - номер функции, EBX, ECX, EDX - первый, второй, третий параметры для передачи в эту функцию (этот порядок отчасти напоминает MS-DOS)
теперь хотя остаётся RAX - номер функции, аргументы идут по порядку в RDI, RSI, RDX, RCX... (мы ещё не встречали регистры xDI, xSI - но в целом они ведут себя как и остальные)
Кроме того сам системный вызов теперь делается специальной инструкцией syscall
вместо морально устаревшего int 0x80
-
Если мы модифицируем предложенную выше программу, получится (не включая str_len
) достаточно похожий код:
...
.macro sys_call fn
mov \fn, %rax
syscall
.endm
...
#===========================
_start:
mov $msg, %rsi
call print_str
call normal_exit
#===========================
# no input arguments
normal_exit:
mov $0, %rdi
sys_call $fn_exit
#===========================
# address of string in RSI
print_str:
call str_len
mov %rax, %rdx
mov $1, %rdi
sys_call $fn_write
ret
...
Как видим, макрос sys_call
неплохо нам помогает - мы заменили в нем обе строчки - зато все места где он вызывается менять не нужно. К сожалению другие параметры передаваемые в системные вызовы приходится модифицировать т.к. регистры не совпадают.
Можно добавить эти аргументы к самому макросу, как-то так:
.macro sys_call fn arg1=0 arg2=0 arg3=0 arg4=0
mov \fn, %rax
mov \arg1, %rdi
mov \arg2, %rsi
mov \arg3, %rdx
mov \arg4, %rcx
syscall
.endm
Макросы умеют использовать дефолтные значения для параметров. Правда у нас будут лишние инструкции к каждому системному вызову (присваивание нулей в неиспользуемые регистры), но поскольку сами вызовы занимают гораздо больше времени, это некритично.
Мы сможем поместить макросы для разных версий системы в два разных include-файла и подключать либо тот, либо другой, по необходимости. Кот основной программы тогда совсем упростится:
_start:
sys_call fn_write $1 $msg $len
sys_call fn_exit $0
Для красоты ещё для stdout
константу можно определить было бы.
К сожалению с функцией str_len
ещё придётся колдовать - сам по себе её код точно также будет работать на 64-битной системе, т.к. он использует валидные регистры - но для того чтобы передать в неё параметры и получить результат в "совместимом" виде - понадобится что-то придумать.
Однако не будем на этом задерживаться и пойдём дальше.
Linux 64 бит
По сравнению с FreeBSD 64 бит тут всё похоже - те же "обновлённые" регистры, тот же порядок (в отличие от старого 32-битного Linux) и тот же syscall
.
Но есть и отличие - номера системных вызовов поменялись. Они их все перепутали, Карл!!!
Теперь функция write
будет не 4, а 1. Функция exit
же была 1
а стала 60
. Надеюсь вам понятна логика? (мне нет)
В остальном код будет идентичен варианту FreeBSD 64, то есть написать переносимую программу для этих двух случаев проще всего - просто вынесем строчки с определением номеров системных функций в отдельный подключаемый файл:
.equ fn_write, 1
.equ fn_exit, 60
То есть достаточно завести linux64.inc
и freebsd64.inc
с разными номерами функций - и включать тот либо другой. Альтернативный вариант - можно использовать сишный препроцессор с #define ... #if ... #else ... #endif
- Gnu ассемблер использует его автоматически если расширение файла указать большой буквой .S
Отметим однако что сами по себе системные функции могут немного различаться. Те которые существовали исторически ещё в Unix будут идентичны почти наверняка - но более поздние различаются. Даже их количество неодинаково (во FreeBSD их 500 с хвостиком, в Линуксе по-моему ощутимо меньше).
FreeBSD 32 бит
Возвращаясь в 32-битную FreeBSD мы обнаруживаем что номера функций используются старые (в общем, они везде одинаковы кроме Linux-64) - и используется старый добрый вызов int 0x80
.
Но значительное различие в том как передаются параметры. Они вообще передаются не в регистрах а на стеке - это Си-шный (и Юниксовый исторически) стиль. То есть если взять самый первый пример, его пришлось бы написать вот так:
...
push $len
push $msg
push $1
push $4
int $0x80
add $16, %esp
...
Тут два важных момента - во-первых порядок помещения аргументов на стек естественно важен. Во-вторых после выполнения системного вызова надо либо вытолкнуть из стека 4 ненужных теперь числа - либо сдвинуть его указатель (регистр ESP) на 4 четырёхбайтовых значения - то есть на 16 байт.
Как можно было бы сделать этот код совместимым с Linux-32? очевидно, волшебный макрос sys_call
с четырьмя аргументами мог бы помочь и здесь. Хотя есть и другая волшебная возможность - FreeBSD умеет компилировать код в "совместимом" с Linux режиме.
Кроме того часто удобнее использовать небольшую библиотечку syscall
- тогда вы сможете делать вызовы из своего кода единообразно - и просто прилинковать нужную версию библиотеки. Однако использование (и создание) библиотек для ассемблера уже немного выходит за рамки нашего мини-тьюториала.
Заключение
Вероятно от раздела про различие и совместимость различных ОС у вас могло остаться лёгкое замешательство. С одной стороны мы понимаем что в принципе программу можно написать в "переносимом" стиле если выработать определенные правила. С другой же - это явно требует определенных усилий и навыка. С переносимостью между 32 и 64 разрядной системами дело явно обстоит похуже чем между Linux и FreeBSD одной разрядности.
Впрочем подобная переносимость (как и вообще программы на ассемблере) нужны в достаточно небольшом количестве случаев - например при написании "бэкенда" компилятора, или каких-либо ассемблерных вставок.
Цель нашей статьи была лишь немножко больше погрузить любопытного читателя в возможности ассемблера (и его сложности) - и если вы дочитали досюда, возможно эта цель хотя бы отчасти достигнута. Спасибо за внимание!