存拷貝實戰(zhàn))
把 MicroPython 和 DMA 放在同一句話里很多人第一反應是懷疑MicroPython 不是靠解釋器跑字節(jié)碼的嗎怎么可能碰 DMA 這種底層外設我一開始也這么想直到有個項目要把幾百 KB 的音頻數(shù)據(jù)從一塊緩沖區(qū)搬到另一塊CPU 在 while 循環(huán)里搬了幾萬次數(shù)據(jù)板子卡得連 I2C 掃描都明顯變慢。后來在樹莓派 Pico 上研究了一晚上發(fā)現(xiàn) MicroPython 可以通過machine.mem32直接操作 RP2040 的 DMA 寄存器實現(xiàn)內(nèi)存到內(nèi)存拷貝速度比 Python 循環(huán)快幾十倍而且代碼并不復雜。這篇文章就把整個過程整理成保姆級教程從寄存器原理到實操代碼、常見坑全走一遍。1. 內(nèi)容整體設計與思路拆解1.1 DMA 到底解決什么問題DMA 的全稱是 Direct Memory Access直接內(nèi)存訪問。你可以把它理解成一條獨立的“數(shù)據(jù)傳送帶”CPU 只需要把“從哪搬、搬到哪、搬多少”告訴 DMA 控制器剩下的重復搬運工作由 DMA 硬件自動完成搬運過程中 CPU 基本不用管。內(nèi)存到內(nèi)存?zhèn)鬏斁褪前褦?shù)據(jù)從 SRAM 的一塊區(qū)域搬到另一塊區(qū)域。你可能想這種事情不是一行for循環(huán)就能做嗎問題在于MicroPython 的for循環(huán)是解釋執(zhí)行的每搬一個字節(jié)解釋器都要解析指令、查詢對象、更新循環(huán)計數(shù)器開銷是巨大的。DMA 是純硬件搬運沒有解釋器開銷一個 AHB 時鐘周期就能搬一個 word兩者差距非常明顯。1.2 為什么在 MicroPython 里也能配置 DMA很多初學者誤以為 MicroPython 是“玩具語言”只能點燈、讀傳感器。其實 MicroPython 運行在真實硬件上天然可以通過內(nèi)存映射寄存器訪問外設。RP2040 和普通 Cortex-M0 芯片一樣沒有 MMU所有外設寄存器都映射在固定的地址空間里。MicroPython 的machine.mem32允許你直接讀寫在某個絕對地址上的 32 位整數(shù)。DMA 控制器的寄存器也在內(nèi)存映射區(qū)所以理論上可以用machine.mem32操作 DMA 控制器的所有寄存器。這個方案不依賴任何第三方庫通用性極強只要固件是官方 MicroPython 就能跑。1.3 內(nèi)存到內(nèi)存 DMA 的適用場景內(nèi)存到內(nèi)存?zhèn)鬏斪畹湫偷膱鼍笆且纛l處理。音頻采集 DMA 會把 ADC 或 I2S 的數(shù)據(jù)寫進緩沖區(qū)你需要在緩沖區(qū)充滿之后把它搬到另一個處理緩沖區(qū)這時候用 DMA 搬運CPU 可以騰出來做濾波、壓縮、顯示刷新。另一個場景是圖形刷新。如果你用 Pico 驅動一塊小 LCD可能需要頻繁把圖像 buffer 從一個緩存拷貝到另一個緩存DMA 能顯著減少刷新耗時。還有緩沖區(qū)清零后面我會單獨講一個由 DMA 執(zhí)行的快速清零技巧。但是如果只是幾十個字節(jié)的搬運DMA 反而不劃算。配置寄存器本身就有幾條語句MicroPython 里執(zhí)行這幾條語句也需要時間。數(shù)據(jù)量越大DMA 的優(yōu)勢越明顯個人經(jīng)驗是單次搬運超過 256 字節(jié)DMA 帶來的收益就很可觀了。1.4 為什么自己寫寄存器而不是找現(xiàn)成庫MicroPython 的 rp2 移植目前沒有提供開箱即用的 DMA 高層封裝網(wǎng)上能搜到的第三方庫大多依賴特定固件版本而且功能不完整。自己寫寄存器雖然看起來“底層”但恰恰是這類硬件外設最穩(wěn)定的使用方式。寄存器一旦配好只要 RP2040 的地址映射不變代碼在很長一段時間內(nèi)都能直接用。另外理解了寄存器配置之后你會對 DMA 的工作機制有更深的理解后面再去用 SPI DMA、PIO DMA 都是順理成章的事。2. 核心細節(jié)解析RP2040 DMA 控制器的寄存器與配置原理2.1 外設基地址與通道布局RP2040 的 DMA 控制器基地址是0x50000000一共 12 個 DMA 通道每個通道占 0x40 字節(jié)的寄存器空間。也就是說通道 0 的寄存器在0x50000000通道 1 的寄存器在0x50000040通道 N 的寄存器在0x50000000 N * 0x40。通道內(nèi)最關鍵的四個寄存器是寄存器偏移作用READ_ADDR0x00讀地址DMA 從哪里讀數(shù)據(jù)WRITE_ADDR0x04寫地址DMA 把數(shù)據(jù)寫到哪里TRANS_COUNT0x08傳輸計數(shù)還剩下多少數(shù)據(jù)沒搬完CTRL0x0C控制寄存器配置寬度、遞增、觸發(fā)源等這里特別強調(diào)一下TRANS_COUNT是一個“遞減型計數(shù)器”DMA 每完成一次傳輸它就自動減一。當你讀取它發(fā)現(xiàn)是 0 的時候說明傳輸已經(jīng)完成。這個特性在后面的等待邏輯里會用到。2.2 CTRL 控制寄存器逐位拆解CTRL 寄存器是 DMA 配置的核心。我在實驗中最常用的位段如下位段位數(shù)取值含義ENbit 0通道使能置 1 后通道開始響應觸發(fā)DATA_SIZEbit 2-3傳輸寬度0 表示 8 位1 表示 16 位2 表示 32 位INCR_READbit 4讀地址是否遞增1 表示每次傳輸后源地址 數(shù)據(jù)寬度INCR_WRITEbit 5寫地址是否遞增1 表示每次傳輸后目標地址 數(shù)據(jù)寬度DIRbit 11傳輸方向1 表示從內(nèi)存讀取0 表示從外設讀取BSWAPbit 16字節(jié)交換內(nèi)存到內(nèi)存通常不用CHAIN_TObit 21-25鏈式 DMA指定當前通道完成后自動觸發(fā)哪個通道TREQ_SELbit 26-30觸發(fā)源選擇決定 DMA 通道以什么節(jié)奏傳輸這里最容易搞混的是DIR位。RP2040 的 DMA 通道可以用在“外設到內(nèi)存”和“內(nèi)存到外設”兩個方向上。對于內(nèi)存到內(nèi)存?zhèn)鬏斘覀兿M?DMA 從內(nèi)存讀數(shù)據(jù)所以DIR必須置 1。如果DIR為 0DMA 會以為你是從某個外設讀數(shù)據(jù)行為可能會很詭異。2.3 TREQ_SEL 設為 0x3F 是內(nèi)存到內(nèi)存?zhèn)鬏數(shù)年P鍵TREQ_SEL 是 DMA 的“觸發(fā)源選擇”。外設 DMA 的場景里SPI 發(fā)送一個字節(jié)就會有 DREQ 信號請求 DMA 喂下一個數(shù)據(jù)所以 DMA 的傳輸節(jié)奏由外設決定。但內(nèi)存到內(nèi)存?zhèn)鬏敍]有外設信號你必須給 DMA 一個“永遠有請求”的狀態(tài)讓它以最快速度連續(xù)搬運。RP2040 手冊里專門定義了一個觸發(fā)源叫DREQ_FORCE值剛好是0x3F也就是 63。把TREQ_SEL設為0x3FDMA 就會變成無條件的連續(xù)傳輸模式。很多人在網(wǎng)上抄代碼發(fā)現(xiàn) DMA 不工作一個常見原因就是TREQ_SEL沒有設置為0x3F。這個字段在 CTRL 寄存器的 bit 26 到 bit 30如果寫 0DMA 可能一直在等待外設觸發(fā)TRANS_COUNT永遠減不到 0。2.4 對齊、數(shù)據(jù)寬度和地址遞增策略RP2040 的 DMA 在 32 位模式下要求源地址和目標地址都必須 4 字節(jié)對齊同時傳輸字節(jié)數(shù)也必須是 4 的倍數(shù)。如果不滿足對齊條件DMA 可能直接觸發(fā)總線錯誤甚至導致系統(tǒng) HardFault。8 位模式?jīng)]有對齊要求任意地址、任意長度都能搬。16 位模式要求 2 字節(jié)對齊。所以你的取舍是追求最快速度就用 32 位模式但要保證 buffer 對齊追求通用性就用 8 位模式犧牲一部分性能。地址遞增策略也有講究。如果INCR_READ 1且INCR_WRITE 1DMA 會從源地址一路遞增讀到目標地址一路遞增寫這是標準的“內(nèi)存拷貝”模式。如果INCR_WRITE 0DMA 會把同一個值反復寫到同一塊地址的連續(xù)區(qū)域這個特性可以用來快速填充和清零后面第 5 節(jié)會詳細講。3. 實操過程MicroPython 下的完整實現(xiàn)3.1 準備工作與環(huán)境搭建實操前需要準備一塊樹莓派 Pico 或 Pico W一根 USB 數(shù)據(jù)線刷好官方 MicroPython 固件的開發(fā)環(huán)境Thonny 或 mpremote 都行RP2040 Datasheet遇到不明白的位段可以查固件版本建議至少 1.19 以上我實測用的 1.23 和 1.24 都正常。machine.mem32和uctypes都是標準模塊不需要額外安裝。連接開發(fā)板后先驗證machine.mem32能否正常訪問 DMA 寄存器import machine DMA_BASE 0x50000000 # 讀通道0的CTRL寄存器初始值應該是0 print(hex(machine.mem32[DMA_BASE 0x0C]))如果輸出了0x0說明內(nèi)存映射訪問正??梢岳^續(xù)。3.2 獲取 bytearray 真實內(nèi)存地址的正確姿勢MicroPython 中bytearray是一個 Py 對象你需要的地址是它內(nèi)部數(shù)據(jù)區(qū)在 SRAM 中的地址。id()返回的是對象頭部的地址不是數(shù)據(jù)區(qū)地址所以不能用。正確做法是用uctypes.addressof()import uctypes buf bytearray(1024) buf_addr uctypes.addressof(buf) print(hex(buf_addr), buf_addr % 4)addressof返回的是 bytearray 數(shù)據(jù)區(qū)首地址。只要你的 buffer 變量一直存活這個地址就是有效的DMA 可以放心用。注意MicroPython 的 GC 不會移動對象所以即使發(fā)生垃圾回收這個地址也不會變但前提是引用不能丟否則對象被回收地址就懸空了。3.3 基于寄存器配置的內(nèi)存到內(nèi)存 DMA 完整代碼下面是我整理的兩個函數(shù)。第一個是 32 位模式速度最快但對齊條件嚴格第二個是 8 位模式無對齊要求適合通用搬運。from machine import mem32 from uctypes import addressof DMA_BASE 0x50000000 def dma_copy_word(dst, src, nbytes, ch0): 32位模式搬運要求dst/src地址4字節(jié)對齊nbytes是4的倍數(shù) base DMA_BASE ch * 0x40 if nbytes % 4 ! 0: raise ValueError(nbytes must be multiple of 4) words nbytes // 4 # 先配置好地址和計數(shù)再寫入CTRL啟動 mem32[base 0x00] addressof(src) # READ_ADDR mem32[base 0x04] addressof(dst) # WRITE_ADDR mem32[base 0x08] words # TRANS_COUNT按word計數(shù) # EN1, DATA_SIZE2(32位), INCR_READ1, INCR_WRITE1, DIR1, TREQ_SEL0x3F ctrl (1 0) | (2 2) | (1 4) | (1 5) | (1 11) | (0x3F 26) mem32[base 0x0C] ctrl # 等待傳輸完成TRANS_COUNT自然會減到0 while mem32[base 0x08] ! 0: pass def dma_copy_bytes(dst, src, nbytes, ch0): 8位模式搬運地址任意、長度任意最通用 base DMA_BASE ch * 0x40 mem32[base 0x00] addressof(src) # READ_ADDR mem32[base 0x04] addressof(dst) # WRITE_ADDR mem32[base 0x08] nbytes # TRANS_COUNT按字節(jié)計數(shù) # EN1, DATA_SIZE0(8位), INCR_READ1, INCR_WRITE1, DIR1, TREQ_SEL0x3F ctrl (1 0) | (0 2) | (1 4) | (1 5) | (1 11) | (0x3F 26) mem32[base 0x0C] ctrl while mem32[base 0x08] ! 0: pass我先解釋一下ctrl這行是怎么算出來的(1 0)EN 位置 1使能 DMA 通道(2 2)DATA_SIZE 設置為 2也就是 32 位模式(1 4)INCR_READ 置 1讀地址遞增(1 5)INCR_WRITE 置 1寫地址遞增(1 11)DIR 置 1表示本次傳輸從內(nèi)存讀取數(shù)據(jù)(0x3F 26)TREQ_SEL 設置為 0x3F使用 DREQ_FORCE 強制連續(xù)傳輸配置順序也有講究READ_ADDR、WRITE_ADDR、TRANS_COUNT 都必須在 CTRL 的 EN 位寫 1 之前完成配置否則 DMA 可能在地址還沒有準備好的時候就開始搬數(shù)據(jù)。最穩(wěn)妥的方式就是把 CTRL 放在最后一步寫。3.4 性能驗證與結果分析下面跑一個簡單的對比測試數(shù)據(jù)量 4KBimport time src bytearray(4096) dst bytearray(4096) # 給源數(shù)據(jù)填充規(guī)律值方便驗證 for i in range(4096): src[i] i 0xFF t0 time.ticks_us() dma_copy_word(dst, src, 4096, ch0) t_dma time.ticks_diff(time.ticks_us(), t0) print(DMA copy: , t_dma, us) print(match:, src dst) # Python純循環(huán)對照 t0 time.ticks_us() for i in range(4096): dst[i] src[i] t_py time.ticks_diff(time.ticks_us(), t0) print(Python copy:, t_py, us) print(speedup:, t_py / t_dma)我在 Pico 上實測4KB 數(shù)據(jù)用 DMA 也就幾十微秒而純 Python 循環(huán)通常要幾毫秒差距在三到五倍以上。而且數(shù)據(jù)量越大這個差距越明顯。注意dma_copy_word要求 src 和 dst 的地址是 4 字節(jié)對齊的如果遇到對齊錯誤改成dma_copy_bytes就不會出問題。4. 常見問題與排查實錄4.1 數(shù)據(jù)拷貝完了但內(nèi)容不對這種現(xiàn)象最常見的原因是用了id(buffer)去取地址。id()返回的是 PyObject 頭部的地址不是數(shù)據(jù)區(qū)域DMA 從頭部地址讀出來的數(shù)據(jù)當然不是你想要的。解決辦法統(tǒng)一用uctypes.addressof()。另一個原因是字節(jié)序。RP2040 默認小端模式如果你在 32 位模式下搬運數(shù)據(jù)內(nèi)存布局是低字節(jié)在前當你把源和目標都看成統(tǒng)一數(shù)組時結果應該是對的。但如果你在其他架構上生成的數(shù)據(jù)文件再放到 Pico 上直接搬運就可能出現(xiàn)大小端問題。這種情況可以檢查一下 CTRL 的 BSWAP 位。4.2 DMA 傳輸一直不結束如果你發(fā)現(xiàn)while mem32[base 0x08] ! 0卡死了先檢查 CTRL 的 TREQ_SEL 是否寫成了 0x3F。沒有這個強制觸發(fā)源DMA 可能一直在等外設 DREQTRANS_COUNT 不減到 0。另外也可能是通道被占用。MicroPython 的某些底層庫比如 audio、PIO、I2S 驅動可能已經(jīng)占用了某幾個 DMA 通道。如果你使用通道 0 恰好在系統(tǒng)固件里被占用就會沖突。寫代碼前可以先檢查一下通道是否空閑def is_dma_channel_free(ch): return (machine.mem32[DMA_BASE ch * 0x40 0x0C] 1) 0如果返回 False說明通道被占用換一個 ch 重新試。4.3 系統(tǒng) HardFault 或死機HardFault 絕大多數(shù)情況是地址沒對齊或地址非法。32 位模式要求源地址、目標地址都是 4 的倍數(shù)如果bytearray恰好不是按 4 對齊分配的就會出問題。遇到死機的時候可以先換成 8 位模式驗證數(shù)據(jù)通路是否正常再考慮對齊問題。另外DMA 訪問不能超過實際內(nèi)存邊界。RP2040 的內(nèi)置 SRAM 是 264KB地址范圍是0x20000000到0x20041FFF如果源 buffer 或目標 buffer 越界DMA 可能訪問到不存在的地址系統(tǒng)不死才怪。4.4 DMA 沒有比 Python 循環(huán)快多少如果你發(fā)現(xiàn) DMA 帶來的提速不明顯先看數(shù)據(jù)量。幾十字節(jié)的拷貝根本發(fā)揮不出 DMA 的優(yōu)勢至少幾百字節(jié)往上再考慮。還有一個容易被忽略的問題MicroPython 的函數(shù)調(diào)用、寄存器配置本身也有執(zhí)行開銷。如果你在 DMA 的等待循環(huán)里加了額外的打印操作或者把配置寫在一個低效的循環(huán)里整體耗時會被 Python 端拉高。最好的做法是設計一個長緩存一次 DMA 搬完而不是小段多次搬運。4.5 速查表現(xiàn)象可能原因解決辦法數(shù)據(jù)是全零或亂碼用id()取地址出錯改用uctypes.addressof()TRANS_COUNT 卡著不動TREQ_SEL 未設 0x3FCTRL 寫入0x3F 26HardFault / 死機地址未 4 字節(jié)對齊換 8 位模式或手動對齊DMA 通道沖突固件庫占用通道換空閑通道提速不明顯數(shù)據(jù)量太小至少 256 字節(jié)以上再 DMA5. 擴展玩法DMA 還能這么用5.1 快速清零與填充固定地址中間值DMA 的 INCR_READ 和 INCR_WRITE 是分開控制的這給我們留了一個很有意思的玩法。如果 Read 地址不遞增Write 地址遞增DMA 就會把同一個值反復寫到一塊連續(xù)區(qū)域。利用這個特性可以實現(xiàn)快速清零def dma_zero_fill(dst, value, nbytes, ch0): base DMA_BASE ch * 0x40 if nbytes % 4 ! 0: raise ValueError(nbytes must be multiple of 4) # 準備一個固定的word值的buffer val_buf bytearray(4) val_buf[0] value 0xFF val_buf[1] (value 8) 0xFF val_buf[2] (value 16) 0xFF val_buf[3] (value 24) 0xFF mem32[base 0x00] addressof(val_buf) # 源地址固定 mem32[base 0x04] addressof(dst) # 目標地址遞增 mem32[base 0x08] nbytes // 4 # INCR_READ0, INCR_WRITE1, DATA_SIZE2, DIR1, TREQ_SEL0x3F ctrl (1 0) | (2 2) | (0 4) | (1 5) | (1 11) | (0x3F 26) mem32[base 0x0C] ctrl while mem32[base 0x08] ! 0: pass這個技巧在音頻靜音、圖像清屏、幀緩沖初始化里特別實用。一次性 DMA 搬運幾百個 word 比for循環(huán)快得多。5.2 雙緩沖與環(huán)形緩沖DMA 通道自帶的 RING_SIZE 和 RING_SEL 位可以實現(xiàn)環(huán)形緩沖DMA 在地址到達邊界時自動回繞。這個在大批量連續(xù)數(shù)據(jù)采集場景下非常有用比如 ADC 持續(xù)采樣到一個小緩沖區(qū)緩沖區(qū)滿了自動從頭覆蓋CPU 只需要在間隙讀取有效數(shù)據(jù)。內(nèi)存到內(nèi)存?zhèn)鬏斠部梢杂玫江h(huán)形緩沖比如音頻播放循環(huán)播放一段短采樣。把源地址配成環(huán)形回繞DMA 會自動循環(huán)播放采樣數(shù)據(jù)CPU 幾乎零負擔。5.3 多通道并行搬運RP2040 有 12 個 DMA 通道實際項目中可以同時啟用多個通道搬運多路數(shù)據(jù)。比如一個通道負責從 ADC 采集緩沖區(qū)搬到處理緩沖區(qū)另一個通道負責從處理結果搬到顯示緩沖區(qū)兩條流水線互不干擾。不過要注意多個通道共享總線和 DMA 控制器如果同時發(fā)起大量傳輸會搶占帶寬。是否需要分配高優(yōu)先級取決于你的實時性需求。CTRL 的 HIGH_PRIORITY 位可以給關鍵通道更高的總線優(yōu)先級。5.4 配合 PIO 和 SPI 外設DMA 的真正價值不止內(nèi)存到內(nèi)存它還可以配合外設做“零 CPU 參與”的數(shù)據(jù)流傳輸。PIO 是 RP2040 的特色把 DMA 通道綁到 PIO 的 DREQ 上可以實現(xiàn)連續(xù)輸出波形、讀取特定時序的數(shù)據(jù)CPU 只需要配置一次剩下的數(shù)據(jù)流由 DMA 自動搬運。SPI 屏幕刷新也可以這樣從顯存 buffer 直接 DMA 到 SPI TX刷新過程完全不用 CPU 盯著。我實測用這種方案推 320x240 的小屏刷新率比逐像素寫寄存器的方式高出一大截。6. 寫在最后一點個人提醒折騰 RP2040 DMA 的時候我有兩個很深的體會。第一不要迷信“底層就一定難”。DMA 無非就是“源地址、目的地址、數(shù)量、控制”四個要素對著手冊把每個位段搞清楚比在論壇上零散抄代碼靠譜得多。抄來的代碼一旦出錯你都不知道該怎么改。第二MicroPython 寫 DMA 并不是“離經(jīng)叛道”。很多場景下我們既要 Python 的開發(fā)效率又想要接近底層的性能直接操作寄存器就是這座橋。你完全可以在 MicroPython 里把 DMA 配置封裝成通用函數(shù)然后把精力放在應用邏輯上。最后分享一個小技巧如果你不確定當前固件的 DMA 通道是否被其它庫占用可以在初始化前執(zhí)行一次全通道掃描打印所有通道的 EN 狀態(tài)。這樣既能避開沖突也能幫你快速定位“為什么 DMA 不響應”。希望這篇教程能幫你少踩幾個坑順利把 RP2040 的 DMA 用起來。