10 Star 3 Fork 0

iGEM_UCAS_China/2023 iGEM Hardware New Member Tutorial

加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
文件
克隆/下载
winter-part.org 75.08 KB
一键复制 编辑 原始数据 按行查看 历史
凉凉 提交于 2023-02-01 17:52 . winter class 06

iGEM 2023 寒假培训内容

课程安排

年前

主要是复习和复现:

  • 复习上个学期介绍的主要内容:
    • 程序语言和形式语法 (视时间安排, 可以略去)
    • 程序编写和阅读 (以 C 和 Python 为主)
      • 查文档的方法 (man, 等等)
      • 写一些简单的控制用的逻辑代码 (作业和课堂演示)
    • 单片机控制方面 (以 NodeMCU 为主, 或者找模拟器)
    • 建模 (以 SW 为主)
    • 焊接 (略)
  • 复现去年的 iGEM 项目
    • 程序部分
      • (课堂) 读代码并解释代码中的部分去年没讲的和单片机相关的知识点, 可以改写部分的代码.
      • (作业) 改写代码 (挖空? 或者别的形式? ) 或者写拓展
    • 建模部分
      • 在去年的项目中选择一些零件来复现 (作业和课堂演示)
  • 文献调研 (作业)
    • 去阅读 iGEM Best Hardware 并记录
    • 看看能不能每节课抽一些时间来读 Best Hardware 或者在上课的时候用 Best Hardware 里面的 Project 来做例子.

年后

看项目是否确定了要做什么:

  • 若确定了明年的项目:
    • 以项目的方向为主去做一些教学
  • 若还没确定:
    • 分组去实现一些硬件的项目

[年后]

项目一: PID 控制器

可以参考的文章: 用 Ruby 实现飞机自动驾驶仪.

我们会从最简单的控制部分讲起, 并且不会涉及非常多的专业的知识, 保证以实用和简单为主. 毕竟难的也不会.

注: 这个教学的例子为了方便快速准备, 我用的是我比较熟练的 Ruby. 不过基本上不会特别利用 Ruby 中的高级操作, 所以和伪代码差不多. 如果课上有要求换语言的话, 我到时候可以重新写.

我们会用到的例子如下: (我会尽量去找一些和硬件组有关的例子, 不过受限于我目前没法搞到实物, 所以大部分的例子都是代码模拟运行的. 所有的示例代码都在 classes/winter-class-04/ 中.)

现在我们想要控制一个容器内的温度保持在一个范围附近, 比如说保持在 37.5 附近.

一个温度控制的模型

该例子中的模型 Bottleclasses/winter-class-04/bottle.rb 中声明, 我们在使用的时候只需要 load 'bottle.rb'= 即可调用. (或 =require_relative)

  • 其中通过 bottle = Bottle.new 声明一个新的容器 bottle.
  • 通过 bottle.on 或者 bottle.off 来打开或关闭加热器
  • 通过 bottle.temp 来读取当前的 bottle 的温度
  • 通过 bottle.update 来模拟下一帧

一个例子如下: 容器从初始温度 16.0 升温到室温 20.0

load 'bottle.rb'

bottle = Bottle.new

puts "t\ttemp"
1000.times { bottle.update }
t	temp
0	16.0
1.0	18.54
2.0	19.46
3.0	19.8
4.0	19.93
5.0	19.97
6.0	19.99
7.0	20.0
8.0	20.0
9.0	20.0

控制的类型

那么在上面的温度控制程序中, 一个很自然的控制想法就是: 如果温度低了就加热, 如果温度到了就停止加热.

于是我们的控制代码就变成类似这样的东西:

load 'bottle.rb'

bottle = Bottle.new(refresh: -1, heat_capacity: c, watt: w)
data = []

1000.times do |i|
  bottle.update

  # This is the main body of control system
  bottle.on if bottle.temp < 37.5
  bottle.off if bottle.temp > 37.5

  data << [bottle.t.round(2), bottle.temp.round(2), bottle.heater] \
    if i % 10 == 0 # && i < 1000
end

data

对于这种简单的控制思路, 我们称之为 Open-Loop 开环控制. (请不必纠结名称, 在介绍了之后的 Closed-Loop 闭环控制后, 这个名字就非常显然了. )

./pic/dot-open-control-example.svg

实际上这个控制包含了两个部分:

  • 启动 heater 控制部分, 其中 trigger 部分是 bottle.temp < 37.5
  • 关闭 heater 控制部分, 其中 trigger 部分是 bottle.temp > 37.5

./pic/ruby-temp-control-example.svg

但是可以从上面的温度变化图中看到, 上面的控制系统会导致要控制的温度会在一个范围进行波动.

尽管我们可以通过增加容器的热容量 heat_capacity, 或者减少加热器运转的功率 watt 来减少最终稳定的温度变化. 但是这样会导致受外界变化较大且预热时间较长.

那么一个非常朴素的想法就是: 在要加热的时候选择更高的功率, 而在需要保温的时候, 则选择较低的功率. 就像是在烧水的时候, 在水沸腾前选择大火, 而在水沸腾后则只需要保持小火即可.

于是为了完成这样的一个控制回路, 我们需要在控制回路中加入一个简单的外界读取的判断:

./pic/dot-closed-control-example.svg

于是一个更新的控制代码如下:

load 'bottle.rb'

max_watt = 100.0
expect_temp = 37.5

bottle = Bottle.new(refresh: -1, heat_capacity: c)
res = []

bottle.on

1000.times do |i|
  bottle.update

  err = bottle.temp - expect_temp
  watt = err < 0 ? - k * err : 0
  bottle.watt = watt < max_watt ? watt : max_watt if err < 0

  res << [bottle.t.round(2), bottle.temp.round(2), bottle.watt.round(2)] \
    if i % 10 == 0
end

res

./pic/ruby-temp-control-closed.svg

于是发现这样的控制更加稳定. 这样的控制方式叫做闭环控制, 即将输出和输入联合在一起. (当然没有必要将这个分类分得很细致, 大概知道一个概念即可. 毕竟我们不是专业的, 要会用即可).

积分项修正

上面的闭环控制我们实现的是一种比例项的修正. 即控制信号 $u$ 和误差信号 $e$ 有 $u = k_p e$ 的线性关系. 这样的控制方法相交于之前的直接开关会使得温度更加稳定.

尽管, 如果你仔细观察的话… 其结果并不是那么 “精确”. 实际上非常容易理解: 当前的平衡方程:

而所用的 $P_{\mathrm{env}} = K (T - T_{\mathrm{expect}})$. 于是就得到的是一个: $\ddot{T} + ω^2 (T - T_0(T_{\mathrm{env}}, T_{\mathrm{expect}})) = 0$. 也就是一个偏离平衡位置的振动方程. (其实如果上个学期期末听了的话…)

那么这个方程显然会和我们的预期的平衡位置有一个误差: $δ T = T_{\mathrm{expect}} - T_0$.

幸好, 这个误差是一个常量误差, 我们可以通过 “积分” 的方式来修正它. 即在前者的基础上再添加一个积分项 $u = k_p e + \boldsymbol{k_i ∫ e \mathrm{d} t}$. 这就像是你发现如果一个信号一直都是偏低的 (其积分小于零), 那么说明这个信号缺一个正向的激励来将其总体抬高.

那么控制代码即可如下构建:

load 'bottle.rb'

max_watt = 100.0
expect_temp = 40.0

bottle = Bottle.new(refresh: -1, heat_capacity: c)
res = []
bottle.on

err_integrate = 0

4000.times do |i|
  bottle.update

  err = bottle.temp - expect_temp
  err_integrate += err
  watt = - k_p * err - k_i * err_integrate

  if watt < 0
    bottle.watt = 0
  elsif watt > max_watt
    bottle.watt = max_watt
  else
    bottle.watt = watt
  end

  res << [bottle.t.round(2), bottle.temp.round(2), bottle.watt.round(2), watt] \
    if i % 10 == 0
end

res

./pic/ruby-temp-control-intergrate.svg

为了方便观察, 我将稳定的温度设定在 40, 这个时候, 可以看到, 加入了积分项的控制程序可以在短暂振荡后稳定在我们设定好的控制温度上. 尽管现在可能看起来稳定的时间过长了, 但是我们可以通过设置合适的积分参数, 来加速稳定所需的时间.

./pic/ruby-temp-control-intergrate-good-param.svg

可以看到, 一个较小的积分项因子可以较快的收敛. 但是过小的积分项因子则会导致收敛到设定值的速度变慢.

所以调参还真是一门学问啊…

微分项因子以及一个比较完整的 PID 控制器

可以将前面的所有的东西进行一个抽象封装成一个 PIDController 类来进行操作. (注: 该部分为 OOP 内容, 可以了解一下面向对象编程的概念, 但是不了解不会对代码产生太多的误解. )

PIDController 的核心代码

全部代码请参考 ./classes/winter-class-04/pid_controller.rb.

def update(err, opts = {}, &block)
  dt = (opts[:dt] || 0.01).to_f
  inverse = opts[:inverse] || false
  sign = inverse ? -1 : 1

  # update the status
  @err_integrate += err * dt
  err_derivate = (err - @err_previous) / dt
  @err_previous = err

  control_value = sign * (@k_p * err + @k_i * @err_integrate + @k_d * err_derivate)

  return control_value
end

那么对于微分部分就不解释了. 直接来比较实际一点的例子吧:

一个比较现实的例子

给予这个系统一个 “随机” 的外界微扰:

  • 微扰的形式是 rand(5.0)0.0...5.0 的一个温度变化.
  • 微扰的形式是 Math.sin(frame) 即一个震荡的温度变化.
 load "bottle.rb"
 load "pid_controller.rb"

 expect_temp = 40.0
 max_watt = 50.0

 formatter = -> (num) { num.round(2) }
 # env_temp = -> (time) { 15.0 + 10.0 * rand() }
 env_temp = -> (time) { 20.0 + 2.0 * Math.sin(time / 5.0) }
 bottle = Bottle.new(refresh: -1, heater: true)
 controller = PIDController.new(kp: kp, ki: ki, kd: kd)
 res = []

 20000.times do |frame|
   res << [bottle.t, bottle.temp, bottle.watt].map(&formatter) if frame % 50 == 0

   # the +Bottle#update()+ takes a block, for which it yields itself.
   bottle.update do |b|
     b.env_temp = env_temp[b.t]
     err = expect_temp - b.temp
     controller.update(err, dt: b.dt) do |watt|
	b.watt = watt < 0 ? 0 : (watt > max_watt ? max_watt : watt)
     end
   end

   # res << [bottle.t, bottle.temp, bottle.watt].map(&formatter) if frame % 1000 == 0
 end

 res

./pic/gnuplot-ruby-pid-controller-sin.svg

总结:

  • $k_p$ 可以让控制系统快速收敛, 尽管它并不一定能够很好地处理变化和偏移. 但是它简单啊.
  • $k_i$ 可以在系统中除去偏移量, 但是它并不能很快地收敛且对变化仍然比较敏感.
  • $k_d$ 虽然对于让系统恢复到平衡位置几乎没有什么帮助, 但是它可以减少因为外界影响导致地变化.
  • 调参真麻烦

那么和外界微扰相对应的则是用户的期望的改变, 关于如何和用户进行交互的内容会在下一个项目中介绍, 现在我们假设我们的用户都是会编程的用户, 并且在系统运转的过程中修改了 expect_temp 的字面量.

load "system.rb"

env_temp_func = -> (t) { 20 + 2 * rand() }
expect_temp_f = -> (t) {
  case t
  when 0..30 then 35
  when 30..60 then 40
  when 60..90 then 30
  when 90..nil then 25
  end
}
# env_temp_func = -> (t) { 20 + 5 * Math.sin(t / 10.0) }
# expect_temp_f = -> (t) { 40 }
bottle = Bottle.new(expect_temp: 40, expect_temp_f: expect_temp_f,
		      max_watt: 50.0,
		      heater: :on, env_temp_func: env_temp_func)
pid_controller = PIDController.new(kp: 10.0, ki: 1.0)
logger = [:t, :temp, :env_temp, :expect_temp]
system = TempSystem.new(bottle, pid_controller,
			  logger: logger, frame: frame)
system.simulate(times)

./pic/gnuplot-pid-user-change-temperture.svg

基本上, 目前这个模型属于是能用的水平了. 也许可以更好, 但是受限于时间有限, 我没时间继续手工调整参数了.

项目一点五: 参数调整和用户交互

注: 关于控制部分的其他介绍并不会继续下去了. 如果有需要的话, 可以参考 【转载】那些年的神贴——自动控制的故事 一文.

参数调整

在前一个项目中, 我们仅仅实现了一个简单的 PID 控制程序. 然而, 我们也会发现, 这个调参的过程十分的艰难和麻烦. 尽管我们可以从直观的感觉中感受到 PID 各个参数之间大概有什么作用:

./pic/pid-parameters-effects-on-controll-system.png

不难得到一个没有用的结论: 我们要的参数不能太大也不能太小. 但是如果要精确地为一个系统给出很好的控制参数, 或者是想要给一个系统的调参方式, 总觉得有些困难.

于是一个自然的想法就是如何自动化调参. 或者至少, 来一个实用的调参方法. 一些可以参考的例子:

(根据上课的情况选择是否展开)

一个简单而暴力的作法就是在找参数的时候一遍遍模拟, 根据模拟的结果写一个打分函数, 然后根据打分函数的结果去查找最优的参数组合.

这样的方式是当我们对于这个系统一无所知的情况下可以采用的, 另一个可行的方式就是通过分析系统的具体特征来实现.

用户交互和其他

首先, 我们需要了解一个非常现实的事实, iGEM 硬件组应该需要解决的是一个工程问题. 理想的情况是当我们完成了一个课题之后, 我们能把它直接拿出去卖钱.

所以我们的目标是让用户能够拿到我们的硬件就可以直接上手无障碍使用, 而不是像看外星科技一样蒙逼.

./pic/alien-tech.jpg

毕竟没有人想要学完交换群之后才知道 $1 + 2 = 2 + 1$ 的. (如果是法国小学生的话当我没说)

而同时, 我们也需要让我们的机器容易被调试, 能够满足尽可能多的功能来支撑实验组或者建模组的需求. 即尽管我们的机器可以简单到是数兔子 $1 + 1$ 的程度, 同时也要满足能进行加法群这样晦涩的高级操作.

于是如何设置一个用户交互的界面, 让普通用户能够简单地操作; 同时设置一个后端的控制界面, 让我们制作者可以轻松地维护和处理. 这就是当前的问题.

终端和图形交互界面

以苹果公司的 iMovie 影片编辑软件为例, 如果我们想要裁剪一段视频, 比如说从 01:30 开始裁剪到 02:30, 那么在 iMovie 中, 只需要简单的拖动鼠标, 或者是简单的点点即可.

./pic/imovie-cut.png

而在终端环境下, 以 ffmpeg 为例, 我们裁剪视频的方式就变得不是那么直观了:

ffmpeg -i input.mp4 -ss 00:01:30 -t 00:02:30 -c:v copy -c:a copy output.mp4

尽管两者并不一定有所优劣, 但是对于用户来说, 使用后者的学习成本显然会高于前者. 而类似的, 对于硬件来说, 通过 Serial 和机器进行串口通信的学习成本显然会高于使用屏幕, 按键, 遥控器之类的方式控制机器.

所以我们的目标就是在已经实现了基本控制功能的机器上重新设计一个控制界面. 来让用户可以轻松地控制机器. 这样可以是什么呢? 以前面的温度控制程序为例, 其中有一大堆控制用的参数, 比如温度, 设定温度, 功率, 功率限制等等一系列的参数. 但是对于一般使用的用户来说, 肯定需要关心的只有设定温度, 以及现在的温度到底有没有达到设定温度.

于是我们设计一个温度控制操作界面的时候, 只需要做减法即可:

元素 说明 命名
总开关 switch
当前温度 显示当前的温度的数值 temp
设定温度 显示设定温度的数值 expect_temp
是否到达预期温度 用一个指示灯来表示 if_ok
增加设定温度按钮 increase_expect
降低设定温度按钮 decrease_expect

常见的操作界面的实现手段:

  • 按钮, 数字显示器, 指示灯等硬件实现
  • Qt, GTK 等客户端软件
  • Web 去年的项目 的实现方法

那么讲了这么多, 来实现吧

  • 选择一个实现图形界面的工具, 比如我们可以使用常见的 Python 中的 PySimpleGUI 来实现.
  • 然后根据前面的分析列出的需求, 设置界面中的元素:
    # Import PySimpleGUI, you may need to install first:
    #   pip install pysimplegui
    import PySimpleGUI as sg
    # import Simulate
    
    # Window Layout
    # It is easy to setup a window layout,
    # just consider these as a table in row and columns,
    # by which you will presee the results.
    layout = [
        [sg.Text("Status: "), sg.Button("OFF")],
        [sg.Text("Expect Temperture: "), sg.InputText()],
        [sg.Text("Environment Temperture: "), sg.Text("20.0")],
        [sg.Button("Quit"), sg.Button("SetTemp")]
    ]
    
    # sg.Window(window_title, layout)
    window = sg.Window("Temp Control", layout)
    
    while True:
        event, values = window.read()
        if event == "SetTemp":
    	  # set temp for bottle
    	  break
        if event == sg.WIN_CLOSED || event == "Quit":
    	  break
    
    window.close()
        

项目二: 开开眼

The Eye of Horus, wedjat eye or udjat eye is a concept and symbol in ancient Egyptian religion that represents well-being, healing, and protection.

from Wikipedia

./pic/Eye_of_Horus_bw.svg.png

既然是最后一节课, 那么我们不妨随意一些. 来开开眼.

不要被语言限制了想象力

一般来说, 做特定的事情需要特定的语言, 因为对应的语言有相应的优势. 尽管大部分情况下这样的优势是它们历史更加悠久, 对应的库更加的多, 用的人比较多且网上买它们课的方便赚钱.

当然, 特定的类型的语言可能会有特定的相应风格. 一个比较有意思的 例子:

./pic/computer-lang-influence.png

尽管我们并没有必要去学习所有的语言, 但是尽可能多地去了解一个语言的风格, 对于我们编程还是很有帮助的. (坏, 怎么变成软件组了) 不过鉴于我们之前想要实现生物编译器的一个想法, 即我们不仅仅是一个语言的使用者, 更应该是一个语言的设计者.

在年前我们介绍的形式化语言中, 实际上我们介绍的是一个语言的形式部分. 比如可以通过下面这样简单的形式语言 ebnf 来声明一个叫做 Lisp 的编程语言的 (几乎) 所有的语法:

[1] prog ::= (symbols | exp)+
[2] exp ::= " "* "(" " "* func " "+ args " "* ")" " "*
[2] func ::= symbols

@terminals
[4] args ::= (" "* symbols " "*)*
[5] symbols ::= [^#x9#xa#xd#x20#x28#x29]+ # none space characters: "\t", "\n", "\r", " "
					    # and escape preserved character "(" and ")"

比如下面的这个 Lisp 的例子:

(+ 3 2)
5

并且我们可以很轻松地根据上面的规则写一个语法处理器 (parser):

require 'ebnf'

class Lisp
  include EBNF::PEG::Parser

  ReSym = /[^\s\(\)]+/
  ReInt = /\d+/
  ReFloat = /(\d*\.\d+|\d+\.\d+)/
  ReArray = /(\s*[^\s\(\)]+\s*)+/

  @@rule = EBNF.parse(LISP_RULES).make_peg.ast
  @@to_sym = -> (str) {
    str =~ ReInt ? str.to_i : str =~ ReFloat ? str.to_f : str.to_sym } 

  terminal(:symbols, ReSym) do |value|
    @@to_sym[value]
  end

  terminal(:args, ReArray) do |value|
    value.split(/\s+/).map(&@@to_sym)
  end

  start_production(:exp, as_hash: true)
  start_production(:func, as_hash: true)
  production(:exp, clear_packrat: true) do |node|
    { node[:func][:symbols] => node[:args] }
  end

  def read_ast(input)
    parse(input, :prog, @@rule, whitespace: /\n/)
  end
end

Lisp.new.read_ast("(add 1 2) add")

注: 更多关于 EBNF 的介绍, 可以参考 Ruby and EBNF (Very Naive) 一文的介绍.

那么, 诸君, 这有何用呢? 我们大可以在此基础上写一段程序来将其转换为 Python 代码, 转换为 C 代码, 转换为 Ruby 代码等等, 只要我们定义好转换的规则: 又或者是写一个程序来运行这个语言 – 于是我们就得到了一个自己的语言. (这段到时看有没有人想看再写, 实现起来比较快)

而我们的编译器的想法, 就是想要通过实现一门能够用来描述如何生成插在细菌 DNA 中的 “代码” 片段的 (“查询”) 语言. 比如在 2021 年培训中的:

./pic/iGEM-basic-class-2021-liu-zi-chen.png

(start          ;; which points to the promoter
 (generate NO)  ;; which describe the function
		  ;; and the program will search the database
		  ;; and find out the most possible pieces to insert
 ) ;; return a possible combinations

这短短的代码可能可以被编译成一个以特定功能为目标的数据库查询语句, (比如 Uniport) 以及最优的分析模拟算法等等, 经过处理后, 然后就会返回一堆可能的组合结果, 选择其中一个组合结果, 可以自动给出合成的路径和方法, 最后甚至可以自动根据合成路径和方法, 自动化调用硬件设施来合成对应的细菌.

./pic/2022-winter-liu-yucheng.png

或者是下面这样的看起来稍微合理一些的代码:

(define killer-switch (condition)
  (if condition (generate anti-toxin))
  (nomatter condition (generate toxin)))

(define self-kill-when-leaving-body
  (killer-switch (less env-temp 37)))

当然, 这样的白日梦实在是有点过于宏伟了, (并且这是否能够计算也是一个问题), 但是这只是提供一个非常夸张的愿景. 现实中, 我们更加常见的可能是更加平凡的场景.

控制和输入

是不是感觉在哪里看到过类似上面的图? (没错, 就是第一个项目中, 和我们在介绍开环控制的时候的示意图极其类似. 一个启动子就像是控制中的开环控制. )

确实, 上面的确实就像是一种 “如果… 就…” 的开环控制模型. 显然, 我们可以将之前学到的关于控制方面的知识应用到合成生物学中.

./pic/basic-concept-in-sb-2021.png

当然, 也不能忘了我们还是硬件组, 并不是只有编译器才是出路. 我们还需要通过构建和现实相关连的组件来与现实进行联系. 满足操作现实, 读取现实数据的需求.

如果用读和写来类比, 那么传感器部分就像是读, 控制部分就像是写. 同时拥有这两种能力可以让项目变得更加强壮.

当然, 我们控制的方法并不是只有 PID 一种 (它只是众多控制方法中的一种, 并且比较常用而已). 写的方式也并不是只有简单的传感器. 一种比较有趣且高级的方式: 计算机视觉 (机器视觉).

尽管计算机视觉听起来非常高大上, 但是你也可以简单认为它就是数字图像处理, 然后再简单一些就是对输入的数据的分类和识别.

我们不妨用一个比较简单的例子来引入:

现在我们有一个红外线测温元件 (IR Temperture Sensor) 来读取温度:

./pic/ir-temperture-sensor-arduino.jpg

假设我们用一个简单的程序来实现了读取温度的功能:

// arduino code
void loop() {
  temp = ir_sensor_read(); // return the temperature in Celsius degree
  output_temp(temp);       // output temp in screen
  delay(TIME);             // wait for a TIME microseconds
}

注: 一个 WOKWI 的例子在 classes/winter-class-06/Temp Sensor Example 里面. 可以去把代码放到平台上面跑一遍试试. (尽管这里用的是 NTC 传感器)

下面的例子来自 Electrics Lab

./pic/IR-temperature-sensor-Demo.jpg

那么假设我们现在通过电脑端捕获 Serial 读到的数据并将其储存在一个数组中. 于是我们就得到了一个随时间变化的一个数据信号, 然后可以将其绘图输出, 或者交给建模组用于数据拟合之类的.

而如果我们想要同时测量多个不同容器的数据并同时记录, 那么一个普通的想法就是: 多买点温度传感器, 比如有一个容器矩阵, 我们便可以整一堆的温度传感器, 对每一个都进行编号处理:

./pic/bottle-matrix-not.jpg

./pic/microbiological.jpg

那么我们读到的数据就是一个二维数组. 为了让有限的引脚能够控制这么多的传感器, 可以通过矩阵逐行扫描的方式来读取数据. 而如何能够在有限的空间里面塞满这么多传感器, 又变成了另外一个麻烦的事情. 并且这样二维堆料堆采样点的操作, 简直就像是丧心病狂的手机相机厂商…

没错, 只要引入一个红外相机即可解决我们的问题了. 譬如说我们的红外摄像机固定在摇床上方. 那么对于拍摄到的热成像图片, 只需要将其按照对应的网格区域进行裁切, 并对对应网格内的信号转换为平均温度. 于是我们就完成了多测温的一个比较理想的装置了.

(注: 尽管这里还有一些其他的问题, 比如如何保证温度不会受到其他网格的影响? 是通过温度梯度这样的方式来实现么? 还是如何实现? 以及引入这样的测温装置有什么好处? 是否能够有一些特别强大的理由来说服人们来采用这个方案?

譬如说我们可以在平板基底下铺设电加热丝和热敏电阻. 在其上滴加液体以实现一个加热和温度控制平台. 并且可以参考 MIT Programmable Drops 的这个项目, 来做到一个微型的自动化培养平台.)

注: 关于热成像, 有一个比较完整的 例子.

当然, 不要被 “数字图像处理” 中的 图像 一词给忽悠了, 实际上数字图像处理的方法并不仅仅局限于用相机拍摄到的图像, 甚至我们一开始的传感器矩阵也是一个可行的 图像 的例子.

OpenCV

既然谈到了输入的图像处理的技术, 我们可以用 OpenCV 来进行一个示范. 以下的例子主要参考 图像和视频分析 一文. 由于我对这个并不是很熟练, (写文的时候刚学), 所以你们也可以跳过这部分直接看原文. 或者可以查找官方的 OpenCV 资料.

我们会略去环境配置的一些繁琐的部分, 直接进入正题. 如果有想要了解环境配置的同学可以去看原文以及查找相关的资料.

首先我们引入 python-opencv 的库:

import cv2

然后通过 python-opencv 的库来以灰度模式 cv2.IMREAD_GRAYSCALE 读取一张图片:

img = cv2.imread('example.jpg', cv2.IMREAD_GRAYSCALE)

通过 cv2.imshow('title', img), 我们可以将其显示在屏幕上. 所有的代码在 classes/winter-class-06 中可以找到. opencv.py.

可以像 Python 中的数组一样来去访问图片的部分, 比如通过 img.shape 来得到行数, 列数和通道数:

rows, cols, channels = img.shape

于是我们就可以来访问一个图片的特定通道和特定区域的信息了:

cv2.imshow('In Channel %d' % i, img[0:rows, 0:cols, i])

在前面的例子中, 很自然的我们便可以将图片通过切割的方式来得到各个子区域的部分. 比如通过切割掉一圈白边来得到对内容物的识别.

cv2.imshow('Cut Image', img[white:(rows - white), white:(cols - white)])

但是如果是手动切割的话, 对于我们来说还是过于痛苦了一些. 并且也并不是那么稳定, 尽管消除稳定的方法除了从根本上解决产生干扰的问题, 提高抗干扰能力的自适应的方式也是一种不错的方法. 即通过识别画面中的主体, 来让计算机自己选择该切什么.

为了让程序更好地认出主体, 首先需要输入的图片进行一个预处理:

  • 设定阀值
  • 筛选颜色
  • 根据前面的再突出主体
  • 或者筛选并提取轮廓
  • 等等

OpenCV 的好处就是隐藏了具体的算法细节, 让对图像处理的方法可以快速地被调用. 我们需要做的只是简单地调用方法即可.

控制的更加高级的东西

现在是幻想时刻:

  • 在控制中加入 Tensorflow Lite 等人工智能的部分, 或者通过将收集到的数据扔到神经网络中去处理, 比如 Mathematica 之类的.
  • 更好的控制算法, 比如在 PID 控制基础上的魔改控制, 或则是其他的控制思路.

创意和想法

我们可以观察一下往年的硬件组做的东西和往年的优秀 iGEM 硬件组做的东西.

  • 2021 iGEM TUDelft Hardware 做的硬件是一个检测装置, 优点是完成度高.
  • 2021 iGEM SZU-China Hardware 做的硬件是一个采样装置, 优点是新鲜创新. (并且不只做了一个硬件).
  • 2022 Sheffield Bioinformatics Toroidal Bioreactor 是一个通用的模块化培养装置, 旨在低成本实现生物培养方案. 想法很好. 其中对模型的可行性的验证部分值得我们去学习.

    2022 Sheffield Bioinformatics Toolbox 其中还有一个做的类似一个简单的编译器, 将一些变换用的小工具来整合在一起:

    ./pic/sheffield-igem-bio-toolbox.png

  • 去年的其他几个硬件最佳感觉和我们的方向差不多…
    • 2022 Aachen 做的是培养环境, 和我们的区别是光照系统在反应容器内部. 为了减少搅拌器带来的对外部光照的影响, 直接让搅拌器发光.
    • 2022 UFMG_UFV_BRAZIL 做的也是培养环境, 和我们的区别是最终的数据展示比较好. 并且对采集到的数据的分析做得很好. 这个我们去年可能缺少了一些. 但是他们的造价过于高昂…

那么总结起来就是, 我们可以在今年的项目实现的时候努力的方向:

  1. 提高项目的完成度:
    • 最终的数据展示
    • 装置的可行性分析
    • 可复现性 (来自去年组长的要求, 因为是开源项目)
  2. 去做一些没有人做的东西来解决一些比较繁琐的操作或者问题. 并且这样的问题要尽可能和实验和项目相关:
    • 基础性的部件, 比如数据库, 基本采集部件之类的
    • 化简或者自动化繁琐的操作

最后

感觉也没有讲很多的东西. 还是希望大家自己多写代码多去练吧.

[年前] 冬季学期没教完的东西

SolidWorks

通过复现上一次的工程机器来教学.

  • 每个人挑选一个 (或者几个) 去年的工程图中的零件, 在 SW 中重新实现
  • 其他要求?
  • 实现一些机械零件结构, 可以照网上的教程来做都行

C 及编程

通过复现去年的控制部分代码.

  • 阅读去年的代码, 了解:
    • Serial 和波特率
    • 以及其他不懂的…

    然后画出或者写出对代码核心的控制流程图 (loop 函数, 比如)

  • 重写 loop 函数中对温度控制的代码. (可以在控制理论讲完之后重写)
  • 往里面加一些自己喜欢的东西

第一节课

  • 复习 (请课后或者课前) 阅读 形式化的 C 的介绍 部分
  • 基本 从零开始, 用项目来介绍 C 的编程

(上学期) 形式化的 C 的介绍

形式化的 C 的介绍

该部分推荐的参考书为 C Programming Language by K&R, 以及 C: A Referrence Manual. 不过我对 C 语言也不是很熟练, 所以如果文档中有任何问题, 请在 issue 中提出.

在该部分的 C 语言中, 我们主要使用的是 C 和汇编的对应来进行简单的教学. 不过请注意, 这样的教学仅仅只是最基础的简单教学.

(注: 其中汇编的代码为 gcc -S -fverbose code.c 编译得到, 编译的环境为 Ubuntu 21.10 (GNU/Linux 5.13.0-48-generic x86_64), 如果你不想要复杂的输出的话, 可以选择去掉 -fverbose flag, 通过 gcc -S code.c 来编译. )

变量, 表达式, 值, 数组, 结构

变量 <<c-assignment>>

我们可以用 <value-type-option> <variable-name> (= <variable-value>){0,1}; 的方式来为一个变量赋予名字. 这就像是在计算机中找了一个空间来储存变量名字 所对应的变量的值.

其中 <value-type-option> 为变量的值的类型, 我们将会在下面简单介绍, 目前你可以简单认为有如下的形式:

<assignment> ::= <type-opt> (<equation>)+;
<type-opt> ::= "int"    /* 整型 */
	       | "float"  /* 浮点数 */
	       | "double" /* 双精度 */
	       | "char"   /* 字符值 */
	       /* 不完整的规则 */
<equation> ::= <variable-name> (= <variable-value>){0,1}
// assignment
int x = 1;
int y;
y = 2;

printf("%d %d", x, y);

(思考题: 请在阅读后文的 值的类型 一部分后写出更加规范的形式定义. )

比如:

int x = 3;

在汇编中, 我们可以看到这样的操作对应了下面的代码:

# main.c:3:   int x = 3;
	  movl	$3, -4(%rbp)	#, x

我们会发现, 变量 x 被放在了相对 rbp 位置为 -4 的栈上. (关于变量和栈, 我们在计算机的运行中介绍了大概的基本知识. 如果你觉得这部分比较困难, 请认为 rbp 就是一个记录局部变量在哪里的标志, 有点像是局部坐标的坐标原点, 而 -4 则是一个变量的坐标. )

表达式 <<c-expression>>

我们可以通过像数学一样来写简单的表达式:

比如:

int x = 3;
int y;
y = (x * x + 1) / 10; // y = 1

一个简单的表达式的形式如下:

<exp> ::= <binary-exp> | <single-exp>
<binary-exp> ::= (<exp> <binary-operator> <exp>) | <val>
<single-exp> ::= (<prefix-op> <val>) | (<val> <subfix-op>)
<binary-operator> ::= "+" | "-" | "*" | "/" /* 四则运算 */
		      | "&" | "^" | "|" | "<<" | ">>" /* 位运算 */
		      | "&&" | "||" | "<=" | ">=" | "==" | "!=" /* 逻辑运算 */
		      /* 其他, 留作课后作业 */
<prefix-op> ::= "++" | "--" /* 运算后取值 */
		| "&" | "*" /* 取地址和取指向的值 */
		/* 其他, 留作课后作业 */
<val> ::= /* 变量名, 字面值, 函数返回值 */

我们自然可以像数学一样使用函数来作为返回值: f(x). 具体的内容见后面的函数调用.

值 <<c-value>>

在 C 语言中有不同的值的类型. (其实为什么会有不同的值的类型的原因非常简单, 工程上来说, 是因为不同的值在计算机中的储存和表示方式不同; 或者也可以数学一些来说, 就是其表示的域的不同, 对于考虑的问题的不同, 选择不同的精度和值的类型. )

(注: 这里是工程, 所以是会有误差的. 所以在编程语言中的值, 大多数是数值近似值, 而不是一个解析的准确值. )

狄拉克: 工程学对我最大的启发就是工程学能够容忍误差.

from 赵亚溥 力学

可以参考 维基百科 中对 C Data Type 的说明, 我们可以发现, 在 C 语言中有四种主要的类型:

  • char 字符, 在形式上为单引号括起来的一个字符, 在计算机储存的时候, 实际上仍然是用数的形式储存, 只是通过表格映射到字符的形状.
  • int 整型, 在形式上为整数
  • float 浮点数, 在形式上为小数, 或者写成整数的形式也不是不行
  • double 双精度, 就顾名思义.

在这四种类型之上, 可以通过添加修饰符来定义值的更加细节的表现:

  • unsigned, signed, 是否有符号位, 实际上可以和计算机中的值的表示方式一起学习
  • long, long long, 在原本的基础上进行拓展

不过实际上, 基本目前我们可能就只会使用非常少的几种类型. 大家只需要了解如何写就好了. (意思就是说, 更加细节的部分可以自学, 或者可以在 issue 中提出等. )

数组

变量 一部分中, 我们提到了定义的变量会在栈上通过一个相对 bp 的位移来定位, 假如我们想要定义一系列有序的变量, 比如 a1, a2, a3 等等. 除了直接通过 int a1, a2, a3, ... 的方式来定义, (这样定义的变量, 应该是在栈上依次排布的, 于是一个非常简单的想法就是直接用相对位移来访问, 如: 令 a1 的地址为 &a1, 于是加上一个相对位移 &a1 + delta, 就可以得到 &a2 的地址… 于是以次类推. )

这样利用相对位移的方式来定义一系列的有序变量的方式, 就是一种简单的数组的形式.

那么在形式上, 数组的定义像这样 <type-opt> <array-name>[<array-size>];, 或者 <type-opt> <array-name>={<array-values>};:

int a[5];
int b[] = {1, 2, 3, 4, 5};

于是我们就能够通过 <array-name>[<index>] 的方式像变量一样去访问对应的元素.

其中, <type-opt> 定义了数组中的元素的值的类型, 而 <array-size> 定义了数组的大小.

在 C 语言中, 字符串也就是一种数组:

char s1[] = "I am Lucky";
char s2[] = {'L', 'u', 'c', 'k', 'y', '\0'};

只是通过 ~’\0’~ 符号来标记字符串的结束.

那么说到数组, 就不得不提起指针. 目前我们仅仅从形式上来理解指针的定义和调用, 在 函数 一节, 我会介绍一些我们为什么要使用指针以及指针该如何去理解的知识.

我们用符号 * 来表示这个是一个指向某某的指针, 用 & 来表示这个是某某的地址.

简单来说, 假如我们有这样的一个 Excel 表格:

index 0 1 2 3 4 5 6

——-—+—+—+—+—+—+—+—–+

value % ) + - < > ?

