
简介C 编写的守护进程保护程序面向需要在 Windows 平台上持续运行 C# 客户端、防止进程被手动终止或意外退出的开发者。实现上以 C DLL 为核心通过 Hook 技术监控系统尝试结束进程的操作并在后台守护目标 C# 程序标签中的 ProtectProcess 即对应这套保护机制。压缩包包含完整的 C DLL 工程、C# 调用测试工程及编译产物项目在 Win7 系统实测通过适合想学习 C 与 C# 互操作、Windows 钩子编程或进程保护的开发者参考。资源共 173 个文件压缩包大小 24.32MB主要文件类型包括 C 源文件、头文件、Visual Studio 工程文件以及编译生成的 DLL、EXE 和 PDB/OBJ 中间文件结构与常规解决方案一致便于直接打开调试。已有 682 人学习下载。通过这份完整项目可以了解 SetWindowsHookEx 等 API 的调用方式、DLL 注入的常见思路、C# 端 P/Invoke 加载与调用流程也能在自有程序中复用进程守护与防终止模块的基础框架。 上一周我接手了一个老服务它最大的毛病是跑着跑着就没了没人知道它什么时候没的。于是我用 C 写了一个守护进程程序专门负责保护这个业务进程——业务进程退出就自动拉起卡死就重启日志全记录下来。今天我把这套守护进程的设计思路和核心代码梳理出来给同样被进程不稳定困扰的朋友做个参考。如果你在 Linux 下用 C 写后台服务或者正在纠结要不要自己造一个进程守护轮子这篇内容应该能帮你少踩几个坑。1. 需求厘清你的“保护进程”到底要保护什么写守护进程之前我先把“保护”这个词拆成了非常具体的指标。如果连“什么样算异常”都定义不清楚代码写出来就是一团浆糊。1.1 把“保护”翻译成可检测的技术指标实际落到程序上“保护进程”这件事就三块检测、恢复、记录。检测就是判断业务进程当前处于什么状态恢复就是一旦发现状态不对按预定策略把它重新拉起来记录就是把每次事件写进日志方便事后排查。我先明确一个边界本文说的“保护”指的是服务可靠性的保活不是对抗式安全防护。业务进程是被我们自己拉起、由我们负责维护的子进程守护进程只处理“它没了”“它卡住了”“它反复退出”这三类问题。举两个最常见的场景。第一个业务进程被 kill 掉或者自己段错误崩溃了——进程不存在了守护进程轮询发现后立即重新启动。第二个业务进程陷入死锁或者长时间阻塞在 IO 上——进程还在但已经不能提供服务了这时候单纯检查“进程还活着”就没意义得靠心跳机制来识别“假死”。我最终把需求收敛成四条业务进程异常退出后5 秒内自动拉起业务进程心跳超时假死后自动重启连续重启超过 N 次进入退避状态防止重启风暴所有检测和拉起动作都写日志保留可追溯记录1.2 为什么现成工具没让我直接省事说到进程守护很多人第一反应是 systemd 或者 supervisor。我也试过先把它们的边界说清楚方案优点局限systemd Restartalways系统原生配置简单稳定只看进程是否退出不会识别“假死”健康检查要额外写脚本supervisor配置清晰Web 管理界面友好依赖 Python 环境嵌入式或精简系统上不够灵活自研 C 守护进程完全可控无外部依赖能跟业务代码深度集成自己要处理 daemonize、信号、日志等一堆边界情况如果只是普通 Web 服务或者简单的后台任务我强烈建议直接用 systemd别折腾。但这次我要守护的进程是一个 Agent它需要跟远端控制端交互重启规则跟心跳状态和业务握手状态强相关——systemd 的 Restartalways 表达不了这种规则。我的结论是当你的重启策略开始需要“如果心跳超时就先杀掉再拉起连续三次失败就等五分钟”这种自定义逻辑时自研守护进程的边际收益就上来了。既然团队核心栈是 C用 C 写完全合理。2. 核心方案拆解存活检查与心跳超时设计这一章是整个守护进程最核心的部分。存活性检查和心跳超时我建议分开设计因为它们对应两种完全不同的故障进程消失和进程假死。2.1 最直接的存活检查kill(pid, 0) 与 PID 复用问题检查一个进程是否存活Linux 下有个很轻量的办法调用kill(pid, 0)。它不会真的发送信号只是让内核去查找这个 pid 对应的进程描述符。找到返回 0找不到返回 -1 并且errno变成ESRCH。#include sys/types.h #include signal.h #include cerrno bool isProcessAlive(pid_t pid) { if (pid 0) return false; if (::kill(pid, 0) 0) return true; return (errno EPERM); // 进程存在只是当前用户权限不够 }这个函数很简单但有一个隐藏很深的坑PID 复用。进程退出后它的 PID 不会马上被回收但如果系统短时间内创建大量进程新的进程可能拿到一个已经被使用过的 PID。假如守护进程记录的是旧 pid检查的时候发现“这个 pid 还存在”于是误判业务进程还活着。解决办法是比对进程启动时间。在 Linux 下读/proc/pid/stat里面的第 22 个字段是进程启动时间单位是系统 tick。启动业务进程的时候记录一个base_start_time每次检查时重新读一次当前 pid 的 starttime两者不一致就说明这个 PID 已经不是原来的进程了。unsigned long getProcessStartTime(pid_t pid) { std::string path /proc/ std::to_string(pid) /stat; std::ifstream in(path); std::string content; std::getline(in, content); auto rp content.rfind()); if (rp std::string::npos) return 0; std::stringstream ss(content.substr(rp 1)); char state; unsigned long starttime 0; // 跳过 state、ppid、pgrp、session、tty_nr、tpgid、flags、minflt... ss state; for (int i 0; i 18; i) { unsigned long v; ss v; } ss starttime; return starttime; }实际写的时候我建议把这段逻辑封装成checkProcessIdentity(pid, expectedStartTime)跟存活检查一起用别只靠kill(pid, 0)。2.2 防“假死”心跳文件与超时阈值怎么定进程还在但不干活了这种情况kill(pid, 0)是检查不出来的。我用的是心跳文件方案业务进程定期更新一个文件的时间戳守护进程检查这个文件的修改时间。业务侧的心跳更新逻辑我的第一版用了一个独立线程std::thread heartbeatThread([]{ while (true) { std::ofstream hb(/var/run/biz_heartbeat, std::ios::trunc); hb std::time(nullptr); hb.close(); std::this_thread::sleep_for(std::chrono::seconds(10)); } });后来发现如果业务进程只有一个主循环完全没必要为心跳单独开线程直接在主循环里加一行更新时间戳就行。开线程反而增加死锁风险还多消耗一个线程栈。守护侧检查逻辑struct stat st; if (stat(HEARTBEAT_FILE, st) 0) { time_t lastUpdate st.st_mtime; if (time(nullptr) - lastUpdate TIMEOUT_SECONDS) { // 心跳超时判定假死先杀掉再让主循环拉起来 kill(childPid, SIGKILL); } }关于超时阈值我推荐一个经验值心跳间隔的 3 到 5 倍。比如业务每 10 秒更新一次心跳守护进程超过 30 秒没看到更新就判定假死。阈值设太小业务进程一次 GC 停顿或者 IO 抖动就会误杀设太大假死后要等很久才会被拉起失去保活的意义。2.3 子进程退出通知SIGCHLD 与 waitpid 的配合守护进程是业务进程的父进程子进程退出时内核会给父进程发 SIGCHLD 信号。如果父进程不调用waitpid去回收子进程就会变成僵尸进程。这个问题在 C 八股文里经常被问但在实际写守护进程时是真的会踩到业务进程每崩一次如果守护进程不收尸系统里就多一个僵尸攒多了甚至会拖垮进程表。我的主循环用的是非阻塞waitpid而不是在 SIGCHLD handler 里处理。原因很简单handler 里不能安全地做太重的事而且我主循环本来就要定时检查心跳顺带收尸最合适。while (running) { int status 0; pid_t ret waitpid(childPid, status, WNOHANG); if (ret childPid) { handleChildExit(status); // 记录退出码、信号、时间 childPid -1; } if (childPid 0 || !isProcessAlive(childPid)) { childPid startChild(); } sleep(CHECK_INTERVAL); }为什么用 WNOHANG 而不是阻塞式 waitpid因为守护进程除了等子进程退出还要定期检查心跳、处理日志、处理信号阻塞等待会卡死整个主循环。3. 代码实战从零搭建一个C守护进程理论部分讲完这里直接上一个可用的骨架。我按“daemonize → 主监控循环 → 日志与配置”三个步骤拆开讲。3.1 daemonize把干活的自己扔到后台守护进程的标准启动步骤基本是固定的我把它封装成了一个函数void daemonize() { pid_t pid fork(); if (pid 0) exit(EXIT_FAILURE); if (pid 0) exit(EXIT_SUCCESS); // 父进程退出 setsid(); // 创建新会话脱离控制终端 pid fork(); if (pid 0) exit(EXIT_FAILURE); if (pid 0) exit(EXIT_SUCCESS); // 第二次 fork防止重新获得控制终端 umask(0); // 清空文件权限掩码 chdir(/); // 防止占用某个挂载点 freopen(/dev/null, r, stdin); freopen(/dev/null, w, stdout); freopen(/dev/null, w, stderr); }简单解释一下每一步的意图。第一次 fork 后父进程退出是为了让子进程不是会话组长为后面setsid做准备。setsid之后这个进程就脱离了原来的终端和会话终端关了、用户登出了都不影响它。第二次 fork 是为了确保它不是会话组长避免它将来打开某个终端设备时自动获得控制终端。chdir(/)的作用是避免守护进程把某个目录挂载点占住导致后面想卸载那个磁盘都卸不掉。这里有一个容易忽略的点freopen到/dev/null等于把守护进程的控制台输出全部丢弃了。如果你后续不想丢日志得在这之前把标准输出、标准错误重定向到日志文件而不是/dev/null。3.2 主监控循环检查、拉起、退避一次做完主循环的逻辑其实很朴素定时检查发现不对就拉起。问题是直接拉可能造成“重启风暴”——业务进程每次启动都立刻崩溃守护进程又立刻拉起来CPU 和磁盘 IO 全被刷爆。要给重启行为加退避限制void monitorLoop() { int failCount 0; while (running) { // 心跳假死检查 checkHeartbeat(); int status 0; pid_t ret waitpid(childPid, status, WNOHANG); if (ret childPid) { handleChildExit(status); childPid -1; failCount; } if (childPid 0 || !isProcessAlive(childPid)) { if (failCount MAX_RESTART) { log(too many failures, backoff...); sleep(RETRY_BACKOFF); // 连续失败后多等一会儿 continue; } childPid startChild(); if (childPid 0) { log(restart child, pid std::to_string(childPid)); } } else { failCount 0; // 进程正常重置失败次数 } sleep(CHECK_INTERVAL); } }启动子进程的函数我强烈建议用fork execl不要用system()。system()相当于在中间包了一层 shellShell 会做路径查找和环境变量展开业务路径稍微带点特殊字符就出问题而且system()返回的是 shell 的退出状态不是业务进程本身的拿不到准确的信号退出信息。pid_t startChild() { pid_t pid fork(); if (pid 0) { // 子进程 chdir(bizWorkDir); // 切到业务工作目录 execl(bizPath.c_str(), bizPath.c_str(), nullptr); _exit(127); // execl 失败才到这里 } return pid; }注意这里_exit(127)的写法。如果execl失败子进程直接退出不要继续往下跑守护进程的逻辑否则会出现两个守护进程实例互相抢业务的盛况。3.3 日志与配置让守护进程可维护守护进程的日志是排查问题的重要依据。我的日志函数非常简单就两行void log(const std::string msg) { std::ofstream out(/var/log/guardian.log, std::ios::app); out [ std::time(nullptr) ] msg std::endl; }配置我建议用命令行参数或者简单的配置文件加载不要写死在代码里。我用的是getopt_long解析命令行参数把业务路径、心跳超时、检查间隔、最大重启次数都作为可配置项暴露出去。这样换一台机器部署不需要重新编译。配置文件长这样解析起来也简单biz_path/usr/local/bin/biz_service biz_workdir/usr/local/bin heartbeat_file/var/run/biz_heartbeat heartbeat_timeout30 check_interval5 max_restart5我的个人体会是守护进程的配置项不要太多核心就“检查什么”“多久查一次”“拉不起来怎么办”这几件事。配置越多越容易出错而且守护进程本身出问题比业务进程出问题更让人崩溃。4. 常见问题与排查技巧实录实际跑守护进程的过程中我踩过几个坑整理成问题排查清单比看任何文档都管用。4.1 子进程是正常退出还是被信号干掉的我们在waitpid拿到状态后不要只看“进程结束了”就完事要区分正常退出和被信号杀掉这对排查故障很重要void handleChildExit(int status) { if (WIFEXITED(status)) { int code WEXITSTATUS(status); log(child exited normally code std::to_string(code)); if (code 0) { // 主动退出可能业务逻辑要求退出按需处理 } } else if (WIFSIGNALED(status)) { int sig WTERMSIG(status); log(child killed by signal std::to_string(sig)); if (sig SIGKILL || sig SIGSEGV) { // 记录一次异常退出加重启计数 } } }这里也顺带说下僵尸进程子进程退出后内核会保留一个“残骸”等待父进程读取退出状态。如果父进程一直不调waitpid残骸就一直留在进程表里。守护进程如果不处理 SIGCHLD业务进程每崩一次就多一个僵尸最终后果就是fork()失败谁都起不了新进程。4.2 chdir 之后业务进程的工作目录怎么救这个坑是我真实踩过的。守护进程chdir(/)之后fork 出来的子进程会继承根目录作为工作目录。业务代码里如果用了相对路径比如./config.ini或者./logs启动后会发现文件全部找不到。解决办法有两个一个是在execl之前chdir到业务工作目录就是我代码里写的chdir(bizWorkDir)另一个是业务代码全部改成绝对路径。我建议两个都做——守护进程侧chdir保证默认路径正确业务代码关键路径用绝对路径防止意外。另外如果业务进程需要特定环境变量fork 之前可以用setenv设置别指望继承的/etc/profile之类的配置因为守护进程启动时不一定走登录会话。4.3 守护进程和 systemd 打架了怎么办我自己写守护进程跑了一段时间后决定还是把它托管给 systemd这样开机自启、崩溃自拉都有保障。这里有个坑如果守护进程代码里已经做了 daemonizefork 后父进程退出systemd 的配置就得用Typeforking并且要指定PIDFile让 systemd 知道真正的守护进程 PID 是多少。如果让 systemd 直接用Typesimple托管那守护进程就不要自己 daemonize直接写成一个前台常驻进程由 systemd 负责后台化。我个人的建议是既然已经到了 systemd 这一层就让它直接管前台进程省去自己的 daemonize 逻辑反而更简单更稳。有一个细节必须注意如果你的守护进程自己 daemonize但 systemd 配置写成了Typesimplesystemd 会把第一个 fork 出来的父进程当成主进程等它一退出systemd 就会认为服务退出了然后疯狂重启。这种情况我修过一次症状就是系统日志里全是“start request repeated too quickly”。4.4 日志无限增长磁盘被写满守护进程的日志如果只增不减跑几个月就能吃掉几个 G 的磁盘。我第一版没做任何截断业务高频率崩溃那阵子日志文件直接膨胀到 2G 以上把服务所在分区占满了整个系统都不干活了。后来加了个简单的切割逻辑void safeLog(const std::string msg) { namespace fs std::filesystem; if (fs::exists(LOG_FILE) fs::file_size(LOG_FILE) 10 * 1024 * 1024) { fs::rename(LOG_FILE, LOG_FILE_OLD); } std::ofstream out(LOG_FILE, std::ios::app); out [ std::time(nullptr) ] msg std::endl; }这个实现粗糙但是有效。更完善的方案是直接用系统的 logrotate按天轮转保留最近 7 个文件。守护进程这边只需要做到“写日志不中断、文件可轮换”就够了。最后还有个小技巧别在守护进程里写太多业务逻辑它越简单越稳。我第一版往守护进程里加了告警、统计、动态配置结果守护进程本身经常出问题后来砍到只剩“检查-拉起-记录”三件事再也没崩过。如果你也要写这类程序先做一个朴素版本跑起来再逐步加东西。本文还有配套的精品资源点击获取