嵌入式后台开发:守护进程制作与程序开机自启
一、引言
前面九篇文章,我们的示例代码都是前台运行的程序——启动后在终端里打印日志,Ctrl+C 退出。但在嵌入式设备上,传感器采集服务、网络通信服务、MQTT 消息处理服务都不是这么玩的。
这些服务需要脱离终端独立运行、开机自动启动、崩溃后自动重启、可以优雅停止。这就是**守护进程(Daemon)**的用武之地。
本文先拆解守护进程的原理和创建步骤,再介绍三种主流的开机自启配置方式,帮你把前面的劳动成果——不管是文件 IO、网络服务器还是 MQTT 客户端——都做成真正的"企业级后台服务"。
二、守护进程是什么?
守护进程(Daemon)是在后台运行、不受终端控制的长期服务进程。它的名字来源于古希腊神话中的"守护精灵"。
和普通进程的核心区别:
| 特性 | 普通进程 | 守护进程 |
|---|---|---|
| 终端关联 | 关联,终端关闭=进程退出 | 脱离,终端关闭不影响 |
| 运行位置 | 前台或后台 | 纯后台 |
| 输出目标 | 标准输出/标准错误 | 日志文件或 syslog |
| 父进程 | shell 或用户手动启动 | init/systemd(PID=1) |
| 工作目录 | 启动时的目录 | 通常是根目录/ |
一句话:守护进程就是"不需要人来盯着的后台服务"——拿嵌入式传感器采集来说,你不可能每次断电重启后手动 SSH 上开发板启动程序。
三、制作守护进程的五步标准流程
Richard Stevens 在《Unix 环境高级编程》中定义了守护进程创建的经典步骤,被业界奉为"守护进程圣经"。
步骤一:fork 并退出父进程
pid_tpid=fork();if(pid>0){exit(0);// 父进程退出}// 子进程继续目的:让 shell 认为程序已经结束,释放终端。
步骤二:创建新会话(setsid)
if(setsid()<0){exit(1);}sessid()让调用进程成为新会话和新进程组的首领,彻底切断与旧终端的一切关联。
步骤三:再次 fork(可选但推荐)
pid=fork();if(pid>0)exit(0);第二次 fork 后的孙子进程不再是会话首领,永远不会意外获得控制终端。
步骤四:改变工作目录到根目录
chdir("/");如果不改,进程启动时的目录(可能是挂载点)将无法卸载。
步骤五:关闭不需要的文件描述符,重定向标准 IO
close(STDIN_FILENO);close(STDOUT_FILENO);close(STDERR_FILENO);// 重定向到 /dev/nullintfd=open("/dev/null",O_RDWR);dup2(fd,STDIN_FILENO);dup2(fd,STDOUT_FILENO);dup2(fd,STDERR_FILENO);if(fd>2)close(fd);重要:守护进程不应该往 stdout/stderr 输出——因为没有终端能接收了。日志要通过 syslog 或文件记录。
完整创建函数
#include<stdio.h>#include<stdlib.h>#include<unistd.h>#include<fcntl.h>#include<sys/stat.h>voiddaemonize(void){pid_tpid;// Step 1: fork, 父进程退出pid=fork();if(pid<0){perror("fork");exit(1);}if(pid>0){exit(0);// 父进程退出,子进程变孤儿}// Step 2: 创建新会话if(setsid()<0){perror("setsid");exit(1);}// Step 3: 再次 fork,孙进程不再是会话首领pid=fork();if(pid<0){perror("fork#2");exit(1);}if(pid>0){exit(0);// 第二次父进程退出}// Step 4: 切换到根目录chdir("/");// Step 5: 重设文件掩码umask(0);// Step 6: 关闭所有文件描述符intmax_fd=sysconf(_SC_OPEN_MAX);for(intfd=0;fd<max_fd;fd++){close(fd);}// Step 7: 重定向标准 IO 到 /dev/nullintdev_null=open("/dev/null",O_RDWR);if(dev_null>=0){dup2(dev_null,STDIN_FILENO);dup2(dev_null,STDOUT_FILENO);dup2(dev_null,STDERR_FILENO);if(dev_null>2)close(dev_null);}// Step 8: 记录 PID 文件(便于后续管理)FILE*fp=fopen("/var/run/mydaemon.pid","w");if(fp){fprintf(fp,"%d\n",getpid());fclose(fp);}}使用示例:把网络服务器做成守护进程
#include<signal.h>staticvolatileintg_running=1;staticvoidsig_handler(intsigno){if(signo==SIGTERM||signo==SIGINT){g_running=0;// 优雅退出}}intmain(intargc,char*argv[]){// 安装信号处理器signal(SIGTERM,sig_handler);signal(SIGINT,sig_handler);// 变成守护进程if(argc<2||strcmp(argv[1],"-f")!=0){daemonize();}// 打开日志文件(守护进程后 stdout 已经没用)FILE*log_fp=fopen("/var/log/mydaemon.log","a");fprintf(log_fp,"[启动] PID=%d\n",getpid());fflush(log_fp);// 启动 TCP 服务 (省略了具体的 epoll 实现)intserver_fd=tcp_server_init(8888);fprintf(log_fp,"[就绪] 端口 8888\n");fflush(log_fp);// 主循环while(g_running){event_loop_iteration(server_fd);}// 清理fprintf(log_fp,"[退出] 服务正常关闭\n");fflush(log_fp);close(server_fd);fclose(log_fp);unlink("/var/run/mydaemon.pid");return0;}-f参数(foreground)是守护进程开发的标配——开发调试时用-f跑在前台看日志,发布后去掉-f跑在后台。
四、PID 文件机制
PID 文件是守护进程的"身份证",记录了当前运行的进程 ID,用于管理脚本的启停控制:
#!/bin/sh# /etc/init.d/mydaemon 启动/停止脚本PID_FILE="/var/run/mydaemon.pid"APP="/usr/local/bin/mydaemon"start(){echo"Starting mydaemon..."$APPsleep1if[-f"$PID_FILE"];thenecho"OK, PID=$(cat$PID_FILE)"elseecho"Failed"fi}stop(){if[-f"$PID_FILE"];thenPID=$(cat"$PID_FILE")echo"Stopping mydaemon (PID=$PID)..."kill$PIDrm-f"$PID_FILE"elseecho"Not running"fi}case"$1"instart)start;;stop)stop;;restart)stop;start;;*)echo"Usage:$0{start|stop|restart}";;esac五、开机自启的三种方式
方式一:/etc/rc.local(最简单)
在/etc/rc.local的exit 0之前添加:
/usr/local/bin/mydaemon&优点:零学习成本,改完即生效
缺点:不支持崩溃重启,没有进程监控
方式二:SysV init 脚本(兼容性最好)
将启动脚本放到/etc/init.d/,再创建软链接:
sudocpmydaemon_init.sh /etc/init.d/mydaemonsudochmod+x /etc/init.d/mydaemonsudoupdate-rc.d mydaemon defaults9901参数含义:
defaults:使用默认的启动/停止顺序99:启动优先级(数字越大越晚启动)01:停止优先级(数字越小越早停止)
优点:兼容所有老式嵌入式 Linux 系统
缺点:配置较繁琐
方式三:systemd 服务文件(现代标配)
创建/etc/systemd/system/mydaemon.service:
[Unit] Description=嵌入式传感器服务 After=network.target [Service] Type=forking PIDFile=/var/run/mydaemon.pid ExecStart=/usr/local/bin/mydaemon ExecStop=/bin/kill -TERM $MAINPID Restart=always RestartSec=5 StandardOutput=file:/var/log/mydaemon.log StandardError=file:/var/log/mydaemon_error.log [Install] WantedBy=multi-user.target启用并启动:
sudosystemctl daemon-reloadsudosystemctlenablemydaemon# 开机自启sudosystemctl start mydaemon# 立即启动sudosystemctl status mydaemon# 查看状态sudosystemctl stop mydaemon# 停止systemd 关键字段解析:
| 字段 | 含义 |
|---|---|
| Type=forking | 程序自己 fork 成守护进程后才返回 |
| PIDFile | systemd 通过这个文件跟踪进程 |
| Restart=always | 不管什么原因退出都自动重启 |
| RestartSec=5 | 重启前等待 5 秒(防止 crash loop) |
| WantedBy=multi-user.target | 在多用户模式(正常开机)时启动 |
三种方式选型指南
| 场景 | 推荐方式 |
|---|---|
| 开发调试 | -f前台模式 |
| 老系统(BusyBox init) | /etc/rc.local |
| Debian/Ubuntu 旧版 | SysV init 脚本 |
| 嵌入式 Linux(systemd) | systemd service |
| 需要崩溃自动重启 | systemd(Restart=always) |
六、常见问题排查
| 现象 | 原因 | 排查方法 |
|---|---|---|
| 启动后立即退出 | fork失败或setsid失败 | 前台模式(-f)调试 |
| 日志文件为空 | 未刷新缓冲区 | 每次写日志后fflush或fsync |
| systemd 显示 activating | 程序没有返回(没有 fork 完) | 检查Type=forking是否匹配 |
| 重启循环(crash loop) | 程序启动后就崩溃 | RestartSec调大,查看错误日志 |
| 两个实例同时运行 | PID 文件残留 | 启动前检查 PID 文件并确认进程是否存在 |
七、守护进程调试技巧
调试守护进程最大的痛点是"看不到 stdout"。解决方法:
// 开发阶段:不 daemonize,直接用 printfif(getenv("FOREGROUND")==NULL){daemonize();}// 运行时$ FOREGROUND=1./mydaemon// 前台调试$./mydaemon// 后台运行或者用 syslog:
#include<syslog.h>openlog("mydaemon",LOG_PID,LOG_DAEMON);syslog(LOG_INFO,"传感器服务启动");syslog(LOG_ERR,"传感器初始化失败: %s",strerror(errno));然后用tail -f /var/log/syslog或journalctl -fu mydaemon查看。
八、总结
守护进程制作的核心步骤一个都不能少:
- fork + exit 父进程— 让 shell 以为程序结束
- setsid— 成为新会话首领,脱离终端
- 二次 fork— 彻底杜绝意外获得控制终端
- **chdir(“/”) ** — 释放工作目录
- 关闭 fd + 重定向到 /dev/null— 防止后台输出干扰
- PID 文件— 管理脚本的"身份证"
- signal 处理器— 优雅停止,不丢数据
开机自启选择:老系统用rc.local,新系统用systemd service,需要崩溃重启能力果断上 systemd。
把前面九篇文章的任何一段代码——不管是文件 IO、串口采集还是 epoll 服务器——套进daemonize()函数,它就成了一名合格的"嵌入式后台打工人"。