适合谁收藏
- 正在让 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 错误不能混成一个码。",就不能把局部代码截图当成实现证据。
片段一:入口、声明与前置条件
iecst
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。
片段二:状态推进、边界与输出
iecst
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 -> 完整源码加更 -> 综合收束。