The architecture overview showed the layers. This page follows a single value through all of them, naming the actual mechanism at every boundary — because "the layers talk to each other" is not enough to debug with.
The value: cabin temperature, driver's side. It starts as a voltage and ends
as the characters 21.5° on a screen.
Hop 1 — A thermistor, and a voltage#
A thermistor in the cabin changes resistance with temperature. An puts a known voltage across it and measures what comes back through an analogue-to-digital converter.
The ECU now has a number — say 2048 out of 4095. That is not a temperature.
It is a raw ADC reading that only means something with the thermistor's curve
applied.
Hop 2 — The ECU broadcasts a CAN frame#
The ECU converts its reading into the units the vehicle's network specification says it should send, packs it into a small message, and broadcasts it.
ID: 0x1A4 ← which message this is (not who sent it)
Length: 8 bytes
Data: [ 0x8F 0x02 0x00 0x00 0x1B 0x00 0x00 0x00 ]
└─┬──┘
cabin temperature lives in bytes 0-1The two bytes 0x8F 0x02 are 655 as a little-endian 16-bit value. The vehicle's
signal database says this signal is raw × 0.1 − 40, so 655 means 25.5 °C.
Hop 3 — The kernel receives it#
The CAN transceiver on the board raises an interrupt. A kernel driver reads the frame out of the controller's buffer and hands it to the networking stack.
On Linux this is usually SocketCAN, which presents CAN as a network interface, so user-space code reads frames from a socket.
ip link set can0 up type can bitrate 500000
candump can0
# can0 1A4 [8] 8F 02 00 00 1B 00 00 00Hop 4 — The vendor service translates#
Now we cross into software somebody wrote for this vehicle. This layer is not in AOSP — it is the car maker's or their supplier's.
Its job is to turn bus traffic into the values the promised.
void OnCanFrame(const CanFrame& frame) {
if (frame.id != 0x1A4) return;
// Extract the bits this signal occupies
uint16_t raw = frame.data[0] | (frame.data[1] << 8);
// Apply the scaling from the signal database. Exactly once.
float celsius = raw * 0.1f - 40.0f;
publisher_.Update(kHvacTemperatureCurrent, kAreaDriver, celsius);
}Hop 5 — The Vehicle HAL publishes#
The stores the value in its cache and, if anyone is subscribed, publishes a change event.
VehiclePropValue update{
// CLOCK_BOOTTIME nanoseconds — nanoseconds since the device booted.
// Not wall clock. Not milliseconds.
.timestamp = elapsedRealtimeNano(),
.areaId = SEAT_ROW_1_LEFT,
.prop = toInt(VehicleProperty::HVAC_TEMPERATURE_CURRENT),
.status = VehiclePropertyStatus::AVAILABLE,
};
update.value.floatValues = {25.5f};
property_change_callback_(std::vector{std::move(update)});adb shell dumpsys car_service --get-property 0x11600203 -a 1Hop 6 — Binder into Car Service#
The VHAL is a separate process from . The change event crosses that boundary as a transaction — the mechanism from the previous module.
CarPropertyService receives it and does four things:
- Updates its own cache, so a later
getdoes not need to reach the HAL. - Checks which apps are subscribed to this property and area.
- Filters by user — a background user's app should not receive events it should not see.
- Fans out one HAL event to every subscribed app.
adb shell dumpsys car_service --services CarPropertyServiceHop 7 — Binder into your app#
Another process boundary, another Binder transaction. Your callback runs.
override fun onChangeEvent(value: CarPropertyValue<*>) {
// This is a BINDER THREAD, not your main thread.
if (value.status != CarPropertyValue.STATUS_AVAILABLE) return
pendingTemperature = value.value as Float
scheduleRender() // hand off; do not draw here
}Hop 8 — Onto the screen#
Your handler posts to the main thread, the view or composable updates, SurfaceFlinger composites the frame, and the display shows it.
private fun scheduleRender() {
// A gauge at 60 fps does not need 60 Hz of data. Interpolate between
// samples instead of redrawing on every event.
if (!renderScheduled) {
renderScheduled = true
mainHandler.post { render(pendingTemperature); renderScheduled = false }
}
}The whole path as a debugging tool#
This is the real payoff. When a value is wrong, you now have eight places to look and a command for most of them.
| Hop | Question | How to check |
|---|---|---|
| 1–2 | Is the ECU sending anything? | candump can0 |
| 2 | Is the raw value right? | Compare against the signal database |
| 3 | Does the kernel see frames? | ip -s link show can0 |
| 4 | Did the vendor layer scale it correctly? | Compare candump with the next row |
| 5 | What does the HAL hold? | dumpsys car_service --get-property |
| 5 | Is the HAL even running? | lshal | grep vehicle |
| 6 | Does Car Service know the property? | dumpsys car_service --list-properties |
| 6 | Is anything blocked by policy? | dmesg | grep avc |
| 7 | Does the app hold the permission? | dumpsys package <pkg> |
| 7–8 | Is the app rendering what it received? | Log the callback value |
Timing: where the milliseconds go#
For a driver-visible value, the whole path usually needs to complete in under about 200 ms. A rough breakdown:
| Hop | Typical cost |
|---|---|
| ECU sample and broadcast interval | 10–100 ms |
| Kernel receive | under 1 ms |
| Vendor translation | under 1 ms |
| VHAL publish | under 1 ms |
| Binder to Car Service | 1–5 ms |
| Car Service fan-out | 1–5 ms |
| Binder to app | 1–5 ms |
| Render | 16 ms (one frame) |
adb shell perfetto -o /data/misc/perfetto-traces/t -t 20s \
sched freq binder_driver am wm gfx view halbinder_driver is the category that makes the two IPC hops visible. Without it,
a trace shows your app waiting and gives no clue why.
What changes on a virtualised head unit#
On a consolidated cockpit computer, hop 3 is different: instead of a CAN driver, Android is a guest under a and receives values from another guest through shared memory or a virtio channel.
Next#
Building a property of your own that travels this whole path.

