☰
shttpd CGI实现:环境变量、fork/exec与响应回传
2026/10/26 6:50:06 网站建设 项目流程

1. 从一次 500 报错说起:CGI 在 shttpd 里究竟挂在哪一环

前阵子帮人排查一个跑在嵌入式板子上的小服务,静态页面全都正常,唯独一点表单提交就返回500 Internal Server Error,日志里除了一个CGI error什么都没有。那台设备上跑的就是 shttpd 这类几百 KB 的轻量服务器,源码翻出来一读才发现,问题出在 CGI 支持的那条链路上——从 URL 判定、环境变量拼装,到 fork/exec 和输出回传,中间任何一环没接好,表现都是同一个 500。这也是我觉得 shttpd 的 CGI 实现特别值得逐行读一遍的原因:它把 CGI 规范里那些"文档上写着但没人在意"的细节,全部塞进了不到一千行的 C 代码里,读懂了它,等于把 CGI 的整个生命周期在地上跑了一遍。

先说清楚这篇东西讲给谁看。你如果只写业务代码、从来不碰服务器底层,读完也能明白浏览器提交一个表单之后,服务端到底发生了哪几个系统调用;你要是正在做嵌入式 Web 配置页、想在最小体积里塞进动态能力,那这篇就是可以直接抄的结构参考。shttpd 之所以在资源受限场景里一直有人用,就是因为它没有把 PHP 解释器、Lua 虚拟机这些东西链进来,动态能力完全靠"叫一个外部程序出来干活"这种最原始的方式实现。代价是每个请求一个进程,收益是体积和复杂度都压到了极限。

1.1 为什么小体积服务器宁可留着 CGI 也不做内置脚本

很多人第一反应是 CGI 太老了,每个请求 fork 一次,性能差得要命。这话放在高并发站点上完全成立,但放在 shttpd 的典型使用场景里就不一定了。一台路由器、一块开发板、一台工业网关,它们的 Web 界面一天可能就十几个请求,管理员点一下"重启服务"、改一个 Wi-Fi 名字,用完就关。这种负载下 fork 一个进程的开销(几毫秒)根本不构成瓶颈,真正稀缺的是 Flash 空间和内存。

内置脚本引擎的路径是把解释器编进来,动辄几百 KB 到几 MB,还得处理解释器与服务器的内存交互、生命周期、线程安全。CGI 的路径是把解释器当普通程序调用,服务器这边只需要会 pipe、fork、exec,代码量三位数行就能搞定,语言还能随便选——Shell、Perl、Python、编译好的 C 程序,只要是可执行文件就能挂上去。这种"用操作系统现成的进程隔离换掉一切复杂度"的取舍,是 shttpd 这类服务器最核心的设计哲学。

不过有个前提得说清楚:CGI 只负责"把请求转给程序、把程序的输出转回客户端",它不负责性能。如果你预期单机 QPS 上千,那 CGI 一定不是答案,这时候才轮到 FastCGI 或者常驻进程模型出场,那需要另外一套协议解析和连接池管理,跟本文讨论的这套实现完全是两条路。

1.2 请求处理链上那个关键分叉点

读 shttpd 源码,我建议从连接对象(struct conn)和 handler 表入手。它在启动时会把一组处理函数注册进去,大致是静态文件、目录索引、CGI、SSI、认证这么几类,每个 handler 有一个"我能处理这个 URI 吗"的判断函数。请求进来之后,从表头一个个问下去,第一个说"我来"的就执行它的处理函数。

CGI 的判断函数做的事情很单纯:拿 URI 去匹配配置里的cgi_pattern,默认大概是**.cgi$|**.pl$|**.php$这种形式。匹配上就返回真,于是整个请求的控制权就交给了 CGI handler。这个"责任链"式的结构看起来平平无奇,但它带来一个很重要的性质:CGI 分支和静态文件分支共用同一套请求解析结果(方法、URI、查询串、请求头),所以你不需要为 CGI 重新解析一遍 HTTP 报文,这也是它能写得这么短的原因。

真正的分叉发生在这个 handler 内部。静态文件的处理是"打开文件、读、写 socket、关掉",全程同步且必然结束;CGI 的处理是"建管道、建子进程、进入一个可能阻塞的读写循环",它必须在struct conn上挂载额外的状态——子进程的标准输入输出 fd、是不是已经发出响应头、还剩多少超时时间。我第一次读的时候没注意这一点,以为 handler 是个无状态的纯函数,结果调试超时逻辑时绕了很久。你在自己的实现里如果也要加 CGI,务必先想清楚这些状态存在哪、谁来清理。

2. 一个 URL 凭什么判定要走 CGI:匹配与路径拆解

URL 判定这一层看着简单,实际上是整个 CGI 实现里最容易埋雷的地方。扩展名匹配只是第一步,真正麻烦的是把一条 URI 拆成"脚本路径"和"附加路径信息"两部分,还要保证这个拆分过程不会被../之类的路径穿越手法绕过。我见过不止一个自己手写的迷你服务器栽在这里,文件读得到处都是。

2.1 cgi_pattern 匹配的是 URI,不是文件路径

这一点必须先纠正一个常见误解。cgi_pattern匹配的对象是请求行里那个/cgi-bin/hello.cgi?name=x的路径部分(已经去掉查询串、已经做过一次 URL 解码),而不是磁盘上的绝对路径。原因很实际:如果拿磁盘路径去匹配,那么/..%2f..%2fetc/passwd这类编码过的请求在你解码之前和之后会得到两种不同的匹配结果,攻击面立刻就出来了。

shttpd 的做法是先把 URI 里的%XX解码、把..之类的危险片段过滤或拒绝,再拿干净的 URI 去跟 pattern 比对。pattern 本身用的是通配符语义,**.cgi$表示"以 .cgi 结尾",前缀的**可以跨目录匹配。这个语法不算优雅,但胜在实现只有几十行,用一次回溯就够。

2.2 PATH_INFO:那个被大多数人忽略的拼接规则

CGI 规范里有一个概念叫PATH_INFO,指的是脚本路径之后、"看起来像路径但不是脚本本身"的那一段。举个例子,请求/cgi-bin/report.cgi/2024/summary的时候,SCRIPT_NAME应该是/cgi-bin/report.cgi,PATH_INFO应该是/2024/summary。脚本拿PATH_INFO做路由,就能用一个可执行文件实现多种页面,这也是早期很多 CGI 程序做伪 REST 的方式。

问题在于服务器怎么知道脚本到哪里结束。shttpd 这类实现的思路是"从长到短试":先把整个路径当脚本去stat,不行就砍掉最后一段再试,直到找到一个存在的、可执行的普通文件为止,剩下的部分归入PATH_INFO。这个循环看着笨,但兼容性最好,因为它天然支持脚本被放在任意深度目录里。代价是最坏情况下要做 N 次系统调用,N 是路径段数,实际场景里通常两三次就命中了。

环境变量典型取值取值来源
SCRIPT_NAME/cgi-bin/report.cgiURI 中被 stat 命中的那一段
SCRIPT_FILENAME/var/www/cgi-bin/report.cgi文档根目录 + SCRIPT_NAME 拼接
PATH_INFO/2024/summary命中脚本之后剩余的那一段
PATH_TRANSLATED/var/www/2024/summary文档根目录 + PATH_INFO(不是脚本目录!)

PATH_TRANSLATED这一行值得单独说一句:它的拼接基数是文档根目录,不是脚本所在目录。这个细节在看源码时很容易看错,一旦搞错,脚本里读文件的位置会全部偏掉,而且症状很隐蔽——文件读不到,脚本却没有报错退出,只是返回空内容。

2.3 可执行位、目录与后缀:三个必须挡掉的情况

判定通过之后,真正 exec 之前还有三道检查,任何一道漏掉都会让你在安全审计里挨批。

第一道是必须确认目标是一个普通文件且带可执行权限。用stat拿到的st_mode,S_ISREG且mode & S_IXUSR才算过关。这道检查能挡掉"目录名刚好以 .cgi 结尾"这种诡异情况,也能把 403 和 500 区分开——没有执行权限是权限问题,不是服务器内部错误,返回码不该混。

第二道是路径穿越。哪怕你前面已经做过一次过滤,拼接完SCRIPT_FILENAME之后最好再做一次规范化,确认最终路径仍然落在文档根目录前缀之下。用realpath把两边都解析成绝对路径再比对前缀,是最省事的写法。

第三道跟平台相关。Windows 上的可执行文件必须有.exe之类的后缀,CreateProcess才能直接拉起来;而 Unix 上一个没有 shebang(#!开头)的文本文件就算有执行权限,exec 会直接失败返回ENOEXEC。这两种失败在日志里长得几乎一样,排查的时候容易浪费时间。我的习惯是在 exec 失败的分支里把errno打出来,一秒钟定位。

3. 环境变量拼装:CGI 规范里最容易被偷工减料的一环

CGI 之所以能跨语言通用,靠的就是"请求信息全放环境变量、请求体走标准输入"这个约定。这个约定朴素到有点可爱,但也意味着服务器这边必须老老实实把十几个变量拼对。我读过的几个迷你服务器实现里,环境变量通常是最先被砍的部分——只填QUERY_STRING和REQUEST_METHOD两个就敢上线,跑简单 demo 没问题,一旦脚本稍微复杂点就出错。

3.1 必填变量清单与 shttpd 的实际取值

按规范,一个"及格"的 CGI 实现至少要提供下面这些。我把每个变量的真实来源和缺失后果都列出来,方便你对照自己的实现查漏:

变量来源缺失后的典型症状
REQUEST_METHOD请求行第一部分脚本无法区分 GET/POST,多数框架直接报错
QUERY_STRINGURI 里?之后的部分(不解码)表单 GET 提交收不到参数
CONTENT_LENGTH请求头 Content-LengthPOST 时脚本读不到或读错请求体
CONTENT_TYPE请求头 Content-Type上传和 JSON 提交解析失败
SCRIPT_NAME匹配到的脚本 URI脚本生成自引用链接时出错
SCRIPT_FILENAME磁盘绝对路径部分实现依赖它定位自身
SERVER_PROTOCOL请求行第三部分判断 HTTP/1.0 还是 1.1 时出错
SERVER_SOFTWARE固定字符串一般只用于页面页脚显示
GATEWAY_INTERFACE固定为 CGI/1.1部分老库会检查
REMOTE_ADDR对端 socket 地址日志和访问控制失效
SERVER_NAME / SERVER_PORT配置或请求头 Host生成绝对 URL 时出错
HTTP_*请求头逐个转换脚本拿不到 Cookie、User-Agent 等

QUERY_STRING有一个细节必须强调:不要解码。按照规范,这个变量的值应该保持 URL 编码的原样,交给脚本自己去解。如果你在服务器这边先解一遍,脚本再解一遍,就会出现双重解码,%2520会变成空格而不是%20,中文参数更是直接乱码。shttpd 在这点上做得是对的,它只做边界识别,不碰内容。

3.2 请求头到 HTTP_ 变量的转换规则

所有客户端请求头都会变成HTTP_前缀的环境变量,规则就三条:名字全大写、-换成_、加前缀。User-Agent变成HTTP_USER_AGENT,Accept-Encoding变成HTTP_ACCEPT_ENCODING。听起来简单,但有两个例外必须记住,否则脚本会拿到重复或冲突的值:

Content-Type和Content-Length这两个头不生成HTTP_前缀的版本,而是直接作为CONTENT_TYPE、CONTENT_LENGTH出现。有的实现图省事两个都生成,脚本框架读到两份可能行为不一致,尤其是 Python 的一些老版本库会优先取HTTP_CONTENT_TYPE,拿到空值当成application/x-www-form-urlencoded处理,上传就挂了。

另一个坑是超长头和超多头的处理。环境变量区是有大小限制的(传统上受ARG_MAX约束,一般几 MB,但有些系统小得多),如果客户端发了一百个 Cookie 或者一个超长的 Authorization,你无脑全塞进去,execve会直接失败返回E2BIG。稳妥的做法是设一个总字节上限,超了就截断或者直接拒绝请求。

3.3 内存布局:为什么这里要用一次预扫描

从工程角度看,拼环境变量最直觉的写法是搞一个char **env数组,每个元素strdup一个KEY=VALUE,最后补个NULL。能用,但对 shttpd 这种追求小体积的实现来说有点浪费——几十次小内存分配,碎片化严重,在几百 KB 堆的嵌入式环境里不是好习惯。

看它的实现思路,是先遍历一遍所有要加的变量,把KEY=VALUE\0的长度累加起来,一次性malloc出一块连续内存,第二遍再把字符串挨个拷贝进去,同时把每个字符串的起始地址写进指针数组。这样整个环境区只有两次分配(字符串块 + 指针数组),释放的时候两次free就干净了,也不会出现"数组释放了但字符串忘了释放"的经典泄漏。

指针数组的末尾那个NULL千万不能忘。execve是靠它判断环境区结束的,少了它,内核会一直往后面的内存读,直到撞上一个偶然的零字节——症状是子进程启动时崩溃或者环境变量里出现垃圾。这种 bug 在调试器里都很难看出来,因为传进去的指针本身完全合法。

4. 双管道与子进程:fork/exec 那几十行的顺序不能乱

从服务器进程到 CGI 子进程,中间隔着一整套文件描述符的重定向。这段代码通常只有四五十行,但它对顺序的敏感程度超出很多人想象——顺序错一步,表现就是请求卡死、CPU 空转或者子进程读到自己的输出。我在第一次自己写类似逻辑的时候,因为把两个 pipe 的关闭顺序搞反,程序跑了三秒就死锁,查了一下午。

4.1 两根管道的方向与 fd 重定向

需要的是两根单向管道:一根让服务器写、子进程读(子进程的 stdin),一根让子进程写、服务器读(子进程的 stdout)。用pipe()创建出来的是双向 fd 对,fd[0]是读端、fd[1]是写端,所以:

  • 输入管道:服务器持有fd[1](写),子进程dup2(fd[0], 0)后关掉fd[0]和fd[1]
  • 输出管道:服务器持有fd[0](读),子进程dup2(fd[1], 1)后关掉fd[0]和fd[1]

关键在于"关掉"这一步。fork之后子进程继承的是父进程所有 fd 的副本,如果你在子进程里只做了dup2却忘了关原始的管道 fd,那些 fd 仍然指向同一根管道的另一端。后果是父进程读输出时永远等不到 EOF——因为子进程自己还握着一个写端没关,管道不算"所有写端都关闭",read就一直阻塞着。这个坑我第一次踩的时候完全没往这个方向想,还以为是自己没写waitpid。

正确的写法是在子进程分支里,dup2完成之后,把所有已知的管道 fd 都close一遍,包括刚 dup 过来的原始 fd 和不需要的那一端。宁可多关一次(close一个无效 fd 只会返回EBADF,无害),也不要漏。

/* 子进程分支:重定向 stdin/stdout 之后立刻清理 */ if (child) { dup2(in_fd[0], STDIN_FILENO); dup2(out_fd[1], STDOUT_FILENO); dup2(out_fd[1], STDERR_FILENO); /* 让错误信息也能被回传 */ close(in_fd[0]); close(in_fd[1]); close(out_fd[0]); close(out_fd[1]); execve(script_path, argv, envp); _exit(127); /* exec 失败必须立刻退出 */ }

把stderr也重定向到输出管道这个选择值得单独说。好处是脚本里的错误信息(Python 的 traceback、Shell 的command not found)会一起流回客户端,调试时能看到真实原因;坏处是这些内容会混在 HTTP 响应体里,如果脚本已经发过响应头了,客户端会收到一堆乱码。我的做法是:开发环境重定向到 stdout,生产环境重定向到/dev/null或者一个日志文件。shttpd 默认走的是前者,这也是它适合调试的原因。

4.2 GET 请求里必须关掉 stdin 的写端

这一点极其容易被忽略,因为它不影响 GET 请求的"正确性",只影响"能不能结束"。一个 GET 请求没有请求体,所以子进程理论上不该读 stdin。但很多脚本框架是条件反射式地"如果 CONTENT_LENGTH 存在就读 stdin",而有些实现干脆无脑读一次。如果你这时候还握着一根打开的写端,脚本的read就永远等不到 EOF,整个请求挂在那里直到超时。

shttpd 的处理是:请求方法不是 POST(或者没有 Content-Length)的时候,创建完管道立刻close掉输入管道的写端,让子进程读到的是"写端已关闭"的状态,read直接返回 0。这是最省事的做法,不需要给脚本发任何信号。

同理,请求体转发完之后也必须关掉写端。顺序是:服务器write(fd, body, len)若干次,全部写完之后close(fd),然后再开始等子进程的输出。如果你一边写一边读,在请求体很大、输出也很大的场景下会互相阻塞——服务器在write时管道满了被阻塞,子进程在write输出时管道也满了被阻塞,经典死锁。要避免只能上select或者多线程,这对 shttpd 来说就不划算了,所以它的选择很明确:先把请求体写完、关掉、再读输出。这个取舍代价是"请求体巨大且脚本边读边输出的场景"会慢,但那种场景本来就该用 FastCGI。

4.3 工作目录与 execve 的路径选择

还有一个不起眼但影响很大的点:子进程的当前工作目录。fork出来的子进程继承的是服务器的 cwd,通常是启动目录,可能是/。如果脚本里用了相对路径(open("config.json")、require "./lib.pl"),你永远找不到文件。

两种解法:一是 exec 之前chdir到脚本所在目录,二是 chdir 到文档根目录。CGI 规范里没有强制规定,但实际经验是跟随主流服务器:Apache 和 nginx 都把 cwd 设成脚本目录。所以推荐chdir(dirname(script_path)),这样require "./xxx.pl"这类相对引用才能正常工作。Chdir 失败不影响执行,但要记日志。

execve的第一个参数要用绝对路径。用相对路径依赖 cwd,而你刚刚才改过 cwd,逻辑上绕了一圈,容易出奇怪问题。另外execve用的环境数组是完整替换,不是追加——这意味着如果你想让脚本拿到PATH、HOME、LANG这些基础变量,必须自己塞进去。少了PATH,脚本里调用ls、date这类命令就会全部失败,因为 shell 找不到它们;少了LANG,中文输出可能变成问号。我一般会把服务器进程原有的environ里挑几个安全的变量(PATH、TZ、LANG)合并到 CGI 环境里,再把PATH默认设成/usr/local/bin:/usr/bin:/bin保底。

5. 子进程输出怎么变成合法 HTTP 响应

从这里开始进入我觉得 CGI 实现里最有"手艺感"的部分。CGI 脚本输出的是一段"带头的裸数据",格式大致是Content-Type: text/html\r\n\r\n<html>...,它并不是一个完整的 HTTP 响应,没有状态行、没有版本号、也没有 Connection 头。服务器的任务是把这段东西改造成客户端能接受的响应,同时补上所有缺失的字段。

5.1 头体分离:第一个空行在哪

判断头体分界点是整个回传逻辑的第一步。规范说头部以空行结束,但现实里有三种写法:\r\n\r\n(标准)、\n\n(很多脚本语言的print加上某些平台的换行转换)、以及混合。稳妥的实现要同时识别这三种,取最早出现的那个位置作为分界。

shttpd 的做法是在读到的缓冲区里找这两个序列,如果这一批数据里没找到,就继续读下一批,直到找到或者缓冲区满。这里必须设一个上限——比如 8KB 或 16KB——防止一个恶意脚本狂发头信息把服务器内存吃光。超过上限还没找到空行,就当协议错误处理,返回 502 或者直接断开。

找到分界点之后,前半段是 CGI 头,后半段是响应体,而缓冲区里可能已经不只有一批数据了,所以这个"半个缓冲区"的状态要能跨多次read保留下来。我见过的简易实现里,有一半是把头部解析和正文转发写成两个独立函数,中间靠一个全局缓冲区传递,结果并发一上来就串包。正确做法是把状态挂在连接对象上。

5.2 Status、Location 与默认 Content-Type 的特殊规则

CGI 头域里有三个字段需要特殊对待,处理错了就会出现"明明脚本说 302 但浏览器显示 200"这类问题。

Status字段是脚本指定 HTTP 状态码的唯一途径,格式是Status: 404 Not Found。服务器要把它解析出来,用其中的数字和原因短语构造响应行HTTP/1.1 404 Not Found,并且不能把这个头原样转发给客户端(它不是标准 HTTP 头)。

Location字段有个隐含语义:如果脚本只给了Location没给Status,那默认状态码应该是 302 而不是 200。很多脚本作者靠这个省一行代码,服务器不支持的话,浏览器收到 200 + Location 会原地不动,用户点了按钮页面没反应。

Content-Type缺失时要有默认值。规范没规定,实际做法是text/plain或者text/html,我倾向后者,因为绝大多数 CGI 输出就是 HTML 片段。这个默认值看似无所谓,其实影响不小——如果缺了它又没给默认,浏览器会靠嗅探猜类型,某些情况下会猜成下载而不是渲染,用户体验直接崩。

5.3 流式转发与 Content-Length 的取舍

响应体怎么发给客户端,有两套思路。理想情况是脚本给出Content-Length,服务器按长度精确转发,还能顺便支持 keep-alive;现实情况是大部分脚本根本不算长度,直接边生成边输出。

对于没有长度的情况,最稳的方案是用Connection: close加 chunked 或者干脆靠关闭连接来表示结束。shttpd 的选择更简单:直接发Connection: close,然后一直读到 EOF,读到多少转多少,最后关连接。这个做法牺牲了 keep-alive 复用,但换来的是实现极简,而且不会出现"长度算错导致响应截断"的问题。

转发用的缓冲区大小也值得一调。太小(比如 128 字节)会导致大量系统调用,脚本输出 100KB 页面要走上千次read/write;太大(比如 1MB)在嵌入式设备上直接吃光内存。经验值是 4KB 到 16KB,8KB 是个不错的折中,跟大多数系统的页大小也对得上。shttpd 内部用的就是这个量级,你在自己的实现里可以直接照抄。

6. 并发模型下 CGI 的真实压力点

前面讲的都是单请求路径,真跑起来之后,问题几乎全出在并发和进程管理上。shttpd 用的是单线程事件循环(select或poll)加非阻塞 socket,这套模型处理静态文件很舒服,但一碰上 CGI 就开始别扭——因为子进程的管道 fd 也可以放进select的监听集合里,可子进程什么时候结束、什么时候该杀,事件循环本身给不了答案。

6.1 把管道 fd 塞进 select 之后的新问题

思路很自然:fork之后把输出管道的读端设成非阻塞,加入select的读集合,有数据就转发给客户端,没数据就等着。但这里有个隐藏的坑——select报告"可读"不等于"有数据",它也可能表示"对端已关闭"(EOF)。如果你不区分这两种情况,要么在 EOF 时死循环空转(CPU 100%),要么把最后一批数据丢掉。

正确的判断是:read返回大于 0,说明有数据;返回 0,说明对端关闭,这时候该收尾了;返回 -1 且errno是EAGAIN/EWOULDBLOCK,说明是假唤醒,继续等。这三个分支必须齐全,缺一个都会出问题。

另外,select本身对 fd 数量有FD_SETSIZE的限制(通常是 1024),而且每次调用都要把整个 fd 集合从用户态拷到内核态。对 shttpd 这种预期并发几十上百的场景够用,再往上就得换epoll/kqueue了。但换完之后 CGI 的逻辑并没有变简单,反而因为边缘触发模式要处理"一次事件读完所有数据"而更麻烦,这也是我对小项目坚持用select的原因。

6.2 僵尸进程:不收尸的代价比你想的大

子进程退出之后,如果父进程不调用wait或waitpid,它会变成僵尸,占着一个进程表项不放。单个僵尸无害,但一个每天跑几百次 CGI 的服务器,几天下来进程表就能被塞满,到时候fork直接返回EAGAIN,所有 CGI 请求全挂。这个故障来得悄无声息,因为静态文件请求完全不受影响。

处理方式有两种。偷懒的做法是在启动时signal(SIGCHLD, SIG_IGN),让内核自动回收,一行搞定。但这么做的副作用是你再也拿不到子进程的退出状态,脚本崩溃时(退出码非 0)你完全不知道,日志里干干净净。而且SIG_IGN的行为在不同系统上有细微差异,某些情况下还会影响waitpid的语义。

稳一点的做法是装一个SIGCHLD处理函数,里面循环调用waitpid(-1, &status, WNOHANG)直到返回 0 或 -1。注意WNOHANG不能少,否则信号处理函数里会阻塞;循环也不能少,因为多个子进程同时退出时信号可能被合并,一次信号对应多个待回收进程。信号处理函数里能做的事情很有限(异步信号安全),所以一般只是置一个标志位,真正的回收放到事件循环里做。

6.3 超时杀进程:什么时候该动手

脚本可能死循环、可能等一个永远不来的网络响应、可能被自己写的read卡住。服务器必须有超时机制,否则连接和进程都会堆积。shttpd 的连接对象上有个超时时间戳,每次有数据往来就刷新,超过阈值就断开连接。

断开连接之后还有一步:杀掉子进程。顺序是先close管道,再给子进程发SIGTERM,等一小会儿(比如 100ms)还没退出就SIGKILL。先关管道的好处是很多脚本会在管道关闭后因为SIGPIPE或写失败自己退出,省得你动手;直接上SIGKILL虽然干净,但脚本没机会清理临时文件,跑久了/tmp会被塞满。

还有一个容易漏的点:SIGPIPE信号本身。如果子进程已经退出,服务器还往输入管道里写请求体,内核会给服务器进程发SIGPIPE,默认行为是直接杀死服务器。所以要么在启动时忽略这个信号(signal(SIGPIPE, SIG_IGN)),要么在写之前用MSG_NOSIGNAL。忽略是最省事的,几乎所有的服务器程序都会在main函数开头加这一行。

7. 我调试 CGI 时踩过的几个坑

前面讲的都是"读源码读出来的道理",这一节讲几个真刀真枪调出来的教训。这些问题的共同点是:日志里信息极少,症状和原因之间隔了好几层,不看源码根本猜不到。

7.1 环境变量没传对,脚本跑到了错误的解释器

有次部署一个 Python 脚本,本地跑得好好的,放到设备上就返回 500,日志里只有exec failed。查了半天发现是 shebang 写的是#!/usr/bin/env python3,而env是靠PATH环境变量找python3的。我构造 CGI 环境的时候只塞了 CGI 相关的变量,把PATH忘了,env找不到python3,直接退出返回 127。

这个坑的教训是:execve的环境是完整替换,不是继承。凡是被执行程序可能依赖的基础变量(PATH、HOME、TMPDIR、LANG、TZ),要么从父进程的environ里挑出来合并进去,要么显式设一个合理默认值。我现在写这类代码,会固定加三个:PATH=/usr/local/bin:/usr/bin:/bin、HOME=/tmp、LANG=C.UTF-8。最后一个尤其重要,缺了它中文输出会变成一堆问号,而且不是所有脚本都会主动设编码。

7.2 没有 Content-Length,浏览器一直转圈

另一个反复出现的现象是页面内容全都收到了,但浏览器标签页上的加载动画一直不停,几十秒后才结束。原因是没有发Content-Length,也没有正确关闭连接——服务器写完数据之后忘了shutdown(sock, SHUT_WR),或者把连接留在 keep-alive 状态等着下一个请求,浏览器就一直等"还有没有后续数据"。

排查这类问题我一般开curl -v看两件事:响应头里有没有Content-Length或Transfer-Encoding: chunked,以及连接是不是在数据发完后被关闭。两个都没有,那就是典型的"响应边界不明确"。修法前面说过:要么算长度,要么明确写Connection: close并在发完后关闭 socket。

7.3 脚本输出被缓冲,导致响应截断

还有一个特别隐蔽的坑:脚本在函数中间print了内容,日志里能看到,但客户端收到的响应只有一半。查下来是脚本语言的输出缓冲区问题——stdout接的是管道而不是终端,默认是全缓冲(4KB 或 8KB),脚本进程被SIGKILL或者异常退出时,缓冲区里的内容直接丢了。

严格说这不是服务器的 bug,但服务器可以缓解:把SIGTERM到SIGKILL之间的等待时间给足(比如 500ms),让脚本有机会 flush;或者在文档里提醒脚本作者加sys.stdout.flush()、setvbuf(stdout, NULL, _IONBF, 0)。我自己的习惯是在写 CGI 脚本时,第一行就先关掉输出缓冲,这个成本几乎为零,能省掉一大堆莫名其妙的"内容丢失"工单。

7.4 不同平台下的进程创建差异

最后提一个跨平台的问题。Unix 上fork之后子进程几乎原样复制父进程,改完 fd 就能execve;Windows 上没有fork,得用CreateProcess,把 stdin/stdout 通过STARTUPINFO里的hStdInput/hStdOutput传进去,管道要用CreatePipe并且设成可继承。两套 API 的语义差异很大,尤其是"文件描述符继承"这件事,Windows 默认不继承,得在SECURITY_ATTRIBUTES里显式打开bInheritHandle,否则子进程拿到的是无效句柄。

shttpd 在这一点上做了一层薄封装,把"启动进程"抽象成一个统一函数,Unix 侧走fork+execve,Windows 侧走CreateProcess。这层封装的价值在于让上层的 CGI 逻辑完全不用关心平台差异——环境数组、命令行、管道 fd 都是同一套参数。如果你打算把这套实现移植到别的平台,这个抽象层是最值得保留的部分。

另外 Windows 上还有个细节:CreateProcess要求命令行是单个字符串,而 Unix 那边是argv数组,所以你得自己写一个带引号转义的拼接函数。脚本路径里带空格是最常见的翻车点,"C:\Program Files\..."不加引号会被拆成两段,CreateProcess报"找不到文件",而错误码跟"文件不存在"一模一样,很容易误判。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询