Thermal Zones Trip Points and Sensors
A thermal zone’s control loop has two kinds of input: a sensor that tells the kernel how hot the hardware is, and a set of trip points that say what temperatures matter and what should happen at each. The sensor is just a driver callback,
->get_temp, returning a temperature in milli-degrees Celsius; the trip points are an array ofstruct thermal_trip, each carrying atemperature, ahysteresis, and atypedrawn from{ACTIVE, PASSIVE, HOT, CRITICAL}(include/uapi/linux/thermal.h, v6.12). The type decides the consequence: an active trip spins fans, a passive trip throttles via frequency caps, a hot trip notifies userspace, and a critical trip drives an emergency shutdown throughthermal_zone_device_critical()→hw_protection_shutdown()(thermal_core.c, v6.12). This note dissects those inputs: where temperatures come from, what each trip type triggers, how hysteresis prevents flapping, how trips bind to cooling devices, and how the.set_tripscallback turns polling into interrupt-driven monitoring.
This is the companion to The Thermal Framework, which owns the overall architecture and the monitoring loop. Here we zoom into the left half of that loop — the sensor and the trips — and the binding that connects a trip to a cooling device. The cooling devices and governor algorithms themselves are in Cooling Devices and Thermal Governors.
Mental Model: A Thermometer Marked With Lines
Picture a thermometer with four colored lines drawn on it. As the mercury rises past the green line, a fan turns on (active). Past the yellow line, the engine is told to ease off — run slower (passive). Past the orange line, an alarm sounds to whoever is watching (hot). Past the red line, the whole machine is killed to save it (critical). Each line also has a small band beneath it — once you cross a line going up, you do not “uncross” it until you fall a little below it, so the fan does not stutter on and off around a single temperature (hysteresis). That picture is almost exactly the data model: a trip is a line (temperature), a band (hysteresis), and a meaning (type); the sensor is the mercury.
flowchart TB GETTEMP["sensor driver<br/>->get_temp() → milli-Celsius"] subgraph TRIPS["trips[] : ascending temperatures"] A["ACTIVE 50°C<br/>→ fan on"] P["PASSIVE 80°C<br/>→ cap CPU freq"] H["HOT 95°C<br/>→ notify userspace"] C["CRITICAL 105°C<br/>→ shutdown"] end GETTEMP -->|"compare each poll"| A GETTEMP --> P GETTEMP --> H GETTEMP --> C A -. "bound via thermal_instance" .-> FAN["cooling device:<br/>Fan (active)"] P -. "bound via thermal_instance" .-> CAP["cooling device:<br/>cpufreq cap (passive)"] C ==>|"bypasses governor"| KILL["hw_protection_shutdown()<br/>orderly_poweroff(true)"]
Figure 1 — the four trip types of a CPU thermal zone and what each drives. What it shows: trips are an ascending ladder of temperatures; active and passive trips bind (through a thermal_instance) to cooling devices the governor manages, while the critical trip is a hard line wired straight to the kernel’s emergency-shutdown path. The insight to take: trip type is not cosmetic — it routes the crossing to completely different machinery. Active/passive go through the governor and cooling devices (graceful); hot goes only to userspace (advisory); critical bypasses everything and kills the machine (last resort). Designing a zone is choosing temperatures and assigning the right type to each.
Where Temperature Comes From — The Sensor
Every zone has exactly one sensor, exposed through the mandatory ->get_temp operation in struct thermal_zone_device_ops (include/linux/thermal.h, v6.12):
int (*get_temp)(struct thermal_zone_device *tz, int *temp);The driver fills *temp with the current temperature in milli-degrees Celsius (55000 = 55.000 °C) and returns 0, or returns a negative errno. The core wraps this in __thermal_zone_get_temp(), which is the only sanctioned reader — drivers and sysfs both go through it, under tz->lock (thermal_helpers.c, v6.12):
int __thermal_zone_get_temp(struct thermal_zone_device *tz, int *temp)
{
lockdep_assert_held(&tz->lock);
ret = tz->ops.get_temp(tz, temp); // call the driver
if (IS_ENABLED(CONFIG_THERMAL_EMULATION) && tz->emul_temperature) {
/* find the critical trip temperature ... */
if (!ret && *temp < crit_temp) // only below critical
*temp = tz->emul_temperature; // allow fake injection
}
return ret;
}Two details matter. First, the wrapper enforces that emulation can never hide a real over-critical reading — if the true temperature is already at or above the critical trip, the injected emul_temperature is ignored. This is a deliberate safety property: you can use echo 90000 > emul_temp to test your trips, but you cannot use it to suppress a genuine meltdown. Second, the trend (rising/dropping/stable) that governors like step_wise consume is derived here too — get_tz_trend() compares tz->temperature to tz->last_temperature, but if the driver supplies a ->get_trend op it is preferred (some hardware reports trend directly).
Concrete sensor sources, by platform:
- x86 CPU package — the
x86_pkg_temp_thermaldriver reads the per-package Digital Thermal Sensor (DTS) and registers a zone of typex86_pkg_temp. acpitz is a separate ACPI-firmware-described zone, usually a coarser motherboard reading. - hwmon bridge — many sensors already expose readings through the hwmon (hardware monitoring) subsystem; the thermal core can also publish a zone’s temperature back into hwmon (the
no_hwmonflag inthermal_zone_paramscontrols this), solm-sensorsand the thermal framework see the same number. - SoC junction sensors via Device Tree — on ARM/RISC-V, an on-die temperature sensor (TSENS on Qualcomm, the TI bandgap sensor, etc.) is wired to a zone through the DT
thermal-zonesbinding (below). Its driver implements->get_tempagainst the sensor’s registers.
Trip Points — struct thermal_trip
A trip point is small (include/linux/thermal.h, v6.12):
struct thermal_trip {
int temperature; // the threshold, milli-Celsius
int hysteresis; // relative hysteresis, milli-Celsius
enum thermal_trip_type type; // ACTIVE / PASSIVE / HOT / CRITICAL
u8 flags; // THERMAL_TRIP_FLAG_RW_TEMP / _RW_HYST
void *priv; // driver data (DT path uses it for the trip node)
};Internally the core wraps each trip in a struct thermal_trip_desc that adds the runtime bookkeeping the loop needs — most importantly threshold, the effective comparison point that shifts down by hysteresis once the trip is crossed (thermal_core.h, v6.12):
struct thermal_trip_desc {
struct thermal_trip trip;
struct thermal_trip_attrs trip_attrs; // the sysfs files
struct list_head notify_list_node; // queued for crossing notification
int notify_temp;
int threshold; // effective trip line (with hysteresis)
};The flags field is how a trip becomes writable from sysfs. THERMAL_TRIP_FLAG_RW_TEMP makes trip_point_N_temp writable; THERMAL_TRIP_FLAG_RW_HYST makes trip_point_N_hyst writable. Trips parsed from Device Tree are given THERMAL_TRIP_FLAG_RW_TEMP so userspace and tools can retune them at runtime (thermal_of.c, v6.12).
The four trip types and what each triggers
The type enum is in the UAPI header so userspace sees the same values (include/uapi/linux/thermal.h, v6.12):
enum thermal_trip_type {
THERMAL_TRIP_ACTIVE = 0, // "active"
THERMAL_TRIP_PASSIVE, // "passive"
THERMAL_TRIP_HOT, // "hot"
THERMAL_TRIP_CRITICAL, // "critical"
};The routing happens in handle_thermal_trip(). When a trip is crossed upward (temperature reaches trip->temperature with no mitigation yet under way), the core does:
} else if (tz->temperature >= trip->temperature) {
list_add_tail(&td->notify_list_node, way_up_list); // always: notify
td->notify_temp = trip->temperature;
td->threshold -= trip->hysteresis; // arm hysteresis band
if (trip->type == THERMAL_TRIP_PASSIVE)
tz->passive++; // PASSIVE: count → faster polling + governor
else if (trip->type == THERMAL_TRIP_CRITICAL ||
trip->type == THERMAL_TRIP_HOT)
handle_critical_trips(tz, trip); // HOT/CRITICAL: act NOW
}So, type by type:
- ACTIVE — falls through to the default case (no special branch here). Its consequence is delivered by the governor, which, when it runs
->manage(), finds the active trip crossed and drives the bound cooling device — typically a fan. Active cooling adds something that removes heat without slowing the workload. Detail in Passive and Active Cooling. - PASSIVE — increments
tz->passive. A non-zeropassivecount does two things: it makes the loop poll at the shorterpassive_delay(so throttling reacts faster), and it signals the governor to throttle the bound cooling device — usually a CPU/GPU frequency cap. “Passive” means cooling by slowing the source rather than adding a fan. - HOT — calls
handle_critical_trips(), which invokes the driver’s->hot(tz)op if present. This is an advisory notification to firmware/userspace (“getting dangerous”) that does not by itself shut the machine down — it is a hook for platform-specific emergency handling short of a full kill. - CRITICAL — also calls
handle_critical_trips(), which invokestz->ops.critical(tz)— the shutdown path (next section).
handle_critical_trips() itself is tiny and shows the HOT/CRITICAL split clearly (thermal_core.c, v6.12):
static void handle_critical_trips(struct thermal_zone_device *tz,
const struct thermal_trip *trip)
{
trace_thermal_zone_trip(tz, thermal_zone_trip_id(tz, trip), trip->type);
if (trip->type == THERMAL_TRIP_CRITICAL)
tz->ops.critical(tz); // shutdown
else if (tz->ops.hot)
tz->ops.hot(tz); // advisory
}A second crucial fact, in thermal_governor_trip_crossed(): the governor is explicitly not told about HOT or CRITICAL crossings (if (trip->type == THERMAL_TRIP_HOT || trip->type == THERMAL_TRIP_CRITICAL) return;). Those types are handled synchronously and entirely outside the policy layer — exactly so that no governor bug can swallow a meltdown.
Hysteresis — why trips do not flap
Hysteresis is the band that prevents a cooling action from chattering on and off when the temperature hovers right at a trip. The mechanism is the threshold field. Reading handle_thermal_trip() (thermal_core.c, v6.12):
When a trip is crossed upward at temperature T_trip, the core sets td->threshold = T_trip - hysteresis. From then on, the downward comparison is against T_trip - hysteresis, not T_trip:
if (tz->last_temperature >= old_threshold && ...) {
/* mitigation already under way; stop it only if we fall below the band */
if (tz->temperature < trip->temperature - trip->hysteresis) {
list_add(&td->notify_list_node, way_down_list); // crossed down
td->notify_temp = trip->temperature - trip->hysteresis;
if (trip->type == THERMAL_TRIP_PASSIVE) {
tz->passive--;
WARN_ON(tz->passive < 0);
}
} else {
td->threshold -= trip->hysteresis; // stay engaged
}
}Worked example: a passive trip at 80 °C with 5 °C hysteresis. The CPU heats to 80 °C → trip crosses up, passive++, throttling begins, and the effective threshold drops to 75 °C. Now the CPU cools to 78 °C — above 75 °C, so throttling stays on (no flap). Only when it falls below 75 °C does the trip cross down, passive--, and throttling release. Without the band, a CPU oscillating between 79 °C and 81 °C would toggle throttling every poll. The hysteresis is configured per trip (the DT hysteresis property; the trip_point_N_hyst sysfs file if writable).
The Critical Trip — Emergency Shutdown in Full
When a CRITICAL trip fires, tz->ops.critical(tz) runs. If the driver gave no ->critical op, registration installed the default thermal_zone_device_critical(), which calls thermal_zone_device_halt(tz, /*shutdown=*/true) → hw_protection_shutdown(). That is an inline wrapper in include/linux/reboot.h over __hw_protection_shutdown() in kernel/reboot.c (reboot.c, v6.12):
void __hw_protection_shutdown(const char *reason, int ms_until_forced, bool shutdown)
{
static atomic_t allow_proceed = ATOMIC_INIT(1);
pr_emerg("HARDWARE PROTECTION shutdown (%s)\n", reason);
if (!atomic_dec_and_test(&allow_proceed)) // only once
return;
/* Queue a backup forced poweroff in case orderly_poweroff() hangs */
hw_failure_emergency_poweroff(ms_until_forced);
if (shutdown)
orderly_poweroff(true); // ask userspace nicely
else
orderly_reboot();
}The design has two layers. orderly_poweroff(true) invokes the userspace poweroff handler (systemd’s), giving the system a chance to flush filesystems and shut down cleanly. But because a wedged userspace must not be allowed to let the chip burn, hw_failure_emergency_poweroff() first arms a timer (CONFIG_THERMAL_EMERGENCY_POWEROFF_DELAY_MS milliseconds) that will force the machine off unconditionally if the orderly path has not completed in time. The allow_proceed atomic guarantees the whole sequence runs at most once even if several zones cross critical simultaneously. There is also a reboot variant: the Device-Tree critical-action = "reboot" property makes a zone use thermal_zone_device_critical_reboot() (→ hw_protection_reboot() → orderly_reboot()) instead of poweroff (thermal_of.c, v6.12).
This is why you see kernel: ... critical temperature reached ... shutting down followed seconds later by a hard power-off in logs after a thermal event — the orderly request and its forced backup, exactly as designed. Conversely, a misconfigured critical trip (firmware setting it absurdly low) produces spurious shutdowns, a recurring real-world bug class.
Binding a Trip to a Cooling Device
A trip alone does nothing for active/passive cooling until a cooling device is bound to it, creating a thermal_instance. In the modern (post-rework) framework, binding is decided by the zone’s ->should_bind callback. When a cooling device registers, thermal_zone_cdev_bind() walks every trip in the zone and asks should_bind whether this cooling device belongs on this trip; if yes, it calls thermal_bind_cdev_to_trip() (thermal_core.c, v6.12):
static void thermal_zone_cdev_bind(struct thermal_zone_device *tz,
struct thermal_cooling_device *cdev)
{
struct thermal_trip_desc *td;
if (!tz->ops.should_bind)
return;
for_each_trip_desc(tz, td) {
struct cooling_spec c = { .upper = THERMAL_NO_LIMIT,
.lower = THERMAL_NO_LIMIT,
.weight = THERMAL_WEIGHT_DEFAULT };
if (!tz->ops.should_bind(tz, &td->trip, cdev, &c))
continue;
thermal_bind_cdev_to_trip(tz, &td->trip, cdev, &c);
}
}thermal_bind_cdev_to_trip() then allocates a thermal_instance, resolves the cooling-state range (lower defaults to 0, upper defaults to cdev->max_state), validates lower <= upper <= max_state, creates the cdevN, cdevN_trip_point, and cdevN_weight sysfs files, links the instance into both tz->thermal_instances and cdev->thermal_instances, and pokes the governor (THERMAL_TZ_BIND_CDEV). The cooling_spec carries the range and weight — so you can say “on this passive trip, drive the cpufreq cooling device only between states 2 and 5, weighted 1024.” On the Device-Tree path, the range comes from the cooling-device = <&cpu0 THERMAL_NO_LIMIT 4> cells in the cooling-maps node, parsed by thermal_of_should_bind() (thermal_of.c, v6.12).
Uncertain
Verify: that
->should_bind(withstruct cooling_spec) is the current and only binding mechanism in 6.12, fully replacing the older.bind/.unbindzone ops. Reason: the binding API was reworked during the 2022–2024 thermal overhaul and the v6.12thermal.hshowsshould_bindinthermal_zone_device_opswith no.bind/.unbind, but I did not exhaustively confirm no legacy path remains for non-OF drivers. To resolve: grep the 6.12 tree for.bind =indrivers/thermal/and confirm onlyshould_bindis wired intothermal_zone_cdev_bind(). uncertain
The .set_trips Boundary-Interrupt Optimization
Polling wastes power: waking the CPU every second to read a sensor that has not changed is exactly the kind of busy-work power management exists to avoid. Hardware with a programmable temperature-threshold interrupt lets the kernel stop polling entirely. The mechanism is the optional ->set_trips op:
int (*set_trips)(struct thermal_zone_device *tz, int low, int high);After each update, the loop computes the bounding window — the highest trip below the current temperature (low) and the lowest trip above it (high) — and calls thermal_zone_set_trips() (thermal_trip.c, v6.12):
void thermal_zone_set_trips(struct thermal_zone_device *tz, int low, int high)
{
lockdep_assert_held(&tz->lock);
if (!tz->ops.set_trips)
return;
if (tz->prev_low_trip == low && tz->prev_high_trip == high)
return; // window unchanged → nothing to do
tz->prev_low_trip = low;
tz->prev_high_trip = high;
/* Driver programs hardware to interrupt when temp leaves [low, high]. */
ret = tz->ops.set_trips(tz, low, high);
}The driver programs the sensor’s comparators to raise an interrupt when the temperature crosses below low or above high. While the temperature stays inside that window, no trip state can change, so there is nothing to do and the kernel does not poll at all — a zone registered with polling_delay = 0 and a working ->set_trips is purely interrupt-driven. The IRQ handler calls thermal_zone_device_update(tz, ...), which re-reads the sensor, re-evaluates trips, re-computes the window, and re-arms via set_trips. The prev_low_trip/prev_high_trip check avoids redundant reprogramming when the window has not moved.
This is the headline reason the modern framework scales to battery-powered SoCs: between adjacent trips the thermal subsystem is entirely idle, woken only by a boundary IRQ, instead of polling on a timer. The framework still falls back to timed polling on hardware without threshold interrupts (a non-zero polling_delay), and runs the shorter passive_delay while actively throttling so it reacts quickly during a thermal event. A related but distinct facility — userspace-settable thresholds (no hysteresis, notification-only) added in 2024 — lets a thermal daemon get the same boundary-crossing notifications without faking trip points in firmware (LWN: “Add thermal thresholds support”).
The Device-Tree thermal-zones Binding
On ARM/RISC-V the entire zone — sensor, trips, and cooling maps — is declared in the Device Tree under a thermal-zones node, and devm_thermal_of_zone_register() builds the C structures from it (thermal_of.c, v6.12; DT thermal binding):
thermal-zones {
cpu-thermal {
polling-delay-passive = <250>; // ms while throttling
polling-delay = <1000>; // ms otherwise
thermal-sensors = <&tsens 0>; // phandle to the sensor + its index
trips {
cpu_alert: trip-point0 {
temperature = <80000>; // 80 °C, milli-Celsius
hysteresis = <2000>; // 2 °C
type = "passive";
};
cpu_crit: trip-point1 {
temperature = <105000>; // 105 °C
hysteresis = <2000>;
type = "critical";
};
};
cooling-maps {
map0 {
trip = <&cpu_alert>;
cooling-device = <&cpu0 THERMAL_NO_LIMIT THERMAL_NO_LIMIT>;
};
};
};
};The kernel side parses this in stages: thermal_of_trips_init() reads the trips children into a struct thermal_trip[] (each populated by thermal_of_populate_trip(), which reads temperature, hysteresis, and maps the type string to the enum); thermal_of_monitor_init() reads the two polling-delay* values; and thermal_of_should_bind() consults the cooling-maps at bind time to decide which cooling device attaches to which trip and over what state range. The result is handed to thermal_zone_device_register_with_trips() — so the DT path and the direct-C path converge on the same core, as described in The Thermal Framework.
Failure Modes
- Critical trip set too low (firmware bug). A vendor DSDT or DT that puts the critical trip well below the safe junction temperature causes spurious shutdowns under normal load — the most common thermal complaint. Diagnose by reading
trip_point_*_tempandtrip_point_*_typeand comparing the critical value against the chip’s datasheet. - No critical trip at all. If no zone has a critical trip, the kernel’s last-resort software protection is absent; you depend entirely on hardware throttling. Some minimal embedded DTs omit it — risky.
- Hysteresis of zero. A passive trip with
hysteresis = 0will flap: throttling toggles every time the temperature wobbles across the exact trip value, causing audible fan/performance oscillation. Always give a few degrees. - Sensor returns garbage /
THERMAL_TEMP_INVALID. If->get_tempreturns a value at or belowTHERMAL_TEMP_INVALID(−274000, i.e. below absolute zero), the core treats the reading as “no data” and skips trip evaluation that poll. Persistent failures back off and disable the zone (see The Thermal Framework). set_tripswindow miscomputed by the driver. If a driver programs the boundary IRQ wrong (e.g. swaps low/high or off-by-one), the kernel can miss a crossing and over- or under-react. Symptom: throttling that engages late or a temperature that sails past a passive trip without the loop running.
Common Misunderstandings
- “Active trips throttle the CPU.” No — passive trips throttle (slow the source); active trips add cooling (fans) without slowing anything. The names are about the cooling strategy, not aggressiveness. See Passive and Active Cooling.
- “The governor decides whether to shut down on critical.” No — critical (and hot) crossings are handled synchronously in
handle_thermal_trip()and are deliberately hidden from the governor. - “Trip temperatures are in degrees.” They are in milli-degrees Celsius everywhere — DT, sysfs, and the C structs.
80000is 80 °C. - “
emul_tempcan simulate a critical overheat.” Only up to (not including) the critical trip — the core refuses to let an emulated value mask a real critical condition.
See Also
- The Thermal Framework — the architecture and monitoring loop these inputs feed
- Cooling Devices and Thermal Governors — the outputs: what gets bound to a trip and the governor algorithms
- Passive and Active Cooling — the cooling strategies the active/passive trip types select
- Linux Power Management MOC — the parent map (§7 Thermal Management)