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

Некоторые программы, написанные в исходных кодах, таких как PHP, Python, JavaScript и т. д., могут выполняться на различных платформах с совместимым движком. Исходный код Java не может быть исполняемым; тем не менее, скомпилированная программа байт-кода Java может выполняться на разных платформах с почти незначительными различиями (или иногда разница разительна, поэтому слоган Sun Write Once, Run Anywhere обычно становится Write Once, debug everywhere).

Единственным исключением являются программы, написанные на машинном коде (также и ассемблер). Технически, машинный язык — это единственный язык, который компьютер может понять и исполнить, поэтому было бы верно сказать, что одна программа не может работать на многих разных платформах. Если у вас есть опыт работы с C++ (он же запускает Hello World на языках продавцов и HR), вы довольно хорошо знаете, что для того, чтобы код мог работать на другой платформе, вы должны использовать кросс-компилятор, указать целевую платформу, чтобы скомпилировать совершенно новый файл машинного кода из исходного кода.

Платформа по определению представляет собой комбинацию аппаратной архитектуры и определенной версии операционной системы. Исходя из этого определения, как платформа влияет на наш машинный код? Это ответ, основанный на моем опыте работы на https://sotatek.com.

Процессор

Во-первых, машинный код написан на машинном языке, который не является единственным языком программирования. Машинный язык — это язык, который ЦП может прочитать, понять и сразу же выполнить; однако разные процессоры говорят на разных языках. Каждое семейство ЦП имеет определенный Instruction Set Architecture (ISA); поэтому первым фундаментальным элементом платформы, от которого зависит машинный код, является архитектура процессора.

https://en.wikipedia.org/wiki/X86_instruction_listings

Давайте взглянем на наборы инструкций CPU x 86. Существует ряд различий не только между разными производителями ЦП, такими как Intel или AMD, но и между каждым поколением в одном семействе одного и того же производителя. Этот набор инструкций похож на ключевые слова в языках программирования высокого уровня, а также является наиболее интуитивно понятным, иллюстрирующим, что машинный код не может работать на многих ISA. Стоит отметить, что это всего лишь разница в инструкции списка семейства x86. Разница между процессорами ARM и x86 намного больше.

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

К счастью, процессоры AMD и Intel довольно хорошо совместимы, поскольку программа, которая без проблем работает на процессоре одного производителя, может работать без проблем и на другом. Основное различие между ними заключается в более низких архитектурных уровнях, где выполняются отдельные инструкции. ЦП часто поставляются с кодом операции CUPUID, который помогает идентифицировать архитектуру времени выполнения — систему, которая управляет индивидуальным поведением каждой архитектуры. Благодаря этим фундаментальным элементам нам редко приходится компилировать исходный код для каждой из различных архитектур ЦП.

https://ru.wikipedia.org/wiki/CPUID

Итак, может ли машинный код работать гладко на каждом компьютере, использующем одну и ту же ISA?

Ответ ДА, если мы загружаем программы прямо из загрузочного сектора. А может быть и НЕТ, если эта программа загружается из операционной системы.

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

Программа, которая загружается непосредственно из загрузочного сектора, называется программой без операционной системы, тогда она должна изменить режим ЦП сама (например, для процессоров x86–64 она должна изменить реальный режим по умолчанию на защищенный режим, поддерживающий защиту ресурсов). механизм и может использовать больше основной памяти или перейти из защищенного режима в длинный режим с поддержкой 64-разрядной версии), а также должен иметь самоуправляемый код для всех системных ресурсов, таких как создание таблиц страниц для основной памяти, выделение и управление основной памятью, установка обработчиков для работы с вводом-выводом, прерываниями и исключениями и так далее. Эти задачи сложны и подвержены ошибкам; однако ОС позаботится о них, чтобы мы могли свести к минимуму периферийную работу и сосредоточиться на конкретном продуктивном бизнесе.

Операционная система

Делегирование ОС полномочий и ответственности по управлению ресурсами приводит к тому, что если наши программы не используют эти ресурсы, то в противном случае они должны полагаться на интерфейс ОС. Если программа не использует эти ресурсы, это означает, что программа не читает и не записывает файлы, не записывает вывод на консоль, не использует аппаратные устройства и Интернет. Другими словами, эта программа бесполезна.

Для того, чтобы написать полезную программу (по крайней мере, ее можно вывести на консоль в строке Hello World), мы должны ознакомиться с еще одним элементом помимо аппаратного обеспечения компьютера, а также последней составляющей в определении платформы: OS.

Платформа ABI

Наиболее заметным отличием влияния ОС является формат executable file. Исполняемые файлы Windows обычно имеют расширение .exe, но Linux не имеет расширения, как и MacOS. Все эти файлы не являются необработанным машинным кодом, но могут содержать различные другие компоненты в зависимости от требований ОС.

Помимо исходного машинного кода, исполняемый файл также содержит некоторую информацию, такую ​​как:

  • Статические переменные, которые определяет программа
  • Точка входа в программу (пример: Main объявление адреса функции)
  • Целевая архитектура
  • Целевая версия ABI
  • Информация о переезде

