Rust Unsafe, Raw Pointers and FFI¶
Unsafe Rust does not disable ownership or make undefined behaviour acceptable. It permits specific operations whose safety obligations must be justified by the implementation and its callers. This lesson uses narrow examples with explicit contracts, then completes Part III with a worker that needs no unsafe code at all.
Learning goals and prerequisites¶
Use ownership and borrowing, resource cleanup, thread shutdown and edition configuration to explain why a boundary is safe. You will distinguish unsafe functions from unsafe blocks, trait implementation obligations from ordinary traits, and layout from a complete foreign-interface contract.
Use stable Rust, Cargo and edition 2024. No external libraries, GPIO or system settings are needed. Our C-ABI call links two declarations in one Rust executable; it does not claim to test a separately compiled C library.
Rust unsafe: permission is not proof¶
The unsafe operation list includes raw-pointer dereference, unsafe function calls, union field reads and unsafe trait implementations. Ordinary creation or comparison of a raw pointer is not the same as dereferencing it. Borrow checking and ordinary type checking still operate inside an unsafe block.
An unsafe fn states a requirement its caller must uphold. A small unsafe block identifies where the implementation relies on a justified requirement. The edition-2024 unsafe-operation lint helps separate these roles; our crate denies it so an unsafe fn body cannot silently omit the explicit block.
Run the complete dependency-free program¶
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 | |
Expected output on the verified aarch64 Linux target:
Audit the safe wrapper, not only its test results¶
first_reading accepts a slice, not an arbitrary pointer supplied by safe code. It rejects empty input before the read; the remaining pointer originates in a live shared slice. The wrapper returns a copied integer, so no borrowed pointer escapes. The unsafe function separately states what an unsafe caller must establish.
For production code, values.first().copied() is preferable here. Similarly, zero_value::
Union reads require validity, not a tracked active variant¶
Rust unions share field storage without a discriminant. Writing a field is different from reading one: the read must produce a valid value of its chosen type. The integer fields above occupy the same-sized initialized storage and permit all bit patterns. This argument does not generalise to bool, references or arbitrary structs. Use enums for ordinary tagged states rather than forcing callers to track a union's interpretation manually.
FFI needs layout, ABI, ownership and error policy¶
The layout Reference explains repr(C), alignment and padding. repr(C) does not turn String, Vec, references or trait objects into a universal C data format. Our present byte would need an agreed interpretation such as 0 or 1 at an actual foreign boundary; the struct itself does not enforce that logical policy. Do not serialize its entire memory, including padding, as a portable wire format.
The external-block rules describe the declared ABI and foreign items. A declaration is a promise, not verification of a library implementation. Our safe wrapper relies on the matching symbol defined in the same program and uses c_int for C's int. It crosses the C ABI, but no external C compiler or third-party library was tested. An actual FFI interface must also specify valid pointers/lengths, allocation and freeing responsibilities, retention, threads, errors and unwinding.
The unsafe-attribute rules require explicit acknowledgement for no_mangle in edition 2024. A symbol collision can affect safety outside the function body; our teaching symbol must remain unique. extern "C" is not an instruction to permit Rust panic unwinding through arbitrary C frames. This function uses saturating arithmetic and has no panic path; a broader exported API needs a considered error/unwind boundary.
Undefined behaviour is not an expected failing exercise¶
The undefined-behaviour Reference covers invalid values, data races, dangling/misaligned access, ABI violations and aliasing obligations. Its list is not a complete formal model. Unsafe code must remain valid for every safe caller of its API, not only the fixture that happened to run.
Do not test a null dereference, a dangling reference, an invalid union value or double free by running it and waiting for a crash. Undefined behaviour may appear to succeed and can affect the entire program. The failures below are compiler diagnostics; runtime tests use only valid inputs. Miri can be an additional tool for suitable Rust code, but is not a proof and does not generally validate arbitrary external FFI; this lesson does not claim a Miri run.
Deliberately failing: unsafe fn still needs an unsafe block¶
In a separate scratch project:
cargo check reports E0133 under our lint policy. Repair with a narrow unsafe block and preserve the pointer safety contract, or replace the operation with a safe slice API. Do not add allow merely to remove the diagnostic.
Part III checkpoint: defend ownership and shutdown¶
worker_report is intentionally safe Rust. Explain this sequence before changing it:
- The calling thread retains the input; a scoped worker borrows its immutable slice.
- The worker owns its sender and produces an owned Result, not a borrowed pointer to worker-local data.
- The receiver waits before join, allowing a rendezvous channel to make progress too.
- The function records both receive and join outcomes before propagating an error. A received message alone is not a substitute for successful worker completion.
- Invalid fixture input rejects the entire report, rather than inventing a zero or returning a partial success.
The lexical scope prevents its worker borrow escaping. Explicit join lets us inspect the panic outcome; scoped threads also enforce completion on scope exit. Capacity one bounds this one-message channel, not all possible resource use. No async runtime is required, and adding unsafe would not repair a lifecycle or error-policy mistake.
Checkpoint tasks: change channel capacity to zero and four, add an invalid reading after a valid one, and confirm the empty input policy. Explain what would change if the worker retained owned input instead. For multiple workers, inspect every join outcome even if an earlier worker fails; cancellation requires an explicit protocol rather than merely dropping a result handle. Revisit lesson 21 for Stop versus sender disconnection, and lesson 22 for why dropping a future is not universal rollback.
Exercises and troubleshooting¶
- Replace first_reading's implementation with values.first().copied(). The same tests and output must pass without unsafe code in that wrapper.
- Remove the unsafe block around the union read. Expect E0133. Explain validity before restoring it.
- Remove unsafe from the trait implementation. Expect E0200: implementing an unsafe trait is an explicit obligation, not inference from Copy.
- Remove unsafe from the extern block, or use #[no_mangle] without its unsafe wrapper. Edition 2024 rejects these declarations. Do not assume older syntax means the safety obligation disappeared.
- Make a repr(C, packed) struct with a u8 followed by i32 and try to borrow the i32 field. Expect E0793. Copying a field or using an appropriately justified unaligned raw-pointer operation is different from creating an aligned reference; do not execute a misaligned reference workaround.
- Inspect every SAFETY comment. Can a safe caller violate any premise through the exposed API? A passing test does not replace that argument.
Verification and next step¶
On October 10, 2026, this lesson was verified on the authorised Raspberry Pi 4B with 64-bit user space, kernel 6.18.50+rpt-rpi-v8, Rust and Cargo 1.99.0, and edition 2024. Checks, nine debug/release tests, formatting, debug/release outputs and five further comparisons passed. The safe slice repair and capacity-zero/four checkpoint variants passed their tests and outputs. Six compile failures checked missing unsafe-operation blocks, unsafe trait implementation, union access, extern declaration, unsafe attributes and packed references. No undefined-behaviour example was executed, no external C library or Miri run was tested, and these checks do not establish soundness for arbitrary unsafe extensions.
Continue with files, arguments and a Linux CLI: validate input and expose a predictable process contract without unnecessary unsafe code.