大厂技术总监:带你吃透linux多线程编程-嵌入式大牛技术总结+笔记 #总结版
继续更新大厂技术leader系列
------------------------------------------------------------------------------------------------------------------------------------------------------ 更新于 2026.3.19
笔记说明
本笔记针对珠三角嵌入式 Linux 开发岗位应届生 14k 薪资要求,系统梳理多进程、多线程核心编程知识点,基于原随手笔记优化整理,兼顾新手入门理解、代码实战可落地、面试考点全覆盖、嵌入式场景强相关四大核心要求,全程采用嵌入式开发人员易懂的类比,避开纯理论空谈,所有代码均可直接编译运行。
第一部分:Linux 进程核心编程(对应知识点 D1-D3)
模块一:进程的创建与回收(fork/wait/waitpid)
一、先搞懂:什么是进程?(嵌入式视角)
在嵌入式 Linux 系统中,进程是操作系统资源分配的最小单位,你可以把它理解成:
一个独立运行的嵌入式程序实例,操作系统给它分配了独立的内存空间(代码段、数据段、堆、栈)、CPU 时间片、文件描述符等资源,每个进程之间相互隔离,一个进程崩溃不会直接影响其他进程。
比如你嵌入式设备里,传感器数据采集程序、网络通信程序、日志打印程序,都可以做成独立的进程,并行运行,互不干扰。
嵌入式开发中进程的核心价值:实现多任务并行执行,比如同时做「传感器数据采集」+「网络数据上传」+「本地数据存储」三个任务,避免一个任务阻塞导致整个设备卡死。
二、进程创建:fork () 函数 ——“克隆进程干多活”
1. fork () 函数核心原理
fork () 是 Linux 下创建子进程的唯一系统调用,它的核心动作是 **“克隆当前进程”**:
- 调用 fork () 后,操作系统会复制当前进程(父进程)的几乎所有资源,生成一个全新的子进程;
- 父子进程的代码段完全相同,从 fork () 调用后的下一行代码开始,父子进程并行执行;
- 最关键的区别:fork () 的返回值,父子进程拿到的不一样!
表格
进程角色 fork () 返回值 父进程 子进程的 PID(正整数,进程唯一 ID) 子进程 0 调用失败 -1(比如内存不足、PID 号耗尽)
新手必懂类比:你写了一个单片机程序,编译成了 hex 文件,你把这个 hex 文件烧录到两个完全相同的开发板上,两个开发板都从 main 函数开始运行,但是两个开发板有独立的硬件资源,互不干扰。fork () 就相当于这个 “烧录克隆” 的动作,父进程是原开发板,子进程是新烧录的开发板。
2. fork () 函数完整实战代码(嵌入式场景)
这里我们模拟嵌入式中最常见的场景:父进程负责网络通信,子进程负责传感器数据采集,两个任务并行执行。
c
运行
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <errno.h>
int main() {
pid_t pid; // 存储fork()返回的进程ID,pid_t是Linux内核定义的整型,专门存PID
// 核心:调用fork()创建子进程
pid = fork();
// 1. fork调用失败处理(嵌入式必须做,资源有限,极易出现内存不足)
if (pid < 0) {
perror("fork创建进程失败"); // perror会自动打印errno对应的错误原因
printf("错误码:%d\n", errno);
return -1;
}
// 2. 子进程分支:pid == 0,执行传感器采集逻辑
else if (pid == 0) {
printf("【传感器采集子进程】启动,我的PID=%d,父进程PID=%d\n", getpid(), getppid());
// 模拟嵌入式传感器循环采集
for (int i = 0; i < 5; i++) {
printf("【子进程】采集到温度数据:%d.%d℃\n", 25 + i, i * 2);
sleep(1); // 模拟1秒采集一次
}
printf("【子进程】采集任务完成,退出\n");
return 0; // 子进程任务完成,正常退出
}
// 3. 父进程分支:pid > 0,执行网络通信逻辑
else {
printf("【网络通信父进程】启动,我的PID=%d,创建的子进程PID=%d\n", getpid(), pid);
// 模拟嵌入式网络循环上传
for (int i = 0; i < 5; i++) {
printf("【父进程】向云端上传第%d次数据\n", i+1);
sleep(1); // 模拟1秒上传一次
}
printf("【父进程】上传任务完成\n");
}
return 0;
}
3. 代码编译与运行结果
编译命令(Linux 环境):
bash
运行
gcc fork_demo.c -o fork_demo
./fork_demo
典型运行结果:
plaintext
【网络通信父进程】启动,我的PID=1234,创建的子进程PID=1235
【传感器采集子进程】启动,我的PID=1235,父进程PID=1234
【父进程】向云端上传第1次数据
【子进程】采集到温度数据:25.0℃
【父进程】向云端上传第2次数据
【子进程】采集到温度数据:26.2℃
【父进程】向云端上传第3次数据
【子进程】采集到温度数据:27.4℃
【父进程】向云端上传第4次数据
【子进程】采集到温度数据:28.6℃
【父进程】向云端上传第5次数据
【子进程】采集到温度数据:29.8℃
【父进程】上传任务完成
【子进程】采集任务完成,退出
4. 逐行核心知识点解析(新手必看)
- pid_t 类型:Linux 内核专门定义的进程 ID 类型,本质是有符号整型,不要直接用 int 代替,保证跨平台兼容性。
- getpid() / getppid():
getpid():获取当前进程自己的 PID;getppid():获取当前进程的父进程 PID。
- 父子进程执行顺序:面试高频问题:fork 之后父子进程谁先执行?答:没有固定顺序,由 Linux 内核的进程调度器决定。如果要强制控制执行顺序,需要用 wait ()/waitpid () 或者信号量同步,比如代码里如果父进程不加 sleep,可能父进程先执行完退出,子进程变成孤儿进程。
- 嵌入式 fork () 核心特性(面试必问):
- 文件描述符共享:父进程打开的传感器设备文件、串口、网络 socket,子进程会复制文件描述符,父子进程共享文件偏移量。比如父进程读了串口前 10 个字节,子进程再读,会从第 11 个字节开始,这是嵌入式开发中父子进程协同读写硬件的核心特性。
- 写时复制(COW)机制:fork 之后,父子进程的内存空间不是立刻复制,而是只有当其中一个进程修改内存内容时,才会复制对应的内存页。这对嵌入式内存受限的场景极其重要,避免 fork 瞬间占用双倍内存,导致设备内存不足。
5. 新手踩坑避坑指南(结合你的笔记)
- 坑 1:忘记处理 fork () 失败的情况嵌入式设备内存极其有限,fork () 很容易因为内存不足失败,必须加
pid < 0的错误处理,否则程序会直接跑飞。 - 坑 2:fork 之后父子进程同时操作同一个硬件设备,不加同步比如父子进程同时写串口,会导致数据乱码,必须加互斥锁或者用其他同步机制。
- 坑 3:父进程提前退出,子进程变成孤儿进程父进程先于子进程退出,子进程会被 init 进程(PID=1)收养,变成孤儿进程,虽然不会崩溃,但会脱离你的控制,嵌入式开发中要尽量避免。
三、进程回收:wait () 与 waitpid ()——“等子进程干完活,回收资源”
1. 为什么必须回收子进程?(嵌入式核心痛点)
子进程退出后,操作系统会保留它的退出状态、PID 等资源,直到父进程回收它。如果父进程不回收,子进程会变成僵尸进程(Zombie Process)。
僵尸进程的危害(嵌入式致命):
- 持续占用 PID 号,嵌入式 Linux 系统的 PID 号是有限的(通常默认 32768 个),大量僵尸进程会导致 PID 耗尽,无法创建新的进程;
- 占用内核内存资源,长期运行会导致设备内存泄漏,最终死机。
通俗类比:子进程是你公司的员工,离职后工位、工牌还没被行政回收,长期下来公司工位全被离职员工占了,新员工没法入职。wait ()/waitpid () 就是行政回收工位的动作。
2. wait () 函数详解
函数原型:
c
运行
#include <sys/wait.h>
pid_t wait(int *status);
核心功能:
- 阻塞等待任意一个子进程退出,回收它的资源;
- 参数
status:输出型参数,存储子进程的退出状态,需要用专门的宏解析; - 返回值:成功返回回收的子进程 PID,失败返回 - 1。
解析 status 的核心宏(面试必背):
表格
| 宏 | 作用 |
|---|---|
| WIFEXITED(status) | 判断子进程是否正常退出(比如 return、exit () 退出),正常退出返回真 |
| WEXITSTATUS(status) | 提取子进程的退出码(比如 return 123,就能提取到 123),只有 WIFEXITED 为真时才能用 |
| WIFSIGNALED(status) | 判断子进程是否被信号杀死(比如 kill 命令、段错误),是则返回真 |
| WTERMSIG(status) | 提取杀死子进程的信号编号,只有 WIFSIGNALED 为真时才能用 |
3. wait () 函数实战代码(嵌入式场景)
模拟场景:父进程创建子进程做传感器校准,父进程等待子进程校准完成,获取校准结果,再执行后续逻辑。
c
运行
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdlib.h>
int main() {
pid_t pid, wait_pid;
int status; // 存储子进程退出状态
pid = fork();
if (pid < 0) {
perror("fork失败");
return -1;
}
// 子进程:执行传感器校准任务
else if (pid == 0) {
printf("【校准子进程】启动,PID=%d,开始传感器校准...\n", getpid());
sleep(3); // 模拟校准需要3秒
printf("【校准子进程】校准完成\n");
// 退出码:0=校准成功,非0=校准失败,这里模拟校准成功,返回100(校准后的基准值)
exit(100);
}
// 父进程:等待子进程校准完成,回收资源
else {
printf("【主进程】等待传感器校准完成...\n");
// 阻塞等待子进程退出
wait_pid = wait(&status);
// 解析子进程退出状态
if (WIFEXITED(status)) {
printf("【主进程】子进程%d正常退出,校准结果(退出码):%d\n", wait_pid, WEXITSTATUS(status));
if (WEXITSTATUS(status) == 100) {
printf("【主进程】校准成功,开始正常采集任务\n");
} else {
printf("【主进程】校准失败,设备重启\n");
}
} else if (WIFSIGNALED(status)) {
printf("【主进程】子进程%d被信号杀死,信号编号:%d,校准异常\n", wait_pid, WTERMSIG(status));
}
}
return 0;
}
编译运行结果:
plaintext
【主进程】等待传感器校准完成...
【校准子进程】启动,PID=1236,开始传感器校准...
【校准子进程】校准完成
【主进程】子进程1236正常退出,校准结果(退出码):100
【主进程】校准成功,开始正常采集任务
4. waitpid () 函数详解:比 wait () 更灵活的回收
wait () 只能阻塞等待任意子进程,而 waitpid () 可以指定等待某个子进程、设置非阻塞模式,是嵌入式开发中更常用的函数。
函数原型:
c
运行
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
参数详解:
- pid 参数:指定要等待的子进程
pid > 0:等待 PID 等于这个值的指定子进程;pid = -1:等待任意一个子进程,和 wait () 功能完全一样;pid = 0:等待和当前进程同一会话组的所有子进程;pid < -1:等待进程组 ID 等于 pid 绝对值的所有子进程。
- status 参数:和 wait () 完全一样,存储退出状态,用宏解析。
- options 参数:功能选项,最常用的两个:
0:阻塞等待,和 wait () 一样;WNOHANG:非阻塞模式,如果指定的子进程还没退出,函数立刻返回 0,不会阻塞。
嵌入式核心价值:非阻塞模式WNOHANG是嵌入式开发的神器!父进程可以一边循环做自己的任务(比如按键检测、屏幕刷新),一边轮询检查子进程有没有退出,不会因为等待子进程而卡死整个主循环。
5. waitpid () 实战代码(嵌入式非阻塞场景)
模拟场景:父进程主循环负责屏幕刷新和按键检测,同时创建子进程做固件升级,非阻塞检查升级进度,不会让主界面卡死。
c
运行
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdlib.h>
int main() {
pid_t upgrade_pid, wait_ret;
int status;
int upgrade_done = 0; // 升级完成标志
// 创建子进程执行固件升级
upgrade_pid = fork();
if (upgrade_pid < 0) {
perror("创建升级进程失败");
return -1;
} else if (upgrade_pid == 0) {
printf("【升级子进程】启动,开始固件升级...\n");
// 模拟固件升级5个阶段,每个阶段1秒
for (int i = 1; i <= 5; i++) {
printf("【升级子进程】升级进度:%d%%\n", i * 20);
sleep(1);
}
printf("【升级子进程】升级完成,退出\n");
exit(0); // 0=升级成功
}
// 父进程主循环:屏幕刷新+按键检测,同时非阻塞检查升级状态
printf("【主进程】主界面循环启动\n");
while (1) {
// 1. 非阻塞检查升级子进程状态
wait_ret = waitpid(upgrade_pid, &status, WNOHANG);
// 情况1:子进程已经退出
if (wait_ret == upgrade_pid) {
if (WIFEXITED(status) && WEXITSTATUS(status) == 0) {
printf("【主进程】固件升级成功!\n");
} else {
printf("【主进程】固件升级失败!\n");
}
upgrade_done = 1;
}
// 情况2:子进程还在运行,wait_ret=0,不做处理
else if (wait_ret == 0) {
if (!upgrade_done) {
printf("【主进程】升级中,主界面正常刷新...\n");
}
}
// 情况3:waitpid调用失败
else {
perror("waitpid调用失败");
break;
}
// 模拟主循环其他任务:按键检测、屏幕刷新
usleep(500000); // 500ms循环一次
// 升级完成后,退出主循环
if (upgrade_done) {
printf("【主进程】任务完成,退出\n");
break;
}
}
return 0;
}
编译运行结果:
plaintext
【主进程】主界面循环启动
【升级子进程】启动,开始固件升级...
【主进程】升级中,主界面正常刷新...
【升级子进程】升级进度:20%
【主进程】升级中,主界面正常刷新...
【升级子进程】升级进度:40%
【主进程】升级中,主界面正常刷新...
【升级子进程】升级进度:60%
【主进程】升级中,主界面正常刷新...
【升级子进程】升级进度:80%
【主进程】升级中,主界面正常刷新...
【升级子进程】升级进度:100%
【升级子进程】升级完成,退出
【主进程】固件升级成功!
【主进程】任务完成,退出
6. 面试高频问题与新手避坑
- 面试题:wait () 和 waitpid () 的区别?答:
- wait () 只能阻塞等待任意一个子进程,waitpid () 可以指定等待某个子进程;
- waitpid () 支持非阻塞模式(WNOHANG),wait () 只能阻塞;
- waitpid () 支持等待进程组,功能更灵活,嵌入式开发中优先用 waitpid ()。
- 面试题:嵌入式中 fork () 要注意什么?答:
- 嵌入式内存有限,fork () 会复制父进程资源,要避免频繁 fork,子进程尽量少分配内存;
- 父进程必须用 wait/waitpid 回收子进程,避免僵尸进程耗尽 PID 和内存;
- 父子进程共享文件描述符,操作硬件设备时要做好同步,避免竞态。
- 新手坑:在循环里重复调用 waitpid (),忘记处理退出的子进程子进程退出后,再调用 waitpid () 会返回 - 1,必须加标志位,避免重复调用。
- 新手坑:WEXITSTATUS () 只在 WIFEXITED () 为真时才能用否则会提取到无效值,导致逻辑错误。
模块二:exec 函数族 —— 进程程序替换
一、先搞懂:exec 函数族是干嘛的?(嵌入式视角)
fork () 是克隆一个和父进程一样的进程,而 exec 函数族的核心作用是 **“程序替换”**:
在当前进程中,用一个全新的程序,替换掉当前进程的代码段、数据段、堆、栈等所有资源,进程的 PID 保持不变,但是运行的程序完全变了。
通俗类比:你有一个开发板,里面跑着 A 程序,你直接用 B 程序的固件覆盖了 A 程序的固件,开发板还是那个开发板(PID 不变),但是跑的程序变成了 B 程序。exec 函数族就是这个 “固件覆盖” 的动作。
嵌入式核心价值:在一个进程中启动另一个独立的程序,比如:
- 主进程检测到升级包,调用 exec 启动固件升级程序;
- 主进程检测到硬件故障,调用 exec 启动硬件诊断程序;
- 启动 shell 脚本、系统命令(比如 ls、reboot)。
二、exec 函数族 6 个核心函数详解
exec 函数族一共有 6 个函数,都在<unistd.h>头文件里,核心功能都是程序替换,只是传参方式、查找程序的方式不同,新手先记住最常用的 2 个:execl()和execvp(),再掌握其他 4 个的区别。
函数命名规则(新手秒记):
l:list,参数用可变参数列表传递,参数最后必须以 NULL 结尾;v:vector,参数用 ** 字符串数组(char* argv [])** 传递,数组最后一个元素必须是 NULL;p:path,会自动从系统的PATH环境变量里查找程序,不用写绝对路径;e:env,传递自定义的环境变量,给新程序用。
6 个函数的原型与区别:
表格
| 函数名 | 参数格式 | 是否自动找 PATH | 是否传自定义环境变量 | 新手使用难度 |
|---|---|---|---|---|
| execl | 可变参数列表 | 否(必须写绝对路径) | 否 | ★☆☆☆☆ 最简单 |
| execlp | 可变参数列表 | 是 | 否 | ★★☆☆☆ 常用 |
| execle | 可变参数列表 | 否 | 是 | ★★★☆☆ |
| execv | 字符串数组 | 否 | 否 | ★★☆☆☆ |
| execvp | 字符串数组 | 是 | 否 | ★★☆☆☆ 常用 |
| execve | 字符串数组 | 否 | 是 | ★★★★☆ 系统调用底层 |
核心结论:所有 exec 函数,最终都会调用内核的 execve 系统调用,其他 5 个都是库函数封装,新手先掌握execl()和execvp(),90% 的嵌入式场景都够用了。
三、execl () 函数实战(最基础,新手必学)
函数原型:
c
运行
#include <unistd.h>
int execl(const char *path, const char *arg, ...);
参数说明:
path:要执行的程序的绝对路径,比如/bin/ls、/sbin/reboot;arg:传给新程序的第一个参数,通常是程序名本身(比如ls);...:可变参数,后续的参数是传给新程序的命令行参数,最后必须以 NULL 结尾,标记参数列表结束。
返回值:
- 调用成功:永远不会返回,因为当前程序已经被替换,原来的代码不会再执行;
- 调用失败:返回 - 1,继续执行原来的代码。
实战代码 1:execl 执行系统 ls 命令
c
运行
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
int main() {
printf("执行execl前,当前进程PID=%d\n", getpid());
// 执行ls -l命令,绝对路径/bin/ls
// 参数:/bin/ls(程序路径),ls(程序名),-l(参数),NULL(结束标记)
execl("/bin/ls", "ls", "-l", NULL);
// 注意:如果execl成功,下面的代码永远不会执行!
// 只有execl失败了,才会走到这里
perror("execl执行失败");
printf("错误码:%d\n", errno);
return -1;
}
编译运行结果:
plaintext
执行execl前,当前进程PID=1240
总用量 20
-rwxr-xr-x 1 linux linux 12344 3月 20 10:00 execl_demo
-rw-r--r-- 1 linux linux 450 3月 20 10:00 execl_demo.c
关键知识点:
- 执行前后 PID 不变,证明是同一个进程,只是程序被替换了;
- execl 成功后,原来的代码完全被替换,所以
perror那行永远不会执行,只有失败才会走。
实战代码 2:execl 执行自定义嵌入式程序
嵌入式场景:主程序检测到用户按下升级按键,调用 execl 执行独立的升级程序upgrade。
第一步:先写一个自定义的升级程序upgrade.c,编译成可执行文件upgrade
c
运行
// upgrade.c
#include <stdio.h>
#include <unistd.h>
int main(int argc, char* argv[]) {
printf("【升级程序】启动,PID=%d,收到参数:%s\n", getpid(), argv[1]);
printf("【升级程序】开始固件升级...\n");
sleep(3);
printf("【升级程序】升级完成,设备将重启\n");
return 0;
}
编译升级程序:
bash
运行
gcc upgrade.c -o upgrade
第二步:写主程序main.c,用 execl 调用升级程序
c
运行
// main.c
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
int main() {
printf("【主程序】设备启动,PID=%d\n", getpid());
printf("【主程序】检测到升级按键按下,启动升级程序...\n");
// 执行当前目录下的upgrade程序,传入参数"update_v1.2.bin"
execl("./upgrade", "upgrade", "update_v1.2.bin", NULL);
// 失败处理
perror("启动升级程序失败");
printf("【主程序】升级失败,进入正常模式\n");
return 0;
}
编译运行:
bash
运行
gcc main.c -o main
./main
运行结果:
plaintext
【主程序】设备启动,PID=1245
【主程序】检测到升级按键按下,启动升级程序...
【升级程序】启动,PID=1245,收到参数:update_v1.2.bin
【升级程序】开始固件升级...
【升级程序】升级完成,设备将重启
四、execvp () 函数实战(常用,支持 PATH 查找)
函数原型:
c
运行
#include <unistd.h>
int execvp(const char *file, char *const argv[]);
参数说明:
file:程序名,会自动从PATH环境变量里找,不用写绝对路径;argv:字符串数组,传给新程序的参数,数组最后一个元素必须是 NULL。
优势:不用写绝对路径,执行系统命令更方便,适合动态传参的场景。
实战代码:execvp 执行 ping 命令(嵌入式网络诊断)
模拟场景:嵌入式设备网络不通,调用 execvp 执行 ping 命令,诊断网络连通性。
c
运行
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
int main() {
printf("【网络诊断程序】启动,PID=%d\n", getpid());
printf("【网络诊断】开始ping百度服务器...\n");
// 定义参数数组:ping -c 4 www.baidu.com
// 数组最后一个元素必须是NULL
char* argv[] = {
"ping",
"-c",
"4",
"www.baidu.com",
NULL
};
// 执行ping命令,不用写绝对路径,execvp会自动找
execvp("ping", argv);
// 失败处理
perror("execvp执行失败");
return -1;
}
编译运行结果:
plaintext
【网络诊断程序】启动,PID=1250
【网络诊断】开始ping百度服务器...
PING www.baidu.com (110.242.68.3) 56(84) bytes of data.
64 bytes from 110.242.68.3 (110.242.68.3): icmp_seq=1 ttl=52 time=12.3 ms
64 bytes from 110.242.68.3 (110.242.68.3): icmp_seq=2 ttl=52 time=11.8 ms
64 bytes from 110.242.68.3 (110.242.68.3): icmp_seq=3 ttl=52 time=12.1 ms
64 bytes from 110.242.68.3 (110.242.68.3): icmp_seq=4 ttl=52 time=11.9 ms
--- www.baidu.com ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3004ms
rtt min/avg/max/mdev = 11.823/12.032/12.312/0.192 ms
五、嵌入式核心特性与面试考点
- exec 函数族会保留什么?(面试必问)
- 进程 PID 保持不变;
- 父进程打开的文件描述符会保留(除非设置了 FD_CLOEXEC 标志),这是嵌入式核心特性:主进程打开的串口、传感器设备、socket,exec 后的新程序可以直接用,不用重新打开;
- 进程的当前工作目录、umask、环境变量会保留。
- exec 函数族会替换什么?
- 原进程的代码段、数据段、堆、栈、信号处理函数,全部会被新程序替换,原进程的代码除了 exec 之前的,都不会执行。
- 面试题:fork 和 exec 配合使用的场景?答:这是 Linux 下创建新程序的标准流程!
- 第一步:fork () 创建子进程;
- 第二步:子进程调用 exec 函数族,执行新的程序;
- 第三步:父进程用 wait/waitpid 回收子进程。这样既可以创建新的程序,又能保证父进程继续运行,不会被替换。
六、新手踩坑避坑指南
- 坑 1:execl 的参数列表忘记加 NULL 结尾这是新手最常见的错误,会导致 exec 函数执行失败,参数列表必须以 NULL 结尾!
- 坑 2:execl 成功后,还写了后续代码exec 成功后,原程序被完全替换,后续代码永远不会执行,只有失败才会走,所以后续代码只能写错误处理。
- 坑 3:用 execl 执行 shell 脚本,忘记加解释器路径执行 shell 脚本时,不能直接写脚本路径,必须写
/bin/sh,比如:c
运行
execl("/bin/sh", "sh", "./test.sh", NULL); - 坑 4:嵌入式中 exec 执行程序,忘记给程序加可执行权限自定义的程序必须用
chmod +x 程序名加可执行权限,否则 exec 会执行失败。
模块三:守护进程 —— 嵌入式后台服务实现
一、什么是守护进程?(嵌入式视角)
守护进程(Daemon Process),也叫精灵进程,是在 Linux 后台运行、脱离终端、不受用户登录 / 退出影响的特殊进程,生命周期和系统一致,系统开机启动,关机才退出。
嵌入式核心价值:实现设备的后台常驻服务,比如:
- 传感器数据采集服务:开机就一直运行,循环采集传感器数据;
- 网络心跳服务:后台一直和云端保持心跳连接;
- 硬件看门狗服务:后台监控设备运行状态,异常时自动重启;
- 日志服务:后台收集设备所有日志,写入本地存储。
通俗类比:守护进程就像你家里的智能电表,你看不见它,但是它一直在后台运行,24 小时记录用电数据,不受你家开灯关灯、进出家门的影响,除非断电(系统关机)才会停止。
二、守护进程的核心特性
- 脱离终端:不会和任何终端绑定,终端关闭不会导致守护进程退出;
- 后台运行:没有前台界面,完全在后台运行;
- 生命周期长:随系统启动,系统关机才退出;
- 独立会话:有自己独立的会话组和进程组,不受原终端的信号影响;
- PID 通常为 1 的 init/systemd 进程收养。
三、守护进程创建的标准 7 步流程(面试必背,嵌入式通用)
创建守护进程有严格的标准步骤,每一步都有对应的作用,少一步都可能导致守护进程异常,新手必须严格按步骤来。
表格
| 步骤 | 操作 | 核心作用 |
|---|---|---|
| 1 | fork () 创建子进程,父进程退出 | 让子进程成为孤儿进程,被 init 进程收养,脱离原终端的控制 |
| 2 | 子进程调用 setsid () 创建新会话 | 让子进程成为新会话的组长,彻底脱离原终端、原进程组,成为独立会话 |
| 3 | 再次 fork () 创建孙进程,子进程退出 | 彻底禁止进程重新打开控制终端,避免会话组长获取终端的风险,这一步是可选但推荐的 |
| 4 | 修改工作目录为根目录 / | 避免原工作目录被卸载(比如 U 盘挂载的目录),导致进程异常 |
| 5 | 修改文件权限掩码 umask 为 0 | 取消继承的文件权限限制,让守护进程创建文件时,权限完全可控 |
| 6 | 关闭所有不需要的文件描述符 | 关闭从父进程继承的 0 (标准输入)、1 (标准输出)、2 (标准错误),以及其他不需要的文件描述符,避免占用资源,脱离终端 |
| 7 | 重定向标准输入 / 输出 / 错误到 /dev/null | 把 printf 等输出重定向到 “黑洞设备”/dev/null,避免终端输出,也避免输出报错导致进程异常 |
四、守护进程完整实战代码(嵌入式传感器采集服务)
我们实现一个嵌入式场景的守护进程:开机后台运行,每 10 秒采集一次温度数据,写入日志文件,不受终端关闭影响。
c
运行
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
#include <signal.h>
#include <time.h>
// 日志文件路径,嵌入式中通常放在/var/log/或者本地存储目录
#define LOG_FILE "/tmp/sensor_daemon.log"
// 步骤1-7:创建守护进程的标准函数
void create_daemon() {
pid_t pid;
// 第一步:第一次fork,创建子进程,父进程退出
pid = fork();
if (pid < 0) {
perror("第一次fork失败");
exit(1);
} else if (pid > 0) {
// 父进程直接退出,子进程成为孤儿进程
printf("父进程退出,子进程PID=%d\n", pid);
exit(0);
}
// 第二步:子进程调用setsid()创建新会话
if (setsid() < 0) {
perror("setsid创建会话失败");
exit(1);
}
// 第三步:第二次fork,创建孙进程,子进程退出,彻底禁止打开终端
pid = fork();
if (pid < 0) {
perror("第二次fork失败");
exit(1);
} else if (pid > 0) {
// 子进程退出,孙进程成为最终的守护进程
exit(0);
}
// 第四步:修改工作目录为根目录
if (chdir("/") < 0) {
perror("修改工作目录失败");
exit(1);
}
// 第五步:修改文件权限掩码为0
umask(0);
// 第六步:关闭所有不需要的文件描述符
// 系统最大文件描述符数量,嵌入式中可以直接关闭0、1、2三个
int max_fd = sysconf(_SC_OPEN_MAX);
for (int fd = 0; fd < max_fd; fd++) {
close(fd);
}
// 第七步:重定向标准输入/输出/错误到/dev/null
int fd_null = open("/dev/null", O_RDWR);
dup2(fd_null, STDIN_FILENO); // 标准输入0 → /dev/null
dup2(fd_null, STDOUT_FILENO); // 标准输出1 → /dev/null
dup2(fd_null, STDERR_FILENO); // 标准错误2 → /dev/null
close(fd_null);
}
// 守护进程核心业务:传感器采集+日志写入
void sensor_task() {
FILE *log_fp;
time_t now;
struct tm *local_time;
float temp;
while (1) {
// 1. 打开日志文件,追加模式
log_fp = fopen(LOG_FILE, "a+");
if (log_fp == NULL) {
// 守护进程不能用printf,只能写日志,这里失败直接退出
exit(1);
}
// 2. 获取当前时间
now = time(NULL);
local_time = localtime(&now);
// 3. 模拟传感器采集温度
temp = 25.0 + (rand() % 100) / 10.0; // 25.0~35.0℃
// 4. 写入日志:时间 + 温度数据
fprintf(log_fp, "[%04d-%02d-%02d %02d:%02d:%02d] 采集温度:%.1f℃\n",
local_time->tm_year + 1900, local_time->tm_mon + 1, local_time->tm_mday,
local_time->tm_hour, local_time->tm_min, local_time->tm_sec,
temp);
// 5. 关闭文件
fclose(log_fp);
// 6. 休眠10秒,循环采集
sleep(10);
}
}
int main() {
// 创建守护进程
create_daemon();
// 守护进程核心业务
sensor_task();
return 0;
}
五、代码编译与运行验证
- 编译命令:
bash
运行
gcc sensor_daemon.c -o sensor_daemon - 运行守护进程:
bash
运行
./sensor_daemon - 验证守护进程是否运行:
bash
运行
你会看到进程在后台运行,TTY 列显示ps -ef | grep sensor_daemon?,说明已经脱离终端。 - 查看日志文件:
bash
运行
可以看到每 10 秒写入一条温度采集日志,即使关闭终端,进程依然在运行,日志持续写入。tail -f /tmp/sensor_daemon.log
六、嵌入式开发中守护进程的核心注意事项
- 日志输出:守护进程脱离了终端,不能用 printf、scanf 等标准输入输出,必须把日志写入专门的日志文件,或者用 syslog 系统日志服务。
- 异常处理:守护进程需要长期运行,必须做好异常处理,比如文件打开失败、内存分配失败,不能直接崩溃,要记录日志后优雅退出或者重启。
- 心跳与看门狗:嵌入式守护进程通常会和硬件看门狗配合,定期喂狗,避免进程卡死导致设备失控。
- 单实例运行:守护进程通常要保证只有一个实例在运行,避免重复启动,常用的方法是加文件锁(flock),创建 PID 文件。
- 信号处理:守护进程需要处理 SIGTERM、SIGINT 等信号,收到信号后要释放资源、关闭文件,优雅退出,而不是直接被杀死。
七、面试高频问题
- 面试题:创建守护进程为什么要 fork 两次?答:
- 第一次 fork:让子进程成为孤儿进程,被 init 进程收养,为调用 setsid () 做准备(setsid () 不能由进程组组长调用);
- 第二次 fork:让孙进程不再是会话组长,彻底禁止进程重新打开控制终端,因为只有会话组长才能打开控制终端,这一步是为了彻底隔离终端,避免意外情况。
- 面试题:setsid () 函数的作用?答:
- 创建一个新的会话,当前进程成为会话组长;
- 创建一个新的进程组,当前进程成为进程组组长;
- 让当前进程彻底脱离原控制终端、原进程组、原会话,不受原终端的信号影响,是创建守护进程的核心步骤。
- 面试题:守护进程为什么要修改工作目录为根目录?答:避免守护进程的工作目录所在的文件系统被卸载(比如 U 盘、SD 卡挂载的目录),导致进程运行异常,根目录 / 是系统永远不会卸载的目录,保证进程稳定运行。
第一部分总结
本部分覆盖了知识点 D1-D3 的全部内容,从嵌入式开发视角,系统讲解了进程创建与回收、exec 函数族、守护进程三大核心模块,每个模块都包含了原理通俗讲解、面试考点、嵌入式实战代码、新手避坑指南
-------------------------------------------------------------------------------------------------------------------------------------------- 更新于2026.3.19晚8:43
第二部分:GDB 调试多进程 + 线程核心编程(对应知识点 D4-D6)
模块一:GDB 调试多进程程序(嵌入式调试核心技能)
一、为什么嵌入式开发必须掌握多进程 GDB 调试?
嵌入式设备没有图形化调试界面,所有调试都依赖 GDB(命令行调试工具),而多进程程序的调试是应届生面试 / 工作的高频痛点 —— 新手调试时会发现 “断点只在父进程生效,子进程跑飞找不到问题”,掌握多进程 GDB 调试是 14k 岗位的必备实操技能。
二、GDB 调试多进程的核心问题:fork 后进程 “分身”
普通 GDB 调试默认只跟踪父进程,子进程 fork 后会脱离 GDB 控制,导致子进程的断点无效。解决这个问题的核心是设置 GDB 的「fork 跟踪模式」。
三、GDB 多进程调试核心命令(新手必背)
表格
| 命令 | 作用 | 嵌入式场景用法 |
|---|---|---|
set follow-fork-mode parent |
fork 后跟踪父进程(默认) | 调试父进程的网络通信逻辑 |
set follow-fork-mode child |
fork 后跟踪子进程 | 调试子进程的传感器采集逻辑 |
set detach-on-fork on |
fork 后子进程脱离 GDB(默认) | 只调试一个进程时用 |
set detach-on-fork off |
fork 后子进程不脱离 GDB,GDB 控制所有进程 | 同时调试父子进程(最常用) |
info inferiors |
查看 GDB 当前控制的所有进程(inferior 是 GDB 对进程的称呼) | 切换进程前先看进程列表 |
inferior <进程编号> |
切换到指定编号的进程调试 | 比如inferior 2切换到子进程 |
break <函数/行号> fork |
在 fork 处打断点,方便切换跟踪模式 | 调试 fork 后的分支逻辑 |
通俗类比:GDB 默认是 “单摄像头”,只拍父进程;设置detach-on-fork off后变成 “多摄像头”,同时监控父子进程;inferior命令就是切换摄像头视角,看不同进程的执行情况。
四、实战:GDB 调试传感器采集多进程程序
我们用第一部分的 fork+wait 代码(传感器采集子进程),完整演示多进程调试流程:
1. 准备调试代码(带调试信息编译)
c
运行
// fork_debug.c(基于第一部分的传感器采集代码,加调试断点标记)
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdlib.h>
int main() {
pid_t pid, wait_pid;
int status;
pid = fork(); // 断点1:fork处
if (pid < 0) {
perror("fork失败");
return -1;
}
// 子进程:传感器采集
else if (pid == 0) {
printf("【子进程】PID=%d,开始采集\n", getpid()); // 断点2:子进程入口
sleep(3); // 模拟采集
printf("【子进程】采集完成,退出码100\n");
exit(100); // 断点3:子进程退出
}
// 父进程:等待回收
else {
printf("【父进程】PID=%d,等待子进程\n", getpid()); // 断点4:父进程等待
wait_pid = wait(&status);
if (WIFEXITED(status)) {
printf("【父进程】子进程%d退出,码=%d\n", wait_pid, WEXITSTATUS(status));
}
}
return 0;
}
2. 带调试信息编译(嵌入式调试必须加 - g)
bash
运行
gcc fork_debug.c -o fork_debug -g # -g:生成调试信息,嵌入式编译必须加
3. GDB 调试完整流程(新手一步一步来)
bash
运行
# 启动GDB调试
gdb ./fork_debug
# 步骤1:设置多进程调试模式(关键!)
(gdb) set detach-on-fork off # fork后子进程不脱离GDB
(gdb) set follow-fork-mode parent # 默认跟踪父进程(先看父进程)
# 步骤2:设置断点(在fork处、子进程入口、父进程等待处)
(gdb) break fork_debug.c:10 # 断点1:fork行
(gdb) break fork_debug.c:17 # 断点2:子进程入口
(gdb) break fork_debug.c:24 # 断点4:父进程等待
# 步骤3:运行程序
(gdb) run
# 此时程序停在fork行(断点1),执行下一步
(gdb) next
# 步骤4:查看所有进程(inferior列表)
(gdb) info inferiors
# 输出示例:
# Num Description Executable
# * 1 process 12345 ./fork_debug (父进程)
# 2 process 12346 ./fork_debug (子进程)
# 步骤5:切换到子进程调试
(gdb) inferior 2 # 切换到子进程(编号2)
(gdb) continue # 让子进程继续运行,停在断点2(子进程入口)
(gdb) next # 单步执行子进程代码,直到exit(100)
# 步骤6:切回父进程,查看回收结果
(gdb) inferior 1 # 切回父进程
(gdb) continue # 父进程执行wait,回收子进程
(gdb) print WEXITSTATUS(status) # 打印子进程退出码,结果是100
# 步骤7:退出GDB
(gdb) quit
五、嵌入式多进程 GDB 调试避坑指南
- 坑 1:忘记加 - g 编译嵌入式编译时如果不加
-g,GDB 看不到行号、变量名,只能调试汇编代码,新手直接懵。 - 坑 2:fork 后子进程断点不生效原因是默认
detach-on-fork on,子进程脱离 GDB,必须先设置detach-on-fork off。 - 坑 3:切换进程后忘记 continue切换到子进程后,程序处于暂停状态,需要
continue才能执行到子进程的断点。 - 坑 4:嵌入式交叉调试(面试高频)实际嵌入式开发中,调试板端程序用「GDB 交叉调试」:
- 板端运行
gdbserver :1234 ./fork_debug; - 主机端运行
arm-linux-gnueabihf-gdb ./fork_debug; - 主机 GDB 中执行
target remote 板端IP:1234,后续命令和本地调试一致。
- 板端运行
六、面试高频问题
- 面试题:GDB 调试多进程时,如何跟踪子进程?答:
- 先设置
set detach-on-fork off(子进程不脱离 GDB); - 用
set follow-fork-mode child(默认跟踪子进程),或info inferiors+inferior <编号>切换到子进程; - 加断点后
continue即可调试子进程。
- 先设置
- 面试题:嵌入式交叉调试的流程?答:
- 板端启动 gdbserver,监听端口:
gdbserver :端口 程序名; - 主机端用交叉编译的 GDB 工具链,连接板端:
target remote 板端IP:端口; - 后续断点、单步、查看变量和本地 GDB 一致。
- 板端启动 gdbserver,监听端口:
模块二:线程的创建与参数传递(pthread 核心)
一、先搞懂:进程 vs 线程(嵌入式视角)
应届生最容易混淆进程和线程,用嵌入式场景类比:
表格
| 维度 | 进程 | 线程 | 嵌入式通俗类比 |
|---|---|---|---|
| 资源分配 | 操作系统分配资源的最小单位 | 调度执行的最小单位(共享进程资源) | 进程 = 开发板整机(独立电源、内存);线程 = 开发板上的多个模块(共享电源、内存) |
| 内存空间 | 独立(代码 / 数据 / 堆 / 栈) | 共享进程的内存空间(仅栈独立) | 进程 = 两个独立开发板;线程 = 一个开发板上的传感器模块 + 网络模块 |
| 通信成本 | 高(需管道 / 消息队列) | 低(直接读写共享变量) | 进程通信 = 两个开发板之间用串口传数据;线程通信 = 开发板内部模块直接读内存 |
| 创建开销 | 大(复制内存 / 文件描述符) | 小(仅创建栈和寄存器上下文) | 进程创建 = 烧录一个新开发板;线程创建 = 在开发板上启动一个新模块 |
| 崩溃影响 | 单个进程崩溃不影响其他进程 | 一个线程崩溃导致整个进程崩溃 | 一个开发板坏了不影响另一个;开发板上一个模块烧了导致整机死机 |
嵌入式核心结论:
- 耗时 / 阻塞任务(如传感器采集、网络通信)用线程(开销小、通信方便);
- 独立 / 隔离任务(如固件升级、硬件诊断)用进程(崩溃不影响主程序)。
二、线程创建:pthread_create () 函数(新手必掌握)
1. 函数原型与头文件
c
运行
#include <pthread.h> // 线程所有函数都需要这个头文件
int pthread_create(
pthread_t *thread, // 输出参数:存储新线程的ID
const pthread_attr_t *attr, // 线程属性(NULL=默认属性)
void *(*start_routine)(void *), // 线程入口函数(函数指针)
void *arg // 传给线程入口函数的参数
);
返回值:
- 成功:返回 0(注意!不是进程的 PID,线程函数返回 0 表示成功);
- 失败:返回错误码(不是 - 1,Linux 线程函数都是返回错误码)。
编译注意:链接时必须加-lpthread库,否则编译报错:
bash
运行
gcc thread_demo.c -o thread_demo -lpthread # 嵌入式编译必须加-lpthread
2. 核心参数解析(新手最容易错)
表格
| 参数 | 新手易错点 | 嵌入式注意事项 |
|---|---|---|
thread |
传普通变量而非地址(比如传pthread_t tid而不是&tid) |
线程 ID 是pthread_t类型,不是 int,打印用%lu(无符号长整型) |
attr |
新手直接用 NULL 即可,不用自定义属性(嵌入式 99% 场景用默认) | 自定义属性(如栈大小)需注意嵌入式内存限制,栈默认 8MB,可改小节省内存 |
start_routine |
函数指针类型写错(必须是void* (*)(void*)) |
线程入口函数不能返回 int,必须返回void* |
arg |
传递局部变量地址导致野指针(线程执行时变量已销毁) | 传递参数优先用全局变量 / 堆内存,或值传递(封装成结构体) |
3. 实战 1:简单线程创建(传感器采集线程)
模拟嵌入式场景:主线程负责屏幕刷新,子线程负责温度采集,共享变量传递数据。
c
运行
// thread_simple.c
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
// 全局共享变量:子线程采集的温度,主线程打印
float g_temp = 0.0;
// 线程退出标志(全局变量,线程间通信)
int g_exit_flag = 0;
// 线程入口函数:温度采集(必须是void* (*)(void*)类型)
void* sensor_collect(void* arg) {
printf("【采集线程】启动,线程ID=%lu\n", (unsigned long)pthread_self());
while (!g_exit_flag) { // 没收到退出标志就循环采集
// 模拟传感器采集(25~35℃)
g_temp = 25.0 + (rand() % 100) / 10.0;
printf("【采集线程】采集温度:%.1f℃\n", g_temp);
sleep(1); // 1秒采集一次
}
printf("【采集线程】收到退出标志,退出\n");
// 线程退出,返回NULL(必须返回void*)
return NULL;
}
int main() {
pthread_t tid; // 存储线程ID
int ret;
// 步骤1:创建采集线程
ret = pthread_create(&tid, NULL, sensor_collect, NULL);
if (ret != 0) { // 线程创建失败(返回错误码)
perror("pthread_create失败");
return -1;
}
printf("【主线程】创建采集线程成功,线程ID=%lu\n", (unsigned long)tid);
// 步骤2:主线程循环打印温度(模拟屏幕刷新)
for (int i = 0; i < 5; i++) {
printf("【主线程】屏幕显示温度:%.1f℃\n", g_temp);
sleep(1);
}
// 步骤3:通知子线程退出
g_exit_flag = 1;
// 等待子线程退出(后续讲pthread_join)
pthread_join(tid, NULL);
printf("【主线程】程序退出\n");
return 0;
}
4. 编译运行结果
bash
运行
gcc thread_simple.c -o thread_simple -lpthread
./thread_simple
plaintext
【主线程】创建采集线程成功,线程ID=1407083200
【采集线程】启动,线程ID=1407083200
【采集线程】采集温度:28.5℃
【主线程】屏幕显示温度:28.5℃
【采集线程】采集温度:29.2℃
【主线程】屏幕显示温度:29.2℃
【采集线程】采集温度:30.1℃
【主线程】屏幕显示温度:30.1℃
【采集线程】采集温度:31.5℃
【主线程】屏幕显示温度:31.5℃
【采集线程】采集温度:32.3℃
【主线程】屏幕显示温度:32.3℃
【采集线程】收到退出标志,退出
【主线程】程序退出
5. 实战 2:线程参数传递(新手避坑重点)
线程参数传递是应届生最高频错误点,我们演示正确的 3 种传参方式:
方式 1:值传递(封装成 void*)
适合传递单个整数 / 浮点型(注意:void * 是指针,传递值需强制转换):
c
运行
// thread_arg1.c
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
// 线程入口函数:接收采集间隔(秒)
void* sensor_collect(void* arg) {
// 把void*转回int(值传递)
int interval = (int)arg;
printf("【采集线程】采集间隔:%d秒\n", interval);
for (int i = 0; i < 3; i++) {
printf("【采集线程】采集温度:%.1f℃\n", 25.0 + i);
sleep(interval);
}
return NULL;
}
int main() {
pthread_t tid;
int interval = 2; // 采集间隔2秒
// 传递值:把int强制转换成void*
pthread_create(&tid, NULL, sensor_collect, (void*)interval);
pthread_join(tid, NULL);
return 0;
}
方式 2:地址传递(全局 / 堆内存)
适合传递结构体(必须用全局 / 堆内存,禁止用局部变量!):
c
运行
// thread_arg2.c
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
// 传感器配置结构体
typedef struct {
int interval; // 采集间隔
float min_temp; // 最低温度阈值
float max_temp; // 最高温度阈值
} SensorConfig;
// 线程入口函数:接收配置结构体
void* sensor_collect(void* arg) {
// 把void*转回结构体指针
SensorConfig* cfg = (SensorConfig*)arg;
printf("【采集线程】配置:间隔=%d秒,阈值=[%.1f, %.1f]\n",
cfg->interval, cfg->min_temp, cfg->max_temp);
// 模拟采集
for (int i = 0; i < 3; i++) {
float temp = 25.0 + i;
if (temp < cfg->min_temp || temp > cfg->max_temp) {
printf("【采集线程】温度异常:%.1f℃\n", temp);
} else {
printf("【采集线程】温度正常:%.1f℃\n", temp);
}
sleep(cfg->interval);
}
return NULL;
}
int main() {
pthread_t tid;
// 方式1:全局结构体(推荐)
// static SensorConfig cfg = {2, 25.0, 30.0};
// 方式2:堆内存(用完要释放)
SensorConfig* cfg = (SensorConfig*)malloc(sizeof(SensorConfig));
cfg->interval = 2;
cfg->min_temp = 25.0;
cfg->max_temp = 30.0;
// 传递结构体地址
pthread_create(&tid, NULL, sensor_collect, (void*)cfg);
pthread_join(tid, NULL);
// 堆内存要释放
free(cfg);
return 0;
}
方式 3:禁止!传递局部变量地址(新手致命坑)
c
运行
// 错误示例!!!
void wrong_demo() {
pthread_t tid;
int interval = 2; // 局部变量,栈上分配
// 错误:传递局部变量地址,主线程退出后变量销毁,子线程访问野指针
pthread_create(&tid, NULL, sensor_collect, (void*)&interval);
// 即使加sleep,也不保证安全!
sleep(1);
}
错误原因:局部变量存在栈上,主线程执行完后栈空间被释放,子线程访问&interval时,内存已经被覆盖,导致数据错乱 / 段错误。
六、线程创建的面试高频问题
- 面试题:pthread_create 的返回值为什么不是 - 1?答:
- Linux 线程函数(pthread 系列)遵循 POSIX 标准,成功返回 0,失败返回 errno 对应的错误码;
- 进程相关函数(fork/wait)遵循 UNIX 传统,失败返回 - 1,errno 存储错误码;
- 嵌入式开发中要注意区分,比如 pthread_create 返回非 0 就是失败。
- *面试题:线程入口函数的类型为什么是 void (*)(void*)?**答:
void*返回值:允许线程返回任意类型的数据(比如返回采集结果结构体);void*参数:允许传递任意类型的参数(整数、结构体、指针),通用性最强;- 嵌入式中如果不需要返回 / 传参,用 NULL 即可。
- 面试题:进程和线程的区别?(嵌入式视角)答(核心得分点):
- 资源:进程有独立内存空间,线程共享进程内存(仅栈独立);
- 开销:线程创建 / 切换开销远小于进程,嵌入式内存 / CPU 有限,优先用线程;
- 崩溃:线程崩溃导致整个进程崩溃,进程崩溃不影响其他进程;
- 通信:线程直接读写共享变量,进程需要管道 / 消息队列等 IPC 机制。
模块三:线程的回收与内存管理(pthread_join/pthread_detach)
一、为什么要回收线程?(嵌入式内存痛点)
和进程的 “僵尸进程” 类似,线程退出后如果不回收,会导致线程资源泄漏(TCB 线程控制块占用内核内存),嵌入式设备长期运行会耗尽内存,最终死机。
线程回收的两种方式:
表格
| 方式 | 函数 | 核心特点 | 嵌入式场景 |
|---|---|---|---|
| 阻塞回收 | pthread_join() | 主线程阻塞等待线程退出,回收资源,可获取线程返回值 | 必须等子线程完成的场景(如传感器校准) |
| 分离回收 | pthread_detach() | 线程退出后自动回收资源,主线程不阻塞 | 后台常驻线程(如心跳线程) |
二、阻塞回收:pthread_join () 函数
1. 函数原型
c
运行
#include <pthread.h>
int pthread_join(
pthread_t thread, // 要回收的线程ID
void **retval // 输出参数:存储线程的返回值(void*类型)
);
返回值:成功返回 0,失败返回错误码。
通俗类比:pthread_join () 就像 “等同事干完活再走”—— 主线程停下来等子线程完成,回收子线程的资源,还能拿到子线程的 “工作报告”(返回值)。
2. 实战:pthread_join 获取线程返回值
模拟嵌入式场景:子线程完成传感器校准,返回校准结果,主线程根据结果执行后续逻辑。
c
运行
// thread_join.c
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
// 校准结果结构体(线程返回值用)
typedef struct {
int code; // 0=成功,1=失败
float offset; // 校准偏移量
} CalibResult;
// 线程入口函数:传感器校准
void* sensor_calib(void* arg) {
printf("【校准线程】开始校准...\n");
sleep(3); // 模拟校准耗时
// 分配堆内存存储校准结果(不能用局部变量!)
CalibResult* res = (CalibResult*)malloc(sizeof(CalibResult));
res->code = 0; // 校准成功
res->offset = 0.5; // 校准偏移量
printf("【校准线程】校准完成,返回结果\n");
// 线程退出,返回结果指针
return (void*)res;
}
int main() {
pthread_t tid;
int ret;
void* thread_ret; // 存储线程返回值
// 创建校准线程
ret = pthread_create(&tid, NULL, sensor_calib, NULL);
if (ret != 0) {
perror("pthread_create失败");
return -1;
}
// 阻塞等待线程退出,获取返回值
printf("【主线程】等待校准完成...\n");
ret = pthread_join(tid, &thread_ret);
if (ret != 0) {
perror("pthread_join失败");
return -1;
}
// 解析返回值
CalibResult* res = (CalibResult*)thread_ret;
if (res->code == 0) {
printf("【主线程】校准成功,偏移量=%.1f℃\n", res->offset);
} else {
printf("【主线程】校准失败\n");
}
// 释放线程返回值的堆内存(关键!)
free(res);
printf("【主线程】程序退出\n");
return 0;
}
3. 编译运行结果
bash
运行
gcc thread_join.c -o thread_join -lpthread
./thread_join
plaintext
【主线程】等待校准完成...
【校准线程】开始校准...
【校准线程】校准完成,返回结果
【主线程】校准成功,偏移量=0.5℃
【主线程】程序退出
三、分离回收:pthread_detach () 函数
1. 函数原型
c
运行
#include <pthread.h>
int pthread_detach(pthread_t thread);
核心作用:将线程设置为 “分离状态”,线程退出后内核自动回收其资源,主线程无需调用 pthread_join,也不能再调用 pthread_join(调用会失败)。
嵌入式核心场景:后台常驻线程(如网络心跳、日志收集),主线程不需要等它退出,设置为分离状态避免资源泄漏。
2. 实战:分离线程(心跳线程)
c
运行
// thread_detach.c
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <signal.h>
// 全局退出标志
int g_exit_flag = 0;
// 信号处理函数:捕获Ctrl+C,设置退出标志
void sig_handler(int sig) {
if (sig == SIGINT) {
printf("\n【主线程】收到Ctrl+C,通知心跳线程退出\n");
g_exit_flag = 1;
}
}
// 线程入口函数:网络心跳
void* heartbeat_thread(void* arg) {
// 设置线程为分离状态(也可以在主线程调用pthread_detach)
pthread_detach(pthread_self());
printf("【心跳线程】启动(分离状态)\n");
while (!g_exit_flag) {
printf("【心跳线程】发送心跳包...\n");
sleep(2); // 每2秒发一次心跳
}
printf("【心跳线程】退出,资源自动回收\n");
return NULL;
}
int main() {
pthread_t tid;
int ret;
// 注册信号处理函数(捕获Ctrl+C)
signal(SIGINT, sig_handler);
// 创建心跳线程
ret = pthread_create(&tid, NULL, heartbeat_thread, NULL);
if (ret != 0) {
perror("pthread_create失败");
return -1;
}
// 也可以在主线程设置分离状态
// pthread_detach(tid);
// 主线程阻塞(模拟主循环)
printf("【主线程】按Ctrl+C退出程序\n");
while (!g_exit_flag) {
sleep(1);
}
printf("【主线程】程序退出\n");
return 0;
}
3. 运行结果
bash
运行
gcc thread_detach.c -o thread_detach -lpthread
./thread_detach
plaintext
【主线程】按Ctrl+C退出程序
【心跳线程】启动(分离状态)
【心跳线程】发送心跳包...
【心跳线程】发送心跳包...
【心跳线程】发送心跳包...
^C
【主线程】收到Ctrl+C,通知心跳线程退出
【心跳线程】退出,资源自动回收
【主线程】程序退出
四、线程内存管理的核心坑点(嵌入式必避)
表格
| 坑点 | 现象 | 解决方案 |
|---|---|---|
| 线程返回局部变量地址 | 主线程解析返回值时野指针 / 段错误 | 返回值用堆内存(malloc),主线程用完 free |
| 不回收线程(既不 join 也不 detach) | 长期运行内存泄漏,设备死机 | 必须二选一:join(需要等结果)/detach(后台线程) |
| 重复 join 同一个线程 | pthread_join 返回错误(EINVAL) | 每个线程只能 join 一次,加标志位记录是否已回收 |
| 线程栈溢出 | 段错误(SIGSEGV) | 嵌入式中自定义线程栈大小(pthread_attr_setstacksize),默认 8MB 可改小 |
五、线程退出的 3 种方式(新手必知)
表格
| 方式 | 用法 | 注意事项 |
|---|---|---|
| return | 线程入口函数 return NULL; | 最安全,会清理线程栈,返回值可被 pthread_join 获取 |
| pthread_exit() | pthread_exit((void*)ret); | 可在线程任意位置退出,效果和 return 一致 |
| 取消线程 | pthread_cancel(tid); | 强制杀死线程,可能导致资源泄漏(如未关闭文件),嵌入式尽量不用 |
示例:pthread_exit 退出线程
c
运行
void* thread_func(void* arg) {
for (int i = 0; i < 10; i++) {
if (i == 5) {
// 提前退出线程,返回值100
pthread_exit((void*)100);
}
sleep(1);
}
return NULL;
}
六、面试高频问题
- 面试题:pthread_join 和 pthread_detach 的区别?答:
- pthread_join 是阻塞回收,主线程等待线程退出,可获取返回值;pthread_detach 是分离回收,线程退出后自动回收资源,主线程不阻塞,也不能获取返回值;
- 每个线程只能选一种方式,既不 join 也不 detach 会导致资源泄漏;
- 嵌入式中,需要等待线程结果用 join,后台常驻线程用 detach。
- 面试题:线程返回值为什么不能用局部变量?答:局部变量存储在线程栈上,线程退出后栈空间被内核回收,主线程通过 pthread_join 获取的地址指向已释放的内存,访问会导致野指针 / 段错误;必须用堆内存(malloc),主线程用完后 free。
- 面试题:嵌入式中如何优化线程内存占用?答:
- 自定义线程栈大小(pthread_attr_setstacksize),比如改到 128KB(满足需求即可);
- 后台线程用 pthread_detach,避免 join 阻塞;
- 减少线程数量,优先用线程池(高频面试点,后续讲);
- 线程间共享内存,避免重复分配。
第二部分总结
- GDB 多进程调试:核心是设置
detach-on-fork off和follow-fork-mode,用info inferiors/inferior切换进程,嵌入式交叉调试需配合 gdbserver; - 线程创建:pthread_create 必须加
-lpthread编译,参数传递禁止用局部变量地址,优先用全局 / 堆内存; - 线程回收:
- pthread_join:阻塞回收,可获取返回值,适合需要等待结果的场景;
- pthread_detach:分离回收,自动释放资源,适合后台常驻线程;
- 内存管理:线程返回值用堆内存,主线程用完释放;不回收线程会导致内存泄漏,嵌入式设备必须避免。
接下来的第三部分,我们将讲解 D7-D9 知识点:线程同步(互斥锁 / 条件变量)、线程池、嵌入式多任务编程最佳实践。
第三部分:线程同步 + 线程池 + 嵌入式最佳实践(对应知识点 D7-D9)
模块一:线程同步(互斥锁 / 条件变量)—— 解决多线程竞态核心
一、为什么嵌入式必须掌握线程同步?(新手踩坑重灾区)
多线程共享进程内存空间,当多个线程同时读写共享变量 / 硬件资源时,会出现「竞态条件(Race Condition)」—— 数据被随机篡改,这是嵌入式多线程最常见的 BUG,比如:
- 两个线程同时写串口,导致数据乱码;
- 采集线程和处理线程同时修改温度变量,导致数值跳变;
- 多线程操作传感器寄存器,导致硬件响应异常。
通俗类比:线程同步就像 “卫生间上锁”—— 一个线程(人)使用共享资源(卫生间)时上锁,用完解锁,其他线程排队等待,避免同时使用导致冲突。
二、核心同步工具 1:互斥锁(pthread_mutex_t)—— 最常用的 “锁”
1. 互斥锁核心原理
互斥锁(Mutex)是 “互斥访问” 的缩写,核心规则:
- 加锁(lock):线程获取锁,若锁已被占用则阻塞,直到锁被释放;
- 解锁(unlock):线程释放锁,唤醒等待该锁的线程;
- 唯一性:同一时刻只有一个线程能持有锁,保证共享资源的原子操作。
2. 互斥锁核心函数(新手必背)
表格
| 函数 | 作用 | 嵌入式注意事项 |
|---|---|---|
pthread_mutex_init(&mutex, NULL) |
初始化互斥锁(NULL = 默认属性) | 嵌入式中优先用静态初始化:pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER |
pthread_mutex_lock(&mutex) |
加锁(阻塞式,拿不到锁就等) | 必须和 unlock 成对出现,避免死锁 |
pthread_mutex_trylock(&mutex) |
尝试加锁(非阻塞,拿不到锁直接返回 EBUSY) | 嵌入式主循环中用,避免阻塞主任务 |
pthread_mutex_unlock(&mutex) |
解锁 | 只有持有锁的线程能解锁,否则报错 |
pthread_mutex_destroy(&mutex) |
销毁互斥锁 | 静态初始化的锁无需销毁 |
编译注意:仍需加-lpthread链接库。
3. 实战 1:解决多线程竞态问题(温度变量读写)
模拟嵌入式场景:采集线程和打印线程同时访问温度变量,不加锁导致数据错乱,加锁后恢复正常。
错误示例(不加锁,数据错乱)
c
运行
// thread_race.c(错误版)
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
float g_temp = 0.0; // 共享温度变量
// 采集线程:修改温度
void* collect_thread(void* arg) {
for (int i = 0; i < 5; i++) {
g_temp = 25.0 + i; // 写操作
printf("【采集线程】设置温度:%.1f℃\n", g_temp);
usleep(100000); // 0.1秒,放大竞态概率
}
return NULL;
}
// 打印线程:读取温度
void* print_thread(void* arg) {
for (int i = 0; i < 5; i++) {
printf("【打印线程】读取温度:%.1f℃\n", g_temp); // 读操作
usleep(100000);
}
return NULL;
}
int main() {
pthread_t tid1, tid2;
pthread_create(&tid1, NULL, collect_thread, NULL);
pthread_create(&tid2, NULL, print_thread, NULL);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
return 0;
}
运行结果(数据错乱):
plaintext
【采集线程】设置温度:25.0℃
【打印线程】读取温度:25.0℃
【采集线程】设置温度:26.0℃
【打印线程】读取温度:26.0℃
【采集线程】设置温度:27.0℃
【打印线程】读取温度:0.0℃ // 竞态导致数据错乱
【采集线程】设置温度:28.0℃
【打印线程】读取温度:28.0℃
【采集线程】设置温度:29.0℃
【打印线程】读取温度:29.0℃
正确示例(加互斥锁,解决竞态)
c
运行
// thread_mutex.c(正确版)
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
float g_temp = 0.0;
// 静态初始化互斥锁(嵌入式推荐)
pthread_mutex_t temp_mutex = PTHREAD_MUTEX_INITIALIZER;
// 采集线程:加锁修改温度
void* collect_thread(void* arg) {
for (int i = 0; i < 5; i++) {
pthread_mutex_lock(&temp_mutex); // 加锁
g_temp = 25.0 + i;
printf("【采集线程】设置温度:%.1f℃\n", g_temp);
pthread_mutex_unlock(&temp_mutex); // 解锁
usleep(100000);
}
return NULL;
}
// 打印线程:加锁读取温度
void* print_thread(void* arg) {
for (int i = 0; i < 5; i++) {
pthread_mutex_lock(&temp_mutex); // 加锁
printf("【打印线程】读取温度:%.1f℃\n", g_temp);
pthread_mutex_unlock(&temp_mutex); // 解锁
usleep(100000);
}
return NULL;
}
int main() {
pthread_t tid1, tid2;
pthread_create(&tid1, NULL, collect_thread, NULL);
pthread_create(&tid2, NULL, print_thread, NULL);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
// 销毁互斥锁(静态初始化可省略)
pthread_mutex_destroy(&temp_mutex);
return 0;
}
运行结果(数据正常):
plaintext
【采集线程】设置温度:25.0℃
【打印线程】读取温度:25.0℃
【采集线程】设置温度:26.0℃
【打印线程】读取温度:26.0℃
【采集线程】设置温度:27.0℃
【打印线程】读取温度:27.0℃
【采集线程】设置温度:28.0℃
【打印线程】读取温度:28.0℃
【采集线程】设置温度:29.0℃
【打印线程】读取温度:29.0℃
4. 实战 2:嵌入式硬件资源保护(串口读写)
嵌入式中硬件资源(串口、I2C、SPI)是共享的,必须用互斥锁保护:
c
运行
// thread_uart_mutex.c
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <fcntl.h>
#include <termios.h>
#define UART_DEV "/dev/ttyS0" // 嵌入式串口设备
int uart_fd;
pthread_mutex_t uart_mutex = PTHREAD_MUTEX_INITIALIZER;
// 串口初始化(嵌入式通用)
int uart_init() {
uart_fd = open(UART_DEV, O_RDWR | O_NOCTTY | O_NDELAY);
if (uart_fd < 0) {
perror("打开串口失败");
return -1;
}
struct termios cfg;
tcgetattr(uart_fd, &cfg);
cfsetispeed(&cfg, B9600); // 波特率9600
cfsetospeed(&cfg, B9600);
cfg.c_cflag |= CLOCAL | CREAD; // 本地连接、使能接收
cfg.c_cflag &= ~CSIZE;
cfg.c_cflag |= CS8; // 8位数据位
cfg.c_cflag &= ~PARENB; // 无校验位
cfg.c_cflag &= ~CSTOPB; // 1位停止位
tcsetattr(uart_fd, TCSANOW, &cfg);
return 0;
}
// 线程1:串口发送传感器数据
void* uart_send_thread(void* arg) {
char buf[32];
for (int i = 0; i < 3; i++) {
pthread_mutex_lock(&uart_mutex); // 加锁保护串口
snprintf(buf, sizeof(buf), "TEMP:%d.%d\n", 25 + i, i * 2);
write(uart_fd, buf, strlen(buf));
printf("【发送线程】串口发送:%s", buf);
pthread_mutex_unlock(&uart_mutex); // 解锁
sleep(1);
}
return NULL;
}
// 线程2:串口发送日志数据
void* uart_log_thread(void* arg) {
char buf[32];
for (int i = 0; i < 3; i++) {
pthread_mutex_lock(&uart_mutex); // 加锁保护串口
snprintf(buf, sizeof(buf), "LOG:INFO %d\n", i);
write(uart_fd, buf, strlen(buf));
printf("【日志线程】串口发送:%s", buf);
pthread_mutex_unlock(&uart_mutex); // 解锁
sleep(1);
}
return NULL;
}
int main() {
if (uart_init() < 0) return -1;
pthread_t tid1, tid2;
pthread_create(&tid1, NULL, uart_send_thread, NULL);
pthread_create(&tid2, NULL, uart_log_thread, NULL);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
close(uart_fd);
pthread_mutex_destroy(&uart_mutex);
return 0;
}
5. 互斥锁死锁问题(嵌入式致命坑)
死锁是指多个线程互相持有对方需要的锁,导致永久阻塞,嵌入式中死锁会直接导致设备卡死。
死锁场景示例
c
运行
// 死锁示例!!!
pthread_mutex_t mutexA = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mutexB = PTHREAD_MUTEX_INITIALIZER;
// 线程1:先锁A,再锁B
void* thread1(void* arg) {
pthread_mutex_lock(&mutexA);
sleep(1); // 让线程2先锁B
pthread_mutex_lock(&mutexB); // 等待B锁,永远拿不到
// ... 业务逻辑
return NULL;
}
// 线程2:先锁B,再锁A
void* thread2(void* arg) {
pthread_mutex_lock(&mutexB);
sleep(1); // 让线程1先锁A
pthread_mutex_lock(&mutexA); // 等待A锁,永远拿不到
// ... 业务逻辑
return NULL;
}
嵌入式死锁避免规则(面试必背)
- 锁的顺序一致:所有线程按相同顺序加锁(比如先锁 A,再锁 B);
- 锁的粒度最小:只在操作共享资源时加锁,用完立即解锁,避免长时间持有锁;
- 使用超时锁:用
pthread_mutex_timedlock设置超时,避免永久阻塞; - 避免嵌套锁:尽量不用多层锁,必须用则严格遵守顺序。
三、核心同步工具 2:条件变量(pthread_cond_t)—— 解决 “忙等” 问题
1. 为什么需要条件变量?
互斥锁只能保证 “互斥访问”,但无法实现 “等待某个条件满足”,比如:
- 处理线程需要等待采集线程采集到数据后再处理,若用互斥锁实现,处理线程会循环加锁检查(忙等),浪费 CPU 资源;
- 条件变量可以让线程 “休眠”,直到条件满足被唤醒,大幅节省 CPU(嵌入式电池供电场景关键)。
2. 条件变量核心原理
条件变量需配合互斥锁使用,核心流程:
- 线程 1:加锁 → 检查条件不满足 → 调用
pthread_cond_wait,释放锁并休眠; - 线程 2:加锁 → 修改条件 → 调用
pthread_cond_signal唤醒线程 1 → 解锁; - 线程 1:被唤醒后重新加锁 → 检查条件满足 → 执行业务逻辑 → 解锁。
3. 条件变量核心函数
表格
| 函数 | 作用 | 嵌入式注意事项 |
|---|---|---|
pthread_cond_init(&cond, NULL) |
初始化条件变量 | 静态初始化:pthread_cond_t cond = PTHREAD_COND_INITIALIZER |
pthread_cond_wait(&cond, &mutex) |
等待条件满足(自动释放 mutex,被唤醒后重新加锁) | 必须在加锁后调用,否则未定义行为 |
pthread_cond_signal(&cond) |
唤醒一个等待该条件的线程 | 嵌入式常用,精准唤醒 |
pthread_cond_broadcast(&cond) |
唤醒所有等待该条件的线程 | 适合多消费者场景 |
pthread_cond_destroy(&cond) |
销毁条件变量 | 静态初始化可省略 |
4. 实战:生产者 - 消费者模型(嵌入式经典场景)
模拟嵌入式场景:
- 生产者线程(采集线程):采集温度数据,放入缓冲区,通知消费者处理;
- 消费者线程(处理线程):等待缓冲区有数据,收到通知后处理数据;
- 用条件变量避免消费者 “忙等”,节省 CPU。
c
运行
// thread_cond.c
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
#define BUF_SIZE 5 // 缓冲区大小
float g_buf[BUF_SIZE]; // 数据缓冲区
int g_buf_count = 0; // 缓冲区数据个数
// 同步工具:互斥锁+条件变量
pthread_mutex_t buf_mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t buf_not_empty = PTHREAD_COND_INITIALIZER; // 缓冲区非空条件
pthread_cond_t buf_not_full = PTHREAD_COND_INITIALIZER; // 缓冲区非满条件
// 生产者线程:采集温度,放入缓冲区
void* producer_thread(void* arg) {
for (int i = 0; i < 10; i++) { // 采集10次
pthread_mutex_lock(&buf_mutex);
// 等待缓冲区非满(满了就休眠)
while (g_buf_count >= BUF_SIZE) {
printf("【生产者】缓冲区满,休眠等待...\n");
pthread_cond_wait(&buf_not_full, &buf_mutex);
}
// 放入数据
float temp = 25.0 + (rand() % 100) / 10.0;
g_buf[g_buf_count++] = temp;
printf("【生产者】放入数据:%.1f℃,缓冲区数量:%d\n", temp, g_buf_count);
// 唤醒消费者(缓冲区非空)
pthread_cond_signal(&buf_not_empty);
pthread_mutex_unlock(&buf_mutex);
usleep(500000); // 0.5秒采集一次
}
return NULL;
}
// 消费者线程:处理缓冲区数据
void* consumer_thread(void* arg) {
for (int i = 0; i < 10; i++) { // 处理10次
pthread_mutex_lock(&buf_mutex);
// 等待缓冲区非空(空了就休眠)
while (g_buf_count <= 0) {
printf("【消费者】缓冲区空,休眠等待...\n");
pthread_cond_wait(&buf_not_empty, &buf_mutex);
}
// 取出数据
float temp = g_buf[--g_buf_count];
printf("【消费者】取出数据:%.1f℃,缓冲区数量:%d\n", temp, g_buf_count);
// 唤醒生产者(缓冲区非满)
pthread_cond_signal(&buf_not_full);
pthread_mutex_unlock(&buf_mutex);
// 模拟数据处理
usleep(800000);
}
return NULL;
}
int main() {
pthread_t tid_prod, tid_cons;
pthread_create(&tid_prod, NULL, producer_thread, NULL);
pthread_create(&tid_cons, NULL, consumer_thread, NULL);
pthread_join(tid_prod, NULL);
pthread_join(tid_cons, NULL);
// 销毁同步工具
pthread_mutex_destroy(&buf_mutex);
pthread_cond_destroy(&buf_not_empty);
pthread_cond_destroy(&buf_not_full);
return 0;
}
运行结果:
plaintext
【生产者】放入数据:28.5℃,缓冲区数量:1
【消费者】取出数据:28.5℃,缓冲区数量:0
【消费者】缓冲区空,休眠等待...
【生产者】放入数据:29.2℃,缓冲区数量:1
【消费者】取出数据:29.2℃,缓冲区数量:0
【生产者】放入数据:30.1℃,缓冲区数量:1
【生产者】放入数据:31.5℃,缓冲区数量:2
【消费者】取出数据:31.5℃,缓冲区数量:1
...
5. 条件变量新手避坑指南
- 必须用 while 循环检查条件:不能用 if!因为线程可能被 “虚假唤醒”(无信号也唤醒),while 会重新检查条件;
- 配合互斥锁使用:pthread_cond_wait 必须在加锁后调用,否则会导致竞态;
- 信号发送时机:修改条件后再发送信号,避免信号丢失。
模块二:线程池(嵌入式高频面试考点)
一、为什么嵌入式需要线程池?
线程的创建 / 销毁有开销(栈分配、内核 TCB 创建),嵌入式 CPU / 内存有限,频繁创建线程会导致:
- CPU 占用率飙升;
- 内存碎片化;
- 系统响应变慢。
线程池的核心思想:提前创建固定数量的线程,复用线程处理任务,避免频繁创建销毁,嵌入式中线程池是处理批量任务(如传感器数据、网络请求)的最优方案。
二、嵌入式线程池核心结构(新手能看懂的简化版)
一个简单的线程池包含 4 个核心部分:
- 任务队列:存储待处理的任务(用链表 / 数组实现);
- 工作线程:固定数量的线程,循环从任务队列取任务执行;
- 同步工具:互斥锁(保护任务队列)+ 条件变量(通知线程有任务);
- 控制参数:线程池是否退出、工作线程数量。
三、实战:实现一个嵌入式简易线程池
我们实现一个适合嵌入式的轻量级线程池,支持任务提交、线程池销毁,可直接移植到 ARM 开发板。
c
运行
// thread_pool.c
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
// 任务结构体
typedef struct {
void (*func)(void*); // 任务函数指针
void* arg; // 任务参数
} Task;
// 任务队列(链表实现,嵌入式推荐)
typedef struct TaskNode {
Task task;
struct TaskNode* next;
} TaskNode;
// 线程池结构体
typedef struct {
TaskNode* task_head; // 任务队列头
int thread_num; // 工作线程数量
int exit_flag; // 退出标志
pthread_t* threads; // 工作线程ID数组
pthread_mutex_t mutex; // 保护任务队列
pthread_cond_t cond; // 通知有任务
} ThreadPool;
// 任务队列初始化
TaskNode* task_queue_init() {
TaskNode* head = (TaskNode*)malloc(sizeof(TaskNode));
head->next = NULL;
return head;
}
// 任务入队
void task_enqueue(ThreadPool* pool, Task task) {
pthread_mutex_lock(&pool->mutex);
// 创建新任务节点
TaskNode* node = (TaskNode*)malloc(sizeof(TaskNode));
node->task = task;
node->next = NULL;
// 插入队列尾部
TaskNode* p = pool->task_head;
while (p->next != NULL) p = p->next;
p->next = node;
// 唤醒工作线程
pthread_cond_signal(&pool->cond);
pthread_mutex_unlock(&pool->mutex);
}
// 任务出队
int task_dequeue(ThreadPool* pool, Task* task) {
pthread_mutex_lock(&pool->mutex);
// 队列为空
if (pool->task_head->next == NULL) {
pthread_mutex_unlock(&pool->mutex);
return -1;
}
// 取出队首任务
TaskNode* node = pool->task_head->next;
*task = node->task;
// 删除节点
pool->task_head->next = node->next;
free(node);
pthread_mutex_unlock(&pool->mutex);
return 0;
}
// 工作线程函数:循环取任务执行
void* worker_thread(void* arg) {
ThreadPool* pool = (ThreadPool*)arg;
Task task;
while (!pool->exit_flag) {
pthread_mutex_lock(&pool->mutex);
// 无任务且未退出,休眠等待
while (pool->task_head->next == NULL && !pool->exit_flag) {
pthread_cond_wait(&pool->cond, &pool->mutex);
}
// 线程池退出,结束线程
if (pool->exit_flag) {
pthread_mutex_unlock(&pool->mutex);
break;
}
// 取出任务
TaskNode* node = pool->task_head->next;
task = node->task;
pool->task_head->next = node->next;
free(node);
pthread_mutex_unlock(&pool->mutex);
// 执行任务
task.func(task.arg);
}
printf("【工作线程%lu】退出\n", (unsigned long)pthread_self());
return NULL;
}
// 线程池初始化
ThreadPool* thread_pool_init(int thread_num) {
ThreadPool* pool = (ThreadPool*)malloc(sizeof(ThreadPool));
pool->thread_num = thread_num;
pool->exit_flag = 0;
pool->task_head = task_queue_init();
// 初始化同步工具
pthread_mutex_init(&pool->mutex, NULL);
pthread_cond_init(&pool->cond, NULL);
// 创建工作线程
pool->threads = (pthread_t*)malloc(thread_num * sizeof(pthread_t));
for (int i = 0; i < thread_num; i++) {
pthread_create(&pool->threads[i], NULL, worker_thread, pool);
printf("【线程池】创建工作线程%lu\n", (unsigned long)pool->threads[i]);
}
return pool;
}
// 提交任务到线程池
void thread_pool_add_task(ThreadPool* pool, void (*func)(void*), void* arg) {
Task task;
task.func = func;
task.arg = arg;
task_enqueue(pool, task);
}
// 销毁线程池
void thread_pool_destroy(ThreadPool* pool) {
// 设置退出标志
pool->exit_flag = 1;
// 唤醒所有工作线程
pthread_cond_broadcast(&pool->cond);
// 等待所有线程退出
for (int i = 0; i < pool->thread_num; i++) {
pthread_join(pool->threads[i], NULL);
}
// 释放任务队列
TaskNode* p = pool->task_head;
while (p != NULL) {
TaskNode* tmp = p;
p = p->next;
free(tmp);
}
// 释放资源
free(pool->threads);
pthread_mutex_destroy(&pool->mutex);
pthread_cond_destroy(&pool->cond);
free(pool);
printf("【线程池】销毁完成\n");
}
// -------------------------- 测试代码 --------------------------
// 嵌入式任务:处理传感器数据
void sensor_task(void* arg) {
int task_id = *(int*)arg;
float temp = 25.0 + (rand() % 100) / 10.0;
printf("【任务%d】处理温度数据:%.1f℃(线程%lu)\n",
task_id, temp, (unsigned long)pthread_self());
free(arg); // 释放参数内存
usleep(500000); // 模拟处理耗时
}
int main() {
// 初始化线程池(3个工作线程,嵌入式推荐2-4个)
ThreadPool* pool = thread_pool_init(3);
// 提交10个任务
for (int i = 0; i < 10; i++) {
int* task_id = (int*)malloc(sizeof(int));
*task_id = i + 1;
thread_pool_add_task(pool, sensor_task, task_id);
printf("【主线程】提交任务%d\n", i + 1);
}
// 等待所有任务完成
sleep(3);
// 销毁线程池
thread_pool_destroy(pool);
return 0;
}
编译运行结果
bash
运行
gcc thread_pool.c -o thread_pool -lpthread
./thread_pool
plaintext
【线程池】创建工作线程1407083200
【线程池】创建工作线程1407082368
【线程池】创建工作线程1407081536
【主线程】提交任务1
【主线程】提交任务2
【主线程】提交任务3
【主线程】提交任务4
【主线程】提交任务5
【主线程】提交任务6
【主线程】提交任务7
【主线程】提交任务8
【主线程】提交任务9
【主线程】提交任务10
【任务1】处理温度数据:28.5℃(线程1407083200)
【任务2】处理温度数据:29.2℃(线程1407082368)
【任务3】处理温度数据:30.1℃(线程1407081536)
【任务4】处理温度数据:31.5℃(线程1407083200)
【任务5】处理温度数据:32.3℃(线程1407082368)
...
【工作线程1407083200】退出
【工作线程1407082368】退出
【工作线程1407081536】退出
【线程池】销毁完成
嵌入式线程池优化点(面试加分)
- 动态调整线程数:根据任务数量动态增加 / 减少线程(避免固定线程数浪费资源);
- 任务优先级:给任务加优先级,高优先级任务先执行(如硬件异常任务);
- 内存优化:用静态数组代替链表,避免 malloc/free 导致内存碎片;
- 任务超时:给任务设置超时时间,避免单个任务阻塞整个线程池。
模块三:嵌入式多任务编程最佳实践(珠三角 14k 岗位核心要求)
一、进程 / 线程选型原则(嵌入式场景化)
表格
| 场景 | 选型 | 原因 |
|---|---|---|
| 独立任务(如固件升级、硬件诊断) | 进程 | 崩溃不影响主程序,隔离性好 |
| 高频 / 低延迟任务(如传感器采集、串口处理) | 线程 | 开销小,通信方便,响应快 |
| 后台常驻服务(如心跳、日志) | 守护进程 / 分离线程 | 不受终端影响,长期运行 |
| 批量短任务(如数据解析、网络请求) | 线程池 | 避免频繁创建线程,节省资源 |
二、内存优化技巧(嵌入式内存受限)
- 线程栈大小调整:默认 8MB,嵌入式中可改到 128KB~512KB:
c
运行
pthread_attr_t attr; pthread_attr_init(&attr); pthread_attr_setstacksize(&attr, 128*1024); // 128KB栈 pthread_create(&tid, &attr, thread_func, NULL); - 减少全局变量:用局部变量 + 堆内存(按需分配),避免全局变量占用静态内存;
- 复用内存缓冲区:固定大小的缓冲区复用,避免频繁 malloc/free;
- 避免内存泄漏:
- 进程:用 valgrind 工具检测(
valgrind --leak-check=full ./program); - 线程:确保 pthread_join/detach 回收线程,堆内存用完 free。
- 进程:用 valgrind 工具检测(
三、调试与排障技巧(嵌入式实战核心)
- 多进程调试:GDB 设置
detach-on-fork off,用inferior切换进程; - 多线程调试:GDB 命令
info threads查看线程,thread <编号>切换线程; - 死锁检测:用
pstack <PID>查看线程调用栈,定位死锁位置; - 性能分析:用
top -H查看线程 CPU 占用,ps -Lf <PID>查看线程状态; - 日志规范:所有任务的关键操作写入日志(时间 + 进程 / 线程 ID + 操作 + 结果),方便定位问题。
四、面试高频问题(珠三角 14k 岗位必答)
-
进程和线程的区别?(嵌入式视角)答:
- 资源:进程有独立内存空间,线程共享进程内存(仅栈独立);
- 开销:线程创建 / 切换开销远小于进程,嵌入式优先用线程;
- 崩溃:线程崩溃导致进程崩溃,进程崩溃不影响其他进程;
- 通信:线程直接读写共享变量,进程需 IPC(管道 / 消息队列)。
-
互斥锁和条件变量的区别?答:
- 互斥锁保证 “互斥访问”,同一时刻只有一个线程操作资源;
- 条件变量解决 “忙等”,让线程休眠直到条件满足,节省 CPU;
- 条件变量必须配合互斥锁使用,避免竞态。
-
嵌入式中如何避免死锁?答:
- 按固定顺序加锁;
- 减小锁的粒度,用完立即解锁;
- 使用超时锁(pthread_mutex_timedlock);
- 避免嵌套锁。
-
线程池的优势?嵌入式中如何实现?答:
- 优势:避免频繁创建销毁线程,降低 CPU / 内存开销,提高任务处理效率;
- 实现:提前创建固定数量的工作线程,用任务队列存储待处理任务,互斥锁保护队列,条件变量通知线程有任务。
-
守护进程的创建步骤?答:
- fork 创建子进程,父进程退出;
- setsid 创建新会话,脱离终端;
- 再次 fork,子进程退出,禁止打开终端;
- chdir 修改工作目录为 /;
- umask 设置为 0;
- 关闭所有文件描述符;
- 重定向标准输入 / 输出 / 错误到 /dev/null。
第三部分总结
- 线程同步:互斥锁解决多线程竞态,条件变量解决忙等问题,必须避免死锁;
- 线程池:嵌入式处理批量任务的最优方案,核心是复用线程、减少创建销毁开销;
- 最佳实践:
- 进程 / 线程选型按场景来(独立任务用进程,高频任务用线程);
- 内存优化重点调整线程栈、减少内存泄漏;
- 调试技巧掌握 GDB 多进程 / 线程调试、死锁检测。
#self :2026.3.20
目前已经完成进程->exec--deamon-gbd--线程-互斥-- 条件变量的总结!!
接下来继续更新:线程池-管道-共享内存-信号-消息队列的内容!


-----------------------------------------------------------------------------
更新于2026.3.21 19:52
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)