嵌入式Linux多进程编程


1. 什么是进程

进程是操作系统资源分配的基本单位,进程是程序执行的实例,由以下核心组件构成:

进程
代码段
数据段
文件描述符表
寄存器状态
安全上下文
信号处理表

Linux 内核中管理进程关键数据结构(进程控制块:PCB)

struct task_struct {
    pid_t pid;                // 进程ID
    struct mm_struct *mm;     // 内存管理结构
    struct files_struct *files; // 打开文件表
    struct signal_struct *signal; // 信号处理
    // ...
};

进程 vs 线程对比

特性 进程 线程
内存空间 独立 共享
创建开销 高(复制页表等) 低(仅需栈和寄存器)
通信方式 IPC(管道、共享内存等) 共享变量
容错性 高(隔离性强) 低(线程崩溃影响整个进程)

2. 进程的原理和使用

2.1 Linux进程管理架构
init/systemd
进程1
进程2
子进程
子进程
内核
调度器
运行队列
task_struct
task_struct
进程管理器
创建进程
终止进程
等待进程
2.2 进程创建方式详解
这里vfolk和clone不常用,场景太局限了,一笔掠过
2.2.1 手动启动或者系统创建

通过命令行直接运行程序或者使用系统的启动脚本自动启动程序,表象上一种创建方式,其实底层本质上还是通过fork、exec去创建进程。不算是一种创建方式。

# 1. 命令行直接运行
./program
# 或者指定完整路径
/usr/bin/program

# 2. 后台运行
nohup ./program 2<&1 > /dev/null &

# 3. 通过脚本启动
bash start_script.sh
2.2.2 fork() - 标准进程创建

特点

  • 完整复制父进程的地址空间(采用写时复制COW技术)
  • 子进程继承父进程的文件描述符、信号处理等资源
  • 子进程获得新的PID和独立的内存空间

使用场景

  • 需要与父进程共享环境的子进程
  • 并行任务处理
  • 最常用的进程创建方式

fork()函数原型

NAME
    fork - 创建一个子进程

SYNOPSIS
    #include <sys/types.h>
    #include <unistd.h>

    pid_t fork(void);

DESCRIPTION
    fork() 通过复制调用进程来创建一个新进程。新进程称为子进程。调用进程称为父进程。
RETURN VALUE
    成功返回pid号,其中-1表示失败,0表示是子进程。

fork demo

#include "stdio.h"
#include "stdlib.h"
#include "unistd.h"
#include "sys/wait.h"

int main() {
    pid_t pid = fork();
    if (pid < 0) {
        perror("fork");
        exit(1);
    }
    if (pid == 0) {
        printf("child process: pid = %d, ppid = %d\n", getpid(), getppid());
        sleep(5);
    } else {
        printf("parent process: pid = %d, ppid = %d\n", getpid(), getppid());
        //等待子进程结束,也可以用wait,waitpid可以指定等待的子进程,而wait是等待任意一个子进程结束
        waitpid(pid, NULL, 0);
        //wait(NULL);
    }
    return 0;
}
2.2.3 exec函数族 - 程序替换

特点

  • 完全替换当前进程的代码段
  • 保持进程ID不变
  • 有多个变体处理不同参数传递方式

使用场景

  • 执行外部程序
  • 改变当前进程映像
  • 配合fork()实现"fork-exec"模式

函数原型

execl("/path/to/prog", "prog", "arg1", NULL);  // 参数列表
execv("/path/to/prog", argv);                 // 参数数组
execle("/path/to/prog", "prog", NULL, envp);  // 自定义环境

exec demo

#include "stdio.h"
#include "stdlib.h"
#include "unistd.h"
#include "sys/wait.h"

int main() {
    pid_t pid = fork();
    if (pid < 0) {
        // 错误处理
        perror("fork failed");
        exit(1);
    } else if (pid == 0) {
        // 子进程
        execl("/bin/ls", "ls", "-l", NULL);
        // 如果exec执行成功,不会执行到这里
        // 如果执行到这里,说明exec失败
        perror("exec failed");
        exit(1);
    } else {
        // 父进程
        wait(NULL); // 等待子进程结束
    }
    return 0;
}
2.2.4 vfork() - 轻量级进程创建

特点

  • 子进程共享父进程地址空间
  • 子进程优先运行,父进程阻塞
  • 子进程必须立即调用exec()_exit()

使用场景

  • 内存受限系统
  • 需要立即执行新程序的场景

vfork demo

#include "stdio.h"
#include "stdlib.h"
#include "unistd.h"
#include "sys/wait.h"

int main() {
    pid_t pid = vfork();
    if (pid == 0) {
        execl("/bin/ls", "ls", "-l", NULL);
        _exit(127); // 仅exec失败时执行
    } else if (pid > 0) {
        int status;
        waitpid(pid, &status, 0);
    }
    return 0;
}
2.2.5 clone - 灵活的进程/线程创建

特点

  • 可以精确控制与父进程共享的资源
  • 可以创建进程或线程
  • 通过flags参数控制共享特性

使用场景

  • 需要细粒度控制资源共享
  • 实现特殊的线程模型
  • 容器技术(如Docker使用clone实现namespace隔离)
2.2.6 总结

这些进程创建方式各有特点,选择时需要根据具体场景考虑内存使用、性能需求和功能需求。一般情况下,fork() 是最安全和最常用的选择。

  • 一般场景: 使用 fork() - 最通用、最安全的选择
  • 执行新程序: 使用 fork() + exec() 组合
  • 特殊需求: 需要细粒度控制资源共享时使用 clone()
  • 避免的做法: 不要在 vfork() 子进程中执行复杂操作

3 进程分离和守护进程(Daemon)

进程分离是指一个进程能够脱离其父进程,即使父进程终止后仍能继续运行。当子进程与其父进程分离时,它会成为一个后台进程或守护进程,能够独立继续运行而不受父进程生命周期的影响。

3.1 进程分离
#include "stdio.h"
#include "stdlib.h"
#include "unistd.h"
#include "sys/wait.h"

int main() {
    pid_t pid = fork();
    if (pid < 0) {
        perror("fork");
        exit(1);
    }
    if (pid == 0) {
        setsid();//创建一个新的会话和进程组,子进程分离
        sleep(10);
        printf("child process: pid = %d, ppid = %d\n", getpid(), getppid());
    } else {
        printf("parent process: pid = %d, ppid = %d\n", getpid(), getppid());
    }
    return 0;
}
3.2 守护进程

守护进程是在后台运行的一种特殊进程,与终端或用户会话无关。它们通常在系统启动时启动,并持续运行直到系统关闭。规范的守护进程创建过程中需要进行两次 fork,这是防止守护进程受到终端相关的信号影响,这种方式虽然看起来有点复杂,但是能够最大程度地确保守护进程的独立性和稳定性。

#include "stdio.h"
#include "stdlib.h"
#include "unistd.h"
#include "sys/stat.h"
#include "sys/wait.h"
#include "fcntl.h"

int main() {
    // 第一次 fork
    pid_t pid = fork();
    if (pid < 0) {
        exit(1);
    }
    if (pid > 0) {
        // 父进程退出
        exit(0);
    }
    
    // 创建新会话
    if (setsid() < 0) {
        exit(1);
    }
    // 第二次 fork
    pid = fork();
    if (pid < 0) {
        exit(1);
    }
    if (pid > 0) {
        // 第一子进程退出
        exit(0);
    }

    //第二次 fork 后不需要调用 setsid
    chdir("/");
    umask(0);

    // 关闭0、1、2文件描述符
    close(0);
    close(1);
    close(2);

    // 将标准输入、输出和错误重定向到 /dev/null
    open("/dev/null", O_RDWR);
    dup(0);
    dup(0);
    printf("child process: pid = %d, ppid = %d\n", getpid(), getppid());
    // 在此处添加你的守护进程任务代码
    return 0;
}

4 面试常见问题

4.1 进程与线程的区别?
  • 进程是资源分配的最小单位,线程是CPU调度的最小单位;
  • 进程有自己的独立地址空间,线程是共享进程中的数据资源;
  • 线程不能独立运行,需要依赖于进程;
  • 进程执行和切换的开销大,线程执行开销小。
4.2. 什么是僵尸进程?如何避免僵尸进程?

子进程死亡时父进程没有回收,这样就造成子进程资源无法回收,通常用 ps 可以看到它被显示为defunct,这样就产生了僵尸进程。它将永远保持这样直到父进程回收。

  • 父进程主动回收子进程(wait/waitpid);
  • 父进程注册SIGCHLD信号,在回调中进程回收;
  • 父进程忽略SIGCHLD、SIGCLD信号,子进程结束后由内核回收;
  • fork两次,父进程fork一个子进程,然后继续工作,子进程fork一个孙进程后退出,那么孙进程被init接管,孙进程结束后,init会回收。
// 方法1: 父进程调用wait/waitpid
while (waitpid(-1, NULL, WNOHANG) > 0);

//方法2: 父进程注册SIGCHLD信号,在回调中进程回收;
sigaction(SIGCHLD, &sigaction_fd, NULL)

// 方法3: 忽略SIGCHLD信号
signal(SIGCHLD, SIG_IGN);

// 方法4: 双fork技术
if (fork() == 0) {
    if (fork() == 0) {
        // 实际工作进程
    }
    exit(0); // 中间进程立即退出
}
wait(NULL); // 父进程回收中间进程
4.3. 进程间通信有哪些?如何选择?
场景 推荐IPC 原因
大数据传输 共享内存 零拷贝,速度最快
结构化消息 消息队列 自带消息类型和优先级
简单数据流 管道/命名管道 使用简单
跨主机通信 Socket(套接字) 网络透明
异步事件通知 信号 轻量级
4.4. 写时复制(COW:copy-on-write)如何工作?

COW是一种内存管理技术,将复制操作推迟到第一次写入时进行:在创建一个新副本时,不会立即复制资源,而是共享原始副本的资源;当修改时再执行复制操作。该技术最初产生于Unix系统,用于实现一种傻瓜式的进程创建,最早的fork会将调用进程的所有内容原封不动的拷贝到新产生的子进程中去,这些拷贝的动作很消耗时间,而如果fork完之后我们马上就调用exec,这些辛辛苦苦拷贝来的东西又会被立刻抹掉,所以为了效率,现在的fork结束后并不立刻复制父进程的内容,而是到了真正实用的时候才复制,这样如果下一条语句是exec,它就不会白白作无用功了,也就提高了效率。这是实现原理。掌握这种理念,可以应用在自己的程序设计开发中。

只读访问
写访问
父进程内存页
子进程
触发页错误
内核复制新页
子进程写入新页
4.5. 如何实现进程热升级?

使用fork配合execl族函数实现热升级。

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