Так сложилось, что я совсем не знаю ассемблера. Даже несмотря на то, что я разрабатываю компиляторы, на уровень близкий к аппаратуре я почти не спускаюсь. Была пара попыток его выучить, но я просто не находил подходящего материала. В итоге решил что если его нет, то нужно написать самому. В этой заметке я планирую показать как написать простой Hello world на ассемблере.
В данной статье я преследую несколько целей:
Я буду стараться давать минимум теории, т.к. её рассказывают много где, гораздо более подробно и понятно. Поэтому буду описывать только то, что касается данного примера.
Итак, задача: написать программу, выводящую на экран сообщение "Hello, world". В качестве эталона возьмём программу на C:
Сборка и запуск:
Здесь специально не использована стандартная библиотека, а применён системный вызов write. Подробнее про него можно прочесть по команде man 2 write.
В качестве процессора на данной архитектуре применяется Intel Core i5, операционная система - Gentoo GNU/Linux, синтаксис AT&T. По моей любимой привычке сначала напишем программу, а потом будем думать.
Сборка и запуск:
Теперь попытаемся понять что произошло.
Краткое описание синтаксиса:
На каждой строчке находятся команды (statement). Команда начинается с нуля и более меток, после которых находится ключевой символ, обозначающий тип команды. Всё что начинается с точки `.' является директивой ассемблера. Всё что начинается с буквы является инструкцией ассемблера и транслируется в машинный код. Комментарии бывают многострочными `/**/' и однострочными `#'.
Директивы
Далее идёт директива
Директива
Директива
После метки
Данная программа написана под процессор Intel архитектуры amd64 (она же x86_64). Это 64-х битное расширение архитектуры IA-32. Описание самой архитектуры процессора находится в [intel1]. Подробное описание команд процессора находится в [intel2].
Итак, в данной программе мы оперируем регистрами - внутренней памятью процессора. Архитектура amd64 содержит очень мало регистров - всего 16 64-х разрядных регистров общего назначения: RAX, RBX, RCX, RDX, RBP, RSI, RDI, RSP, R8D-R15D.
Операция
Далее идёт операция
Понятно, что помимо номеров нам нужны ещё аргументы этих вызовов. Их можно найти следующим образом:
Но в целом таблицами, подготовленными хорошими людьми пользоваться удобнее.
Итак, видно, что вызов write требует 3 аргумента. Первый - это дескриптор файла вывода. Он кладётся на регистр rdi. Мы на rdi кладём 1, что является дескриптором stdout. На регистр rsi кладётся указатель на адрес строки. И на регистр rdx кладётся длина строки. Всё, теперь, когда все регистры подготовлены, можно делать syscall и нам будет выведено сообщение.
Далее нужно выйти из программы. Для этого используется системный вызов exit. Он имеет номер 60 и требует код возврата в качестве первого аргумента. Мы завершаемся с кодом 0, как и положено успешно выполненной программе.
Не устали? Теперь внезапно рассмотрим sparc. Меня эта платформа интересует, т.к. одна из линеек процессоров Эльбрус основана на этой архитектуре. Я тестировался на процессорах TI UltraSparc III+ (Cheetah+) с ОС Gentoo и процессорах Эльбрус R1000 c ОС Эльбрус. Итак, смотрим:
Сборка и запуск:
Вроде как отличий немного. Синтаксис as был описан в блоке amd64, разве что здесь однострочные комментарии задаются символом
Данные регистры называются r регистрами и используются для целочисленных вычислений. Плавающие регистры называются f регистрами, они расположены отдельно, и о них мы сегодня говорить не будем. Интересно отметить, что сама архитектура предполагает от 64 до 528 r регистров, но регистровое окно содержит только 24. Чтение %g0 всегда возвращает 0, а запись в него не даёт эффекта. Вообще на спарке регистры сделаны очень круто, но их очень долго описывать, советую прочитать документацию [sparcv9].
Переходим к инструкциям. Начнём с инструкции
Синтетические инструкции не являются частью стандарта, но входят в информационное приложение к нему, так что их можно смело использовать.
Следующая инструкция
В данной статье я преследую несколько целей:
- Изучить основы работы с ассемблером
- Сравнить ассемблеры процессоров различных архитектур и, как следствие, показать разные аппаратные особенности
- Написать материал по которому новички далее смогут самостоятельно продолжить изучение ассемблера
1. Введение
Я буду стараться давать минимум теории, т.к. её рассказывают много где, гораздо более подробно и понятно. Поэтому буду описывать только то, что касается данного примера.
Итак, задача: написать программу, выводящую на экран сообщение "Hello, world". В качестве эталона возьмём программу на C:
#include <unistd.h>
int main()
{
const char * msg = "Hello, world\n";
write(0, msg, 13);
return 0;
}
Сборка и запуск:
$ gcc t.c && ./a.out
Hello, world
Здесь специально не использована стандартная библиотека, а применён системный вызов write. Подробнее про него можно прочесть по команде man 2 write.
2. amd64
В качестве процессора на данной архитектуре применяется Intel Core i5, операционная система - Gentoo GNU/Linux, синтаксис AT&T. По моей любимой привычке сначала напишем программу, а потом будем думать.
.section .data
hello_str:
.string "Hello, world\n"
.set hello_str_len, . - hello_str - 1
.section .text
.globl _start
_start:
# Здесь подготавливаем и вызываем write
mov $1, %rax
mov $1, %rdi
mov $hello_str, %rsi
mov $hello_str_len, %rdx
syscall
# Здесь подготавливаем и вызываем exit
mov $60, %rax
mov $0, %rdi
syscall
Сборка и запуск:
$ as tt.s -o tt.o && ld tt.o && ./a.out
Hello, world
Теперь попытаемся понять что произошло.
Краткое описание синтаксиса:
На каждой строчке находятся команды (statement). Команда начинается с нуля и более меток, после которых находится ключевой символ, обозначающий тип команды. Всё что начинается с точки `.' является директивой ассемблера. Всё что начинается с буквы является инструкцией ассемблера и транслируется в машинный код. Комментарии бывают многострочными `/**/' и однострочными `#'.
Директивы
.section
обозначают начало секций. Секция - это диапазон адресов без пробелов, содержащий в себе данные, предназначенные для одной цели [as]. Объектный файл, сгененрированный as имеет как минимум три секции: .text
, .data
, .bss
. Внутри объектного файла по адресу 0 располагается секция .text
, за ней идёт секция .data
, а за ней секция .bss
. Все адреса as вычисляет как (адрес начала секции) + (смещение внутри секции). Итак, что же означают секции:- .data - в этой секции обычно хранятся константы
- .text - в этой секции обычно хранятся инструкции программы
- .bss - содержит обнулённые байты и применяется для хранения неинициализированной информации
.data
у нас стоит метка hello_str
, которая указывает на начало строки.Далее идёт директива
.string
. Это псевдо операция, копирующая байты в объектник.Директива
.set
присваивает символу значение выражения. Т.о. мы говорим что символ hello_str_len
равен выражению . - hello_str - 1
. Символ `.
' означает текущий адрес. Вычитая из него адрес метки hello_str
получаем длину строки с завершающим нулём. Чтобы он не попал на печать вычитаем 1.Директива
.globl
говорит что данный символ должен быть виден ld. Т.е. теперь символ _start
сможет быть слинкован. Это нужно, т.к. вход в программу осуществляется именно через этот символ.После метки
_start
начинаются непосредственно ассемблерные инструкции. И теперь опять вернёмся к теории.Данная программа написана под процессор Intel архитектуры amd64 (она же x86_64). Это 64-х битное расширение архитектуры IA-32. Описание самой архитектуры процессора находится в [intel1]. Подробное описание команд процессора находится в [intel2].
Итак, в данной программе мы оперируем регистрами - внутренней памятью процессора. Архитектура amd64 содержит очень мало регистров - всего 16 64-х разрядных регистров общего назначения: RAX, RBX, RCX, RDX, RBP, RSI, RDI, RSP, R8D-R15D.
Операция
mov
предназначена для копирования первого операнда во второй (заметьте, что это особенность синтаксиса AT&T, и интеловский синтаксис имеет обратный порядок операндов). Мы можем скопировать константу, значение общего или сегментного регистра или значение из памяти. Копировать можно в общий или сегментный регистр или память. Для обозначения констант используется символ $
, а для регистров - %
Чуть позже станет понятно что куда и зачем мы копировали.Далее идёт операция
syscall
. Она делает системный вызов. Системный вызов - это функция из ядра ОС. Каждый системный вызов производится по номеру. Он должен находиться в регистре rax
. Номера системных вызовов можно посмотреть в таблицах [syscall1][syscall2]. Но можно выяснить самому. Их конкретное местоположение зависит от дистрибутива. В моём случае они, например, находятся в файле /usr/include/asm/unistd_64.h. Вот выдержка из этого файла:
...
#define __NR_read 0
#define __NR_write 1
#define __NR_open 2
...
#define __NR_execve 59
#define __NR_exit 60
#define __NR_wait4 61
...
Понятно, что помимо номеров нам нужны ещё аргументы этих вызовов. Их можно найти следующим образом:
$ cd /usr/src/linux/
$ grep -rA3 'SYSCALL_DEFINE.\?(write,' *
fs/read_write.c:SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
fs/read_write.c- size_t, count)
fs/read_write.c-{
fs/read_write.c- struct fd f = fdget_pos(fd);
Но в целом таблицами, подготовленными хорошими людьми пользоваться удобнее.
Итак, видно, что вызов write требует 3 аргумента. Первый - это дескриптор файла вывода. Он кладётся на регистр rdi. Мы на rdi кладём 1, что является дескриптором stdout. На регистр rsi кладётся указатель на адрес строки. И на регистр rdx кладётся длина строки. Всё, теперь, когда все регистры подготовлены, можно делать syscall и нам будет выведено сообщение.
Далее нужно выйти из программы. Для этого используется системный вызов exit. Он имеет номер 60 и требует код возврата в качестве первого аргумента. Мы завершаемся с кодом 0, как и положено успешно выполненной программе.
3. Sparc v9
Не устали? Теперь внезапно рассмотрим sparc. Меня эта платформа интересует, т.к. одна из линеек процессоров Эльбрус основана на этой архитектуре. Я тестировался на процессорах TI UltraSparc III+ (Cheetah+) с ОС Gentoo и процессорах Эльбрус R1000 c ОС Эльбрус. Итак, смотрим:
.section .data
hello_str:
.ascii "Hello, world\n"
.set hello_str_len, . - hello_str
.global _start
.section .text
_start:
! Подготавливаем и вызываем write
mov 1, %o0
set hello_str, %o1
mov hello_str_len, %o2
mov 4, %g1
ta 0x10
! Подготавливаем и вызываем exit
mov 0, %o0
mov 1, %g1
ta 0x10
Сборка и запуск:
$ as -Av9 -64 t1.s -o t1.o && ld -Av9 -m elf64_sparc t1.o && ./a.out
Hello, world
Вроде как отличий немного. Синтаксис as был описан в блоке amd64, разве что здесь однострочные комментарии задаются символом
!
, поэтому его опускаем и переходим сразу к отличиям. Сразу скажу, что речь идёт о Sparc v9 если не оговорено другое. v9 является 64-х битным расширением архитектуры sparc v8. Начнём с регистров. Их здесь больше чем в amd64 - целых 32 общего назначения, доступных пользователю. Сами регистры называются %r0 - %r31, но у них есть логическое разделение:Название | Имя внутри окна | Имя r-регистра |
---|---|---|
Глобальные (global) | %g0 - %g7 | %r0 - %r7 |
Выходные (out) | %o0 - %o7 | %r8 - %r15 |
Локальные (local) | %l0 - %l7 | %r16 - %r23 |
Входные (in) | %i0 - %i7 | %r24 - %r31 |
Данные регистры называются r регистрами и используются для целочисленных вычислений. Плавающие регистры называются f регистрами, они расположены отдельно, и о них мы сегодня говорить не будем. Интересно отметить, что сама архитектура предполагает от 64 до 528 r регистров, но регистровое окно содержит только 24. Чтение %g0 всегда возвращает 0, а запись в него не даёт эффекта. Вообще на спарке регистры сделаны очень круто, но их очень долго описывать, советую прочитать документацию [sparcv9].
Переходим к инструкциям. Начнём с инструкции
mov
. От интела эта инструкция отличается тем, что её нет в Спарке. Sparc - это RISC архитектура с малым количеством команд, но для удобства программистов ассемблер поддерживает синтетические инструкции. В частности приведённый mov
возможно будет оттранслирован следующим образом (есть несколько способов трансляции в зависимости от аргументов):mov 1, %o1
-> or %g0, 1, %o1
Синтетические инструкции не являются частью стандарта, но входят в информационное приложение к нему, так что их можно смело использовать.
Следующая инструкция
set
, являющаяся синонимом к инструкции setuw
, которая тоже является синтетической инструкцией. Её раскрытие возможно выглядит следующим образом:set hello_str %o2 | -> |
|
sethi
поместит старшие 22 бита hello_str
(т.е. её адрес) на регистр %o2. Инструкция or
поместит туда младший остаток. Обозначения %hi и %lo нужны для взятия старших и младших битов соответственно. Такие сложности возникают из-за того что инструкция кодируется 32 битами, и просто не может включать в себя 32-х битную константу.Далее мы кладём значение 4 на глобальный регистр %g1. Можно догадаться что это номер вызова write. Системный возов будет искать номер вызова именно там.
Операция
ta
инициирует системное прерывание. Её аргументом является тип системного прерывания. Скажу честно - я не нашёл нормального описания системных вызовов для v9, а то что туда надо подавать 0x10 выяснил случайно из архивов какой-то переписки. Поэтому придётся просто это запомнить :)Далее производятся аналогичные действия для вызова exit, думаю их пояснять не нужно.
UPD:
Спасибо уважаемому Анониму за версию данной программы для SunOS 5.10:
.section ".text"
.global _start
_start:
mov 4,%g1 ! 4 is SYS_write
mov 1,%o0 ! 1 is stdout
set .msg,%o1 ! pointer to buffer
mov (.msgend-.msg),%o2 ! length
ta 8
mov 1,%g1 ! 1 is SYS_exit
clr %o0 ! return status is 0
ta 8
.msg:
.ascii "Hello world!\n"
.msgend:
Запуск:
$ as t1.s -o t1.o && ld t1.o && ./a.out
Hello world!
4. Эльбрус
Ну и, собственно, жемчужина коллекции - процессор Эльбрус. Работа проводилась на процессоре Эльбрус-4С, который имеет архитектуру команд v3 (наше внутреннее обозначение). Управляется машина ОС Эльбрус. Про сам Эльбрус можно почитать в [elbrus], про какую-либо документацию, находящуюся в открытом доступе мне неизвестно.
Как и Sparc, архитектура Эльбруса рассчитана в первую очередь на то что оптимальный код выдаст компилятор. Но в отличает от Sparc, ассемблер Эльбруса вообще не предназначен для людей. Итак, вот наш пример:
.section ".data"
$hello_msg:
.ascii "Hello, world\n\000"
.section ".text"
.global _start
_start:
! Подготавливаем вызов write
{
sdisp %ctpr1, 0x3
addd, 0 0x0, 13, %b[3]
addd, 2 0x0, [ _f64, _lts1 $hello_msg ], %b[2]
addd, 1 0x0, 0x1, %b[1]
addd, 3 0x0, 0x4, %b[0]
}
! Вызываем write
{
call %ctpr1, wbs = 0x4
}
! Подготавливаем вызов exit
{
sdisp %ctpr2, 0x1
addd, 0 0x0, 0x0, %b[1]
addd, 1 0x0, 0x1, %b[0]
}
! Вызываем exit
{
call %ctpr2, wbs = 0x4
}
Сборка и запуск:
$ las t.s -o t.o && ld t.o && ./a.out
Hello, world
Начнём с изменения синтаксиса.
Мы видим что к синтаксису добавились фигурные скобки. Процессоры Эльбрус основаны на VLIW архитектуре, а значит могут исполнять множество статически спланированных команд за такт. Набор таких команд называется широкой командой (ШК) и заключается в фигурные скобки. Остальной синтаксис более или менее идентичен.
Если посмотреть на команду сборки, то вместо as используется las. Это наш местный ассемблер, но сейчас идёт процесс перехода на gas, поэтому скоро он станет неактуален (отдел, занимающийся ассемблером уже сейчас ругается если я его использую, но в дистрибутиве пока именно он).
Чтобы процессор мог исполнять много команд за такт, ему нужно много регистров. Согласен, что их никогда не бывает много, но для программы на Эльбрусе регистровый файл содержит 256 регистров общего назначения размером 64 бита. Из них 224 предназначены для процедурного стека, а 32 являются глобальными регистрами. В Эльбрусе нет отдельных регистров для плавающих вычислений, все они выполняются на одном конвейере и хранятся в общих регистрах. Именование регистров идёт следующим образом:
- %r<номер> - прямоадресуемые регистры текущего окна. <номер> является индексом относительно базы текущего окна
- %b[<номер>] - вращаемые регистры текущего окна. <номер> - индекс относительно текущей базы
- %g<номер> - глобальные регистры. <номер> является индексом относительно базы текущей глобальной области
- s одинарный формат регистра - 32 бита (Single)
- d двойной формат регистра - 64 бита (Double)
- x расширенный двойной регистра - 80 бит (Extended)
- q квадро формат регистра - 128 бит (Quadro)
Итак теперь переходим к самой программе. Думаю первые несколько строк и так понятны, поэтому рассмотрим сразу первую ШК:
_start:
{
sdisp %ctpr1, 0x3
addd, 0 0x0, 13, %b[3]
addd, 2 0x0, [ _f64, _lts1 $hello_msg ], %b[2]
addd, 1 0x0, 0x1, %b[1]
addd, 3 0x0, 0x4, %b[0]
}
Рассмотрим первую команду
sdisp %ctpr1, 0x3
. А чтобы понять что это такое и что оно делает нужно ещё немного рассказать про механизм работы переходов в Эльбрусе. В процессорах Эльбрус вызов функции является дорогим удовольствием, поэтому переходы следует готовить заранее. Для этого существует два типа команд - ctp (подготовка перехода) и ct - фактический переход. Нам доступно три регистра перехода: %ctpr1-%ctpr3, т.е. за раз мы можем подготовить три маршрута для прыжка. Существует несколько команд подготовки перехода, нас здесь интересует sdisp
. Эта команда подготавливает переход для системного вызова. Первым аргументом идёт регистр перехода, по которому мы будем совершать прыжок. Вторым аргументом - точка входа в операционную систему, нам она нужна равной 3 (64-х битный вход в ОС).Далее рассмотрим команды
addd
. Как я уже говорил, ассемблер Эльбруса не предназначен для людей, и общепринятых мнемоников здесь пока нет. Так в ассемблере нет команды MOV
. Чтобы положить значение на регистр применяется команда add
. Она производит сложение регистров или констант и записывает их в регистр.Для Эльбруса одновременно доступно 6 арифметико-логических каналов (АЛК), т.е. за такт мы можем производить до 6 сложений. Итак, в первой операции мы кладём число 13 в регистр %b[3] - это длина нашей строки. (В версиях для других архитектур мы вычисляли это программно, и в Эльбрусе можно сделать также, но для las у меня это так и не получилось, хотя в gas всё заработало). Далее на регистр %b[2] мы кладём адрес начала нашего сообщения. Затем в %b[1] кладём идентификатор устройства вывода, и, наконец, в %b[0] кладём номер системного вызова. В целом аналогия с другими архитектурами прослеживается.
Далее может возникнуть вопрос зачем в команде
addd
третья d. В мнемониках команд, реализованных для нескольких форматов операндов, последняя буква обозначает используемый формат. В данном случае мы работаем в double формате, т.е. с полноценным 64-х битным регистром.Отдельно рассмотрим команду
addd, 2 0x0, [ _f64, _lts1 $hello_msg ], %b[2]
, которая, как можно догадаться, кладёт в регистр %b[2] адрес печатаемого сообщения. Для того чтобы закодировать адрес в памяти используется аргумент [ _f64, _lts1 $hello_msg ]
. Квадратные скобки означают взятие адреса. Внутри расположен длинный литерал. Его содержимое означает следующее:- _f64 - формат литерала. В данном случае мы говорим что это литерал размера 64 (хотя он уместится и в 32 бита)
- _lts1 - литеральный слог, кодирующий константное значение. Всего доступно 4 литеральных слога, так что в одной ШК мы не сможем поместить более 4 длинных литералов (в случае формата _f64 - не более 2).
- $hello_msg - идентификатор, обозначающий нашу метку
Во второй ШК у нас производится операция
call %ctpr1, wbs = 0x4
, которая вызывает функцию, переход на которую подготовлен на регистре %ctpr1. т.е. вызывается наш write. Второй аргумент задаёт смещение для новой базы регистрового окна. Здесь я не буду объяснять что это значит, т.к. это займёт много времени, просто пока придётся запомнить что это должно быть так (на самом деле это очень частный случай и нужно понимать как его высчитывать)В третьей ШК мы аналогичным образом подготавливаем переходы для вызова exit, и в четвёртой ШК мы его вызываем.
Всё, проще некуда.
Послесловие
Как я уже говорил в начале, данный материал появился потому что я не смог найти чего-то подобного в сети. На самом деле многое я взял из этого [0xax] блога - описание примера на x86 и вообще саму идею. Для остальных архитектур пришлось изворачиваться :) Позже, во время работы над заметкой, я нашёл это [mechasm] неплохое описание, но оно уже было неактуально.
Вообще я планировал написать эту заметку за неделю-две и перейти на следующий пример. Более того хотел ещё включить описание llvm IR. Но внезапно простенькая заметка про hello world заняла у меня несколько месяцев. Преимущественно из-за Эльбруса. Тут оказалось много нового и непонятного при почти полном отсутствии читабельной документации. И тут хотелось бы сказать огромное спасибо многим моим коллегам, которые терпеливо в течении долгого времени разъясняли мне простейшие вещи.
В данной заметки могут быть неточности, ошибки и вообще фиг знает что, поэтому если что-то не так - пишите, я поправлю :)
Источники
[intel1] Intel® 64 and IA-32 Architectures Software Developer’s Manual Volume 1: Basic Architecture
[intel2] Intel® 64 and IA-32 Architectures Software Developer’s Manual Combined Volumes: 1, 2A, 2B, 2C, 3A, 3B, 3C and 3D
[syscall1] Таблица системных вызовов linux
[syscall2] Другая таблица системных вызовов linux
[as] Мануал по ассемблеру
[0xax] Серия постов про написание hello world на ассемблере amd64. Во многом при написании заметки я смотрел именно в этот пост, там весьма подробное и доходчивое описание с замечаниями в комментах
[mechasm]Аналогичный пост на русском, который я нашёл не сразу и не пользовался им. Но стиль изложения мне нравится
[sparcv9]The SPARC Architecture Manual Version 9
[sparcv9asm] SPARC Assembly Language Reference Manual
[oracle] Актуальная документация от Oracle
[sparcasmbook]SPARC Architecture, Assembly Language Programming, and C. Очень хороший учебник по ассемблеру и по спарку
[elbrus] Микропроцессоры и вычислительные комплексы семейства «Эльбрус»