【问题标题】:Why do you need to recompile C/C++ for each OS? [duplicate]为什么需要为每个操作系统重新编译 C/C++? [复制]
【发布时间】:2020-08-21 23:44:14
【问题描述】:

这更像是一个理论问题。我是 Comp sci 专业的,对低级编程非常感兴趣。我喜欢了解事情在幕后是如何运作的。我的专长是编译器设计。

无论如何,当我正在开发我的第一个编译器时,我遇到了一些令人困惑的事情。

当您使用 C/C++ 编写程序时,人们所知道的传统做法是,编译器会神奇地将您的 C/C++ 代码转换为该机器的本机代码。

但是这里没有加起来。如果我针对 x86 架构编译我的 C/C++ 程序,似乎相同的程序应该在具有相同架构的任何计算机上运行。但这不会发生。你需要为 OS X 或 Linux 或 Windows 重新编译你的代码。(再一次为 32 位 vs 64 位)

我只是想知道为什么会这样?我们在编译 C/C++ 程序时不是针对 CPU 架构/指令集吗? Mac OS 和 Windows 操作系统可以在完全相同的架构上运行。

(我知道 Java 和类似的目标是 VM 或 CLR,所以这些不算)

如果我对此给出了最佳答案,我会说 C/C++ 必须编译为特定于操作系统的指令。但是我读到的每个来源都说编译器以机器为目标。所以我很困惑。

【问题讨论】:

标签: c++ c winapi compilation


【解决方案1】:

我们在编译 C/C++ 程序时不是针对 CPU 架构/指令集吗?

不,你没有。

我的意思是,您正在为 CPU 指令集进行编译。但这并不是全部编译。

考虑最简单的“你好,世界!”程序。它所做的只是调用printf,对吗?但是没有“printf”指令集操作码。那么……到底发生了什么?

嗯,这是 C 标准库的一部分。它的printf 函数对字符串和参数进行一些处理,然后... 显示它。这是怎么发生的?好吧,它将字符串发送到标准输出。好的...谁来控制它?

操作系统。而且也没有“标准输出”操作码,因此将字符串发送到标准输出涉及某种形式的操作系统调用。

而且操作系统调用没有跨操作系统标准化。几乎每一个标准库函数都可以做一些你不能在 C 或 C++ 中自己构建的事情,都会与操作系统对话以至少完成它的一些工作。

malloc?记忆不属于你;它属于操作系统,您也许可以拥有一些。 scanf?标准输入不属于你;它属于操作系统,您可以从中读取。以此类推。

您的标准库是通过调用操作系统例程构建的。并且这些操作系统例程是不可移植的,因此您的标准库实现是不可移植的。所以你的可执行文件中有这些不可移植的调用。

除此之外,不同的操作系统对“可执行文件”的外观有不同的看法,甚至看起来。毕竟,可执行文件不仅仅是一堆操作码。您认为所有这些常量和预初始化的static 变量都存储在哪里?不同的操作系统有不同的启动可执行文件的方式,可执行文件的结构是其中的一部分。

【讨论】:

  • ABI 也不同
  • @NassimAssaf 操作系统将字节插入 GPU RAM 以告诉它您要打印的字符的像素。 (简体)
  • 在最后一段中已经暗示过,但值得明确提及:语言通常需要语言支持库。该图书馆负责,例如初始化具有静态存储持续时间的对象(如最后一段中所述),或将 C++ 异常映射到操作系统和编译器特定的基础设施。语言支持库是特定于编译器和操作系统的。
  • 您谈到了这一点,但没有明确提及 链接器,它是“编译”过程中相当重要的一部分,因为大多数人的真正意思是“构建可执行的二进制文件” " 当他们说“编译”而不仅仅是编译步骤时。即使在编译器完成了特定于环境的工作之后,链接器也是非常特定于环境的。
  • @NassimAssaf 您开始提出一些与此问题无关的问题,但它们是很好的问题!操作系统知道连接到计算机的所有硬件,因此它知道要打印文字,例如,它需要与某些显示器接口。它有一个用于该显示器的设备驱动程序,它说“嘿驱动程序,打印这些东西”并且驱动程序会这样做。因此,即使是操作系统也不负责“简单”的事情,比如打印“hello world”。基本上,一直都是乌龟。
【解决方案2】:

你如何分配内存?没有分配动态内存的 CPU 指令,您必须向操作系统索取内存。但是参数是什么?如何调用操作系统?

如何打印输出?你如何打开一个文件?你如何设置计时器?如何显示 UI?所有这些都需要向操作系统请求服务,而不同的操作系统通过不同的调用来提供不同的服务来请求它们。

【讨论】:

  • 您的观点很有道理,但是如果我可能会问一个稍微偏离主题的问题,操作系统本身是如何知道如何做到这一点的?操作系统本身最终要管理硬件,那么CPU在某种程度上不是需要知道如何打印吗?并分配?我的意思是,您通过 API 告诉操作系统代表您执行此操作。
  • @NassimAssaf 这就是内核开发人员得到大笔报酬才能弄清楚的原因。
  • @NassimAssaf "操作系统本身最终必须管理硬件,因此 CPU 在某种程度上不需要知道如何打印吗?" - 不一定。其中一些东西是由 BIOS 而不是 CPU 处理的。操作系统内核知道如何与 BIOS 对话。 "And to allocate?" - 这不是 CPU 的工作。那么安装在显卡上而不是 RAM 上的内存呢?同样,不是 CPU 的工作要管理。
  • @NassimAssaf CPU 必须能够向硬件发出命令,但它不必知道如何去做。它不需要知道存在什么硬件,它们的寄存器在哪里,在这些寄存器中放入什么值来完成什么功能,等等。这是操作系统软件的工作,通常是驱动程序。我可以让我的手机传达诗歌,所以我的手机必须能够发送诗歌,但它不必知道诗歌是什么或英语的结构是什么。
  • @NassimAssaf 有关于这个主题的整本书和课程,比这里可以讨论的要详细得多。
【解决方案3】:

如果我针对 x86 架构编译我的 C/C++ 程序,似乎相同的程序应该在具有相同架构的任何计算机上运行。

确实如此,但有一些细微差别。

让我们考虑几种从 C 语言的角度来看与操作系统无关的程序。


  1. 假设您的程序所做的一切,从一开始就是通过在没有任何 I/O 的情况下进行大量计算来对 CPU 进行压力测试。

所有操作系统的机器代码可能完全相同(前提是它们都运行在相同的 CPU 模式下,例如 x86 32 位保护模式)。你甚至可以直接用汇编语言编写它,它不需要针对每个操作系统进行调整。

但是每个操作系统都希望包含此代码的二进制文件具有不同的标头。例如。 Windows 需要PE format,Linux 需要ELF,macOS 使用Mach-O 格式。对于您的简单程序,您可以将机器代码准备为单独的文件,并为每个操作系统的可执行格式准备一堆标题。那么你需要“重新编译”实际上就是连接标题和机器代码,并可能添加对齐“页脚”。

所以,假设您将 C 代码编译成机器码,如下所示:

offset:  instruction  disassembly
    00:  f7 e0        mul eax
    02:  eb fc        jmp short 00

这是简单的压力测试代码,它自己反复做eax寄存器的乘法。

现在您想让它在 32 位 Linux 和 32 位 Windows 上运行。您需要两个标头,这里是示例(十六进制转储):

  • 对于 Linux:
000000 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00  >.ELF............<
000010 02 00 03 00 01 00 00 00 54 80 04 08 34 00 00 00  >........T...4...<
000020 00 00 00 00 00 00 00 00 34 00 20 00 01 00 28 00  >........4. ...(.<
000030 00 00 00 00 01 00 00 00 54 00 00 00 54 80 04 08  >........T...T...<
000040 54 80 04 08 04 00 00 00 04 00 00 00 05 00 00 00  >T...............<
000050 00 10 00 00                                      >....<
  • 对于 Windows(* 只是重复上一行,直到到达* 下面的地址):
000000 4d 5a 80 00 01 00 00 00 04 00 10 00 ff ff 00 00  >MZ..............<
000010 40 01 00 00 00 00 00 00 40 00 00 00 00 00 00 00  >@.......@.......<
000020 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  >................<
000030 00 00 00 00 00 00 00 00 00 00 00 00 80 00 00 00  >................<
000040 0e 1f ba 0e 00 b4 09 cd 21 b8 01 4c cd 21 54 68  >........!..L.!Th<
000050 69 73 20 70 72 6f 67 72 61 6d 20 63 61 6e 6e 6f  >is program canno<
000060 74 20 62 65 20 72 75 6e 20 69 6e 20 44 4f 53 20  >t be run in DOS <
000070 6d 6f 64 65 2e 0d 0a 24 00 00 00 00 00 00 00 00  >mode...$........<
000080 50 45 00 00 4c 01 01 00 ee 71 b4 5e 00 00 00 00  >PE..L....q.^....<
000090 00 00 00 00 e0 00 0f 01 0b 01 01 47 00 02 00 00  >...........G....<
0000a0 00 02 00 00 00 00 00 00 00 10 00 00 00 10 00 00  >................<
0000b0 00 10 00 00 00 00 40 00 00 10 00 00 00 02 00 00  >......@.........<
0000c0 01 00 00 00 00 00 00 00 03 00 0a 00 00 00 00 00  >................<
0000d0 00 20 00 00 00 02 00 00 40 fb 00 00 03 00 00 00  >. ......@.......<
0000e0 00 10 00 00 00 10 00 00 00 00 01 00 00 00 00 00  >................<
0000f0 00 00 00 00 10 00 00 00 00 00 00 00 00 00 00 00  >................<
000100 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  >................<
*
000170 00 00 00 00 00 00 00 00 2e 66 6c 61 74 00 00 00  >.........flat...<
000180 04 00 00 00 00 10 00 00 00 02 00 00 00 02 00 00  >................<
000190 00 00 00 00 00 00 00 00 00 00 00 00 60 00 00 e0  >............`...<
0001a0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  >................<
*
000200

现在,如果您将机器代码附加到这些标头,并且对于 Windows,还附加一堆空字节以使文件大小为 1024 字节,您将获得可在相应操作系统上运行的有效可执行文件。


  1. 现在假设您的程序在进行了一些计算后想要终止。

    现在它有两个选择:

    1. 崩溃——例如通过执行无效指令(在 x86 上它可能是 UD2)。这很简单,独立于操作系统,但并不优雅。

    2. 要求操作系统正确终止进程。此时我们需要一个依赖于操作系统的机制来执行此操作。

在 x86 Linux 上会是

xor ebx, ebx ; zero exit code
mov eax, 1   ; __NR_exit
int 0x80     ; do the system call (the easiest way)

在 x86 Windows 7 上会是

    ; First call terminates all threads except caller thread, see for details:
    ; http://www.rohitab.com/discuss/topic/41523-windows-process-termination/
    mov eax, 0x172  ; NtTerminateProcess_Wind7
    mov edx, terminateParams
    int 0x2e        ; do the system call
    ; Second call terminates current process
    mov eax, 0x172
    mov edx, terminateParams
    int 0x2e
terminateParams:
    dd 0, 0 ; processHandle, exitStatus

请注意,在其他 Windows 版本上,您需要另一个系统调用号。调用NtTerminateProcess 的正确方法是通过操作系统依赖的另一个细微差别:共享库。


  1. 现在您的程序想要加载一些共享库以避免重新发明一些轮子。

好的,我们已经看到我们的可执行文件格式是不同的。假设我们已经考虑到这一点,并为针对每个目标操作系统的文件准备了导入部分。还有一个问题:每个操作系统调用函数的方式——所谓的calling convention——是不同的。

例如假设您的程序需要调用的 C 语言函数返回一个包含两个 int 值的结构。在 Linux 上,调用者必须分配一些空间(例如在堆栈上)并将指向它的指针作为第一个参数传递给被调用的函数,如下所示:

sub esp, 12 ; 4*2+alignment: stack must be 16-byte aligned
push esp    ;                right before the call instruction
call myFunc

在 Windows 上,您将在 EAX 中获得结构的第一个 int 值,在 EDX 中获得第二个值,而无需向函数传递任何其他参数。


还有其他细微差别,例如不同的 name mangling 方案(尽管即使在相同的操作系统上,编译器之间也可能有所不同),不同的数据类型(例如 MSVC 上的 long double 与 GCC 上的 long double )等,但以上从编译器和链接器的角度来看,这些是操作系统之间最重要的区别。

【讨论】:

    【解决方案4】:

    不,您不只是针对 CPU。您还针对操作系统。假设您需要使用cout 在终端屏幕上打印一些内容。 cout 最终会为运行程序的操作系统调用 API 函数。对于不同的操作系统,该调用可以而且将会有所不同,这意味着您需要为每个操作系统编译程序,以便它可以进行正确的操作系统调用。

    【讨论】:

      【解决方案5】:
      1. 标准库和 C 运行时必须与 OS API 交互。
      2. 不同目标操作系统的可执行格式不同。
      3. 不同的操作系统内核可以以不同的方式配置硬件。字节顺序、堆栈方向、寄存器使用约定以及可能许多其他的东西在物理上可能不同。

      【讨论】:

        【解决方案6】:

        严格来说,你不需要

        程序加载器

        您有 wine、WSL1 或 darling,它们都是其他操作系统二进制格式的加载程序。这些工具工作得很好,因为机器基本上是一样的。

        当您创建一个可执行文件时,“5+3”的机器代码基本上在所有基于 x86 的平台上都是相同的,但是其他答案已经提到了一些差异,例如:

        • 文件格式
        • API:例如。操作系统公开的功能
        • ABI:二进制布局等

        这些不同。现在,例如。 wine 使 Linux 理解 WinPE 格式,然后“简单地”将机器代码作为 Linux 进程运行(没有仿真!)。它实现了部分 WinAPI 并将其翻译为 Linux。实际上,Windows 做的事情几乎相同,因为 Windows 程序不与 Windows 内核 (NT) 对话,而是与 Win32 子系统对话……它将 WinAPI 转换为 NT API。因此,wine“基本上”是另一种基于 Linux API 的 WinAPI 实现。

        虚拟机中的 C

        此外,您实际上可以将 C 编译为“裸”机器代码以外的其他内容,例如 LLVM 字节代码或 wasm。像 GraalVM 这样的项目甚至可以在 Java 虚拟机中运行 C:一次编译,到处运行。在那里,您以另一种 API/ABI/文件格式为目标,该格式从一开始就旨在“便携”。

        因此,虽然 ISA 构成了 CPU 可以理解的整个语言,但大多数程序不仅“依赖”于 CPU ISA,还需要操作系统才能工作。工具链必须注意这一点

        但你是对的

        不过,实际上,您已经非常接近正确了。实际上,您可以使用您的编译器为 Linux 和 Win32 进行编译,甚至可能得到相同的结果——“编译器”的定义相当狭窄。但是当你像这样调用编译器时:

        c99 -o foo foo.c
        

        您不仅要编译(将 C 代码翻译成例如汇编),而且还要这样做:

        1. 运行 C 预处理器
        2. 运行“实际的”C 编译器前端
        3. 运行汇编程序
        4. 运行链接器

        可能会有更多或更少的步骤,但这是通常的流程。第 2 步是,同样的,在每个平台上基本上都是一样的。然而,预处理器将不同的头文件复制到您的编译单元中(步骤 1),并且链接器的工作方式完全不同。从一种语言 (C) 到另一种语言 (ASM) 的实际翻译,从理论上讲是编译器所做的,与平台无关。

        【讨论】:

          【解决方案7】:

          要使二进制文件正常工作(或在某些情况下完全正常工作),有很多丑陋的细节需要保持一致/正确,包括但可能不限于。

          • 如何将 C 源代码构造(如过程调用、参数、类型等)映射到特定于体系结构的构造(如寄存器、内存位置、堆栈帧等)。
          • 编译结果如何在可执行文件中表示,以便二进制加载程序可以将它们加载到虚拟地址空间中的正确位置和/或在将它们加载到任意位置后执行“修复”。
          • 标准库的具体实现方式,有时标准库函数是库中的实际函数,但通常它们是可能依赖于库中非标准函数的宏、内联函数甚至编译器内置函数。
          • 操作系统和应用程序之间的边界被认为是,在类 unix 系统上,C 标准库被认为是核心平台库。另一方面,在 Windows 上,C 标准库被认为是编译器提供的东西,要么编译到应用程序中,要么与它一起提供。
          • 其他库是如何实现的?他们用什么名字?它们是如何加载的?

          其中一个或多个方面的差异是为什么您不能只获取一个用于一个操作系统的二进制文件并将其正常加载到另一个操作系统上。

          话虽如此,可以在另一个操作系统上运行用于一个操作系统的代码。这基本上就是葡萄酒的作用。它具有特殊的转换器库,可将 Windows API 调用转换为 Linux 上可用的调用,还有一个特殊的二进制加载器,它知道如何加载 Windows 和 Linux 二进制文件。

          【讨论】:

            猜你喜欢
            • 2016-10-03
            • 2021-06-09
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 2013-04-20
            • 1970-01-01
            • 1970-01-01
            • 2020-02-10
            相关资源
            最近更新 更多