sirve que para c++ c++11 language-lawyer memcpy object-lifetime

c++ - que - memcpy arduino



¿Por qué el comportamiento de std:: memcpy sería indefinido para objetos que no son TriviallyCopyable? (9)

¿Por qué el comportamiento de std::memcpy sí sería indefinido cuando se usa con objetos que no son TriviallyCopyable?

¡No es! Sin embargo, una vez que copia los bytes subyacentes de un objeto de un tipo que no se puede copiar trivialmente en otro objeto de ese tipo, el objeto de destino no está vivo . Lo destruimos reutilizando su almacenamiento, y no lo hemos revitalizado por una llamada de constructor.

El uso del objeto de destino (llamar a sus funciones miembro, acceder a sus miembros de datos) está claramente indefinido [basic.life] / 6 , y también lo es una llamada destructora implícita posterior [basic.life] / 4 para los objetos de destino que tienen una duración de almacenamiento automática. Observe cómo el comportamiento indefinido es retrospectivo . [intro.execution] / 5:

Sin embargo, si dicha ejecución contiene una operación indefinida, esta Norma Internacional no impone ningún requisito a la implementación que ejecuta ese programa con esa entrada ( ni siquiera con respecto a las operaciones que preceden a la primera operación indefinida ).

Si una implementación detecta cómo un objeto está muerto y necesariamente sujeto a operaciones adicionales que no están definidas, ... puede reaccionar alterando la semántica de sus programas. Desde la llamada memcpy adelante. Y esta consideración se vuelve muy práctica una vez que pensamos en los optimizadores y ciertas suposiciones que hacen.

Sin embargo, debe tenerse en cuenta que las bibliotecas estándar pueden y permiten optimizar ciertos algoritmos de biblioteca estándar para tipos que se pueden copiar trivialmente. std::copy en punteros a tipos trivialmente copiables generalmente llama a memcpy en los bytes subyacentes. También lo hace el swap .
Por lo tanto, simplemente utilice algoritmos genéricos normales y deje que el compilador realice las optimizaciones apropiadas de bajo nivel; esto es en parte para lo que se inventó la idea de un tipo trivialmente copiable en primer lugar: Determinar la legalidad de ciertas optimizaciones. Además, esto evita dañar su cerebro al tener que preocuparse por partes contradictorias y poco especificadas del lenguaje.

Desde http://en.cppreference.com/w/cpp/string/byte/memcpy :

Si los objetos no son TriviallyCopyable (por ejemplo, escalares, matrices, estructuras compatibles con C), el comportamiento es indefinido.

En mi trabajo, hemos usado std::memcpy durante mucho tiempo para intercambiar objetos bit a bit que no son TriviallyCopyable usando:

void swapMemory(Entity* ePtr1, Entity* ePtr2) { static const int size = sizeof(Entity); char swapBuffer[size]; memcpy(swapBuffer, ePtr1, size); memcpy(ePtr1, ePtr2, size); memcpy(ePtr2, swapBuffer, size); }

y nunca tuve ningún problema.

Entiendo que es trivial abusar de std::memcpy con objetos que no son TriviallyCopyable y causar un comportamiento indefinido aguas abajo. Sin embargo, mi pregunta:

¿Por qué el comportamiento de std::memcpy sí sería indefinido cuando se usa con objetos que no son TriviallyCopyable? ¿Por qué el estándar considera necesario especificar eso?

ACTUALIZAR

El contenido de http://en.cppreference.com/w/cpp/string/byte/memcpy se ha modificado en respuesta a esta publicación y las respuestas a la publicación. La descripción actual dice:

Si los objetos no son TriviallyCopyable (por ejemplo, escalares, matrices, estructuras compatibles con C), el comportamiento no está definido a menos que el programa no dependa de los efectos del destructor del objeto de destino (que no se ejecuta mediante memcpy ) y la vida útil de el objeto de destino (que finaliza, pero no se inicia mediante memcpy ) se inicia por algún otro medio, como ubicación-nueva.

PD

Comentario de @Cubbi:

@RSahu si algo garantiza UB en sentido descendente, hace que todo el programa no esté definido. Pero estoy de acuerdo en que parece posible esquivar UB en este caso y modificar la preferencia en consecuencia.


C ++ no garantiza para todos los tipos que sus objetos ocupen bytes contiguos de almacenamiento [intro.object] / 5

Un objeto de tipo trivialmente copiable o de diseño estándar (3.9) ocupará bytes contiguos de almacenamiento.

Y, de hecho, a través de clases base virtuales, puede crear objetos no contiguos en implementaciones principales. He intentado construir un ejemplo en el que se encuentra un subobjeto de clase base de un objeto x antes de la dirección de inicio de x . Para visualizar esto, considere el siguiente gráfico / tabla, donde el eje horizontal es el espacio de direcciones y el eje vertical es el nivel de herencia (el nivel 1 hereda del nivel 0). Los campos marcados por dm están ocupados por miembros de datos directos de la clase.

L | 00 08 16 --+--------- 1 | dm 0 | dm

Este es un diseño de memoria habitual cuando se usa la herencia. Sin embargo, la ubicación de un subobjeto de clase base virtual no es fija, ya que puede ser reubicada por clases secundarias que también heredan virtualmente de la misma clase base. Esto puede llevar a la situación de que el objeto de nivel 1 (subclase de clase base) informa que comienza en la dirección 8 y tiene un tamaño de 16 bytes. Si ingenuamente agregamos esos dos números, pensaríamos que ocupa el espacio de direcciones [8, 24) a pesar de que realmente ocupa [0, 16).

Si podemos crear dicho objeto de nivel 1, entonces no podemos usar memcpy para copiarlo: memcpy accedería a la memoria que no pertenece a este objeto (direcciones 16 a 24). En mi demo, el desinfectante de direcciones de clang ++ lo detecta como un desbordamiento de búfer en la pila.

¿Cómo construir tal objeto? Al usar la herencia virtual múltiple, se me ocurrió un objeto que tiene el siguiente diseño de memoria (los punteros de tabla virtual están marcados como vp ). Se compone de cuatro capas de herencia:

L 00 08 16 24 32 40 48 3 dm 2 vp dm 1 vp dm 0 dm

El problema descrito anteriormente surgirá para el subobjeto de clase base de nivel 1. Su dirección inicial es 32 y tiene 24 bytes de tamaño (vptr, sus propios miembros de datos y miembros de datos de nivel 0).

Aquí está el código para tal diseño de memoria en clang ++ y g ++ @ coliru:

struct l0 { std::int64_t dummy; }; struct l1 : virtual l0 { std::int64_t dummy; }; struct l2 : virtual l0, virtual l1 { std::int64_t dummy; }; struct l3 : l2, virtual l1 { std::int64_t dummy; };

Podemos producir un desbordamiento de búfer de pila de la siguiente manera:

l3 o; l1& so = o; l1 t; std::memcpy(&t, &so, sizeof(t));

Aquí hay una demostración completa que también imprime información sobre el diseño de la memoria:

#include <cstdint> #include <cstring> #include <iomanip> #include <iostream> #define PRINT_LOCATION() / std::cout << std::setw(22) << __PRETTY_FUNCTION__ / << " at offset " << std::setw(2) / << (reinterpret_cast<char const*>(this) - addr) / << " ; data is at offset " << std::setw(2) / << (reinterpret_cast<char const*>(&dummy) - addr) / << " ; naively to offset " / << (reinterpret_cast<char const*>(this) - addr + sizeof(*this)) / << "/n" struct l0 { std::int64_t dummy; void report(char const* addr) { PRINT_LOCATION(); } }; struct l1 : virtual l0 { std::int64_t dummy; void report(char const* addr) { PRINT_LOCATION(); l0::report(addr); } }; struct l2 : virtual l0, virtual l1 { std::int64_t dummy; void report(char const* addr) { PRINT_LOCATION(); l1::report(addr); } }; struct l3 : l2, virtual l1 { std::int64_t dummy; void report(char const* addr) { PRINT_LOCATION(); l2::report(addr); } }; void print_range(void const* b, std::size_t sz) { std::cout << "[" << (void const*)b << ", " << (void*)(reinterpret_cast<char const*>(b) + sz) << ")"; } void my_memcpy(void* dst, void const* src, std::size_t sz) { std::cout << "copying from "; print_range(src, sz); std::cout << " to "; print_range(dst, sz); std::cout << "/n"; } int main() { l3 o{}; o.report(reinterpret_cast<char const*>(&o)); std::cout << "the complete object occupies "; print_range(&o, sizeof(o)); std::cout << "/n"; l1& so = o; l1 t; my_memcpy(&t, &so, sizeof(t)); }

Demo en vivo

Salida de muestra (abreviada para evitar el desplazamiento vertical):

l3::report at offset 0 ; data is at offset 16 ; naively to offset 48 l2::report at offset 0 ; data is at offset 8 ; naively to offset 40 l1::report at offset 32 ; data is at offset 40 ; naively to offset 56 l0::report at offset 24 ; data is at offset 24 ; naively to offset 32 the complete object occupies [0x9f0, 0xa20) copying from [0xa10, 0xa28) to [0xa20, 0xa38)

Tenga en cuenta las dos compensaciones finales enfatizadas.


Es bastante fácil construir una clase donde se memcpy ese memcpy basado en memcpy :

struct X { int x; int* px; // invariant: always points to x X() : x(), px(&x) {} X(X const& b) : x(b.x), px(&x) {} X& operator=(X const& b) { x = b.x; return *this; } };

memcpy tales objetos rompe esa invariante.

GNU C ++ 11 std::string hace exactamente eso con cadenas cortas.

Esto es similar a cómo se implementan las secuencias estándar de archivos y cadenas. Las secuencias eventualmente derivan de std::basic_ios que contiene un puntero a std::basic_streambuf . Las secuencias también contienen el búfer específico como miembro (o subobjeto de clase base), al que apunta el puntero en std::basic_ios .


Lo que puedo percibir aquí es que, para algunas aplicaciones prácticas, el estándar C ++ puede ser restrictivo, o más bien, no lo suficientemente permisivo.

Como se muestra en otras respuestas, memcpy se descompone rápidamente para los tipos "complicados", pero en mi humilde opinión, en realidad debería funcionar para los tipos de diseño estándar siempre que la memcpy no rompa lo que hacen las operaciones de copia definidas y el destructor del tipo de diseño estándar. (Tenga en cuenta que incluso una clase TC puede tener un constructor no trivial). El estándar solo llama explícitamente a los tipos TC wrt. esto, sin embargo.

Un reciente borrador de cita (N3797):

3.9 Tipos

...

2 Para cualquier objeto (que no sea un subobjeto de clase base) de tipo T copiable trivialmente, ya sea que el objeto tenga o no un valor válido de tipo T, los bytes subyacentes (1.7) que componen el objeto se pueden copiar en una matriz de caracteres o un personaje sin firmar. Si el contenido del conjunto de caracteres char o unsigned char se copia de nuevo en el objeto, el objeto mantendrá posteriormente su valor original. [Ejemplo:

#define N sizeof(T) char buf[N]; T obj; // obj initialized to its original value std::memcpy(buf, &obj, N); // between these two calls to std::memcpy, // obj might be modified std::memcpy(&obj, buf, N); // at this point, each subobject of obj of scalar type // holds its original value

—Ejemplo]

3 Para cualquier tipo T trivialmente copiable, si dos punteros a T apuntan a objetos T distintos obj1 y obj2, donde ni obj1 ni obj2 es un subobjeto de clase base, si los bytes subyacentes (1.7) que componen obj1 se copian en obj2, obj2 posteriormente tendrá el mismo valor que obj1. [Ejemplo:

T* t1p; T* t2p; // provided that t2p points to an initialized object ... std::memcpy(t1p, t2p, sizeof(T)); // at this point, every subobject of trivially copyable type in *t1p contains // the same value as the corresponding subobject in *t2p

—Ejemplo]

El estándar aquí habla sobre tipos TriviallyCopyable , pero como se observó por @dyp arriba, también hay tipos de diseño estándar que, hasta donde puedo ver, no se superponen necesariamente con los tipos copiables trivialmente.

El estándar dice:

1.8 El modelo de objetos C ++

(...)

5 (...) Un objeto de tipo trivialmente copiable o de diseño estándar (3.9) ocupará bytes contiguos de almacenamiento.

Entonces, lo que veo aquí es que:

  • El estándar no dice nada acerca de los tipos wrt que no se pueden copiar trivialmente. memcpy . (como ya se mencionó varias veces aquí)
  • El estándar tiene un concepto separado para los tipos de diseño estándar que ocupan almacenamiento contiguo.
  • El estándar no permite ni prohíbe explícitamente el uso memcpy de objetos de Diseño estándar que no se puedan copiar trivialmente.

Por lo tanto, no parece llamarse explícitamente UB, pero ciertamente tampoco es lo que se conoce como comportamiento no especificado , por lo que se podría concluir lo que @underscore_d hizo en el comentario a la respuesta aceptada:

(...) No se puede simplemente decir "bueno, no se llamó explícitamente como UB, por lo tanto, es un comportamiento definido", que es lo que parece ser este hilo. N3797 3.9 puntos 2 ~ 3 no definen qué hace memcpy para los objetos que no se pueden copiar trivialmente, por lo que (...) eso es prácticamente funcionalmente equivalente a UB en mis ojos ya que ambos son inútiles para escribir código confiable, es decir, portátil

Personalmente , concluiría que equivale a UB en lo que respecta a la portabilidad (oh, esos optimizadores), pero creo que con un poco de cobertura y conocimiento de la implementación concreta, uno puede salirse con la suya. (Solo asegúrate de que valga la pena).

Nota al margen: También creo que el estándar realmente debería incorporar explícitamente la semántica del tipo de diseño estándar en todo el memcpy desorden, porque es un caso de uso válido y útil para hacer copias bit a bit de objetos que no se pueden copiar trivialmente, pero eso no viene al caso aquí.

Enlace: ¿Puedo usar memcpy para escribir en varios subobjetos de diseño estándar adyacentes?


Muchas de estas respuestas mencionan que memcpy podría romper invariantes en la clase, lo que causaría un comportamiento indefinido más tarde (y que en la mayoría de los casos debería ser motivo suficiente para no arriesgarse), pero eso no parece ser lo que realmente está preguntando. .

Una razón por la cual la llamada memcpy sí misma se considera un comportamiento indefinido es dar el mayor espacio posible al compilador para realizar optimizaciones basadas en la plataforma de destino. Al hacer que la llamada en sí sea UB, el compilador puede hacer cosas extrañas que dependen de la plataforma.

Considere este ejemplo (muy artificial e hipotético): para una plataforma de hardware en particular, puede haber varios tipos diferentes de memoria, y algunos son más rápidos que otros para diferentes operaciones. Puede haber, por ejemplo, un tipo de memoria especial que permita copias de memoria extra rápidas. Por lo tanto, un compilador para esta plataforma (imaginaria) puede colocar todos los tipos TriviallyCopyable en esta memoria especial e implementar memcpy para usar instrucciones de hardware especiales que solo funcionan en esta memoria.

Si memcpy usar memcpy en objetos que no son TriviallyCopyable en esta plataforma, podría haber un bloqueo de OPCODE NO memcpy bajo nivel en la llamada memcpy .

Tal vez no sea el argumento más convincente, pero el punto es que el estándar no lo prohíbe , lo cual solo es posible al hacer que la memcpy llame a UB.


Otra razón por la cual memcpy es UB (aparte de lo que se mencionó en las otras respuestas, podría romper invariantes más adelante) es que es muy difícil para el estándar decir exactamente qué sucedería .

Para los tipos no triviales, el estándar dice muy poco acerca de cómo se coloca el objeto en la memoria, en qué orden se colocan los miembros, dónde está el puntero vtable, cuál debe ser el relleno, etc. El compilador tiene una gran cantidad de libertad al decidir esto.

Como resultado, incluso si el estándar quisiera permitir la memcpy en estas situaciones "seguras", sería imposible establecer qué situaciones son seguras y cuáles no, o cuándo se activaría exactamente la UB real para casos inseguros.

Supongo que se podría argumentar que los efectos deberían estar definidos por la implementación o no, pero personalmente creo que eso sería profundizar demasiado en los detalles de la plataforma y dar un poco de legitimidad a algo que en el caso general es bastante inseguro


Porque el estándar lo dice así.

Los compiladores pueden suponer que los tipos que no son TriviallyCopyable solo se copian a través de sus constructores de copia / movimiento / operadores de asignación. Esto podría ser para fines de optimización (si algunos datos son privados, podría diferir la configuración hasta que se produzca una copia / movimiento).

El compilador es incluso libre de tomar su llamada de memcpy y no hacer nada , o formatear su disco duro. ¿Por qué? Porque el estándar lo dice así. Y no hacer nada es definitivamente más rápido que mover bits, así que ¿por qué no optimizar su memcpy a un programa más rápido igualmente válido?

Ahora, en la práctica, hay muchos problemas que pueden ocurrir cuando simplemente se desplazan por bits en tipos que no lo esperan. Es posible que las tablas de funciones virtuales no estén configuradas correctamente. La instrumentación utilizada para detectar fugas puede no estar configurada correctamente. Los objetos cuya identidad incluye su ubicación se desordenan completamente con su código.

La parte realmente divertida es que using std::swap; swap(*ePtr1, *ePtr2); using std::swap; swap(*ePtr1, *ePtr2); debe poder compilarse en una memcpy para que el compilador pueda memcpy trivialmente los tipos, y para otros tipos se debe definir el comportamiento. Si el compilador puede probar que la copia son solo bits que se están copiando, es libre de cambiarla a memcpy . Y si puede escribir un swap más óptimo, puede hacerlo en el espacio de nombres del objeto en cuestión.


Primero, tenga en cuenta que es incuestionable que toda la memoria para objetos C / C ++ mutables tiene que estar sin tipear, sin especialización, y ser utilizable para cualquier objeto mutable. (Supongo que la memoria para las variables globales constantes podría escribirse hipotéticamente, simplemente no hay ningún punto con tanta hipercomplicación para un caso de esquina tan pequeño). A diferencia de Java, C ++ no tiene una asignación escrita de un objeto dinámico : new Class(args) en Java es una creación de objeto con tipo: creación de un objeto de un tipo bien definido, que podría vivir en la memoria con tipo. Por otro lado, la expresión C ++ new Class(args) es solo una delgada envoltura de escritura alrededor de la asignación de memoria sin tipo, equivalente a new (operator new(sizeof(Class)) Class(args) : el objeto se crea en "neutral memoria ". Cambiar eso significaría cambiar una gran parte de C ++.

La prohibición de la operación de copia de bits (ya sea realizada por memcpy o la copia equivalente byte byte byte) definida por el usuario en algún tipo da mucha libertad a la implementación de clases polimórficas (aquellas con funciones virtuales) y otras llamadas "clases virtuales" (no un término estándar), que son las clases que usan la palabra clave virtual .

La implementación de clases polimórficas podría usar un mapa asociativo global de direcciones que asocian la dirección de un objeto polimórfico y sus funciones virtuales. Creo que fue una opción seriamente considerada durante el diseño del lenguaje C ++ de las primeras iteraciones (o incluso "C con clases"). Ese mapa de objetos polimórficos podría usar características especiales de CPU y memoria asociativa especial (tales características no están expuestas al usuario de C ++).

Por supuesto, sabemos que todas las implementaciones prácticas de funciones virtuales usan vtables (un registro constante que describe todos los aspectos dinámicos de una clase) y ponen un vptr (puntero vtable) en cada subobjeto de clase base polimórfica, ya que ese enfoque es extremadamente simple de implementar (en menos para los casos más simples) y muy eficiente. No existe un registro global de objetos polimórficos en ninguna implementación del mundo real, excepto posiblemente en modo de depuración (no conozco dicho modo de depuración).

El estándar C ++ hizo que la falta de registro global fuera algo oficial al decir que puede omitir la llamada del destructor cuando reutiliza la memoria de un objeto, siempre y cuando no dependa de los "efectos secundarios" de esa llamada del destructor. (Creo que eso significa que los "efectos secundarios" son creados por el usuario, es decir, el cuerpo del destructor, no la implementación creada, ya que la implementación lo hace automáticamente al destructor).

Porque en la práctica en todas las implementaciones, el compilador solo usa miembros ocultos vptr (puntero a vtables), y estos miembros ocultos serán copiados adecuadamente por memcpy ; como si hicieras una copia simple para miembros de la estructura C que representa la clase polimórfica (con todos sus miembros ocultos). Las copias a nivel de bits o las copias completas de miembros de C struct (la estructura de C completa incluye miembros ocultos) se comportarán exactamente como una llamada de constructor (como se hace mediante la colocación de una nueva), por lo que todo lo que tiene que hacer es dejar que el compilador piense que podría he llamado colocación nueva. Si realiza una llamada a una función fuertemente externa (una llamada a una función que no puede estar en línea y cuya implementación no puede ser examinada por el compilador, como una llamada a una función definida en una unidad de código cargada dinámicamente o una llamada al sistema), entonces el El compilador solo asumirá que tales constructores podrían haber sido invocados por el código que no puede examinar. Por lo tanto, el comportamiento de memcpy aquí no está definido por el estándar del lenguaje, sino por el compilador ABI (Application Binary Interface). El ABI define el comportamiento de una llamada a una función fuertemente externa, no solo por el estándar del lenguaje. El lenguaje define una llamada a una función potencialmente inlinable ya que se puede ver su definición (ya sea durante el compilador o durante la optimización global del tiempo de enlace).

Entonces, en la práctica, dadas las "compilaciones" apropiadas (como una llamada a una función externa, o simplemente asm("") ), puede memcpy clases que solo usan funciones virtuales.

Por supuesto, el lenguaje semántico le debe permitir hacer una nueva ubicación cuando hace una memcpy : no puede redefinir de ninguna manera el tipo dinámico de un objeto existente y pretender que simplemente no ha destruido el objeto antiguo. Si tiene un subobjeto de miembro, subobject de matriz, estático, automático, no constante, puede sobrescribirlo y colocar allí otro objeto no relacionado; pero si el tipo dinámico es diferente, no puede pretender que sigue siendo el mismo objeto o subobjeto:

struct A { virtual void f(); }; struct B : A { }; void test() { A a; if (sizeof(A) != sizeof(B)) return; new (&a) B; // OK (assuming alignement is OK) a.f(); // undefined }

El cambio del tipo polimórfico de un objeto existente simplemente no está permitido: el nuevo objeto no tiene relación con a excepto por la región de la memoria: los bytes continuos que comienzan en &a . Tienen diferentes tipos.

[El estándar está fuertemente dividido sobre si *&a puede usarse (en máquinas de memoria plana típicas) o (A&)(char&)a (en cualquier caso) para referirse al nuevo objeto. Los escritores de compiladores no están divididos: no debes hacerlo. Este es un defecto profundo en C ++, quizás el más profundo y problemático.]

Pero no puede en el código portátil realizar copias bit a bit de clases que usan herencia virtual, ya que algunas implementaciones implementan esas clases con punteros a los subobjetos de base virtual: estos punteros que fueron inicializados correctamente por el constructor del objeto más derivado tendrán su valor copiado por memcpy (como una copia sabia de miembro simple de la estructura C que representa la clase con todos sus miembros ocultos) y no apuntaría el subobjeto del objeto derivado.

Otros ABI usan compensaciones de direcciones para localizar estos subobjetos base; dependen solo del tipo del objeto más derivado, como los typeid finales y typeid , y por lo tanto pueden almacenarse en la tabla vtable. En estas implementaciones, memcpy funcionará como lo garantiza la ABI (con la limitación anterior para cambiar el tipo de un objeto existente).

En cualquier caso, es un problema de representación de objetos, es decir, un problema de ABI.


memcpy copiará todos los bytes, o en su caso intercambiará todos los bytes, muy bien. Un compilador demasiado entusiasta podría tomar el "comportamiento indefinido" como una excusa para todo tipo de travesuras, pero la mayoría de los compiladores no lo harán. Aún así, es posible.

Sin embargo, después de copiar estos bytes, el objeto al que los copió puede que ya no sea un objeto válido. El caso simple es una implementación de cadena donde las cadenas grandes asignan memoria, pero las cadenas pequeñas solo usan una parte del objeto de cadena para contener caracteres, y mantienen un puntero a eso. El puntero obviamente apuntará al otro objeto, por lo que las cosas estarán mal. Otro ejemplo que he visto fue una clase con datos que se usaron solo en muy pocos casos, por lo que los datos se guardaron en una base de datos con la dirección del objeto como clave.

Ahora, si sus instancias contienen un mutex, por ejemplo, creo que mover eso podría ser un problema importante.