Давайте начнем с основ того, как работает ваш компьютер на самом базовом уровне.

Устройство компьютера

Центральный процессор (CPU) компьютера отвечает за все вычисления. Он - главный. Он начинает работу, когда вы включаете компьютер, исполняя одну инструкцию за другой.

Первым массово производимым центральным процессором был Intel 4004, разработанный в конце 60-х годов итальянским физиком и инженером Федерико Фаджином. Он имел 4-битную архитектуру вместо 64-битной, которую мы используем сегодня, и он был гораздо менее сложным, чем современные процессоры, но много его базовых концепций применяются и по сей день.

"Инструкции", которые исполняет CPU (центральный процессор), представляют собой просто двоичные данные: несколько байтов, представляющих код выполняемой операции, за которыми следуют необходимые данные для выполнения самой операции. То, что мы называем машинным кодом, - это просто серия двоичных инструкций. У нас есть ассемблер - более читабельный для человека формат. Но он всегда компилируется в двоичный код, который CPU умеет процессить.

Маленькое отступление: инструкции не всегда представлены 1:1 в машинном коде, как в приведенном выше примере. К примеру, add eax, 512 преобразуется в 05 00 02 00 00.

Первый байт (05) - это opcode, представляющий добавление регистра EAX к 32-битному числу. Остальные байты - 512 (0x200) в порядке следования младшего байта (little-endian).

Компания Defuse Security создала полезный инструмент для экспериментов с переводом между ассемблером и машинным кодом.

ОЗУ (оперативная память) - это основной банк памяти вашего компьютера, большое многоцелевое пространство, в котором хранятся все данные, используемые программами, выполняющимися на вашем компьютере. Она включает в себя сам код программы, а также код, являющийся ядром операционной системы. CPU всегда считывает машинный код непосредственно из ОЗУ, и код не может быть выполнен, если он не загружен в ОЗУ.

CPU хранит указатель инструкции (pointer), который указывает на место в ОЗУ, где будет получена следующая инструкция. После выполнения каждой инструкции CPU перемещает pointer и повторяет процесс. Это так называемый цикл извлечения и выполнения.

После выполнения инструкции pointer перемещается вперед в ОЗУ, и теперь он указывает на следующую инструкцию. Вот почему код работает! Pointer продолжает двигаться вперед, выполняя машинный код в том порядке, в котором он был сохранен в памяти. Некоторые инструкции могут сказать pointer перейти в другое место памяти в зависимости от определенного условия; это позволяет создавать повторно используемый код и условную логику.

Pointer хранится в регистре. Регистры - это небольшие хранилища, к которым CPU может обращаться на чтение и запись с крайне высокой скоростью. Каждая архитектура CPU имеет фиксированный набор регистров, используемых для всего, начиная от временного хранения значений во время вычислений до настройки процессора.

Некоторые регистры непосредственно доступны из машинного кода, например, ebx на предыдущей диаграмме.

Другие регистры используются только во внутренней кухне CPU, но их часто можно обновлять или считывать с помощью специализированных инструкций. Один из примеров - pointer, который нельзя читать напрямую, но он может быть обновлен, например, инструкцией перехода (jump instruction).

Процессоры просты

Вернемся к исходному вопросу: что происходит, когда вы запускаете исполняемую программу на вашем компьютере? Сначала происходит подготовка к запуску (подробнее ее разберем позже), которая приводит к появлению файла с машинным кодом. Операционная система загружает его в ОЗУ и дает инструкцию CPU перевести pointer на эту область памяти. CPU начинает свой цикл извлечения и выполнения, программа начинает свое выполнение.

(Именно так работает любой процессор и даже тот, который стоит в вашем компьютере и выполняет fetch/execute тысячи раз пока вы читаете эту статью)

Оказывается, CPU имеет очень простое представление о мире: он видит только позицию pointer и хранит небольшое внутреннее состояние. Процессы - это абстракции операционной системы, которые CPU не понимает и не отслеживает самостоятельно.

У меня это вызывает больше вопросов, чем ответов:

  1. Если CPU не знает о мультипроцессорности и просто последовательно выполняет инструкции, почему он не застревает в пределах программы, которую выполняет? Как могут одновременно выполняться несколько программ?

  2. Если программы выполняются непосредственно на CPU, и CPU может напрямую обращаться к ОЗУ, почему клиентский код не может получить доступ к памяти других процессов или к ядру ОС?

  3. Какой механизм в ядре ОС предотвращает выполнение любой инструкции любым процессом ? Как ограничивается возможность получения полного доступа к ядру ? Что такое System Call ?

Пока что мы сделаем допущение о том, что программы могут напрямую обращаться ко всей доступной ОЗУ, а компьютеры могут выполнять только один процесс одновременно.

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

Операционная система вашего компьютера, такая как macOS, Windows или Linux, - это набор программного обеспечения, которое работает на компьютере и обеспечивает все основные функции. "Основные функции" - это очень общее понятие, так же, как и "операционная система" - в зависимости от того, кого вы спросите, но думаю большинство людей видит под этими терминами одинаковые вещи.

Ядро - это ядро операционной системы. Когда вы включаете компьютер, pointer начинает работу с программы, которая где-то находится. Эта программа и есть ядро. Ядро имеет почти полный доступ к памяти вашего компьютера, периферийным устройствам и другим ресурсам, и отвечает за запуск установленного на вашем компьютере программного обеспечения. Мы узнаем о том, как ядро получает такой доступ, а пользовательские программы - нет, в течение этой статьи.

Ядро в macOS называется XNU и является Unix-подобным, а современное ядро Windows называется NT Kernel. Linux - это тоже ядро.

Два кольца, чтобы править всеми

Режим (иногда называемый уровнем привилегий) процессора контролирует, что ему разрешено делать. У современных архитектур есть как минимум два варианта: режим ядра/супервизора и режим пользователя. Хотя архитектура может поддерживать более двух режимов, в наши дни обычно используются только перечисленные выше режимы.

В режиме ядра разрешено все: CPU может выполнять любую поддерживаемую инструкцию и получать доступ к произвольной области памяти. В режиме пользователя разрешено только подмножество инструкций, ограничивается доступ к вводу-выводу и памяти, а также блокируется большинство настроек CPU. Обычно ядро и драйверы работают в режиме супервизора, в то время как приложения работают в режиме пользователя.

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

Пример того, как режимы процессора проявляются на реальной архитектуре: на x86-64 текущий уровень привилегий (CPL) может быть прочитан из регистра, называемого cs (сегмент кода). Более конкретно, CPL содержится в двух младших битах регистра cs. Эти два бита могут хранить четыре возможных режима x86-64: режим 0 - это режим ядра, а режим 3 - режим пользователя. Режимы 1 и 2 предназначены для работы с драйверами, но используются только несколькими устаревшими операционными системами. Если биты CPL равны 11, например, CPU работает в режиме 3: режим пользователя.

Что такое system call ?

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

Если вы когда-либо писали код, взаимодействующий с операционной системой, вам, вероятно, знакомы такие функции, как open, read, fork и exit. Несмотря на несколько слоев абстракции, все эти функции используют системные вызовы, чтобы обратиться к операционной системе за помощью. Системный вызов (system call) - это особая процедура, позволяющая программе начать переход из пользовательского пространства в пространство ядра.

Переходы из пользовательского пространства в пространство ядра осуществляются с использованием функции процессора, называемой - software interrupts

  1. Во время загрузки операционная система сохраняет в ОЗУ таблицу, называемую таблицей векторов прерываний (IVT; в x86-64 она называется таблицей дескрипторов прерываний), и регистрирует ее в CPU. IVT сопоставляет номера прерываний с указателями на обработчики кода. В таблице лежат указатели на функции, доступные только из ядра.

  2. Затем пользовательские программы могут использовать инструкцию, такую как INT, которая указывает процессору найти заданный номер прерывания в таблице IVT, переключиться в режим ядра, а затем перевести pointer к адресу памяти, хранящемуся в IVT.

