主要是复习和复现:
看项目是否确定了要做什么:
可以参考的文章: 用 Ruby 实现飞机自动驾驶仪.
我们会从最简单的控制部分讲起, 并且不会涉及非常多的专业的知识,
保证以实用和简单为主. 毕竟难的也不会.
注: 这个教学的例子为了方便快速准备, 我用的是我比较熟练的 Ruby. 不过基本上不会特别利用 Ruby 中的高级操作, 所以和伪代码差不多. 如果课上有要求换语言的话, 我到时候可以重新写.
我们会用到的例子如下: (我会尽量去找一些和硬件组有关的例子,
不过受限于我目前没法搞到实物, 所以大部分的例子都是代码模拟运行的.
所有的示例代码都在 classes/winter-class-04/
中.)
现在我们想要控制一个容器内的温度保持在一个范围附近,
比如说保持在 37.5
附近.
该例子中的模型 Bottle
在 classes/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 闭环控制后, 这个名字就非常显然了. )
实际上这个控制包含了两个部分:
trigger
部分是 bottle.temp < 37.5
trigger
部分是 bottle.temp > 37.5
但是可以从上面的温度变化图中看到, 上面的控制系统会导致要控制的温度会在一个范围进行波动.
尽管我们可以通过增加容器的热容量 heat_capacity
,
或者减少加热器运转的功率 watt
来减少最终稳定的温度变化.
但是这样会导致受外界变化较大且预热时间较长.
那么一个非常朴素的想法就是: 在要加热的时候选择更高的功率, 而在需要保温的时候, 则选择较低的功率. 就像是在烧水的时候, 在水沸腾前选择大火, 而在水沸腾后则只需要保持小火即可.
于是为了完成这样的一个控制回路, 我们需要在控制回路中加入一个简单的外界读取的判断:
于是一个更新的控制代码如下:
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
于是发现这样的控制更加稳定. 这样的控制方式叫做闭环控制, 即将输出和输入联合在一起. (当然没有必要将这个分类分得很细致, 大概知道一个概念即可. 毕竟我们不是专业的, 要会用即可).
上面的闭环控制我们实现的是一种比例项的修正. 即控制信号 $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
为了方便观察, 我将稳定的温度设定在 40
, 这个时候, 可以看到,
加入了积分项的控制程序可以在短暂振荡后稳定在我们设定好的控制温度上.
尽管现在可能看起来稳定的时间过长了, 但是我们可以通过设置合适的积分参数,
来加速稳定所需的时间.
可以看到, 一个较小的积分项因子可以较快的收敛. 但是过小的积分项因子则会导致收敛到设定值的速度变慢.
所以调参还真是一门学问啊…
可以将前面的所有的东西进行一个抽象封装成一个 PIDController
类来进行操作.
(注: 该部分为 OOP 内容, 可以了解一下面向对象编程的概念,
但是不了解不会对代码产生太多的误解. )
全部代码请参考 ./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
总结:
那么和外界微扰相对应的则是用户的期望的改变,
关于如何和用户进行交互的内容会在下一个项目中介绍,
现在我们假设我们的用户都是会编程的用户,
并且在系统运转的过程中修改了 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)
基本上, 目前这个模型属于是能用的水平了. 也许可以更好, 但是受限于时间有限, 我没时间继续手工调整参数了.
注: 关于控制部分的其他介绍并不会继续下去了. 如果有需要的话, 可以参考 【转载】那些年的神贴——自动控制的故事 一文.
在前一个项目中, 我们仅仅实现了一个简单的 PID 控制程序. 然而, 我们也会发现, 这个调参的过程十分的艰难和麻烦. 尽管我们可以从直观的感觉中感受到 PID 各个参数之间大概有什么作用:
不难得到一个没有用的结论: 我们要的参数不能太大也不能太小. 但是如果要精确地为一个系统给出很好的控制参数, 或者是想要给一个系统的调参方式, 总觉得有些困难.
于是一个自然的想法就是如何自动化调参. 或者至少, 来一个实用的调参方法. 一些可以参考的例子:
(根据上课的情况选择是否展开)
一个简单而暴力的作法就是在找参数的时候一遍遍模拟, 根据模拟的结果写一个打分函数, 然后根据打分函数的结果去查找最优的参数组合.
这样的方式是当我们对于这个系统一无所知的情况下可以采用的, 另一个可行的方式就是通过分析系统的具体特征来实现.
首先, 我们需要了解一个非常现实的事实, iGEM 硬件组应该需要解决的是一个工程问题. 理想的情况是当我们完成了一个课题之后, 我们能把它直接拿出去卖钱.
所以我们的目标是让用户能够拿到我们的硬件就可以直接上手无障碍使用, 而不是像看外星科技一样蒙逼.
毕竟没有人想要学完交换群之后才知道 $1 + 2 = 2 + 1$ 的. (如果是法国小学生的话当我没说)
而同时, 我们也需要让我们的机器容易被调试, 能够满足尽可能多的功能来支撑实验组或者建模组的需求. 即尽管我们的机器可以简单到是数兔子 $1 + 1$ 的程度, 同时也要满足能进行加法群这样晦涩的高级操作.
于是如何设置一个用户交互的界面, 让普通用户能够简单地操作; 同时设置一个后端的控制界面, 让我们制作者可以轻松地维护和处理. 这就是当前的问题.
以苹果公司的 iMovie 影片编辑软件为例, 如果我们想要裁剪一段视频,
比如说从 01:30
开始裁剪到 02:30
, 那么在 iMovie 中, 只需要简单的拖动鼠标,
或者是简单的点点即可.
而在终端环境下, 以 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 |
常见的操作界面的实现手段:
# 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
既然是最后一节课, 那么我们不妨随意一些. 来开开眼.
一般来说, 做特定的事情需要特定的语言, 因为对应的语言有相应的优势. 尽管大部分情况下这样的优势是它们历史更加悠久, 对应的库更加的多, 用的人比较多且网上买它们课的方便赚钱.
当然, 特定的类型的语言可能会有特定的相应风格. 一个比较有意思的 例子:
尽管我们并没有必要去学习所有的语言, 但是尽可能多地去了解一个语言的风格, 对于我们编程还是很有帮助的. (坏, 怎么变成软件组了) 不过鉴于我们之前想要实现生物编译器的一个想法, 即我们不仅仅是一个语言的使用者, 更应该是一个语言的设计者.
在年前我们介绍的形式化语言中, 实际上我们介绍的是一个语言的形式部分. 比如可以通过下面这样简单的形式语言 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 年培训中的:
(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) 以及最优的分析模拟算法等等, 经过处理后, 然后就会返回一堆可能的组合结果, 选择其中一个组合结果, 可以自动给出合成的路径和方法, 最后甚至可以自动根据合成路径和方法, 自动化调用硬件设施来合成对应的细菌.
或者是下面这样的看起来稍微合理一些的代码:
(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)))
当然, 这样的白日梦实在是有点过于宏伟了, (并且这是否能够计算也是一个问题), 但是这只是提供一个非常夸张的愿景. 现实中, 我们更加常见的可能是更加平凡的场景.
是不是感觉在哪里看到过类似上面的图? (没错, 就是第一个项目中, 和我们在介绍开环控制的时候的示意图极其类似. 一个启动子就像是控制中的开环控制. )
确实, 上面的确实就像是一种 “如果… 就…” 的开环控制模型. 显然, 我们可以将之前学到的关于控制方面的知识应用到合成生物学中.
当然, 也不能忘了我们还是硬件组, 并不是只有编译器才是出路. 我们还需要通过构建和现实相关连的组件来与现实进行联系. 满足操作现实, 读取现实数据的需求.
如果用读和写来类比, 那么传感器部分就像是读, 控制部分就像是写. 同时拥有这两种能力可以让项目变得更加强壮.
当然, 我们控制的方法并不是只有 PID 一种 (它只是众多控制方法中的一种, 并且比较常用而已). 写的方式也并不是只有简单的传感器. 一种比较有趣且高级的方式: 计算机视觉 (机器视觉).
尽管计算机视觉听起来非常高大上, 但是你也可以简单认为它就是数字图像处理, 然后再简单一些就是对输入的数据的分类和识别.
我们不妨用一个比较简单的例子来引入:
现在我们有一个红外线测温元件 (IR Temperture Sensor) 来读取温度:
假设我们用一个简单的程序来实现了读取温度的功能:
// 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
那么假设我们现在通过电脑端捕获 Serial
读到的数据并将其储存在一个数组中.
于是我们就得到了一个随时间变化的一个数据信号, 然后可以将其绘图输出,
或者交给建模组用于数据拟合之类的.
而如果我们想要同时测量多个不同容器的数据并同时记录, 那么一个普通的想法就是: 多买点温度传感器, 比如有一个容器矩阵, 我们便可以整一堆的温度传感器, 对每一个都进行编号处理:
那么我们读到的数据就是一个二维数组. 为了让有限的引脚能够控制这么多的传感器, 可以通过矩阵逐行扫描的方式来读取数据. 而如何能够在有限的空间里面塞满这么多传感器, 又变成了另外一个麻烦的事情. 并且这样二维堆料堆采样点的操作, 简直就像是丧心病狂的手机相机厂商…
没错, 只要引入一个红外相机即可解决我们的问题了. 譬如说我们的红外摄像机固定在摇床上方. 那么对于拍摄到的热成像图片, 只需要将其按照对应的网格区域进行裁切, 并对对应网格内的信号转换为平均温度. 于是我们就完成了多测温的一个比较理想的装置了.
(注: 尽管这里还有一些其他的问题, 比如如何保证温度不会受到其他网格的影响? 是通过温度梯度这样的方式来实现么? 还是如何实现? 以及引入这样的测温装置有什么好处? 是否能够有一些特别强大的理由来说服人们来采用这个方案?
譬如说我们可以在平板基底下铺设电加热丝和热敏电阻. 在其上滴加液体以实现一个加热和温度控制平台. 并且可以参考 MIT Programmable Drops 的这个项目, 来做到一个微型的自动化培养平台.)
注: 关于热成像, 有一个比较完整的 例子.
当然, 不要被 “数字图像处理” 中的 图像 一词给忽悠了, 实际上数字图像处理的方法并不仅仅局限于用相机拍摄到的图像, 甚至我们一开始的传感器矩阵也是一个可行的 图像 的例子.
既然谈到了输入的图像处理的技术, 我们可以用 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 的好处就是隐藏了具体的算法细节, 让对图像处理的方法可以快速地被调用. 我们需要做的只是简单地调用方法即可.
现在是幻想时刻:
我们可以观察一下往年的硬件组做的东西和往年的优秀 iGEM 硬件组做的东西.
2022 Sheffield Bioinformatics Toolbox 其中还有一个做的类似一个简单的编译器, 将一些变换用的小工具来整合在一起:
那么总结起来就是, 我们可以在今年的项目实现的时候努力的方向:
感觉也没有讲很多的东西. 还是希望大家自己多写代码多去练吧.
通过复现上一次的工程机器来教学.
通过复现去年的控制部分代码.
Serial
和波特率然后画出或者写出对代码核心的控制流程图 (loop
函数, 比如)
loop
函数中对温度控制的代码. (可以在控制理论讲完之后重写)该部分推荐的参考书为 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
来编译. )
我们可以用 <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
则是一个变量的坐标. )
我们可以通过像数学一样来写简单的表达式:
比如:
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 语言中有不同的值的类型. (其实为什么会有不同的值的类型的原因非常简单, 工程上来说, 是因为不同的值在计算机中的储存和表示方式不同; 或者也可以数学一些来说, 就是其表示的域的不同, 对于考虑的问题的不同, 选择不同的精度和值的类型. )
(注: 这里是工程, 所以是会有误差的. 所以在编程语言中的值, 大多数是数值近似值, 而不是一个解析的准确值. )
狄拉克: 工程学对我最大的启发就是工程学能够容忍误差.
from 赵亚溥 力学
可以参考 维基百科 中对 C Data Type 的说明, 我们可以发现, 在 C 语言中有四种主要的类型:
在这四种类型之上, 可以通过添加修饰符来定义值的更加细节的表现:
不过实际上, 基本目前我们可能就只会使用非常少的几种类型. 大家只需要了解如何写就好了. (意思就是说, 更加细节的部分可以自学, 或者可以在 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
就是再说, 我们要去 index
为 point_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>
来定义一个结构体变量.
pi
, 其值为 3.14159265354
, 思考应该用什么类型label
t
采集的温度 temp
, 流量 q
, 成品率 present
等的数据其实在计算机的运行中我们已经接触过了控制流了.
其实基本的思想很简单, 就是如何让本来应该是线性的程序进行分支和循环.
在流程图中, 我们可以将分支画成下面这样:
但是我们的代码肯定是一个线性的代码, 没有办法表现出像这样的丰富的网状结构.
但是一个非常简单的做法: 在程序中划出一些代码块, 如果条件成立,
就执行代码块, 否则就不执行代码块. 于是我们就有了 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);
}
我们可以用一个图来描述上面的过程:
前面思考题的解答
<initial_code>;
while (<final_condition>) {
// do something
<step_code>;
}
for (<initial_code>; <final_condition>; <step_code>) {
// do something
}
$$u(t) = K_p e(t) + K_i ∫_0^t e(τ) \mathrm{d}τ + K_d \frac{\mathrm{d}}{\mathrm{d}t}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}$$
前文刚说了, 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)$ 的作用.
既然我们已经知道了 P 和 D 的作用, 那么 I 又是什么呢?
简单, 假设我们现在有一个电压输入转输出信号线性转换器 (我也不知道这个是什么, 我们可以先假装有那么一个东西), 但是由于我们用的是一个奇怪的神奇二极管, 导致我们输出的电压始终和我们预期的电压有一个 $0.3V$ 的压降, 这个时候, 我们希望能够调节输入 $u$ 来使得输入和期望的差 $e$ 能够减小到零.
但是很遗憾, 这个时候, 只靠 P 和 D 难以将这个偏差修改, 因为这就像是斜面上的阻尼振动, 我们的偏差值在平均中被 P 项忽略了, 而 D 项因为是变化项, 压根就不会理会常量偏差.
所以, 通过加入对信号的时间积分, 我们可以去修正这个常量的偏差. 直观上来看, 除非 $\bar{e}$ 为零, 否则 I 项就会参与到控制信号中.
和数学的函数不一样的地方在于, 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;
}
这里我们就定义了一个加一函数.
函数的执行过程:
于是我们就会面对许多的概念: 局部变量以及相对应的全局变量, 栈, 堆, 进程等等的概念.
在之前, 我们已经了解过栈是如何存放变量的: 我们通过用相对位置的方式来记录相对不同的变量. 就像是我们用相对原点的坐标来记录不同的点一样.
那么既然是相对位置, 我们还能够定义相对位置的相对位置: 这就像是物理里面的相对参考系. 于是应该不难这样构造: 我们认为, 子过程可以是相对主过程有一个位置, 然后对于过程来说, 自己所知道的变量都是相对自己为原点的.
(注: 这个例子, 在之后 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
, … 这样是很愚蠢的.
这种方法我们可能会在之后介绍. 目前我们可以暂时默认现代编译器已经为我们想了很多了.
(就像是现代社会下, 不少老师会拿这个开刷学生: 你们不能说知道知识在哪里可以查到, 要背下来记在脑子里. 不过我持反对意见, 毕竟我连在哪里我都说不上来. )
这样的指针尤其是在传递数组, 结构的时候尤为方便, 因为于其将一整个数组完全拷贝到栈上作为参数传递, 肯定是直接传递指针来得更加方便.
并且传递指针, 而不是拷贝的值备份, 还能够满足我们想要修改栈上的数据的愿望.
我们已经了解了这么多, 但是还有一个比较麻烦的小问题, 既然我们的程序是读取一个指针所指向的命令, 那么在跳转到一个函数的子过程的时候, 我们该怎么跳转呢? 总不能每次都将同一个函数放在下一条命令吧? 那样也太蠢了.
实际上答案就在上面的那段话里面了.
提示: 跳转.
我们可以根据这样简单的东西来写一个非常简单的小程序, 来模拟单核处理器是如何实现多进程的. 见小练习:
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>
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
, 而不是上面的代码.
那么思考该如何让他们同时, 或者说, 看起来同时进行? 提示: 周游历遍.
我们以一个简单的程序为例:
<c_code> ::= <preprocess>*
<function_declaration>*
<main_function>
<function_definition>*
也就是预处理, 在这一部分, 我们会遇到引用头文件, 宏定义, 预编译等等的知识.
在 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;
}
之所以会这样做, 是为了减少我们在代码中使用不必要的魔法数字,
比如后者的 1
和 0
就会让人不知道他们的含义. 甚至如果你定义
TRUE
为 666
, FALSE
为 999
, 那么如果只看数字值的话,
你的代码就是一个很好的混淆的代码了.
if
判断
在预编译的时候, 你可能还会接触到宏条件判断,
比如如果没有定义宏 HELLO
, 那么定义宏 HELLO
,
就可以用来防止重复定义:
#ifndef HELLO
#define HELLO
#endif
类似的就不多展开了.
include
我们可能还想要在代码中引用其他已经写好了的库,
于是通过 #include <lib_name>
即可引用相应的库.
比如标准输入和输出的库:
#include <stdio.h>
int main() {
printf("Hello\n");
return 0;
}
一般而言, 一个 C 程序以 main
函数作为程序入口.
正如我们之前所介绍的子程序的例子一样.
我们可以将一个系统看作是一个大的主程序, 那么想要运行一个程序的时候,
就会将我们的程序先载入到内存中,
就像是古早的游戏机插上了游戏卡带 (实际并不是) 一样.
然后系统就会像是对待一个子程序一样对待我们的程序: 为其开辟栈来存放变量, 为其提供函数调用, 等等.
(Note: 因为编译器是线性读取代码的, 所以在处理函数跳转的位置的时候, 如果你的代码定义是在调用后的话, 编译器会不知道你在说什么, 或者说, 它不知道自己要跳转到哪里. 所以你会经常看见 C 代码里面会有 将定义写在前面的, 或者只写一句话定义的形式. )
#include <stdio.h>
void foo();
int main() {
foo();
}
void foo() {
puts("foo");
}
其实还有很多需要学, 不过目前只是提供了一个简单的形式框架. 可以参考:
使用的平台: WOKWI
学习内容:
工程文件见 classes/winter-class-01/Blink
文件夹.
将工程文件以次复制到平台上即可.
观察:
setup
, loop
,
pinMode
, digitalWrite
, delay
的作用LED
, 9
, 1000
对应什么例:
count
变量, 定义其为全局变量.
int count;
,count = 0
,count++
.请修改代码满足要求.
(拓展: 这样的代码会有什么问题? 或者说是否有问题? )
if
判断语句, 使得 count
为 $\mathbb{Z}/m$ 中元素.
其中 $m$ 为宏定义.
if
语句如下:
if (<condition>) { <code> }
.#define M <val>
.请修改代码满足要求
delay(1000)
;
在 $\bar{1}$ 时, delay(1100)
等等.作业:
(代码见 classes/winter-class-01/Simple Digital Number
文件夹)
使用的平台: WOKWI
学习的内容:
工程文件见 官方示例文件.
观察:
#include <LiquidCrystal.h>
做了什么.
这个 库 干了什么, 有什么可以做的.(注: 实际上一般来说, 如果是正经零件的话, 一般是不需要担心很多的东西的, 因为购买的时候会提供文档. 如果你是直接捡到了一个零件, 那么可以先找到其对应的代码, 比如 LCD 1602 即这个零件的型号代码. 然后在网络上搜索: “LCD 1602 Libraries”, 或者 “LCD 1602 Datasheet” 等等. 并且因为我们的东西并不是很硬核, 实际上大多数时候, 你只需要搜索 “LCD 1602 Examples” 之类的大部分时候就可以了. )
createChar
函数)例:
#define BAUD 9600;
void setup() {
Serial.begin(BAUD);
}
Serial.println
和 Serial.read
来做到简单的输出和输入.
(注: 这样的方式好么? )
作业:
使用的平台: WOKWI
要求:
了解的内容:
如何组织存储我们的数据
UI 界面
一些控制代码
使用的平台: WOKWI, 实体环境 (更加推荐)
要求:
Serial
将数据格式化输出使用的平台: WOKWI, 实体环境 (更加推荐)
要求:
可以考虑去前端组蹭蹭课
阅读 iGEM Best Hardware 的项目, 了解:
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。