🐈

特別なライブラリを使わずにマイコンプログラミング

2022/12/23に公開

はじめに

こんにちは。gettsuです。
C言語やTinyGo、組み込みRustや組み込みZigに興味がある方、低レイヤプログラミングに興味がある方向けに、あとは自分の知識の確認のために記事を書きます。また薄い内容になってしまいました。
「組み込みRustやTinyGoに入門したはいいもののライブラリが抽象化されすぎていてよくわからない。」、「新しくライブラリを作りたいがどうすればいいかわからない。」、「ArduinoIDEとか会社ごとに独自のツールを使わせられるのがなんとなく気持ち悪い。前提となる知識が欲しい。」と思う人が少なくないと思います。ほとんど僕です。今回は Arduino Mega 2560互換機を利用してベアメタル(OSのない環境)でのプログラミングを学ぼうという内容です。言語はC言語を使います。

環境

Ubuntu20.04 と Mac Ventura13.04での実行を確認しています。
avr系のツールを利用します。
MacでHomebrewを使っているなら

> brew tap osx-cross/avr
> brew install avr-gcc
> brew install avrdude

でavr系のツールをダウンロードしてください。
マイコンとしてはArduino Mega2560互換機を利用します。Arduino Unoの互換機の場合は適宜読み替えてください。また、他のマイコンでも開発の流れは同じだと思います。
Arduino IDEは使いません!

Lチカ

まずはマイコンのハローワールドことLチカです。
Arduino Mega 2560ではdigital pin 13がLEDに対応しています。
Arduino IDEでは

led.ino
void setup() {
  pinMode(13, OUTPUT);
}

void loop() {
  digitalWrite(13, HIGH);
  delay(1000);

  digitalWrite(13, LOW);
  delay(1000);
}

のように書くと思います。
まず、13番ピンを出力に設定した後、loop()内で1秒ごとにHIGH、LOWと変化させてチカチカさせます。

それでは早速、LチカをArduino IDEなしで書きましょう!

led.c
#include <stdint.h>

#define WAIT_NUM 100000

volatile uint8_t *portb = (uint8_t*)(0x25);
volatile uint8_t *ddrb = (uint8_t*)(0x24);

// 適当なディレイ関数。
void delay(uint32_t num) {
    uint32_t i;
    for (i = 0; i < num; i++) {
	// インラインアセンブリで何もしない命令
        asm volatile("nop");
    }
}

int main() {
    // LEDのピンを出力に設定
    *ddrb = (1<<7);
    while(1) {
        // ピンをHIGHに
        *portb = (1<<7);
        delay(WAIT_NUM);
	// ピンをLOWに
        *portb = 0;
        delay(WAIT_NUM);
    }
}
コンパイルと書き込み
# avr-gcc でコンパイル
# main関数を呼ぶ前の処理をリンクしてくれたり、リンカスクリプトを書く必要がなくなるため楽です。
# さらに気になる人はリンカスクリプトを書いたり、avr-objdumpでled.elfを逆アセンブルしてみてください。
> avr-gcc led.c -Os -mmcu=atmega2560 -o led.elf
# textセクション、dataセクションを取り出し、eepromセクションを削除してihex形式のファイルを作る
# arduino mega2560はこのhexファイルでブートできる。
> avr-objcopy -j .text -j .data -O ihex -R .eeprom led led.hex
# 書き込み
# avrdude.confの位置、usbポートは適宜読み替えてください
# atmega2560ならstk500v2で、arduino unoならarduinoとすればOKです。
> avrdude -q -V -p atmega2560 -C /usr/local/etc/avrdude.conf -D -c stk500v2 \
-b 115200 -P usbポート  -U flash:w:uart.hex:i

avr-gccでなくともclangでコンパイルできます。

clangの場合
> clang led.c -mmcu=atmega2560 -target=avr-freestanding-eabi -Os -o led.elf

まずコードの説明をします。
Arduino Mega 2560には周辺機器とともにCPUとしてatmega2560がのっています。これはavrベースのCPUで、X86-64やARMとは違った命令セットを持っています。
基本的にこのatmega2560のデータシートを見ながらプログラミングしていきます。
まず、arduinoが提供するデジタルピンとCPUのピンの対応を見る必要があります。
「arduino mega 2560 pin mapping」などでググるとArduinoの公式情報が得られます。
https://docs.arduino.cc/hacking/hardware/PinMapping2560
これを見ると13番ピンがCPUの26番ピンに対応していることがわかります。
つまり、13番ピンを出力設定にすることは26番ピンを出力設定にすることに他なりません。これらの設定はどうすればいいのでしょうか。

データシートを読め

ここからatmega2560のデータシートを読んでいくことになります。
https://akizukidenshi.com/download/ds/microchip/atmega2560.pdf
適当に「atmega2560 datasheet」とかでググれば出てきます。
まあまあ量があって面食らうかもしれませんが、全部読む必要はありません。
まずCPUの26番ピンがPB7という名前であることがわかると思います。PB7に関する記述を根気よく読んでいくと、
PortBレジスタとddrBレジスタという二つのレジスタが関係することに気づくと思います。
まず、レジスタの説明をします。
レジスタとはCPUの記憶装置です。メモリとは異なります。特に汎用レジスタと呼ばれるレジスタは実際のCPUの計算において利用されます。このPortBレジスタとddrBレジスタは汎用レジスタとは異なり、特定の機能を持つレジスタです。その値の設定は少し面倒です。
アセンブリの場合、汎用レジスタはmov命令のような命令で簡単に書き込めますが、これらのレジスタはin/out命令のような命令を書く必要があります。in/out命令はC言語では書くことができません。インラインアセンブリなどを利用する必要があります。

