Моим первым языком программирования был ActionScript. Написание кода для Macromedia Flash максимально далеко от голого железа, и эта специфика работы глубоко засела в моём сознании. В результате меня интересовали преимущественно высокоуровневые языки для веб-программирования. Низкоуровневые же казались непостижимыми. Со временем я постепенно из разных источников узнавал о них всё больше, но это моё убеждение оставалось прежним. Низкоуровневые языки пугают, и машинный код подтверждал это наглядно. Когда я обращался к Google с запросом «понятный машинный код», то результат выдачи чаще представлял нечто пугающее и отталкивающее, нежели полезное для обучения.

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

Машинный код совсем не страшен. Если вы можете обеспечить, чтобы документ JSON соответствовал схеме JSON, то без проблем сможете писать и машинный код.

Какой именно машинный код?

Одна из проблем машинного кода в том, что для него нет единого стандарта. Существует множество «наборов инструкций» для разных процессоров. Большинство современных ПК используют инструкции x86-64, но в самых последних моделях Mac, Raspberry Pi и мобильных устройств применяется архитектура ARM. Существуют и другие архитектуры, особенно, если заглянуть в прошлое.

Но в этой статье я не планирую углубляться в какой-то конкретный набор инструкций. Я просто объясню типичные принципы работы машинного кода, чтобы развеять ваш страх перед ним. Начнём мы с рассмотрения примеров 64-битного кода ARM (обозначается как aarch64) и уже после поговорим об x86-64.

Машинный код. Основы

Для понимания основ машинного кода вам нужно уяснить три концепции:

1.   Инструкции.

2.   Регистры.

3.   Память.

Инструкции говорят сами за себя. Это тот код, который будет выполняться. В машинном коде — это просто числа. По факту в AArch64 каждая из них является 32-битным числом. В инструкции кодируются операции, которые должна выполнить машина (сложение, перемещение, вычитание, переход и так далее) и передаются аргументы, отражающие обрабатываемые данные. Эти аргументы могут содержать константы (например, число 2; и часто называются «immediate» [непосредственные данные]) либо указывать на регистр или адрес памяти. Пока что будем рассматривать регистр как переменную, а память — как список.

Инструкции ARM

Вот пример инструкции add (immediate).

31

30

29

28

27

26

25

24

23

22

21

20

19

18

17

16

15

14

13

12

11

10

9

8

7

6

5

4

3

2

1

0

sf

0

0

1

0

0

0

1

0

sh

imm12

Rn

Rd

Сперва всё это может казаться сложным, но когда вы познакомитесь с достаточным количеством таких таблиц, то начнёте легко их понимать. Каждый столбец здесь представляет один бит 32-битного числа. Если его значение 0 или 1, значит, бит заполнен. Если у него есть метка, значит, это переменная, которую нужно заполнить. Значение sf указывает, какие регистры мы будем использовать — 32- или 64-битные. sh означает «сдвиг». Далее идёт imm12, означающее 12-битное непосредственное значение (константу). Таким образом, если мы хотим прибавить к чему-то 42, то подставляем вместо imm12 значение 000000101010 и устанавливаем sh на 0 (то есть число мы не сдвигаем). Но что, если мы хотим выразить число больше 12 бит?

Инструкция add не позволяет выражать столь большие числа. Однако установка sh на 1 приведёт к сдвигу нашего числа на 12 бит. То есть мы, например, можем выразить 172032172032, оставив 42 без изменений и установив sh на 1. Это продуманная техника для кодирования больших чисел в небольшом пространстве. Переменные, которые начинаются с R — это регистры. В данном случае Rn содержит прибавляемый нами аргумент, а Rd является целевым регистром.

В итоге показанную выше инструкцию можно представить так:

struct Add {

 is_sixty_four_bit: boolean,

 shift: boolean,

 immediate: u12,

 n: Register,

 destination: Register,

}

По сути, add — это просто структура данных, в которой мы расставляем всё по своим местам.

Регистры

Регистры — это небольшие области для хранения значений. В каждом наборе инструкций используется своё количество регистров. Это могут быть разные регистры разного размера, следующие тем или иным соглашениям об именовании. В AArch64 31 64-битных регистра общего назначения, пронумерованные от X0 до X30. Предположим, нам нужно прибавить 42 к содержимому регистра X0 и сохранить результат в X1. Для этого мы используем следующее двоичное число:

sf

Операция

sh

imm12

Rn

Rd

1

0

0

1

0

0

0

1

0

0

0

0

0

0

0

0

1

0

1

0

1

0

0

0

0

0

0

0

0

0

0

1

Чтобы закодировать регистры в нашу инструкцию, мы просто используем их номер. Тогда регистр X0 будет 00000, а регистр X1810010. Регистры — это просто области, где мы можем хранить значения. Но в соответствии с соглашениями они могут использоваться и для других задач. Имеются в виду соглашения о вызовах, которые определяют кодирование функций в более высокоуровневых языках вроде C.

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

