內存到內存數(shù)據(jù)傳輸保姆級教程)
做了小半年 MicroPython 開發(fā)平時最煩的就是兩件事一是 Python 循環(huán)太慢二是在內存里搬數(shù)據(jù)還得等 CPU。前陣子做一塊 RP2040 的顯示驅動板需要在兩塊 RAM 緩沖區(qū)之間頻繁拷貝幀數(shù)據(jù)128x64 的 framebuffer 也就 1KB用普通 for 循環(huán)搬一次要吃掉好幾毫秒CPU 直接被拖死。后來把這塊“搬磚”的活交給了 DMA 控制器內存到內存數(shù)據(jù)傳輸直接把等待時間壓到幾十微秒量級CPU 還能同時去刷新 GPIO、處理按鍵邏輯體驗完全不一樣。這篇文章就是一份保姆級教程全程基于 MicroPython 直接操作 RP2040 的 DMA 寄存器跑通內存到內存Memory-to-Memory簡稱 M2M數(shù)據(jù)傳輸。我會從 RP2040 DMA 的寄存器地圖講起再到怎么在 MicroPython 里拿到一塊緩沖區(qū) RAM 的真實地址然后一步步寫出可運行的代碼最后附上實測對比和踩坑清單。適合那些對 MicroPython 很熟、但對寄存器操作還比較陌生的朋友。1. 為什么在 MicroPython 里做內存到內存 DMA 不是閑得慌1.1 真實場景MicroPython 什么時候需要大量內存搬運很多人一聽到“DMA 內存到內存?zhèn)鬏敗钡谝环磻荕icroPython 根本不適合干這種事Python 慢不如直接用 C。但實際項目中MicroPython 要面對的內存拷貝場景比想象中多圖形界面OLED/LCD 的 framebuffer 滾動、圖層合成、圖片描畫。128x64 單色屏的 framebuffer 是 1024 字節(jié)往上疊一個小圖標、往左滾一列都是整塊內存搬運。傳感器數(shù)據(jù)幀陀螺儀/加速度計的原始數(shù)據(jù)先存在臨時數(shù)組里攢夠一幀后要搬到 DMA 發(fā)送緩沖區(qū)或環(huán)形隊列經(jīng)常是一回搬運幾百字節(jié)。數(shù)據(jù)流轉發(fā)串口、I2C、SPI 收到的不定長數(shù)據(jù)先落地在接收緩沖處理完還要搬到另一個 buffer 排隊等發(fā)送。雙緩沖切換后臺繪制與前臺顯示各用一塊 buffer繪制完成后把整塊 buffer 的內容搬給顯示驅動。這些場景如果都用 Python 的 for 循環(huán)一個字節(jié)一個字節(jié)地拷數(shù)據(jù)量一旦過 KB 級解釋器的開銷會被放得非常大。DMA 控制器作為一個獨立于 CPU 的硬件搬運工可以把整塊內存原封不動地搬走CPU 干別的事就行。1.2 直接把字節(jié)拷過去不行嗎解釋器開銷分析在 RP2040 的 MicroPython 環(huán)境里執(zhí)行下面這段代碼for i in range(n): dst[i] src[i]每一次循環(huán)都會經(jīng)歷取值、索引、賦值、遞增、比較而這背后的解釋器循環(huán)在 133MHz 的 RP2040 上大約要吃掉幾百納秒到一兩個微秒。單純搬運 1KB 數(shù)據(jù)用這個循環(huán)會花上幾毫秒。如果是在硬實時性要求較高的外設驅動流程中這幾毫秒就是實實在在的 CPU 占用。相比之下DMA 控制器一旦配置完成搬運 1KB 數(shù)據(jù)只需要幾十微秒左右視總線狀態(tài)而定而且這個過程不需要 CPU 一條一條指令地跟著走。只要你用的是連續(xù)內存塊DMA 就是天然的工具。當然不是說 Python 循環(huán)一無是處而是明確一件事連續(xù)內存塊的批量搬運本來就該交給 DMA。2. RP2040 DMA 控制器的保姆級寄存器地圖2.1 DMA 通道與寄存器布局RP2040 的 DMA 控制器在地址0x50000000一共有 12 個獨立通道編號 0 到 11。每個通道獨占0x4064 字節(jié)的寄存器空間通道 n 的基地址就是DMA_BASE 0x50000000 chan_base DMA_BASE n * 0x40每個通道里最常用的寄存器其實就 4 個我做了一張表偏移寄存器名作用0x00READ_ADDR源地址。DMA 從這個地址開始讀數(shù)據(jù)0x04WRITE_ADDR目標地址。DMA 往這個地址寫數(shù)據(jù)0x08TRANS_COUNT傳輸計數(shù)。寫入要傳輸?shù)淖止?jié)數(shù)/元素數(shù)傳輸過程中會遞減0x0CCTRL_TRIG控制寄存器。寫入即觸發(fā)一次傳輸也可以讀取狀態(tài)除了這 4 個每個通道還有 AL1_CTRL、AL2_CTRL、AL3_CTRL 這些別名寄存器以及 AL1_READ_ADDR、AL1_WRITE_ADDR、AL1_TRANS_COUNT_TRIG 等。它們的基本作用是把“配置”和“觸發(fā)”拆開避免在需要反復觸發(fā)時反復寫同一個 CTRL_TRIG 導致配置被覆蓋。對于初學來說直接用前 4 個就夠了。提示CTRL_TRIG 與控制字的區(qū)別要搞清楚。CTRL_TRIG 是一個 32 位寄存器一次寫入既包含通道控制配置也包含“觸發(fā)一次傳輸”的動作。如果只配置不想觸發(fā)要用 AL1_CTRL / AL2_CTRL / AL3_CTRL 這類別名寄存器。2.2 CTRL_TRIG 關鍵位域逐個看CTRL_TRIG 是整篇教程的核心。它的 32 個 bit 中做內存到內存?zhèn)鬏敃r最常用到這些位位名稱作用bit 0EN通道使能必須置 1bit 1HIGH_PRIORITY高優(yōu)先級置 1 后該通道優(yōu)先級更高bit [3:2]DATA_SIZE每個傳輸元素的大小08bit116bit232bitbit 4INCR_READ源地址遞增置 1 表示每次傳輸后源地址自動加一個元素大小bit 5INCR_WRITE目標地址遞增置 1 表示每次傳輸后目標地址自動加一個元素大小bit [13:9]CHAIN_TO鏈式傳輸?shù)南乱粋€通道M2M 單次傳輸通常設 0bit [21:16]TREQ_SEL傳輸請求源選擇。內存到內存?zhèn)鬏敱仨毺?0x3FDREQ_FORCEbit 22WRITE_ERROR寫錯誤狀態(tài)bit 23READ_ERROR讀錯誤狀態(tài)bit 24BUSY忙標志。傳輸未完成時為 1完成后自動清零bit 28AHB_ERRORAHB 總線錯誤。地址越界或非法訪問時置 1內存到內存?zhèn)鬏斂刂谱值某S媒M合如下CTRL (1 0) | (0 2) | (1 4) | (1 5) | (0x3F 16) # EN1 8bit 源地址遞增 目標地址遞增 TREQ_SEL0x3F這個控制字的意思是使能 DMA 通道按字節(jié)搬運源地址和目標地址都自動遞增并且不依賴任何外設請求靠軟件寫入 CTRL_TRIG 的方式觸發(fā)傳輸。2.3 DREQ_FORCE內存到內存?zhèn)鬏數(shù)拈_關TREQ_SEL 這個字段是很多教程里容易一筆帶過、但實際又極其重要的地方。DMA 控制器平時搬運數(shù)據(jù)通常需要一個“數(shù)據(jù)請求信號”來推動比如串口接收 FIFO 有數(shù)據(jù)了DMA 才去搬SPI 發(fā)送 FIFO 空了DMA 才去搬。這個請求信號叫 DREQ每個外設都有自己固定的 DREQ 編號。但是內存到內存?zhèn)鬏敍]有外設參與數(shù)據(jù)請求從哪來答案是DREQ_FORCE它在 RP2040 里的值是0x3F。把 TREQ_SEL 寫成 63也就是強制拉高請求信號DMA 只要收到軟件觸發(fā)就會傳輸。這就是為什么很多人照著外設 DMA 的示例改內存搬運時怎么都不動——TREQ_SEL 一直填的是某個外設的編號DMA 在那里傻等一個永遠不會來的外設請求。注意DREQ_FORCE 0x3F也就是十進制的 63。這個值在 MicroPython 代碼里可以直接寫0x3F 16不要寫成0 16。這一位設錯是最常見的“DMA 不執(zhí)行”原因。3. MicroPython 側的基礎設施訪問寄存器與拿緩沖區(qū)地址3.1 machine.mem32 直接讀寫外設寄存器MicroPython 的machine模塊提供了mem8、mem16、mem32三個對象可以直接讀寫 CPU 地址空間。RP2040 上的 DMA 寄存器都是 32 位的所以這里用mem32from machine import mem32 DMA_BASE 0x50000000 chan_base DMA_BASE 0 * 0x40 mem32[chan_base 0x00] src_addr # READ_ADDR mem32[chan_base 0x04] dst_addr # WRITE_ADDR mem32[chan_base 0x08] byte_count # TRANS_COUNT mem32[chan_base 0x0C] ctrl # CTRL_TRIG寫入即觸發(fā)mem32的用法和外設寄存器地址映射直接對應省去了 C 語言里的*(volatile uint32_t *) REG那種寫法。本質上一樣就是把一個整數(shù)值寫到指定地址。3.2 用 uctypes.addressof 獲取緩沖區(qū)真實地址MicroPython 的uctypes模塊里有一個很關鍵的函數(shù)addressof(obj)。它可以返回一個支持 buffer 協(xié)議對象的內部緩沖區(qū)起始地址。比如from uctypes import addressof buf bytearray(256) print(addressof(buf)) # 例如 1072300032這里要注意幾點傳入的必須是字節(jié)緩沖類對象比如bytearray、bytes、array創(chuàng)建的數(shù)組。普通的list不能用因為list存的是指向 Python 對象的指針不是連續(xù)內存。bytearray(256)得到的是 8 位元素緩沖區(qū)地址可以是任意對齊。如果要用 32 位搬運最好用array(I, ...)并且要檢查地址是否 4 字節(jié)對齊。addressof返回的地址是 MicroPython 堆上的地址映射到 RP2040 的內存地址空間里DMA 可以正常訪問。下面看一個用array創(chuàng)建 32 位緩沖區(qū)的例子from array import array src array(I, [0x11111111, 0x22222222, 0x33333333]) dst array(I, [0, 0, 0]) src_addr addressof(src) dst_addr addressof(dst) print(hex(src_addr), hex(dst_addr))3.3 為什么 MicroPython 的對象地址可以穩(wěn)定使用很多從 C 語言過來的人會擔心Python 對象不是會被 GC 移動嗎地址會不會變這里有一點可以放心MicroPython 的 GC 是非移動式 GC。也就是說當一個對象在堆上分配出來后它的地址在存活期間是穩(wěn)定的不會被垃圾回收移到別處。所以只要你在 DMA 傳輸期間保持對源緩沖區(qū)和目標緩沖區(qū)的引用地址就不會變。但有一個坑必須注意如果在函數(shù)里創(chuàng)建了一個局部bytearrayDMA 傳輸還沒完成函數(shù)就返回了這個bytearray的引用計數(shù)歸零GC 可能把它回收掉。而 DMA 還在傻傻地往那塊地址寫數(shù)據(jù)這時候就會出現(xiàn)“數(shù)據(jù)憑空消失”甚至“系統(tǒng)崩潰”。所以做 DMA 傳輸時源和目標緩沖區(qū)一定要在調用方維持引用最好在傳輸完成后才允許釋放。4. 手寫三段式 DMA 傳輸代碼并跑通4.1 準備緩沖區(qū)并填充測試數(shù)據(jù)先從最簡單的 8 位搬運開始。準備兩個 256 字節(jié)的bytearray一個源、一個目標源數(shù)據(jù)填充成有規(guī)律的遞增序列方便傳輸完成后驗證from machine import mem32 from uctypes import addressof import time DMA_BASE 0x50000000 src bytearray(256) dst bytearray(256) for i in range(256): src[i] i 0xFF這里用遞增序列是為了后面檢查數(shù)據(jù)時能快速看出問題如果dst[j]不等于src[j]一定是這次傳輸出了問題。4.2 拼裝控制字控制字按前面說的組合來拼chan_base DMA_BASE 0 * 0x40 # 讀取源/目標地址 src_addr addressof(src) dst_addr addressof(dst) # 配置源地址、目標地址、傳輸字節(jié)數(shù) mem32[chan_base 0x00] src_addr mem32[chan_base 0x04] dst_addr mem32[chan_base 0x08] 256 # 控制字 # bit0 EN 1 # bit4 INCR_READ 1源地址遞增 # bit5 INCR_WRITE 1目標地址遞增 # TREQ_SEL 0x3F 16強制請求 ctrl (1 0) | (1 4) | (1 5) | (0x3F 16) # 寫入 CTRL_TRIG觸發(fā)傳輸 mem32[chan_base 0x0C] ctrl4.3 觸發(fā)、等待完成、檢查結果寫完 CTRL_TRIG 后DMA 通道進入忙碌狀態(tài)。在 MicroPython 里最簡單的等待方式就是輪詢 BUSY 位# 等待 DMA 完成 while mem32[chan_base 0x0C] (1 24): pass # 驗證結果 for i in range(256): if dst[i] ! src[i]: print(Mismatch at, i) break else: print(DMA OK)BUSY 位在 CTRL_TRIG 的 bit 24。傳輸結束后該位自動清零讀到的值會變成 0。所有數(shù)據(jù)搬運完成后dst里的內容應該和src完全一致。4.4 封裝成可復用的 dma_memcpy 函數(shù)為了后面能反復調用把它封裝成一個函數(shù)比較合理from machine import mem32 from uctypes import addressof import time DMA_BASE 0x50000000 def dma_memcpy(dst, src, nNone, channel0): if n is None: n min(len(src), len(dst)) if n 0: return src_addr addressof(src) dst_addr addressof(dst) base DMA_BASE channel * 0x40 # 先寫地址和長度 mem32[base 0x00] src_addr mem32[base 0x04] dst_addr mem32[base 0x08] n # 8 bit 傳輸、源/目標地址遞增、強制請求 ctrl (1 0) | (1 4) | (1 5) | (0x3F 16) mem32[base 0x0C] ctrl # 等待完成加超時保護 t0 time.ticks_ms() while mem32[base 0x0C] (1 24): if time.ticks_diff(time.ticks_ms(), t0) 200: raise OSError(DMA timeout)調用方式很簡單src bytearray(bHello RP2040 DMA) dst bytearray(len(src)) dma_memcpy(dst, src) print(dst) # bHello RP2040 DMA這個函數(shù)兼容任意支持緩沖協(xié)議的對象。后面跑性能對比時直接用它來測 DMA 的耗時不用每次重寫配置代碼。4.5 用 array(I) 跑 32 位搬運字節(jié)傳輸在某些場景下不夠高效如果要搬運大量的 32 位像素數(shù)據(jù)、音頻采樣數(shù)據(jù)建議直接用 32 位 DMA。一個完整的例子from array import array from machine import mem32 from uctypes import addressof import time DMA_BASE 0x50000000 src array(I, [0x11111111, 0x22222222, 0x33333333, 0x44444444]) dst array(I, [0, 0, 0, 0]) src_addr addressof(src) dst_addr addressof(dst) elem_count len(src) base DMA_BASE 0 * 0x40 mem32[base 0x00] src_addr mem32[base 0x04] dst_addr mem32[base 0x08] elem_count # DATA_SIZE 2 2 ctrl (1 0) | (2 2) | (1 4) | (1 5) | (0x3F 16) mem32[base 0x0C] ctrl while mem32[base 0x0C] (1 24): pass print(dst)32 位模式下TRANS_COUNT 表示的是元素個數(shù)而不是字節(jié)數(shù)這一點要特別留意。比如src長度是 4TRANS_COUNT 寫 4DMA 會搬 4 個 32 位數(shù)據(jù)也就是 16 字節(jié)。5. 實測數(shù)據(jù)DMA 到底比 Python 拷貝快多少5.1 測試方法光說快不算數(shù)直接上板子測。用time.ticks_us()來測兩種方式復制 1KB 數(shù)據(jù)的耗時。Python 側用最直白的 for 循環(huán)DMA 側用上面封裝的dma_memcpy。測試腳本大致長這樣import time from machine import mem32 from uctypes import addressof src bytearray(4096) dst bytearray(4096) for i in range(len(src)): src[i] i 0xFF # 測 for 循環(huán) t0 time.ticks_us() for i in range(len(src)): dst[i] src[i] t1 time.ticks_us() print(Python copy:, time.ticks_diff(t1, t0), us) # 測 DMA t0 time.ticks_us() dma_memcpy(dst, src, len(src)) t1 time.ticks_us() print(DMA copy:, time.ticks_diff(t1, t0), us)注意這套測試腳本里dma_memcpy函數(shù)內部包含配置寄存器和等待完成兩部分時間。這樣測出來的是實際落地耗時而不是理論帶寬。5.2 不同長度下的對比結果以下是我手頭這塊 Pico 板RP2040 133MHzMicroPython 1.20 固件跑出的量級參考實際環(huán)境不同會有差異但數(shù)量級的差別是穩(wěn)定的數(shù)據(jù)長度Python for 循環(huán)DMA 搬運倍率256 B約 0.3 ms約 8 us約 35 倍1 KB約 1.2 ms約 14 us約 85 倍4 KB約 4.8 ms約 30 us約 160 倍可以看到數(shù)據(jù)量越大DMA 的優(yōu)勢越明顯。這是因為 Python 一個字節(jié)一個字節(jié)地走解釋器循環(huán)開銷線性增長而 DMA 只是配置時間長一點搬運本身由硬件完成增長非常有限。5.3 結論DMA 的價值不在“更快”而在“不占 CPU”這里必須把話說透DMA 內存到內存?zhèn)鬏數(shù)恼嬲齼?yōu)勢不是讓“某一次拷貝”變快而是讓 CPU 在拷貝期間徹底解放。上面測試里雖然只是“等待完成”了幾十微秒但你可以想象一下真實項目如果你在 MicroPython 里用 DMA 搬運 framebuffer那么 CPU 可以在這幾十微秒里去處理掃描按鍵、更新計數(shù)器、處理外設中斷。如果你在發(fā)送串口數(shù)據(jù)時用 DMA 搬運發(fā)送緩沖CPU 可以去填下一塊緩沖區(qū)的數(shù)據(jù)實現(xiàn)流水線作業(yè)。如果是一次性的、只有幾十字節(jié)的小拷貝用 Python 循環(huán)反而更簡單DMA 的配置開銷不一定劃算。所以什么時候用 DMA 內存到內存?zhèn)鬏斠痪湓掃B續(xù)數(shù)據(jù)塊越大、搬運次數(shù)越頻繁越值得用。6. 踩坑與排查第一次跑不通的常見原因清單6.1 傳輸沒開始TREQ_SEL 設成了 0這是我在教程和論壇里看到最多的提問?,F(xiàn)象是寫入 CTRL_TRIG 后BUSY 位一直是 1TRANS_COUNT 也不減少程序卡死在等待循環(huán)里。原因幾乎都是 TREQ_SEL 沒有設成 DREQ_FORCE。有些人從外設 DMA 的例程改過來直接填了 SPI、UART 的 DREQ 編號DMA 一直在等那個外設的信號。排查方法很簡單在讀回 CTRL_TRIG 時檢查一下ctrl_now mem32[chan_base 0x0C] print(hex((ctrl_now 16) 0x3F)) # 應該輸出 0x3f如果讀出來不是0x3F那就說明控制字拼錯了。6.2 地址對齊問題導致數(shù)據(jù)錯亂或死機使用 32 位 DMA 時源地址和目標地址都必須 4 字節(jié)對齊傳輸元素個數(shù)也是按 32 位算的。如果src、dst是用bytearray建的地址不一定是 4 字節(jié)對齊這時候直接上 32 位 DMA很容易出現(xiàn) AHB 總線錯誤嚴重時直接把 MicroPython 干崩。更隱蔽的坑是緩沖區(qū)長度不是 4 的倍數(shù)但代碼里沒有檢查。TRANS_COUNT 按元素個數(shù)算此時多傳的字節(jié)會越過緩沖區(qū)邊界寫到相鄰內存里可能踩壞解釋器的變量甚至堆。所以用 32 位傳輸前務必加上檢查if (src_addr 3) or (dst_addr 3): raise ValueError(address must be 4-byte aligned) if len(src) % 4 ! 0 or len(dst) % 4 ! 0: raise ValueError(length must be multiple of 4)推薦的做法32 位搬運就用array(I)建緩沖區(qū)長度天然是 4 的倍數(shù)地址對齊也交給分配器通常不會出問題。6.3 等待完成不要死等記得看 AHB_ERROR初學者容易把等待循環(huán)寫成while mem32[chan_base 0x0C] (1 24): pass如果配置有誤這個循環(huán)就是死循環(huán)。建議至少加一個超時t0 time.ticks_ms() while mem32[chan_base 0x0C] (1 24): if time.ticks_diff(time.ticks_ms(), t0) 100: raise OSError(DMA timeout)同時在傳輸完成后檢查 AHB_ERROR 位bit 28和讀寫錯誤位bit 23、bit 22可以幫助定位地址越界、非法訪問之類的問題。錯誤位寫 1 表示發(fā)生過錯誤讀完后軟件清零的方式是寫 1 清除但更穩(wěn)妥的做法是直接重新配置整個通道。6.4 GC 回收對象導致數(shù)據(jù)丟失MicroPython 的 GC 雖然不會移動對象但會回收不再被引用的對象。如果你在函數(shù)里寫完這樣一段代碼def bad_copy(): tmp_src bytearray(1024) tmp_dst bytearray(1024) dma_memcpy(tmp_dst, tmp_src, len(tmp_src)) # 函數(shù)返回后 tmp_src/tmp_dst 不再被引用可能被GC回收看起來好像沒有毛病但如果測試環(huán)境里堆內存緊張GC 可能在函數(shù)返回后的某個時刻回收緩沖區(qū)而 DMA 已經(jīng)寫完了問題不明顯。更危險的是如果 DMA 傳輸還沒結束函數(shù)就返回了硬件還在往一塊即將被回收的內存里寫數(shù)據(jù)整個堆都被污染。所以凡是做 DMA源和目標緩沖區(qū)都應該由調用方持有引用傳輸完成后再丟棄。6.5 推薦的調試順序第一次上手建議按下面的順序來能少踩很多坑先用 8 位傳輸、小緩沖區(qū)比如 16 字節(jié)跑通基本流程確認 BUSY 等待和結果校驗邏輯正確。再換大一點的連續(xù)內存比如 1KB確認 INCR_READ、INCR_WRITE 的行為符合預期。最后再上 32 位傳輸并且加好對齊檢查和超時保護。如果發(fā)現(xiàn)異常一件事一件事地排除控制字、地址、長度、等待方式。我個人在實際操作中的體會是RP2040 的 DMA 內存到內存?zhèn)鬏斠坏┡芡ㄒ淮魏罄m(xù)就是在 12 個通道之間自由調度的事。你完全可以把幾個通道固定給不同的數(shù)據(jù)搬運任務再用 CHAIN_TO 把多個通道串成鏈式傳輸實現(xiàn)“搬完一塊接著搬下一塊”的流水線。最后的最后再分享一個小技巧如果緩沖區(qū)地址不滿足 32 位對齊但又想提高傳輸效率可以考慮先做一次字節(jié)搬運把地址“對齊”剩下的主體部分用 32 位 DMA尾部再用字節(jié)補齊。不過這個概念對 MicroPython 來說有點偏底層了平時先保證對齊條件比任何優(yōu)化都來得實際。