ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

ADXL345三轴驱动与计步算法实战:软件模拟IIC与波峰检测

ADXL345三轴驱动与计步算法实战:软件模拟IIC与波峰检测

简介:这份资源面向嵌入式开发初学者与需要快速接入三轴加速度传感器的工程师,提供ADXL345驱动代码的完整实现与知识点梳理,可用于运动检测、跌倒检测与计步等场景的二次开发。压缩包内共1个doc文件,约63KB,以文档形式集中呈现驱动源码与函数说明,便于对照阅读和移植。已有474人学习下载,具备一定的参考基础。内容围绕IIC通信协议展开,逐一讲解IIC_Init、IIC_Start、IIC_Stop、IIC_Wait_Ack、IIC_Ack、IIC_NAck、IIC_Send_Byte、IIC_Read_Byte等底层函数的实现细节,并给出AD_Wtite_One_Byte、AD_Write_Multi_Byte等寄存器读写封装,同时涵盖计步算法的思路说明与实际应用测试结论。读者可据此理解GPIO推挽输出配置、时序延时控制与应答超时处理等关键点,快速搭建可稳定运行的ADXL345驱动框架。

1. 从一块焊歪的 ADXL345 说起:这套驱动到底能干什么

去年帮朋友调一个手环原型,板子焊完上电,串口里加速度三个轴全是 0,示波器一量 SCL 有波形、SDA 死活不动。翻来覆去查了两小时,最后发现是软件模拟 IIC 的引脚宏定义和实际飞线对不上。那次之后我对“三轴加速度计驱动”这类代码有了个基本判断:能不能跑,八成不取决于算法多花哨,而取决于 IIC 时序和寄存器配置这两块地基有没有打牢。这份 ADXL345 三轴驱动代码就是典型的“地基型”资源,它把软件模拟 IIC 的完整时序、ADXL345 的寄存器读写封装、以及一套基于波峰检测的计步逻辑全塞进了一个adxl.c里,配合main.h里的引脚宏就能在 STM32 这类 Cortex-M 平台上跑起来。适合谁?正在做可穿戴、运动检测、跌倒报警这类小项目的开发者,尤其是手里有 ADXL345 模块、想快速拿到三轴原始数据和步数、又不想从零啃数据手册的人。它不依赖硬件 IIC 外设,GPIO 推挽输出就能驱动,移植成本低,这是它最实在的价值。

2. 代码结构拆解:一个 adxl.c 里藏着的三层职责

2.1 文件组织与依赖关系

这份代码的物理结构非常扁平,核心就两个文件:adxl.c和它 include 的main.h。main.h承担了所有“平台相关”的东西——引脚宏、位带操作、延时函数声明、标准库头文件。adxl.c则从下往上叠了三层:最底层是软件模拟 IIC 的时序函数,中间层是 ADXL345 的寄存器读写封装,最上层是数据读取、滤波和计步算法。这种分层在裸机项目里很常见,好处是换 MCU 时只需要改main.h里的宏和IIC_Init里的时钟使能,上层逻辑一行不用动。

引脚定义这块,代码里用的是 PB10 和 PB11,通过宏映射到 SDA 和 SCL。我一般会把这类宏集中放在main.h顶部,并且加上注释标明实际接线,避免后面飞线改了忘了同步。位带操作是这套代码的一个隐含依赖,IIC_SDA、READ_SDA这些宏大概率是基于 Cortex-M 的位带别名实现的,这样单根引脚读写不用读改写整个端口寄存器,时序更干净。如果你换到没有位带的平台(比如某些 RISC-V),这部分得改成HAL_GPIO_ReadPin之类的调用,延时也要重新标定。

/* main.h 中典型的引脚宏与位带定义(按代码推断补全) */ #define IIC_SCL PBout(11) /* SCL 接 PB11 */ #define IIC_SDA PBout(10) /* SDA 输出接 PB10 */ #define READ_SDA PBin(10) /* SDA 输入读 PB10 */ #define SDA_IN() {GPIOB->CRH &= 0XFFFFF0FF; GPIOB->CRH |= (u32)8<<8;} #define SDA_OUT() {GPIOB->CRH &= 0XFFFFF0FF; GPIOB->CRH |= (u32)3<<8;}

上面这段是SDA_IN/SDA_OUT的典型写法,通过改 GPIOB 的 CRH 寄存器把 PB10 在输入和推挽输出之间切换。参数说明:0xFFFFF0FF是清掉 PB10 对应的 4 个配置位,8<<8表示上拉/下拉输入模式,3<<8表示 50MHz 推挽输出。这里有个细节,代码里IIC_Init把 PB10、PB11 都设成了推挽输出并置高,但 SDA 在读取时必须切输入,所以SDA_IN/SDA_OUT这对宏是整套 IIC 能工作的前提,缺一不可。

2.2 软件模拟 IIC 的时序实现

软件模拟 IIC 的核心就四件事:起始、停止、应答、读写字节。代码里IIC_Start的做法是 SDA 先拉高、SCL 拉高、延时 4us,然后 SDA 拉低产生下降沿,再拉低 SCL 钳住总线。这个顺序不能反,因为 IIC 协议规定起始条件是 SCL 高电平期间 SDA 由高变低。IIC_Stop则相反,SCL 低时先拉低 SDA,再拉高 SCL,最后拉高 SDA 产生上升沿。

void IIC_Start(void) { SDA_OUT(); IIC_SDA = 1; IIC_SCL = 1; delay_us(4); IIC_SDA = 0; /* SCL 高期间 SDA 下降沿 = 起始 */ delay_us(4); IIC_SCL = 0; /* 钳住总线 */ }

延时 4us 对应的是 100kHz 左右的 IIC 速率,ADXL345 支持 400kHz,但软件模拟留点余量更稳。IIC_Wait_Ack里有个 250 次的超时计数,超时就调IIC_Stop并返回 1,这个设计很关键——没有超时保护的模拟 IIC 一旦从机没响应就会死等,整个主循环卡死。IIC_Send_Byte是高位先行,每次把txd的最高位送到 SDA,然后左移一位,8 次循环发完。IIC_Read_Byte则在 SCL 高电平期间采样 SDA,先左移再累加,最后根据ack参数决定发 ACK 还是 NACK。

提示:IIC_Send_Byte的注释里提到“对 TEA5767 这三个延时都是必须的”,这是从别的项目抄过来没改干净的痕迹,不影响功能,但说明这段 IIC 代码是经过多个项目复用的老代码,时序参数相对保守。

2.3 ADXL345 寄存器读写封装

中间层的四个函数AD_Wtite_One_Byte、AD_Write_Multi_Byte、AD_Read_One_Byte、AD_Read_Multi_Byte把 IIC 时序和 ADXL345 的器件地址、寄存器地址拼在一起。写操作的流程是:起始 → 发写命令(WRITE_CMD,通常是 0xA6)→ 等 ACK → 发寄存器地址 → 等 ACK → 发数据 → 等 ACK → 停止。读操作稍微绕一点,要先发写命令和寄存器地址“告诉芯片我要读哪个寄存器”,然后重新起始、发读命令(READ_CMD,0xA7),再读数据。

uint8 AD_Read_One_Byte(uint8 reg) { uint8 data = 0; IIC_Start(); IIC_Send_Byte(WRITE_CMD); /* 0xA6,写方向 */ IIC_Wait_Ack(); IIC_Send_Byte(reg); /* 指定要读的寄存器 */ IIC_Wait_Ack(); IIC_Start(); /* 重复起始 */ IIC_Send_Byte(READ_CMD); /* 0xA7,读方向 */ IIC_Wait_Ack(); data = IIC_Read_Byte(0); /* 单字节读,回 NACK */ IIC_Stop(); return data; }

AD_Read_Multi_Byte是读三轴数据的主力,它利用 ADXL345 寄存器地址自增的特性,从DATAX0开始连续读 6 个字节,正好覆盖 X、Y、Z 的低字节和高字节。这里最后一个字节必须回 NACK,否则从机会继续输出数据,总线状态就乱了。参数len是uint8类型,意味着一次最多读 255 字节,对 ADXL345 的 6 字节读取绰绰有余。

2.4 初始化流程与关键寄存器

AD_Initial是整个驱动的入口,它先调IIC_Init,然后读0x00寄存器判断返回值是不是0xE5——这是 ADXL345 的器件 ID,读不到就返回 1 表示失败。这个“读 ID 验活”的做法值得抄,比盲目写寄存器然后发现没反应要高效得多。验活通过后依次写六个寄存器:DATA_FORMAT写0x69(±4g 量程、全分辨率 11 位)、BW_RATE写0x09(50Hz 输出速率)、POWER_CTL写0x28(进入测量模式)、INT_ENABLE和INT_MAP写0x80(关中断)、三个偏移寄存器写 0。

寄存器写入值作用
DATA_FORMAT0x69±4g,全分辨率,11 位模式
BW_RATE0x09输出数据速率 50Hz
POWER_CTL0x28使能测量,进入正常工作
INT_ENABLE0x80关闭 DATA_READY 中断
OFSX/Y/Z0x00三轴偏移清零

0x69这个值拆开看:bit3 是 FULL_RES 置 1,bit1:0 是 01 对应 ±4g,其余位保留。±4g 量程对应灵敏度约 256 LSB/g(全分辨率下 3.9mg/LSB),这个量程对计步够用,对跌落检测可能偏小,后面改造时会提到。POWER_CTL写0x28是置 bit3(Measure)和 bit5(Link),让芯片开始采样。初始化返回 0 表示成功,返回 1 表示 ID 校验失败,调用方根据这个返回值决定是否重试或报错。

3. 数据读取与计步算法:从原始加速度到步数

3.1 三轴数据合成与符号处理

Read_Axis负责把 6 个字节拼成三个int16。ADXL345 的数据是 16 位补码小端格式,低字节在前,所以buf[1]<<8 | buf[0]再强转int16就能得到带符号的加速度值。这里有个容易翻车的点:buf是uint8数组,左移时如果先转u16再移位,能避免符号扩展问题,代码里((u16)buf[1]<<8)+buf[0]正是这么做的。如果直接buf[1]<<8,在某些编译器上uint8会被提升成int,高位字节为 0x80 以上时结果就错了。

void Read_Axis(int16 *data) { uint8 buf[6] = {0}; AD_Read_Multi_Byte(DATAX0, buf, 6); *data = (int16)(((u16)buf[1] << 8) + buf[0]); /* X */ *(data+1) = (int16)(((u16)buf[3] << 8) + buf[2]); /* Y */ *(data+2) = (int16)(((u16)buf[5] << 8) + buf[4]); /* Z */ }

参数data是调用方传进来的三元素数组首地址,函数内部用指针偏移写回。这种“传数组首地址 + 内部偏移”的写法在裸机里很常见,省去返回值拷贝,但调用方必须保证数组至少 3 个int16的空间,否则就是内存越界。

3.2 波峰检测计步的状态机

计步逻辑集中在Filter_AD_Data和Filter_AD_Data_1两个函数里,后者是前者的增强版,多了波峰上升沿/下降沿的细分状态。核心思路是:对每个轴维护一个Step_P_Flag状态,0 表示找最小值,非 0 表示找最大值,当加速度从最小值爬升越过阈值再回落到最大值以下时,判定为一个波峰,计算峰峰值Step_P2P,再结合阈值Axis_Threhold和时间窗Sample_Timer决定是否计一步。

