Привет, Хабр! Представляю вашему вниманию перевод статьи "Pointers Are Complicated, or: What's in a Byte?" авторства Ralf Jung.


Этим летом я снова работаю над Rust фуллтайм, и я снова буду работать (помимо прочих вещей) над "моделью памяти" для Rust/MIR. Однако, прежде чем я заговорю о своих идеях, я наконец должен развеять миф, что "указатели просты: они являются просто числами". Обе части этого утверждения ошибочны, по крайней мере в языках с небезопасными фичами, таких как Rust или C: указатели нельзя назвать ни простыми, ни (обычными) числами.


Я бы также хотел обсудить часть модели памяти, которую необходимо затронуть, прежде чем мы можем говорить о более сложных частях: в какой форме данные хранятся в памяти? Память состоит из байтов, минимальных адресуемых единиц и наименьших элементов, к которым можно получить доступ (по крайней мере на большинстве платформ), но каковы возможные значения байта? Опять же, оказывается, что "это просто 8-битное число" не подходит в качестве ответа.


Я надеюсь, что прочитав этот пост, вы согласитесь со мной относительно обоих утверждений.


Указатели сложны


В чем проблема с "указатели — это обычные числа"? Давайте рассмотрим следующий пример: (я использую C++ здесь, так как писать небезопасный код в C++ проще, чем в Rust, и небезопасный код — это как раз то место, где и появляются проблемы. Небезопасный Rust и C имеют все те же проблемы, что и C++).


int test() {
    auto x = new int[8];
    auto y = new int[8];
    y[0] = 42;
    int i = /* какие-то вычисления без побочных эффектов */;
    auto x_ptr = &x[i];
    *x_ptr = 23;
    return y[0];
}

Оптимизация последнего чтения y[0] с возвращением всегда 42 очень выгодна. Обоснование такой оптимизации — изменение x_ptr, которое указывает на x, не может изменить y.


Однако, имея дело с языками низкого уровня, такими как C++, мы можем нарушить это предположение, присвоив i значение y-x. Так как &x[i] — это то же самое, что и x+i, мы записываем 23 в &y[0].


Конечно, это не мешает C++ компиляторам делать такие оптимизации. Чтобы разрешить это, стандарт говорит, что наш код имеет UB.


Во-первых, не разрешается выполнять арифметические операции над указателями (как в случае с &x[i]), если в этом случае указатель выходит за любую из границ массива. Наша программа нарушает это правило: x[i] выходит за границы x, поэтому это является UB. Иными словами, даже вычисление значения x_ptr является UB, так что мы даже не доходим до того места, где мы хотим использовать этот указатель.


(Оказывается, i = y-x также является UB, так как разрешается вычитать только указатели, указывающие в место одного выделения памяти. Однако мы могли бы написать i = ((size_t)y — (size_t)x)/sizeof(int), чтобы обойти это ограничение.)


Но мы еще не закончили: это правило имеет единственное исключение, которое мы можем использовать в нашу пользу. Если арифметическая операция вычисляет значение указателя на адрес точно после конца массива, то все в порядке. (Это исключение необходимо для вычисления vec.end() для самых обычных циклов в C++98.)


Давайте немного изменим пример:


int test() {
    auto x = new int[8];
    auto y = new int[8];
    y[0] = 42;
    auto x_ptr = x+8; // элемент после конца
    if (x_ptr == &y[0])
      *x_ptr = 23;
    return y[0];
}

А теперь представьте, что x и y были выделены друг за другом, причем y имеет больший адрес. Тогда x_ptr указывает на начало y! Тогда условие истинно и присваивание происходит. При этом тут нет UB из-за выхода указателя за границы.


Кажется, что это не позволит провести оптимизацию. Однако стандарт C++ имеет другой туз в рукаве, чтобы помочь создателям компиляторов: на самом деле он не позволяет нам использовать x_ptr. Согласно тому, что говорится в стандарте про прибавление чисел к указателям, x_ptr указывает на адрес после последнего элемента массива. Он не указывает на конкретный элемент другого объекта, даже если они имеют одинаковый адрес. (По крайней мере это распространенная интерпретация стандарта, на основе которой LLVM оптимизирует этот код.)


И даже несмотря на то, что x_ptr и &y[0] указывают на один адрес, это не делает их одинаковым указателем, то есть они не могут быть использованы взаимозаменяемо: &y[0] указывает на первый элемент y; x_ptr указывает на адрес после x. Если мы заменим *x_ptr = 23 строкой *&y[0] = 0, мы изменим значение программы, даже несмотря на то, что два указателя проверялись на равенство.


Это стоит повторить:


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

Да, эта разница трудноуловима. На самом деле, это до сих пор вызывает различия в программах, скомпилированных с LLVM и GCC.


Также заметьте, что это правило "один после" — не единственное место в C/C++, где мы можем наблюдать такой эффект. Другой пример — ключевое слово restrict в C, которое может быть использовано для выражения того, что указатели не перекрываются (не равны):


int foo(int *restrict x, int *restrict y) {
    *x = 42;
    if (x == y) {
        *y = 23;
    }
    return *x;
}

int test() {
    int x;
    return foo(&x, &x);
}

Вызов test() вызывает UB, так как два доступа к памяти в foo не должны происходить по одному адресу. Заменив *y на *x в foo, мы изменим значение программы, и она больше не будет вызывать UB. Еще раз: несмотря на то, что x и y имеют один адрес, их нельзя использовать взаимозаменяемо.


Указатели — это определенно не просто числа.


Простая модель указателей


Так что такое указатель? Я не знаю полный ответ. На самом деле, это открытая область для исследований.


Один важный момент: здесь мы рассматриваем абстрактную модель указателей. Безусловно, на настоящем комьютере указатели являются числами. Но настоящий компьютер не проводит те оптимизации, которые делают современные компиляторы C++. Если бы мы написали вышеприведенные программы на ассемблере, то там не было бы ни UB, ни оптимизаций. C++ и Rust применяют более "высокоуровневый" подход к памяти и указателям, ограничивая программиста в угоду компилятору. Когда требуется формально описать, что программист может и не может делать в этих языках, модель указателей как чисел разбивается вдребезги, так что нам нужно найти что-то еще. Это другой пример использования "виртуальной машины", отличающейся от реального компьютера в целях спецификации — идее, о которой я писал раньше.


Вот простое предложение (на самом деле эта модель указателей используется в CompCert и моей работе RustBelt, а также способ, согласно которому интерпретатор miri реализует указатели): указатель — это пара какого-то ID, однозначно определяющего область памяти (allocation), и смещение относительно этой области. Если написать это на Rust:


struct Pointer {
    alloc_id: usize,
    offset: isize,
}

Операции добавления (вычитания) числа к указателю (из указателя) влияют только на смещение, и поэтому указатель никогда не может покинуть область памяти. Вычитание указателей возможно только в том случае, если они относятся к одной области памяти (в соответствии с C++).


(Как мы могли видеть, стандарт C++ применяет эти правила к массивам, а не областям памяти. Однако, LLVM применяет их на уровне областей.)


Оказывается (и miri показывает то же самое), что эта модель может хорошо послужить нам. Мы всегда помним, к какой области памяти относится указатель, поэтому мы можем отличить указатель "один после" одной области памяти от указателя на начало другой области. Таким образом miri может обнаружить, что наш второй пример (с &x[8]) имеет UB.


Наша модель разваливается на куски


В нашей модели указатели хоть и не являются числами, но они хотя бы простые. Однако эта модель начнет разваливаться на глазах, как только вы вспомните приведения указателей к числам. В miri приведение указателя к числу на самом деле ничего не делает, мы просто получаем числовую переменную (т. е. ее тип говорит, что это число), чье значение является указателем (т. е. пара область памяти и смещение). Однако, умножение этого числа на 2 ведет к ошибке, так как совершенно непонятно, что значит "умножить такой абстрактный указатель на 2".