add x1, x0, #0x2a 

Для пущей крутизны люди обычно прописывают числа в ассемблере в виде шестнадцатеричных значений. Выше написано просто число 42. Здесь мы видим, что ассемблер скрывает некоторые детали кодирования, которое мы только что произвели. Мы не думаем об sf, sh, о размере нашего числа, что используются регистры Rn и Rd. Вместо этого сначала указывается место для записи результата, а затем идут аргументы. Из-за отсутствия этих деталей одна инструкция ассемблера add в зависимости от своих аргументов фактически может отображаться во множество разных инструкций машинного кода.

Память

Последним элементом, который нам нужно понимать, является память. Чтобы разобраться в принципе её работы, мы рассмотрим инструкцию, которая сохраняет в ней данные. Называется эта инструкция STR (от англ. store, сохранять).

31

30

29

28

27

26

25

24

23

22

21

20

19

18

17

16

15

14

13

12

11

10

9

8

7

6

5

4

3

2

1

0

1

x

1

1

1

0

0

1

0

0

imm12

Rn

Rt

С помощью STR мы сохраним некоторое значение (RT) по адресу (RN) с некоторым смещением (imm12). Если рассматривать память как большой массив, то эта инструкция подобна записи в него, array[offset] = value. Здесь x аналогичен sf из примера выше. Он определяет, используем ли мы 64-битные значения. Для внесения конкретности предположим, что у нас есть значение в X2, адрес памяти в X1, и мы хотим сохранить значение со смещением 2 байта от этого адреса. Тогда у нас получится такая структура:

x

операция

imm12

Rn

Rt

1

1

1

1

1

0

0

1

0

0

0

0

0

0

0

0

0

0

0

0

1

0

0

0

0

0

1

0

0

0

1

0

Поскольку прописывать всё это муторно, зачастую просто используется нотация ассемблера. Мы сохраняем значение из X2 по адресу из X1 + 2 байта.

