Skip to main content

lab2

lab2 正式进入到了底层工具库的编写,在进入 lab2 时我又碰到了一个预料之外的问题但勉强解决了,在实际编写代码时我发现仅仅是 lab0 中的知识还不足以支撑 lab2 的要求,Rust by Example虽然内容并不少,但只是一个入门教程。

预料之外的依赖错误

按照课程描述在 merge lab2 的内容后执行 make 安装检查依赖没有报错就可以开始写代码了,但是我在执行 make 时遇到了一个依赖错误。简单来说是包core_io依赖包rust_version,而core_io的更新缓慢和rust_version删除旧版本共同造成了core_io依赖了一个已经被删除的早期版本,我寻找解决方法时在core_io的 issue 中还看到了一位疑似也在学 CS3210 的朋友。 虽然解决的过程花了不少时间,但是这里就不再赘述。考虑到原作者更新缓慢,最终解决这个依赖问题的方式是 fork 自己修。我的 fork 版本合并了两个有更新内容的分支。

1A StackVec

StackVec 就是一个非常基础的定长栈,但是第一次用 Rust 实现起来就没有那么轻松了。if不加括号,返回值可以省略return之类的语法特性虽然很简洁,但让人有些不习惯,这些都是无伤大雅的问题。在这个模块里最有趣的就是下面三个函数

pub struct StackVec<'a, T: 'a> {
storage: &'a mut [T],
len: usize
}
// 返回可变切片及失去所有权
pub fn into_slice(self) -> &'a mut [T] {
&mut self.storage[..self.len]
}

// 返回不可变切片
pub fn as_slice(&self) -> &[T] {
&self.storage[..self.len]
}

// 返回可变切片
pub fn as_mut_slice(&mut self) -> &mut [T] {
&mut self.storage[..self.len]
}

这里涉及到的问题就是函数返回隐含了释放其拥有的变量。 相比这里,实现Deref DerefMut IntoIterator几个 trait 的代码难度更大,也让我对 trait 和生命周期的理解加深了不少,但目前还不能够清晰地表达出来,后面再提。

1B volatile

这个模块没有要求补全代码,而是讲解了 Rust 中裸指针(raw pointer)的使用。 前面提到过很多有关 Rust 中所有权体系的内容,而在 Rust 中解引用裸指针是无法在编译阶段追踪的不安全操作,解裸指针操作必须在unsafe块中进行,裸指针和 C 语言相同需要程序员自行管理。

  • 不能保证指向有效的内存,甚至不能保证是非空的
  • 没有任何自动清除,所以需要手动管理资源
  • 是普通旧式类型,也就是说,它不移动所有权,因此 Rust 编译器不能保证不出像释放后使用这种 bug
  • 缺少任何形式的生命周期,不像&,因此编译器不能判断出悬垂指针
  • 除了不允许直接通过*const T 改变外,没有别名或可变性的保障

在应用级编程中可能不需要使用裸指针,但在系统级编程中裸指针是不可避免的,使用裸指针时经常还会用到 volatile。在 C 中声明指针时可以添加volatile关键字,在 Rust 中则是有read_volatilewrite_volatile两个方法。 volatile 用于在不改变代码优化级别的前提下禁止对指定变量或操作进行编译器优化,一个比较好理解的例子就是 lab1 中的代码,编译器并不知道某个地址究竟是普通的寄存器还是会产生代码之外的效果的寄存器,所以在编译器看来,对一个地址仅写不读是没有意义的,是可以删除掉来提高代码质量的,是一种代码优化行为。但 lab1 中涉及到的三个地址都是与 GPIO 配置有关的寄存器,写地址会影响 GPIO 的工作状态,在这个场景中 volatile 就是用来防止写操作被“优化”的。

这个模块是对read_volatilewrite_volatile的封装,简化使用阶段的代码,模块中同时使用了 macro 和 trait 两个特性。代码理解起来有一些困难,代码的作用还是比较容易看出来的。

1C xmodem

这个模块实现的是一个十分古老的文件传输协议 xmodem,它只有五种控制字符,没有身份验证功能,抗干扰能力和纠错能力也很弱。优点是协议简单,用 rust 实现的发送端和接收端的代码量都在一百行左右。 实现这个模块需要我自己打的代码大约只有一百行。由于协议文档并不长,也预先给好了一部分定义和函数,所以在前期花了一些时间梳理协议的逻辑和提供的函数定义,课程也建议可以参考测试数据 debug,后面发现测试函数里面的一些代码不容易理解,自己对于协议的纠错能力过于自信,所以花了不少时间 debug。实际上这个协议一旦出现非预期的结果就抛出异常,不会尝试纠错。 在这个过程中我更加理解了 rust 的 match 语句、解构、问号操作符的优势,我最初没有利用好这些特性的代码有最终版本的两倍长,而且有很多嵌套分支结构,在修改之后就清晰了许多。以接收端的核心逻辑为例,仅两个 match 语句就实现了分支选择,还有函数调用后面的问号操作符简化了返回值的判断。

match self.read_byte(true) {
Ok(byte) if byte == SOH => {
self.expect_byte_or_cancel(self.packet, "packet number")?;
self.expect_byte_or_cancel(255 - self.packet, "packet number 1s complete")?;

for i in 0..128 {
buf[i] = self.read_byte(false)?;
}
match self.expect_byte(get_checksum(buf), "checksum") {
Ok(_) => {
self.write_byte(ACK)?;
self.packet += 1;
return Ok(128);
},
Err(_) => {
self.write_byte(NAK)?;
return ioerr!(Interrupted, "checksum failed");
}
}
},
Ok(byte) if byte == EOT => {
self.write_byte(NAK)?;
self.expect_byte_or_cancel(EOT, "second EOT");
self.write_byte(ACK)?;
return Ok(0);
},
Ok(_) => {
self.write_byte(CAN);
return ioerr!(InvalidData, "")
},
Err(e) => {
self.write_byte(CAN)?;
return Err(e)
}
}

1D ttywrite

这个模块是一个串口发送端 cli 程序,使用的依赖包相比前面的模块要多一些,参数解析使用了structopt,串口配置与通信使用了serial,通信协议用到了上一个模块 xmodem,串口相关参数的检查代码也预先提供了,实际需要编写的内容非常少。串口参数没有什么可说的,这个模块支持两种数据源和两种发送方式,如何用更少的代码写出判断逻辑花了我一些时间,后来参考别人的代码看到装箱茅塞顿开。调试好代码后又去学习了一遍错误处理和智能指针的内容。

let mut _file:Box<dyn io::Read> = if let Some(path) = opt.input {
Box::new(File::open(path).unwrap())
} else {
Box::new(io::stdin())
};

if opt.raw {
io::copy(&mut _file, &mut port).unwrap();
} else {
Xmodem::transmit_with_progress(_file, port, progress_fn).unwrap();
}

在处理输入的部分,两种输入方式分别为std::fs::Filestd::io::Stdin,这两种结构体都实现了后面需要用到的std::io::Read,满足多态的条件。Box<T>会将变量移动到堆上并维护一个指针,通过装箱实现了多态。 在处理输出的部分,由于io::copy接受两个相同类型的参数,所以只能将两个参数都设为可变类型。在上面装箱的输入流在这里并没有显式拆箱,我理解是由于 Rust 的解引用强制多态(deref coercions)特性实现了隐式的拆箱和类型匹配。 代码中的unwrap()函数是异常处理代码,一旦前面的函数抛出错误就恐慌(panic)。