Skip to content

Instantly share code, notes, and snippets.

@AldoMX
Created November 13, 2022 22:19
Show Gist options
  • Save AldoMX/eac026bcb9c839c4f10244233a9bba12 to your computer and use it in GitHub Desktop.
Save AldoMX/eac026bcb9c839c4f10244233a9bba12 to your computer and use it in GitHub Desktop.
How I built a 'Hello World' in Rust that works for Windows XP [2022]

How I built a 'Hello World' in Rust that works for Windows XP [2022]

Why XP?

Let's add some context first: Windows XP went out of support at April 2014, Windows 7 went out of support at January 2020, and Windows 8.1 will go out of support at January 2023. Why would someone want to support Windows XP?

In my particular case: I have an arcade machine. No matter how strong Microsoft pushes to kill legacy hardware, my arcade will still have a 800Mhz FSB Pentium 4 Motherboard. Windows 7 runs slow on that hardware and newer versions run slower (not to mention that Microsoft stopped supporting 32-bit Windows).

Linux is a viable choice, I know that there's an ongoing effort to port some games from 16-bit DOS to Linux by hand-editing assembly in the binaries, and that effort has seen some success, but we're still not there yet.

ReactOS is starting to look viable too. I remember reading a person who resurrected a medical equipment thanks to ReactOS, but I couldn't find the link, I think it was an ECG.

Welcome to the land of unsupported Rust

Here's an excerpt from the ticket to drop XP support in Rust:

Removing official XP support would not stop anyone from compiling to XP (minus the std) [...]

What does this mean in practice? You can only use no_std, which means you can only use the core library and no_std crates.

I missed the second part of the excerpt on purpose:

[...] so long as LLVM and their linker still support XP targets.

So let's discuss about this particular point.

When you build a new project you usually pick the dependencies that will make it easier to port the project to other platforms. In the no_std world such project is the libc crate, but do not confuse the Rust crate with the GNU library. This crate is maintained by the Rust team, and the support for XP depends on whether or not the maintainers decide to provide appropriate fallbacks whenever an update breaks XP support. Spoilers: They won't.

By not having the standard library of Rust, and by not having libc I am limiting myself to the Windows API (provided by the windows-sys crate and maintained by Microsoft).

That will make an interesting experiment, because I can no longer use println! or a libc equivalent like printf to write a hello world.

An important detail about the Windows API is that you can find a handy table with details of the Windows version where a method was added.

Fixing the Hello World

Let's start by creating the hello world project:

cargo new hello_world
cd hello_world
cargo run

We get greeted by the familiar hello world:

   Compiling hello_world v0.1.0 (C:\rust\hello_world)
    Finished dev [unoptimized + debuginfo] target(s) in 0.75s
     Running `target\debug\hello_world.exe`
Hello, world!

Looks unexciting, I know, but it will take some time until we see hello world working again, so let's start with this journey.

BTW, I am using the x86_64-pc-windows-msvc toolchain.

First, I add an innocent attribute (#![no_std]) at the start of main.rs:

#![no_std]

fn main() {
    println!("Hello, world!");
}

And I try to cargo run it again:

   Compiling hello_world v0.1.0 (C:\rust\hello_world)
error: cannot find macro `println` in this scope
 --> src\main.rs:4:5
  |
4 |     println!("Hello, world!");
  |     ^^^^^^^

error: `#[panic_handler]` function required, but not found

error: language item required, but not found: `eh_personality`
  |
  = note: this can occur when a binary crate with `#![no_std]` is compiled for a target where `eh_personality` is defined in the standard library
  = help: you may be able to compile for a target that doesn't need `eh_personality`, specify a target with `--target` or in `.cargo/config`

error: could not compile `hello_world` due to 3 previous errors

Oh boy, it sure broke. By removing the standard library I accepted to pay a hefty price: I don't have error handling anymore and I can't even panic!.

The Panic Handler and the windows-sys crate

Let's start by writing a super-simple panic handler, one which simply closes the program with error code 0xdeadbeef.

use core::panic::PanicInfo;
use std::process::exit;

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    exit(0xdeafbeef)
}

