制造商零件编号 ESP32-S3-DEVKITC-1-N32R8V
ESP32-S3-WROOM-2-N32R8V DEV BRD
Espressif Systems
In previous installments, we’ve looked at how Zephyr uses Devicetree to describe hardware, and how you can navigate Devicetree syntax and structure. We also touched briefly on “bindings.” Bindings connect the abstract hardware descriptions in the Devicetree source (DTS) files to the driver code that makes devices actually work.
In this tutorial, we’ll look at Devicetree bindings in the context of enabling and reading from an Analog-to-Digital Converter (ADC) on the ESP32-S3 platform. By walking through this example, you’ll gain a practical understanding of how Zephyr’s bindings files map Devicetree properties to driver code. You’ll also learn how to customize your DTS overlays and reference binding-defined properties in your C application.
All example code and solutions for this series can be found in this repository: https://github.com/ShawnHymel/introduction-to-zephyr/
Devicetree files (.dts or .overlay) describe hardware in a structured way. However, the system still needs to know how to interpret these descriptions.
Bindings are the link between this abstract hardware description and the low-level driver code. They are typically defined in YAML files placed in the zephyr/dts/bindings directory. Each binding file corresponds to one or more compatible strings. A compatible string might look like "espressif,esp32-adc". Inside the corresponding binding file, you’ll find documentation on what properties that node can have, their types, default values, and what’s required vs. optional.
When you run west build, Zephyr’s build system uses these binding files to:
As a result, changing something in your DTS configuration automatically propagates to your code through these auto-generated headers and macros. This keeps your code clean, maintainable, and easy to adapt when you switch boards or peripherals.
Note that bindings files are unique to Zephyr—Linux does not use them to map Devicetree nodes to driver code. Zephyr reads them at compile time to enforce node properties in the Devicetree (interface) and to determine which driver files to use.
I recommend this tutorial if you need a good starter guide and reference for YAML syntax.
Let’s look at the ESP32-S3 ADC as our example. On the ESP32-S3, the ADCs are documented in Zephyr’s Devicetree files under something like &adc0 or &adc1. The compatible string for the ESP32 ADC controller might be something like "espressif,esp32-adc".
You’ll find the ADC binding files in zephyr/dts/bindings/adc/. There should be a generic ADC binding (such as adc-controller.yaml) and a SoC-specific binding (espressif,esp32-adc.yaml), among others.
These YAML files use a schema that lists required and optional properties, their types, and how the driver will interpret them.
For example, espressif,esp32-adc.yaml might include:
Some key properties defined by these bindings include:
By reading these binding files, you learn how to properly configure each node and what the driver expects. For instance, zephyr,gain and zephyr,reference might be enums defined in the binding file, ensuring that your DTS picks from a valid set of options (like ADC_GAIN_1_4 or ADC_REF_INTERNAL).
As previously mentioned, these properties create an interface you must adhere to when defining a Devicetree node that uses this particular compatible. Zephyr’s build system will throw an error if you forget to add a required property defined in the bindings file or use the wrong type of property.
For this demonstration, we will be using a 10k potentiometer connected to pin 1 on the ESP32-S3-DevKitC. Here is a Fritzing diagram showing all of the connections we will use throughout this series:
The ESP32S3 has two analog-to-digital (ADC) controllers: ADC1 and ADC2. You can read more about these controllers in section 39.3 of the ESP32S3 technical reference manual. Zephyr, by convention, uses zero-based indexing for nodes, so ADC1 is adc0 in the Devicetree and ADC2 is adc1.
We’re going to enable the ADC1 (&adc0) and configure one of its channels. Let’s say we want to read from GPIO1, which maps to ADC1 channel 0 on the ESP32-S3.
Create a new project directory structure:
/workspace/apps/05_demo_adc/
├─ boards/
│ └─ esp32s3_devkitc.overlay
├─ src/
│ └─ main.c
├─ CMakeLists.txt
└─ prj.conf
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(adc_demo)
target_sources(app PRIVATE src/main.c)
This is a standard (minimum) boilerplate setup for a Zephyr application.
This tells Zephyr to include ADC support. Without it, the ADC driver wouldn’t be compiled in, and you’d see errors.
/{
aliases {
my-adc = &adc0;
my-adc-channel = &adc0_ch0;
};
};
// ADC1 (adc0) channel 0 is connected to GPIO1
&adc0 {
status = "okay";
#address-cells = <1>;
#size-cells = <0>;
adc0_ch0: channel@0 {
reg = <0>;
zephyr,gain = "ADC_GAIN_1_4";
zephyr,reference = "ADC_REF_INTERNAL";
zephyr,vref-mv = <3894>;
zephyr,acquisition-time = <ADC_ACQ_TIME_DEFAULT>;
zephyr,resolution = <12>;
};
};
Let’s break this down:
If something is off, the binding files and Zephyr’s build system will complain during compilation. This ensures you use valid properties and configurations.
#include <stdio.h>
#include <zephyr/kernel.h>
#include <zephyr/drivers/adc.h>
// Settings
static const int32_t sleep_time_ms = 100;
// Get Devicetree configurations
#define MY_ADC_CH DT_ALIAS(my_adc_channel)
static const struct device *adc = DEVICE_DT_GET(DT_ALIAS(my_adc));
static const struct adc_channel_cfg adc_ch = ADC_CHANNEL_CFG_DT(MY_ADC_CH);
int main(void)
{
int ret;
uint16_t buf;
uint16_t val_mv;
int32_t vref_mv;
// Get Vref (mV) from Devicetree property
vref_mv = DT_PROP(MY_ADC_CH, zephyr_vref_mv);
// Buffer and options for ADC (defined in adc.h)
struct adc_sequence seq = {
.channels = BIT(adc_ch.channel_id),
.buffer = &buf,
.buffer_size = sizeof(buf),
.resolution = DT_PROP(MY_ADC_CH, zephyr_resolution)
};
// Make sure that the ADC was initialized
if (!device_is_ready(adc)) {
printk("ADC peripheral is not ready\r\n");
return 0;
}
// Configure ADC channel
ret = adc_channel_setup(adc, &adc_ch);
if (ret < 0) {
printk("Could not set up ADC\r\n");
return 0;
}
// Do forever
while (1) {
// Sample ADC
ret = adc_read(adc, &seq);
if (ret < 0) {
printk("Could not read ADC: %d\r\n", ret);
continue;
}
// Calculate ADC value (mV)
val_mv = buf * vref_mv / (1 << seq.resolution);
// Print ADC value
printk("Raw: %u, mV: %u\r\n", buf, val_mv);
// Sleep
k_msleep(sleep_time_ms);
}
}
Let’s examine the key concepts in the code:
No hardcoded addresses, no platform-specific #defines. If we change the attenuation or the reference voltage in the DTS overlay later, the code adapts automatically after rebuilding.
To build and run:
west build -p always -b esp32s3_devkitc/esp32s3/procpu -- -DDTC_OVERLAY_FILE=boards/esp32s3_devkitc.overlay
This command:
Once built, flash the firmware onto your ESP32-S3 board. Note that if you are using the Docker image to build your Zephyr applications, this next step must be performed on your host machine (not in the Docker container). Change <PORT> to the port connected to your ESP32-S3-DevKitC.
python -m esptool --port "<PORT>" --chip auto --baud 921600 --before default_reset --after hard_reset write_flash -u --flash_size detect 0x0 workspace/apps/05_adc_demo/build/zephyr/zephyr.bin
Then connect to the serial monitor:
python -m serial.tools.miniterm "<PORT>" 115200
You should see raw ADC values and calculated voltage appearing. Try turning the potentiometer knob to watch the values change.
Note that the ESP32’s ADC is notoriously inaccurate. You can read more about those inaccuracies (and how to programmatically offset those inaccuracies) here: https://www.luisllamas.es/en/esp32-adc/
All of this dynamic configuration works because of the binding files. The espressif,esp32-adc.yaml and generic adc-controller.yaml files told Zephyr’s build system how to interpret the zephyr,gain, zephyr,vref-mv, and other properties. It also enforced that reg = <0> for our channel node is valid, and guided the macros used by DT_ALIAS() and DT_PROP().
Without bindings, you’d have to hardcode many platform-specific details in your code. You’d lose the flexibility and consistency that Devicetree brings. Bindings are like a contract between your DTS and the driver code: they define what’s allowed what each property means, and how to translate those properties into driver configuration. In other words, they define Devicetree node interfaces.
The approach we used here isn’t unique to ADCs. Any peripheral—UART, I2C, SPI, PWM—follows a similar pattern:
As you become more familiar with the process, you’ll rely less on guesswork. The binding files become a source of truth, guiding you in how to set up each device. Instead of searching datasheets and hoping your code aligns with the driver’s assumptions, you let Devicetree and bindings ensure correctness.
Be aware that this assumes your driver code has been provided for you! In the next episode, we will examine creating our own device driver.
For a more advanced project, try controlling PWM brightness of an LED based on the ADC input (like a trimpot). Use the same techniques:
When done, you should be able to adjust the potentiometer knob to control the LED brightness:
%%%cose-up of knob controlling LED
This challenge will reinforce how bindings unify the entire hardware configuration and help you build a highly configurable embedded application without scattering board-specific details throughout your code.
In this post, we saw how bindings files defined Devicetree node interfaces and connected those nodes to the appropriate device driver code. If you’d like to dive more into the Devicetree, bindings, and the ESP32S3 ADC, I recommend the following resources:
In the next tutorial, we will write our own device driver so you can see how Kconfig, the Devicetree, and bindings files all work together to create portable application and driver code.