C语言知识总结
以上strlenstrcpystrcatstrstrmemcpymemmovememsetatoi面试中极其频繁考察。空指针检查\0结尾处理缓冲区溢出内存重叠方向判断返回值的用途(链式调用)第一层:解决什么问题?CPU按"字"(word)访问内存——32位一次读4字节,64位一次读8字节。如果一个4字节的int跨两个字的边界(比如从地址2开始),CPU需要读两次才能拼出完整数据,严重降低性能。有些
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
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一次就能读完。
第二层:如何实现?
对齐规则:
- 第一个成员在偏移量0处
- 每个成员放在
min(该成员大小, 默认对齐数)的整数倍偏移处 - 结构体总大小 = 最大对齐数的整数倍
- 嵌套结构体对齐到它内部最大对齐数的整数倍
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 的工作原理:后面空间够→原地扩展;不够→找新地方→复制旧数据→释放旧内存→返回新地址。
第三层:实际场景与常见问题
realloc可能失败返回NULL——不要把结果直接赋给原指针!
// ❌ 错误:realloc失败,旧指针也丢了(内存泄漏)
p = realloc(p, new_size);
// ✅ 正确
int *tmp = realloc(p, new_size);
if (tmp) p = tmp;
- 每次
malloc必须有对应的free,否则内存泄漏 free后把指针置为 NULL,避免野指针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语言有些类型的写法非常复杂——函数指针、结构体。每次都写完整类型又麻烦又易错。同时如果将来想换类型(如 int 换 long),需要在代码中到处改。
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]) 求数组元素个数。
更多推荐


所有评论(0)