This should remove the first error, right? Let's cargo run again:

   Compiling hello_world v0.1.0 (C:\rust\hello_world)
error[E0433]: failed to resolve: use of undeclared crate or module `std`
 --> src\main.rs:4:5
  |
4 | use std::process::exit;
  |     ^^^ use of undeclared crate or module `std`

Oh, right! I forgot I'm not using std anymore! Wait, how can I exit a program without std then? That's right, I should check the Windows API (winapi for short), the answer is ExitProcess (introduced in Windows XP, how convenient). That means I have to add the windows-sys crate, but before doing that let's check the documentation on docs.rs.

There's this footnote: Required features: "Win32_System_Threading", I will need that feature name later so let's keep it at hand.

Let's add the windows-sys crate to our Cargo.toml, as well as the Win32_System_Threading feature:

[package]
name = "hello_world"
version = "0.1.0"
edition = "2021"

[dependencies]
windows-sys = { version = "0.42.0", features = ["Win32_System_Threading"] }

And let's replace our panic handler:

use core::panic::PanicInfo;
use windows_sys::Win32::System::Threading::ExitProcess;

#[panic_handler]
unsafe fn panic(_info: &PanicInfo) -> ! {
    ExitProcess(0xdeafbeef)
}

See that unsafe fn? Every method from winapi is considered unsafe, so keep that in mind because the Drop trait will be helpful when writing destructors.

I run cargo run again, and the panic_handler error is gone, yay! 🎉

Exception Handler personality (MSVC)

I hope my phychologist doesn't believe I am crazy when I tell him that I spent a lot of time this weekend because of my Exception Handler's personality.

The good news is that you don't have to spend as much time as I did, we only need to add these lines to the Cargo.toml:

[profile.dev]
panic = "abort"

[profile.release]
lto = true
panic = "abort"

I run cargo run again, and I only have the println! error:

   Compiling windows_x86_64_msvc v0.42.0
   Compiling windows-sys v0.42.0
   Compiling hello_world v0.1.0 (C:\rust\hello_world)
error: cannot find macro `println` in this scope
  --> src\main.rs:20:5
   |
20 |     println!("Hello, world!");
   |     ^^^^^^^

error: could not compile `hello_world` due to previous error

Print function (Unstable)

I will use the following winapi methods to write the in-house print method: WriteFile and GetStdHandle.

They bring the following features with them: Win32_Foundation, Win32_Storage_FileSystem, Win32_System_Console and Win32_System_IO.

At this point it is getting out of hand to add the features to our Cargo.toml because rustfmt collapses everything in a single line, so I'm going to give you a hint, you can separate a dependency into its own section (I'm not removing [dependencies] yet, I'll be adding another crate later):

[dependencies]

[dependencies.windows-sys]
version = "0.42.0"
features = [
    "Win32_Foundation",
    "Win32_Storage_FileSystem",
    "Win32_System_Console",
    "Win32_System_IO",
    "Win32_System_Threading",
]

This is my ahem unstable implementation of print:

use core::{ffi::c_void, ptr::null_mut};
use windows_sys::Win32::{
    Storage::FileSystem::WriteFile,
    System::Console::{GetStdHandle, STD_OUTPUT_HANDLE},
};

fn print(str: &str) -> usize {
    let mut bytes_written = 0u32;
    unsafe {
        WriteFile(
            GetStdHandle(STD_OUTPUT_HANDLE),
            str.as_ptr() as *const c_void,
            str.len() as u32,
            &mut bytes_written,
            null_mut(),
        );
    }
    bytes_written as usize
}

I will talk about that unstability later, for now let's cargo run this:

   Compiling hello_world v0.1.0 (C:\rust\hello_world)
error: requires `start` lang_item

error: could not compile `hello_world` due to previous error

Wait, what? Are we playing wack-a-mole?

C-style main function

Long story short: I'm not using std, so I can't have a Rust-style main function, but I can have a C-style main function, so let's add a #![no_main] attribute to the top and replace our main function:

use core::ffi::{c_char, c_int};

#[no_mangle]
pub extern "C" fn main(_argc: c_int, _argv: *const *const c_char) -> isize {
    print("Hello, world!");
    0
}

Let's try cargo run again:

$ cargo run
   Compiling hello_world v0.1.0 (C:\rust\hello_world)
error: linking with `link.exe` failed: exit code: 1120
  |
  = note: LINK : error LNK2001: unresolved external symbol mainCRTStartup
          C:\rust\hello_world\target\debug\deps\hello_world.exe : fatal error LNK1120: 1 unresolved externals

Oh my, we are indeed playing wack-a-mole 🥲, this is again an issue with no_std, I have to tell the linker to link against the visual studio runtime, so I add the following attributes to the main.rs:

#[cfg(target_env = "msvc")]
#[link(name = "msvcrt")]
extern "C" {}

#[cfg(target_env = "msvc")]
#[link(name = "vcruntime")]
extern "C" {}

#[cfg(target_env = "msvc")]
#[link(name = "ucrt")]
extern "C" {}

I cargo run again:

   Compiling hello_world v0.1.0 (C:\rust\hello_world)
    Finished dev [unoptimized + debuginfo] target(s) in 0.34s
     Running `target\debug\hello_world.exe`
Hello, world!

It works! 🎉

Running the project on XP

I want to run on Windows XP, let's add the i686 target:

rustup target add i686-pc-windows-msvc

And cargo run --target i686-pc-windows-msvc this time:

   Compiling windows_i686_msvc v0.42.0
   Compiling windows-sys v0.42.0
   Compiling hello_world v0.1.0 (C:\rust\hello_world)
    Finished dev [unoptimized + debuginfo] target(s) in 1.97s
     Running `target\i686-pc-windows-msvc\debug\hello_world.exe`
Hello, world!

I copy the hello_world.exe to Windows XP and I am greeted with hello_world.exe is not a valid Win32 application. What's going on?

Long story short: You need to install the Visual Studio 2017 Build Tools for XP (aka v141_xp Platform Toolset), BUT... Rust doesn't yet have an equivalent to msbuild hello_world.vcxproj /p:PlatformToolset=v141_xp, and the Rust team is still discussing how to target different Windows Subsystems. So the only way to continue is to edit ~/.cargo/config to point to the correct linker.

I'm going to take a detour here and instead use the i686-pc-windows-gnu toolchain:

rustup default stable-i686-pc-windows-gnu
cargo run

It works! 🎉

   Compiling windows_i686_gnu v0.42.0
   Compiling windows-sys v0.42.0
   Compiling hello_world v0.1.0 (C:\rust\hello_world)
    Finished dev [unoptimized + debuginfo] target(s) in 2.62s
     Running `target\debug\hello_world.exe`
Hello, world!

I copy the hello_world.exe to Windows XP and... It works! 🎉

Enabling the alloc crate

Let's be honest here, the current setup is not attractive, at the very least we should be able to use familiar structs like String, Vec, or Box, which is what we get if we enable the alloc crate.

So let's add the following line to our (already crowded) main.rs:

#[macro_use]
extern crate alloc;

I cargo run and:

   Compiling hello_world v0.1.0 (C:\rust\hello_world)
error: no global memory allocator found but one is required; link to std or add `#[global_allocator]` to a static item that implements the GlobalAlloc trait

error: `#[alloc_error_handler]` function required, but not found

note: use `#![feature(default_alloc_error_handler)]` for a default error handler

error: could not compile `hello_world` due to 2 previous errors

Back to playing wack-a-mole 🐭.

Let's add a basic error handler:

use core::alloc::Layout;

#[alloc_error_handler]
unsafe fn alloc_error_handler(_layout: Layout) -> ! {
    ExitProcess(0xdeafbeef)
}

And...

error[E0658]: the `#[alloc_error_handler]` attribute is an experimental feature
  --> src\main.rs:16:1
   |
16 | #[alloc_error_handler]
   | ^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: see issue #51540 <https://github.com/rust-lang/rust/issues/51540> for more information

For more information about this error, try `rustc --explain E0658`.

Oh, wow, I can't use stable Rust today, the good news is that a PR is in progress, so hopefully this ticket will be fixed soon.

In the meantime let's move to the nightly channel, so create a rust-toolchain.toml at the project root:

[toolchain]
channel = "nightly-i686-pc-windows-gnu"

Now that I moved to the nightly channel I can use the default error handling by adding #![feature(default_alloc_error_handler)] to the top of main.rs, so let's remove the error handler I added previously.

Now let's add the allocator (which requires adding the Win32_System_Memory feature to Cargo.toml):

#[derive(Default)]
struct Allocator;

unsafe impl GlobalAlloc for Allocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        HeapAlloc(GetProcessHeap(), 0, layout.size()) as *mut u8
    }

    unsafe fn dealloc(&self, ptr: *mut u8, _layout: Layout) {
        HeapFree(GetProcessHeap(), 0, ptr as *const c_void);
    }
}

#[global_allocator]
static GLOBAL_ALLOCATOR: Allocator = Allocator;

I cargo run again:

   Compiling windows-sys v0.42.0
   Compiling hello_world v0.1.0 (C:\rust\hello_world)
error: linking with `i686-w64-mingw32-gcc` failed: exit code: 1
  |
  = note: %USERPROFILE%\.rustup\toolchains\nightly-i686-pc-windows-gnu\lib\rustlib\i686-pc-windows-gnu\lib\liballoc-92c8714f80fbae62.rlib(alloc-92c8714f80fbae62.alloc.1911634b-cgu.0.rcgu.o):alloc.1911634b-cgu:(.eh_frame+0xbdb): undefined reference to `rust_eh_personality'
          %USERPROFILE%\.rustup\toolchains\nightly-i686-pc-windows-gnu\lib\rustlib\i686-pc-windows-gnu\lib\libcore-e8cbede5689dc631.rlib(core-e8cbede5689dc631.core.13971860-cgu.0.rcgu.o):core.13971860-cgu.:(.eh_frame+0x5e7f): undefined reference to `rust_eh_personality'

We meet again, error handler personality.

Exception Handler personality (MINGW32)

Remember when I mentioned that I spent some time adding the personality?

#[cfg(target_env = "gnu")]
#[link(name = "stdc++")]
extern "C" {
    fn __gxx_personality_v0(
        version: c_int,
        actions: c_int,
        exception_class: *const c_char,
        exception_object: *mut c_void,
        context: *mut c_void,
    ) -> c_int;
}

#[cfg(target_env = "gnu")]
#[no_mangle]
unsafe fn rust_eh_personality(
    version: c_int,
    actions: c_int,
    exception_class: *const c_char,
    exception_object: *mut c_void,
    context: *mut c_void,
) -> c_int {
    __gxx_personality_v0(version, actions, exception_class, exception_object, context)
}

Long story short, this personality is provided by gcc (__gxx_personality_v0), I looked for its signature and created a function that passes the arguments to it. I also link against lstdc++.

I cargo run again, and... We're back to business, the project works! 🎉 It works on XP too! 🎉🎉🎉

Singleton for stdout

Remember when I mentioned that the print method was unstable?

Long story short: WriteFile expects a C string, it should be null-terminated.

Sorry I didn't tell you earlier, I didn't have any memory at all!

Why a singleton? Windows handles have a ritual to follow (ex. create, use, free), I don't want to do this ritual for every line I'm printing, and I don't want to pass references across the whole program either. I know singletons are cursed, but building for XP is cursed too. BTW, in the particular case of Std Handles cleaning up is not required.

