Я мыслю MOV EAX, 1
Чем больше усилий ты прикладываешь, тем лучше это у тебя получается. Программирование не исключение, и чтобы с уверенностью сказать: «Я могу написать это» нужно много работать. Эта статья о том с какого языка начать путь в программировании и о том как понять принципы работы компьютера на низком уровне.
Что делает компьютер
Остановимся на абстракции, следующей за аппаратным уровнем — машинном коде, или его читабельной версии, ассемблере. Ассемблер — очень простой язык. Машина делает в точности то что вы ей указываете. Вы раскладываете происходящее на маленькие действия, которые в совокупности составляют сложную (комплексную) систему. Код выполняется по шагам (тактам), за один шаг исполняется одна машинная инструкция. Среди машинных инструкций есть те, которые работают с арифметикой, условиями, вводом-выводом и другими аспектами, но всё их объединяет одно: типов данных не существует.
Типы данных — абстракция
На машинном уровне есть только биты. В том числе размер данных (в байтах) условен и определяется исполняемой инструкцией. Типы данных, модификаторы (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
Для 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.
Вычисляется значение по формуле:
Это даёт абсолютный диапазон чисел от 10–38 до 1038.
Для 64-битных чисел диапазон шире — от 10–308 до 10308.
Строки в голове программиста
Строки существуют только пока мы их таковыми считаем. На самом деле это или последовательность байт, заданной длины, или последовательность байт в конце которой стоит ноль (NUL). Каждой цифре соответствует выводимый на экран знак.
В таблице ASCII определены символы для чисел от 0 до 127 (7F16), от 128 до 255 могут занимать специфичные для локали символы, например кириллические.
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 ; Указатель на строку имени, ограниченную нулём в конце
}
В памяти это размещено так:
; начало памяти
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 гендер человека достаточно сделать:
MOV BX, byte ptr [RAX] ; загружаем байт по адресу в RAX
; В 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 — это языки высокого уровня. В них программисту не нужно заниматься ручным управлением памяти — для этого существует сборщик мусора, нет указателей на память — вместо этого используются объекты, отсутствуют либо затруднены способы что-то сломать.
Программирование не ограничивается одним выбранным языком. Все языки создавались не просто так и решают задачи в своей области лучше остальных. Выберите любой из популярных (чтобы были актуальные туториалы и используемые фреймворки), выберите несколько, у неофита нет опыта чтобы сравнить языки и выбрать лучший для изучения, а все советующие опираются на личный опыт и современные тренды.