震惊!C++程序真的从main开始吗?99%的程序员都答错了

嘿,朋友们好啊!我是小康。今天咱们来聊一个看似简单,但实际上99%的C++程序员都答错的问题:C++程序真的是从main函数开始执行的吗?

如果你毫不犹豫地回答"是",那恭喜你,你和大多数人一样——掉进了C++的第一个陷阱!别担心,等你看完这篇文章,你就能成为那个与众不同的1%了。

微信搜索 【跟着小康学编程】,关注我,定期分享计算机编程硬核技术文章。

一、揭开C++启动的神秘面纱

还记得你写的第一个C++程序吗?可能是这样的:

#include <iostream>int main() {std::cout << "Hello, World!" << std::endl;return 0;
}

老师告诉你:"程序从main函数开始执行"。然后你就这么相信了,一路写代码写到现在。但事实真的如此吗?

剧透一下:并不是!

如果你仔细思考,一定会冒出许多疑问:

  • 谁负责调用main函数?
  • 在main执行前,系统到底做了什么?
  • 为什么main前面的全局变量已经初始化好了?
  • main函数返回后又发生了什么?

今天,我们就来一起掀开这神秘的黑箱,看看C++程序启动的真相!

二、C++程序启动的真实过程

想象一下,一个C++程序的生命周期就像一次电影拍摄:

1、前期准备:搭建场景,准备道具(操作系统加载程序)

2、彩排:演员就位,准备开拍(初始化运行环境)

3、正式拍摄:导演喊"Action!"(执行main函数)

4、收尾工作:打包器材,清理现场(释放资源,结束程序)

而我们平时只关注"正式拍摄"阶段,却忽略了其他同样重要的环节。

第一幕:操作系统的角色

当你双击一个.exe文件或者以命令行./program 执行时,发生了什么?

操作系统会首先加载可执行文件到内存,然后做一系列准备工作:

1、创建进程和线程

2、分配栈空间和堆空间

3、加载依赖的动态链接库(DLL或so文件)

4、 设置各种环境变量

这就像电影开拍前,场务人员布置好拍摄场地,准备好所有道具。

第二幕:C/C++运行时的初始化

操作系统准备好后,并不会直接跳到main函数,而是先调用C/C++运行时库的初始化代码。在Windows中,这通常是_startmainCRTStartup,在Linux中是_start

这个启动函数负责完成以下工作

1、初始化C运行时库

2、设置堆管理器的数据结构

3、初始化I/O子系统

4、处理命令行参数(构建argc和argv)

5、初始化全局变量和静态变量

6、调用全局对象的构造函数

7、最后才调用main函数

看到了吗?main函数实际上是被运行时库调用的!它不是起点,而是运行时库准备好一切后才执行的函数。

我们来看个例子

#include <iostream>// 全局变量
int globalVar = 42;// 全局对象
class GlobalObject {
public:GlobalObject() {std::cout << "全局对象构造函数被调用,此时main还没开始执行!" << std::endl;}~GlobalObject() {std::cout << "全局对象析构函数被调用,此时main已经结束了!" << std::endl;}
};GlobalObject g_obj; // 全局对象实例int main() {std::cout << "现在才是main函数开始执行..." << std::endl;std::cout << "全局变量值:" << globalVar << std::endl;std::cout << "main函数结束..." << std::endl;return 0;
}

运行这段代码,你会惊讶地发现输出是:

全局对象构造函数被调用,此时main还没开始执行!
现在才是main函数开始执行...
全局变量值:42
main函数结束...
全局对象析构函数被调用,此时main已经结束了!

看到了吗?全局对象的构造函数在main函数之前就执行了!这就是最直接的证据:程序并非从main开始

第三幕:main函数 - 只是主角,而非导演

main函数的确很重要,它是我们编写业务逻辑的地方。但它就像电影中的主角,是整部戏的核心,却不是整个电影制作的起点。

main函数有两种标准形式

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

或者带命令行参数的版本:

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

这些参数是谁准备的?没错,是运行时库!它将操作系统传来的命令行参数整理成C++程序易于使用的格式,然后再传给main函数。

第四幕:main函数结束后的故事

很多人以为main函数结束,程序就立刻退出了。但实际上,这只是电影的高潮过去了,还有结尾要拍。

当 main 函数返回后

1、运行时库接收到main的返回值

2、调用全局对象的析构函数(按照创建的相反顺序)

3、释放程序资源

4、将main的返回值传递给操作系统

5、最后结束进程

这就解释了为什么全局对象的析构函数在main函数结束后才被调用。

实战例子:我们来抓个现行!

光说不练假把式。我们来做个实验,亲眼看看main函数前后都发生了什么。

#include <iostream>// 定义一个计数器
int initCounter = 0;// 全局变量初始化
int globalA = ++initCounter;  // 应该是1
int globalB = ++initCounter;  // 应该是2// 使用__attribute__((constructor))在main之前执行函数(GCC编译器特性)
__attribute__((constructor))
void beforeMain() {std::cout << "【main之前】beforeMain函数执行,计数器值:" << initCounter << std::endl;std::cout << "【main之前】全局变量globalA = " << globalA << ", globalB = " << globalB << std::endl;
}// 使用__attribute__((destructor))在main之后执行函数
__attribute__((destructor))
void afterMain() {std::cout << "【main之后】afterMain函数执行,计数器值:" << initCounter << std::endl;
}// 全局类
class GlobalTracer {
public:GlobalTracer(const char* name) : name_(name) {std::cout << "【main之前】全局对象 " << name_ << " 构造,计数器值:" << ++initCounter << std::endl;}~GlobalTracer() {std::cout << "【main之后】全局对象 " << name_ << " 析构,计数器值:" << ++initCounter << std::endl;}
private:const char* name_;
};// 创建全局对象
GlobalTracer tracerA("A");  // 计数器应该是3
GlobalTracer tracerB("B");  // 计数器应该是4// main函数
int main(int argc, char* argv[]) {std::cout << "\n【main开始】main函数开始执行,计数器值:" << ++initCounter << std::endl;std::cout << "【main中】命令行参数数量: " << argc << std::endl;// 创建局部对象GlobalTracer localObj("Local");  // 计数器应该是6std::cout << "【main结束】main函数即将结束,计数器值:" << ++initCounter << std::endl;return 0;
}

在 Linux 下用g++编译运行这段代码,你会得到类似这样的输出:

【main之前】beforeMain函数执行,计数器值:0
【main之前】全局变量globalA = 0, globalB = 0
【main之前】全局对象 A 构造,计数器值:3
【main之前】全局对象 B 构造,计数器值:4【main开始】main函数开始执行,计数器值:5
【main中】命令行参数数量: 1
【main之前】全局对象 Local 构造,计数器值:6
【main结束】main函数即将结束,计数器值:7
【main之后】全局对象 Local 析构,计数器值:8
【main之后】全局对象 B 析构,计数器值:9
【main之后】全局对象 A 析构,计数器值:10
【main之后】afterMain函数执行,计数器值:10

从输出中,我们可以清晰地看到整个流程:

1、首先初始化全局变量globalA和globalB

2、然后执行标记为constructor的beforeMain函数

3、接着构造全局对象A和B

4、之后才开始执行main函数

5、main函数返回后,首先析构局部对象Local

6、然后按照与构造相反的顺序析构全局对象B和A

7、最后执行标记为destructor的afterMain函数

三、初始化顺序:魔鬼藏在细节里

现在我们知道C++程序不是从main开始的了,接下来要面对的是另一个容易让人头疼的问题:全局变量和对象的初始化顺序。这个问题就像是魔鬼一样,藏在细节里,稍不注意就会导致奇怪的bug。

同一个.cpp文件中的初始化是有序的

好消息是,如果所有全局变量和对象都在同一个.cpp文件中,那么它们的初始化顺序是完全可预测的:

  • 全局变量按照你写代码的顺序初始化(从上到下)
  • 全局对象也按照你写代码的顺序构造(从上到下)

举个简单的例子

#include <iostream>int apple = 5;
int banana = apple * 2;  // banana = 10,因为apple已经初始化为5了class Fruit {
public:Fruit(const char* name) {std::cout << name << "被构造了,此时banana = " << banana << std::endl;}
};Fruit orange("橙子");  // 输出"橙子被构造了,此时banana = 10"
Fruit grape("葡萄");   // 输出"葡萄被构造了,此时banana = 10"

在这个例子中,一切都按照我们的预期进行:apple先初始化,然后banana初始化,接着orange构造,最后grape构造。这很简单,对吧?

但是...不同.cpp文件中的初始化顺序是个迷

现在问题来了!当你的程序有多个.cpp文件,每个文件都有自己的全局变量和对象时,它们之间的初始化顺序就变得不确定了。

想象一下这种情况

// 文件1:breakfast.cpp
#include <iostream>// 声明一个在dinner.cpp中定义的变量
extern int dinnerTime;// 定义早餐时间
int breakfastTime = 7;// 计算从早餐到晚餐的时间
int hoursBetweenMeals = dinnerTime - breakfastTime;class Breakfast {
public:Breakfast() {std::cout << "早餐准备好了!距离晚餐还有" << hoursBetweenMeals << "小时" << std::endl;}
};// 创建早餐对象
Breakfast myBreakfast;
// 文件2:dinner.cpp
#include <iostream>// 声明一个在breakfast.cpp中定义的变量
extern int breakfastTime;// 定义晚餐时间
int dinnerTime = 18;// 计算从早餐到晚餐的时间(和breakfast.cpp中的计算相同)
int mealGap = dinnerTime - breakfastTime;class Dinner {
public:Dinner() {std::cout << "晚餐准备好了!距离早餐已经过了" << mealGap << "小时" << std::endl;}
};// 创建晚餐对象
Dinner myDinner;

问题来了

  • 谁会先被初始化?breakfastTime还是dinnerTime?
  • hoursBetweenMeals和mealGap的值会是多少?
  • myBreakfast和myDinner哪个会先构造?

答案是:完全不确定!这完全取决于编译器和链接器如何组合这些文件,而这些通常不在我们的控制范围内。

这就会导致非常诡异的问题。比如,如果dinner.cpp先初始化:

  • dinnerTime被设为18
  • 但breakfastTime还没初始化,它的值可能是任意垃圾值
  • mealGap = 18 - 垃圾值,得到一个无意义的结果
  • myDinner构造时打印这个无意义的值
  • 然后breakfast.cpp才开始初始化...

这种情况下,程序不会崩溃,但会输出错误的结果,这种bug特别难找!

拯救方案:用函数内的静态变量

幸好,C++新标准提供了一个简单而优雅的解决方案,叫做"函数内静态变量"。这种方式有个特点:它们只在第一次调用该函数时才会被初始化。

我们来看看如何利用这个特性解决问题:

// 使用函数包装我们的全局变量
int& getBreakfastTime() {static int breakfastTime = 7;  // 只在第一次调用时初始化return breakfastTime;
}int& getDinnerTime() {static int dinnerTime = 18;  // 只在第一次调用时初始化return dinnerTime;
}// 需要用到这些值时,调用函数获取
int getHoursBetweenMeals() {return getDinnerTime() - getBreakfastTime();  // 现在顺序没问题了!
}

这种方式,我们不再依赖全局变量的初始化顺序,而是在需要用到这些值的时候才去获取它们。由于函数内静态变量保证只初始化一次,所以无论你调用多少次,都只会有一份数据。

还可以把这种思路扩展为"单例模式",用于全局对象:

class Restaurant {
public:// 获取唯一的Restaurant实例static Restaurant& getInstance() {// 这个static对象只在第一次调用时创建static Restaurant instance;return instance;}void serveBreakfast() {std::cout << "早餐时间到!" << std::endl;}void serveDinner() {std::cout << "晚餐时间到!" << std::endl;}private:// 构造函数设为私有,防止外部创建对象Restaurant() {std::cout << "餐厅开业了!" << std::endl;}
};// 使用方式
void morningRoutine() {// 第一次调用会初始化RestaurantRestaurant::getInstance().serveBreakfast();
}void eveningRoutine() {// 再次调用会返回同一个Restaurant实例Restaurant::getInstance().serveDinner();
}

这样,无论morningRoutine()和eveningRoutine()哪个先被调用,Restaurant对象都只会在第一次调用时被创建,而且我们可以确保在使用它之前它已经被正确初始化了。

这就是为什么单例模式在C++中如此流行 - 它不仅能保证全局只有一个实例,还能解决初始化顺序的问题!厉害吧?

微信搜索 【跟着小康学编程】,关注我,定期分享计算机编程硬核技术文章。

四、深入理解:一个完整程序的启动过程

让我们把整个过程连起来,看看从你双击程序到main函数执行再到程序结束,完整的流程是怎样的:

1、操作系统加载阶段

  • 加载可执行文件到内存
  • 创建进程和线程
  • 分配内存空间(栈、堆等)
  • 加载所需的动态链接库
  • 跳转到程序入口点(通常是_start)

2、 C/C++运行时初始化阶段

  • 初始化C运行时库
  • 设置堆管理器的数据结构
  • 初始化I/O子系统
  • 设置环境变量
  • 准备命令行参数(argc, argv)
  • 初始化全局/静态变量和对象
  • 调用constructor属性的函数

3、 main函数执行阶段

  • 调用main(argc, argv)
  • 执行用户代码
  • 返回退出码

4、 程序终止阶段

  • 接收main函数的返回值
  • 调用全局/静态对象的析构函数
  • 调用destructor属性的函数
  • 释放程序资源
  • 将退出码返回给操作系统
  • 终止进程

五、实际应用:为什么这些知识很重要?

你可能会想:"知道这些有什么用?反正我的代码还是从main开始写起。"

实际上,理解这个过程对解决许多实际问题非常有帮助:

1. 全局对象的依赖问题

如果你的程序使用全局对象,并且这些对象之间有依赖关系,那么初始化顺序就至关重要。了解C++的初始化机制可以帮你避免因初始化顺序不确定导致的微妙bug。

2. 资源管理

理解main函数返回后的清理过程,有助于你正确管理资源,避免其他资源泄漏问题。

3. 构造函数中的陷阱

全局对象的构造函数中不应该依赖其他全局对象(除非你能确保初始化顺序),因为这可能导致"静态初始化顺序问题"。

4. 调试复杂问题

当你遇到一些奇怪的问题,比如程序启动崩溃但没有明显错误时,了解启动过程可以帮你定位问题。

5. 面试加分项

这绝对是面试中的一个亮点!当面试官问"C++程序从哪里开始执行"时,如果你能详细解释整个过程,一定会给面试官留下深刻印象。

六、高级技巧:控制main函数前后的执行

了解了C++程序的启动过程,我们还可以利用这些知识来做一些有趣的事情:

1. 在main之前执行代码

除了前面提到的__attribute__((constructor)),还有其他方法可以在main之前执行代码:

全局对象的构造函数

class StartupManager {
public:StartupManager() {// 这里的代码会在main之前执行std::cout << "程序启动中..." << std::endl;// 做一些初始化工作}
};// 创建全局对象
StartupManager g_startupManager;

编译器特定的扩展

在GCC中:

void beforeMain() __attribute__((constructor));
void beforeMain() {// 这里的代码会在main之前执行
}

2. 在main之后执行代码

使用atexit注册清理函数

#include <cstdlib>void cleanupFunction() {// 这里的代码会在main之后执行std::cout << "程序清理中..." << std::endl;
}int main() {// 注册清理函数atexit(cleanupFunction);std::cout << "main已结束..." << std::endl;// 正常的main函数代码return 0;
}

全局对象的析构函数

class ShutdownManager {
public:~ShutdownManager() {// 这里的代码会在main之后执行std::cout << "程序关闭中..." << std::endl;}
};// 创建全局对象
ShutdownManager g_shutdownManager;

编译器特定的扩展

在GCC中:

void afterMain() __attribute__((destructor));
void afterMain() {// 这里的代码会在main之后执行
}

总结:揭开C++启动的神秘面纱

通过这篇文章,我们已经揭开了C++程序启动过程的神秘面纱:

1、C++程序根本不是从main函数开始的!在main执行前,系统和运行时库已经偷偷做了大量工作

2、全局变量和对象在main函数执行前就已经初始化完毕,这就是为什么main函数一开始就能使用它们

3、main函数结束不等于程序结束,之后还有全局对象析构、资源释放等一系列"收尾工作"

4、跨文件的全局对象初始化顺序是个"定时炸弹",搞不好就会引发难以察觉的bug

5、掌握了这些知识,你可以利用constructor/destructor属性、全局对象构造/析构函数、atexit函数等工具在main函数前后插入自己的代码,实现自动初始化和清理功能

下次有人告诉你"C++程序从main开始执行",你可以自豪地纠正他们了!

是不是觉得C++比想象的要复杂得多?别担心,这正是C++的魅力所在 — 它让你能掌控程序的每一个细节,从出生到死亡的全过程。真正的C++高手,就是了解这些不为人知的秘密!

如果这篇文章让你有所收获,别忘了点赞、收藏、关注,让更多的C++爱好者看到!

有疑问?有经验想分享?欢迎在评论区留言交流~

想持续充电?「跟着小康学编程」公众号等你关注,每周定期推送接地气的编程干货,让你的编程水平蹭蹭往上涨!

怎么关注我的公众号?

扫下方公众号二维码即可关注。

另外,小康还建了一个技术交流群,专门聊技术、答疑解惑。如果你在读文章时碰到不懂的地方,随时欢迎来群里提问!我会尽力帮大家解答,群里还有不少技术大佬在线支援,咱们一起学习进步,互相成长!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/896042.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

11套!量产15W~1000W开关电源电路全套方案资料合集!

本系列小编给大家带来了15W到1000W完整量产版开关电源全系列,全套资料分期给大家分享。每套资料都包含详细的原理图,PCB图,变压器图纸,共模电感图纸,磁环图纸!点击下方链接获取! 15W开关电源方案👆👆👆(点击下载) 25W开关电源方案👆👆👆(点击下载) 30W开…

【每日一题】20250309

我所渴望的,不过是过上一种发自本心的生活,为什么竟会如此困难?【每日一题】已知 \(\odot C\) 过点 \(P(1,2)\),与 \(y\) 轴相交于点 \(Q(0,6)\).若过点 \(Q\) 作 \(\odot C\) 的切线 \(l\),其切线 \(l\) 与 \(x\) 轴平行,则 \(l\) 的方程为_________,\(\odot C\) 的标…

FastAPI Cookie 和 Header 参数完全指南:从基础到高级实战

title: FastAPI Cookie 和 Header 参数完全指南:从基础到高级实战 🚀 date: 2025/3/9 updated: 2025/3/9 author: cmdragon excerpt: 本教程深入探讨 FastAPI 中 Cookie 和 Header 参数的读取与设置,涵盖从基础操作到高级用法。通过详细的代码示例、课后测验和常见错误解…

vim按f5运行代码配置

使vim能够像vscode一样按f5运行代码 效果图let g:last_terminal_buf = -1 " 用于存储上一个终端缓冲区编号 function! RunCurrentFile() " 如果存在上次的终端缓冲区,则删除它 if g:last_terminal_buf != -1 && bufexists(g:last_terminal_buf) silent exec…

DeepSeek + Xmind,1分钟自动把pdf/word文档转成思维导图

DeepSeek加Xmind,1分钟把PDF、Word文档转成思维导图!步骤超简单:第一步:打开DeepSeek,点击“深度思考”,上传你的文档。第二步:告诉DeepSeek“帮我转成思维导图,输出Markdown格式”。第三步:复制代码, 保存文件到桌面文本文件中,修改文件后缀为“.md”。第四步:打开…

Windows平台调试器原理与编写05.内存断点

https://www.bpsend.net/thread-274-1-3.html 内存断点访问断点 写入断点内存写入断点简介:当被调试进程访问,读或写指定内存的时候,程序能够断下来。 思考1:要想将一段内存设为内存断点,最终的目的是让其能够抛异常。调试器是基于异常的一个程序。应该如何实现呢?可以通…

Redis--Lesson01--NoSQL简史

单击MySQL的演进 单机MySQL 在早期互联网时代,也就是90年代以前,一个基本的互联网的访问量不会太大,可以说很多国家和地区都还没有配备互联网,所以在这种情况下的互联网格局使用的数据存储格式就是简单的单机模式,即使用一个数据库的如MySQL库就可以满足日常的数据读写 如…

Excel的快捷键

1、填充序号1~1000(删除后,序号会自动更新) (1)首先在左上角的位置框中输入A1:A1000,然后按Enter回车健,即可选中A1到A1000的单元格。(2)然后在函数框中输入=ROW(),按Ctrl + Enter即可,即可填充1-1000。 本文来自博客园,作者:业余砖家,转载请注明原文链接:http…

AutoGLM: Autonomous Foundation Agents for GUIs

AutoGLM: 针对Web和手机,基于ChatGLM,具体细节并不清楚。主要内容 提出AUTOGLM,集成了一套全面的技术和基础设施,以创建适合用户交付的可部署代理系统。首先,为GUI控制设计合适的"intermediate interface"是至关重要的,可以实现规划和定位的分离。其次,开发了…

Vulnhub-election靶机

总结:本靶机给了很多目录,对于信息收集考察的比较严格,给了一个数据库,很多时候容易陷进去,拿到用户权限登录后,也需要大量的信息收集,虽然可以在数据库里找到root和密码,但是不是靶机本身的,最终利用suid发现可疑目录,查找日志后利用脚本提权一、靶机搭建 选择扫描虚…

[HDCTF 2023]double_code _wp

其实这道题的加密函数我是手翻出来的,但是做完之后了解到这是一个sheelcode 实际上就是跑病毒的代码 WriteProcessMemory 用于向指定进程中写入数据,写入一个缓冲区中的数据到另一个进程指定的内存地址中。 函数接受的参数包括要写入的进程句柄,要写入的内存地址,要写入的…

VisionPro添加显示标签(二维码)简单版

!!!——!!! 咱们先展示效果,这个显示的是二维码的信息1.首先呢,你先添加工具 CogIDTool ,工具里我是这么设置的,如果你自己添加的码跟我的不一样,左边几个都运行看看2.现在可以添加脚本了,我接触的都是第二个C#高级脚本,下边是C#高级脚本演示 1)先创建 1个标签2)…