しかし、このPortB、ddrBレジスタはメモリマップドIO(MMIO)でアクセスできると書かれています。実際はレジスタにあるのにも関わらず、まるで特定のアドレスにあるかのようにアクセスできるということです。データシートにはPortBレジスタは0x25、ddrBレジスタは0x24のメモリアドレスにあるかのようにアクセスできると書いてあります。

led.cの一部
volatile uint8_t *portb = (uint8_t*)(0x25);
volatile uint8_t *ddrb = (uint8_t*)(0x24);

ここでレジスタをとってきています。
ddrbレジスタが出力の向きを設定していて、7ビット目が1ならPB7が出力で設定できることがわかります。

led.cの一部
int main(){
    // LEDのピンを出力に設定
    *ddrb = (1<<7);
    ...
}

また、portbの7ビット目がPB7に対応しているため

*portb = (1<<7);

とすればピンをHIGHにしたことになります。

こうして光らせることに成功しました。
delay関数はまだクロックの機能が使えないため、適当に時間のかかる処理をさせることにします。
ここではnop命令(何もしない命令)をたくさん回すことで行っています。nop命令はアセンブリでしか書けない命令なのでインラインアセンブリの形で呼び出しています。

コンパイル & 書き込みについて

arduinoは簡単に書き込みと実行が行えるようになっています。avrdudeでstk500v2という通信方式でhexファイルを送れば、ブートローダーが後は実行してくれます。

コンパイルとリンクはavr-gccで行っています。avr-gccでコンパイルしたのち、avr-ldによってcrt.oみたいな名前のファイルなどとリンクされます。crt.oみたいな名前のファイルはC runtime の略で、ランタイムライブラリというやつです。C言語ではmain関数が呼ばれる前に.bssセクションをからにする、スタック領域の設定など、色々な処理をしています。crt.oはリンクするとそこらへんの処理をmain関数の前に入れます。あと、main関数を抜けた後exitする処理を入れたりします。

USART(UART)

Lチカからわかるようにデータシートを読んでその通りにプログラミングすることが基本となります。
これは自作OSのときもかなり近く、X64ならIntel SDMと睨めっこしながらコードを書くことになります。
データシートでUSARTを探せばinitの方法とtransmit receiveの方法が載っています。
データシートはavrのライブラリを利用することを想定していそうなので、そこら辺を読み替えながらコードを書きます。
UARTは非同期通信の方法で、USARTはUARTを同期通信もできるように拡張したやつです。
今回はUARTを実装しました。
gnu screen などで値を確認できます。

uart.c
#include <stdint.h>

#define FOSC 16000000UL // クロック速度
#define BAUD 9600 // ボーレート
#define MYUBRR FOSC/16/BAUD-1 // Normal setting
#define TXEN 3
#define RXEN 4
#define UCSZ00 1
#define UCSZ01 2
#define UDRE0 5

volatile uint8_t *ubrr0h = (uint8_t*)(0xc5);
volatile uint8_t *ubrr0l = (uint8_t*)(0xc4);
volatile uint8_t *ucsr0a = (uint8_t*)(0xc0);
volatile uint8_t *ucsr0b = (uint8_t*)(0xc1);
volatile uint8_t *ucsr0c = (uint8_t*)(0xc2);
volatile uint8_t *udr0 = (uint8_t*)(0xc6);

void usart_init(uint16_t ubrr) {
    *ubrr0h = (uint8_t)(ubrr >> 8);
    *ubrr0l = (uint8_t)(ubrr);
    *ucsr0b = (1 << RXEN) | (1 << TXEN);
    *ucsr0c = (1 << UCSZ01) | (1 << UCSZ00);
}

void usart_transmit(uint8_t data) {
    while (!(*ucsr0a & (1 << UDRE0))) {}
    *udr0 = data;
}

int main() {
    usart_init(MYUBRR);
    // Zを送信
    usart_transmit('Z');
    while (1) {}
}

基本的に、CPUの一つのピンには複数の機能があり、その切り替えのための設定をいじることになります。今回はtxd0があるporte周りの設定はしていません。データシートを読む限り、設定の必要はないようです。CPUによってはこれが必要なため、データシートの指示に従ってください。

割り込み (12/23 追記)

INT0での割り込みハンドラは__vector_1()として定義すれば問題なく登録できました。avr/intterupt.hを見てマクロを展開していってわかりました。あまりスマートな方法ではないきがするのでアセンブリで書くべきかもしれません。

終わりに

大体データシートを読めばわかるということがわかったと思います。あの抽象的なライブラリたちはこの設定のわかりづらさをラップしてくれていたと考えるとありがたいですね。このあたりがプログラミング言語レベルでどうこうできる話です。なぜうまく動くのかが気になったら、データシートの回路図を見ると良いと思います。

心置きなく組み込みRustやTinyGoで遊べますね!!

Discussion