Сноб-потребитель и фирмваре: адресные пространства портируемых программ
реклама
В этой серии заметок сноб-потребитель не только ведет борьбу с хищниками-производителями, но и удовлетворяет эстетические потребности народа, демонстрирует красоты природы, рукотворный, так сказать, ландшафт в виде бесконечных нагромождений глыб абсолютно неработоспособного фирмваре. Чистая природа эпохи полного оледенения или лучше сказать "эпохи полного обфирмварения".
Единственное, что может спасти
тяжело раненого кота,
это глоток бензина...
(кот, починяющий примус)
Ну что, не ждали? Ну и ладно, я всегда очень критиковал firmware. Противники разумного, доброго и вечного отмахивались от "неспециалистов", но в глубине души все же лелеяли надежду, что альтернативы для firmware никто толком сформулировать не сможет. Обычно, чтобы оценить вкус борща, совсем не обязательно быть поваром, но в нашем случае (как и для USB, разметки диска и т.п.) существует вполне ясная альтернатива. Ее и опубликуем.
Содержание.
1. Введение.
2. Размер байта.
3. Унифицированные целые типы.
4. Сегменты и плоская модель памяти.
5. Унифицированное адресное пространство:
5.1 Размер.
5.2 Структура.
5.3 Локальная адресация.
5.4 Вопрос NULL.
5.5 Межмодульная адресация.
5.6 Абстрактная реализация.
5.7 Конкретная реализация.
6. Унифицированная модель многозадачности.
7. Заключение.
Текст представляет интерес для программистов, использующих языки С/С++ или подобные им по возможностям. Рассматриваются вопросы написания переносимого модульного программного обеспечения с использованием аппаратных возможностей существующих (2004- 2 014 годы ) процессоров (защиты и т.п.), применение таких методов для ПО с открытым исходным кодом.
Введение.
1. Рассматривая историческое развитие IBM PC совместимых ПК за 30 лет их существования, можно сказать, что помимо появления большого количества технических улучшений, современные ПК (2004-2014 годы) заметно отличаются от своих предшественников крайне низким качеством программного обеспечения, а именно, ПК содержат в себе значительное количество программных компонентов, которые не работают должным образом и не могут быть исправлены.
Проблемы сосредоточены главным образом в ПО для устройств и контроллеров на базе микропроцессоров, в так называемом firmware. Экономическая причина "проблемы firmware" в том, что производитель реально не имеет обязанностей по качеству работы своих изделий, но при этом имеет права, вытекающие из авторства над этими изделиями. Техническая причина "проблемы firmware" в том, что для реализации своих авторских прав, для недопущения нелегального производства отдельных компонент своих изделий и пр., производитель отказывается от открытых интерфейсов и открытых реализаций для своих продуктов.
Опыт работы показывает, что firmware применимо только к монолитным системам, которые не предусматривают произвольное изменение конфигурации, добавления новых устройств и т.п., потому что производитель может выполнить подбор и настройку firmware сам. Примерами хороших монолитных систем являются мобильные телефоны и прочие современные устройства, которые добавляют к телефону разные опции - камеры, дисплеи и т.п.
Монолитные системы выгодны производителям-монополистам, но монолитность подразумевает ограниченный функционал системы. Проблема даже не столько в том, что функционал ограничен, сколько в том, что функционал жестко предопределен при производстве. Монолитные системы хорошо подходят для создания таких устройств как телефоны, но плохо подходят для создания таких устройств как ПК.
Почему же тогда в современных монолитных системах есть возможность выполнять многие действия, которые традиционно есть в ПК? Это потому, что программное обеспечение позволяет менять конфигурацию "исходного телефона" в достаточно широких пределах, но возможности программы все равно ограничены исходными аппаратными ресурсами.
Можно ли построить "монолитный ПК"? Нет, но можно построить монолитное устройство с фиксированным, но достаточно широким функционалом, который подойдет для "домашнего использования", для "офисного использования" и т.п. Такие монолитные устройства (не будучи мобильными) появились давно и одновременно с ПК, но исторический факт популярности ПК по отношению к таким устройствам показывает, что добровольно пользователи монолитные устройства не выбирают и покупают их в основном тогда, когда у них нет иного выбора.
реклама
Итак, производители решили отказаться от ПК в пользу монолитных устройств, останутся ли у ПК приверженцы? Безусловно, все дело в том, что ПК это не бытовое устройство, у ПК всегда был и до сих пор есть вполне определенный сегмент потребителей - радиолюбители, студенты, инженеры и т.п., для них фиксированная конфигурация аппаратуры может быть совершенно недопустимой. У них свои задачи, которых просто нет у домохозяек или у офисных работников.
Я считаю, что у монолитных устройств есть очень хорошие применения не только в качестве телефонов, поскольку монолитность позволяет удешевить продукт, функциональность которого хорошо определена; но монолитность повсеместно вытесняющая ПК из "бытовых компьютеров" не даст возможность интегрировать разные устройства в одну систему, находить новые применения для ПК, что в конечном итоге замедлит развитие компьютерной техники.
2. Сам по себе "ПК с открытой архитектурой" не может возникнуть из хаотических усилий разных производителей. Для такого ПК нужен некий базовый проект, основа. Такой основой для ПК был IBM PC, пока не исчерпались возможности его архитектуры. Новой основы создано не было.
Как мы уже выяснили, идеология firmware неприменима для построения ПК с открытой архитектурой, заместо firmware такие ПК должны применять ПО как минимум с открытыми интерфейсами, но производители это делать отказываются, производителей мало и рыночный механизм также не работает (иногда мне кажется, что я вообще не знаю примеров, когда бы в наше время работал "рыночный механизм").
Как и для случая аппаратной архитектуры ПК, "ПО с открытыми интерфейсами" не может возникнуть самопроизвольно, также необходимо выполнять организованную, целенаправленную работу по проектированию интерфейсов.
Всем известно, что помимо коммерческого ПО, существует бесплатное ПО с открытым исходным кодом. Может ли такое ПО быть заменой для идеологии firmware? Рассматривая существующие продукты с открытым исходным кодом, мы можем отметить ряд проблем такого ПО.
2.а) Концептуально, неисправимо, разработчик ПО с открытым исходным кодом не имеет коммерческого интереса, что в целом отрицательно сказывается на результате. Также есть случаи, когда ПО с открытым исходным кодом используется прямо или косвенно для последующей коммерческой деятельности. Такое ПО как правило сочетает в себе худшие качества firmware и ПО с открытым исходным кодом.
2.б) ПО с открытым исходным кодом требует повышенной культуры программирования, может быть даже иной, по сравнению с коммерческим ПО, технологии программирования. Почему?
реклама
Представьте себе автомобиль размерами 2х3 метра. Чтобы построить такой автомобиль надо иметь огромные заводы, с огромными цехами, где грудами навалены материалы и трудится огромное число людей. Коммерческая программа построена именно так. Производитель на своих мощностях делает автомобиль и не поставляет с ним ничего лишнего.
ПО с открытым исходным кодом вместо автомобиля поставляет вам даже не разобранный автомобиль, вам поставляется весь завод, поэтому чтобы построить такой автомобиль вам надо развернуть завод, на котором затем будет произведен автомобиль. Какой технологический уровень нужен для такого завода, чтобы ему уместиться на площади хотя бы в 100х100 метров?
Как вы понимаете, это просто невозможно, поэтому ПО с открытым исходным кодом должно чем то жертвовать по сравнению с коммерческим софтом. Одна из жертв это необходимость повторного использования кода. Говорят, что многие разные программы внутри совершенно идентичны и писать каждую программу с нуля это переливать из пустого в порожнее, поэтому написав один элемент кода его затем можно использовать многократно.
Использовать многократно это легче сказать, чем сделать. Технологии ООП и паттерны проектирования это как раз то, что призвано для эффективного повторного использования кода. В том виде, как они стали известны широкой публике, это технологии 2000 года, до этого времени такие технологии даже не обсуждались к практическому применению.
Кроме повторного использования элементов кода, при смене аппаратной части готовый программный продукт повторно используется целиком. Это свойство называют переносимостью или портируемостью.
2.в) Коммерческое ПО обычно объявляется "не портируемым", но это не совсем правда, поскольку такое ПО абсолютно переносимо на уровне своего интерфейса, но этот интерфейс работает на уровне процессора, а не на уровне человека. Коммерческое ПО поставляется обычно в виде исполнимого файла. Почему? Потому что этой информации абсолютно достаточно для "портируемости" на уровне процессора.
В свою очередь ПО с открытым исходным кодом обычно объявляется "портируемым" и поставляется обычно в виде исходных файлов. Достаточно ли такой информации для человека? Нет. Безусловно, человек может вносить некоторые изменения в исходные файлы, но для того чтобы эффективно и осмысленно "портировать" программу из одной системы в другую (выполнять действия, которые на своем уровне совместимости эффективно делает процессор), человеку нужна проектная документация на программу.
Можно сказать, что ПО с открытым исходным кодом должно на 90% состоять из хорошей проектной документации и на 10% из хорошего исходного кода, документация и ПО с открытым исходным кодом это вещи совершенно неразрывные, вместе они являются аналогом исполнимого файла для уровня совместимости процессора.
Проектная документация должна описать структуру ПО, предназначение компонент и интерфейсов, причины принятия проектных решений и содержать всю остальную необходимую информацию, нужную для внесения изменений в эту структуру. В случае работы с конкретной периферией проектная документация должна содержать описание интерфейса работы с данной аппаратурой.
Без проектной документации ПО с открытым исходным кодом это просто вариант откомпилированного машинного языка, аналогично коммерческому ПО, но не для реального, а для некоего виртуального процессора такого типа, что человек может вносить изменения в код, а процессор автоматически правильно пересчитает смещения для переходов на не изменявшийся код.
2.г) Реальный процессор имеет довольно примитивные команды, которые одинаково исполняются на всех совместимых процессорах, а "виртуальный процессор" уровня человека имеет "команды", которые на всех "совместимых" процессорах выполняются непредсказуемо по разному.
Чтобы устранить такую проблему для ПО с открытым исходным кодом требуется очень хорошо определенная "виртуальная машина уровня человека", которая будет выполняться на всех реальных процессорах абсолютно одинаково. Эти требования реализует язык программирования. Как только мы используем язык программирования, мы имеем дело с виртуальной машиной.
2.д) Проблема виртуальной машины не только в том, что ее надо определить, но и в том, что любая виртуальная машина ухудшает качество работы программы по сравнению с программой, которая пользуется всеми возможностями реальной системы.
Требования при определении виртуальной машины взаимоисключающие: улучшение портируемости обязательно ведет к ухудшению эффективности на конкретном процессоре. Если бы это было не так, то все процессоры были бы абсолютно совместимы между собой.
Исторически эта проблема решается так. Вместо того, чтобы выбрать виртуальную машину в виде 50% совместимости и 50% эффективности, в программе выделяются абсолютно портируемая и абсолютно не портируемая части. Первая часть реализуется на 100% совместимой виртуальной машине, а вторая часть реализуется на языке на 100% приближенном к конкретной системе, так что первая часть так или иначе использует вторую часть.
реклама
В качестве языка второй части часто используется макроассемблер или язык С с макроассемблерными вставками. Роль языка С заключается не в обеспечении портируемости, а в предоставлении операторов структурного языка, фреймов функций и т.п. "макро деятельности". Код второй части имеет портируемым только интерфейс, реализацию второй части, несмотря на применение языка С, на другую систему перенести нельзя.
2.е) В качестве языка первой части (виртуальной машины) часто также используется язык С или С++. Вот тут и начинаются хорошо известные проблемы. С одной стороны, взять совсем абстрактную виртуальную машину (типа Java) нельзя из соображений эффективности, с другой стороны, эффективный язык С и С++ не имеет хорошо определенной абстрактной машины, поэтому есть риск, что код первой части будет перемешан с кодом второй части, визуально это все трудно будет отличить, в результате программа откажется переноситься на другую платформу или нормально на ней работать по совершенно неясной причине.
У языка типа JAVA есть хорошие применения даже в системе с хорошо определенными открытыми интерфейсами, например, пользовательская настройка расширенных параметров сетевых устройств: с помощью стандартного интерфейса можно настроить стандартные параметры маршрутизатора, но стандартизировать "тонкие" настройки конкретного устройства невозможно, чтобы задействовать особенности конкретного сетевого устройства нужно выполнять уникальный для этого устройства код на компьютере с неизвестной архитектурой. Для таких целей язык типа JAVA подходит, для обычной работы язык типа JAVA подходит плохо.
Традиционное решение этих проблем также известно. Языки С/С++ не совсем безнадежны в плане абстракции, при их небольших модификациях, а также при выдержке программистом определенного стиля программирования, можно получить некую абстрактную машину для кода первой части.
Применяя С/С++ в первой части мы пытаемся построить 100% совместимую абстрактную машину так, чтобы в рамках этой машины программа была бы на 100% эффективной на каждом конкретном процессоре.
Это кажется возможным, например, абстрактная машина С может иметь оператор целочисленного умножения. На процессорах, где такой команды нет, этот оператор будет эмулироваться, но эффективность, при необходимости умножать, у абстрактной машины не станет хуже (виноват будет конкретный процессор).
Можно догадаться, что "абстрактных машин С" может быть сколько угодно, в зависимости от потребностей в тех или иных абстрактных операторах, т.е. в зависимости от того, на какой примерно аппаратуре эта программа будет выполняться. Абстрактная машина для многоядерного суперкомпьютера не может быть такой же, как для контроллера дисковода.
Если абстрактных машин будет слишком много, то они станут "конкретными машинами" и программы потеряют совместимость, если же абстрактных машин будет слишком мало, то они станут неэффективными.
Я не готов предложить универсальную теорию о том, как правильно создавать абстрактные машины, но в этой работе мы рассмотрим требования к абстрактным машинам С для их исполнения на ПК периода 2004-2014 годов.
Размер байта.
Мне довольно трудно отойти от вступления и перейти к делу. Наверное продолжение я буду писать уже завтра. Как видите, написание текстов имеет свои тонкости, может быть вступления надо добавлять в конце работы над текстом.
Написание стандартов тоже имеет свои тонкости, о которых нам неизвестно, но слушая обычных сторонников стандартов языка С и С++, можно догадаться, что они верят, что стандарт на эти языки сделает программы не только компилируемыми, но и совместимыми с разными реальными системами. Это заблуждение происходит оттого, что такие сторонники часто не учитывают или даже не знают о "rationale", лежащем за тем или иным требованием стандарта.
Вообще, их отказ рассматривать проблемы языка применительно к действиям программиста сразу вызывает недоумение, ведь это стандарт для программиста, а не наоборот; нужно получить работающий код, а не соответствие стандарту. Виноват и стандарт, который на таких языках должен не только декларировать, но и указывать назначение тех или иных правил.
Вероятно у стандарта есть свои цели, его конечно можно менять и улучшать, но нам важно, что в описании нашей абстрактной машины С мы будем отступать от стандарта там, где это нужно для дела.
Стандарт языков С и С++ описывает некую абстрактную машину, характеристики этой машины говорят о том, что за основу был взят типовой компьютер 70-х годов. С тех пор утекло много воды, появилась новая аппаратура и было написано много кода, чтобы можно было оценить пользу тех или иных правил языка.
Давайте рассмотрим целые типы языка (POD): char, int и модификаторы short, long, unsigned. Типы выглядят красиво, не зависят от разрядности машины и программа их использующая, казалось бы, могла бы быть портируемой.
Но на деле они порождают неимоверное количество проблем, запечатленных в истории. Встретить эти проблемы можно в обработке текстовых файлов с буквой "я" на процессоре i286, в секции BIOS для обслуживания жесткого диска на процессоре i586 и в бесчисленном количестве приложений для всех типов процессоров, где переполнение или знак используются неправильно во время выполнения.
Причина в том, что эти типы в хорошей программе часто работают как вполне определенные регистры, которые характеризуются разрядностью и знаковостью, при этом программа учитывает все эффекты целочисленной арифметики с такими регистрами; в плохой программе эти типы (int в основном) используются как возглас "ну дайте хоть что-нибудь, где я сам не знаю что буду хранить".
Плохую программу, конечно, никак не исправить, а для хорошей я не могу вспомнить ни одного алгоритма, когда портируемой программе было бы все равно, какова разрядность целого типа, алгоритм всегда в явном виде учитывает разрядность и знаковость. В языке С обычные типы (по отношению к типам абстрактной машины С они "без суффикса") появились для языка второй части, они не предназначены для портирования, поэтому в нашей "абстрактной машине С" типы неизвестной разрядности не могут применяться. Для обозначения разрядности будем использовать суффикс "число бит".
Как же тогда быть с портируемостью? Например, мы задаем int64 и пытаемся исполнить нашу программу на 16 битном окружении. Ведь не будет работать. Конечно же не будет, только с именем "int64" вы это увидите уже во время программирования, а с именем "int" только во время выполнения.
Значит написать портируемую программу нельзя? Программу, которой нужен непрерывный массив размером 24бит эффективно исполнить на компьютере с общей памятью размером 16бит нельзя. Наоборот можно.
Для работы с переменными С памяти auto, программист вынужден заранее выбирать разрядность переменной, исходя из требований алгоритма работы. Значит суффикс может быть любым? Может, но лучше учесть и соображения эффективности.
Если выбрать суффикс, разрядность которого не соответствует разрядности процессора, с такой переменной невозможно будет эффективно работать одной командой процессора. Компилятор или класс С++ будут эмулировать операции с запрошенной программистом нестандартной разрядностью выполняя несколько команд процессора.
Сразу вспомним, что для алгоритма в общем недопустима смена заявленной программистом разрядности как в сторону повышения, так и в сторону понижения. Программа перестанет правильно работать, разрядность всегда жестко зашита в алгоритм. С этим ничего нельзя сделать, надо или использовать конкретные регистры, эффективные на данном типе процессора или использовать абстрактные типы, неэффективные везде.
Какую же именно разрядность выбрать для портируемой программы, учитывая соображение эффективности, после того как минимальная разрядность определена из алгоритма?
В языке С память это множество бит. В языке С есть зарезервированное слово "char". С одной стороны тип "char" это вариант int, который служит для хранения кодов символов; с другой стороны понятие "char" служит для обозначение особой группы из нескольких бит, такой группы, что
-
chars идут в памяти "друг за другом, без пропусков бит" (перебирая все chars можно перебрать все биты, без потерь); -
все типы состоят в памяти из целого числа chars; -
адресное пространство данного процессора измеряется в chars (указатель на любой тип содержит адрес, который является числом переменных типа char от адреса 0 до первого char в этой переменной, а не числом переменных данного типа от адреса 0); -
число бит в char не менее 8.
Требование минимума в 8 бит вероятно историческое, связанное с ASCII кодами и стандартной библиотекой С. В чем то char оказался даже основательней int, поскольку int можно было бы задать как typedef для long модификации типа char.
Сразу прокомментируем системы, для которых 8 бит это много. Для таких систем размеры кода и данных как правило тоже очень малы (это может быть пара килобайт или даже сотня байт), о портировании кода между системами здесь не приходится говорить. Усилия по написанию нового кода будут соизмеримы с усилиями по портированию старого, поэтому для таких небольших систем абстрактная машина С несовместима с современными многоядерными ПК. Если же совместимость все равно нужна, то 8 бит надо будет эмулировать.
На процессорах х86 "char" это будет "байт", а "байт" это историческая разрядность ALU и шины данных первых процессоров данной серии, так что система команд процессора позволяет в пределах системы команд предшествующего процессора эффективно работать с такой разрядностью (реальная разрядность ALU называется "машинным словом").
На процессорах х86 "байт" равен 8 битам. Также "байт" равный 8 битам используется повсеместно (для измерения количества памяти оперативной, кэша, диска), как внесистемная единица.
Ясно, что на процессорах х86 следует применять суффиксы 8,16,32,64. Они не потребуют эмуляции. Сомнения многих сразу вызовет суффикс 16. Я думаю, что если есть возможность использовать 16 бит данные, их надо использовать. Портируемая программа, не затребовавшая без нужды 32 битые данные, сможет работать в 16 битном окружении другого процессора более эффективно.
Проблемой являются процессоры, для которых char состоит из 9,12 и т.п. количества бит. Повторим, что в этом случае с эффективностью ничего нельзя поделать, алгоритм не может быть рассчитан на неопределенный размер регистра. Если ваша программа оригинально рассчитана на 16 битный аппаратный регистр, то при ее портировании на 18 битный аппаратный регистр потребуется эмуляция операций классом С++ или компилятором.
Чтобы понять как делать эмуляцию нестандартной разрядности, надо понять какие POD типы нам на деле нужны. А нужно их нам несколько и не всегда будут нужны именно регистры с их аппаратными особенностями арифметики.
Унифицированные целые типы.
Принято, что имена, которые в контексте исходного кода С начинаются с двойного подчеркивания "__" или с подчеркивания, за которым следует заглавная буква "_X" являются системными, заведомо непортируемыми именами, поэтому имена для абстрактной машины С не могут быть такими.
Для разрешения конфликта имен мы ограниченно применяем пространства имен С++. Ограничение на применение вызвано стремлением сохранить бинарность С для объявления имени, т.е. имя может быть объявлено только в локальном или только в глобальном контексте, это ограничение не требует задействовать механизм параметризации имен функций (этот механизм стандартным компилятором не поддерживается). Практически это требование в частности означает, что локальные функции не могут объявлять локальное использование глобальных идентификаторов из произвольных пространств имен, они могут объявлять только локальные имена, определенные только в этом локальном контексте, весь остальной интерфейс функции проходит через глобальный контекст или класс его замещающий.
Напомним, что нельзя написать программу, которая оперирует POD данными неопределенной разрядности с неопределенными свойствами; поэтому в портируемой программе надо явно задавать не только наличие знака, но и такие качества POD типа данных, как:
-
рабочая разрядность, -
разрядность в памяти (выравнивание), -
тип контроля арифметических операций.
Поэтому на замену int для абстрактной машины С мы вводим такие целые типы (классы):
-
[u]intXX, -
[u]regXX, -
bsXX;
где "XX" это число бит рабочей разрядности, "u" префикс беззнаковости, "int/reg/bs" тип контроля над арифметическими операциями и тип выравнивания в памяти. Рассмотрим каждый тип на примерах, отдельно и подробнее.
uint8
-
арифметическое беззнаковое целое число, т.е. тип предназначен для правильного выполнения арифметических операций, эти операции контролируются компилятором/классом; -
для работы требуется ровно 8 бит, может хранить числа 0x00-0xff; -
выравнивание в памяти не определено, может занимать больше 8 бит памяти.
Произвольное выравнивание в памяти означает, что несмотря на суффикс "8", для типа [u]intXX недопустима адресная арифметика как операции над char, все адресные операции надо проводить над "указателем на [u]intXX" (вид операции "++" в пересчете на char зависит от системы). Если под тип отведено более 8 бит, то корректное обнуление старших бит при необходимости должен выполнять компилятор/класс.
Тип предназначен для правильного выполнения арифметических операций над числами диапазона, заданного числом значащих бит, это означает, что операция 0xff + 0x01 не может завершиться правильно, поскольку результат выходит за пределы диапазона 8 бит. Этот тип работает именно с абстрактными математическими операциями, для которых не существует ни двоичного кода, ни насыщения, ни иных аппаратных сущностей процессоров.
Алгоритм, который затребовал тип [u]intXX, не только гарантирует, что на всех диапазонах входных данных проблем с переполнением возникнуть не может, но и не готов обнаруживать и обслуживать переполнения. На самом деле огромная часть реального кода использует знаковый тип int именно в таких целях, для хранения размеров, счетчиков и т.п. вещей при условии их непереполнения, к тому же еще и при их беззнаковости. На портирумых программах такие предположения часто заканчиваются плохо.
Это все происходит потому, что правильное использование типа программистом не продумывается. С этим нежеланием ничего нельзя поделать, кроме того чтобы во время выполнения контролировать правильность использования типа. Именно этим целям и служит тип [u]intXX. При возникновении знакового или беззнакового переполнения во время арифметических операций над этим типом генерируется исключение.
Особый комментарий для x86, система команд которого содержит аппаратное исключение для переполнения при выполнении команды div (деление на ноль). Это исключение даже позволяет выполнить рестарт сбойного участка кода. Вероятно в подобном поведении есть какая-то историческая причина, но мне она неизвестна. Для типа [u]intXX неудача подобной операции не отличается от неудачи в 0xff + 0x01 и означает выставление флагов CF/[OF] и вызов исключения.
Операции логические двоичные, а также сдвига и ротации бит определены также как и для регистрового типа, т.е. предполагается, что число хранится в двоичном прямом или дополнительном коде. Переполнения при сдвигах влево вызывают исключения.
Какова эффективность выполнения операций над типом [u]intXX в конкретной системе, можно ли использовать такой тип в своих портируемых программах? Этот тип арифметический, он не эмулирует точное поведение целочисленного регистра процессора, т.е. его возможности меньше чем у регистра, поэтому в конкретной системе, где есть подходящие регистры процессора, этот тип будет выполняться в них достаточно эффективно. Выполнение каждой арифметической операции контролируется, что в принципе дает код немного большего размера и немного более медленный, чем при использовании регистровых типов, конкретное ухудшение зависит от процессора. Этот тип позволяет убрать из кода явные проверки, но в коде, где генерация исключений недопустима, такой тип использовать нельзя.
ureg8
-
регистр для хранения беззнакового целого числа, т.е. интерпретация бит контролируется программистом, арифметические операции трактуются как беззнаковые; -
для работы требуется ровно 8 бит, может хранить числа 0x00-0xff; -
выравнивание в памяти не определено, может занимать больше 8 бит памяти.
Регистровый тип [u]regXX отличается от арифметического тем, что он эмулирует поведение традиционного целочисленного регистра ALU процессора: биты могут совсем не иметь арифметической интерпретации; арифметические операции выполняются в двоичном прямом и дополнительном коде, переполнение вызывает потерю старшего значащего разряда. Библиотечными функциями типа "macs" над регистровыми типами возможна арифметика с насыщением. Определены логические битовые операции. Исключения при переполнениях (включая деление на ноль) не генерируются.
Для достаточно низкоуровневых программ этот тип данных основной, он позволяет программисту контролировать пределы и диапазоны только в нужных местах, но явным образом.
bs8
-
битовый набор ровно из 8 бит, интерпретация бит контролируется программистом, арифметические операции не допускаются; -
для работы требуется ровно 8 бит, может хранить числа 0x00-0xff; -
выравнивание в памяти нулевое (без пропусков бит), при этом в памяти занимается ровно 8 бит.
Битовый набор это тип для системно-независимого описания двоичных данных. Такие типы применяются в двоичных файлах, при передаче данных по сети и т.п.
Над типом определены операции логические двоичные, сдвига и ротации бит. Значение такого типа может быть передано в регистровый тип и наоборот.
Такой тип почти на всех конкретных системах будет работать неэффективно, поскольку битовое выравнивание никак не согласовано с размером char конкретной системы, однако применяется он довольно широко, например, при считывании заголовка двоичного файла в память одним блоком, поля заголовка будут представлены в памяти именно типом bsXX.
Кроме абсолютно системно-независимого bsXX, в абстрактной машине С для портируемой программы используется и абсолютно системно-зависимый тип char, для которого битовый размер не определен. Нет ли здесь противоречия с предыдущими утверждениями, приведшими нас к необходимости заменить int?
Противоречий нет, char нужен конечно, не для того чтобы хранить в нем коды символов (ASCII коды можно хранить в типе ureg8), а для того чтобы перебирать память без пропуска битов.
Потому для абстрактной машины С мы вводим такие целые типы char (классы):
-
[u]char;
где "u" префикс беззнаковости, "char" тип контроля над арифметическими операциями и тип выравнивания в памяти. Пример.
char
-
битовый набор неопределенного размера (из 8 или более бит), интерпретация бит контролируется программистом, арифметические операции трактуются как знаковые; -
для работы требуется 8 или более бит, может хранить числа 0x00-0xff или более; -
выравнивание в памяти нулевое (без пропусков бит).
Типичное применение char выглядит так "memset(&p,0,sizeof(p))". Ноль в этой функции имеет тип char, а "sizeof(p)" возвращает число chars в типе переменной "p".
Помимо использования в качестве инструмента для доступа к памяти, над типом char можно выполнять логические и арифметические операции также как над регистровым типом, но требуется помнить, что битовый размер типа char произвольный, при этом с одной стороны для типа char нет контроля переполнения, как для типа intXX, с другой стороны, для типа char нет эмуляции аппаратного регистра, как для типа regXX (эмуляция есть, но неизвестно на каком бите). В арифметико-логическом смысле тип char объединяет в себе худшие качества арифметического и регистрового унифицированного типа.
Итак, программа которая использует только унифицированные типы данных абстрактной машины С, на всех конкретных машинах будет работать совершенно правильно и не потребует специальной перепроверки алгоритма. Это очень большое достижение, значит не зря столько лет развивались компьютерные технологии.
Старый код С, который не использует унифицированные типы, не может быть перенесен на абстрактную машину С без переделок.
А теперь подумаем, могут ли современные стандартные компиляторы позволить нам использовать унифицированные типы данных? Для работы с этими типами нам понадобится или нестандартный компилятор С, который прямо понимает эти типы, или нам понадобятся классы С++. Несмотря на все недостатки классов С++, для создания вариаций POD типов, возможностей этих классов и семантики копирования будет вполне достаточно.
А еще можно получить портируемую программу на стандартном компиляторе и совсем ничего для этого не делать. Если вы хотите написать портируемую программу, которая сразу будет выполняться на вполне конкретном окружении, то можно использовать унифицированные регистровые типы заданные через typedef, а созданием классов для этих регистровых типов на несовместимых окружениях будет заниматься тот, кто будет туда портировать вашу программу. Примеры задания регистровых типов через typedef.
namespace Nacm16{}
//для 16 битного окружения x86
typedef unsigned char ureg8;
typedef unsigned int ureg16;
typedef unsigned long ureg32;
typedef char reg8;
typedef int reg16;
typedef long reg32;
typedef unsigned char uchar;
namespace Nacm32{}
//для 32 битного окружения x86
typedef unsigned char ureg8;
typedef unsigned short ureg16;
typedef unsigned int ureg32;
typedef char reg8;
typedef short reg16;
typedef int reg32;
typedef unsigned char uchar;
Как видим, программа пользующаяся этими унифицированными типами сможет работать на 16 и 32 битном окружении. Если программист необоснованно не запрашивает данные размером 32 бита, то алгоритмы, оригинально написанные для исполнения на 32 битном окружении, но способные отработать на 16 битном окружении, будут работать на 16 битном окружении также эффективно.
Казалось бы, сегодня 16 битное окружение можно найти разве что в некоторых мобильных устройствах, периферийных устройствах и контроллерах, но принцип, лежащий в основе несовместимости между 16 и 32 битами, точно такой же, какой лежит в основе несовместимости между 32 и 64 битами. Все, что сказано для границы 16 бит остается абсолютно справедливо и для границы в 32 бита и для любой другой границы.
Сегменты и плоская модель памяти.
Язык С предполагает использование плоской модели памяти. Это значит, что все указатели в программе ссылаются на одну и ту же физическую память, а различие областей этой памяти существует только логически. На компьютерах 70-х годов прошлого века, во времена создания языка С, такая модель имела свои преимущества.
К 80-м годам прошлого века идея модульного программирования была вполне сформирована, модульные программы признавались хорошо структурированными и надежными по сравнению с программами без модулей, но в общем требовали более сложной аппаратуры для своей работы. Существуют реальные процессоры, та же архитектура х86, которые поддерживают модульность программ аппаратно.
Модуль выделяет из общего адресного пространства область памяти "реализации", при этом эта область должна быть недоступна из остальных частей программы, модульные программы различают внутреннюю и внешнюю адресацию, для них не существует "универсального указателя" плоской модели памяти.
Логически модульная программа разделяет память на сегменты, поэтому такая программа если и не требует аппаратной защиты для сегментов, то точно может нормально работать на аппаратуре с сегментами.
Аппаратные сегменты, в той или иной их реализации, это аппаратная поддержка логически модульной структуры программы, поддержка такой технологии программирования, которая требует раздельных адресных пространств для интерфейса и реализации модуля.
Без аппаратной поддержки сегментов написать надежную систему, в которой одновременно выполняется много разных модулей, нельзя.
Унифицированное адресное пространство, размер.
Портируемая программа нуждается не только в унифицированных целых типах, но и в унифицированном адресном пространстве. Проблема тут та же самая, что была и для типов - невозможно написать программу, адресное пространство которой имеет неизвестные на момент написания размеры, структуру и свойства.
Повторим, что в хорошо структурированных программах плоская модель памяти не очень полезна. В данном случае возможность портируемости программы не потребует отказа от более качественной технологии программирования, поэтому современная абстрактная машина С не может не поддерживать технологию модульного программирования.
Логически, модульная программа разделяет память на сегменты, которые, в той или иной их аппаратной реализации, являются независимыми, изолированными друг от друга адресными пространствами модулей; поэтому программист должен задавать не размер адресного пространства вообще, а размер конкретного адресного пространства конкретного модуля, а модуль может представлять собой компилируемый файл, класс, отдельную функцию или блок данных.
Также как это было для целых типов, только от программиста зависит обоснованность выбора в каждом случае размера требуемого адресного пространства, чем он выбран меньше, тем лучше портируемая программа будет исполняться на конкретных машинах с аппаратным адресным пространством малого размера.
В результате, унифицированное адресное пространство абстрактной машины С всегда описывается парой {сегмент(селектор):смещение(указатель)}. Эта пара и есть адрес.
В абстрактной машине С "адрес" это не то же самое, что "указатель", также несмотря на применение терминов из архитектуры x86, по другому компоненты унифицированного адреса не назовешь, а "селектор" и "сегмент" не относятся к сегментным регистрам или дескрипторным таблицам процессоров x86, вся похожесть получается только оттого, что архитектура x86 имеет элементы аппаратной поддержки модулей.
Оба компонента адресной пары независимо друг от друга виртуализируются подобно унифицированным целым типам, в первом приближении, это абстрактные типы (классы):
-
selXX, -
ptrXX;
где XX это число бит разрядности адресного пространства. Таким образом, программист может задавать размер адресного пространства модуля через выбор подходящих типов selXX, ptrXX.
В первом приближении, для типов selXX, ptrXX:
-
в пределах модуля, адресное пространство модуля описывается только указателем (селектор устанавливается скрыто при вызове метода модуля) и код модуля при внутренней адресации имеет дело с традиционной для С плоской моделью памяти; -
в пределах межмодульных взаимодействий, адресное пространство описывается обоими компонентами; тот факт, что адрес описывается обоими компонентами не означает, что такой адрес, будучи "полным", имеет смысл за пределами модуля (например, два разных модуля могу работать с одинаковыми в числовом выражении "полными" адресами, которые описывают совсем разные физические сегменты памяти), для передачи адресов между модулями надо дополнительно предпринимать специальные усилия, основой для этих усилий служат оба компонента адреса.
Унифицированное адресное пространство, структура.
Помимо того, что унифицированное адресное пространство можно охарактеризовать размерами сегментов модулей, оно имеет довольно сложную логическую структуру.
На схеме унифицированного адресного пространства, деление на сегменты принадлежащие модулям представляются горизонтальными линиями (модули образуют вертикальные связи), а вертикальные линии в пределах каждого модуля разделяют логические сегменты модуля (логическая структура модуля образует горизонтальные связи).
Сложность логической структуры обусловлена несовместимостью операций, которые можно совершать над разными логическими сегментами, эта разница дает нам возможность аппаратного контроля правильности таких операций, а наличие аппаратного контроля делает систему более устойчивой к сбоям отдельных модулей. Поддержка аппаратного контроля современных процессоров это то, что мы хотим добавить в абстрактную машину С.
Хорошо известно логическое деление на сегменты кода и данных: данные нельзя исполнять и т.п. Аппаратно подобное деление уже во времена создания языка С поддерживалось "битом защиты от выполнения". Еще хорошо известно, что данные логически можно разделять на константные и обычные: в константные данные нельзя записать. И также уже во времена создания языка С, подобное деление аппаратно поддерживалось "битом защиты от записи".
Для приведенных примеров в модуле есть уже три логических сегмента: кода, констант и данных. Теперь ясно, что представление адресного пространства модуля как плоской модели памяти с указателем типа ptrXX, которое было найдено в первом приближении, не может быть использовано на практике.
Унифицированное адресное пространство, локальная адресация.
В абстрактной машине С для портируемого приложения не существует "указателя указывающего неизвестно куда" (конструкция типа "char * ptr" недопустима), все указатели постоянно связаны с логическим сегментом, в пределах которого они имеют смысл (для пользователей архитектуры x86 подобная связь знакома по конструкциям типа "char *__ds ptr"). В пределах логического сегмента модель памяти плоская.
Старый код С, который использует "указатели указывающие неизвестно куда", не может быть перенесен на абстрактную машину С без переделок.
Для указателя такая связь с логическим сегментом называется "storage" (по отношению к объекту, на который указывают, а не к самой переменной указателя). Эта связь в чем-то аналогична формату вызова функции ("linkage") и в отечественной транскрипции в обоих случаях часто говорят о "линковке".
В абстрактной машине С, такой storage указателя называется "тип памяти" (тип логического сегмента, с которым он связан) и тип памяти неразрывен со значком объявления указателя "*", объявить указатель без типа памяти "*void" нельзя, также как нельзя объявить переменную типа "void". В свою очередь, типизированный памятью указатель уже ссылается на "тип данных", так же как указатель в С.
В абстрактной машине С тип памяти указателя это не только название, а полноценный тип, такой же как тип данных; можно сказать, что указатель всегда двойной: сначала он ссылается на логический сегмент, а внутри этого сегмента ссылается на тип данных. При этом выражение "типизированный указатель" продолжает применяться только по отношению к типу данных.
Как и переменные разных типов данных, указатели на разные типы памяти несовместимы между собой по присваиванию и сравнению, потому что в общем случае указывают на физически разную память. Конечно, язык С позволяет в принципе проинициализировать указатель произвольным числом, в том числе взяв это число от указателя на другой тип памяти, но автоматически такая операция не выполняется.
Концептуально, использование типизации памяти вместо "обычного указателя" это такой же плюс, как использование типизации данных вместо "обычного указателя на void". Хотя для работы с любыми данными можно написать только одну функцию, принимающую параметр "void*", так стараются не делать, чтобы иметь контроль типа во время компиляции.
С каждым логическим сегментом, прозрачно для указателей на этот сегмент, связан свой селектор. Именно поэтому полные адреса сами по себе непригодны для межмодульного обмена, поскольку собственное адресное пространство модуля описывается полными адресами его логических сегментов.
Вопрос, а сколько типов логических сегментов может быть в программе всего? Ответ, типов логических сегментов и аппаратного контроля над ними может быть неограниченно много, но из всего этого многообразия мы вполне можем выбрать некий разумный набор, который соответствует развитию реальных процессоров в настоящее время.
Задача минимизации при выборе компонент этого набора не ставится, поскольку на конкретной машине все многообразие логических сегментов может отображаться на единственную аппаратную плоскую модель памяти, никакой аппаратной защиты для логических сегментов при этом не будет, но программа, рассчитанная на абстрактную машину С, будет работать и на плоской модели памяти, наоборот нет.
Чем лучше конкретная машина (процессор), тем больше аппаратной защиты можно будет задействовать для абстрактной машины С.
Для того, чтобы перечислить логические сегменты абстрактной машины С, нам необходимо перечислить сущности, которые действуют в современном исполнимом окружении. Эти сущности прямо связаны с модульной структурой программы и многозадачностью: система, процесс, нить и модуль.
Попытки реализаций потребностей этих сущностей показали нам список необходимых логических сегментов унифицированного адресного пространства, это типы (классы):
основные сегменты кода
ptrXX_f (function)
основные сегменты данных
ptrXX_d (proc_data)
ptrXX_h (proc_heap)
ptrXX_x (proc_heap_array)
ptrXX_ud (proc_unit_data)
ptrXX_uh (proc_unit_heap)
ptrXX_ux (proc_unit_heap_array)
ptrXX_td (thread_data)
ptrXX_th (thread_heap)
ptrXX_tx (thread_array)
ptrXX_ld (thread_unit_data)
ptrXX_lh (thread_unit_heap)
ptrXX_lx (thread_unit_heap_array)
ptrXX_a (current_auto)
ptrXX_ax (current_auto_array)
ptrXX_b (thread_auto)
ptrXX_bx (thread_auto_array)
константные основные сегменты данных
ptrXX_D (const proc_data)
ptrXX_H (const proc_heap)
ptrXX_X (const proc_heap_array)
ptrXX_UD (const proc_unit_data)
ptrXX_UH (const proc_unit_heap)
ptrXX_UX (const proc_unit_heap_array)
ptrXX_TD (const thread_data)
ptrXX_TH (const thread_heap)
ptrXX_TX (const thread_array)
ptrXX_LD (const thread_unit_data)
ptrXX_LH (const thread_unit_heap)
ptrXX_LX (const thread_unit_array)
ptrXX_A (const auto)
ptrXX_AX (const auto_array)
ptrXX_B (const thread_auto)
ptrXX_BX (const thread_auto_array)
логические сегменты (не связаные с аппаратной защитой)
ptrXX_s (current stack)
ptrXX_S (const current stack)
ptrXX_z (thread B stack)
ptrXX_Z (const thread B stack)
ptrXX_g (global (thread) B stack)
ptrXX_G (const global (thread) B stack)
ptrXX_W (const code_wide_data)
ptrXX_sx (stack_array)
ptrXX_SX (const stack_array)
ptrXX_zx (stack_B_array)
ptrXX_ZX (const stack_B_array)
ptrXX_gx (global stack_array)
ptrXX_GX (const global stack_array)
ptrXX_WX (code_wide_data_array)
ptrXX_i (указатель на данные объекта (тот же тип как у this), для шаблонной реализации методов класса)
ptrXX_I (const вариант)
Всего удалось найти около 50 различных логических сегментов, применяемых в унифицированном адресном пространстве, но на самом деле их общее большое количество связано только с комбинацией пяти основных типов сегментов {f,d,a,t,u}.
Однажды меня спросили, а правда ли, что вы хотели добавить новое ключевое слово? На что я ответил, что нет, это неправда, я хотел добавить около 25 новых слов, но сейчас мы уже перешагнули психологический рубеж в 50 новых слов.
Такое большое число аппаратных типов защит не обязательно есть в каждом конкретном процессоре, но в портируемой программе нельзя заранее предсказать какие из защит будут, а какие не будут действовать, это зависит только от конкретной машины, на которой программа будет выполняться. Тем не менее, логически это 50 совершенно несовместимых между собой физических сегментов и указатели на них также несовместимы между собой уже на этапе компиляции.
Наличие 50 разных сегментов требует от программиста продумывать размещение данных. Подробнее эти типы рассмотрены далее, в разделе "реализация".
Унифицированное адресное пространство, вопрос NULL.
Численное значение указателя (адрес) в абстрактной машине С это беззнаковое целое число от 0 до максимального значения, которое зависит от числа бит адресного пространства.
Каждый тип памяти может иметь свой собственный зарезервированный под NULL адрес указателя, доступ к которому аппаратно защищен. Целое число 0 в абстрактной машине С нельзя использовать для проверки на NULL, все операции сравнения С указателей должны быть адаптированы под типизированный памятью NULL:
определение NULL
#define nullXY (0x...U) //null16f, null16d...
сравнение указателя на NULL
!p//требует поддержку компилятора для С указателей
p!=nullXY
возврат NULL
return nullXY;
Допустимо сравнение указателя с беззнаковым целым числом операциями "==", "!=", "<", ">", "<=", ">="; в том числе со значением nullXY для проверки на null операциями "==", "!=".
Автоматически преобразовать численное значение указателя в целое нельзя, иначе указатели разных типов можно будет сравнивать между собой и не будет работать сравнение на null (nullXY численно не совпадает с 0). Для извлечения численного значения указателя используется операция get_ptr().
При сравнении указателя на null, заместо "шаблонного null", который бы автоматически подставлял правильный nullXY в зависимости от типа указателя, используется операция "преобразование в bool" if(p) и операция "!" if(!p). В компиляторе, в котором типа bool нет, его функцию выполняет тип char.
Автоматически преобразовать численное значение указателя в bool тоже нельзя, иначе указатели разных типов можно будет сравнивать между собой, а сравнение указателя операцией "!" или операцией "преобразование в bool" больше не совпадает с таким сравнением для численного значения указателя как обычного целого числа.
Операция "преобразование в bool" для указателя логическая, т.е. в итоге указатель преобразуется в bool так или иначе, но технически, для класса С++ представляющего указатель, эта операция реализуется с использованием автоматического преобразования к промежуточному типу, уникальному для указателя на каждый тип памяти, так что указатели разных типов памяти не смогут быть сравнены между собой, например, преобразование к фиктивному указателю С на промежуточный тип с NULL равным 0:
class ptrXY{
private: class Tcmp{};
public: operator Tcmp _mXY* ()const
{return (Tcmp _mXY*)((offs==nullXY)? 0: 1);}
};
Далее по тексту будет перечислен интерфейс класса С++, использующегося в качестве указателя.
Унифицированное адресное пространство, межмодульная адресация.
Тип (класс) межмодульного адреса имеет вид:
adrXXptYY
где "XX" и "YY" это битовый размер селектора и указателя соответственно; "p" это префикс области определения селектора (контекста); а "t" это тип памяти селектора.
Как уже ранее говорилось, для межмодульной адресации приходится применять специальные усилия, чтобы сделать адрес осмысленным за пределами модуля. В перечисленном выше многозадачном окружении абстрактной машины С этих "пределов за модулем" больше одного, а именно:
p|<none> (proc) контекст процесса
u (proc_unit) контекст процесса модуля
t (thread) контекст нити
l (thread_unit) контекст нити модуля (не что иное, как упрощенный по числу типов локальный указатель)
q (sys) глобальный IPC (в ОС с "горизонтальной" защитой в пределах уровня привилегий практически не применяется)
Типы сегментов памяти при межмодульной адресации немного отличаются от типов локальных селекторов, это типы (классы):
основные межмодульные типы адреса
f (function)
d (data)
D (const data)
логические межмодульные типы адреса (не связано с аппаратной защитой)
h (heap)
H (const heap)
x (heap array)
X (const heap array)
Подробнее типы полных адресов рассмотрены далее, в разделе "реализация".
Унифицированное адресное пространство, абстрактная реализация.
Чтобы не откладывать, сразу полные адреса и рассмотрим. Некоторые их свойства.
Пара {selXX:ptrXX} как адрес аналогична комплексному числу, для которого определена только операция сравнения на равно.
Тип "d" это статические данные, тип "D" это статические константы, а тип "f" это код. Все как в старые, добрые времена, только указатель на статические данные (статическую переменную) не может иметь тип "f" и код всегда защищен от записи.
Типы "h" и "x" используются для указания на динамические данные С. Разница между ними в том, что через указатель типа "x" можно во время выполнения получить размер динамического массива С (структуры данных в памяти "h" и "x" не совпадают). Выделять и уничтожать межмодульные динамические данные можно только средствами IPC (вид IPC зависит от контекста адреса).
Типы "H" и "X" указывают на константные динамические данные С. Константность адреса можно повысить (преобразовать из "d" в "D" и т.п.), но выполнить обратную операцию нельзя. Хотя С всегда позволяет это сделать обходными путями, никакого смысла в этом нет, поскольку данные могут быть защищены от записи аппаратно.
Контекст полного адреса (префикс) зависит от контекста его селектора. Приведем пример простейшей реализации на совершенно обычном компиляторе С++ портируемых 16 битных селекторов абстрактной машины С, прямо совместимых с архитектурой х86:
/*
abstract address space
selectors
_IU address areas
p prefix <empty|p>utlq
t type fdhxDHX
*/
//**************
//abstract address space
//selector
////////////
//1
typedef ureg16 sel16;
////////////
//2
//C++ interfaces
//s16Y
//s16##p##t
//
#define DEF_s16_data(p,t)\
\
ureg16 sel;
//
#define DEF_s16_ctor(p,t)\
\
s16##p##t(){}\
s16##p##t(ureg16 const val):sel(val){}\
\
inline \
s16##p##t&\
operator=(ureg16 const val){\
sel=val;\
return *this;\
}\
\
inline \
s16##p##t&\
set_sel(ureg16 const val){\
sel=val;\
return *this;\
}
//
#define DEF_s16_access(p,t)\
\
inline \
ureg16\
get_sel()const {\
return sel;\
}
//
#define DEF_s16_compar(p,t)\
\
class sel16sel{};\
\
typedef \
sel16sel * \
t_sel16sel_ptr; \
\
inline \
operator t_sel16sel_ptr ()const {\
return ((t_sel16sel_ptr)sel);\
}
//
//base interface
#define DEF_s16_base(p,t)\
protected:\
DEF_s16_data(p,t)\
\
public:\
DEF_s16_ctor(p,t)\
\
public:\
DEF_s16_access(p,t)\
\
public:\
DEF_s16_compar(p,t)
//
//classes
#define DEF_s16(p,t)\
class s16##p##t{\
\
DEF_s16_base(p,t)\
};
//
DEF_s16(,f)
DEF_s16(,d)
...
//
#undef DEF_s16_data
#undef DEF_s16_ctor
#undef DEF_s16_access
#undef DEF_s16_compar
#undef DEF_s16_base
#undef DEF_s16
//
Как видим все эти 50 типов не будут особенно сложны в простых реализациях.
Теперь о некоторых свойствах указателей. Указатель типа "f" это указатель на код модуля, как и для межмодульной адресации, те же самые свойства.
Указатели на данные "dhx" принадлежат разным контекстам. Процесс, нить и модуль втроем образуют четыре раздельных контекста (каждый имеет свой <префикс>), два из этих контекстов это старые добрые "процесс" <none> и "нить" <t>, которые являются общими для всех модулей процесса/нити; два других контекста это опять "процесс" <u> и "нить" <l>, но они уже принадлежат только одному модулю; т.е. модуль разделяет контексты "процесс" и "нить" на две части.
Статические данные могут размещаться в этих сегментах с помощью linkage "static" или "_dd", "_du", "_dt", "_dl" (при совместимом компиляторе).
Динамические данные выделяются с помощью библиотечных вызовов типа new/delete. Наличие разных динамических сегментов улучшает в общем неэффективное управление динамической памятью.
Константные сегменты опять кодируются большими буквами. Правила преобразования константности те же самые, что и для межмодульной адресации.
Стек данных образует отдельный сегмент. Модульная программа подразумевает такое понятие, как "фрейм стека". Наличие фрейма стека означает, что каждая функция в общем случае имеет свой независимый сегмент стека, такой стек обозначается как "текущий", тип <a>.
Локальные переменные (storage "auto" или "_da"), объявленные в функции С, размещаются в этом стеке.
Для стека <a> есть своя, очень эффективно выделяемая динамическая память. Выделять память можно с помощью "alloca". Освобождается такая память тоже очень эффективно, но только при выходе из функции. Правильная поддержка alloca очень проста и зависит исключительно от компилятора.
Компилятор для архитектуры x86, который адресует через "?bp+offset" что-либо, кроме локальных переменных функции, такой поддержкой не обладает, несмотря на возможное наличие в библиотеке функции "alloca". Эта проблема вызвана исторически недостатком в системе команд x86, из-за которого возможности адресации через [?sp] ограничены, поэтому для работы с динамическими данными на стеке, компилятору надо использовать базовый регистр [?bx] или аналогичные приемы.
Помимо этого стека, как и в плоской модели памяти, с каждой нитью связан свой отдельный собственный стек, тип <b>. Этот стек является общим для всех модулей, его наличие позволяет модулям обмениваться между собой параметрами, а нити выполняться.
В стеке типа <b> память можно динамически выделять с помощью "allocb". Поведение аналогично alloca. Локальные переменные в этом стеке можно размещать с помощью linkage "_db" (при совместимом компиляторе).
В отличие от плоской модели памяти, в модульной программе для расширения стека надо порождать новые фреймы (делать новые вызовы функции), поэтому в модульной программе стек фрагментирован, но по размеру ограничен только количеством виртуальной памяти.
Функции абстрактной машины С, которые при вызове получают свой собственный фрейм стека имеют специальный linkage типа <frame>. Правила передачи параметров для таких функций отличаются от обычных. Функции типа frame также используют стек <b> для обмена общими данными.
Обычные функции (без frame), работают в физически одном и том же фрейме стека <a>, в этом их назначение. Это значит, что адреса стека таких функций имеют один и тот же селектор (селектор определяется или последней frame функцией или исходным селектором стека <a> для нити).
Совершенно стандартной ошибкой является передача адреса из сегмента стека <a> в статическую переменную, время жизни которой больше, чем время жизни этого стека и наличие несовместимых указателей помогает устранять эту ошибку.
Однако, при работе программы, адреса из стека <a> иногда должны быть переданы в вызываемую функцию и возвращены из нее. Для этого служит логический сегмент типа <s>. Тип <s> указывает, что данный адрес принадлежит верхнему по уровню стеку <a>.
Есть правила использования <a> и <s> при вызовах функций, описании параметров функций и т.п. (правила далее), но в общем контроль над правильностью использования типа <s> и преобразованию данных из <a> в <s> лежит на программисте.
Также традиционным для С++ является способ создавать статические переменные динамически, внутри функции main и т.п. Такие данные имеют время жизни сравнимое со статическими переменными. Нет смысла размещать такие данные в h/x сегментах и загружать систему динамической памяти, поскольку реально эта память не будет перераспределяться и такие данные можно эффективно размещать на стеке <b>. Для работы с такими данными используется логический сегмент <g>.
Некоторые из правил сочетаний a,s:
-
конверсия s2a выполняется автоматически, допускается всегда; -
хранение стековых адресов типа а, s допускается только в памяти типа "а" (только для стековых адресов типа "g" допускается хранение в статической и динамической памяти); -
переменные типа "а" нельзя возвращать из функции, возвращать можно только тип "s", никакая конверсия a2s для возвращаемой переменной недопустима; -
поэтому, если функция не получает "s" параметров, она не может иметь "s" тип возврата; -
если адрес типа "а" передается в функцию с возможностью его возврата, параметр функции задается как тип "s", допускается автоматическая конверсия a2s в качестве параметра при вызове функции; -
если адрес типа "s" передается в функцию и его возврат не допускается, параметр функции задается как тип "a"; -
если адрес типа "s" возвращается из функции, программист должен контролировать какому стеку принадлежит возврат и правильно затребовать конверсию s2a; -
в функцию с linkage frame нельзя передавать a,s параметры (такие адреса передавать нельзя, только копии данных, на которые они указывают), поэтому возвращать s параметры из таких функций также нельзя; -
в структурах правила a,s распространяются на все поля таких типов.
Для упрощения отслеживания правильности стека на базе адресов s можно иметь структурный тип пользователя s2{s_addr;id_stack} и хранить в нем id_стека_адреса (id от 0 до uregX_max), а также иметь локальную переменную id_текущего_стека, это позволит гарантированно отслеживать конверсию s2a:
-
функции, получающие тип s2 должны получить в качестве параметра id предыдущего стека (автоматическая проверка требует совместимости компилятора); -
функции, не создающие на своем стеке "a" адреса для их отправки в другие функции, последний id стека не изменяют, иначе увеличивают id на 1 и хранят его в переменной типа auto; -
функции, не получающие s параметры устанавливают id стека в 0 и не могу возвращать s типы; -
конверсия s2a для типа s2 выполняется, если id текущего стека больше или равен id стека адреса, иначе генерируется исключение.
Логический сегмент <W> используется для доступа к константным данным, размещенным в сегменте кода. Отметим, что данные в сегменте кода имеют видимость кода, т.е. они общие для всех пользователей такого кода в системе.
Логический сегмент <i> используется для типизации адреса "this". Объект класса может быть создан в любом из логических сегментов. Методы класса, которым нужен тип логического сегмента своего объекта, ссылаются на него через логический сегмент <i>, как шаблоны.
В качестве варианта будущего развития типов можно уже сегодня анонсировать типы указателей формата "ptrX_Y_Z". В этом формате компонент Z отвечает за поле индекса сегмента, которое хранится в одном регистре с полем указателя и имеет вид "yHL":
-
"y" это сокращение от 'y'dx; -
"H" число старших бит индекса; -
"L" число младших бит индекса.
Например, объявление типа "p32dy12" означает, что из 32 бит ureg32 хранящих численное значение адреса типа p32d, самый старший бит и самые младшие 2 бита отведены под индекс сегмента (всего восемь сегментов индексируются):
-
младшие биты индекса определяют минимальное выравнивание данных в памяти (для двух бит это chars[4]); -
старшие биты индекса определяют максимальный размер сегмента (в данном случае это 31 бит - 2Гбайта).
Применение такой индексации удобно для исполнимых окружений с сегментами больших размеров (для архитектуры x86 32 бит и более) и позволяет уменьшить размер памяти, отведенный под хранение указателей, но немного усложняет работу с такими указателями. Без такого хранения индексации, параметр шаблона кода времени выполнения, указывающий на сегмент используемого указателя, будет занимать в памяти размер в общем равный "машинному слову" (размер указателя удваивается, хотя хранится не значение селектора, а индекс "прозрачного селектора").
Для указателей в виде класса С++, значок "*" при объявлении указателя не используется, но для объявлений в стиле С нужен конкретный синтаксис объявления указателя, типизированного памятью. Полный синтаксис при объявлении указателя стиля С использует обе звездочки:
data_type *memory_type *pointer_name;
В упрощенном синтаксисе одну из "*" можно убрать. Если убрать левую звездочку, то у нас появляется "data_type" размещенный в "memory_type", на который указывает "pointer_name" тоже где-то размещенный, т.е. memory_type мы соединяем с традиционным data_type и "*", как и в обычном С, разделяет описание указателя и того, на что он указывает:
data_type memory_type *pointer_name;
Для описания "memory_type" используем префикс "_m", после которого следуют разрядность адреса "XX" и буквы типа локального указателя: "d", "td", "lh" ... :
//статическая переменная data в сегменте нити
//описание _dt см. далее
_dt ureg32 data;
//константный указатель с именем ptr на стеке А
//указывающий на константную переменную ureg32
//в сегменте нити размером 32 бита
//проинициализирован адресом data
//это C вариант указателя ptr32_td
const ureg32 _m32td* auto const ptr=&data;
//C++
p32td auto const ptr=&data;
Если убрать правую звездочку из полного описания, то у нас появляется "data_type", на который указывает тоже где-то размещенный "pointer_name" имеющий тип "pointer_type" (ptrX_Y), т.е. memory_type мы соединяем с типом указателя и "*", как и в обычном С, разделяет описание указателя и того, на что он указывает:
data_type *pointer_type pointer_name;
Для описания "pointer_type" используем префикс "cp" (от C pointer), после которого следуют разрядность адреса "XX" и буквы типа локального указателя: "d", "lh" и т.п.:
//статическая переменная data в сегменте нити
//описание _dt см. далее
_dt ureg32 data;
//константный указатель типа cp32td на стеке А с именем ptr
//указывающий на константную переменную ureg32
//в сегменте нити размером 32 бита
//проинициализирован адресом data
//это C вариант указателя ptr32_td
const ureg32 *cp32td auto const ptr=&data;
В целом оба типа идентичны, первый тип традиционен и водит все равно необходимый тип памяти; второй тип переносит разрядность адресного пространства от типа данных ближе к переменной указателя.
Для указателей в виде класса С++ "pXY" используется следующий интерфейс:
//data
uregX offs;
//ctor
ctor()
copy_ctor
pXY& =(const pXY&)
ctor(T _mXY*)
pXY& =(T _mXY*)
pXY& set_ptr(T _mXY*)
//access
T _mXY* get_ptr()const
T _mXY& *()const
//access2
T _mXY& [](uregX)const //idx
uregX -(const pXY&)const //возвращает idx между двумя указателями
//access3
T _mXY* ->
//comparison
//если компилятор может bool (){ return (ptr!=nullXY); }
//то для операций (!, ==, !=, <, >, <=, >=)
class ptrXY<T>::Tcmp{};
operator Tcmp _mXY*()
//modification
pXY& ++()
pXY& --()
pXY& +=(uregXX)
pXY& -=(uregXX)
pXY +(uregXX)
pXY -(uregXX)
Операции ++, -- только префиксные, поскольку повсеместно программисты до сих пор не могут освоить правильное применение таких постфиксных операторов в С++. Для указателей абстрактной машины С такие постфиксные операторы С++ для указателей явно задать или использовать нельзя.
На аппаратуре, которая постфиксные ++, -- не поддерживает аппаратно (на х86, например), их применение для классов С++ порождает поражающий воображение своей неэффективностью код, который выполняет десяток операций вместо одной, поэтому программист (пользуясь только префиксными операциями ++, --) должен явно выделять временные хранилища, когда они действительно нужны, компилятор сам определить такую необходимость не может.
В иных случаях, когда постфиксные операторы ++, -- пользователем не заданы, совместимый компилятор может поддержать постфиксные операторы сам, упорядочивая операции и применяя префиксный оператор в тех выражениях, где это возможно.
Операции -,[], ++, --, +=, -= требуют sizeof(T), операция -> определена только для тех T, для которых такая операция существует, поэтому надо ввести дополнительный суффикс типа интерфейса указателя по двухбитовой комбинации:
включен
бит 0 нет операции sizeof(T)
бит 1 есть операция ->
В десятичном и используемом в программе виде, дополнительный суффикс типа указателя:
нет указатель на POD тип (есть sizeof(T), нет ->)
2 полный указатель (есть ->, есть sizeof(T))
3 указатель внутри структуры на себя (нет sizeof(T), есть ->)
1 самый простой доступ (нет sizeof(T), нет ->)
Вот кусок кода инициализации простой реализаций lh, созданный по правилам абстрактной машины С:
class Theap_mcb{
...
public:
//controlled memory size (excluding self sizeof(Theap_mcb))
uregX size;
//?allocated
char is_busy;
//to faster free
Tptr prev;
...
//
(*_p_data).total_heap_size=
(*_p_base).n_heap.heap_top-(*_p_base).n_heap.heap_base;
//?no space to place mcbs
if( (*_p_data).total_heap_size <= 2*sizeof(Theap_mcb) )return;
(*_p_data).mcbs.set_ptr( (uregX)((*_p_base).n_heap.heap_base) );
(*_p_data).mcbs->size=
(*_p_data).total_heap_size - 2*sizeof(Theap_mcb);
(*_p_data).mcbs->is_busy= 0;
(*_p_data).mcbs->prev= 0;
(*_p_data).free_mcb= (*_p_data).mcbs;
(*_p_data).free_heap_size= (*_p_data).mcbs->size;
//
Tptr
last_mcb;
last_mcb.set_ptr( (uregX)(
(*_p_base).n_heap.heap_base
+ ((*_p_data).total_heap_size - sizeof(Theap_mcb))
));
last_mcb->size= 0;
last_mcb->is_busy= 1;
last_mcb->prev= (*_p_data).mcbs;
Получается очень похоже на обычный С, только добавляется портируемость. Для сравнения код выделения памяти для alloca(ureg32 size) на архитектуре х86 и совместимом компиляторе:
if(size&_align4_mask)size+= (4-(size&_align4_mask));
__sp32-= size;
return __sp32;
Пример простой реализации унифицированных указателей на х86 абсолютно аналогичен примеру для селекторов на х86, только занимает больше места, поэтому приводить его нет смысла.
Унифицированное адресное пространство, конкретная реализация.
Главное требование к реализации унифицированного адресного пространства на конкретной машине заключается в том, чтобы наличие логических сегментов было полностью прозрачным для программы. В терминах архитектуры x86 это означает, что сегментные регистры за все время работы модуля загружаются только один раз, при вызове модуля.
Конечно, отличие от плоской модели памяти при этом все равно есть, отличие не меньшее, чем увеличение для модульной программы числа параметров при вызове функций, но это плата за пользование аппаратной защитой времени выполнения и раздельными адресными пространствами. В правильно спроектированном процессоре при смене модулей передается минимум "лишних" данных, нужных только для поддержки аппаратной защиты.
Вопрос, который при этом может взволновать нового читателя: как тогда на вполне себе обычной аппаратной системе практически выразить 50 разных логических сегментов?
Надо признать, что унифицированное адресное пространство можно полностью реализовать даже на 286 процессоре, у которого всего четыре аппаратных сегментных регистра. Ясно, что это можно сделать только тогда, когда некоторые из этих сегментов (в нашем примере это должно быть 46 из них) не получат аппаратной реализации.
Проблемы как кажется минимальны, в системе с плоской моделью памяти все 50 из этих 50 логических сегментов не получат аппаратной реализации, но тут есть один подводный камень.
Если портируемая программа затребовала 50 "прозрачных" сегментных регистров по 16 бит, то несмотря на то, что 286 поддерживает 16-битные сегменты, он может предоставить только 3 "прозрачных" сегментных регистра и такая 16-битная программа на нем просто не сможет быть выполнена. Аналогичный пример для 50 "прозрачных" сегментных регистров по 32 бита. Их в 386 только 5.
Заметим, что даже 3 сегмента это по размеру все же больше чем один сегмент. Если портируемая программа умещает каждый свой модуль в три 16-ти битных или пять 32-битных сегментов, с учетом их неравномерного использования, то независимо от логического разбиения этих аппаратных сегментов, программа будет работать на 286 и 386.
Но этот подводный камень заключается в несовпадении затребованного размера адресного пространства с предоставляемым при такой схеме отображения, что совершенно недопустимо для портируемой программы.
Программист, затребовав 16 битные сегменты, вправе ожидать их полный 16 битый размер, но при таком их отображении на общие 16 битные аппаратные сегменты 286, этого полного размера нет и во время выполнения может возникнуть неожиданная нехватка памяти. Более того, нехватка памяти может возникать редко и в общем, что хуже всего, невозможно предсказать когда она возникнет.
Нельзя исключать из использования "лишние" сегменты, если они требуются по логике программы, ради выполнения на "реальном 286". Это лишает программу портируемости и надежности. Возможны такие решения:
-
программист явно выбирает размер логического сегмента меньше 16 бит, с учетом исполнения на реальных системах с тремя прозрачными сегментами (это весьма трудный вариант, но приведем его для полноты картины); -
16 битные сегменты исполняются в 32-битном окружении, тогда они все полностью умещаются, не пересекаясь; -
программист во время компиляции с помощью DEF файла (или аналогичных операторов языка) задает нужные предельные размеры использованных им 16 битных логических сегментов или их объединений.
Путь с помощью DEF файла самый хороший, сохраняется как единственность определения сегментов (в системе только 16 бит сегменты), так и не требуется повышения битности исполнимого окружения (только 16 бит исполнимое окружение).
Модульная портируемая программа для абстрактной машины С требует от программиста не только явно выбирать размеры используемых целых типов, но и явно выбирать кластеры памяти для модуля, которые потом не могут быть перераспределены в сторону уменьшения или увеличения. Это плата за портируемость и надежность.
Такое требование к памяти как заставляет программиста тщательно выяснять предполагаемый максимальный размер используемых сегментов, так и оставляет малоиспользуемые "хвосты" в пределах сегментов, которые могут быть убраны из физической памяти в общем только аппаратной системой страничной адресации.
Если перераспределение памяти нужно, то портируемая программа использует сервис IPC для довольно малоэффективного перераспределения из системной памяти дополнительных внешних сегментов в адресном пространстве модуля, пользование которыми уже не прозрачно, нужны полные адреса (на процессоре х86 эти сегменты будут доступны через перезагрузку регистра ES).
Унифицированная модель многозадачности.
Наверное последний вопрос сегодня это реализация сущностей модульности и многозадачности: процесс, нить и модуль.
Для реализации процессов, нитей и модулей на конкретной машине требования по прозрачности сегментов те же самые что для реализации адресного пространства, т.е. в терминах архитектуры х86, существование контекста процесса, нити и модуля означает, что доступ к каждому контексту во время работы метода модуля осуществляется без перезагрузки сегментных регистров.
Обычно всегда сравнивают между собой процессы и нити, при этом появление нити для процесса обычно означает, что появляется некий дополнительный сегмент данных, который для каждой нити свой.
Посмотрим на следующую таблицу, которая показывает, как количество независимых аппаратных сегментов данных для архитектуры х86 влияет на богатство возможных сочетаний (количество разных конфигураций) для процесса, нити и модуля.
Таблица: число независимых сегментов данных и исполнимый формат Exx:
--1 сегмент
(для 16 бит есть поддержка компиляторов для DOS)
E20 тяжелые нити,
плоская память (нет отдельного сегмента кода), SP protected
E21 тяжелые нити, SP protected
--2 сегмента (286)
E22 нити,
*IPC раздельные пространства процесса
E23 тяжелые нити
+раздельные пространства модулей
E24 тяжелые нити +1 фреймы стека,
*IPC раздельные пространства модулей
--3 сегмента (386)
E30 нити +1 фреймы стека,
*IPC раздельные пространства процесса
--4 сегмента (386)
E40 нити +раздельные пространства модулей
E41 нити +1 фреймы стека
+раздельные пространства нити
E42 нити +2 фреймы стека,
*IPC раздельные пространства процесса
--5 сегментов (нет процессора архитектуры х86)
E50 нити +1 фреймы стека
+раздельные пространства модулей
--6 сегментов (нет процессора архитектуры х86)
E60 нити +2 фреймы стека
+раздельные пространства модулей
--7 сегментов, селекторы с четырьмя таблицами
(нет процессора архитектуры х86)
E70 нити +2 фреймы стека
+раздельные пространства модулей
+защита записи
+2 рабочих регистра _IU_DATA
Здесь "тяжелые нити" это эмуляция общих данных процесса через IPC (через дополнительный сегмент). Хотя такое соглашение не соответствует идеологии "идеальной нити", это приходится терпеть, потому что реальные процессоры с одним/двумя независимыми сегментами данных существуют, а "тяжелая нить" все равно лучше, чем "легкий процесс". Функции помеченные (*) опциональны, дают ограниченные в применению расширения тоже через дополнительный сегмент.
Кроме этих сегментов в архитектуре х86 еще есть регистр ES, который в этой таблице всегда играет роль рабочего, а также, еще есть регистр CS, который содержит код, поэтому для х86 общее число сегментных регистров в процессоре равно указанному в таблице плюс 2.
Как видите, портируемая программа абстрактной машины С дополнительно должна явно выбрать формат многозадачности, исходя из количества независимых сегментов данных в системе, на которой программа способна будет отработать с желаемой эффективностью.
Формат многозадачности определяет интерфейс сервиса, который программа может запрашивать у системы. Программа формата E20 не сможет слинковаться, если попробует создавать нити, т.е. исполнимый формат Exx определяет только качественные возможности интерфейса, виды динамических библиотек. Конкретный интерфейс, форматы заголовков и т.п. определяется типом ОС, в которой предполагается выполнять приложение.
Если для процессора аналогичного 386 все сущности многозадачности так или иначе (почти) поддержаны, для процессоров с меньшим количеством аппаратных сегментов и разрядности, этот выбор особенно важен.
Хотя в данной таблице для определенности сегменты приведены для архитектуры х86, это не важно какая именно архитектура и как именно реализованы сегменты. Формат E20 это плоская модель любого процессора, а формат E40 это возможности 386 процессора или лучше. Формат E40 не сможет исполняться с ожидаемой эффективностью (без эмуляции какого-либо рода) на процессоре хуже, чем 386.
Такой самоцели, как запускать все приложения в мире на 16 битном окружении, конечно нет, но если модули данной портируемая программы не требуют типов данных или адресных сегментов размером более 16 бит, то такая программа должна работать на любом реальном 16 битном окружении (то же самое для границы в 32 бита); если этого не происходит, программа написана плохо, как минимум в плане портируемости.
Заключение.
Если ваша программа соответствует абстрактной машине С, портировать ее на другую платформу или иное исполнимое окружение будет очень просто. Усилия для написания любой вашей программы в стиле этой абстрактной машины С также не очень велики.
Описание обобщенных интерфейсов сервиса для каждого из исполнимых форматов (типа POSIX) выходит за рамки этого текста, но как бы не называлась в вашей системе функция, реализующая формат Exx, сделать переходник для вызовов функций другой системы, в которой есть функция, которая делает то же самое но называется иначе, не будет очень трудной задачей при портировании.
Нет ни одной причины, по которой программы предназначенные для переноса надо было бы писать на непортируемом языке и возможно ваши программы будут лучше, если вы будете использовать в них эту абстрактную машину С.
Обсуждение этой заметки в форуме.
Создано: 10.02.014
Последний раз отредактировано: 14.02.014
Ссылка на оригинал этой статьи.
реклама
Лента материалов
Соблюдение Правил конференции строго обязательно!
Флуд, флейм и оффтоп преследуются по всей строгости закона!
Комментарии, содержащие оскорбления, нецензурные выражения (в т.ч. замаскированный мат), экстремистские высказывания, рекламу и спам, удаляются независимо от содержимого, а к их авторам могут применяться меры вплоть до запрета написания комментариев и, в случае написания комментария через социальные сети, жалобы в администрацию данной сети.
Сейчас обсуждают