从零构建可移植SHT21传感器驱动:工程化实践与I2C协议解析
2026/9/5 23:42:25 网站建设 项目流程

简介:本资源为SHT21温湿度传感器的嵌入式驱动开发套件,面向物联网开发者、嵌入式初学者及高校电子类课程实践者,解决I²C接口传感器快速集成与数据精准解析的核心问题。压缩包共80个文件,含4个C源文件(如sht21.c、i2c.c)、3个头文件(sht21.h、i2c.h等)、2个Makefile构建脚本、2个主程序示例(example/目录)、11个测试样例及LICENSE、README.md等工程必备文档,整体仅222KB,轻量易集成。已有8096人学习下载,说明其在教学与原型开发中具备广泛验证基础。读者可直接复用已调试通过的I²C初始化、测量触发、CRC校验、原始值转工程单位(%RH/℃)等关键函数,并参考bcm2835.c等平台适配代码快速移植至树莓派等ARM平台;目录结构清晰体现“驱动层–硬件抽象层–应用示例”分层设计,便于理解传感器通信协议实现逻辑与错误处理机制。

1. 项目概述:一份被遗忘的传感器驱动代码

最近在整理一个老旧的嵌入式项目备份时,我翻到了一个名为SHT21.zip的压缩包。解压后,里面是SHT21温湿度传感器的驱动源代码,发件人邮箱是shtfab@gamil.comshtfab@gmail.cim,看起来像是某个工程师在项目交接或社区分享时匆忙打包的,连邮箱地址都打错了。这个压缩包本身没什么特别,但它背后代表的——如何将一份来源模糊、可能不完整的嵌入式驱动代码,整合到一个现代、可维护的工程中——却是每个嵌入式开发者都会遇到的经典问题。SHT21作为一款经典的I2C接口数字温湿度传感器,精度高、体积小,在物联网、环境监测等领域应用极广。但网上找到的驱动代码质量参差不齐,有的只是一个简单的.c文件,有的则包含了完整的工程框架。这份SHT21.zip很可能就是前者,它需要一个“家”,一个由Makefile管理、用git进行版本控制的清晰项目结构。接下来,我就以处理这份“遗产代码”为线索,分享如何从零搭建一个规范、可移植的SHT21传感器驱动模块,并集成到你的项目中。

2. 代码遗产处理:评估与解构

拿到一份陌生的源代码,尤其是这种以个人邮箱命名、附带明显拼写错误的压缩包,第一步绝不是直接拿来编译。盲目的信任会带来无尽的调试深渊。我们需要像考古学家一样,先进行细致的评估与解构。

2.1 初步审查与风险评估

首先,在虚拟机或隔离的开发环境中解压SHT21.zip。立刻检查目录结构。一个理想的驱动模块应该至少包含:源文件(如sht21.c)、头文件(sht21.h)、可能有的示例文件(example.c)和一个说明文档(README.md)。但根据我的经验,这份“遗产”很可能只有孤零零的一两个文件。

用文本编辑器打开sht21.c,快速浏览。重点关注以下几点:

  1. 许可证信息:文件开头是否有明确的许可证声明(如GPL, MIT, BSD)?如果没有,用于商业项目存在风险。
  2. 依赖关系:代码中是否直接包含了类似#include “stm32f1xx_hal.h”#include <wiringPiI2C.h>的硬件抽象层或平台特定头文件?这决定了代码的移植性。
  3. 函数接口:查找初始化、读取温湿度的核心函数,例如SHT21_Init(),SHT21_ReadTemperature(),SHT21_ReadHumidity()。观察它们的参数和返回值是否清晰。
  4. 代码质量:是否有基础的错误处理?I2C通信失败后是直接返回错误值还是死循环?代码注释是否清晰?

注意:对于来源不明的代码,务必警惕“恶意代码”或“有缺陷的实现”。例如,检查是否有无限循环、内存操作越界、或对硬件寄存器进行危险写操作的嫌疑。最好先在不重要的开发板上测试。

2.2 核心逻辑提取与平台抽象化

审查后,我们很可能发现这份代码是“裸奔”的,即直接调用了特定平台(如STM32的HAL库、Linux的ioctl)的I2C读写函数。我们的目标是将这些平台相关的部分SHT21传感器本身的通信协议逻辑分离开。

SHT21协议逻辑是核心,它独立于硬件平台,包括:

  • 传感器地址:SHT21的7位I2C地址是0x40
  • 命令字:如触发温度测量(0xF3)、触发湿度测量(0xF5)、读用户寄存器(0xE7)等。
  • 数据格式:读取的数据是14位(温度)和12位(湿度),需要根据数据手册中的公式进行转换。
  • CRC校验:SHT21返回的数据包含CRC校验字节,可靠的驱动应该实现校验功能。

而平台相关部分,就是实现下面两个最基本的I2C底层操作:

  1. i2c_write(uint8_t dev_addr, uint8_t *data, uint16_t len)
  2. i2c_read(uint8_t dev_addr, uint8_t *data, uint16_t len)

我们的任务就是重构代码,将SHT21的协议逻辑封装成独立的模块,而将这两个底层函数作为“依赖注入”的接口。这样,同一份SHT21驱动代码,只需提供不同的底层实现,就能在STM32、ESP32、Linux用户态等多种环境中运行。

3. 工程化构建:从零编写Makefile

有了清晰的代码结构规划后,我们需要一个构建工具将蓝图变为现实。对于中小型嵌入式C项目,Makefile依然是轻量且强大的选择。它定义了源代码如何编译、链接成最终的可执行文件或库。

3.1 Makefile基础结构与核心变量

一个典型的项目Makefile结构如下,我们以在Linux环境下编译一个测试程序为例:

# 编译器定义 CC = gcc # 编译选项:启用所有警告、调试信息、C99标准 CFLAGS = -Wall -g -std=c99 # 头文件搜索路径 INCLUDES = -I./include -I./drivers # 链接库路径 LDFLAGS = # 链接库 LDLIBS = -lm # 目标可执行文件 TARGET = sht21_test # 源文件 (自动查找所有.c文件) SRCS = $(wildcard src/*.c drivers/*.c) # 对象文件 (.o文件) OBJS = $(SRCS:.c=.o) # 默认目标:构建最终的可执行文件 all: $(TARGET) # 链接规则:将所有的.o文件链接成可执行文件 $(TARGET): $(OBJS) $(CC) $(LDFLAGS) -o $@ $^ $(LDLIBS) # 编译规则:将每个.c文件编译成.o文件 %.o: %.c $(CC) $(CFLAGS) $(INCLUDES) -c $< -o $@ # 清理构建产物 clean: rm -f $(OBJS) $(TARGET) # 伪目标声明,防止有同名文件时规则不执行 .PHONY: all clean

关键变量解析

  • CC,CFLAGS: 这是控制编译行为的核心。-Wall开启所有警告,能帮你发现很多潜在问题。-g加入调试信息,为后续使用gdb调试做准备。
  • INCLUDES: 使用-I指定头文件目录。良好的项目应将公共头文件放在include/,模块头文件放在各自目录(如drivers/sht21.h)。
  • $@,$^,$<: 这是Makefile的自动变量。$@代表目标文件,$^代表所有依赖文件,$<代表第一个依赖文件。掌握它们能写出非常简洁的规则。
  • wildcard: 函数用于自动匹配目录下所有.c文件,避免手动罗列,当新增源文件时,Makefile无需修改。

3.2 为驱动模块创建子Makefile

对于更复杂的项目,我们可以采用层次化的Makefile管理。例如,在drivers/目录下为SHT21驱动单独创建一个Makefile,将其编译为静态库(.a文件),方便链接和复用。

drivers/Makefile示例:

# 驱动模块的Makefile CC = gcc AR = ar rcs CFLAGS = -Wall -g -std=c99 -I../include # 驱动源文件 SRCS = sht21.c i2c_linux_impl.c # 假设这是Linux平台的I2C实现 OBJS = $(SRCS:.c=.o) # 目标静态库 TARGET = libsht21drv.a all: $(TARGET) # 创建静态库 $(TARGET): $(OBJS) $(AR) $@ $^ %.o: %.c $(CC) $(CFLAGS) -c $< -o $@ clean: rm -f $(OBJS) $(TARGET) .PHONY: all clean

在主Makefile中,可以这样包含和链接这个库:

# 在主Makefile中 SUBDIRS = drivers ... # 构建所有子目录 $(SUBDIRS): $(MAKE) -C $@ # 主目标依赖子目录的构建 all: $(SUBDIRS) $(TARGET) # 链接时加上驱动库 $(TARGET): $(OBJS) | $(SUBDIRS) $(CC) $(LDFLAGS) -o $@ $(OBJS) -L./drivers -lsht21drv $(LDLIBS)

这种结构清晰地将驱动模块与主应用分离,非常适合模块化开发。

4. 版本控制入门:Git实战管理

代码和构建脚本都准备好了,现在必须请出版本控制的“时光机”——Git。没有Git的项目就像在悬崖边行走,一次误删或错误的修改就可能让你前功尽弃。

4.1 仓库初始化与基础工作流

在你的项目根目录下,执行git init。这会创建一个隐藏的.git文件夹,记录所有的版本信息。接下来是标准的首次提交流程:

# 1. 将当前目录所有文件添加到暂存区(除了.gitignore中定义的) git add . # 2. 提交到本地仓库,并附上清晰的提交信息 git commit -m “初始提交:添加SHT21驱动模块、Makefile构建系统和基础示例程序”

提交信息的艺术:提交信息应简短清晰,首行总结改动,空一行后可以写详细描述。例如:

feat(driver): add platform-abstracted SHT21 driver - Extract SHT21 protocol logic from legacy code. - Define `i2c_read/write` interface for platform abstraction. - Add Linux implementation using `ioctl`. - Add basic CRC8 check function.

清晰的历史记录在未来回溯问题、理解代码演进时价值连城。

4.2 .gitignore文件:保持仓库清洁

一个必须创建的文件是.gitignore。它告诉Git哪些文件或目录不应该被纳入版本控制。对于我们的C项目,一个典型的.gitignore如下:

# 编译产物 *.o *.a *.so *.out *.exe sht21_test # 构建目录 build/ dist/ # 编辑器临时文件 *~ .*.swp .vscode/ .idea/ # 系统文件 .DS_Store Thumbs.db # 本地配置文件(不应共享) config.local.h

创建并配置好.gitignore后,再次执行git status,你会发现仓库干净了许多,只显示我们真正关心的源代码和脚本文件。

4.3 分支策略:隔离开发与修复

永远不要在main(或master) 分支上直接进行功能开发或bug修复。合理的分支策略是高效协作的基石。

# 1. 基于main创建新功能分支 git checkout -b feature/sht21-crc-verification # 在此分支上开发CRC校验功能... git add . git commit -m “feat: add comprehensive CRC-8 verification for SHT21 data” # 2. 切换到main分支,创建修复分支 git checkout main git checkout -b hotfix/read-timeout # 在此分支上修复I2C读取超时问题... git add . git commit -m “fix(i2c): add timeout mechanism to prevent blocking” # 3. 开发完成后,合并回main分支 git checkout main git merge feature/sht21-crc-verification # 如果合并有冲突,需要手动解决冲突文件,然后 `git add` 和 `git commit`

使用git stash可以临时保存未提交的修改并清空工作区,这在需要紧急切换分支时非常有用:git stash保存现场,git stash pop恢复现场。

5. 驱动实现详解:SHT21与I2C协议

现在,让我们深入最核心的部分:实现一个健壮、可移植的SHT21驱动。我们将遵循之前提到的“平台抽象”原则。

5.1 头文件设计:定义清晰的接口

首先创建include/sht21.h,它是对外提供的接口。

#ifndef SHT21_H #define SHT21_H #include <stdint.h> #include <stdbool.h> // 可能的错误码 typedef enum { SHT21_OK = 0, SHT21_ERR_I2C, SHT21_ERR_CRC, SHT21_ERR_TIMEOUT, } sht21_err_t; // 传感器句柄,用于存储状态和平台特定的I2C信息 typedef struct { uint8_t i2c_addr; // 设备地址,默认0x40 void *i2c_handle; // 指向平台特定I2C句柄的指针,如Linux的文件描述符或HAL的I2C_HandleTypeDef* } sht21_dev_t; // 平台必须实现的底层I2C操作函数 // 这些函数指针将在初始化时注册给驱动 typedef sht21_err_t (*i2c_write_func)(void *handle, uint8_t dev_addr, const uint8_t *data, uint16_t len); typedef sht21_err_t (*i2c_read_func)(void *handle, uint8_t dev_addr, uint8_t *data, uint16_t len); // 初始化传感器,注册底层函数 sht21_err_t sht21_init(sht21_dev_t *dev, uint8_t i2c_addr, void *i2c_handle, i2c_write_func write_fn, i2c_read_func read_fn); // 触发一次温度测量并读取结果(阻塞式) sht21_err_t sht21_read_temperature(sht21_dev_t *dev, float *temperature_c); // 触发一次湿度测量并读取结果(阻塞式) sht21_err_t sht21_read_humidity(sht21_dev_t *dev, float *humidity_rh); // 软件复位传感器 sht21_err_t sht21_soft_reset(sht21_dev_t *dev); #endif // SHT21_H

这个头文件的关键在于sht21_dev_t结构体和两个函数指针类型。它不包含任何具体的平台I2C代码,驱动逻辑将通过调用注册进来的write_fnread_fn来与硬件通信。

5.2 源文件实现:协议逻辑与CRC校验

drivers/sht21.c中实现核心逻辑。

#include “sht21.h” #include <unistd.h> // for usleep (Linux delay) // SHT21命令字 #define SHT21_CMD_TRIGGER_TEMP_MEASURE_HOLD 0xE3 #define SHT21_CMD_TRIGGER_HUMI_MEASURE_HOLD 0xE5 #define SHT21_CMD_SOFT_RESET 0xFE // 内部函数:计算CRC-8校验和,多项式为 x^8 + x^5 + x^4 + 1 (0x31) static uint8_t sht21_crc8(const uint8_t *data, uint32_t len) { uint8_t crc = 0x00; for (uint32_t i = 0; i < len; i++) { crc ^= data[i]; for (uint8_t bit = 8; bit > 0; --bit) { if (crc & 0x80) { crc = (crc << 1) ^ 0x31; } else { crc = (crc << 1); } } } return crc; } sht21_err_t sht21_init(sht21_dev_t *dev, uint8_t i2c_addr, void *i2c_handle, i2c_write_func write_fn, i2c_read_func read_fn) { if (!dev || !write_fn || !read_fn) { return SHT21_ERR_I2C; } dev->i2c_addr = i2c_addr; dev->i2c_handle = i2c_handle; // 通常这里还会存储函数指针到dev结构体中,为了简化,假设是全局变量或通过其他方式管理。 // 实际项目中,可能需要一个更复杂的上下文结构体。 return SHT21_OK; } static sht21_err_t _sht21_read_measurement(sht21_dev_t *dev, uint8_t cmd, float *result, float (*convert)(uint16_t)) { uint8_t tx_cmd = cmd; uint8_t rx_buf[3]; sht21_err_t err; // 1. 发送测量命令 err = dev->write_fn(dev->i2c_handle, dev->i2c_addr, &tx_cmd, 1); if (err != SHT21_OK) return SHT21_ERR_I2C; // 2. 等待测量完成(SHT21典型时间:温度最大85ms,湿度最大29ms) usleep(100000); // 等待100ms,确保完成 // 3. 读取3个字节的数据(2字节数据 + 1字节CRC) err = dev->read_fn(dev->i2c_handle, dev->i2c_addr, rx_buf, 3); if (err != SHT21_OK) return SHT21_ERR_I2C; // 4. 验证CRC:对前两个数据字节进行校验 if (sht21_crc8(rx_buf, 2) != rx_buf[2]) { return SHT21_ERR_CRC; } // 5. 组合数据并转换 uint16_t raw_value = (rx_buf[0] << 8) | rx_buf[1]; raw_value &= 0xFFFC; // 清除状态位(低两位为状态位) *result = convert(raw_value); return SHT21_OK; } // 转换函数 static float _convert_temperature(uint16_t raw) { return -46.85 + 175.72 * ((float)raw / 65536.0); } static float _convert_humidity(uint16_t raw) { return -6.0 + 125.0 * ((float)raw / 65536.0); } sht21_err_t sht21_read_temperature(sht21_dev_t *dev, float *temperature_c) { return _sht21_read_measurement(dev, SHT21_CMD_TRIGGER_TEMP_MEASURE_HOLD, temperature_c, _convert_temperature); } sht21_err_t sht21_read_humidity(sht21_dev_t *dev, float *humidity_rh) { return _sht21_read_measurement(dev, SHT21_CMD_TRIGGER_HUMI_MEASURE_HOLD, humidity_rh, _convert_humidity); }

这个实现包含了几个关键点:CRC校验确保了数据在传输过程中的完整性;统一的测量函数_sht21_read_measurement避免了代码重复;转换公式直接来自SHT21数据手册。阻塞式的usleep等待在实时性要求高的系统中可能需要改为非阻塞状态查询,但作为起点,这样最简单可靠。

5.3 提供平台特定实现

最后,我们需要为特定平台实现i2c_write_funci2c_read_func。以Linux用户态为例,创建drivers/i2c_linux_impl.c

#include <fcntl.h> #include <linux/i2c-dev.h> #include <sys/ioctl.h> #include <unistd.h> #include “sht21.h” // 假设i2c_handle在这里就是文件描述符(int) sht21_err_t linux_i2c_write(void *handle, uint8_t dev_addr, const uint8_t *data, uint16_t len) { int fd = *(int*)handle; if (ioctl(fd, I2C_SLAVE, dev_addr) < 0) { return SHT21_ERR_I2C; } if (write(fd, data, len) != len) { return SHT21_ERR_I2C; } return SHT21_OK; } sht21_err_t linux_i2c_read(void *handle, uint8_t dev_addr, uint8_t *data, uint16_t len) { int fd = *(int*)handle; if (ioctl(fd, I2C_SLAVE, dev_addr) < 0) { return SHT21_ERR_I2C; } if (read(fd, data, len) != len) { return SHT21_ERR_I2C; } return SHT21_OK; }

这样,驱动层就完全与平台解耦了。要移植到STM32,只需新写一个i2c_stm32_hal_impl.c,实现相同的两个函数,内部调用HAL库的HAL_I2C_Master_TransmitHAL_I2C_Master_Receive即可。

6. 集成测试与问题排查

代码写好了,Makefile也能编译通过了,但真正的挑战才刚刚开始:让它正确地跑起来。集成测试是暴露问题的最佳环节。

6.1 编写测试程序与硬件连接

创建一个简单的测试程序src/main.c

#include <stdio.h> #include <stdlib.h> #include <fcntl.h> #include “sht21.h” // 声明平台实现函数 sht21_err_t linux_i2c_write(void *handle, uint8_t dev_addr, const uint8_t *data, uint16_t len); sht21_err_t linux_i2c_read(void *handle, uint8_t dev_addr, uint8_t *data, uint16_t len); int main() { // 1. 打开Linux I2C设备文件(例如I2C总线1) const char *i2c_bus = “/dev/i2c-1”; int i2c_fd = open(i2c_bus, O_RDWR); if (i2c_fd < 0) { perror(“Failed to open I2C bus”); return EXIT_FAILURE; } // 2. 初始化SHT21设备结构体 sht21_dev_t sensor; sht21_err_t err = sht21_init(&sensor, 0x40, &i2c_fd, linux_i2c_write, linux_i2c_read); if (err != SHT21_OK) { fprintf(stderr, “Sensor init failed: %d\n”, err); close(i2c_fd); return EXIT_FAILURE; } // 3. 循环读取数据 for (int i = 0; i < 10; i++) { float temp, humi; err = sht21_read_temperature(&sensor, &temp); if (err == SHT21_OK) { printf(“Temperature: %.2f C\t”, temp); } else { printf(“Temp read error: %d\t”, err); } err = sht21_read_humidity(&sensor, &humi); if (err == SHT21_OK) { printf(“Humidity: %.2f %%RH\n”, humi); } else { printf(“Humi read error: %d\n”, err); } sleep(2); // 间隔2秒 } close(i2c_fd); return EXIT_SUCCESS; }

硬件连接:将SHT21模块(例如常见的GY-21模块)连接到树莓派或Linux开发板的I2C引脚上。通常:

  • VCC -> 3.3V
  • GND -> GND
  • SDA -> I2C总线的SDA线(如树莓派GPIO2)
  • SCL -> I2C总线的SCL线(如树莓派GPIO3)

在Linux上,需要先启用I2C驱动并安装i2c-tools

sudo apt-get install i2c-tools sudo i2cdetect -y 1 # 扫描I2C总线1上的设备,应能看到地址0x40

6.2 常见问题与调试技巧实录

在实际操作中,你几乎一定会遇到下面这些问题。这里是我的排查实录:

问题1:编译通过,但运行时报Permission denied打开/dev/i2c-1失败。

  • 原因:普通用户默认没有访问I2C设备文件的权限。
  • 解决
    1. 临时解决:使用sudo运行程序。
    2. 永久解决:将用户加入i2c用户组。
      sudo usermod -aG i2c $(whoami) # 然后需要注销并重新登录,或者使用 newgrp i2c 命令使组生效

问题2:i2cdetect能看到设备(0x40),但程序读取失败,返回I2C错误。

  • 排查步骤
    1. 检查接线:确保SDA、SCL没有接反,接触良好。用万用表测量VCC是否为稳定的3.3V。
    2. 检查上拉电阻:I2C总线需要上拉电阻(通常4.7kΩ-10kΩ)。很多模块已内置,如果使用裸传感器芯片,必须外接。
    3. 逻辑分析仪抓波形:这是终极武器。连接逻辑分析仪的通道到SDA和SCL,查看起始信号、地址字节(0x40写地址是0x80,读地址是0x81)、ACK信号、数据波形是否正常。我遇到过因为电源噪声导致SCL波形畸变,通信失败的情况。
    4. 在代码中添加调试打印:在linux_i2c_write/read函数内部,打印出每次发送/接收的原始字节,与逻辑分析仪抓到的波形对比。

问题3:数据能读取,但温湿度值明显不对(例如温度是85°C,湿度是120%)。

  • 原因:这是最经典的问题,几乎都是数据解析错误
  • 排查
    1. 检查CRC:首先确认你的CRC校验函数是否正确。可以用数据手册中的例子验证:对于数据0x68 0x3A,CRC校验字节应为0x7C。如果你的函数算不出来,那就是CRC实现有误。
    2. 检查数据组合:SHT21返回的3个字节是[MSB, LSB, CRC]。需要将MSB和LSB组合成一个16位整数(MSB << 8) | LSB
    3. 清除状态位:组合后的16位整数的最低两位是状态位,必须清零raw_value &= 0xFFFC;),否则计算出的值会完全错误。
    4. 检查转换公式:确保使用的是正确的公式,并且进行浮点数运算。65536.0而不是65536,否则是整数除法,结果永远为0。

问题4:程序第一次读取正常,后续读取全部失败。

  • 原因:SHT21在完成一次“保持主机”模式测量后,会进入空闲状态。但如果I2C通信在读取过程中异常终止(如程序崩溃),传感器可能卡在某种状态。
  • 解决:在初始化或错误恢复时,调用sht21_soft_reset函数(发送0xFE命令),让传感器恢复初始状态。这个函数在我们的头文件中已声明,实现起来就是发送一个单字节命令。

问题5:Makefile修改后,编译似乎没有生效。

  • 原因:Makefile依赖关系没写好,或者中间文件(.o)已存在且时间戳比源文件新。
  • 解决
    1. 先执行make clean,清除所有旧的编译产物。
    2. 再执行make重新编译。
    3. 检查Makefile规则,确保目标文件正确地依赖于其对应的源文件和头文件。例如:
      sht21.o: sht21.c sht21.h i2c_platform.h $(CC) $(CFLAGS) -c $< -o $@
      这样,当sht21.h改变时,sht21.o也会被重新编译。

通过以上步骤,你应该能将那份来源模糊的SHT21.zip源代码,改造为一个工程结构清晰、平台可移植、版本可控的优质驱动模块。这个过程本身,就是嵌入式开发中一项极其重要的能力——消化、重构并集成第三方代码。记住,好的代码不是写出来的,是不断重构和打磨出来的。每次遇到问题并解决它,你对硬件、协议和软件的理解就会更深一层。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询