基于Step Pico实现的水平仪
本项目使用基于RP2040芯片的STEP PICO核心板,搭配扩展板上的三轴传感器MMA7660FC以及OLED屏幕,实现了一个水平仪。
标签
嵌入式系统
显示
2023寒假在家练
电子卷卷怪
更新2023-03-29
南京大学
509

一、项目概述

   本项目使用硬禾课堂设计的基于RP2040的STEP PICO核心板,通过配套扩展板上的MMA7660FC三轴加速度传感器芯片以及SSD1306驱动的128*64 OLED屏幕,实现了一款简易的水平仪。该水平仪能够显示x轴和y轴的倾斜角,并同时显示一个指示当前平面偏移方向和程度的小球,当当前平面水平时,小球位于屏幕(显示区域)的中心。

 

二、项目思路与结构

0.测量原理:

   由于水平仪使用的场景绝大多数都是稳态或说静止状态,因此根据F=ma,某一轴上读出的”加速度“实际上就是该轴上所受支持力的分量。当芯片倾侧不同角度时,各个轴上受力也相应变化,从而可以分析出当前的姿态。

1.总体思路:

   RP2040是一款cortex-m0+架构的芯片,从而本项目的思路,从本质上讲就是使用SDK封装库进行编程,通过交叉编译工具编译生成可执行文件,并通过烧录工具烧录到芯片。

   STEP PICO提供了至少两种推荐的开发途径:通过MicroPython开发,以及通过C-SDK开发。这两种开发方式实际上处于不同的层次。通过MicroPython开发,事实上就是先烧录一个运行在RP2040上的Python解释器(或一个包含Python解释器的操作系统),通过Python解释器这个接口来间接调用片上资源;而通过C-SDK开发出来的程序,可以认为与Python解释器处于同一层次。

   本项目选择在WSL2上使用C-SDK开发。因为我认为这更有助于我锻炼自己使用SDK进行开发的能力——毕竟,像我开发STM32那样直接写寄存器代码的方式,终究不是长久之策(开发周期太长,效率太低)。作为嵌入式开发爱好者,应该兼顾知识的深度与广度。

2.具体思路:

   观察开发板的原理图,并确定需要使用的外设,然后再调用C-SDK中封装好的标准库进行开发即可。

   本项目涉及的模块较少。MMA7660FC是I2C协议,SSD1306是SPI协议,这意味着在开发中将会使用到与GPIO,SPI,I2C相关的标准库。通过学习SDK配套的例程,可以快速掌握相关库函数的使用。

 

三、实现过程:

1.创建项目:

   我认为直接在例程上进行修改并不是好的做法,因此选择从新建立项目。

   通过学习官方的getting started指南,以及观察例程项目的结构,发现该SDK采用CMAKE工具管理与组织项目结构。这是我首次接触CMAKE工具,并在Linux系统(WSL2)上使用它,在我的理解中,CMAKE是一个实用的工具,它使得(像我这种程度的)开发者可以从艰深繁难的makefile编写以及目标构建关系中解脱出来,更加专注于项目实际功能的开发。

   指南中的示例为:

cd pico-examples
mkdir build
cd build
export PICO_SDK_PATH=../../pico-sdk
cmake ..
cd blink
make

   观察pico-examples下的CMakeLists.txt:

......
add_subdirectory(adc)
add_subdirectory(clocks)
......

   这些恰是当前目录下的文件夹名称,因此在此目录下新建文件夹,同时在CMakeLists末尾添加一句:

add_subdirectory(mytests)

   我理解的CMAKE就是一个makefile生成器,每一级子目录下的CMakeLists(如果存在的话)构成一个表示项目结构的图(Graph),因此观察项目的目录结构很重要:(以uart例程为例)

cd ./uart
tree -L 2
.
├── CMakeLists.txt
├── hello_uart
│   ├── CMakeLists.txt
│   └── hello_uart.c
├── lcd_uart
│   ├── CMakeLists.txt
│   ├── README.adoc
│   ├── lcd_uart.c
│   ├── lcd_uart.fzz
│   └── lcd_uart_bb.png
└── uart_advanced
    ├── CMakeLists.txt
    └── uart_advanced.c

第一层的CMakeLists.txt为:

if (NOT PICO_NO_HARDWARE)
    add_subdirectory(hello_uart)
    add_subdirectory(lcd_uart)
    add_subdirectory(uart_advanced)
endif ()

第二层的其中一个CMakeLists.txt为:

add_executable(hello_uart
        hello_uart.c
        )

# pull in common dependencies
target_link_libraries(hello_uart pico_stdlib)

# create map/bin/hex file etc.
pico_add_extra_outputs(hello_uart)

# add url via pico_set_program_url
example_auto_set_url(hello_uart)

   则各级关系显然,从而可以仿照此关系编辑自己新建的目录:

(还是保密吧):~/pico/pico-examples/mytests$ tree -L 3
.
├── CMakeLists.txt
└── mma7660fc
    ├── CMakeLists.txt
    ├── include
    │   ├── mma7660fc.h
    │   └── oled.h
    └── mma7660fc.c

其中mma7660fc路径下的CMakeLists.txt是关键:

add_executable(mma7660fc
        mma7660fc.c
)

target_link_libraries(mma7660fc pico_stdlib hardware_spi hardware_i2c)

include_directories(./include)

pico_add_extra_outputs(mma7660fc)

   至此项目创建完成。

2.外设映射

   观察原理图可以发现,引脚映射关系为:

   GPIO8 - OLED_RES;GPIO9 - OLED_DC;GPIO10 - OLED_SCK;GPIO11-OLED_SDA;

   GPIO14 - I2C_SDA;GPIO15 - I2C_SCL。

   其中OLED_RES和OLED_DC是需要我们手动操控的。观察以下文件:

cd ~/pico/pico-sdk/src/rp2_common/hardware_gpio/include/hardware/
vim gpio.h

   它会告诉我们外设到GPIO的整个映射矩阵:

/*
...... 
*  GPIO   | F1       | F2        | F3       | F4     | F5  | F6   | F7   | F8            | F9
 *  -------|----------|-----------|----------|--------|-----|------|------|---------------|----
......
 *  10     | SPI1 SCK | UART1 CTS | I2C1 SDA | PWM5 A | SIO | PIO0 | PIO1 |               | USB VBUS DET
 *  11     | SPI1 TX  | UART1 RTS | I2C1 SCL | PWM5 B | SIO | PIO0 | PIO1 |               | USB VBUS EN
......
 *  14     | SPI1 SCK | UART0 CTS | I2C1 SDA | PWM7 A | SIO | PIO0 | PIO1 |               | USB VBUS EN
 *  15     | SPI1 TX  | UART0 RTS | I2C1 SCL | PWM7 B | SIO | PIO0 | PIO1 |               | USB OVCUR DET
*/

   其中SIO就是通用GPIO的意思。则映射关系显然:需要将I2C1和SPI1映射出去。

3.GPIO初始化

   仿照SPI与I2C例程,很容易写出GPIO的初始化代码:

        stdio_init_all();
//......
        //init i2c1 instance
        //GPIO mapping on GP14, GP15
        i2c_init(&i2c1_inst,100*1000);
        gpio_set_function(14,GPIO_FUNC_I2C);
        gpio_set_function(15,GPIO_FUNC_I2C);
        gpio_pull_up(14);
        gpio_pull_up(15);

  SPI的初始化放在了oled_init()函数里:

void oled_init(void){
        spi_init(spi1,1000 * 1000);
        gpio_set_function(10,GPIO_FUNC_SPI);
        gpio_set_function(11,GPIO_FUNC_SPI);
        gpio_init(OLED_RESET_PIN);
        gpio_set_dir(OLED_RESET_PIN,GPIO_OUT);

        gpio_init(OLED_DC_PIN);
        gpio_set_dir(OLED_DC_PIN,GPIO_OUT);
//......
}

   在设置SPI和I2C通信速率时,应参考数据手册。SSD1306要求时钟周期不能短于100ns,MMA7660FC要求时钟的正脉宽和负脉宽分别大于0.7us和1.3us,不应超过这些参数的限制。

