简介:一套完整的STM32F4通过SPI接口驱动ICM20648六轴惯性传感器(IMU)的工程源码,面向嵌入式开发者、电子竞赛参赛者及需要快速完成运动数据采集的硬件工程师。工程基于STM32CubeMX + HAL库搭建,从SPI接口初始化、时钟与引脚配置,到寄存器读取加速度计、陀螺仪原始数据,形成了完整的传感器通信链路,可直接移植至无人机、机器人、可穿戴设备等项目。压缩包共379个文件,大小19.25MB,以118个.h头文件和105个.c源文件为主,包含HAL底层驱动库、用户应用代码,并附Keil工程文件(.uvprojx)、链接脚本(.sct)、批处理脚本(.bat)以及编译生成的.hex固件和.map映射文件,既可用于工程复现,也便于烧录调试。已有609人学习下载,代码结构清晰、模块划分明确,对理解SPI时序、ICM20648寄存器操作和STM32F4外设配置均有很好的参考价值。
1. 为什么 STM32F4 驱动 ICM20648 值得绕开 I2C 走 SPI
把 ICM20648 接到 STM32F4 的 SPI 总线上,表面上是换一种通信方式,实际上把你从 I2C 的地址冲突、400kHz 上限和软件模拟时的时序抖动里解放出来。这颗六轴传感器自带 4KB FIFO 和片上数字运动处理器,配合 STM32F4 的硬件 SPI,可以按 1kHz 帧率连续产出数据,CPU 只要在片选周期里做搬数据这件事。对于从 MPU6050 迁移过来的人,ICM20648 的 banked 寄存器会让你第一次读 WHO_AM_I 就意识到这不是老一套;对于刚用 CubeMX 生成工程的新手,只要把 SPI 的模式和片选规则定对,剩下的问题就集中在读到的数字是否可信。我下面写的连线、CubeMX 参数和最小验证代码,就是顺着这条线展开的。
2. SPI 引脚、时序模式与片选设计:ICM20648 上板前先拨清楚的三件事
2.1 先分清单片机引脚,别把 SDIO 和 SDO 接反
ICM20648 是六轴惯性传感器,接口上只留了 SCLK、SDIO、SDO、CS 这四根 SPI 线。SDIO 是主机发命令和数据用的,接到 STM32F4 的 MOSI;SDO 是从机输出数据用的,接到 STM32F4 的 MISO。这块芯片不带硬件仲裁,接反不会有任何反馈,结果就是读 WHO_AM_I 永远抓不到有效字节。
我在 STM32F4 上用 SPI1 做验证时,引脚分配如下:
| ICM20648 引脚 | STM32F4 引脚 | 说明 |
|---|---|---|
| VDD | 3.3V / 1.8V 视批次 | 逻辑电平不一致要加转换,不能硬接 |
| GND | GND | 电源地平层靠近传感器布局 |
| SCLK | PA5 / SPI1_SCK | 由 SPI1 外设控制 |
| SDIO | PA7 / SPI1_MOSI | 写命令和写寄存器共用 |
| SDO | PA6 / SPI1_MISO | 读寄存器输出数据 |
| CS | 任意 GPIO,如 PB6 | 用软件片选时由 GPIO 控制 |
| INT1 | 任意 GPIO,如 PB7 | 数据就绪中断输出 |
注意 SDO 在 I2C 模式下还是地址引脚 AD0。如果三极管或上拉电阻把它拉到一个固定电平,SPI 的 MISO 方向不会被影响,但 I2C 地址会被锁死。这个引脚电平在 SPI 模式下没有额外作用,不过调试时最好别让它悬空,省得逻辑分析仪上出现莫名其妙的噪声。
建议在 PCB 或者杜邦线上把 SCLK 和 SDIO 留出一段短引线,方便夹逻辑分析仪探头。惯性传感器对时序敏感,SPI 时钟边沿上的过冲比数据线电平更容易掩盖问题,我一般会把示波器探头夹在 SCLK 上而不是 SDO 上。
2.2 SPI Mode 3 不是玄学:CPOL/CPHA 与读取时序的对应关系
ICM20648 的寄存器访问帧和普通 SPI Flash 不太一样:CS 拉低后,主机发出的第一个字节是高 7 位寄存器地址加最低位读写标志,随后的 SCK 边沿才真正搬数据。如果 CPOL 和 CPHA 反了,地址字节会被从机误采,后面读回来的数据自然全错。
这颗传感器最常见的接法是 SPI Mode 3,也就是 CPOL=1、CPHA=1。CubeMX 里对应两个选项:Clock Polarity (CPOL) 选 High,Clock Phase (CPHA) 选 2 Edge。SCLK 空闲时保持高电平,第一个边沿输出地址位,第二个边沿采样 MISO 上的数据。这里不是让你背 MODE 数字,而是记住一个判断方法:先读 WHO_AM_I,能返回 0x48 就说明时序模式对了,返回全 0x00 或者全 0xFF 就去交换 CPOL 和 CPHA。
SCK 时钟频率也要压住。STM32F4 的 SPI1 挂在 APB2 总线上,CubeMX 里显示的分频数是相对于系统时钟算的。我习惯先把 Prescaler 设成 16,跑出 5.25MHz 左右,把链路跑通后再循序渐进地提高。软件模拟 SPI 可以慢慢读,硬件 SPI 一旦频率上去,线缆长度和上拉强度都会变成变量。
2.3 硬件片选和软件片选怎么选
ICM20648 要求 CS 在完整的寄存器访问期间保持低电平:地址字节和数据字节共用一条 SCLK 时钟线,CS 中间抖一下就会丢掉字节边界。STM32F4 的硬件 NSS 在连续传输多个字节时通常也能满足这个条件,但配置多一个环节,多一个出错点。
| 对比项 | 软件片选(GPIO) | 硬件片选(NSS) |
|---|---|---|
| 时序可控性 | 高,可手动控制拉低时机 | 依赖 SPI 外设状态机 |
| 调试难度 | 低,逻辑清楚 | 要高,NSS 配置错会一直读零 |
| 多设备共享总线 | 需要仔细防止总线争用 | 硬件自动切换更省心 |
| 推荐程度 | 调试例程首选 | 量产或 RTOS 多任务时考虑 |
我写这颗传感器例程时一律用软件片选。CS 拉低前先把 GPIO 配成推挽输出,传输完成后再拉高,这样即使 SPI 外设处于空闲状态,也不会把 CS 长期压在地上。
icm20648_cs_low(); spi_write_byte(reg | 0x80); // 带读标记的寄存器地址 spi_write_byte(0x00); // 数据阶段,MISO 上出现寄存器值 icm20648_cs_high();这段伪代码表达了第 3 章 HAL 函数底层的完整事务模型:CS 低电平从命令字节开始,到数据字节结束。实际工程里,CS 拉低后最好加一个 100ns 左右的延时,给从机地址采样准备时间,但 STM32F4 的 HAL 库函数调用本身已经有足够开销,通常不需要额外插入 nop。
3. CubeMX 里把 SPI1 配置成 Mode 3,再用 HAL 库读 WHO_AM_I 验证通信
3.1 CubeMX 参数表
先把 STM32F4 的 SPI1 打开,引脚映射会自动出现在 PA5、PA6、PA7 上。CubeMX 左侧 Parameter Settings 里需要改动的参数不多,照着下面这张表填就行:
| 参数名 | 设置值 | 理由 |
|---|---|---|
| Mode | Full-Duplex Master | ICM20648 需要主机发命令、从机回数据 |
| Hardware NSS Signal | Disable | 使用软件片选,避免 NSS 抢占 GPIO |
| Data Size | 8 bits | 寄存器地址和数据都是单字节,不需要 16 位 |
| First Bit | MSB First | InvenSense 寄存器协议固定 MSB 先发 |
| Clock Polarity (CPOL) | High | 对应 SPI Mode 3 |
| Clock Phase (CPHA) | 2 Edge | 数据在第二个边沿采样 |
| Prescaler | 16 | 约 5.25MHz,留足时序余量 |
| CRC | Disable | SPI CRC 会污染单字节寄存器帧 |
生成代码之前把片选引脚在 GPIO 标签里设成 Output Push Pull、High Level。上电默认 CS 高电平,让芯片先停留在非选中状态,等到第一笔 SPI 事务开始才拉低。
有些人会把 FSYNC 也配一个 GPIO 输出低电平。实际上 ICM20648 的 FSYNC 不用时可以悬空,但悬浮引脚在电噪声大的电机驱动板上有可能引入异常采样,我会顺手在初始化阶段把 FSYNC 对应的 GPIO 设为 0。
3.2 读 WHO_AM_I 的最小 HAL 函数
下面是完整的读写原语。这段代码能直接放进 STM32CubeIDE 生成的工程里,只需要把 hspi1 和片选引脚改成你自己工程里的句柄和宏即可。
/* icm20648_spi.c */ #include "main.h" #include "spi.h" extern SPI_HandleTypeDef hspi1; #define ICM20648_CS_LOW() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET) #define ICM20648_CS_HIGH() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET) #define ICM20648_WHO_AM_I 0x00 /* ICM20648 的 Bank0 地址,按手册调整 */ static void icm20648_write_reg(uint8_t reg, uint8_t value) { uint8_t tx[2]; tx[0] = reg & 0x7F; /* 写操作时最高位为 0 */ tx[1] = value; ICM20648_CS_LOW(); HAL_SPI_Transmit(&hspi1, tx, 2, HAL_MAX_DELAY); ICM20648_CS_HIGH(); } static uint8_t icm20648_read_reg(uint8_t reg) { uint8_t tx[2] = { reg | 0x80, 0x00 }; uint8_t rx[2]; ICM20648_CS_LOW(); HAL_SPI_TransmitReceive(&hspi1, tx, rx, 2, HAL_MAX_DELAY); ICM20648_CS_HIGH(); return rx[1]; /* 第二个字节才是从机回的数据 */ } uint8_t icm20648_check_id(void) { return icm20648_read_reg(ICM20648_WHO_AM_I); }HAL_SPI_TransmitReceive是同步发送和接收接口。发送数组第一个字节是带读标记的寄存器地址,第二个字节是填充字节,作用只是产生 SCLK 时钟;从机在第二个字节对应的时钟周期内把寄存器值放到 SDO 上,所以返回值取rx[1]。
HAL_MAX_DELAY表示不超时。在调试阶段这样写最省事,一旦 SPI 引脚没配对,程序会卡死在等待 SCLK 完成的状态。量产代码里我会改成 100 毫秒超时,并在返回出错状态时做重试。
3.3 返回 0x00 或 0xFF 的排查顺序
WHO_AM_I 是检验通信的第一道关卡。常见返回值有两种错误:
第一个是 0x00。看到全 0 先查 MISO 电平。STM32F4 的 GPIO 要复用为 SPI,但 MISO 引脚本身默认有上下拉配置,如果在 CubeMX 里把它设成了 Output,HAL 库根本采不到数据。第二个是 0xFF,表示 SDO 始终被拉高,多半是 CPOL/CPHA 配错了方向,或者 IC 的 CS 根本没拉低。
还有一个隐蔽问题:从机地址和主机地址的字节序。ICM20648 的寄存器协议要求 MSB 先发,CubeMX 默认也是 MSB First,但如果之前跑过 W25Q64 之类的 SPI Flash,把 First Bit 改成了 LSB First,读回来的 ID 就会变成按位翻转后的值,看着像乱码却不等于 0xFF。遇到这种中间状态,先把工程里 SPIn 的其他参数截图和 CubeMX 默认值对比一下。
4. 寄存器初始化与 14 字节批量读取:把 ICM20648 的原始惯量数据搬回内存
4.1 唤醒、复位与默认量程
ICM20648 上电后默认处于低功耗态,直接用 SPI 读加速度寄存器大概率读到全零。我的最小初始化顺序是:先延时等待电源平稳,再写 PWR_MGMT_1 复位,最后清除睡眠位并确认 WHO_AM_I。
注意这颗芯片的寄存器是分 bank 的,REG_BANK_SEL决定当前访问哪个地址空间。默认停在 Bank 0,PWR_MGMT_1 和 WHO_AM_I 都在 Bank 0 里,所以初始化阶段不用切 bank。如果你拿到的寄存器手册是更早的版本,地址定义可能有差异,以下宏在实际移植时以自己手上的手册为准。
#define ICM20648_PWR_MGMT_1 0x06 #define ICM20648_WHO_AM_I 0x00 uint8_t icm20648_init(void) { uint8_t id; HAL_Delay(50); /* 复位所有寄存器,回到上电默认状态 */ icm20648_write_reg(ICM20648_PWR_MGMT_1, 0x80); HAL_Delay(100); /* 清除睡眠位,选内部振荡器作为时钟源 */ icm20648_write_reg(ICM20648_PWR_MGMT_1, 0x01); HAL_Delay(10); id = icm20648_read_reg(ICM20648_WHO_AM_I); if (id != 0x48) { return 1; } return 0; }这里不修改 GYRO_CONFIG 和 ACCEL_CONFIG,意味着陀螺仪量程是默认的 ±250dps,加速度计量程是默认的 ±2g。先跑通再调量程,比一开始就把所有寄存器都配置好更容易排错。如果你需要更高量程,比如把陀螺仪调到 ±500dps,记得后面的换算常数也要同步改。
4.2 burst read 批量读取 14 字节原始数据
连续读取比单字节读更实用。ICM20648 支持从某个起始寄存器地址开始自动递增输出,所以我只要把 CS 拉低,发出起始地址,然后连续接收 14 个字节,就能一次拿到加速度 X/Y/Z、温度和陀螺仪 X/Y/Z。
typedef struct { int16_t accel[3]; int16_t temp; int16_t gyro[3]; } icm20648_raw_t; #define ICM20648_ACCEL_OUT 0x2D /* Bank0 中加速度首个输出寄存器 */ icm20648_raw_t icm20648_read_raw(void) { icm20648_raw_t raw; uint8_t tx_buf = ICM20648_ACCEL_OUT | 0x80; uint8_t rx_buf[14]; ICM20648_CS_LOW(); HAL_SPI_Transmit(&hspi1, &tx_buf, 1, HAL_MAX_DELAY); HAL_SPI_Receive(&hspi1, rx_buf, 14, HAL_MAX_DELAY); ICM20648_CS_HIGH(); raw.accel[0] = (int16_t)((rx_buf[ 0] << 8) | rx_buf[ 1]); raw.accel[1] = (int16_t)((rx_buf[ 2] << 8) | rx_buf[ 3]); raw.accel[2] = (int16_t)((rx_buf[ 4] << 8) | rx_buf[ 5]); raw.temp = (int16_t)((rx_buf[ 6] << 8) | rx_buf[ 7]); raw.gyro[0] = (int16_t)((rx_buf[ 8] << 8) | rx_buf[ 9]); raw.gyro[1] = (int16_t)((rx_buf[10] << 8) | rx_buf[11]); raw.gyro[2] = (int16_t)((rx_buf[12] << 8) | rx_buf[13]); return raw; }执行逻辑是:先发送一个地址字节,HAL_SPI_Receive内部会连续产生 14 个字节的 SCLK,同时把从机 SDO 上输出的数据写进rx_buf。因为 CS 在整个过程中保持低电平,从机会自动把地址递增下去,直到读满 14 个字节才拉高片选。
HAL_SPI_Transmit和HAL_SPI_Receive是两次独立的同步调用,但它们之间共享 CS 低电平状态,所以底层仍然是同一个 SPI 事务。要保证从机地址和后续数据之间没有多余延时,STM32F4 的 HAL 库调用间隔很短,正常调到 5.25MHz 时不会丢字节。
4.3 原始值换算成 g 和 dps 的常数选择
原始值是补码形式的有符号数。把两个相邻字节拼成一个int16_t后,加速度按 16384 LSB/g 换算,陀螺仪按 131 LSB/dps 换算。
float ax_g = (float)raw.accel[0] / 16384.0f; float gx_dps = (float)raw.gyro[0] / 131.0f;这两个常数对应的是默认量程。改变量程后,加速度常数会变成 8192、4096 等,陀螺仪常数变成 65.5、32.8 等。换算结果不是浮点性能瓶颈,但对于 STM32F4 做姿态解算的场景,可以在读取后就地转成 float,避免后续整型乘除反复溢出。
如果你把传感器水平放在桌面上,加速度 Z 轴读数应该在 16384 附近,X/Y 轴接近 0。陀螺仪三个轴都接近 0,但不会严格等于 0,因为传感器本身有零偏,这一点在第 5 章会用来验证数据链路。
5. 静止校验法:用 SPI-ICM20648 例程上电后的第一组数据抓住时序问题
5.1 采集 1000 个静止样本
驱动读通之后,第一件事不是接 DMP,而是把传感器放在水平桌面上连续打印原始数据。我用串口每 5 毫秒打印一行:
printf("%d,%d,%d,%d,%d,%d\r\n", raw.accel[0], raw.accel[1], raw.accel[2], raw.gyro[0], raw.gyro[1], raw.gyro[2]);采集大约 1000 行,保存成imu.csv,然后用 Python 在电脑上做一次统计。判断标准是:加速度模长落在 1g 附近,陀螺仪三个轴均值不超过每秒几度。
import math import statistics rows = [[int(v) for v in line.split(',')] for line in open('imu.csv')] acc_norm = [math.sqrt(r[0]**2 + r[1]**2 + r[2]**2) / 16384.0 for r in rows] print("acc norm mean:", statistics.mean(acc_norm)) print("acc norm stddev:", statistics.pstdev(acc_norm)) gyro_offset = [statistics.mean([r[3], r[4], r[5]]) / 131.0 for r in rows] print("gyro offset dps:", gyro_offset)加速度模长的标准差如果超过 0.02g,说明数据里混入了明显抖动;陀螺仪均值如果偏向同一方向超过 5dps,可能是传感器没放平,也可能是字节拼接时高低位反了。注意高字节在前、低字节在后,如果拼反,静止时数字会来回跳而不是恒定接近 0。
5.2 校验结果对应到 SPI 层排查
加速度模长正常、陀螺仪零偏也正常,说明 SPI 的片选、SCLK 和数据线三件事都对了,这时再去调量程和 DMP 也放心。如果出现两边数值都异常,优先检查两处。
第一处是 CS 有没有在整个读取期间保持低。用逻辑分析仪抓 CS 和 SCLK 的叠加波形,如果 CS 在命令字节结束之后出现毛刺,HAL_SPI_Receive的时序就会失效。第二处是时钟模式。Mode 3 下 SCLK 空闲为高,HAL_SPI_TransmitReceive期间如果有上升沿抖动,从机可能把地址采错。
这套静止校验方法的好处是不依赖任何外部设备,串口加一个水平桌面就能做。我每次移植新 PCB 上的 SPI-ICM20648 例程,都会先跑这一步,确认原始数据的统计特性无异常后,再往上加 FIFO 和四元数融合。这样后面任何姿态误差都可以直接往算法层和传感器标定层去找,不用回头怀疑 SPI 驱动本身。
本文还有配套的精品资源,点击获取