Ещё статья про ассемблер для тех кто с ним не знаком. В предыдущей про 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 одной разрядности.

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

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

Комментарии (39)


  1. evgeniy_kudinov
    13.11.2024 05:18

    Что используете для разработки и дебага asm?


    1. kuza2000
      13.11.2024 05:18

      Для дебага компилируемых в натив-код языков точно полезно.


    1. RodionGork Автор
      13.11.2024 05:18

      простой редактор (vim / nano / gedit) - ну и принтлны, коды возврата, пайпы, светодиоды для отладки

      т.к. под разные архитектуры / чипы в основном писать случается, чаще всего вопрос поиска развесистого IDE и отладчика (плюс программатор с возможностью отладки) затруднителен или себя не оправдывает


    1. kuza2000
      13.11.2024 05:18

      Кстати, в начале 90-х я изучал C++ после ассемблера. Книг не было, интернета тоже. Пользовался встроенной справкой F1. Метод изучения был такой - я знал, что мне нужно на ассемблере и подбирал такой код на C++, который компилировался в нужный мне :)) Ну, сурово, конечно... но в начале 90-х и C++ был попроще...

      Кстати, поиск нулевого символа в строке обычно делается по другому, цепочечными командами. Как бы цикл внутри команды, x86 и такое умеет (REP, REPE, REPNE, REPNZ, REPZ). Автор их не использует, видимо, из педагогических соображений...


      1. RodionGork Автор
        13.11.2024 05:18

        Автор их не использует, видимо, из педагогических соображений

        да нет конечно, просто подзабыл уже - для x86 всё-таки я уже ассемблер давно не использую, кроме конечно разборок в чужих проектах :)

        кстати, в начале 90-х я изучал C++ после ассемблера. Книг не было, интернета тоже.

        ну, я в конце 90х, но проблема оставалась. то есть с C как раз было проще - т.к. встроенная справка и книжка кёрнигана-ричи. а вот с ассемблером беда - отдельно нужен справочник по командам, отдельно по системным средствам. книжка джордейна из библиотеки и пара электронных справочников принесённых на дискетке (почему-то валялись в школьной сети) - это было хоть и корявая но значительная помощь.


      1. HardWrMan
        13.11.2024 05:18

        Метод изучения был такой - я знал, что мне нужно на ассемблере и подбирал такой код на C++, который компилировался в нужный мне :)) Ну, сурово, конечно... но в начале 90-х и C++ был попроще...

        Забавно, но это до сих пор актуально для меня но в области FPGA и xHDL. Я знаю, что мне надо получить схематически, я пишу на xHDL, собираю, открываю RTL и смотрю, насколько точно понял меня синтезатор. После нескольких итераций получается прям отлично.


      1. SIISII
        13.11.2024 05:18

        В 64-разрядном режиме, если память не изменяет, строковые операции выпилены.


        1. unreal_undead2
          13.11.2024 05:18

          С чего бы? Вполне себе работают с RSI/RDI, относительно недавно были дополнительные оптимизации (в мануале вижу, что на Golden Cove ещё кое что улучшили).


          1. SIISII
            13.11.2024 05:18

            Значит, с чем-то ещё спутал. Всё ж под ПК на асме уже четверть века не быдлокодил, особо поэтому не слежу :)

            Но, вроде б, для реализации функций вроде memcpy стандартные либы используют пересылки через SIMD-регистры. Или тоже с чем-то путаю?


            1. unreal_undead2
              13.11.2024 05:18

              Оптимальный вариант зависит от микроархитектуры, размера и выравнивания данных, где то лучше SIMD, где то rep movs. Вот тут можно глянуть, как работает glibc на невыровненных данных, в частности

              6. On machines with ERMS feature, if size greater than equal or to __x86_rep_movsb_threshold and less than __x86_rep_movsb_stop_threshold, then REP MOVSB will be used.


  1. HardWrMan
    13.11.2024 05:18

    mov \fn, %rax

    Эм... И оно компилируется?


    1. RodionGork Автор
      13.11.2024 05:18

      вроде да. пошёл перепроверил. и компилируется и работает

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


      1. HardWrMan
        13.11.2024 05:18

        Ааа, красноглазые и тут отличились, синтаксис AT&T: https://en.wikipedia.org/wiki/X86_assembly_language#Syntax

        Вопрос снимаю.


        1. RodionGork Автор
          13.11.2024 05:18

          не знаю что за красноглазые, но по моим ощущениям порядок операндов приблизительно в половине ассемблеров (ну может 40 на 60 процентов) обратный. в целом большой проблемы это не вызывает кроме команд сравнения (вот после cmp 0, eax как будет действовать jge)


          1. HardWrMan
            13.11.2024 05:18

            Просто я привык на интелях (MCS48, MCS51, i8080, x86/x64) кодить в синтаксисе самого интеля.


          1. SIISII
            13.11.2024 05:18

            В ГНУсном ассемблере изначально заимствовали порядок, принятый в родном ассемблере PDP-11, но почти во всём остальном всё исказили; потом зачем-то применили его к интеловскому, но изрядная часть других архитектур использует синтаксис, "родной" для архитектуры, а не для ГНУсных извращенцев.

            Вообще, практически единственная задача AS -- глотать выхлоп GCC и преобразовывать его в объектный файл. Для написания сколько-нибудь серьёзных ассемблерных программ он непригоден (нет очень многих возможностей, и, в частности, макросредств).


            1. checkpoint
              13.11.2024 05:18

              нет очень многих возможностей, и, в частности, макросредств

              Каких именно макросредств Вам нехватает в GAS ? Не то, чтобы мне он сильно нравится, но мне всегда казалось что в GAS переизбыток всякого разного мета-синтаксиса из-за чего программы на нём нечитаемы.


              1. SIISII
                13.11.2024 05:18

                Что такое традиционные макрокоманды, знаете? Какие, скажем, были в ассемблере OS/360? Вот ничего подобного в нём нет. Локальных меток (определённых только внутри блока, ограниченного нормальными метками) тоже нет -- лишний гемор при написании команд переходов. А переизбыток в нём директив для вывода всякой разной информации в объектный файл -- как раз для того, чтобы переваривать выхлоп компилятора и помещать в объектный файл сгенерированную компилятором отладочную информацию.


                1. RodionGork Автор
                  13.11.2024 05:18

                  так он вроде дружит с препроцессором сишным, правда мне не показалось что это всегда удобно.

                  насчет локальных меток - я не пробовал но по-моему в доке их видел. хотя может что-то путаю.


                  1. SIISII
                    13.11.2024 05:18

                    Сишный препроцессор исключительно убог. В той же ОС/360 процесс генерации (сборки) ОС построен как раз на использовании ассемблера с его макросредствами: параметры будущей системы описываются кучей макрокоманд, и ассемблер на выходе даёт не объектный файл, а текст заданий, выполнение которых, собственно, и сгенерирует систему. Ну а для этого макросредства должны иметь возможность вычислений во время трансляции, обработки строк, ветвлений, циклов и т.д. (Конкретно в асме ОС/360 это всё синтаксически довольно коряво -- всё ж первая половина 1960-х -- но функционально полноценно.)


                1. checkpoint
                  13.11.2024 05:18

                  Что такое традиционные макрокоманды, знаете? Какие, скажем, были в ассемблере OS/360? Вот ничего подобного в нём нет.

                  Вспомнила бабка Юрьев день. :) Нет, с макроассемблером в OS/360 я знаком не был, наверное многое потерял.

                  В GAS используется синтаксис обычного Сишног препроцессора, а он достаточно фичкав чтобы управлять сборкой ядер таких ОС как Linux и *BSD. Но мне видится весь этот синтаксис очень убогим и избыточно сложным.


                  1. SIISII
                    13.11.2024 05:18

                    Ну, если и потеряли, то лишь в плане знакомства с возможностями довольно навороченных макросредств. Синтаксически, повторюсь, он довольно страшненький, и многие вещи можно было бы решить лучше (в частности, лучше решено в макроассемблере PDP-11 -- но функционально он несколько слабее).

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


                    1. checkpoint
                      13.11.2024 05:18

                      Боюсь представить каких делов можно наворотить с циклами в макросах. :-)

                      Хотя, программисты на LISP как-то справляются с такой сложностью.


                      1. SIISII
                        13.11.2024 05:18

                        Ну, скажем, если макрокоманда описывает 8 дисководов на одном контроллере, то она развернётся в восемь блоков управления устройством -- по одному на каждый из дисководов. Часть информации в них идентичная, часть (вроде адреса устройства) отличается.


                      1. checkpoint
                        13.11.2024 05:18

                        Какая расточительность, по тем-то временам. :-) Почему не один кусок кода с параметром - адрес привода ?


                      1. SIISII
                        13.11.2024 05:18

                        Наоборот, это очень компактное представление. Код общий для всех типов дисков, все необходимые параметры (далеко не только адреса) -- как раз в блоках управления.


  1. unreal_undead2
    13.11.2024 05:18

    Но есть и отличие - номера системных вызовов поменялись. Они их все перепутали, Карл!!!

    Они и на Линуксе на разных платформах сильно отличаются.


    1. RodionGork Автор
      13.11.2024 05:18

      я даже видел тред на стековерфлоу где пытались вспомнить почему это так, но не уверен дошли ли до какого-то консенсуса. по-моему кто-то в этом какую-то микрооптимизацию усмотрел.


      1. unreal_undead2
        13.11.2024 05:18

        Да, для x86-64 табличку действительно оптимизировали, но та же оптимизация актуальна и для других платформ, а по факту везде по историческим причинам делали по своему. На более менее новых архитектурах есть тенденция использовать общие номера из linux/include/uapi/asm-generic/unistd.h


  1. checkpoint
    13.11.2024 05:18

    Но есть и отличие - номера системных вызовов поменялись. Они их все перепутали, Карл!!!

    Да, забавно получилось - в 64bit фрюхе пришли к линуховому формату системного вызова, но линухоиды усмотрели в этом опасность и всё напрочь поменяли. :-)

    Кстати, int 0x80 во FreeBSD 64bit всё еще легальный способ вызова системы. Только что проверил:

    код
    .section .data
    msg: .ascii "Hi, Peoplez!\n"
    len = . - msg
    
    .section .text
    .global _start
    
    _start:
    mov $4, %rax
    mov $1, %rdi
    mov $msg, %rsi
    mov $len, %rdx
    int $0x80
    //syscall
    
    mov $1, %rax
    mov $0, %rdx
    int $0x80
    //syscall
    
    rz@butterfly:~ % as -o a.o a.s
    rz@butterfly:~ % ld a.o -o a
    rz@butterfly:~ % ./a
    Hi, Peoplez!


    1. RodionGork Автор
      13.11.2024 05:18

      Любопытно, спасибо - это протестировать я не догадался :) загрузка аргументов через стек в 64-битном ядре не работает.


      1. checkpoint
        13.11.2024 05:18

        В начале 2000-х на эту тему было очень много полемики, линухоиды не упускали шанс при каждом случае уколоть фрюховодов тем что во фре вызов сисколлов неэффективен (через стек), а фря тогда стояла на каждом втором (если не первом) сервер. Поэтому в 64 битной фре от передачи параметров через стек отказались как неэффективной.


        1. SIISII
          13.11.2024 05:18

          Ну дык если регистров для передачи не хватает, придётся в любом случае размещать в памяти -- а там стек будет, скорей, даже более эффективным, чем произвольное место ОЗУ, поскольку он с большей вероятностью будет в кэше.


          1. checkpoint
            13.11.2024 05:18

            Сисколлов с большим числом параметров нет, обычно там передаются указатели на большие структуры. Но прикол в другом. К моменту появления 64-битных ОС проведенные бенчмарки уже не показывали какого-то выигрыша в линуксовой схеме сисколлов, видимо за счет кэшей и прочих микроархитектурных решений. Гораздо более серьезное влияние на производительность оказывал сам процесс переключения контекста и копирование данных между ядрёной и пользовательской памятью. Что в линухе, что во фре это является очень тяжелым процессом. Поэтому переход на линуховую схему был больше PR-ный ход, чтобы ублажить часть сомнивающихся. :)


            1. SIISII
              13.11.2024 05:18

              Ну, я подозреваю. Переключение контекста -- это всегда дорого, а если при этом сыплются кэши и/или TLB, то вообще жуть... В частности, поэтому настоящих микроядерных осей и нет (та же QNX ни разу не микроядерная, если брать сей термин, как он изначально понимался: в ней абсолютно все функции традиционного ядра вроде управления процессами, потоками и памятью, находятся в общем адресном пространстве, и лишь драйверы устройств вынесены в отдельные пространства -- но такое было возможно ещё в той же RSX-11 и VAX/VMS).


              1. checkpoint
                13.11.2024 05:18

                Это все из-за тяжелого наследия под названием "виртуальная память" (пэйджинг). Если отказаться от пэйджинга то можно хорошо увеличить производительность системы. Но тогда потребуется либо тэггированная память, либо системы на базе виртуальной байт-код машины. Не понятно что из них меньшее зло. Ну и переход на такие системы это слишком радикальное решение для подавляющего числа пользователей.


                1. SIISII
                  13.11.2024 05:18

                  А что подразумевается под тэгированной памятью?


                  1. checkpoint
                    13.11.2024 05:18

                    Тэггированный указатель как в E2K в защищенном режиме. Указатель который не является адресом, а некий уникальный идентификатор блока памяти. В нём закодирован размер блока, права доступа и т.д.


  1. emusic
    13.11.2024 05:18

    Итак, мы соорудили две подпрограммы

    В линуксовом ассемблере до сих пор нет адекватных средств оформления подпрограмм, или Вы решили не рассматривать их с целью упрощения?