简介面向Windows平台进程保护需求的开发者这套基于C的守护进程实现核心目标是保护指定的C#程序不被异常终止。资源以C编写的DLL为主利用Hook技术拦截系统事件并通过进程监控在目标程序被关闭时及时介入兼顾C#与C跨语言调用实现。压缩包共173个文件大小24.32MB涵盖.cpp/.h源码、DLL/EXE编译产物、PDB调试符号、lib/exp链接库及工程配置文件sln/vcxproj等目录内还包含QtHookSrv、dllmain等关键实现和C#侧调用项目便于直接编译、调试与二次开发。已有683人学习下载适合具备一定C和Windows API基础、希望理解进程保护与Hook机制的开发者。资源价值在于提供了一整套可运行的守护进程样例既能学习SetWindowsHookEx等API的典型用法也能参考C#调用C DLL的P/Invoke流程同时Win7实测有效的兼容性说明降低了踩坑成本。1. 守护进程程序C编写保护进程从误杀事故讲起守护进程程序C编写保护进程这个标题听起来像系统编程课的作业但它背后是一个非常现实的需求服务器上的业务进程可能因为内存压力被 OOM killer 选中可能因为别人的脚本写错参数被误杀也可能自己崩了没人发现。等人工发现问题服务往往已经中断了好几分钟。我要做的不是监控告警而是一个能把进程自己拉回来的守护程序——崩溃自动重启、假死能够识别、自身尽量不被误杀并且把每一次退出原因记录清楚。这份笔记写给正在用 C 给服务做部署兜底的同学覆盖守护进程的系统调用组合、waitpid 与心跳两种监控机制怎么配合以及那几个让守护进程自己翻车的坑在哪里。2. 守护进程的底层选型waitpid 为什么比轮询 /proc 更靠谱写守护进程之前我先把两件事想明白后台化的系统调用怎么排监控子进程用事件驱动还是轮询。这两件事决定了后面整个程序的骨架。我见过不少实现把这两步混在一起最后的表现是进程确实在后台跑但业务进程一崩守护进程毫无反应或者拉起了一个根本不是自己要管的东西。2.1 守护进程三件套fork、setsid、重定向的先后顺序常见做法是fork 一次父进程_exit(0)子进程调用setsid()脱离会话随后chdir(/)、umask(0)最后把标准输入输出重定向到/dev/null或日志文件。顺序不能乱setsid()必须在 fork 之后调用因为它要求调用者不是进程组组长。#include fcntl.h #include sys/stat.h #include unistd.h // log_fd 是已经打开的日志文件 fd没有则传 -1 int daemonize(int log_fd) { pid_t pid fork(); // 第一次 fork if (pid 0) return -1; if (pid 0) _exit(0); // 父进程退出子进程被 init 收养 if (setsid() 0) return -1; // 新会话脱离控制终端 if (chdir(/) 0) return -1; // 不占用某个挂载点 umask(0); // 文件权限交给后续 open 显式控制 int devnull open(/dev/null, O_RDWR); if (devnull 0) return -1; dup2(devnull, STDIN_FILENO); // 标准输入不再关联终端 dup2(devnull, STDOUT_FILENO); // 标准输出不写终端 if (log_fd 0) dup2(log_fd, STDERR_FILENO); // stderr 落到日志 return 0; }几个关键点fork 后父进程要尽快退出让子进程脱离原来的进程组和会话chdir(/)是为了避免守护进程占用某个业务目录导致后续卸载或清理时出现device busyumask(0)之后pidfile、日志文件的权限完全由 open 的 mode 参数决定不会被外层继承的 umask 悄悄改掉。这里我只 fork 一次是有意为之后面专门有一节讲为什么单 fork 对“保护进程”场景更合适。2.2 两种监控模型轮询 /proc 与 waitpid 事件驱动监控业务进程有没有死最常见的有两种路子。第一种是定期kill(pid, 0)检查 errno 是否变成 ESRCH或者直接看/proc/pid目录是否还存在。这种轮询实现简单但有两个问题轮询周期决定了发现延迟更麻烦的是 PID 复用——进程退出后同一个 pid 可能被完全不相关的进程复用轮询会读到“还活着”的假象。第二种是让守护进程 fork 出业务进程然后阻塞在waitpid上。业务进程退出时内核立刻通知守护进程它不只告诉你“进程没了”还能告诉你退出码是多少、是正常退出还是被哪个信号杀死。waitpid只认自己的子进程天然避开了 PID 复用问题这是它比轮询/proc更可靠的关键。监控方式发现退出发现假死主要风险建议kill(pid,0)定期探测慢一个周期无法发现PID 复用误判只能做辅助告警/proc/pid存在性检查慢一个周期无法发现需解析 stat 字段辅助waitpid事件驱动即时无法发现必须保持父子关系主监控心跳文件/时间戳能容忍延迟能发现阈值设置不当会误杀搭配主监控/proc轮询也不是没用。当业务进程陷入不可中断睡眠D 状态时waitpid不会返回进程也没有退出事件但请求已经全部卡死。这种“进程还活着服务已经死了”的假死只能靠心跳文件来兜底。2.3 用户态保护的真实边界为什么没有“杀不死”的进程这里必须先泼一盆冷水用户态的守护进程做不到让一个进程“杀不死”。root 的kill -9无法被忽略SIGKILL 直接由内核处理进程没有任何机会执行清理函数。要做到真正免疫kill -9需要内核模块、虚拟机监控器或更底层的安全机制这不是一个普通 C 程序能解决的问题。那标题里的“保护进程”保护的是什么是两层现实目标第一层让被保护进程退出后快速被拉起崩溃时间控制在秒级第二层降低守护进程自身被误杀的概率比如忽略不该接的信号、调低自己的 OOM 优先级、用单实例锁防止重复拉起。把预期定在这两层上后面代码的边界就清晰了。接下来直接看实现。3. 用 C 写一个能自动拉起崩溃进程的守护程序这一章给出一套能够直接编译跑通的最小实现重启退避策略、forkexec 拉起子进程、waitpid 主循环以及配套的心跳假死检测。代码按“守护进程 业务子进程”两个角色拆开守护进程负责监控业务子进程只需在启动时开一个心跳线程。3.1 先定重启策略连续失败要退避写代码前先定重启策略。常见做法是“短间隔重启 连续失败退避”第一次崩溃马上拉起第二次延迟几秒连续多次崩溃就拉长间隔避免业务进程一启动就崩、守护进程疯狂 fork 把服务器打挂。连续失败次数拉起前等待说明11s第一时间恢复22s仍然可以接受35s疑似环境问题4 次以上30s进入“频繁崩”状态需要人工介入实现上我习惯把它写成一个独立函数避免在监控循环里堆分支逻辑。退避状态变量fail_count在处理完退出事件后累加业务子进程稳定运行一段时间后要清零不然退避状态会一直累积导致一次偶发崩溃后长期重启缓慢。3.2 拉起业务子进程fork exec 的正确姿势拉起子进程用fork()加execv()这里有一个隐藏需求业务进程要和守护进程保持父子关系同时业务进程自己最好能脱离终端会话避免继承终端的 Ctrl-C 和挂断信号。#include sys/wait.h #include unistd.h #include csignal #include cstring #include string #include vector #include sys/prctl.h // 启动一个业务子进程返回其 pid pid_t spawn_svc(const std::vectorstd::string argv) { pid_t pid fork(); if (pid 0) return -1; if (pid 0) { // 子进程分支先脱离会话避免终端信号波及 setsid(); // 守护进程死亡时业务进程收到 SIGTERM 并退出 prctl(PR_SET_PDEATHSIG, SIGTERM, 0, 0, 0); if (getppid() 1) { // prctl 设置完成前父进程已经退出自己结束 _exit(1); } // 构造 execv 需要的参数数组 std::vectorchar* args; for (const auto s : argv) { args.push_back(const_castchar*(s.c_str())); } args.push_back(nullptr); execv(args[0], args.data()); _exit(127); // exec 失败让父进程看到一个明确退出码 } return pid; }子进程里做了三件事setsid()让业务进程不再从守护进程的终端接收 Ctrl-CPR_SET_PDEATHSIG保证守护进程突然死亡时业务进程会自动退出避免它变成无人看护的孤儿execv把子进程替换成真正的业务程序。exec失败返回 127父进程通过waitpid能拿到这个退出码日志里可以区分“业务崩溃”和“拉起失败”。如果业务程序需要在特定工作目录下运行记得在 exec 前先chdir。3.3 主监控循环waitpid EINTR 处理主循环的核心是阻塞waitpid。它会把 CPU 让出去直到子进程退出才返回。这里最容易踩的坑是 EINTR只要有信号处理函数存在阻塞中的waitpid可能被中断返回 -1不能把它当成异常退出循环必须continue重新等。#include cerrno #include atomic std::atomicbool g_stop{false}; // 根据连续失败次数计算等待时间 int respawn_delay(int fail_count) { if (fail_count 1) return 1; if (fail_count 2) return 2; if (fail_count 3) return 5; return 30; } int run_loop(const std::vectorstd::string cmd) { int fail_count 0; pid_t child spawn_svc(cmd); if (child 0) return -1; while (!g_stop) { int status 0; pid_t r waitpid(child, status, 0); if (r 0) { if (errno EINTR) continue; // 被信号打断重新等 if (errno ECHILD) break; // 子进程关系丢失说明守护结构坏了 break; } int exit_code -1; int term_sig 0; if (WIFEXITED(status)) { exit_code WEXITSTATUS(status); } else if (WIFSIGNALED(status)) { term_sig WTERMSIG(status); } // 记录退出原因和连续失败次数后面排查全靠它 log_exit(child, exit_code, term_sig, fail_count); if (g_stop) break; fail_count; sleep(respawn_delay(fail_count)); child spawn_svc(cmd); if (child 0) break; } return 0; }WIFEXITED和WIFSIGNALED用来区分正常退出与被信号杀死。业务进程被kill -9时WTERMSIG(status)返回 9日志里一眼就能看出“曾经被 SIGKILL”。fail_count在成功运行一段时间后要清零常见做法是开一个心跳检查业务子进程存活超过 10 分钟就重置失败计数避免一次历史崩溃导致后续重启永远偏慢。3.4 心跳检测识别进程活着但业务假死waitpid只能发现进程退出发现不了业务线程卡死。解决办法是心跳文件业务进程定期写一个时间戳守护进程检查文件修改时间超过阈值就判定假死并强制杀掉让主循环走正常重启流程。// 业务进程内部启动一个线程每 5 秒更新时间戳 #include thread #include fstream #include ctime void heartbeat_loop(const std::string path) { while (true) { std::ofstream f(path, std::ios::trunc); if (f) { f std::time(nullptr); } std::this_thread::sleep_for(std::chrono::seconds(5)); } }// 守护进程内部每 5 秒检查一次心跳文件 mtime #include sys/stat.h void check_hb_loop(pid_t child, const std::string path) { int miss 0; while (!g_stop child 0) { struct stat st; if (stat(path.c_str(), st) ! 0) { miss; } else { time_t age std::time(nullptr) - st.st_mtime; if (age 30) miss; else miss 0; } if (miss 3) { log_hb_miss(child, miss); kill(child, SIGKILL); // 杀掉假死进程waitpid 会触发重启 break; } sleep(5); } }参数说明心跳写入周期 5 秒检查周期 5 秒判定阈值 30 秒连续 3 次超时才 kill。为什么要“连续 3 次”单次超时可能是业务线程一次调度抖动、磁盘写慢或负载短暂飚高直接 kill 会把健康进程误杀3 次判定大约能容忍 15 到 30 秒的瞬时停顿。心跳线程杀掉假死进程后自己就结束重启逻辑统一由waitpid主循环接管避免两个线程同时在spawn_svc造成双进程。4. 保护进程自身PID 文件、信号矩阵与 OOM 参数守护进程如果自己先被误杀或失控比如两个守护进程同时拉起同一业务保护就无从谈起。所以“保护进程”的第三层是守护进程自身怎么活、怎么优雅退出、怎么避免被自己人搞乱。4.1 flock 单实例锁防止两个守护进程抢拉起同一套服务被两个守护进程同时看护会演变成两个进程抢同一个端口、互相抢着重启的业务事故。常见做法是用文件锁做单实例保护。#include sys/file.h #include fcntl.h #include unistd.h // 成功返回锁 fd失败返回 -1 int acquire_single_instance(const std::string path) { int fd open(path.c_str(), O_CREAT | O_RDWR, 0644); if (fd 0) return -1; if (flock(fd, LOCK_EX | LOCK_NB) ! 0) { close(fd); return -1; // 已被另一个守护进程占用 } ftruncate(fd, 0); dprintf(fd, %d\n, getpid()); return fd; }为什么用flock而不是单纯检查 PID 文件存在文件锁在进程退出或崩溃时由内核自动释放不存在“pid 文件残留导致永远无法启动”的尴尬。锁要在daemonize()之前拿因为 fork 后父进程和子进程共享同一个 fd父进程退出不会释放锁守护进程继续持有。第二个守护进程启动时flock(..., LOCK_NB)直接失败报错退出业务进程不会被双份拉起。4.2 信号矩阵哪些信号要忽略哪些要用来优雅退出守护进程自己的信号处理很多人直接忽略结果 Ctrl-C 能把它杀掉终端断开也能把它带走。常见配置如下信号行为理由SIGHUP忽略控制终端断开时不要退出SIGINT忽略守护进程不应被 Ctrl-C 终止SIGTERM触发优雅退出运维 stop 时走正常回收流程SIGPIPE忽略防止写日志管道断裂直接终止进程SIGCHLD交给 waitpid子进程退出通知不设 handler#include csignal #include atomic #include cstring std::atomicbool g_stop{false}; void on_term(int) { g_stop.store(true); } void init_signals() { struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_handler on_term; sigaction(SIGTERM, sa, nullptr); signal(SIGHUP, SIG_IGN); signal(SIGINT, SIG_IGN); signal(SIGPIPE, SIG_IGN); }SIGTERM 处理函数里只设置一个原子标志不做复杂操作也不调用printf。主循环检测到g_stop后先给业务子进程发 SIGTERM再回收这套顺序就是“优雅退出”。SIGCHLD 不设 handler因为阻塞waitpid已经覆盖了退出通知设了反而可能打断它。优雅退出还要有超时上限给子进程发 SIGTERM 后轮询几秒超时再 SIGKILL否则遇到不响应 SIGTERM 的子进程会卡住退出流程。void graceful_stop(pid_t child) { kill(child, SIGTERM); for (int i 0; i 10; i) { int st 0; pid_t r waitpid(child, st, WNOHANG); if (r child) return; sleep(1); } kill(child, SIGKILL); // 超时强制 waitpid(child, nullptr, 0); }4.3 调低 OOM 优先级在子进程 exec 前写 oom_score_adj被保护进程在内存压力下被 OOM killer 挑中是“保护进程”最常见的失败场景。常见做法是在业务子进程 exec 之前设置自己的oom_score_adj值越小越不容易被 OOM killer 优先选中。#include fstream #include string // target 范围 -1000 ~ 1000默认 0越小越不容易被杀 void tune_oom_priority(int target) { std::ofstream f(/proc/self/oom_score_adj); if (f) f target; }调用时机必须在 fork 之后的子进程分支、exec 之前写入的是子进程自己的 proc 入口。写入负分需要权限普通用户改不了容器里经常没 CAP_SYS_RESOURCE写入失败要容忍继续往下走同时打一条日志不要因为调优失败就让子进程起不来。写入后最好读回验证防止有些内核约束覆盖写入了事。配合PR_SET_PDEATHSIG保护逻辑才完整守护进程崩溃时业务子进程收到 SIGTERM 自动退出不会变成没人管的孤儿。注意要在 fork 之后立刻调用并检查getppid()防止prctl设置完成前父进程已经退出导致信号丢失。更重的场景会让两个守护进程互相守护但双守护的启动时序、锁竞争和日志归属都要额外处理复杂度会高一个级别我一般先用 systemd 兜底而不是自己写双守护。5. 避坑守护进程在线上最容易翻车的 5 个现场这一章整理自几个项目里实际踩过的坑每一条都按“现象 → 原因 → 解决”写线上遇到时可以按现象对号入座。这几个坑有一个共同特点不崩溃的时候一切正常一旦崩溃守护进程先于业务进程倒下。5.1 双 fork 之后 waitpid 接不到退出事件现象业务进程被kill -9守护进程日志里没有任何重启记录业务一直停着。原因daemonize 按旧模板写了双 fork。第一次 fork 后的中间进程再 fork 出业务进程然后中间进程退出业务进程被 init 收养。这时守护进程不是业务进程的父进程waitpid自然等不到退出事件。解决单 fork setsid足够。守护进程直接作为业务进程的父进程waitpid(child, status, 0)才能可靠工作。“脱离终端”和“变成孤儿”是两件事不要为了后台化把父子关系也切掉。5.2 waitpid 返回 EINTR 被认为是异常现象服务跑得好好的突然守护进程退出日志里写着 waitpid errorerrno 是 EINTR。原因进程收到了信号阻塞中的waitpid被中断返回 -1。代码只判断了 errno 不是 ECHILD 就当错误退出没有单独处理 EINTR。解决在循环里对 EINTR 单独 Continue。一行就能解决但不写它守护进程会在各种信号下悄然退出这种“守护进程先死”的事故最隐蔽。5.3 用 kill(pid,0) 判断存活导致 PID 复用误判现象业务进程早崩了监控脚本一直显示进程存活服务却无法访问。原因轮询用的是启动时记录的 pid。业务进程退出后pid 被系统复用到另一个无关进程kill(pid, 0)返回成功误以为业务还活着。解决用waitpid事件驱动替代轮询如果必须轮询就同时读取/proc/pid/stat里的 starttime 字段启动时记录一次每次检查时比对。starttime 是第 22 个字段按系统 tick 计数短时间内不会重复。5.4 心跳阈值太短误杀健康进程引发重启风暴现象高峰时段业务请求变慢心跳线程被调度延迟几秒守护进程立刻杀掉业务进程并重启。新进程还没热身又被打满形成循环重启。原因一次超时就判死没有容忍调度抖动的冗余。业务进程本身健康只是瞬时无法执行心跳写入。解决连续 3 次检查都超时才 kill阈值放宽到心跳周期的 6 倍左右。假死判定只负责触发 kill不直接拉起新实例让waitpid主循环统一接管重启逻辑避免两个线程同时在 spawn。5.5 OOM 参数写入成功但实际被杀现象业务进程在代码里设置了oom_score_adj-500日志显示写入成功内存压力上来时还是被 OOM killer 杀掉。原因写入 proc 文件的进程和真正被保护的进程不是同一个或者容器里没有 CAP_SYS_RESOURCE写入没有实际生效还有一种常见情况业务进程和其他进程共享一个 cgroupOOM 策略按 cgroup 整体内存用量计算单个进程的 adj 值起不到决定作用。解决在子进程 exec 前的分支里写入而不是守护进程写另一个 pid 的 proc 文件写入后读回验证同时在 cgroup 层面对业务进程做独立分组和内存上限别让它和一堆无关进程混在同一个全局 OOM 候选池里。6. 把守护进程跑起来以后用崩溃注入脚本验证三层行为代码写完别急着上生产。我在把守护进程接入某个订单 worker 之前先跑了一套很粗暴的崩溃注入脚本。验证的不是“它不崩”而是“它崩了以后恢复得怎么样”。#!/bin/bash # 假设业务进程名是 order_worker WORKERorder_worker # 第 1 层强杀验证 waitpid 自动拉起 kill -9 $(pgrep -f $WORKER) sleep 3 pgrep -f $WORKER # 应该打印一个新 pid # 第 2 层冻结验证心跳假死检测 kill -STOP $(pgrep -f $WORKER) sleep 35 # 大于心跳阈值 30s 检查周期 kill -CONT $(pgrep -f $WORKER) 2/dev/null pgrep -f $WORKER # 应该是一个新 pid旧进程已被回收 # 第 3 层连续崩溃验证退避间隔 for i in 1 2 3; do kill -9 $(pgrep -f $WORKER) sleep 1 done tail -n 20 /var/log/order_svc.log跑之前把日志级别调到 debug重点看三样东西waitpid 日志里的退出原因是不是 SIGKILL心跳超时日志有没有出现三次强杀后重启间隔是否从 1 秒涨到了 5 秒以上。如果这些都符合预期守护进程才算具备上线条件。我还有一个每次上线前的检查习惯手动跑一次kill -9然后盯着日志里的退出码和拉起耗时。拉起耗时超过 1 秒的问题多半出在退避 sleep 或日志写盘阻塞上值得单独优化。这个习惯让我少背了好几次告警。希望帮到你。本文还有配套的精品资源点击获取