用 Wifi 来传输音频数据,会比蓝牙更好。使用蓝牙方式,不管你用什么协议,都会对数据重新编码,说人话就是有损音质,虽然不至于全损。而使用 Wifi 就可以将 PCM 数据直接传输,无需再编码和压缩。在 ESP32 开发板上可以通过 I2S(IIS)向功放芯片发出音频数据。
关于 i2s 的时序,老周就不啰嗦了,这种玩意儿,网上一搜一大把,老周写东西向来不喜欢抄的,所以,时序相关的就省略了。不过,有一点老周要说清楚:i2s 传输的是数字信号,不是模拟信号。这一点一定得记住,千万不要把 i2s 直接连接喇叭,没鸟用的。它要先给功放处理,放大后输出模拟信号,才能连接喇叭。所以说,i2s 是数字芯片之间通信用的。本质来说,也是 IO 接口的电平高低的变化,所以,i2s 不仅可以传输数字音频,还可以驱动 WS2812 彩灯。这种 RGB 彩灯也真是博大包容,几乎啥协议它们都受用。
先简单老周自己做的个人 WiFi 音响,功放芯片用的是 NS4168,对,M5Stack Atom Echo 开发套件用的就是这个芯片,这货虽然体积小巧,但是喇叭配得不怎么行,声音又尖又刺,还伴随严重的谐振,所以不要拿它来播放太嗨的电子舞曲(官方文档也说了,不要长时间播放重低音,嗯,他们还算有点自知之明)。老周用的是 3W/4Ω 扬声器,是从一台某科 DVD 机上拆下来的。前面用过 MaxXXXX 系列的芯片,发现杂音特严重,就跟二战时期的电报音差不多。
至于传输,这个就没限制,就是常规的网络通信。用 TCP、UDP、MTQQ(这个不太适合)都行,老周用的是 HTTP。音频不可能保存在 ESP 的 Flash 上的,不然就不叫 Wi Fi 音响了。在服务器上,老周用 ASP.NET Core 实现,做了三个页面:简单的密码验证(主要防熊孩子)、PCM 音频上传页,以及自定义播放列表页。播放列表是事先定义好,存放在 JSON 文件中。当我按一下连接到 ESP32 的按钮,就会向服务器发出请求,开始播放列表中的歌曲。
ESP 32 上面(客户端)本来计划用 .NET Nano Framework 来搞的,毕竟这个兼得了 .NET 的高效编程方式,同时性能也不太差。但很可惜,老周连试了三块开发板都不行。面向 Esp 32-Pico 的 Nano CLR 固件不带 i2s 本地代码,无法用;刷其他版本的固件无法启动 CLR。另一块 Esp32-S3 因为是高度封装版,没有引出太多的 IO,也干不了。然后,老周翻出尘封多年,当初 78 元买入,现在涨了四倍价格的乐鑫 LyraT 开发板。经测试还是不行。然后,又用某果云定制的 ESP32 板子测试,依然不行。
那玩不下了吗?不,千好万好还是原生 SDK 好,那就用 esp-idf 来弄吧。至于 .NET Nano 的,下次老周买一块 esp32-s3 的核心板再试。
WTF,不知不觉居然讲了那么F话,下面咱们开始。.NET 服务器端很好弄,所以留在后面说,先说 IDF 的。ESP32 最让人喜欢的就是有 Wifi,有蓝牙,还集成各种玩意儿,确实是性价比之王。但,乐鑫自己做的开发板就特别贵,当然做工会比20多元的好。esp32 客户端咱们要完成这几件事:
1、初始化网络接口。不管是用 Wifi-STA,Wifi-AP,或是用带以太网接口的,都要初始化 netif(Net Interface);
2、初始化 Wifi。这里咱们是要连接到路由器,然后访问服务器上的音频。故,很明显,是要选择 STA 模式(Station);
3、初始化 i2s 驱动(5.x 的 idf 是分开发送和接收通道的,发送是播放,接收是录音,比如麦克风);
4、初始化 HTTP 客户端参数;
5、发起 HTTP 请求。
一、初始化 Wifi
Wifi 的初始化过程是这样的:
A、调用 esp_netif_init 函数(esp_netif.h),这是初始化所有网络接口的驱动,并不只是无线网。
B、调用 esp_netif_create_default_wifi_sta 函数(esp_wifi_default.h)。这个函数会用默认的配置初始化 Wifi 驱动,并创建表示网络接口的 esp_netif_t,类型当然是指针的。我们用的是STA模式,所以......,如果是AP模式,可以调用 esp_netif_create_default_wifi_ap 函数。其实,C语言的指针不是你想的那么恐怖,只是很多教程压根没告诉你指针怎么用。因为返回的这个 esp_netif_t 对象,后面在调用其他函数时会用到,也就是说在其他地方要引用这个对象,所以你想想,用什么合适?那当然是指针了。毕竟大伙都知道,指针是保存地址的,正因为这样,才能保存你把它传给其他代码后,它引用的仍然是同一个对象。直接用类型声明的话,你在传递时它会自我复制,这会导致其他代码引用的不是这个对象了,而是复制体。
另外,不要看到指针类型就以为一定是堆上分配内存,看到一般变量声明就说是栈分配内存。指针类型与堆分配并没什么关系,它只是保存某对象的内存地址罢了,如果你代码这样写,那么,指针类型也可以保存栈内存的地址:
int x;
x = 999;
int* px = &x; /* 存入了x的地址,x是栈上分配的 */
堆分配是用 new 关键字,或 malloc 函数,或 calloc 函数分配的,在不需要时可以 delete 或 free。堆上分配的是动态的内存空间,所以得到的肯定是指针类型的值,因为有了指针,就有其地址,就能访问。所以,很多有良好编码习惯的人,都会在 delete / free 之后,把指针类型的变量设置为 NULL:px = NULL。
这啥呢,虽然你把那片内存毙了,但指针变量里还是存着那个地址,此时它指向的是那片被清理了的内存。那里很乱的,所以人们也叫它"脏内存",里面全是些没用的随机字节,污染严重,故很脏。
esp_netif_create_default_wifi_ap 或 esp_netif_create_default_wifi_sta 函数实际上调用了宏------ ESP_NETIF_DEFAULT_WIFI_AP、ESP_NETIF_DEFAULT_WIFI_STA,用默认的值配置后,用 esp_netif_new 函数创建 esp_netif_t;然后调用 esp_netif_attach_wifi_station 或 esp_netif_attach_wifi_ap 函数,把驱动关联到接口。最后用 esp_wifi_set_default_wifi_ap_handlers 或 esp_wifi_set_default_wifi_sta_handlers 注册默认的事件回调用函数。
ESP 的事件由两个值来描述:1、esp_event_base_t 类型的是事件基础值,可以理解为一组事件中的组标识。比如,咱们 Wifi 相关的事件,其 event base 就是 WIFI_EVENT;2、事件 ID,指代具体的事件,比如,属于 WIFI_EVENT 下的事件有:
WIFI_EVENT_STA_START:STA模式已启动;
WIFI_EVENT_AP_START:AP模式已启动;(AP模式,就是 wifi 热点,你可以理解为 esp32 当作路由器来用,其他机器连接到 esp32)
WIFI_EVENT_STA_CONNECTED:esp32 成功连上 Wifi 后发生;
WIFI_EVENT_STA_DISCONNECTED:掉线后发生,此时可以重新连接。
......
C、调用 esp_netif_set_hostname 函数为 esp32 板子设置主机名。这一步是可选的,如果不设置,默认是"espressif";
D、调用 esp_wifi_init 函数初始化 Wifi;
E、调用 esp_wifi_set_config 函数配置 Wifi。如你路由器的 SSID,密码等。它的参数是内联类型------即共享内存的类型。说简单的就是 STA 模式和 AP 模式的配置信息占用相同的内存。
typedef union {
wifi_ap_config_t ap; /**< configuration of AP */
wifi_sta_config_t sta; /**< configuration of STA */
wifi_nan_config_t nan; /**< configuration of NAN */
} wifi_config_t;
当你用的是STA模式,就配置 sta 成员,类型是 wifi_sta_config_t 结构体;同理,用AP模式时只配置 ap 成员就可以了;用 NAN 模式时,只配置 nan 成员。nan 也是个好用的东西,Network Awareness,网络感知。它是端对端联机,就是你不用连接路由器,不用上网,而是网卡之间直接可以连接,esp32 板子之间可以直接通信。
F、一切就绪,调用 esp_wifi_start 启动 Wifi。这时,esp 会自动连接路由器,连接成功后会发生 WIFI_EVENT_STA_CONNECTED 事件。
二、初始化 I2S
A、调用 i2s_new_channel 函数创建 I2S 通道,包括发送(TX)和接收(RX)通道。创建的通道用 i2s_chan_handle_t 表示。如果只用发送(播放音乐,不录音)不用接收,调用函数时,接收通道可以传递 NULL。
B、通道创建后,还无法使用,还要初始化它。因为 I2S 用发送和接收两个方向,有 PDM、STD、TDM 等模式。PDM一般是麦克风用,播放音频需要用 STD(标准模式)。为了方便配置,IDF 也提供了一组宏,可以直接用,只要指定采样率(Hz)即可,其他参数保持默认。如 I2S_STD_CLK_DEFAULT_CONFIG 宏可直接配置标准 I2S。配置参数传给 i2s_channel_init_std_mode 函数进行初始化。
C、调用 i2s_channel_enable 函数启用通道。如果不传输数据了,也可以调用 i2s_channel_disable 函数禁用通道。
D、此时,可以向功放芯片发送数据了。发送数据调用 i2s_channel_write 函数,接收数据调用 i2s_channel_read 函数。
E、不再使用 I2S 时可以调用 i2s_del_channel 函数删除通道,释放驱动。
三、初始化 HTTP 客户端
A、用 esp_http_client_config_t 结构体初始化 HTTP 客户端,如请求的 URL,请求方式(GET、POST 等),随后用 esp_http_client_init 函数初始化,会返回 esp_http_client_handle_t 类型的句柄,它就是个符号,后面调用的 HTTP 有关的函数需要用到它。
B、esp_http_client_open 函数打开连接;
C、esp_http_client_write 函数向服务器发数据。POST 的时候需要,GET 的时候不需要,可以不调用。
D、esp_http_client_fetch_headers 函数获取服务器响应的 HTTP 头。注意,获取的是消息头,不是正文。
E、esp_http_client_read 函数读数据。这时候读的才是 HTTP 正文(Body)。
F、esp_http_client_close 函数,调用它关闭连接。
G、如果不再发出 HTTP 请求了可以调用 esp_http_client_cleanup 清理资源;如果后面还要向服务器发请求,那先不要调用。
从步聚B到F,其实可以用一个 esp_http_client_perform 函数一步到位。它会自动调用 从open,到 fetch,到 write、read,到 close 等方法。
不过,咱们这里向服务器请求的是 PCM 音频流,数据较长,不能一次就读完,咱们要读一点,然后发到 I2S 播放,然后再读后面的。所以就不能用 esp_http_client_perform 函数了。
有了上面的流程印象,接下来咱们编码就好弄很多了。其实 C 语言没有你想的那么复杂,应该说复杂的是 C++。某些编程语言,如 Rust 拼命宣传自己这样那样比C语言好,而实际上根本不是。Rust 在设计上出发点就是错的,反人类语法多,还加入了各种莫名其妙的东西。想想那么多硬件设备程序都是用汇编、C语言写的,也不见得人家那么多故障。更多时候,无操作系统裸机跑的程序才是最稳定,或者用一些内核简单的系统做复杂任务调度(如 esp 用的 RTOS)。设备一旦有了操作系统,问题就多起来。
1、编写 init_i2s 函数,初始化 i2s 接口。
// I2S通道句柄
static i2s_chan_handle_t iis_tx_ch;
static void init_i2s()
{
// 1、创建通道
i2s_chan_config_t chcfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
ESP_ERROR_CHECK(i2s_new_channel(&chcfg, &iis_tx_ch, NULL));
// 2、配置通道
i2s_std_config_t stdcfg = {
// 时钟源,调用默认宏设置就行了
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(SAMPLE_RATE),
// slot其实就是声道数
.slot_cfg = I2S_STD_PCM_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO),
// 下面配置IO引脚号
.gpio_cfg = {
.dout = I2S_DATA, // 数据线
.bclk = I2S_BIT_CLK, // 位时钟线
.ws = I2S_LR_CLK, // 左右声道选择线
// 下面这几个是说,引脚电平是否反转,通常不要反转,否则信号全错了
.invert_flags = {
.mclk_inv = false,
.bclk_inv = false,
.ws_inv = false}}};
// 初始化函数
ESP_ERROR_CHECK(i2s_channel_init_std_mode(iis_tx_ch, &stdcfg));
// 3、使能通道,不然通不了
ESP_ERROR_CHECK(i2s_channel_enable(iis_tx_ch));
}
i2s_chan_handle_t 类型的变量要声明为全局变量,因为待会儿在读取 HTTP 流并发送数据时要用到。
i2s_chan_config_t 对象咱们不必自己设置,用 I2S_CHANNEL_DEFAULT_CONFIG 宏就行了。
I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER)
i2s_new_channel 后两个参数分别是发送和接收通道的句柄,但这里咱们不用接收,所以直接给它 NULL。
I2S_NUM_0 指的是 i2s 总线号,ESP32 通常有两路 i2s 可用,第一路就是0,如果是 I2S_NUM_1 就表示选择用第二路。注意,这个只是逻辑上的总线号,不绑定硬件的,所以,IO脚编号你可以选不同组合。I2S_ROLE_MASTER 表示主机模式,因为是开发板发音频数据给功放芯片的,所以开发板当然是主机了。如果开发板作为从机,比如 esp 成为功放设备,电脑向 esp 发数据,那可以选从机角色(I2S_ROLE_SLAVE)。
主机和从机角色有啥不同呢?咱们先了解一下 IIS 的引脚就知道了。
1、MCLK:主时钟源,这个现在 99.996% 的芯片是不用连接的。这个是在功放芯片自己没有时钟源时才需要(比如无振荡器),没有时钟就不能产生电平高低变化了,那还通信个妖。
2、LRCLK:选择左右声道用的。就是上面代码 gpio_cfg 的 ws 成员,叫法不一样罢了。
3、BCLK:位时钟线,就是每个跳变周期你得发送/接收一个二进制位,这个好懂吧,就跟 i2c 的 SCL 差不多。
4、DATA:可能一根线,可能两根线(输入/输出)。就是传数据用的。
当你的 I2S 是主机时,LRCLK、BCLK 等时钟线是输出状态 ,时钟快慢,电平高低由你来决定 ,你是西楚霸王你说了算。当 I2S 是从机时,这些时钟线是输入状态 ,你必须听从别人的命令干活,人家发一个时钟周期你就要传一个二进制位。电平高低是别人说了算。
此处咱们是向功放发数据,所以数据线只配置 dout 就行了。引脚编号基本可以随便选。
i2s_std_config_t 的 clk_cfg 成员是配置时钟源,用 I2S_STD_CLK_DEFAULT_CONFIG 宏设置默认的就行,免得自己配置错了还要计算分频。参数是采样率,如 44100 Hz。
slot_cfg 成员其实指的是声道,同理,用 I2S_STD_PCM_SLOT_DEFAULT_CONFIG 宏解决。因为咱这里是用 PCM 数据,所以要用针对 PCM 的配置,参数是位宽和声道数。当然,如果用飞利浦标准的话,就用 I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG 宏。常见的无损音频多是 16 位,这也是CD的标准;第二个参数 I2S_SLOT_MODE_STEREO 表示立体声(不是单纯的左右双声通道,而是有混合的);如果想用单声道,可以取值 I2S_SLOT_MODE_MONO。
注意,初始化通道后记得调用 i2s_channel_enable 函数启用通道,这一步容易忘记。
编写 init_wifi 函数,初始化 Wifi。既然要无线传输了,当然得连路由器啦。这个过程一般配合事件队列来弄,可以在不同条件下触发不同的行为。当然了,你嫌麻烦也可以不用事件的,在启动 Wifi STA 后 delay 200 毫秒,在连接 Wifi 时 delay 3 秒。用延时等待的方式也不是不行,只是要等多久不太好确定,控制不够精准,所以还是用事件的好。
按流程走就不会错,连 Wifi 的流程时:接口初始化(加载驱动)--> WIFI 初始化--> 配置 STA-->启动WIFI-->连接WIFI。
static void init_wifi()
{
// 1、初始化网络接口
esp_netif_init();
// 2、加载无线网络接口
esp_netif_t *interface = esp_netif_create_default_wifi_sta();
// 设置主机名(可选)
esp_netif_set_hostname(interface, "WaWaZ");
// 3、初始化wifi
wifi_init_config_t wfcfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&wfcfg));
// 这个可选
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
// 4、配置STA模式
wifi_config_t cfg =
{
.sta = {
.ssid = MY_SSID,
.password = MY_PWD,
.bssid_set = false,
.threshold.authmode = WIFI_AUTH_WPA_WPA2_PSK}};
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &cfg));
// 设置wifi密码保存在Flash上(nvs分区)
esp_wifi_set_storage(WIFI_STORAGE_FLASH);
// 启动wifi
ESP_ERROR_CHECK(esp_wifi_start());
}
顺便补充一点,返回 esp_error_t 类型的函数都可以把返回传给 ESP_ERROR_CHECK 宏,这个宏是当有错误时输出在哪个代码文件哪一行,帮助你找到错误。
esp_netif_init 函数必须在所有网络相关的初始化之前调用。也就是说,不管你用无线还是有线(有些板子有以太网口),只要是和网络有关的,你都要先调用它。esp_netif_create_default_wifi_sta 是为STA模式的无线网络接口分配资源(加载驱动等),返回 esp_netif_t 实例,引用它可以调用其他相关函数。
esp_wifi_init 函数初始化的是接口层面上的配置,不是用来设置 SSID、连接密码的。一般用 WIFI_INIT_CONFIG_DEFAULT 宏获取默认值就可以了。这个是设置硬件参数的,自己设置如果弄不好,可能连接不了网络。甚至包括加解密的算法,除非你的路由器是自己做的,加密算法是自己写的,否则你不需要更改默认配置。
esp_wifi_set_config 函数才是用来设置 SSID、连接密码的,使用 wifi_config_t 结构体来配置。咱们这里用的是 STA 模式,所以只配置 sta 成员就好了。STA 模式下要把 bssid_set 成员设置为 false。ssid和 password 成员就不用介绍,字面意思都能知道是啥玩意。threshold.authmode 是指定路由器的加密措施,可以看路由器配置,也可以逐个试。常见是 WIFI_AUTH_WPA_WPA2_PSK 、WIFI_AUTH_WPA2_PSK。
esp_wifi_set_storage 函数是设置 wifi 配置的保存地方,就是你设置的 SSID、密码保存在哪,这样下次连 Wifi 时不用再设置了。配网的时候就经常这样弄。不过老周这里是直接把 SSID 硬编码了,为了简单。此处指定 WIFI_STORAGE_FLASH 就是把配置存到 Flash上。你看看 esp 的分区表,是不是有个叫 nvs 的。对,这个分区就是用来存放配置的,以字典(key / value)方式读写数据。正因为要用到 nvs 分区,所以在初始化 wifi 前,就要初始化 nvs,这个咱们把代码放到 app_main 函数里写。
esp_wifi_start 函数调用完毕后,如果不出事故,wifi 已经可用了。连接 WIFI 调用 esp_wifi_connect 函数,断开 Wifi 调用 esp_wifi_disconnect 函数。不过,前面说了,咱们既然用到事件队列,连接 Wifi 的操作自然要放在事件回调函数中。
static void network_event_cb(
void *ev_arg,
esp_event_base_t evtbase,
int32_t evt_id,
void *evt_data)
{
if (evtbase == WIFI_EVENT)
{
switch (evt_id)
{
case WIFI_EVENT_STA_CONNECTED:
// 连接成功,发送一个事件位标志
xEventGroupSetBits(evt_grp_hd, EVG_WIFI_CONNECTED_BIT);
break;
case WIFI_EVENT_STA_DISCONNECTED:
// 断线了自动连接
esp_wifi_connect();
break;
case WIFI_EVENT_STA_START:
// STA 模式启动了,连接路由器
esp_wifi_connect();
break;
default:
break;
}
}
if (evtbase == IP_EVENT)
{
// 获取到IP地址
if (evt_id == IP_EVENT_STA_GOT_IP)
{
// 发送一个事件位标志
xEventGroupSetBits(evt_grp_hd, EVG_NETIF_GOTIP_BIT);
}
}
}
事件回调用函数的声明是这样的:
void (*esp_event_handler_t)(void* event_handler_arg,
esp_event_base_t event_base,
int32_t event_id,
void* event_data);
没错,这货是一个函数指针,event_handler_arg 参数是指向 void 的指针,在注册事件回调时由你自己指定,等于是一个上下文对象,不用的话,直接给 NULL 就行;event_base 就是事件基础标识,前面介绍过,你可以认为它是一个事件发组的标识,这里用到 WIFI_EVENT,表明我后面处理的事件是和 Wifi 有关的;event_data 是事件相关的数据,不同事件的数据不同,所以它的类型是 void 指针。vadw oid 可以表示万能类型。
例如,WIFI_EVENT_STA_CONNECTED 事件表示 Wifi 连接成功,它对应的事件数据是 wifi_event_sta_connected_t。包括 SSID,连接使用的频道等信息。
注册事件在 app_main 函数中完成,待会再扯,下面看HTTP客户端初始化。写到一个函数里面,在app_main中会创建一个新任务,让它在新任务上运行。
static void http_req_task(void *arg)
{
esp_http_client_config_t cfg =
{
.url = HTTP_SERVER_ADDR,
.buffer_size = 89120,
.method = HTTP_METHOD_GET};
esp_http_client_handle_t httpHandle;
// 初始化客户端
httpHandle = esp_http_client_init(&cfg);
// 缓冲区
const uint16_t bufSize = 98000;
uint8_t *buffer = (uint8_t *)malloc(bufSize);
memset(buffer, 0, bufSize);
while (1)
{
// 1、打开连接
err_t res = esp_http_client_open(httpHandle, 0);
if (res != ESP_OK)
{
vTaskDelay(pdMS_TO_TICKS(5000));
continue;
}
// 2、获取流大小
int64_t contentLen = esp_http_client_fetch_headers(httpHandle);
if (contentLen <= 0)
{
vTaskDelay(pdMS_TO_TICKS(5000));
continue;
}
// 3、读取内容
int readLen = 0;
readLen = esp_http_client_read(httpHandle, (char *)buffer, bufSize);
// 4、把数据发送到 i2s
while (readLen > 0)
{
i2s_channel_write(iis_tx_ch, (void *)buffer, readLen, NULL, 100);
// 继续读
readLen = esp_http_client_read(httpHandle, (char *)buffer, bufSize);
}
// 5、关闭连接
esp_http_client_close(httpHandle);
vTaskDelay(pdMS_TO_TICKS(2000));
}
// 清理
free(buffer);
}
HTTP 是协议层的,初始化时不用加载硬件驱动,所以它的仪式感就没那么强了。esp_http_client_config_t 结构体用于配置 HTTP 请求相关的信息。url 成员指定你要请求的URL,buffer_size 是esp处理传输数据的缓冲大小,不是你写代码时用的字节数组的大小。method 成员指定请求方式,如 GET、POST 等。
调用 esp_http_client_init 函数后,返回 esp_http_client_handle_t 句柄,后面调用其他 HTTP 函数时用得到。这样就完工了,然后就是通信了。此处由于要使用流操作,不使用 esp_http_client_perform 函数,而是分步完成。esp_http_client_fetch_headers 函数读取服务器响应的 HTTP 头,并且该函数返回的值就是 Content-Length。这样咱们就知道音频 PCM 有多大了。
剩下的就是不断用 esp_http_client_read 从流中读数据,再用 i2s_channel_write 函数发数据。在上述代码中,代码写在一个死循环中,所以,会向同一 URL 不断发出请求,单曲循环(当然了,服务器可以选择返回不同的曲子)。
最后就是主任务------ app_main 函数了。
void app_main(void)
{
// 初始化nvs存储
err_t res = nvs_flash_init();
if (res != ESP_OK)
{
// 不管你大爷是什么原因导致初始化失败
// 一律格(杀)式(勿)化(论)
nvs_flash_erase();
// 再试一次
res = nvs_flash_init();
}
if (res != ESP_OK)
{
ESP_LOGI("nvs", "真的无法初始化NVS了,请自我检讨");
return;
}
/*------------------------------------------------------------------------*/
// 创建默认的事件队列
ESP_ERROR_CHECK(esp_event_loop_create_default());
// 创建事件组
evt_grp_hd = xEventGroupCreate();
// 注册事件
ESP_ERROR_CHECK(esp_event_handler_register(
WIFI_EVENT,
WIFI_EVENT_STA_START,
network_event_cb,
NULL));
ESP_ERROR_CHECK(esp_event_handler_register(
WIFI_EVENT,
WIFI_EVENT_STA_CONNECTED,
network_event_cb,
NULL));
ESP_ERROR_CHECK(esp_event_handler_register(
WIFI_EVENT,
WIFI_EVENT_STA_DISCONNECTED,
network_event_cb,
NULL));
ESP_ERROR_CHECK(esp_event_handler_register(
IP_EVENT,
IP_EVENT_STA_GOT_IP,
network_event_cb,
NULL));
/*-----------------------------------------------------------------------*/
// 初始化WIFI
init_wifi();
// 初始化IIS
init_i2s();
/*------------------------------------------------------------------------*/
// 等待事件组设置二进制位
EventBits_t evbits = xEventGroupWaitBits(
evt_grp_hd, // 事件组句柄
// 要等待的二进制位
EVG_WIFI_CONNECTED_BIT | EVG_NETIF_GOTIP_BIT,
pdTRUE, // 自动清除二进制位
pdTRUE, // 等待所有位同时有效
portMAX_DELAY // 一直等待
);
if (evbits & EVG_WIFI_CONNECTED_BIT)
{
ESP_LOGI("wifi", "wifi已连接");
}
if (evbits & EVG_NETIF_GOTIP_BIT)
{
ESP_LOGI("wifi", "已获取IP地址");
}
// 创建用于发起HTTP请求的任务
xTaskCreate(
http_req_task,
"mytask", // 任务名称
4096, // 任务栈大小
NULL, // 用户参数,这里无参数
2, // 任务优先级
NULL // 任务句柄,这里不用存储
);
/*
主任务是允许退出的
*/
}
idf 隐藏了 main 函数,应用程序编写的入口改为 app_main 函数,它实际上是 RTOS 的主任务调用的。可以看看 idf 是如何调用 app_main 的。
static void main_task(void* args)
{
ESP_LOGI(MAIN_TAG, "Started on CPU%d", (int)xPortGetCoreID());
#if !CONFIG_FREERTOS_UNICORE
// Wait for FreeRTOS initialization to finish on other core, before replacing its startup stack
esp_register_freertos_idle_hook_for_cpu(other_cpu_startup_idle_hook_cb, !xPortGetCoreID());
while (!s_other_cpu_startup_done) {
;
}
esp_deregister_freertos_idle_hook_for_cpu(other_cpu_startup_idle_hook_cb, !xPortGetCoreID());
#endif
// [refactor-todo] check if there is a way to move the following block to esp_system startup
heap_caps_enable_nonos_stack_heaps();
// Now we have startup stack RAM available for heap, enable any DMA pool memory
#if CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL
if (esp_psram_is_initialized()) {
esp_err_t r = esp_psram_extram_reserve_dma_pool(CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL);
if (r != ESP_OK) {
ESP_LOGE(MAIN_TAG, "Could not reserve internal/DMA pool (error 0x%x)", r);
abort();
}
}
#endif
// Initialize TWDT if configured to do so
#if CONFIG_ESP_TASK_WDT_INIT
esp_task_wdt_config_t twdt_config = {
.timeout_ms = CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000,
.idle_core_mask = 0,
#if CONFIG_ESP_TASK_WDT_PANIC
.trigger_panic = true,
#endif
};
#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0
twdt_config.idle_core_mask |= (1 << 0);
#endif
#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1
twdt_config.idle_core_mask |= (1 << 1);
#endif
ESP_ERROR_CHECK(esp_task_wdt_init(&twdt_config));
#endif // CONFIG_ESP_TASK_WDT
/*
Note: Be careful when changing the "Calling app_main()" log below as multiple pytest scripts expect this log as a
start-of-application marker.
*/
ESP_LOGI(MAIN_TAG, "Calling app_main()");
extern void app_main(void);
app_main();
ESP_LOGI(MAIN_TAG, "Returned from app_main()");
vTaskDelete(NULL);
}
看到否?app_main 用 extern 修饰,把它声明为由外部其他代码实现的函数,idf 自身不实现,只负责调用。整初始化过程包括 CPU 两个核的初始化,接着是任务看门狗,最后调用 app_main。做完这些后 vTaskDelete(NULL) 表示该任务自杀。从这里也能知道,app_main 函数内是不需要死循环的,当你安排好程序的其他执行任务后,app_main 函数是可以返回的。
看门狗其实是利用定时器,在那里无休止地数咩咩,数着数着它就饿了。你的代码必须在看门狗饿疯之前喂它。看门狗的三观很简单,有得吃就是快乐。如果你的代码不喂狗,看门狗数咩咩数到一定数值(Time out)就会受不了,然后它会强制让开发板重启。看门狗的作用是防止你的程序死机,当开发板过一定时间后没反应,就重启。
任务看门狗就是监听任务队列,所有任务都是抢占 CPU 时间片的(和咱们常说的多线程差不多),当你的任务长时间不让出 CPU 时间片,任务看门狗就认为你这主人可能死机了,这么久不喂狗。由于 idf 默认已配置了一个任务看门狗,所以,你在任务代码是不用刻意去喂狗的,只要你每隔一段时间(没有 Time out 前,这个超时值可以在 SDK 选项中改)让出一下 CPU 时间片,就会自动喂狗了。开发板就不会重启了,最简单的方法就是调用一下 vTaskDelay() 做一下延时,不管延时多长,这个过程都会让出 CPU 时间片。
好,说回 app_main 函数。在这个函数里,咱们做了这几件事:
1、初始化 nvs,前面说了,用来保存配置的。
nvs_flash_init
这里为什么会做两次调用呢,因为这个 nvs 分区一般比较小,有时候存的数据满了(或者是以前的固件存的,现在你的新应用不需要这些垃圾数据),所以,如果初始化不成功,可尝试将 nvs 分区擦除(就像你格式化硬盘分区),这样就有空间来存放新数据了。
2、创建事件队列,前面说了嘛,Wifi 操作使用事件,如果不创建事件队列,那是收不到事件通知的,回调用函数永远无法运行。esp_event_loop_create_default 表示创建默认队列,无需保存变量,因为它由 idf 自动管理。当然,手动创建也可以的,还能选择动态分配或使用静态内存。你看,用 C 语言写就有这好处,灵活,你用 MicroPython、Arduino、.NET Nano 等封装过的框架,是没有这么细节的配置的。
3、xEventGroupCreate 函数创建一个事件分组,这个实际上就是给定一组由二进制位 OR 运算组合的标志。这些标志全是你自己定义,爱怎么定义都行,只要你保证每个标志只占一个二进制位。比如,
【吃饭】 = 0001
那么,接下来定义【啃树皮】就不能用第一位了,只能用2、3、4位任选一。
【啃树皮】 = 0100
如果做 【吃饭】|【啃树皮】运算,那么结果就是 0101,这就能看出,两件事同时发生了。设置二进制位可调用 xEventGroupSetBits 函数(请看前面 Wifi 事件回调函数);而我们的代码可以调用 xEventGroupWaitBits,当你需要的二进制位被设置了,这个函数就会返回。这就类似于线程信号灯,一个点灯,一个等灯。
4、注册事件回调函数。尽管你创建了事件队列,如果不注册回调函数,那么回调函数也不会被触发的。注册回调函数就是告诉事件队列:我对哪些事件感兴趣,并且这些事件发生时你帮我调用 XXX 函数;其他事件我没兴趣,别打扰我。
注册事件回调,可以用 esp_event_handler_register 函数,或者 esp_event_handler_instance_register 函数。两者有啥区别?
A、esp_event_handler_register 是旧版函数,但在新版中也兼容的;esp_event_handler_instance_register 是新版本函数,提供给你,但你也可以不用;
B、esp_event_handler_register 函数注册后只告诉你个结果------有没有成功,但不给你任务句柄变量,后面要干吗你无法引用我;而 esp_event_handler_instance_register 函数在注册后会留一个 esp_event_handler_instance_t 类型的变量,后面你想调用其他函数时,可以用这个变量来引用。
这里我用到了两组事件,WIFI_EVENT 是和 wifi 有关的事件,IP_EVENT 是和 IP 地址有关的,因为我要用到 IP_EVENT_STA_GOT_IP 事件。此事件在 ESP 32 连上路由器并获取到 IP 地址后发生。响应此事件可以明确知道:我能上网啦,可以发出 HTTP 请求了。
当所有初始化工作完成后,用 xTaskCreate 创建一个任务,这个任务执行前面写的 http_req_task 函数,不断地接收 PCM 数据,并传给 i2s 接口播放。
xTaskCreate(
http_req_task,
"mytask", // 任务名称
4096, // 任务栈大小
NULL, // 用户参数,这里无参数
2, // 任务优先级
NULL // 任务句柄,这里不用存储
);
客户端竣工,现在来搓 HTTP 服务器。服务器直接建一个空白的 ASP.NET Core 项目。
代码很简单,Mini-API 即可胜任。
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.Map("/", () => "洋癫疯音乐服务平台");
app.Map("/song", (IWebHostEnvironment env) =>
{
// 获取应用程序所在目录
IFileProvider rootDir = env.ContentRootFileProvider;
// 从目录下获取PCM音频文件
var pcmFile = rootDir.GetFileInfo("song.pcm");
if(pcmFile.Exists)
{
// 直接把文件内容以流的形式返回
return Results.Stream(pcmFile.CreateReadStream(), "application/octet-stream");
}
return Results.NotFound();
});
app.Run("http://192.168.1.10:80");
以 IWebHostEnvironment 类型为 API 方法的参数,它会自动注入。然后,用 ContentRootFileProvider 属性就得到了当前 Web 应用程序所在目录,再调用 GetFileInfo 方法就能获取到音频文件了。因为老周把 PCM 文件放在项目目录下。实际使用时,可以在服务器上建一个专用目录,存放文件。
PCM 数据怎么来呢?其实,WAV 文件除去文件头,剩下的就是 PCM 数据了。所以说,WAV 格式的音乐才叫无损。老周找了一首清新女神的歌进行演示,用 FFmpeg 来提取 PCM 数据。
ffmpeg -i "E:\音乐\王韵婵\王韵婵 - 勇敢高飞不寂寞.wav" -f s16le d:\out.pcm
-f 用在 input 之前设置的输入文件的格式,但这里用在输出路径之前,所以设置的是输出文件的格式。s16 表示有符号的 16 整数,le 表示小端。也就是说,咱们提取的 PCM 数据是 Uint16 类型数值,并且低地址存放低字节,高地址存放高字节。如果是大端,就是 s16be。但是,建议使用小端,因为这个比较通用,be 很多时候会出问题。
因为在这个例子中,ESP 32 一运行就发出 HTTP 请求的,所以,先运行服务器,然后再给 ESP 上电。老周这里的请求地址是 http://192.168.1.10:80/song,即 http://192.168.1.10/song 就行了。你需要根据实际情况改地址,确保服务器和客户端的地址匹配。
好了,今天就水到这儿了,改天等老周用 .NET Nano framework 做成功了,再写一文来介绍。其实,.NET 封装后的 I2S 调用起来更容易,只是老周自己还没弄成功,所以先不写。老周分享的这些破玩意儿,向来都要亲自验证过才写的。