Является ли «взлом структуры» технически неопределенным поведением?

111

Я спрашиваю о хорошо известном трюке «последний член структуры имеет переменную длину». Это выглядит примерно так:

struct T {
    int len;
    char s[1];
};

struct T *p = malloc(sizeof(struct T) + 100);
p->len = 100;
strcpy(p->s, "hello world");

Благодаря тому, как структура размещается в памяти, мы можем наложить структуру на больший, чем необходимо, блок и обрабатывать последний член так, как если бы он был больше 1 charуказанного.

Итак, вопрос: является ли эта техника технически неопределенным поведением? . Я ожидал, что это так, но мне было любопытно, что об этом говорится в стандарте.

PS: Мне известен подход C99 к этому, я хотел бы, чтобы ответы касались именно версии трюка, как указано выше.

Эван Теран
источник
33
Это кажется вполне ясным, разумным и, прежде всего, ответом на вопрос. Не вижу причины закрытого голосования.
cHao
2
Если бы вы представили компилятор "ansi c", который не поддерживает struct hack, большинство моих знакомых программистов на c не согласятся с тем, что ваш компилятор "работает правильно". Несмотря на то, что они приняли бы строгое прочтение стандарта. Комитет просто упустил один из них.
dmckee --- котенок экс-модератора
4
@james. Этот хакер работает, разрушая объект, достаточно большой для массива, который вы имеете в виду, несмотря на объявление минимального массива. Итак, вы обращаетесь к выделенной памяти вне строгого определения структуры. Запись за пределами вашего выделения - бесспорная ошибка, но это отличается от записи в вашем распределении, но вне «структуры».
dmckee --- котенок экс-модератора
2
@ Джеймс: Здесь очень важен негабаритный malloc. Он обеспечивает наличие памяти - памяти с юридическим адресом, «принадлежащей» структуре (т. Е. Ее использование запрещено любым другим объектом) - за номинальным концом структуры. Обратите внимание, что это означает, что вы не можете использовать взлом структуры для автоматических переменных: они должны быть динамически распределены.
dmckee --- котенок экс-модератора
5
@detly: проще выделить / освободить одну вещь, чем выделить / освободить две вещи, тем более что у последнего есть два способа отказа, с которыми вам нужно иметь дело. Для меня это имеет большее значение, чем предельная экономия затрат / скорости.
jamesdlin

Ответы:

52

Как сказано в C FAQ :

Не ясно, легально это или портативно, но довольно популярно.

и:

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

Обоснование бита «строгое соответствие» содержится в спецификации, раздел J.2 «Неопределенное поведение» , которое включено в список неопределенного поведения:

  • Нижний индекс массива находится за пределами допустимого диапазона, даже если объект явно доступен с заданным нижним индексом (как в выражении lvalue с a[1][7]учетом объявления int a[4][5]) (6.5.6).

В параграфе 8 Раздела 6.5.6 Аддитивные операторы еще раз упоминается, что доступ за пределы определенных границ массива не определен:

Если и операнд-указатель, и результат указывают на элементы одного и того же объекта массива или на один за последним элементом объекта массива, оценка не должна вызывать переполнения; в противном случае поведение не определено.

Карл Норум
источник
1
В коде OP p->sникогда не используется как массив. Он передается strcpy, и в этом случае он распадается на простой char *, который указывает на объект, который можно юридически интерпретировать как char [100];внутри выделенного объекта.
R .. GitHub НЕ ПОМОГАЕТ ICE
3
Возможно, другой способ взглянуть на это заключается в том, что язык мог бы предположительно ограничить доступ к фактическим переменным массива, как описано в J.2, но он не может сделать такие ограничения для объекта, выделенного malloc, когда вы просто преобразовали возвращенные void *на указатель на [структуру, содержащую] массив. По-прежнему можно получить доступ к любой части выделенного объекта, используя указатель на char(или предпочтительно unsigned char).
R .. GitHub НЕ ПОМОГАЕТ ICE
@Р. - Я понимаю, что J2 может не покрывать это, но разве это также не покрывается 6.5.6?
Detly
1
Конечно, может! Информация о типе и размере может быть встроена в каждый указатель, и любая арифметика с ошибочными указателями может затем быть обработана - см., Например, CCured . На более философском уровне не имеет значения, сможет ли вас поймать никакая возможная реализация , это все еще неопределенное поведение (есть, iirc, случаи неопределенного поведения, которые потребовали бы оракула для решения проблемы остановки - именно поэтому они не определены).
zwol
4
Объект не является объектом массива, поэтому 6.5.6 не имеет значения. Объект - это блок памяти, выделенный malloc. Поищите "объект" в стандарте перед тем, как выбросить bs.
R .. GitHub НЕ ПОМОГАЕТ ICE
34

Я считаю, что технически это неопределенное поведение. Стандарт (возможно) не обращается к нему напрямую, поэтому он подпадает под действие «или из-за отсутствия какого-либо явного определения поведения». пункт (§4 / 2 C99, §3.16 / 2 C89), в котором говорится, что это неопределенное поведение.

Вышеупомянутое «возможно» зависит от определения оператора индексации массива. В частности, в нем говорится: «Постфиксное выражение, за которым следует выражение в квадратных скобках [], является обозначением объекта массива с нижним индексом». (C89, §6.3.2.1 / 2).

Вы можете утверждать, что здесь нарушается «объект массива» (поскольку вы указываете индекс за пределами определенного диапазона объекта массива), и в этом случае поведение (немного больше) явно undefined, а не просто undefined любезно ничего не определяющего.

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

Джерри Гроб
источник
2
Я также могу представить компилятор, который мог бы решить, что если бы массив оказался размером 1, то его arr[x] = y;можно было бы переписать как arr[0] = y;; для массива размером 2 его arr[i] = 4;можно переписать как: i ? arr[1] = 4 : arr[0] = 4; Хотя я никогда не видел, чтобы компилятор выполнял такую ​​оптимизацию, в некоторых встроенных системах они могут быть очень продуктивными. На PIC18x, использующем 8-битные типы данных, код для первой инструкции будет шестнадцать байтов, второй - два или четыре, а третий - восемь или двенадцать. Неплохая оптимизация, если она легальна.
supercat
Если стандарт определяет доступ к массиву вне границ массива как неопределенное поведение, то взлом структуры тоже. Если, однако, стандарт определяет доступ к массиву как синтаксический сахар для арифметики указателя ( a[2] == a + 2), это не так. Если я прав, все стандарты C определяют доступ к массиву как арифметический указатель.
yyny 05
13

Да, это неопределенное поведение.

Отчет о дефектах языка C # 051 дает окончательный ответ на этот вопрос:

Идиома, хотя и распространена, не совсем соответствует

http://www.open-std.org/jtc1/sc22/wg14/www/docs/dr_051.html

В документе C99 Rationale Комитет C добавляет:

Обоснованность этой конструкции всегда вызывала сомнения. В ответ на один отчет о дефектах комитет решил, что это было неопределенное поведение, потому что массив p-> items содержит только один элемент, независимо от того, существует ли пространство.

ouah
источник
2
+1 за обнаружение этого, но я все еще утверждаю, что это противоречиво. Два указателя на один и тот же объект (в данном случае на данный байт) равны, и один указатель на него (указатель на массив представления всего объекта, полученного с помощью malloc) действителен при добавлении, поэтому как может идентичный указатель, полученный по другому маршруту, недопустимо в дополнении? Даже если они хотят заявить, что это UB, это довольно бессмысленно, потому что с вычислительной точки зрения реализация не может отличить четко определенное использование от предположительно неопределенного использования.
R .. GitHub ПРЕКРАТИТЕ ПОМОЩЬ ICE
Жаль, что компиляторы C начали запрещать объявление массивов нулевой длины; если бы не этот запрет, многим компиляторам не пришлось бы выполнять какую-либо специальную обработку, чтобы заставить их работать так, как они «должны», но все равно были бы в состоянии кодировать особый случай для одноэлементных массивов (например, если он *fooсодержит одноэлементный массив boz, выражение foo->boz[biz()*391]=9;можно упростить как biz(),foo->boz[0]=9;). К сожалению, отказ компиляторов от массивов с нулевым элементом означает, что во многих кодах вместо этого используются одноэлементные массивы, и эта оптимизация может нарушить работу.
supercat 06
11

Этот конкретный способ сделать это явно не определен ни в одном стандарте C, но C99 действительно включает «взлом структуры» как часть языка. В C99 последним членом структуры может быть «гибкий элемент массива», объявленный как char foo[](с любым типом, который вам нужен вместо char).

цыпленок
источник
Чтобы быть педантичным, это не взлом структуры. В struct hack используется массив фиксированного размера, а не гибкий член массива. Взлом структуры - это то, о чем спрашивали, и это UB. Гибкие элементы массива просто кажутся попыткой успокоить людей, которых видели в этой ветке, которые жалуются на этот факт.
underscore_d
7

