深入理解进程:从底层原理到嵌入式实战(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):操作系统管理进程的 “身份证”,记录进程状态、资源等信息
用思维导图展示三者关系:
嵌入式视角:在 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; // 执行到此处,进程进入终止态}
状态转换思维导图:
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 LRsubgraph 进程链表A[PCB1(pid=1)] <--> B[PCB2(pid=2)]B <--> C[PCB3(pid=3)]C <--> Aendsubgraph 哈希表(pid_hash)D[哈希桶0] --> AE[哈希桶1] --> BF[哈希桶2] --> Cend
实战查看:在 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 TDA[父进程内存] -->|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的进程发SIGKILLkill -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 0x1234int 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 5int 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 进程知识体系思维导图
9.2 面试高频问题与答案要点
- 进程与线程的区别?
-
进程:资源分配单位,有独立地址空间
-
线程:调度单位,共享进程资源
-
开销:进程创建 / 切换开销大,线程小
- 僵尸进程产生原因及解决方法?
-
原因:子进程终止后父进程未回收 PCB
-
解决:wait ()/waitpid ()、忽略 SIGCHLD、双重 fork ()
- 什么是写时复制?为什么用它?
-
原理:fork () 后父子进程共享内存,修改时才复制
-
好处:加快进程创建速度,节省内存
- 进程间通信方式及优缺点?
-
管道:简单,仅限亲缘进程
-
共享内存:最快,需同步
-
信号量:用于同步,不传递数据
-
信号:异步,适合简单通知
- 实时调度与普通调度的区别?
-
实时:优先保证响应时间(如 SCHED_FIFO)
-
普通:优先保证公平性(如 CFS)
9.3 下一步学习建议
-
动手实践:用本文代码在开发板上实际运行,观察进程行为
-
阅读源码:看 FreeRTOS 的任务调度器源码(理解简化版进程管理)
-
项目实战:实现一个多进程的嵌入式应用(如智能家居网关)
-
深入内核:学习 Linux 内核进程调度和 IPC 的实现细节
掌握进程知识,不仅能通过面试,更能设计出稳定、高效的嵌入式系统。记住:最好的学习方法是 “用起来”—— 在实际项目中遇到问题、解决问题,才能真正理解进程的精髓。
(注:文档部分内容可能由 AI 生成)