简介:面向嵌入式、物联网及可穿戴设备开发者,这份标准C实现的demo代码包聚焦意法半导体(ST)多款传感器,覆盖LIS2DS12、LIS2DH12、LIS2DW12、LSM6DSM等常用型号,用于解决驱动移植、传感器数据读取及基础功能集成等实际问题。压缩包共104个文件,以C源文件(51个)、头文件(26个)、说明文档(26个txt与1个md)为主,整体仅383KB,目录结构清晰,便于按需查找和裁剪。代码涵盖传感器初始化、I2C/SPI通信、数据读取与滤波、中断处理、计步算法、功耗管理和错误处理等关键模块,尤其提供了多型号寄存器级驱动实现,可帮助开发者跳过底层配置细节,直接聚焦业务逻辑。资源附带的文本说明与整体md文档可用于对照理解初始化流程和数据结构,降低二次开发门槛。目前已有1100人学习/下载,适合具备一定嵌入式基础、需要快速完成原型验证或进行二次开发的工程师参考。 上个月整理驱动库时,我把ST系列芯片里三颗常用的三轴加速度计——LISDS12(标准型号LIS2DS12)、LISDh12(标准型号LIS2DH12)、LISDW12(标准型号LIS2DW12)的demo代码统一成了一套标准C工程。起因是一个低功耗倾角监测项目,硬件同事为了应对不同批次的采购压力,在板子上同时预留了三颗传感器的焊盘,选型时谁有货、谁便宜就贴谁。如果固件还按老办法复制粘贴驱动,每次换料都要改半天寄存器。于是花了一个周末把三颗芯片的差异彻底理清,写了一套接口统一的标准C实现,跑通了I2C和SPI两种总线,覆盖初始化、加速度读取、FIFO和中断唤醒这几个产品里最常用的功能。这篇文章把代码的分层思路、几个关键流程的写法,以及那些不踩一次根本记不住的坑都记录下来,给正在用ST系列MEMS加速度计做产品、又不想直接搬官方大库的朋友当个参考。
1. 一套代码兼容三颗传感器的真实动机
1.1 换料是常态,驱动也必须跟着换
嵌入式产品里MEMS加速度计的选型,很多时候不是技术决定,而是BOM成本、供货周期、代理商库存这些现实因素决定的。今天用LIS2DH12,下个月可能就换成LIS2DW12;如果后面要做活动识别,又得评估LIS2DS12的机器学习内核。三颗芯片管脚兼容性很好,PCB稍微做点兼容设计就能做到“一颗板子贴三种料”,但固件要是各写各的,每换一次料就要重新适配一次寄存器,工作量全堆在驱动上。
我见过不少项目,驱动代码从A芯片复制改成B芯片时,只改了初始化函数,读数据函数直接沿用旧逻辑。结果LIS2DH12输出低4位恒为0的问题就这样掩盖过去了,数据看起来“能用”,但精度永远比别人差一截。所以这次我做的时候就明确目标:不管贴哪颗芯片,固件启动时先读WHO_AM_I自动识别型号,再按型号走对应的配置流程,上层业务代码完全不关心底层是哪颗料。
1.2 为什么不用官方驱动库
ST官方确实提供了完善的传感器驱动库,寄存器定义非常齐全,对快速评估硬件很有帮助。但真要在产品固件里用,我个人觉得有几个尴尬的地方。
第一,官方驱动偏“完整”,会把中断、FIFO、自检、温度补偿、各种模式全部用结构体和函数包起来,直接搬进裸机工程往往要连带引入一堆依赖,代码体积膨胀得很厉害。第二,官方驱动的代码风格更贴近桌面端C,动辄返回复杂的错误码,在MCU上为了省一个判断分支,我自己更愿意写成轻量级的整型返回。第三,官方驱动的寄存器定义分散在多个头文件里,不同芯片之间的定义还有交叉引用,裁剪起来很费劲。所以我更倾向于把官方驱动当作“寄存器字典”来查,代码逻辑还是自己写,写出来的东西自己心里有底。
1.3 这套demo的边界与目标
我不想做一个把寄存器全部封装一遍的“万能库”,那在嵌入式里反而是灾难。这套代码的定位很明确:只覆盖产品里最常用的功能,包括WHO_AM_I身份识别、软件复位、ODR和量程配置、轮询读加速度、FIFO批量读取、INT1中断唤醒。传输层通过回调函数和驱动层解耦,上层业务代码只需要调用五个左右的接口函数。
这个“做减法”的思路很重要。做嵌入式驱动最怕功能做得太大,最终没人敢改、没人敢维护。先把核心链路跑通,后续需要什么功能再加,是在实际项目里更务实的选择。
2. 先摸清三颗传感器的寄存器“家谱”
2.1 同门师兄弟的大同
三颗传感器都是ST MEMS产品线里的三轴加速度计,寄存器风格一脉相承,这对做统一驱动实在是太友好了。
WHO_AM_I寄存器都在0x0F,加速度输出寄存器都从0x28开始连续6个字节,依次是X_L、X_H、Y_L、Y_H、Z_L、Z_H,状态寄存器都在0x27。这意味着“读ID”和“读加速度数据”这两段代码,三颗芯片可以完全复用。只要传输层回调写好了,驱动层面对的就是一套几乎相同的寄存器地图。
2.2 三兄弟的“小个性”
大同之下,三颗芯片的关键差异非常值得注意,这也是整套代码里最容易出错的地方。我整理了一份对比表:
| 对比项 | LIS2DH12 | LIS2DW12 | LIS2DS12 |
|---|---|---|---|
| WHO_AM_I (0x0F) | 0x33 | 0x44 | 0x43 |
| 软件复位位置 | CTRL_REG2(0x21)的BOOT位 | CTRL2(0x21)的SOFT_RESET位 | CTRL3(0x22)的BOOT位 |
| 加速度输出寄存器 | 0x28~0x2D | 0x28~0x2D | 0x28~0x2D |
| 输出数据有效位 | 12位左对齐,低4位恒为0 | 16位右对齐,可配低功耗位宽 | 16位右对齐 |
| 量程范围 | ±2g/±4g/±8g/±16g | ±2g/±4g/±8g/±16g | ±2g/±4g/±8g/±16g |
| 特色功能 | 经典方案,高分辨率模式 | 超低功耗,电源模式丰富 | FIFO、机器学习内核(MLC) |
这个表格基本决定了我后面代码里要做的特殊处理。LIS2DH12的16位输出寄存器实际只有12位有效数据,低4位恒为0,读出来必须右移4位才是有效值;LIS2DW12和LIS2DS12则是完整的16位输出,不能跟着一起移。这个问题不整理清楚,三颗芯片的原始数据就没法统一。
2.3 哪些寄存器必须分开单独处理
除了输出寄存器,ODR配置位和量程配置位在三颗芯片上的编码也不完全一样。比如同样是把ODR设成100Hz,LIS2DH12在CTRL_REG1(0x20)里写的ODR[3:0]是0100,LIS2DW12在CTRL1(0x20)里写的又是另一个编码,LIS2DS12也略有差异。量程配置更是如此,FS位有的在CTRL_REG4(0x23),有的在CTRL6,有的在CTRL1里直接带上。
所以在我的代码里,凡是跟ODR、量程、工作模式相关的寄存器写入,一律做成带型号分支的独立函数,绝不共用同一个寄存器值。这个设计在一开始看起来有点啰嗦,但后面换芯片时你会发现,改一行配置代码要比逐条核对寄存器编码省太多时间。
3. 标准C demo代码的分层结构
3.1 文件划分
整套代码我按三层来组织:传输层、驱动层、应用层。目录结构长这样:
lis2_demo/ ├── lis2_hal.h /* 传输层回调函数声明,由MCU工程实现 */ ├── lis2_hal.c /* 对应MCU的I2C/SPI读写,用户自己完成 */ ├── lis2_reg.h /* 三颗芯片共用及差异化的寄存器宏定义 */ ├── lis2_driver.c /* 统一驱动接口实现 */ ├── lis2_driver.h /* 对外API声明 */ ├── demo_main.c /* 演示逻辑 */ └── README.md传输层直接以头文件的方式剥离开,驱动层不依赖任何MCU厂商库,这在标准C工程里移植起来非常省事。你拿到这套代码后,只需要把lis2_hal.c里的两个函数补上,就可以在任意MCU上跑起来。
3.2 传输层回调
核心思路是:驱动层不直接调用你工程里的I2C或SPI收发函数,而是用两个函数指针把传输动作“注入”进来。回调函数定义如下:
typedef int8_t (*lis2_write_ptr)(uint8_t reg, uint8_t *buf, uint16_t len); typedef int8_t (*lis2_read_ptr)(uint8_t reg, uint8_t *buf, uint16_t len);在设备结构体里保存这两个函数指针,驱动层读写寄存器时只跟这个结构体打交道。这样同一套标准C代码,在STM32上可以实现一份lis2_hal.c,在GD32上实现一份,在其它国产MCU上再实现一份,驱动层代码一行都不用动。
3.3 设备结构体与统一API
设备结构体我保持得尽量精简,只保存驱动真正需要的东西:
typedef enum { LIS2_TYPE_DH12 = 0, LIS2_TYPE_DW12, LIS2_TYPE_DS12 } lis2_type_t; typedef struct { lis2_type_t dev_type; uint8_t i2c_addr; lis2_write_ptr write; lis2_read_ptr read; uint16_t odr_hz; uint8_t fs_range; /* 0: ±2g, 1: ±4g, 2: ±8g, 3: ±16g */ } lis2_dev_t;对外接口我控制在5个左右,没有绕来绕去的状态机:
int8_t lis2_probe(lis2_dev_t *dev); /* 读WHO_AM_I,识别型号 */ int8_t lis2_init(lis2_dev_t *dev, uint16_t odr_hz, uint8_t fs_range); int8_t lis2_read_accel(lis2_dev_t *dev, lis2_accel_t *accel); int8_t lis2_read_fifo(lis2_dev_t *dev, int16_t out[][3], uint16_t *samples); int8_t lis2_enable_wakeup(lis2_dev_t *dev, uint16_t threshold_mg, uint8_t duration);这些接口足够覆盖绝大多数加速度计使用场景。我不建议在一开始就设计一大堆“可配置项”,在标准C里,简单直接的接口才是最能长期维护的。
4. 初始化、读数据与中断唤醒的编码细节
4.1 初始化流程
初始化流程我固定为四步:读WHO_AM_I识别型号、软复位、等待复位完成、按型号写入ODR和量程配置。核心代码骨架如下:
int8_t lis2_init(lis2_dev_t *dev, uint16_t odr_hz, uint8_t fs_range) { uint8_t id = 0; if (dev->read(dev->i2c_addr, LIS2_WHO_AM_I, &id, 1) != 0) { return LIS2_ERR_BUS; } switch (id) { case LIS2DH12_ID: dev->dev_type = LIS2_TYPE_DH12; break; case LIS2DW12_ID: dev->dev_type = LIS2_TYPE_DW12; break; case LIS2DS12_ID: dev->dev_type = LIS2_TYPE_DS12; break; default: return LIS2_ERR_DEVICE; } /* 软复位:三颗芯片的复位位位置不同,必须按型号执行 */ lis2_soft_reset(dev); lis2_delay_ms(5); /* 写入ODR、量程配置,内部按dev_type分开处理 */ lis2_apply_cfg(dev, odr_hz, fs_range); return LIS2_OK; }这里有个细节:软复位之后不能马上写配置。有些芯片复位后内部逻辑还需要几个毫秒稳定,尤其是I2C总线刚上电时,连续操作容易丢ACK。我实际验证下来,延时5毫秒是比较稳妥的,有些传感器甚至需要更久,如果你发现初始化后读数据不对,先把这个延时拉长到20毫秒试试。
4.2 轮询读加速度的核心逻辑
读加速度数据要判断数据就绪,不能上来就读。三颗芯片的状态寄存器都在0x27,bit0是数据就绪标志(ZYXDA)。轮询模式下的核心代码如下:
int8_t lis2_read_accel(lis2_dev_t *dev, lis2_accel_t *accel) { uint8_t st; uint8_t buf[6] = {0}; if (dev->read(dev->i2c_addr, LIS2_STATUS, &st, 1) != 0) { return LIS2_ERR_BUS; } if ((st & 0x01) == 0) { return LIS2_ERR_NO_DATA; } dev->read(dev->i2c_addr, LIS2_OUT_X_L, buf, 6); accel->x = (int16_t)((uint16_t)buf[1] << 8 | buf[0]); accel->y = (int16_t)((uint16_t)buf[3] << 8 | buf[2]); accel->z = (int16_t)((uint16_t)buf[5] << 8 | buf[4]); /* LIS2DH12 的12位数据左对齐,右移4位消除无效低4位 */ if (dev->dev_type == LIS2_TYPE_DH12) { accel->x >>= 4; accel->y >>= 4; accel->z >>= 4; } return LIS2_OK; }这段代码里最关键的就是那个按型号判断的右移4位。很多人在LIS2DH12上读到的数据看似正常,但分辨率低了4倍,就是漏了这一步。
FIFO读取的思路类似,只是把读单次6字节改成连续读若干个采样点,核心是配置好FIFO模式和中断水位。我很建议在demo阶段就把FIFO链路调通,因为在低功耗模式下CPU可以长时间睡觉,攒一批数据再唤醒处理,对功耗的影响非常明显。
4.3 中断唤醒配置
INT1中断唤醒是我这套代码里最有实际意义的一个功能。配置思路是:先把INT1引脚映射到唤醒检测事件,再设置阈值和持续时间,最后开启中断使能。三颗芯片的中断映射寄存器不完全相同,但逻辑是一致的。
以运动唤醒为例,需要设置三个关键参数:唤醒阈值、持续时间、中断使能位。阈值决定“动到什么程度算唤醒”,通常用毫克(mg)表示,比如跑步时加速度变化能到几百mg,放在桌上的振动只有几十mg。持续时间用来过滤毛刺,防止电机启动瞬间导致的误唤醒。这些寄存器值的计算方式在数据手册里都有公式,demo阶段可以先挑一个阈值实测,再根据数据调整。
中断唤醒配置好之后,MCU可以进入低功耗睡眠模式,等INT1引脚拉高再唤醒读数据。这样既保证了事件不漏报,又不会因为频繁轮询浪费电流,可穿戴设备和电池供电的传感器节点特别吃这一套。
5. 移植到不同MCU时的适配经验
5.1 I2C地址与SDO引脚的坑
三颗芯片的7位I2C地址都是靠SDO/SA0引脚决定的:引脚接低地址是0x18,接高地址是0x19。这个设计本身不复杂,但硬件上经常出问题。
最常见的情况是SDO引脚悬空。SDO在I2C模式下是地址选择脚,悬空会导致地址不稳定,有时读到0x18有时读到0x19,初始化时好时坏。另外一个坑是SPI模式下的SDO脚,它同时又是数据输出线,如果你在SPI模式下去读“SA0电平”来选择地址,那八成是搞错了引脚定义。每次画板之前都对着数据手册把SDO/SA0的复用关系确认一遍,比在软件里反复找问题快得多。
5.2 SPI读写位序与多字节传输
ST的MEMS传感器走SPI时,读写命令的第一个字节很有讲究:bit7为1表示读操作,bit6为1表示多字节自动递增读。也就是说,读单字节寄存器要发0x80 | reg,连续读6字节加速度数据要发0xC0 | 0x28。
很多第一次用ST传感器的工程师,SPI模式读不出数据,就是因为只发了寄存器地址,忘了置位读写标志位。I2C模式没有这个问题,所以大家往往先在I2C上调通,再切到SPI就卡住了。移植时务必把这两点写清楚,尤其是在多字节读取时,SPI的MOSI线上要先看到0xC0 | reg这个首字节。
5.3 从官方驱动“做减法”的移植思路
如果你之前用的是官方驱动,想迁移到这套精简代码,最直接的办法不是从零重写,而是把官方驱动里的寄存器宏定义全量复制到自己的头文件里,逻辑部分按自己的接口重新组织。官方驱动经过充分测试,寄存器地址和位定义基本可信,我们只需要把它庞大的状态机丢掉,换成自己熟悉的流程。
这样“做减法”之后,你会发现代码量能缩到原来的三分之一都不到,可读性反而大幅提升。遇到问题时,直接翻数据手册对照自己的代码,比在官方驱动的函数调用链里追查快得多。
6. 实测验证与几个容易被忽略的坑
6.1 判断加速度数据是否正确
代码写完后,第一件事不是接上位机,而是用最朴素的方法验证数据。把传感器平放在桌面上,Z轴输出应该接近1g,也就是约9.8 m/s²;翻过来放,Z轴符号要反转。X轴和Y轴在水平静止时应该接近0,但需要看PCB贴装方向,如果传感器在板子上转了90度,轴的定义也会跟着变。
用原始LSB数值换算成物理量时,要注意先处理掉LIS2DH12的右移4位,再乘以灵敏度。很多人在这一步算出上百g的离谱数据,基本都是有效位处理错了,而不是传感器坏了。
6.2 低功耗模式下的输出更新率
LIS2DW12的低功耗模式特别丰富,不同电源模式下ODR上限、噪声、唤醒电流都不一样。这里有一个容易忽略的细节:切换电源模式或ODR之后,输出数据并不会立刻按新配置更新,芯片内部可能需要几个ODR周期才能稳定。如果程序上电后立刻读数据,很可能拿到的是配置切换前的旧数据。
我的建议是每次配置完传感器后,先连续丢弃前面几组数据,再开始正常采数。或者在读取时严格检查状态寄存器的数据就绪位,没有就绪就不读,宁可多等一个周期,也不能拿旧数据去做算法。
6.3 防御式编程与集成时的细节
标准C代码在嵌入式的集成阶段,真正浪费时间的往往不是传感器本身,而是总线上那些边界情况。我在驱动里做了三件事来避免排查困难:第一,传输层回调函数的返回值全部判断,一旦I2C或SPI返回错误,驱动立刻返回错误码,绝不静默吞掉;第二,读数据时加超时保护,避免在总线异常时无限死循环,这个在低功耗唤醒场景里非常重要;第三,编译时开最高级别警告,把int16_t和uint16_t之间的隐式转换全部处理干净。
这也是为什么标题里强调“标准C实现”——在各类MCU工程里,能用标准C把驱动做得干净利落,远比依赖厂商封装库更容易跨平台复用。实际用下来,这套代码在STM32和GD32上都能直接跑通,差别只是两个传输层回调函数的实现。以后再遇到ST系列其它传感器,只要寄存器风格接近,我大概率会沿用同样的分层思路来做。
本文还有配套的精品资源,点击获取