電子工作界隈ではおなじみのダイナミック点灯LEDですが、大抵は割り込みを使用した実装が使われます。今回は割り込みを使用せず、DMAを使用して、ハードウェアで完結した(高頻度にソフトウェアで触る必要のない)ダイナミック点灯の例を紹介します。
成果物
こんな感じの表示ができます。
下位1桁に0.1秒の単位を、上位3桁に秒数を16進で表示、秒に同期してパルス幅50%でピリオドを点滅、5秒周期で輝度を正弦波状に変更、という感じの表示パターンです。
なぜか1MB未満程度のAPNGしかアップロードできないので低fpsですが、実際はもっとスムーズに見えます。
4桁のLEDですが、このように、-12.34
というような表示もできます。
裏面はこんな感じです。今回使用したLEDは4桁が1パッケージでカソードコモンのタイプです。
見えづらいですが、アノード側にはチップ抵抗を入れてあります(今回は1kΩ)。LEDの定格VFは3.3Vですが、低電流であればもっと低い電圧となり、3.3Vのマイコンでも十分に駆動できます。また、電流が低ければ8セグ全点灯でもマイコンのシンク電流の定格を満足するため、今回はトランジスタ等は使用せず、直接マイコンでコモンを駆動しています。
配線はクロスせず、素直に引いてあります。もちろん、7セグLED自体のピンアサインは複雑なので、マイコンのピンとの組み合わせはかなりぐちゃぐちゃですが、ソフトウェア実装であればピンアサインは自由に設定できるので、このような配線で問題ありません。
マイコンとLEDの間に、ロジアナで見れるようにピンヘッダを置いてあります。
本題
ダイナミック点灯では、文字通りダイナミックに表示するLEDを(目で見えない速度で)逐次変更していきます。しかし、ただ切り替えていくだけでは隣のパターンが滲んだりするので、適当なブランキングも必要になります。このあたりを割り込みで処理しようとすると結構面倒です。十分なフレームレートがないとチカチカして見づらいですが、割り込みで処理しようとすると本当に行いたい処理との計算能力の奪い合いになるため、ある程度で妥協する必要もあります。
今回はSTM32のTIMとDMAを使用し、ハードウェアで表示を完結できるため、コアの計算は文字の更新程度で済みます。また、リフレッシュレートもかなり高く設定できます(例えば10kHzとか)。
ダイナミック点灯を行うためには、
- 表示する桁の選択
- セグメントの点灯
- セグメントの消灯
を桁ごとに高速に繰り返す必要があります。
桁の変更とセグメントの点灯、セグメントの消灯と桁の変更を同時に行うと、隣の文字がにじみ出てしまうことがあるため、適当な待ち時間も設定する必要があります。輝度を調整するためには、セグメントの点灯時間も高精度に管理する必要があります。
これを行うために、それぞれの制御をDMAを使って行います。今回の例ではDMAが3本必要になります。また、タイミングを作るためのタイマも必要です。
今回の例ではRAMに格納した点灯パターンをGPIO.BSRRにDMAで転送しますが、GPIOにはDMAとの接続がないためにメモリ間転送の扱いとなり、STM32F4ではDMA2で転送する必要があります(DMA1はメモリ間転送ができない)。DMA2の駆動は汎用TIMでは行えないため、TIM1かTIM8を使用する必要があります。
表示のサンプル
auto &dyn_LED = Dynamic_LED_driver::get_instance();
dyn_LED.initialize();
for (;;)
{
const uint32_t tick = HAL_GetTick();
const char *const bin_to_ch = "0123456789ABCDEF";
char text[7] = "000\b.0";
text[0] = bin_to_ch[tick / 1000 >> 8 & 0xF];
text[1] = bin_to_ch[tick / 1000 >> 4 & 0xF];
text[2] = bin_to_ch[tick / 1000 & 0xF];
text[4] = tick % 1000 < 500 ? '.' : ' ';
text[5] = bin_to_ch[tick / 100 % 10];
dyn_LED.write_text(0, text);
dyn_LED.luminance_set(sinf(tick % 5000 / 5000.0 * 3.14 * 2) / 2 + 0.5);
HAL_Delay(23);
}
以降のライブラリを使うと、以上のようなコードで、最初のアニメーションのような表示ができます。
write_text
関数に文字列を渡すと数字等の表示ができますが、普通に"-12.34"
のような文字列を渡すと6桁分の表示領域が必要になります。"-\b12\b.34"
のようにバックスペースを挟んで渡すと、バックスペース前後の文字を結合して1文字分として表示できます。
また、luminance_set
関数は0.0から1.0の範囲を与えると、それに応じた輝度を設定できます(ただし0を渡しても完全な非表示にはなりません)。
実際に駆動するプログラム
class Dynamic_LED_driver
{
public:
void initialize(void)
{
RCC_CLK_ENA();
{ // init GPIOs
port.BSRR = dig_mask | Seg::mask << 16;
// dig: 負論理なのでリセット時はHighが欲しい(BSRR LSB側Set)
// seg: 正論理なのでリセット時はLowが欲しい(BSRR MSB側Reset)
GPIO_InitTypeDef gpio_init = {
.Pin = Seg::mask | dig_mask,
.Mode = GPIO_MODE_OUTPUT_PP,
.Speed = GPIO_SPEED_FREQ_VERY_HIGH,
};
HAL_GPIO_Init(&port, &gpio_init);
// digはOpenDrainでも良いが、ロジアナ等で見るときに困るのでPushPullで初期化する(逆電圧に注意)
}
{ // init DMAs
constexpr uint32_t DMA_CR_general_config =
dma_channel |
DMA_PRIORITY_MEDIUM |
DMA_MINC_ENABLE |
DMA_CIRCULAR |
DMA_MEMORY_TO_PERIPH |
DMA_SxCR_EN;
dma1.PAR = reinterpret_cast<uint32_t>(&port.BSRR);
dma1.M0AR = reinterpret_cast<uint32_t>(digs);
dma1.NDTR = num_of_digits;
dma1.CR = DMA_CR_general_config | DMA_MDATAALIGN_WORD | DMA_PDATAALIGN_WORD;
dma2.PAR = reinterpret_cast<uint32_t>(&port.BSRR);
dma2.M0AR = reinterpret_cast<uint32_t>(segment_buff);
dma2.NDTR = num_of_digits;
dma2.CR = DMA_CR_general_config | DMA_MDATAALIGN_HALFWORD | DMA_PDATAALIGN_HALFWORD;
dma3.PAR = reinterpret_cast<uint32_t>(&port.BSRR) + 2;
dma3.M0AR = reinterpret_cast<uint32_t>(&Seg::mask);
dma3.NDTR = 1;
dma3.CR = DMA_CR_general_config | DMA_MDATAALIGN_HALFWORD | DMA_PDATAALIGN_HALFWORD;
}
{ // init TIM
tim.PSC = tim_prescaler;
tim.ARR = tim_period;
tim.CCMR1 = TIM_CCMR1_OC1PE | TIM_CCMR1_OC2PE;
tim.CCMR2 = TIM_CCMR2_OC3PE | TIM_CCMR2_OC4PE;
// どのchがdma2に使われているのか不明なので、全CCRのプリロードを有効にしておく
// プリロードが無効な場合、輝度を変更した際に桁がズレる
tim_ccr_dma1 = dig_change_delay;
luminance_set(0.5);
tim.DIER = tim_DMA_flag;
tim.CR1 = TIM_CR1_CEN;
}
}
// arg 0.0 to 1.0
void luminance_set(const float a)
{
constexpr uint32_t foo = dig_change_delay + seg_delay_after_dig;
constexpr uint32_t bar = tim_period - seg_pulse_width_min - foo;
tim_ccr_dma2 = foo + static_cast<int32_t>(bar - powf(a < 0 ? 0 : 1 < a ? 1 : a, 2) * bar);
}
void write_text(uint8_t x, const char *const text)
{
for (uint8_t i = 0; x < num_of_digits && text[i]; ++i, ++x)
{
if (text[i] == '\b')
{
if (!text[++i])
{
break;
}
if (0 < x)
{
--x;
}
segment_buff[x] |= Seg::char_to_segment(text[i]);
}
else
{
segment_buff[x] = Seg::char_to_segment(text[i]);
}
}
}
static Dynamic_LED_driver &get_instance(void) { return (instance); }
protected:
private:
// |=| |=| digit change delay (after segment negate)
// |=====| |=====| segment visible area (pulse width modulation, luminance control)
// |===| |===| |= segment blank area
// seg X|___|XXXXX|___|XXXXX|_
// dig XXX|XXXXXXXXX|XXXXXXXXX
// | | first edge (dma1) (e.g. ch1) change digit
// | | second edge (dma2) (e.g. ch2) assert segment
// | | update edge (dma3) negate segment
TIM_TypeDef &tim = *TIM1;
volatile uint32_t &tim_ccr_dma1 = tim.CCR1;
volatile uint32_t &tim_ccr_dma2 = tim.CCR2;
static constexpr uint32_t tim_prescaler = 1 - 1;
static constexpr uint32_t tim_period = 42000 - 1;
static constexpr uint32_t dig_change_delay = 168 * 2; // dig change after seg negate
static constexpr uint32_t seg_delay_after_dig = 168 * 2; // seg assert after dig change
static constexpr uint32_t seg_pulse_width_min = 168; // seg assert minimum width
DMA_Stream_TypeDef &dma1 = *DMA2_Stream1; // CH1
DMA_Stream_TypeDef &dma2 = *DMA2_Stream2; // CH2
DMA_Stream_TypeDef &dma3 = *DMA2_Stream5; // UP
static constexpr uint32_t dma_channel = DMA_CHANNEL_6;
static constexpr uint32_t tim_DMA_flag = TIM_DIER_UDE | TIM_DIER_CC1DE | TIM_DIER_CC2DE;
GPIO_TypeDef &port = *GPIOC;
static void RCC_CLK_ENA(void)
{
__HAL_RCC_GPIOC_CLK_ENABLE();
__HAL_RCC_TIM1_CLK_ENABLE();
__HAL_RCC_DMA2_CLK_ENABLE();
}
static constexpr uint32_t dig_mask = GPIO_PIN_1 | GPIO_PIN_7 | GPIO_PIN_9 | GPIO_PIN_10;
static constexpr uint32_t digs[] = {
dig_mask << 16 | (dig_mask & ~GPIO_PIN_1),
dig_mask << 16 | (dig_mask & ~GPIO_PIN_7),
dig_mask << 16 | (dig_mask & ~GPIO_PIN_9),
dig_mask << 16 | (dig_mask & ~GPIO_PIN_10),
};
static constexpr uint8_t num_of_digits = sizeof(digs) / sizeof(digs[0]);
struct Seg
{
static constexpr uint16_t A = GPIO_PIN_3;
static constexpr uint16_t B = GPIO_PIN_11;
static constexpr uint16_t C = GPIO_PIN_6;
static constexpr uint16_t D = GPIO_PIN_2;
static constexpr uint16_t E = GPIO_PIN_0;
static constexpr uint16_t F = GPIO_PIN_5;
static constexpr uint16_t G = GPIO_PIN_8;
static constexpr uint16_t P = GPIO_PIN_4;
static constexpr uint16_t mask = A | B | C | D | E | F | G | P;
static constexpr uint32_t char_to_segment(const char ch)
{
if ('0' <= ch && ch <= '9')
{
return (charset[ch - '0']);
}
if ('A' <= ch && ch <= 'F')
{
return (charset[ch - 'A' + 0xA]);
}
if ('a' <= ch && ch <= 'f')
{
return (charset[ch - 'a' + 0xA]);
}
switch (ch)
{
case '.':
return (charset[0x10]);
case '-':
return (charset[0x11]);
}
return (0);
}
private:
static constexpr uint32_t charset[18] = {
// +--A--+ *
// F B *
// +--G--+ *
// E C *
// +--D--+ P */
A | B | C | D | E | F | 0, // 0
B | 0 | C | 0 | 0 | 0 | 0, // 1
A | B | 0 | D | E | 0 | G, // 2
A | B | C | D | 0 | 0 | G, // 3
B | 0 | C | 0 | 0 | F | G, // 4
A | 0 | C | D | 0 | F | G, // 5
A | 0 | C | D | E | F | G, // 6
A | B | C | 0 | 0 | 0 | 0, // 7
A | B | C | D | E | F | G, // 8
A | B | C | D | 0 | F | G, // 9
//
A | B | C | 0 | E | F | G, // A
0 | 0 | C | D | E | F | G, // b
A | 0 | 0 | D | E | F | 0, // C
0 | B | C | D | E | 0 | G, // d
A | 0 | 0 | D | E | F | G, // E
A | 0 | 0 | 0 | E | F | G, // F
//
P, // 0x10 ピリオド
G, // 0x11 マイナス
};
};
uint16_t segment_buff[num_of_digits] = {};
static constexpr double refresh_rate = 168e6 / (tim_prescaler + 1) / (tim_period + 1) / num_of_digits;
Dynamic_LED_driver(void)
{
}
~Dynamic_LED_driver(void)
{
}
Dynamic_LED_driver(const Dynamic_LED_driver &);
Dynamic_LED_driver &operator=(const Dynamic_LED_driver &);
static Dynamic_LED_driver instance;
};
Dynamic_LED_driver Dynamic_LED_driver::instance;
constexpr uint32_t Dynamic_LED_driver::digs[];
constexpr uint16_t Dynamic_LED_driver::Seg::mask;
constexpr uint32_t Dynamic_LED_driver::Seg::charset[];
ダイナミックLEDの制御はタイマ+DMAで行われるため、最初に1回初期化を行ってしまえば、あとは文字や輝度の変更を行うだけで、LED自体の制御は必要ありません。
輝度の変更はCCRの変更で行いますが、初期設定のままで変更すると1フェーズ中に2回のDMA転送が行われて表示する桁がズレることがあるので、プリロードを有効に設定しておき、タイマ側でタイミングを合わせてもらいます。
今回は初期設定をしたら常に表示しっぱなしですが、消灯機能が必要であれば、例えばtim.CR1 |= TIM_CR1_OPM;
のようにしてしばらく待てば、セグメントが消灯した直後にタイマが停止します。tim.CR1 & TIM_CR1_CEN
がクリアされるのを待って、ソフトウェアでOPMをクリアしたり、必要に応じて桁の出力を変更します。再開する場合はTIM.CR1 = TIM_CR1_CEN;
で再開できます。
ピンの初期化の際に、コモンもPushPullで初期化しています。ARM系のマイコンは大抵の場合問題ないと思いますが、PICやArduinoのような5V系のマイコンの場合はLEDのVR(逆電圧)を満足しない場合があるので注意してください。
雑記
今回はseg/digを同じポートに出力していますが、別のポートに出力することも可能です(セグメントはポートAで、桁はポートBで、等)。また、今回は1パッケージに7セグLEDが4個入ったLEDを1個使っているだけですが、桁数を増やした際に配線が簡単に済むように、桁ごとにピンアサインが異なる、といった表示パターンでも、同じように制御できます(charsetの次元を増やす)。
マイコン単体では、十分なGPIOが使える場合では、16セグLEDを16桁まで、あるいは16x16のドットマトリクスLEDを直接制御できます。外部にデコーダを置けば16セグを65536桁まで制御できます。さすがにそこまで行くとフレームレートやメモリで問題が出てくると思いますが。
今回はダイナミックLEDの制御を行いましたが、TIM+DMA+GPIOの組み合わせは、STM32では結構有力なツールとなります。特にメモリコントローラがない小型のパッケージのマイコンでパラレル信号を出力したい場合は、かなり役に立ちます。
例えば、HUB75と呼ばれるパラレルバスの映像信号をマイコンから出力すれば、それなりの面積のLEDディスプレイ(128x128前後)も、STM32F405RGTワンチップで表示できるようになります。外部にラダーDACを用意すれば高速DACとして使うこともできます。