驱动层开发:SPI/I2C通信协议初始化与触摸数据读取

驱动层是整个触摸校准流程的基石。说白了,没有驱动层正确读取原始坐标,后面所有校准算法都是空中楼阁。我个人习惯把驱动层开发分成四个步骤:协议初始化、原始数据读取、数据滤波去抖、接口封装。咱们一个一个来拆解。

通信协议的选择:SPI vs I2C

触摸芯片和主控之间的通信,主流就两种:SPI和I2C。选哪个?看你的项目需求。

特性 SPI I2C
引脚数量 4线(SCLK, MOSI, MISO, CS) 2线(SCL, SDA)
传输速度 通常10MHz以上 通常400kHz~1MHz
通信方式 全双工 半双工
从机寻址 片选线CS选择 7位/10位地址
抗干扰能力 较强(差分信号可做) 一般(开漏输出)
典型应用 高速触摸屏、多点触控 低功耗、引脚受限场景

我在项目中遇到过一块7寸电容屏,原厂推荐I2C接口。结果实际测试发现,在电磁干扰较强的工业环境下,I2C经常出现数据错位。后来硬着头皮改成了SPI,问题才解决。所以我的建议是:如果引脚够用,优先选SPI。

SPI初始化实战

以STM32平台为例,SPI初始化其实不复杂。但有几个坑,我踩过。

void SPI_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    SPI_HandleTypeDef hspi = {0};

    // 使能时钟
    __HAL_RCC_SPI1_CLK_ENABLE();
    __HAL_RCC_GPIOA_CLK_ENABLE();

    // 配置引脚:SCK-PA5, MISO-PA6, MOSI-PA7, CS-PA4
    GPIO_InitStruct.Pin = GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7;
    GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
    GPIO_InitStruct.Alternate = GPIO_AF5_SPI1;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

    // CS引脚单独配置为推挽输出
    GPIO_InitStruct.Pin = GPIO_PIN_4;
    GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
    HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); // CS拉高

    // SPI配置
    hspi.Instance = SPI1;
    hspi.Init.Mode = SPI_MODE_MASTER;
    hspi.Init.Direction = SPI_DIRECTION_2LINES;
    hspi.Init.DataSize = SPI_DATASIZE_8BIT;
    hspi.Init.CLKPolarity = SPI_POLARITY_LOW;
    hspi.Init.CLKPhase = SPI_PHASE_1EDGE;
    hspi.Init.NSS = SPI_NSS_SOFT;
    hspi.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_8;
    hspi.Init.FirstBit = SPI_FIRSTBIT_MSB;
    hspi.Init.TIMode = SPI_TIMODE_DISABLE;
    hspi.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE;
    hspi.Init.CRCPolynomial = 10;
    HAL_SPI_Init(&hspi);
}

注意:SPI的时钟极性和相位必须和触摸芯片的数据手册严格匹配。我曾经因为CLKPolarity配反了,读回来的数据全是0xFF,排查了整整一天才发现是这个问题。建议初始化前先拿逻辑分析仪抓一下波形,确认时序正确。

I2C初始化实战

I2C初始化相对简单,但要注意上拉电阻。很多新手在这里翻车。

void I2C_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    I2C_HandleTypeDef hi2c = {0};

    // 使能时钟
    __HAL_RCC_I2C1_CLK_ENABLE();
    __HAL_RCC_GPIOB_CLK_ENABLE();

    // 配置引脚:SCL-PB6, SDA-PB7
    GPIO_InitStruct.Pin = GPIO_PIN_6 | GPIO_PIN_7;
    GPIO_InitStruct.Mode = GPIO_MODE_AF_OD;
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
    GPIO_InitStruct.Alternate = GPIO_AF4_I2C1;
    HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);

    // I2C配置
    hi2c.Instance = I2C1;
    hi2c.Init.Timing = 0x00303D5B;  // 100kHz
    hi2c.Init.OwnAddress1 = 0;
    hi2c.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT;
    hi2c.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE;
    hi2c.Init.OwnAddress2 = 0;
    hi2c.Init.OwnAddress2Masks = I2C_OA2_NOMASK;
    hi2c.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE;
    hi2c.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE;
    HAL_I2C_Init(&hi2c);
}

小技巧:I2C的Timing参数计算比较麻烦。我一般直接用STM32CubeMX生成,或者参考芯片手册里的推荐值。手动算的话,注意SCL高低电平时间要满足I2C标准(100kHz模式下,低电平时间至少4.7μs)。

读取触摸坐标原始数据

协议初始化完成后,就可以读取触摸数据了。不同芯片的寄存器布局不同,但大体流程一致。

以常见的FT5x06系列电容触摸芯片为例,读取坐标的流程如下:

  1. 检查中断引脚(INT)是否拉低,判断是否有触摸事件
  2. 通过I2C读取状态寄存器(0x02),确认触摸点数
  3. 根据触摸点数,依次读取每个触摸点的X/Y坐标寄存器
  4. 解析数据,组合成16位坐标值
typedef struct {
    uint16_t x;
    uint16_t y;
    uint8_t touch_id;
    uint8_t event_flag;  // 0:按下, 1:抬起, 2:持续
} TouchPoint_t;

uint8_t FT5x06_ReadTouchData(TouchPoint_t *points, uint8_t max_points)
{
    uint8_t buf[32];
    uint8_t touch_count = 0;
    uint8_t status;

    // 读取状态寄存器
    HAL_I2C_Mem_Read(&hi2c1, FT5x06_ADDR, 0x02, 1, &status, 1, 100);

    touch_count = status & 0x0F;
    if (touch_count > max_points) {
        touch_count = max_points;
    }

    // 读取触摸数据寄存器(从0x03开始,每个触摸点6字节)
    HAL_I2C_Mem_Read(&hi2c1, FT5x06_ADDR, 0x03, 1, buf, touch_count * 6, 100);

    for (int i = 0; i < touch_count; i++) {
        points[i].touch_id = (buf[i * 6] >> 4) & 0x0F;
        points[i].event_flag = (buf[i * 6] >> 6) & 0x03;
        points[i].x = ((uint16_t)(buf[i * 6 + 1] & 0x0F) << 8) | buf[i * 6 + 2];
        points[i].y = ((uint16_t)(buf[i * 6 + 3] & 0x0F) << 8) | buf[i * 6 + 4];
    }

    return touch_count;
}

核心要点:原始坐标数据通常以12位或16位格式存储,分布在多个寄存器中。读取时要注意字节序(大端/小端)和位对齐。我习惯在读取后立即打印原始值,和芯片手册对照,确认解析正确。

数据滤波与去抖算法

原始数据直接拿来用?不行。触摸屏的物理特性决定了数据会有噪声。你想想看,手指按在屏幕上,ADC采样值会有±5~10的跳动。如果不做滤波,光标会像喝醉了一样乱晃。

我常用的两种滤波算法:中值滤波和均值滤波。各有适用场景。

中值滤波

中值滤波对突发性噪声(比如静电干扰)效果特别好。原理很简单:取N次采样值,排序后取中间值。

#define MEDIAN_WINDOW_SIZE 5

uint16_t MedianFilter(uint16_t *buffer, uint8_t size)
{
    uint16_t temp[MEDIAN_WINDOW_SIZE];
    uint8_t i, j;

    // 复制数据
    for (i = 0; i < size; i++) {
        temp[i] = buffer[i];
    }

    // 冒泡排序(数据量小,够用)
    for (i = 0; i < size - 1; i++) {
        for (j = 0; j < 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[size / 2];
}

经验之谈:窗口大小选5或7比较合适。太小了滤波效果差,太大了延迟明显。我在一个工业平板项目里试过窗口9,结果触摸响应延迟到了50ms,用户反馈说「像在泥巴里划拉」。后来改回5,效果就好多了。

均值滤波

均值滤波对周期性噪声(比如电源纹波)效果好。计算简单,资源占用少。

#define AVG_WINDOW_SIZE 8

uint16_t AverageFilter(uint16_t *buffer, uint8_t size)
{
    uint32_t sum = 0;
    uint8_t i;

    for (i = 0; i < size; i++) {
        sum += buffer[i];
    }

    return (uint16_t)(sum / size);
}

你可能会问:两种滤波怎么选?我的经验是:

  • 如果噪声是偶发的尖峰(比如ESD干扰),用中值滤波
  • 如果噪声是持续的波动(比如电源不稳),用均值滤波
  • 如果条件允许,可以组合使用:先中值剔除异常值,再均值平滑

去抖处理

滤波解决的是数值抖动,去抖解决的是状态抖动。什么叫状态抖动?就是触摸按下/抬起瞬间,电平会反复跳变。

typedef struct {
    uint16_t x;
    uint16_t y;
    uint8_t valid;      // 1:有效触摸, 0:无效
    uint8_t debounce_cnt;
} DebouncedTouch_t;

#define DEBOUNCE_THRESHOLD 3

uint8_t TouchDebounce(uint8_t raw_touch_state, DebouncedTouch_t *state)
{
    if (raw_touch_state) {
        // 触摸按下
        if (state->debounce_cnt < DEBOUNCE_THRESHOLD) {
            state->debounce_cnt++;
        }
        if (state->debounce_cnt >= DEBOUNCE_THRESHOLD) {
            state->valid = 1;
        }
    } else {
        // 触摸抬起
        if (state->debounce_cnt > 0) {
            state->debounce_cnt--;
        }
        if (state->debounce_cnt == 0) {
            state->valid = 0;
        }
    }

    return state->valid;
}

避坑指南:去抖阈值不要设太大。我曾经设了10次采样才确认按下,结果用户快速点击时,系统根本来不及响应。后来改成3次,手感就正常了。记住:去抖是为了消除误触发,不是为了过滤快速操作。

驱动层接口封装

好的驱动层,应该像黑盒子一样。上层应用不需要知道底层是SPI还是I2C,也不需要关心滤波算法细节。我习惯封装成这样的接口:

// 触摸驱动接口定义
typedef struct {
    uint8_t (*Init)(void);
    uint8_t (*ReadTouch)(TouchPoint_t *points, uint8_t max_points);
    uint8_t (*GetTouchCount)(void);
    void (*SetFilterMode)(uint8_t mode);
    void (*SetDebounceThreshold)(uint8_t threshold);
} TouchDriver_t;

// 全局驱动实例
TouchDriver_t g_touch_driver = {
    .Init = FT5x06_Init,
    .ReadTouch = FT5x06_ReadFilteredData,
    .GetTouchCount = FT5x06_GetTouchCount,
    .SetFilterMode = FT5x06_SetFilterMode,
    .SetDebounceThreshold = FT5x06_SetDebounceThreshold
};

// 上层应用调用示例
void App_ProcessTouch(void)
{
    TouchPoint_t points[5];
    uint8_t count;

    count = g_touch_driver.ReadTouch(points, 5);
    for (int i = 0; i < count; i++) {
        // 处理触摸点
        ProcessTouchPoint(&points[i]);
    }
}

这样做的好处很明显:

  • 更换触摸芯片时,只需要重新实现接口函数
  • 调试时可以方便地切换滤波算法
  • 代码可读性和可维护性大幅提升

本章知识体系

下面这张图总结了驱动层开发的整体流程和关键节点:

驱动层开发知识体系 通信协议选择 SPI(4线,高速) I2C(2线,低功耗) 协议初始化(时钟/引脚/时序) 原始坐标数据读取 中值滤波 均值滤波 去抖处理 驱动层接口封装 核心原则:上层不关心底层实现,底层不关心上层业务

驱动层开发,说白了就是做好两件事:把数据读准,把接口做干净。数据读准靠滤波去抖,接口做干净靠封装抽象。这两件事做好了,上层应用开发就会非常顺畅。