Указатель ссылается на ячейку памяти, а разыменовать указатель — значит считать значение указываемой ячейки. Значением самого указателя является адрес ячейки памяти. Стандарт языка C не оговаривает форму представления адресов памяти. Это очень важное замечание, поскольку разные архитектуры могут использовать разные модели адресации. Большинство современных архитектур использует линейное адресное пространство или аналогичное ему. Однако даже этот вопрос не оговаривается строго, поскольку адреса могут быть физическими или виртуальными. В некоторых архитектурах используется и вовсе нечисловое представление. Так, Symbolics Lisp Machine оперирует кортежами вида (object, offset) в качестве адресов.

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

#include <stdio.h>

int main(void) {
    int a, b;
    int *p = &a;
    int *q = &b + 1;
    printf("%p %p %d\n", (void *)p, (void *)q, p == q);
    return 0;
}

Если мы скомпилируем этот код GCC с уровнем оптимизации 1 и запустим программу под Linux x86-64, она напечатает следующее:

0x7fff4a35b19c 0x7fff4a35b19c 0

Обратите внимание, что указатели p и q ссылаются на один и тот же адрес. Однако результат выражения p == q есть false, и это на первый взгляд кажется странным. Разве два указателя на один и тот же адрес не должны быть равны?

Вот как стандарт C определяет результат проверки двух указателей на равенство:
C11 § 6.5.9 пункт 6

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

Прежде всего возникает вопрос: что такое «объект»? Поскольку речь идёт о языке C, то очевидно, что здесь объекты не имеют ничего общего с объектами в языках ООП вроде C++. В стандарте C это понятие определяется не вполне строго:
C11 § 3.15

Объект — это область хранения данных в среде выполнения, содержимое которой может использоваться для представления значений

ПРИМЕЧАНИЕ При упоминании объект может рассматриваться как имеющий конкретный тип; см. 6.3.2.1.

Давайте разбираться. 16-битная целочисленная переменная — это набор данных в памяти, которые могут представлять 16-битные целочисленные значения. Следовательно, такая переменная является объектом. Будут ли два указателя равны, если один из них ссылается на первый байт данного целого числа, а второй — на второй байт этого же числа? Комитет по стандартизации языка, разумеется, имел в виду совсем не это. Но тут надо заметить, что на этот счёт у него нет чётких разъяснений, и мы вынуждены гадать, что же имелось в виду на самом деле.

Когда на пути встаёт компилятор


Вернёмся к нашему первому примеру. Указатель p получен из объекта a, а указатель q — из объекта b. Во втором случае применяется адресная арифметика, которая для операторов «плюс» и «минус» определена следующим образом:
C11 § 6.5.6 пункт 7

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

Поскольку любой указатель на объект, не являющийся массивом, фактически становится указателем на массив длиной в один элемент, стандарт определяет адресную арифметику только для указателей на массивы — это уже пункт 8. Нас интересует следующая его часть:
C11 § 6.5.6 пункт 8

Если целочисленное выражение прибавляется к указателю или вычитается из него, результирующий указатель имеет тот же тип, что и исходный указатель. Если исходный указатель ссылается на элемент массива и массив имеет достаточную длину, то исходный и результирующий элементы отстоят друг от друга так, что разность между их индексами равна значению целочисленного выражения. Другими словами, если выражение P указывает на i-й элемент массива, выражения (P)+N (или равносильное ему N+(P)) и (P)-N (где N имеет значение n) указывают соответственно на (i+n)-й и (i?n)-й элементы массива, при условии что они существуют. Более того, если выражение P указывает на последний элемент массива, то выражение (P)+1 указывает на позицию за последним элементом массива, а если выражение Q указывает на позицию за последним элементом массива, то выражение (Q)-1 указывает на последний элемент массива. Если и исходный, и результирующий указатели ссылаются на элементы одного и того же массива либо на позицию за последним элементом массива, то переполнение исключено; в противном случае поведение не определено. Если результирующий указатель ссылается на позицию за последним элементом массива, к нему не может применяться унарный оператор *.

Из этого следует, что результатом выражения &b + 1 совершенно точно должен быть адрес, и, значит, p и q — это валидные указатели. Напомню, как определено равенство двух указателей в стандарте: "Два указателя равны тогда и только тогда, когда [...] один указатель ссылается на позицию за последним элементом массива, а другой — на начало другого массива, следующего сразу за первым в том же адресном пространстве" (C11 § 6.5.9 пункт 6). Именно это мы и наблюдаем в нашем примере. Указатель q ссылается на позицию за объектом b, за которым сразу же следует объект a, на который ссылается указатель p. Получается, в GCC баг? Это противоречие было описано в 2014 году как ошибка #61502, но разработчики GCC не считают его багом и поэтому исправлять его не собираются.

С похожей проблемой в 2016 году столкнулись программисты под Linux. Рассмотрим следующий код:

extern int _start[];
extern int _end[];

void foo(void) {
    for (int *i = _start; i != _end; ++i) { /* ... */ }
}

Символами _start и _end задают границы области памяти. Поскольку они вынесены во внешний файл, компилятор не знает, как на самом деле массивы расположены в памяти. По этой причине он должен здесь проявить осторожность и исходить из предположения, что они следуют в адресном пространстве друг за другом. Однако GCC компилирует условие цикла так, что оно всегда верно, из-за чего цикл становится бесконечным. Эта проблема описана вот в этом посте на LKML — там используется похожий фрагмент кода. Кажется, в данном случае авторы GCC все-таки учли замечания и изменили поведение компилятора. По крайней мере я не смог воспроизвести эту ошибку в версии GCC 7.3.1 под Linux x86_64.

Разгадка — в отчёте об ошибке #260?


Наш случай может прояснить отчёт об ошибке #260. Он больше касается неопределённых значений, однако в нём можно найти любопытный комментарий от комитета:

Реализации компиляторов [...] могут также различать указатели, полученные из разных объектов, даже если эти указатели имеют одинаковый набор битов.

Если понимать этот комментарий буквально, то тогда логично, что результат выражения p == q есть «ложь», так как p и q получены из разных объектов, никак не связанных между собой. Похоже, мы всё ближе подбираемся к истине — или нет? До сих пор мы имели дело с операторами равенства, а как насчёт операторов отношения?

Окончательная разгадка — в операторах отношения?


Определение операторов отношения <, <=, > и >= в контексте сравнения указателей содержит одну любопытную мысль:
C11 § 6.5.8 пункт 5

Результат сравнения двух указателей зависит от взаимного расположения указываемых объектов в адресном пространстве. Если два указателя на объектные типы ссылаются на один и тот же объект, либо оба ссылаются на позицию за последним элементом одного и того же массива, то такие указатели равны. Если указываемые объекты являются членами одного и того же составного объекта, то указатели на члены структуры, объявленные позже, больше указателей на члены, объявленные раньше, а указатели на элементы массива с большими индексами больше указателей на элементы того же массива с меньшими индексами. Все указатели на члены одного и того же объединения равны. Если выражение P указывает на элемент массива, а выражение Q — на последний элемент того же массива, то значение указателя-выражения Q+1 больше, чем значение выражения P. Во всех остальных случаях поведение не определено.

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

int *p = malloc(64 * sizeof(int));
int *q = malloc(64 * sizeof(int));
if (p < q) // неопределённое поведение
    foo();

Здесь указатели p и q ссылаются на два разных объекта, которые не связаны между собой. Поэтому результат их сравнения не определён. А вот в следующем примере:

int *p = malloc(64 * sizeof(int));
int *q = p + 42;
if (p < q)
    foo();

указатели p и q ссылаются на один и тот же объект и, следовательно, связаны между собой. Значит, их можно сравнить — если только malloc не вернёт нулевое значение.

Резюме


Стандарт C11 недостаточно строго описывает сравнение указателей. Наиболее проблемным моментом, с которым мы столкнулись, стал пункт 6 § 6.5.9, где явно разрешено сравнивать два указателя, ссылающиеся на два разных массива. Это противоречит комментарию из отчёта об ошибке #260. Однако там речь идёт о неопределённых значениях, и я не хотел бы строить свои рассуждения на основании одного лишь этого комментария и толковать его в другом контексте. При сравнении указателей операторы отношения определяются несколько иначе, чем операторы равенства — а именно, операторы отношения определены, только если оба указателя получены из одного и того же объекта.

Если отвлечься от текста стандарта и задаться вопросом, можно ли сравнивать два указателя, полученных из двух различных объектов, то в любом случае ответ, скорее всего, будет «нет». Пример в начале статьи демонстрирует скорее теоретическую проблему. Поскольку переменные a и b имеют автоматическую продолжительность хранения, наши предположения об их размещении в памяти будут ненадёжными. В отдельных случаях мы можем угадать, но совершенно очевидно, что такой код не получится безопасно портировать, и узнать смысл программы можно, только скомпилировав и запустив или деассемблировав код, а это противоречит любой серьёзной парадигме программирования.

Однако в целом я не удовлетворён формулировками в стандарте C11, и так как уже несколько человек столкнулось с этой проблемой, актуальным остаётся вопрос: почему бы не сформулировать правила яснее?

Дополнение
Указатели на позицию за последним элементом массива


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

const int num = 64;
int x[num];

for (int *i = x; i < &x[num]; ++i) { /* ... */ }

С помощью цикла мы обходим весь массив x, состоящий из 64 элементов, т.е. тело цикла должно выполниться ровно 64 раза. Но на самом деле условие проверяется 65 раз — на один раз больше, чем число элементов в массиве. В первые 64 итерации указатель i всегда ссылается внутрь массива x, тогда как выражение &x[num] всегда указывает на позицию за последним элементом массива. На 65-й итерации указатель i будет также ссылаться на позицию за концом массива x, из-за чего условие цикла станет ложным. Это удобный способ обойти весь массив, при этом он опирается на исключение из правила о неопределённости поведения при сравнении таких указателей. Обратите внимание, что стандарт описывает поведение лишь при сравнении указателей; их разыменование — это отдельная тема.

