1. 为什么要在川崎机器人上自己写TCP服务器
车间里那台川崎机器人跑得好好的,突然接到需求:产线MES系统要实时读取机器人的关节角度和运行状态,同时还要能下发简单的启停指令。找供应商问了一圈,对方报价六位数,交付周期三个月。这种场景下,最经济的做法就是自己动手,用川崎控制器自带的AS语言写一个TCP服务器,让机器人直接和上位机对话。
川崎机器人控制器(无论是C系列还是E系列)都内置了以太网口,AS语言提供了TCP_LISTEN、TCP_ACCEPT、TCP_RECV、TCP_SEND这一套通信指令。很多人第一次翻手册看到这些指令时觉得挺简单,但真正上手写多客户端并发的时候,问题就来了:AS语言没有线程、没有异步IO、没有select/poll机制,怎么同时伺候多个客户端?
这就是本篇要解决的核心问题。我会从零开始,把多客户端TCP服务器的完整实现思路、代码结构、踩坑经验全部摊开讲。适合有川崎机器人基础编程经验、需要做上位机通信的工程师,也适合刚接触AS语言但想直接上手实战的朋友。代码可以直接复制到控制器里跑,但建议先看完原理部分再动手,否则出了问题不好排查。
提示:本文所有代码基于川崎AS语言标准指令集,不同控制器型号(如C56、E73等)在指令支持上可能有细微差异,建议先确认你的控制器固件版本是否支持
TCP_ACCEPT的非阻塞模式。
2. AS语言做TCP通信的底层限制与破局思路
2.1 AS语言没有多线程,这是最大的拦路虎
如果你写过C语言或者Python的TCP服务器,第一反应可能是开线程或者用asyncio。但AS语言是单线程顺序执行的,程序从第一行跑到最后一行,中间不会自动切换。这意味着你不能在一个循环里阻塞等待某个客户端的数据,否则其他客户端全部卡死。
我见过不少人的第一版代码是这样的:TCP_LISTEN之后进入一个WHILE TRUE循环,里面TCP_ACCEPT等新连接,然后TCP_RECV等数据。结果就是第一个客户端连上之后,第二个客户端根本连不进来,因为程序卡在TCP_RECV上了。
破局的核心思路是:把所有TCP操作都改成非阻塞模式,用一个主循环轮询所有socket的状态。AS语言的TCP_ACCEPT和TCP_RECV都支持超时参数,把超时设成0或者很小的值,就能实现非阻塞效果。
2.2 非阻塞模式的具体实现方式
AS语言里,TCP_ACCEPT的基本语法是:
TCP_ACCEPT port, timeout, socket_id其中timeout单位是毫秒。如果设成0,表示立即返回,不等待。返回值会告诉你是否有新连接。类似地,TCP_RECV也有超时参数:
TCP_RECV socket_id, timeout, recv_data把这两个超时都设成0,主循环就可以快速轮询:先检查有没有新连接,再遍历所有已连接的客户端检查有没有数据。这就是单线程下实现多客户端并发的核心机制。
但这里有个坑:超时设成0的时候,CPU占用率会很高,因为主循环跑得飞快。实际项目中我一般设成10到50毫秒,既能保证响应速度,又不会让控制器CPU过载。
2.3 客户端管理的核心数据结构
AS语言没有结构体数组这种高级数据结构,但可以用多个一维数组来模拟。我通常用这几个数组来管理客户端:
| 数组名 | 类型 | 用途 |
|---|---|---|
| client_socket | INTEGER数组 | 存储每个客户端的socket ID,-1表示空闲 |
| client_active | INTEGER数组 | 标记该位置是否被占用 |
| client_buffer | STRING数组 | 每个客户端的接收缓冲区 |
| client_count | INTEGER | 当前连接的客户端总数 |
最大客户端数量根据实际需求定,一般设8到16个就够了。设太多会占用大量内存,AS语言的内存资源本来就紧张。
注意:AS语言的数组下标从1开始,不是从0开始。这一点和C语言不同,写循环的时候要特别注意,否则会数组越界。
3. 多客户端TCP服务器的完整代码拆解
3.1 初始化部分:监听端口与数组清零
先看初始化代码:
; 初始化变量 max_clients = 8 listen_port = 5000 i = 0 ; 初始化客户端数组 FOR i = 1 TO max_clients client_socket[i] = -1 client_active[i] = 0 client_buffer[i] = "" NEXT i ; 启动监听 TCP_LISTEN listen_port, 1这里TCP_LISTEN的第二个参数是1,表示允许同时监听的连接数。实际上这个参数在不同固件版本里行为不太一样,有的版本设成1就够,有的需要设成max_clients。我一般直接设成max_clients,保险一点。
数组清零这一步看起来简单,但绝对不能省。AS语言的全局变量默认值不一定是0,如果不手动初始化,client_socket里可能是随机值,后面判断的时候就会出问题。我刚开始写的时候就吃过这个亏,调试了半天才发现是数组没清零。
3.2 主循环:轮询新连接与已有客户端
主循环是整个服务器的核心,结构如下:
WHILE TRUE ; 检查新连接 TCP_ACCEPT listen_port, 10, new_socket IF new_socket > 0 THEN ; 找空闲位置 FOR i = 1 TO max_clients IF client_active[i] = 0 THEN client_socket[i] = new_socket client_active[i] = 1 client_buffer[i] = "" EXIT ENDIF NEXT i ENDIF ; 轮询已有客户端 FOR i = 1 TO max_clients IF client_active[i] = 1 THEN TCP_RECV client_socket[i], 10, recv_data IF recv_data <> "" THEN ; 处理数据 CALL ProcessData(i, recv_data) ENDIF ENDIF NEXT i ; 短暂延时,降低CPU占用 DELAY 0.01 ENDWHILE这段代码的逻辑很清晰:先看有没有新客户端要连进来,有的话找个空位放进去;然后遍历所有已连接的客户端,看有没有数据要收。TCP_ACCEPT和TCP_RECV的超时都设成10毫秒,整个循环一轮大概几十毫秒,响应速度完全够用。
这里有个细节:TCP_ACCEPT返回的new_socket如果大于0,表示有新连接。但有些固件版本返回的是0表示成功,-1表示失败。这个一定要查你手头控制器的手册确认,不同版本行为不一样。我遇到过C56控制器返回0表示成功的情况,代码里判断> 0就漏掉了新连接,排查了好久。
3.3 数据接收与缓冲区管理
TCP_RECV一次不一定能收到完整的数据包。TCP是流式协议,没有消息边界,客户端发过来的数据可能被拆成多次接收。所以需要一个缓冲区来拼接:
SUB ProcessData(client_idx, data) client_buffer[client_idx] = client_buffer[client_idx] + data ; 检查是否有完整消息(假设以换行符结尾) pos = INSTR(client_buffer[client_idx], CHR(10)) WHILE pos > 0 msg = LEFT$(client_buffer[client_idx], pos - 1) client_buffer[client_idx] = MID$(client_buffer[client_idx], pos + 1) CALL HandleMessage(client_idx, msg) pos = INSTR(client_buffer[client_idx], CHR(10)) ENDWHILE END SUB这里假设客户端发来的消息以换行符\n结尾,这是最常见的做法。如果你的协议不是这样,需要相应调整。缓冲区拼接的时候要注意AS语言的字符串操作函数:INSTR找子串位置,LEFT$取左边部分,MID$取中间部分。这些函数和VB类似,用起来还算顺手。
提示:缓冲区不能无限增长。如果客户端发了数据但一直不完整,缓冲区会越来越大。建议设一个上限,比如4096字节,超过就清空或者断开连接。
3.4 数据发送与客户端断开处理
发送数据用TCP_SEND:
TCP_SEND client_socket[i], send_data如果发送失败,返回值会告诉你。这时候需要把该客户端标记为空闲,关闭socket:
TCP_CLOSE client_socket[i] client_socket[i] = -1 client_active[i] = 0 client_buffer[i] = ""客户端主动断开的时候,TCP_RECV会返回空字符串或者特定的错误码。这时候也要做同样的清理工作。我一般会在TCP_RECV之后检查返回值,如果连续多次收到空数据,就认为连接已断开。
这里有个经验:不要等到TCP_SEND失败才清理客户端。有些客户端断开后,TCP_SEND可能还能成功一两次(数据进了缓冲区),但后续就会失败。更好的做法是定期发送心跳包,如果心跳发送失败就立即清理。
4. 实际调试中遇到的五个典型问题
4.1 客户端连上后收不到数据
第一次跑通代码的时候,客户端能连上,但发数据过去机器人没反应。排查了半天,发现是TCP_RECV的超时参数设得太大了。我一开始设的是1000毫秒,结果主循环每轮要等1秒才检查下一个客户端,8个客户端轮一遍要8秒,响应慢得没法用。
改成10毫秒之后问题解决。但这里有个权衡:超时太小,CPU占用高;超时太大,响应慢。实测下来10到50毫秒是比较合适的范围。如果你的控制器还要跑其他任务,建议设大一点,比如50毫秒。
4.2 多个客户端同时发数据导致数据错乱
有一次测试的时候,两个客户端同时发数据,结果机器人的回复发错了对象。查代码发现是ProcessData里用了全局变量存client_idx,但主循环里又改了它。AS语言没有局部变量作用域的概念(至少老版本没有),所有变量都是全局的。
解决办法很简单:不要在子程序里依赖全局的循环变量。把client_idx作为参数传进去,子程序里用参数值,不要直接引用主循环的i。这个坑很隐蔽,因为单客户端测试的时候完全正常,只有多客户端并发才暴露。
4.3 长时间运行后内存泄漏
AS语言没有垃圾回收机制,字符串拼接操作会不断申请新内存。如果client_buffer只增不减,跑几天之后控制器就会报内存不足。
我的做法是:每次处理完完整消息后,立即把缓冲区里已处理的部分截掉。另外设一个最大缓冲区限制,超过就强制清空。还有一点:TCP_RECV返回的recv_data变量在每次循环后最好手动清空,虽然AS语言可能会自动处理,但手动清空更保险。
4.4 控制器重启后客户端无法重连
这个问题困扰了我很久。控制器断电重启后,之前的客户端连接全部失效,但客户端程序不知道,还在往旧socket发数据。机器人这边重新监听后,客户端不主动重连就永远连不上。
解决方案是在客户端加心跳机制,定期发送心跳包。如果连续几次心跳没有回复,客户端就主动断开重连。机器人这边也要定期检查所有客户端的心跳,超时就清理掉。这样双方都能及时感知连接状态。
4.5 不同固件版本的指令差异
川崎控制器的固件版本更新比较频繁,不同版本之间TCP_ACCEPT和TCP_RECV的行为可能有差异。我遇到过E73控制器上TCP_ACCEPT超时参数单位是秒而不是毫秒的情况,代码移植过去之后响应变得极慢。
建议在代码里加一个版本检测,或者至少在注释里写清楚适配的固件版本。移植到新控制器之前,先用简单的测试程序验证一下各个TCP指令的实际行为。
5. 让服务器更稳的几个进阶技巧
5.1 用状态机管理客户端生命周期
简单的client_active标记只能区分“空闲”和“占用”,但实际项目中客户端有更多状态:正在连接、已连接、正在接收数据、正在发送数据、等待断开等。用状态机来管理会更清晰:
| 状态值 | 含义 | 处理逻辑 |
|---|---|---|
| 0 | 空闲 | 可分配给新连接 |
| 1 | 已连接 | 正常收发数据 |
| 2 | 接收中 | 数据不完整,等待后续 |
| 3 | 待断开 | 发送失败或心跳超时,准备清理 |
状态机的好处是逻辑清晰,排查问题的时候一眼就能看出每个客户端处于什么阶段。而且后续扩展功能(比如加认证、加加密)的时候,只需要在状态转换里插入处理逻辑就行。
5.2 心跳机制的具体实现
心跳包不需要太复杂,客户端每隔几秒发一个固定字符串(比如PING),服务器收到后回复PONG。服务器这边记录每个客户端最后一次心跳的时间,超过阈值(比如30秒)就认为断开。
AS语言里可以用TIMER指令获取当前时间。每次收到心跳就更新client_last_heartbeat[i],主循环里检查当前时间和上次心跳的差值。这个逻辑不复杂,但能极大提升连接的可靠性。
5.3 数据协议的简单设计
虽然本文主要讲TCP服务器实现,但协议设计也很重要。我一般用最简单的文本协议:每条消息一行,以换行符结尾,字段之间用逗号分隔。比如:
GET_JOINT,1 SET_SPEED,50 START STOP这种协议的好处是可读性好,调试的时候直接用telnet就能测试。缺点是传输效率低,如果数据量大可以考虑二进制协议。但对于大多数工业场景,文本协议完全够用。
5.4 日志记录与问题追溯
AS语言没有现成的日志库,但可以用文件操作自己写一个简单的日志函数:
SUB WriteLog(msg) OPEN "log.txt" FOR APPEND AS #1 PRINT #1, TIME$ + " " + msg CLOSE #1 END SUB每次有新连接、断开、收到数据、发送数据的时候都记一条日志。出问题的时候翻日志,比盯着屏幕猜要高效得多。注意日志文件要定期清理,否则会占满控制器存储空间。
6. 代码整合与部署注意事项
6.1 完整代码结构
把前面的片段整合起来,完整的程序结构如下:
; ===== 变量定义 ===== max_clients = 8 listen_port = 5000 i = 0 new_socket = 0 recv_data = "" send_data = "" ; ===== 数组初始化 ===== FOR i = 1 TO max_clients client_socket[i] = -1 client_active[i] = 0 client_buffer[i] = "" client_last_heartbeat[i] = 0 NEXT i ; ===== 启动监听 ===== TCP_LISTEN listen_port, max_clients ; ===== 主循环 ===== WHILE TRUE ; 接受新连接 TCP_ACCEPT listen_port, 10, new_socket IF new_socket > 0 THEN FOR i = 1 TO max_clients IF client_active[i] = 0 THEN client_socket[i] = new_socket client_active[i] = 1 client_buffer[i] = "" client_last_heartbeat[i] = TIMER CALL WriteLog("Client " + STR$(i) + " connected") EXIT ENDIF NEXT i ENDIF ; 轮询客户端 FOR i = 1 TO max_clients IF client_active[i] = 1 THEN TCP_RECV client_socket[i], 10, recv_data IF recv_data <> "" THEN CALL ProcessData(i, recv_data) ENDIF ; 心跳超时检查 IF TIMER - client_last_heartbeat[i] > 30000 THEN CALL DisconnectClient(i) ENDIF ENDIF NEXT i DELAY 0.01 ENDWHILE这个结构清晰明了,主循环负责调度,子程序负责具体处理。实际项目中可以根据需要往HandleMessage里添加业务逻辑。
6.2 部署时的几个关键检查点
代码写完之后,部署到控制器之前,建议按这个清单检查一遍:
- 确认控制器的以太网口配置正确,IP地址和上位机在同一网段
- 确认防火墙没有拦截监听端口(有些控制器有内置防火墙)
- 确认
max_clients不超过控制器允许的最大socket数量 - 确认所有数组都正确初始化,没有残留的随机值
- 确认
TCP_ACCEPT和TCP_RECV的超时参数单位正确(毫秒还是秒) - 确认日志文件路径可写,存储空间充足
6.3 性能调优的经验数据
根据我在C56和E73控制器上的实测,几个关键参数的经验值如下:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| TCP_ACCEPT超时 | 10ms | 再小CPU占用高,再大响应慢 |
| TCP_RECV超时 | 10ms | 同上 |
| 主循环延时 | 10ms | 配合超时使用,降低CPU占用 |
| 最大客户端数 | 8 | 超过8个建议用多控制器方案 |
| 心跳间隔 | 5s | 客户端发送频率 |
| 心跳超时 | 30s | 服务器判定断开的阈值 |
| 缓冲区上限 | 4096字节 | 超过强制清空 |
这些数值不是绝对的,需要根据实际网络环境和业务需求调整。比如网络延迟大的时候,超时可以适当加大;业务数据量大的时候,缓冲区上限要调高。
6.4 从单客户端到多客户端的迁移建议
如果你已经有一个单客户端的TCP程序在跑,想升级到多客户端,建议不要直接改原代码,而是新写一个程序并行测试。因为多客户端的逻辑和单客户端差异很大,直接改容易引入难以排查的bug。
迁移步骤建议:
- 先写一个最简单的多客户端框架,只做连接和断开,不做业务逻辑
- 用多个telnet客户端测试连接和断开是否正常
- 逐步加入数据收发功能,每加一个功能测试一轮
- 最后把原单客户端程序的业务逻辑移植过来
这样虽然看起来慢,但实际上比直接改代码再反复调试要快得多。我在实际项目中用这个方法,从单客户端迁移到8客户端只用了两天,其中大部分时间花在测试上。
6.5 一个容易被忽略的细节:socket ID的复用
AS语言里,TCP_CLOSE之后socket ID可能会被系统回收,下次TCP_ACCEPT的时候可能分配到相同的ID。如果你的代码里用socket ID作为客户端的唯一标识,就会出现问题:旧客户端断开后,新客户端拿到相同的ID,但缓冲区里可能还有旧数据。
解决办法是不要用socket ID作为唯一标识,而是用数组下标i作为客户端编号。socket ID只用来收发数据,不参与逻辑判断。这个细节在客户端频繁断开重连的场景下特别重要。
7. 写在最后的一些个人体会
这套多客户端TCP服务器的方案,我在三个不同的产线项目里用过,最长的已经稳定运行了一年多。中间踩过的坑基本都写在上面了,但肯定还有没遇到的情况。AS语言虽然限制多,但把非阻塞轮询这个核心思路吃透之后,实现多客户端并发并不难。
如果你刚开始接触AS语言的TCP编程,建议先用一个客户端把基本流程跑通,再逐步增加客户端数量。不要一上来就写8个客户端的完整代码,那样出了问题很难定位。另外,调试的时候善用日志,AS语言没有断点调试功能,日志是唯一能追溯问题的手段。
代码里的HandleMessage子程序我留空了,因为每个项目的业务逻辑都不一样。你可以在里面解析客户端发来的指令,控制机器人运动,或者读取机器人状态返回给客户端。这部分就是你的业务代码,根据实际需求来写就行。