news 2026/9/3 23:34:20

3线SPI非库驱动ADXL345:寄存器操作与调试踩坑全记录

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
3线SPI非库驱动ADXL345:寄存器操作与调试踩坑全记录

简介:基于STM32平台的3线非库SPI方式连接ADXL345加速度计完整工程包,面向嵌入式开发者,可在不依赖标准库的条件下实现三轴加速度数据读取,适合资源受限或要求底层可控的场景。包内共22个文件,包含C源文件、头文件、启动文件、Keil工程配置以及HEX、ELF、MAP等编译产物,均带完整工程配置,压缩包仅69KB,便于直接查看或烧录验证。目前已有173人学习,适合具备基础STM32知识的开发者参考。资源不仅有寄存器级SPI通信代码与3线模式GPIO配置,还演示了ADXL345寄存器写入、数据读取和16位二进制补码转换的思路,可帮助理解SPI时序和器件数据手册,亦能快速移植到实际项目中。 嵌入式项目里,凡是涉及ADXL345的基本都能找到一堆现成例程,但大多是用I2C或者4线SPI,配上HAL库或者标准外设库,拿来就能跑。最近我在一个资源比较紧张的板子上调“3线非库SPI接ADXL345”,不依赖任何库函数,纯寄存器操作,结果发现这里的门道比想象中多。尤其是3线SPI的方向切换、寄存器里的SPI模式位、测量模式的坑,每一个都能让人折腾半天。这篇就把我完整的调试过程、代码和踩过的坑都记录下来,给同样准备这么干的人一个参考。

1. 先搞懂3线SPI和非库方案背后的逻辑

1.1 3线SPI和4线SPI的本质区别

很多朋友接触SPI都是从4线开始的:SCK、CS、MOSI、MISO,全双工,一发一收,简单直接。但有些场景下引脚不够用,尤其是小封装MCU,GPIO恨不得一个掰成两个用,这时候3线SPI就非常有价值。

3线SPI其实就是把MOSI和MISO合并成一根双向数据线SDA,配合SCK和CS,一共三根线。通信变成半双工:主机发命令时SDA作为输出,主机读数据时SDA立即切换成输入。ADXL345本身支持这种模式,在数据手册里写得很清楚,可以工作在3线或4线SPI模式,具体由DATA_FORMAT寄存器(0x31)的bit6控制,置1就启用3线模式。

这里有个很容易踩的误区:不少模块默认是按4线模式工作的,如果你只接了三根线,并且没有把DATA_FORMAT的bit6置1,那么读出来的数据大概率是全0xFF或者乱跳。我一开始就是在验证DEVID时卡了半天,后来翻手册才发现这个位没设。

1.2 为什么不用HAL库,非要寄存器操作

说回“非库”这个点。很多人觉得STM32有HAL库,SPI_TransmitReceive一调就完事了,为什么还要自讨苦吃去操作寄存器?我这次的选择主要基于三个现实原因。

第一是固件体积和RAM。板子主控资源紧张,HAL库一开,SPI外设初始化带中断带DMA,flash和RAM开销都不小。寄存器操作只占用几十字节,干净利落。

第二是3线SPI半双工模式下,硬件SPI外设要配置BIDIMODE(双向数据模式),还要在收发之间切换BIDIOE位来改变数据线方向,这套操作如果封装在库函数里,每读一个字节都要做一堆上下文切换,反而别扭。用GPIO模拟,方向切换完全可控,时序心里门儿清。

第三是从学习角度,GPIO模拟SPI能逼着你把协议每个细节吃透。当时钟频率、相位、方向切换都由自己控制时,你对整个通信过程的理解会完全不一样。这不是说HAL库不好,而是在这种特殊模式下,寄存器操作往往更直接、更可控。

2. 硬件连接和初始化准备

2.1 引脚分配与电路细节

先看接线。我用的ADXL345是常见的那种小模块,引脚有VCC、GND、CS、SCL/SCK、SDA/SDI、SDO。3线SPI模式下,核心连接如下:

ADXL345引脚接MCU引脚说明
VCC3.3VADXL345供电范围2.0~3.6V,别接5V
GNDGND共地
CSPA4片选,低电平有效
SCLKPA5SPI时钟
SDA/SDIPA6双向数据线
SDO悬空或接上拉3线模式下不用

