首页 / 资讯中心 / 文章详情

C#上位机与KUKA机器人TCP通讯及KRL联调实战

C#上位机与KUKA机器人TCP通讯及KRL联调实战 ★ FEATURED ARTICLE
简介面向工业自动化软件工程师与机器人调试人员这份方案围绕C#上位机与库卡机器人的TCP通讯展开方案适配库卡系统软件8.3版本目标是在线获取机器人各关节实时位置并实现远程运动控制。库卡端工程包含配置文件、子程序、主程序源代码与XML配置PC端为使用.NET Framework 4.0开发的C#上位机程序可将位置数据导出为CSV支持各关节单步运动及从当前点移动到给定坐标。压缩包内还收录了Ethernet KRL多语言说明文档和KSS系统软件资料对理解通讯参数配置、排查连接问题很有帮助。整个资源包共68个文件以C#源代码、KRL程序、XML配置、PDF手册和可执行程序为主压缩后大小约36.47MB目录分类清晰便于按模块查阅。目前已有743人在学习使用适合具备C#基础、希望进一步自主扩展库卡机器人控制功能的技术人员。1. 为什么C#上位机与KUKA机器人之间需要一条自有TCP通道车间调试位前最怕的就是数据不通。KUKA机器人本身有自己的示教器和总线协议但当你需要在工位电脑上实时看到机器人当前位置、批量下发目标点位、把运动控制逻辑和MES/视觉系统串起来时示教器那套交互方式根本撑不起自动化的需求。拿C#写一个上位机通过TCP直接和库卡机器人通信把实际位置实时拉回来、把运动指令推下去这是我在多个组装和搬运工位上验证过的最省事方案。TCP在这里的可信度常被低估。很多人一听TCP就条件反射说“慢”但真实工位场景里KUKA控制器的位置刷新路径本身就有几十毫秒级别的周期TCP的传输延迟在这个量级面前完全可以被吞掉。真正决定实时性的不是TCP而是你的协议设计、机器人端的程序结构、以及上位机有没有做无意义的阻塞。这篇文章要把这条链路拆开讲透C#端怎么连、数据帧怎么定、KRL端怎么配、断开重连怎么处理、跳位置怎么排查一步一步交代清楚。适合正在被“上位机连KUKA”这件事卡住或者准备从示教器手工操作升级成自动化工位的人。2. 方案选型与数据帧设计先把通讯结构定死再写代码TCP通讯看着自由一旦两边代码都写完了再改协议代价极高。我通常建议先花半天把数据帧、命令字、周期、超时全部定义成文档再动手写C#和KRL。这个顺序反着来后面基本都要返工。2.1 通讯选型为什么TCP是首选而不是UDP或现场总线C#上位机连KUKA可选的路有好几条。用TCP是默认首选原因是KUKA控制器上的EKIEthernet KRL Interface模块天然支持TCP套接字不需要额外买硬件选项TCP自带确认和重传机制报文丢了会重发数据顺序也不会乱这正是位置反馈和运动指令最需要的性质。UDP不是不能用但你要自己处理丢包、乱序、重复帧而上位机收到的位置数据一旦丢一帧画面上的坐标就会“抖”一下运动指令丢一帧甚至可能让机器人停下来等待或者执行错误行为。现场验收时解释这种底层问题非常麻烦所以UDP一般只用于实时性要求极高且自主开发通讯协议的场景普通工位没必要承担这个复杂度。现场总线也常见某公司做过Profibus或EtherCAT的方案延迟确实更低、更稳定。但它要求机器人端配置对应的总线选项还要买网关或者通讯板卡成本一下子就上去了。对于多数数控机床上下料、码垛、装配工位几十毫秒级别的响应完全够用。总结就是能用TCP解决的不要为了“显得专业”去引入总线。2.2 数据帧设计报文结构、字节序与校验数据帧的核心是让C#和KRL对同一段字节流的理解完全一致。我习惯把所有通讯内容统一封装成二进制帧结构如下字段类型长度说明帧头byte[2]2字节固定0x5A 0xA5用于找帧起始数据长度ushort2字节从命令字到校验前的字节数命令字byte1字节0x01位置请求、0x02位置响应、0x03运动指令、0x04指令确认指令序号byte1字节用于匹配请求和响应数据区变长按命令字定义坐标、速度、运动类型等CRC16校验ushort2字节对命令字到数据区末尾做CRC位置响应帧的数据区固定存放机器人当前笛卡尔坐标X、Y、Z和姿态角A、B、C每个值都用float类型4字节小端存储。加起来24字节整帧不超过40字节一条位置刷新约1千字节每秒TCP负载完全没有压力。运动指令帧稍微复杂一点数据区里要有目标点坐标、运动类型0表示PTP、1表示LIN、速度覆盖百分比、姿态参数这些字段都要在协议文档里写死字段顺序。C#端下发后必须等KUKA回一个带相同指令序号的确认帧才算这条运动指令真正被接收。协议里没有确认机制运动指令就是开环出问题很难定位。字节序一定要强调C#的BinaryWriter默认使用小端KUKA控制器内部是x86架构也是小端所以两端天然一致。但如果你用Java或者Python写测试工具模拟KUKA端字节序默认就是大端整条链路全乱。排查这类问题的时候先看字节序再查数据内容。2.3 实时性预算通讯周期与超时阈值“实时”这个词容易被过度神话。先说清楚C#上位机和KUKA之间的TCP通讯能做到的合理实时性是位置数据每20到50毫秒刷新一次运动指令从下发到机器人开始动端到端延迟在80到150毫秒之间。这个量级能满足绝大多数自动化工位需求。位置刷新的瓶颈主要在两处KRL程序的主循环扫描周期以及KUKA控制器内部把实际位置写入系统变量的频率。KRL里不能靠一条指令连续读取位置然后立刻发出去因为程序执行是顺序的IP互联的收发也要时间。常见做法是在KRL循环里每隔一小段时间读取一次$POS_ACT并发送循环周期通过等待指令控制。超时阈值建议这样设定接收位置数据时超过200毫秒没有收到任何有效帧上位机就标记通讯超时这时候画面上的坐标显示为灰色并弹出提示同时禁止下发运动指令心跳包每500毫秒发一次连续3次没有收到对方回应才判定连接断开。阈值设太短容易误报设太长又保护不了设备200毫秒和3次重试是我用过比较平衡的组合。3. C#上位机落地Socket连接、位置解析与运动指令下发通讯架构定好之后C#端主要做三件事管好连接、解析好位置、封好运动指令。很多人在这个阶段翻车不是代码写不出来而是细节没想清楚比如接收缓冲区处理不好半包、心跳和业务数据混在一个通道里互相阻塞这些问题都可以通过合理的代码结构避免。3.1 Socket连接管理与心跳保持C#端的连接管理我习惯拆成一个独立的类专门负责TcpClient的建立、断开、重连和心跳。核心是TcpClient连接时必须设置NoDelay属性把Nagle算法关掉。Nagle算法会把小数据包合并后再发送用于减少网络拥塞但在工控场景里它会让一条本该立刻到达的位置请求多等几十毫秒造成“网络不卡但数据很肉”的假象。public class KukaTcpClient { private TcpClient _tcpClient; private NetworkStream _stream; private CancellationTokenSource _cts; private readonly string _kukaIp; private readonly int _kukaPort; private readonly object _lock new object(); public KukaTcpClient(string ip, int port) { _kukaIp ip; _kukaPort port; } public bool Connect() { try { _tcpClient new TcpClient(); _tcpClient.NoDelay true; // 关闭Nagle降低小包延迟 _tcpClient.SendTimeout 1000; // 发送超时1秒 _tcpClient.ReceiveTimeout 1000; // 接收超时1秒 _tcpClient.Connect(_kukaIp, _kukaPort); _stream _tcpClient.GetStream(); return true; } catch (SocketException ex) { Console.WriteLine($[KukaTcp] 连接失败: {ex.SocketErrorCode}); return false; } } public void StartHeartbeat() { _cts new CancellationTokenSource(); Task.Run(async () { while (!_cts.IsCancellationRequested) { try { var heartbeat new byte[] { 0x5A, 0xA5, 0x00, 0x02, 0x10, 0x00, 0x00 }; byte[] crc Crc16.Calc(heartbeat, 2, 4); heartbeat[5] crc[0]; heartbeat[6] crc[1]; lock (_lock) { _stream.Write(heartbeat, 0, heartbeat.Length); } } catch (Exception ex) { Console.WriteLine($[KukaTcp] 心跳异常: {ex.Message}); } await Task.Delay(500); } }, _cts.Token); } }这里的心跳帧和业务帧共用同一条TCP连接但通过命令字区分0x10是心跳0x01是位置请求。共用连接的好处是只维护一个端口连接不需要额外开销坏处是心跳发送不能阻塞业务接收。所以写操作用了lock避免多个线程同时写一个NetworkStream导致数据交错。连接失败时按照指数退避重连比如第一次等1秒第二次等2秒最多等10秒避免工位电脑重启后疯狂重连把控制器端口占满。3.2 位置数据接收与解析从字节流到XYZABC接收位置数据是线程安全问题最容易扎堆的地方。NetworkStream读取数据时不可能保证每次Read都恰好拿到完整的一帧很常见的情况是第一次读到了半帧第二次读到了后半帧加下一帧的前半段。处理办法是维护一个字节缓冲区先把读到的数据追加进去再从缓冲区里循环查找帧头、判断长度、校验CRC、截取完整帧。public class PositionFrameParser { private readonly Listbyte _buffer new Listbyte(); public ListRobotPosition Parse(byte[] data) { var result new ListRobotPosition(); _buffer.AddRange(data); int index 0; while (_buffer.Count - index 6) { // 查找帧头 0x5A 0xA5 if (_buffer[index] ! 0x5A || _buffer[index 1] ! 0xA5) { index; continue; } // 读取长度字段判断完整帧是否到达 ushort len BitConverter.ToUInt16(_buffer.ToArray(), index 2); if (_buffer.Count - index - 4 len) break; // 数据还没收齐等下一轮 // 校验CRC16防止半包错位误判 byte[] frame _buffer.Skip(index).Take(len 4).ToArray(); byte[] crc Crc16.Calc(frame, 2, len - 2); if (crc[0] ! frame[2 len - 2] || crc[1] ! frame[2 len - 1]) { index; continue; } // 命令字0x02代表位置响应 if (frame[4] 0x02) { int offset 5; float x BitConverter.ToSingle(frame, offset); offset 4; float y BitConverter.ToSingle(frame, offset); offset 4; float z BitConverter.ToSingle(frame, offset); offset 4; float a BitConverter.ToSingle(frame, offset); offset 4; float b BitConverter.ToSingle(frame, offset); offset 4; float c BitConverter.ToSingle(frame, offset); offset 4; result.Add(new RobotPosition(x, y, z, a, b, c)); } index len 4; } // 移除已处理的数据保留不完整的半帧 _buffer.RemoveRange(0, index); return result; } }这段代码的关键在于“未收齐就break绝不丢弃还没用上的字节”。如果处理不完整帧时直接把缓冲区清了TCP分包导致的半包问题就会让你时不时丢一个位置点画面上表现为坐标偶尔跳一下。另外注意CRC计算范围是“命令字到数据区末尾”帧头和长度字段不参与校验这样帧头万一被干扰可以通过CRC失效来判断然后丢弃不至于把错数据当有效位置。解析出的位置数据要丢给UI线程刷新显示这里不建议直接在接收线程里改控件常见做法是把坐标塞进一个ConcurrentQueueUI用一个定时器每16毫秒取出最新一帧刷新。这样接收线程永远不阻塞UI刷新再慢也不会阻塞数据的持续接收。3.3 运动控制指令封装与确认等待机制运动指令下发比位置接收多一个要求必须等待机器人端确认。如果C#端只管发不管确认遇到通讯抖动机器人可能根本没收到指令而上位机还以为指令已经执行了整个工位就会完全失控。指令确认这事不能靠Sleep等待要用信号量或者TaskCompletionSource来做异步等待。public class MotionCommandService { private readonly KukaTcpClient _client; private readonly ConcurrentDictionarybyte, TaskCompletionSourcebool _pendingCommands; public async Taskbool SendMoveAsync(RobotPosition target, byte moveType, byte velocity) { var seq NextSequence(); // 递增指令序号从0循环到255 using var frame new MemoryStream(); frame.WriteByte(0x5A); frame.WriteByte(0xA5); // 长度占位后面回填 frame.WriteByte(0); frame.WriteByte(0); frame.WriteByte(0x03); // 运动指令 frame.WriteByte(seq); // 数据区X/Y/Z/A/B/C加运动类型和速度 WriteFloat(frame, target.X); WriteFloat(frame, target.Y); WriteFloat(frame, target.Z); WriteFloat(frame, target.A); WriteFloat(frame, target.B); WriteFloat(frame, target.C); frame.WriteByte(moveType); frame.WriteByte(velocity); // 回填长度并计算CRC byte[] body frame.ToArray(); ushort len (ushort)(body.Length - 4); body[2] (byte)len; body[3] (byte)(len 8); byte[] crc Crc16.Calc(body, 2, len - 2); // 注册确认回调 var tcs new TaskCompletionSourcebool(); _pendingCommands[seq] tcs; // 发送 _client.Send(body, crc); // 等待确认或超时 return await tcs.Task.WaitAsync(TimeSpan.FromMilliseconds(2000)); } }运动指令的数据区是六个float坐标加两个byte共26字节整帧34字节。指令序号sequel是设计里的关键它让确认帧和指令之间形成一对一匹配。如果机器人端回了确认但C#端因超时已经放弃了等待下一次下发同一个序号时就会和旧的确认匹配上造成误判所以seq必须每次递增。超时时间定为2秒超过就认为是通讯异常同时触发报警不允许自动重发。运动指令重发是非常危险的机器人可能已经执行了第一条重复下一条会把它送到意想不到的位置。4. KUKA机器人端配合WorkVisual配置与KRL程序C#端把发送逻辑写得再漂亮KUKA端不配合等于白搭。机器人端的配置和KRL程序是整个链路里时效性最强、也最容易踩坑的部分。很多人卡在这里不是因为复杂而是因为EKI的概念没理解透。4.1 WorkVisual项目配置与EKI通讯模块接入KUKA机器人的网络通讯选项叫EKI全称是Ethernet KRL Interface。在WorkVisual里打开机器人项目后需要从菜单里把EKI选项添加到控制器配置中。添加后项目里会生成XML格式的通讯配置文件这个文件定义了开放的端口号、数据发送和接收的缓冲区结构。EKI支持服务器模式也支持客户端模式。C#上位机主动连机器人所以机器人端配置为服务器模式监听一个固定端口比如50000。XML配置里可以定义多个发送缓冲区和接收缓冲区每个缓冲区有自己的名称和数据类型。常见的做法是定义两个发送缓冲区一个存位置反馈数据一个存确认状态再定义一个接收缓冲区专门接收上位机发来的运动指令。配置完成后的流程是先在WorkVisual里做语法检查和交叉编译生成新的系统文件然后传输到控制器。这一步需要注意的是EKI配置修改后控制器需要重启才能生效而且生产中途改配置会中断当前程序执行所以尽量安排在停机窗口做。很多现场机器人停机20分钟就改完配置重新上线了动作本身不复杂复杂的是别把现有的信号配置、安全配置一起改坏了。4.2 KRL程序结构位置循环发送与运动指令执行KRL程序的核心思路是在主循环里轮询EKI接收缓冲区有指令就解析、执行没指令就发送位置反馈。下面是一段简化的KRL逻辑示意重点展示流程实际指令名以你机器人上的EKI版本为准DEF KI_KUKA_TCP() ; 初始化EKI连接 EKI_INIT DECL INT cmdSeq DECL REAL targetX, targetY, targetZ DECL REAL targetA, targetB, targetC DECL BOOL newCommand LOOP ; 读取上位机下发的运动指令 newCommand ReadCommand(cmdSeq, targetX, targetY, targetZ, targetA, targetB, targetC) IF newCommand THEN ; 安全互锁检查确保外围条件允许运动 IF $IN[1] AND $IN[2] THEN ; 构造目标点并执行PTP运动 PTP P1:X, Y, Z, A, B, C ; 运动完成后回传确认 SendConfirm(cmdSeq) ELSE ; 互锁不满足回传错误状态 SendErrorState(cmdSeq) ENDIF ENDIF ; 读取实际位置并发送给上位机 SendPosition($POS_ACT.X, $POS_ACT.Y, $POS_ACT.Z, $POS_ACT.A, $POS_ACT.B, $POS_ACT.C) ; 控制循环节奏避免占满控制器资源 WAIT SEC 0.02 ENDLOOP END这段KRL程序有几个细节值得注意。第一位置反馈和指令接收在同一个循环里循环周期大约20毫秒WAIT SEC 0.02是为了留出时间让控制器处理内部任务不能删掉。第二$POS_ACT是控制器内部维护的实际位置变量直接读它就行不需要自己去调运动学接口。第三运动执行使用PTP适合不需要精确走直线的场景需要走直线就用LIN但LIN要求目标点和当前位置之间有可达路径上位机必须保证点位合理性。KRL里还有一个常见坑声明变量和执行指令之间EKI收发的数据缓冲区类型必须和XML里定义的完全一致。XML里如果定义的是REAL类型KRL里就必须用REAL接用INT接会截断数据。C#端发的float在机器人内部按REAL处理精度是够的。发送位置时用字符串拼XML再发送会导致循环周期翻倍所以尽量用二进制方式收发。4.3 机器人端的安全互锁与状态确认机制上位机连KUKA之后示教器不再是唯一的控制入口这本身就是一个安全边界问题。我强烈建议在KRL运动指令执行之前检查必要的安全信号这些信号来自外围设备安全门关闭、急停复位、气路压力正常、工件到位。任何一条不满足都不应该执行运动指令只回传错误状态。状态确认机制不能省。KRL执行完运动后要把“已到位”“运动中”“出错”这些状态实时回传上位机。C#端根据状态决定是否下发下一条指令。比如上下料工位机器人从工位A抓料放到工位B上位机必须等到“放置完成”信号才能通知下一台设备启动。这个机制不是通讯层面的需求是工艺逻辑的需求。机器人的$ALARM_STOP、$STOPMESS等系统状态信号也可以读出来放进发送帧里。当控制器进入急停状态时上位机界面要能立即显示“急停触发”而不是继续显示“连接正常”这种细节在现场验收时很加分。5. 常见问题与排查连不上、跳变、延迟逐条拆解TCP通讯链路跨了上位机和控制器两个系统出问题后第一反应不要猜按链路逐段排查。这一章列了5个我实际踩过、别人也反复踩的坑每个都按现象、原因、解决写清楚。5.1 现象C#连KUKA总是失败或连上一会就断开原因最常见的就这么几个IP地址配置错了工位网段、机器人端EKI服务器没有启动、防火墙拦了端口、控制器上的连接数达到上限。排查先ping通机器人IPping不通就查网线和IP能ping通但TCP连不上大概率是EKI没启动或者端口没监听。用一根网线直连排除交换机问题是最快的定位手段。解决方法是建立一套固定的联调检查顺序第一步确认C#电脑的IP和机器人控制器IP在同一个网段第二步在KRL程序里显式调用EKI的启动指令第三步用网络工具测试端口连通性。KUKA控制器的防火墙在默认配置下一般不拦截工位内部通讯但如果接入了车间管理网络就要检查网关有没有ACL策略。5.2 现象位置数据偶尔跳变刷新出来的坐标突然变成零跳变通常不是网络问题而是接收缓冲区处理半包时丢字节导致解析错位。比如C#端收到两帧位置数据粘在一起第一帧读了后半段第二帧开头就被误判成新帧头坐标自然乱掉。还有一种可能KRL发数据时用了字符串格式上位机却按二进制解析此时浮点数被拆成四个字节坐标值会变成极大的乱数。解决方法是严格按第3.2节的解析逻辑处理缓冲区必须保留半帧数据CRC校验必须启用校验失败就丢弃当前帧重新寻找帧头。把CRC从“可选优化”当成“必需功能”之后跳变问题基本绝迹。如果改了这些仍然跳去机器人侧看KRL里发送位置用的变量是不是在用$AXIS_ACT这种轴坐标而C#端按笛卡尔坐标解析两者混用也会让位置看起来像在飞。5.3 现象运动指令下发后机器人反应迟钝延迟明显变大延迟大的第一嫌疑人就是Nagle算法。C#的TcpClient只要不设置NoDelaytrue小包数据就会被内核攒着凑满一个TCP段才发出去。位置刷新的小帧特别容易触发。第二嫌疑人是KRL循环里的WAIT SEC时间过长比如设了0.5秒每帧位置和每条指令都要等500毫秒必然“肉”。解决办法是C#端设置NoDelaytrueKRL的WAIT时间降到20到50毫秒指令执行时不要等完全到位才发确认把“开始执行”和“执行完成”拆成两种状态上位机收到“开始执行”就可以准备下一条不用干等。千万注意别把NoDelay理解成TCP无关紧要的选项它在这条链路上往往是瓶颈来源。5.4 现象上位机或者机器人重新启动后连接无法自动恢复连接的本质是两端状态需要同步。上位机重启后重新发起TCP连接机器人端如果KRL程序还在旧的连接状态里保持循环新连接就会被拒绝机器人重启后EKI配置加载完成前C#端反复连接全部失败。两个方向都必须做自动重连设计。解决方法是C#端指数退避重连KRL端在每次LOOP开始时检测连接状态发现断开就重新初始化EKI。重连之后把指令序号重置为0避免新的连接带着旧序号和机器人端保存的序号冲突。还有一个容易被忽略的细节重连成功后C#端要主动请求一次位置数据刷新界面不能等机器人主动推否则界面上一直显示断开前的旧坐标。5.5 现象偶发CRC校验失败抓日志发现字节错位CRC失败绝大多数是粘包和半包处理不严谨导致的也有例外是上位机里存在多个线程同时写NetworkStream两个线程的数据交错写入帧内容错乱。另一个隐蔽来源是缓冲区数组没有正确清理旧数据混进新帧。解决办法是发送端统一走lock确保一次Write是原子操作接收端严格维护缓冲区处理完的字节立即移除字节错位问题可以在地面日志里打印原始hex来定位把出错帧的每一字节打出来和正常帧对照基本一眼就能看出是半包错位还是内容交错。串口和网口排错思路相似先抓包再改代码是一条不容易跑偏的路。6. 进阶验证回环测试与位置平滑技巧6.1 用回环测试量化链路延迟常规联调通过后我会做一轮回环测试来量化真实延迟。做法是C#端下发一条运动指令在指令字节里附加当前时间戳KRL端收到后把机器人实际目标位置回传C#端收到响应时计算时间差。这里要先排除界面刷新的干扰在接收线程里打日志记录时间戳不用UI显示做参考。网络延迟和机械执行延迟要分开测。只测通讯链路时下发一条“空指令”机器人不运动只回传确认这趟往返时间就是纯粹的链路延迟正常应该在5到20毫秒之间。如果超过20毫秒从TCP缓冲区和KRL循环周期找原因。加上运动执行后延迟会从几十毫秒到几百毫秒不等这部分是机械特性不能用网络手段优化。6.2 位置反馈的平滑处理位置数据从KRL发过来即使CRC全部通过画面上也可能出现几个厘米的抖动。这通常是机器人末端带着负载高速运动时控制器对实际位置的采样值本身就有波动。显示层面的轻微波动可以通过一阶低通滤波处理但要小心滞后滤波系数太大会让人觉得位置“拖影”。public class LowPassSmoother { private readonly float _alpha; private RobotPosition _last; public RobotPosition Smooth(RobotPosition current) { if (_last null) { _last current; return current; } float x _alpha * current.X (1 - _alpha) * _last.X; float y _alpha * current.Y (1 - _alpha) * _last.Y; float z _alpha * current.Z (1 - _alpha) * _last.Z; _last new RobotPosition(x, y, z, current.A, current.B, current.C); return _last; } }_alpha取0.6到0.8之间比较合适太小会导致位置变化滞后明显。注意姿态角A、B、C只需要显示平滑不需要参与数值滤波姿态角跳变往往是多轴联动时奇异点导致的滤波解决不了根因。要真正确认位置精度还是要靠机器人端示教器读取的值做参照上位机显示只能作为人机交互参考不能作为验收依据。6.3 断线重连后的数据一致性检查最后一个习惯每次重连成功后强制进行一次全量状态同步。C#端主动请求机器人当前坐标、当前状态、当前指令序号再开始正常的周期通讯。不做这一步机器人在断线期间可能已经在执行别的任务上位机却用旧状态继续判断容易出现工艺错乱。这条链路只要走完几个现场你就会明白稳定通讯的核心不是把它写得花哨而是协议定死、状态同步、超时可控。我自己的教训是“把心跳和业务逻辑耦合在一起后排查问题会多花三倍时间”后来把心跳独立、命令序号全局唯一通讯才算真正省心。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站