1. 数据存储

1.1 原码、反码、补码

第一层:解决什么问题?

计算机只认识0和1,但要表示正数和负数。最朴素的想法是用最高位当符号位(0正1负),其余位表示数值——这就是原码。

但原码有一个致命问题:用原码做加减法,电路设计非常复杂。 1 + (-1) 用原码一算:

 0000 0001  (1)
+1000 0001  (-1)
─────────────
 1000 0010  (-2)   ← 结果竟然是 -2!

CPU得先判断符号位,符号不同做减法,符号相同做加法——需要两套电路。补码的动机就是:让加法电路也能算减法,让 a - b 变成 a + (-b),用同一套加法电路得到正确结果。

第二层:如何实现?

  • 原码:最高位为符号位(0正1负),其余位表示数值的绝对值
  • 反码:正数反码=原码;负数反码=符号位不变,其余位按位取反
  • 补码:正数补码=原码;负数补码=反码+1

用补码重算 1 + (-1)

 0000 0001  (1的补码)
+1111 1111  (-1的补码:原码10000001→反码11111110→补码11111111)
─────────────
 0000 0000  (溢出位丢弃,结果为0)  ← 正确!

补码的数学本质是模运算。8位寄存器模256,-1等价于255,二进制正是1111 1111。减去一个数 = 加上它的补数,这就是"补码"名字的由来。"补码 = 反码 + 1"不是人为规定——8位数中反码=255-数值,补码=256-数值,差1,自然推导得出。

第三层:实际场景与常见问题

  • 负数范围不对称:8位有符号数范围-128+127(不是对称的-127+127),因为补码中1000 0000被规定为-128而不是-0。abs(INT_MIN)会溢出,经典陷阱。
  • 整形数据在内存中存放的是补码int a = -1; 在内存中看到的就是 0xFFFFFFFF
  • 位运算时要意识到是补码-1 >> 1 得 -1(算术右移,符号位填充);printf("%u", -1) 输出 4294967295(无符号解读)。
int a = -1;
printf("%d\n", a >> 1);  // -1,算术右移
printf("%u\n", a);       // 4294967295,无符号解读

面试常考:给一个补码反推十进制值;判断 ~0 的值。


1.2 大小端存储

第一层:解决什么问题?

一个多字节数据(如 int 占4字节),低位的字节放在低地址还是高地址?这不止是理论问题——不同机器通过网络传数据,或不同CPU读同一个二进制文件时,字节序不一致就会读出错。

大小端本质上是没有统一标准的历史时期,不同CPU厂商各自的选择:Intel(x86)选小端,Motorola/IBM(PowerPC)选大端,ARM可切换。

第二层:如何实现?

0x12345678 在内存中的布局:

地址:   低地址 ──────────→ 高地址
大端:  | 0x12 | 0x34 | 0x56 | 0x78 |
小端:  | 0x78 | 0x56 | 0x34 | 0x12 |
模式 规则 代表厂商
大端 高位字节存低地址 Motorola 68000, IBM PowerPC
小端 低位字节存低地址 Intel x86, 大部分ARM

第三层:实际场景与常见问题

判断当前机器大小端:

int check_endian() {
    int num = 1;
    return *(char *)#  // 读低地址字节,为1则小端
}

网络字节序是大端。在x86机器上写网络程序不调用 htonl/htons,对方收到的数据就是反的。面试常考联合体判断大小端、给定内存布局推断值。


1.3 整形截断与提升

第一层:解决什么问题?

C语言有 char/short/int/long 多种整数类型,占的字节数不同。类型间互相赋值时必然涉及数据宽度变化——变窄了哪些位被丢弃?变宽了高位填什么?这不是C语言制造麻烦,而是硬件寄存器固定宽度的天然限制

第二层:如何实现?

整型提升(窄→宽):

情况 填充方式
有符号数 按符号位填充高位
无符号数 高位填0
char c = -1;   // 11111111
int i = c;     // 11111111 11111111 11111111 11111111(符号位=1,高位全填1)

截断(宽→窄):直接丢弃高位字节,保留低位。

int i = 0x12345678;
char c = i;    // c = 0x78,只保留最低字节

第三层:实际场景与常见问题

char c = 128;
printf("%d\n", c);     // 可能是 -128(char有符号时溢出)
printf("%u\n", c);     // 4294967168(整型提升 + 无符号打印)

unsigned int a = 1;
int b = -2;
if (a + b > 0)         // 有符号和无符号混算,b被隐式转为无符号
    printf("大于0\n"); // 实际会打印!b变成一个巨大的正数

核心原则:有符号和无符号混算时,有符号会被隐式转为无符号。 这是无数bug的根源。


1.4 浮点数的存储

第一层:解决什么问题?

整数可以用简单二进制表示,但小数(如0.1)怎么存?浮点数范围极广——从极小的科学计数(6.02×10⁻²³)到极大的天文数字(6.02×10²³),固定小数点位置根本行不通。核心问题:用有限空间同时表示数值范围和精度。

第二层:如何实现?

IEEE 754标准:V = (-1)^S × M × 2^E

组成部分 float位数 含义
S(符号位) 1位 0正1负
E(阶码) 8位 实际指数+127(偏移量)
M(尾数) 23位 小数点后部分,隐含整数部分为1

以8.25为例:8.25 = 1000.01₂ = 1.00001 × 2³,S=0, E=3+127=130=10000010₂, M=00001000000000000000000。内存中存储为:0 10000010 00001000000000000000000

第三层:实际场景与常见问题

  • 浮点数不能精确表示:0.1在二进制中是无限循环小数。float f = 0.1; if (f == 0.1) 可能为假!
  • 不要用 == 比较浮点数:用 fabs(a-b) < 1e-6 判断差值是否足够小
  • 超出范围得 inf(无穷大),非法运算(如 sqrt(-1))得 NaN

2. 指针与数组

2.1 指针与数组的本质

第一层:解决什么问题?

指针要解决的问题:函数间传递大块数据时,按值传递会复制整个数据——灾难性开销。需要一种"引用"机制:不传数据本身,只传地址(间接访问)。更深层的背景是:C语言要替代汇编写操作系统,必须能直接操作内存地址。

数组要解决的问题:处理一组同类型数据(100个学生成绩、一张图片的像素),每个单独声明变量无法维护。需要连续内存 + 下标访问,一个名字管理整批数据。

第二层:如何实现?

指针是一个变量,存的是另一个变量的地址。数组是一块连续内存空间,数组名在大多数表达式中隐式转为首元素指针。

维度 指针 数组
本质 变量,存地址 一块连续内存
sizeof 指针本身大小(4/8字节) 整个数组大小
&操作 取指针变量自己的地址 取数组地址(类型是数组指针)
能否赋值 可以 p = ... 不可以(数组名是常量)
内存位置 指针变量本身在栈上 数组数据可在栈/静态区

第三层:实际场景与常见问题

void func(int arr[]) {  // arr 实际是指针!等价于 int *arr
    sizeof(arr);         // 4 或 8,不是数组大小!
}

int a[10];
sizeof(a);   // 40(整个数组)
sizeof(&a);  // 4 或 8

面试核心:数组名什么时候代表整个数组?——只有 sizeof(数组名)&数组名 时。其他情况都等同于首元素地址。


2.2 指针数组 vs 数组指针

第一层:解决什么问题?

名字很像,解决完全不同的问题。

指针数组:你需要一组指针——比如多个长度不同的字符串,用指针数组来管理。char *argv[] 就是典型。

数组指针:你需要一个指针指向一整个数组——比如把二维数组的一行整体传给函数。

第二层:如何实现?

int *p[5];    // 指针数组:[]优先级高于*,p[5]是数组,元素是 int*
int (*p)[5];  // 数组指针:(*p)说明p是指针,指向 int[5]

记忆方法:从变量名开始,按运算符优先级往外读。 [] 优先级高于 *,不加括号时变量先和 [] 结合构成数组。

第三层:实际场景与常见问题

// 指针数组:命令行参数
int main(int argc, char *argv[]) { ... }

// 指针数组:管理多个字符串
const char *names[] = {"Alice", "Bob", "Charlie"};

// 数组指针:指向二维数组的一行
int arr[3][4];
int (*p)[4] = arr;  // p指向第一行,p+1跳过一整行(16字节)

2.3 &数组名数组名

第一层:解决什么问题?

这是一个类型系统问题。数组名和 &数组名 的值相同(都是数组首地址),但类型不同——这决定了 +1 时跳过多少字节。编译器需要知道类型才能正确计算偏移量。

第二层:如何实现?

int a[5];
a;       // 类型 int*,指向首元素
&a;      // 类型 int(*)[5],指向整个数组(5个int)
&a[0];   // 类型 int*,指向首元素

三者的地址值相同,但 a+1 跳4字节(一个int),&a+1 跳20字节(整个数组),&a[0]+1 跳4字节。

第三层:实际场景与常见问题

int a[5] = {1, 2, 3, 4, 5};
int *p1 = a;         // OK,int*
int *p2 = &a;        // 警告!&a 是 int(*)[5],类型不匹配
int (*p3)[5] = &a;   // OK,类型匹配

printf("%p\n", a);      // 0x100
printf("%p\n", a + 1);  // 0x104(+4)
printf("%p\n", &a + 1); // 0x114(+20)

2.4 二级指针