这里特别注意两点。第一,ADXL345是3.3V器件,如果MCU是5V供电,必须做电平转换,否则长期运行容易烧芯片。第二,SDA这根双向数据线,建议在模块端加一个4.7kΩ到10kΩ的上拉电阻到VCC。原因很简单:3线模式下数据线是开漏还是推挽取决于模块内部设计,实测很多模块的SDA在输入模式下是高阻态,没有上拉的话,主机读到的电平可能不稳定,尤其是通信速率稍高的时候。

还有人会问SDO是不是可以接地。这个我建议悬空就好,因为ADXL345的SDO引脚在SPI模式下还有一个作用:它和I2C的地址选择位共用。3线SPI模式下不参与数据,悬空最保险。

2.2 GPIO初始化的寄存器配置

引脚选定后,先把GPIO初始化写好。既然是“非库”,我直接操作GPIO的CRL寄存器(STM32F103的PA0~PA7由CRL控制),每个引脚占用4位,对应关系是引脚编号乘以4。

#define ADXL_CS_PIN 4 #define ADXL_SCK_PIN 5 #define ADXL_SDA_PIN 6 #define ADXL_CS_PORT GPIOA #define ADXL_SCK_PORT GPIOA #define ADXL_SDA_PORT GPIOA // 方向控制宏:SDA引脚在推挽输出(03)和浮空输入(04)之间切换 #define SDA_DIR_OUT() do { \ GPIOA->CRL &= ~(0xF << (ADXL_SDA_PIN * 4)); \ GPIOA->CRL |= (0x3 << (ADXL_SDA_PIN * 4)); \ } while(0) #define SDA_DIR_IN() do { \ GPIOA->CRL &= ~(0xF << (ADXL_SDA_PIN * 4)); \ GPIOA->CRL |= (0x4 << (ADXL_SDA_PIN * 4)); \ } while(0) // 电平控制宏 #define CS_LOW() GPIOA->BRR = (1 << ADXL_CS_PIN) #define CS_HIGH() GPIOA->BSRR = (1 << ADXL_CS_PIN) #define SCK_LOW() GPIOA->BRR = (1 << ADXL_SCK_PIN) #define SCK_HIGH() GPIOA->BSRR = (1 << ADXL_SCK_PIN) #define SDA_HIGH() GPIOA->BSRR = (1 << ADXL_SDA_PIN) #define SDA_LOW() GPIOA->BRR = (1 << ADXL_SDA_PIN) #define SDA_READ() ((GPIOA->IDR & (1 << ADXL_SDA_PIN)) != 0)

初始化函数里,先把CS、SCK配成推挽输出,SDA先配成输出,默认拉高。

void adxl_spi_gpio_init(void) { // 开启GPIOA时钟:RCC->APB2ENR bit2 对应IOPA RCC->APB2ENR |= (1 << 2); // PA4、PA5、PA6全部先设为推挽输出,50MHz GPIOA->CRL &= ~(0xFFF << (4 * 4)); GPIOA->CRL |= (0x333 << (4 * 4)); CS_HIGH(); SCK_HIGH(); SDA_HIGH(); }

CRL寄存器操作时,0x3表示通用推挽输出50MHz,0x4表示浮空输入。这个写法在F1系列上通用,换到F4或者G4系列就得改成MODER寄存器了,思路一致。

3. 寄存器级3线SPI时序实现

3.1 底层读写字节函数

3线SPI的核心全在这两个函数里。先看写字节,这个和4线SPI的MOSI写一模一样,唯一要注意的是MSB先行,时钟相位选择模式0,也就是SCK空闲为低,数据在上升沿被采样。

void spi3_write_byte(uint8_t data) { for (int i = 7; i >= 0; i--) { SCK_LOW(); if (data & (1 << i)) SDA_HIGH(); else SDA_LOW(); delay_us(1); SCK_HIGH(); delay_us(1); } }

读字节就有讲究了,因为SDA这根线现在是双向的。主机必须先把它从输出模式切到输入模式,然后再产生时钟去采样数据。

uint8_t spi3_read_byte(void) { uint8_t data = 0; SDA_DIR_IN(); // 关键:切输入 for (int i = 0; i < 8; i++) { SCK_LOW(); delay_us(1); SCK_HIGH(); delay_us(1); data = (data << 1) | (SDA_READ() ? 1 : 0); } SDA_DIR_OUT(); // 读完后切回输出,方便下次发命令 return data; }

3.2 方向切换是3线SPI的灵魂

上面代码里,方向切换的位置非常关键。ADXL345在SCK低电平期间把数据输出到SDA线上,主机在SCK高电平期间采样,所以在SCK拉高之前把SDA切成输入,并且保证方向切换发生在SCK空闲(低电平)状态,这是整个3线通信稳定的前提。

我第一次写的时候偷了个懒,在发送完命令字节后没立刻切方向,而是等到循环里才切,结果第一个字节的高位全读错,数据又乱又跳。后来把SDA_DIR_IN()提到循环之前,一切就正常了。

还要注意,方向切换之后,SDA线上有可能会有瞬间的不稳定。解决办法就是加一个微小延时,或者像上面代码那样,在SCK拉低到拉高之间保留1us的间隔。实际测试1us在100kHz的通信速率下完全够用,如果MCU主频高,可以把这个延时缩短到几百纳秒,但别完全去掉。

3.3 ADXL345寄存器读写命令格式

有了底层收发函数,就可以封装ADXL345的寄存器操作了。ADXL345的SPI命令格式很简单:第一个字节最高位是读写标志位,1表示读,0表示写,剩余7位是寄存器地址。所以读命令是0x80|reg,写命令是reg&0x7F。

uint8_t adxl345_read_reg(uint8_t reg) { uint8_t val = 0; CS_LOW(); spi3_write_byte(0x80 | reg); val = spi3_read_byte(); CS_HIGH(); return val; } void adxl345_write_reg(uint8_t reg, uint8_t val) { CS_LOW(); spi3_write_byte(reg & 0x7F); spi3_write_byte(val); CS_HIGH(); }

多字节读也有技巧。ADXL345支持连续读多个寄存器,地址自动递增,读DATAX0到DATAZ1六个寄存器时特别方便。操作上就是CS拉低后,先发读命令0x80|0x32,然后连续读取六个字节,最后CS拉高。整个过程CS必须保持低电平,中间不能松。

void adxl345_read_multi(uint8_t start_reg, uint8_t *buf, uint8_t len) { CS_LOW(); spi3_write_byte(0x80 | start_reg); SDA_DIR_IN(); for (int i = 0; i < len; i++) { SCK_LOW(); delay_us(1); SCK_HIGH(); delay_us(1); buf[i] = (uint8_t)((buf[i] << 0) | (SDA_READ() ? 1 : 0)); for (int j = 1; j < 8; j++) { SCK_LOW(); delay_us(1); SCK_HIGH(); delay_us(1); buf[i] = (buf[i] << 1) | (SDA_READ() ? 1 : 0); } } SDA_DIR_OUT(); CS_HIGH(); }

这段代码比单字节读稍微繁琐,但好处是只用了一次CS拉低,效率高很多。实际上就是手动展开了8位读取,避免在连续读时频繁切方向造成时序间隙。

4. 完整初始化与加速度数据读取

4.1 初始化寄存器:两个关键配置

ADXL345初始化只需要写两个寄存器:DATA_FORMAT(0x31)和POWER_CTL(0x2D)。但这两个寄存器里藏着两个大坑。

先看DATA_FORMAT。这个寄存器控制数据格式,bit6就是SPI模式选择位,置1启用3线SPI模式。我设置的是0x4B,二进制01001011,含义是:

  • bit6=1:3线SPI模式
  • bit3=1:全分辨率模式(FULL_RES)
  • bit1:0=11:±16g量程

再看POWER_CTL。这个寄存器bit5是Measure位,置1才进入测量模式。正确的值是0x20。但网上有大量的例程写的是0x08,我一直很困惑为什么这么多代码和手册对不上。实际测试中,写了0x08之后,芯片没有进入测量模式,读出来的加速度数据全为0。后来把所有位都翻了一遍,才发现Measure位是bit5。

void adxl345_init(void) { // 3线SPI模式 + 全分辨率 + ±16g adxl345_write_reg(0x31, 0x4B); // 进入测量模式 adxl345_write_reg(0x2D, 0x20); delay_ms(10); }

初始化之后,先读一下DEVID寄存器(0x00),确认通信通道没问题。DEVID固定是0xE5,如果读不到这个值,后面一切免谈。

uint8_t devid = adxl345_read_reg(0x00); if (devid != 0xE5) { // 通信异常,重点检查接线和SPI模式配置 }

4.2 读取三轴加速度数据

加速度数据寄存器从0x32开始,依次是DATAX0、DATAX1、DATAY0、DATAY1、DATAZ0、DATAZ1。每个轴是16位,低字节在前。用多字节读一次拿6个字节最省事。

typedef struct { int16_t x; int16_t y; int16_t z; } accel_data_t; accel_data_t adxl345_read_accel(void) { uint8_t buf[6] = {0}; accel_data_t data = {0}; adxl345_read_multi(0x32, buf, 6); data.x = (int16_t)((buf[1] << 8) | buf[0]); data.y = (int16_t)((buf[3] << 8) | buf[2]); data.z = (int16_t)((buf[5] << 8) | buf[4]); return data; }

这里有个细节容易搞错:数据拼接的时候,如果直接用(int16_t)((buf[1]<<8)|buf[0]),buf[1]的符号位会被正确带出来,因为左移8位后符号位刚好落在bit15。但是如果你的编译器把int默认当成16位处理,就得格外小心。STM32上int是32位,所以这样写没问题。

计算实际g值也很简单。±16g量程+全分辨率模式下,每个LSB对应3.9mg(16g/4096,因为全分辨率时数据是13位有效,但左对齐后读出来是16位有符号数,实际满量程对应-2048~2047)。粗略算就是:加速度g值 = 原始值 * 3.9 / 1000。

4.3 实测数据与滤波处理

传感器静止水平放置时,Z轴读数应该在+1g附近,X轴和Y轴接近0g。我把原始值打印出来看一眼:

X: 12, Y: -8, Z: 260

Z轴260乘以3.9mg约等于1.014g,符合预期。如果Z轴读数在0附近,说明测量模式没进去或者量程配置有问题。

实际应用中,传感器数据会有一定噪声,尤其是电源纹波大的时候。我习惯做一次简单的均值滤波,取10次采样平均。对于非库工程这是最省事的方案,不需要额外引入滤波库。

accel_data_t adxl345_read_filtered(uint8_t samples) { int32_t sum_x = 0, sum_y = 0, sum_z = 0; accel_data_t data; for (uint8_t i = 0; i < samples; i++) { data = adxl345_read_accel(); sum_x += data.x; sum_y += data.y; sum_z += data.z; } data.x = sum_x / samples; data.y = sum_y / samples; data.z = sum_z / samples; return data; }

5. 常见问题与排查技巧实录

5.1 读了DEVID返回全0xFF或全0x00

这个现象在3线SPI下最常见,我排查了一晚上,总结出三个高频原因。

第一,DATA_FORMAT的bit6没有置1。3线SPI模式没真正启用,芯片还在4线模式下工作,数据线方向错乱,读回来全是0xFF。这个最好排查,初始化时看0x31有没有写成0x4B。

第二,方向切换时序不对。特别是spi3_read_byte里,如果没有在读数据前把SDA_DIR_IN()切到位,那整个读取过程SDA一直处于输出状态,读到的自然是自己的输出电平或者高阻态乱码。调试时可以在切方向后加个延时,人为增大时序裕量,如果数据突然变正常,基本就是方向切换时机问题。

第三,接线虚焊或者接触不良。SDA线是双向的,如果接触不良,主机输出命令时还勉强能用,一旦主机切输入、从设备想拉低电平,高阻态下波形就会严重畸变。用示波器看SDA波形最直观,没有示波器就换一根杜邦线,或者直接把SDA焊上去。

5.2 POWER_CTL写0x08不出数据

这真的是网上代码重灾区。不少例程写adxl345_write_reg(0x2D, 0x08),说这是测量模式,实际上0x08只是设置了唤醒频率2Hz,并没有把Measure位置1。芯片处于待机模式,加速度数据寄存器全是0。

这个坑的隐蔽性在于,DEVID照样能读出来,寄存器读写通道是通的,但就是没加速度数据。如果你遇到“能读ID但数据全0”的情况,十有八九是POWER_CTL写错了。正确的测量模式应该是0x20,bit5置1。

5.3 数据偶发跳变,读10次错1次

这种问题往往出在通信速率和线上干扰上。GPIO模拟SPI虽然没有硬性上限,但在长导线和双向数据线这种半双工场景下,速率拉太高容易出问题。

我的建议是把两个延时函数都调到至少1us,使实际SCK频率在100kHz~250kHz之间,稳定性很高。另外MCU主频高时,循环里的GPIO操作可能只有几十纳秒,如果SDA线上电容较大,从设备还没把信号稳定下来主机就采样了,也会偶发跳变。可以适当在SCK_HIGH后加一个小的空循环延时,让信号有充足时间稳定。

5.4 常见问题速查表

现象可能原因解决方法
读DEVID返回0xFF3线SPI模式未启用确认0x31写入0x4B
读DEVID返回0x00SDA没接上或方向切换失败检查SDA线连接,确认读前切输入
能读ID,但加速度全是0POWER_CTL没有进入测量模式0x2D应写入0x20而不是0x08
数据偶尔跳一个很大的值时序裕量不足或干扰降低SCK频率,延时加大
Z轴静止读数明显小于1g量程或分辨率配置不对检查0x31的FULL_RES位和range位
数据恒定不变CS一直拉低没释放每次通信结束必须CS_HIGH

最后再分享两个小技巧

第一,调试3线SPI时,强烈建议先用4线SPI把ADXL345调通,再改成3线。4线模式下数据线收发分离,遇到问题容易定位。调通后切到3线,只需要关注方向切换一个新变量,排查范围小很多。

第二,GPIO模拟SPI虽然是“土办法”,但也是理解协议最快的方式。我每次调新传感器,都会坚持用寄存器操作模拟一遍时序,跑通之后再考虑要不要换硬件SPI优化速度。这次3线非库接ADXL345虽然折腾了不少时间,但之后再看SPI协议相关的任何文档,基本都是一遍过。做嵌入式,该省的地方省,不该省的理解时间千万别省。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/3 23:32:48

AI内容审核的困境与出路:从技术对抗到人机协同治理

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/3 23:30:13

SAR成像距离多普勒算法(RDA)核心原理与工程实现详解

简介&#xff1a;针对SAR成像算法中的距离徙动校正与RDA实现&#xff0c;这套资源以MATLAB脚本形式提供了根据回波信号生成SAR图像的完整示例&#xff0c;贴近遥感成像处理的实际验证场景。面向遥感信号处理学习者、SAR成像算法入门者&#xff0c;以及需要对比RCMC改进效果的研…

作者头像 李华
网站建设 2026/9/3 23:26:56

iKF Ear Clip耳夹式耳机评测:舒适稳固佩戴与实用音质体验

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/3 23:26:17

STM32驱动国产24位高精度ADC CS1238:从时序到代码实践

简介&#xff1a;STM32平台使用的CS1238/CS1237 ADC驱动源码包&#xff0c;面向嵌入式开发与传感器数据采集场景&#xff0c;解决在STM32上快速接入12位逐次逼近型ADC芯片的驱动问题。资源体积仅2KB&#xff0c;共2个文件&#xff0c;其中C源文件实现芯片初始化、采样率配置、启…

作者头像 李华
网站建设 2026/9/3 23:25:14

Linux 内核高危漏洞综合解析:iSCSI、xfrm 与 netfilter‑conntrack 安全风险

近期 Linux 内核陆续披露多例严重级别安全漏洞&#xff0c;CVE‑2026‑63888、CVE‑2026‑53363、CVE‑2026‑80668 的 CVSS 评分均达到 9.8&#xff0c;分别涉及 iSCSI 存储子系统、xfrm 网络加密框架、netfilter 连接跟踪模块。漏洞覆盖存储服务、IPsec 加密、NAT 与容器网络…

作者头像 李华
网站建设 2026/9/3 23:24:23

C语言实现GNSS卫星坐标解算:从RINEX广播星历到SP3精密星历

简介&#xff1a;基于C语言实现的GNSS星历读取与卫星坐标计算工程&#xff0c;面向卫星导航、大地测量方向的开发者与学生&#xff0c;用于对比广播星历与精密星历的坐标精度差异。压缩包共32个文件&#xff0c;涵盖C源码、头文件、Visual Studio工程、可执行程序、sp3精密星历…

作者头像 李华