本系列面向 C/C++ 后端方向,系统梳理五大核心知识领域:C语言、C++、数据结构、Linux 操作系统、Git。每个领域独立成篇,内容追求体系化、细节化,从原理到代码再到易错点全覆盖,适合作为系统学习的完整笔记。
系列总览
| 篇目 | 领域 | 章节数 | 状态 |
|---|---|---|---|
| 第01篇 | C语言 | 12章 | 本文 |
| 第02篇 | C++(特性/面向对象/内存/STL/并发) | 待定 | 后续生成 |
| 第03篇 | 数据结构与算法 | 待定 | 后续生成 |
| 第04篇 | Linux 操作系统 | 待定 | 后续生成 |
| 第05篇 | Git 版本控制 | 待定 | 后续生成 |
每篇内部按"章节"组织,力求一章吃透一个主题。学习时建议边读边动手敲代码,光看不练等于白学。
C语言篇
第1章 程序结构与编译流程
1.1 一个最基本的 C 程序
cpp
#include <stdio.h> // 头文件:引入标准输入输出库的声明
// main 是程序入口,操作系统从这里开始执行
int main(void) {
printf("Hello, C!\n"); // 输出一行文本,\n 是换行符
return 0; // 0 表示正常退出
}
要点拆解:
#include <stdio.h>:预处理指令,把 stdio.h 的内容"复制"进来。<...>表示在系统头文件目录找,"..."表示先在当前目录找。main函数:程序入口。标准写法返回int,return 0表示成功,非 0 表示出错码。printf来自 stdio.h,用于格式化输出。
1.2 编译的四个阶段
从 .c 源文件到可执行文件,要经过四个阶段。这是面试高频考点。
| 阶段 | 命令 | 产物 | 做什么 |
|---|---|---|---|
| 预处理 | gcc -E a.c -o a.i |
.i |
宏展开、头文件展开、条件编译、删除注释 |
| 编译 | gcc -S a.i -o a.s |
.s |
词法/语法/语义分析,生成汇编代码 |
| 汇编 | gcc -c a.s -o a.o |
.o |
汇编代码翻译成机器指令(可重定位目标文件) |
| 链接 | gcc a.o -o a |
可执行文件 | 符号解析 + 重定位,合并目标文件与库 |
# 一条命令直接到可执行文件
gcc a.c -o a && ./a
# 或者分步执行看中间产物
gcc -E a.c # 只看预处理结果
gcc -S a.c # 生成汇编
gcc -c a.c # 生成目标文件 .o
常见问题:
- 链接阶段报
undefined reference to 'xxx':函数声明了但没定义,或者库没链接上(-lm数学库等)。 - 编译阶段报语法错误:代码写错。
- 预处理阶段能看到所有宏展开后的样子,是排查宏问题的利器。
1.3 main 函数的两种标准写法
cpp
// 无命令行参数
int main(void) { ... }
// 带命令行参数
int main(int argc, char *argv[]) { ... }
// argc: 参数个数(含程序名本身)
// argv: 参数字符串数组,argv[0] 是程序名
cpp
#include <stdio.h>
int main(int argc, char *argv[]) {
for (int i = 0; i < argc; i++)
printf("argv[%d] = %s\n", i, argv[i]);
return 0;
}
易错点 :argv[argc] 保证为 NULL,可用来安全遍历。
1.4 注释
// 单行注释
/*
* 多行注释
* 可以跨行
*/
C99 起支持
//行注释。多行注释不能嵌套 (/* /* */ */会提前结束)。
第2章 数据类型与变量
2.1 基本数据类型一览
| 类型 | 大小(64位常见) | 取值范围(大致) |
|---|---|---|
char |
1 字节 | -128 ~ 127 |
unsigned char |
1 字节 | 0 ~ 255 |
short |
2 字节 | -32768 ~ 32767 |
int |
4 字节 | -2^31 ~ 2^31-1 |
unsigned int |
4 字节 | 0 ~ 2^32-1 |
long |
Linux64位下8字节 | 取决于平台 |
long long |
8 字节 | -2^63 ~ 2^63-1 |
float |
4 字节 | 约 ±3.4e38(7位有效数字) |
double |
8 字节 | 约 ±1.8e308(15位有效数字) |
关键认知:
sizeof(char)恒等于 1,这是标准保证的。int大小不保证 4 字节,标准只要求至少 16 位。现代平台基本是 4 字节。- 判断类型大小时,不要靠猜,用
sizeof在目标平台实测。
cpp
#include <stdio.h>
int main(void) {
printf("char: %zu\n", sizeof(char));
printf("int: %zu\n", sizeof(int));
printf("long: %zu\n", sizeof(long));
printf("double: %zu\n", sizeof(double));
return 0;
}
2.2 signed 与 unsigned
signed:有符号,最高位是符号位。unsigned:无符号,全部位表示数值,正数范围翻倍。
经典坑 :有符号与无符号混用会发生隐式转换(有符号转无符号),导致负数变成巨大正数。
cpp
#include <stdio.h>
int main(void) {
int a = -1;
unsigned int b = 1;
if (a < b)
printf("a < b\n"); // 你以为会打印这个
else
printf("a > b\n"); // 实际打印这个!
return 0;
}
-1 转成 unsigned 变成 4294967295,所以
a < b为假。记住:有符号和无符号比较时,有符号会被转成无符号。
2.3 常量
cpp
#define MAX 100 // 宏常量(预处理阶段替换,无类型)
const int N = 10; // const 常量(编译期,有类型,有作用域)
// 字面量
int a = 100; // 十进制
int b = 0x64; // 十六进制(0x 前缀)
int c = 0144; // 八进制(0 前缀)
long d = 100L; // long 后缀
float f = 3.14f; // float 后缀
double g = 3.14; // double 默认
char ch = 'A'; // 字符字面量
const vs #define 对比:
| 维度 | #define | const |
|---|---|---|
| 阶段 | 预处理文本替换 | 编译期 |
| 类型检查 | 无 | 有 |
| 作用域 | 全局(无作用域) | 有作用域 |
| 内存占用 | 不占数据内存(展开多份) | 占内存 |
| 调试 | 看不到(已替换) | 可被调试器跟踪 |
现代 C 代码推荐优先用 const + enum 代替裸宏常量(除了需要条件编译的场景)。
2.4 变量作用域与生命周期
cpp
int global = 1; // 全局变量:作用域整个文件,生命周期整个程序
static int sg = 2; // 静态全局变量:仅本文件可见
void f(void) {
int local = 3; // 局部变量:作用域函数内,生命周期函数调用期间
static int cnt = 0; // 静态局部变量:只初始化一次,跨多次调用保留值
cnt++;
}
生命周期小结:
| 变量类型 | 存储位置 | 初始化时机 | 释放时机 |
|---|---|---|---|
| 全局变量 | 数据段 | 程序启动前 | 程序结束 |
| static 局部变量 | 数据段 | 第一次执行到该行 | 程序结束 |
| 普通局部变量 | 栈 | 每次进入函数 | 函数返回 |
| malloc 分配 | 堆 | malloc 调用时 | free 调用时 |
2.5 类型转换
隐式转换(自动发生,可能带来精度损失):
cpp
int i = 3;
double d = i; // int → double,安全拓宽
double x = 3.7;
int j = x; // double → int,截断成 3(小数部分丢失)
显式转换(强制类型转换,程序员自己负责):
cpp
int a = 5, b = 2;
double r = (double)a / b; // 5.0 / 2 = 2.5
double r2 = a / b; // 5 / 2 = 2(整数除法!)
整数除法是初学者第一大坑:两个 int 相除结果仍是 int,小数部分被丢弃。
整数提升:char、short 参与运算时先提升为 int。
cpp
char a = 200; // 超出范围?200 存不下 char(范围-128~127)
unsigned char c = 200; // 这样才行
char b = a + 1; // 可能溢出成负数
第3章 运算符与表达式
3.1 运算符分类总表
| 类别 | 运算符 |
|---|---|
| 算术 | + - * / % |
| 关系 | > < >= <= == != |
| 逻辑 | `&& |
| 位运算 | `& |
| 赋值 | `= += -= *= /= %= <<= >>= &= |
| 自增自减 | ++ -- |
| 三目 | ?: |
| 逗号 | , |
| 取地址/解引用 | & * |
| 其他 | sizeof ( ) [ ] -> . |
3.2 逻辑运算的短路求值 ★★★
cpp
if (p != NULL && p->val > 10) { ... } // p 为空时不会执行右侧
if (a != 0 || b / a > 2) { ... } // a 为 0 时不会执行右侧
&&:左边为假,右边不执行。||:左边为真,右边不执行。
面试官爱问的坑:
cpp
int a = 0;
if (a && (a = 5)) ; // a=5 不会执行,a 仍为 0
int b = 1;
if (b || (b = 9)) ; // b=9 不会执行,b 仍为 1
3.3 自增自减的前缀与后缀 ★★
cpp
int i = 5;
int a = i++; // a = 5, i = 6(先用后加)
int b = ++i; // i 先变 7, b = 7(先加后用)
一个句子区分 :i++ 表达式的值是旧值,++i 表达式的值是新值。
未定义行为警告:同一表达式里对同一变量多次修改是 UB。
cppint x = 1; int y = x++ + x++; // 未定义行为!结果不可预测标准规定:在两个序列点之间,一个变量的值最多只能被修改一次。面试/工作里避免写这种代码。
3.4 位运算基础
| 运算符 | 含义 | 例子 |
|---|---|---|
& |
按位与 | 0b1010 & 0b1100 = 0b1000 |
| ` | ` | 按位或 |
^ |
按位异或 | 0b1010 ^ 0b1100 = 0b0110 |
~ |
按位取反 | ~0b1010 = 0b...0101 |
<< |
左移(乘2) | 5 << 1 = 10 |
>> |
右移(除2) | 5 >> 1 = 2 |
重要应用(后续章节还会详细展开):
- 判断奇偶:
n & 1 - 乘 2:
n << 1 - 取低位:
n & 0xFF - 置位/清位:
n |= 1<<k/n &= ~(1<<k)
3.5 三目与逗号表达式
cpp
int max = (a > b) ? a : b; // 三目:条件 ? 真值 : 假值
int x = (a = 1, b = 2, a + b); // 逗号表达式:依次执行,整体值为最后一个
// x = 3
3.6 运算符优先级速记
口诀:单目 → 算术 → 移位 → 关系 → 相等 → 位 → 逻辑 → 三目 → 赋值 → 逗号
cpp
() [] -> .
! ~ ++ -- + - * & (type) sizeof
* / %
+ -
<< >>
< <= > >=
== !=
&
^
|
&&
||
?:
= += -= ...
,
经典陷阱:优先级顺序决定很多看似"理所当然"的代码其实是错的。
cpp
// 常见错误1
if (a & 1 == 0) // 实际是 a & (1 == 0),因为 == 优先级高于 &
// 应写作
if ((a & 1) == 0)
// 常见错误2
*p++ // 等价于 *(p++),先取旧地址的值再自增
(*p)++ // 解引用后自增
第4章 控制流
4.1 条件分支 if / else if / else
cpp
if (score >= 90) {
printf("A");
} else if (score >= 60) {
printf("B");
} else {
printf("C");
}
易错点:
-
else就近匹配最近的未匹配if(悬空 else 问题)。 -
忘记加花括号导致逻辑错误:
cppif (a > 0) printf("positive\n"); printf("done\n"); // 这一行不受 if 控制! -
赋值当判断:
if (a = 1)恒真(a 被赋成 1)。检查是否写错成if (a == 1)。有些编译器会警告。
4.2 switch 分支
cpp
switch (day) {
case 1: printf("Monday"); break;
case 2: printf("Tuesday"); break;
case 6:
case 7: printf("Weekend"); break; // 多个 case 合并
default: printf("Invalid"); break;
}
重点:
- 每个 case 末尾一般要
break,否则会**贯穿(fallthrough)**继续执行下一个 case。 case后必须是整型常量表达式,不能是变量或浮点。switch相比多个if-else,编译可能生成跳转表,效率更高。
面试官可能会问:故意不写 break 的合法场景?答:多个 case 共享同一段代码(如上例 6、7 合并),此时要在注释里写明
/* fall through */。
4.3 循环:for / while / do-while
cpp
// for:已知次数
for (int i = 0; i < 10; i++) { ... }
// while:条件前置
while (n > 0) { ... n--; }
// do-while:至少执行一次
do {
...;
} while (cond);
三种循环的选择:
| 场景 | 推荐 |
|---|---|
| 已知循环次数 | for |
| 次数未知、先判断 | while |
| 次数未知、必须至少执行一次 | do-while |
无限循环的两种写法:
cpp
while (1) { ... }
for (;;) { ... }
break 与 continue:
break:跳出当前循环或 switch。continue:跳过本次循环剩余部分,进入下一次。
break只能跳出一层循环,多层循环要用 goto 或标志变量(面试问"break 能跳出几层"答案是:一层)。
4.4 goto(谨慎使用)
cpp
for (int i = 0; i < 10; i++)
for (int j = 0; j < 10; j++) {
if (fatal) goto error_exit; // 直接跳出多层
}
error_exit:
printf("error\n");
观点 :工程上 goto 名声不好,但在多层错误处理、统一出口场景下(如内核代码),goto 比标志位更清晰。不要滥用,不要在函数间乱跳。
4.5 浮点数比较的坑 ★★
cpp
float f = 0.1f;
if (f == 0.1f) { ... } // 可能成立,但很多情况不成立!
// 正确比较方式:用误差范围
if (fabs(f - 0.1f) < 1e-6) { ... }
浮点数在计算机中是近似存储(IEEE 754),不要用
==直接比较,用误差阈值。
4.6 经典循环题训练
打印九九乘法表:
cpp
for (int i = 1; i <= 9; i++) {
for (int j = 1; j <= i; j++)
printf("%dx%d=%2d ", j, i, i * j);
printf("\n");
}
判断素数:
cpp
int is_prime(int n) {
if (n < 2) return 0;
for (int i = 2; i * i <= n; i++) // 只需试到 sqrt(n)
if (n % i == 0) return 0;
return 1;
}
第5章 数组与字符串
5.1 一维数组
cpp
int a[5]; // 定义:5 个 int,a[0]~a[4]
int b[5] = {1,2,3}; // 初始化:前3个赋值,后2个为0
int c[] = {1,2,3}; // 自动推断大小为3
int d[5] = {0}; // 全部初始化为0(常用!)
数组的下标从 0 开始 ,a[n] 访问第 n+1 个元素。
易错点:
- 越界访问不报错(C 不检查边界),属于未定义行为,可能崩溃或产生脏数据。
- 数组名是首元素地址的常量 ,不能
a++、不能整体赋值a = b。
5.2 二维数组
cpp
int m[2][3] = {{1,2,3},{4,5,6}}; // 2行3列
// 内存中按行优先连续存储:1,2,3,4,5,6
cpp
// 遍历
for (int i = 0; i < 2; i++)
for (int j = 0; j < 3; j++)
printf("%d ", m[i][j]);
重点认知 :m[i][j] 等价于 *(*(m+i)+j)。二维数组名 m 的类型是"指向含3个int的一维数组的指针",即 int (*)[3]。
5.3 数组与指针的关系 ★★★
cpp
int a[5] = {1,2,3,4,5};
a // 类型 int*,首元素地址,等价 &a[0]
a + 1 // 指向 a[1],步长 sizeof(int)
&a // 类型 int(*)[5],整个数组的地址,值同 a
&a + 1 // 跳过整个数组!步长 sizeof(int[5]) = 20
cpp
printf("%p\n", a); // 例如 0x1000
printf("%p\n", a+1); // 0x1004(步长4)
printf("%p\n", &a); // 0x1000
printf("%p\n", &a+1); // 0x1014(步长20)
指针遍历数组:
cpp
int a[] = {1,2,3,4,5};
int *p = a;
while (p < a + 5)
printf("%d ", *p++);
5.4 数组作为函数参数的退化 ★★★
cpp
// 下面三种写法完全等价,都是 int* 指针!
void f(int a[]) {}
void f(int a[5]) {}
void f(int *a) {}
// 在函数内部 sizeof(a) 是 8(指针大小),不是数组大小!
结论 :数组传给函数时退化为指针,丢失长度信息 ,所以标准库函数都要额外传长度参数(如 memcpy(dst, src, n))。
5.5 字符数组与字符串 ★★★
C 语言没有原生字符串类型,字符串是以 '\0' 结尾的字符数组。
cpp
char s1[] = "hello"; // 大小6:h e l l o \0
char s2[] = {'h','e','l','l','o','\0'}; // 等价
char s3[] = {'h','e','l','l','o'}; // 没有\0,不是字符串!
char *p = "hello"; // p 指向常量区的字符串字面量,只读
关键区别:
char s[] = "hello" |
char *p = "hello" |
|
|---|---|---|
| 存储位置 | 栈上数组 | 常量区(.rodata) |
| 是否可修改 | 可以 | 不可(修改是UB) |
| 大小 | sizeof=6 | sizeof=8(指针) |
| 生命周期 | 函数作用域 | 整个程序 |
cpp
char s[] = "hello";
s[0] = 'H'; // 合法,s 变成 "Hello"
char *p = "hello";
p[0] = 'H'; // 未定义行为!很可能段错误
5.6 字符串处理函数(重点)★★★
cpp
#include <string.h>
strlen(s); // 长度(不含'\0')
strcpy(dst, src); // 拷贝(含'\0'),不检查目标容量!
strncpy(dst, src, n); // 拷贝n个字符,不保证补'\0'
strcat(dst, src); // 拼接,不检查容量!
strcmp(a, b); // 比较,返回<0/0/>0
strchr(s, c); // 查找字符
strstr(s, sub); // 查找子串
手写 strlen 与 strcpy(面试必会):
cpp
size_t my_strlen(const char *s) {
const char *p = s;
while (*p) p++;
return p - s;
}
char *my_strcpy(char *dst, const char *src) {
char *ret = dst;
while ((*dst++ = *src++))
;
return ret;
}
strcpy 的安全问题 :不检查目标空间,可能溢出。工程上用 strncpy + 手动补 '\0',或使用安全函数。
5.7 常见字符串陷阱
cpp
// 陷阱1:== 比较的是地址不是内容
char a[] = "abc";
char b[] = "abc";
if (a == b) ... // 永远不成立,比较的是两个数组的地址!
// 正确:strcmp(a, b) == 0
// 陷阱2:strlen 没算 \0
char buf[5] = "abcd";
strcpy(buf, "hello"); // 溢出!"hello"需要6字节
// 陷阱3:gets 是恶魔,绝不使用
// gets(buf); // 不限制长度,缓冲区溢出神器
fgets(buf, sizeof(buf), stdin); // 安全读入
第6章 指针(C语言灵魂)
指针是 C 语言最强大也最危险的部分,几乎每场 C 方向面试必考。本章务必吃透。
6.1 指针基础
cpp
int x = 10;
int *p = &x; // p 保存 x 的地址
*p = 20; // 通过 p 修改 x,x 变成 20
printf("%d\n", *p); // 20,解引用
printf("%p\n", p); // 地址,如 0x7ffe...
两个运算符:
&取地址:&x得到 x 的地址。*解引用:*p访问 p 指向的内存。
指针变量大小:所有指针大小相同(64位下8字节),与指向类型无关。但指针的"步长"(+1 跳过多少字节)与类型有关。
6.2 指针类型与步长 ★★★
cpp
int *pi; // +1 跳过 sizeof(int)=4
char *pc; // +1 跳过 1
double *pd; // +1 跳过 8
int (*pa)[5]; // +1 跳过 20
int a[5];
int *p = a;
p + 1; // 指向 a[1],地址 = p + 4字节
为什么指针要分类型 ?因为 *p 需要知道取几个字节、p+1 需要知道跳多远。
6.3 指针数组 vs 数组指针 ★★★
cpp
int *p1[3]; // 指针数组:p1 是数组,装 3 个 int*
int (*p2)[3]; // 数组指针:p2 是指针,指向含3个int的数组
// 优先级:[] 高于 *
int *p1[3] → 先看 [] → 是数组 → 元素是 int*
int (*p2)[3] → 先看 (*p2) → 是指针 → 指向 int[3]
cpp
// 指针数组应用:命令行参数 argv
char *argv[] = {"prog", "-a", "file", NULL};
// 数组指针应用:二维数组形参
void print_matrix(int (*m)[3], int rows);
6.4 多级指针 ★★
cpp
int x = 10;
int *p = &x;
int **pp = &p; // 指向指针的指针
printf("%d\n", **pp); // 10
应用场景:
-
函数内修改指针本身(如链表插入头节点):
cppvoid insert_head(Node **head, Node *n) { n->next = *head; *head = n; // 通过二级指针修改外面的头指针} -
指向字符串数组:
char **argv。
6.5 void* 指针
void*是"无类型指针",可以接收任意类型指针,但不能直接解引用。- 必须先转成具体类型才能解引用。
cpp
int x = 5;
void *vp = &x;
int *ip = (int *)vp; // 强转
printf("%d\n", *ip);
- 应用:
malloc返回void*,qsort比较函数用const void*。
6.6 const 与指针的组合 ★★★
四种组合,务必分清:
cpp
const int *p; // 指向const int的指针:不能通过p改值,但p可换指向
int const *p; // 同上(等价写法)
int *const p; // 常量指针:p不能换指向,但能改指向的值
const int *const p; // 都不能改
// 记忆:const 在 * 左边 → 修饰"指向的值";const 在 * 右边 → 修饰"指针本身"
cpp
int x = 10, y = 20;
const int *p1 = &x;
// *p1 = 30; // 错误!不能通过 p1 修改
p1 = &y; // 合法,p1 换指向
int *const p2 = &x;
*p2 = 30; // 合法,改值
// p2 = &y; // 错误!p2 不能换指向
函数参数用 const 的规范:
cpp
void show(const char *s); // 告诉调用者:我不会改你的字符串
6.7 函数指针 ★★★
cpp
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int (*fp)(int, int); // 定义函数指针
fp = add;
printf("%d\n", fp(3, 4)); // 7,直接调用
printf("%d\n", (*fp)(3, 4)); // 7,等价写法
typedef 简化:
cpp
typedef int (*op_t)(int, int);
op_t fp = add;
经典应用:回调函数 + 命令分发表
cpp
// 通用排序接口 qsort:传比较函数作为回调
int cmp_int(const void *a, const void *b) {
return *(int *)a - *(int *)b;
}
int arr[] = {3,1,4,1,5};
qsort(arr, 5, sizeof(int), cmp_int);
cpp
// 计算器:函数指针表实现命令分发
int (*ops[])(int, int) = {add, sub, mul, div};
// ops[0] 执行 add,ops[1] 执行 sub ...
6.8 野指针、悬空指针与内存安全 ★★★
三种危险指针:
| 类型 | 定义 | 后果 |
|---|---|---|
| 野指针 | 未初始化就使用 | 指向随机地址 |
| 悬空指针 | 指向的内存已被 free/释放 | 访问已释放内存 |
| NULL 指针 | 值为 0(空) | 解引用即段错误 |
cpp
// 野指针
int *p; // 未初始化
*p = 10; // 危险!p 指向哪里不知道
// 悬空指针
int *p = malloc(sizeof(int));
free(p);
// 此时 p 是悬空指针
p = NULL; // 规范做法:置空
// NULL 指针
int *p = NULL;
if (p) *p = 10; // 判空后再用,安全
规范:
- 指针定义即初始化:
int *p = NULL; - free 后立即置 NULL。
- 使用前判空。
- 用 valgrind / ASan 检测内存问题。
6.9 指针经典面试题
题1:char s[] vs char *p
cpp
char s[] = "hello";
char *p = "hello";
sizeof(s); // 6
sizeof(p); // 8(64位)
题2:为什么 arr++ 不合法
cpp
int arr[5];
arr++; // 错误!arr 是地址常量,不能自增
// 但 int *p = arr; p++; 合法
题3:交换两个变量(面试常问)
cpp
void swap(int *a, int *b) {
int t = *a;
*a = *b;
*b = t;
}
题4:手写字符串反转
cpp
void reverse(char *s) {
char *left = s, *right = s + strlen(s) - 1;
while (left < right) {
char t = *left;
*left++ = *right;
*right-- = t;
}
}
第7章 函数
7.1 函数声明、定义与调用
cpp
// 声明(原型):告诉编译器函数存在
int add(int a, int b);
// 定义:实现
int add(int a, int b) {
return a + b;
}
// 调用
int r = add(3, 4);
为什么需要声明?C 编译是单遍的,先声明后使用;否则编译器不知道函数签名,会报隐式声明错误。
参数传递:值传递。C 语言只有值传递!传的是副本。
cpp
void change(int x) { x = 100; }
int main(void) {
int a = 1;
change(a); // a 仍是 1!形参 x 是副本
return 0;
}
想改外部变量的值,必须传地址(指针)。
7.2 传值 vs 传址
cpp
void by_value(int x) { x = 100; }
void by_addr(int *x) { *x = 100; }
int a = 1;
by_value(a); // a = 1
by_addr(&a); // a = 100
再进一步:函数内要修改指针本身怎么办? 传指针的指针(二级指针):
cpp
void alloc(int **pp) {
*pp = malloc(sizeof(int));
**pp = 42;
}
int *p = NULL;
alloc(&p); // 现在 p 指向堆内存
7.3 return 返回什么
-
返回值 :
return x; -
返回指针 :注意返回局部变量地址是错误!
cppint *bad(void) { int local = 5; // 栈上的局部变量 return &local; // 危险!函数返回后内存失效(悬空指针)}正确做法:返回堆内存(调用者负责 free)或静态变量。
7.4 递归与尾递归 ★★
递归三要素:终止条件 + 缩小规模 + 自身调用。
cpp
// 阶乘
int fact(int n) {
if (n <= 1) return 1; // 终止条件
return n * fact(n - 1); // 缩小规模
}
// 斐波那契(低效写法,指数复杂度)
int fib(int n) {
if (n <= 1) return n;
return fib(n-1) + fib(n-2);
}
递归的代价 :每次调用压栈(函数栈帧),深度过大会栈溢出。
尾递归(优化空间):
cpp
int fact_tail(int n, int acc) {
if (n <= 1) return acc;
return fact_tail(n - 1, acc * n); // 递归调用是最后一步
}
编译器可能把尾递归优化成循环。但 C 标准不强制,深度依赖仍可能爆栈。深递归场景优先用迭代(循环)。
循环改递归、递归改循环是基本功,面试手写题常考。
7.5 可变参数函数 ★★
cpp
#include <stdarg.h>
int sum(int count, ...) {
va_list ap;
va_start(ap, count);
int total = 0;
for (int i = 0; i < count; i++)
total += va_arg(ap, int);
va_end(ap);
return total;
}
printf("%d\n", sum(3, 10, 20, 30)); // 60
注意:
...之前必须有一个命名参数(确定参数个数或结束标志)。va_arg的类型必须和实参实际类型一致,否则 UB。- printf 家族就是可变参数函数。
7.6 static 函数与内联函数
cpp
// static 函数:仅本文件可见,防止符号冲突
static int helper(int x) { return x * 2; }
// 内联函数:建议编译器内联展开,减少调用开销
static inline int square(int x) { return x * x; }
内联适合小而频繁调用的函数;大函数内联会导致代码膨胀。
7.7 函数设计最佳实践
- 单一职责:一个函数只做一件事。
- 参数用 const 修饰只读参数,表明意图。
- 用返回值/错误码表示状态,不滥用全局变量。
- 命名清晰:
get_user_by_id好于f1。
第8章 结构体、联合体与枚举
8.1 结构体 struct ★★★
cpp
struct Student {
char name[32];
int age;
float score;
};
// 使用
struct Student s1; // 声明变量
s1.age = 20; // 成员访问
strcpy(s1.name, "Alice");
// 初始化
struct Student s2 = {"Bob", 21, 88.5};
// typedef 简化
typedef struct Student Student; // 以后直接写 Student
// 或合并写法
typedef struct {
char name[32];
int age;
} Person;
结构体赋值 :Person p2 = p1; 整体拷贝(逐成员复制,浅拷贝)。
8.2 内存对齐与结构体大小 ★★★(必考)
对齐规则:
- 每个成员的偏移地址必须是
min(自身大小, 默认对齐数)的整数倍。 - 结构体总大小必须是最大成员对齐数的整数倍。
- 默认对齐数由编译器决定(x86 常见 8)。
cpp
struct A {
char a; // 偏移0,1字节
int b; // 需4字节对齐 → 偏移4(1~3填充),占4
short c; // 需2字节对齐 → 偏移8,占2
}; // 总大小 = 10 → 对齐到4的倍数 → 12
sizeof(struct A); // 12
cpp
// 如果换个顺序
struct B {
char a; // 0
short c; // 2(偏移对齐到2),占2
int b; // 4,占4
}; // 总大小 = 8,没有额外填充
sizeof(struct B); // 8
优化技巧:大类型放前面,小类型放后面,可以减少填充,让结构体更紧凑。
内存布局图解(struct A):
cpp
偏移: 0 1 2 3 4 5 6 7 8 9 10 11
[a][ padding ][ b ][ c ][ padding ]
手动控制对齐:
cpp
#pragma pack(1) // 按1字节对齐,取消填充
struct Packed {
char a; // 0
int b; // 1
}; // 总大小5
#pragma pack() // 恢复默认
pack(1) 省空间但访问可能变慢(未对齐访问),用于网络协议、文件格式等需要精确布局的场景。
8.3 位域 bit-field ★★
cpp
struct Flags {
unsigned int read : 1; // 只占1位
unsigned int write : 1;
unsigned int cache : 2;
};
- 节省内存,按位存储。
- 不能取地址(
&f.read非法)。 - 布局依赖编译器,不可移植。
- 应用:解析协议头(TCP flags、IP 头字段)。
8.4 联合体 union ★★★
cpp
union Data {
int i;
float f;
char bytes[4];
};
// 所有成员共享同一块内存,大小 = 最大成员大小
union Data d;
d.i = 42; // 此时只有 i 有意义
d.f = 3.14; // 覆盖了 i,i 变成垃圾值
应用1:判断大小端
cpp
union {
int i;
char c;
} u;
u.i = 1;
if (u.c == 1) printf("小端\n"); // 低字节在低地址
else printf("大端\n");
应用2:类型双关(读取一个值的字节表示):
cpp
float f = 3.14f;
union { float f; unsigned int u; } u;
u.f = f;
printf("0x%x\n", u.u); // 打印浮点数的位模式
struct vs union 对比:
| struct | union | |
|---|---|---|
| 成员存储 | 各自独立 | 共享内存 |
| 大小 | 成员之和+对齐 | 最大成员+对齐 |
| 同时有效 | 所有成员 | 仅最后一个写入的 |
| 用途 | 组合多种数据 | 变体/类型双关 |
8.5 枚举 enum
cpp
enum Color { RED, GREEN, BLUE };
// RED=0, GREEN=1, BLUE=2(默认从0递增)
enum Status { OK = 200, ERROR = 500 }; // 手动指定值
enum Color c = GREEN;
if (c == GREEN) ...
enum vs #define 常量:
- enum 有类型检查、有作用域、可调试。
- #define 只是文本替换。
- 现代代码优先 enum。
8.6 结构体的自引用与嵌套
cpp
// 链表节点(自引用:结构体里放指向自身的指针)
typedef struct Node {
int data;
struct Node *next;
} Node;
// 嵌套
typedef struct {
Point center; // 嵌套另一个结构体
double radius;
} Circle;
自引用必须用指针 (
struct Node *next),不能直接struct Node next(无限递归大小)。
第9章 动态内存管理
9.1 内存四区(进程地址空间)★★★
程序运行时的内存布局:
cpp
高地址
+------------------+ 栈区(向下增长):局部变量、函数参数、返回地址
| 栈 Stack |
| ↓ ↓ ↓ |
+------------------+
| ↑ ↑ ↑ |
| 堆 Heap | 堆区(向上增长):malloc 分配
+------------------+
| .bss 未初始化全局/静态变量 |
+------------------+
| .data 已初始化全局/静态变量 |
+------------------+
| .rodata 字符串常量、只读数据 |
+------------------+
| .text 代码段(机器指令) |
+------------------+
低地址
栈 vs 堆对比:
| 维度 | 栈 | 堆 |
|---|---|---|
| 分配方式 | 自动(编译器) | 手动(malloc/free) |
| 分配速度 | 快(移动栈指针) | 慢(查找空闲块) |
| 大小 | 较小(默认约8MB) | 较大(受虚拟内存限制) |
| 生命周期 | 函数返回即释放 | free 才释放 |
| 数据 | 局部变量 | 动态数据 |
| 碎片 | 无 | 有 |
9.2 malloc / calloc / realloc / free ★★★
cpp
#include <stdlib.h>
// 分配 n 字节
int *p = malloc(10 * sizeof(int));
if (p == NULL) { /* 分配失败处理 */ }
// calloc:分配并清零
int *q = calloc(10, sizeof(int)); // 10个int,全部初始化为0
// realloc:调整大小(可能搬移数据)
int *r = realloc(p, 20 * sizeof(int));
// 成功:返回新地址(可能与原来相同或不同),旧指针作废
// 失败:返回 NULL,原指针仍有效
// 释放
free(r);
使用规范:
- malloc 后必须检查返回值是否为 NULL。
- 用完必须 free,且 free 后置 NULL。
- free 的次数必须和 malloc 一一对应。
- malloc/free 配对使用,谁分配谁释放。
9.3 常见内存错误清单 ★★★
| 错误 | 例子 | 后果 |
|---|---|---|
| 内存泄漏 | malloc 后忘记 free | 内存持续增长 |
| 越界访问 | 写超过分配范围 | 破坏堆元数据,崩溃 |
| double free | 同一指针 free 两次 | 堆损坏,崩溃 |
| use-after-free | free 后仍访问 | 悬空指针 |
| 返回局部变量地址 | 返回栈变量 & | 悬空指针 |
| 分配0字节 | malloc(0) | 行为依赖实现 |
cpp
// 泄漏示例
void leak(void) {
int *p = malloc(100);
// 忘了 free(p)
}
// double free 示例
int *p = malloc(4);
free(p);
free(p); // 错误!double free
// 正确姿势
int *p = malloc(4);
free(p);
p = NULL; // 置空后第二次 free(p) 是安全的(free(NULL) 是空操作)
检测工具:
- valgrind :
valgrind --leak-check=full ./program,报泄漏点和越界。 - ASan (AddressSanitizer):编译时加
-fsanitize=address,运行时检测。 - gdb + core dump:崩溃定位。
9.4 malloc 的底层实现 ★★
glibc ptmalloc:
- 小内存(默认 < 128KB):通过
brk()扩展堆顶,配合空闲链表分箱复用已释放块。 - 大内存(≥ 128KB):用
mmap()映射匿名内存,free 时munmap归还操作系统。 - free 的内存一般不会立即还给 OS,而是进入 ptmalloc 的空闲缓存,供后续 malloc 复用。
为什么会产生碎片:
- 多次分配/释放不同大小,剩余空闲块不连续。
- 内碎片:分配比需求大(如对齐)。
- 外碎片:空闲块分散,无法满足大请求。
高性能替代:tcmalloc(每线程缓存,减少锁竞争)、jemalloc。服务器/数据库常用。
9.5 手写简单内存池(思路)★
cpp
// 固定大小对象的内存池思想
typedef struct {
void *memory; // 预分配的大块
int free_count; // 空闲数量
// ... 空闲链表
} Pool;
// 优点:
// 1. 一次大块分配,多次小块使用,减少系统调用
// 2. 固定大小,无外碎片
// 3. 分配释放 O(1)
高频场景(网络请求对象、消息缓冲)用内存池能显著提升性能。
9.6 智能指针的启示(预告)
C++ 用 RAII 封装裸指针,实现自动释放。C 里可通过函数内部分配+统一出口释放 、引用计数结构体等方式模拟。第 02 篇会详细讲 C++ 智能指针。
第10章 预处理与宏
10.1 预处理指令一览
| 指令 | 作用 |
|---|---|
#include |
包含头文件 |
#define |
定义宏 |
#undef |
取消宏定义 |
#ifdef / #ifndef |
是否定义 |
#if / #elif / #else / #endif |
条件编译 |
#pragma |
编译器指令 |
#error |
编译期报错 |
#line |
改变行号 |
## / # |
连接/字符串化 |
10.2 宏定义 #define ★★★
cpp
#define PI 3.14159
#define MAX(a, b) ((a) > (b) ? (a) : (b)) // 带参宏
带参宏的陷阱(必考):
cpp
#define SQUARE(x) x * x
SQUARE(2 + 3) // 展开成 2 + 3 * 2 + 3 = 11,不是25!
// 正确:
#define SQUARE(x) ((x) * (x))
规则:
- 参数必须加括号 :
((x) * (x))。 - 整个表达式加括号:
#define SUM(a,b) ((a)+(b))。 - 避免带副作用的实参:
SQUARE(i++)会展开成((i++)*(i++)),i 自增两次,UB。
多行宏:
cpp
#define SWAP(a, b) do { \
typeof(a) t = (a); \
(a) = (b); \
(b) = t; \
} while (0)
do { ... } while (0)让宏像语句一样使用,加;也安全。
10.3 # 与 ## 运算符 ★★
cpp
// # 字符串化
#define STR(x) #x
STR(hello) // 展开成 "hello"
// ## 连接
#define CAT(a, b) a ## b
CAT(foo, bar) // 展开成 foobar
// 实用例子:生成变量名
#define VAR(name, n) name##n
int VAR(a, 1) = 10; // int a1 = 10;
10.4 条件编译 ★★★
cpp
// 调试开关
#define DEBUG 1
#if DEBUG
printf("debug info\n");
#endif
// 平台判断
#ifdef _WIN32
// Windows 代码
#elif defined(__linux__)
// Linux 代码
#else
#error "Unknown platform"
#endif
// 防止头文件重复包含
#ifndef MY_HEADER_H
#define MY_HEADER_H
// ... 内容
#endif
头文件保护两种写法:
cpp
// 方式1:ifndef 宏保护(传统,可移植)
#ifndef __MY_H__
#define __MY_H__
...
#endif
// 方式2:#pragma once(现代编译器支持,更快)
#pragma once
应用:
- 调试日志开关。
- 跨平台代码。
- 按配置编译功能。
- 头文件防重复包含(双保险)。
10.5 宏 vs 函数 vs 内联
| 维度 | 宏 | 函数 | static inline |
|---|---|---|---|
| 展开时机 | 预处理 | 运行 | 编译 |
| 类型检查 | 无 | 有 | 有 |
| 调用开销 | 无 | 有 | 建议内联 |
| 代码体积 | 每处展开一份 | 一份 | 内联则每处一份 |
| 副作用风险 | 高 | 低 | 低 |
| 适用 | 常量、简单操作 | 一般逻辑 | 小而高频 |
现代 C 编码规范:能用
static inline函数代替带参宏就尽量用。
第11章 文件操作
11.1 文件打开与关闭
cpp
#include <stdio.h>
FILE *fp = fopen("data.txt", "r"); // r: 只读, w: 写入(清空), a: 追加
if (fp == NULL) {
perror("fopen");
return -1;
}
// ... 读写 ...
fclose(fp); // 必须关闭!否则泄漏文件句柄
常用模式:
| 模式 | 含义 | 文件不存在 |
|---|---|---|
"r" |
只读 | 打开失败 |
"w" |
只写,清空 | 创建 |
"a" |
追加 | 创建 |
"r+" |
读写 | 打开失败 |
"w+" |
读写,清空 | 创建 |
"rb"/"wb" |
二进制模式 | 同上 |
Windows 下文本模式与二进制模式有区别(\r\n 转换);Linux 无区别。
11.2 读写函数族
cpp
// 字符读写
int c = fgetc(fp); // 读一个字符,EOF 时返回 EOF
fputc('A', fp); // 写一个字符
// 格式化读写
int n = fscanf(fp, "%d", &x);
fprintf(fp, "value = %d\n", x);
// 按行读写
char line[256];
fgets(line, sizeof(line), fp); // 安全:最多读 sizeof-1 个
fputs(line, fp);
// 块读写(二进制)
fread(buf, 1, size, fp); // 读 size 字节
fwrite(buf, 1, size, fp); // 写 size 字节
11.3 文本 vs 二进制
- 文本文件:人可读,用 fscanf/fprintf/fgets,受换行、编码影响。
- 二进制文件:内存原始字节,用 fread/fwrite,精确、高效,适合保存结构体数据。
cpp
// 结构体写二进制文件
struct Rec { int id; double val; };
struct Rec r = {1, 3.14};
fwrite(&r, sizeof(r), 1, fp);
// 读回
struct Rec r2;
fread(&r2, sizeof(r2), 1, fp);
11.4 文件定位 fseek / ftell
cpp
long pos = ftell(fp); // 当前位置
fseek(fp, 0, SEEK_SET); // 移到开头
fseek(fp, 0, SEEK_END); // 移到末尾
fseek(fp, 10, SEEK_CUR); // 从当前位置后移10字节
rewind(fp); // 等价 fseek(fp, 0, SEEK_SET)
11.5 错误处理
cpp
// feof: 是否到达文件末尾
while (!feof(fp)) { ... } // 注意:feof 要到读取失败后才能检测到EOF
// ferror: 是否发生错误
if (ferror(fp)) { perror("read error"); }
// 更健壮的读取循环:
int c;
while ((c = fgetc(fp)) != EOF) { // 先读到EOF再判断
process(c);
}
EOF 的本质 :EOF 是 int 类型的 -1,不是 一个真实字符。所以读字符要用 int 接收,不能用 char(char 可能是有符号,但 -1 会与 0xFF 混淆)。
11.6 标准流
cpp
stdin // 标准输入
stdout // 标准输出
stderr // 标准错误(不缓冲,用于错误信息)
printf=fprintf(stdout, ...),scanf=fscanf(stdin, ...)。重定向时./prog < input.txt > out.txt 2>err.txt。
第12章 位运算与进阶技巧
12.1 常用位操作模式 ★★★
cpp
// 判断奇偶
if (n & 1) // 奇数
// 乘2 / 除2(整数)
n << 1 // 乘2
n >> 1 // 除2(注意负数右移是实现定义的)
// 判断是否为2的幂
(n & (n - 1)) == 0 // 且 n > 0
// 获取第 k 位
(n >> k) & 1
// 设置第 k 位
n |= (1 << k)
// 清除第 k 位
n &= ~(1 << k)
// 翻转第 k 位
n ^= (1 << k)
// 取低 8 位
n & 0xFF
// 交换两个数(不用临时变量,面试技巧)
a ^= b; b ^= a; a ^= b;
12.2 统计二进制中 1 的个数 ★★
cpp
// 方法1:逐位判断
int count_ones(int n) {
int c = 0;
while (n) {
c += n & 1;
n >>= 1;
}
return c;
}
// 方法2:n & (n-1) 每次消掉最低位的1(更高效)
int count_ones2(int n) {
int c = 0;
while (n) {
c++;
n &= (n - 1);
}
return c;
}
12.3 大小端与字节序 ★★★
cpp
// 大小端判断(前面学过 union 方法)
int is_little_endian(void) {
int x = 1;
return *(char *)&x == 1; // 低字节在低地址 → 小端
}
为什么重要:
- 网络字节序是大端(big-endian),主机多为小端。
- 网络编程必须转换:
htons/htonl/ntohs/ntohl(host ↔ network)。 - 序列化协议、跨平台数据传输都要统一字节序。
12.4 位操作实战:RGB 颜色打包
cpp
// 将 RGBA 打包成 32 位整数(小端)
unsigned int pack_rgba(unsigned char r, unsigned char g,
unsigned char b, unsigned char a) {
return (r << 24) | (g << 16) | (b << 8) | a;
}
12.5 回调函数的实战:通用排序
cpp
#include <stdio.h>
#include <stdlib.h>
// 对任意类型数组排序,只需传比较函数
typedef int (*cmp_t)(const void *, const void *);
int cmp_int(const void *a, const void *b) {
return *(const int *)a - *(const int *)b;
}
int cmp_str(const void *a, const void *b) {
return strcmp(*(const char **)a, *(const char **)b);
}
int main(void) {
int arr[] = {5, 2, 8, 1, 9};
qsort(arr, 5, sizeof(int), cmp_int);
// arr → {1, 2, 5, 8, 9}
return 0;
}
12.6 C 语言易错点大汇总(考前必看)★★★
语法类:
if (a = 1)赋值当比较,恒真。- switch 忘 break 贯穿。
==写成=。- 整数除法
5/2 == 2。 - 分号问题:
while (i++ < 5);空循环体。
内存类:
- 数组越界不报错。
- 返回局部变量地址。
- 字符串没留 '\0' 空间。
- free 后不置 NULL。
- 忘记检查 malloc 返回值。
指针类:
- 野指针未初始化。
- 指针类型与步长搞混。
char *p = "abc"; p[0]='x';修改常量。- 二级指针用于修改实参指针。
数组类:
sizeof(arr)在函数参数里失效。- 二维数组传参类型
int (*)[n]记错。 - 数组名不能自增。
表达式类:
- 短路求值陷阱。
- 未定义行为(同一表达式多次修改变量)。
- 有符号无符号比较。
12.7 推荐练习与下一步
动手练习:
- 手写:strlen / strcpy / memcpy / atoi / 字符串反转 / 链表反转。
- 实现:单链表(增删改查)、栈、队列。
- 实战:写一个简易通讯录(结构体 + 文件 + 动态内存)。
检验清单:读完本篇,能不看资料解释"内存四区、指针步长、结构体对齐、malloc 底层、宏陷阱、大小端",就说明 C 语言基础过关了。
结语与下篇预告
本篇用 12 章系统梳理了 C 语言:从程序结构、类型、运算符、控制流,到数组、指针、函数、结构体,再到动态内存、预处理、文件、位运算,覆盖了 C 语言的核心全貌与高频易错点。
下一篇(第02篇):C++ ------ 将系统讲解:C 与 C++ 的区别、引用与指针、面向对象三要素(封装/继承/多态)、虚函数表、构造析构、内存管理(new/delete/智能指针)、STL 容器、C++11 新特性等。
本系列持续更新,一篇一个文件。欢迎收藏,跟着节奏把后端基础全部打牢。
