From 812278a22f7268bb94ef7b3d2542878087c44cdc Mon Sep 17 00:00:00 2001 From: portersky <24420859+portersky@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:48:21 +0200 Subject: [PATCH] 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 --- AGENTS.md | 117 +++++++++++++++++++++++++++++----------------- CMakeLists.txt | 77 +++++++++++++++++++++--------- macros/src/lib.rs | 53 +++++++++++++++++++++ main.rs | 95 ------------------------------------- runtime/main.rs | 11 +++++ src/io.rs | 61 ++++++++++++++++++++++++ src/lib.rs | 56 ++++++++++++++++++++++ src/runtime.rs | 103 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 412 insertions(+), 161 deletions(-) create mode 100644 macros/src/lib.rs delete mode 100644 main.rs create mode 100644 runtime/main.rs create mode 100644 src/io.rs create mode 100644 src/lib.rs create mode 100644 src/runtime.rs diff --git a/AGENTS.md b/AGENTS.md index ba033ef..ad090ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,48 +2,69 @@ ## Project Overview -This is a minimal Rust experiment that builds one executable without Cargo -and without linking Rust `core`, `alloc`, or `std`. CMake invokes `rustc` -directly. The executable writes `Hello` to stdout and exits. +This project is a small Rust common library and runtime built without Cargo. +CMake invokes `rustc` directly. The library provides a low-footprint subset +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 -- `CMakeLists.txt`: the direct `rustc` custom command -- `.gitignore`: ignores generated build output +The project contains: -## 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`. -- Do not replace `#![no_core]` with `#![no_std]`; `no_std` still links - Rust `core`. -- Do not add Cargo files or invoke Cargo. -- Do not import or link `core`, `alloc`, or `std`. -- Keep the required language-item and marker-trait definitions local to - `main.rs` unless the user explicitly asks for a different design. -- Use primitive Rust types and explicit platform FFI instead of library - helpers. -- Keep unsafe code inside small safe wrappers. The public call site for - stdout should remain a normal string call such as: +## No-Std Design + +- Keep `#![no_std]` in both the library and runtime. +- Do not add Rust `std` to the target library or runtime, and do not add + Cargo unless explicitly requested. +- The host-side procedural macro may use `proc_macro` and host libraries; + it is not linked into the target executable. +- `core` is allowed and is the foundation for `Result`, formatting, slices, + and other basic language-library functionality. +- `alloc` is allowed through `extern crate alloc`; it supplies `Vec` and + 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 - 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`. -- Windows builds call `GetStdHandle`, `WriteFile`, and `ExitProcess` - directly through `kernel32`. -- The Windows build must not link the C runtime. This keeps the executable - small and uses `/NODEFAULTLIB` with the `kernel32` import library. -- Preserve the safe `write_stdout(&str)` interface when changing output. +- Unix stdout calls libc `write` with file descriptor `1`. +- Windows stdout calls `GetStdHandle` and `WriteFile` through `kernel32`. +- Windows allocation calls `HeapAlloc` and `HeapFree` through the system + heap. +- The Windows runtime dynamically links only the platform support needed by + `alloc` and the compiler runtime. Do not statically link the full C + runtime. +- Keep `panic=abort` enabled. ## Build System -CMake is configured with `LANGUAGES NONE` and calls `rustc` through an -`add_custom_command`. `find_program(RUSTC rustc REQUIRED)` locates the -compiler. +CMake is configured with `LANGUAGES NONE` and invokes `rustc` through three +custom commands: + +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: @@ -52,16 +73,28 @@ cmake -S . -B build -GNinja -DCMAKE_BUILD_TYPE=Release 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: ```sh -./build/no_core_write +./build/rslibc_example ``` Run it on Windows: ```powershell -.\build\no_core_write.exe +.\build\rslibc_example.exe ``` `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` - 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 After changing the source or build configuration, run a clean release build -and execute the program: +and execute the example: ```sh rm -rf build cmake -S . -B build -GNinja -DCMAKE_BUILD_TYPE=Release cmake --build build -./build/no_core_write +./build/rslibc_example ``` 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 -build. Avoid adding runtime libraries, formatting code, or Rust library -dependencies. +When size matters, inspect the Windows executable after a release build. +Avoid adding formatting, allocation, or OS dependencies unless they are +needed by the library API. ## Commit Messages @@ -106,7 +135,7 @@ dependencies. ```text 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 @@ -115,5 +144,5 @@ dependencies. - Use four-space indentation in Rust and CMake. - Keep normal text in Markdown within 80 columns where practical. - 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. diff --git a/CMakeLists.txt b/CMakeLists.txt index ad487d9..7d5e7f9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,16 +1,17 @@ cmake_minimum_required(VERSION 3.20) -project(no_core_write LANGUAGES NONE) +project(rslibc LANGUAGES NONE) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) find_program(RUSTC rustc REQUIRED) -set(RUST_SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/main.rs") -set(RUST_BINARY "${CMAKE_CURRENT_BINARY_DIR}/no_core_write${CMAKE_EXECUTABLE_SUFFIX}") +set(RUST_MACRO_SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/macros/src/lib.rs") +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 -# minimal example be built with the installed compiler without Cargo or core. -set(RUSTC_ENV "RUSTC_BOOTSTRAP=1") -set(RUSTC_FLAGS -C panic=abort) +set(RUSTC_FLAGS --edition=2021 -C panic=abort) # CMAKE_BUILD_TYPE does not automatically affect a custom rustc command. # Pass the corresponding optimization/debug-info settings explicitly. @@ -22,31 +23,63 @@ else() list(APPEND RUSTC_FLAGS -C opt-level=0 -C debuginfo=2) endif() -# A no_core binary does not get Rust's normal startup/linker setup. Link the -# small platform system/runtime library needed by the stdout implementation. +# A no_std binary does not get Rust's normal startup or allocator setup. +# Use the OS heap and dynamically link only the small platform libraries +# needed by this runtime. 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 -l kernel32 + -l vcruntime -C - "link-args=/NODEFAULTLIB /ENTRY:mainCRTStartup /SUBSYSTEM:CONSOLE /OPT:REF /OPT:ICF" + "link-args=/ENTRY:mainCRTStartup /SUBSYSTEM:CONSOLE /OPT:REF /OPT:ICF" ) elseif (UNIX) set(RUSTC_LINK_ARGS -C "link-args=-lc") endif() add_custom_command( - OUTPUT "${RUST_BINARY}" - COMMAND "${CMAKE_COMMAND}" -E env "${RUSTC_ENV}" "${RUSTC}" - "${RUST_SOURCE}" - --crate-name no_core_write - --crate-type bin - -o "${RUST_BINARY}" - ${RUSTC_FLAGS} - ${RUSTC_LINK_ARGS} - DEPENDS "${RUST_SOURCE}" + OUTPUT "${RUST_MACRO}" + COMMAND "${RUSTC}" + "${RUST_MACRO_SOURCE}" + --crate-name rslibc_macros + --crate-type proc-macro + -o "${RUST_MACRO}" + --edition=2021 + -C opt-level=3 + DEPENDS "${RUST_MACRO_SOURCE}" 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}") diff --git a/macros/src/lib.rs b/macros/src/lib.rs new file mode 100644 index 0000000..1fd1d07 --- /dev/null +++ b/macros/src/lib.rs @@ -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 +} diff --git a/main.rs b/main.rs deleted file mode 100644 index aa581c4..0000000 --- a/main.rs +++ /dev/null @@ -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) } -} diff --git a/runtime/main.rs b/runtime/main.rs new file mode 100644 index 0000000..d979fe5 --- /dev/null +++ b/runtime/main.rs @@ -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"); +} diff --git a/src/io.rs b/src/io.rs new file mode 100644 index 0000000..0a81bb9 --- /dev/null +++ b/src/io.rs @@ -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) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..5dd20ac --- /dev/null +++ b/src/lib.rs @@ -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) + } + }; +} diff --git a/src/runtime.rs b/src/runtime.rs new file mode 100644 index 0000000..7c2a54c --- /dev/null +++ b/src/runtime.rs @@ -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(mut future: F) +where + F: Future, +{ + 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(), + } + } +}