str x2, [x1, #0x2]

X86-64

Кодировка x86 несколько иная, хотя состоит из почти тех же частей. Здесь мы всё также работаем с инструкциями, регистрами и памятью. А вот некоторые имена отличаются. Вместо последовательного именования от 0 до 30 мы используем исторический багаж 64-битных регистров: rax, rbx, rcx, rdx, rsi, rdi, rbp, rsp, r8-r15. Самое же большое отличие в том, что длина набора инструкций для x86 не фиксирована. Вместо этого инструкции собираются из частей, которым присваиваются разные имена. При виде кодировки инструкции вы понимаете, как эти части объединить.

REX

7

6

5

4

3

2

1

0

0

1

0

0

W

R

X

B

Первая часть называется REX. Это префикс, который можно использовать для реализации 64-битных операций. Не уверен, есть ли какая-то официальная расшифровка этого названия, но я думаю, что оно означает «Register Extension Prefix».

К сожалению, поскольку REX — это префикс, смысл он обретает, только когда мы видим, что идёт за ним. REX добавляется для обратной совместимости. Символ W в кодировке REX позволяет указать, используем ли мы для определённых операций 64 бита. R и B «расширяют» доступный набор регистров в конкретных операциях. Иными словами, они позволяют использовать их большее число (речь идёт о регистрах r8-r15, именование которых отличается от более старых). Они нам нужны, поскольку до 64-битного расширения архитектуры x86 было доступно меньше регистров, и инструкции имели всего по 3 бита на каждый. При 16 регистрах нам нужен дополнительный бит. (X относится к структуре SIB, которую мы здесь разбирать не будем).

ModR/M

7

6

5

4

3

2

1

0

mod

reg

Rm

Следующая часть — это байт ModR/M. ModR/M следует традиции использования очень коротких и непонятных имён. Здесь mod означает Mode и сообщает, действует ли rm в качестве регистра, или же является указателем на область памяти. Если mod == 11, тогда rm используется как регистр. В противном случае он работает как указатель. Что касается reg, то это просто регистр.

OpCode

С OpCode всё просто. Это число, которое может иметь длину от 1 до 3 байт.

А теперь всё вместе

Есть и другие части, но здесь мы их затрагивать не будем. Мы вполне сможем собрать инструкцию, используя те, которые рассмотрели. Предположим, нам нужно перенести 32-битное знаковое непосредственное значение в 64-битный регистр. Обратившись к таблице кодировок инструкций, мы получим следующее:

REX.W + C7 /0 id

Теперь можно собрать все части в инструкцию. Начнём с REX.W. Эта запись просто означает, что REX с W установлена на 1. Затем идёт B8. Это просто число в шестнадцатеричной записи. /0 — это ещё одно сокращение для использования ModR/M, но с установкой reg на 0. Наконец, id означает «immediate doubleword» (непосредственное двойное слово). Иными словами, это постоянное число, имеющее длину 32 бита. Теперь, имея всё это, мы можем написать свою инструкцию. Давайте перенесём число 42 в регистр rbx.

Индекс байта

Биты

Описание

Байт 0

55—48

01001000 REX.W = 1

Байт 1

47—40

11000111 Opcode C7

Байт 2

39—32

11000011 ModR/M: reg=000, r/m=011 (RBX)

Байт 3

31—24

00101010 42

Байт 4

23—16

00000000 остальная часть 42

Байт 5

15—8

00000000 ...

Байт 6

7—0

00000000 ...

Почему RBX равен 011? Так гласит таблица. Да, я говорил, что у x86 есть свои странности.

Остальное

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

Мы здесь не затрагивали стек, но если вкратце, то это область памяти, в которую вы производите запись для отслеживания порядка выполнения. Мы также не говорили о переходах или о кодировании в ARM более крупных непосредственных значений. Но вы усвоили основы. И теперь вам будет проще начать использовать Compiler Explorer для изучения низкоуровневых процессов.

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

Постепенно изучая низкоуровневые детали разработки, я раз за разом убеждаюсь, что всё это вовсе не сложно, просто плохо задокументировано и объяснено.

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


  1. NeriaLab
    15.06.2025 09:45

    Здравствуйте. Недавно, я сам занялся вплотную изучением языка Assembler. И у Вас, честно, Ужжжжасная статья, хотя я рад, действительно рад что Вы ее написали. Но почему я сказал что статья Ужжжжасная и с большой буквы. Я как понял, Вы не учили людей, от слова совсем.

    1. Начните с простого, объясните регистры начального уровня (AH, AL) и постепенно переходите к регистрам высокого уровня (EAX, RAX). Покажите схематичную разницу между ними и т.д.

    2. Затем дайте список с определениями:

      EAX (как пример) - это аккумулятор, применяется тогда то и тогда то и при таких условиях

      EBX - ...здесь описание и т.д.

    3. Расскажите о FLAGS. Как они работают

      CF - (Carry Flag) флаг переноса... устанавливается при таких то условиях

      ZF - ... и т.д.

    4. Дайте простейшие примеры кода. Как и при каких условиях будут меняться флаги

      Как пример MOV AH, 1 будут ли изменяться флаги и какие или не будут

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

    P.S. И не прыгайте вокруг архитектуры. Если Вы взялись описывать ARM то дальше объясняйте про то, как работает ARM и не смешивайте его с x86. Мои все примеры основаны на x86


    1. LuckyStarr
      15.06.2025 09:45

      Это - перевод. На что немножко намекает ник и плашка "Перевод".


      1. NeriaLab
        15.06.2025 09:45

        И что? Так надо делать нормальную и полную статью про Ассемблер. Я тоже могу много чего перевести, но этого не делаю. Но в данном случае разговор идет об ассемблере. Дайте полную информацию. Если надо сделать авторские дополнения, то сделайте. Иначе это что получается? Хайп ради хайпа - "смотрите, я какой крутой и опубликовал статью про ассемблер". И не важно, что новички ничего не поймут, испугаются и уйдут там, например на Питон - странный подход. Про ассемблер много чего можно рассказать и показать. Действительно научить людей. Сейчас, с учетом спроса на ретро-игры, люди начали массово изучать ассемблер, Watcom C и т.д., чтобы "возродить" любимую игру в новом формате или понять, как люди делали игры и софт в целом в конце 80/начале 90ых - без всяких крутых IDE, автокомплитов, интеллисенсов и прочее


    1. MAK74
      15.06.2025 09:45

      IMHO, это объём информации для толстой книги, а не для статьи на Хабре


      1. NeriaLab
        15.06.2025 09:45

        Не соглашусь. Я же не зря написал, что ассемблер простой и понятный. В самом начале, можно объяснить архитектуру, что такое регистры, сегменты, флаги, память и как они между собой взаимодействуют. На другом этапе можно рассказать об основных операциях: помещения значения в регистр/стек (пример: mov; push, pop), арифметических операциях (пример: add; sub), условных/безусловных переходах (пример: jmp; jnz; jbe), функциях (пример: call; ret), системные вызовы и прерывания (пример: int), по какому принципу строится программа на ассемблере. И на последнем этапе, можно показать несколько примеров и не только "Hello World". Легко можно уложится в 2-3 публикации, чтобы заинтересовать читателей. Если публикации "взлетят", то можно расширять сложность работы с ассемблером. Не надо выдавать абсолютно всё, т.к. даже большая часть команд никогда не используется в реальных условиях


    1. muhachev
      15.06.2025 09:45

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


  1. vadimr
    15.06.2025 09:45

    Согласен, ужасная статья. Если уж автор замахнулся на вопрос с разными архитектурами, то надо было бы начать с понятия одноадресных, двухадресных и т.д. архитектур. И вообще что такое система команд. А то получилось, что между x86 и ARM

    Самое же большое отличие в том, что длина набора инструкций для x86 не фиксирована