Without further ado, here is my singleton:

#[macro_use]
extern crate lazy_static;

use alloc::{string::String, vec::Vec};
use core::{ffi::c_void, ptr::null_mut};
use windows_sys::Win32::{
    Foundation::{HANDLE, INVALID_HANDLE_VALUE},
    Storage::FileSystem::WriteFile,
    System::Console::{GetStdHandle, STD_OUTPUT_HANDLE},
};

pub struct StdOut {
    handle: HANDLE,
}

impl StdOut {
    const NEWLINE_BYTES: &'static [u8] = &['\r' as u8, '\n' as u8, 0];

    // Use the `STD_OUT` singleton instead
    pub fn _new() -> Self {
        let handle = unsafe { GetStdHandle(STD_OUTPUT_HANDLE) };
        StdOut { handle }
    }

    pub fn is_handle_valid(&self) -> bool {
        self.handle != INVALID_HANDLE_VALUE
    }

    pub fn print_bytes(&self, bytes: Vec<u8>) -> usize {
        let mut bytes_written = 0u32;
        let buffer_size = bytes.len() as u32;
        if self.is_handle_valid() {
            unsafe {
                WriteFile(
                    self.handle,
                    bytes.as_ptr() as *const c_void,
                    buffer_size,
                    &mut bytes_written,
                    null_mut(),
                );
            }
        }
        bytes_written as usize
    }

    pub fn print(&self, txt: String) -> usize {
        let mut bytes = txt.into_bytes();
        bytes.push(0);
        self.print_bytes(bytes)
    }

    pub fn println(&self, txt: String) -> usize {
        let mut bytes = txt.into_bytes();
        bytes.extend_from_slice(Self::NEWLINE_BYTES);
        self.print_bytes(bytes)
    }
}

lazy_static! {
    pub static ref STD_OUT: StdOut = StdOut::_new();
}

For this singleton to work, I need to add the lazy_static crate to Cargo.toml:

[dependencies]
lazy_static = { version = "1.4.0", features = ["spin_no_std"] }

I also removed the first iteration of the print method, and rewrote the main function like this:

use core::ffi::{c_char, c_int};

#[no_mangle]
pub extern "C" fn main(_argc: c_int, _argv: *const *const c_char) -> isize {
    STD_OUT.println(format!("Hello, {}!", "World"));
    0
}

Notice that format!? That's a gift from the alloc crate!

Technical debt left as an exercise to the reader

  1. Parse the arguments from main to receive the name via command line args. HINT: alloc::slice::from_raw_parts and core::ffi::CStr::to_str
  2. Create a singleton for STD_ERR
  3. Split the code in modules
  4. Fix compilation for MSVC
[package]
name = "hello_world"
version = "0.1.0"
edition = "2021"
[profile.dev]
panic = "abort"
[profile.release]
lto = true
panic = "abort"
[dependencies]
lazy_static = { version = "1.4.0", features = ["spin_no_std"] }
[dependencies.windows-sys]
version = "0.42.0"
features = [
"Win32_Foundation",
"Win32_Storage_FileSystem",
"Win32_System_Console",
"Win32_System_IO",
"Win32_System_Memory",
"Win32_System_Threading",
]
#![feature(default_alloc_error_handler)]
#![no_main]
#![no_std]
#[macro_use]
extern crate alloc;
#[macro_use]
extern crate lazy_static;
use alloc::{
alloc::{GlobalAlloc, Layout},
string::String,
vec::Vec,
};
use core::{
ffi::{c_char, c_int, c_void},
panic::PanicInfo,
ptr::null_mut,
};
use windows_sys::Win32::{
Foundation::{HANDLE, INVALID_HANDLE_VALUE},
Storage::FileSystem::WriteFile,
System::Console::{GetStdHandle, STD_OUTPUT_HANDLE},
System::Memory::{GetProcessHeap, HeapAlloc, HeapFree},
System::Threading::ExitProcess,
};
#[panic_handler]
unsafe fn panic(_info: &PanicInfo) -> ! {
ExitProcess(0xdeafbeef)
}
#[cfg(target_env = "msvc")]
#[link(name = "msvcrt")]
extern "C" {}
#[cfg(target_env = "msvc")]
#[link(name = "vcruntime")]
extern "C" {}
#[cfg(target_env = "msvc")]
#[link(name = "ucrt")]
extern "C" {}
#[cfg(target_env = "gnu")]
#[link(name = "stdc++")]
extern "C" {
fn __gxx_personality_v0(
version: c_int,
actions: c_int,
exception_class: *const c_char,
exception_object: *mut c_void,
context: *mut c_void,
) -> c_int;
}
#[cfg(target_env = "gnu")]
#[no_mangle]
unsafe fn rust_eh_personality(
version: c_int,
actions: c_int,
exception_class: *const c_char,
exception_object: *mut c_void,
context: *mut c_void,
) -> c_int {
__gxx_personality_v0(version, actions, exception_class, exception_object, context)
}
#[derive(Default)]
struct Allocator;
unsafe impl GlobalAlloc for Allocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
// GetProcessHeap() may return a null pointer, in which case the program will crash.
// I'm fine with this behavior, but you may want to extend this code to call HeapCreate()
// when GetProcessHeap() returns a null pointer.
HeapAlloc(GetProcessHeap(), 0, layout.size()) as *mut u8
}
unsafe fn dealloc(&self, ptr: *mut u8, _layout: Layout) {
// GetProcessHeap() may return a null pointer, in which case the program will crash.
// I'm fine with this behavior, but you may want to extend this code to use the handle
// returned by HeapCreate().
HeapFree(GetProcessHeap(), 0, ptr as *const c_void);
}
}
#[global_allocator]
static GLOBAL_ALLOCATOR: Allocator = Allocator;
pub struct StdOut {
handle: HANDLE,
}
impl StdOut {
const NEWLINE_BYTES: &'static [u8] = &['\r' as u8, '\n' as u8, 0];
// Use the `STD_OUT` singleton instead
pub fn _new() -> Self {
let handle = unsafe { GetStdHandle(STD_OUTPUT_HANDLE) };
StdOut { handle }
}
pub fn is_handle_valid(&self) -> bool {
self.handle != INVALID_HANDLE_VALUE
}
pub fn print_bytes(&self, bytes: Vec<u8>) -> usize {
let mut bytes_written = 0u32;
let buffer_size = bytes.len() as u32;
if self.is_handle_valid() {
unsafe {
WriteFile(
self.handle,
bytes.as_ptr() as *const c_void,
buffer_size,
&mut bytes_written,
null_mut(),
);
}
}
bytes_written as usize
}
pub fn print(&self, txt: String) -> usize {
let mut bytes = txt.into_bytes();
bytes.push(0);
self.print_bytes(bytes)
}
pub fn println(&self, txt: String) -> usize {
let mut bytes = txt.into_bytes();
bytes.extend_from_slice(Self::NEWLINE_BYTES);
self.print_bytes(bytes)
}
}
#[no_mangle]
pub extern "C" fn main(_argc: c_int, _argv: *const *const c_char) -> isize {
STD_OUT.println(format!("Hello, {}!", "World"));
0
}
lazy_static! {
pub static ref STD_OUT: StdOut = StdOut::_new();
}
[toolchain]
channel = "nightly-i686-pc-windows-gnu"
@lem0nify
Copy link

Long story short: You need to install the Visual Studio 2017 Build Tools for XP (aka v141_xp Platform Toolset)

No, you don't need it.
It's enough to add subsystem version supported by WinXP to linker arguments: /SUBSYSTEM:CONSOLE,5.01.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment