Pull to refresh

Я мыслю MOV EAX, 1

Level of difficultyEasy
Reading time7 min
Views10K

Чем больше усилий ты прикладываешь, тем лучше это у тебя получается. Программирование не исключение, и чтобы с уверенностью сказать: "Я могу написать это" нужно много работать. Эта статья о том с какого языка начать путь в программировании и о том как понять принципы работы компьютера на низком уровне.

Что делает компьютер

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

Типы данных - абстракция

На машинном уровне есть только биты. В том числе размер данных (в байтах) условен и определяется исполняемой инструкцией. Типы данных, модификаторы (final, private, public) - только на уровне языка, в машинном коде их не существует, все "поля" (байты) объекта изменяемы вне зависимости от настроек языка.

В низкоуровневой разработке применяются следующие обозначения размеров данных:

  • BYTE = 1 байт

  • WORD = 2 байта

  • DWORD = 4 байта

  • QWORD = 8 байт

У процессоров есть регистры, ячейки памяти с максимально быстрым доступом (менее одного такта). В современных процессорах x86 и arm их 32 по 64 бит каждый.

Регистры могут использоваться в качестве аргументов операций, например сложение значения регистров будет записано так для x86 архитектуры:

ADD AX, BX  ; в AX запишется сумма AX и BX
MOV CX, AX  ; в CX скопируется значение из AX

Разрядность регистра - максимальное число бит, которое он может хранить. Так как не всегда нужна полная разрядность в 32 или 64 бита, то можно использовать часть регистра.

AL - младшие 8 бит регистра AX, AH - старшие. Регистр AX 16-ти битный, чего не хватает для современных задач, поэтому в современных процессорах его расширили до 32 бит - EAX, а затем до 64 - RAX. Это разные части одного и того же регистра:

Состав регистра RAX
Состав регистра RAX

Для arm архитектуры - это регистры X (64 бита) и их младшие части W (32 бита).

Целые числа в голове программиста

Чтобы переводить числа из двоичной системы счисления в десятичную, достаточно помнить ряд чисел: 1, 2, 4, 8, 16 - и так далее, каждое последующее вдвое больше предыдущего. Чтобы перевести 10-чное число в двоичное нужно разбить его на сумму этих чисел, начиная с большего:

10 - это 8 + 2, а 7 - это 4 + 2 + 1

В двоичной записи справа стоит бит отвечающий за единицу, левее него отвечающий за двойку и далее по ряду. Чтобы перевести число из двоичной в десятичную - достаточно сложить те числа из ряда которые соответствуют тем номерам бит, где стоит единица:

10102 - это 1010, 11002 - это 8 + 4 = 1210

Для чисел, которые мы считаем знаковыми вычитается самое старшее число из ряда, если самый старший бит числа установлен в 1:

01112 = 7 (бит 8-ми не установлен), 10002 = -8 (бит установлен), 11112 = -8 + 4 + 2 + 1 = -1

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

Двоичные числа удобнее представлять в 16-ти разрядной форме, помимо цифр от 0 до 9 добавляются буквы, A = 1010, B, C, D, E, F = 1510. Удобство в том, что перевод из двоичной и обратно тривиален: один знак в 16-ричной = 4 знака в 2-ичной, F16 = 11112 = 1510, соответственно число в 4 раза короче и помнить его проще.

Дробные числа в голове программиста

С дробными числами всё сложнее. Их бесконечное количество, а возможных комбинаций из 32 бит 4.2 млрд. Компромисс - использовать числа с плавающей запятой, в которых выделен один бит под знак (поэтому для дробных чисел возможны ноль и минус ноль), несколько бит под значащее число (мантиссу) и несколько бит для экспоненты.

Для 32 бит на мантиссу приходится 23, на экспоненту - 8.

Вычисляется значение по формуле:

sign\cdot2^{exponent-127}\cdot mantissa

Это даёт абсолютный диапазон чисел от 10-38 до 1038.

Для 64-битных чисел диапазон шире - от 10-308 до 10308.

Строки в голове программиста

Строки существуют только пока мы их таковыми считаем. На самом деле это или последовательность байт, заданной длины, или последовательность байт в конце которой стоит ноль (NUL). Каждой цифре соответствует выводимый на экран знак.

В таблице ASCII определены символы для чисел от 0 до 127 (7F16), от 128 до 255 могут занимать специфичные для локали символы, например кириллические.

ASCII таблица
ASCII таблица

4816, 4116, 4216, 5216, 016 = HABR ; В конце строка ограничена нулём

Существуют другие кодировки, например UTF-16, в которой каждый символ кодируется двумя байтами, или UTF-8, ставшая негласным стандартом, с переменной длиной байт на символ.

Указатели в голове программиста

Этим словом пугают новичков на пути изучения С++, на самом деле зря. Указатель есть номер ячейки в памяти. Мы вольны поместить в регистр AX значение 5216, а затем загрузить в регистр BX значение по адресу в AX:

MOV AX, 52h     ; номер ячейки в оперативной памяти, h - число в 16-ти ричной системе
MOV BX, [AX]    ; AX - указатель, адрес в памяти

При работе с указателями память рассматривается как один огромный массив байт. Нумерация начинается с нуля.

Объекты в голове программиста

Многие слышали про Объектно-Ориентированное Программирование, как же объекты представлены на машинном уровне? Точно так же. В байтах.

Чтобы хранить пол, возраст и имя человека:

; размер структуры = 1 + 1 + 8 = 10 байт
struct Human {
  DB gender     ; 1 байт - пол. 0 - не указан, 1 - мужской, 2 - женский
  DB age        ; Возраст. Число будем считать беззнаковым, соответственно
                ; его диапазон от 0 до 255
  DQ namePtr    ; Указатель на строку имени, ограниченную нулём в конце
}
Выравнивание объектов

Современные процессоры и память читают данные блоками по 2-4-8 байт. Если 4-байтная переменная расположена по адресу 3, то потребуется два чтения из памяти. Если она выравнена по 4 (0, 4, 8, 12 etc.), то потребуется 1 чтение.

Поэтому, например в C++, структура Human займёт 16 байт (для выравнивания namePtr по 8), байты со 2 до 7 задействованы не будут. По этой причине в памяти bool может занимать от 1 до 4 байт.

Java выравнивает объекты по 8 байт, поэтому 4-байтовые ссылки позволяют адресовать 32Гб оперативной памяти. В объектах поля сортируются по размеру (от большего к меньшему) для оптимизации размера объекта при выравнивании.

В ознакомительных целях в примере выравнивание не использовано.

В памяти это размещено так:

; начало памяти
DQ 0   ; отступим 8 байт от начала (для примера)

; здесь начинается структура Human, по адресу 8
DB 1   ; пол - мужской
DB 23h  ; возраст 35 лет
DQ 20h  ; указатель на строку имени
; структура заканчивается 

; 2 пустых байта, в которые ничего не записано, 
; их могло бы не быть, но с ними интереснее
DB 0
DB 0

; адрес = 8 + 10 + 2*1 = 20 байт
DB 4dh, 61h, 78h, 0  ; имя (Max)

В таком случае, чтобы поместить в RAX (64-битный регистр) адрес структуры человека:

MOV RAX, 8  ; мы отступали 8 байт от начала

Чтобы поместить в регистр BX гендер человека достаточно сделать:

MOVZX BX, byte ptr [RAX]  ; загружаем байт по адресу в BX
; В BX лежит 1

Используется byte ptr чтобы поместить в регистр 1 байт, а не два.

Чтобы в CL загрузить возраст:

MOV CL, byte ptr [RAX + 1]  ; следующий в структуре байт за гендером - возраст
; В CL лежит 35

Поместим в RDI указатель на имя:

MOV RDI, [RAX + 2]   ; В RDI находится 20, адрес начала строки в памяти

Напишем функцию, которая принимает указатель на структуру Human в RAX, увеличивает его возраст на 1 и поздравляет человека с днём рождения:

jmp main                       ; безусловный переход на main.
                               ; выполнение кода продолжится с метки main

happy_birthday:   ; метка
  MOV BL, byte ptr [RAX + 1]   ; поместим в BL возраст (1 байт)
  INC BL                       ; увеличим BL на 1
  MOV byte ptr [RAX + 1], BL   ; запишем новый возраст (1 байт)

  MOV RAX, [RAX + 2]           ; поместим в RAX указатель на имя
  CALL print_hb                ; вызовем функцию print_hb, которая обратится
                               ; к человеку по имени и поздравит с ДР

  RET                          ; вернёмся из функции

main:                          ; основной код
  MOV RAX, 8                   ; по адресу 8 записана структура human
  CALL happy_birthday          ; вызываем функцию happy_birthday
  
  ; в RAX уже не 8, а другое число

  HLT                          ; останавливаем выполнение процесса

Вывод текста зависит от реализации, поэтому реализацию функции print_hb не рассматриваем.

Стек в голове программиста

Предположим, что в предыдущем примере мы захотим после функции print_hb вызвать функцию update_photo, нам снова нужен адрес структуры, но RAX может быть изменён функцией print_hb. Поэтому нужно где-то сохранить адрес структуры Human. Сделать это можно в стеке.

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

Локальные переменные функции хранятся в стеке. Это позволяет писать рекурсивные алгоритмы (в которых функции вызывают сами себя). Сюда же попадают аргументы функции.

В зависимости от соглашения, при вызове подпрограммы (call), адрес следующей за call инструкции помещается либо в стек, либо в специальный регистр.

Какой язык выбрать новичку?

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

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

Ассемблер, С/С++, Rust - эти языки позволяют понять что делает машина, позволяют "выстрелить в ногу" - написать код, поведение которого зависит от случая к случаю (например, взять переменную по случайному адресу), или, например, прочитать байты float как байты целого числа. Последнее имеет смысл при сериализации, и для этого в языках высокого уровня существуют специальные инструменты.

Python, JavaScript, Java, PHP - это языки высокого уровня. В них программисту не нужно заниматься ручным управлением памятью - для этого существует сборщик мусора, нет указателей на память - вместо этого используются объекты, отсутствуют либо затруднены способы что-то сломать.

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

Only registered users can participate in poll. Log in, please.
С каких языков вы начинали?
45.99% Низкоуровневые86
54.01% Высокоуровневые101
187 users voted. 6 users abstained.
Tags:
Hubs:
Total votes 23: ↑22 and ↓1+24
Comments24

Articles