Поревьюим и порефакторим — Ассемблер для любопытных #2

от автора

Ещё статья про ассемблер для тех кто с ним не знаком. В предыдущей про 5 ассемблеров последний примерчик вызвал критику за «упрощенизм». Давайте посмотрим вместе как его улучшить и немножко нарастить — в качестве «продолжения знакомства».

Заодно полюбуемся на несовместимость Linux и BSD, а также на различие 32 и 64-битной версии обеих ОС — и подумаем как с этим бороться.

Автор не претендует на непогрешимость, поэтому приглашаем умудрённых коллег делиться идеями и подсказками в комментариях если что упущено.

План нашего повествования

  1. Вспомним пример с «Hello World» на ассемблере под линукс, напомним что там к чему — регистры, системные вызовы и т.п.

  2. Макросы — научимся «абстрагировать» фрагменты кода с их помощью

  3. Подпрограммы — аналоги функций и процедур

  4. Реальная задачка — добавим функцию определения длины строки

  5. Результат рефакторинга — окинем взглядом что получилось

  6. Поговорим о функциях чтении строки и печати целого числа (упражнение)

  7. Стек — его мы до сих пор не касались, а вещь краеугольная

  8. Как меняются системные вызовы в зависимости от ОС и разрядности (боль) — бегло обсудим возможности создания «более переносимого» кода

Пример с которого всё началось

Мы говорили об ассемблерах для разных процессоров — смотрели на сходства и различия, чтобы немножко освоиться в теме — и какие-то подробности про разные платформы узнать. И последним шёл вариант с использованием ассемблера на нашем популярном 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 одной разрядности.

Впрочем подобная переносимость (как и вообще программы на ассемблере) нужны в достаточно небольшом количестве случаев — например при написании «бэкенда» компилятора, или каких-либо ассемблерных вставок.

Цель нашей статьи была лишь немножко больше погрузить любопытного читателя в возможности ассемблера (и его сложности) — и если вы дочитали досюда, возможно эта цель хотя бы отчасти достигнута. Спасибо за внимание!


ссылка на оригинал статьи https://habr.com/ru/articles/857208/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *