Arduino 用简易的GUI 后台“线程” 实现实时按键响应

发布于:2025-06-30 ⋅ 阅读:(16) ⋅ 点赞:(0)

简而言之,就是要做个高响应性的程序结构。需求是用Arduino 做个Modbus 主机,操作从机做各种东西,同时接收用户的按键输入,在屏幕上更新信息。用过Modbus 的人都知道,做主机的话,单片机程序需要先从串口发送请求,然后等待从机响应。等待的过程中几乎干不了别的事,因为后续操作都要依赖响应的结果。而Modbus 和I2C 那种响应又不一样,I2C 的从机响应速度比Modbus 快多了,一般就算原地死等I2C 从机也不会有太明显的卡顿,但是Modbus 从机经常需要几十毫秒才能响应,要是遇到通讯异常,主机可能得等待几秒钟,原地死等过于低效,还会卡住界面,不能及时响应按键。

这个场景和以太网、Wi-Fi 网络通讯比较类似。在电脑上,经典的解决方案是用后台线程执行通讯,这样前台的界面和按钮就不会卡住;现在时兴的方案是用协程。基本原则都是让通讯部分的逻辑看起来是同步的:先请求,然后原地等待响应;只不过CPU 或者编译器会自动在原地死等时切换到其他可以执行的任务。而单片机程序一般可以考虑三种方案:

  1. 在中断里执行实时操作,主程序里死等;
  2. 上RTOS;
  3. 手动搞状态机,发送请求后切换到等待状态,在每个阻塞点手动指挥CPU 回去处理实时任务;

这次我不考虑RTOS 和状态机的方案,因为前者费硬件,占用的资源比较多,Arduino 芯片扛不住;而后者费人,自己写过状态机的人应该能理解我的意思,当然用RTOS 其实也挺费人的。中断最好也不用,有几个原因:

  1. 我的程序要基于Arduino,底层中断已经被库函数占用了;
  2. 用中断会破坏库函数的封装,失去用Arduino 方便快捷的优势,增加自己的中断函数与库函数冲突的顾虑;
  3. 用中断会破坏可移植性,不用中断的项目可以方便的移植到其他支持Arduino 的芯片平台上,用了中断要考虑的就多了;
  4. 中断里的代码逻辑有限制,最好不要放耗时、复杂的操作,但是响应按键后经常要立即做复杂操作,比如刷新屏幕;

额外说一下第四点。用中断确实可以实时检测按键输入,但是实时检测到输入并不等于能实时响应。检测到按键后,还得给用户发出反馈才算执行了响应,但是用来发出反馈的代码可能就比较复杂了,并不适合放在中断函数里,比如在屏幕上显示个进度条动画。

一般在中断里处理输入,都是拿全局变量给主程序传递个标志,等主程序轮询到标志了再响应,代价是主程序就不能死循环等待。不过也有个很简单的变通方法——我一边死循环等待通信结果,一边轮询标志不就行了。这就是我用的办法,在主程序所有阻塞等待的地方死循环,死循环里面检测按键并响应。这就相当于在主程序等待的时候切换到后台“线程”里执行其他操作,和一般电脑程序的做法刚好相反,但是能实现一样的效果。

简单实现

用伪代码表示大概是这样:


bool mode_changed = false;

void show_show_wait() {
	// 在屏幕上显示进度条动画,每次被调用时刷新一帧
	// ...
}

void clear_animation() {
	// 清除屏幕上的进度条动画
}

// 负责轮询按键并响应的“后台”函数,需要被不停循环调用
// 也可以用来做刷新屏幕动画、闪烁LED 之类的活 
void poll() {
	if(button_ok.clicked()) {
		// 按键OK 被点击,通知主程序控制从机切换工作模式
		// 此时主程序可能正在读取其他数据,必须等读完了再切换模式,
		// 所以先显示个进度条,告诉用户按键输入有效,但是得等一下
		mode_changed = true;
	}

	if (mode_changed) {
		// 如果mode_changed == true,表示主程序还没有处理完成,
		// 刷新动画到下一帧
		show_show_wait();
	}
}

// 主循环,负责处理通信逻辑,也要负责更新屏幕内容
void loop() {
	poll();   // 有空就轮询一下

	// 给17 号从机发请求,读取数据
	// 主循环可能要在这一行卡住好几秒
	int count = read_data(17);
	// 读完了就显示出去
	show_count(count);

	// 如果用户要切换模式,就给他切换
	if(mode_changed) {
		// 向从机发请求,切换模式
		// 也可能在这一行卡住好几秒
		write_mode(17, m);
		// 把切换后的当前模式显示出来
		show_mode(m);
		// 操作完成,把进度条动画清除掉
		mode_changed = false;
		clear_animation();
	}
	
	// 然后就这么循环工作,主程序里完全是按顺序一步一步处理通信逻辑
	// 好像根本没有做实时响应的活
	// 因为实时响应的工作放在了这些会原地卡住等待的函数内部
	
	wait_for(500); // 延时500ms,等从机有新数据了再去读
}

// 读取数据
int read_data(int num) {
	// 通信协议是一问一答的规则,要先发送请求,再等待响应
	request_data(num);  // 向指定编号的从机请求数据
	// 原地循环等待响应
	while(1) {
		if(response_available()) { // 如果检测了响应,就跳出循环,返回结果
			return response_data();
		}
		// 如果一直没有响应,就一直死循环,同时不停调用后台轮询函数处理按键输入
		poll();
	}
} 

// 设置模式
void write_mode(int num, int mode) {
	request_set_mode(num, mode); // 向指定编号的从机请求修改模式
	// 原地循环等待响应
	while(1) {
		if(response_available()) {
			return;  // 检测到响应,直接退出,就当是成功了,这里省略处理异常情况的代码
		}

		// 同样,响应还没来得时候,就死循环,调用轮询函数
		poll();
	}
}

// 内部调用了轮询的延时函数
void wait_for(int t) {
	time.reset();
	while(time.diff() < t) {
		// 等待并轮询,直到时间间隔超过输入的延时时间
		poll();
	}
}

这样顺序执行的主程序代码写起来可比状态机舒服太多了,又不像RTOS 要考虑很多资源管理的问题,应该算是我这种应用场景的一种最佳实践了。如果系统中同一时间只有一个这种阻塞式的逻辑要执行,就可以写成这种一边阻塞一边轮询的形式,虽说底层逻辑其实就是把轮询按键的代码分散到各处,大力出奇迹。要注意的只有一点,就是不要在poll 里调用这些死循环函数,只在主程序里使用,原因:

  1. 后台轮询处理应该尽量快;
  2. poll 函数很难写成可重入的;
  3. 没准就意外搞成无限递归了;

更复杂一点的玩法

我要做的东西有两个界面,两个界面对按键输入的处理并不相同,不方便复用一个poll 函数,而且两个界面的主循环要做的事也不一样。比较方便的办法是把两个界面分别定义成一个类,主程序根据用户输入切换进两个界面之一。那么poll 函数也得两个类各有一份,代码大概是这样:


class Base {
	public:
	// 让两个界面都实现这个poll 接口,这样read_data 、wait_for 之类的函数就可以根据当前激活的界面去调用对应的poll
	// 也就是所谓的多态
	virtual void poll() = 0;

	// 界面中处理通讯请求之类同步逻辑的主循环就放在这个函数里面
	// 也定义成虚函数,方便统一处理
	virtual void show() = 0;
};

// 界面 A
class A : public Base {
	public:
	virtual void poll() override {
		// 界面A 的轮询函数
		// 执行类似之前的逻辑,处理按键之类的
	}

	virtual void show() override {
		// 界面A 的主循环函数
		while(1) {
			// ...
			if(切换去界面B()) {
				return;   // 如果用户要切换到另一个界面,就先从当前界面返回
			}
			
			// 延时500ms。现在调用这些函数要传入界面对象指针,这样函数内部才能调用对应的poll
			wait_for(this, 500);
		}
	}
}

// 界面 B
class B : public Base {
	public:
	virtual void poll() override {
		// ...
	}

	virtual void show() override {
		// 界面B 的主循环函数
		while(1) {
			// ...
			if(切换去界面A()) {
				return;   // 如果用户要切换到另一个界面,就先从当前界面返回
			}
			
			wait_for(this, 500);
		}
	}
}

// 给这些死循环函数都增加一个参数,指向调用它们的当前激活界面

// 读取数据
int read_data(Base *p, int num) {
	// 通信协议是一问一答的规则,要先发送请求,再等待响应
	request_data(num);  // 向指定编号的从机请求数据
	// 原地循环等待响应
	while(1) {
		if(response_available()) { // 如果检测了响应,就跳出循环,返回结果
			return response_data();
		}
		// 如果一直没有响应,就一直死循环,同时不停调用后台轮询函数处理按键输入
		p->poll();  // 调用虚函数poll
	}
} 

// 设置模式
void write_mode(Base *p, int num, int mode) {
	request_set_mode(num, mode); // 向指定编号的从机请求修改模式
	// 原地循环等待响应
	while(1) {
		if(response_available()) {
			return;  // 检测到响应,直接退出,就当是成功了,这里省略处理异常情况的代码
		}

		// 同样,响应还没来得时候,就死循环,调用轮询函数
		p->poll();
	}
}

// 内部调用了轮询的延时函数
void wait_for(Base *p, int t) {
	time.reset();
	while(time.diff() < t) {
		// 等待并轮询,直到时间间隔超过输入的延时时间
		p->poll();
	}
}

A aa;
B bb;

void loop() {
	// 在两个界面之间切换
	if(进去界面A) {
		aa.show();
	}
	else if(进去界面B) {
		bb.show();
	}
}

如果不想用C++,不想找对象,那也可以不用虚函数,把两个界面各自的poll 作为函数指针传给死循环函数就行了。