上周有个做运维工具的朋友来找我,说他们内部有个 C 写的小采集程序,跑在公司机房里一直好好的,搬到云上之后目标站点开始回 403。排查了两小时,问题不在代码逻辑,在于请求全是从同一个出口出去的,站点一眼就把它认了出来。他要在 C 里加代理,问的第一句话是:这玩意儿是不是得自己写协议栈?
不用。C 语言设置代理 IP 其实就两条路,一条是借 libcurl 这辆现成的车,几十行代码搞定;另一条是自己拿 socket 手搓隧道,麻烦但可控。下面把两条路都走一遍,代码可以直接抄。
路径一:libcurl,参数填对就通了
如果你项目里已经链接了 libcurl(大多数发行版装 libcurl4-openssl-dev 就有,编译加 -lcurl),那设置代理真就是加两三行 setopt 的事。
#include <stdio.h>
#include <curl/curl.h>
#define TARGET_URL "https://目标站点/api/data"
int main(void) {
curl_global_init(CURL_GLOBAL_DEFAULT);
CURL *h = curl_easy_init();
if (!h) return 1;
curl_easy_setopt(h, CURLOPT_URL, TARGET_URL);
/* 关键三行:告诉 curl 走代理 */
curl_easy_setopt(h, CURLOPT_PROXY,
"http://用户名:密码@代理服务器地址:端口");
curl_easy_setopt(h, CURLOPT_PROXYTYPE, CURLPROXY_HTTP);
/* 一定要设,否则网络抖一下线程就挂那儿了 */
curl_easy_setopt(h, CURLOPT_CONNECTTIMEOUT, 10L);
curl_easy_setopt(h, CURLOPT_TIMEOUT, 30L);
CURLcode rc = curl_easy_perform(h);
if (rc != CURLE_OK)
fprintf(stderr, "请求失败: %s\n", curl_easy_strerror(rc));
curl_easy_cleanup(h);
curl_global_cleanup();
return 0;
}
账号密码直接塞进代理地址里是最省事的写法,curl 会自己拼出 Proxy-Authorization 头。如果不想把凭据写在字符串里,用 CURLOPT_PROXYUSERPWD 单独传,日志打出来的时候不会带密码。
有个参数新手最容易漏:CURLOPT_PROXYTYPE。默认值是 CURLPROXY_HTTP,如果你的代理服务商给你的是 SOCKS5 端口,类型不改,握手就会失败,报的还是个含糊的 56 错误。走 SOCKS5 时建议写 CURLPROXY_SOCKS5_HOSTNAME,这样域名解析也交给代理解析,不会出现"用了代理但 DNS 还是本地发出去的"这种半吊子状态。
还有两个小细节值得记一下。一是多线程环境里每个线程用一个独立的 CURL* 句柄,别共享,但 curl_global_init 在程序启动时调一次就够。二是加一句 curl_easy_setopt(h, CURLOPT_NOSIGNAL, 1L),不然在多线程里超时机制会走 alarm 信号,偶发地把进程搞崩。
路径二:socket 手搓一条 CONNECT 隧道
什么情况需要自己写?比如你的程序已经在某个框架里管着连接池,不想再引入一个 HTTP 库;或者你要做的是原始 TCP 转发,需要在加密前后的字节流上做手脚。这时候就得自己跟代理服务器"谈"一次。
谈的方式取决于代理类型。HTTP 代理走 HTTPS 目标时,标准动作是发一个 CONNECT 请求,代理回 200 之后,这条 TCP 连接就变成了一条透传管道,后面的数据你原样收发就行——TLS 握手也是你自己在管道里完成。
int fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in pa = {0};
pa.sin_family = AF_INET;
pa.sin_port = htons(PROXY_PORT);
inet_pton(AF_INET, PROXY_HOST, &pa.sin_addr);
connect(fd, (struct sockaddr *)&pa, sizeof(pa));
/* 拼 CONNECT 请求,Basic 认证的串要提前 base64 编码好 */
char req[512];
int n = snprintf(req, sizeof(req),
"CONNECT %s:443 HTTP/1.1\r\n"
"Host: %s:443\r\n"
"Proxy-Authorization: Basic %s\r\n"
"\r\n",
TARGET_HOST, TARGET_HOST, BASE64_USER_PASS);
send(fd, req, n, 0);
/* 读到空行为止,再判断状态码 */
char buf[4096];
int len = recv(fd, buf, sizeof(buf) - 1, 0);
buf[len] = '\0';
if (strstr(buf, " 200 ") == NULL) {
fprintf(stderr, "隧道建立失败:\n%s\n", buf);
close(fd);
return -1;
}
/* 到这里 fd 就是通往目标站点的管道了 */
这段代码看着简单,坑却不少,下面几个点一个个踩过就知道疼:
响应不一定一次读完。代理回的头可能被拆成两个 TCP 分段,你第一次 recv 只拿到半截 HTTP/1.1 200,剩下的下次才来。稳妥做法是循环 recv,直到读到 \r\n\r\n 为止,同时设个总字节上限,别让服务端把你撑爆。
recv 返回 0 或者 -1 要当回事。返回 0 说明对方关了连接,返回 -1 要看 errno,EINTR 重试、EAGAIN 走 select/poll 等待。很多"隧道莫名断了"的 bug 都是这里把 -1 当正常处理了。
base64 得自己算。C 标准库里没有现成的,写个三十行的编码函数,或者引入一个轻量库。别用字符串拼接硬凑,带特殊字符的密码一定出错。
如果你的代理是 SOCKS5,流程换一套:先发 0x05 0x01 0x02 打招呼,服务端回 0x05 0x02 表示要用户名密码认证,再按 RFC 1929 发账号;认证过了发 CONNECT 命令和目标的地址端口,回包第二个字节是 0x00 才算成功。协议本身不长,但手写起来状态机得清楚,容易在"回包长度不对"上卡半天。
两条路怎么选,给个直接的判断
| 对比项 | libcurl 路径 | 原生 socket 路径 |
|---|---|---|
| 上手成本 | 几十行,半小时能跑通 | 半天起步,SOCKS5 更久 |
| 协议覆盖 | HTTP、HTTPS、SOCKS4/5 全支持 | 要用哪个自己实现哪个 |
| 超时与重试 | 参数一设就有 | 全靠自己写 |
| 连接复用 | 自动 keep-alive 管理 | 自己维护连接池 |
| 可控性 | 只能调开放出来的口子 | 字节级完全可控 |
| 适合场景 | 采集、接口调试、脚本工具 | 自研网络栈、原始字节转发 |
我的建议很朴素:先用 libcurl 把业务跑通,再考虑要不要重写。绝大多数数据采集、接口联调的需求,libcurl 已经是性能过剩了。真正需要自己写 socket 的,通常是已经有了一套自研的连接管理层,为了一个代理功能引入新依赖反而更乱。
几个能让程序少崩几次的细节
代理能不能用,很多时候不取决于代码,取决于你手上的代理质量。写代码前先用命令行快速验一下,省掉一半的排错时间:
# 看这条代理的实际出口和耗时
curl -x http://用户名:密码@代理服务器地址:端口 \
-o /dev/null -s -w "%{http_code} %{time_total}s\n" \
https://目标站点/api/data
返回 200 且耗时在几百毫秒内,说明这条线是通的;返回 407 是认证没过,返回 502、503 多半是代理侧的问题。把这些状态码在程序里分开处理,比统一打一句"请求失败"有用得多。
另外三点:一是给每一类错误配不同策略,407 别重试,换凭据才有用;连接超时可以换一条线再来。二是把代理地址做成配置项,不要硬编码在源码里,C 程序重新编译一次的成本比改配置文件高太多。三是并发数别开满,本地机器几百个线程同时往外打,最先撑不住的是你自己的文件描述符上限,先 ulimit -n 看一眼。
代理池这块,我们项目最后接的是神龙HTTP。它能提供的量级在 3000 万以上正规授权代理 IP,可用率标称 99.9%,IP 纯度 99.8%,覆盖 300 多个城市级定位点,需要按城市换出口的时候直接选就行。协议上 HTTP、HTTPS、SOCKS5 都支持,所以上面两种实现路径都能对接——libcurl 那条把 CURLOPT_PROXYTYPE 改一下就能走 SOCKS5,socket 那条把握手状态机换成 SOCKS5 版本即可。
还有一个对我们挺实用的点:个人中心里有可视化的用量统计和实时监控,能看出哪段时间提取频率异常、哪类请求失败率高。以前调采集程序只能靠翻日志猜,现在先看一眼趋势图,基本能定位到是代码问题还是线路问题。API 兼容主流语言,C 这边拿文档里的示例代码改改就能集成,遇到协议细节卡住,技术团队 7×24 小时都有人接。
常见问题
Q:代码里 setopt 都设了,但抓包发现还是直连,怎么回事?
先确认 curl_easy_perform 用的就是你设置过参数的那个句柄,多线程里句柄被别的线程改过是很常见的坑。其次检查有没有被环境变量 http_proxy 覆盖,可以用 CURLOPT_PROXY 显式设置后调 curl_easy_getinfo 把实际生效的代理读出来打印一下,两分钟能定位。
Q:CONNECT 返回 200 了,但后面发数据就没反应?
九成是响应头没读干净。代理回的 200 Connection established\r\n\r\n 后面如果还带了别的字节,你没读完就开始往里写数据,双方节奏就乱了。老办法:循环 recv 直到遇到 \r\n\r\n,把读到的多余字节存起来当后续数据的开头,问题就没了。
Q:程序跑一段时间内存一直涨,跟代理有关吗?
大概率是句柄或者连接没释放。libcurl 那边每个 curl_easy_init 都要配一个 curl_easy_cleanup;socket 那边每条隧道失败路径上都要 close(fd),尤其是 connect 成功但 CONNECT 被拒的分支,最容易漏。用 valgrind 跑一遍就能看出是哪行漏的。
Q:短效代理 IP 频繁变化,C 程序需要跟着改代码吗?
不需要。提取接口每次返回给你的是一组新的地址和端口,你的程序只要在每次建立连接前重新请求一次接口,拿到新地址填进 CURLOPT_PROXY 或者拿去 connect 就行。建议在本地缓存一小批地址轮着用,别每次请求都去拉一次列表,那样接口调用次数会成为新的瓶颈。






