Глоссарий
Ассемблер - программа, которая преобразует(транслирует) код, написанный на языке ассемблера в машинный код;
Язык Ассемблера – низкоуровневый язык программирования, где машинный инструкции(числа) заменены на мнемоники(слова) для удобства человека.
Программа - текстовый файл, который содержит в себе код на каком либо из языков программирования;
Процесс – абстракция операционной системы, позволяющая следить и управлять ходом выполнения программы;
Введение: что будет в статье?
На самом деле, целью статьи является не столько просто написать "Hello, World!" на языке ассемблера, сколько разобрать вещи, которые многие воспринимают как данность, хотя имеют с ними дело каждый миг использования компьютера.
Но прежде чем перейти к написанию "Hello, World!", нужно разобрать некоторые концепции, которые помогут нам в полной мере понять происходящее за сценой. Для понимания изложенного ниже нужно хотя бы знать о существовании Linux и понимать что делает программа "Hello, World!". Думаю, кем бы вы ни были, раз вы на Хабре, то с этими проблем не будет :)
Мы разберём множество вещей, которые прямо или косвенно касаются программы с приветствием мира на языке ассемблера и к концу статьи будем досконально понимать каждую инструкцию в коде.
Мы разберёмся:
Что такое регистры процессора и как их использовать;
Как происходит процесс компиляции программ;
Какой формат имеют исполняемые файлы в Linux и каких типов они бывают;
Узнаем что такое виртуальное адресное пространство процесса и что в нём хранится;
Поймём процесс линковки(компоновки) программ;
Сделаем собственную библиотеку и поймём разницу между статическими и динамическими библиотеками;
Наконец, с полным пониманием происходящего напишем "Hello, World!" на языке ассемблера.
И начнём мы, как ни странно, с "Hello, World!" на язык C, но прежде проговорим основные вещи.
Основы, которые стоит понять
Язык Ассемблера, процессор и регистры
Итак, мозгом компьютера является процессор, который понимает определённый ограниченный набор команд(сложить, умножить, разделить, перенести значение из регистра A в регистр B и т.д.). Очевидно, что если мы хотим заставить процессор производить какую-либо полезную нагрузку, например решить задачу, нам нужно переформулировать эту задачу в инструкции, доступные процессору.
Из задачи "Сложи 2 и 3" мы получим:
mov rdi, 2 ; значение первого операнда в регистре rdi
mov rax, 3 ; значение второго операнда в регистре rax
add rdi, rax ; ответ в регистре rdi = rdi + rax
Мы уже затронули регистры, но что это такое?
Процессор располагает регистрами, сверхбыстрыми элементами памяти, которые физически расположены на кристалле процессора. Регистры представляют собой набор триггеров, базовых элементов памяти, состоящих из транзисторов. Триггер может хранить одно из двух состояний: 1 и 0, есть заряд и нет заряда. Не будем углубляться в схемотехнику и виды триггеров и транзисторов, а просто примем это как абстрактную модель, отражающую суть физической модели.

Регистр же состоит из множества триггеров, являясь, проще говоря, массивом триггеров. То есть массивом булевых значений, если говорить на языке программистов.
А теперь посмотрим что будут хранить регистры в процессе выполнения задачи "Сложи 2 и 3", которую мы описали выше:
mov rdi, 2 ; занесли 010(2 в двоичной) в регистр rdi
mov rax, 3 ; занесли 011(3 в двоичной) в регистр rax
add rdi, rax ; получили 101(5 в двоичной) ответ в регистре rdi
Инструкцией мы называем одно строчку кода, одну завершённую к процессору просьбу, например:
mov rdi, 2 ; занеси значение 2 в регистр rdi
А мнемоникой(командой) называют отдельную команду, такую как add
или mov
, с которыми мы уже познакомились.
Команда add
работает так, что прибавляет значение во втором переданном ей регистре к значению первого переданного ей регистра. И вот что выходит:

