【问题标题】:How to compile and execute from memory directly?如何直接从内存编译执行?
【发布时间】:2012-11-21 08:15:23
【问题描述】:

是否可以编译C++(或类似)程序而不生成可执行文件,而是直接从内存中写入并执行它?

例如GCC 和clang,效果类似于:

c++ hello.cpp -o hello.x && ./hello.x $@ && rm -f hello.x

在命令行中。

但无需将可执行文件写入磁盘以立即加载/重新运行它。

(如果可能,该过程可能不使用磁盘空间,或者至少不使用当前目录中可能是只读的空间)。

【问题讨论】:

  • @David Heffernan,alfC 从未明确指定使用 Linux。他只是提供了一个gcc 构建过程作为他想做的一个例子。
  • 如果您认为这是一项有用的优化,甚至值得考虑,您应该更换 1970 年代的磁盘盘片。
  • @TJD 使用 RAM 磁盘来减少构建时间是一个相当普遍的想法。例如,请参阅this thread。
  • @slavik262 这个问题特别提到了 Linux。无论如何,任何答案都将很大程度上取决于操作系统。
  • 我知道您可能对适用于当前工具的方法感兴趣,但从历史上看,答案是肯定的。该方法称为“编译并运行”。它在较旧的编译器 textoobks 中进行了讨论,并且至少从 60 年代就已经存在。这个想法是为了消除文件系统延迟,并且效果很好。例如。从 80 年代中期到 90 年代中期,有几个版本的 Turbo Pascal 可以做到这一点。它们的速度非常快:在当时的 80486 处理器上每秒可以处理数十万行,而基于文件的编译方案则需要执行数千或数百行。

标签: c++ linux


【解决方案1】:

可能吗?不是你似乎希望的方式。任务分为两部分:

1) 如何将二进制文件放入内存

当我们在 Linux 中指定 /dev/stdout 作为输出文件时,我们可以通过管道进入我们的程序 x0,它读取 来自标准输入的可执行文件并执行它:

  gcc -pipe YourFiles1.cpp YourFile2.cpp -o/dev/stdout -Wall | ./x0

在x0 中,我们可以从标准输入读取直到到达文件末尾:

int main(int argc, const char ** argv)
{
    const int stdin = 0;
    size_t ntotal = 0;
    char * buf = 0;
    while(true)
    {
        /* increasing buffer size dynamically since we do not know how many bytes to read */
        buf = (char*)realloc(buf, ntotal+4096*sizeof(char));
        int nread = read(stdin, buf+ntotal, 4096); 
        if (nread<0) break;
        ntotal += nread;
    }
    memexec(buf, ntotal, argv); 
}

x0 也可以直接执行编译器并读取输出。这个问题已经在这里回答了:Redirecting exec output to a buffer or file

警告:我刚刚发现,由于某种奇怪的原因,这在我使用管道 | 时不起作用,但在我使用 x0 &lt; foo 时起作用。

注意:如果你愿意修改你的编译器或者你做 JIT,比如 LLVM、clang 和其他框架,你可以直接生成可执行代码。但是对于本次讨论的其余部分,我假设您想使用现有的编译器。

注意:通过临时文件执行

其他程序(例如 UPX)通过执行临时文件来实现类似的行为,这比下面概述的方法更容易且更便携。在/tmp 映射到RAM 磁盘的系统上,例如典型的服务器,临时文件无论如何都是基于内存的。

#include<cstring> // size_t
#include <fcntl.h>
#include <stdio.h> // perror
#include <stdlib.h> // mkostemp
#include <sys/stat.h> // O_WRONLY
#include <unistd.h> // read
int memexec(void * exe, size_t exe_size, const char * argv)
{
    /* random temporary file name in /tmp */
    char name[15] = "/tmp/fooXXXXXX"; 
    /* creates temporary file, returns writeable file descriptor */
    int fd_wr = mkostemp(name,  O_WRONLY);
    /* makes file executable and readonly */
    chmod(name, S_IRUSR | S_IXUSR);
    /* creates read-only file descriptor before deleting the file */
    int fd_ro = open(name, O_RDONLY);
    /* removes file from file system, kernel buffers content in memory until all fd closed */
    unlink(name);
    /* writes executable to file */
    write(fd_wr, exe, exe_size);
    /* fexecve will not work as long as there in a open writeable file descriptor */
    close(fd_wr);
    char *const newenviron[] = { NULL };
    /* -fpermissive */
    fexecve(fd_ro, argv, newenviron);
    perror("failed");
}

