LCD触摸驱动标准库移植HAL库(2)

Tutorial: 卓越工程师 Category: 嵌入式 Published: 2026-10-23 11:05:29 Views: 0 Likes: 0 Comments: 0

208.3.7 LCD触摸驱动标准库移植HAL库(2)

/*
 * 本节目标:完成 XPT2046 触摸驱动的 HAL 迁移,编译、烧录并验证触摸画线与坐标显示。
 * 收录范围:只列出本节新增、修改或重点讲解的业务代码;CubeMX 和 HAL 库内部实现不重复收录。
 * 源码边界:保持课程配套工程的最终写法,包括 C2_Clr() 的原始命名以及未检查 HAL 返回值等现状。
 */

/* lcd_init.h:最终使用的触摸片选宏。 */
//#define TCS PAout(4)                                  /* 旧标准库位带宏已经停用。 */
#define C2_Clr() HAL_GPIO_WritePin(CS2_GPIO_Port, CS2_Pin, 0) /* 课程源码原名 C2_Clr():将 PA4/CS2 拉低。 */
#define CS2_Set() HAL_GPIO_WritePin(CS2_GPIO_Port, CS2_Pin, 1) /* 将 PA4/CS2 拉高,结束本次触摸通信。 */

/* main.c:补齐触摸画线所需的状态变量,并把标准库延时换成 HAL 延时。 */
u16 lastpos[2];                                        /* 保存上一触摸点的 X、Y 坐标。 */
lastpos[0]=0XFFFF;                                     /* 以 0xFFFF 表示尚未取得第一个有效点。 */
HAL_Delay(1);                                          /* 按下处理中的 1 ms 延时,替代 delay_ms(1)。 */

/* touch.c:从 XPT2046 读取一次 12 位 ADC 转换结果。 */
u16 TP_Read_AD(u8 CMD)                                 /* CMD 是 X 或 Y 轴的 XPT2046 控制字。 */
{                                                      /* 单次 ADC 读取开始。 */
    u16 Num=0;                                         /* 保存拼接并右对齐后的 12 位结果。 */
    C2_Clr();                                          /* 拉低 CS2,选中 XPT2046。 */
    HAL_SPI_Transmit(&hspi1, &CMD, 1, 100);            /* 通过 SPI1 发送 1 字节控制命令。 */
    uint8_t rx_data[2];                                /* 接收 XPT2046 随后返回的两个字节。 */
    uint8_t tx_data[2]={0};                            /* 发送两个 0 以产生接收所需的 16 个时钟。 */
    HAL_SPI_TransmitReceive(&hspi1, tx_data, rx_data,2, 100); /* 同步发送 2 字节并接收 2 字节。 */
    Num = rx_data[0]<<8|rx_data[1];                    /* 将先收到的高字节与后收到的低字节拼成 16 位。 */
    Num>>=4;                                           /* 丢弃末尾 4 个无效位,使 12 位 ADC 值右对齐。 */
    CS2_Set();                                         /* 拉高 CS2,释放 XPT2046。 */
    return(Num);                                       /* 返回范围为 0~4095 的 12 位原始值。 */
}                                                      /* 单次 ADC 读取结束。 */

/* touch.c:对同一坐标轴执行五次采样、排序、去极值和平均。 */
#define READ_TIMES 5                                   /* 每个轴连续采样 5 次。 */
#define LOST_VAL 1                                     /* 排序后从低端和高端各丢弃 1 个值。 */
u16 TP_Read_XOY(u8 xy)                                 /* xy 为 CMD_RDX 或 CMD_RDY。 */
{                                                      /* 单轴滤波函数开始。 */
    u16 i, j;                                          /* 两层循环使用的下标。 */
    u16 buf[READ_TIMES];                               /* 保存 5 个原始 ADC 采样值。 */
    u16 sum=0;                                         /* 累加保留下来的中间值。 */
    u16 temp;                                          /* 排序交换及最终平均值的临时变量。 */
    for(i=0;i<READ_TIMES;i++)buf[i]=TP_Read_AD(xy);    /* 连续取得 5 个同轴采样值。 */
    for(i=0;i<READ_TIMES-1; i++)                       /* 从第一个元素开始逐个确定较小值。 */
    {                                                  /* 外层排序循环开始。 */
        for(j=i+1;j<READ_TIMES;j++)                    /* 用 buf[i] 与其后的每个元素比较。 */
        {                                              /* 内层排序循环开始。 */
            if(buf[i]>buf[j])                          /* 若前面的值更大,则交换两者。 */
            {                                          /* 交换开始。 */
                temp=buf[i];                           /* 暂存前面的较大值。 */
                buf[i]=buf[j];                         /* 把较小值放到前面。 */
                buf[j]=temp;                           /* 把原来的较大值放到后面。 */
            }                                          /* 本次比较交换结束。 */
        }                                              /* 内层循环结束。 */
    }                                                  /* 排序完成,数组按升序排列。 */
    sum=0;                                             /* 求和前再次清零累加器。 */
    for(i=LOST_VAL;i<READ_TIMES-LOST_VAL;i++)sum+=buf[i]; /* 累加下标 1、2、3 的三个中间值。 */
    temp=sum/(READ_TIMES-2*LOST_VAL);                  /* 用和除以 3,得到去极值后的平均值。 */
    return temp;                                       /* 返回这一坐标轴的滤波结果。 */
}                                                      /* 单轴滤波函数结束。 */

/* touch.c:分别读取 X、Y 两个轴。 */
u8 TP_Read_XY(u16 *x,u16 *y)                           /* 通过指针输出一组 X/Y 原始坐标。 */
{                                                      /* 双轴读取开始。 */
    u16 xtemp,ytemp;                                   /* 临时保存两个轴的滤波值。 */
    xtemp=TP_Read_XOY(CMD_RDX);                        /* 使用 X 轴命令取得 5 次滤波后的结果。 */
    ytemp=TP_Read_XOY(CMD_RDY);                        /* 使用 Y 轴命令取得 5 次滤波后的结果。 */
    *x=xtemp;                                          /* 把 X 写到调用者提供的地址。 */
    *y=ytemp;                                          /* 把 Y 写到调用者提供的地址。 */
    return 1;                                          /* 课程源码固定返回成功。 */
}                                                      /* 双轴读取结束。 */

/* touch.c:连续读取两组已经滤波的 X/Y,再做一致性检查。 */
#define ERR_RANGE 50                                   /* 同一轴两组结果允许的差值必须小于 50。 */
u8 TP_Read_XY2(u16 *x,u16 *y)                         /* 成功返回 1,偏差过大返回 0。 */
{                                                      /* 双组一致性滤波开始。 */
    u16 x1,y1;                                         /* 保存第一组 X/Y。 */
    u16 x2,y2;                                         /* 保存第二组 X/Y。 */
    u8 flag;                                           /* 保存 TP_Read_XY() 的返回值。 */
    hspi1.Instance->CR1&=0XFFC7;                       /* 清零 SPI1_CR1 的 BR[2:0]。 */
    hspi1.Instance->CR1|=4<<3;                         /* 写入 100,读取触摸时切换为 32 分频。 */
    flag=TP_Read_XY(&x1,&y1);                          /* 读取第一组滤波后的 X/Y。 */
    if(flag==0)return(0);                              /* 第一组失败则提前返回;当前实现实际总会返回 1。 */
    flag=TP_Read_XY(&x2,&y2);                          /* 读取第二组滤波后的 X/Y。 */
    if(flag==0)return(0);                              /* 第二组失败则提前返回;当前实现实际总会返回 1。 */
    hspi1.Instance->CR1&=0XFFC7;                       /* 再次清零 BR[2:0]。 */
    hspi1.Instance->CR1|=1<<3;                         /* 写入 001,恢复 LCD 使用的 4 分频。 */
    if(((x2<=x1&&x1<x2+ERR_RANGE)||(x1<=x2&&x2<x1+ERR_RANGE))&& /* 要求两次 X 的差值小于 50。 */
       ((y2<=y1&&y1<y2+ERR_RANGE)||(y1<=y2&&y2<y1+ERR_RANGE))) /* 要求两次 Y 的差值小于 50。 */
    {                                                  /* 两个轴的一致性检查均通过。 */
        *x=(x1+x2)/2;                                  /* 输出两次 X 的平均值。 */
        *y=(y1+y2)/2;                                  /* 输出两次 Y 的平均值。 */
        return 1;                                      /* 告知上层本次坐标有效。 */
    }else return 0;                                    /* 任一轴偏差过大便丢弃本次坐标。 */
}                                                      /* 双组一致性滤波结束。 */

一、LCD触摸屏标准库移植

本节接续第 207 课,把剩余的标准库接口迁移为 HAL 工程能够使用的写法,并最终在实物上验证:白色画板能够响应触摸、绘制蓝色轨迹,同时更新底部 X/Y 坐标。

完整调用链如下:

main(): tp_dev.scan(0)
  └─ TP_Scan()
      └─ TP_Read_XY2()             连续读取两组 X/Y,并检查两组偏差
          ├─ TP_Read_XY()          分别读取 X、Y
          │   └─ TP_Read_XOY()     每个轴采 5 次,排序后去掉最大、最小值
          │       └─ TP_Read_AD()  发送控制字,取得一次 12 位 ADC 结果
          └─ 恢复 SPI1 为 4 分频

1.1 查找文件

第 207 课只完成了进入触摸读取前的 32 分频。本节开头先复制同样的两条 CR1 位操作,在两次坐标读取之后恢复 4 分频:

hspi1.Instance->CR1 &= 0XFFC7;
hspi1.Instance->CR1 |= 1 << 3;

1 的二进制是 001,左移 3 位得到 0b0000_1000 = 0x08,写入 BR[2:0] 后代表外设时钟 4 分频。这里的“001”是三位字段编码,最终写入寄存器的值不是 1,而是 1 << 3。

随后沿 TP_Read_XY2() → TP_Read_XY() → TP_Read_XOY() → TP_Read_AD() 逐层查看。普通 for 循环、数组、排序和算术运算与使用标准库还是 HAL 无关;真正需要迁移的是 GPIO、延时和 SPI 收发接口。原笔记中的 “for 函数”应为 for 循环。

1.2 SPI功能描述

TP_Read_AD() 的任务是完成一次 XPT2046 转换读取。标准库版本先手动控制时钟和数据引脚,再调用 SPI1_ReadWriteByte();HAL 工程已经将 PA5/PA6/PA7 配成硬件 SPI1,因此本节改为 HAL 阻塞式收发。

一次读取的总线顺序是:

  1. CS2 拉低,选中 XPT2046;
  2. MOSI 发送 1 字节控制命令,指定要转换的坐标轴;
  3. 主机继续发送 2 个占位字节,以便 SPI 产生时钟;
  4. 同时从 MISO 收到 2 字节转换结果;
  5. 拼接并右移 4 位,得到 12 位 ADC 数值;
  6. CS2 拉高,结束本次事务。

SPI 是同步全双工接口:每发出一个位的同时也会收进一个位。主机要“只接收”数据,仍必须在 MOSI 上发送占位位来产生 SCK;这就是 tx_data[2] 存在的硬件原因。

1.3 触摸屏读取坐标功能分析

标准库片选写法为:

#define TCS PAout(4)
TCS = 0;
/* 通信 */
TCS = 1;

PA4 对应 CS2,低电平有效。本节参考 LCD 的 CS1 宏,新建 C2_Clr() 和 CS2_Set(),分别拉低和拉高 CS2。课程源码中第一个名字少了字母 S,但定义与调用一致,所以可以编译;精校源码按原样保留,不擅自改名。

视频第一次复制宏时只改了宏名,宏体仍是:

HAL_GPIO_WritePin(CS1_GPIO_Port, CS1_Pin, ...)

这实际上控制的是 PA3/显示屏片选,不是 PA4/触摸片选。代码能够编译,但 XPT2046 从未被正确选中,因此烧录后只有白屏、触摸无响应。最终把两个宏的端口和引脚都改成 CS2_GPIO_Port, CS2_Pin,并注释旧 TCS 宏,复位后触摸恢复正常。

这说明“0 个编译错误”只能证明符号和语法成立,不能证明引脚连接与业务逻辑正确。片选类错误尤其容易在复制宏时出现,排查时应同时核对宏名、GPIO 端口和引脚号。

1.4 发送接口函数

视频从前几课已经验证过的 LCD SPI 发送代码中借用接口,把参数由显示数据改成触摸命令:

HAL_SPI_Transmit(&hspi1, &CMD, 1, 100);

五个信息分别是:

项目本节实参含义
SPI 句柄&hspi1使用 SPI1
数据地址&CMD发送控制命令变量的地址
数据单元数1SPI 配置为 8 位,因此发送 1 字节
超时100最多等待 100 个 HAL tick;默认 1 ms tick 时约为 100 ms

CS2 在发送命令前已经拉低,并保持到结果接收完成,命令和数据因此属于同一次从机事务。

1.5 HAL库函数介绍

视频选择 HAL_SPI_TransmitReceive() 读取数据,并根据个人调试经验说单独的 HAL_SPI_Receive() “有时不好使”。这个表述不能当作 HAL 的普遍结论。

本工程使用的 STM32F1 HAL 源码显示:当 SPI 处于主机、双线全双工模式时,HAL_SPI_Receive() 内部本身也会调用 HAL_SPI_TransmitReceive(),通过发送占位数据产生时钟。课程显式调用收发接口的优势在于可以清楚指定 tx_data={0},使接收期间 MOSI 输出确定的 0;并不是说 HAL_SPI_Receive() API 天生不可用。

阻塞式接口的“阻塞”表示函数会轮询硬件状态,直到传输完成、发生错误或超时后才返回。它不能简单理解成“所有协议延时都可以删除”;本节能移除原来的手动 SCK 翻转和等待,是因为这些时钟已经由硬件 SPI 传输过程接管,并且课程在实物上完成了验证。

1.6 发送接收函数

最终调用为:

HAL_SPI_TransmitReceive(&hspi1, tx_data, rx_data, 2, 100);
参数含义
&hspi1SPI1 句柄地址,不是原笔记误识别的 “ctrl c”
tx_data两个待发送占位字节的首地址
rx_data两个接收字节的存储首地址
2同时发送并接收 2 个 8 位数据单元,即 2 字节
100阻塞等待的超时 tick 数

两个接收字节的组合关系为:

[ raw16=(rx_data[0]\ll8);|;rx_data[1] ]

XPT2046 的 12 位结果在该 16 位接收序列的高 12 位,最终执行:

[ adc12=raw16\gg4 ]

视频先口误为“左移 4 位”,随后立即纠正为右移 4 位;最终源码 Num >>= 4 是正确方向。所得数值范围为 0~4095。

课程源码没有检查 HAL_SPI_Transmit() 和 HAL_SPI_TransmitReceive() 的返回状态。若传输超时或失败,随后仍会读取 rx_data,结果不可靠。笔记为保持课程源码不擅自修改,但实际项目应判断 HAL_OK,失败时先释放 CS2,再向上层返回错误。

1.7 问题代码

第一次迁移完 SPI 读取后,编译还提示 main.c 中触摸画线所需变量未定义。视频回到标准库 main.c,补入:

u16 lastpos[2];
lastpos[0] = 0XFFFF;

lastpos 保存上一触摸点,让 LCD_DrawRoughLine() 能连接前后坐标。只初始化 lastpos[0] 是因为它充当“是否已经有起点”的哨兵;当它等于 0xFFFF 时,代码会同时把当前 X、Y 写入 lastpos[0]、lastpos[1],所以未初始化的 lastpos[1] 不会在此之前用于画线。

原笔记中的 “men 点 c” 是 main.c 的语音识别错误,“变量定义、绘制、初始化”应具体落实为上面这组变量和哨兵值。

1.8 编译时错误

标准库的 delay_ms() 仍出现在两个位置:

  • main.c 的触摸按下处理使用 delay_ms(1);
  • touch.c 的 TP_Adjust() 校准成功或失败提示分支中有多处 delay_ms(1000)。

本节把它们全部替换为 HAL_Delay()。其中主循环使用 HAL_Delay(1),校准流程中的四处等待使用 HAL_Delay(1000)。虽然当前 Adujust == 1 不进入手动校准分支,编译器仍必须能解析该函数体,所以不能因为运行时暂时不用就保留未定义的旧接口。

1.9 校准函数分析

最后一个旧标准库依赖来自:

void TP_Write_Byte(u8 num)
{
    SPI1_ReadWriteByte(num);
}

原来 TP_Read_AD() 通过它发送命令。本节已经在 TP_Read_AD() 内直接调用 HAL_SPI_Transmit(),因此没有任何活动代码再调用 TP_Write_Byte()。视频将该函数实现整体注释,消除其中未迁移的 SPI1_ReadWriteByte()。

课程的 touch.h 仍保留 TP_Write_Byte() 声明。只有声明而没有调用不会产生链接错误,但这是遗留的无效接口;本节以课程最终工程为准,没有进一步整理公共头文件。

完成变量、延时和旧 SPI 包装函数处理后,工程能够编译链接。此时仍可能存在未使用变量等警告,因此视频的关键结论是“没有阻塞构建的错误”,不是所有警告都已经清零。

1.10 触摸屏代码问题分析

第一次烧录后,LCD 能显示白色画板,但触摸没有反应。排查时 SPI 分频和收发代码看起来都已迁移,最终在 lcd_init.h 找到复制错误:

错误:C2_Clr()/CS2_Set() 的宏体仍使用 CS1_GPIO_Port、CS1_Pin
正确:宏体必须使用 CS2_GPIO_Port、CS2_Pin

CS1 是 ST7796 的片选,CS2 才是 XPT2046 的片选。错误版本会在触摸读取期间反复选中/释放 LCD,触摸控制器没有收到完整事务。修正两个宏并注释 TCS 后重新编译、烧录、复位,视频中触摸画线和坐标显示均正常,至此标准库触摸驱动的 HAL 迁移完成。

1.11 触摸屏扫描函数分析

TP_Read_XY2() 的 “2” 表示它连续取得两组 X/Y。每组已经经过 TP_Read_XOY() 的五次采样滤波,然后再做第二层一致性判断:

[ |x_1-x_2|<50,\qquad |y_1-y_2|<50 ]

两个条件都成立时输出平均值:

[ x=\frac{x_1+x_2}{2},\qquad y=\frac{y_1+y_2}{2} ]

源码使用严格小于关系,所以差值恰好为 50 时会被判为失败;原注释“偏差不超过 50”不够精确。

还要注意,当前 TP_Read_XY() 无条件 return 1,因此 TP_Read_XY2() 中两处 if(flag==0) 在现有实现下不会触发。真正可能返回 0 的路径是两组坐标偏差过大。若以后让底层传递 HAL 通信错误,则应同时确保任何提前返回前都恢复 SPI 分频并释放片选。

1.12 触摸屏读取坐标功能分析

TP_Read_XY() 先用 CMD_RDX 取得一个轴,再用 CMD_RDY 取得另一个轴,通过 u16 *x、u16 *y 把结果传给调用者。对于 USE_HORIZONTAL 为 0 或 1 的当前工程,源码定义:

u8 CMD_RDX = 0xD0;
u8 CMD_RDY = 0x90;

控制字决定 XPT2046 的输入通道和转换方式;屏幕方向改变时,课程源码会交换两个命令。读出的仍是 0~4095 的 ADC 原始值,之后由 TP_Scan() 使用校准系数换算成 LCD 像素坐标。

一次 TP_Read_AD() 的有效数据链为:

两个接收字节 → 拼成 16 位 → 右移 4 位 → 12 位 ADC 值

片选必须在命令和两个返回字节期间一直为低,全部接收完再拉高。若过早拉高,控制字和返回数据会被拆成不同事务。

1.13 触摸屏读取坐标功能分析

TP_Read_XOY() 实现的是去极值平均滤波:

  1. 对同一轴调用 TP_Read_AD() 5 次,得到 buf[0]~buf[4];
  2. 使用两层 for 循环把 5 个值按升序排列;
  3. LOST_VAL == 1,因此不累加下标 0 和 4,即丢弃最小值与最大值;
  4. 将下标 1、2、3 相加,再除以 5 - 2×1 = 3。

公式为:

[ v=\frac{buf_{(1)}+buf_{(2)}+buf_{(3)}}{3} ]

其中括号下标表示排完序后的次序。它能抑制单个偏大或偏小的毛刺,但若一次窗口内出现多个异常值,或者触摸点正在快速移动,也可能造成迟滞或结果被丢弃。

按当前调用关系,一次 TP_Read_XY() 对 X、Y 各采 5 次,共进行 10 次 ADC 转换;TP_Read_XY2() 又执行两次 TP_Read_XY(),所以一次完整的按下坐标判断最多包含 20 次单轴 ADC 转换。这是坐标较稳定、但读取开销也较大的原因。

本节最终形成两层滤波:底层“五次采样、去掉最大最小值、取中间三项平均”,上层“两组坐标比较、通过后再平均”。这种组合适合机械抖动和模拟噪声较明显的电阻触摸屏,但阈值与采样次数仍应结合实际响应速度和噪声水平确定,不能机械套用到所有传感器。