📝

C++は甘え

2024/12/21に公開

はじめに

高専の授業でArduinoを使うことがあった。普通ならC++とArduino IDEで適当に書くと思う。
この授業とは関係のない別の教員が「楽しちゃダメだから」と言っていたこともあり、楽せずアセンブリ言語を用いてArduinoのプログラムを書いてみることにする。
今回は授業内で利用していたArduino UNO R3を対象とする。

本記事ではLチカを例としてアセンブルと書き込み方法を説明したのち、命令やアセンブラの疑似命令を紹介する。不明瞭な点についてもできるだけ明瞭に書いているつもり。

アセンブラーと書き込みツール

アセンブラーはavraを利用する。
書き込みツールはavrdudeを利用する。

aptであれば以下のコマンドでインストール可能。

$ sudo apt install avra
$ sudo apt install avrdude

pacman+AURであれば以下のコマンドでインストールできる。avraはAUR、avraはpacmanにある。

$ yay -S avra avrdude

こんな感じ

https://avr.jp/ にある命令手引書を見ながら書いていく。すべて日本語になっているので非常にありがたい。

まずは内蔵LEDを点灯させるコードを示す。結果だけ言えばこのようなコードになるが、その前にPINBなどのレジスタについて解説する。

LED.asm
.equ DDRB = 0x04 ;DDRBを0x04と定義
.equ PORTB = 0x05 ;PORTBを0x05と定義

.org 0x0000 ; 0x0000番地に配置
    jmp main ; mainへジャンプ

main:
    sbi DDRB, 5 ;D13を出力に指定
loop:
    sbi PORTB, 5 ;D13にHIGHを出力
    rjmp loop ;メインループ

I/Oレジスタと入出力端子の関係

Arduino IDE等でDDRxやPORTx, PINxなどの意味が理解できる人は、それと完全に同じなので読み飛ばしてもらって構わない。

一応簡単な解説をする。

レジスタとArduino上のピンの関係

レジスタと役割の対応表は以下の通りになる。xにはB,C,Dのいずれかが入る。

key value
DDRx 入力か出力かを決定 (1にすると出力)
PORTx 入力値になる
PINx 出力値を書き込む

DDRxの各ビットを0にしたうえで、PORTxに1を出力するとプルアップ抵抗ありの入力(INPUT_PULLUP)になる。

また、Arduino上の入出力ピンとレジスタの関係は以下の通りになる。黄色背景に書かれているのがピンとレジスタの関係になる。この図はArduino公式サイトから見ることができる。


Arduino公式のピンアウト

例えばD2にHIGHを出力する場合、DDRDの3ビット目[1]を1にして、PORTDの3ビット目を1にすればよい。
C言語で書くと以下のような感じ。

pinMode(2, OUTPUT);
digitalWrite(2, HIGH);
// 上下は等価コード
DDRD |= 0b100;
PORTD |= 0b100;

レジスタをCPUから操作する

前述したようなDDRxなどのレジスタにアクセスする方法を説明する。
まずはCPU上に配置されたアドレスとの対応表を示す。

レジスタ CPU上のアドレス
PINB 0x03
DDRB 0x04
PORTB 0x05
PINC 0x06
DDRC 0x07
PORTC 0x08
PIND 0x09
DDRD 0x0a
PORTD 0x0b

Lチカを理解する

I/Oポートを理解できれば、Lチカはちょろい。
とりあえずもう一度内蔵LEDを光らせるプログラムを示す。

LED.asm
.equ DDRB = 0x04 ;DDRBを0x04と定義
.equ PORTB = 0x05 ;PORTBを0x05と定義

.org 0x0000 ; 0x0000番地に配置
    jmp main ; mainへジャンプ

main:
    sbi DDRB, 5 ;D13を出力に指定
loop:
    sbi PORTB, 5 ;D13にHIGHを出力
    rjmp loop ;メインループ

アセンブラの機能

main:のようなものはラベルと言い、ジャンプ命令などのジャンプ先に利用できる。
このようにすることでアセンブラーがラベルに指定したジャンプ先などのアドレスを自動計算してくれる。これだけでだいぶ革命だと思う。

ピリオドから始まる命令はアセンブラ疑似命令と呼ぶ。
.equは定数を定義する。C言語でいう#define的なものだが、定数のみを定義できる(式は定義できない)。ちなみにAVRアセンブラにも#defineはある[2]。これは式も定義できる。

CPUの命令

普通に書いてある命令はCPUの命令になる。機械語と1対1になったニーモニックと呼ばれる記法で書く。
「AVR命令一式手引書」に命令とニーモニックの詳細がある。

sbi 0x03, 5 
cbi 0x03, 5

sbi命令はI/Oレジスタの指定ビットを1にする命令。
sbi 0x03, 5と書けばアドレス0x03のI/Oレジスタの6ビット目のビットを立てるということになる。
cbi命令もある。これはsbiの逆で、指定されたI/Oレジスタの指定ビットを0にする命令。


main:
    jmp main
    rjmp main

jmp命令は無条件ジャンプの命令。メモリ上のどこにでもジャンプできるが、命令長が4バイトで、命令の実行時間が長い。
rjmp命令は、その命令が実行されている番地から-2048~2047語[3]以内の無条件ジャンプになる。この命令は命令長が2バイトで、命令の実行時間が短い。近い命令にジャンプするならこっちの方がおすすめ。

どちらのジャンプ命令もラベルを書くだけでアセンブラーが良い感じにやってくれる。

Lチカを実行する

上の説明は必要になったら読めば問題ない。とりあえず実行してみる。
何回か見せているLED.asmをコピペして適当なフォルダに置く。

以下のコマンドでアセンブル[4]ができる。LED.hexが機械語のプログラムになる。
また、LED.lstが出力される。これはアセンブルリストと呼び、アセンブリと実際に変換された機械語の対応が書かれている(実際に計算されたジャンプ先アドレスなども含まれる)。

$ avra -fI LED.asm -l LED.lst

以下のコマンドでプログラムをArduinoに書き込むことができる。

$ avrdude -c arduino -P シリアルポート -b 115200 -p atmega328p -D -U flash:w:LED.hex:i


シリアルポートの確認

シリアルポートはlsコマンドで調べられるが、筆者の環境ではなんかたくさん出てきたが、探すのがめんどくさいならArduino IDEから確認することができる。この画像では/dev/ttyACM0がシリアルポートの値になる。

実行するとしっかり内蔵LEDが光っていることがわかる。


アセンブラでLチカ

いろいろな命令

以下に使いそうな命令を示す。

  • Rd, Rrは汎用レジスタを示す。Kは即値、kは命令へのアドレス(絶対または相対)を示す。
  • bはビットを示す。一番右のビットを0で数える。図というかテキストで示すとこんな感じ。
    bit | 0 1 0 1 0 1 0 1 |
    b   | 7 6 5 4 3 2 1 0 |
    
  • Rxyzは16ビットレジスタ(X,Y,Z)を指す。(これはこの記事のみで使用)
  • Ryzは16ビットレジスタ(Y,Z)を指す。(これはこの記事のみで使用)

データ転送

構文 動作
mov Rd, Rr Rrの値をRdにコピー
movw Rd, Rr R(r+1):Rrの値をR(d+1):Rdにコピー
ld Rd, Rxyz X,Y,Zレジスタの指すメモリの値をRdに代入
ldd Rd, Ryz+q Y,Zレジスタの値 + qの指すメモリの値をRdに代入(q=0~63)
ldi Rd, K 即値KRdに代入
out A , Rr Rrの値をI/OレジスタAに出力
in Rd, A I/OレジスタAの値をRrに入力

演算

構文 動作
inc Rd Rdに1加算
dec Rd Rdから1減算
add Rd, Rr Rd + Rrの値をRdに代入
adc Rd, Rr Rd + Rr + キャリーフラグの値をRdに代入
sub Rd, Rr Rd - Rrの値をRdに代入
subi Rd, K Rd - 即値Kの値をRdに代入
mul Rd, Rr RdRrの符号なし乗算値の上位8ビットをr1、下位8ビットをr0に代入
muls Rd, Rr RdRrの符号あり乗算値の上位8ビットをr1、下位8ビットをr0に代入
neg Rd Rdの2の補数をRdに代入 (0 - Rdを実行)

ビット演算

eor命令はレジスタの0クリアにも利用できる。