警告:为了清楚起见,省略了错误处理。为简洁起见包括在内。

注意:通过将步骤main() 和memexec() 合并到一个函数中并使用splice(2) 在stdin 和fd_wr 之间直接复制,可以显着优化程序。

2) 直接从内存执行

人们不会简单地从内存中加载和执行ELF 二进制文件。必须进行一些准备,主要与动态链接有关。有很多材料解释了 ELF 链接过程的各个步骤,研究它让我相信理论上是可能的。例如,请参阅此密切相关的 question on SO,但似乎不存在可行的解决方案。

更新 UserModeExec 似乎非常接近。

编写一个有效的实现会非常耗时,而且肯定会提出一些有趣的问题。我喜欢相信这是设计使然:对于大多数应用程序,强烈不希望(意外地)执行其输入数据,因为它允许code injection。

执行 ELF 时究竟会发生什么?通常,内核接收一个文件名,然后创建一个进程,加载可执行文件的不同部分并将其映射到内存中,执行大量健全性检查并将其标记为可执行文件,然后将控制权和文件名传回运行时链接器ld-linux.so(libc 的一部分)。负责重定位函数、处理附加库、设置全局对象和跳转到可执行文件入口点。 AIU 这个繁重的工作由 dl_main() 完成(在 libc/elf/rtld.c 中实现)。

甚至fexecve 也是使用/proc 中的文件实现的,正是这种对文件名的需求导致我们重新实现了此链接过程的某些部分。

图书馆

阅读

SO 的相关问题

所以看起来可能,你自己决定是否也实用。

【讨论】:

  • 好的,所以我发出了第一个命令并将可执行输出(二进制代码)显示到屏幕上。这看起来很有希望,我该把它输送到哪里?我是否必须将它传递给由您的 C 代码制成的特殊可执行文件? pipe、fork、exec 在哪里使用?管道二进制代码与“fooXXXXX”有何关系?
  • 你是对的,也许我做了一些跳跃。管道二进制代码将使用文件描述符fd 编写,该文件描述符将具有以 foo 开头的随机名称。希望我以后可以提供一个例子。无论如何,我的出发点是:users.encs.concordia.ca/~mia/tutorials/coen346/IPC_threads/…
  • 很棒的答案,首先我想知道为什么像您的程序这样的程序不是操作系统中包含的程序之一,它是一个采用二进制流并执行它的程序。其次,代码的main 部分似乎存在问题,因为./x0 在读取流时挂起。我必须更改为 if (nread != 0) break; 才能完成对流的读取。有了这个改变,我收到了一条消息failed: Exec format error。到目前为止,我正在尝试实施 2)。
  • @alfC 我假设它正在加载文件。不幸的是,我远离我的电脑。尝试在char buf[SOME_BIG_NUMBER]上使用固定大小读取
  • @alfC:因为这个答案很酷,像x0 这样的东西是我见过的最大的安全漏洞。尤其是考虑到破解是多么容易。我不希望我的系统上有那个东西。绝不。它们需要集成到一个进程中,因此x0 不存在。否则,您的安全性就完蛋了。
【解决方案2】:

是的,尽管正确执行此操作需要在设计编译器的重要部分时考虑到这一点。 LLVM 的人已经做到了这一点,首先使用了一个独立的 JIT,然后使用了 MC 子项目。我认为没有现成的工具可以做到这一点。但原则上,它只是链接到 clang 和 llvm,将源传递给 clang,并将它创建的 IR 传递给 MCJIT 的问题。也许一个演示可以做到这一点(我隐约记得一个像这样工作的基本 C 解释器,尽管我认为它是基于遗留 JIT)。

编辑:找到我记得的demo。此外,还有cling,它似乎与我描述的基本一样,但更好。

【讨论】:

  • 虽然不是解决方案,我也不是在寻找口译员,但这是我所期待的答案。 +1
  • @alfC - 你可以用 LLVM JIT 做你想做的事。从 LLVM IR 代码创建一个 llvm::Module(通过链接到可执行文件的 clang 将 C/C++ 代码编译成 LLVM IR 来获取它)。从该模块创建一个 llvm::ExecutionEngine,然后通过 JIT 调用 yourEngine->FindFunctionNamed(someFunction),然后调用 yourEngine->runFunction(pSomeFunction)。它甚至可以给你一个返回值。只要您不炸毁您的 ExecutionEngine,JITed 代码就可以一次又一次地被调用,在后续调用中更快,因为它在第一次调用时是 JITed。
  • 据我所知,有 gcc JIT 项目和 Android NDk 兼容项目。问题在于它的所有实现和操作系统特定
【解决方案3】:

Linux 可以使用tempfs 在 RAM 中创建虚拟文件系统。例如,我在文件系统表中设置了tmp 目录,如下所示:

tmpfs       /tmp    tmpfs   nodev,nosuid    0   0

使用它,我放入 /tmp 的所有文件都存储在我的 RAM 中。

Windows 似乎没有任何“官方”的方式来做到这一点,但有很多third-party options。

如果没有这种“RAM 磁盘”概念,您可能必须对编译器和链接器进行大量修改才能完全在内存中运行。

【讨论】:

  • 听听听听。我相信这有时被称为“RAM 磁盘”。
  • 实际上,在 Windows 上所需的修改相当小。创建输出文件时只需传递FILE_ATTRIBUTE_TEMPORARY。这会抑制刷新到磁盘,将文件保留在缓存中。文档明确指出,如果文件在此之后立即被删除,这可能会避免写入。
【解决方案4】:

如果您不是特别依赖于 C++,您还可以考虑其他基于 JIT 的解决方案:

  • 在 Common Lisp 中 SBCL 能够动态生成机器代码
  • 您可以使用 TinyCC 及其 libtcc.a,它会从内存中的 C 代码中快速生成较差(即未优化)的机器代码。
  • 还可以考虑任何 JITing 库,例如libjit,GNU Lightning,LLVM,GCCJIT,asmjit
  • 当然会在一些 tmpfs 上发出 C++ 代码并编译它...

但是如果你想要好的机器代码,你需要优化它,而且速度不快(所以写入文件系统的时间可以忽略不计)。

如果你依赖于 C++ 生成的代码,你需要一个好的 C++ 优化编译器(例如 g++ 或 clang++);他们花费大量时间将 C++ 代码编译为优化的二进制文件,因此您应该生成一些文件 foo.cc(可能在像一些 tmpfs 这样的 RAM 文件系统中,但这会带来很小的收益,因为大部分时间都花在了在g++ 或clang++ 优化通过,而不是从磁盘读取),然后将foo.cc 编译为foo.so(可能使用make,或者至少分叉g++ -Wall -shared -O2 foo.cc -o foo.so,可能还有其他库)。最后让你的主程序dlopen 生成foo.so。 FWIW,MELT 正是这样做的,在 Linux 工作站上,manydl.c 程序显示一个进程可以生成然后dlopen(3) 数十万个临时插件,每个插件都是通过生成一个临时 C 文件并编译它来获得的。对于 C++,请阅读 C++ dlopen mini HOWTO。

或者,生成一个独立的源程序foobar.cc,将其编译为可执行文件foobarbin,例如使用g++ -O2 foobar.cc -o foobarbin 并使用execve 执行foobarbin 可执行二进制文件

在生成 C++ 代码时,您可能希望避免生成微小的 C++ 源文件(例如,仅十几行;如果可能,至少生成几百行的 C++ 文件;除非大量 template 扩展通过广泛使用发生现有的 C++ containers,其中生成一个将它们组合起来的小型 C++ 函数是有意义的)。例如,尽可能尝试将多个生成的 C++ 函数放在同一个生成的 C++ 文件中(但要避免生成非常大的 C++ 函数,例如在单个函数中包含 10KLOC;它们需要很长时间才能被 GCC 编译)。如果相关,您可以考虑在生成的 C++ 文件中仅包含一个 #include,并预编译通常包含的标头。

Jacques Pitrat 的书Artificial Beings, the conscience of a conscious machine (ISBN 9781848211018) 详细解释了为什么在运行时生成代码是有用的(在像他的 CAIA 系统这样的符号人工智能系统中) . RefPerSys 项目试图遵循这个想法,并在运行时生成一些 C++ 代码(希望越来越多)。 Partial evaluation 是一个相关概念。

您的软件生成 C++ 代码所花费的 CPU 时间可能比 GCC 编译它所花费的更多。

【讨论】:

    【解决方案5】:

    可以很容易地修改编译器本身。这听起来很难,但仔细想想,它接缝很明显。因此,修改编译器源代码直接暴露一个库并使其成为共享库不应该花太多钱(取决于实际实现)。

    只需用内存映射文件的解决方案替换每个文件访问。

    这是我即将在后台透明地编译某些东西以操作代码并在 Java 中执行这些代码的事情。

    -

    但是考虑到您最初的问题,您希望加快编译以及您的编辑和运行周期。首先获得一个 SSD 磁盘,您几乎可以获得内存速度(使用 PCI 版本),并且可以说它是我们正在谈论的 C。 C 执行此链接步骤会导致非常复杂的操作,这些操作可能比从磁盘读取和写入磁盘花费更多时间。因此,只需将所有内容都放在 SSD 上并忍受延迟。

    【讨论】:

    • 感谢您的想法,我已经在使用 SSD,但这不是唯一的一点。正如您所说,编译器可以像解释器一样在编译后立即执行,而无需在这里和那里创建文件。
    • 好吧,如果我对 Java 中的 ASM 的尝试可行,那么有人可能会对 C 做同样的事情。会很好。 stackoverflow.com/questions/29481317/inline-asm-in-java
    【解决方案6】:

    tcc 编译器“-run”选项允许这样做,编译到内存中,在那里运行,最后丢弃编译的东西。不需要文件系统空间。 "tcc -run" 可用于 shebang 以允许 C 脚本,来自 tcc 手册页:

    #!/usr/local/bin/tcc -run
    #include <stdio.h>
    
    int main()
    {
        printf("Hello World\n");
        return 0;
    }
    

    C scripts 允许混合 bash/C 脚本,“tcc -run”不需要任何临时空间:

    #!/bin/bash
    
    echo "foo"
    sed -n "/^\/\*\*$/,\$p" $0 | tcc -run -
    
    exit
    /**
    */
    #include <stdio.h>
    
    int main()
    {
        printf("bar\n");
        return 0;
    }
    

    执行输出:

    $ ./shtcc2
    foo
    bar
    $
    

    带有 gcc 的 C 脚本也是可能的,但需要像其他提到的那样临时空间来存储可执行文件。此脚本产生与前一个相同的输出:

    #!/bin/bash
    
    exc=/tmp/`basename $0`
    if [ $0 -nt $exc ]; then sed -n "/^\/\*\*$/,\$p" $0 | gcc -x c - -o $exc; fi
    
    echo "foo"
    $exc
    
    exit
    /**
    */
    #include <stdio.h>
    
    int main()
    {
        printf("bar\n");
        return 0;
    }
    

    后缀为“.c”的 C 脚本很好,headtail.c 是我第一个需要可执行的“.c”文件:

    $ echo -e "1\n2\n3\n4\n5\n6\n7" | ./headtail.c 
    1
    2
    3
    6
    7
    $
    

    我喜欢 C 脚本,因为你只有一个文件,你可以轻松地四处移动,并且 bash 或 C 部分的更改不需要进一步的操作,它们只会在下一次执行时起作用。

    附注:
    上面显示的“tcc -run”C 脚本有问题,C 脚本标准输入不可用于执行的 C 代码。原因是我通过管道将提取的 C 代码传递给“tcc -run”。新的 gist run_from_memory_stdin.c 做得对:

    ...
    echo "foo"
    tcc -run <(sed -n "/^\/\*\*$/,\$p" $0) 42
    ...
    

    “foo”由 bash 部分打印,“bar 42”由 C 部分打印(42 被传递 argv[⁠1]),然后从 C 代码打印管道脚本输入:

    $ route -n | ./run_from_memory_stdin.c 
    foo
    bar 42
    Kernel IP routing table
    Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
    0.0.0.0         172.29.58.98    0.0.0.0         UG    306    0        0 wlan1
    10.0.0.0        0.0.0.0         255.255.255.0   U     0      0        0 wlan0
    169.254.0.0     0.0.0.0         255.255.0.0     U     303    0        0 wlan0
    172.29.58.96    0.0.0.0         255.255.255.252 U     306    0        0 wlan1
    $ 
    

    【讨论】:

    • 非常好。它适用于 C++ 吗?
    • tcc 是“微型 C 编译器”,不能处理 C++。我创建了基于 C++ script 的 g++,其中 bash 部分输出“hello”,C++ 部分输出“world”(因为 g++ 没有“-run”选项,所以 C++ 可执行文件在“/tmp”下创建并从那里执行)。跨度>
    • 现在也适用于 C++,请参阅我的 memrun C repo 的 C++ script section。
    【解决方案7】:

    最后,OP 问题的答案是肯定的!

    我从 guitmz 找到了 memrun 存储库,它演示了从内存中运行 (x86_64) ELF,使用 golang 和汇编程序。我分叉了它,并提供了 C 版本的 memrun,它可以从标准输入或通过第一个参数进程替换运行 ELF 二进制文件(在 x86_64 和 armv7l 上验证)。该 repo 包含演示和文档(memrun.c 只有 47 行代码):
    https://github.com/Hermann-SW/memrun/tree/master/C#memrun

    这是一个最简单的例子,使用“-o /dev/fd/1” gcc 编译的 ELF 被发送到标准输出,并通过管道传输到执行它的 memrun:

    pi@raspberrypi400:~/memrun/C $ gcc info.c -o /dev/fd/1 | ./memrun
    My process ID : 20043
    argv[0] : ./memrun
    no argv[1]
    evecve --> /usr/bin/ls -l /proc/20043/fd
    total 0
    lr-x------ 1 pi pi 64 Sep 18 22:27 0 -> 'pipe:[1601148]'
    lrwx------ 1 pi pi 64 Sep 18 22:27 1 -> /dev/pts/4
    lrwx------ 1 pi pi 64 Sep 18 22:27 2 -> /dev/pts/4
    lr-x------ 1 pi pi 64 Sep 18 22:27 3 -> /proc/20043/fd
    pi@raspberrypi400:~/memrun/C $ 
    

    我对这个话题感兴趣的原因是在“C 脚本”中的使用。 run_from_memory_stdin.c 一起演示:

    pi@raspberrypi400:~/memrun/C $ wc memrun.c | ./run_from_memory_stdin.c 
    foo
    bar 42
      47  141 1005 memrun.c
    pi@raspberrypi400:~/memrun/C $ 
    

    产生显示输出的 C 脚本是如此之小......

    #!/bin/bash
    
    echo "foo"
    ./memrun <(gcc -o /dev/fd/1 -x c <(sed -n "/^\/\*\*$/,\$p" $0)) 42
    
    exit
    /**
    */
    #include <stdio.h>
    
    int main(int argc, char *argv[])
    {
      printf("bar %s\n", argc>1 ? argv[1] : "(undef)");
    
      for(int c=getchar(); EOF!=c; c=getchar())  { putchar(c); }
    
      return 0;
    }
    

    附注:
    我在 gcc 和 g++ 中添加了 tcc 的“-run”选项,详情见:
    https://github.com/Hermann-SW/memrun/tree/master/C#adding-tcc--run-option-to-gcc-and-g

    很好,文件系统中没有存储任何内容:

    pi@raspberrypi400:~/memrun/C $ uname -a | g++ -O3 -Wall -run demo.cpp 42
    bar 42
    Linux raspberrypi400 5.10.60-v7l+ #1449 SMP Wed Aug 25 15:00:44 BST 2021 armv7l GNU/Linux
    pi@raspberrypi400:~/memrun/C $ 
    

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2020-04-07
      • 1970-01-01
      • 2020-02-13
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2011-03-11
      相关资源
      最近更新 更多