This lecture is part of EmbeddedPathashala’s free Linux kernel development course. In the last lecture of this free embedded Linux course we finished the three classes of snd_soc_dai_ops callbacks and the PCM state machine. In this lecture of our free Linux device drivers course we go one level deeper into ALSA and understand exactly how a sound driver exposes a “Volume” slider, a “Mute” switch, or a “Playback Source” dropdown to userspace tools like amixer, alsamixer, and PulseAudio/PipeWire. Every mixer knob you have ever dragged in a Linux audio app is backed by one C structure: struct snd_kcontrol_new. Understanding this structure is the single most useful skill for anyone building a codec driver, and it is a core topic in this free embedded systems course.
What You Will Learn
ALSA control naming convention
TLV and decibel metadata
get/put/info callbacks
SOC_SINGLE / SOC_DOUBLE_R_TLV / SOC_ENUM macros
snd_soc_add_component_controls()
Building a real control array
Testing controls with amixer
Prerequisites
- Lecture 4 of this chapter (
snd_soc_dai_ops, PCM state machine, ASoC component basics) - Comfort with
regmapreads/writes on a Linux kernel driver - A working ALSA SoC codec skeleton (component + DAI driver registered)
- Kernel 6.x source tree, or docs.kernel.org for reference
Why Controls Exist Separately From the DAI
The DAI driver you studied in the previous lecture is only concerned with moving audio samples — clocks, formats, and stream state. It has no idea what a “volume” is. Volume, mute, gain, and input-source selection are handled by an entirely separate mechanism called an ALSA kcontrol. A kcontrol is nothing but a named, addressable “knob” that userspace can read and write through the standard ALSA control API (the same API that amixer and alsamixer talk to). The ASoC core does not invent a new protocol here — it reuses the plain ALSA control core and just gives you convenience macros so you don’t have to hand-write dozens of near-identical control definitions for every register-backed volume or switch in your codec.
Anatomy of struct snd_kcontrol_new
Every control — whether it is a single on/off switch or a 128-step stereo volume slider — is described by one instance of struct snd_kcontrol_new. Below is the field-by-field breakdown, explained conceptually rather than pasted from any book, because the fields rarely change but their meaning is often misunderstood by beginners.
name -> the text shown in amixer / alsamixer
index -> disambiguates duplicate names on multi-codec cards
access -> READ / WRITE / TLV_READ permission bits
info() -> tells userspace the control’s type and min/max/step
get() -> reads current hardware value into userspace
put() -> writes a new userspace value into hardware
tlv -> optional dB scale metadata for sliders
iface tells the ALSA control core what category this control belongs to. In almost every codec driver you will use SNDRV_CTL_ELEM_IFACE_MIXER, since volume/gain/switch controls conceptually live on the mixer. Other values such as PCM, CARD, or HWDEP exist for controls tied to a specific PCM device rather than the whole sound card, but codec drivers rarely need them.
name is not a free-text label — ALSA has a documented naming convention that userspace mixer apps rely on to group and render controls sensibly. A control name is built from three optional parts: Source (Master, PCM, Line, Mic), Direction (Playback, Capture — omitted means the control applies to both directions), and Function (Volume, Switch, Route). “Headphone Playback Volume” tells alsamixer exactly how to categorize the slider. Breaking this convention doesn’t crash anything, but it does make your driver’s mixer look inconsistent next to every other ALSA driver on the system.
index only matters when the same control name would otherwise collide — for example a sound card with two identical codecs, each exposing its own “Playback Volume”. Leave it at 0 unless you actually have that situation.
access is a bitmask built from flags like SNDRV_CTL_ELEM_ACCESS_READWRITE and SNDRV_CTL_ELEM_ACCESS_TLV_READ. The TLV flag is what tells userspace “this control also has dB scale metadata available through the tlv.p field” — without it, alsamixer would show a raw 0-255 number instead of a dB value.
info, get, put are the three callbacks that make the control actually work. info() answers “what type of value is this, and what is the min/max/step?” so userspace can build the right widget (slider vs checkbox vs dropdown). get() and put() are where your driver actually touches hardware — almost always through a regmap_read()/regmap_update_bits() pair. The good news: for the common cases (single register bitfield, stereo register pair, enum lookup) the ASoC core already ships generic info/get/put implementations, so you rarely write these three callbacks by hand.
tlv carries the decibel metadata for sliders. This is where TLV (Type-Length-Value) comes in — a small self-describing container that tells userspace “step 0 of this control means -40.50 dB, and every step after that adds 1.50 dB”. The DECLARE_TLV_DB_SCALE(name, min_db_x100, step_db_x100, mute_at_min) macro builds this container for you: the second argument is the minimum value in units of 0.01 dB, the third is the per-step size in the same units, and the fourth is set to 1 only if the lowest step should also mean “muted”.
The Control Macros You Will Actually Use
Hand-writing info/get/put for a plain register bitfield would be repetitive, so ASoC gives you generic macros that expand into a fully-formed snd_kcontrol_new entry. The table below compares the ones you’ll reach for most often, verified against the current mainline include/sound/soc.h / include/sound/tlv.h definitions.
| Macro | Use case | Backing storage |
|---|---|---|
SOC_SINGLE |
One field in one register (e.g. a mute switch) | Single register, no dB metadata |
SOC_SINGLE_TLV |
One field in one register, shown in dB | Single register + TLV |
SOC_DOUBLE |
Left/right fields packed in one register | Single register, two bitfields |
SOC_DOUBLE_R_TLV |
Stereo volume split across two separate registers, shown in dB | Two registers + TLV |
SOC_ENUM |
A MUX / input-source dropdown | Register field mapped to a text list |
SOC_SINGLE_BOOL_EXT |
A control needing fully custom get/put logic | Driver-supplied callbacks |
Registering Controls With the Component
Once your control array is defined, it must be handed to the ASoC core so it becomes visible to userspace. The current API is:
int snd_soc_add_component_controls(struct snd_soc_component *component,
const struct snd_kcontrol_new *controls,
unsigned int num_controls);
In practice you rarely call this directly. The cleaner and more common pattern on current kernels is to attach the array to the component driver itself via the .controls and .num_controls fields of struct snd_soc_component_driver that we studied two lectures ago — the ASoC core then registers them automatically during devm_snd_soc_register_component(). Calling snd_soc_add_component_controls() explicitly is still useful when you need to add controls conditionally, for example only when a certain hardware revision is detected at probe time.
Building a Real Control Set — ep_codec Example
Let’s build an original, from-scratch control array for a fictitious codec we’ll call ep_codec — not copied from any book or vendor driver. It exposes a stereo DAC volume in dB, a speaker mute switch, and a two-way input-source MUX.
#include <linux/regmap.h>
#include <sound/soc.h>
#include <sound/tlv.h>
/* Register map for our fictitious codec */
#define EP_REG_DAC_L_VOL 0x10
#define EP_REG_DAC_R_VOL 0x11
#define EP_REG_SPK_CTRL 0x20
#define EP_REG_INPUT_MUX 0x30
/* -63.5 dB to 0 dB in 0.5 dB steps, no mute-on-min */
static const DECLARE_TLV_DB_SCALE(ep_dac_tlv, -6350, 50, 0);
static const char * const ep_input_texts[] = {
"Line In", "Mic In",
};
static SOC_ENUM_SINGLE_DECL(ep_input_enum, EP_REG_INPUT_MUX, 0,
ep_input_texts);
static const struct snd_kcontrol_new ep_codec_controls[] = {
SOC_DOUBLE_R_TLV("DAC Playback Volume",
EP_REG_DAC_L_VOL, EP_REG_DAC_R_VOL,
0, 127, 0, ep_dac_tlv),
SOC_SINGLE("Speaker Playback Switch",
EP_REG_SPK_CTRL, 0, 1, 1),
SOC_ENUM("Input Source", ep_input_enum),
};
Each macro call expands into one fully-formed snd_kcontrol_new entry, complete with generic info/get/put callbacks that the ASoC core already provides for regmap-backed components — we never had to write a single get/put function by hand for these three controls. Now we attach the array in the component driver:
static const struct snd_soc_component_driver ep_codec_component = {
.controls = ep_codec_controls,
.num_controls = ARRAY_SIZE(ep_codec_controls),
.idle_bias_on = 1,
.use_pmdown_time = 1,
.endianness = 1,
};
With this attached and the component registered through devm_snd_soc_register_component() as covered earlier in this chapter, all three controls appear automatically the moment the sound card is instantiated — no manual registration call needed.
Verifying the Controls From Userspace
After loading the driver and confirming the card is bound (check with aplay -l or cat /proc/asound/cards), list and query the new controls with amixer:
$ amixer -c 0 controls
numid=3,iface=MIXER,name='DAC Playback Volume'
numid=4,iface=MIXER,name='Speaker Playback Switch'
numid=5,iface=MIXER,name='Input Source'
$ amixer -c 0 sget 'DAC Playback Volume'
Simple mixer control 'DAC Playback Volume',0
Capabilities: pvolume
Playback channels: Front Left - Front Right
Limits: Playback -63.50dB - 0.00dB
Front Left: Playback 96 [75%] [-15.50dB]
Front Right: Playback 96 [75%] [-15.50dB]
$ amixer -c 0 sset 'Speaker Playback Switch' off
Simple mixer control 'Speaker Playback Switch',0
Front Left: Playback [off]
Notice how amixer automatically prints dB values — that is entirely coming from the TLV metadata we declared with DECLARE_TLV_DB_SCALE. Nothing in our driver had to format decibel strings; the ALSA control core did it using the TLV container.
Control Data Flow
|
v
ALSA control core (checks info(), calls put())
|
v
SOC_DOUBLE_R_TLV generic put() callback
|
v
regmap_update_bits() on EP_REG_DAC_L_VOL / EP_REG_DAC_R_VOL
|
v
Codec hardware register
Real-World Use Cases
- Exposing headphone/speaker volume sliders that PipeWire and PulseAudio auto-discover
- Building an input-source MUX so userspace can switch between Mic and Line In without a reboot
- Adding a “Deemphasis” or “Noise Gate” style boolean toggle via
SOC_SINGLE_BOOL_EXTwhen the logic can’t be expressed as a simple register bitfield - Letting audio test frameworks (e.g. CI running on target boards) script volume and mute state entirely through
amixer, with no custom debugfs interface needed
Common Mistakes and Troubleshooting
- Forgetting the TLV_READ access flag: if you build a TLV but the control’s
accessfield doesn’t includeSNDRV_CTL_ELEM_ACCESS_TLV_READ(the_TLVmacros set this for you automatically, but hand-rolled controls won’t), alsamixer silently falls back to raw integer values. - Wrong bit width in SOC_SINGLE: passing the wrong
maxvalue causesinfo()to report an incorrect range, so userspace either clips valid values or accepts invalid ones. - Reusing a control name across components without setting index: this causes controls to silently overwrite each other in the mixer listing on multi-codec cards.
- Assuming get()/put() run in process context: they can be called from contexts where sleeping regmap I/O may need care if the codec is on a bus with strict locking — always check your regmap’s I/O method (I2C is fine to sleep in; some SPI/GPIO combos are not).
Best Practices
- Always follow the Source-Direction-Function naming convention so your controls integrate cleanly with generic mixer UIs.
- Prefer the generic
SOC_*macros over hand-written get/put — they are well tested and reduce driver-specific bugs. - Use
SOC_SINGLE_BOOL_EXTonly when logic genuinely can’t be expressed as a register bitfield; it opts you out of the well-tested generic path. - Performance: for controls read frequently by pro-audio tools, avoid unnecessary
regmapround-trips inget()by relying on the regmap cache instead of forcing a hardware read every call. - Security: control
put()callbacks are reachable by any local user with mixer permissions — always validate/clamp incoming values even though the generic macros already bound-check againstinfo()‘s reported max.
Summary and Key Takeaways
Every mixer control in ALSA, no matter how it looks in userspace, is backed by one struct snd_kcontrol_new entry with info/get/put callbacks. The ASoC core’s SOC_SINGLE, SOC_DOUBLE_R_TLV, and SOC_ENUM family of macros generate these entries for the overwhelmingly common cases of single-register, dual-register, and enum-backed controls, using generic callbacks so you almost never write raw get/put logic yourself. TLV containers, built with DECLARE_TLV_DB_SCALE, are what let tools like alsamixer render human-readable dB values instead of raw register numbers. Attaching your control array to .controls/.num_controls on the component driver is the cleanest registration path on current kernels.
Frequently Asked Questions
What is the difference between SOC_SINGLE and SOC_SINGLE_TLV?
SOC_SINGLE exposes a raw integer control with no dB metadata; SOC_SINGLE_TLV is identical but additionally attaches a TLV dB-scale container so userspace can render and report the value in decibels.
Do I always need to write my own get() and put() callbacks?
No. For controls that map cleanly onto register bitfields, the generic SOC_* macros already supply working info/get/put callbacks backed by regmap. You only write custom callbacks when using SOC_SINGLE_EXT/SOC_SINGLE_BOOL_EXT style macros.
What does the TLV in DECLARE_TLV_DB_SCALE actually store?
It stores the minimum dB value (in units of 0.01 dB), the per-step dB increment, and a flag indicating whether the minimum step also represents mute, packaged in a format the ALSA control core can hand to userspace on request.
Why does my control not show up in alsamixer?
Most commonly the control array was never attached to .controls/.num_controls on the component driver, or snd_soc_add_component_controls() was never called for a manually-added control.
Can one register back two different named controls?
Yes, as long as each control targets a different bitfield (shift/mask) within that register — this is exactly how SOC_DOUBLE exposes two channels packed into one register.
What is the purpose of the index field if I never use multiple codecs?
For a single-codec card you can safely leave it at 0; it exists purely to disambiguate identically-named controls when more than one instance of the same control name exists on the card.
Is SOC_ENUM the right macro for an input-source selector?
Yes — SOC_ENUM paired with SOC_ENUM_SINGLE_DECL is the standard way to expose a register field as a named dropdown list, which is exactly what an input MUX needs.
Continue the Free Linux Kernel Development Course
Next, we move from mixer controls into DAPM (Dynamic Audio Power Management) — how ASoC automatically powers widgets on and off based on the active audio path.
