playing with optimized C/C++ code on a pine64
  • C 96.2%
  • Makefile 3.8%
Find a file
Vincent Batts 60fa752074 Clarify that UDS is the reprogramming mechanism, not the parameter map
Adds a section explaining what ISO 14229 actually defines (generic
session/security/block-transfer services) versus what it doesn't (which
addresses hold which calibration parameters, units, or the checksum
algorithm) -- that mapping is a separate "ECU definition" obtained via
OEM docs, diffing dumps, or a tuning company's reverse-engineered
database, not something the protocol itself exposes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 12:45:58 -04:00
src Add GPIO and I2C libraries, plus the Pi-2 header pinout table 2026-07-26 17:04:09 -04:00
.gitignore Untrack editor artifacts accidentally swept in by git add -A 2026-07-25 13:55:32 -04:00
ECU-PROJECT.md Clarify that UDS is the reprogramming mechanism, not the parameter map 2026-07-27 12:45:58 -04:00
LICENSE Initial commit 2026-07-25 16:19:37 +00:00
Makefile Add GPIO and I2C libraries, plus the Pi-2 header pinout table 2026-07-26 17:04:09 -04:00
README.md Document ECU reverse-engineering project plan and hardware recommendations 2026-07-26 18:18:45 -04:00

tmp.2026-07-25.2n4Z

playing with optimized C/C++ code on a pine64

See ECU-PROJECT.md for a separate, in-progress plan to use the GPIO/I2C header work below plus a CAN interface to dump/modify/ reflash a car ECU.

what's here

Five small, self-contained C programs, each demonstrating a different way to actually use the hardware on a 4-core Pine64 (Cortex-A53):

  • mandelbrot -- renders the Mandelbrot set, splitting rows across pthread worker threads (no locks needed, since each thread only writes its own rows). -b runs a benchmark sweep from 1 thread up to nproc and prints a speedup table. -p block|stripe picks how rows are handed out, and -v prints each thread's row count and elapsed time.
  • bench_crc32, bench_aes, bench_sha256 -- each computes the same checksum/cipher/hash two ways: a portable software implementation, and a hardware version using the CPU's dedicated instructions (ARMv8 CRC/Crypto Extension on aarch64 via <arm_acle.h>/ <arm_neon.h>; SSE4.2/AES-NI on x86_64/i386 for CRC32 and AES via <nmmintrin.h>/<wmmintrin.h> -- x86 SHA is deliberately not implemented, see src/sha256/sha256.c). Each one checks its own output against known-answer test vectors (FIPS-197 for AES, FIPS 180-4 for SHA-256) and checks software vs. hardware agree, before trusting the speedup number.
  • hwcheck -- checks whether /dev/gpiochip* and /dev/i2c-* exist and are actually openable by the current user, without reading or writing any hardware state. Run this before writing any GPIO/I2C code against the board; see "GPIO / I2C access" below.
  • gpio_tool, i2c_tool -- CLIs exercising src/gpio/gpio.c and src/i2c/i2c.c, thin wrappers around the Linux GPIO/I2C character-device ioctls (<linux/gpio.h>, <linux/i2c-dev.h>, <linux/i2c.h>) -- no vendor SDK, no libgpiod dependency. See "GPIO / I2C hardware access" below for the header pinout and library details.

All build the same way on any Linux box; the hardware paths are picked automatically for the arch you're building on (see the Makefile), so you can sanity-check CRC32/AES on a regular x86_64 dev machine before ever touching the board (SHA-256 hardware is aarch64-only here).

build & run

make                          # builds everything into bin/
./bin/mandelbrot -b           # thread-count vs. speedup table
./bin/mandelbrot -o out.ppm   # render one image (view with e.g. `feh out.ppm`)
./bin/bench_crc32   128 20    # 128 MiB buffer, 20 iterations
./bin/bench_aes     128 10
./bin/bench_sha256  128 10
./bin/hwcheck                 # can this user touch GPIO/I2C without sudo?
./bin/gpio_tool list           # header pin table + live line state (read-only)
./bin/i2c_tool scan 1          # probe I2C1 header pins (bus 1) for devices

measured on the actual pine64 (Cortex-A53, ARMv8 Crypto Extension)

benchmark software hardware speedup
CRC32C (64 MiB) 104.8 MiB/s 975.8 MiB/s 9.3x
AES-128 ECB (32 MiB) 5.7 MiB/s 197.7 MiB/s 34.7x
SHA-256 (48 MiB) 29.0 MiB/s 153.0 MiB/s 5.3x

All three passed their known-answer self-tests on-device, and software vs. hardware output matched byte-for-byte in every run.

On the Pine64 (Armbian/Debian, Cortex-A53, Mali400 via lima): same make, same commands, over ssh -- no cross-compiling needed since gcc runs natively there.

a real finding: block partitioning doesn't scale monotonically

Running mandelbrot -b on the pine64 gives a non-monotonic result -- 3 threads is slower than 2:

threads  seconds   speedup
      1    4.569     1.00x
      2    2.285     2.00x
      3    2.790     1.64x   <- worse than 2 threads!
      4    1.869     2.44x

-v shows why: with block partitioning, thread 1's row range straddles the dense middle of the set (interior points run the full max_iter loop; exterior points escape in a handful of iterations, and the set is symmetric about the real axis), so it does 3x the work of its siblings while they sit idle at pthread_join:

$ ./bin/mandelbrot -t 3 -p block -v -o /tmp/m.ppm
    thread 0:   240 rows  0.896s
    thread 1:   240 rows  2.790s   <- straggler
    thread 2:   240 rows  0.890s

-p stripe (round-robin row assignment, y % nthreads == thread_id instead of contiguous chunks) spreads the expensive middle rows evenly across every thread regardless of count, and fixes it:

threads  seconds   speedup
      1    4.571     1.00x
      2    2.279     2.01x
      3    1.615     2.83x
      4    1.159     3.95x   <- essentially linear

The general lesson: for any embarrassingly-parallel workload, contiguous block partitioning only balances well if the work is uniform across the partition axis (or, by luck, if the split happens to land on a symmetry in the workload, the way a 2-way split does here). When cost is skewed, interleaved/striped partitioning is the standard fix.

Note: this specific board's Mali400 GPU only supports OpenGL ES 2.0 (graphics) via the mainlined lima driver -- there's no OpenCL or Vulkan compute path on Utgard-generation Mali, so "GPU" work here means shader graphics, not general compute. The CPU side is where the real, measurable wins are: multithreading across the 4 cores, and the hardware-accelerated CRC32/AES/SHA instructions exposed by the crc32 aes sha1 sha2 pmull flags in lscpu -- see the results table above.

Possible next steps: a from-scratch, carefully-verified x86 SHA-NI path (intentionally skipped for now, see src/sha256/sha256.c); or the EGL + OpenGL ES 2.0 stretch goal to actually exercise the Mali400 via lima (needs libegl1-mesa-dev libgles2-mesa-dev installed on the board first).

GPIO / I2C hardware access

Both are exposed as Linux character devices (/dev/gpiochip*, /dev/i2c-*), controlled via ioctls from the kernel's own public headers (<linux/gpio.h>, <linux/i2c-dev.h>, <linux/i2c.h>) -- no vendor SDK needed. But on a stock Armbian image, neither is usable by a regular user out of the box:

  • GPIO: /dev/gpiochip* defaults to root:root, mode 0600 -- no gpio group exists at all. Fixed with a udev rule plus a group:

    sudo groupadd -f gpio
    sudo usermod -aG gpio "$USER"
    sudo tee /etc/udev/rules.d/99-gpio.rules <<'EOF'
    SUBSYSTEM=="gpio", KERNEL=="gpiochip*", GROUP="gpio", MODE="0660"
    EOF
    sudo udevadm control --reload-rules
    sudo udevadm trigger
    

    This board has three gpiochips -- gpiochip0/gpiochip1 are the SoC's own pin controller, gpiochip2 is the AXP803/AXP813 PMIC's GPIO lines (a separate driver, axp20x-gpio) -- and a single udevadm trigger may not re-apply the rule to a chip that was already present before the rule was added; retrigger that one specifically if hwcheck still shows it as denied: sudo udevadm trigger --action=add <its /sys path>.

  • I2C: i2c-tools already ships a udev rule (60-i2c-tools.rules) that sets /dev/i2c-* to group i2c, mode 0660 -- but you still have to be a member of that group yourself:

    sudo usermod -aG i2c "$USER"
    
  • Either way, log back in (new SSH session, or newgrp gpio/newgrp i2c) afterward -- group membership is read at login, so an already-open shell won't see the change.

Run ./bin/hwcheck after any of the above to confirm it actually worked -- it reports each device as accessible / permission-denied / not found, with exit status 0 only once everything's usable without sudo.

the library: no vendor SDK, just the kernel's own uAPI

src/gpio/gpio.c and src/i2c/i2c.c are thin wrappers around the same ioctls any GPIO/I2C library uses under the hood -- built against the exact struct/ioctl definitions in this board's own /usr/include/linux/gpio.h, <linux/i2c-dev.h>, <linux/i2c.h>, not guessed from memory:

  • GPIO uses the v2 character-device uAPI: open() a /dev/gpiochipN, GPIO_V2_GET_LINE_IOCTL to request a specific line offset as input or output (for output, the initial value is set atomically as part of the same request, so the pin is never briefly left at some other state), then GPIO_V2_LINE_GET_VALUES_IOCTL/_SET_VALUES_IOCTL on the returned line fd to read/write it. gpio_get_line_info() queries a line's name/ used-state without requesting it -- read-only, safe regardless of what's wired to the pin.
  • I2C uses ioctl(fd, I2C_SLAVE, addr) to select a device, then plain read()/write(), or I2C_RDWR with two struct i2c_msgs for a combined register read with a proper repeated START (what i2c_read_reg() does -- a plain write() then read() risks the adapter inserting a STOP in between, which most sensors don't expect). i2c_probe() uses an SMBus "quick" transaction (no data byte, just an address+direction ACK/NACK check) to detect devices, picking read vs. write direction per address range the same way i2cdetect's default mode does, to avoid upsetting devices in ranges known to dislike a write-direction probe.

Pi-2 header pinout (Pine A64/A64+/A64-LTS)

src/gpio/pine64_pinout.c maps the 40-pin, Raspberry-Pi-layout header to (chip, offset) pairs. Source: the "Pi-2/Euler/Ext Bus Connector Pin Assignment" schematic linked from wiki.pine64.org, cross-checked against this exact board's live gpioinfo (line counts and reserved-line positions matched the offset arithmetic for every port-labeled pin):

  • Ports B-H live on the main pin controller, /dev/gpiochip0 (256 lines): offset = bank*32 + pin, A=0, B=1, ..., H=7 (e.g. PC7 -> offset 71).
  • Port L (the always-on domain) lives on /dev/gpiochip1 (32 lines): offset = pin directly (e.g. PL10 -> offset 10).
  • A few header pins were only labeled with a bare Raspberry-Pi-style BCM GPIO number in the source schematic, with no Allwinner port name given -- those are left unverified (chip = NULL) rather than guessed at; gpio_tool list marks them clearly.

Verified live on the board:

$ ./bin/gpio_tool list
pin  signal        port  chip            offset  info
  3  I2C1_SDA      PH3   /dev/gpiochip0  227     free
  5  I2C1_SCL      PH2   /dev/gpiochip0  226     free
 11  -             PC7   /dev/gpiochip0  71      free
  ...
$ ./bin/gpio_tool get 11
pin 11 (PC7, /dev/gpiochip0 offset 71) = 1
$ ./bin/i2c_tool scan 1   # I2C1, physical pins 3/5 -- clean scan, nothing attached

gpio_tool set and i2c_tool read-reg actually drive a pin or address a real device -- only point those at a physical pin/address you've confirmed is safe to touch (nothing sensitive wired to it).