Skip to main content

· 11 min read

Hey everyone, I've got a super cool project to share with you - a falling sand simulation.

So, I was just chilling, watching some YouTube videos, when I stumbled upon this awesome falling sand coding challenge. I thought, why not bring this magic to our SwiftIO Playground Kit? (The Swift core team is actively developing embedded Swift, and I'd like to introduce you the potential of embedded Swift programming through hands-on projects.)

Here's the plan: I use a button to spray sand and a potentiometer to control where it goes. Turn the knob, press the button, and boom! A beautiful sand scene on my tiny screen. So, I set up the kit next to my computer and dive right in.


Here's a breakdown of the development process in 7 steps:

  1. Add one sand particle
  2. Move it
  3. Add sand sprayer
  4. Create sand with the sprayer
  5. Create natural sandpiles
  6. Add more sand
  7. Add more colors

Let's go through them step by step.

1. Add one sand particle

Picture this: the screen is like a giant canvas, divided into a grid where each square represents a single particle of sand. As the sand moves, it jumps from one square to another. To keep track of these squares, I’ve organized them into a 2D array of rows and columns. A specific square is referred to as grid[rowIndex][columnIndex].

Each square in the array is labeled as either having sand (1) or being empty (0). At the start, it's all 0 because, well, there is no sand yet. So, I randomly picked a square and switched its status to 1. Then, I drew a red square on that spot to mark the initial sand particle.

Show sand particle

2. Move it

To move a sand particle downward, I just need to draw it on the square below and clear its current position. But, what if the square below isn't empty? It gets blocked and stays put.

The trick is to check the states of all squares. If a square isn't empty, I looked below to determine if the sand can move or not.

Move sand particle

Ah, the first problem emerged! While iterating and updating grid states, an error popped up. Drawing from experience, I suspected it might be an array out-of-index issue. And indeed, that's the case.

When iterating through the cells in the last row to determine movement, each one needs to reference the one below it. But alas, there are no cells below the last row! Additionally, no need to check that row, as it's already at the bottom🤨. The solution? Simply skip the last row during iteration.

3. Add sand sprayer

Let's take a break from the sand and shift our attention to the sprayer. Positioned at the top of the screen, it's set to smoothly slide left or right in response to my potentiometer twist.

To make this happen, I created a square matching the size of all other squares. This way, the first row of the grid is solely dedicated to the sprayer's movements. The potentiometer readings are mapped to the cell index, determining the sprayer's position.

Add sand sprayer

Oh, wait… sometimes the sprayer drifted back and forth 🫨.

This issue often arises when dealing with a potentiometer, as its readings can fluctuate and be mapped to different values. To avoid this, I opted to take an average reading.

4. Create sand with the sprayer

Now, let's bring the sprayer and sand together. The initial random sand particle was no longer necessary. I was ready to generate sand!

To kick things off, I needed a trigger condition for sand generation. So, I introduced a button, and when pressed, sand generation began. The new sand particles would appear below the sprayer, specifically in the second row.

However, if sand particles were created endlessly while the button is held down, it could overcrowd my tiny screen. To prevent this, I added a small gap before each new sand particle.

Create sand with sprayer

As the number of sand particles increased, I noticed an illogical aspect of my sand falling mechanism. If the square below the current sand was not empty, the current sand remained still, while the sand particle below it might fall when it was its turn.

To address this, I reversed the iteration of rows in my algorithm. Thus, the sand particle below would be moved downward to clear the square in advance if possible, allowing the current particle to fall successfully.

Sand movement with different iteration order

5. Create natural sandpiles

Currently, the sand particles would stop if the squares below them were filled. As a result, they piled up vertically, which appeared unnatural. In reality, sand continues to spread to the sides of the pile instead of stacking vertically.

If the square below is not empty, it's time to examine the states of the squares to the right and left of it. To prevent the sand from moving exclusively to the left or right, I introduced some randomness to determine the direction each time.

Sand pile

And voila! The falling sand simulation is complete.

6. Add more sand

Now, it was time to elevate the visual effects.

The sprayer only added one sand particle at a time, which wasn’t particularly striking. To enhance the visual impact, I increased the number of sand particles added each time within a certain range. The squares within that range were chosen randomly.

Add more sand particle

7. Add more colors

A monochrome scene can be rather dull... I’d like to inject some colors! It's quite straightforward. Instead of just using binary values of 1 and 0, update the grid state with color values. Ta-da!

Add more colors for sand particles


Well, folks, there you have it. Below is my code.

You can find it on GitHub. Feel free to download and give it a try. Happy coding!

import SwiftIO
import MadBoard
import ST7789

// Initialize the SPI pin and the digital pins for the LCD.
let bl = DigitalOut(Id.D2)
let rst = DigitalOut(Id.D12)
let dc = DigitalOut(Id.D13)
let cs = DigitalOut(Id.D5)
let spi = SPI(Id.SPI0, speed: 30_000_000)

// Initialize the LCD using the pins above. Rotate the screen to keep the original at the upper left.
let screen = ST7789(spi: spi, cs: cs, dc: dc, rst: rst, bl: bl, rotation: .angle90)

let cursor = AnalogIn(Id.A0)
let button = DigitalIn(Id.D1)
var pressCount = 0

var sand = Sand(screen: screen, cursor: cursor)

while true {
// Add more sand particles if the button is been pressed.
if pressCount > 10 {
pressCount = 0

if {
pressCount += 1
} else {
pressCount = 0

sleep(ms: 5)

// Update the position of sand and cursor over time.
sand.update(cursor: cursor)

· 11 min read

I personally find the SPI protocol to be more favorable than I2C, as the I2C protocol has several additional features that make the protocol more complex to implement and more prone to communication errors. Recently, a member, Jimmy, in our discord community encountered an I2C communication failure. So I researched the issue in more depth to understand and solve the problem. I would like to share my findings.

What's the problem

A 1602 LCD screen that was connected to the I2C interface was displaying an incrementing value over time. However, unexpectedly, after an indeterminate period, the screen froze and stopped displaying the updated value.

How to find out the cause

Step 1

To understand the cause of the problem with the 1602 LCD screen, I decided to observe the I2C signals. To do this, I added a digital trigger pin and configured it to go low when an error occurs. This would allow me to visualize the I2C signals and detect any issues with the communication. Here is my test code:

import SwiftIO
import MadBoard
import LCD1602

public struct I2CIssue {
public static func main() {
var seconds = 0

let i2c = I2C(Id.I2C0)
let lcd = LCD1602(i2c)
lcd.write(x: 0, y: 0, "Hello SwiftIO!")

let trigger = DigitalOut(Id.D0)

var ret = true
while true {
seconds += 1
sleep(ms: 20)

// Display the new value on the LCD.
ret = lcd.write(x: 0, y: 1, "\(seconds)s")
// If an error happens, change the state on trigger pin.
if ret == false {
seconds = 0

while true {
sleep(ms: 200)

As shown below, the I2C signal appears to be working normally at first, then an error happens, the I2C reports an I/O error, and the trigger pin is set to low, which is indicated by a yellow dotted line. The last I2C data before the error was sent without any issue, which indicates that something is causing the I2C communication failure.

Step 2

The problem with the I2C communication and the LCD screen was difficult to reproduce consistently. Despite multiple attempts, I was unable to consistently replicate the issue, making it hard to pinpoint the exact cause of the failure😔.

Fortunately, I did discover that when other connectors were plugged into the same USB hub as the SwiftIO board, the LCD would stop working. This provided a clue towards identifying the cause of the problem🧐.

Further investigation revealed that the I2C communication would fail when power-consuming devices were connected to the USB hub. This led me to suspect that the issue may be related to a power problem.

Step 3

After trying to sample the actual analog voltages on the bus, I FINALLY noticed a small glitch in the signal which causes the I2C communication error.

I discovered that when another device was plugged into the hub, the voltage on the SDA line dropped to around 2.9V, causing the glitch which interfered with the signal. The master device thought that communication was still ongoing on the bus and waited for a stop signal, thereby keeping the I2C in busy mode.

About I2C

Let's first take a look at I2C. I2C is a two-wire protocol that is used to connect multiple devices to a single bus. It is suitable for short-distance communication.

The I2C bus consists of two lines: the serial data line (SDA) and the serial clock line (SCL). The master device generates the clock signal on the SCL line, and the devices on the bus use this clock to synchronize their communication. Data is transmitted on the SDA line.


Each I2C device has a unique address. The master device can initiate communication with a specific slave device by sending its address, and the slave device will respond if it matches the address. Then, the master device can send or request data from the slave device, which is transmitted on the SDA line.

I2C device address

I2C is designed for short-distance communication, typically on a single PCB (printed circuit board). Longer bus lines can have more noise and crosstalk that can affect the signal integrity and cause errors.

Push-pull and open-drain

The I2C uses open-drain configuration. There are two kinds: push-pull and open-drain:

  • A push-pull output can both source and sink current. This is how it got its name since it pushes the signal high and pulls it low.

    In a push-pull circuit, two active devices are used to drive a load. One device is used to "push" current into the load, while the other device is used to "pull" current out of the load. The two devices switch back and forth, following the internal signal, to create the output.

    It can drive a signal over a longer distance and have less susceptibility to noise compared to open-drain outputs.

    Push pull output
  • In contrast, an open-drain output can only sink current.

    An active device is used to pull the output voltage to a low state, but does not actively drive it to a high state. It acts like a switch that connects the signal line to the ground, pulling the voltage level of the signal line low. This is called "drain" because it is "draining" current away from the signal line. And if it is turned off, the signal line is in an "open" state, not connected to either power or ground. This means that there is no current flowing through the line and it would float.

    Open drain output

    To drive the signal high, an external pull-up resistor can be used to connect it to the high voltage level (Vcc), which will pull the output high when no other device is actively driving the signal. This means that the current flowing through the pull-up resistor is sourced by the voltage source, not by the open-drain output.

The use of open-drain outputs is useful in situations where multiple devices need to share a single communication line, such as the I2C bus.

I2C Pull-up resistor

I2C bus uses open-drain output, hence it needs external pull-up resistors.

Open drain output

The value of the pull-up resistors can affect the charging and discharging time of the bus capacitance, which in turn can affect the rise time of the signals on the bus. The capacitance refers to the total capacitance present on the SDA and SCL lines, including the parasitic capacitance of the devices and any additional capacitors that may be present. It makes the voltage level on the two lines cannot change instantaneously. When a device changes the line state, it causes a charging or discharging process of the bus capacitance, which takes a certain amount of time known as the rise time and fall time.

Why the falling time of a signal is usually shorter than the rising time? When a device pulls the SDA or SCL line low, the bus capacitance is effectively discharged through the device's open-drain output. Since the output is actively pulling the line low, the transistor acts as a switch with very low internal resistance and the discharge time is relatively short.

When a device releases the SDA or SCL line, the bus capacitance is charged through the pull-up resistor. Since the device is no longer actively pulling the line low, the charge time is determined by the value of the pull-up resistor and the total capacitance of the bus. A higher value pull-up resistor or larger bus capacitance results in a longer charge time, and therefore a longer rising time.

  • A "strong" pull-up is one with relatively low resistance. It allows for a faster charging of the bus capacitance when the lines are released, which results in a faster bus speed and better performance in high-speed I2C applications. However, it also increases the power consumption on the bus.

  • A "weak" pull-up is one with relatively high resistance, which increases the charging time of the bus capacitance when the lines are released. This can result in slower communication speeds and a higher probability of errors on the bus. It may be suitable for low-speed or low-power I2C applications.

By default, the I2C interfaces on the SwiftIO board have 4.7kΩ pull-up resistors. So you don't have to add external pull-up resistors when connecting other I2C devices.


Returning to the issue at hand, it was established that the I2C signal can be easily affected by external noises and disturbances. After further investigation, I discovered that the MCU has an I2C glitch filter feature that can be enabled to prevent these types of errors.

The I2C glitch filter is a useful feature that helps to prevent errors in communication caused by short, unwanted pulses or glitches on the SDA and SCL lines of the I2C bus. It is typically set to ignore pulses that are shorter than a certain duration, for example, in a 1MHz I2C bus, a cycle lasts for 1000ns, with 500ns for the high level and 500ns for the low level. By adding a 400ns glitch filter, any noise or disturbance shorter than 400ns will be filtered out, thus improving the reliability of the I2C communication by reducing the chances of errors caused by unwanted pulses.

After activating the I2C glitch filter and running the test again, the communication is much more stable, indicating that the filter successfully eliminated the unwanted pulses that were causing errors in the communication👏.

Go further: Schmitt trigger

I found in the MCU's datasheet that it has an I2C glitch filter built-in, which is specifically designed to filter out unwanted pulses or glitches on the SDA and SCL lines of the I2C interface. Furthermore, it also provides a Schmitt trigger for the General Purpose Input/Output (GPIO) pins to filter out external noise and improve signal integrity.

In a digital input circuit, the input signal is typically an analog signal, such as a voltage, which needs to be converted into a digital signal that can be understood by the digital logic of the system. Schmitt triggers are commonly used in digital input circuits to provide noise immunity and improve signal quality. It compares the input signal to two different threshold voltages, typically an upper threshold and a lower threshold. When the input voltage exceeds the upper threshold, the output goes high. When the input voltage falls below the lower threshold, the output goes low.

Since the Schmitt trigger has hysteresis, the threshold voltages between high and low states, there is a range of input voltage where the state doesn't change even though the input voltage is changing. This property allows the Schmitt trigger to ignore small voltage fluctuations that would otherwise cause false triggers.

For example, after a mechanical switch is pressed, the voltage may fluctuate for a certain period near the center threshold before settling in its final state. These fluctuations near the center threshold can be interpreted as multiple pulses. For example, once the voltage passes the center threshold, it is considered high, and if it falls a bit below that threshold, it becomes low again. This can cause errors in the digital signal, and the Schmitt trigger helps to filter out these unwanted pulses and ensure a clean transition in the signal.

  • If the button is pressed (or released depending on your circuit), after the input voltage rises above the upper threshold, the Schmitt trigger's output changes to a high level, indicating that the button has been released.
  • When the button is released (or pressed depending on your circuit), after the input voltage falls below the lower threshold, the Schmitt trigger's output changes to a low level, indicating that the button has been pressed.

Therefore, it ensures that the input is interpreted as a single, clean transition.

While Schmitt triggers are often effective at suppressing noise on digital input signals, there may still be some noise present even after adding a Schmitt trigger to the circuit. This may be due to the sufficient hysteresis of the Schmitt trigger to filter out all the noise present on the input signal. Or the amplitude of the noise on the input signal may be larger than the hysteresis of the Schmitt trigger, making it impossible for the circuit to filter it out. Therefore, a combination of different types of filters or even other software debounce techniques may be needed to achieve the desired level of noise suppression.

The MCU has the Schmitt trigger on all GPIOs. It's configured by default, I cannot change its setting but only turn on/off it. So there may be still some noises when using these pins. So when I am working with a button, I tend to add a simple RC filter, made up of a resistor and a capacitor, for button debounce.

· 15 min read

Thanks janhendry for helping me improve the content : )

What is "Embedded, Bare-metal, Real-time, Microcontroller, Arduino, Raspberry Pi"

Sorry to put so many technical terms at this beginning. I met so many people who felt confused about these terms. Of course you will, because it's hard to find any precise definitions if you google them. First, I'll give a brief explanation about them.

  • Embedded: At the very beginning, the meaning of embedded application is pretty intuitive. It refers to a kind of software built into any machine other than a general-purpose computer. But its meaning is becoming vague and confused nowadays. You could find so many different explanations with google. I prefer the one on wikipedia: If the main software functions are not initiated/controlled via a human interface, but through machine-interfaces, you could call it embedded application, from simple firmware that controls the microwave oven to complicated Android-based automotive HMIs. Many people might think it is relevant to the complexity and performance of applications, or if an OS is used, and that’s not the case.

  • Bare-metal: This term means your code runs on the hardware directly, without any typical OS. It has nothing to do with specific hardware. You can write a bare-metal program on a simple microcontroller or (possibly) on a complicated x86-64 machine.

  • Real-time: Real-time programs must guarantee a response within specified time constraints, often referred to as "deadlines". In short, the operation time must be deterministic. You can write a real-time program on the hardware directly (Bare-metal) or based on a real-time OS. BTW, the typical OS such as macOS, Windows, Linux, iOS, Android, none of them guarantee real-time, so it's really hard (most time impossible) to write a real-time program based on those platforms.

  • Microcontroller/MCU: A microcontroller is a small computer combined with various peripherals. Most of the peripherals are used to communicate with external machines or sensors. Typically, a microcontroller contains everything (CPU, ROM, RAM, peripherals) in a single chip. You just need to power it up, then code execution begins. Normally, the microcontroller's performance/complexity is much less than any regular computer, so developers prefer to write their application directly on the hardware (Bare-metal). But the situation is changing in recent years. Many microcontrollers are becoming more and more powerful and complex rapidly. It's more challenging to develop bare-metal applications on them.

  • Arduino: A very famous brand/company that produces a series of microcontroller boards. The boards can be programmed using C or C++. The greatest advantage of Arduino is the massive amount of C/C++ libraries and the huge community. When programming an Arduino board, you are actually doing bare-metal programming. However, you don't need to deal with the low-level hardware details because Arduino provides a series C/C++ API, which is very easy for software engineers to get started.

  • Raspberry Pi: RPi looks like Arduino, but it's totally different. RPi is a general-purpose computer but small in size (also very cheap). The hardware architecture is similar to a cellphone or tablet. It can run the standard Linux. It's the same to develop an RPi application as any other Linux applications. RPi does provide some peripherals, so you can use it to communicate (through Linux driver API) with external sensors. I think this is the only similarity with Arduino.

The difference when programming for a general-purpose computer and an embedded one

After knowing those terms above, you may find out the main difference between a normal application and an embedded application: the former targets humans while the latter targets machines.

A normal application runs on general-purpose computers in our daily life: PC, Mac, Cellphone, tablet, etc. On those computers, people can install any application they like, run them simultaneously. In this situation, the computer is controlled by a human interface. The primary task of the OS is to guarantee all the apps/processes won't affect each other.

By contrast, the application in an embedded device mainly focuses on machines. All demands are fixed. Therefore, only one dedicated application is running normally.

Embedded categories

Hardware difference: MMU

It's not hard to tell many differences between a general-purpose computer and an embedded device/microcontroller. But don't be confused by those intricate details. IMHO, there's only one key difference: if there is a memory management unit (MMU).

MMU is a computer hardware unit that decides whether the CPU supports a general-purpose OS. It gives OS the capability to run multiple apps/processes simultaneously. Each process has its own virtual address space. MMU maps them to the actual physical RAM.

By contrast, the microcontroller is used under a more stable circumstance. It doesn't need to support multiple processes. All the code, including application, libraries, OS (if you have one) share the same address space. Besides, all the hardware components such as ROM, RAM, peripherals are also mapped into the same address space.

The single address space greatly simplifies the compute architectures, and it brings another characteristic of the microcontroller: where the application is stored.

We know a normal application is stored at the external storage, such as hard driver, SSD, EMMC, SD card etc. File system allows the OS to find and load the exact application. The OS needs to copy the application into RAM before running. Then the CPU and MMU can work together to translate the memory addresses and execute the app.

When you develop an application for the microcontroller, all addresses are fixed after linking. Then you could put the application in the ROM, which has its own address. So it takes only a few milliseconds to run your code after the device power-up. It's called execute in place (XIP).

Software difference: Operating system

As a Swift programmer, you must be familiar with app development on macOS or iOS. The OS manages computer hardware, software resources, and provides common services for applications. The OS is like a huge black box. All the resources are restricted to the system API. You can not talk to the hardware directly but through a batch of system APIs. Any simple application might depend on many hidden system-level libraries (most of them are dynamic linked). The OS will help to invoke these libraries at run time.

By contrast, every microcontroller application is self-contained. It means all the stuff are static linked together, including the application code, dependent libraries and the OS itself. In such context, the OS is normally provided at the source code level (sometimes binary), and you could consider it a normal scheduler library like any other dependencies. Actually, there is a term to describe such kind of OS: Library operating system.

In most cases, the software arch for microcontrollers is much simpler than the one on general-purpose computers. In such situation, the OS is just another dependency, you can even modify (but not recommend) the OS if you need.

Why Swift and How?


C is still a popular language for low-level embedded programming. That’s because:

  • C is the most widely supported language by various hardware archs in the embedded world. It is considered a kind of portable assembler. You don’t have any other choices if you are targeting a rarely-seen hardware platform.

  • Even though the absolute quantity of embedded devices is huge, most of them need relatively simple software architecture. In such cases, you don’t need to use a higher abstraction provided by modern programming languages.

In recent years, the embedded device evolves and hence both the performance and complexity increase rapidly. Embedded engineers are trying different ways to handle the increasing complexity. There comes Arduino (C++), MicroPython, Espruino (JavaScript), TinyGo (Golang), Meadow (C#), etc. But except C++, none of those languages are designed for system-level programming naturally.

As I know, only Swift and Rust announce themselves are (modern) system-level languages in recent decades. When I first have the thought of porting Swift to the embedded world, I have compared Swift and Rust carefully. My conclusion is that Rust is too complicated for application development. But now, in 2022, you can see Rust has already gained a reputation as a system-level programming language. More and more people try to use Rust in embedded development and they have really active community. Unbelievable, are people gluttons for punishment nowadays😅? Come on! Swift! We can do this!

Here are some typical features a system-level language should have:

  • Compiled language
  • Easy to interact with C API
  • Strong type
  • Fast
  • No GC
  • Deterministic

Both Swift and Rust have these features. Unfortunately, Swift isn’t that fast and deterministic as Rust currently. Because there are many hidden operations behind the code, and it’s hard to find them out now. Since the core team has put this as a main goal in Swift 6, I believe everything is ready for Swift in the embedded world.

Cross compile

As we all know, any programming language is translated into assembly at last (I won’t discuss interpreted language here, they are relatively slow compared to compiled languages).

Swift/LLVM toolchain is naturally a cross compiler. It provides an easy way to translate the Swift code for different hardware architectures.

Here is a very simple demonstration:

func empty() {

We have an empty.swift here, you could use such command to translate it into assembly for different hardware arch:

swiftc -target x86_64-apple-macosx12.0.0 -c empty.swift

Depending on your platform, you may meet the error:

error: unable to load standard library for target ***

Just add the -parse-stdlib arg, it tells the compiler to not look for the Swift standard library. Like this:

swiftc -target x86_64-apple-macosx12.0.0 -parse-stdlib -c empty.swift

Changing the target, you could translate the source code into object files for different archs/systems. Here are some examples:

  • x86_64-apple-macosx12.0.0

  • x86_64-unknown-windows

  • x86_64-unknown-linux

  • arm64-apple-macosx12.0.0

  • arm64-unknown-linux

  • arm64-apple-ios15.0.0

In the current MadMachine project, I added the target thumbv7em-none-unknown-eabi for ARM Cortex-M series microcontroller to the Swift toolchain. Thanks to the nice framework of Swift/LLVM project, it’s super convenient to enable a new target.

So is that done? Can you write Swift code for Cortex-M microcontrollers now? Not yet!


As you can see in the demonstration, there’re only two hardware archs: x86_64 and arm64. But what are those OSes doing here? Since the archs are the same, shouldn’t they have the same assembly? Technically, it should be. But your Swift code needs the help of some fundamental functions provided by the OS.

For example, when you create an instance of a class in Swift code, you need some memory space in the heap. Different OS might have different API for this operation. So Swift toolchain implemented a very fundamental abstraction layer to cover these differences. This (library) is called Swift Runtime and it’s implemented in C++ (so it can interact with OS's C API directly). In the example above, the function is swift_allocObject. The runtime is used for very low-level management such as casting, ARC, metadata, etc. It’s not something magic, but another low-level library. BTW, you can not access this Runtime library in Swift code directly, it is only used by the Swift compiler when necessary.

If you want to use Swift on some new archs or OSes, you need to connect the Runtime invocations with the OS API first, or at least stub them out. Once you implemented those APIs used by the Runtime, most of the Swift code can run correctly.

Connect Swift Runtime to Zephyr

As I mentioned before, embedded devices are becoming more complex and have higher performance. For the MadMachine project, I chose NXP RT10xx series microcontroller which has a 600MHz ARM Cortex-M7 core and various complex peripherals. It’s not easy to leverage all the capabilities on bare-metal.

So I use a Real-time OS named Zephyr. It is a Linux Foundation project which focuses on the area that Linux can’t cover. It provides a batch of standard APIs to access the low-level peripherals. Many semiconductor companies such as NXP contribute to implementation for these APIs. So you don’t need to spend that much energy on the low-level details when developing an application.

The Zephyr Real-time OS is provided via source code, and it uses Kconfig to configure the kernel (just like the Linux kernel). A normal C application based on Zephyr is compiled with the OS source together. But for a specific hardware, you could compile the whole OS layer as a library and use it at linking time. This is how the MadMachine project works.

Swift standard library

After the Swift Runtime and Zephyr are connected, all the difficult parts are done. Now you can cross compile the whole Swift standard library into the expected target: thumbv7em-none-unknown-eabi.

Then a new problem appears. As I have said before, a microcontroller application needs to static link all stuff together. The Swift std is such a huge library that the binary size is bigger than 2MB. Currently, this size is unacceptable on most microcontrollers (ROM size < 2MB is still the mainstream).

In the traditional C developing, it’s very easy to enable the linking time optimization to reduce the application binary size:

  1. Separate each C functions/data into different segments while compiling to object files.
  2. Eliminate those segments not used in the application when linking.

But Swift uses some new technologies such as metadata, causing this kind of optimization not that useful. Because the linker cannot infer if those functions/data are used in those object files now.

create a project.

Currently, the MadMachine microcontroller board includes a 32MB external RAM. The application binary is linked to the RAM start address. Developers need to copy the application binary to the onboard SD card. After board power-up, the firmware in the ROM will copy this application to the specific RAM address and then begin execution.

Remember what is XIP? Yes, this board does contain an 8MB ROM. Technically you could link your application to the ROM address and write to the ROM, so you don’t need the SD card anymore. (It’s relatively slow to write data to the ROM)

Room for improvements

Language evolve

As I mentioned before, the Swift team would pay attention to the performance and deterministic in Swift 6. They are the most important features to program in the embedded world. I'll list them here:

  • Performance
  • Deterministic

Binary size

Next, the binary size becomes a critical problem. Even if the hardware would evolve over time, we must try to improve the situation. At present, there are mainly two ways:

  • Implement another Swift standard library dedicated for constrained environments

    This would be the only choice for some low-end microcontrollers (They may have only 64KB ROM or even lower). But in such situation, I think people prefer to use C rather than any kinds of modern languages.

  • Keep the current Swift std, but make it more convenient for link time optimization

    Personally I prefer this way. In many cases, people chooses a language not because the language itself, but the powerful built-in functions and mass libraries. We’d better try our best to keep the fundamental std the same so we can leverage the whole ecosystem rather than the language syntax.

Unlike Rust implementing its standard library into two parts (core and std), the Swift std is designed high coupling as a whole module due to performance requirement. This makes it hard to reduce its size. Even if you use a standard Int type or simple assignment statements, you have to import the whole standard library.

BTW, Swift team is working hard for linking time optimization technology. We just don’t know how well it would be for reducing the binary size. Hope it would work great!


Swift has added this missing part in Swift 5.5. I haven't looked into it carefully. Seems the main part is implemented as a runtime level library. It dependes on the externel libdispatch which depends on a series of OS APIs such as pthread (Plz correct me if I'm wrong).

Wow, this is really unfriendly for Bare-metal or RTOS development. We still have a tough nut to crack.


The embedded/bare-metal development has already existed near half a century. It is equivalent to assembly or C programming in such a long period. In recent years, it becomes more and more difficult due to the rapid evolvement of embedded hardware. The whole industry is in urgent need of some new ways to improve the situcation. But still, there is no result. And Swift is really suitable in such use case.

IMHO, it’s more easy for Swift to expand into a brand new and growing field rather than replace other lanugages in some existing scene. Especially, there’s almost no competitor in this area.

Since the global chip shortage started in 2020, we have paused the hardware development until we can purchase the core components. Please stay tuned. This must be cool and fun!