TCP 已经连接,为什么 HTTP 消息还没有收完整
2026/7/23 2:06:07 网站建设 项目流程

第03篇_TCP 已经连接,为什么 HTTP 消息还没有收完整

[!abstract] TCP 交付连续有序的字节,不交付 HTTP 消息。本文把半包、粘连、Header 结束、Content-Length、chunked 和连接关闭放进 PLC 扫描周期解释。

适合谁收藏

  • 第一次系统理解 HTTP 的 PLC 工程师。
  • 已经会调用通信功能块,但分不清 TCP 连通与 HTTP 完整事务的人。
  • 需要建立后续 Server、Client 学习坐标的读者。

[!note] 本篇位置 基础认知,第 3/4 篇;主系列第 03/28 篇。

现场问题

调试工具显示 Connected,PLC 第一次 Read 也拿到了POST /api/setpoint HTTP/1.1和几行 Header,但 Body 为空。下一扫描周期 Body 才到达。如果程序在第一次 Read 后就执行写入,业务看到的就是一条伪完整请求。

另一种情况是连接被复用,一次 Read 中同时包含上一条消息的尾部和下一条消息的开头。TCP 没有做错,它从来没有承诺一次 Read 对应一条 HTTP 消息。

先给结论

TCP 连接和 TCP Read 只提供字节证据。HTTP 完成条件必须由协议字段决定:先找到 Header 边界,再按 Content-Length、chunked 或受控关闭策略判断 Body;数据不足返回 NeedMoreData,语法非法则立即拒绝。

读图重点

这张图只压缩本篇的判断路径。读图时先找“TCP 字节流”对应的输入边界,再沿着“chunk-size 与 0 终止块”检查状态怎样推进,最后用“每个块和 CRLF 都要验证”确认输出是否已经形成验收证据。

把对象和边界分开

对象或阶段工程职责现场观察点
TCP 字节流保证有序、可靠传输不保留 HTTP 消息边界
Header 结束CRLF CRLF只能证明 Header 已完整
普通 BodyContent-Length累计字节达到声明长度
分块 Bodychunk-size 与 0 终止块每个块和 CRLF 都要验证

三种常见消息边界

边界方式判断依据PLC 应怎样结束接收
Content-LengthHeader 声明 Body 字节数收到指定字节后完成
chunked每块十六进制长度和 0 终止块解码完 0 块及结束边界后完成
close-delimited对端关闭连接只在协议允许且已有响应上下文时使用

如果同时出现Transfer-Encoding: chunkedContent-Length,消息边界存在歧义,当前实现直接拒绝。拒绝不是兼容性差,而是避免不同中间节点按不同长度解释同一条消息。

把半包放进扫描周期

假设一条请求共 105 字节:

扫描周期新增累计Parser 判断
N32 字节32 字节请求行不完整,继续等待
N+148 字节80 字节Header 完整,Body 仍不足
N+225 字节105 字节消息完整,交给业务

业务层只会在 N+2 看到这条请求。N 和 N+1 的内容属于协议栈内部状态,不能提前触发设备动作。

从协议约束到代码职责

协议约束

TCP 连接和 TCP Read 只提供字节证据。HTTP 完成条件必须由协议字段决定:先找到 Header 边界,再按 Content-Length、chunked 或受控关闭策略判断 Body;数据不足返回 NeedMoreData,语法非法则立即拒绝。 这条结论先限定消息什么时候成立,再限定哪个角色可以消费结果。若绕过协议边界直接驱动业务,半包、超时、重复执行和连接残留就会进入应用层。

工程抽象

在 PLC 中,一笔事务天然跨越多个扫描周期。每周期把新增字节追加到固定缓冲区,调用 Parser,只有 Parser 返回完成态才把请求交给业务。NeedMoreData 表示当前字节还可能组成合法消息,应保留缓冲区继续等待;InvalidHeader 表示继续等待也不会变正确,应停止事务并留下错误证据。

固定缓冲区让资源可计算,也要求超限时明确失败。当前工程的消息、Header 和 Body 上限是实现边界,不是 HTTP 标准上限;文章必须把二者区分开。

  • TCP 字节流:工程职责是“保证有序、可靠传输”。它不能只停留在命名层面,运行时必须能通过“不保留 HTTP 消息边界”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。
  • Header 结束:工程职责是“CRLF CRLF”。它不能只停留在命名层面,运行时必须能通过“只能证明 Header 已完整”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。
  • 普通 Body:工程职责是“Content-Length”。它不能只停留在命名层面,运行时必须能通过“累计字节达到声明长度”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。
  • 分块 Body:工程职责是“chunk-size 与 0 终止块”。它不能只停留在命名层面,运行时必须能通过“每个块和 CRLF 都要验证”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。

