【问题标题】:embed a functions assembly code in a struct在结构中嵌入函数汇编代码
【发布时间】:2019-06-06 14:43:07
【问题描述】:

我有一个相当特殊的问题:是否可以在 C/++ 中(因为我确信这两种语言的问题相同)指定函数的位置?为什么?我有一个非常大的函数指针列表,我想消除它们。

(目前)看起来像这样(重复超过一百万次,存储在用户的 RAM 中):

struct {
    int i;
    void(* funptr)();
} test;

因为我知道在大多数汇编语言中,函数只是“goto”指令,所以我有了以下想法。是否可以优化上述构造使其看起来像这样?

struct {
    int i;
    // embed the assembler of the function here
    // so that all the functions
    // instructions are located here
    // like this: mov rax, rbx
    // jmp _start ; just demo code
} test2;

最后,这个东西在内存中应该是这样的:一个包含任何值的 int,后跟函数的汇编代码,由 test2 引用。我应该可以这样调用这些函数:((void(*)()) (&pointerToTheStruct + sizeof(int)))();

您可能会认为我以这种方式优化应用程序很疯狂,并且我无法透露有关其功能的更多详细信息,但如果有人对如何解决此问题有一些指示,我将不胜感激。 我不认为有一个标准的方式来做到这一点,所以任何通过内联汇编器/其他疯狂的东西来做到这一点的 hacky 方式也是值得赞赏的!

【问题讨论】:

  • 虽然有一些技巧可以将代码嵌入到数据中,但我看不出你会如何从中受益。最终目标是什么?
  • "因为我知道在大多数汇编语言中,函数只是“goto”指令" 你从哪里“知道”这个? goto 只是一个 jmp 声明,而 call 不止于此。如果只是简单的跳转,我们知道的递归是不可能的。
  • @thejack 你的函数代码大小比指针还小???因为如果不是,那么您将使用 更多 内存,而不是通过将函数代码存储在结构中来减少。
  • @thejack 段对内存使用有什么影响?你的架构是什么?
  • 还要注意((void(*)()) (&pointerToTheStruct + sizeof(int)))(); 绝对不正确。您将 4 个指针大小移动到存储 pointerToTheStruct 的位置,然后 calling 该位置。我们明白您的意思,但很明显这不是实际要求。

标签: c++ c c++11 assembly inline-assembly


【解决方案1】:

你真正需要做的唯一一件事就是让编译器知道你想要在结构中的函数指针的(常量)值。然后编译器将(可能/希望)内联该函数调用,无论它看到它通过该函数指针调用:

template<void(*FPtr)()>
struct function_struct {
    int i;
    static constexpr auto funptr = FPtr;
};

void testFunc()
{
    volatile int x = 0;
}

using test = function_struct<testFunc>;

int main()
{
    test::funptr();
}

Demo - 优化后没有calljmp

目前尚不清楚int i 的意义是什么。请注意,这里的代码在技术上并不是“直接在 i 之后”,但更不清楚您希望结构的 instances 是什么样子(是其中的代码还是它在某种程度上是“静态的”?我觉得你对编译器实际产生的东西有一些误解......)。但是考虑一下编译器内联可以帮助您的方式,您可能会找到所需的解决方案。如果您担心内联后的可执行文件大小,请告诉编译器,它会在速度和大小之间妥协。

【讨论】:

    【解决方案2】:

    这听起来像是一个糟糕的主意,原因有很多,它可能不会节省内存,并且会通过用数据稀释 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 shortuint8_t 并在 GNU C 中使用 __attribute__((packed)) 打包结构。

    【讨论】:

      【解决方案3】:

      不,不是。

      指定函数位置的方法是使用函数指针,您已经在这样做了。

      你可以创建不同的类型,它们有自己不同的成员函数,但你又回到了原来的问题。

      我过去曾尝试自动生成(作为预构建步骤,使用 Python)一个带有长 switch 语句的函数,该语句执行将 int i 映射到正常函数调用的工作。这以分支为代价摆脱了函数指针。我不记得它在我的情况下是否值得,即使我这样做了,也不能告诉我们它在你的情况下是否值得。

      因为我知道在大多数汇编语言中,函数只是“goto”指令

      嗯,这可能比这更复杂一点……

      你可能会认为我以这种方式优化应用程序很疯狂

      也许吧。试图消除间接性本身并不是一件坏事,所以我认为你试图改进这一点并没有错。我只是不认为你一定可以。

      但是如果有人有一些指示

      哈哈

      【讨论】:

        【解决方案4】:

        我不明白这个“优化”的目标是为了节省内存吗?

        我可能误解了这个问题,但如果你只是用常规函数替换你的函数指针,那么你的结构将只包含 int 作为数据和编译器在你使用时插入的函数指针它的地址,而不是存储在内存中。

        那就这样吧

        struct {
            int i;
            void func();
        } test;  
        

        如果您将对齐/打包设置为紧密,sizeof(test)==sizeof(int) 应该成立。

        【讨论】:

        • 对,但是现在您必须在程序中的这种类型的变体之间进行选择,这意味着某种查找,而这正是您通过删除函数指针设法避免的!在某些时候,计算机必须决定调用哪个函数,而你无法摆脱这个要求:你只能决定实现它的成本最低的方式。
        • @LightnessRacesinOrbit 是的,有人可能会认为函数指针指向不同的地方,但我看不到问题明确提到这一点,他的“jmp _start”示例也没有指向这一点。
        • 我想我确实假设了,但我认为考虑到上下文,“函数指针的海量列表”,整数索引的存在,......
        猜你喜欢
        • 2010-09-11
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2016-09-05
        • 1970-01-01
        • 2016-08-27
        • 2013-03-13
        • 1970-01-01
        相关资源
        最近更新 更多