今日尝试开发一款简单好学的PC上位机无线控制二维云台的小试验品:
主要开发环境与工具介绍:
单片机 STM32F103C8T6 使用标准库函数编程
Visual Studio 2022软件C# Winform 开发 上位机控制软件
DL_20 无线串口模块 + USB-TTL 模块 实现无线通信功能
文章提供完整代码解释、设计点解释、测试效果图、完整工程下载
目录
主要用到的知识如下:
C# Winform上位机的编程:
窗体设计:
Form1初始化:
打开串口 控件函数:
串口接收 控件函数:
串口发送 控件函数:
头部/底部开始移动 控件函数:
一键归位 控件函数:
测试连接 控件函数:
创建日志委托 函数:
清除日志区 控件函数:
注意事项:
C# Winform 整体测试工程下载:
STM32F10xx 单片机的编程:
OLED的驱动显示:
PWM控制舵机运动:
初始化TIM3为舵机控制定时器:
设置TIM3占空比控制舵机运转:
串口接收与串口中断服务函数的编写:
STM32F103C8T6测试工程下载:
测试视频与图片:
主要用到的知识如下:
DL_20无线串口模块_dl20无线串口模块-CSDN博客
C#学习笔记10:winform上位机与西门子PLC网口通信_中篇_winform的窗口操作设计、日志的添加使用_c#网口通信界面-CSDN博客
C# Winform上位机的编程:
窗体设计:
主要用到的控件有Listview、imaginelist、button、checkbox、combobox、label 、serialport
Form1初始化:
//创建这个窗体的addlog ,需要绑定一个实际方法
private AddLog myaddlog;
bool Form1_FClosing = false;//用于防止二次Form1_FormClosing()事件发生的
string formattedLogMessage; //用于临时拼接字符串
bool OPEN_SERIAL_flag = false;//打开串口标志 false:未打开
int angle; //角度
public Form1()
{
InitializeComponent();
this.Load += Form1_Load;
myaddlog = this.AddLog;//绑定方法
serialPort1.Encoding = Encoding.GetEncoding("GB2312"); //串口接收编码
Control.CheckForIllegalCrossThreadCalls = false;
}
private void Form1_Load(object sender, EventArgs e)
{
设置第一列的宽度=整个宽度 减去 第0页宽度
lstInfo.Columns[1].Width = lstInfo.ClientSize.Width - lstInfo.Columns[0].Width;
for (int i = 1; i < 10; i++)//初始化串口 号下拉框内容
{
comboBox4.Items.Add("COM" + i.ToString()); //添加串口
}
for (int H = 0; H < 5; H++)//初始化串口 波特率下拉框内容
{
switch (H)
{
case 0: comboBox5.Items.Add("2400"); break;
case 1: comboBox5.Items.Add("4800"); break;
case 2: comboBox5.Items.Add("9600"); break;
case 3: comboBox5.Items.Add("115200"); break;
}
}
//停止位 下拉框内容
for (int j = 0; j < 3; j++)
{
switch (j)
{
case 0: comboBox7.Items.Add("1"); break;
case 1: comboBox7.Items.Add("1.5"); break;
case 2: comboBox7.Items.Add("2"); break;
}
}
comboBox4.Text = "COM1";//端口下拉框初始值
comboBox5.Text = "9600";//波特率下拉框初始值
comboBox7.Text = "1";//停止位
comboBox6.Text = "8";//数据位
serialPort1.Close(); //关闭串行端口连接
}
打开串口 控件函数:
//打开/关闭串口
private void button6_Click(object sender, EventArgs e)
{
if(OPEN_SERIAL_flag==false)
{
try
{
serialPort1.PortName = comboBox4.Text;//设置端口号
serialPort1.BaudRate = Convert.ToInt32(comboBox5.Text);//设置端口波特率
serialPort1.StopBits = (StopBits)Convert.ToInt32(comboBox7.Text);//设置停止位
serialPort1.DataBits = Convert.ToInt32(comboBox6.Text);//设置数据位
serialPort1.ReceivedBytesThreshold = 1;
serialPort1.DataReceived += new SerialDataReceivedEventHandler(serialPort1_DataReceived);
serialPort1.Open(); //打开串口
OPEN_SERIAL_flag = true; //标记打开了串口
myaddlog(0, "当前串口有设备连接,串口已成功打开");
button6.Text = "关闭串口";
}
catch
{
myaddlog(1, "错误警告: 端口无设备连接");
button6.Text = "打开串口";
}
}
else if(OPEN_SERIAL_flag == true)
{
try
{
serialPort1.Close(); //关闭串口
myaddlog(0, "已关闭串口 ");
OPEN_SERIAL_flag = false;
button6.Text = "打开串口";
}
catch { }
}
}
串口接收 控件函数:
用到的全局变量:
string formattedLogMessage; //用于临时拼接字符串
//串口接收
//一个接收数据事件获取串口发送来的数据
private void serialPort1_DataReceived(object sender, System.IO.Ports.SerialDataReceivedEventArgs e)
{
//处理事件这块可以加上延时确保不定数的数据可以全部收到缓冲后,才去读缓冲内容--单位: 毫秒
Thread.Sleep(50);
//如果16进制转换没被勾选
if (!checkBox1.Checked)
{
myaddlog(0, serialPort1.ReadExisting());
myaddlog(0, "串口消息接收回传:");
}
//如果16进制转换被勾选了
else
{
try
{
//定义缓冲区数组大小为串口缓冲区数据的字节数
//因为串口事件触发时有可能收到不止一个字节
byte[] data = new byte[serialPort1.BytesToRead];
serialPort1.Read(data, 0, data.Length);
foreach (byte Member in data) //遍历用法
{
string str = Convert.ToString(Member, 16).ToUpper();
formattedLogMessage = string.Format("0x" + (str.Length == 1 ? "0" + str : str) + " ");
myaddlog(0, formattedLogMessage);
}
myaddlog(0, "串口消息接收回传:");
}
catch { }
}
}
串口发送 控件函数:
//串口测试发送:
private void button7_Click(object sender, EventArgs e)
{
byte[] Data = new byte[1]; //单字节发数据
if (serialPort1.IsOpen)
{
if (textBox1.Text != "")
{
//如果不是16进制发送,就直接string形式发送
if (!checkBox2.Checked)
{
try
{
serialPort1.Write(textBox1.Text);
myaddlog(0, "单条发送成功");
//serialPort1.WriteLine(); //字符串写入
}
catch
{
myaddlog(1, "串口数据写入错误");
}
}
else //数据模式
{
try //如果此时用户输入字符串中含有非法字符(字母,汉字,符号等等,try,catch块可以捕捉并提示)
{
for (int i = 0; i < (textBox1.Text.Length - textBox1.Text.Length % 2) / 2; i++)//转换偶数个
{
Data[0] = Convert.ToByte(textBox1.Text.Substring(i * 2, 2), 16); //转换
serialPort1.Write(Data, 0, 1);
}
if (textBox1.Text.Length % 2 != 0)
{
//单独处理最后一个字符
Data[0] = Convert.ToByte(textBox1.Text.Substring(textBox1.Text.Length - 1, 1), 16);
serialPort1.Write(Data, 0, 1);//写入
}
//Data = Convert.ToByte(textBox2.Text.Substring(textBox2.Text.Length - 1, 1), 16);
myaddlog(0, "单条发送成功");
}
catch
{
myaddlog(1, "数据转换错误,请输入数字。");
}
}
}
}
}
头部/底部开始移动 控件函数:
//头部开始移动
private void button1_Click(object sender, EventArgs e)
{
bool success;//用于检查文本框textbox输入规范用
//先检查串口是否打开
if (serialPort1.IsOpen == false)
{
myaddlog(1, "无法发送内容,请检查 串口是否打开!");
}
else
{
//尝试转换 textBox2 角度的输入数值,看是否失败
success = int.TryParse(textBox2.Text.Trim(), out angle);
if (success == false && serialPort1.IsOpen)
{
myaddlog(1, "无法将框中内容转换,请检查 设定移动角度 的输入。");
}
else
{
if (Headleft.Checked && Headright.Checked)
{
myaddlog(1, "错误!头部移动方向 不可多选!");
}
else if (Headleft.Checked == false && Headright.Checked == false)
{
myaddlog(1, "错误!头部移动方向 并未选择!");
}
else if (Headleft.Checked == true && Headright.Checked == false)
{
//此处添加串口发送数据:
formattedLogMessage = string.Format("HL{0}&", textBox2.Text);
serialPort1.Write(formattedLogMessage);
formattedLogMessage = string.Format("已发送头部 移动方向为左 角度为{0}", textBox2.Text);
myaddlog(0, formattedLogMessage);
}
else if (Headleft.Checked == false && Headright.Checked == true)
{
//此处添加串口发送数据:
formattedLogMessage = string.Format("HR{0}&", textBox2.Text);
serialPort1.Write(formattedLogMessage);
formattedLogMessage = string.Format("已发送头部 移动方向为右 角度为{0}", textBox2.Text);
myaddlog(0, formattedLogMessage);
}
}
}
}
//底座开始移动
private void button2_Click(object sender, EventArgs e)
{
bool success;//用于检查文本框textbox输入规范用
//先检查串口是否打开
if (serialPort1.IsOpen == false)
{
myaddlog(1, "无法发送内容,请检查 串口是否打开!");
}
else
{
//尝试转换 textBox3 角度的输入数值,看是否失败
success = int.TryParse(textBox3.Text.Trim(), out angle);
if (success == false)
{
myaddlog(1, "无法将框中内容转换,请检查 设定移动角度 的输入。");
}
else
{
if (Buttomleft.Checked && Buttomright.Checked)
{
myaddlog(1, "错误!底部移动方向 不可多选!");
}
else if (Buttomleft.Checked == false && Buttomright.Checked == false)
{
myaddlog(1, "错误!底部移动方向 并未选择!");
}
else if (Buttomleft.Checked == true && Buttomright.Checked == false)
{
//此处添加串口发送数据:
formattedLogMessage = string.Format("BL{0}&", textBox3.Text);
serialPort1.Write(formattedLogMessage);
formattedLogMessage = string.Format("已发送底座 移动方向为左 角度为{0}", textBox3.Text);
myaddlog(0, formattedLogMessage);
}
else if (Buttomleft.Checked == false && Buttomright.Checked == true)
{
//此处添加串口发送数据:
formattedLogMessage = string.Format("BR{0}&", textBox2.Text);
serialPort1.Write(formattedLogMessage);
formattedLogMessage = string.Format("已发送底座 移动方向为右 角度为{0}", textBox3.Text);
myaddlog(0, formattedLogMessage);
}
}
}
}
一键归位 控件函数:
//一键归位
private void button3_Click(object sender, EventArgs e)
{
if (serialPort1.IsOpen)
{
formattedLogMessage = "RS&";
serialPort1.Write(formattedLogMessage);
myaddlog(0, "已发送归位测试字符串RS");
}
else
{
myaddlog(1, "无法发送内容,请检查 串口是否打开!");
}
}
测试连接 控件函数:
//测试连接
private void button4_Click(object sender, EventArgs e)
{
if (serialPort1.IsOpen)
{
formattedLogMessage = "TEST&";
serialPort1.Write(formattedLogMessage);
myaddlog(0, "已发送测试字符串TEST");
}
else
{
myaddlog(1, "无法发送内容,请检查 串口是否打开!");
}
}
创建日志委托 函数:
创建委托函数需要放置的位置:
//info 表示报警级别 ,log 表示报警信息
public delegate void AddLog(int info, string log);
//写入日志委托方法
//创建委托
private void AddLog(int info, string Log)
{
if (!lstInfo.InvokeRequired)
{
//创建ListViewItem ,将时间与info放进去
ListViewItem lst = new ListViewItem(" " + DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss"), info);
lst.SubItems.Add(Log);
lstInfo.Items.Insert(0, lst);
}
else
{
Invoke(new Action(() =>
{
ListViewItem lst = new ListViewItem(" " + DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss"), info);
lst.SubItems.Add(Log);
lstInfo.Items.Insert(0, lst);
}));
}
}
清除日志区 控件函数:
//清除日志区
private void button8_Click(object sender, EventArgs e)
{
lstInfo.Items.Clear(); //清除日志listview 的内容
MessageBox.Show("已成功清除日志区", "清除接收区");
}
注意事项:
1、要检查各个控件操作可能出现的错误连接的情况:串口未打开、字符输入非法等,并设置报错日志
2、日志委托写入listview控件,别忘了编辑列
3、
C# Winform 整体测试工程下载:
https://download.csdn.net/download/qq_64257614/89368716?spm=1001.2014.3001.5503
STM32F10xx 单片机的编程:
OLED的驱动显示:
有关于OLED的驱动就不多赘述,这里只介绍在哪里刷新了哪些显存,具体配置是有关IIC通信的相关文章贴出如下:
STM32 F103C8T6学习笔记9:0.96寸单色OLED显示屏—自由取模显示—显示汉字与图片_stm32f103c8t6 oled显示文字-CSDN博客
STM32 F103C8T6学习笔记11:RTC实时时钟—OLED手表日历_stm32f103c8t6显示实时时间-CSDN博客
STM32 F103C8T6学习笔记16:1.3寸OLED的驱动显示日历-CSDN博客
PWM控制舵机运动:
初始化TIM3为舵机控制定时器:
底座舵机: Signal: PA7
头部舵机: Signal: PA6
设置TIM3占空比控制舵机运转:
这里为了防止舵机运转过快出问题,我使用定时器控制其占空比更新频率不过快,并对占空比输出限幅:然后再主函数调用TIM_SetCompare X();函数来落实占空比的设置:
//通用定时器 定时器1 中断服务函数 void TIM1_UP_IRQHandler(void) { if (TIM_GetITStatus(TIM1, TIM_IT_Update) == SET) { if(++t==70) //每70ms设置一次舵机占空比 { t=0; t1=MIDDLE + t1_receive; if(t1>=260) {t1=260;} if(t1<150) {t1=150;} t2=MIDDLE + t2_receive; if(t2>=260) {t2=260;} if(t2<150) {t2=150;} } TIM_ClearITPendingBit(TIM1, TIM_IT_Update);//清出中断寄存器标志位,用于退出中断 } }
串口接收与串口中断服务函数的编写:
这部分的设计有些麻烦,串口接收是一件比较麻烦的事,
这里为了开发迅速,就不自己编写 状态机+结构体 这样比较规范的串口接收方式了,
我选择了简单的 定义接收buff[]数组缓冲区+末尾接收字符检验 的方式进行串口接收校验了,这种方式好编程,但缺点也很多很明显!
定义的诸多变量如下:
int t,t1,t2,t1_receive,t2_receive; //辅助配置占空比 int Receive[20]; //提取 串口接收数组 字符串里的 所有数字 int temp_Receive; //定义串口程序需要用到的变量 char USART0_save[20]; //存字符串命令的数组 char USART0_xb=0; //帮助数组下标位移 char USART0_flag=0; //接收完成标志 //定义命令字符串,用于与接收进行比较 ,不可修改 const char str1_order[]="TEST&"; //测试命令 const char str2_order[]="RS&"; //归位命令 //定义响应字符串,用于响应不同的命令 char str1_receive[]="Cotact OVER"; char str2_receive[]="NTM: Hello,STM32F1xx !"; char error_receive[]="ERROR CMD!";
串口中断服务函数:
//串口1中断服务函数 void USART1_IRQHandler(void) { if(USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) //接收中断 {USART_ClearFlag(USART1, USART_FLAG_RXNE);} USART0_save[USART0_xb++]=USART_ReceiveData(USART1); if(USART0_xb== 20){USART0_xb=0; } //下标最大不超过20 if(USART0_save[USART0_xb-1]=='&') {USART0_flag=1;} //命令以&结尾 }
串口接收buff[]缓冲处理函数:
void Handle_Uart_Receive(void) { int i; if(USART0_flag==1) { printf("STM32 confirm Receive : %s",USART0_save); //先重复接受到的字符串 //先判断命令长度,再根据其判断是否为接受到的命令字符串,根据情况发送不同回应 if(strncmp(USART0_save,str1_order,5)==0) printf("%s",str1_receive); else if(strncmp(USART0_save,str2_order,3)==0) { t1_receive=0;t2_receive=0; printf("%s",str2_receive); } //如果是头部舵机转动的命令头 if(USART0_save[0]=='H') { extractDigitsFromStringArray();//提取 USART0_save 接收数组中的数字 //循环拼接提取到的每个数字 for (i = 0; i < USART0_xb -3; i++) { temp_Receive = temp_Receive*10+Receive[i]; } //判断向左向右 if(USART0_save[1]=='L'){t1_receive=0-temp_Receive;} if(USART0_save[1]=='R'){t1_receive=0+temp_Receive;} } //如果是底部舵机转动的命令头 if(USART0_save[0]=='B') { extractDigitsFromStringArray(); //提取 USART0_save 接收数组中的数字 //循环拼接提取到的每个数字 for (i = 0; i < USART0_xb -3; i++) { temp_Receive = temp_Receive*10+Receive[i]; } //判断向左向右 if(USART0_save[1]=='L'){t2_receive=0+temp_Receive;} if(USART0_save[1]=='R'){t2_receive=0-temp_Receive;} } } memset(USART0_save,0,sizeof(USART0_save)); //处理完命令别忘了将数组清零,以便接收下个命令 temp_Receive=0; USART0_xb=0; //重置数组下标 USART0_flag=0; //清理标志位 }
缓冲处理辅助函数:
这是一些缓冲处理的辅助函数,主要是C语言的基础,对数据类型的处理判断:其中一些基础函数需要添加头文件
#include <ctype.h> #include <string.h> #include <stdbool.h>
//从一个数组中提取数字到另一个数组 void extractDigitsFromStringArray(void) { int i=0,j=0; for(i=0;i<=sizeof(USART0_save);i++) { if(isStringNumeric(USART0_save[i])==true) { Receive[j]=USART0_save[i] - '0'; j++; } } } // 辅助函数:检查字符是否是数字 bool isStringNumeric(char str) { if (str == NULL || str == '\0') { // 空字符或NULL指针,不是数字 return false; } if (!isdigit((unsigned char)str)) { //发现非数字字符,则返回false return false; } //字符是数字 return true; }
STM32F103C8T6测试工程下载:
https://download.csdn.net/download/qq_64257614/89368723?spm=1001.2014.3001.5503
测试视频与图片:
本次小试验品开发前后总共耗时不到俩天,按小时计算的话就少于一天了......
Zigbee +PC上位机 无线控制二维云台开发