第一层:解决什么问题?

指针本身也是变量,有自己的地址。当你需要在函数内修改外部的一个指针变量时——比如函数内malloc要传出地址给外面——只传一级指针改不了调用者的变量。这就是二级指针的用武之地。

第二层:如何实现?

int a = 10;
int *p = &a;     // 一级指针,存 a 的地址
int **pp = &p;   // 二级指针,存 p 的地址
// *pp = p, **pp = a

第三层:实际场景与常见问题

// ✅ 正确:二级指针传出地址
void get_memory(char **p) {
    *p = (char *)malloc(100);  // 修改外部指针的值
}

// ❌ 错误:一级指针,外面拿不到
void bug_get_memory(char *p) {
    p = (char *)malloc(100);   // 只改了局部变量p,外面不变
}

或者直接用返回值传出:return (char *)malloc(100);。这在链表操作中尤其重要——删除节点需要修改前驱的next指针。


2.5 函数指针

第一层:解决什么问题?

代码也存放在内存中,函数也有地址。如果想让程序在运行时动态决定调用哪个函数——回调机制、插件系统、状态机——就得把函数当参数传递。没有函数指针,所有函数调用关系必须在编译时就完全确定。

第二层:如何实现?

int (*func_ptr)(int, int);           // 声明:返回类型 (*指针名)(参数类型)
int add(int a, int b) { return a + b; }
func_ptr = add;                      // 函数名隐式转为函数指针
int result = func_ptr(3, 4);         // 通过指针调用,等价 (*func_ptr)(3,4)

第三层:实际场景与常见问题

// 回调:qsort
int compare(const void *a, const void *b) { return *(int*)a - *(int*)b; }
qsort(arr, n, sizeof(int), compare);

// 状态机:函数指针数组
typedef void (*handler_t)(void);
handler_t handlers[] = {state0, state1, state2};
handlers[current_state]();

面试常考:写出返回函数指针的函数声明——void (*signal(int sig, void (*func)(int)))(int)


2.6 sizeof 全解析

第一层:解决什么问题?

分配内存、计算数组元素个数、控制循环边界——到处需要知道类型的大小。如果手动写死 int是4字节,换平台就可能错。需要一种方式让编译器告诉我们类型的大小

第二层:如何实现?

sizeof编译时操作符(不是函数),编译器在编译阶段就将它替换为常量。唯一例外是C99的变长数组(VLA)。

sizeof(int);       // 可能是4
sizeof(char);      // 永远是1(C标准保证,这是计量基准)
sizeof(int*);      // 4或8
sizeof(int[10]);   // 10 × sizeof(int) = 40

第三层:实际场景与常见问题

一维数组:

int a[] = {1, 2, 3, 4};

sizeof(a);         // 16(整个数组)
sizeof(a[0]);      // 4
sizeof(*a);        // 4
sizeof(a + 1);     // 4或8(指针)
sizeof(&a);        // 4或8
sizeof(*&a);       // 16(&a解引用后是数组)
sizeof(&a + 1);    // 4或8
sizeof(&a[0]);     // 4或8
sizeof(&a[0] + 1); // 4或8

字符数组:

char arr[] = "abcdef";  // 共7个元素:a,b,c,d,e,f,\0

sizeof(arr);         // 7(含\0)
sizeof(arr[0]);      // 1
sizeof(*arr);        // 1
sizeof(arr + 1);     // 4或8(指针)
sizeof(&arr);        // 4或8
sizeof(*&arr);       // 7

二维数组:

int a[3][4] = {0};

sizeof(a);               // 48 = 3×4×4
sizeof(a[0][0]);         // 4
sizeof(a[0]);            // 16(一行,类型 int[4])
sizeof(a[0] + 1);        // 4或8(指针)
sizeof(*(a[0] + 1));     // 4(int)
sizeof(a + 1);           // 4或8(指针)
sizeof(*(a + 1));        // 16(一行)
sizeof(&a[0] + 1);       // 4或8(指针)
sizeof(*(&a[0] + 1));    // 16
sizeof(*a);              // 16(第一行)
sizeof(a[3]);            // 16(虽越界但不计算表达式,只推断类型)

核心规律

情况 sizeof结果
数组名单独出现 整个数组大小
数组名参与运算(+1、传参) 退化为指针 → 4或8
地址 4(32位)或8(64位)
表达式 不实际求值,只看类型

2.7 strlen 全解析

第一层:解决什么问题?

C语言没有独立的字符串类型,字符串是以 \0 结尾的char数组。需要一种方式知道字符串多长。strlen 计算以 \0 结尾的字符串的字符数(不含 \0)。

第二层:如何实现?

从首地址开始逐个字节查找 \0,返回经过的字符数。必须遍历整个字符串——每次调用O(n)。

size_t strlen(const char *s) {
    const char *p = s;
    while (*p) p++;
    return p - s;
}

第三层:实际场景与常见问题

char arr[] = "abcdef";  // {'a','b','c','d','e','f','\0'}

strlen(arr);           // 6
strlen(arr + 0);       // 6
strlen(*arr);          // ❌ *arr='a'=97,把97当地址,段错误!
strlen(arr[1]);        // ❌ 同理
strlen(&arr);          // 6(值=首地址,但类型不匹配有警告)
strlen(&arr + 1);      // 不确定(跳过整个数组后的未知区域)
strlen(&arr[0] + 1);   // 5(从索引1开始)

strlen vs sizeof

维度 sizeof strlen
本质 操作符 函数
计算时机 编译时(通常) 运行时
计算内容 类型占内存(字节) 字符串长度(字符数)
包含 \0 包含 不包含
参数可以是类型? 可以 不可以

strlen遇上转义字符

char s[] = "11123456\1123456\t";
// \112 是一个转义字符(八进制),代表ASCII码74('J')
// \t 是一个转义字符(Tab)
// strlen计算的是转义后的实际字符数,不是源码中的字符数

3. C语言库函数

3.1 strlen

2.7 strlen 全解析


3.2 strcpy

第一层:解决什么问题?

C语言的字符串是char数组,不能直接用 = 赋值。strcpy 把源字符串的内容(含 \0)复制到目标地址。

第二层:如何实现?

char *strcpy(char *dest, const char *src) {
    char *ret = dest;
    while ((*dest++ = *src++));
    return ret;
}

返回 dest 是为了支持链式调用。

第三层:实际场景与常见问题

三个经典问题:

问题 原因 解决
目标空间不够 strcpy 不检查缓冲区大小 strncpy 或确保目标足够大
源和目标重叠 标准未定义行为 memmove
缺少 \0 源字符串没有结束符会一直复制到内存中遇到 \0 为止 确保源合法

3.3 strcat

第一层:解决什么问题?

拼接两个字符串。没有 strcat 只能手动计算位置再复制。

第二层:如何实现?

先找到dest的 \0 位置,从那里开始把src含 \0 复制过去。

第三层:实际场景与常见问题

  • 缓冲区溢出风险(和strcpy一样)
  • 必须保证dest已被正确初始化(有 \0),否则找不到拼接起点
  • 返回值是指向dest的指针,支持链式调用

3.4 strstr

第一层:解决什么问题?

在大字符串中查找子串——文本搜索、命令解析都需要。

第二层:如何实现?

在 haystack 的每个位置尝试匹配 needle。朴素实现O(n×m),KMP算法可优化到O(n+m)。

第三层:实际场景与常见问题

char *result = strstr("hello world", "world");
// result 指向字符串中 "world" 的起始地址,找不到返回 NULL

3.5 memcpy

第一层:解决什么问题?

strcpy 只能复制字符串(遇 \0 就停),经常需要复制任意类型的数据块——结构体、数组、二进制数据。memcpy无视内容、按字节复制的通用方案。

第二层:如何实现?

void *memcpy(void *dest, const void *src, size_t n) {
    char *d = (char *)dest;
    const char *s = (const char *)src;
    while (n--) *d++ = *s++;
    return dest;
}

