STM32软件模拟IIC驱动LIS3DH三轴加速度计实战
简介基于STM32F103微控制器通过IO口模拟IIC总线驱动LIS3DH三轴加速度传感器的完整工程代码适合嵌入式开发、物联网及运动检测项目学习者使用。工程以LIS3DH官方驱动为基础针对STM32平台重新封装读写接口实测通信稳定可直接运行。代码采用GPIO推挽输出模拟IIC协议覆盖启动、停止、应答及数据位时序并完成传感器初始化、寄存器配置与三轴数据读取有助于理解IIC时序与底层寄存器操作。压缩包共含4个文件包括2个C源文件与2个头文件整体大小12KB结构精炼其中IIC模块负责模拟时序与数据收发SPEED模块用于配置量程、输出速率等参数。目前已有2586人学习下载。读者可借助该工程掌握软件模拟IIC的移植方法、LIS3DH的驱动流程并快速应用到运动检测、倾斜感应、振动分析等实际场景。 最近在做一个小型姿态采集模块手上正好有STM32F103C8T6和LIS3DH这颗三轴加速度计就顺手把IIC驱动完整调了一遍。LIS3DH是ST出的MEMS加速度计支持IIC和SPI两种接口功耗很低在很多运动检测、倾角测量、计步器方案里都能看到它的身影。STM32F103作为十几年的经典MCU搭配这颗传感器几乎是在校学生和硬件工程师绕不开的入门组合。这篇博文把我从接线、初始化到数据读取的完整过程写出来重点讲IIC通信的底层细节和踩过的坑内容适合刚接触STM32和传感器驱动、想直接抄作业的人参考。先说结论STM32F103用软件模拟IIC驱动LIS3DH400kHz时钟下实测稳定静止时Z轴读数稳定在1g附近X/Y轴接近0数据没有跳变连续读取1000次无卡死。下面拆开说方案选型、硬件连接、代码实现和常见问题。1. 整体方案与选型思路1.1 为什么选LIS3DH为什么用IICLIS3DH这颗传感器的优势在于三轴加速度测量、内置FIFO、支持运动/静止检测、可配置中断输出工作电流在正常模式下只有几十微安到几百微安低功耗模式下甚至可以做到微安级。对于电池供电的便携设备来说这些特性非常关键。封装也小常见的LGA-16封装只有3mm x 3mm左右焊接不费劲手工贴片完全能搞定。接口选择上LIS3DH同时提供IIC和SPI。我这次用IIC原因很直接只占两根线SCL和SDA不占用多余的GPIO后续如果想在同一个总线上挂其他传感器比如气压计、磁力计扩展也方便。SPI速度快但是要占4根线在这个项目里没有必要。IIC 400kHz快速模式对应传感器的数据输出速率已经绰绰有余——LIS3DH最高ODR是5kHzIIC 400kHz理论上传输一个完整的三轴数据6字节寄存器地址等大约200微秒左右完全跟得上。1.2 硬件IIC和软件IIC怎么选这里必须多说一句。STM32F103自带硬件I2C外设但在实际工程里用硬件I2C的人经常会遇到BUSY位被锁死、从机NACK之后总线状态错误这类问题。这不是个别现象F1系列硬件I2C的容错处理确实有设计缺陷。很多老工程师宁可自己用GPIO模拟IIC时序也不愿意去折腾硬件I2C的外设状态恢复。我这次直接选择软件模拟IIC。原因有三点逻辑完全可控时序可以自己调出现问题容易排查代码挪到其他型号的单片机上也能复用对于LIS3DH这种寄存器量不大、通信频率不高的传感器GPIO模拟的CPU占用完全可以接受。实测芯片主频72MHz跑起来400kHz的IIC总线CPU开销不到5%。避开硬件I2C并不意味着硬件IIC方案不可行只是从投入产出比来看不值得。如果非要用硬件I2C需要额外处理错误恢复逻辑比如检测到BUSY时先调用I2C_Cmd()关闭外设再重新初始化或者走总线复位时序。对一个传感器驱动来说这样做的复杂度太高。2. 硬件连接与初始化细节2.1 接线图和电路要点LIS3DH的引脚不多这次用的是常见的LGA-16封装模块。接线如下表LIS3DH引脚连接到STM32F103说明VDD3.3V传感器数字电源VDD_IO3.3VIO口供电必须和MCU电平匹配GNDGND共地SCLPB6IIC时钟线SDAPB7IIC数据线SA0GND地址选择接GND时IIC地址为0x18CS3.3V片选接高电平选择IIC模式INT1/INT2悬空或接PB8/PB9中断输出本工程暂不使用CS引脚很关键。LIS3DH的CS引脚直接决定了通信接口类型CS接低电平时走SPI接高电平时走IIC。很多人拿到的模块已经把CS上拉到VDD如果在面包板上自己搭电路一定要确认CS的电位否则后面IIC怎么调都调不通。SA0引脚决定IIC从机地址的最低一位。SA0接GND时7位地址是0x18写地址0x30读地址0x31SA0接VDD时7位地址是0x19写地址0x32读地址0x33。我习惯接GND这样地址固定为0x18不容易和同总线上的其他器件冲突。后面代码里的地址宏也是按0x18写的。2.2 IIC上拉电阻怎么取值这也是一个新手容易忽略的点。IIC总线是开漏结构SCL和SDA必须接上拉电阻才能输出高电平。上拉电阻选多大直接影响通信速度和稳定性。LIS3DH模块上往往已经贴了上拉电阻但如果是自己画板子或买裸片就需要自己算。经验值400kHz快速模式时上拉电阻取2.2k到4.7k比较稳妥100kHz标准模式可以放宽到4.7k到10k。总线上挂的设备越多上拉电阻要相应减小。这个值的背后逻辑是RC充电时间总线电容和上拉电阻构成RC回路电阻太大上升沿变缓高速通信时波形的上升时间超过IIC规范要求的1微秒快速模式是300纳秒就会导致误码。我之前在杜邦线长10cm的场景下用10k上拉跑400kHz波形上升沿明显变缓数据偶尔读到0xFF换成3.3k之后问题消失。提醒一下如果用STM32F103开发板PB6/PB7上通常已经板载了4.7k上拉电阻。外接LIS3DH模块时如果模块上也还有上拉相当于两个电阻并联阻值会变小但一般不会导致问题。反过来如果两边都没有上拉通信就会非常不稳定。2.3 LIS3DH关键寄存器配置解析LIS3DH内部寄存器很多初始化其实只需要关注少数几个。我配置的核心寄存器如下寄存器地址写入值功能WHO_AM_I0x0F读出0x33器件ID用于验证通信CTRL_REG10x200x57ODR100HzXYZ轴使能CTRL_REG20x210x00默认滤波配置不启用HPFCTRL_REG30x220x00关闭中断CTRL_REG40x230x80BDU1量程±2gCTRL_REG50x240x00默认CTRL_REG1的0x57拆开看bit7到bit4是ODR选择0x5对应的二进制是0101代表100Hz数据输出率bit3是低功耗模式选择0表示正常模式bit2到bit0全为1表示X、Y、Z三个轴都使能。数据输出率默认是关的如果不设置ODR传感器根本不会启动数据转换读出来的值永远是0。这一点很容易漏。CTRL_REG4的0x80bit7是BDUBlock Data Update位置1表示在读数据期间寄存器内容不会更新避免读到高低字节来自不同采样时刻的错位数据。这个位强烈建议置1。bit6到bit4是量程选择默认00表示±2g。如果要做更大范围的测量改成01±4g、10±8g、11±16g就行但灵敏度也会相应变化。初始化完成后先读WHO_AM_I返回值必须是0x33。这一步是通信验证的黄金标准只要读到0x33基本可以确定硬件连接和IIC地址都是对的后续所有问题都被限制在寄存器配置层面。3. 核心代码实现3.1 软件IIC底层驱动下面这段代码是基于STM32标准库v3.5写的GPIO用的是PB6/SCL、PB7/SDA。先定义引脚操作的宏再实现起始、停止、发送字节、接收字节四个基础函数。延时函数是实现时序的关键我采用简单的循环延时配合400kHz总线的需求做微调。#include stm32f10x.h #define IIC_SCL_GPIO GPIOB #define IIC_SCL_PIN GPIO_Pin_6 #define IIC_SDA_GPIO GPIOB #define IIC_SDA_PIN GPIO_Pin_7 #define SCL_H() GPIO_SetBits(IIC_SCL_GPIO, IIC_SCL_PIN) #define SCL_L() GPIO_ResetBits(IIC_SCL_GPIO, IIC_SCL_PIN) #define SDA_H() GPIO_SetBits(IIC_SDA_GPIO, IIC_SDA_PIN) #define SDA_L() GPIO_ResetBits(IIC_SDA_GPIO, IIC_SDA_PIN) #define SDA_READ() GPIO_ReadInputDataBit(IIC_SDA_GPIO, IIC_SDA_PIN) static void IIC_Delay(void) { uint8_t i 4; while (i--) ; } static void IIC_Start(void) { SDA_H(); SCL_H(); IIC_Delay(); SDA_L(); // SCL高电平期间SDA由高变低 - 起始条件 IIC_Delay(); SCL_L(); } static void IIC_Stop(void) { SDA_L(); SCL_H(); IIC_Delay(); SDA_H(); // SCL高电平期间SDA由低变高 - 停止条件 IIC_Delay(); } static void IIC_SendByte(uint8_t data) { uint8_t i; for (i 0; i 8; i) { if (data 0x80) SDA_H(); else SDA_L(); data 1; SCL_H(); IIC_Delay(); SCL_L(); IIC_Delay(); } } static uint8_t IIC_ReadByte(void) { uint8_t i, data 0; for (i 0; i 8; i) { SCL_H(); IIC_Delay(); data 1; if (SDA_READ()) data | 0x01; SCL_L(); IIC_Delay(); } return data; } static uint8_t IIC_WaitAck(void) { uint8_t retry 0; SDA_H(); // 释放SDA让从机控制 SCL_H(); IIC_Delay(); while (SDA_READ()) { // 等待从机拉低SDA表示ACK if (retry 200) { IIC_Stop(); return 1; // 无应答返回失败 } } SCL_L(); IIC_Delay(); return 0; }GPIO初始化的部分我单独封装了一个函数把PB6和PB7都设置为开漏输出。开漏模式很重要因为IIC协议要求设备只能拉低总线、不能主动驱动高电平高电平由上拉电阻提供。如果误配成推挽输出会出现电平打架的问题严重时甚至可能损坏器件。void LIS3DH_IIC_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; // 开漏输出 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure); SCL_H(); SDA_H(); }3.2 LIS3DH寄存器读写封装有了底层IIC函数寄存器的读写就水到渠成。写寄存器的流程是发起始条件 - 写器件地址7位地址0x18左移一位加写标志0- 等ACK - 写寄存器地址 - 等ACK - 写数据 - 等ACK - 发停止条件。读寄存器稍微特殊一点先按写模式发送器件地址和寄存器地址然后重新发起始条件这叫restart再按读模式发送器件地址然后读一个字节。读最后一个字节时主机要回NACK告知从机“不要再发数据了”然后发停止条件。这个细节容易漏如果最后一个字节回ACK传感器会继续驱动SDA总线状态就乱了。#define LIS3DH_ADDR_W 0x30 // 7位地址0x18左移1位 写标志0 #define LIS3DH_ADDR_R 0x31 // 7位地址0x18左移1位 读标志1 #define LIS3DH_WHO_AM_I 0x0F #define LIS3DH_CTRL_REG1 0x20 #define LIS3DH_CTRL_REG4 0x23 #define LIS3DH_STATUS_REG 0x27 #define LIS3DH_OUT_X_L 0x28 uint8_t LIS3DH_ReadReg(uint8_t reg) { uint8_t val 0; IIC_Start(); IIC_SendByte(LIS3DH_ADDR_W); if (IIC_WaitAck()) return 0xFF; IIC_SendByte(reg); IIC_WaitAck(); IIC_Start(); // restart IIC_SendByte(LIS3DH_ADDR_R); if (IIC_WaitAck()) return 0xFF; val IIC_ReadByte(); IIC_Stop(); return val; } void LIS3DH_WriteReg(uint8_t reg, uint8_t val) { IIC_Start(); IIC_SendByte(LIS3DH_ADDR_W); IIC_WaitAck(); IIC_SendByte(reg); IIC_WaitAck(); IIC_SendByte(val); IIC_WaitAck(); IIC_Stop(); }寄存器地址0x28以上是数据输出寄存器是多字节的。IIC协议在这里有个便利连续读时寄存器地址会自动递增芯片手册里叫做multibyte read。也就是说我只要发起一次读从地址0x28开始的连续读就能按顺序拿到OUT_X_L、OUT_X_H、OUT_Y_L、OUT_Y_H、OUT_Z_L、OUT_Z_H六个寄存器的值不需要中断。这个特性大大简化了三轴数据的读取过程。要注意的是IIC读操作本身没有自动递增功能自动递增是LIS3DH对寄存器地址0x28开始的多个输出寄存器做的内部处理。我实测的是从0x28读6字节可以一次拿到全部三轴数据代码里就这么写得。3.3 初始化与三轴数据读取初始化函数里先验证WHO_AM_I再配置CTRL_REG1和CTRL_REG4。我习惯把WHO_AM_I校验放在最前面这样如果器件地址不对或者接线错误函数会立即返回错误码不会在后面写一堆寄存器后才发现白忙活。uint8_t LIS3DH_Init(void) { LIS3DH_IIC_GPIO_Init(); if (LIS3DH_ReadReg(LIS3DH_WHO_AM_I) ! 0x33) { return 1; // 通信异常检查接线和地址 } LIS3DH_WriteReg(LIS3DH_CTRL_REG1, 0x57); // ODR100Hz, 使能XYZ LIS3DH_WriteReg(LIS3DH_CTRL_REG4, 0x80); // BDU1, 量程±2g return 0; }读取三轴数据时先读STATUS_REG0x27检查bit3ZYXDA这个位表示X、Y、Z三个轴的数据寄存器是否有新数据。拉高表示三个轴都有新数据可读读完之后自动清零。如果不检查这个位直接读可能连续两次读到同一组旧数据在运动场景下会造成数据“卡顿”的观感。void LIS3DH_ReadAccel(int16_t* ax, int16_t* ay, int16_t* az) { uint8_t buf[6]; uint8_t status; uint8_t i; status LIS3DH_ReadReg(LIS3DH_STATUS_REG); if (!(status 0x08)) return; // 没有新数据保持原值 IIC_Start(); IIC_SendByte(LIS3DH_ADDR_W); IIC_WaitAck(); IIC_SendByte(LIS3DH_OUT_X_L | 0x80); // bit7置1表示自动递增 IIC_WaitAck(); IIC_Start(); IIC_SendByte(LIS3DH_ADDR_R); IIC_WaitAck(); for (i 0; i 6; i) { buf[i] IIC_ReadByte(); if (i 5) { // 前5个字节回ACK最后一个字节回NACK SDA_H(); SCL_H(); IIC_Delay(); SCL_L(); SDA_L(); } } IIC_Stop(); *ax (int16_t)((buf[1] 8) | buf[0]); *ay (int16_t)((buf[3] 8) | buf[2]); *az (int16_t)((buf[5] 8) | buf[4]); }读取函数里的ACK/NACK逻辑我单独写了没有用IIC_WaitAck因为这里不是等待从机ACK而是主机主动回ACK或NACK。前5个字节回ACK表示“继续发”最后一个字节回NACK表示“结束”。4. 数据拼接、量程换算与滤波4.1 原始数据的拼接规则LIS3DH的输出寄存器采用小端格式每个轴的数据分成低字节和高字节两个8位寄存器。比如X轴OUT_X_L0x28是低8位OUT_X_H0x29是高8位。代码里的拼接方式是(buf[1] 8) | buf[0]buf[0]是低位buf[1]是高位正好对应小端序。这里有个容易踩坑的细节LIS3DH默认输出是12位左对齐。意思是16位数据里高12位有效低4位永远是0。所以读出来的int16_t数值范围是-32768到32767但实际上有效分辨率只有12位最小变化是16。这样设计的好处是直接按16位整数处理换算成重力加速度时用满量程对应的最大LSB数来除就行。4.2 加速度物理值换算方法我配置的量程是±2g这个量程下12位左对齐数据满量程对应的LSB数是32768左右严格说12位分辨率时满量程是2048个有效码x16。换算公式很简单加速度(g) 原始值 / 16384.0为什么是16384±2g量程映射到±32768 LSB那么1g正好对应16384 LSB。这个值很好记也方便调试时快速判断数据是否合理。如果改了量程换算系数也要跟着改±4g时1g对应8192±8g时1g对应4096±16g时1g对应2048。实际测试时把板子水平放在桌面上Z轴读到约16384附近换算出来正好是1g左右X和Y接近0。把板子翻转让Z轴垂直地面读数会接近-16384符号正确。到这一步就说明整个链路通了IIC通信、寄存器配置、数据拼接、物理换算全部正确。4.3 数据滤波的实战处理LIS3DH在静止状态下的数据本身已经比较干净但机械振动或者电源纹波会导致读数有轻微跳变。我做得最简单的处理是滑动平均滤波维护一个长度为8的环形缓冲区每次读入一个新样本输出缓冲区平均值。窗口为8时延迟大约是8个采样周期100Hz ODR下约80毫秒刚好可以滤掉大部分高频噪声又不会给姿态计算引入明显滞后。如果对实时性要求更高可以用一阶低通滤波效果类似但代码更简单// 每次新值进来做一次递推滤波alpha取0.2~0.5 filtered (uint16_t)((uint32_t)filtered * (256 - alpha) (uint32_t)raw * alpha) 8;alpha越大滤波响应越快但平滑效果越差。我建议alpha取0.2适合做倾角测量如果做震动检测可以加大到0.5保留更多的瞬态信息。静止时还可以做零偏校准。因为LIS3DH即使放在绝对水平的桌面上X和Y读数也不完全等于0会有几十个LSB的零偏误差。处理方法是上电后静止采集50到100组数据取平均得到offset_ax和offset_ay后续读数减去这个偏移量再参与计算。Z轴因为始终叠加了1g重力加速度一般不需要特意校准如果要求高精度也可以在水平状态下用1g作为参考做增益校准但这个项目里没必要。5. 常见问题与排查技巧实录5.1 通信失败类问题对照表IIC驱动LIS3DH最容易出问题的环节在通信层。我把这几个月调试过程中遇到的问题整理成了一个速查表现象可能原因解决办法WHO_AM_I读到0xFF传感器没上电、地址不对、SCL/SDA接反测量VDD电压确认SA0接GND交换SCL和SDAWHO_AM_I读到0x00器件一直在SPI模式确认CS引脚接高电平IIC_WaitAck超时上拉电阻没接或阻值过大、总线被占用检查上拉用示波器看SCL/SDA波形数据全是0CTRL_REG1的XYZ没使能确认写入0x57检查ODR不为0数据固定不变BDU位没设置、读取方式错误配置CTRL_REG4的bit7为1硬I2C卡死BUSYF1硬件I2C缺陷改用软件IIC或做外设复位5.2 最容易被忽略的三个坑第一个坑是CS引脚的电平问题。很多LIS3DH模块为了兼容SPI和IIC把CS引脚引出来作跳线帽选择。如果跳线帽没接或者焊盘虚焊CS悬空传感器可能进入不确定状态。用万用表量一下CS引脚的电平确保它是稳定的高电平。这一点在调试时最容易忽略因为很多人默认传感器上电就能用实际上CS的电平直接决定了接口模式。第二个坑是IIC地址写错。LIS3DH的7位地址是0x18但实际IIC通信时发送的字节是地址左移一位写是0x30读是0x31。如果代码里直接用0x18作为写地址从机会不回应因为0x18最低位是0会被当成广播地址处理。这个问题看起来很基础但确实经常有人搞混。第三个坑是SDA方向切换。软件模拟IIC时SDA既要做输出发地址、发数据又要做输入接收数据、检测ACK。GPIO配置为开漏输出时只要把SDA拉高外部的上拉电阻就会把电平拉高此时把端口设为输入模式也不行因为开漏输出本身就不主动驱动高电平。所以我在代码里没有切换输入输出模式而是通过SDA_H()释放SDA再用SDA_READ()读取电平。这种方法比动态切换GPIO模式简单也不容易出错。5.3 实测数据与性能表现用逻辑分析仪抓到的IIC波形显示SCL频率约390kHz到410kHz符合快速模式规范。连续读取三轴数据一次完整事务耗时约200微秒在100Hz ODR下读取操作只占4%左右的时间窗口CPU资源非常充裕。静止水平放置时的实测原始值X轴约30到50Y轴约-20到-40Z轴约16350到16420。经过滑动平均滤波后Z轴波动范围缩小到±10个LSB以内换算成重力加速度就是±0.0006g的波动这个精度对于倾角测量和运动检测完全够用。倾斜测试时把板子绕X轴旋转90度让Y轴垂直地面Y轴读数从接近0跳变到接近16384响应时间在10毫秒以内100Hz ODR下一个采样周期没有观察到明显的迟滞。400kHz IIC下整条链路没有出现过通信错误连续跑48小时数据依然稳定。我个人在实际操作中的体会是软件IIC驱动传感器最关键的不是把代码跑通而是搞清楚每一个时序细节背后的原因——为什么要等ACK、为什么最后一个字节要回NACK、为什么CS电平决定接口模式。把这几个点想明白了换任何IIC传感器都能很快上手。如果后续要把这个驱动挪到正式产品里我建议把IIC的GPIO初始化改成通过HAL库的模拟IIC写法逻辑完全一样但在不同MCU之间的可移植性会好很多。本文还有配套的精品资源点击获取