C 语言核心知识梳理:指针、结构体、位操作、内存布局、volatile、const、宏与链接过程
前言
C 语言是一门非常接近底层的语言。它没有复杂的运行时机制,也没有自动内存管理,但正因为如此,C 语言能够让程序员直接理解数据在内存中的组织方式、程序如何被编译和链接、CPU 如何访问变量,以及硬件寄存器如何被操作。
本文整理几个 C 语言中非常重要、也非常容易混淆的知识点:
- 指针
- 结构体
- 位操作
- 内存布局
volatileconst- 宏
- 编译与链接过程
这些内容在嵌入式开发、操作系统、驱动开发、网络协议解析、性能优化中都非常常见。
一、指针
指针是 C 语言的灵魂。简单来说,指针就是“保存地址的变量”。
int a = 10;
int *p = &a;
这里:
a是一个int类型变量&a表示变量a的地址p是一个指针变量,用来保存int类型变量的地址*p表示访问指针指向的内容
printf("%d\n", *p); // 输出 10
1. 指针变量本身也占内存
很多初学者会误以为指针就是变量本身,其实不是。
int a = 10;
int *p = &a;
内存关系大致如下:
变量 a:保存整数 10
变量 p:保存变量 a 的地址
在 32 位系统中,指针通常占 4 字节;在 64 位系统中,指针通常占 8 字节。
printf("%zu\n", sizeof(p));
2. 指针类型决定访问方式
int a = 0x12345678;
char *p = (char *)&a;
p 是 char * 类型,因此每次访问 1 字节。
printf("%x\n", *p);
这类写法常用于分析大小端、内存布局和底层协议。
二、结构体
结构体用于把多个不同类型的数据组合成一个整体。
struct Student {
int id;
char name[20];
float score;
};
使用方式:
struct Student stu = {1, "Tom", 95.5};
printf("%d %s %.1f\n", stu.id, stu.name, stu.score);
1. 结构体指针
struct Student *p = &stu;
printf("%d\n", p->id);
p->id 等价于:
(*p).id
因为 . 的优先级高于 *,所以必须写成 (*p).id。
2. 结构体内存对齐
结构体的大小不一定等于所有成员大小之和。
struct A {
char c;
int i;
};
很多系统中:
sizeof(struct A)
结果可能是 8,而不是 5。
原因是 CPU 访问内存时通常要求数据按照一定边界对齐,比如 int 通常按 4 字节对齐。编译器会在成员之间插入填充字节。
内存可能类似这样:
c padding padding padding i i i i
1字节 3字节填充 4字节
3. 减少结构体填充
可以调整成员顺序:
struct B {
int i;
char c;
};
虽然它的大小通常仍然是 8,但在成员更多时,合理排序可以减少内存浪费。
三、位操作
位操作直接操作二进制位,在底层开发中非常常见。
常见位运算符:
& 按位与
| 按位或
^ 按位异或
~ 按位取反
<< 左移
>> 右移
1. 设置某一位
将第 n 位置 1:
value |= (1 << n);
2. 清除某一位
将第 n 位置 0:
value &= ~(1 << n);
3. 判断某一位是否为 1
if (value & (1 << n)) {
printf("bit is set\n");
}
4. 翻转某一位
value ^= (1 << n);
5. 示例:操作寄存器标志位
#define FLAG_ENABLE (1 << 0)
#define FLAG_READY (1 << 1)
#define FLAG_ERROR (1 << 2)
unsigned int reg = 0;
reg |= FLAG_ENABLE; // 设置 ENABLE 位
reg &= ~FLAG_ERROR; // 清除 ERROR 位
if (reg & FLAG_READY) {
// READY 位被置位
}
四、C 程序的内存布局
一个典型 C 程序运行时,内存大致可以分为以下几个区域:
高地址
+------------------+
| 栈区 stack |
+------------------+
| 堆区 heap |
+------------------+
| BSS 段 |
+------------------+
| 数据段 data |
+------------------+
| 代码段 text |
+------------------+
低地址
1. 代码段
保存程序的机器指令。
int main(void) {
return 0;
}
编译后的指令通常存放在代码段。
2. 数据段
保存已初始化的全局变量和静态变量。
int g_value = 10;
static int s_value = 20;
3. BSS 段
保存未初始化或初始化为 0 的全局变量、静态变量。
int g_count;
static int s_count;
这些变量在程序启动时会被初始化为 0。
4. 堆区
通过 malloc、calloc、realloc 动态申请的内存位于堆区。
int *p = malloc(sizeof(int));
*p = 100;
free(p);
使用堆内存时,必须注意释放,否则会造成内存泄漏。
5. 栈区
局部变量、函数参数、返回地址通常位于栈区。
void func(void) {
int a = 10;
}
函数调用结束后,栈上的局部变量会自动释放。
五、volatile 关键字
volatile 表示变量的值可能会在程序控制之外发生变化,要求编译器每次都从内存中读取,不要随意优化。
典型场景:
- 硬件寄存器
- 中断服务程序共享变量
- 多线程共享变量,注意它不等价于线程安全
- 内存映射 IO
示例:硬件寄存器
#define REG_STATUS (*(volatile unsigned int *)0x40000000)
while ((REG_STATUS & 0x01) == 0) {
// 等待硬件状态变化
}
如果没有 volatile,编译器可能认为 REG_STATUS 在循环中不会变化,从而进行错误优化。
volatile 不能保证原子性
volatile int count = 0;
count++;
count++ 不是原子操作,通常包含:
读取 count
加 1
写回 count
所以 volatile 不能替代锁、原子操作或内存屏障。
六、const 关键字
const 表示只读约束,它告诉编译器某个对象不应该被修改。
1. 修饰普通变量
const int a = 10;
此时不能通过 a 修改值:
// a = 20; // 错误
2. 指向常量的指针
const int *p;
表示不能通过 p 修改它指向的值。
int a = 10;
const int *p = &a;
// *p = 20; // 错误
p = NULL; // 可以
3. 常量指针
int *const p = &a;
表示指针变量 p 本身不能变,但可以修改它指向的内容。
*p = 20; // 可以
// p = NULL; // 错误
4. 指向常量的常量指针
const int *const p = &a;
表示:
- 不能修改
p - 不能通过
p修改*p
5. const 和宏的区别
#define SIZE 10
const int size = 10;
区别:
- 宏在预处理阶段进行文本替换
const变量有类型检查const变量通常更安全- 宏不占用变量存储空间,但可能带来副作用
七、宏
宏由预处理器处理,发生在编译之前。
#define PI 3.14159
#define MAX(a, b) ((a) > (b) ? (a) : (b))
1. 宏的本质是文本替换
#define N 100
int arr[N];
预处理后大致变成:
int arr[100];
2. 宏函数要注意括号
错误写法:
#define SQUARE(x) x * x
使用:
int a = SQUARE(1 + 2);
展开后:
int a = 1 + 2 * 1 + 2;
结果是 5,不是 9。
正确写法:
#define SQUARE(x) ((x) * (x))
3. 宏可能导致副作用
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int x = 1;
int y = 2;
int z = MAX(x++, y++);
展开后,x++ 或 y++ 可能被执行多次,容易产生难以发现的问题。
如果可以,优先使用 static inline 函数:
static inline int max_int(int a, int b) {
return a > b ? a : b;
}
八、C 程序的编译与链接过程
一个 C 程序从源代码到可执行文件,通常经历四个阶段:
预处理 -> 编译 -> 汇编 -> 链接
以文件 main.c 为例。
1. 预处理
处理 #include、#define、条件编译等内容。
gcc -E main.c -o main.i
预处理后,头文件会被展开,宏会被替换。
2. 编译
把预处理后的 C 代码转换成汇编代码。
gcc -S main.i -o main.s
3. 汇编
把汇编代码转换成目标文件。
gcc -c main.s -o main.o
目标文件中已经包含机器码,但还不能独立运行。
4. 链接
把多个目标文件和库文件组合成最终可执行文件。
gcc main.o add.o -o app
链接器主要负责:
- 符号解析
- 地址重定位
- 合并代码段、数据段
- 引入静态库或动态库
5. 符号解析示例
main.c:
#include <stdio.h>
extern int add(int a, int b);
int main(void) {
printf("%d\n", add(1, 2));
return 0;
}
add.c:
int add(int a, int b) {
return a + b;
}
编译:
gcc -c main.c -o main.o
gcc -c add.c -o add.o
gcc main.o add.o -o app
如果链接时缺少 add.o:
gcc main.o -o app
就会出现类似错误:
undefined reference to `add'
这说明编译器知道有一个 add 函数,但链接器找不到它的实现。
九、几个容易混淆的问题
1. 数组名和指针一样吗?
不完全一样。
int arr[10];
int *p = arr;
大多数表达式中,数组名会退化为指向首元素的指针。
但在 sizeof 中不同:
sizeof(arr); // 整个数组大小
sizeof(p); // 指针大小
2. const 变量一定不能改吗?
从语义上不能改。但如果强制转换指针去修改,属于未定义行为或危险行为。
const int a = 10;
int *p = (int *)&a;
*p = 20; // 不推荐,行为不可靠
3. volatile 能解决多线程并发问题吗?
不能。volatile 只限制编译器优化,不保证原子性,也不保证线程同步。
多线程中应使用:
- 互斥锁
- 原子变量
- 条件变量
- 内存屏障
总结
C 语言的强大之处在于它非常接近底层。
指针让我们可以直接访问内存;结构体让我们组织复杂数据;位操作让我们控制二进制位;内存布局帮助我们理解程序运行机制;volatile 和 const 让我们更精确地表达变量访问规则;宏提供了预处理阶段的灵活能力;编译链接过程则解释了源代码如何最终变成可执行程序。
这些知识看似分散,但实际上都围绕一个核心问题:
数据在哪里?如何访问?什么时候确定地址?编译器和链接器对它做了什么?
理解了这些内容,才算真正走进了 C 语言的底层世界。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)