做运动控制的老工程师都清楚,“上电找原点”这五个字落到设备调试现场有多折腾。增量编码器靠A/B相脉冲计数,一旦断电位置全丢,重新上电就得让机械结构去撞限位或者找Z相零点。这个流程在单台样机上还能忍,到了产线上、或者需要无人化复位的设备上,完全不可接受。所以这些年绝对式编码器在各类运动设备上越来越多,而我当时手里的控制器主控是一颗STM32F427,板上没有FPGA也没有CPLD,外设列表里翻来翻去也找不到一个叫“SSI”的专用控制器。要在不动硬件的前提下,把绝对式编码器的位置反馈接入闭环控制,方案只有一条:用GPIO和定时器把SSI协议的主机行为模拟出来。
这篇东西不是教科书式的协议解析,是我从第一版GPIO模拟到最终定时器+DMA方案的完整记录,包括了可以移植的代码、关键参数的估算方法,以及现场调试时实际踩过的坑。不管你是要把设备从增量编码器升级到绝对式编码器,还是手上正好有一颗F427想做多圈位置采集,这篇文章应该都能帮你少走一段弯路。
1. 为什么放着现成通信外设不用,非要去模拟SSI
1.1 平台上的硬约束
先说说我面临的实际条件。控制板主控用的是STM32F427VIT6,168MHz主频,Cortex-M4F带FPU,做运动控制的主控芯片绰绰有余。板子上资源也够丰富:USART、SPI、I2C、CAN、USB、以太网MAC一应俱全,定时器更是多得用不完。但问题恰恰出在这里——F427的外设矩阵里,就是没有“SSI主接口”这种东西。
ST的芯片家族里,专门针对伺服驱动的型号比如STM32F3系列部分型号确实有编码器接口,但F427没有。这颗芯片虽然带正交编码器接口(QEI),能直接解A/B/Z增量编码器,但对于绝对式编码器的同步串行通信,硬件上完全要靠自己搭。
我当时的处境是:设备已经量产了一版,主控板不愿意推倒重来,电机驱动和通信接口都焊死了,唯一能改的就是固件。如果为了读一个绝对式编码器就要重新Layout一块板子,成本和时间都不允许。所以在现有平台上把SSI用软件模拟出来,是唯一一条不用改硬件的路子。
1.2 SPI、USART同步模式为什么都顶不上
有人会问,F427不是有SPI吗?SPI的主机模式能产生时钟、能接收数据,拿来读SSI编码器不是正好?
我一开始也这么想,试了一圈才发现没那么简单。
SPI和SSI最大的差异在物理层结构。SPI是全双工四线制:SCK、MOSI、MISO、CS,发送和接收各走各的线。而SSI是两线制的半双工通信:主机输出时钟,从机回传数据,只有一根数据线。想办法把MOSI和MISO短接起来,在电气上能勉强通,但时序上立刻出问题。SSI编码器对帧长没有固定的8位或者16位限制,25位、29位、32位都常见,而SPI的硬件帧长度是定死的,要拼出25位数据就得拆成两个16位帧去处理跨字节拼接,软件复杂度反而更高。
USART的同步模式我也评估过,同样有帧长限制。USART同步模式本质上是给字符通信设计的外设,收发单位是8位或9位,能输出同步时钟,但很难生成一个完整的25位SSI帧。就算能通过连续发送字节凑出时钟脉冲,数据采样点和SSI编码器要求的建立时间、保持时间也很难对上。
折腾一圈下来,结论很清楚:在F427上,与其拿SPI/USART去硬凑,不如直接用GPIO输出时钟、GPIO读取数据,再配一个定时器来控制时序。这种做法看着原始,实际是最灵活的,因为整个时序都由软件定义,想发多少位就发多少位,想调整采样点就调整采样点。
1.3 软件模拟的可行性边界
当然,软件模拟是有边界的。SSI时钟频率通常在125kHz到2MHz之间,各位周期从0.5µs到8µs不等。只要编码器要求的时钟频率不是高到离谱,F427完全能应付。用GPIO翻转时钟线加软件延时的方式,在2MHz时钟下,每一位只有500ns的窗口,还要在里面完成数据采样,对代码优化是个考验。但如果把时钟频率设为250kHz到500kHz这个区间,每位有2µs到4µs的时间窗口,软件模拟就非常从容。
另一个约束是CPU占用。软件逐位模拟时,整个SSI帧读取期间CPU基本被占用。读一次25位帧在250kHz时钟下大约要100多微秒。如果你的控制循环是10kHz中断周期,每个周期读一次位置,那就占用了几近100%的CPU。所以第一版用软件延时方案打通流程没问题,但真正上产品时必须换成定时器+DMA方案。后面我会把两条路线都展开讲。
2. SSI协议时序拆解:从一根数据线上抠出位置信息
2.1 一次完整传输的波形过程
SSI全称Synchronous Serial Interface,同步串行接口。在编码器场景里,它通常只有两根信号线:一根是主机发出的时钟线(CLK+),一根是编码器回传的数据线(DATA+)。实际工业编码器为了抗干扰,往往是RS422差分传输,CLK+/CLK-、DATA+/DATA-四根线,但协议逻辑和单端是一样的。
一次传输过程是这样的:
- 空闲时,主机把时钟线拉高。数据线因为编码器内部有上拉,也保持高电平。
- 主机把时钟线拉低,这个下降沿相当于一个“请求”信号,告诉编码器“准备发送位置数据”。编码器把最高位(MSB)放到数据线上。
- 主机开始产生N个时钟脉冲,N就是编码器的总位数。在每一个时钟的有效边沿,编码器把下一位数据送到数据线上。
- 全部N位发送完,主机把时钟线恢复到高电平空闲状态,数据线也跟着回到高电平。
这里有个关键点:SSI不是像UART那样自己发送数据帧,它完全是在主机的时钟驱动下被动地“逐位移出”。从机本身不会主动说话,你给它多少拍时钟,它就吐多少位数据。这也是为什么从时序上看,SSI主机就是“数着拍子”把数据一位一位抠出来。
2.2 数据帧里到底装了什么
绝对式编码器回传的帧内容,不同品牌差异很大。最典型的有这么几类:
| 位数 | 常见格式 | 典型应用 |
|---|---|---|
| 25位 | 单圈13位 + 多圈12位 | 单圈分辨率8192线,多圈4096圈 |
| 29位 | 单圈16位 + 多圈12位 + 1位校验 | 更高单圈分辨率 |
| 32位 | 单圈16位 + 多圈12位 + 4位状态/校验 | 含符号位、错误标志、奇偶校验 |
单圈位决定了编码器在一圈内的分辨率。比如13位单圈,一圈被分成8192份,角度分辨率就是360/8192≈0.044°。多圈位记录编码器轴转了多少整圈,12位就是最多记录4096圈,超过这个范围多圈计数值会溢出回卷,使用前一定要确认应用场景不会超圈。
除了位置数据,编码器帧里可能还有报警位、符号位、CRC或奇偶校验位。这些位的位置在不同品牌编码器手册里都不尽相同。我调试过两种编码器,一个25位,一个32位,前者第24位是奇偶校验,后者最后4位里有两个是状态标志。所以拿到一个新编码器,第一件事就是翻手册确认位序和位含义,不要想当然。
2.3 时序参数与时钟频率选型
SSI协议对时序有一组参数,虽然在不同编码器手册里叫法略有差异,但核心是这几个:
| 参数 | 含义 | 常见范围 |
|---|---|---|
| 时钟周期T | 单个时钟脉冲的周期 | 0.5µs ~ 16µs |
| 最高时钟频率f_max | 编码器允许的最大时钟 | 通常1MHz或2MHz |
| 请求响应时间 | 请求下降沿到第一位数据稳定 | 典型2µs左右 |
| 数据保持时间 | 时钟边沿后数据有效窗口 | 100ns以上 |
选择工作频率时,不能只盯着f_max这个上限。频率越高,信号完整性问题越突出,线缆的分布电容、端接电阻、走线长度都会影响数据线上的波形质量。我的经验是:只要能满足控制周期要求,时钟频率尽量往低选。控制周期10kHz,读一次25位帧,即便用125kHz时钟,也就200多微秒,完全来得及。频率低一点,抗干扰能力和稳定性都会好很多。
2.4 采样点选择:什么时候读数据最稳
这部分是初学者最容易搞错的。很多人的第一反应是在时钟上升沿或者下降沿触发读数据,但要注意,编码器数据线的变化不是瞬间完成的,边沿附近存在建立时间和保持时间窗口,采样太靠近边沿很容易读到不稳定电平。
推荐的做法是:把采样点放在数据绝对稳定的区间。比如编码器在时钟下降沿更新数据,那就在时钟低电平的中段读数据。这个窗口不贴着边沿,前后都有余量,即使线缆有轻微延时或振铃,读到错误数据的概率也会大幅下降。我第一版代码就把采样点放在低电平稳定段,后面示波器验证时发现数据线上的毛刺基本都不影响采样结果。
3. GPIO软件模拟版:能跑通,但代价不小
3.1 引脚初始化与电气配置
先看最基础的一版实现。我当时用的引脚分配是:
- PB6:时钟线,推挽输出,空闲拉高
- PB7:数据线,上拉输入
初始化代码用的是STM32标准外设库:
void SSI_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct; RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOB, ENABLE); // PB6 - SSI 时钟,推挽输出 GPIO_InitStruct.GPIO_Pin = GPIO_Pin_6; GPIO_InitStruct.GPIO_Mode = GPIO_Mode_OUT; GPIO_InitStruct.GPIO_OType = GPIO_OType_PP; GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStruct.GPIO_PuPd = GPIO_PuPd_NOPULL; GPIO_Init(GPIOB, &GPIO_InitStruct); GPIO_SetBits(GPIOB, GPIO_Pin_6); // 空闲状态时钟线置高 // PB7 - SSI 数据,上拉输入 GPIO_InitStruct.GPIO_Pin = GPIO_Pin_7; GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IN; GPIO_InitStruct.GPIO_Speed = GPIO_Speed_2MHz; GPIO_InitStruct.GPIO_PuPd = GPIO_PuPd_UP; GPIO_Init(GPIOB, &GPIO_InitStruct); }这里有个细节:时钟线的GPIO速度我配成50MHz,是因为要保证边沿足够陡。如果配成2MHz,GPIO输出上升沿会变得很缓,经过长线缆后可能在编码器端产生不可靠的时钟沿。数据线输入用2MHz就够了,输入侧不需要驱动能力,而且降低速度还能起到一定的数字滤波作用。
3.2 用DWT做微秒级延时
软件模拟SSI最关键的是延时精度。千万别用普通的for循环空转计数,不同编译器优化级别下延时会差出好几倍。F427内核有DWT(Data Watchpoint and Trace)单元,里面有个CYCCNT周期计数器,用它可以做精确到单周期的延时。
static void DWT_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; DWT->CYCCNT = 0; } static void DWT_DelayCycles(uint32_t cycles) { DWT->CYCCNT = 0; while (DWT->CYCCNT < cycles); } static void DWT_DelayUs(uint32_t us) { uint32_t cycles = us * (SystemCoreClock / 1000000U); DWT_DelayCycles(cycles); }F427主频168MHz,1µs就是168个周期,精度完全够用。用这个函数做SSI时钟的每一个半周期延时,时序能被控制在纳秒级偏差以内。
3.3 读取一帧25位数据的核心函数
下面这段是我第一版的核心读取函数。我用一个“请求下降沿”启动编码器输出最高位,然后循环读取每一位,并在每位之后产生一个完整时钟脉冲,驱动编码器输出下一位。
#define SSI_BITS 25 #define SSI_HALF_PERIOD_US 2 // 250kHz时钟,半个周期2µs #define SSI_REQ_DELAY_US 2 // 请求后等待编码器输出最高位 #define SSI_SETTLE_US 1 // 下降沿后等待数据稳定 uint32_t SSI_ReadFrame(void) { uint32_t frame = 0; uint8_t i; // 1. 从空闲高拉到低,作为通信请求 GPIO_ResetBits(GPIOB, GPIO_Pin_6); DWT_DelayUs(SSI_REQ_DELAY_US); // 2. 逐位读取 for (i = 0; i < SSI_BITS; i++) { // 此时时钟处于低电平,且已经过下降沿后的稳定时间 frame = (frame << 1); if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_7) != RESET) frame |= 1; // 产生一个完整时钟脉冲:拉高、保持半周期、拉低、保持半周期 GPIO_SetBits(GPIOB, GPIO_Pin_6); DWT_DelayUs(SSI_HALF_PERIOD_US); GPIO_ResetBits(GPIOB, GPIO_Pin_6); DWT_DelayUs(SSI_HALF_PERIOD_US - SSI_SETTLE_US); } // 3. 时钟线恢复空闲高电平 GPIO_SetBits(GPIOB, GPIO_Pin_6); DWT_DelayUs(SSI_REQ_DELAY_US); return frame; }代码的逻辑不复杂,但要注意采样点的位置。我在循环开头先读数据,此时时钟处于上一轮的拉低状态,距离时钟下降沿已经过了至少一个稳定时间,数据线完全进入有效电平区。读完之后再产生下一轮时钟脉冲,编码器在下降沿更新下一位数据,如此往复。
如果你手里的编码器是在时钟上升沿更新数据,那采样点就要挪到高电平中段,代码逻辑是对称的。实际调试时用示波器看一次完整波形,对照手册确认采样点位置,比闷头写代码快得多。
3.4 位置换算:从原始帧到角度和圈数
读出来的原始帧是一串25位的二进制数,不能直接当位置用。以“单圈13位+多圈12位”的25位编码器为例:
// 单圈位置:高13位 uint16_t single_turn = (uint16_t)((raw >> 12) & 0x1FFF); // 多圈计数值:低12位 uint16_t multi_turn = (uint16_t)(raw & 0x0FFF); // 角度换算 float angle_deg = (float)single_turn * 360.0f / 8192.0f;单圈13位对应8192个码值,角度分辨率0.044°,这个精度在很多设备里已经相当够用。多圈计数值直接乘以单圈角度就能得到绝对值角度,但要注意溢出回卷的问题。
实际应用时还要处理两个常见问题:
第一个是方向取反。电机机械结构不同,编码器安装方向可能和期望的位置正方向相反。如果直接翻转原始帧raw = (1UL << SSI_BITS) - 1 - raw,会把单圈位置和多圈计数一起翻转,有时候多圈部分的翻转不是你想要的。我当时是在应用层分别处理单圈和多圈的方向,宁可多写两行代码,也不去省这个逻辑。
第二个是零位偏移。机械装配后,编码器的零位不一定和设备零位重合。需要先让设备回到机械原点,记录当时的编码器读数,把它作为偏移量存进Flash,之后每次读取都减去这个偏移量:
int32_t pos = (int32_t)single_turn - (int32_t)zero_offset; if (pos < 0) pos += 8192;3.5 这套方案最大的问题不是功能,是实时性
第一版代码跑起来,功能上确实没问题,位置能读出来,闭环也能正常转。但我很快就发现一个严重问题:读取函数执行期间,CPU被完全卡住。
表面上看,读25位在250kHz时钟下也就100多微秒,占比不大。但运动控制程序里往往有多个中断,PWM更新中断、串口通信中断、故障保护中断都在跑。SSI读取如果放在中断里执行,这100多微秒内其他中断全部被阻塞,PWM波形的更新很可能被延迟几个周期,直接影响到电流环的实时性。
如果放在主循环里执行,又很难保证每次位置采样的时间点一致,位置环的采样抖动一变大,伺服刚性就会下降,电机高速运转时容易抖。所以第一版方案只能作为功能验证,想要稳定地用在产品上,必须把逐位读取的工作从CPU里解放出来。这正是下一篇要讲的定时器+DMA方案的出发点。
4. 定时器PWM+DMA:把逐位读取交给外设
4.1 整体思路:三个外设各司其职
我的升级方案是这样设计的:定时器负责产生SSI时钟脉冲,DMA负责在时钟的稳定相位自动采样GPIO数据线,CPU只在读取开始和结束时参与一下。
- TIM3产生250kHz、50%占空比的PWM波形,通过复用功能输出到某个GPIO引脚,作为SSI的时钟线。
- TIM3的更新事件作为DMA请求源,触发DMA从GPIOB->IDR寄存器搬运数据到内存缓冲区。
- DMA传输完成产生中断,通知CPU数据已经全部采完。
这样一来,读一帧数据期间CPU几乎不用干活,只需要发起读取前把DMA和定时器打开,等DMA完成中断一响,去缓冲区里把25个位拼成帧就行。100多微秒的外设工作时间里,CPU完全可以去处理其他中断。
4.2 先要确认GPIO复用映射和DMA请求映射
用定时器输出PWM波形,不能像GPIO模拟那样直接翻转引脚,而是要把引脚配置为复用功能。F427上TIM3_CH1可以映射到PB4或PC6,具体哪个引脚能用,要看芯片数据手册里的AF映射表。我当时用的是PC6,对应的AF编号是AF2,配置的时候要把GPIO_PinAFConfig设置成GPIO_AF2_TIM3。
GPIO_PinAFConfig(GPIOC, GPIO_PinSource6, GPIO_AF_TIM3);DMA请求映射也要查手册。F427的DMA请求不是任意连接的,TIM3的更新事件对应某个DMA流和通道,不同库函数版本里的枚举定义略有差异。我在标准外设库里的配置是这样:
DMA_InitTypeDef DMA_InitStruct; RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA2, ENABLE); DMA_DeInit(DMA2_Stream0); DMA_InitStruct.DMA_Channel = DMA_Channel_5; // 对照芯片手册的DMA请求映射表 DMA_InitStruct.DMA_PeripheralBaseAddr = (uint32_t)&GPIOB->IDR; DMA_InitStruct.DMA_Memory0BaseAddr = (uint32_t)ssi_sample_buf; DMA_InitStruct.DMA_DIR = DMA_DIR_PeripheralToMemory; DMA_InitStruct.DMA_BufferSize = SSI_BUF_SIZE; DMA_InitStruct.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStruct.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStruct.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; DMA_InitStruct.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; DMA_InitStruct.DMA_Mode = DMA_Mode_Normal; DMA_InitStruct.DMA_Priority = DMA_Priority_High; DMA_InitStruct.DMA_FIFOMode = DMA_FIFOMode_Disable; DMA_InitStruct.DMA_FIFOThreshold = DMA_FIFOThreshold_HalfFull; DMA_InitStruct.DMA_MemoryBurst = DMA_MemoryBurst_Single; DMA_InitStruct.DMA_PeripheralBurst = DMA_PeripheralBurst_Single; DMA_Init(DMA2_Stream0, &DMA_InitStruct); DMA_ITConfig(DMA2_Stream0, DMA_IT_TC, ENABLE); DMA_Cmd(DMA2_Stream0, ENABLE);这里的DMA缓冲区类型要重点关注。DMA_PeripheralDataSize我配置的是半字(HalfWord),因为GPIO IDR寄存器是16位有效数据。如果配成字节,采样时会只读取IDR的低8位,高位引脚数据就丢了。缓冲区类型用uint16_t数组:
#define SSI_TOTAL_BITS 25 #define SSI_BUF_SIZE (SSI_TOTAL_BITS + 2) volatile uint16_t ssi_sample_buf[SSI_BUF_SIZE]; volatile uint8_t ssi_sample_done = 0;4.3 定时器PWM配置与一次读取的发起流程
TIM3输出250kHz、占空比50%的PWM,参照主频168MHz计算:
ARR = 168MHz / 250kHz = 672,CCR = 336。
这里ARR是自动重装值,CCR是捕获比较值。我在标准库里的配置长这样:
void SSI_Timer_Init(void) { TIM_TimeBaseInitTypeDef TIM_BaseInitStruct; TIM_OCInitTypeDef TIM_OCInitStruct; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOC, ENABLE); GPIO_InitTypeDef GPIO_InitStruct; GPIO_InitStruct.GPIO_Pin = GPIO_Pin_6; GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF; GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStruct.GPIO_OType = GPIO_OType_PP; GPIO_InitStruct.GPIO_PuPd = GPIO_PuPd_NOPULL; GPIO_Init(GPIOC, &GPIO_InitStruct); GPIO_PinAFConfig(GPIOC, GPIO_PinSource6, GPIO_AF_TIM3); TIM_BaseInitStruct.TIM_Prescaler = 0; TIM_BaseInitStruct.TIM_CounterMode = TIM_CounterMode_Up; TIM_BaseInitStruct.TIM_Period = 672 - 1; TIM_BaseInitStruct.TIM_ClockDivision = 0; TIM_TimeBaseInit(TIM3, &TIM_BaseInitStruct); TIM_OCInitStruct.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStruct.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStruct.TIM_Pulse = 336 - 1; TIM_OCInitStruct.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM3, &TIM_OCInitStruct); TIM_SelectOutputTrigger(TIM3, TIM_TRGOSource_Update); }发起读取时,先用GPIO把时钟线拉低作为通信请求,再打开DMA和定时器:
void SSI_StartRead(void) { ssi_sample_done = 0; // 拉低时钟线发起请求,等待编码器输出最高位 GPIO_ResetBits(GPIOB, GPIO_Pin_6); DWT_DelayUs(SSI_REQ_DELAY_US); DMA_SetCurrDataCounter(DMA2_Stream0, SSI_BUF_SIZE); DMA_Cmd(DMA2_Stream0, ENABLE); TIM_SetCounter(TIM3, 0); TIM_Cmd(TIM3, ENABLE); }DMA会在TIM3的更新事件驱动下,一次次把GPIOB->IDR的值拷进缓冲区。当搬完设定数量的数据后,DMA传输完成标志置位,进入中断服务函数。
void DMA2_Stream0_IRQHandler(void) { if (DMA_GetITStatus(DMA2_Stream0, DMA_IT_TC)) { DMA_ClearITPendingBit(DMA2_Stream0, DMA_IT_TC); DMA_Cmd(DMA2_Stream0, DISABLE); TIM_Cmd(TIM3, DISABLE); ssi_sample_done = 1; } }这里有个小技巧:DMA传输结束,立刻把定时器停掉,防止编码器接收到多余的时钟脉冲。有些编码器对额外脉冲有容忍度,有些则会进入错误状态,保险起见还是停掉为好。
4.4 从采样缓冲区重组出位置帧
缓冲区里的每个元素都是GPIOB->IDR的值。我们需要从这个值里提取PB7引脚的电平状态,然后逐位拼成25位帧:
uint32_t SSI_GetFrameFromBuffer(void) { uint32_t frame = 0; uint8_t i; // 缓冲区里按采样顺序存放:buf[0]对应最高位,buf[N-1]对应最低位 for (i = 0; i < SSI_TOTAL_BITS; i++) { frame <<= 1; if (ssi_sample_buf[i] & GPIO_Pin_7) frame |= 1; } return frame; }使用流程就是:主循环或者控制中断里先调用SSI_StartRead(),然后在DMA完成标志置位后调用SSI_GetFrameFromBuffer()拿数据。中间这100多微秒CPU可以继续干别的事。
4.5 这套方案的实际效果
改版之后,我拿示波器对比了波形,和GPIO模拟版读出来的位置数据完全一致,但CPU占用肉眼可见地下降。控制中断里的阻塞没有了,PWM更新的实时性不再受影响。我在10kHz控制循环里跑版本,每个周期读一次位置,示波器量到位置环采样抖动从原来的±20µs降到±1µs以内,基本接近硬件编码器接口的水准。
如果不用DMA,改用定时器中断里读GPIO,也能实现类似效果,但每次定时器中断都有固定的进入/退出开销,25位要触发25次中断,总开销加起来不比GPIO模拟省多少。DMA的优势是一次性把数据传输完,CPU只需要在开头和结尾各做一次动作。
5. 现场实测中踩过的坑:每条都对应一次返工
5.1 电机静止时位置偶尔跳一个码
这是第一个坑。现象很诡异:电机锁死不动,编码器位置理论上应该完全恒定,但串口打印出来的原始帧偶尔会跳一个LSB,有时候甚至从0x1FFF直接跳到0x0000。
排查链路:
- 先怀疑软件逻辑,把读取频率降下来,问题依旧。
- 用示波器同时抓时钟线和数据线,放大看下降沿附近,发现时钟线下降沿有过冲和振铃,幅度接近1V。
- 过冲导致编码器内部的时钟整形电路产生误判,采样点出现亚稳态。
解决办法不复杂:把时钟线的GPIO输出速度从50MHz降到2MHz,同时在PCB上给时钟线串联一个100Ω到220Ω的电阻,抑制边沿过冲。改完后再测,静止时连续读10000帧,零跳变。
这里有个经验:GPIO的高速输出模式不是什么时候都好用。在短距离PCB走线上,50MHz的边沿斜率是好事;一旦引线变长,高速边沿反而会激发反射,带来过冲振铃。低速应用优先保稳定。
5.2 上电之后第一帧永远错乱
这个坑也是折腾了很久。现象是:每次上电,第一次读SSI位置大概率读到0xFFFFFFFF或者一个明显错误的值,第二次开始就正常。
排查链路:
- 怀疑是GPIO初始化顺序问题,反复调整初始化代码,没效果。
- 单步跟踪第一次读取,发现延时不够,第一位数据还没稳定就被采了。
- 再往深了查,其实是编码器本身的上电时序导致的。编码器供电从0V爬升到5V之后,内部电路还要经过一段初始化时间才能响应SSI请求。如果MCU这边上电后立刻发起通信,编码器还处于复位状态,自然给不出有效数据。
解决方法是:通信前先延时等待编码器稳定,至少给100ms到200ms。我在系统初始化函数的最前面加了一个DWT_DelayUs(200000),然后连续上下电测试50次,首帧错乱的问题消失。
这种问题在实验室里很难暴露,因为调试的时候串口、示波器、仿真器一大堆设备接在上面,供电时序和最终产品不一样。真正上产品后客户那边一上电就报错,才暴露出来。
5.3 引线加长到5米后数据帧错位
设备从小批量验证进入正式生产后,编码器线缆从0.5米换成了正式线束,长度接近5米。结果出现了一个更隐蔽的问题:数据帧偶尔会整体错开一位,位置值一下子差了上千个码。
排查链路:
- 首先看数据帧的奇偶校验位,发现校验位位置上的值从固定值变成了随机值,说明不是单纯的位跳变。
- 用示波器在编码器端和控制器端同时测量数据线波形,发现长线下数据线边沿变缓,时钟线到达编码器和数据线返回控制器之间有明显延迟。
- 采样点如果刚好落在数据翻转窗口内,就会读到一个不确定电平,导致位错位。
解决办法组合拳:把SSI时钟频率从250kHz降到125kHz,给每一位留出8µs的窗口;数据线上并行接一个4.7kΩ上拉电阻,增强静态电平稳定性;线缆换成双绞屏蔽线,并且保证屏蔽层单端接地。
这套组合改成之后,让设备连续跑了一个晚上,20多万次读取,零错帧。
5.4 电平匹配和共地问题
还有个容易被想当然的坑。有的编码器是5V供电,数据输出也是5V电平,直接接到F427的3.3V GPIO上,轻则读出来的数据一直不对,重则把GPIO引脚烧了。
我在评估阶段就吃过这个亏。编码器的数据输出是推挽式5V电平,接进F427后引脚电压一直在5V左右,已经接近3.3V引脚的绝对最大额定值。后来加了电平转换芯片才解决。
另外,编码器地和控制器地没有共地也会导致数据错乱。SSI的信号线是相对参考地来判定高低电平的,如果两边地电位不一致,数据线上的电平判断就会出现偏差。用万用表量一下编码器地和控制器地之间的压差,如果超过0.3V,一定要把两地连起来,最好星型接法,避免大电流回路流过信号地。
5.5 调试工具和复现思路
整个调试过程中,示波器是刚需。建议至少用双通道示波器,一个通道抓时钟线,一个通道抓数据线,触发方式设置为时钟线下降沿触发,然后观察整个SSI帧的完整波形。
逻辑分析仪也很好用,一些入门级逻辑分析仪可以直接把25位的帧解码出来,省去手动数波形的功夫。我当时用的逻辑分析仪虽然不支持SSI协议解码,但我把它配置成SPI模式,再把时钟极性和相位调成和编码器一致,居然也能解出正确的数据帧。这个技巧推荐给手上没有SSI专用调试工具的朋友。
复现问题时,尽量在最终产品和正式线缆条件下测试。实验室短接的杜邦线往往隐藏了信号完整性问题,等到客户现场才爆发,那就不只是改个代码那么简单了。
6. 多编码器、闭环同步:扩展思路与经验总结
6.1 多轴扩展:一根时钟线带多个编码器不可取
设备上不止一根轴的时候,自然会想到能不能让多个编码器共用一条时钟线。从SSI协议本身来说,如果编码器支持级联或者多路从机,确实可以在一条时钟线上串联读取。但实际工业编码器大多数不这么干,尤其不同型号混用的时候,电气特性和帧格式都不一致,硬串联风险很大。
稳妥的做法是每条编码器独立时钟线,数据线也可以独立。反正F427的GPIO管脚够多,定时器资源也比较丰富,每个轴一个定时器一个DMA流,或者干脆复用同一个定时器的不同通道。多轴扩展时,重点是注意DMA和中断资源的分配,别让两个轴抢同一个DMA流。
6.2 位置采样与电机控制周期同步
运动控制最怕的是位置采样时刻不确定。如果位置环在PWM中断里运行,读取SSI位置的时间点必须和中断周期对齐,否则位置反馈滞后量会不断变化,高速运行时机器的轨迹会毛糙。
用定时器+DMA方案之后,位置采样时间和外设硬件挂钩,天然带有确定性。我通常会把SSI读取的DMA完成中断和PWM更新中断做成联动,或者直接在PWM更新中断里发起SSI读取,在下一个PWM周期的中断到来之前完成采样。这样每个控制周期采到的位置都是同一时刻的位置,闭环的稳定性会好很多。
6.3 个人体会
这一路折腾下来,我的体会是:SSI模拟本身并不复杂,真正的难度在于把模拟出来的时序放进一个完整的实时系统中不掉链子。第一版GPIO翻转代码能通,但它把CPU占得死死的,位置数据虽然准确,却牺牲了控制系统的实时性。
能不用引脚中断加软件延时的场合,尽量别用。定时器触发DMA的收益是立竿见影的,尤其是当你发现电机高速运转时电流环出现异常抖动,或者示波器量到PWM更新周期出现毛刺的时候,再回头改方案的代价就大了。
另一个体会是:模拟SSI最大的敌人往往不在代码层面,而在信号完整性和上电时序。控制板到编码器的线怎么走,屏蔽怎么接,供电稳定时间够不够,这些硬件层面的问题,软件写得再漂亮也救不回来。遇到位置数据不稳定的情况,先拿示波器看波形,别急着改代码。
希望这篇记录能帮各位少焊几根线、少刷几个版本的固件。SSI虽然看起来像一个老掉牙的串行协议,但搞清楚了它的时序本质之后,你会发现用任何MCU都能从容应对市面上的绝大多数绝对式编码器。