元旦前后北京降温,我突然想看看自己能不能用纯C写一个命令行天气查询工具,不依赖任何第三方网络库,只靠操作系统提供的Socket接口。折腾了两个晚上,工具能跑了,输入城市名就能拿到实时温度、体感温度和天气描述,过程比我想象中更能暴露问题。今天把整个项目的代码结构和HTTP请求部分拆开讲一遍,包括每行代码为什么那样写,以及我真实踩过的坑。如果你也是那种喜欢把网络协议用手动挡过一遍的人,这篇应该能帮你省不少调试时间。
1. 为什么我要用C语言写一个天气查询工具
这年头用Python写天气查询只要一句 requests.get(),用Shell更简单,curl wttr.in/beijing 就完事了。但那样写完之后,你对网络通信的理解还是停留在“调库”层面。用C语言写这个工具,除了能拿到天气数据,更重要的是把TCP连接建立、HTTP报文格式、响应解析这些原来被封装得看不见的东西,一层层亲手拆开看清楚。
1.1 选 C 语言而不是 Python/Shell 的原因
C语言标准库里没有HTTP客户端,Socket操作也全部暴露在底层。socket()、connect()、send()、recv() 这些函数就是你唯一能用的工具。这个过程很像开车,自动挡当然舒服,但只有开过手动挡,才真正明白离合器和换挡逻辑是怎么回事。
具体到网络编程里,你必须搞清楚几个问题:
- 域名是怎么变成IP地址的
- TCP连接到底是怎么建立的
- HTTP请求的文字排版长什么样
- 服务端返回的数据什么时候算结束
- 收到一段响应之后,怎么把JSON内容抠出来
这些知识点在高级语言里全被封装掉了,但在C语言里每个都得自己处理。一次项目下来,你会对计算机网络教材里的那些概念有真实的体感,而不是只会背概念。
另外还有一个很实际的价值:编译产物是一个不依赖解释器的二进制文件,部署到只有 gcc 和基础库的精简Linux服务器上也能跑。这对某些受限环境下的日常小工具需求很友好。
1.2 工具的最终能力与使用场景
这个天气查询工具的实际使用方式是这样的:
bash复制./weather beijing
终端返回北京的天气描述、当前温度、体感温度、湿度和风速,整个过程大概一两秒。它没有任何图形界面,输出就是纯文本,非常适合放在终端里快速查看。
我选择 wttr.in 作为数据源,根本原因有两个:
- 它提供纯HTTP接口,不需要API Key,也没有繁琐的注册流程
- 返回的JSON结构对新手足够友好,字段命名清晰,比如
temp_C、FeelsLikeC、humidity
这里要说明一件事:因为C语言标准Socket不支持TLS加密,这个工具只演示HTTP接口调用。如果你要接真实商业天气服务,一般走HTTPS,那还需要在Socket之上加一层OpenSSL或者做TLS握手,那是另一个层级的话题。这篇文章先把HTTP这条主线讲透。
整个项目适合什么人群?学过C语言基础语法,知道指针、结构体、文件操作,但还没接触过网络编程的开发者。也适合那些用过 curl 但想知道底层发生了什么的人。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 项目文件怎么拆:一个适合练手的模块划分
写这个小工具的时候,我给自己定的规矩是:哪怕项目再小,也要按职责拆文件。原因是网络编程天然分三层,TCP连接管连通,HTTP报文管格式,JSON解析管数据提取,如果全塞在 main.c 里,用不了多久就成一团乱麻。
2.1 目录结构与模块职责
最终的项目结构是这样的:
text复制weather/
├── main.c
├── tcp.h
├── tcp.c
├── http.h
├── http.c
├── json.h
├── json.c
└── Makefile
每个文件的定位非常明确:
| 文件 | 职责 | 核心内容 |
|---|---|---|
main.c |
命令行入口、流程编排 | 接收城市参数,调用HTTP层,解析并打印天气 |
tcp.h / tcp.c |
TCP连接管理 | DNS解析、创建socket、connect、设置超时 |
http.h / http.c |
HTTP请求与响应接收 | 构造GET请求,发送,接收完整响应 |
json.h / json.c |
轻量JSON字段提取 | 在响应体中找到指定键名并取值 |
Makefile |
构建脚本 | 编译链接生成 weather 可执行文件 |
这样拆的好处是,出了问题能快速定位:网络连不上就看 tcp.c,请求被拒绝就看 http.c,数据解析不对就看 json.c。模块之间的依赖关系也简单清晰:main 调 http,http 调 tcp,json 被 main 使用。
2.2 各模块之间的数据流
整个程序的数据流可以用一条链路描述:
text复制main(城市名) -> 拼接HTTP路径 -> http_get() -> tcp_connect() 建立连接
-> send() 发送HTTP GET请求 -> recv() 循环接收响应
-> 返回完整响应字符串给 main -> 在 \r\n\r\n 后截取出响应体
-> json 模块提取 temp_C、FeelsLikeC、value 等字段 -> printf 打印
这里有一个关键设计决定:http_get() 把整个响应字符串一次性返回给调用方,而不是流式处理。原因是天气接口的响应体一般只有几十KB,内存完全够用,一次性读入可以让上层解析逻辑变得非常简单。
http 层只负责“把HTTP请求发出去,把HTTP响应完整拿回来”,完全不知道天气是什么。真正理解天气数据结构的是 main 和 json 模块。这种分层方式在小型项目里看起来有点“过度设计”,但一旦你扩展成多个API调用,就会发现每一层都还能独立复用。
3. TCP连接建立:从域名到socket的完整链路
TCP连接是通信的基础。在C语言里,连接一个域名的主机不像Python那样一句 socket.create_connection 就结束,你需要自己处理域名解析、地址遍历、连接失败重试等一系列事情。
3.1 getaddrinfo 解析域名与端口
连接一个域名,第一步是把域名和端口转换成 struct sockaddr 这类的地址结构。现代C网络编程推荐用 getaddrinfo(),而不是老版本的 gethostbyname()。原因有几个:getaddrinfo 线程安全、支持IPv4/IPv6、同时把端口也一起解析好。
核心代码在 tcp.c 里:
c复制#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#include <sys/time.h>
int tcp_connect(const char *host, int port) {
char port_str[8];
struct addrinfo hints;
struct addrinfo *res = NULL;
struct addrinfo *p = NULL;
int fd = -1;
snprintf(port_str, sizeof(port_str), "%d", port);
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_INET; // 只使用 IPv4
hints.ai_socktype = SOCK_STREAM; // TCP 流式套接字
hints.ai_protocol = IPPROTO_TCP;
int rc = getaddrinfo(host, port_str, &hints, &res);
if (rc != 0) {
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rc));
return -1;
}
// 后续代码在 3.2 中继续
}
注意 getaddrinfo 的第二个参数 port_str 是字符串,不是整数。我一开始直接传了整数 port,编译器只有警告,运行时解析失败,排查了好久才发现这个接口的“脾气”。把端口先 snprintf 成字符串再传进去,是最稳妥的写法。
hints.ai_family = AF_INET 表示只解析IPv4地址。如果你想兼容IPv6,可以改成 AF_UNSPEC,程序会遍历返回的所有地址。但对于天气查询这种简单场景,限制IPv4能减少很多麻烦。
3.2 创建套接字并connect
拿到地址链表后,真正的连接过程是循环尝试:
c复制 for (p = res; p != NULL; p = p->ai_next) {
fd = socket(p->ai_family, p->ai_socktype, p->ai_protocol);
if (fd == -1) {
continue;
}
if (connect(fd, p->ai_addr, p->ai_addrlen) == 0) {
break;
}
close(fd);
fd = -1;
}
freeaddrinfo(res);
if (fd == -1) {
fprintf(stderr, "无法连接到 %s:%d\n", host, port);
return -1;
}
// 设置接收超时,避免 recv 永久阻塞
struct timeval tv;
tv.tv_sec = 10;
tv.tv_usec = 0;
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
return fd;
}
这里有几个细节值得展开。
getaddrinfo 返回的是一个链表,因为一个域名可能对应多个IP地址,比如负载均衡或者IPv4/IPv6并存。操作系统帮你把这些都解析出来,你需要逐个尝试连接,直到成功为止。这就像拨一个多号码的客服电话,第一个占线就试下一个。
如果 socket() 创建成功但 connect() 失败,必须 close(fd),否则文件描述符泄漏。别小看这个操作,长时间运行的工具泄漏几个fd,最终会把自己搞崩溃。
设置接收超时是实战中非常重要的一步。如果服务端连上了但不返回数据,recv() 会一直阻塞,程序就卡死了。SO_RCVTIMEO 设置10秒超时后,recv 超时会返回 -1 并且 errno 变成 EAGAIN 或 EWOULDBLOCK,这就给你一个明确的错误信号。
3.3 连接阶段最容易踩的坑
我从这个项目里总结出连接阶段最容易踩的三个坑。
第一个是忘记 freeaddrinfo()。一旦拿到 res 并完成连接,这个链表占用的堆内存必须释放。不释放就是内存泄漏,再加上循环重试,很快就会暴露问题。
第二个是没有区分 socket() 失败和 connect() 失败的逻辑。如果 socket() 失败,通常意味着系统文件描述符耗尽或权限问题;如果 connect() 失败,可能是网络不通或者对端拒绝连接。两种错误的原因完全不一样,排查方式也不同。
第三个是 connect() 本身可能阻塞很久。默认情况下,connect 是一个阻塞操作,如果对端IP不可达,可能要等几十秒甚至更久才超时。更好的做法是配合 poll()/select() 或者非阻塞connect设置主动超时,但对于这次演示项目,SO_RCVTIMEO 已经足够覆盖主要的卡住场景。
4. 手写HTTP GET请求:格式、头字段与发送细节
TCP连接建立之后,就该说“人话”了。HTTP请求的本质是一段有格式的ASCII文本,服务器解析这段文本后决定返回什么内容。
4.1 HTTP请求由哪几部分组成
一个标准的HTTP/1.1 GET请求分为三部分:请求行、请求头、空行。我实际发给 wttr.in 的请求文本长这样:
text复制GET /beijing?format=j1 HTTP/1.1
Host: wttr.in
User-Agent: CWeatherCli/1.0
Accept: application/json
Connection: close
注意每一行都以 \r\n 结尾,而不是 \n。头部结束之后,必须有一个额外的空行 \r\n,表示请求头结束,接下来是请求体(GET请求没有请求体,所以直接空行)。如果空行没写对,服务器会一直等你把请求头发完,连接就会挂住。
请求行的格式是 方法 路径 HTTP版本。这里的方法用 GET,路径是 /beijing?format=j1,版本号写 HTTP/1.1。
为什么不是 http://wttr.in/beijing 这种URL写法?因为在HTTP协议层,请求行里面的路径是相对于服务器的绝对路径,不需要带域名和协议头。Host 头才是告诉服务器你想要访问哪个域名。
4.2 为什么这里要把关键头字段写清楚
写HTTP请求的时候,头字段不是随便写几个就行的,每个字段背后都有原因。
Host 是HTTP/1.1的必选字段。现在的服务器普遍采用虚拟主机方案,一台服务器上可能同时跑着几十个网站,它就是靠 Host 来区分请求到底要访问哪个站点的。少了这个字段,服务器很可能直接返回400。
User-Agent 用来标识客户端身份。部分服务器会拦截没有UA的请求,或者返回不同的内容格式。我设置成 CWeatherCli/1.0,就是为了让服务器正常返回JSON数据。
Accept: application/json 告诉服务器:“我想要JSON格式的返回。”如果你不指定,wttr.in 默认可能会返回终端友好的ANSI文本,而不是JSON。这个头字段相当于和服务器商量“请给我最适合程序解析的数据格式”。
Connection: close 是我刻意加的。HTTP/1.1默认是长连接,服务器处理完一个请求后会继续保持TCP连接,等待下一个请求。对一次天气查询来说,长连接没必要,反而让响应何时结束变得复杂。加上 Connection: close,服务器处理完请求就会主动关闭TCP连接,这样我用 recv() 收到返回0时,就知道响应读完了,简单直接。
如果接口压缩了数据,比如返回 Content-Encoding: gzip,直接按文本解析看到的是乱码。对 wttr.in 来说,默认不开启压缩。但如果你接别的API,可能需要加一个 Accept-Encoding: identity 头,明确告诉服务器“不要压缩”。这不是必须项,但遇到乱码时值得检查。
4.3 发送请求与接收响应的代码骨架
http.c 的核心函数 http_get 实现如下:
c复制#include "http.h"
#include "tcp.h"
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
static int send_all(int fd, const char *buf, size_t len) {
size_t sent = 0;
while (sent < len) {
ssize_t n = send(fd, buf + sent, len - sent, 0);
if (n < 0) {
if (errno == EINTR) {
continue;
}
return -1;
}
sent += (size_t)n;
}
return 0;
}
int http_get(const char *host, int port, const char *path,
char *response, size_t resp_size) {
int fd = tcp_connect(host, port);
if (fd < 0) {
return -1;
}
char request[1024];
snprintf(request, sizeof(request),
"GET %s HTTP/1.1\r\n"
"Host: %s\r\n"
"User-Agent: CWeatherCli/1.0\r\n"
"Accept: application/json\r\n"
"Connection: close\r\n"
"\r\n",
path, host);
if (send_all(fd, request, strlen(request)) < 0) {
perror("send");
close(fd);
return -1;
}
size_t total = 0;
while (total < resp_size - 1) {
ssize_t n = recv(fd, response + total, resp_size - total - 1, 0);
if (n == 0) {
break;
}
if (n < 0) {
if (errno == EINTR) {
continue;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
fprintf(stderr, "接收数据超时\n");
close(fd);
return -1;
}
perror("recv");
close(fd);
return -1;
}
total += (size_t)n;
}
response[total] = '\0';
close(fd);
return (int)total;
}
为什么用 send_all 而不是一次 send 完?TCP是流式协议,send 并不保证一次把整个缓冲区发出去。虽然对于1KB左右的请求,绝大多数情况下一次 send 就能发完,但严谨的代码必须考虑“部分发送”的情况。send_all 通过循环确保所有数据都进入内核发送缓冲区。
接收端为什么循环 recv?因为网络数据是分块到达的,服务器返回的响应可能横跨多个TCP报文段。一次 recv 只能拿到当前已经到达的一部分,所以必须循环调用直到连接关闭或缓冲已满。
5. HTTP响应体提取与天气数据解析
请求发出去之后,服务器会返回一个结构完整的HTTP响应。这一章讲怎么把响应里的JSON天气数据提取出来。
5.1 响应结构:状态行、响应头、空行、响应体
HTTP响应的格式和请求类似,也是三部分:状态行、响应头、空行、响应体。实际收到的响应开头是这样的:
text复制HTTP/1.1 200 OK
Server: nginx
Date: Sat, 04 Jan 2025 12:00:00 GMT
Content-Type: application/json
Content-Length: 5123
Connection: close
{"request": ..., ...}
状态行第一段是 HTTP/1.1 200 OK,其中 200 表示请求成功。响应头是一系列键值对,直到空行 \r\n\r\n,之后才是真正的JSON响应体。
在 main.c 里,我先用 strstr(response, "\r\n\r\n") 找到头部和响应体的分隔位置,然后 +4 跳过空行,就拿到了响应体字符串。
c复制char *body = strstr(response, "\r\n\r\n");
if (body == NULL) {
fprintf(stderr, "响应格式不正确,未找到头部结束标记\n");
return 1;
}
body += 4;
5.2 判断响应何时结束:Content-Length、chunked、Connection: close
判断响应什么时候算结束,HTTP协议提供了几种方式,很多人在这里卡住。
第一种是看 Content-Length 头。它告诉你响应体一共有多少个字节。收到这么多字节后,响应就结束了。这种方法适合响应体长度明确的情况。
第二种是 Transfer-Encoding: chunked。服务器不确定要发多少数据,于是把响应体拆成一块一块发送,每块开头用十六进制数字标长度。客户端必须解析这些分块,才能拼出完整响应体。wttr.in 一些接口会返回chunked编码。
第三种是我们这次用的方法:请求时带上 Connection: close。服务器发送完响应后主动关闭连接,客户端 recv() 返回0就表示响应结束。这个方案最省事,不用解析 Content-Length 也不用处理chunked,非常适合命令行小工具。
这里给一个提醒:如果你去掉 Connection: close,服务器保持连接,recv() 就不会自动返回0。程序就会一直卡在接收数据。到时候别惊讶,这是TCP长连接的正常行为。
5.3 轻量JSON字段提取:不装库也能读出天气
拿到JSON响应体之后,如果你用完整的JSON库,当然规范。但对于只看几个字段的小工具,手写一个简单的字符串查找函数就够了。我在 json.c 里写了两个实用的函数:
c复制#include "json.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
double json_get_double(const char *json, const char *key) {
char needle[128];
snprintf(needle, sizeof(needle), "\"%s\":\"", key);
const char *start = strstr(json, needle);
if (start == NULL) {
return 0.0;
}
start += strlen(needle);
return strtod(start, NULL);
}
int json_get_string(const char *json, const char *key, char *out, size_t out_size) {
char needle[128];
snprintf(needle, sizeof(needle), "\"%s\":\"", key);
const char *start = strstr(json, needle);
if (start == NULL) {
return -1;
}
start += strlen(needle);
size_t i = 0;
while (*start != '\0' && *start != '"' && i < out_size - 1) {
out[i++] = *start++;
}
out[i] = '\0';
return (int)i;
}
这两个函数的核心逻辑是:构造 "key":" 这种固定格式的字符串,然后在JSON文本里查找它。找到之后,往后读取直到下一个引号,这个就是字段值。
为什么能这么做?因为 wttr.in 返回的JSON里,天气数据字段的值都是字符串形式,例如 "temp_C":"12"、"humidity":"43"、"windspeedKmph":"19"。对这样的结构,字符串查找足够可靠。
不过我必须强调这个方法的适用范围。它完全不懂JSON的嵌套结构,遇到 "temp_C" 出现在数组里多个地方时会取到第一个匹配值。wttr.in 恰好第一个 temp_C 就在 current_condition 里,所以能用。如果你想认真解析更复杂的JSON,建议引入 cJSON 这个成熟库,一行调用解析,性能也好很多。
