简而言之,就是要做个高响应性的程序结构。需求是用Arduino 做个Modbus 主机,操作从机做各种东西,同时接收用户的按键输入,在屏幕上更新信息。用过Modbus 的人都知道,做主机的话,单片机程序需要先从串口发送请求,然后等待从机响应。等待的过程中几乎干不了别的事,因为后续操作都要依赖响应的结果。而Modbus 和I2C 那种响应又不一样,I2C 的从机响应速度比Modbus 快多了,一般就算原地死等I2C 从机也不会有太明显的卡顿,但是Modbus 从机经常需要几十毫秒才能响应,要是遇到通讯异常,主机可能得等待几秒钟,原地死等过于低效,还会卡住界面,不能及时响应按键。
这个场景和以太网、Wi-Fi 网络通讯比较类似。在电脑上,经典的解决方案是用后台线程执行通讯,这样前台的界面和按钮就不会卡住;现在时兴的方案是用协程。基本原则都是让通讯部分的逻辑看起来是同步的:先请求,然后原地等待响应;只不过CPU 或者编译器会自动在原地死等时切换到其他可以执行的任务。而单片机程序一般可以考虑三种方案:
- 在中断里执行实时操作,主程序里死等;
- 上RTOS;
- 手动搞状态机,发送请求后切换到等待状态,在每个阻塞点手动指挥CPU 回去处理实时任务;
这次我不考虑RTOS 和状态机的方案,因为前者费硬件,占用的资源比较多,Arduino 芯片扛不住;而后者费人,自己写过状态机的人应该能理解我的意思,当然用RTOS 其实也挺费人的。中断最好也不用,有几个原因:
- 我的程序要基于Arduino,底层中断已经被库函数占用了;
- 用中断会破坏库函数的封装,失去用Arduino 方便快捷的优势,增加自己的中断函数与库函数冲突的顾虑;
- 用中断会破坏可移植性,不用中断的项目可以方便的移植到其他支持Arduino 的芯片平台上,用了中断要考虑的就多了;
- 中断里的代码逻辑有限制,最好不要放耗时、复杂的操作,但是响应按键后经常要立即做复杂操作,比如刷新屏幕;
额外说一下第四点。用中断确实可以实时检测按键输入,但是实时检测到输入并不等于能实时响应。检测到按键后,还得给用户发出反馈才算执行了响应,但是用来发出反馈的代码可能就比较复杂了,并不适合放在中断函数里,比如在屏幕上显示个进度条动画。
一般在中断里处理输入,都是拿全局变量给主程序传递个标志,等主程序轮询到标志了再响应,代价是主程序就不能死循环等待。不过也有个很简单的变通方法——我一边死循环等待通信结果,一边轮询标志不就行了。这就是我用的办法,在主程序所有阻塞等待的地方死循环,死循环里面检测按键并响应。这就相当于在主程序等待的时候切换到后台“线程”里执行其他操作,和一般电脑程序的做法刚好相反,但是能实现一样的效果。
简单实现
用伪代码表示大概是这样:
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
里调用这些死循环函数,只在主程序里使用,原因:
- 后台轮询处理应该尽量快;
poll
函数很难写成可重入的;- 没准就意外搞成无限递归了;
更复杂一点的玩法
我要做的东西有两个界面,两个界面对按键输入的处理并不相同,不方便复用一个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
作为函数指针传给死循环函数就行了。