printf 使える?

とりあえず乱暴にもこんなコードを試してみました。

#include "Arduino.h"
#include <stdio.h>

void setup() {
  // put your setup code here, to run once:
  printf("Hello World\n");
}

void loop() {
  // put your main code here, to run repeatedly:
}

コンパイルは…OK、おぉ、ってことは printf() あるんだ! ダウンロードも OK だ。 シリアルモニタを確認っと…

あれっ…出ない!?…おかしいなぁ…。

念の為、オマジナイ Serial.begin(9600); while (!Serial); を入れてみましたが、変わりません。 一体全体 printf() の出力はどこに行っちゃったんでしょう?

デバッガで確認

printf() の行方を探すため、トラ技 ARM ライタ でステップインしてみると、スカッ!と次の行に移動しちゃいます。どうやら空の関数っぽい挙動です。う〜ん…

printf()をデバッグ

ライブラリを試す

気を取り直し、IDE のライブラリマネージャーで「printf」を検索。シンプルなものから重厚なものまで工夫を凝らしたものが色々出てきます。大抵の場合は良く出来ていて、Sirial 以外のデバイスにもバインドできる感じに作られています。

1番シッカリしてそうなのは、公式サイトにも上がっている LibPrintf でしょうか。snprintf()vsnprintf() も再現されています。

でも僕には帯にも襷にも長い感じ。

で、一番しっくりきたのが Qiita の記事 のコードです。ただ僕は、きっとどこかで printf()weak シンボルvirtual として定義されているに違いないと読み、stdio.h の I/F 仕様でコーディング&試してみました。案の定(定義が見つかっていないのでわかりませんが…)エラーも無く動作 OK です。

#define PRINTF_BUF_SIZE  256

int printf(const char* fmt, ...) {
  int len = 0;
  char buf[PRINTF_BUF_SIZE];

  va_list arg_ptr;
  va_start(arg_ptr, fmt);
  len = vsnprintf(buf, PRINTF_BUF_SIZE, fmt, arg_ptr);
  va_end(arg_ptr);

  // output to the serial console through the 'Serial'
  len = Serial.write((uint8_t*)buf, (size_t)len);

  return len;
}

このコードはかつて LPC-1343 ボードで実装した sci.c からの移植です。Serial 専用だけど、僕的にはこれで十分です。

当然ですが、printf() にステップイン出来ました。読みは正しかった様です :+1:

再びprintf()をデバッグ

Minima と WiFi の違い!

Minima では、出力関数により低レベルな関数 Serial.write() を使って OK でしたが、WiFi では、連続する printf() 間で前後の文字が欠落するという問題に遭遇したので、Serial.print() を使わざるを得ず、処理時間もかなり異なります。GitHub にテストスケッチを置いたので、気になる方は試してみてください。

また WiFi では、初期化待ちの while(!Serial); は機能せず、delay(1000); で待つ必要がありました。どちらも HardwareSerial という抽象クラスを継承していますが、Minima真偽を評価するオペレータがそれなりに実装されている のに対し、 WiFi では 単に true を返しているだけ だからです。

Minima と違い WiFi の USB は、ARM との間に ESP32-S3-MINI-1-N8 を挟んでいるので、 このような違いが出るのだと思います。回路上の違いを十分検証せず、安易にプログラムを公開すると危険ということを思い知らされました。

printfの書式と動作確認

printf()scanf() で押さえるべき書式文字列のフォーマットは次図の通り、% に続き、フラグ、最小フィールド幅、精度、長さ修飾子、変換指定子で構成されます。

`printf()` や `scanf()` の書式

それぞれの厳密な仕様はそれなりのボリュームになり、ここには書ききれませんが、Linux 系や Mac であれば、シェルからコマンド man 3 printf を打てば標準 C ライブラリの書式を調べられますし、簡潔にまとめてくれている次のサイトがオススメです。

今回は出力を検証するために、前者のサンプルコードを使わせてもらいました。どれも記事の記述通りになりましたし、浮動小数点もそれなりに(厳密に仕様通りか未検証という意味)フォーマットしてくれてます。

void setup() {
  // put your setup code here, to run once:
  Serial.begin(9600);
  while (!Serial);

#ifdef  ARDUINO_UNOR4_WIFI
  // UNO R4 WiFi needs to wait for a while to complete Serial initialization.
  delay(1000); // It requires at least 600 ms.
#endif

#define EPSILON 0.00001

  for (float f = 0.0; f <= 1.0 + EPSILON; f += 0.01) {
    printf("f = %5.3f\n", f);
    printf("e = %5.3e\n", f);
  }
}
...
20:43:36.438 -> f = 0.980
20:43:36.438 -> e = 9.800e-01
20:43:36.438 -> f = 0.990
20:43:36.438 -> e = 9.900e-01
20:43:36.438 -> f = 1.000
20:43:36.438 -> e = 1.000e+00

エスケープシーケンスのテスト

ついでに IDE のシリアルモニタ側テストとして、エスケープシーケンス も試してみました。

printf("%s%s", "\33[2J", "\33[0;0H"); // 画面クリア+原点に移動

Tera Term 並の動作を期待したのですが、これはダメでした。不可視文字も含めてそのまま出てきちゃいます。

20:44:40.763 -> [2J [0;0H

VSCode のターミナルなら対応している気もしますが、僕の Mac が古いためか(Late 2013)、環境構築がうまくいかず未確認です。せめて画面クリアや文字色の変更か反転ぐらいできると助かるんですけどネ :expressionless:

ライブラリ化

デバッグのたびにコピペするのも面倒なので、ライブラリ化しちゃいました。UNO R4 以外の動作確認ができないので R4 用としています → Printf v0.3

よかったら使ってやってください。

  1. Release から最新版の zip ファイルSource code (zip) をダウンロードします。

  2. Arduino IDE メニューの「スケッチライブラリをインクルード.ZIP形式のライブラリをインストール…」からダウンロードした .zip ファイルを読み込みます。

  3. IDE メニューの「ファイルスケッチ例カスタムライブラリのスケッチ例」からサンプル printf を試せます。

Serial.println() の小技

String クラス数値を文字列に変換 したり 他の文字列との足し算 ができるので、println() との組み合わせがイイ感じです。僕はもっぱらコレですネ。

float pi = 3.1415926;
uint32_t ul = 1234;
Serial.println("pi = " + String(pi, 4) + ", " + String(ul) + " = 0x" + String(ul, HEX));

クラス内部 で引数(コンストラクタ)に応じて dtostrf()ultoa() を選り分けているので、オーバーヘッドやメモリへのフットプリントもそれなりと思いますが、小数点以下の桁数指定や基数変換ができたりする多才な奴です。

02:35:19.513 -> pi = 3.1416, 1234 = 0x4d2

Arduino の String クラスC++ の標準規格 に比べてメソッド数が少ないものの、ハードウェアに依存しない ArduinoCore-API レイヤに属しているので、どのアーキテクチャのボードでもそれなりに動くハズです。

printfの行方は?

さて冒頭の件に戻りますが、以下の2つの疑問が湧いてきます。

  • なぜ空の printf() が定義されているのか
  • どこで定義されているのか

Arduino 新参者で UNO R4 しか持ってない僕には歴史的な経緯はわかりませんが、Arduino Playground - Printf の冒頭には「stdio および(それを出力先とする)printf() は Arduino 環境に組み込まれていません…」とハッキリ書かれています。

メモリが少なく、また多くのアーキテクチャを抱える Arduino が printf() ではなく、より単純な print()println() をベースラインにしたことは理解できます。

では「stdio を組み込んでいない」のはなぜか? 以下は私の勝手な解釈です。

OS が載るシステムでは、システムをオペレートするための標準入出力 I/F として、stdinstdoutstderr という抽象化された仮想の標準端末を必要とします。

一方デフォルトで OS が載らない Arduino ですが、SerialLiquidCrystalEthernetWiFi といったデバイスクラスは、Stream クラス から Print クラス を継承しているので、これらを元にすれば stdio を Serial に決めることも可能だったハズです。

それでも stdio を一意に決めていないのは、標準入出力 I/F として物理層に何を載せるか、「決めるのはお前だゾ」と暗に言われているように僕には思えます。生粋の組込系的発想ですかね。

とはいえ、単純に未定義にしないのは親切心か、はたまた余計なお世話か?… 未だに見つからない定義箇所と共に謎は残ります。まぁ、どうでもイイことですが…

以上、中途半ばな謎解きと小技でした :grin: