资讯动态

C++守护进程编写:从fork到双守护的进程保护实战

发布时间:2026/10/13 10:49:59 来源:尧图企业网站定制
简介这是一份用C编写的守护进程DLL资源核心功能是保护指定的C#程序不被意外终止适用于Windows平台下的进程守护与防杀场景。实现中采用Hook技术拦截系统进程终止事件一旦检测到目标程序被尝试结束便会立即介入在Win7系统上已验证有效适合需要掌握进程保护技术的开发人员学习和二次开发。压缩包共173个文件整体约24.32MB包含exe可执行程序、dll动态库、cpp/cs源码、pdb调试符号、manifest清单以及完整的工程配置文件源码与编译产物齐全既能直接运行验证效果也便于根据业务需求修改核心逻辑。目前已有683人学习下载。内容覆盖C与C#的跨语言调用、通过SetWindowsHookEx使用消息钩子、DLL注入及进程保护等关键知识点并附带可运行的测试工程帮助读者理解守护进程的整体运作机制积累常见排错思路是一份可动手实践的完整参考方案。1. 守护进程程序C编写保护进程先理解“保护”到底能保护什么有一类后台服务很尴尬日志采集、定时任务、状态上报业务本身不复杂但一遇到凌晨低峰、内存波动或者运维误操作进程就悄悄没了。第二天早上打开面板看到的是断档的记录唯一的补救办法就是手动拉起来。守护进程程序C编写保护进程说的就是在系统里放一个常驻的、由 C 实现的家长进程用 waitpid、心跳探测、重启退避这些手段盯住真正的业务进程。业务逻辑该怎么写还怎么写它负责把“进程还活着”这件事变成默认状态。这个方向适合三类人想搞懂 daemon 正确写法的 C 开发者、要给自己工具加进程保护的程序员、以及被异常退出搞怕了想补最后一层兜底的服务维护者。先泼一盆冷水Linux 用户态里没有任何代码能硬扛 kill -9真正能实现的“保护”是把“被杀之后毫秒级自动拉起”这件事做扎实并且把守护进程自己挂掉的情况也考虑进去。后面所有章节都围绕这一点展开。2. 守护进程的生死fork、setsid 和 C 里的职责切分2.1 daemon 化四步fork、setsid、工作目录、文件描述符在讲保护之前先要把“守护进程”本身的诞生过程讲清楚。一个 C 程序在终端里运行它和终端会话绑定你关掉终端它就收到 SIGHUP它再厉害也没机会保护别人。所谓 daemon 化就是让它脱离会话、脱离控制终端生命周期完全由 init 体系接管。常见的做法是四步走fork、setsid、改工作目录、重定向标准流。我一般会单独写一个 daemonize 函数把所有细节关在一个函数里。最精简的版本如下#include fcntl.h #include sys/stat.h #include unistd.h #include cstdlib bool daemonize() { // 第一次 fork父进程退出子进程变成孤儿不再占用终端会话 pid_t pid fork(); if (pid 0) return false; if (pid 0) std::exit(0); // 创建新会话脱离控制终端这一步是 daemon 的灵魂 if (setsid() 0) return false; // 第二次 fork 不是必须但习惯上再做一次确保不会重新获得控制终端 pid fork(); if (pid 0) return false; if (pid 0) std::exit(0); // 文件权限掩码清零避免后续创建文件带上意外权限 umask(0); // 切换到根目录避免占用某个被卸载/删除的目录 if (chdir(/) ! 0) return false; // 标准输入输出都转向 /dev/null不再与任何终端交互 int nullfd open(/dev/null, O_RDWR); if (nullfd 0) return false; dup2(nullfd, STDIN_FILENO); dup2(nullfd, STDOUT_FILENO); dup2(nullfd, STDERR_FILENO); if (nullfd STDERR_FILENO) close(nullfd); return true; }逻辑说明第一段 fork 是让 init 收养子进程父进程退出时终端不会向它发 SIGHUPsetsid 让子进程成为新会话首进程彻底切断和原来控制终端的关系第二次 fork 再加一道保险因为会话首进程一旦打开某个终端设备仍可能被拖回会话。umask、chdir 和 fd 重定向解决的是环境残留问题。参数和边界说明这版函数默认工作目录是根目录。如果你的守护进程需要读配置文件、写日志目录chdir 这一步要放在读配置之后或者在启动参数里传绝对路径否则你会发现所有相对路径都失效。日志文件建议用 open 打开后主动 dup2 到 STDOUT/STDERR而不是依赖 shell 重定向因为进程 daemon 化后 shell 已经不在场了。2.2 保护与托管什么时候不该自己写守护进程有些读者看到这里会问Linux 明明有 systemd我为什么还要再写一个 daemon这是个好问题。业界共识是如果能用 systemd 的 Restarton-failure、RestartSec、StartLimitInterval 实现就不该自己写守护进程。systemd 已经处理好了开机启动、依赖顺序、资源限制、日志接管你还自己造一套有点违背工程常规。那什么情况下值得自己写我判断的标准是三条。第一重启逻辑必须带业务上下文比如连续崩溃三次要写标记文件、退避时间要和业务缓存状态联动第二目标系统不一定有 systemd或者你是把一个私有二进制部署到某个受限容器里第三你要保护的进程不是简单拉起就行它需要先清理旧锁、检查版本号、再切换目录。这些逻辑放 init 配置里会非常别扭放在守护进程代码里反而自然。也就是说自定义化程度高的自己写常规“进程挂就拉起”的一律交给 systemd。边界也要说清楚daemon 不是安全机制它解决的是可用性不解决入侵问题。真正的恶意 kill用户态守护进程并不能阻止信号送达它能做的是收到异常退出后重新拉起。如果对方连守护进程一起 kill那就需要后面的双守护方案。这一点在第一章已经说过后面会用代码验证。你把保护能力理解成“快速自愈”比理解成“杀不死”更接近现实。2.3 用 C 而不用 shell 和 C 的现实理由选 C 不是因为它比 C 更现代而是这类程序天然适合 C 的生态。进程管理、信号、文件、日志每一处都要跟 POSIX API 打交道shell 脚本写出来的守护逻辑到了信号处理和资源释放这层会变得很难收场。C 的数据结构、生命周期和 RAII 也能帮你在常年跑动的进程里减少人为失误。std::string、std::thread、结构化日志在守护进程里都常用尤其日志部分后面章节会用到。另一个现实理由是开销。守护进程的检查循环往往每秒或者每几百毫秒执行一次shell 每条检测指令都起一个子进程开销立刻上去C 直接在进程内完成不产生额外子进程交互。拿 waitpid 判断子进程退出是内核直接通知不是轮询出来的开销几乎可以忽略。对于高并发服务这种差异就是几万 QPS 的差距。我还想提醒一点守护进程的 C 代码尽量少用多线程。刻意简单的建议是主体用单线程事件循环信号处理只做标志位具体回收逻辑放在主循环里。守护进程本身不该有复杂的并发业务复杂度应该在受保护的业务进程里。这样做的原因是守护进程一旦因为并发问题挂掉所有保护全断复杂度越低越稳妥。你要保护别人自己先得站得稳。3. C 写一个可运行守护进程骨架最小代码与两个关键信号3.1 一个能编译的最小保护器现在把前面的 daemonize 接到一个可运行的主程序里。作为保护器它承担两个职责被系统启动起来、看住业务进程。先写一个能跑的最小版本只负责把自己变成后台守护循环业务保护逻辑下一章再接#include csignal #include cstring #include iostream #include unistd.h static volatile sig_atomic_t g_running 1; static volatile sig_atomic_t g_reload 0; void on_signal(int signo) { if (signo SIGTERM || signo SIGINT) g_running 0; if (signo SIGHUP) g_reload 1; } int main(int argc, char* argv[]) { if (!daemonize()) { std::cerr daemonize failed std::endl; return 1; } struct sigaction sa; std::memset(sa, 0, sizeof(sa)); sa.sa_handler on_signal; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART; // 被信号打断的阻塞调用自动恢复 sigaction(SIGTERM, sa, nullptr); sigaction(SIGINT, sa, nullptr); sigaction(SIGHUP, sa, nullptr); while (g_running) { if (g_reload) { g_reload 0; // 重读配置或重新加载保护对象 } sleep(1); } return 0; }逻辑说明daemonize 必须放在最前面之后任何终端交互都不该出现。信号处理统一走 on_signal内部不调用任何非异步信号安全的函数只改全局标志位。主循环每秒醒来一次响应退出和重载。这里用了 sigaction 而不是老式的 signal因为 signal 在不同系统上的语义差异很大sigaction 能明确指定是否 SA_RESTART这个细节在 waitpid 场景里特别重要。参数说明g_running 用 volatile sig_atomic_t保证信号处理与主循环之间的读写是原子的这在 C 里是标准做法。sleep(1) 是给守护进程一个呼吸节拍下一章接保护逻辑时这一秒会改成更适合检测的短睡眠。常见错误是在信号处理函数里直接做 write、log、甚至 new结果偶发死锁或日志损坏。记住信号处理里只能做标记其余动作全部回到主循环。3.2 日志落盘先解决排查时最痛苦的环节守护进程最核心的可观测性就是日志。daemon 化之后 stdout 已指向 /dev/null你不写日志出了问题基本只能靠猜。常见做法是分两条路关键生命周期事件走 syslog运行细节写自己的文件。syslog 的好处是日志回滚、远程转发都有人管自建文件日志则更精确适合详细调试信息。写文件时有个细节目录名必须绝对路径不能依赖当前目录。你 chdir 到根目录之后相对路径全废。另外用 O_APPEND 打开日志多进程写日志时不会互相覆盖。代码很简单int log_fd open(/var/log/myguard.log, O_WRONLY | O_CREAT | O_APPEND, 0640); if (log_fd 0) { /* 记录错误但不自杀 */ } void write_log(const std::string msg) { std::string line [guard] msg \n; ssize_t r write(log_fd, line.data(), line.size()); (void)r; }逻辑说明write_log 只是最小示例生产环境应该加时间戳、进程号、进程名。我故意不用 std::cout因为守护进程场景下 std::cout 可能没有绑定到文件而且 stream 缓冲会把日志堵在内存里直接 write 最可靠。这里有坑常年运行的守护进程日志文件会积到几十 GB。建议在守护进程里加一个大小检查超过阈值就 rename 后重新创建或者直接依赖系统的 logrotate。我一般会优先用 logrotate因为守护进程自身越简单越好把日志文件旋转交给专门工具比自己在主循环里做可靠得多。3.3 SIGCHLD接收退出消息的正确姿势保护进程要想知道业务进程死了最正统的通知途径是 SIGCHLD。它和 SIGTERM、SIGHUP 一样需要注册处理函数但有个特殊点处理函数里可以做 waitpid 回收也可以只置标志位回主循环处理。我习惯在主循环里统一 waitpid因为这样能把正常退出、崩溃退出、被信号杀死几种情况集中判断。需要特别注意一个配置注册 SIGCHLD 时不要设置 SA_NOCLDWAIT也不要直接 signal(SIGCHLD, SIG_IGN)。这两个做法都会让子进程被内核自动回收你的 waitpid 永远拿不到退出状态也就无法区分“正常退出”和“异常退出”。注册代码和前面一样信号处理函数里置个标志static volatile sig_atomic_t g_child_exit 0; void on_signal(int signo) { if (signo SIGCHLD) g_child_exit 1; if (signo SIGTERM || signo SIGINT) g_running 0; if (signo SIGHUP) g_reload 1; }逻辑说明SIGCHLD 到来只标记不 wake 任何线程也不调用 waitpid。主循环在检测到 g_child_exit 后调用 waitpid拿到退出状态。这是一个非常典型的“信号置标记、主循环干活”的协作方式能避开大量和异步安全相关的坑。如果你的保护进程还有其他子进程比如临时调用了外部命令主循环里要区分 waitpid 返回的 pid 是不是受保护的对象。不是的话只需要 recycle 状态不需要触发重启逻辑。这个区分在下一章的守护循环里非常重要。4. 保护进程的核心存活检测、重启退避与双守护互拉4.1 三种存活检测waitpid、kill(pid, 0) 和 /proc做进程保护第一件事是回答“它到底还活着吗”。常见做法有三种各有各的坑检测方式适合关系优点坑waitpid父子关系内核直接通知能拿退出码子进程必须由自己 forkkill(pid, 0)任意同用户轻量只查 pid 是否存在僵尸进程也算活着pid 复用会误判/proc/pid 目录任意同用户能查 cmdline、状态、启动时间慢高频检查开销大结论很明确守护进程自己 fork 出来的业务进程用 waitpid 是唯一正确的选择因为它不是“轮询存活”而是内核通知退出。kill(pid, 0) 不是真的发信号只是检查是否有权限发送信号pid 存在就返回成功哪怕它是一个已经僵死的进程。pid 复用会导致误判。所以双守护场景里用 pid 文件判断对方是否存活时必须叠加上 /proc/pid/cmdline 校验确认 pid 对应的还是同一个可执行文件。/proc 方式我一般只在跨进程监督时用比如一个独立 watchdog 去监督别人的进程。它要 open、read、close 三次操作每秒跑一次还可以毫秒级就不合适了。跨进程监督还有权限问题dumpable 状态或非同一用户时读 proc 会失败不能只看 errno 就判定进程死了。4.2 带连续退出计数与退避重启的保护循环现在组合前面几段daemonize、信号处理、waitpid再加大循环。业务进程交给守护进程 fork 出来守护进程只做一件事等它退出然后决定要不要拉起。核心代码如下static pid_t worker_pid -1; static int launch_worker() { pid_t pid fork(); if (pid 0) { // 子进程把信号处理恢复成默认避免继承守护进程的行为 signal(SIGCHLD, SIG_DFL); signal(SIGTERM, SIG_DFL); execl(/opt/app/worker, /opt/app/worker, nullptr); _exit(127); // execl 失败 } return pid; } while (g_running) { if (g_child_exit) { g_child_exit 0; int status 0; pid_t ret waitpid(worker_pid, status, WNOHANG); if (ret worker_pid) { int exit_code WIFEXITED(status) ? WEXITSTATUS(status) : -1; if (exit_code 0) { crash_count 0; // 平稳退出一次重置计数 } else { crash_count; } if (crash_count 2) { int seconds (1 (crash_count - 1)) - 1; // 0、1、3、7 秒 if (seconds 30) seconds 30; sleep(seconds); } if (g_running) worker_pid launch_worker(); } } sleep(1); }逻辑说明g_child_exit 由 SIGCHLD 处理函数置位主循环一醒来就处理。waitpid 用 WNOHANG 加上 sleep(1)组成一个低频轮询结构既不会漏掉退出事件也不会因为某个被保护进程长时间不退而卡死整个守护进程。crash_count 只在退出码非零时累加连续崩溃两次开始退避避免进程一退出就立刻重新拉起形成空转风暴。参数说明退避公式(1 (crash_count - 1)) - 1在 crash_count 等于 2、3、4、5 时分别产生 1、3、7、15 秒封顶 30 秒。这个指数退避的节奏适合大多数服务因为连续崩溃往往说明环境持续异常频繁重启只会加剧负载。如果 crash_count 超过阈值比如 10 次你还可以选择停止拉起并写一条 fatal 日志等人工介入要不要取决于业务接受的停机时间。注意一点单独靠 waitpid 只能保护“直接子进程”。有些业务会自己 daemon 化fork 之后父进程退出你的守护进程盯住的是一个很快退出的壳真正干活的进程漂到了 init 底下。这时候保护逻辑会反复重启业务而实际业务进程每启动一次就多一个残留实例非常危险。遇到这种业务要在 launch_worker 里捕获它的初始化输出确认它不会二次 fork或者干脆让业务进程不要 daemon 化把守护职责完全交给外层保护进程。4.3 双守护者互拉让保护进程自己也被保护单层守护的软肋是守护进程自己被杀业务进程就变成无人看管的孤儿。所以高可靠场景里我会给守护进程配一个搭档形成互拉。常见做法是设计成两个角色的双守护同一份 C 二进制用启动参数区分是“守护者”还是“业务进程”。守护者看业务业务进程也在周期性地看守护者是否还活着发现守护者没了就重新拉起它。// worker 内部每 10 秒检查一次守护者是否存活 bool guardian_alive() { std::ifstream f(/var/run/myguard.pid); int pid 0; f pid; if (pid 0) return false; if (kill(pid, 0) ! 0) return false; // 防止 pid 复用误判cmdline 里必须带 --guard std::ifstream cmd(/proc/ std::to_string(pid) /cmdline); std::string line; cmd line; return line.find(--guard) ! std::string::npos; } // 主循环发现守护者不在就自己起一个 if (!guardian_alive()) { pid_t pid fork(); if (pid 0) { setsid(); execl(argv0, argv0, --guard, nullptr); _exit(127); } }逻辑说明守护者启动业务进程时把自己的 pid 写进 /var/run/myguard.pidworker 读这个文件并检查守护者是否存活。检查分两层kill(pid, 0) 只确认 pid 存在紧接着读 /proc/pid/cmdline 确认它还是当前这版程序否则一个 pid 被系统回收再用到别的进程上就会误判守护者活着。实现上的关键点是新的守护者起来后不能立刻再 fork 一个新 worker而是先读 worker 的 pid 文件。如果 worker 还活着就“接管”它只盯 waitpid 不重新启动。这个接管流程我通常放在 --guard 分支的最前面int worker_pid read_worker_pid(); if (worker_pid 0 process_alive(worker_pid)) { // 已有 worker 在跑直接接管看护 } else { worker_pid launch_worker(); write_worker_pid(worker_pid); }逻辑说明接管模式保证了双守护互拉不会产生两套业务实例。你 kill 掉守护者worker 拉起新守护者新守护者看到 worker 没死就只做监督不重复启动。反过来kill 掉 worker守护者会正常拉起新 workerworker 自己也会用同样的逻辑重新验证守护者。这套配合就是“保护进程程序”最完整的形态。这个方案有个前提两个角色必须共享同一个 pid 文件目录并且对 pid 文件加锁。实际部署中我会用 lockf 对 pid 文件加排他锁避免两个守护者同时接管产生竞争。如果不想自己做这些问题退路还是交给 systemd 或者容器编排系统它们对单实例保护的实现已经相当成熟。4.4 开机自启守护进程也要有人管双守护互拉解决了运行时保护但机器重启之后谁先起来答案是系统 init。无 systemd 的环境可以写 System V init 脚本有 systemd 的环境直接写一个最小 unit 文件。守护进程自己不喜欢被 cron 反复拉起因为它没有单实例锁时cron 会在进程还活着的时候启动第二个实例。systemd 的简单配置是这样的不需要额外代码[Unit] Descriptionmy process guard Afternetwork.target [Service] ExecStart/opt/app/myguard --guard Restartalways RestartSec3 StartLimitIntervalSec60 StartLimitBurst3 [Install] WantedBymulti-user.target逻辑说明Restartalways 让守护进程在意外退出时被 systemd 拉起来RestartSec3 给一个固定间隔StartLimitIntervalSec 和 StartLimitBurst 防止守护进程启动后立刻崩溃形成死循环。这相当于把“守护进程的保护进程”这一层交给了 init和双守护互拉是互补关系不是替换关系。我一般会把 unit 文件的启动参数设计成 --guard 还是 --work 由配置文件决定这样同一个二进制既可以被 systemd 管也可以自启动互拉。最保守的组合是systemd 管守护者守护者管业务进程双守护互拉作为 systemd 不可用时的备选。5. 避坑守护进程 C 实现里的 5 个真实翻车现场5.1 现象fork 后监听端口报 bind 失败守护进程和业务进程共用一个租约端口时fork 之前监听的 socket 会被子进程继承。业务进程启动时尝试 bind 同一个端口结果端口仍在 TIMEWAIT 或已被自己占用报 address already in use。原因daemonize 里只重定向了标准 fd没有关闭从守护进程继承的其他 fd。socket 没有设 SO_REUSEADDR或者根本没关闭子进程继承后便把端口占了。解决daemonize 里主动遍历关闭所有超过标准 fd 的文件描述符或者给 socket 加 SO_REUSEADDR。保护进程把业务启动起来之后最关键的一个操作是把与业务相关的 fd 全关干净只保留日志和 pid 文件。代码上可以用 close_range 这类接口或者逐个 close 到系统最大 fd。5.2 现象关掉终端后整条链路全断保护进程在终端里跑着没问题一退出终端或者用 nohup 起隔几分钟整个链路就断了业务进程跟着消失。原因进程没有正确脱离会话。最后客户端连接断了会触发 SIGHUP终端关闭时也给整个前台进程组发 SIGHUP。daemon 化只 fork 了一次或者 setsid 调用失败后没检查返回值进程仍然属于原会话。解决fork 之后马上检查 setsid 返回值失败就不要继续。习惯上再做一次 fork让当前进程不再是会话首进程避免后续打开终端设备时被拖回会话。验证方法很简单启动守护进程后直接把 SSH 会话断掉再回来看进程是否存在而不是看屏幕。5.3 现象业务进程反复被拉起CPU 白转业务进程一崩溃守护进程立刻 fork 一个新进程新进程几毫秒又崩又拉起。日志里全是启动和退出记录系统 load 飙升但业务根本没有实际产出。原因没有做连续退出计数和退避。每次崩溃都是先重启再判断等于在给崩溃风暴火上浇油。解决第四章的 crash_count 退避逻辑。有一个原则连续崩溃超过 3 次必须进入指数退避并且写出带退出码的日志。还有一种更保守的做法前 N 次崩溃使用固定间隔N 次之后增加一个“冷却窗口”窗口内不做任何拉起只记录。这样既能处理瞬时故障也能防止环境持续异常时重启风暴。5.4 现象SIGCHLD 被忽略waitpid 拿不到退出状态保护进程能启动业务进程也能看到它退出但 waitpid 返回 -1errno 是 ECHILD正常退出和异常退出完全区分不出来。原因代码里用了 signal(SIGCHLD, SIG_IGN)或者 sigaction 设置了 SA_NOCLDWAIT。这两种写法都是明确告诉内核“子进程的死我不关心”内核会直接回收子进程不给你留任何查询途径。解决SIGCHLD 必须注册自定义处理函数且不能带 SA_NOCLDWAIT。处理函数里可以只置位标志主循环用 waitpid 取状态。如果守护进程里还有别的临时子进程要做好 pid 校对别把临时命令的退出当成业务进程的退出。这个坑躲过去了退避逻辑才能准确判断是崩溃还是正常退出。5.5 现象保护进程和业务进程一起被 OOM 杀死系统内存不足时多个进程同时被杀保护进程还没来得及拉起业务进程自己也没了。之后整个依赖链路全部中断。原因保护进程自身占用内存偏高或者系统给每个进程的 OOM 打分没有区分优先级。OOM killer 通常会选某个占用高、恢复成本低的进程开刀不保证保留保护进程。解决给保护进程调低内存占用同时用 cgroup 限制被保护进程的内存上限避免单个业务进程无限膨胀拖垮整机。如果环境支持给保护进程配置较优的 OOM 分数让内核 kill 别轮到你。换到 systemd 里就是 MemoryMax 和 OOMScoreAdjust 两个参数。记住保护进程代码尽量精简和低资源这是它活下去的最大优势。6. 用 kill -9 验证保护效果再做最小权限加固验证保护逻辑最直接的办法就是故意杀死目标进程。我平时的验证顺序是先 kill 业务进程观察保护进程是否在预期时间窗口内拉起新实例日志里有没有退出码和重启次数记录再 kill 保护进程观察业务进程是否接替拉起新的守护者最后 kill 两个进程确认双守护模式下能恢复到完整状态而不是出现两份业务实例。每一步都要记录间隔时间这个指标直接反映保护能力的下限。在保护逻辑验证通过后我还会做一个加固降权运行。业务进程如果不需要 root 权限启动后立刻切换到专用业务账号用 setuid 或 Linux capabilities 把能力收窄。保护进程与业务进程之间通过日志和 pid 文件协作尽量不共享可写资源。这样即使业务被攻破它也无法反过来伪造保护进程的决策。守护进程程序 C 编写的代码再可靠也架不住它以 root 跑着。权限最小化属于真正的防线。最后给一个有价值的细节我会在保护进程里保留一个持久化的健康状态文件每次拉起业务进程、退避、追加上下文写一行。排查线上问题时这个文件能直接告诉你是 crash、自动退出还是人为 kill。养成这个习惯之前我常在几天后根本没法判断进程是自己退的还是环境杀的有了状态记录大部分血泪经验都有据可查。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