硬件介绍
ESP32-S2-mini-1模块配备了240M的单核Xtensa32处理器、和4MB SPI flash以及43 个 GPIO 口,14 个电容式传感 IO和多种外设。
本项目使用到了ADC,DAC、SPI接口的128*64 OLED,SPI、以及按键等。
本次我使用的平台是ESP-IDF,其提供了丰富的示例以及各种api接口。
功能说明
音频信号示波器/频谱仪:
- 将Mic或耳机插孔输入的语音信号进行ADC量化,并在OLED显示屏上将波形和频谱显示出来,并能够自动测量输入信号的参数 - 峰峰值、频率(最高为10khz),此外可以使用adc内置的IIR数字滤波器,其时域表达式为:y(n+1)=1/k*x(n+1)+(k-1)/k*x(n),k可调节。
- 通过DAC生成一个单频的模拟正弦波信号,可以调节其幅度和频率。
过程简介
由于是第一次接触单片机嵌入式的,对于硬件模块和嵌入式系统并没有明确概念,在这方面,我们可以说是从零开始。从单片机的概念学起,结合网上资料和乐鑫的代码示例,逐渐学习了gpio、oled、SPI、I2C、freeRTOS、wifi等模块以及通信协议、中断服务等知识,对于硬件和软件之间的交互,和各模块之间的通信的理解更深了。同时也学习到了抽象级系统的设计流程以及各子系统的最终实现的方法。
设计思路
音频信号示波器/频谱仪:
1.MIC对音频采集后对模拟信号放大。
2.adc1以20kHz的速率进行采样,将其存放到内存中。
3.将adc采集到的原始数据处理,再经过256位FFT处理。
4.通过处理得到主频率,峰峰值,平均值。
5.将频谱或者音频数据(通过sw2控制)通过spi串口传输到oled屏上显示。
单频音频输出:
1.通过sw1,sw2,sw4控制输出幅值和频率,并显示到屏幕上。
2.dac开启输出。
代码示例
spi_oled的操作部分代码,这部分是通过查阅网上关于oled的工作原理的介绍以及基于I2C接口的oled库函数改编而来的。通过乐鑫提供的spi_master.h库函数编写数据和指令传输函数对oled进行控制。
void send_cmd(const uint8_t cmd, spi_device_handle_t spi)//通过spi向oled传输命令
{
esp_err_t ret;
spi_transaction_t t;
memset(&t, 0, sizeof(t));
t.length=8;
t.tx_buffer=&cmd;
t.user=(void*)0; //0表示传输的是命令
ret=spi_device_polling_transmit(spi, &t);
assert(ret==ESP_OK);
}
void send_data(spi_device_handle_t spi, const uint8_t *data, int len)//通过spi向oled传输数据
{
esp_err_t ret;
spi_transaction_t t;
if (len==0) return;
memset(&t, 0, sizeof(t));
t.length=len*8;
t.tx_buffer=data;
t.user=(void*)1; //1表示传输数据
ret=spi_device_polling_transmit(spi, &t); //Transmit!
assert(ret==ESP_OK);
}
void oled_spi_pre_transfer_callback(spi_transaction_t *t)//回调函数,每次传输开始前会自动先调用这个函数,告诉oled是数据还是命令
{
int dc=(int)t->user;
gpio_set_level(PIN_NUM_DC, dc);
}
void oled_start()//初始化oled
{
send_cmd(0x8D,spi);
send_cmd(0x14,spi);
send_cmd(0xAF,spi);
}
void oled_refresh()//将开辟的screen数组中的数据传输到oled
{
uint8_t m;
for(m=0;m<8;m++)
{
send_cmd(0xb0+m,spi);
send_cmd(0x00,spi);
send_cmd(0x10,spi);
send_data(spi,screen[m],128);
}
}
void oled_clear()//将screen数组置零
{
uint8_t i,j;
for(i=0;i<8;i++)
for(j=0;j<128;j++)
screen[i][j]=0;
oled_refresh();
}
void oled_drawpoint(uint8_t x,uint8_t y)//在screen的对应(x,y)处的二进制值置1
{
uint8_t i=0,j=0;
x=127-x;
i=y/8;
j=y%8;
j=1<<j;
screen[i][x] |= j;
}
void oled_clearpoint(uint8_t x,uint8_t y)////在screen的对应(x,y)处的二进制值置0
{
uint8_t i=0,j=0;
x=127-x;
i=y/8;
j=y%8;
j=1<<j;
screen[i][x]=~screen[i][x];
screen[i][x] |= j;
screen[i][x]=~screen[i][x];
}
void oled_char(uint8_t x,uint8_t y,char chr,uint8_t size1)//调用字库数组中的数据,在(x,y)处写一个字符
{
uint8_t i,m,temp,size2,chr1;
uint8_t y0=y;
size2=(size1/8+((size1%8)?1:0))*(size1/2);
chr1=chr-' ';
for(i=0;i<size2;i++)
{
if(size1==12)
{temp=asc2_1206[chr1][i];} //调用1206字体
else if(size1==16)
{temp=asc2_1608[chr1][i];} //调用1608字体
else return;
for(m=0;m<8;m++)
{
if(temp&0x80)oled_drawpoint(x,y);
else oled_clearpoint(x,y);
temp<<=1;
y--;
if((y0-y)==size1)
{
y=y0;
x++;
break;
}
}
}
}
void oled_string(uint8_t x,uint8_t y,char *chr,uint8_t size1)//在(x,y)处写一个字符串
{
while((*chr>=' ')&&(*chr<='~')&&(*chr!='.'))
{
oled_char(x,y,*chr,size1);
x+=size1/2;
if(x>128-size1)
{
x=0;
y-=size1;
}
chr++;
}
}
FFT部分代码,从复数结构体,到复数的运算法则,再到DFT,最后才到FFT,由于是自己编写,所以可以根据实际情况在其过程中做一定的优化。其中由于屏宽为128,只能显示128个数据,由于实信号频域对称性,所以只需做256位的FFT。
此部分先生成256位数据的变换后的位置数据,以免后面每做一次FFT就要算一次。
int j;
for(j=0;j<256;j++)
{
X[0][j]=j;
}
for(int i=0;i<7;i++)
{
j=power_2(i);
for(int k=0;k<j;k++)
{
sort(X[i%2]+256/j*k,X[(i+1)%2]+256/j*k,256/j);
}
}
for(j=0;j<256;j++)
{
fft_256_order[j]=X[1][j];//得到256位fft的实信号排列顺序
}
FFT
int power_2(int n)//2^n
{
int c=1;
while(n-->0)
{
c*=2;
}
return c;
}
cpx add(cpx a,cpx b)//复数加法
{
cpx c;
c.re=a.re+b.re;
c.im=a.im+b.im;
return c;
}
cpx sub(cpx a,cpx b)//复数减法
{
cpx c;
c.re=a.re-b.re;
c.im=a.im-b.im;
return c;
}
cpx mull(cpx a,cpx b)//复数乘法
{
cpx c;
c.re=a.re*b.re-a.im*b.im;
c.im=a.re*b.im+a.im*b.re;
return c;
}
cpx mul(uint8_t a,cpx b)//复数和实数的乘法
{
b.re=b.re*a;
b.im=b.im*a;
return b;
}
cpx cpx_exp(cpx a)//复数e指数
{
cpx c;
c.re=exp(a.re)*cos(a.im);
c.im=exp(a.re)*sin(a.im);
return c;
}
float cpx_abs(cpx a)//复数取模
{
float c;
c=sqrt(a.re*a.re+a.im*a.im);
return c;
}
void DFT_2(uint8_t *data,int offset)//两位的DFT,这里由于两位数的DFT要比FFT要快,所以采用DFT
{
xx[1][offset].re=(data[0]+data[1]);
xx[1][offset].im=0;
xx[1][offset+1].re=(data[0]-data[1]);
xx[1][offset+1].im=0;
}
void sort(uint8_t *data,uint8_t *dd,int len)//将数组data的偶数位放入dd的前len/2位,奇数位放入dd的后len/2位。
{
for (int i=0;i<len/2;i++)
{
dd[i]=data[2*i];
dd[len/2+i]=data[2*i+1];
}
}
void FFT_256()
{
int j;
for(int i=0;i<256;i++)
X[1][i]=X[0][fft_256_order[i]];
for(int i=0;i<128;i++)
{
DFT_2(X[1]+2*i,2*i);
}
for(int i=6;i>0;i--)//FFT具体步骤
{
j=power_2(i);
int p=256/j;
cpx c;
for(int k=0;k<j;k++)
{
for(int m=0;m<p/2;m++)
{
c.re=0;
c.im=2*pi*m/p;
xx[(i)%2][m+k*p]=add(xx[(i+1)%2][m+k*p],mull(xx[(i+1)%2][m+k*p+p/2],cpx_exp(c)));
xx[(i)%2][m+k*p+p/2]=sub(xx[(i+1)%2][m+k*p],mull(xx[(i+1)%2][m+k*p+p/2],cpx_exp(c)));
}
}
}
for (int i=0;i<128;i++)//最后只需要前128位幅值
{
cpx c;
c.re=0;
c.im=2*pi*i/256;
X[0][i]=cpx_abs(add(xx[1][i],mull(xx[1][128+i],cpx_exp(c))))/8;
}
}
io_task是开的一个处理按键输入的线程,按键中断后调用中断服务函数向任务队列里发送一个事件,线程得到触发中断io端口,从而进行相应的处理,用于adc和dac之间切换以及设置参数。
void io_task(void *arg)
{
io_evt_t ar;
for(;;)//主任务,不会返回
if(xQueueReceive(io_evt_queue, &ar, portMAX_DELAY))
{
if(ar.io_num==1&&adc_switch==1)//调节IIR滤波器系数,(0 2 4 8 16 64),只有adc工作时才有用
{
flag1=flag1==5?0:flag1+1;
if(flag1==5)
{
adc_digi_filter_enable(ADC_DIGI_FILTER_IDX0, 0);
}
else
{
adc_digi_filter_t cfg = {
.adc_unit = ADC_UNIT_1,
.mode = flag1,
.channel = ADC_CHANNEL_9,
};
adc_digi_filter_enable(ADC_DIGI_FILTER_IDX0, 1);
adc_digi_filter_set_config(ADC_DIGI_FILTER_IDX0, &cfg);
}
}
if(ar.io_num==1&&adc_switch==0)//dac开关,adc关闭才能开启
{
dac_switch=(dac_switch==0)?1:0;
if(dac_switch==0)
{
dac_digi_stop();
adc_dac_dma_linker_deinit();
dac_digi_deinit();
oled_work();
}
else
{
dac_addr=dac_dma_linker_init();
dac_start();
oled_work();
}
}
if(ar.io_num==2&&dac_switch==0)//adc开关,只有dac关闭时才能使用
{
adc_switch=(adc_switch==0)?1:0;
if(adc_switch==1)
{
adc1_start();
xTaskCreate(oled_task, "task", 2048, NULL, 10, adc_task);
}
if(adc_switch==0)
{
vTaskDelay(100 / portTICK_RATE_MS);
oled_clear();
oled_string(20,40,str_adc,16);
oled_refresh();
}
}
if(ar.io_num==3&&adc_switch==1) flag2=(flag2==1)?0:1;//控制adc页面,显示频谱还是音频信号
if(ar.io_num==3&&adc_switch==0) //调节输出幅度,只有adc关闭才有用
{
Amplitude=Amplitude==120?0:Amplitude+20;
oled_work();
dac_digi_stop();
dac_addr=dac_dma_linker_init();
dac_digi_start();
}
if(ar.io_num==6&&adc_switch==0)//调节输出频率,只有adc关闭才有用
{
freq=(freq==10)?0:freq+1;
oled_work();
dac_digi_stop();
dac_addr=dac_dma_linker_init();
dac_digi_start();
}
}
}
还需要另外开一个线程,用于oled显示实时的音频信号和频谱数据,这部分主要要保证实时性,当每个adc的dma_linker来到时,必须在下一个数据链到来前将其处理完毕并且还要显示在oled屏上,在保证没有数据链丢失的采样率范围内尽量选择高的频率,这可以提高帧率。在线程结束前需要将任务删除,并且将adc_digi_deinit()解除,不能只是调用adc_digi_stop(),这是因为dac和adc是公用一个dma通道,如果不解除adc数字控制器的话,在调用dac时会导致很卡。
void oled_task()
{
adc_dma_t ar;
for(;adc_switch==1;)//此任务可返回,结束后要删除任务
if(xQueueReceive(adc_dma_queue, &ar, 1000 / portTICK_RATE_MS))
{
data_process(ar.dma_linker);
if(flag2==0)
{
oled_freq();
}
else
{
oled_wave();
}
}
adc_dac_dma_linker_deinit();
adc_dac_dma_isr_deregister(dma_isr_handler, NULL);
adc_digi_deinit();//关闭adc数字控制器,将dma_linker释放
vTaskDelete(adc_task);//删除任务
}
主程序就是初始化spi总线、oled和用到的gpio端口,创建任务。
void app_main(void)
{
esp_err_t ret;
spi_bus_config_t buscfg={
.miso_io_num=PIN_NUM_MISO,
.mosi_io_num=PIN_NUM_MOSI,
.sclk_io_num=PIN_NUM_CLK,
.quadwp_io_num=-1,
.quadhd_io_num=-1,
.max_transfer_sz=128*8,
};
spi_device_interface_config_t devcfg={
.clock_speed_hz=20*1000*1000, //Clock out at 20 MHz
.mode=0, //SPI mode 0
.spics_io_num=PIN_NUM_CS,
.queue_size=1,
.pre_cb=oled_spi_pre_transfer_callback,
};
//Initialize the SPI bus
ret=spi_bus_initialize(SPI2_HOST, &buscfg, SPI_DMA_CH_AUTO);
ESP_ERROR_CHECK(ret);
//Attach the oled to the SPI bus
ret=spi_bus_add_device(SPI2_HOST, &devcfg, &spi);
ESP_ERROR_CHECK(ret);
//GPIO config
gpio_config_t io_cfg = {
.intr_type = GPIO_INTR_DISABLE,
.mode = GPIO_MODE_OUTPUT,
.pin_bit_mask = ((1ULL<<PIN_NUM_SPEAKER) | (1ULL<<PIN_NUM_AUDIO)),
.pull_up_en = 0,
.pull_down_en = 0
};
gpio_config(&io_cfg);
gpio_set_level(PIN_NUM_SPEAKER,1);
gpio_set_level(PIN_NUM_AUDIO,1);
gpio_set_direction(PIN_NUM_DC, GPIO_MODE_OUTPUT);
gpio_set_direction(PIN_NUM_RST, GPIO_MODE_OUTPUT);
gpio_config_t io_cfg1 = {
.intr_type = GPIO_INTR_POSEDGE,
.mode = GPIO_MODE_INPUT,
.pin_bit_mask = (1ULL<<1|1ULL<<2|1ULL<<3|1ULL<<6), //输入控制信号
.pull_up_en = 0,
.pull_down_en = 0
};
gpio_config(&io_cfg1);
gpio_install_isr_service(0);
gpio_isr_handler_add(1,sw_isr_handler,(void*)GPIO_NUM_1);
gpio_isr_handler_add(2,sw_isr_handler,(void*)GPIO_NUM_2);
gpio_isr_handler_add(3,sw_isr_handler,(void*)GPIO_NUM_3);
gpio_isr_handler_add(6,sw_isr_handler,(void*)GPIO_NUM_6);
oled_init();
oled_start();
//初始化配置完成
char *str3="Get started!.";
oled_string(16,40,str3,16);
oled_refresh();
vTaskDelay(1000 / portTICK_RATE_MS);
int j;
for(j=0;j<256;j++)
{
X[0][j]=j;
}
for(int i=0;i<7;i++)
{
j=power_2(i);
for(int k=0;k<j;k++)
{
sort(X[i%2]+256/j*k,X[(i+1)%2]+256/j*k,256/j);
}
}
for(j=0;j<256;j++)
{
fft_256_order[j]=X[1][j];
}
adc_addr=adc_dma_linker_init();
dac_addr=dac_dma_linker_init();//不变的全局变量,只初始化一次
adc_dma_queue = xQueueCreate(5, sizeof(adc_dma_t));创建任务队列存放dma事件
io_evt_queue = xQueueCreate(5, sizeof(io_evt_t));//创建任务队列存放io事件
xTaskCreate(io_task, "io_task", 2048, NULL, 10, NULL)//开始io任务
}
结果展示
心得体会
感谢此次硬禾学堂暑假在家练活动让我学习到了很多知识,第一次接触到了嵌入式开发,对于外设和硬件的理解更加深入且具体,在查阅资料和阅读示例代码的过程中,我学会了从更底层的去理解代码的执行,从系统流程的设计再到每个具体功能实现上,我学会了从抽象层到底层实现自上而下的编程方式。同时结合自身的C语言基础,力求做到全过程自己编写,从复数的四则运算到fft算法实现,可以很大程度上根据实际情况进行进一步的优化。希望以后能多参加硬禾学堂此类的在家练活动,自己动手嵌入式开发。
项目代码地址: