¿El "truco de estructura" es un comportamiento técnicamente indefinido?

111

Lo que estoy preguntando es el conocido truco "el último miembro de una estructura tiene longitud variable". Es algo parecido a esto:

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

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

Debido a la forma en que la estructura se presenta en la memoria, podemos superponer la estructura sobre un bloque más grande de lo necesario y tratar el último miembro como si fuera más grande que el 1 charespecificado.

Entonces la pregunta es: ¿Es esta técnica un comportamiento técnicamente indefinido? . Esperaría que lo sea, pero tenía curiosidad por saber qué dice el estándar sobre esto.

PD: Soy consciente del enfoque C99 para esto, me gustaría que las respuestas se ajustaran específicamente a la versión del truco que se enumera anteriormente.

Evan Terán
fuente
33
Esta parece una pregunta bastante clara, razonable y, sobre todo, que se puede responder . Sin ver el motivo de la votación cerrada.
cHao
2
Si introdujiste un compilador "ansi c" que no soportara el truco de struct, la mayoría de los programadores de c que conozco no aceptarían que tu compilador "funcionara bien". A pesar de que aceptarían una lectura estricta de la norma. El comité simplemente se perdió uno en eso.
dmckee --- ex-moderador gatito
4
@james El truco funciona mallozando un objeto lo suficientemente grande para la matriz a la que te refieres, a pesar de haber declarado una matriz mínima. Entonces, está accediendo a la memoria asignada fuera de la definición estricta de la estructura. Escribir más allá de su asignación es un error indiscutible, pero eso es diferente de escribir en su asignación pero fuera de "la estructura".
dmckee --- ex-moderador gatito
2
@James: El malloc de gran tamaño es fundamental aquí. Asegura que haya memoria --- memoria con dirección legal y "propiedad" de la estructura (es decir, es ilegal que cualquier otra entidad la use) --- más allá del final nominal de la estructura. Tenga en cuenta que esto significa que no puede usar el truco de estructura en variables automáticas: deben asignarse dinámicamente.
dmckee --- ex-moderador gatito
5
@detly: Es más sencillo asignar / desasignar una cosa que asignar / desasignar dos cosas, especialmente porque esta última tiene dos formas de fallar con las que debe lidiar. Para mí, esto es más importante que el ahorro marginal de costes / velocidad.
jamesdlin

Respuestas:

52

Como dice C FAQ :

No está claro si es legal o portátil, pero es bastante popular.

y:

... una interpretación oficial ha considerado que no se ajusta estrictamente al estándar C, aunque parece funcionar en todas las implementaciones conocidas. (Los compiladores que comprueban cuidadosamente los límites de la matriz pueden emitir advertencias).

La razón detrás del bit 'estrictamente conforme' está en la especificación, sección J.2 Comportamiento indefinido , que incluye en la lista de comportamiento indefinido:

  • Un subíndice de matriz está fuera de rango, incluso si un objeto es aparentemente accesible con el subíndice dado (como en la expresión lvalue a[1][7]dada la declaración int a[4][5]) (6.5.6).

El párrafo 8 de la Sección 6.5.6 Operadores aditivos tiene otra mención de que el acceso más allá de los límites definidos de la matriz no está definido:

Si tanto el operando del puntero como el resultado apuntan a elementos del mismo objeto de matriz, o uno más allá del último elemento del objeto de matriz, la evaluación no producirá un desbordamiento; de lo contrario, el comportamiento no está definido.

Carl Norum
fuente
1
En el código del OP, p->snunca se usa como una matriz. Se pasa a strcpy, en cuyo caso decae a un plano char *, que pasa a apuntar a un objeto que puede ser interpretado legalmente como char [100];dentro del objeto asignado.
R .. GitHub DEJA DE AYUDAR A ICE
3
Quizás otra forma de ver esto es que el lenguaje podría restringir la forma en que accede a las variables de matriz reales como se describe en J.2, pero no hay forma de que pueda hacer tales restricciones para un objeto asignado por malloc, cuando simplemente ha convertido el archivo devuelto void *a un puntero a [una estructura que contiene] una matriz. Sigue siendo válido acceder a cualquier parte del objeto asignado utilizando un puntero a char(o preferiblemente unsigned char).
R .. GitHub DEJA DE AYUDAR A ICE
@R. - Puedo ver cómo J2 podría no cubrir esto, pero ¿no lo cubre también 6.5.6?
detly
1
¡Seguro que podría! La información de tipo y tamaño podría estar incrustada en cada puntero, y cualquier aritmética de puntero errónea podría hacerse para atrapar - ver, por ejemplo, CCured . En un nivel más filosófico, no importa si ninguna implementación posible podría atraparte, sigue siendo un comportamiento indefinido (hay, iirc, casos de comportamiento indefinido que requerirían un oráculo para que el problema de detención se concretara, que es precisamente la razón por la cual están indefinidos).
zwol
4
El objeto no es un objeto de matriz, por lo que 6.5.6 es irrelevante. El objeto es el bloque de memoria asignado por malloc. Busque "objeto" en el estándar antes de lanzar bs.
R .. GitHub DEJA DE AYUDAR A ICE
34

Creo que técnicamente es un comportamiento indefinido. El estándar (posiblemente) no lo aborda directamente, por lo que se incluye "o por la omisión de cualquier definición explícita de comportamiento". cláusula (§4 / 2 de C99, §3.16 / 2 de C89) que dice que es un comportamiento indefinido.

El "posiblemente" anterior depende de la definición del operador de subíndice de matriz. Específicamente, dice: "Una expresión de sufijo seguida de una expresión entre corchetes [] es una designación subindicada de un objeto de matriz". (C89, §6.3.2.1 / 2).

Puede argumentar que el "de un objeto de matriz" está siendo violado aquí (ya que está subíndice fuera del rango definido del objeto de matriz), en cuyo caso el comportamiento es (un poquito más) explícitamente indefinido, en lugar de simplemente indefinido cortesía de nada que lo defina.

En teoría, puedo imaginar un compilador que verifica los límites de la matriz y (por ejemplo) abortaría el programa cuando / si intentara usar un subíndice fuera de rango. De hecho, no sé que exista algo así, y dada la popularidad de este estilo de código, incluso si un compilador intentara hacer cumplir los subíndices en algunas circunstancias, es difícil imaginar que alguien lo toleraría en esta situación.

Jerry Coffin
fuente
2
También puedo imaginar un compilador que podría decidir que si una matriz fuera de tamaño 1, entonces arr[x] = y;podría reescribirse como arr[0] = y;; para una matriz de tamaño 2, arr[i] = 4;podría reescribirse como i ? arr[1] = 4 : arr[0] = 4; Si bien nunca he visto a un compilador realizar tales optimizaciones, en algunos sistemas embebidos podrían ser muy productivos. En un PIC18x, utilizando tipos de datos de 8 bits, el código para la primera declaración sería de dieciséis bytes, la segunda, dos o cuatro, y la tercera, ocho o doce. No es una mala optimización si es legal.
supercat
Si el estándar define el acceso a la matriz fuera de los límites de la matriz como comportamiento indefinido, entonces el truco de estructura también lo es. Sin embargo, si el estándar define el acceso a la matriz como azúcar sintáctico para la aritmética de punteros ( a[2] == a + 2), no lo hace. Si estoy en lo cierto, todos los estándares de C definen el acceso a la matriz como aritmática de puntero.
yyny
13

Sí, es un comportamiento indefinido.

C Language Defect Report # 051 da una respuesta definitiva a esta pregunta:

El modismo, aunque es común, no se ajusta estrictamente

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

En el documento C99 Justificación, el Comité C agrega:

La validez de este constructo siempre ha sido cuestionable. En respuesta a un Informe de Defectos, el Comité decidió que se trataba de un comportamiento indefinido porque la matriz p-> elementos contiene solo un elemento, independientemente de que exista el espacio.

