Как работает компьютер - глубокое погружение (на примере Linux)
Введение
Я делала много вещей с компьютерами, но в моих знаниях всегда был пробел: что конкретно происходит при запуске программы на компьютере? Я думала об этом пробеле - у меня было много низкоуровневых знаний, но не было цельной картины. Про граммы действительно выполняются прямо в центральном процессоре (central processing unit, CPU)? Я использовала системные вызовы (syscalls), но как они работают? Чем они являются на самом деле? Как несколько программ выполняются одновременно?
Наконец, я сломалась и начала это выяснять. Мне пришлось перелопатить тонны ресурсов разного качества и иногда противоречащих друг другу. Несколько недель исследований и почти 40 страниц заметок спустя я решила, что гораздо лучше понимаю, как работают компьютеры от запуска до выполнения программы. Я бы убила за статью, в которой объясняется все, что я узнала, поэтому я решила написать эту статью.
И, как говорится, ты по-настоящему знаешь что-то, только если можешь объяснить это другому.
1. Основы
Одной вещью, которая удивляла меня снова и снова, является то, насколько компьютеры просты. Эта простота очень красива, но порой превращается в кошмар.
Архитектура компьютера
ЦП - это место, в котором происходят все вычисления. Он начинает "пыхтеть" как только вы включаете компьютер, выполняя инструкцию за инструкцией.
Первым массовым ЦП был Intel 4004, спроектированный в конце 60-х итальянским физиком и инженером Федерико Фарджином. Это была 4-битная архитектура, а не 64-битная, которая используется сегодня. 4-битная архитектура была гораздо менее сложной, чем современные процессоры, но многое осталось почти неизменным.
"Инструкции", выполняемые ЦП - всего лишь двоичные (binary) данные: байт или два для представления запускаемой инструкции (код операции - opcode), за которыми следуют данные, необходимые для выполнения инструкции. То, что мы называем машинным кодом (machine code) - всего лишь серия этих бинарных инструкций в виде строки. Assemble - это полезный синтаксис, облегчающий чтение и запись сырых (raw) битов людьми. Он всегда компилируется в двоичные данные, понятные ЦП.
Ремарка: инструкции в машинном коде не всегда представлены 1:1, как в приведенном примере. Например,
add eax, 512
преобразуется в05 00 02 00 00
.Первый байт (
05
) - это код операции, представляющий добавление регистра EAX к 32-битному числу. Остальные байты - это 512 (0x200
) с порядком байтов little-endian.Defuse Security разработали полезный инструмент для преобразования языка ассемблера в машинный код.
Оперативная память или оперативное запоминающее устройство (Random Access Memory, RAM) - это основное хранилище компьютера, большое многоцелевое пространство, где хранятся все данные, используемые программами, запущенными на компьютере. Это включает код самих программ, а также код ядра (kernel) операционной системы. ЦП всегда читает машинный код прямо из ОП. Код, не загруженный в ОП, не может быть выполнен.
ЦП хранит указатель инструкции (instruction pointer), который указывает на место в ОП, где находится следующая инструкция. После выполнения всех инструкций, ЦП возвращает указатель в начало, и процесс повторяется. Это называется циклом выборки-исполнения (fetch-execute cycle).
После выполнения инструкции указатель передвигается за нее в ОП и таким образом указывает на следующую инструкцию. Указатель инструкции двигается вперед, и машинный код выполняется в порядке его хранения в памяти. Некоторые инструкции могут заставить указатель переместиться (перепрыгнуть - jump) в другое место (выполнить другую инструкцию вместо текущей или выполнить одну из инструкций в зависимости от определенного условия). Это делает возможным повторное и условное выполнение кода.
Указатель инструкции хранится в регистре (registry). Регистры - это маленькие хранилища, которые являются очень производительными для чтения и записи ЦП. Каждая архитектура ЦП имеет фиксированный набор регистров, используемых для всего: от хранения временных значений в проц ессе вычислений до настройки процессора.
Некоторые регистры доступны из машинного кода напрямую, например, ebx
на приведенной выше диаграмме.
Другие регистры предназначены только для внутреннего использования ЦП, но часто могут обновляться или читаться с помощью специальных инструкций. Одним из примеров такого регистра является указатель инструкции, который не может читаться напрямую, но может обновляться, например, для выполнения другой инструкции вместо текущей.
Процессоры наивны
Вернемся к вопросу о том, что происходит при запуске программы на компьютере. Сначала происходит некоторая магия - мы поговорим об этом позже - и мы получаем файл с машинным кодом, который где-то хранится. ОС загружает его в ОП и указывает ЦП переместить указатель на определенную позицию в ОП. Начинается цикл выборки-исполнения, и программа запускается!
Ваш ЦП получает последовательные инструкции браузера из ОП и выполняет их, что приводит к рендерингу этой статьи.
ЦП имеют очень ограниченный кругозор. Они видят только указатель текущей инструкции и немного внутреннего состояния. Процессы - это абстракции ОС, а не нечто, что понимают или отслеживают ЦП.
У меня это вызывает больше вопросов, чем ответов:
- Если ЦП не знает о многозадачности (multiprocessing) и выполняет инструкции последовательно, почему он не застревает в выполняемой программе? Как могут одновременно выполняться несколько программ?
- Если программа выполняется в ЦП, и ЦП имеет прямой доступ к ОП, почему код из других процессов или, прости господи, ядра не имеет доступа к памяти?
- Каков механизм, предотвращающий выполнение любой инструкции любым процессом? И что такое системный вызов?
Вопрос о памяти заслуживает отдельного раздела (см. раздел 5). Если коротко, то большая часть обращений к памяти проходит через слой неправильного направления (misdirection), который переназначает все адресное пространство. Пока мы будем исходить из предположения, что программы имеют прямой доступ ко всей ОП, и компьютеры могут запускать только один процесс за раз. Со временем мы избавимся от обоих этих предположений.
Пришло время прыгнуть в нашу первую кроличью нору – в страну, полную системных вызовов и колец безопасности (security rings).
Ремарка: что такое ядро?
ОС вашего компьютера, такая как macOS, Windows или Linux - это коллекция программного обеспечения, выполняющая всю основную работу. "Основная работа" - это общее понятие, и, в зависимости от ОС, может включать такие вещи, как приложения, шрифты, иконки, которые поставляются с компьютером по умолчанию.
Каждая ОС имеет ядро. Когда вы включаете компьютер, указатель инструкции запускает какую-то программу. Этой программой является ядро. Ядро имеет почти полный доступ к памяти, периферийным устройствам и другим ресурсам и отвечает за запуск ПО, установленного на компьютере (пользовательских программ).
Linux - это всего лишь ядро, которому для полноценной работы требуется множество пользовательских программ, таких как оболочки (shells) и серверы отображения (display servers). Ядро macOS называется XNU, а современное ядро Windows - NT Kernel.
Два кольца, чтобы править всеми
Режим (mode) (иногда именуемый уровнем привилегий (privilege level) или кольцом (ring)), в котором находится процессор, управляет тем, что разрешено делать. В современных архитектурах имеется, как минимум, два варианта: режим ядра/администратора (kernel/supervisor mode) и режим пользователя (user mode). Несмотря на то, что архитектура может поддерживать более двух режимов, как правило, используются только режимы ядра и пользователя.
В режиме ядра разрешено все: ЦП может выполнять любую поддерживаемую инструкцию и обращаться к любой памяти. В режиме пользователя разрешен только определенный набор инструкций, ввод/вывод (input/output, I/O) и доступ к памяти ограничены, многие настройки ЦП заблокированы. Как правило, ядро и драйверы запускаются в режиме ядра, а приложения - в режиме пользователя.
Процессоры запускаются в режиме ядра. Перед выполнением программы ядро инициализирует переключение в пользовательский режим.
Пример того, как режимы процессора проявляются в реальной архитектуре: в x86-64 текущий уровень привилегий (current privilege level, CPL) может читаться из регистра cs
(code segment - сегмент кода). В частности, CPL содержится в 2 наименьших битах регистра cs
. Эти 2 бита могут хранить 4 возможных кольца x86-64: кольцо 0 - это режим ядра, а кольцо 3 - режим пользователя. Кольца 1 и 2 предназначены для запуска драйверов, но используются только несколькими нишевыми ОС. Если, например, битами CPL являются 11
, ЦП запускается в режиме пользователя.
Системный вызов
Программы запускаются в режиме пользователя, потому что им нельзя предоставлять полный доступ к компьютеру. Режим пользователя делает свою работу, предотвращая доступ к большей части компьютера, но программам требуется ввод/вывод, память и возможность взаимодействия с ОС! Для этого ПО, запущенное в пользовательском режиме, обращается за помощью к ядру ОС. ОС затем реализуют собственные меры защиты.
Если вы писали код, взаимодействующий с ОС, то должны быть знакомы с функциями open
, read
, fork
и exit
. Под несколькими слоями абстракций эти функции используют системные вызовы для обращения к ОС за помощью. Системный вызов - это специальная процедура, позволяющая программе запускать переход из пространства пользователя в пространство ядра, перепрыгивать из кода программы в код ОС.
Передача управления из пространства пользователя в пространство ядра выполняется с помощью функций процессора, которые называются программными прерываниями (software interrupts):
- При запуске ОС сохраняет "таблицу векторов прерываний" (interrupt vector table, IVT) (в x86-64 она называется "таблицей дескрипторов прерываний" (interrupt descriptor table)) в ОП и регистрирует ее с помощью ЦП. IVT представляет собой таблицу сопоставления номера прерывания (interrupt number) и указателя обработчика кода (handler code pointer).
- Затем пользовательские программы могут использовать такие инструкции, как INT, указывающие процессору найти заданный номер прерывания в IVT, переключиться в режим ядра и переместить указатель инструкции на адрес памяти, указанный в IVT.
После завершения этого кода, ядро указывает ЦП переключиться обратно в режим пользователя и вернуть указатель инструкции в то место, где произошло прерывание. Это делается с помощью таких инструкций, как IRET.
Если вам интересно, то идентификатором прерывания для системных вызовов в Linux является 0x80
. Список системных вызовов Linux можно найти здесь.
Интерфейсы оболочки: абстрагирование прерываний
Вот, что мы узнали о системных вызовах:
- программы, запущенные в режиме пользователя, не имеют доступа к вводу/выводу или памяти. Им приходится обращаться к ОС за помощью во взаимодействии с внешним миром;
- программы могут передавать управление ОС с помощью специальных инструкций, содержащих машинный код, таких как INT и IRET;
- программы не могут переключать уровни привилегий напрямую. Программные прерывания являются безопасными, поскольку процессор предварительно настраивается ОС относительно того, к какому коду ОС следует обращаться. Векторная таблица прерываний может настраиваться только в режиме ядра.
Программы должны передавать данные ОС при системном вызове. ОС должна знать не только то, какой системный вызов выполнять, но также располагать данными, необходимыми для его выполнения, такими как название файла (filename). Механизм передачи данных зависит от ОС и архитектуры, но, как правило, это делается посредством помещения данных в определенные регистры или в стек (stack) перед запуском прерывания.
Различия в том, как системные вызовы вызываются на разных устройствах означает, что реализация системных вызовов для каждой программы разработчиками является, по меньшей мере, непрактичной. Это также означало бы невозможность изменения ОС своей обработки прерываний во избежание поломки программ, рассчитанных на использование старых систем. Наконец, мы больше не пишем программы на языке ассемблера – нельзя ожидать от программистов перехода на assembly при каждом чтении файла или выделении памяти.
Поэтому ОС предоставляют слой абстракции поверх этих прерываний. "Переиспользуемые" высокоуровневые функции, оборачивающие необходимые инструкции на языке ассемблера, предоставляются libc в Unix-подобных системах и частью библиотеки под названием ntdll в Windows. Простой вызов этих функций не влечет переключения в режим ядра. Внутри библиотек код assembly передает управление ядру. Этот код гораздо более зависим от платформы, чем библиотечная обертка.
Когда мы вызываем exit(1)
из C, запущенного на Unix, эта функция выполняет машинный код для запуска прерывания после помещения кода операции системного вызова и аргументов в правильный регистр/стек/что угодно. Компьютеры такие клевые!
Жажда скорости / CISC
Многие архитектуры CISC, такие как x86-64, содержат инструкции, предназначенные для системных вызовов, созданные из-за преобладания парадигмы системных вызовов.
Intel и AMD не очень хорошо координировали свои действия при работе над x86-64. Поэтому у нас имеется 2 набора оптимизированных инструкций системных вызовов. SYSCALL и SYSENTER являются оптимизированными альтернативами таких инструкций, как INT 0x80
. Их инструкции возврата, SYSRET и SYSEXIT, предназначены для быстрого обратного перехода в пространство пользователя и продолжения выполнения кода программы.
Процессоры AMD и Intel имеют немного разную совместимость с этими инструкциями. SYSCALL
, как правило, лучше подходит для 64-битных программ, а SYSENTER
лучше поддерживается 32-битными программами.
Архитектуры RISC не имеют таких специальных инструкций. AArch64, архитектура RISC, которая применяется в Apple Silicon, использует только одну инструкцию прерывания для системных вызовов и программных прерываний.
Вот что мы узнали в этом разделе:
- процессоры выполняют инструкции в бесконечном цикле выборки-исполнения и не имеют ни малейшего понятия об ОС или программах. Режим процессора, обычно хранящийся в регистре, определяет, какие инструкции могут выполняться. Код ОС выполняется в режиме ядра и переключается на режим пользователя для запуска программ;
- для запуска исполняемого файла ОС переключается в режим пользователя и сообщает процессору входную точку кода в ОП. Поскольку программы имеют низкие привилегии, они вынуждены обращаться за помощью к коду ОС для взаимодействия с внешним миром. Системные вызовы – это стандартизированный способ переключения программ из режима пользователя в режим ядра и в код ОС;
- программы, как правило, используют эти системные вызовы путем вызова функций специальных библиотек. Эти функции являются обертками над машинным кодом для программных прерываний или специфических для архитектуры инструкций системных вызовов, которые передают управление ядру ОС и переключают кольца безопасности. Ядро делает свое дело, переключается обратно в режим пользователя и возвращается к коду программы.
Вернемся к вопросу о том, что если ЦП не отслеживает больше одного процесса и просто выполняет одну инструкцию за другой, почему он не застревает в запущенной программе. Как несколько программ могут работать одновременно?
Все дело в таймерах.
2. Нарезка времени
Предположим, что мы разрабатываем ОС и хотим, чтобы пользователи могли запускать несколько программ одновременно. У нас нет модного многоядерного процессора, наш ЦП может выполнять только одну инструкцию за раз.
К счастью, мы очень умные разработчики ОС. Мы выяснили, что конкурентность (concurrency) может имитироваться путем выполнения процессов по очереди. Если мы циклически перебираем процессы и выполняем по несколько инструкций из каждого, все процессы будут отзывчивыми и ни один из них не загрузит ЦП полностью.
Как вернуть управление из программного кода? После небольшого исследования выясняется, что большинство компьютеров содержат микросхемы (чипы) таймеров (timer chips). Мы можем запрограммировать чип таймера для переключение на обработчик прерываний ОС по прошествии определенного времени.