Управление объектами C ++ в буфере с учетом предположений о выравнивании и расположении памяти - PullRequest
1 голос
/ 06 января 2009

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

Если я знаю общий размер объекта, допустимо ли создавать указатель на эту память и вызывать на нем функции?

например. скажем, у меня есть следующий класс:

[int,int,int,int,char,padding*3bytes,unsigned short int*]

1) если я знаю, что этот класс имеет размер 24, и я знаю адрес, где он начинается в памяти в то время как небезопасно предполагать, что расположение памяти приемлемо для приведения этого к указателю и вызова функций для этого объекта, которые обращаются к этим членам? (Знает ли c ++ по волшебству правильную позицию члена?)

2) Если это не безопасно / хорошо, есть ли другой способ, кроме использования конструктора, который принимает все аргументы и извлекает каждый аргумент из буфера по одному за раз?

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

Ответы [ 8 ]

6 голосов
/ 06 января 2009

Вы можете создать конструктор, который будет принимать все члены и назначать их, а затем использовать размещение new.

class Foo
{
    int a;int b;int c;int d;char e;unsigned short int*f;
public:
    Foo(int A,int B,int C,int D,char E,unsigned short int*F) : a(A), b(B), c(C), d(D), e(E), f(F) {}
};

...
char *buf  = new char[sizeof(Foo)];   //pre-allocated buffer
Foo *f = new (buf) Foo(a,b,c,d,e,f);

Это имеет то преимущество, что даже v-таблица будет сгенерирована правильно. Обратите внимание, однако, что если вы используете это для сериализации, указатель unsigned short int не будет указывать на что-нибудь полезное при десериализации, если только вы не будете очень осторожны, используя какой-либо метод для преобразования указателей в смещения и затем снова .

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

На переменные-члены ссылаются, используя смещение от указателя this. Если объект выложен так:

0: vtable
4: a
8: b
12: c
etc...

a будет доступен разыменованием this + 4 bytes.

3 голосов
/ 06 января 2009

По сути, вы предлагаете прочитать несколько байтов (надеюсь, не случайных), привести их к известному объекту, а затем вызвать метод класса для этого объекта. Это может на самом деле работать, потому что эти байты будут заканчиваться указателем «this» в методе этого класса. Но вы рискуете тем, что не находится там, где этого ожидает скомпилированный код. И, в отличие от Java или C #, нет реальной «среды выполнения» для решения подобных проблем, поэтому в лучшем случае вы получите дамп ядра, а в худшем - поврежденную память.

Звучит так, будто вы хотите C ++ версию сериализации / десериализации Java. Вероятно, для этого есть библиотека.

3 голосов
/ 06 января 2009

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

2 голосов
/ 06 января 2009

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

Это приемлемо в той степени, в которой допустимо использование приведений:

#include <iostream>

namespace {
    class A {
        int i;
        int j;
    public:
        int value()
        {
            return i + j;
        }
    };
}

int main()
{
    char buffer[] = { 1, 2 };
    std::cout << reinterpret_cast<A*>(buffer)->value() << '\n';
}

Преобразование объекта в нечто вроде необработанной памяти и обратно на самом деле довольно распространено, особенно в мире Си. Однако, если вы используете иерархию классов, имеет смысл использовать указатель на функции-члены.

скажи, что у меня есть следующий класс: ...

если я знаю, что этот класс имеет размер 24, и я знаю адрес, где он начинается в памяти ...

Здесь все становится сложнее. Размер объекта включает в себя размер его элементов данных (и любых элементов данных из любых базовых классов) плюс любые отступы плюс любые указатели функций или информацию, зависящую от реализации, за вычетом чего-либо, сохраненного из определенных оптимизаций размера (пустая оптимизация базового класса). Если полученное число равно 0 байтов, то объект должен занимать как минимум один байт в памяти. Эти вещи представляют собой сочетание языковых проблем и общих требований, предъявляемых большинством процессоров к доступу к памяти. Попытка заставить вещи работать должным образом может быть настоящей болью .

Если вы просто выделите объект и приведете к исходной памяти и из нее, вы можете игнорировать эти проблемы. Но если вы копируете внутренности объекта в какой-то буфер, то они довольно быстро поднимают голову. Приведенный выше код опирается на несколько общих правил выравнивания (т. Е. Я знаю, что класс A будет иметь те же ограничения на выравнивание, что и целые, и, таким образом, массив может быть безопасно приведен к A; но я не мог обязательно гарантировать то же самое, если бы я приводил части массива к A и части к другим классам с другими членами данных).

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

Вас также могут заинтересовать такие вещи, как Буферы протокола Google или Экономия Facebook .


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

В Sun HotSpot JVM хранилище объектов выравнивается по ближайшей 64-битной границе. Кроме того, каждый объект имеет в памяти заголовок из двух слов. Размер слова в JVM обычно соответствует собственному размеру указателя платформы. (Для объекта, состоящего только из 32-разрядного типа int и 64-разрядного двойного числа - 96 бит данных - потребуется) два слова для заголовка объекта, одно слово для целого числа, два слова для двойного. Это 5 слов: 160 бит. Из-за выравнивания этот объект будет занимать 192 бита памяти.

Это связано с тем, что Sun полагается на относительно простую тактику решения проблем с выравниванием памяти (на воображаемом процессоре символу может быть разрешено существовать в любом месте памяти, int в любом месте, кратном 4, и двойной может потребоваться выделять только области памяти, которые делятся на 32, но наиболее ограничительное требование выравнивания также удовлетворяет всем остальным требованиям выравнивания, поэтому Sun выравнивает все в соответствии с наиболее ограничивающим местоположением).

Другая тактика выравнивания памяти может вернуть часть этого пространства .

2 голосов
/ 06 января 2009
  1. Объект, имеющий тип POD, в этом случае уже создан (независимо от того, вызываете ли вы новое. Достаточно ли уже выделено необходимое хранилище), и вы можете получить доступ к его членам, включая вызов функции для этого объект. Но это сработает, только если вы точно знаете требуемое выравнивание T и размер T (буфер не может быть меньше его), а также выравнивание всех членов T. Даже для типа pod компилятор разрешено помещать байты заполнения между членами, если это необходимо. Для не POD-типов вы можете испытать такую ​​же удачу, если у вашего типа нет виртуальных функций или базовых классов, никакого определяемого пользователем конструктора (конечно), и это относится и к базе, и ко всем ее нестатическим членам.

  2. Для всех остальных типов все ставки выключены. Вы должны сначала прочитать значения с помощью POD, а затем инициализировать не-POD тип с этими данными.

2 голосов
/ 06 января 2009

Похоже, вы сами не храните объекты в буфере, а скорее данные, из которых они состоят.

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

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

Однако вы можете инициализировать не POD с данными из POD.

Что касается адресов, где расположены не виртуальные функции: они статически связаны во время компиляции с некоторым местоположением в вашем сегменте кода, которое является одинаковым для каждого экземпляра вашего типа. Обратите внимание, что здесь нет «времени выполнения». Когда вы пишете такой код:

class Foo{
   int a;
   int b;

public:
   void DoSomething(int x);
};

void Foo::DoSomething(int x){a = x * 2; b = x + a;}

int main(){
    Foo f;
    f.DoSomething(42);
    return 0;
}

компилятор генерирует код, который делает что-то вроде этого:

  1. функция main:
    1. выделить 8 байтов в стеке для объекта "f"
    2. вызов инициализатора по умолчанию для класса "Foo" (в этом случае ничего не делает)
    3. выдвинуть значение аргумента 42 в стек
    4. вставить указатель на объект "f" в стек
    5. вызов функции Foo_i_DoSomething@4 (реальное имя обычно более сложное)
    6. загрузить возвращаемое значение 0 в регистр аккумулятора
    7. возврат звонящего
  2. функция Foo_i_DoSomething@4 (находится в другом месте в сегменте кода)
    1. загрузить значение "x" из стека (выдано вызывающим абонентом)
    2. умножить на 2
    3. загрузить указатель «this» из стека (выдаваемый вызывающим)
    4. вычислить смещение поля "a" внутри Foo объекта
    5. добавить вычисленное смещение к this указателю, загруженному в шаге 3
    6. хранить товар, рассчитанный на шаге 2, для смещения, рассчитанного на шаге 5
    7. загрузить значение "x" из стека, снова
    8. загрузить указатель «this» из стека, снова
    9. вычислить смещение поля "a" в объекте Foo, снова
    10. добавить вычисленное смещение к указателю this, загруженному на шаге 8
    11. загрузить значение "a", сохраненное со смещением,
    12. добавить значение "a", загруженное в шаге 12, к значению "x", загруженному в шаге 7
    13. загрузить указатель «this» из стека, снова
    14. вычислить смещение поля "b" внутри Foo объекта
    15. добавить вычисленное смещение к указателю this, загруженному на шаге 14
    16. сумма накопления, рассчитанная на шаге 13, для смещения, рассчитанного на шаге 16
    17. возврат звонящему

Другими словами, это будет примерно такой же код, как если бы вы написали это (особенности, такие как имя функции DoSomething и способ передачи указателя this зависят от компилятора):

class Foo{
    int a;
    int b;

    friend void Foo_DoSomething(Foo *f, int x);
};

void Foo_DoSomething(Foo *f, int x){
    f->a = x * 2;
    f->b = x + f->a;
}

int main(){
    Foo f;
    Foo_DoSomething(&f, 42);
    return 0;
}
1 голос
/ 06 января 2009
  1. Если класс не содержит виртуальных функций (и, следовательно, экземпляры класса не имеют vptr), и если вы сделаете правильные предположения о том, как данные члена класса размещаются в памяти, то выполнение то, что вы предлагаете, может работать (но может и не быть переносимым).
  2. Да, другой способ (более идиоматичный, но не намного более безопасный ... вам все еще нужно знать, как класс выдает свои данные) - использовать так называемый «оператор размещения new» и конструктор по умолчанию.
0 голосов
/ 06 января 2009

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

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

...