一、写在前面
前段时间算是完赛了24年的电赛初赛,在正赛及赛前一个月的集训里一直负责串口屏交互的代码编写和UI设计,稍微积攒了一些经验,想着把自己踩过的坑写出来给后人们排雷。在集训初期摸索这个串口屏的打开方式时,笔者发现虽然网络上有大量基于淘晶驰串口屏的使用例程,但实操中可能存在环境不同、库不匹配、版本不同、硬件连线出错等问题,对于为了打电赛从零开始学习串口屏通信的同学来说很搞心态,一些小bug会卡很久很久(我就在这个破玩意儿上卡了一周多)。因此写这篇博客也算是尽一些绵薄之力把容易踩的坑排除一下。
二、软硬件配置及环境
硬件方面:
(1) STM32G474RETx单片机系统板
(2) 淘晶驰X5系列10.1存串口屏(电容触摸版)
上位机UI工程烧录工具采用官方USB转TTL套件
软件方面:
(1) CLion 2024.1.4 (截至20240729最新版)
(2) CubeMX 6.12.0 (截至20240729最新版)
(3) USART HMI(于淘晶驰官方资料中心下载)淘晶驰资料中心 — 淘晶驰资料中心 1.1.0-2024-08-06 17:45:50 文档 (tjc1688.com)http://wiki.tjc1688.com/
三、上位机工程
关于上位机工程的具体制作方法在淘晶驰官方网站(即上方链接)中有详解,堪称保姆级,这也是采用淘晶驰串口屏的好处之一,UI设计确实很好上手。此处仅对上位机工程中与串口通信相关的控件进行解释。
首先解释一下淘晶驰串口屏的通信协议。
对于从串口屏发出数据到单片机,官方在上位机程序中给出了prints和printh两种函数。笔者所写例程中均使用prints函数发送定长为2的字符串,并规定第一个字符为操作位,第二个字符为具体操作值,这样在代码中也比较便于处理。下图是官方prints函数说明页面的部分截取,其中lenth部分写0则代表发送整个完整的字符串。不过由于代码是处理定长数据的,为了防止溢出,建议仍对lenth写2,即发送2字节。注意所发送数据需要带“ ”号,否则会被当作数值类型发送。
对于从单片机回传数据至串口屏,数据的结束符为“0XFF 0XFF 0XFF”三个字节。
现给出笔者设计的UI各控件的返回值如下:
(1)【“操作位+操作值” 型返回值】
· 数字键盘,返回值 “N + 数字(字符型)”
如,按键b6的功能为数字键盘“1”,则按下后返回字符串“N1”
· “调节”按钮,返回值 “O + 数字(字符型)”
如,按键b0的功能为切换至对t0所示值进行调节,按下后返回字符串“O0”。此处建议按钮名、文本框名和返回值中的操作值相对应(b0->t0->O0),便于代码中进行操作。
· 步进按钮,返回值 “S + 数字(字符型)”
为适应本次电赛C题要求,特增加步进控件。按钮b8步进,返回“S0”;按钮b9为步退,返回“S1”。
(2)【纯字符型返回值】
· 退格键“CC”,确认键“EE”,重置键“RR”
· “CW”模式,“AM”模式
此控件也是为了适配电赛C题添加的。
配置好上位机工程后下载烧录到串口屏上即可,只要返回值格式正确,单片机就可以正确接收和处理串口屏发送的数据。UI部分可以自行设计,建议统一配色以获得更好的视觉效果。
注,在Program.s页中,笔者将波特率设置为115200;为保证串口通信正常,后续在CubeMX中也应按照115200设置串口波特率。
四、代码部分
首先自行安装CLion和CubeMX,并配置好联合开发环境,网络上教程很多,此处不赘述。
(一)CubeMX配置
串口屏和单片机的相互通信只占用两个IO口,只需要开启一个USART即可。本例程开启USART1。
· USART1
Mode选择Asynchronous,波特率配置115200,开启中断NVIC,其他默认
· SYS
Debug选Serial Wire,其他默认
· RCC
· CLOCK CONFIGURATION
· PROJECT MANAGER
Toolchain/IDE记得勾选STM32CubeIDE
配置完成后即可GENERATE CODE
(二)CLion代码编写
为了便于复用,主函数main.c里请尽量精简代码块,而将功能更多地写在外部.c文件中。本例程创建了一个HMI.h和一个HMI.c,需要复用时直接将两文件复制到相应CLion工程的Inc和Src文件夹中即可。
此处直接给出完整代码,注释写的还算清楚。
HMI.h
#ifndef __HMI_H__
#define __HMI_H__
typedef struct{
char name[100]; //项目名
char unit[100]; //项目所用单位
uint32_t buff; //缓冲区,存当前最后一个输入值
uint32_t output; //输出区,存实际输出值,函数中引入的变量请代入此值
uint32_t min; //最小输入范围
uint32_t max; //最大输入范围
uint32_t step; //步长
} ITEM; //对要输入输出的项目进行定义
extern ITEM items[50]; //至多50个项目
void HMIInit();
/*----------------------------------------------------*/
void HMISends(const char *buf1);
void HMISendb();
/*----------------------------------------------------*/
void HMIKeyboard(uint32_t opt, char *rx_byte);
uint32_t HMIChangeIndex(char *rx_byte);
#endif
此处声明了一个结构体ITEM,extern该结构体,当在其他文件中调用该,h文件时即可直接对结构体数组items[ ]的元素进行操作。
该结构体的元素即为你要计数的项目,配置项目的名称和单位并在代码中对上位机的各文本值进行修改,可便于直接在C代码中更改文本,避免频繁烧录上位机程序。
HMI.c
首先给出完整代码:
#include <math.h>
#include "main.h"
#include "usart.h"
#include "stdio.h"
#include "HMI.h"
ITEM items[50];
void HMIInit() //初始化items
{
for (int i = 0; i < 50; i++) {
items[i].name[0] = '\0'; //使用前记得重定义名称
items[i].unit[0] = '\0'; //使用前记得重定义单位
items[i].buff = 0;
items[i].output = 0;
items[i].min = 0;
items[i].max = 65535; //使用前记得修改上下限
items[i].step = 0;
}
}
/*----------------------------------------------------*/
void HMISends(const char *buf1)
{
if (buf1 == NULL) {
return; // 如果传入的字符串为空,直接返回
}
while (*buf1) // 检查字符串是否结束
{
HAL_UART_Transmit(&huart1, (uint8_t *)buf1, 1, HAL_MAX_DELAY); // 发送一个字节
buf1++; // 移动到下一个字符
}
}
void HMISendb()
{
uint8_t k=0xff;
for (uint8_t i = 0; i < 3; i++) {
HAL_UART_Transmit(&huart1, &k, 1, HAL_MAX_DELAY); // 发送一个字节
}
}
/*----------------------------------------------------*/
void HMIKeyboard(uint32_t opt, char *rx_byte)
{
int RxBuff;
RxBuff = items[opt].buff; //将当前操作对象在文本框中的显示值赋给缓冲区
switch (rx_byte[0]) //判断标识位
{
case 'N': //NUMBER //按下数字键,在文本框中更新当前待输出值,直到最大值max
RxBuff *= 10; //乘十进位
RxBuff += (rx_byte[1] - 48); //个位加上所键入的数
/*限制取值上限*/
if (RxBuff > items[opt].max) {
items[opt].buff = items[opt].max;
}
else {
items[opt].buff = RxBuff;
}
break;
case 'S': //STEP
if (rx_byte[1] == '0') { //STEP++
RxBuff += items[opt].step;
if (RxBuff > items[opt].max) {
items[opt].buff = items[opt].max;
}
else {
items[opt].buff = RxBuff;
}
}
else if (rx_byte[1] == '1') { //STEP--
RxBuff -= items[opt].step;
if (RxBuff < 0) {
items[opt].buff = 0;
}
else {
items[opt].buff = RxBuff;
}
}
break;
case 'E': //ENSURE //按下确认键,判断是否合法输出
int output_ready = 0; //输出标准
for (int j = 0; j < 6; j++) { //遍历,判断是否合法输出
if (items[j].buff < items[j].min) {
output_ready = 0;
break;
}
else output_ready = 1;
}
if (output_ready == 1) { //合法输出
for (int j = 0; j < 6; j++) {
items[j].output = items[j].buff;
}
char msg[100];
sprintf(msg,"func0.t6.txt=\"已输出\"");
HMISends(msg);
HMISendb();
}
else { //非法输出
char msg[100];
sprintf(msg,"func0.t6.txt=\"存在非法输入!请检查待输出值的范围!\"");
HMISends(msg);
HMISendb();
}
break;
case 'C': //CANCEL //按下退格键,在文本框中更新当前待输出值,直到最小值min
RxBuff /= 10; //除以十退位
/*下限为0,使键入待输出值更加方便*/
items[opt].buff = RxBuff;
break;
case 'R': //RESET //按下重置键,将所有buff还原至min,但不清除output
char msg[100];
sprintf(msg,"func0.t6.txt=\"已重置为各值下限\"");
HMISends(msg);
HMISendb();
for (int j = 0; j < 6; j++) {
items[j].buff = items[j].min;
}
break;
default:
break;
}
}
uint32_t HMIChangeIndex(char *rx_byte)
{
uint32_t index;
index = rx_byte[1] - 48;
return index; //返回当前opt值
}
各部分说明如下。
HMISends和HMISendb两函数在调用时需要一起使用:HMISends将你要发送的字符串拆分成一个个单独的字符发送,HMISendb发送终止符0xff0xff0xff。
/*--------------------使用例 BEGIN---------------------*/
char msg[100];
sprintf(msg,"Hello World");
HMISends(msg);
HMISendb();
/*--------------------使用例 END-----------------------*/
HMIKeyboard键盘部分的逻辑部分不复杂,自行阅读理解即可,大部分代码是在用上下限框定输入输出值的范围。读者可以自行添加控件,配置相应的返回值和功能,注意返回值是长度为2的字符串。
HMIChangeIndex用于在main.c中改变opt的值,opt对应于每个items[ ]的下标,也对应于上位机程序中相应控件的名称。
main.c
由于主函数自动生成部分过于冗长,且不同版本CubeMX自动生成代码部分存在差异,可能存在误导,此处仅将USER CODE部分按顺序给出,请读者自行在相应位置添加代码。
添加Include:
/* USER CODE BEGIN Includes */
#include "HMI.h"
#include "stdio.h"
#include "string.h"
/* USER CODE END Includes */
添加Private Variables:
/* USER CODE BEGIN PV */
uint32_t opt = 0; //代表当前进行的操作,opt即为手动配置项目时各项目的数组下标,且与上位机程序中的'O+option'回传相对应
char rx_byte[2]; //接收到的字符 //单次接收2字符,为标识位+操作位
uint32_t CW_ON = 1, AM_ON = 0; //切换CW或AM
/* USER CODE END PV */
在USER CODE 0添加中断回调函数:
/* USER CODE BEGIN 0 */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (rx_byte[0] == 'C' && rx_byte[1] == 'W') { //切换至CW
CW_ON = 1;
AM_ON = 0;
char msg[100];
sprintf(msg,"func0.t6.txt=\"已切换至 CW\"");
HMISends(msg);
HMISendb();
}
else if (rx_byte[0] == 'A' && rx_byte[1] == 'M') { //切换至AM
CW_ON = 0;
AM_ON = 1;
char msg[100];
sprintf(msg,"func0.t6.txt=\"已切换至 AM\"");
HMISends(msg);
HMISendb();
}
else if (rx_byte[0] == 'O') { //切换所调节的值
opt = HMIChangeIndex(rx_byte);
char msg[100];
sprintf(msg,"func0.t6.txt=\"正在调节:%s\"",
items[opt].name
);
HMISends(msg);
HMISendb();
strcpy(msg, '\0');
sprintf(msg,"func0.b8.txt=\"+%d%s\"",
items[opt].step,
items[opt].unit
);
HMISends(msg);
HMISendb();
strcpy(msg, '\0');
sprintf(msg,"func0.b9.txt=\"-%d%s\"",
items[opt].step,
items[opt].unit
);
HMISends(msg);
HMISendb();
}
else HMIKeyboard(opt, rx_byte);
/*------------手动配置输出 BEGIN----------------*/
for (int i = 0; i < 6; i++) {
char msg[100];
sprintf(msg,"func0.t%d.txt=\"%s:%d%s\"",
i,
items[i].name,
items[i].buff,
items[i].unit
);
HMISends(msg);
HMISendb();
}
/*------------手动配置输出 END------------------*/
HAL_UART_Receive_IT(&huart1, (uint8_t *)rx_byte, 2);
}
/* USER CODE END 0 */
代码的前半部分是赛时仓促添加的,较为冗杂,读者可以尝试将切换CW和AM以及切换opt等操作也封装入HMI.c中。
再次注意,该中断回调只接受长度为2的字符串。
在USER CODE 1自定义各计数项目的参数:
/* USER CODE BEGIN 1 */
/*-----------手动配置各项目的名称、单位、上下限 BEGIN--------------*/
HMIInit(); //items初始化
strcat(items[0].name, "直达信号载波频率\0");
strcat(items[0].unit, "MHz\0");
items[0].min = 30;
items[0].max = 40;
items[0].step = 1;
strcat(items[1].name, "载波幅度有效值\0");
strcat(items[1].unit, "mV\0");
items[1].min = 100;
items[1].max = 1000;
items[1].step = 100;
strcat(items[2].name, "AM调制度\0");
strcat(items[2].unit, "%\0");
items[2].min = 30;
items[2].max = 90;
items[2].step = 10;
strcat(items[3].name, "多径信号初始相位\0");
strcat(items[3].unit, "°\0");
items[3].min = 0;
items[3].max = 180;
items[3].step = 30;
strcat(items[4].name, "多径信号幅度衰减\0");
strcat(items[4].unit, "dB\0");
items[4].min = 0;
items[4].max = 20;
items[4].step = 2;
strcat(items[5].name, "多径信号时延\0");
strcat(items[5].unit, "ns\0");
items[5].min = 50;
items[5].max = 200;
items[5].step = 30;
/*-----------手动配置各项目的名称、单位、上下限 END----------------*/
/* USER CODE END 1 */
在USER CODE 2写入初始输出,并开启中断回调:
/* USER CODE BEGIN 2 */
/* 初始输出 */
for (int i = 0; i < 6; i++) {
char msg[100];
sprintf(msg,"func0.t%d.txt=\"%s:%d%s\"",
i,
items[i].name,
items[i].buff,
items[i].unit
);
HMISends(msg);
HMISendb();
}
char msg[100];
sprintf(msg,"func0.t7.txt=\"CW\"");
HMISends(msg);
HMISendb();
/* 进入中断回调 */
HAL_UART_Receive_IT(&huart1, (uint8_t *)rx_byte, 2);
/* USER CODE END 2 */
以上为所需的全部代码。
五、硬件连接
对于连线,最重要的就是保持单片机和串口屏的共地,且注意RX->TX,TX->RX。同时建议使用外部电源供电,因为常规的电脑USB接口只提供至多500mA的电流,对于点亮七寸以上的串口屏是比较吃力的,若通过单片机供电则容易电流过大烧坏单片机。淘晶驰官方基于F103单片机系统板给出了连线示意图如下。
笔者的实物连线图如下:
配置CubeMX时,我们设置的USART1,PC4为TX,PC5为RX,查数据手册获得端口位置如下:
共地时选取单片机上任意两个GND连线即可,共地设备较多时可以考虑用面包板。
六、最终效果展示
开机后,首先点击单片机上的RST按键,屏幕显示数值如下图所示:
点击“直达信号载波频率”的“调节”按钮,键入“35”,效果如下图所示:
点击“重置”,效果如下图所示:
点击“输出”,效果如下图所示:
七、一些小tips
(1)供电不稳或共地不良可能出现屏幕无法点亮的情况。用原装4pin连接线延伸屏幕接口,再用双公头杜邦线连接4pin连接线对应引脚和单片机母头GND,以获得更好的供电效果;
(2)屏幕初始亮度过亮也可能导致黑屏。在上位机程序的Program.s文件中将亮度变量“dim=100”修改为“dim=20”,亲测该亮度在室内不刺眼且显示清晰。还可以单独设置一个settings页面,用滑块控件修改亮度,具体参考淘晶驰官方滑块控件示例。
(3)若串口屏无法收到单片机回传的数据,再次检查:
· 单片机和串口屏是否共地
· 单片机的RX和TX是否正确对应连接串口屏的TX和RX
(4)新版CubeMX和CLion的联合开发环境中,若将外部.h文件复制到Inc文件夹并添加到CMake,会在CMakeLists.txt对LINKER_SCRIPT的配置添加一段代码,导致编译出错。修改为以.ld项结尾,如下:
八、一点小心得
关于电赛信号类题目的建议。
· 赛前各种模块该设计的设计,该测试的测试,该打板的打板。信号的输入输出端口同时预留SMA和插针以备不时之需;电源接口最好统一用接线端子或JST,不要直接用插针,一方面便于一眼识别电源接口不易接错,另一方面接线更稳不容易虚接;选取合适的芯片,使用前认真阅读数据手册,在规定的频段和偏置电压内使用芯片,有效防止芯片烧毁和自激。
· 供电可自己设计一个简单的电源模块,包括常用的电源端口如【-3V3, GND, +3V3】,【GND, +3V3】,【-5V, GND, +5V】,【GND, +5V】。若允许使用移动电源,可将模块外部接口设计为TYPE-C的PD输入,用支持PD协议的充电宝供电,相比直接使用实验台上的直流电流源,其波纹会更小。
· 设计简单的通用运放模块(0603或0805电阻电容本即可覆盖大部分所需值,同时预备好0Ω电阻和100MΩ电阻)可快速焊接积分器、微分器、加法器、有源滤波器等。
· 打板时预留LED电源指示灯可以快速检验模块是否成功上电,极大方便了断路、虚焊的检查。
· 各模块初次上电使用时,用实验台的直流电流源供电,记录各状态下的电流,调试时心里有个数。(一般情况下实验台的稳压还是挺稳的,如果突然出现明显的压降+电流猛增,99.99%是芯片烧了)
从学期末开始到七月底的电赛正赛期间确实是学了不少东西,尤其是提升动手实操能力,课内学习的仅仅是理论基础;电赛不是模电的DLC,而是模拟电路、数字电路、单片机基础等课程的综合体。希望大家能在电赛的集训和正赛过程中真真正正的收获知识,提升专业素养,这才是电赛的意义所在。最后,借用学长每次集训ppt的结尾:
(附两张我队最终成品剪影)