前言
前面的章节我们从认识摄像头开始,逐渐认识的YCbCr,并对其进行了H264的编码以及MP4封装。整个过程中,我们大致使用了V4L2和FFmpeg这两个重量级工具,就像我们前面章节所讲,V4L2只是给图像做服务的,并不参与音频。
在第3章中,我们说过V4L2这位大哥只管视频,不管音频。那音频谁来管?ALSA大佬管!
那么这一章,我们重点讨论一下ALSA,并且我们列一个目标:从USB摄像头获取音频流,并且编码成AAC!
一、ALSA介绍
1、ALSA是什么
- 全称: Advanced Linux Sound Architecture (高级 Linux 声音架构)
- 本质: Linux 内核的音频子系统和驱动框架。它提供了从底层硬件声卡驱动到上层用户空间应用程序接口(API)的一整套解决方案。
- 目的: 管理和驱动计算机的声卡硬件,允许应用程序播放和录制声音。
- 历史: 在 2.6 内核中正式取代了老旧的 OSS (Open Sound System),成为 Linux 默认的标准声音系统。
2、ALSA 的核心组成部分和功能
-
内核驱动:
- 这部分包含在内核源代码中 (
/sound
目录)。 - 直接与物理声卡硬件(集成、独立声卡、USB 声卡等)通信,处理中断、DMA、硬件寄存器读写等底层操作。
- 为每种支持的声卡芯片或型号提供特定的驱动程序模块,等我们有能力后,也可以为一个音频芯片或驱动模块编写驱动程序,现在还是先用起来。
- 这部分包含在内核源代码中 (
-
用户空间库 (
libasound.so
- ALSA library):- 这是应用程序主要交互的接口。
- 提供了一组丰富、统一的 API (称为 ALSA API 或 alsa-lib API),让应用程序开发者无需关心底层硬件的细节即可播放或录制音频。
- 库负责将应用程序的请求(如"播放这个 PCM 数据流")传递给内核驱动,并处理缓冲区、格式转换、插件等高级功能。
- 支持多种音频格式(采样率、位深、通道数)、参数设置(缓冲区大小、周期数)。
-
设备文件 (
/dev/snd/
目录下):- 内核驱动为用户空间暴露的接口文件。虽然应用程序通常通过
libasound
访问音频功能,但理解这些设备文件有助于调试。 - 主要设备:
/dev/snd/controlC#
: 控制设备 (Control device),用于混音器控制(如alsamixer
/amixer
使用)。/dev/snd/pcmC#D#
: PCM 播放/录制设备 (Playback/Capture device)。C#
表示声卡号 (Card),D#
表示该声卡上的设备号 (Device)。我们编程程序的时候,会用到这个。
- 内核驱动为用户空间暴露的接口文件。虽然应用程序通常通过
二、ALSA初体验
为了能够在程序中使用ALSA,需要安装ALSA开发库:
bash
sudo apt-get install libasound2-dev
下面直接给出通过ALSA获取USB摄像头的PCM音频数据的代码,并根据这份代码进行讲解:
cpp
#include <stdio.h>
#include <stdlib.h>
#include <alsa/asoundlib.h>
#define SAMPLE_RATE 22050 // 采样率
#define CHANNELS 1 // 单声道
#define PERIOD_SIZE 512 // 周期大小
#define PERIODS 4 // 缓冲区周期数
#define RECORD_SECONDS 5 // 录音时长(秒)
int main() {
int rc;
snd_pcm_t *capture_handle;
snd_pcm_hw_params_t *hw_params;
FILE *pcm_file;
short *buffer;
int dir = 0;
// 1. 打开音频设备
rc = snd_pcm_open(&capture_handle, "plughw:1,0", SND_PCM_STREAM_CAPTURE, 0);
if (rc < 0) {
fprintf(stderr, "无法打开音频设备: %s\n", snd_strerror(rc));
return 1;
}
// 2. 分配硬件参数结构
snd_pcm_hw_params_alloca(&hw_params);
// 3. 初始化硬件参数
rc = snd_pcm_hw_params_any(capture_handle, hw_params);
if (rc < 0) {
fprintf(stderr, "无法初始化硬件参数: %s\n", snd_strerror(rc));
goto cleanup;
}
// 4. 设置访问类型(交错模式)
rc = snd_pcm_hw_params_set_access(capture_handle, hw_params, SND_PCM_ACCESS_RW_INTERLEAVED);
if (rc < 0) {
fprintf(stderr, "无法设置访问类型: %s\n", snd_strerror(rc));
goto cleanup;
}
// 5. 设置采样格式(16位小端)
rc = snd_pcm_hw_params_set_format(capture_handle, hw_params, SND_PCM_FORMAT_S16_LE);
if (rc < 0) {
fprintf(stderr, "无法设置采样格式: %s\n", snd_strerror(rc));
goto cleanup;
}
// 6. 设置采样率
unsigned int sample_rate = SAMPLE_RATE;
rc = snd_pcm_hw_params_set_rate_near(capture_handle, hw_params, &sample_rate, &dir);
if (rc < 0) {
fprintf(stderr, "无法设置采样率: %s\n", snd_strerror(rc));
goto cleanup;
}
printf("实际采样率: %u Hz\n", sample_rate);
// 7. 设置声道数(单声道)
rc = snd_pcm_hw_params_set_channels(capture_handle, hw_params, CHANNELS);
if (rc < 0) {
fprintf(stderr, "无法设置声道数: %s\n", snd_strerror(rc));
goto cleanup;
}
// 8. 设置周期大小
snd_pcm_uframes_t period_size = PERIOD_SIZE;
rc = snd_pcm_hw_params_set_period_size_near(capture_handle, hw_params, &period_size, &dir);
if (rc < 0) {
fprintf(stderr, "无法设置周期大小: %s\n", snd_strerror(rc));
goto cleanup;
}
printf("实际周期大小: %lu 帧\n", period_size);
// 9. 设置周期数(缓冲区大小 = 周期大小 * 周期数)
unsigned int periods = PERIODS;
rc = snd_pcm_hw_params_set_periods_near(capture_handle, hw_params, &periods, &dir);
if (rc < 0) {
fprintf(stderr, "无法设置周期数: %s\n", snd_strerror(rc));
goto cleanup;
}
printf("实际周期数: %u\n", periods);
// 10. 应用硬件参数
rc = snd_pcm_hw_params(capture_handle, hw_params);
if (rc < 0) {
fprintf(stderr, "无法设置参数: %s\n", snd_strerror(rc));
goto cleanup;
}
// 11. 准备音频缓冲区
buffer = malloc(period_size * sizeof(short));
if (!buffer) {
fprintf(stderr, "无法分配缓冲区\n");
goto cleanup;
}
// 12. 打开输出文件
pcm_file = fopen("test.pcm", "wb");
if (!pcm_file) {
fprintf(stderr, "无法创建输出文件\n");
goto cleanup;
}
printf("开始录音...\n");
// 13. 录音循环
int frames = 0;
const int total_frames = (sample_rate * RECORD_SECONDS) / period_size;
while (frames < total_frames) {
rc = snd_pcm_readi(capture_handle, buffer, period_size);
if (rc == -EPIPE) {
fprintf(stderr, "缓冲区溢出,正在恢复\n");
snd_pcm_prepare(capture_handle);
continue;
} else if (rc < 0) {
fprintf(stderr, "读取错误: %s\n", snd_strerror(rc));
break;
} else if (rc != period_size) {
fprintf(stderr, "短帧读取,期望 %lu,实际 %d\n", period_size, rc);
}
// 写入PCM数据到文件
fwrite(buffer, sizeof(short), rc, pcm_file);
frames++;
printf("\r已录制 %.1f 秒... ", (float)frames * period_size / sample_rate);
fflush(stdout);
}
printf("\n录音完成!保存为 test.pcm\n");
cleanup:
if (capture_handle) {
snd_pcm_close(capture_handle);
}
if (buffer) {
free(buffer);
}
if (pcm_file) {
fclose(pcm_file);
}
return 0;
}
代码整体上还是比较简单的,逻辑也很清晰,只对新出现的部分做一些补充:
1、snd_pcm_xxx是ALSA库(alsa-lib)接口,编译的时候,需要链接 -lsound
2、snd_pcm_open的参数中,有一个"plughw:1,0",这里的plug指的是插件,比如应用程序想要获取44100采样率的数据,但是硬件只支持22050,那么plug就可以自动将音频数据从22050转成44100给到应用程序,主要是考虑到兼容性问题。但是在嵌入式中,音频硬件和驱动是固定的,不考虑兼容性,所以在打开音频设备时,使用"hw:1,0"即可。毕竟兼容性是要牺牲算力资源和内存资源的。
3、"hw:1,0"的命名规则如下:

card如果是0,代表是系统默认声卡。如果是1,一般是外接声卡,比如USB声卡。
设备号是从0开始的,我们的USB摄像头设备号只有一个,其他的不清楚。
所以"hw:1,0"表示的是:硬件访问方式,外置声卡,且声卡的设备号为0。
在Ubuntu中,在插入USB摄像头之前,/dev/snd里面的设备如下:
bash
by-path controlC0 midiC0D0 pcmC0D0c pcmC0D0p pcmC0D1p seq timer
插入USB摄像头后,/dev/snd里面的设备如下:
bash
by-id by-path controlC0 controlC1 midiC0D0 pcmC0D0c pcmC0D0p pcmC0D1p pcmC1D0c seq timer
可以看到多出了"controlC1"和"pcmC1D0c",后者就是声卡1,设备0。
4、周期是什么?
在代码中,音频的采样率(每秒钟采样多少个点)和单声道都好理解,但是PERIODS/PERIODS_SIZE是什么?

当ALSA从USB摄像头获取到音频数据后,会循环放在P个buffer中,这个P就是就是周期数,也就是PERIODS。每个音频帧的大小都是固定的,也就是PERRIODS_SIZE。其中音频帧又是交错格式(Interleaved)存储的。如果是立体声(左右双声道):L R L R... 我手里的摄像头是单声道的,就算是交错模式,存储方式也是单声道的:L L L L ...
在snd_pcm_readi读取音频数据时,有一个错误EPIPE处理。
在播放音频的时候,EPIPE代表的是欠载,表示应用程序填充数据太慢,硬件已经消耗完所有的周期,需要加快数据填充用于播放。
在录音的时候,EPIPE代表的是超限,意思是应用程序snd_pcm_readi读取的不及时,导致buffer中的数据溢出了,需要及时读取。
5、编译并运行
bash
gcc uvc_voice_streaming.c -o uvc_voice_streaming -lasound -lavcodec -lavutil
运行后,就可以生成test.pcm,因为这个是pcm数据,没有头部结构,一般的播放器无法进行播放。笔者使用的是GoldWave,在打开的时候,参数选项要正确,否则无法正确播放录音。


三、使用FFmpeg进行AAC编码
FFmpeg在前面几章介绍过,虽然一个是视频,一个是音频,但是处理方式都差不多,这里就不再重复。
cpp
#include <stdio.h>
#include <stdlib.h>
#include <alsa/asoundlib.h>
#include <libavcodec/avcodec.h>
#include <libavutil/opt.h>
#include <time.h>
#define SAMPLE_RATE 22050 // 采样率
#define CHANNELS 1 // 单声道
#define PERIOD_SIZE 512 // 周期大小
#define PERIODS 4 // 缓冲区周期数
#define RECORD_SECONDS 5 // 录音时长(秒)
// ADTS头部长度为7个字节
static uint8_t *adts_header = NULL;
// 生成ADTS头
static void add_adts_header(uint8_t *header, int packet_size, int sample_rate_index, int channels) {
// Sync Point
header[0] = 0xFF;
header[1] = 0xF1;
// Profile(2), Sampling Freq(4), Private(1), Channel Config(1)
header[2] = ((2 - 1) << 6) // AAC-LC = 2
| (sample_rate_index << 2)
| ((channels & 4) >> 2);
// Channel Config(2), Original(1), Home(1), Copyright ID(1), Copyright Start(1), Frame Length(2)
header[3] = ((channels & 3) << 6)
| ((packet_size + 7) >> 11);
// Frame Length(8)
header[4] = ((packet_size + 7) >> 3) & 0xFF;
// Frame Length(3), Buffer Fullness(5)
header[5] = (((packet_size + 7) & 0x07) << 5)
| 0x1F;
// Buffer Fullness(6), Raw Data Blocks(2)
header[6] = 0xFC;
}
// 获取采样率索引
static int get_sample_rate_index(int sample_rate) {
int sample_rates[] = {96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000};
for (int i = 0; i < 12; i++) {
if (sample_rate == sample_rates[i]) {
return i;
}
}
return 7; // 默认使用22050Hz的索引
}
int main() {
int rc;
snd_pcm_t *capture_handle;
snd_pcm_hw_params_t *hw_params;
FILE *aac_file;
short *buffer;
int dir = 0;
// FFmpeg变量
AVCodec *codec = NULL;
AVCodecContext *codec_ctx = NULL;
AVFrame *frame = NULL;
AVPacket *pkt = NULL;
// 1. 打开音频设备
rc = snd_pcm_open(&capture_handle, "plughw:1,0", SND_PCM_STREAM_CAPTURE, 0);
if (rc < 0) {
fprintf(stderr, "无法打开音频设备: %s\n", snd_strerror(rc));
return 1;
}
// 2. 分配硬件参数结构
snd_pcm_hw_params_alloca(&hw_params);
// 3. 初始化硬件参数
rc = snd_pcm_hw_params_any(capture_handle, hw_params);
if (rc < 0) {
fprintf(stderr, "无法初始化硬件参数: %s\n", snd_strerror(rc));
goto cleanup;
}
// 4. 设置访问类型(交错模式)
rc = snd_pcm_hw_params_set_access(capture_handle, hw_params, SND_PCM_ACCESS_RW_INTERLEAVED);
if (rc < 0) {
fprintf(stderr, "无法设置访问类型: %s\n", snd_strerror(rc));
goto cleanup;
}
// 5. 设置采样格式(16位小端)
rc = snd_pcm_hw_params_set_format(capture_handle, hw_params, SND_PCM_FORMAT_S16_LE);
if (rc < 0) {
fprintf(stderr, "无法设置采样格式: %s\n", snd_strerror(rc));
goto cleanup;
}
// 6. 设置采样率
unsigned int sample_rate = SAMPLE_RATE;
rc = snd_pcm_hw_params_set_rate_near(capture_handle, hw_params, &sample_rate, &dir);
if (rc < 0) {
fprintf(stderr, "无法设置采样率: %s\n", snd_strerror(rc));
goto cleanup;
}
printf("实际采样率: %u Hz\n", sample_rate);
// 7. 设置声道数(单声道)
rc = snd_pcm_hw_params_set_channels(capture_handle, hw_params, CHANNELS);
if (rc < 0) {
fprintf(stderr, "无法设置声道数: %s\n", snd_strerror(rc));
goto cleanup;
}
// 8. 设置周期大小
snd_pcm_uframes_t period_size = PERIOD_SIZE;
rc = snd_pcm_hw_params_set_period_size_near(capture_handle, hw_params, &period_size, &dir);
if (rc < 0) {
fprintf(stderr, "无法设置周期大小: %s\n", snd_strerror(rc));
goto cleanup;
}
printf("实际周期大小: %lu 帧\n", period_size);
// 9. 设置周期数(缓冲区大小 = 周期大小 * 周期数)
unsigned int periods = PERIODS;
rc = snd_pcm_hw_params_set_periods_near(capture_handle, hw_params, &periods, &dir);
if (rc < 0) {
fprintf(stderr, "无法设置周期数: %s\n", snd_strerror(rc));
goto cleanup;
}
printf("实际周期数: %u\n", periods);
// 10. 应用硬件参数
rc = snd_pcm_hw_params(capture_handle, hw_params);
if (rc < 0) {
fprintf(stderr, "无法设置参数: %s\n", snd_strerror(rc));
goto cleanup;
}
// 初始化FFmpeg编码器
codec = avcodec_find_encoder(AV_CODEC_ID_AAC);
if (!codec) {
fprintf(stderr, "找不到AAC编码器\n");
goto cleanup;
}
codec_ctx = avcodec_alloc_context3(codec);
if (!codec_ctx) {
fprintf(stderr, "无法分配编码器上下文\n");
goto cleanup;
}
// 设置AAC编码器参数
codec_ctx->bit_rate = 64000; // 64 kbps
codec_ctx->sample_fmt = AV_SAMPLE_FMT_FLTP; // AAC需要浮点平面格式
codec_ctx->sample_rate = SAMPLE_RATE;
codec_ctx->channel_layout = AV_CH_LAYOUT_MONO; // 单声道
codec_ctx->channels = CHANNELS;
codec_ctx->profile = FF_PROFILE_AAC_LOW; // AAC-LC
rc = avcodec_open2(codec_ctx, codec, NULL);
if (rc < 0) {
char errbuf[AV_ERROR_MAX_STRING_SIZE];
av_strerror(rc, errbuf, AV_ERROR_MAX_STRING_SIZE);
fprintf(stderr, "无法打开编码器: %s\n", errbuf);
goto cleanup;
}
// 分配音频帧
frame = av_frame_alloc();
if (!frame) {
fprintf(stderr, "无法分配音频帧\n");
goto cleanup;
}
frame->nb_samples = codec_ctx->frame_size;
frame->format = codec_ctx->sample_fmt;
frame->channel_layout = codec_ctx->channel_layout;
frame->sample_rate = codec_ctx->sample_rate;
rc = av_frame_get_buffer(frame, 0);
if (rc < 0) {
fprintf(stderr, "无法分配帧缓冲区\n");
goto cleanup;
}
// 分配数据包
pkt = av_packet_alloc();
if (!pkt) {
fprintf(stderr, "无法分配数据包\n");
goto cleanup;
}
// 分配ADTS头部缓冲区
adts_header = (uint8_t *)malloc(7);
if (!adts_header) {
fprintf(stderr, "无法分配ADTS头部缓冲区\n");
goto cleanup;
}
// 准备音频缓冲区
buffer = malloc(period_size * sizeof(short));
if (!buffer) {
fprintf(stderr, "无法分配缓冲区\n");
goto cleanup;
}
// 打开输出文件
aac_file = fopen("test.aac", "wb");
if (!aac_file) {
fprintf(stderr, "无法创建输出文件\n");
goto cleanup;
}
printf("开始录音...\n");
// 录音循环
int frames = 0;
const int total_frames = (SAMPLE_RATE * RECORD_SECONDS) / period_size;
float *samples = (float *)frame->data[0];
int samples_index = 0;
while (frames < total_frames) {
rc = snd_pcm_readi(capture_handle, buffer, period_size);
if (rc == -EPIPE) {
fprintf(stderr, "缓冲区溢出,正在恢复\n");
snd_pcm_prepare(capture_handle);
continue;
} else if (rc < 0) {
fprintf(stderr, "读取错误: %s\n", snd_strerror(rc));
break;
}
// 将PCM数据转换为浮点格式并填充到frame
for (int i = 0; i < rc; i++) {
samples[samples_index++] = buffer[i] / 32768.0f;
if (samples_index >= frame->nb_samples) {
// 帧满了,进行编码
rc = avcodec_send_frame(codec_ctx, frame);
if (rc < 0) {
fprintf(stderr, "发送帧失败\n");
goto cleanup;
}
while (rc >= 0) {
rc = avcodec_receive_packet(codec_ctx, pkt);
if (rc == AVERROR(EAGAIN) || rc == AVERROR_EOF) {
break;
} else if (rc < 0) {
fprintf(stderr, "接收包失败\n");
goto cleanup;
}
// 添加ADTS头
add_adts_header(adts_header, pkt->size,
get_sample_rate_index(SAMPLE_RATE), CHANNELS);
// 写入ADTS头和AAC数据
fwrite(adts_header, 1, 7, aac_file);
fwrite(pkt->data, 1, pkt->size, aac_file);
av_packet_unref(pkt);
}
samples_index = 0;
}
}
frames++;
printf("\r已录制 %.1f 秒... ", (float)frames * period_size / SAMPLE_RATE);
fflush(stdout);
}
// 刷新编码器
avcodec_send_frame(codec_ctx, NULL);
while (1) {
rc = avcodec_receive_packet(codec_ctx, pkt);
if (rc == AVERROR_EOF) {
break;
} else if (rc < 0) {
fprintf(stderr, "刷新编码器失败\n");
break;
}
// 添加ADTS头并写入最后的数据
add_adts_header(adts_header, pkt->size,
get_sample_rate_index(SAMPLE_RATE), CHANNELS);
fwrite(adts_header, 1, 7, aac_file);
fwrite(pkt->data, 1, pkt->size, aac_file);
av_packet_unref(pkt);
}
printf("\n录音完成!保存为 test.aac\n");
cleanup:
if (capture_handle) {
snd_pcm_close(capture_handle);
}
if (buffer) {
free(buffer);
}
if (aac_file) {
fclose(aac_file);
}
if (codec_ctx) {
avcodec_free_context(&codec_ctx);
}
if (frame) {
av_frame_free(&frame);
}
if (pkt) {
av_packet_free(&pkt);
}
if (adts_header) {
free(adts_header);
}
return 0;
}
代码的逻辑关系如下:

该代码是在上一节代码基础上修改的。snd_pcm_readi之后,使用FFmpeg处理。这里只讲新的知识点。
1、重采样:
USB摄像头传过来的音频数据是交错模式(interleaved),即立体声的时候排列方式是:L R L R...
但是FFmpeg要求的是平面格式(Plannar),即每个声道单独存放:LLLL...RRR...
因为我们只有单通道,所以存储格式不需要更改,后面我们见到重采样就知道怎么回事的。代码里面只是对音频数据进行了浮点重采样,因为FFmpeg是要求浮点的。
2、AAC
AAC(Advanced Audio Coding)是现代音频压缩技术的巅峰之作,代表了心理声学模型应用的最高水平。作为MPEG-4标准的核心音频技术,AAC在效率、质量和灵活性方面都超越了前代MP3标准。笔者并没有对AAC做过多研究,有兴趣的道友可以稍微深入一下。
AAC常见容器格式
格式 | 特点 | 使用场景 |
---|---|---|
.aac | 原始ADTS流 | 简单存储 |
.m4a | MP4容器 | iTunes标准 |
.mp4 | 视频容器 | 视频伴音 |
.3gp | 移动设备 | 手机录制 |
.ts | 传输流 | 数字电视 |
3、ADTS头结构
// ADTS头示例
uint8_t adts[7] = {
0xFF, // Sync byte 1
0xF1, // Sync byte 2 + 保护位
0x50, // 配置信息 (AAC-LC, 44.1kHz)
0x80, // 声道配置 + 帧长度高位
0x1F, // 帧长度中位
0xFC, // 帧长度低位 + 缓冲区
0x70 // 帧计数器
};
有了ADTS头,现代一般的播放器就能识别。
4、编译和运行
gcc -o uvc_voice_streaming uvc_voice_streaming_aac+adts.c -lasound -lavcodec -lavutil -lm
运行后,可以得到test.aac文件,使用VLC或者其他播放器一般是可以播放的。
四、总结
本章节主要讨论了ALSA框架下的音频获取的过程,可以看到应用程序还是比较简单的,不需要对底层有过多的关注。
并使用FFmpeg对音频数据进行了AAC编码,有了之前的章节,我们对FFmpeg使用基本上已经得心应手了。
下一章将面临关键的挑战:如何精确同步来自V4L2的视频帧和来自ALSA的音频包的时间戳,并使用FFmpeg将它们无缝地封装进MP4文件,实现真正的音视频录制。
再之后我们就要进入真正运动相机硬件方面的探讨了。