STM32でBNO055をI2C接続——ポインタとアドレスのシフトでつまずいた初心者が、姿勢角を読むまで
I2Cでセンサーを読むのは、配線2本とコード数行で済む——はずなのに、最初の1個目はそう簡単にはいきませんでした。
自分がつまずいたのは、I2Cそのものよりも、その手前にある C言語のポインタ と データシートの読み方 でした。最初はチップIDのレジスタ(0x00)を読もうとして値が返らず、「HAL_..._Transmit で書き込みできてないから失敗してるのか?」と何時間も別の所を疑っていました。本当の原因は、「レジスタ番号を書き込む → そこから値を読む」という2段階の流れ と、関数に渡す「ポインタ」 を理解していなかったこと。結局、きちんと仕様書(データシート)を読んで、ようやく腑に落ちました。
この記事では、自分がハマったその「2段階の読み取り」「ポインタ」「データシートの読み方」も含めて、STM32L432KC と Bosch の9軸センサー BNO055 を I2C でつなぎ、姿勢角(向き)を読むところまでをやります。
環境:STM32CubeIDE 1.19.0 / HALドライバ / Nucleo-L432KC(STM32L432KCUx)。I2C1(SCL=PA9 / SDA=PA10)、センサーは BNO055。
1. I2C通信は、ざっくりこういうもの
まず仕組みだけ押さえておくと、つまずいたときに切り分けやすくなります。
I2Cは2本の線で複数のデバイスとやり取りする、クロック同期式のシリアル通信です。
- SDA(Serial Data)… データを送受信する線。マスターとスレーブの双方向
- SCL(Serial Clock)… タイミングを合わせるクロックの線。マスターからスレーブへの一方向
ここでの「マスター」はマイコン(STM32)、「スレーブ」はセンサー(BNO055)です。同じ2本の線に複数のセンサーをぶら下げられるので、各スレーブにはアドレス(住所)があり、マスターは「このアドレスの子としゃべりたい」と宣言してから通信します。
勉強しながら、自分はこんなふうに手で整理しました(接続のしかたと、SDA/SCLの波形)。
I2Cの1回の通信は、おおまかにこの順で進みます。
- スタートコンディション:通信開始の合図(SCLがHighのときにSDAをLowにする)
- スレーブアドレス送信:相手を指定する
- 書き込み / 読み出しビット:これからどっちをするか(書き込み=0 / 読み出し=1)
- ACK / NACK:スレーブからの返事(通信OK=0 / 不可=1)
- データ:8ビット単位でやり取り
- ACK / NACK:1バイトごとに返事
- ストップコンディション:通信終了の合図
他のシリアル通信と並べると、立ち位置が見えてきます。
| 方式 | 通信速度の目安 | クロック線 | 信号線 | 構成 |
|---|---|---|---|---|
| SPI | 〜50Mbps | 有 | データ・クロック・チップセレクト | マスター1 ↔ 複数スレーブ |
| I2C | 〜1Mbps | 有 | データ・クロック | 複数マスター ↔ 複数スレーブ |
| UART | 〜500kbps | 無 | データ | デバイス1 ↔ デバイス1 |
この「アドレス」と「2段階のやり取り」が、後で出てくる関門になります。
2. センサーを読むのは「書いてから、読む」の2段階
I2Cでセンサーの値を読むときに、自分が最初につまずいた最大のポイントがここです。
「読む」のに、いきなり受信するのではありません。まず「どのレジスタを読みたいか」をスレーブに書き込んで(送信して)から、改めてそのデータを読み出す、という2段階になります。
HALだと、この2段階はそのまま2つの関数に対応します。
// ① 読みたいレジスタの番号を「書き込む」
tx[0] = 0x1A; // 例:オイラー角データの先頭レジスタ
HAL_I2C_Master_Transmit(&hi2c1, DevAddress_BNO055, tx, 1, 100);
// ② そこからデータを「読む」
HAL_I2C_Master_Receive(&hi2c1, DevAddress_BNO055, rx, 6, 100);
自分は最初、Master_Transmit を「書き込み(=値をセンサーに書く)」だと思い込んでいて、チップIDが読めないのを「書き込みが失敗してる?」と延々疑っていました。実際は、Master_Transmit で 読みたいレジスタの番号を伝え、Master_Receive で その中身を受け取る、という役割分担でした。ここが分かると一気に進みます。
3. BNO055とは(なぜ初心者にやさしいか)
BNO055は、加速度センサー・ジャイロ・地磁気センサーを1つに統合した9軸センサーです。最大の特徴は、チップの中で計算(センサーフュージョン)まで済ませてくれること。生データを自分で積分・フィルタする必要がなく、「向き(オイラー角)」を読むだけで済みます。
今回使う値を、データシートから拾って先に並べておきます。これらはコードを眺めても出てこない、仕様書を読んで初めて分かる情報です。
-
I2Cアドレス:
0x28(ADDRピンをL/GNDにしたとき。Hにすると0x29)。2進数だと0b0101000 -
チップID:レジスタ
0x00を読むと0xA0が返る(生きているBNO055の確認に使う) -
動作モード:起動直後は設定モード。
OPR_MODE(0x3D)に値を書くと動き出す。今回は姿勢制御向けの IMUモード(0x08) を使用(地磁気を使わない相対姿勢) -
オイラー角データ:レジスタ
0x1Aから。1度 = 16 LSB / 1ラジアン = 900 LSB - 起動待ち:電源投入からセンサーが立ち上がるまで時間がかかるので、最初に十分なウェイト(数百ms〜)を入れる
4. CubeMXの設定
- Connectivity から I2C1 を選び、Mode を「I2C」にする
- ピンは SCL=PA9 / SDA=PA10 に割り当てた(CubeMXが自動で選ぶピンと違う場合は手動で変更)
- I2C Speed は 100kHz か 400kHz。BNO055は400kHzまで対応
設定すると、生成コードに hi2c1 というハンドルと MX_I2C1_Init() が作られます。
配線とプルアップ:GPIOの設定で有効にする
I2CでつまずきやすいのがSDA/SCLのプルアップです。I2Cはオープンドレインという仕組みで、線を「H」に引き上げる役目がないと信号がうまく出ません。
外付けの抵抗(4.7kΩ程度)で引き上げる方法もありますが、今回は CubeMXのGPIO設定で内蔵プルアップを有効化しました。I2C1を設定したあと、GPIO Settings で SCL / SDA の GPIO Pull-up/Pull-down を「Pull-up」にします。
これを設定するまで IsDeviceReady も通らず、ここでもしばらく悩みました。「配線は合ってるのに反応しない」ときは、プルアップを真っ先に疑うのがよさそうです。
補足:内蔵プルアップは抵抗値がやや弱めなので、通信速度が速いときや配線が長いときは、外付けの抵抗のほうが安定することもあります。まず内蔵で試して、不安定なら外付けを検討、という順でよいです。
5. コード:アドレスを作る → 確認する → 起こす → 読む
コードの前に:uint8_t / uint16_t って何?
コードに出てくる uint8_t や uint16_t は、サイズが決まった整数の型です。組み込みではレジスタやバイト単位を直接扱うので、「何ビットの入れ物か」をはっきりさせるためによく使います。
-
uint8_t… 符号なし 8ビット(1バイト、0〜255)。I2Cでやり取りする1バイトのデータはこれ -
uint16_t… 符号なし 16ビット(2バイト、0〜65535)。I2Cアドレスなどに使う -
int16_t… 符号あり 16ビット(−32768〜32767)。角度のようにマイナスもとる値はこれ
u が付くと符号なし(プラスだけ)、付かないと符号あり(マイナスもOK)、数字がビット数、と読むと分かりやすいです。センサーの角度は負の値もとるので、後で int16_t で受けています。
アドレスは「7bitを1bit左シフト」する
I2C + HAL でいちばん多いつまずきです。データシートのアドレスは 7bitの 0x28 ですが、HALの関数は8bitのアドレスを要求します。なので1bit左にシフトします。
uint16_t DevAddress_BNO055 = 0b0101000 << 1; // 0x28 << 1 = 0x50
0x28 のまま渡すと、いつまでも応答が返らず「配線は合ってるのに動かない」と悩むことになります。
① チップIDを読んで「本物のBNO055」か確認する
いきなり姿勢角を読む前に、答えが決まっているレジスタで試すのがコツです。チップID(0x00)は仕様書に「必ず 0xA0 が返る」と書いてあるので、これが読めれば「アドレスもI2Cの読み取りも正しい」と確定できます。値が毎回変わるセンサーでいきなり試すと、配線が悪いのかコードが悪いのか切り分けられません。
uint8_t reg[1];
uint8_t value[1];
reg[0] = 0x00; // CHIP_ID レジスタ
HAL_I2C_Master_Transmit(&hi2c1, DevAddress_BNO055, reg, 1, 100);
HAL_I2C_Master_Receive(&hi2c1, DevAddress_BNO055, value, 1, 100);
printf("BNO055 Chip ID : 0x%X\r\n", value[0]); // 0xA0 が出れば成功
② IMUモードにする(書き込み = 2バイト送信)
起動直後は設定モードで、姿勢角はまだ出てきません。レジスタに値を書き込んで動作させます。書き込みは「[レジスタ番号, 書き込む値] の2バイトを送信する」だけです。
uint8_t opr_mode[2];
opr_mode[0] = 0x3D; // OPR_MODE レジスタ
opr_mode[1] = 0x08; // IMU モード
HAL_I2C_Master_Transmit(&hi2c1, DevAddress_BNO055, opr_mode, 2, 100);
HAL_Delay(100); // モード切替の待ち
③ オイラー角(向き)を読む
データは 0x1A から、下位バイト→上位バイトの順で並んでいます。6バイトまとめて読み、2バイトずつ組み立てます。
uint8_t tx[1];
uint8_t rx[6];
tx[0] = 0x1A;
HAL_I2C_Master_Transmit(&hi2c1, DevAddress_BNO055, tx, 1, 100);
HAL_I2C_Master_Receive(&hi2c1, DevAddress_BNO055, rx, 6, 100);
int16_t heading = ((int16_t)rx[1] << 8) | (int16_t)rx[0];
int16_t roll = ((int16_t)rx[3] << 8) | (int16_t)rx[2];
int16_t pitch = ((int16_t)rx[5] << 8) | (int16_t)rx[4];
// 1度 = 16 LSB なので16で割ると「度」になる
float heading_deg = heading / 16.0f;
float roll_deg = roll / 16.0f;
float pitch_deg = pitch / 16.0f;
これで姿勢角が度で取れます。
バイナリデータの変換:LSBとMSBを1つにまとめる
ここで初心者がつまずくのが、センサーの値が2つのバイトに分かれて返ってくることです。16ビットの値は1バイト(8ビット)には収まらないので、BNO055は 下位8ビット(LSB)と上位8ビット(MSB)の2バイトに分けて、しかも LSBを先に送ってきます。
-
rx[0]… LSB(下位8ビット) -
rx[1]… MSB(上位8ビット)
これを1つの数値に戻すのが、コード中の ((int16_t)rx[1] << 8) | (int16_t)rx[0] です。MSBを8ビット左にずらして「上の桁」に持ち上げ(<< 8)、LSBと |(OR)で合体させています。この変換をやらないと、値がおかしくなります。なぜLSBが先なのか、何ビットずらすのかは、データシートのレジスタ表を読んで初めて分かるところでした。
ちなみに、オイラー角・クォータニオン・線形加速度・重力ベクトルはレジスタが連続して並んでいるので、rx を大きめ(26バイトなど)にして一度の Receive でまとめて読むと効率的です。
④ ループで読み続ける
姿勢角は刻々と変わるので、③の「読む部分(Transmit → Receive)」を while(1) のループの中に置いて、繰り返し読み続けます。チップID確認とモード設定は一度きりでいい初期化なのでループの前に、データの読み取りはループの中に、と分けるのがポイントです。
// --- ループの前(初期化)---
// チップID確認、IMUモード設定 …(前述)
while (1) {
// 「レジスタを書く → データを読む」を毎回くり返す
tx[0] = 0x1A;
HAL_I2C_Master_Transmit(&hi2c1, DevAddress_BNO055, tx, 1, 100);
HAL_I2C_Master_Receive(&hi2c1, DevAddress_BNO055, rx, 6, 100);
// …ここで rx を角度に変換して使う…
HAL_Delay(20); // 読みすぎないよう少し待つ
}
Transmit(レジスタ指定)と Receive(読み取り)はセットです。毎周このペアを回すことで、最新の姿勢角が取れ続けます。
6. つまずきポイント(自分がハマった所)
★ ポインタ:rx は &rx[0] のこと
最大の壁でした。HAL_I2C_Master_Receive の引数には rx とだけ書きますが、これは「配列名がそのまま先頭要素のアドレスを表す」からです(rx と &rx[0] は同じ意味)。関数側は「ここから何バイト書き込んでいいよ」という置き場所の先頭住所を受け取って、そこに順番にデータを入れていきます。
整理すると、
-
配列(
rx、tx)→ そのまま渡せる(配列名 = 先頭アドレス) -
1個の変数 →
&を付けてアドレスにして渡す(例:&value) - ポインタ型は宣言時に
*を付ける(例:uint8_t *p;)
「ポインタは変数のアドレスを格納する変数」「配列名は先頭要素を指すポインタ」——この2つが腑に落ちると、HALの関数の引数が急に読めるようになりました。
★ 自分のコードは USER CODE BEGIN の中に書く
地味だけど重要。CubeMXでピンや設定を変えてコードを再生成すると、/* USER CODE BEGIN */ 〜 /* USER CODE END */ の外に書いたコードは消えます。自分で足したコードは、必ずこのコメントブロックの中に書きます。これを知らずに、再生成で自分のコードが消えて「あれ、動かなくなった」となるのは初心者あるある(自分もやりました)。
printfが出ない / 表示が崩れる
printf でマイコンのデータをPCに出すとき、出力がすぐ反映されないことがあります。setbuf(stdout, NULL); でバッファリングを切ると、その場で出るようになります。ターミナル(Teratermなど)側でバッファをクリアすると表示もきれいになります。
いちばんの教訓:データシートから逃げない
アドレスが7bitか8bitか、データはLSB/MSBどちらが先か、1度が何LSBか、モードはどう設定するか——全部データシートに書いてあり、コードを眺めても出てきません。最初は英語の仕様書が面倒ですが、必要なのは「レジスタ表」と該当する数ページだけ。そこを読む習慣がついてから、つまずきの大半が消えました。
ソフトだけでなく、ハードも疑う
最後にもうひとつ、大事な実体験を。コードをいくら見直しても動かず、何時間も「自分のプログラムのどこが悪いんだ」と悩んでいたのに、実は原因がハードウェア側だったことがありました。自分の場合は、基板そのものの接続がうまくいっておらず、センサーを別の個体に交換したらあっさり成功しました。
ソフトを書く人ほど「バグはコードにある」と思い込みがちですが、組み込みでは配線・半田・接触・センサーの個体不良といったハード側の原因も同じくらいあります。チップID(0xA0)すら読めないときは、コードを疑う前に、ジャンパ線を挿し直す・別のセンサーや配線で試す、といったハードの切り分けも並行してやると、無駄に時間を溶かさずに済みます。
まとめ
- I2Cは SDA / SCL の2本。マスター(STM32)がアドレスで相手を指定する
- センサーの読み取りは 「レジスタ番号を書く(Transmit)→ データを読む(Receive)」の2段階。これを ループの中でくり返す
- HALのI2Cアドレスは 7bitを1bit左シフト(
0x28 << 1) - 値は LSB/MSBの2バイトに分かれて返るので、
(MSB << 8) | LSBで1つに戻す -
uint8_t(1バイト)/int16_t(符号あり16ビット)など、型のビット数を意識する - SDA/SCLには プルアップが要る(CubeMXのGPIO設定で内蔵Pull-upを有効化。弱ければ外付け)
- 流れは「チップID(0xA0)で確認 → OPR_MODEを設定 → データを読む」
- ポインタは「配列名 = 先頭アドレス」「1個の変数は
&を付ける」 - 自分のコードは USER CODE BEGIN の中に書く(再生成で消えるのを防ぐ)
- 倍率やレジスタは データシートにすべて書いてある
- 動かないときは ソフトだけでなくハード(配線・接触・センサーの個体)も疑う(自分は別のセンサーに替えたら動いた)
ポインタや2段階の読み取りでつまずいたのは、たぶん自分だけじゃないはずです。同じ所で止まっている人に、この記事が地図になればうれしいです。
次回は、ここで読んだ姿勢角を使って、前回作ったサーボを PD制御 で動かすところまでやります。
※コード・レジスタ値・倍率は BNO055 + 標準的なHALドライバを前提にしています。ボードやライブラリのバージョンによって細部が異なる場合があるので、ご自分の環境で動作を確認してから使ってください。