第三层:实际场景与常见问题

  • memcpy 不处理内存重叠——重叠场景用 memmove
  • 编译器通常会内联优化为高效块拷贝指令(如 rep movsb

3.6 memmove

第一层:解决什么问题?

memcpy 不能处理重叠。但数组内部前移/后移数据非常常见,不能用 memcpy 就得自己写安全版本。

第二层:如何实现?

判断src和dest的相对位置:

void *memmove(void *dest, const void *src, size_t n) {
    char *d = (char *)dest;
    const char *s = (const char *)src;
    if (d < s) {
        while (n--) *d++ = *s++;        // 从前往后
    } else if (d > s) {
        d += n - 1; s += n - 1;
        while (n--) *d-- = *s--;        // 从后往前
    }
    return dest;
}

如果dest在src前面,从前往后安全;如果dest在src后面,从后往前避免覆盖。

第三层:实际场景与常见问题

面试中经常要求手写 memmove。关键是判断重叠方向,选择正确的复制顺序。


3.7 memset

第一层:解决什么问题?

初始化大块内存——清零数组、重置结构体、填充缓冲区。没有 memset 只能写循环。

第二层:如何实现?

逐字节设置指定的值。

第三层:实际场景与常见问题

memset逐字节设置的,所以对 int 数组执行 memset(arr, 1, sizeof(arr)),每个元素不是1而是 0x01010101。只有 char 和清零(0)是安全的。


3.8 atoi / itoa

第一层:解决什么问题?

用户输入、命令行参数、文件读取——数据进来是字符串,程序计算需要整数。atoi(ASCII to Integer)和 itoa(Integer to ASCII)就是字符串和整数的互转桥梁。

第二层:如何实现?

atoi:跳过前导空白 → 处理 +/- 符号 → 逐个字符 result = result * 10 + (c - '0')

itoa:逐位取模、倒序存储、处理负数和INT_MIN。

第三层:实际场景与常见问题

函数 问题 替代
atoi 错误返回0,无法区分"输入0"和"解析失败" strtol,能告知哪里出错
itoa 不是C标准库函数,只是某些编译器的扩展 sprintf/snprintf

3.9 库函数模拟实现总结

以上 strlen/strcpy/strcat/strstr/memcpy/memmove/memset/atoi 面试中极其频繁考察。关键不是默写代码,而是理解边界条件:

  • 空指针检查
  • \0 结尾处理
  • 缓冲区溢出
  • 内存重叠方向判断
  • 返回值的用途(链式调用)

4. 自定义类型

4.1 结构体内存对齐

第一层:解决什么问题?

CPU按"字"(word)访问内存——32位一次读4字节,64位一次读8字节。如果一个4字节的int跨两个字的边界(比如从地址2开始),CPU需要读两次才能拼出完整数据,严重降低性能。有些CPU直接报硬件异常。

内存对齐就是用空间换时间:让每个成员放在自己大小的整数倍地址上,CPU一次就能读完。

第二层:如何实现?

对齐规则:

  1. 第一个成员在偏移量0处
  2. 每个成员放在 min(该成员大小, 默认对齐数) 的整数倍偏移处
  3. 结构体总大小 = 最大对齐数的整数倍
  4. 嵌套结构体对齐到它内部最大对齐数的整数倍
struct S {
    char a;   // 偏移0,占1字节,填充3字节
    int b;    // 偏移4(int对齐到4),占4字节
    short c;  // 偏移8,占2字节,填充2字节(对齐到4)
};
// 总大小:12

第三层:实际场景与常见问题

优化结构体大小——把大成员放前面:

struct S1 { char a; int b; short c; };  // 12字节
struct S2 { int b; short c; char a; };  // 8字节

同样的成员,排列顺序不同,大小差很多。#pragma pack 可修改对齐数,但可能导致性能下降或硬件异常。

offsetof宏

#define offsetof(type, member) ((size_t)&(((type *)0)->member))

4.2 枚举

第一层:解决什么问题?

代码中充斥"魔法数字"——status=2 不知道什么意思。枚举给这些值起有意义的名字,让代码可读。

第二层:如何实现?

enum Color { RED, GREEN, BLUE };          // 默认从0开始
enum Status { OK = 200, NOT_FOUND = 404 }; // 可自定义值

编译后枚举常量被替换为对应的整数值——零运行时开销。

第三层:实际场景与常见问题

优点 缺点
可读性 if (color == RED)if (color == 0) 清晰
类型安全 有限的范围约束 C语言枚举本质是int,可随意赋整数值
封装性 不同枚举类型可混用(编译器最多警告),无法迭代

4.3 联合(union)

第一层:解决什么问题?

一个变量在不同时刻需要存不同类型——比如一个"值"可能是整数、浮点或字符串。为每种类型都声明变量浪费内存。联合让所有成员共享同一块内存空间,同一时间只有一个成员有效。

第二层:如何实现?

union Data { int i; float f; char str[20]; };
// sizeof = max(sizeof(i), sizeof(f), sizeof(str)) = 20
// 还要对齐到最大对齐数的倍数

大小规则:联合大小 ≥ 最大成员;必须是所有成员对齐数的整数倍。

union U { char a[5]; int b; };
// sizeof = 8(5向上取整到4的倍数)

第三层:实际场景与常见问题

  • 判断大小端
union { int num; char byte; } u;
u.num = 1;
if (u.byte == 1) // 小端
  • 同一时间只能用一个成员,覆盖写入后其他成员值无效
  • 类型双关(type punning)在C中合法但需小心,C++中行为未定义

5. 内存管理

5.1 malloc / calloc / realloc

第一层:解决什么问题?

局部变量在栈上,函数返回就没了;全局变量在编译时就要确定大小。但程序经常需要运行时动态分配——用户输入多长不确定、文件多大不确定、链表要插入多少节点不确定。堆内存就是"动态分配、按需使用、手动释放"的方案。

第二层:如何实现?

函数 参数 初始化 适用场景
malloc(size) 字节数 不初始化(垃圾值) 一般动态分配
calloc(num, size) 元素个数 + 大小 清零 数组分配
realloc(ptr, new_size) 旧指针 + 新大小 扩容/缩容

realloc 的工作原理:后面空间够→原地扩展;不够→找新地方→复制旧数据→释放旧内存→返回新地址。

第三层:实际场景与常见问题

  1. realloc 可能失败返回NULL——不要把结果直接赋给原指针!
// ❌ 错误:realloc失败,旧指针也丢了(内存泄漏)
p = realloc(p, new_size);

// ✅ 正确
int *tmp = realloc(p, new_size);
if (tmp) p = tmp;
  1. 每次 malloc 必须有对应的 free,否则内存泄漏
  2. free 后把指针置为 NULL,避免野指针
  3. realloc(NULL, size) 等价 malloc(size)realloc(ptr, 0) 等价 free(ptr)(不推荐依赖)

5.2 进程地址空间

第一层:解决什么问题?

局部变量为什么出了函数就没了,全局变量却一直在?为什么 malloc 的变量必须手动释放?这些问题本质上是操作系统对内存的组织管理

第二层:如何实现?

C程序的典型内存布局(低地址→高地址):

┌─────────────┐
│ 代码段(.text) │ 机器指令(只读)
├─────────────┤
│ 数据段(.data) │ 已初始化的全局变量、静态变量
├─────────────┤
│ BSS段(.bss)  │ 未初始化的全局变量、静态变量(自动清零)
├─────────────┤
│  堆(heap)    │ 动态分配(malloc/calloc/realloc),向高地址增长
│     ↓       │
│     ↑       │
│  栈(stack)   │ 局部变量、函数参数、返回地址,向低地址增长
└─────────────┘
int global_init = 10;     // .data
int global_noinit;        // .bss(自动为0)
static int s_init = 20;   // .data
static int s_noinit;      // .bss

void func() {
    int local;                  // 栈
    static int s_local = 30;    // .data(不在栈上!)
    char *p = malloc(100);      // p在栈上,指向的内存块在堆上
}

第三层:实际场景与常见问题

问题 原因 解决
栈溢出 局部数组过大(int a[1000000])或递归太深 大数组用堆、递归改迭代
返回局部变量地址 函数返回后栈帧被回收 用静态变量或堆分配
静态变量"记住"状态 存储于数据段,生命周期=整个程序 利用它做函数调用计数、缓存

6. 文件操作

6.1 文件读写接口

第一层:解决什么问题?

程序数据在内存中,运行结束就消失了。要持久化保存——配置文件、日志、用户数据——必须写到磁盘。文件操作就是内存和磁盘之间的搬运工。

第二层:如何实现?

C标准库对操作系统文件接口的封装:

FILE *fp = fopen("data.txt", "r");   // 打开
fscanf(fp, "%d", &num);              // 格式化读
fprintf(fp, "%d\n", num);            // 格式化写
fgets(buf, size, fp);                // 读一行
fread(buf, size, count, fp);         // 二进制读
fwrite(buf, size, count, fp);        // 二进制写
fseek(fp, offset, whence);           // 移动文件指针
ftell(fp);                           // 获取当前位置
fclose(fp);                          // 关闭(刷新缓冲区)

操作系统级别还有 open/read/write/lseek/close(POSIX系统调用),比C标准库更底层。

第三层:实际场景与常见问题

  • fopen 失败返回 NULL,必须检查
  • fclose 会刷新缓冲区,忘记关闭可能导致数据丢失
  • 文本模式下 \n 在Windows上被转为 \r\n——跨平台要注意
  • fgets 会保留换行符,需手动去除

6.2 二进制文件 vs 文本文件

第一层:解决什么问题?

同样的整数 12345,可以存为人类可读的文本 "12345"(5字节),也可以存为内存中的原始二进制 0x00003039(4字节)。选哪种取决于你更看重可读性/可移植性还是效率/空间

第二层:如何实现?

维度 文本文件 二进制文件
存储方式 每字节代表一个字符 按内存中的二进制原样存储
人类可读
空间效率
读写速度 慢(需格式解析) 快(无需转换)
可移植性 好(不同平台通用) 差(大小端、对齐问题)

第三层:实际场景与常见问题

  • 配置文件、日志、源代码 → 文本文件
  • 图片、音频、序列化数据、数据库文件 → 二进制文件
  • 关键选择因素:需要人读用文本,追求性能空间用二进制

7. 程序的编译与链接

7.1 编译链接四阶段

第一层:解决什么问题?

你写的 .c 文件最终要变成CPU能执行的机器指令。实际项目有多个源文件、多个头文件——怎么把这些零散的代码拼成最终的一个可执行程序?需要一个分阶段、可拆解的流水线。

第二层:如何实现?

阶段 做了什么 输入→输出 命令示例
预处理 展开头文件、宏替换、条件编译、去掉注释 .c.i gcc -E
编译 词法分析→语法分析→语义分析→生成汇编代码 .i.s gcc -S
汇编 汇编代码翻译为机器码,生成目标文件(含符号表) .s.o gcc -c
链接 符号解析 + 重定位,合并多个目标文件和库为可执行程序 .o → 可执行文件 gcc

第三层:实际场景与常见问题

  • 修改一个 .c 只需重新编译该文件、重新链接——这是增量编译(make/CMake)的基础
  • 链接错误 undefined reference to...:只有声明没有定义,或忘了链接对应的库
  • 静态库(.a/.lib)在链接时嵌入可执行文件,动态库(.so/.dll)在运行时加载

7.2 头文件卫士

第一层:解决什么问题?

一个头文件可能被多个源文件 #include 直接或间接包含。如果同一个头文件被包含两次,里面的定义就会出现两份——编译器报"重复定义"错误。头文件卫士就是为了防止头文件被重复包含。

第二层:如何实现?

#ifndef EXAMPLE_H      // 如果 EXAMPLE_H 没被定义过
#define EXAMPLE_H      // 就定义它

// ... 头文件内容 ...

#endif                 // 结束条件编译

第一次包含:EXAMPLE_H 未定义 → 定义它 → 包含内容。第二次包含:EXAMPLE_H 已定义 → 跳过全部内容。

#pragma once 效果相同、更简洁,但不是C标准(主流编译器都支持)。

第三层:实际场景与常见问题

任何 .h 文件的第一行和最后一行都应该有卫士。这是C/C++项目的铁律。


7.3 #include <> vs #include ""

第一层:解决什么问题?

项目中有系统头文件(stdio.h)和自己写的头文件,它们放在不同目录下,需要告诉预处理器去哪里找。

第二层:如何实现?

写法 查找优先级
#include <file.h> 系统标准头文件目录 → -I 指定目录
#include "file.h" 当前源文件所在目录 → 系统标准目录

本质就是搜索路径的优先级不同

第三层:实际场景与常见问题

  • 标准库用 <>#include <stdio.h>
  • 项目头文件用 ""#include "myutils.h"
  • 标准库用 "" 也能找到,但不符合惯例且效率稍低

7.4 宏的优缺点

第一层:解决什么问题?

宏在预处理阶段做文本替换。最初动机:定义常量(没有 const 的年代)、简化重复代码(不用函数调用开销)。

第二层:如何实现?

#define PI 3.14159
#define MAX(a, b) ((a) > (b) ? (a) : (b))
#define SQUARE(x) ((x) * (x))

不是函数调用,是文本替换。 预处理阶段宏名被原样替换为定义内容。

第三层:实际场景与常见问题

优点 缺点
无函数调用开销(代码直接展开) 没有类型检查——参数类型错误不会提示
可做函数做不到的事(如 offsetof 利用零指针) 运算符优先级陷阱——SQUARE(1+2)1+2*1+2=5 不是9
类型无关(MAX(3,5)MAX(3.0,5.0) 都能用) 副作用——MAX(a++, b++) 中a++会被执行多次
难以调试——错误信息指向替换后的代码
没有作用域——整个文件有效,无法限定范围

C++中的替代const 替代宏常量,inline 替代宏函数,template 替代类型无关宏。

// C语言中的改进写法(GCC扩展)
#define MAX(a, b) ({ __typeof__(a) _a = (a); __typeof__(b) _b = (b); _a > _b ? _a : _b; })

8. 关键字

8.1 volatile

第一层:解决什么问题?

编译器为了优化,会把频繁使用的变量缓存到寄存器中。但有些变量的值可能在程序自身的代码之外被改变——硬件寄存器、多线程共享变量、信号处理函数中修改的标志位。如果编译器做了缓存优化,程序读到的就是过时值。

volatile 告诉编译器:“这个变量随时可能被改变,不要对它做优化,每次都用内存读。”

第二层:如何实现?

volatile int flag = 0;

void wait_for_flag() {
    while (flag == 0) {}  // 每次循环都从内存读 flag
}

不加 volatile,编译器可能优化为:load flag into R1; loop: if R1==0 goto loop——永远读寄存器,flag变了也不知道。

第三层:实际场景与常见问题

场景 说明
嵌入式硬件寄存器 硬件随时可能改变寄存器值
中断服务程序 中断中修改的全局变量,主程序不能缓存
多线程标志位 但不保证原子性!不要用volatile替代互斥锁

8.2 extern

第一层:解决什么问题?

项目有多个 .c 文件,a.c 定义了一个全局变量,b.c 要访问它。编译器单独编译 b.c 时发现这个变量只有使用没有定义——需要一种方式告诉编译器:“这个符号在别的文件里定义了,你先相信我,链接时能找到它。”

extern 就是跨文件的声明——让编译器通过编译,链接器来解决问题。

第二层:如何实现?

// a.c
int g_count = 100;           // 定义(分配空间)

// b.c
extern int g_count;           // 声明(不分配空间,只记录符号引用)
void func() { g_count++; }

编译时 extern 不分配内存,只记录符号引用。链接时,链接器把 b.o 中的引用和 a.o 中的定义匹配起来。

第三层:实际场景与常见问题

  • 全局变量跨文件共享(谨慎使用,多用getter/setter模式)
  • 函数的 extern 默认存在——void func(); 等价于 extern void func();
  • 跨C/C++互调用用 extern "C"(告诉C++编译器不要对函数名做mangling)

8.3 static

第一层:解决什么问题?

static 在C语言中扮演两个完全不同的角色:

问题1:有些变量/函数只在当前 .c 文件内使用,不想暴露给其他文件——怎么隐藏?

问题2:函数内某些变量需要在多次调用间保持状态——怎么让局部变量活得比函数调用更久?

第二层:如何实现?

修饰全局变量/函数 —— 限制作用域

// file1.c
static int internal_counter = 0;  // 只在 file1.c 中可见
static void helper() {}           // 只在 file1.c 中可见(链接器不会暴露)

编译器生成本地符号而非全局符号,链接器不会暴露给其他目标文件。这实现了C语言中的"封装"。

修饰局部变量 —— 延长生命周期

void counter() {
    static int count = 0;  // 只初始化一次,存储在 .data 段
    count++;
    printf("调用次数: %d\n", count);
}

第三层:实际场景与常见问题

用法 作用
static 全局变量 模块内部封装,避免命名冲突
static 函数 隐藏实现细节,不让外部调用
static 局部变量 调用计数、缓存计算结果、单例模式

注意:默认情况下函数是 extern 的(全局可见),加 static 才能隐藏。


8.4 const

第一层:解决什么问题?

代码中有些值初始化后不该被改——数学常量、配置项、函数参数(声明只读)。如果程序员不小心修改了,编译器应该能在编译时报错,而不是等到运行时出bug。

const 就是让编译器帮你检查"不该改的东西被改了"这个错误。

第二层:如何实现?

关键记忆法:const* 的哪一边。

const int *p;       // const在*左边 → *p(指向的内容)不能改
int * const p;      // const在*右边 → p(指针本身)不能改
const int * const p;// 两个都不能改

第三层:实际场景与常见问题

  • 函数参数声明只读:void print(const char *str)
  • C语言中 const 变量不是编译期常量(不同于C++),不能用于定义数组大小
  • 可以强制去除 const(int*)&const_var),但要清楚自己在做什么

