1. 这块屏到底难在哪:先把整体架构捋清楚
如果你和我一样,之前一直泡在FPGA的世界里,天天跟Verilog、时序约束、仿真波形打交道,那第一次接到“把7寸触摸屏在Linux下跑起来”这个任务时,多半会愣一下。不是说点不亮,而是“点亮”和“跑起来”之间,隔着一整套陌生的软件栈。
先说结论:这个任务的核心不是FPGA本身,而是Linux下的输入子系统(Input Subsystem)、设备树(Device Tree),以及你手里那块触摸屏对应的通信协议。FPGA在这里的角色,更像是一个“画面输出设备”加“IO扩展中枢”——屏幕的RGB时序由PL侧产生,触摸芯片的数据则通过SPI或I2C总线传给ARM核,再由Linux内核里的驱动转换成标准的输入事件,最终被Qt、GTK或者任何图形程序消费掉。
我用的是ZYNQ平台,PS端跑Linux,PL端写了一个简单的LCD时序控制器来驱动7寸RGB屏。触摸部分则根据屏幕型号分了两种方案:电阻屏走SPI接口的ADS7846/XPT2046,电容屏走I2C接口的GT911。这两种芯片非常典型,市面上大部分7寸模组要么是前者要么是后者,把这两条路都走通,基本就能应对绝大多数项目。
适合看这篇文章的,主要是两类人:一是像我这样从纯FPGA往SoC/Linux方向跨界的工程师,想找个完整的驱动案例上手;二是做嵌入式Linux开发,但之前只弄过按键、LED这类简单字符设备,想试试触摸屏这种带中断、带异步上报、还得调坐标映射的输入设备。
如果你只想在板子上“能用”,那直接启用内核自带的ads7846或goodix驱动就行。但如果你想搞明白每个环节为什么这么设计,出了问题知道去哪查,那还是得自己把驱动从头到尾写一遍。这篇文章就是按后者来写的。
2. 不谈原理写不好驱动:触摸屏的硬件细节补课
2.1 7寸RGB屏的接口定义和FPGA侧时序
先搞清楚你手里的屏幕是什么接口。常见的7寸屏,比如800x480或1024x600分辨率的RGB屏,接口上通常包含两组信号:一组是显示用的RGB数据线和同步信号,另一组就是触摸屏的引线。
显示部分一般有24根RGB数据线(RGB888)、VSYNC、HSYNC、DE、PCLK,再加背光控制和电源。这部分由FPGA的PL侧负责,我用Verilog写了一个简单的时序控制器,按屏幕数据手册里的参数产生行场同步和像素时钟。具体参数各家屏幕略有差异,但原理都一样,本质上就是三个计数器:一个数像素(行内),一个数行(帧内),一个数帧。像素时钟跟屏幕分辨率、刷新率挂钩,比如800x480@60Hz,典型像素时钟在33MHz左右。这个在FPGA里用PLL锁相环分频产生,不是什么难事。
真正容易翻车的是触摸部分的接口定义。很多7寸模组的触摸排线是独立的,有的是4根线(电阻屏),有的是4根或6根线(电容屏,I2C加中断和复位)。拿到屏幕第一步,别急着接线,先找模组厂家要一份接口定义表,确认触摸芯片型号。我之前就遇到过排线上写着“TSC”但实际芯片是GT911的情况,硬件接错了排查起来特别痛苦。
2.2 电阻触摸:XPT2046/ADS7846的工作过程
电阻屏的原理其实很朴素,就是靠压力让上下两层导电膜接触,形成一个分压电阻网络。你按的位置不同,分压值就不同,ADC采出来就是对应的X或Y坐标。所以电阻屏驱动芯片的核心就是一个带触摸检测功能的ADC,XPT2046、ADS7846、TSC2046这些芯片功能基本兼容,都是12位ADC,SPI接口,一个PENIRQ引脚用来上报“有触摸”。
用SPI读XPT2046的时候,主机要发送一个控制字节,用来选择转换通道和模式。比如读X坐标,通常发0xD0(12位、差分模式),然后连续读两个字节,把12位有效数据拼出来。Y坐标同理,换一个控制字节。注意这里有个细节:XPT2046的SPI是“发一个字节、收一个字节”交替进行的,读坐标需要先发命令再读数据,CS片选要保持住,期间时钟不能断。很多第一次写驱动的人在这里翻车,以为像读普通SPI Flash那样连续读就行。
还有一个是触摸检测和坐标采样的配合。PENIRQ引脚在无触摸时是高电平,有触摸时拉低。所以驱动里可以用这个引脚做外部中断触发,中断来了之后启动一次坐标采样。但要注意,刚检测到触摸时,ADC模拟部分还没稳定,最好延时一小段时间再采样,或者连续采几次取平均,否则读出来的坐标抖动会非常大。
2.3 电容触摸:GT911的配置流程和点报格式
电容屏和电阻屏完全是两个思路。电容屏是通过检测手指触碰时电容变化来定位的,触摸感应本身就集成在玻璃面板里,外面那颗IC负责扫描、计算坐标,然后通过I2C把坐标数据报给主机。
以GT911为例,这颗芯片在7寸屏上非常常见。I2C地址可以通过外部引脚配置,常见的是0x5D或0x14(7位地址),对应到8位传输地址就是左移一位。这个地址在驱动里必须匹配你板子上的实际配置,不然i2cdetect扫描不到设备,后面全白搭。
GT911上电之后有个初始化流程,需要操作INT和RST两个引脚。典型时序是:上电后先将INT拉低,RST拉低保持一段时间,然后RST拉高,延时后再将INT拉高,此时芯片才进入正常工作模式。这个时序是硬性的,因为GT911要在复位过程中根据INT引脚的电平来决定I2C地址和通信模式。如果初始化时序不对,芯片可能不响应I2C命令,或者地址和预期不符。
正常工作后,GT911会把触摸点数据缓存在内部寄存器里。驱动要做的,就是读0x814E这个寄存器拿到当前有效触摸点数,然后从0x8150开始读取每个触摸点的数据块。每个点6个字节,其中包含12位的X坐标、12位的Y坐标,以及触摸面积和触摸ID。这里要特别注意,GT911的寄存器地址是16位的,Linux标准的i2c_smbus接口只支持单字节命令,读不了0x8150这种高地址,必须自己用i2c_transfer拼一个两字节地址前缀再读数据。这个坑几乎人人都踩,提前说一声能省你半天时间。
3. 驱动开发实战:手写一个可用的触摸驱动
3.1 驱动骨架:SPI/I2C设备驱动注册
不管屏幕是电阻屏还是电容屏,Linux驱动的框架套路是一样的:先注册一个SPI或I2C设备驱动,在probe函数里做初始化,然后注册输入设备,最后处理中断、上报坐标。
以SPI接口的XPT2046为例,核心结构体是spi_driver:
static const struct of_device_id xpt2046_of_match[] = { { .compatible = "mycompany,xpt2046", }, { } }; static struct spi_driver xpt2046_driver = { .driver = { .name = "xpt2046", .of_match_table = xpt2046_of_match, }, .probe = xpt2046_probe, .remove = xpt2046_remove, }; module_spi_driver(xpt2046_driver);对应的设备树节点这样写:
&spi0 { status = "okay"; xpt2046@0 { compatible = "mycompany,xpt2046"; reg = <0>; spi-max-frequency = <2000000>; interrupt-parent = <&gpio0>; interrupts = <20 IRQ_TYPE_EDGE_FALLING>; }; };这里有个细节,Linux的SPI框架要求片选号通过reg属性指定,而且SPI设备的地址是“挂在哪条片选上”的编号,不是I2C那种器件地址。spi-max-frequency别给太高,XPT2046虽然号称时钟能跑几MHz,但实际采样时给太高容易采到毛刺,2MHz左右比较稳。
I2C的GT911也差不多,i2c_driver + 设备树节点:
&i2c1 { status = "okay"; gt911@5d { compatible = "mycompany,gt911"; reg = <0x5d>; interrupt-parent = <&gpio0>; interrupts = <23 IRQ_TYPE_EDGE_FALLING>; reset-gpios = <&gpio0 22 GPIO_ACTIVE_LOW>; interrupt-gpios = <&gpio0 23 GPIO_ACTIVE_LOW>; }; };i2c_driver的注册代码和SPI版本结构类似,区别在于i2c_client的访问方式。这里要记得在probe里用devm_request_threaded_irq申请中断,并且用request_threaded_irq的线程化机制来处理后续的I2C读写,因为I2C传输是可能睡眠的,不能在普通中断上下文里直接调用。
3.2 中断处理与坐标上报:input子系统核心API
Linux把键盘、鼠标、触摸屏这类设备统一抽象成输入设备,驱动要做的事情就三件:分配input_dev、设置能力位、上报事件。
分配和注册的流程是固定的:
struct input_dev *input = devm_input_allocate_device(&spi->dev); if (!input) return -ENOMEM; input->name = "xpt2046 Touchscreen"; input->id.bustype = BUS_SPI; __set_bit(EV_ABS, input->evbit); input_set_abs_params(input, ABS_X, 0, 4095, 0, 0); input_set_abs_params(input, ABS_Y, 0, 4095, 0, 0); input_set_abs_params(input, ABS_PRESSURE, 0, 255, 0, 0); ret = input_register_device(input);注意input_set_abs_params的第三个参数,电阻屏12位ADC采样的原始范围是0~4095,所以这里设置成4095。后面做坐标映射时,再把原始值换算成屏幕分辨率。
中断处理这里有两种写法。XPT2046因为在PENIRQ触发时,芯片并不缓存坐标数据,需要主机主动发起SPI读操作去采样,所以比较适合在线程化中断里直接读。GT911则不同,芯片内部已经按一定频率扫描出坐标并缓存了,驱动只要在中断触发后去寄存器里把坐标取出来即可。
以GT911为例,中断处理的核心逻辑:
static irqreturn_t gt911_irq_handler(int irq, void *dev_id) { struct gt911_dev *ts = dev_id; u8 buf[6]; int count, i; count = gt911_read_reg(ts->client, 0x814E) & 0x0F; if (count == 0 || count > 5) return IRQ_HANDLED; for (i = 0; i < count; i++) { if (gt911_i2c_read(ts->client, 0x8150 + i * 6, buf, 6) != 2) continue; if (buf[0] & 0x80) { int x = ((buf[2] & 0x0F) << 8) | buf[1]; int y = ((buf[4] & 0x0F) << 8) | buf[3]; input_report_abs(ts->input, ABS_X, x); input_report_abs(ts->input, ABS_Y, y); input_report_abs(ts->input, ABS_PRESSURE, buf[5]); } } input_sync(ts->input); return IRQ_HANDLED; }这里有个关键点:GT911是16位寄存器地址,所以gt911_read_reg不能直接用i2c_smbus_read_byte_data,得自己封装带两字节地址的i2c_transfer。坐标数据是12位的,由高4位和低8位拼接而成。判断触摸是否按下,看状态字节的最高位。
3.3 坐标映射、旋转、防抖这些绕不开的细节
驱动把原始坐标上报出去,并不代表工作就结束了。你很快会发现,手指在屏幕上横着滑,光标却是竖着走的,或者点击左上角,光标落在右上角。这就是坐标映射和方向问题。
坐标映射分两层:第一层把ADC原始值换算成屏幕分辨率,第二层根据安装方向做翻转或旋转。换算公式就是线性比例,比如电阻屏的X轴原始范围是0~4095,屏幕宽度是800,那么:
x = (x_raw * 800) / 4095; y = (y_raw * 480) / 4095;听起来很简单,但实际项目里屏幕模组的安装方向五花八门,有的朝上,有的朝下,有的电线朝左。在通用驱动里,内核的touchscreen库提供了几个设备树属性来解决这个问题,比如touchscreen-inverted-x、touchscreen-inverted-y、touchscreen-swapped-x-y。建议自己写的驱动也读取这几个属性,方便后人调整方向而不需要改代码。
防抖滤波则是另一件必须做的事,尤其是电阻屏。由于ADC采样噪声和接触不稳定,单次采样的坐标经常会跳变几格甚至几十个像素。处理办法一般是在驱动里做一个简单的滑动平均,比如连续采5次,去掉最大值最小值后取平均。电容屏因为芯片内部已经做了滤波,驱动里一般不额外处理,但注册输入设备时可以把ABS_PRESSURE的fuzz参数设大一点,让内核层面帮忙过滤一下抖动。
4. 调通全过程的坑与排查方法
4.1 中断来了但读不到坐标:SPI时序配置错了
这是电阻屏驱动最经典的故障现象:用手点屏幕,cat /proc/interrupts能看到中断号在增长,但坐标数据读出来全是0xFF或者乱码。用示波器抓SPI引脚,波形看着也对,命令发出去了,MISO上也有数据,但解析出来不对。
问题多半出在SPI模式上。XPT2046要求的是SPI Mode 0,也就是CPOL=0、CPHA=0。Linux的spi_device结构体里通过mode成员配置,你如果设备树里spi-max-frequency写太高,或者驱动里mode设置不对,特别是CPHA配置反了,采样时序就会错位,读出来永远是错的数据。
我当时排查这个问题的过程也挺曲折。一开始以为是FPGA里的SPI控制器有问题,后来用逻辑分析仪抓波形,对比芯片手册上的时序图,发现命令字节确实发出去了,但是MISO上返回的数据比预期晚了一个时钟周期。就是因为CPHA配置不对。把mode改成SPI_MODE_0之后,一次通过,读出来的坐标非常稳定。
还有一个隐藏的坑:XPT2046在刚上电或者长时间没有触摸时,模拟部分会进入休眠省电状态。中断触发后立刻发起SPI读操作,有可能因为内部还没来得及唤醒而读到错误数据。稳妥的做法是在probe函数里先做几次“空的转换”,把ADC唤醒之后再用,或者每次中断后加一小段延时再采样。
4.2 触摸点跟屏幕显示错位、方向反转
这个现象在GT911电容屏上更容易遇到。显示明明是正常的,触摸也响应,但点屏幕的左边,鼠标出现在右边,点上面,光标出现在下面。这种情况基本可以断定是坐标系方向不一致。
Linux里有一个坐标系约定,输入设备的坐标原点在屏幕左上角,X向右递增,Y向下递增。但触摸屏模组安装后,物理坐标的初始方向未必和屏幕显示方向对齐,可能旋转180度,也可能X和Y互换了。
排查步骤很简单,先用evtest看事件上报的原始值,然后做一个系统的坐标检测:依次点击屏幕的四个角和中心点,看原始坐标值的递增方向跟屏幕方向的关系。比如我点左上角,原始值显示(X=4000, Y=4000),点右下角显示(X=100, Y=100),那说明两个轴都反了。这就在设备树里加上touchscreen-inverted-x和touchscreen-inverted-y。
还有一种更隐蔽的情况:X轴反了,而且X和Y也互换了。比如屏幕横屏安装,但触摸芯片的物理X轴恰好对应屏幕的显示Y轴。这个用设备树属性同样能处理,kernel的touchscreen_parse_properties函数已经把磁带翻转涉x-y互换的解析做进去了。
4.3 点击没反应:问题可能出在设备树和事件节点
如果中断也不触发,事件节点也没生成,那问题可能不在驱动代码本身,而是设备树里中断资源没配好。最常见的是interrupt-parent指向了错误的GPIO控制器。ZYNQ平台上,PS侧的MIO和通过EMIO扩展的PL引脚,GPIO控制器的实例编号不同,中断号也不同。设备树里写错一个数字,中断子系统匹配不到对应的GPIO,probe函数就会失败。
排查思路是这样:先看内核日志,dmesg里有没有我们这个驱动的报错信息。再看/sys/bus/i2c/devices/或/sys/bus/spi/devices/下有没有对应的设备文件夹。如果有设备但没中断,多半是interrupts属性里的GPIO编号跟实际接线不符。
另外一个小技巧是,在probe函数和中断处理函数里都加上dev_info或dev_dbg打印。模块加载的时候,看probe有没有被执行;中断触发的时候,看handler有没有被调用。这样能把问题快速定位到“驱动没加载”还是“中断没触发”还是“中断触发了但数据不对”这三个阶段之一。
4.4 电容屏第一次上电没反应:复位时序是关键
GT911这种芯片和传统的I2C传感器还不一样,它在上电后需要主机配合初始化时序,芯片才会正常响应I2C命令。如果你的驱动probe里直接就去读寄存器,大概率读回来的全是0xFF,i2cdetect也扫描不到设备。
正确的上电流程应该是:先给屏幕供上电,延时一段时间等电源稳定,然后拉低INT引脚,再拉低RST引脚,保持至少10ms,然后把RST拉高,再延时10ms,最后把INT拉高。完成这个时序之后,GT911才会切换到正常工作模式,I2C地址也才会生效。
这里有一个特别容易踩的坑:GT911有两个可选I2C地址,通过复位时INT引脚的电平状态来决定。INT拉低复位,地址是0x5D;INT拉高复位,地址是0x14。很多初始化代码先拉高INT再复位,结果地址就变成0x14了,和你设备树里写的0x5D对不上,自然怎么都探测不到。我自己的做法是在复位流程里把INT和RST都明确地拉低,确保地址稳定在预设值。
5. 实测调通的完整流程:从设备树到事件上报
5.1 触摸和显示联调,先上设备树
下面以我手头的一块ZYNQ板子为例,记录一次完整的调通过程。屏幕是7寸800x480的RGB电容屏,触摸芯片是GT911,I2C挂在PS端的I2C1上,中断引脚接到PL侧的GPIO。
第一步,先在设备树里把I2C节点和GT911节点配上。需要注意,ZYNQ的I2C控制器和设备树节点的对应关系要查清楚,I2C1对应的是&i2c1,里面的时钟频率配置也要和实际硬件匹配。
&i2c1 { status = "okay"; clock-frequency = <100000>; gt911@5d { compatible = "mycompany,gt911"; reg = <0x5d>; interrupt-parent = <&gpio0>; interrupts = <23 IRQ_TYPE_EDGE_FALLING>; reset-gpios = <&gpio0 22 GPIO_ACTIVE_LOW>; interrupt-gpios = <&gpio0 23 GPIO_ACTIVE_LOW>; touchscreen-size-x = <800>; touchscreen-size-y = <480>; }; };设备树编译、烧录、启动之后,先别急着加载驱动,用i2cdetect确认芯片在总线上能被扫描到。扫描结果里能看到5d这个地址的设备,说明芯片上电成功,初始化时序对了,地址也对了。
5.2 驱动模块加载与事件节点验证
驱动编译成内核模块,insmod加载,然后看dmesg输出。正常情况probe会被调用,打印出我们加的调试信息,然后注册输入设备,生成/dev/input/event0节点。
用hexdump验证数据是接下来最直接的一步:
hexdump /dev/input/event0手指点在屏幕上移动,终端里应该会不停地刷出事件数据。每个输入事件是struct input_event结构体,16字节,里面有类型、编码、数值。看到EV_ABS和ABS_X/ABS_Y事件,说明驱动从底层到上层已经打通了。
如果hexdump有输出但evtest显示的坐标范围不对,那就需要在驱动里重新检查坐标换算。我之前踩过这种坑:GT911内部报告的坐标范围其实是通过配置寄存器设置的,默认可能是1024x600或者别的值,和屏幕的800x480不一致。这时候要么在驱动里做换算,要么在初始化GT911时往它的配置寄存器里写入正确的坐标上限,让它输出的原始值本来就落在0~800和0~480范围。
5.3 用evtest做最终验证
等hexdump确认事件在流动,再用evtest做UI层面的验证。evtest会显示每次事件的详细信息,包括类型、编码、值,还能看到ABS_X的range是多少。
我通常会在屏幕上画一个十字线,然后依次点击中心和四个角,看上报的坐标是否符合预期。如果中心点上报的值大约是(400, 240),四个角的数值也都对得上,那驱动这一层的坐标映射就算是完全没问题了。
最后一步,接上Qt或者GTK应用,用滑动手势测试流畅度。这一步主要感受触摸事件的连续性,如果光标移动一顿一顿的,多半是中断频繁且每次只上报了一次坐标,可以在中断处理里把芯片缓存的所有点都读出来再上报,或者提高芯片内部扫描频率。GT911这个芯片本身就有扫描缓存功能,即使中断上报频率不高,坐标数据也应该是连续的。
6. 几个能直接用的调试命令和真实心得
调试触摸驱动,有一些命令是高频使用的,我整理一下,方便你直接抄走:
i2cdetect -y -r 1:扫描I2C总线1上的设备地址,GT911没扫描到,先检查复位时序和地址配置。cat /proc/interrupts | grep gpio:确认触摸中断有没有触发。如果不增长,检查设备树的中断引脚配置,或者用GPIO调试接口确认引脚状态。evtest /dev/input/event0:查看事件上报的详细内容,坐标范围对不对、压力值有没有,一眼就能看出来。hexdump /dev/input/event0:轻量验证,适合在没有evtest的板子上快速确认事件是否在流动。dmesg | grep gt911:驱动自身的打印信息,probe成功没、中断申请成功没,都有记录。
还有一些日常文档里不太会写的经验,我觉得比命令本身更值钱。
第一,驱动代码里一定要加probe成功、中断触发、坐标读取三个关键节点的调试打印。交付之前可以关掉,但开发阶段不要嫌log多。触摸驱动不像纯逻辑代码,你没法单步调试,打印就是唯一的眼睛。
第二,示波器或者逻辑分析仪是必需品,但别一上来就抓时序。先确认软件层面的寄存器读写正常没有,再确认中断有没有触发,最后才上示波器看波形。除非你怀疑硬件连接有问题,否则直接抓波形往往事倍功半。
第三,FPGA+Linux的调试,和纯FPGA调试有个很大的心理差异。FPGA里出了问题,第一反应是看RTL逻辑哪里写错了;Linux系统里出了问题,第一反应应该变成了看设备树、看内核日志、看中断有没有触发。这个思维转换非常重要。我见过不少FPGA工程师转做Linux驱动时,还在用仿真的思维去排查驱动问题,结果陷入死胡同。驱动开发的核心是“看系统实际状态”,不是“推演逻辑”。
第四,如果发现坐标有固定偏移,比如触摸点整体偏右下,但方向和大小都对,那大概率不是算法问题,而是屏幕的触摸有效区域和显示区域的物理偏差。这种问题在批量生产的模组上比较常见,可以在用户空间做一个校准,四角采样后生成校准参数,比在驱动里硬编码更灵活。
最后再说一个容易忽略的点:VCC供电。7寸屏幕的触摸芯片供电一般是3.3V或者2.8V,如果供电电压不足或者纹波大,触摸坐标会出现随机跳变,而且是在整个屏幕上无规律跳,看起来特别像软件滤波没做好。排查这种问题,用示波器看触摸芯片供电引脚的纹波,比调代码快得多。我就遇到过一块屏幕触摸不稳,折腾两天滤波算法都没用,最后发现是背光供电和触摸供电共用一个LDO,背光一亮就拉低触摸电压,造成干扰。把这个供电拆开之后,问题彻底消失。
触摸驱动的开发,说到底是把物理世界的“按”和数字世界的“坐标”连接起来。摸清原理、看懂时序、会查日志,剩下的就是耐心和细心。