为什么需要为每个操作系统重新编译 C/C + + ?

这只是个理论问题。我主修计算机科学,对低级编程非常感兴趣。我喜欢探索引擎盖下的世界。我的专长是编译器设计。

不管怎样,当我在编写我的第一个编译器的时候,有些事情发生在我身上,让我有点困惑。

当你用 C/C + + 编写一个程序时,人们通常知道的是,编译器会神奇地把你的 C/C + + 代码变成机器的本机代码。

但是有些事情说不通。如果我以 x86架构为目标编译我的 C/C + + 程序,那么似乎相同的程序应该在具有相同架构的任何计算机上运行。但那不会发生。您需要为 OS X、 Linux 或 Windows 重新编译代码。(32位 VS 64位)

我只是好奇为什么会这样?在编译 C/C + + 程序时,我们不是以 CPU 体系结构/指令集为目标吗?Mac 操作系统和 Windows 操作系统可以在完全相同的架构上运行。

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

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

6242 次浏览

不,你的目标不仅仅是一个 CPU。你也瞄准了操作系统。假设您需要使用 cout将某些内容打印到终端屏幕上。cout最终将调用程序所运行的操作系统的 API 函数。对于不同的操作系统,这种调用可能会有所不同,也将会有所不同,因此这意味着您需要为每个操作系统编译程序,以便它能够进行正确的操作系统调用。

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

如何打印输出?怎么打开文件?怎么设定计时器?如何显示 UI?所有这些事情都需要从操作系统请求服务,不同的操作系统提供不同的服务,需要不同的调用来请求它们。

  1. 标准库和 C 运行时必须与 OS API 交互。
  2. 不同目标操作系统的可执行格式是不同的。
  3. 不同的操作系统内核可以以不同的方式配置硬件。比如字节顺序、堆栈方向、注册使用约定等,可能还有许多其他的事情在物理上是不同的。

在编译 C/C + + 程序时,我们不是以 CPU 体系结构/指令集为目标吗?

不,你不知道。

我的意思是,是的,您正在编译一个 CPU 指令集。但是这不是 所有编译。

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

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

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

操作系统调用并没有跨操作系统进行标准化。几乎所有的标准库函数都会与操作系统对话,至少完成一部分工作,而这些函数在 C 或 C + + 中是无法独立构建的。

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

您的标准库是从对操作系统例程的调用构建的。而且这些操作系统例程是不可移植的,所以您的标准库实现是不可移植的。因此,可执行文件中包含这些不可移植的调用。

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

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

这是真的,但有一些细微的差别。

让我们考虑一些程序的情况,从 C 语言的角度来看,这些程序与操作系统无关。


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

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

但是每个操作系统都希望包含此代码的二进制文件有不同的头。例如,Windows 需要 PE 格式,Linux 需要 精灵,macOS 使用 马赫数格式。对于您的简单程序,您可以将机器代码准备为一个单独的文件,以及每个操作系统的可执行格式的一组头文件。然后,您所需要的“重新编译”实际上是连接头部和机器代码,并可能添加对齐“页脚”。

因此,假设您将 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的 Windows7系统上是这样的

    ; 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. 现在,您的程序希望加载一些共享库,以避免重新发明一些轮子。

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

例如,假设程序需要调用的 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中获得第二个 int值,而不需要向函数传递任何额外的参数。


还有其他的细微差别,比如不同的 名字毁坏模式(尽管在同一个操作系统上编译器之间可能有所不同) ,不同的数据类型(例如 MSVC 上的 long double和 GCC 上的 long double)等等,但是上面提到的这些是从编译器和链接器的角度来看操作系统之间最重要的差别。

严格来说,你不需要

程序加载器

你有葡萄酒,WSL1或 Darling,它们都是各自其他 OS 的二进制格式的加载程序。这些工具工作得很好,因为机器的 差不多吧是相同的。

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

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

These differ. Now, eg. wine makes Linux understand the WinPE format, and then "simply" runs the machine code as a Linux process (no emulation!). It implements parts of the WinAPI and translates it for Linux. Actually, Windows does pretty much the same thing, as Windows programs do not talk to the Windows Kernel (NT) but the Win32 subsystem... which translates the WinAPI into the NT API. As such, wine is "basically" another WinAPI implementation based on the Linux API.

虚拟机中的 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. 运行连接器

可能有或多或少的步骤,但这是通常的管道。第二步,同样有点保留,基本上每个平台都是一样的。然而,预处理器将不同的头文件复制到编译单元(步骤1) ,链接器的工作方式完全不同。从理论角度来看,从一种语言(C)到另一种语言(ASM)的实际翻译是与平台无关的。

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

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

其中一个或多个操作系统之间的差异就是为什么不能直接将一个用于某个操作系统的二进制文件正常地加载到另一个操作系统上的原因。

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