一、项目概述
本项目使用硬禾课堂设计的基于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系统变得更加熟悉和喜爱了。(嗯,真香。)
五、附录
硬件框图:
软件框图:
功能演示1:平放
功能演示2:侧放
功能演示3:侧放