程序单元

本篇主证据来自FB_HttpMessageParser.st中以iHeaderEnd := FIND(sMessage, '$R$N$R$N')为定位点的连续源码。这里不是为了展示语法,而是把协议约束落到确定程序单元:输入先进入结构体或缓冲区,状态机只在本周期处理可确认的部分,长度和结束条件决定能否前进,错误码与指标负责把失败原因带出对象边界。这样一来,“一次 Read 不等于一条 HTTP 消息。”可以在代码、在线变量和外部报文之间逐项对照,而不是依赖经验猜测。

本篇核心源码片段

下面两段代码来自同一个真实文件FB_HttpMessageParser.st,以iHeaderEnd := FIND(sMessage, '$R$N$R$N')为中心连续截取,没有改写变量、删除分支或用伪代码替代。第一段用于确认入口与前置条件,第二段用于确认状态、边界和输出。核对时重点看“保证有序、可靠传输”怎样进入对象,以及“每个块和 CRLF 都要验证”怎样证明本次处理已经结束。若两段之间的连续关系无法解释“NeedMoreData 与语法错误必须使用不同恢复路径。”,就不能把局部代码截图当成实现证据。

片段一:入口、声明与前置条件

uiHeaderCount : UINT; // 计数、长度或状态数值。 bHeaderFound : BOOL; // 布尔状态或命令标志。 bHeaderInvalid : BOOL; // 布尔状态或命令标志。 END_VAR // === IMPLEMENTATION === // 工程说明:本段集中处理状态、边界或诊断,避免跨周期残留。 // 边界说明:执行前后保持输出和错误码可被在线诊断追踪。 // 约束:HTTP/1.1 请求必须校验 Host,缺失或重复 Host 直接按协议错误处理,避免虚拟主机场景路由歧义。 // 约束:Transfer-Encoding 与 Content-Length 同时存在必须拒绝,避免请求走私类歧义进入 PLC Server。 // 风险:Content-Length、chunked 和 close 语义互斥处理,任何长度错误都必须停在协议层。 // 诊断:解析失败统一输出 eError 和 sDiagMsg,外部 Server 探针可把 4xx 响应映射到具体协议原因。 M_Reset(); M_ParseRequest := FALSE; M_ResetRequest( stRequest := stRequest ); iFirstLineEnd := FIND(sMessage, '$R$N'); iHeaderEnd := FIND(sMessage, '$R$N$R$N'); IF (iFirstLineEnd <= 1) OR (iHeaderEnd <= iFirstLineEnd) THEN M_SetError( eNewError := E_HttpError.iNeedMoreData, sMessage := 'request header is incomplete' ); RETURN; END_IF sStartLine := LEFT(sMessage, iFirstLineEnd - 1); iSpace1 := FIND(sStartLine, ' '); IF iSpace1 <= 1 THEN M_SetError( eNewError := E_HttpError.iInvalidStartLine, sMessage := 'request start-line misses method' ); RETURN; END_IF sAfterMethod := MID(sStartLine, LEN(sStartLine) - iSpace1, iSpace1 + 1); iSpace2Relative := FIND(sAfterMethod, ' '); IF iSpace2Relative <= 1 THEN M_SetError( eNewError := E_HttpError.iInvalidStartLine, sMessage := 'request start-line misses version' ); RETURN; END_IF stRequest.sMethod := LEFT(sStartLine, iSpace1 - 1); stRequest.sTarget := LEFT(sAfterMethod, iSpace2Relative - 1); stRequest.sVersion := MID(sAfterMethod, LEN(sAfterMethod) - iSpace2Relative, iSpace2Relative + 1); stRequest.eMethod := M_ParseMethod( sMethod := stRequest.sMethod

这一段先回答对象在什么输入和状态下开始工作。阅读时要核对变量的初值、长度上限和启动条件,不能只看某个布尔量是否变成 TRUE。

片段二:状态推进、边界与输出

); IF (LEN(stRequest.sTarget) = 0) OR (FIND(stRequest.sVersion, 'HTTP/') <> 1) THEN M_SetError( eNewError := E_HttpError.iInvalidStartLine, sMessage := 'request target or version is invalid' ); RETURN; END_IF iHeaderStart := iFirstLineEnd + 2; iHeaderLen := iHeaderEnd - iHeaderStart; IF iHeaderLen > GVL_Http.cnMaxHeaderSize THEN M_SetError( eNewError := E_HttpError.iBufferTooSmall, sMessage := 'request header exceeds limit' ); RETURN; END_IF IF iHeaderLen > 0 THEN sHeaderText := MID(sMessage, iHeaderLen, iHeaderStart); ELSE sHeaderText := ''; END_IF stRequest.sRawHeaders := sHeaderText; bHeaderFound := F_HttpFindHeader( sHeaders := sHeaderText, sHeaderName := 'Host', sValue => sHeaderValue, uiCount => uiHeaderCount, bInvalidGrammar => bHeaderInvalid ); IF bHeaderInvalid THEN M_SetError( eNewError := E_HttpError.iInvalidHeader, sMessage := 'request header grammar is invalid' ); RETURN; ELSIF NOT bHeaderFound THEN M_SetError( eNewError := E_HttpError.iMissingHost, sMessage := 'request host is missing' ); RETURN; ELSIF uiHeaderCount > 1 THEN M_SetError( eNewError := E_HttpError.iDuplicateHost, sMessage := 'request host is duplicated' ); RETURN; ELSE

第二段继续展示同一连续源码范围。把它与第一段合起来,才能判断输入怎样被锁存、状态何时推进、边界何时满足,以及错误出口是否保留了足够诊断信息。

验证路径

场景操作通过口径
Header 半包结束符拆成两次 Read第一次只返回 NeedMoreData
Body 晚到Header 先到、Body 后到达到声明长度才完成
两条消息粘连同一连接顺序发送上一事务完整消费后再开始下一条
歧义边界同时发送 TE 与 CL协议层明确拒绝

场景 1:Header 半包

执行“结束符拆成两次 Read”前,先清理上一笔事务的完成脉冲、错误锁存和接收残留,再记录起始状态与计数。操作后同时观察外部报文、角色状态机和诊断量;只有三条证据共同指向“第一次只返回 NeedMoreData”,这一场景才算通过。若只看到外部结果而内部状态未收口,应继续检查资源回收;若内部 Done 已出现而报文不完整,应回到消息边界重新取证。

场景 2:Body 晚到

执行“Header 先到、Body 后到”前,先清理上一笔事务的完成脉冲、错误锁存和接收残留,再记录起始状态与计数。操作后同时观察外部报文、角色状态机和诊断量;只有三条证据共同指向“达到声明长度才完成”,这一场景才算通过。若只看到外部结果而内部状态未收口,应继续检查资源回收;若内部 Done 已出现而报文不完整,应回到消息边界重新取证。

场景 3:两条消息粘连

执行“同一连接顺序发送”前,先清理上一笔事务的完成脉冲、错误锁存和接收残留,再记录起始状态与计数。操作后同时观察外部报文、角色状态机和诊断量;只有三条证据共同指向“上一事务完整消费后再开始下一条”,这一场景才算通过。若只看到外部结果而内部状态未收口,应继续检查资源回收;若内部 Done 已出现而报文不完整,应回到消息边界重新取证。

场景 4:歧义边界

执行“同时发送 TE 与 CL”前,先清理上一笔事务的完成脉冲、错误锁存和接收残留,再记录起始状态与计数。操作后同时观察外部报文、角色状态机和诊断量;只有三条证据共同指向“协议层明确拒绝”,这一场景才算通过。若只看到外部结果而内部状态未收口,应继续检查资源回收;若内部 Done 已出现而报文不完整,应回到消息边界重新取证。

常见误判

  • 把一次 TCP_Read 当成一条完整 HTTP 消息。
  • 找到 Header 空行就执行带 Body 的请求。
  • 把 NeedMoreData 和非法语法都处理成继续等待,让坏连接长期占用资源。

这些误判的共同点,是拿一个局部现象替代完整事务。定位时必须回到本篇的输入、状态、边界和输出四个坐标,并用相同输入完成回归。

这一篇你最该记住

  • 一次 Read 不等于一条 HTTP 消息。
  • Header 完整不等于 Body 完整。
  • NeedMoreData 与语法错误必须使用不同恢复路径。

系列导航

  • 系列:CodeSys HTTP 系列教程,第 03/28 篇。
  • 阶段:基础认知,职责线位置 3/4。
  • 上一篇:第02篇
  • 下一篇:第04篇
  • 发布顺序:基础认知 -> Server -> Client -> 完整源码加更 -> 综合收束。

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

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

立即咨询