今天继续更新嵌入式硬件系统开发之进程管理>>>>
深入理解进程:从底层原理到嵌入式实战(3-4 万字详解)
前言:为什么嵌入式开发者必须吃透进程?
作为嵌入式开发者,你可能会说:“我平时用的 RTOS 里只有任务(Task),没有进程啊!” 但如果你想在珠三角拿到 12k + 的嵌入式开发 offer,尤其是进入智能硬件或汽车电子领域,进程管理是绕不开的硬骨头 ——
- 智能硬件常需要 Linux 系统跑应用程序,多进程协作是基础
- 汽车电子的 ECU(电子控制单元)里,RTOS 的任务管理本质是简化的进程管理
- 面试时,进程相关知识点(如 IPC、调度算法)是大厂必考题
本文将从 “是什么 - 为什么 - 怎么做” 三个维度,用 3-4 万字的篇幅彻底讲透进程。包含 15 + 代码示例、8 张思维导图、10 + 实战案例,保证刷过牛客 100 题的嵌入式开发者都能看懂。
一、进程的本质:从 “死代码” 到 “活程序” 的蜕变
1.1 程序与进程的核心区别(附实例对比)
很多人搞不清 “程序” 和 “进程” 的区别,我们用一个嵌入式场景举例:
程序(Program):你写的led_blink.c编译后生成的led_blink.elf文件,存储在开发板的 Flash 里,这是静态的—— 就像一本菜谱,躺在书架上不会自己做菜。
进程(Process):当你在 Linux 开发板上执行./led_blink,操作系统会把led_blink.elf加载到内存,分配 CPU 时间、GPIO 资源,让代码跑起来 —— 这是动态的,就像厨师按照菜谱实际做菜的过程。
用表格对比关键区别:
对比项 |
程序(Program) |
进程(Process) |
嵌入式场景举例 |
存在形式 |
静态文件(.elf/.bin) |
动态执行过程 |
Flash 里的固件 vs 运行中的固件 |
资源占用 |
不占用 CPU / 内存(仅占磁盘) |
占用 CPU、内存、I/O 资源 |
未运行的 APP vs 后台运行的 WiFi 服务 |
生命周期 |
永久存在(除非删除文件) |
有创建、运行、终止的过程 |
下载固件 vs 启动 / 关闭传感器服务 |
独立性 |
无(多个程序可共享文件) |
独立地址空间、独立资源 |
多个任务共享 UART vs 进程独占 SPI |
实战验证:在 Linux 开发板上执行ls -l /bin/ls(查看程序)和ps -ef | grep ls(查看进程),前者显示文件属性,后者显示运行状态。
1.2 进程的 “三要素”:程序、数据、PCB
一个进程能跑起来,必须具备三个核心要素:
- 程序段(Code Segment):存放指令,比如while(1){toggle_led();delay(1000);}
- 数据段(Data Segment):存放变量,比如int led_state = 0;(全局变量)、栈上的局部变量
- 进程控制块(PCB):操作系统管理进程的 “身份证”,记录进程状态、资源等信息
用思维导图展示三者关系:
graph TD A[进程] --> B[程序段] A --> C[数据段] A --> D[PCB] B --> B1[机器指令] B --> B2[函数库调用] C --> C1[全局变量] C --> C2[局部变量] C --> C3[常量] D --> D1[进程ID] D --> D2[状态] D --> D3[CPU寄存器] D --> D4[内存指针] D --> D5[打开文件列表] |
嵌入式视角:在 STM32 的 FreeRTOS 中,任务控制块(TCB)就是简化的 PCB,包含任务栈指针、优先级、状态等信息,对应的数据结构类似:
// FreeRTOS任务控制块(简化版) typedef struct tskTaskControlBlock { StackType_t *pxTopOfStack; // 栈顶指针(对应PCB的CPU上下文) xListItem xStateListItem; // 状态链表项(对应PCB的状态) UBaseType_t uxPriority; // 优先级(对应PCB的调度信息) // ... 其他资源信息 } TCB_t; |
1.3 进程的 5 个核心特征(附反例说明)
进程有 5 个特征,缺一个都不能叫 “进程”:
- 动态性:能被创建、调度、终止(反例:ROM 里的固化程序,无法动态调度)
举例:在 Linux 中用./app &启动进程,kill终止进程,体现动态性。
- 并发性:多个进程可同时存在(反例:单任务单片机程序,一次只能跑一个功能)
举例:开发板上同时运行温度采集进程和WiFi上传进程。
- 独立性:拥有独立地址空间(反例:线程,共享进程地址空间)
举例:一个进程崩溃(如段错误),不会影响其他进程。
- 异步性:进程按不可预知的速度推进(反例:实时任务,需严格按时间执行)
举例:两个进程打印日志,输出顺序可能每次不同。
- 结构性:由程序段、数据段、PCB 组成(反例:裸机程序,没有 PCB 管理)
举例:Linux 的/proc/[pid]/目录下的文件,就是进程结构的体现。
面试陷阱:面试官可能问 “线程是否具备这些特征?”—— 线程没有独立性(共享地址空间),所以不是进程。
二、进程状态:从 “就绪” 到 “运行” 的生死轮回
2.1 进程的 5 种基本状态(附 Linux 实际验证)
进程在生命周期中会经历 5 种状态,我们结合ps命令的实际输出理解:
状态名称 |
英文标识 |
含义(大白话) |
Linux 中查看方式(ps aux) |
创建态 |
NEW |
刚被创建,还没加入就绪队列 |
一般看不到(持续时间极短) |
就绪态 |
READY |
万事俱备,就等 CPU 时间片 |
R(Running 的缩写,包含就绪) |
运行态 |
RUNNING |
正在 CPU 上执行 |
R |
阻塞态 |
BLOCKED |
等资源(如 I/O),主动放弃 CPU |
S(Sleeping)或 D(深度睡眠) |
终止态 |
TERMINATED |
已结束,等待回收 PCB |
Z(Zombie,僵尸进程) |
实战操作:在 Linux 开发板上执行:
# 启动一个会阻塞的进程(如ping一个不存在的IP) ping 192.168.1.254 & # 查看状态(会显示S,阻塞在网络I/O) ps aux | grep ping |
你会看到ping进程状态为S,表示它因等待网络响应而阻塞。
2.2 状态转换的 6 种场景(附代码触发示例)
进程状态不会凭空变化,每种转换都有明确的触发条件。我们用 “嵌入式传感器采集” 场景举例:
- 创建态 → 就绪态
触发:进程创建完成,资源分配完毕。
代码示例:
#include <stdio.h> #include <unistd.h> int main() { pid_t pid = fork(); // 创建子进程(进入创建态) if (pid == 0) { // 子进程创建完成,进入就绪态 printf("子进程就绪\n"); } return 0; } |
- 就绪态 → 运行态
触发:调度器选中该进程,分配 CPU。
场景:就绪队列中只有你的传感器进程,调度器会立即让它运行。
- 运行态 → 就绪态
触发:时间片用完,或被高优先级进程抢占。
Linux 验证:
# 启动一个占用CPU的进程 while true; do :; done & # 再启动一个高优先级进程(nice值更小) nice -n -5 ./high_prio_app & # 查看第一个进程会变成就绪态(R,但实际未运行) ps -l |
- 运行态 → 阻塞态
触发:进程请求 I/O(如读取传感器数据)。
代码示例:
// 读取I2C传感器(会阻塞等待数据) int fd = open("/dev/i2c-1", O_RDWR); char data[10]; read(fd, data, 10); // 执行到此处,进程进入阻塞态 |
- 阻塞态 → 就绪态
触发:等待的资源到了(如传感器数据读取完成)。
原理:I/O 完成后,硬件会产生中断,内核处理中断时将进程从阻塞队列移到就绪队列。
- 运行态 → 终止态
触发:进程执行完毕,或被 kill。
代码示例:
// 正常终止 int main() { printf("任务完成\n"); return 0; // 执行到此处,进程进入终止态 } |
状态转换思维导图:
graph TD A[创建态] -->|分配完资源| B[就绪态] B -->|调度器选中| C[运行态] C -->|时间片用完/被抢占| B C -->|等I/O/信号量| D[阻塞态] D -->|资源就绪| B C -->|执行完毕/被kill| E[终止态] B -->|被强制终止| E D -->|被强制终止| E |
2.3 嵌入式 RTOS 中的状态变种(以 FreeRTOS 为例)
RTOS 的任务状态是进程状态的简化版,但更贴近硬件实际:
FreeRTOS 任务状态 |
对应进程状态 |
嵌入式场景举例 |
就绪态(Ready) |
就绪态 |
等待调度器分配 CPU 的传感器任务 |
运行态(Running) |
运行态 |
正在采集温湿度的任务 |
阻塞态(Blocked) |
阻塞态 |
调用 vTaskDelay () 的延时任务 |
挂起态(Suspended) |
无对应 |
被 vTaskSuspend () 暂停的调试任务 |
关键区别:RTOS 没有 “僵尸态”,任务删除后资源立即回收(因为嵌入式系统资源有限,不允许浪费)。
代码对比:
// FreeRTOS任务状态转换示例 void vSensorTask(void *pvParameters) { while(1) { // 读取传感器(可能进入阻塞态) read_sensor();
// 延时100ms(主动进入阻塞态) vTaskDelay(pdMS_TO_TICKS(100)); // 对应进程的阻塞态 } } |
三、进程控制块(PCB):进程的 “身份证 + 档案袋”
3.1 PCB 的作用:操作系统如何 “记住” 进程?
想象一个场景:你正在用开发板调试程序,突然被打断去接电话,回来后能接着调试 —— 因为你 “记住” 了之前的状态(断点位置、变量值)。
操作系统管理进程也是同理,PCB 就是用来 “记住” 进程状态的结构。没有 PCB,操作系统就无法管理进程。
具体来说,PCB 的作用有三个:
- 唯一标识:通过 PID 区分不同进程(就像身份证号)。
- 状态记录:记录进程当前状态(就绪 / 阻塞等),供调度器参考。
- 资源索引:保存进程占用的内存、文件、设备等资源的指针。
类比理解:PCB 就像医院的病历卡 —— 每个病人(进程)一张,记录病情(状态)、检查结果(资源),医生(操作系统)通过病历卡了解病人情况。
3.2 Linux 内核中的 PCB:task_struct 结构体详解
Linux 中的 PCB 是task_struct结构体(定义在linux/sched.h),包含 300 + 字段,我们挑嵌入式开发者必懂的 10 个字段详解:
struct task_struct { // 1. 进程标识 pid_t pid; // 进程ID(唯一标识) pid_t tgid; // 线程组ID(多线程时用)
// 2. 状态信息 volatile long state; // 进程状态(TASK_RUNNING等) unsigned int flags; // 进程标志(如PF_KTHREAD表示内核线程)
// 3. 调度信息 int prio; // 动态优先级 int static_prio; // 静态优先级 struct sched_entity se; // 调度实体(用于CFS调度器)
// 4. 内存信息 struct mm_struct *mm; // 内存描述符(用户空间内存) struct mm_struct *active_mm;// 活跃内存描述符(内核线程用)
// 5. 上下文信息(CPU寄存器) struct thread_struct thread;// 存放寄存器值(切换时保存/恢复)
// 6. 父子关系 struct task_struct *parent; // 父进程指针 struct list_head children; // 子进程链表
// 7. 文件信息 struct files_struct *files; // 打开的文件列表
// 8. 信号处理 struct signal_struct *signal; // 信号描述符 struct sighand_struct *sighand; // 信号处理函数
// 9. 时间信息 cputime_t utime; // 用户态CPU时间 cputime_t stime; // 内核态CPU时间
// 10. 其他 struct task_struct *real_parent; // 实际父进程(被领养前) }; |
关键字段解析:
- pid 与 tgid:
- 单进程:pid = tgid
- 多线程:主线程 pid = tgid,子线程 pid 不同但 tgid 相同
- 查看方式:ps -L -p <pid> 可看到线程的 LWP(轻量级进程 ID,即 pid)
- state:
- TASK_RUNNING:运行 / 就绪态
- TASK_INTERRUPTIBLE:可中断阻塞(如等待键盘输入)
- TASK_UNINTERRUPTIBLE:不可中断阻塞(如等待磁盘 I/O,ps显示 D)
- 注意:ps命令中 R = 运行 / 就绪,S = 可中断阻塞,D = 不可中断阻塞
- mm 与 active_mm:
- 用户进程:mm 指向自己的内存空间
- 内核线程:mm=NULL,active_mm 指向借用的用户内存
- 嵌入式意义:内核线程不占用用户内存,适合资源紧张的嵌入式系统
- thread_struct:
- 存放 CPU 寄存器值(如 ARM 的 sp、pc、lr 等)
- 进程切换时,内核会保存当前 thread_struct,加载下一个进程的 thread_struct
- 举例:当进程因中断切换时,pc(程序计数器)的值会被保存,恢复时从该地址继续执行
3.3 PCB 的组织方式:进程链表与哈希表
操作系统需要快速找到某个进程的 PCB,Linux 用两种数据结构组织:
- 双向循环链表:
- 所有 PCB 通过task_struct的tasks字段链接成链表
- 遍历所有进程时使用(如ps aux命令)
- 定义:struct list_head tasks;
- 哈希表:
- 通过 PID 快速查找 PCB(pid_hash数组)
- 时间复杂度 O (1),比遍历链表快
- 嵌入式优化:嵌入式 Linux 可能精简哈希表大小,减少内存占用
图示:
graph LR subgraph 进程链表 A[PCB1(pid=1)] <--> B[PCB2(pid=2)] B <--> C[PCB3(pid=3)] C <--> A end subgraph 哈希表(pid_hash) D[哈希桶0] --> A E[哈希桶1] --> B F[哈希桶2] --> C end |
实战查看:在 Linux 内核源码中,init_task是第一个进程(swapper)的 PCB,所有进程都从它衍生:
// 内核启动时创建的第一个进程 struct task_struct init_task = INIT_TASK(init_task); |
四、进程创建:从 fork () 到 exec () 的完整流程
4.1 进程创建的 4 个步骤(附内核源码分析)
创建进程就像开分店:总店(父进程)复制一套经营模式(代码),准备新店面(资源),招聘员工(分配 PID),最后开业(加入就绪队列)。
具体步骤:
- 分配 PID:
- 从pidmap位图中找一个未使用的 PID
- 代码逻辑(简化):
static int alloc_pid(struct pid_namespace *ns) { // 遍历pidmap,找第一个0位 for (i = 0; i < PIDMAP_ENTRIES; i++) { if (pidmap[i].page) { // 找到空闲PID return pid; } } } |
- 复制 PCB:
- 调用dup_task_struct()复制父进程的task_struct
- 关键操作:分配新的内核栈(alloc_thread_info)
- 注意:默认不复制用户内存(用写时复制技术)
- 初始化新 PCB:
- 修改 PID、状态等信息(设为 TASK_RUNNING)
- 清空父进程特有的信息(如信号处理、计时器)
- 代码片段:
p->pid = alloc_pid(p->nsproxy->pid_ns); p->state = TASK_RUNNING; p->parent = current; // current是当前进程(父进程) |
- 加入进程队列:
- 将新 PCB 加入进程链表(list_add(&p->tasks, &init_task.tasks))
- 加入对应优先级的就绪队列
- 通知调度器有新进程就绪
4.2 fork () 系统调用:从 “一分为二” 到 “写时复制”
fork()是创建进程的 “瑞士军刀”,我们从用法、原理、优化三个层面解析。
4.2.1 fork () 的基本用法(附嵌入式场景示例)
函数原型:
#include <unistd.h> pid_t fork(void); // 返回值:父进程得到子进程PID,子进程得到0,失败返回-1 |
嵌入式场景示例:开发板上同时采集温湿度和光照数据:
#include <stdio.h> #include <unistd.h> #include <sys/wait.h> // 采集温度(子进程) void collect_temperature() { while(1) { printf("温度: 25℃\n"); sleep(2); // 模拟2秒采集一次 } } // 采集光照(父进程) void collect_light() { while(1) { printf("光照: 500lux\n"); sleep(3); // 模拟3秒采集一次 } } int main() { pid_t pid = fork(); if (pid < 0) { perror("fork failed"); return 1; } else if (pid == 0) { // 子进程:采集温度 collect_temperature(); } else { // 父进程:采集光照 collect_light(); // 等待子进程(实际中不会在循环里等) wait(NULL); } return 0; } |
运行结果:温度和光照数据交替打印,实现并行采集。
4.2.2 fork () 的 “写时复制”(COW)优化
早期的fork()会完整复制父进程的内存,效率极低(比如父进程有 1GB 内存,复制就要 1GB 空间)。现代操作系统用 “写时复制” 优化:
- 原理:父子进程共享同一块物理内存,只有当任一进程修改内存时,才复制被修改的部分(页)
- 好处:创建进程快(不用复制内存),节省内存(未修改的页共享)
图示:
graph TD A[父进程内存] -->|fork()| B[共享物理页] B --> C[父进程修改页1] C --> D[复制页1,父进程用新页1] B --> E[子进程未修改] E --> F[子进程仍用共享页] |
验证 COW:在 Linux 上用fork()创建子进程后,立即查看内存使用(top命令),会发现父子进程共享大部分内存。
4.2.3 vfork () 与 fork () 的区别(嵌入式必知)
嵌入式系统资源有限,vfork()比fork()更轻量,区别如下:
对比项 |
fork() |
vfork() |
内存共享 |
写时复制 |
完全共享(包括栈) |
执行顺序 |
父子进程执行顺序不确定 |
子进程先执行,父进程阻塞到子进程 exit () |
用途 |
通用进程创建 |
子进程立即调用 exec () 的场景 |
风险 |
低 |
高(子进程修改内存会影响父进程) |
嵌入式使用场景:在内存只有 64MB 的嵌入式设备上,用vfork()+execve()启动新程序,比fork()节省内存。
代码示例:
#include <stdio.h> #include <unistd.h> #include <sys/stat.h> #include <sys/wait.h> int main() { pid_t pid = vfork(); if (pid == 0) { // 子进程必须调用exec系列函数或exit execl("/bin/ls", "ls", "-l", NULL); _exit(0); // 如果exec失败,必须exit } else { // 父进程在子进程exit或exec后才执行 printf("子进程已执行\n"); wait(NULL); } return 0; } |
4.3 exec 系列函数:进程 “改头换面”
fork()创建的子进程与父进程执行相同代码,exec系列函数能让子进程执行新程序(“换代码”)。
常用 exec 函数:
函数名 |
功能 |
示例 |
execl() |
命令行参数列表传参 |
execl("/bin/ls", "ls", "-l", NULL) |
execv() |
命令行参数数组传参 |
char *argv[] = {"ls", "-l", NULL}; execv("/bin/ls", argv) |
execlp() |
从 PATH 找程序,不用写全路径 |
execlp("ls", "ls", "-l", NULL) |
execvp() |
结合 execv () 和 execlp () 的特点 |
char *argv[] = {"ls", "-l", NULL}; execvp("ls", argv) |
嵌入式场景:父进程监控传感器,子进程执行不同的处理程序:
#include <stdio.h> #include <unistd.h> #include <sys/wait.h> int main() { pid_t pid = fork(); if (pid == 0) { // 子进程:执行温度处理程序 execl("./temperature_handler", "temperature_handler", "25", NULL); // 如果exec失败才会执行下面的代码 perror("exec failed"); _exit(1); } else { // 父进程:继续监控 printf("监控中...\n"); wait(NULL); } return 0; } |
注意:exec成功后,子进程的代码、数据会被新程序替换,但 PID 不变(还是原来的子进程)。
五、进程终止与资源回收:避免 “僵尸” 横行
5.1 进程终止的 3 种方式(附代码)
进程终止就像 “死亡”,有自然死亡、意外死亡、被杀死三种方式:
- 正常终止(自然死亡):
#include <stdio.h> #include <stdlib.h> // exit() #include <unistd.h> // _exit() int main() { printf("正常终止"); // 没有换行符 exit(0); // 会刷新缓冲区,输出"正常终止" // _exit(0); // 不刷新缓冲区,可能不输出 } |
-
- 从main()返回(return 0;)
- 调用exit()(会刷新缓冲区)
- 调用_exit()(不刷新缓冲区,嵌入式常用)
- 异常终止(意外死亡):
int main() { int a = 1 / 0; // 会产生SIGFPE信号,异常终止 return 0; } |
-
- 除以零、非法内存访问(段错误)
- 收到致命信号(如 SIGSEGV、SIGFPE)
- 被其他进程杀死(他杀):
#include <signal.h> #include <stdio.h> int main() { pid_t pid = fork(); if (pid == 0) { while(1) sleep(1); // 子进程死循环 } else { sleep(2); kill(pid, SIGKILL); // 父进程杀死子进程 } return 0; } |
-
- 其他进程调用kill()发送信号
- 用kill命令(如kill -9 <pid>)
5.2 僵尸进程:是什么、为什么、怎么办
5.2.1 僵尸进程的产生(附复现代码)
定义:子进程终止后,PCB 未被回收,变成僵尸进程(Zombie)。
产生原因:父进程未调用wait()或waitpid()回收子进程资源。
复现代码:
#include <stdio.h> #include <unistd.h> int main() { pid_t pid = fork(); if (pid == 0) { // 子进程:立即终止 printf("子进程终止\n"); _exit(0); } else { // 父进程:不调用wait(),进入死循环 while(1) sleep(1); } return 0; } |
查看僵尸进程:
# 编译运行上述程序后 ps aux | grep defunct # defunct表示僵尸进程 |
会看到子进程状态为Z+(Zombie)。
5.2.2 僵尸进程的危害与解决方法
危害:
- 占用 PID(系统 PID 有限,如 32768 个),僵尸太多会导致无法创建新进程
- 占用 PCB 内存(每个 PCB 约 1KB,10 万个僵尸就占 100MB)
解决方法:
- 父进程主动回收:调用wait()或waitpid()
#include <stdio.h> #include <unistd.h> #include <sys/wait.h> int main() { pid_t pid = fork(); if (pid == 0) { _exit(0); } else { int status; waitpid(pid, &status, 0); // 等待子进程终止 // 可以通过status获取子进程退出状态 if (WIFEXITED(status)) { printf("子进程正常退出,返回值:%d\n", WEXITSTATUS(status)); } } return 0; } |
- 父进程忽略 SIGCHLD 信号:
#include <signal.h> signal(SIGCHLD, SIG_IGN); // 告诉内核:子进程终止后自动回收 |
- 双重 fork ():让 init 进程领养孙子进程:
// 父进程 -> 子进程A -> 子进程B // 子进程A创建B后立即退出,B成为孤儿进程被init领养,init会回收B |
5.3 孤儿进程:被 “福利院”(init)领养
定义:父进程先于子进程终止,子进程被 init 进程(PID=1)领养。
特点:
- 无害(init 会负责回收)
- 状态为S(就绪 / 阻塞),不是僵尸
复现代码:
#include <stdio.h> #include <unistd.h> int main() { pid_t pid = fork(); if (pid == 0) { // 子进程:等待父进程死亡 sleep(2); // 父进程已死,打印新的父进程PID(应为1) printf("子进程的新父进程PID:%d\n", getppid()); } else { // 父进程:立即退出 _exit(0); } return 0; } |
运行结果:子进程的新父进程 PID 为 1(init 进程)。
六、进程间通信(IPC):让进程 “说话”
进程是独立的,但需要协作(如传感器进程将数据传给上传进程),这就需要 IPC。
6.1 管道(Pipe):最简单的 “传话筒”
管道是最古老的 IPC 方式,像一根 “管子”,数据从一端进,另一端出。
6.1.1 匿名管道(父子进程专用)
特点:
- 半双工(数据单向流动)
- 只能用于有亲缘关系的进程(父子、兄弟)
- 基于文件描述符(读端 fd [0],写端 fd [1])
代码示例:父进程给子进程发送传感器数据
#include <stdio.h> #include <unistd.h> #include <string.h> int main() { int fd[2]; // 创建管道 if (pipe(fd) == -1) { perror("pipe failed"); return 1; } pid_t pid = fork(); if (pid == 0) { // 子进程:读数据 close(fd[1]); // 关闭写端(只需要读) char buf[100]; read(fd[0], buf, sizeof(buf)); printf("子进程收到:%s\n", buf); close(fd[0]); } else { // 父进程:写数据 close(fd[0]); // 关闭读端(只需要写) char *data = "温度:25℃"; write(fd[1], data, strlen(data)); close(fd[1]); } return 0; } |
注意:
- 管道有缓冲(默认 64KB),满了会阻塞写操作
- 读端关闭后写操作会产生 SIGPIPE 信号(默认终止进程)
6.1.2 命名管道(FIFO):任意进程通信
特点:
- 有文件名(在文件系统中可见,如/tmp/myfifo)
- 可用于任意进程(无亲缘关系)
- 用法类似匿名管道,但需要先创建
创建 FIFO:
mkfifo /tmp/sensor_fifo # 命令行创建 |
或代码创建:
#include <sys/stat.h> mkfifo("/tmp/sensor_fifo", 0666); // 0666是权限 |
通信示例:
写进程(传感器采集):
#include <stdio.h> #include <unistd.h> #include <fcntl.h> #include <string.h> int main() { int fd = open("/tmp/sensor_fifo", O_WRONLY); char *data = "光照:500lux"; write(fd, data, strlen(data)); close(fd); return 0; } |
读进程(数据处理):
#include <stdio.h> #include <unistd.h> #include <fcntl.h> int main() { int fd = open("/tmp/sensor_fifo", O_RDONLY); char buf[100]; read(fd, buf, sizeof(buf)); printf("收到:%s\n", buf); close(fd); return 0; } |
嵌入式应用:在嵌入式 Linux 中,多个进程(如采集、处理、显示)可通过 FIFO 传递数据,无需考虑进程关系。
6.2 信号(Signal):进程间的 “紧急电报”
信号是异步通知机制,像 “发电报” 一样简单粗暴,适合传递简单指令(如终止、暂停)。
6.2.1 常见信号及默认行为
信号编号 |
名称 |
含义 |
默认行为 |
嵌入式场景举例 |
2 |
SIGINT |
中断(Ctrl+C) |
终止进程 |
手动停止调试中的程序 |
9 |
SIGKILL |
杀死进程 |
终止进程 |
强制结束无响应的进程 |
11 |
SIGSEGV |
段错误(非法内存访问) |
终止 + CoreDump |
程序 bug 导致内存越界 |
17 |
SIGCHLD |
子进程终止 |
忽略 |
父进程回收子进程 |
19 |
SIGSTOP |
暂停进程 |
暂停进程 |
调试时暂停程序执行 |
查看所有信号:kill -l
6.2.2 发送信号:kill () 函数与 kill 命令
用 kill 命令发送信号:
kill -9 1234 # 给PID=1234的进程发SIGKILL kill -SIGSTOP 1234 # 暂停进程 |
用 kill () 函数发送信号:
#include <signal.h> #include <stdio.h> #include <unistd.h> int main() { pid_t pid = fork(); if (pid == 0) { while(1) { printf("运行中...\n"); sleep(1); } } else { sleep(2); kill(pid, SIGSTOP); // 暂停子进程 sleep(2); kill(pid, SIGCONT); // 继续子进程 sleep(2); kill(pid, SIGKILL); // 杀死子进程 } return 0; } |
6.2.3 捕获信号:自定义信号处理函数
进程可以自定义信号的处理方式(除 SIGKILL 和 SIGSTOP,这两个信号不能被捕获)。
代码示例:捕获 SIGINT,实现优雅退出
#include <stdio.h> #include <signal.h> #include <unistd.h> // 信号处理函数 void sigint_handler(int signo) { if (signo == SIGINT) { printf("\n收到中断信号,正在保存数据...\n"); // 保存传感器数据等清理工作 sleep(1); printf("数据保存完成,退出\n"); _exit(0); } } int main() { // 注册信号处理函数 if (signal(SIGINT, sigint_handler) == SIG_ERR) { perror("signal failed"); return 1; }
// 模拟传感器采集 while(1) { printf("采集数据中...\n"); sleep(1); } return 0; } |
运行:按 Ctrl+C 时,进程会先保存数据再退出,而不是立即终止。
6.3 共享内存:最快的 IPC(嵌入式首选)
共享内存是效率最高的 IPC 方式 —— 数据直接在内存中共享,无需拷贝。
6.3.1 共享内存的使用步骤
- 创建 / 打开共享内存:shmget()
- 映射到进程地址空间:shmat()
- 读写共享内存:直接操作指针
- 解除映射:shmdt()
- 删除共享内存:shmctl()
代码示例:
写进程(传感器):
#include <stdio.h> #include <sys/ipc.h> #include <sys/shm.h> #include <string.h> #define SHM_SIZE 1024 // 共享内存大小 #define SHM_KEY 0x1234 // 共享内存键值(唯一标识) int main() { // 1. 创建共享内存 int shmid = shmget(SHM_KEY, SHM_SIZE, IPC_CREAT | 0666); if (shmid == -1) { perror("shmget failed"); return 1; } // 2. 映射到地址空间 char *shmaddr = shmat(shmid, NULL, 0); if (shmaddr == (void*)-1) { perror("shmat failed"); return 1; } // 3. 写数据 strcpy(shmaddr, "温度:25℃ 湿度:60%"); printf("写入共享内存: %s\n", shmaddr); // 等待读进程读取 sleep(5); // 4. 解除映射 shmdt(shmaddr); // 5. 删除共享内存(通常由一个进程负责) shmctl(shmid, IPC_RMID, NULL); return 0; } |
读进程(数据处理):
#include <stdio.h> #include <sys/ipc.h> #include <sys/shm.h> #define SHM_SIZE 1024 #define SHM_KEY 0x1234 int main() { // 1. 获取共享内存(已由写进程创建) int shmid = shmget(SHM_KEY, SHM_SIZE, 0666); if (shmid == -1) { perror("shmget failed"); return 1; } // 2. 映射到地址空间 char *shmaddr = shmat(shmid, NULL, 0); if (shmaddr == (void*)-1) { perror("shmat failed"); return 1; } // 3. 读数据 printf("从共享内存读取: %s\n", shmaddr); // 4. 解除映射 shmdt(shmaddr); return 0; } |
6.3.2 共享内存的同步问题(必知)
共享内存不提供同步机制,多进程同时读写会导致数据错乱(如两个进程同时写同一位置)。
解决方法:用信号量(Semaphore)同步。
示例:用信号量保护共享内存读写:
// 初始化信号量(确保先于共享内存操作) sem_t *sem = sem_open("/sensor_sem", O_CREAT, 0666, 1); // 写共享内存前加锁 sem_wait(sem); // 写操作... sem_post(sem); // 读共享内存前加锁 sem_wait(sem); // 读操作... sem_post(sem); |
嵌入式注意:嵌入式 Linux 可能需要开启CONFIG_SYSVIPC配置才能使用共享内存。
6.4 信号量(Semaphore):进程同步的 “红绿灯”
信号量像 “红绿灯”,控制进程何时可以访问共享资源(如共享内存、硬件设备)。
6.4.1 信号量的基本概念
- 计数信号量:值可以是任意非负数,用于控制资源数量(如 3 个串口设备)
- 二元信号量(互斥锁):值只能是 0 或 1,用于互斥访问(如同一时间只能一个进程用 SPI 总线)
P 操作(等待):sem_wait()—— 信号量减 1,若值 < 0 则阻塞
V 操作(释放):sem_post()—— 信号量加 1,唤醒阻塞进程
6.4.2 System V 信号量与 POSIX 信号量
Linux 有两种信号量接口,嵌入式常用 POSIX 信号量(更简单):
POSIX 信号量示例(互斥访问 SPI):
#include <semaphore.h> #include <stdio.h> #include <unistd.h> #include <pthread.h> sem_t sem; // 全局信号量 // 模拟SPI操作 void spi_operation(int id) { sem_wait(&sem); // P操作:获取锁 printf("进程%d开始使用SPI\n", id); sleep(2); // 模拟SPI操作 printf("进程%d结束使用SPI\n", id); sem_post(&sem); // V操作:释放锁 } int main() { // 初始化信号量(1表示互斥锁) sem_init(&sem, 0, 1); // 第二个参数0表示线程间共享 pid_t pid = fork(); if (pid == 0) { spi_operation(2); // 子进程 } else { spi_operation(1); // 父进程 } // 销毁信号量 sem_destroy(&sem); return 0; } |
运行结果:两个进程不会同时使用 SPI,体现互斥效果。
6.4.3 信号量解决生产者 - 消费者问题
场景:传感器(生产者)采集数据到缓冲区,处理程序(消费者)从缓冲区取数据。
代码示例:
#include <semaphore.h> #include <stdio.h> #include <unistd.h> #include <pthread.h> #define BUFFER_SIZE 5 int buffer[BUFFER_SIZE]; int in = 0, out = 0; sem_t empty; // 空缓冲区数量 sem_t full; // 满缓冲区数量 sem_t mutex; // 互斥锁 // 生产者(传感器) void *producer(void *arg) { for (int i = 0; i < 10; i++) { int data = i; // 模拟传感器数据 sem_wait(&empty); // 等空缓冲区 sem_wait(&mutex); buffer[in] = data; printf("生产: %d, 位置: %d\n", data, in); in = (in + 1) % BUFFER_SIZE; sem_post(&mutex); sem_post(&full); // 满缓冲区+1 sleep(1); // 模拟采集间隔 } return NULL; } // 消费者(数据处理) void *consumer(void *arg) { for (int i = 0; i < 10; i++) { sem_wait(&full); // 等满缓冲区 sem_wait(&mutex); int data = buffer[out]; printf("消费: %d, 位置: %d\n", data, out); out = (out + 1) % BUFFER_SIZE; sem_post(&mutex); sem_post(&empty); // 空缓冲区+1 sleep(2); // 模拟处理时间 } return NULL; } int main() { // 初始化信号量 sem_init(&empty, 0, BUFFER_SIZE); // 初始有5个空缓冲区 sem_init(&full, 0, 0); // 初始0个满缓冲区 sem_init(&mutex, 0, 1); // 互斥锁 pthread_t prod_tid, cons_tid; pthread_create(&prod_tid, NULL, producer, NULL); pthread_create(&cons_tid, NULL, consumer, NULL); pthread_join(prod_tid, NULL); pthread_join(cons_tid, NULL); // 清理 sem_destroy(&empty); sem_destroy(&full); sem_destroy(&mutex); return 0; } |
运行结果:生产者和消费者交替操作缓冲区,不会出现数据混乱。
七、进程调度:谁先 “上车” 谁说了算
7.1 进程调度的基本概念(嵌入式视角)
进程调度就是 “决定哪个进程先使用 CPU”,像公交车调度 —— 谁先上车、谁后上车,需要规则。
为什么需要调度:
- CPU 是稀缺资源(通常只有 1-4 核)
- 多个进程需要 “公平” 使用 CPU
- 不同进程有不同需求(如实时进程需要立即响应)
嵌入式调度 vs 通用 OS 调度:
- 嵌入式:强调实时性(如传感器数据必须 10ms 内处理)
- 通用 OS:强调公平性和交互性(如桌面系统)
7.2 Linux 的 CFS 调度器(完全公平调度)
Linux 采用 CFS(Completely Fair Scheduler)调度器,核心思想是 “让每个进程获得公平的 CPU 时间”。
7.2.1 CFS 的基本原理
- 虚拟运行时间:进程实际运行时间按优先级加权后的时间
- 红黑树:所有就绪进程按虚拟运行时间排序,每次选虚拟运行时间最小的进程
举例:
- 高优先级进程的虚拟时间流逝慢(如实际运行 1ms,虚拟时间 + 0.5ms)
- 低优先级进程的虚拟时间流逝快(如实际运行 1ms,虚拟时间 + 2ms)
- 这样高优先级进程能获得更多实际 CPU 时间
7.2.2 进程优先级与 nice 值
Linux 用 nice 值表示进程优先级:
- 范围:-20(最高优先级)~ 19(最低优先级)
- 默认值:0
- 调整优先级:nice -n <值> 命令 或 renice <值> -p <pid>
查看进程 nice 值:ps -l(NI 列)
嵌入式应用:在嵌入式系统中,可将实时任务的 nice 值设为 - 20,确保优先执行。
7.3 实时调度策略(嵌入式必备)
嵌入式系统常需要实时调度(如汽车的刹车控制必须立即响应),Linux 提供两种实时调度策略:
- SCHED_FIFO:
- 先进先出,一旦获得 CPU 就一直运行,直到主动放弃或被更高优先级进程抢占
- 适合短时间运行的实时任务(如传感器数据处理)
- SCHED_RR:
- 时间片轮转,相同优先级的进程轮流执行
- 适合需要定期执行的任务(如 10ms 一次的电机控制)
设置实时调度策略:
#include <stdio.h> #include <sched.h> int main() { struct sched_param param; param.sched_priority = 50; // 优先级(1-99,值越大优先级越高) // 设置SCHED_FIFO调度策略 if (sched_setscheduler(0, SCHED_FIFO, ¶m) == -1) { perror("sched_setscheduler failed"); return 1; } // 实时任务... return 0; } |
注意:需要 root 权限才能设置实时优先级,嵌入式系统中通常会开启相关配置。
7.4 嵌入式 RTOS 的调度器(以 FreeRTOS 为例)
FreeRTOS 的调度器比 Linux 简单,适合资源有限的嵌入式系统:
- 抢占式调度:高优先级任务可立即抢占低优先级任务
- 时间片调度:相同优先级任务轮流执行(可配置)
代码示例:
// 高优先级任务(传感器数据处理) void vHighPriorityTask(void *pvParameters) { while(1) { // 处理数据(必须快速完成) vTaskDelay(pdMS_TO_TICKS(10)); } } // 低优先级任务(日志打印) void vLowPriorityTask(void *pvParameters) { while(1) { // 打印日志(可延迟) vTaskDelay(pdMS_TO_TICKS(100)); } } int main() { // 创建任务,高优先级任务优先执行 xTaskCreate(vHighPriorityTask, "HighTask", 128, NULL, 2, NULL); xTaskCreate(vLowPriorityTask, "LowTask", 128, NULL, 1, NULL); vTaskStartScheduler(); // 启动调度器 return 0; } |
关键区别:FreeRTOS 的任务切换开销小(约几微秒),适合微控制器(如 STM32),而 Linux 调度切换开销大(约几十微秒)。
八、实战:用进程知识解决嵌入式实际问题
8.1 案例 1:嵌入式设备的多进程架构设计
以 “智能温湿度传感器” 为例,设计多进程架构:
进程名称 |
功能 |
优先级 |
IPC 方式 |
采集进程 |
读取温湿度传感器 |
高 |
共享内存 |
处理进程 |
数据校准、转换 |
中 |
共享内存 + 信号量 |
上传进程 |
WiFi 上传数据 |
低 |
管道 |
日志进程 |
记录系统日志 |
最低 |
命名管道 |
优势:
- 模块化(一个进程出问题不影响其他进程)
- 可独立升级(如只更新上传进程支持新协议)
- 方便调试(可单独重启某个进程)
8.2 案例 2:解决传感器数据丢失问题
问题:传感器数据采集快(10ms 一次),但上传慢(100ms 一次),导致数据丢失。
分析:采集进程和上传进程速度不匹配,没有缓冲机制。
解决方案:用共享内存 + 信号量实现环形缓冲区:
- 采集进程:将数据写入环形缓冲区,信号量计数 + 1
- 上传进程:从缓冲区读数据,信号量计数 - 1
- 缓冲区满时,采集进程可选择覆盖旧数据或等待
核心代码:参考 6.4.3 的生产者 - 消费者模型,将缓冲区改为环形。
8.3 案例 3:调试进程相关问题的工具
工具 |
用途 |
嵌入式场景示例 |
ps |
查看进程状态 |
检查是否有僵尸进程(Z 状态) |
top/htop |
实时查看进程 CPU / 内存使用 |
发现 CPU 占用 100% 的异常进程 |
pstree |
查看进程树(父子关系) |
找到某个进程的父进程 |
strace |
跟踪进程系统调用 |
调试进程为何无法打开设备文件 |
gdb attach |
调试运行中的进程 |
在不重启的情况下调试上传进程 |
调试示例:用strace查看进程为何无法读取传感器:
strace -f ./sensor_collect # -f跟踪子进程 |
会输出所有系统调用,若看到open("/dev/i2c-1", O_RDWR) = -1 ENOENT,说明设备文件不存在。
九、总结:进程知识体系与面试重点
9.1 进程知识体系思维导图
graph TD A[进程] --> B[基本概念] A --> C[状态] A --> D[PCB] A --> E[创建与终止] A --> F[IPC] A --> G[调度] B --> B1[程序vs进程] B --> B2[进程特征] C --> C1[5种状态及转换] D --> D1[task_struct结构] D --> D2[组织方式] E --> E1[fork/exec] E --> E2[僵尸/孤儿进程] F --> F1[管道/FIFO] F --> F2[信号] F --> F3[共享内存] F --> F4[信号量] G --> G1[CFS调度器] G --> G2[实时调度] |
9.2 面试高频问题与答案要点
- 进程与线程的区别?
- 进程:资源分配单位,有独立地址空间
- 线程:调度单位,共享进程资源
- 开销:进程创建 / 切换开销大,线程小
- 僵尸进程产生原因及解决方法?
- 原因:子进程终止后父进程未回收 PCB
- 解决:wait ()/waitpid ()、忽略 SIGCHLD、双重 fork ()
- 什么是写时复制?为什么用它?
- 原理:fork () 后父子进程共享内存,修改时才复制
- 好处:加快进程创建速度,节省内存
- 进程间通信方式及优缺点?
- 管道:简单,仅限亲缘进程
- 共享内存:最快,需同步
- 信号量:用于同步,不传递数据
- 信号:异步,适合简单通知
- 实时调度与普通调度的区别?
- 实时:优先保证响应时间(如 SCHED_FIFO)
- 普通:优先保证公平性(如 CFS)
9.3 下一步学习建议
- 动手实践:用本文代码在开发板上实际运行,观察进程行为
- 阅读源码:看 FreeRTOS 的任务调度器源码(理解简化版进程管理)
- 项目实战:实现一个多进程的嵌入式应用(如智能家居网关)
- 深入内核:学习 Linux 内核进程调度和 IPC 的实现细节
最后有一句话和大家分享:
最好的学习方法是 “用起来”—— 在实际项目中遇到问题、解决问题,才能真正理解进程的精髓。