如果我针对 x86 架构编译我的 C/C++ 程序,似乎相同的程序应该在具有相同架构的任何计算机上运行。
确实如此,但有一些细微差别。
让我们考虑几种从 C 语言的角度来看与操作系统无关的程序。
- 假设您的程序所做的一切,从一开始就是通过在没有任何 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 上运行。您需要两个标头,这里是示例(十六进制转储):
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 字节,您将获得可在相应操作系统上运行的有效可执行文件。
-
现在假设您的程序在进行了一些计算后想要终止。
现在它有两个选择:
崩溃——例如通过执行无效指令(在 x86 上它可能是 UD2)。这很简单,独立于操作系统,但并不优雅。
要求操作系统正确终止进程。此时我们需要一个依赖于操作系统的机制来执行此操作。
在 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 的正确方法是通过操作系统依赖的另一个细微差别:共享库。
- 现在您的程序想要加载一些共享库以避免重新发明一些轮子。
好的,我们已经看到我们的可执行文件格式是不同的。假设我们已经考虑到这一点,并为针对每个目标操作系统的文件准备了导入部分。还有一个问题:每个操作系统调用函数的方式——所谓的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 )等,但以上从编译器和链接器的角度来看,这些是操作系统之间最重要的区别。