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裏編寫應用層軟件。欲知後事如何,且看下回分解……

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