这更多的是理论问题,而不是任何问题。我是Comp sci专业,对低级编程非常感兴趣。我喜欢找出事物的幕后运作方式。我的专长是编译器设计。
无论如何,当我在开发第一个编译器时,发生的事情让我有些困惑。
当您使用C / C ++编写程序时,人们所知道的传统知识是,编译器神奇地将您的C / C ++代码转换为该计算机的本机代码。
但是这里没有加起来。如果我针对x86架构编译C / C ++程序,则似乎同一程序应在具有相同架构的任何计算机上运行。但这不会发生。您需要针对OS X,Linux或Windows重新编译代码。(然后再次针对32位和64位)
我只是想知道为什么会这样?编译C / C ++程序时,我们不是针对CPU体系结构/指令集吗? Mac OS和Windows Os可以在完全相同的体系结构上运行。
(我知道Java和类似的目标是VM或CLR,所以这些都不算数)
如果我对此做出了最好的回答,我会说C / C ++必须随后编译为特定于OS的指令。但是我阅读的所有资料都说编译器针对的是机器。所以我很困惑。
答案 0 :(得分:82)
在编译C / C ++程序时,我们不是针对CPU体系结构/指令集吗?
不,你没有。
我的意思是,您正在编译CPU指令集。但这不是 all 编译的全部。
考虑最简单的“世界,你好!”程序。它所要做的就是调用printf
,对吗?但是没有“ printf”指令集操作码。那么...到底发生了什么?
好吧,这是C标准库的一部分。它的printf
函数对字符串和参数进行一些处理,然后显示它。怎么发生的?好吧,它将字符串发送到标准输出。好...是谁控制的?
操作系统。而且也没有“标准输出”操作码,因此向标准输出发送字符串涉及某种形式的OS调用。
并且OS调用不是跨操作系统标准化的。几乎每个标准库功能都无法完成您在C或C ++中无法自行构建的功能,因此将与操作系统进行交流,以至少完成其某些工作。
malloc
?记忆不属于你;它属于操作系统,因此您也许可以拥有一些。 scanf
?标准输入不属于您;它属于操作系统,您也许可以从中读取内容。依此类推。
您的标准库是从对OS例程的调用中构建的。而且这些OS例程是不可移植的,因此您的标准库实现是不可移植的。因此,您的可执行文件中包含这些不可移植的调用。
最重要的是,不同的操作系统甚至对“可执行文件”的外观也有不同的看法。毕竟,可执行文件不只是一堆操作码。您认为所有这些常量和预初始化的static
变量都存储在哪里?不同的操作系统具有不同的启动可执行文件的方式,并且可执行文件的结构是其中的一部分。
答案 1 :(得分:17)
如何分配内存?没有用于分配动态内存的CPU指令,您必须向OS请求内存。但是参数是什么?您如何调用操作系统?
如何打印输出?您如何打开文件?您如何设置计时器?您如何显示UI?所有这些事情都需要从OS请求服务,并且不同的OS为不同的服务提供不同的服务,并且需要不同的调用来请求它们。
答案 2 :(得分:12)
如果我针对x86架构编译C / C ++程序,则似乎同一程序应在具有相同架构的任何计算机上运行。
这是真的,但是有一些细微差别。
从C语言的角度来看,让我们考虑几种与OS无关的程序。
所有操作系统的机器代码可能完全相同(前提是它们都在相同的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 >....<
*
只需重复上一行直到到达*
以下的地址):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上,您将在int
中获得结构的第一个EAX
值,并在EDX
中获得第二个值,而无需将任何其他参数传递给该函数。
还有其他细微差别,例如不同的name mangling方案(尽管即使在同一OS上,编译器之间也可能有所不同),不同的数据类型(例如MSVC上的long double
与GCC上的long double
)等,但是从编译器和链接器的角度来看,上述是操作系统之间最重要的区别。
答案 3 :(得分:9)
不,您不仅仅针对CPU。您还以操作系统为目标。假设您需要使用cout
在终端屏幕上打印一些内容。 cout
最终将为运行程序的OS调用API函数。对于不同的操作系统,该调用可能而且会有所不同,因此,这意味着您需要为每个OS编译程序,以便进行正确的OS调用。
答案 4 :(得分:5)
答案 5 :(得分:3)
严格来说,您不需要
您有WSL1或亲爱的酒,它们都是各自其他OS二进制格式的加载器。这些工具运行得很好,因为计算机基本上相同。
创建可执行文件时,在所有基于x86的平台上,“ 5 + 3”的机器代码基本上是 相同,但是其他答案已经提到了这些区别,例如:< / p>
这些不同。现在,例如。 wine使Linux理解WinPE格式,然后“简单地”将机器代码作为Linux进程运行(无需仿真!)。它实现了WinAPI的各个部分,并将其转换为Linux。实际上,Windows做几乎相同的事情,因为Windows程序不与Windows内核(NT)对话,而是与Win32子系统对话……Win32子系统将WinAPI转换为NT API。因此,wine是“基本上”基于Linux API的另一个WinAPI实现。
此外,您实际上可以将C编译为“裸”机器代码以外的其他内容,例如LLVM字节代码或wasm。像GraalVM这样的项目甚至使在Java虚拟机中运行C成为可能:一次编译,随处运行。您在那里定位到另一种API / ABI /文件格式,该格式一开始就是“便携式”的。
因此,尽管ISA构成了CPU可以理解的全部语言,但大多数程序不仅“依赖” CPU ISA,还需要使OS正常工作。工具链必须做到这一点
实际上,您实际上非常正确。实际上,您可以使用编译器为Linux和Win32进行编译,甚至可以获得相同的结果-对于“编译器”的定义非常狭窄。但是当您像这样调用编译器时:
c99 -o foo foo.c
您不仅可以编译(将C代码转换为例如汇编语言),还可以这样做:
可能有更多或更少的步骤,但这是通常的流程。第2步同样是撒盐,每个平台上的基本相同。但是,预处理器会将不同的头文件复制到您的编译单元中(步骤1),并且链接程序的工作方式完全不同。从一种语言(C)到另一种语言(ASM)的实际翻译,即从编译器的理论角度来看,是与平台无关的。