1. 从一次文件上传超时说起为什么需要chunked编码去年帮一个做嵌入式设备管理的朋友排查问题他们的C客户端要把设备日志打包上传到后台日志文件小则几十KB大则几百MB。最初用的是最朴素的HTTP POST一次性把整个文件读进内存拼好Content-Length再发出去。小文件没问题一到大文件就出状况内存直接飙到几百MB设备本身内存就紧张跑着跑着就被系统杀掉了就算内存扛得住上传中途网络一抖动整个请求作废只能从头再来。这个场景其实非常典型。C语言写的客户端尤其是跑在资源受限环境里的处理大文件上传时绕不开两个核心矛盾内存占用和传输可靠性。HTTP/1.1引入的Transfer-Encoding: chunked机制正是为了解决我不知道最终要发多少数据或者我不想把数据全塞进内存这类问题而设计的。这篇内容就是围绕一个C客户端实现HTTP POST chunked上传的完整demo展开。我会把chunked编码的报文格式、C语言socket编程的落地细节、分块策略的设计取舍、以及实际调试中踩过的坑一层层拆开讲清楚。适合有基本C语言和网络编程基础、正在做设备端或客户端上传功能的开发者参考。哪怕你之前只写过最简单的HTTP请求跟着思路走也能理解整套机制。先说清楚chunked到底解决什么问题。普通的HTTP POST请求头里必须带Content-Length告诉服务器我这次要发N个字节。服务器收到这个数字就知道读到第N个字节时请求结束。但如果你要上传的数据是动态生成的比如边压缩边发送、边读文件边发送发送前根本算不出总长度这时候Content-Length就没法填了。chunked编码的思路是不预先告诉总长度而是把数据切成一块一块chunk每块前面标明这块有多大最后用一个长度为0的块表示发完了。服务器一边收一边拼不需要提前知道总量。这个机制对C客户端特别友好。你可以开一个固定大小的缓冲区比如8KB循环从文件里读8KB、发8KB内存占用恒定跟文件多大没关系。而且因为是一块块发的理论上可以在每块之间做进度统计、做超时控制灵活性比一次性发送高得多。2. chunked报文的字节级格式拆解要自己手写chunked上传必须把报文格式抠到字节级别不然服务器解析出错你都不知道错在哪。很多人第一次写会想当然地以为chunked就是简单地把数据分段发出去结果服务器返回400排查半天发现是格式少了个回车换行。2.1 请求头里必须声明的两个字段chunked请求和普通POST最大的区别在请求头。普通POST是POST /upload HTTP/1.1 Host: example.com Content-Length: 102400 Content-Type: application/octet-streamchunked请求要把Content-Length去掉换成Transfer-EncodingPOST /upload HTTP/1.1 Host: example.com Transfer-Encoding: chunked Content-Type: application/octet-stream这里有个容易忽略的点Transfer-Encoding和Content-Length不能同时出现。HTTP/1.1规范里明确规定如果两者同时存在Content-Length必须被忽略但很多服务器会直接返回400错误。所以写代码时一定要确保只发Transfer-Encoding: chunked。另外Content-Type是可选的但建议带上告诉服务器你传的是什么类型的数据。传二进制文件用application/octet-stream传JSON用application/json传表单用multipart/form-data。这个字段不影响chunked机制本身但影响服务器怎么处理你发过去的数据。2.2 每个chunk的精确结构chunked的body部分由若干个chunk组成每个chunk的格式是[chunk大小(十六进制)]\r\n [chunk数据]\r\n注意这里的\r\n是CRLF也就是0x0D 0x0A两个字节不是单纯的\n。这是新手最容易踩的坑之一。C语言里写\r\n才是对的写\n服务器会解析失败。举个例子假设你要发送Hello这5个字节对应的chunk是5\r\n Hello\r\n5是十六进制的5表示后面跟5个字节的数据。数据发完后跟一个\r\n。如果要分两块发Hello和World就是5\r\n Hello\r\n 5\r\n World\r\n 0\r\n \r\n最后那个0\r\n\r\n是结束标志。长度为0的chunk表示数据传输完毕后面跟一个空的\r\n有些实现会在这里放trailer头但大多数场景不需要。2.3 十六进制长度字段的坑chunk大小用十六进制表示这个细节很多人会忽略。比如你要发255字节十六进制是FF不是255。发4096字节十六进制是1000。C语言里用%X或%x格式化输出就行char size_buf[16]; int len snprintf(size_buf, sizeof(size_buf), %X\r\n, chunk_size);用%X是大写十六进制%x是小写两种服务器都能识别但建议统一用一种别混着来。另外长度字段本身不带0x前缀就是纯十六进制数字。还有一个边界情况chunk大小理论上可以任意大但实际中不要超过服务器能接受的范围。常见的做法是8KB到64KB一块后面会详细讲怎么选。3. C语言socket层实现chunked发送的完整链路理解了格式接下来就是怎么用C代码把它发出去。这里我用最基础的BSD socket来写不依赖任何第三方HTTP库这样你能看清每一层在做什么。实际项目中你可以用libcurl但自己实现一遍对理解协议帮助极大。3.1 建立连接与发送请求头第一步是TCP连接和发送请求头。这部分和普通HTTP请求没区别#include stdio.h #include string.h #include stdlib.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include netdb.h int connect_to_server(const char *host, int port) { struct hostent *server gethostbyname(host); if (server NULL) { fprintf(stderr, DNS解析失败\n); return -1; } int sockfd socket(AF_INET, SOCK_STREAM, 0); if (sockfd 0) { perror(socket创建失败); return -1; } struct sockaddr_in serv_addr; memset(serv_addr, 0, sizeof(serv_addr)); serv_addr.sin_family AF_INET; memcpy(serv_addr.sin_addr.s_addr, server-h_addr, server-h_length); serv_addr.sin_port htons(port); if (connect(sockfd, (struct sockaddr *)serv_addr, sizeof(serv_addr)) 0) { perror(connect失败); close(sockfd); return -1; } return sockfd; }连接建立后发送请求头。注意请求头以空行结束也就是最后一个头字段后面要跟\r\n\r\nint send_headers(int sockfd, const char *host, const char *path) { char header[1024]; int len snprintf(header, sizeof(header), POST %s HTTP/1.1\r\n Host: %s\r\n Transfer-Encoding: chunked\r\n Content-Type: application/octet-stream\r\n Connection: close\r\n \r\n, path, host); int sent send(sockfd, header, len, 0); return (sent len) ? 0 : -1; }这里Connection: close表示发完就关连接简化处理。如果要复用连接得处理keep-alive和响应解析demo阶段没必要。3.2 分块读取与发送的核心循环这是整个实现的核心。思路是打开文件循环读取固定大小的块每读一块就按chunked格式发出去直到文件读完最后发结束块。#define CHUNK_SIZE 8192 int send_file_chunked(int sockfd, const char *filepath) { FILE *fp fopen(filepath, rb); if (fp NULL) { perror(文件打开失败); return -1; } unsigned char buffer[CHUNK_SIZE]; size_t bytes_read; while ((bytes_read fread(buffer, 1, CHUNK_SIZE, fp)) 0) { // 发送chunk大小十六进制 CRLF char size_line[32]; int size_len snprintf(size_line, sizeof(size_line), %zX\r\n, bytes_read); if (send(sockfd, size_line, size_len, 0) ! size_len) { fclose(fp); return -1; } // 发送chunk数据 if (send(sockfd, buffer, bytes_read, 0) ! (int)bytes_read) { fclose(fp); return -1; } // 发送数据后的CRLF if (send(sockfd, \r\n, 2, 0) ! 2) { fclose(fp); return -1; } } // 发送结束块0\r\n\r\n if (send(sockfd, 0\r\n\r\n, 5, 0) ! 5) { fclose(fp); return -1; } fclose(fp); return 0; }这段代码有几个细节值得说。fread返回实际读到的字节数最后一次可能小于CHUNK_SIZE这没关系chunk大小按实际读到的算就行。%zX里的z是size_t的长度修饰符配合X输出十六进制。如果你的编译器不支持%zX可以强制转换成unsigned int再用%X。send函数不保证一次把所有数据都发出去理论上可能只发了一部分。严谨的写法应该循环发送直到全部发完int send_all(int sockfd, const void *data, size_t len) { const char *ptr (const char *)data; size_t remaining len; while (remaining 0) { ssize_t sent send(sockfd, ptr, remaining, 0); if (sent 0) return -1; ptr sent; remaining - sent; } return 0; }实际测试中对于几KB的小块send几乎总是一次发完但网络状况差或者发送缓冲区满的时候就可能分多次。这个send_all封装建议直接用上省得后面出诡异问题。3.3 接收服务器响应数据发完后要读服务器的响应确认上传成功。因为用了Connection: close可以一直读到连接关闭void recv_response(int sockfd) { char buf[4096]; ssize_t n; while ((n recv(sockfd, buf, sizeof(buf) - 1, 0)) 0) { buf[n] \0; printf(%s, buf); } }响应里第一行是状态码比如HTTP/1.1 200 OK表示成功HTTP/1.1 400 Bad Request表示请求格式有问题。调试阶段把完整响应打出来能快速定位问题。4. 分块大小的选择与内存、性能的平衡CHUNK_SIZE设成多少合适这个问题没有标准答案但有几个因素需要权衡。4.1 太小和太大的代价块太小比如设成64字节那么每发64字节数据就要额外发一个长度行比如40\r\n4字节和一个\r\n2字节协议开销占比接近10%。而且系统调用次数暴增一个1MB的文件要发一万六千多次sendCPU全耗在系统调用上了。块太大比如设成1MB内存占用就上去了。虽然比一次性读整个文件好但在内存紧张的嵌入式设备上1MB的缓冲区可能还是太大。而且块太大单次send阻塞的时间变长进度反馈不及时。4.2 实测中的经验值我试过几种大小在普通Linux设备和嵌入式设备上都跑过结论是8KB到64KB是比较舒服的区间。8KB是很多文件系统和网络栈的默认缓冲区大小对齐这个值往往能获得不错的吞吐。16KB和32KB在大多数场景下表现接近64KB在千兆网络下能明显减少系统调用次数。如果设备内存特别紧张4KB也能用协议开销大概在0.15%左右完全可以接受。如果追求极致吞吐且内存充足128KB甚至256KB也行但收益递减而且要考虑服务器端的接收缓冲区大小块太大可能被服务器拒绝。块大小协议开销占比1MB文件send次数适用场景1KB约0.6%1024内存极度受限4KB约0.15%256嵌入式设备8KB约0.08%128通用场景32KB约0.02%32性能优先128KB约0.005%8大内存设备协议开销的计算方式是每个chunk额外发长度行 \r\n长度行大约4到6字节加上2字节的\r\n总共约6到8字节。以8KB块为例8字节开销除以8192字节约0.1%。4.3 动态调整的思路进阶一点的做法是根据网络状况动态调整块大小。比如开始用8KB如果连续几次send都很快返回说明网络通畅可以逐步增大到32KB如果send开始阻塞说明发送缓冲区满了就减小块大小。这个逻辑实现起来不复杂但在demo阶段不是必须的知道有这个优化方向就行。5. 调试chunked上传时最容易翻车的几个点这部分是我实际调试中踩过的坑每一个都让我多花了不少时间。如果你正在写类似的代码建议对照着检查一遍。5.1 CRLF写成LF导致服务器400前面提过但值得再强调一次。chunked协议里所有的换行都必须是\r\n。C语言里写\r\n没问题但如果你从某个地方复制代码或者用文本编辑器处理过很可能变成\n。服务器收到\n会认为格式非法直接返回400。排查方法用xxd或者od -c把实际发出的字节dump出来看。比如# 在发送端把buffer打印出来 od -c chunk_data.bin正常的chunk应该是35 0d 0a 48 65 6c 6c 6f 0d 0a如果看到35 0a 48 65 6c 6c 6f 0a就是少了0d。5.2 忘记发结束块服务器一直等结束块0\r\n\r\n是必须的。如果忘了发服务器会一直等下一个chunk直到超时。表现就是客户端觉得发完了但服务器迟迟不返回响应最后连接超时断开。这个问题的隐蔽之处在于如果服务器超时时间设得长你可能要等几十秒才看到失败还以为是网络问题。养成习惯发完数据立刻发结束块别漏。5.3 十六进制长度算错比如要发256字节十六进制是100不是256。如果误写成256服务器会按十六进制解析成598字节然后等598字节的数据但你只发了256字节服务器就一直等最后超时。还有一种情况是长度字段带了0x前缀写成0x100\r\n这也是错的服务器不认。长度字段就是纯十六进制数字不带任何前缀。5.4 服务器不支持chunked虽然HTTP/1.1强制要求支持chunked但有些老旧的服务器或者配置不当的服务器可能不支持。表现是返回411 Length Required或者501 Not Implemented。遇到这种情况要么换服务器要么退回到Content-Length方案如果数据量可控。判断方法先发一个很小的chunked请求测试看服务器返回什么。如果返回411基本就是不支持。5.5 大文件上传中途断网的处理chunked上传本身不提供断点续传能力。如果传到一半网络断了整个请求就废了只能重来。如果业务上需要断点续传得在应用层自己做比如把文件切成固定大小的段每段单独发一个请求记录哪些段成功了重连后从失败的段继续。这是另一个话题但做设备端上传时迟早会遇到。6. 从demo到生产还需要补哪些能力上面那个demo能跑通基本的上传流程但离生产可用还有距离。如果你的项目要长期跑下面这几个能力建议逐步补上。6.1 超时控制demo里的send和recv都是阻塞的如果网络卡住程序就挂在那里了。生产环境必须设超时。可以用setsockopt设置SO_SNDTIMEO和SO_RCVTIMEOstruct timeval tv; tv.tv_sec 10; tv.tv_usec 0; setsockopt(sockfd, SOL_SOCKET, SO_SNDTIMEO, tv, sizeof(tv)); setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv));这样send或recv超过10秒没动静就返回-1程序可以据此判断超时并做重试或报错。6.2 进度回调上传大文件时用户或上层模块通常需要知道进度。可以在发送循环里加一个回调函数每发完一块就调用一次传入已发送字节数和总字节数。总字节数可以通过fseek和ftell提前获取fseek(fp, 0, SEEK_END); long total_size ftell(fp); fseek(fp, 0, SEEK_SET);然后在循环里累加bytes_read算百分比。6.3 HTTPS支持如果服务器要求HTTPS就不能直接用裸socket了得用TLS库比如OpenSSL或者mbedTLS。用法是把send和recv替换成SSL_write和SSL_read其他逻辑不变。chunked编码是在HTTP层和TLS层是独立的所以格式部分不用改。6.4 重试与错误恢复网络抖动是常态上传失败后自动重试能大幅提升成功率。简单的重试策略是失败后等几秒重新建立连接从头再传。如果文件很大重传成本高就得考虑分段上传加断点续传。重试次数建议设上限比如3次避免无限循环。6.5 内存与文件句柄的清理demo里用了fopen和close但错误分支里容易漏掉fclose。生产代码建议用goto统一清理或者封装成函数确保资源释放。文件句柄泄漏在长时间运行的程序里是致命的。7. 几个实际场景下的变体做法chunked上传不是只有读文件发文件这一种用法实际项目中还有几种常见变体思路类似但细节不同。7.1 边压缩边上传如果上传前要压缩数据可以用zlib的流式接口边压缩边发chunk。这样不需要先把整个文件压缩到内存或临时文件内存占用更小。zlib的deflate函数每次输出一块压缩数据直接按chunked格式发出去就行。注意压缩后的数据大小是动态的正好发挥chunked不需要预知长度的优势。7.2 边采集边上传设备端采集数据比如传感器读数、日志时数据是持续产生的没有明确的文件结束。这种场景下可以设定一个时间窗口或者大小阈值攒够一块就发一个chunk全部采集结束后再发结束块。这样数据可以近实时地上传到服务器而不是等采集完再传。7.3 多文件打包上传如果要一次上传多个文件可以用multipart/form-data格式每个文件作为一个part整体用chunked发送。这样只需要一个HTTP请求比每个文件发一个请求效率高。不过multipart的边界处理比单纯chunked复杂一些需要仔细构造分隔符。8. 关于这套方案我自己的几点体会写C客户端做chunked上传最深的体会是协议细节决定成败。chunked的格式本身不复杂但每一个字节都不能错一个\r\n写错就全盘失败。所以调试阶段一定要把实际发出的字节dump出来看别靠猜。另一个体会是不要过早优化。我一开始纠结块大小设多少最优后来发现8KB和32KB在实际场景下差别微乎其微真正影响成功率的是超时处理和重试逻辑。先把基本流程跑通再根据实际瓶颈去调比一开始就追求完美参数高效得多。还有一点如果项目允许用第三方库libcurl其实已经封装好了chunked上传用CURLOPT_READFUNCTION配合CURLOPT_UPLOAD就能实现代码量少很多。但自己实现一遍的价值在于出问题时你知道去哪一层找原因而不是对着库的黑盒干瞪眼。我建议至少手写一次之后再用库就会心里有底。最后分享一个小技巧调试chunked上传时可以先用一个几KB的小文件测试确认格式没问题后再用大文件测性能和内存。小文件能快速暴露格式错误大文件能暴露缓冲区和超时问题分开测比一上来就用大文件效率高。