0755-82922363
杰理AC695X AD按键机制讲解
AD按键机制完整文档目录概述硬件层驱动层扫描层映射层应用层配置说明数据流程概述AD按键(ADC Key)是一种通过模拟数字转换器(ADC)检测按键状态的按键方案。通过在按键电路中串联不同阻值的电阻,形成分压电路,ADC采样得到不同的电压值,从而识别不同的按键。主要特点节省IO口:多个按键共用一个ADC通道成本低:只需要一个ADC通道和若干电阻支持多按键:最多支持10个按键(ADKEY_MAX_NUM = 10)灵活配置:支持内部/外部上拉,可配置电阻值核心文件apps/common/key/adkey.c# AD按键驱动实现apps/common/key/adkey_rtcvdd.c # RTCVDD AD按键实现apps/common/key/key_driver.c # 按键驱动框架include_lib/system/device/adkey.h# AD按键头文件include_lib/system/device/key_driver.h # 按键驱动头文件cpu/br23/adc_api.c # ADC底层API硬件层1. 硬件电路原理VDD (3.3V)|R_UP (上拉电阻: 10K内部 或 22K外部)|+-----> ADC_PIN (连接到ADC通道)|R_KEY (按键电阻: 0R, 3K, 6.2K, 9.1K, 15K, 24K, 33K, 51K, 100K, 220K)|SW (按键开关)| GND2. 分压计算公式当按键按下时,ADC采样到的电压值:ADC_VALUE = 0x3FF * R_KEY / (R_KEY + R_UP)其中:0x3FF (1023) 是ADC的最大值(10位ADC)R_KEY 是按键串联的电阻值R_UP 是上拉电阻值3. GPIO配置// GPIO配置(adkey.c: adkey_init)gpio_set_die(adkey_pin, 0); // 禁用数字输入gpio_set_direction(adkey_pin, 1); // 设置为输入gpio_set_pull_down(adkey_pin, 0); // 禁用下拉if (extern_up_en) {gpio_set_pull_up(adkey_pin, 0); // 使用外部上拉,禁用内部上拉} else {gpio_set_pull_up(adkey_pin, 1); // 使用内部上拉(10K)}4. ADC通道定义// 支持的ADC通道(adc_api.h)#define AD_CH_PA1(0x0)#define AD_CH_PA5(0x1)#define AD_CH_PA6(0x2)#define AD_CH_PA10 (0x3)#define AD_CH_PB1(0x5)#define AD_CH_PB3(0x6)// ... 更多通道驱动层1. 数据结构平台数据结构struct adkey_platform_data {u8 enable;// 是否使能AD按键u8 adkey_pin; // AD按键对应的GPIO引脚u8 extern_up_en;// 是否使用外部上拉(1:外部, 0:内部10K)u32 ad_channel; // ADC通道u16 ad_value[ADKEY_MAX_NUM];// 电压阈值数组(10个)u8key_value[ADKEY_MAX_NUM]; // 按键值数组(10个)};扫描参数结构struct key_driver_para {const u32 scan_time;// 按键扫描频率(单位: ms)u8 last_key;// 上一次的按键值u8 filter_value;// 用于按键消抖u8 filter_cnt;// 消抖累加值const u8 filter_time; // 消抖延时次数const u8 long_time; // 长按判定次数const u8 hold_time; // HOLD判定次数u8 press_cnt; // 按下计数u8 click_cnt; // 单击次数u8 click_delay_cnt; // 连击延时计数const u8 click_delay_time;// 连击延时次数u8 notify_value;// 待发送按键值u8 key_type;// 按键类型(KEY_DRIVER_TYPE_AD)u8(*get_value)(void); // 获取按键值函数指针};2. 初始化流程// 1. ADC初始化(adc_api.c: adc_init)void adc_init() {// 配置ADC时钟// 配置ADC寄存器// 初始化VBAT和VBG采样// 注册ADC中断// 启动ADC定时扫描(2ms)}// 2. AD按键初始化(adkey.c: adkey_init)int adkey_init(const struct adkey_platform_data *adkey_data) {__this = adkey_data;// 添加ADC采样通道adc_add_sample_ch(__this->ad_channel);// 配置GPIOgpio_set_die(__this->adkey_pin, 0);gpio_set_direction(__this->adkey_pin, 1);gpio_set_pull_down(__this->adkey_pin, 0);if (__this->extern_up_en) {gpio_set_pull_up(__this->adkey_pin, 0);// 外部上拉} else {gpio_set_pull_up(__this->adkey_pin, 1);// 内部上拉}return 0;}// 3. 注册按键扫描定时器(key_driver.c: key_driver_init)int key_driver_init(void) {extern const struct adkey_platform_data adkey_data;extern struct key_driver_para adkey_scan_para;err = adkey_init(&adkey_data);if (err == 0) {// 注册10ms定时扫描sys_s_hi_timer_add((void *)&adkey_scan_para, key_driver_scan, adkey_scan_para.scan_time);}}3. ADC采样机制// ADC采样队列(adc_api.c)struct adc_info_t {u32 ch;// ADC通道u16 value; // 采样值};static struct adc_info_t adc_queue[ADC_MAX_CH];// 添加采样通道u32 adc_add_sample_ch(u32 ch) {for (i = 0; i < ADC_MAX_CH; i++) {if (adc_queue[i].ch == ch) {break;// 已存在} else if (adc_queue[i].ch == -1) {adc_queue[i].ch = ch;adc_queue[i].value = 1;break;// 添加成功}}return i;}// 获取ADC值u32 adc_get_value(u32 ch) {for (int i = 0; i < ADC_MAX_CH; i++) {if (adc_queue[i].ch == ch) {return adc_queue[i].value;}}return 0;}// ADC定时扫描(2ms周期)void adc_scan(void *priv) {u8 next_ch = adc_get_next_ch(cur_ch);adc_queue[cur_ch].value = adc_sample(adc_queue[next_ch].ch);cur_ch = next_ch;}4. 按键值获取// 获取AD按键值(adkey.c: ad_get_key_value)u8 ad_get_key_value(void) {u8 i;if (!__this->enable) {return NO_KEY;}// 获取ADC采样值ad_data = adc_get_value(__this->ad_channel);// 遍历电压阈值数组,匹配按键for (i = 0; i < ADKEY_MAX_NUM; i++) {if ((ad_data <= __this->ad_value[i]) && (__this->ad_value[i] < 0x3ffL)) {return __this->key_value[i];// 返回对应的按键值}}return NO_KEY;// 没有按键按下}扫描层1. 扫描参数配置// AD按键扫描参数(adkey.c)struct key_driver_para adkey_scan_para = {.scan_time= 10, // 扫描频率: 10ms.last_key = NO_KEY, // 上一次按键值.filter_time= 2,// 消抖延时: 2次(20ms).long_time= 75, // 长按判定: 75次(750ms).hold_time= (75 + 15),// HOLD判定: 90次(900ms).click_delay_time = 20, // 连击延时: 20次(200ms).key_type = KEY_DRIVER_TYPE_AD,.get_value= ad_get_key_value,};2. 按键扫描状态机// 按键扫描函数(key_driver.c: key_driver_scan)static void key_driver_scan(void *_scan_para) {struct key_driver_para *scan_para = _scan_para;// 1. 获取当前按键值cur_key_value = scan_para->get_value();// 2. 按键消抖处理if (cur_key_value != scan_para->filter_value) {scan_para->filter_cnt = 0;scan_para->filter_value = cur_key_value;return;// 第一次检测,返回}if (scan_para->filter_cnt < scan_para->filter_time) {scan_para->filter_cnt++;return;// 消抖未完成}// 3. 按键状态判断if (cur_key_value != scan_para->last_key) {// 按键状态改变if (cur_key_value == NO_KEY) {// 按键抬起if (scan_para->press_cnt >= scan_para->long_time) {key_event = KEY_EVENT_UP;// 长按后抬起goto _notify;}scan_para->click_delay_cnt = 1;// 开始连击延时} else {// 按键按下scan_para->press_cnt = 1;if (cur_key_value != scan_para->notify_value) {scan_para->click_cnt = 1;// 新按键,重新计数scan_para->notify_value = cur_key_value;} else {scan_para->click_cnt++;// 连击计数}}} else {// 按键状态未改变if (cur_key_value == NO_KEY) {// 没有按键按下if (scan_para->click_cnt > 0) {// 有待处理的按键消息if (scan_para->click_delay_cnt > scan_para->click_delay_time) {// 连击延时到,判断点击次数if (scan_para->click_cnt >= 5) {key_event = KEY_EVENT_FIRTH_CLICK;// 五击} else if (scan_para->click_cnt >= 4) {key_event = KEY_EVENT_FOURTH_CLICK; // 四击} else if (scan_para->click_cnt >= 3) {key_event = KEY_EVENT_TRIPLE_CLICK; // 三击} else if (scan_para->click_cnt >= 2) {key_event = KEY_EVENT_DOUBLE_CLICK; // 双击} else {key_event = KEY_EVENT_CLICK;// 单击}key_value = scan_para->notify_value;goto _notify;} else {scan_para->click_delay_cnt++;// 继续延时}}} else {// 按键持续按下scan_para->press_cnt++;if (scan_para->press_cnt == scan_para->long_time) {key_event = KEY_EVENT_LONG;// 长按} else if (scan_para->press_cnt == scan_para->hold_time) {key_event = KEY_EVENT_HOLD;// HOLDscan_para->press_cnt = scan_para->long_time;} else {return;// 继续等待}key_value = cur_key_value;goto _notify;}}_notify:// 4. 发送按键事件key_value &= ~BIT(7);// 清除特殊标志位e.type = SYS_KEY_EVENT;e.u.key.init = 1;e.u.key.type = scan_para->key_type;e.u.key.event = key_event;e.u.key.value = key_value;e.u.key.tmr = timer_get_ms();scan_para->click_cnt = 0;scan_para->notify_value = NO_KEY;e.arg = (void *)DEVICE_EVENT_FROM_KEY;if (key_event_remap(&e)) {sys_event_notify(&e);// 发送系统事件}_scan_end:scan_para->last_key = cur_key_value;}3. 按键事件类型// 按键事件定义(event.h)enum {KEY_EVENT_CLICK, // 0: 单击KEY_EVENT_LONG,// 1: 长按KEY_EVENT_HOLD,// 2: HOLD(持续长按)KEY_EVENT_UP,// 3: 抬起KEY_EVENT_DOUBLE_CLICK,// 4: 双击KEY_EVENT_TRIPLE_CLICK,// 5: 三击KEY_EVENT_FOURTH_CLICK,// 6: 四击KEY_EVENT_FIRTH_CLICK, // 7: 五击KEY_EVENT_USER,// 8: 用户自定义KEY_EVENT_MAX,};4. 时间计算事件类型计算公式默认值消抖时间filter_time × scan_time2 × 10ms = 20ms长按时间long_time × scan_time75 × 10ms = 750msHOLD时间hold_time × scan_time90 × 10ms = 900ms连击延时click_delay_time × scan_time20 × 10ms = 200ms映射层1. 按键映射表结构// 按键映射表类型(task_key.c)typedef const u16(*type_key_ad_table)[KEY_EVENT_MAX];// 不同模式的按键表extern const u16 bt_key_ad_table[KEY_AD_NUM_MAX][KEY_EVENT_MAX];extern const u16 music_key_ad_table[KEY_AD_NUM_MAX][KEY_EVENT_MAX];extern const u16 fm_key_ad_table[KEY_AD_NUM_MAX][KEY_EVENT_MAX];extern const u16 record_key_ad_table[KEY_AD_NUM_MAX][KEY_EVENT_MAX];extern const u16 linein_key_ad_table[KEY_AD_NUM_MAX][KEY_EVENT_MAX];extern const u16 rtc_key_ad_table[KEY_AD_NUM_MAX][KEY_EVENT_MAX];extern const u16 pc_key_ad_table[KEY_AD_NUM_MAX][KEY_EVENT_MAX];extern const u16 spdif_key_ad_table[KEY_AD_NUM_MAX][KEY_EVENT_MAX];extern const u16 idle_key_ad_table[KEY_AD_NUM_MAX][KEY_EVENT_MAX];2. 按键表管理// 按键表映射数组(task_key.c)static const type_key_ad_table ad_table[APP_TASK_MAX_INDEX] = {[APP_BT_TASK] = bt_key_ad_table,[APP_MUSIC_TASK]= music_key_ad_table,[APP_FM_TASK] = fm_key_ad_table,[APP_RECORD_TASK] = record_key_ad_table,[APP_LINEIN_TASK] = linein_key_ad_table,[APP_RTC_TASK]= rtc_key_ad_table,[APP_PC_TASK] = pc_key_ad_table,[APP_SPDIF_TASK]= spdif_key_ad_table,[APP_IDLE_TASK] = idle_key_ad_table,};3. 按键事件映射函数// 按键事件转消息(task_key.c: adkey_event_to_msg)u16 adkey_event_to_msg(u8 cur_task, struct key_event *key) {if (ad_table[cur_task] == NULL) {return KEY_NULL;}type_key_ad_table cur_task_ad_table = ad_table[cur_task];// 通过按键值和事件类型索引,获取对应的消息return cur_task_ad_table[key->value][key->event];}4. 按键表示例(BT模式)// BT模式的AD按键表(adkey_table.c)const u16 bt_key_ad_table[KEY_AD_NUM_MAX][KEY_EVENT_MAX] = {// [按键值] = {单击, 长按, HOLD, 抬起, 双击, 三击}[0] = {KEY_CHANGE_MODE,// 单击:切换模式KEY_POWEROFF, // 长按:关机KEY_POWEROFF_HOLD,// HOLD:关机保持KEY_NULL, // 抬起:无KEY_TWS_SEARCH_REMOVE_PAIR,// 双击:TWS配对KEY_NULL// 三击:无},[1] = {KEY_MUSIC_PREV, // 单击:上一曲KEY_VOL_DOWN, // 长按:音量减KEY_VOL_DOWN, // HOLD:持续音量减KEY_NULL,KEY_NULL,KEY_NULL},[2] = {KEY_MUSIC_PP, // 单击:播放/暂停KEY_CALL_HANG_UP, // 长按:挂断电话KEY_NULL,KEY_NULL,KEY_CALL_LAST_NO, // 双击:回拨KEY_NULL},[3] = {KEY_MUSIC_NEXT, // 单击:下一曲KEY_VOL_UP, // 长按:音量加KEY_VOL_UP, // HOLD:持续音量加KEY_NULL,KEY_NULL,KEY_NULL},[4] = {KEY_NULL,KEY_MODE, // 长按:模式切换KEY_NULL,KEY_NULL,KEY_NULL,KEY_NULL},};5. 按键重映射// 按键事件重映射(app_task_switch.c: app_key_event_remap)int app_key_event_remap(struct sys_event *e) {struct key_event *key = &e->u.key;int msg = KEY_NULL;switch (key->type) {case KEY_DRIVER_TYPE_AD:case KEY_DRIVER_TYPE_RTCVDD_AD:// AD按键事件转消息msg = adkey_event_to_msg(app_curr_task, key);break;case KEY_DRIVER_TYPE_IO:// IO按键事件转消息msg = iokey_event_to_msg(app_curr_task, key);break;case KEY_DRIVER_TYPE_IR:// 红外按键事件转消息msg = irkey_event_to_msg(app_curr_task, key);break;// ... 其他按键类型}if (msg != KEY_NULL) {key->value = msg;// 更新按键值为消息return true;}return false;}应用层1. 按键消息处理流程// 应用层按键消息处理(app_common.c: app_common_key_msg_deal)int app_common_key_msg_deal(struct sys_event *event) {int ret = false;struct key_event *key = &event->u.key;int key_event = event->u.key.event;int key_value = event->u.key.value;if (key_event == KEY_NULL) {return false;}// UI接管检查if (key_is_ui_takeover()) {ui_key_msg_post(key_event);return false;}// 处理按键消息switch (key_event) {case KEY_POWEROFF:// 关机处理break;case KEY_CHANGE_MODE:// 模式切换break;case KEY_MUSIC_PP:// 播放/暂停break;case KEY_VOL_UP:// 音量加break;case KEY_VOL_DOWN:// 音量减break;// ... 更多按键处理}return ret;}2. 按键消息定义// 按键消息定义(key_event_deal.h)enum {// 基础按键KEY_POWER_START = 0,KEY_POWER,KEY_PREV,KEY_NEXT,KEY_OK,KEY_CANCLE,KEY_MENU,KEY_MODE,// 音量控制KEY_VOLUME_DEC = 10,KEY_VOLUME_INC,KEY_VOL_UP,KEY_VOL_DOWN,// 音乐控制KEY_MUSIC_PP,KEY_MUSIC_PREV,KEY_MUSIC_NEXT,KEY_MUSIC_STOP,// 通话控制KEY_CALL_LAST_NO,KEY_CALL_HANG_UP,KEY_CALL_ANSWER,// 模式切换KEY_CHANGE_MODE,KEY_BT_MODE,KEY_MUSIC_MODE,KEY_FM_MODE,KEY_LINEIN_MODE,// 电源控制KEY_POWEROFF,KEY_POWEROFF_HOLD,// TWS功能KEY_TWS_SEARCH_REMOVE_PAIR,// 其他功能KEY_REVERB_OPEN,KEY_EQ_MODE,KEY_MINOR_OPT,KEY_NULL = 0xFFFF,};3. 按键音效// 按键音效播放(key_driver.c)#if TCFG_KEY_TONE_ENaudio_key_tone_play();// 播放按键提示音#endif配置说明1. 板级配置文件配置文件位置:apps/soundbox/board/br23/board_xxx/board_xxx_cfg.h//*********************************************************************************//// adkey 配置////*********************************************************************************//#define TCFG_ADKEY_ENABLE ENABLE_THIS_MOUDLE// 是否使能AD按键#define TCFG_ADKEY_LED_IO_REUSE DISABLE_THIS_MOUDLE // ADKEY和LED IO复用#define TCFG_ADKEY_PORT IO_PORTB_01 // AD按键端口#define TCFG_ADKEY_AD_CHANNEL AD_CH_PB1 // AD通道#define TCFG_ADKEY_EXTERN_UP_ENABLE ENABLE_THIS_MOUDLE// 是否使用外部上拉// 上拉电阻配置#if TCFG_ADKEY_EXTERN_UP_ENABLE#define R_UP220 // 22K,外部上拉阻值#else#define R_UP100 // 10K,内部上拉默认10K#endif2. 电压阈值配置// 按键电阻配置(必须从小到大填电阻)#define TCFG_ADKEY_AD0(0) // 0R#define TCFG_ADKEY_AD1(0x3ffL * 30 / (30 + R_UP)) // 3K#define TCFG_ADKEY_AD2(0x3ffL * 62 / (62 + R_UP)) // 6.2K#define TCFG_ADKEY_AD3(0x3ffL * 91 / (91 + R_UP)) // 9.1K#define TCFG_ADKEY_AD4(0x3ffL * 150/ (150+ R_UP)) // 15K#define TCFG_ADKEY_AD5(0x3ffL * 240/ (240+ R_UP)) // 24K#define TCFG_ADKEY_AD6(0x3ffL * 330/ (330+ R_UP)) // 33K#define TCFG_ADKEY_AD7(0x3ffL * 510/ (510+ R_UP)) // 51K#define TCFG_ADKEY_AD8(0x3ffL * 1000 / (1000 + R_UP)) // 100K#define TCFG_ADKEY_AD9(0x3ffL * 2200 / (2200 + R_UP)) // 220K#define TCFG_ADKEY_VDDIO(0x3ffL)// VDDIO// 电压阈值(取相邻两个电阻值的中间值)#define TCFG_ADKEY_VOLTAGE0 ((TCFG_ADKEY_AD0 + TCFG_ADKEY_AD1) / 2)#define TCFG_ADKEY_VOLTAGE1 ((TCFG_ADKEY_AD1 + TCFG_ADKEY_AD2) / 2)#define TCFG_ADKEY_VOLTAGE2 ((TCFG_ADKEY_AD2 + TCFG_ADKEY_AD3) / 2)#define TCFG_ADKEY_VOLTAGE3 ((TCFG_ADKEY_AD3 + TCFG_ADKEY_AD4) / 2)#define TCFG_ADKEY_VOLTAGE4 ((TCFG_ADKEY_AD4 + TCFG_ADKEY_AD5) / 2)#define TCFG_ADKEY_VOLTAGE5 ((TCFG_ADKEY_AD5 + TCFG_ADKEY_AD6) / 2)#define TCFG_ADKEY_VOLTAGE6 ((TCFG_ADKEY_AD6 + TCFG_ADKEY_AD7) / 2)#define TCFG_ADKEY_VOLTAGE7 ((TCFG_ADKEY_AD7 + TCFG_ADKEY_AD8) / 2)#define TCFG_ADKEY_VOLTAGE8 ((TCFG_ADKEY_AD8 + TCFG_ADKEY_AD9) / 2)#define TCFG_ADKEY_VOLTAGE9 ((TCFG_ADKEY_AD9 + TCFG_ADKEY_VDDIO) / 2)3. 按键值配置// 按键值配置(对应10个按键)#define TCFG_ADKEY_VALUE0 0#define TCFG_ADKEY_VALUE1 1#define TCFG_ADKEY_VALUE2 2#define TCFG_ADKEY_VALUE3 3#define TCFG_ADKEY_VALUE4 4#define TCFG_ADKEY_VALUE5 5#define TCFG_ADKEY_VALUE6 6#define TCFG_ADKEY_VALUE7 7#define TCFG_ADKEY_VALUE8 8#define TCFG_ADKEY_VALUE9 94. 平台数据实例化// 板级配置文件(board_xxx.c)const struct adkey_platform_data adkey_data = {.enable = TCFG_ADKEY_ENABLE,.adkey_pin = TCFG_ADKEY_PORT,.ad_channel = TCFG_ADKEY_AD_CHANNEL,.extern_up_en = TCFG_ADKEY_EXTERN_UP_ENABLE,.ad_value = {TCFG_ADKEY_VOLTAGE0,TCFG_ADKEY_VOLTAGE1,TCFG_ADKEY_VOLTAGE2,TCFG_ADKEY_VOLTAGE3,TCFG_ADKEY_VOLTAGE4,TCFG_ADKEY_VOLTAGE5,TCFG_ADKEY_VOLTAGE6,TCFG_ADKEY_VOLTAGE7,TCFG_ADKEY_VOLTAGE8,TCFG_ADKEY_VOLTAGE9,},.key_value = {TCFG_ADKEY_VALUE0,TCFG_ADKEY_VALUE1,TCFG_ADKEY_VALUE2,TCFG_ADKEY_VALUE3,TCFG_ADKEY_VALUE4,TCFG_ADKEY_VALUE5,TCFG_ADKEY_VALUE6,TCFG_ADKEY_VALUE7,TCFG_ADKEY_VALUE8,TCFG_ADKEY_VALUE9,},};5. AUX检测配置(可选)// AUX检测电阻配置#define AUX_DET_R2200 // 外部AUX DET电阻220K#define ADC_AUX(0x3ffL*AUX_DET_R / (AUX_DET_R+R_UP))// AUX插入时电压#define ADC_AUX_IN ((TCFG_ADKEY_AD8 + ADC_AUX)/2) // AUX插入阈值// AUX模式下的按键电压(并联电阻计算)#define Rmul(R1,R2)((R1*R2)/(R1+R2))#define ADC_AUX_220(0x3ffL*Rmul(AUX_DET_R,2200)/(Rmul(AUX_DET_R,2200) + R_UP))#define ADC_AUX_100(0x3ffL*Rmul(AUX_DET_R,1000)/(Rmul(AUX_DET_R,1000) + R_UP))// ... 更多AUX按键电压配置// AUX模式按键阈值#define TCFG_ADKEY_AUX_VOLTAGE0((ADC_AUX_3 + ADC_AUX_0)/2)#define TCFG_ADKEY_AUX_VOLTAGE1((ADC_AUX_62+ ADC_AUX_3)/2)// ... 更多AUX阈值配置数据流程1. 完整数据流程图┌─────────────────────────────────────────────────────────────────┐│硬件层││按键按下 → 分压电路 → ADC采样 → GPIO输入│└────────────────────────┬────────────────────────────────────────┘ │ ↓┌─────────────────────────────────────────────────────────────────┐│ADC层 ││adc_sample() → ADC中断 → adc_queue[ch].value ││(2ms周期扫描所有ADC通道)│└────────────────────────┬────────────────────────────────────────┘ │ ↓┌─────────────────────────────────────────────────────────────────┐│驱动层││ad_get_key_value() → adc_get_value() → 电压匹配 → 返回按键值 │└────────────────────────┬────────────────────────────────────────┘ │ ↓┌─────────────────────────────────────────────────────────────────┐│扫描层││key_driver_scan() (10ms周期) ││├─ 获取按键值 ││├─ 消抖处理 (20ms) ││├─ 状态判断 │││ ├─ 按键按下 → 计数开始 │││ ├─ 按键抬起 → 判断单击/双击/三击 │││ └─ 按键持续 → 判断长按/HOLD││└─ 生成按键事件 │└────────────────────────┬────────────────────────────────────────┘ │ ↓┌─────────────────────────────────────────────────────────────────┐│映射层││key_event_remap() → adkey_event_to_msg() ││按键值 + 事件类型 → 查表 → 按键消息│└────────────────────────┬────────────────────────────────────────┘ │ ↓┌─────────────────────────────────────────────────────────────────┐│事件层││sys_event_notify() → 发送系统事件│└────────────────────────┬────────────────────────────────────────┘ │ ↓┌─────────────────────────────────────────────────────────────────┐│应用层││app_common_key_msg_deal() → 处理按键消息 → 执行功能│└─────────────────────────────────────────────────────────────────┘2. 时序图时间轴 →0ms 10ms20ms30ms750ms 900ms 1000ms│ │ │ │ │ │ │按键按下 │ │ │ │ │ ││ │ │ │ │ │ │├─消抖──┤ │ │ │ │ ││(20ms) │ │ │ │ ││ │ │ │ │ │ ││ └───────┴───────┴───────┤ │ ││ │ │ ││长按事件 │ ││(750ms)│ ││ │ │ ││ └───────┤ ││ │ ││HOLD事件 ││(900ms)││ │ ││ └───────┤│ │按键抬起抬起事件│(1000ms)3. 按键状态转换图┌──────────┐│NO_KEY│ (初始状态)└─────┬────┘│按键按下│↓┌──────────┐┌─────┤PRESS │ (按下状态)│ └─────┬────┘│ ││持续按下│ ││ ↓│ ┌──────────┐│ │ LONG │ (长按状态,750ms)│ └─────┬────┘│ ││持续按下│ ││ ↓│ ┌──────────┐│ │ HOLD │ (HOLD状态,900ms)│ └─────┬────┘│ │ 按键抬起按键抬起│ │↓ ↓┌──────────┐ ┌──────────┐│CLICK │ │UP│ (抬起事件)└─────┬────┘ └──────────┘│ 连击延时│↓┌──────────┐│ DOUBLE/│ (双击/三击/...)│ TRIPLE │└──────────┘4. 按键值匹配流程ADC采样值 (ad_data)│↓遍历电压阈值数组 (ad_value[0..9])│├─ ad_data <= ad_value[0] ? → 返回 key_value[0]├─ ad_data <= ad_value[1] ? → 返回 key_value[1]├─ ad_data <= ad_value[2] ? → 返回 key_value[2]├─ ad_data <= ad_value[3] ? → 返回 key_value[3]├─ ad_data <= ad_value[4] ? → 返回 key_value[4]├─ ad_data <= ad_value[5] ? → 返回 key_value[5]├─ ad_data <= ad_value[6] ? → 返回 key_value[6]├─ ad_data <= ad_value[7] ? → 返回 key_value[7]├─ ad_data <= ad_value[8] ? → 返回 key_value[8]├─ ad_data <= ad_value[9] ? → 返回 key_value[9]│└─ 都不匹配 → 返回 NO_KEY特殊功能1. IO复用功能AD按键支持与LED、IR等外设IO复用:// IO复用配置#define TCFG_ADKEY_LED_IO_REUSEENABLE_THIS_MOUDLE// ADKEY和LED IO复用#define TCFG_ADKEY_IR_IO_REUSE ENABLE_THIS_MOUDLE// ADKEY和IR IO复用#define TCFG_ADKEY_LED_SPI_IO_REUSEENABLE_THIS_MOUDLE// ADKEY和LED SPI IO复用// IO复用进入(adkey.c)u8 adc_io_reuse_enter(u32 ch) {if (ch == __this->ad_channel) {// 挂起LED/IR功能led_io_suspend();ir_io_suspend();// 配置GPIO为ADC模式gpio_set_die(__this->adkey_pin, 0);gpio_set_direction(__this->adkey_pin, 1);// ...}return 0;}// IO复用退出(adkey.c)u8 adc_io_reuse_exit(u32 ch) {if (ch == __this->ad_channel) {// 恢复LED/IR功能led_io_resume();ir_io_resume();}return 0;}2. RTCVDD AD按键RTCVDD AD按键是一种特殊的AD按键实现,使用RTCVDD作为参考电压:// RTCVDD AD按键平台数据(adkey.h)struct adkey_rtcvdd_platform_data {u8 enable;u8 adkey_pin;u8 adkey_num;u32 ad_channel;u32 extern_up_res_value;// 外部上拉电阻值u16 res_value[ADKEY_MAX_NUM]; // 电阻值数组(从大到小)u8key_value[ADKEY_MAX_NUM];};// RTCVDD AD按键获取值(adkey_rtcvdd.c)u8 adkey_rtcvdd_get_key_value(void) {u16 rtcvdd_value = 2 * adc_get_value(AD_CH_RTCVDD);u32 ad_value = adc_get_value(__this->ad_channel);// 自动匹配VDDIO电平if (rtcvdd_auto_match_vddio_lev(rtcvdd_value)) {return NO_KEY;}// 匹配按键值// ...}3. 动态按键配置(AUX检测)支持根据AUX插入状态动态切换按键配置:// 动态按键配置(adkey.c: my_key_con)void my_key_con(void) {if (ad_data > TCFG_ADKEY_VOLTAGE9) {// 没有AUX插入,使用正常按键配置adkey_data.ad_value[0] = TCFG_ADKEY_VOLTAGE0;adkey_data.ad_value[1] = TCFG_ADKEY_VOLTAGE1;// ...power_on_flag = 1;} else if (ad_data > ADC_AUX_IN) {// AUX插入,使用AUX按键配置adkey_data.ad_value[0] = TCFG_ADKEY_AUX_VOLTAGE0;adkey_data.ad_value[1] = TCFG_ADKEY_AUX_VOLTAGE1;// ...power_on_flag = 1;}}// 定时检测AUX状态void sys_my_key_con(void) {sys_timer_add(NULL, my_key_con, 100);// 100ms检测一次}4. 按键特殊标志支持按键特殊处理标志(BIT(7)):// 只响应单击事件的按键#define KEY_EVENT_CLICK_ONLY_SUPPORT1// 在按键扫描中处理if (scan_para->notify_value & BIT(7)) {// BIT(7)标志,只发送单击事件key_event = KEY_EVENT_CLICK;key_value = scan_para->notify_value;goto _notify;}// 发送前清除标志key_value &= ~BIT(7);调试方法1. 打印ADC值// 在 ad_get_key_value() 中添加打印u8 ad_get_key_value(void) {ad_data = adc_get_value(__this->ad_channel);static int num = 0;if(num++ > 30) {printf("ad_value = %d ", ad_data);num = 0;}// ...}2. 打印按键事件// 在 key_driver_scan() 中添加打印_notify:printf("key_value: 0x%x, event: %d", key_value, key_event);sys_event_notify(&e);3. 电压阈值计算工具# Python脚本计算电压阈值def calc_adkey_voltage(r_key, r_up=220):"""计算AD按键电压值r_key: 按键电阻值(单位:K欧)r_up: 上拉电阻值(单位:K欧)"""adc_max = 0x3FF# 10位ADC最大值voltage = int(adc_max * r_key / (r_key + r_up))return voltage# 示例r_up = 220# 22K上拉keys = [0, 3, 6.2, 9.1, 15, 24, 33, 51, 100, 220]print("按键电阻ADC值阈值")for i in range(len(keys)):adc_val = calc_adkey_voltage(keys[i], r_up)if i < len(keys) - 1:threshold = (calc_adkey_voltage(keys[i], r_up) +calc_adkey_voltage(keys[i+1], r_up)) // 2else:threshold = (calc_adkey_voltage(keys[i], r_up) + 0x3FF) // 2print(f"{keys[i]}K{adc_val}{threshold}")4. 常见问题排查问题可能原因解决方法按键无响应ADC通道未初始化检查 adc_add_sample_ch() 是否调用按键误触发消抖时间太短增加 filter_time 参数按键值错误电压阈值配置错误重新计算并配置电压阈值长按不触发long_time 配置太大减小 long_time 参数连击不触发click_delay_time 太短增加 click_delay_time 参数IO复用冲突LED/IR未正确挂起检查 adc_io_reuse_enter/exit总结AD按键机制是一个完整的多层架构系统:硬件层:通过分压电路实现多按键共用一个ADC通道ADC层:2ms周期采样所有ADC通道,维护采样队列驱动层:初始化GPIO和ADC,提供按键值获取接口扫描层:10ms周期扫描,实现消抖、长按、连击等事件判定映射层:根据当前模式和按键值/事件类型,映射到具体的按键消息应用层:处理按键消息,执行相应的功能整个系统通过定时器驱动,事件驱动的方式工作,具有良好的实时性和可扩展性。附录A. 相关宏定义// 按键相关#define NO_KEY0xff#define KEY_NOT_SUPPORT 0x01#define ADKEY_MAX_NUM 10#define KEY_AD_NUM_MAX10#define KEY_EVENT_MAX 9// 按键类型#define KEY_DRIVER_TYPE_IO0x0#define KEY_DRIVER_TYPE_AD0x1#define KEY_DRIVER_TYPE_RTCVDD_AD 0x2#define KEY_DRIVER_TYPE_IR0x3#define KEY_DRIVER_TYPE_TOUCH 0x4#define KEY_DRIVER_TYPE_RDEC0x5// 系统事件#define SYS_KEY_EVENT 0x0001#define DEVICE_EVENT_FROM_KEY (('K' << 24) | ('E' << 16) | ('Y' << 8) | '')B. 相关API// ADC APIu32 adc_add_sample_ch(u32 ch);u32 adc_remove_sample_ch(u32 ch);u32 adc_get_value(u32 ch);u32 adc_get_voltage(u32 ch);// AD按键APIint adkey_init(const struct adkey_platform_data *adkey_data);u8 ad_get_key_value(void);// RTCVDD AD按键APIint adkey_rtcvdd_init(const struct adkey_rtcvdd_platform_data *rtcvdd_adkey_data);u8 adkey_rtcvdd_get_key_value(void);// 按键驱动APIint key_driver_init(void);u16 adkey_event_to_msg(u8 cur_task, struct key_event *key);int key_event_remap(struct sys_event *e);// 系统事件APIint sys_event_notify(struct sys_event *e);C. 参考文档ADC驱动文档:cpu/br23/adc_api.c按键驱动文档:apps/common/key/key_driver.c板级配置示例:apps/soundbox/board/br23/board_ac695x_demo/文档版本:v1.0最后更新:2025-01-05适用平台:AC695N SDK 3.1.2
2026.05.19了解详情
AD16N_1.4.1__TF卡和Flash共用引脚会导致程序在PC_mode下死机
AD16N_1.4.1__TF卡和Flash共用引脚会导致程序在PC_mode下死机TF卡和Flash共用SPI:怎么接电脑当U盘项目里用了杰理AD16N(一颗跑音频+蓝牙的小芯片,SOP16封装只有16个脚),T卡和外挂Flash得共用同一组SPI引脚。插上USB线进PC模式,电脑要同时看到T卡和Flash两个盘。三根线、两个设备、一条USB——其实跟独木桥一个道理:桥就一条,两辆车得轮着过,谁过桥谁就得把对面拦住。下面就按这个思路捋一遍。整体思路Plain Text// TF卡+Flash共用SPI → USB接电脑的完整链路板级配置(PA09/PA10/PA11)│├── 注册设备:sd0(T卡) + ext_flsh(Flash)│├── 进PC模式 → usb_slave_app()│├── dev_open("ext_flsh") → SPI仲裁 → Flash初始化│├── 预热读(sector 0、1)│└── usb_start() → USB枚举│├── 电脑发SCSI命令(READ_10 / WRITE_10)│├── MSD层拿到LUN号 → 找到对应设备│├── IOCTL_CMD_RESUME → SPI仲裁抢桥│├── Flash读写(带4KB cache)│└── IOCTL_CMD_SUSPEND → SPI仲裁还桥│└── 退出PC模式 → dev_close() → 释放SPI翻译:芯片上电先把两个设备都注册好,插USB进PC模式后电脑看到两个盘,每次读写Flash前先把T卡的SPI线挂起来(拦桥),读写完再还回去(放行),来来回回就这么切。一、硬件怎么共的——三根线两个人用AD16N的SOP16封装脚不够用,T卡和Flash只能共用PA09、PA10、PA11这三根线。两个设备看这三根线的角色不一样:引脚T卡视角(SD模式)Flash视角(SPI模式)PA09CLK(时钟)CS(片选)PA10DAT0(数据)CLK(时钟)PA11CMD(命令)DO/DI(数据出入)Plain Text// 共线拓扑——独木桥结构PA09 PA10 PA11│ │ │┌─────┴────────┴────────┴─────┐│ SPI / SD 总线 │└─────┬──────────────┬────────┘│ │┌─────┴─────┐ ┌─────┴────────┐│ T卡(SD) │ │ Flash(SPI) ││ CLK/DAT │ │ CS/CLK/DO ││ /CMD │ │ /DI │└───────────┘ └──────────────┘三根线就是那条独木桥——同一时刻只能有一个设备在上面跑数据,另一个得让路。整体架构放一张图:看一下SDK里怎么配的:Plain Text// app_config.h —— SPI引脚和复用开关#define TFG_SPI_UNIDIR_MODE_EN ENABLE //外挂Flash单线模式#define TFG_SPI_WORK_MODE SPI_MODE_UNIDIR_1BIT#define TFG_SPI_CS_PORT_SEL IO_PORTA_09#define TFG_SPI_CLK_PORT_SEL IO_PORTA_10#define TFG_SPI_DO_PORT_SEL IO_PORTA_11#define TFG_SPI_DI_PORT_SEL IO_PORTA_11 //单线模式DO和DI共用PA11#define SPI_SD_IO_REUSE TFG_SD_EN //SD启用时才开IO复用协调再看T卡那边的引脚定义:Plain Text// sd_port.h —— T卡引脚(和Flash完全重叠)#define SDMMC_CMD_IO IO_PORTA_11 //CMD → PA11#define SDMMC_CLK_IO IO_PORTA_09 //CLK → PA09#define SDMMC_DAT_IO IO_PORTA_10 //DAT → PA10两段配置对着看:PA09/PA10/PA11一个不差,Flash的SPI和T卡的SD用的就是同一组脚。SPI_SD_IO_REUSE这个宏就是"独木桥交通灯"的总开关——T卡启用了,复用协调才会生效。二、软件怎么仲裁的——独木桥上的交通灯硬件共线了,软件得管好"谁在用桥"。SDK里搞了一套信号量+SPI切换的机制,每次Flash要用SPI之前,先把T卡的IO挂起来,用完再还回去。抢桥:norflash_reuse_enter()Plain Text// norflash.c —— Flash抢占SPI总线static int norflash_reuse_enter(){if (norflash_reuse_keep) { //已经占着桥了,直接过return 0;}if (sd_io_reuse_suspend() != 0) { //叫T卡让路,让不了就失败return -1;}spi_busy = 1; //标记:桥上有人了spi_flash_io_resume(); //把SPI控制器重新配好给Flash用return 0;}还桥:norflash_reuse_exit()Plain Text// norflash.c —— Flash归还SPI总线static void norflash_reuse_exit(){if (norflash_reuse_keep) { //有"霸桥"标记,不还return;}if (!spi_busy) { //本来就没占,不用还return;}spi_flash_io_suspend(); //SPI引脚释放(IO设高阻)spi_busy = 0; //标记清除sd_io_reuse_resume(); //把T卡的IO还回去}T卡怎么让路:sd_io_reuse_suspend()这是最底层的仲裁——逐线挂起T卡的三个IO(CMD、CLK、DAT),每根线对应一把信号量:Plain Text// norflash.c —— 逐线挂起T卡IOint sd_io_reuse_suspend(){u8 sd_io_suspend_status = 0;u16 retry_cnt = 0;_retry:OS_ENTER_CRITICAL(); //关中断,进临界区if (0 != sd_io_suspend(0, 0)) { //挂CMD线goto _exit;}sd_io_suspend_status |= BIT(0);if (0 != sd_io_suspend(0, 1)) { //挂CLK线goto _exit;}sd_io_suspend_status |= BIT(1);if (0 != sd_io_suspend(0, 2)) { //挂DAT线goto _exit;}OS_EXIT_CRITICAL();return 0; //三根线全挂起,成功_exit://中途失败,已挂的线得还回去if (sd_io_suspend_status & BIT(0)) sd_io_resume(0, 0);if (sd_io_suspend_status & BIT(1)) sd_io_resume(0, 1);OS_EXIT_CRITICAL();if (retry_cnt++ < 10000) { //最多重试10000次,每次等10μsudelay(10);goto _retry;}return -1; //100ms还没拿到,彻底失败}独木桥上的交通灯工作流程:Flash要过桥 → 先按住T卡三根信号线的"红灯"(CMD→CLK→DAT逐个锁) → Flash过桥干活 → 干完把红灯放开 → T卡可以走了。整个过程在临界区里做,中断都关了,不会被打断。SPI底层切换做了什么"挂起"和"恢复"不只是改个标志位,SPI控制器得真正切换:Plain Text// spi.c —— SPI挂起(释放IO给T卡)void spi_suspend(hw_spi_dev spi){u8 *port = spix_p_data_cache[spi].port;spi_io_port_uninit(port[0]); //CLK → 高阻spi_io_port_uninit(port[1]); //DO → 高阻spi_io_port_uninit(port[2]); //DI → 高阻spi_io_crossbar_uninit(spi); //断开IO矩阵映射spi_disable(spi_regs[spi]); //关SPI外设}Plain Text// spi.c —— SPI恢复(IO重新配给Flash)void spi_resume(hw_spi_dev spi){spi_set_bit_mode(spi, spi_info->mode); //重配单线/双线模式spi_cs_dis(spi_regs[spi]); //CS手动控制spi_clk_idle_sel(...); //时钟极性spi_set_baud(spi, spi_info->clk); //波特率spi_enable(spi_regs[spi]); //开SPI外设}挂起=把IO全放成高阻+关SPI外设+断IO矩阵,恢复=重配寄存器+重连IO矩阵+开SPI。不是软件层面的"暂停",是硬件层面的"拆线重接"。整个仲裁时序放一张图,看起来更直观:拿一张表汇总一下整个仲裁流程的状态变化:阶段spi_busyT卡IO状态SPI控制器Flash可访问空闲0正常(T卡用)关闭❌Flash抢桥中0→1逐线挂起恢复❌→✅Flash使用中1挂起开启✅Flash还桥中1→0逐线恢复关闭✅→❌三、怎么接电脑的——USB MSD把Flash当U盘独木桥的仲裁搞定了,接下来看怎么让电脑认出这两个盘。USB Mass Storage用的是Bulk-Only Transport协议——电脑发SCSI命令,芯片收到后读写对应的存储设备,再把结果传回去。注册两个盘SDK里把T卡和Flash分别注册成USB MSD的两个LUN(逻辑单元号):Plain Text// msd.c —— 注册T卡和Flash为两个U盘_msd_var.max_lun = 2;_msd_var.inquiry[0] = SCSIInquiryData; //LUN0: T卡_msd_var.inquiry[1] = EX_SCSIInquiryData; //LUN1: Flashmsd_register_disk("sd0", NULL); //T卡msd_register_disk("ext_flsh", NULL); //外挂FlashPlain Text// device_list.c —— 设备注册表//U盘模式用norflash_dev_ops(512字节块单位,自带擦写管理){.name = "ext_flsh", .ops = &norflash_dev_ops, .priv_data = &norflash_data},//T卡用sd_dev_ops{.name = "sd0", .ops = &sd_dev_ops, .priv_data = &sd0_data},电脑插上USB看到两个盘:一个是T卡(LUN0),一个是Flash(LUN1)。电脑不知道它们共用SPI,它只管发SCSI命令,芯片内部自己协调。进PC模式的完整流程Plain Text// usb_slave_mode.c —— PC模式入口void usb_slave_app(void){usb_device_mode(0, 0); //配置USB为从机模式void *device = dev_open("ext_flsh", 0); //打开Flash设备if (device != NULL) {//开启4KB读缓存 + 关闭缓存同步的临界区保护dev_ioctl(device, IOCTL_SET_READ_USE_CACHE, 1);dev_ioctl(device, IOCTL_SET_CACHE_SYNC_ISR_EN, 0);//预热:读sector 0和1,把Flash叫醒+填充cachememset(usb_slave_flash_warmup_buf, 0, sizeof(usb_slave_flash_warmup_buf));dev_bulk_read(device, usb_slave_flash_warmup_buf, 0, 1);dev_bulk_read(device, usb_slave_flash_warmup_buf, 1, 1);}usb_start(); //启动USB枚举,电脑开始识别//... 消息循环处理SCSI命令 ...}进PC模式就四步:配USB从机 → 打开Flash(触发一次SPI仲裁) → 预热读两个扇区 → 启动USB。预热是为了把Flash从休眠里叫醒,顺便填好cache。电脑读写Flash的数据通路电脑发一个SCSI READ_10命令,芯片这边的处理路径(同步模式):Plain Text// msd.c —— SCSI READ_10处理(同步路径)static void read_10(const struct usb_device_t *usb_device){u32 lba = read_32(msd_var->cbw.lba); //起始扇区号u16 lba_num = (cbw.LengthH << 8) | cbw.LengthL; //扇区数while (lba_num) {num = lba_num > MSD_BLOCK_SIZE ? MSD_BLOCK_SIZE : lba_num;dev_ioctl(dev_fd, IOCTL_CMD_RESUME, 0); //抢桥err = dev_bulk_read(dev_fd, msd_buf, lba, num); //读Flashdev_ioctl(dev_fd, IOCTL_CMD_SUSPEND, 0); //还桥msd_mcu2usb(usb_device, msd_buf, num * 512); //数据传给电脑lba += num;lba_num -= num;}}写操作(WRITE_10)也是同样的套路,多了一个flush:Plain Text// msd.c —— SCSI WRITE_10处理(同步路径,简化)while (lba_num) {msd_usb2mcu_64byte_fast(..., msd_buf, num * 512); //从USB收数据dev_ioctl(dev_fd, IOCTL_CMD_RESUME, 0); //抢桥dev_bulk_write(dev_fd, msd_buf, lba, num); //写Flashdev_ioctl(dev_fd, IOCTL_CMD_SUSPEND, 0); //还桥}if (need_flush) {dev_ioctl(dev_fd, IOCTL_CMD_RESUME, 0);dev_ioctl(dev_fd, IOCTL_FLUSH, 0); //把cache里的脏数据刷到Flashdev_ioctl(dev_fd, IOCTL_CMD_SUSPEND, 0);}每次读写Flash的套路都是"抢桥→干活→还桥"三板斧。写完还得flush一下,把cache里攒着的脏数据真正写进Flash。电脑那边发 SYNCHRONIZE_CACHE命令时也会触发flush。四、Flash cache怎么工作的——桥上的临时仓库Flash写入有个特点:得先擦后写,而且擦除是按4KB扇区整块来的。如果每写512字节就擦一次4KB,速度慢得离谱,Flash寿命也撑不住。SDK的做法是在RAM里开一个4KB缓冲区当"临时仓库"——数据先往仓库里攒,攒满一个扇区或者被催了(flush)才真正擦写一次。cache的三个状态变量Plain Text// norflash.c —— Flash cache核心变量static u8 *flash_cache_buf; //4KB缓冲区(RAM里)static u32 flash_cache_addr; //当前缓存的是哪个4KB扇区static u8 flash_cache_is_dirty; //脏标记:缓冲区内容和Flash上不一样了static u8 flash_cache_timer; //脏计时:攒了多久没写写入流程:攒着写Plain Text// norflash.c —— _norflash_write() 写入逻辑(简化)//1. 算出写入地址落在哪个4KB扇区u32 align_addr = addr / 4096 * 4096;//2. 如果和当前缓存的扇区不一样,先把旧脏数据刷掉if (align_addr != flash_cache_addr) {if (flash_cache_is_dirty) {_norflash_eraser(FLASH_SECTOR_ERASER, flash_cache_addr); //擦旧扇区_norflash_write_pages(flash_cache_addr, flash_cache_buf, 4096); //写回flash_cache_is_dirty = 0;}_norflash_read(align_addr, flash_cache_buf, 4096, 0); //读新扇区到缓存flash_cache_addr = align_addr;}//3. 在缓冲区里改数据memcpy(flash_cache_buf + (addr - align_addr), w_buf, align_len);//4. 判断是否凑满一整个扇区if ((addr + align_len) % 4096) {flash_cache_is_dirty = 1; //没满,标脏,先不写flash_cache_timer = 1; //开始计时} else {//满了,立刻擦写_norflash_eraser(FLASH_SECTOR_ERASER, align_addr);_norflash_write_pages(align_addr, flash_cache_buf, 4096);flash_cache_is_dirty = 0;}写入逻辑就是"先攒后刷":数据塞进RAM缓冲区,凑满4KB整扇区就立刻擦写,没凑满就标个脏等着。跟你往快递箱里塞包裹一样——塞满了叫快递员来收,没满就先攒着。完整的写入流程放一张图:定时同步:别攒太久如果一直没凑满怎么办?SDK有个定时器 _norflash_cache_sync_timer(),隔一段时间检查一次——有脏数据就强制刷盘:Plain Text// norflash.c —— 定时同步脏缓存(简化)void _norflash_cache_sync_timer(u32 sync_step){if (flash_cache_is_dirty) {//同步也得抢桥!if (norflash_reuse_enter() == 0) {_norflash_eraser(FLASH_SECTOR_ERASER, flash_cache_addr);_norflash_write_pages(flash_cache_addr, flash_cache_buf, 4096);flash_cache_is_dirty = 0;norflash_reuse_exit(); //用完还桥}}}定时同步也得走独木桥仲裁——抢桥→擦写→还桥。所以就算Flash自己在后台刷缓存,T卡的SPI也会被短暂挂起。五、会出什么问题——桥上堵车的几种情况独木桥方案能跑,但有几个容易出问题的地方。1:仲裁超时死机sd_io_reuse_suspend()重试10000次×10μs = 100ms。T卡如果在做大块读写,100ms内还没释放IO信号量,Flash这边就返回-1。上层拿到-1后的处理方式各不相同——有的直接返回0(读写失败但不崩),有的地方没做错误处理就直接空指针炸了。查了半天发现根因有两层:•T卡在做连续多扇区读写时,一个命令周期可能超过100ms•Flash驱动的 norflash_bulk_read拿到-1后返回0,MSD层以为读了0个扇区,后续指针计算就错了修法:1.在 sd_io_reuse_suspend()里把重试上限从10000调到更大的值(或者改成带超时的信号量等待)2.在 norflash_bulk_read/norflash_bulk_write失败路径加明确的错误码返回,MSD层收到错误码后回 MEDIUM_ERROR给电脑,不继续读写Plain Text// norflash.c —— 仲裁失败时的日志(排查用)//抢桥失败会打印:log_error("nf reuse enter fail keep=%d busy=%d", norflash_reuse_keep, spi_busy);//等Flash BUSY超时会打印:log_error("norflash_wait_ok timeout cmd=%x off=%x len=%x wr=%d sr1=%x busy=%d keep=%d",norflash_dbg_last_cmd, norflash_dbg_last_offset, norflash_dbg_last_len,norflash_dbg_last_is_write, reg_1, spi_busy, norflash_reuse_keep);看到这两条log就知道是独木桥堵车了——要么T卡太忙让不了路,要么Flash操作太慢桥上堵住了。2:异步MSD时序冲突SDK里 msd.c有个 USB_MSD_BULK_DEV_USE_ASYNC宏控制异步写入。异步模式下USB收数据和Flash写数据是重叠的(双缓冲乒乓),但SPI仲裁不是线程安全的——如果USB中断里触发了T卡检测,同时Flash又在写,两边同时操作SPI就会数据错乱。Plain Text// msd.c —— 异步开关#define USB_MSD_BULK_DEV_USE_ASYNC 0 //关闭异步MSD,走同步读写路径这个宏设成0就是走同步路径:收完USB数据再写Flash,写完再收下一包。慢一点但安全。共用SPI的场景下别开异步,时序冲突太难排查。3:写入慢(flush太频繁)电脑往Flash盘拷文件时,每写几个扇区Windows就发一次 SYNCHRONIZE_CACHE命令。每次flush都要走"抢桥→擦写4KB→还桥",而Flash擦除一个4KB扇区要几十ms。如果flush太频繁,写入速度就被擦除时间拖慢。调了一下发现:•不设缓存,每512字节都擦写一次:写入极慢,Flash寿命也撑不住•缓存开了但 CACHE_SYNC_ISR_EN没关,定时器在中断里也会抢桥flush:写入时被打断,速度波动大•IOCTL_SET_CACHE_SYNC_ISR_EN设成0(关闭中断内同步),只在SCSI的 SYNCHRONIZE_CACHE和手动flush时才刷缓存:写入稳定,速度刚好Plain Text// usb_slave_mode.c —— PC模式下的缓存策略dev_ioctl(device, IOCTL_SET_READ_USE_CACHE, 1); //读缓存开dev_ioctl(device, IOCTL_SET_CACHE_SYNC_ISR_EN, 0); //关中断内自动同步PC模式下得关掉中断内自动同步,不然后台定时器会抢桥flush,和前台的SCSI读写撞在一起。让flush只在SCSI命令触发时走,独木桥的通行顺序就可控了。六、串联回顾——完整走一遍从板子上电到电脑看到两个盘并读写Flash,整条链路用树枝图串起来:Plain Text// 完整数据链路——从配置到读写板级配置├── PA09/PA10/PA11 三脚共用(独木桥)├── SPI_SD_IO_REUSE = 1(交通灯开关)│设备注册├── "sd0" → sd_dev_ops(T卡驱动)├── "ext_flsh" → norflash_dev_ops(Flash驱动,512B块模式+cache)│进PC模式 usb_slave_app()├── ① USB配从机模式├── ② dev_open("ext_flsh") → norflash_reuse_enter()(第一次抢桥)│└── sd_io_reuse_suspend() → spi_flash_io_resume()├── ③ 预热读sector 0、1(Flash叫醒+cache填充)├── ④ usb_start()(USB枚举,电脑识别到两个盘)│电脑发SCSI命令├── READ_10 / WRITE_10│├── IOCTL_CMD_RESUME → norflash_reuse_enter()(抢桥)│├── dev_bulk_read/write → SPI读写Flash(桥上干活)││└── 写入走4KB cache:攒满擦写 / 标脏等flush│└── IOCTL_CMD_SUSPEND → norflash_reuse_exit()(还桥)├── SYNCHRONIZE_CACHE → IOCTL_FLUSH → 强制刷脏缓存│退出PC模式└── dev_close("ext_flsh") → norflash_reuse_exit()(最后一次还桥)回扣一下独木桥的类比:现实中代码里独木桥(一条路)PA09/PA10/PA11(三根共用线)交通灯(红绿灯)sd_io_reuse_suspend()/ resume()车辆通行证spi_busy+ norflash_reuse_keep桥上施工(读写Flash)spi_flash_io_resume()+ SPI命令临时仓库(桥头堆货)flash_cache_buf(4KB RAM缓冲)催收员(定时来收货)_norflash_cache_sync_timer()收费站(电脑USB口)USB MSD Bulk-Only Transport整条链路就这些——配置共脚、注册设备、仲裁抢桥、cache攒着写、USB传数据。搞明白"独木桥怎么轮着走"这一件事,剩下的都是工程细节。感受SPI共线本身不复杂,跟I2C多从机一个思路,分时复用嘛。SDK把仲裁封装成 norflash_reuse_enter/exit也算清晰,抢桥还桥看代码就能明白。USB MSD协议更不用说了,SCSI命令集翻一翻就上手。卡住我比较久的是仲裁时序——T卡和Flash的SPI切换牵扯IO矩阵、信号量、临界区,出了问题条件都凑不齐,很难复现。cache flush策略也绕了一阵,什么时候刷、在哪个上下文刷、会不会和前台SCSI命令撞车,翻了好几遍代码才理顺。最意外的是MSD异步双缓冲,宏定义看着挺美,放到共用SPI场景下直接没法用,这块的问题还是得等待杰理的AD小组来完善共脚、仲裁、cache、MSD——搞明白这四个词,这套方案就算摸透了。
2026.05.18了解详情
蓝牙PAN协议——不用WiFi,蓝牙也能上网?
蓝牙PAN协议——不用WiFi,蓝牙也能上网?最近刷YouTube的时候看到一个东西:蓝牙PAN。感觉挺有意思的,跟大家分享一下——嵌入式设备没有WiFi模块(或者WiFi挂了),但有经典蓝牙,能不能借手机的网络上网?PAN就是干这个的。整个链路大概是这样的:蓝牙配对 → SDP发现NAP服务 → BNEP连接 → DHCP拿IP → 正常跑TCP/IP。做个记录分析一下。一、PAN是什么——蓝牙版的"网络共享"PAN全称Personal Area Networking Profile,是 经典蓝牙(BR/EDR)的一个Profile。核心能力就一句话:通过蓝牙链路传输以太网帧,让蓝牙设备像接了网线一样上网。日常最常见的场景:手机开蓝牙网络共享,平板/手表/嵌入式设备连上去就能上网。跟WiFi热点比,PAN功耗更低、不占WiFi通道,但带宽也低——经典蓝牙EDR理论3Mbps,实际1~2Mbps(受环境干扰波动大,WiFi共存时可能只有700Kbps)。发个HTTP请求、跑个MQTT够用,传大文件就别想了。和前面搞过的WiFi+Socket那套路完全不同:WiFi走的是802.11无线局域网,PAN走的是蓝牙射频。但上了IP层之后,TCP/UDP/HTTP该怎么跑还怎么跑——对应用层来说是透明的。二、三个角色——谁提供网,谁用网PAN里定义了三个角色:角色全称干什么典型设备PANUPAN User上网的一方(客户端)嵌入式设备、平板NAPNetwork Access Point提供网络接入(像路由器)手机、笔记本GNGroup Ad-hoc Network自组网,设备之间互连临时会议、传感器组网几个关键区别:NAP能接外网,GN不能——GN只管自己组里的设备互相通信,不提供互联网NAP和GN都能转发包,PANU不转发NAP和GN不能互连——一个设备要么做NAP要么做GN,不能同时是两者嵌入式设备99%的场景是做PANU——连上手机(NAP),借手机的4G/5G/WiFi上网。三、协议栈——BNEP是关键的"桥"PAN的协议栈长这样:BNEP(Bluetooth Network Encapsulation Protocol)是PAN的核心。它干的事说白了就是:把以太网帧的头扒掉,换上BNEP自己的头,然后塞进L2CAP通道传出去。对面收到后再把BNEP头扒掉,还原成以太网帧丢给IP层处理。对比旧方案在PAN之前,蓝牙设备上网走的是PPP over RFCOMM——IP层到L2CAP之间得经过PPP和RFCOMM两层协议。BNEP直接桥接IP层到L2CAP,省掉了PPP和RFCOMM两层开销,效率高不少。BNEP的五种包类型类型携带的地址适合场景GENERAL_ETHERNET自身MAC + 对端MAC多连接场景,通用COMPRESSED_ETHERNET不带地址一对一,PANU↔PANUCOMPRESSED_DEST_ONLY只带对端MAC一对多,省自身地址COMPRESSED_SOURCE_ONLY只带自身MAC一对多,省对端地址CONTROL—控制命令(连接/过滤器)一对一连接的时候用COMPRESSED模式可以省掉地址字段,包更小。BNEP的MTU最小得1691字节,L2CAP配置阶段如果给的MTU比这小,通道直接建不起来。四、连接流程——从配对到上网整个PAN从蓝牙配对到能上网,走了六步:一步步拆开看:ACL连接——标准蓝牙配对,建立底层链路SDP服务发现——PANU去查NAP有没有PAN服务。关键信息包括:Service Class ID(标识是NAP还是GN)、BNEP版本(v1.0)、支持的以太网类型(IPv4 0x0800、ARP 0x0806)、PSM = 0x000FL2CAP通道——基于PSM 0x000F建L2CAP连接,MTU得 ≥ 1691BNEP Setup——PANU发 SETUP_CONNECTION_REQUEST,NAP回 SETUP_CONNECTION_RESPONSE(响应码0x0000表示成功)。到这一步BNEP通道就通了DHCP——走标准DHCP四步(DISCOVER → OFFER → REQUEST → ACK),NAP给PANU分配IP地址ARP——MAC地址和IP地址互相绑定,之后就可以直接用IP地址通信了DHCP和ARP跑完,PANU就有了自己的IP地址。后面该怎么发HTTP、怎么跑MQTT,跟WiFi环境下一模一样——应用层完全感知不到底下走的是蓝牙。五、数据收发——以太网帧怎么走蓝牙数据走PAN的路径:接收就反过来:蓝牙射频收到 → L2CAP重组 → BNEP解析还原成以太网帧 → 丢给IP层。BNEP帧格式Plain Text// BNEP帧结构┌──────────┬──────────────┬──────────────┬──────────────┬──────────┐│ BNEP Type│ Dest Addr │ Src Addr │ Protocol Type│ Payload ││ (1 byte) │ (6B, 可选) │ (6B, 可选) │ (2 bytes) │ (变长) │└──────────┴──────────────┴──────────────┴──────────────┴──────────┘BNEP Type决定了后面有没有地址字段。GENERAL_ETHERNET带全部地址,COMPRESSED模式省掉一个或两个。Protocol Type标识上层是IPv4(0x0800)还是ARP(0x0806)还是IPv6(0x86DD)。六、嵌入式视角——MCU上怎么跑PANTCP/IP栈的选择MCU上跑PAN,蓝牙协议栈管到BNEP这一层,但BNEP上面的IP/TCP/UDP得有个TCP/IP栈来处理。两个常见选项:TCP/IP栈RAM占用适合uIP~几KB极度内存受限、低带宽场景lwIP~几十KB有RTOS、功能更全uIP比lwIP更轻量,适合资源紧张的场景。泰凌的BT SDK就是用uIP跑PAN的。虚拟网卡——用环形队列模拟PAN走的是蓝牙射频,不是真的网卡。但TCP/IP栈(不管是uIP还是lwIP)都认为自己在跟一张网卡打交道。所以得用软件模拟一张虚拟网卡:Plain Text// 虚拟网卡架构┌─────────────┐│ uIP/lwIP ││ TCP/IP栈 │└──────┬──────┘读取↓ ↑写入┌──────────┐ ┌──────────┐│ RX FIFO │ │ TX FIFO ││(环形队列)│ │(环形队列)│└────┬─────┘ └─────┬────┘写入↓ ↑读取┌──────────────────────┐│ BNEP收发层 ││ (蓝牙协议栈) │└──────────────────────┘收方向:蓝牙收到BNEP帧 → 解析成以太网帧格式 → 写进RX FIFO → TCP/IP栈从RX FIFO取出处理发方向:TCP/IP栈把以太网帧写进TX FIFO → BNEP层从TX FIFO取出 → 封装成BNEP帧 → 蓝牙发出两个环形队列就是这张"虚拟网卡"的驱动。main loop不停地在两个FIFO之间搬数据。带宽和适用场景经典蓝牙EDR理论带宽3Mbps,减去协议开销实际也就~2Mbps。跑轻量级的HTTP请求、MQTT消息收发完全够用。但流媒体、大文件下载就别指望了。什么时候考虑PAN:设备没有WiFi模块,但有经典蓝牙——PAN是唯一的IP联网途径WiFi环境不可靠,蓝牙做备选通道——BT+WiFi双链路容灾不想额外开WiFi,省功耗——蓝牙本身就开着(比如在跑A2DP播歌),顺带走PAN上网七、PAN vs 其他蓝牙联网方案对比项PAN(BNEP)SPP+PPPBLE IPSPWiFi热点协议栈IP→BNEP→L2CAPIP→PPP→RFCOMM→L2CAPIP→6LoWPAN→L2CAP(BLE)标准WiFi蓝牙类型经典BT(BR/EDR)经典BTBLE 4.1+不走蓝牙带宽~2Mbps~1-2Mbps(RFCOMM开销略高)200Kbps~1Mbps(取决于BLE版本和PHY)几十Mbps功耗中中低高层级开销少(3层)多(4层)少—适合通用IP通信老设备兼容BLE低功耗场景高带宽SPP+PPP是老方案,多了RFCOMM和PPP两层,带宽也不如PAN。BLE IPSP走的是低功耗蓝牙,功耗最低但带宽也最低,适合传感器那种数据量小的场景。WiFi热点带宽碾压,但功耗也最高。八、PAN的硬伤——为什么实际产品里用的人不多协议栈是完整的,技术上能跑通。但翻了一圈论坛和实测数据,PAN在蓝牙协议族里一直属于"有但没人用"的那一类。原因很现实:带宽天花板太低经典蓝牙EDR理论最大3Mbps,但那是射频层的峰值。走完L2CAP → BNEP → IP这一串协议开销之后,实际到应用层的吞吐量:场景实测网速备注BT PAN共享(WiFi同时开着)~700 KbpsXDA论坛多人复现,很普遍BT PAN共享(WiFi关闭)~1.2-1.5 Mbps关WiFi后明显提速BT PAN共享(理想环境)~2 Mbps 封顶协议理论上限700Kbps这个数字很多人都碰到了。原因是蓝牙和WiFi共用2.4GHz频段,WiFi开着的时候蓝牙得让路(共存机制coexistence),吞吐量直接砍一半。关WiFi之后能跑到1.5Mbps左右,但也就到头了。对比一下:WiFi热点轻松几十Mbps,USB共享跑满手机上行。PAN的带宽只够发HTTP请求、跑MQTT、传个小文件。延迟高且抖动大蓝牙PAN的实测ping在 100~200ms量级(来源:XDA/SuperUser论坛多位用户实测,非官方数据),而且 抖动很大——有人测出来稳定200ms,有人在100~400ms之间跳。蓝牙的跳频机制和重传策略本身就不是为低延迟设计的,再叠上BNEP封装和IP栈处理,延迟没法控。对比:WiFi局域网1~5ms,4G网络20~50ms。蓝牙PAN比4G还慢。2.4GHz干扰敏感蓝牙工作在2.4GHz ISM频段,跟WiFi、微波炉、无线鼠标键盘全挤在一起。WiFi密集的环境(办公室、展会)里,蓝牙跳频虽然能一定程度避开干扰,但丢包率和重传都会上去,吞吐进一步下降。多设备共享带宽见底PAN本质是点对点连接。一个NAP理论上能接7个PANU(piconet上限),但实际同时活跃2~3个就很吃力了——带宽是共享的,3个设备一起跑就每个只剩几百Kbps。操作系统支持在退化这才是最致命的。iOS不开放蓝牙PAN的PANU角色——iPhone只能做NAP给别人共享网络,不能作为PANU通过别人的蓝牙上网(不是硬件不支持,是Apple没开放这个接口)。Android虽然支持,但从Android 10开始不少厂商的ROM把蓝牙共享优先级降了,部分机型直接砍掉了PAN选项。Windows倒是一直支持,但配对流程比WiFi麻烦得多。整个行业的趋势:WiFi Direct / WiFi Aware拿走了高带宽场景,BLE拿走了低功耗场景,PAN卡在中间两头不靠。九、PAN能不能跑WebRTC/AI语音?——结合AI玩具场景聊聊最近AI玩具(AI语音对话玩具、故事机、陪伴机器人)特别火,这类产品的核心链路都差不多:Plain Text// AI玩具的典型语音交互链路MIC录音 → 音频上传云端 → ASR语音识别 → LLM大模型推理→ TTS语音合成 → 音频下载播放绝大多数AI玩具走的是WiFi联网。但WiFi有个痛点:配网体验差。让小朋友或者不懂技术的家长在一个没屏幕的玩具上输WiFi密码,是很头疼的事情。蓝牙配对就简单多了——扫一下、点一下就连上了。所以一个很自然的想法是:能不能用蓝牙PAN代替WiFi,让AI玩具通过蓝牙借手机的网络上网?省掉WiFi模块和配网流程,成本也能降一点。答案是:看场景,大部分情况不行。WebRTC实时语音通话:不行现在不少AI语音方案(比如百度BRTC、火山引擎RTC)走的是WebRTC协议,音频通过RTP实时双向传输。WebRTC对网络的要求:指标WebRTC要求蓝牙PAN能提供带宽(纯语音)40~80 Kbps双向✅ 够带宽(语音+视频)1~5 Mbps双向❌ 远不够单向延迟< 150ms❌ 蓝牙PAN自身就100~200ms端到端延迟< 300ms❌ PAN 100~200ms + 互联网 50~100ms > 300ms抖动越小越好❌ 100~400ms不可控带宽跑纯语音是够的,但延迟是硬伤。蓝牙PAN自身就吃掉100~200ms,加上STUN/TURN服务器中转和对方网络延迟,端到端很容易超过300ms。超过300ms的语音通话体验就很差了——说话跟对讲机似的,得等对方说完才能接话。而且WebRTC的拥塞控制算法(GCC)会根据延迟和丢包动态调码率。蓝牙PAN的高抖动理论上会让GCC反复误判网络状况,码率忽高忽低,音频断断续续(这是合理推测,目前没找到有人在蓝牙PAN上实测WebRTC的报告)。HTTP/WebSocket方式的AI语音:勉强能用不是所有AI语音方案都走WebRTC。有些方案是这样的:Plain Text// HTTP/WS方式的AI语音交互录完一段话 → HTTP POST音频到云端 → 等云端返回TTS音频 → 播放// 或者录音 → WebSocket实时流上传 → 云端流式返回TTS → 边收边播这种方式对延迟没那么敏感——用户说完一句话,等个1~2秒出结果是可以接受的(像ChatGPT那样)。蓝牙PAN跑这种场景:带宽:一段5秒的Opus音频也就几十KB,上传没压力延迟:多了100~200ms的蓝牙传输延迟,用户感知不明显(本来就要等LLM推理)TTS下载:流式TTS边生成边播,每个chunk几KB,700Kbps也够用所以非实时的AI语音交互,蓝牙PAN理论上是可以跑的。但有个前提:手机得一直开着蓝牙共享,而且不能离太远(蓝牙有效距离10米左右)。AI玩具到底该怎么选方案带宽延迟配网体验成本适合WiFi直连几十Mbps低差(要输密码)要WiFi模块WebRTC实时对话、视频BLE配网+WiFi几十Mbps低好(BLE引导配网)要WiFi+BLE主流AI玩具方案蓝牙PAN~1Mbps高好(蓝牙配对)省WiFi模块非实时HTTP/MQTT4G模组几Mbps中免配网(插卡即用)模组+流量费户外/无WiFi场景现在主流AI玩具的做法是BLE配网+WiFi通信——用BLE把WiFi密码传给设备,设备再连WiFi跑业务。这样既解决了配网体验问题,又有WiFi的带宽和低延迟。蓝牙PAN只在一个很窄的场景下有价值:设备没有WiFi模块,只有经典蓝牙,而且业务不需要实时音视频。比如一个只跑MQTT上报传感器数据的小设备,或者一个只做HTTP请求查天气/查百科的简单终端。但说实话,现在WiFi+BT combo芯片(比如杰理AC791N、乐鑫ESP32)已经很便宜了,没什么理由非要省掉WiFi走PAN。PAN更多是一个备用通道或者技术好奇心的存在。总的来说PAN是个完整的技术方案,协议栈设计得很工整——BNEP做桥、SDP做发现、lwIP做TCP/IP、DHCP做地址分配,每一层该有的都有。但带宽低、延迟高、抖动大、OS支持退化这四个硬伤决定了它在实际产品里用不起来。能跑的场景:HTTP请求、MQTT消息、REST API、小文件传输——"不着急"的IP通信。跑不了的场景:WebRTC音视频、实时语音对话、流媒体——任何对延迟敏感的东西。对AI玩具来说,WiFi仍然是不可替代的。PAN最多做个WiFi挂了之后的降级备份,或者在纯蓝牙芯片上做个轻量联网的备选——但这种场景在WiFi combo芯片白菜价的今天,越来越少了。
2026.05.12了解详情
杰理AC6951C双蓝牙方案——两颗芯片拼出"A蓝牙+B蓝牙+外音"三合一音频系统
杰理AC6951C双蓝牙方案——两颗芯片拼出"A蓝牙+B蓝牙+外音"三合一音频系统最近做了一个双蓝牙音箱项目:一台音箱能同时接两部手机的蓝牙音乐,还能接外部LINE_IN音源。单颗AC695x只有一路A2DP,不够用,于是用了两颗AC6951C,A芯片管一路蓝牙,B芯片管另一路蓝牙+外音+IIS输出,两颗芯片之间用UART传状态、模拟开关切音频。这篇文章重点讲两件事:硬件上两颗芯片怎么连的,以及软件状态机怎么决定谁出声。一、为啥要搞这个组合先过一下AC6951C的底子:32-bit RISC CPU @ 120MHz(杰理自研pi32指令集)蓝牙5.0,BLE + EDR,A2DP / AVRCP / HFP / SPP128KB SRAM,外挂 SPI NOR Flash(最大16MB)24-bit Audio DAC,差分/单端输出,SNR > 95dBLADC 2通道,支持 LINE_IN 模拟采样I2S/IIS Master/Slave,最高 48kHz 立体声3×UART,10-bit SAR ADC(ADKEY),内置充电管理封装 QFN32 / QFN48这颗芯片天生就是做蓝牙音箱的料——内置完整蓝牙协议栈,A2DP解码后直接DAC推功放,EQ/DRC/虚拟低音全片内跑,SDK成熟开箱即用。但它单颗只支持一路A2DP,不能同时连两部手机播歌。客户要三路音源自由切换:手机A播歌、手机B播歌、外部LINE_IN。所以方案就是两颗AC6951C各跑一套蓝牙协议栈,硬件上用模拟开关合到一条音频通路:A芯片:负责一路蓝牙,DAC模拟输出B芯片:负责另一路蓝牙 + LINEIN采集 + DAC/IIS双输出两者之间:UART传蓝牙状态,模拟开关切音频来源二、硬件怎么接的┌───────────────────────┐UART1(AT指令)┌───────────────────────┐│ A 芯片 │ ───────────────────────────── → │ B 芯片 ││(AC6951C A_SDK) ││(AC6951C B_SDK) │││DAC模拟输出│││手机A ── BT A2DP ──→ DAC ──→──┐ │││││ │手机B ── BT A2DP│└───────────────────────┘│ │││┌──────────┐ ││└──→ │ 模拟开关│ ──→ LADC ──→ DAC ──→ 喇叭│ 外部LINE_IN ────────→ │(io控制) │ │ ↓ │└──────────┘ │IIS ──→ 外部DAC │└───────────────────────┘A芯片的DAC模拟输出和外部LINE_IN都接到一个模拟开关上,B芯片用PA9控制选通哪路,选通后经LADC采样,走DAC和IIS双路输出。B芯片还用了几组GPIO做硬件控制:PB11控制功放静音(切换音源时先mute再unmute避免pop声),PC2/PC1/PC0三根线组合控制功放前端的音频路由(000=B蓝牙→喇叭,100=A蓝牙→喇叭,010=外音→喇叭),PA12做密码复位检测(长按10秒低电平恢复默认PIN码)。IIS方面,B芯片配置成DAC+IIS双输出,IIS跑Master模式、48kHz,同时给外部DAC芯片送数字音频。三条音频通路一目了然:A蓝牙播放:手机A → A芯片BT解码 → A DAC → 模拟开关(PA9=LOW) → B LADC → B DAC+IIS → 喇叭B蓝牙播放:手机B → B芯片BT解码 → B DAC+IIS → 喇叭外部LINE_IN:外部音源 → 模拟开关(PA9=HIGH) → B LADC → B DAC+IIS → 喇叭三、软件状态机怎么跑的A芯片很简单,就干两件事:正常当蓝牙音箱(连手机、收A2DP、DAC输出),以及把自己的蓝牙状态通过UART1用AT指令告诉B(AT+STATUS=CONNECTED/DISCONNECTED/PLAY/PAUSE)。A上电后主动把音量拉满,不依赖手机端音量同步。B芯片是整个系统的"大脑"。它在UART1中断里收A的状态,通过系统事件投递到任务上下文解析,然后所有决策都围绕四个状态变量展开:A是否连接、A是否在播、B是否连接、B是否在播。决策规则很直观:模拟开关(PA9):A或B任一在播,或A/B同时连接 → PA9拉低选通A的DAC;否则PA9拉高选通外部LINE_IN。切换时先静音等30ms再恢复,防pop声。功放路由(PA-SHDN):A在播→A蓝牙出声,B在播→B蓝牙出声,都没播→外音出声。任务切换:B在播歌就跑BT任务,B没播歌就切回LINEIN任务。另外还做了两层保护:暂停10秒超时——A或B暂停后不立即判定停止,等10秒没恢复才切走,避免切歌时音源乱跳;后台A2DP回切拦截——B刚切走LINEIN的短窗口内,蓝牙底层试图自动抢回BT任务会被拦住。四、踩过的坑LINEIN播久了有规律POPO声:播几分钟后出现pop声,串口伴随 W 字符。查下来是LADC环形缓冲区溢出丢数据——LADC采样时钟和DAC回放时钟有微小频差,SDK原有的采样率补偿力度不够,加上双蓝牙后台+UART+IIS的额外CPU负载,缓冲区水位逐渐漂移直到溢出(对应杰理官方问题 154)。修法是把采样率补偿从单级改成三级(水位越偏离中心补偿越猛),同时把缓冲区从 *6(约17ms)加大到 *24(约70ms)。蓝牙音量同步干扰:有些手机默认不开音量同步,上一个用户关了音量,下一个连上就没声音。修法是A和B都关掉音量同步(BT_SUPPORT_MUSIC_VOL_SYNC = 0),各自上电主动设最大音量。一句话总结:两颗AC6951C,A只管连手机播歌并把状态告诉B;B是中枢,根据四个状态变量决定模拟开关接谁、功放路由怎么走、当前任务跑BT还是LINEIN。
2026.04.24了解详情
杰理AC791 AI对话 TTS音频下行链路分析
LLM 文本 → TTS 语音 → OPUS 编解码 → 播放:完整技术链路1. 文档目标系统讲清楚以下链路涉及的业务流程、技术原理、背景知识以及每个参数为什么这样选:LLM 文本 → TTS 语音合成 → OPUS 编码 → 网络传输 → OPUS 解码 → 扬声器播放本文不涉及具体代码实现,只讲业务流、技术原理和参数设计。2. 整体业务流全景2.1 完整链路图flowchart TD%% 云端服务模块subgraph 云端服务A["用户语音 → STT 识别为文本"]B["LLM 大语言模型生成回复文本"]C["TTS 文本转语音引擎"]D{"TTS 输出格式"}D1["原始 PCM"]D2["MP3 / WAV"]D3["解码为 PCM"]E["PCM 重采样 + 转单声道 + 按帧切分"]F["OPUS 编码器压缩"]end%% 网络传输模块subgraph 网络传输G["JSON 控制消息:字幕/状态"]H["二进制 OPUS 音频包"]end%% 设备端模块subgraph 设备端I["接收 JSON → 更新字幕和设备状态"]J["接收 OPUS 包 → 放入解码队列"]K["OPUS 解码 → 得到 PCM"]L{"设备采样率 = 服务端采样率?"}M["重采样适配"]N["直接使用"]O["PCM 送入播放队列"]P["音频硬件驱动输出"]Q["扬声器发声"]end%% 云端服务流程连线A --> B B --> C C --> DD -->|直接输出 PCM| D1 --> ED -->|输出 MP3/WAV| D2 --> D3 --> EE --> FF --> HB -.文本字幕.-> G%% 网络传输 → 设备端流程连线G --> IH --> J J --> K K --> LL -->|不一致| M --> OL -->|一致| N --> OO --> P P --> Q2.2 核心要点LLM 只负责生成文本,不产出任何音频TTS 引擎在云端运行,把文本合成为语音OPUS 编码在云端完成,设备端只做解码和播放设备同时收到两路数据:JSON 文本消息:用于屏幕字幕显示、控制设备状态机OPUS 二进制音频包:用于扬声器真正发声这两路是并行的,不是串行的3. 每个环节的技术原理3.1 LLM 大语言模型输入:用户的语音识别结果(文本)输出:自然语言回复(纯文本字符串),例如 "今天天气晴,最高温度 26 度。"关键特性:LLM 是流式输出的,一边生成 token 一边往下游推送TTS 不需要等整句话生成完才开始合成流式能力直接决定了整条链路的首包延迟(用户说完话到听到回复的等待时间)3.2 TTS 文本转语音做什么把 LLM 输出的文本转换成人声语音波形。常见 TTS 输出格式对比格式本质特点PCM未压缩原始采样体积大,无需解码,零延迟WAVPCM + 文件头本质还是 PCM,多了 44 字节头MP3有损压缩广泛兼容,但编码延迟大(~100ms+)OGG/Opus低延迟有损压缩专为实时通信设计AAC有损压缩苹果生态常用,需要授权费为什么不直接把 TTS 原始输出发给设备PCM/WAV 太大:16kHz/16bit/mono PCM,每秒 32KB,10 秒对话 320KB,对嵌入式设备和窄带网络负担沉重MP3 延迟高:MP3 编码器帧延迟较大,不适合逐句流式场景需要统一格式:设备上行(麦克风)和下行(语音回复)需要统一协议,避免维护两套编解码器结论:无论 TTS 引擎输出什么格式,都在云端统一转换为 OPUS 再发给设备。3.3 OPUS 编解码器背景OPUS 是由 IETF 标准化的开放音频编码格式(RFC 6716),专为实时互联网通信设计。WebRTC、Discord、Zoom、微信语音通话等产品的底层都使用 OPUS。OPUS vs 其他编码对比特性OPUSMP3AACPCM算法延迟2.5~60ms 可调~100ms+~20ms+0ms码率范围6~510 kbps32~320 kbps8~256 kbps固定语音专项优化✅ SILK 内核❌❌不适用帧长灵活性2.5~120ms 多档固定 26ms固定 ~21ms任意丢包容忍内置 FEC + PLC差差极差流式友好天生流式需缓冲需缓冲天生流式嵌入式适用复杂度可调(0~10)解码较重解码较重无需解码开源免版税✅专利已到期❌ 需授权不适用OPUS 的内部结构OPUS 是两个编码器的混合体:┌──────────────────────────────────────┐│OPUS 编码器││││┌──────────┐┌───────────────┐│││ SILK││ CELT││││(语音) ││(音乐/通用)│││└──────────┘└───────────────┘││ ↑↑││窄带/宽带语音 全频带音频││(人声为主)(音乐/混合)│└──────────────────────────────────────┘SILK 内核:源自 Skype,针对人声优化,低码率下语音清晰度极佳CELT 内核:源自 Xiph.org,针对全频带通用音频OPUS 会根据输入内容自动切换或混合两个内核为什么 OPUS 特别适合 AI 语音助手低延迟:对话要求自然流畅,OPUS 算法延迟远低于 MP3低码率音质好:16kbps OPUS 语音质量 ≈ 64kbps MP3帧长可调:可根据嵌入式设备处理能力选择合适帧长复杂度可调:嵌入式 MCU 可以把复杂度设到最低,省 CPU天然流式:收到一帧解码一帧,不需要缓冲整段音频上下行统一:麦克风上传和语音下发共用同一套编解码器3.4 网络传输并行双通道设备和服务端之间同时维持两个逻辑通道:通道内容作用控制通道JSON 文本消息状态切换、字幕显示音频通道OPUS 二进制包真正的语音数据两种传输方案方案 A:WebSocket 全双工设备 ◄══════ WebSocket ══════► 服务端文本帧 (JSON)◄─────►二进制帧 (OPUS) ◄─────►一条连接承载 JSON + 音频基于 TCP,可靠传输实现简单,延迟略高方案 B:MQTT 控制 + UDP 音频设备 ◄── MQTT/TLS ──► 服务端(JSON 控制,可靠)设备 ◄── UDP/AES ───► 服务端(OPUS 音频,低延迟)控制走 MQTT/TLS:可靠、有 QoS音频走 UDP + AES-128-CTR 加密:更低延迟需要自行处理丢包和序列号校验比 WebSocket 方案延迟更低,但实现更复杂握手协商设备连接时发送 hello 消息声明音频参数:{"type": "hello","audio_params": {"format": "opus","sample_rate": 16000,"channels": 1,"frame_duration": 60}}服务端回复确认最终参数(可能微调采样率等),双方协商一致后音频通道正式开启。一次完整的 TTS 消息时序时间 ─────────────────────────────────────────────────────► ① {"type":"tts", "state":"start"}设备进入"播放"状态 ② {"type":"tts", "state":"sentence_start", 屏幕显示字幕 "text":"今天天气晴"} ③ [OPUS包][OPUS包][OPUS包]... 扬声器播放这句话 ④ {"type":"tts", "state":"sentence_start", 下一句字幕 "text":"最高温度 26 度"} ⑤ [OPUS包][OPUS包][OPUS包]... 播放下一句 ⑥ {"type":"tts", "state":"stop"} 设备回到"空闲/聆听"文本消息和音频包是交替并行发送的,不是先发完所有文本再发音频。3.5 设备端解码与播放解码流程OPUS 音频包到达 │ ▼检查采样率和帧长 → 必要时重新配置解码器 │ ▼OPUS 解码 → 得到 PCM(16bit 有符号整数) │ ▼采样率匹配检查 │ ├── 不一致 → 重采样适配硬件 └── 一致 → 直接使用 │ ▼送入播放队列 │ ▼音频硬件驱动(I2S / Codec 芯片) │ ▼扬声器发声为什么需要重采样服务端 TTS 可能输出 24kHz(高质量 TTS 常见)设备硬件 Codec 可能只支持 16kHz 或 48kHz重采样器作为兜底,让协议层保持灵活,不强绑固定采样率三任务并行模型设备端音频系统拆分为三个独立实时任务:┌───────────────┐ ┌────────────────┐ ┌───────────────┐│音频输入任务 │ │ 编解码任务│ │音频输出任务 ││(最高优先级) │ │(中等优先级) │ │(较高优先级) ││ │ ││ │ ││麦克风采集│ │上行:│ │从播放队列取 ││唤醒词检测│ │ PCM → OPUS │ │PCM 数据││语音前端处理 │ │下行:│ │写入音频硬件 ││ │ │ OPUS → PCM │ │驱动扬声器│└───────┬───────┘ └────────┬───────┘ └───────┬───────┘│││▼▼▼编码队列 ──►解码/发送队列 ◄── 播放队列为什么这样拆分:音频输入 优先级最高:麦克风必须实时采集,否则丢帧编解码 单独任务:CPU 密集操作,不能阻塞 I/O音频输出 独立运行:保证播放连续平滑,不被解码或网络抖动打断为什么队列里存 OPUS 包而不是 PCM同一个 60ms 音频帧的大小对比:格式大小PCM(16kHz/16bit/mono)960 采样 × 2 字节 =1920 字节OPUS 压缩后通常40~120 字节OPUS 包体积约为 PCM 的 1/15 ~ 1/50。在只有几百 KB 可用 RAM 的 MCU 上:用更少内存缓冲更长时间的音频减少内存分配压力降低队列阻塞风险3.6 本地提示音:另一条辅助链路除了在线语音回复,设备还有本地提示音(如开机音、错误提示音)。内置 OGG 音频资源(封装的也是 Opus) │ ▼OGG 解封装 → 提取出 Opus 帧 │ ▼送入同一条解码队列 │ ▼复用完全相同的 OPUS 解码 → 播放链设计好处:在线语音和本地提示音共用同一套播放基础设施,不需要额外维护 MP3/WAV 解码器。4. 参数选择详解4.1 参数总览参数值类别编码格式OPUS传输格式采样率16000 Hz音频质量通道数1(单声道)音频质量帧长60 ms延迟与效率码率自动(Auto)压缩策略复杂度0(最低)CPU 负载前向纠错 FEC关闭抗丢包不连续传输 DTX开启省电省带宽可变码率 VBR开启压缩效率队列缓冲上限~2400 ms流畅度4.2 采样率:16000 Hz背景采样率决定可表达的最高频率(奈奎斯特定理:最高频率 = 采样率 / 2)。采样率最高频率典型用途8000 Hz4 kHz电话语音(窄带)16000 Hz8 kHz宽带语音、语音助手24000 Hz12 kHz高质量 TTS48000 Hz24 kHz专业音频/音乐选择原因语音频带完整覆盖:人声基频 85~255 Hz,共振峰 300~3400 Hz,辅音能量到 ~8 kHz。16kHz 完整覆盖语音模型标准:绝大多数 STT / TTS 模型以 16kHz 为标准资源与质量甜点:比 8kHz 明显更清晰;比 24kHz/48kHz 节省一半以上带宽和处理量嵌入式友好:ESP32 级 MCU 处理 16kHz 游刃有余,48kHz 就需要更大缓冲和更多 CPU每秒数据量对比采样率每秒 PCM(16bit mono)每分钟8 kHz16 KB960 KB16 kHz32 KB1.9 MB24 kHz48 KB2.8 MB48 kHz96 KB5.6 MB4.3 通道数:1(单声道)语音助手不需要空间定位:不像音乐需要立体声数据量减半:单声道 = 双声道的 50%编解码负载减半嵌入式设备通常单扬声器队列内存减半:对 RAM 紧张的 MCU 至关重要4.4 帧长:60ms背景OPUS 支持的帧长:2.5 / 5 / 10 / 20 / 40 / 60 / 80 / 100 / 120 ms帧长决定了每次编解码处理的音频时长、每个网络包的大小、端到端延迟的下限。帧长的权衡短帧 (5~20ms)长帧 (60~120ms) ◄──────────────────────────────────────────────► 低延迟高延迟 高包率(网络开销大)低包率(网络开销小) 低压缩效率 高压缩效率 高CPU中断频率 低CPU中断频率 适合实时通话适合嵌入式/低功耗选择 60ms 的原因维度60ms 的表现压缩效率帧内统计冗余多,压缩率比 20ms 更高网络包率每秒 ~17 个包(vs 20ms 的 50 个包),减少包头开销CPU 调度编解码每秒触发 ~17 次(vs 50 次),更省 CPU队列管理同样缓冲区存放更长时间音频延迟60ms 帧延迟 + 网络 + 处理 ≈ 150~300ms,对话场景可接受帧长对帧大小的影响60ms,16kHz,mono:PCM 帧:16000 × 0.06 = 960 采样 × 2 字节 = 1920 字节OPUS 帧:压缩后约 40~120 字节队列缓冲设计项目将队列缓冲上限设为 ~2400ms,即约 2400 / 60 = 40 个 OPUS 包。这个设计保证:有足够缓冲应对网络抖动不会因为缓冲过多导致延迟过大内存占用可控(40 个 OPUS 包 ≈ 40 × 100 = 4KB,远小于 40 帧 PCM 的 77KB)4.5 码率:自动(Auto)什么是自动码率让 OPUS 编码器根据当前帧的音频内容自动决定分配多少比特:安静段 → 低码率复杂语音段 → 高码率过渡段 → 中等码率为什么不手动指定固定码率固定码率无法适应语音内容的动态变化指定过高浪费带宽,指定过低损害音质自动模式是 OPUS 官方推荐的语音场景默认值对于语音助手这种内容不可预测的场景,自动模式最稳妥4.6 复杂度:0(最低)背景OPUS 的 complexity 参数范围 0~10,控制编码器搜索最优压缩方案的努力程度:复杂度CPU 占用压缩效率适用场景0最低稍低嵌入式设备5中等较好普通手机/PC10最高最优服务器端/离线处理选择 0 的原因实时优先:MCU 必须在一帧时长(60ms)内完成编码,复杂度高可能超时低功耗:智能硬件长期在线运行,省 CPU = 省电可接受的质量差异:complexity 0 vs 10 在语音场景下的主观音质差异很小(OPUS 本身在语音上已经很好)只影响编码端:设备端上行编码用 0,服务端下行编码可以用更高值4.7 前向纠错 FEC:关闭什么是 FECForward Error Correction,编码时在当前包中嵌入前一包的冗余信息。如果前一包丢失,解码器可以从当前包中恢复它。开启 FEC 的代价码率增加约 50%编码计算量增加引入额外延迟为什么关闭WebSocket 方案基于 TCP:TCP 本身保证可靠传输,不会丢包UDP 方案更依赖轻量低延迟:靠序列号检测丢包,接受偶尔丢帧,而不是增加冗余开销语音对话场景容忍偶尔丢帧:人耳对短暂(60ms)的语音缺失不太敏感,OPUS 解码器内置 PLC(包丢失隐藏)可以平滑过渡4.8 不连续传输 DTX:开启什么是 DTXDiscontinuous Transmission,当检测到输入是静音或背景噪声时,编码器停止发送或只发极小的静音描述包。开启的好处省带宽:对话中大量时间是"无人说话"的静默段省电:不编码不发送 = CPU 空闲 + 无线模块休眠减轻服务端压力:无效音频不传输长期在线设备尤其受益:语音助手 90% 以上的时间处于待命/静音状态注意事项DTX 只影响上行(麦克风 → 服务端),下行 TTS 语音通常无大段静默恢复说话时有极短的首帧延迟(通常 <20ms),对对话体验无感知影响4.9 可变码率 VBR:开启什么是 VBRVariable Bit Rate,每一帧根据内容复杂度动态分配不同的码率。与 CBR(Constant Bit Rate,固定码率)对比:模式特点VBR安静段省码率,复杂段多给码率,整体更高效CBR每帧码率固定,简单可预测,但压缩效率较低选择 VBR 的原因语音的动态范围大:元音能量高,辅音能量低,静默无能量VBR 能把码率花在刀刃上OPUS 官方推荐语音场景使用 VBR配合 DTX,静默段几乎零码率4.10 解码端重采样为什么存在服务端 TTS 输出的采样率和设备硬件 Codec 的工作采样率不一定相同。例如:服务端 TTS 输出 24kHz(高质量 TTS 常见)设备端 Codec 芯片运行在 16kHz工作方式设备在握手阶段收到服务端确认的采样率如果与本机硬件采样率不一致,启动重采样器重采样在 OPUS 解码之后、写入硬件之前执行设计好处协议层保持灵活,不硬编码某一个采样率板级硬件差异被隔离在最后一环未来服务端升级 TTS(如从 16kHz 升到 24kHz)不需要改设备固件5. 上行链路:为什么也要了解虽然主题是"文本到播放"(下行),但理解上行链路有助于理解参数为什么这样统一设计。上行业务流麦克风采集 → 语音前端处理(降噪/回声消除) → PCM 分帧 → OPUS 编码 → 网络发送 → 服务端 STT上下行参数统一的好处协议统一:上下行用同一种音频格式,简化协议设计编解码器复用:设备只维护一套 OPUS 编解码器队列结构复用:帧长一致,队列管理逻辑通用内存可预测:统一帧大小,内存分配可预算服务端处理简化:收发都是 OPUS,无需多格式适配6. MP3/WAV 与当前架构的关系6.1 当前架构的真实情况在线音频协议声明的格式是 opus,不是 MP3 也不是 WAV设备端只包含 OPUS 编解码器,没有 MP3/WAV 解码器本地提示音也是 OGG(Opus) 封装,不是 MP3/WAV结论:当前项目在线语音回复链路不涉及 MP3/WAV。6.2 如果上游 TTS 输出 MP3/WAV 怎么办推荐做法:在服务端完成转换TTS 输出 MP3/WAV │ ▼ (服务端)解码为 PCM → 重采样到 16kHz/mono → 按 60ms 分帧 → OPUS 编码 │ ▼ (网络)发给设备 → 设备端正常 OPUS 解码 → 播放为什么不在设备端解码 MP3/WAV:因素服务端转换设备端解码CPU 负载服务端资源充裕MCU 负担重内存占用不影响设备需额外解码器内存网络带宽OPUS 传输,带宽最优MP3/WAV 直传,带宽浪费实时性OPUS 天生流式MP3 需要缓冲维护成本设备端无需改动需新增解码器和适配架构一致性保持统一引入异构路径7. 完整参数速查表参数值选择原因格式OPUS低延迟、低码率语音好、流式、免版税、嵌入式友好采样率16kHz语音频带甜点、STT/TTS 标准、嵌入式可承受通道Mono语音不需立体声、省一半资源帧长60ms压缩效率与延迟的折中、嵌入式省 CPU 省内存码率Auto自适应内容、OPUS 推荐默认值复杂度0MCU 实时约束、省电、语音下音质差异小FEC关闭TCP 已可靠、UDP 接受偶尔丢帧、省码率DTX开启静默段省电省带宽、长期在线设备必备VBR开启语音动态范围大、码率花在刀刃上队列缓冲~2400ms抗网络抖动、不过度延迟、内存可控8. 一句话总结云端 LLM 生成文本 → 云端 TTS 合成语音 → 云端 OPUS 编码压缩 → 网络传输(JSON 字幕 + OPUS 音频并行)→ 设备端 OPUS 解码为 PCM → 必要时重采样 → 音频硬件驱动 → 扬声器发声。全链路以 OPUS 16kHz/mono/60ms 为核心,在延迟、码率、音质、嵌入式资源之间取得最优平衡。
2026.04.24了解详情
杰理AC791 AI对话 ASR音频上行链路分析
语音识别(ASR)上行链路:从麦克风到文字的完整技术链路1. 文档目标系统讲清楚 ASR(Automatic Speech Recognition,自动语音识别)上行链路涉及的业务流程、技术原理、背景知识以及每个参数和设计决策的原因:麦克风采集 → 音频前端处理(AEC/NS/VAD) → OPUS 编码 → 网络传输 → 服务端 STT → 识别文字返回设备本文不涉及具体代码实现,只讲业务流、技术原理和参数设计。与本文配套的下行链路文档:tts-opus-playback-pipeline.md2. 整体业务流全景2.1 完整链路图‍flowchart TDsubgraph 设备端A[麦克风硬件采集]B{"硬件采样率 = 16kHz?"}B1["重采样到 16kHz"]B2[直接使用]C[唤醒词检测引擎]D{"检测到唤醒词?"}D1[触发唤醒事件]D2[继续检测]E[音频前端处理 AFE]E1[回声消除 AEC]E2[噪声抑制 NS]E3[语音活动检测 VAD]F[处理后的干净 PCM]G["按 60ms 分帧"]H[OPUS 编码压缩]I[送入发送队列]endsubgraph 网络传输J[WebSocket 二进制帧 或 UDP 加密包]K[JSON 控制消息]endsubgraph 云端服务L[接收 OPUS 音频流]M[OPUS 解码为 PCM]N[STT 语音识别引擎]O[输出识别文字]P[返回 stt JSON 消息给设备]endA --> BB -->|不一致| B1 --> CB -->|一致| B2 --> CC --> DD -->|否| D2 --> CD -->|是| D1D1 -."打开音频通道".-> KD1 -."可选:发送唤醒词音频".-> JC -."切换到聆听模式".-> EE --> E1 --> E2 --> E3E3 --> F --> G --> H --> I --> JK -."listen.start".-> LJ --> L --> M --> N --> O --> PP -."stt.text".-> K2.2 核心要点语音识别在云端完成,设备端不做 STT设备端负责:麦克风采集 → 音频前端处理 → OPUS 编码 → 上传整条上行链路有两个阶段:唤醒阶段:设备端本地运行唤醒词检测模型,不联网聆听阶段:音频经前端处理后编码上传,服务端做 STT唤醒词检测和音频前端处理是两个独立模块,可以单独启用/禁用设备同时发送两路数据给服务端:JSON 控制消息:listen.start、listen.stop、listen.detectOPUS 二进制音频包:真正的语音数据3. 每个环节的技术原理3.1 麦克风采集做什么通过 I2S 接口或板级音频 Codec 芯片,从麦克风持续采集原始 PCM 音频数据。采集参数参数典型值说明硬件采样率因板而异(16kHz / 48kHz 等)Codec 芯片的原生采样率目标采样率16000 Hz统一重采样到 16kHz位深16 bit(有符号整数)标准语音处理位深通道数1 或 2单麦/双麦,部分板子还有参考通道每次读取10ms(160 采样点)喂给唤醒词和音频处理器的最小粒度为什么统一重采样到 16kHz唤醒词模型和语音识别模型都以 16kHz 为标准输入OPUS 编码器配置为 16kHz统一采样率简化整条链路的缓冲区管理如果硬件采样率不是 16kHz,在采集后立即做重采样转换通道处理单麦克风:直接使用双通道(单麦 + 参考通道):麦克风通道用于语音采集,参考通道(来自扬声器回采)用于回声消除 AEC双麦克风:用于波束成形等高级前端处理最终喂给唤醒词和编码器的都是单声道 16kHz PCM3.2 唤醒词检测背景语音助手需要一个低功耗的本地触发机制。用户说出唤醒词(如"小智同学"),设备才开始联网进行语音识别。为什么在本地做唤醒词检测隐私:不联网就不上传任何音频低延迟:本地检测比云端快几百毫秒省电:不需要一直维持网络连接省带宽:只有唤醒后才开始上传三种唤醒词引擎本项目支持三种唤醒词检测方案,适配不同硬件能力:引擎适用芯片特点AFE 唤醒词ESP32-S3 / ESP32-P4集成音频前端(AEC + NS),唤醒检测与音频前端处理合一自定义唤醒词ESP32-S3 / ESP32-P4基于 MultiNet 命令词模型,支持自定义唤醒词和命令词轻量唤醒词ESP32 等较弱芯片仅做唤醒词检测,无音频前端处理,资源占用最小唤醒词检测流程麦克风 PCM(10ms 一帧) │ ▼送入唤醒词引擎缓冲区 │ ▼累积到引擎所需的块大小(如 30ms / 512 采样) │ ▼执行检测推理 │ ├── 未检测到 → 继续累积下一帧 │ └── 检测到唤醒词!│├── 回调通知应用层├── 可选:编码最近 ~2 秒的音频为 OPUS 发给服务端└── 应用层开始建立连接 + 切换到聆听模式唤醒词音频回传(可选)检测到唤醒词时,引擎会保留最近约 2 秒的音频数据。这些数据可以被编码为 OPUS 后发给服务端,好处是:服务端可以做说话人识别(是谁在叫唤醒词)服务端可以做唤醒词确认(减少误唤醒)服务端可以直接用于 STT(用户可能唤醒词后紧跟着说了指令)3.3 音频前端处理(AFE)背景真实环境中的麦克风采集到的不是干净的人声,而是混合了:回声:扬声器播放的声音被麦克风重新采集环境噪声:风扇、空调、电视等背景声混响:房间墙壁反射的声音这些干扰会严重影响语音识别准确率。音频前端处理(Audio Front-End, AFE)的目标是在发送给 STT 之前清理音频。三大核心模块┌──────────────────────────────────────────────────┐│ 音频前端处理 (AFE)││││┌────────────┐┌────────────┐┌─────────────┐ │││AEC ││NS││VAD│ │││回声消除 │→│噪声抑制 │→│语音活动检测 │ ││└────────────┘└────────────┘└─────────────┘ ││ ↑ ││参考信号(扬声器回采)│└──────────────────────────────────────────────────┘3.3.1 AEC 回声消除问题:当设备一边播放 AI 回复一边监听用户语音时(实时对话模式),扬声器声音会被麦克风采到,服务端 STT 会把 AI 自己说的话也识别出来。原理:AEC 利用扬声器的输出信号作为"参考",从麦克风信号中估计并减去回声成分,只保留用户的真实语音。两种 AEC 方案:方案执行位置工作方式优点缺点设备端 AEC设备端 AFE用扬声器参考通道做本地回声消除延迟最低,不依赖网络需要硬件支持参考通道,对板级设计要求高服务端 AEC云端设备上传音频时带 timestamp,服务端用回放时间戳对齐做消除对硬件无要求依赖网络延迟稳定,效果不如设备端不使用 AEC 时:设备在播放 AI 回复时不监听麦克风播放结束后才切回聆听模式对话节奏是严格的"一问一答"轮替,不能打断3.3.2 NS 噪声抑制问题:环境噪声降低 STT 识别准确率。两种方案:方案说明传统 NS基于频谱减法等信号处理算法,轻量快速神经网络 NS基于 NSNet 等深度学习模型,效果更好但 CPU 开销更大项目在 AFE 中优先使用神经网络 NS(如果模型存在),否则关闭 NS。3.3.3 VAD 语音活动检测做什么:判断当前音频帧是"有人在说话"还是"静默/噪声"。输出:二值状态 —— SPEECH(说话中)或 SILENCE(静默)。作用:自动停止模式下:VAD 检测到持续静默后,设备自动结束聆听,触发服务端 STT 处理LED 反馈:聆听时根据 VAD 状态变化更新 LED 指示灯服务端辅助:服务端也可以用 VAD 信息决定何时开始/结束识别AFE 的两种运行场景场景AFE 类型用途唤醒检测阶段AFE_TYPE_SR(语音识别型)集成唤醒词检测,需要运行 WakeNet 模型聆听/通信阶段AFE_TYPE_VC(语音通信型)专注于 AEC + NS + VAD,输出干净音频给编码器两个阶段使用不同的 AFE 实例,因为:唤醒阶段需要跑唤醒词模型,聆听阶段不需要聆听阶段需要输出固定帧长的 PCM(60ms),唤醒阶段的帧长由模型决定分开管理可以独立启停,不互相干扰无 AFE 的回退方案如果设备不支持 AFE(资源不足),系统退化为直通模式:麦克风 PCM 直接分帧,不做任何前端处理不支持 AEC(不能在播放时同时听)不支持 VAD(不能自动停止)不支持 NS(噪声直接上传)只做立体声转单声道和帧长对齐3.4 OPUS 编码做什么将 AFE 处理后的干净 PCM 音频帧压缩为 OPUS 格式,减小网络传输数据量。编码流程AFE 输出干净 PCM(60ms = 960 采样点) │ ▼送入编码队列(最多 2 个任务排队) │ ▼编解码任务取出 PCM 帧 │ ▼OPUS 编码器压缩 │ ▼OPUS 包(约 40~120 字节)送入发送队列 │ ▼主循环触发 → 协议层发送编码参数(与下行完全一致)参数值原因采样率16kHz语音频带甜点,STT 模型标准通道Mono语音不需立体声帧长60ms压缩效率与嵌入式资源的折中码率Auto自适应内容复杂度复杂度0MCU 实时约束,省 CPU 省电FEC关闭WebSocket/TCP 已可靠,UDP 接受偶尔丢帧DTX开启静默段不发数据,省带宽省电VBR开启语音动态范围大,码率花在刀刃上上下行参数完全统一的好处:编解码器复用、队列结构复用、协议一致、内存可预测。详细参数解释见 tts-opus-playback-pipeline.md 第 4 章。为什么编码队列最多只排 2 个任务编码是 CPU 密集操作,如果排队太多说明编码速度跟不上采集速度限制为 2 个可以背压到音频处理器,避免内存无限增长同时保证网络抖动时有 1 帧的缓冲余量3.5 时间戳与服务端 AEC背景在服务端 AEC 方案中,服务端需要知道每个上行音频帧对应的设备端时刻,才能与同一时刻下发的回放音频做对齐消除。工作方式设备端:收到服务端下行音频包 → 记录 timestamp → 放入时间戳队列编码上行音频时 → 从时间戳队列取出 timestamp → 附加到 OPUS 包服务端:收到上行音频包的 timestamp查找同一 timestamp 的下行音频执行回声消除这样服务端就能知道"设备在播放哪段音频的同时录到了这段麦克风信号",从而正确消除回声。3.6 网络发送发送队列编码后的 OPUS 包进入发送队列,队列上限约 2400ms(约 40 个 60ms 的包)。主循环检测到队列有数据后,逐包通过协议层发送。两种传输方案WebSocket 方案:OPUS 包 → 加上协议头(版本/类型/时间戳/大小)→ WebSocket 二进制帧发送V1:裸 OPUS payloadV2:带 timestamp 和 payload_size 头(用于服务端 AEC)V3:轻量头,只带 type 和 payload_sizeMQTT + UDP 方案:OPUS 包 → AES-128-CTR 加密 → 加上 UDP 包头(类型/标志/SSRC/时间戳/序列号)→ UDP 发送控制消息走 MQTT/TLS音频数据走 UDP,更低延迟加密防窃听,序列号防重放3.7 服务端 STT 语音识别做什么服务端接收 OPUS 音频流,解码为 PCM,送入 STT 引擎转换为文字。STT 的工作模式服务端 STT 通常支持流式识别:不需要等整段话说完一边收到音频一边输出中间结果(partial results)用户说完后输出最终结果(final result)识别结果返回服务端将识别出的文字通过 JSON 消息返回设备:{"type": "stt", "text": "今天天气怎么样"}设备收到后在屏幕上显示为用户发言的字幕。4. 聆听模式详解4.1 三种聆听模式设备进入聆听状态时,需要告诉服务端采用哪种聆听模式:模式名称JSON 值触发结束的方式需要 AEC自动停止AutoStop"auto"VAD 检测到持续静默后自动结束❌手动停止ManualStop"manual"用户按键/再次唤醒才结束❌实时对话Realtime"realtime"不主动结束,持续双向通信✅4.2 模式选择逻辑AEC 已开启?│├── 是 → 默认使用 Realtime 模式(全双工对话)│└── 否 → 默认使用 AutoStop 模式(半双工轮替)AutoStop 模式(最常用)用户说话 ──► VAD=SPEECH ──► 用户停顿 ──► VAD=SILENCE 持续一段时间│▼设备自动结束聆听服务端输出最终识别结果进入 LLM 处理 → TTS 回复Realtime 模式(需要 AEC)用户说话 ──────────────────────────────────────────►AI 回复◄────────────────────────────────────────── (可以随时打断,双向同时进行)设备在 AI 播放回复的同时仍然监听麦克风AEC 消除掉扬声器回声,只保留用户语音用户可以随时打断 AI4.3 聆听相关的 JSON 消息协议设备 → 服务端消息含义{"type":"listen", "state":"detect", "text":"小智同学"}唤醒词检测到,附带唤醒词文本{"type":"listen", "state":"start", "mode":"auto"}开始聆听,告知模式{"type":"listen", "state":"stop"}停止聆听{"type":"abort", "reason":"wake_word_detected"}打断当前 AI 回复服务端 → 设备消息含义{"type":"stt", "text":"用户说的话"}STT 识别结果,设备显示为用户字幕5. 设备状态机与 ASR 的关系5.1 状态流转图stateDiagram-v2[*] --> 空闲Idle空闲Idle --> 连接中Connecting : 唤醒词检测到连接中Connecting --> 聆听中Listening : 音频通道打开成功聆听中Listening --> 回复中Speaking : 收到 tts.start回复中Speaking --> 聆听中Listening : 收到 tts.stop(非手动模式)回复中Speaking --> 空闲Idle : 收到 tts.stop(手动模式)聆听中Listening --> 空闲Idle : 用户停止聆听回复中Speaking --> 聆听中Listening : 唤醒词打断聆听中Listening --> 聆听中Listening : 唤醒词重新触发5.2 各状态下的音频模块启停设备状态唤醒词检测音频前端处理OPUS 编码上传OPUS 解码播放空闲✅ 运行❌ 停止❌ 停止❌ 停止连接中❌ 停止❌ 停止❌ 停止❌ 停止聆听中视配置✅ 运行✅ 运行❌ 停止回复中视配置视模式视模式✅ 运行聆听中:唤醒词检测默认关闭(避免自己说的话触发唤醒),但如果使用 AFE 唤醒词引擎可以配置为同时运行回复中 + Realtime 模式:音频前端处理和编码上传保持运行(全双工)回复中 + 非 Realtime 模式:音频前端处理和编码上传停止(半双工)6. AEC 回声消除深入6.1 为什么 AEC 是 ASR 链路中最复杂的部分在语音助手场景中,最理想的体验是随时可以打断 AI。但这要求设备在播放 AI 回复的同时监听麦克风,而此时:扬声器声音 >> 用户声音(扬声器就在麦克风旁边)不消除回声的话,STT 会把 AI 自己说的话识别出来甚至会形成"AI 自己触发自己"的死循环AEC 的基本原理 参考信号(扬声器输出)│▼┌──────────────────┐│ 自适应滤波器 │ ← 估计回声路径└──────────────────┘│▼ 估计的回声麦克风信号 ─────⊖──────► 残差信号(≈ 纯用户语音) 减去估计回声用扬声器的输出信号作为参考自适应滤波器学习"扬声器到麦克风"的传递路径用学到的路径预测麦克风会采到的回声从麦克风信号中减去预测的回声残差就是用户的真实语音6.2 设备端 AEC vs 服务端 AEC维度设备端 AEC服务端 AEC执行位置设备端 AFE 模块云端参考信号来源硬件回采(I2S 回环/Codec 回采)服务端下行音频的时间戳对齐延迟极低(本地处理)受网络延迟影响消除效果好(参考信号精确对齐)一般(网络抖动导致对齐误差)硬件要求需要 Codec 支持参考通道输出无特殊要求稳定性成熟标注为不稳定(Unstable)6.3 不使用 AEC 时的对话模式用户说话 → 设备上传 → 服务端 STT → LLM → TTS ↓用户等待 ← 设备播放 AI 回复 ← OPUS 下发 ↓播放结束 ↓设备重新进入聆听 → 用户可以继续说话这是半双工轮替模式:一方说话时另一方必须等待。用户体验不如全双工自然,但实现简单可靠。7. 完整的一次对话时序7.1 从唤醒到收到识别结果时间 ─────────────────────────────────────────────────────────────────────►设备端:① 麦克风持续采集,喂给唤醒词引擎② 用户说:"小智同学"③ 唤醒词引擎检测到 → 触发唤醒事件④ 编码最近 ~2秒 唤醒词音频为 OPUS(可选)⑤ 打开音频通道(WebSocket 连接 / MQTT+UDP 建立)⑥ 发送 hello 握手,协商音频参数⑦ 发送唤醒词 OPUS 数据(可选)⑧ 发送 {"type":"listen", "state":"detect", "text":"小智同学"}⑨ 切换到聆听状态⑩ 启动音频前端处理(AFE)⑪ 发送 {"type":"listen", "state":"start", "mode":"auto"}⑫ 用户说:"今天天气怎么样"⑬ AFE 处理 → OPUS 编码 → 连续发送 OPUS 包⑭ VAD 检测到静默 → 自动结束服务端:⑮ 收到音频流 → OPUS 解码 → STT 识别⑯ 返回 {"type":"stt", "text":"今天天气怎么样"}⑰ 送入 LLM 生成回复⑱ TTS 合成 → OPUS 编码 → 下发(进入下行链路)设备端:⑲ 收到 stt 消息 → 屏幕显示用户字幕⑳ 收到 tts.start → 切换到回复中状态㉑ 收到 OPUS 音频包 → 解码播放(下行链路)7.2 打断场景(Realtime 模式)① AI 正在播放回复(回复中状态)② 用户突然说话③ AEC 消除扬声器回声,提取用户语音④ 唤醒词引擎检测到唤醒词(或 VAD 检测到语音)⑤ 发送 {"type":"abort", "reason":"wake_word_detected"}⑥ 设备停止播放,切换到聆听⑦ 开始新一轮语音上传8. 关键设计决策总结8.1 为什么唤醒词检测和音频前端处理是两个独立模块维度唤醒词检测音频前端处理运行时机空闲时一直运行只在聆听时运行AFE 类型SR(语音识别型,集成 WakeNet)VC(语音通信型,集成 AEC/NS/VAD)输出检测事件(是/否)干净的 PCM 音频帧帧长由模型决定(~30ms)由编码器决定(60ms)CPU 占用适中较高(特别是开启 AEC + NS)分开设计可以:空闲时只跑轻量的唤醒词检测,省电聆听时才启动重量级的 AFE 处理独立启停,互不干扰切换时重置重采样器,避免缓冲区残留8.2 为什么麦克风采集用 10ms 粒度而编码用 60ms 粒度10ms 采集:满足唤醒词模型和 AFE 引擎的喂入需求(它们通常需要更细粒度的输入)60ms 编码:OPUS 编码器的帧长设置,60ms 是压缩效率和嵌入式资源的最佳折中AFE 处理器内部做帧长对齐:累积 AFE 输出的小帧,攒够 60ms(960 采样)后一次性输出给编码器8.3 为什么发送队列上限是 ~2400ms与解码队列对称:上下行队列使用相同的设计应对网络抖动:短暂的网络卡顿不会丢失音频内存可控:40 个 OPUS 包 ≈ 4KB,远小于同等时长的 PCM(77KB)不过度缓冲:超过 2.4 秒说明网络严重卡顿,继续缓冲意义不大8.4 为什么编码队列只允许 2 个任务编码队列存的是 PCM 帧(1920 字节/帧),比 OPUS 包大得多限制为 2 个任务控制内存峰值背压机制:如果编码器处理不过来,会阻塞 AFE 输出,形成流量控制正常情况下编码速度远快于实时(60ms 音频编码只需几毫秒),队列几乎不会积压9. AFE 参数选择详解9.1 音频前端处理(聆听阶段)参数参数值原因AFE 类型VC(语音通信)聆听阶段不需要唤醒词检测,专注于音频清理AEC 模式VOIP_HIGH_PERF为 VoIP 场景优化的高性能回声消除VAD 模式VAD_MODE_0最灵敏的 VAD 设置,不漏掉轻声说话VAD 最小噪声时长100ms低于 100ms 的短暂噪声不触发 VAD 状态变化NS 模式优先使用 NSNet(神经网络),否则关闭神经网络降噪效果远好于传统方法AGC关闭自动增益控制可能引入失真,当前场景不需要内存分配优先使用 PSRAMAFE 模型较大,放在外部 PSRAM 节省内部 SRAM9.2 唤醒词检测(空闲阶段)参数参数值原因AFE 类型SR(语音识别)集成唤醒词检测功能AEC 模式SR_HIGH_PERF为语音识别场景优化优先核心Core 1AFE 任务固定在 Core 1,避免与其他关键任务争抢 Core 0内存分配优先使用 PSRAM模型数据放 PSRAM9.3 重采样器参数参数值原因复杂度2速度优先,嵌入式场景不追求极致音质性能类型SPEED明确告知算法优先速度而非质量位深16bit与整条链路一致10. 参数速查表上行链路核心参数参数值选择原因采集采样率16kHz(重采样后)语音模型标准、OPUS 编码器配置采集位深16bit 有符号整数标准语音处理位深采集通道1~2(输出为 mono)最终编码为单声道采集粒度10ms(160 采样点)满足唤醒词和 AFE 引擎的喂入需求编码帧长60ms(960 采样点)压缩效率与嵌入式资源折中编码格式OPUS低延迟、低码率、流式、上下行统一编码码率Auto自适应内容编码复杂度0MCU 实时约束DTX开启静默段省电省带宽VBR开启语音动态范围大FEC关闭TCP 已可靠 / UDP 接受偶尔丢帧编码队列上限2 个 PCM 帧控制内存,背压流控发送队列上限~2400ms(~40 个 OPUS 包)抗网络抖动,内存可控唤醒词音频保留~2 秒供服务端做说话人识别/唤醒确认11. 一句话总结设备端麦克风采集 16kHz PCM → 本地唤醒词引擎检测触发 → 音频前端处理(AEC 消除回声 + NS 降噪 + VAD 检测语音活动)→ 干净 PCM 按 60ms 分帧 → OPUS 编码压缩 → 网络上传(WebSocket/UDP)→ 云端 OPUS 解码 → STT 语音识别引擎 → 识别文字返回设备显示。全链路以 OPUS 16kHz/mono/60ms 为核心编码参数,与下行链路完全对称统一;唤醒词检测和音频前端处理作为两个独立模块分阶段运行,在低功耗待机和高质量语音上传之间取得平衡。
2026.04.23了解详情
< 12345··· >
联系方式

地址:深圳市龙华区观湖街道观乐路5号多彩科创园B栋801

邮箱:steven@yunthinker.com