C++ 巩固基础之一
编译与链接原理
提示
强烈建议在阅读完本节的内容后,多花点时间深入读一遍《深入理解计算机系统 - 第三版》的第 7 章 "链接"。
编译命令
步骤 | 命令 |
---|---|
1. 预处理 | gcc -E hello.c -o hello.i |
2. 编译到汇编代码 | gcc -S hello.i -o hello.s |
3. 汇编到目标代码(机器语言) | gcc -c hello.s -o hello.o |
4. 链接,生成可执行文件 | gcc hello.o -o hello |
以上四个步骤,可以合成一个步骤,直接编译链接成可执行的目标文件 | gcc hello.c -o hello |
编译参数
参数 | 作用 | 编译示例 | 示例说明 |
---|---|---|---|
-o | 指定输出可执行程序的名称,默认文件名为”a.out” | gcc hello.c -o hello | 编译单个源文件 hello.c,指定输出可执行程序的名称为 hello,支持同时编译多个源文件 |
-E | 仅作预处理,不进行编译、汇编和链接 | gcc -E hello.c -o hello.i | 仅预处理源文件,指定生成中间文件 *.i ,此阶段主要处理源文件中的 #ifdef、#include、#define 等预处理命令 |
-S | 只编译到汇编语言,不进行汇编和链接,生成汇编代码 | gcc -S hello.c -o hello.s | 仅编译到汇编语言,指定生成汇编源文件 *.s |
-c | 只编译、汇编到目标代码,不进行链接,生成目标文件(机器语言) | gcc -c hello.s -o hello.o | 根据汇编源文件 *.s ,指定生成目标文件 *.o ,最后根据生成的目标文件,可执行 gcc hello.o -o hello 命令生成可执行程序 |
-l | 指定程序链接哪个静态库或者动态库 | ||
-m | 表示是数学库,也就是使用 math.h 头文件 | gcc hello.c -o hello -lm | 编译单个源文件 hello.c,指定输出可执行程序名称为 hello,并指定程序链接到数学库 |
-I dir | 在头文件的搜索路径列表中添加 dir 目录 | ||
-L dir | 在库文件的搜索路径列表中添加 dir 目录 | ||
-O、-O2、-O3 | 将优化状态打开,该选项不能与”-g” 选项联合使用 | ||
-g | 在生成的可执行程序中包含标准调试信息 | ||
-Wall | 在发生警告时取消编译操作,即将警告看作是错误 | ||
-pedantic | 严格要求代码符合 ANSI/ISO C 标准,若不符合则给出编译警告信息 | ||
-w | 禁止输出所有警告 | ||
-v | 打印编译器内部编译各过程的命令行信息和编译器的版本号 |
编译流程
C++ 的编译和链接过程通常分为四个主要阶段:预处理、编译、汇编和链接。每个阶段都有特定的任务,最终生成可执行文件。这些阶段的流程如下:
- 预处理:处理
#include
、#define
等指令,生成纯文本代码。 - 编译:将代码转换成汇编代码。
- 汇编:将汇编代码转换成机器代码,生成目标文件。
- 链接:将目标文件和库文件组合,生成可执行文件。
第一步:预处理
预处理阶段由预处理器处理,主要任务是处理源代码中的以 #
开头的指令,包括 #include
、#define
等。预处理的主要任务包括:
- 头文件包含:将
#include
引用的头文件内容插入代码中。 - 宏替换:将
#define
宏展开成具体内容。 - 条件编译:根据
#if
、#ifdef
等条件编译指令有选择地编译代码片段。
经过预处理后的代码形成一个纯文本文件,通常称为 “预处理文件”。
第二步:编译
在编译阶段,编译器将预处理后的代码转换成汇编代码。编译的主要任务包括:
- 语法分析和语义分析:检查代码的语法和语义是否正确,如变量是否定义、数据类型是否匹配等。
- 生成中间代码:将源码转换成一种与机器无关的中间表示,以便后续优化和生成目标代码。
- 优化:编译器可能会优化代码以提高执行效率,比如消除冗余代码、优化循环等。
编译阶段输出的通常是一个 .s
文件,其中包含汇编代码。
第三步:汇编
汇编阶段由汇编器将汇编代码转换为机器代码。机器代码是与目标 CPU 架构相关的低级二进制指令。汇编器会生成一个目标文件,通常带有 .o
或 .obj
后缀。值得一提的是,每个源文件都会经过汇编阶段,生成一个独立的目标文件。
第四步:链接
链接阶段由链接器负责,主要任务是将多个目标文件和库文件组合成一个可执行文件。链接的主要任务包括:
- 符号解析:将所有函数和变量的引用与其定义关联起来。例如,如果一个文件引用了另一个文件的函数,链接器会将引用解析为实际地址。
- 地址分配:链接器分配每个符号(函数、变量)在最终可执行文件中的内存地址。
- 合并代码段和数据段:链接器会将所有目标文件的代码段、数据段等部分合并,形成最终的可执行文件。
链接完成后,生成最终的可执行文件,程序可以在操作系统上直接执行。
编译分析
0
C++ 编译示例代码
sum.cpp
的源码
1 | int gdata = 10; |
main.cpp
的源码
1 |
|
- 编译代码生成可执行文件(默认是
a.out
)
1 | g++ main.cpp sum.cpp |
C++ 编译分析命令
- 只编译、汇编到目标代码,不进行链接
1 | g++ -c sum.cpp |
- 查看目标文件的符号表,包括函数和全局变量的地址、大小等信息
1 | objdump -t sum.o |
- 显示目标文件中各个段的内容(即十六进制和 ASCII 表示的原始数据)
1 | objdump -s sum.o |
- 查看目标文件的文件头信息
1 | readelf -h sum.o |
- 查看目标文件中各段的详细信息,包括段的名称、大小、地址、类型和属性等
1 | readelf -S sum.o |
- 将目标文件中的汇编代码(通过反汇编得到)与源代码进行对比显示,以便进行调试和分析
1 | g++ -c main.cpp -g // 生成目标文件,-g 参数表示带上调试信息 |
- 链接所有目标文件,并查看指定文件的符号表信息
1 | g++ -e main *.o // 将所有的目标文件链接成一个完整的程序,默认会生成一个名为 a.out 的可执行文件 |
- 查看可执行文件(通常是 ELF 格式)的程序头信息
1 | readelf -l a.out |
C++ 的内存分配与释放
C++ 的内存分配
new 和 malloc 的概述
new
的概述new
是 C++ 的关键字,专门用于动态内存分配。- 在分配内存的同时,
new
会调用类对象的构造函数(如果是类对象)。 - 如果分配失败,
new
会抛出异常std::bad_alloc
。
malloc
的概述malloc
是 C 语言中的函数,用于分配一块连续的内存。- 它不会初始化分配的内存,也不会调用类对象的构造函数(如果分配的内存是用于类对象)。
- 如果分配失败,
malloc
返回NULL
,需要手动检查。
new 和 malloc 的区别
特性 | new | malloc |
---|---|---|
适用语言 | C++ | C 和 C++ |
是否调用构造函数 | 分配内存,会调用构造函数 | 仅分配内存,不会调用构造函数 |
初始化 | 自动初始化(如 new int[5]() -> 0) | 不初始化,分配的内存为未定义的垃圾值 |
返回值 | 返回具体类型的指针,无需类型转换 | 返回 void * ,需要显式类型转换 |
释放方式 | 使用 delete 或 delete[] 释放内存 | 使用 free 释放内存 |
错误处理 | 内存分配失败时抛出 std::bad_alloc 异常 | 内存分配失败时返回 NULL ,需手动检查 |
内存分配用途 | 高级面向对象编程,推荐用于 C++ 动态内存分配 | 兼容 C 语言,适用于简单的内存分配需求 |
C++ 的内存释放
delete 和 free 的概述
delete
的概述delete
关键字用于释放通过new
分配的内存。- 在释放内存之前,
delete
会先调用类对象的析构函数,以确保资源(如文件句柄、网络连接等)正确释放。 - 如果分配的是数组,必须使用
delete[]
,否则行为未定义。
free
的概述free
是 C 标准库函数,用于释放通过malloc/calloc/realloc
分配的内存。- 它只会释放内存,不会执行任何其他操作,例如对象的析构。
delete
与free
不能混用- 通过
new
分配的内存必须用delete
释放,不能用free
,否则会导致未定义行为。 - 通过
malloc
分配的内存必须用free
释放,不能用delete
,否则会导致未定义行为。
- 通过
delete
与free
的内存布局和管理差异new
和delete
是 C++ 的关键字,它们了解对象的类型,并能为复杂类型的构造和析构做出正确的处理。malloc
和free
是 C 的函数,它们只分配和释放内存,不了解对象的类型。
delete 和 free 的区别
特性 | delete | free |
---|---|---|
适用语言 | C++ | C 和 C++ |
适用对象 | 动态分配的对象(通过 new 分配) | 动态分配的内存块(通过 malloc/calloc 分配) |
是否调用析构函数 | 是,会调用类对象的析构函数 | 否,只释放内存,不调用析构函数 |
分配与释放的匹配要求 | 必须和 new 成对使用 | 必须和 malloc/calloc 成对使用 |
底层机制 | C++ 的运行时库负责,处理更高级的资源管理 | C 的运行时库负责,直接释放内存 |
数组释放 | 使用 delete[] 释放动态数组 | 没有专门的数组释放功能 |
内存分配与释放的使用
new 和 delete 的使用
1 |
|
程序执行后的输出结果:
1 | p = 20 |
malloc 和 free 的使用
1 |
|
程序执行后的输出结果:
1 | p = 20 |
new 的几种常见用法
1 |
|
程序执行后的输出结果:
1 | data = 50 |
C++ 的 const、指针、引用
指针与引用
- 引用的本质
- 引用本质上是一种更安全的指针。
- 指针与引用的区别
- 引用必须初始化,指针可以不初始化。
- 引用只有一级引用,没有多级引用;指针可以有一级指针,也可以有多级指针。
- 定义一个引用变量和定义一个指针变量,其底层的汇编指令是一模一样的。
C 案例代码一
- C 语言中常量的使用
1 |
|
程序执行后的输出结果:
1 | 30 30 30 |
C++ 案例代码一
- C++ 中常量的使用
1 |
|
程序执行后的输出结果:
1 | 3 20 3 |
C++ 案例代码二
- C++ 中指针与引用的使用
1 |
|
程序执行后的输出结果:
1 | 10 |
C++ 案例代码三
- C++ 中指针与引用、左值引用与右值引用的使用
1 |
|
程序执行后的输出结果:
1 | 20 20 20 |
C++ 案例代码四
- const 与一二级指针的使用
1 |
|
程序执行后的输出结果:
1 | p = 20 |
C++ 形参带默认值的函数
- 形参设置默认值的时候,必须从右向左设置。
- 形参是否设置默认值,对函数的调用效率会产生影响(底层生成的汇编指令会有差别)。
- 函数定义时可以给形参设置默认值,函数声明时也可以给形参设置默认值。
- 形参设置默认值的时候,不管是在函数定义处,还是在函数声明处,形参的默认值设置只能出现一次。
1 |
|
程序执行后的输出结果:
1 | result = 30 |
C++ 内联函数的使用
- 普通函数
- 普通函数的调用会有开销,比如:参数压栈、函数栈帧的开辟、回退等操作。
- 内联函数
- 内联函数在编译过程中,就没有函数的调用开销,而是在函数的调用点直接用函数的代码展开(替换)处理。
inline
关键字只是建议编译器将指定的函数处理成内联函数,但并不是所有的inline
关键字都会被编译器处理成内联函数,比如:递归函数。- 在 Debug 版本中,
inline
关键字是不起作用的,inline
关键字只有在 Release 版本中才起作用。 - 当编译器将
inline
关键字修饰的函数处理成内联函数后,就不会再生成该函数对应的符号表内容。
1 |
|
C++ 函数重载的使用
一组函数,其中函数名相同,参数列表的个数、类型、顺序不同,那么这一组函数就称为 - 函数重载。
- (1) 一组函数要称得上是函数重载,那么这组函数必须是处于同一个作用域当中的。
- (2) 一组函数,函数名称相同,参数列表相同,仅仅返回值不同,这不叫函数重载。
- (3) C++ 的多态有两种表现形式,包括静态多态(编译时期)和动态多态(运行时期),而函数重载属于静态多态的一种。
- (4)
const
关键字不能作为判断函数是否重载的条件,比如void sub(int a);
与void sub(const int a)
之间不是重载关系。
为什么 C++ 支持函数重载,而 C 语言不支持函数重载?
- C++ 代码产生函数符号的时候,由函数名 + 参数列表决定。
- C 语言代码产生函数符号的时候,仅由函数名决定。
函数重载案例代码一
1 |
|
函数代码重载案例二
C++ 调用在 C 语言中定义的函数时,默认会编译失败,这是因为 C++ 与 C 语言生成函数符号的规则是不同的,从而导致 C++ 在编译时找不到在 C 语言中定义的函数。解决方法是,在 C++ 代码中,使用 extern "C" { }
来包裹函数的声明。
- C 语言的源代码
1 |
|
- C++ 的源代码
1 | // 标记大括号里的函数符号是按照 C 语言的规则来生成 |
提示
如果是 C 语言需要调用在 C++ 中定义的函数,那么在 C++ 的代码中,也需要使用 extern "C" { }
包裹函数的定义。
值得一提的是,在多语言的项目开发中,往往会看到下面这样的 C++ 代码,其中的 __cplusplus
是 C++ 编译器内置的宏名。这样写的好处是,无论是在 C++ 还是 C 语言的代码中调用 sum()
函数,编译器都可以正常编译。
1 |
|
进程之间的通信方式
在 C++ 中,进程间通信(IPC)有多种方式可供选择。以下是一些常见的 IPC 方法:
管道 (Pipe)
- 匿名管道:只能在父子进程之间通信。它在 Unix 和 Windows 上都支持。
- 命名管道:允许不同的进程进行通信。它可以跨不同的无亲缘关系的进程进行通信。
信号 (Signal)
- 信号是一种异步通知机制,可以用于进程之间的简单通信。通常用于通知进程某个事件发生,例如终止、暂停等。信号适用于 Unix 系统。
消息队列 (Message Queue)
- 消息队列允许进程之间通过发送和接收消息进行通信。这种方式支持消息的优先级排序,能够在多种操作系统上实现。POSIX 和 System V 都提供了消息队列的实现。
共享内存 (Shared Memory)
- 共享内存是一种高效的 IPC 方式,允许多个进程共享同一块内存区域。由于数据直接存储在共享的内存空间中,读写速度较快,但需要使用同步机制来避免竞争条件。
信号量 (Semaphore)
- 信号量是一种用于多进程间同步的机制,通常和共享内存配合使用,以保证进程对共享资源的有序访问。System V 和 POSIX 都提供了信号量的实现。
套接字 (Socket)
- 套接字是一种强大的通信方式,支持同一台机器上的进程通信(本地套接字)以及不同机器之间的网络通信(网络套接字)。它具有跨平台的特性,广泛用于分布式系统。
内存映射文件 (Memory-Mapped Files)
- 内存映射文件可以将文件内容映射到进程的地址空间中,多个进程可以通过映射相同的文件来实现数据共享。这种方式在 Unix 和 Windows 系统上都支持。
远程过程调用 (Remote Procedure Call)
- RPC 允许进程在远程主机上执行函数调用。虽然并非严格意义上的 IPC 机制,但它能够在分布式系统中用于进程通信。gRPC 是一种流行的 RPC 框架。
总结
在选择 IPC 方式时,可以根据应用的需求(如速度、复杂性、跨平台性)来选择合适的方式。例如,管道和消息队列适合简单的通信需求,而共享内存适合大数据量的高效通信。
进程虚拟地址空间的内存布局
在 X86 32 位的 Linux 系统上,Linux 系统会给进程分配 2^32 大小(4GB)的一块空间,其中用户空间(User Space)占 3GB,内核空间(Kernel Space)占 1GB。值得一提的是,每一个进程的用户空间是私有的,但是内核空间是共享的。
.text
:已编译程序的机器代码。.rodata
:只读数据,比如printf
语句中的格式串和开关语句的跳转表。.data
:已初始化的全局和静态 C 变量。局部 C 变量在运行时被保存在栈中,既不出现在.data
节中,也不出现在.bss
节中。.bss
:未初始化的全局和静态 C 变量,以及所有被初始化为0
的全局或静态变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件格式区分已初始化和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。程序运行时,在内存中分配这些变量,初始值为0
。