Можно ли изменить наш пример так, чтобы на позицию за последним элементом массива x не ссылался бы ни один указатель? Можно, но это будет сложнее. Придётся изменить условие цикла и запретить инкремент переменной i на последней итерации.

const int num = 64;
int x[num];

for (int *i = x; i <= &x[num-1]; ++i) {
        /* ... */
        if (i == &x[num-1]) break;
}

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

Примечание команды PVS-Studio

При разработке анализатора кода PVS-Studio нам приходится иногда разбираться с тонкими моментами, чтобы сделать диагностики более точными или чтобы давать подробные консультации нашим клиентам. Эта статья показалась нам интересной, так как затрагивает вопросы, в которых мы сами до конца не чувствуем себя уверенными. Поэтому мы попросили у автора выложить её перевод. Надеемся, так с ней познакомится больше C и C++ программистов и поймут, что не всё так просто и что, когда вдруг анализатор выдаёт странное сообщение, не стоит сразу спешить считать его ложным срабатыванием :).

Статья впервые была опубликована на английском языке на сайте stefansf.de. Перевод публикуются с разрешения автора.

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


  1. nafgne
    24.07.2018 13:29

    Зачем это так сделано?


    1. amarao
      24.07.2018 15:14
      +2

      Не «зачем», а «как так получилось».

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

      Потом язык пошёл в массы и появились разные компиляторы. Пришлось придумать стандарт, который бы позволил разным компиляторам давать такой же результат на таком же коде. В процессе разработки стандарта опирались на существующую (-ие?) реализацию (-ии) и попытались дать полное описание для дырявых абстракций.

      Получилось как получилось.


    1. khim
      25.07.2018 01:09

      На самом деле, как ни странно, ответ на этот вопрос есть — просто людям он часто не нравится.

      Язык C изначально создан для написания одной-единственной вещи: операционной систему UNIX.

      А она, как известно, является переносимой.

      Сделать же язык, на котором можно написать эффективную переносимую программу — не так-то просто.

      Это сейчас — все процессоры похожи. А в те времена, когда C создавался — между ними была масса различий. И можно было либо делать так, как в Java — регламентировать, что получится, скажем в результате операции «1<<256», и наплевать на эффективности… или запретить программистам использовать «неправильные» конструкции.

      Что и было проделано: «неправильные» конструкции, которые могут вести себя по разному на разном железе и в разных ОС в C — запрещены. Вот только создание и полное описание того, что такое «неправильная» конструкция — оказалось не так-то просто сделать


      1. nafgne
        25.07.2018 12:36

        /me не понимает, почему просто не сделать сравнение указателей арифметической операцией, если с объектами полноценно работать нельзя.


        1. Dicebot
          25.07.2018 13:01

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


          1. nafgne
            25.07.2018 14:12

            Так объекты — это же виртуальные сущности, полностью контролируемые компилятором. Или мы про упоротые случаи, вроде сопроцессоров поддержки Java?


            1. Dicebot
              25.07.2018 14:34

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


            1. khim
              25.07.2018 19:41

              Мы не про «упоротые сущности». Мы про «упоротые архитектуры». В XXI веке — да, их мало, а вот в XX — чуть не каждый первый процессор изобретал какой-нибудь особый способ адресовать больше 64K при использовании только 16-битных регистров.

              А современная культура, когда можно было потратить 90% ресурсов только на то, чтобы облегчить жизнь программисту — ещё не овладела массами (на самом деле первая попытка уже тогда была… но не сложилась… дикие люди — не понимали что для их же блага лучше то, что за что можно заплатить два рубля лучше покупать за сто).

              В результате — писать нужно было так, чтобы на всех платформах (а среди них, напомним, «плоская» память была скорее исключением, чем правилом!) код работал…

              Ну а потом — да, пришли люди, пытающиеся использовать C/C++ как замену для ассемблера… и начались беды с «undefined behavior», да…


          1. khim
            25.07.2018 15:03
            +1

            Это вполне легально по стандарту С.
            Это не только вполне легально. Это, чёрт побери, банальный Turbo C! Указатели могут указывать в одно (физически) место в памяти, но быть, при этом, разными.


  1. alexxisr
    24.07.2018 13:59

    «либо один указатель ссылается на позицию за последним элементом массива, а другой — на начало другого массива, следующего сразу за первым в том же адресном пространстве.»
    — но в первом примере никто не может гарантировать, что a и b расположены в одном адресном пространстве, поэтому gcc и считает, что указатели не могут быть равны.


    1. mayorovp
      24.07.2018 14:03

      Что значит «не может гарантировать» когда на платформе всего одно адресное пространство?


      1. Deosis
        24.07.2018 15:31

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


        1. mikelavr
          24.07.2018 21:02
          +3

          Не просто возможна, а прямо таки норма для embedded процессоров, где программа хранится и выполняется из flash памяти, а данные хранятся в оперативной памяти.

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

          Все это, разумеется, для экономии ресурсов. Нежно любимый мной PIC16F84A имел 2048 слов кода и 68 байт оперативной памяти…
          www.microchip.com/wwwproducts/en/PIC16F84A


          1. mayorovp
            24.07.2018 21:26

            Вряд ли автор поста компилировал код для embedded процессора…


            1. mikelavr
              24.07.2018 22:06
              +2

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


            1. khim
              25.07.2018 01:34

              Небольшой комментарий к замечанию mikelavr. Цепочка рассуждений следующая:
              0. Язык C предназначен для написания переносимых программ.
              0a. Никто и никогда не должен писать непереносимый код на C.
              0b. Задача программиста — написать корректную и переносимую программу.
              1. Соотвественно любая конструкция рассматривается исключительно в разрезе исполнения соотвествующего кода на всех известных науке процессорах и со всеми известными науке операционками.
              2. Если какой-то код хоть где-то может вести себя странно — то это значит, что программист сделает так, чтобы этот код не вызвался. Как — его проблемы.

              Очень много разработчиков этим подходом недовольны и им очень не нравится такое «своевольничание», но… если мы откажемся от этого подхода — то это будет уже другой язык, предназначенный для других целей.


              1. vsb
                25.07.2018 03:18

                Есть мнение, что язык C изначально воспринимался людьми, как эдакий переносимый ассемблер чуть более высокого уровня. Причём если человек пишет код для x86, например, то его совершенно не волнуют проблемы всяких экзотических архитектур, на которых его код никогда не будет работать. То бишь человек представляет, как выглядит машинный код x86 и пишет код на C, ожидая, что он будет компилироваться очевидным образом в этот код. Если брать пример из статьи, то он ожидает, что сравнение указателей будет брать и сравнивать, собственно, 32-битные (или 64-битные, если речь о x64) значения-адреса. И когда компилятор начинает вести себя не как ожидает программист, апеллируя тем, что на архитектуре из созвездия Ориона этот код работать не будет, человек совершенно справедливо негодует, ему эта архитектура не нужна и её проблемы не интересны.

                С другой стороны переносимость может вылезти в совсем неожиданных местах. Никто не думал-не гадал, а в браузерах внезапно появилась и практически везде поддерживается виртуальная машина webassembly, которая вполне себе цель для компиляции и в то же время вполне себе экзотическая. И вполне может быть, что завтра код, который вроде как планировался сугубо для обычных компьютеров, захотят увидеть в браузере. Туда же внезапные позывы десктопов на ARM. Поэтому портируемость штука такая, вроде оно и не надо, а вроде бывает и нужно. И если компилятор постреляет по пяткам пораньше, то потом проблем будет меньше.

                Плюс, вероятно, такие абстракции позволяют разворачивать всякие хитрые макросы в кучу лапшеобразного кода, и потом компилятор из него погрохает всё лишнее и останется быстрый код, туда же, наверное, и inline функций, то бишь такой «нелогичный» С позволяет делать производительные абстракции, которые были бы куда менее производительны в случае тупого компилятора. Это я предполагаю, что программист сам код пишет нормально и дурацкий код может получаться только из-за кодогенерации, организованной тем или иным способом, что, конечно, тоже не всегда верно.


                1. khim
                  25.07.2018 19:49

                  То бишь человек представляет, как выглядит машинный код x86 и пишет код на C, ожидая, что он будет компилироваться очевидным образом в этот код.
                  Вот только очевидность — она у всех разная.

                  Вот, например:
                  #include <stdio.h>
                  
                  struct A {
                    int x;
                    int y;
                  };
                  
                  int foo() {
                    struct A a;
                    a.x = 2;
                  }
                  
                  int bar() {
                    struct A a;
                    a.y = 2;
                  }
                  
                  int baz() {
                    struct A a;
                    return a.x + a.y;
                  }
                  
                  int main() {
                    foo();
                    bar();
                    printf("%d\n", baz());
                  }
                  
                  $ gcc test.c -O0 -o test && ./test
                  4
                  
                  Вполне себе такой код в духе Fortran-66. И если код на C будет «очевидным образом» компилироватья в машинный код — даже корректный. А с -O0 он даже на современных компиляторах работает.

                  Но вы действительно хотите, чтобы оптимизаторы подобные трюки поддерживали? Вы хотя бы представляете — насколько это замедлит типичную программу?

                  Призывы к тому, чтобы сделать «лучшую», более безопасную спецификацию C — звучат давно. Но при попытки их воплотить в жизнь мы неизменно напарываемся на неочевидность очевидности…


                  1. alexxisr
                    26.07.2018 06:07

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


                    1. mayorovp
                      26.07.2018 10:01

                      Или здесь работает то, что они остаются лежать в стеке с прошлого вызова?

                      Да, именно этот фактор и срабатывает.


              1. mayorovp
                25.07.2018 08:43

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

                Если бы в стандарте было написано что-то вроде «нельзя сравнивать указатели полученные из разных объектов не являющихся частями одного объекта» — я бы с вами согласился. Но я вижу совсем другие цитаты.


                1. lany
                  25.07.2018 13:00

                  Любая свобода в стандарте — это простор для оптимизации компилятора, чем компиляторы и пользуются. Полагаю, что тут вне зависимости от целевой архитектуры (где-нибудь на уровне middle level intermediate language) компилятор заметил, что указатели основаны на разных объектах, а значит, легально заменить выражение сравнения на константу 0. Кодогенератор целевой платформы в бэкенде компилятора уже не видит сравнения, а видит просто 0. Возможна и более агрессивная оптимизация, когда этот ноль прямо подставится в строку формата "%p %p 0" на этапе компиляции.


                  1. mayorovp
                    25.07.2018 13:02

                    Механику произошедшего я понимаю. А вот отношение стандарта к происходящему остается для меня загадкой.


                1. Dicebot
                  25.07.2018 14:28

                  Вообще-то приведенная в статье цитата из 6.5.8 говорит об этом прямым текстом — "Во всех остальных случаях поведение не определено" (после списка валидных сравнений).


                  1. mayorovp
                    25.07.2018 14:33

                    Вы пост-то читали? Случай — не остальной, а перечисленный:


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

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


                    1. Dicebot
                      25.07.2018 14:38

                      Все дело в формулировке "следующего сразу за первым в том же адресном пространстве".


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


                    1. khim
                      25.07.2018 15:13

                      Почитайте тот же раздел в C++17.

                      Думаю что разработчики компиляторов справедливо полагают, что это ошибка в стандарте, которая в C22 (или когда у нас там следующий по плану?) будет исправлена.


                      1. mayorovp
                        25.07.2018 15:14

                        Тогда ок, принимается.


                1. khim
                  25.07.2018 19:50

                  Если бы в стандарте было написано что-то вроде «нельзя сравнивать указатели полученные из разных объектов не являющихся частями одного объекта» — я бы с вами согласился.
                  В C++17 так и написно. А в C11 — в обном месте поправили, в другом — забыли…


    1. mayorovp
      24.07.2018 14:09

      Да нет, единое адресное пространство как раз гарантируется. Просто потому что у наиболее распространенной платформы всего одно адресное пространство.


      1. alexxisr
        24.07.2018 14:27

        Почему нельзя, например, разместить a в регистре, а b — в оперативке?
        Компилятор вполне может свернуть все указатели на регистр в нужные инструкции.


      1. khim
        25.07.2018 01:36

        Просто потому что у наиболее распространенной платформы всего одно адресное пространство.
        Языки, которые используются для написания кода только и исключительно для «наиболее распространённых платформ» не называются С и C++. Это могут быть C++/CLI или там F# — но не C и не C++.

        Код на C должен работать на всех платформах — а не только на «наиболее распространённых».


        1. mayorovp
          25.07.2018 08:44

          Так он и работает, только выдает разные результаты (и это ожидаемо!)

          Вопрос лишь в том почему конкретно в этом случае он выдал такой результат.

          PS если я случайно отправил комментарий два раза, вовсе не обязательно отвечать на каждый :-)


          1. khim
            25.07.2018 15:16

            Так он и работает, только выдает разные результаты (и это ожидаемо!)
            Нет. Это не ожидаемо. Ожидаемо — это когда результат в стандарте описан как implementation defined. Таких места там очень мало.

            Во всех остальных случаях считается, что нас устраивает любой результат — и компилятор его и обеспечивает. Скажем участок кода в 0 байт длиной обеспечивает какой-то результат, исполняется быстро и вообще хорошо… вот его вы и получите.


      1. Miron11
        25.07.2018 08:30
        -1

        Но ведь стандарт С не говорит, что оно «должно» быть едино. Поэтому гарантия, на которой Вы настаиваете, скорее вопрос среды. И даже если, допустим, сегодня такую гарантию можно дать, чисто теоретически, никто не может гарантировать что завтра ничего не изменится.

        И потом, даже в среде, которую Вы имеете ввиду, единое адресное пространство гарантировано для виртуальной памяти. А С он, говоря по — Английски «promiscuous» и может, на той же платформе, с той же оболочкой, выполняться внутри некоего физического устройства, с собственной архитектурой физической памяти, которая может быть разделена на секторы, с непреодолимыми барьерами, и С будет в этом устройстве выполняться, по совершенно другим правилам, и здесь гарантии единого адресного пространства совершенно неприменимы.

        Но не в этом дело. Мне кажется С был создан для того, чтобы некоторые устройства работали. Он не создавался для того, чтобы были правила. И поэтому он так горячо любим. Язык сорванец :)


  1. HighPredator
    24.07.2018 14:48

    Спасибо за интересный обзор. Действительно понимание таких тонкостей разработчикам дается достаточно тяжело. Кстати, на базе вашего первого примера можно вообще сделать нечто, ломающее мозг. Например:

    #include <stdio.h>
    
    int main(void) {
        int a, c, b;
    
        (void)c;
    
        int *p = &a;
        int *q = &b + 1;
        printf("%p %p %d\n", (void *)p, (void *)q, p == q);
        return 0;
    }
    Результат:
    0xffffcbec 0xffffcbec 1
    gcc version 6.4.0 x86_64-pc-cygwin (-O0 -Wall -Wextra -Werror -std=c11)


    1. khabib
      24.07.2018 15:39

      А можете обьяснить почему так?


      1. iOrange
        24.07.2018 16:02

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


      1. 0xdde
        24.07.2018 16:05

        Дело не в третьей переменной, а именно в -O0. С -O1 будет как в примере в статье.


        Если посмотреть дизассемблер, с оптимизацией p == q предподсчитывается как 0.


            printf("%p %p %d\n", (void *)p, (void *)q, p == q);
         6b4:   48 8d 54 24 0c          lea    0xc(%rsp),%rdx
         6b9:   48 89 d6                mov    %rdx,%rsi
         6bc:   b9 00 00 00 00          mov    $0x0,%ecx # Вот здесь просто пишется 0 в ecx
         6c1:   48 8d 3d 9c 00 00 00    lea    0x9c(%rip),%rdi 
         6c8:   b8 00 00 00 00          mov    $0x0,%eax
         6cd:   e8 8e fe ff ff          callq  560 <printf@plt>

        Без оптимизации делается честный cmp.


            printf("%p %p %d\n", (void *)p, (void *)q, p == q);
         6cc:   48 8b 45 f8             mov    -0x8(%rbp),%rax
         6d0:   48 3b 45 f0             cmp    -0x10(%rbp),%rax # Тот самый cmp
         6d4:   0f 94 c0                sete   %al
         6d7:   0f b6 c8                movzbl %al,%ecx
         6da:   48 8b 55 f0             mov    -0x10(%rbp),%rdx
         6de:   48 8b 45 f8             mov    -0x8(%rbp),%rax
         6e2:   48 89 c6                mov    %rax,%rsi
         6e5:   48 8d 3d 98 00 00 00    lea    0x98(%rip),%rdi  
         6ec:   b8 00 00 00 00          mov    $0x0,%eax
         6f1:   e8 6a fe ff ff          callq  560 <printf@plt>


      1. iOrange
        24.07.2018 16:18

        Значит я не правильно понял вопрос khabib, подумал вопрос был «почему &b + 1 == &a».


        1. LynXzp
          24.07.2018 18:34

          А не все что угодно может быть? Кажется только структуры гарантируют последовательное расположение переменных. И то с выравниванием.
          А тут «так совпало», пусть даже ожидаемо.


          1. bugdesigner
            25.07.2018 06:59

            Объяснить легко — переменные внутри функций размещаются в стеке, а стек обычно растет "вниз" (на некоторых архитектурах возможно и нет, но на x86/x64 — точно). То есть следующий помещаемый элемент будет иметь адрес меньше предыущего, что мы и видим в примере. Если объявить эти переменные вне функции (глобальными), фокус может и не сработать — все зависит от реализации компилятора.
            Насчет сравнения указателей — это, как уже сказали, козни оптимизации.


            1. bugdesigner
              25.07.2018 07:30

              Вот, пример, когда переменные a1, b1 глобальные, те не помещены в стек. Адреса назначаются в другом порядке.
              #include <stdio.h>
              int a1;
              int b1;

              int main() {
              int a;
              int b;

              int *p = &a;
              int *q = &b + 1;

              int *p1 = &a1;
              int *q1 = &b1 + 1;

              printf("%p %p %d\n", (void *)p, (void *)q, p == q);
              printf("%p %p %d\n", (void *)p1, (void *)q1, p1 == q1);
              return 0;
              }

              Компилируем без оптимизации
              $ gcc 1.c
              $ ./a.out
              0xbedd7614 0xbedd7614 1
              0x205c8 0x205d0 0
              Проверил на Linux x86_64(GСС), Linux armhf (GCC), а также на ARM Cortex-M0 (компилятор ARMCC) — результаты аналогичны, не стал копипастить.


    1. Greendq
      24.07.2018 16:02

      Вы подвесили меня на минуту. Как???? Почему???


  1. 0xdde
    24.07.2018 15:52
    +2

    В переводе с первым примером упущена существенная деталь.

    If compiled with and optimization level 1, then a run of the program on a x86-64 Linux system prints:

    Без оптимизации результат другой.

    > gcc main.c
    > ./a.out
    0x7ffd9dad19fc 0x7ffd9dad19fc 1
    > gcc main.c -O1
    > ./a.out
    0x7ffe4b876ebc 0x7ffe4b876ebc 0

    gcc version 6.3.0 20170516 (Debian 6.3.0-18+deb9u1)


    1. Andrey2008 Автор
      24.07.2018 16:41

      Добавил.


  1. IBAH_II
    24.07.2018 17:54

    C11 § 6.5.9 пункт 6

    Два указателя равны тогда и только тогда…


    сакральные тексты перевели на русский?! Где смотреть?


    1. Andrey2008 Автор
      24.07.2018 21:29
      +1

      1. IBAH_II
        25.07.2018 18:43

        такого добра я богато читал…
        Если PVS-Studio действительно хочет прославится могли бы перевести С11 на русский. Все бы сишные программисты рассказывали какая PVS-Studio крутая компания и какие у нее крутые продукты.
        Был у меня перевод С89 выполненный советской властью, отпечатанный на ЦПУ на бумаге с дырочками по бокам…


        1. HighPredator
          26.07.2018 08:59
          +2

          Тут пару лет назад господа Зуев и Чупринов сделали перевод плюсового стандарта, проделав колоссальный объем работы. Совокупное мнение получилось неоднозначным. Тут специфика в том, что это не просто техническая литература, а еще и ISO-шная. И тут две проблемы. Во-первых, по моему глубокому убеждению, именно такую литературу нужно переводить по ISO-шным же стандартам переводов для максимальной точности. Во-вторых, чисто стандарта недостаточно. Надо как у юристов: условно том закона + том комментариев. С учетом всего, думаю PVS-Studio не захочет. Полезность сомнительна, затраты большие, покупать результат (а он выйдет по опыту книги выше недешевым) будут плохо.


  1. AVI-crak
    24.07.2018 18:22

    Интересная тема, как минимум направление.
    У меня есть собственный зуд на уровне чистого как слеза Си, без плюсов.
    Хотелось сделать универсальные функции под большинство типов, одна общая декларация и масса вариантов решений под каждый тип. Да, всё это можно сделать на Си++, но зуд!!!
    Получилось три универсальных заготовки определения типов, в комбинации которых можно распознать float/uint(8~32)_t /int(8~32)_t. С небольшими дополнениями будут распознаваться 64-128бит числа.
    #define ICE_TYPE(type_x) (__typeof__(type_x))
    #define ICE_i(var) (ICE_TYPE (var) (0) > -1? 0: 1 )
    #define ICE_F(var) (ICE_TYPE (var) (0.1f) == 0? 0: 1 )
    bitbucket.org/AVI-crak/sprint/src/default/sPrint.h

    Но вот чего я не смог найти — так это решения по поводу указателей.
    На словах всё просто, но написать работающий макрос не получается.
    Собственно — каким образом силами Си отличить тип указателя от всего остального.?


    1. khim
      25.07.2018 01:52

      Так вы ж уже всё равно вышли за рамки C! Вызовите __builtin_classify_type(p) и сравните с пятёркой… делов-то.

      P.S. Для clang'а лучше делать через __overloadable__.

      P.P.S. Что делают другие компиляторы — не знаю. Но любой C99-совместимый компилятор должен такие трюки уметь. Ибо tgmath.h. Проблема в том, что это стандартная библиотека такое должна уметь — а людям такие ручки давать не положено.


      1. AVI-crak
        25.07.2018 10:44

        Спасибо за направление.
        Поиском по __builtin_classify_type(p) нашлось много интересного.
        И да, основная идея доработать math. Буквально переписать все стандартные функции под актуальные для мк типы, на три вида результата: точный, оптимальный и быстрый. С быстрым переключением режима прямо в коде пользователя.
        __overloadable__ — согласен, для моего варианта это оптимально.


  1. Dark_Purple
    24.07.2018 19:11
    -1

    В первом примере по моему все четко, правильно и логично,- сравниваются два разных указателя, а не то на что они указывают. А вот так p == q доджно быть true.


    1. Dark_Purple
      24.07.2018 19:13

      Заезды с телевона не вставились почему-то перед p и q


      1. mayorovp
        24.07.2018 21:29
        +1

        Это не звезды не вставились, это Markdown включился. Там пара звезд работает как тэг <i>


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


        1. Dark_Purple
          24.07.2018 23:10

          Проверка *p ==*q
          использовал тег

          `


          1. Dark_Purple
            24.07.2018 23:19

            Невозможно напечатать вместо этой закарючки ‘ тег <code> </code>
            :)


    1. 0serg
      24.07.2018 23:02

      Два разных указателя на один и тот же адрес в памяти, о чем наглядно свидетельствует вывод print-а где печатаются адреса указателей а не то на что они указывают.



  1. Nick_Shl
    24.07.2018 22:44

    А если так:

    printf("%p %p %d\n", (void *)p, (void *)q, (void *)p == (void *)q);
    а?


  1. KirEv
    25.07.2018 03:32

    Обратите внимание, что указатели p и q ссылаются на один и тот же адрес

    int a, b;   
    int *p = &a;
    int *q = &b + 1;


    я один не понимаю зачем это сделано и почему p и q ссылаются на один и тот же адрес?

    и далее почему то пытаются сравнить

    printf("%p %p %d\n", (void *)p, (void *)q, p == q);


    и почему вообще должно давать положительный результат p == q, если мы сделали

    int *q = &b + 1
    ,

    и вообще что сейчас происходить с q ??

    У меня вопрос: зачем так делать и кто так делает?

    И отсебятины ответ: если вы так делаете, в архитектуре вашей программы чтото не так…

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

    И вообще2: почему не делать программы явными… если сравнение указателей вызывает подозрения, вместо описания и добавление возможный опций компиляций — инным способом реализовать эту часть логики программы?


    1. 0serg
      25.07.2018 08:08

      При выделении памяти компилятор обычно разместит a и b сразу друг за другом

      То же самое он бы сделал если бы мы попросили вместо int a,b выделить int a[2]. То есть пример в каком-то смысле эквивалентен
      int main(void) {
      int mem[2];
      int *b = mem[0];
      int *a = mem[1];
      int *p = a;
      int *q = b + 1;
      printf("%p %p %d\n", (void *)p, (void *)q, p == q);
      return 0;
      }

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

      extern int _start[];
      extern int _end[];

      void foo(void) {
      for (int *i = _start; i != _end; ++i) { /* ... */ }
      }

      В этом безобидно выглядящем примере ряд компиляторов с включенными оптимизациями автоматически и зачастую молча убирают проверку на (_start==_end) в сгенерированном коде со всеми вытекающими последствиями


      1. KirEv
        25.07.2018 08:28

        по моему, часто в казино нужно выигрывать, чтобы рассчитывать на последовательное выделение адресов, мне казалось, этими делами вовсе не компилятор занимается (порядок выделения адресов и т.п.), это дело ОС и архитектуры, какой-то блок новый, какой-то переиспользованный, вплоть до получить для «а» один из первых адресов, для «б» — последний после последнего использованного, или вообще где-то в конце… почему компилятор должен отвечать за глупости «int *q = &b + 1»?

        в этом, пожалуй, отличие Си от так называемых современных языков и т.п., там можно делать странные вещи, которые вроде бы должны вести себя непредсказуемо — но работают, в си халява не прокатить, напартачишь с нескольких местах с указателями и смещениями — потом отлаживай днями…

        просто нужно делать, просто


        1. 0serg
          25.07.2018 10:42

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


        1. Ogra
          25.07.2018 11:10

          этими делами вовсе не компилятор занимается (порядок выделения адресов и т.п.), это дело ОС и архитектуры

          Компилятор под конкретную архитектуру этим занимается. И на x86 такое выделение вполне себе нормальное, и будет работать ну очень часто. Это ж не куча, а стек, тут все предсказуемо.

          там можно делать странные вещи, которые вроде бы должны вести себя непредсказуемо — но работают

          О, в Си можно делать очень много странных вещей. Например, на x86 из-за выравнивая адресов в памяти, malloc всегда возвращает указатель (а указатель это число, как мы помним) с нулевыми младшими битами (минимум 2 бита будут нулевыми). Следовательно, мы можем в эти два бита положить какую-нибудь свою информацию! Естественно, для использования указателя нужно будет эти биты сбрасывать. Итого, фича должна себя вести непредсказуемо, но она работает.


  1. Sirikid
    25.07.2018 04:17

    del


    1. bugdesigner
      25.07.2018 08:00

      Почему так происходит уже ответил выше. А делать так, конечно же не стоит. Просто С допускает такие вольности, но это на некоторых архитектурах может дать совсем другие результаты.


  1. Sirikid
    25.07.2018 09:18

    В независимости от уровня оптимизации и компилятора, не воспроизводится — либо указатели равны и сравнение возвращает 1, либо неравны и сравнение возвращает 0.


    test.fish
    #!/bin/fish
    
    echo >wat.c "#include <stdio.h>
    int main(void) {
      int a, b, *p = &a, *q = &b + 1;
      printf(\"%p %p %d\\n\", (void *)p, (void *)q, p == q);
    }
    "
    
    for cc in gcc clang
        eval $cc --version
        for opt in 0 1 2 3 s
            eval $cc -Werror -Wall -Wextra -Wpedantic wat.c
            echo -n -- "-O$opt: "
            ./a.out
        end
        echo =====
    end


    1. Gromin
      25.07.2018 10:35

      test.sh
      #!/bin/sh

      echo >wat.c "#include <stdio.h>
      int main(void) {
      int a, b, *p = &a, *q = &b + 1;
      printf(\"%p %p %d\\n\", (void *)p, (void *)q, p == q);
      return (0);
      }
      "

      gcc --version
      for opt in 0 1 2 3 s; do
      gcc -Werror -Wall -Wextra -Wpedantic -O${opt} wat.c
      echo -n "-O${opt}: "
      ./a.out
      done


    1. 0serg
      25.07.2018 10:49

      Для GCC на Вашей машине надо в примере поменять в декларации a и b местами
      int b,a;
      В правильной версии кода первые два числа (адреса) должны быть одинаковы. Если они разные — то пример сломан. Как этого добиться — зависит от компилятора. Суть проблемы показывает лишь третье число при условии что первые два равны.

      Выше к слову есть отличная ссылка из которой следует что пример можно изящно расширить
      #include <stdio.h>

      int main(void) {
      int a = 1, b = 2;
      int *p = &a;
      int *q = &b + 1;
      *p = 11;
      printf("%p %p %d %d %d\n", (void *)p, (void *)q, p == q, a, b);
      return 0;
      }


    1. lany
      25.07.2018 13:08

      Вы в test.fish в командную строку компилятора не добавили опцию -O, только вывели её на экран.


      1. Sirikid
        25.07.2018 15:08

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


  1. WinPooh73
    25.07.2018 14:23

    > int *p = malloc(64 * sizeof(int));
    > int *q = malloc(64 * sizeof(int));
    > if (p < q) // неопределённое поведение
    > foo();

    То есть теоретически выполнение такого сравнения может отформатировать жёсткий диск, или сделать всё остальное, чем обычно пугают при рассказах о UB? Как страшно программировать на Си…