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

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

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/638344.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Laykefu客服系统 任意文件上传漏洞复现

0x01 产品简介 Laykefu 是一款基于workerman+gatawayworker+thinkphp5搭建的全功能webim客服系统,旨在帮助企业有效管理和提供优质的客户服务。 0x02 漏洞概述 Laykefu客服系统/admin/users/upavatar.html接口处存在文件上传漏洞,而且当请求中Cookie中的”user_name“不为…

[学习笔记]刘知远团队大模型技术与交叉应用L3-Transformer_and_PLMs

RNN存在信息瓶颈的问题。 注意力机制的核心就是在decoder的每一步&#xff0c;都把encoder的所有向量提供给decoder模型。 具体的例子 先获得encoder隐向量的一个注意力分数。 注意力机制的各种变体 一&#xff1a;直接点积 二&#xff1a;中间乘以一个矩阵 三&#xff1a;…

找不到vcruntime140_1.dll无法继续执行怎么办?全面分析修复方法

当系统提示vcruntime140_1.dll文件出现错误时&#xff0c;可能会引发一系列影响计算机正常运行的问题。这个特定的动态链接库文件&#xff08;DLL&#xff09;是Microsoft Visual C Redistributable的一部分&#xff0c;对于许多基于Windows的应用程序来说至关重要。一旦vcrunt…

如何在供应链管理中有效管理供应商和采购成本

一、管理供应商 在供应链管理中,供应商的管理是至关重要的环节。有效的供应商管理不仅可以确保稳定的原材料供应,还可以降低采购成本,提高企业的竞争力。以下是一些管理供应商的有效方法: 供应商评估与选择在选择供应商之前,企业需要对潜在供应商进行全面的评估。评估标准…

回归预测 | Python基于ALO-BiGRU蚁狮优化算法优化双向BiGRU多变量回归预测+适应度曲线+多个评价指标

目录 效果一览基本介绍程序设计参考资料 效果一览 基本介绍 ALO算法是Mirjalili于2015提出的一种新型元启发式群智能算法。由于引入了随机游走、轮盘赌策略及精英策略&#xff0c;使得 ALO 算法成为一种种群多样、寻优性能强、调节参数少、易于实现的搜索技术 python代码 优化参…

JS之Math

一提到数学&#xff0c;就想到被数学支配的噩梦&#xff0c;只不过这个数学用在了代码当中&#xff0c;那么代码当中的数学对象又是什么样的呢&#xff1f;让我为大家简单介绍一下吧&#xff01; 数学对象常用方法&#xff1a; 常用方法简述ceil向上取整floor向下取整round四舍…

线段树详解

什么是线段树&#xff1f; 1、线段树是一棵二叉搜索树&#xff0c;它储存的是一个区间的信息。 2、每个节点以结构体的方式存储&#xff0c;结构体包含以下几个信息&#xff1a; 区间左端点、右端点&#xff1b;&#xff08;这两者必有&#xff09; 这个区间要维护的信息&…

【leetcode】移除元素

大家好&#xff0c;我是苏貝&#xff0c;本篇博客带大家刷题&#xff0c;如果你觉得我写的还不错的话&#xff0c;可以给我一个赞&#x1f44d;吗&#xff0c;感谢❤️ 目录 一.暴力求解法二.使用额外数组三.原地修改数组 点击查看题目 一.暴力求解法 若我们不考虑时间复杂度…

【题目】2023年国赛信息安全管理与评估正式赛任务书-模块3 CTF

全国职业院校技能大赛 高等职业教育组 信息安全管理与评估 任务书 模块三 网络安全渗透、理论技能与职业素养 竞赛相关资源资料可在文末关注公众号获得 比赛时间及注意事项 本阶段比赛时长为180分钟&#xff0c;时间为9:00-12:00。 【注意事项】 &#xff08;1&#xf…

算法优化:LeetCode第122场双周赛解题策略与技巧

接下来会以刷常规题为主 &#xff0c;周赛的难题想要独立做出来还是有一定难度的&#xff0c;需要消耗大量时间 比赛地址 3011. 判断一个数组是否可以变为有序 public class Solution {public int minimumCost(int[] nums) {if (nums.length < 3) {// 数组长度小于3时&a…

UG制图-创建图纸的多种方法

1、2D&#xff1a;创建独立2D图纸&#xff0c;不引用任何3D模型 在UG软件中选择新建&#xff0c;或者快捷键ctrl N&#xff0c;进入新建命令&#xff0c;然后点击图纸&#xff0c;在关系中选择独立的部件&#xff0c;就创建了一个独立的图纸&#xff0c;我们可以在装配中添加…

项目管理该考哪个证书❓NPDP还是软考❓

有小伙伴在纠结是要考NPDP认证呢还是考软考呢❓ 今天小编要给大家好好说说NPDP认证❗️ &#x1f4a1;NPDP全称New Product Development Professional&#xff0c;也就是产品经理国际资格认证。 &#x1f525;NPDP是国际公认的为一的新产品开发专业认证&#xff0c;是集理论、方…

「环境配置」使用Windows自带工具清理C盘空间

​ Windows电脑操作系统一般是安装在磁盘驱动器的C盘中&#xff0c;一旦运行&#xff0c;便会产生许多垃圾文件&#xff0c;C盘空间在一定程度上都会越来越小。伴随着电脑工作的时间越久&#xff0c;C盘常常会提示显示其内存已不足。本文记录笔者清理机器的步骤。 一、使用Win…

在Qt中通过控制按钮实现登录界面密码与明码的转换

创建控件&#xff1a; 首先&#xff0c;在Qt设计师界面界面上创建QLineEdit类文本框&#xff0c;用于输入密码&#xff0c;并且实现密码与明码相互转化。 设置初始状态&#xff1a; 默认情况下&#xff0c;输入密码的文本框应该是可见的并允许用户输入。 添加切换按钮&…

【Oracle】收集Oracle数据库内存相关的信息

文章目录 【Oracle】收集Oracle数据库内存相关的信息收集Oracle数据库内存命令例各命令的解释输出结果例参考 【声明】文章仅供学习交流&#xff0c;观点代表个人&#xff0c;与任何公司无关。 编辑|SQL和数据库技术(ID:SQLplusDB) 【Oracle】收集Oracle数据库内存相关的信息 …

【Elasticsearch】索引恢复(recovery)流程梳理之副本分片数据恢复

replica shard重启具体流程 replica shard node &#xff08;generic threadpool&#xff09; 也是因为应用新的集群状态触发recovery&#xff0c;进入index阶段进入translog 阶段。先尝试重放本地的translog到global checkpoint向primary shard发起start recovery的请求&…

用户ssh正确密码登陆树莓派镜像均报错Permission denied, please try again.处理方法

一个树莓派镜像&#xff0c;启动后发现没有 sshd 功能&#xff0c;于是 启用 openssh&#xff0c;重新启动&#xff0c;又发现树莓派拒绝 ssh 连接请求。 我的一台树莓派IP是&#xff1a;192.168.59.133任何服务器使用任何用户ssh均报错&#xff0c;甚至连自己都不能ssh自己。 …

algotithm -- 排序算法

排序算法总结表&#xff1a; 1. In-place 和 Out-place 含义 参考链接 in-place 占用常数内存&#xff0c;不占用额外内存 假如问题规模是n&#xff0c;在解决问题过程中&#xff0c;只开辟了常数量的空间&#xff0c;与n无关&#xff0c;这是原址操作&#xff0c;就是In-…

HarmonyOS开源软件Notice收集策略说明

开源软件Notice是与项目开源相关的文件&#xff0c;收集这些文件的目的是为了符合开源的规范。 收集目标 只收集打包到镜像里面的模块对应的License&#xff1b;不打包的都不收集&#xff0c;比如构建过程使用的工具&#xff08;如clang、python、ninja等&#xff09;都是不收…

【Linux环境配置】EPYC7642双路服务器Ubuntu22.04安装配置纪要

文章目录 1. 硬件环境配置1.1 WiFi网卡配置1.2 机械键盘配置1.2.1 快速配置1.2.2 按键确认1.2.3 配置存储 1.3 声卡和输出 2. 软件安装2.1 安装常用工具2.1.1 安装Chrome2.1.2 sogou 输入发安装 2.2 开发工具安装2.2.1 安装 vscode2.2.2 文本比较工具 3. 其他环境配置X client,…