这听起来像是一个糟糕的主意,原因有很多,它可能不会节省内存,并且会通过用数据稀释 L1I-cache 和用代码稀释 L1D-cache 来降低性能。你曾经修改或复制对象:自我修改代码停滞不前。
但是,这在 C99/C11 中是可能的,在结构的末尾有一个灵活的数组成员,您可以将其强制转换为函数指针。
struct int_with_code {
int i;
char code[]; // C99 flexible array member. GNU extension in C++
// Store machine code here
// you can't get the compiler to do this for you. Good Luck!
};
void foo(struct int_with_code *p) {
// explicit C-style cast compiles as both C and C++
void (*funcp)(void) = ( void (*)(void) ) p->code;
funcp();
}
编译器输出from clang7.0, on the Godbolt compiler explorer 在编译为 C 或 C++ 时是相同的。这是针对 x86-64 System V ABI,其中第一个函数 arg 在 RDI 中传递。
# this is the code that *uses* such an object, not the code that goes in its code[]
# This proves that it compiles,
# without showing any way to get compiler-generated code into code[]
foo: # @foo
add rdi, 4 # move the pointer 4 bytes forward, to point at code[]
jmp rdi # TAILCALL
(如果您在 C 中省略 (void) arg-type 声明,编译器将在 x86-64 SysV 调用约定中首先将 AL 归零,以防它实际上是一个可变参数函数,因为它在寄存器中没有传递任何 FP args .)
您必须将对象分配到可执行的内存中(通常不会这样做,除非它们是带有静态存储的 const),例如用gcc -zexecstack 编译。或者在 POSIX 或 Windows 上使用自定义 mmap/mprotect 或 VirtualAlloc/VirtualProtect。
或者,如果您的对象都是静态分配的,则可以通过在每个对象之前添加int 成员来按摩编译器输出以将.text 部分中的函数转换为对象。也许通过一些 .section 和链接器技巧,也许还有链接器脚本,您甚至可以以某种方式自动化它。
但除非它们的长度相同(例如,使用像 char code[60] 这样的填充),否则不会形成可以索引的数组,因此您需要某种方式来引用所有这些可变长度对象。
如果您在调用对象函数之前修改对象,可能会导致巨大的性能下降:在 x86 上,您将获得用于执行代码的自修改代码管道核弹接近刚刚编写的内存位置.
或者如果您在调用其函数之前复制了一个对象:x86 管道刷新,或者在其他 ISA 上,您需要手动刷新缓存以使 I-cache 与 D-cache 同步(因此可以执行新写入的字节)。 但您无法复制此类对象,因为它们的大小未存储在任何地方。您无法在机器代码中搜索ret 指令,因为0xc3 字节可能出现在不是x86 指令开始的地方。或者在任何 ISA 上,该函数可能有多个 ret 指令(尾部复制优化)。或者以 jmp 而不是 ret(尾调用)结束。
存储大小将开始违背节省大小的目的,在每个对象中至少占用一个额外的字节。
在运行时将代码写入对象,然后强制转换为函数指针,在 ISO C 和 C++ 中是未定义的行为。在 GNU C/C++ 上,确保调用 __builtin___clear_cache 以同步缓存或其他任何必要的操作。是的,即使在 x86 上也需要禁用死存储消除优化:see this test case。在 x86 上,它只是是一个编译时的东西,没有额外的 asm。它实际上并没有清除任何缓存。
如果您在运行时启动时进行复制,则可能会在复制时分配一大块内存并从中分割出可变长度的块。如果你分别malloc ,你就是在浪费内存管理开销。
这个想法不会节省你的内存,除非你有和你的对象一样多的函数
通常,实际函数的数量相当有限,许多对象具有相同函数指针的副本。 (你有一种手动 C++ 虚函数,但只有一个函数,你只有一个函数指针,而不是一个指向该类类型指针表的 vtable 指针。间接级别少了,显然你不要将对象自己的地址传递给函数。)
这种间接级别的几个好处之一是一个指针通常比函数的整个代码小得多。如果不是这种情况,您的函数必须是tiny。
示例:有 10 个不同的函数,每个函数 32 字节,以及 1000 个带有函数指针的对象,总共有 320 字节的代码(将在 I-cache 中保持热)和 8000 字节的函数指针。 (在您的对象中,每个对象另外 4 个字节浪费在填充上以对齐指针,使每个对象的总大小为 16 而不是 12 个字节。)无论如何,这 整个结构 + 代码总共有 16320 个字节 .如果您分别分配每个对象,则存在每个对象的簿记。
将机器代码内联到每个对象中,并且没有填充,即 1000 * (4+32) = 36000 字节,超过总大小的两倍。
x86-64 可能是最好的情况,其中指针为 8 个字节,x86-64 机器代码使用(著名的复杂)可变长度指令编码,它允许高代码密度在某些情况下,尤其是在优化代码大小时。 (例如代码高尔夫。https://codegolf.stackexchange.com/questions/132981/tips-for-golfing-in-x86-x64-machine-code)。但除非你的函数大多是像lea eax, [rdi + rdi*2](3 字节=操作码 + ModRM + SIB)/ret(1 字节)这样微不足道的东西,否则它们仍然会占用超过 8 个字节。 (对于在 x86-64 System V ABI 中采用 32 位整数 x arg 的函数,这是 return x*3;。)
如果它们是更大函数的包装器,则普通的call rel32 指令为 5 个字节。静态数据的加载至少为 6 个字节(opcode + modrm + rel32 用于 RIP 相对寻址模式,或者专门加载 EAX 可以使用特殊的 no-modrm 编码作为绝对地址。但在 x86-64 中,这是一个 64 位绝对地址除非您也使用地址大小前缀,否则可能会导致英特尔解码器中的 LCP 停顿。mov eax, [32 bit absolute address] = addr32 (0x67) + opcode + abs32 = 6 字节,所以这更糟,没有任何好处。
您的函数指针类型没有任何参数(假设这是 C++,其中 foo() 在声明中表示 foo(void),不像旧 C 中空参数列表有点类似于 (...))。因此我们可以假设你没有传递参数,所以为了做任何有用的事情,函数可能会访问一些静态数据或进行另一个调用。
更有意义的想法:
-
使用像 Linux x32 这样的 ILP32 ABI,其中 CPU 在 64 位模式下运行,但您的代码使用 32 位指针。这将使您的每个对象只有 8 个字节而不是 16 个字节。避免指针膨胀是 x32 或 ILP32 ABI 的典型用例。
或者(糟糕)将您的代码编译为 32 位。但是你有过时的 32 位调用约定,它们在堆栈而不是寄存器上传递 args,而且不到一半的寄存器,并且位置无关代码的开销要高得多。 (没有 EIP/RIP 相对寻址。)
将unsigned int 表索引存储到函数指针表中。 如果您有 100 个函数但有 10k 个对象,则该表只有 100 个指针长。如果所有函数都填充到相同的长度,则在 asm 中您可以直接索引代码数组(计算 goto 样式),但在 C++ 中您不能这样做。带有函数指针表的额外间接级别可能是您最好的选择。
例如
void (*const fptrs[])(void) = {
func1, func2, func3, ...
};
struct int_with_func {
int i;
unsigned f;
};
void bar(struct int_with_func *p) {
fptrs[p->f] ();
}
clang/gcc -O3 输出:
bar(int_with_func*):
mov eax, dword ptr [rdi + 4] # load p->f
jmp qword ptr [8*rax + fptrs] # TAILCALL # index the global table with it for a memory-indirect jmp
如果您正在编译共享库、PIE 可执行文件或不针对 Linux,则编译器无法使用 32 位绝对地址通过一条指令来索引静态数组。所以那里会有一个与 RIP 相关的 LEA,类似于 jmp [rcx+rax*8]。
与在每个对象中存储一个函数指针相比,这是一个额外的间接级别,但它可以让您将每个对象从 16 个字节缩小到 8 个字节,就像使用 32 位指针一样。或者到 5 或 6 个字节,如果您使用 unsigned short 或 uint8_t 并在 GNU C 中使用 __attribute__((packed)) 打包结构。