← 博客

C语言

C语言学习过程中的一些基础知识

中文内容33 分钟约 11112 字

一,编译环境的准备

编译器

编译器是环境中最关键的部分。它负责语法检查并将源代码翻译成可执行文件。

维度GCCClangMSVC
开源协议GPLv3严格限制闭源衍生Apache 2.0商业友好闭源商业
主导方/阵营GNU 基金会 / Linux 社区Apple, LLVM 社区微软 (Microsoft)
编译速度中等极快中等
报错诊断良好(新版已有很大改善)优秀(开创者)良好
架构与芯片支持极其广泛(全能王)广泛主要针对 x86 / x64 / ARM (Windows)
典型使用场景Linux 内核/系统、嵌入式开发macOS/iOS、Chrome、LLVM 工具链Windows 桌面应用、AAA 级 Windows 游戏开发

构建工具

Make

一个基于依赖关系的自动化构建引擎。它读取名为 Makefile 的配置文件,根据文件的时间戳判断哪些代码修改过,实现增量编译(只重新编译修改过的文件,极大节省时间)

基本语法

目标(要生成什么): 依赖(需要什么材料)
	命令(具体怎么做,行首必须是 Tab 键)
# 1. 定义变量(方便统一修改)
CC = gcc               # 指定编译器为 gcc
CFLAGS = -Wall -g      # 编译参数:开启所有警告(-Wall),保留调试信息(-g)
 
# 2. 终极目标:把 .o 零部件链接成可执行文件 main
main: main.o utils.o
	$(CC) $(CFLAGS) -o main main.o utils.o
 
# 3. 子任务:把 main.c 编译成 main.o
main.o: main.c
	$(CC) $(CFLAGS) -c main.c
 
# 4. 子任务:把 utils.c 编译成 utils.o
utils.o: utils.c
	$(CC) $(CFLAGS) -c utils.c
 
# 5. 清理任务:执行 make clean 即可删除编译产生的中间文件
clean:
	rm -f *.o main

CMake

不直接编译代码,而是读取统一的配置文件,在 Linux 生成 Makefile,在 Windows 生成 Visual Studio 工程(.sln),在 macOS 生成 Xcode 工程。

# 1. 指定所需的 CMake 最低版本
cmake_minimum_required(VERSION 3.10)
 
# 2. 设置项目名称与语言
project(MyCProject C)
 
# 3. 指定 C 语言语法标准(如 C11)
set(CMAKE_C_STANDARD 11)
 
# 4. 指定头文件所在的文件夹(解决 #include "xxx.h" 查找问题)
include_directories(include)
 
# 5. 核心指令:将源码编译并合成可执行程序 main
add_executable(main src/main.c src/utils.c)

调试器

在程序运行、死循环或发生崩溃,如段错误 Segmentation Fault时,动态暂停程序、检查内存变量、排查崩溃位置。

调试器(如 GDB/LLDB)本身也是一个普通程序,它能暂停另一个程序,读取它的内存依赖于操作系统提供的系统调用(System Call):

  • Linux 下的 ptrace 系统调用:GDB 通过调用 ptrace,获得了对被调试进程(Tracee)的绝对控制权,可以读取/修改其内存、寄存器,以及拦截信号。
  • Windows 下的 Debug API:如 DebugActiveProcess。

调试符号表 当运行 gcc -g main.c -o main 时,编译器不仅生成了二进制机器码,还在可执行文件中附带了一份 DWARF 格式的调试符号表。

操作GDB / LLDB 命令简写功能说明
打断点breakb示例:b main(停在 main 函数入口)或 b 15(停在 15 行)
启动运行runr启动程序,直到遇到断点或崩溃时暂停
单步跳过nextn执行下一行代码(不进入调用的函数内部)
单步步入steps进入当前行调用的函数内部
继续执行continuec恢复运行,直到遇到下一个断点
查看变量print 变量名p打印当前变量的值或表达式结果(如 p ptr)
堆栈回溯backtracebt查看崩溃时函数层层调用的位置
退出调试quitq退出 GDB

1. 断点机制(Breakpoint)

当设置 b main.c:10 时,调试器是怎么让程序正好停在第 10 行的?

  • 软件断点(默认机制): 当打下断点时,GDB 会秘密地把第 10 行对应的机器指令的第一个字节,替换成一条特殊的CPU中断指令(在 x86 架构上是 INT 3,对应十六进制 0xCC)。 当 CPU 执行到这里时,会触发一个“陷阱中断”(Trap Exception),操作系统接管并将控制权交给 GDB,GDB 再把原来的指令恢复回去,表现为看到的“程序暂停在第 10 行”。
  • 硬件断点(Hardware Breakpoint): 利用 CPU 芯片内部自带的调试寄存器(如 x86 的 DR0DR3)。不需要修改代码内存,通常用于调试只读内存(ROM)中的代码。

2. 观察点/数据断点(Watchpoint)

有时候你的变量不知被谁悄悄篡改了,你不知道是哪一行代码干的。此时普通的行号断点毫无作用。

  • 用法:watch count(当 count 变量的值发生改变时暂停程序)
  • 原理:利用 CPU 的硬件调试寄存器,直接监控特定内存地址的写操作或读操作。只要有任何指令尝试向该地址写入数据,CPU 会立刻暂停程序并告警。

3. 堆栈回溯(Backtrace)

当程序遭遇致命错误(如 Segmentation Fault 段错误)猝死时,GDB 会在崩溃发生的瞬间拉住程序。

此时敲入 bt(或 backtrace),GDB 会沿着 CPU 的栈帧指针(RBP/EBP)向上逆向推导,还原出从 main() 函数开始,一层层调用到崩溃点的完整函数调用链。

开发环境

类型代表工具特点与适用场景
集成开发环境 (IDE)Visual Studio (Windows)、CLion (JetBrains 系列)、Code::Blocks (轻量级)开箱即用。自带编译器、构建工具和可视化调试器,适合大型项目开发。
轻量级编辑器 + 插件VS Code高定制化。仅为文本编辑器,需安装 C/C++ 扩展,并手动配置编译与调试路径(如 tasks.json 与 launch.json)。

二,格式化输入与输出流

printf 和 scanf 属于标准输入输出库中的格式化(Formatted)函数,核心机制是基于格式控制字符串。

格式符数据类型说明与注意事项
%d / %i有符号十进制整数int 类型
%u (拓展)无符号十进制整数unsigned int 类型
%f单精度/双精度浮点数打印 float 或 double,默认保留 6 位小数
%lf双精度浮点数scanf 读取 double 时必须用 %lf;在 printf 中 %f 和 %lf 通用
%c单个字符char 类型
%s字符串接收 char* 或字符数组首地址,遇到 \0 或空白符停止
%p指针地址以十六进制形式打印变量的内存地址(如 0x7ffe...)

printf

printf("格式控制字符串", 输出参数列表);

格式控制修饰符

  • 对齐与对齐宽度:
    • %5d:输出至少占 5 列,默认右对齐(位数不足前补空格)。
    • %-5d:加-号代表左对齐(位数不足后补空格)。
  • 前导补零:
    • %03d:输出宽度至少为 3 列,不足时前导补零(例如:数字 7 输出为 007)。
  • 精度控制(用于浮点数):
    • %.2f:强制保留 2 位小数(采用四舍五入规则)。
  • 组合使用:
    • %08.2f:总宽度占 8 列,保留 2 位小数,不足 8 列时在前补 0。

scanf

scanf("格式控制字符串", &变量地址列表);

取地址符 & 的本质

在 C 语言中,函数参数传递默认是值传递(Pass by Value),即函数内部拿到的是参数的副本。

  • 读操作(如 printf):只需将变量当前的值传过去即可。
  • 写操作(如 scanf):函数必须知道变量在内存中的实际物理地址,才能把键盘输入的数据写入该内存。
  • 特例:字符串数组名(如 char name[20];)本身在表达式中就会退化为数组首地址,因此 scanf("%s", name) 不需要加 &。

空白字符的处理机制

  • 读取 %d、%f、%s 时,scanf 会自动忽略/跳过开头的空白字符(空格、Tab、换行符 \n)。
  • 读取 %c 时,scanf 不会跳过任何字符,包括上一次输入残留在缓冲区里的回车换行符 \n。
    • 避坑解法:在 %c 前显式加上一个空格,如 scanf(" %c", &ch);,指示 scanf 先跳过前面的所有空白字符。

scanf 的返回值

  • 返回值含义:scanf 返回一个整数,表示成功匹配并赋值的变量个数。
  • 常见用途:用于校验用户输入是否合法,或在循环中判断输入是否结束。

安全性问题与跨平台解决方案

scanf 在读取字符串(%s)时,不会检查目标数组的边界,如果用户输入的字符串超出了数组长度,会导致严重的缓冲区溢出(Buffer Overflow)安全漏洞。

# 方案 1:使用微软 MSVC 独有的 safe 函数 (限制:跨平台移植性差)
scanf_s("%s", str, (unsigned)sizeof(str));
 
# 方案 2:使用宏关闭 Warning 警告 (推荐用于 VS 环境学习阶段)
# 必须放在源文件 (.c) 的第 1 行处
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
 
# 方案 3 *(拓展)*:使用标准 C 安全限制修饰符 (跨平台标准写法)
scanf("%19s", str); // 限制最多只读取 19 个字符,留 1 个位置给 '\0'

三,流程结构

如果使用GCC,可以通过不同参数手动让编译停在某一阶段。

gcc -E main.c -o main.i    # 1. 只做预处理
gcc -S main.i -o main.s    # 2. 只做编译(生成汇编)
gcc -c main.s -o main.o    # 3. 只做汇编(生成机器码目标文件)
gcc main.o -o main         # 4. 链接(生成可执行文件)

**预处理期(Preprocessing)**产物为.i 预处理文本

纯粹的文本“展开与替换,预处理器(Preprocessor)本质上就是一个高级文本编辑器,它完全不检查 C 语言的语法(即使你宏定义写错了,预处理也会照样替换),只对所有 # 开头的预处理指令进行处理,产物为.i 预处理文本

