C语言中的main()方法是如何工作的?

97

我知道编写主方法有两种不同的签名形式 -

int main()
{
   //Code
}

或者处理命令行参数,我们将其写成-

int main(int argc, char * argv[])
{
   //code
}

我知道在C++中我们可以重载一个方法,但在C中编译器如何处理main函数的这两个不同的签名?


14
重载(Overloading)是指在同一个程序中有两个同名的方法。在 C (或者实际上几乎任何有这种构造的语言中)一个程序只能有一个 main 方法。 - Kyle Strand
14
C语言没有方法(methods),只有函数(functions)。方法是面向对象编程中通用函数的后端实现。程序通过一些对象参数调用一个函数,对象系统根据它们的类型选择一个方法(或者一组方法)。除非你自己模拟,否则C语言没有这种功能。 - Kaz
4
针对程序入口点(不仅仅是 main)的深入讨论,我推荐阅读 John R. Levine 经典著作《Linkers & Loaders》。请注意,本书需要英语阅读能力。 - Andreas Spindler
1
@KeithThompson 在C语言中,函数原型应该包含 void。但实现时完全不需要它。因此 int main(void); 是可以的,int main() { } 也是可以的。但是 int main(void) { /* some code*/ } 中的 void 是多余的。 - harper
1
@harper:()形式已经过时了,而且即使是对于main函数也不清楚它是否被允许(除非实现明确将其作为允许的形式进行记录)。 C标准(请参见5.1.2.2.1程序启动)没有提到()形式,这与()形式并不完全等价。细节太长了,无法在此评论中列出。 - Keith Thompson
显示剩余2条评论
9个回答

137

C语言的一些特性最初只是一些 hack,碰巧起作用了。

多种 main 函数签名以及可变长度参数列表就是其中之一。

程序员们注意到他们可以向函数传递额外的参数,而使用其给定的编译器并不会出现什么问题。

前提是调用约定遵循以下规则:

  1. 调用函数清理参数。
  2. 最左边的参数靠近栈顶或栈帧基址,这样杂乱的参数就不会使地址无效。

遵守这些规则的一组调用约定是基于栈的参数传递方式,即调用者弹出参数,然后从右到左依次压入堆栈:

 ;; pseudo-assembly-language
 ;; main(argc, argv, envp); call

 push envp  ;; rightmost argument
 push argv  ;; 
 push argc  ;; leftmost argument ends up on top of stack

 call main

 pop        ;; caller cleans up   
 pop
 pop

在这种调用约定适用的编译器中,不需要特殊处理支持两种或多种main的情况。如果main是一个没有参数的函数,则它对被推入栈中的项目不予考虑。如果main是一个有两个参数的函数,则它会将argcargv作为栈顶的两个元素。如果main是具有平台特定的三个参数的变体,并且带有环境指针(一种常见的扩展),那也没有问题:它将把第三个参数作为从栈顶开始的第三个元素。

因此,一个固定的调用可以适用于所有情况,允许将单个固定的启动模块链接到程序中。该模块可以是用C编写的函数,类似于以下形式:

/* I'm adding envp to show that even a popular platform-specific variant
   can be handled. */
extern int main(int argc, char **argv, char **envp);

void __start(void)
{
  /* This is the real startup function for the executable.
     It performs a bunch of library initialization. */

  /* ... */

  /* And then: */
  exit(main(argc_from_somewhere, argv_from_somewhere, envp_from_somewhere));
}

换句话说,这个起始模块只调用一个带有三个参数的main函数。如果main函数不带参数或者只带有int, char **类型的参数,它还是能正常运行的,因为调用约定允许这样做。

如果你在程序中这样做,那么它将是不可移植的,并且被ISO C视为未定义行为:在声明和调用函数的方式不同于在定义函数时。但是编译器的启动技巧并不需要是可移植的;它不受适用于可移植程序的规则的指导。

但是,假设调用约定不允许以这种方式工作。在这种情况下,编译器就必须特殊处理main函数。当它注意到正在编译main函数时,它可以生成与三个参数调用兼容的代码。

也就是说,你需要这样写:

int main(void)
{
   /* ... */
}

但是当编译器看到它时,它基本上会执行代码转换,以便编译的函数看起来更像这样:

int main(int __argc_ignore, char **__argv_ignore, char **__envp_ignore)
{
   /* ... */
}

除了名称__argc_ignore并不存在,这些名称不会引入到您的作用域中,并且不会有关于未使用参数的警告。

代码转换使编译器生成正确链接的代码,该代码知道必须清理三个参数。

另一种实现策略是由编译器或链接器定制生成__start函数(或其他名称),或者至少从几个预编译的选择中选择一个。可以将信息存储在目标文件中,指示正在使用哪个被支持的main形式。链接器可以查看此信息,并选择包含调用与程序定义兼容的main版本的启动模块的正确版本。C实现通常只有少量支持的main形式,因此这种方法是可行的。

C99语言的编译器始终需要在某种程度上特殊处理main,以支持如果函数在没有return语句的情况下终止,则行为就像执行了return 0一样的技巧。这也可以通过代码转换来处理。编译器注意到正在编译名为main的函数。然后它检查函数体是否可能到达末尾。如果是,它插入return 0;语句。


34

即使在C++中也不存在对main的重载。Main函数是程序的入口点,应该只存在一个定义。

对于标准的C语言:

对于托管环境(通常情况下),C99标准规定:

5.1.2.2.1程序启动

在程序启动时调用的函数名为main。实现不为此函数声明原型。它应该被定义为返回类型为int且没有参数:

int main(void) { /* ... */ }

或使用两个参数(这里称为argcargv,但可以使用任何名称,因为它们是在声明它们的函数中局部的):

int main(int argc, char *argv[]) { /* ... */ }

或等效; 9) 或以其他实现定义的方式。

9) 因此,int 可以被定义为 int 的 typedef 名称所替代,或者 argv 的类型可以写成 char ** argv,等等。

对于标准C++:

3.6.1 主函数 [basic.start.main]

1 程序必须包含一个名为 main 的全局函数,这是程序的指定起点。[...]

2 一个实现不得预定义主函数。 此函数不得被重载。它应具有返回类型 int,但其类型在实现中是定义的。 所有实现都应允许以下两种 main 的定义:

int main() { /* ... */ }

int main(int argc, char* argv[]) { /* ... */ }

根据C++标准,"main函数的返回类型必须是int类型,但其它方面它的类型是由实现定义的",并要求与C标准相同的两个签名。

托管环境中(支持C库的C环境),操作系统调用main函数。

非托管环境中(用于嵌入式应用程序),您可以使用预处理器指令更改程序的入口点(或退出点)。

#pragma startup [priority]
#pragma exit [priority]

priority是一个可选的整数。

Pragma startup在主函数之前(按优先级)执行该函数,而pragma exit在主函数之后执行该函数。如果有多个启动指令,则priority决定哪个指令首先执行。


4
我不认为这个答案实际上回答了编译器如何处理这种情况的问题。在我看来,@Kaz给出的答案更有洞见。 - Tilman Vogel
4
我认为这个回答比@Kaz的更好回答了这个问题。原始问题的印象是正在发生运算符重载,而这个答案通过展示编译器接受两个不同签名来解决,而不是某些重载解决方案。编译器的细节很有趣,但并非回答问题所必需的。 - Waleed Khan
1
对于独立环境(“非托管”),除了一些#pragma之外,还有很多其他的工作需要处理。硬件会发出复位中断,程序就从那里开始。然后执行所有基本设置:设置堆栈、寄存器、MMU、内存映射等。接下来,将初始化值从NVM复制到静态存储变量(.data段)中,并将所有应设置为零的静态存储变量进行 "清零" (.bss段)。在C++中,构造具有静态存储期限的对象。完成所有这些操作后,才会调用main函数。 - Lundin

8

不需要过载。是的,有两个版本,但一次只能使用一个。


5
main函数的不寻常之处并不在于它可以以多种方式定义,而是它只能以两种不同的方式之一进行定义。 main是用户定义的函数;实现没有为其声明原型。 foobar也是如此,但你可以按任何喜欢的方式定义这些函数。
不同之处在于main由实现(运行时环境)调用,而不仅仅是由你自己的代码调用。实现不局限于普通的C函数调用语义,因此它可以(并且必须)处理一些变化——但它不需要处理无限多种可能性。形如int main(int argc, char *argv[])的形式允许使用命令行参数,而C的int main(void)或C++的int main()则只是用于简单程序不需要处理命令行参数的便利方式。
至于编译器如何处理这个问题,则取决于实现。大多数系统可能有调用约定,使得这两种形式有效兼容,并且传递给不带参数的main的任何参数都会被悄悄地忽略掉。如果没有,编译器或链接器很容易特殊处理main。如果你想知道它在你的系统上是如何工作的,可以看一些汇编清单。
就像C和C++中的许多其他事情一样,细节很大程度上是由语言的设计者及其前辈做出的历史和任意决定的结果。
请注意,C和C++都允许main的其他实现定义——但很少有什么好理由使用它们。对于自由实现(例如没有操作系统的嵌入式系统),程序入口点是实现定义的,甚至不一定称为main

5

这是C和C++语言中奇怪的不对称性和特殊规则之一。

我的观点是,它存在只是出于历史原因,并没有真正严肃的逻辑支持。请注意,main还有其他特殊原因(例如,在C++中,main不能递归,您不能获取其地址,在C99 / C ++中允许省略最后的return语句)。

同时注意,即使在C++中,它也不是重载...程序只能有第一种形式或第二种形式之一,不能同时存在。


在C语言中(自C99起),您也可以省略return语句。 - dreamlax
在C语言中,您可以调用main()函数并获取其地址;而C++则会施加C所没有的限制。 - Jonathan Leffler
@JonathanLeffler:您是正确的,已修复。除了可以省略返回值之外,我在C99规范中发现main函数唯一有趣的事情是,据我理解,标准措辞使得在递归调用时不能将负值传递给 argc(5.1.2.2.1未指定对 argcargv 的限制仅适用于对main的初始调用)。 - 6502

3

main只是链接器决定的起始地址的名称,其中main是默认名称。程序中的所有函数名都是函数开始的起始地址。

函数参数被推入/弹出堆栈,因此如果没有为函数指定参数,则不会将任何参数推入/弹出堆栈。这就是为什么main可以在有或没有参数的情况下工作的原因。


2
之前有类似的问题被问到过:为什么没有参数的函数(与实际函数定义相比)会编译? 其中排名最高的答案之一是:
在C语言中,func() 的意思是你可以传入任意数量的参数。如果您不想要任何参数,则必须声明为 func(void)
所以,我猜这就是如何声明main(如果您可以将术语“声明”应用于main)。实际上,您可以编写如下内容:
int main(int only_one_argument) {
    // code
}

它仍将编译并运行。


1
非常好的观察!看起来链接器对于main非常宽容,因为还有一个问题没有提到:即使是main更多参数!“Unix(但不是Posix.1)和Microsoft Windows”添加了char ** envp(我记得DOS也允许这样做,不是吗?),而Mac OS X和Darwin又添加了另一个“任意操作系统提供的信息”char *指针。[维基百科](http://en.wikipedia.org/wiki/Main_function) - Jongware

2
嗯,同一函数main()的两种不同签名只有在需要时才会出现,我的意思是如果您的程序在任何实际处理代码之前需要数据,则可以通过使用 - 传递它们。
    int main(int argc, char * argv[])
    {
       //code
    }

变量argc存储传递的数据计数,而argv是一个指向从控制台传递的值的char指针数组。否则最好使用

    int main()
    {
       //Code
    }

无论如何,一个程序中只能有一个main()函数。因为这是程序开始执行的唯一起点,所以不能有多个。


0

您不需要覆盖此函数,因为一次只会使用一个。是的,有两个不同版本的主函数。


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