做磁编码器这个项目之前,我本来以为最难的地方是机械安装或者磁场标定,结果真正上手之后才发现,把SPI调通、把寄存器读对才是第一道坎。尤其是AS5047P这种带“响应滞后”特性的芯片,很多人在CubeMX里配好SPI,代码写了一堆,读出来的角度却是0x3FFF、0或者乱七八糟的跳变,最后排查一圈发现根本不是芯片坏了,而是压根没读懂它的SPI时序。这篇东西我尽量把从CubeMX配置到寄存器读写再到角度换算的每个环节都写清楚,顺手把我踩过的坑也一并列出来,给后面做舵机反馈、云台、机器人关节的朋友省点时间。
1. 项目背景与整体思路
1.1 AS5047P 是什么,为什么选它
AS5047P 是 ams 家的 14 位磁角编码器,核心卖点是分辨率和接口。14 位分辨率意味着每圈被分成 16384 个计数,每个 LSB 对应 360 / 16384 = 0.02197 度,也就是大约 0.022 度的角度分辨率,用于舵机反馈、云台稳定、机器人关节这类应用完全够用。它内部集成了霍尔阵列和 CORDIC 反正切运算单元,你只需要在转轴端放一颗合适的径向磁铁,芯片就能直接算出绝对角度,不需要码盘、不需要光栅,天然抗灰尘、抗油污、抗振动,安装余量也大。
和其它磁编码器比,AS5047P 的优势在于三点:第一,SPI 接口最快支持 10MHz 时钟,读一次角度只要几微秒;第二,内置自动增益控制(AGC)和诊断寄存器 DIAAGC,可以直接通过寄存器判断磁铁安装是否健康;第三,支持零点编程(ZPOS),可以软件标定机械零点,省去安装时反复对位。相比常见的 AS5600(12 位)、MT6701(14 位,但寄存器体系完全不同),AS5047P 的文档和开源方案最全,出了问题也相对好查。
1.2 整体方案:CubeMX + HAL 库 + 软件片选
这套方案我选了 STM32F103C8T6 作为主控,理由是量大、便宜、资料多,而且 CubeMX 对它的支持非常成熟。软件开发流程是 STM32CubeMX 生成初始化工程,再在 HAL 库基础上自己封装 AS5047P 的驱动。为什么用 CubeMX 而不是裸寄存器?因为时钟树、SPI 外设、GPIO 复用这些配置,手写很容易出错,CubeMX 图形化点几下就把初始化代码生成好了,后面只需要在生成框架里填业务逻辑,省心且不容易翻车。
片选策略上,这里明确建议用软件片选(GPIO 手动拉 CS),不要让 SPI 硬件自动控制 NSS。原因后面会详细说——AS5047P 读取角度需要两段式操作,中间必须把 CS 拉高一次再拉低,硬件 NSS 在这种特殊时序面前不够灵活,容易把帧边界搞乱。软件片选虽然多占一个 GPIO,但控制力直接拉满,出问题也好排查。
2. 理解 AS5047P 的寄存器与 SPI 通信机制
2.1 关键寄存器速查
AS5047P 的寄存器是 14 位地址空间,SPI 命令帧和响应帧都是 16 位。第一次调这块芯片,不需要把几十个寄存器全背下来,先把下面这几个常用的搞清楚就够上路了。
| 寄存器名 | 地址 | 读写 | 作用 |
|---|---|---|---|
| ANGLECOM | 0x3FFF | 读 | 综合角度,包含零点偏移补偿后的 14 位角度 |
| ANGLERAW | 0x3FFE | 读 | 原始角度,不包含 ZPOS 零点偏移 |
| DIAAGC | 0x3FFC | 读 | 诊断寄存器,低 8 位是 AGC 增益值 |
| ERRFL | 0x3FFD | 读 | 错误标志寄存器 |
| PROG | 0x3FFB | 读/写 | OTP 编程控制,非必要不要碰 |
| ZPOSM | 0x0016 | 读/写 | 零点位置高 8 位 |
| ZPOSL | 0x0017 | 读/写 | 零点位置低 6 位 |
| UNLOCK1 | 0x3FE8 | 写 | 解锁密钥 1,写入 0x5F11 |
| UNLOCK2 | 0x3FE9 | 写 | 解锁密钥 2,写入 0x6D2E |
| PROT | 0x3FEC | 写 | 写保护寄存器 |
ANGLECOM 和 ANGLERAW 的区别要理解清楚。ANGLECOM 是经过 ZPOS 零点补偿之后的角度值,ANGLERAW 是传感器原始角度。上电默认 ZPOS=0,所以这两个寄存器读出来的值是一样的;如果你写了 ZPOS,ANGLECOM 会跟着零点偏移变,ANGLERAW 不变。实际工程里大多数场景直接读 ANGLECOM 就行,做闭环控制也是用这个值。
2.2 命令格式与“流水线响应”特性
这一节是整个项目最核心的坑,理解了它,AS5047P 调通就完成了一半。
SPI 通信时,主机通过 MOSI 发送 16 位命令,命令格式是:bit15 必须为 0(正常模式标志),bit14 是读写位(1=读,0=写),bit13~bit0 是寄存器地址。举个例子,读 ANGLECOM 的命令就是 0x4000 | 0x3FFF = 0x7FFF;写 UNLOCK1 的命令就是 0x3FE8(bit14=0,高两位不用管)。
芯片通过 MISO 返回 16 位数据帧,数据帧格式是:bit15 是偶校验位,bit14 是错误标志位(数据无效时置 1,正常工作时为 0),bit13~bit0 才是真正的有效数据。所以读角度的时候,拿到的原始数据要先跟 0x3FFF 做 mask,把状态位清掉,剩下的低 14 位才是角度。
真正的坑在于 AS5047P 的响应不是“即时”的,而是流水线式的:芯片在“当前这一帧”的 MISO 上返回的,是“上一帧命令”的结果。换句话说,你确实发出去了读 ANGLECOM 的命令,但此时 MISO 上回来的数据是上一条命令(或者是上电默认值)的响应,不是当前命令的响应。非要类比的话,就像你隔着门问问题,你问第一句的时候,门里的人回答的是你上一句的问题,只有你再问一次,才能拿到当前问题的答案。
对应到代码上,业界有两种常用读法。第一种是两段式,也是最稳妥的:CS 拉低,发读命令,CS 拉高;然后 CS 拉低,再发一次相同的读命令(或者任意非法读命令),此时 MISO 返回的才是第一次命令对应的数据,CS 拉高。第二种是一次性 32 拍:CS 拉低后连续发送 16 位命令 + 16 位任意数据(实际发 0x0000),取后面这 16 位返回,因为芯片在第二个 16 位周期把命令结果放到 MISO 上了。两种方法都能用,但两段式跟数据手册的时序图最接近,也最容易理解,本文代码按这个来。
写寄存器则不太一样。写操作必须在同一个 CS 低电平周期内连续发送 16 位写命令 + 16 位写入数据,芯片在第二个 16 位阶段锁存数据,中途 CS 不能拉高。如果按读操作的“两段式”去写,CS 在命令发完就拉高了,芯片会认为这是一个不完整的帧,写入直接失败。所以驱动里读和写要分开写,别共用一套时序。
3. STM32CubeMX 配置 SPI 与工程搭建
3.1 硬件连接与 CubeMX 时钟配置
硬件连接其实很简单。AS5047P 是 3.3V 器件,STM32F103 的 SPI1 引脚正好也是 3.3V 电平,可以直接对接,不需要电平转换。我这里用的是 SPI1,引脚分配如下:
| AS5047P 引脚 | STM32F103 引脚 | 说明 |
|---|---|---|
| VDD3V3 | 3.3V | 电源,建议加一个 100nF 去耦电容 |
| GND | GND | 共地 |
| SCLK | PA5 | SPI1_SCK |
| MOSI | PA7 | SPI1_MOSI |
| MISO | PA6 | SPI1_MISO |
| CSN | PA4 | 软件片选,GPIO 输出 |
CubeMX 里新建工程后,第一步先配 RCC,外部高速时钟 HSE 选 Crystal/Ceramic Resonator,给后面的倍频提供基准。然后配 SYS,Debug 选 Serial Wire,这个非常重要——如果你选的是 No Debug,程序烧进去之后 SWD 引脚会被复用掉,第二次就下载不进程序了,C8T6 最小系统板的经典翻车现场。
时钟树里,外部 8MHz 晶振,PLL 倍频到 72MHz。注意 STM32F103 的 SPI1 挂在 APB2 总线上,APB2 的时钟就是 72MHz,后面算 SPI 预分频要用到这个数。
3.2 SPI 参数设置与片选策略
CubeMX 的 SPI1 配置页面,关键参数按下面这份来:
- Mode:Full-Duplex Master(AS5047P 是全双工 SPI 从机)
- Data Size:16 Bits(命令和数据帧都是 16 位,用 16 位模式最直接)
- Clock Prescaler:/16,SPI 时钟=72/16=4.5MHz
- CPOL:Low
- CPHA:2 Edge
- First Bit:MSB First
- NSS:Disable
CPOL=Low 和 CPHA=2 Edge 合起来就是 SPI Mode 1,AS5047P 数据手册推荐的 SPI 模式。在实际调试中,如果你发现数据读数始终不对,波形时序看起来又正常,可以把 CPHA 换一下试另一个边沿,但不建议一开始就用其它模式,会给自己制造不必要的变量。
预分频这里解释一下为什么选 /16 而不是选 /8(9MHz)。AS5047P 的 SPI 时钟上限是 10MHz,9MHz 确实没超,但项目里如果用杜邦线连接、或者 PCB 走线不太规整,高频下容易出现信号振铃和码间干扰,MISO 上的返回数据就会偶发错误。4.5MHz 对读取一个 16 位帧来说只需要 3.5 微秒左右,绝大多数控制场景根本感知不到这个速度差异。我的原则是:先稳定,再谈速度。
NSS 选择 Disable,然后单独把 PA4 配置成 GPIO_Output,初始电平设为 High。这就是软件片选的实现方式——CS 默认高电平表示芯片未选中,需要通信时手动拉低。在 CubeMX 的 GPIO 标签页里可以把 PA4 的 Maximum output speed 设为 Low 或 Medium,其它保持默认。
生成代码之后,可以在 main.c 里看到 SPI1 的初始化参数。有个细节值得确认:如果 CubeMX 生成的是 HAL_SPI_Init,参数里会出现CLKPolarity = SPI_POLARITY_LOW和CLKPhase = SPI_PHASE_2EDGE,这是对的,别手滑改成别的。
4. 代码实现:从寄存器读写到角度输出
4.1 底层读写函数
我用两个文件封装 AS5047P 驱动,as5047p.h 里放寄存器地址宏和函数声明,as5047p.c 里放实现。先看头文件:
#ifndef AS5047P_H #define AS5047P_H #include "main.h" #define AS5047P_PROG 0x3FFB #define AS5047P_DIAAGC 0x3FFC #define AS5047P_ERRFL 0x3FFD #define AS5047P_ANGLERAW 0x3FFE #define AS5047P_ANGLECOM 0x3FFF #define AS5047P_ZPOSM 0x0016 #define AS5047P_ZPOSL 0x0017 #define AS5047P_UNLOCK1 0x3FE8 #define AS5047P_UNLOCK2 0x3FE9 #define AS5047P_PROT 0x3FEC #define AS5047P_CMD_READ(reg) (0x4000 | ((reg) & 0x3FFF)) #define AS5047P_CMD_WRITE(reg) ((reg) & 0x3FFF) uint16_t AS5047P_ReadReg(uint16_t reg); void AS5047P_WriteReg(uint16_t reg, uint16_t value); uint16_t AS5047P_GetRawAngle(void); float AS5047P_GetAngleDeg(void); #endifCS 控制直接用 HAL 的 GPIO 操作,代码里可以这样定义:
#define AS5047P_CS_LOW() HAL_GPIO_WritePin(AS5047P_CS_GPIO_Port, AS5047P_CS_Pin, GPIO_PIN_RESET) #define AS5047P_CS_HIGH() HAL_GPIO_WritePin(AS5047P_CS_GPIO_Port, AS5047P_CS_Pin, GPIO_PIN_SET)这里的AS5047P_CS_GPIO_Port和AS5047P_CS_Pin在 CubeMX 生成的头文件里会自动定义,PA4 对应的是 GPIOA 和 GPIO_PIN_4,直接用就行。
然后是读寄存器函数。根据前面讲的两段式原理,代码长这样:
uint16_t AS5047P_ReadReg(uint16_t reg) { uint16_t cmd = AS5047P_CMD_READ(reg); uint16_t rx1 = 0, rx2 = 0; // 第一段:发送读命令,此时 MISO 返回的是上一次命令的数据,丢弃 AS5047P_CS_LOW(); HAL_SPI_TransmitReceive(&hspi1, (uint8_t *)&cmd, (uint8_t *)&rx1, 1, 10); AS5047P_CS_HIGH(); // 第二段:再发一次相同命令,此时 MISO 返回的才是第一段命令的结果 AS5047P_CS_LOW(); HAL_SPI_TransmitReceive(&hspi1, (uint8_t *)&cmd, (uint8_t *)&rx2, 1, 10); AS5047P_CS_HIGH(); return rx2; }这里有个细节容易被新手忽略:CubeMX 生成工程默认 SPI 数据大小是 8 位,如果你在代码里直接传 uint16_t 而不改配置,HAL 会按两个 8 位帧发送,CS 控制就乱套了。所以务必在 CubeMX 里把 Data Size 改成 16 Bits,这一步我没少提醒人。
写寄存器函数则不一样,必须在一个 CS 低电平周期内连续发送命令和数据:
void AS5047P_WriteReg(uint16_t reg, uint16_t value) { uint16_t cmd = AS5047P_CMD_WRITE(reg); uint16_t data = value; uint8_t tx_buffer[4]; tx_buffer[0] = (uint8_t)(cmd >> 8); tx_buffer[1] = (uint8_t)(cmd & 0xFF); tx_buffer[2] = (uint8_t)(data >> 8); tx_buffer[3] = (uint8_t)(data & 0xFF); AS5047P_CS_LOW(); HAL_SPI_Transmit(&hspi1, (uint8_t *)tx_buffer, 4, 10); AS5047P_CS_HIGH(); }为什么这里用HAL_SPI_Transmit而不是TransmitReceive?因为写操作不需要关心 MISO 返回的数据,发送 4 个字节(即 16 位命令 + 16 位数据)就够了。这里HAL_SPI_Transmit的第三个参数 4 是字节数,它是按字节传输的,与前面 16 位模式下TransmitReceive的 size 单位是 16 位字不同,别搞混。
4.2 角度读取与换算
角度数据的读取,本质上就是读 ANGLECOM 或者 ANGLERAW,然后处理返回的原始值。上电后建议先做一次“热身读”,丢弃第一次结果,因为芯片刚上电时内部状态可能还没有稳定,流水线响应也还没有建立起有效的前置命令。驱动里可以在初始化阶段主动读一次 ANGLERAW 并忽略结果。
获取原始角度的函数:
uint16_t AS5047P_GetRawAngle(void) { uint16_t data = AS5047P_ReadReg(AS5047P_ANGLERAW); // 高两位是状态位,低 14 位才是有效数据 return data & 0x3FFF; }角度换算成度,就是:
#define AS5047P_RESOLUTION 16384.0f // 2^14 float AS5047P_GetAngleDeg(void) { uint16_t raw = AS5047P_GetRawAngle(); return (float)raw * 360.0f / AS5047P_RESOLUTION; }举个例子,读到的原始数据是 4000,那么角度就是 4000 * 360 / 16384 = 87.89 度。这个换算过程不需要浮点也能做,定点的话用raw * 360 / 16384,注意溢出问题——raw 最大 16383,乘以 360 后是 5,897,880,int32 完全装得下。
在主循环里这样调用就行:
float angle = AS5047P_GetAngleDeg(); printf("angle: %.2f deg\r\n", angle); HAL_Delay(100);实际测试中,只要磁铁安装正常、供电稳定,读到的角度应该是平滑变化的,不会出现大幅度跳动。如果每 100ms 测一次,手动缓慢旋转轴头,角度数值会单调增减,这就是基本调通了。
4.3 零点标定(写 ZPOS)
机械安装时,磁铁旋钮和电机轴的零点不可能严格对齐。AS5047P 比普通编码器方便的地方在于,它可以在软件里设零点,不需要动机械结构。具体做法是写 ZPOS 寄存器:它一共 14 位,高 8 位存在 ZPOSM(0x0016),低 6 位存在 ZPOSL(0x0017)。
写 ZPOS 前必须先解锁写保护,官方解锁流程是依次写 UNLOCK1 和 UNLOCK2:
void AS5047P_Unlock(void) { AS5047P_WriteReg(AS5047P_UNLOCK1, 0x5F11); AS5047P_WriteReg(AS5047P_UNLOCK2, 0x6D2E); HAL_Delay(1); } void AS5047P_SetZero(uint16_t zero) { AS5047P_Unlock(); AS5047P_WriteReg(AS5047P_ZPOSM, (uint8_t)((zero >> 6) & 0xFF)); AS5047P_WriteReg(AS5047P_ZPOSL, (uint8_t)(zero & 0x3F)); }比如想设 5000 作为新的零点,调用AS5047P_SetZero(5000)即可。写完之后读 ANGLECOM 验证,转到这个位置时它应该输出 0,或者在 16383~0 的边界跳动(因为 14 位角度的回绕特性)。
有一点需要特别提醒:如果只是想临时指定零点,直接写 ZPOSM/ZPOSL 就够了,这是 RAM 配置,掉电会丢失。绝对不要手滑去碰 PROG(0x3FFB)相关的熔断流程,OTP 编程是烧断内部熔丝的操作,一旦执行是不可逆的。如果你不确定自己在干嘛,只写 ZPOS 即可。
5. 常见问题与避坑实录
5.1 数据不对?先查时序和接线
调试过程中遇到最多的就是“读数固定不变”或者“读数要么 0 要么 65535”。我建议先别急着改代码,按下面顺序排查。
第一,量电压。AS5047P 必须稳定供 3.3V,地线要和 STM32 共地,不然 SPI 逻辑电平根本没有参考基准。
第二,抓波形。有逻辑分析仪就抓 CS、SCLK、MOSI、MISO 四个信号,确认每一帧确实是 16 位,CS 拉低之后 SCK 才开始抖动,且在两次读命令之间 CS 有完整的高电平恢复期。没有逻辑分析仪的话,用示波器至少能看到 16 个完整的 SCLK 脉冲。
第三,确认 SPI 模式。很多读数和时序问题最后都是 CPOL/CPHA 配错了,AS5047P 是 Mode 1(CPOL=Low,CPHA=2 Edge),如果你发现 MISO 在 SCLK 第一个沿就输出数据、时序总是反的,可以把这两项改成 Mode 0 或 Mode 2 交叉验证。
第四,检查命令字。读 ANGLECOM 的命令是 0x7FFF,不是 0xBFFF,更不是 0xFFFF。命令字最高位 bit15 必须是 0,有些网友从网上抄的代码里写成了 0x8000 开头,那种命令会让芯片进入特殊模式,返回的数据就会对不上。
第五,如果 MISO 一直输出 0xFFFF,大概率是 CS 引脚根本没拉低,或者 MISO 引脚接反了。SPI 的 MOSI/MISO 容易在接线阶段对调,排查时先把这个排除。
5.2 角度跳变与磁铁安装
如果波形正常、寄存器也能读,但角度总是跳变,那问题基本出在磁铁安装和供电上。AS5047P 需要一颗径向充磁的钕铁硼圆片磁铁,直径 6mm、厚度 3mm 是常见选择,居中安装在电机轴末端,芯片中心正对磁铁圆心,距离芯片表面大约 0.5~1.5mm。
怎么判断磁铁装得好不好?用 DIAAGC 寄存器。读取 0x3FFC 的值,低 8 位就是 AGC 自动增益控制值,它反映磁场强度:正常工作时 AGC 值应该落在一个健康区间,大概在 100~200 之间。如果 AGC 接近 0,说明磁铁太近、磁场过强;如果 AGC 接近 255,说明磁铁太远或偏心,信号太弱。这个值读出来非常直接,比看角度跳变再去猜原因高效得多。
另外,供电稳定性也要注意。AS5047P 内部有模拟电路和 ADC,如果电源纹波大,角度读数会周期性抖动。我试过在面包板上用杜邦线供电,电机一转起来角度就跳,后来给 VDD3V3 和 GND 之间加了 100nF + 10uF 去耦电容,情况立刻好转。主控和编码器尽量用同一路 3.3V,避免压差。
5.3 DMA 与性能优化建议
很多朋友看到热词里“SPI DMA”就想上 DMA,实际上在这个项目里,DMA 的优先级并不高。AS5047P 在 4.5MHz 时钟下,读一次 16 位帧大约需要 3.5 微秒,加上 CS 切换和两段式操作,一次角度读取也就 7 微秒左右。就算控制周期是 1kHz,一次读取占 CPU 的时间也就是 0.7%,这点开销对绝大多数 MCU 来说完全可以忽略。
真正需要考虑 DMA 的反而是读时序复杂度。AS5047P 的两段式读需要中间拉高 CS,而 DMA 传输一旦启动,中途停止或者精确控制 CS 就变得麻烦。我见过有人在 DMA 中断回调里翻转 CS,最终把代码搞得巨复杂,调试难度直线上升,性能收益却微乎其微。所以我的建议是:先用阻塞式把功能跑通,如果项目确实有高频采样需求,再考虑把“命令+数据”合并成一次 32 位 DMA 传输,用一次 CS 周期读完,而不是纠结两段式。
5.4 一个实操检查清单
最后我把整个调试过程浓缩成一张检查表,照着过一遍能省掉大量排查时间。
| 检查项 | 期望结果 | 异常处理 |
|---|---|---|
| VDD3V3 | 稳定 3.3V,去耦电容 100nF | 换供电、加电容 |
| SCLK/MOSI/MISO/CS 接线 | 无虚焊,无对调 | 用万用表通断档逐根量 |
| SPI 时钟 | ≤10MHz,推荐 4.5MHz | 调预分频 |
| SPI 模式 | Mode 1(CPOL=Low, CPHA=2 Edge) | 交叉验证其它模式 |
| 数据帧长度 | 16 Bits | 检查 Data Size 配置 |
| 读命令 | 0x4000 | 寄存器地址 | 检查命令字位 |
| 数据有效位 | mask 低 14 位后使用 | 检查 & 0x3FFF |
| 流水线响应 | 第一次读丢弃 | 初始化时热身读 |
| AGC 值 | 100~200 左右 | 调整磁铁位置和距离 |
| 角度换算 | raw * 360 / 16384 | 检查除数不要写成 36000 |
我个人在实际操作中习惯把这个检查表放在手边,每次新焊一块板子,先看 AGC 再读角度。这套流程帮我区分了“芯片没工作”和“SPI 没调通”这两类问题,调试效率高了特别多。最后再分享一个小细节:AS5047P 焊接时温度不要太高、时间不要过长,芯片内部有磁感应元件,过热的返修过程可能会导致零点漂移,正常焊接时用低温烙铁快速完成,焊完先读 DIAAGC 确认芯片没“受伤”,再继续调后续逻辑。