Команды на языке ассемблера, которые мы видели выше, называются мнемониками. Это удобочитаемые псевдонимы для команд. Процессор может оперировать только числами, а человеку легче иметь дело с английским языком и сокращениями на нём, поэтому и придумали мнемоники(add
, mov
, sub
и т.д.)!
Чтобы запустить ассемблерный код(чем мы и займёмся немного позже), нужно пройти процесс ассемблирования. Программа под названием Ассемблер возьмёт текст программы на языке ассемблера и переведёт каждую из инструкций с английского языка на язык чисел.
Не запутались? Можно сказать, что ассемблер - это компилятор(транслятор) для языка ассемблера, только он намного проще компиляторов высокоуровневых языков программирования. Так как ему нужно просто пройтись по таблице, в которой указано соотношение между мнемониками и командами в их численно и понятном для процессора виде.
Пример такой таблицы:
Мнемоника/Команда |
Численное значение |
---|---|
add |
1 |
mov |
2 |
Наш вымышленный процессор, исполняя код, увидев на каком-то моменте число 1 поймёт, что его просят произвести сложение и обратится с АЛУ(Арифметико-логическое устройство), чтобы произвести сложение.
Таким образом код на языке ассемблера простым сопоставлением(стандартизированным, разумеется) переводится в машинный код(в простонародье бинарник), понятный для нашего процессора!
Процесс перевода кода на языке ассемблера в машинный код называется ассемблированием. А процесс перевода машинного кода в программу на языке ассемблера называется дизассемблированием, но тут мы смотрим таблицу уже справа налево. Этим и занимаются реверс-инженеры.
Языка Habra: немного о компиляции
Предположим, что мы придумали язык Habrа
. Правда, придумали мы только синтаксис, да и то лишь малую его часть. Но это уже что-то! Вот что у нас вышло:
funct main() -> integer <
println("Hello, World!\n");
ret 0;
>
Функции мы будем определять с помощь ключевого слова funct
, так как func
уже есть в Golang
, а fn
в Rust
, мы же хотим хоть какого-то разнообразия. Возвращаемое значение функции указываем как в питоне, а вместо фигурных скобок тело функции оборачивается в скобки угловые.
Как по мне, выглядит очень даже стильно и понятно, но как нам запустить эту программу? Для этого нам нужно написать компилятор, который будет преобразовывать код на языке Habra
в код на языке ассемблера.
Предположим, что компилятор мы написали и дали ему на вход текст программы, которая написана выше. Основной его задачей является брать текст на языке Habra
и переводить его в текст на языке ассемблера. Это он и сделал(внимание, спойлер):
global _start
section .data
string_hello: db "Hello, World!", 10
section .text
_start:
mov rax, 1
mov rdi, 1
mov rsi, string_hello
mov rdx, 14
syscall
mov rax, 60
mov rdi, 0
syscall
Семантически(то есть по смыслу), что программа на языке Habra
, что этот ассемблерный код(который мы полностью поймём к концу статьи) - одинаковы. Несложно догадаться, что обе программы выводят в терминал строчку "Hello, World!".
Компилятор Habra
выдал семантически идентичный нашему коду код на языке ассемблера. Простыми словами код на языке ассемблера, который выполняет то, что мы хотели от кода на языке Habra
.
После этого код на языке ассемблера нужно скормить программе Ассемблера, которая переведёт этот код в машинные инструкции(в бинарный код). С этим мы уже разобрались выше.
Системные вызовы и оболочки над ними
Вот самый простой способ написать приветствие мира на языке C:
#include <stdio.h>
int main() {
printf("Hello, World!\n");
return 0;
}
Мы воспользовались функцией printf
(print format), которая является частью стандартной библиотеки C
- libc
.
printf
внутри вызывает множество вспомогательных функций(на эту тему есть отличная статья на Хабре), среди которых есть функция write
, что является самым низким уровнем абстракции для прикладного программиста:
#include <stdio.h>
#include <unistd.h>
char *string = "Hello, World!\n";
int main() {
write(STDOUT_FILENO, string, 14);
return 0;
}
Первым аргументом мы передаём дескриптор файла, в который мы хотим осуществить запись. В нашем случае мы хотим записать в терминал, поэтому передаём число 1
STDOUT_FILENO
является константой, которая определена в стандартной библиотеке в unistd.h
/* Standard file descriptors. */
#define STDIN_FILENO 0 /* Standard input. */
#define STDOUT_FILENO 1 /* Standard output. */
#define STDERR_FILENO 2 /* Standard error output. */
Да, в Linux всё есть файл, поэтому с выводом в терминал мы взаимодействуем также, как и с записью в файл. Это просто удобный интерфейс для взаимодействия с многими вещами. Не будем особо углубляться в это, так как тема далека от темы данной статьи. Данная концепция неплохо разобрана в моей статье про Docker. Вторым аргументом передаём указатель на начало строки и длину строки третьим аргументом.
Функция write
является оболочкой над системным вызовом sys_write
. Системные вызовы - это API операционной системы для пользовательских процессов, посредством которого они могут безопасно управлять системными ресурсами. write
является оболочкой над системным вызовом в том смысле, что для нас, как для программистов, это обычная функция, которая выполняет запись(например в терминал) и мы не задумываемся о том, что происходит за сценой. Мы просто используем эту функцию(оболочку над системным вызовом).
В стандартной библиотеки C
функция write
под капотом имеет разные ассемблерные коды для вызова системного вызова sys_write
в зависимости от архитектуры. Если программа исполняется на intel x86_64
, то внутри библиотеки вызовется именно sys_write_x86_64.asm
(название выдумал). Разумеется, ведь разные архитектуры имеют разные наборы команд или разную длину регистров, поэтому машинный код для x86_64
не исполнится, например, на ARM
.
Если это было не до конца понятно, то подождите, к концу всё встанет на свои места.
ELF: формат исполняемых файлов. Виртуальное адресное пространство процесса
Напишем простую программу на C:
#include <stdio.h>
int main() {
char buf[1024];
printf("What is your cat's name?\nEnter a name: ");
scanf("%s", &buf);
printf("%s is a very cute cat!\n", buf);
return 0;
}
Вывод очевиден, но мы проверим :) Скомпилируем её с помощью компилятора gcc
. Так как мы не указали имя исполняемого файла через флаг -o
у gcc
, получаем стандартное название a.out
. Давайте запустим этот файл и посмотрим что получится:
zpnst@debian ~/D/p/h/a/elf> gcc main.c
zpnst@debian ~/D/p/h/a/elf> ./a.out
What is your cat's name?
Enter a name: Barsik
Barsik is a very cute cat!
Котики, это, конечно же, классно, но задумывались ли вы что представляет из себя файл a.out
? Если вы не встречались с этим раньше, можно наивно подумать(как и я думал раньше), что исполняемый файл содержит только лишь машинные инструкции, которые задают поведение нашей программы. Они то, разумеется, там есть, но всё не так просто.
Формат исполняемых файлов в Linux
называется ELF
(Executable and Linkable Format, формат исполняемых и связываемых файлов), аналог EXE
файлов на Windows. И содержат они всю информацию о будущем процессе, что будет порождён операционной системой при запуске этого файла.
Это как подробный рецепт для приготовления процесса для ядра операционной системы. Давайте же посмотрим на его содержимое с помощью утилиты hexdump
:
zpnst@debian ~/D/p/h/a/elf> hexdump a.out
0000000 457f 464c 0102 0001 0000 0000 0000 0000
0000010 0003 003e 0001 0000 1060 0000 0000 0000
0000020 0040 0000 0000 0000 36c8 0000 0000 0000
0000030 0000 0000 0040 0038 000d 0040 001f 001e
0000040 0006 0000 0004 0000 0040 0000 0000 0000
0000050 0040 0000 0000 0000 0040 0000 0000 0000
0000060 02d8 0000 0000 0000 02d8 0000 0000 0000
... and more
Ничего не понятно? Конечно, было бы очень скучно открывать спецификацию формата ELF
и смотреть какой байт с каким значением за что отвечает. Благо, существует утилита readelf
, которая делает это за нас:

ELF
файл состоит из заголовка и прочих данных, на скриншоте выше виден заголовок. readelf
сама интерпретировала байты, записанные в файле в соответствии со спецификацией(стандартом) и выдала нам удобочитаемую информацию.
В поле Type
мы можем увидеть Position-Independent Executable file
. Это значит, что данный файл является исполняемым файлом, а также ему без разницы по какому адресу в виртуальной памяти(об этом чуть позже) ОС разместит его исходный код. Если вы не поняли про Position-Independent
(PIE), это не страшно, достаточно прочитать страницу в Wiki. А возможно, вы поймёте к концу статьи, когда мы соберём всё воедино.
Всё это тесно связано с виртуальным адресным пространством процесса, в двух словах сложно объяснить что тут к чему, данная тема заслуживает отдельной большой статьи, но я постараюсь :)

Дисклеймер: пожалуйста, дочитайте этот раздел до конца, даже если по началу не совсем поймёте о чем речь.
Давным давно, когда компьютеры были очень дорогими и их было сложно обслуживать, встал вопрос о их многозадачности. Так как выполнять всего одну задачу в рамках одной "железки" было очень долго и дорого. Разумеется, решая какую-либо задачу, компьютер хранит данные и их структуры, относящиеся к этой задаче, в оперативной памяти. И если компьютер решает более одной задачи одновременно, то как нам гарантировать, что данные для одной задачи в ОЗУ не повредят данные, которые относятся к другой задаче?
Да и вообще, ядро операционной системы это та же самая программа, код и данные которой также находятся в ОЗУ. Поэтому не так страшно повредить данные чужой задачи, страшно повредить код или данные ОС. Тогда рухнет всё, включая все пользовательские задачи. Придётся перезагружать систему.
Именно поэтому, после долгих попыток и идей, люди придумали виртуальную память. Операционная система создаёт процессу иллюзию того, что в его власти находится вся доступная машине оперативная память. Главной фишкой виртуальной памяти является свопинг. Так как виртуальная память выделяется процессу страницами(чаще всего блоками по 4КБ), то те страницы, которыми процесс не пользуется в данный момент могут быть выгружены из ОЗУ на жёсткий диск, дабы освободить драгоценное место в оперативной памяти. Таким образом, мы расширяем оперативную память фактически до размера свободного места на жёстком диске.
Также, хоть процесс и думает, что монопольно использует ОЗУ, он может достучаться только до своих страниц. Процесс не может как в старые времена просто обратиться по любому физическому адресу в оперативной памяти и поменять его содержимое. Теперь он общается с физической памятью только через прослойку, называемую виртуальной памятью и регулируемую операционной системой. Процесс оперирует только виртуальными адресами, он не может напрямую обратиться к конкретному адресу в физической памяти(ОЗУ), эта роль перекладывается на плечи умной и безопасной ОС. Операционная система выделяет процессу только свободные блоки по 4КБ(страницы), которые в данный момент ни кем не используются.
Страницы виртуальной памяти сопоставляются с физическими посредством таблицы страниц(картинка выше). Также, есть и аппаратная поддержка в виде MMU с TLB, но не будем сейчас заострять на этом внимание. Таблица страниц это просто словарь(хэш-таблица), который хранится в памяти ядра операционной системы и к которому ОС обращается каждый раз, когда ей нужно перевести виртуальный адрес в физический.
Сам процесс перевода адресов и прочие вещи совершенно не тривиальны! Чтобы понять как это работает в деталях нужно провести не один день, а может и не одну неделю за чтением материалов по данной теме и разбора исторических костылей intel.
Важно понимать основные концепции, а именно:
Изоляция процессов(процесс А не может достучаться до данных процесса Б);
Свопинг(сбрасывание неиспользуемых страниц на диск, тем самым освобождая оперативную память);
Создание для процесса иллюзии, что он владеет всей доступной в системе ОЗУ, так как есть свопинг и изоляция.
Всё это было нужно нам для того, чтобы посмотреть на модель виртуального адресного пространства процесса, уже плюс минус понимая что это. В большинстве случаев оно выглядит так:

То есть в страницах, выделенных процессу операционной системой, данные хранятся следующим образом(идём снизу вверх):
.text - тут хранится исходный машинный код программы;
.data - тут хранятся глобальные переменные, которые были инициализированы на момент компиляции;
.bss - тут хранятся переменные, которые пока не были инициализированы, их просто объявили и всё;
heap - в куче хранятся динамические данные процесса,
malloc
вС
выделяет память именно в куче;stack - тут хранятся локальные переменные и аргументы функций, а также адреса возврата.
Скоро мы рассмотрим конкретный пример кода и поймём какая переменная где хранится
И тут нет никакой магии, это просто стандартизированный способ организации адресного пространства процесса. Например, область памяти, где располагается стек ни чуть не отличается от области памяти, где располагается куча. Дело в том, как мы(ОС) обращается к этой памяти. Кучу отдают на произвол судьбы и мы сами записываем туда данные. В стеке же всё чётко, как сложенные тарелки на кухне. Самая нижняя тарелка, что была положена на стол первой дождётся еды самой последней, LIFO(разумеется, если будут брать только верхнюю тарелку из стопки).
Дабы лучше понять что такое стек, рассмотрим программу:
#include <stdio.h>
int foo3() {
int var3 = 3;
printf("foo3 start, my var = %d\n", var3);
printf("foo3 end\n");
}
int foo2() {
int var2 = 2;
printf("foo2 start, my var = %d\n", var2);
foo3();
printf("foo2 end\n");
}
int foo1() {
int var1 = 1;
printf("foo1 start, my var = %d\n", var1);
foo2();
printf("foo1 end\n");
}
int main() {
int var0 = 0;
printf("main start, my var = %d\n", var0);
foo1();
printf("main end\n");
}
Вывод:
zpnst@debian ~/D/p/h/a/elf> gcc stack.c
zpnst@debian ~/D/p/h/a/elf> ./a.out
main start, my var = 0
foo1 start, my var = 1
foo2 start, my var = 2
foo3 start, my var = 3
foo3 end
foo2 end
foo1 end
main end
Вот так будет выглядеть область памяти, в которой обычно располагается стек(картинка выше) на момент выполнения функции foo3
:

Каждая функция в стеке представляется стековым фреймом, давайте рассмотрим их поближе:

Не стоит боятся терминологии и абстракций. Стековый фрейм представляет собой часть в стеке, где хранятся локальные переменные и аргументы конкретной функции, а также адрес возврата. То есть следующая инструкция после инструкции вызова текущей функции, с которой нужно продолжить выполнения программы, когда текущая функция подойдёт к концу. Этот адрес, разумеется, указывает на секцию .text, так как именно там находится машинный код программы. Адреса возврата на картинке записаны как ret от слова return.
Как мы видели из вывода программы, функция main
началась первая и закончилась последняя, так как мы "закопали" её в цепочке вызовов. В конце концов, вызвав функцию foo3
из foo2
, foo3
сразу напечатала start со своей переменной и end. Далее из функции foo3
мы перешли обратно в функцию foo2
на момент печати end, так как в стековом фрейме функции foo3
хранился адрес возврата, который указывает на второй printf
в функции foo2
. И так по цепочке вызовов мы дошли обратно в main
, которая напечатала свой end.
Тут нет никакой магии, компилятор в конец каждой функции(разумеется в код на языке ассемблера) вставляет инструкцию ret
, которая берёт из стека значение адреса возврата и передаёт управление инструкции, которая располагается по этому адресу возврата. То есть кладёт значение этого адреса в регистр PC
(Program counter), из которого процессор извлекает адрес следующей инструкции. Обычно, когда мы последовательно выполняем тело функции, которая ничего не вызывает, с выполнением каждой инструкции регистр PC
инкрементируется на единицу. Что логично, так как мы просто переходим к следующей инструкции и так шаг за шагом.
Опять же, тут нет никакой магии, просто сквозь года и множество ошибок люди договорились, что функции будут работать именно так, а структура памяти процесса будет именно такая. Всё это связано с поиском лучшего решения и костылей, которые иногда появляются чисто исторически, из-за желания сохранить обратную совместимость(этим очень славится компания intel)
Итак, теперь мы имеем представление о виртуальном адресном пространстве процесса. Давайте взглянем на простой код и поймём где что будет хранится в адресном пространстве процесса при запуске этой программы:
#include <stdio.h>
#include <stdlib.h>
int a; /** .bss - сегмент неинициализированных данных */
int primes[] = {2, 3, 5, 7}; /** .data - сегмент инициализированных данных */
int sum(int a, int b) { /** Стековый фрейм функции sum */
int c = a + b; /** Переменная c в стековом фрейме функции sum */
return c;
}
int main() { /** Стековый фрейм функции main */
int *array = (int*)malloc(sizeof(int) * 5); /** Переменная указатель array в стековом фрейме функции main
А память(массив), на которую указывает указатель array в куче */
array[1] = 42;
printf("%d\n", array[1]);
return 0;
}
Вывод:
zpnst@debian ~/D/p/h/a/elf> gcc mem.c
zpnst@debian ~/D/p/h/a/elf> ./a.out
42
И зачем же нам понимать что где хранится в виртуальном адресном пространстве процесса?
Рассмотрим другую часть нашего ELF
файла c программой про котиков из начала параграфа, ещё помните её?
Там мы найдём Section Headers
, где среди всего прочего будут знакомые нам вещи:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[15] .text PROGBITS 0000000000001060 00001060
000000000000014b 0000000000000000 AX 0 0 16
[25] .data PROGBITS 0000000000004010 00003010
0000000000000020 0000000000000000 WA 0 0 16
[26] .bss NOBITS 0000000000004030 00003030
0000000000000008 0000000000000000 WA 0 0 4
Да, это те самые секции, о которых мы говорили выше. В ELF
файле указывается какой у них будет размер и по какому адресу они будут загружены(в конце статьи мы наглядно посмотрим на это).
То есть в исполняемом файле записывается как бы образ/прототип процесса. При запуске исполняемого файла ОС считывает все эти данные и порождает процесс из этого образа/прототипа. ОС помещает машинный код программы в секцию .text
, настраивает виртуальное адресное пространство будущего процесса, записывает в регистр PC
(мы упоминали о нём выше) первую инструкцию кода программы и так далее.
Разумеется в ELF
не описываются куча и стек, потому что мы не можем заранее сказать какого размера нам они нужны. Куча и стек это атрибуты рантайма программы, то есть они динамически увеличиваются и уменьшаются во время выполнения программы. В куче мы можем как выделить память(malloc
в С), так и освободить(free
в C). В стеке мы можем как вызывать много функций, так и долго исполнять одну единственную функцию. Думаю, эта идея ясна.
Всё это нужно для полного понимая того, как работает программа "Hello, World!" на ассемблере. Нам осталось совсем чуть-чуть, понять что такое линковка.
Процесс линковки: создаём свою библиотеку
Так будет выглядеть наша библиотека mathlib.c
:
#include <stdio.h>
const float PI = 3.14;
extern char* APP_NAME;
float sum(float a, float b) {
return a + b;
}
float mult(float a, float b) {
return a * b;
}
void print_app_name() {
printf("%s\n", APP_NAME);
}
extern
у переменной APP_NAME указывает компилятору на то, что данная переменная будет определена в другом месте. В нашем примере мы будет определять её в нашем приложении.
А вот так будет выглядеть наше приложение main.c
, которое будет пользоваться библиотечными функциями и переменными:
#include <stdio.h>
extern float PI;
char* APP_NAME;
float sum(float a, float b);
float mult(float a, float b);
void print_app_name();
int main() {
float res1 = sum(41.0, 1.0);
printf("res1: %f\n", res1);
float res2 = mult(PI, 2.0);
printf("res2: %f\n", res2);
APP_NAME = "habr-app";
print_app_name();
return 0;
}
extern
у переменной PI, опять же, говорит о том, что эта переменная определена в другом месте, а именно в библиотеке mathlib.c
.
Итак, у нас есть два разных файла, как нам их соединить? Мы можем воспользоваться нашей библиотекой как статической библиотекой и просто скомпилировать всё в один исполняемый ELF
файл:
zpnst@debian ~/D/p/h/a/linking> gcc mathlib.c main.c -o app
zpnst@debian ~/D/p/h/a/linking> ./app
res1: 42.000000
res2: 6.280000
habr-app
zpnst@debian ~/D/p/h/a/linking> ll
total 24K
-rwxr-xr-x 1 zpnst zpnst 16K Aug 23 21:59 app*
-rw-r--r-- 1 zpnst zpnst 350 Aug 23 21:59 main.c
-rw-r--r-- 1 zpnst zpnst 225 Aug 23 21:51 mathlib.c
Я использую оболочку fish
, команда ll
там эквивалента команде ls -l
в bash
Обратите внимание, исполняемый файл app
весит 16 Килобайт.
Мы можем и отдельно скомпилировать каждый из файлов, а потом соединить в одно целое. Результат будет тот же:
zpnst@debian ~/D/p/h/a/linking> gcc -c mathlib.c -o mathlib.o
zpnst@debian ~/D/p/h/a/linking> gcc -c main.c -o main.o
zpnst@debian ~/D/p/h/a/linking> gcc main.o mathlib.o -o app
zpnst@debian ~/D/p/h/a/linking> ./app
res1: 42.000000
res2: 6.280000
habr-app
zpnst@debian ~/D/p/h/a/linking> ll
total 32K
-rwxr-xr-x 1 zpnst zpnst 16K Aug 23 22:03 app*
-rw-r--r-- 1 zpnst zpnst 350 Aug 23 21:59 main.c
-rw-r--r-- 1 zpnst zpnst 2.0K Aug 23 22:03 main.o
-rw-r--r-- 1 zpnst zpnst 225 Aug 23 21:51 mathlib.c
-rw-r--r-- 1 zpnst zpnst 1.6K Aug 23 22:03 mathlib.o
Файл .o
это объектный файл того же формата ELF
:
zpnst@debian ~/D/p/h/a/linking> readelf --all mathlib.o
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: REL (Relocatable file)
Поле Type
сообщает нам о том, что это REL (Relocatable file)
. Это значит, что его пока нельзя запустить. Чтобы его запустить, нужно для начала его слинковать. Мы сделали это командой gcc main.o mathlib.o -o app
, сшили два файла воедино в одно приложение. Также, линковать можно и утилитой ld
, но c gcc
это делать проще в данном случае.
Вот что нам говорит man ld
:
LD(1) GNU Development Tools LD(1)
NAME
ld - The GNU linker
Всё явно и по делу :) Данная утилита понадобится нам чуть позже.
Зачем это нужно? Например у нас огромный проект, написанный на C
, компиляция которого с нуля занимает целый час(да, и такое может быть). И вдруг нам захотелось поменять логику одной из функций в одном из файлов. Отлично, поменяли, но не ждать же нам опять час, пока всё скомпилируется?
Поэтому отельные файлы компилируют в объектные .o
файлы и потом линкуют их вместе, что занимает считанные секунды. При таком подходе мы потратим пару секунд на перекомпиляцию файла, в котором мы меняли логику одной из функций и пару секунд на повторную линковку нашего проекта. Дело в шляпе!
Но проблема остаётся той же, мы сильно раздуваем размер исполняемого файла приложения.
Но мы можем сделать и динамическую библиотеку. При динамической линковке код библиотек не включается в исполняемый файл приложения. Библиотека остаётся отдельным файлом, на который приложение просто ссылается.
zpnst@debian ~/D/p/h/a/linking> gcc -c -fPIC mathlib.c -o mathlib.o
zpnst@debian ~/D/p/h/a/linking> gcc -shared mathlib.o -o mathlib.so
zpnst@debian ~/D/p/h/a/linking> readelf --all mathlib.so
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Shared object file)
Что мы тут сделали?
Скомпилировали
mathlib.c в
объектный файл, сказав, что мы хотим именноPIC
код. О нём мы упоминали ранее, это значит, что ОС сможет загрузить этот код в любое место в адресном пространстве. Это очень важно особенно для библиотек, но не стоит сейчас заострять на этом особое внимание;Сделали разделяемую(динамическую) библиотеку
.so
, указав флаг-shared
;Исследовали новый для нас формат с помощью утилиты
readelf
. Теперь в полеType
написаноDYN (Shared object file)
.
А теперь попробуем воспользоваться свежеиспеченной библиотекой:
zpnst@debian ~/D/p/h/a/linking> gcc -c main.c -o main.o
zpnst@debian ~/D/p/h/a/linking> gcc main.o mathlib.so -o app
zpnst@debian ~/D/p/h/a/linking> ./app
./app: error while loading shared libraries: mathlib.so: cannot open shared object file: No such file or directory
zpnst@debian ~/D/p/h/a/linking [127]> export LD_LIBRARY_PATH=.
zpnst@debian ~/D/p/h/a/linking> echo $LD_LIBRARY_PATH
.
zpnst@debian ~/D/p/h/a/linking> gcc main.o mathlib.so -o app
zpnst@debian ~/D/p/h/a/linking> ./app
res1: 42.000000
res2: 6.280000
habr-app
zpnst@debian ~/D/p/h/a/linking> ll
total 48K
-rwxr-xr-x 1 zpnst zpnst 16K Aug 23 22:22 app*
-rw-r--r-- 1 zpnst zpnst 350 Aug 23 21:59 main.c
-rw-r--r-- 1 zpnst zpnst 2.0K Aug 23 22:22 main.o
-rw-r--r-- 1 zpnst zpnst 225 Aug 23 21:51 mathlib.c
-rw-r--r-- 1 zpnst zpnst 1.7K Aug 23 22:12 mathlib.o
-rwxr-xr-x 1 zpnst zpnst 16K Aug 23 22:13 mathlib.so*
Что мы тут сделали?
Скомпилировали наш
main
в объектный файл;Скомпилировали приложение, указав динамическую библиотеку
mathlib.so
;Попробовали запустить и получили ошибку, что нет такой библиотеки. Дело в том, что когда мы указываем название библиотеки, ОС ищет их сначала в переменной окружения
$LD_LIBRARY_PATH,
а потом по стандартным путям(например/usr/lib
или/usr/local/lib/
);Поэтому добавляем точку(то есть нашу текущий каталог, так как именно там лежит нужный для линковки
mathlib.s
o файл);Пробуем скомпилировать снова и запускаем. Ура, всё работает!
С помощью утилиты ldd
мы можем посмотреть от каких динамических библиотек зависит наш исполняемый файл. Кроме стандартной библиотеки языка С libc.so
для функции printf
, он зависит и от нашей mathlin.so
:
zpnst@debian ~/D/p/h/a/linking> ldd app
linux-vdso.so.1 (0x00007ffcf4bb7000)
mathlib.so => ./mathlib.so (0x00007ff8be719000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007ff8be51e000)
/lib64/ld-linux-x86-64.so.2 (0x00007ff8be725000)
Но почему-то размер исполняемого файла app
остался по прежнему 16 Килобайт, так в чём же плюс? Дело в том, что библиотека до смеха мала.
Давайте проведём эксперимент с простой программой "Hello, World!":
#include <stdio.h>
int main() {
printf("Hello, World!\n");
return 0;
}
А теперь сравним:
zpnst@debian ~/D/p/h/a/linking> gcc hello.c -o hello
zpnst@debian ~/D/p/h/a/linking> ./hello
Hello, World!
zpnst@debian ~/D/p/h/a/linking> ldd hello
linux-vdso.so.1 (0x00007ffecd1a2000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fae2ec16000)
/lib64/ld-linux-x86-64.so.2 (0x00007fae2ee18000)
zpnst@debian ~/D/p/h/a/linking> ll hello
-rwxr-xr-x 1 zpnst zpnst 16K Aug 23 22:32 hello*
zpnst@debian ~/D/p/h/a/linking> gcc -static hello.c -o hello
zpnst@debian ~/D/p/h/a/linking> ./hello
Hello, World!
zpnst@debian ~/D/p/h/a/linking> ldd hello
not a dynamic executable
zpnst@debian ~/D/p/h/a/linking [1]> ll hello
-rwxr-xr-x 1 zpnst zpnst 745K Aug 23 22:33 hello*
zpnst@debian ~/D/p/h/a/linking>
Что мы сделали?
Скомпилировали программу так, как мы это делаем обычно(по умолчанию
gcc
ищетlibc.so
дляprintf
по стандартным путям и динамически линкует её с нашей программой);Проверяем, что программа работает как надо;
С помощью утилиты
ldd
смотрим, от каких динамических библиотек зависит наш исполняемый файл;Смотрим на размер, всего
16 Килобайт
;А теперь компилируем тоже самое, но уже с флагом
-static
, указывая компилятору, что мы хотим слинковать стандартную библиотеку языка C с нашей программой статически;Проверяем, что программа работает как надо;
Смотрим на вывод
ldd
, он нам говорит, что наш исполняемый файл не зависит ни от каких динамических библиотек. Этого мы и хотели, наш файл вобрал в себя всюlibc
и теперь он полностью самодостаточен;А теперь смотрим на размер, целых
745 Килобайт
, что почти в 47 раз больше!
Ура, с этим разобрались! Круто, если вы дошли до сюда, приступим к написанию "Hello, World!" на языке ассемблера. Теперь эта задача будет для нас проще простого.
Ассемблер NASM
Почему NASM?
Ассемблеров существует великое множество, ну будем углубляться в историю каждого их них. Вы можете прочитать об этом тут. Плюсы NASM в том, что он кроссплатформенный и имеет приятный синтаксис(без усложнений, как в некоторых ассемблерах).
Какой бы ассемблер мы не использовали, и какие бы там ни были инструменты для упрощения написания кода(например очень крутая система макросов в FASM, у меня даже есть шуточный язык программирования, созданный на FASM макросах, но это уже совсем для ценителей тратить время впустую).

