動(dòng)ILI9486 SPI屏填充矩形出現(xiàn)隨機(jī)像素的排查與解決)
最近在做一塊 STM32F767ZI 開發(fā)板的外設(shè)擴(kuò)展外接 Waveshare 4.0 寸 LCD ShieldSKU13587主控是 ILI9486跑 SPI 接口。剛開始一切正常初始化能刷背景色點(diǎn)個(gè)像素、畫條直線都沒毛病。等我開始調(diào)用 fillRect 填充一個(gè)大矩形的時(shí)候屏幕上突然出現(xiàn)零星的隨機(jī)像素點(diǎn)位置每次刷新都不一樣顏色也不固定就像屏幕上撒了一把細(xì)鹽。第一反應(yīng)是排線松了、屏壞了但重新壓緊排線、換了一塊屏幕后問題照舊。后來把整個(gè)顯示鏈路從硬件到驅(qū)動(dòng)代碼重新過了一遍才確認(rèn)這根本不是屏的問題而是 SPI 傳輸細(xì)節(jié)里幾個(gè)非常容易忽略的坑疊在了一起。這篇記錄的就是完整的排查過程和最終修法遇到類似“大區(qū)域填充出現(xiàn)隨機(jī)像素”的朋友可以直接照著排。1. 故障現(xiàn)象還原填充矩形時(shí)出現(xiàn)的“雪花點(diǎn)”到底長(zhǎng)什么樣1.1 最開始的復(fù)現(xiàn)步驟我的環(huán)境是這樣的STM32CubeMX 生成工程HAL 庫(kù)SPI1 做主模式查詢方式發(fā)送F767 主頻 216MHzSPI 時(shí)鐘最初配置在 27MHz。屏幕初始化序列用的從 Waveshare 官方例程改過來的 ILI9486 初始化代碼顏色格式選 RGB565。復(fù)現(xiàn)步驟如下全屏填充黑色0x0000調(diào)用 fillRect(50, 50, 250, 250) 畫一個(gè)紅色矩形期望結(jié)果是邊緣清晰、內(nèi)部純色的矩形實(shí)際結(jié)果是矩形內(nèi)部出現(xiàn)大約三四十個(gè)隨機(jī)像素點(diǎn)。這些像素點(diǎn)有一個(gè)共同特點(diǎn)位置不固定。同一段代碼重復(fù)執(zhí)行每次出現(xiàn)的位置都不一樣但橫坐標(biāo)和縱坐標(biāo)也不會(huì)偏到矩形外面去。矩形面積越小出現(xiàn)概率越低全屏填充時(shí)數(shù)量最多肉眼很明顯。小矩形比如 16x16 的填充有時(shí)候完全看不出問題這可能也是很多人最初沒當(dāng)回事的原因。1.2 為什么偏偏是“填充矩形”暴露出問題這個(gè)問題最迷惑人的地方在于畫點(diǎn)、畫線都正常憑什么填充矩形就出亂子關(guān)鍵在于數(shù)據(jù)量。畫一個(gè)點(diǎn)SPI 上只需要發(fā)送幾條命令加兩三個(gè)字節(jié)數(shù)據(jù)整段時(shí)間不到幾十微秒傳輸間隙極短就算 SPI 配置有輕微偏差也很難積累出肉眼可見的錯(cuò)誤。畫線稍微長(zhǎng)一點(diǎn)但每條線最多也就是幾百個(gè)點(diǎn)數(shù)據(jù)量依然有限。填充矩形是完全不同的場(chǎng)景。一個(gè) 200x200 的矩形RGB565 格式下就是 200x200x2 80000 字節(jié)如果是 320x480 全屏一次要往 SPI 灌 307200 字節(jié)。這么長(zhǎng)的連續(xù)數(shù)據(jù)流任何一字節(jié)的解釋錯(cuò)誤、任何一次時(shí)鐘采樣點(diǎn)偏差、任何一次片選信號(hào)異常都會(huì)被放大成可見像素錯(cuò)誤。SPI 協(xié)議本身是同步串行傳輸發(fā)送端和接收端只要有一個(gè) bit 錯(cuò)位后續(xù)所有數(shù)據(jù)全部錯(cuò)位直到下一次命令序列重新同步。所以當(dāng)時(shí)我就意識(shí)到屏幕和初始化大概率沒壞問題出在“長(zhǎng)數(shù)據(jù)流”的傳輸機(jī)制上也就是 SPI 外設(shè)配置、片選控制、以及底層填充函數(shù)的實(shí)現(xiàn)方式。2. 從硬件開始排除確認(rèn) SPI 接口與片選控制2.1 ILI9486 的接口模式比你想的更復(fù)雜很多人把 ILI9486 當(dāng)成一塊“SPI 屏”直接用 SPI 外設(shè)去驅(qū)動(dòng)。實(shí)際上 ILI9486 內(nèi)部支持多種接口模式包括 I8080 并行接口、3 線 SPI、4 線 SPI型號(hào)引腳 IM[3:0] 決定當(dāng)前用哪種。Waveshare 這種 Shield 板卡硬件上通常有模式選擇電阻出廠可能是 I8080 并口模式也可能是 SPI 模式必須看這塊板子的原理圖或者絲印說明確認(rèn)。我當(dāng)時(shí)最初犯過一個(gè)粗心錯(cuò)誤以為 Shield 插上就能用結(jié)果白屏了半小時(shí)后來發(fā)現(xiàn)是 DC 引腳的復(fù)用沒配好。ILI9486 在 4 線 SPI 模式下DCX 引腳必須由主機(jī) GPIO 控制用來區(qū)分當(dāng)前傳輸?shù)氖敲钸€是數(shù)據(jù)。如果 DCX 沒接或者被固定拉高/拉低芯片會(huì)把所有字節(jié)都當(dāng)成同一種類型命令和數(shù)據(jù)全混在一起。畫點(diǎn)和畫線時(shí)由于命令較短偶然能“碰對(duì)”但填充矩形時(shí)命令和數(shù)據(jù)字節(jié)數(shù)量不對(duì)等必然錯(cuò)亂。另外SKU13587 這類 Shield 上還帶 SD 卡槽SD 卡也是走 SPI 的。如果 SD 卡和 LCD 共用一條 SPI 總線必須各自獨(dú)立 CS。很多代碼在初始化階段會(huì)先探測(cè) SD 卡占住 SPI 總線一段時(shí)間如果 CS 控制沒做好SD 卡的通信垃圾數(shù)據(jù)會(huì)被 LCD 誤收屏幕上也會(huì)出現(xiàn)隨機(jī)點(diǎn)。2.2 每根線都值得重查一遍在排查這個(gè)問題時(shí)我把接線重新整理了一遍這里給出一份參考接線表不一定適用于所有開發(fā)板但作為 STM32F767ZI SPI1 的典型接法沒問題信號(hào)STM32 引腳說明CLKPA5 / SPI1_SCK接到屏幕 SCL線盡量短MOSIPA7 / SPI1_MOSI接到屏幕 SDI/SDAMISO可不接ILI9486 的 SDO 只用于讀顯存不用可以不連CS任意 GPIO例如 PB0軟件控制后續(xù)會(huì)重點(diǎn)講DC任意 GPIO例如 PB1命令/數(shù)據(jù)選擇RST任意 GPIO例如 PB2復(fù)位引腳這里特別提醒一點(diǎn)MISO 如果沒用到不要在主機(jī)的 SPI 配置里強(qiáng)制開啟讀功能。STM32F767 的 SPI 外設(shè)如果是全雙工模式發(fā)送每個(gè)字節(jié)的同時(shí)都會(huì)在 MISO 上采樣。MISO 懸空時(shí)采到的電平是隨機(jī)的雖然大多不會(huì)影響輸出數(shù)據(jù)但如果你開啟了 SPI 接收中斷或者 DMA 雙緩沖這些隨機(jī)垃圾數(shù)據(jù)會(huì)被 CPU/DMA 處理反而干擾主流程。對(duì)只寫屏的場(chǎng)景建議直接用 SPI 發(fā)送專用配置或者把 MISO 引腳在 GPIO 初始化時(shí)配置為普通輸入并下拉。還有一個(gè)硬件層面的檢查點(diǎn)供電。4 寸 TFT 背光電流很容易到幾十毫安如果從開發(fā)板的 3.3V 排針直接取電當(dāng)背光占空比變化或屏幕刷新大塊面積時(shí)電源紋波會(huì)增大進(jìn)而干擾 SPI 電平閾值。排障時(shí)最好用穩(wěn)壓源或單獨(dú)的 LDO 給屏幕單獨(dú)供電排除電源因素。2.3 硬件片選和軟件片選隨機(jī)像素能否消失的分水嶺說到 SPI 片選很多人會(huì)直接想到 STM32 的 NSS 引腳。NSS 可以作為硬件片選自動(dòng)控制但這個(gè)“自動(dòng)控制”在主機(jī)模式下并不總符合預(yù)期。特別是當(dāng)你使用 HAL 的HAL_SPI_Transmit時(shí)如果你把 NSS 配成了硬件模式外設(shè)會(huì)在某些幀邊界翻轉(zhuǎn) NSS或在多字節(jié)傳輸過程中產(chǎn)生額外的電平變化。對(duì)于 ILI9486 這種對(duì)命令序列完整性有要求的芯片CS 在命令中間被拉高是災(zāi)難性的。記住一個(gè)結(jié)論驅(qū)動(dòng) ILI9486 這類需要連續(xù)多字節(jié)傳輸?shù)?SPI 屏?xí)r推薦使用 GPIO 軟件片選不要使用硬件 NSS。軟件片選的核心原則是一個(gè)完整事務(wù)內(nèi)CS 保持低電平不動(dòng)事務(wù)結(jié)束后才拉高。比如發(fā)送“設(shè)置窗口命令 0x2A 四個(gè)參數(shù)”CS 必須在這五個(gè)字節(jié)全部發(fā)送完畢后再釋放。下面給一個(gè)參考實(shí)現(xiàn)void ili9486_write_command(uint8_t cmd) { CS_LOW(); DC_LOW(); // 命令 spi_write_byte(cmd); CS_HIGH(); } void ili9486_write_data(uint8_t data) { CS_LOW(); DC_HIGH(); // 數(shù)據(jù) spi_write_byte(data); CS_HIGH(); }看起來更細(xì)的拆分就是每個(gè)字節(jié)一個(gè)事務(wù)。這樣在畫點(diǎn)時(shí)沒有問題但大填充時(shí)一定要演變成行緩沖模式不要讓 CS 在像素之間反復(fù)翻轉(zhuǎn)。后面第 4 章會(huì)專門講 fillRect 的實(shí)現(xiàn)方式。3. SPI 參數(shù)與 ILI9486 時(shí)序的匹配時(shí)鐘頻率和采樣邊沿3.1 檢查 CPOL/CPHA別讓采樣邊沿卡在懸崖上SPI 協(xié)議有四種模式由時(shí)鐘極性 CPOL 和時(shí)鐘相位 CPHA 決定。ILI9486 的 4 線 SPI 通常工作在 Mode 0也就是 CPOL0、CPHA0SCK 空閑低電平數(shù)據(jù)在上升沿采樣。如果你的初始化配置成 Mode 1、Mode 2 甚至 Mode 3屏幕可能也能亮因?yàn)樾酒瑫r(shí)序容忍度有一定余量低速時(shí)尤其不明顯。但問題恰恰出在“低速時(shí)正常高速時(shí)隨機(jī)錯(cuò)”上。當(dāng) SPI 時(shí)鐘拉到 20MHz 以上SCK 的建立時(shí)間和保持時(shí)間余量都很小如果 CPOL/CPHA 和芯片要求不一致采樣點(diǎn)可能落在數(shù)據(jù)線翻轉(zhuǎn)的附近。這時(shí)候數(shù)據(jù)線上的毛刺、走線串?dāng)_、電平上升沿不陡都會(huì)導(dǎo)致某一 bit 被采錯(cuò)。一個(gè) bit 采錯(cuò)反映到屏幕上就是一個(gè)顏色錯(cuò)誤的隨機(jī)像素點(diǎn)。在 CubeMX 里檢查 SPI 配置時(shí)重點(diǎn)確認(rèn)這幾個(gè)參數(shù)Clock Polarity (CPOL)LowClock Phase (CPHA)1 EdgeData Size8 bitFirst BitMSB First如果排查過程中不確定可以先按 Mode 0 跑最低頻確認(rèn)現(xiàn)象有沒有變化。如果 Mode 0 低頻穩(wěn)定再逐步升頻。3.2 分頻降頻是百試百靈的定位手段SPI 時(shí)鐘頻率不是越高越好尤其當(dāng)你用杜邦線連接屏幕時(shí)。F767 的 SPI1 掛在 APB2 上PCLK2 理論最高 108MHzSPI 外設(shè)分頻最小 2 就是 54MHz。但實(shí)際上 ILI9486 的 SPI 時(shí)鐘上限通常在 20MHz 左右Waveshare 官方 Arduino 例程一般也用 15~20MHz。超過這個(gè)范圍就算協(xié)議沒錯(cuò)信號(hào)完整性也會(huì)出問題。信號(hào)完整性出問題時(shí)具體表現(xiàn)就是長(zhǎng)線傳輸下出現(xiàn)偶發(fā)錯(cuò)位。我最初用 27MHz隨機(jī)像素很多降到 13.5MHz隨機(jī)像素明顯變少但仍有再配合其他修復(fù)后回到 16MHz 才徹底穩(wěn)定。這個(gè)過程說明兩點(diǎn)時(shí)鐘頻率是誘發(fā)因素但不是根因。給你一個(gè)實(shí)用的分頻對(duì)照表方便你在 CubeMX 里做估算。假設(shè) PCLK2 108MHzPrescalerSPI 時(shí)鐘254 MHz427 MHz813.5 MHz166.75 MHz323.375 MHz排障時(shí)建議從最低頻率開始比如 6.75MHz確認(rèn)屏幕顯示一切正常后再往上升頻。如果 6.75MHz 也有隨機(jī)像素那就基本可以排除頻率因素。3.3 數(shù)據(jù)位序和像素格式的隱性影響SPI 字節(jié)傳輸還有一個(gè)容易被忽略的選項(xiàng)MSB first 還是 LSB first。STM32 的 SPI 外設(shè)默認(rèn) MSB firstILI9486 的數(shù)據(jù)手冊(cè)也是按 MSB 先傳來定義的。如果不小心把 SPI 配置成了 LSB first整個(gè)數(shù)據(jù)流按位翻轉(zhuǎn)圖像會(huì)左右鏡像或顏色通道錯(cuò)亂表現(xiàn)上也可能是滿屏噪點(diǎn)。所以要在 CubeMX 里把First Bit設(shè)為 MSB First。更關(guān)鍵的還是像素格式。ILI9486 本身是 18 位色深也就是 RGB666但 SPI 模式下可以通過 0x3A 寄存器設(shè)置輸入數(shù)據(jù)格式0x5516 位/像素RGB5650x6618 位/像素RGB6660x113 位/像素RGB111一般不用于正常顯示驅(qū)動(dòng)庫(kù)和初始化寄存器必須匹配。比如初始化時(shí)把 0x3A 設(shè)成了 0x66但 fillRect 按 RGB565 每像素 2 字節(jié)發(fā)送那么從第二個(gè)像素開始數(shù)據(jù)就錯(cuò)位了芯片會(huì)把 RGB 分量拆錯(cuò)屏幕上的表現(xiàn)就是密密麻麻的彩色噪點(diǎn)有時(shí)會(huì)被誤認(rèn)為“隨機(jī)像素”。我在最后檢查初始化序列時(shí)確認(rèn) 0x3A 0x55才排除這個(gè)因素。4. ILI9486 初始化序列與顯存窗口故障最可能的藏身之處4.1 關(guān)鍵初始化參數(shù)怎么調(diào)ILI9486 的初始化序列可以從 Waveshare 官方代碼或任何成熟驅(qū)動(dòng)庫(kù)抄但別從 ILI9341 的代碼直接改芯片型號(hào)就完事。這兩顆芯片寄存器差異很大比如 ILI9486 的顯示分辨率是 320x480而 ILI9341 是 240x320窗口邊界和像素格式的設(shè)置完全不同。我見過不少“初始化后花屏”的案例查到最后都是因?yàn)橛昧藙e的芯片的初始化序列。這里列出幾個(gè)關(guān)鍵寄存器和它們的作用方便排查時(shí)對(duì)照寄存器作用常用值0x3A像素格式設(shè)置0x55 RGB5650x66 RGB6660x36顯存訪問控制MADCTL控制掃描方向、RGB/BGR 順序0xC0電源控制 1跟隨官方初始化0xB0顯示模式/幀率等跟隨官方初始化0x11退出睡眠模式初始化最后需要0x29打開顯示初始化完成打開顯示特別注意 0x36 MADCTL。如果你的窗口設(shè)置函數(shù)和 MADCTL 的掃描方向不匹配填充矩形時(shí)內(nèi)容方向會(huì)偏但不至于出現(xiàn)隨機(jī)點(diǎn)。不過 RGB/BGR 位如果搞反顏色通道會(huì)錯(cuò)亂在某些顏色過渡區(qū)域出現(xiàn)類似噪點(diǎn)的紋理。排障時(shí)可以先固定 MADCTL 為 0x00 或 0x48測(cè)試純色矩形是否正常。4.2 窗口設(shè)置命令被截?cái)嗟刂分羔樺e(cuò)亂的源頭ILI9486 的顯存寫入流程是先用 0x2A 設(shè)置列地址范圍CASET再用 0x2B 設(shè)置行地址范圍PASET最后用 0x2C 連續(xù)寫入像素?cái)?shù)據(jù)。任何一條命令后面的參數(shù)如果沒收到完整芯片內(nèi)部的窗口地址就會(huì)指向一個(gè)錯(cuò)誤位置。錯(cuò)誤窗口范圍內(nèi)的數(shù)據(jù)會(huì)被寫入顯存但不一定是當(dāng)前你要填充的矩形區(qū)域。這些寫到錯(cuò)誤區(qū)域的數(shù)據(jù)最終會(huì)在屏幕上表現(xiàn)為顯示內(nèi)容混亂如果只是一小部分越界看起來就是零星的異常像素。窗口命令的格式如下0x2A: [x0_high, x0_low, x1_high, x1_low] // CASET 0x2B: [y0_high, y0_low, y1_high, y1_low] // PASET 0x2C: [pixel data...] // RAMWR所有參數(shù)都必須在一個(gè) CS 低電平區(qū)間內(nèi)連續(xù)發(fā)送。如果發(fā)送中間 CS 被拉高或者 SCK 意外停擺ILI9486 只能收到部分參數(shù)后續(xù)數(shù)據(jù)就會(huì)落到錯(cuò)誤地址。這也解釋了為什么“CS 拆包”會(huì)導(dǎo)致隨機(jī)像素窗口地址設(shè)錯(cuò)了數(shù)據(jù)還在往顯存里寫但位置不對(duì)。自查方法用邏輯分析儀抓 CS 和 MOSI放大 0x2A 后面一段確認(rèn) 4 個(gè)地址字節(jié)是否連續(xù)、CS 是否一直維持低電平。一旦發(fā)現(xiàn)高脈沖插在參數(shù)中間直接定位。4.3 fillRect 實(shí)現(xiàn)方式對(duì)比逐像素發(fā)送是原罪接下來看 fillRect 本身。很多從畫點(diǎn)函數(shù)擴(kuò)展來的矩形填充代碼長(zhǎng)這樣void fillRect(uint16_t x0, uint16_t y0, uint16_t w, uint16_t h, uint16_t color) { set_window(x0, y0, x0 w - 1, y0 h - 1); for (uint32_t i 0; i w * h; i) { CS_LOW(); DC_HIGH(); spi_write_byte(color 8); spi_write_byte(color 0xFF); CS_HIGH(); } }這代碼從功能上沒錯(cuò)畫小矩形時(shí)看起來也正常。但每次只寫一個(gè)像素就把 CS 拉高意味著每?jī)蓚€(gè)像素之間 CS 都會(huì)產(chǎn)生一個(gè)高脈沖。對(duì)于 ILI9486 來說每個(gè)高脈沖都意味著當(dāng)前 SPI 事務(wù)結(jié)束芯片狀態(tài)機(jī)回到空閑或命令接收狀態(tài)。當(dāng)這個(gè)操作發(fā)生在 RAMWR 數(shù)據(jù)流中間時(shí)芯片內(nèi)部可能會(huì)認(rèn)為數(shù)據(jù)寫入結(jié)束或者顯存地址指針被重置。最終寫進(jìn)去的數(shù)據(jù)不連續(xù)顯存里就出現(xiàn)了空洞。正確做法是把窗口設(shè)置和像素?cái)?shù)據(jù)分成兩個(gè)大事務(wù)窗口設(shè)置一次性發(fā)完像素?cái)?shù)據(jù)按行緩沖連續(xù)發(fā)送。推薦這樣實(shí)現(xiàn)static uint8_t line_buffer[480 * 2]; // 行緩沖按屏幕寬度算 void fillRect(uint16_t x0, uint16_t y0, uint16_t w, uint16_t h, uint16_t color) { uint32_t row_bytes w * 2; // 填滿行緩沖 for (uint16_t i 0; i w; i) { line_buffer[i * 2] color 8; line_buffer[i * 2 1] color 0xFF; } set_window(x0, y0, x0 w - 1, y0 h - 1); CS_LOW(); DC_HIGH(); // 數(shù)據(jù)模式 for (uint16_t row 0; row h; row) { spi_write_buffer(line_buffer, row_bytes); } CS_HIGH(); }這個(gè)版本里