構文 動作
and Rd, Rr Rd AND Rrの値をRdに代入
or Rd, Rr Rd OR Rrの値をRdに代入
eor Rd, Rr Rd XOR Rrの値をRdに代入
andi Rd, K Rd AND 即値Kの値をRdに代入
ori Rd, K Rd OR 即値Kの値をRdに代入

条件分岐命令1

この条件分岐命令は比較命令を行った後か、演算後に利用する

構文 動作 備考
cp Rd, Rr Rd - Rrを演算しフラグを更新する(レジスタは更新されない) 分岐命令の前に使用する
cpi Rd, K Rd - 定数Kを演算しフラグを更新する(レジスタは更新されない) 分岐命令の前に使用する
breq k ゼロフラグ=1ならkに相対分岐(-64 ~ +63語以内) Rd == Rrの時分岐
brne k ゼロフラグ=0ならkに相対分岐(-64 ~ +63語以内) Rd != Rrの時分岐
brge k 符号フラグ=0ならkに相対分岐(-64 ~ +63語以内) Rd >= Rrの時分岐
brlt k 符号フラグ=1ならkに相対分岐(-64 ~ +63語以内) Rd < Rrの時分岐

条件分岐命令2

構文 動作
cpse Rd, Rr Rd == Rrの時、その次の命令をスキップ
sbic A, b IOレジスタAbビットが0の時、その次の命令をスキップ
sbis A, b IOレジスタAbビットが1の時、その次の命令をスキップ
sbrc Rr, b Rrbビットが0の時、その次の命令をスキップ
sbrs Rr, b Rrbビットが1の時、その次の命令をスキップ

スキップがある条件分岐命令は以下のような形で使う。

    cpse r0, r1     ; r0 == r1の時次の命令がスキップされる
    jmp  else
    nop             ; r0 == r1の時
    jmp  exit
else:
    nop             ; r0 != r1の時
exit:
    ; 続き

いろいろ

SPはスタックポインタを表す。

構文 動作
call k スタックに復帰アドレスを格納してkにジャンプ(サブルーチン呼び出し)
ret スタックから取得した復帰アドレスにジャンプ(サブルーチンから戻る)
push Rr Rrの値をスタックに代入してSPを1減算
pop Rr 1加算したSPの示すスタックからRrに代入
nop 何もしない

マクロ疑似命令

関数にするほどでもないけどプログラムをまとめたい時にマクロを利用する。
マクロは以下のような形で書く

; ex: detect PORT, BIT 
.macro detect
detect:
    sbis @0, $1
    rjmp detect ; if PORT is 0
    ; PORT is 1
.endmacro

@0, @1のように書くと呼び出し時のパラメータを作ることができる(最大10個)。
マクロを使用すると以下のようになる。

detect PINB, 2
; 上のように書くとアセンブラが下に置き換える

detect:
    sbis PINB, 2
    rjmp detect 

書くときに便利なやつ

今まではPORTxなどのI/Oレジスタを自分で定義していたが、定義されたファイルがある。

https://github.com/DarkSector/AVR/blob/master/asm/include/m328Pdef.inc

これがArduino UNO R3に使われているATmega328Pの定義ファイルになる。ここにはI/Oレジスタのアドレスなどが定義されているので、使うと便利。

このファイルをプログラムで使うには、プログラムと同じフォルダに定義ファイルを置いて以下の疑似命令を書くと利用できる。

.nolist ; ここからアセンブルリストに含めない
.include "m328Pdef.inc"
.list   ; ここからアセンブルリストに含める

最後に

シリアル通信などはよくわからなかったので記事では除いている。
また、Arduino UNO R4ではチップがルネサス製に変わっているが、デジタルI/O出力だけでもR3よりも複雑そうなので今後の学習課題にしたい。日本語のマニュアル(1600ページ以上)があるだけましだとは思う。

皆さんも楽しいアセンブラライフを!

脚注
  1. 一番右のビットを1ビット目として数える ↩︎

  2. https://avr.jp/ にある「AVRアセンブラ使用者の手引き」を参照 ↩︎

  3. 1語は多分2バイト。 ↩︎

  4. アセンブラがアセンブリを機械語に変換することをアセンブルと呼ぶ。 ↩︎

Discussion