第03篇_TCP 已经连接为什么 HTTP 消息还没有收完整[!abstract] TCP 交付连续有序的字节不交付 HTTP 消息。本文把半包、粘连、Header 结束、Content-Length、chunked 和连接关闭放进 PLC 扫描周期解释。适合谁收藏第一次系统理解 HTTP 的 PLC 工程师。已经会调用通信功能块但分不清 TCP 连通与 HTTP 完整事务的人。需要建立后续 Server、Client 学习坐标的读者。[!note] 本篇位置 基础认知第 3/4 篇主系列第 03/28 篇。现场问题调试工具显示 ConnectedPLC 第一次 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: chunked和Content-Length消息边界存在歧义当前实现直接拒绝。拒绝不是兼容性差而是避免不同中间节点按不同长度解释同一条消息。把半包放进扫描周期假设一条请求共 105 字节扫描周期新增累计Parser 判断N32 字节32 字节请求行不完整继续等待N148 字节80 字节Header 完整Body 仍不足N225 字节105 字节消息完整交给业务业务层只会在 N2 看到这条请求。N 和 N1 的内容属于协议栈内部状态不能提前触发设备动作。从协议约束到代码职责协议约束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第一次只返回 NeedMoreDataBody 晚到Header 先到、Body 后到达到声明长度才完成两条消息粘连同一连接顺序发送上一事务完整消费后再开始下一条歧义边界同时发送 TE 与 CL协议层明确拒绝场景 1Header 半包执行“结束符拆成两次 Read”前先清理上一笔事务的完成脉冲、错误锁存和接收残留再记录起始状态与计数。操作后同时观察外部报文、角色状态机和诊断量只有三条证据共同指向“第一次只返回 NeedMoreData”这一场景才算通过。若只看到外部结果而内部状态未收口应继续检查资源回收若内部 Done 已出现而报文不完整应回到消息边界重新取证。场景 2Body 晚到执行“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 - 完整源码加更 - 综合收束。