☰
STM32 Modbus RTU从机实现:CRC16、DMA收帧与功能码联调
2026/10/30 3:32:55 网站建设 项目流程

简介:这份压缩包面向STM32-F0/F1/F2单片机与嵌入式学习者,聚焦在STM32平台上实现Modbus通信的测试工程,适合已具备GPIO、定时器、中断和串口基础的开发者用来理解主从站交互、帧格式解析及工业现场通信的落地方式。包内共70个文件、约727KB,以9个C源文件和9个头文件承载业务与外设驱动代码,并配有工程配置、编译中间产物、HEX与MAP等输出文件,可配合Keil环境查看完整构建关系;模块划分涉及串口、DMA、定时器、LCD、LED及Modbus相关目录,便于按功能定位实现。已有165人学习,说明其对入门与课程实践具有一定参考性。读者可从中梳理Modbus RTU/ASCII的地址、功能码、寄存器读写、校验与超时错误处理,也能借鉴UART参数配置、从站中断服务、主站请求响应流程以及ST-LINK/J-Link下载调试思路,为后续集成协议栈或连接PLC、传感器等设备打下基础。

1. 从一块只焊了 USART2 的板子说起:modbusTest 包里到底装了什么

上个月帮人看一块 STM32F103 的最小系统板,Modbus 从机死活不回数据,示波器上波形规规矩矩,上位机却一直超时。最后发现是 CRC 校验字节高低位接反了,整整排查了半天。这类问题在单片机/嵌入式 STM32-F0/F1/F2 专区里出现的频率极高——协议本身不复杂,难的是收发时序、帧边界判断和寄存器地址映射这些"脏活"。电子-modbusTest.zip 这个包围绕的就是这件事:把 Modbus RTU 在 STM32 上的从机侧实现和联调流程固定成一套可复现的框架,包含协议帧解析、CRC16 校验、串口收发驱动、功能码处理以及配套的测试方法。它适合三类人:刚接触 Modbus 想跑通第一个从机的嵌入式新手、手里有 F0/F1/F2 老平台需要快速补协议栈的工程师、以及调试中怀疑"到底是硬件还是协议"的排查者。

2. 先把帧啃透:RTU 报文结构、CRC16 与字节序的三个硬约束

很多人拿到这类包第一反应是直接编译下载,其实更省时间的做法是先花二十分钟把 RTU 帧的字节摆清楚。后面的收发驱动、超时判断、异常响应,全部建立在对这几十个字节的理解上。如果帧结构没想明白,后面任何一个 printf 打出来的十六进制都会让你怀疑人生。

2.1 一帧 RTU 报文到底由哪几段组成

Modbus RTU 的一帧由四段拼成:从机地址(1 字节)、功能码(1 字节)、数据域(N 字节)、CRC 校验(2 字节)。帧与帧之间靠总线空闲时间分隔,不靠任何起始符或结束符,这是它和 ASCII 模式最本质的区别,也是后面所有"帧边界"问题的根源。

字段长度说明典型值
从机地址1 字节0 为广播,1~247 为单播地址0x01
功能码1 字节高位为 1 表示异常响应0x03 / 0x06 / 0x10
数据域0~252 字节随功能码变化起始地址 + 数量
CRC162 字节低字节在前,高字节在后由前 N 字节算出

以读保持寄存器为例,主机发01 03 00 00 00 0A C5 CD,含义是"1 号从机,读功能码 03,起始地址 0x0000,读 10 个寄存器"。从机回的帧里数据域第一个字节是字节数(10 个寄存器 = 20 字节),后面才是寄存器内容。这个"字节数 = 寄存器数 × 2"的关系看着简单,实际写代码时漏掉它,上位机就会把数据错位解析,读出来的值全是乱的。

2.2 CRC16 为什么必须低字节先发

CRC16/MODBUS 的生成多项式是 0xA001(反向的 0x8005),初值 0xFFFF,不取反、不异或输出。算完之后,低字节放在帧尾第一个位置,高字节放第二个。这一点是新手翻车最集中的地方——代码里算出0xCDC5,直接按高字节在前塞进缓冲,发出去变成CD C5,而从机期望的是C5 CD,校验必然不过。