文件包含(#include):

把被包含的头文件(如 <stdio.h> 或 "my_header.h")里的全部内容复制并粘贴到当前位置。 这就是为什么之前在 CMake 里写 include_directories(include)——就是为了告诉预处理器去哪里找到 #include "xxx.h" 里的头文件!

宏替换(#define):

将代码中的宏名称无条件替换为对应的文本(如 #define MAX 100 或 #define _CRT_SECURE_NO_WARNINGS)。

条件编译(#ifdef / #ifndef):

根据条件裁剪代码(例如 #ifdef WIN32 编译 Windows 代码,#else 编译 Linux 代码)。

清理注释

把代码里所有的 // 和 /* ... */ 注释直接剔除,替换成空格。

**编译期(Compilation)**生成 .s 文件

高级语言→汇编语言,真正的语法剖析 编译器(Compiler,如 GCC、Clang、MSVC 的前端和优化器)在这个阶段接入。这是整个编译流程中最复杂、技术含量最高的一步:

词法分析与语法分析

检查你的代码是否符合 C 语言语法规则(比如少写了 ;,或者 scanf 类型不匹配)。将代码转化为抽象语法树(AST, Abstract Syntax Tree)。

语义分析与类型检查

检查变量是否先声明后使用,函数调用参数类型是否匹配。

代码优化(Optimization)

编译器会做大量死代码消除、常量折叠、循环展开等操作(例如你写 int a = 2 * 3;,编译器直接优化成 int a = 6;)。

生成汇编代码(Assembly)

将高级的 C 逻辑转化为对应 CPU 架构(x86、ARM、RISC-V 等)的汇编指令(生成 .s 文件)。 之前聊 Clang 时提到它基于 LLVM,Clang 就是先生成一种中间代码(LLVM IR),然后再由 LLVM 转换成特定 CPU 的汇编。

汇编期(Assembly)重定位目标文件.obj

人类可读的汇编→计算机识别的二进制(0101)

核心任务

汇编器(Assembler,如 as)把纯文本的汇编指令逐句翻译成 CPU 能够直接执行的机器语言(二进制指令)。

重要概念(符号表): .o 文件已经是二进制文件了,不能直接用文本编辑器打开。 如果在编译时加了 -g 参数,汇编器就会把调试符号表(DWARF 格式)一起打包进 .o 文件中!

产物:生成重定位目标文件(Relocatable Object File),Windows 下为 .obj,Linux/Unix 下为 .o。

链接期(Linking).exe

当你项目中有多个 .c 文件(如 main.c 和 utils.c)时,汇编期结束后会生成 main.o 和 utils.o。此时 main.o 里调用了 printf() 和 my_func(),但 main.o 自己根本不知道这两个函数的具体内存地址在哪! 链接器(Linker,如 ld)就是负责把所有散落的 .o 文件和库文件(.a / .so / .lib / .dll)拼装、焊接成一个完整 exe 的裁缝:

符号解析(Symbol Resolution)

main.o 说:“我需要调用 my_func 函数。” 链接器在 utils.o 里找到了 my_func 的实现,把它们关联起来。如果找不到,就会报你绝对见过的错误:Undefined reference to 'my_func'(未定义的符号引用)。

重定位(Relocation)

把各个 .o 文件里的代码段、数据段合并,分配最终在内存里运行时的绝对地址,并把函数调用的跳转指令修正为真实的内存地址。

静态链接 vs 动态链接

静态链接:把用到的库代码(比如 printf 的实现)直接拷贝一份完整打包进 .exe 中。优点是程序独立运行,缺点是体积巨大。

动态链接:不拷贝代码,只在 .exe 里留一个“存根/标记”。程序运行时才动态加载系统的 .dll(Windows)或 .so(Linux)。优点是体积小、节省内存。

Make 和 CMake 的终极任务,本质上就是控制从【预处理→ 编译→汇编 →链接】的整个自动化流水线,确保每一个 .c 都顺利变成 .o,最后成功链接出 main.exe

四,数据类型

类型分类类型关键字占用内存格式占位符数值范围 / 精度核心特征与适用场景
字符型char1 字节%c-128 ~ 127存储 ASCII 码,本质是 1 字节的小整数
短整型short2 字节%hd-32,768 ~32,767适合对内存极度敏感的数组或嵌入式场景
整型int4 字节%d约为 21 亿最常用的标准整数,CPU 处理效率最高
长整型long4 或 8 字节%ld视系统架构而定较大范围整数(Windows 64 位为 4 字节,Linux 64 位为 8 字节)
超长整型long long8 字节%lld约为9*10^18存放极大的整数(如时间戳、大数运算)
单精度浮点float4 字节%f6~7位有效数字末尾建议加 f(如 3.14f),否则默认被当作 double
双精度浮点double8 字节%lf15~16位有效数字默认的浮点类型,精度更高,避免累积误差

类型修饰符

signed(有符号,默认):最高位用作符号位(0 表示正数,1 表示负数)。

unsigned(无符号):没有负数,所有二进制位全用来表示数值。 • 内存大小不变,但最大正数值扩大一倍(例如:unsigned char 范围为 0~255)。 • 占位符:unsigned int 用 %u,unsigned long 用 %lu。

short / long:用于调整整型变量的内存占用大小。

类型转换

自动类型转换(隐式转换)

当不同类型的数据混合运算时,编译器会自动将小范围 / 低精度类型向大范围 / 高精度类型安全提升(防止精度丢失)

char, short⟶int⟶unsigned⟶long⟶double\text{char, short} \longrightarrow \text{int} \longrightarrow \text{unsigned} \longrightarrow \text{long} \longrightarrow \text{double}

示例:int + double 的结果会自动提升为 double 类型。

强制类型转换(显式转换)

• 语法:(类型名) 表达式 • 规则:强制将浮点型转为整型时,直接截断并丢弃小数部分,非四舍五入

double pi = 3.99;
int a = (int)pi; // a 的值为 3,小数部分被强行砍掉

构造类型

数组

数组是相同数据类型元素的有序集合。

核心特性与内存布局

内存连续:所有元素在内存中紧挨着存放,没有间隙。

数组名即首地址:数组名 arr 在大多数情况下会隐式转换为指向第一个元素的指针(即 &arr[0])。

int arr[5] = {10, 20, 30, 40, 50};
// 内存布局:[10][20][30][40][50]  (每个占 4 字节,连续排列)
  • 越界访问(Undefined Behavior):C 语言不做数组边界检查!访问 arr[5] 不会编译报错,但会读取/写入未知内存,导致程序崩溃或数据损坏。
  • 数组长度必须为常量(非 VLA 限制):在 C99 之前,初始化数组大小不能用变量(如 int n; scanf("%d", &n); int arr[n];)。C99 引入了变长数组(VLA),但工程实践中仍尽量避免。
  • 数组作为函数参数退化:当数组传递给函数时,它会退化为指针,丢失长度信息!
void test(int arr[]) {
    // 这里的 sizeof(arr) 在 64 位系统下是 8(指针大小),而不是整个数组的字节数!
    printf("%zu\n", sizeof(arr)); 
}

结构体

结构体用于将不同类型的数据项组合在一起,用来模拟现实世界中的对象(比如“学生”、“商品”、“网卡配置”)。

声明与使用

// 1. 定义结构体类型
struct Student {
    char name[20]; // 20 字节
    int age;       // 4 字节
    float score;   // 4 字节
};
 
// 2. 声明变量并初始化
struct Student s1 = {"Alice", 18, 92.5f};
 
// 3. 访问成员
s1.age = 19; // 直接访问用 '.'
 
struct Student *p = &s1;
p->age = 20; // 指针访问用 '->' (等价于 (*p).age = 20)

内存对齐

很多人以为 sizeof(struct Student) 就是 20 + 4 + 4 = 28字节,但在实际 CPU 中**可能更大,**因为 CPU 访问对齐的内存效率更高。 对齐 3 大基本原则:

  1. 首成员偏移:第一个成员的偏移量(Offset)永远为 0。
  2. 成员对齐:后续每个成员的起始偏移量,必须是该成员自身大小(或指定对齐数,取较小者)的整数倍。
  3. 整体对齐:结构体的总字节数,必须是最大成员大小(或指定对齐数)的整数倍。 对齐案例分析:
struct Example {
    char a;   // 1 字节 (偏移 0)
    int b;    // 4 字节 (偏移必须是 4 的倍数 -> 填充 3 字节,在偏移 4 处存放)
    short c;  // 2 字节 (在偏移 8 处存放)
};
// 此时占用:1(a) + 3(填充) + 4(b) + 2(c) = 10 字节
// 整体对齐:最大成员 int 为 4 字节,总大小必须是 4 的倍数 -> 补到 12 字节!
// 结果:sizeof(struct Example) == 12

共用体 / 联合体

共用体也是由不同类型的成员组成,但所有成员共享同一块内存空间。任何时刻,只有一个成员是有效的。

核心特性与内存布局

  • 内存共享:共用体的大小至少等于最大成员的大小(同样需满足对齐要求)。
  • 写入覆盖:给其中一个成员赋值,会直接覆盖其他成员的值。
union Data {
    int i;      // 4 字节
    float f;    // 4 字节
    char str[9];// 9 字节 -> 考虑到 int 对齐,总大小补齐为 12 字节
};
 
union Data data;
data.i = 10;
data.f = 220.5f; // 此时 data.i 的二进制数据已经被改写,不再是 10 了!

笔试常考题:用共用体验证当前机器是小端存储(Low-byte at Low-address)还是大端存储

#include <stdio.h>
 
int checkEndian() {
    union {
        int i;
        char c;
    } u;
    u.i = 1; 
    return u.c; // 读取最低地址的字节:若为 1 则是小端,若为 0 则是大端
}
 
int main() {
    if (checkEndian()) {
        printf("小端架构 (Little-Endian)\n"); // x86 / ARM 常见
    } else {
        printf("大端架构 (Big-Endian)\n");
    }
    return 0;
}

小端存储

地址低地址⟶⟶高地址
存储字节0x040x030x020x01

*常见架构:Intel/AMD (x86/x64)、大多数 ARM 处理器、现代苹果 Apple Silicon*

大端存储,符合人类阅读习惯

地址低地址⟶⟶高地址
存储字节0x010x020x030x04

*常见架构:网络传输协议(TCP/IP 网络字节序)、部分老旧服务器 CPU(如 IBM PowerPC、SPARC)。*

int checkEndian() {
    int a = 1;
    // 取 a 的地址,并强制转换为 char* 指针
    // 这样 *p 就只会读取 a 内存里的第一个字节(低地址)
    char *p = (char *)&a; 
    
    return *p; // 结果为 1 即小端,0 即大端
}

枚举

枚举用于定义一组有名称的整数常量,增加代码的可读性和可维护性,避免出现“魔鬼数字(Magic Numbers)”。

enum Weekday {
    SUN,      // 默认值为 0
    MON,      // 默认值为 1
    TUE,      // 默认值为 2
    WED = 10, // 显式指定值为 10
    THU,      // 递增为 11
    FRI,      // 12
    SAT       // 13
};
 
enum Weekday today = WED;
printf("%d\n", today); // 输出 10

与#define的区别

对比维度enum 枚举#define 宏
发生阶段编译期(有类型检查)预处理期(纯文本替换,无类型检查)
调试支持调试器(GDB)中可以看到枚举名称预处理后宏名已被替换,调试时看不到
作用域遵循 C 语言作用域规则(局部/全局)无作用域限制,直到文件末尾或被 #undef 取消
内存与值本质是 int 类型的常量没有任何内存分配,仅在预处理阶段展开

五,运算符表达式

优先级与结合性

优先级运算符类别代表运算符结合性核心说明 / 避坑点
1作用域与成员访问::、.、->、[]、()从左到右优先级最高,用于定位与访问
2单目运算符++ / --(前缀)、!、~、sizeof、*(解引用)、&(取地址)从右到左注意前缀自增和解引用的优先级同级
3算术(乘除模)*、/、%从左到右高于加减算术
4算术(加减)+、-从左到右—
5移位运算符<<、>>从左到右优先级低于算术运算符,如 1 + 2 << 3 等价于 (1 + 2) << 3
6关系运算符<、<=、>、>=从左到右高于相等判断
7相等运算符==、!=从Left到Right高于位运算符
8位运算符& 高于 ^ 高于 ``从左到右
9逻辑运算符&& 高于 ``
10条件运算符? :从右到左唯一的三目运算符
11赋值运算符=、+=、-=、&= 等从右到左优先级极低,支持链式赋值 a = b = c
12逗号运算符,从左到右整个表达式的值为最后一个子表达式的值

前缀后缀自运算

对比维度前缀自增(++i)后缀自增(i++)
计算顺序先将变量加 1,再返回修改后的变量先保存旧值的副本,变量加 1,再返回旧值副本
返回值类型左值(Lvalue),可直接赋值(如 ++i = 5)右值(Rvalue),为临时对象,不可赋左值
性能表现无额外开销,直接在原对象上递增有额外开销,需创建临时副本并调用拷贝构造函数
自定义类重载Type& operator++();Type operator++(int);(带占位参数 int)
最佳实践在循环或迭代器使用中优先选择仅在确需使用递增前旧值时使用

逻辑运算符与短路求值

逻辑表达式触发短路求值的条件短路后的行为典型安全应用场景
A && BA 计算结果为 false不执行 B,直接返回 false空指针防崩:if (ptr != nullptr && ptr->isValid())
`AB`A 计算结果为 true

位运算

位运算符 / 技巧表达式名称 / 功能原理解释 / 作用
a & b / `ab/a ^ b`按位与 / 按位或 / 按位异或
~a按位取反按位翻转所有 0 和 1
a << k / a >> k左移 / 右移左移相当于乘以 2^k,右移相当于除以 2^k
x & 1判断奇偶取二进制最低位:结果为 1 为奇数,0 为偶数
x & (x - 1)清空最低位的 1将 x 最右边的 1 变为 0(常用于统计二进制中 1 的个数或判断是否为 2 的幂)
x & (-x)提取最低位的 1(Lowbit)保留最右边的 1,其余全置 0(树状数组的核心操作)
a ^= b; b ^= a; a ^= b;无额外空间交换两数利用异或的消消乐性质(注意:a 与 b 不能指向同一内存地址)

六,指针与数组

指针的基本概念

核心操作与语法

  • 声明与取地址:int* p = &a;(p 存的是 a 的首地址)。
  • 解引用:p(沿着地址找到对应的内存块进行读写)。
  • 空指针:现代 C++ 统一用 nullptr,不要再用 NULL 或 0。nullptr 具备强类型(std::nullptr_t),能避免重载函数调用时的歧义。

算数运算内存步长

指针加减不是数值加减,它的单位是指向类型的大小(sizeof(T)):

int a = 10;
int* p = &a; // 假设 p 的地址是 0x1000
 
p + 1; // 实际地址是 0x1004(因为 sizeof(int) == 4)

野指针与悬空指针

  • 野指针:未初始化的指针(如 int* p;),里面是随机垃圾地址。
  • 悬空指针:指向的内存已经被 delete 或退出了作用域,但指针没清空。

工程规范:声明指针时顺手初始化(如 = nullptr);delete 动态内存后,立刻把指针置为 nullptr。

一维数组

数组是内存中物理连续存储的同类型元素集合。

下标访问的底层本质

arr[i]  <===>  *(arr + i)

因为交换加法顺序列为 *(i + arr),所以在语法上甚至连 i[arr] 都能编译通过

退化机制

数组名在绝大多数表达式中都会自动退化为指向首元素的指针

  • 例外 1:sizeof(arr) —— 返回整个数组占用的总字节数,不退化。
  • 例外 2:&arr —— 取的是整个数组的地址(类型为数组指针 int(*)[N]),虽然数值和 arr 相同,但指针步长完全不同!

当把数组作为参数传进函数时,它会彻底退化为普通指针,长度信息会完全丢失

void printArray(int arr[]) { // 这里的 arr 等价于 int* arr
    std::cout << sizeof(arr); // 输出的是指针大小(64位系统下是 8 字节),而不是数组大小!
}

指针数组与数组指针

对比维度指针数组(Array of Pointers)数组指针(Pointer to an Array)
定义语法int* ptr_arr[5];int (*arr_ptr)[5];
本质属性本质是一个数组,里面保存的是指针本质是一个指针,指向一个固定大小的数组
运算符优先级解析[] 优先级高于 *,先结合组成数组,再与 int* 结合,即 (int*) ptr_arr[5]有括号 (*arr_ptr) 强制优先,先确定是一个指针,再指向 int[5]
内存结构 / 布局在内存中连续开辟 5 个指针空间,每个元素存一个地址在内存中只占用 1 个指针空间(存整个数组的首地址)
指针步长(+1 操作)ptr_arr + 1 移动 一个指针的大小(64位下为 8 字节)arr_ptr + 1 移动 整个数组的大小(即 5 * sizeof(int) = 20 字节)
典型应用场景1. 存储不规则字符串列表(如 const char* menu[]);2. 存储多个动态分配对象的指针1. 二维数组传参(如 void func(int (*p)[5]));2. 指向连续多维矩阵的指针操作
初始化示例int a=1, b=2; int* ptr_arr[2] = {&a, &b};int arr[5] = {1, 2, 3, 4, 5}; int (*arr_ptr)[5] = &arr;

多维数组

二维数组在逻辑上是“网格”,但在物理内存上完全是一维连续的

内存映射公式

对于声明 int arr[M][N],元素 arr[i][j] 的内存地址推导公式为:

Address(arr[i][j])=BaseAddress+(i×N+j)×sizeof(int)\text{Address}(arr[i][j]) = \text{BaseAddress} + (i \times N + j) \times \text{sizeof(int)}

这解释了为什么二维数组传参时,第二维(列数 N)绝对不能省略:如果不给 N,编译器就无法计算 i * N,也就根本找不到对应的内存地址

函数传递二维数组

1.语法直接写明列数(直观)

void process(int arr[][4], int rows); // 必须填入列数 4

2.使用数组指针形参

void process(int (*arr)[4], int rows); // 类型严格匹配

3.扁平化一维指针传递(游戏引擎 / 图形学中常用)

// 把二维内存当一维处理,手动计算偏移,性能高且最灵活
void process(int* arr, int rows, int cols) {
    int val = arr[i * cols + j]; 
}

七,函数

相关原理

当一个函数被调用时,操作系统和 CPU 会为它分配一块独立的内存空间,称为栈帧(Stack Frame)。

栈帧的生命周期

每当调用一个函数,操作系统就会在栈区(Stack)为该函数分配一块私有领地(栈帧)。

  • 入栈(Push):保存调用者(Caller)的返回地址、函数实参、寄存器上下文以及当前函数的局部变量。
  • 出栈(Pop):函数执行完毕后返回,恢复之前的上下文,销毁局部变量,释放栈帧空间。

避坑要点:严禁返回指向局部变量的指针或引用。因为函数执行结束后,其栈帧已被销毁,对应的内存已失效,访问该内存会导致未定义行为(Undefined Behavior)。

参数传递的本质

参数传递本质上是向新栈帧中灌入数据的过程:

  • 值传递:在被调函数的栈帧里复制一份副本。对于大结构体或对象,会产生昂贵的拷贝开销。
  • 指针/引用传递:仅传递 8 字节的内存地址(64位系统)。无拷贝开销,且能实现被调函数对外部数据的修改(双向通信)。

inline 与 Lambda 表达式

普通函数调用存在压栈、跳转、恢复栈帧的开销。对于微型逻辑,如果在循环里调用一百万次,保护现场、跳转、建立栈帧和恢复栈帧的时间,可能比函数体本身执行的时间还要长。C++ 提供了两种消除/控制开销的机制:

  • 内联函数(inline):
    • 原理:编译器会在编译阶段(而不是运行阶段),直接将函数体“拷贝”到每一个调用点,从而消除函数调用的跳转与栈帧开销。
    • 软建议:只是给编译器的建议(包含循环、递归或代码量过大时编译器会拒绝内联);定义必须放在头文件中,以便编译期直接替换。
    • **代码膨胀隐患:**如果在一个大函数前加了 inline,且被调用了几千次,会导致编译出来的二进制文件体积急剧变大,反而可能降低 CPU 指令缓存(I-Cache)的命中率,导致性能下降。
  • Lambda 匿名函数(C++11): 为了解决局部逻辑散乱和需要带状态(Context/State)的回调函数编写繁琐的问题。

在 C++11 之前,如果想给 std::sort 传一个自定义比较规则,或者给异步接口传一个回调,你必须在类外单独写一个普通函数或手写一个复杂的仿函数类(Functor)。Lambda 允许你在调用的地方就地定义函数,极大地提高了代码的高内聚性。

[capture] (parameters) mutable -> return_type { body }

[capture] 捕获列表:

  • []:不捕获任何外部变量(退化为普通函数指针)。
  • [=]:按值捕获外部作用域的所有变量。内部得到的是一份副本(只读,除非加上 mutable)。
  • [&]:按引用捕获外部作用域的所有变量。内部修改会直接改变外部变量。
  • [x, &y]:混合捕获,x 按值拷贝,y 按引用传递。
  • [this]:捕获当前类的 this 指针,可以在 Lambda 内部访问类的成员变量和成员函数。
  • [=, &b]:意思是“默认全按值复印,但唯独 b 要拉引用线”。

举个例子,假设手里有一组数字 {3, 1, 4, 2},想把它们从大到小排序。

传统做法只能跑到很远的地方(比如文件开头或类外面),专门写一个比较函数,再把它传进来。

// 1. 先在别的地方专门定义一个比较函数(起个名字叫 compare)
bool compare(int a, int b) {
    return a > b;
}
 
int main() {
    vector<int> nums = {3, 1, 4, 2};
    // 2. 把这个函数名传给 sort
    sort(nums.begin(), nums.end(), compare); 
}

而使用lambda表达式,直接在 sort 括号里面写你的排序规则,不需要起名字,也不用跑去别的地方写

int main() {
    vector<int> nums = {3, 1, 4, 2};
    
    // 直接把“怎么排”写在 sort 的括号里
    sort(nums.begin(), nums.end(), [](int a, int b) {
        return a > b; // 从大到小排
    });
}

对于捕获列表,可举例:

int main() {
    int limit = 10; // 外面的变量:上限是 10
    
    // 用 [&] 口袋,把外面的 limit 抓进来用
    auto check = [&] (int x) {
        return x > limit; // 判断 x 是否超过了外面的 limit
    };
    
    cout << check(15); // 输出 1 (true),因为 15 > 10
}

状态

函数在 C/C++ 程序中的生命周期通常对应以下 三种状态(或阶段)

声明(Declaration)/ 前置声明

告诉编译器函数的名称、返回值类型以及参数列表(函数签名),但不提供具体的实现体。

定义(Definition)

提供函数的具体实现(即包含花括号 { ... } 的函数体)。

调用(Call)

在程序执行流程中使用该函数,传入实参并接收(或忽略)返回值。

组成

函数头

返回值类型(Return Type)

函数执行完毕后返回给调用者的具体类型(若无返回值则用 void)。

函数名(Function Name)

标识函数的唯一标识符(遵守标识符命名规范)。

形参列表(Parameter List)

用小括号 () 包裹,说明函数接收的入参类型和变量名。

函数体

用大括号 {} 包裹的代码块,包含具体的逻辑处理以及 return 语句。

// 返回值类型  函数名     形参列表
int          Add(int a, int b) 
{ 
    // 函数体
    return a + b; 
}

前置声明

编译器在解析 C/C++ 源码时是 从上到下单向单趟(Single-pass)扫描 的:

  1. 解决“未声明即使用”的编译错误:若函数 B 在 main() 或函数 A 之后才定义,而 main() 中调用了函数 B,编译器在扫描到调用处时无法预先得知 B 的参数类型与返回值,从而抛出错误。
  2. 解决循环依赖(Circular Dependency):若函数 A 调用函数 B,同时函数 B 也调用函数 A,单纯靠调整定义顺序无法同时满足两者,必须至少对其中一个进行前置声明。
  3. 优化编译速度与解耦(针对头文件):将声明放在 .h 文件中,实现放在 .cpp 中,使得其他模块仅需包含头文件即可完成编译与符号检查,无需暴露具体实现细节,也能避免重复编译。

最佳顺序

先声明,后调用,再定义

  • 前置声明区(顶层):写在文件开头(或通过 .h 头文件引入)。
  • 主逻辑/调用区(中层):如 main() 函数或其他核心业务函数,便于代码阅读者快速抓取核心业务流程。
  • 具体定义区(底层):将函数的具体代码实现统一放在文件末尾。
#include <iostream>
 
// 1. 前置声明
int CalculateDamage(int baseAttack, float multiplier);
 
// 2. 核心调用逻辑 (如 main 函数或引擎入口)
int main() {
    int total = CalculateDamage(100, 1.5f);
    std::cout << "Damage: " << total << std::endl;
    return 0;
}
 
// 3. 具体实现定义
int CalculateDamage(int baseAttack, float multiplier) {
    return static_cast<int>(baseAttack * multiplier);
}

函数指针

在 C/C++ 中,代码本身也是保存在内存中的(代码段 .text)。函数名本质上就是一个指向该函数入口地址的常量指针。函数指针就是专门用来存储函数入口地址的变量。

语法与声明

函数指针的声明格式必须与目标函数的返回值类型和参数列表严格匹配:

// 普通函数定义
int Add(int a, int b) { return a + b; }
 
// 声明一个函数指针变量 pFunc
// 语法:返回值类型 (*指针变量名)(参数列表)
int (*pFunc)(int, int) = nullptr;
 
// 赋值与调用
pFunc = Add;             // 或 pFunc = &Add; (两者等价)
int result = pFunc(3, 5); // 或 (*pFunc)(3, 5);

但可读性安全性差,所以现阶段C++重构以下方式替代:

using / typedef 别名

using ActionHandler = int(*)(int, int);
ActionHandler handler = Add;

std::function + std::bind / Lambda(C++11)

可以统一封装函数指针、Lambda 表达式、成员函数以及模仿函数的类(Functor)。

#include <functional>
 
// 统一的函数容器
std::function<int(int, int)> op = Add;
op = [](int a, int b) { return a * b; }; // 随时切换为 Lambda

函数递归

递归是函数在执行过程中直接或间接调用自身的编程技巧。它将大问题分解为性质完全相同的子问题。

必要条件

写递归时必须满足以下条件,否则会导致栈溢出(Stack Overflow)

  • 终止条件(Base Case):明确什么时候停止递归,直接返回确定结果。 递归推进(Recursive Step):每次调用自身时,传入的参数必须逐步逼近终止条件。

重构与优化策略

尾递归(Tail Recursion)

若递归调用是函数的最后一个操作(返回值不包含其他运算),编译器可以开启尾调用优化(Tail Call Optimization, TCO),复用当前栈帧,将空间复杂度从 O(n) 降为 O(1)。

// 尾递归形式的阶乘(累加器传递中间结果)
int FactorialTail(int n, int acc = 1) {
    if (n <= 1) return acc;
    return FactorialTail(n - 1, n * acc); // 递归调用后无追加乘法
}

记忆化搜索(Memoization)

解决重复子问题(如斐波那契数列)。使用数组或哈希表将已计算的结果缓存起来,将指数级复杂度 O(2n)O(2^n) 降低到线性复杂度 O(n)O(n)。

改为迭代(循环)或手写显式栈

在对性能要求极高或深度不可控的工程场景(如游戏场景树遍历、复杂 GUI 节点渲染)中,为了绝对避免栈溢出,通常将递归代码重构为:

  • while / for 循环迭代。
  • 使用 std::vector 或 std::stack 在堆区模拟栈操作。

堆栈内存

堆栈内存(Stack & Heap Memory)是 C/C++ 内存管理的核心,也是底层开发、内存优化以及解决崩溃问题(如段错误、内存泄漏、栈溢出)的基石。

特性栈内存(Stack)堆内存(Heap)
管理方式编译器/操作系统自动分配与释放程序员显式手动申请与释放
分配速度极快(CPU 移动栈指针即可)相对较慢(需寻找合适大小的内存块)
空间大小非常有限(通常预设为 1MB ~ 8MB)非常大(取决于虚拟内存和物理内存大小)
生长方向由高地址向低地址向下生长由低地址向高地址向上生长
碎片问题无碎片(连续严格压栈/出栈)频繁申请释放会导致严重的外碎片
数据生存期绑定于所在的函数作用域/栈帧跨函数/全局生存,直到显式释放(或程序结束)

栈内存

栈的底层实现完全依托于硬件架构(CPU 的 RSP / ESP 栈指针寄存器)每当发生一次函数调用:

  • 压栈(Push):编译器为该函数建立栈帧(Stack Frame),存放函数形参、局部变量、返回地址等。
  • 出栈(Pop):函数执行完毕退栈,栈指针移动,原内存自动失效。
void ProcessPoint() {
    int x = 10;        // 栈上分配 4 字节
    float arr[100];    // 栈上分配 400 字节
} // 函数结束,x 和 arr 的内存自动被回收

栈溢出

深层递归没有终止条件,或者在栈上定义了超大数组(如 int buffer[1000000])。

重构方案:大型数组或结构体改用堆内存(std::vector / std::unique_ptr);递归改用迭代或显示栈。

悬挂指针与野指针

int* GetDanger() {
    int temp = 42;
    return &temp; // 错误!temp 离开函数后栈帧已销毁,返回的指针指向无效内存
}

堆内存

堆是一块巨大的全局内存池。在 C/C++ 中,我们通过标准库/操作系统接口向内存管理器申请不同大小的内存。

  • C 风格:malloc() / free()(仅分配/释放裸内存,不调用构造/析构函数)。
  • C++ 风格:new / delete(分配内存 + 调用构造函数 / 释放内存 + 调用析构函数)。

痛点

  • 内存泄漏(Memory Leak):申请了内存却没有 delete。如果在游戏主循环(Tick)中每帧泄漏一点内存,运行一段时间后游戏就会卡死崩溃。
  • 内存碎片化(Memory Fragmentation):频繁申请和释放不固定大小的小对象,会导致堆内存被切割得零零碎碎。即使总剩余内存很大,也可能因为找不到足够大的连续空间而分配失败。
  • 分配开销:malloc 内部涉及锁竞争、内存块搜索,甚至跨内核态的页分配,性能显著慢于栈分配。

局部变量与全局变量

特性局部变量(Local)全局变量(Global)
定义位置函数内部或代码块 {} 内部所有函数外部
作用域仅限于当前 {} 内部整个文件(用 extern 甚至能跨文件)
生命周期运行到定义处创建,出了大括号自动销毁程序启动时创建,退出时销毁
存放位置栈区(Stack)全局/静态存储区(Data/BSS段)
默认初始值未定义(随机垃圾值,必须手动初始化)自动初始化为 0 或 nullptr
内存区域谁住在里面?谁负责管理它?生命周期
栈区(Stack)函数形参、局部变量编译器自动分配和释放出大括号 {} 即销毁
堆区(Heap)new / malloc 出来的对象程序员手动控制(或智能指针代管)手动 delete 或程序结束
全局/静态区全局变量、static 变量操作系统/编译器统一处理程序启动到程序结束
代码区/常量区CPU 执行的代码指令、字符串常量操作系统(只读,防止被修改)程序启动到程序结束

typedef类型别名

由编译器处理,用于为已存在的的数据类型(包括结构体、指针等)创建一个新的名字,具有类型检查功能。

#define宏定义

由预处理器处理,纯粹在编译前进行文本替换,不进行类型检查,容易引发优先级陷阱。

typedef int* INT_PTR; // INT_PTR 是 int* 的别名
#define STR_PTR char*  // STR_PTR 在预处理时被替换为 char*
 
INT_PTR p1, p2;        // p1 和 p2 都是 int*(指针)
STR_PTR s1, s2;        // 替换后变成 char* s1, s2; -> s1 是指针,s2 变成了 char 字符!

extern外部声明

用于告诉编译器:“这个变量或函数在别的文件中已经定义了,你去别处找它,这里只是引用它。”不分配内存,只做声明。

static静态修饰

修饰局部变量:延长局部变量的生命周期至整个程序运行期间,但作用域仍限制在函数内部,且只初始化一次。全局变量或者函数被static修饰没就变成了内部链接,只能在当前源文件访问,其他文件用extern也做不到,实际应用可以防止不同文件的命名冲突。

作用域与生命周期

作用域指变量在哪里可见,生命周期指变量活多久。

作用域分类

  • 代码块作用域(局部作用域):

    在大括号 {} 内部定义的变量(比如函数内部的变量、if 或 for 循环内部的变量)。

    可见性:只能在当前 {} 内部访问,出了大括号就彻底失效。

  • 文件作用域(全局作用域):

    在所有函数外部定义的变量。

    可见性:从定义的那一行开始,到整个源文件结束。默认情况下,其他源文件也能通过 extern 访问它。

  • 函数原型作用域:

    只在函数声明的参数列表中有效(例如 int func(int a, int b); 里的 a 和 b),出了这个声明就没用了,很少关注。

  • 函数作用域:

    仅适用于 goto 语句的标号(Label),标号在整个函数内部任何地方都可见。

生命周期分类

变量在内存中存放的位置决定了它的生命周期

  • 自动存储期(栈区):

    普通的局部变量。

    进入作用域时分配内存,离开作用域时自动释放。

  • 静态存储期(静态/全局区):

    全局变量、以及加了 static 修饰的局部变量。

    在程序刚运行(main 函数执行前)时就分配内存,直到整个程序结束才释放。

  • 动态存储期(堆区):

    通过 malloc、calloc 等手动申请的内存,直到你用 free 释放或程序结束。

文件处理

打开与关闭

在 C 语言中,操作任何文件都必须通过一个指向 FILE 类型的指针(即 FILE *)来完成

fopen

打开文件

FILE *fp = fopen("filename.txt", "r");
  • 第一个参数:文件名(可以是相对路径或绝对路径)。
  • 第二个参数:打开模式(这是最容易掉坑的地方):
    • "r":只读。文件必须存在,否则返回 NULL。
    • "w":只写。如果文件不存在会新建;如果文件已存在,会直接清空(截断)原文件内容!
    • "a":追加。在文件末尾写数据,文件不存在会新建。
    • 在后面加一个 b(如 "rb", "wb", "ab"),代表以二进制模式(Binary)打开。在 Windows 下非常重要,能避免换行符 \n 和 \r\n 的自动转换。

每次用完 fopen 后,必须检查指针是否为 NULL!如果文件打不开却直接操作,程序会直接段错误(Segmentation Fault)崩溃。

FILE *fp = fopen("save.dat", "rb");
if (fp == NULL) {
    perror("打开文件失败啦");
    return 1;
}

fclose

关闭文件

用完之后必须 fclose(fp);,否则会造成内存泄漏和文件句柄未释放(缓冲区的数据可能无法正常写入磁盘)。

读写

级别读函数写函数适用场景
字符级fgetc(fp)fputc(ch, fp)逐个字符处理(如统计文件字符数)
文本行级fgets(buf, size, fp)fputs(str, fp)按行读取文本(如读取配置文件、文本日志)
格式化fscanf(fp, ...)fprintf(fp, ...)像 printf/scanf 一样按格式读写结构化文本
二进制块fread(ptr, size, count, fp)fwrite(ptr, size, count, fp)最强大!直接读写内存块(游戏存档、结构体序列化)

fread/fwrite

size_t fread(void *ptr, size_t size, size_t count, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t count, FILE *stream);
参数fread 的含义(读)fwrite 的含义(写)
void *ptr存放读出数据的内存缓冲区地址(收纳箱)要写入数据的源数据内存地址(货源)
size_t size单个元素的大小(字节数,用 sizeof())单个元素的大小(字节数,用 sizeof())
size_t count准备连续读取的元素个数准备连续写入的元素个数
FILE *stream已打开的文件指针 (FILE*)已打开的文件指针 (FILE*)

文件指针定位

文件内部有一个隐式的文件位置指针,每次读写后它都会自动往后移动。如果想随意跳到指定位置读写,就要靠这两个函数:

ftell(fp)

返回当前文件光标距离文件开头的字节偏移量。常用来计算文件总大小。

fseek(fp, offset, origin)

  • 移动文件光标。
  • origin(起始参考点)有三个宏:
    • SEEK_SET:文件开头。
    • SEEK_CUR:当前位置。
    • SEEK_END:文件末尾。
fseek(fp, 0, SEEK_END); // 光标移到末尾
long size = ftell(fp);  // 获取当前偏移量,即文件总字节数

npm

feof

File End Of File

它是一个布尔判断函数(返回真或假),用来检查文件指针是否已经到达了文件末尾(EOF)

只有当已经进行了一次读取操作,并且这次读取不幸撞上了文件末尾,feof() 才会慢半拍地把状态改成到末尾了。

通过邮件交流

相关推荐