适合谁收藏正在让 PLC 主动访问 HTTP 服务的工程师。需要处理请求构造、响应边界、超时与连接复用的人。希望把 Client 故障定位到确定状态和错误出口的读者。本篇位置客户端篇第 2/7 篇主系列第 13/28 篇。现场问题PLC 主动访问上位机 API 时在线变量最早出现的成功信号通常是 TCP 连接成立。很多程序就在这里置位 Done随后业务拿到的状态码仍然是 0。HTTP Client 事务至少经过准备、连接、构造、发送、接收、解析和完成。任一步失败都需要不同诊断。先给结论Client 只有在响应 Parser 完成并输出有效状态码后才能置位请求完成。连接句柄和发送完成只是中间证据。读图重点这张图只压缩本篇的判断路径。读图时先找“Idle/Prepare”对应的输入边界再沿着“收齐并解析响应”检查状态怎样推进最后用“决定 Done 或 Error”确认输出是否已经形成验收证据。把对象和边界分开对象或阶段工程职责现场观察点Idle/Prepare锁存参数并清理历史状态避免上次事务残留Connect获得有效 TCP 句柄只证明通道成立Send分片写完完整请求不代表对端已处理Receive/Parse收齐并解析响应决定 Done 或 Error从协议约束到代码职责协议约束Client 只有在响应 Parser 完成并输出有效状态码后才能置位请求完成。连接句柄和发送完成只是中间证据。 这条结论先限定消息什么时候成立再限定哪个角色可以消费结果。若绕过协议边界直接驱动业务半包、超时、重复执行和连接残留就会进入应用层。工程抽象Client 同时保留 CodeSys 风格的xEnable/xExecute和旧接口兼容输入但内部会归一为一次真实事务。上升沿触发和周期调用必须同时满足不能在一个扫描周期里反复重建请求。连接状态、HTTP 错误和 NBS 错误分开输出。这样可以区分“端口拒绝”“响应超时”“状态行非法”和“Body 超限”。Idle/Prepare工程职责是“锁存参数并清理历史状态”。它不能只停留在命名层面运行时必须能通过“避免上次事务残留”观察到输入、状态或结果否则这一层即使有代码也没有形成可验证边界。Connect工程职责是“获得有效 TCP 句柄”。它不能只停留在命名层面运行时必须能通过“只证明通道成立”观察到输入、状态或结果否则这一层即使有代码也没有形成可验证边界。Send工程职责是“分片写完完整请求”。它不能只停留在命名层面运行时必须能通过“不代表对端已处理”观察到输入、状态或结果否则这一层即使有代码也没有形成可验证边界。Receive/Parse工程职责是“收齐并解析响应”。它不能只停留在命名层面运行时必须能通过“决定 Done 或 Error”观察到输入、状态或结果否则这一层即使有代码也没有形成可验证边界。程序单元本篇主证据来自FB_HttpClient.st中以CASE eState OF为定位点的连续源码。这里不是为了展示语法而是把协议约束落到确定程序单元输入先进入结构体或缓冲区状态机只在本周期处理可确认的部分长度和结束条件决定能否前进错误码与指标负责把失败原因带出对象边界。这样一来“connect 不是 Client 的完成态。”可以在代码、在线变量和外部报文之间逐项对照而不是依赖经验猜测。本篇核心源码片段下面两段代码来自同一个真实文件FB_HttpClient.st以CASE eState OF为中心连续截取没有改写变量、删除分支或用伪代码替代。第一段用于确认入口与前置条件第二段用于确认状态、边界和输出。核对时重点看“锁存参数并清理历史状态”怎样进入对象以及“决定 Done 或 Error”怎样证明本次处理已经结束。若两段之间的连续关系无法解释“TCP 错误与 HTTP 错误不能混成一个码。”就不能把局部代码截图当成实现证据。片段一入口、声明与前置条件PT : GVL_Http.cnWriteTimeout ); M_Reset(); RETURN; END_IF bDone : FALSE; CASE eState OF E_HttpClientState.iDisabled: eState : E_HttpClientState.iIdle; E_HttpClientState.iIdle: bBusy : FALSE; IF rtrigSend.Q THEN M_PrepareRequest(); stMetrics.udiRequestCount : stMetrics.udiRequestCount 1; IF NOT bRequestQueued THEN eState : E_HttpClientState.iFault; ELSIF (hConnection 0) AND bTcpConnected AND NOT stRequest.bConnectionClose THEN bReuseAttempt : TRUE; eState : E_HttpClientState.iSend; ELSE bReuseAttempt : FALSE; eState : E_HttpClientState.iTcpConnect; END_IF END_IF E_HttpClientState.iTcpConnect: bBusy : TRUE; fbTcpClient( xEnable : TRUE, udiTimeOut : udiTimeOut, ipAddr : ipServer, uiPort : uiEffectivePort, eError eTcpError, hConnection hConnection ); bTcpConnected : fbTcpClient.xActive AND (hConnection 0); IF fbTcpClient.xError THEN eLastNbsError : eTcpError; stMetrics.udiConnectErrorCount : stMetrics.udiConnectErrorCount 1; M_SetClientError( eError : E_HttpError.iTcpClientFailed, sMessage : TCP connect failed ); eState : E_HttpClientState.iFault; ELSIF bTcpConnected THEN eState : E_HttpClientState.iSend; ELSIF (udiNowMs 0) AND ((udiNowMs - udiStateEnterMs) udiTimeoutMs) THEN M_SetClientError( eError : E_HttpError.iTimeout,这一段先回答对象在什么输入和状态下开始工作。阅读时要核对变量的初值、长度上限和启动条件不能只看某个布尔量是否变成 TRUE。片段二状态推进、边界与输出sMessage : TCP connect timeout ); eState : E_HttpClientState.iFault; END_IF E_HttpClientState.iSend: bBusy : TRUE; fbTcpClient( xEnable : TRUE, udiTimeOut : udiTimeOut, ipAddr : ipServer, uiPort : uiEffectivePort, eError eTcpError, hConnection hConnection ); M_ServiceWrite(); IF (NOT bWriteBusy) AND (NOT bWriteExecute) THEN eState : E_HttpClientState.iReceive; END_IF E_HttpClientState.iReceive: bBusy : TRUE; fbTcpClient( xEnable : TRUE, udiTimeOut : udiTimeOut, ipAddr : ipServer, uiPort : uiEffectivePort, eError eTcpError, hConnection hConnection ); M_ServiceRead(); M_ProcessResponse(); IF (udiNowMs 0) AND ((udiNowMs - udiStateEnterMs) udiTimeoutMs) THEN M_SetClientError( eError : E_HttpError.iTimeout, sMessage : HTTP response timeout ); eState : E_HttpClientState.iFault; END_IF E_HttpClientState.iDone: bBusy : FALSE; bDone : TRUE; IF stRequest.bConnectionClose OR xCloseConnection THEN fbTcpClient( xEnable : FALSE, ipAddr : ipServer, uiPort : uiEffectivePort, hConnection hConnection ); bTcpConnected : FALSE; ELSE第二段继续展示同一连续源码范围。把它与第一段合起来才能判断输入怎样被锁存、状态何时推进、边界何时满足以及错误出口是否保留了足够诊断信息。验证路径场景操作通过口径连接拒绝目标端口无服务TcpClientFailed 而非协议错误连接后不响应服务端接受但不返回Timeout 且可复位返回坏报文状态行或 Header 非法Parser 错误正常事务200 响应完整Done、状态码和计数一致场景 1连接拒绝把 Client 指向未监听的端口并记录连接尝试开始到失败的时间。此时不应产生任何 HTTP 请求字节也不应进入 Header 或 Body 解析错误必须被标记为 TCP 建连失败。随后改回正常端口重试若旧错误没有被显式清除或状态机不能从连接失败回到 Idle说明复位边界仍不可靠。场景 2连接后不响应让测试服务接受连接却故意不发送响应验证 Client 从发送完成转入接收等待后能在设定超时到达时退出。在线量应显示连接曾建立、请求已发出、超时发生在响应阶段缓冲区不能把空响应当成成功。复位之后再次发起正常 GET 必须获得完整响应证明超时没有留下被占用的连接或半包状态。场景 3返回坏报文由通信猫返回缺少 CRLF、非法状态行或截断 Header 的报文观察解析器在哪个字段停止而不是只看一个笼统的失败标志。验收要求是错误码能说明格式问题已接收字节数可供复盘并且业务回调不会得到部分 Body。若坏 Header 仍被当作 200 成功后续的所有业务判断都会建立在错误报文上。场景 4正常事务最后返回一个带明确Content-Length的 200 报文逐项比对状态行、Header、Body、完成脉冲和统计计数。只有解析完成后才允许Done并且该次成功应与前面三种失败记录清晰分离。这个对照场景的价值是确认状态机不会因为先前的拒绝、超时或格式错而吞掉下一笔正常事务。常见误判TCP connect 一成功就置位 Done应用随后读到状态码 0。发送完成后立即复位事务响应晚一个周期到达时被当成无关数据。把端口拒绝、响应超时和状态行非法全部折叠成同一个 Error。这些误判的共同点是拿一个局部现象替代完整事务。定位时必须回到本篇的输入、状态、边界和输出四个坐标并用相同输入完成回归。这一篇你最该记住connect 不是 Client 的完成态。发送和响应必须分别验收。TCP 错误与 HTTP 错误不能混成一个码。系列导航系列CodeSys HTTP 系列教程第 13/28 篇。阶段客户端篇职责线位置 2/7。上一篇第12篇下一篇第14篇发布顺序基础认知 - Server - Client - 完整源码加更 - 综合收束。