Чем больше усилий ты прикладываешь, тем лучше это у тебя получается. Программирование не исключение, и чтобы с уверенностью сказать: "Я могу написать это" нужно много работать. Эта статья о том с какого языка начать путь в программировании и о том как понять принципы работы компьютера на низком уровне.
Что делает компьютер
Остановимся на абстракции, следующей за аппаратным уровнем - машинном коде, или его читабельной версии, ассемблере. Ассемблер - очень простой язык. Машина делает в точности то что вы ей указываете. Вы раскладываете происходящее на маленькие действия, которые в совокупности составляют сложную (комплексную) систему. Код выполняется по шагам (тактам), за один шаг исполняется одна машинная инструкция. Среди машинных инструкций есть те, которые работают с арифметикой, условиями, вводом-выводом и другими аспектами, но все их объединяет одно: типов данных не существует.
Типы данных - абстракция
На машинном уровне есть только биты. В том числе размер данных (в байтах) условен и определяется исполняемой инструкцией. Типы данных, модификаторы (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. Это разные части одного и того же регистра:
Для 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 могут занимать специфичные для локали символы, например кириллические.
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 - это языки высокого уровня. В них программисту не нужно заниматься ручным управлением памятью - для этого существует сборщик мусора, нет указателей на память - вместо этого используются объекты, отсутствуют либо затруднены способы что-то сломать.
Программирование не ограничивается одним выбранным языком. Все языки создавались не просто так и решают задачи в своей области лучше остальных. Выберите любой из популярных (чтобы были актуальные туториалы и используемые фреймворки), выберите несколько, у неофита нет опыта чтобы сравнить языки и выбрать лучший для изучения, а все советующие опираются на личный опыт и современные тренды.
Комментарии (22)
a9d
19.10.2023 10:09+1Есть книга Чарльз Петцольд "Код" там это гораздо понятней и проще описано. А если хочется посмотреть как работает, то есть серия статей на Wasm по написанию своего компилятора или взлом с OlyDb
Oangai
19.10.2023 10:09+1для понимания основ(/расширения кругозора/разрыва шаблонов) стоит познакомиться с архитектурой мотороловского MC14500, там всего шестнадцать инструкций. Вот к примеру у одного любителя есть экстракт, если не охота по даташитам искать: http://www.righto.com/2021/02/a-one-bit-processor-explained-reverse.html
Для спойлера: там например нет переходов назад, только вперед. Циклы тем не менее возможны, хотя очень весело
johnfound
19.10.2023 10:09+1Вижу ассемблер – плюсую. Потом прочитал, понравилось – захотелось еще раз лайкнуть, а нельзя-я-я!
vadimr
19.10.2023 10:09На машинном уровне есть только биты. В том числе размер данных (в байтах) условен и определяется исполняемой инструкцией. Типы данных, модификаторы (final, private, public) - только на уровне языка, в машинном коде их не существует, все "поля" (байты) объекта изменяемы вне зависимости от настроек языка.
Смотря какая машина.
Yuri0128
19.10.2023 10:09+3WORD = 2 байта
DWORD = 4 байта
А еще бывает и другое: word = 4 bytes; halfword=2 bytes; dword=8bytes....
Все зависит от конкретного ассемблера и конкретного проца/MCU.
SIISII
19.10.2023 10:09+2Причём у упоминаемого здесь ARM слово как раз 4 байта, а 2 байта -- это полуслово. Кстати, 64-разрядной является архитектура ARMv8-A, а вот все более ранние, а также ARMv8-M -- 32-разрядные.
А ещё биты могут нумероваться не справа налево, а наоборот (0 -- старший): так дело обстоит в ИБМовской Системе 360 и её потомках, включая современную z/Architecture.
В общем, косяков по мелочи найти можно немало.
shadrap
19.10.2023 10:09Спасибо! Действительно очень талантливо описано.) Вызывает ностальгические чувства .
push bp
mov pb,sp
Не забыть ни когда)
d00m911
19.10.2023 10:09+7"Код выполняется по шагам (тактам), за один шаг исполняется одна машинная инструкция".
Нет.
Oangai
19.10.2023 10:09да, абсолютно. Новичкам нужно знать что с этим вообще интересные варианты бывают: вот была например такая старая архитектура 8051, простая как валенок, но до двенадцати тактов на инструкцию им требовалось. А потом какой-то добрый человек догадался добавить туда конвеер, и так он там хорошо прижился, что сейчас даже у дешевых китайцев она инструкцию за 1-2 такта делает.
SIISII
19.10.2023 10:09+1Вообще, сколько тактов выполняется та или иная команда и насколько они могут совмещаться, зависит и от команды, и от конкретной реализации. Даже концептуальный порядок, предполагающий, что команды выполняются строго по одной и строго последовательно, работает лишь до определённого предела -- но это уже зависит не от реализации, а от архитектуры. Хелловорлд на асме, как правило, можно написать, не углубляясь в эти мелочи, а вот что-то более-менее сложное...
Maksim-Inozemtsev
19.10.2023 10:09+1Я впервые познакомился с программированием как раз через ассемблер. Классе в 10 по-моему. Наверное 1999 год. Сейчас мой язык JavaScript
mark_ablov
19.10.2023 10:09+1Многие вещи упрощены. Как уже писали выше про инструкцию на такт. Или к примеру про локальные переменные на стеке, про то что стек растет вниз, про упрощенные float'ы, и т. д.
Но для новичка я думаю нюансы и не нужны, и как обзорная статья вполне норм должна зайти.
checkpoint
19.10.2023 10:09+2Да, как же на ассемблере все проще и понятней. Объяснить школьнику обьекты, темплейты и прочие сущности С++ крайне тяжело. А вот байты и операции с ними гораздо проще и у человека появляется понимание что на самом деле представляет из себя машина.
SIISII
19.10.2023 10:09+1Скорей, концепции, используемые на уровне машинного языка и ассемблера, как его прямого отражения, являются куда более простыми и поэтому лёгкими для восприятия. Ну и, кроме того, Це++ -- один из наиболее сложных и запутанных языков программирования, с сильно неоднозначным для нетренированного взгляда синтаксисом и т.д. и т.п. (попробуй пойми без предварительного знакомства с языком, где массив указателей, а где -- указатель на массив; а вот в Паскале с подобными вещами никаких проблем из-за более ясного синтаксиса).
kulaginds
Было бы круто, если бы вы добавили в раздел "Объекты в голове программиста" информацию про выравнивание, когда ожидается, что структура в памяти будет занимать, например, 5 байт, а занимает 8.
SIISII
Да и про влияние выравнивания и его необходимость тоже. Скажем, на IA-32 (x86) базовый набор команд не требует какого-либо выравнивания операндов, но производительность от этого таки зависит. А вот операнды SSE уже должны быть выровнены в обязательном порядке, если правильно помню. А в ARMах допустимость невыровненных операндов зависит от разновидности архитектуры. Ну и т.д. и т.п.
lleo_aha
Ещё было бы круто не называть struct объектами. Все таки объект это грубо говоря struct в сочетании с кодом для его обработки
firehacker
Так себе логика.
struct с методами это объект?
class без методов это не объект?
В Си объекты могут быть представлены только struct-ами. Но не любой экземпляр struct-а правомочно назвать объектом с точки зрения программистской логики.
MessageBoxIndirect принимает структуру, но это не объект, а просто пачку параметров упаковали в структуру.
IoDeleteDevice в ядерном/драйверном коде тоже принимает структуру (DEVICE_OBJECT), и это явно объект.
lleo_aha
Ну так я о том же что не стоит ООПшные объекты сюда цеплять. Лучше структуру структурой называть, разве нет?