☰
C++课堂笔记
2026/10/19 12:06:01 网站建设 项目流程

C/C++ 局部变量与栈内存

#include<iostream> using namespace std; #include<assert.h> int* func() { int ar[10] = { 12,23,34,45,56,67,78,89,90,100 }; int* ip = ar; return ip; } void funa() { int br[10] = { 1,2,3,4,5,6,7,8,9 ,10}; } int main() { int* cp = func(); funa(); printf("%p : %d \n", cp, *cp); return 0; }为什么每次打印出来cp的地址都会改变

实际运行会发生什么

int* cp = func(); // cp 指向 func 中 ar 的栈地址(已失效) funa(); // funa 的 br 覆盖了那块栈内存 printf("%p : %d \n", cp, *cp); // 打印的 *cp 很可能是 br[0] = 1

可能的输出:

text

0000008F2A3FF8A0 : 1
  • 地址每次都不同(ASLR)。

  • *cp的值很可能是1(因为被br[0]覆盖了),也可能因为编译器优化而不同。

问题原因
cp指向失效内存func返回局部数组地址,函数返回后栈内存被回收
每次地址不同ASLR + 栈帧布局随运行环境变化
*cp值不稳定被后续函数调用(funa)的栈内容覆盖

一句话总结:

func()返回的是栈上局部数组的地址,函数返回后该内存失效;下次调用其他函数会复用这块栈空间,再加上操作系统的 ASLR 机制,所以cp的地址每次运行都会改变。

扩展:为什么static或全局变量地址就固定?

  • static和全局变量存放在数据段(.data/.bss),地址在链接时就确定(或相对固定)。

  • 栈和堆的地址是运行时动态确定的,受 ASLR 影响。


#include

#include实质是进行拷贝

单独一行,只能写一个文件名

假设同目录下有一个名为yhp.txt的文件,里面的内容不是普通的文本,而是符合 C++ 数组初始化语法的数字,例如:

text 1, 2, 3, 4, 5, 6

经过预处理器处理后,编译器实际看到的代码会变成:

int ar[] = { 1, 2, 3, 4, 5, 6 };

printf可变函数

...可变参数 --->参考书<<C语言程序设计>>7.3变长参数表

...和cpu中的寄存器ebp,esp,push,pop指令有关

...:可变参数

声明一个函数可以接受数量和类型都不固定的参数。

1. 语法规则
  • 必须放在最后:在一个函数原型中,...必须出现在参数列表的末尾。

  • 必须有前置参数:...前面必须至少有一个明确的、固定类型的参数。对于printf来说,这个固定参数就是const char* format。

    • 为什么?因为函数在执行时,必须知道从哪里开始去读取那些未知的参数。格式字符串就是那个“路标”,它通过%d、%s等占位符告诉函数后面该怎么解析内存。

2. 它在底层是如何工作的?

从零手写一个 printf 函数:变参宏与默认参数提升_printf 宏-CSDN博客

C语言如何实现printf函数

C语言实现参数可变函数printf_c 可变参数 printf 实现-CSDN博客

随机函数

  • rand()(伪随机函数):它其实是一个数学算法。每次调用它,它会根据内部的“状态”算出一个数字(在 VS 中通常是 0 到 32767 之间)。如果没有给它提供新的“状态”,它的初始状态是固定的。

  • srand(unsigned int seed)(随机种子):它的作用是重置rand()的初始状态(种子)。如果种子相同,那么每次程序运行时,rand()产生的一连串数字序列也是完全相同的。

  • time(NULL):获取当前系统时间(从 1970 年 1 月 1 日到现在的秒数)。因为时间是不断变化的,把它作为种子,就能保证每次运行程序时,种子不同,产生的随机数也就真正“随机”了。

为什么最后两次的b都是 39?

  1. 执行太快:程序在瞬间连续调用了两次my_rand()。

  2. 种子相同:time(NULL)是以秒为单位的。这两次调用发生在同一秒内,所以time(NULL)返回的值完全一样。

  3. 序列重置:每次进入my_rand(),srand()都会用相同的种子重置rand()的序列。因此,紧接着调用的rand() % 100必然会算出同一个数字(39)。

