驱动层开发: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系列电容触摸芯片为例,读取坐标的流程如下:
- 检查中断引脚(INT)是否拉低,判断是否有触摸事件
- 通过I2C读取状态寄存器(0x02),确认触摸点数
- 根据触摸点数,依次读取每个触摸点的X/Y坐标寄存器
- 解析数据,组合成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]);
}
}
这样做的好处很明显:
- 更换触摸芯片时,只需要重新实现接口函数
- 调试时可以方便地切换滤波算法
- 代码可读性和可维护性大幅提升
本章知识体系
下面这张图总结了驱动层开发的整体流程和关键节点:
驱动层开发,说白了就是做好两件事:把数据读准,把接口做干净。数据读准靠滤波去抖,接口做干净靠封装抽象。这两件事做好了,上层应用开发就会非常顺畅。