基于ESP32S2的游戏手柄屏幕控制
本项目使用硬禾课堂设计的基于ESP32S2模块的开发板,以及配套的扩展板,实现了使用扩展板上的游戏手柄FJ08K控制LCD屏幕上的动画。
标签
嵌入式系统
显示
2023寒假在家练
电子卷卷怪
更新2023-03-27
南京大学
714

一、项目概述

   本项目使用硬禾课堂设计的基于ESP32S2模块的开发板,以及配套的扩展板,实现了使用扩展板上的游戏手柄FJ08K控制st7735驱动的128*128 RGB LCD上的动画显示。

   项目最终的结果是能够使用扩展板上的游戏手柄实时地、自由地控制一个圆点在屏幕区域内运动,并证明了采用我基于寄存器编程设计的LCD驱动程序能够显著提升屏幕显示的效率和性能。

 

二、项目思路与结构

   在本项目中,我所开发的程序的主函数,严格上讲并不能叫做”main函数“。因为ESP32S2内置了freeRTOS,对整个SOC的片上资源进行管理,一旦发生内存泄漏等错误,则默认情况下,这个freeRTOS操作系统就会直接复位芯片(我看到群里有同学反馈屏幕”一直闪“的问题,有可能就是这种情况,而他没有意识到)。

   项目实现的思路大致可概括为两部分:硬件连接分析、软件编程开发。由于要使用LCD,因此需要获得驱动芯片的初始化序列,留意四线SPI的具体时序要求,并确定对应引脚是否能够映射到SPI外设;由于要测量频率和占空比,因此首先需要确定频率和占空比的动态范围,然后确定是用什么方法测量:是使用ADC,定时器还是脉冲计数器。另外,还应考虑如何使得屏幕驱动变得尽可能地高效,以节省功耗并提高帧率。

 

三、实现过程

1.LCD连接映射

   根据原理图,LCD引脚映射如下:

#define SCL_NUM GPIO_NUM_41
#define SDA_NUM GPIO_NUM_21
#define RES_NUM GPIO_NUM_18
#define DCX_NUM GPIO_NUM_17
#define CSX_NUM GPIO_NUM_13

   如果能够使用硬件SPI(即SPI外设),则驱动的效率将大大提高。然而遗憾的是,根据数据手册(esp32s2 technical reference manual cn的166页)得知,这几个引脚并没有映射到SPI外设——只要SDA和SCK中的一个无法映射,这种方案就没戏。因此,不得不采用软件模拟SPI时序的方式来实现。

2.LCD底层驱动

   查阅st7735的数据手册得知,芯片在SCK上升沿读取SDA线,数据传输以8bit为单位,MSB先传输,因此可以确定最底层的函数如下:

void st7735_write_dc(uint8_t data, uint8_t dc){
    uint8_t i;
    gpio_set_level(SCL_NUM,0);
    gpio_set_level(SDA_NUM,0);
    gpio_set_level(DCX_NUM,dc);

    gpio_set_level(CSX_NUM,0);
    for(i = 0; i < 8; i ++){
        if(data & 0x80)
            gpio_set_level(SDA_NUM,1);
        else
            gpio_set_level(SDA_NUM,0);

        gpio_set_level(SCL_NUM,1);
        data <<= 1;

        gpio_set_level(SCL_NUM,0);
    }
    gpio_set_level(CSX_NUM,1);
}

基于此函数,就可以移植在网上找到的驱动函数,因为绝大多数的LCD/OLED驱动函数的层次都是相似的,除了底层的writeByte函数之外,其他函数几乎都是平台无关的。由此一来,屏幕的初始化和基本的数据写入就可以完成。由于本项目依然采用GRAM的方式实现屏幕刷新,因此只要通过最简单地一次性写入128*128*2个字节的数据的方式,就可以实现屏幕的显示功能。

3.PWM测量

   经示波器测量,待测量的PWM信号频率范围为238~467Hz,中心值314Hz;占空比33~80%,中心值57%。

   相比之下,采用定时器或脉冲计数器的方法,计算占空比会比较麻烦(片上的脉冲计数器甚至几乎无法算出占空比)。而SDK中给出的ADC采样频率范围为(5MHz/2)/30 ~ 2*(5MHz/2)/4095(分母必须是30~4095之间的整数),也就是0.61k~83.33kHz。这个范围是完全适合的,且采用ADC的方式,计算占空比和频率都可以十分直观。

   综上,确定采用ADC进行PWM测量。

4.ADC连续转换配置

   官网提供了完整的API指南,来告诉开发者如何以顺应SDK的层次和逻辑的方式来逐步完成ADC的初始化,并将它配置于连续转换模式下。

   第一,声明一个新的ADC转换句柄(可以认为该句柄与一次具体的ADC转换配置相关联),并指定与该句柄关联的ADC转换的数据帧大小以及最大缓存大小:

//Step 1.
adc_continuous_handle_t handle = NULL;
adc_continuous_handle_cfg_t adc_config = {
        .max_store_buf_size = 1024,
        .conv_frame_size = ADC_FRAME_LEN,
    };
ESP_ERROR_CHECK(adc_continuous_new_handle(&adc_config, &handle));

   注意,max_store_buf_size必须是conv_frame_size的整倍数,否则就会出现由于内存泄漏导致的板子反复自动复位的情况。

   第二,指定与句柄关联的ADC转换配置的采样率、转换模式、转换位宽与通道衰减等参数:

  //Step 2.  
  adc_continuous_config_t dig_cfg = {
        .pattern_num = 1,
        .sample_freq_hz = ADC_SAMP_FREQ,
        .conv_mode = ADC_CONV_SINGLE_UNIT_1,
        .format = ADC_DIGI_OUTPUT_FORMAT_TYPE1
    };

    adc_unit_t fj08k_adc_unit;
    adc_channel_t fj08k_adc_channel;
    ESP_ERROR_CHECK(adc_continuous_io_to_channel(FJ08K_IO_NUM, &fj08k_adc_unit, &fj08k_adc_channel));

    adc_digi_pattern_config_t dig_pattern_cfg = {
        .atten = ADC_ATTEN_DB_0,
        .channel = fj08k_adc_channel,
        .unit = fj08k_adc_unit,
        .bit_width = SOC_ADC_DIGI_MAX_BITWIDTH
    };
    dig_cfg.adc_pattern = &dig_pattern_cfg;
    ESP_ERROR_CHECK(adc_continuous_config(handle,&dig_cfg));

类比STM32寄存器开发的经验,这些参数显然就是ADC的地址范围内的一系列连续的寄存器,API将它们读进去之后,先用assertion之类的方法判断参数是否合法,然后写入配置寄存器。

   第三,指定ADC的事件回调函数(类似于中断服务函数):

//Step 3.  
  adc_continuous_evt_cbs_t cbs = {
        .on_conv_done = s_conv_done_cb,
    };
    ESP_ERROR_CHECK(adc_continuous_register_event_callbacks(handle, &cbs, NULL));

   这里,选择在中断服务函数中读取ADC转换结果,并设置两个全局的互锁标志,防止主循环在中断服务函数更新ADC数据的同时读取同一个数组,导致数据混乱和错误:

static bool IRAM_ATTR s_conv_done_cb(adc_continuous_handle_t handle, const adc_continuous_evt_data_t *edata, void *user_data)
{
    BaseType_t mustYield = pdFALSE;
    vTaskNotifyGiveFromISR(s_task_handle, &mustYield);
    is_adc_buf_busy = true;
    adc_continuous_read(handle, fj08k_buffer, ADC_FRAME_LEN, &fj08k_actual_read_num, 0);
    is_adc_buf_busy = false;
    is_adc_buf_loaded = true;
    return (mustYield == pdTRUE);
}

   第四,启动ADC,并进入主循环:

ESP_ERROR_CHECK(adc_continuous_start(handle));

5.频率和占空比计算

   遍历采样数组,并寻找一个完整的方波周期(即“上升沿——下降沿——上升沿”或“下降沿——上升沿——下降沿”)。统计完整周期内的点数,结合采样率,就可以计算出频率;统计完整周期内大于上阈值和小于下阈值的点数,就可以计算出占空比。具体代码实现如下:

enum fj08k_state_type{
    UNCERTAIN,
    WAIT_1_EDGE_FALLING,
    WAIT_1_EDGE_RISING,
    START_COUNTING,
    WAIT_2_EDGE_FALLING,
    WAIT_2_EDGE_RISING,
    WAIT_3_EDGE_FALLING,
    WAIT_3_EDGE_RISING,
    DONE
};
//......
enum fj08k_state_type state_temp;
//......
for(i = 0; i < fj08k_actual_read_num; i += 2){
                    adc_digi_output_data_t *p = (void*)&fj08k_buffer[i];
                    switch(state_temp){
                        case UNCERTAIN:{
                            if(p->type1.data > FJ08K_THRESHOLD_HIGH)state_temp = WAIT_1_EDGE_FALLING;
                            else if(p->type1.data < FJ08K_THRESHOLD_LOW)state_temp = WAIT_1_EDGE_RISING;
                            break;
                        }
                        case WAIT_1_EDGE_FALLING:{
                            if(p->type1.data < FJ08K_THRESHOLD_LOW)state_temp = WAIT_2_EDGE_RISING;
                            break;
                        }
                        case WAIT_1_EDGE_RISING:{
                            if(p->type1.data > FJ08K_THRESHOLD_HIGH)state_temp = WAIT_2_EDGE_FALLING;
                            break;
                        }
                        case WAIT_2_EDGE_FALLING:{
                            if(p->type1.data > FJ08K_THRESHOLD_HIGH)pos_cnt ++;
                            else if(p->type1.data < FJ08K_THRESHOLD_LOW)state_temp = WAIT_3_EDGE_RISING;
                            break;
                        }
                        case WAIT_2_EDGE_RISING:{
                            if(p->type1.data < FJ08K_THRESHOLD_LOW)neg_cnt ++;
                            else if(p->type1.data > FJ08K_THRESHOLD_HIGH)state_temp = WAIT_3_EDGE_FALLING;
                            break;
                        }
                        case WAIT_3_EDGE_FALLING:{
                            if(p->type1.data > FJ08K_THRESHOLD_HIGH)pos_cnt ++;
                            else if(p->type1.data < FJ08K_THRESHOLD_LOW)state_temp = DONE;
                            break;
                        }
                        case WAIT_3_EDGE_RISING:{
                            if(p->type1.data < FJ08K_THRESHOLD_LOW)neg_cnt ++;
                            else if(p->type1.data > FJ08K_THRESHOLD_HIGH)state_temp = DONE;
                            break;
                        }
                        case DONE:{
                            break;
                        }
                        default:;
                    }
                    if(state_temp == DONE)break;
                }
                frequency = ADC_SAMP_FREQ / (neg_cnt + pos_cnt);
                duty_cycle = pos_cnt * 100 / (neg_cnt + pos_cnt);
                is_adc_buf_loaded = false;

   其实上述代码就是一个有限状态机,它首先判断数组的首个点是位于正脉冲还是负脉冲之中,随后依照上面提到的方法寻找一个完整的方波周期。这里规定了帧长度为512字节,也就是256样点,采样率为20kHz,所以采样周期为0.05ms,一帧对应的时间为12.8ms,大于最小频率(238Hz)的周期4.2ms,因此无论如何一定能找到完整的周期。

   互锁控制变量is_adc_buf_loaded和is_adc_buf_busy,分别表示“自上一次读取之后,数组中的数据有无更新”,以及“当前时刻数组是否正在被写入或读取”。

6.施密特触发器滤波

   如果在屏幕上显示出frequency和duty_cycle(前提是要帧率足够高,否则会造成频率和占空比很稳定的假象),则会发现这两个数据一直在大约±10的范围内抖动。如果直接用raw data进行判断的话,势必引入极大的噪声。

   我曾尝试过用我在STEP PICO水平仪项目中的环形滤波器,但我很快注意到这两个问题的差异。环形滤波器虽然能有效滤除抖动,但它的缺点也很明显——那就是一个时间序列中的跳变边沿都被极大地平缓且延迟了。由于水平仪的使用场景就是稳定态,因此这一问题几乎没有影响,但摇杆控制动画却是一个很吃实时性的功能。实测也证明,采用环形滤波器会引入显著的操控延时,这显然不是我想要的。

   因此,我简单地想了一个使用施密特触发器的思路。将摇杆的运动分解为横向(horizontal)和纵向(vertical)两个维度,它们分别由frequency和duty_cycle表征。以判断横向的静止与向左滑动为例,摇杆位于中心时,认为是属于”横向静止(SCHMIDT_HORIZONTAL_STATIC )“状态。当频率低于一个阈值(SCHMIDT_STATIC_TO_LEFT_FREQ)时,就认为发生了”向左滑动“操作,并进入”向左滑动(SCHMIDT_SHIFTING_LEFT)“状态,在该状态下,小球会持续朝左移动,直至达到屏幕边缘。如果在”向左滑动“状态下,检测到频率低于另一个阈值(SCHMIDT_LEFT_TO_STATIC_FREQ),则认为摇杆回到了”横向静止“状态。注意SCHMIDT_LEFT_TO_STATIC_FREQ必须小于SCHMIDT_STATIC_TO_LEFT_FREQ,两者中间最好留出10左右的余量。

   据此方法,可以分别设计出判断右滑、上滑、下滑的逻辑,具体代码实现如下:

enum fj08k_horizontal_state{
    SCHMIDT_HORIZONTAL_STATIC,
    SCHMIDT_SHIFTING_LEFT,
    SCHMIDT_SHIFTING_RIGHT
};

enum fj08k_vertical_state{
    SCHMIDT_VERTICAL_STATIC,
    SCHMIDT_SHIFTING_UP,
    SCHMIDT_SHIFTING_DOWN
};
//......
enum fj08k_horizontal_state state_horz = SCHMIDT_HORIZONTAL_STATIC;
enum fj08k_vertical_state state_vert = SCHMIDT_VERTICAL_STATIC;
uint8_t curr_x = 64, curr_y = 64;
//......
                 switch(state_horz){
                    case SCHMIDT_HORIZONTAL_STATIC:{
                        if(frequency < SCHMIDT_STATIC_TO_LEFT_FREQ)
                            state_horz = SCHMIDT_SHIFTING_LEFT;
                        else if(frequency > SCHMIDT_STATIC_TO_RIGHT_FREQ)
                            state_horz = SCHMIDT_SHIFTING_RIGHT;
                        break;
                    }
                    case SCHMIDT_SHIFTING_LEFT:{
                        if(frequency > SCHMIDT_LEFT_TO_STATIC_FREQ)
                            state_horz = SCHMIDT_HORIZONTAL_STATIC;
                        if(curr_x > 4)curr_x -= 3;
                        break;
                    }
                    case SCHMIDT_SHIFTING_RIGHT:{
                        if(frequency < SCHMIDT_RIGHT_TO_STATIC_FREQ)
                            state_horz = SCHMIDT_HORIZONTAL_STATIC;
                        if(curr_x < 121)curr_x += 3;
                        break;
                    }
                    default:break;
                }

                switch(state_vert){
                    case SCHMIDT_VERTICAL_STATIC:{
                        if(duty_cycle < SCHMIDT_STATIC_TO_UP_DUTY)
                            state_vert = SCHMIDT_SHIFTING_UP;
                        else if(duty_cycle > SCHMIDT_STATIC_TO_DOWN_DUTY)
                            state_vert = SCHMIDT_SHIFTING_DOWN;
                        break;
                    }
                    case SCHMIDT_SHIFTING_UP:{
                        if(duty_cycle > SCHMIDT_UP_TO_STATIC_DUTY)
                            state_vert = SCHMIDT_VERTICAL_STATIC;
                        if(curr_y > 4)curr_y -= 3;
                        break;
                    }
                    case SCHMIDT_SHIFTING_DOWN:{
                        if(duty_cycle < SCHMIDT_DOWN_TO_STATIC_DUTY)
                            state_vert = SCHMIDT_VERTICAL_STATIC;
                        if(curr_y < 121)curr_y += 3;
                        break;
                    }
                    default:break;
                }

   本次设计的是简化版,即默认两个方向的分速度绝对值都只能是0或3。若要随着摇杆的位置变化速度,只需先以frequency和duty_cycle为衡量(最好采用float来表示),划分出几档,然后以每一档的设定值为中心,分别设置施密特阈值(赋予它们滞回特性),则可以实现有效消抖的速度分档。

7.屏幕驱动优化

   理论上,项目到此就结束了。但如果直接按照上面的思路组合起来,会发现屏幕刷新的速度只有目测0.4fps左右,慢得跟PPT一样。而问题的本质,就出在”gpio_set_level“这个函数上。

esp_err_t gpio_set_level(gpio_num_t gpio_num, uint32_t level)
{
    GPIO_CHECK(GPIO_IS_VALID_OUTPUT_GPIO(gpio_num), "GPIO output gpio_num error", ESP_ERR_INVALID_ARG);
    gpio_hal_set_level(gpio_context.gpio_hal, gpio_num, level);
    return ESP_OK;
}

#define gpio_hal_set_level(hal, gpio_num, level) gpio_ll_set_level((hal)->dev, gpio_num, level)

static inline void gpio_ll_set_level(gpio_dev_t *hw, uint32_t gpio_num, uint32_t level)
{
    if (level) {
        if (gpio_num < 32) {
            hw->out_w1ts = (1 << gpio_num);
        } else {
            hw->out1_w1ts.data = (1 << (gpio_num - 32));
        }
    } else {
        if (gpio_num < 32) {
            hw->out_w1tc = (1 << gpio_num);
        } else {
            hw->out1_w1tc.data = (1 << (gpio_num - 32));
        }
    }
}

对于我们的应用来说,这个函数显然封装地太过严实和臃肿了。试想一下:要”设置引脚电平“,我用寄存器编程就是往一个32位地址写一个32位立即数的事儿,但如果要用gpio_set_level的话,所消耗的时钟周期(所被编译成的汇编指令数)却直接翻了数倍不止。而”设置引脚电平“恰恰是LCD驱动中调用的最频繁的一个功能。因为要模拟时序,所以传输一个像素点就需要调用16(bit)*2(wire)*2(edge)=64次,刷一次屏就要调用64*128*128 = 1048576次。可以想象这其中的优化空间是何等的巨大。

   无奈之下,我只得祭出自己所整过的唯一拿得出手的花活:寄存器编程。

   在soc/gpio_reg.h中,我如愿地找到了4个关键的寄存器:

#define GPIO_OUT_W1TS_REG          (DR_REG_GPIO_BASE + 0x8)
#define GPIO_OUT_W1TC_REG          (DR_REG_GPIO_BASE + 0xC)
#define GPIO_OUT1_W1TS_REG          (DR_REG_GPIO_BASE + 0x14)
#define GPIO_OUT1_W1TC_REG          (DR_REG_GPIO_BASE + 0x18)

   这显然是分别控制低32位(GPIO0~31)和高32位(GPIO32~63)的置1和置0寄存器。只要对应到LCD驱动所使用的引脚,就能获得在置1/0时所需要写入的立即数:

#define SCL_MASK (0x1UL << 9) //41-32 = 9
#define SDA_MASK (0x1UL << 21)
#define DCX_MASK (0x1UL << 17)
#define CSX_MASK (0x1UL << 13)

   由此一来,我在SCL上制造一个上升沿,顶多只需要:

        *(volatile unsigned int*)GPIO_OUT1_W1TC_REG = SCL_MASK;
        *(volatile unsigned int*)GPIO_OUT1_W1TS_REG = SCL_MASK;   

   这大概会被编译成四句汇编:两句将立即数装入通用寄存器,两句将通用寄存器的内容写入存储,执行速度直接快了数倍不止。据此,我重新封装了一个尽可能高效的writeByte函数:

enum FAST_WB_MASK{
    FAST_WB_MASK_8  = 0x00000080u,
    FAST_WB_MASK_16 = 0x00008000u,
    FAST_WB_MASK_24 = 0x00800000u,
    FAST_WB_MASK_32 = 0x80000000u
};

//这个函数专门用来快速写入8~32bit的数据,mask为独热编码,指向data的MSB
//为了通用性,这个函数不会管CS和DC,只保证SCL的CPOL=0特性
inline void st7735_fast_write_single(uint32_t data, enum FAST_WB_MASK mask){
    *(volatile unsigned int*)GPIO_OUT1_W1TC_REG = SCL_MASK;
    while(mask){
        if(data & mask)
            *(volatile unsigned int*)GPIO_OUT_W1TS_REG = SDA_MASK;
        else
            *(volatile unsigned int*)GPIO_OUT_W1TC_REG = SDA_MASK;
        *(volatile unsigned int*)GPIO_OUT1_W1TS_REG = SCL_MASK;
        mask >>= 1;
        *(volatile unsigned int*)GPIO_OUT1_W1TC_REG = SCL_MASK;        
    }
}

   由此一来,很多原先的LCD函数都可以优化,比如setPos函数(设置驱动芯片的像素指针):

inline void setPos( int sx, int ex, int sy, int ey ) {
	writeCmdData( 0x2a, LCMD );
	writeCmdData( 0x00, LDAT );
	writeCmdData( sx+2, LDAT );
	writeCmdData( 0x00, LDAT );
	writeCmdData( ex+2, LDAT );

	writeCmdData( 0x2b, LCMD );
	writeCmdData( 0x00, LDAT );
	writeCmdData( sy+3, LDAT );
	writeCmdData( 0x00, LDAT );
	writeCmdData( ey+3, LDAT );

	writeCmdData( 0x2c, LCMD );
}

   这里一个命令带4个参数,完全可以把4个参数看成1个32bit进行传输:

inline void st7735_fast_set_pos(void){
    *(volatile unsigned int*)GPIO_OUT_W1TC_REG = DCX_MASK;
    st7735_fast_write_single(0x2a,FAST_WB_MASK_8);
	*(volatile unsigned int*)GPIO_OUT_W1TS_REG = DCX_MASK;
    st7735_fast_write_single(0x00020081,FAST_WB_MASK_32);

    *(volatile unsigned int*)GPIO_OUT_W1TC_REG = DCX_MASK;
    st7735_fast_write_single(0x2b,FAST_WB_MASK_8);
    *(volatile unsigned int*)GPIO_OUT_W1TS_REG = DCX_MASK;
    st7735_fast_write_single(0x00030082,FAST_WB_MASK_32);

    *(volatile unsigned int*)GPIO_OUT_W1TC_REG = DCX_MASK;
    st7735_fast_write_single(0x2c,FAST_WB_MASK_8);
}

   更不必说刷屏函数,完全可以直接连续写入:

inline void st7735_refresh_fast(void){
    uint16_t size = LCD_HEIGHT * LCD_WIDTH;
    uint16_t *p = (uint16_t *)st7735_buffer[0];
    *(volatile unsigned int*)GPIO_OUT_W1TC_REG = CSX_MASK;

    st7735_fast_set_pos();

    *(volatile unsigned int*)GPIO_OUT_W1TS_REG = DCX_MASK;
    while(size){
        st7735_fast_write_single(*p,FAST_WB_MASK_16);
        p ++;
        size --;
    }
    *(volatile unsigned int*)GPIO_OUT_W1TS_REG = CSX_MASK;
}

   经过改动之后,屏幕的刷新率直接到了目测10fps以上(在直接显示frequency和duty_cycle的值时,通过拨动摇杆,可以观察到一秒内最多有10个以上的数据闪过),效果是非常明显的。

   接下来就是动点显示的问题。直观的思路是每次都先清屏,然后重新画圆,但这事实上是根本没有必要的。因为圆和背景的大小都是固定的,除此之外再无其他元素,也就是说,前后两帧之间存在着大量的信息重叠,而fail to utilize这样的信息重叠会增加耗能和耗时:因为每次都要对整个数组进行写入。

   我们可以换一个角度,即:不是认为圆在动,而是认为背景在动(反正两者相对运动)。或者说,并不是需要显示的(包含一个圆及其背景区域的)数组发生了改变,而是开始显示的位置发生了改变。

   比如说,数组中的图像始终只是一个圆处在正中央。但当我要表示”圆左移了一个像素“的时候,我不是去改变圆的位置,而是把初始的数组指针右移一个元素,然后去刷屏。只要对128求余(&=0x7f),右边溢出的那1个像素又会重新回到左边,处理上下移动时也是一样的道理。因此,我专门写了一个处理运动圆的函数:

inline void st7735_refresh_move_fast(uint8_t offset_x, uint8_t offset_y){
    uint8_t x,y;
    *(volatile unsigned int*)GPIO_OUT_W1TC_REG = CSX_MASK;
    st7735_fast_set_pos();
    *(volatile unsigned int*)GPIO_OUT_W1TS_REG = DCX_MASK;
    for(y=offset_y;y < (offset_y | 0x80); y ++)
        for(x=offset_x;x < (offset_x | 0x80); x ++){
            st7735_fast_write_single(st7735_buffer[x&0x7f][y&0x7f],FAST_WB_MASK_16);
        }
    *(volatile unsigned int*)GPIO_OUT_W1TS_REG = CSX_MASK;
}

我设置的初始值是一个位于中心(64,64),r=4的圆。经过推导,该函数与curr_x和curr_y的映射关系应该如下:

st7735_refresh_move_fast((192-curr_x) & 0x7f, (192-curr_y) & 0x7f);

这样就实现了很高效的绘制动圆的函数。

 

四、项目总结

   虽然本次项目实现的功能并不复杂,但对我而言是相当有意义的。

   第一,是极大地丰富了使用SDK进行开发的经验,以及检索信息和阅读资料的能力(我现在拿着英文资料就生啃,其味无穷)。

   第二,是在项目实现的过程中随做随想出了一些小技巧,比如施密特触发器滤波,还有动圆的高效绘制。尤其是绘制动圆,之前我并没有认真想过可以用这种思路去做,但现在做出来了,我认为这个方法是相当富有艺术感的(哦,赞美位运算!)。此外,我也很高兴看见我的老伙计”寄存器编程“在这里发挥了决定性的关键作用,这至少证明我之前死磕STM32底层的收获不至于是毫无意义的。

   第三,则是看到了自己的不足。本来我是打算用网络通信和M1S DOCK来一个联动的,但我在网络编程方面确实学艺不精,对freeRTOS更是一窍不通(我能从概念上定位它——然而这也是我对它的全部理解了),以至于忙活了很久最终还是没有做出来。我认为这次的项目让我找到了一个很好的开发平台——而且我至少已经把它用起来了,那么在日后我去开拓这两块知识荒漠的时候,这次的项目经历也许就能为我提供很好的起点和支持。

 

五、附录

硬件框图:

FpRrEUVgpKJnMH2svsz8146_JaEn

软件框图:

Fs8k20lBHgj3p2Gt6o8lOgv8rEHP

注:B站视频里面的演示效果会更加直观,因为摇杆控制是动态的。

演示效果:

FoJv5sR5oS0jtTrG5QdfA7M0fNGn

附件下载
mywork.c
团队介绍
本项目为2023年寒假在家练项目,单人独立完成,成员为南京大学电子学院2019届本科生。
团队成员
赵雨飞
南京大学电子学院2019级本科生,研究生方向为人工智能芯片设计
评论
0 / 100
查看更多
目录
硬禾服务号
关注最新动态
0512-67862536
info@eetree.cn
江苏省苏州市苏州工业园区新平街388号腾飞创新园A2幢815室
苏州硬禾信息科技有限公司
Copyright © 2023 苏州硬禾信息科技有限公司 All Rights Reserved 苏ICP备19040198号