Когда код ядра завершается, он использует инструкцию, наподобие IRET, чтобы указать CPU переключиться обратно в режим пользователя и вернуть pointer в то место, где он был, когда прерывание было сгенерировано.

(Если вас интересует, идентификатор прерывания, используемый для системных вызовов в Linux, то он равен 0x80. Вы можете ознакомиться со списком системных вызовов Linux в электронной директории Майкла Керриска.)

Вот что мы можем подытожить по системным вызовам:

  • Программы в режиме пользователя не могут напрямую получать доступ к вводу-выводу или памяти. Они должны обратиться к операционной системе за помощью.

  • Программы могут делегировать управление операционной системе с помощью специальных инструкций машинного кода, таких как INT и IRET.

  • Программы не могут напрямую переключать уровни привилегий; программные прерывания безопасны, потому что процессор заранее настроен операционной системой с указанием, куда переходить. Таблица векторов прерываний может быть настроена только в режиме ядра.

Программам необходимо передавать данные операционной системе при выполнении system call; операционная система должна знать, какой конкретный системный вызов выполнить вместе c данными необходимыми для этого вызова, например, какой файл открыть. Механизм передачи этих данных различается в зависимости от операционной системы и архитектуры, но обычно данные помещаются в определенные регистры или на стек перед вызовом прерывания.

На разных архитектурах процессоров обращения к system call могут отличаться, а это означает, что было бы чрезвычайно неэффективно для программистов самостоятельно реализовывать системные вызовы для каждой программы. Это также означало бы, что операционные системы не смогли бы изменять обработку прерываний, опасаясь нарушить обратную совместимость. Наконец, мы больше не пишем программы на чистом ассемблере - не ожидается, что программисты будут переходить на ассемблер каждый раз, когда им нужно прочитать файл или выделить память. Но стоит отметить, что все еще есть кейсы когда приходится писать и на ассемблере.

Таким образом, операционные системы предоставляют абстракцию поверх этих прерываний. Используются более высокоуровневые библиотечные функции, которые оборачивают необходимые ассемблерные инструкции и предоставляются библиотекой libc в Unix-подобных системах и частью библиотеки с названием ntdll.dll в Windows. Вызовы самих этих библиотечных функций не приводят к переходу в режим ядра. Внутри библиотек ассемблерный код фактически передает управление ядру и является намного более зависимым от платформы, чем оберточная подпрограмма библиотеки.

Когда вы вызываете exit(1) из программы на языке C в Unix-подобной системе, функция под капотом запускает машинный код для генерации прерывания.

Recap

  • Процессоры выполняют инструкции в бесконечном цикле извлечения и выполнения (fetch/executre) и не имеют представления об операционных системах или программах. Режим процессора, обычно хранящийся в регистре, определяет, какие инструкции могут быть выполнены. Код операционной системы выполняется в режиме ядра и переключается в режим пользователя для выполнения программ.

  • Для запуска бинарного файла операционная система переключается в режим пользователя и направляет процессор к точке входа кода в ОЗУ. Программы в режиме пользователя, которые хотят взаимодействовать с внешним миром, должны обратиться к операционной системе за помощью.

  • Системные вызовы являются стандартизованным способом для программ переходить из режима пользователя в режим ядра и в код операционной системы.

  • Программы обычно используют эти системные вызовы, через вызов функций общих библиотек. Ядро выполняет свои функции и снова переключается в режим пользователя, возвращаясь к коду программы.

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


  1. EvgenWithYou
    22.09.2023 12:55
    -1

    Интересная статья, узнал много нового


  1. ZEN_LS
    22.09.2023 12:55
    +1

    Это же дубликат этого перевода.


    1. Ab0cha Автор
      22.09.2023 12:55

      Спасибо, что подсветили !

      Тогда думаю в моем переводе нет смысла, архивирую статью)