8.5 typedef

第一层:解决什么问题?

C语言有些类型的写法非常复杂——函数指针、结构体。每次都写完整类型又麻烦又易错。同时如果将来想换类型(如 intlong),需要在代码中到处改。

typedef 给类型起别名,让复杂类型有简洁名字,让类型更换只改一处。

第二层:如何实现?

typedef int INT32;                       // 简单别名
typedef struct { int x, y; } Point;      // 结构体别名
typedef int (*FuncPtr)(int, int);        // 函数指针类型
typedef int IntArray[10];                // 数组类型

typedef 不创建新类型,只是给已有类型取别名。语法上把定义变量的写法中的变量名换成新类型名即可。

第三层:实际场景与常见问题

typedef vs #define

#define PTR1 int*    // 文本替换
typedef int* PTR2;   // 类型别名

PTR1 a, b;  // → int *a, b;   ← a是指针,b是int!
PTR2 c, d;  // → int *c, *d;  ← 两个都是指针!

#define文本替换typedef真正的类型别名——这是根本区别。


8.6 sizeof

第一层:解决什么问题?

分配内存、计算数组长度——到处需要知道类型大小。如果手动写死"int是4字节",换平台就可能错(有些平台int是2字节)。需要让编译器告诉我们。

第二层:如何实现?

sizeof编译时操作符(不是函数),编译阶段替换为常量。唯一例外是C99变长数组(VLA)。

sizeof(int);       // 可能是4
sizeof(char);      // 永远是1(C标准规定,这是计量基准)
sizeof(int*);      // 4或8
sizeof(int[10]);   // 10 × sizeof(int)

第三层:实际场景与常见问题

int a = 5;
printf("%d\n", sizeof(a++));  // 打印4
printf("%d\n", a);            // a还是5!a++没有被执行

sizeof 不计算表达式,编译器只看类型。常见用法:sizeof(arr) / sizeof(arr[0]) 求数组元素个数。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