Using Rust and Zephyr together (III)

In the last two installments of this series we looked at simple ways to get Rust to work with Zephyr. We got some basic stuff to work, however, there’s still one major point, which we haven’t looked at at all:

Syscalls & Peripherals

Zephyr’s syscalls come in two flavours: User mode and kernel mode syscalls, as the name implies the user mode calls are invoked by usermode code, whereas kernelmode syscalls implement the actual logic of the syscalls. Very much like a Linux kernel driver works.

The elephant in the room with respect to Zephyr’s syscalls is, that they’re not really known ahead of time. The syscalls file is generated during the initial CMake run and might change, whenever the kconfig tool is invoked. As with most other Zephyr calls, syscalls are defined as static inline, which – again – causes some headaches as those are not visible for bindgen. To make matters worse, interfacing with peripherals in Zephyr will usually mean using macros that reference the device tree as shown below:

/* The devicetree node identifier for the "led0" alias. */
#define LED0_NODE DT_ALIAS(led0)

/*
 * A build error on this line means your board is unsupported.
 * See the sample documentation for information on how to fix this.
 */
static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(LED0_NODE, gpios);

K_MUTEX_DEFINE(mtx);

int main(void)
{
	k_mutex_lock(&mtx, K_FOREVER);	

	if (!gpio_is_ready_dt(&led)) {
		return 0;
	}
/* ... */

If we follow the GPIO_DT_SPEC_GET and DT_ALIAS macros we’ll find a huge rabbit hole of further macros with no real hope to untangle on the Rust side. In order to somehow get access to these peripherals we’ll either have to write C wrappers or – again – generate code that makes them visible to Rust. Thankfully the “gpio_dt_spec” type is a simple struct, so this will work:

extern "C" 
{     
    pub static mut led: bindings::gpio_dt_spec;
}

Using the same codegeneration approach as with the other kernel objects is simple enough here and will be reliable enough for our usecase. We obviously still have to figure out a way to be able to do the actual syscall (gpio_is_ready_dt).

Digging deeper

As we’ve already figured out that directly using a syscall is not easily possible and we’ll need another way of doing that, preferrably without writing too much additional code. If we dig around Zephyr’s codebase, especially the code generated during the kernel configuration we’ll finde a file called “syscalls.json”. This file seems to contain a list of all available syscalls for the current configuration in machine readable form:

[
    [
        [
            "int sys_cache_data_flush_range",
            "void *addr, size_t size"
        ],
        "cache.h"
    ],
    [
        [
            "int sys_cache_data_invd_range",
            "void *addr, size_t size"
        ],
        "cache.h"
    ],
    
    ....

This is actually pretty neat. While the data here needs a bit of cleaning up to be completely usable this file’s content will allow us to get out of the “static inline” mess with very moderate effort: Since we know, each of the functions in the file point to a static inline function we can make that function “visible” to Rust by creating a function with the same signature that forwards a call to the actuall implementation, i.e. given the above definition of sys_cache_data_flush_range we want to create a C function sys_cache_data_flush_range_rs, that looks as follows:

int sys_cache_data_flush_range_rs (void *addr, size_t size)
{
  return sys_cache_data_flush_range(addr, size);
}

Since the above is no longer static inline it can be picked up by bindgen. I whipped up a quick python script to do just that for us:

import json
import re

def extract_parameter_names(parameter_list):
    parameters = parameter_list.split(',')

    parameter_names = []
    for param in parameters:
        param = param.strip()
        param = re.sub(r'[*\[\]]', '', param)

        # Extract the parameter name using regex
        match = re.search(r'\b\w+\b$', param)
        if match:
            parameter_name = match.group()
            parameter_names.append(parameter_name)

    return parameter_names

def find_from_right(string, characters, start_offset=0):
    for i in range(start_offset - 1, 0, -1):
        if string[i] in characters:
            return i
    return -1

def extract_return_type(function_signature):
    try:
        opening_parenthesis_index = function_signature.index('(')
        last_whitespace_index = find_from_right(function_signature, ' *', opening_parenthesis_index)
        return_type = function_signature[:last_whitespace_index+1].strip()
        return return_type
    except ValueError:
        return None

def extract_function_name(function_signature):
    try:
        opening_parenthesis_index = function_signature.index('(')
        last_whitespace_index = function_signature.rfind(' ', 0, opening_parenthesis_index)
        function_name = function_signature[last_whitespace_index+1:opening_parenthesis_index].strip().lstrip('*')
        return function_name
    except ValueError:
        return None

def read_syscalls_json(file_path):
    with open(file_path, 'r') as file:
        content = json.load(file)
    return content

file_path = "../../build/zephyr/misc/generated/syscalls.json"
result = read_syscalls_json(file_path)

header_files = []
seen_values = set()

entries_to_ignore = ["cache.h", 
                     "device.h", 
                     "error.h", 
                     "socket.h",
                     "socket_select.h",
                     "phy.h",
                     "ivshmem.h",
                     "usb_bc12.h",
                     "uart_mux.h",
                     "espi_saf.h",
                     "uart_mux.h",
                     "flash_simulator.h",
                     "nrf_qspi_nor.h",
                     "maxim_ds3231.h",
                     "sip_svc_driver.h",
                     "usb_bc12.h",
                     "ivshmem.h",
                     "log_ctrl.h",
                     "log_msg.h",
                     "updatehub.h",
                     "ethernet.h",
                     "net_if.h",
                     "net_ip.h",
                     "phy.h",
                     "socket.h",
                     "socket_select.h",
                     "time.h",
                     "rand32.h",
                     "rtio.h",
                     "atomic_c.h",
                     "errno_private.h",
                     "kobject.h",
                     "libc-hooks.h",
                     "mem_manage.h",
                     "time_units.",
                     "mutex.h"]

entries_to_rewrite = {
    "kernel.h": "<zephyr/kernel.h>",
    "time_units.h": "<zephyr/sys/time_units.h>",
}

for key,value in result:
    if value not in seen_values and value not in entries_to_ignore:
        if value in entries_to_rewrite:
            _value = entries_to_rewrite[value]
        else:
            _value = f"<zephyr/drivers/{value}>"
        header_files.append(_value)
        seen_values.add(value)

with open ("./syscall_stub.c", "w") as file:
    for header_file in header_files:
        file.write(f"#include {header_file}\n")

    for key,value in result:  
        if value not in entries_to_ignore:
            ret_ty = extract_return_type(key[0]+"()")  
            retty = extract_function_name(key[0]+"()")
            file.write(f"{ret_ty} {retty}_rs({key[1]}){{ return {retty}("); 
            params = extract_parameter_names(key[1])
            for i, param in enumerate(params):
                if (param != "void"):
                    file.write(f"{param}")
                    if i < len(params) - 1:
                        file.write(",")
            file.write(");}\n")
        else:
            print(f"Ignoring {key[0]}")

with open ("./syscall_stub.h", "w") as file:
    for header_file in header_files:
        file.write(f"#include {header_file}\n")
    for key,value in result:
        if value not in entries_to_ignore:             
            file.write(f"{key[0]}_rs({key[1]});\n"); 

Things to note:

  • The include files in the original json are always just file names, we don’t get relative paths or similar. The script uses a heuristic here saying “if there’s no entry in the ‘entries_to_rewrite’ dict, assume that the file is located in “<zephyr/drivers>”
  • The json file contains references to calls that are not necessarily available for all platforms. I’m not quite sure why they show up in the first place but I didn’t want to dig deeper into Zephyr’s build system. Instead the script contains a list of includes that should be ignored. All calls that come from headers in that list will not processed.
  • The script is obviously quick and dirty and not ready for production use.

Running the script will create a header and a .c file for us containing declarations and implementations of our syscalls. All that remains to do is to integrate them with the rest of the build:

  • We add them to our application’s cmake lists file.
  • We add an #include “syscall_stub.h” statement to the header file that is fed to bindgen.

Going back to the thread functions we defined in the last installment, we can now access the syscalls from the “bindings::” namespace and – e.g.- implement a blinking led like this:

pub fn another_thread_func_impl()
{

    loop {
        unsafe
        {                        
            bindings::r_sleep(1000);
            bindings::gpio_port_toggle_bits_rs(led.port, 1 << led.pin as usize);                
            bindings::r_sleep(1000);
            bindings::gpio_port_toggle_bits_rs(led.port, 1 << led.pin as usize);                
        }
    }
}

Note: I did add the function r_sleep manually. There is k_sleep but that function takes ticks as an argument which are usually obtained by means of a macro, i.e. not nicely usable from Rust. The r_sleep function is implemented in C as:

void r_sleep(unsigned int ms)
{
    k_sleep(K_TIMEOUT_ABS_MS(ms));
}

Wrap Up & Next Steps

Alas, we’ve done it and made Zephyr syscalls available for Rust with moderate effort. There are obviously more areas to work on if one wants to have a production ready environment:

  • All calls Zephyr are currently unsafe. It would be nice to have safe abstractions.
  • In order to make full use of Rusts safety guarantees with respect to data races we’ll at least need an implementation of a mutex (or similar) in Rust that wraps the Zephyr mutex (but is Send+Sync!)
  • While the mechanism to pull syscalls from syscalls.json is decent, since that file is a Zephyr-internal thing, Zephyr’s developers might change the format at any time they need it differently (or even drop it altogether!). This will obviously break the integration outlined in this series.

Image Credit: Nik via Unsplash

Leave a Reply

Your email address will not be published. Required fields are marked *