简介这套针对STM32读取多摩川TS5668N21编码器的工程资料适合嵌入式开发者、电机控制与工业自动化方向学习者使用。内容基于STM32F429平台完整展示ADM485协议下UART收发配置、A/B/Z相脉冲捕获、位置增量与零位处理以及Hall信号读取逻辑帮助读者掌握高精度编码器数据解析与实时监测方法。资源共263个文件压缩包约4.79MB核心代码以C源文件和H头文件为主另有启动汇编、链接脚本、工程配置及Hex/Axf固件文件可直接对照工程结构学习移植也可参考CubeMX配置与中断服务程序设计思路。附属的uvprojx、uvoptx等文件便于打开工程复盘map/lst等生成文件则适合排查编译与链接问题。已有4829人学习下载适合正在调试多摩川编码器或希望基于STM32实现工业位置采集的开发人员参考。1. 这程序是干什么的给多摩川 TS5668N21 编码器写一个 STM32 侧“翻译官”你手头拿到一只多摩川 TS5668N21 绝对式编码器想把它接在 STM32 上做低成本伺服反馈多半会遇到一个尴尬传感器资料里全是协议描述读出来的字节流和自己预期的单圈位置值完全对不上。这个“读取程序”解决的问题就是把这层黑匣子拆开——通过在 STM32 上配置一路 RS485 串口把查询帧发出去再把编码器回帧同步解析成可以直接用的位置值。它适合做机器人关节、云台电机、AGV 舵轮这类场合的嵌入式工程师不用再依赖驱动器现成的位置接口自己几行 C 代码就能把“编码器读数”变成闭环控制里的真实反馈。下文会从硬件接线、协议帧、轮询状态机一直讲到二次封装的思路。2. 硬件侧准备TS5668N21 的接线、总线收发与初始化参数2.1 四线接口和 RS485 半双工总线TS5668N21 这类工业绝对编码器面板上通常留出四根线电源正、电源地、SD、SD-。不少第一次接这编码器的人都以为 SD 和 SD- 是一组差分数据线随便接到单片机的 TX/RX 上。这里会翻车编码器总线的速率是兆级串行常见速率里 2.5Mbps 非常普遍电平是 RS485 差分信号而单片机的 UART 引脚是 3.3V 单端 TTL直接连接必然收不到稳定数据。合理做法是引入一块 RS485 收发器比如常见的 MAX485/SP485 方案A/B 分别接编码器的 SD/SD-RO 接 STM32 的 RXDI 接 STM32 的 TX整块收发器还有一个 DE/RE 方向控制脚由单片机一个 GPIO 控制决定当前时刻总线是“发送查询帧”还是“接收应答”。TS5668N21 的接口虽然资料里可能把它叫多摩川专用串行协议但在物理层它就是一个差分 UART所以用 RS485 收发器来跨接是标准做法。还有一点容易忽略编码器端的接线说明书上常要求终端电阻一般电机线不长时可以不接或者只在总线末端跨接 120Ω 电阻。总线如果超过两三米终端电阻没有的话波形边沿反射就会出现“十次读取五次丢帧”的诡异现象。我习惯在第一次调通代码之前先把这两条总线的终端电阻位置确定下来免得后面把问题归到软件上查半天。2.2 DE/RE 换向控制的时序RS485 半双工最需要小心的是方向切换时序。有的开发者在主循环里把 GPIO 拉高、立刻发送查询帧发送完成后马上拉低 GPIO 去收结果刚拉低时总线还有最后一位在空中导致收帧丢失。常规处理是发送完成中断里把 GPIO 拉高后再延时一下或者用串口发送完成标志位等发送完最后一字节后再切换方向。我给一个可用的时序节奏/* 发送一帧查询数据注意方向切换的先后顺序 */ void enc_send_query(void) { DE_GPIO 1; /* 先把 485 收发器切到发送模式 */ HAL_Delay_us(10); /* 等待总线稳定量化放宽一点 */ HAL_UART_Transmit(huart, query, 3, 1); while (tx_done ! 1); /* 等待串口发送完成中断把标志位置 1 */ DE_GPIO 0; /* 再切回接收模式准备收应答 */ }这里 HAL_Delay_us(10) 的时间是按常见 485 芯片和线缆长度估算的。如果你把 RS485 收发器换成带自动换向的型号就不需要这个 GPIO但代价是自动换向芯片一般只支持到几十 Mbps 以下的通用串口而且方向切换延迟略大在 2.5Mbps 下容易吃掉帧间隙。我一般还是习惯用 GPIO 显示控制方向代码里留一个宏定义后面换硬件时只改这一个位置。所谓 query我这里用的是 3 字节查询帧。具体协议手册里会把每一帧的位序写得很复杂工程实践中真正要关心的是发送查询帧后编码器会在固定时间窗口内回一帧数据至于帧内容里的命令字在没有确定自动化标定要求时用全零或固定命令字都行。你如果拿到另一家厂商的编码器改成该厂商查询帧的几个字节即可状态机骨架可以原样保留。2.3 在 CubeMX 里把串口和时钟配好STM32 侧我用的是 HAL 库CubeMX 里需要打开一个具备 DMA 的串口可以选 USART1/UART4 这种带硬件 DMA 的实例。波特率填成 2500000数据位选择 8 位停止位 1 位无校验。不少开发者在 CubeMX 里看到 2500000 这个数值会以为软件报错其实不是串口外设的时钟源来自 PCLK分频后可以支持到这个速率只要在配置界面里点开 Advanced 确定时钟源没有超频就行。关键在 DMA 配置。读取编码器这种随机中断较多的轮询任务如果在中断里用 HAL_UART_Receive 逐字节取数系统负载不高时没问题但一旦叠加伺服 PWM 中断、通信任务丢帧就开始了。我一般用空闲中断 DMA 接收把 DMA 配置成循环模式接收缓冲区设成 16 字节当串口连续空闲超过一个字节时间时就触发 IDLE 中断在 DMA 中断里拿到本次接收到的帧长度。这样主循环完全不用等。CubeMX 里我通常这么配 DMADMA Request: UART4_RX Mode: Circular Direction: Peripheral to Memory Memory Increment: Enable Peripheral Increment: Disable得到这组配置后代码里启动接收函数写得也简单HAL_UART_Receive_DMA(huart4, (uint8_t *)rxbuf, 16)。之后每帧新数据都会自动填进这 16 字节的环形缓冲区我们只需要在空闲中断里计算“数据改变了多少”再把有效字节拷贝出来解析。实际项目中这一条 DMA 链路是后面所有解析代码的基础一旦这个环节的缓冲区容量给错了后面任何校验都会跟着错。2.4 一个容易被忽略的问题波特率漂移2.5Mbps 下晶振误差影响会被放大。STM32 内部 RC 振荡器在常温下误差约 ±1% 到 ±2%用在 115200 波特率下没什么问题但跑 2.5Mbps 时采样点偏差就可能超出容限收到的帧开始出现随机错位。我最早调试这个读取程序时整套代码对着协议用示波器看每个字节都对就是解析不出位置值折腾一夜才发现是时钟源用成了内部高速振荡器导致的波特率漂移。解决方式是强制在代码里把串口的时钟源拨到外部高速晶振void enc_uart_clock_init(void) { RCC_PeriphCLKInitTypeDef clk {0}; clk.PeriphClockSelection RCC_PERIPHCLK_UART4; clk.PLL1_DivQ 2; /* 示例从 PLL 分频 2 得 50MHz */ clk.ClockType RCC_PERIPHCLK_UART4; HAL_RCCEx_PeriphCLKConfig(clk); }PLL 的参数会随具体芯片、板载晶振频率而变所以这里只给一个示意。想要判断波特率有没有漂最直接的“土办法”是打印一串连续方波用逻辑分析仪抓串口 TX 引脚看每一位脉宽。1 位时间 1 / 2500000 0.4us如果逻辑分析仪采出来的脉宽稳定在 0.4us 附近时钟源就没有问题。这个检查我建议放在工程开跑的第一天做掉能省下后面好几天排查时间。2.5 上电自检先别急着解析位置一个我吃了不少亏的习惯第一次上电时不要直接跑完整状态机单纯把 RS485 里收到的原始字节按十六进制往调试串口打印。因为这时候如果协议位序理解错了光看解析出来的位置值是看不出规律的看原始字节更容易发现“有的帧头固定字节稳定有的地址位会变”这类线索。顺便说一句绝对式编码器第一帧数据往往没有参考价值。总线一上电DMA 缓冲区里很可能残留随机噪声或者编码器内部还在做上电自检应答帧里的位置值并不是稳定的机械位置。所以我最少会在启动后先连续丢两次查询等到第三帧才开始解析。这不是玄学而是给编码器一个稳定时间窗口很多现场“首帧必错”的怪问题都能被这个动作消掉。3. 主循环轮询把“请求-应答”做成一个不丢帧的状态机3.1 需求1ms 周期里完成一次位置刷新对电机控制来说编码器位置更新的频率决定了速度环的带宽。一般位置环跑 1kHz那编码器读取程序至少也要在 1ms 内把位置帧解析出来并写进全局变量。这里面的核心矛盾是串口接收是异步的主循环的处理节奏和编码器回帧的节奏并不总对齐所以必须用一个状态机把流程分阶段推进而不是在这里 HAL_Delay。如果把“发送查询帧”和“等待回帧”写成顺序函数一旦编码器某帧没回程序就会一直等到地老天荒或者靠超时中断救场。状态机的价值在于它把“查询、等待、校验、解析”四个动作拆开每个 1ms 定时器周期只处理当前状态对应的那一步某一状态卡住了不会整体阻塞。3.2 四个状态切换到收帧完成初始定义typedef enum { ENC_IDLE 0, ENC_WAIT_RX, ENC_CHECK, ENC_READY } EncodeState; static EncodeState enc_state ENC_IDLE; static uint8_t enc_uint8_rxbuf[16]; static uint16_t enc_frame_len 0;打开 DMA 接收后状态机的骨架很直接void encoder_poll_1ms(void) { switch (enc_state) { case ENC_IDLE: enc_send_query(); /* 发查询帧 */ enc_state ENC_WAIT_RX; HAL_UART_Receive_DMA(huart4, enc_uint8_rxbuf, sizeof(enc_uint8_rxbuf)); break; case ENC_WAIT_RX: if (enc_frame_len 5) /* 至少收齐一帧完整应答 */ enc_state ENC_CHECK; break; case ENC_CHECK: if (encoder_frame_check(enc_uint8_rxbuf) 0) enc_state ENC_READY; /* 校验通过读取位置 */ else enc_state ENC_IDLE; /* 帧不对回到 IDLE 重新来 */ break; case ENC_READY: g_encoder_position decode_position(enc_uint8_rxbuf); enc_frame_len 0; /* 清空帧长度 */ enc_state ENC_IDLE; break; } }这个状态机的出发点是把“发送查询”“等待回帧”“校验解析”解耦避免一方卡住时整个程序崩溃。我用了一个 1ms 定时器中断去调用 encoder_poll_1ms而真正耗时的 DMA 接收却在后台自己搬运数据。这样主循环里哪怕被别的任务打断几百微秒也不影响下一次读取节奏。解释两个需要注意的参数第一个是 ENC_WAIT_RX 里的超时保护。DMA 循环模式下如果编码器没应答enc_frame_len 永远不会累加状态机会一直停在 ENC_WAIT_RX但此时总线还在等回帧下一次 1ms 中断还会再次调用 encoder_send_query造成查询帧堆积。更稳的做法是传一个超时计数器进入状态机超过 5 个周期没收到帧就强制切回 ENC_IDLE。第二个是 enc_frame_len 的获取。DMA 空闲中断收到数据后在串口空闲中断回调里用当前 DMA 计数值减去上一次的值就能得到本次收到的字节数。这个长度很可能某次只收到 2 个字节所以别拿它当作一定能凑满 5 字节的保证在状态机里再次判断 5 才是最稳妥的。3.3 超时重置和错误统计编码器读取程序最好还带一个错误计数器。我在实际项目里是这样加的每检测到一次超时或 CRC 错就累加 enc_err_cnt只有连续 3 次读取失败才把状态强置为 IDLE 并打印报警单次失败不影响后续读取。原因是 2.5Mbps 总线在伺服电机启停瞬间会受到 PWM 干扰偶发丢帧是正常现象没必要立即停机。把错误统计放在状态机之外的优势是开发调试速度快。比如用串口打印 enc_err_cnt 时能看到“重载下偶尔会涨 1-2 个但马上又恢复”这通常说明时序问题不大如果错误计数一直涨再回头查方向切换延时和时钟源误差。我习惯把这个数字在电机启动前归零用它作为现场调整 RS485 方向切换延时的依据。3.4 和中断系的配合别在中断里做解码有的同学图省事在 UART 空闲中断回调里直接调用 decode_position理由是“反正数据已经到了”。这套写法在电机不转时看不出毛病一旦电流环拉开中断优先级稍微被卡一下主循环里读到的 g_encoder_position 就会出现半个帧的残缺数据。我自己的血泪经验是中断回调里只做“收到数据了把长度算出来置一个 Ready 位”把解码工作全部留给主循环或低优先级任务去做。这样设计还有一个好处你可以在主循环任意时刻读取最新位置值甚至在电机堵转测试里把编码器读取频率降到 500Hz也不会影响中断里的 DMA 正常接收。中断函数越短整个系统的实时性越可控这是我处理所有外部通信的一贯原则。4. 回帧解析校验、位拼接和位置值计算是整套代码的核心4.1 校验函数为什么必须先做编码器回帧里有一到两个字节的校验位。如果校验不过就拿着数据去算位置电机出乱转的概率非常高。我在项目里第一个写的不是解码功能而是校验/* 按 5 字节回帧假设前 4 字节位置第 5 字节校验 */ int encoder_frame_check(const uint8_t *buf) { uint8_t crc 0; for (int i 0; i 4; i) crc crc ^ buf[i]; return (crc buf[4]) ? 0 : -1; }这里的校验算法我按“累加异或”简化处理因为不同批次的编码器设计差异不小部分型号会给出更复杂的 CRC-8 多项式。代码里我保留一个 CheckCrcVer 的宏方便你根据自己手上型号的资料切换。有点经验的人都明白校验函数是最容易被“玄学”干扰的地方。数据线接触不良、485 收发器带载能力不够、地线没共地最终表现都是“CRC 老对不上”。所以在现场调试时一旦 CRC 频繁失败先把示波器探头夹在 A/B 两个差分脚上确认总线空闲电平在 0V 上下对称摆动再回头查软件。4.2 位拼接与左右移的顺序TS5668N21 的回帧是 32 位位置数据普通的串口收进来的却是一串 8 位字节这里就需要把字节流按协议位序拼回一个 uint32_t。最容易翻车的大概率是盲目用 memcpy 或者按常规大端序去拼。多摩川这类绝对编码器消息里数据位通常是低位先出也就是说收到第一个字节的 bit0实际对应的是位置值的最低有效位。拼接代码我写成这样/* 假设低字节在前按位搬移到 pos 里 */ uint32_t decode_position(const uint8_t *buf) { uint32_t pos 0; pos ((uint32_t)buf[0] 0); pos | ((uint32_t)buf[1] 8); pos | ((uint32_t)buf[2] 16); pos | ((uint32_t)buf[3] 24); return pos; }看起来简单但不同型号的协议一旦换序读取出来的位置值会在最大范围内瞎跳而且你很难一眼看出问题。我调试的时候常用一个方法把编码器轴慢慢转到一个有刻度盘的位置比如 90 度然后观察 pos 的二进制高位变化规律。如果高位变化方向和手动旋转方向一致说明位序正确如果反了交换 16 位或者反向按位排布就能定位。如果协议里位置值不是简单的 32 位而是带符号的 23 位则还需要做一次无符号到有符号的转换/* 23 位分辨率把高 9 位全部作为符号扩展丢弃 */ int32_t human_position (int32_t)(pos 9) 9;左移 9 位相当于把最高 9 位作为符号扩展丢弃这是针对 23 位有效值、剩余高位是符号位的常见处理。具体要移多少位取决于编码器单圈分辨率比如 17 位就是左移 15 位23 位就是左移 9 位。这个参数是驱动里最容易被改错的一个我一般在宏定义旁边注释好“换编码器型号必须同步改这里”免得三个月后自己回头看代码时又踩一遍。4.3 把单圈位置算成速度和圈数拿到了 32 位位置值控制上常用的还有两个量多圈圈数和转速。圈数计算不能在每次解析时从头累计否则断电重开会恢复成零。常见做法是把上一次位置保存在静态变量里比较增量static uint32_t last_position 0; static int64_t total_steps 0; void encoder_update(int32_t new_pos, int32_t max_encoder) { int32_t delta (int32_t)(new_pos - last_position); /* 处理单圈跳变越过一圈最大值时需要补偿 */ if (delta max_encoder / 2) delta - max_encoder; if (delta -max_encoder / 2) delta max_encoder; total_steps delta; last_position new_pos; }max_encoder 就是单圈分辨率比如 23 位编码器对应 8388608。这个代码里最关键的一步是判断单圈回绕当位置从最大值跳到最小值附近时直接做差值会产生一个巨大的正负翻转补偿后才是真实增量。velocity 计算可以放在电机速度环里根据两次位置更新的时间戳做差不过 1ms 定时器读取下直接用 (总步数上次 - 这次) 乘以 1000 就可以得到每秒的位置步数。这类增量计算在电机刚上电时最值得注意。编码器是绝对式的上电瞬间位置值已经包含了零点信息所以 total_steps 不需要清零但如果你用的是同类的相对编码器就会需要每圈维护一个计数变量零点漂移问题还会更严重。我在驱动里把“绝对位置”和“相对增量”分成两个独立变量绝对位置给位置环做零点标定相对增量给速度环做微分这样既不会有符号方向混乱也不会因为累计误差干扰闭环。4.4 位置有效性标记别拿旧数据当新数据还有一个很多人会忽略的点解析出来的位置值要伴随一个时间戳或更新标记。原因是中断里 DMA 可能在任意时刻修改 rxbuf主循环拿到的 buf 可能已经是新帧和旧帧拼接的产物。我在结构体里放了一个更新时间戳每次校验通过并解析成功就把时间戳置为 HAL_GetTick()上层在使用位置之前先看这个时间戳是不是新鲜。这个设计在低速调试时看不出价值但当你把电机转速拉高到 3000rpm 以上单圈脉冲频率变高串口会频繁触发空闲中断这时候如果没有版本标记很容易在下一次 DMA 写入过程中读到半个旧帧加半个新帧位置值瞬间跳一大格。加一个时间戳或者帧序号就能彻底解决这类问题成本几乎为零。5. 避坑指南TS5668N21 调试里最容易翻车的几个点5.1 帧校验一直失败但示波器看着波形很干净现象CRC 错误计数不断上涨用逻辑分析仪抓 A/B 差分线波形幅值和宽度都符合预期。原因最常见的是编码器和单片机没有共地差分信号虽然抗干扰但收发器和编码器之间地电位不一致接收端判定电平会偶漂。另一个可能原因是 DE 方向切换太晚应答帧的头几个字节还在总线上就被当作接收数据收进来。解决先用万用表确认 VCC、GND 连接点之间的电势差小于 0.1V然后把发送完成到 DE 拉低之间的延时从 5us 逐步加大到 50us看看 CRC 错误数是否下降。如果都无效给 485 收发器的 RO 到单片机 RX 之间串一个小阻值电阻减少长线反射。5.2 位置值在转到一个角度后会突然跳到负数现象电机匀速正转位置值一路变大但在某个角度附近直接变成负值速度环角度瞬间畸变。原因大概率是位序处理里没有考虑编码器位置值的符号扩展。TS5668N21 单圈数据可能超过 2^23你直接按 int32_t 无符号读时虽然数值连续但在转换成有符号角度后超过了符号位范围就会出现翻转。解决确认单圈分辨率到底是 17 位还是 23 位把第 4.2 节的左移符号扩展代码代入。如果电机旋转一圈后位置值不是从最大值回 0 而是变成负数那要检查是不是高位被当成符号位误处理把那一层左移去掉。调试时建议把原始 pos 一并打印对比“无符号原始值”和“有符号角度值”的差异定位会更快。5.3 上电后第一次读数值永远不对第二次才正常现象日志打印显示每次开机首帧位置都和预置零点差很多从第二帧开始稳定。原因DMA 接收使用循环缓冲模式上电瞬间缓冲区里还残留上电前或随机的噪声字节空闲中断触发的“本次接收长度”不是从帧头开始的。第一次解析时拿到的位拼接起始点不对自然读不到零点。解决在启动 DMA 之后再发查询帧并且收到第一帧后先不解析用作同步丢弃或者每次 DMA 接收前对缓冲区做一次 memset并在解析前校验帧头帧尾的固定字节。这种“第一帧丢弃”做法在绝对值编码器通信里不算浪费反正也就多花 1ms。5.4 电机一带负载位置值开始像毛刺一样来回抖现象空载时读取的位置值很稳定一加上电流负载位置值在 ±50 左右抖动有时伴随 CRC 错误。原因电机动力线和编码器信号线走同一束线时PWM 开关会造成共模噪声RS485 抗共模能力有限加上终端电阻配置不当高频尖峰容易耦合进 RX 引脚。解决把动力线和编码器线分开排或者至少交叉成 90 度在 485 A/B 到地各加一个 10pF 电容滤掉高频毛刺再不行就把终端电阻换成 390Ω 而不是裸的 120Ω降低总线阻抗对信号幅度的影响。这里别指望软件层过滤底层波形先理干净再谈滤波算法。5.5 读取例程在其他板子上跑不通换寄存器配置就好了现象同一份代码在 A 板卡正常烧到 B 板卡比如不同系列之间迁移后完全无应答。原因不同系列的 UART 时钟树完全不同尤其常见型号里 PCLK1 的最大频率差异很大2.5Mbps 波特率对分频精度很敏感PCLK 源没对应改波特率自然偏掉。解决迁移时重新打开 CubeMX确认串口所属总线时钟频率再看计算出的 BRR 分频值是否有小数截断误差。不放心就直接读寄存器打印 BRR 值和逻辑分析仪量出的实际位宽做对比用数据代替“玄学”定位。这条规律适用于所有高速串口不止编码器。6. 二次开发思路把这份读取程序封装成通用编码器驱动6.1 用函数指针把 UART 硬件隔离掉如果只是读编码器一次直接在 main 里写死 UART 句柄没什么问题但很快你会想在同一个 STM32 上接第二个轴或者把整个驱动移植到另一块单片机。我建议把底层收发抽象成两个函数指针typedef struct { int (*send)(const uint8_t*, uint32_t); int (*receive_dma)(uint8_t*, uint32_t); uint16_t (*get_frame_len)(void); } EncoderTransport; EncoderTransport enc_tc { .send hal_uart4_send, .receive_dma hal_uart4_dma_recv, };解析状态机只依赖这三件套不直接操作 HAL。换板子时你只需要重写一个 hal_uart4_* 这一层代码上层校验、符号扩展、多圈累计一概不动。代码量不多但对后期维护非常值得。6.2 增加零点标定接口做伺服控制必需的是“机械零点”电机上电后起始位置不一定是零所以驱动层要留出零点偏移寄存器。常见做法是记一个 enc_zero_offset真正给应用层用的角度用 decode_position 的结果减去 offset 后换算。这个 offset 可以由上位机通过命令写入也可以由按键触发编码器当前值捕捉。我建议把 offset 存到外部 Flash掉电不丢失否则每次重启都要重新标定一次调机时很痛苦。改完这些之后验证方法也很重要。先别急着接线先在函数测试里模拟一组回帧数据遍历位置值、CRC、单圈跳变三种用例。用串口把模拟帧灌进解析函数如果 1000 次迭代输出结果恒定再上真机。当初我第一次写完这个驱动就跳过模拟测试直接带电机翻阅了整整一下午 CRC 错误的日志才意识到是自己把模拟帧的位序写错了不是硬件问题。从那以后我每次换编码器型号或者改驱动接口都会强制跑一遍模拟帧回归再把 485 方向切换时序的示波器检查单放一起物理、逻辑各过一遍再进闭环。希望帮到你。本文还有配套的精品资源点击获取