Add embedded unittesting framework

Change-Id: Ie0b9fb17baba5acde9f491b8b0360005152ddb4a
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/maize/+/256319
Reviewed-by: Travis Geiselbrecht <travisg@google.com>
Commit-Queue: Erik Gilling <konkers@google.com>
Lint: Lint 🤖 <android-build-ayeaye@system.gserviceaccount.com>
diff --git a/.bazelrc b/.bazelrc
index 5a09641..c3dc1ee 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -17,22 +17,28 @@
 build --output_groups=+clippy_checks
 
 # Enforce rustfmt formatting
-# TODO - konkers: Fix rustfmt
-# build --aspects=@rules_rust//rust:defs.bzl%rustfmt_aspect
-# build --output_groups=+rustfmt_checks
+build --aspects=@rules_rust//rust:defs.bzl%rustfmt_aspect
+build --output_groups=+rustfmt_checks
 
 # TODO - konkers: fix upstream stable toolchains
-build --@@rules_rust+//rust/toolchain/channel=nightly
+common --@@rules_rust+//rust/toolchain/channel=nightly
 
-build:qemu-microbit --platforms=//target/qemu:microbit
+common --@pigweed//pw_log/rust:pw_log_backend=@pigweed//pw_log/rust:pw_log_backend_println
+
+# Clippy broken with embedded tests
+build:qemu-microbit --output_groups=-clippy_checks
+build:qemu-microbit --platforms=//target/qemu:microbit --output_groups=-clippy_checks
 run:qemu-microbit --run_under="@qemu//:qemu-system-arm \
 -cpu cortex-m0 \
 -machine microbit \
 -nographic \
 -semihosting-config \
 enable=on,target=native \
+-serial mon:stdio \
 -kernel "
 
+# Clippy broken with embedded tests
+build:qemu-lm3s6965evb --output_groups=-clippy_checks
 build:qemu-lm3s6965evb --platforms=//target/qemu:lm3s6965evb
 run:qemu-lm3s6965evb --run_under="@qemu//:qemu-system-arm \
 -cpu cortex-m3 \
@@ -40,6 +46,15 @@
 -nographic \
 -semihosting-config \
 enable=on,target=native \
+-serial mon:stdio \
+-kernel "
+test:qemu-lm3s6965evb --run_under="@qemu//:qemu-system-arm \
+-cpu cortex-m3 \
+-machine lm3s6965evb \
+-nographic \
+-semihosting-config \
+enable=on,target=native \
+-serial mon:stdio \
 -kernel "
 
 # Remote execution config definitions
diff --git a/MODULE.bazel b/MODULE.bazel
index 612b179..aa83822 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -59,6 +59,11 @@
     dev_dependency = True,
 )
 
+# Disable rules_rust toolchains
+rust = use_extension("//rust:extensions.bzl", "rust")
+rust.toolchain(versions = [])
+use_repo(rust, "rust_toolchains")
+
 pw_rust = use_extension("@pigweed//pw_toolchain/rust:extensions.bzl", "pw_rust")
 pw_rust.toolchain(cipd_tag = "rust_revision:bf9c7a64ad222b85397573668b39e6d1ab9f4a72")
 use_repo(pw_rust, "pw_rust_toolchains")
diff --git a/build/BUILD.bazel b/build/BUILD.bazel
index 8499b5d..443fbb6 100644
--- a/build/BUILD.bazel
+++ b/build/BUILD.bazel
@@ -13,8 +13,3 @@
 # the License.
 
 package(default_visibility = ["//visibility:public"])
-
-label_flag(
-    name = "linker_script",
-    build_setting_default = "@platforms//:incompatible",
-)
diff --git a/build/crates_io/crates_no_std/Cargo.lock b/build/crates_io/crates_no_std/Cargo.lock
index 04c6f50..ba09986 100644
--- a/build/crates_io/crates_no_std/Cargo.lock
+++ b/build/crates_io/crates_no_std/Cargo.lock
@@ -3,6 +3,12 @@
 version = 3
 
 [[package]]
+name = "autocfg"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
+
+[[package]]
 name = "bare-metal"
 version = "0.2.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -65,6 +71,8 @@
  "cortex-m",
  "cortex-m-rt",
  "cortex-m-semihosting",
+ "embedded-io",
+ "intrusive-collections",
  "panic-halt",
 ]
 
@@ -79,6 +87,30 @@
 ]
 
 [[package]]
+name = "embedded-io"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
+
+[[package]]
+name = "intrusive-collections"
+version = "0.9.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "189d0897e4cbe8c75efedf3502c18c887b05046e59d28404d4d8e46cbc4d1e86"
+dependencies = [
+ "memoffset",
+]
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
 name = "nb"
 version = "0.1.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/build/crates_io/crates_no_std/Cargo.toml b/build/crates_io/crates_no_std/Cargo.toml
index d149a2f..b901aef 100644
--- a/build/crates_io/crates_no_std/Cargo.toml
+++ b/build/crates_io/crates_no_std/Cargo.toml
@@ -24,6 +24,8 @@
 cortex-m = "0.7.7"
 cortex-m-rt = "0.7.5"
 cortex-m-semihosting = "0.5.0"
+embedded-io = "0.6.1"
+intrusive-collections = { version = "0.9.7", default-features = false }
 panic-halt = "1.0.0"
 
 [features]
diff --git a/build/crates_io/crates_std/Cargo.lock b/build/crates_io/crates_std/Cargo.lock
index 43a15a4..36daa3d 100644
--- a/build/crates_io/crates_std/Cargo.lock
+++ b/build/crates_io/crates_std/Cargo.lock
@@ -3,6 +3,12 @@
 version = 3
 
 [[package]]
+name = "autocfg"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
+
+[[package]]
 name = "bare-metal"
 version = "0.2.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -65,6 +71,8 @@
  "cortex-m",
  "cortex-m-rt",
  "cortex-m-semihosting",
+ "embedded-io",
+ "intrusive-collections",
  "nom",
  "panic-halt",
  "proc-macro2",
@@ -83,12 +91,36 @@
 ]
 
 [[package]]
+name = "embedded-io"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
+
+[[package]]
+name = "intrusive-collections"
+version = "0.9.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "189d0897e4cbe8c75efedf3502c18c887b05046e59d28404d4d8e46cbc4d1e86"
+dependencies = [
+ "memoffset",
+]
+
+[[package]]
 name = "memchr"
 version = "2.7.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
 
 [[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
 name = "minimal-lexical"
 version = "0.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/build/crates_io/crates_std/Cargo.toml b/build/crates_io/crates_std/Cargo.toml
index 1c8a336..4bf12ff 100644
--- a/build/crates_io/crates_std/Cargo.toml
+++ b/build/crates_io/crates_std/Cargo.toml
@@ -24,6 +24,8 @@
 cortex-m = "0.7.7"
 cortex-m-rt = "0.7.5"
 cortex-m-semihosting = "0.5.0"
+embedded-io = "0.6.1"
+intrusive-collections = "0.9.7"
 nom = "7.1.3"
 panic-halt = "1.0.0"
 proc-macro2 = "1.0.92"
diff --git a/build/crates_io/rust_crates/aliases.bzl b/build/crates_io/rust_crates/aliases.bzl
index 7151e53..d83e57e 100644
--- a/build/crates_io/rust_crates/aliases.bzl
+++ b/build/crates_io/rust_crates/aliases.bzl
@@ -59,6 +59,34 @@
   )
 
   native.alias (
+    name = "embedded-io",
+    target_compatible_with = select({
+      ":no_std": [],
+      ":std": [],
+      "//conditions:default": ["@platforms//:incompatible"],
+    }),
+    actual = select({
+      ":no_std": "@crates_no_std//:embedded-io",
+      ":std": "@crates_std//:embedded-io",
+    }),
+    visibility = ["//visibility:public"],
+  )
+
+  native.alias (
+    name = "intrusive-collections",
+    target_compatible_with = select({
+      ":no_std": [],
+      ":std": [],
+      "//conditions:default": ["@platforms//:incompatible"],
+    }),
+    actual = select({
+      ":no_std": "@crates_no_std//:intrusive-collections",
+      ":std": "@crates_std//:intrusive-collections",
+    }),
+    visibility = ["//visibility:public"],
+  )
+
+  native.alias (
     name = "nom",
     target_compatible_with = select({
       ":std": [],
diff --git a/build/test/rust/BUILD.bazel b/build/test/rust/BUILD.bazel
index ffc8769..843054d 100644
--- a/build/test/rust/BUILD.bazel
+++ b/build/test/rust/BUILD.bazel
@@ -32,13 +32,13 @@
     name = "embedded_hello",
     srcs = ["embedded_hello.rs"],
     edition = "2021",
-    linker_script = "//build:linker_script",
     target_compatible_with = select({
         "@pigweed//pw_build/constraints/chipset:lm3s6965evb": [],
         "@pigweed//pw_build/constraints/chipset:nrf52833": [],
         "//conditions:default": ["@platforms//:incompatible"],
     }),
     deps = [
+        "//target:linker_script",
         "@rust_crates//:cortex-m",
         "@rust_crates//:cortex-m-rt",
         "@rust_crates//:cortex-m-semihosting",
diff --git a/lib/unittest/BUILD.bazel b/lib/unittest/BUILD.bazel
new file mode 100644
index 0000000..4183b61
--- /dev/null
+++ b/lib/unittest/BUILD.bazel
@@ -0,0 +1,117 @@
+# Copyright 2024 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+
+load("@pigweed//pw_build:pw_linker_script.bzl", "pw_linker_script")
+load("@rules_rust//rust:defs.bzl", "rust_library", "rust_proc_macro", "rust_test")
+load("@pigweed//pw_build:compatibility.bzl", "incompatible_with_mcu")
+
+package(default_visibility = ["//visibility:public"])
+
+rust_proc_macro(
+    name = "unittest_proc_macro",
+    srcs = [
+        "unittest_proc_macro.rs",
+    ],
+    visibility = ["//visibility:public"],
+    deps = [
+        "@rust_crates//:proc-macro2",
+        "@rust_crates//:quote",
+        "@rust_crates//:syn",
+    ],
+)
+
+rust_library(
+    name = "unittest",
+    srcs = ["unittest.rs"],
+    proc_macro_deps = [
+        ":unittest_proc_macro",
+    ],
+    visibility = ["//visibility:public"],
+    deps = [
+        ":unittest_core",
+        ":unittest_runner",
+    ],
+)
+
+rust_library(
+    name = "unittest_core",
+    srcs = ["unittest_core.rs"],
+    target_compatible_with = select({
+        # The intrusive collections crate uses atomics which are not available
+        # on the M0.
+        "@pigweed//pw_build/constraints/chipset:nrf52833": ["@platforms//:incompatible"],
+        "//conditions:default": [],
+    }),
+    visibility = ["//visibility:public"],
+    deps = [
+        "@pigweed//pw_bytes/rust:pw_bytes",
+        "@rust_crates//:intrusive-collections",
+    ],
+)
+
+rust_library(
+    name = "unittest_runner_host",
+    srcs = ["unittest_runner_host.rs"],
+    crate_name = "unittest_runner",
+    target_compatible_with = incompatible_with_mcu(),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":unittest_core",
+        "@pigweed//pw_log/rust:pw_log",
+    ],
+)
+
+rust_library(
+    name = "unittest_runner_cortex_m",
+    srcs = ["unittest_runner_cortex_m.rs"],
+    crate_name = "unittest_runner",
+    target_compatible_with = select({
+        "@pigweed//pw_build/constraints/arm:cortex-m0": [],
+        "@pigweed//pw_build/constraints/arm:cortex-m3": [],
+        "//conditions:default": ["@platforms//:incompatible"],
+    }),
+    deps = [
+        ":unittest_core",
+        "@pigweed//pw_log/rust:pw_log",
+        "@rust_crates//:cortex-m-rt",
+        "@rust_crates//:cortex-m-semihosting",
+        "@rust_crates//:panic-halt",
+    ],
+)
+
+rust_library(
+    name = "unittest_test",
+    srcs = ["unittest_test.rs"],
+    visibility = ["//visibility:public"],
+)
+
+rust_test(
+    name = "unittest_test_test",
+    crate = ":unittest_test",
+    use_libtest_harness = False,
+    visibility = ["//visibility:public"],
+    deps = [
+        ":unittest",
+        "//target:linker_script",
+        "@pigweed//pw_log/rust:pw_log",
+        "@rust_crates//:cortex-m-rt",
+        "@rust_crates//:cortex-m-semihosting",
+        "@rust_crates//:panic-halt",
+    ],
+)
+
+label_flag(
+    name = "unittest_runner",
+    build_setting_default = ":unittest_runner_host",
+)
diff --git a/lib/unittest/unittest.rs b/lib/unittest/unittest.rs
new file mode 100644
index 0000000..2aaa9da
--- /dev/null
+++ b/lib/unittest/unittest.rs
@@ -0,0 +1,4 @@
+#![no_std]
+pub use unittest_core::*;
+pub use unittest_proc_macro::*;
+pub use unittest_runner::*;
diff --git a/lib/unittest/unittest_core.rs b/lib/unittest/unittest_core.rs
new file mode 100644
index 0000000..4e3fa03
--- /dev/null
+++ b/lib/unittest/unittest_core.rs
@@ -0,0 +1,101 @@
+#![no_std]
+
+use core::ptr::addr_of_mut;
+use intrusive_collections::{intrusive_adapter, LinkedList, LinkedListLink};
+
+pub use pw_bytes;
+
+intrusive_adapter!(pub TestDescAndFnAdapter<'a> = &'a TestDescAndFn: TestDescAndFn { link: LinkedListLink });
+
+static mut TEST_LIST: Option<LinkedList<TestDescAndFnAdapter>> = None;
+
+// All accesses to test list go through this function.  This gives us a
+// single point of ownership of TEST_LIST and keeps us from leaking references
+// to it.
+fn access_test_list<F>(callback: F)
+where
+    F: FnOnce(&mut LinkedList<TestDescAndFnAdapter>),
+{
+    // Safety: Tests are single threaded for now.  This assumption needs to be
+    // revisited.
+    let test_list: &mut Option<LinkedList<TestDescAndFnAdapter>> =
+        unsafe { addr_of_mut!(TEST_LIST).as_mut().unwrap_unchecked() };
+    let list = test_list.get_or_insert_with(|| LinkedList::new(TestDescAndFnAdapter::new()));
+    callback(list)
+}
+
+pub fn add_test(test: &'static mut TestDescAndFn) {
+    access_test_list(|test_list| test_list.push_back(test))
+}
+
+pub fn for_each_test<F>(mut callback: F)
+where
+    F: FnMut(&TestDescAndFn),
+{
+    access_test_list(|test_list| {
+        for test in test_list.iter() {
+            callback(test);
+        }
+    });
+}
+
+pub struct TestError {
+    pub file: &'static str,
+    pub line: u32,
+    pub message: &'static str,
+}
+
+pub type Result<T> = core::result::Result<T, TestError>;
+
+pub enum TestFn {
+    StaticTestFn(fn() -> Result<()>),
+}
+
+pub struct TestDesc {
+    pub name: &'static str,
+}
+
+pub struct TestDescAndFn {
+    pub desc: TestDesc,
+    pub test_fn: TestFn,
+    pub link: LinkedListLink,
+}
+
+impl TestDescAndFn {
+    pub const fn new(desc: TestDesc, test_fn: TestFn) -> Self {
+        Self {
+            desc,
+            test_fn,
+            link: LinkedListLink::new(),
+        }
+    }
+}
+
+// We're marking these as send and sync so that we can declare statics with.
+// them.  They're not actually Send and Sync because they contain linked list
+// pointers but in practice tests are single threaded and these are never sent
+// between threads.
+//
+// A better pattern here must be worked out with intrusive lists of static data
+// (for statically declared threads for instance) so we'll revisit this later.
+unsafe impl Send for TestDescAndFn {}
+unsafe impl Sync for TestDescAndFn {}
+
+#[macro_export]
+macro_rules! assert_eq {
+    ($a:expr, $b:expr) => {
+        if $a != $b {
+            return Err(unittest::TestError {
+                file: file!(),
+                line: line!(),
+                message: unittest::pw_bytes::concat_static_strs!(
+                    "assert_eq!(",
+                    stringify!($a),
+                    ", ",
+                    stringify!($b),
+                    ") failed"
+                ),
+            });
+        }
+    };
+}
diff --git a/lib/unittest/unittest_proc_macro.rs b/lib/unittest/unittest_proc_macro.rs
new file mode 100644
index 0000000..a2ac9ea
--- /dev/null
+++ b/lib/unittest/unittest_proc_macro.rs
@@ -0,0 +1,40 @@
+use proc_macro::TokenStream;
+use quote::{format_ident, quote};
+use syn::{parse_macro_input, ItemFn};
+
+#[proc_macro_attribute]
+pub fn test(_attr: TokenStream, item: TokenStream) -> TokenStream {
+    let item: ItemFn = parse_macro_input!(item as ItemFn);
+    let fn_ident = item.sig.ident.clone();
+    let fn_name = item.sig.ident.to_string();
+    let ctor_fn_ident = format_ident!("__mz_unittest_ctor_fn_{}__", fn_name);
+    let ctor_ident = format_ident!("__mz_unittest_ctor_{}__", fn_name);
+    let desc_ident = format_ident!("__MZ_UNITTEST_DESC_{}__", fn_name.to_uppercase());
+    quote! {
+        static mut #desc_ident: unittest::TestDescAndFn = unittest::TestDescAndFn::new(
+            unittest::TestDesc{
+                name: #fn_name,
+            },
+             unittest::TestFn::StaticTestFn(#fn_ident),
+        );
+
+        extern "C" fn #ctor_fn_ident() -> usize {
+            use core::ptr::addr_of_mut;
+            // Safety: We're only ever mutating this at constructor time which
+            // is single threaded.
+            let desc = unsafe { addr_of_mut!(#desc_ident).as_mut().unwrap_unchecked() };
+            unittest::add_test(desc);
+            0
+        }
+
+        #[used]
+        #[cfg_attr(target_os = "linux", link_section = ".init_array")]
+        #[cfg_attr(target_os = "none", link_section = ".init_array")]
+        #[cfg_attr(target_vendor = "apple", link_section = "__DATA,__mod_init_func")]
+        #[cfg_attr(windows, link_section = ".CRT$XCU")]
+        static #ctor_ident: extern "C" fn() -> usize = #ctor_fn_ident;
+
+        #item
+    }
+    .into()
+}
diff --git a/lib/unittest/unittest_runner_cortex_m.rs b/lib/unittest/unittest_runner_cortex_m.rs
new file mode 100644
index 0000000..de96c47
--- /dev/null
+++ b/lib/unittest/unittest_runner_cortex_m.rs
@@ -0,0 +1,63 @@
+#![no_std]
+#![feature(type_alias_impl_trait)]
+
+use panic_halt as _;
+
+use cortex_m_rt::entry;
+use cortex_m_semihosting::debug;
+use pw_log::{error, info};
+
+// #[no_mangle]
+// pub extern "C" fn _exit(_status: u32) {
+//     debug::exit(debug::EXIT_SUCCESS);
+// }
+
+type CtorFn = unsafe extern "C" fn() -> usize;
+extern "C" {
+    static __init_array_start: CtorFn;
+    static __init_array_end: CtorFn;
+}
+
+fn run_ctors() {
+    unsafe {
+        let start_ptr: *const CtorFn = &__init_array_start;
+        let end_ptr: *const CtorFn = &__init_array_end;
+        let num_ctors = end_ptr.offset_from(start_ptr) as usize;
+        let ctors = core::slice::from_raw_parts(start_ptr, num_ctors);
+        for ctor in ctors {
+            let _ = ctor();
+        }
+    }
+}
+
+#[entry]
+fn main() -> ! {
+    // cortex_m_rt does not run ctors so we need to the that manually.
+    run_ctors();
+
+    let mut success = true;
+
+    unittest_core::for_each_test(|test| {
+        info!("[{}] running", test.desc.name);
+        match test.test_fn {
+            unittest_core::TestFn::StaticTestFn(f) => {
+                if let Err(e) = f() {
+                    error!(
+                        "[{}] FAILED: {}:{} - {}",
+                        test.desc.name, e.file, e.line, e.message
+                    );
+                    success = false;
+                } else {
+                    info!("[{}] PASSED", test.desc.name);
+                }
+            }
+        };
+    });
+    match success {
+        true => debug::exit(debug::EXIT_SUCCESS),
+        false => debug::exit(debug::EXIT_FAILURE),
+    }
+
+    #[allow(clippy::empty_loop)]
+    loop {}
+}
diff --git a/lib/unittest/unittest_runner_host.rs b/lib/unittest/unittest_runner_host.rs
new file mode 100644
index 0000000..5115930
--- /dev/null
+++ b/lib/unittest/unittest_runner_host.rs
@@ -0,0 +1,27 @@
+#![feature(type_alias_impl_trait)]
+use pw_log::{error, info};
+
+#[no_mangle]
+pub extern "C" fn main() {
+    let mut success = true;
+    unittest_core::for_each_test(|test| {
+        info!("[{}] running", test.desc.name);
+        match test.test_fn {
+            unittest_core::TestFn::StaticTestFn(f) => {
+                if let Err(e) = f() {
+                    error!(
+                        "[{}] FAILED: {}:{} - {}",
+                        test.desc.name, e.file, e.line, e.message
+                    );
+                    success = false;
+                } else {
+                    info!("[{}] PASSED", test.desc.name);
+                }
+            }
+        }
+    });
+
+    if !success {
+        std::process::exit(1);
+    }
+}
diff --git a/lib/unittest/unittest_test.rs b/lib/unittest/unittest_test.rs
new file mode 100644
index 0000000..9d83535
--- /dev/null
+++ b/lib/unittest/unittest_test.rs
@@ -0,0 +1,18 @@
+#![no_std]
+#![cfg_attr(test, no_main)]
+
+pub fn add(a: u32, b: u32) -> u32 {
+    a + b
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use unittest::test;
+
+    #[test]
+    fn test_add() -> unittest::Result<()> {
+        unittest::assert_eq!(add(1, 2), 3);
+        Ok(())
+    }
+}
diff --git a/pigweed.json b/pigweed.json
index c25af0d..c563a8b 100644
--- a/pigweed.json
+++ b/pigweed.json
@@ -19,9 +19,19 @@
                         "//..."
                     ],
                     [
+                        "test",
+                        "--config=qemu-lm3s6965evb",
+                        "//..."
+                    ],
+                    [
                         "build",
                         "--config=qemu-microbit",
                         "//..."
+                    ],
+                    [
+                        "test",
+                        "--config=qemu-microbit",
+                        "//..."
                     ]
                 ]
             }
diff --git a/sandbox/logging/BUILD.bazel b/sandbox/logging/BUILD.bazel
index e718e5c..d9dcbea 100644
--- a/sandbox/logging/BUILD.bazel
+++ b/sandbox/logging/BUILD.bazel
@@ -12,19 +12,19 @@
 # License for the specific language governing permissions and limitations under
 # the License.
 
-load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library", "rust_test")
+load("@rules_rust//rust:defs.bzl", "rust_binary")
 
 rust_binary(
     name = "logging",
     srcs = ["main.rs"],
     edition = "2021",
-    linker_script = "//build:linker_script",
     target_compatible_with = select({
         "@pigweed//pw_build/constraints/chipset:lm3s6965evb": [],
         "@pigweed//pw_build/constraints/chipset:nrf52833": [],
         "//conditions:default": ["@platforms//:incompatible"],
     }),
     deps = [
+        "//target:linker_script",
         "@pigweed//pw_log/rust:pw_log",
         "@rust_crates//:cortex-m",
         "@rust_crates//:cortex-m-rt",
diff --git a/sandbox/logging/main.rs b/sandbox/logging/main.rs
index cf1d5b1..5dc55a3 100644
--- a/sandbox/logging/main.rs
+++ b/sandbox/logging/main.rs
@@ -20,6 +20,7 @@
 //! how the tokenized backend is setup.
 #![no_main]
 #![no_std]
+#![allow(unused_imports)]
 
 // Panic handler that halts the CPU on panic.
 use panic_halt as _;
@@ -32,7 +33,13 @@
 
 use pw_log::{critical, infof, warnf};
 
+#[no_mangle]
+pub extern "C" fn _exit(_status: i32) {
+    debug::exit(debug::EXIT_SUCCESS);
+}
+
 #[entry]
+#[cfg(not(test))]
 fn main() -> ! {
     // Plain text printout without `pw_log`
     hprintln!("Hello, Pigweed!");
@@ -55,3 +62,9 @@
     #[allow(clippy::empty_loop)]
     loop {}
 }
+
+#[cfg(test)]
+mod tests {
+    #[test]
+    fn test_test() {}
+}
diff --git a/target/BUILD.bazel b/target/BUILD.bazel
new file mode 100644
index 0000000..a37c205
--- /dev/null
+++ b/target/BUILD.bazel
@@ -0,0 +1,24 @@
+# Copyright 2024 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+
+package(default_visibility = ["//visibility:public"])
+
+label_flag(
+    name = "linker_script",
+    build_setting_default = ":no_linker_script",
+)
+
+cc_library(
+    name = "no_linker_script",
+)
diff --git a/target/qemu/BUILD.bazel b/target/qemu/BUILD.bazel
index 0706bb3..f44b533 100644
--- a/target/qemu/BUILD.bazel
+++ b/target/qemu/BUILD.bazel
@@ -26,7 +26,8 @@
         "@rust_crates//:no_std",
     ],
     flags = flags_from_dict({
-        "//build:linker_script": "//target/qemu/linker_scripts:qemu-lm3s6965",
+        "//target:linker_script": "//target/qemu/linker_scripts:qemu_lm3s6965_linker_script",
+        "//lib/unittest:unittest_runner": "//lib/unittest:unittest_runner_cortex_m",
         "@pigweed//pw_log/rust:pw_log_backend": "//target/qemu/pw_log_backend_qemu:pw_log_backend",
     }),
 )
@@ -41,7 +42,7 @@
         "@rust_crates//:no_std",
     ],
     flags = flags_from_dict({
-        "//build:linker_script": "//target/qemu/linker_scripts:qemu-nrf51823",
+        "//target:linker_script": "//target/qemu/linker_scripts:qemu_nrf51823_linker_script",
         "@pigweed//pw_log/rust:pw_log_backend": "//target/qemu/pw_log_backend_qemu:pw_log_backend",
     }),
 )
diff --git a/target/qemu/linker_scripts/BUILD.bazel b/target/qemu/linker_scripts/BUILD.bazel
index ac9e2ad..cf8bced 100644
--- a/target/qemu/linker_scripts/BUILD.bazel
+++ b/target/qemu/linker_scripts/BUILD.bazel
@@ -14,12 +14,20 @@
 
 package(default_visibility = ["//visibility:public"])
 
-filegroup(
-    name = "qemu-nrf51823",
-    srcs = ["qemu-nrf51823.ld"],
+cc_library(
+    name = "qemu_nrf51823_linker_script",
+    linkopts = ["-T$(location qemu-nrf51823.ld)"],
+    target_compatible_with = ["@pigweed//pw_build/constraints/chipset:nrf52833"],
+    deps = [
+        "qemu-nrf51823.ld",
+    ],
 )
 
-filegroup(
-    name = "qemu-lm3s6965",
-    srcs = ["qemu-lm3s6965.ld"],
+cc_library(
+    name = "qemu_lm3s6965_linker_script",
+    linkopts = ["-T$(location qemu-lm3s6965.ld)"],
+    target_compatible_with = ["@pigweed//pw_build/constraints/chipset:lm3s6965evb"],
+    deps = [
+        "qemu-lm3s6965.ld",
+    ],
 )