引子:
嵌入式C语言高手之路:从算法到Linux高级编程,冲刺12K Offer(上)
今天来一个总结性学习大纲梳理:
主要包括---硬件知识总必:从linux高级、算法、file、parallel、socket、database/linux应用、c++、qt、opencv、实战项目、arm结构、系统移植、内核驱动开发 7月总结
1 为什么要搞这么一篇总结性文章???
因为之前完成了4大金刚的淬炼:
1 c语法
2 100例经典编程
3 牛客热题101热榜
4 晚上课程总结+自我提炼
4个文件夹,呕心沥血两个月,2w行代码巨作
现在除了实战项目,还有一些模块化知识:
2 具体的还缺了哪些知识点???
硬件知识:从linux高级、算法、file、parallel、socket、database/linux应用、c++、qt、opencv、实战项目、arm结构、系统移植、内核驱动开发 --->>>>7月总结
正所谓一山更比一山高!
冲就完事儿了
*mark:
2025.3 -2025.6月 写了2w行代码,也算在csdn写完了30个帖子/排名进入1-2w、入选几次热门技术帖、入选深圳前99名,
接下来继续以下内容:
linux高级、算法、file、parallel、socket、database/linux应用、c++、qt、opencv、实战项目、arm结构、系统移植、内核驱动开发
其实,刚进入深水区的总结归纳......
废话不多说,开始》》》》》》
----------------------------------------------------------------------------------------------------更新于2025.6.28晚上11:39 加班更新!
兄弟们,大家好!我是你们的老朋友,一个在代码世界里摸爬滚打多年的C语言老兵。最近,我看到一篇“自白书”,字里行间透着一股焦虑,但更多的是不服输的劲头——“暑假要准备面试+笔试+投简历”、“25.9月之前要拿到珠三角地区满意的嵌入式开发12k offer了!”
这不就是咱们每一个奋斗在嵌入式战线上的兄弟们的心声嘛!从C语法到100例经典编程,再到牛客热题101,你已经完成了“四大金刚”的淬炼,写了两万多行代码,甚至在优快云上都小有名气。这说明你的基础扎实,毅力惊人!
但是,你也很清楚,现在除了实战项目,还有一大堆“模块化知识”没复习到位:Linux高级、算法、文件I/O、并行编程、网络编程、数据库、C++、Qt、OpenCV、ARM架构、系统移植、内核驱动开发,以及最重要的“企业级嵌入式实战项目”。
正所谓“一山更比一山高”,想要在2025年激烈的求职竞争中脱颖而出,拿到那份心仪的12k offer,光靠“刷题”和“啃语法”可不够,咱们得把“内功”和“外功”都练到极致!
别急,今天这篇文章,就是为你量身定制的“嵌入式C语言高手之路”系列第一篇!我将结合你目前的学习进度,帮你梳理、提炼、总结那些你还没“吃透”的知识点,并给出实战代码和学习建议。目标只有一个:让你彻底搞懂,让你在面试官面前,自信满满地秀出你的硬核实力!
废话不多说,咱们直接进入第一部分的修炼!
第一章:重温经典,夯实C语言高级基石
虽然你已经刷了1.8遍C语言语法和经典编程,但“温故而知新”,咱们还是得把C语言那些“高级”但又“底层”的特性再捋一遍。毕竟,C语言是嵌入式开发的“母语”,它的每一个细节都可能影响到你的代码性能和系统稳定性。
1.1 指针的“艺术”与“陷阱”——嵌入式C的命脉
上次咱们简单聊了指针,但它在嵌入式里,那可真是“命脉”!操作硬件寄存器、内存映射、DMA(直接内存访问)、中断服务程序等等,都离不开指针。用好了,你就是内存的“魔术师”;用不好,那就是各种“野指针”、“内存泄漏”、“段错误”的“噩梦”!
1.1.1 指针的本质:地址的“门牌号”
指针,说白了,就是一个变量,它里面存的不是普通数据,而是另一个变量在内存中的“门牌号”(内存地址)。理解了这一点,你就抓住了指针的“牛鼻子”。
代码示例:指针与内存地址的亲密接触
#include <stdio.h> // 标准输入输出库
#include <stdint.h> // 用于定义固定宽度的整数类型,如 uint32_t,在嵌入式中非常常用
int main() {
int normal_var = 100; // 定义一个普通的整型变量,它有自己的内存地址和值
int *ptr_to_var = &normal_var; // 定义一个指向 int 类型的指针变量 ptr_to_var
// '&' 是取地址运算符,它获取 normal_var 的内存地址,并存储到 ptr_to_var 中
printf("--- 普通变量与指针的基本操作 ---\n");
printf("变量 normal_var 的值: %d\n", normal_var);
printf("变量 normal_var 的地址: %p\n", (void *)&normal_var); // 使用 %p 格式符打印地址,通常需要转换为 void*
printf("指针 ptr_to_var 的值 (它存储的地址): %p\n", (void *)ptr_to_var);
printf("通过指针 ptr_to_var 访问变量 normal_var 的值: %d\n", *ptr_to_var); // '*' 是解引用运算符,访问指针指向的内存中的值
printf("指针 ptr_to_var 自身的地址 (指针变量本身也在内存中): %p\n", (void *)&ptr_to_var);
// 嵌入式中常见的直接操作硬件寄存器:
// 假设我们有一个 GPIO(通用输入输出)端口的数据寄存器,其物理地址是 0x40021000。
// 我们需要直接通过指针来读写这个寄存器,从而控制硬件。
// 'volatile' 关键字在这里至关重要!
// 它告诉编译器,这个内存位置的内容可能会在程序外部(例如硬件)发生改变,
// 因此编译器不要对针对这个地址的读写操作进行优化(比如把多次读写合并成一次,或者直接从CPU缓存中取值)。
// 在嵌入式中,如果操作硬件寄存器不加 volatile,很可能导致程序行为异常或不符合预期。
volatile uint32_t *gpio_data_reg = (volatile uint32_t *)0x40021000;
printf("\n--- 嵌入式中直接操作硬件寄存器示例 ---\n");
printf("模拟 GPIO 数据寄存器地址: %p\n", (void *)gpio_data_reg);
// 模拟写入值到寄存器:
// 假设我们要设置 GPIO 端口的某个输出状态
*gpio_data_reg = 0x000000FF; // 将 0x000000FF 写入到 0x40021000 这个地址
printf("向 GPIO 寄存器写入值 (模拟): 0x%08X\n", *gpio_data_reg); // 打印写入后的值
// 模拟从寄存器读取值:
// 假设我们要读取 GPIO 端口的当前输入状态
uint32_t current_gpio_state = *gpio_data_reg; // 从 0x40021000 这个地址读取值
printf("从 GPIO 寄存器读取值 (模拟): 0x%08X\n", current_gpio_state);
// 进一步操作:例如,只修改寄存器中的某一位
// 假设我们要设置第 0 位为 1,同时不影响其他位
// 常见的位操作:读-改-写 (Read-Modify-Write)
printf("\n--- 寄存器位操作示例 (读-改-写) ---\n");
printf("原始寄存器值: 0x%08X\n", *gpio_data_reg);
*gpio_data_reg |= (1 << 0); // 将第 0 位设置为 1 (使用位或操作)
printf("设置第 0 位为 1 后: 0x%08X\n", *gpio_data_reg);
*gpio_data_reg &= ~(1 << 0); // 将第 0 位设置为 0 (使用位与非操作)
printf("设置第 0 位为 0 后: 0x%08X\n", *gpio_data_reg);
return 0;
}
代码分析:
-
int *ptr_to_var = &normal_var;
:&
是取地址运算符,它返回变量normal_var
的内存地址。这个地址被存储在ptr_to_var
这个指针变量中。 -
*ptr_to_var
:*
是解引用运算符,它访问ptr_to_var
所指向的内存地址中的值。 -
%p
:用于打印地址的格式符,通常会把地址显示为十六进制。 -
嵌入式特有:
volatile uint32_t *gpio_data_reg = (volatile uint32_t *)0x40021000;
-
volatile
:这是个“防优化”的“金钟罩”!它告诉编译器,gpio_data_reg
所指向的内存内容可能会在程序执行过程中被外部(例如硬件、中断、DMA)意外修改,因此编译器不要对对其进行任何“自作聪明”的优化。在操作硬件寄存器时,必须加volatile
,否则你的代码可能在仿真器上跑得好好的,一到真机就“抽风”。 -
uint32_t *
:定义一个指向 32 位无符号整数的指针。硬件寄存器通常是特定位宽的(8位、16位、32位),使用stdint.h
中的uintx_t
类型能确保位宽的准确性,提高代码的可移植性。 -
(volatile uint32_t *)0x40021000
:这是一个强制类型转换,将一个常量地址0x40021000
(这通常是硬件手册中定义的寄存器地址)转换为volatile uint32_t *
类型。这意味着我们直接告诉编译器,这个地址就是一个 32 位宽的硬件寄存器,而且它的内容是易变的。
-
1.1.2 指针与数组:天生一对,相辅相成
在 C 语言里,指针和数组的关系那叫一个“剪不断理还乱”。很多时候,数组名在表达式中会被“降级”为指向其首元素的常量指针。理解它们之间的这种“暧昧”关系,能让你在处理内存和数据结构时游刃有余。
代码示例:指针与数组的“合体”
#include <stdio.h>
int main() {
int numbers[] = {10, 20, 30, 40, 50}; // 定义一个整型数组,编译器会自动计算其大小
int *p = numbers; // 数组名 'numbers' 在这里被隐式转换为指向其首元素 (numbers[0]) 的指针
printf("--- 指针与数组的等价性 ---\n");
printf("numbers[0] 的地址: %p\n", (void *)&numbers[0]);
printf("p 的值 (numbers 数组的首地址): %p\n", (void *)p); // 可以看到,p 的值就是 numbers[0] 的地址
printf("numbers[0] 的值: %d\n", numbers[0]);
printf("通过指针 p 访问 numbers[0] 的值: %d\n", *p); // 解引用 p,得到 numbers[0] 的值
printf("\n--- 指针算术运算:步步为营 ---\n");
// 指针算术运算:指针加 1 意味着移动到下一个“元素”的地址,而不是简单地加 1 个字节。
// 移动的字节数取决于指针所指向的数据类型的大小。
// 例如,如果 int 占 4 字节,那么 p + 1 实际上是 p 的地址 + 4 字节。
printf("p 的当前值: %p\n", (void *)p);
printf("p + 1 的值 (理论上是 numbers[1] 的地址): %p\n", (void *)(p + 1));
printf("通过 p + 1 访问 numbers[1] 的值: %d\n", *(p + 1)); // 解引用 (p+1)
printf("\n--- 使用指针遍历数组:高效且灵活 ---\n");
printf("遍历数组 (使用指针算术):\n");
// 这种方式在嵌入式中很常见,因为它直接操作内存地址,效率高
for (int i = 0; i < 5; i++) {
printf("%d ", *(p + i)); // *(p + i) 等价于 p[i],也等价于 numbers[i]
}
printf("\n");
printf("遍历数组 (指针自增):\n");
int *current_ptr = numbers; // 另一个指针,用于遍历
for (int i = 0; i < 5; i++) {
printf("%d ", *current_ptr); // 打印当前指针指向的值
current_ptr++; // 指针自增,移动到下一个元素
}
printf("\n");
return 0;
}
代码分析:
-
int *p = numbers;
:数组名numbers
在大多数表达式中会被隐式转换为指向其第一个元素numbers[0]
的指针。所以p
就存储了numbers
数组的起始地址。 -
p + 1
:指针加 1,实际上是加上sizeof(int)
个字节。如果int
是 4 字节,那么p + 1
会指向p
后面 4 个字节的地址,也就是numbers[1]
的地址。这种“步长”是根据指针类型自动调整的,非常智能。 -
*(p + i)
或p[i]
:这两种写法是等价的,都表示访问从p
开始偏移i
个元素位置的值。在 C 语言中,数组下标操作符[]
本质上就是指针算术和解引用的语法糖。
1.1.3 指针与函数参数:传递的艺术,修改的权力
在 C 语言中,函数参数传递默认是值传递。这意味着函数会收到参数的一个副本,对副本的修改不会影响到原始变量。但如果你想在函数内部修改外部变量的值,或者传递大型数据结构以避免复制开销,就必须使用指针。
代码示例:指针作为函数参数的妙用
#include <stdio.h>
// 示例1:值传递
// 无法修改外部变量 my_num 的值,因为 num 只是一个副本
void increment_by_value(int num) {
num++; // 这里的 num 是 main 函数中 my_num 的一个局部副本
printf("函数内部 (值传递) num: %d\n", num);
}
// 示例2:地址传递 (通过指针)
// 可以修改外部变量 my_num 的值,因为 num_ptr 存储了 my_num 的地址
void increment_by_pointer(int *num_ptr) {
// (*num_ptr) 先解引用 num_ptr 得到它所指向的值,然后对这个值进行自增操作
(*num_ptr)++;
printf("函数内部 (地址传递) *num_ptr: %d\n", *num_ptr);
}
// 示例3:通过指针交换两个变量的值
// 这是一个经典的例子,展示了指针如何实现函数对多个外部变量的修改
void swap_values(int *a, int *b) {
int temp = *a; // 将 a 指向的值存入临时变量
*a = *b; // 将 b 指向的值赋给 a 指向的位置
*b = temp; // 将临时变量的值赋给 b 指向的位置
}
// 示例4:传递数组给函数 (数组名作为指针传递)
// 传递数组名时,实际上传递的是数组首元素的地址
void print_array(int *arr, int size) {
printf("函数内部打印数组:\n");
for (int i = 0; i < size; i++) {
printf("%d ", arr[i]); // arr[i] 和 *(arr + i) 是等价的
}
printf("\n");
// 注意:这里 arr 只是一个指针,函数内部无法知道数组的原始大小,所以需要 size 参数
}
int main() {
int my_num = 10;
printf("原始 my_num: %d\n", my_num);
increment_by_value(my_num);
printf("调用 increment_by_value 后 my_num: %d\n", my_num); // 仍然是 10,因为是值传递
increment_by_pointer(&my_num); // 传递 my_num 的地址给函数
printf("调用 increment_by_pointer 后 my_num: %d\n", my_num); // 变为 11,因为通过指针修改了原变量
int x = 5, y = 10;
printf("\n交换前: x = %d, y = %d\n", x, y);
swap_values(&x, &y); // 传递 x 和 y 的地址给函数
printf("交换后: x = %d, y = %d\n", x, y); // x = 10, y = 5,成功交换
int data_array[] = {1, 2, 3, 4, 5};
int array_size = sizeof(data_array) / sizeof(data_array[0]);
printf("\n原始数组:\n");
for (int i = 0; i < array_size; i++) {
printf("%d ", data_array[i]);
}
printf("\n");
print_array(data_array, array_size); // 传递数组名和大小
return 0;
}
代码分析:
-
increment_by_value
:num
是my_num
的一个副本,对其修改不会影响my_num
。 -
increment_by_pointer
:num_ptr
存储了my_num
的地址。(*num_ptr)++
通过解引用num_ptr
,直接修改了my_num
所在内存地址的值。 -
swap_values
:这是经典的通过指针交换两个变量值的例子,充分展示了指针在函数间数据修改方面的强大作用。 -
print_array
:当数组名作为函数参数时,它会“退化”为指向其首元素的指针。因此,在函数内部,arr
实际上是一个int *
类型的指针,而不是整个数组。这也是为什么在 C 语言中,传递数组给函数时,通常还需要额外传递一个size
参数来告知数组的实际长度。
1.1.4 多级指针:指向指针的指针,玩转复杂数据结构
多级指针,顾名思义,就是指向指针的指针。它听起来有点绕,但在 C 语言中并不罕见,尤其是在处理动态分配的二维数组、字符串数组、或者需要修改指针本身的值时(比如在函数内部修改一个指向链表头部的指针)。
代码示例:多级指针的“层层深入”
#include <stdio.h>
#include <stdlib.h> // 用于 malloc, free
int main() {
int original_value = 100; // 原始整型变量
int *ptr1 = &original_value; // 一级指针:ptr1 存储 original_value 的地址
int **ptr2 = &ptr1; // 二级指针:ptr2 存储 ptr1 的地址
int ***ptr3 = &ptr2; // 三级指针:ptr3 存储 ptr2 的地址
printf("--- 多级指针的解引用 ---\n");
printf("original_value 的值: %d\n", original_value);
printf("*ptr1 的值 (通过一级指针访问): %d\n", *ptr1); // 解引用 ptr1 得到 original_value 的值
printf("**ptr2 的值 (通过二级指针访问): %d\n", **ptr2); // 先解引用 ptr2 得到 ptr1,再解引用 ptr1 得到 original_value 的值
printf("***ptr3 的值 (通过三级指针访问): %d\n", ***ptr3); // 依此类推,层层解引用
// 通过多级指针修改原始变量的值
printf("\n--- 通过多级指针修改原始变量的值 ---\n");
***ptr3 = 200; // 通过三级指针修改 original_value 的值
printf("修改后 original_value 的值: %d\n", original_value); // 200
// 动态分配二维数组:多级指针的典型应用场景
// 在嵌入式中,动态分配的二维数组常用于图像处理、传感器数据矩阵、或者其他需要灵活大小的二维数据结构。
int rows = 3, cols = 4;
// 1. 分配一个指针数组,每个指针将指向一行数据
int **dynamic_matrix = (int **)malloc(rows * sizeof(int *));
if (dynamic_matrix == NULL) {
perror("动态矩阵:分配行指针失败");
return 1;
}
// 2. 为每一行分配实际的数据空间
for (int i = 0; i < rows; i++) {
dynamic_matrix[i] = (int *)malloc(cols * sizeof(int));
if (dynamic_matrix[i] == NULL) {
perror("动态矩阵:分配列数据失败");
// 错误处理:如果某一行分配失败,需要释放之前已经分配的行内存,避免内存泄漏
for (int j = 0; j < i; j++) {
free(dynamic_matrix[j]);
}
free(dynamic_matrix);
return 1;
}
}
// 填充并打印动态二维数组
printf("\n--- 动态分配的二维数组示例 ---\n");
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
dynamic_matrix[i][j] = i * cols + j; // 简单填充数据
printf("%2d ", dynamic_matrix[i][j]);
}
printf("\n");
}
// 释放动态分配的内存 (注意释放顺序:先释放内层,再释放外层)
printf("\n--- 释放动态分配的内存 ---\n");
for (int i = 0; i < rows; i++) {
free(dynamic_matrix[i]); // 先释放每行数据所占用的内存
}
free(dynamic_matrix); // 最后释放存储行指针的数组内存
printf("动态二维数组内存已释放。\n");
return 0;
}
代码分析:
-
int **ptr2 = &ptr1;
:ptr2
存储了ptr1
的地址。由于ptr1
本身是一个int *
类型(指向int
的指针),所以ptr2
的类型就是int **
(指向int
指针的指针)。 -
**ptr2
:解引用ptr2
得到ptr1
,再解引用ptr1
得到original_value
。多级指针的解引用就是这样一层一层地剥开。 -
动态二维数组: 这是 C 语言中模拟二维数组的常见且灵活的方式。它实际上是一个指针数组,这个指针数组的每个元素又是一个指针,指向一个一维数组。这种结构在处理不确定大小的矩阵、图像数据等场景中非常有用。
-
malloc(rows * sizeof(int *))
:首先分配rows
个int *
类型的指针空间,这些指针将分别指向每一行的起始地址。 -
malloc(cols * sizeof(int))
:然后在循环中,为每一行分配cols
个int
类型的数据空间。 -
内存释放顺序: 释放动态分配的内存时,必须严格按照先内后外的顺序。即先释放每一行的实际数据内存 (
free(dynamic_matrix[i])
),最后再释放存储行指针的数组内存 (free(dynamic_matrix)
)。如果顺序颠倒,就会导致内存泄漏或程序崩溃。
-
1.1.5 函数指针:回调的艺术,灵活的灵魂
函数指针是 C 语言中实现回调机制和灵活设计的强大工具。在嵌入式系统中,它更是无处不在:中断处理函数、事件驱动编程、状态机、协议栈、驱动程序中的操作函数表等等,都离不开函数指针。
代码示例:函数指针的“多态”魅力
#include <stdio.h>
// 定义两个简单的函数,它们有相同的参数列表和返回值类型
int add(int a, int b) {
printf("执行加法操作...\n");
return a + b;
}
int subtract(int a, int b) {
printf("执行减法操作...\n");
return a - b;
}
// 定义一个函数,它接受一个函数指针作为参数。
// 这里的 'operation' 是一个函数指针,它可以指向任何接受两个 int 参数并返回 int 的函数。
// 这种设计使得 perform_operation 函数可以执行不同的具体操作,而无需修改其内部逻辑。
void perform_operation(int (*operation)(int, int), int x, int y) {
printf("--- 执行通用操作 ---\n");
int result = operation(x, y); // 通过函数指针调用它所指向的函数
printf("操作结果: %d\n", result);
}
// 嵌入式中常见的回调函数注册机制:
// 假设我们有一个简单的事件系统,当特定事件发生时,会调用预先注册的处理函数。
// typedef 用于为函数指针类型定义一个别名,提高代码的可读性和可维护性。
typedef void (*EventHandler)(int event_id, void *data);
// 模拟事件处理函数数组(实际中可能更复杂,如链表或哈希表)
// 假设我们最多有 10 个事件类型
EventHandler event_handlers[10] = {NULL}; // 初始化为 NULL
// 模拟事件注册函数:将一个处理函数注册到特定的事件ID
void register_event_handler(int event_id, EventHandler handler) {
if (event_id >= 0 && event_id < 10) {
event_handlers[event_id] = handler;
printf("事件 %d 已注册处理函数。\n", event_id);
} else {
printf("错误:无效的事件 ID %d。\n", event_id);
}
}
// 模拟事件触发函数:当某个事件发生时,调用对应的处理函数
void trigger_event(int event_id, void *data) {
printf("\n--- 模拟事件触发 (ID: %d) ---\n", event_id);
if (event_id >= 0 && event_id < 10 && event_handlers[event_id] != NULL) {
event_handlers[event_id](event_id, data); // 通过函数指针调用注册的处理函数
} else {
printf("事件 %d 没有注册处理函数或 ID 无效。\n", event_id);
}
}
// 实际的事件处理函数:当按钮按下事件发生时被调用
void button_press_handler(int event_id, void *data) {
printf("按钮按下事件 (ID: %d) 发生!接收到数据: %d\n", event_id, *(int*)data);
// 可以在这里执行具体的硬件操作,如点亮LED,发送数据等
}
// 实际的事件处理函数:当传感器数据就绪事件发生时被调用
void sensor_data_ready_handler(int event_id, void *data) {
printf("传感器数据就绪事件 (ID: %d) 发生!数据值: %f\n", event_id, *(float*)data);
// 处理传感器数据,例如发送到云端或进行本地分析
}
int main() {
// 声明并初始化函数指针变量
int (*p_add)(int, int) = add; // p_add 指向 add 函数
int (*p_subtract)(int, int) = subtract; // p_subtract 指向 subtract 函数
printf("--- 通过函数指针直接调用函数 ---\n");
printf("10 + 5 = %d\n", p_add(10, 5));
printf("10 - 5 = %d\n", p_subtract(10, 5));
// 将函数指针作为参数传递给另一个函数
printf("\n--- 将函数指针作为参数传递 ---\n");
perform_operation(add, 20, 10); // perform_operation 调用 add
perform_operation(subtract, 20, 10); // perform_operation 调用 subtract
// 嵌入式回调机制示例:
int button_raw_data = 1; // 模拟按钮的原始数据
float sensor_value = 25.5f; // 模拟传感器数据
// 注册事件处理函数
register_event_handler(0, button_press_handler); // 注册按钮事件处理函数到 ID 0
register_event_handler(1, sensor_data_ready_handler); // 注册传感器事件处理函数到 ID 1
register_event_handler(5, NULL); // 尝试注册一个空处理函数 (通常不这么做,这里只是为了演示)
// 模拟事件发生,触发对应的处理函数
trigger_event(0, &button_raw_data); // 模拟按钮按下
trigger_event(1, &sensor_value); // 模拟传感器数据就绪
trigger_event(99, NULL); // 模拟一个无效事件 ID
trigger_event(5, NULL); // 模拟一个注册了空处理函数的事件
return 0;
}
代码分析:
-
int (*p_add)(int, int) = add;
:这行代码声明了一个名为p_add
的函数指针。它前面的int
表示函数指针所指向的函数返回int
类型,括号里的(*p_add)
表示p_add
是一个指针,而后面的(int, int)
则表示它指向的函数接受两个int
类型的参数。最后,= add;
将add
函数的地址赋给了p_add
。 -
perform_operation(int (*operation)(int, int), int x, int y)
:函数perform_operation
接受一个函数指针作为参数。这种设计模式被称为回调(Callback)。它允许perform_operation
在运行时动态地调用不同的函数,而无需在编译时确定具体调用哪个函数,大大增加了程序的灵活性和可扩展性。 -
typedef void (*EventHandler)(int event_id, void *data);
:使用typedef
为函数指针类型定义别名,是 C 语言中提高代码可读性和可维护性的最佳实践。特别是当函数指针类型比较复杂时,typedef
能让代码看起来更简洁、更易懂。在嵌入式中,中断服务函数(ISR)、各种驱动层面的操作函数表(如文件系统的open/read/write
函数指针表)等,都大量使用了函数指针和typedef
。 -
回调机制: 嵌入式系统常常是事件驱动的。比如,按下按钮、传感器数据达到阈值、定时器溢出、网络数据包到达等等,这些都是事件。通过函数指针,我们可以实现一个通用的事件处理框架:当某个事件发生时,系统就去调用预先注册好的、针对该事件的处理函数。这比传统的
if-else if
结构更加灵活和高效。
总结与提升:指针的“道”与“术”
概念 |
描述 |
嵌入式应用 |
提升建议 |
---|---|---|---|
指针本质 |
存储内存地址的变量 |
硬件寄存器操作、内存映射、DMA、外设控制 |
深入理解目标硬件的内存映射,掌握大小端模式对多字节指针操作的影响。 |
|
告知编译器变量可能被外部修改,防止优化 |
硬件寄存器、中断共享变量、多线程共享变量 |
养成操作硬件寄存器、中断标志位、DMA缓冲区时必加 |
指针运算 |
移动指针到下一个元素,步长取决于数据类型大小 |
数组遍历、缓冲区处理、数据包解析、协议栈数据操作 |
警惕指针越界和未初始化指针,利用 |
指针作参数 |
实现函数对外部变量的修改,避免大结构体复制开销 |
驱动程序参数传递、设备状态控制、数据采集回调 |
区分值传递、地址传递和引用传递(C++),理解何时使用指针参数。 |
多级指针 |
指向指针的指针,用于动态二维数组、复杂链表、树等 |
内存池管理、图像缓冲区、动态数据结构、文件系统管理 |
逐步理解其解引用过程,画图辅助理解,尤其在内存分配和释放时要格外小心。 |
函数指针 |
存储函数地址,实现回调、多态、策略模式 |
中断服务例程 (ISR)、事件处理、状态机、驱动程序接口函数表 |
熟练使用 |
NULL 指针 |
指向地址 0 的指针,表示不指向任何有效内存 |
错误检查、链表或树的末尾标记、无效句柄 |
每次解引用指针前,务必检查是否为 |
野指针 |
指向未知或无效内存地址的指针 |
内存泄漏、程序崩溃、难以调试、安全漏洞 |
及时 |
编程技巧:
-
初始化: 永远初始化你的指针!
int *ptr = NULL;
是个好习惯,能有效避免野指针问题。 -
边界检查: 对所有涉及指针的数组或缓冲区操作,务必进行边界检查,防止越界访问,这是嵌入式系统稳定性的重要保障。
-
const
关键字: 合理使用const
关键字来修饰指针,可以提高代码的安全性和可读性。例如:-
const int *ptr;
:ptr
指向一个常量int
,不能通过ptr
修改*ptr
的值,但ptr
本身可以指向其他地方。 -
int *const ptr;
:ptr
是一个常量指针,它必须在初始化时指向一个地址,之后不能再指向其他地址,但可以通过ptr
修改*ptr
的值。 -
const int *const ptr;
:ptr
是一个常量指针,指向一个常量int
。
-
-
内存泄漏: 每次
malloc
后,都要确保有对应的free
。对于复杂的内存管理,可以考虑引入内存池或引用计数机制,尤其是在资源有限的嵌入式环境中。 -
指针与位操作: 结合指针和位操作(
&
,|
,^
,~
,<<
,>>
),是嵌入式中控制硬件寄存器的基本功。
1.2 文件 I/O:嵌入式数据持久化的基石
在嵌入式系统中,文件 I/O 虽然不如桌面系统那样频繁,但在日志记录、配置存储、固件升级、数据采集、数据持久化等场景中,仍然扮演着重要角色。理解文件操作的底层原理和 C 语言标准库提供的文件 I/O 函数,是必不可少的。
1.2.1 文件操作的基本流程:打开、读写、关闭
文件操作通常遵循一个清晰的生命周期:
-
打开文件: 使用
fopen()
函数打开一个文件,并指定打开模式(读、写、追加等)。成功则返回一个FILE*
类型的文件指针,这是后续所有文件操作的“句柄”。 -
读写文件: 使用
fread()
,fwrite()
,fgetc()
,fputc()
,fgets()
,fputs()
,fprintf()
,fscanf()
等函数进行数据读写。选择哪个函数取决于你是要按字符、按行、按格式化字符串还是按二进制块进行操作。 -
关闭文件: 使用
fclose()
函数关闭文件,释放文件句柄和相关的系统资源(如缓冲区)。这一步至关重要,否则可能导致数据丢失或资源泄漏。
代码示例:文件读写,从文本到二进制
#include <stdio.h> // 标准输入输出库,包含文件 I/O 函数
#include <stdlib.h> // 包含 exit 函数,用于程序异常退出
#include <string.h> // 包含字符串处理函数,如 strlen
// 定义一个简单的结构体,用于演示二进制文件读写
typedef struct {
int id;
char name[20];
float value;
} SensorData;
int main() {
FILE *fp = NULL; // 文件指针,初始化为 NULL 是个好习惯
char text_buffer[100]; // 用于文本读取的缓冲区
const char *text_filename = "sensor_log.txt"; // 文本文件名
const char *binary_filename = "sensor_data.bin"; // 二进制文件名
printf("--- 1. 文本文件写入与读取 ---\n");
// --- 写入文本文件 ---
// "w" 模式:写入模式。如果文件不存在则创建;如果文件存在,则会清空其内容。
fp = fopen(text_filename, "w");
if (fp == NULL) {
perror("文本文件打开失败 (写入)"); // 打印系统错误信息
return 1; // 错误退出
}
fprintf(fp, "传感器日志开始记录...\n"); // 格式化写入字符串
fputs("温度: 25.5 C\n", fp); // 写入字符串
fputs("湿度: 60.2 %RH\n", fp); // 写入字符串
fputc('E', fp); // 写入单个字符
fputc('N', fp);
fputc('D', fp);
fputc('\n', fp); // 添加换行符
fclose(fp); // 关闭文件,确保数据写入磁盘
printf("文本数据已写入 %s\n", text_filename);
// --- 读取文本文件 ---
// "r" 模式:读取模式。文件必须存在,否则 fopen 会返回 NULL。
fp = fopen(text_filename, "r");
if (fp == NULL) {
perror("文本文件打开失败 (读取)");
return 1;
}
printf("从 %s 读取内容:\n", text_filename);
// fgets 逐行读取,直到遇到换行符、EOF 或缓冲区满
while (fgets(text_buffer, sizeof(text_buffer), fp) != NULL) {
printf("%s", text_buffer); // printf 会处理 buffer 中的换行符
}
// fseek(fp, 0, SEEK_SET); // 将文件指针重置到文件开头,以便再次读取
// int ch;
// printf("再次逐字符读取:\n");
// while ((ch = fgetc(fp)) != EOF) { // 逐字符读取,直到文件结束 (EOF)
// putchar(ch);
// }
fclose(fp);
printf("文本文件读取完毕。\n");
printf("\n--- 2. 二进制文件写入与读取 (结构体数据) ---\n");
// --- 写入二进制文件 ---
// "wb" 模式:二进制写入模式。与 "w" 类似,但以二进制方式处理数据,不进行任何字符转换。
fp = fopen(binary_filename, "wb");
if (fp == NULL) {
perror("二进制文件打开失败 (写入)");
return 1;
}
SensorData data1 = {101, "TemperatureSensor", 28.3f};
SensorData data2 = {102, "HumiditySensor", 72.1f};
// fwrite 函数:用于写入二进制数据块。
// 参数:数据源指针,每个数据项的大小,数据项数量,文件指针
size_t written_bytes1 = fwrite(&data1, sizeof(SensorData), 1, fp);
if (written_bytes1 != 1) {
perror("写入 data1 失败");
}
size_t written_bytes2 = fwrite(&data2, sizeof(SensorData), 1, fp);
if (written_bytes2 != 1) {
perror("写入 data2 失败");
}
fclose(fp);
printf("二进制数据已写入 %s (写入 %zu 和 %zu 个结构体)\n", binary_filename, written_bytes1, written_bytes2);
// --- 读取二进制文件 ---
// "rb" 模式:二进制读取模式。
fp = fopen(binary_filename, "rb");
if (fp == NULL) {
perror("二进制文件打开失败 (读取)");
return 1;
}
SensorData read_data;
printf("从 %s 读取二进制内容:\n", binary_filename);
// fread 函数:用于读取二进制数据块。
// 参数:目标缓冲区指针,每个数据项的大小,数据项数量,文件指针
while (fread(&read_data, sizeof(SensorData), 1, fp) == 1) {
printf("ID: %d, Name: %s, Value: %.2f\n", read_data.id, read_data.name, read_data.value);
}
fclose(fp);
printf("二进制文件读取完毕。\n");
// --- 追加写入文件 ---
// "a" 模式:追加模式。如果文件不存在则创建;如果文件存在,则在文件末尾添加内容。
// "ab" 模式:二进制追加模式。
fp = fopen(text_filename, "a");
if (fp == NULL) {
perror("文本文件打开失败 (追加)");
return 1;
}
fprintf(fp, "这是新的追加内容。\n");
fclose(fp);
printf("数据已追加到 %s\n", text_filename);
// --- 再次读取以验证追加 ---
fp = fopen(text_filename, "r");
if (fp == NULL) {
perror("文本文件打开失败 (再次读取)");
return 1;
}
printf("\n追加后 %s 的完整内容:\n", text_filename);
while (fgets(text_buffer, sizeof(text_buffer), fp) != NULL) {
printf("%s", text_buffer);
}
fclose(fp);
// --- 删除文件 (清理) ---
printf("\n--- 清理文件 ---\n");
if (remove(text_filename) == 0) {
printf("文件 %s 已删除。\n", text_filename);
} else {
perror("文件删除失败");
}
if (remove(binary_filename) == 0) {
printf("文件 %s 已删除。\n", binary_filename);
} else {
perror("文件删除失败");
}
return 0;
}
代码分析:
-
fopen(filename, "w")
:以写入模式打开文件。如果filename
不存在则创建,如果存在则清空内容。 -
fprintf()
,fputs()
,fputc()
:常用的文本写入函数。fprintf
支持格式化输出(像printf
一样),fputs
写入字符串,fputc
写入单个字符。它们会进行字符编码转换(例如,将换行符\n
转换为操作系统特定的行结束符)。 -
fclose(fp)
:关闭文件,释放资源。这是个必须的操作,否则你写入的数据可能还在缓冲区里没真正写到磁盘上,或者文件句柄没释放导致资源耗尽。 -
fopen(filename, "r")
:以读取模式打开文件。 -
fgets(buffer, sizeof(buffer), fp)
:从文件读取一行,最多读取sizeof(buffer)-1
个字符,并将结果存储在buffer
中,遇到换行符或 EOF 停止。 -
fgetc(fp)
:从文件读取单个字符。 -
fseek(fp, 0, SEEK_SET)
:移动文件指针。SEEK_SET
表示从文件开头算起。SEEK_CUR
从当前位置算起,SEEK_END
从文件末尾算起。这在需要随机访问文件内容时非常有用。 -
fopen(filename, "a")
:以追加模式打开文件。在文件末尾添加内容。 -
remove(filename)
:删除文件。 -
二进制文件操作:
-
fopen(filename, "wb")
和fopen(filename, "rb")
:"b"
后缀表示二进制模式。在这种模式下,文件 I/O 不会进行任何字符转换,数据会按字节原封不动地读写。这对于存储结构体、图像、音频等非文本数据至关重要。 -
fwrite(&data, sizeof(SensorData), 1, fp)
:将data
结构体的内容按字节写入文件。sizeof(SensorData)
是每个数据项的大小,1
是要写入的数据项数量。 -
fread(&read_data, sizeof(SensorData), 1, fp)
:从文件读取一个SensorData
结构体大小的数据块到read_data
。
-
1.2.2 错误处理与文件模式:严谨是嵌入式的生命线
-
错误检查: 每次
fopen
后,务必检查返回值是否为NULL
。如果为NULL
,表示文件打开失败(可能是文件不存在、权限不足、磁盘空间不足等),可以使用perror()
函数打印系统错误信息,这对于调试和系统健壮性至关重要。 -
文件模式:
-
"r"
:读模式。文件必须存在。 -
"w"
:写模式。文件不存在则创建,存在则清空。 -
"a"
:追加模式。文件不存在则创建,存在则在末尾追加。 -
二进制模式:
rb
,wb
,ab
。在模式字符串后添加b
,表示以二进制方式打开文件。这在嵌入式中处理原始数据流时非常常用,例如读取传感器原始数据、存储固件映像、处理图像像素数据等。
-
1.2.3 嵌入式中的文件系统:不只是FAT32
在嵌入式系统中,文件系统通常不是像桌面操作系统(Windows/Linux)那样简单地基于硬盘的。嵌入式设备往往使用各种专门为 Flash 存储器设计的Flash 文件系统,或者兼容性更好的 SD 卡/eMMC 文件系统,甚至还有RAM 文件系统和裸设备访问。
-
Flash 文件系统:
-
YAFFS2 (Yet Another Flash File System 2): 专门为 NAND Flash 设计,支持磨损均衡、坏块管理,适用于需要高可靠性的嵌入式设备。
-
JFFS2 (Journaling Flash File System 2): 适用于 NOR Flash,也支持磨损均衡和崩溃恢复。
-
UBIFS (Unsorted Block Image File System): 针对大容量 NAND Flash 优化,性能更好,支持压缩。
-
特点: 这些文件系统都考虑了 Flash 存储器独特的擦写特性(擦除块大、擦写寿命有限),通过磨损均衡(Wear Leveling)技术,将数据均匀分布到 Flash 的不同区域,延长存储器寿命;通过坏块管理,跳过损坏的存储区域。
-
-
SD 卡/eMMC 文件系统:
-
通常是
FAT32
,exFAT
,ext4
等。这些文件系统与桌面系统兼容性好,方便数据交换。在嵌入式设备中,SD 卡和 eMMC 常用于存储用户数据、日志、多媒体文件等。 -
FAT32/exFAT: 简单易用,兼容性广,但不支持权限管理和日志。
-
ext4: Linux 下常用的文件系统,支持日志、权限、大文件等特性,更稳定可靠。
-
-
RAM 文件系统:
-
将文件存储在 RAM(内存)中。特点是速度极快,但掉电丢失。常用于临时文件存储、缓存、或者需要高速读写的场景。例如,一些实时操作系统会在 RAM 中创建一个小型的文件系统用于快速存储临时配置或状态数据。
-
-
裸设备访问:
-
在某些对性能要求极高、或者需要直接操作硬件底层存储的场景,开发者可能会选择直接读写 Flash 或其他存储介质的原始扇区,而不经过任何文件系统。这需要开发者对存储器的物理特性和控制器操作非常了解,但可以获得极致的控制和性能,代价是开发难度和维护成本大大增加。
-
总结与提升:文件 I/O 的实践智慧
概念 |
描述 |
嵌入式应用 |
提升建议 |
---|---|---|---|
文件打开/关闭 |
|
配置存储、日志记录、固件升级、数据采集 |
务必检查 |
读写函数选择 |
|
数据采集、传感器数据存储、协议解析、图像视频处理 |
区分文本模式和二进制模式,根据数据类型选择合适的读写函数;理解 |
文件指针定位 |
|
随机读写、大文件处理、特定数据块访问 |
熟练使用 |
错误处理 |
|
系统稳定性、故障诊断、鲁棒性 |
编写健壮的代码,处理所有可能的错误情况,尤其是在文件操作失败时给出清晰的提示。 |
文件系统选择 |
在存储介质上组织和管理文件的方式 |
决定数据持久化的可靠性、性能、兼容性、寿命 |
了解不同嵌入式文件系统(Flash FS, FAT32, ext4, RAM FS)的特点和适用场景,根据项目需求选择最合适的文件系统。 |
缓冲机制 |
文件 I/O 通常有缓冲区,提高效率,减少实际物理读写次数 |
实时性、数据完整性、性能优化 |
理解 |
字节序 |
多字节数据在内存或文件中的存储顺序(大小端) |
跨平台数据交换、协议解析、固件兼容性 |
在处理二进制文件时,尤其是在跨平台或与不同硬件交互时,务必注意字节序问题,必要时进行字节序转换。 |
编程技巧:
-
资源管理: 文件句柄是有限资源,用完即关。即使在函数中间遇到错误,也要确保文件被关闭。可以使用
goto
语句(在 C 语言中,这是处理错误和资源清理的一种常见且有效的方式)或设计资源管理函数来确保资源释放。 -
二进制文件: 存储结构体或原始数据时,使用
fread
/fwrite
和二进制模式。特别注意字节序(大小端)问题,这在不同处理器架构之间交换数据时尤为重要。 -
日志系统: 在嵌入式中,基于文件 I/O 实现一个简单的日志系统是常用的调试和维护手段。可以记录系统运行状态、错误信息、关键事件等,方便离线分析。
-
配置文件: 将设备配置信息存储在文件中,开机时读取,可以实现设备的灵活配置和状态持久化。
写在最后:C语言的深度,嵌入式的广度!
老铁们,今天咱们深入探讨了 C 语言中指针的“道”与“术”,以及文件 I/O 在嵌入式中的应用。你会发现,C 语言的每一个高级特性,在嵌入式开发中都有其独特的价值和挑战。理解这些底层机制,是你在嵌入式领域“横着走”的底气!
这只是咱们“嵌入式C语言高手之路”的第一篇,后面还有更多硬核内容等着你:Linux 高级编程的进程线程、IPC、网络编程,以及 ARM 架构、系统移植、内核驱动开发这些“真功夫”!
每攻克一个难点,你的“内功”就更深一层,离那个“12k offer”也就更近一步!
希望这部分内容对你有所启发。如果你觉得有帮助,那就别忘了点赞、收藏、转发!你的每一个支持,都是我持续分享干货的巨大动力!
我是你们的老朋友,一个热爱C语言和嵌入式开发的程序员,我们下期再见!
第二章:Linux高级编程——嵌入式系统的“操作系统之魂”
兄弟们,咱们的嵌入式设备,从简单的单片机到复杂的智能网关、车载电脑,跑的操作系统可能千差万别。但对于那些高性能、多任务的嵌入式设备,Linux 几乎成了标配。
搞嵌入式 Linux 开发,你不能只停留在“会写代码”的层面,你得深入理解 Linux 的“魂”——它的进程管理、内存管理、文件系统、网络通信等等。这些知识,不仅是面试的“高频考点”,更是你解决实际问题、优化系统性能的“撒手锏”!
这一章,咱们就从 Linux 高级编程的几个核心概念入手,结合 C 语言代码,把它们彻底讲透。
2.1 进程与线程:并发的基石,多任务的舞蹈
在 Linux 系统中,进程和线程是实现并发(Concurreny)和并行(Parallelism)的基石。理解它们,就像理解一支军队里的“师”和“连”的关系,搞清楚了,你才能合理地分配任务,让系统高效运转。
2.1.1 什么是进程?资源的“独立王国”
**进程(Process)**是程序的一次执行实例,是系统进行资源分配和调度的基本单位。简单来说,当你运行一个程序时,操作系统就会为它创建一个进程。
每个进程都有自己独立的:
-
地址空间: 进程有自己独立的内存区域,包括代码段、数据段、堆、栈等。一个进程无法直接访问另一个进程的内存,这保证了进程间的隔离性和安全性。
-
文件描述符表: 每个进程都有自己打开的文件列表。
-
信号处理: 进程有自己的信号处理方式。
-
进程 ID (PID): 唯一的标识符。
进程的创建:fork()
的“分身术”
在 Linux 中,创建新进程最常用的方式是使用 fork()
系统调用。fork()
会创建一个当前进程的副本,这个副本被称为子进程。子进程会继承父进程的几乎所有资源(除了 PID、父进程 ID 等少数属性)。
代码示例:fork()
创建子进程
#include <stdio.h> // 标准输入输出
#include <stdlib.h> // 标准库函数,如 exit
#include <unistd.h> // 包含 fork(), getpid(), getppid() 等 POSIX 系统调用
#include <sys/wait.h> // 包含 wait(), waitpid() 等函数
int main() {
pid_t pid; // pid_t 是一个整数类型,用于存储进程ID
printf("--- 父进程开始执行 (PID: %d) ---\n", getpid());
// 调用 fork() 创建子进程
// fork() 的返回值对于父进程和子进程是不同的:
// - 对于父进程:返回子进程的 PID
// - 对于子进程:返回 0
// - 如果创建失败:返回 -1
pid = fork();
if (pid < 0) {
// fork() 失败
perror("fork 创建子进程失败"); // 打印错误信息
exit(EXIT_FAILURE); // 退出程序,表示失败
} else if (pid == 0) {
// 子进程执行的代码块
// 在子进程中,fork() 返回 0
printf("--- 我是子进程 (PID: %d, 父进程 PID: %d) ---\n", getpid(), getppid());
// 子进程可以执行自己的任务
for (int i = 0; i < 3; i++) {
printf("子进程正在工作... %d\n", i + 1);
sleep(1); // 暂停 1 秒
}
printf("子进程任务完成,即将退出。\n");
exit(EXIT_SUCCESS); // 子进程退出
} else {
// 父进程执行的代码块
// 在父进程中,fork() 返回子进程的 PID
printf("--- 我是父进程 (PID: %d),我的子进程 PID 是: %d ---\n", getpid(), pid);
// 父进程等待子进程结束
// wait(NULL) 会阻塞父进程,直到任意一个子进程结束
// waitpid(pid, NULL, 0) 会阻塞父进程,直到指定的子进程 (pid) 结束
printf("父进程正在等待子进程结束...\n");
waitpid(pid, NULL, 0); // 等待特定子进程结束,NULL 表示不关心子进程的退出状态
printf("父进程检测到子进程 (PID: %d) 已结束。\n", pid);
printf("父进程任务完成,即将退出。\n");
exit(EXIT_SUCCESS); // 父进程退出
}
return 0; // 理论上不会执行到这里,因为上面有 exit()
}
代码分析:
-
fork()
:这是核心。当fork()
被调用时,操作系统会复制当前进程(父进程)的地址空间、文件描述符等,创建一个几乎一模一样的子进程。 -
返回值判断:
-
pid < 0
:fork()
失败,通常是系统资源不足。 -
pid == 0
:当前执行的是子进程。子进程会从fork()
调用点继续执行。 -
pid > 0
:当前执行的是父进程,pid
的值是新创建的子进程的 PID。父进程也会从fork()
调用点继续执行。
-
-
getpid()
:获取当前进程的 PID。 -
getppid()
:获取当前进程的父进程的 PID。 -
exit(EXIT_SUCCESS)
/exit(EXIT_FAILURE)
:用于进程退出。EXIT_SUCCESS
通常为 0,表示成功;EXIT_FAILURE
通常为 1,表示失败。 -
waitpid(pid, NULL, 0)
:父进程通过waitpid()
等待子进程结束。这很重要,可以防止**僵尸进程(Zombie Process)**的产生。如果父进程不等待子进程,子进程结束后会变成僵尸进程,占用系统资源,直到父进程退出或被其他进程(如init
进程)回收。
思维导图:进程生命周期
graph TD
A[程序启动] --> B{创建进程};
B --> C[运行态];
C --> D{fork()};
D -- 返回子进程PID --> E[父进程];
D -- 返回0 --> F[子进程];
E --> G{wait()/waitpid()};
F --> H[执行任务];
H --> I[exit()];
I --> J[僵尸态 (Zombie)];
J -- 父进程调用wait()/waitpid() --> K[回收资源];
K --> L[进程结束];
G -- 子进程结束 --> M[父进程继续执行];
M --> L;
2.1.2 什么是线程?进程内的“轻量级工人”
**线程(Thread)**是进程内的一个执行单元,是 CPU 调度的基本单位。一个进程可以包含一个或多个线程。所有线程共享进程的资源,如地址空间、文件描述符表等。
你可以把进程想象成一个工厂,而线程就是工厂里的工人。所有工人都共享工厂的厂房、设备(进程的内存、文件等),但每个工人有自己的工作台、工具(线程的栈、寄存器等)。
线程的优点:
-
轻量级: 创建、销毁和切换线程的开销比进程小得多。
-
资源共享: 线程间共享进程资源,通信更方便(直接读写共享内存)。
-
提高并发度: 在多核 CPU 上,不同线程可以并行执行,提高系统吞吐量。
线程的创建:pthread_create()
的“多线程魔法”
在 Linux 中,线程通常使用 POSIX 线程库(pthread)来创建和管理。
代码示例:pthread_create()
创建线程
#include <stdio.h> // 标准输入输出
#include <stdlib.h> // 标准库函数
#include <pthread.h> // POSIX 线程库
#include <unistd.h> // 包含 sleep()
// 线程函数:每个线程都会执行这个函数
// 参数 arg 是一个 void* 类型,可以用来传递任何类型的数据给线程
void *thread_function(void *arg) {
char *message = (char *)arg; // 将传入的参数转换为字符串
printf("--- 线程 %lu (PID: %d) 接收到消息: %s ---\n", pthread_self(), getpid(), message);
for (int i = 0; i < 3; i++) {
printf("线程 %lu 正在工作... %d\n", pthread_self(), i + 1);
sleep(1); // 暂停 1 秒
}
printf("线程 %lu 任务完成,即将退出。\n", pthread_self());
// 线程退出,可以返回一个 void* 类型的值
pthread_exit((void *)"线程执行完毕");
}
int main() {
pthread_t thread_id1; // 线程ID变量
pthread_t thread_id2; // 另一个线程ID变量
void *thread_return_value; // 用于接收线程的返回值
char *msg1 = "Hello from Thread 1";
char *msg2 = "Greetings from Thread 2";
printf("--- 主线程开始执行 (PID: %d) ---\n", getpid());
// 创建第一个线程
// pthread_create(thread_id, attributes, start_routine, arg)
// - &thread_id1: 存储新创建线程的 ID
// - NULL: 线程属性,通常为 NULL 使用默认属性
// - thread_function: 线程的入口函数
// - (void *)msg1: 传递给线程函数的参数
if (pthread_create(&thread_id1, NULL, thread_function, (void *)msg1) != 0) {
perror("创建线程 1 失败");
return EXIT_FAILURE;
}
printf("主线程:线程 1 (ID: %lu) 已创建。\n", thread_id1);
// 创建第二个线程
if (pthread_create(&thread_id2, NULL, thread_function, (void *)msg2) != 0) {
perror("创建线程 2 失败");
return EXIT_FAILURE;
}
printf("主线程:线程 2 (ID: %lu) 已创建。\n", thread_id2);
// 主线程等待子线程结束
// pthread_join(thread_id, return_value_ptr)
// - thread_id: 要等待的线程 ID
// - &thread_return_value: 用于接收线程返回值的指针
printf("主线程正在等待线程 1 结束...\n");
if (pthread_join(thread_id1, &thread_return_value) != 0) {
perror("等待线程 1 失败");
return EXIT_FAILURE;
}
printf("主线程:线程 1 (ID: %lu) 已结束,返回值为: %s\n", thread_id1, (char *)thread_return_value);
printf("主线程正在等待线程 2 结束...\n");
if (pthread_join(thread_id2, &thread_return_value) != 0) {
perror("等待线程 2 失败");
return EXIT_FAILURE;
}
printf("主线程:线程 2 (ID: %lu) 已结束,返回值为: %s\n", thread_id2, (char *)thread_return_value);
printf("主线程所有任务完成,即将退出。\n");
return EXIT_SUCCESS;
}
代码分析:
-
pthread_t thread_id;
:pthread_t
是线程 ID 的类型,通常是一个无符号长整型。 -
pthread_create(&thread_id, NULL, thread_function, (void *)msg);
:-
&thread_id
:新创建线程的 ID 将存储在这里。 -
NULL
:线程属性。通常使用NULL
来使用默认属性。 -
thread_function
:线程的入口函数。这个函数必须接受一个void *
类型的参数,并返回void *
。 -
(void *)msg
:传递给线程函数的参数。由于是void *
,你可以传递任何类型的数据,但需要在使用时进行强制类型转换。
-
-
pthread_self()
:获取当前线程的 ID。 -
pthread_exit((void *)"...")
:线程退出函数。它允许线程返回一个值。 -
pthread_join(thread_id, &thread_return_value)
:主线程(或其他线程)通过pthread_join()
等待指定线程结束。这类似于进程的waitpid()
,可以回收线程资源,并获取线程的返回值。如果不join
线程,可能会导致僵尸线程(虽然线程的资源开销比进程小,但仍然是资源)。
2.1.3 进程与线程的对比:知己知彼,百战不殆
理解进程和线程的区别,是你在 Linux 环境下进行并发编程和系统设计的基础。
表格:进程与线程的详细对比
特性 |
进程 (Process) |
线程 (Thread) |
嵌入式开发考量 |
---|---|---|---|
定义 |
程序的一次执行实例,资源分配和调度的基本单位。 |
进程内的一个执行单元,CPU 调度的基本单位。 |
在 RTOS(实时操作系统)中,通常称之为“任务(Task)”,概念上更接近线程。 |
资源 |
拥有独立的地址空间、文件描述符表、信号处理等。 |
共享进程的地址空间、文件描述符表、信号处理等。 |
内存占用: 进程创建开销大,线程创建开销小。嵌入式设备内存有限,多线程是更常见的并发选择。 |
调度 |
操作系统对进程进行调度。 |
操作系统对线程进行调度。 |
实时性: 对于实时性要求高的任务,通常会创建独立的线程(或 RTOS 任务),并设置合适的优先级。 |
通信 |
进程间通信(IPC)机制复杂,如管道、共享内存、消息队列等。 |
线程间通信简单,直接读写共享内存即可,但需要同步机制。 |
数据共享: 线程间共享数据方便,但引入了数据竞争问题,需要互斥锁、信号量等同步机制。进程间通信则需要额外的 IPC 机制,开销较大。 |
开销 |
创建、销毁、切换开销大。 |
创建、销毁、切换开销小。 |
启动速度: 线程启动快,适合需要快速响应的场景。 |
独立性 |
独立性强,一个进程崩溃通常不影响其他进程。 |
独立性弱,一个线程崩溃可能导致整个进程崩溃。 |
稳定性: 进程隔离性强,安全性高,但通信复杂。线程共享性强,通信简单,但需要更精细的同步控制来保证稳定性。在嵌入式中,为了提高系统鲁棒性,有时会将不同功能模块放入独立的进程。 |
并发 |
进程间可以实现并发(多进程)。 |
线程间可以实现并发(多线程)。 |
CPU 利用率: 在多核 CPU 上,多线程可以更好地利用多核优势,实现真正的并行计算。 |
应用 |
独立的应用程序、服务、守护进程。 |
进程内的并发任务、UI 响应、后台数据处理等。 |
场景选择: 对于相互独立、需要强隔离的任务(如不同的应用程序),选择进程;对于需要频繁通信、共享数据、且对性能要求较高的任务(如一个应用程序内的多个功能模块),选择线程。 |
ER 图:进程与线程的关系
erDiagram
PROCESS ||--o{ THREAD : contains
PROCESS {
PID int PK
MemorySpace string
FileDescriptors string
SignalHandlers string
}
THREAD {
TID int PK
Stack string
Registers string
ProgramCounter string
}
2.1.4 进程与线程的选择:嵌入式场景下的权衡
在嵌入式开发中,选择使用进程还是线程,是一个需要仔细权衡的问题:
-
内存资源: 嵌入式设备内存通常有限。进程创建时需要复制父进程的地址空间,开销较大。线程共享进程地址空间,开销小得多。因此,多线程是嵌入式中最常见的并发模型。
-
实时性: 对于需要快速响应、高实时性的任务(如传感器数据采集、电机控制),通常会创建独立的线程(或 RTOS 中的任务),并赋予高优先级,以确保其及时执行。
-
隔离性与稳定性: 进程之间相互隔离,一个进程崩溃通常不会影响其他进程。而一个线程的崩溃可能导致整个进程崩溃。如果你的系统需要极高的稳定性,或者不同模块之间需要严格隔离,可以考虑使用多进程。
-
通信与同步: 线程间通信直接通过共享内存即可,但需要复杂的同步机制来避免数据竞争。进程间通信需要 IPC 机制,虽然开销大,但提供了更强的隔离。
-
CPU 核数: 在单核 CPU 上,多进程和多线程都是通过时间片轮转实现“并发”;在多核 CPU 上,多线程可以真正实现“并行”,充分利用多核优势。
总的来说,在嵌入式 Linux 开发中,多线程是更常用、更高效的并发模型。但对于需要强隔离、或者涉及不同应用程序的场景,多进程依然有其用武之地。
2.2 进程间通信 (IPC):数据流转的艺术
当你的系统中有多个进程(或线程)时,它们之间往往需要进行数据交换和协作。这就引出了**进程间通信(IPC, Inter-Process Communication)**的概念。IPC 是操作系统提供的一组机制,允许不同进程之间进行数据传输和同步。
2.2.1 管道 (Pipe):最简单的“水管”
管道是最简单的 IPC 机制之一,它提供了一个单向的字节流通信通道。数据从管道的一端写入,从另一端读取。管道分为两种:
-
匿名管道 (Unnamed Pipe): 只能用于具有亲缘关系的进程之间(如父子进程)。它没有文件路径,只能通过文件描述符访问。
-
命名管道 (Named Pipe / FIFO): 可以在任意不相关的进程之间进行通信,它有一个文件路径,可以像普通文件一样被打开。
代码示例:匿名管道实现父子进程通信
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h> // 包含 pipe(), fork(), read(), write()
#include <string.h> // 包含 strlen()
#include <sys/wait.h> // 包含 wait()
#define BUFFER_SIZE 256 // 定义缓冲区大小
int main() {
int pipefd[2]; // pipefd[0] 用于读取,pipefd[1] 用于写入
pid_t pid;
char buffer[BUFFER_SIZE];
const char *parent_msg = "Hello, child! From parent process.";
const char *child_msg = "Hello, parent! From child process.";
printf("--- 匿名管道通信示例 ---\n");
// 1. 创建管道
// pipefd[0] 用于读端,pipefd[1] 用于写端
if (pipe(pipefd) == -1) {
perror("创建管道失败");
exit(EXIT_FAILURE);
}
printf("管道创建成功: 读端文件描述符 = %d, 写端文件描述符 = %d\n", pipefd[0], pipefd[1]);
// 2. 创建子进程
pid = fork();
if (pid < 0) {
perror("fork 创建子进程失败");
exit(EXIT_FAILURE);
} else if (pid == 0) {
// 子进程
printf("\n--- 子进程开始执行 (PID: %d) ---\n", getpid());
// 子进程关闭写端 (pipefd[1]),因为它只从管道读取
close(pipefd[1]);
printf("子进程关闭管道写端 %d。\n", pipefd[1]);
// 从管道读取父进程发送的数据
ssize_t bytes_read = read(pipefd[0], buffer, BUFFER_SIZE - 1);
if (bytes_read > 0) {
buffer[bytes_read] = '\0'; // 添加字符串结束符
printf("子进程收到父进程消息: \"%s\" (%zd 字节)\n", buffer, bytes_read);
} else if (bytes_read == 0) {
printf("子进程:管道读端已关闭。\n");
} else {
perror("子进程读取管道失败");
}
// 子进程向管道写入数据,发送给父进程
// 注意:这里子进程需要一个写端,如果之前关闭了,就不能写了。
// 为了演示双向通信,通常需要创建两个管道,或者使用命名管道。
// 这里为了简化,假设子进程只读。如果需要子进程写回,父进程也需要一个读端。
// 为了演示子进程写回,我们让子进程打开一个新的写端(如果它需要写)
// 或者更常见的是,创建两个管道,一个用于父到子,一个用于子到父。
// 这里我们模拟子进程写回,但需要父进程也保留一个读端。
// 实际中,匿名管道是单向的,双向通信需要两个管道。
// 假设我们只演示父写子读。
// 关闭读端
close(pipefd[0]);
printf("子进程关闭管道读端 %d。\n", pipefd[0]);
printf("子进程任务完成,即将退出。\n");
exit(EXIT_SUCCESS);
} else {
// 父进程
printf("\n--- 父进程开始执行 (PID: %d) ---\n", getpid());
// 父进程关闭读端 (pipefd[0]),因为它只向管道写入
close(pipefd[0]);
printf("父进程关闭管道读端 %d。\n", pipefd[0]);
// 父进程向管道写入数据
ssize_t bytes_written = write(pipefd[1], parent_msg, strlen(parent_msg));
if (bytes_written == -1) {
perror("父进程写入管道失败");
} else {
printf("父进程发送消息: \"%s\" (%zd 字节)\n", parent_msg, bytes_written);
}
// 关闭写端,这将导致子进程的 read() 返回 0 (EOF)
close(pipefd[1]);
printf("父进程关闭管道写端 %d。\n", pipefd[1]);
// 父进程等待子进程结束
printf("父进程等待子进程结束...\n");
wait(NULL);
printf("父进程检测到子进程已结束。\n");
printf("父进程任务完成,即将退出。\n");
exit(EXIT_SUCCESS);
}
return 0;
}
代码分析:
-
int pipefd[2];
:这是一个包含两个整数的数组。pipefd[0]
将是管道的读端文件描述符,pipefd[1]
将是管道的写端文件描述符。 -
pipe(pipefd)
:创建管道。成功返回 0,失败返回 -1。 -
关键点:文件描述符的关闭
-
在
fork()
之后,父子进程都继承了管道的两个文件描述符pipefd[0]
和pipefd[1]
。 -
为了实现单向通信,子进程必须关闭写端
pipefd[1]
,因为它只负责从管道读取。 -
父进程必须关闭读端
pipefd[0]
,因为它只负责向管道写入。 -
如果任一端没有关闭不使用的文件描述符,可能会导致
read()
阻塞(因为写端仍然存在,系统认为数据可能还会到来),或者资源泄漏。
-
-
read(pipefd[0], buffer, BUFFER_SIZE - 1)
:从管道的读端读取数据。 -
write(pipefd[1], parent_msg, strlen(parent_msg))
:向管道的写端写入数据。 -
匿名管道的局限性: 只能用于有亲缘关系的进程。如果需要不相关的进程通信,需要使用命名管道(FIFO)。
思维导图:匿名管道通信流程
graph TD
A[父进程] --> B{pipe()};
B -- pipefd[0], pipefd[1] --> C[父进程];
B -- pipefd[0], pipefd[1] --> D[子进程 (fork())];
C --> E[close(pipefd[0])];
D --> F[close(pipefd[1])];
E --> G[write(pipefd[1])];
G --> H[管道];
F --> I[read(pipefd[0])];
H --> I;
G --> J[父进程等待子进程];
I --> K[子进程退出];
K --> J;
J --> L[父进程结束];
代码示例:命名管道 (FIFO) 实现不相关进程通信
命名管道(FIFO)是一个特殊的文件,它存在于文件系统中,因此不相关的进程可以通过打开这个文件来进行通信。
创建 FIFO (在终端执行或在代码中用 mkfifo
):
mkfifo my_fifo
写入进程 (fifo_writer.c):
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h> // 包含 open()
#include <unistd.h> // 包含 write(), close()
#include <sys/stat.h> // 包含 mkfifo()
#define FIFO_NAME "my_fifo" // 命名管道的文件名
int main() {
int fd;
const char *message = "Hello from FIFO writer!";
printf("--- FIFO 写入进程 ---\n");
// 1. 创建 FIFO (如果不存在)
// S_IRUSR | S_IWUSR 表示所有者可读写
if (mkfifo(FIFO_NAME, 0666) == -1) {
// 如果 FIFO 已经存在,mkfifo 会返回 -1 并设置 errno 为 EEXIST
// 此时可以忽略错误,继续打开
if (errno != EEXIST) {
perror("创建 FIFO 失败");
return EXIT_FAILURE;
}
}
printf("FIFO '%s' 已创建或已存在。\n", FIFO_NAME);
// 2. 打开 FIFO (只写模式)
// O_WRONLY: 只写
// O_NONBLOCK: 非阻塞模式,如果 FIFO 没有读端打开,write 不会阻塞
// 实际中通常会使用阻塞模式,等待读端打开
fd = open(FIFO_NAME, O_WRONLY); // 阻塞模式打开,会等待读端打开
if (fd == -1) {
perror("打开 FIFO 失败 (写)");
return EXIT_FAILURE;
}
printf("FIFO '%s' 已打开,准备写入...\n", FIFO_NAME);
// 3. 写入数据到 FIFO
ssize_t bytes_written = write(fd, message, strlen(message));
if (bytes_written == -1) {
perror("写入 FIFO 失败");
close(fd);
return EXIT_FAILURE;
}
printf("已写入 %zd 字节到 FIFO: \"%s\"\n", bytes_written, message);
// 4. 关闭 FIFO
close(fd);
printf("FIFO 写入进程结束。\n");
return EXIT_SUCCESS;
}
读取进程 (fifo_reader.c):
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h> // 包含 open()
#include <unistd.h> // 包含 read(), close()
#include <sys/stat.h> // 包含 mkfifo()
#include <errno.h> // 包含 errno
#define FIFO_NAME "my_fifo"
#define BUFFER_SIZE 256
int main() {
int fd;
char buffer[BUFFER_SIZE];
printf("--- FIFO 读取进程 ---\n");
// 1. 创建 FIFO (如果不存在) - 读取进程也需要确保 FIFO 存在
if (mkfifo(FIFO_NAME, 0666) == -1) {
if (errno != EEXIST) {
perror("创建 FIFO 失败");
return EXIT_FAILURE;
}
}
printf("FIFO '%s' 已创建或已存在。\n", FIFO_NAME);
// 2. 打开 FIFO (只读模式)
// O_RDONLY: 只读
fd = open(FIFO_NAME, O_RDONLY); // 阻塞模式打开,会等待写端打开
if (fd == -1) {
perror("打开 FIFO 失败 (读)");
return EXIT_FAILURE;
}
printf("FIFO '%s' 已打开,准备读取...\n", FIFO_NAME);
// 3. 从 FIFO 读取数据
ssize_t bytes_read = read(fd, buffer, BUFFER_SIZE - 1);
if (bytes_read > 0) {
buffer[bytes_read] = '\0'; // 添加字符串结束符
printf("已从 FIFO 读取 %zd 字节: \"%s\"\n", bytes_read, buffer);
} else if (bytes_read == 0) {
printf("FIFO 读端已关闭 (EOF)。\n");
} else {
perror("从 FIFO 读取失败");
}
// 4. 关闭 FIFO
close(fd);
printf("FIFO 读取进程结束。\n");
// 5. 删除 FIFO 文件 (可选,通常由一个管理进程负责)
// remove(FIFO_NAME); // uncomment this if you want to delete the fifo after use
// printf("FIFO '%s' 已删除。\n", FIFO_NAME);
return EXIT_SUCCESS;
}
如何运行命名管道示例:
-
编译两个文件:
gcc fifo_writer.c -o writer
gcc fifo_reader.c -o reader
-
先运行读取进程(因为它会阻塞等待写入进程):
./reader
-
再在另一个终端运行写入进程:
./writer
你会看到reader
进程收到消息。
命名管道分析:
-
mkfifo(FIFO_NAME, 0666)
:创建命名管道文件。0666
是文件权限,表示所有用户可读写。 -
open(FIFO_NAME, O_WRONLY)
/open(FIFO_NAME, O_RDONLY)
:通过open()
系统调用打开 FIFO。 -
阻塞特性:
open()
一个 FIFO 时,如果另一端没有打开,它会默认阻塞。例如,写进程打开 FIFO 时,如果没有读进程打开,它会一直等待。这保证了通信的同步性。 -
remove(FIFO_NAME)
: FIFO 也是文件,用完后可以删除。
2.2.2 共享内存 (Shared Memory):最快的“高速公路”
共享内存是最高效的 IPC 方式,它允许两个或多个进程直接访问同一块物理内存区域。一旦内存映射完成,进程就可以像访问自己的内存一样访问共享内存,无需通过内核进行数据拷贝,因此速度极快。
代码示例:共享内存实现进程通信
写入进程 (shm_writer.c):
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ipc.h> // 包含 IPC 相关的常量和结构体
#include <sys/shm.h> // 包含 shmget(), shmat(), shmdt(), shmctl()
#include <unistd.h> // 包含 sleep()
#define SHM_KEY 1234 // 共享内存的键值,用于唯一标识共享内存段
#define SHM_SIZE 1024 // 共享内存的大小 (字节)
int main() {
int shmid; // 共享内存 ID
char *shm_ptr; // 指向共享内存的指针
const char *message = "Hello from Shared Memory Writer!";
printf("--- 共享内存写入进程 ---\n");
// 1. 获取共享内存段
// shmget(key, size, flag)
// - key: 共享内存的键值,可以是 IPC_PRIVATE (私有) 或一个 ftok() 生成的键
// - size: 共享内存的大小
// - flag: IPC_CREAT (如果不存在则创建) | IPC_EXCL (如果存在则报错) | 权限 (如 0666)
// 这里使用 IPC_CREAT | 0666,如果不存在就创建,权限是所有用户可读写
shmid = shmget(SHM_KEY, SHM_SIZE, IPC_CREAT | 0666);
if (shmid == -1) {
perror("shmget 获取共享内存失败");
return EXIT_FAILURE;
}
printf("共享内存段 ID: %d\n", shmid);
// 2. 将共享内存段附加到当前进程的地址空间
// shmat(shmid, shmaddr, shmflag)
// - shmid: 共享内存 ID
// - shmaddr: 指定附加的地址,通常为 NULL,让系统自动选择
// - shmflag: 附加标志,通常为 0
shm_ptr = (char *)shmat(shmid, NULL, 0);
if (shm_ptr == (char *)-1) { // shmat 失败返回 (char *)-1
perror("shmat 附加共享内存失败");
return EXIT_FAILURE;
}
printf("共享内存已附加到地址: %p\n", (void *)shm_ptr);
// 3. 向共享内存写入数据
printf("向共享内存写入数据: \"%s\"\n", message);
strncpy(shm_ptr, message, SHM_SIZE - 1); // 写入数据,注意防止越界
shm_ptr[SHM_SIZE - 1] = '\0'; // 确保字符串以 null 结尾
printf("等待 5 秒,让读取进程读取...\n");
sleep(5); // 等待一段时间,让读取进程有机会读取数据
// 4. 将共享内存段从当前进程的地址空间分离
// shmdt(shmaddr)
if (shmdt(shm_ptr) == -1) {
perror("shmdt 分离共享内存失败");
return EXIT_FAILURE;
}
printf("共享内存已分离。\n");
// 5. 删除共享内存段 (通常由一个管理进程或最后一个使用它的进程负责)
// shmctl(shmid, cmd, buf)
// - shmid: 共享内存 ID
// - cmd: IPC_RMID (删除共享内存段)
// - buf: 通常为 NULL
if (shmctl(shmid, IPC_RMID, NULL) == -1) {
perror("shmctl 删除共享内存失败");
return EXIT_FAILURE;
}
printf("共享内存段 ID: %d 已删除。\n", shmid);
printf("共享内存写入进程结束。\n");
return EXIT_SUCCESS;
}
读取进程 (shm_reader.c):
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
#define SHM_KEY 1234
#define SHM_SIZE 1024
int main() {
int shmid;
char *shm_ptr;
printf("--- 共享内存读取进程 ---\n");
// 1. 获取共享内存段 (必须是已存在的)
// shmget(key, size, flag)
// - key: 必须与写入进程使用的 key 相同
// - size: 必须与写入进程创建时的大小相同或更小
// - flag: 0 表示只获取不创建
shmid = shmget(SHM_KEY, SHM_SIZE, 0); // 0 表示只获取,不创建
if (shmid == -1) {
perror("shmget 获取共享内存失败 (可能写入进程未创建或已删除)");
return EXIT_FAILURE;
}
printf("获取到共享内存段 ID: %d\n", shmid);
// 2. 将共享内存段附加到当前进程的地址空间
shm_ptr = (char *)shmat(shmid, NULL, 0);
if (shm_ptr == (char *)-1) {
perror("shmat 附加共享内存失败");
return EXIT_FAILURE;
}
printf("共享内存已附加到地址: %p\n", (void *)shm_ptr);
// 3. 从共享内存读取数据
printf("从共享内存读取数据: \"%s\"\n", shm_ptr);
// 4. 将共享内存段从当前进程的地址空间分离
if (shmdt(shm_ptr) == -1) {
perror("shmdt 分离共享内存失败");
return EXIT_FAILURE;
}
printf("共享内存已分离。\n");
// 注意:读取进程通常不负责删除共享内存,因为可能还有其他进程在使用。
// 删除操作通常由创建者或一个专门的管理进程负责。
// 如果这里也删除,可能导致写入进程的 shmctl 失败 (因为已经被删了)
printf("共享内存读取进程结束。\n");
return EXIT_SUCCESS;
}
如何运行共享内存示例:
-
编译两个文件:
gcc shm_writer.c -o writer -lrt
(注意-lrt
可能需要,用于某些系统上的shm_open
等,这里是 System V IPC,通常不需要)gcc shm_reader.c -o reader -lrt
-
先运行写入进程:
./writer
它会创建共享内存,写入数据,然后等待 5 秒。 -
在写入进程等待的 5 秒内,在另一个终端运行读取进程:
./reader
你会看到reader
进程成功读取数据。 -
等待
writer
进程结束,它会删除共享内存段。
共享内存分析:
-
shmget(SHM_KEY, SHM_SIZE, IPC_CREAT | 0666)
:创建或获取一个共享内存段。SHM_KEY
是一个整数键值,用于唯一标识共享内存。IPC_CREAT
表示如果不存在则创建。0666
是权限。 -
shmat(shmid, NULL, 0)
:将共享内存段附加到当前进程的地址空间。NULL
表示让系统自动选择地址。 -
shmdt(shm_ptr)
:将共享内存段从当前进程的地址空间分离。分离后,进程不能再访问这块内存。 -
shmctl(shmid, IPC_RMID, NULL)
:删除共享内存段。只有当所有附加到该共享内存段的进程都分离后,它才会被真正销毁。如果一个进程在分离前就退出了,操作系统会自动分离。
共享内存的优缺点:
-
优点: 速度最快,因为数据直接在内存中共享,避免了内核拷贝。
-
缺点:
-
同步问题: 进程需要自己实现同步机制(如互斥锁、信号量)来协调对共享内存的访问,否则可能导致数据竞争和不一致。
-
生命周期管理: 共享内存的创建和删除需要手动管理,如果管理不当,可能导致共享内存段一直存在于系统中(即使没有进程使用),成为“孤儿”共享内存。
-
2.2.3 消息队列 (Message Queue):带“信箱”的通信
消息队列是 IPC 的一种方式,它提供了一个消息链表,允许进程以发送和接收消息的方式进行通信。每个消息都有一个类型,接收进程可以根据消息类型选择接收特定的消息。
代码示例:消息队列实现进程通信
发送进程 (msg_sender.c):
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ipc.h> // 包含 IPC 相关的常量和结构体
#include <sys/msg.h> // 包含 msgget(), msgsnd(), msgrcv(), msgctl()
#define MSG_KEY 5678 // 消息队列的键值
#define MAX_MSG_SIZE 256 // 最大消息数据大小
// 定义消息结构体:必须以 long int msg_type 开头
struct message_buffer {
long msg_type; // 消息类型,必须是正整数
char msg_text[MAX_MSG_SIZE]; // 消息数据
};
int main() {
int msgid; // 消息队列 ID
struct message_buffer sbuf; // 发送缓冲区
printf("--- 消息队列发送进程 ---\n");
// 1. 获取消息队列
// msgget(key, flag)
// - key: 消息队列的键值
// - flag: IPC_CREAT (如果不存在则创建) | 权限 (如 0666)
msgid = msgget(MSG_KEY, IPC_CREAT | 0666);
if (msgid == -1) {
perror("msgget 获取消息队列失败");
return EXIT_FAILURE;
}
printf("消息队列 ID: %d\n", msgid);
// 2. 准备消息
sbuf.msg_type = 1; // 消息类型,可以是任意正整数
strcpy(sbuf.msg_text, "Hello from Message Queue Sender!");
// 3. 发送消息
// msgsnd(msgid, msgp, msgsz, msgflg)
// - msgid: 消息队列 ID
// - msgp: 指向消息结构体的指针
// - msgsz: 消息数据的大小 (不包括 msg_type)
// - msgflg: 消息发送标志,通常为 0
if (msgsnd(msgid, &sbuf, strlen(sbuf.msg_text) + 1, 0) == -1) { // +1 for null terminator
perror("msgsnd 发送消息失败");
return EXIT_FAILURE;
}
printf("消息已发送: 类型=%ld, 内容=\"%s\"\n", sbuf.msg_type, sbuf.msg_text);
// 4. 发送另一条不同类型的消息
sbuf.msg_type = 2;
strcpy(sbuf.msg_text, "This is a second message of type 2.");
if (msgsnd(msgid, &sbuf, strlen(sbuf.msg_text) + 1, 0) == -1) {
perror("msgsnd 发送第二条消息失败");
return EXIT_FAILURE;
}
printf("消息已发送: 类型=%ld, 内容=\"%s\"\n", sbuf.msg_type, sbuf.msg_text);
printf("消息队列发送进程结束。\n");
return EXIT_SUCCESS;
}
接收进程 (msg_receiver.c):
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#define MSG_KEY 5678
#define MAX_MSG_SIZE 256
// 定义消息结构体:必须以 long int msg_type 开头
struct message_buffer {
long msg_type;
char msg_text[MAX_MSG_SIZE];
};
int main() {
int msgid;
struct message_buffer rbuf; // 接收缓冲区
printf("--- 消息队列接收进程 ---\n");
// 1. 获取消息队列 (必须是已存在的)
msgid = msgget(MSG_KEY, 0); // 0 表示只获取,不创建
if (msgid == -1) {
perror("msgget 获取消息队列失败 (可能发送进程未创建或已删除)");
return EXIT_FAILURE;
}
printf("获取到消息队列 ID: %d\n", msgid);
// 2. 接收消息 (接收类型为 1 的消息)
// msgrcv(msgid, msgp, msgsz, msgtyp, msgflg)
// - msgid: 消息队列 ID
// - msgp: 指向消息结构体的指针
// - msgsz: 消息数据缓冲区的大小 (不包括 msg_type)
// - msgtyp: 消息类型。
// - > 0: 接收类型为 msgtyp 的消息
// - = 0: 接收队列中的第一个消息
// - < 0: 接收类型小于或等于 msgtyp 绝对值的消息中,类型最小的那个
// - msgflg: 消息接收标志,通常为 0
printf("等待接收类型为 1 的消息...\n");
if (msgrcv(msgid, &rbuf, MAX_MSG_SIZE, 1, 0) == -1) { // 接收类型为 1 的消息
perror("msgrcv 接收消息失败");
return EXIT_FAILURE;
}
printf("收到消息: 类型=%ld, 内容=\"%s\"\n", rbuf.msg_type, rbuf.msg_text);
// 3. 接收另一条消息 (接收类型为 2 的消息)
printf("等待接收类型为 2 的消息...\n");
if (msgrcv(msgid, &rbuf, MAX_MSG_SIZE, 2, 0) == -1) { // 接收类型为 2 的消息
perror("msgrcv 接收第二条消息失败");
return EXIT_FAILURE;
}
printf("收到消息: 类型=%ld, 内容=\"%s\"\n", rbuf.msg_type, rbuf.msg_text);
// 4. 删除消息队列 (通常由创建者或一个管理进程负责)
// msgctl(msgid, cmd, buf)
// - msgid: 消息队列 ID
// - cmd: IPC_RMID (删除消息队列)
// - buf: 通常为 NULL
if (msgctl(msgid, IPC_RMID, NULL) == -1) {
perror("msgctl 删除消息队列失败");
return EXIT_FAILURE;
}
printf("消息队列 ID: %d 已删除。\n", msgid);
printf("消息队列接收进程结束。\n");
return EXIT_SUCCESS;
}
如何运行消息队列示例:
-
编译两个文件:
gcc msg_sender.c -o sender
gcc msg_receiver.c -o receiver
-
先运行发送进程:
./sender
它会发送两条消息。 -
再运行接收进程:
./receiver
你会看到receiver
进程按类型接收消息。
消息队列分析:
-
struct message_buffer
:消息队列中的消息必须定义为一个结构体,并且第一个成员必须是long int msg_type
。这个msg_type
决定了消息的类型,接收方可以根据类型选择接收。 -
msgget(MSG_KEY, IPC_CREAT | 0666)
:创建或获取消息队列。 -
msgsnd(msgid, &sbuf, strlen(sbuf.msg_text) + 1, 0)
:发送消息。strlen(sbuf.msg_text) + 1
是消息数据的大小,包括字符串的\0
结束符。 -
msgrcv(msgid, &rbuf, MAX_MSG_SIZE, 1, 0)
:接收消息。第四个参数1
表示只接收msg_type
为1
的消息。如果消息队列中没有类型为1
的消息,msgrcv
会阻塞直到有这类消息到来。 -
msgctl(msgid, IPC_RMID, NULL)
:删除消息队列。
消息队列的优缺点:
-
优点:
-
提供了消息的发送和接收机制,可以实现异步通信。
-
消息可以带有类型,方便接收方过滤。
-
消息队列是基于文件的,可以被不相关的进程访问。
-
-
缺点:
-
消息的长度有限制。
-
消息的拷贝开销(从用户空间到内核空间,再到另一个用户空间)。
-
2.2.4 信号量 (Semaphore):同步的“交通信号灯”
信号量主要用于进程或线程间的同步,而不是直接传递数据。它是一个计数器,用于控制对共享资源的访问。
-
P 操作(
semop
中的SEM_UNDO
和SEM_FLG
): 减少信号量的值。如果信号量为 0,则阻塞,直到信号量变为正数。这通常用于获取资源。 -
V 操作(
semop
中的SEM_UNDO
和SEM_FLG
): 增加信号量的值。这通常用于释放资源。
代码示例:信号量实现进程同步(读者-写者问题简化版)
sem_writer.c (写者进程):
#include <stdio.h>
#include <stdlib.h>
#include <sys/ipc.h>
#include <sys/sem.h> // 包含信号量相关的函数和结构体
#include <unistd.h> // 包含 sleep()
#define SEM_KEY 7890 // 信号量的键值
// union semun 定义:用于 semctl 函数
// 在某些系统上,semun 联合体可能未定义,需要手动定义
union semun {
int val; // 用于 SETVAL 命令
struct semid_ds *buf; // 用于 IPC_STAT 和 IPC_SET 命令
unsigned short *array; // 用于 GETALL 和 SETALL 命令
struct seminfo *__buf; // 用于 IPC_INFO 命令
};
int main() {
int semid; // 信号量集 ID
struct sembuf sem_op; // 信号量操作结构体
printf("--- 信号量写者进程 ---\n");
// 1. 获取信号量集
// semget(key, nsems, flag)
// - key: 信号量的键值
// - nsems: 信号量集中的信号量数量 (这里我们只需要一个,所以是 1)
// - flag: IPC_CREAT (如果不存在则创建) | 权限 (如 0666)
semid = semget(SEM_KEY, 1, IPC_CREAT | 0666);
if (semid == -1) {
perror("semget 获取信号量失败");
return EXIT_FAILURE;
}
printf("信号量集 ID: %d\n", semid);
// 2. 初始化信号量 (只在创建时执行一次)
// 通常由创建信号量的进程负责初始化
union semun arg;
arg.val = 1; // 将信号量的值初始化为 1 (表示资源可用)
if (semctl(semid, 0, SETVAL, arg) == -1) { // 0 表示信号量集中的第一个信号量
perror("semctl 初始化信号量失败");
return EXIT_FAILURE;
}
printf("信号量已初始化为 1。\n");
// 3. 执行 P 操作 (获取资源)
// sem_op.sem_num: 信号量在集中的索引 (0 表示第一个)
// sem_op.sem_op: 操作值。负数表示 P 操作 (减去),正数表示 V 操作 (增加)
// sem_op.sem_flg: 标志,通常为 0 (阻塞)
sem_op.sem_num = 0;
sem_op.sem_op = -1; // P 操作,信号量值减 1
sem_op.sem_flg = 0;
printf("写者:尝试获取资源 (P 操作)...\n");
if (semop(semid, &sem_op, 1) == -1) { // 1 表示操作的信号量数量
perror("semop P 操作失败");
return EXIT_FAILURE;
}
printf("写者:成功获取资源,正在写入数据...\n");
sleep(3); // 模拟写入数据的时间
// 4. 执行 V 操作 (释放资源)
sem_op.sem_op = 1; // V 操作,信号量值加 1
printf("写者:写入完成,释放资源 (V 操作)...\n");
if (semop(semid, &sem_op, 1) == -1) {
perror("semop V 操作失败");
return EXIT_FAILURE;
}
printf("写者:资源已释放。\n");
// 5. 删除信号量集 (通常由一个管理进程或最后一个使用它的进程负责)
// 这里为了演示,让写者删除
if (semctl(semid, 0, IPC_RMID, NULL) == -1) {
perror("semctl 删除信号量失败");
return EXIT_FAILURE;
}
printf("信号量集 ID: %d 已删除。\n", semid);
printf("信号量写者进程结束。\n");
return EXIT_SUCCESS;
}
sem_reader.c (读者进程):
#include <stdio.h>
#include <stdlib.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <unistd.h>
#define SEM_KEY 7890
// union semun 定义 (与写者进程相同)
union semun {
int val;
struct semid_ds *buf;
unsigned short *array;
struct seminfo *__buf;
};
int main() {
int semid;
struct sembuf sem_op;
printf("--- 信号量读者进程 ---\n");
// 1. 获取信号量集 (必须是已存在的)
semid = semget(SEM_KEY, 1, 0); // 0 表示只获取,不创建
if (semid == -1) {
perror("semget 获取信号量失败 (可能写者进程未创建或已删除)");
return EXIT_FAILURE;
}
printf("获取到信号量集 ID: %d\n", semid);
// 2. 执行 P 操作 (获取资源)
sem_op.sem_num = 0;
sem_op.sem_op = -1; // P 操作,信号量值减 1
sem_op.sem_flg = 0;
printf("读者:尝试获取资源 (P 操作)...\n");
if (semop(semid, &sem_op, 1) == -1) {
perror("semop P 操作失败");
return EXIT_FAILURE;
}
printf("读者:成功获取资源,正在读取数据...\n");
sleep(2); // 模拟读取数据的时间
// 3. 执行 V 操作 (释放资源)
sem_op.sem_op = 1; // V 操作,信号量值加 1
printf("读者:读取完成,释放资源 (V 操作)...\n");
if (semop(semid, &sem_op, 1) == -1) {
perror("semop V 操作失败");
return EXIT_FAILURE;
}
printf("读者:资源已释放。\n");
printf("信号量读者进程结束。\n");
return EXIT_SUCCESS;
}
如何运行信号量示例:
-
编译两个文件:
gcc sem_writer.c -o writer
gcc sem_reader.c -o reader
-
先运行写者进程:
./writer
它会初始化信号量为 1,然后获取资源,写入 3 秒,释放资源,最后删除信号量。 -
在写者进程“写入”的 3 秒内,在另一个终端运行读者进程:
./reader
你会看到读者进程会阻塞,直到写者进程释放资源后,读者进程才能获取资源并开始读取。
信号量分析:
-
semget(SEM_KEY, 1, IPC_CREAT | 0666)
:创建或获取一个信号量集,其中包含 1 个信号量。 -
semctl(semid, 0, SETVAL, arg)
:初始化信号量的值。SETVAL
命令用于设置单个信号量的值。 -
struct sembuf sem_op;
:定义信号量操作结构体。-
sem_num
: 信号量在集中的索引(从 0 开始)。 -
sem_op
: 操作值。-1
表示 P 操作(wait()
),+1
表示 V 操作(signal()
)。 -
sem_flg
: 标志,通常为0
(阻塞)。
-
-
semop(semid, &sem_op, 1)
:执行信号量操作。
信号量的优缺点:
-
优点: 强大的同步工具,可以实现复杂的同步逻辑,如生产者-消费者问题、读者-写者问题。
-
缺点: 容易出错,如果 P/V 操作不匹配,可能导致死锁或资源竞争。
IPC 机制总结与选择:
| IPC 机制 | 优点 | 缺点 | 适用场景