Я должен пояснить: это не хорошее решение, если речь заходит об определении семантики языка. Однако, это хорошо работает для интерпретатора. Это самый простой подход, и мы выбрали его, потому что непонятно, как это можно сделать иначе (кроме как не поддерживать такие приведения вовсе — но с их поддержкой miri может запускать больше программ): в нашей абстрактной машине нет единого "адресного пространства", в котором располагались бы все выделенные области памяти, а все указатели были сопоставлены с конкретными различными числами. Каждая область памяти идентифицируется (скрытым) ID. Теперь мы можем начинать добавлять в нашу модель дополнительные данные вроде базового адреса для каждой области памяти, и каким-то образом использовать это для приведения числа обратно к указателю… и на этом моменте процесс становится действительно очень сложным, и, в любом случае, обсуждение этой модели не является целью написания поста. Его цель — обсудить необходимость такой модели. Если вы заинтересованы, я рекомендую вам прочитать этот документ, который подробнее рассматривает вышеприведенную идею добавления базового адреса.


Короче говоря, приведения указателей и чисел друг к другу запутанные, и их сложно определить формально, учитывая обсужденные выше оптимизации. Возникает конфликт между высокоуровневым подходом, необходимым для оптимизаций, и низкоуровневым подходом, необходимым для описания приведения указателей к числам и обратно. По большей части мы просто игнорируем эту проблему в miri и по возможности стараемся делать как можно больше, используя простую модель, с которой мы работаем. Полное определение языков таких, как C++ или Rust, разумеется, не может пойти по такому простому пути, оно должно объяснять, что происходит в действительности. Насколько мне известно, не существует подходящего решения, но академические изыскания приближаются к истине.


Именно поэтому указатели также не являются и простыми.


От указателей к байтам


Я надеюсь, я привел достаточно убедительный довод, что числа — не единственный тип данных, которые нужно учитывать, если мы хотим формально описать низкоуровневые языки вроде C++ или (небезопасную часть) Rust. Однако это значит, что простая операция вроде чтения байта из памяти не может просто вернуть u8. Представьте себе, что мы реализуем memcpy, читая по очереди каждый байт источника в какую-то локальную переменную v, а затем сохраняем это значение в целевом месте. А что, если этот байт является частью указателя? Если указатель — это пара ID области памяти и смещения, то каков будет его первый байт? Нам нужно сказать, чему равно значение v, поэтому нам придется как-то ответить на этот вопрос. (И это совершенно иная проблема, чем проблема с умножением, которая была в предыдущей секции. Мы просто предположим, что существует некий абстрактный тип Ponter.)


Мы не можем представить байт указателя как значение диапазона 0..256 (прим.: здесь и далее 0 включается, 256 — нет). В целом, если мы используем наивную модель представления памяти, дополнительная "скрытая" часть указателя (та, что делает его больше чем просто числом) будет потеряна, когда указатель будет записан в память и заново считан из нее. Нам придется исправить это, а для этого придется расширить наше понятие "байта" для представления этого дополнительного состояния. Таким образом, байт теперь либо значение диапазона 0..256 ("сырые биты"), либо n-ый байт какого-то абстрактного указателя. Если бы нам пришлось реализовать нашу модель памяти на Rust, это могло бы выглядеть так:


enum ByteV1 {
  Bits(u8),
  PtrFragment(Pointer, u8),
}

Например, PtrFragment(ptr, 0) представляет собой первый байт указателя ptr. Таким образом, memcpy может "разбить" указатель на отдельные байты, представляющие этот указатель в памяти, и копировать их по отдельности. На 32-битной архитектуре полное представление ptr будет содержать 4 байта:


[PtrFragment(ptr, 0), PtrFragment(ptr, 1), PtrFragment(ptr, 2), PtrFragment(ptr, 3)]

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


Неинициализированная память


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


enum Byte {
  Bits(u8),
  PtrFragment(Pointer, u8),
  Uninit,
}

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


Это очень похоже на правила LLVM по отношению к специальному значению poison. Заметьте, что LLVM также имеет значение undef, которое используется для неинициализированной памяти и работает несколько иначе. Однако, компиляция нашего Uninit в undef корректна (undef в каком-то смысле "слабее"), и есть предложения убрать undef из LLVM и использовать вместо него poison.