我们用序号去访问值, 比如在序号为 6 的地方是一个 ?, 那么我们便可以用这样的方式说, 对于 ?, 我们不妨说它就是我们名叫 question_mark 的变量的地址, 于是 &question_mark 的值就应该是 6. 我们不妨将其用一个变量 point_to_question_mark 来表示, 即: point_to_question_mark = &question_mark.

同理, 对于上面的例子, 我们也能够说: *point_to_question_mark 就是再说, 我们要去 indexpoint_to_question_mark 的值的地方, 也就是 index 为 6 的地方去找到一个值, 也就是 ?.

这就是取地址和读地址的简单的理解. 那么具体有什么用呢? 让你头晕脑花.

结构

一个数组中的所有元素的类型都是一样的. 相当于在一个名字下, 按序组织了相同类型的元素.

那如果我们不太想要顺序性, 但是想要在一个名字底下, 存放不同类型的元素呢? (这里就是放弃顺序性而获得了更多的能力了. )

那么, 古尔丹, 代价是什么呢?

在 C 语言中, 这样的方式就是结构体:

struct namingspace {
  char c;
  int x;
  int a[5];
};

struct namingspace example;
example.c = 'L';
example.x = 1;
example.a[0] = 2;

在形式上, 通过 struct <name> { <c-assignment>+ }; 的形式来定义一个 struct 类型. 使用 struct <struct-name> <v-name> 来定义一个结构体变量.

小练习
  1. 尝试描述这些定义的形式语言
  2. 在看完后文中的程序结构后尝试编写一些程序来验证定义
    • 定义一个变量 pi, 其值为 3.14159265354, 思考应该用什么类型
    • 定义一个结构, 该结构可以用来定义一个:
      • 二维平面上的一个点 $(x, y)$ 或者 $(ρ, θ)$ 以及其标签 label
      • 某一个时间点 t 采集的温度 temp, 流量 q, 成品率 present 等的数据
    • (较难) 定义一个 Linked List
  3. 定义一个 二维数组, 用来储存一个平面格点上的不同点位中的菌体的温度. 思考该如何定位? (画饼) 思考, 如何通过红外摄像头来捕捉? 请设计一个简单的系统来描述.

控制流

其实在计算机的运行中我们已经接触过了控制流了.

其实基本的思想很简单, 就是如何让本来应该是线性的程序进行分支和循环.

分支

在流程图中, 我们可以将分支画成下面这样:

pic/programming-branching-fig.png

但是我们的代码肯定是一个线性的代码, 没有办法表现出像这样的丰富的网状结构. 但是一个非常简单的做法: 在程序中划出一些代码块, 如果条件成立, 就执行代码块, 否则就不执行代码块. 于是我们就有了 if 条件判断.

<if_branching> ::= "if (" <condition> ")" <code_block>
<condition> ::= <exp>
<code_block> ::= <line_exp> | "{" <line_exp>* "}"
<line_exp> ::= <exp> ";"
<exp> ::= /* 应该不用写很多了吧? */

(注: 代码块的形式上的定义: { <exp>; })

举个例子:

int x = 1;

if (x > 0)
  printf("x is greater than 0");

当然, 除了单纯的 if, 还能够写更多的形式, 如双分支: if (condition) {} else {}, 以及多条件分支: if (condition) {} else if {} else {}. 以及多条件分支选择: switch (exp) { case val: break; }. (不过可以看作是一种语法糖. )

循环

在计算机的运行中, 我们介绍过了如何让程序循环. 同样的, 在 C 语言里面, 也有循环的结构:

while (condition) {
}

你可以将其理解为:

.while_loop
	  cmp condition
	  je .out
;;; 	do something
	  jmp .while_loop

.out

一般来说, 我们的循环都会有一个朴实而无华的想法, 就是历遍. 比如让传感器编号从 0 到 9, 检测每个传感器上的温度:

int sensor_index = 0;

while (sensor_index < 10) {
  read_sensor_tempture_at_index(sensor_index);
  sensor_index++;
}

而这样的代码因为太过常见. 思考题: 请给出代码的一个形式结构. 并解释这样的形式结构为何能够用后面的 for 来简化书写. 解答会在 for 的语法结构解释完后给出.

于是人们便创造出了一个语法叫 for 来简化书写.

for (int sensor_index = 0; sensor_index < 10; sensor_index++) {
  read_sensor_tempture_at_index(sensor_index);
 }

我们可以用一个图来描述上面的过程:

pic/programming-loop-fig.png

前面思考题的解答

<initial_code>;

while (<final_condition>) {
  // do something
  <step_code>;
}

for (<initial_code>; <final_condition>; <step_code>) {
  // do something
}
小练习
  1. 写一个条件判断来进行对输入信号的筛选. 比如我们有这样的一个热敏电阻:
    • 在 $10 ∼ 30^ˆ C$ 的时候, 电压和温度的关系近似有线性关系: $u = u_{10} + λ_1 (t - t_{10})$;
    • 在 $30 ∼ 50^ˆ C$ 的时候, 电压和温度的关系近似有二次关系: $u = u_{20} + λ_2 (t - t_{20}) + μ_2 (t - t_{20})^2$
  2. 写一个简单的循环模型, 读取一个热敏电阻传感器阵列上每个温度传感器的数值, 然后打印出来.
  3. 请写一个 PID 控制, 使用简单的循环, 不必写得很规范.
    • 关于 PID, 我们可以简单这样理解: 其包含比例 (Proportional), 积分 (Integral) 以及微分 (Derivative) 组成, 在数学上的公式如下:

      $$u(t) = K_p e(t) + K_i ∫_0^t e(τ) \mathrm{d}τ + K_d \frac{\mathrm{d}}{\mathrm{d}t}e(t)$$

      当然, 光看数学公式没什么鸟用, 实际上其中的参数十分好理解.

    • $K_p e(t)$ 项:

      我们用一个简单的例子来理解: 烧菜, 咸了加水, 淡了加盐. 用 $e(t)$ 来量化表示我们的菜的咸度, 若 $e(t) > 0$, 那么就是咸了, 反之就是淡了. 我们要做的机器人通过一个控制信号 $u(t)$ 来决定是加盐 或者加水的量:

           void p_control(int expect_flavor) {
      	 int u;
      	 for (int e = taste_soup - expect_flavor; ; e = taste_soup - expect_flavor) {
      	   if (e > 0) {
      	     add_salt(k_p * e);
      	   } else {
      	     add_water(k_p * e);
      	   }
      	 }
           }
              

      但是一个简单的问题就是, 往往这样只会得到一个振荡的不衰减的信号. 因为它和我们的简谐振动方程冥冥中有那么一丝相像.

      $$u(t) = K_p e(t) ⇔ \boldsymbol{F} = - k Δ\boldsymbol{x}$$

    • $K_d$ 项:

      前文刚说了, PID 方程中的 P 项很像我们的简谐振动方程, 并且有一个信号变化幅度很难衰减, 也就是有会一直摆动的缺点. 那么一个自然的一个想法就是像阻尼振动的问题一样, 为其添加一个阻尼项.

      于是, 我们就得到了 $K_d \frac{\mathrm{d}}{\mathrm{d}t}e(t)$ 项.

      或者, 我们可以用一个更加玄学的感觉来说, $K_d$ 项就像是一种扼制未来发展趋势的项: 假如信号有增加的趋势, $e’(t) > 0$, 那么我们就要使用控制信号来将其减少.

      举一个例子, 在温度控制中, 如果我们开始加热, 看到 $e(t) = T_{expect} - T_{now}$ 逐渐减少, 但是 $e’(t)$ 却仍然很大, 就像是开车到了红灯的十字路口, 在衡量了一下相差的距离 $e(t)$ 和当前的速度 $e’(t)$, 也许很自然的结论就是, 我们应该要降低一下车速, 也就是 $K_d e’(t)$ 的作用.

    • $K_i$ 项:

      既然我们已经知道了 P 和 D 的作用, 那么 I 又是什么呢?

      简单, 假设我们现在有一个电压输入转输出信号线性转换器 (我也不知道这个是什么, 我们可以先假装有那么一个东西), 但是由于我们用的是一个奇怪的神奇二极管, 导致我们输出的电压始终和我们预期的电压有一个 $0.3V$ 的压降, 这个时候, 我们希望能够调节输入 $u$ 来使得输入和期望的差 $e$ 能够减小到零.

      但是很遗憾, 这个时候, 只靠 P 和 D 难以将这个偏差修改, 因为这就像是斜面上的阻尼振动, 我们的偏差值在平均中被 P 项忽略了, 而 D 项因为是变化项, 压根就不会理会常量偏差.

      所以, 通过加入对信号的时间积分, 我们可以去修正这个常量的偏差. 直观上来看, 除非 $\bar{e}$ 为零, 否则 I 项就会参与到控制信号中.

    • 那么, 请思考该如何用一个循环来简单地实现类似的算法呢? 假设我们已经知道了其中所有的参数.
    • 注: 对于上面的方程, 更好的处理方式是对其使用傅里叶变换, 或者 Laplace 变换, 然后就能够比较方便地计算.

函数, IO, 系统调用

函数 <<c-function>>

和数学的函数不一样的地方在于, C 语言中的函数更像是一种子过程 (sub-progress).

(注: 如果有兴趣的话, 可以参考一下 Lisp 中的一些思想. )

我们先从形式上来看函数的定义以及函数的调用:

函数的定义:

<define_function> ::= <return_type> " " <function_name> "(" <args> ")" <function_block>
<return_type> ::= <value_type>
<function_name> ::= <vaild_name>
<function_block> ::= "{" "}" /* code */
<args> ::= <value_type> " " <vaild_name>
<vaild_name> ::= ([a-z] | [A-Z]) ([a-z] | [A-Z] | [0-9] | "_" )*
<value_type> ::= "char" | "int" | "float" | "double"

函数的调用:

<function_call> ::= <function_name> "(" <function_args_value> ")"

那么拿一个例子来看即可:

int puls_one(int x) {
  return x + 1;
}

这里我们就定义了一个加一函数.

函数的执行过程:

  • 我们可以将函数看作是一个子过程 (subprogress), 之所以这么叫, 是因为和所谓的 “主” 过程 (main progress)相比, 子过程十分类似于主过程
  • 一个过程是 “局域” 的, 或者说, 这个过程是有极限的 (bushi). 这意味着这样的一个过程只能够掌握它周围一小部分区域的信息, 而如果想要了解更多, 得加钱, 就会跳出这个过程.

    于是我们就会面对许多的概念: 局部变量以及相对应的全局变量, 栈, 堆, 进程等等的概念.

    • 在之前, 我们已经了解过栈是如何存放变量的: 我们通过用相对位置的方式来记录相对不同的变量. 就像是我们用相对原点的坐标来记录不同的点一样.

      那么既然是相对位置, 我们还能够定义相对位置的相对位置: 这就像是物理里面的相对参考系. 于是应该不难这样构造: 我们认为, 子过程可以是相对主过程有一个位置, 然后对于过程来说, 自己所知道的变量都是相对自己为原点的.

    • 于是我们就知道了局部变量, 也就是相对自己的变量, 这些变量是自己所能够掌握的. 这就像是每个家庭里面都有的 “dad”, “mom” 变量, 但是这些变量对于不同的家庭并不一定具有相同的值.

      (注: 这个例子, 在之后 Ruby 的 OOP (面向对象编程) 里面, 我们应该会再一次提到, 不过需要注意区分这个例子使用的目的的不同. )

      而全局变量更像是一种经过约定之后, 大家都知道它在哪里的变量.

  • 参数传递

    那么既然我们已经知道了, 一个过程是 “局域” 的这个事实, 那么一个简单的想法就是, 一个局域的过程是如何看到外界告诉它的信息呢?

    这方法就是参数传递. 囿于篇幅有限, 以及能力有限, 这里我们只是给出一个可能的传递参数的方式: 通过栈来传递. 至于其他的传递参数的方式, 可以作为有兴趣的同学的拓展了解内容.

    譬如我们一开始的局部坐标都是 $O+index = var$, 但是为何不换一种思路, 让我们用负坐标来表示传入的参数, 如 $O-index = arg$, 于是我们就实现了简单的参数传递.

    一个简单的例子:

    arg 2 arg 1 bp var1 var2 sp

    ——-——-+—-+——+——+—-+

    -2 -1 0 1 2 3

    大概是这个样子. 当然, 实际绝对不是这样的就是了.

    那么很明显, 你就会发现, 如果我们写了一个递归函数:

       int sub_to_zero(int x) {
         if (x > 0) {
           return sub_to_zero(x - 1);
         } else {
    	return x;
         }
       }
        

    那么当我们传入一个很大的参数 x 的时候, 我们的栈中就会堆积一堆的看起来很没有用的值: x - 1, x - 2, … 这样是很愚蠢的.

    • TOC 不过现代的编译器会使用一种叫做尾递归的方式, 将这种显然可以用循环来简化的函数用循环的方式来编译, 大大提高了程序的效率.

      这种方法我们可能会在之后介绍. 目前我们可以暂时默认现代编译器已经为我们想了很多了.

    • 指针, 就像是全局变量一样, 我们可以使用指针来告诉被递归调用的子函数, 你并不需要得到传入参数的完整的拷贝, 你只需要知道在哪里找到这个值就好了.

      (就像是现代社会下, 不少老师会拿这个开刷学生: 你们不能说知道知识在哪里可以查到, 要背下来记在脑子里. 不过我持反对意见, 毕竟我连在哪里我都说不上来. )

      这样的指针尤其是在传递数组, 结构的时候尤为方便, 因为于其将一整个数组完全拷贝到栈上作为参数传递, 肯定是直接传递指针来得更加方便.

      并且传递指针, 而不是拷贝的值备份, 还能够满足我们想要修改栈上的数据的愿望.

  • 去哪里执行代码啊?

    我们已经了解了这么多, 但是还有一个比较麻烦的小问题, 既然我们的程序是读取一个指针所指向的命令, 那么在跳转到一个函数的子过程的时候, 我们该怎么跳转呢? 总不能每次都将同一个函数放在下一条命令吧? 那样也太蠢了.

    实际上答案就在上面的那段话里面了.

    提示: 跳转.

  • 那么知道这些有什么用呢?

    我们可以根据这样简单的东西来写一个非常简单的小程序, 来模拟单核处理器是如何实现多进程的. 见小练习:

小练习 - 虚假的多进程
  1. 假设我们已经有了一个神奇的函数 compile, 能够将我们输入的代码变成一个神奇的指针 p, 指向我们代码的单步指令:
    command *compile(char *code) {
      // break the code into simple step code
      return pointer_to_simple_code;
    }
        

    以及一个简单的指令 eval, 能够执行 p 指向位置的简单命令:

    void eval(command *p) {
      execute_command_at(p);
    }
        

    如果你有兴趣的话, 可以先实现 eval 函数, 假设我们的 simple_code 的格式就是 asm code:

    <asm_code> ::= <cmd> <args>
        
  2. 我们打算有一个栈结构: (通过链表来实现)
    struct stack {
      int val;
      struct stack *previous;
    };
        

    请在阅读 链接 后思考该如何实现一个 push 和一个 pop 的操作:

    int push(int val, struct stack *s) {
      struct stack *next = (struct stack *)malloc(sizeof(struct stack));
    
      next->val = val;
      next->previous = s;
    
      s = next;
    
      return val;
    }
    
    int pop(struct stack *s) {
      int val = s->val;
      
      s = s->previous;
    
      return val;
    }
        

    (注: 代码不一定正确, 并且请了解如何使用 typeof 来让上面的代码变得更美观. )

    假设我们已经拥有了一个完美的栈 stack, 而不是上面的代码.

  3. 我们现在维护一个数组, 作为进程池, 其中每个元素都是一个指向我们子进程的一个运行指针.

    那么思考该如何让他们同时, 或者说, 看起来同时进行? 提示: 周游历遍.

  4. 请在上面的提示的基础上, 给一个最粗略的多线程的例子. 并且了解程序的 interrupt. 给出上面的多线程的例子有什么不足之处, 以及可以如何改进. (optional)

程序的基本结构

我们以一个简单的程序为例:

<c_code> ::= <preprocess>*
	       <function_declaration>*
	       <main_function>
	       <function_definition>*

Preprocess

也就是预处理, 在这一部分, 我们会遇到引用头文件, 宏定义, 预编译等等的知识.

在 C 语言里, 宏虽然是叫 marco, 实际上和 Lisp 的 marco 系统比起来, 更像是一种字符串替换的系统.

  • define
    #define TRUE 1
    #define FALSE 0
    
    #define MAX(a, b) (a > b ? a : b)
        

    实际上就是在编译的时候, 提前替换程序中所有和定义的字符串, 或者定义的形式相同的文本, 然后假装啥也没发生. 比如下面的代码,

    if (MAX(x, y) > 3) {
      return TRUE;
    } else {
      return FALSE;
    }
        

    经过替换后会变成:

    if ((x > y ? x : y) > 3) {
      return 1;
    } else {
      return 0;
    }
        

    之所以会这样做, 是为了减少我们在代码中使用不必要的魔法数字, 比如后者的 10 就会让人不知道他们的含义. 甚至如果你定义 TRUE666, FALSE999, 那么如果只看数字值的话, 你的代码就是一个很好的混淆的代码了.

  • if 判断

    在预编译的时候, 你可能还会接触到宏条件判断, 比如如果没有定义宏 HELLO, 那么定义宏 HELLO, 就可以用来防止重复定义:

    #ifndef HELLO
    #define HELLO
    #endif
        

    类似的就不多展开了.

  • include

    我们可能还想要在代码中引用其他已经写好了的库, 于是通过 #include <lib_name> 即可引用相应的库. 比如标准输入和输出的库:

    #include <stdio.h>
    
    int main() {
      printf("Hello\n");
    
      return 0;
    }
        

