Software - Prota04/purv-black-box GitHub Wiki

LSM9DS1 Userspace Driver

The LSM9DS1 userspace driver provides an interface for communication with the LSM9DS1 Inertial Measurement Unit (IMU) integrated into the Raspberry Pi Sense HAT.

The LSM9DS1 is a 9-axis motion sensor containing a three-axis accelerometer, a three-axis gyroscope, and a three-axis magnetometer. For the Black Box accident detection system, only the accelerometer and gyroscope are used.

The accelerometer measures acceleration along the X, Y, and Z axes, while the gyroscope measures angular velocity around these axes. The collected measurements are used by the accident detection algorithm to identify potential collisions and vehicle rollovers.

The driver is implemented in C and operates in Linux userspace. It communicates with the sensor through the Linux I2C device.

Source Files

  • include/lsm9ds1.h — Public interface and data structure definitions.
  • src/lsm9ds1.c — Sensor initialization, I2C communication, raw data acquisition, and unit conversion.
  • include/uapi.h — Shared definitions for sensor addresses and raw sample structures.
  • src/task1_lsm9ds1.c — Periodic sensor sampling and integration with the accident detection system.

Sensor Configuration

The accelerometer and gyroscope are configured for high-frequency measurement in order to support real-time accident detection.

Parameter Configuration
Communication interface I2C
I2C bus /dev/i2c-1
Accelerometer/gyroscope I2C address 0x6A
Expected WHO_AM_I value 0x68
Accelerometer full-scale range ±16 g
Gyroscope full-scale range ±500 dps
Sensor output data rate 952 Hz
Application sampling frequency 500 Hz
Application sampling period 2 ms
Measurement resolution 16 bits

The sensor output data rate and application sampling frequency are different parameters. The sensor is internally configured to generate measurements at 952 Hz, while the real-time application requests measurements periodically at 500 Hz.

The selected accelerometer range of ±16 g allows the system to measure acceleration values exceeding the configured 4 g impact detection threshold.

I2C Communication

The driver uses the Linux i2c-dev interface to communicate with the sensor from userspace.

The communication procedure consists of opening the I2C device, selecting the sensor slave address, configuring the sensor registers, and reading the measurement data.

The lsm9ds1_open() function opens /dev/i2c-1 using open() and selects the LSM9DS1 accelerometer/gyroscope address through the I2C_SLAVE ioctl command.

Configuration registers are written using the write() system call, while measurement registers are accessed using the read() system call.

The sensor supports register auto-increment, configured using the IF_ADD_INC bit in CTRL_REG8. This enables consecutive measurement registers to be accessed during a multi-byte transfer.

Register Configuration

The driver configures the following registers during sensor initialization.

Register Address Value Purpose
WHO_AM_I 0x0F 0x68 (expected) Device identification
CTRL_REG1_G 0x10 0xC8 Gyroscope data rate and range
CTRL_REG4 0x1E 0x38 Enable gyroscope X, Y, Z axes
CTRL_REG5_XL 0x1F 0x38 Enable accelerometer X, Y, Z axes
CTRL_REG6_XL 0x20 0xC8 Accelerometer data rate and range
CTRL_REG8 0x22 0x44 Block Data Update and auto-increment

The CTRL_REG8 configuration enables Block Data Update (BDU), which prevents output registers from being updated in the middle of a multi-byte measurement read.

The initialization procedure also includes a 20 ms delay after configuration, allowing the sensor to start producing measurements.

Sensor Data Acquisition

Raw acceleration measurements are read from consecutive registers beginning at OUT_X_L_XL (0x28).

Raw gyroscope measurements are read from consecutive registers beginning at OUT_X_L_G (0x18).

Each axis measurement is represented as a signed 16-bit integer stored in two consecutive 8-bit registers.

The driver reconstructs the measurement using little-endian byte ordering.

The function lsm9ds1_read_sample() obtains raw measurements and converts them into physical units.

For the configured ±16 g accelerometer range:

$$ a[g] = a_{raw} \cdot 0.000732 $$

For the configured ±500 dps gyroscope range:

$$ \omega[rad/s] = \omega_{raw} \cdot 0.01750 \cdot \frac{\pi}{180} $$

Converted acceleration values are stored in units of g, while angular velocity values are stored in radians per second.

Both raw and converted measurements are made available to the application.

Integration with the Black Box System

The LSM9DS1 userspace driver is used by Task 1, the highest-priority real-time thread.

Task 1 periodically calls lsm9ds1_read_sample() to obtain accelerometer and gyroscope measurements.

The raw sensor data is timestamped and written to the custom kernel-space circular buffer through /dev/crash_buffer.

The converted measurements are passed to the accident detection algorithm for collision and rollover analysis.

This separation allows the userspace driver to remain responsible for sensor communication, while the accident detection module performs higher-level signal processing.

Mathematical Model of Accident Detection

The accident detection algorithm uses measurements from the LSM9DS1 accelerometer to detect potential collisions and vehicle rollovers. Two independent triggers are implemented: impact detection and rollover detection.

Impact Detection

A potential collision is detected based on the total acceleration measured along the X, Y, and Z axes.

The acceleration magnitude is calculated as:

a_total = sqrt(ax² + ay² + az²)

where ax, ay, and az are acceleration components expressed in g.

An impact event is triggered when:

a_total >= 4 g

This condition must be satisfied for 2 consecutive samples to avoid triggering on a single acceleration spike.

Rollover Detection

A potential rollover is detected based on the vehicle's orientation, calculated from accelerometer measurements.

Two orientation angles are calculated:

  • Roll – rotation around the X axis.
  • Pitch – rotation around the Y axis.

The angles are calculated as:

roll = atan2(ay, az)

pitch = atan2(-ax, sqrt(ay² + az²))

The total tilt angle is then calculated using:

tilt = acos(cos(roll) * cos(pitch))

The trigonometric calculations use radians, while the resulting angles are expressed in degrees.

A rollover event is triggered when:

tilt >= 60°

The condition must be satisfied for 50 consecutive samples, corresponding to approximately 100 ms at the nominal sampling frequency of 500 Hz.

Accident Trigger and System Response

Separate counters are used for impact and rollover detection. If a measurement falls below the corresponding threshold, its counter is reset.

Once a trigger condition is confirmed, the algorithm sets the appropriate detection flag and returns an ACCIDENT_EVENT_IMPACT or ACCIDENT_EVENT_ROLLOVER event.

Task 1 then records the detection timestamp, locks the kernel ring buffer, and signals Task 2 to activate the camera and SOS response.

The gyroscope measurements are stored for later analysis.

SOS LED matrix output

The display_sos_led_matrix() function in task2_camera.c is responsible for rendering a scrolling "SOS" message in bright red across the LED matrix. This provides undeniable visual confirmation that the Black Box has detected a crash and has secured the data.

Low-Level Framebuffer Implementation

To ensure maximum performance and avoid the overhead of high-level Python libraries or complex graphics stacks, the SOS animation is implemented using direct, low-level hardware access via the Linux Framebuffer (/dev/fbX) interface.

  1. Device Discovery: The system does not hardcode the framebuffer device path (e.g., /dev/fb1), as it can change depending on the connected displays. Instead, the open_sense_hat_framebuffer() function iterates through the sysfs directory (/sys/class/graphics/fbX/name). It reads the name of each framebuffer until it finds the one matching "RPi-Sense FB" or "rpisense", ensuring the correct hardware is targeted.
  2. Direct Memory Writes: Once the framebuffer is opened, the software writes pixel data directly to the device file descriptor. The Sense HAT expects 16-bit RGB565 pixel data. The color red is defined as 0xF800. The write_frame() function uses lseek() to reset the file pointer to the beginning of the framebuffer and writes the 128 bytes (8x8 pixels * 2 bytes) required to update the entire matrix in a single, atomic operation.

The Scrolling Animation Algorithm

The "SOS" text is generated using a predefined 5x11 pixel bitmap font matrix stored in a 2D array (sos[5][MESSAGE_WIDTH]). Because the physical LED matrix is only 8x8 pixels, the 11-pixel wide message cannot be displayed all at once.

To solve this, the software implements a horizontal scrolling animation:

  1. Frame Generation: The create_scroll_frame() function takes the current horizontal offset (message_x) and maps the 5x11 bitmap onto the 8x8 grid. It calculates the screen coordinates for each pixel, ignoring any parts of the text that have scrolled off the left or right edges of the display.
  2. Animation Loop: A for loop shifts the message_x coordinate from the right edge of the matrix (MATRIX_WIDTH) to the left edge (-MESSAGE_WIDTH).
  3. Timing: After writing each frame to the framebuffer, the thread calls nanosleep() with a delay of 150 milliseconds (150L * 1000L * 1000L nanoseconds). This creates a smooth, easily readable scrolling effect.
  4. Cleanup: Once the text has completely scrolled off the left side of the matrix, the function writes a blank frame (all zeros) to turn off the LEDs, and closes the framebuffer device.

Real-Time Execution Context

The SOS display logic is executed entirely within Task 2, which operates under the SCHED_FIFO real-time scheduling policy with a priority of 95. Because Task 2 is awakened by a real-time signal (SIGRTMIN) from Task 1 the moment an accident is detected, the execution of the LED matrix code is guaranteed to be preemptive and deterministic. This ensures that the SOS signal begins flashing almost instantaneously after the physical impact, fulfilling the system's requirement for rapid emergency response signaling.

V4L2 camera capture

To interface with the MIPI-CSI camera, the application utilizes Video4Linux2 (V4L2), the standard Linux kernel framework for real-time video capture. The primary challenge in an accident detection system is minimizing the latency between the physical impact and the availability of the image. Initializing a camera, configuring the sensor, and starting the stream can take several seconds—time that is unacceptable for a crash response system.

To solve this, the camera_capture_v4l2.c module implements a continuous background streaming architecture that keeps the V4L2 pipeline active and ready to deliver a fresh frame the exact millisecond it is requested.

Continuous Background Streaming

Instead of turning the camera on only after an accident is detected, the camera_capture_init() function starts the V4L2 stream during the system's boot sequence.

  1. Subdevice Configuration: Before opening the main video node (/dev/video0), the software configures the IMX219 sensor subdevice (/dev/v4l-subdev0) via VIDIOC_SUBDEV_S_FMT to output the MEDIA_BUS_FMT_SRGGB10_1X10 format. This ensures the hardware pipeline is correctly aligned for 10-bit RAW capture.
  2. Memory Mapping (MMAP): The system requests 4 video buffers from the V4L2 driver using VIDIOC_REQBUFS and maps them directly into user space using mmap(). This zero-copy approach prevents the overhead of copying large image frames between kernel and user space.
  3. The Streaming Thread: A dedicated background thread (camera_stream_thread) is spawned. This thread continuously polls the camera file descriptor. When a frame is ready, it dequeues the buffer (VIDIOC_DQBUF), and immediately requeues it (VIDIOC_QBUF) so the hardware can fill it with the next frame. This relentless cycle ensures the V4L2 pipeline never stalls and the camera is always producing fresh frames.

The Snapshot Mechanism

When Task 1 detects an accident and wakes up Task 2, Task 2 does not need to initialize the camera. Instead, it calls trigger_camera_capture(), which invokes camera_capture_snapshot(). This function simply sets a flag (capture_requested = 1) and waits on a condition variable. The background streaming thread, upon dequeuing the next completed frame, checks this flag. If set, it copies the raw frame data from the MMAP buffer into a dedicated, secure user-space buffer (secure_image_memory) and signals Task 2 that the image is ready.

Latency Measurement and Memory Safety

A critical requirement of the project is measuring the exact latency from the moment the accident threshold is crossed to the moment the first byte of the image is available in user space.

Timestamping: Inside the background thread, right before the memcpy operation begins, the system calls clock_gettime(CLOCK_MONOTONIC) to record first_byte_timestamp_ns. This precise timestamp is passed back to Task 2 to calculate the end-to-end system latency.

Memory Locking: To guarantee that the secure image buffer is never swapped out to the SD card by the Linux kernel's virtual memory manager (which would introduce massive, unpredictable latency), the buffer is locked into physical RAM using the mlock() system call immediately after allocation.

Kernel ring buffer driver (/dev/crash_buffer)

The crash_buffer kernel module provides the circular buffer that stores the most recent IMU samples. It is implemented as a Linux character driver (dev/crash_buffer.c) and exposes the device node /dev/crash_buffer.

The buffer holds BUFFER_SIZE (2500) raw LSM9DS1 samples, which corresponds to 5 seconds of pre-accident data at the 500 Hz sampling rate. It is allocated once at module load with kmalloc_array(). When the buffer is full, a new sample overwrites the oldest one.

Source Files

  • dev/crash_buffer.c — Kernel module implementing the ring buffer and its file operations.
  • include/uapi.h — Shared definitions of BUFFER_SIZE, the raw sample structure, and the ioctl commands.

Interface

Operation Caller Purpose
write() Task 1 Appends one raw sample (the length must equal sizeof(lsm9ds1_raw_sample_t))
read() Task 3 Returns the stored samples ordered from oldest to newest
ioctl(CRASH_BUFFER_IOC_LOCK) Task 1, Task 3 Freezes the buffer
ioctl(CRASH_BUFFER_IOC_UNLOCK) Task 1, Task 3 Unfreezes the buffer and resets it

Synchronization with Spinlocks

The ring buffer is shared between Task 1, which writes a sample every 2 ms, and Task 3, which reads the buffer after an accident. The state that must stay consistent (head, tail, count, the sample array, and the is_buffer_locked flag) is protected by a single spinlock, buffer_lock, declared with DEFINE_SPINLOCK().

A spinlock is used instead of a mutex because every critical section is only a few instructions long. Task 1 runs under SCHED_FIFO and cannot be delayed by a contending thread for long. All lock acquisitions use spin_lock_irqsave() and spin_unlock_irqrestore(), which also disable local interrupts while the lock is held.

Operations that can sleep are deliberately kept outside the locked regions:

  • write() — copy_from_user() is performed before the lock is taken. The critical section only checks the freeze flag, stores the sample at head, advances the indices, and, when the buffer is full, advances tail to discard the oldest sample.
  • read() — The count and tail values are snapshotted under the lock. The kmalloc() of a temporary buffer happens outside the lock. The lock is then taken again only to copy the samples, in chronological order, into the temporary buffer. copy_to_user() runs after the lock is released.
  • ioctl() — Setting and clearing the freeze flag, and resetting head, tail and count on unlock, are done under the lock.

Freeze Mechanism

When Task 1 detects an accident, it calls CRASH_BUFFER_IOC_LOCK, which sets is_buffer_locked under the spinlock. While the flag is set, write() silently drops incoming samples and still reports success, so the real-time task never blocks or fails. This keeps the 5 seconds of pre-accident data from being overwritten while Task 3 reads it.

After the crash data has been saved, CRASH_BUFFER_IOC_UNLOCK clears the flag and resets the buffer indices in a single critical section. Sampling then resumes with an empty buffer.

Crash data storage (Task 3)

Task 3 is responsible for saving the crash evidence to persistent storage. It is implemented in src/storage.c and runs as a normal Linux thread under the SCHED_OTHER policy, because writing to the SD card is slow and must never compete with the real-time tasks.

After an accident, Task 3 reads the frozen IMU history from the kernel ring buffer and stores it together with the camera frame captured by Task 2. Each accident is saved to its own directory, named after the accident detection timestamp.

Source Files

  • include/storage.h — Shared task arguments structure and the public interface of the storage module.
  • src/storage.c — Task 3 thread, communication with /dev/crash_buffer, and writing of the telemetry and image files.
  • include/camera_capture.h — Definition of the camera_image_t structure received from Task 2.

Task Synchronization

Task 3 is idle until Task 2 hands over the crash data. The two tasks communicate through the storage_task_args_t structure, which is protected by a pthread_mutex_t and a pthread_cond_t.

  1. Task 3 locks the mutex and waits on the condition variable until the data_ready flag is set.
  2. After capturing the camera frame, Task 2 locks the mutex and stores the detection timestamp, the captured status, and the image descriptor. It then sets data_ready, calls pthread_cond_signal(), and unlocks the mutex.
  3. Task 3 wakes up, copies the received values into local variables, and releases the mutex before starting any file operations.

Task 3 copies the data under the mutex and releases it before any slow storage operation begins, so Task 2 is never blocked by file I/O. If the camera capture failed, Task 3 still saves the IMU telemetry and reports a warning that no image is available.

Reading the Kernel Ring Buffer

The function storage_task_save_crash_data() performs the following steps:

  1. Creates the base directory /var/log/blackbox and the crash directory crash_<detection_timestamp_ns>.
  2. Opens /dev/crash_buffer.
  3. Freezes the buffer with the CRASH_BUFFER_IOC_LOCK ioctl. Task 1 has already locked the buffer when the accident was detected, so this call only guarantees that the buffer stays frozen while it is being read.
  4. Allocates a user-space buffer for BUFFER_SIZE (2500) samples and reads the complete history with a single read() call. The driver returns the samples ordered from oldest to newest.
  5. Writes the output files.
  6. Unlocks and resets the buffer with CRASH_BUFFER_IOC_UNLOCK, which allows Task 1 to start logging again.

The unlock step is located in the cleanup section of the function, so the buffer is released even when saving a file fails.

Saved Files

File Content
accident_details.txt Accident summary and the full sensor history in CSV format
accident_site.raw Exact bytes of the captured camera frame (10-bit packed Bayer, lossless original)
accident_site.ppm Color preview image generated from the RAW frame

Telemetry file. The header contains the number of recorded samples, the corresponding duration (samples divided by 500 Hz), and the maximum total acceleration in g, calculated as the magnitude of the acceleration vector using the same 0.000732 g/LSB scale as the sensor driver. The sensor history follows as one CSV row per sample: timestamp_ns,accel_x,accel_y,accel_z,gyro_x,gyro_y,gyro_z. The values are the raw sensor readings.

RAW image. The frame is written exactly as it was received from the Unicam block, so all 10-bit sensor data is preserved.

PPM preview. The packed 10-bit format (pRAA) cannot be opened by regular image viewers. The preview is created by unpacking the upper 8 bits of each Bayer sample into an 8-bit Bayer matrix and applying a simple bilinear demosaicing algorithm for the SRGGB pattern. The result is written as a binary RGB (P6) PPM file. If the preview cannot be created, only a warning is printed, because the RAW file already contains the original capture.

Reliable Writing

To make sure that the crash data survives a power loss after the accident, the files are written with the following measures:

  • The telemetry file and the RAW image are flushed to the storage device with fsync() before the files are closed.
  • The RAW image is written in a loop that handles partial writes and retries the call when it is interrupted by a signal (EINTR).
  • The RAW file is created with 0600 permissions.
  • Every step checks its return value, and failures are reported through the cleanup path of the function.