Это не неопределенное поведение , независимо от того , что говорят официальные лица или кто- либо другой , потому что оно определено стандартом. p->s, за исключением случаев использования в качестве lvalue, вычисляет указатель, идентичный (char *)p + offsetof(struct T, s). В частности, это действительный charуказатель внутри объекта malloc'd, и есть 100 (или более, в зависимости от соображений выравнивания) последовательных адресов, следующих сразу за ним, которые также действительны как charобъекты внутри выделенного объекта. Тот факт, что указатель был получен путем использования ->вместо явного добавления смещения к указателю, возвращаемому функцией malloccast to char *, не имеет значения.

Технически, p->s[0]это единственный элемент charмассива внутри структуры, следующие несколько элементов (например, p->s[1]сквозные p->s[3]), вероятно, являются байтами заполнения внутри структуры, которые могут быть повреждены, если вы выполняете присваивание структуре в целом, но не если вы просто обращаетесь к отдельным члены, а остальные элементы - это дополнительное пространство в выделенном объекте, которое вы можете использовать, как хотите, при условии, что вы соблюдаете требования выравнивания (и charне имеете требований к выравниванию).

Если вас беспокоит, что возможность перекрытия байтов заполнения в структуре может каким-то образом вызвать назальных демонов, вы можете избежать этого, заменив 1in [1]на значение, которое гарантирует отсутствие заполнения в конце структуры. Простой, но расточительный способ сделать это - создать структуру с идентичными членами, за исключением массива в конце, и использовать ее s[sizeof struct that_other_struct];для массива. Затем p->s[i]четко определяется как элемент массива в структуре для i<sizeof struct that_other_structи как объект типа char по адресу, следующему за концом структуры для i>=sizeof struct that_other_struct.

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

Изменить 2: перекрытие с байтами заполнения определенно не является проблемой из-за другой части стандарта. C требует, чтобы, если две структуры согласуются в начальной подпоследовательности своих элементов, к общим начальным элементам можно было получить доступ через указатель на любой тип. Как следствие, если была объявлена ​​структура, идентичная, struct Tно с большим конечным массивом, элемент s[0]должен был бы совпадать с элементом s[0]в struct T, и наличие этих дополнительных элементов не могло повлиять или быть затронуто доступом к общим элементам более крупной структуры используя указатель на struct T.

R .. GitHub НЕ ПОМОГАЕТ ICE
источник
4
Вы правы в том, что природа арифметики указателя не имеет значения, но вы ошибаетесь, говоря о доступе, превышающем заявленный размер массива. См. N1494 (последний общедоступный черновик C1x), раздел 6.5.6, параграф 8 - вам даже не разрешено выполнять добавление, которое принимает указатель более чем на один элемент за объявленный размер массива, и вы не можете разыменовать его, даже если это всего лишь один элемент прошлого.
zwol
1
@Zack: это правда, если объект является массивом. Это неверно, если объект является выделенным объектом, к mallocкоторому осуществляется доступ как массив, или если это более крупная структура, доступ к которой осуществляется через указатель на меньшую структуру, элементы которой, среди прочего, являются начальным подмножеством элементов большей структуры. случаи.
R .. GitHub НЕ ПОМОГАЕТ ICE
6
+1 Если mallocне выделить диапазон памяти, к которой можно получить доступ с помощью арифметики указателей, какой в ​​этом смысл? И если p->s[1]это определено стандартом , как синтаксический сахар для арифметики с указателями, то этот ответ : всего вновь утверждает , что mallocявляется полезным. Что осталось обсудить? :)
Дэниел Эрвикер
3
Вы можете сколько угодно утверждать, что это четко определено, но это не меняет того факта, что это не так. В стандарте очень четко прописан доступ за пределы массива, а граница этого массива - 1. Это так просто.
Гонки за легкостью на орбите
3
@R .., я думаю, ваше предположение, что два указателя, сравнивающие равные, должны вести себя одинаково, неверно. Допустим, вы вошли int m[1]; int n[1]; if(m+1 == n) m[1] = 0;в ifветку. Это UB (и не гарантируется инициализация n) согласно 6.5.6 p8 (последнее предложение), как я его читал. Связано: 6.5.9 p6 со сноской 109. (Ссылки на C11 n1570.) [...]
mafso
7

Да, это технически неопределенное поведение.

Обратите внимание, что есть как минимум три способа реализовать «взлом структуры»:

(1) Объявление конечного массива размером 0 (самый «популярный» способ в устаревшем коде). Очевидно, это UB, поскольку объявления массива нулевого размера всегда недопустимы в C. Даже если он компилируется, язык не дает никаких гарантий относительно поведения любого кода, нарушающего ограничения.

