rust嵌入式之用类函数宏简写状态机定义

笔者一向认为,用有限状态自动机来做硬件控制是最好的选择,同时又倾向于用文本定义来定义状态机是更好的做法。所以此次用rust开发嵌入式自然也是如此。

状态机实现起来很简单,关键是用文本来定义状态机,在rust中,自然是用宏来实现。

在折腾的过程中,又是发现各种解说文章铺天盖地的,但真正有用的不多,都是泛泛而谈。所以还是老样子,写篇文章讲一下自己经过痛苦折腾后的实现,希望能帮到需要的兄弟。

目标

我希望实现的状态机的定义是:

//充电控制状态机
stateMachine!{
    name: sm_charge;
    init: charge_close, charge_close;
    state: charge_close, charge_open, charge_close_wait;
    event: event_charge_close, event_charge_open, event_charge_timeout;
    active: charge_close, charge_open, charge_start_timer;
    trans: charge_close, event_charge_open, charge_open, charge_open;
    trans: charge_open, event_charge_close, charge_close_wait, charge_start_timer;
    trans: charge_close_wait, event_charge_timeout, charge_close, charge_close;
}

即,用一个宏,以文本的方式完成整个状态机的定义【在init函数之外】,然后在init函数执行初始化时执行:

let smi_charge = sm_charge_init();

就可以完成全部的初始化的工作。然后就可以将状态机实例smi_charge放入shared中使用了。

状态机的实现非常简单,这不是我们的重点,我们主要展示如何编写一个类函数宏来定义并初始化状态机。

状态机定义了8种语句:

1、name,状态机名字,形式【name:smname;】

2、init,状态机初始设置,形式【init:initstate, initfunc[可选];】

3、state,状态机的状态列表,形式【state:state1, state2, ...;】

4、event,状态机的事件列表,形式【event:event1, event2, ...;】

5、active,状态机的动作列表,形式【active:active1, active2, ...;】

6、trans,状态机的跃迁,形式【trans:from_state, event, to_state, active[可选];】

7、trans_else,状态机的跌落,形式【trans_else:from_state, to_state, active[可选];】

8、force,状态机的强制跃迁,形式【force:event, to_state, active[可选];】

前面的12345,有且仅有一次,后面的678可重复多次,78也可忽略。

每种语句以一个关键字开头,跟一个英文的冒号,然后是单个或多个标识符【标识符之间以英文逗号分隔】,最后跟一个英文的分号作为结尾。

准备

这个很多文章都讲到,我就集中整理一下,免得大家再去翻。

1、过程宏是在编译的时候执行的,所以过程宏必须以crate的方式创建,而不能是模块。所以,在项目主目录下执行:

mkdir macro_sm
cd macro_sm
cargo init --lib

注意:macro_sm和项目的src目录平级

2、macro_sm的Cargo.toml:

[dependencies]
proc-macro2 = "1.0.76"
quote = "1.0.35"
syn = { version = "2.0.48", features = ["full","extra-traits"] } 
	
[lib]
proc-macro = true

然后就可以在macro_sm的src目录中的lib.rs文件中编写宏了。

3、在主项目的的Cargo.toml中添加依赖:

[dependencies]
macro_sm = { path='./macro_sm' }

4、在主项目的main.rs中引用:

extern crate macro_sm;
use macro_sm::stateMachine;

然后就可以使用sm宏定义自己的状态机了。

类函数宏的工作机制

类函数宏的工作包括四步:

  • 将stateMachine!{...}定义中花括号之间的内容进行解析,识别为一个个rust词法单元【Token】组成的TokenStream
  • 将此TokenStream转换为自定义数据结构形式的数据
  • 根据转换后的数据生成想要的rust语法块
  • 将生成的rust语法块再次转换为TokenStream

本质上,类函数宏最终的成果和java中的反射是一样的,都是向程序中注入已经良好实现过的代码。但java是动态的,而rust则是在编译时一次性完成的。

第一步和第四步,rust的编译器以及syn已经帮我们做完了,我们的主要工作就是二、三两步。所以我们的工作主要分为三个阶段:语句解析、文章解析、语义扩写

  • 语句解析:将name、init、state等我们自定义的语句一一识别并从中提取我们需要的数据
  • 文章解析:将这八种语句一一识别出来后,整合为我们对状态机的完整描述
  • 语义扩写:根据得到的状态机描述,将其翻译为状态机的函数调用代码等以创建对应的自动机

强调一点:在第一步我们说了,对我们自定义的内容首先是识别为rust的词法单元,所以不管我们如何定义,都必须符合rust的词法要求【不是语法要求,语法是我们自己定义的,如我上面自定义的八种语句】,即标识符必须是rust中的合法标识符;如果rust识别为表达式,我们就只能当做表达式来用。

如,【:::】即连续三个英文冒号,rust会识别为一个类引用符【::】和一个冒号,我们就不能按自己的想法随意使用,将这三个英文冒号当做自己的一个词汇。

所以,类函数宏本质上是用rust的词汇,根据我们自定义的语法来造句,在理解了用这个语法书写的文章的意图后注入对应的代码

语句解析

看一下上面状态机的八种语句,其格式都是【识别是哪种语句的关键字】【英文冒号】【数量不定的标识符,如果多个标识符则以英文逗号分隔】【英文分号】。

所以我们的工作包括三步:

1、准备词汇

可以看出,词汇有三种:关键字;英文的冒号、逗号、分号;标识符。后两者syn已经帮我们解析完了,关键字syn也提供了相应的处理函数,我们只需要根据其提供的工具来定义这八个关键字即可:

mod kw {
    syn::custom_keyword!(name);
    syn::custom_keyword!(init);
    syn::custom_keyword!(state);
    syn::custom_keyword!(event);
    syn::custom_keyword!(active);
    syn::custom_keyword!(trans);
    syn::custom_keyword!(trans_else);
    syn::custom_keyword!(force);
}

2、准备数据结构

状态机定义的这八种语句,大家仔细琢磨一下,其实关键的就是两种信息:什么类型的语句,以及这些语句中都包含了哪些标识符。

按rust的习惯,这两种信息分别用两类数据结构来表示:

  • 每一种语句,我们都需要一种数据结构来保存该语句识别出来的信息
  • 再定义一个枚举,来表示属于哪一种类型的语句

语句的定义是:

name语句:

struct SMName {
	name: Ident,
}

state语句:

struct SMState {
	idents: Vec<Ident>,
}

其它语句都和state语句一样,都只有idents来记录本语句由哪些标识符组成。

3、解析

然后就是对每种语句进行解析,syn已经帮我们完成了中间的工作,我们只需要根据我们的语法来提取标识符就可以了:

//name语句的识别。name语句的语法格式是【name:smname;】
impl Parse for SMName {
	//syn已经把TokenStream转换为了识别时更好用的ParseStream
    fn parse(input: ParseStream) -> Result<Self> {
	    //生成一个探查头
        let lookahead = input.lookahead1();
        //name语句是以name关键字开头,所以要先检查是不是这样;peek不移动读取游标
        if lookahead.peek(kw::name) {
	        //从流中提取name关键字,但对我们没用,所以直接丢弃;parse如果成功会移动读取游标
            let _: kw::name = input.parse()?;
            //提取英文冒号,还是没用,直接丢弃
			//如果name后跟的不是英文冒号,会提取失败,最后的问号就会立刻结束对name语句的识别并返回错误
            let _: Token![:] = input.parse()?;
            //提取出名字对应的标识符
            let name: Ident = input.parse()?;
            //name语句是以英文分号结尾的,检查是否如此,并丢弃
            let _: Token![;] = input.parse()?;
            Ok(SMName {
	            //识别并提取成功,返回SMName来保存识别结果
                name,
            })
        }else{
	        //不是name语句
            Err(lookahead.error())
        }
    }
}

其它七种语句都是一个以上的标识符,所以只是在识别冒号和分号之间做一个循环即可:

let _: Token![:] = input.parse()?;
let mut b = true;
while b {
    //识别并提取标识符
    let t: Result<Ident> = input.parse();
    match t {
        Ok(ident) => {
            idents.push(ident);
        },
        Err(_) => {
            //有两种可能
            let ct: Result<Token![,]> = input.parse();
            match ct {
	            //一种是标识符后跟着其它类型的词汇,就停止识别
                Err(_) => b = false,
	            //一种是标识符后跟着逗号,表示没完,需要继续
                _ => (),
            }
        },
    }
}
let _: Token![;] = input.parse()?;

由于那七种语句都是这么识别的,所以把上面的语句写成一个函数来用就好了。

到这,我们就完成了对八种语句的识别。然后我们用一个枚举来提供各语句的类别信息:

enum SMItem {
    Name(SMName),
    Init(SMInit),
    State(SMState),
    Event(SMEvent),
    Active(SMActive),
    Trans(SMTrans),
    Else(SMTransElse),
    Force(SMForce),
}
文章解析

有了句子,我们就可以将之组合运用来写自己的文章了。但笔者如今满打满算开始看rust都没满两个月,syn的例子又太少,实在来不了挥洒写意,所以干脆的约定死了八种语句的语义约束:就按我一开始给出的语句顺序一个个来,前五种一个不能少,后三者可重复,最后两种可省略。

而在上面,我们用枚举SMItem来综合八种语句,这就大大简化了我们对状态机的描述:

struct StateMachine {
    list: Vec<SMItem>
}

即状态机就是一系列顺序语句的集合。

这样一来,整个状态机的解析就是按上面的约束,一个语句一个语句的解析后放入list中即可:

impl Parse for StateMachine {
    fn parse(input: ParseStream) -> Result<Self> {
        let mut list: Vec<SMItem> = vec![];
        list.push(SMItem::Name(SMName::parse(input)?));
        list.push(SMItem::Init(SMInit::parse(input)?));
        list.push(SMItem::State(SMState::parse(input)?));
        list.push(SMItem::Event(SMEvent::parse(input)?));
        list.push(SMItem::Active(SMActive::parse(input)?));
        loop {
            let tr = SMTrans::parse(input);
            match tr {
                Ok(item) => list.push(SMItem::Trans(item)),
                Err(_) => break,
            }
        }
        loop {
            let tr = SMTransElse::parse(input);
            match tr {
                Ok(item) => list.push(SMItem::Else(item)),
                Err(_) => break,
            }
        }
        loop {
            let tr = SMForce::parse(input);
            match tr {
                Ok(item) => list.push(SMItem::Force(item)),
                Err(_) => break,
            }
        }
        Ok(SM { list })
    }
}

有了对整个状态机的解析,我们就完成了第二步工作:从rust词汇中得到我们需要的数据。

现在,我们就可以完成类函数宏的上半部分的编写了:

#[proc_macro]
pub fn stateMachine(tokens: TokenStream) -> TokenStream {
	//加了proc_macro属性宏的sm函数,就是我们自己编写的sm宏
	//其参数tokens就是stateMachine!{...}执行时花括号中的文本被识别为rust词汇后的结果
	//然后我们将tokens解析为我们自己的SM数据结构
    let mut data = parse_macro_input!(tokens as StateMachine);

	//下面就是用得到的数据来生成我们需要的代码了	
}
语义扩写

得到了状态机的描述,我们就可以根据这些描述数据,来生成状态机定义的代码了。

简单的说,就是根据这些数据,拼出一个字符串,然后将这个字符串翻译为TokenStream输出,rust编译器就会将这个字符串其当做代码进行编译了。即

  • rust编译器在编译我们的源代码的时候,读到了stateMachine!{...},就会把花括号中的文本解析为rust词汇流,然后调用另一个crate中的stateMachine函数
  • stateMachine函数将编译器送入的rust词汇流翻译成一个字符串,然后将这个字符串转换成另一个rust词汇流,返回给rust编译器
  • rust编译器就会将原本的【stateMachine!{...}】用得到的rust词汇流进行整体替换

所以,我们生成的代码,就是rust代码,所以不仅仅要符合rust词法,还要符合rust语法。

由于基本都差不多,我们就只以状态的定义和跃迁的定义进行说明。

我实现的状态机的状态和事件,都是u8的静态变量,所以:

//这些生成代码,就接在上面从tokens中提取出data之后
let mut order = 0;
let mut tss = String::new();
data.list.retain_mut(|item|{
	//从状态机的各语句中只提出state语句来扩写
    match item {
        SMItem::State(SMState{ idents, ..}) => {
            for ident in idents.iter() {
                tss = format!("{}\nstatic {}: u8 = {};\n", tss, ident.to_string().to_uppercase(), order);
                order += 1;
            }
			//retain_mut如果返回false会删除掉该项
            false
        },
        _ => true
    }
});
tss += "\n";

就是将【state: charge_close, charge_open, charge_close_wait;】的状态语句,生成对应的代码:

static CHARGE_CLOSE: u8 = 0;
static CHARGE_OPEN: u8 = 1;
static CHARGE_CLOSE_WAIT: u8 = 2;

跃迁【trans】是同样的处理框架,只是由于其active可选,所以:

let mut active_name = "None".to_owned();

如果trans语句中的标识符是四个的话,就修改active_name:

active_name = format!("Some({})", ident.to_string());

由于rust中的字符串拼太麻烦,所以我用了quote,但需要在调用前将字符串转换为标识符【字符串带引号的】:

let ident_from: syn::Ident = syn::parse_str(from.as_str()).expect("Unable to parse");
let ident_event: syn::Ident = syn::parse_str(event.as_str()).expect("Unable to parse");
let ident_to: syn::Ident = syn::parse_str(to.as_str()).expect("Unable to parse");
//active_name如果有则形如【Some(...)】,在rust词法中,这是一个表达式
let ident_active_name: syn::Expr = syn::parse_str(active_name.as_str()).expect("Unable to parse");
//用quote来扩写trans语句对应的add_trans函数调用
let ts_init = quote!(
	let _ = &sm.add_trans(#ident_from, #ident_event, #ident_to, #ident_active_name);
);
//我还是将其转换为了字符串
rs += ts_init.to_string().as_mut_str();

然后扩写出一个名为【sname_init】的函数,将init、trans、trans_else、force这几种语句扩写后的代码块包含进来:

fn sm_charge_init() -> state_machine::SMInstance {
    let mut state_machine = State_machine::new(CHARGE_CLOSE, Some(charge_close));
    //trans语句扩写后的代码块
    let _ = &state_machine
        .add_trans(CHARGE_CLOSE, EVENT_CHARGE_OPEN, CHARGE_OPEN, Some(charge_open));
	......
    //trans_else语句扩写后的代码块,如果有的话
    //force语句扩写后的代码块,如果有的话
    
    //根据创建好的状态机,生成其实例
    return State_machine::instance(sm);
}

最终,整个rs字符串包括,state和event语句扩写为对应的静态变量声明语句,active语句扩写为一组动作函数,name语句、init语句、trans语句、trans_else语句、force语句这五种语句扩写为上面的sm_charge_init语句。

在stateMachine的最后,我们将生成的字符串再翻译回TokenStream:

	//显示我们生成的代码
    eprint!("State_machine:{}\n", rs);
	//将这段代码翻译为rust词汇流
    let mut ts: TokenStream = rs.parse().unwrap();
	//返回结果
    ts
}//state_machine函数结束

这样,在init函数中,只要调用sm_charge_init函数,就可以得到该状态机的实例了:

let smi_charge = sm_charge_init();

将其放入shared中,在需要时触发事件即可:

if voltage > VOLTAGE_15V {
    let sr = cx.shared.smi_charge.lock(|smi_charge| {
        //电池电压超过15伏时,触发禁止充电事件
        smi_charge.happen(EVENT_CHARGE_CLOSE, None)
    });
}

注意:rtic中的任务无法通过闭包的形式来调用【参考我上篇文章的说明】,所以需要先手工编写rtic的任务函数:

#[task(priority = 1, shared = [out_charge, state_charge])]
fn charge_close_inner(mut cx: charge_close_inner::Context, param: Option<BTreeMap<u8, Value>>) {
    //禁止充电
    cx.shared.out_charge.lock(|out_charge| {                
        out_charge.set_high()
    });
    cx.shared.state_charge.lock(|state_charge| {                
        *state_charge = 0;
    });
    let _ = send_packet::spawn();
}

然后我们就可以扩写active语句中的charge_close动作为对此任务函数的调用入口函数了:

fn charge_close(param: Option<BTreeMap<u8, Value>>) {
	//调用实际执行禁止充电任务的charge_close_inner函数
    let _ = charge_close_inner::spawn(param);
}

结语

rust中的宏,尤其是类函数宏,很好用也很强大。如状态机,如果不用宏,写起来就比较麻烦,当然这点麻烦并不足以抵消学习宏的高昂成本。

关键是改起来就要疯掉了,增加一个状态、增加一个事件,调整几个跃迁,这在控制系统开发过程中是常态,还是频繁发生、反反复复发生着的。

这时,文本定义由于集中在一起,不需要频繁的翻页、查找,所以注意力高度集中;而且也不需要分神去理解程序逻辑,就是集中考虑状态机该如何动作就好了。相比用代码编程实现,效率高,关键bug也会少很多。

说一个最不起眼的好处:rust要求静态变量全用大写,关键看大写单词非常吃力啊,写跃迁的时候,一行全是大写单词,光在脑子里翻译大写单词了:(

而用宏,完全可以在定义的时候都用小写,然后扩写成大写,在思考状态机的定义的时候,就轻松了很多。

当然,触发的时候,还是得用大写单词,但事件触发是分布在各输入处理中的,本来就需要大量的翻找和定位,这个时候的大写反而比较显眼,有助于在翻找分散精力后迅速集中注意力了。

相关推荐
邓校长的编程课堂6 小时前
基于树莓派Pico和声音传感器实现声控风扇的技术分享
物联网·嵌入式开发·树莓派pico·编程入门·c++编程·声音传感器·c++趣味编程
SomeB1oody15 小时前
【Rust自学】4.1. 所有权:栈内存 vs. 堆内存
开发语言·后端·rust
SomeB1oody1 天前
【Rust自学】4.2. 所有权规则、内存与分配
开发语言·后端·rust
SomeB1oody1 天前
【Rust自学】4.5. 切片(Slice)
开发语言·后端·rust
编码浪子2 天前
构建一个rust生产应用读书笔记6-拒绝无效订阅者02
开发语言·后端·rust
baiyu332 天前
1小时放弃Rust(1): Hello-World
rust
baiyu332 天前
1小时放弃Rust(2): 两数之和
rust
Source.Liu2 天前
数据特性库 前言
rust·cad·num-traits
编码浪子2 天前
构建一个rust生产应用读书笔记7-确认邮件1
数据库·rust·php
SomeB1oody2 天前
【Rust自学】3.6. 控制流:循环
开发语言·后端·rust