4. 传感器数据采集:ADC采样原理、I2C/SPI总线读取传感器数据、数据滤波(均值/中值)
好,咱们进入第四讲。这一章是真正开始「动手」的关键环节。传感器装好了,怎么把它的「感觉」变成单片机认识的数字?说白了,就是数据采集。
我刚开始做窗帘电机项目时,最头疼的不是电机控制,而是传感器数据怎么读得准。光线传感器忽明忽暗,霍尔传感器偶尔跳变,搞得窗帘跟抽风似的。后来才明白,采集这一步没做好,后面算法再牛也白搭。
4.1 ADC采样原理——把模拟量变成数字量
窗帘电机里最常见的模拟传感器,就是光敏电阻和电位器(用来检测窗帘位置)。它们输出的是电压,而单片机只认0和1。ADC(模数转换器)就是干这个的。
ADC的核心参数,我列个表,大家一看就明白:
| 参数 | 说明 | 我的经验值 |
|---|---|---|
| 分辨率 | 多少位?10位就是0~1023,12位就是0~4095 | 窗帘电机用10位够用,12位更稳 |
| 采样率 | 每秒采多少次?单位Hz | 光线传感器100Hz足够,别浪费CPU |
| 参考电压 | ADC能测量的最大电压 | 一般用3.3V或内部参考 |
| 转换时间 | 从开始采样到出结果的时间 | 几微秒到几十微秒,看芯片 |
嗯,这里要注意一个坑:参考电压一定要稳定。我曾经在一个项目里直接用板子的3.3V供电做参考,结果电机一启动,电压掉到3.1V,ADC读数直接飘了10%。后来老老实实加了外部基准电压芯片,问题才解决。
ADC采样代码示例(STM32 HAL库):
// 初始化ADC,单次转换模式
HAL_ADC_Start(&hadc1);
// 等待转换完成
HAL_ADC_PollForConversion(&hadc1, 100);
// 读取原始值(0~4095)
uint16_t adc_value = HAL_ADC_GetValue(&hadc1);
// 转换成电压值(假设参考电压3.3V)
float voltage = (float)adc_value * 3.3f / 4095.0f;
你想想看,如果直接拿这个原始值去控制窗帘,那画面太美不敢看。光线稍微抖一下,窗帘就跟着晃。所以,滤波是必须的。
4.2 I2C/SPI总线读取传感器数据
窗帘电机里很多传感器是数字输出的,比如温湿度传感器(SHT30)、加速度计(MPU6050)、磁编码器(AS5600)。它们通过I2C或SPI总线跟单片机通信。
I2C和SPI怎么选? 我个人习惯这样判断:
- I2C:两根线(SDA、SCL),可以挂多个设备。速度一般400kHz或1MHz。适合温湿度、光线这类低速传感器。接线少,省IO口。
- SPI:四根线(MOSI、MISO、SCK、CS),全双工,速度可以到几十MHz。适合加速度计、磁编码器这类需要高速读取的传感器。但每个设备要一个片选引脚。
我记得有一次调试窗帘的磁编码器,用I2C读位置数据,结果电机一转,数据就丢包。查了半天,发现是I2C总线太长,加上电机干扰。换成SPI后,问题迎刃而解。所以,有电机的地方,优先考虑SPI。
避坑指南: 我曾经在I2C总线上忘记加上拉电阻,结果数据全是0xFF。查了两天才发现。记住:I2C的SDA和SCL必须接上拉电阻(一般4.7kΩ),否则没法工作。
I2C读取传感器示例(以SHT30温湿度传感器为例):
// SHT30地址:0x44(ADDR引脚接GND)
#define SHT30_ADDR 0x44 << 1
// 发送测量命令(0x2C, 0x06)
uint8_t cmd[2] = {0x2C, 0x06};
HAL_I2C_Master_Transmit(&hi2c1, SHT30_ADDR, cmd, 2, 100);
// 等待测量完成(至少15ms)
HAL_Delay(20);
// 读取6字节数据(温度高8位、温度低8位、CRC、湿度高8位、湿度低8位、CRC)
uint8_t buf[6];
HAL_I2C_Master_Receive(&hi2c1, SHT30_ADDR, buf, 6, 100);
// 计算温度(单位:℃)
uint16_t temp_raw = (buf[0] << 8) | buf[1];
float temperature = -45.0f + 175.0f * temp_raw / 65535.0f;
// 计算湿度(单位:%RH)
uint16_t hum_raw = (buf[3] << 8) | buf[4];
float humidity = 100.0f * hum_raw / 65535.0f;
这里有个细节:很多传感器带CRC校验。我建议不要偷懒跳过CRC。电机产生的电磁干扰很容易让数据出错,CRC能帮你过滤掉明显错误的数据。
4.3 数据滤波——让传感器数据「冷静」下来
原始数据直接拿来用?不行的。传感器噪声、电源波动、机械振动,都会让数据跳来跳去。滤波就是让数据变得平滑、可靠。
窗帘电机里最常用的两种滤波方法:均值滤波和中值滤波。
4.3.1 均值滤波
原理很简单:连续采N次,取平均值。适合抑制随机噪声,比如光线传感器的白噪声。
代码实现:
#define FILTER_SIZE 10 // 滤波窗口大小
uint16_t filter_buffer[FILTER_SIZE];
uint8_t filter_index = 0;
uint16_t mean_filter(uint16_t new_value) {
uint32_t sum = 0;
// 存入最新值
filter_buffer[filter_index] = new_value;
filter_index = (filter_index + 1) % FILTER_SIZE;
// 求和
for (int i = 0; i < FILTER_SIZE; i++) {
sum += filter_buffer[i];
}
// 返回平均值
return (uint16_t)(sum / FILTER_SIZE);
}
嗯,这里要注意:窗口大小不是越大越好。我试过用50点的均值滤波,数据是平滑了,但响应慢得像蜗牛。窗帘都拉到一半了,光线值还没更新完。一般10~20点比较合适。
4.3.2 中值滤波
均值滤波怕什么?怕「野值」。比如霍尔传感器偶尔跳一个很大的数,均值滤波会被带偏。中值滤波就不怕——它取中间值,把异常点直接扔掉。
代码实现:
#define MEDIAN_SIZE 5 // 一般取奇数
uint16_t median_filter(uint16_t new_value) {
static uint16_t buffer[MEDIAN_SIZE];
static uint8_t index = 0;
uint16_t temp[MEDIAN_SIZE];
// 存入最新值
buffer[index] = new_value;
index = (index + 1) % MEDIAN_SIZE;
// 拷贝到临时数组并排序
memcpy(temp, buffer, sizeof(temp));
// 冒泡排序(数据量小,够用)
for (int i = 0; i < MEDIAN_SIZE - 1; i++) {
for (int j = 0; j < MEDIAN_SIZE - 1 - i; j++) {
if (temp[j] > temp[j + 1]) {
uint16_t t = temp[j];
temp[j] = temp[j + 1];
temp[j + 1] = t;
}
}
}
// 返回中间值
return temp[MEDIAN_SIZE / 2];
}
警告: 中值滤波的窗口大小建议3~7点。窗口太大,排序耗时增加,而且会丢失细节。我曾经在电机位置检测上用9点中值滤波,结果窗帘停的位置总是差几度。后来改成5点,问题解决。
4.3.3 实际应用中的选择
我一般这样用:
- 光线传感器:均值滤波(10点),因为噪声是随机的,均值效果好。
- 霍尔位置传感器:中值滤波(5点),因为偶尔有脉冲干扰,中值能剔除。
- 温湿度传感器:均值滤波(5点),温湿度变化慢,不需要太强滤波。
- 加速度计:先中值滤波(3点)去野值,再均值滤波(10点)平滑。
说白了,没有万能的滤波方法。你得根据传感器的特性和应用场景来选。我建议在调试阶段,把原始数据和滤波后的数据同时打印出来,对比着看,很快就能找到最合适的方案。
总结一下本章要点:
- ADC采样要注意参考电压稳定,分辨率选10位或12位够用。
- I2C适合低速、多设备场景;SPI适合高速、抗干扰场景。电机附近优先SPI。
- 均值滤波抑制随机噪声,中值滤波剔除野值。两者可以组合使用。
- 滤波窗口大小要权衡平滑度和响应速度,一般3~20点。
下一章,我们会把这些采集到的数据融合起来,判断窗帘该开还是该关。到时候你会发现,数据采集的质量直接决定了控制效果。所以,这一章的基础一定要打牢。