1. はじめに
アドベントカレンダーの目標
Qiitaアドベントカレンダー2025の完走賞を目指し、記事を投稿します。
今回は「Raspberry Pi Pico(RP2040)でのベアメタル開発」という一貫したテーマを設定しました。
最初は定番のLチカから始め、UARTログ出力やメモリマップ設定、Flash操作などをOSなし環境で開発・実装していきます。最終目標は簡易的なセキュアブートローダー実装です。
この記事執筆時点ではまだ実装完了していないため、25日間を完走できるか怪しいです。しかし、ひとまず挑戦してみます。
このような内容に興味のある方は是非読んでいただけるとうれしいです。
この記事で実現すること
今回は、組み込み開発入門では定番のLチカ、つまりLED点灯・消灯制御を行いたいと思います。また、ベアメタル環境でコードを実行するため、スタートアップコードの実装やリンカスクリプト設定も行います。そして、これらの実装をバイナリファイルに簡単にビルドするため、makeコマンドのためのMakefileファイルも作成していきます。
お断り
一連の記事は筆者の個人的な実践と理解に基づいたものです。第三者による検証等は行っていないため、間違いや誤解が含まれる可能性があります。あくまで「ご参考まで」でお読みください。
本記事を参考にする際は、参考にする内容の正確性を確かめ、ご自分の責任でご利用ください。
2. 開発環境の準備
ハードウェア
Raspberry Pi Pico
どこのご家庭にも必ず二、三台はあるRaspberry Pi Picoを使用します。
今後必要となるもの
今回の記事では不要ですが、今後UARTでのログ出力を行う際に使用するため、USB-シリアル変換モジュールを準備しておきます。
スイッチサイエンスや秋月電子、Amazonなどで購入できるので準備しておきます。
ソフトウェアと開発環境
この一連の記事では詳述しませんが、arm-none-eabi-gccなどのソフトウェアを導入し、クロスコンパイルができる環境を準備します。筆者はエディタとしてVisual Studio Codeを使用し、WSLで動作するUbuntu上で開発しています。
3. ベアメタル実装の最小構成
リンカスクリプト
リンカがコードをリンクする際の配置情報としてリンカスクリプトを作成します。リンカスクリプトに関しては筆者は知見が浅いですが、RAMやFlashメモリ上にコードやデータをどう配置するかを指示するファイルみたいなものと認識しています。
メモリレイアウトの定義
実際にリンカスクリプトを記述していきます。最初にメモリレイアウトを記載します。
MEMORY
{
FLASH (rx) : ORIGIN = 0x10000000, LENGTH = 2M
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 264K
}
データシートを参照してFlashとRAMの場所を定義します。
領域 開始アドレス ROM 0x00000000 XIP 0x10000000 SRAM 0x20000000 APB Peripherals 0x40000000 以下略 以下略
Table 15. Address Map Summary
RP2040 Datasheetより
RP2040はフラッシュが外付けで、上記表のXIP(execute-in-place)がフラッシュに相当します。
また、PicoのフラッシュサイズとSRAMサイズは以下から確認しました。
264KB of SRAM, and 2MB of on-board flash memory
Pico-series Microcontrollers - Raspberry Pi Documentation
エントリポイントの設定
さらに、エントリーポイントも必要となるため、スタートアップコードで記述するリセットハンドラを指定します。
ENTRY(Reset_Handler)
.boot2セクションの配置
SECTIONS
{
/* 第2ステージブートローダー: 先頭256バイト必須 */
.boot2 : {
KEEP(*(.boot2))
. = 0x100; /* 256バイト = 0x100 */
} > FLASH
(略)
}
RP2040はブート処理でいくつかの段階を踏みます。最初はROMに書かれたBootromがFlashの先頭256バイトをSRAMへ読み出し、それを実行します。
Copy 256 bytes from SPI to internal SRAM (SRAM5) and check for valid CRC32 checksum
2.8.1. Processor Controlled Boot Sequence
RP2040 Datasheetより
つまり、Bootromの次のブートローダーはFlashの先頭に配置する必要があります。(pico-sdkに習って、これを.boot2と呼ぶことにします)
.text、.data、.bssセクション
各種セクション(.text、.data、.bss)の記述は既存のものを参考にします。_sdata、_edata、_sbss、_ebss、_estackはスタートアップコードで使うため、各位置を記録しています。
SECTIONS
{
(略)
.text : {
KEEP(*(.vector_table))
*(.text*)
*(.rodata*)
. = ALIGN(4);
} > FLASH
.data : {
_sdata = .;
*(.data*)
. = ALIGN(4);
_edata = .;
} > RAM AT > FLASH
_sidata = LOADADDR(.data);
.bss : {
_sbss = .;
*(.bss*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} > RAM
_estack = ORIGIN(RAM) + LENGTH(RAM);
}
第2ステージブートローダー
RP2040における.boot2の必要性
既に書きましたが、RP2040ではBootromが.boot2コードをブートします。その際、BootromはFlashの先頭252バイトのチェックサムを求め、Flashの253~256バイトにある値(チェックサムであると仮定される部分)と照合します。一致しなければブートせず、ブート失敗となります。
Picoではブートに失敗すると、BOOTSELボタンを押して電源を入れた際と同様に、PCに接続するとUSBドライブとして認識されるモードになり、通常起動が行われません。
SDK既存バイナリからの抽出方法
.boot2コードをどこから入手するかが問題ですが、生成AIのClaudeくんに尋ねたところ、既存のサンプルプロジェクトでビルドしたバイナリから抽出することを提案されました。具体的には以下のステップです。
dd if=../pico-examples/blink/blink.bin of=boot2_binary.bin bs=1 count=256
cat > boot2.S << 'EOF'
.cpu cortex-m0plus
.thumb
.section .boot2, "ax"
.incbin "src/modules/boot2/boot2_binary.bin"
EOF
blink.binはPicoのSDKサンプルをビルドしたバイナリです。このバイナリには.boot2コードが含まれているため、これを利用します。
ddコマンドでバイナリの先頭256バイトをboot2_binary.binへ取り出します。そして、このバイナリファイルを読み込むためのアセンブラファイルを準備します。
バイナリから取り出すのであれば、そのバイナリのビルドに用いたソースを取り込んでビルドし直す方が正のように思えますが、Claudeくん曰く、SDKのファイル依存を取り込まないといけないため手間なので、この方法がいいらしいです。
以上で、boot2.Sファイルとboot2_binary.binを作成しました。
4.実装
スタートアップコード
ここまで来たらいよいよCコードを書いていきます。最初にスタートアップコードを準備します。これはmain関数が呼び出される前に実行されるコードです。リンカスクリプトでエントリポイントとしてリセットハンドラを設定しているため、ここで実装します。
リセットハンドラの実装
以下に示すとおり、リセットハンドラを実装します。
void Reset_Handler(void) {
uint32_t *src, *dst;
src = &_sidata;
dst = &_sdata;
while (dst < &_edata) {
*dst++ = *src++;
}
dst = &_sbss;
while (dst < &_ebss) {
*dst++ = 0;
}
main();
while (1);
}
.dataセクションは初期化済み変数が配置される場所、.bssセクションはゼロ初期化変数が配置される場所らしいです。
初期化済み変数の値(つまり初期値)はFlashに保管されていますが、プログラム実行中に変更される可能性があるため、書き込み不可(読み取り専用)のFLASHからRAMへ移動する必要があります。またゼロ初期化変数も実行中に変更される可能性があるため、RAMへ配置するのですが、これは初期値がゼロのため、変数の配置位置をゼロクリアするだけでよい――らしいです。
詳しくは筆者も理解できていませんが、そういうことみたいです。
ベクタテーブルの定義
続いてベクタテーブルを定義します。
void Default_Handler(void) {
while (1);
}
__attribute__((section(".vector_table")))
void (*const vector_table[])(void) = {
(void (*)(void))(&_estack), /* 初期スタックポインタ */
Reset_Handler, /* リセットハンドラ */
Default_Handler, /* NMI */
Default_Handler, /* HardFault */
};
ベクタテーブルは例外ハンドラや割り込みハンドラのアドレスを格納するテーブル、みたいな認識を筆者はしています。
ベクタテーブルの内容はプロセッサアーキテクチャ(RP2040はARM Cortex-M)で決められています。今回はメモリ上でのベクタテーブルの位置はVTOR(Vector Table Offset Register)で設定します。
VTORの初期値は0x0000_0000です。RP2040の場合、Flashの先頭は.boot2が使用するため、ベクタテーブルはその後に続きます。したがってVTORの設定が必要そうですが、Claudeくんに訊いたところ、.boot2が設定しているかもしれないということで、逆アセンブルして確かめることを提案されました。
arm-none-eabi-objdump -D -b binary -m arm -M force-thumb boot2_binary.bin
このコマンドで.boot2のコードを逆アセンブルしたところ、
(略)
9a: 4700 bx r0
9c: 4812 ldr r0, [pc, #72] @ (0xe8)
9e: 4913 ldr r1, [pc, #76] @ (0xec)
a0: 6008 str r0, [r1, #0]
(略)
e6: a000 add r0, pc, #0 @ (adr r0, 0xe8)
e8: 0100 lsls r0, r0, #4 // これはデータ領域
ea: 1000 asrs r0, r0, #32 // これはデータ領域
ec: ed08 e000 stc 0, cr14, [r8, #-0] // これはデータ領域
(略)
この9c,9e,a0の部分がVTORの設定になっているそうです。
つまり以下のようなことをしているらしい。
r0 = 0x1000_0100; // ベクタテーブルの位置
r1 = 0xE000_ED08; // VTORレジスタのアドレス
*r1 = r0; // VTORに0x10000100を設定
これでベクタテーブルの位置をCPUが適切に認識します。後は、アーキテクチャの仕様書に従い、ベクタテーブルの内容を定義するだけです。今回は初期スタックポインタと4つのハンドラを設定しました。
GPIOモジュール
お馴染みのGPIO操作処理を実装します。初期化処理、出力値設定処理で使用する列挙体、構造体は以下の様に定義してあります。
typedef enum {
GPIO_0, GPIO_1, GPIO_2, GPIO_3, GPIO_4, GPIO_5,
GPIO_6, GPIO_7, GPIO_8, GPIO_9, GPIO_10, GPIO_11,
GPIO_12, GPIO_13, GPIO_14, GPIO_15, GPIO_16, GPIO_17,
GPIO_18, GPIO_19, GPIO_20, GPIO_21, GPIO_22, GPIO_23,
GPIO_24, GPIO_25, GPIO_26, GPIO_27, GPIO_28, GPIO_29
} gpio_pin_t;
typedef enum {
GPIO_DIR_IN = 0,
GPIO_DIR_OUT = 1
} gpio_direction_t;
typedef enum {
GPIO_LEVEL_LOW = 0,
GPIO_LEVEL_HIGH = 1
} gpio_level_t;
GPIO初期化
一般的に各モジュールは電力消費を抑えるために、クロック供給が止まっています。このため、何かクロック供給やリセット解除をしなければならないと考えました。細かくデータシートを調べるのが面倒なため、これもClaudeくんに訊いたところ、IO_BANK0とPADSがGPIOに関わるペリフェラルらしいです。
この情報を参考に、指定したGPIOピンをSIO機能の出力方向に設定するための関数を実装します。
void GPIO_init_sio(gpio_pin_t pin, gpio_direction_t direction) {
(void)direction;
if(!gpio_initialized) {
RESETS_RESET &= ~((1 << 5) | (1 << 8)); // IO_BANK0, PADS_BANK0
while ((RESETS_RESET_DONE & ((1 << 5) | (1 << 8))) != ((1 << 5) | (1 << 8)));
gpio_initialized = 1;
}
/* GPIOをSIO機能に設定(Function: SIO = 5) */
volatile uint32_t *gpio_ctrl = (volatile uint32_t *)GPIO_CTRL_BASE(pin);
*gpio_ctrl = 5; // Function: SIO
/* 方向を設定 */
GPIO_OE_SET |= (1 << pin); // 出力有効化
}
GPIO出力値設定
GPIOの出力値を設定する関数を実装します。
void GPIO_set(gpio_pin_t pin, gpio_level_t level) {
if (level == GPIO_LEVEL_HIGH) {
GPIO_OUT |= (1 << pin);
} else {
GPIO_OUT &= ~(1 << pin);
}
}
ボード依存
ボード上のLEDはボード依存であるため、これらをカプセル化するヘッダファイルを作成し、ボード上のLED用の制御関数をインライン関数で実装します。
#define ONBOARD_LED_PIN GPIO_25
inline void ONBOARD_LED_init() {
GPIO_init_sio(ONBOARD_LED_PIN, GPIO_DIR_OUT);
}
inline void ONBOARD_LED_on(void) {
GPIO_set(ONBOARD_LED_PIN, GPIO_LEVEL_HIGH);
}
inline void ONBOARD_LED_off(void) {
GPIO_set(ONBOARD_LED_PIN, GPIO_LEVEL_LOW);
}
メイン処理
最後にmain関数を実装します。
int main(void) {
ONBOARD_LED_init();
while (1) {
ONBOARD_LED_on();
delay(1000*1000);
ONBOARD_LED_off();
delay(1000*1000);
}
return 0;
}
LED点灯・消灯を繰り返し、点滅させます。ディレイは今のところはfor文ループで代用することにして、適当にループでディレイさせます。
void delay(uint32_t count) {
for (volatile uint32_t i = 0; i < count; i++);
}
5.ビルドシステム
これから開発する毎に繰り返しビルドすることが前提のため、makeコマンド用にMakefileを作成します。以下は既存のものを参考に書いただけのため、筆者は解説できません。
# ツールチェーン設定
PREFIX = arm-none-eabi-
CC = $(PREFIX)gcc
OBJCOPY = $(PREFIX)objcopy
SIZE = $(PREFIX)size
# ディレクトリ
SCRIPT_DIR = script
BUILD_DIR = build
SRC_DIR = src
SRC_MODULE_DIR = src/modules
# コンパイルオプション
CFLAGS = -mcpu=cortex-m0plus -mthumb -O2 -Wall -I$(SRC_DIR)/include
CFLAGS += -ffreestanding
# リンカオプション
LDFLAGS = -T $(SCRIPT_DIR)/linker.ld -Wl,--gc-sections
LDFLAGS += -Wl,-Map=$(BUILD_DIR)/firmware.map
# ソースファイル
SRCS = \
$(SRC_DIR)/startup.c \
$(SRC_DIR)/main.c \
$(SRC_MODULE_DIR)/gpio.c
ASRCS = $(SRC_MODULE_DIR)/boot2/boot2.S
# オブジェクトファイル
OBJS = $(patsubst $(SRC_DIR)/%.c,$(BUILD_DIR)/%.o,$(SRCS))
OBJS += $(patsubst $(SRC_DIR)/%.S,$(BUILD_DIR)/%.o,$(ASRCS))
# 依存関係ファイル
DEPS = $(OBJS:.o=.d)
# ターゲット
TARGET = firmware
# デフォルトターゲット
all: $(TARGET).uf2
# ELFファイル生成
$(TARGET).elf: $(OBJS)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^
$(SIZE) $@
# BINファイル生成
$(TARGET).bin: $(TARGET).elf
$(OBJCOPY) -O binary $< $@
# UF2ファイル生成
$(TARGET).uf2: $(TARGET).bin
python3 $(SCRIPT_DIR)/uf2conv.py -b 0x10000000 -f 0xe48bff56 -o $@ $<
# オブジェクトファイル生成
$(BUILD_DIR)/%.o: $(SRC_DIR)/%.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -MMD -MP -c $< -o $@
# アセンブリファイルのコンパイル
$(BUILD_DIR)/%.o: $(SRC_DIR)/%.S
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -MMD -MP -c $< -o $@
# 依存関係ファイルの読み込み
-include $(DEPS)
# クリーンアップ
clean:
rm -rf $(BUILD_DIR)
rm -f $(TARGET).elf $(TARGET).bin $(TARGET).uf2
# .PHONYターゲット
.PHONY: all clean
Picoにドラッグアンドドロップで書きこむにはバイナリファイルをUF2ファイルに変換する必要があります。そのため、バイナリファイルをUF2ファイルに変換する仕組みも一連のビルドに導入してあります。
変換用のPythonプログラムは以下で入手しました。
https://github.com/microsoft/uf2/tree/2c8dbaf81bfd5455154ba3b019751766effbd6e7
6.ビルドと動作確認
ビルド手順
Makefileを準備したため、ビルド時はmakeコマンドを実行するだけです。
make clean && make
書き込み方法
PicoのBOOTSELボタンを押した状態でPCへUSB接続をすることでPicoがUSBドライブとしてマウントされます。ここへUF2ファイルをドラッグアンドドロップします。すると自動的にアンマウントされます。再度マウントされなければ書き込みと基礎的なブートは成功しています。
動作確認
LEDが点滅すれば動作OKです。内部的にはBootrom -> .boot2 -> スタートアップコード -> main関数が実行されています。
7.トラブルシューティング
再マウントされる問題の解決
UF2ファイルを書きこんだ後にドライブが再マウントされる場合は、正常に起動しないソフトが書きこまれたことを意味します。筆者も上述の.boot2コードがない状況でビルドしたUF2ファイルを使用したところ、この現象になりました。
8.まとめ
ということで、今回はベアメタル環境でLチカを実現しました。
知らないことが多かったため、多くを生成AIのClaudeくんに頼ってしまいましたが、「これは何か?」と質問して説明してもらうことで、ただ手を動かすだけにならないようにしました。
次回はデバッグを行いやすくなるよう、UARTでのログ出力を実装しようと思います。
興味のある方は引き続きよろしくおねがいいたします。ここまで読んでいただきありがとうございました。