ouah
fuente
2
+1 por encontrar esto, pero sigo afirmando que es contradictorio. Dos punteros al mismo objeto (en este caso, el byte dado) son iguales, y un puntero a él (el puntero a la matriz de representación de todo el objeto obtenido por malloc) es válido en la suma, entonces, ¿cómo puede el puntero idéntico, obtenido a través de otra ruta, ¿será inválido en la adición? Incluso si quieren reclamar que es UB, eso no tiene mucho sentido, porque computacionalmente no hay forma de que una implementación distinga entre el uso bien definido y el uso supuestamente indefinido.
R .. GitHub DEJA DE AYUDAR A ICE
Es una lástima que los compiladores de C hayan comenzado a prohibir la declaración de matrices de longitud cero; si no fuera por esa prohibición, muchos compiladores no habrían tenido que hacer ningún manejo especial para que funcionen como "deberían", pero aún habrían podido codificar en casos especiales las matrices de un solo elemento (por ejemplo, si *foocontiene un matriz de un solo elemento boz, la expresión se foo->boz[biz()*391]=9;podría simplificar como biz(),foo->boz[0]=9;). Desafortunadamente, el rechazo de las matrices de elementos cero de los compiladores significa que una gran cantidad de código usa matrices de un solo elemento en su lugar, y esa optimización lo rompería.
supercat
11

Esa forma particular de hacerlo no está definida explícitamente en ningún estándar de C, pero C99 incluye el "truco de estructura" como parte del lenguaje. En C99, el último miembro de una estructura puede ser un "miembro de matriz flexible", declarado como char foo[](con el tipo que desee en lugar de char).

Arrojar
fuente
Para ser pedante, ese no es el truco de estructura. El truco de estructura usa una matriz con un tamaño fijo, no un miembro de matriz flexible. El truco de estructura es sobre lo que se preguntó y es UB. Los miembros de la matriz flexible simplemente parecen un intento de apaciguar al tipo de gente que se ve en este hilo quejándose de ese hecho.
underscore_d
7

No es un comportamiento indefinido , independientemente de lo que alguien, oficial o no , diga, porque está definido por el estándar. p->s, excepto cuando se usa como un lvalue, se evalúa como un puntero idéntico a (char *)p + offsetof(struct T, s). En particular, este es un charpuntero válido dentro del objeto malloc'd, y hay 100 (o más, dependiendo de las consideraciones de alineación) direcciones sucesivas inmediatamente después de él que también son válidas como charobjetos dentro del objeto asignado. El hecho de que el puntero se haya derivado usando en ->lugar de agregar explícitamente el desplazamiento al puntero devuelto por malloc, cast to char *, es irrelevante.

Técnicamente, si p->s[0]el elemento único de la charmatriz está dentro de la estructura, los siguientes elementos (por ejemplo, a p->s[1]través p->s[3]) probablemente sean bytes de relleno dentro de la estructura, que podrían corromperse si realiza una asignación a la estructura como un todo, pero no si simplemente accede a un individuo miembros, y el resto de los elementos son espacio adicional en el objeto asignado que puede usar libremente como quiera, siempre que obedezca los requisitos de alineación (y charno tenga requisitos de alineación).

Si le preocupa que la posibilidad de superposición con el acolchado de bytes de la estructura puede ser que de alguna forma de invocación demonios nasales, se podría evitar esta reemplazando la 1de [1]con un valor que asegura que no hay relleno al final de la estructura. Una forma simple pero inútil de hacer esto sería crear una estructura con miembros idénticos, excepto sin una matriz al final, y usarla s[sizeof struct that_other_struct];para la matriz. Entonces, p->s[i]se define claramente como un elemento de la matriz en la estructura para i<sizeof struct that_other_structy como un objeto char en una dirección que sigue al final de la estructura para i>=sizeof struct that_other_struct.

Editar: en realidad, en el truco anterior para obtener el tamaño correcto, es posible que también deba colocar una unión que contenga cada tipo simple antes de la matriz, para asegurarse de que la matriz en sí comience con una alineación máxima en lugar de en el medio del relleno de algún otro elemento . Una vez más, no creo que nada de esto sea necesario, pero se lo ofrezco al más paranoico de los abogados del lenguaje.

Edición 2: La superposición con bytes de relleno definitivamente no es un problema, debido a otra parte del estándar. C requiere que si dos estructuras coinciden en una subsecuencia inicial de sus elementos, se puede acceder a los elementos iniciales comunes mediante un puntero a cualquier tipo. Como consecuencia, si struct Tse declarara una estructura idéntica a, pero con una matriz final más grande, el elemento s[0]tendría que coincidir con el elemento s[0]en struct T, y la presencia de estos elementos adicionales no podría afectar o verse afectada al acceder a elementos comunes de la estructura más grande. usando un puntero a struct T.

R .. GitHub DEJA DE AYUDAR A ICE
fuente
4
Tiene razón en que la naturaleza de la aritmética del puntero es irrelevante, pero se equivoca acerca del acceso más allá del tamaño declarado de la matriz. Consulte N1494 (último borrador público C1x) sección 6.5.6 párrafo 8: ni siquiera se le permite hacer la adición que lleva un puntero más de un elemento más allá del tamaño declarado de la matriz, y no puede eliminar la referencia incluso si es solo un elemento del pasado.
zwol
1
@Zack: eso es cierto si el objeto es una matriz. No es cierto si el objeto es un objeto asignado por el malloccual se accede como una matriz o si es una estructura más grande a la que se accede a través de un puntero a una estructura más pequeña cuyos elementos son un subconjunto inicial de los elementos de la estructura más grande, entre otros casos.
R .. GitHub DEJA DE AYUDAR A ICE
6
+1 Si mallocno asigna un rango de memoria al que se puede acceder con aritmética de punteros, ¿de qué uso sería? Y si p->s[1]está definido por la norma como el azúcar sintáctica para la aritmética de punteros, entonces esta respuesta simplemente reafirma que malloces útil. ¿Qué queda por discutir? :)
Daniel Earwicker
3
Puede argumentar que está bien definido tanto como quiera, pero eso no cambia el hecho de que no lo está. El estándar es muy claro sobre el acceso más allá de los límites de una matriz, y el límite de esta matriz es 1. Es tan simple como eso.
Lightness Races in Orbit
3
@R .., creo, su suposición de que dos punteros que comparan iguales deben comportarse igual es incorrecta. Considere int m[1]; int n[1]; if(m+1 == n) m[1] = 0;suponer que ifse ingresa la rama. Esto es UB (y no se garantiza que se inicialice n) según 6.5.6 p8 (última oración), como lo leí. Relacionado: 6.5.9 p6 con nota al pie 109. (Las referencias son a C11 n1570.) [...]
mafso
7

Sí, es un comportamiento técnicamente indefinido.

Tenga en cuenta que hay al menos tres formas de implementar el "truco de estructura":

(1) Declarar la matriz final con tamaño 0 (la forma más "popular" en el código heredado). Obviamente, esto es UB, ya que las declaraciones de matriz de tamaño cero siempre son ilegales en C. Incluso si se compila, el lenguaje no ofrece garantías sobre el comportamiento de cualquier código que viole las restricciones.

(2) Declarar la matriz con un tamaño legal mínimo: 1 (su caso). En este caso, cualquier intento de tomar puntero ay p->s[0]usarlo para aritmética de puntero que vaya más allá p->s[1]es un comportamiento indefinido. Por ejemplo, se permite una implementación de depuración para producir un puntero especial con información de rango incrustada, que atrapará cada vez que intente crear un puntero más allá p->s[1].

(3) Declarar la matriz con un tamaño "muy grande" como 10000, por ejemplo. La idea es que se supone que el tamaño declarado es más grande que cualquier cosa que pueda necesitar en la práctica. Este método está libre de UB con respecto al rango de acceso a la matriz. Sin embargo, en la práctica, por supuesto, siempre asignaremos una menor cantidad de memoria (solo la realmente necesaria). No estoy seguro de la legalidad de esto, es decir, me pregunto qué tan legal es asignar menos memoria para el objeto que el tamaño declarado del objeto (asumiendo que nunca accedemos a los miembros "no asignados").

Hormiga
fuente
1
En (2), s[1]no hay comportamiento indefinido. Es lo mismo que *(s+1), que es lo mismo que *((char *)p + offsetof(struct T, s) + 1), que es un puntero válido a a charen el objeto asignado.
R .. GitHub DEJA AYUDAR A ICE
Por otro lado, estoy casi seguro de que (3) es un comportamiento indefinido. Siempre que realice cualquier operación que dependa de dicha estructura que resida en esa dirección, el compilador es libre de generar código de máquina que lea desde cualquier parte de la estructura. Podría ser inútil o podría ser una característica de seguridad para una comprobación estricta de la asignación, pero no hay razón para que una implementación no pueda hacerlo.
R .. GitHub DEJA DE AYUDAR A ICE
R: Si se declaró que una matriz tiene un tamaño (no es solo el foo[]azúcar sintáctico *foo), entonces cualquier acceso más allá del menor de su tamaño declarado y su tamaño asignado es UB, independientemente de cómo se haya realizado la aritmética del puntero.
zwol
1
@Zack, te equivocas en varias cosas. foo[]en una estructura no es azúcar sintáctico para *foo; es un miembro de matriz flexible C99. Para el resto, vea mi respuesta y comentarios sobre otras respuestas.
R .. GitHub DEJA DE AYUDAR A ICE
6
El problema es que algunos miembros del comité quieren desesperadamente que este "truco" sea UB, porque imaginan un país de hadas donde una implementación de C podría imponer límites de puntero. Sin embargo, para bien o para mal, hacerlo entraría en conflicto con otras partes del estándar, como la capacidad de comparar punteros para la igualdad (si los límites estuvieran codificados en el puntero mismo) o el requisito de que cualquier objeto sea accesible a través de una unsigned char [sizeof object]matriz superpuesta imaginaria . Mantengo mi afirmación de que el miembro de matriz flexible "hack" para pre-C99 tiene un comportamiento bien definido.
R .. GitHub DEJA DE AYUDAR A ICE
3

El estándar es bastante claro que no puede acceder a cosas al lado del final de una matriz. (y pasar por punteros no ayuda, ya que no se le permite incluso incrementar los punteros más allá de uno después del final de la matriz).

Y por "trabajar en la práctica". He visto el optimizador gcc / g ++ usando esta parte del estándar generando así un código incorrecto al cumplir con esta C.

Bernhard R. Link
fuente
¿Puede dar un ejemplo?
Tal
1

Si un compilador acepta algo como

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

Creo que está bastante claro que debe estar listo para aceptar un subíndice en 'dat' más allá de su longitud. Por otro lado, si alguien codifica algo como:

typedef struct {
  int lo que sea;
  char dat [1];
} MY_STRUCT;

y luego accede a alguna estructura-> dat [x]; No creo que el compilador tenga la obligación de utilizar un código de cálculo de direcciones que funcione con valores grandes de x. Creo que si uno quisiera estar realmente seguro, el paradigma adecuado sería más como:

#define LARGEST_DAT_SIZE 0xF000
typedef struct {
  int lo que sea;
  char dat [LARGEST_DAT_SIZE];
} MY_STRUCT;

y luego hacer un malloc de (sizeof (MYSTRUCT) -LARGEST_DAT_SIZE + deseada_array_length) bytes (teniendo en cuenta que si deseada_array_length es mayor que LARGEST_DAT_SIZE, los resultados pueden no estar definidos).

Por cierto, creo que la decisión de prohibir las matrices de longitud cero fue desafortunada (algunos dialectos más antiguos como Turbo C lo admiten) ya que una matriz de longitud cero podría considerarse como una señal de que el compilador debe generar código que funcione con índices más grandes. .

Super gato
fuente