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

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

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

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

На машинном уровне есть только биты. В том числе размер данных (в байтах) условен и определяется исполняемой инструкцией. Типы данных, модификаторы (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 - это языки высокого уровня. В них программисту не нужно заниматься ручным управлением памятью - для этого существует сборщик мусора, нет указателей на память - вместо этого используются объекты, отсутствуют либо затруднены способы что-то сломать.

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

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


  1. kulaginds
    19.10.2023 10:09
    +3

    Было бы круто, если бы вы добавили в раздел "Объекты в голове программиста" информацию про выравнивание, когда ожидается, что структура в памяти будет занимать, например, 5 байт, а занимает 8.


    1. SIISII
      19.10.2023 10:09

      Да и про влияние выравнивания и его необходимость тоже. Скажем, на IA-32 (x86) базовый набор команд не требует какого-либо выравнивания операндов, но производительность от этого таки зависит. А вот операнды SSE уже должны быть выровнены в обязательном порядке, если правильно помню. А в ARMах допустимость невыровненных операндов зависит от разновидности архитектуры. Ну и т.д. и т.п.


    1. lleo_aha
      19.10.2023 10:09

      Ещё было бы круто не называть struct объектами. Все таки объект это грубо говоря struct в сочетании с кодом для его обработки


      1. firehacker
        19.10.2023 10:09

        Так себе логика.

        struct с методами это объект?

        class без методов это не объект?

        В Си объекты могут быть представлены только struct-ами. Но не любой экземпляр struct-а правомочно назвать объектом с точки зрения программистской логики.

        MessageBoxIndirect принимает структуру, но это не объект, а просто пачку параметров упаковали в структуру.

        IoDeleteDevice в ядерном/драйверном коде тоже принимает структуру (DEVICE_OBJECT), и это явно объект.


        1. lleo_aha
          19.10.2023 10:09
          +1

          Ну так я о том же что не стоит ООПшные объекты сюда цеплять. Лучше структуру структурой называть, разве нет?


  1. a9d
    19.10.2023 10:09
    +1

    Есть книга Чарльз Петцольд "Код" там это гораздо понятней и проще описано. А если хочется посмотреть как работает, то есть серия статей на Wasm по написанию своего компилятора или взлом с OlyDb


  1. Oangai
    19.10.2023 10:09
    +1

    для понимания основ(/расширения кругозора/разрыва шаблонов) стоит познакомиться с архитектурой мотороловского MC14500, там всего шестнадцать инструкций. Вот к примеру у одного любителя есть экстракт, если не охота по даташитам искать: http://www.righto.com/2021/02/a-one-bit-processor-explained-reverse.html

    Для спойлера: там например нет переходов назад, только вперед. Циклы тем не менее возможны, хотя очень весело


  1. johnfound
    19.10.2023 10:09
    +1

    Вижу ассемблер – плюсую. Потом прочитал, понравилось – захотелось еще раз лайкнуть, а нельзя-я-я!


  1. vadimr
    19.10.2023 10:09

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

    Смотря какая машина.


  1. psynix
    19.10.2023 10:09

    Ух. Спасибо. Ностальжи. TASM forever!


  1. Yuri0128
    19.10.2023 10:09
    +3

    • WORD = 2 байта

    • DWORD = 4 байта

    А еще бывает и другое: word = 4 bytes; halfword=2 bytes; dword=8bytes....

    Все зависит от конкретного ассемблера и конкретного проца/MCU.


    1. SIISII
      19.10.2023 10:09
      +2

      Причём у упоминаемого здесь ARM слово как раз 4 байта, а 2 байта -- это полуслово. Кстати, 64-разрядной является архитектура ARMv8-A, а вот все более ранние, а также ARMv8-M -- 32-разрядные.

      А ещё биты могут нумероваться не справа налево, а наоборот (0 -- старший): так дело обстоит в ИБМовской Системе 360 и её потомках, включая современную z/Architecture.

      В общем, косяков по мелочи найти можно немало.


  1. shadrap
    19.10.2023 10:09

    Спасибо! Действительно очень талантливо описано.) Вызывает ностальгические чувства .

    push bp

    mov pb,sp

    Не забыть ни когда)


  1. d00m911
    19.10.2023 10:09
    +7

    "Код выполняется по шагам (тактам), за один шаг исполняется одна машинная инструкция".

    Нет.


    1. Oangai
      19.10.2023 10:09

      да, абсолютно. Новичкам нужно знать что с этим вообще интересные варианты бывают: вот была например такая старая архитектура 8051, простая как валенок, но до двенадцати тактов на инструкцию им требовалось. А потом какой-то добрый человек догадался добавить туда конвеер, и так он там хорошо прижился, что сейчас даже у дешевых китайцев она инструкцию за 1-2 такта делает.


    1. SIISII
      19.10.2023 10:09
      +1

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


  1. Maksim-Inozemtsev
    19.10.2023 10:09
    +1

    Я впервые познакомился с программированием как раз через ассемблер. Классе в 10 по-моему. Наверное 1999 год. Сейчас мой язык JavaScript


  1. mark_ablov
    19.10.2023 10:09
    +1

    Многие вещи упрощены. Как уже писали выше про инструкцию на такт. Или к примеру про локальные переменные на стеке, про то что стек растет вниз, про упрощенные float'ы, и т. д.

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


  1. checkpoint
    19.10.2023 10:09
    +2

    Да, как же на ассемблере все проще и понятней. Объяснить школьнику обьекты, темплейты и прочие сущности С++ крайне тяжело. А вот байты и операции с ними гораздо проще и у человека появляется понимание что на самом деле представляет из себя машина.


    1. SIISII
      19.10.2023 10:09
      +1

      Скорей, концепции, используемые на уровне машинного языка и ассемблера, как его прямого отражения, являются куда более простыми и поэтому лёгкими для восприятия. Ну и, кроме того, Це++ -- один из наиболее сложных и запутанных языков программирования, с сильно неоднозначным для нетренированного взгляда синтаксисом и т.д. и т.п. (попробуй пойми без предварительного знакомства с языком, где массив указателей, а где -- указатель на массив; а вот в Паскале с подобными вещами никаких проблем из-за более ясного синтаксиса).


  1. YakovSava
    19.10.2023 10:09
    +2

    А память программст очищает так:

    xor eax, eax


  1. HADGEHOGs
    19.10.2023 10:09
    +1

    Инфоцыганщина.