3. 触摸事件链路解析

触摸屏这东西,说白了就是个模拟器件。你用手指戳下去,它返回的是ADC值,不是像素坐标。ADC值和屏幕像素之间,存在一个映射关系。这个映射关系,就是校准要解决的问题。

我刚开始做嵌入式UI时,觉得校准可有可无。结果呢?屏幕边缘的按钮死活点不准,用户反馈说“你们的触摸屏是不是坏了”。嗯,从那以后,我再也不敢跳过校准这一步了。

3.1 为什么需要校准?

先看一个实际数据。我手头有一块7寸电阻屏,左上角物理坐标是(0,0),右下角是(800,480)。但ADC读出来的值呢?左上角是(1850, 1850),右下角是(120, 120)。你看,不仅数值不对,连方向都是反的。

造成这种偏差的原因有三个:

  • 物理偏差:触摸屏的导电层不可能绝对均匀
  • 安装偏差:触摸屏贴到LCD上时,总会有几度的旋转
  • 老化漂移:用久了,电阻值会变,校准参数也得跟着变

核心结论:不校准的触摸屏,用户体验就是灾难。校准不是可选项,是必选项。

3.2 线性校准算法

线性校准是最基础的算法。它假设触摸屏的ADC值和屏幕坐标之间,是一个线性关系。说白了,就是一条直线:ScreenX = A * ADC_X + B。

这里有两个参数需要求解:A(比例因子)和B(偏移量)。怎么求?两个点就够了。

我在项目中常用的做法是:在屏幕左上角和右下角各点一个点,记录ADC值,然后解二元一次方程组。

// 两点校准法:求解线性参数
// 已知:点1 (ADC_X1, ADC_Y1) -> (ScreenX1, ScreenY1)
//       点2 (ADC_X2, ADC_Y2) -> (ScreenX2, ScreenY2)

// 计算X方向参数
float scaleX = (float)(ScreenX2 - ScreenX1) / (ADC_X2 - ADC_X1);
float offsetX = ScreenX1 - scaleX * ADC_X1;

// 计算Y方向参数
float scaleY = (float)(ScreenY2 - ScreenY1) / (ADC_Y2 - ADC_Y1);
float offsetY = ScreenY1 - scaleY * ADC_Y1;

// 应用校准
uint16_t getCalibratedX(uint16_t adcX) {
    return (uint16_t)(scaleX * adcX + offsetX);
}

uint16_t getCalibratedY(uint16_t adcY) {
    return (uint16_t)(scaleY * adcY + offsetY);
}

我的经验:两点校准虽然简单,但精度有限。如果触摸屏安装有旋转角度,两点法会引入误差。这时候就需要三点校准法了。

3.3 三点校准法

三点校准法,说白了就是考虑了旋转和缩放。它用三个点来求解一个仿射变换矩阵。这个矩阵有6个自由度:X方向缩放、Y方向缩放、X方向旋转、Y方向旋转、X方向偏移、Y方向偏移。

数学表达式是这样的:

ScreenX = A * ADC_X + B * ADC_Y + C
ScreenY = D * ADC_X + E * ADC_Y + F

六个未知数,需要三个点(每个点提供两个方程)来求解。我一般选屏幕的三个角:左上、右上、左下。为什么不选右下?因为三个角已经能覆盖整个屏幕的变换信息了。

求解过程用到了矩阵运算。这里我直接给出一个经过验证的C代码实现:

// 三点校准法:求解仿射变换参数
// 输入:三个校准点的ADC坐标和屏幕坐标
// 输出:6个校准参数

typedef struct {
    float a, b, c;  // X方向参数
    float d, e, f;  // Y方向参数
} CalibParams;

CalibParams calcThreePointCalib(
    uint16_t adcX1, uint16_t adcY1, uint16_t scrX1, uint16_t scrY1,
    uint16_t adcX2, uint16_t adcY2, uint16_t scrX2, uint16_t scrY2,
    uint16_t adcX3, uint16_t adcY3, uint16_t scrX3, uint16_t scrY3) {
    
    CalibParams params;
    float divisor;
    
    // 计算分母(行列式)
    divisor = (float)(adcX1 * (adcY2 - adcY3) + 
                      adcX2 * (adcY3 - adcY1) + 
                      adcX3 * (adcY1 - adcY2));
    
    if (divisor == 0.0f) {
        // 三点共线,校准失败
        params.a = params.b = params.c = 0;
        params.d = params.e = params.f = 0;
        return params;
    }
    
    // 求解X方向参数
    params.a = (float)(scrX1 * (adcY2 - adcY3) + 
                       scrX2 * (adcY3 - adcY1) + 
                       scrX3 * (adcY1 - adcY2)) / divisor;
    
    params.b = (float)(scrX1 * (adcX3 - adcX2) + 
                       scrX2 * (adcX1 - adcX3) + 
                       scrX3 * (adcX2 - adcX1)) / divisor;
    
    params.c = (float)(scrX1 * (adcX2 * adcY3 - adcX3 * adcY2) + 
                       scrX2 * (adcX3 * adcY1 - adcX1 * adcY3) + 
                       scrX3 * (adcX1 * adcY2 - adcX2 * adcY1)) / divisor;
    
    // 求解Y方向参数
    params.d = (float)(scrY1 * (adcY2 - adcY3) + 
                       scrY2 * (adcY3 - adcY1) + 
                       scrY3 * (adcY1 - adcY2)) / divisor;
    
    params.e = (float)(scrY1 * (adcX3 - adcX2) + 
                       scrY2 * (adcX1 - adcX3) + 
                       scrY3 * (adcX2 - adcX1)) / divisor;
    
    params.f = (float)(scrY1 * (adcX2 * adcY3 - adcX3 * adcY2) + 
                       scrY2 * (adcX3 * adcY1 - adcX1 * adcY3) + 
                       scrY3 * (adcX1 * adcY2 - adcX2 * adcY1)) / divisor;
    
    return params;
}

注意:分母不能为0。如果三个校准点选得太靠近,或者恰好共线,计算就会出问题。我建议三个点之间至少间隔屏幕尺寸的30%。

3.4 校准参数存储与加载

校准参数算出来了,得存起来。总不能每次开机都让用户点三个点吧?那体验也太差了。

存储位置的选择,我一般按优先级排序:

存储介质 优点 缺点 适用场景
EEPROM 读写简单,寿命长 容量小(通常几KB) MCU方案
Flash 容量大 需要擦除再写,有寿命限制 资源丰富的方案
文件系统 管理方便 需要文件系统支持 Linux/RTOS方案

我个人习惯用EEPROM。6个float参数,总共24字节,加上一个校验和,30字节搞定。EEPROM的寿命是10万次擦写,够用了。

下面是一个存储和加载的实现:

// 校准参数存储结构
typedef struct {
    uint8_t magic;          // 魔数,用于校验
    CalibParams params;     // 校准参数
    uint8_t checksum;       // 校验和
} CalibStorage;

// 计算校验和
uint8_t calcChecksum(uint8_t* data, uint16_t len) {
    uint8_t sum = 0;
    for (uint16_t i = 0; i < len; i++) {
        sum += data[i];
    }
    return sum;
}

// 保存校准参数到EEPROM
void saveCalibParams(CalibParams* params) {
    CalibStorage storage;
    storage.magic = 0xA5;
    storage.params = *params;
    storage.checksum = calcChecksum((uint8_t*)&storage, 
                                    sizeof(CalibStorage) - 1);
    
    // 写入EEPROM(地址0x0000)
    eeprom_write(0x0000, (uint8_t*)&storage, sizeof(CalibStorage));
}

// 从EEPROM加载校准参数
uint8_t loadCalibParams(CalibParams* params) {
    CalibStorage storage;
    
    // 从EEPROM读取
    eeprom_read(0x0000, (uint8_t*)&storage, sizeof(CalibStorage));
    
    // 校验魔数
    if (storage.magic != 0xA5) {
        return 0;  // 无效数据
    }
    
    // 校验校验和
    uint8_t calc = calcChecksum((uint8_t*)&storage, 
                                sizeof(CalibStorage) - 1);
    if (calc != storage.checksum) {
        return 0;  // 数据损坏
    }
    
    *params = storage.params;
    return 1;  // 加载成功
}

避坑指南:我曾经遇到过一个问题——EEPROM第一次上电时全是0xFF,魔数校验通过不了,导致每次都要重新校准。解决方案是:在出厂前预置一组默认校准参数,或者在校准界面加一个“跳过”按钮。

3.5 校准工具开发

校准工具,说白了就是让用户点三个点,然后自动计算并保存参数。但这里面有不少细节。

我设计的校准流程是这样的:

  1. 显示校准点:在屏幕指定位置显示一个十字光标
  2. 等待触摸:用户点击十字光标中心
  3. 记录ADC值:读取当前触摸点的ADC原始值
  4. 去抖动处理:连续读取5次,取平均值
  5. 重复步骤1-4:直到三个点都采集完成
  6. 计算参数:调用三点校准算法
  7. 验证精度:让用户再点一次,检查误差
  8. 保存参数:写入EEPROM

这里有一个关键点:去抖动。触摸屏的ADC值是有噪声的,尤其是电阻屏。我一般用滑动平均滤波:

// 滑动平均滤波,用于去抖动
#define FILTER_SAMPLES 5

typedef struct {
    uint16_t buffer[FILTER_SAMPLES];
    uint8_t index;
    uint32_t sum;
} MovingAverage;

void ma_init(MovingAverage* ma) {
    ma->index = 0;
    ma->sum = 0;
    for (uint8_t i = 0; i < FILTER_SAMPLES; i++) {
        ma->buffer[i] = 0;
    }
}

uint16_t ma_add(MovingAverage* ma, uint16_t value) {
    ma->sum -= ma->buffer[ma->index];
    ma->buffer[ma->index] = value;
    ma->sum += value;
    ma->index = (ma->index + 1) % FILTER_SAMPLES;
    return (uint16_t)(ma->sum / FILTER_SAMPLES);
}

校准工具的UI设计也有讲究。十字光标的中心要留一个空心区域,让用户能看到自己点的是不是正中心。我一般用10x10像素的空心十字,中心留2x2的透明区。

我曾经踩过的坑:校准点的位置不能太靠近屏幕边缘。边缘区域的触摸屏线性度最差,ADC值波动也最大。我一般把校准点放在距离边缘15%屏幕尺寸的位置。

3.6 校准精度验证

校准完了,怎么知道准不准?我一般用两个指标:

  • 最大误差:在屏幕9个位置(四角、四边中点、中心)分别测试,记录最大偏差
  • 平均误差:所有测试点的平均偏差

对于7寸屏,我要求最大误差不超过3个像素,平均误差不超过1个像素。如果达不到,说明触摸屏本身质量有问题,或者安装有严重偏差。

下面是我常用的验证代码:

// 校准精度验证
typedef struct {
    uint16_t scrX, scrY;   // 屏幕坐标
    uint16_t calX, calY;   // 校准后的坐标
    int16_t errX, errY;    // 误差
} TestPoint;

void verifyCalibration(CalibParams* params, TestPoint* points, uint8_t count) {
    uint16_t adcX, adcY;
    int16_t maxErr = 0;
    float avgErr = 0;
    
    for (uint8_t i = 0; i < count; i++) {
        // 等待用户点击测试点
        waitForTouch(&adcX, &adcY);
        
        // 应用校准
        points[i].calX = (uint16_t)(params->a * adcX + 
                                    params->b * adcY + params->c);
        points[i].calY = (uint16_t)(params->d * adcX + 
                                    params->e * adcY + params->f);
        
        // 计算误差
        points[i].errX = (int16_t)points[i].calX - (int16_t)points[i].scrX;
        points[i].errY = (int16_t)points[i].calY - (int16_t)points[i].scrY;
        
        // 更新最大误差
        int16_t err = abs(points[i].errX) + abs(points[i].errY);
        if (err > maxErr) maxErr = err;
        avgErr += err;
    }
    
    avgErr /= count;
    
    printf("最大误差: %d 像素\n", maxErr);
    printf("平均误差: %.1f 像素\n", avgErr);
}

我的经验总结:校准不是一劳永逸的。温度变化、湿度变化、触摸屏老化,都会导致校准参数漂移。我建议在产品中加一个“重新校准”的入口,让用户在感觉不准时可以手动触发校准。

触摸屏校准知识体系 为什么需要校准? 物理偏差 安装偏差 老化漂移 校准算法 线性校准(两点法) 三点校准(仿射变换) 参数存储与加载 EEPROM Flash 文件系统 核心流程:采集ADC值 → 计算校准参数 → 存储到非易失介质 → 每次启动时加载