
当我们运行下面这段代码时,究竟发生了什么?
#include <stdio.h>
int main(void) {
printf("hello\n");
return 0;
}
一般觉得就是:代码 -> 运行。其实中间还有很多步骤
我们所写的:
a = b + c;
CPU 看到的(x86 机器码)可能更像这样:
8B 45 F8
03 45 F4
89 45 EC
这是十六进制表示出来的机器码。不同的 CPU(x86、ARM)有各自的机器码格式,所以一个平台上的机器码,不能直接拿到另一个平台上跑
那么 CPU 只认识机器码,那在"翻译"的时候,是每次运行时翻译,还是运行之前就翻译好? 各自的优势是什么?
在上一篇文章中有提到两者结合的方法:Java
现在知道了源代码最终要变成机器码,那机器码长啥样呢?
MZê ÿ ¸ @ º Í!¸LÍ!
This program cannot be run in DOS mode.
$û®Ç?ü¹?ü¹?ü¹...
差不多长这样。当然,这不是乱码,而是文本编辑器用"显示文字"的方式,强行把一堆原本是数字的内容展示出来
当我们用十六进制转储工具去看,会更清楚一点
偏移量:距离起点的距离
偏移量 实际内容(十六进制)
00000000(从头开始的第 0 个字节) 4D 5A 90 00 03 00 00 00
00000008(从头开始的第 8 个字节) 04 00 00 00 FF FF 00 00
00000010(从头开始的第 16 个字节) B8 00 00 00 00 00 00 00
这里先按 Windows 上常见的 来理解.exe
.exe 不只有机器码。它的文件结构大致分为:
.text.data那 CPU 怎么知道自己该去哪里取指令?
CPU 内部有一个专门的寄存器,叫程序计数器(PC)
程序计数器的作用:永远指向"下一条要执行的指令的地址"
它如何指使 CPU 工作?
但这里要注意一件事:CPU 不是自己去猜"这块是代码段,还是数据段"
真正先做这件事的是操作系统。它在装载 的时候,会根据文件头的信息,把 、 这些内容按不同规则映射进内存,再把 PC 对准程序入口。这样 CPU 后面才能顺着往下跑.exe.text.data
编译器并不是直接把源代码硬翻成机器码就完了,它得先确认这段代码在语法和语义上说得通
比如源代码:
int a = b + c;
分四步做

不同平台需要不同编译器
int a = b + c -> x86 编译器 -> x86 机器码
int a = b + c -> ARM 编译器 -> ARM 机器码
首先你如果是这样做的,那没有问题
int add(int a, int b) {
return a + b;
}
int main(void) {
add(3, 5);
return 0;
}
但是,如果分成两个文件,事情就不一样了
// main.c
int main(void) {
add(3, 5);
return 0;
}
// math.c
int add(int a, int b) {
return a + b;
}
生成的 会出现一个问题:main.obj
add.objadd你会发现,上面这两个 都只是半成品.obj
这时候,链接器就出现了,负责把这些半成品拼成一个完整程序
main.obj + math.obj -> [链接器] -> main.exe
它负责做的事大致有:
.obj.exe那么,如果调用一个函数,但忘记把那个函数所在的 交给链接器,会在编译阶段报错,还是链接阶段报错?.obj
答案是:链接阶段报错
编译阶段主要检查:
链接阶段主要检查:
那新的问题来了
int main(void) {
add(3, 5);
return 0;
}
这里明明没定义 ,为什么有时候以前还能编过?add
这是因为早期 C 语言里有一个隐式声明规则
它会自动假设:
"add 是一个返回 int、参数未知的函数"
先继续编译,把真正地址留给后面的链接器去补
但这个规则现在早就废除了。现代 C 里,更正常的做法是:
.h.c#include链接器把 合并成 ,光靠你自己写的 还不够,通常还需要两样东西.obj.exe.obj
第一个:启动代码

你写的 往往不是程序真正开始执行的第一行。真正最先跑的,通常是一段启动代码main()
它负责的事包括:
main()第二样:库文件(.lib)
这里要区分一下:
.lib.lib你写代码时用到的 、、,都不是你自己实现的。它们通常都在库里printfsqrtmalloc
.obj静态库有个问题:代码是直接复制进 的.exe
比如:
程序A.exe 内含某段公共代码
程序B.exe 内含某段公共代码
程序C.exe 内含某段公共代码
如果三份程序都带着几乎一样的代码,那磁盘和内存都会重复
DLL 的思路就是:代码只保存一份,多个程序共享

这时,链接时你拿到的 ,很多时候就不是函数本体了,而更像是一张"路标".lib
某个函数在某个 DLL 里
程序运行时去那里找
于是编译链接后的 ,不会直接把这段代码塞进去,而是把"以后去哪里找它"这件事记下来.exe
那好处和坏处分别是什么?
好处:
坏处:
它的意思就是:程序 A 依赖某个 DLL 的旧版本,程序 B 升级后把这个 DLL 换成了新版本,结果 A 下次运行就崩了

后来微软引入了并行程序集这类机制,允许不同版本的 DLL 同时存在,每个程序明确声明自己依赖哪个版本,互不干扰
.exe 文件里虽然已经有机器码了,但还不能直接执行,因为编译时并不知道自己将来会被放到内存的哪里
假设:
程序里有一个变量 a,地址是多少?
编译器:现在还不知道
因为运行时内存里可能已经有别的程序了,你的程序最终会被装到哪里,这是加载前无法完全确定的
所以解决办法是:
比如:
变量 a 在程序起点往后 100 字节处
那如果程序这次被加载到 0x00400000
a0x00400000 + 100下次如果被加载到 0x00600000
a0x00600000 + 100那问题又来了,如果程序 A 和程序 B 都觉得自己在 ,不就撞地址了吗?0x00400000
这就轮到虚拟内存出场了
操作系统会给每个进程各自准备一套独立的虚拟地址空间
程序A 以为自己在: 0x00400000 -> MMU -> 实际物理地址:0x10000000
程序B 以为自己在: 0x00400000 -> MMU -> 实际物理地址:0x20000000
所以表面上看,两个程序都"占着"同一个地址,其实它们看到的是各自的假地址,背后对应的是不同的物理内存
也就是说:
看到这里,题目里最关键的一层其实才刚出来
应用程序并不是直接去控制硬件的。大多数时候,它只是表达"我想做什么",真正负责分配资源、管理权限、和硬件打交道的,是操作系统
拿最开头那句 举例printf("hello\n")
它表面上只是打印一句话,但背后大致是这样的:
printfprintf 先在 C 运行库里把这段输出整理好hello文件读写、网络请求、创建进程、创建线程、申请更多内存,本质上也都差不多
所以应用程序和操作系统的关系,说白了就是:
这也是为什么,应用程序通常不能想干嘛就干嘛。它得按操作系统给的规则来
程序被加载进内存后,操作系统通常还会额外给它准备几块常用区域

栈是自动管理的临时区域
函数调用时,栈会自动扩张;函数返回时,栈会自动收缩
void c() { int z = 3; }
void b() { int y = 2; c(); }
void a() { int x = 1; b(); }
int main(void) { a(); }

你可以看到, 一层层调用进去时,新的局部变量会一层层压上去。等 执行完,最上面这一层就先退掉,这就是典型的后进先出main -> a -> b -> cc
堆是手动管理的动态区域
当你需要在运行时动态申请一块更灵活的内存时,就会用到堆

它们的区别可以先这样记:
malloc/newfree/delete所以可以看出:
从你点下"运行"开始,真正发生的事情大致是:
源代码 -> 编译 -> 链接 -> 生成可执行文件 -> 操作系统装载到内存 -> 分配地址空间和运行环境 -> CPU 开始执行机器码 -> 应用程序再通过操作系统去申请各种服务
所以题目里的关系,其实可以压成一句话:
应用程序不是脱离操作系统单独工作的,而是建立在操作系统提供的装载、隔离、调度和服务之上的。