Writing a PHP
extension in Rust
Or
What is a PHP extension?
Levi Morrison
There’s a lot to cover here
PHP extensions aren’t something a lot of people know.
PHP extensions are normally written in C or C++.
This is about PHP extensions in Rust.
This talk is only ~50 minutes. I don’t have time to teach Rust too, but I’ll try to keep it accessible.
I might skip some slides.
About Me
Authored over 10 RFCs, some which were accepted include:
Have been working at Datadog for the past 6 years on their PHP extensions.
I write PHP extensions at Datadog
Two years working on ddtrace.
It’s very hard.
Four years working datadog-profiling.
Works best when you don’t have to embed too deeply into the engine.
Examples of Extensions
PHP extensions are libraries
So let’s make a Rust library:
% cargo init --lib native
% tree -C "native"
native
├── Cargo.toml
└── src
└── lib.rs
% cd native
% cat -n "Cargo.toml"
1 [package]
2 name = "native"
3 version = "0.1.0"
4 edition = "2021"
5
6 [lib]
7 crate-type = ["cdylib"]
8
9 [dependencies]
10 libc = "0.2"
Build it
% cargo build� Updating crates.io index� Locking 2 packages to latest compatible versions� Compiling libc v0.2.159� Compiling native v0.1.0� Finished `dev` profile [unoptimized + debuginfo] target(s) in 20.63s
% file "target/debug/libnative.dylib"�target/debug/libnative.dylib: Mach-O 64-bit dynamically linked shared library arm64
Run it
% php -d "extension=target/debug/libnative.dylib" --version
Warning: PHP Startup: Invalid library (maybe not a PHP library) 'target/debug/libnative.dylib' in Unknown on line 0
Search for error on heap.space
115 zend_module_entry *(*get_module)(void);
...
187 get_module = (zend_module_entry *(*)(void))
DL_FETCH_SYMBOL(handle, "get_module");
...
197 if (!get_module) {
...
203 DL_UNLOAD(handle);
204 php_error_docref(NULL, error_type, "Invalid library (maybe not a PHP library) '%s'", filename);
205 return FAILURE;
206 }
1 mod zend {� 2 #[repr(C)]� 3 pub struct ModuleEntry {}� 4 }� 5 � 7 mod module {� 8 use crate::zend;� 9 � 10 #[no_mangle]� 11 extern "C" fn get_module() -> *mut zend::ModuleEntry {� 12 core::ptr::null_mut()� 13 }� 14 }
lib.rs
% cargo build
% php -d "extension=target/debug/libnative.dylib" --version
zsh: segmentation fault php -dextension=target/debug/...
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x20)
frame #0: 0x0000000100229164 php`php_load_extension(filename=<unavailable>, type=1, start_now=0) at dl.c:208:59 [opt]
205 return FAILURE;
206 }
207 module_entry = get_module();
-> 208 if (zend_hash_str_exists(&module_registry, module_entry->name, strlen(module_entry->name))) {
209 zend_error(E_CORE_WARNING, "Module \"%s\" is already loaded", module_entry->name);
210 DL_UNLOAD(handle);
211 return FAILURE;
zend_module_entry native_module_entry = {
STANDARD_MODULE_HEADER,
"native", /* Extension name */
ext_functions, /* zend_function_entry */
NULL, /* PHP_MINIT - Module init */
NULL, /* PHP_MSHUTDOWN - Module shutdown */
PHP_RINIT(native), /* PHP_RINIT - Request init */
NULL, /* PHP_RSHUTDOWN - Request shutdown */
PHP_MINFO(native), /* PHP_MINFO - Module info */
PHP_NATIVE_VERSION, /* Version */
STANDARD_MODULE_PROPERTIES
};
71 struct _zend_module_entry {
...
78 const char *name;
79 const struct _zend_function_entry *functions;
80 zend_result (*module_startup_func)(INIT_FUNC_ARGS);
81 zend_result (*module_shutdown_func)(SHUTDOWN_FUNC_ARGS);
82 zend_result (*request_startup_func)(INIT_FUNC_ARGS);
83 zend_result (*request_shutdown_func)(SHUTDOWN_FUNC_ARGS);
84 void (*info_func)(ZEND_MODULE_INFO_FUNC_ARGS);
85 const char *version;
...
87 #ifdef ZTS
88 ts_rsrc_id* globals_id_ptr;
89 #else
90 void* globals_ptr;
91 #endif
...
Split into different files
% tree -C "src"
src
├── lib.rs
├── module.rs
└── zend.rs
zend.rs
3 #[repr(C)]
4 pub enum Result {
5 Success = 0,
6 Failure = -1,
7 }
...
18 #[repr(C)]
19 pub struct ModuleEntry {
20 pub size: libc::c_ushort,
21 pub zend_api: libc::c_uint,
22 pub zend_debug: libc::c_uchar,
23 pub zts: libc::c_uchar
...
36 #[cfg(php_zts)]
37 pub globals_id_ptr: *mut ts_rsrc_id,
38 #[cfg(not(php_zts))]
39 pub globals_ptr: *mut libc::c_void,
...
49 }
zend.rs
51 impl Default for ModuleEntry {
52 fn default() -> ModuleEntry {
53 ModuleEntry {
54 size: 0,
55 zend_api: 0,
56 zend_debug: 0,
57 zts: 0,
58 ini_entry: null(),
...
78 }
79 }
80 }
% cat -n "src/lib.rs"� 1 mod module;� 2 mod zend;
% cat -n "src/module.rs"� 1 use crate::zend;� 2 � 3 #[no_mangle]� 4 extern "C" fn get_module() -> *mut zend::ModuleEntry {� 5 Box::leak(Box::new(zend::ModuleEntry::default()))� 6 }
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x20)
frame #1: 0x0000000100229170 php`php_load_extension(filename=<unavailable>, type=1, start_now=0) at dl.c:208:65 [opt]
205 return FAILURE;
206 }
207 module_entry = get_module();
-> 208 if (zend_hash_str_exists(&module_registry, module_entry->name, strlen(module_entry->name))) {
209 zend_error(E_CORE_WARNING, "Module \"%s\" is already loaded", module_entry->name);
210 DL_UNLOAD(handle);
211 return FAILURE;
module.rs
1 use crate::zend;
2
3 #[no_mangle]
4 extern "C" fn get_module() -> *mut zend::ModuleEntry {
5 Box::leak(Box::new(zend::ModuleEntry {
-> 6 name: c"native".as_ptr(),
7 ..Default::default()
8 }))
9 }
% cargo build
Compiling native v0.1.0 (%s/writing-a-php-extension-in-rust)�error: null characters in C string literals are not supported
--> src/module.rs:6:23
|
6 | name: c"native\0".as_ptr(),
| ^^
% cargo build
% php -d "extension=target/debug/libnative.dylib" --version
Warning: PHP Startup: native: Unable to initialize module
Module compiled with module API=0
PHP compiled with module API=20230831
These options need to match
in Unknown on line 0
�PHP 8.3.8-dev (cli) (built: May 17 2024 11:49:59) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.3.8-dev, Copyright (c) Zend Technologies
module.rs
1 use crate::zend;
2
3 #[no_mangle]
4 extern "C" fn get_module() -> *mut zend::ModuleEntry {
5 Box::leak(Box::new(zend::ModuleEntry {
-> 6 zend_api: 20230831,
7 name: c"native".as_ptr(),
8 ..Default::default()
9 }))
10 }
% cargo build
% php -d "extension=target/debug/libnative.dylib" --version
zsh: segmentation fault php -dextension=target/debug/...
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x20)
frame #1: 0x00000001002291d0 php`php_load_extension(filename=<unavailable>, type=1, start_now=0) at dl.c:223:5 [opt]
220 DL_UNLOAD(handle);
222 return FAILURE;
222 }
-> 223 if (strcmp(module_entry->build_id, ZEND_MODULE_BUILD_ID)) {
224 php_error_docref(NULL, error_type,
225 "%s: Unable to initialize module\n"
226 "Module compiled with build ID=%s\n"
module.rs
1 use crate::zend;
2
3 #[no_mangle]
4 extern "C" fn get_module() -> *mut zend::ModuleEntry {
5 Box::leak(Box::new(zend::ModuleEntry {
6 zend_api: 20230831,
7 name: c"native".as_ptr(),
-> 8 build_id: c"".as_ptr(),
9 ..Default::default()
10 }))
11 }
% cargo build
% php -d "extension=target/debug/libnative.dylib" --version
Warning: PHP Startup: native: Unable to initialize module
Module compiled with build ID=
PHP compiled with build ID=API20230831,NTS
These options need to match
module.rs
1 use crate::zend;
2
3 #[no_mangle]
4 extern "C" fn get_module() -> *mut zend::ModuleEntry {
5 Box::leak(Box::new(zend::ModuleEntry {
6 zend_api: 20230831,
7 name: c"native".as_ptr(),
-> 8 build_id: c"API20230831,NTS".as_ptr(),
9 ..Default::default()
10 }))
11 }
% cargo build
% php -d "extension=target/debug/libnative.dylib" --version
PHP 8.3.8-dev (cli) (built: May 17 2024 11:49:59) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.3.8-dev, Copyright (c) Zend Technologies
What is in a zend_module_entry?
What is in a zend_module_entry?
PHP ext_skel script
% cd "$php_src/ext"
% php ext_skel.php --ext native --onlyunix
% tree -C native
native
├── config.m4
├── native.c
├── native.stub.php
├── native_arginfo.h
├── php_native.h
└── tests
├── 001.phpt
├── 002.phpt
└── 003.phpt
phpize + configure + make
% phpize # These numbers are for PHP 8.3�Configuring for:�PHP Api Version: 20230831�Zend Module Api No: 20230831�Zend Extension Api No: 420230831
% ./configure # Standard
% make # Standard
% make install # Standard
How do we “oxidize” this process?
cargo has tools to handle configure and make, but not make install.
What about phpize?
Build Scripts
“Placing a file named build.rs in the root of a package will cause Cargo to compile that script and execute it just before building the package.
Some example use cases of build scripts are:
Let’s run PHP scripts in build.rs!
% php -r "echo PHP_ZTS ? 1 : 0, PHP_EOL;"�0
% php -r "echo PHP_DEBUG ? 1 : 0, PHP_EOL;"�0
build.rs
fn php_script_num(arg: &str) -> u64 {
let output = std::process::Command::new("php")
.arg("-r")
.arg(arg)
.output()
.expect("Unable to run `php`. Is it in your PATH?");
if !output.status.success() {
match String::from_utf8(output.stderr) {
Ok(stderr) => panic!("`php` failed: {stderr}"),
Err(err) => panic!("`php` failed, not utf-8: {err}"),
}
}
std::str::from_utf8(output.stdout.as_slice())
.expect("`php script stdout to contain valid utf-8")
.trim()
.parse::<u64>()
.expect("php vernum to be a number and fit in u64")
}
build.rs
1 fn php_script_num(arg: &str) -> u64 {
...
18 }
19
20 fn main() {
21 let zts = php_script_num("echo PHP_ZTS ? 1 : 0, PHP_EOL;");
22 let debug = php_script_num(
"echo PHP_DEBUG ? 1 : 0, PHP_EOL;");
23
24 println!("cargo::rustc-check-cfg=cfg(php_zts)");
25 if zts != 0 {
26 println!("cargo:rustc-cfg=php_zts");
27 }
28
29 println!("cargo::rustc-check-cfg=cfg(php_debug)");
30 if debug != 0 {
31 println!("cargo:rustc-cfg=php_debug");
32 }
33 }
src/module.rs
...
3 #[no_mangle]
4 extern "C" fn get_module() -> *mut zend::ModuleEntry {
5 Box::leak(Box::new(zend::ModuleEntry {
6 zend_api: 20230831,
7 name: c"native".as_ptr(),
8 build_id: c"API20230831,NTS".as_ptr(),
-> 9 zend_debug: cfg!(php_debug) as u8,
-> 10 zts: cfg!(php_zts) as u8,
11 ..Default::default()
12 }))
13 }
Build Scripts: bindgen
�typedef struct Doggo {
int many;
char wow;
} Doggo;
#[repr(C)]
pub struct Doggo {
pub many: std::os::raw::c_int,
pub wow: std::os::raw::c_char,
}
“Automatically generates Rust FFI bindings to C (and some C++) libraries.”
Let’s do something!
module_startup_func -> MINIT
module_shutdown_func -> MSHUTDOWN
request_startup_func -> RINIT
request_shutdown_func -> RSHUTDOWN
MINIT
PHP_MINIT_FUNCTION(native);
zend_result ZEND_MODULE_STARTUP_N(native)(INIT_FUNC_ARGS);
zend_result zim_startup_native(int type, int module_number);
typedef enum {� SUCCESS = 0,� FAILURE = -1,�} ZEND_RESULT_CODE;�typedef ZEND_RESULT_CODE zend_result;
src/module.rs
1 use crate::zend;
2 use libc::c_int;
3 use std::process;
4
-> 5 extern "C" fn minit(_: c_int, _: c_int) -> zend::Result {
6 eprintln!("[{}] MINIT", process:id());
7 zend::Result::Success
8 }
...
24 #[no_mangle]
25 extern "C" fn get_module() -> *mut zend::ModuleEntry {
26 Box::leak(Box::new(zend::ModuleEntry {
...
30 zend_debug: cfg!(php_debug) as u8,
31 zts: cfg!(php_zts) as u8,
-> 32 module_startup_func: Some(minit),
...
36 ..Default::default()
37 }))
38 }
% cat -n "sleep.php"� 1 <?php� 2 error_log("sleeping...");� 3 sleep(1);
% php -d "extension=target/debug/libnative.dylib" \� -S "0.0.0.0:8080" sleep.php
[Thu Oct 24 21:50:49 2024] PHP 8.3.14-dev Development Server (http://0.0.0.0:8080) started
[Thu Oct 24 21:50:51 2024] 127.0.0.1:63112 Accepted
[68616] RINIT
[Thu Oct 24 21:50:51 2024] sleeping…
[68616] RSHUTDOWN
[Thu Oct 24 21:50:52 2024] 127.0.0.1:63112 Closing
[Thu Oct 24 21:50:52 2024] 127.0.0.1:63114 Accepted
[Thu Oct 24 21:50:52 2024] 127.0.0.1:63115 Accepted
[68616] RINIT
[Thu Oct 24 21:50:52 2024] sleeping…
[68616] RSHUTDOWN
[Thu Oct 24 21:50:53 2024] 127.0.0.1:63114 Closing
[Thu Oct 24 21:50:53 2024] 127.0.0.1:63115 Closed without sending a request; it was probably just an unused speculative preconnection
[Thu Oct 24 21:50:53 2024] 127.0.0.1:63115 Closing
^C[68616] MSHUTDOWN
Module Globals
ZEND_BEGIN_MODULE_GLOBALS(native)
bool at_least_one_request;
ZEND_END_MODULE_GLOBALS(native)
typedef struct _zend_native_globals
{
bool at_least_one_request;
} zend_native_globals;
Module Globals
ZEND_DECLARE_MODULE_GLOBALS(native)
// NTS�zend_native_globals native_globals;
// ZTS�ts_rsrc_id native_globals_id;
Module Globals
struct _zend_module_entry {
//...
#ifdef ZTS
ts_rsrc_id* globals_id_ptr;
#else
void* globals_ptr;
#endif
//...
};
// NTS�zend_native_globals native_globals;
// ZTS�ts_rsrc_id native_globals_id;
C globals are tricky to get right
Globals can be accessed from any code from any thread.
Globals are mutable.
PHP globals are slightly easier
Globals can be accessed from any code from any thread, but generally are localized to the PHP thread.
Globals are still mutable, mostly have to worry about ourselves modifying it though.
Rust doesn’t like shared, mutable state
“Given an object T, it is only possible to have one of the following:
This is enforced by the Rust compiler. However, there are situations where this rule is not flexible enough.”
Plan for module globals in Rust
src/globals.rs
1 use std::cell::RefCell;
2
3 pub struct Globals {
4 pub at_least_one_request: bool,
5 }
7 impl Globals {
8 pub const fn new() -> Globals {
9 Globals {
10 at_least_one_request: false,
11 }
12 }
13 }
15 // A mutable memory location with dynamically checked borrow rules
16 pub type RequestContext = RefCell<Globals>;
2. Define instance of globals
src/module.rs
5 #[cfg(not(php_zts))]
6 #[no_mangle]
7 static mut zend_native_globals: RequestContext =
8 RequestContext::new(Globals::new());
Equivalent to NTS version of ZEND_DECLARE_MODULE_GLOBALS(native)
3. Define helpers to make it safe
src/module.rs
#[cfg(not(php_zts))]
fn get_request_context<'a>(
_: &'a PhpThreadContext
) -> &'a RequestContext {
// SAFETY: safe when called from a PHP thread (no
// other access is allowed).
unsafe { &*addr_of!(zend_native_globals) }
}
}
3. Define helpers to make it safe
src/module.rs
// Not pub, no way to construct out of this module!
struct PhpThreadContext {
// Teach Rust not to send nor share across threads.
_marker: PhantomData<*mut ()>,
}
impl PhpThreadContext {
/// # Safety
/// Caller needs to ensure that it is executed on a PHP thread.
unsafe fn new() -> PhpThreadContext {
PhpThreadContext { _marker: PhantomData }
}
}
3. Define helpers to make it safe
src/module.rs
fn set_request_served(
php_thread: &PhpThreadContext,
) -> Result<(), BorrowMutError> {
let context = get_request_context(php_thread);
context.try_borrow_mut()?.at_least_one_request = true;
Ok(())
}
4. Finally use globals!
src/module.rs
extern "C" fn rinit(_: c_int, _: c_int) -> zend::Result {
eprintln!("[{}] RINIT", process::id());
// SAFETY: rinit is only called by a PHP thread.
let php_thread = unsafe { PhpThreadContext::new() };
if set_request_served(&php_thread).is_err() {
eprintln!(
"[{}] Failed to borrow request context in rinit",
process::id()
);
return zend::Result::Failure;
}
zend::Result::Success
}
4. Finally use globals!
src/module.rs
extern "C" fn mshutdown(_: c_int, _: c_int) -> zend::Result {
eprintln!("[{}] MSHUTDOWN", process::id());
// SAFETY: mshutdown is only called by a PHP thread.
let php_thread = unsafe { PhpThreadContext::new() };
let globals = get_request_context(&php_thread).borrow();
if globals.at_least_one_request {
eprintln!("[{}] Served at least one request.", process::id());
} else {
eprintln!("[{}] Served no requests.", process::id());
}
zend::Result::Success
}
PHP Globals in Rust
% export PHP_CLI_SERVER_WORKERS=2
% php -d "extension=target/debug/libnative.dylib" \
-S "0.0.0.0:8080" sleep.php
[13508] MINIT
...
^C[13510] MSHUTDOWN
[13510] Served at least one request.
[13509] MSHUTDOWN
[13509] Served at least one request.
[13508] MSHUTDOWN
[13508] Served no requests.
PHP Globals in Rust
I had to skip some things, see the repository for more information:
https://github.com/morrisonlevi/writing-a-php-extension-in-rust
We did it!
Does it protect anything in practice?
50 extern "C" fn rinit(_: c_int, _: c_int) -> zend::Result {
51 eprintln!("[{}] RINIT", process::id());
52
53 // SAFETY: rinit is only called by a PHP thread.
54 let php_thread = unsafe { PhpThreadContext::new() };
-> 55 let globals = get_request_context(&php_thread).borrow();
56 if let Err(_) = set_request_served(&php_thread) {
57 eprintln!(
58 "[{}] Failed to borrow request context in rinit",
59 process::id()
60 );
61 return zend::Result::Failure;
62 }
-> 63 drop(globals);
64 zend::Result::Success
65 }
Does it protect anything in practice?
% php -d "extension=target/debug/libnative.dylib" \
-S "0.0.0.0:8080" sleep.php
[15162] RINIT
[15162] Failed to borrow request context in rinit
[15160] [Sun Oct 20 19:53:33 2024] 127.0.0.1:51304 Accepted
[15160] RINIT
[15160] Failed to borrow request context in rinit
[15161] [Sun Oct 20 19:53:33 2024] 127.0.0.1:51303 Accepted
[15161] RINIT
[15161] Failed to borrow request context in rinit
Does it protect anything in practice?
Yes.
Does it protect anything in practice?
This is unintended aliasing.
Datadog Profiler uses Rust
There have been very few crashes in the Datadog profiler, far fewer than the tracer which is mostly still written in C.
We consider writing PHP extensions in Rust to still be difficult but worthwhile.
What other things can extensions do?
https://www.phpinternalsbook.com/php7/extensions_design/hooks.html
Use ext_php_rs and cargo-php instead
use ext_php_rs::prelude::*;
/// Gives you a nice greeting!
/// @param string $name Your name.
/// @return string Nice greeting!
#[php_function]
pub fn hello_world(name: &str) -> String {
format!("Hello, {name}!")
}
// Required to register the extension with PHP.
#[php_module]
pub fn module(module: ModuleBuilder) -> ModuleBuilder {
module
}
Questions?