前言

C 语言是一门非常接近底层的语言。它没有复杂的运行时机制,也没有自动内存管理,但正因为如此,C 语言能够让程序员直接理解数据在内存中的组织方式、程序如何被编译和链接、CPU 如何访问变量,以及硬件寄存器如何被操作。

本文整理几个 C 语言中非常重要、也非常容易混淆的知识点:

  • 指针
  • 结构体
  • 位操作
  • 内存布局
  • volatile
  • const
  • 编译与链接过程

这些内容在嵌入式开发、操作系统、驱动开发、网络协议解析、性能优化中都非常常见。


一、指针

指针是 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;

pchar * 类型,因此每次访问 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. 堆区

通过 malloccallocrealloc 动态申请的内存位于堆区。

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 语言的强大之处在于它非常接近底层。

指针让我们可以直接访问内存;结构体让我们组织复杂数据;位操作让我们控制二进制位;内存布局帮助我们理解程序运行机制;volatileconst 让我们更精确地表达变量访问规则;宏提供了预处理阶段的灵活能力;编译链接过程则解释了源代码如何最终变成可执行程序。

这些知识看似分散,但实际上都围绕一个核心问题:

数据在哪里?如何访问?什么时候确定地址?编译器和链接器对它做了什么?

理解了这些内容,才算真正走进了 C 语言的底层世界。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