Skip to content

Software

A New Board is Born

Where Have You Been?

The short answer is "around", but the real answer is more complicated. Due to :gestures at world:, I haven't really been mentally in a place where I can spend time working on things like this project. After a few months of just zoning out, I've realized I need the structure of a project to keep me focused, and so I'm trying to pick back up where I left off.

A long PCB is inserted into a
protoboard When last we talked, I was talking about a modular base board built around an STM32H5, but I realized that there was another alternative ... build it around an existing board. So, that's my plan now. This removes a lot of design work, yes, but at least for the initial design, it lets me focus on some other things. I've chosen to use a board from WeAct Studio. These are the people behind the famous Blue Pill and Black Pill STM32 boards. They also make one built around an STM32G474CEU6, which is a 170MHz ARM Cortex-M4 with 128KB of RAM, 512KB of Flash, and 2 CAN-FD buses available. It's in a somewhat long 48-pin package. If you want to buy one, you can find them on your neighborhood massive Chinese marketplace.

Since I'm planning to write all the software on top of Zephyr, and this board isn't in their already extensive list of boards, I needed to add it to that. So, into the breach we go.

First, I read the documentation. Since the SOC is supported already thanks to a ton of work from STMicroelectronics, it really just meant customizing for this specific instance on this board. To do that, I needed to name the board, and then create a bunch of files. The name I chose was stm32g474_long under the weact vendor directory. Those files are:

board.yml

a YAML file describing the high-level meta data of the boards such as the boards names and the SOC.

weact_stm32h474_long.dts

The hardware description in devicetree format. This declares your SOC, connectors, and any other hardware components such as LEDs, buttons, sensors, or communication peripherals (USB, etc).

Kconfig.weact_stm32h474_long

This is the base software configuration for selecting SOC and other board and SOC related settings. Kconfig settings outside of the board and SOC tree must not be selected. To select general Zephyr Kconfig settings the Kconfig file must be used.

board.cmake

Provides information on flashing and debugging the board

weact_stm32h474_long.yaml

A bunch of metadata that's mostly used by the test runner to help understand what tests to run. Note, I don't know why this one ends in yaml, but the other in yml, nor whether it matters, but I just copied the style in the documentation.

weact_stm32h474_long_defconfig

Some Kconfig-style presets for building any application. These can be overridden in the application.

Rather than start from a clean slate, I actually decided to "borrow" some of the already made board configs from an existing WeAct board, the STM32G431 model. This gave me a good head start on what I needed to do.

So let's go through the files. I hope my explanations are both reasonably accurate and passably consumable. I'm learning this as I go along, and so this is just my current knowledge.

board.yml

1
2
3
4
5
board:
  name: weact_stm32g474_long
  vendor: weact
  socs:
    - name: stm32g474xx

This is pretty obvious, but the name of the SOC (line 5) is something you have to find in the existing repository. Some of the naming isn't necessarily obvious to me, but it was pretty easy to figure out. Once you get to the right processor family it's not too bad.

weact_stm32h474_long.dts

This is the biggest of all of them, so let's take it from the top (post copyright). First, this is a devicetree format, which is also used Linux. I can't say as I consider myself to know it yet, but I've started to get a feel for it. I've elided empty lines along the way.

 7
 8
 9
10
 /dts-v1/;
 #include <st/g4/stm32g474Xe.dtsi>
 #include <st/g4/stm32g474r(b-c-e)tx-pinctrl.dtsi>
 #include <zephyr/dt-bindings/input/input-event-codes.h>

First, we set the version of the DTS (devicetree) schema that's in use, then we include some existing files to set up some presets.