Так вот, какие бы там ни были инструменты для упрощения написания кода, нам важно лишь одно - какую инструкцию мы написали в исходном коде программы, такая и будет исполняться на нашем процессоре.
Ведь если скомпилировать одну и ту же программу на C
разными компиляторами(например gcc
и clang
), то ассемблерный код получится разный, хоть и делать он будет тоже самое с точки зрения пользователя. В языках ассемблера же, таких как NASM, такого нет. Что мы написали, то и будет, мы имеем полный контроль над тем, какие именно инструкции будут исполняться на процессоре и в каких регистрах что будет лежать.
Пишем "Hello, World!" на NASM
Для начала напишем на NASM эквивалент этой программы на Cи:
#include <stdlib.h>
int main() {
exit(137);
}
Всё, что делает эта программа - это завершает свою работу с кодом завершения 137, используя оболочку над системным вызовом sys_exit
:
zpnst@debian ~/D/p/h/a/helloworld> gcc return.c -o return
zpnst@debian ~/D/p/h/a/helloworld> ./return
zpnst@debian ~/D/p/h/a/helloworld [SIGKILL]> echo $status
137
Опять же, я использую оболочку fish
и там последний код возврата хранится в переменной окружения $status
, в bash
он хранится в $?
Напишем тоже самое на NASM:
mov rax, 60
mov rdi, 137
syscall
А теперь запустим:
zpnst@debian ~/D/p/h/a/helloworld> nasm -f elf64 -o return.o return.asm
zpnst@debian ~/D/p/h/a/helloworld> ld return.o -o return
zpnst@debian ~/D/p/h/a/helloworld> ./return
zpnst@debian ~/D/p/h/a/helloworld [SIGKILL]> echo $status
137
Что мы сделали?
Скомпилировали код программы в объектный файл готовый к линковке, указав формат
ELF
;Слинковали файл, создав исполняемый файл;
Запустили и проверили код возврата, всё работает.
Но почему это работает, что за число 60 и почему мы кладём данные именно в эти регистры?
Чтобы выполнить системный вызов, операционная система должна каким-то образом узнать о том, какой именно системный вызов мы хотим выполнить и какие у него будут аргументы. Так как в коде на языке ассемблера мы располагаем только регистрами, то логично предположить, что информацию о нужном на системном вызове можно сохранять в регистрах. Но в каких именно?
Для этого в Linux x86_64 системах существует соглашение по обработке системных вызовов. Оно описано тут.
Применяя инструкцию syscall
, она ищет ID системного вызова в регистре rax
, а аргументы, которые нам нужно передать в системный вызов в регистрах rdi
, rsi
, rdx
, r10
, r8
, r9
по порядку. То есть первый аргумент нужно положить в регистр rdi
, второй в rsi
и так до r9
.
Мы это и сделали:
mov rax, 60
mov rdi, 137
syscall
В rax
мы положили ID системного вызова sys_exit
, можете проверить по таблице.
Вот часть таблицы:
%rax |
System call |
%rdi |
%rsi |
%rdx |
%r10 |
%r8 |
%r9 |
---|---|---|---|---|---|---|---|
0 |
sys_read |
unsigned int fd |
char *buf |
size_t count |
|||
1 |
sys_write |
unsigned int fd |
const char *buf |
size_t count |
|||
... |
... |
... |
|||||
60 |
sys_exit |
int error_code |
У sys_exit
только один аргумент - код возврата. Кладём его в регистр rdi
и выполняем инструкцию syscall
, которая возьмёт значение из rax
, интерпретировав это как порядковый номер системного вызова. После этого обратиться к регистру rdi
, чтобы получить первый и единственный для sys_exit
аргумент - код возврата.
Просто? Мне кажется более чем, просто стандарт, просто договорённость, не более.
Программа на ассемблере выполняется строго сверху вниз. Поэтому если мы пишем нечто большее, чем 3 инструкции, лучше явно обозначать точку входа и секции(скоро узнаем почему). Это будет выглядеть так:
global _start
section .text
_start:
mov rax, 60
mov rdi, 137
syscall
Мы указали, что программу нужно начинать с метки _start
. Это просто alias(псевдоним) для адреса, по которому располагается инструкция mov rax, 60
. Так удобнее, в примере с приветствием мира мы поймём почему.
Также, указываем уже знакомую нам секцию .text
, так как в ней хранится исходный код программы.
Теперь мы полностью готовы к "Hello, World!" :)
Вот аналог на C:
#include <unistd.h>
#include <stdlib.h>
const char *string_hello = "Hello, World!\n";
int main() {
write(1, string_hello, 14);
exit(0);
}
Вывод:
zpnst@debian ~/D/p/h/a/helloworld> gcc hello.c -o hello
zpnst@debian ~/D/p/h/a/helloworld> ./hello
Hello, World!
Что тут происходит?
Используем оболочку над системным вызовом
sys_write
, передавая первым аргументом номер дескриптораSTDOUT
, вторым аргументом указатель на начало строки, третьем аргументом длину строку. Длина нужна для того, чтобы распечатать именно 14 байт от начала указателя, иначе кроме приветствия мира мы напечатаем ещё и произвольный мусор из памяти;Используем оболочку над системным вызовом
sys_exit
, передавая единственным аргументом код возврата 0(то есть успешно).
Ради эксперимента попробуем указать длину строки на 14, а 114:
write(1, string_hello, 14 + 100);
Вывод(получаем ещё и мусор из памяти, как я и говорил, просто какие-то числа в памяти, которые терминал тщетно пытает интерпретировать как ASCII символы):
zpnst@debian ~/D/p/h/a/helloworld> gcc hello.c -o hello
zpnst@debian ~/D/p/h/a/helloworld> ./hello
Hello, World!
(
���t<����L���D5����zRx
���"⏎
А вот долгожданный код на NASM:
global _start
section .data
string_hello: db "Hello, World!", 10
section .text
_start:
mov rax, 1
mov rdi, 1
mov rsi, string_hello
mov rdx, 14
syscall
mov rax, 60
mov rdi, 0
syscall
Что мы тут сделали?(на самом деле тоже самое, что и на C):
Для начала объявили уже знакомую нам секцию
.data
, в которой хранятся инициализированные переменные, чем и являетсяstring_hello
;Инициализируем
string_hello
строкой "Hello, World!"(NASM сам переведёт каждый символ строки с байтовое число ASCII). Мы могли написать и так:string_hello: db 72, 101, 108, 108, 111, 44, 32, 87, 111, 114, 108, 100, 33, 10
, где 72 - это ASCII символH
, 101 -E
, 108 -L
, 108 -L
, 111 -O
и так далее.10
на конце это\n
(символ перехода на следующую строку).db
- сокращение от define bytes;Объявляем секцию
.text
с исходным кодом программы и вызываемsys_write
(Посмотрите на таблицу выше).sys_write
имеет порядковый номер 1, кладём его вrax
. Кладём первый аргумент вrdi
- 1, это номер дескриптора STDOUT, второй аргумент кладём вrdi
- указатель на начало строки, третий аргумент кладём вrdx
- сколько байт от начала строки распечатать. Всё как на C! Если на этом моменте ничего не щёлкнуло, то советую перечитать этот параграф заново;Вызываем инструкцию
syscall
, которая первым делом обратившись к региструrax
поймёт, что мы хотимsys_write
, далее по регистрам соберёт аргументы для этого системного вызова;Завершаем программ с помощью
sys_exit
с кодом завершения 0(это мы уже разбирали).
Теперь компилируем, линкуем и запускаем:
zpnst@debian ~/D/p/h/a/helloworld> nasm -f elf64 -o hello.o hello.asm
zpnst@debian ~/D/p/h/a/helloworld> ld hello.o -o hello
zpnst@debian ~/D/p/h/a/helloworld> ./hello
Hello, World!
Давайте, ради интереса, попробуем убрать точку входа _start:

Что тут произошло?
Так как ассемблер в тупую исполняется сверху вниз, то начинаем мы со строки "Hello, World!\n". Понимаете в чём дело? программа падает, так как ассемблер интерпретирует байты строки как машинные инструкции, коими они не являются. С помощью утилиты objdump
посмотрим что за машинные инструкции получились из байтов строки "Hello, World!\n"(я их выделил, какая-то бессмыслица). Ниже же, хоть и в немного другом синтаксисе, который называется AT&T синтаксис(просто objdump
выдаёт в таком, принципиальной разницы нет), мы узнаём нашу программу!
Давайте добавим точку входа:

Уже лучше, программа работает, но objdump
по прежнему интерпретирует байты строки как набор команд. Для этого и нужно указать что в какой секции. Это нужно для явного указания секция для линковщика и для того, чтобы визуально понимать где что лежит в коде. Разумеется, для такой маленькой программы это не принципиально, но всё же:

Ура! Теперь не только всё работает, но и objdump
не чудит, так как мы явно указали где находятся данные, а где код программы.
Заключение
Спасибо, что дочитали до конца!
Надеюсь, что вы узнали для себя что-то новое.
Во всех этих темах нет никакой магии и не нужно быть гением, чтобы всё это понять.
Абсолютно всё в IT, если мы смотрим на внутреннее устройство, строится на формальностях и стандартах(договорённостях). Например стандарт исполняемого файла ELF
или договорённость вызова системных вызовов из кода на языке ассемблера.
Не стоит закапываться в эти формальности, но стоит понимать основные концепции, лежащие в основе того или иного механизма или инструмента, чтобы обладать хоть каким-то контекстом его использования.
До новых встреч на Хабре!