为什么 10 次循环出来的结果一模一样?
和上面的原因完全相同。现代 CPU 执行一个 10 次的简单循环只需几微秒。这 10 次循环都在同一秒内完成。

  • 每次循环开始,srand(time(NULL))获取到的时间戳是一样的。

  • 每次循环,种子被重置为相同的值。

  • 每次循环,rand()返回的是由该种子生成的序列的第一个数。


函数重载

1.函数重载的依据

函数重载判定只看形参列表:参数个数、参数类型、形参类型的排列顺序。

函数返回值不能作为函数重载的判断依据,因为会产生二义性

⚠️重点区分:这里的参数顺序是【类型的先后顺序】,不是形参变量名。形参变量名不参与函数签名,不影响重载判断。

✅可以重载示例(类型顺序不同)

cpp

void f(int x, double y){} void f(double x, int y){} //类型序列:(int,double) 和 (double,int),类型顺序不同,构成重载

❌不能重载示例(仅变量名更换,类型序列不变)

cpp

void f(int a, double b){} void f(int x, double y){} //类型序列都是(int,double),仅仅变量名字不一样,属于同一函数,无法重载

2.返回类型不同的函数能否重载

函数返回值不能作为函数重载的判断依据,因为会产生二义性。

如果两个函数参数列表完全一致,仅仅返回类型不同,编译器无法仅依靠返回值区分该调用哪一个版本,因此无法重载,编译报错。

int Max(char a,char b)与cahrt Max(char a,char b)冲突

3.带默认(缺省)形参的相关规则

  • 带默认值的形参必须写在参数列表的最右侧。
  • 默认参数只写在函数声明中,函数定义处不要再重复写默认值。
  • 默认参数会改变函数能够接收的实参数量,极易和重载组合产生调用二义性

二义性例子:

void fun(int a){} void fun(int a, int b = 10); // fun(10); 既可以匹配第一个,也可以匹配第二个,二义性,编译报错

安全无歧义示例:

void Print(int *br,int n); void Print(int *br,int len = 10); // Print(arr) 只能匹配第二个;Print(arr,5)只能匹配第一个,无歧义

4.值传递 / 指针 / 引用中 const 的重载规则

函数重载看函数签名(参数类型列表),const能不能参与重载,取决于 const 修饰的是谁:

  1. 值传递:const修饰的是形参本身,不参与函数签名识别,不能用来重载
  2. 引用 / 指针:const修饰的是引用绑定的对象、指针指向的对象,属于参数类型一部分,可以用来重载

实例:

  • 值传递形参:识别函数签名时不考虑 const/volatile 修饰,不能用来重载
void fun(int a) {} void fun(const int a) {} //二者视为同一个函数,不能重载 // const只是限制形参a在函数内部不能修改,实参传进来都是拷贝,外部不受影响 // 编译器识别签名时忽略值形参的const
  • 2.指针、引用形参:识别签名时要考虑 const 修饰,可以用来重载
void fun(int *p) {} void fun(const int *p) {} //可以重载 void fun(int &a) {} void fun(const int &a) {} //可以重载

  • int a=10; func(&a);:&a是int*,优先匹配int* ip版本

隐式转换规则:int*可以隐式转换为const int*;反向不能转换。

编译会报错:

int* const icp是顶层 const:const修饰的是指针变量icp本身,不是指针指向的数据。

顶层 const 修饰指针形参的时候,编译器在识别函数签名时会忽略这个 constfunc(int*)和func(int* const)被编译器认为是同一个函数签名!

写法const 修饰对象类型能否参与重载
const int *pint(指针指向的内容)底层 const✅ 可以重载
int * const p指针变量p本身顶层 const❌ 不能重载

调用函数时重载匹配顺序:

  1. 可修改左值实参:优先匹配普通左值引用int &
  2. 右值实参:优先匹配右值引用int &&
  3. 普通左值 / 右值都能匹配const int &(兜底版本)

5. 为什么C++可以函数重载而C语言不可

  • 核心原因:名字修饰(Name Mangling)机制不同。

  • C语言:编译时对函数的命名修饰非常简单,通常只在函数名前加一个下划线(例如_func)。因此,如果两个函数名字相同,在符号表中就会冲突,链接时报错。

  • C++语言:编译器会将函数名、参数类型、参数个数等信息结合起来,生成一个唯一的符号名(例如_Z4funci代表func(int),_Z4funcid代表func(int, double))。这样即使函数名相同,只要参数不同,生成的符号名就不同,链接器就能区分它们。

名字修饰(名字粉碎 Name Mangling)

  • 含义:C++ 编译时,将函数名 + 参数类型编码生成唯一内部符号,这个过程叫名字粉碎。
  • C 语言没有名字粉碎。
  • 实现方式

    编译生成目标文件.o 时,编译器提取完整签名(命名空间、类、函数名、参数类型、成员 const 属性,返回值不参与粉碎),按照编译器 ABI 编码规则拼接为唯一字符串,写入目标文件符号表。不同编译器(GCC/MSVC)编码规则不同;extern "C"可以关闭名字粉碎。

  • MSVC 名字修饰代号(考点)
    代号调用约定说明
    YA__cdecl全局函数,C 默认,调用方清理栈
    YG__stdcall全局函数,被调用函数清理栈
    YF__thiscall类的成员函数专用

编译器利用名字粉碎技术,在编译过程会产生新名字

_cdecl调用约定:
1、以“?”标识函数名的开始,后跟函数名;
2、函数名后面以“@@YA”标识参数表的开始,后跟参数表;
3、参数表的第一项为该函数的返回值类型,其后依次为参数的数据类型,指针标识在其所指数据类型前;
4、参数表后以“@Z”标识整个名字的结束,如果该函数无参数,则以“Z”标识结束。

6.extern 关键字的使用

  • 本质:声明,不分配内存;告诉编译器该实体在别处已定义。
  • 修饰变量:extern int a;仅声明,不开辟内存,不能初始化;
  • // 文件A.cpp int num = 100; // ✅ 定义:分配内存 // 文件B.cpp extern int num; // ✅ 声明,不分配内存。告诉编译器num在其他文件存在 int main(){ cout << num; return 0; }

一 旦extern int a = 10;就变成定义(extern 失效)。

用途:实现跨文件共享全局变量,只可用于全局变量,不能修饰局部变量。

  • extern "C":关闭 C++ 名字修饰,采用 C 命名规则;用于 C/C++ 互调;
  • extern "C" { void test(); }

限制:extern "C"修饰的函数不能重载。

思考题:

  1. 问:为什么 printf 必须是__cdecl?

简答答案:因为 printf 是可变参数,参数个数不确定,只能由调用者在调用结束后清理堆栈,所以采用__cdecl

2.为什么 C++ 需要名字修饰?

C++ 允许函数重载:同名函数,参数列表不同。 链接器区分重载函数不能只靠函数原名,编译器通过名字修饰,把参数类型编码进符号名,让链接器识别不同重载版本。

开关语句

1.#ifndef/#ifdef

用于条件编译,判断某个宏是否被定义。

#ifndef MY_HEADER_H #define MY_HEADER_H // 头文件内容 #endif

这是最经典的头文件保护(include guard):

  • #ifndef MY_HEADER_H:如果MY_HEADER_H没有定义,就编译下面的内容

  • #define MY_HEADER_H:定义它,防止重复包含

  • #endif:结束条件块

#ifdef则相反,宏已定义时才编译:

#ifdef DEBUG printf("debug info\n"); #endif

配套的还有#else、#elif:

#ifdef _WIN32 // Windows 代码 #elif defined(__linux__) // Linux 代码 #else // 其他平台 #endif

2.#if 0 ... #endif

用于临时屏蔽一段代码,等价于注释掉,但比/* */更安全(可嵌套、不怕代码里本身有注释)。

注意:#if 0里的内容仍会被预处理器扫描(比如未闭合的#if会报错),但不会被编译。

函数模板

无论是函数模板还是类模板,它们的本质都是“代码生成器”。它们本身不占用内存,也不会被编译成机器码,只有当被具体类型实例化时,编译器才会帮我们生成真正的代码。

1.函数模板定义:

template <模版参数表> 返回类型 函数名(形式参数表) { ....... // 函数体 }

typename和class在模板声明中完全等价,推荐用typename语义更清晰)

常见的定义形式:

1. 最基础的类型模板

template <typename T> T my_max(T a, T b) { return a > b ? a : b; }
2. 带有多个类型参数
不确定返回值类型,在模版声明中用R表示,在主函数中调用时,要明确标出需要的返回值类型
// 编译器无法通过返回值推导,必须显式指定

3.模版实参推演过程

define与typedef区别:

特性#define(宏定义)typedef(类型别名)
处理阶段预处理阶段(编译前)编译阶段
本质纯文本替换,不涉及内存分配,不检查语法。为现有类型起一个新名字,是编译器认可的独立类型。
指针陷阱多个变量声明时,只有第一个变量是指针(除非每个变量前都加*)。无论声明多少个变量,它们都是指针。
const修饰const只会修饰紧随其后的基础类型。const修饰的是整个别名代表的类型。

  • const PINT c, d;

    • 文本替换为:const int *c, d;

    • const修饰的是int。所以c是const int *(指针指向的值不能改,指针自己可以改)。

    • d是const int(普通的常量整数)。

  • const SINT e, f;

    • SINT等同于int*。所以这句话等价于const (int*) e, f;

    • const修饰的是整个int*类型。所以e是int* const(常量指针:指针指向的地址不能改,但可以通过指针修改指向的值)。

    • 同理,f也是int* const。

注:图中由于const定义在局部作用域且未初始化,编辑器(或编译器)会划红线报错,因为const变量必须在定义时初始化。

名字空间:namespace

在C++中支持域(4种):全局域,局部域,块域,名字空间域

namespace的定义限制:

  • 只能在全局作用域或其他命名空间内部定义。

  • 绝对不允许在函数内部(局部作用域)定义namespace。

using namespace 命名空间名;

  1. 作用:将指定命名空间的所有成员引入当前作用域,省略前缀(如std::)。

  2. 生效范围:从书写位置开始,到当前作用域结束(全局或局部大括号)。

全局域:

程序启动时(进入main函数前),操作系统会为所有全局变量分配内存,并完成初始化(未显式初始化的默认置 0)。它们会在整个程序运行期间一直存在。

局部变量遮蔽全局变量(就近原则)

  1. 规则:在函数(或内部代码块)中定义的局部变量,如果与全局变量同名,在局部变量的作用域内,局部变量会遮蔽(隐藏)全局变量。

  2. 查找顺序:编译器按“由内向外”的顺序查找变量(局部 -> 全局)。

  3. 注意:遮蔽只在局部作用域内生效,且从局部变量声明的那一行开始生效(声明之前使用的依然是全局变量)。

如果非要在这个函数里用全局的g_max怎么办?

破解方法:如果要强行访问被遮蔽的全局变量,使用作用域解析符::变量名。

static修饰局部变量

  1. 数字赋初值(常量初始化):像static int a = 10;,编译器在编译期就确定了值,直接放在.data区(数据段),程序加载时直接映射进内存。空间提前开辟好,直接赋上初值。

  2. 变量赋值(动态初始化):像static int b = x;,因为x是运行时的参数,编译器没法提前算出来,所以它会在.data或.bss区(通常是.bss段,如果没初始化为 0 的话)给b留好空间,并打上一个“标记”(Flag)。

new/delete

可执行程序的虚拟地址空间:

malloc

void* malloc(size_t size); 它申请 size 个字节的内存,如果成功,返回这块内存的首地址;如果失败,返回 NULL。

.void*的三大特性(泛型指针):

  • 无类型:void*被称为“无类型指针”,它不知道指向的数据是什么类型,也不知道数据占几个字节。

  • 纯地址存储:它内部只存储一个内存地址。

  • 自动转换(核心):

    • 在C 语言中,void*可以隐式自动转换为任何其他类型的指针,反之亦然。所以 C 语言中不需要强转。

    • 在C++中,为了类型安全,void*不能隐式转换为其他类型的指针,必须显式强转。

怎么实现泛型操作?

1.c

  • 本质是按字节操作。

  • 依赖void*和size_t(字节大小),配合memcpy、memcmp等内存操作函数。

#include <stdio.h> #include <string.h> #include <stdlib.h> // 泛型交换函数:接收两个 void* 和数据的字节大小 void generic_swap(void* a, void* b, size_t size) { // 1. 申请一块临时内存,大小等于数据类型的大小 void* temp = malloc(size); // 2. 按字节拷贝:把 a 的内容拷到 temp,b 的内容拷到 a,temp 的内容拷到 b memcpy(temp, a, size); memcpy(a, b, size); memcpy(b, temp, size); // 3. 释放临时内存 free(temp); } int main() { int x = 10, y = 20; // 传入地址,并告诉函数:我们要交换的是 int(占 4 个字节) generic_swap(&x, &y, sizeof(int)); printf("x=%d, y=%d\n", x, y); // x=20, y=10 double d1 = 1.1, d2 = 2.2; generic_swap(&d1, &d2, sizeof(double)); printf("d1=%.1f, d2=%.1f\n", d1, d2); // d1=2.2, d2=1.1 return 0; }

2.c++

  • 模板(Template)是 C++ 的核心泛型机制,在编译期实例化,保证类型安全,无需字节搬运。

  • 内存分配差异:malloc只分配原始内存,不调用构造函数;C++ 泛型对象应使用new或标准库容器(如vector),以确保对象的正确构造与析构。

// C++ 真正的泛型交换,基于模板,完全不需要 malloc 和 memcpy template <typename T> void generic_swap(T& a, T& b) { T temp = a; // T 可以是 int、double、自定义类 a = b; b = temp; }

new,malloc区别(重要):

1、new/delete 是C++中的运算符。malloc/free是函数api(应用程序编程接口)。

运算符举例:+-*/,sizeof(),delete,>,<
2、malloc申请内存空间时,手动计算所需大小,new只需类型名,自动计算大小;
3、malloc申请的内存空间不会初始化,new可以初始化;
4、malloc的返回值为Void*,接收时必须强转,new不需要;
5、malloc申请内存空间失败时,返回的是NULL,使用时必须判空;
6、new申请内存空间失败时抛出异常,所以要有捕获异常处理程序;

new实现步骤1.sizeof()2.调用malloc函数 3.赋初值 4.返回申请空间地址

内存空间泄露:

1.地址丢失

  • 现象:你用malloc或new申请了一块堆内存,把指针保存下来。但后来这个指针变量被重新赋值、或者函数结束被销毁了,导致这块内存的首地址彻底丢失。

  • 结果:这块内存还在堆里,但谁也找不到它,也无法free/delete它,直到程序结束被操作系统强制回收。

  • 解决方案:智能指针

2.不断从堆区申请,空间用光了

  • 对策:

    • 规范释放:有借有还,再借不难(配合 RAII)。

    • 内存池):预先申请大块内存,接管内存的分配和释放,程序用完内存后归还给池子,供下次复用,而不是立刻还给操作系统。

    • 使用 STL 容器:如vector,自动管理动态扩容与释放。

未定义行为:

早期 g++(glibc 底层)是如何处理free:

  1. 第一次free(交还控制权):内存被系统回收,扔进空闲链表,但指针ip仍保留原地址(变成悬垂指针)。

  2. *ip = 10(非法写入):强行向已交还的内存写入数据。轻则写入无主空间,重则破坏其他程序的数据(内存踩踏)。

  3. 第二次free(引爆地雷):系统检查到该内存已被标记为空闲,强行二次释放破坏管理规则,直接触发程序崩溃(Double Free 报错)。

新版VS处理delete情况:

1. 释放后指针未置空(悬垂指针)
delete ip只回收了内存,ip的地址值依然保留。此时执行*ip = 100;属于未定义行为(UB),可能踩坏别人内存,也可能暂不报错(隐藏的 Bug)。

2. 连续delete两次(双重释放)

  • 早期编译器:无保护机制。第二次delete会直接破坏堆管理结构(Chunk Header),导致程序崩溃,或引发Double Free 安全漏洞(可被黑客利用)。

  • 现代编译器(如 VS Debug 模式):加入安全防御。第一次delete后,底层会将内存填充特定垃圾值(如0xDD),并将指针内部地址改写为受保护的无效地址(或只读地址)。当执行第二次delete时,系统检测到地址非法,立刻抛出异常阻断,防止堆结构被进一步破坏,从而强制暴露出代码的 Bug。

💡 黄金法则:delete之后,立刻ip = nullptr;。

vs2026新的编译器,会给释放掉的变量,赋值一个新的只读空间地址,再次释放是只会影响自己,不会其他程序,这样的编译会是程序更加安全

思考题:

1.异步IO,同步IO

IO 模型核心区分点:调用 IO 的线程是否阻塞,以及数据拷贝是谁来完成

同步 IO

进程发起 IO 系统调用后,进程会阻塞,直到内核把数据从磁盘 / 网卡读到内核缓冲区,再拷贝到用户空间,系统调用才返回。

  • 特点:用户线程等待 IO 全过程(等待设备就绪 + 内核拷贝数据到用户缓冲区)
  • 常见:阻塞 IO、非阻塞 IO、IO 多路复用(select/poll/epoll)都属于同步 IO,因为最终数据从内核→用户空间这一步,都需要用户线程等待完成。

重点:非阻塞≠异步!非阻塞只是不会一直等就绪,但数据拷贝阶段仍同步等待。

异步 IO

进程发起 IO 请求后,调用立刻返回,不阻塞。内核全权负责:等待设备就绪 + 把数据从内核缓冲区拷贝到用户缓冲区。全部完成后,内核通过信号 / 回调通知进程。

  • 特点:用户线程全程不参与等待与拷贝,IO 所有工作交给内核后台完成。

表格

同步 IO异步 IO
阻塞会阻塞(IO 多路复用是同步,只是等待方式不同)不会阻塞
数据拷贝用户进程等待内核把数据拷贝到用户空间内核自动完成拷贝,完成后通知
典型例子read、epollLinux AIO、io_uring

一句话记忆:同步:用户等数据拷贝完成;异步:内核干完所有活再来通知你。

2.零拷贝

定义

零拷贝不是完全没有拷贝,而是减少 CPU 参与的数据拷贝次数,避免数据在内核缓冲区、用户缓冲区之间来回复制,减少 CPU 开销、内存带宽占用。

传统 read+send 流程(4 次拷贝、2 次 CPU 拷贝):

  1. 磁盘 → 内核缓冲区(DMA 拷贝,不需要 CPU)
  2. 内核缓冲区 → 用户缓冲区(CPU 拷贝)
  3. 用户缓冲区 → socket 内核缓冲区(CPU 拷贝)
  4. socket 缓冲区 → 网卡(DMA 拷贝)

零拷贝方案(经典两个)

  1. mmap(内存映射)把内核缓冲区映射到用户进程虚拟地址空间。用户直接读写内核缓冲区,省去【内核→用户】CPU 拷贝。 流程:磁盘→内核 buf (DMA) → socket buf (CPU) →网卡 (DMA),一共 3 次拷贝,1 次 CPU 拷贝。
  2. sendfile(真正零拷贝思想,Linux)数据不经过用户态,全程在内核态传输。 磁盘→内核缓冲区 (DMA),内核直接把数据传给 socket 缓冲区,再 DMA 发送网卡。 新版本 sendfile 支持 DMA 收集拷贝,甚至省去内核 buf 到 socket buf 的 CPU 拷贝,只剩 2 次 DMA 拷贝,没有 CPU 拷贝,这就是常说的零拷贝。

核心总结

  • “零拷贝”:消除 CPU 负责的内存拷贝,DMA 硬件拷贝仍然存在。
  • 适用场景:文件服务器、流媒体,大文件传输,减少 CPU 占用。
  • 局限:sendfile 不能对数据做修改,数据只能原样转发;mmap 有缺页、内存安全问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询