包里的 CRC 实现一般是查表或位运算两种写法,位运算版本更直观,也方便移植到资源紧张的 F0 上:

/* CRC16/MODBUS:初值 0xFFFF,多项式 0xA001,右移算法 */ uint16_t mb_crc16(const uint8_t *buf, uint16_t len) { uint16_t crc = 0xFFFF; while (len--) { crc ^= *buf++; /* 低 8 位先异或 */ for (uint8_t i = 0; i < 8; i++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; /* 调用方负责低字节先入帧 */ } /* 在帧尾追加校验,注意顺序 */ void mb_append_crc(uint8_t *frame, uint16_t len) { uint16_t crc = mb_crc16(frame, len); frame[len] = (uint8_t)(crc & 0xFF); /* 低字节 */ frame[len + 1] = (uint8_t)(crc >> 8); /* 高字节 */ }

这段代码有两个容易忽略的点。第一,crc ^= *buf++是拿数据的整个字节和 CRC 低 8 位异或,不是逐位比较,写错这一行结果全错。第二,追加 CRC 时长度参数传的是"不含 CRC 的部分",调用方如果传了len + 2,校验就会算到自己头上,从机永远判不过。我一般会在函数入口加一句断言或者返回值检查,把这种低级错误挡在编译期之外。

2.3 异常响应与广播帧的处理边界

从机收到帧之后,不是只有"正确应答"和"沉默"两种结果。当地址匹配但功能码不支持、寄存器地址越界、数量超限时,要回异常响应:功能码最高位置 1,数据域放一个异常码。比如 0x03 变成 0x83,异常码 0x02 表示非法数据地址,0x03 表示非法数据值。

/* 构造异常响应:功能码 | 0x80,后跟异常码 */ void mb_build_exception(uint8_t *out, uint8_t addr, uint8_t func, uint8_t code) { out[0] = addr; out[1] = func | 0x80; out[2] = code; mb_append_crc(out, 3); /* 异常帧固定 5 字节 */ }

广播地址 0x00 是另一个边界:所有从机都要执行写操作,但一律不回复。如果代码里把地址判断写成if (addr == my_addr),广播帧会被直接丢掉,主机侧会以为写失败。正确写法是先判断addr == 0走"执行但不回复"分支,再判断单播地址。这两条规则不写进状态机,联调时会表现为"读正常、写广播没反应",很容易被误判成硬件问题。

3. 串口收发的底座:DMA + 空闲中断 + 3.5 字符定时器怎么配

帧结构清楚之后,接下来要解决的是"怎么把一串字节完整地收进来,并且知道它什么时候结束"。这是整个从机实现里最容易出玄学的地方:轮询方式会丢字节,单纯中断方式在高波特率下 CPU 被吃掉,只有 DMA 加空闲中断加超时定时器这套组合,才能在 F0/F1/F2 这种资源有限的平台上稳定跑起来。

3.1 DMA 循环接收与 IDLE 中断的分工

DMA 负责把 USART 数据寄存器里的字节搬到内存缓冲,全程不打扰 CPU;IDLE 中断负责在总线静默一个字符时间后告诉你"这一批搬完了"。两者配合的前提是 DMA 必须工作在循环模式,否则收满一次就停了,第二帧开始全部丢失。

#define MB_BUF_SIZE 256 /* 必须 ≥ RTU 最大帧长 256 字节 */ uint8_t mb_rx_buf[MB_BUF_SIZE]; void mb_port_start(void) { /* 循环模式启动 DMA 接收,长度给最大值 */ HAL_UART_Receive_DMA(&huart2, mb_rx_buf, MB_BUF_SIZE); /* 打开空闲中断,用于判断一帧结束 */ __HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE); } /* USART2 中断服务函数里的空闲处理 */ void USART2_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart2); /* 清标志,顺序不能反 */ uint16_t remain = __HAL_DMA_GET_COUNTER(huart2.hdmarx); uint16_t len = MB_BUF_SIZE - remain; /* 已收字节数 */ if (len > 0 && len <= MB_BUF_SIZE) { mb_frame_indicate(len); /* 交给协议层处理 */ } } HAL_UART_IRQHandler(&huart2); }

这里的关键参数有三个。缓冲大小 256 是 RTU 帧的理论上限,给小于这个值,长帧会被截断,表现为"读十几个寄存器正常,读几十个就超时"。__HAL_DMA_GET_COUNTER返回的是剩余可搬字节数,用总长减掉它才是本轮收到的长度,这个减法方向写反的人不在少数。清 IDLE 标志必须在读计数器之前,部分系列的标志清除依赖"先读 SR 再读 DR"的序列,HAL 的宏已经封装好了,自己手写寄存器时容易漏。

3.2 F0/F1/F2 三个系列的差异点

这三个系列在串口和 DMA 上的架构不完全一样,直接照搬代码会踩到通道映射的坑。F1 和 F2 的 DMA 是"通道固定映射"结构,USART2_RX 只能走 DMA1_Channel6;F0 也是通道结构但外设编号不同;F2 更进一步,用的是 Stream + Channel 两级选择,一个 Stream 上挂多个可选 Channel。

项目STM32F0STM32F1STM32F2
DMA 架构Channel 固定映射Channel 固定映射Stream + Channel 可选
USART2_RX 常见通道DMA1_Channel5DMA1_Channel6DMA1_Stream5 / Channel4
空闲中断标志清除读 SR + 读 DR读 SR + 读 DR读 SR + 读 DR
时钟源注意点USART 时钟来自 PCLK,需使能USART1 在 APB2,其余 APB1同 F1,注意 APB 分频
推荐做法CubeMX 生成后核对通道号同左核对 Stream 与 Channel 组合

用 CubeMX 起手是最省事的,它会自动填好通道号和中断向量。但生成之后一定要打开stm32fxxx_it.c和 DMA 初始化那段代码看一眼,确认接收通道确实是 USART 对应的那个。我在 F2 上遇到过一次 Stream 选对了、Channel 选错的情况,现象是能进中断但计数器一直不减少,查了半天才发现 DMA 根本没在搬数据,这种"静默失败"比报错还难定位。

3.3 3.5 字符超时定时器怎么算

IDLE 中断的触发条件是总线空闲一个字符时间,但 Modbus 规范要求帧间隔至少 3.5 个字符。波特率高于 19200 时规范允许固定用 1.75 ms,低于这个值时就得实算。以 9600 波特率、8 数据位、1 停止位、无校验为例,一个字符 10 bit,耗时约 1.04 ms,3.5 字符约 3.65 ms,取整到 4 ms 比较稳。

/* 定时器 1 MHz 计数,1 tick = 1 us * 9600 波特率:1 字符 ≈ 1042 us,3.5 字符 ≈ 3646 us * 向上取 4000 us,留出中断响应裕量 */ #define MB_T35_US 4000 void mb_t35_start(void) { __HAL_TIM_SET_COUNTER(&htim3, 0); __HAL_TIM_SET_AUTORELOAD(&htim3, MB_T35_US - 1); HAL_TIM_Base_Start_IT(&htim3); } /* 定时器溢出中断:确认总线真的静默了 3.5 字符,再交给协议层 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { HAL_TIM_Base_Stop_IT(&htim3); mb_rx_complete(); /* 触发一次完整帧的解析 */ } }

参数的调整逻辑是这样的:波特率变了,MB_T35_US必须跟着变,115200 下 3.5 字符只有约 300 us,还按 4000 设会导致从机响应慢半拍,主机侧超时时间卡得紧就会误判。反过来,如果定时器时基设成 1 kHz 而不是 1 MHz,精度只剩 1 ms,低速下抖动会很明显。我个人的习惯是把这个值做成宏,跟波特率配置放在同一个头文件里,改一处就够用。

4. 从机状态机落地:03/06/16 三个功能码的请求应答怎么走

驱动层把字节收全之后,剩下的工作是解析、查表、构造响应。这部分代码的结构决定了后期加功能码、加寄存器区有多痛苦。见过不少项目把解析逻辑写成一大坨 if-else,加一个功能码要改三个地方,改完还不知道别的地方有没有被影响。

4.1 地址匹配、功能码分发与寄存器区划分

从机处理的顺序是固定的:先校验 CRC,再判断地址是否为本机或广播,然后按功能码分发,最后统一追加 CRC 发出。CRC 校验放在最前面是有讲究的——校验不过的帧直接丢弃,不做任何解析,避免坏数据污染寄存器。

#define MB_REG_NUM 128 /* 保持寄存器数量,按实际需求改 */ uint16_t mb_holding_reg[MB_REG_NUM]; /* 4x 区,0 基地址 */ void mb_frame_indicate(uint16_t len) { if (len < 4 || len > MB_BUF_SIZE) return; uint16_t crc_calc = mb_crc16(mb_rx_buf, len - 2); uint16_t crc_recv = mb_rx_buf[len - 2] | (mb_rx_buf[len - 1] << 8); if (crc_calc != crc_recv) return; /* 校验失败静默丢弃 */ uint8_t addr = mb_rx_buf[0]; uint8_t func = mb_rx_buf[1]; uint8_t resp[256]; uint16_t resp_len = 0; if (addr != MB_MY_ADDR && addr != 0x00) return; /* 非本机地址 */ switch (func) { case 0x03: resp_len = mb_func03(resp, len); break; /* 读保持寄存器 */ case 0x06: resp_len = mb_func06(resp, len); break; /* 写单个寄存器 */ case 0x10: resp_len = mb_func16(resp, len); break; /* 写多个寄存器 */ default: resp_len = 0; mb_build_exception(resp, addr, func, 0x01); break; } if (resp_len > 0 && addr != 0x00) { /* 广播不回复 */ rs485_tx(); /* 切发送方向 */ HAL_UART_Transmit_DMA(&huart2, resp, resp_len); } }

这段代码里的几个宏和参数值得说明。MB_REG_NUM是按实际业务改的,给 128 意味着 4x 区地址范围是 0~127,越界请求要回异常码 0x02。crc_recv的拼装顺序是"低字节在前",和前面发送侧严格对应。广播地址判断放在分发之后、发送之前,保证广播帧被执行但不产生应答。默认分支返回异常码 0x01(非法功能码),上位机看到83 01就知道从机不支持这个功能,而不是干等超时。

4.2 读功能码 03 的应答构造

03 的请求帧固定 8 字节,数据域是起始地址和寄存器数量,各 2 字节大端。响应帧的数据域第一个字节是字节数,后面紧跟寄存器值,同样大端排列。

uint16_t mb_func03(uint8_t *resp, uint16_t len) { uint16_t start = (mb_rx_buf[2] << 8) | mb_rx_buf[3]; uint16_t count = (mb_rx_buf[4] << 8) | mb_rx_buf[5]; /* 数量上限 125,且起始 + 数量不能越界 */ if (count == 0 || count > 125 || (uint32_t)start + count > MB_REG_NUM) { mb_build_exception(resp, mb_rx_buf[0], 0x03, 0x02); return 5; } resp[0] = mb_rx_buf[0]; resp[1] = 0x03; resp[2] = (uint8_t)(count * 2); /* 字节数 */ for (uint16_t i = 0; i < count; i++) { uint16_t v = mb_holding_reg[start + i]; resp[3 + i * 2] = (uint8_t)(v >> 8); /* 高字节在前 */ resp[4 + i * 2] = (uint8_t)(v & 0xFF); } uint16_t total = 3 + count * 2; mb_append_crc(resp, total); return total + 2; }

参数上有两条硬约束:数量上限 125 是规范定的,超过就得分多次读;字节数字段是count * 2,如果一个寄存器占 4 字节(浮点),这个字段和循环里的搬运方式都要改,不能只改一处。还有start + count的比较要转成 32 位再比,直接两个 uint16 相加在地址接近 65535 时会溢出回绕,越界判断就失效了。

4.3 写功能码 06 与 10 的回显差异

06 写单个寄存器最省事,请求和响应完全一样,从机执行完把原帧发回去即可。10 写多个寄存器稍微复杂,请求帧里带起始地址、数量、字节数和数据,响应帧只回起始地址和数量,不回数据。

uint16_t mb_func16(uint8_t *resp, uint16_t len) { uint16_t start = (mb_rx_buf[2] << 8) | mb_rx_buf[3]; uint16_t count = (mb_rx_buf[4] << 8) | mb_rx_buf[5]; uint8_t bytes = mb_rx_buf[6]; if (count == 0 || count > 123 || bytes != count * 2 || (uint32_t)start + count > MB_REG_NUM) { mb_build_exception(resp, mb_rx_buf[0], 0x10, 0x02); return 5; } for (uint16_t i = 0; i < count; i++) { mb_holding_reg[start + i] = (uint16_t)((mb_rx_buf[7 + i * 2] << 8) | mb_rx_buf[8 + i * 2]); } /* 响应只回显起始地址和数量 */ resp[0] = mb_rx_buf[0]; resp[1] = 0x10; resp[2] = mb_rx_buf[2]; resp[3] = mb_rx_buf[3]; resp[4] = mb_rx_buf[4]; resp[5] = mb_rx_buf[5]; mb_append_crc(resp, 6); return 8; }

10 的数量上限是 123 而不是 125,因为请求帧里多占了地址、数量、字节数三个字节的头部开销。字节数字段必须和下发的数据长度严格相等,不等就回异常码 0x03。写完寄存器之后要不要立即保存到 Flash、要不要触发业务动作,属于应用层的事,不要塞进协议解析函数里,否则以后加一个寄存器就得改一次解析代码。

5. 排查手册:Modbus 从机跑不通的五种现象与定位路径

协议和驱动都写完之后,真正的麻烦才开始。下面这几条是我和身边人反复踩过的,按"现象—原因—解决"整理,遇到问题可以逐条对号入座,比从头翻规范快得多。

5.1 CRC 校验一直失败,波形却很正常

现象:示波器上或者串口助手能看到从机收到的字节和主机发出的完全一致,但从机就是不回,单步调试发现 CRC 比较结果为假。

原因:CRC 计算的范围不对,或者接收端拼装校验值的字节序和发送端不一致。常见情况是发送侧用(crc >> 8) | (crc << 8)交换了字节,接收侧却按原顺序拼;另一种是计算时把 CRC 两个字节本身也算进去了。

解决:在收发两侧打印出参与计算的原始字节和算出的 CRC 值,逐字节比对。我一般会在校验失败分支里加一行调试输出,把len、crc_calc、crc_recv打出来,一眼就能看出是范围问题还是字节序问题。这个"后悔药"建议保留到项目稳定之后再删。

5.2 上位机读到的值整体偏移一位

现象:用调试工具按地址 40001 读,读出来是 0;按 40000 读才对,或者读到的数值和预期差一个寄存器。

原因:Modbus 的寄存器地址有 0 基和 1 基两种表示。协议报文里传的是 0 基偏移量,而上位机界面上显示的 40001 是 1 基编号,两者差 1。有些工具默认按 1 基显示,有些可以切换,混用就会错位。

解决:统一在代码里用 0 基地址,和报文保持一致;上位机侧把显示模式改成 0 基或者手动减 1。团队协作时把这个约定写进注释,比事后解释省事得多。这个坑几乎每个新接触 Modbus 的人都躲不过。

5.3 偶发丢帧,短帧正常长帧必挂

现象:读 2 个寄存器稳定,读 20 个寄存器偶尔超时,读 50 个基本必失败。

原因:DMA 接收缓冲小于实际帧长,长帧被截断;或者 IDLE 中断里读计数器的时间点太晚,数据已经被下一轮覆盖;还有一种是 3.5 字符定时器设得太短,正常帧内字节间隔被误判成帧结束。

解决:先把缓冲扩到 256 字节并确认 DMA 是循环模式,再把超时定时器的值按实际波特率重算一遍。判断是不是超时误判,可以在每次帧结束时打印收到的长度,正常应该等于请求帧长度,如果总是偏短且偏短的位置不固定,基本就是帧被切碎了。

5.4 RS485 方向切换过早,回帧被自己吃掉

现象:从机确实构造了响应并调用了发送,但主机收不到;或者多个从机同时挂在总线上时通信时好时坏。

原因:发送完成中断用的是 TXE(发送数据寄存器空)而不是 TC(发送完成),TXE 触发时最后一个字节还在移位寄存器里没发完,此时就把 DE 拉低切回接收,总线上的最后一个字节被截断。另外,切回接收后如果立刻开启接收中断,可能把自己刚发的回波收进来。

解决:用HAL_UART_TxCpltCallback做方向切换,它对应的是 TC 事件;切换后加一个几十微秒的延时再开接收。如果芯片支持 RS485 硬件方向控制,优先用硬件,省掉软件时序的麻烦。

5.5 F0 上移植后进不了中断,或者进去就卡死

现象:同一份代码在 F1 上正常,换到 F0 编译通过但 IDLE 中断从不触发,或者触发一次之后程序跑飞。

原因:F0 的中断向量名、DMA 通道映射和 F1 不同,直接复制工程会漏掉启动文件里的向量注册;另外 F0 的 USART 时钟源配置和 F1 有差异,时钟没使能时外设看起来初始化成功,实际根本不工作。

解决:用 CubeMX 重新生成一份 F0 工程,把业务代码迁过去,而不是手工改工程配置。迁完之后重点核对三处:启动文件里的中断函数名、DMA 通道号、USART 时钟使能位。这三处对齐了,剩下的协议层代码一行都不用改。

6. 用上位机把链路跑满:压测脚本与一个寄存器映射技巧

功能跑通只说明"能用",离"稳"还有距离。真实现场的总线负载、电磁环境、多从机轮询节奏,和实验室里点一下读一次完全是两回事。我会在交付前用脚本压一轮,把超时率、异常码分布和长帧稳定性都过一遍,问题提前暴露比现场返工便宜太多。

6.1 用 Python 写一个可复用的压测脚本

上位机工具点按钮适合手工验证,做压力测试还是脚本方便。核心是三个统计量:成功次数、超时次数、CRC 或异常响应次数。请求帧自己拼,CRC 自己算,不依赖任何第三方 Modbus 库,这样才能确认问题到底出在从机还是在库上。

import serial, time, random def crc16(data: bytes) -> int: crc = 0xFFFF for b in data: crc ^= b for _ in range(8): crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1 return crc def build_read(addr: int, start: int, count: int) -> bytes: body = bytes([addr, 0x03, start >> 8, start & 0xFF, count >> 8, count & 0xFF]) c = crc16(body) return body + bytes([c & 0xFF, c >> 8]) # 低字节在前 ser = serial.Serial("COM3", 9600, timeout=0.1) ok = timeout_cnt = bad = 0 for i in range(2000): start = random.randint(0, 100) # 随机起始地址,覆盖边界 req = build_read(1, start, 10) ser.write(req) resp = ser.read(25) # 10 个寄存器 = 25 字节 if len(resp) == 0: timeout_cnt += 1 elif len(resp) >= 5 and resp[1] & 0x80: bad += 1 # 异常响应,记下来看异常码 else: ok += 1 time.sleep(0.01) # 模拟轮询间隔 print(f"ok={ok} timeout={timeout_cnt} exception={bad}")

参数上,timeout=0.1对应 100 ms 响应窗口,现场如果从机处理慢可以放宽到 200 ms;range(2000)是轮询次数,按需调整;随机起始地址是为了覆盖越界和边界地址,固定读同一段测不出隐藏问题。跑完之后如果超时率超过千分之一,就值得回去查 5.3 和 5.4 两条。

6.2 把寄存器地址做成映射表而不是硬编码

功能码函数里如果直接写mb_holding_reg[5]这种魔法数字,业务一改就得全局搜索替换,非常容易漏。我习惯在协议层和应用层之间加一张映射表,协议层只认偏移量,应用层通过表找到对应的变量。

偏移含义数据类型读写
0x0000设备状态字uint16只读
0x0001采样值高字uint16只读
0x0002采样值低字uint16只读
0x0010目标值设定uint16读写
0x0020校准系数uint16读写

表驱动的好处是越界判断只需要和表的长度比,新增一个寄存器就是加一行,不用改分发逻辑。浮点数据拆成高低两个字放在连续偏移上,上位机按 32 位解析即可,这个做法在 F0 这种没有硬件浮点、又不想在协议层引入 float 的场景里特别顺手。

我这几年养成的习惯是:任何带总线的项目,交付前必须让压测脚本连续跑够两万次不出一次超时,才敢往现场送。中间吃过一次亏,实验室里点了几十次都正常,装到设备上第一天就偶发丢包,回头查是方向切换的延时太紧,把脚本压测这一步补上之后,同类问题再没漏过。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询