简介:面向嵌入式开发者的STM32F103与LIS3DH三轴加速度传感器通信实现,采用GPIO模拟IIC总线方式驱动,已实测通过。压缩包共4个文件,含2个C源文件与2个头文件,整体仅12KB,涵盖IIC底层时序模拟与传感器数据读取等模块。目前已有2586人学习参考,代码量小、结构清晰,适合需要在STM32平台上快速接入LIS3DH的工程师使用。这套实现提供了完整的模拟IIC读写接口,开发者可参照其START、STOP、ACK及数据位时序理解LIS3DH寄存器操作流程;同时底层配置基于官方驱动移植,符合芯片要求,只需调整读写函数即可适配不同硬件,便于二次开发。LIS3DH适用于运动检测、倾斜感应与振动分析等场景,该驱动有助于快速搭建数据采集链路;SPEED相关文件还涉及通信速度或采样速率设置,可辅助理解IIC时序与功耗取舍。 周二下午,同事扔给我一块很小的 breakout 板,上面的芯片比一粒米大不了多少,丝印写着 LIS3DH。他说:“帮我把这玩意儿跑起来,用 F103 的 IIC,我下午就要用。” 我扫了一眼,STM32F103C8T6 最小系统板、几根杜邦线、一个 LIS3DH 模块,连个原理图都没给。半小时后,串口刷刷刷吐出了三轴加速度数据,数值稳定性比我预想的好不少。整个过程里踩了几个不大不小的坑,特别是软件 IIC 的时序细节和寄存器配置的字节顺序问题,折腾了我一阵子。如果你也要在 stm32f103 上用 IIC 驱动 LIS3DH,这篇文章应该能帮你省下我当时的那些弯路。
LIS3DH 是 ST 出的一颗三轴加速度计,量程可选 ±2g/±4g/±8g/±16g,支持 IIC 和 SPI 两种接口,工作电压 1.71V 到 3.6V,功耗极低。很多电池供电的便携设备、姿态检测模块都拿它做核心传感器。它之所以受欢迎,除了便宜、好买、资料多之外,关键是 IIC 从机时序非常标准,STM32 无论是硬件 IIC 还是 GPIO 模拟的软件 IIC 都能轻松驱动。我用的是软件 IIC,原因后面细说。这篇文章面向的是有一定 STM32 基础、想把 LIS3DH 快速跑起来并理解背后原理的开发者。
1. 选型之前:为什么是 LIS3DH,为什么走 IIC
1.1 LIS3DH 这颗传感器的定位
LIS3DH 在 ST 的加速度计产品线里属于“全能选手”那一档。它不像 LIS2DH12 那样更强调低功耗、也不像 IIS3DWB 那样走超宽带宽路线,它的定位就是接口全、量程宽、功耗可控、价格友好。具体参数上,它的输出数据速率 ODR 可以从 1Hz 一路调到 5kHz,内置了高通和低通滤波,还有两个可配置的中断引脚,可以做成运动检测、自由落体检测、方向检测这类应用,而不只是傻傻读数据。
正因为它的 IIC 从机地址支持通过 SA0 引脚切换(0x18 或 0x19),所以一条 IIC 总线上甚至可以挂两颗 LIS3DH 做到 6 轴数据采集(双三轴合并),这在一些简易机械臂、双足小机器人的姿态解算里很常见。对于初学者来说,它最友好的地方是数据寄存器地址是连续排列的,读三轴数据可以一次性从 0x28 读到 0x2D,省去多次寻址的麻烦。
1.2 IIC 和 SPI 怎么选
LIS3DH 同时支持 IIC 和 SPI,这几乎是所有 ST 传感器的标配。当你同时面对两种接口时,怎么选?我的经验是看两个东西:总线上有没有其他设备、以及你的 MCU 引脚还够不够用。
SPI 需要 4 根线(CS、SCK、MOSI、MISO),速率可以做得很高,理论上能跑到 10MHz 以上,适合高频采集。IIC 只需要 2 根线(SCL、SDA),速率一般跑 400kHz(快速模式)就算不错了,但胜在省引脚、支持多设备挂总线。LIS3DH 这种加速度计,ODR 即使开到 1kHz,每次读 6 个字节,400kHz 的 IIC 也毫无压力。所以除非你的系统里 IIC 总线上已经挂了很多设备导致时序紧张,否则 IIC 完全够用。
我这边的实际场景是 F103 的 SPI1 已经被一个 TFT 屏幕占了,SPI2 被预留给了外部 Flash,IIC1 虽然硬件上存在,但 STM32 的硬件 IIC 在标准外设库里用起来比较别扭,所以我干脆用两个 GPIO 模拟软件 IIC。这样还有一个附带好处:后续如果换 pin、换板子,软件 IIC 几乎不用改驱动逻辑。
1.3 硬件准备与接线
这次我用的是最常见的 STM32F103C8T6 蓝色 pill 板,3.3V 供电,LIS3DH 模块是淘宝上很常见的 breakout 小板,板载了 10k 上拉电阻到 VDD。接线非常简单:
- VCC -> 3.3V
- GND -> GND
- SCL -> PB6
- SDA -> PB7
注意 LIS3DH 的 VDD 供电范围是 1.71V ~ 3.6V,别傻乎乎接到 5V 上去。如果你的模块没有板载上拉电阻,那必须在 SCL 和 SDA 上各接一个 4.7k 上拉到 3.3V,否则 IIC 通信时拉低没问题、释放后回不到高电平,时序直接乱掉。
提示:STM32F103 的 GPIO 引脚在标准库初始化时,如果配置成开漏输出加上拉输入的组合,就能模拟 IIC 的半双工双向通信。实际电路中,外部 4.7k 上拉电阻和内部上拉是并联关系,所以模块板载了上拉的话,一般不用再加外部电阻。
2. 读手册抓重点:LIS3DH 的关键寄存器没有那么玄
很多新手拿到传感器第一反应是去翻完整数据手册,结果被 60 多页的 PDF 劝退。实际上要让 LIS3DH 跑起来,只需要搞清楚下面几个寄存器就够了,其余功能都是锦上添花。
2.1 WHO_AM_I 寄存器(0x0F):第一件事永远是确认芯片活着
WHO_AM_I 是几乎所有 ST 传感器都有的“身份 ID”寄存器。LIS3DH 的 WHO_AM_I 固定返回 0x33。上电后第一件事就是读它,如果读出来是 0x33,说明 IIC 通信链路已经通了、芯片地址也对;如果读到 0xFF 或者 0x00,基本可以断定接线、地址或时序有问题,没必要继续往下折腾。
我在调试时习惯写一个LIS3DH_CheckWhoAmI()函数,返回 1 才继续初始化。这一步能帮你把“传感器坏了”和“通信没通”快速区分开,省去无头苍蝇式的排查。
2.2 CTRL_REG1(0x20):传感器的心脏开关
CTRL_REG1 这个寄存器控制着传感器的“总开关”。它的位分配是:
- bit 7: ADDR 自动地址增量(通常设为 1,后面连续读数据会用到)
- bit 6: LPen 低功耗模式使能
- bit 5: Zen,bit 4: Yen,bit 3: Xen 分别对应 Z/Y/X 轴使能
- bit 2:0: ODR 输出数据速率选择
我初始化的常用配置是0x57,二进制是0101 0111,拆开看就是:ADDR=1(地址自动增量)、LPen=0(正常模式)、Z/Y/X 全使能、ODR=111(1.344kHz)。为什么要开 1.344kHz 这么高?因为默认想留足余量,实际使用如果只是做倾角检测,ODR 开 100Hz(0x57中 ODR 改成 100Hz 对应的值 0x47 或按需)就绰绰有余了。ODR 越高,功耗越大,数据噪声也相对会大一点,所以不要盲目拉高。
2.3 CTRL_REG4(0x23):量程和字节序,最容易出错的寄存器
CTRL_REG4 里最重要的两个配置:
- bit 6:5 是量程选择,
00对应 ±2g,01对应 ±4g,10对应 ±8g,11对应 ±16g - bit 7 是 BDU(Block Data Update),建议置 1。这个位的作用是:在读数据寄存器期间,如果传感器内部更新了新数据,输出寄存器不会在读取过程中被改写,避免你读高字节和低字节之间数据发生跳动,导致合成出来的 16 位数据是“新旧拼接”的错值
我习惯初始化为0x80,即 BDU=1,量程 ±2g。为什么默认 ±2g?因为 ±2g 下的灵敏度最高(1mg/LSB),分辨率最好,适合做倾角这类相对精细的测量。如果做剧烈运动或碰撞检测,再改成 ±16g 也不迟。
2.4 STATUS_REG(0x27)和三轴输出寄存器(0x28 ~ 0x2D)
STATUS_REG 的 bit 3 是 ZYXOR(数据溢出标志),bit 2 是 ZYXDA(新数据可用标志)。我习惯在读数据前先检查 bit 2 是否为 1,确保读到的是新数据而不是上一次的旧数据。在 ODR 很高时这一步尤其重要,否则你可能会“重复读取”同一份数据,导致表现上像是数据延迟。
三轴输出寄存器从 0x28 开始,排列顺序是:OUT_X_L、OUT_X_H、OUT_Y_L、OUT_Y_H、OUT_Z_L、OUT_Z_H。每个轴两个字节,低字节在前,组合时要注意高字节左移 8 位再与低字节按位或,数据类型要用int16_t,因为加速度数据是有符号的。
3. 软件 IIC 的底层实现:先搞懂时序再写代码
如果只是用现成的 HAL 库或标准库里的硬件 IIC 函数,你其实不需要懂时序也能调通。但用软件模拟 IIC 就绕不开协议本身了。这里我用最通俗的方式讲一遍 IIC 协议里和 LIS3DH 驱动相关的几个关键点。
3.1 IIC 的四个基本动作:起始、停止、应答、数据
IIC 总线空闲时,SCL 和 SDA 都是高电平(由上拉电阻保持)。一次通信开始前,主机要先发送起始条件:在 SCL 为高电平期间,把 SDA 从高拉低。通信结束后发送停止条件:在 SCL 为高电平期间,把 SDA 从低拉高。这两个“在 SCL 高电平期间 SDA 跳变”的动作,是 IIC 总线最容易用错的地方——很多人把起始和停止写成了在 SCL 低电平时拉 SDA,虽然某些从机也能容忍,但不是标准行为,换一颗芯片就可能翻车。
数据位的传输规则是:SDA 上的数据必须在 SCL 为低电平时变化,在 SCL 高电平时保持稳定。每个字节 8 位,先发最高位(MSB First),第 9 个时钟周期是从机回 ACK 的时间。主机在第 9 个时钟释放 SDA(置为输入),如果从机正常工作会把 SDA 拉低表示 ACK;如果 SDA 保持高电平,说明 NACK——从机没收到数据或地址不匹配。
3.2 从机地址是 7 位还是 8 位?一个困扰很多人的问题
LIS3DH 的 7 位从机地址默认是0b0011000(0x18),如果 SA0 引脚接高电平则是0b0011001(0x19)。但在 IIC 发送时,你要把这个 7 位地址左移一位,最低位填入读写标志:0 表示写、1 表示读。所以:
- 写地址 = 0x18 << 1 = 0x30
- 读地址 = 0x18 << 1 | 1 = 0x31
我见过不少新手直接把 0x18 当作地址发出去,结果从机毫无反应。这里的关键是:你在数据手册上看到的地址是 7 位地址,你在 IIC 总线上发的字节是“7 位地址 + 读写位”组成的 8 位。千万别搞混。
3.3 代码实现:从 GPIO 模拟到完整的 IIC 时序
我用的是标准外设库(StdPeriph_Lib V3.5),GPIO 选择 PB6 和 PB7。结构上做了一个简单的i2c_soft.c,核心逻辑如下。
#define I2C_SCL_PIN GPIO_Pin_6 #define I2C_SDA_PIN GPIO_Pin_7 #define I2C_GPIO_PORT GPIOB #define I2C_SCL_H() GPIO_SetBits(I2C_GPIO_PORT, I2C_SCL_PIN) #define I2C_SCL_L() GPIO_ResetBits(I2C_GPIO_PORT, I2C_SCL_PIN) static void I2C_SDA_OUT(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = I2C_SDA_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; // 推挽输出 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(I2C_GPIO_PORT, &GPIO_InitStructure); } static void I2C_SDA_IN(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = I2C_SDA_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; // 浮空输入 GPIO_Init(I2C_GPIO_PORT, &GPIO_InitStructure); } static void I2C_Delay(void) { // 大约产生 5us 延时,IIC 时钟约 100kHz for (volatile int i = 0; i < 40; i++); }起始条件的实现逻辑是:先把 SDA 设为输出并拉高,SCL 拉高,然后在 SCL 高电平期间拉低 SDA。
void I2C_Start(void) { I2C_SDA_OUT(); I2C_SCL_H(); I2C_SDA_H(); I2C_Delay(); I2C_SDA_L(); // SCL高电平期间,SDA由高变低 = 起始 I2C_Delay(); I2C_SCL_L(); I2C_Delay(); }发送一个字节时,每一位先在 SCL 低电平期间设置 SDA 的电平,然后拉高 SCL 让从机采样,再拉低 SCL 准备下一位。这一位一位的节奏就是 IIC 的全部秘密。
void I2C_SendByte(uint8_t byte) { I2C_SDA_OUT(); for (int i = 7; i >= 0; i--) { if (byte & (1 << i)) I2C_SDA_H(); else I2C_SDA_L(); I2C_Delay(); I2C_SCL_H(); I2C_Delay(); I2C_SCL_L(); I2C_Delay(); } // 接收 ACK I2C_SDA_IN(); I2C_SCL_H(); I2C_Delay(); // 此处读取 SDA,低电平表示 ACK I2C_SCL_L(); I2C_Delay(); I2C_SDA_OUT(); }读一个字节时则是相反的过程:主机在每第 8 个时钟后决定是否拉低 SDA 发送 ACK 还是释放 SDA 发送 NACK。读完最后一个字节时主机要回 NACK,从机才知道“你不用再发了”。
3.4 时序验证:没有逻辑分析仪也能排查
写完软件 IIC 后,我强烈建议先做一次“写寄存器的回读验证”,而不是直接去读加速度数据。具体做法是往 CTRL_REG1 里写一个已知值,比如 0x57,再读回来,看是不是 0x57。如果回读一致,说明 IIC 通信链路和驱动函数都基本没问题,接下来可以放心调传感器业务逻辑。
有条件的话,用逻辑分析仪抓一下 SCL 和 SDA 的波形。逻辑分析仪上,起始条件看起来是一小段“SDA 先掉下去”的凹槽,停止条件看起来是“SDA 先弹起来”的尖峰。每个字节的 8 个时钟清晰可见,第 9 个时钟的 ACK 位如果能看到低电平,就说明从机在正常回应你。这一步在调试阶段的价值极大,因为你能直接看到时序有没有满足芯片要求,而不是靠猜。
4. 驱动代码与数据换算:从裸寄存器到物理量
4.1 初始化函数的完整流程
初始化函数做的事可以用一句话概括:确认芯片身份,然后按需配置寄存器。我的初始化代码如下:
uint8_t LIS3DH_Init(void) { uint8_t id = 0; I2C_Start(); I2C_SendByte(0x30); // 写地址 I2C_SendByte(0x0F); // WHO_AM_I I2C_Stop(); I2C_Start(); I2C_SendByte(0x31); // 读地址 id = I2C_ReadByte(0); // 最后一个字节回 NACK I2C_Stop(); if (id != 0x33) return 1; // 通信或接线异常 // 配置 CTRL_REG1: 1.344kHz, XYZ 使能 LIS3DH_WriteReg(0x20, 0x57); // CTRL_REG4: BDU=1, ±2g LIS3DH_WriteReg(0x23, 0x80); return 0; }写寄存器的函数就是一个标准的 IIC 写流程:起始 -> 发写地址 -> 发寄存器地址 -> 发数据 -> 停止。
void LIS3DH_WriteReg(uint8_t reg, uint8_t val) { I2C_Start(); I2C_SendByte(0x30); I2C_SendByte(reg); I2C_SendByte(val); I2C_Stop(); }4.2 读取三轴数据的完整函数
这里要利用 CTRL_REG1 里设置的 ADDR 自动增量位。LIS3DH 支持一次连续读多个寄存器:主机先发送读地址,从机会从你指定的第一个寄存器开始依次把连续寄存器的内容推出来,你只需要不断回 ACK,直到最后一个字节回 NACK 再停止。这种“突发读”的方式比一个个寄存器单独读要高效得多,而且能保证三个轴的数据来自同一次采样。
void LIS3DH_ReadAccel(int16_t *x, int16_t *y, int16_t *z) { uint8_t buf[6]; I2C_Start(); I2C_SendByte(0x30); // 写地址 I2C_SendByte(0x28); // 从 OUT_X_L 开始 I2C_Stop(); I2C_Start(); I2C_SendByte(0x31); // 读地址 for (int i = 0; i < 6; i++) { // 前 5 个字节回 ACK,最后一个回 NACK buf[i] = I2C_ReadByte(i < 5 ? 1 : 0); } I2C_Stop(); *x = (int16_t)((buf[1] << 8) | buf[0]); *y = (int16_t)((buf[3] << 8) | buf[2]); *z = (int16_t)((buf[5] << 8) | buf[4]); }提示:
I2C_ReadByte的最后一个参数要区分 ACK 和 NACK。前 5 个字节因为后面还有数据,所以回 ACK 告诉从机“继续发”;第 6 个字节回 NACK,从机就会释放 SDA,主机发出停止条件,通信结束。这个细节如果做反了,主机读到的数据会错位,而且从机会一直霸占总线。
4.3 原始值怎么换算成 g、加速度、角度
LIS3DH 的数据寄存器里存的原始值是整型,要让它有意义,必须结合量程做换算。换算公式很简单:物理值 = 原始值 × 灵敏度。不同量程下的灵敏度如下表:
| 量程 | 灵敏度 (mg/LSB) |
|---|---|
| ±2g | 1 |
| ±4g | 2 |
| ±8g | 4 |
| ±16g | 12 |
以默认的 ±2g 为例,原始值 1000 就代表 1000mg = 1g。代码里只需要:
float x_g = (float)(*x) * 0.001f; float y_g = (float)(*y) * 0.001f; float z_g = (float)(*z) * 0.001f;如果只是做倾角检测(静态姿态),可以利用三轴重力的分量求角度。当传感器水平放置时,z 轴读数约等于 1g,x 和 y 接近 0。绕 x 轴的俯仰角可以近似为atan2(y_g, z_g),绕 y 轴的滚转角近似为atan2(x_g, z_g)。注意这里用atan2而不是atan,能自动处理象限问题,输出范围是 -π 到 π。角度单位记得转成度:
float roll = atan2(x_g, z_g) * 180.0f / 3.14159265f; float pitch = atan2(-y_g, z_g) * 180.0f / 3.14159265f;实测下来,水平静止时滚转角和俯仰角的抖动在 ±0.5 度以内,对大多数姿态入门应用完全够用。如果后续要做动态姿态,那就要上卡尔曼滤波或互补滤波了,那是另一个话题。
5. 实测中的坑,我替你先踩过了
5.1 坑一:模块上的地址跳线和 IIC 地址不一致
我拿到的那块 LIS3DH 板子,SA0 引脚默认被拉低,7 位地址是 0x18。但我第一次编译时不知道自己记错还是模块批次问题,地址填了 0x19,结果 WHO_AM_I 读出来全是 0xFF。排查了半天,最后用万用表量了一下 SA0 引脚的电平才确认是 0x18。
排查方法很简单:如果读 WHO_AM_I 返回 0xFF,先用另一个地址(0x18 换成 0x19)再试一次。因为 LIS3DH 只有这两种可能性,全试一遍比对着原理图猜快得多。要确认当前地址,用万用表量 SA0 引脚对 GND 的电压,接近 0V 就是 0x18,接近 3.3V 就是 0x19。
5.2 坑二:杜邦线带来的隐性接触问题
用杜邦线连接模块和 F103 时,最隐蔽的问题是“接触不良但不完全断开”。IIC 是半双工协议,SDA 既要输出又要输入,接触电阻大了以后,主机释放 SDA 时电平上拉会很慢,导致从机回 ACK 时主机读不到低电平。表现症状就是:有时能读到数据、有时读不到,数据偶尔全是 0xFF。
解决方法是把 IIC 速率降下来,把前面代码里的I2C_Delay()的延时加大到 10us 左右。软件 IIC 的好处就是调速只需要改一个函数。再就是尽量缩短杜邦线的长度,超过 20cm 就要小心信号完整性了。我这个项目最后把模块直接插在了面包板上,只留了 5cm 左右的杜邦线,之后再没出过这种间歇性问题。
5.3 坑三:数据读出来一直是 0 或 0xFF
这个问题基本可以归结为三种原因:芯片没被正确唤醒(CTRL_REG1 没配置对)、读地址错误、或者时序不对。先把初始化函数的返回值打印出来,确认 WHO_AM_I 读到了 0x33;再往 CTRL_REG1 写 0x57 读回来验证;最后再去看数据寄存器。一步一步排查,不要跳步。
还有一个特别蠢但我犯过的错误:读取数据寄存器时,起始条件和发送寄存器地址的写操作之间,我没有加停止条件就直接发了读地址。这在逻辑上虽然某些从机能容忍,但不是标准 IIC 操作。LIS3DH 对这类非标时序容忍度还行,但保不齐其他传感器就翻脸。标准做法是:起始 -> 发写地址 -> 发寄存器地址 -> 停止 -> 起始 -> 发读地址 -> 读数据 -> 停止。不要为了省一个停止条件去赌从机的脾气。
5.4 数据跳动和滤波的心得
LIS3DH 在 ±2g 量程下,静止时的原始值一般在 ±10 到 ±20 之间波动(换算成 mg 就是 10~20mg),这是正常的电气噪声。做倾角检测时,如果你不想让角度在界面上一直抖,最简单的方法是滑动平均滤波:维护一个长度为 5~10 的环形缓冲区,每次取平均。
我用 8 次滑动平均后,静止时角度的抖动从 ±0.5 度降到了 ±0.1 度左右,效果显著。代价是响应变慢了一点点,但对于静止倾角检测场景,这个延迟完全可以接受。如果你要追快速动态,那就别滤波或者用更高级的滤波算法。
LIS3DH 内部其实还自带了硬件滤波功能(CTRL_REG2 里可以配置高通/低通滤波),可以把滤波截止频率设置成 ODR 的几分之一。但硬件的低通滤波对数据延迟的影响比软件滑动平均大,实时性要求高的场合要慎重。软件滤波的好处是参数改起来方便,调试时可以随时调整窗口大小。
6. 写在最后的几条经验
代码全部验证跑通之后,有几条经验想单独拎出来说一下。第一,如果你第一次用这颗芯片,强烈建议先做 WHO_AM_I 回读和寄存器回写验证这两个“冒烟测试”,再去做业务逻辑。这个习惯能让你在后续整套系统出问题时,快速排除传感器驱动层的嫌疑。第二,软件 IIC 的延时不要一味追求快。我曾经把延时调到 1us 左右,结果在长线连接时偶发读错数据,把延时放宽到 5us 之后世界清净了。LIS3DH 的 IIC 时序容限很宽,没必要冒险跑极限速率。
第三,关于后续扩展,LIS3DH 还内置了 FIFO 和中断功能。FIFO 可以在 MCU 来不及读取时缓存最多 32 组数据,适合做低功耗唤醒上报的场景;中断引脚可以配置成运动检测、方向检测等,能让 MCU 在数据变化时才被唤醒。如果只是简单轮询读数据,这篇文章的内容足够你用了;但如果做低功耗产品,强烈建议把 FIFO 和中断研究透,那才是 LIS3DH 真正的价值所在。
本文还有配套的精品资源,点击获取