Как компьютер выполняет программы
И почему он напрямую не работает с кодом, который пишет программист. А ещё — что общего у Терминатора и Бендера из «Футурамы»
Мы уже знаем, что главное отличие компьютеров от более примитивных вычислительных машин — возможность выполнять длинные цепочки вычислений в соответствии с программой.
Больше всех трудится процессор. Чтобы он мог выполнить программу, последовательность составляющих её команд сначала нужно записать в оперативную память.
С точки зрения процессора любая программа представляет собой последовательность команд в памяти. Просто берёшь и делаешь, раз-раз-раз.
Команда — требование к процессору выполнить операцию с данными.
Вспомним парочку важных вещей из предыдущих статей:
- Процессор считывает и команды, и данные из памяти.
- Память разбита на ячейки одинакового размера.
- К каждой ячейке памяти можно обратиться по её адресу.
Процессор любого компьютера может выполнять много разных операций. Среди них есть арифметические (сложение, умножение, деление), логические (сравнение, побитовое инвертирование — замена каждого бита в двоичном числе на противоположный), операции пересылки, операции управления и другие.
Непосредственным выполнением этих операций занимается блок процессора, называемый арифметико-логическим устройством (АЛУ).
Совокупность всех команд, которые может выполнять процессор, называют системой команд. Это важная характеристика процессора и компьютера в целом — она определяет, какие программы на нём заработают сразу, а какие придётся адаптировать. На заре компьютерных технологий каждая ЭВМ имела свою систему команд. О переносе программ между разными машинами можно было только мечтать.
Люди быстро поняли, что на адаптацию одних и тех же алгоритмов под разные системы уходит слишком много ресурсов, и потому уже начиная с 1950-х наметился курс на создание компьютеров, которые были бы совместимы между собой по системе команд. Это оказалось мудрым решением, и сегодня дела с переносимостью программ обстоят намного лучше.
Как выглядит команда
Рассмотрим для примера некую абстрактную ЭВМ — которая, впрочем, во многом похожа на реальные компьютеры начала цифровой эры. В этой ЭВМ все команды имеют одинаковую структуру:
КОП — это код операции, которую нужно выполнить. Например, 1 — сложение, 2 — вычитание, 3 — умножение и так далее.
А₁ — это адрес ячейки памяти, из которой нужно взять первый операнд. Операндом называют блок данных, который обрабатывают командой. Не путать с Орландо Блумом и Воландом.
А₂ — адрес ячейки, из которой нужно взять второй операнд.
А₃ — адрес ячейки, в которую нужно поместить результат операции.
Такие команды называют трёхадресными. Проводя аналогию со школьной задачей по математике, можно сказать, что КОП, А₁ и А₂ относятся к блоку «Дано», а А₃ — это уже «Ответ».
Для наглядности будем вместо кода операции писать её обозначение из нескольких заглавных букв: сложение — СЛЖ, вычитание — ВЧТ, умножение — УМН...
Адреса ячеек памяти будем записывать с нулём (012 вместо 12), чтобы отличать их от чисел, хранящихся в ячейках. Для удобства все значения будем давать в десятичной системе счисления — естественно, не забывая, что в компьютере они представлены в двоичном виде.
Всё просто, не правда ли? Впрочем, кого мы обманываем...
Для начала заполним память нашей воображаемой ЭВМ такими числами:
Теперь выполним команду СЛЖ 011 012 013. В АЛУ процессора поступят значения из ячеек с номерами 011 и 012 — числа 3 и 5. АЛУ выполнит их сложение и получит 8. Далее число 8 будет записано в ячейку с номером 013. В итоге содержимое памяти изменится:
Некоторые из адресов А₁, А₂, А₃ могут совпадать друг с другом. Например, если вы хотите увеличить содержимое ячейки А₁ на величину числа, записанного в А₂, то в поле А₃ вам нужно повторить А₁ — чтобы результат суммирования оказался в ячейке с адресом А₁.
СЛЖ 011 012 011
После выполнения команды память будет выглядеть так:
Более того, оказалось, что в реальных программах эти адреса совпадали довольно часто — например, если нужно было просуммировать множество чисел подряд. Поэтому почти одновременно с трёхадресными системами команд появились и более простые двухадресные.
Двухадресная команда может указывать, что результат операции нужно записать вместо одного из операндов. Поскольку у нас пока нет отдельного поля для выбора первого или второго операнда, мы можем ввести новые коды операций. Например, операция СЛ1 будет записывать результат на место первого операнда, а СЛ2 — на место второго.
СЛ2 011 012
В этом примере результат сложения окажется на месте второго операнда:
Наконец, есть немало процессоров с одноадресной системой команд!
А вот тут может быть непонятненько. Как с помощью одноадресной команды заставить компьютер сложить два числа, если можно указать только одно? Хитрость в том, что в одноадресных системах выполнение одной арифметической операции обычно требует нескольких команд. А в составе процессора есть узел, который называют регистром-аккумулятором.
Регистр — небольшое запоминающее устройство (как правило, на одно число или одну команду), которое входит в состав процессора и работает на одной скорости с ним.
Регистр-аккумулятор — регистр процессора, в котором сохраняются результаты выполнения арифметических и логических команд. Наличие такого регистра позволяет использовать одноадресные команды и в конечном счёте сделать процессор более простым.
Чтобы реализовать операцию сложения, нам придётся придумать несколько новых команд:
- ЗГР — загрузить содержимое ячейки памяти в регистр-аккумулятор. Эту операцию можно заменить операцией СЛА, если хочется.
- СЛА — сложить содержимое ячейки памяти с содержимым регистра-аккумулятора и записать результат операции в него же.
- СХР — сохранить содержимое регистра-аккумулятора в ячейке памяти по указанному адресу.
Вот так теперь будет выглядеть наша простейшая программа:
ЗГР 011
СЛА 012
СХР 013
Результат будет таким же, как в самом первом примере. За счёт увеличения количества команд и, пожалуй, некоторого снижения читаемости программы мы получили возможность уменьшить длину команд и упростить схему процессора.
Все примеры, показанные выше, очень простые. Но ни один из них не придуман только для иллюстрации: существовали компьютеры, команды которых выглядели именно так. Трёхадресные команды из примера 1 (КОП | А₁ | А₂ | А₃) использовались в вычислительной машине БЭСМ (1952).
БЭСМ — классическая трёхадресная ЭВМ. Оператор мог по лампочкам на пульте видеть, какая команда выбрана из памяти и как она выполняется
Двухадресные команды из второго примера (с несколькими дополнительными битами) использовала, например, ЭВМ «Минск-22» (1965). Одноадресные команды, эквивалентные приведённым в примере 3, были в системе команд первой массовой отечественной ЭВМ «Урал-1» (1957). Одноадресным был и первый советский суперкомпьютер БЭСМ-6 (1967), и многие другие машины.
В современных процессорах есть несколько регистров-аккумуляторов и другие специализированные регистры, а команды могут иметь переменную адресность.
Количество поддерживаемых команд сильно различается от процессора к процессору. Например, процессор «Урала-1» имел систему всего из 29 команд. Очень популярный в своё время микропроцессор MOS 6502 (он стоял в первых компьютерах Apple) мог выполнять 56 команд.
А ещё 6502 стоял, к примеру, в Терминаторе и Бендере из «Футурамы».
Фотодоказательство: процессор 6502 в голове робота Бендера Родригеса
Современные процессоры с архитектурой x86-64 поддерживают свыше 800 разных команд. Одно только их описание в руководстве Intel занимает почти 3 тысячи страниц.
Понятно для человека — непонятно для компьютера
Когда мы записываем команду в виде СЛЖ 011 012 013, мы пользуемся буквами и десятичными цифрами. Но компьютеры, как мы знаем, на уровне аппаратуры работают только с двоичным кодом. Как будет выглядеть та же команда для процессора? Предположим, что на код операции у нас отводится 6 бит, а на каждый адрес — 11 бит (опять же, так было в БЭСМ). Заменяем, указывая в подстрочном индексе используемую систему счисления:
СЛЖ → 1₍₁₀₎ → 000001₍₂₎
Адрес 011₍₁₀₎ → 00000001011₍₂₎
Адрес 012₍₁₀₎ → 00000001100₍₂₎
Адрес 013₍₁₀₎ → 00000001101₍₂₎
То есть процессор видит нашу команду так:
000001000000010110000000110000000001101
Понятно? Для компьютера — абсолютно, а вот для нас — наверное, не очень. Да и, будем честны, в виде СЛЖ 011 012 013 команда тоже выглядит не вполне по-человечески. Люди всё-таки привыкли изъясняться на естественных языках, а компьютеру нужны однозначные инструкции, состоящие только из нулей и единиц. Здесь явно нужен «переводчик».
Поначалу в качестве таких переводчиков выступали сами программисты. Они составляли программы в удобном для себя виде, а затем переводили описание всех операций в машинный код.
Определение: машинный код — набор команд, специфичный для конкретной вычислительной машины, который интерпретируется непосредственно её процессором.
Далее технические сотрудники набивали этот код на перфокартах или перфоленте, и уже они поступали в устройство ввода ЭВМ.
Оператор вводит данные с перфокарт в компьютер IBM 704, 1957
Это занимало очень много времени и требовало высочайшей квалификации программистов. Ситуация изменилась с появлением трансляторов — или, как их весьма точно называли в те годы, «программирующих программ». Программа-транслятор автоматически формировала машинный код на основе описания, составленного на специальном формальном языке — языке программирования.
Один из первых в мире трансляторов создали в СССР в 1954 году, он назывался ПП-1.
В современном IT процесс «перевода» с языка программирования на язык команд процессора называют компиляцией, а соответствующие программы — компиляторами. Так что, когда мы говорим о некой программе, нужно уточнять, что именно мы имеем в виду — её исходный код или уже исполняемый файл:
- Исходный код — это изначальный текст на языке программирования, составленный человеком (или искусственным интеллектом).
- Исполняемый файл, он же «бинарник», — это скомпилированный код, который может быть выполнен компьютером.
Первый массовый язык программирования высокого уровня — Фортран, 1957 (сокращение английского Formula Translator). Изначально он был разработан для компьютера IBM 704. Продолжает развиваться до сегодняшнего дня и довольно широко используется в научных расчётах.
В качестве промежуточного звена между языком высокого уровня и машинным кодом можно выделить так называемый язык ассемблера. Он максимально близок к машинному коду того или иного процессора, но позволяет вместо нолей и единиц использовать понятные мнемоники для названий команд, регистров, а также обращаться к переменным по именам.
Примеры для СЛЖ 011 012 вполне могут считаться командами на языке ассемблера некого абстрактного процессора. Реальная ассемблерная команда может выглядеть, например, как ADD AX, BX. Процессор сложит содержимое регистра AX с содержимым регистра BX и запишет результат в AX.
Язык ассемблера незаменим, когда нужно максимально точно «объяснить» процессору, как именно он должен решать задачу, — например, если компилятор выдаёт неэффективный, избыточный код.
Создание языков высокого уровня значительно повысило скорость и удобство разработки программ и позволило привлечь в программирование много людей, не являющихся профессиональными математиками и инженерами. Со временем появление ещё более дружелюбных к новичкам языков открыло доступ в мир программирования даже для детей, едва научившихся читать и писать.
Ну а наша следующая остановочка — «Что происходит в компьютере, когда вы нажимаете на кнопку».


