C/C++ 后端筑基系列(一):C语言 —— 从基础到进阶系统指南

本系列面向 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 函数:程序入口。标准写法返回 intreturn 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。

cpp 复制代码
int 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 问题)。

  • 忘记加花括号导致逻辑错误:

    cpp 复制代码
    if (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

应用场景

  • 函数内修改指针本身(如链表插入头节点):

    cpp 复制代码
    void 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;   // 判空后再用,安全

规范

  1. 指针定义即初始化:int *p = NULL;
  2. free 后立即置 NULL。
  3. 使用前判空。
  4. 用 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;

  • 返回指针 :注意返回局部变量地址是错误!

    cpp 复制代码
    int *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 函数设计最佳实践

  1. 单一职责:一个函数只做一件事。
  2. 参数用 const 修饰只读参数,表明意图。
  3. 用返回值/错误码表示状态,不滥用全局变量。
  4. 命名清晰: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 内存对齐与结构体大小 ★★★(必考)

对齐规则

  1. 每个成员的偏移地址必须是 min(自身大小, 默认对齐数) 的整数倍。
  2. 结构体总大小必须是最大成员对齐数的整数倍。
  3. 默认对齐数由编译器决定(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);

使用规范

  1. malloc 后必须检查返回值是否为 NULL
  2. 用完必须 free,且 free 后置 NULL
  3. free 的次数必须和 malloc 一一对应。
  4. 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) 是空操作)

检测工具

  • valgrindvalgrind --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))

规则

  1. 参数必须加括号((x) * (x))
  2. 整个表达式加括号:#define SUM(a,b) ((a)+(b))
  3. 避免带副作用的实参: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 的本质EOFint 类型的 -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 语言易错点大汇总(考前必看)★★★

语法类

  1. if (a = 1) 赋值当比较,恒真。
  2. switch 忘 break 贯穿。
  3. == 写成 =
  4. 整数除法 5/2 == 2
  5. 分号问题:while (i++ < 5); 空循环体。

内存类

  1. 数组越界不报错。
  2. 返回局部变量地址。
  3. 字符串没留 '\0' 空间。
  4. free 后不置 NULL。
  5. 忘记检查 malloc 返回值。

指针类

  1. 野指针未初始化。
  2. 指针类型与步长搞混。
  3. char *p = "abc"; p[0]='x'; 修改常量。
  4. 二级指针用于修改实参指针。

数组类

  1. sizeof(arr) 在函数参数里失效。
  2. 二维数组传参类型 int (*)[n] 记错。
  3. 数组名不能自增。

表达式类

  1. 短路求值陷阱。
  2. 未定义行为(同一表达式多次修改变量)。
  3. 有符号无符号比较。

12.7 推荐练习与下一步

动手练习

  • 手写:strlen / strcpy / memcpy / atoi / 字符串反转 / 链表反转。
  • 实现:单链表(增删改查)、栈、队列。
  • 实战:写一个简易通讯录(结构体 + 文件 + 动态内存)。

检验清单:读完本篇,能不看资料解释"内存四区、指针步长、结构体对齐、malloc 底层、宏陷阱、大小端",就说明 C 语言基础过关了。


结语与下篇预告

本篇用 12 章系统梳理了 C 语言:从程序结构、类型、运算符、控制流,到数组、指针、函数、结构体,再到动态内存、预处理、文件、位运算,覆盖了 C 语言的核心全貌与高频易错点。

下一篇(第02篇):C++ ------ 将系统讲解:C 与 C++ 的区别、引用与指针、面向对象三要素(封装/继承/多态)、虚函数表、构造析构、内存管理(new/delete/智能指针)、STL 容器、C++11 新特性等。

本系列持续更新,一篇一个文件。欢迎收藏,跟着节奏把后端基础全部打牢。

相关推荐
caimouse13 小时前
ReactOS 图形系统分析(51):元文件子系统 — metafile.c
c语言·开发语言
Canmag 兴隆磁性16 小时前
C 系列充磁机
c语言·开发语言·学习·永磁材料·充磁·退磁
_Narcissus_17 小时前
排序算法总结表格
c语言·数据结构·c++·笔记·算法·排序算法·总结
小雪崩18 小时前
嵌入式学习 day27:标准IO
linux·c语言·学习
xx~t19 小时前
嵌入式——Linux软件编程2
linux·运维·服务器·c语言·学习方法
luj_17681 天前
变频技术核心原理揭秘
服务器·c语言·开发语言·经验分享·算法
_Narcissus_1 天前
B树概念及操作笔记(含完整代码实现)
c语言·数据结构·数据库·c++·笔记·b树·算法
松小白song1 天前
嵌入式通信协议全解:UART、RS232、RS485、I²C、SPI 与 CAN 的本质区别与选型指南
c语言·开发语言
Fa_Mian_Tuan1 天前
图论基础 | 邻接多重表原理剖析 + 完整可运行C语言代码(插边/删边/释放)
c语言·数据结构·笔记·算法·图论