
1. 为什么我非要用纯C做一个天气查询客户端先把话说在前面用C语言写HTTP客户端确实是有点逆着时代潮流的意思。Python一个requests.get()就完事Node.js一个fetch()也行甚至Shell里curl一把梭。那我为什么还要在C语言上死磕起因是我在一块资源受限的单片机开发板上做嵌入式相关实验板上没有Python运行时也没有JS引擎能用的就是C语言工具链和底层网络协议栈。要在这种环境里拿到天气数据、显示在终端或者小屏幕上除了手动拼HTTP请求、解析响应别无选择。当时我正好刚把翁恺老师的C语言课程刷完PTA上的题也过了不少感觉自己已经掌握了指针、数组、字符串、结构体这些基本功但转头一看——练习题里根本没有“和真实服务器通信”这个环节。我决定自己补上这一课。这个项目做完之后回头看它的价值其实远超“能查天气”本身。写这个客户端的过程中你会被迫理解域名解析、socket通信、HTTP报文格式、响应头字段含义、JSON数据提取、缓冲区管理、内存分配和释放。这些知识点在刷题时是割裂的但真实项目会把它们全部串起来。而且C语言写网络程序最大的好处是没有任何魔法。你发出去的每一个字节都是自己拼的收到的每一个字节都是自己读的。一旦跑通你对HTTP协议的理解会比用高级语言深刻得多。所以这篇文章适合谁适合C语言学到指针和结构体、想做第一个真实网络项目的同学适合需要在嵌入式或资源受限环境下完成数据拉取任务的开发者也适合那些写过很多练习题、但想看看“C语言到底能不能干点真事”的人。我会把整个实现过程拆开讲包括API选型、环境搭建、HTTP请求构造、JSON解析以及我在调试中踩过的好几个坑。2. 准备工作天气数据源选型与开发环境搭建2.1 天气API怎么选免费、稳定、返回JSON做这个项目第一步不是写代码而是挑数据源。天气API有不少但作为学习项目我建议按这几个标准来选免费额度够用个人学习查询频率很低免费档完全足够。返回格式是JSON方便我们拿来做JSON解析练习。不需要OAuth等复杂鉴权最好就是一个API Key拼在URL里降低入门门槛。国内网络环境直接可访问这一步很重要免得在请求阶段就卡住。我最终选了一家和风天气的免费开发版他山之石也可以选其他同类服务。注册后在控制台创建一个项目就能拿到一个API Key。天气接口的URL格式大致是这样https://xxx.example.com/v7/weather/now?location101010100keyYOUR_KEYlocation填城市代码101010100是北京的城市编码如果你想用经纬度传longitude,latitude格式也可以。返回的JSON简化后大概长这样{ code: 200, now: { temp: 23, text: 多云, windDir: 东南风, windScale: 3, humidity: 58 } }我们需要的就是从now这个对象里取出temp、text、windDir这些字段。结构非常清晰很适合用来练习JSON解析。2.2 先把环境跑起来VSCode GCC的配置细节开发环境我用的是VSCode加MinGW GCCWindows和Linux都适用。这一步看着简单但我在配置时遇到过几个问题顺手说一下排查思路。**第一个坑找不到编译器。**在VSCode里写完.c文件按CtrlF5想跑起来结果终端提示gcc不是内部或外部命令。原因通常是装了MinGW但没有把gcc.exe所在目录加到PATH环境变量。确认方法很简单在命令行执行gcc --version能输出版本号说明PATH没问题如果提示找不到去MinGW安装目录下找到bin文件夹把它的完整路径加进系统PATH重启终端再试。**第二个坑VSCode提示“无法打开源文件”。**这个通常不是编译器真的找不到头文件而是VSCode的C/C插件内置的IntelliSense引擎用的是它自己的配置和MinGW的include路径不一致。解决办法是在.vscode/c_cpp_properties.json里显式指定编译器路径{ configurations: [ { name: Win-GCC, includePath: [ ${workspaceFolder}/**, C:/MinGW/include ], compilerPath: C:/MinGW/bin/gcc.exe, intelliSenseMode: windows-gcc-x64 } ], version: 4 }配置完重启VSCode红波浪线基本就消失了。**第三个坑调试器起不来。**想用VSCode的调试功能单靠GCC还不够还需要装GDB。MinGW的bin目录里一般自带gdb.exe如果调试时提示找不到调试器检查一下launch.json里miDebuggerPath是否指向了gdb.exe的完整路径。环境弄好之后我建议先写一个最简单的HTTP GET请求程序打印返回结果不用做任何解析先确保“能发能收”再往下走。3. HTTP请求到底怎么发从socket到报文组装3.1 别急着写代码先用命令行工具确认接口行为很多初学者会直接跳进代码如果返回结果不对根本分不清是接口参数错了、网络不通、还是自己的代码有bug。我的习惯是先用工具打通链路再让C代码去模仿工具的行为。如果你用的是Linux或者Windows的PowerShell可以直接用curl验证curl https://xxx.example.com/v7/weather/now?location101010100keyYOUR_KEY正常情况下会返回刚才那段JSON。接着再加两个参数把响应头和握手细节都打印出来curl -v https://xxx.example.com/v7/weather/now?location101010100keyYOUR_KEY这里能看到完整的请求头、DNS解析过程、TLS握手细节。你会注意到一个关键点URL的协议是https默认走443端口而且传输过程中有TLS加密。C语言裸写socket默认只能发HTTP明文所以在代码实现上要分两条路要么我找的API支持HTTP明文访问很多公共服务为了兼容低端设备会开放80端口的HTTP接口要么就得在代码里引入TLS库。我建议学习阶段选择支持HTTP明文访问的API先跑通整个流程之后再嵌套TLS库作为进阶练习。3.2 域名是如何变成IP的DNS解析的两种写法接下来进入正题。C语言要发起HTTP请求第一步不是拼报文而是把域名解析成IP地址。以前教科书里的经典写法是gethostbyname()但这个函数在老版本里只支持IPv4而且不是线程安全的在新的编译环境里甚至会提示已废弃。现在推荐的写法是getaddrinfo()它统一了IPv4和IPv6的解析逻辑返回一个链表我们遍历这个链表找到第一个能用的地址就行。struct addrinfo hints; memset(hints, 0, sizeof(hints)); hints.ai_family AF_UNSPEC; // 不限定IPv4和IPv6 hints.ai_socktype SOCK_STREAM; struct addrinfo *res NULL; int ret getaddrinfo(HOST, PORT, hints, res); if (ret ! 0) { fprintf(stderr, DNS解析失败: %s\n, gai_strerror(ret)); return -1; }这里的HOST是API服务器域名PORT是字符串80。拿到res后尝试用socket()和connect()逐个连接哪个成功用哪个。提示C语言里.不是普通字符域名、端口、IP地址到处都在用字符串处理。你后面解析HTTP响应头时也要频繁和分隔符打交道字符串函数strstr、strtok、sscanf会是你最得力的助手练习时多留意这些函数的边界行为能少踩不少坑。3.3 手写一个HTTP GET请求报文规范与字节细节TCP连接建立之后我们的任务是按照HTTP/1.1协议规范向服务器发送一段请求文本。最简形式长这样GET /v7/weather/now?location101010100keyYOUR_KEY HTTP/1.1 Host: xxx.example.com User-Agent: c-http-client/1.0 Accept: */* Connection: close逐行解释一下请求行方法GET 空格 路径和查询参数 空格 协议版本最后以\r\n结尾。Host头HTTP/1.1里是必填项服务器靠它区分同一个IP上的多个域名。User-Agent虽然是可选项但很多服务器会拦截空User-Agent的请求最好带上。Connection: close这个头很关键告诉服务器处理完请求后主动关闭连接。这样我们判断响应结束就变得非常简单——read()返回0就意味着收完了。缺点是每次请求都要重新建立TCP连接效率低一些但我们这个学习项目完全够用。请求头和请求体之间有一个空行用\r\n表示。这个空行是必须的少了它服务器会一直等你发body直到超时。C语言拼接字符串的时候最容易犯的错误是数组越界。我一般会一次性把整个请求放到一个缓冲区里用snprintf()格式化char request[512]; int len snprintf(request, sizeof(request), GET %s HTTP/1.1\r\n Host: %s\r\n User-Agent: c-http-client/1.0\r\n Accept: */*\r\n Connection: close\r\n \r\n, path, HOST);注意snprintf的返回值是“如果缓冲区足够大应该写入的字符数”所以发送数据时应该用strlen(request)而不是sizeof(request)否则会把缓冲区后面没用到的内容也发出去我第一次就栽在这。然后调用send()发送再循环调用recv()接收响应send(sockfd, request, strlen(request), 0); char response[4096]; int total 0; int n; while ((n recv(sockfd, response total, sizeof(response) - total - 1, 0)) 0) { total n; if (total sizeof(response) - 1) break; } response[total] \0;一个4000字节的缓冲区对一次天气查询来说基本够用但如果以后想拉更长的内容需要改成动态扩容。后面我会专门讲这个问题。3.4 读取响应的隐藏陷阱文件缓冲区与EOF误区这里我要专门提一个很多人搞错的地方就是“怎么判断响应读完了”。有些教程会教你先读响应头找Content-Length字段然后按长度读完body。这个方法最严谨但代码量稍多也有些教程会偷懒说循环读到recv()返回0就行。基于我们请求头里带了Connection: close服务器处理完会主动断开TCP连接此时recv()返回0循环自然结束。这种方法在HTTP/1.1的close模式下行得通。但如果你把同样的逻辑写成文件读取读到一个EOF就结束那就会踩另一个坑——文件缓冲区和EOF的关系。热搜里有人搜“文件缓冲区 c语言程序”说明这个问题困扰了不少人。原理是这样C语言标准库的fread和fgetc在读取文件时数据会先进入一个用户态的缓冲区EOF标志只有在“缓冲区里的数据全读完、再次尝试读取时发现没数据”才会被置位。所以你在循环里判断feof(fp)来决定是否结束结果往往是多读了一次或者读到末尾时多处理了一轮。正确做法是“先读再判断读到了多少最后判断出错还是EOF”。网络socket的recv()逻辑类似但它不是文件流没有EOF标志位。它的“EOF”就是返回0表示对端关闭了连接。所以一定要先写int n recv(...)再对n做判断不要想当然地用一个所谓的eof函数去判断。这个问题排查起来非常隐蔽我当时调了一晚上最后加了个打印total字节数的日志才明白。4. JSON解析能自己写就先别急着上库4.1 为什么我不建议一开始就引入JSON库拿到服务器返回的JSON字符串后要提取温度、天气、风力这些字段。初学者第一反应往往是“C语言有没有Python那样的json.loads”——标准库里还真没有。然后就想下载一个cJSON、jsmn之类的库。我的建议是如果你是为了学习第一版完全可以自己写一个极简解析器。理由很实在这段JSON结构非常固定字段不多嵌套层级也浅咱们手写五六十行代码就能搞定。这个过程能帮你把字符串搜索、指针移动、内存管理这些C语言基本功练透比直接调库收获大得多。但如果你是在真实项目里或者要解析的JSON结构复杂、嵌套深那就千万别造轮子直接上cJSON稳定可靠。这也是为什么我把两种写法都讲一遍。4.2 手写极简JSON字段提取字符串查找与指针移动先看一个简化的JSON片段{code:200,now:{temp:23,text:多云}}我们要提取now.temp的值。可以分两步走第一步找到temp这个子串然后用指针定位到它后面的冒号再跳过冒号和可能的引号把后面的数字字符串拷出来。C语言代码写出来是这样char *p strstr(json, \temp\); if (p NULL) { printf(未找到temp字段\n); return; } p strchr(p, :); // 找到冒号 p; // 跳过冒号 while (*p || *p \) p; // 跳过空格和左引号 char temp_str[8] {0}; int i 0; while (*p 0 *p 9) { temp_str[i] *p; } temp_str[i] \0; printf(当前温度: %s °C\n, temp_str);这段代码能跑通但它非常脆弱如果服务器返回的字段顺序变了或者temp的值不是纯数字而是带小数逻辑就需要调整。不过作为练习它的意义在于让你亲手操作“字符串指针逐字节移动”的过程这比单纯看指针教程实在多了。字符串字段比如text的值“多云”提取也类似只是判断结束的条件从“非数字字符”变成了“遇到引号”p strstr(json, \text\); p strchr(p, :); p; while (*p || *p \) p; char text_str[32] {0}; i 0; while (*p ! \) { text_str[i] *p; } text_str[i] \0;其中strstr负责定位子串strchr负责定位冒号两个函数搭配使用就是最朴素的JSON字段提取方案。4.3 生产环境选择接入cJSON并处理嵌套对象手写解析器跑通之后如果你觉得每次嵌套层级一深就头大那就该请出cJSON了。这是一个轻量级C语言JSON解析库整个代码就一个.c和一个.h文件下载后丢到工程目录里和主程序一起编译即可。核心用法非常直观#include cJSON.h // 假设json_str是从服务器拿到的整个响应字符串 cJSON *root cJSON_Parse(json_str); if (root NULL) { printf(JSON解析失败: %s\n, cJSON_GetErrorPtr()); return -1; } cJSON *now cJSON_GetObjectItem(root, now); if (now NULL) { printf(没有now字段\n); cJSON_Delete(root); return -1; } cJSON *temp cJSON_GetObjectItem(now, temp); cJSON *text cJSON_GetObjectItem(now, text); if (cJSON_IsNumber(temp)) { printf(温度: %.1f °C\n, temp-valuedouble); } else if (cJSON_IsString(temp)) { printf(温度: %s °C\n, temp-valuestring); } printf(天气: %s\n, text-valuestring); cJSON_Delete(root);有几个细节说一下**第一别漏了cJSON_Delete(root)。**这个库是动态分配内存的解析出的JSON树会占据堆空间。忘记释放循环查询几次内存就会蹭蹭涨。我用valgrind跑过一次一开始每次查询泄漏将近1KB内存补上释放语句后清零了。第二取字段之前先判类型。cJSON_GetObjectItem返回的是cJSON*指针如果字段不存在会返回NULL直接访问-valuestring就是野指针。我第一次跑就是没判空结果一个字段拼错了名字程序直接段错误。**第三字符串转数值。**天气API返回的温度在JSON里可能是个字符串也可能是数字。用cJSON_IsNumber和cJSON_IsString分别处理再atof()转换能兼容更多接口风格。注意cJSON本身不具备网络功能它是纯解析库只处理带{}、[]的JSON文本。网络部分还是要靠我们自己用socket收发。5. 实测拷打我在调试中遇到的三类诡异问题5.1 服务端返回500先分清是哪一层的锅有段时间我在Windows环境的Docker Desktop终端里执行docker search redis结果直接报了这么一段错误request returned 500 Internal Server Error for API route and version http://%2f%2f.%2fpipe%2fdockerdesktoplinuxengine/v1.56/images/search?termredis, check if the server supports the requested api version。这个报错虽然和C语言天气客户端八竿子打不着但它的排查思路对我们写网络客户端有很强的借鉴意义——HTTP 500是服务器内部错误客户端请求可能没问题但一定有什么信息服务端处理不了。回到我的天气查询程序上我第一次跑的时候服务器也返回了500。我当时第一反应是“接口参数写错了”然后回头一行行对照API文档参数没错又怀疑城市编码不对换一个试试还是500。后来我把完整的请求原样贴到curl里执行结果curl能正常返回200。这一下就把问题锁定在“我的请求和curl的请求有什么区别”上。果然检查后发现我的Host头写的是API服务器IP而不是域名有些服务器对Host头不匹配会直接拒绝处理返回500。修正之后立刻变成200。所以以后你们遇到HTTP 500记得按这个顺序排查先用curl模拟同样的请求看能不能成功成功就是你的报文格式问题不成功就是服务端配置或者参数问题接着检查Host头、User-Agent、请求路径三者是否和你注册API时看到的信息完全一致最后再考虑是否触发了频率限制或者鉴权错误。5.2 响应被截断Content-Length和Connection头的关系另一个让我印象深刻的坑是响应“读不全”。表现是天气信息偶尔能打印出来偶尔只能打印一半有时候干脆卡住不输出。问题出在我前面说的接收策略上。我用的Connection: close方式循环读到recv()返回0这依赖服务器在发完数据后主动断开连接。但有些HTTP服务端出于性能考虑即使你请求头声明了Connection: close它还是会复用连接不立即断开。这样我的循环就永远等不到返回0程序卡死在recv()里。解决办法有两个方向方向一严格按Content-Length来读。先读响应头找到以\r\n\r\n结尾的空行然后在这个空行之前的文本块里查找Content-Length字段解析出数值再循环读取直到读满这个长度。这是最标准的HTTP客户端做法。方向二给recv()加超时。用setsockopt()设置SO_RCVTIMEO比如5秒收不到数据就跳出循环避免永久阻塞。学习项目里这个方法最简单还能顺带练习socket超时控制。我的最终版本两个方法都用了优先读Content-Length读满即为结束同时加上socket超时兜底防止因为服务端不回包或者响应格式异常导致整个程序挂死。5.3 gzip压缩为什么你要警惕返回内容突然变成乱码第三个问题很隐蔽是我把API从免费版换到另一个数据源时遇到的明明请求成功了打印出来的却是乱码。排查下来是服务器检测到我的请求头里带了Accept-Encoding: gzip当时的测试请求是从浏览器模版拷来的于是返回了gzip压缩后的二进制数据。我的C程序没有解压能力直接把这些二进制流当成字符串打印自然就是乱码。解决办法最省事的就是主动在请求头里声明Accept-Encoding: identity告诉服务器“我不支持压缩请返回原始格式”。这样服务器就会退回纯文本JSON。如果将来想做压缩传输优化就得引入zlib库先uncompress()再解析但那是后话学习阶段完全没必要引入这种复杂度。5.4 打印日志是最朴素的“调试器”这一路调试下来我最大的体会是C语言没有Python那种一行行交互的REPL环境网络程序又常常在多个环节出错所以学会在关键节点打印日志比任何花哨的调试技巧都重要。我会在以下位置加打印printf([DNS] 解析到IP: %s\n, ip_str); printf([TCP] 连接成功: %s:%s\n, ip_str, PORT); printf([HTTP] 发送请求:\n%s\n, request); printf([HTTP] 接收响应, 共 %d 字节\n, total); printf([HTTP] 响应首行: %s\n, first_line);一开始可能觉得打印太多但当你发现程序连不上服务器时这几条日志能直接告诉你卡在DNS、卡在connect、还是卡在recv。排查效率至少提升一倍。等所有问题都解决想让输出干净一些再把这些调试日志包在宏里或者用条件编译控制。6. 编译、运行与工程化打磨6.1 用Makefile管理多文件工程手写解析器版本只有一个.c文件直接gcc weather.c -o weather就能编译。但接入cJSON之后文件变成了两个以上命令也要写成gcc weather.c cJSON.c -o weather -Wall -O2这时候就值得上一个Makefile了。我也顺手把编译参数写得严一点让GCC帮我抓出潜在问题CC gcc CFLAGS -Wall -Wextra -O2 -stdc11 TARGET weather OBJS weather.o cJSON.o $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $ $(OBJS) weather.o: weather.c cJSON.h $(CC) $(CFLAGS) -c weather.c cJSON.o: cJSON.c cJSON.h $(CC) $(CFLAGS) -c cJSON.c clean: rm -f $(OBJS) $(TARGET)-Wall -Wextra会把代码里未使用变量、类型转换不匹配这类警告全部暴露出来。我第一次打开时GCC提示了三个警告有一个是printf格式字符串用了%d但传了size_t——这种问题在32位和64位平台上表现还不一样现在不做迟早踩雷。6.2 让程序更像个“真正的工具”参数化与错误处理到现在为止城市编码还是写在代码里的常量。一个“真正的工具”至少应该支持从命令行传入城市参数int main(int argc, char *argv[]) { if (argc ! 2) { fprintf(stderr, 用法: %s 城市编码\n, argv[0]); fprintf(stderr, 示例: %s 101010100\n, argv[0]); return 1; } const char *location argv[1]; char path[128]; snprintf(path, sizeof(path), /v7/weather/now?location%skey%s, location, API_KEY); // 后续流程... }这个改动还能顺带练习指针数组argv的使用。热搜里有人搜“C语言 数组 指针 移动 指定位输出 字符”其实argv就是一个字符指针数组argv[1]就是用户输入的第一个参数直接用就行。错误处理方面要给每一步操作设置明确的返回码DNS解析失败返回1TCP连接失败返回2HTTP请求发送失败返回3JSON解析失败返回4。这样脚本调用时只需要检查退出码就能快速定位问题。6.3 进阶方向超时控制、IPv6和可持续扩展的点基本的天气查询跑通之后项目可以往几个方向延伸**加超时控制。**不管是connect()还是recv()都可能因为网络原因长时间阻塞。用select()配合sockaddr_in的端口信息可以实现几秒超时自动放弃避免程序卡死。这是很多生产级网络程序的基础。**支持IPv6。**前面用了AF_UNSPEC大部分情况下能自动选择IPv4或IPv6。想强制测试IPv6可以改成AF_INET6试一遍看看不同服务器在双栈环境下的表现差异。**扩展字段和数据源。**当前只显示了温度、天气、风力方向API返回里还有湿度、能见度、紫外线指数、体感温度等字段试着把它们都解析出来展示或者把API切换到一个支持城市名直接查询的服务省去手动查城市编码的麻烦。**接入终端代码高亮与格式化输出。**把输出的温度用不同颜色显示、对齐字段宽度、支持连续查询多个城市——这些改动更像给终端用户用的工具了。**把纯C移植到嵌入式环境。**如果手头有ESP8266或ESP32开发板把本项目的socket部分替换成厂商提供的网络库API逻辑可以原封不动搬过去。这也是C语言写HTTP客户端最实用的落地场景之一。我实际跑通的代码里显示效果大概是这样当前城市编码: 101010100 天气: 多云 温度: 23 °C 风向: 东南风 3级 湿度: 58%简洁但足够说明问题是真被解决了。最后分享一个我在整个过程中体会最深的小技巧写网络客户端程序无论如何先保证“能向服务器发出一个合法请求”。这一步比什么都重要。一旦服务器给了你响应后面的解析反而是简单的字符串处理难的是在你完全看不到对端的情况下靠日志和报文比对把“发不出去”“发出去了没响应”“有响应但读不全”“读到了但解析出错”这四类问题逐一定位清楚。而等你真的亲手把这一整套流程跑下来再回头看翁恺老师课上的指针、数组、字符串处理你会突然明白原来这些基础语法是拿来干这个的。