Не все форматы исполняемых файлов поддерживают все вышеперечисленные функции. Перейдите по ссылкам ниже, чтобы сравнить некоторые популярные форматы исполняемых файлов:

https://en.wikipedia.org/wiki/Comparison_of_executable_file_formats

Пример исполняемого файла в формате ELF для Linux:

https://en.wikipedia.org/wiki/Executable_and_Linkable_Format

Разработчики с небольшим опытом системного программирования могут быть чужды ABI, но знакомы с тесно связанной с ним концепцией API.

API — Application Program Interface, который представляет собой коммуникационный интерфейс, определяющий протоколы связи между компонентами на уровне исходного кода (т. е. работа с функциями и аргументами программы). На более высоких уровнях API может определять интерфейс связи между процессами на одном компьютере посредством межпроцессного взаимодействия (IPC) или даже процессами на разных компьютерах, подключенных через интрасеть или Интернет.

В то время как API определяет интерфейс на уровне исходного кода, ABI — Application Binary Interface определяет интерфейс на более низком уровне: двоичном уровне. Проще говоря, API содержит функции, которые могут вызывать определения и необходимые аргументы. С другой стороны, ABI содержит определения того, как вызывать эти функции и механизм передачи параметров с использованием машинного языка. Спецификации того, как ABI выполняет вызовы функций, известны как calling conventions. Это считается одной из наиболее важных спецификаций ABI.

Сам процессор вряд ли знает о функциях. Обычно, чтобы сделать вызов функции, ЦП будет делать это, просто перемещая курсор IP (или курсор EIP в 32-битной системе), курсор указывает на следующую инструкцию, которая будет выполняться, туда, где функция объявлена ​​в основной памяти. . Такие задачи, как создание фреймов стека, в которых хранятся локальные переменные функций, в том числе переданные параметры, удаление этих значений для освобождения памяти после выполнения функций и т. д. определяются и специфицируются ABI. В частности, соглашение о вызовах должно как минимум содержать следующее:

  • Как параметры передаются в функцию (хранить параметры в стеке или регистрах, или и в регистрах, и в стеке). Если параметры хранятся в регистрах, необходимо сделать резервную копию текущих значений в этих регистрах, а затем восстановить после возврата.
  • Механизм очистки параметров и локальных переменных (какой из них будет выполнять действие очистки, вызывающий или вызываемый? Как очистить и восстановить данные в исходное состояние?)
  • Где будут храниться возвращенные данные? Как вызывающая сторона считывает возвращаемое значение?
  • Как исключения будут распространяться и обрабатываться?

Некоторые примеры соглашений о вызовах:

https://en.wikibooks.org/wiki/X86_Disassembly/Calling_Convention_Examples#Example:_C_Calling_Conventions

Определяя коммуникационный интерфейс на уровне исходного кода, API не должен зависеть от платформы. Напротив, работая на двоичном уровне, ABI в значительной степени зависит от платформы, на которой он работает. Таким образом, в основном, если программе нужно работать на разных платформах, ее просто нужно перекомпилировать для целевой платформы, чтобы компилятор мог заменить ABI без необходимости обновления исходного кода.

Если посмотреть на заголовок файла EFL, поле e_ident, в котором хранится версия исполняемого файла ABI. Значение по умолчанию 0x00 — самая популярная версия дистрибутива Linux: SystemV.

System V Application Binary Interface определяет соглашение о вызове функций на машинном языке, формат исполняемого файла, механизм связывания библиотек. Сам ELF является частью двоичного интерфейса приложения System V.

Все мы знаем, что для выполнения код программы должен быть загружен в оперативную память. Если программа была загружена непосредственно из загрузочного сектора, версия в памяти точно такая же, как у файла машинного кода, хранящегося во вторичной памяти. Первая его инструкция будет храниться по адресу 0x00000000 основной памяти и постепенно увеличиваться до конца. Регистр IP (или EIP) запускается со значением 0x00000000. Это называется точкой входа программы, что означает, что первая команда будет выполняться всякий раз, когда эта программа запускается.

Когда ОС участвует в истории, программа будет загружена компонентом, называемым загрузчиком. Он будет считывать информацию из исполняемого файла, проверять проверки и целевую архитектуру, инициировать статические значения, передавать аргументы командной строки в программу и заменять значение в поле EIP значением поля e_entry в заголовочном файле. Это позволяет программе main функцию объявлять в любой позиции машинного кода, а не в первой строке.

В то же время ОС может изменить базовый адрес программы 0x00000000, информация о перемещении файла поможет ОС обновить ссылочные адреса в машинном коде в соответствии с новым базовым адресом. Благодаря перемещению адреса программа может быть загружена в любую позицию в памяти вместо 0x00000000 адреса. Это уменьшит зависимость от статуса адреса основной памяти, создаст возможность запуска нескольких экземпляров программы в памяти.

Фактически, из-за популярности memory paging, метода, который помогает отображать адрес памяти, называемый логическим адресом, используемым программой, в физический адрес в основной памяти компьютера, современные программы больше не нуждаются в перемещении адреса, чтобы их можно было загрузить в любом месте. основной памяти. Две программы могут использовать один и тот же логический адрес, который будет отображаться в два разных физических адреса, о которых программы даже не знают. Это позволяет каждому экземпляру программы загружаться и выполняться в memory sandbox, где каждый экземпляр считает, что это единственный загруженный процесс с базовым адресом 0x00000000.

