FPGA模拟PS/2键盘

FPGA模拟PS/2键盘

  • ———— VerilogHDL + SpinalHDL

众所周知,PS/2是一种很常见的键盘鼠标接口,很多开发板上都有,不论是单片机还是FPGA,基本例程都少不了PS/2控制器。但是,绝大部分代码都是作为PS/2主机来读取键盘鼠标发送的数据,很少有作为键盘鼠标设备来跟电脑通信的。Arduino倒是有几个PS/2键盘的库,只是难以移植,而用FPGA实现PS/2设备的方案,一个字都搜不到!因此作本文以记录笔者实现FPGA模拟PS/2键盘的全过程,希望能对大家有所帮助。

1. PS/2协议简介

1.1 首先,重要的话说三遍

  • PS/2接口不支持热插拔!!!

  • PS/2接口不支持热插拔!!!

  • PS/2接口不支持热插拔!!!

1.2 参考资料

《PS2技术参考》(Adam Chapweske著)是教科书级的参考资料,一定要潜心拜读。文章较长,一次性读完肯定记不住,最好先大致浏览,待遇到问题时反复查阅。

1.3 “协议栈”

为便于理解,在此冒用现代网络技术的概念。

1.3.1 物理层

连接器
通常电脑上是母的,键盘和鼠标是公的,键盘是紫色,鼠标是绿色。时钟线和数据线都是5V,兼容3.3V CMOS。时钟信号总是由设备产生,时钟周期一般取40us。

1.3.2 数据链路层

与常用的串口类似,不管是上行还是下行,每帧都包含以下内容:

1个起始位 总为0
8个数据位(payload) 低位(LSB)在前
1个校验位 奇校验
1个停止位 总为1

此外,在主机到设备的传输中,最后还有一个应答位。
记住这些,很重要。

1.3.3 传输层

这一层将低层与高层解耦合,将发送与接收的细节封装起来,为应用层打基础。
需要注意的问题有帧之间的延时、发送与接收间的干扰、与高层的总线时序等。

1.3.4 会话层

键盘与鼠标在本层分道扬镳,本文仅讨论键盘,不涉及鼠标。
要让电脑(host)识别到你写的键盘(device)是个艰巨的任务。电脑会在开机时发送一连串指令,每条指令都要得到正确的响应,稍有不慎就会被当做无效设备而抑制通信,严重时甚至使电脑无法正常启动!
因为会话是在开机时建立的,所以开机后再插入键盘是无效的,正所谓 “不支持热插拔”

1.3.5 应用层

成功被主机识别后,就可以愉快地发送扫描码了。🚀当键盘检测到有键按下时发送通码,有键擡起时发送断码。断码即在通码前加了一字节“F0h”。并不是每个键的通码都只有一字节,有的键Prt Sc甚至没有断码。

2. Arduino开源库移植测试

先用单片机把协议学明白了才能用FPGA实现

Arduino有很多模拟PS/2键盘的库,随便找一个能用的移植到正点原子的mini板上(因为笔者手头上只有这块单片机开发板带PS/2接口),点击下载完整工程

2.1 GPIO配置——物理层

首先一定要看清楚这个引脚耐压有没有5V!⚡️因为主机上带5V上拉,所以开发板有没有上拉电阻都无所谓,配置为开漏输出即可——STM32F1系列的GPIO配置成开漏输出时也是可以直接读IDR的。

2.2 观察总线波形——数据链路层

串行总线的本质是波形图

判断键盘有没有被成功识别最简单的办法就是发送一个键看电脑有没有反应,但这个过程很难用FPGA实现(尤其是还有bug的时候),所以调试时一般用示波器观察,正好双通道。用公对公PS/2线连接开发板与电脑,时钟线与数据线对应的IO已由排针引出,反复开关机观察示波器有以下发现:

  • 总线空闲时均为高电平,但时钟线会周期性地被主机拉低
  • 若未正确应答主机的指令,时钟线会被永久性拉低
  • 通常主机接收完一帧的瞬间会立即拉低时钟来抑制通信
  • 主机可能不释放时钟就开始发送
  • 主机发送时,数据在时钟的下降沿转变
  • BIOS初始化时有密集通信,Windows初始化时只发送LED

2.3 记录初始化过程——会话层

用串口输出PS/2总线上传输的每一字节:

initing...
sending aa
sent  
init ok!!!
received 0xf5 
sending fa
sent  
received 0xff 
sending fa
sent  
sending aa
sent  
received 0xed 
sending fa
sent  
received 0x2 
sending fa
sent  
received 0xf5 
sending fa
sent  
received 0xf4 
sending fa
sent  
received 0xf5 
sending fa
sent  
received 0xf4 
sending fa
sent  
received 0xed 
sending fa
sent  
received 0x2 
sending fa
sent  
received 0xf5 
sending fa
sent  
received 0xf4 
sending fa
sent  
received 0xf5 
sending fa
sent  
received 0xf4 
sending fa
sent  
received 0xed 
sending fa
sent  
received 0x2 
sending fa
sent  
received 0xff 
sending fa
sent  
sending aa
sent  
received 0xf3 
sending fa
sent  
received 0x0 
sending fa
sent  
received 0xed 
sending fa
sent  
received 0x0 
sending fa
sent  
received 0xed 
sending fa
sent  
received 0x0 
sending fa
sent  
received 0xf3 
sending fa
sent  
received 0x8 
sending fa
sent  
received 0xf3 
sending fa
sent  
received 0x20 
sending fa
sent  
received 0xed 
sending fa
sent  
received 0x2 
sending fa
sent  
received 0xf3 
sending fa
sent  
received 0x20 
sending fa
sent  
received 0xed 
sending fa
sent  
received 0x0 
sending fa
sent  
received 0xed 
sending fa
sent  
received 0x2 
sending fa
sent  

3. 时序逻辑基础——Verilog描述数据链路层

看波形写电路是做数字逻辑设计最基本的能力之一。PS/2的时序比较特殊的地方在于,它的数据线不能与时钟线同时变化,因此整个模块的时钟域是总线时钟频率的4倍。此外,PS2_CLKPS2_DAT是连接到片外的三态线,发送电路与接收电路分别并联于其上。

3.1 发送电路

发送电路用来实现设备到主机的通讯过程。

3.1.1 时序图

发送时序
发送时序如此简单,却并不容易实现,因为规定从时钟脉冲的上升沿到一个数据转变的时间至少要有5微秒,数据变化到时钟脉冲的下降沿的时间至少要有5微秒并且不大于25微秒 。时钟频率可取10 ~ 16.7 kHz,亦即每个时钟脉冲的宽度在30 ~ 50微秒。

具体细节详见《参考》,此处不再赘述。

笔者采用一个4进制计数器来产生1 bit信号计数到00和10时翻转时钟线,计数到11时改变数据线,以此保证两根线不会同时改变。时钟信号的脉宽取40微秒,因此计数器的时钟就要有10微秒的脉宽,称之为clock_quarter

3.1.2 原理图

除去三态总线,整个模块包括4个输入信号与3个输出信号。

  • clock_quarter作为整个电路的时钟,quarter代表其四分频后为总线时钟频率。
  • start的上升沿开始发送
  • ready代表空闲
  • abort为高代表主机抑制(inhibit)了传输
  • finish在发送完成后产生一节拍脉冲
    原理图

3.1.3 验证

完整代码已开源。用示波器观察输出波形:tx
显然,黄色是数据线,蓝色是时钟线,两根线的边沿是正好交错开的,满足协议的要求。检查脉宽正常就大功告成了。

3.2 接收电路

跟发送电路有异曲同工之妙,主要区别在payload的移位寄存器。

3.2.1 时序图

接收时序
注意,host是不产生时钟信号的,但具有优先控制权,这正是PS/2总线的巧妙之处。PS/2总线与I2C总线都是一根时钟一根数据、开漏、分主从;但I2C可以一主对多从而PS/2只能一对一;PS/2的从机可以随时发送数据而I2C不行。

3.2.2 原理图

与发送模块相比唯一的区别就是faild代表校验错误或没有停止位。至于为什么叫buffer,是因为数据会一直缓存到下一次接收。
q

3.2.3 状态图

每个状态都有自环是在等待bit_cnt数到11b,代表总线传输了1bit。START回到IDLE是因为没有等到主机拉低数据线(起始位)。
状态机

3.2.4 验证

先上源码。调试时可以接一个uart模块,输出总线上读到的数据。下面是两台不同的电脑发送时的实际波形。
ed
可以看出,主机都是在时钟下降沿转变数据线的,这与上行的时序完全不同。注意下图中的低电平部分,主机拉低的时候比从机拉低的时候要更低一些、过冲更大,这应该与参考点的选取有关,调试时可利用这一特征来查错。
ed


3.3 代码解读

发送电路与接收电路分别在两个module中,但存在大量的相似代码。

3.3.1 位计数器

reg[1:0] bit_cnt;是两个电路的“引擎”。

always @(posedge clock_quarter)
	if (curr_state != IDLE && curr_state != START)
		bit_cnt <= bit_cnt + 1;
	else
		bit_cnt <= 0;

它直接生成总线时钟信号assign PS2_CLK = (^bit_cnt) ? 1'b0 : 1'bz;
wire bit_finish = bit_cnt == 2'b11;也是非常重要的信号,它贯穿整个电路。

3.3.2 数据

发送和接收payload都是状态机里的一个状态,此处需要一个计数器:

// count 8 bit when sending data
always @ (posedge clock_quarter)
	if (curr_state != DATA)
		byte_cnt <= 0;
	else if (bit_finish)
		byte_cnt <= byte_cnt + 1;
	else
		byte_cnt <= byte_cnt;

下面就不同了,接收时使用串入并出移位寄存器:

always @(posedge clock_quarter, posedge reset)
	if (reset)
		buffer <= 8'h0;
	else if (curr_state == START)
		buffer <= 8'h0;
	else if (curr_state == DATA && bit_finish) // latch PS2_DAT at posedge PS2_CLK
		buffer <= {dat_sync, buffer[7:1]};  // LSB first, right shift
	else
		buffer <= buffer;

发送时,因为校验位在payload后面,移位了就没了,所以笔者选择用MUX实现:

always @ *  // asserting PS2_DAT before negedge PS2_CLK
	if (!tx_en)
		PS2_DAT = 1'bz;
	else if (curr_state == START)
		PS2_DAT = 1'b0;
	else if (curr_state == DATA)
		PS2_DAT = buffer[byte_cnt] ? 1'bz : 1'b0;
	else if (curr_state == PARITY)
		PS2_DAT = (^buffer) ? 1'b0 : 1'bz;
	else
		PS2_DAT = 1'bz;

3.3.3 ready / finish

ready都是一样的assign ready = curr_state == IDLE;
finish有点不同,发送时是这样的assign finish = curr_state == STOP && bit_finish;,接收时是assign finish = rx_timeout || (bit_finish && curr_state == ACK);,其实也就是多了个timeout,用于等待主机产生起始位。
事实上,finish完了马上就是ready。

3.3.4 failed / abort

发送失败叫abort:

assign abort = !tx_delay && bit_finish && !PS2_CLK;

接收失败叫failed:

assign faild  = rx_timeout || (bit_finish && (
	(curr_state == PARITY  && (^buffer) != dat_sync) |
	(curr_state == STOP    && !dat_sync)));

前文之述备矣。

4. 封装与耦合——传输层

传输层承上启下,通过空闲、发送、接收三个状态,将下层的发送电路与接收电路耦合起来,并对上层封装所有细节,使上层得以专注于总线上传输的数据,此之谓传输层
此外,为了上层使用SpinalHDL描述,需要将本层电路写成blackbox🙈

4.1 原理图

黑盒子
从上层往下看,只有如下几个信号:

信号 类型
clock_quarter / reset ClockDomain
tx slave Stream
rx master Flow
tx_failed output reg
PS2_CLK / PS2_DAT inout tri

庞大的电路被封装成一个小黑盒子(下图红线即连接到片外的PS/2总线):
全图
SpinalHDL里的BlackBox:
scala
在SpinalHDL中例化模块非常方便:



  val bus = new ps2_bus(80)
  bus.PS2_CLK <> PS2_CLK; bus.PS2_DAT <> PS2_DAT
  bus.tx.valid := False
  bus.tx.payload := B(0)

4.2 对仗工整,强行押韵

强迫症为了对齐,强行让read对send,没想到还押韵了呢,整个状态转移浑然天成🎉🎉🎉
状态机

4.3 实际波形

依然是两台不同的电脑,两图都是在设备发送FAh后电脑立即拉低时钟线。
fa
上图是在短暂释放后紧接着又请求发送,而下图根本就没释放,直接进入BUS_READ状态。上图还说明了一个问题,当主机发送数据时,若设备不产生时钟,则主机会等待相当长一段时间。而下图可以看出,主机发送结束的瞬间(读到ACK位)就会拉低时钟线。此外,主机几乎可以在任何时候拉低时钟线来中止传输,这些都是需要考虑的细节。
发送接收
观察这两图可以看出,上行帧和下行帧都是从数据线的下降沿开始的,区别在于上行帧开始时时钟为高,而下行帧反之。熟练掌握看波形读数据的能力对后面会话层的调试有很大帮助。



4.4 为什么发送电路不会干扰接收电路

笔者在写代码之前就在考虑这个问题,直到稀里糊涂地写完了之后还是没明白过来——这么写咋就能用了呢?作为“能用就行”的👷工程师,笔者一直没有去深究,但现在要写文档了,总得有个严谨的证明,以彰大国工匠精神。
那么我们自顶向下看。首先在ps2_bus模块中,总线是直接连进发送电路和接收电路的,本层没有任何drive,说明这里不会产生干扰。
bus
但是有个叫clk_sync的寄存器,这是检测时钟线有没有被主机拉低的,那么就有这种可能——设备发送过程中这里检测到低电平,误判为电脑的发送请求。但是我们的接收电路只有在其start信号的上升沿才会开始产生时钟信号从主机读取,而start信号的定义是wire start_rx = curr_state == BUS_READ && rx_idel;也就是上图中叫“comb~1”的与门。那么问题来了,什么时候会进入BUS_READ状态呢?现在翻上去看状态转移部分,发现只有在BUS_IDLE状态才有可能转移过去,这样一来就保证了发送过程中不可能触发接收动作。同理,在接收时也绝对不会发送,就这么简单。


5. 翻译伪代码——Spinal描述会话层

话说笔者当初用Verilog写本层的时候耗时近半个月才调通💩,后来学了Spinal,几个小时搞定,上板一次成功。

SpinalHDL,用了都说好~

5.1 是什么

电脑开机时会发送一系列指令来检测这个PS/2接口上是否插入了一个键盘,称之为“会话层”可能并不恰当,此处仅借用这一概念便于理解。
除了FEh与EEh外,对于电脑的每个指令,设备都必须回应FAh。如果设备正发送多字节指令时被主机打断,那么设备应清空发送缓冲区并优先处理主机发送的指令。一旦开机时识别到了键盘,进入系统后每次Caps Lock、Num Lock、Scr Lock变化时主机都会立即发送相应指令告知键盘哪个LED应该点亮。关于指令的具体含义详见《参考》,实际的过程已在2.3节给出。需要注意的是,上电后设备应不停地发送AAh,直到发送成功为止(因为刚启动时总线电平不稳定)。

5.2 为什么

5.2.1 先说结论

为什么要用SpinalHDL?因为香。

5.2.2 再说理由

可以略微夸张地说,Spinal在各方面全面碾压Verilog,如果要举例子那真的是多如牛毛,不如先上手试试,谁用谁知道。
就拿本层来说,笔者起初用Verilog写,不清楚应有几个状态,来来回回改了好多次,每次改状态都要

  • 在localparam处修改定义
  • 修改state寄存器位数
  • 修改状态转移的always块

但Spinal只需要

  • 定义新状态,开始写…

5.3 怎样做

首先,用中文描述一下这个过程

  • 空闲状态
    • 总线 发送=否
  • 初始化状态(入口)
    • 总线 发送=是
    • 总线 发送 数据=8‘hAA
    • 当(总线 未发送失败 且 正在发送)
      • 转到 空闲状态
  • 接收状态
    • 总线 发送=否
  • 应答状态
    • 当 正在发送
      • 如果 总线 接收 数据
        • 是0xFF:转到 初始化状态
        • 是0xED:转到 接收状态
        • 是0xF3:转到 接收状态
        • 其他:转到 空闲状态
    • 总线 发送=是
    • 总线 发送 数据=8’hFA
  • (每个状态)总是
    • 当 总线 接收 有效
      • 转到 应答状态

接下来,翻译翻译,什么叫SpinalHDL!

中文 英文
状态 State
总线 bus
发送 tx
接收 rx
is
when
转到 goto
入口 EntryPoint

好了,如果中文描述能看懂,翻译就是查表,那么下面的代码谁也没有理由看不懂!
状态机
不要怀疑,我们就是在写硬件~
会话
看一下它生成的电路图吧。



  • 用了spinal,谁还想回到三段式呢?

6. TODO:应用层

《参考》中提到,Intel 8042有4个8位寄存器,分别是输入缓冲区、输出缓冲区、状态寄存器和控制寄存器。笔者计划将会话层完善封装后挂载到AvalonMM总线,在nios里编写应用层软件。欲知后事如何,且看下回分解……

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章