笔者一向认为,用有限状态自动机来做硬件控制是最好的选择,同时又倾向于用文本定义来定义状态机是更好的做法。所以此次用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宏定义自己的状态机了。
类函数宏的工作包括四步:
本质上,类函数宏最终的成果和java中的反射是一样的,都是向程序中注入已经良好实现过的代码。但java是动态的,而rust则是在编译时一次性完成的。
第一步和第四步,rust的编译器以及syn已经帮我们做完了,我们的主要工作就是二、三两步。所以我们的工作主要分为三个阶段:语句解析、文章解析、语义扩写:
强调一点:在第一步我们说了,对我们自定义的内容首先是识别为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代码,所以不仅仅要符合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要求静态变量全用大写,关键看大写单词非常吃力啊,写跃迁的时候,一行全是大写单词,光在脑子里翻译大写单词了:(
而用宏,完全可以在定义的时候都用小写,然后扩写成大写,在思考状态机的定义的时候,就轻松了很多。
当然,触发的时候,还是得用大写单词,但事件触发是分布在各输入处理中的,本来就需要大量的翻找和定位,这个时候的大写反而比较显眼,有助于在翻找分散精力后迅速集中注意力了。