12
13
14
15
16
17
18
19
20
21
22
23
 / {
     model = "WeAct STM32G474CEU6 Long board";
     compatible = "weact,stm32g474_long";

     chosen {
         zephyr,console = &lpuart1;
         zephyr,shell-uart = &lpuart1;
         zephyr,sram = &sram0;
         zephyr,flash = &flash0;
         zephyr,canbus = &fdcan2;
         zephyr,code-partition = &slot0_partition;
     };

The first thing to know is that the start of line 12 is the start of the definition of a node, in this case the root (/) node. Like most languages, everything inside the curly brackets is part of that definition.

The /chosen node sets up some defaults. For example, line 17 says that the node lpuart1 is the default Zephyr console. The & is used a lot like C to grab the "address of" something else. They're called phandles. In this case, lpuart1 is the low power UART. Low power means it can function in low power mode when the low-speed (32.768KHz) clock is in effect. It is, however, limited to 9600bps at that time.

25
26
27
28
29
30
31
     leds {
         compatible = "gpio-leds";
         blue_led: led_0 {
             gpios = <&gpioc 6 GPIO_ACTIVE_HIGH>;
             label = "User LED";
         };
     };

First we set up the on-board LED, in this case, an annoying blue one. Walking through this, line 25 creates the /leds node to contain all the LEDs on the board. It then says it's compatible with the gpio-leds hardware feature, and creates a specific instance /leds/led_0 that also has an label with a different name, /leds/blue_led (line 27). On line 28 it sets up the GPIO pins for that LED. Since the LED is attached to pin PC6, we know that it's part of the C cluster of GPIO pins (&gpioc), and then pin #6 in that cluster. Finally, we tell it that the pin is considered active, when it's high. Zephyr can tell the difference between active high and active low pins. The <> defines an array, here with 3 elements.

Note we are referencing phandles that are defined outside of our file and have been brought in via the earlier includes.

33
34
35
36
37
38
39
     pwmleds {
         compatible = "pwm-leds";

         blue_pwm_led {
             pwms = <&pwm3 1 PWM_MSEC(20) PWM_POLARITY_NORMAL>;
         };
     };

Next, what's an LED that you can't dim? Useless, that's what!

So, as above, we explain that it's compatible with the pwm-leds feature, and name the node /pwmleds/blue_pwm_led. We then have to set up the timer that specifically drives the pulse-width modulated (PWM) signal.

The hidden world of timers

This is hidden in things like Arduino, but in Zephyr and most other embedded systems, you have to know which timers are able to be connected to which GPIO pins. This is often considered an "alternate function" of the pin. On STM32 there are multiple times, and each timer can have multiple "channels". There's various restrictions on them, but that's a topic for another time.

Here, we say this PWM is on channel 1 of timer 3, with a nominal period of 20ms, and "normal" polarity, meaning it's active high.

41
42
43
44
45
46
47
48
     gpio_keys {
         compatible = "gpio-keys";
         user_button: button {
             label = "User";
             gpios = <&gpioc 13 GPIO_ACTIVE_LOW>;
             zephyr,code = <INPUT_KEY_0>;
         };
     };

Now we define some fancy "keys". This is part of the gpio-keys feature, which allows you to map GPIO pins into a set of input events, rather than just GPIO events. Think of it as mapping the keys on a keyboard. There are other more advanced options for key matrices. The properties are similar to earlier, except this button is active when it's low (pulled to ground), and then we say that it emits 0 into the input stream when pressed.

50
51
52
53
54
55
56
57
58
59
60
     aliases {
         led0 = &blue_led;
         mcuboot-led0 = &blue_led;
         pwm-led0 = &blue_pwm_led;
         sw0 = &user_button;
         watchdog0 = &iwdg;
         die-temp0 = &die_temp;
         volt-sensor0 = &vref;
         volt-sensor1 = &vbat;
     };
 };

Here we set up a bunch of aliases that can be used to refer to things. These work exactly like any other phandle, so referencing &led0 is the same as referencing &blue_led.

Now comes the beast of a thing .. the clock tree. This is something that if you've never worked low-level (below Arduino, for example), you're probably completely unfamiliar with. There are multiple clock sources, phase-locked loops, and derivative clocks that drive everything. There's an excellent tutorial on the STM32 support forum.

62
63
64
 &clk_lsi {
     status = "okay";
 };

Here we just tell Zephyr that the low-speed internal clock is "okay", meaning you can use it. You'll see this notation somewhat frequently.

67
68
69
 &clk_hsi48 {
     status = "okay";
 };

Here we do the same thing for the high-speed internal clock, which runs at 48MHz.

70
71
72
73
 &clk_hse {
     clock-frequency = <DT_FREQ_M(8)>;
     status = "okay";
 };
Now here's where it gets interesting. Here we have to tell the system what the high-speed external clock is. It just gets a repeated pulse from the crystal, but it has no idea what that frequency is, so we tell it that it's 8MHz.

I got this wrong initially as I misread the schematic, and it resulted in some challenges. I like to use a LED blink program as the initial test because it sorts out the clocks as well as GPIO, and when it was initially set to be a 1s cycle, it was taking 3.something seconds. Something was off.

It turned out that I had both clk_hse and pll wrong. I had copied them from another SOC which has a different target clock rate, and different oscillator frequency. There was definitely some trial and error to sort that out as I also had to make sure I really understood the STM32 clock tree.

75
76
77
78
79
80
81
82
83
 &pll {
     div-m = <2>;
     mul-n = <85>;
     div-p = <2>;
     div-q = <2>;
     div-r = <2>;
     clocks = <&clk_hse>;
     status = "okay";
 };

One of the first things that is done is to use a phase-locked loop to create a derivative highly accurate clock signal from the input. We tell it that we want to use &clk_hse we just defined as the source (there's another separate thing going on for the low-speed clock that's out of scope here). Then we have to provide all the multipliers and divisors that it needs to generate the output clock, which are documented in the chip reference manual, but also some information is in this post. The best way to get these is to use STM32CubeMX and its clock solver to get it all right.

To get an idea of how complex the clock system is, here's a quick screenshot of the clock configuration section of the app:

A screenshot from STM32CubeMX's clock configuration screen

85
86
87
88
89
90
91
 &rcc {
     clocks = <&pll>;
     clock-frequency = <DT_FREQ_M(170)>;
     ahb-prescaler = <1>;
     apb1-prescaler = <1>;
     apb2-prescaler = <1>;
 };

Here we configure the reset and clock control (RCC) circuit, which is one of the main sources that distributes and times things across the rest of the MCU. We tell it to get its source from the PLL, and that the target frequency is 170MHz. We then provide any scalers needed for the various buses inside the MCU.

Why so many knobs?

You might ask, why is this so damned complicated? The main reason is power management. If you're worrying about power consumption, say because you're battery powered, slower clocks use less power. A lot less. Being able to tune clocks across the system to only use the "minimum" clock to get the job done lets you seriously dial down the power consumption of the MCU.

In our case, we don't really care because everything in mains-powered, and we are only talking about milliamps. But in a battery-powered system, you could reduce the power consumption by a factor of 5 or more.

93
94
95
96
97
zephyr_udc0: &usb {
    pinctrl-0 = <&usb_dm_pa11 &usb_dp_pa12>;
    pinctrl-names = "default";
    status = "okay";
};
We now enter the heavy IO section of the devicetree, and we need to start by talking about pin control. This is a deep topic, and is also specific to the individual MCU architecture. Diving in, every GPIO (general purpose IO) pin has an entire little active complex circuit inside it that not only drives it but does a huge amount of configuration. Pin control is what decides if the pin is just a regular IO pin, or if it's using an alternate function (AF), like ADC or DAC. It determines if it's an input or output pin, if it's pulled up or down, or left floating. It determines whether it's an open drain output or configured in push-pull. It's a whole collection of switches, resistors, and other components to let you plop a value in an IO register and have it all magically reconfigure. There's a great introduction to how all this works (in Linux, but it's very much the same in Zephyr) in this presentation.

Here we're creating a node usb on the phandles specifically for the USB AF on pins PA11 and PA12 as the first (0th) configuration of pin control, which in this case is labeled as "default" and marked as OK.

 99
100
101
102
103
104
105
106
107
108
109
110
111
112
&usart1 {
     pinctrl-0 = <&usart1_tx_pc4 &usart1_rx_pc5>;
     pinctrl-names = "default";
     current-speed = <115200>;
     status = "okay";
 };

 &lpuart1 {
     pinctrl-0 = <&lpuart1_tx_pa2 &lpuart1_rx_pa3>;
     pinctrl-1 = <&analog_pa2 &analog_pa3>;
     pinctrl-names = "default", "sleep";
     current-speed = <115200>;
     status = "okay";
 };

Something similar is happening here, but in the lpuart1 we have something different. We have 2 different configurations (0, 1) with different names (aliases basically) of "default" and "sleep". We're saying that in the normal running mode, this is the TX and RX pins for the low-power UART #1, but when the chip is asleep, it flips to a regular analog I/O, which allows for easier wake-up.

114
115
116
117
118
 &i2c1 {
     pinctrl-0 = <&i2c1_scl_pb8 &i2c1_sda_pb9>;
     pinctrl-names = "default";
     status = "okay";
 };

...

120
121
122
123
124
125
 &spi1 {
     pinctrl-0 = <&spi1_sck_pa5 &spi1_miso_pa6 &spi1_mosi_pa7>;
     pinctrl-names = "default";
     cs-gpios = <&gpiob 6 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>;
     status = "okay";
 };

Here we're defining an SPI set of pins. The cs-gpios defines the pin PB6 as the chip select line for the SPI port, marks it as active when it's low, and then attaches a pull-up resistor to it internally to hold it inactive by default.

127
128
129
130
131
132
133
134
135
136
137
138
139
 &spi2 {
     pinctrl-0 = <&spi2_nss_pb12 &spi2_sck_pb13
              &spi2_miso_pb14 &spi2_mosi_pb15>;
     pinctrl-names = "default";
     status = "okay";
 };

 &spi3 {
     pinctrl-0 = <&spi3_nss_pa15 &spi3_sck_pc10
              &spi3_miso_pc11 &spi3_mosi_pc12>;
     pinctrl-names = "default";
     status = "okay";
 };

More of the same here, but without the chip select defined yet. This can be done in the user's code.

141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
 &timers2 {
     status = "okay";

     pwm2: pwm {
         status = "okay";
         pinctrl-0 = <&tim2_ch1_pa5>;
         pinctrl-names = "default";
     };
 };

 &timers3 {
     st,prescaler = <10000>;
     status = "okay";
     pwm3: pwm {
         status = "okay";
         pinctrl-0 = <&tim3_ch1_pb4>;
         pinctrl-names = "default";
     };
 };

 stm32_lp_tick_source: &lptim1 {
     clocks = <&rcc STM32_CLOCK_BUS_APB1 0x80000000>,
         <&rcc STM32_SRC_LSI LPTIM1_SEL(1)>;
     status = "okay";
 };

We need to set up our timers, and we configure them for use as PWM timers as well. The st,prescaler is an STM32-specific property that says that the timer should be "scaled" down by 1000x.

We also define a low-power tick source leveraging the low-power timer #1. Unfortunately, I don't actually understand the clocks property and copied it from another definition. I have no idea if it works.

167
168
169
170
171
 &rtc {
     clocks = <&rcc STM32_CLOCK_BUS_APB1 0x00000400>,
          <&rcc STM32_SRC_LSI RTC_SEL(2)>;
     status = "okay";
 };

The STM32 has a real-time clock (RTC) that keeps a time and date running as long as it's not in the most aggressive sleep state. Again, copied, because the clocks property escapes my understanding and searching.

173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
 &flash0 {
     partitions {
         compatible = "fixed-partitions";
         #address-cells = <1>;
         #size-cells = <1>;

         boot_partition: partition@0 {
             label = "mcuboot";
             reg = <0x00000000 DT_SIZE_K(34)>;
         };
         slot0_partition: partition@8800 {
             label = "image-0";
             reg = <0x00008800 DT_SIZE_K(240)>;
         };
         slot1_partition: partition@44800 {
             label = "image-1";
             reg = <0x00044800 DT_SIZE_K(234)>;
         };
         /* Set 4Kb of storage at the end of the 512Kb of flash */
         storage_partition: partition@7f000 {
             label = "storage";
             reg = <0x0007f000 DT_SIZE_K(4)>;
         };
     };
 };

We need to break the flash storage into a few pieces here. We define 4 partitions for the boot loader, two versions of the code, and a 4Kb storage area that you could lay a filesystem abstraction on top of, or just store things directly into.

199
200
201
202
203
204
205
206
207
208
209
 &iwdg {
     status = "okay";
 };

 &rng {
     status = "okay";
 };

 &die_temp {
     status = "okay";
 };

Just a few peripherals, the watchdog timer (iwdg), random number generator (rng), and die temperature sensor (die_temp). We simply mark them as available and ready.

211
212
213
214
215
216
217
218
219
220
221
222
223
 &adc1 {
     pinctrl-0 = <&adc1_in1_pa0>;
     pinctrl-names = "default";
     st,adc-clock-source = <SYNC>;
     st,adc-prescaler = <4>;
     status = "okay";
 };

 &dac1 {
     pinctrl-0 = <&dac1_out1_pa4>;
     pinctrl-names = "default";
     status = "okay";
 };

We wire up pin PA0 to ADC #1 channel 1. We tie it to the APB bus clock (st,adc-clock-source), rather than letting it be independent, and then we scale it down by a factor of 4 (st,adc-prescaler).

225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
 &fdcan1 {
     clocks = <&rcc STM32_CLOCK_BUS_APB1 0x02000000>,
          <&rcc STM32_SRC_HSE FDCAN_SEL(0)>;
     pinctrl-0 = <&fdcan1_rx_pa11 &fdcan1_tx_pa12>;
     pinctrl-names = "default";
     status = "okay";
 };

 &fdcan2 {
     clocks = <&rcc STM32_CLOCK_BUS_APB1 0x02000000>,
              <&rcc STM32_SRC_HSE FDCAN_SEL(0)>;
     pinctrl-0 = <&fdcan2_rx_pb12 &fdcan2_tx_pb13>;
     pinctrl-names = "default";
     status = "okay";
 };

The STM3G474 has 3 CAN-FD (or is it FDCAN? I see it written both ways), and so we configure two of them. At this point, this should be mostly understandable.

241
242
243
244
245
246
247
 &vref {
     status = "okay";
 };

 &vbat {
     status = "okay";
 };

And finally, we enable the VREF+ and VBAT nodes on the chip. This allows them to be measured with the ADC.

Kconfig.weact_stm32h474_long

1
2
config BOARD_WEACT_STM32G474_LONG
    select SOC_STM32G474XX
Here we set the base software configuration for selecting SoC and other board and SoC related settings.

board.cmake

1
2
3
4
5
board_runner_args(dfu-util "--device=0483:df11" "--alt=0" "--dfuse")

include(${ZEPHYR_BASE}/boards/common/dfu-util.board.cmake)
include(${ZEPHYR_BASE}/boards/common/openocd.board.cmake)
include(${ZEPHYR_BASE}/boards/common/blackmagicprobe.board.cmake)

Now we setup some things to make programming the board easier. You can get the values for --device and --alt by running the dfu-util -l when the board is plugged in and in DFU mode (for this one, that means holding down the BOOT0 button while pressing the NRST button). You'll get something like this:

$ dfu-util -l
dfu-util 0.11

Copyright 2005-2009 Weston Schmidt, Harald Welte and OpenMoko Inc.
Copyright 2010-2021 Tormod Volden and Stefan Schmidt
This program is Free Software and has ABSOLUTELY NO WARRANTY
Please report bugs to http://sourceforge.net/p/dfu-util/tickets/

Found DFU: [0483:df11] ver=0200, devnum=1, cfg=1, intf=0, path="1-1", alt=2, name="@OTP Memory   /0x1FFF7000/01*1024 e", serial="205939885533"
Found DFU: [0483:df11] ver=0200, devnum=1, cfg=1, intf=0, path="1-1", alt=1, name="@Option Bytes   /0x1FFF7800/01*048 e/0x1FFFF800/01*048 e", serial="205939885533"
Found DFU: [0483:df11] ver=0200, devnum=1, cfg=1, intf=0, path="1-1", alt=0, name="@Internal Flash   /0x08000000/256*02Kg", serial="205939885533"

Looking at this we can see the vendor and device ID in brackets ([0483:df11]), and the alt targets.

weact_stm32h474_long.yaml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
identifier: weact_stm32g474_long
name: WeAct Studio STM32G474 Long Board
type: mcu
arch: arm
toolchain:
  - zephyr
  - gnuarmemb
  - xtools
ram: 128
flash: 512
supported:
  - nvs
  - pwm
  - i2c
  - gpio
  - usb device
  - counter
  - spi
  - watchdog
  - adc
  - dac
  - dma
  - can
  - rtc
vendor: weact

This metadata is used primarily by the test runner to help determine which parts of the test suite to run. The reason is you could target multiple boards with different capabilities, and then adjust both your application and the testing to meet them.

weact_stm32h474_long_defconfig

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
CONFIG_CLOCK_CONTROL=y
CONFIG_PINCTRL=y

CONFIG_ARM_MPU=y
CONFIG_HW_STACK_PROTECTION=y

CONFIG_GPIO=y

CONFIG_SERIAL=y
CONFIG_UART_INTERRUPT_DRIVEN=y
CONFIG_CONSOLE=y
CONFIG_UART_CONSOLE=y

Finally, we set some default Kconfig settings for every project that they can override. You can learn more about each of these, although I think they're mostly self-evident, from this search interface.

Problems I Ran Into and Open Questions

I ran into a few problems along the way and have a few open questions.

  • As mentioned earlier, the clock tree was the hardest part that I ran into. This is also why I like an LED blink script to test the board with. It's the simplest thing to run, but it exposes timing issues quite quickly. You might not find a small error, but you'll definitely find most of them.
  • I still don't really understand a lot of the phandles for things like clocks. This is just a complex topic, and my search foo isn't finding the information I need to grok it. I'll get there eventually.
  • It's unclear to me how you would start with a clean slate. Definitely starting from a similar board helped enormously.
  • Eventually, I want to figure out how to add a new SOC. They're also defined through the device tree paradigm.

I will be putting this up on Github at some point, but need to write some documentation first, and make sure that I've shaken it out some, before I subject the world to it. My intention is to submit it to the Zephyr project for inclusion in future releases.

Posted on Github

I have posted this on Github, and it's been renamed as the core board since it turns out both forms of the board have the same pinouts just arranged slightly differently.

To do is cleaning it up and submitting it to the Zephyr project.

Here's the blink script that I used.

1
2
3
4
5
6
7
#include <stdio.h>
#include <stdlib.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/drivers/uart.h>
#include <zephyr/kernel.h>
#include <zephyr/sys/printk.h>
#include <zephyr/usb/usb_device.h>

Just normal includes to get access to everything.

16
17
18
19
20
21
22
23
/* The devicetree node identifier for the "led0" alias. */
#define LED0_NODE DT_ALIAS(led0)

/*
 * A build error on this line means your board is unsupported.
 * See the sample documentation for information on how to fix this.
 */
static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(LED0_NODE, gpios);

Here's where all that device tree work comes into play. We first resolve the led0 alias, and then we get the GPIO spec for it. This abstraction means that you can have a bunch of different boards with slightly different wiring that don't require any software changes. For many uses, it is unfortuantely, maybe an abstraction that obscures how things work. I'm definitely of a mixed mood about it.

25
26
27
28
29
30
31
32
33
34
35
36
37
int main(void)
{
    int ret;
    int sleep_ms;

    if (!gpio_is_ready_dt(&led)) {
        return 0;
    }

    ret = gpio_pin_configure_dt(&led, GPIO_OUTPUT_ACTIVE);
    if (ret < 0) {
        return 0;
    }

As befits an embedded system, there's no parameters passed to main. I mean, where would they come from?

We then make sure that there's a GPIO pin attached to the led variable, and then configure it to be an output pin.

39
40
    ret = usb_enable(NULL);
    printk("usb_enable: %i", ret);

Here we enable the USB functionality. I ran into some issues here, because according to the docs, this should return 0 on success, and something negative on failure. But I got negative values no matter what, and yet the USB was working. On the heap of things to dig into more.

I had originally wanted to loop here to wait until the console is attached. For that, I just use minicom, and it's executed on my M1 Macbook Air as minicom -D /dev/tty.usbmodem1101 -b 115200. Not really sure that the baud rate matters, but I used it anyway.

42
43
44
45
46
47
48
49
50
51
52
53
54
    while (1) {
        ret = gpio_pin_toggle_dt(&led);
        if (ret < 0) {
            return 0;
        }

        printk("LED state: %s\n", gpio_pin_get(led.port, led.pin) ? "ON" : "OFF");

        // 1000ms = 1s
        k_msleep(1000);
    }
    return 0;
}

Finally, we throw the program into an infinite loop and toggle the pin off and on, printing out the current state of the pin and sleeping 1 second, leading to 1 second on and 1 second off. The program is marginally more complicated than an Arduino sketch, but that's largely because Arduino's framework hides many things from you. Up to a point, this is a huge win, until it's not. For my overly complicated project, I wanted to have it all exposed. I'll be diving into more advanced features of Zephyr later like pub/sub, threading, sensor framework, etc.

And with that, we're kinda done? Somewhat done? The magic smoke didn't come out of the chip, at least!