STM32F103驱动LIS3DH加速度传感器实战:I²C寄存器级调试与三轴数据读取 简介本资源是一份面向嵌入式初学者与STM32开发者的LIS3DH三轴加速度传感器驱动实战代码包聚焦于在资源受限的STM32F103平台上通过GPIO模拟I²C协议实现高可靠性通信。项目已实测验证可直接用于运动检测、姿态识别及振动监测等物联网终端场景无需额外硬件I²C外设支持。压缩包共4个文件2个C源文件 2个H头文件总大小仅12KB结构精简iic.c/h封装了完整的软件模拟I²C底层时序含START/STOP/ACK/读写speed.c/h则负责LIS3DH寄存器配置、数据采集速率设定与加速度值解析接口清晰、移植性强。目前已有2586人学习下载读者可直接获取经过真机验证的驱动框架、关键时序控制逻辑、寄存器映射注释及中断适配预留位显著降低传感器接入门槛避免从零调试I²C电平与时序的常见痛点。1. 一个连着杜邦线就能出加速度数据的硬核小模块STM32F103 LIS3DH 通过标准I²C跑通即用新手照着接线烧录就能看到XYZ三轴实时数值你手头有一块最常见的蓝 pillSTM32F103C8T6开发板还有一颗LIS3DH加速度传感器模块——不是开发套件里那种带复杂底板、一堆跳线的“教学版”就是淘宝上五块钱包邮、背面印着“I²C”和“3.3V”的裸模块。你翻遍官方例程发现ST的HAL库对LIS3DH支持极弱CubeMX生成的I²C初始化甚至默认关掉了重启动RESTART而LIS3DH在读取多字节寄存器时恰恰依赖这个特性你试过网上零散的“可用”代码结果串口打印出来全是0x00或0xFF示波器一测SCL根本没起振。别折腾了——这份实测可用的工程就是为这种“焊完线就想看数”的场景写的。它不依赖任何中间件纯寄存器级I²C驱动兼容标准模式快速模式完整实现LIS3DH的初始化、自检、XYZ三轴16位有符号数据读取、ODR配置、中断引脚映射并通过USART1以CSV格式实时输出。适合嵌入式入门者练手I²C时序调试也适合产品原型阶段快速验证传感器接入链路。所有代码基于Keil MDK-ARM v5.38无CMSIS-Pack依赖下载即编译烧录即运行。2. 工程结构与核心外设配置从SystemInit到I²C时钟树的硬核对齐2.1 目录结构与文件职责划分为什么只保留7个.c/.h文件整个工程采用极简主义设计共7个源文件全部位于Src/和Inc/根目录下无子文件夹嵌套。这种结构不是偷懒而是为了彻底暴露I²C底层细节——当你改错一个时钟分频参数必须亲手去stm32f10x_rcc.c里确认RCC-CFGR的I2CCLK位是否被正确置位而不是靠CubeMX自动生成的黑匣子掩盖问题。Src/ ├── main.c // 系统入口RCC初始化→GPIO→I²C→USART→LIS3DH初始化→主循环读数 ├── stm32f10x_i2c.c // 核心重写I²C底层驱动非ST标准库函数 ├── lis3dh_driver.c // LIS3DH专用寄存器映射、初始化序列、数据读取逻辑 ├── usart1_printf.c // 串口重定向printf直接输出CSV波特率115200 └── system_stm32f10x.c // 系统时钟配置HSE8MHz → PLL72MHz → APB136MHzI²C挂APB1 Inc/ ├── stm32f10x_i2c.h // I²C宏定义CR1/CR2/OAR1/DR等寄存器偏移量关键位掩码 ├── lis3dh_driver.h // LIS3DH寄存器地址表CTRL_REG10x20, OUT_X_L0x28等含bit字段注释提示stm32f10x_i2c.c是本工程的命脉。它完全绕过ST标准外设库的I2C_GenerateSTART()等函数直接操作I2C1-CR1、I2C1-CR2、I2C1-OAR1寄存器。原因标准库函数在I2C_GenerateSTOP()后会自动清CR1:PE位导致后续读操作失败——而LIS3DH的连续读必须保持I²C外设使能状态。2.2 RCC时钟树配置APB1分频比决定I²C通信成败LIS3DH要求I²C时钟频率≤400kHz快速模式而STM32F103的I²C外设挂载在APB1总线上。若APB1分频比设置错误即使代码逻辑完美SCL波形也会严重失真。本工程强制将APB1分频设为2RCC_CFGR_PPRE1_DIV2确保APB136MHz主频72MHz÷2。此时I²C时钟频率由CCR寄存器计算// 计算公式I2CCLK APB1CLK / (2 * CCR) // 要求I2CCLK ≤ 400kHz → CCR ≥ 36MHz / (2 * 400kHz) 45 // 实际取CCR48留余量对应I2CCLK 36MHz/(2*48) 375kHz I2C1-CCR 48; // 标准模式下此值必须≥45 I2C1-TRISE 37; // TRISE APB1CLK/1MHz 1 36 1 37这段代码必须放在I2C1外设使能RCC-APB2ENR | RCC_APB2ENR_IOPBEN和I2C1时钟使能RCC-APB1ENR | RCC_APB1ENR_I2C1EN之后且在I2C1-CR1 | I2C_CR1_PE之前。顺序错一步I²C就永远发不出START信号。2.3 GPIO复用配置PB6/PB7的开漏输出与上拉电阻真相LIS3DH模块的SCL/SDA引脚必须接外部上拉电阻通常4.7kΩ这是I²C物理层硬性要求。但很多新手误以为只要在代码里配置GPIO_Mode_AF_OD复用开漏就够了却忘了硬件上没接上拉示波器测SCL始终是低电平。// 正确配置PB6SCL, PB7SDA RCC-APB2ENR | RCC_APB2ENR_IOPBEN; // 使能PORTB时钟 GPIOB-CRH ~(GPIO_CRH_MODE6 | GPIO_CRH_CNF6 | GPIO_CRH_MODE7 | GPIO_CRH_CNF7); // 清除PB6/PB7原配置 GPIOB-CRH | GPIO_CRH_MODE6_1 | GPIO_CRH_CNF6_1 | // PB6: AF Output, 50MHz, Open-drain GPIO_CRH_MODE7_1 | GPIO_CRH_CNF7_1; // PB7: 同上注意GPIO_CRH_CNFx_1表示开漏模式GPIO_CRH_MODEx_1表示50MHz输出速度。若此处配置成GPIO_Mode_Out_PP推挽I²C总线将因电平冲突而损坏传感器——这是真实翻车现场某开发者曾因此报废3片LIS3DH。3. I²C底层驱动实现从START信号生成到多字节读取的寄存器级控制3.1 START信号生成为什么必须手动轮询SB位I²C协议规定START条件是在SCL高电平时SDA由高变低。STM32F103的I²C外设通过CR1:START位触发但硬件不会立即执行——它需等待总线空闲SR2:BUSBUSY0且自身处于主模式SR2:MSL0。标准库函数常忽略此等待导致START写入后SR1:SBStart Bit位迟迟不置位。// 手动实现STARTlis3dh_driver.c中调用 void I2C1_GenerateSTART(uint8_t Address, uint8_t Direction) { // 1. 等待总线空闲 while(I2C1-SR2 I2C_SR2_BUSY); // 2. 清除ADDR位避免ADDR未清导致后续操作卡死 if(I2C1-SR1 I2C_SR1_ADDR) (void)I2C1-SR2; // 3. 发送START I2C1-CR1 | I2C_CR1_START; // 4. 等待SB置位超时保护 uint16_t timeout 0xFFFF; while(!(I2C1-SR1 I2C_SR1_SB)) { if(--timeout 0) return; // 超时退出 } }关键点I2C1-CR1 | I2C_CR1_START后必须轮询SR1:SB而非SR1:EV5。因为EV5事件包含ADDR和SB而ADDR在未发送地址前不可能发生轮询它会导致无限等待。3.2 地址发送与应答处理7位地址左移1位的玄学操作LIS3DH的I²C从机地址为0x187位但STM32的DR寄存器要求写入8位地址方向位。方向位R/W为0表示写1表示读。因此写操作地址为0x181 | 0 0x30读操作为0x181 | 1 0x31。// 发送设备地址Direction0写1读 void I2C1_SendAddress(uint8_t Address, uint8_t Direction) { I2C1-DR (Address 1) | Direction; // 关键左移1位 // 等待ADDR置位地址被应答 uint16_t timeout 0xFFFF; while(!(I2C1-SR1 I2C_SR1_ADDR)) { if(--timeout 0) return; } (void)I2C1-SR2; // 清除ADDR标志读SR2 }注意(void)I2C1-SR2这行不能省略。若不清除ADDR后续读取SR1:RXNE时会因ADDR未清而无法进入接收状态——这是最隐蔽的坑现象是DR寄存器读不到数据但示波器显示SCL/SDA波形完全正常。3.3 多字节连续读取LIS3DH的AUTO_INCREMENT特性与RESTART机制LIS3DH支持自动递增地址读取AUTO_INCREMENT。要读XYZ三轴6字节X_L/X_H/Y_L/Y_H/Z_L/Z_H只需发送起始地址0x28OUT_X_L然后连续读6次DR。但关键在于第一次读完X_L后必须发送RESTART信号再发地址0x28才能触发自动递增。否则第二次读仍是X_L。// lis3dh_driver.c中读取6字节XYZ数据 uint8_t LIS3DH_ReadXYZ(int16_t* x, int16_t* y, int16_t* z) { uint8_t buffer[6]; // 步骤1写寄存器地址0x28 I2C1_GenerateSTART(0x18, 0); // START 写地址 I2C1_SendAddress(0x18, 0); I2C1_SendByte(0x28); // 指向OUT_X_L // 步骤2RESTART 读地址触发AUTO_INCREMENT I2C1_GenerateSTART(0x18, 1); // RESTART 读地址 I2C1_SendAddress(0x18, 1); // 步骤3连续读6字节硬件自动递增地址 for(uint8_t i 0; i 6; i) { if(i 5) I2C1-CR1 ~I2C_CR1_ACK; // 最后一字节NACK buffer[i] I2C1_ReceiveByte(); } I2C1_GenerateSTOP(); // STOP // 解析16位有符号数LIS3DH为小端X_L在前X_H在后 *x (int16_t)(buffer[0] | (buffer[1] 8)); *y (int16_t)(buffer[2] | (buffer[3] 8)); *z (int16_t)(buffer[4] | (buffer[5] 8)); return 0; }血泪经验I2C1-CR1 ~I2C_CR1_ACK必须在读最后一个字节之前执行否则从机继续发送第7字节导致总线错误。且I2C1_ReceiveByte()函数内部必须轮询SR1:RXNE位而非直接读DR——未就绪就读DR会丢失数据。4. LIS3DH初始化与功能配置从电源管理到中断触发的全链路控制4.1 初始化序列为什么CTRL_REG1的0x07值是黄金组合LIS3DH上电后默认处于低功耗模式LPen0且所有轴禁用。必须按严格顺序写入多个控制寄存器否则传感器无响应。本工程采用最简可靠初始化// lis3dh_driver.c uint8_t LIS3DH_Init(void) { // 1. 退出低功耗CTRL_REG1[3]1PD1 LIS3DH_WriteReg(LIS3DH_CTRL_REG1, 0x07); // 0x07 0b00000111 // ▲ ▲ ▲ // │ │ └─ XEN1X轴使能 // │ └── YEN1Y轴使能 // └──── ZEN1Z轴使能 // 2. 设置量程±2gCTRL_REG4[4:3]00FS00 LIS3DH_WriteReg(LIS3DH_CTRL_REG4, 0x00); // 3. 设置数据速率CTRL_REG1[7:4] 0100ODR400Hz // 当前0x07已满足0x07 0xF0 0x00 → ODR1Hz太慢需重写 LIS3DH_WriteReg(LIS3DH_CTRL_REG1, 0x47); // 0x47 0b01000111 → ODR400Hz // 4. 自检校验读WHO_AM_I应返回0x33 if(LIS3DH_ReadReg(LIS3DH_WHO_AM_I) ! 0x33) return 1; return 0; }关键点CTRL_REG1的高4位ODR[3:0]决定输出数据速率ODR。0x07的高4位是0x0对应ODR1Hz几乎静止必须改为0x470x4即二进制0100才能达到400Hz。这个值在数据手册Table 19中明确定义但无数新手因忽略高4位而误以为传感器“没反应”。4.2 中断配置INT1引脚触发运动检测的实战步骤LIS3DH的INT1引脚可配置为多种事件中断如运动检测、自由落体。本工程启用“运动检测”AOI1当任意轴加速度超过阈值时拉低INT1。// 配置运动检测中断INT1引脚 uint8_t LIS3DH_EnableMotionInt(uint8_t threshold) { // 1. 设置阈值INT1_THS threshold单位LSB1LSB≈15.6mg±2g LIS3DH_WriteReg(LIS3DH_INT1_THS, threshold); // e.g., threshold32 → ~500mg // 2. 设置持续时间INT1_DURATION 1至少1个采样周期 LIS3DH_WriteReg(LIS3DH_INT1_DURATION, 0x01); // 3. 使能AOI1中断源CTRL_REG3[6]1I1_AOI11 uint8_t reg3 LIS3DH_ReadReg(LIS3DH_CTRL_REG3); LIS3DH_WriteReg(LIS3DH_CTRL_REG3, reg3 | 0x40); // 4. 配置INT1为开漏输出CTRL_REG6[7]1INT1_PAD1 LIS3DH_WriteReg(LIS3DH_CTRL_REG6, 0x80); return 0; }硬件连接LIS3DH的INT1引脚需接STM32的任意GPIO如PA0并配置为上拉输入。软件中需在main()里开启EXTI中断// main.c中添加 RCC-APB2ENR | RCC_APB2ENR_AFIOEN; // 使能AFIO时钟 AFIO-EXTICR[0] AFIO_EXTICR1_EXTI0_PA; // PA0映射到EXTI0 EXTI-IMR | EXTI_IMR_MR0; // 使能EXTI0中断 EXTI-FTSR | EXTI_FTSR_TR0; // 下降沿触发INT1低有效 NVIC_EnableIRQ(EXTI0_IRQn);4.3 数据精度校准如何用OFFSET寄存器消除零点漂移LIS3DH存在固有零点偏移Zero-g Offset尤其在温度变化时。可通过OFF_X,OFF_Y,OFF_Z寄存器进行硬件校准。本工程提供一键校准函数// 在静止状态下调用自动计算偏移并写入寄存器 uint8_t LIS3DH_CalibrateOffset(void) { int32_t sum_x0, sum_y0, sum_z0; for(uint8_t i0; i16; i) { // 采样16次 int16_t x,y,z; LIS3DH_ReadXYZ(x,y,z); sum_x x; sum_y y; sum_z z; Delay_ms(10); } // 计算平均偏移单位LSB写入OFFSET寄存器 uint8_t off_x (sum_x/16) 0xFF; uint8_t off_y (sum_y/16) 0xFF; uint8_t off_z (sum_z/16) 0xFF; LIS3DH_WriteReg(LIS3DH_OFF_X, off_x); LIS3DH_WriteReg(LIS3DH_OFF_Y, off_y); LIS3DH_WriteReg(LIS3DH_OFF_Z, off_z); return 0; }提示OFFSET寄存器是8位有符号数范围-128~127 LSB。若计算值超出范围需截断处理 0xFF自动完成。校准后传感器输出将更接近理论值这对姿态解算至关重要。5. 常见问题排查那些让你对着示波器抓狂的5个真实翻车现场5.1 现象串口打印全是0x00或0xFF示波器测SCL无波形原因RCC-APB1ENR未使能I²C1时钟或I2C1-CR1的PE位未置位。解决检查system_stm32f10x.c中RCC-APB1ENR | RCC_APB1ENR_I2C1EN;是否执行在main()中I2C1-CR1 | I2C_CR1_PE;后加LED闪烁确认执行到此处。5.2 现象START信号发出但SR1:SB始终不置位程序卡死原因SCL或SDA引脚被意外拉低如焊接短路、模块电源未接、上拉电阻缺失。解决用万用表测PB6/PB7对地电压正常应为3.3V上拉有效断开LIS3DH模块测PB6/PB7电压是否升至3.3V——若仍为0V说明MCU引脚被硬件短路。5.3 现象能读到WHO_AM_I0x33但读XYZ数据全为0原因CTRL_REG1未正确配置ODR数据速率或CTRL_REG4的FS量程与实际需求不匹配。解决用逻辑分析仪抓取I²C波形确认写CTRL_REG1时发送的是0x47而非0x07检查LIS3DH_ReadReg(LIS3DH_CTRL_REG4)返回值是否为0x00±2g。5.4 现象INT1中断偶尔触发但无规律或完全不触发原因INT1_THS阈值设置过小环境振动干扰或过大灵敏度不足CTRL_REG3未使能I1_AOI1位。解决先将threshold设为0x20约500mg用手指轻敲开发板观察中断用示波器测INT1引脚电平确认其是否随敲击拉低检查LIS3DH_ReadReg(LIS3DH_CTRL_REG3)返回值的bit6是否为1。5.5 现象烧录后程序不运行或运行几秒后死机原因I2C1-CR1的ACK位在读操作中未及时关闭导致从机持续发送数据I²C总线锁死。解决严格遵循“读倒数第二字节前关闭ACK读最后一字节后发STOP”原则在I2C1_ReceiveByte()函数中增加超时保护避免死循环。6. 二次开发改造指南从CSV输出到FreeRTOS任务封装的平滑升级路径6.1 输出格式改造从printf CSV到结构化JSON上报原始工程用printf(X:%d,Y:%d,Z:%d\n, x, y, z)输出CSV便于串口助手查看。但若需对接物联网平台如MQTTJSON格式更通用。改造只需两步第一步定义数据结构体// Inc/sensor_data.h typedef struct { int16_t x; int16_t y; int16_t z; uint32_t timestamp_ms; // 毫秒时间戳 } lis3dh_data_t;第二步封装JSON序列化函数无需第三方库// Src/json_encoder.c #include sensor_data.h #include usart1_printf.c // 复用现有printf void LIS3DH_SendJSON(const lis3dh_data_t* data) { // 手动拼接JSON避免动态内存分配 printf({\acc\:{\x\:%d,\y\:%d,\z\:%d},\ts\:%lu}\n, >lis3dh_data_t sensor_data; while(1) { if(LIS3DH_ReadXYZ(sensor_data.x, sensor_data.y, sensor_data.z) 0) { sensor_data.timestamp_ms HAL_GetTick(); // 若用HAL库否则用SysTick LIS3DH_SendJSON(sensor_data); } Delay_ms(20); // 控制上报频率 }这种硬编码JSON方式牺牲了灵活性但换来零依赖、零内存碎片——在资源紧张的C8T6上这是最稳妥的选择。6.2 FreeRTOS任务封装将传感器读取抽象为独立任务若项目升级为FreeRTOS需将I²C读取与业务逻辑解耦。关键原则I²C操作必须在单一任务中串行执行禁止多任务并发访问同一I²C外设。// 创建传感器任务 void SensorTask(void *pvParameters) { lis3dh_data_t data; QueueHandle_t xQueue (QueueHandle_t) pvParameters; // 1. 初始化LIS3DH在任务内完成避免main中初始化失败影响其他任务 if(LIS3DH_Init() ! 0) { Error_Handler(); // 传感器初始化失败系统级错误 } while(1) { // 2. 读取数据阻塞式无超时 if(LIS3DH_ReadXYZ(data.x, data.y, data.z) 0) { data.timestamp_ms xTaskGetTickCount(); // 3. 发送至队列供其他任务消费 if(xQueue ! NULL) { xQueueSend(xQueue, data, portMAX_DELAY); } } vTaskDelay(pdMS_TO_TICKS(20)); // 50Hz采样 } } // 在main中创建队列和任务 int main(void) { // ... 硬件初始化 ... QueueHandle_t xSensorQueue xQueueCreate(10, sizeof(lis3dh_data_t)); xTaskCreate(SensorTask, Sensor, configMINIMAL_STACK_SIZE*2, xSensorQueue, tskIDLE_PRIORITY1, NULL); vTaskStartScheduler(); }避坑重点LIS3DH_ReadXYZ()函数内部不可调用任何FreeRTOS API如vTaskDelay因其可能触发调度器切换导致I²C状态机错乱。所有延时必须在任务层面统一管理。6.3 功耗优化从连续采集到中断唤醒的终极省电方案LIS3DH支持“睡眠-唤醒”模式可将电流从120μA降至3μA。改造核心是禁用连续读取改由INT1中断触发单次采集// 修改main()中的主循环 int main(void) { // ... 初始化 ... LIS3DH_EnableMotionInt(0x20); // 使能运动中断 while(1) { __WFI(); // 进入Wait For InterruptCPU休眠 // INT1中断服务程序中执行LIS3DH_ReadXYZ()并唤醒主任务 } } // EXTI0_IRQHandler中 void EXTI0_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken pdFALSE; // 清除中断标志 EXTI-PR EXTI_PR_PR0; // 读取数据此处需保证I²C操作足够快避免中断嵌套 lis3dh_data_t data; if(LIS3DH_ReadXYZ(data.x, data.y, data.z) 0) { // 通过队列或全局变量通知主任务 g_sensor_data data; g_data_ready 1; } }血泪教训在中断中执行I²C读取时必须关闭全局中断__disable_irq()或确保I²C操作原子性否则可能被SysTick中断打断导致I²C状态寄存器混乱。从那以后我每次做低功耗设计都强制走一遍中断上下文检查流程——用__get_PRIMASK()确认中断是否被屏蔽再执行外设操作。希望帮到你。本文还有配套的精品资源点击获取