ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

STM32移植野火PID调试助手协议:实现PID参数在线整定与波形实时观测

STM32移植野火PID调试助手协议:实现PID参数在线整定与波形实时观测 1. 为什么值得把野火PID调试助手协议搬进自己的项目搞电机控制的朋友大概率都经历过这个场景辛辛苦苦把电流环、速度环、位置环都调通了波形也能跑起来但一到整定PID参数就抓瞎。改一次参数、重新编译、烧录、观察波形一轮下来五分钟没了一天下来调不出三组像样的参数。更别提三环级联的时候内环外环互相耦合靠盲调基本等于买彩票。野火出的那个PID调试助手本质上解决的就是这个痛点。它把下位机的实时数据通过串口上传到上位机上位机画出曲线你在电脑上拖动滑块就能改Kp、Ki、Kd下位机立刻生效波形实时刷新。这套东西如果自己从零写通信协议、数据打包、波形渲染、参数下发没个三五天搞不定。但如果你直接把它的通信协议移植到自己的STM32工程里半天就能用上。这篇文章面向的是已经在做电机控制、手里有STM32工程、想快速接入上位机调试工具的开发者。不管你是用霍尔编码器做速度闭环还是用位置式PID做位置控制或者是级联PID做三环调速这套协议都能直接用。我会把协议格式、移植步骤、代码实现、踩坑经验全部拆开讲清楚你照着抄作业就行。先说清楚一个前提野火PID调试助手支持两种模式一种是简单模式只发数据一种是高级模式支持参数读写。我们移植的是高级模式协议因为只有高级模式才能实现上位机改参数、下位机实时生效这个核心功能。简单模式只能看波形调参还得重新烧录意义不大。2. 协议格式拆解与通信帧设计逻辑2.1 帧结构全貌与各字段作用野火PID调试助手的通信协议是一个典型的自定义串口协议帧结构不算复杂但每个字段都有明确用途。完整的一帧数据由帧头、命令字、数据长度、数据载荷、校验和、帧尾组成。字段字节数说明帧头2固定为0xAA 0x55命令字1区分数据方向和数据类型数据长度1载荷字节数数据载荷N实际传输的数据校验和1从帧头到载荷的累加和取低8位帧尾1固定为0x5A帧头用0xAA 0x55是有讲究的。0xAA的二进制是101010100x55是01010101两者交替出现在示波器上非常容易识别也方便接收方做字节对齐。很多自定义协议喜欢用单个0xFF做帧头但0xFF在数据中出现的概率太高容易误判。双字节帧头加上固定帧尾基本可以保证帧同步的可靠性。命令字决定了这一帧是干什么的。上位机发给下位机的命令包括读取参数、写入参数、启动数据上传、停止数据上传。下位机发给上位机的命令包括参数应答、数据上传。每个命令字对应一个固定的数值移植的时候需要和上位机保持一致。数据长度字段告诉接收方后面有多少个有效数据字节。这个字段的存在让协议变得灵活不同命令可以携带不同长度的数据接收方不需要为每种命令单独写解析逻辑。校验和用的是最简单的累加和取低8位。虽然CRC校验更可靠但累加和计算量小在STM32上几乎不占时间而且对于短帧数据来说累加和的检错能力已经够用了。实际测试下来只要串口线不太长、波特率不太高累加和基本不会漏检。2.2 数据上传帧的格式与浮点数传输下位机上传的数据帧是最常用的。每一帧可以携带多个通道的数据每个通道用4个字节的float表示。为什么用float而不是int因为PID的输出量、反馈量、设定值都可能是小数用int会丢失精度。比如速度环的反馈值可能是123.45 RPM用int就只能传123调参的时候看着不舒服。float在内存中是4字节但传输的时候需要按字节拆开。这里有个坑STM32是小端模式float的低字节在低地址。如果你直接用一个union把float和uint8_t数组叠在一起发出去的字节顺序就是小端。上位机如果也按小端解析就没问题但如果上位机按大端解析数据就全乱了。野火的上位机是按小端解析的所以STM32这边直接发就行不需要做字节序转换。数据上传帧的载荷结构是这样的第一个字节是通道数量后面依次是每个通道的4字节float数据。比如你要上传3个通道设定值、反馈值、输出值载荷就是13×413字节。上位机收到后按顺序解析出3个float分别画三条曲线。这里有个细节需要注意通道数量是固定的还是可变的野火的上位机支持配置通道数量但配置信息是在上位机端设置的下位机只管按固定格式发。所以你在下位机代码里要写死通道数量或者通过一个宏定义来配置。我一般习惯上传3个通道目标值、实际值、PID输出值。这三个量足够看出控制效果了。2.3 参数读写帧的格式与交互流程参数读写是高级模式的核心。上位机要改Kp就发一帧写参数命令载荷里包含参数编号和新的参数值。下位机收到后更新对应的PID参数然后回一帧应答告诉上位机我收到了。参数编号是一个字节范围0到255。你可以自己定义编号规则比如0x01代表速度环Kp0x02代表速度环Ki0x03代表速度环Kd0x11代表位置环Kp以此类推。上位机那边也要配置同样的编号映射否则改的参数对不上。参数值用4字节float传输和上传数据一样。写参数帧的载荷是145字节第一个字节是参数编号后面4字节是参数值。读参数帧的载荷只有1字节就是参数编号。下位机收到读参数请求后回一帧应答载荷是145字节参数编号当前参数值。交互流程是这样的上位机发写参数帧→下位机解析→更新参数→回写参数应答帧→上位机收到应答→更新界面显示。如果下位机没有回应答上位机会认为通信失败可能会弹窗报错。所以应答帧一定要发哪怕参数更新失败也要回一个带错误标志的应答。注意参数编号的映射关系必须在上位机和下位机之间严格一致。我见过有人下位机用0x01表示Kp上位机配置成0x01表示Ki结果调了半天发现调的是错的参数。建议在代码里用宏定义把编号和参数名绑定比如#define PARAM_SPEED_KP 0x01这样不容易搞混。3. STM32端协议移植的完整实操3.1 串口外设配置与DMA接收移植的第一步是把串口配好。野火PID调试助手默认波特率是1152008位数据位1位停止位无校验。STM32的USART配置成一样的参数就行。如果你用的是HAL库直接调HAL_UART_Init把波特率设成115200字长设成8位停止位设成1位校验位设成None。但光配好串口还不够接收数据的方式很关键。如果你用中断接收每来一个字节进一次中断115200波特率下每秒最多11520个字节中断频率太高会占用大量CPU时间。电机控制本身就要跑PID计算、PWM更新、编码器读取CPU资源本来就紧张再被串口中断频繁打断控制周期会抖动。我推荐用DMA接收。DMA可以在不占用CPU的情况下把串口数据搬到内存缓冲区等一帧数据收完了再通知CPU处理。具体做法是开一个足够大的接收缓冲区比如256字节配置DMA为循环模式串口每收到一个字节就自动存到缓冲区里。然后开一个空闲中断IDLE当串口总线空闲超过一个字节时间时触发空闲中断在中断里计算收到了多少字节然后置一个标志位主循环里处理。空闲中断加DMA的组合是STM32串口接收的经典方案实测非常稳。唯一需要注意的是DMA缓冲区的索引计算。在空闲中断里用缓冲区总大小减去DMA剩余传输数量就是本次收到的字节数。处理完之后要重置DMA计数器否则下次接收会从上次的位置继续。// 串口DMA接收初始化示例HAL库 #define RX_BUFFER_SIZE 256 uint8_t rxBuffer[RX_BUFFER_SIZE]; uint16_t rxLen 0; volatile uint8_t rxFlag 0; void UART_DMA_Init(void) { // 启动DMA接收 HAL_UART_Receive_DMA(huart1, rxBuffer, RX_BUFFER_SIZE); // 使能空闲中断 __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); } // 空闲中断处理 void USART1_IRQHandler(void) { if(__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); // 计算接收长度 rxLen RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); rxFlag 1; // 停止DMA准备重新配置 HAL_UART_DMAStop(huart1); // 重新启动DMA接收 HAL_UART_Receive_DMA(huart1, rxBuffer, RX_BUFFER_SIZE); } HAL_UART_IRQHandler(huart1); }上面这段代码是核心框架。实际用的时候rxFlag置位后要在主循环里尽快处理处理完了把rxFlag清零。如果处理太慢下一帧数据来了会覆盖缓冲区导致丢帧。不过对于PID调试来说丢一两帧数据问题不大上位机画曲线的时候会自动插值。3.2 协议解析状态机的实现收到一帧数据后需要解析出命令字和载荷。最直接的方法是写一个解析函数先找帧头0xAA 0x55然后读命令字、数据长度、载荷最后校验。但这种方法有个问题如果数据中恰好出现了0xAA 0x55会被误认为是帧头。虽然概率不高但一旦发生就会解析错位。更稳妥的做法是用状态机。状态机逐个字节扫描只有按顺序经过帧头1→帧头2→命令字→长度→载荷→校验→帧尾所有状态才算一帧有效数据。如果中间任何一个字节不符合预期就回到初始状态重新找帧头。这样即使数据中出现了0xAA 0x55只要后续字节不满足状态转移条件就不会被误判。// 协议解析状态机 typedef enum { STATE_HEAD1, // 等待帧头1 STATE_HEAD2, // 等待帧头2 STATE_CMD, // 等待命令字 STATE_LEN, // 等待长度 STATE_DATA, // 接收载荷 STATE_CHECK, // 等待校验和 STATE_TAIL // 等待帧尾 } ParseState; ParseState state STATE_HEAD1; uint8_t dataBuf[64]; uint8_t dataIndex 0; uint8_t dataLen 0; uint8_t checkSum 0; uint8_t cmd 0; void ParseByte(uint8_t byte) { switch(state) { case STATE_HEAD1: if(byte 0xAA) state STATE_HEAD2; break; case STATE_HEAD2: if(byte 0x55) state STATE_CMD; else state STATE_HEAD1; break; case STATE_CMD: cmd byte; checkSum byte; state STATE_LEN; break; case STATE_LEN: dataLen byte; checkSum byte; dataIndex 0; if(dataLen 0) state STATE_DATA; else state STATE_CHECK; break; case STATE_DATA: dataBuf[dataIndex] byte; checkSum byte; if(dataIndex dataLen) state STATE_CHECK; break; case STATE_CHECK: if(byte (checkSum 0xFF)) state STATE_TAIL; else state STATE_HEAD1; break; case STATE_TAIL: if(byte 0x5A) { // 一帧完整数据处理它 HandleFrame(cmd, dataBuf, dataLen); } state STATE_HEAD1; break; } }这个状态机的好处是鲁棒性强即使串口上混入了噪声数据也能自动恢复到正常解析状态。而且状态机是逐字节处理的不需要额外的缓冲区来存整帧数据内存占用小。3.3 参数映射表的设计与PID参数在线更新参数映射表是连接上位机参数编号和实际PID变量的桥梁。最简单的做法是用一个结构体数组每个元素包含参数编号、参数指针、参数类型。收到写参数命令后遍历数组找到匹配的编号然后通过指针更新对应的变量。typedef struct { uint8_t id; // 参数编号 float *pValue; // 参数指针 float minVal; // 最小值 float maxVal; // 最大值 } ParamMap; // PID参数变量 float speedKp 1.0f, speedKi 0.1f, speedKd 0.0f; float posKp 2.0f, posKi 0.0f, posKd 0.05f; ParamMap paramTable[] { {0x01, speedKp, 0.0f, 100.0f}, {0x02, speedKi, 0.0f, 10.0f}, {0x03, speedKd, 0.0f, 10.0f}, {0x11, posKp, 0.0f, 100.0f}, {0x12, posKi, 0.0f, 10.0f}, {0x13, posKd, 0.0f, 10.0f}, }; uint8_t UpdateParam(uint8_t id, float value) { for(int i 0; i sizeof(paramTable)/sizeof(ParamMap); i) { if(paramTable[i].id id) { // 限幅保护 if(value paramTable[i].minVal) value paramTable[i].minVal; if(value paramTable[i].maxVal) value paramTable[i].maxVal; *paramTable[i].pValue value; return 1; // 成功 } } return 0; // 失败未找到参数 }限幅保护很重要。上位机拖动滑块的时候可能会不小心拖到很大的值比如Kp拖到1000。如果没有限幅电机可能瞬间疯转轻则触发过流保护重则烧驱动。加上限幅之后即使上位机发了离谱的值下位机也会自动截断到安全范围内。实操心得参数映射表里的minVal和maxVal要根据实际电机和负载来定。我一般会把Kp的上限设成理论计算值的2倍左右Ki的上限设成Kp的1/10Kd的上限设成Kp的1/5。这样即使上位机乱拖也不会出大问题。4. 数据上传与波形观测的实战细节4.1 上传数据的时机与频率控制数据上传的时机很关键。如果你在PID计算的中断里直接发串口数据会阻塞中断影响控制周期。正确的做法是在中断里把需要上传的数据存到一个缓冲区然后在主循环里发送。这样中断只负责存数据发送的事情交给主循环互不干扰。上传频率也要控制。野火上位机默认的刷新率是50Hz左右也就是每20ms上传一帧。如果你上传太快比如每1ms发一帧串口带宽不够115200波特率下一帧13字节的数据加上帧头帧尾大概20字节1ms发一帧就是20000字节/秒超过了115200/1011520字节/秒的上限会导致数据堆积和丢帧。上传太慢又看不出波形细节。我的做法是用一个定时器每10ms触发一次数据上传。10ms对应100Hz的刷新率对于大多数电机控制来说足够看出动态响应了。如果控制周期是1ms那就是每10个控制周期上传一次数据。在控制中断里用一个计数器计数到10就把数据存入上传缓冲区主循环检测到缓冲区有数据就发送。// 控制中断里的数据采集 volatile float uploadBuf[3]; volatile uint8_t uploadReady 0; uint8_t uploadCounter 0; void Control_ISR(void) // 1ms周期 { // PID计算... float target ...; float actual ...; float output ...; uploadCounter; if(uploadCounter 10) // 每10ms上传一次 { uploadCounter 0; uploadBuf[0] target; uploadBuf[1] actual; uploadBuf[2] output; uploadReady 1; } } // 主循环里发送 void Main_Loop(void) { if(uploadReady) { uploadReady 0; SendDataFrame(uploadBuf, 3); } // 其他任务... }4.2 浮点数打包与字节序处理发送float数据的时候需要把float拆成4个字节。最简洁的方法是用uniontypedef union { float f; uint8_t bytes[4]; } FloatUnion; void SendDataFrame(volatile float *data, uint8_t channelCount) { uint8_t frame[64]; uint8_t index 0; uint8_t checksum 0; // 帧头 frame[index] 0xAA; frame[index] 0x55; // 命令字数据上传 frame[index] 0x01; checksum 0x01; // 数据长度 uint8_t payloadLen 1 channelCount * 4; frame[index] payloadLen; checksum payloadLen; // 通道数量 frame[index] channelCount; checksum channelCount; // 各通道数据 for(int i 0; i channelCount; i) { FloatUnion fu; fu.f data[i]; for(int j 0; j 4; j) { frame[index] fu.bytes[j]; checksum fu.bytes[j]; } } // 校验和 frame[index] checksum 0xFF; // 帧尾 frame[index] 0x5A; // 发送 HAL_UART_Transmit_DMA(huart1, frame, index); }这里用HAL_UART_Transmit_DMA而不是阻塞发送是为了避免发送过程阻塞主循环。DMA发送把数据交给DMA控制器后CPU就可以去干别的事了等发送完成中断来了再处理下一帧。注意如果上一帧还没发完下一帧就来了DMA会覆盖上一帧的数据。所以发送之前要检查DMA是否空闲。可以用一个标志位记录DMA发送状态发送完成中断里清零标志位发送前检查标志位。4.3 上位机端的配置与联调下位机代码写好后上位机端也要配置。打开野火PID调试助手选择串口和波特率然后配置通道数量、参数编号映射、数据格式。通道数量要和下位机一致参数编号映射也要和下位机的paramTable一致。联调的时候先不接电机用手转动编码器或者用信号发生器模拟反馈信号观察上位机能不能收到数据、波形对不对。确认数据通路没问题后再接电机。接电机后先给很小的Kp观察电机能不能稳定运行然后逐步加大Kp直到出现等幅振荡再减小到振荡消失记录此时的Kp为临界增益。然后根据临界增益法Ziegler-Nichols计算Ki和Kd的初始值再在上位机上微调。5. 移植过程中最容易踩的坑与排查方法5.1 通信类问题速查表现象可能原因排查方法上位机收不到数据串口线接反交换TX和RX上位机收不到数据波特率不匹配检查两边波特率是否都是115200数据波形乱跳字节序不对检查float打包顺序数据波形乱跳通道数量配置错误检查上位机通道数是否和下位机一致参数改了没反应参数编号映射错误检查paramTable和上位机配置参数改了没反应应答帧没发检查下位机是否回了应答偶尔丢帧上传频率太高降低上传频率或提高波特率偶尔丢帧DMA缓冲区太小增大RX_BUFFER_SIZE5.2 参数在线更新的安全性设计参数在线更新最大的风险是上位机发了错误的参数导致电机失控。除了前面说的限幅保护还有几个安全措施可以做。第一个是参数更新使能开关。在代码里加一个全局变量paramUpdateEnable只有这个变量为1的时候才允许更新参数。默认设为0需要调参的时候通过一个特定的命令字打开。这样即使上位机误发了写参数命令也不会生效。第二个是参数变化率限制。如果新参数和旧参数的差值超过一定阈值就拒绝更新。比如Kp从1突然跳到100差值99超过了阈值50就拒绝。这样可以防止滑块拖太快导致的参数突变。第三个是看门狗。如果参数更新后电机失控控制循环里的看门狗会检测到异常比如电流过大、转速过高自动复位或者切换到安全参数。这个需要结合具体的电机控制逻辑来实现。// 参数更新安全保护 uint8_t paramUpdateEnable 0; float lastSpeedKp 1.0f; uint8_t SafeUpdateParam(uint8_t id, float value) { if(!paramUpdateEnable) return 0; // 变化率限制 if(id 0x01) // speedKp { if(fabsf(value - lastSpeedKp) 50.0f) return 0; lastSpeedKp value; } return UpdateParam(id, value); }5.3 中断优先级与实时性保障串口接收中断的优先级要设置合理。如果串口中断优先级太高会打断PID控制中断导致控制周期抖动。如果太低又可能丢数据。我的建议是PID控制中断优先级设为最高比如抢占优先级1串口空闲中断优先级设为中等抢占优先级2DMA发送完成中断优先级设为最低抢占优先级3。这样PID控制永远能按时执行串口接收稍微延迟一点也没关系因为DMA已经在后台把数据存到缓冲区了空闲中断晚一点处理只是晚一点解析不会丢数据。实操心得我遇到过一个问题串口空闲中断和PID中断优先级设成一样结果串口数据量大的时候PID周期从1ms抖到了1.5ms电机出现轻微异响。后来把PID中断优先级调高就好了。电机控制里控制周期的稳定性比什么都重要。6. 从单环到三环级联的协议扩展思路6.1 多环参数的编号规划单环调试跑通后大概率要上三环级联。三环级联意味着有3组PID参数每组3个参数一共9个参数。如果再加上前馈系数、滤波系数可能超过15个参数。参数编号规划要提前做好不然后面加参数的时候容易乱。我的编号方案是这样的高4位表示环编号低4位表示参数类型。0x1X表示电流环0x2X表示速度环0x3X表示位置环。每个环内0xX1表示Kp0xX2表示Ki0xX3表示Kd0xX4表示前馈0xX5表示输出限幅。这样一眼就能看出参数属于哪个环、是什么类型。编号含义编号含义0x11电流环Kp0x21速度环Kp0x12电流环Ki0x22速度环Ki0x13电流环Kd0x23速度环Kd0x14电流环前馈0x24速度环前馈0x15电流环限幅0x25速度环限幅0x31位置环Kp0x32位置环Ki0x33位置环Kd0x34位置环前馈0x35位置环限幅6.2 多通道数据上传的通道分配三环级联的时候需要观测的数据也变多了。电流环要看目标电流和实际电流速度环要看目标速度和实际速度位置环要看目标位置和实际位置。如果全部上传就是6个通道。野火上位机支持最多8个通道6个通道完全没问题。但通道多了之后每帧数据变长上传频率就要降低。6个通道的数据帧载荷是16×425字节加上帧头帧尾和校验一帧大概30字节。115200波特率下一帧的传输时间是30×10/115200≈2.6ms。如果每10ms发一帧占用2.6ms的串口时间还有7.4ms的空闲完全够用。通道分配建议通道1到通道3给最外环位置环或速度环通道4到通道6给内环电流环。这样上位机画曲线的时候外环的曲线在上面内环的曲线在下面看起来层次分明。6.3 级联PID调试的顺序与技巧级联PID的调试顺序必须是从内到外。先调电流环电流环稳定了再调速度环速度环稳定了再调位置环。如果顺序反了外环调好了内环一改外环又乱了。调电流环的时候把速度环和位置环的输出限幅设成0让它们不输出只让电流环工作。给电流环一个阶跃给定观察实际电流的响应。如果超调太大就减小Kp如果响应太慢就增大Kp。电流环的带宽一般设成速度环的5到10倍速度环的带宽设成位置环的5到10倍。调速度环的时候电流环保持已经调好的参数不变给速度环一个阶跃给定观察实际速度的响应。速度环的Kp决定了响应速度Ki决定了稳态误差消除速度Kd决定了抗扰动能力。速度环调好后位置环的调试就简单了因为位置环的输出就是速度环的给定只要位置环的带宽远低于速度环整个系统就是稳定的。实操心得级联PID调试的时候我习惯先把所有积分项和微分项设成0只调比例项。比例项调好了再加积分项积分项调好了再加微分项。这样每一步只调一个参数变量少容易定位问题。如果三个参数一起调出了问题都不知道是哪个参数引起的。7. 移植后的性能评估与优化方向7.1 通信延迟与数据实时性评估移植完成后我实测了一下通信延迟。从下位机采集数据到上位机显示波形总延迟大概在30到50ms之间。这个延迟主要来自三个部分下位机采集到发送的延迟最多10ms取决于上传周期、串口传输延迟约2.6ms、上位机解析和渲染延迟约20到40ms。对于PID调试来说30到50ms的延迟完全可以接受。因为PID调试看的是趋势和稳态不是看毫秒级的瞬态响应。如果你要看电流环的阶跃响应那可能需要更低的延迟这时候可以把上传周期缩短到2ms但通道数量要减少到2个否则串口带宽不够。7.2 CPU占用率与内存开销协议移植带来的CPU开销很小。DMA接收和发送几乎不占CPU空闲中断每帧触发一次解析状态机每字节执行一次switch加起来大概几十个时钟周期。参数更新只在收到写参数命令时执行频率很低。整体算下来协议处理占用的CPU时间不到1%。内存开销主要是接收缓冲区和发送缓冲区。接收缓冲区256字节发送缓冲区64字节参数映射表大概100字节总共不到500字节。对于STM32F103C8T6这种64KB Flash、20KB RAM的芯片来说完全无压力。7.3 后续可扩展的功能方向这套协议移植好之后还可以扩展一些实用功能。比如数据记录功能在上位机端加一个开始记录按钮把接收到的数据存成CSV文件方便后续用MATLAB或者Python做分析。再比如参数自整定功能在上位机端实现继电器反馈法或者临界比例度法自动计算PID参数并下发。还有一个很实用的扩展是参数组保存与加载。调好一组参数后把参数保存到Flash里下次上电自动加载。这样就不用每次上电都重新调参了。STM32的Flash读写很简单用HAL_FLASH_Program就行注意擦除和写入的地址对齐。最后再分享一个小技巧如果你用的是Keil MDK开发环境可以在Debug模式下用Logic Analyzer功能实时看变量波形和野火上位机的波形对照着看。Logic Analyzer看的是芯片内部的变量没有通信延迟可以用来验证上位机波形的准确性。两者结合调参效率翻倍。
返回列表