if (Step_P_Flag[i] == 0) { /* 找最小值阶段 */ if (Axis_ResultA[i] <= Step_Min[i]) Step_Min[i] = Axis_ResultA[i]; else { Step_Max[i] = Axis_ResultA[i]; Step_P_Flag[i] ^= 0xff; /* 翻转到找最大值 */ } } else { /* 找最大值阶段 */ if (Axis_ResultA[i] >= Step_Max[i]) Step_Max[i] = Axis_ResultA[i]; else { Step_P2P[i] = Cal_Abs_Value(Step_Max[i], Step_Min[i]); /* 幅值 + 门限 + 时间窗三重判定 */ if (Step_P2P[i] > Axis_Precision[i] && Step_Min[i] < Axis_Threhold[i] && Axis_Threhold[i] < Step_Max[i] && Sample_Timer >= 10) Judge_Show(); Step_Min[i] = Axis_ResultA[i]; Step_P_Flag[i] ^= 0xff; } }

这段状态机的关键参数有三个:Axis_Precision是幅值精度门限,峰峰值小于它认为是噪声;Axis_Threhold是过零门限,要求最小值在门限以下、最大值在门限以上,相当于确认信号穿过了基线;Sample_Timer是时间窗,至少 10 个采样周期才允许计步,防止高频抖动被误判。Judge_Show是实际累加步数的钩子函数,代码里没给实现,需要自己补。Select_Axis负责选当前波动最大的轴作为计步参考轴,这样手机横放竖放都能工作。

3.3 滑动窗口自适应阈值

Judge_Extreme_Ad、Cal_Threhold_Precision、Exchage_Extrem_Reg这三个函数配合Decide_Sample_No计数器,实现了一个 50 个采样周期的滑动窗口。每 50 个周期更新一次最大值和最小值寄存器,然后Cal_Threhold_Precision根据窗口内的极值算出新的Axis_Threhold和Axis_Precision,Exchage_Extrem_Reg交换新旧极值以便下个周期继续统计。这套自适应机制解决的是不同人步幅、步频差异大的问题——固定阈值要么对慢走不敏感,要么对快跑误判。

Filter_Axis_Value是另一层滤波,它把当前读数和上一次读数做差,三个轴差值都小于 10 就认为是噪声返回 0,否则返回 1 表示有大变化。这个函数更像是一个“运动/静止”判别器,适合做屏幕唤醒或低功耗切换,和计步状态机是并行的两条线。实际用的时候我一般把Filter_Axis_Value的返回值接到主循环里,静止超过一定时间就降采样率省电。

注意:Filter_AD_Data_1里int16 abs[2][3]这个变量名和标准库abs()函数重名,虽然局部变量会遮蔽函数名、编译能过,但可读性差,二次开发时建议改成delta之类。另外Step_P_Flag[i] ^= 0xff这种翻转写法在uint8上等价于0 <-> 255,但不如直接赋 0/1 直观,接手的人容易看懵。

4. 避坑与排查:上板后最容易翻车的五个点

4.1 读 ID 返回 0x00 或 0xFF

现象:AD_Initial一直返回 1,串口打印器件 ID 不是0xE5。原因通常是三类:接线错(SDA/SCL 反了或没接上拉电阻)、引脚宏和实际飞线不一致、SDA_IN/SDA_OUT的 CRH 位操作写错了端口。解决顺序是先量电压——空闲时 SDA 和 SCL 都应该是高电平,如果 SDA 一直是低,说明总线被拉死或宏定义反了;再查上拉,ADXL345 模块一般自带 4.7k 上拉,但如果自己画板忘了加,模拟 IIC 的推挽输出在切换输入后会浮空。最后用示波器抓起始信号,确认 SCL 高期间 SDA 有下降沿。

4.2 三轴数据恒定不变或全是 0

现象:ID 读到了,初始化也过了,但Read_Axis读出来三个值一直不变。原因多半是POWER_CTL没真正进入测量模式,或者DATA_FORMAT的位写错导致数据寄存器不更新。ADXL345 上电默认是待机模式,必须写POWER_CTL的 Measure 位。另一个常见原因是读的寄存器地址偏了——DATAX0宏如果定义成0x31而不是0x32,读出来的就是别的东西。排查方法:单独读POWER_CTL回读确认写入生效,再手动读DATAX0和DATAX1看是否有变化。

4.3 计步数疯涨或永远为 0

现象:静止放着步数也在涨,或者怎么走都不计步。疯涨一般是Axis_Precision太小或Sample_Timer门限太低,噪声被当成波峰;永远为 0 则可能是Axis_Threhold设得比实际加速度幅值还大,信号穿不过门限。解决:先把Read_Axis的原始值打印出来,人走几步看峰峰值大概多少,再反推阈值。±4g 量程下正常走路单轴峰峰值大概在 2000~6000 LSB 之间,阈值设在这个区间的一半左右比较稳。另外Select_Axis选错轴也会导致计步失效,比如手环戴在手腕上,主要波动轴可能不是 Z。

4.4 模拟 IIC 在高速 MCU 上时序错乱

现象:在 72MHz 的 STM32 上跑得好好的,换到 168MHz 的板子就读写失败。原因是delay_us的实现依赖循环计数,主频变了延时就不准,SCL 周期被压缩到 ADXL345 跟不上的程度。解决:把delay_us改成基于 SysTick 或定时器的精确延时,或者在 IIC 函数里加__NOP()撑时间。血泪经验是别用for空循环当延时,编译器优化等级一改,循环直接被优化掉,时序全乱。

4.5 多字节读最后一个字节没回 NACK

现象:单字节读写正常,连续读 6 个字节时偶尔读到错位数据或总线卡死。原因在AD_Read_Multi_Byte的循环里,最后一个字节必须调IIC_Read_Byte(0)发 NACK,如果误写成IIC_Read_Byte(1),从机会以为主机还要数据,继续占用 SDA,后续IIC_Stop产生不了正确的停止条件。排查时重点看循环里i != (len-1)这个判断有没有写反。这个坑我在两个项目里各踩过一次,后来养成了读多字节必先单步看最后一个字节 ACK 状态的习惯。

5. 二次开发:把这份驱动改成能落地的运动检测模块

拿到这份代码,直接烧进去能出数据,但要真做成产品,至少得动三处。第一处是量程和速率。DATA_FORMAT的0x69是 ±4g,做跌倒检测时瞬时冲击可能超过 4g 导致削顶,我一般改成0x6D(±16g),代价是分辨率降到 3.9mg/LSB 变 15.6mg/LSB,计步精度略降但跌落不漏。BW_RATE的0x09是 50Hz,计步够用,做手势识别得提到 100Hz 以上,对应写0x0A或0x0B。

第二处是中断。代码里把INT_ENABLE和INT_MAP都写成0x80关掉了中断,实际产品里应该打开DATA_READY中断,让 MCU 在数据就绪时才醒来读,而不是主循环死等。改法是把INT_ENABLE写0x80(DATA_READY 使能),INT_MAP写0x00(映射到 INT1 引脚),然后在 EXTI 中断里调Read_Axis。这样平均功耗能降一个数量级,对手环类项目是刚需。

第三处是计步状态机的可调参数外置。现在Axis_Precision、Axis_Threhold、Sample_Timer的门限都散在代码里,二次开发时建议抽成一个结构体,通过串口或 Flash 配置,方便不同佩戴位置快速标定。

typedef struct { int16 precision[3]; /* 三轴幅值门限 */ int16 threshold[3]; /* 三轴过零门限 */ uint8 time_window; /* 最小计步间隔,单位采样周期 */ uint8 axis_sel; /* 0=自动选轴 1=X 2=Y 3=Z */ } StepConfig; /* 验证方法:串口打印原始三轴 + 当前步数,走 100 步对比误差 */

验证改造是否有效,我习惯用“已知步数对照法”:让测试者走 100 步,串口同时输出原始加速度和累计步数,事后把原始数据导出来在 PC 上重跑一遍算法,看误差是出在采样还是判定。如果原始数据里波峰清晰但步数偏少,就是门限太高;如果波峰本身糊成一团,那是采样率或滤波的问题,调算法没用,得先解决数据质量。

从那以后我每次移植这类传感器驱动,都强制先跑一遍“读 ID + 打印原始三轴 + 静止看噪声”这三步,确认地基没问题再碰算法,省下来的调试时间比什么都值。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表