为什么将main声明为数组会编译通过?

62

我在CodeGolf上看到了一段代码片段,它是一个编译器炸弹,其中main被声明为一个巨大的数组。 我尝试了以下(非炸弹)版本:

int main[1] = { 0 };

在Clang下编译似乎很好,在GCC下只有一个警告:

warning: 'main' is usually a function [-Wmain]

生成的二进制文件当然是垃圾。

但是为什么它能够编译呢?C规范是否允许这样做?我认为相关的部分说:

5.1.2.2.1 程序启动

程序启动时调用的函数名为main。实现没有为此函数声明原型。它应具有返回类型int且不带参数[...]或带两个参数[...]或以某种其他实现定义的方式定义。

“某些其他实现定义的方式”是否包括全局数组?(看起来规范仍然参考了函数。)

如果不是,那么这是编译器的扩展吗?还是工具链的特性,它具有某些其他目的,并且他们决定通过前端使其可用?


1
无法编译。ISO C禁止大小为零的数组。 - Jens
8
这不被 C 规范所允许。编译器通常会实现规范未涉及的功能。 - M.M
相关问题:一个名为main的全局变量而不是main函数的程序如何工作?。我认为这也受到了一个代码高尔夫问题的启发。 - Shafik Yaghmour
@M.M 特别是在 Malbolge 的情况下。 - MilkyWay90
6个回答

44
这是因为 C 允许“非主机”或自由环境,不需要 main 函数。这意味着名称 main 可供其他用途。这就是为什么语言允许这样的声明。大多数编译器都设计为同时支持两种情况(不同之处在于链接方式),因此它们不会禁止在主机环境下非法的构造。
标准中所提到的部分是针对主机环境的,自由环境的相应内容如下:
在自由环境(其中 C 程序可以在没有操作系统的任何帮助下运行)中调用程序启动时函数的名称和类型是实现定义的。除了条款 4 规定的最小集合之外,可用于自由环境程序的任何库设施都是实现定义的。
如果像平常一样进行链接,则会出现问题,因为链接器通常对符号的性质(其类型或甚至是否为函数或变量)知之甚少。在这种情况下,链接器将高兴地将对 main 的调用解析为名为 main 的变量。如果找不到该符号,则会导致链接错误。
如果像平常一样进行链接,那么你基本上是试图在主机操作中使用编译器,然后未按照所要求定义 main,这意味着未定义的行为,根据附录 J.2。

独立运行的目的是为了能够在没有标准库或CRT初始化等情况下使用C语言。这意味着在调用main之前运行的代码(即初始化C运行时的CRT初始化)可能不会提供,您需要自己提供它(并且您可以选择是否有一个main函数)。


这段代码在cygwin上使用gcc 4.9.3编译和链接是可以的(尽管有一个警告):int f(int argc, char **argv) { return 0; } char *main = (char *)f; - Peter - Reinstate Monica
@PeterA.Schneider 但如果它能正常运行,那只是纯粹的运气。CRT-init将尝试调用存储指针而不是指向的内容的main函数。 - skyking
它链接了但是段错误了。顺便说一下,我不认为这个问题与“freestanding”有太大关系。例如,在VS13中,以下内容编译并链接(到dll):namespace Main_abused { class Program { int Main = 0; } } 。问题在于main(以及C#中的Main)不是关键字,而且C链接器很蠢,呃,简单。 - Peter - Reinstate Monica
@PeterA.Schneider 我不同意,如果main函数的定义与标准(或实现规定)所要求的不同,那么程序就是格式错误的。 - skyking
这并不是非常准确的。C99/C11 的托管部分有一个混乱的句子“或者以其他一些实现定义的方式”,这完全不清楚。所以没有人真正知道哪些形式的 main 是被允许的... 在这里进行了详细讨论 - Lundin
我真的看不出那个句子有什么歧义(除非你需要记录缺少其他形式)。你提到理由不是规范性的,5.1.2.2.3节(关于main函数签名)也不是规范性的 - 所以它们指向不同方向并不意味着有歧义。 - skyking

26
如果您想了解如何在主数组中创建程序,请参考https://jroweboy.github.io/c/asm/2015/01/26/when-is-main-not-a-function.html。那里的示例源代码仅包含一个名为main的字符(后来是整数)数组,其中填充了机器指令。
主要步骤和问题是:
  • 从gdb内存转储中获取主函数的机器指令,并将其复制到数组中
  • 通过声明它为常量来标记main[]中的数据可执行(数据显然可以写入或执行)
  • 最后一个细节:更改实际字符串数据的地址。
生成的C代码只是:
const int main[] = {
    -443987883, 440, 113408, -1922629632,
    4149, 899584, 84869120, 15544,
    266023168, 1818576901, 1461743468, 1684828783,
    -1017312735
};

但是在64位PC上会生成一个可执行程序:

$ gcc -Wall final_array.c -o sixth
final_array.c:1:11: warning: ‘main’ is usually a function [-Wmain]
 const int main[] = {
           ^
$ ./sixth 
Hello World!

10
问题在于main不是一个保留标识符。C标准只规定在托管系统中通常有一个名为main的函数,但标准并没有阻止你滥用相同的标识符进行其他恶意目的。
GCC会给出一个自以为是的警告“main通常是一个函数”,暗示将标识符main用于其他无关目的并不是一个好主意。
愚蠢的例子:
#include <stdio.h>

int main (void)
{
  int main = 5;
  main:

  printf("%d\n", main);
  main--;

  if(main)
  {
    goto main;
  }
  else
  {
    int main (void);
    main();
  }
}

这个程序会不断地打印数字5, 4, 3, 2, 1,直到出现堆栈溢出并崩溃(请勿在家中尝试)。不幸的是,上面的程序是一个严格符合C语言标准的程序,编译器无法阻止你编写它。


8
在编译后就像许多其他对象文件中的全局函数、全局变量等一样,只是另一个符号而已。

无论其类型如何,链接器都会链接符号

。实际上,链接器根本看不到符号的类型(但他可以看到它不在<.text>部分,但他不关心)。使用gcc时,标准入口点是_start,然后准备运行时环境并调用main()。因此,它将跳转到整数数组的地址,这通常会导致错误指令、段错误或其他糟糕的行为。

当然,所有这些都与C标准没有任何关系。


我在skyking的回答下发布了一个最小示例,但是它链接时出现了段错误。有什么调整可以使其工作,比如使用内联汇编或其他方法? - Peter - Reinstate Monica
@PeterA.Schneider 它会发生段错误,因为它会跳转到指针的地址而不是它的内容。 - Ctx
谢谢!我猜我仍然期望这些工具链的C前端会抛出异常,即使链接器在看到目标文件时不关心。 - Theodoros Chatzigiannakis

3

之所以能够编译通过,是因为您没有使用正确的选项(并且工作是因为链接器有时只关心符号的名称而不是它们的类型)。

$ gcc -std=c89 -pedantic -Wall x.c
x.c:1:5: warning: ISO C forbids zero-size array ‘main’ [-Wpedantic]
 int main[0];
     ^
x.c:1:5: warning: ‘main’ is usually a function [-Wmain]

3
它仍然可以编译和链接。唯一的区别是它会警告你main通常是一个函数(然后它继续并链接)。 - skyking
1
@skyking 你想让编译/链接失败吗?那就加上“-Werror”。 - Jens
1
但是,其他有效的C程序也将无法编译。 - skyking
2
我同意使用-Werror并启用警告是一个好主意,但这并不否认这样做会导致编译器无法编译有效的C程序。 - skyking
1
“-Werror”的整个概念是如果发出警告,则编译失败。即使在有效的C程序上也可能会发出警告。我相信GCC开发人员不想看到这种情况发生。 - skyking
显示剩余2条评论

1
const int main[1] = { 0xc3c3c3c3 };

这可以在x86_64上编译和执行...什么也不做,只是返回:D。

有趣,它是如何工作的?它仍然可以与ASLR一起工作吗? - SilverWolf
1
C3只是一个返回语句。因此它执行并返回。 - Zibri

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接