feat: add no-std common runtime

Add an alloc-backed common library with direct platform I/O.

Build a host attribute macro for async entry points and keep runtime
boilerplate outside application code. Compile the library, macro, and
example directly with rustc through CMake.

Co-Authored-By: luna (openrouter/openai/gpt-5.6-luna): runtime work
This commit is contained in:
2026-09-05 04:48:21 +02:00
parent 54051d4068
commit 812278a22f
8 changed files with 412 additions and 161 deletions
+73 -44
View File
@@ -2,48 +2,69 @@
## Project Overview ## Project Overview
This is a minimal Rust experiment that builds one executable without Cargo This project is a small Rust common library and runtime built without Cargo.
and without linking Rust `core`, `alloc`, or `std`. CMake invokes `rustc` CMake invokes `rustc` directly. The library provides a low-footprint subset
directly. The executable writes `Hello` to stdout and exits. of standard-library-like functionality:
The project currently contains: - `Result` and formatting from `core`
- `Vec` and `vec![]` from `alloc`
- project-local `print!` and `println!` macros
- direct platform stdout and heap calls
- `main.rs`: the `#![no_core]` Rust program The project contains:
- `CMakeLists.txt`: the direct `rustc` custom command
- `.gitignore`: ignores generated build output
## Non-Negotiable No-Core Design - `src/lib.rs`: the `#![no_std]` common library API
- `src/io.rs`: the `core::fmt::Write` stdout backend
- `src/runtime.rs`: allocator, async polling, and platform entry helpers
- `macros/src/lib.rs`: host-side `#[rslibc_macros::main]` attribute macro
- `runtime/main.rs`: minimal example entry point
- `CMakeLists.txt`: direct `rustc` library, macro, and executable commands
- Keep `#![no_core]` in `main.rs`. ## No-Std Design
- Do not replace `#![no_core]` with `#![no_std]`; `no_std` still links
Rust `core`. - Keep `#![no_std]` in both the library and runtime.
- Do not add Cargo files or invoke Cargo. - Do not add Rust `std` to the target library or runtime, and do not add
- Do not import or link `core`, `alloc`, or `std`. Cargo unless explicitly requested.
- Keep the required language-item and marker-trait definitions local to - The host-side procedural macro may use `proc_macro` and host libraries;
`main.rs` unless the user explicitly asks for a different design. it is not linked into the target executable.
- Use primitive Rust types and explicit platform FFI instead of library - `core` is allowed and is the foundation for `Result`, formatting, slices,
helpers. and other basic language-library functionality.
- Keep unsafe code inside small safe wrappers. The public call site for - `alloc` is allowed through `extern crate alloc`; it supplies `Vec` and
stdout should remain a normal string call such as: related heap-backed types.
- The final runtime must provide its own global allocator and panic handler;
`rslibc::entry!` supplies them to an executable.
- Keep OS integration in small FFI modules. Do not pull in a general-purpose
runtime or C I/O layer for basic output.
- `rslibc_macros::main` can wrap `async fn main()` with the small `block_on`
executor. It is only a polling loop and does not provide Tokio-style I/O,
timers, or a reactor.
- Keep unsafe code inside small safe wrappers. Application code should be
able to use calls such as:
```rust ```rust
write_stdout("Hello\\n"); let values = rslibc::vec![1, 2, 3];
rslibc::println!("values = {:?}", values);
``` ```
## Output Implementation ## Platform Runtime
- Unix builds call the libc `write` function with file descriptor `1`. - Unix stdout calls libc `write` with file descriptor `1`.
- Windows builds call `GetStdHandle`, `WriteFile`, and `ExitProcess` - Windows stdout calls `GetStdHandle` and `WriteFile` through `kernel32`.
directly through `kernel32`. - Windows allocation calls `HeapAlloc` and `HeapFree` through the system
- The Windows build must not link the C runtime. This keeps the executable heap.
small and uses `/NODEFAULTLIB` with the `kernel32` import library. - The Windows runtime dynamically links only the platform support needed by
- Preserve the safe `write_stdout(&str)` interface when changing output. `alloc` and the compiler runtime. Do not statically link the full C
runtime.
- Keep `panic=abort` enabled.
## Build System ## Build System
CMake is configured with `LANGUAGES NONE` and calls `rustc` through an CMake is configured with `LANGUAGES NONE` and invokes `rustc` through three
`add_custom_command`. `find_program(RUSTC rustc REQUIRED)` locates the custom commands:
compiler.
1. Compile `macros/src/lib.rs` as a host procedural macro.
2. Compile `src/lib.rs` and its modules to `librslibc.rlib`.
3. Compile the minimal `runtime/main.rs` and link it against that rlib.
Configure and build a release executable with Ninja: Configure and build a release executable with Ninja:
@@ -52,16 +73,28 @@ cmake -S . -B build -GNinja -DCMAKE_BUILD_TYPE=Release
cmake --build build cmake --build build
``` ```
Build only the host macro with:
```sh
cmake --build build --target rslibc_macros
```
Build only the common library with:
```sh
cmake --build build --target rslibc_library
```
Run it on Unix: Run it on Unix:
```sh ```sh
./build/no_core_write ./build/rslibc_example
``` ```
Run it on Windows: Run it on Windows:
```powershell ```powershell
.\build\no_core_write.exe .\build\rslibc_example.exe
``` ```
`CMAKE_BUILD_TYPE` is translated explicitly to rustc flags: `CMAKE_BUILD_TYPE` is translated explicitly to rustc flags:
@@ -70,28 +103,24 @@ Run it on Windows:
- `RelWithDebInfo`: `-C opt-level=3 -C debuginfo=2` - `RelWithDebInfo`: `-C opt-level=3 -C debuginfo=2`
- Other or unset configurations: `-C opt-level=0 -C debuginfo=2` - Other or unset configurations: `-C opt-level=0 -C debuginfo=2`
`no_core` is unstable, so CMake passes `RUSTC_BOOTSTRAP=1` to the direct
rustc invocation. A nightly compiler may be used instead, but Cargo is
still not permitted.
## Verification ## Verification
After changing the source or build configuration, run a clean release build After changing the source or build configuration, run a clean release build
and execute the program: and execute the example:
```sh ```sh
rm -rf build rm -rf build
cmake -S . -B build -GNinja -DCMAKE_BUILD_TYPE=Release cmake -S . -B build -GNinja -DCMAKE_BUILD_TYPE=Release
cmake --build build cmake --build build
./build/no_core_write ./build/rslibc_example
``` ```
On Windows, use `Remove-Item -Recurse -Force build` and run On Windows, use `Remove-Item -Recurse -Force build` and run
`build\\no_core_write.exe` instead. Confirm that stdout contains `Hello`. `build\\rslibc_example.exe` instead. Confirm that stdout contains `Hello`.
When size matters, inspect the Windows executable size after a release When size matters, inspect the Windows executable after a release build.
build. Avoid adding runtime libraries, formatting code, or Rust library Avoid adding formatting, allocation, or OS dependencies unless they are
dependencies. needed by the library API.
## Commit Messages ## Commit Messages
@@ -106,7 +135,7 @@ dependencies.
```text ```text
Co-Authored-By: qwen (lmstudio/qwen3.6-27b-mtp): wrote tests + build Co-Authored-By: qwen (lmstudio/qwen3.6-27b-mtp): wrote tests + build
Co-Authored-By: luna (openai/gpt-5.6-luna): reviewed edge cases Co-Authored-By: luna (openrouter/openai/gpt-5.6-luna): reviewed edge cases
``` ```
## Editing Guidelines ## Editing Guidelines
@@ -115,5 +144,5 @@ dependencies.
- Use four-space indentation in Rust and CMake. - Use four-space indentation in Rust and CMake.
- Keep normal text in Markdown within 80 columns where practical. - Keep normal text in Markdown within 80 columns where practical.
- Do not add speculative abstractions or dependencies. - Do not add speculative abstractions or dependencies.
- Update this file when the build workflow, no-core constraints, supported - Update this file when the build workflow, library API, supported
platforms, or source layout changes. platforms, or source layout changes.
+55 -22
View File
@@ -1,16 +1,17 @@
cmake_minimum_required(VERSION 3.20) cmake_minimum_required(VERSION 3.20)
project(no_core_write LANGUAGES NONE) project(rslibc LANGUAGES NONE)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
find_program(RUSTC rustc REQUIRED) find_program(RUSTC rustc REQUIRED)
set(RUST_SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/main.rs") set(RUST_MACRO_SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/macros/src/lib.rs")
set(RUST_BINARY "${CMAKE_CURRENT_BINARY_DIR}/no_core_write${CMAKE_EXECUTABLE_SUFFIX}") set(RUST_MACRO "${CMAKE_CURRENT_BINARY_DIR}/rslibc_macros${CMAKE_SHARED_LIBRARY_SUFFIX}")
set(RUST_LIBRARY_SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/src/lib.rs")
set(RUST_RUNTIME_SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/runtime/main.rs")
set(RUST_LIBRARY "${CMAKE_CURRENT_BINARY_DIR}/librslibc.rlib")
set(RUST_BINARY "${CMAKE_CURRENT_BINARY_DIR}/rslibc_example${CMAKE_EXECUTABLE_SUFFIX}")
# no_core is an unstable rustc feature. RUSTC_BOOTSTRAP lets this deliberately set(RUSTC_FLAGS --edition=2021 -C panic=abort)
# minimal example be built with the installed compiler without Cargo or core.
set(RUSTC_ENV "RUSTC_BOOTSTRAP=1")
set(RUSTC_FLAGS -C panic=abort)
# CMAKE_BUILD_TYPE does not automatically affect a custom rustc command. # CMAKE_BUILD_TYPE does not automatically affect a custom rustc command.
# Pass the corresponding optimization/debug-info settings explicitly. # Pass the corresponding optimization/debug-info settings explicitly.
@@ -22,31 +23,63 @@ else()
list(APPEND RUSTC_FLAGS -C opt-level=0 -C debuginfo=2) list(APPEND RUSTC_FLAGS -C opt-level=0 -C debuginfo=2)
endif() endif()
# A no_core binary does not get Rust's normal startup/linker setup. Link the # A no_std binary does not get Rust's normal startup or allocator setup.
# small platform system/runtime library needed by the stdout implementation. # Use the OS heap and dynamically link only the small platform libraries
# needed by this runtime.
if (WIN32) if (WIN32)
# Use Win32 directly instead of the C runtime; this keeps the executable
# small while still writing to the console's stdout handle.
set(RUSTC_LINK_ARGS set(RUSTC_LINK_ARGS
-l kernel32 -l kernel32
-l vcruntime
-C -C
"link-args=/NODEFAULTLIB /ENTRY:mainCRTStartup /SUBSYSTEM:CONSOLE /OPT:REF /OPT:ICF" "link-args=/ENTRY:mainCRTStartup /SUBSYSTEM:CONSOLE /OPT:REF /OPT:ICF"
) )
elseif (UNIX) elseif (UNIX)
set(RUSTC_LINK_ARGS -C "link-args=-lc") set(RUSTC_LINK_ARGS -C "link-args=-lc")
endif() endif()
add_custom_command( add_custom_command(
OUTPUT "${RUST_BINARY}" OUTPUT "${RUST_MACRO}"
COMMAND "${CMAKE_COMMAND}" -E env "${RUSTC_ENV}" "${RUSTC}" COMMAND "${RUSTC}"
"${RUST_SOURCE}" "${RUST_MACRO_SOURCE}"
--crate-name no_core_write --crate-name rslibc_macros
--crate-type bin --crate-type proc-macro
-o "${RUST_BINARY}" -o "${RUST_MACRO}"
${RUSTC_FLAGS} --edition=2021
${RUSTC_LINK_ARGS} -C opt-level=3
DEPENDS "${RUST_SOURCE}" DEPENDS "${RUST_MACRO_SOURCE}"
VERBATIM VERBATIM
) )
add_custom_target(no_core_write ALL DEPENDS "${RUST_BINARY}") add_custom_command(
OUTPUT "${RUST_LIBRARY}"
COMMAND "${RUSTC}"
"${RUST_LIBRARY_SOURCE}"
--crate-name rslibc
--crate-type rlib
-o "${RUST_LIBRARY}"
${RUSTC_FLAGS}
DEPENDS
"${RUST_LIBRARY_SOURCE}"
"${CMAKE_CURRENT_SOURCE_DIR}/src/io.rs"
"${CMAKE_CURRENT_SOURCE_DIR}/src/runtime.rs"
VERBATIM
)
add_custom_command(
OUTPUT "${RUST_BINARY}"
COMMAND "${RUSTC}"
"${RUST_RUNTIME_SOURCE}"
--crate-name rslibc_example
--crate-type bin
--extern "rslibc=${RUST_LIBRARY}"
--extern "rslibc_macros=${RUST_MACRO}"
-o "${RUST_BINARY}"
${RUSTC_FLAGS}
${RUSTC_LINK_ARGS}
DEPENDS "${RUST_RUNTIME_SOURCE}" "${RUST_LIBRARY}" "${RUST_MACRO}"
VERBATIM
)
add_custom_target(rslibc_macros DEPENDS "${RUST_MACRO}")
add_custom_target(rslibc_library DEPENDS "${RUST_LIBRARY}")
add_custom_target(rslibc_example ALL DEPENDS "${RUST_BINARY}")
+53
View File
@@ -0,0 +1,53 @@
extern crate proc_macro;
use proc_macro::{Ident, TokenStream, TokenTree};
use std::str::FromStr;
#[proc_macro_attribute]
pub fn main(_: TokenStream, item: TokenStream) -> TokenStream {
let mut output = TokenStream::new();
let mut saw_fn = false;
let mut renamed = false;
let mut is_async = false;
for token in item {
match &token {
TokenTree::Ident(identifier)
if identifier.to_string() == "async" && !saw_fn =>
{
is_async = true;
}
TokenTree::Ident(identifier) if identifier.to_string() == "fn" => {
saw_fn = true;
}
TokenTree::Ident(identifier)
if saw_fn && !renamed && identifier.to_string() == "main" =>
{
output.extend([TokenTree::Ident(Ident::new(
"__rslibc_user_main",
identifier.span(),
))]);
renamed = true;
continue;
}
_ => {}
}
output.extend([token]);
}
if !renamed {
return "compile_error!(\"rslibc::main expects fn main()\");"
.parse()
.unwrap();
}
let wrapper = if is_async {
"fn main() { rslibc::runtime::block_on(__rslibc_user_main()); } rslibc::entry!(main);"
} else {
"fn main() { __rslibc_user_main(); } rslibc::entry!(main);"
};
output.extend(TokenStream::from_str(wrapper).unwrap());
output
}
-95
View File
@@ -1,95 +0,0 @@
#![no_core]
#![no_main]
#![feature(no_core, lang_items, auto_traits)]
#![allow(internal_features)]
// These three marker traits are the minimum language items needed when core is
// not linked. They are compiler contracts, not a replacement for core.
#[lang = "pointee_sized"]
trait PointeeSized {}
#[lang = "meta_sized"]
trait MetaSized: PointeeSized {}
#[lang = "sized"]
trait Sized: MetaSized {}
#[lang = "copy"]
trait Copy {}
#[lang = "freeze"]
unsafe auto trait Freeze {}
#[lang = "eh_personality"]
extern "C" fn eh_personality() {}
#[lang = "panic_info"]
struct PanicInfo;
#[lang = "panic_impl"]
fn panic_impl(_: &PanicInfo) -> ! {
loop {}
}
#[lang = "panic_cannot_unwind"]
fn panic_cannot_unwind() -> ! {
loop {}
}
#[cfg(unix)]
unsafe extern "C" {
fn write(fd: i32, buffer: *const u8, count: usize) -> isize;
}
#[cfg(windows)]
unsafe extern "system" {
fn GetStdHandle(which: u32) -> *mut u8;
fn WriteFile(
handle: *mut u8,
buffer: *const u8,
count: u32,
written: *mut u32,
overlapped: *mut u8,
) -> i32;
fn ExitProcess(status: u32) -> !;
}
// A string slice is a (pointer, length) pair. This keeps the public helper
// ergonomic without importing core just to call str::as_ptr and str::len.
#[repr(C)]
union StringParts<'a> {
string: &'a str,
bytes: (*const u8, usize),
}
fn write_stdout(message: &str) {
let (buffer, length) = unsafe { StringParts { string: message }.bytes };
unsafe {
#[cfg(unix)]
{
write(1, buffer, length);
}
#[cfg(windows)]
{
let handle = GetStdHandle(0xffff_fff5); // STD_OUTPUT_HANDLE
let mut written: u32 = 0;
WriteFile(handle, buffer, length as u32, &mut written, 0 as *mut u8);
}
}
}
#[cfg(unix)]
#[no_mangle]
pub extern "C" fn main() -> i32 {
write_stdout("Hello\n");
0
}
#[cfg(windows)]
#[no_mangle]
pub extern "system" fn mainCRTStartup() -> ! {
write_stdout("Hello\n");
unsafe { ExitProcess(0) }
}
+11
View File
@@ -0,0 +1,11 @@
#![no_std]
#![no_main]
fn print(message: &str) {
let _ = rslibc::io::write_str(message);
}
#[rslibc_macros::main]
async fn main() {
print("Hello\n");
}
+61
View File
@@ -0,0 +1,61 @@
use core::fmt;
#[cfg(unix)]
unsafe extern "C" {
fn write(fd: i32, buffer: *const u8, count: usize) -> isize;
}
#[cfg(windows)]
unsafe extern "system" {
fn GetStdHandle(which: u32) -> *mut u8;
fn WriteFile(
handle: *mut u8,
buffer: *const u8,
count: u32,
written: *mut u32,
overlapped: *mut u8,
) -> i32;
}
pub struct Stdout;
impl fmt::Write for Stdout {
fn write_str(&mut self, message: &str) -> fmt::Result {
unsafe {
#[cfg(unix)]
{
if write(1, message.as_ptr(), message.len()) < 0 {
return Err(fmt::Error);
}
}
#[cfg(windows)]
{
let handle = GetStdHandle(0xffff_fff5); // STD_OUTPUT_HANDLE
let mut written: u32 = 0;
if WriteFile(
handle,
message.as_ptr(),
message.len() as u32,
&mut written,
0 as *mut u8,
) == 0
{
return Err(fmt::Error);
}
}
}
Ok(())
}
}
pub fn write_str(message: &str) -> fmt::Result {
let mut stdout = Stdout;
fmt::Write::write_str(&mut stdout, message)
}
pub fn print(arguments: fmt::Arguments<'_>) -> fmt::Result {
let mut stdout = Stdout;
fmt::Write::write_fmt(&mut stdout, arguments)
}
+56
View File
@@ -0,0 +1,56 @@
#![no_std]
extern crate alloc;
pub mod io;
pub mod runtime;
pub use alloc::vec;
pub use alloc::vec::Vec;
pub use core::result::Result;
#[macro_export]
macro_rules! print {
($($arg:tt)*) => {{
let _ = $crate::io::print(core::format_args!($($arg)*));
}};
}
#[macro_export]
macro_rules! println {
() => {
$crate::print!("\n")
};
($($arg:tt)*) => {{
$crate::print!($($arg)*);
$crate::print!("\n");
}};
}
#[macro_export]
macro_rules! entry {
($main:path) => {
#[global_allocator]
static RSLIBC_ALLOCATOR: $crate::runtime::SystemAllocator =
$crate::runtime::SystemAllocator;
#[panic_handler]
fn rslibc_panic(_: &core::panic::PanicInfo<'_>) -> ! {
loop {}
}
#[cfg(unix)]
#[no_mangle]
pub extern "C" fn main() -> i32 {
$main();
0
}
#[cfg(windows)]
#[no_mangle]
pub extern "system" fn mainCRTStartup() -> ! {
$main();
$crate::runtime::exit(0)
}
};
}
+103
View File
@@ -0,0 +1,103 @@
use core::alloc::{GlobalAlloc, Layout};
use core::future::Future;
use core::pin::Pin;
use core::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
#[cfg(unix)]
unsafe extern "C" {
fn malloc(size: usize) -> *mut u8;
fn free(pointer: *mut u8);
}
#[cfg(windows)]
unsafe extern "system" {
fn GetProcessHeap() -> *mut u8;
fn HeapAlloc(heap: *mut u8, flags: u32, bytes: usize) -> *mut u8;
fn HeapFree(heap: *mut u8, flags: u32, pointer: *mut u8) -> i32;
fn ExitProcess(status: u32) -> !;
}
pub struct SystemAllocator;
// alloc's CString support references the C ABI strlen symbol. Keep this
// compatibility shim local so the runtime only needs VCRUNTIME's low-level
// allocation helpers instead of the full UCRT.
#[no_mangle]
unsafe extern "C" fn strlen(mut pointer: *const u8) -> usize {
let mut length = 0;
while *pointer != 0 {
pointer = pointer.add(1);
length += 1;
}
length
}
unsafe impl GlobalAlloc for SystemAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
if layout.size() == 0 {
return layout.align() as *mut u8;
}
#[cfg(unix)]
{
malloc(layout.size())
}
#[cfg(windows)]
{
HeapAlloc(GetProcessHeap(), 0, layout.size())
}
}
unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
if layout.size() == 0 {
return;
}
#[cfg(unix)]
{
free(pointer);
}
#[cfg(windows)]
{
HeapFree(GetProcessHeap(), 0, pointer);
}
}
}
#[cfg(windows)]
pub fn exit(status: u32) -> ! {
unsafe { ExitProcess(status) }
}
fn clone_waker(_: *const ()) -> RawWaker {
RawWaker::new(core::ptr::null(), &WAKER_VTABLE)
}
unsafe fn wake_waker(_: *const ()) {}
unsafe fn wake_by_ref_waker(_: *const ()) {}
unsafe fn drop_waker(_: *const ()) {}
static WAKER_VTABLE: RawWakerVTable = RawWakerVTable::new(
clone_waker,
wake_waker,
wake_by_ref_waker,
drop_waker,
);
pub fn block_on<F>(mut future: F)
where
F: Future<Output = ()>,
{
let waker = unsafe { Waker::from_raw(clone_waker(core::ptr::null())) };
let mut context = Context::from_waker(&waker);
let mut future = unsafe { Pin::new_unchecked(&mut future) };
loop {
match future.as_mut().poll(&mut context) {
Poll::Ready(()) => return,
Poll::Pending => core::hint::spin_loop(),
}
}
}