Вы можете удивиться, почему у нас вообще есть специальное значение Uninit. Почему бы не выбрать какое-нибудь произвольное b: u8 для каждого нового байта, и затем использовать Bits(b) в качестве изначального значения? Это действительно является одним из вариантов. Однако, прежде всего, все компиляторы пришли к подходу с использованием специального значения для неинициализированной памяти. Не следовать этому подходу значит не только вызвать проблемы с компиляцией через LLVM, но и пересмотреть все оптимизации и убедиться, что они работают корректно с этой измененной моделью. Ключевой момент здесь: всегда можно безопасно заменить Uninit любым другим значением: любая операция, получающая это значение, в любом случае приводит к UB.


Например, этот код на C проще оптимизировать с Uninit:


int test() {
    int x;
    if (condA()) x = 1;
    // Много трудного для анализа кода, который точно приведет к выходу из функции, если condA()
    // не истинно, но этот код не изменяет x.
    use(x); // оптимизируем x = 1.
}

С Uninit мы можем с легкостью сказать, что x имеет либо значение Uninit, либо значение 1, и раз замена Uninit на 1 работает, оптимизация легко объясняется. Без Uninit x равно либо "какому-то произвольному битовому паттерну", либо 1, и проведение той же оптимизации уже сложнее объяснить.


(Мы можем возразить, что мы можем поменять местами операции, когда делаем недетерминированный выбор, но тогда нам надо будет доказать, что трудный для анализа код не использует никаким образом x. Uninit позволяет избегать этой мороки с ненужными доказательствами.)


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


Заключение


Мы увидели, что в языках вроде C++ и Rust (в отличие от реальных компьютеров) указатели могут быть различны, даже если они указывают на один адрес, и что байт — это нечто большее, чем просто число из диапазона 0..256. Поэтому если в 1978 году язык C можно было "портативным ассемблером", то сейчас это невероятно ошибочное утверждение.

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


  1. IgorPie
    22.07.2019 20:07
    +1

    Выравнивание на 32 бит, в принципе, норма еще с 386. Так, что байт — байтом, но под капотом все существенно сложнее.
    Производительность чтения-записи живьем не сравнивали? Когда нужна производительность (а производительность процессоров, в общем-то стоит на месте последние годы), си снова становится портативным ассемблером. Где важно было бы поймать за руку вылет за границы массива, давно были придуманы языки попроще. Имхо.


    1. amarao
      22.07.2019 21:45

      Мне трудно понять смысл вашего послания, но я попробую: работа с массивами в Rust медленее, чем в Си? Это не так. А пост касался вообще модели памяти, а не работы с массивами.


  1. pda0
    22.07.2019 23:14

    Однако, умножение этого числа на 2 ведет к ошибке, так как совершенно непонятно, что значит «умножить такой абстрактный указатель на 2».
    Честно говоря совершенно не тот пример. Проблема не в том, что указатель нельзя умножать, иногда может и можно, проблема в том, что у указателей есть обособленное значение null, эквивалентное NaN в числах с плавающей точкой. Строго говоря любые операции, включающие в себя null обязаны давать null в результате. Но поскольку null отображается на ноль, не имеющий в целых числах такой особенности и возникает большая часть той чехарды, что мы имеем.


  1. myxo
    22.07.2019 23:47

    Однако это значит, что простая операция вроде чтения байта из памяти не может просто вернуть u8.

    Если честно совсем не понял почему. Может кто-нибудь пояснить?


    1. humbug
      23.07.2019 02:51

      Потому что в области памяти могут лежать указатели. И тогда u8 — это байт, который является частью указателя. И тут два пути, либо мы забиваем на то, что u8 — это часть указателя, что стирает инфу о хранимом указателе, либо надо надо каждый байт рассматривать как какое-то значение тип-суммы байта|части указателя. В этом же части статьи чуть ниже описано.


      1. myxo
        23.07.2019 11:13

        я вот этот момент как раз совершенно не понял. Что значит «стирает инфу». Ну попросим второй, третий байт и по кусочкам соберем информацию о указателе. Если мы просим один байт, то мы практически всегда не получаем полную картину так как подавляющее большинство типов занимает больше 1 байта.

        Ну то есть в чем разница, что мы попросили байт от указателя или от u32?


        1. amarao
          23.07.2019 14:01
          +1

          Половина поста посвящена объяснению этого. Указатель 0xFFFE на один массив и указатель 0xFFFE на другой массив — это разные указатели, даже если у них одно и то же байтовое значение.


          Если язык будет "стирать" эту разницу (считая, что раз 0xFFFE, то и ладно), то либо будет сильно страдать оптимизация, либо будет много неприятного UB.


          1. dkfrmmnt
            23.07.2019 15:34
            +1

            Непонятно, почему нельзя просто побайтово копировать структуру, которой представлены указатели. Тогда будет скопирована и информация об области выделения, и о смещении. Зачем все эти сложности с определением байта как части указателя?


            1. amarao
              23.07.2019 15:45

              А если я скопирую 2 байта из одного указателя, а потом два байта из другого указателя, это какой именно указатель будет?


              Это риторический вопрос, на самом деле. Для постороннего человека вся эта трахомудия с указателями ещё менее понятна, чем проблема поиска отображения из одного семигрупоида в другой.


              Суть проблемы: если у нас есть указатель на что-то, и компилятор видит, что это указатель единственный, он может оптимизировать код. Например, если из этого указателя читают то, что записали, сравнивают со старым значением и делают if, то весь блок else можно выкинуть, потому что это тафтология.


              А теперь кто-то рядом делает указатель на те же данные методом "кастим 8 байт и пишем туда что-то". И это был осмысленный код (с точки зрения того, кто пишет). Что компилятору делать? Три варианта:


              1. Запретить делать фигню с указателями. Мы не можем полностью это запретить, потому что кто-то должен обрабатывать прерывания и переключать real mode в 64-битный режим.
              2. Отключить все оптимизации делать что сказали. Работает (с поправкой на баги человеков), но меееедленно. А все, кто лезут в указатели, хотят быыыыстро.
              3. Объявить это undeined behaviour и делать что делается, и получится что получится. (Текущая ситуация с указателями).

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


              1. dkfrmmnt
                23.07.2019 15:53
                +1

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


                1. amarao
                  23.07.2019 16:06

                  Дело не в том, как эту область преобразовать в указатель, а в том, что наличие "неучтённых" указателей ломает оптимизации в других местах (не там, где указатели появляются).


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


                  1. dkfrmmnt
                    23.07.2019 17:16
                    +1

                    Затрудняюсь найти источник «неучтенных» указателей, кроме как преобразование числа/области памяти в указатель. И не понимаю, как указанное переосмысление байта помешает нам осуществлять такое преобразование. Если мы получили последовательность байт условно 2 байта одного указателя, 2 другого, то мы на этапе статического анализа запретим преобразование такого блока памяти в указатель?


                    1. amarao
                      23.07.2019 17:36

                      В статье писали про интерпретатор, обратите внимание. Он такое может.


                    1. humbug
                      23.07.2019 21:29

                      В языке Rust есть компонент под названием MIRI. Он нужен для интерпретации кода для поиска багов (UB) в рантайме, типа плюсовых санитайзеров, но со специализацией именно в Rust. Работа над ним все еще идет, но он уже нашел несколько багов в стандартной либе (в unsafe части). Ральф как раз и занимается формальной верификацией и MIRI, поэтому периодически выдает всякие статьи о том, как оно внутри устроено, как они хотят что-то улучшить и т.д. Советую почитать его блог.


              1. Danil1404
                23.07.2019 15:55

                А если взять скопировать первые 2 байта из float32, а вторые 2 байта из int32? Или даже просто взять 4 числа float32 и взять по байту из каждого из них?
                Автор текста вполне донес до меня мысль, что указатель — это вещь в себе, и если они не указывают на одну и ту же область памяти, то с точки зрения компилятора это все равно что разные типы данных.
                Вопрос в том, чем указатели отличаются в этом плане от других типов данных? Если взять любой тип данных и начать производить с ним любые побайтовые манипуляции кроме полного копирования, то скорее всего ничего хорошего не выйдет.


                UPD. Я еще раз перечитал предыдущее объяснение и понял.


              1. 0xd34df00d
                23.07.2019 16:28

                А если я скопирую 2 байта из одного указателя, а потом два байта из другого указателя, это какой именно указатель будет?

                Я, если честно, даже не уверен, что делать memcpy в кусок одного объекта типа инта корректно и не UB (условно, memcpy(static_cast<char*>(&theInt) + 1, smth, 2);, полагая, что инт занимает хотя бы 4 байта). Какие уж там указатели.


                1. amarao
                  23.07.2019 16:37

                  В какой-то момент времени это придётся делать, если спускаться вниз по стеку к более низкоуровневому коду. У нас может прийти сетевой пакет, в котором фрагментация, и граница режет наш любимый int не там, где он заканчивается. Прийдётся делать memcpy, а потом его оттуда вычитывать. Код вычитывания можно сколько угодно обложить красивой типизацией (я не кастю память к типу просто так), но внутри оно всё равно будет кастить память к типу просто так.


                  1. 0xd34df00d
                    23.07.2019 18:24

                    Не обязательно придётся.


                    Даже в приведённом вами примере можно просто вычитывать по char'у, которые потом объединять в инты простыми битовыми сдвигами и or'ами.


                    1. amarao
                      23.07.2019 23:09

                      Если вы будете приходящие сетевые пакеты вычитывать по-байтно, а объединять сдвигами, то у вас будет непреодолимая проблема — сетевой пакет должен обрабатываться за 8нс (наносекунд) чтобы обеспечить linespeed на 40G. Это 200 пикосекунд на байт, что меньше времени выполнения одной простой инструкции на современных процессорах.


                      1. 0xd34df00d
                        24.07.2019 00:12

                        А я не говорил, чтоб прям по байту из сокета или откуда там ещё читать.


                        Сделали memcpy всего куска памяти как char* или std::byte* или void* куда-то в другое место и потом там с ним работаете (а компилятор соптимизирует).


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


              1. hhba
                23.07.2019 18:18

                Особенно приятно при оптимизации становится простым тестам памяти и скрабберам.


                1. amarao
                  23.07.2019 23:05

                  Я не могу говорить про Си, а в Rust со скрабберами всё довольно просто, потому что трейт Copy надо явно иметь (иначе тебя нельзя копировать), а чистку надо делать в трейте Drop (потому что его drop() будет точно вызван точно тогда, когда объект выйдет из scope'а).


                  … Как эту проблему решают в Си — я представить себе не могу. Видимо, как-то решают.


                  1. hhba
                    24.07.2019 10:35

                    Не, я не про тот скраббер, который очищает память от конфиданных, я про тот, который вычищает сбои устройств памяти.


                    1. amarao
                      24.07.2019 11:15

                      А скрабберам-то что? Если виртуальная память используется, и область памяти с GDT не затронута, надо всего лишь ремапнуть страничку. Даже ссылки не поменяются (в userspace). В kernel — ой.


                      1. hhba
                        24.07.2019 11:55

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


      1. Danil1404
        23.07.2019 11:51

        Я не понял, а почему указатели как-то отдельно выделяются в плане чтения памяти?
        По моему пониманию, когда мы разыменовываем указатель, то мы никогда не получаем «сырые» байты. У указателя всегда есть какой-то тип, и при разыменовывании мы должны получить значение этого типа.
        Потому что если сказать, что значение в памяти, на которое указывает указатель — это либо 8-битное число, либо какая-то часть указателя, то что делать со всеми остальными типами? Например, числа с плавающей запятой — точно так же, как и для указателей, любой один прочитанный байт никакого смысла не несет. И, в общем-то, это верно для любых многобайтовых типов.
        Почему вместо хранения типа указателя (применимого для всех типов данных) в статье введен тип значения, хранящегося в байте?
        Я бы сказал, что это для упрощения, но упрощения я здесь не вижу. В этом есть какой-то скрытый смысл?


        1. amarao
          23.07.2019 15:46

          См коммент выше.


          Цель — на ёлку влезть и попу не оцарапать. Для того, кому не надо на ёлку задача простая — не лезь на ёлку. А если надо?


  1. AntonSazonov
    23.07.2019 00:59
    +1

    Честно говоря, статья — хрень полнейшая.
    То ли автор говорит про Strict Aliasing Rules, то ли про то что указатель это не значение адреса памяти, то ли про оптимизации…
    А потом еще пишет: "Так что такое указатель? Я не знаю полный ответ."
    На своей волне где-то там плавает...


    1. humbug
      23.07.2019 02:54

      то ли про то что указатель это не значение адреса памяти, то ли про оптимизации…

      Потому что надо прочесть пейпер, который указан в статье http://www.cis.upenn.edu/%7Estevez/papers/KHM+15.pdf. Автор опирается на академические исследования. В этом же пейпере и говорится про указатель как инт или указатель как модель, и как это применять в оптимизациях.


  1. ivanrt
    23.07.2019 04:15

    Статья — похожа на крик души человека, который занимается компилятором или анализатором rust. Я так не понял, что сделает компилятор c++ и rust, когда ему передадут два объекта одинакового типа. Вроде в c++ отключают какие-то оптимизации если нельзя доказать что объекты разные.


    1. Halt
      23.07.2019 09:59

      Вы недалеки от истины. Ральф занимается вопросами формальной верификации Rust и является одним из лидеров в этой области. Его группа в свое время доказала корректность базовых примитивов стандартной библиотеки в терминах фундаментальных инвариантов языка, для чего они разработали формализм ?rust.


    1. Halt
      23.07.2019 21:35
      +2

      Теперь о самом вопросе: C++ ничего не остается делать, кроме как проводить анализ control flow и выяснять, являются ли T* a и T* b указателями на один объект или же на разные.

      В Rust возможны варианты. Например, если функция принимает параметры a: &mut T и b: &T, то Rust совершенно однозначно может сказать, что это два разных объекта; стало быть, вердикт — NoAlias. Если a: &mut T и b: &mut T то тоже NoAlias. И только если обе ссылки &T то MayAlias.

      Результат проведения такого анализа использует LLVM для того чтобы, например, проводить оптимизации redundant load/store elimination, тем самым повышая производительность.


      1. ivanrt
        24.07.2019 06:06

        Спасибо, это и хотелось услышать.


  1. trolley813
    23.07.2019 08:46
    +1

    указатели просты: они являются простыми числами

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


    Вообще, правильно будет сказать, что указатели — это не количественные (т.е. которые отвечают на вопрос "сколько"), а порядковые (вопрос "какой по счету") числа. Хороший пример "из реальной жизни" — это календарь (например, годы — но подойдут так же и века или числа в месяце, например). По сути, с ними можно проделывать те же самые арифметические операции, что и с указателями: прибавлять и вычитать к ним "обычные" числа (2019-10=2009, 2019+10=2029; то есть, если сейчас 2019 год, то 10 лет назад был 2009, а через 10 лет наступит 2029-й), вычитать друг из друга (2019-1942=73; один из авторов языка Си, Брайан Керниган, родился в 1942 году, значит, в 2019 ему 73 года), причем в рамках единого "адресного пространства" (смысла нет, например, вычитать год по хиджре из григорианского года). Но никто в здравом уме не будет складывать годы друг с другом (2019+1942=3961 — какой в этом смысл (конечно, если 1942 — это тоже порядковый номер года, а не число лет)?) или умножать их на числа (2019*3=6057 — то же самое, особого смысла нет).


    1. iskorotkov Автор
      23.07.2019 09:23

      Да, спасибо. Поправил.


      Тут действительно имеются ввиду не простые числа (2, 3, 5, 7 и т.д.). "Просто числа" звучит однозначнее.


    1. pallada92
      23.07.2019 13:37

      Но никто в здравом уме не будет складывать годы друг с другом (2019+1942=3961 — какой в этом смысл (конечно, если 1942 — это тоже порядковый номер года, а не число лет)?) или умножать их на числа (2019*3=6057 — то же самое, особого смысла нет).

      Но если мы хотим найти середину отрезка между 1942 и 2019, то нам придется как складывать порядковые числа, так и умножать их на скаляр: (1942 + 2019) * 0.5, при этом результат будет вполне осмысленным.


      1. Mingun
        23.07.2019 18:26
        +1

        Вообще не обязательно: 1942 + (2019 - 1942) * 0.5 разницей получили число лет, которое спокойно можно умножать на скаляр (потому как это действительно просто число), а потом к году прибавили число лет — тоже вполне допустимая операция.