A SwiftPM package that enables seamless Swift development for the Raspberry Pi Pico SDK, targeting the RP2xxx device family with a streamlined developer experience.
This project is licensed under the MIT License.
The easiest way to get started is with the Example project located in the Example/ directory:
-
Clone the repository and navigate to the Example project:
cd Example/ -
Install swiftly and other dependencies if using linux: Linux: First, install required system dependencies:
sudo apt install build-essential libhidapi-hidraw0 libhidapi-libusb0
Linux and macOS: Install Swiftly from swift.org and ensure all dependencies mentioned at the end of the build script are installed.
-
Run the build script for the first time:
bash build.sh
⏱️ Note: The first run will take a couple of minutes as it downloads all dependencies (Pico SDK, toolchains, and Swift packages).
-
Open in VSCode (optional but recommended): Install the recommended extensions when prompted. This enables full IDE integration with debugging and flashing capabilities.
Once your project is built, you have two options for programming your RP2xxx device:
-
Using cortex-debug (with debug probe): Connect an SWD debug probe (like a Raspberry Pi Debug Probe or Picoprobe) to your device. This enables full debugging with breakpoints, variable inspection, and step-through execution.
-
Using picotool (USB flashing): Hold down the BOOTSEL button while connecting your device via USB (or press it while pressing the reset button). The device will appear as a USB mass storage device, and picotool can directly write the firmware without needing a debug probe.
The Example project includes a small PIO demo based on hello_pio from pico-examples. It ships a hello.pio file and uses the PIOASM SwiftPM plugin to assemble it and expose a Swift helper (hello.program_init). In Example.swift, the PIO program is loaded and a state machine drives GPIO 20 by pushing values into the TX FIFO.
Swift-specific blocks can be embedded directly in a .pio file using a % swift { ... } section. In hello.pio, that block defines a Swift extension on the generated hello namespace with program_init(...), which wires up pin mapping, GPIO init, pin direction, state machine config, and enable. This lets you keep PIO program + Swift setup side by side in a single source file.
The % swift { ... } section is placed after the .program body in hello.pio, so the assembly remains up top and the Swift helper lives at the bottom of the same file.
The example also keeps a small Unicode-aware string check on purpose. That exercises the embedded Swift Unicode runtime path so CPicoSDK can demonstrate automatically linking libswiftUnicodeDataTables.a only when the final link actually needs it.
Text and binary resources can be embedded into the firmware by adding .codeasset files to your Swift target and enabling the AssetCompiler plugin:
.target(
name: "Example",
dependencies: [
.product(name: "CPicoSDK", package: "CPicoSDK"),
],
plugins: [
.plugin(name: "AssetCompiler", package: "CPicoSDK"),
]
)For a file named sample.codeasset, the plugin generates an Asset.sample accessor:
print("Asset name: \(Asset.sample.name) bytes: \(Asset.sample.data.count)")
print("Asset content: \(String(decoding: Asset.sample.data, as: UTF8.self))")Asset.data is an UnsafeRawBufferPointer that points at the linked resource bytes. The finalizer embeds the resource with objcopy into a read-only flash section, so accessing the bytes does not require a generated Swift byte array or an SRAM copy of the file content.
The Example shows PSRAM as an optional configuration and allocator target.
In configure(with:), enable PSRAM by adding:
configurator.configure(PSRAMConfiguration())Then allocate explicitly in PSRAM:
if let ptr = UnsafeMutableRawPointer.allocate(byteCount: 1024, alignment: 4, in: .psram) {
ptr.deallocate()
}Or use fallback allocation:
if let ptr = UnsafeMutableRawPointer.allocate(byteCount: 1024, alignment: 4, in: .psramIfAvailable) {
ptr.deallocate()
}How it behaves:
- If
PSRAMConfigurationis present and initialization succeeds, a PSRAM allocator is registered. .psramallocations require a PSRAM allocator and fail if unavailable..psramIfAvailableprefers PSRAM and falls back to SRAM when PSRAM is not registered.- The Example keeps this opt-in commented by default because not all boards include external PSRAM.
CPicoSDK now has a first pass at embedded Swift Concurrency support, but it is still experimental.
Important points:
- it is opt-in
- it currently lives in a secondary package product:
CPicoConcurrency - no concurrency support is pulled in unless you depend on and import it explicitly
For now, if you want to use Task, await, or the helper APIs in this repository, add CPicoConcurrency to your target dependencies and import it in your code:
import CPicoSDK
import CPicoConcurrencyCurrent status:
- plain embedded
async/awaitis working - the implementation is still exploratory
- custom executors are not supported yet
- Linux currently depends on a vendored fallback copy of the embedded
_Concurrencyartifacts for the pinned Swift snapshot - the API surface may still change
The Example project is expected to keep demonstrating an important embedded constraint here: long-running while true { ... } loops need some form of yielding or scheduler pumping, otherwise non-awaiting code can monopolize the CPU.
On RP2350 targets, core1 is optional for Swift concurrency. EmbeddedAsyncApp
starts core1 for the Swift concurrency scheduler after configure(with:) has
run and before setup() is awaited, unless you disable it:
static func configure(with configurator: inout Configurator) {
configurator.core1Enabled = false
}When core1 is disabled, Swift concurrency still works on core0. That gives apps an escape hatch for systems where core1 is better reserved for a tight time-sensitive flow, such as a dedicated control loop, protocol bridge, sampling routine, or other code that should not share a scheduler run loop. In that mode, you can launch and manage core1 yourself using the Pico SDK multicore APIs while allowing Swift async tasks, timers, continuations, and scheduler pumping to coexist on core0.
The scheduler does not launch core1 from task enqueue paths. Until
startMulticore() is called, Swift concurrency work stays on core0. If you are
not using EmbeddedAsyncApp, multicore startup is explicit. After your app has
initialized CPicoSDK and sealed any configuration, call:
ConcurrencyRuntime.startMulticore()Only call ConcurrencyRuntime.startMulticore() when core1 belongs to the Swift
concurrency scheduler. If you plan to use core1 for your own dedicated runtime,
do not call it.
CPicoSDK sizes the scheduler stacks during finalization. The prepared environment exports these variables to the finalizer:
export CPICOSDK_CORE0_STACK_SIZE_BYTES=8192
export CPICOSDK_CORE1_STACK_SIZE_BYTES=8192Set them before prepare-rp2xxx-environment runs, or uncomment/change them in
your project's build.sh. Values are byte counts. Core0 must be greater than
zero. Core1 may be set to zero. The same values are passed into both the SwiftPM
C/Swift build and the final CMake/link step, so stack sizing affects compiled
code and linker symbols consistently.
export CPICOSDK_CORE1_STACK_SIZE_BYTES=0When core1 stack size is zero, CPicoSDK compiles out the scheduler core1 stack
allocation and core1 launch path. Calling ConcurrencyRuntime.startMulticore()
then leaves the scheduler on core0; it does not start core1 and does not print a
warning.
The finalizer also makes the exported linker stack symbols match the actual scheduler stacks. Placement is selected from the requested sizes:
- If core0 is 4 KiB or smaller, core0 is placed in
SCRATCH_Y. - If core0 is larger than 4 KiB and no larger than 8 KiB, core0 uses the
contiguous
SCRATCH_X+SCRATCH_Yrange. In this mode core1 is placed in main RAM so core0 can keep the scratch banks as one bus-independent stack. - If core0 is larger than 8 KiB, core0 still ends at the top of
SCRATCH_Yand extends downward through both scratch banks into the top of main RAM. If core1 is also enabled, core1 is placed below core0 in main RAM and the heap is capped below both stacks. - When core0 is 4 KiB or smaller and core1 is enabled, core1 starts at the top
of
SCRATCH_X. If core1 is larger than 4 KiB, its lower bound extends into the top of main RAM and the heap is capped below it.
The exported symbols describe the selected real stack ranges:
__StackBottom / __StackTop core0 guarded stack
__StackOneBottom / __StackOneTop core1 scheduler stack
These symbols intentionally do not describe Pico SDK dummy stack reservations. Generic runtime code may use them as the canonical stack bounds for stack-limit checks and diagnostics.
CPU metrics follow the same active-core model. CPUStats.usageEvents() emits
reports for scheduler-managed cores. With core1 disabled or not yet started, the
combined stream only produces core0 reports. Per-core subscriptions such as
CPUStats.usageEvents(for: .core1) will not produce useful samples until core1
has been started for the scheduler.
CPU metrics are also compile-time gated by the CPUMetrics trait. Without that
trait, the metrics APIs return nil, even if concurrency itself is enabled.
CPicoSDK follows a versioning scheme that tracks the underlying Pico SDK version:
- Major.Minor versions match the Pico SDK version (e.g.,
2.2) - Patch version is calculated as:
(Pico SDK patch × 100) + CPicoSDK revision- This gives us 100 possible revisions for each Pico SDK patch version
- Allows for CPicoSDK-specific updates without changing the base SDK version
Example: When Pico SDK 2.2.1 is released, CPicoSDK will release:
2.2.100- Initial release tracking Pico SDK 2.2.12.2.101-2.2.199- Up to 99 additional CPicoSDK updates for the same base SDK
⚠️ Note: This versioning scheme prioritizes clarity about which Pico SDK version is bundled, but may introduce breaking changes between revisions. Always check release notes when updating.
CPicoSDK brings the power of Swift to embedded development on the RP2xxx microcontroller family. This project enables you to:
- 🚀 Use Pico SDK in Swift with full SwiftPM integration
- 🔧 Develop in VSCode with complete debugging support, SourceKit-LSP autocompletion, and one-click flashing
- 🎯 Target RP2xxx devices with a modern, type-safe language
- 💻 Cross-platform development on macOS and linux
- ⚡ Streamlined workflow with automated environment setup and dependency management
This project is built with the intention of streamlining the developer experience when building for embedded systems, making it as simple as developing any other Swift package.
- ✅ Fully supported: Pico 2 (RP2350A), Pico 2 W (RP2350A + CYW43439), Pimoroni Pico Plus 2 (RP2350B), Pimoroni Pico Plus 2 W (RP2350B + CYW43439)
⚠️ Limited support: Pico (RP2040), Pico W (RP2040 + CYW43439) - Concurrency is not available for this platform.- 🚧 In progress: More RP2xxx boards and configuration combinations are actively being developed
- 🔍 Debugging: Currently using
cortex-debugin VSCode, following the proven pico-vscode approach. LLDB support is being worked on but requires additional development time.
This matrix is temporary while we work on a more generic approach that ties traits to non-specific board configurations.
| Board | Combination | Platform Trait | Variant Trait | Radio Trait |
|---|---|---|---|---|
| Pico | pico |
Platform_RP2040 |
Variant_RP2040 |
Radio_None |
| Pico W | pico_w |
Platform_RP2040 |
Variant_RP2040 |
Radio_CYW43439 |
| Pico 2 | pico2 |
Platform_RP2350 |
Variant_RP2350A |
Radio_None |
| Pico 2 W | pico2_w |
Platform_RP2350 |
Variant_RP2350A |
Radio_CYW43439 |
| Pimoroni Pico Plus 2 | pimoroni_pico_plus2_rp2350 |
Platform_RP2350 |
Variant_RP2350B |
Radio_None |
| Pimoroni Pico Plus 2 W | pimoroni_pico_plus2_w_rp2350 |
Platform_RP2350 |
Variant_RP2350B |
Radio_CYW43439 |
- Full Pico SDK Access: Interact with hardware peripherals (GPIO, ADC, I2C, SPI, UART, PWM, DMA, etc.) directly from Swift
- WiFi & Networking: Built-in support for CYW43 wireless chip with lwIP and HTTP client capabilities
- Automated Environment Setup: One-command setup that downloads and configures all dependencies
- Integrated Debugging: Set breakpoints, inspect variables, and step through Swift code on real hardware
- UF2 Generation: Automatic binary generation ready for drag-and-drop flashing
- Smart Embedded Swift Runtime Linking: Automatically links extra embedded Swift archives such as
libswiftUnicodeDataTables.aonly when your build artifact actually references them - Build Configurations: Support for Debug, Release, RelWithDebInfo, and MinSizeRel builds
Install Swiftly and you're ready to go! Get it from swift.org
There is no Windows support at the moment. This is mostly due to lack of ways to test the platform. If interested please consider contributing support or opening/+1 an issue requesting it to track and quantify interest.
First, install required system dependencies:
sudo apt install build-essential libhidapi-hidraw0 libhidapi-libusb0Then install Swiftly from swift.org and ensure all dependencies mentioned at the end of the build script are installed.
Note: This project currently uses a very specific Swift version (
main-snapshot-2026-04-01) due to bugs that need resolution in newer versions. This requirement will be relaxed as upstream issues are addressed.
- Create your Swift package that depends on CPicoSDK:
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "MyPicoProject",
products: [
.library(name: "MyPicoProject", type: .static, targets: ["MyPicoProject"]),
],
dependencies: [
.package(
url: "https://github.com/gonzalolarralde/CPicoSDK", exact: "2.2.7",
traits: [.init(name: "Variant_RP2350A"), .init(name: "Radio_None")] // Pico 2
traits: [.init(name: "Variant_RP2350A"), .init(name: "Radio_CYW43439")] // Pico 2W
),
],
targets: [
.target(
name: "MyPicoProject",
dependencies: ["CPicoSDK"],
plugins: [.plugin(name: "PIOASM", package: "CPicoSDK")]
),
]
)- Write your Swift code:
import CPicoSDK
@main
struct App {
static func main() {
stdio_init_all()
gpio_init(25)
gpio_set_dir(25, true)
while true {
gpio_put(25, true)
sleep_ms(250)
gpio_put(25, false)
sleep_ms(250)
}
}
}- Build and flash (see the Example directory for a complete build. sh script):
./build.sh --flashThe build script will:
- Download and configure the Pico SDK, ARM toolchain, and all dependencies
- Set up VSCode with debugging configurations
- Build your Swift code
- Link it with the Pico SDK
- Generate UF2 and ELF binaries
- Optionally flash to your device
When you run the environment preparation step, CPicoSDK automatically generates:
.vscode/settings.json: VSCode workspace configuration.vscode/launch.json: Debug configurations for cortex-debugbuildServerConfig.json: SourceKit-LSP configuration for code completiontoolset.json: SwiftPM toolchain configuration.swift-version: Swiftly version pinning
This gives you a complete IDE experience with:
- Syntax highlighting and code completion
- Inline error checking
- Hardware debugging with breakpoints
- Variable inspection
- One-click build and flash
This section explains how CPicoSDK works internally and how the various pieces fit together.
CPicoSDK uses a hybrid approach combining SwiftPM plugins for Swift-native tooling with bash scripts for complex build orchestration. This approach balances maintainability with the flexibility needed for embedded cross-compilation workflows.
The configuration system is built around Configuration values collected by Configurator.
Current flow:
- App code adds configuration values via
configurator.configure(...). - Values are stored as
AnyConfigurationand type-erased throughUnsafeWeaklyTypedContainer. - During
sealConfiguration(), CPicoSDK executes configuration closures and allows those executions to append additional configurations. - Sealing uses a pending-ID loop: newly discovered configuration IDs are processed in later passes.
- Once sealing completes, the result is frozen into static storage (
Configurator.configurations) for runtime queries.
Important current behavior:
- Multiple instances under a configuration ID are all executed when that ID is processed.
- If execution appends a brand-new ID, it is picked up in a later pass of the same sealing operation.
- If execution appends additional instances under an ID that was already processed, those new instances are not re-processed in the current pass model.
Error model:
- Each configuration execution wraps typed errors into
ConfigurationError. - Sealing accumulates execution failures and returns them as
[ConfigurationError]. - Callers can then pass the returned errors to
handleConfigurationErrors, and execution continues according to that handling path.
This design intentionally favors deterministic staged setup over open-ended re-execution loops.
CPicoSDK allocator routing is currently configuration-driven and address-space based.
Core pieces:
Allocatoris itself aConfigurationthat registers function pointers/closures formalloc,calloc,realloc,free, and stats.AllocatorManagerowns a mutex-protected linked-list registry of allocators.- Address-space dispatch uses
Allocator.addressSpaceMaskto resolve which allocator owns a pointer. - SRAM allocator is always present as the built-in default.
- The PSRAM backend uses TLSF as its heap engine (
tlsf_create_with_pool,tlsf_malloc,tlsf_realloc,tlsf_free) for low-overhead dynamic allocation in external RAM. PSRAMAllocatorimplementation and QMI setup flow are a Swift reimplementation of SparkFun's Pico memory allocation library.
Registration flow:
- Feature configs (for example
PSRAMConfiguration) create/register anAllocatorduring execution. AllocatorManager.registerrejects overlapping masked address spaces.- Allocators are used by wrapped allocation entrypoints (
__wrap_malloc,__wrap_calloc,__wrap_realloc,__wrap_free).
Dispatch behavior:
- New allocations default to SRAM for plain malloc/calloc.
realloc/freechoose allocator by pointer address.- High-level typed APIs (for example explicit
.psramand.psramIfAvailableallocation modes) choose allocator policy before calling into the low-level wrappers.
Recent allocator integration updates:
freenow consistently resolves the owning allocator from the pointer address space before dispatching, so pointers allocated from PSRAM-backed allocators are released by the correct backend automatically.callocparticipates in the same allocator model, so zero-initialized allocations can be served by non-SRAM allocators when selected by higher-level allocation policy.- Together, this keeps allocation/deallocation symmetric across mixed memory spaces and avoids requiring callers to manually track allocator provenance.
Unsafe mutable pointer ergonomics:
- Because allocator selection is routed through CPicoSDK allocation wrappers,
UnsafeMutableRawPointerand typedUnsafeMutablePointerfamilies can use allocation APIs with memory-space intents (.psram,.psramIfAvailable) without changing deallocation call sites. - This gives consumers a seamless experience: choose memory space at allocation time, then use normal pointer deallocation paths while backend routing remains correct.
Concurrency and safety notes:
- Allocation wrappers use explicit locking/exception-level-aware reentrancy rules to mirror Pico SDK expectations.
- PSRAM allocator initialization remains guarded by a mutex-backed singleton path.
The env.json file is the central configuration that drives the entire build environment. It specifies:
- Toolchain versions: Swift snapshot, ARM GCC, CMake, Ninja
- SDK versions: Pico SDK, picotool, OpenOCD versions
- Paths: Where to find tools and dependencies (with variable substitution support like
${PICO_SDK_PATH}) - Base configuration: Default board, build type, SwiftPM triple, and base library selection
- Combinations: Per-board overrides and trait mappings (Variant/Radio) used to generate targets
Example:
{
"vars": {
"SWIFT_VERSION": "main-snapshot-2026-04-01",
"SDK_VERSION": "2.2.0",
"TOOLCHAIN_VERSION": "14_2_Rel1",
"BOARD": "pico2",
"IMPORTED_LIBS": "pico_stdlib,hardware_gpio",
"SWIFTPM_TRIPLE": "armv7em-none-none-eabi",
"BUILD_TYPE": "RelWithDebInfo"
},
"combinations": {
"pico2": {
"vars": {
"BOARD": "pico2",
"IMPORTED_LIBS_MORE": ""
},
"traits": ["Variant_RP2350A", "Radio_None"]
},
"pico2_w": {
"vars": {
"BOARD": "pico2_w",
"IMPORTED_LIBS_MORE": "pico_cyw43_arch,pico_cyw43_arch_lwip_poll,pico_lwip,pico_lwip_arch,pico_lwip_http"
},
"traits": ["Variant_RP2350A", "Radio_CYW43439"]
}
}
}This configuration is:
- Read by the PrepareEnvironmentPlugin to set up the environment (base vars + combinations)
- Resolved (variables expanded) and exported as bash environment variables
- Used by GenerateCPicoSDKPlugin to generate per-combination headers and
Package.swift - Consumed by FinalizeBinaryPlugin during the linking stage
The root build.sh script is specifically for CPicoSDK maintainers to regenerate the generated headers and Package.swift. Users consuming CPicoSDK as a dependency don't need to run it. Package.swift is generated from Package.swift.template, so edit the template instead.
The script:
- Cleans generated build artifacts and copies
Package.swift.templatetoPackage.swift - Locates the
swiftlyexecutable - Sets the
PICO_SDK_BUNDLE_PATH(defaults to~/.pico-sdk) - Invokes the
PrepareEnvironmentPluginwith flags to disable most side effects - Sources the generated preparation script to export environment variables
- Calls the
GenerateCPicoSDKPluginto generate per-combination headers and targets
This separation allows the header generation to be version-controlled while keeping the environment setup flexible.
Swift cannot directly consume hundreds of individual C header files efficiently in an embedded context. To solve this, CPicoSDK generates a compound header file per combination (e.g. CPicoSDK_pico2.h) that:
- Aggregates all relevant Pico SDK headers into a single file based on
IMPORTED_LIBS - Processes through the C preprocessor with the correct target architecture flags
- Resolves all macros, includes, and definitions into one cohesive header
- Wraps in a Swift module via a modulemap
Generation process (in Plugins/GenerateCPicoSDKPlugin/GenerateCPicoSDKPlugin.swift + Plugins/GenerateCPicoSDKPluginTool/CMakeHarness):
-
Create a source header that includes all requested Pico SDK headers:
#define __ARM_ARCH_8M_MAIN__ 1 #include <pico/stdlib.h> #include <hardware/gpio.h> // ... all other requested libraries
-
Use CMake to invoke the C preprocessor with the correct target flags
-
Generate
CPicoSDK_<combination>.hwith all macros expanded and definitions resolved -
Wrap it in a modulemap:
module _CPicoSDK_<combination> [system] { umbrella header "include/CPicoSDK_<combination>.h" export * } -
Copy to
Sources/_CPicoSDK_<combination>/where SwiftPM can find it
This approach:
- ✅ Provides fast compilation (single header vs. hundreds)
- ✅ Ensures correct macro expansion for the target architecture
- ✅ Works around SwiftPM limitations with embedded toolchains
- ❌ Requires regeneration when library selection or SDK version changes
After preprocessing, the generated compound header is passed through Plugins/GenerateCPicoSDKPlugin/PicoSDKHeaderFixer.swift before it is written into Sources/_CPicoSDK_<combination>/include/.
That fixer currently performs a few targeted transformations so the result imports more cleanly into Swift:
-
Normalize
_u(...)numeric macros Pico headers frequently emit numeric macros in the form:#define GPIO_FUNC_SIO _u(5)
These are rewritten to:
#define GPIO_FUNC_SIO 5u
-
Preserve importer-friendly hardware entrypoints Fixed-address hardware macros such as:
#define timer0_hw ((timer_hw_t *)TIMER0_BASE) #define nvic_hw ((nvic_hw_t *)(PPB_BASE + M33_NVIC_ISER0_OFFSET)) #define spi0 ((spi_inst_t *)spi0_hw)
are rewritten into
static ... * const ...declarations:static timer_hw_t * const timer0_hw = (timer_hw_t *)TIMER0_BASE; // ORIGINAL: #define timer0_hw ((timer_hw_t *)TIMER0_BASE) static nvic_hw_t * const nvic_hw = (nvic_hw_t *)(PPB_BASE + M33_NVIC_ISER0_OFFSET); // ORIGINAL: #define nvic_hw ((nvic_hw_t *)(PPB_BASE + M33_NVIC_ISER0_OFFSET)) static spi_inst_t * const spi0 = (spi_inst_t *)spi0_hw; // ORIGINAL: #define spi0 ((spi_inst_t *)spi0_hw)
This covers:
- direct base-address macros like
timer0_hw - base-plus-offset macros like
nvic_hw - casted handle aliases like
spi0anduart0
- direct base-address macros like
-
Rewrite simple hardware aliases with
__typeof__Some Pico macros are aliases or indexed aliases rather than direct casts:#define arm_cpu_hw m33_hw #define interp0_hw (&interp_hw_array[0]) #define pio0 pio0_hw
These are rewritten as:
static __typeof__(m33_hw) const arm_cpu_hw = m33_hw; // ORIGINAL: #define arm_cpu_hw m33_hw static __typeof__((&interp_hw_array[0])) const interp0_hw = (&interp_hw_array[0]); // ORIGINAL: #define interp0_hw (&interp_hw_array[0]) static __typeof__(pio0_hw) const pio0 = pio0_hw; // ORIGINAL: #define pio0 pio0_hw
__typeof__is used here so the fixer does not need a separate type-reconstruction pass for alias-only macros. -
Prefix the final header with
#pragma GCC system_headerThis keeps the generated C import quieter on the Swift side by treating it as system-header input.
The current fixups are intentionally narrow. They target hardware entrypoints and related aliases without attempting to rewrite every Pico macro. Function-like selector helpers such as PIO_INSTANCE(num) or SPI_NUM(spi) are left as macros unless there is a specific reason to expose them differently.
Separately from these textual fixups, maintainers may also annotate selected imported MMIO and peripheral handle types with API notes or header attributes such as swift_attr("~Copyable") when Swift should treat them as address-identified hardware views rather than ordinary copyable values.
Located at Plugins/FinalizeBinaryPluginTool/CMakeHarness/shims.c, this file provides POSIX compatibility shims for functions that the Swift runtime expects but aren't provided by the bare-metal ARM toolchain.
For example:
int posix_memalign(void **memptr, size_t alignment, size_t size) {
// Implementation using memalign from newlib
}These shims are compiled into the final binary during the finalization stage, ensuring Swift's memory management works correctly on bare metal.
CPicoSDK depends on PicoSDKDownloader, a separate package that replicates the pico-vscode model for downloading dependencies.
When you run the PrepareEnvironmentPlugin, it:
- Invokes the
pico-bootstrapcommand from PicoSDKDownloader - Downloads the Pico SDK, ARM toolchain, CMake, Ninja, picotool, and OpenOCD
- Extracts them to
~/.pico-sdk(orPICO_SDK_BUNDLE_PATH) - Verifies checksums and versions
This ensures:
- 🔒 Reproducible builds: Everyone gets the same tool versions
- 📦 Zero-configuration: No manual installation required
- 🌐 Offline-friendly: Downloaded tools are cached locally
CPicoSDK uses SwiftPM plugins to orchestrate the build and inspect generated artifacts:
Purpose: Set up the complete build environment
What it does:
- Merges base vars and combination overrides from
env.jsonwith user overrides - Resolves variable substitutions (e.g.,
${HOME},${PICO_SDK_PATH}) - Downloads dependencies via PicoSDKDownloader
- Generates
toolset.jsonfor the ARM cross-compiler - Creates
.swift-versionfor Swiftly - Writes VSCode settings and launch configurations
- Generates
buildServerConfig.jsonfor SourceKit-LSP - Outputs a bash script with exported environment variables and helper functions
Key files:
PrepareEnvironmentPlugin.swift: Main plugin logicEnv.swift: Environment modeling and combination resolutionResolvers.swift: Environment variable resolutionGenerators.swift: Config file generationExtensions.swift: Utilities (JSON parsing, async process execution)
Purpose: Generate per-combination compound headers and Package.swift
What it does:
- Reads
IMPORTED_LIBSplus combination overrides (e.g.IMPORTED_LIBS_MORE) - Creates a source header with the appropriate includes per combination
- Invokes CMake to preprocess the header with target-specific flags
- Wraps the result in a modulemap
- Copies to
Sources/_CPicoSDK_<combination>/ - Generates
Package.swiftfromPackage.swift.templatewith combination targets
Why it exists: SwiftPM doesn't support prebuild commands for header generation, so this must be run manually during development.
Purpose: Link Swift object files with Pico SDK and generate the final binary
What it does:
- Takes the compiled Swift
.ofiles from SwiftPM - Links them with Pico SDK libraries using the CMake harness
- Detects when the final link needs extra embedded Swift runtime archives and adds them automatically
- Embeds
.codeassetresources by passing generated name/path lists into the CMake harness - Runs as a native Swift plugin (no standalone shell build script)
- Includes
shims.cfor runtime compatibility - Generates both ELF (for debugging) and UF2 (for flashing) binaries
- Prints final artifact file sizes, including the raw
.binflash payload, UF2 transfer file size, and host ELF debug file size - Prints the CPicoSDK memory map report for the finalized ELF, including flash/static-RAM/heap/stack usage and ownership by source
- Optionally flashes to a connected device
Why it's necessary: SwiftPM can't directly produce firmware binaries - it generates object files that must be linked with the Pico SDK's startup code, linker scripts, and library implementations.
Purpose: Generate Swift accessors for target-local .codeasset files
What it does:
- Scans the consuming target's source files for
.codeassetresources - Generates an
Asset.<name>accessor for each file - Declares the objcopy-provided
_binary_<resource>_startand_binary_<resource>_endsymbols - Builds each accessor as an
UnsafeRawBufferPointerover those linker symbols - Emits a small sidecar Swift file with source metadata for the plugin output
The finalizer gathers the same resources and passes their names and absolute paths into the CMake harness. embed_resources.cmake stages each file under its resource name before running objcopy; this keeps the generated linker symbols predictable and aligned with the names generated by AssetCompilerTool.
Embedded resource objects are created with:
${CMAKE_OBJCOPY} -I binary -O elf32-littlearm -B arm \
--rename-section .data=.rodata.cpicosdk_assets,alloc,load,readonly,data,contents \
<resource-name> <resource-object>The section rename is important. Plain objcopy -I binary emits a writable .data section, which the Pico linker treats as initialized SRAM. Renaming it to a read-only section keeps the resource bytes in flash while still exposing normal linker symbols to Swift.
Purpose: Report flash/RAM usage for an existing finalized ELF without building or flashing.
Run it from a prepared consumer package after ./build.sh or after the
finalizer has produced an ELF:
swift package --disable-sandbox memory-map-reportThe plugin looks for .env_prep first, then uses SWIFTPM_PRODUCT,
SWIFTPM_TRIPLE, SWIFT_BUILD_TYPE, BOARD, and the prepared ARM toolchain to
find the existing ELF, linker map, arm-none-eabi-size, and
arm-none-eabi-nm. It does not invoke SwiftPM build or CMake. If no ELF exists,
it prints a build-first message.
You can override artifact paths when inspecting another output:
swift package --disable-sandbox memory-map-report \
.build/armv7em-none-none-eabi/release/Example.elf
swift package --disable-sandbox memory-map-report \
--elf .build/armv7em-none-none-eabi/release/Example.elf \
--map .build/plugins/FinalizeBinaryPlugin/outputs/CMakeHarness/build_pico2/Example.elf.mapWhen only an ELF path is provided, the tool first checks the ELF directory for a
matching sibling map file, such as Example.elf.map or Example.map. A second
positional path or --map can be used to select a different linker map.
The report starts with a small summary in KiB, then shows ownership grouped by user program, CPicoSDK, CPicoConcurrency, Pico SDK, Swift runtime, and C/C++ runtime. Ownership comes from linker-map object paths, so it is most useful when the finalizer map is present. Without a map, the command still reports ELF sections, stack symbols, and heap headroom, but source ownership is unavailable.
There is now a first pass at Swift Concurrency support in a separate target:
CPicoConcurrency
This is intentionally not folded into the base CPicoSDK product yet.
Reasons:
- the implementation is still experimental
- the current goal is to get a mostly functional baseline on device
- the limitations of Embedded Swift concurrency on RP2350 are still being mapped out
- no concurrency code should be referenced unless the package explicitly opts into it
Current implementation shape:
- the toolchain-provided
libswift_Concurrency.ais used - multicore concurrency depends on a Swift build/runtime that supports the
embedded threading hooks used by
ConcurrencyShims - the low-level runtime hook exports remain in Sources/ConcurrencyShims/ConcurrencyShims.c
- the scheduling backend now lives in Sources/CPicoConcurrency/RuntimeScheduler.swift
- that scheduler uses Pico SDK
async_contextrather than a handwritten queue loop - on Linux, the build can fall back to vendored embedded
_Concurrencyartifacts under Vendor/EmbeddedSwiftRuntime when the toolchain packaging is incomplete
Maintainer note: the concurrency path is not using an arbitrary stock embedded
Swift runtime in isolation. Multicore scheduler support relies on a modified
Swift build with embedded threading support, plus the matching shim symbols in
Sources/ConcurrencyShims. If SWIFT_VERSION is changed in env.json, and
concurrency support is expected to keep working, the replacement Swift snapshot
must include the same threading/runtime support or an equivalent update to the
shims and vendored fallback artifacts. A plain snapshot that can compile
Embedded Swift code is not enough to prove multicore concurrency is supported.
The current state is best described as:
- generic embedded
async/awaitworks - the implementation is not considered stable yet
- custom executors are still not viable on this embedded runtime
- exploratory pieces like
@CPU0Actorand@CPU1Actorexist as future-facing scaffolding, not as working CPU pinning support
The Linux fallback is intentionally versioned by SWIFT_VERSION. The build only uses a vendored runtime directory when the active Swift snapshot has a matching directory name, which avoids silently mixing concurrency runtime files from different toolchains.
Concurrency support for RP2040 is not available yet.
The Example is expected to keep representing an important behavioral constraint:
- a tight
while true { ... }loop still needs some kind of yielding or scheduler pumping so non-awaiting code does not lock the CPU
For the detailed investigation history, current architecture, and known limitations, see:
While the plugins are written in Swift for type safety and integration with SwiftPM, many complex operations are delegated to bash scripts:
Why bash?
- ✅ Direct access to CMake, GNU toolchain, and POSIX utilities
- ✅ Easier to debug and iterate during development
- ✅ Familiar to embedded developers
- ✅ Handles complex multi-tool pipelines naturally
Why not all bash?
- ❌ No access to SwiftPM's package graph and dependency information
- ❌ No type safety or structured error handling
- ❌ Harder to generate JSON and structured config files
The hybrid approach lets us:
- Use Swift for orchestration, environment resolution, and file generation
- Use bash for invoking CMake, gcc-arm-none-eabi, picotool, etc.
- Keep
build.shfiles simple and maintainable - Gradually migrate more logic to Swift over time (see TODOs)
CPicoSDK/
├── Package.swift # Generated package manifest with traits
├── Package.swift.template # Template used to generate Package.swift
├── env.json # Central configuration
├── generator_vars.json # Available hardware options and libraries
├── toolset.json # Generated by PrepareEnvironmentPlugin
├── build.sh # Maintainer tool for header + manifest regeneration
├── Sources/
│ ├── _CPicoSDK_pico2/ # Generated compound header (git-tracked)
│ │ ├── include/CPicoSDK_pico2.h # The compound header
│ │ └── module.modulemap # Swift module wrapper
│ ├── _CPicoSDK_pico2_w/ # Generated compound header (git-tracked)
│ ├── _CPicoSDK_pimoroni_pico_plus2_rp2350/
│ ├── _CPicoSDK_pimoroni_pico_plus2_w_rp2350/
│ └── CPicoSDK/ # Swift wrapper target
├── Plugins/
│ ├── PrepareEnvironmentPlugin/
│ │ ├── PrepareEnvironmentPlugin.swift
│ │ ├── Env.swift
│ │ ├── Generators.swift
│ │ ├── Resolvers.swift
│ │ ├── Extensions.swift
│ │ └── BuildType.swift
│ ├── GenerateCPicoSDKPlugin/
│ │ ├── GenerateCPicoSDKPlugin.swift # Header generation logic
│ │ ├── Env.swift
│ │ └── Extensions.swift
│ ├── GenerateCPicoSDKPluginTool/
│ │ └── CMakeHarness/
│ │ └── CMakeLists.txt # CMake project for preprocessing
│ ├── FinalizeBinaryPlugin/
│ │ ├── FinalizeBinaryPlugin.swift # Linking and UF2 generation
│ │ ├── Env.swift
│ │ └── Extensions.swift
│ └── FinalizeBinaryPluginTool/
│ └── CMakeHarness/
│ ├── CMakeLists.txt # CMake project for linking
│ └── shims.c # POSIX compatibility layer
└── Example/
├── Package.swift # Example project consuming CPicoSDK
├── build.sh # User-facing build script
└── Sources/
└── Example/
└── main.swift
For CPicoSDK maintainers:
- Modify
env.json,generator_vars.json, orPackage.swift.template - Run
./build.shto regeneratePackage.swiftand the compound headers - Commit the updated
Package.swiftandSources/_CPicoSDK_*outputs - Tag a new version
For users consuming CPicoSDK:
- Add CPicoSDK as a dependency
- Run
./build.sh(which calls the plugins) - Develop Swift code with full IDE support
- Flash and debug on hardware
CPicoSDK includes a physical-device test harness for maintainer and contributor
validation. Tests live under Tests/Device/**/*.swift and are run from the
repository root with:
swift package --disable-sandbox test-in-device --allow-writing-to-package-directory --allow-network-connections allDevice execution defaults to local OpenOCD, preserving the original device-test
command behavior. HardwareRunner configuration is not parsed or validated for
local, --list, or --build-only runs.
Pass --remote to dispatch a physical run to HardwareRunner, and configure it
with:
export HARDWARE_RUNNER_URL="http://hardware-runner.example:8080"
export HARDWARE_RUNNER_TOKEN="hr_..."
export HARDWARE_RUNNER_PROFILE_ID="00000000-0000-0000-0000-000000000000"
# Optional when the token/profile can access only one matching pool:
export HARDWARE_RUNNER_POOL_ID="00000000-0000-0000-0000-000000000000"
export HARDWARE_RUNNER_CAPABILITIES="rp2350,cmsis-dap,rtt"
# Optional; defaults to "rtt":
export HARDWARE_RUNNER_CAPTURE_CHANNEL="rtt"The equivalent command-line options are --hardware-runner-url,
--hardware-runner-token, --hardware-runner-profile-id,
--hardware-runner-pool-id, --hardware-runner-capabilities, and
--hardware-runner-capture-channel. Command-line values override environment
values. Prefer the environment for the bearer token so it does not appear in
process listings. These values are required or validated only for a physical
--remote execution.
For example, run one test remotely with:
swift package --disable-sandbox test-in-device --remote --filter HelloRTT \
--allow-writing-to-package-directory --allow-network-connections allCPICOSDK_DEVICE_TEST_EXECUTION=local|remote provides the environment
equivalent; --execution local|remote and the --local compatibility alias are
also accepted. Explicit command-line selection overrides the environment.
--list and --build-only never require HardwareRunner credentials, even when
remote execution is selected in the environment.
The outer --disable-sandbox is required for direct probe access in local mode.
The network permission is required for HardwareRunner and may also be needed to
download build dependencies.
Device tests are self-contained Swift files with a leading //% metadata block
followed by top-level no-argument test functions:
//% -- test yaml
//% name: HelloRTT
//% timeout: 5s
//% buildType: RelWithDebInfo
//% traits:
//% add: [StdIO_RTT]
//% expect:
//% stdout:
//% equals: "hello\n"
//% durationMs:
//% min: 0
//% max: 5000
//% -----------
import CPicoSDK
func helloRTT() throws {
print("hello")
try deviceExpect(1 + 1 == 2)
}buildType is optional and defaults to Debug. Supported values are Debug,
Release, RelWithDebInfo, and MinSizeRel; benchmark-style tests should
usually use Release.
Useful commands:
# List discovered device tests
swift package --disable-sandbox test-in-device --list --allow-writing-to-package-directory --allow-network-connections all
# Generate and build all device-test firmware without flashing/running it
swift package --disable-sandbox test-in-device --build-only --allow-writing-to-package-directory --allow-network-connections all
# Run one test by name
swift package --disable-sandbox test-in-device --filter HelloRTT --allow-writing-to-package-directory --allow-network-connections all
# Generate and build one device-test firmware without flashing/running it
swift package --disable-sandbox test-in-device --filter HelloRTT --build-only --allow-writing-to-package-directory --allow-network-connections all
# Override test metadata and build all device-test firmware as Release
swift package --disable-sandbox test-in-device --build-type Release --build-only --allow-writing-to-package-directory --allow-network-connections all
# Print a memory map report for each finalized device-test ELF
swift package --disable-sandbox test-in-device --filter HelloRTT --build-only --memory-map-report --allow-writing-to-package-directory --allow-network-connections allUse --build-only when you want to verify that device tests still generate,
compile, and link without programming hardware. Add --memory-map-report to
print the memory report for each finalized test ELF. Full device runs require a
connected target and will program and reset it.
The harness reuses a generated SwiftPM package per target in the plugin work
directory and builds it against the local CPicoSDK checkout. In remote mode it
first stages all selected firmware, uploads each unique ELF, and submits one
fair HardwareRunner job with one work item per selected test. --passes N
sets that work item's runs value to N; HardwareRunner returns a separately
attributed capture for each 1-based run index, and the harness parses every run
with the same per-pass labels, failures, and score statistics as local mode.
For each successful run it also prints a machine-readable HardwareRunner
attribution line containing the job, work-item, caller-item, run-index, and
attempt identifiers.
Repeated runs reuse the test's immutable object and bundle.
Independent items may fan out across compatible devices; one connected device
runs them sequentially through its mutex. Job submission uses one idempotency
key and retries transient transport, HTTP 408/429, an equivalent in-progress
idempotency claim, and server failures three times after the initial attempt.
Once submitted, temporary polling failures do not abandon the queued hardware
work. The client rejects batches above HardwareRunner's 10,000-logical-run
per-job limit before uploading any artifacts.
Remote output identifies the durable HardwareRunner job as soon as dispatch succeeds. While the job is queued, the harness reports HardwareRunner's best-effort device-start estimate without calculating an independent local ETA; repeated estimates are coalesced into useful time buckets to keep CI logs readable. It reports again when HardwareRunner starts the job. Older servers that do not provide queue estimates remain supported and are reported as such.
HardwareRunner reports infrastructure outcomes only. The harness downloads the
authoritative raw RTT stream and feeds it through the same
DeviceResultParser and controller-side expectations used by local OpenOCD
execution. Result lines report build, queue (when remote), program, host
run/capture time, device-reported time, UF2 firmware size, and per-function
pass/fail status.
One source file can define multiple build variants with an optional alts:
metadata block. Each entry expands into a separate generated device test named
<name>-<altName>. Alternatives can add/remove package traits and provide
Swift compile definitions for #if-guarded test behavior:
//% alts:
//% - name: baseline
//% swiftDefines: [BENCH_BASELINE]
//% - name: cpuMetrics
//% traits:
//% add: [CPUMetrics]
//% swiftDefines: [BENCH_CPU_METRICS]If a test file contains any async top-level test function, the harness
automatically uses the async runner and links CPicoConcurrency; sync functions
in the same file still run normally. Swift Testing syntax (import Testing,
@Test, #expect) is intentionally not supported by this embedded harness yet;
use deviceExpect(...) for device-side assertions.
See TODO.md for detailed tasks, including:
- Moving more bash logic into Swift plugins
- Supporting multiple boards and configurations via traits
- Migrating from compound header to per-library targets
- LLDB integration for debugging
- Better handling of build types and debug symbols
Contributions are welcome! Key areas of interest:
🎯 Adding support for more RP2xxx boards (Pico, Pico W, Pico 2, etc.)Done! Thanks @BastianKusserow- 🐛 Improving LLDB integration for debugging
- ⚡ Optimizing the header generation process
- 🧪 Adding test coverage
This project builds upon the excellent work of:
- The Raspberry Pi Pico SDK team
- The pico-vscode extension developers
- The Swift Embedded community
- TLSF — Two-Level Segregated Fit allocator, BSD License, written by Matthew Conte (matt@baisoku.org). Used as the heap engine for PSRAM-backed dynamic memory allocation.
- sparkfun-pico — MIT License, Copyright (c) 2024 SparkFun Electronics. The
PSRAMAllocatorQMI setup and PSRAM initialization flow are heavily inspired by this library.
Happy embedded Swift hacking! 🚀