本文介绍了一位视障学生在科研训练中开发的辅助视力残疾人士的导引系统,该项目最终获得全国嵌入式竞赛国家级三等奖。系统基于VL53L1X传感器实现障碍物检测、报警及环境提示功能,兼具技术探索与人文关怀。
前言
这个项目最初是我在学校科研训练中的一个选题,后经各方面的优化和方案的迭代,最终参加了全国大学生嵌入式…(忘了)竞赛芯片应用组瑞萨电子赛道,最终获得国家级三等奖,虽然奖项不大,但是一路以来的开发过程是非常有趣的,加之笔者也是视障的原因导致这个选题变得更加的有意义 (地狱) ,所以分享给大家。
项目概述
系统要求
系统的设计目的是为了给视力残疾人士提供导引,报警,以及对周围人和车辆的提示,因此功能大致分为如下几个部分:
障碍物检测:系统通过基于VL53L1X激光测距传感器的tof400c模块进行实时的距离检测和数据处理,当距离小于设定的临界值,系统发出语音提示和报警。
跌倒检测:系统通过加速度传感器MPU6050检测当前的姿态,包含加速度和角速度,通过识别人摔倒时的特征,触发紧急情况下的报警。
光线检测:系统通过自带ADC和光敏电阻检测当前光照强度,当光线低于设定值,可以设置警示灯在此情况下点亮,提示路人和车辆。警示灯也可以手动设定开启和关闭状态。
显示:在OLED 12864显示器上显示当前的距离,光照,温度信息,方便开发人员的调试。
定位和报警:系统在手动操作或自动检测紧急情况后,触发报警。报警时,系统会通过GPS模块和WLAN融合定位来确定当前的位置信息,并通过地图API来获取该经纬度对应的地点文字描述。信息将编码成电子邮件发送给紧急联系人,可以在邮件中通过超链接跳转到地图软件。同时,报警功能还会发出声音,引起注意。
上几张我们作品决赛时的图片:
这是机身:


这是接收的数据:

硬件和开发工具
需要的硬件:
- 主控:RA6M5(CPKIOT系列开发板)
- 网络控制器:ESP32-C3
- MPU6050
- 蜂鸣器
- LED
- SSD1306 OLED 128x64
- VL53L1X
- 任意支持NMEA输出的GPS模块
- 任意支持GBK编码解码的TTS语音模块
- 按键和连接线若干
开发工具:
- e2 studio和适用于RA6M5的
renesas FSP
- Arduino IDE 2+(pio对ESP32C3的USB串口兼容性有些问题)
minicom
等UART串口开发工具和JLink RTT Viewer
(用于RA芯片的日志输出)
一些开发心得
先把开源仓库贴出来,里面的代码都有详细的解释->【戳我】
主控部分
瑞萨开发指南
开发瑞萨RA系列的芯片需要使用e2 studio集成开发环境,在安装程序运行时会自动安装部分驱动程序和FSP支持包,同时,如果需要使用外部/内部的SWD接口进行下载和调试,需要安装JLink支持工具,如果需要通过串口下载编译的hex文件,则需要下载瑞萨的UART烧录工具。
- e2 Studio:下载
- J-Link工具:下载
- 瑞萨下载工具:下载
- 瑞萨USB驱动程序:下载
我的项目使用C语言,非RTOS开发,使用R7FA6M5BH3CFP
芯片,在新建项目时,需要选择名称,项目类型,芯片和对应FSP版本,操作系统支持以及加密操作空间等选项,笔者选择了最简单的。
瑞萨的开发工具有着与STM32CubeMX
相似的HAL管理逻辑,在工程目录下,点击comfiguration.xml
进入FSP管理器,可以自由配置引脚输入输出、非GPIO的高级功能,外设堆栈等。为了开发和理解的简单,笔者已经将常见的Read/Write
,UART
,I2C
,delay
,Timer
,printf
等操作进行了二次封装,变成了类似于Arduino代码的形式,大家可以下载我的开源代码,在mk_
开头的库文件中了解外设的读写和启停操作,在FSP配置工具中,点击i
字母也可以打开官方的说明文档,里面也有关于FSP函数的详细解释(虽然是英文)。
现在简单介绍一下管理器中外设的配置方式:
在管理器下方的操作栏中:
bsp
用于定义芯片和开发板类型,修改使用的板级支持包
pins
用于修改引脚(Pxxx
)的模式、中断和初始状态,如果是GPIO,需要在此处指定,如果是外设功能引脚,需要在此处进行解绑,释放给periph
模式
stack
用于添加和删除各种外设(如串口、时钟、外部中断等),并通过属性界面进行详细的调整,包括参数设置、中断和优先级选择以及引脚分配。
Generate
用于将代码自动部署到工程文件中
我们以初始化UART为例:
- 进入
stack
,添加一个串行接口设备(如g_uart0
),属性面板中指定波特率、校验、中断函数、优先级、以及使用的硬件SCIx
外设通道。
- 下载你使用的开发板的引脚定义图,找到串口对应的引脚。或者在已知使用串口外设编号的情况下,直接在选项卡里找到
SCIx
,导览到这组串口IO。
- IO口名称应为类似于
P411
的组合,4是Port,11是Pin。在引脚设置中,如果你已经注册了stack
中的设备,那么只需要Enable这个引脚并取消所有的GPIO功能,功能复选框中将自动设置为periph mode
。至此,一个外设初始化成功。
- 回到
stack
确定引脚不是null
,按Ctrl+S
保存,点击Generate
生成。
其他外设的操作也是类似的,瑞萨对每个外设的操作有一套通用形式:
_Open(pCtrl, pCfg);
初始化
_Write(pCtrl, pData)
读写操作,数据和Ctrl控制对象均使用指针传递
_Callback(pArgs)
中断函数,传入的参数中可以解析出标志位、寄存器、缓冲区等信息
可以在hal_data.h
中查看所有自己定义的外部设备句柄,包括_ctrl
和_cfg
对象。其余的常规操作,可以使用AI工具查找和参考笔者在开源仓库的文档,此处不再赘述。
外设驱动支持
所有本项目使用的外设,其驱动文件都已进行移植并包含于开源仓库文件中,此处特别感谢:
- OLED:江科大的STM32 OLED驱动程序
- Serial接收:江科大的51单片机串口缓冲程序
- VL53L1X:HFUT的智能车竞赛开源项目
- GPS:
minmea project
烧录和调试
在编写完成,且指定了系统默认的调试器和编译器后,就可以进行编译和下载了,按下Ctrl+B
可以进行编译,在顶栏选择Run->Debug
可以使用JLink调试工具进行下载和调试,选中后,系统会自动初始化JLink工具和gdb,程序二进制文件此时被自动下载到开发板,且锁定到程序入口之前的断点。当下载完成之后,IDE会显示每个函数断点的地址,此时点击工具栏的开始按钮,若未发生内存溢出或访问外设失败的情况,程序会进入main loop,开发板上的零部件也会开始工作。
值得注意的几点是:
- 有些板子没有自带JLink接口,而瑞萨默认的SWD调试工具是JLink,所以如果没有JLink硬件的话,建议使用串口下载。在
C/C++ properties
设置中,选择生成的文件类型为Intel HEX
,重新执行编译,在Debug/
目录下就可以找到程序的hex文件,此时,我们需要把板子的启动选择跳线连接到下载一侧,连接电脑,在下载工具中新建实例,选择对应的COM口后,快速按下Reset
按键和电脑建立连接,然后加入HEX文件下载,下载后,板子断电,启动跳线连接另一侧,通电就可以运行程序啦。
- 如果在调试时一直卡在
DefaultExeption
而且脱机也无法进入程序循环,那么一般是因为初始化或者访问外设资源的方式不正确,建议重新参考源码或文档。
- 使用
SEGGER RTT Viewer
来读取dbg_logi()
发送的日志时,我们需要在编译之后找到Debug/<projectName>.map
文件,Ctrl+F
搜索SEGGER_RTT
,你会看到:
把这个地址复制下来,输入到监视器软件的地址框(而不是让软件自动检测),就可以打开RTT串口通道了。dbg_logi()
的输出格式会带一个时间戳,格式为[timestamp]context
。
ESP32-C3部分
负责接收串口指令、定位、解析数据、发送邮件。下面简单讲述一下代码结构和一些设计思路。
串口数据接收
Arduino库函数简化了串口数据处理的中断注册和逐个字节接收的过程,我们只需要在主循环中查询Serial.available()
参数,即可对串口缓冲区进行处理。由于需要识别特定的指令以及提取String
对象进行比较,此处和RA的接收函数一样约定了指令字符串的识别格式,打包成了pollSerial()
函数,具体函数如下;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| void pollSerial1() { while (Serial1.available()) { char c = Serial1.read();
if (c == '\n' || c == '\r') { cmdBuffer[cmdIndex] = '\0'; cmdIndex = 0;
if (cmdBuffer[0] == '#') { command = String(cmdBuffer + 1); cmdReady = true; } } else { if (cmdIndex < CMD_BUFFER_SIZE - 1) { cmdBuffer[cmdIndex++] = c; } else { cmdIndex = 0; } } } }
|
接着,就可以与RA6M5进行握手了。
连接Wi-Fi
ESP32对于无线网络提供了丰富的系统调用,最简单的连接方法是传入代表SSID和密码的两个字符串,但出于灵活配置的需要,我没有采取硬编码,而是使用了以下逻辑:
- 上电后:读取Flash特定地址的信息,SSID和密码各32字节,加入字符串。
- 判断字符串,如果非空则尝试连接网络,成功则进入主循环。
- 连接超时或字符串为空:关闭WiFi连接进程,使能一个HTTP Server,发起一个简单的网页,包含两个输入框和一个确认按钮,用于收集SSID和密码。
- 当收到表单的提交后,将SSID和密码存储到Flash的对应地址,使用
esp.restart()
重启系统,重新尝试连接操作。
此处涉及的代码较多,详见开源仓库,下面展示部分代码(可以用折叠按钮隐藏):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125
| #include <WiFi.h> #include <WebServer.h> #include <EEPROM.h>
void Wifi_init_process(); void LoadWifiConfig(void); void saveWifiConfig(String newssid, String newpswd); bool ConnectWifi(); void handleRoot(); void handleSave();
String ssid = ""; String pswd = "";
#define EEPROM_SIZE 64 #define SSID_ADDR 0 #define PSWD_ADDR 32
void LoadWifiConfig(void) { char pswd_buf[32] = {0}; char ssid_buf[32] = {0}; EEPROM.begin(EEPROM_SIZE); EEPROM.get(SSID_ADDR, ssid_buf); EEPROM.get(PSWD_ADDR, pswd_buf); EEPROM.end(); ssid = String(ssid_buf); pswd = String(pswd_buf); }
void saveWifiConfig(String newssid, String newpswd) { EEPROM.begin(EEPROM_SIZE); int i = 0; while(newssid[i]){ EEPROM.write(SSID_ADDR+i, newssid[i]); delay(1); i++; } EEPROM.write(SSID_ADDR+i, 0); i = 0; while(newpswd[i]){ EEPROM.write(PSWD_ADDR+i, newpswd[i]); delay(1); i++; } EEPROM.write(PSWD_ADDR+i, 0); delay(2); EEPROM.end(); delay(2); }
bool ConnectWifi() { WiFi.begin(ssid.c_str(), pswd.c_str()); Serial.print("Connecting"); int attempt = 0; while (WiFi.status() != WL_CONNECTED && attempt < 10) { delay(1000); attempt++; Serial.print("."); } Serial.println(); Serial.printf("[INFO] Wi-Fi state: %d\n", WiFi.status() == WL_CONNECTED); return WiFi.status() == WL_CONNECTED; }
void handleRoot() { server.send(200, "text/html", "<html><body>" "<h1>ESP32 Wi-Fi Configure</h1>" "<form action='/save' method='POST'>" "SSID: <input type='text' name='ssid'><br>" "Password: <input type='password' name='pass'><br>" "<input type='submit' value='Save'>" "</form></body></html>" ); }
void handleSave() { if (server.hasArg("ssid") && server.hasArg("pass")) { String newSSID = server.arg("ssid"); String newPass = server.arg("pass"); saveWifiConfig(newSSID, newPass); server.send(200, "text/plain", "Wi-Fi config saved, rebooting..."); Serial.println("[INFO] Configuration saved, system will reboot after 2 secs..."); Serial1.println("@CONFIG_END"); delay(2000); ESP.restart(); } else { server.send(400, "text/plain", "Error!"); } }
void Wifi_init_process() { LoadWifiConfig();
if (ssid.length() > 0 && ConnectWifi()) { Serial.println("[INFO] WiFi Connected!"); Serial1.println("@WL_CONNECTED"); } else { WiFi.softAP("ESP32_Config", "12345678"); IPAddress IP = WiFi.softAPIP(); Serial1.println("@NEED_CONFIG"); Serial.printf("[INFO] Please configure network: IP: %d.%d.%d.%d\n", IP[0], IP[1], IP[2], IP[3]);
server.on("/", handleRoot); server.on("/save", handleSave); server.begin(); while(1) { server.handleClient(); } } }
|
EMail的发送
邮件的发送一开始使用了ESPMail
的库函数,使用QQ邮箱发送,后发现内存开销太大以至于无法运行JSON解析和打包程序,于是经过了笔者的研究,使用了一个无base64
的测试邮件平台——mailtrap
,在注册账号和获取实例(我使用了自己名下的域名进行绑定,这样发出的邮件是我网站的后缀,其他方法详见官网文档)后,你会获得一个账号和密码,下面有两种发送方式:
- SMTP协议:传统的电子邮件接口协议,在ESP32中初始化一个HTTP Client,对文档中指定服务器接口依次发送指定格式的请求信息和邮件内容字符串,可以实现邮件的发送。
- RESTful API:此平台提供的一种便捷方式,笔者使用的方法,向网站发送一个HTTP请求即可。(格式参见开源ESP32源码)
本次开发中的发送代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| void sendMail(String s) { String smtp_server = SMTP_HOST; int smtp_port = SMTP_PORT; String smtp_user = AUTHOR_EMAIL; String smtp_pass = AUTHOR_PASS; String sender_email = AUTHOR_EMAIL; String recipient_email = mail_addr; String author_from = AUTHOR_FROM;
if (WiFi.status() == WL_CONNECTED) { HTTPClient http; http.begin("https://send.api.mailtrap.io/api/send"); http.addHeader("Authorization", "Bearer " + smtp_pass); http.addHeader("Content-Type", "application/json");
String postData = "{"; postData += "\"from\":{\"email\":\"" + author_from + "\",\"name\":\"" + String("ESP32 Dev Module") + "\"},"; postData += "\"to\":[{\"email\":\"" + mail_addr + "\"}],"; postData += "\"subject\":\"" + String("Caution") + "\","; postData += "\"text\":\"" + s + "\""; postData += "}";
int httpResponseCode = http.POST(postData);
if (httpResponseCode > 0) { String response = http.getString(); Serial.print(httpResponseCode == 200 ? "[INFO] Sent" : "[Warning] Send failed: Service error"); if(httpResponseCode != 200) { Serial.print(", errorCode: "); Serial.println(httpResponseCode); } else Serial.println(); } else { Serial.println("[Warning] Send failed: Connect failure\n"); } http.end(); } }
|
使用网络API进行定位
由于廉价GPS模块在室内时常表现不佳,笔者引入了网络融合定位功能。在国内,这种技术十分常见,但是对于个人开发者较难获得授权,笔者使用了对嵌入式开发者友好且免费的WAYZ
平台。
服务器需要收集环境WiFi的MAC地址和信号强度,我们需要通过ESP32的系统调用,获取BSSID、时间戳、信号强度、随机序列号等信息,信息通过ArduinoJSON
库函数进行打包后,向服务器提交GET
请求。在解码后,可以获取经纬度、区划、具体地址字符串 等定位结果信息。信息传递给上述邮件发送程序进行发送。
参见:开源项目链接 官网
定位代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134
| #include <time.h> #include <ArduinoJson.h> #include <WiFi.h> #include <HTTPClient.h>
struct GpsLocation { float latitude; float longitude; char valid; String name; } loc;
#define UUID "your_uuid" #define KEY "your_key"
typedef struct { String mac; signed char rssi; } ap_info_typedef;
ap_info_typedef ApInfo_array[10]; int ApInfo_count = 0;
void wifi_scan() { ApInfo_count = WiFi.scanNetworks(); delay(300); if (ApInfo_count == 0) { return; } else { for (int i = 0; i < ApInfo_count; ++i) { ApInfo_array[i].rssi = WiFi.RSSI(i); ApInfo_array[i].mac = WiFi.BSSIDstr(i); delay(10); } Serial.printf("[INFO] Wi-Fi scanned, count: %d\n", ApInfo_count); return; } }
DynamicJsonDocument doc(1024); DynamicJsonDocument rep(1024);
const long gmtOffset_sec = 8 * 3600; const int daylightOffset_sec = 0;
void getTimeStamp() {
configTime(gmtOffset_sec, daylightOffset_sec, "ntp.aliyun.com", "ntp.ntsc.ac.cn", "pool.ntp.org");
struct tm timeinfo; while (!getLocalTime(&timeinfo)) { delay(500); } time_t now = time(nullptr); Serial.printf("[INFO] Current timestamp:%d\n", now); }
String JsonSerialization() { String message; unsigned long long timestamp = time(nullptr); int64_t timestamp_ms = timestamp * 1000LL + random(1000);
doc["timestamp"] = timestamp_ms; doc["id"] = "esp32-" + String(random(1000000)); doc["asset"]["id"] = UUID; doc["asset"]["manufacturer"] = "espressif"; doc["location"]["timestamp"] = time(nullptr); for(int i = 0;i < min(10, ApInfo_count);i++) { doc["location"]["wifis"][i]["macAddress"] = ApInfo_array[i].mac; doc["location"]["wifis"][i]["signalStrength"] = ApInfo_array[i].rssi; } serializeJson(doc, message); Serial.printf("\nTransmit to the server:\n %s\n", message.c_str()); return message; }
bool get_loc_online(GpsLocation *loc) { getTimeStamp(); ap_info_typedef ApInfo; wifi_scan(); String msg_to_send = JsonSerialization(); HTTPClient http; http.begin("https://api.newayz.com/location/hub/v1/track_points?access_key=<your_key>"); http.addHeader("Content-Type", "application/json"); http.addHeader("Host", "api.newayz.com"); http.addHeader("Connection", "keep-alive"); int httpCode = http.POST(msg_to_send); String payload = http.getString(); DeserializationError error = deserializeJson(rep, payload); if (error) { Serial.println("[Warning] Failed to parse JSON response"); delay(1000); return false; } double longitude = rep["location"]["position"]["point"]["longitude"]; double latitude = rep["location"]["position"]["point"]["latitude"]; const char * name = rep["location"]["address"]["name"]; const char * source = rep["location"]["position"]["source"]; const char * spatialReference = rep["location"]["position"]["spatialReference"]; String full_result; serializeJson(rep, full_result); http.end(); loc->latitude = latitude; loc->longitude = longitude; loc->name = String(name); loc->valid = 1; Serial.println(); Serial.printf("[INFO] Get Location: lat:%f, lon:%f\n", latitude, longitude); Serial.println(); Serial.printf("The full JSON Data is:\n %s", full_result.c_str()); Serial.println(); return true; }
|
烧录和下载
笔者的ESP32使用USB-CDC技术,简单来说,它的USB口不是像传统的开发板一样用一个USB-to-TTL来连接到芯片的串行接口,而是直接连接了芯片的USB外设,电脑上的串口是芯片模拟出来的,因此,我们需要一些设置来更好的使用这个串口:
启用USB CDC支持:在Arduino IDE顶栏的工具配置栏将USB CDC Support
设置为Enabled
跳过CDC自检:在setup()代码中加入一条Serial.setTxTimeoutMs(0);
,避免在找不到电脑时卡在死循环
然后插上板子点击IDE的Download
就行了,很傻瓜。
成果展示
视频->【点我】
参赛的碎碎念和总结
虽然我是负责所有技术和开发工作的,但其实比赛负责人不是我,所以很多内幕也就不知道了,我也懒得过问这些事情,这些是纯粹的参赛心得还有需要避雷的地方。
雷点是很多老师为了冲业绩可能会对比赛内容夸大或者对赛事主办方的要求含糊其辞,要仔细阅读官方文档,仔细评估比赛的难度和作品迁移可行性!
不是随便填个选题就行!这个填了就不能大改,也就是要一直顺着这个大的主线做下去
不是拿个stm32代码改一下就行!这玩意没有想象中的那么简单,包括其他的那些赛道(比如RK、海思、RT-Thread什么的),每个平台都有自己的特性和学习曲线,请善用文档和AI工具,对自己的熟练度做好评价,别随随便便就付钱了结果大批大批的人没回本
还有这一套下来挺烧钱的,尤其是想拿一等奖的,基本上都需要完整的外壳和可以用于实际场景的可靠性,像我这样的概念模型也只能三等奖了
然后是全流程:
报名:学校会组织,去官网把信息填了就行,学校可能会预支你板子,或者你付押金自己申请
初赛:提交演示视频、正式说明文档、电路原理图、包含开发板的不同角度照片、开源仓库地址等
区域赛:前往省内的承办学校,向评委展示作品,一般在提交海选的2周之内可以看到参赛资格,时间一般在暑假(7月20日前后)
国赛:在8月中旬,全国参赛选手携带作品前往决赛场地,持续3天,包含签到、编程能力测评、答辩、领奖几个环节,现场可以看到很多优秀作品,也可以和很多科技企业的负责人近距离交流。
比赛没有纸质奖状,可以付费申请PCB奖状,邮寄送达,同时进入国赛也可以领到PCB参赛证和纪念服装。
一点碎照片,大家就当游记吧:





然后这应该是我最近打完的比较大的比赛了,也许是我的最后一个国家级比赛,但我希望不是hhh~