本笔记按照cherno的C++视频进行学习,可能会存在部分错误。

一、预处理

预处理阶段有下面的部分,看不懂没关系,选择性阅读理解,继续往下看即可

1. 处理宏展开:#define
2. 处理头文件包含:#include
3. 删除注释、处理#pragma等指令

预处理先将注释删除,再主要处理#include头文件处理、#define 宏处理、#ifndef等条件编译、#pragma指令等部分,之后将预处理后的文件插入到对应的.cpp文件,两者构成了翻译单元TranlationUnit。
#include头文件预处理本质上是寻找对应的文件然后复制粘贴到当前文件,比如#include <iostream> 本质是找到iostream这个文件,之后先对iostream这个文件本身先进行预处理,这样递归处理完之后将递归处理后的文件的内容复制粘贴到当前#include<iostream>的文件中。

比如#inlcude<iostream>内部有#include<bits/requires_hosted.h>,则会先将bits/requires_hosted.h的文件复制到iostream中,之后同理对bits/requires_hosted.h的头文件进行预处理,递归处理完成之后再将整个处理完成的头文件复制粘贴到有include<iostream>的.cpp文件中

宏定义预处理本质是对预处理 token 中匹配到的宏名进行替换,对象式宏会把宏名替换成替换列表;函数式宏还会先处理参数,再按规则展开。
比如#define MAX_SIZE 20 本质是在这个文件的token中找到MAX_SIZE并将所有MAX_SIZE直接替换成20。

#define MAX_SIZE 20
#include<iostream>
int main()
{
	std::cout<<MAX_SIZE<<std::endl;
	//MAX_SIZE
	std::cout<<"MAX_SIZE"<<std::endl;
} 
#include<iostream>
int main()
{
	std::cout<<20<<std::endl;
	std::cout<<"MAX_SIZE"<<std::endl;
}

后者是前者对宏定义进行预处理之后得到的结果,头文件处理不在这里展开。
相应的,他只是在Token层面进行展开,比如字符字面量和字符串字面量(例子中的"MAX_SIZE")这种就是不会展开的,注释会被删除,所以同样也不会展开。
当然,如果使用的头文件中有对应的宏定义的话,会出现难以找出的bug。

比如window.h中包含的minwindef.h中就有下面的宏定义
#ifndef max
#define max(a,b) ((a)>(b))?(a):(b)
#endif
此时如果你自己宏定义了max函数,那么如果你的max函数宏定义在他的后面,你的宏定义就会被他的宏定义替代从而出现报错。
也会出现使用std::max(a,b)和其他自定义的max(a,b)等被当作函数宏进行宏展开从而破坏对应的标识符模板等。

同时宏没有类型、没有作用域、没有调试友好性,所以最好优先考虑constexpr/const/enum class等,这些会在后续提到,宏定义更适合做条件编译、平台开关、编译配置。

为了防止多重定义会使用到的两个预处理#pragma once#ifndef这两种预处理方式各有优劣,#pragma通过物理标记(也就是该物理文件的 Inode)该文件是否被编译过来防止多次编译导致多重定义,#ifndef通过检查标识符宏定义是否被定义过来防止多重定义,但具体使用还是需要我们自己去思考。
还有其他的预处理类型,比如#pragram pack(1)这种强制按照一字节进行内存对齐的(不推荐使用)在这里就暂时忽略,在遇到的时候会再进行说明。
这里我们可以举一个很简单的例子,来自cherno大佬。
assistant.h

}

main.cpp

#include<iostream>
int main()
{
	std::cout<<"hellow word"<<std::endl;
	std ::cin.get();
	return 0;
#include"assistant.h"

这里我们可以看到编译器vscode甚至已经提前将对应的内容显示了

Pasted image 20260314222337.png

我们可以看到,它能够十分正常的进行运行

Pasted image 20260314222456.png

这是因为#include"assistant.h"预处理将assistant.h中的内容复制拷贝到了main.cpp ,也就是将’}‘拷贝到了main .cpp #include"assistant.h"这一行,补齐了原先缺少的右花括号,我们可以通过*.ii预处理文件直观看到预处理做了什么,在vscode中我们可以在tasks.json文件中进行配置,将obj这些临时文件进行保存,在"tasks""[{"args":[]}]中的输出文件设置"-o",path下方添加"-save-temps"保留临时文件

*.ii/*.i 预处理文件
*.s/*.asm 汇编代码文件
*.o/*.obj object机器码二进制文件
*.exe 二进制可执行程序文件
打开预处理文件可以看到其预处理结果

Pasted image 20260314223321.png

可以看到他将assistant.h中的'}'复制粘贴到了main.cpp中。
这是简单的头文件预处理,其他的预处理不再详细展示。
在预处理完毕之后生成的.ii或.i文件就是对应翻译单元Translation Unit,后续会送入编译器各自进行编译,得到二进制对象文件之后再进行链接。

二、编译

编译阶段大概有下面的内容,看不懂没关系,选择性阅读理解,继续往下看即可

1. 词法分析,将字符流拆分为Token
2. 语法分析,根据语法规则组装抽象语法树AST
3. 语义分析,基于AST进行类型检查、作用域与名字解析、重载决议、模板实例化等
4. 优化,AST降低为中间表达IR进行优化
5. 汇编,根据IR和目标架构进行汇编,进行指令选择、寄存器分配调度等生成汇编文件.s,再根据.s生成二进制机器码.o

编译器对源文件.cpp生成抽象语法树,之后经过一步步转化为机器码二进制文件.obj,后续工作交由Linker链接器将.obj链接为一个.exe,在这个阶段会进行优化,包括但不限于完成所有常数的计算,也就是说return 2* 5;实际上在obj中是return 10;
汇编指令存在两种派别,一种是AT&T,另一种是Intel。
两者存在一定差别,比如下面的汇编指令mov

mov target resource;Intel派别的mov指令,将resource的值赋值给target,右边为resource,左边为target
mov resource target;AT&T派别的mov指令,同理将resource赋给target,但是相反的,右边为target,左边才是resource

所以我们在看汇编指令的时候不需要疑惑为什么一会左值赋给右值,一会右值赋给左值,这只是因为使用的汇编派别不同,按照逻辑去进行正常合理的推理理解即可。
在vscode中可以固定使用Intel或AT&T的派别,在tasks.json中加入汇编指令派别参数即可。

"-g",
"-masm=intel", "${workspaceFolder}\\src\\*.cpp",

三、链接

链接阶段大概有下面的内容,看不懂没关系,选择性阅读理解,继续往下看即可

1. 合并多个.obj文件和库.lib
2. 符号解析,将函数和变量引用绑定到定义
3. 重定位,修正地址和跳转目标
4. 生成.exe或者.lib
Linker将各个.obj 链接到一起合成一个可执行文件.exe,其间链接器会对各个函数进行链接,所以只需要你本身在当前文件有进行函数声明,链接器会自动在所有obj文件中找到对应的函数定义。举个例子

main.cpp

#include<iostream>
void hellow();
int main()
{
	hellow();
}

hellow.cpp

#include<iostream>
void hellow()
{
	std::cout<<"hellow"<<std::endl;
}

hellow()在main.cpp只进行了声明,但是在编译器进行编译的时候会给声明的函数提供函数签名,只要对应的返回值、函数名、参数数量及类型相同,对应的函数签名就相同,两个cpp文件编译成obj文件之后linker会在这两个obj文件中找到对应的函数签名,将对应的函数内容链接合成到exe中,所以exe可以正常运行而不会出现因为hellow()没有定义而报错。
这就带来了预处理和链接的一些问题
由于预处理实际上只是复制粘贴到本文件,所以如果我们在hellow.h中对hellow()进行定义,然后在hellow().cpp中再对hellow()进行同样的定义,那么hellow.obj和main.obj中都有具有相同函数签名的hellow()函数,此时linker将不知道要链接向哪一次,便会出现LNK链接错误报错。
解决方法有三种
第一种是限定作用域
使用static 将hellow()函数的作用域限定在对应的.obj中,这样链接器只会在对应的obj内部进行链接,此时main.obj有自己的hellow(),hellow.obj也有自己的hellow()。
第二种方法是使用内联inline

inline void hellow(const char* message)
{
	std::cout<<message<<std::endl;
}
void OutHellow()
{
	hellow("hellow");
}

此时程序相当于

void OutHellow()
{
	std::cout<<"hellow"<<std::endl;
}

内联直接将函数进行展开,那么预处理的时候直接复制粘贴过来,在编译的时候变不会作为函数处理,提供对应的函数签名,那么链接的时候也就不会因为不同obj文件中有拥有着相同的函数签名的函数定义而出现LNK链接错误。
第三种就是最常见的,只在cpp中定义。
将.h中的函数定义移植到.cpp中,那么预处理的时候只会复制粘贴对应的声明语句,那么就不会出现有不同obj文件有拥有者相同的函数签名的函数定义而出现的LNK链接错误。

一个简单的预处理->编译->链接流程。

这一部分如果看不懂,不知道什么是变量什么是inline内联,什么是extern、static没关系,可以直接跳过继续往后看,然后再回头来看。

1. 示例源代码

1.1 math.h

#pragma once
#include <iostream>

// 1. 外部变量声明
extern int g_GlobalVar; 

// 2. 静态函数 
static int StaticCalc(int x) { return x * 2; }

// 3. 内联函数 
inline int InlineAdd(int a, int b) { return a + b; }

// 4. 模板函数 
template <typename T>
T TemplateMult(T a, T b) { return a * b; }

1.2 math.cpp

#include "math.h"

// 给出全局变量的真正物理定义
int g_GlobalVar = 42; 

int HeavyCompute(int x) {
    g_GlobalVar++;
    // 在本文件调用这三个函数
    return StaticCalc(x) + InlineAdd(x, x) + TemplateMult<int>(x, x);
}

1.3 main.cpp

#include "math.h"

int HeavyCompute(int); 

int main() {
    // 在另一个文件调用这三个函数
    int a = StaticCalc(10);
    int b = InlineAdd(10, 20);
    int c = TemplateMult<int>(10, 10);
    
    return HeavyCompute(a) + g_GlobalVar;
}

2. 预处理

编译器根据config脚本生成的makefile文件里记录的文件编译顺序进行编译,首先对math.h进行预处理。
读取到#pragma once的时候预处理器调用操作系统的stat()函数获取到math.h文件的Inode和Device信息,之后在预处理器记录的处理文件缓存哈希表((st_dev,st_ino)为键)里进行查找,未查找到对应Inode,表明未被预处理,则对math.h进行预处理,#include<iostream>将iostream复制粘贴过来,再删去math.h内部的注释,
之后对math.cpp进行预处理,将math.h复制粘贴过来,同理复制粘贴iostream,删除注释,得到math.ii,
之后对main.cpp进行预处理,复制粘贴math.h,同理复制粘贴iostream,删去内部注释得到mian.ii,
接下来进行编译。

3. 编译

编译的前端将进行抽象语法树和符号表的构建,首先读取预处理文件.ii,切分成token,按照规则构建抽象语法树AST,同时在编译器堆内存中简历逻辑符号表,比如对外部变量etern int HeavyCompute(int)会记录其名字_z12HeavyCompuptei和状态UND未定义,
对内联函数inline int InlineAdd(int a,int b){return a+b;}会记录这一函数的名字和状态以及对应的AST结构为中端内联优化做准备。
来到编译中端,编译器根据AST生成中间代码IR同时进行逻辑优化,比如main.ii中的内联int b = InlineAdd(10,20);由于代码小且没有出现强行取地址的现象会被编译器内联展开,变成int b = 10+20,同时由于10+20都是固定的常量,所以直接在这里完成计算,优化成int b = 30;,同时再发现b在后续并没有被使用到,于是直接优化把int b = 30;删除。
编译后端进行寄存器分配和指令选择,使用图着色算法进行寄存器分配,之后将IR翻译成对应架构的 汇编指令得到.s文件。
最后来到汇编阶段,汇编器进行两项操作,第一项是Opcode,将符号转化为二进制操作吗,比如mov eax,9999对应的二进制操作码为B8 0F 27 00 00;B8对应mov eax,0F 27是9999的小端序存储十六进制表示,也就是9999_10 = 270F_16=0x270F,
比如call _z7GetBuffv翻译为E8 00 00 00 00;E8对应call指令,未知地址直接填写00 00 00 00等待链接器链接填写
翻译完之后来到第二阶段,进行ELF目标文件(Relocatable object file,也就是*.o文件)构建,切分出下面四个物理段

1 .text段 代码段,存放翻译出来的机器码
2 .data/.bss段 数据段,存放全局变量
3 .symtab段 符号表,记录编译器记录的函数名字、状态、偏移
4 .rela.text段 重定位表,记录需要链接器将未知地址进行链接的地址的位置,如上面的E8 00 00 00 00的位置,让链接器知道在.text段偏移量为0x0F(笔者随意取的,实际不一定是这个)的位置调用的call指令缺少相对物理地址,方便链接器将_z7GerBuffv(只是以这个函数调用为例子)的相对物理地址进行填写

4. 链接

四、变量

变量是用存储数据的一种结构,不同类型的变量之间的本质差别只是其单个变量在内存(堆、栈)中占用的字节大小。
C++中有一些基础的原始数据类型,不同数据类型有各自不同的作用。

short 一般占用两个字节
int 一般占用四个字节(取决于系统或编译器)
long 一般占用四个字节(取决于编译器)
long long 一般占用八个字节
float 一般占用四个字节
double 一般占用八个字节
bool 一般占用一字节
取int为例子,在他占用四个字节的情况下,一个字节8个bit位,总共能表示$2^{32}$种情况,C++中的int默认是有符号int,意味着它的最开始的bit位被用来表示正负,则只剩下31个bit位用来表示大小,所以其存储范围为 [-$2{31},2-1$]

int a = 0;//-2^{31}<=a<=2^{31}-1

如果要将最开始的符号位去掉,只需要使用无符号int即可

unsigned int  a =  0;//0<=a<=2^{32}-1}

数据类型本身有自己的对应用途,但C++本身很灵活,在一些情况下不必局限于类型本身。

#include<iostream>
int main()
{
   char a = 65;
   int b = 65;
   std::cout<<a<<std::endl<<b<<std::endl;
   a = 'A';
   std::cout<<a<<std::endl<<(int) a<<std::endl;
}

在这里我们可以看到我们创建了两个变量,分别是char类型的a和int类型的b,但是在输出他们的时候同样都是 65的十进制数值为什么输出不同?
原因是对于char类型的变量我们去翻阅ascii表可以看到他对应的十进制65实际上是字符'A',那么在输出这个char类型的时候被作为字符处理,输出到终端自然就是字符'A',而int类型表示整数,b则自然被当作整数处理,输出其对应的十进制数值65,后续我们对a的再赋值(a = 'A'😉 将字符'A'赋值给他a,这个初始化时的赋值65没有任何差别,所以后续仍旧输出的'A'。
可以看到cout中还有一个(int)的类型转化,将char类型的a转化为了int类型,所以他按照int类型处理便和b一样输出了65。
这大概可以说明变量和数据类型的用处,便于对不同类型的数据进行管理 。
在这里存在类型转化的问题,以cherno大佬视频中的例子为例,我们定义一个浮点变量a的时候右值输入的小数系统默认是double,在将他赋值给a的时候会触发隐式转化,将double向下转化为float再赋予a,如下面

int main(){
	float a = 3.14;
}

鼠标放置到3.14上我们可以看到显示的是double而不是我们期望的float

Pasted image 20260314223822.png

这会导致精度丢失还有性能下降,我们可以在后面添加一个f或者F表示其为float。

int main(){
	float a = 3.14f;
	//或者
	//float a = 3.14F;
}

下面是不添加f可能会导致的四种问题

  1. 编译器由于double向下转化float触发精度丢失警告
  2. auto 关键字和template模板的自我推导会受到影响
auto v1 = 3.13;//推导为double
auto v2 = 3.13f;//推导为float
  1. 函数重载调用
#include<iostream>
void print(float a)
{
	std::cout<<"float";
}
void pirnt(double a)
{
	std::cout<<"double";
}
int main()
{
	print(3.13);//输出double
	print(3.13f);//输出float
}
  1. 运算时性能下降
float a = 3.3f;
float b = a*3.13;
/*
**在执行a*3.13的时候需要先将float a转化为double之后再和3.13进行相乘,之后再将double结果向下转化为float塞回到b中,部分编译器可能会将这个过程优化,但没有优化的话将会多出两步转化,在矩阵等场景会浪费很大的算力
*/

五、函数

函数是代码块,用于反复调用,主要是减少重复代码的编写,类似模板。
主要格式是
返回类型 函数名(参数类型)
{
//代码块
return 返回类型对应对象;
}
如下面

#include<iostream>
void print(const char* string)
{
	std::cout<<string<<std::endl;
	return;//void 类型没有返回对象,所以return后面不跟东西,也可以直接省略return。
}
int add(const int& a, const int& b)//const 只读,表示不会修改a和b,int& a,引用a,引用传递,在这里使用const常量和&引用目的是避免值传递导致的性能开销和避免引用传递改变不应该改变的变量值。
{
	return a+b;//返回int类型
}
int main()
{
	print("TEST");
	int a = 1,b =2;
	std::cout<<add(a,b);
	std::cout<<add(b,3);
	//main()主函数是程序的主入口,是个唯一的特殊例子,主入口不需要写return 0;可写可不写
}

函数的参数传递有三种,值传递、指针传递、引用传递,但本质上全都是值传递。

六、头文件

头文件*.h,用于存放声明,方便预处理、编译和链接
在预处理处我们知道#inclue"file"会将file文件里的内容进行复制拷贝到本文件,为了减少反复编写声明代码,我们将声明写到头文件中,这样只需要#include"file"一行就能直接将file里的声明全部预处理到本文件中,之后编译器便会编译然后根据声明去其他obj文件中寻找并链接,很省时间,比如下面(下面的代码会很糟糕,但只是暂时作为一个例子进行学习)。
assistant.h

void log(const char* message)
{
	std::cout<<message<<std::endl;
}
int add(const int& a,const int& b)
{
	return a+b;
}

math.cpp

#include"assistant.h"

void logMath()
{
	log("logMath used.");
}

main.cpp

#include"assistant.h"
int main()
{
	log("logMain here.");
	std::cout<<add(1,2)<<std::endl;
}

在这里我们只需要#include"assistant"就能将assistant.h下的两个函数复制粘贴到main和math中,省去多余代码编写,但这就会带来 三、链接 那一部分提到的问题。
同时还有代码重复的问题,比如说更改main.cpp为下面

#include"assistant.h"
#include"math"
int main()
{
//todo
}

在预处理的时候main.cpp中的#include"assistant.h"会把assistant.h的内容复制粘贴到main.cpp中,而math.cpp中再次包含了一次#include"assistant.h",于是又会再粘贴一次,重复进行了声明和定义,编译器会发生编译错误,为了防止这种情况出现,我们有两种方法提供你使用,在大体上没太大的差别
第一种是
#pragram once
绝大多数编译器都支持,原理是标记物理文件是否已经被读取过,理论上性能稍微快点

#pragram once
void log()
{
//...
}

第二种是

ifndef _filename_

def _filename_

声明
#endif
官方标准,所有编译器都支持,通过宏定义检查,性能更低,同时由于宏定义检查,如果两个头文件的宏定义名相同,会导致靠后读取到的头文件失效,存在一定风险

#ifndef _ASSISTANT_H_
#def _ASSISTANT_H_
void log()
{
...
}
#endif

七、条件分支

条件分支语句

if(expr1)
{
//如果expr1为真,也就是非零就执行该语句块
}
else if (expr2) 
{
//如果expr1为假,且expr2为真则执行该语句块
}
else 
{
//expr1和expr2均假,执行该语句块
}

在判定expr真假的时候,如果是两个数值进行比对,做法是读取这两个数值在内存中各自对应位置的值进行一字节一字节的比对。
比如,int a和int b 进行比对,CPU会读取a和b在内存中对应的存储值进行一字节一字节的比对,四个字节每一位bit位都完全相同才会判断相等。
如果是double a和int b进行比对呢?double占用八字节,int占用四字节,而且他们在内存中的存储机制还完全不同,int是使用头位做符号,剩下31位做值, 而double对应的存储标准是IEEE 754,一个符号位十一个指数位五十二个尾数位。
假设这两者还是按照一字节一字节进行比对的话,结果可以预想得到的糟糕,光是总字节数就对不上了,更何况他们存储的机制也不同,
所以在这种情况下C++的机制是低级的向上转化,转化之后再进行比对。
下面是用汇编语言表示的a和b比对时CPU会执行的指令。

int b = 4;
double a = 4.0;
bool result = (a==b);
mov eax, 4 
; 将4存储到整数寄存器eax中,对应int b = 4
cvtsi2sd xmm(),eax
; 检测到a和b类型不同,将b用cvtsi2sd指令转化为double存储到xmm()中
ucomisd xmm(),xmm1
; 比较寄存器xmm()((double)b对应寄存器)和xmm1((double) a对应寄存器)
sete al
; 如果相同,结果设为1或true

可以看到不同类型数值之间的比较会多一步向上转化,导致多出部分CPU指令,降低系统性能,所以最好在进行比对的时候只进行同类型数据之间的比对。
在编写expr的时候,可以省略一些,看下面的例子

bool IsPassed = ture;
if(IsPassed)
{
//
}
if(IsPassed == true)
{
//
}

这两个expr的表达效果相同,if只看括号内的expr值是否非零,非零则为真,进行执行,为零则为假,跳到elseif或者else或者下个语句。
或许会有人在思考,正数为真很容易理解,那么负数呢?
在此再次说明,唯有为零才是假,任何其他的都是真。
接下来将补充一部分关于CPU底层原理,可以选择性阅读,从应用的角度来说,记住只有零为假,应用就没有太大问题。
状态寄存器中存在零标志位ZF(Zero Flag),CPU昨晚一次运算之后会检测结果并更新ZF,如果为0则ZF为1亮起,否则为0关闭,if(expr)的时候CPU看的就是ZF为1还是为0,ZF为1则为假,ZF为0则为真。
下面是一种编译器编译方案mov+test+jz,部分编译器会使用cmp+jz,编译器在遇到if(expr)的时候会生成两句汇编语言指令

int a = -10;
if(a)
{
//
}
move eax,[a] ;变量a读进寄存器eax
test eax,eax ;eax自己与自己按位与运算,如果是0则结果仍旧为0,如果非零则结果仍旧非零
jz skip_if_block ;如果ZF为1,跳转到skip_if_block语句块执行该语句块
;

在使用if(expr)的时候现代CPU会进行分支预测,先分出pipeline预测先执行skip_if_block语句块或者不执行或者先执行skip_elseif_block语句块等,同时进行if(expr)判断,假设预测执行了skip_if_block,而且if(expr)判断为真,那么皆大欢喜,CPU可以正常继续运行,如果if(expr)判断为假,那么很不幸,要浪费掉之前执行skip_if_block的生命周期,重新在这里CPU热启动,假设在处理一千万个例子更新的时候使用if(expr)进行判断会对性能影响很大。
我们可以尝试使用一些位运算来避免使用if分支,比如下面的问题。
将一个装了三百万个0~255随机整数的数组里面大于或等于128的数值找出并全部求和。
使用if分支

#include<iostream>
#include<vector>
#include<algorithm>
#include<chrono>

#define COUNT 3000000
int main()
{
	std::vector<int> data(COUNT);
	for (int i = 0;i<COUNT;i++)
	{
		data[i]=random()%256;
	}
	
	std::sort(data.begin(),data.end());
	
	long long sum = 0;
	auto ProgressStart = std::chrono::high_resolution_clock::now();
	for (int i = 0;i<COUNT;i++)
	{
		if(data[i]>=128)
		{
			sum+=data[i];
		}
	}
	auto ProgressEnd = std::chrono::high_resolution_clock::now();
	std::cout<<sum<<std::endl;
	auto time = std::chrono::duration_cast<std::chrono::microseconds>(ProgressEnd-ProgressStart);
	std::cout<<"用时"<<time.count()<<"微秒"<<std::endl;
}

使用位运算去除if分支

#include<iostream>
#include<algorithm>
#include<vector>
#include<chrono>

#define COUNT 3000000
int main()
{
	std::vector<int> data(COUNT);
	for (int i = 0;i<COUNT;i++)
	{
		data[i] = random()%256;
	}
	std::sort(data.begin(),data.end());
	long long sum = 0;
	int mask = 0;
	auto ProgressStart = std::chrono::high_resolution_clock::now();
	for(int i = 0;i<COUNT;i++)
	{
		mask = -(data[i]>=128);
		sum+=data[i]&mask;
	}
	auto ProgressEnd = std::chrono::high_resolution_clock::now();
	std::cout<<sum<<std::endl;
	auto time = std::chrono::duration_cast<std::chrono::microseconds>(ProgressEnd-ProgressStart);
	std::cout<<"耗时"<<time.count()<<"微秒"<<std::endl;
}

笔者进行了一次简单的测试,可以看到使用位运算去除if提高的性能很高,如下方两图,位运算用时是if的30%。
03a796225f360ea1145f23c8f79b7da9.png

a23853022826f4472c39653b62f993c7.png

八、循环

循环用于使循环体内的代码连续运行多次,运行次数总体由你决定(当然,出bug是屡见不鲜的)。
C++循环有三种写法,

for(states0;expr;states1){states2;};//先执行0一次,之后每次执行states2之前都会先判断一次expr,为真则执行states2,states2执行完执行states1,之后再判断expr,如此循环,直到expr为假则跳出for循环(此时states1不会再被执行)。
states1->expr->states2->states1->expr->states2->states1->expr->……

while(expr){states;};//先判断expr,为真则执行states,之后再判断expr,如此反复直到expr为假停止该循环
expr->states->expr->states->expr->……

do{states;}while(expr);//先执行states再进行expr判断,为真则再执行states然后再判断expr,如此反复直至expr为假
states->expr->states->expr->……

CPU本身是使用的do{}while()逻辑,如下面的汇编语言。

.LoopStart:
    ; ... 执行循环体代码states ...
    cmp eax, 10      ; 比较条件expr
    jl .LoopStart    ; 如果满足条件 (以Jump if Less为例子),直接跳回头部.LoopStart:进行循环

先执行states,然后判断expr,根据判断的结果确定要不要回到头部。
但是实际上三种语言在性能并没有太大差别,因为现代编译器都会将for(){}和while(){}优化成do()while{}的汇编指令,只不过在开头多了一步条件判断分支,如下面的代码(初始化这些省略)。

	while(n)
	{
		temp = num*temp;
		n--;
		if (n==1)
		{
			return temp;//返回int
		}
	}
        jmp     .L2;先跳到.L2判断expr
.L4:;states
        mov     rax, QWORD PTR [rbp-24]
        mov     eax, DWORD PTR [rax]
        mov     edx, DWORD PTR [rbp-4]
        imul    eax, edx
        mov     DWORD PTR [rbp-4], eax
        sub     DWORD PTR [rbp-28], 1
        cmp     DWORD PTR [rbp-28], 1
        jne     .L2
        mov     eax, DWORD PTR [rbp-4]
        jmp     .L3
.L2:;判断expr
        cmp     DWORD PTR [rbp-28], 0;比较n和0
        jne     .L4;n不为0则跳到.L4 执行states

所以为了代码可读性,一般使用for(){}和while(){},这两种循环体在什么场合进行使用看个人风格习惯。
个人习惯在大致明确循环次数的情况下使用for,比如进行数组排序之类的情况,而在循环停止情况不明朗不确定的情况下使用while,比如游戏的渲染线程中的渲染循环函数一般使用while反复渲染,直至确定退出游戏在循环体内部break或者设置while(expr)中的expr为假。

九 控制流语句

C++共三种控制流语句,都可在for、while、switch中使用

break;//直接停止,跳出当前循环体
continue;//跳过这一次循环,continue;后续的语句states2不执行,但states1仍旧会执行,之后直接进入到下一次循环
return [states]; //返回某个值,一般都是直接将值states返回给函数调用处,对应的函数直接结束(包括函数内包含return的循环这些),[states]表示states根据情况提供或不提供,在void function(){}中states不能提供,在类似int function(){}中除了int main(){}之外必须提供。

在下面的例子中把这些情况都囊括了。

#include<iostream>

int pow(const int &num, int n)
{
	int temp  = num;
	while(n)
	{
		temp = num*temp;
		n--;
		if (n==1)
		{
			return temp;//返回int
		}
	}
	std::cout<<"若成功输出此句,则return没有退出for 和 pow"<<std::endl;
	return 0;
}
void RefPow( int &num,int n)
{
	int temp = num;
	while(n)
	{
		num = num*temp;
		n--;
		if (n==1)
		{
			return ;//什么也不返回,函数直接结束
		}
	}
	std::cout<<"若成功输出此句,则return没有退出for 和 pow"<<std::endl;
	return ;
}
int main()
{
	int a = 0;
	for(;a<=10;a++)
	{
		if(a==5)
		{
			continue;
			//a到5的时候跳过std::cout<<a<<std::endl;
			//之后执行a++;
			//之后进入下一个循环
		}
		else if(a==8)
		{
			break;//a到8的时候break跳出for循环
		}
		std::cout<<a<<std::endl;
	}
	a=pow(a,2);
	std::cout<<a<<std::endl;//return直接结束函数,for循环也被直接结束
	RefPow(a,2);
	std::cout<<a<<std::endl;
}

十、指针

指针本身只是一个用于存储地址的八字节(64位操作系统对应八字节)整数。
指针类型只影响他向这个地址进行读取和写入操作时对应的字节数和写法以及进行指针运算时的步长。
对于读取和写入以及指针步长运算的影响如下程序演示

#include<iostream>
int main()
{
	int a = 65345;//二进制为1111 1111 0100 0001
	//后面的一个字节0100 0001对应65,也就是'A'
	void* ptr = &a;//初始化无类型指针ptr指向变量a(ptr存储a的存储地址),&a表示a变量的地址
	std::cout<<*(char*)ptr<<std::endl;//转化无类型指针ptr为char类型指针,对于&a这一个内存地址内存储的数值按照char类型只读取一字节,故读到'A'
	std::cout<<*(int*)ptr<<std::endl;//转化char类型指针ptr为int类型指针,按照int类型只读取四个字节,故读到65345
	double b = 114514.1919810;//按照double类型进行写入
	ptr = &b;//由于int a在内存中只占4个字节,继续用a会出现内存越界,segmentation fault,指针切换到double变量b继续演示
	std::cout<<*(double*)ptr<<std::endl;
	std::cout<<*(int*)ptr<<std::endl;//按照int类型读取
	/**114514.1919810按照double使用IEEE754
	  *进行存储为0100 0000 1111 1011 1111 0101 0010 0011 0001 0010 0101 1010 1010 1011 0100 0111
	  *int读取四个字节,也就是0001 0010 0101 1010 1010 1011 0100 0111
	  *int读取得到的二进制按照int的读取方式得到307,931,975,而不是原本数值的整数部位114514
	**/
	
}

在这里各种数据类型的底层存储方式我不过多赘述,简单相提,里面对应的术语都比较常见且很容易查到,请自行细看。

short;int;long;long long;char;bool 补码
float; double IEEE754
unsigned int 纯二进制

使用原生指针容易出现内存泄漏和内存溢出的问题。
内存泄漏指的是某部分内存在其没有剩余后续意义的时候没有进行释放,一直占用着这部分内存,简单来说就是new分配了内存但是在不需要再用到它的时候没有及时使用delete释放这部分内存。
内存溢出指的是内存不足以支撑后续操作,包括但不限于栈溢出、堆溢出,一般是由于许多内存泄漏的累积效果导致,也有很直接的在1MB的栈里面new一个4MB大小的数组的导致的栈溢出的情况。
我们在使用new和delete进行指针操作的时候需要注意这两个问题,下面是这两个问题的三个衍生。

1. 内存泄漏

内存泄漏有人工问题,比如确实是没有写delete,另外一种就是except情况下导致delete未被执行,比如下面的情况。

void PlayGame()
{
	Monster* boos = new Monster();
	if(PlayerHP<=0)
	{
		throw std::runtime_error("玩家死亡");
	}
	
	delete boss;//因为except被抛出导致delete未被执行
}

解决这种问题的方法是try,或者使用智能指针,下面是try。

void PlayGame()
{
	Monster* boss = new Monster();
	try 
	{
		//
		if(PlayHP<=0)
		{
			throw std::runtime_error("玩家死亡");
		}
	}
	catch 
	{
		delete boss;
		boss = nullptr;
	}
	
	delete boss;
	boss = nullptr;
}

2. 悬空指针

虽然使用delete把内存回收了,但是指针本身仍旧指向该处,所以该指针仍旧能进行指针运算等,容易被利用恶意控制,如下面。

Monster* m = new Monster();
delte m;//m仍旧存在

//
m->Attack();//m仍旧指向原本的地址

解决方法可以使用宏定义,但由于宏定义没有类型检查,也无法调试,同时容易由于指针递增导致崩溃,所以现代解决方法更多是使用模板内联函数。
我们先解决为什么使用宏定义而不使用函数的问题。
参数传递一般是值传递,不会影响到原本的变量,比如

#include<iostream>
int example(int a){return ++a;} 
int main()
{
	int a = 0;
	std::cout<<example(a)<<std::endl;//输出1
	std::cout<<a<<std::endl;//输出0
}

我们可以使用指针对原本的数值进行操作,这涉及到栈的部分内容,调用example()函数的时候栈会分配一定内存用来存储参数,在这里是存储int类型变量a,在底层是将a的值从原址读取然后复制到栈中对应位置,所以在example函数里进行的操作都是对这个栈内的局部变量a进行的,而不是a原址。
但是我们可以利用指针,假设我们的参数是一个int* 指针,会在栈内分配内存给这个八字节大小的指针,然后底层会将这个指针的值复制过来,于是我们在这里通过访问这个指针内的地址能够对原来的变量进行修改,不会局限于目前这个函数的作用域内。引用也是同理,在底层引用一般是使用指针相同的指令实现的,如下面的两个函数。

#include<iostream>
int example(int* a)
{
	return (*a)++;
}
int example(int& a)
{
	return a++;
}
int main()
{
	int* a = new int(0);
	std::cout<<example(a)<<std::endl;
	std::cout<<example(*a)<<std::endl;
	std::cout<<*a<<std::endl;
}

所以问题就出现在这里,它只是将内容复制过来,而不是将这个变量直接转移过来,在函数里将指针置空并不能改变原本的指针,我们来看看下面的程序

#include<iostream>
void UnSafeDelete(int* ptr)
{
	if(ptr)
	{
		std::cout<<ptr<<std::endl;
		delete ptr;
		ptr = nullptr;
	}
}

int main()
{
	int* ptr = new int(0);
	UnSafeDelete(ptr);
	std::cout<<ptr<<std::endl;
}

在函数UnSafeDelete()中我们将函数内局部变量ptr置空ptr = nullptr;,但是我们能够看到在main()中将ptr指向的地址输出出来和置空前的地址是一摸一样的,他并没有按照我们所想要的在函数UnSafeDelete()内将main(){}作用域内的ptr置空为nullptr,仍旧指向原本的地址。
原因就是函数参数传递本身只是在函数的栈内存中分配内存存放临时变量,这里的临时变量只是将原本的值复制过来,而不是将原本的变量转移到函数的栈内存中,所以对函数栈内的局部变量进行操作ptr = nullptr并不会改变到main()栈内存中的ptr,只改变了UnSafeDelete()栈内存中的ptr。
那为什么其他变量设置用指针传递和引用传递能修改原来的数值?我们看下面的程序

#include<iostream>
void BeZero(int* a)
{
	*a = 0;
}

int main()
{
	int* a = new int(42);
	std::cout<<*a<<std::endl;
	BeZero(a);
	std::cout<<*a<<std::endl;
}

还是那句话,函数的参数传递只是复制数值到函数的栈内存中,不是将变量转移到函数的栈内存中。
BeZero(int* a)有自己的栈内存,在里面分配了内存给BeZero()的指针a,然后将main()中的指针a的数值复制过来,就是这里,main()中的指针a的数值是什么?
是一个存储了int类型的数值为42的四字节大小的存储空间的地址,所以在BeZero()中的指针a也存储着main()中这个存储了int类型的数值为42的四字节大小的存储空间的地址,那么这个时候我们在BeZero()栈内寸中使用指针去访问这个地址然后进行操作和在main()栈内存中使用指针去访问这个地址然后进行操作有差别吗?
没有差别,都是访问的同一个内存地址然后在同一个内存地址上进行操作。
是不是有点绕晕了?核心就是函数的参数传递只是传递的值,不是转移变量本身,指针本身只是一个用来存储指向地址的八字节整数。
因为值传递,所以用指针能够对同一个存储地址的存储数值进行修改,达到修改main()中变量的效果,但又因为只是值传递,所以不能对指针本身的指向进行修改,因为他不是将指针本身转移到函数栈内存中,只是单纯将指针指向的地址从main()中的指针复制到函数中的指针,所以对函数内的指针本身进行修改只能是对函数内的指针修改,根本影响不到main()的指针。

十一、引用

引用本身并不是新的变量,他必须引用已经存在的变量,对于引用是否会导致分配新的内存空间,由于编译器的底层实现和优化,我们需要按照实际情况来看。
cherno视频里所说的不会分配新的空间是在编译器进行内联优化的情况下不会给引用分配新的空间,编译器在进行编译的时候就会把他优化回到他引用的变量,比如int a;int& ref = a;int b =ref;会直接内联,我们并不会得到三个变量a和ref和b,只会得到a和b,相当于int a; int b = a;
在一般情况下,编译器在引用和指针的实现上是一样的,都是存储地址,比如int example(int& n) 这种不会被内联优化的情况下就会分配新的空间,此时引用可以看成一个常量指针。
引用一般用于参数引用传递,参数传递一般默认是值传递,不会影响到原本的变量,我们可以看下下面的程序

int example(int n)
{
//
	std::cout<<++n<<std::endl;
	return n;
}

系统在example函数在栈里对应的内存分配新的内存临时创建新的变量n,系统将原本的n的值复制给example中的n,此时对于example中的n的任何操作都不会影响到原本的n,这就是会分配新空间的参数值传递。
我们要使在example中对n的操作影响到原本的n,可以使用指针或者引用,指针本身是直接将存储n的地址传递过来,进行的操作变更基于地址,没有所谓的新的临时变量。

int example(int* n)
{
//
	std::cout<<++(*n)<<std::endl;
	return *n;
}

下面使用引用传递,引用传递只是给变量n取了个别名(虽然还是n),仍旧指向原本的n,example中对n的操作会作用在原本的n上。

int example(int& n)
{
//
	std::cout<<++n<<std::endl;
	return n;
}

将引用传递和指针传递的两个函数放置在汇编语言的实现上来看,两个的汇编指令是一样的,对于CPU的性能开销是一样的,对应的汇编指令如下。

add     DWORD PTR [rdi], 1  ; 顺着 rdi 寄存器(指针n)里的地址(n指向的变量)找过去,把内存里的值加 1

我们用三种写在一起来看看结果。

#include<iostream>

int plus1(int n)
{
	++n;
	return n;
}

int plus2(int* n)
{
	++(*n);
	return *n;
}

void plus3(int& n)

{
	++n;
}

int main()
{
	int n = 0;
	int* ptr = &n;
	std::cout<<plus1(n)<<std::endl;//输出plus1内部返回过来的n为1
	std::cout<<n<<std::endl;//输出main中原本的n,不受plus1影响,仍为初始的0
	std::cout<<plus2(ptr)<<std::endl;//输出plus2返回过来的指针指向的n(也就是main中的n),和下面这条输出的n是同一个,为1
	std::cout<<n<<std::endl;//输出plus2中地址传递加一之后的n,为1
	plus3(n);
	std::cout<<n<<std::endl;//输出引用传递加一之后main中的n,为2
}

引用一般情况下可以看作是常量指针(如int* const),它具有不能更改他的引用指向的性质,比如下面这段只会把b的值赋给a,不会将ref的指向改向b。

#include<iostream>
int main()
{
	int a = 5;
	int &ref = a;
	std::cout<<"ref = "<<ref<<std::endl;
	int b = 8;
	ref = b;
	std::cout<<"ref = "<<ref<<std::endl;
	a =13;
	std::cout<<"b = "<<b<<" but ref = a = "<<ref<<std::endl;
}

引用还有一个空悬挂的问题,引用指向的变量可能会被回收,此时再使用这个引用会导致segment fault,如果没有被回收而是被其他操作覆盖了数据也会导致读取到脏数据,看下面的程序。

int& temp()
{
	int temp = 42;
	return temp;//返回栈内的局部变量temp的引用,但是temp在这个函数执行完之后栈顶弹出,后续如果该内存被覆盖就会读到脏数据,如果该内存被回收了就会导致出现segment fault
}

所以要注意对象的生命周期,在这里可能会有人想用new来将temp对象建立在堆里进行一定的防御,但还是十分不建议,因为调用这个函数的人不一定会记得后续进行delete释放内存,同时这种操作也十分反人类。

#include<iostream>
int& temp()
{
	int* temp = new int(42);//new创建的变量内存分配在堆里,不会随着函数temp()的执行结束而被回收
	return *temp;
}
int main()
{
	int& a = temp();
	std::cout<<a<<std::endl;
	delete &a;
}

最好是直接返回int值,但是如果要返回的对象很大,必须在堆内存中进行存储,那么最好使用智能指针让系统进行内存管理。

十二、智能指针

智能指针和jvm的垃圾回收(GC)不同,核心是RAII(Resource Acquisition Is Initialization,资源获取即初始化)和所有权语义

十三、栈、堆

这一部分牵涉到的内容较多较杂,但还是得提前进行介绍说明,笔者尽量做到细致正确严谨明确,有问题欢迎指出。
栈和堆是虚拟内存中人为进行的内存分界,在真实的物理内存RAM上是连续的,并没有栈和堆的界限。
栈是线程性的,每个线程都有自己的一个栈,占用内存小,随用随弃,速度快,类似草稿纸。
堆是进程性的,占用内存大,生命周期长,但是速度相对较慢,类似仓库。
栈的内存分配简单粗暴,每次进行函数调用的时候都需要开辟栈帧这一个临时空间来进行函数调用和数据处理,栈帧一般由rsp栈顶指针和rbp栈底指针(栈基指针)开辟维护。
栈顶指针rsp向下移动对应的字节大小即完成了内存分配,向下移动导致的多出来的栈内内存就是分配给对应变量的内存。

void rspExample()
{
	double x = 1.0;
	doubel y = 1.0;
	//
}
rspExample:
	sub rsp 16;栈顶指针向下移动两个八字节
	;;省略函数体操作
	add rsp,16;离开rspExample作用域,栈顶指针回滚
	ret;回归main

栈内存绝对连续,缓存命中率高,每个线程都有自己独立的栈内存,在栈内存上没有资源竞争问题,同时生命周期严格同步,在离开了作用域后栈顶指针回滚,该栈帧占用的内存不再被明确占用,能够被后续其他操作继续覆盖,所以最好不要返回一个栈帧上的局部变量,你不能保证他后续不被其他操作覆盖这一部分内存导致脏数据。
由于栈的内存连续性,操作系统一般给一个栈分配的内存有限,windows系统一般只有1MB,Linux默认8MB,所以栈的内存分配要注意大小,一个int a[1000000];约4MB能把一个栈顶爆,触发栈溢出StackOverflowException
堆内存大,生命周期长,全局共享,用于存储大或长生命周期需求的对象。

十四、静态链接动态链接

1. 静态链接

静态链接是指在链接阶段把库中需要的目标代码合并进最终产物
在 Windows / MSVC 语境下,静态库通常表现为 .lib
当可执行程序或 DLL 链接静态库时,链接器会把被引用到的目标代码并入最终模块,而不是在运行时再去单独加载该库。
相比动态链接,静态链接通常会让最终产物体积更大,但部署更直接,因为运行时不需要再额外找到对应的动态库文件。
静态链接并不是“把整个库无脑全部复制进去”,而是按未解析外部符号的需求提取需要的目标代码
最终产物磁盘体积通常更大;但是运行时内存占用则取决于加载段、共享页、是否多进程复用等因素和dll相比并不是“必然更大”。
更准确的说就是 “静态链接会增大最终二进制体积”

2. 动态链接

动态链接是指最终程序在构建时只记录对外部动态库的依赖关系,而在运行时由操作系统加载器把 DLL 映射进进程并完成符号解析与地址修正
在 Windows 下,动态库通常表现为 .dll
对于使用该 DLL 的程序,构建时通常还会配套一个 .lib 文件,这个 .lib 在 DLL 场景下一般是导入库,主要供链接器使用,而不是静态库本体
动态链接的常见特点是:

  • 最终可执行文件本体通常更小
  • 多个程序可以共享同一个 DLL
  • 运行时必须能找到所依赖的 DLL
  • 对 ABI、CRT、编译器配置的一致性要求更高

下面以 Windows 下的 Hazel.dllSandbox.exe 为例展开。

2.1 编译 / 链接阶段

假设我们将引擎 Hazel 构建为动态库,将会得到:

  • Hazel.dll:真正运行时被加载的动态库
  • Hazel.lib:导入库,供链接器在构建 Sandbox.exe 时使用

Hazel.lib 的作用不是提供完整实现代码,而是告诉链接器哪些符号将由 Hazel.dll 提供,最终产物需要建立对 Hazel.dll 的导入关系。

当链接 Sandbox.exe 时,链接器会处理 Sandbox.obj 中未解析的外部符号,例如:

  • Hazel::Application::Run
  • Hazel::CreateApplication

此时它会搜索输入的库文件,并通过 Hazel.lib 确认这些符号来自 Hazel.dll
然后,链接器不会把 Hazel.dll 的实现代码直接拷进 Sandbox.exe,而是在 Sandbox.exe 中建立对 Hazel.dll 及其导出符号的导入记录。
这一阶段主要的结果如下:

  • Sandbox.exe 知道自己依赖 Hazel.dll
  • Sandbox.exe 记录了自己需要从 Hazel.dll 导入哪些符号
    真正的函数地址还要等到运行时由加载器填写。

2.2 运行时

运行时过程可以简单概括为:

  1. Sandbox.exe映射进进程空间;
  2. 发现其依赖 Hazel.dll
  3. 映射 Hazel.dll
  4. 解析 DLL 导出符号;
  5. 填写修正 Sandbox.exe 中的 IAT导入地址表;
  6. 此后 Sandbox.exe 中对 Hazel 符号的调用就可以跳转到到被加载进进程中的 Hazel.dll 中真实的实现地址。
    当 Windows 启动 Sandbox.exe 时,加载器会先把 Sandbox.exe 映射进当前进程地址空间,然后检查它的依赖模块列表,发现它还依赖 Hazel.dll
    随后,加载器会去搜索 Hazel.dll,找到之后把它也映射进当前进程空间。
    加载完成后,系统会根据 Hazel.dll 的导出信息,修改进程中的IAT导入地址表,把 Sandbox.exe 里对应导入项的地址修正为当前进程内真实可调用的函数地址。

2.3 DLL 与 EXE 的编译环境问题

DLL 工程真正复杂的地方,通常不是“函数能不能找到”,而是 DLL 与 EXE 是否在使用兼容的 ABI、CRT 和运行时规则。
这里最敏感的因素通常包括:

  • 编译器及工具链版本;
  • Debug / Release 配置是否一致;
  • C 运行时库(CRT)配置是否一致;
  • 标准库实现和对象布局是否一致。

2.3.1 CRT 与堆管理

Windows 进程本身提供底层堆接口,例如 GetProcessHeapHeapAllocHeapFree
但我们在 C/C++ 里通常直接使用的是更高一层的运行时库接口,例如:

  • new / delete
  • malloc / free
  • fopen / fclose
  • 某些 STL 对象(如std::string)背后的隐式分配逻辑。

微软文档明确指出每一份 CRT 拷贝都有独立状态,并且每一份 CRT 都有自己的 heap manager堆管理器。
分配方和释放方不在使用同一套 CRT / heap manager / 调试元数据规则 时进行堆操作容易出现问题,例如某块内存是在一个 CRT 中分配、却在另一个 CRT 中释放,就可能导致内存访问错误或 heap corruption,

  • Hazel.dll 中创建对象;
  • Sandbox.exe 中直接 delete 该对象;
    如果二者运行时配置不一致,就可能因为堆管理规则不一致而出问题。

2.3.2 /MD/MT

在MSVC 中:

  • /MD:使用 DLL 版运行时库
  • /MT:使用静态链接版运行时库
    微软文档明确说明同一次链接中传递给链接器的所有模块,都必须使用相同的运行时库选项进行编译。
    一般来说使用 /MD 时,多个模块更容易共享同一套 DLL 版 CRT,使用 /MT 时,每个模块可能各自静态带入一份 CRT。
    如果一个模块 /MD,另一个模块 /MT,再跨模块传递 CRT/STL 对象或跨模块分配或释放内存,风险会显著增加。

2.3.3 Debug / Release 一致性

除了 /MD/MT,Debug / Release 的一致性也非常关键。
调试版 CRT 往往包含额外调试元数据、边界检查和不同的堆调试逻辑。
CRT 调试堆文档明确说明,Debug CRT 会为分配块附加额外头信息和保护区域,用于检测越界和内存错误。
这意味着在Debug DLL + Release EXE或 Release DLL + Debug EXE的情况下,即使“函数能连上”,也仍然可能在对象构造 / 析构、分配 / 释放阶段埋下运行时问题。

2.4 工程建议

在 DLL 设计中,更稳妥的做法通常包括:

1)统一运行时配置

尽量保证:

  • 所有模块使用同一 CRT 选项;
  • Debug 全体 Debug,Release 全体 Release;
  • 使用同一工具链和兼容的标准库版本。
    例如在 CMake 中配置
    set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
    这表示Debug 使用 MultiThreadedDebugDLL(即 /MDd),其他配置使用 MultiThreadedDLL(即 /MD
    本质上是在尽量把整个工程统一到 DLL 版 CRT 上。

2)缩小 ABI 暴露面积

DLL 边界越窄,越稳定。
因此通常更建议跨 DLL 传递例如int、float、double、POD struct、句柄、ID简单值等类型 而不是直接跨 DLL 传递STL容器(std::string、std::vector)、FILE*、复杂所有权对象`

3)明确所有权

尽量遵循 谁分配,谁释放。
例如,如果对象由 DLL 创建,更稳的方式通常是由 DLL 也提供对应销毁接口,而不是在 EXE 中直接释放。
这是规避“不同 CRT / 不同堆规则”问题最实用的工程策略之一。

3. 总结

静态链接的重点是链接阶段把需要的代码并入最终模块
动态链接的重点是构建阶段建立依赖关系,运行时由系统加载 DLL 并修正导入地址
而 DLL 工程真正最需要警惕的,不只是“能不能找到函数”,更是DLL 与 EXE 是否在使用一致的运行时库、堆管理规则和 ABI。
一旦跨模块传递复杂对象,或者在一边分配、另一边释放内存,就很容易引出堆损坏和运行时不兼容问题。