第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 已完整 |
| 普通 Body | Content-Length | 累计字节达到声明长度 |
| 分块 Body | chunk-size 与 0 终止块 | 每个块和 CRLF 都要验证 |
三种常见消息边界
| 边界方式 | 判断依据 | PLC 应怎样结束接收 |
|---|---|---|
| Content-Length | Header 声明 Body 字节数 | 收到指定字节后完成 |
| chunked | 每块十六进制长度和 0 终止块 | 解码完 0 块及结束边界后完成 |
| close-delimited | 对端关闭连接 | 只在协议允许且已有响应上下文时使用 |
如果同时出现Transfer-Encoding: chunked和Content-Length,消息边界存在歧义,当前实现直接拒绝。拒绝不是兼容性差,而是避免不同中间节点按不同长度解释同一条消息。
把半包放进扫描周期
假设一条请求共 105 字节:
| 扫描周期 | 新增 | 累计 | Parser 判断 |
|---|---|---|---|
| N | 32 字节 | 32 字节 | 请求行不完整,继续等待 |
| N+1 | 48 字节 | 80 字节 | Header 完整,Body 仍不足 |
| N+2 | 25 字节 | 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 -> 完整源码加更 -> 综合收束。