(2) Объявление массива с минимальным допустимым размером - 1 (ваш случай). В этом случае любые попытки получить указатель p->s[0]и использовать его для арифметики указателя, выходящей за рамки, p->s[1]являются неопределенным поведением. Например, отладочная реализация может создавать специальный указатель со встроенной информацией о диапазоне, которая будет перехватывать каждый раз, когда вы пытаетесь создать указатель за пределами p->s[1].

(3) Объявление массива с «очень большим» размером, например, 10000. Идея состоит в том, что заявленный размер должен быть больше всего, что вам может понадобиться на практике. Этот метод свободен от UB в отношении диапазона доступа к массиву. Однако на практике, конечно, мы всегда будем выделять меньший объем памяти (ровно столько, сколько действительно необходимо). Я не уверен в законности этого, т.е. мне интересно, насколько законно выделять для объекта меньше памяти, чем заявленный размер объекта (при условии, что мы никогда не обращаемся к «невыделенным» членам).

Муравей
источник
1
В (2) s[1]нет неопределенного поведения. Это то же самое *(s+1), что то же самое *((char *)p + offsetof(struct T, s) + 1), что является действительным указателем на a charв выделенном объекте.
R .. GitHub НЕ ПОМОГАЕТ ICE
С другой стороны, я почти уверен, что (3) - неопределенное поведение. Всякий раз, когда вы выполняете какую-либо операцию, которая зависит от такой структуры, расположенной по этому адресу, компилятор может генерировать машинный код, который читает из любой части структуры. Это может быть бесполезно или может быть функцией безопасности для строгой проверки распределения, но нет причин, по которым реализация не может этого сделать.
R .. GitHub НЕ ПОМОГАЕТ ICE
R: Если было объявлено, что массив имеет размер (это не просто foo[]синтаксический сахар для *foo), то любой доступ, превышающий меньший из его объявленного размера и его выделенного размера, будет UB, независимо от того, как выполнялась арифметика указателя.
zwol
1
@Zack, ты ошибаешься в нескольких вещах. foo[]в структуре не является синтаксическим сахаром для *foo; это гибкий член массива C99. В остальном смотрите мой ответ и комментарии к другим ответам.
R .. GitHub ПЕРЕСТАНОВИТЬ ПОМОЩЬ ICE
6
Проблема в том, что некоторые члены комитета отчаянно хотят, чтобы этот «взлом» был UB, потому что они представляют себе некую сказочную страну, где реализация C могла бы обеспечить соблюдение границ указателя. Однако, к лучшему или худшему, это будет противоречить другим частям стандарта - таким вещам, как возможность сравнивать указатели на равенство (если границы были закодированы в самом указателе) или требованием, чтобы любой объект был доступен через воображаемый наложенный unsigned char [sizeof object]массив. . Я поддерживаю свое утверждение, что гибкий элемент массива "hack" для pre-C99 имеет четко определенное поведение.
R .. GitHub НЕ ПОМОГАЕТ ICE
3

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

И за «отработку на практике». Я видел, как оптимизатор gcc / g ++ использовал эту часть стандарта, создавая неправильный код при встрече с этим недопустимым C.

Бернхард Р. Линк
источник
можешь привести пример?
Tal
1

Если компилятор принимает что-то вроде

typedef struct {
  int len;
  char dat [];
};

Я думаю, совершенно очевидно, что он должен быть готов принять нижний индекс на «dat», превышающий его длину. С другой стороны, если кто-то кодирует что-то вроде:

typedef struct {
  int что угодно;
  char dat [1];
} MY_STRUCT;

а затем обращается к somestruct-> dat [x]; Я бы не подумал, что компилятор обязан использовать код вычисления адреса, который будет работать с большими значениями x. Я думаю, что если бы кто-то хотел быть в безопасности, правильная парадигма была бы больше похожа на:

#define LARGEST_DAT_SIZE 0xF000
typedef struct {
  int что угодно;
  char dat [LARGEST_DAT_SIZE];
} MY_STRUCT;

а затем выполните malloc размером (sizeof (MYSTRUCT) -LARGEST_DAT_SIZE + required_array_length) байтов (имея в виду, что если желаемый_array_length больше LARGEST_DAT_SIZE, результаты могут быть неопределенными).

Кстати, я думаю, что решение запретить массивы нулевой длины было неудачным (некоторые старые диалекты, такие как Turbo C, поддерживают его), поскольку массив нулевой длины можно рассматривать как знак того, что компилятор должен генерировать код, который будет работать с большими индексами .

Supercat
источник