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要求静态变量全用大写,关键看大写单词非常吃力啊,写跃迁的时候,一行全是大写单词,光在脑子里翻译大写单词了:(

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

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

相关推荐
YiSLWLL7 小时前
使用Tauri 2.3.1+Leptos 0.7.8开发桌面小程序汇总
python·rust·sqlite·matplotlib·visual studio code
yu4106217 小时前
Rust 语言使用场景分析
开发语言·后端·rust
朝阳58111 小时前
Rust项目GPG签名配置指南
开发语言·后端·rust
朝阳58111 小时前
Rust实现高性能目录扫描工具ll的技术解析
开发语言·后端·rust
红尘散仙15 小时前
六、WebGPU 基础入门——Vertex 缓冲区和 Index 缓冲区
前端·rust·gpu
苏近之15 小时前
深入浅出 Rust 异步运行时原理
rust·源码
红尘散仙16 小时前
四、WebGPU 基础入门——Uniform 缓冲区与内存对齐
前端·rust·gpu
大卫小东(Sheldon)19 小时前
魔方求解器桌面版(层先法,基于Tauri实现)
rust
景天科技苑1 天前
【Rust结构体】Rust结构体详解:从基础到高级应用
开发语言·后端·rust·结构体·关联函数·rust结构体·结构体方法
苏近之1 天前
说明白 Rust 中的泛型: 泛型是一种多态
设计模式·rust