Вводный экскурс в компьютерные системы, часть 1
Мотивация
Это серия статей-рассказов, где я буду рассказывать о компьютерных системах. Мы затронем разные уровни компьютерных систем - от процессора и регистров до языка Assembly. Прочтение статей не заменит курс или целый семестр по этой теме. Также не исключаю, что статьи будут содержать технические ошибки, потому что я не являюсь экспертом в компьютерных системах и я также как вы обучаюсь в данном направлении. Если увидите ошибки, то укажите их мне по контактным данным. Ну что же, время становится "базированным" программистом!
Информация = Биты + Контекст
Повторим урок информатики: любой исходник состоит из последовательности битов. Группу из 8 последовательных битов называют байтами, и в компьютерных системах в основном оперируют этой единицей измерения. Но открыв файл, мы увидим код, состоящий из цифр и символов. Важно понимать, что синтаксис кода видит человек, а для компонентов системы это все еще нули и единицы. Каждый символ имеет численное представление, которое равно размеру байта. Например, букве "h" в представлении стандарта ASCII соответствует число 104, в двоичном формате - 1101000.
В этом разделе лежит фундаментальная идея: вся информация, которая хранится на компьютере - дисковые файлы, программы в оперативной памяти, данные в сети - представляет из себя последовательность битов. Единственное, что помогает отличить их - контекст.
Верхнеуровневый обзор компиляции программы на языке С
Для обзора возьмем обычную программу, которая выводит в стандартный поток вывода текст "Hello, world!".
#include <stdio.h>
int main() {
printf("Hello, world!");
return 0;
}Теперь рассмотрим как происходит перевод из высокоуровневого языка C в низкоуровневые инструкции машинного языка. В данном контексте я буду называть язык C высокоуровневым, потому что тут он представляет из себя абстракцию на уровне операционной системы. Эти инструкции потом компонуются в исполняемые модули и хранятся на диске как бинарный файл.
Компиляция с помощью компилятора GCC производится командой:
gcc -o hello hello.cТут мы указываем название исполняемого файла(т.е. результат компиляции) и исходника. Как показано на следующей картинке, процесс компиляции происходит в 4 фазах.
- Фаза предварительной обработки. Эта фаза происходит благодаря препроцессору, которая реализована уже на C++. Препроцессор изменяет оригинальный файл на языке C в соответствии с директивами, начинающимися с символа решетки '#'. В нашей программе есть такая директива #include <stdio.h>. Препроцессор видит данную команду и вставляет код из файла-заголовка stdio.h прямо в нашу программу. В результате этой фазы получаем файл с расширением .i
- Фаза компиляции. На данном этапе компилятор переводит текстовый файл hello.i в текстовый файл hello.s, которая состоит из инструкции языка Assembly. Каждая строка с 2 по 7 содержит одну машинную инструкцию в виде текста. Язык ассемблера был и остается полезным тем, что обеспечивал уровень абстракции над машинным кодом, что упрощало портирование программ между разными архитектурами процессора. Снизу фрагмент кода, где определяется функция main:
main:
subq $8, %rsp
movl $.LC0, %edi
call puts
movl $0, %eax
addq $8, %rsp
ret- Фаза ассемблирования или сборки. В следующую очередь, программа ассемблер переводит hello.s в машинные инструкции и сохраняет их как "перемещаемые файлы" (hello.o), или по-другому "объектные файлы" (объектник). Это промежуточный бинарный файл, который содержит 17 байт кода для кодирования инструкций функции main.
- Фаза компоновки или линковки. Заметьте, что наша программа вызывает функцию printf, которая является частью стандартной библиотеки C. Эта функция обитает в другом объектном модуле printf.o, которая должна каким-то образом быть интегрирована в нашу программу. За это слияние отвечает программа компоновщик(линкер). В итоге наконец-то получаем исполняемый файл hello, или как говорят в народе "экзешник". Данный файл готов загрузиться в память и исполниться компьютером.
Знание процесса компиляции окупает себя
Для таких простых программ как в предыдущем разделе мы можем довериться компилятору, что он сгенерирует правильный и оптимизированный машинный код. Но тем не менее есть причины, почему стоит разобраться во всем этом:
- Оптимизация выполнения программ. Как уже написано выше, современные компиляторы способны генерировать оптимизированный код. Однако для принятия правильных решений в коде важно обладать базовым пониманием машинного кода и процесса перевода разных выражений компилятором. Например, является ли конструкция switch более эффективным, чем последовательность if-else? Сколько "стоит" вызов одной функции? Является ли цикл while более эффективным, чем for? Являются ли указатели более эффективными, чем индексы массива? Почему функция выполняется гораздо быстрее, если мы увеличиваем локальную переменную, а не аргумент?
- Понимание ошибок во время компоновки. Одни из самых неприятных ошибок в программировании относятся к операциям компоновщика. Например, что значит когда компоновщик не находит ссылку? Какое отличие статических и глобальных переменных? Что случится, если определить две глобальные переменные с одним именем в двух разных файлах? Какая разница между статической и динамической библиотекой? И хуже всего, почему некоторые ошибки компановщика не выявляются до запуска?
- Избежание дыр в безопасности. Многие годы большинство уязвимостей в безопасности в сетях и на серверах были связаны с переполнением буффера. Эти ошибки возникали потому что малое число программистов разбирались в ограничении количества и формата данных, которые поступали из ненадежных источников. Первый шаг в изучении безопасного программирования - это осознание последствий того, как данные хранятся в стеке программ.
Процессор читает и интерпретирует инструкции, которые хранятся в памяти
Чтобы запустить исполняемый файл на Unix подобных системах мы должны указать путь к нему в оболочке shell:
user@host: ./helloShell - интерпретатор командной строки, которая вывод подсказку, ждет вас пока вы не напишите команду, а затем исполняет данную команду. Если вы пишите то чего нет среди встроенных команд shell, то она будет пытаться запустить исполняемый файл с таким же названием.
Организация "железа" в системе. Чтобы полноценно понимать исполнение программ в компьютерных системах нам стоит разобрать это на аппаратном уровне. Если по ходу чтения многие вещи будут непонятны, то не стоит волноваться, потому что мы разберем эту тему более подробно в последующих статьях.
Шины. На материнской плате можно заметить электрические каналы, как показаны снизу, называемые шинами(buses). Они служат для передачи байтов информации между компонентами. Шины могут передавать за раз только огранинные по размеру фрагменты байтов, которые называются машинным словом(words). Размер машинного слова - это фундаментальная характеристика системы, которая может отличаться в каждой из ее частях. Например, разрядность (любой части системы) напрямую зависит от размера или длины машинного слова. Сейчас большинство машин делятся по длине машинного слова на 32-битные(4 байт) и 64-битные(8 байт). Также дополню тем, что объем оперативной памяти тоже зависит от данной величины. Потому что байты часто означают адресс в памяти, соответственно если шина способна пропускать только 4 байта за раз, то она не может корректно работать с ОЗУ обьемом больше 4 гигабайт (возведите двойку в степень 32).
Устройства ввода/вывода. Эти устройства связь нашей системы с внешним миром. К примеру наша система может иметь 4 таких устройства: компьютерная мышь и и клавиатура для ввода пользователя, монитор для вывода к пользователю, жесткий диск для долгосрочного хранения данных и программ. Первоначально, исполняемый файл hello хранится на диске. Каждое устройство ввода/вывода(дальше буду называть I/O устройство) подсоединяется к I/O шине с помощью контроллера или адаптера. Разница между ними в том, что контроллер является либо частью самого устройства, либо материнской платы, а адаптер внешне подключается к разъему(сокету) на материнской плате.
Оперативная память. Это временное запоминающее устройство, которое хранит и саму программу, и данные внутри ней пока процессор исполняет нашу программу. На аппаратном уровне, оперативная память состоит из множества чипов DRAM(dynamic random access memory). Логически, память представляется из себя одномерный массив байтов, где индекс каждого из них - это адресс в памяти(начинается с нуля). В общем, каждая машинная инструкция может состоять из разного количества байтов. Это число зависит от типов данных, которые используются в данной инструкции.
Процессор. Или как по-другому ее называют ЦПУ(центральное процессорное устройство) - это устройство, которое исполняет или интерпретирует инструкции из оперативной памяти. В ее ядре расположено устройство, или регистр, под названием счетчик команд(program counter). Ее объем равняется размеру машинного слова, или разрядности процессора. В каждый момент времени, счетчик указывает на адресс какой-либо машинной инструкции в оперативной памяти. Процессор работает в соответствии с моделью, которая определяется его Архитектурой набора команд (ISA - instruction set architecture). В этой модели процессор в строгой последовательности читает одну инструкцию указанную счетчиком команд, интерпретирует биты в ней, воспроизводит очень простую операцию из нее, а затем обновляет счетчик команд для следующей инструкции, которая не обязательно смежная с предыдущей. Операций, которые воспроизводит процессор немного и они вращаются вокруг оперативной памяти, регистрового файла и арифметически-логического устройства(АЛУ, ALU - arithmetic/logic unit). Регистровый файл - маленькое запоминающее устройство внутри процессора, которое состоит из других регистров с объемом машинного слова. Каждый регистр имеет свое уникальное название. АЛУ вычисляет новые данные и адрессные значения. Далее укажу простые операции, которые умеет делать процессор исходя из инструкций:
- Загрузка. Копировать байты или машинные слова из оперативной памяти в регистры, перезаписывая их содержание.
- Хранение. Копировать байты или машинные слова из регистров в оперативную память, изменяя значение адресса в памяти.
- Вычисление. Копировать байты или машинные слова из двух регистров в АЛУ, воспроизвести арифметическую операцию над ними и полученный результат сохранить в другом регистре, перезаписывая ее прежнее содержание.
- Прыжок. Получив новое машинное слово из инструкции, можно перезаписать сожержание счетчика команд, потому что это тоже регистр. Далее счетчик команд(program counter) вытащит следующую нужную нам инструкцию.
Хотя мы и говорим, что ЦПУ является простой реализацией ее Архитектуры набора команд, на самом деле современные процессоры используют гораздо продвинутые технологии для улучшения производительности. Поэтому в информатике различают два понятия Архитектуру набора команд(ISA), модель которая описывает эффект каждой машинной инструкции и микроархитектуру процессора (microarchitecture), то как процессор действительно реализован.
Теперь вооружившись знаниями по "железу" рассмотрим как происходит выполнение программы hello на аппаратном уровне. Мы упустим множество деталей, которые рассмотрим в продолжении этой серии. Когда мы вводим команду ./hello в оболочке shell, она начинает выполнять инструкции по копированию кода и данных из файла hello с диска в оперативную память. К данным относится строка Hello, world!, которая будет выведена на монитор пользователя. С использованием технологии прямого доступа к памяти (DMA - direct memory access) данные перемещаются с диска в память, обходя процессор. После того, как весь код и данные попадут в оперативную память, процессор начнет исполнение машинных инструкций процедуры main в программе hello. Эти инструкции в итоге скопируют байты строки Hello, world! с памяти в регистровый файл и, оттуда, на монитор пользователя. Эти шаги показаны на схеме ниже.
Кэш имеет значение
Как вы могли уже понять, система тратит большое количество времени перемещая информацию с одного места в другое. Первоначально машинные инструкции хранятся на диске, перед исполнением они загружаются в оперативную память, а затем во время исполнения они копируются с памяти в процессор. Некоторые данные затем отображаются на мониторе, как строка Hello, World! в прошлом примере. С точки зрения программиста вся эта мешанина имеет очень мало полезной работы, поэтому самая главная цель системных инженеров - сделать так, чтобы эти операции протекало максимально быстро, почти мгновенно. Законы физики диктуют, что чем больше устройство по размеру и объему памяти, тем оно медленнее. Также чем быстрее устройство, тем дороже будет обходиться ее производство. К примеру, жесткий диск может вмещать 1000 раз больше информации, чем ОЗУ. Но при этом чтение байтов может быть 10,000,000 раз медленнее. Таким же образом, регистровый файл может хранить до несколько сотен байт информации, но при этом он в 100 раз быстрее оперативной памяти. Хотя, индустрия полупроводников прогрессирует, эта разница между процессором и памятью только продолжает увеличиваться. Гораздо легче ускорять работу процессоров, чем памяти. Чтобы уменьшить эту разницу между процессором и памятью были придуманы маленькие и быстрые устройства, которые называют кэшем. Они служат промежуточным хранилищем для тех байтов информации, в которых процессор будет нуждаться в ближайшем будущем. Кэши делятся на уровни, обычно это L1, L2 и L3 кэши(L3 более современное нововведение). Наверно вы уже догадались, что каждый уровень предоставляет больше памяти, но при этом хуже по скорости. Чтение с L1 кэша по скорости ближе к чтению с регистра, а L2 кэш подсоединен специальной шиной прямо к процессору. Кэши используют такую технологию как SRAM(static random access memory). Главная идея, которую реализуют кэши - это локальность данных, свойство программ использовать повторно одни и те же ячейки памяти через короткое время (временная локальность), либо использовать ячейки памяти с близкими значениями адресов (пространственная локальность). Также хочу добавить то, что кэши отсылают нас к расширенной гарвардской архитектуре компьютера, но это отдельная большая тема. Запоминающие устройства в компьютерной системе образуют пирамиду или иерархию памяти, которую вы можете лицезреть снизу.