Main Function and Function Definition

一般而言, 一个 C 程序以 main 函数作为程序入口. 正如我们之前所介绍的子程序的例子一样. 我们可以将一个系统看作是一个大的主程序, 那么想要运行一个程序的时候, 就会将我们的程序先载入到内存中, 就像是古早的游戏机插上了游戏卡带 (实际并不是) 一样.

然后系统就会像是对待一个子程序一样对待我们的程序: 为其开辟栈来存放变量, 为其提供函数调用, 等等.

(Note: 因为编译器是线性读取代码的, 所以在处理函数跳转的位置的时候, 如果你的代码定义是在调用后的话, 编译器会不知道你在说什么, 或者说, 它不知道自己要跳转到哪里. 所以你会经常看见 C 代码里面会有 将定义写在前面的, 或者只写一句话定义的形式. )

#include <stdio.h>

void foo();

int main() {
  foo();
}

void foo() {
  puts("foo");
}

Final

其实还有很多需要学, 不过目前只是提供了一个简单的形式框架. 可以参考:

项目一: 点亮灯

使用的平台: WOKWI

学习内容:

  • 简单的 C 语法
  • 了解函数体的形式, 以及会使用变量
  • 了解基本的宏定义

工程文件见 classes/winter-class-01/Blink 文件夹. 将工程文件以次复制到平台上即可.

观察:

  1. 请先阅读代码并猜测其作用
    • 函数中: setup, loop, pinMode, digitalWrite, delay 的作用
    • 其中的值 LED, 9, 1000 对应什么
  2. 请修改对应的值, 观测变化并验证你的猜测.

:

  1. 添加一个 count 变量, 定义其为全局变量.
    • 已知定义的语句如下: int count;,
    • 已知其初始化的值应为 count = 0,
    • 已知每次点亮灯都要对其进行增加操作 count++.

    请修改代码满足要求.

    (拓展: 这样的代码会有什么问题? 或者说是否有问题? )

  2. 添加一个 if 判断语句, 使得 count 为 $\mathbb{Z}/m$ 中元素. 其中 $m$ 为宏定义.
    • 已知 if 语句如下: if (<condition>) { <code> }.
    • 已知宏定义语句如下: #define M <val>.

    请修改代码满足要求

  3. 在原有的代码基础上, 改变闪烁周期. 在一个闪烁序列中循环. 比如 $\bar{0}$ 时, delay(1000); 在 $\bar{1}$ 时, delay(1100) 等等.

作业: (代码见 classes/winter-class-01/Simple Digital Number 文件夹)

  • (课堂演示部分) 打开平台, 新建一个项目, 添加一个 数字显示管.
  • (课后编写) 让数字显示管能够显示不同的数字, 并间隔 1 秒轮换播放.
  • 思考:
    • 如何让代码变短?
    • 如何控制更多的灯?

第二节课

  • 学会查找库以及直接抄代码
  • 学会写一些简单的程序

项目二: 调用库以及通讯

使用的平台: WOKWI

学习的内容:

  • 学会去找库并调用库
  • 学会看库的说明文档
  • 学会简单的串口通信

工程文件见 官方示例文件.

观察:

  • 代码中的 #include <LiquidCrystal.h> 做了什么. 这个 干了什么, 有什么可以做的.
  • 该怎么找到这样的库. 如果我们去买零件的话.

    (注: 实际上一般来说, 如果是正经零件的话, 一般是不需要担心很多的东西的, 因为购买的时候会提供文档. 如果你是直接捡到了一个零件, 那么可以先找到其对应的代码, 比如 LCD 1602 即这个零件的型号代码. 然后在网络上搜索: “LCD 1602 Libraries”, 或者 “LCD 1602 Datasheet” 等等. 并且因为我们的东西并不是很硬核, 实际上大多数时候, 你只需要搜索 “LCD 1602 Examples” 之类的大部分时候就可以了. )

  • 尝试修改代码, 改变输出的内容. 在文档中找一个函数并试试看效果. (比如 createChar 函数)

:

  • 现在我们想要实现这样的功能, 能够向 Arduino 输入信息, 并让其显示在屏幕 (LCD 1602) 上.
    • 已知 Arduino 等单片机通过 串口 和其他计算机进行通信. (官方文档, 或者 LCD 1602) 其中, 你可能需要了解:
      • 波特率, 你可以理解为打游戏的帧数, 不过这个帧数需要稳定. 一般常见的波特率有 300, 1200, 2400, 9600, 19200, 38400, 115200 等.
      • TX, RX, 你可以理解为两根水管, 一根进一根出. 通过这两条, 得以和其他设备进行沟通. (现实中的下载程序也就是使用这样的两条)
      • 在 Arduino 中, 你可以通过这样来初始化一个串口:
        	#define BAUD 9600;
        
        	void setup() {
        	  Serial.begin(BAUD);
        	}
                    
      • 通过 Serial.printlnSerial.read 来做到简单的输出和输入.

        (注: 这样的方式好么? )

作业:

  • (选做) 写一个简单的四则运算的计算器

第三节课

  • 本节课类似于阶段性考核, 需要上传代码 (或者运行的截图或者视频或者 gif 都行)
  • 本节课不会涉及过多的新知识, 所以可以放心跷课

项目三: 给上节课的计算器加一个漂亮的按键板

  • 该项目为本节课的主要内容

使用的平台: WOKWI

要求:

  • 加一个 Keypad 来通过按键来进行输入
  • 实现浮点数运算
  • 美化输出形式
  • 美化代码
  • (可选) 实现分数计算而不是浮点数运算

了解的内容:

  • 代码重构
  • 项目的组织方法
    • MVC
      • Model - 数据

        如何组织存储我们的数据

      • View - 视图, 显示, 交互

        UI 界面

      • Control - 控制

        一些控制代码

项目四: 做一个数据收集器

  • 该项目的数据主要为瞎掰, 如果有真实的单片机的话可以尝试自行搭建
  • 该项目即为大部分硬件组的主要的核心

使用的平台: WOKWI, 实体环境 (更加推荐)

要求:

  • 能够接受各种传感器的数据输入
  • 通过 Serial 将数据格式化输出
  • (可选) 在本地用 Python 或者其他程序读取信息并储存到 CSV 中

项目五: 做一个服务器

  • 该项目主要通过分解去年的项目代码为主
  • 如果有兴趣的话可以重新写

使用的平台: WOKWI, 实体环境 (更加推荐)

要求:

  • 能够显示一个简单的小网页 (不用美观也可以)

    可以考虑去前端组蹭蹭课

  • 然后通过一个网页控件来控制

项目六: (可选)

  • 如果有时间的话, 随便写点代码吧

文献调研

阅读 iGEM Best Hardware 的项目, 了解:

  • 干了什么, 主要用了什么样的技术和方法 (?)
  • 有什么特别的亮点
马建仓 AI 助手
尝试更多
代码解读
代码找茬
代码优化
1
https://gitee.com/igem_ucas_china/2023-i-gem-hardware-new-member-tutorial.git
git@gitee.com:igem_ucas_china/2023-i-gem-hardware-new-member-tutorial.git
igem_ucas_china
2023-i-gem-hardware-new-member-tutorial
2023 iGEM Hardware New Member Tutorial
master

搜索帮助

0d507c66 1850385 C8b1a773 1850385