LCD触摸驱动标准库移植HAL库(2)
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 阻塞式收发。
一次读取的总线顺序是:
- CS2 拉低,选中 XPT2046;
- MOSI 发送 1 字节控制命令,指定要转换的坐标轴;
- 主机继续发送 2 个占位字节,以便 SPI 产生时钟;
- 同时从 MISO 收到 2 字节转换结果;
- 拼接并右移 4 位,得到 12 位 ADC 数值;
- 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 | 发送控制命令变量的地址 |
| 数据单元数 | 1 | SPI 配置为 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);
| 参数 | 含义 |
|---|---|
&hspi1 | SPI1 句柄地址,不是原笔记误识别的 “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() 实现的是去极值平均滤波:
- 对同一轴调用
TP_Read_AD()5 次,得到buf[0]~buf[4]; - 使用两层
for循环把 5 个值按升序排列; LOST_VAL == 1,因此不累加下标 0 和 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 转换。这是坐标较稳定、但读取开销也较大的原因。
本节最终形成两层滤波:底层“五次采样、去掉最大最小值、取中间三项平均”,上层“两组坐标比较、通过后再平均”。这种组合适合机械抖动和模拟噪声较明显的电阻触摸屏,但阈值与采样次数仍应结合实际响应速度和噪声水平确定,不能机械套用到所有传感器。