Но не так, чтобы address relocation не казалось полезным. Поскольку мы используем загрузчик, загруженная версия программы в памяти больше не должна быть точной копией исходного машинного кода в исполняемом файле. Современные ОС предоставляют функцию под названием shared library или dynamic linking library. Ваша программа может использовать внешние модули ОС без сохранения машинного кода внешних модулей в вашем исполняемом файле во время компиляции, как при использовании библиотек статической компоновки. Каждая разделяемая библиотека представляет собой объектный файл — файл в формате исполняемого файла, но не может быть запущен напрямую. Машинный код общей библиотеки в объектном файле будет загружен в память во время загрузки другого исполняемого файла или во время выполнения процесса. Версия нашей программы в памяти теперь будет представлять собой комбинацию машинного кода в исполняемом файле и машинного кода в необходимых библиотеках.

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

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

Подводя итог, программа тесно связана с ABI каждой платформы. Если это не соответствует ABI, загрузчик ОС не сможет загрузить программы в память или они не смогут взаимодействовать с внешней библиотекой.

API операционной системы

В отличие от программы на «голом железе», программы, загружаемые ОС, не содержат кодов, которые управляют системными ресурсами и обрабатывают их. На самом деле, даже если мы попытаемся внедрить в программу машинный код, который пытается получить доступ к системным ресурсам, он все равно не сможет быть выполнен. Более поздние версии ЦП Intel предоставляют исполняемый режим, называемый защищенным режимом, вместе с возможностью выделения памяти с помощью настройки подкачки, поддерживаемой Memory Management Unit (MMU), что помогает ограничить доступ к портам ввода-вывода, прерываниям, основной памяти и т. д. ЦП обычно предоставляет четыре уровня привилегий для каждого сегмента кода в основной памяти, соответствующие Privilege Ring от 0 до 3, где кольцо 0 имеет наибольшую мощность, а кольцо 3 — наименьшую. Каждый запрос на выполнение и доступ к системным ресурсам может требовать минимального уровня привилегий.

После загрузки из загрузчика ОС создает раздел памяти, в котором хранятся код и данные ядра, называемые kernel space с ring 0, затем устанавливает требуемые уровни привилегий для других ресурсов и позволяет пространству ядра управлять ими. ОС загружает все последующие коды в наименее мощный раздел памяти (ring 3) с именем user space. Каждая операция с системными ресурсами требует разрешения пространства ядра через API, предоставленный ОС.

Существует распространенное заблуждение о существовании пространства ядра, которое ядро ​​выполняет как независимый процесс в системе. Как известно, каждое ядро ​​процессора может одновременно выполнять только одну программу. Если одноядерный ЦП загружает и выполняет пользовательскую программу, и программа не позволяет ЦП активно выполнять ядро, то код ядра не может остановить пользовательский процесс. На самом деле, как и в разделяемых библиотеках, пространство ядра загружается в память во время загрузки пользовательской программы и становится частью кода памяти программы. В любое время всегда есть раздел кода ядра, готовый обрабатывать прерывания, исключения и снижать нагрузку на ЦП пользовательского пространства.

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

API, который ОС предоставляет пользовательскому пространству для запроса пространства ядра для выполнения задачи, называется system call. Правила совершения системного вызова изложены в system call calling convention. Это соглашение отличается от других соглашений о вызовах, поскольку оно полностью изолирует код, стек и данные двух пространств. Кроме того, он также изменяет текущий уровень привилегий с кольца 3 на кольцо 0 и с кольца 0 обратно на кольцо 3.

Это соглашение зависит от платформы. Кроме того, системные вызовы, предоставляемые каждой ОС, различаются по количеству, имени, назначению, количеству параметров и порядку параметров.

Это пример типичной таблицы системных вызовов существующего дистрибутива Linux и ядер Windows NT. Разница огромна, и даже с загрузчиком ОС и ABI программа для Linux не может нормально работать в Windows и наоборот.

https://syscalls.kernelgrok.com/

https://github.com/j00ru/windows-системные вызовы

На самом деле существует своего рода программа под названием compatibility layer, которая пытается предоставить интерфейс, позволяющий запускать исполняемый файл другой платформы на хост-платформе. Самый известный уровень совместимости — это проект WINE, который обеспечивает реализацию формата файла Windows PE в системе Linux. Первоначально WINE обозначал эмулятор Windows, но позже он был изменен на аббревиатуру «Wine Is Not an Emulator», потому что WINE фактически реализовал собственный PE-файл вместо эмулятора или виртуального уровня.

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

Вывод

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

  • Архитектура набора инструкций (ISA)
  • Двоичный интерфейс приложений платформы (ABI)
  • Интерфейс прикладных программ операционной системы (API)

Этот анализ все еще нуждается в более конструктивной критике. Мы приветствуем все ваши отзывы! Спасибо!

Использованная литература: