C++之《程序员自我修养》读书总结(1)
《程序员自我修养》读书总结(一)深入探讨了程序从编译到运行的全过程。文章解析了编译器如何将高级语言转换为机器码,详细介绍了可执行文件的结构与组织方式,包括ELF格式的段布局和元数据。通过分析标准库的实现机制,解释了头文件包含与链接过程的区别。文章还对比了不同编译器、硬件平台和操作系统下的编译结果差异,并完整梳理了"Hello World"程序从装载到退出的执行流程,包括main
《程序员自我修养》读书总结(一)
Author: Once Day Date: 2026年2月3日
一位热衷于Linux学习和开发的菜鸟,试图谱写一场冒险之旅,也许终点只是一场白日梦…
漫漫长路,有人对你微笑过嘛…
全系列文章可参考专栏: C语言_Once-Day的博客-CSDN博客
参考文章:
1. 基础介绍
1.1 程序编译
程序为什么要被编译器编译了之后才可以运行?
编译器在把C语言程序转换成可以执行的机器码的过程中做了什么?
程序之所以需要先经过编译才能运行,本质原因在于计算机硬件只能直接理解并执行特定体系结构下的机器指令,而高级语言只是面向人类的抽象描述。C 语言提供了接近硬件的表达能力,但其语法、类型系统和控制结构并非 CPU 可直接识别。编译器的首要作用,是在不改变程序语义的前提下,将这些抽象结构映射为确定的指令序列、寄存器使用方式和内存访问形式,使程序能够在特定平台上被正确执行。
在编译的前端阶段,编译器会对源代码进行词法分析和语法分析,将字符流解析为语法树结构,并检查语言层面的正确性,例如类型匹配、作用域规则和声明一致性等。这一阶段并不关心最终生成什么指令,而是确保程序在语言语义上是自洽的。以 C 语言为例,诸如指针运算、结构体访问等复杂语义,都会被归约为一种中间表示,为后续处理提供精确的语义基础。
随后进入中端和后端,编译器逐步把与语言相关的中间表示转换为与目标平台相关的实现细节。在这一过程中,编译器会决定函数调用约定、栈帧布局、变量存储位置以及寄存器分配策略,并在保证语义等价的前提下进行优化。例如,常量传播、死代码消除和指令重排等优化,既不改变程序的可观察行为,又能显著提升运行效率。对于 C/C++ 这类强调性能的语言,这一步对最终程序质量尤为关键。
在生成机器码之前,编译器还需要处理目标文件层面的信息,包括符号表、重定位记录以及调试信息等。这些内容并不直接参与指令执行,但它们为链接器完成多文件组合、为调试器定位源码位置提供了必要条件。最终生成的可执行程序,不仅包含 CPU 可执行的指令序列,还携带了操作系统加载和运行程序所需的结构化元数据。
1.2 编译二进制文件
最后编译出来的可执行文件里面是什么?除了机器码还有什么?它们怎么存放的,怎么组织的?
编译生成的可执行文件并不仅仅是一段简单的机器指令序列,而是一个高度结构化的二进制容器,用来同时满足 CPU 执行、操作系统加载以及工具链协作等多方面需求。以常见的 ELF 格式为例,可执行文件最外层由文件头开始,它描述了目标体系结构、入口地址以及后续结构在文件中的布局方式。操作系统在装载程序时,正是依赖这些元信息,才能正确识别文件类型并决定如何映射到进程地址空间。
在文件主体中,最核心的部分是若干具有明确语义的段或节,用于承载不同性质的数据。代码通常存放在只读且可执行的代码段中,包含编译器和汇编器生成的机器指令;已初始化的全局变量和静态变量会被放入数据段,而未初始化的全局对象则以大小信息的形式记录在 BSS 区域,运行时再由操作系统统一清零。这种划分既符合 C/C++ 的语言语义,也有利于内存保护和共享机制的实现。
除了直接参与运行的内容,可执行文件还包含大量“非执行性”的辅助信息。符号表记录了函数和全局对象的名字与地址映射,既为链接阶段解决跨模块引用服务,也在调试版本中支持源级调试。重定位表描述了哪些位置在装载或链接时需要被修正,使得代码能够在不同的虚拟地址下正常运行。此外,还可能包含字符串常量区、异常处理表以及与 C++ 运行时相关的类型信息,这些内容在语言层面不可见,却对正确执行至关重要。
从组织方式上看,这些内容在文件中以偏移和大小的形式被精确排列,并通过表结构相互关联。操作系统加载程序时,并不会简单地把整个文件原样拷贝到内存,而是依据段描述信息按需映射、设置访问权限并建立虚拟地址映射关系。正是这种精细的组织,使得一个可执行文件既能高效运行,又能兼顾可移植性、可调试性和安全性。
1.3 标准库
#include <stdio.h> 是什么意思?把stdio.h 包含进来意味着什么?C语言库又是什么?它怎么实现的?
在 C 语言中,#include <stdio.h> 并不意味着把某个“现成的功能模块”直接拷贝进程序,而是发生在编译早期的预处理阶段。预处理器会将 stdio.h 头文件的内容原样展开到当前源文件中,其核心作用是引入一组函数声明、宏定义以及相关类型说明,例如 printf、FILE 和 EOF 等。这些声明让编译器在语义分析阶段能够正确理解函数的参数、返回值和使用方式,但并不会生成任何实际的代码。
真正的代码来自 C 语言标准库本身。所谓 C 语言库,是对一组通用功能的抽象封装,包括输入输出、字符串处理、内存管理和数学运算等,其接口由 C 标准严格规定,而实现则由具体平台和工具链提供。以 stdio 为例,库的实现通常位于独立编译好的目标文件或静态/动态库中,内部通过系统调用与操作系统交互,实现文件读写、缓冲管理等功能。程序在调用 printf 时,实际执行的是这些库中早已编译好的机器码。
从编译和链接的角度看,包含头文件只是解决“如何调用”的问题,而链接阶段才负责解决“调用谁”的问题。编译器在看到 printf 的声明后,会在生成的目标文件中留下一个未解析的符号引用;链接器随后在标准库中查找对应的实现,并把两者绑定在一起。若使用动态链接,最终可执行文件中甚至不包含 printf 的具体实现,只保留必要的符号信息,程序在运行时再由动态链接器加载对应的库。
因此,#include <stdio.h> 的意义在于建立源代码与标准库之间的契约关系:头文件描述接口和语义,库文件提供具体实现,而编译器、链接器和运行时环境共同保证二者能够正确衔接。这种接口与实现分离的设计,使得 C 语言程序既具备良好的可移植性,又能充分利用底层系统的能力。
1.4 编译器差异
不同的编译器(Microsoft VC、GCC)和不同的硬件平台(x86、SPARC、MIPS、ARM)以及不同的操作系统(Windows/linux/unix/solaris),最终编译出来的结果一样吗?为什么?
从表面看,同一份 C/C++ 源代码在不同编译器、不同硬件平台和不同操作系统下“都能通过编译并运行”,但最终产物在二进制层面几乎不可能完全一致。原因在于,编译器的职责并不仅是忠实翻译语言语义,还要将程序映射到具体的平台约束之中,而这些约束在指令集、ABI 以及操作系统接口上都存在本质差异。
首先,不同硬件平台决定了机器码层面的根本不同。x86、ARM、MIPS、SPARC 等体系结构在指令格式、寄存器数量、内存寻址方式以及调用约定上差异巨大。即便是同一个简单函数,在 x86-64 上可能大量使用寄存器传参,而在某些 RISC 架构上则依赖固定的寄存器窗口或栈布局。编译器后端必须针对目标架构生成完全不同的指令序列,因此二进制指令不具备可比性。
其次,编译器本身也会显著影响结果。GCC、Clang 和 Microsoft VC 在中间表示、优化策略以及对 C/C++ 标准的细节处理上并不相同。即使目标平台一致,不同编译器在寄存器分配、指令调度和内联决策上的差异,也会导致生成的机器码结构不同。更进一步,编译选项如优化级别、调试信息和运行时库选择,都会改变最终可执行文件的布局和行为特征。
操作系统则通过 ABI 和系统调用接口对可执行文件施加约束。Windows 使用 PE 格式,Linux 和多数 Unix 系统使用 ELF,不同格式在文件头、段组织、动态链接机制上各不相同。标准库的实现也与操作系统紧密耦合,例如同样是 printf,其底层最终调用的系统接口在 Windows 与 Linux 上完全不同。这使得即便在相同硬件上,不同操作系统生成的可执行文件也无法直接互换。
因此,“结果是否一样”只能在语言语义层面成立:在遵守标准、避免未定义行为的前提下,程序的可观察行为应当一致;而在二进制表示、性能特征乃至内存布局上,差异不仅存在,而且正是编译器和平台适配能力的体现。
1.5 程序运行
hello World 程序如何运行起来,操作系统如何装载它们?从哪里开始执行,到哪里结束?main 函数之前发生了什么?main函数结束以后又发生了什么?
一个最简单的 Hello World 程序,从源码到真正“跑起来”,经历的是编译器、链接器和操作系统协同完成的一条完整执行链。可执行文件生成后,并不是由 main 函数直接被操作系统调用,操作系统只关心文件格式、入口地址以及进程所需的运行环境,而语言层面的 main 只是这一流程中的一个约定性节点。
当用户在命令行或图形界面启动程序时,操作系统首先解析可执行文件格式(如 ELF 或 PE),为其创建一个新的进程,并建立独立的虚拟地址空间。随后,内核按照文件中描述的段信息,将代码段、数据段映射到内存中,初始化 BSS 区域,并为进程准备好栈和堆。在支持动态链接的系统中,动态链接器也会在这一阶段被加载,用于解析和装载所依赖的共享库。
真正的执行并不是从 main 开始,而是从可执行文件头部指定的入口点进入。这个入口点通常指向一段由编译器或运行时库提供的启动代码,如 _start。它负责从操作系统获取命令行参数和环境变量,建立符合 ABI 约定的栈布局,并完成 C 运行时的初始化工作,包括全局变量构造、静态对象初始化以及必要的运行时检查,最终才以规范的方式调用用户定义的 main 函数。
当 main 函数返回或调用 exit 时,程序并不会立刻终止。控制权会回到运行时库,由其负责执行逆向的清理流程,例如调用已注册的析构函数、刷新并关闭标准 I/O 缓冲区、释放运行时资源等。完成这些工作后,运行时库通过系统调用将退出状态返回给操作系统,内核再回收进程所占用的资源,整个程序的生命周期至此结束。
1.6 操作系统
如果没有操作系统,Hello World 可以运行吗?如果要在一台没有操作系统的机器上运行 Hello World 需要什么?应该怎么实现?
从理论上讲,Hello World 并不依赖操作系统这一抽象本身,但在现代计算环境中,我们习惯的“能运行”往往隐含了大量由操作系统提供的前提条件。如果完全没有操作系统,程序当然仍然可以执行,只是它必须直接面对裸硬件,承担原本由操作系统和运行时环境完成的所有初始化与资源管理工作,这时的程序形态更接近于固件或裸机程序,而非通常意义上的用户态应用。
在没有操作系统的机器上运行 Hello World,首先需要解决“如何开始执行”的问题。CPU 上电后通常从一个固定地址取指,这段代码必须由开发者提供,负责完成最基本的硬件初始化,例如设置栈指针、初始化内存控制器以及关闭或配置中断。只有在这些条件满足后,程序才具备执行 C 代码的最低运行环境,此时的入口点不再是 _start,而是由硬件启动流程直接决定的地址。
其次,C 语言程序依赖的标准库在裸机环境中并不存在。诸如 printf 这样的函数,必须由开发者自行实现或提供精简替代版本。所谓“输出”也不再是写入标准输出,而是直接操作硬件设备,例如通过串口寄存器发送字符,或向显存写入像素数据。一个最小的 Hello World,往往只需要实现字符输出和一个无限循环来防止程序跑飞,其实现方式完全取决于具体硬件平台。
因此,在无操作系统环境下运行 Hello World,本质上需要三部分:启动代码、最小 C 运行时支持以及针对硬件的 I/O 实现。这种程序通常以交叉编译的方式生成,并通过烧录、仿真或引导加载的形式放入目标机器。虽然功能极其简单,但它揭示了操作系统存在的意义:将繁琐而重复的底层工作统一封装,使应用程序能够专注于自身逻辑,而无需直接面对硬件细节。
1.7 运行时
printf 是怎么实现的?它为什么可以有不定数量的参数?为什么它能够在终端上输出字符串?
hello World 程序在运行时,它在内存中是什么样子的?
从运行时视角看,printf 并不是一个简单的“打印函数”,而是标准库中层次较深的一套格式化输出机制。它的核心任务是解析格式字符串,根据其中的占位符逐一取出参数,并将结果转换为字符序列。真正对外设产生影响的并不是 printf 本身,而是其内部最终调用的低层输出接口,这些接口再通过系统调用或设备驱动,将数据交给操作系统处理。
printf 之所以能够接受不定数量的参数,依赖的是 C 语言对可变参数函数的语言级支持。在调用约定层面,编译器会按照 ABI 规则将所有实参依次放入寄存器或栈中,而被调用函数并不知道参数的个数和类型。格式字符串在这里充当了“运行时协议”,printf 通过逐字符解析其中的格式说明符,借助 stdarg.h 中的宏(如 va_start、va_arg)按顺序取出参数并解释其类型,这种设计将灵活性建立在调用者与被调用者约定一致的前提之上。
在终端上输出字符串,并不是 printf 直接操作屏幕或键盘。标准库通常将输出先写入一个用户态缓冲区,当缓冲区满足条件时,再调用 write 等系统调用,将数据交给内核。内核根据文件描述符将这些字节路由到具体设备:可能是终端驱动、伪终端,或者被重定向到文件。正因为这种“文件即接口”的抽象,printf 的输出才能在不同环境下保持一致的使用方式。
就 Hello World 程序本身而言,运行时它在内存中呈现为多个逻辑区域:代码段中存放编译生成的指令,字符串常量如 "Hello World\n" 位于只读数据区,全局和静态数据分别映射到已初始化数据段或 BSS 段,栈上保存着函数调用帧和局部变量,而堆则由运行时库按需管理。操作系统通过虚拟内存将这些区域隔离并保护起来,使一个看似简单的输出操作,得以在复杂而有序的运行时环境中完成。

Once Day
也信美人终作土,不堪幽梦太匆匆......
如果这篇文章为您带来了帮助或启发,不妨点个赞👍和关注!
(。◕‿◕。)感谢您的阅读与支持~~~
更多推荐


所有评论(0)