4.三轴传感器读取

   MMA7660FC的从机地址出厂规定为0x4c,读取和写入寄存器都需要先写入地址,前者在写入之后不需要停止位,后者则需要。在开始读取之前,需要先写入传感器的配置寄存器。实现水平仪只需要读取传感器的X轴数据和Y轴数据,其他功能理论上可以不必考虑。

   寄存器的功能在数据手册上写得很清楚。需要配置的寄存器共有两个,其中0x07寄存器写入0x01,以激活芯片并禁用其自动休眠功能;0x08寄存器写入0x01,配置采样率为每秒钟64次:

        //write 0x01 to R(0x08)
        i2c_tx_buf[0] = 0x08;
        i2c_tx_buf[1] = 0x01;
        i2c_write_blocking(&i2c1_inst,MMA7660FC_SLAVE_ADDR,i2c_tx_buf,2,false);

        //write 0x01 to R(0x07)
        i2c_tx_buf[0] = 0x07;
        i2c_tx_buf[1] = 0x01;
        i2c_write_blocking(&i2c1_inst,MMA7660FC_SLAVE_ADDR,i2c_tx_buf,2,false);

   随后只需要在主循环中重复读取0x00(XOUT)和0x01(YOUT)两个寄存器(的低6位)即可。

   值得注意的是:第一,XOUT和屏幕的x轴没有任何关系,YOUT也一样,在显示的时候需要根据具体的PCB布板做一个简单的转换。第二,读出来的有效数据是二进制补码的格式,数据手册上是有一个补码对应真实数据的转换表的,顺便提一句,隔壁FPGA+STM32的例程里面,是直接把这个数据当成unsigned char显示了,因此会出现OLED上显示着000然后突然跳到063(-1的6位补码)的奇怪现象。

4.屏幕驱动

   在内存足够(RP2040有256KB+8KB)的情况下,采用GRAM的方式有利于使屏幕驱动的思路和实现变得非常简洁。

   第一,要明确SPI的CPOL和CPHA两个关键参数。通过数据手册得知,SSD1306在SCK上升沿采样SDA,因此只要把CPOL和CPHA设成都是0或都是1就行(SDK的初始化是默认两个都是0的)。

   第二,要明确芯片的初始化序列。针对这块开发板的例程只有Python版本,那么只需要把Python例程的初始化序列“借鉴”过来即可:

#define SET_CONTRAST 0x81
#define SET_ENTIRE_ON 0xa4
#define SET_NORM_INV 0xa6
#define SET_DISP 0xae
#define SET_MEM_ADDR 0x20
#define SET_COL_ADDR 0x21
#define SET_PAGE_ADDR 0x22
#define SET_DISP_START_LINE 0x40
#define SET_SEG_REMAP 0xa0
#define SET_MUX_RATIO 0xa8
#define SET_IREF_SELECT 0xad
#define SET_COM_OUT_DIR 0xc0
#define SET_DISP_OFFSET 0xd3
#define SET_COM_PIN_CFG 0xda
#define SET_DISP_CLK_DIV 0xd5
#define SET_PRECHARGE 0xd9
#define SET_VCOM_DESEL 0xd8
#define SET_CHARGE_PUMP 0x8d

#define INIT_SEQUENCE_LEN 27

const uint8_t ssd1306_init_sequence[INIT_SEQUENCE_LEN]={
        SET_DISP,
        SET_MEM_ADDR,
        0x00,
        SET_DISP_START_LINE,
        SET_SEG_REMAP | 0x01,
        SET_MUX_RATIO,
        0x3f,
        SET_COM_OUT_DIR | 0x08,
        SET_DISP_OFFSET,
        0x00,
        SET_COM_PIN_CFG,
        0x12,
        SET_DISP_CLK_DIV,
        0x80,
        SET_PRECHARGE,
        0xf1,
        SET_VCOM_DESEL,
        0x30,
        SET_CONTRAST,
        0xff,
        SET_ENTIRE_ON,
        SET_NORM_INV,
        SET_IREF_SELECT,
        0x30,
        SET_CHARGE_PUMP,
        0x14,
        SET_DISP | 0x01
};

   第三,则是字符显示和画圆函数。

   我设计这些函数的原则就是尽可能地简洁和高效(我几乎都想上汇编了)。

   对于字符显示,只要找一个8x6的字库即可。为了程序的简洁性和效率,我规定所有的字符显示必须与屏幕的页(page)对齐,也就是说,字符(串)的左上角y坐标只能是8的整倍数,这样8x6字符的每一列就正好与芯片存储中的一个字节对齐,省去了麻烦的移位和分次写入操作:

//之所以减去0x20,是因为我找的字库数组是从空格符开始的
void oled_putc(uint8_t page, uint8_t col, unsigned char c){
        if((c > 0x7a) || (c <= 0x20)){
                oled_gram[page][col] = F6x8[0][0];
                oled_gram[page][col+1] = F6x8[0][1];
                oled_gram[page][col+2] = F6x8[0][2];
                oled_gram[page][col+3] = F6x8[0][3];
                oled_gram[page][col+4] = F6x8[0][4];
                oled_gram[page][col+5] = F6x8[0][5];
        }//不在范围内的字符就认为不能显示,全都变成空格
        else{
                oled_gram[page][col] = F6x8[c-32][0];
                oled_gram[page][col+1] = F6x8[c-32][1];
                oled_gram[page][col+2] = F6x8[c-32][2];
                oled_gram[page][col+3] = F6x8[c-32][3];
                oled_gram[page][col+4] = F6x8[c-32][4];
                oled_gram[page][col+5] = F6x8[c-32][5];
        }
}

   字符串显示也力图简洁:

void oled_puts(uint8_t page, uint8_t col, unsigned char* str){
        unsigned char *p;
        uint8_t i;
        p = str;
        while(*p != 0x00){
                oled_putc(page,col,*p);
                p ++;
                col += 6;
        }
}

   由于我最终想显示的是角度(通过LUT获取),因此设计了浮点显示函数:

void oled_put_float(uint8_t page, uint8_t col, float data, uint8_t num){
        uint8_t num1 = 1;
        if(data < 0.0f){
                data = -1.0f*data;
                oled_putc(page,col,'-');
                col += 6;
        }
        while(data >= 10.0f){
                num1 ++;
                data /= 10.0f;
        }
        for(num1;num1>0;num1--){
                oled_putc(page,col,(uint8_t)data + 48);
                data -= (int)data;
                data *= 10.0f;
                col += 6;
        }
        oled_putc(page,col,'.');
        col += 6;
        for(num;num>0;num--){
                oled_putc(page,col,(uint8_t)data + 48);
                data -= (int)data;
                data *= 10.0f;
                col += 6;
        }
}

   最后则是画圆函数。这里我设计的是:x=64~127的区域显示数值,x=0~63的区域显示小球,这样看起来不仅美观,还正好巧妙地利用了返回数据的6位补码性质。

   在小球显示区域,我打算画一个圆心为(32,32),半径32的大圆,做出一个像罗盘一样的效果。

   一个最简单的画圆函数是:

void oled_draw_circle(uint8_t x, uint8_t y, uint8_t r){
        uint8_t i,dy,j,dx;
        for(i = x - r; i <= x + r; i ++){
                dy = (uint8_t)sqrt(powf(r*1.0f,2.0f)-powf(i*1.0f-x*1.0f,2.0f));
                oled_draw_point(i,y+dy);
                oled_draw_point(i,y-dy);
        }
}

但半径一旦大到大概10以上,在圆周的中心附近(处在r-dy~r+dy范围内的圆周)就会出现显著的断点现象,究其本质,是因为这个函数只采取了x方向的遍历。而如果采用下面这种方式:

void oled_draw_circle(uint8_t x, uint8_t y, uint8_t r){
        uint8_t i,dy,j,dx;
        for(i = x - r; i <= x + r; i ++){
                dy = (uint8_t)sqrt(powf(r*1.0f,2.0f)-powf(i*1.0f-x*1.0f,2.0f));
                oled_draw_point(i,y+dy);
                oled_draw_point(i,y-dy);
        }
        for(j = y - r; j <= y + r; j ++){
                dx = (uint8_t)sqrt(powf(r*1.0f,2.0f)-powf(j*1.0f-y*1.0f,2.0f));
                oled_draw_point(x+dx,j);
                oled_draw_point(x-dx,j);
        }
}

虽然不会有断点,但会因为边缘太过厚重而失去美感。因此综合考虑下,采用了以下方式:

void oled_draw_circle1(uint8_t x, uint8_t y, uint8_t r){
        uint8_t r0,i,j,dx,dy;
        r0 = (uint8_t)(r*1.0f / sqrt(2.0f));
        for(i = x - r0; i < x + r0; i ++){
                dy = (uint8_t)sqrt(powf(r*1.0f,2.0f)-powf(i*1.0f-x*1.0f,2.0f));
                oled_draw_point(i,y+dy);
                oled_draw_point(i,y-dy);
        }
        for(j = y - r0 - 1; j < y + r0 + 1; j ++){
                dx = (uint8_t)sqrt(powf(r*1.0f,2.0f)-powf(j*1.0f-y*1.0f,2.0f));
                oled_draw_point(x+dx,j);
                oled_draw_point(x-dx,j);
        }
}

这相当于用一个圆内接正方形的两条对角线,将圆周分成了4个区域,对于x密集而y稀疏的区域,就采用y方向遍历以确保不会因y的稀疏而导致断点,反之亦然(限于篇幅这里没法铺开讲,但实测的效果是比较明显的)。

   最后,我打算用一个半径为3的实心小球来显示当前平面的偏斜情况。

   观察按照unsigned char解释的6位补码序列:

   (32,33,……,62,63,0,1,……,30,31)

   这个序列加上32,再对64求余(&= 0x3f),就是:

   (0,1,……,30,31,32,33,……,62,63)

   95减去这个序列,在对64求余(&= 0x3f),就是:

   (63,62,……,33,32,31,30,……,1,0)

   也就是说不管传感器的X/Y轴和屏幕的X/Y轴是何种对应关系,都可以建立一个简单的映射关系。(其实只要考虑到偏置,则显示区域是无关紧要的。而我这样考虑,也只是因为我觉得这种做法相比用if-else去求真值而言,显得更加艺术——哦,赞美位运算!)

5.环形滤波器

   按照上面所述的步骤,我实现出了一个视觉体验并不友善的版本——实心小球总是在一个区域附近频繁地跳动,像极了一个疯狂做着热运动的电子。究其本质,是因为传感器的噪声直接耦合了过来。在这么小的屏幕区域内,即使只是1LSB的噪声,变化都会很显眼。为此,我设计了一个简单的环形滤波器:

#define FILTER_LENGTH 8
#define FILTER_MASK 0x07
#define FILTER_SHIFT 3
uint8_t filter[2][FILTER_LENGTH] = {0};
uint8_t fp_x = 0,fp_y = 0;

uint8_t filter_x(uint8_t curr_x){
        uint8_t i;
        uint16_t temp;
        filter[0][fp_x] = curr_x;
        fp_x++;
        fp_x &= FILTER_MASK;
        temp = 0;
        for(i=0;i<FILTER_LENGTH;i++){
                temp += filter[0][i];
        }
        temp >>= FILTER_SHIFT;
        return temp;
}

uint8_t filter_y(uint8_t curr_y){
        uint8_t i;
        uint16_t temp;
        filter[1][fp_y] = curr_y;
        fp_y++;
        fp_y &= FILTER_MASK;
        temp = 0;
        for(i=0;i<FILTER_LENGTH;i++){
                temp += filter[1][i];
        }
        temp >>= FILTER_SHIFT;
        return temp;
}

   这样,每次采到值之后,小球的坐标就不是当前值,而是最近的、包含当前值在内的若干个采样值的平均。应用了这种方法之后,显示效果大大地提升了。

四、项目总结

   总的来说,这个项目实现得比较完整。最重要的是习惯了一种使用SDK(而不是和底层硬刚)的嵌入式开发思维。与此同时,我原本对Linux是非常惧怕和陌生的,但坚持在Linux系统下使用C-SDK开发使得我对Linux系统变得更加熟悉和喜爱了。(嗯,真香。)

 

五、附录

硬件框图:

FvfP7mmce_xnpfqVmCXp2oBI3KyX

软件框图:

FogVUZ5RxLIVAt8Rs3WK6j46pRva

功能演示1:平放

FpuZiekEXsuh3Gp95DcNfnC9G6Ta

功能演示2:侧放

FtKqUHjIpyKzN2SfyfAtc_fPWdm0

功能演示3:侧放

Fh_7WRcWmP-Q3--nkxaQMcKD5P2e

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