C语言核心知识点详解
简介
C语言是现代编程语言的基石,无论是操作系统内核、嵌入式开发还是高性能计算,C语言都扮演着不可替代的角色。本文从数据类型、指针、内存模型、预处理、存储类别等多个维度,系统梳理C语言的核心知识点,帮助开发者在实际工程中写出更高效、更安全的代码。
一、数据类型与内存表示
1.1 整数在计算机中的存储
计算机中所有整数都以**补码(Two's Complement)**的形式存储:
- 正数:原码、反码、补码相同
- 负数:符号位为1,其余位取反后加1
c
// 以8位为例
// -1 的表示:
// 原码:10000001
// 反码:11111110
// 补码:11111111 = 255(无符号视角)
1.2 有符号与无符号的转换
当一个表达式中同时存在有符号和无符号数参与运算时,有符号数会被自动转换为无符号数。负数在转换后会变成一个非常大的正数,这是很多隐蔽Bug的来源。
c
unsigned int a = 10;
int b = -5;
if (b < a) {
// 可能不会按预期执行!
// b 被转换为无符号数后变为一个很大的正数
}
关键规则:只要有无符号数参与运算,所有操作数都会被转换为无符号数。
1.3 数据溢出
c
unsigned char a = 256; // a = 0,只取低8位
char b = 255; // b = -1,溢出后的值取决于类型解释
1.4 sizeof 运算符
sizeof 是一个运算符而非函数,它在编译期就确定结果,返回无符号整数(size_t)。
c
char str[] = "Hello";
sizeof(str); // 6,包含 '\0'
strlen(str); // 5,不包含 '\0'
char *p = "Hello";
sizeof(p); // 4(32位) 或 8(64位),指针大小
strlen(p); // 5
图片占位符:sizeof与strlen对数组和指针的区别示意图
二、指针深入理解
2.1 指针的本质
指针就是一个变量,其值为另一个变量的地址。不同类型的指针在内存中占用的空间相同(32位系统4字节,64位系统8字节),唯一的区别在于指针运算时的偏移量不同。
c
int *p = (int*)1;
p + 1; // 偏移 sizeof(int) = 4,结果为 5
char *q = (char*)1;
q + 1; // 偏移 sizeof(char) = 1,结果为 2
double *r = (double*)1;
r + 1; // 偏移 sizeof(double) = 8,结果为 9
2.2 指针的引用
c
int a = 10;
int *ptr = NULL;
int *&r = ptr; // r 是指针ptr的引用
r = &a; // 等价于 ptr = &a
2.3 数组指针与指针数组
c
int (*p)[5]; // 数组指针:p指向含5个int元素的数组
// p+1 移动 5*sizeof(int)=20 个字节
int a[5];
p = &a; // 取数组名的地址
int *q[4]; // 指针数组:含4个元素的数组,每个元素是int指针
记忆方法 :看变量名先和谁结合。p 先和 * 结合说明是指针,先和 [] 结合说明是数组。
2.4 void 指针
void* 指针可以接受任何类型的地址,但不能直接参与运算,因为编译器不知道它指向的数据大小。使用前必须强制转换为确定类型。
c
void *p = malloc(100);
((int*)p)[0] = 12345; // 当作 int 数组使用
((char*)p)[0] = 'A'; // 当作 char 数组使用
((int**)p)[0] = &some_int; // 当作 int 指针数组使用
2.5 指针相减
两个同类型指针相减的结果是它们之间相隔的元素个数,而不是地址差值。
c
int arr[10];
int *p = &arr[0];
int *q = &arr[5];
printf("%ld", q - p); // 输出 5,不是 20
注意:不同类型的指针相减是没有意义的,编译器也不允许。
2.6 字符串与字符数组
c
char *GetStr1(void) {
char *p = "Hello World";
return p;
// 正确:字符串常量存储在 .rodata 段,函数结束不释放
}
char *GetStr2(void) {
char p[] = "Hello World";
return p;
// 危险!p 是栈上的局部数组,函数返回后内存被释放
}
核心区别:字符串常量是不可修改但生命周期贯穿整个程序;字符数组是可修改的局部变量,存储在栈上,函数结束即释放。
2.7 不能返回局部变量的地址
这是C语言编程中的铁律。局部变量分配在栈上,函数返回后栈帧被回收,返回的地址指向的内存内容是未定义的。
三、const 关键字
const 是限定修饰符,将变量放在只读数据段,对其进行写操作是非法的。
3.1 const 与指针
c
const int *p = &a; // 底层 const:指向的值不能改变,指针本身可以改变
int const *p = &a; // 同上,const int* 和 int const* 等价
int *const p = &a; // 顶层 const:指针本身不能改变,指向的值可以改变
const int *const p = &a; // 两者都不能改变
阅读技巧 :从右往左读,const 修饰它左边最近的符号。
3.2 const 与字符串
c
char *p = "Hello World";
// 可以通过 p[0] 语法访问,但不能通过 *(p+1)='s' 来修改
// 实际上等价于 const char *p = "Hello World"
3.3 赋值兼容性
c
const char *ptr = (char*)"hello"; // 合法:增加限定
char *ptr2 = (const char*)"hello"; // 警告:丢弃 const 限定
规则:等号左边变量的限定符应当包含右边变量的所有限定符。
四、volatile 关键字
volatile 告诉编译器该变量可能被意想不到地改变,禁止编译器对该变量进行优化,确保每次都从内存中重新读取值。
4.1 典型使用场景
c
// 场景1:单片机/硬件寄存器访问
volatile int *XBYTE = (volatile int*)0x1234;
XBYTE[2] = 0x55;
XBYTE[2] = 0x56;
XBYTE[2] = 0x57;
XBYTE[2] = 0x58;
// 不加 volatile,编译器可能只执行最后一条赋值
4.2 const 与 volatile 可以同时使用
c
const volatile int status = 0;
// const:程序代码不能修改
// volatile:可能被外部硬件/中断修改,每次都要重新读取
// 典型场景:只读硬件状态寄存器
五、数组与函数参数
5.1 一维数组作为函数参数
c
int a[4];
// 以下三种形式等价:
void fun(int *a);
void fun(int a[4]);
void fun(int a[]);
// 编译器总是将其解析为指向首元素的指针
5.2 二维数组作为函数参数
c
int a[4][3];
// 以下三种形式等价:
void fun(int (*a)[3]);
void fun(int a[][3]);
void fun(int a[4][3]);
核心原理:C语言中数组作为函数实参时,编译器总是将其解析为指向数组首元素地址的指针。
六、内存对齐
6.1 什么是内存对齐
对于32位系统,默认采用4字节对齐;64位系统采用8字节对齐。内存对齐是一种空间换时间的策略,便于CPU高效访问数据。
c
struct val1 {
char ch; // 1 字节
short s; // 2 字节
int i; // 4 字节
};
// sizeof = 8,成员按大小排列,浪费较少
struct val2 {
char ch; // 1 字节 + 3 字节填充
int a; // 4 字节
short s; // 2 字节 + 2 字节填充
};
// sizeof = 12,排列不佳导致更多填充
图片占位符:两种结构体内存布局对比图
6.2 优化建议
- 按成员大小从大到小排列可以减少填充字节
- 在嵌入式等空间紧张的场景中,可以使用
#pragma pack(1)取消对齐
七、位运算详解
位运算在系统编程中极为重要,它的核心目的是节省存储空间,将最小的存储单元由字节变为位。
7.1 掩码操作
c
// 将最后一位清零(可移植写法)
int b = ~1; // 在所有平台上通用
// 不可移植写法:0xFFFE(只在16位机器上正确)
7.2 异或运算(使用最广泛的位运算)
c
// 规则:相同为0,不同为1
// 1. 与1异或取反
// 2. 与0异或保持不变
// 3. 交换两个值,不需要中间变量
a = a ^ b;
b = a ^ b;
a = a ^ b;
7.3 移位操作注意事项
c
int a = 31;
int x = 0xFFFFFFFF;
x >> 31; // 结果为 1(无符号右移,高位补0)
x >> a; // 结果为 -1(a是有符号数,编译器进行算术右移,高位补1)
// 移位次数不能超过数据类型的位数,否则是未定义行为
7.4 循环右移
c
unsigned int rotate_right(unsigned int a, int n) {
unsigned int b = a << (32 - n);
unsigned int c = a >> n;
c = c & ~(~0U << n);
return c | b;
}
7.5 位运算代替乘除法
c
x << 1; // 等价于 x * 2
x >> 1; // 等价于 x / 2(正数)
x << n; // 等价于 x * (2^n)
12 * 5; // 等价于 (12 << 2) + 12
性能对比:除法指令约50个机器周期,位运算和加法运算仅1-2个周期。
八、变量作用域与存储类别
8.1 局部变量与全局变量
c
// 全局变量:定义在函数外,存储在静态存储区
// 未初始化时,编译器自动初始化为0(数值型)或NULL(指针型)
// 局部变量:定义在函数内,存储在栈帧上
// 未初始化时,值为该内存位置之前的残留值(垃圾值)
8.2 存储类别关键字
| 关键字 | 作用 |
|---|---|
auto |
默认存储类别,编译器自动辨别 |
register |
建议编译器将变量存入寄存器(如循环变量) |
extern |
声明外部定义的全局变量 |
static |
限制作用域或延长局部变量生命周期 |
8.3 static 关键字的三大用途
1. 静态局部变量:延长生命周期至程序结束,但作用域不变
c
void counter(void) {
static int count = 0; // 只初始化一次
count++;
printf("%d\n", count);
}
2. 静态全局变量/函数:限制作用域为当前文件
c
// module.c
static Node head; // 只能在 module.c 中访问
// 对外提供接口
extern int insert(int dat);
extern void print(void);
extern void destroy(void);
3. 模块封装:通过 static + 外部接口函数实现信息隐藏
8.4 访问同名全局变量
c
int a = 10; // 全局变量
void func(void) {
int a = 5; // 局部变量
printf("%d %d", a, ::a); // 输出 5 10(C++中)
}
九、预处理机制
9.1 预处理的三个工作
- 过滤注释
- 包含文件(
#include) - 展开宏(
#define)
9.2 条件编译(调试开关)
c
#define DEBUG 1
#ifdef DEBUG
printf("调试信息:x = %d\n", x);
#else
// 正式发布代码
#endif
9.3 模块化设计原则
- 含有类似功能或操作同一对象的函数应放在同一个
.c文件中 - 需要被其他模块引用的函数定义为外部函数(不加
static) - 仅在本模块中使用的函数和变量应定义为
static,防止命名冲突
十、函数调用与优化
10.1 函数调用的开销
函数调用需要以下步骤,每一步都涉及内存访问:
- 将参数压入堆栈
- 保存寄存器的值
- 保存返回地址
- 跳转到函数入口
10.2 函数优化策略
1. 减少函数调用次数
c
// 优化前
result = fun(1) + fun(1) + fun(1);
// 优化后(如果 fun 无副作用)
result = 3 * fun(1);
2. 使用局部变量替代频繁访问的全局变量
编译器会将使用频率高的局部变量存储在寄存器中,而全局变量每次都要访问内存。
3. 循环优化
c
// 优化前:每次循环都调用 strlen
for (i = 0; i < strlen(buf); i++) { ... }
// 优化后:只计算一次
const int len = strlen(buf);
for (i = 0; i < len; i++) { ... }
4. 表达式优化
c
x = x + 1; // 访问两次内存
x += 1; // 访问一次内存
5. if-else 分支优化
将概率最高的条件放在最前面判断,可以减少平均判断次数。
6. switch 与 if-else
switch 维护一张跳转表,是空间换时间 的典型例子。当分支较多且为离散值时,优先使用 switch。
10.3 inline 关键字(C99)
inline 建议编译器将函数代码直接展开到调用位置,避免函数调用的开销。
c
inline int add(int a, int b) {
return a + b;
}
注意事项:
- 不是所有
inline函数都会被展开,由编译器决定 - 只有代码较短时才会展开,过多使用反而会导致代码膨胀
inline不一定展开,但#define宏一定会展开
10.4 restrict 关键字(C99)
restrict 告诉编译器指针指向的内存区域不会重叠,允许编译器进行更激进的优化。
c
// 无 restrict:编译器不能确定指针是否重叠,只能串行执行
void sum(int *out, int *arr1, int *arr2, int num);
// 有 restrict:编译器确信指针不重叠,可以并行优化
void sum(restrict int *out, restrict int *arr1, restrict int *arr2, int num);
十一、大端与小端
c
// 小端模式(Little Endian):高地址存放高位,低地址存放低位
// Intel x86 架构采用小端模式
// 大端模式(Big Endian):高地址存放低位,低地址存放高位
// 网络字节序采用大端模式
// 判断方法:
int a = 1;
if (*(char*)&a == 1) {
printf("小端模式\n");
}
十二、链接与符号解析
12.1 声明与定义
- 声明:告诉编译器某个符号存在
- 定义:给符号分配内存空间
12.2 符号解析的三个规则
- 不允许有多个强符号定义 :两个文件中不能都有
int a = 4; - 一个强符号和一个弱符号:选择强符号作为定义
- 两个弱符号:先扫描到的作为定义
总结
C语言的核心知识体系涵盖了从数据表示到内存管理、从指针操作到编译优化的方方面面。理解这些底层原理不仅有助于写出高质量的C代码,也是学习C++、操作系统和嵌入式开发的坚实基础。
在实际开发中,尤其需要注意以下几点:
- 深入理解指针和内存模型,避免内存泄漏和非法访问
- 善用
const和static实现封装和信息隐藏 - 在性能敏感场景中,合理使用位运算和编译优化技巧
- 重视代码的模块化设计,遵循高内聚、低耦合原则
原始笔记来源: frasight/上课笔记.c