Course: Jetson ESP-Hosted Host Code guide · Phase 2 — Embedded Linux
Previous: Lecture 02 · Next: Lecture 04 — How Wi-Fi becomes wlan0
Read:
esp_hosted_ng/host/spi/esp_spi.c
This file is where the host turns:
- SPI bus selection
- chip select
- handshake GPIO
- data-ready GPIO
into a working transport endpoint for the ESP.
That makes it the best file for learning how transport glue looks in a real Embedded Linux driver.
This lecture sits directly on top of:
- OS Lecture 3 — Interrupts, Exceptions & Bottom Halves
- OS Lecture 17 — Linux Device Driver Model & Device Tree
- OS Lecture 18 — Character Drivers, Interrupt-Driven I/O & V4L2
Lecture 3 from the OS course gives the important interrupt model: hardware raises an IRQ, the kernel acknowledges it quickly, and real work is deferred safely. That is the right frame for reading gpio_to_irq(...), request_irq(...), and the workqueue-driven SPI path in esp_spi.c.
Lecture 17 explains how Linux matches a driver to an already-described device, and Lecture 18 explains event-driven I/O once the device is alive. Together they explain why this code first reuses spi0.0 from the kernel device model and then turns handshake and data-ready GPIO edges into an interrupt-driven transport instead of polling blindly.
The detail that matters most is that an IRQ is not “just a GPIO change notification.” In Linux, an IRQ is the kernel’s promise that when the hardware edge arrives, the driver will get scheduled through the interrupt path and can move the protocol forward with low latency.
That is why the validation focused on /proc/interrupts counts and not only on pin names. A correctly named GPIO that never produces an IRQ is operationally useless, while a line with rising counters proves that the electrical signal, the GPIO mapping, the IRQ translation, and the kernel handler path are all aligned.
The kernel interfaces to track here are:
module_param(...)- SPI device lookup and reuse
gpio_to_irq(...)request_irq(...)- IRQ-driven work scheduling
Near the top of esp_spi.c, look for these:
spi_bus_numspi_chip_selectspi_handshake_gpiospi_dataready_gpiospi_mode
These are exposed as module_param(...).
That tells you:
- transport wiring is now configurable at load time
- the driver can be reused across boards without recompilation
This is a direct improvement over hardcoded board assumptions.
You can see that policy-to-transport bridge right at the top of spi/esp_spi.c:
static ushort spi_bus_num = DEFAULT_SPI_BUS_NUM;
static ushort spi_chip_select = DEFAULT_SPI_CHIP_SELECT;
static int spi_handshake_gpio = DEFAULT_HANDSHAKE_PIN;
static int spi_dataready_gpio = DEFAULT_SPI_DATA_READY_PIN;
static uint spi_mode = DEFAULT_SPI_MODE;
module_param(spi_bus_num, ushort, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
module_param(spi_chip_select, ushort, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
module_param(spi_handshake_gpio, int, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
module_param(spi_dataready_gpio, int, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
module_param(spi_mode, uint, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);This is where “Jetson pin choices” stop being shell-script text and become transport state consumed by the driver.
spi_dev_init(...) is the heart of transport bring-up.
Conceptually, it is responsible for:
- selecting the Linux SPI device
- configuring SPI mode and clock
- setting up the transport context
- requesting and configuring the handshake/data-ready GPIOs
- mapping those GPIOs into IRQs
This is the point where a “Linux board description” becomes an “active transport endpoint.”
That is an important Embedded Linux mental shift:
- device tree and sysfs prove presence
- transport init proves usability
The critical part of spi_dev_init(...) is worth reading literally:
esp_info("Config - SPI GPIOs: Handshake[%d] Dataready[%d]\n",
spi_context.handshake_gpio, spi_context.dataready_gpio);
esp_info("Config - SPI clock[%dMHz] bus[%d] cs[%d] mode[%d]\n",
spi_context.spi_clk_mhz, esp_board.bus_num,
esp_board.chip_select, esp_board.mode);
status = gpio_request(spi_context.handshake_gpio, "SPI_HANDSHAKE_PIN");
...
spi_context.handshake_irq = gpio_to_irq(spi_context.handshake_gpio);
status = request_irq(spi_context.handshake_irq, spi_interrupt_handler,
IRQF_SHARED | IRQF_TRIGGER_RISING,
"ESP_SPI", spi_context.esp_spi_dev);
...
status = gpio_request(spi_context.dataready_gpio, "SPI_DATA_READY_PIN");
...
spi_context.dataready_irq = gpio_to_irq(spi_context.dataready_gpio);
status = request_irq(spi_context.dataready_irq, spi_data_ready_interrupt_handler,
IRQF_SHARED | IRQF_TRIGGER_RISING,
"ESP_SPI_DATA_READY", spi_context.esp_spi_dev);That snippet is the transport bring-up story in one place:
- log the configuration
- request the GPIOs
- convert them to IRQs
- bind the IRQ handlers that make the transport live
The validated Jetson path used:
spi_handshake_gpio=471spi_dataready_gpio=433
But those numbers alone are not success.
The stronger success signal is:
- the host driver requested them
- they became IRQ sources
- interrupt counters moved when the ESP booted
That is how you should debug GPIO-based bring-up:
- name -> request -> IRQ mapping -> actual edge activity
If you stop at “the number looks right,” you have not really debugged anything.
The Jetson fork reuses an existing SPI device such as:
spi0.0
when possible.
That matters because Linux may already have:
- a device tree node
- a bus number
- a chip-select mapping
This is better than always forcing the driver to invent a new SPI device object.
Embedded Linux lesson:
- respect the kernel’s device model when it already describes the hardware correctly
The reuse behavior is explicit:
existing_dev = spi_find_device(esp_board.bus_num, esp_board.chip_select);
if (existing_dev) {
...
spi_context.esp_spi_dev = existing_dev;
esp_info("Reusing existing SPI device spi%u.%u\n",
esp_board.bus_num, esp_board.chip_select);
} else {
master = spi_busnum_to_master(esp_board.bus_num);
...
spi_context.esp_spi_dev = spi_new_device(master, &esp_board);
}The shell script unbinds spidev, but the transport code is written to cooperate with the existing SPI device path.
The validated Jetson work changed the runtime clock behavior.
The host can receive a request from the ESP boot-up path to move to:
26 MHz
But the validated Jetson flow intentionally keeps the host capped at:
10 MHz
Why this is important:
- transport stability beat nominal peak speed during bring-up
- the driver now uses
clockspeed=as both:- initial speed
- runtime ceiling
That is a very real Embedded Linux engineering pattern:
- make the code reflect the validated board limit
- then widen later only with measurement
The actual clamp logic is concise:
static void adjust_spi_clock(u8 spi_clk_mhz)
{
u8 target_spi_clk = spi_clk_mhz;
if (spi_context.spi_clk_cap_mhz && target_spi_clk > spi_context.spi_clk_cap_mhz) {
esp_info("ESP requested SPI CLK %u MHz, clamping to host limit %u MHz\n",
target_spi_clk, spi_context.spi_clk_cap_mhz);
target_spi_clk = spi_context.spi_clk_cap_mhz;
}
if (target_spi_clk != spi_context.spi_clk_mhz) {
esp_info("ESP Reconfigure SPI CLK to %u MHz\n", target_spi_clk);
spi_context.spi_clk_mhz = target_spi_clk;
spi_context.esp_spi_dev->max_speed_hz = target_spi_clk * NUMBER_1M;
}
}The key transport-level log lines were:
Received ESP boot-up eventChipset=ESP32-C6 ID=0d detected over SPIESP requested SPI CLK 26 MHz, clamping to host limit 10 MHz
That proves:
- SPI data transfer is alive
- the protocol exchange is alive
- the transport policy is being enforced
This is a much stronger success criterion than:
- module inserted
- no kernel oops
/dev/spidev0.0exists
Do these:
- Find where
spi_context.spi_bus_numandspi_context.spi_chip_selectget filled. - Find where the handshake and data-ready GPIO values enter the transport context.
- Find the function that handles runtime SPI clock changes.
- Write down the three strongest transport-level log lines you would want during bring-up.
Previous: Lecture 02 · Next: Lecture 04 — How Wi-Fi becomes wlan0