1

我将对象存储在缓冲区中。现在我知道我不能对对象的内存布局做出假设。

如果我知道对象的总体大小,是否可以创建指向该内存的指针并在其上调用函数?

例如说我有以下课程:

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

1)如果我知道这个类的大小为 24 并且我知道它在内存中的起始地址,而假设内存布局是不安全的,可以将它转换为指针并调用该对象上的函数来访问这些成员?(c++ 是否通过某种魔法知道成员的正确位置?)

2)如果这不安全/没问题,除了使用构造函数来获取所有参数并一次将每个参数从缓冲区中拉出一个之外,还有其他方法吗?

编辑:更改标题以使其更适合我的要求。

4

8 回答 8

6

您可以创建一个构造函数来获取所有成员并分配它们,然后使用placement 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-table 也能正确生成。但是请注意,如果您使用它进行序列化,则 unsigned short int 指针在反序列化时不会指向任何有用的东西,除非您非常小心地使用某种方法将指针转换为偏移量然后再返回.

指针上的各个方法this是静态链接的,并且只是对函数的直接调用,并且this是显式参数之前的第一个参数。

成员变量是使用this指针的偏移量来引用的。如果一个对象是这样布局的:

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

a将通过取消引用来访问this + 4 bytes

于 2009-01-06T17:48:41.827 回答
3

非虚函数调用像 C 函数一样直接链接。对象 (this) 指针作为第一个参数传递。调用该函数不需要了解对象布局。

于 2009-01-06T17:36:33.380 回答
3

基本上,您建议做的是读取一堆(希望不是随机的)字节,将它们转换为已知对象,然后在该对象上调用类方法。它实际上可能会起作用,因为这些字节最终会出现在该类方法中的“this”指针中。但是你正在真正的机会去处理那些不是编译代码所期望的地方。与 Java 或 C# 不同的是,没有真正的“运行时”来捕获这类问题,所以充其量您会得到核心转储,更糟糕的是,您会得到损坏的内存。

听起来您想要 Java 的序列化/反序列化的 C++ 版本。可能有一个图书馆可以做到这一点。

于 2009-01-06T17:47:34.483 回答
2

听起来您不是将对象本身存储在缓冲区中,而是将它们组成的数据存储在缓冲区中。

如果此数据按字段在您的类中定义的顺序存储在内存中(为平台使用适当的填充)并且您的类型是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. f在堆栈上为对象“ ”分配 8 个字节
    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;
}
于 2009-01-06T18:03:25.667 回答
2
  1. 在这种情况下,已经创建了具有 POD 类型的对象(无论您是否调用 new。分配所需的存储就足够了),您可以访问它的成员,包括调用该对象的函数。但这只有在您准确知道 T 所需的对齐方式、T 的大小(缓冲区可能不小于它)以及 T 的所有成员的对齐方式时才有效。即使对于 pod 类型,编译器也是如果需要,允许在成员之间放置填充字节。对于非 POD 类型,如果您的类型没有虚函数或基类,没有用户定义的构造函数(当然)并且这也适用于基类及其所有非静态成员,那么您可以拥有同样的运气。

  2. 对于所有其他类型,所有赌注均已取消。您必须首先使用 POD 读取值,然后使用该数据初始化非 POD 类型。

于 2009-01-06T18:51:00.533 回答
2

我将对象存储在缓冲区中。...如果我知道对象的整体大小,是否可以创建指向该内存的指针并在其上调用函数?

在可以接受使用强制转换的范围内,这是可以接受的:

#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';
}

将对象转换为原始内存然后再转换回来实际上很常见,尤其是在 C 世界中。但是,如果您使用的是类层次结构,则使用指向成员函数的指针会更有意义。

说我有以下课程:...

如果我知道这个类的大小为 24 并且我知道它在内存中的起始地址......

这就是事情变得困难的地方。对象的大小包括其数据成员(以及来自任何基类的任何数据成员)的大小加上任何填充加上任何函数指针或实现相关信息,减去从某些大小优化(空基类优化)中保存的任何内容。如果结果数为 0 字节,则对象需要在内存中占用至少一个字节。这些东西是语言问题和大多数 CPU 对内存访问的共同要求的组合。 试图让事情正常工作可能是一个真正的痛苦

如果您只是分配一个对象并在原始内存中进行转换,您可以忽略这些问题。但是,如果您将对象的内部结构复制到某种缓冲区,那么它们很快就会抬起头来。上面的代码依赖于一些关于对齐的一般规则(即,我碰巧知道 A 类将具有与 int 相同的对齐限制,因此可以安全地将数组转换为 A;但我不一定能保证如果我将数组的一部分转换为 A 并将部分转换为具有其他数据成员的其他类,则相同)。

哦,在复制对象时,您需要确保正确处理指针。

您可能还对Google 的 Protocol BuffersFacebook 的 Thrift感兴趣。


是的,这些问题很困难。而且,是的,一些编程语言将它们扫到了地毯下。 但是有很多东西被扫到地毯下

在 Sun 的 HotSpot JVM 中,对象存储与最近的 64 位边界对齐。最重要的是,每个对象在内存中都有一个 2 字头。JVM 的字长通常是平台的本机指针大小。(一个只包含一个 32 位 int 和一个 64 位双精度的对象——96 位数据——将需要)两个字用于对象头,一个字用于 int,两个字用于双精度。那是 5 个字:160 位。由于对齐,这个对象将占用 192 位内存。

这是因为 Sun 依赖于一种相对简单的策略来解决内存对齐问题(在一个虚构的处理器上,可以允许 char 存在于任何内存位置,一个 int 可以存在于任何可被 4 整除的位置,并且可能需要一个 double仅在可被 32 整除的内存位置上分配——但最严格的对齐要求也满足所有其他对齐要求,因此 Sun 正在根据最严格的位置对齐所有内容)。

内存对齐的另一种策略可以回收一些空间

于 2009-01-06T19:13:11.890 回答
1
  1. 如果该类不包含虚函数(因此类实例没有 vptr),并且如果您对类的成员数据在内存中的布局方式做出正确的假设,那么执行您的建议可能会起作用(但是可能不便携)。
  2. 是的,另一种方式(更惯用但不是更安全......您仍然需要知道该类如何布置其数据)将使用所谓的“放置运算符 new”和默认构造函数。
于 2009-01-06T17:43:26.173 回答
0

这取决于您所说的“安全”是什么意思。每当您以这种方式将内存地址转换为点时,您都​​在绕过编译器提供的类型安全功能,并自行承担责任。如果像 Chris 暗示的那样,您对内存布局或编译器实现细节做出了错误的假设,那么您将得到意想不到的结果和松散的可移植性。

由于您担心这种编程风格的“安全性”,因此可能值得您花时间研究可移植和类型安全的方法,例如预先存在的库,或者为此目的编写构造函数或赋值运算符。

于 2009-01-06T17:51:38.047 回答