4、ALSA用户空间编程基础:snd_pcm_open、snd_pcm_hw_params、snd_pcm_sw_params、读写操作
好,我们终于到了用户空间编程这一块。说实话,很多做驱动的兄弟觉得用户空间API很简单,不就是调几个函数嘛。但我在项目里见过太多因为用户空间配置不当,导致音频卡顿、延迟高、甚至无声的案例。所以这一节,咱们把ALSA用户空间编程的四个核心步骤掰开揉碎了讲清楚。
4.1 打开设备:snd_pcm_open
一切从打开设备开始。snd_pcm_open这个函数,说白了就是建立你和音频硬件之间的通道。
#include <alsa/asoundlib.h>
snd_pcm_t *pcm_handle;
int ret;
ret = snd_pcm_open(&pcm_handle, "hw:0,0",
SND_PCM_STREAM_PLAYBACK, 0);
if (ret < 0) {
printf("打开设备失败: %s\n", snd_strerror(ret));
return -1;
}
这里有几个坑,我一个个说。
设备名称:"hw:0,0"表示第一张声卡的第0个设备。但实际项目中,我更推荐用"default"或者"plughw:0,0"。为什么?因为hw设备不支持采样率转换和格式转换,你给什么参数它就必须用什么参数。而plughw会自动帮你做适配。我曾经在一个项目里,用hw设备播放16位音频,结果应用层传了32位数据,直接没声音,排查了半天才发现是格式不匹配。
流方向:SND_PCM_STREAM_PLAYBACK是播放,SND_PCM_STREAM_CAPTURE是录音。这个别搞反了,我见过有人录音时用了PLAYBACK,结果一直没数据,还以为是驱动有问题。
阻塞模式:最后一个参数0表示阻塞模式。如果你希望非阻塞,可以用SND_PCM_NONBLOCK。但我个人习惯,初始化时用阻塞模式,等所有参数配好了再切换。这样调试起来方便。
snd_pcm_name()打印一下设备名,确认打开的是你想要的设备。我在调试时经常这么干,能省不少排查时间。
4.2 配置硬件参数:snd_pcm_hw_params
硬件参数配置是ALSA编程中最关键的一步。你想想看,音频数据怎么传、传多快、传什么格式,全在这里定下来。
snd_pcm_hw_params_t *hw_params;
snd_pcm_uframes_t period_size = 1024;
unsigned int rate = 48000;
int dir;
// 1. 分配参数对象
snd_pcm_hw_params_alloca(&hw_params);
// 2. 初始化默认值
snd_pcm_hw_params_any(pcm_handle, hw_params);
// 3. 设置访问类型
snd_pcm_hw_params_set_access(pcm_handle, hw_params,
SND_PCM_ACCESS_RW_INTERLEAVED);
// 4. 设置数据格式
snd_pcm_hw_params_set_format(pcm_handle, hw_params,
SND_PCM_FORMAT_S16_LE);
// 5. 设置采样率
snd_pcm_hw_params_set_rate_near(pcm_handle, hw_params,
&rate, &dir);
// 6. 设置通道数
snd_pcm_hw_params_set_channels(pcm_handle, hw_params, 2);
// 7. 设置周期大小
snd_pcm_hw_params_set_period_size_near(pcm_handle, hw_params,
&period_size, &dir);
// 8. 应用配置
snd_pcm_hw_params(pcm_handle, hw_params);
这里我重点讲几个容易出问题的地方。
访问类型:SND_PCM_ACCESS_RW_INTERLEAVED是最常用的,左右声道数据交错排列。还有一种MMAP_INTERLEAVED,性能更好,但实现复杂。我建议新手先用RW模式,跑通了再考虑优化。
采样率设置:注意我用的是_near后缀的函数。为什么?因为硬件不一定支持你指定的精确值。比如你设48000,硬件可能只支持47999或48001。_near会帮你选一个最接近的值,并通过rate变量返回实际值。我遇到过有人用_exact,结果硬件不支持直接返回错误,代码就崩了。
周期大小:这个参数直接影响延迟。周期越小,延迟越低,但CPU占用越高。车载场景下,我一般设256或512帧。1024帧适合对延迟不敏感的场景,比如音乐播放。
snd_pcm_hw_params_get_period_size()和snd_pcm_hw_params_get_rate()读取实际值。因为硬件可能做了调整,你拿到的值可能和你设的不一样。不检查这个,后面读写操作很容易出问题。
4.3 配置软件参数:snd_pcm_sw_params
硬件参数搞定后,接下来是软件参数。很多人会忽略这一步,直接开始读写。嗯,这里要注意,软件参数控制的是ALSA中间层的行为,比如什么时候触发中断、什么时候开始播放。
snd_pcm_sw_params_t *sw_params;
snd_pcm_uframes_t start_threshold, avail_min;
// 1. 分配参数对象
snd_pcm_sw_params_alloca(&sw_params);
// 2. 获取当前软件参数
snd_pcm_sw_params_current(pcm_handle, sw_params);
// 3. 设置启动阈值
start_threshold = period_size; // 缓冲区有1个周期数据时启动
snd_pcm_sw_params_set_start_threshold(pcm_handle, sw_params,
start_threshold);
// 4. 设置最小可用帧数
avail_min = period_size; // 每次至少可用1个周期才唤醒
snd_pcm_sw_params_set_avail_min(pcm_handle, sw_params,
avail_min);
// 5. 应用配置
snd_pcm_sw_params(pcm_handle, sw_params);
start_threshold:这个参数决定什么时候开始播放。设成period_size,意思是缓冲区里有了一个周期的数据就启动。如果你设成buffer_size,那要等缓冲区满了才开始,延迟会大很多。我记得有个项目,客户反映声音出来慢,查了半天发现start_threshold设成了buffer_size的一半,导致要等很久才触发播放。
avail_min:这个参数控制poll或select的唤醒时机。设成period_size,表示当可用空间达到一个周期大小时才通知应用层写入数据。这个值设得太小,CPU会被频繁唤醒;设得太大,缓冲区可能溢出。
核心经验:对于车载场景,我通常这样配:
- start_threshold = period_size(快速启动)
- avail_min = period_size(平衡CPU和延迟)
- 如果做低延迟通话,可以把period_size设小到128或64
4.4 读写操作:播放与录音
参数配好了,终于可以读写数据了。ALSA提供了两种读写方式:标准读写和MMAP读写。这里我只讲标准读写,因为最常用、也最容易理解。
4.4.1 播放数据
snd_pcm_sframes_t frames;
char *buffer;
int buffer_size;
// 计算缓冲区大小:周期大小 * 帧大小
buffer_size = period_size * 4; // 16位立体声,每帧4字节
buffer = malloc(buffer_size);
while (1) {
// 填充音频数据到buffer
fill_audio_data(buffer, buffer_size);
// 写入PCM设备
frames = snd_pcm_writei(pcm_handle, buffer, period_size);
if (frames < 0) {
frames = snd_pcm_recover(pcm_handle, frames, 0);
}
if (frames < 0) {
printf("写入失败: %s\n", snd_strerror(frames));
break;
}
}
snd_pcm_writei:这个函数写入交错格式的数据。注意返回值是实际写入的帧数,不是字节数。如果返回值小于你请求的帧数,说明缓冲区满了,需要重试。
错误恢复:snd_pcm_recover是ALSA提供的便捷函数,能自动处理-EPIPE(缓冲区欠载)、-ESTRPIPE(系统挂起)等常见错误。我建议所有读写操作都加上这个恢复逻辑。曾经有个项目,播放过程中突然没声音了,就是因为没处理欠载错误,导致PCM设备进入了SND_PCM_STATE_XRUN状态。
4.4.2 录音数据
while (1) {
frames = snd_pcm_readi(pcm_handle, buffer, period_size);
if (frames < 0) {
frames = snd_pcm_recover(pcm_handle, frames, 0);
}
if (frames < 0) {
printf("读取失败: %s\n", snd_strerror(frames));
break;
}
// 处理录音数据
process_audio_data(buffer, frames * 4);
}
录音和播放是对称的,只是方向不同。但有一个细节:录音时如果缓冲区空了,snd_pcm_readi会阻塞等待数据。如果你不希望阻塞,可以在打开设备时设置SND_PCM_NONBLOCK,或者用poll机制检测数据是否可用。
4.4.3 使用poll进行非阻塞读写
实际项目中,我更推荐用poll机制。这样可以在一个线程里同时处理多个音频流,或者和其他事件一起处理。
struct pollfd pfds[1];
int count;
pfds[0].fd = snd_pcm_poll_descriptors_count(pcm_handle);
snd_pcm_poll_descriptors(pcm_handle, pfds, 1);
while (1) {
count = poll(pfds, 1, 1000); // 超时1秒
if (count > 0) {
unsigned short revents;
snd_pcm_poll_descriptors_revents(pcm_handle, pfds, 1, &revents);
if (revents & POLLOUT) {
// 可以写入数据
frames = snd_pcm_writei(pcm_handle, buffer, period_size);
}
if (revents & POLLIN) {
// 可以读取数据
frames = snd_pcm_readi(pcm_handle, buffer, period_size);
}
}
}
4.5 清理与关闭
最后别忘了清理。虽然程序退出时系统会释放资源,但养成良好的习惯总没错。
snd_pcm_drain(pcm_handle); // 等待所有数据播放完毕
snd_pcm_close(pcm_handle); // 关闭设备
snd_pcm_drain会阻塞直到缓冲区里的数据全部播放完。如果你不想等,可以用snd_pcm_drop直接丢弃数据。我一般在程序正常退出时用drain,异常退出时用drop。
好了,ALSA用户空间编程的基础就这些。说白了就是四步:打开、配硬件、配软件、读写。但每一步都有细节,不注意就会踩坑。下一节我们讲更高级的MMAP模式,那个性能更好,但实现也更复杂。先把基础打牢,后面才能飞得起来。