やりたいこと:RP2350(Raspberry Pi Pico2)を使ってDVI信号を出す (素のMicroPythonを使う、ハードもPico2のままでPSRAMは付けない)
結果:モノクロの640x480のフレームバッファをRP2350のRAM上に構築、HSTX/TMDSを使ってDVI信号を生成、モニタでストライプが出るのを確認、MicroPythonのframebufモジュールでグラフィック表示を確認
詳細:
C言語なら、画像の一行単位で終了割込み入れてアドレス再設定等できるのですが、MicroPythonだと間に合わないので、639行まではDMAのchainで水平同期->画像転送をハードだけで自動的に行い、640行まで転送が終わったかどうかをPIOで判定させて、PIO+DMAで画像転送用DMAのチェーン先を切り替えて、垂直同期用のDMAを起動するようにしています。

当初は320x240のRGB565を考えていましたが、320x240を640x480に変換するのが結構やっかいなので、まずは、画素数変換が不要な640x480モノクロで実装しました。
frameバッファは画素データだけではなく、[TMDS_CTRL][PIXEL(32)].....[TMDS_CTRL][PIXEL(32)]という、制御コマンド+画素データの交互配置(Zipper形式)です。この形式は制御コマンド分が無駄であり、このままだとMicroPythonのframebufによる描画ができないので、Zipper形式の変換もPIOで実装する必要があります。ここはまだできていません。
ソースは以下 (ハードのセットアップが大変なので結構長いプログラムです)
github.com
ストライプを表示したところ

framebufモジュールが使えないので、描画関数を作成。Zipper形式のframe bufferの画素データを書き換え。
VGA_WIDTH = 640
VGA_HEIGHT = 480
PIXEL_PER_WORD = 32
def pset(x, y ,clear_flag=False):
global disp_frame_buffer
virtual_address = y * VGA_WIDTH + x
index = int(virtual_address / PIXEL_PER_WORD)
index_in_frame_buf = index * 2 + 1 # 2 is zipper format
pixel_data = disp_frame_buffer[index_in_frame_buf]
if not clear_flag :
pixel_data |= 1 << (x % PIXEL_PER_WORD)
else:
pixel_data &= ~(1 << (x % PIXEL_PER_WORD))
disp_frame_buffer[index_in_frame_buf] = pixel_data
for y in (239, 240, 241):
for x in range(VGA_WIDTH):
pset(x, y, True)画面中央に黒い線が入る

ライン描画関数を作ってテスト。
def draw_line(point0, point1, is_black=False):
x0, y0 = point0
x1, y1 = point1
if x0 == x1:
for y in range(y0 , y1 + 1):
pset(x0, y, is_black)
elif y0 == y1:
for x in range(x0 , x1 + 1):
pset(x, y0, is_black)
else:
a = (y0 - y1) / (x0 - x1)
b = y1 - a * x1
if a <= 1:
for x in range(x0 , x1 + 1):
y = int(a * x + b)
pset(x, y, is_black)
else:
for y in range(y0 , y1 + 1):
x = int((y - b) / a)
pset(x, y, is_black)線描画してみる
>>> draw_line((0,0),(640,480),is_black=True)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 16, in draw_line
File "<stdin>", line 13, in pset
IndexError: array index out of range
>>> draw_line((0,479),(639,0),is_black=True)
0スタートなので、最大位置は(639,479)であった。画像は以下

これ以上描画関数を作るのが大変なので、pythonで扱えるTMDS Controlコマンドを含まない画素だけのframe bufferを作り、そこにframebufモジュールを使って描画する方式を実装する。frame bufferにより出来上がった画素データを1WORD(32bit)単位で転送するが、転送先はZipper形式なので、1 index ずつ飛ばしながらコピーする。framebufの関数を使って円やライン、テキストを描画してみた
import array, framebuf
VGA_WIDTH = 640
VGA_HEIGHT = 480
WORDS_PER_LINE = VGA_WIDTH // 32
frame_buf = array.array('I', [0] * (WORDS_PER_LINE * VGA_HEIGHT))
fb = framebuf.FrameBuffer(frame_buf, VGA_WIDTH, VGA_HEIGHT, framebuf.MONO_HMSB)
# disp_frame_buffer
#
# dst_fg ... must be zippzer format (*2 of src)
# src_fb ... normal frame buffer
#
def copy_frame_buffer(dst_fb, src_fb):
if len(src_fb) * 2 != len(dst_fb):
print('Error in copy_frame_buffer')
print('not match size (dst is * 2 of src)')
return False
for i in range(len(src_fb)):
dst_fb[i * 2 + 1] = src_fb[i]
return True
fb.fill(0)
fb.text('Hello, world!', 50-1, 50-1,1)
fb.line(320-1, 0, 320-1, 480-1,1)
fb.line(0, 240-1, 640-1, 240-1,1)
fb.line(0, 0, 640-1, 480-1,1)
fb.line(0, 480-1, 640-1, 0,1)
fb.ellipse(320-1, 240-1, 240-1, 240-1,1)
copy_frame_buffer(disp_frame_buffer, frame_buf)
#
#
#結果、framebufを使った描画例

やっていること:画像データだけのモノクロframe bufferを作って、MicroPythonのframebufモジュールで描画。出来上がったframebufferのデータをPythonコードでZipper形式のバッファにコピー。(ソフトウエアによるコピー、Zipper形式は、[CMD][画素]が連なってるので、コピーの際インデックスは一つおきに飛ばす必要ある)
sin/cosの描画例
import array, framebuf
VGA_WIDTH = 640
VGA_HEIGHT = 480
WORDS_PER_LINE = VGA_WIDTH // 32
frame_buf = array.array('I', [0] * (WORDS_PER_LINE * VGA_HEIGHT))
fb = framebuf.FrameBuffer(frame_buf, VGA_WIDTH, VGA_HEIGHT, framebuf.MONO_HMSB)
# disp_frame_buffer
#
# dst_fg ... must be zippzer format (*2 of src)
# src_fb ... normal frame buffer
#
def copy_frame_buffer(dst_fb, src_fb):
if len(src_fb) * 2 != len(dst_fb):
print('Error in copy_frame_buffer')
print('not match size (dst is * 2 of src)')
return False
for i in range(len(src_fb)):
dst_fb[i * 2 + 1] = src_fb[i]
return True
fb.fill(0)
fb.text('sin & cos', 50-1, 50-1, 1)
fb.line(320-1, 0, 320-1, 480-1, 1)
fb.line(0, 240-1, 640-1, 240-1, 1)
import math
for x in range(640):
rad = 2 * math.pi * (x - 320) / 320
y = int(240 + (240 * math.sin(rad)))
fb.pixel(x,y,1)
y = int(240 - (240 * math.cos(rad)))
fb.pixel(x,y,1)
copy_frame_buffer(disp_frame_buffer, frame_buf)
#
#
#画面表示

今後の取り組み
最終的にはZipper形式ではないflame bufferを使ってMicroPythonのframebufで描画させる方式にしたい。そのためには、画素データだけのframe buffer に対して、 Zipper 形式のframe bufferに変換する機構が必要。PIOとDMA2つでできると思ってるけど、ちょっと手間。上記の手抜き方式でも十分な速度なので、もうこれでいいかと・・
余裕ができたらかわいらしいイラストも出してみたい

かわいいくま壁紙モノクロ調|AI百科「シンテリ」で調べる語句・イラスト
■追記
翌日動かしたら、エラーになった。さすがDVI。。簡単には完成しない。Not Supportのメッセージは初めて見ました。同期信号等のタイミングがずれていると思われる。オシロで調べたら分かるかも。

オシロで調べると1画面の周期が71.63Hzであった。これは確かに仕様を逸脱している(正しい周期は60Hz)。

同じプログラムを動かしてるはずなのに、なぜ微妙に狂うのか(苦笑)。。1画面の周期がずれる原因として、画像領域のTMDSエンコーダの指定誤りの可能性が大。

画像1ラインの周期が仕様上は31.68usのはずが、試作版の動作では25.2usで動いている。これはおかしい。一行が早い。クロックは変えていない(つもり)のに、早いということはTMDSエンコーダに対する画素数の指定にミスがあると思われる*1。
フレームバッファの先頭2WORDを確認
>>> bin(disp_frame_buffer[0])
'0b10000000100000'
>>> bin(disp_frame_buffer[1])
'0b11110000110011001010101011111111'
解釈すると、、
TMDS:0x2 + size: 0b0010_0000 (0x20) 32画素
32画素分をTMDSエンコードするということなので、実装は意図通り。だとすると、一行あたりのDMA転送サイズに誤りが??
気になるのは、いつもデバッグ時は正常に動くシンプルなソースを動かしてから、ややこしいプログラムのテストを行っていて、正常に動くソースを最初に動かすことで、見えていないパラメータを暗黙のうちに最適に設定している可能性は?? *2
DMA転送パラメータは以下の通りで、これは変えていない
dma2.config(read=buffer, write=HSTX_FIFO_ADDR, ctrl=dma2_ctrl, trigger=False, count=int(VGA_WIDTH/32) *2 ) # /32 means omoncolor, in 1 data(32) contains 32pixcel, * 2 means control + IMAGE
だとすると実行途中にバグ混入で誤って転送サイズを変更してしまっている??
周期をおかしくする要因としては、(1)TMDSエンコードへのパラメータのミス、(2)DMAによるHSTX FIFOへの転送回数のミス、(3)(3)HSTXに供給ししているクロックのミスが挙げられる。エンコードパラメータは合ってそうなので、、転送回数がおかしいか、クロックがおかしいか。転送回数が間違っていないかの判断は、DMAの転送終了時のアドレスがframe bufferの最大アドレスと一致するかを確認すれば良い。
調べるのが楽なシステムクロックを確認、これはOK
HSTXに対してSYSTEM CLOCKを供給している。
# SYS_CLK
#
CLK_ENABLE = 1
CLK_SYS = 0
CLK_PLL_USB=2
val = (CLK_ENABLE << 11) + (CLK_SYS << 5) # DVI Speed
#val = (CLK_ENABLE << 11) + (CLK_PLL_USB << 5) # slow speed
write_CLK_HSTX_CTRL(0) # disable clock
div = 1 << 16
write_CLK_HSTX_DIV(div) # 150 MHz
write_CLK_HSTX_CTRL(val) # enable and PLL source
現在のシステムクロックはDVI出力用に調整した126MHzを設定
>>> machine.freq()
126000000
DMA2による転送終了時の番地はデバッグ用に記録していて、 last_frame_addrに格納されている。
>>> last_frame_addr-addressof(disp_frame_buffer)
76800
開始番地から終了番地を引くと、76800アドレス(8bit)となっている。
640x480の画像を出すには、1画素1bitで1WORD 32bitなので、9600WORD必要。Zipper形式で構築しているので、2倍の19200WORD必要。上記76800(8bit)をWORD(32bit)で換算すると、19200WORDとなり一致。だから、転送総数も間違いない。
調べられる範囲では設定は合っている。なのに送信速度がおかしい。だとすると、クロックの解釈が間違っている?
たとえば、DVI画像クロックを調べると、HSTXの動作クロックが正しいわかるはず。
あるいは、計測しているいろんな周期のずれ幅の割合が近い値で揃っているなら、供給しているクロックがずれているともいえる。
DVIのCLKを調べると、12.5MHzであった。仕様では25MHzのはずなのに、なぜ2倍なのか??
正常に動作するシンプル実装で描画させるとエラー無く表示されながら、DIV CLKはやはり12.5MHzであった。なぜこれでいいのか分からないが、、12.5MHzは同じ値なので、描画できなくなった変化点はDVI CLKではない。しかも、25MHzに対してぴったり半分なので、HSTXに供給しているクロックがおかしいわけではない。だとするとあと考えられるのはDMAのDATA REQUEST同期がうまく働かず、時々すっぽ抜けてDMAはHSTXのREADYを待たずにHSTXのFIFOに送ってしまうぐらいではないか。。
Claudeに相談するとDREQを指定していたら、HWの動作上すっぽ抜けることは考えにくく、短縮の比率から考えて、水平同期の送信が抜けたりしていると考えるべきでは??と言ってきた。ふむふむ。
本来の一行の周期に対して短くなっている時間が6us程度、水平同期の表示時間が6.4usなので計算上は合う。水平同期のDMA転送がなんらかの要因で動いていないのか? CTRLレジスタにはERRORフラグが立っていないし、もし全然動いていなかったら、後段のDMAにchain しないだろう。転送回数指定が0になっているとか?
水平同期信号を転送するDMA1の終了割込みで、read addressを取得、すると、バッファの先頭になっていた。
>>> hex(last_hsync_addr)
'0x2000bbd0'
>>> hex(addressof(hsync_frame_buffer))
'0x2000bbd0'
タイミング異常の時、上記の値。見る限り動いていないように思える。RINGだからといって、転送終了した時は、バッファの最後を指しているはず。だから、転送すっぽ抜けている。転送せずに、次のDMAにchainしているように思える。
DMA1の転送先(writeレジスタの値)をデバッグ用に用意したバッファに向ける。hsyncのframe bufferの内容は以下
>>> [hex(x) for x in hsync_frame_buffer]
['0x1008', '0x354d5354', '0x1008', '0x354d5354', '0x1060', '0x354d50ab', '0x1030', '0x354d5354']
最後の値が入るはずだが。。
>>> dma1.write = addressof(tmp_buffer)
>>> tmp_buffer
array('I', [1, 0, 0, 0, 0, 0, 0, 0, 0, 0])
>>> tmp_buffer
array('I', [1, 0, 0, 0, 0, 0, 0, 0, 0, 0])1とは何か?? 常に1が書き込まれてはいる。
DMA1の転送回数も参照可能。確認すると転送カウント8となっている。(仕様通り)
>>> OFFSET_CH1_DBG_TCR=0x844
>>> print(read_reg(BASE_DMA, OFFSET_CH1_DBG_TCR))
8
まとめると、、HSTXに供給されているクロックは正しい、画像エリア(640x480)の転送回数も正しい、垂直同期、水平同期は呼び出されている。
異常と思われる点: 水平同期信号を送るDMA1の挙動がなにかおかしい印象
DMA1の終了割込みで送信元アドレスを参照すると、送信用データ領域の先頭のアドレスになっている(最後尾のアドレスになるはずでは?)
DMA1の転送において、送信用データ領域の値とは異なる値が送られているような印象。
不具合原因
Claudeにも助けてもらったのだが、、今回の異常動作はRING用に確保した領域のアライメントミスが原因
>>> hex(addressof(hsync_frame_buffer))
'0x2000bbd0'
>>> bin(addressof(hsync_frame_buffer))
'0b100000000000001011101111010000' # 5bit幅で回したいのに、5bit目が1になっている。NG!
今回のRINGではバス幅として5bitの範囲でぐるぐる回るはずだが、バッファの開始アドレスは、bit5が1になっている。このアドレスから開始したら、正しいリングにならない。だからずれた領域でRING動作した結果、変な値が転送されていた。TMDSでは変な制御コードが来たので無視して取り扱わず、エンコードも速くなったと。。 アライメントを算出しているのは自分の関数なので、原因を作り出したのは自分のアライメント計算関数だ。昨日動いて今日動かないというのも、確保されるアドレスがランダムだから、バグが発生するか、しないか、アドレス次第だったということだろう。。(本当にこれが問題なのか修正完了していないので推測ですが)原因特定まで一日かかったけど、、いやー勉強になった。
アライメントを考慮してバッファを確保する関数
(アライメント不整合のまま返してしまうバグ入り版)
#
# create alligned buffer
# array type is WORD(32bit) only
#
def make_aligned_buffer(size):
if is_power_of_two(size):
pass
else:
print('internal error! not power of 2')
return None
buffer = array('I', [0] * size * 2) # I means unsigned int
base_addr = addressof(buffer)
if base_addr == (base_addr & ~(size - 1)):
target_addr = base_addr
else:
target_addr = (base_addr & ~(size - 1)) + size
offset_index = int((target_addr - base_addr) / 4)
print(offset_index, size)
return memoryview(buffer)[offset_index : offset_index + size]alimentの計算において、引数がWORD単位の長さを意図していたのだが、アライメント計算ではメモリ確保に必要なSIZE*4にせず、1/4のWORDのままで計算していた。(sizeの単位を混乱していたのがバグの原因)
修正版は以下(単純に*4)
#
# size is n of words (not bytes)
# so in memory area, allocating address is n * 4
#
def make_aligned_buffer(word_size):
if is_power_of_two(word_size):
pass
else:
print('internal error! not power of 2')
return None
buffer = array('I', [0] * word_size * 2) # 'I' means unsigned int
base_addr = addressof(buffer)
if base_addr == (base_addr & ~(word_size * 4 - 1)):
target_addr = base_addr
else:
target_addr = (base_addr & ~(word_size * 4 - 1)) + word_size * 4
offset_index = int((target_addr - base_addr) / 4)
print(offset_index, word_size)
return memoryview(buffer)[offset_index : offset_index + word_size]修正版をcommit
github.com