海思3516系列晶片SPI速率慢問題深入分析與優化(基於PL022 SPI 控制器)
我在某個海思主控的專案中需要使用SPI介面來驅動一塊液晶屏,液晶屏主控為 st7789,解析度 240x240,影象格式 RGB565。
查閱海思相關手冊可知,Hi3516EV200 的 SPI 最高速率為 50MHz,理論上每秒鐘可以傳送 50M/8=6.25MB 資料。假設我需要在螢幕上以30fps的速率全螢幕實時顯示攝像頭的預覽畫面,每秒的資料量為 240*240*2*30=3456000B=3375KB=3.296MB
,假設 SPI 工作在阻塞模式,則 cpu 使用率為 3.296/6.25*100%=52.7%
,看起來還不錯。如果我想進一步降低cpu使用率還可以降低預覽圖解析度,降低影格率,比如我可以按 240*180 20fps 的規格顯示縮圖,那麼cpu使用率就可以降到 240*180*2*20/1024/1024/6.25=26.4%
。
上面這些都是我在寫程式碼前的理論分析,真實效果當然需要寫程式簡單測試一下。測試程式碼內容大致如下:使用原生linux提供的 SPI 介面,即/dev/spidevX.X
,主程式中一個死迴圈沒有sleep,使用不同的顏色不停地刷全螢幕,看一秒鐘能達到多少次,也就是重新整理率能達到多少幀。
實際測試結果,每秒大約5次!肉眼可見的慢!能很明顯看到刷屏的時候是從上到下覆蓋過來的!
這麼慢的速率就算以240*180的解析度來重新整理預覽畫面也只能達到7、8幀的水平,何況cpu使用率是100%,其他業務也沒辦法正常執行,所以spi的速率必須優化。
第一步當然是用邏輯分析儀或示波器抓實際波形,觀察是時鐘訊號沒有達到50MHz,還是有其他地方浪費時間。對於邏輯分析儀而言,要考慮取樣率是否足夠,例如邏輯分析儀的取樣率是100MHz,理論上可以採集50MHz的訊號,實際採集50MHz的訊號很可能採集不準;示波器也一樣,示波器需要考慮的是頻寬,一般入門級示波器都有100MHz頻寬,問題不大。對了,別拿DIY的示波器來搗亂。
這張圖就是我通過示波器抓到的海思 SPI 時鐘線上的波形,問題非常明顯,傳送的兩個位元組之間間隔了 1.656us!開發過任意微控制器的spi並實際抓過波形的朋友應該都知道,spi的時鐘幾乎是連續,正常情況下絕對不可能間隔這麼長。
再檢查一下spi的時鐘有沒有達到理論的50MHz,8個clk共計160ns,沒問題,達到了理論速率。
題外話:為什麼50MHz的方波在示波器上顯示為正弦波?因為我這款示波器的頻寬只有100MHz,50MHZ方波訊號本身沒有超過示波器頻寬的上限,但是它的2n+1次諧波分量遠遠高於示波器頻寬。所以想要勉強看到50MHz方波的波形,那麼示波器至少要能採集到3次諧波訊號,也就是150MHz的訊號,這就需要一個200MHz頻寬的示波器(我買不起!)
簡單計算一下,傳送一個位元組需要 0.16+1.656=1.816us,也就是1秒鐘可以傳送 1s/1.816us=549450B=536.6KB 的資料,這個資料量甚至不足理論值的十分之一,換算下來確實1秒鐘也只能刷5屏。所以接下來的目標就是找出到底是什麼原因讓傳送的兩個位元組之間佔用了足足1.6us。
首先我的傳送程式碼中沒有加任何延時函數,所以這1.6us的延時只能來自於linux核心spi驅動。
檢視linux核心spi相關原始碼(提前安裝好完整的海思開發環境,並下載對應版本linux核心原始碼,打海思linux原始碼patch)
linux-4.9.y\drivers\spi\spi-pl022.c
由原始碼可知海思的spi使用的是 ARM PrimeCell SSP (PL022) 控制器,這些驅動程式碼應該非常成熟了,不會存在這麼明顯的問題,大概看了看原始碼也沒發現啥耗時的地方,之後專門以 ssp-pl022 和 delay 作為關鍵字google一番也沒有發現類似的問題。思索了一下,我決定放棄修改核心驅動,首先核心程式碼很成熟,基本輪不到我這種小角色去debug,其次每次修改都要重新編譯並燒錄核心,太麻煩。所以我決定自己重寫一個spi核心模組ko驅動。
非常幸運,在海思SDK的 drv.tgz\drv\extdrv\ssp-st7789
中正好有原始碼,連晶片型號都一樣,基於這個程式碼做一些簡單修改就能用。
經過我的一番修改,實際測試下來發現這個ko模組的效能比linux核心spi的效能有一點點提升,延時從1.656us降低到了1.6us以內,基本等於沒有優化,這裡就不放截圖了。
這個程式碼就比較簡單,讀起來也不費勁,大概看了一下還是能找到一點優化的地方,注意看spi_write_XXbyte
這幾個函數,這幾個函數內都有這樣的程式碼
spi_enable();
ssp_writew(SSP_DR,spi_data);
ret = hi_spi_check_timeout();
if(ret != 0)
{
printk("spi_send timeout\n");
}
spi_disable();
在前面的程式碼中設定過CS片選訊號由spi使能訊號控制,也就是說,這段程式碼每次寫一個位元組都要拉一次CS訊號,效率比較低,我將 spi_enable 和 spi_disable 提出來後再次進行測試,速度確實有一定提升,但提升幅度仍然不大,只有大概幾百ns的水平,優化效果仍然不理想。
這段程式碼只剩下兩個函數了,ssp_writew 是寫暫存器,根本不能優化,只能想辦法優化 hi_spi_check_timeout,來看一下這個函數的實現
static int hi_spi_check_timeout(void)
{
unsigned int value = 0;
unsigned int tmp = 0;
while (1)
{
ssp_readw(SSP_SR,value);
if ((value & SPI_SR_TFE) && (!(value & SPI_SR_BSY)))
{
break;
}
if (tmp++ > MAX_WAIT)
{
printk("spi transfer wait timeout!\n");
return -1;
}
udelay(1);
}
return 0;
}
邏輯非常簡單,不停地讀取傳送FIFO空和SPI忙的標誌位,延時1us繼續讀,直到傳送完成且SPI空閒。看到這個 udelay(1) 你現在的想法肯定和我當時的想法一樣:第一次讀取發現暫存器沒有置位,延時1us,第二次讀取暫存器置位,退出迴圈,現在的傳送間隔是1.6us,去掉這個延時儘快讀取暫存器,應該直接能優化到0.6us以內。
想得美!實際測試去掉這個 udelay(1) 以後優化效果確實挺明顯的,延時直接縮短到 1.2us 左右,但是這個延時還是太長了。
現在再來看這個函數,就剩一個讀暫存器了,為了保證可靠傳輸,讀標誌位肯定不能去掉,這已經最簡單了,還能怎麼優化呢?我們重新梳理一下:去掉 udelay(1) 後間隔縮短到 1.2us 左右,說明這裡迴圈讀了很多次暫存器,不然怎麼還有這麼長的延時?那要不就加列印看看這裡到底迴圈讀取了幾次?來來來,競猜一下這裡到底迴圈了幾次?十次以內?百次以內?還是千次以內?答案是一次!沒錯只有一次!
1次這個答案可以說即在意料之外也在意料之中。
說它在意料之外是因為讀寫暫存器這種操作是非常快的,一般而言幾個cpu時鐘就能完成,但這裡讀一個暫存器卻花費了 1.2us。
說它在意料之內是因為這些物理暫存器都是通過硬體置位的,以傳送 FIFO 為空標誌為例,當 SPI 傳送完成瞬間它就會被硬體置位,這一點在任何一款微控制器上都可以驗證,實際操作一下就會發現類似的暫存器是瞬間被置位的。pl022 應該是非常成熟的 SPI 控制器,我覺得晶片設計人員不會範傳送 FIFO 為空後很長時間才設定標誌位這種低階錯誤。
接下賴重點思考這個問題:為什麼 ssp_readw(SSP_SR,value) 這樣一個簡單的讀暫存器操作要 1.2us 之久?
(目前我測試 ssp_writew 寫一個暫存器大概在 100ns 以內,ssp_readw 讀一個暫存器大概在 1us 左右)
這個問題無論是百度還是谷歌我找了很久都沒有找到確切答案,如果有知道的大佬非常歡迎指導!!!不白嫖知識,私信發紅包。
下面是我個人的推測,雖然是推測,但我覺得這就是正確答案,僅供參考:
linux 不同於微控制器裸機程式那樣可以直接存取暫存器,如果需要讀寫物理暫存器,在核心態使用 ioremap
(在使用者態使用 mmap
)將一段實體地址對映到核心態(或使用者態)的虛擬地址空間,再對對映後的地址進行讀寫。
來看一下實際讀寫暫存器的這兩個介面,很簡單就是直接讀寫某個地址處的資料(前提是這個地址必須經過對映)。
#define ssp_readw(addr,ret) (ret =(*(volatile unsigned int *)(addr)))
#define ssp_writew(addr,value) ((*(volatile unsigned int *)(addr)) = (value))
首先可以明確一點,讀寫暫存器的操作必然會經過MMU。
對於寫暫存器來說,不需要考慮同步、髒資料等問題,MMU 應該是直接將這個資料寫到實體地址了。
對於讀暫存器來說,有 volatile
關鍵字的存在,這裡的程式碼不會去優化,每次讀取必須從實體地址進行讀取,這裡可能需要 cache 回寫等操作導致導致讀取的速度非常慢。
在 u-boot 下可以直接讀寫物理暫存器,應該不需要這麼久,幾個CLK就可以完成吧?這一點我沒有驗證過,有測試過的朋友歡迎分享。
總之,耗時的地方找到了,想辦法優化吧。我這裡有兩種優化思路:
目前我採用的就是第二種方法,第一種方法就留給大家驗證並開發吧(其實就是我懶)。
來看一下我的現實程式碼:
void hi_spi_delay(void)
{
volatile unsigned int tmp = 0;
while (tmp++ < 30);
}
void spi_write_byte(unsigned char dat)
{
unsigned short spi_data = 0;
spi_data = dat;
ssp_writew(SSP_DR, spi_data);
hi_spi_delay();
}
這個延遲函數一定要根據編譯器、CPU主頻、SPI時脈頻率等實際測量後進行調整。經過我的反覆調整和測量,最終把迴圈計數設定為了30,來看一下示波器抓到的波形
間隔 65ns,也就是每位元組耗時 225us,大約相當於 36MHz 的SPI時脈頻率。
改成其他值行不行呢?這是我的測量結果
前面說了,延時函數一定要根據實際情況進行修改,確保每位元組之間有一定間隔。假設你換了海思的另外一款晶片,或者使用了其他基於 ssp-pl022 控制器的晶片,也遇到了類似的 SPI 速率低的問題,但手頭沒有示波器進行測量怎麼辦?假設你所使用的晶片提供了精確到10ns的延時函數,直接拿來用就行。沒有這樣的函數沒關係,可以做一個簡單的估算,估算結果與實際肯定有偏差,但不管怎麼說這個數值也算比較靠譜的。我們來一步一步分析這個延時函數如何實現。
首先是 volatile
關鍵字,開發偵錯期間為了方便分析問題,編譯優化選項往往設定為 -O0
,不論加不加這個關鍵字都沒問題,但正式程式的編譯優化選項一般都會設定為-O2
,-Os
,-O3
,這種情況下編譯器會直接把這個函數優化掉,所以必須加 volatile
關鍵字。
確定好你所使用的編譯器和編譯優化引數,有的晶片廠商會提供多個版本的編譯器,或者後來編譯器更新了,編譯器不同可能會導致程式碼行為不同,編譯優化引數不同也會導致程式碼行為不同,假設最終釋出程式碼使用-Os
,那麼測試期間也使用-Os
,總之確定好這兩點,在之後的開發過程中不要更改。
確定好你的延時函數的迴圈怎麼寫,包括但不限於
while (tmp++ < 30);
tmp = 30
while(1) {if(tmp-- == 0){break;}};
tmp = 30
while (tmp--);
for (tmp=0; tmp<30; tmp++);
無論怎麼寫都能達到延時的作用,有的編譯器可能非常聰明,發現迴圈中什麼都沒做,最終這4種寫法都被優化成了相同的組合程式碼,但有的編譯器可能不會,總之你不能保證編譯器會把他們優化成相同的組合程式碼,所以確定了延時函數的寫法以後在之後的開發過程中不要更改。
下一步將回圈計數設定為比較大的數,例如十萬,一百萬,執行這個函數並計算耗時,不論是用秒錶,還是用 gettimeofday,或者是 time 命令,總之最終目的是算出1個迴圈耗時多少。假設我測量出迴圈百萬次耗時5.3ms,那麼迴圈一次耗時就是5.3ns。向上取整按一次迴圈耗時6ns計算,為什麼這樣做?自己想。
假設 SPI 的時鐘速率是50MHz,傳送資料位寬是 8bit,則傳送1次耗時 1s / 50MHz * 8 = 160ns
,延時迴圈的次數為 160 / 6 = 26.6
,向上取整為27次。考慮到函數呼叫的耗時、讀寫暫存器的耗時等,實際兩次傳送之間的間隔肯定比它略長,但無論怎麼說,27 就是我們估算出來的延時迴圈計數。
當然還有更靠譜一點的估算方法。目前我的延時函數及對應的組合程式碼如下:
void hi_spi_delay(void)
{
volatile unsigned int tmp = 0;
while (tmp++ < 30);
}
Dump of assembler code for function hi_spi_delay:
0x00010454 <+0>: mov r3, #0
0x00010458 <+4>: sub sp, sp, #8
0x0001045c <+8>: str r3, [sp, #4]
0x00010460 <+12>: ldr r3, [sp, #4]
0x00010464 <+16>: cmp r3, #29
0x00010468 <+20>: add r3, r3, #1
0x0001046c <+24>: str r3, [sp, #4]
0x00010470 <+28>: bls 0x10460 <hi_spi_delay+12>
0x00010474 <+32>: add sp, sp, #8
0x00010478 <+36>: bx lr
End of assembler dump.
迴圈體對應的就是這5句
0x00010460 <+12>: ldr r3, [sp, #4]
0x00010464 <+16>: cmp r3, #29
0x00010468 <+20>: add r3, r3, #1
0x0001046c <+24>: str r3, [sp, #4]
0x00010470 <+28>: bls 0x10460 <hi_spi_delay+12>
我所使用的晶片採用Cortex-A7架構,我實在沒找到它的指令週期的檔案,這裡用Cortex-A9的替代一下,ARM官網檔案如下 https://developer.arm.com/documentation/ddi0388/f/Instruction-Cycle-Timings?lang=en ,跳轉指令的週期是不確定的,其他4個指令的都是單週期指令,迴圈中大多情況都是跳轉,只有最後一次是不跳轉,我們也按單週期計算好了。由此可知,迴圈一次需要5個週期,CPU的主頻是900MHz,假設CPU主頻固定,不超頻,也不降頻進入低功耗模式,那麼迴圈一次耗時 1s / 900MHz * 5 = 5.55ns
,這可以說是一個很精確的值了,同樣的條件下可以算出需要的迴圈次數為 160 / 5.55 = 28.8
,向上取整為29次。是不是和我目前使用的迴圈計數30基本一致?
好了,到此為止開發過程中遇到的問題還有我自己的疑惑都講完了,有問題歡迎大家討論。
如果你有朋友正好在海思工作,請將本文轉發給他,我不知道海思其他產品線的晶片會不會也有這個問題。我這樣寫的程式碼多少有些隨意,希望海思官方能優化一下 SPI 的速率問題。